blob: 1136bb63d8a8b144a4f4de2e09a35ac56240b2b5 [file] [log] [blame]
/*
* 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, NullUndefined } from '../util/types';
import {
BoundingRect, OrientedBoundingRect, Polyline, WH, XY, ensureCopyRect, ensureCopyTransform, expandOrShrinkRect,
isBoundingRectAxisAligned
} from '../util/graphic';
import type Element from 'zrender/src/Element';
import { PointLike } from 'zrender/src/core/Point';
import { BoundingRectIntersectOpt } from 'zrender/src/core/BoundingRect';
import { MatrixArray } from 'zrender/src/core/matrix';
import { LabelExtendedTextStyle, LabelMarginType } from './labelStyle';
/**
* This is the input for label layout and overlap resolving.
*/
interface LabelLayoutBase {
label: ZRText
labelLine?: Polyline | NullUndefined
layoutOption?: LabelLayoutOption | NullUndefined
priority: number
// @see `SavedLabelAttr` in `LabelManager.ts`
defaultAttr: {
ignore?: boolean
labelGuideIgnore?: boolean
}
// To replace user specified `textMargin` or `minMargin`.
// Format: `[top, right, bottom, left]`
// e.g., `[0, null, 0, null]` means that the top and bottom margin is replaced as `0`,
// and use the original settings of left and right margin.
marginForce?: (number | NullUndefined)[] | NullUndefined;
// For backward compatibility for `minMargin`. `minMargin` can only be a number rather than number[],
// some series only apply `minMargin` on top/bottom but disregard left/right.
minMarginForce?: (number | NullUndefined)[] | NullUndefined;
// If no `textMargin` and `minMargin` is specified, use this as default.
// Format: `[top, right, bottom, left]`
marginDefault?: number[] | NullUndefined;
// 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` strategy (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.
// It suggests that this label has the lowest priority in ignore-if-overlap strategy.
suggestIgnore?: boolean;
}
const LABEL_LAYOUT_BASE_PROPS = [
'label', 'labelLine', 'layoutOption', 'priority', 'defaultAttr',
'marginForce', 'minMarginForce', 'marginDefault', 'suggestIgnore'
] as const;
/**
* [CAUTION]
* - These props will be created and cached.
* - The created props may be modified directly (rather than recreate) for performance consideration,
* therefore, do not use the internal data structure of the el.
*/
export interface LabelGeometry {
// Only ignore is necessary in intersection check.
label: Pick<ZRText, 'ignore'>
// `NullUndefined` means dirty, as that is a uninitialized value.
dirty: (typeof LABEL_LAYOUT_DIRTY_ALL) | NullUndefined
// 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(labelLayout)` to create and cache obb before using it,
// created by `localRect`&`transform`.
obb: OrientedBoundingRect | NullUndefined
// localRect of the label.
localRect: BoundingRect
// The transform of `label`. Be `NullUndefined` if no `transform`, which follows
// the rule of `Element['transform']`, and bypass some unnecessary calculation.
transform: number[] | NullUndefined
}
// A `LabelLayoutData` indicates that the props of `LabelGeometry` are not necessarily computed.
// Use `Partial<LabelGeometry>` to check prop name conflicts with `LabelGeometry`.
export type LabelLayoutData = LabelLayoutBase & Partial<LabelGeometry>;
// A `LabelLayoutWithGeometry` indicates that `ensureLabelLayoutWithGeometry` has been performed.
export type LabelLayoutWithGeometry = LabelLayoutBase & LabelGeometry;
const LABEL_LAYOUT_DIRTY_BIT_OTHERS = 1 as const;
const LABEL_LAYOUT_DIRTY_BIT_OBB = 2 as const;
const LABEL_LAYOUT_DIRTY_ALL = LABEL_LAYOUT_DIRTY_BIT_OTHERS | LABEL_LAYOUT_DIRTY_BIT_OBB;
export function setLabelLayoutDirty(
labelGeometry: Partial<LabelGeometry>, dirtyOrClear: boolean, dirtyBits?: number
): void {
dirtyBits = dirtyBits || LABEL_LAYOUT_DIRTY_ALL;
dirtyOrClear
? (labelGeometry.dirty |= dirtyBits)
: (labelGeometry.dirty &= ~dirtyBits);
}
function isLabelLayoutDirty(
labelGeometry: Partial<LabelGeometry>, dirtyBits?: number
): boolean {
dirtyBits = dirtyBits || LABEL_LAYOUT_DIRTY_ALL;
return labelGeometry.dirty == null || !!(labelGeometry.dirty & dirtyBits);
}
/**
* [CAUTION]
* - No auto dirty propagation mechanism yet. If the transform of the raw label or any of its ancestors is
* changed, must sync the changes to the props of `LabelGeometry` by:
* either explicitly call:
* `setLabelLayoutDirty(labelLayout, true); ensureLabelLayoutWithGeometry(labelLayout);`
* or call (if only translation is performed):
* `labelLayoutApplyTranslation(labelLayout);`
* - `label.ignore` is not necessarily falsy, and not considered in computing `LabelGeometry`,
* since it might be modified by some overlap resolving handling.
* - To duplicate or make a variation:
* use `newLabelLayoutWithGeometry`.
*
* The result can also be the input of this method.
* @return `NullUndefined` if and only if `labelLayout` is `NullUndefined`.
*/
export function ensureLabelLayoutWithGeometry(
labelLayout: LabelLayoutData | NullUndefined
): LabelLayoutWithGeometry | NullUndefined {
if (!labelLayout) {
return;
}
if (isLabelLayoutDirty(labelLayout)) {
computeLabelGeometry(labelLayout, labelLayout.label, labelLayout);
}
return labelLayout as LabelLayoutWithGeometry;
}
/**
* The props in `out` will be filled if existing, or created.
*/
export function computeLabelGeometry<TOut extends LabelGeometry>(
out: Partial<TOut>,
label: ZRText,
opt?: Pick<LabelLayoutData, 'marginForce' | 'minMarginForce' | 'marginDefault'>
): TOut {
// [CAUTION] These props may be modified directly for performance consideration,
// therefore, do not output the internal data structure of zrender Element.
const rawTransform = label.getComputedTransform();
out.transform = ensureCopyTransform(out.transform, rawTransform);
// NOTE: should call `getBoundingRect` after `getComputedTransform`, or may get an inaccurate bounding rect.
// The reason is that `getComputedTransform` calls `__host.updateInnerText()` internally, which updates the label
// by `textConfig` mounted on the host.
// PENDING: add a dirty bit for that in zrender?
const outLocalRect = out.localRect = ensureCopyRect(out.localRect, label.getBoundingRect());
const labelStyleExt = label.style as LabelExtendedTextStyle;
let margin = labelStyleExt.margin;
const marginForce = opt && opt.marginForce;
const minMarginForce = opt && opt.minMarginForce;
const marginDefault = opt && opt.marginDefault;
let marginType = labelStyleExt.__marginType;
if (marginType == null && marginDefault) {
margin = marginDefault;
marginType = LabelMarginType.textMargin;
}
// `textMargin` and `minMargin` can not exist both.
for (let i = 0; i < 4; i++) {
_tmpLabelMargin[i] =
(marginType === LabelMarginType.minMargin && minMarginForce && minMarginForce[i] != null)
? minMarginForce[i]
: (marginForce && marginForce[i] != null)
? marginForce[i]
: (margin ? margin[i] : 0);
}
if (marginType === LabelMarginType.textMargin) {
expandOrShrinkRect(outLocalRect, _tmpLabelMargin, false, false);
}
const outGlobalRect = out.rect = ensureCopyRect(out.rect, outLocalRect);
if (rawTransform) {
outGlobalRect.applyTransform(rawTransform);
}
// Notice: label.style.margin is actually `minMargin / 2`, handled by `setTextStyleCommon`.
if (marginType === LabelMarginType.minMargin) {
expandOrShrinkRect(outGlobalRect, _tmpLabelMargin, false, false);
}
out.axisAligned = isBoundingRectAxisAligned(rawTransform);
(out.label = out.label || {} as TOut['label']).ignore = label.ignore;
setLabelLayoutDirty(out as TOut, false);
setLabelLayoutDirty(out as TOut, true, LABEL_LAYOUT_DIRTY_BIT_OBB);
// Do not remove `obb` (if existing) for reuse, just reset the dirty bit.
return out as TOut;
}
const _tmpLabelMargin: number[] = [0, 0, 0, 0];
/**
* The props in `out` will be filled if existing, or created.
*/
export function computeLabelGeometry2<TOut extends LabelGeometry>(
out: Partial<TOut>,
rawLocalRect: BoundingRect,
rawTransform: MatrixArray | NullUndefined
): TOut {
out.transform = ensureCopyTransform(out.transform, rawTransform);
out.localRect = ensureCopyRect(out.localRect, rawLocalRect);
out.rect = ensureCopyRect(out.rect, rawLocalRect);
if (rawTransform) {
out.rect.applyTransform(rawTransform);
}
out.axisAligned = isBoundingRectAxisAligned(rawTransform);
out.obb = undefined; // Reset to undefined, will be created by `ensureOBB` when using.
(out.label = out.label || {} as TOut['label']).ignore = false;
return out as TOut;
}
/**
* This is a shortcut of
* ```js
* labelLayout.label.x = newX;
* labelLayout.label.y = newY;
* setLabelLayoutDirty(labelLayout, true);
* ensureLabelLayoutWithGeometry(labelLayout);
* ```
* and provide better performance in this common case.
*/
export function labelLayoutApplyTranslation(
labelLayout: LabelLayoutData,
offset: PointLike,
): void {
if (!labelLayout) {
return;
}
labelLayout.label.x += offset.x;
labelLayout.label.y += offset.y;
labelLayout.label.markRedraw();
const transform = labelLayout.transform;
if (transform) {
transform[4] += offset.x;
transform[5] += offset.y;
}
const globalRect = labelLayout.rect;
if (globalRect) {
globalRect.x += offset.x;
globalRect.y += offset.y;
}
const obb = labelLayout.obb;
if (obb) {
obb.fromBoundingRect(labelLayout.localRect, transform);
}
}
/**
* To duplicate or make a variation of a label layout.
* Copy the only relevant properties to avoid the conflict or wrongly reuse of the props of `LabelLayoutWithGeometry`.
*/
export function newLabelLayoutWithGeometry(
newBaseWithDefaults: Partial<LabelLayoutData>,
source: LabelLayoutBase
): LabelLayoutWithGeometry {
for (let i = 0; i < LABEL_LAYOUT_BASE_PROPS.length; i++) {
const prop = LABEL_LAYOUT_BASE_PROPS[i];
if (newBaseWithDefaults[prop] == null) {
(newBaseWithDefaults[prop] as any) = source[prop];
}
}
return ensureLabelLayoutWithGeometry(newBaseWithDefaults as LabelLayoutData);
}
/**
* Create obb if no one, can cache it.
*/
function ensureOBB(labelGeometry: LabelGeometry): OrientedBoundingRect {
let obb = labelGeometry.obb;
if (!obb || isLabelLayoutDirty(labelGeometry, LABEL_LAYOUT_DIRTY_BIT_OBB)) {
labelGeometry.obb = obb = obb || new OrientedBoundingRect();
obb.fromBoundingRect(labelGeometry.localRect, labelGeometry.transform);
setLabelLayoutDirty(labelGeometry, false, LABEL_LAYOUT_DIRTY_BIT_OBB);
}
return obb;
}
/**
* Adjust labels on x/y direction to avoid overlap.
*
* PENDING: the current implementation is based on the global bounding rect rather than the local rect,
* which may be not preferable in some edge cases when the label has rotation, but works for most cases,
* since rotation is unnecessary when there is sufficient space, while squeezing is applied regardless
* of overlapping when there is no enough space.
*
* NOTICE:
* - The input `list` and its content will be modified (sort, label.x/y, rect).
* - The caller should sync the modifications to the other parts by
* `setLabelLayoutDirty` and `ensureLabelLayoutWithGeometry` if needed.
*
* @return adjusted
*/
export function shiftLayoutOnXY(
list: Pick<LabelLayoutWithGeometry, 'rect' | 'label'>[],
xyDimIdx: 0 | 1, // 0 for x, 1 for y
minBound: number, // for x, leftBound; for y, topBound
maxBound: number, // for x, rightBound; for y, bottomBound
// 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 {
const len = list.length;
const xyDim = XY[xyDimIdx];
const sizeDim = WH[xyDimIdx];
if (len < 2) {
return false;
}
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;
}
/**
* @see `SavedLabelAttr` in `LabelManager.ts`
* @see `hideOverlap`
*/
export function restoreIgnore(labelList: LabelLayoutData[]): void {
for (let i = 0; i < labelList.length; i++) {
const labelItem = labelList[i];
const defaultAttr = labelItem.defaultAttr;
const labelLine = labelItem.labelLine;
labelItem.label.attr('ignore', defaultAttr.ignore);
labelLine && labelLine.attr('ignore', defaultAttr.labelGuideIgnore);
}
}
/**
* [NOTICE - restore]:
* 'series:layoutlabels' may be triggered during some shortcut passes, such as zooming in series.graph/geo
* (`updateLabelLayout`), where the modified `Element` props should be restorable from `defaultAttr`.
* @see `SavedLabelAttr` in `LabelManager.ts`
* `restoreIgnore` can be called to perform the restore, if needed.
*
* [NOTICE - state]:
* Regarding Element's states, this method is only designed for the normal state.
* PENDING: although currently this method is effectively called in other states in `updateLabelLayout` case,
* the bad case is not noticeable in the zooming scenario.
*/
export function hideOverlap(labelList: LabelLayoutData[]): void {
const displayedLabels: LabelLayoutWithGeometry[] = [];
// 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 = ensureLabelLayoutWithGeometry(labelList[i]);
// The current `el.ignore` is involved, since some previous overlap
// resolving strategies may have set `el.ignore` to true.
if (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 {
displayedLabels.push(labelItem);
}
}
}
/**
* Enable fast check for performance; use obb if inevitable.
* If `mtv` is used, `targetLayoutInfo` can be moved based on the values filled into `mtv`.
*
* This method is based only on the current `Element` states (regardless of other states).
* Typically this method (and the entire layout process) is performed in normal state.
*/
export function labelIntersect(
baseLayoutInfo: LabelGeometry | NullUndefined,
targetLayoutInfo: LabelGeometry | NullUndefined,
mtv?: PointLike,
intersectOpt?: BoundingRectIntersectOpt
): boolean {
if (!baseLayoutInfo || !targetLayoutInfo) {
return false;
}
if ((baseLayoutInfo.label && baseLayoutInfo.label.ignore)
|| (targetLayoutInfo.label && targetLayoutInfo.label.ignore)
) {
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);
}