| /* | |
| * 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, | |
| }); | |
| } |