/
githubmirror
/
echarts
Обзор
Документация
Войти
/
githubmirror
/
echarts
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
6.0.0-beta.1
src/label/labelLayoutHelper.ts
537 строк
18 KB
100pah
test(grid layout): make nameMoveOverlap by default and tune textMargin.
25 июн 2025, 05:27
25 июн 2025, 05:27
3fe4574
Код
Авторство
О чём код?
/* * 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 { LabelExtendedTextStyle, LabelLayoutOption, LabelMarginType, NullUndefined } from '../util/types'; import { BoundingRect, OrientedBoundingRect, Polyline, expandOrShrinkRect, isBoundingRectAxisAligned } from '../util/graphic'; import type Element from 'zrender/src/Element'; import { PointLike } from 'zrender/src/core/Point'; import { each, extend, retrieve2 } from 'zrender/src/core/util'; import { normalizeCssArray } from '../util/format'; import { BoundingRectIntersectOpt } from 'zrender/src/core/BoundingRect'; import { MatrixArray } from 'zrender/src/core/matrix'; // Also prevent duck typing. export const LABEL_LAYOUT_INFO_KIND_RAW = 1 as const; export const LABEL_LAYOUT_INFO_KIND_COMPUTED = 2 as const; // PENDING: a LabelLayoutInfoRaw with `defaultAttr.ignore: false` will become a NullUndefined, // rather than a LabelLayoutInfoComputed. export type LabelLayoutInfoAll = LabelLayoutInfoRaw | LabelLayoutInfoComputed | NullUndefined; interface LabelLayoutInfoBase { label: ZRText labelLine?: Polyline | NullUndefined layoutOption?: LabelLayoutOption | NullUndefined priority: number // Save the original value that may be modified in overlap resolving. defaultAttr: { ignore?: boolean labelGuideIgnore?: boolean } ignoreMargin?: boolean; // For backward compatibility. // In grid (cartesian) estimation process (for `grid.containLabel` and related overflow resolving handlings), // the `gridRect` is shrunk gradually according to the last union boundingRect of the axis labels and names. // But the `ignore` stretegy (such as in `hideOverlap` and `fixMinMaxLabelShow`) affects this process // significantly - the outermost labels might be determined `ignore:true` in a big `gridRect`, but be determined // `ignore:false` in a shrunk `gridRect`. (e.g., if the third label touches the outermost label in a shrunk // `gridRect`.) That probably causes the result overflowing unexpectedly. Therefore, `suggestIgnore` is // introduced to ensure the `ignore` consistent during that estimation process. suggestIgnore?: boolean; } export interface LabelLayoutInfoRaw extends LabelLayoutInfoBase { kind: typeof LABEL_LAYOUT_INFO_KIND_RAW // Should no other properties - ensure to be a subset of LabelLayoutInfoComputed. } export interface LabelLayoutInfoComputed extends LabelLayoutInfoBase, LabelIntersectionCheckInfo { kind: typeof LABEL_LAYOUT_INFO_KIND_COMPUTED // The original props in `LabelLayoutInfoBase` are preserved. // ------ Computed properites ------ // All of the members in `LabelIntersectionCheckInfo`. } export interface LabelIntersectionCheckInfo { // Global rect from `localRect` rect: BoundingRect // Weither the localRect aligns to screen-pixel x or y axis. axisAligned: boolean // Considering performance, obb is not created until it is really needed. // Use `ensureOBB(labelLayoutInfo)` to create and cache obb before using it, // created by `localRect`&`transform`. obb: OrientedBoundingRect | NullUndefined // localRect of the label. // [CAUTION] do not modify it, since it may be the internal data structure of the el. localRect: BoundingRect // transform of `label`. If `label` has no `transform`, this prop is `NullUndefined`. // [CAUTION] do not modify it, since it may be the internal data structure of the el. transform: number[] | NullUndefined } export function createLabelLayoutList( rawList: LabelLayoutInfoRaw[] ): LabelLayoutInfoComputed[] { const resultList: LabelLayoutInfoComputed[] = []; each(rawList, raw => { // PENDING: necessary? raw = extend({}, raw); const layoutInfo = prepareLabelLayoutInfo(raw); if (layoutInfo) { resultList.push(layoutInfo); } }); return resultList; } /** * If `defaultAttr.ignore: true`, return `NullUndefined`. * (the caller is reponsible for ensuring the label is always `ignore: true`.) * Otherwise the `layoutInfo` will be modified and returned. * `label.ignore` is not necessarily falsy, since it might be modified by some overlap resolving handling. * * The result can also be the input of this method. * * @see ensureLabelLayoutInfoComputed */ function prepareLabelLayoutInfo( layoutInfo: LabelLayoutInfoAll ): LabelLayoutInfoComputed | NullUndefined { if (!layoutInfo || layoutInfo.defaultAttr.ignore) { return; } const label = layoutInfo.label; const ignoreMargin = layoutInfo.ignoreMargin; const transform = label.getComputedTransform(); // NOTE: Get bounding rect after getComputedTransform, or label may not been updated by the host el. let localRect = label.getBoundingRect(); const axisAligned = isBoundingRectAxisAligned(transform); if (!ignoreMargin) { localRect = applyTextMarginToLocalRect(label, localRect); } const globalRect = localRect.clone(); globalRect.applyTransform(transform); if (!ignoreMargin && (label.style as LabelExtendedTextStyle).__marginType === LabelMarginType.minMargin) { // `minMargin` only support number value. const halfMinMargin = ((label.style.margin as number) || 0) / 2; expandOrShrinkRect(globalRect, halfMinMargin, false, false); } const computed = layoutInfo as LabelLayoutInfoComputed; computed.kind = LABEL_LAYOUT_INFO_KIND_COMPUTED, // --- computed properties --- computed.rect = globalRect; computed.localRect = localRect; computed.obb = null, // will be created by `ensureOBB` when using. computed.axisAligned = axisAligned; computed.transform = transform; return computed; } /** * The reverse operation of `ensureLabelLayoutInfoComputedv`. */ export function rollbackToLabelLayoutInfoRaw( labelLayoutInfo: LabelLayoutInfoAll ): LabelLayoutInfoRaw { if (labelLayoutInfo == null) { return; } const raw = labelLayoutInfo as unknown as LabelLayoutInfoRaw; raw.kind = LABEL_LAYOUT_INFO_KIND_RAW; return raw; } /** * This method supports that the label layout info is not computed until needed, * for performance consideration. * * [CAUTION] * - If the raw label is changed, must call * `ensureLabelLayoutInfoComputed(rollbackToLabelLayoutInfoRaw(layoutInfo))` * to recreate the layout info. * - Null checking is needed for the result. @see prepareLabelLayoutInfo * * Usage: * To make a copy of labelLayoutInfo, simply: * const layoutInfoCopy = rollbackToLabelLayoutInfoRaw(extends({}, someLabelLayoutInfo)); */ export function ensureLabelLayoutInfoComputed( labelLayoutInfo: LabelLayoutInfoAll ): LabelLayoutInfoComputed | NullUndefined { if (!labelLayoutInfo) { return; } if (labelLayoutInfo.kind !== LABEL_LAYOUT_INFO_KIND_COMPUTED) { labelLayoutInfo = prepareLabelLayoutInfo(labelLayoutInfo); } return labelLayoutInfo; } export function prepareIntersectionCheckInfo( localRect: BoundingRect, transform: MatrixArray | NullUndefined ): LabelIntersectionCheckInfo { const globalRect = localRect.clone(); globalRect.applyTransform(transform); return { obb: null, rect: globalRect, localRect, axisAligned: isBoundingRectAxisAligned(transform), transform, }; } export function createSingleLayoutInfoComputed(el: ZRText): LabelLayoutInfoComputed | NullUndefined { return ensureLabelLayoutInfoComputed({ kind: LABEL_LAYOUT_INFO_KIND_RAW, label: el, priority: el.z2, defaultAttr: {ignore: el.ignore}, }); } /** * Create obb if no one, can cache it. */ function ensureOBB(layoutInfo: LabelIntersectionCheckInfo): OrientedBoundingRect { return layoutInfo.obb || ( layoutInfo.obb = new OrientedBoundingRect(layoutInfo.localRect, layoutInfo.transform) ); } /** * The input localRect is never modified. * The returned localRect maybe the input localRect. */ export function applyTextMarginToLocalRect( label: ZRText, localRect: BoundingRect, ): BoundingRect { if ((label.style as LabelExtendedTextStyle).__marginType !== LabelMarginType.textMargin) { return localRect; } const textMargin = normalizeCssArray(retrieve2(label.style.margin, [0, 0])); localRect = localRect.clone(); expandOrShrinkRect(localRect, textMargin, false, false); return localRect; } function shiftLayout( list: Pick<LabelLayoutInfoComputed, '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<LabelLayoutInfoComputed, '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<LabelLayoutInfoComputed, '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); } /** * [CAUTION]: the `label.ignore` in the input is not necessarily falsy. * this method checks intersection regardless of current `ignore`, * if no intersection, restore the `ignore` to `defaultAttr.ignore`. * And `labelList` will be modified. * Therefore, if some other overlap resolving strategy has ignored some elements, * do not input them to this method. * PENDING: review the diff between the requirements from LabelManager and AxisBuilder, * and uniform the behavior? */ export function hideOverlap(labelList: LabelLayoutInfoAll[]): void { const displayedLabels: LabelLayoutInfoComputed[] = []; // TODO, render overflow visible first, put in the displayedLabels. labelList.sort(function (a, b) { return ((b.suggestIgnore ? 1 : 0) - (a.suggestIgnore ? 1 : 0)) || (b.priority - a.priority); }); 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 = ensureLabelLayoutInfoComputed(labelList[i]); if (!labelItem || labelItem.label.ignore) { continue; } const label = labelItem.label; const labelLine = labelItem.labelLine; // NOTICE: even when the with/height of globalRect of a label is 0, the label line should // still be displayed, since we should follow the concept of "truncation", meaning that // something exists even if it cannot be fully displayed. A visible label line is necessary // to allow users to get a tooltip with label info on hover. let overlapped = false; for (let j = 0; j < displayedLabels.length; j++) { if (labelIntersect(labelItem, displayedLabels[j], null, {touchThreshold: 0.05})) { 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); } } } /** * [NOTICE]: * - `label.ignore` is not considered - no requirement so far. * - `baseLayoutInfo` and `targetLayoutInfo` may be modified - obb may be created and saved. * * Enable fast check for performance; use obb if inevitable. * If `mtv` is used, `targetLayoutInfo` can be moved based on the values filled into `mtv`. */ export function labelIntersect( baseLayoutInfo: LabelIntersectionCheckInfo | NullUndefined, targetLayoutInfo: LabelIntersectionCheckInfo | NullUndefined, mtv?: PointLike, intersectOpt?: BoundingRectIntersectOpt ): boolean { if (!baseLayoutInfo || !targetLayoutInfo) { return false; } // Fast rejection. if (!baseLayoutInfo.rect.intersect(targetLayoutInfo.rect, mtv, intersectOpt)) { return false; } if (baseLayoutInfo.axisAligned && targetLayoutInfo.axisAligned) { return true; // obb is the same as the normal bounding rect. } return ensureOBB(baseLayoutInfo).intersect(ensureOBB(targetLayoutInfo), mtv, intersectOpt); }