Spaces:
Running
Running
| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| import ZRText from 'zrender/src/graphic/Text'; | |
| import { LabelLayoutOption } from '../util/types'; | |
| import { BoundingRect, OrientedBoundingRect, Polyline } from '../util/graphic'; | |
| import type Element from 'zrender/src/Element'; | |
| interface LabelLayoutListPrepareInput { | |
| label: ZRText | |
| labelLine?: Polyline | |
| computedLayoutOption?: LabelLayoutOption | |
| priority: number | |
| defaultAttr: { | |
| ignore: boolean | |
| labelGuideIgnore?: boolean | |
| } | |
| } | |
| export interface LabelLayoutInfo { | |
| label: ZRText | |
| labelLine: Polyline | |
| priority: number | |
| rect: BoundingRect // Global rect | |
| localRect: BoundingRect | |
| obb?: OrientedBoundingRect // Only available when axisAligned is true | |
| axisAligned: boolean | |
| layoutOption: LabelLayoutOption | |
| defaultAttr: { | |
| ignore: boolean | |
| labelGuideIgnore?: boolean | |
| } | |
| transform: number[] | |
| } | |
| export function prepareLayoutList(input: LabelLayoutListPrepareInput[]): LabelLayoutInfo[] { | |
| const list: LabelLayoutInfo[] = []; | |
| for (let i = 0; i < input.length; i++) { | |
| const rawItem = input[i]; | |
| if (rawItem.defaultAttr.ignore) { | |
| continue; | |
| } | |
| const label = rawItem.label; | |
| const transform = label.getComputedTransform(); | |
| // NOTE: Get bounding rect after getComputedTransform, or label may not been updated by the host el. | |
| const localRect = label.getBoundingRect(); | |
| const isAxisAligned = !transform || (transform[1] < 1e-5 && transform[2] < 1e-5); | |
| const minMargin = label.style.margin || 0; | |
| const globalRect = localRect.clone(); | |
| globalRect.applyTransform(transform); | |
| globalRect.x -= minMargin / 2; | |
| globalRect.y -= minMargin / 2; | |
| globalRect.width += minMargin; | |
| globalRect.height += minMargin; | |
| const obb = isAxisAligned ? new OrientedBoundingRect(localRect, transform) : null; | |
| list.push({ | |
| label, | |
| labelLine: rawItem.labelLine, | |
| rect: globalRect, | |
| localRect, | |
| obb, | |
| priority: rawItem.priority, | |
| defaultAttr: rawItem.defaultAttr, | |
| layoutOption: rawItem.computedLayoutOption, | |
| axisAligned: isAxisAligned, | |
| transform | |
| }); | |
| } | |
| return list; | |
| } | |
| function shiftLayout( | |
| list: Pick<LabelLayoutInfo, 'rect' | 'label'>[], | |
| xyDim: 'x' | 'y', | |
| sizeDim: 'width' | 'height', | |
| minBound: number, | |
| maxBound: number, | |
| balanceShift: boolean | |
| ) { | |
| const len = list.length; | |
| if (len < 2) { | |
| return; | |
| } | |
| list.sort(function (a, b) { | |
| return a.rect[xyDim] - b.rect[xyDim]; | |
| }); | |
| let lastPos = 0; | |
| let delta; | |
| let adjusted = false; | |
| const shifts = []; | |
| let totalShifts = 0; | |
| for (let i = 0; i < len; i++) { | |
| const item = list[i]; | |
| const rect = item.rect; | |
| delta = rect[xyDim] - lastPos; | |
| if (delta < 0) { | |
| // shiftForward(i, len, -delta); | |
| rect[xyDim] -= delta; | |
| item.label[xyDim] -= delta; | |
| adjusted = true; | |
| } | |
| const shift = Math.max(-delta, 0); | |
| shifts.push(shift); | |
| totalShifts += shift; | |
| lastPos = rect[xyDim] + rect[sizeDim]; | |
| } | |
| if (totalShifts > 0 && balanceShift) { | |
| // Shift back to make the distribution more equally. | |
| shiftList(-totalShifts / len, 0, len); | |
| } | |
| // TODO bleedMargin? | |
| const first = list[0]; | |
| const last = list[len - 1]; | |
| let minGap: number; | |
| let maxGap: number; | |
| updateMinMaxGap(); | |
| // If ends exceed two bounds, squeeze at most 80%, then take the gap of two bounds. | |
| minGap < 0 && squeezeGaps(-minGap, 0.8); | |
| maxGap < 0 && squeezeGaps(maxGap, 0.8); | |
| updateMinMaxGap(); | |
| takeBoundsGap(minGap, maxGap, 1); | |
| takeBoundsGap(maxGap, minGap, -1); | |
| // Handle bailout when there is not enough space. | |
| updateMinMaxGap(); | |
| if (minGap < 0) { | |
| squeezeWhenBailout(-minGap); | |
| } | |
| if (maxGap < 0) { | |
| squeezeWhenBailout(maxGap); | |
| } | |
| function updateMinMaxGap() { | |
| minGap = first.rect[xyDim] - minBound; | |
| maxGap = maxBound - last.rect[xyDim] - last.rect[sizeDim]; | |
| } | |
| function takeBoundsGap(gapThisBound: number, gapOtherBound: number, moveDir: 1 | -1) { | |
| if (gapThisBound < 0) { | |
| // Move from other gap if can. | |
| const moveFromMaxGap = Math.min(gapOtherBound, -gapThisBound); | |
| if (moveFromMaxGap > 0) { | |
| shiftList(moveFromMaxGap * moveDir, 0, len); | |
| const remained = moveFromMaxGap + gapThisBound; | |
| if (remained < 0) { | |
| squeezeGaps(-remained * moveDir, 1); | |
| } | |
| } | |
| else { | |
| squeezeGaps(-gapThisBound * moveDir, 1); | |
| } | |
| } | |
| } | |
| function shiftList(delta: number, start: number, end: number) { | |
| if (delta !== 0) { | |
| adjusted = true; | |
| } | |
| for (let i = start; i < end; i++) { | |
| const item = list[i]; | |
| const rect = item.rect; | |
| rect[xyDim] += delta; | |
| item.label[xyDim] += delta; | |
| } | |
| } | |
| // Squeeze gaps if the labels exceed margin. | |
| function squeezeGaps(delta: number, maxSqeezePercent: number) { | |
| const gaps: number[] = []; | |
| let totalGaps = 0; | |
| for (let i = 1; i < len; i++) { | |
| const prevItemRect = list[i - 1].rect; | |
| const gap = Math.max(list[i].rect[xyDim] - prevItemRect[xyDim] - prevItemRect[sizeDim], 0); | |
| gaps.push(gap); | |
| totalGaps += gap; | |
| } | |
| if (!totalGaps) { | |
| return; | |
| } | |
| const squeezePercent = Math.min(Math.abs(delta) / totalGaps, maxSqeezePercent); | |
| if (delta > 0) { | |
| for (let i = 0; i < len - 1; i++) { | |
| // Distribute the shift delta to all gaps. | |
| const movement = gaps[i] * squeezePercent; | |
| // Forward | |
| shiftList(movement, 0, i + 1); | |
| } | |
| } | |
| else { | |
| // Backward | |
| for (let i = len - 1; i > 0; i--) { | |
| // Distribute the shift delta to all gaps. | |
| const movement = gaps[i - 1] * squeezePercent; | |
| shiftList(-movement, i, len); | |
| } | |
| } | |
| } | |
| /** | |
| * Squeeze to allow overlap if there is no more space available. | |
| * Let other overlapping strategy like hideOverlap do the job instead of keep exceeding the bounds. | |
| */ | |
| function squeezeWhenBailout(delta: number) { | |
| const dir = delta < 0 ? -1 : 1; | |
| delta = Math.abs(delta); | |
| const moveForEachLabel = Math.ceil(delta / (len - 1)); | |
| for (let i = 0; i < len - 1; i++) { | |
| if (dir > 0) { | |
| // Forward | |
| shiftList(moveForEachLabel, 0, i + 1); | |
| } | |
| else { | |
| // Backward | |
| shiftList(-moveForEachLabel, len - i - 1, len); | |
| } | |
| delta -= moveForEachLabel; | |
| if (delta <= 0) { | |
| return; | |
| } | |
| } | |
| } | |
| return adjusted; | |
| } | |
| /** | |
| * Adjust labels on x direction to avoid overlap. | |
| */ | |
| export function shiftLayoutOnX( | |
| list: Pick<LabelLayoutInfo, 'rect' | 'label'>[], | |
| leftBound: number, | |
| rightBound: number, | |
| // If average the shifts on all labels and add them to 0 | |
| // TODO: Not sure if should enable it. | |
| // Pros: The angle of lines will distribute more equally | |
| // Cons: In some layout. It may not what user wanted. like in pie. the label of last sector is usually changed unexpectedly. | |
| balanceShift?: boolean | |
| ): boolean { | |
| return shiftLayout(list, 'x', 'width', leftBound, rightBound, balanceShift); | |
| } | |
| /** | |
| * Adjust labels on y direction to avoid overlap. | |
| */ | |
| export function shiftLayoutOnY( | |
| list: Pick<LabelLayoutInfo, 'rect' | 'label'>[], | |
| topBound: number, | |
| bottomBound: number, | |
| // If average the shifts on all labels and add them to 0 | |
| balanceShift?: boolean | |
| ): boolean { | |
| return shiftLayout(list, 'y', 'height', topBound, bottomBound, balanceShift); | |
| } | |
| export function hideOverlap(labelList: LabelLayoutInfo[]) { | |
| const displayedLabels: LabelLayoutInfo[] = []; | |
| // TODO, render overflow visible first, put in the displayedLabels. | |
| labelList.sort(function (a, b) { | |
| return b.priority - a.priority; | |
| }); | |
| const globalRect = new BoundingRect(0, 0, 0, 0); | |
| function hideEl(el: Element) { | |
| if (!el.ignore) { | |
| // Show on emphasis. | |
| const emphasisState = el.ensureState('emphasis'); | |
| if (emphasisState.ignore == null) { | |
| emphasisState.ignore = false; | |
| } | |
| } | |
| el.ignore = true; | |
| } | |
| for (let i = 0; i < labelList.length; i++) { | |
| const labelItem = labelList[i]; | |
| const isAxisAligned = labelItem.axisAligned; | |
| const localRect = labelItem.localRect; | |
| const transform = labelItem.transform; | |
| const label = labelItem.label; | |
| const labelLine = labelItem.labelLine; | |
| globalRect.copy(labelItem.rect); | |
| // Add a threshold because layout may be aligned precisely. | |
| globalRect.width -= 0.1; | |
| globalRect.height -= 0.1; | |
| globalRect.x += 0.05; | |
| globalRect.y += 0.05; | |
| let obb = labelItem.obb; | |
| let overlapped = false; | |
| for (let j = 0; j < displayedLabels.length; j++) { | |
| const existsTextCfg = displayedLabels[j]; | |
| // Fast rejection. | |
| if (!globalRect.intersect(existsTextCfg.rect)) { | |
| continue; | |
| } | |
| if (isAxisAligned && existsTextCfg.axisAligned) { // Is overlapped | |
| overlapped = true; | |
| break; | |
| } | |
| if (!existsTextCfg.obb) { // If self is not axis aligned. But other is. | |
| existsTextCfg.obb = new OrientedBoundingRect(existsTextCfg.localRect, existsTextCfg.transform); | |
| } | |
| if (!obb) { // If self is axis aligned. But other is not. | |
| obb = new OrientedBoundingRect(localRect, transform); | |
| } | |
| if (obb.intersect(existsTextCfg.obb)) { | |
| overlapped = true; | |
| break; | |
| } | |
| } | |
| // TODO Callback to determine if this overlap should be handled? | |
| if (overlapped) { | |
| hideEl(label); | |
| labelLine && hideEl(labelLine); | |
| } | |
| else { | |
| label.attr('ignore', labelItem.defaultAttr.ignore); | |
| labelLine && labelLine.attr('ignore', labelItem.defaultAttr.labelGuideIgnore); | |
| displayedLabels.push(labelItem); | |
| } | |
| } | |
| } |