/
githubmirror
/
echarts
Обзор
Документация
Войти
/
githubmirror
/
echarts
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/component/axis/axisBreakHelperImpl.ts
572 строки
20 KB
100pah
fix(label): Clarify some of the uniformly use label layout API and data structure. Fix that some label props were not updated during layout in certain places.
15 июл 2025, 11:56
15 июл 2025, 11:56
ae3dbf3
Код
Авторство
О чём код?
/* * 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 type SingleAxisModel from '../../coord/single/AxisModel'; import type CartesianAxisModel from '../../coord/cartesian/AxisModel'; import type { AxisBaseModel } from '../../coord/AxisBaseModel'; import type ExtensionAPI from '../../core/ExtensionAPI'; import type { ExtendedElementProps } from '../../core/ExtendedElement'; import type CartesianAxisView from './CartesianAxisView'; import { makeInner } from '../../util/model'; import type { NullUndefined, ParsedAxisBreak } from '../../util/types'; import { assert, each, extend, find, map } from 'zrender/src/core/util'; import { getScaleBreakHelper } from '../../scale/break'; import type { PathProps } from 'zrender/src/graphic/Path'; import { subPixelOptimizeLine } from 'zrender/src/graphic/helper/subPixelOptimize'; import { applyTransform } from 'zrender/src/core/vector'; import * as matrixUtil from 'zrender/src/core/matrix'; import { AXIS_BREAK_COLLAPSE_ACTION_TYPE, AXIS_BREAK_EXPAND_ACTION_TYPE, AXIS_BREAK_TOGGLE_ACTION_TYPE, BaseAxisBreakPayload } from './axisAction'; import { LabelLayoutWithGeometry, labelIntersect, labelLayoutApplyTranslation } from '../../label/labelLayoutHelper'; import type SingleAxisView from './SingleAxisView'; import type { AxisBuilderCfg } from './AxisBuilder'; import { AxisBreakUpdateResult, registerAxisBreakHelperImpl } from './axisBreakHelper'; import { warn } from '../../util/log'; import ComponentModel from '../../model/Component'; import { AxisBaseOption } from '../../coord/axisCommonTypes'; import { BoundingRect, Group, Line, Point, Polygon, Polyline, WH, XY } from '../../util/graphic'; /** * @caution * Must not export anything except `installAxisBreakHelper` */ /** * The zigzag shapes for axis breaks are generated according to some random * factors. It should persist as much as possible to avoid constantly * changing by every user operation. */ const viewCache = makeInner<{ visualList: CacheBreakVisual[]; }, CartesianAxisView | SingleAxisView>(); type CacheBreakVisual = { parsedBreak: ParsedAxisBreak; zigzagRandomList: number[]; shouldRemove: boolean; }; function ensureVisualInCache( visualList: CacheBreakVisual[], targetBreak: ParsedAxisBreak ): CacheBreakVisual { let visual = find( visualList, item => getScaleBreakHelper()!.identifyAxisBreak(item.parsedBreak.breakOption, targetBreak.breakOption) ); if (!visual) { visualList.push(visual = { zigzagRandomList: [], parsedBreak: targetBreak, shouldRemove: false }); } return visual; } function resetCacheVisualRemoveFlag(visualList: CacheBreakVisual[]): void { each(visualList, item => (item.shouldRemove = true)); } function removeUnusedCacheVisual(visualList: CacheBreakVisual[]): void { for (let i = visualList.length - 1; i >= 0; i--) { if (visualList[i].shouldRemove) { visualList.splice(i, 1); } } } function rectCoordBuildBreakAxis( axisGroup: Group, axisView: CartesianAxisView | SingleAxisView, axisModel: CartesianAxisModel | SingleAxisModel, coordSysRect: BoundingRect, api: ExtensionAPI ): void { const axis = axisModel.axis; if (axis.scale.isBlank() || !getScaleBreakHelper()) { return; } const breakPairs = getScaleBreakHelper()!.retrieveAxisBreakPairs( axis.scale.getTicks({breakTicks: 'only_break'}), tick => tick.break, false ); if (!breakPairs.length) { return; } const breakAreaModel = (axisModel as AxisBaseModel).getModel('breakArea'); const zigzagAmplitude = breakAreaModel.get('zigzagAmplitude'); let zigzagMinSpan = breakAreaModel.get('zigzagMinSpan'); let zigzagMaxSpan = breakAreaModel.get('zigzagMaxSpan'); // Use arbitrary value to avoid dead loop if user gives inappropriate settings. zigzagMinSpan = Math.max(2, zigzagMinSpan || 0); zigzagMaxSpan = Math.max(zigzagMinSpan, zigzagMaxSpan || 0); const expandOnClick = breakAreaModel.get('expandOnClick'); const zigzagZ = breakAreaModel.get('zigzagZ'); const itemStyleModel = breakAreaModel.getModel('itemStyle'); const itemStyle = itemStyleModel.getItemStyle(); const borderColor = itemStyle.stroke; const borderWidth = itemStyle.lineWidth; const borderType = itemStyle.lineDash; const color = itemStyle.fill; const group = new Group({ ignoreModelZ: true } as ExtendedElementProps); const isAxisHorizontal = axis.isHorizontal(); const cachedVisualList = viewCache(axisView).visualList || (viewCache(axisView).visualList = []); resetCacheVisualRemoveFlag(cachedVisualList); for (let i = 0; i < breakPairs.length; i++) { const parsedBreak = breakPairs[i][0].break.parsedBreak; // Even if brk.gap is 0, we should also draw the breakArea because // border is sometimes required to be visible (as a line) const coords: number[] = []; coords[0] = axis.toGlobalCoord(axis.dataToCoord(parsedBreak.vmin, true)); coords[1] = axis.toGlobalCoord(axis.dataToCoord(parsedBreak.vmax, true)); if (coords[1] < coords[0]) { coords.reverse(); } const cachedVisual = ensureVisualInCache(cachedVisualList, parsedBreak); cachedVisual.shouldRemove = false; const breakGroup = new Group(); addZigzagShapes( cachedVisual.zigzagRandomList, breakGroup, coords[0], coords[1], isAxisHorizontal, parsedBreak, ); if (expandOnClick) { breakGroup.on('click', () => { const payload: BaseAxisBreakPayload = { type: AXIS_BREAK_EXPAND_ACTION_TYPE, breaks: [{ start: parsedBreak.breakOption.start, end: parsedBreak.breakOption.end, }] }; payload[`${axis.dim}AxisIndex`] = axisModel.componentIndex; api.dispatchAction(payload); }); } breakGroup.silent = !expandOnClick; group.add(breakGroup); } axisGroup.add(group); removeUnusedCacheVisual(cachedVisualList); function addZigzagShapes( zigzagRandomList: number[], breakGroup: Group, startCoord: number, endCoord: number, isAxisHorizontal: boolean, trimmedBreak: ParsedAxisBreak ) { const polylineStyle = { stroke: borderColor, lineWidth: borderWidth, lineDash: borderType, fill: 'none' }; const dimBrk = isAxisHorizontal ? 0 : 1; const dimZigzag = 1 - dimBrk; const zigzagCoordMax = coordSysRect[XY[dimZigzag]] + coordSysRect[WH[dimZigzag]]; // Apply `subPixelOptimizeLine` for alignning with break ticks. function subPixelOpt(brkCoord: number): number { const pBrk: number[] = []; const dummyP: number[] = []; pBrk[dimBrk] = dummyP[dimBrk] = brkCoord; pBrk[dimZigzag] = coordSysRect[XY[dimZigzag]]; dummyP[dimZigzag] = zigzagCoordMax; const dummyShape = {x1: pBrk[0], y1: pBrk[1], x2: dummyP[0], y2: dummyP[1]}; subPixelOptimizeLine(dummyShape, dummyShape, {lineWidth: 1}); pBrk[0] = dummyShape.x1; pBrk[1] = dummyShape.y1; return pBrk[dimBrk]; } startCoord = subPixelOpt(startCoord); endCoord = subPixelOpt(endCoord); const pointsA = []; const pointsB = []; let isSwap = true; let current = coordSysRect[XY[dimZigzag]]; for (let idx = 0; ; idx++) { // Use `isFirstPoint` `isLastPoint` to ensure the intersections between zigzag // and axis are precise, thus it can join its axis tick correctly. const isFirstPoint = current === coordSysRect[XY[dimZigzag]]; const isLastPoint = current >= zigzagCoordMax; if (isLastPoint) { current = zigzagCoordMax; } const pA: number[] = []; const pB: number[] = []; pA[dimBrk] = startCoord; pB[dimBrk] = endCoord; if (!isFirstPoint && !isLastPoint) { pA[dimBrk] += isSwap ? -zigzagAmplitude : zigzagAmplitude; pB[dimBrk] -= !isSwap ? -zigzagAmplitude : zigzagAmplitude; } pA[dimZigzag] = current; pB[dimZigzag] = current; pointsA.push(pA); pointsB.push(pB); let randomVal: number; if (idx < zigzagRandomList.length) { randomVal = zigzagRandomList[idx]; } else { randomVal = Math.random(); zigzagRandomList.push(randomVal); } current += randomVal * (zigzagMaxSpan - zigzagMinSpan) + zigzagMinSpan; isSwap = !isSwap; if (isLastPoint) { break; } } const anidSuffix = getScaleBreakHelper()!.serializeAxisBreakIdentifier(trimmedBreak.breakOption); // Create two polylines and add them to the breakGroup breakGroup.add(new Polyline({ anid: `break_a_${anidSuffix}`, shape: { points: pointsA }, style: polylineStyle, z: zigzagZ })); /* Add the second polyline and a polygon only if the gap is not zero * Otherwise if the polyline is with dashed line or being opaque, * it may not be constant with breaks with non-zero gaps. */ if (trimmedBreak.gapReal !== 0) { breakGroup.add(new Polyline({ anid: `break_b_${anidSuffix}`, shape: { // Not reverse to keep the dash stable when dragging resizing. points: pointsB }, style: polylineStyle, z: zigzagZ })); // Creating the polygon that fills the area between the polylines // From end to start for polygon. const pointsB2 = pointsB.slice(); pointsB2.reverse(); const polygonPoints = pointsA.concat(pointsB2); breakGroup.add(new Polygon({ anid: `break_c_${anidSuffix}`, shape: { points: polygonPoints }, style: { fill: color, opacity: itemStyle.opacity }, z: zigzagZ })); } } } function buildAxisBreakLine( axisModel: AxisBaseModel, group: Group, transformGroup: Group, pathBaseProp: PathProps, ): void { const axis = axisModel.axis; const transform = transformGroup.transform; assert(pathBaseProp.style); let extent: number[] = axis.getExtent(); if (axis.inverse) { extent = extent.slice(); extent.reverse(); } const breakPairs = getScaleBreakHelper()!.retrieveAxisBreakPairs( axis.scale.getTicks({breakTicks: 'only_break'}), tick => tick.break, false ); const brkLayoutList = map(breakPairs, breakPair => { const parsedBreak = breakPair[0].break.parsedBreak; const coordPair = [ axis.dataToCoord(parsedBreak.vmin, true), axis.dataToCoord(parsedBreak.vmax, true), ]; (coordPair[0] > coordPair[1]) && coordPair.reverse(); return { coordPair, brkId: getScaleBreakHelper()!.serializeAxisBreakIdentifier(parsedBreak.breakOption), }; }); brkLayoutList.sort((layout1, layout2) => layout1.coordPair[0] - layout2.coordPair[0]); let ySegMin = extent[0]; let lastLayout = null; for (let idx = 0; idx < brkLayoutList.length; idx++) { const layout = brkLayoutList[idx]; const brkTirmmedMin = Math.max(layout.coordPair[0], extent[0]); const brkTirmmedMax = Math.min(layout.coordPair[1], extent[1]); if (ySegMin <= brkTirmmedMin) { addSeg(ySegMin, brkTirmmedMin, lastLayout, layout); } ySegMin = brkTirmmedMax; lastLayout = layout; } if (ySegMin <= extent[1]) { addSeg(ySegMin, extent[1], lastLayout, null); } function addSeg( min: number, max: number, layout1: {brkId: string} | NullUndefined, layout2: {brkId: string} | NullUndefined ): void { function trans(p1: number[], p2: number[]): void { if (transform) { applyTransform(p1, p1, transform); applyTransform(p2, p2, transform); } } function subPixelOptimizePP(p1: number[], p2: number[]): void { const shape = {x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1]}; subPixelOptimizeLine(shape, shape, pathBaseProp.style); p1[0] = shape.x1; p1[1] = shape.y1; p2[0] = shape.x2; p2[1] = shape.y2; } const lineP1 = [min, 0]; const lineP2 = [max, 0]; // dummy tick is used to align the line segment ends with axis ticks // after `subPixelOptimizeLine` being applied. const dummyTickEnd1 = [min, 5]; const dummyTickEnd2 = [max, 5]; trans(lineP1, dummyTickEnd1); subPixelOptimizePP(lineP1, dummyTickEnd1); trans(lineP2, dummyTickEnd2); subPixelOptimizePP(lineP2, dummyTickEnd2); // Apply it keeping the same as the normal axis line. subPixelOptimizePP(lineP1, lineP2); const seg = new Line(extend({shape: { x1: lineP1[0], y1: lineP1[1], x2: lineP2[0], y2: lineP2[1], }}, pathBaseProp)); group.add(seg); // Animation should be precise to be consistent with tick and split line animation. seg.anid = `breakLine_${layout1 ? layout1.brkId : '\0'}_\0_${layout2 ? layout2.brkId : '\0'}`; } } /** * Resolve the overlap of a pair of labels. * * [CAUTION] Only label.x/y are allowed to change. */ function adjustBreakLabelPair( axisInverse: boolean, axisRotation: AxisBuilderCfg['rotation'], layoutPair: (LabelLayoutWithGeometry | NullUndefined)[], // Means [brk_min_label, brk_max_label] ): void { if (find(layoutPair, item => !item)) { return; } const mtv = new Point(); if (!labelIntersect(layoutPair[0], layoutPair[1], mtv, { // Assert `labelPair` is `[break_min, break_max]`. // `axis.inverse: true` means a smaller scale value corresponds to a bigger value in axis.extent. // The axisRotation indicates mtv direction of OBB intersecting. direction: -(axisInverse ? axisRotation + Math.PI : axisRotation), touchThreshold: 0, // If need to resovle intersection align axis by moving labels according to MTV, // the direction must not be opposite, otherwise cause misleading. bidirectional: false, })) { return; } // Rotate axis back to (1, 0) direction, to be a standard axis. const axisStTrans = matrixUtil.create(); matrixUtil.rotate(axisStTrans, axisStTrans, -axisRotation); const labelPairStTrans = map( layoutPair, layout => (layout.transform ? matrixUtil.mul(matrixUtil.create(), axisStTrans, layout.transform) : axisStTrans ) ); function isParallelToAxis(whIdx: number): boolean { // Assert label[0] and label[1] has the same rotation, so only use [0]. const localRect = layoutPair[0].localRect; const labelVec0 = new Point( localRect[WH[whIdx]] * labelPairStTrans[0][0], localRect[WH[whIdx]] * labelPairStTrans[0][1] ); return Math.abs(labelVec0.y) < 1e-5; } // If overlapping, move pair[0] pair[1] apart a little. We need to calculate a ratio k to // distribute mtv to pair[0] and pair[1]. This is to place the text gap as close as possible // to the center of the break ticks, otherwise it might looks weird or misleading. // - When labels' width/height are not parallel to axis (usually by rotation), // we can simply treat the k as `0.5`. let k = 0.5; // - When labels' width/height are parallel to axis, the width/height need to be considered, // since they may differ significantly. In this case we keep textAlign as 'center' rather // than 'left'/'right', due to considerations of space utilization for wide break.gap. // A sample case: break on xAxis(no inverse) is [200, 300000]. // We calculate k based on the formula below: // Rotated axis and labels to the direction of (1, 0). // uval = ( (pair[0].insidePt - mtv*k) + (pair[1].insidePt + mtv*(1-k)) ) / 2 - brkCenter // 0 <= k <= 1 // |uval| should be as small as possible. // Derived as follows: // qval = (pair[0].insidePt + pair[1].insidePt + mtv) / 2 - brkCenter // k = (qval - uval) / mtv // min(qval, qval-mtv) <= uval <= max(qval, qval-mtv) if (isParallelToAxis(0) || isParallelToAxis(1)) { const rectSt = map(layoutPair, (layout, idx) => { const rect = layout.localRect.clone(); rect.applyTransform(labelPairStTrans[idx]); return rect; }); const brkCenterSt = new Point(); brkCenterSt.copy(layoutPair[0].label).add(layoutPair[1].label).scale(0.5); brkCenterSt.transform(axisStTrans); const mtvSt = mtv.clone().transform(axisStTrans); const insidePtSum = rectSt[0].x + rectSt[1].x + (mtvSt.x >= 0 ? rectSt[0].width : rectSt[1].width); const qval = (insidePtSum + mtvSt.x) / 2 - brkCenterSt.x; const uvalMin = Math.min(qval, qval - mtvSt.x); const uvalMax = Math.max(qval, qval - mtvSt.x); const uval = uvalMax < 0 ? uvalMax : uvalMin > 0 ? uvalMin : 0; k = (qval - uval) / mtvSt.x; } const delta0 = new Point(); const delta1 = new Point(); Point.scale(delta0, mtv, -k); Point.scale(delta1, mtv, 1 - k); labelLayoutApplyTranslation(layoutPair[0], delta0); labelLayoutApplyTranslation(layoutPair[1], delta1); } function updateModelAxisBreak( model: ComponentModel<AxisBaseOption>, payload: BaseAxisBreakPayload ): AxisBreakUpdateResult { const result: AxisBreakUpdateResult = {breaks: []}; each(payload.breaks, inputBrk => { if (!inputBrk) { return; } const breakOption = find( model.get('breaks', true), brkOption => getScaleBreakHelper()!.identifyAxisBreak(brkOption, inputBrk) ); if (!breakOption) { if (__DEV__) { warn(`Can not find axis break by start: ${inputBrk.start}, end: ${inputBrk.end}`); } return; } const actionType = payload.type; const old = { isExpanded: !!breakOption.isExpanded }; breakOption.isExpanded = actionType === AXIS_BREAK_EXPAND_ACTION_TYPE ? true : actionType === AXIS_BREAK_COLLAPSE_ACTION_TYPE ? false : actionType === AXIS_BREAK_TOGGLE_ACTION_TYPE ? !breakOption.isExpanded : breakOption.isExpanded; result.breaks.push({ start: breakOption.start, end: breakOption.end, isExpanded: !!breakOption.isExpanded, old, }); }); return result; } export function installAxisBreakHelper(): void { registerAxisBreakHelperImpl({ adjustBreakLabelPair, buildAxisBreakLine, rectCoordBuildBreakAxis, updateModelAxisBreak, }); }