diff --git a/package.json b/package.json index 66043c4bd7..da436711fa 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ }, "dependencies": { "tslib": "2.3.0", - "zrender": "5.6.1" + "zrender": "github:ecomfe/zrender#v6" }, "devDependencies": { "@babel/code-frame": "7.10.4", diff --git a/src/chart/chord/ChordEdge.ts b/src/chart/chord/ChordEdge.ts new file mode 100644 index 0000000000..25001ddfb1 --- /dev/null +++ b/src/chart/chord/ChordEdge.ts @@ -0,0 +1,192 @@ +import type { PathProps, PathStyleProps } from 'zrender/src/graphic/Path'; +import type PathProxy from 'zrender/src/core/PathProxy'; +import { extend, isString } from 'zrender/src/core/util'; +import * as graphic from '../../util/graphic'; +import SeriesData from '../../data/SeriesData'; +import { GraphEdge } from '../../data/Graph'; +import type Model from '../../model/Model'; +import { getSectorCornerRadius } from '../helper/sectorHelper'; +import { saveOldStyle } from '../../animation/basicTransition'; +import ChordSeriesModel, { ChordEdgeItemOption, ChordEdgeLineStyleOption, ChordNodeItemOption } from './ChordSeries'; +import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; +import { getECData } from '../../util/innerStore'; + +export class ChordPathShape { + // Souce node, two points forming an arc + s1: [number, number] = [0, 0]; + s2: [number, number] = [0, 0]; + sStartAngle: number = 0; + sEndAngle: number = 0; + + // Target node, two points forming an arc + t1: [number, number] = [0, 0]; + t2: [number, number] = [0, 0]; + tStartAngle: number = 0; + tEndAngle: number = 0; + + cx: number = 0; + cy: number = 0; + // series.r0 of ChordSeries + r: number = 0; + + clockwise: boolean = true; +} + +interface ChordEdgePathProps extends PathProps { + shape?: Partial +} + +export class ChordEdge extends graphic.Path { + shape: ChordPathShape; + + constructor( + nodeData: SeriesData, + edgeData: SeriesData, + edgeIdx: number, + startAngle: number + ) { + super(); + getECData(this).dataType = 'edge'; + this.updateData(nodeData, edgeData, edgeIdx, startAngle, true); + } + + buildPath(ctx: PathProxy | CanvasRenderingContext2D, shape: ChordPathShape): void { + // Start from n11 + ctx.moveTo(shape.s1[0], shape.s1[1]); + + const ratio = 0.7; + const clockwise = shape.clockwise; + + // Draw the arc from n11 to n12 + ctx.arc(shape.cx, shape.cy, shape.r, shape.sStartAngle, shape.sEndAngle, !clockwise); + + // Bezier curve to cp1 and then to n21 + ctx.bezierCurveTo( + (shape.cx - shape.s2[0]) * ratio + shape.s2[0], + (shape.cy - shape.s2[1]) * ratio + shape.s2[1], + (shape.cx - shape.t1[0]) * ratio + shape.t1[0], + (shape.cy - shape.t1[1]) * ratio + shape.t1[1], + shape.t1[0], + shape.t1[1] + ); + + // Draw the arc from n21 to n22 + ctx.arc(shape.cx, shape.cy, shape.r, shape.tStartAngle, shape.tEndAngle, !clockwise); + + // Bezier curve back to cp2 and then to n11 + ctx.bezierCurveTo( + (shape.cx - shape.t2[0]) * ratio + shape.t2[0], + (shape.cy - shape.t2[1]) * ratio + shape.t2[1], + (shape.cx - shape.s1[0]) * ratio + shape.s1[0], + (shape.cy - shape.s1[1]) * ratio + shape.s1[1], + shape.s1[0], + shape.s1[1] + ); + + ctx.closePath(); + } + + updateData( + nodeData: SeriesData, + edgeData: SeriesData, + edgeIdx: number, + startAngle: number, + firstCreate?: boolean + ): void { + const seriesModel = nodeData.hostModel as ChordSeriesModel; + const edge = edgeData.graph.getEdgeByIndex(edgeIdx); + const layout = edge.getLayout(); + const itemModel = edge.node1.getModel(); + const edgeModel = edgeData.getItemModel(edge.dataIndex); + const lineStyle = edgeModel.getModel('lineStyle'); + const emphasisModel = edgeModel.getModel('emphasis'); + const focus = emphasisModel.get('focus'); + + const shape: ChordPathShape = extend( + getSectorCornerRadius(itemModel.getModel('itemStyle'), layout, true), + layout + ); + + const el = this; + + // Ignore NaN data. + if (isNaN(shape.sStartAngle) || isNaN(shape.tStartAngle)) { + // Use NaN shape to avoid drawing shape. + el.setShape(shape); + return; + } + + if (firstCreate) { + el.setShape(shape); + applyEdgeFill(el, edge, nodeData, lineStyle); + } + else { + saveOldStyle(el); + + applyEdgeFill(el, edge, nodeData, lineStyle); + graphic.updateProps(el, { + shape: shape + }, seriesModel, edgeIdx); + } + + toggleHoverEmphasis( + this, + focus === 'adjacency' + ? edge.getAdjacentDataIndices() + : focus, + emphasisModel.get('blurScope'), + emphasisModel.get('disabled') + ); + + setStatesStylesFromModel(el, edgeModel, 'lineStyle'); + + edgeData.setItemGraphicEl(edge.dataIndex, el); + } +} + +function applyEdgeFill( + edgeShape: ChordEdge, + edge: GraphEdge, + nodeData: SeriesData, + lineStyleModel: Model +) { + const node1 = edge.node1; + const node2 = edge.node2; + const edgeStyle = edgeShape.style as PathStyleProps; + + edgeShape.setStyle(lineStyleModel.getLineStyle()); + + const color = lineStyleModel.get('color'); + switch (color) { + case 'source': + // TODO: use visual and node1.getVisual('color'); + edgeStyle.fill = nodeData.getItemVisual(node1.dataIndex, 'style').fill; + edgeStyle.decal = node1.getVisual('style').decal; + break; + case 'target': + edgeStyle.fill = nodeData.getItemVisual(node2.dataIndex, 'style').fill; + edgeStyle.decal = node2.getVisual('style').decal; + break; + case 'gradient': + const sourceColor = nodeData.getItemVisual(node1.dataIndex, 'style').fill; + const targetColor = nodeData.getItemVisual(node2.dataIndex, 'style').fill; + if (isString(sourceColor) && isString(targetColor)) { + // Gradient direction is perpendicular to the mid-angles + // of source and target nodes. + const shape = edgeShape.shape; + const sMidX = (shape.s1[0] + shape.s2[0]) / 2; + const sMidY = (shape.s1[1] + shape.s2[1]) / 2; + const tMidX = (shape.t1[0] + shape.t2[0]) / 2; + const tMidY = (shape.t1[1] + shape.t2[1]) / 2; + edgeStyle.fill = new graphic.LinearGradient( + sMidX, sMidY, tMidX, tMidY, + [ + { offset: 0, color: sourceColor }, + { offset: 1, color: targetColor } + ], + true + ); + } + break; + } +} diff --git a/src/chart/chord/ChordPiece.ts b/src/chart/chord/ChordPiece.ts new file mode 100644 index 0000000000..0c502eb1c4 --- /dev/null +++ b/src/chart/chord/ChordPiece.ts @@ -0,0 +1,169 @@ +import { extend, retrieve3 } from 'zrender/src/core/util'; +import * as graphic from '../../util/graphic'; +import SeriesData from '../../data/SeriesData'; +import { getSectorCornerRadius } from '../helper/sectorHelper'; +import ChordSeriesModel, { ChordNodeItemOption } from './ChordSeries'; +import type Model from '../../model/Model'; +import type { GraphNode } from '../../data/Graph'; +import { getLabelStatesModels, setLabelStyle } from '../../label/labelStyle'; +import type { BuiltinTextPosition } from 'zrender/src/core/types'; +import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; +import { getECData } from '../../util/innerStore'; + +export default class ChordPiece extends graphic.Sector { + + constructor(data: SeriesData, idx: number, startAngle: number) { + super(); + getECData(this).dataType = 'node'; + this.z2 = 2; + + const text = new graphic.Text(); + + this.setTextContent(text); + + this.updateData(data, idx, startAngle, true); + } + + updateData(data: SeriesData, idx: number, startAngle?: number, firstCreate?: boolean): void { + const sector = this; + const node = data.graph.getNodeByIndex(idx); + + const seriesModel = data.hostModel as ChordSeriesModel; + const itemModel = node.getModel(); + const emphasisModel = itemModel.getModel('emphasis'); + + // layout position is the center of the sector + const layout = data.getItemLayout(idx) as graphic.Sector['shape']; + const shape: graphic.Sector['shape'] = extend( + getSectorCornerRadius(itemModel.getModel('itemStyle'), layout, true), + layout + ); + + const el = this; + + // Ignore NaN data. + if (isNaN(shape.startAngle)) { + // Use NaN shape to avoid drawing shape. + el.setShape(shape); + return; + } + + if (firstCreate) { + el.setShape(shape); + } + else { + graphic.updateProps(el, { + shape: shape + }, seriesModel, idx); + } + + const sectorShape = extend( + getSectorCornerRadius( + itemModel.getModel('itemStyle'), + layout, + true + ), + layout + ); + sector.setShape(sectorShape); + sector.useStyle(data.getItemVisual(idx, 'style')); + setStatesStylesFromModel(sector, itemModel); + + this._updateLabel(seriesModel, itemModel, node); + + data.setItemGraphicEl(idx, el); + setStatesStylesFromModel(el, itemModel, 'itemStyle'); + + // Add focus/blur states handling + const focus = emphasisModel.get('focus'); + toggleHoverEmphasis( + this, + focus === 'adjacency' + ? node.getAdjacentDataIndices() + : focus, + emphasisModel.get('blurScope'), + emphasisModel.get('disabled') + ); + } + + protected _updateLabel( + seriesModel: ChordSeriesModel, + itemModel: Model, + node: GraphNode + ) { + const label = this.getTextContent(); + const layout = node.getLayout(); + const midAngle = (layout.startAngle + layout.endAngle) / 2; + const dx = Math.cos(midAngle); + const dy = Math.sin(midAngle); + + const normalLabelModel = itemModel.getModel('label'); + label.ignore = !normalLabelModel.get('show'); + + // Set label style + const labelStateModels = getLabelStatesModels(itemModel); + const style = node.getVisual('style'); + setLabelStyle( + label, + labelStateModels, + { + labelFetcher: { + getFormattedLabel(dataIndex, stateName, dataType, labelDimIndex, formatter, extendParams) { + return seriesModel.getFormattedLabel( + dataIndex, stateName, 'node', + labelDimIndex, + // ensure edgeLabel formatter is provided + // to prevent the inheritance from `label.formatter` of the series + retrieve3( + formatter, + labelStateModels.normal && labelStateModels.normal.get('formatter'), + itemModel.get('name') + ), + extendParams + ); + } + }, + labelDataIndex: node.dataIndex, + defaultText: node.dataIndex + '', + inheritColor: style.fill, + defaultOpacity: style.opacity, + defaultOutsidePosition: 'startArc' as BuiltinTextPosition + } + ); + + // Set label position + const labelPosition = normalLabelModel.get('position') || 'outside'; + const labelPadding = normalLabelModel.get('distance') || 0; + + let r; + if (labelPosition === 'outside') { + r = layout.r + labelPadding; + } + else { + r = (layout.r + layout.r0) / 2; + } + + this.textConfig = { + inside: labelPosition !== 'outside' + }; + + const align = labelPosition !== 'outside' + ? normalLabelModel.get('align') || 'center' + : (dx > 0 ? 'left' : 'right'); + + const verticalAlign = labelPosition !== 'outside' + ? normalLabelModel.get('verticalAlign') || 'middle' + : (dy > 0 ? 'top' : 'bottom'); + + label.attr({ + x: dx * r + layout.cx, + y: dy * r + layout.cy, + rotation: 0, + style: { + align, + verticalAlign + } + }); + } +} + diff --git a/src/chart/chord/ChordSeries.ts b/src/chart/chord/ChordSeries.ts new file mode 100644 index 0000000000..568f5b96d7 --- /dev/null +++ b/src/chart/chord/ChordSeries.ts @@ -0,0 +1,340 @@ +/* +* 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 { + SeriesOption, + SeriesOnCartesianOptionMixin, + SeriesOnPolarOptionMixin, + SeriesOnCalendarOptionMixin, + SeriesOnGeoOptionMixin, + SeriesOnSingleOptionMixin, + OptionDataValue, + RoamOptionMixin, + SeriesLabelOption, + ItemStyleOption, + LineStyleOption, + SymbolOptionMixin, + BoxLayoutOptionMixin, + CircleLayoutOptionMixin, + SeriesLineLabelOption, + StatesOptionMixin, + GraphEdgeItemObject, + OptionDataValueNumeric, + CallbackDataParams, + DefaultEmphasisFocus +} from '../../util/types'; +import Model from '../../model/Model'; +import SeriesModel from '../../model/Series'; +import GlobalModel from '../../model/Global'; +import SeriesData from '../../data/SeriesData'; +import createGraphFromNodeEdge from '../helper/createGraphFromNodeEdge'; +import Graph from '../../data/Graph'; +import { LineDataVisual } from '../../visual/commonVisualTypes'; +import { createTooltipMarkup } from '../../component/tooltip/tooltipMarkup'; +import LegendVisualProvider from '../../visual/LegendVisualProvider'; +import * as zrUtil from 'zrender/src/core/util'; + +interface ExtraEmphasisState { + /** + * For focus on nodes: + * - self: Focus self node, and all edges connected to it. + * - adjacency: Focus self nodes and two edges (source and target) + * connected to the focused node. + * + * For focus on edges: + * - self: Focus self edge, and all nodes connected to it. + * - adjacency: Focus self edge and all edges connected to it and all + * nodes connected to these edges. + */ + focus?: DefaultEmphasisFocus | 'adjacency' +} + +interface ChordStatesMixin { + emphasis?: ExtraEmphasisState +} + +interface ChordEdgeStatesMixin { + emphasis?: ExtraEmphasisState +} + +type ChordDataValue = OptionDataValue | OptionDataValue[]; + +export interface ChordItemStyleOption extends ItemStyleOption { + borderRadius?: (number | string)[] | number | string +} + +export interface ChordNodeStateOption { + itemStyle?: ChordItemStyleOption + label?: ChordNodeLabelOption +} + +export interface ChordNodeItemOption extends ChordNodeStateOption, + StatesOptionMixin { + + id?: string + name?: string + value?: ChordDataValue +} + +export interface ChordEdgeLineStyleOption extends LineStyleOption { + curveness?: number +} + +export interface ChordNodeLabelOption extends Omit, 'position'> { + silent?: boolean + position?: SeriesLabelOption['position'] | 'outside' +} + +export interface ChordEdgeStateOption { + lineStyle?: ChordEdgeLineStyleOption + label?: SeriesLineLabelOption +} + +export interface ChordEdgeItemOption extends ChordEdgeStateOption, + StatesOptionMixin, + GraphEdgeItemObject { + + value?: number +} + +export interface ChordSeriesOption + extends SeriesOption, ChordStatesMixin>, + SeriesOnCartesianOptionMixin, SeriesOnPolarOptionMixin, SeriesOnCalendarOptionMixin, + SeriesOnGeoOptionMixin, SeriesOnSingleOptionMixin, + SymbolOptionMixin, + RoamOptionMixin, + BoxLayoutOptionMixin, + CircleLayoutOptionMixin +{ + type?: 'chord' + + coordinateSystem?: 'none' + + legendHoverLink?: boolean + + clockwise?: boolean + startAngle?: number + endAngle?: number | 'auto' + padAngle?: number + minAngle?: number + + data?: (ChordNodeItemOption | ChordDataValue)[] + nodes?: (ChordNodeItemOption | ChordDataValue)[] + + edges?: ChordEdgeItemOption[] + links?: ChordEdgeItemOption[] + + edgeLabel?: SeriesLineLabelOption + label?: ChordNodeLabelOption + + itemStyle?: ChordItemStyleOption + lineStyle?: ChordEdgeLineStyleOption + + emphasis?: { + focus?: Exclude['focus'] + scale?: boolean | number + label?: SeriesLabelOption + edgeLabel?: SeriesLabelOption + itemStyle?: ItemStyleOption + lineStyle?: LineStyleOption + } + + blur?: { + label?: SeriesLabelOption + edgeLabel?: SeriesLabelOption + itemStyle?: ItemStyleOption + lineStyle?: LineStyleOption + } + + select?: { + label?: SeriesLabelOption + edgeLabel?: SeriesLabelOption + itemStyle?: ItemStyleOption + lineStyle?: LineStyleOption + } +} + +class ChordSeriesModel extends SeriesModel { + + static type = 'series.chord'; + readonly type = ChordSeriesModel.type; + + init(option: ChordSeriesOption) { + super.init.apply(this, arguments as any); + this.fillDataTextStyle(option.edges || option.links); + + // Enable legend selection for each data item + this.legendVisualProvider = new LegendVisualProvider( + zrUtil.bind(this.getData, this), zrUtil.bind(this.getRawData, this) + ); + } + + mergeOption(option: ChordSeriesOption) { + super.mergeOption.apply(this, arguments as any); + this.fillDataTextStyle(option.edges || option.links); + } + + getInitialData(option: ChordSeriesOption, ecModel: GlobalModel): SeriesData { + const edges = option.edges || option.links || []; + const nodes = option.data || option.nodes || []; + + if (nodes && edges) { + const graph = createGraphFromNodeEdge(nodes as ChordNodeItemOption[], edges, this, true, beforeLink); + return graph.data; + } + + function beforeLink(nodeData: SeriesData, edgeData: SeriesData) { + // TODO Inherit resolveParentPath by default in Model#getModel? + const oldGetModel = Model.prototype.getModel; + function newGetModel(this: Model, path: any, parentModel?: Model) { + const model = oldGetModel.call(this, path, parentModel); + model.resolveParentPath = resolveParentPath; + return model; + } + + edgeData.wrapMethod('getItemModel', function (model: Model) { + model.resolveParentPath = resolveParentPath; + model.getModel = newGetModel; + return model; + }); + + function resolveParentPath(this: Model, pathArr: readonly string[]): string[] { + if (pathArr && (pathArr[0] === 'label' || pathArr[1] === 'label')) { + const newPathArr = pathArr.slice(); + if (pathArr[0] === 'label') { + newPathArr[0] = 'edgeLabel'; + } + else if (pathArr[1] === 'label') { + newPathArr[1] = 'edgeLabel'; + } + return newPathArr; + } + return pathArr as string[]; + } + } + } + + getGraph(): Graph { + return this.getData().graph; + } + + getEdgeData() { + return this.getGraph().edgeData as SeriesData; + } + + formatTooltip( + dataIndex: number, + multipleSeries: boolean, + dataType: string + ) { + const params = this.getDataParams(dataIndex, dataType as 'node' | 'edge'); + + if (dataType === 'edge') { + const nodeData = this.getData(); + const edge = nodeData.graph.getEdgeByIndex(dataIndex); + const sourceName = nodeData.getName(edge.node1.dataIndex); + const targetName = nodeData.getName(edge.node2.dataIndex); + + const nameArr = []; + sourceName != null && nameArr.push(sourceName); + targetName != null && nameArr.push(targetName); + + return createTooltipMarkup('nameValue', { + name: nameArr.join(' > '), + value: params.value, + noValue: params.value == null + }); + } + // dataType === 'node' or empty + return createTooltipMarkup('nameValue', { + name: params.name, + value: params.value, + noValue: params.value == null + }); + } + + getDataParams(dataIndex: number, dataType: 'node' | 'edge') { + const params = super.getDataParams(dataIndex, dataType); + if (dataType === 'node') { + const nodeData = this.getData(); + const node = this.getGraph().getNodeByIndex(dataIndex); + // Set name if not already set + if (params.name == null) { + params.name = nodeData.getName(dataIndex); + } + // Set value if not already set + if (params.value == null) { + const nodeValue = node.getLayout().value; + params.value = nodeValue; + } + } + return params; + } + + static defaultOption: ChordSeriesOption = { + // zlevel: 0, + z: 2, + + coordinateSystem: 'none', + + legendHoverLink: true, + colorBy: 'data', + + left: 0, + top: 0, + right: 0, + bottom: 0, + width: null, + height: null, + + center: ['50%', '50%'], + radius: ['70%', '80%'], + clockwise: true, + + startAngle: 90, + endAngle: 'auto', + minAngle: 0, + padAngle: 3, + + itemStyle: { + borderRadius: [0, 0, 5, 5] + }, + + lineStyle: { + width: 0, + color: 'source', + opacity: 0.2 + }, + + label: { + show: true, + position: 'outside', + distance: 5 + }, + + emphasis: { + focus: 'adjacency', + lineStyle: { + opacity: 0.5 + } + } + }; +} + +export default ChordSeriesModel; diff --git a/src/chart/chord/ChordView.ts b/src/chart/chord/ChordView.ts new file mode 100644 index 0000000000..4434ebe427 --- /dev/null +++ b/src/chart/chord/ChordView.ts @@ -0,0 +1,153 @@ +/* +* 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 * as graphic from '../../util/graphic'; +import ChartView from '../../view/Chart'; +import GlobalModel from '../../model/Global'; +import ExtensionAPI from '../../core/ExtensionAPI'; +import SeriesData from '../../data/SeriesData'; +import ChordSeriesModel from './ChordSeries'; +import ChordPiece from './ChordPiece'; +import { ChordEdge } from './ChordEdge'; +import { parsePercent } from '../../util/number'; +import { getECData } from '../../util/innerStore'; + +const RADIAN = Math.PI / 180; + +class ChordView extends ChartView { + + static readonly type = 'chord'; + readonly type: string = ChordView.type; + + private _data: SeriesData; + private _edgeData: SeriesData; + + init(ecModel: GlobalModel, api: ExtensionAPI) { + } + + render(seriesModel: ChordSeriesModel, ecModel: GlobalModel, api: ExtensionAPI) { + const data = seriesModel.getData(); + const oldData = this._data; + const group = this.group; + + const startAngle = -seriesModel.get('startAngle') * RADIAN; + + data.diff(oldData) + .add((newIdx) => { + /* Consider the case when there are only two nodes A and B, + * and there is a link between A and B. + * At first, they are both disselected from legend. And then + * when A is selected, A will go into `add` method. But since + * there are no edges to be displayed, A should not be added. + * So we should only add A when layout is defined. + */ + + const layout = data.getItemLayout(newIdx); + if (layout) { + const el = new ChordPiece(data, newIdx, startAngle); + getECData(el).dataIndex = newIdx; + group.add(el); + } + }) + + .update((newIdx, oldIdx) => { + let el = oldData.getItemGraphicEl(oldIdx) as ChordPiece; + const layout = data.getItemLayout(newIdx); + + /* Consider the case when there are only two nodes A and B, + * and there is a link between A and B. + * and when A is disselected from legend, there should be + * nothing to display. But in the `data.diff` method, B will go + * into `update` method and having no layout. + * In this case, we need to remove B. + */ + if (!layout) { + el && graphic.removeElementWithFadeOut(el, seriesModel, oldIdx); + return; + } + + if (!el) { + el = new ChordPiece(data, newIdx, startAngle); + } + else { + el.updateData(data, newIdx, startAngle); + } + group.add(el); + }) + + .remove(oldIdx => { + const el = oldData.getItemGraphicEl(oldIdx) as ChordPiece; + el && graphic.removeElementWithFadeOut(el, seriesModel, oldIdx); + }) + + .execute(); + + if (!oldData) { + const center = seriesModel.get('center'); + this.group.scaleX = 0.01; + this.group.scaleY = 0.01; + this.group.originX = parsePercent(center[0], api.getWidth()); + this.group.originY = parsePercent(center[1], api.getHeight()); + graphic.initProps(this.group, { + scaleX: 1, + scaleY: 1 + }, seriesModel); + } + + this._data = data; + + this.renderEdges(seriesModel, startAngle); + } + + renderEdges(seriesModel: ChordSeriesModel, startAngle: number) { + const nodeData = seriesModel.getData(); + const edgeData = seriesModel.getEdgeData(); + const oldData = this._edgeData; + const group = this.group; + + edgeData.diff(oldData) + .add(function (newIdx) { + const el = new ChordEdge(nodeData, edgeData, newIdx, startAngle); + getECData(el).dataIndex = newIdx; + group.add(el); + }) + + .update(function (newIdx, oldIdx) { + const el = oldData.getItemGraphicEl(oldIdx) as ChordEdge; + el.updateData(nodeData, edgeData, newIdx, startAngle); + group.add(el); + }) + + .remove(function (oldIdx) { + const el = oldData.getItemGraphicEl(oldIdx) as ChordEdge; + el && graphic.removeElementWithFadeOut(el, seriesModel, oldIdx); + }) + + .execute(); + + this._edgeData = edgeData; + } + + dispose() { + + } +} + + +export default ChordView; diff --git a/src/chart/chord/chordLayout.ts b/src/chart/chord/chordLayout.ts new file mode 100644 index 0000000000..cd9b894416 --- /dev/null +++ b/src/chart/chord/chordLayout.ts @@ -0,0 +1,269 @@ +/* +* 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 { normalizeArcAngles } from 'zrender/src/core/PathProxy'; +import GlobalModel from '../../model/Global'; +import ChordSeriesModel from './ChordSeries'; +import ExtensionAPI from '../../core/ExtensionAPI'; +import { getCircleLayout } from '../../util/layout'; +import SeriesModel from '../../model/Series'; +import { CircleLayoutOptionMixin, SeriesOption } from '../../util/types'; + +const RADIAN = Math.PI / 180; + +export default function chordCircularLayout(ecModel: GlobalModel, api: ExtensionAPI) { + ecModel.eachSeriesByType('chord', function (seriesModel: ChordSeriesModel) { + chordLayout(seriesModel, api); + }); +} + +function chordLayout(seriesModel: ChordSeriesModel, api: ExtensionAPI) { + const nodeData = seriesModel.getData(); + const nodeGraph = nodeData.graph; + const edgeData = seriesModel.getEdgeData(); + const edgeCount = edgeData.count(); + + if (!edgeCount) { + return; + } + + const { cx, cy, r, r0 } = getCircleLayout( + seriesModel as unknown as SeriesModel>, + api + ); + + let padAngle = Math.max((seriesModel.get('padAngle') || 0) * RADIAN, 0); + let minAngle = Math.max((seriesModel.get('minAngle') || 0) * RADIAN, 0); + const startAngle = -seriesModel.get('startAngle') * RADIAN; + const endAngle = startAngle + Math.PI * 2; + const clockwise = seriesModel.get('clockwise'); + const dir = clockwise ? 1 : -1; + + // Normalize angles + const angles = [startAngle, endAngle]; + normalizeArcAngles(angles, !clockwise); + const [normalizedStartAngle, normalizedEndAngle] = angles; + const totalAngle = normalizedEndAngle - normalizedStartAngle; + + const allZero = nodeData.getSum('value') === 0 && edgeData.getSum('value') === 0; + + // Sum of each node's edge values + const nodeValues: number[] = []; + let renderedNodeCount = 0; + nodeGraph.eachEdge(function (edge) { + // All links use the same value 1 when allZero is true + const value = allZero ? 1 : edge.getValue('value') as number; + if (allZero && (value > 0 || minAngle)) { + // When allZero is true, angle is in direct proportion to number + // of links both in and out of the node. + renderedNodeCount += 2; + } + const node1Index = edge.node1.dataIndex; + const node2Index = edge.node2.dataIndex; + nodeValues[node1Index] = (nodeValues[node1Index] || 0) + value; + nodeValues[node2Index] = (nodeValues[node2Index] || 0) + value; + }); + + // Update nodeValues with data.value if exists + let nodeValueSum = 0; + nodeGraph.eachNode(node => { + const dataValue = node.getValue('value') as number; + if (!isNaN(dataValue)) { + nodeValues[node.dataIndex] = Math.max(dataValue, nodeValues[node.dataIndex] || 0); + } + if (!allZero && (nodeValues[node.dataIndex] > 0 || minAngle)) { + // When allZero is false, angle is in direct proportion to node's + // value + renderedNodeCount++; + } + nodeValueSum += nodeValues[node.dataIndex] || 0; + }); + + if (renderedNodeCount === 0 || nodeValueSum === 0) { + return; + } + if (padAngle * renderedNodeCount >= Math.abs(totalAngle)) { + // Not enough angle to render the pad, minAngle has higher priority, and padAngle takes the rest + padAngle = Math.max(0, (Math.abs(totalAngle) - minAngle * renderedNodeCount) / renderedNodeCount); + } + if ((padAngle + minAngle) * renderedNodeCount >= Math.abs(totalAngle)) { + // Not enough angle to render the minAngle, so ignore the minAngle + minAngle = (Math.abs(totalAngle) - padAngle * renderedNodeCount) / renderedNodeCount; + } + + const unitAngle = (totalAngle - padAngle * renderedNodeCount * dir) + / nodeValueSum; + + let totalDeficit = 0; // sum of deficits of nodes with span < minAngle + let totalSurplus = 0; // sum of (spans - minAngle) of nodes with span > minAngle + let totalSurplusSpan = 0; // sum of spans of nodes with span > minAngle + let minSurplus = Infinity; // min of (spans - minAngle) of nodes with span > minAngle + nodeGraph.eachNode(node => { + const value = nodeValues[node.dataIndex] || 0; + const spanAngle = unitAngle * (nodeValueSum ? value : 1) * dir; + if (Math.abs(spanAngle) < minAngle) { + totalDeficit += minAngle - Math.abs(spanAngle); + } + else { + minSurplus = Math.min(minSurplus, Math.abs(spanAngle) - minAngle); + totalSurplus += Math.abs(spanAngle) - minAngle; + totalSurplusSpan += Math.abs(spanAngle); + } + node.setLayout({ + angle: spanAngle, + value: value + }); + }); + + let surplusAsMuchAsPossible = false; + if (totalDeficit > totalSurplus) { + // Not enough angle to spread the nodes, scale all + const scale = totalDeficit / totalSurplus; + nodeGraph.eachNode(node => { + const spanAngle = node.getLayout().angle; + if (Math.abs(spanAngle) >= minAngle) { + node.setLayout({ + angle: spanAngle * scale, + ratio: scale + }, true); + } + else { + node.setLayout({ + angle: minAngle, + ratio: minAngle === 0 ? 1 : spanAngle / minAngle + }, true); + } + }); + } + else { + // For example, if totalDeficit is 60 degrees and totalSurplus is 70 + // degrees but one of the sector can only reduced by 1 degree, + // if we decrease it with the ratio of value to other surplused nodes, + // it will have smaller angle than minAngle itself. + // So we need to borrow some angle from other nodes. + nodeGraph.eachNode(node => { + if (surplusAsMuchAsPossible) { + return; + } + const spanAngle = node.getLayout().angle; + const borrowRatio = Math.min(spanAngle / totalSurplusSpan, 1); + const borrowAngle = borrowRatio * totalDeficit; + if (spanAngle - borrowAngle < minAngle) { + // It will have less than minAngle after borrowing + surplusAsMuchAsPossible = true; + } + }); + } + + let restDeficit = totalDeficit; + nodeGraph.eachNode(node => { + if (restDeficit <= 0) { + return; + } + + const spanAngle = node.getLayout().angle; + if (spanAngle > minAngle && minAngle > 0) { + const borrowRatio = surplusAsMuchAsPossible + ? 1 + : Math.min(spanAngle / totalSurplusSpan, 1); + const maxBorrowAngle = spanAngle - minAngle; + const borrowAngle = Math.min(maxBorrowAngle, + Math.min(restDeficit, totalDeficit * borrowRatio) + ); + restDeficit -= borrowAngle; + node.setLayout({ + angle: spanAngle - borrowAngle, + ratio: (spanAngle - borrowAngle) / spanAngle + }, true); + } + else if (minAngle > 0) { + node.setLayout({ + angle: minAngle, + ratio: spanAngle === 0 ? 1 : minAngle / spanAngle + }, true); + } + }); + + let angle = normalizedStartAngle; + const edgeAccAngle: number[] = []; + nodeGraph.eachNode(node => { + const spanAngle = Math.max(node.getLayout().angle, minAngle); + node.setLayout({ + cx, + cy, + r0, + r, + startAngle: angle, + endAngle: angle + spanAngle * dir, + clockwise + }, true); + edgeAccAngle[node.dataIndex] = angle; + angle += (spanAngle + padAngle) * dir; + }); + + nodeGraph.eachEdge(edge => { + const value = allZero ? 1 : edge.getValue('value') as number; + const spanAngle = unitAngle * (nodeValueSum ? value : 1) * dir; + + const node1Index = edge.node1.dataIndex; + const sStartAngle = edgeAccAngle[node1Index] || 0; + const sSpan = Math.abs((edge.node1.getLayout().ratio || 1) * spanAngle); + const sEndAngle = sStartAngle + sSpan * dir; + const s1 = [ + cx + r0 * Math.cos(sStartAngle), + cy + r0 * Math.sin(sStartAngle) + ]; + const s2 = [ + cx + r0 * Math.cos(sEndAngle), + cy + r0 * Math.sin(sEndAngle) + ]; + + const node2Index = edge.node2.dataIndex; + const tStartAngle = edgeAccAngle[node2Index] || 0; + const tSpan = Math.abs((edge.node2.getLayout().ratio || 1) * spanAngle); + const tEndAngle = tStartAngle + tSpan * dir; + const t1 = [ + cx + r0 * Math.cos(tStartAngle), + cy + r0 * Math.sin(tStartAngle) + ]; + const t2 = [ + cx + r0 * Math.cos(tEndAngle), + cy + r0 * Math.sin(tEndAngle) + ]; + + edge.setLayout({ + s1, + s2, + sStartAngle, + sEndAngle, + t1, + t2, + tStartAngle, + tEndAngle, + cx, + cy, + r: r0, + value, + clockwise + }); + + edgeAccAngle[node1Index] = sEndAngle; + edgeAccAngle[node2Index] = tEndAngle; + }); +} diff --git a/src/chart/chord/install.ts b/src/chart/chord/install.ts new file mode 100644 index 0000000000..f657e5216d --- /dev/null +++ b/src/chart/chord/install.ts @@ -0,0 +1,33 @@ +/* +* 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 { EChartsExtensionInstallRegisters } from '../../extension'; +import ChordView from './ChordView'; +import ChordSeriesModel from './ChordSeries'; +import chordLayout from './chordLayout'; +import dataFilter from '../../processor/dataFilter'; + +export function install(registers: EChartsExtensionInstallRegisters) { + registers.registerChartView(ChordView); + registers.registerSeriesModel(ChordSeriesModel); + + registers.registerLayout(registers.PRIORITY.VISUAL.POST_CHART_LAYOUT, chordLayout); + // Add data filter processor + registers.registerProcessor(dataFilter('chord')); +} \ No newline at end of file diff --git a/src/chart/funnel/funnelLayout.ts b/src/chart/funnel/funnelLayout.ts index 4d433c1b1e..d1f1708724 100644 --- a/src/chart/funnel/funnelLayout.ts +++ b/src/chart/funnel/funnelLayout.ts @@ -25,15 +25,6 @@ import SeriesData from '../../data/SeriesData'; import GlobalModel from '../../model/Global'; import { isFunction } from 'zrender/src/core/util'; -function getViewRect(seriesModel: FunnelSeriesModel, api: ExtensionAPI) { - return layout.getLayoutRect( - seriesModel.getBoxLayoutParams(), { - width: api.getWidth(), - height: api.getHeight() - } - ); -} - function getSortedIndices(data: SeriesData, sort: FunnelSeriesOption['sort']) { const valueDim = data.mapDimension('value'); const valueArr = data.mapArray(valueDim, function (val: number) { @@ -251,7 +242,7 @@ export default function funnelLayout(ecModel: GlobalModel, api: ExtensionAPI) { const data = seriesModel.getData(); const valueDim = data.mapDimension('value'); const sort = seriesModel.get('sort'); - const viewRect = getViewRect(seriesModel, api); + const viewRect = layout.getViewRect(seriesModel, api); const orient = seriesModel.get('orient'); const viewWidth = viewRect.width; const viewHeight = viewRect.height; diff --git a/src/chart/pie/PieView.ts b/src/chart/pie/PieView.ts index 04a81f1e6b..ea87333ba1 100644 --- a/src/chart/pie/PieView.ts +++ b/src/chart/pie/PieView.ts @@ -25,7 +25,8 @@ import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states import ChartView from '../../view/Chart'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; -import { Payload, ColorString } from '../../util/types'; +import { Payload, ColorString, CircleLayoutOptionMixin, SeriesOption } from '../../util/types'; +import SeriesModel from '../../model/Series'; import SeriesData from '../../data/SeriesData'; import PieSeriesModel, {PieDataItemOption} from './PieSeries'; import labelLayout from './labelLayout'; @@ -33,7 +34,8 @@ import { setLabelLineStyle, getLabelLineStatesModels } from '../../label/labelGu import { setLabelStyle, getLabelStatesModels } from '../../label/labelStyle'; import { getSectorCornerRadius } from '../helper/sectorHelper'; import { saveOldStyle } from '../../animation/basicTransition'; -import { getBasicPieLayout, getSeriesLayoutData } from './pieLayout'; +import { getSeriesLayoutData } from './pieLayout'; +import { getCircleLayout } from '../../util/layout'; /** * Piece of pie including Sector, Label, LabelLine @@ -262,7 +264,12 @@ class PieView extends ChartView { if (data.count() === 0 && seriesModel.get('showEmptyCircle')) { const layoutData = getSeriesLayoutData(seriesModel); const sector = new graphic.Sector({ - shape: extend(getBasicPieLayout(seriesModel, api), layoutData) + shape: extend( + getCircleLayout( + seriesModel as unknown as SeriesModel>, api + ), + layoutData + ) }); sector.useStyle(seriesModel.getModel('emptyCircleStyle').getItemStyle()); this._emptyCircleSector = sector; diff --git a/src/chart/pie/labelLayout.ts b/src/chart/pie/labelLayout.ts index 5052ca236b..79dfb820a6 100644 --- a/src/chart/pie/labelLayout.ts +++ b/src/chart/pie/labelLayout.ts @@ -327,7 +327,7 @@ function constrainTextWidth( const newRect = label.getBoundingRect(); textRect.width = newRect.width; - const margin = (label.style.margin || 0) + 2.1; + const margin = ((label.style.margin as number) || 0) + 2.1; textRect.height = newRect.height + margin; textRect.y -= (textRect.height - oldHeight) / 2; } @@ -501,7 +501,7 @@ export default function pieLabelLayout( const textRect = label.getBoundingRect().clone(); textRect.applyTransform(label.getComputedTransform()); // Text has a default 1px stroke. Exclude this. - const margin = (label.style.margin || 0) + 2.1; + const margin = ((label.style.margin as number) || 0) + 2.1; textRect.y -= margin / 2; textRect.height += margin; diff --git a/src/chart/pie/pieLayout.ts b/src/chart/pie/pieLayout.ts index 4ec992ec4b..a94eb42fcf 100644 --- a/src/chart/pie/pieLayout.ts +++ b/src/chart/pie/pieLayout.ts @@ -17,69 +17,19 @@ * under the License. */ -import { parsePercent, linearMap } from '../../util/number'; +import { linearMap } from '../../util/number'; import * as layout from '../../util/layout'; -import * as zrUtil from 'zrender/src/core/util'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import PieSeriesModel from './PieSeries'; -import { SectorShape } from 'zrender/src/graphic/shape/Sector'; import { normalizeArcAngles } from 'zrender/src/core/PathProxy'; import { makeInner } from '../../util/model'; +import SeriesModel from '../../model/Series'; +import { CircleLayoutOptionMixin, SeriesOption } from '../../util/types'; const PI2 = Math.PI * 2; const RADIAN = Math.PI / 180; -function getViewRect(seriesModel: PieSeriesModel, api: ExtensionAPI) { - return layout.getLayoutRect( - seriesModel.getBoxLayoutParams(), { - width: api.getWidth(), - height: api.getHeight() - } - ); -} - -export function getBasicPieLayout(seriesModel: PieSeriesModel, api: ExtensionAPI): - Pick { - const viewRect = getViewRect(seriesModel, api); - - // center can be string or number when coordinateSystem is specified - let center = seriesModel.get('center'); - let radius = seriesModel.get('radius'); - - if (!zrUtil.isArray(radius)) { - radius = [0, radius]; - } - const width = parsePercent(viewRect.width, api.getWidth()); - const height = parsePercent(viewRect.height, api.getHeight()); - const size = Math.min(width, height); - const r0 = parsePercent(radius[0], size / 2); - const r = parsePercent(radius[1], size / 2); - - let cx: number; - let cy: number; - const coordSys = seriesModel.coordinateSystem; - if (coordSys) { - // percentage is not allowed when coordinate system is specified - const point = coordSys.dataToPoint(center); - cx = point[0] || 0; - cy = point[1] || 0; - } - else { - if (!zrUtil.isArray(center)) { - center = [center, center]; - } - cx = parsePercent(center[0], width) + viewRect.x; - cy = parsePercent(center[1], height) + viewRect.y; - } - - return { - cx, - cy, - r0, - r - }; -} export default function pieLayout( seriesType: 'pie', @@ -89,9 +39,12 @@ export default function pieLayout( ecModel.eachSeriesByType(seriesType, function (seriesModel: PieSeriesModel) { const data = seriesModel.getData(); const valueDim = data.mapDimension('value'); - const viewRect = getViewRect(seriesModel, api); + const viewRect = layout.getViewRect(seriesModel, api); - const { cx, cy, r, r0 } = getBasicPieLayout(seriesModel, api); + const { cx, cy, r, r0 } = layout.getCircleLayout( + seriesModel as unknown as SeriesModel>, + api + ); let startAngle = -seriesModel.get('startAngle') * RADIAN; let endAngle = seriesModel.get('endAngle'); diff --git a/src/chart/sankey/sankeyLayout.ts b/src/chart/sankey/sankeyLayout.ts index 0ae24df1c5..410ca7dfbc 100644 --- a/src/chart/sankey/sankeyLayout.ts +++ b/src/chart/sankey/sankeyLayout.ts @@ -33,7 +33,7 @@ export default function sankeyLayout(ecModel: GlobalModel, api: ExtensionAPI) { const nodeWidth = seriesModel.get('nodeWidth'); const nodeGap = seriesModel.get('nodeGap'); - const layoutInfo = getViewRect(seriesModel, api); + const layoutInfo = layout.getViewRect(seriesModel, api); seriesModel.layoutInfo = layoutInfo; @@ -61,18 +61,6 @@ export default function sankeyLayout(ecModel: GlobalModel, api: ExtensionAPI) { }); } -/** - * Get the layout position of the whole view - */ -function getViewRect(seriesModel: SankeySeriesModel, api: ExtensionAPI) { - return layout.getLayoutRect( - seriesModel.getBoxLayoutParams(), { - width: api.getWidth(), - height: api.getHeight() - } - ); -} - function layoutSankey( nodes: GraphNode[], edges: GraphEdge[], diff --git a/src/chart/tree/layoutHelper.ts b/src/chart/tree/layoutHelper.ts index 2aada604ec..04eac1272e 100644 --- a/src/chart/tree/layoutHelper.ts +++ b/src/chart/tree/layoutHelper.ts @@ -33,10 +33,7 @@ * the tree. */ -import * as layout from '../../util/layout'; import { TreeNode } from '../../data/Tree'; -import TreeSeriesModel from './TreeSeries'; -import ExtensionAPI from '../../core/ExtensionAPI'; interface HierNode { defaultAncestor: TreeLayoutNode, @@ -164,18 +161,6 @@ export function radialCoordinate(rad: number, r: number) { }; } -/** - * Get the layout position of the whole view. - */ -export function getViewRect(seriesModel: TreeSeriesModel, api: ExtensionAPI) { - return layout.getLayoutRect( - seriesModel.getBoxLayoutParams(), { - width: api.getWidth(), - height: api.getHeight() - } - ); -} - /** * All other shifts, applied to the smaller subtrees between w- and w+, are * performed by this function. diff --git a/src/chart/tree/treeLayout.ts b/src/chart/tree/treeLayout.ts index d614347e2f..0f1fe87df5 100644 --- a/src/chart/tree/treeLayout.ts +++ b/src/chart/tree/treeLayout.ts @@ -27,12 +27,12 @@ import { secondWalk, separation as sep, radialCoordinate, - getViewRect, TreeLayoutNode } from './layoutHelper'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import TreeSeriesModel from './TreeSeries'; +import { getViewRect } from '../../util/layout'; export default function treeLayout(ecModel: GlobalModel, api: ExtensionAPI) { ecModel.eachSeriesByType('tree', function (seriesModel: TreeSeriesModel) { diff --git a/src/component/axis/AngleAxisView.ts b/src/component/axis/AngleAxisView.ts index 23f018a50a..64590061e3 100644 --- a/src/component/axis/AngleAxisView.ts +++ b/src/component/axis/AngleAxisView.ts @@ -93,7 +93,7 @@ class AngleAxisView extends AxisView { const polar = angleAxis.polar; const radiusExtent = polar.getRadiusAxis().getExtent(); - const ticksAngles = angleAxis.getTicksCoords(); + const ticksAngles = angleAxis.getTicksCoords({breakTicks: 'none'}); const minorTickAngles = angleAxis.getMinorTicksCoords(); const labels = zrUtil.map(angleAxis.getViewLabels(), function (labelItem: TickLabel) { diff --git a/src/component/axis/AxisBuilder.ts b/src/component/axis/AxisBuilder.ts index 06ba6cc0d5..813f7d5c06 100644 --- a/src/component/axis/AxisBuilder.ts +++ b/src/component/axis/AxisBuilder.ts @@ -18,7 +18,7 @@ */ import { - retrieve, defaults, extend, each, isObject, map, isString, isNumber, isFunction, retrieve2 + retrieve, defaults, extend, each, isObject, map, isString, isNumber, isFunction, retrieve2, } from 'zrender/src/core/util'; import * as graphic from '../../util/graphic'; import {getECData} from '../../util/innerStore'; @@ -30,14 +30,26 @@ import * as matrixUtil from 'zrender/src/core/matrix'; import {applyTransform as v2ApplyTransform} from 'zrender/src/core/vector'; import {isNameLocationCenter, shouldShowAllLabels} from '../../coord/axisHelper'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; -import { ZRTextVerticalAlign, ZRTextAlign, ECElement, ColorString } from '../../util/types'; +import { + ZRTextVerticalAlign, ZRTextAlign, ECElement, ColorString, + VisualAxisBreak, + ParsedAxisBreak, + LabelMarginType, + LabelExtendedText, +} from '../../util/types'; import { AxisBaseOption } from '../../coord/axisCommonTypes'; import type Element from 'zrender/src/Element'; -import { PathStyleProps } from 'zrender/src/graphic/Path'; +import { PathProps, PathStyleProps } from 'zrender/src/graphic/Path'; import OrdinalScale from '../../scale/Ordinal'; -import { prepareLayoutList, hideOverlap } from '../../label/labelLayoutHelper'; +import { + prepareLayoutList, hideOverlap, detectAxisLabelPairIntersection, +} from '../../label/labelLayoutHelper'; import ExtensionAPI from '../../core/ExtensionAPI'; import CartesianAxisModel from '../../coord/cartesian/AxisModel'; +import { makeInner } from '../../util/model'; +import { getAxisBreakHelper } from './axisBreakHelper'; +import { AXIS_BREAK_EXPAND_ACTION_TYPE, BaseAxisBreakPayload } from './axisAction'; +import { getScaleBreakHelper } from '../../scale/break'; const PI = Math.PI; @@ -52,8 +64,11 @@ type AxisEventData = { value?: string | number dataIndex?: number tickIndex?: number - breakStart?: number - breakEnd?: number +} & { + break?: { + start: ParsedAxisBreak['vmin'], + end: ParsedAxisBreak['vmax'], + } } & { [key in AxisIndexKey]?: number }; @@ -63,8 +78,18 @@ type AxisLabelText = graphic.Text & { __truncatedText: string } & ECElement; +const getLabelInner = makeInner<{ + break: VisualAxisBreak; +}, graphic.Text>(); + + export interface AxisBuilderCfg { position?: number[] + /** + * In radian. This is to be applied to axis transitionGroup directly. + * rotation 0 means an axis towards screen-right. + * rotation Math.PI/4 means an axis towards screen-top-right. + */ rotation?: number /** * Used when nameLocation is 'middle' or 'center'. @@ -252,7 +277,7 @@ interface AxisElementsBuilder { const builders: Record<'axisLine' | 'axisTickLabel' | 'axisName', AxisElementsBuilder> = { - axisLine(opt, axisModel, group, transformGroup) { + axisLine(opt, axisModel, group, transformGroup, api) { let shown = axisModel.get(['axisLine', 'show']); if (shown === 'auto' && opt.handleAutoShown) { @@ -279,22 +304,29 @@ const builders: Record<'axisLine' | 'axisTickLabel' | 'axisName', AxisElementsBu }, axisModel.getModel(['axisLine', 'lineStyle']).getLineStyle() ); - - const line = new graphic.Line({ - shape: { - x1: pt1[0], - y1: pt1[1], - x2: pt2[0], - y2: pt2[1] - }, - style: lineStyle, + const pathBaseProp: PathProps = { strokeContainThreshold: opt.strokeContainThreshold || 5, silent: true, - z2: 1 - }); - graphic.subPixelOptimizeLine(line.shape, line.style.lineWidth); - line.anid = 'line'; - group.add(line); + z2: 1, + style: lineStyle, + }; + + if (axisModel.get(['axisLine', 'breakLine']) && axisModel.axis.scale.hasBreaks()) { + getAxisBreakHelper()!.buildAxisBreakLine(axisModel, group, transformGroup, pathBaseProp); + } + else { + const line = new graphic.Line(extend({ + shape: { + x1: pt1[0], + y1: pt1[1], + x2: pt2[0], + y2: pt2[1] + }, + }, pathBaseProp)); + graphic.subPixelOptimizeLine(line.shape, line.style.lineWidth); + line.anid = 'line'; + group.add(line); + } let arrows = axisModel.get(['axisLine', 'symbol']); @@ -353,31 +385,45 @@ const builders: Record<'axisLine' | 'axisTickLabel' | 'axisName', AxisElementsBu } }, - // @ts-ignore axisTickLabel(opt, axisModel, group, transformGroup, api) { const ticksEls = buildAxisMajorTicks(group, transformGroup, axisModel, opt); const labelEls = buildAxisLabel(group, transformGroup, axisModel, opt, api); - fixMinMaxLabelShow(axisModel, labelEls, ticksEls); + adjustBreakLabels(axisModel, opt.rotation, labelEls); + + const shouldHideOverlap = axisModel.get(['axisLabel', 'hideOverlap']); + + fixMinMaxLabelShow(opt, axisModel, labelEls, ticksEls, shouldHideOverlap); buildAxisMinorTicks(group, transformGroup, axisModel, opt.tickDirection); // This bit fixes the label overlap issue for the time chart. // See https://github.com/apache/echarts/issues/14266 for more. - if (axisModel.get(['axisLabel', 'hideOverlap'])) { - const labelList = prepareLayoutList(map(labelEls, label => ({ - label, - priority: label.z2, - defaultAttr: { - ignore: label.ignore + if (shouldHideOverlap) { + let priorityBoundary = 0; + each(labelEls, label => { + label.z2 > priorityBoundary && (priorityBoundary = label.z2); + }); + const labelList = prepareLayoutList(map(labelEls, label => { + let priority = label.z2; + if (getLabelInner(label).break) { + // Make break labels be highest priority. + priority += priorityBoundary; } - }))); + return { + label, + priority, + defaultAttr: { + ignore: label.ignore + }, + }; + })); hideOverlap(labelList); } }, - axisName(opt, axisModel, group, transformGroup) { + axisName(opt, axisModel, group, transformGroup, api) { const name = retrieve(opt.axisName, axisModel.get('name')); if (!name) { @@ -525,9 +571,11 @@ function endTextLayout( } function fixMinMaxLabelShow( + opt: AxisBuilderCfg, axisModel: AxisBaseModel, labelEls: graphic.Text[], - tickEls: graphic.Line[] + tickEls: graphic.Line[], + shouldHideOverlap: boolean ) { if (shouldShowAllLabels(axisModel.axis)) { return; @@ -555,11 +603,22 @@ function fixMinMaxLabelShow( const lastTick = tickEls[tickEls.length - 1]; const prevTick = tickEls[tickEls.length - 2]; + // In most fonts the glyph does not reach the boundary of the bouding rect. + // This is needed to avoid too aggressive to hide two elements that meet at the edge + // due to compact layout by the same bounding rect or OBB. + const touchThreshold = 0.05; + // `!hideOverlap` means the visual touch between adjacent labels are accepted, + // thus the "hide min/max label" should be conservative, since the space is sufficient + // in this case. And this strategy is also for backward compatibility. + const ignoreTextMargin = !shouldHideOverlap; + if (showMinLabel === false) { ignoreEl(firstLabel); ignoreEl(firstTick); } - else if (isTwoLabelOverlapped(firstLabel, nextLabel)) { + else if (detectAxisLabelPairIntersection( + opt.rotation, [firstLabel, nextLabel], touchThreshold, ignoreTextMargin + )) { if (showMinLabel) { ignoreEl(nextLabel); ignoreEl(nextTick); @@ -574,7 +633,9 @@ function fixMinMaxLabelShow( ignoreEl(lastLabel); ignoreEl(lastTick); } - else if (isTwoLabelOverlapped(prevLabel, lastLabel)) { + else if (detectAxisLabelPairIntersection( + opt.rotation, [prevLabel, lastLabel], touchThreshold, ignoreTextMargin + )) { if (showMaxLabel) { ignoreEl(prevLabel); ignoreEl(prevTick); @@ -590,30 +651,6 @@ function ignoreEl(el: Element) { el && (el.ignore = true); } -function isTwoLabelOverlapped( - current: graphic.Text, - next: graphic.Text -) { - // current and next has the same rotation. - const firstRect = current && current.getBoundingRect().clone(); - const nextRect = next && next.getBoundingRect().clone(); - - if (!firstRect || !nextRect) { - return; - } - - // When checking intersect of two rotated labels, we use mRotationBack - // to avoid that boundingRect is enlarge when using `boundingRect.applyTransform`. - const mRotationBack = matrixUtil.identity([]); - matrixUtil.rotate(mRotationBack, mRotationBack, -current.rotation); - - firstRect.applyTransform(matrixUtil.mul([], mRotationBack, current.getLocalTransform())); - nextRect.applyTransform(matrixUtil.mul([], mRotationBack, next.getLocalTransform())); - - return firstRect.intersect(nextRect); -} - - function createTicks( ticksCoords: TickCoord[], tickTransform: matrixUtil.MatrixArray, @@ -741,12 +778,12 @@ function buildAxisLabel( axisModel: AxisBaseModel, opt: AxisBuilderCfg, api: ExtensionAPI -) { +): graphic.Text[] { const axis = axisModel.axis; const show = retrieve(opt.axisLabelShow, axisModel.get(['axisLabel', 'show'])); if (!show || axis.scale.isBlank()) { - return; + return []; } const labelModel = axisModel.getModel('axisLabel'); @@ -815,7 +852,7 @@ function buildAxisLabel( y: opt.labelOffset + opt.labelDirection * labelMargin, rotation: labelLayout.rotation, silent: silent, - z2: 10 + (labelItem.level || 0), + z2: 10 + (labelItem.time?.level || 0), style: createTextStyle(itemLabelModel, { text: formattedLabel, align: index === 0 @@ -840,11 +877,16 @@ function buildAxisLabel( : tickValue, index ) - : textColor as string + : textColor as string, + margin: itemLabelModel.get('textMargin', true), }) }); + (textEl as LabelExtendedText).__marginType = LabelMarginType.textMargin; + textEl.anid = 'label_' + tickValue; + getLabelInner(textEl).break = labelItem.break; + graphic.setTooltipConfig({ el: textEl, componentModel: axisModel, @@ -862,23 +904,21 @@ function buildAxisLabel( eventData.targetType = 'axisLabel'; eventData.value = rawLabel; eventData.tickIndex = index; - eventData.breakStart = labelItem.breakStart; - eventData.breakEnd = labelItem.breakEnd; + if (labelItem.break) { + eventData.break = { + // type: labelItem.break.type, + start: labelItem.break.parsedBreak.vmin, + end: labelItem.break.parsedBreak.vmax, + }; + } if (axis.type === 'category') { eventData.dataIndex = tickValue; } getECData(textEl).eventData = eventData; - if (labelItem.breakStart != null) { - textEl.on('click', params => { - axis.scale.expandBreak(labelItem.breakStart, labelItem.breakEnd); - api.dispatchAction({ - type: 'axisBreakExpand', - breakStart: labelItem.breakStart, - breakEnd: labelItem.breakEnd, - }); - }); + if (labelItem.break) { + addBreakEventHandler(axisModel, api, textEl, labelItem.break); } } @@ -896,5 +936,41 @@ function buildAxisLabel( return labelEls; } +function addBreakEventHandler( + axisModel: AxisBaseModel, + api: ExtensionAPI, + textEl: graphic.Text, + visualBreak: VisualAxisBreak +): void { + textEl.on('click', params => { + const payload: BaseAxisBreakPayload = { + type: AXIS_BREAK_EXPAND_ACTION_TYPE, + breaks: [{ + start: visualBreak.parsedBreak.breakOption.start, + end: visualBreak.parsedBreak.breakOption.end, + }] + }; + payload[`${axisModel.axis.dim}AxisIndex`] = axisModel.componentIndex; + api.dispatchAction(payload); + }); +} + +function adjustBreakLabels( + axisModel: AxisBaseModel, + axisRotation: AxisBuilderCfg['rotation'], + labelEls: graphic.Text[] +): void { + const scaleBreakHelper = getScaleBreakHelper(); + if (!scaleBreakHelper) { + return; + } + const breakLabelPairs = scaleBreakHelper.retrieveAxisBreakPairs(labelEls, el => getLabelInner(el).break); + const moveOverlap = axisModel.get(['breakLabelLayout', 'moveOverlap'], true); + if (moveOverlap === true || moveOverlap === 'auto') { + each(breakLabelPairs, pair => + getAxisBreakHelper()!.adjustBreakLabelPair(axisModel.axis.inverse, axisRotation, pair) + ); + } +} export default AxisBuilder; diff --git a/src/component/axis/CartesianAxisView.ts b/src/component/axis/CartesianAxisView.ts index cc0c974426..2e44150bf0 100644 --- a/src/component/axis/CartesianAxisView.ts +++ b/src/component/axis/CartesianAxisView.ts @@ -29,7 +29,7 @@ import CartesianAxisModel from '../../coord/cartesian/AxisModel'; import GridModel from '../../coord/cartesian/GridModel'; import { Payload } from '../../util/types'; import { isIntervalOrLogScale } from '../../scale/helper'; -import { rectCoordBuildBreakAxis } from './axisBreakHelper'; +import { getAxisBreakHelper } from './axisBreakHelper'; const axisBuilderAttrs = [ 'axisLine', 'axisTickLabel', 'axisName' @@ -120,7 +120,7 @@ interface AxisElementBuilder { const axisElementBuilders: Record = { - splitLine(axisView, axisGroup, axisModel, gridModel) { + splitLine(axisView, axisGroup, axisModel, gridModel, api) { const axis = axisModel.axis; if (axis.scale.isBlank()) { @@ -141,7 +141,9 @@ const axisElementBuilders: Record = { - splitLine(axisView, group, axisGroup, axisModel) { + splitLine(axisView, group, axisGroup, axisModel, api) { const axis = axisModel.axis; if (axis.scale.isBlank()) { @@ -103,7 +110,9 @@ const axisElementBuilders: Record; +} { + let breaks: AxisBreakChangedEventBreak[] = []; + each(actionResultBatch, actionResult => { + breaks = breaks.concat(actionResult.eventBreaks); + }); + return { + eventContent: {breaks} + }; +} + +export function registerAction(registers: EChartsExtensionInstallRegisters) { + registers.registerAction(expandAxisBreakActionInfo, actionHandler); + registers.registerAction(collapseAxisBreakActionInfo, actionHandler); + registers.registerAction(toggleAxisBreakActionInfo, actionHandler); + + function actionHandler(payload: BaseAxisBreakPayload, ecModel: GlobalModel) { + const eventBreaks: AxisBreakChangedEventBreak[] = []; + const finderResult = parseFinder(ecModel, payload); + + function dealUpdate(modelProp: string, indexProp: string) { + each(finderResult[modelProp], (axisModel: AxisBaseModel) => { + const result = axisModel.updateAxisBreaks(payload); + each(result.breaks, item => { + eventBreaks.push( + defaults({[indexProp]: axisModel.componentIndex}, item) + ); + }); + }); + } + + dealUpdate('xAxisModels', 'xAxisIndex'); + dealUpdate('yAxisModels', 'yAxisIndex'); + dealUpdate('singleAxisModels', 'singleAxisIndex'); + + return {eventBreaks}; + } +} diff --git a/src/component/axis/axisBreakHelper.ts b/src/component/axis/axisBreakHelper.ts index 92782a4f78..db8b94a56e 100644 --- a/src/component/axis/axisBreakHelper.ts +++ b/src/component/axis/axisBreakHelper.ts @@ -17,189 +17,74 @@ * under the License. */ -import * as graphic from '../../util/graphic'; -import GridModel from '../../coord/cartesian/GridModel'; +import type * as graphic from '../../util/graphic'; import type SingleAxisModel from '../../coord/single/AxisModel'; import type CartesianAxisModel from '../../coord/cartesian/AxisModel'; -import { AxisBaseModel } from '../../coord/AxisBaseModel'; -import ExtensionAPI from '../../core/ExtensionAPI'; -import { ExtendedElementProps } from '../../core/ExtendedElement'; - -export function rectCoordBuildBreakAxis( - axisGroup: graphic.Group, - axisModel: SingleAxisModel | CartesianAxisModel, - gridModel: GridModel | SingleAxisModel, - api: ExtensionAPI -): graphic.Group { - const axis = axisModel.axis; - const isOrdinal = axis.scale.type === 'ordinal'; - const axisMax = axis.scale.getExtent()[1]; - - if (axis.scale.isBlank()) { - return null; - } - - const breakAreaModel = (axisModel as AxisBaseModel).getModel('breakArea'); - const breaks = axis.scale.getBreaks(); - if (!breaks.length) { - return null; - } - const zigzagAmplitude = breakAreaModel.get('zigzagAmplitude'); - const zigzagMinSpan = breakAreaModel.get('zigzagMinSpan'); - const zigzagMaxSpan = breakAreaModel.get('zigzagMaxSpan'); - const expandOnClick = breakAreaModel.get('expandOnClick'); - - const gridRect = gridModel.coordinateSystem.getRect(); - 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 graphic.Group({ - ignoreModelZ: true - } as ExtendedElementProps); - - const isHorizontal = axis.isHorizontal(); - const clipEl = new graphic.Rect({ - shape: { - x: gridRect.x - (isHorizontal ? zigzagAmplitude : 0), - y: gridRect.y - (isHorizontal ? 0 : zigzagAmplitude), - width: gridRect.width + (isHorizontal ? zigzagAmplitude * 2 : 0), - height: gridRect.height + (isHorizontal ? 0 : zigzagAmplitude * 2) - } - }); - - for (let i = 0; i < breaks.length; i++) { - const brk = breaks[i]; - if (brk.isExpanded) { - continue; - } - - // Even if brk.gap is 0, we should also draw the breakArea because - // border is sometimes required to be visible (as a line) - let startCoord; - let endCoord; - const end = brk.end - (isOrdinal ? 1 : 0); - const isEndBreak = end >= axisMax; - if (isEndBreak) { - // The break area is bigger than the max value - startCoord = axis.toGlobalCoord( - axis.dataToCoordWithBreaks(axisMax, false) - ); - endCoord = startCoord; - } - else { - startCoord = axis.toGlobalCoord( - axis.dataToCoordWithBreaks(brk.start, false) - ); - endCoord = axis.toGlobalCoord( - axis.dataToCoordWithBreaks(end, false) - ); - } - const breakGroup = new graphic.Group(); - - addZigzagShapes( - breakGroup, - clipEl, - startCoord, - endCoord, - isHorizontal, - brk.gap === 0 || brk.end === brk.start - ); - - if (expandOnClick) { - breakGroup.on('click', () => { - axis.scale.expandBreak(brk.start, brk.end); - api.dispatchAction({ - type: 'axisBreakExpand', - breakStart: brk.start, - breakEnd: brk.end, - }); - }); - } - - group.add(breakGroup); - } - axisGroup.add(group); - - function addZigzagShapes( - breakGroup: graphic.Group, - clipEl: graphic.Path, - startCoord: number, - endCoord: number, - isHorizontal: boolean, - isGapZero: boolean - ) { - const polylineStyle = { - stroke: borderColor, - lineWidth: borderWidth, - lineDash: borderType, - fill: 'none' - }; - const x = isHorizontal ? startCoord : gridRect.x; - const y = isHorizontal ? gridRect.y : startCoord; - const width = isHorizontal ? endCoord - startCoord : gridRect.width; - const height = isHorizontal ? gridRect.height : endCoord - startCoord; - - const pointsA = []; - const pointsB = []; - let current = isHorizontal ? y : x; - const max = isHorizontal ? y + height : x + width; - let isSwap = true; - - while (current <= max + zigzagMaxSpan) { - if (isHorizontal) { - pointsA.push([x + (isSwap ? -zigzagAmplitude : zigzagAmplitude), current]); - // unshift for bottom to reverse order - pointsB.unshift([x + width - (!isSwap ? -zigzagAmplitude : zigzagAmplitude), current]); - } - else { - pointsA.push([current, y + (isSwap ? -zigzagAmplitude : zigzagAmplitude)]); - // unshift for bottom to reverse order - pointsB.unshift([current, y + height - (!isSwap ? -zigzagAmplitude : zigzagAmplitude)]); +import type { AxisBaseModel } from '../../coord/AxisBaseModel'; +import type ExtensionAPI from '../../core/ExtensionAPI'; +import type CartesianAxisView from './CartesianAxisView'; +import type { PathProps } from 'zrender/src/graphic/Path'; +import type SingleAxisView from './SingleAxisView'; +import type { AxisBuilderCfg } from './AxisBuilder'; +import type { BaseAxisBreakPayload } from './axisAction'; +import type { ComponentModel } from '../../echarts.all'; +import type { AxisBaseOption } from '../../coord/axisCommonTypes'; +import type { AxisBreakOptionIdentifierInAxis, NullUndefined } from '../../util/types'; + +/** + * @file The fasade of axis break view and mode. + * Separate the impl to reduce code size. + * + * @caution + * Must not import `axis/breakImpl.ts` directly or indirctly. + * Must not implement anything in this file. + */ + +export type AxisBreakHelper = { + adjustBreakLabelPair( + axisInverse: boolean, + axisRotation: AxisBuilderCfg['rotation'], + labelPair: graphic.Text[], + ): void; + buildAxisBreakLine( + axisModel: AxisBaseModel, + group: graphic.Group, + transformGroup: graphic.Group, + pathBaseProp: PathProps, + ): void; + rectCoordBuildBreakAxis( + axisGroup: graphic.Group, + axisView: CartesianAxisView | SingleAxisView, + axisModel: CartesianAxisModel | SingleAxisModel, + coordSysRect: graphic.BoundingRect, + api: ExtensionAPI + ): void; + updateModelAxisBreak( + model: ComponentModel, + payload: BaseAxisBreakPayload + ): AxisBreakUpdateResult; +}; + +export type AxisBreakUpdateResult = { + breaks: ( + AxisBreakOptionIdentifierInAxis & { + isExpanded: boolean; + old: { // The old state in breaks. + isExpanded: boolean; } - current += Math.random() * (zigzagMaxSpan - zigzagMinSpan) + zigzagMinSpan; - isSwap = !isSwap; } + )[]; +}; - // Create two polylines and add them to the breakGroup - breakGroup.add(new graphic.Polyline({ - shape: { - points: pointsA - }, - style: polylineStyle, - clipPath: clipEl, - z: 100 - })); - /* 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 (!isGapZero) { - breakGroup.add(new graphic.Polyline({ - shape: { - points: pointsB - }, - style: polylineStyle, - clipPath: clipEl, - z: 100 - })); +let _impl: AxisBreakHelper = null; - // Creating the polygon that fills the area between the polylines - const polygonPoints = pointsA.concat(pointsB); - breakGroup.add(new graphic.Polygon({ - shape: { - points: polygonPoints - }, - style: { - fill: color, - opacity: itemStyle.opacity - }, - clipPath: clipEl, - z: 100 - })); - } +export function registerAxisBreakHelperImpl(impl: AxisBreakHelper): void { + if (!_impl) { + _impl = impl; } } + +export function getAxisBreakHelper(): AxisBreakHelper | NullUndefined { + return _impl; +} diff --git a/src/component/axis/axisBreakHelperImpl.ts b/src/component/axis/axisBreakHelperImpl.ts new file mode 100644 index 0000000000..2f9387f519 --- /dev/null +++ b/src/component/axis/axisBreakHelperImpl.ts @@ -0,0 +1,559 @@ +/* +* 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 * as graphic from '../../util/graphic'; +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 { detectAxisLabelPairIntersection } 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'; + +/** + * @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: graphic.Group, + axisView: CartesianAxisView | SingleAxisView, + axisModel: CartesianAxisModel | SingleAxisModel, + coordSysRect: graphic.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 + ); + 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 graphic.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 graphic.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: graphic.Group, + startCoord: number, + endCoord: number, + isAxisHorizontal: boolean, + trimmedBreak: ParsedAxisBreak + ) { + const polylineStyle = { + stroke: borderColor, + lineWidth: borderWidth, + lineDash: borderType, + fill: 'none' + }; + + const XY = ['x', 'y'] as const; + const WH = ['width', 'height'] as const; + + 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 graphic.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 graphic.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 graphic.Polygon({ + anid: `break_c_${anidSuffix}`, + shape: { + points: polygonPoints + }, + style: { + fill: color, + opacity: itemStyle.opacity + }, + z: zigzagZ + })); + } + } +} + +function buildAxisBreakLine( + axisModel: AxisBaseModel, + group: graphic.Group, + transformGroup: graphic.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 + ); + 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 graphic.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. + */ +function adjustBreakLabelPair( + axisInverse: boolean, + axisRotation: AxisBuilderCfg['rotation'], + labelPair: graphic.Text[], // Means [brk_min_label, brk_max_label] +): void { + + const intersection = detectAxisLabelPairIntersection( + // 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. + axisInverse ? axisRotation + Math.PI : axisRotation, + labelPair, + 0, + false + ); + if (!intersection) { + return; + } + + const WH = ['width', 'height'] as const; + const layoutPair = intersection.layoutPair; + const mtv = new graphic.Point(intersection.mtv.x, intersection.mtv.y); + + // 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 => matrixUtil.mul(matrixUtil.create(), axisStTrans, layout.transform) + ); + + function isParallelToAxis(whIdx: number): boolean { + // Assert label[0] and lable[1] has the same rotation, so only use [0]. + const localRect = layoutPair[0].localRect; + const labelVec0 = new graphic.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 graphic.Point(); + brkCenterSt.copy(labelPair[0]).add(labelPair[1]).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; + } + + graphic.Point.scaleAndAdd(labelPair[0], labelPair[0], mtv, -k); + graphic.Point.scaleAndAdd(labelPair[1], labelPair[1], mtv, 1 - k); +} + +function updateModelAxisBreak( + model: ComponentModel, + 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, + }); +} diff --git a/src/component/axis/axisSplitHelper.ts b/src/component/axis/axisSplitHelper.ts index 83af2d387a..7679bbd1e7 100644 --- a/src/component/axis/axisSplitHelper.ts +++ b/src/component/axis/axisSplitHelper.ts @@ -53,7 +53,9 @@ export function rectCoordAxisBuildSplitArea( const ticksCoords = axis.getTicksCoords({ tickModel: splitAreaModel, - clamp: true + clamp: true, + breakTicks: 'none', + pruneByBreak: 'preserve_extent_bound', }); if (!ticksCoords.length) { diff --git a/src/component/axis/install.ts b/src/component/axis/install.ts deleted file mode 100644 index eb193cd017..0000000000 --- a/src/component/axis/install.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { AxisBaseModel } from '../../coord/AxisBaseModel'; -import type { EChartsExtensionInstallRegisters } from '../../extension'; -import { - axisBreakActionInfo, - axisBreakRevertActionInfo -} from './axisAction'; - -let installed = false; -export function install(registers: EChartsExtensionInstallRegisters) { - if (installed) { - return; - } - installed = true; - - registers.registerAction(axisBreakActionInfo, function (payload, ecModel) { - const breaks = payload.breaks; - if (breaks && breaks.length > 0) { - ecModel.eachComponent( - {mainType: 'xAxis'}, - function (axisModel: AxisBaseModel) { - if (axisModel.axis) { - axisModel.axis.scale.expandBreaks(breaks); - } - } - ); - ecModel.eachComponent( - {mainType: 'yAxis'}, - function (axisModel: AxisBaseModel) { - if (axisModel.axis) { - axisModel.axis.scale.expandBreaks(breaks); - } - } - ); - } - }); - - registers.registerAction(axisBreakRevertActionInfo, function (payload, ecModel) { - ecModel.eachComponent( - {mainType: 'xAxis'}, - function (axisModel: AxisBaseModel) { - if (axisModel.axis) { - axisModel.axis.scale.revertBreaks(); - } - } - ); - ecModel.eachComponent( - {mainType: 'yAxis'}, - function (axisModel: AxisBaseModel) { - if (axisModel.axis) { - axisModel.axis.scale.revertBreaks(); - } - } - ); - }); -} diff --git a/src/component/axis/installBreak.ts b/src/component/axis/installBreak.ts new file mode 100644 index 0000000000..31757e2263 --- /dev/null +++ b/src/component/axis/installBreak.ts @@ -0,0 +1,30 @@ +/* +* 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 { EChartsExtensionInstallRegisters } from '../../extension'; +import { installScaleBreakHelper } from '../../scale/breakImpl'; +import { registerAction } from './axisAction'; +import { installAxisBreakHelper } from './axisBreakHelperImpl'; + +export function installAxisBreak(registers: EChartsExtensionInstallRegisters) { + registerAction(registers); + + installScaleBreakHelper(); + installAxisBreakHelper(); +} diff --git a/src/component/tooltip/TooltipView.ts b/src/component/tooltip/TooltipView.ts index d5bf5d8f21..0a92939757 100644 --- a/src/component/tooltip/TooltipView.ts +++ b/src/component/tooltip/TooltipView.ts @@ -545,6 +545,9 @@ class TooltipView extends ComponentView { if (!axisModel || axisValue == null) { return; } + // FIXME: when using `tooltip.trigger: 'axis'`, the precision of the axis value displayed in tooltip + // should match the original series values rather than using the default stretegy in Interval.ts + // (getPrecision(interval) + 2); otherwise it may cuase confusion. const axisValueLabel = axisPointerViewHelper.getValueLabel( axisValue, axisModel.axis, ecModel, axisItem.seriesDataIndices, diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index f68cd26f12..bb8c302b3a 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -17,20 +17,19 @@ * under the License. */ -import {each, map, filter, find} from 'zrender/src/core/util'; +import {each, map} from 'zrender/src/core/util'; import {linearMap, getPixelPrecision, round} from '../util/number'; import { createAxisTicks, createAxisLabels, calculateCategoryInterval } from './axisTickLabelBuilder'; -import Scale from '../scale/Scale'; -import { DimensionName, ScaleBreak, ScaleDataValue, ScaleTick } from '../util/types'; +import Scale, { ScaleGetTicksOpt } from '../scale/Scale'; +import { DimensionName, ScaleDataValue, ScaleTick } from '../util/types'; import OrdinalScale from '../scale/Ordinal'; import Model from '../model/Model'; import { AxisBaseOption, CategoryAxisBaseOption, OptionAxisType } from './axisCommonTypes'; import { AxisBaseModel } from './AxisBaseModel'; -import { getExtentSpanWithoutBreaks } from '../scale/helper'; const NORMALIZED_EXTENT = [0, 1] as [number, number]; @@ -88,7 +87,7 @@ class Axis { * If axis extent contain given data */ containData(data: ScaleDataValue): boolean { - return this.scale.contain(data); + return this.scale.contain(this.scale.parse(data)); } /** @@ -121,16 +120,9 @@ class Axis { * Convert data to coord. Data is the rank if it has an ordinal scale */ dataToCoord(data: ScaleDataValue, clamp?: boolean): number { - return this.dataToCoordWithBreaks(data, clamp); - } - - dataToCoordWithBreaks( - data: ScaleDataValue, - clamp?: boolean - ): number { let extent = this._extent; const scale = this.scale; - data = scale.normalize(data); + data = scale.normalize(scale.parse(data)); if (this.onBand && scale.type === 'ordinal') { extent = extent.slice() as [number, number]; @@ -176,16 +168,20 @@ class Axis { */ getTicksCoords(opt?: { tickModel?: Model, - clamp?: boolean + clamp?: boolean, + breakTicks?: ScaleGetTicksOpt['breakTicks'], + pruneByBreak?: ScaleGetTicksOpt['pruneByBreak'] }): TickCoord[] { opt = opt || {}; const tickModel = opt.tickModel || this.getTickModel(); - const result = createAxisTicks(this, tickModel as AxisBaseModel); + const result = createAxisTicks(this, tickModel as AxisBaseModel, { + breakTicks: opt.breakTicks, + pruneByBreak: opt.pruneByBreak, + }); const ticks = result.ticks; - const breaks = this.scale.getBreaks(); - const ticksCoords = filter(map(ticks, function (tickVal) { + const ticksCoords = map(ticks, function (tickVal) { return { coord: this.dataToCoord( this.scale.type === 'ordinal' @@ -194,14 +190,12 @@ class Axis { ), tickValue: tickVal }; - }, this), coords => { - return !find(breaks, brk => brk.start === coords.tickValue - 1); - }); + }, this); const alignWithLabel = tickModel.get('alignWithLabel'); fixOnBandTicksCoords( - this, ticksCoords, breaks, alignWithLabel, opt.clamp + this, ticksCoords, alignWithLabel, opt.clamp ); return ticksCoords; @@ -257,8 +251,7 @@ class Axis { const axisExtent = this._extent; const dataExtent = this.scale.getExtent(); - let len = getExtentSpanWithoutBreaks(dataExtent, this.scale.getBreaks()) - + (this.onBand ? 1 : 0); + let len = dataExtent[1] - dataExtent[0] + (this.onBand ? 1 : 0); // Fix #2728, avoid NaN when only one data. len === 0 && (len = 1); @@ -285,8 +278,8 @@ class Axis { function fixExtentWithBands(extent: [number, number], nTick: number): void { const size = extent[1] - extent[0]; - const len = getExtentSpanWithoutBreaks([0, nTick - 1], []) + 1; - const margin = size / Math.max(len, 1) / 2; + const len = nTick; + const margin = size / len / 2; extent[0] += margin; extent[1] -= margin; } @@ -303,7 +296,6 @@ function fixExtentWithBands(extent: [number, number], nTick: number): void { function fixOnBandTicksCoords( axis: Axis, ticksCoords: TickCoord[], - breaks: ScaleBreak[], alignWithLabel: boolean, clamp: boolean ) { diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index c46d78bb52..504b3b7475 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -20,18 +20,16 @@ import { NumericAxisBaseOptionCommon } from './axisCommonTypes'; import { getPrecisionSafe, round } from '../util/number'; import IntervalScale from '../scale/Interval'; -import { getScaleExtent } from './axisHelper'; +import { getScaleExtent, retrieveAxisBreaksOption } from './axisHelper'; import { AxisBaseModel } from './AxisBaseModel'; import LogScale from '../scale/Log'; import { warn } from '../util/log'; -import { increaseInterval, isValueNice } from '../scale/helper'; - -const mathLog = Math.log; +import { logTransform, increaseInterval, isValueNice } from '../scale/helper'; export function alignScaleTicks( scale: IntervalScale | LogScale, - axisModel: AxisBaseModel>, + axisModel: AxisBaseModel>, alignToScale: IntervalScale | LogScale ) { @@ -42,7 +40,7 @@ export function alignScaleTicks( // So if we use the method of InternalScale to set/get these data. // It process the exponent value, which is linear and what we want here. const alignToTicks = intervalScaleProto.getTicks.call(alignToScale); - const alignToNicedTicks = intervalScaleProto.getTicks.call(alignToScale, true); + const alignToNicedTicks = intervalScaleProto.getTicks.call(alignToScale, {expandToNicedExtent: true}); const alignToSplitNumber = alignToTicks.length - 1; const alignToInterval = intervalScaleProto.getInterval.call(alignToScale); @@ -52,10 +50,9 @@ export function alignScaleTicks( const isMaxFixed = scaleExtent.fixMax; if (scale.type === 'log') { - const logBase = mathLog((scale as LogScale).base); - rawExtent = [mathLog(rawExtent[0]) / logBase, mathLog(rawExtent[1]) / logBase]; + rawExtent = logTransform((scale as LogScale).base, rawExtent); } - + scale.setBreaksFromOption(retrieveAxisBreaksOption(axisModel)); scale.setExtent(rawExtent[0], rawExtent[1]); scale.calcNiceExtent({ splitNumber: alignToSplitNumber, diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 99085d09bc..05e9c4e68c 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -21,9 +21,13 @@ import { TextAlign, TextVerticalAlign } from 'zrender/src/core/types'; import { TextCommonOption, LineStyleOption, OrdinalRawValue, ZRColor, AreaStyleOption, ComponentOption, ColorString, - AnimationOptionMixin, Dictionary, ScaleDataValue, CommonAxisPointerOption, ScaleBreak, ItemStyleOption + AnimationOptionMixin, Dictionary, ScaleDataValue, CommonAxisPointerOption, AxisBreakOption, ItemStyleOption, + NullUndefined, + AxisLabelFormatterExtraBreakPart, + TimeScaleTick, } from '../util/types'; import { TextStyleProps } from 'zrender/src/graphic/Text'; +import type { PrimaryTimeUnit } from '../util/time'; export const AXIS_TYPES = {value: 1, category: 1, time: 1, log: 1} as const; @@ -82,14 +86,18 @@ export interface AxisBaseOptionCommon extends ComponentOption, max?: ScaleDataValue | 'dataMax' | ((extent: {min: number, max: number}) => ScaleDataValue); startValue?: number; - breaks?: ScaleBreak[]; + breaks?: AxisBreakOption[]; breakArea?: { - show?: boolean, - itemStyle?: ItemStyleOption, - zigzagAmplitude?: number, - zigzagMinSpan?: number, - zigzagMaxSpan?: number, - expandOnClick?: boolean + show?: boolean; + itemStyle?: ItemStyleOption; + zigzagAmplitude?: number; + zigzagMinSpan?: number; + zigzagMaxSpan?: number; + zigzagZ: number; + expandOnClick?: boolean; + }; + breakLabelLayout?: { + moveOverlap?: 'auto' | boolean; } } @@ -130,9 +138,7 @@ export interface NumericAxisBaseOptionCommon extends AxisBaseOptionCommon { export interface CategoryAxisBaseOption extends AxisBaseOptionCommon { type?: 'category'; boundaryGap?: boolean - axisLabel?: AxisLabelOption<'category'> & { - interval?: 'auto' | number | ((index: number, value: string) => boolean) - }; + axisLabel?: AxisLabelOption<'category'>; data?: (OrdinalRawValue | { value: OrdinalRawValue; textStyle?: TextCommonOption; @@ -187,6 +193,8 @@ interface AxisLineOption { symbolSize?: number[], symbolOffset?: string | number | (string | number)[], lineStyle?: LineStyleOption, + // Display line break effect when axis.breaks is specified. + breakLine?: boolean, } interface AxisTickOption { @@ -199,25 +207,52 @@ interface AxisTickOption { customValues?: (number | string | Date)[] } -type AxisLabelValueFormatter = (value: number, index: number) => string; -type AxisLabelCategoryFormatter = (value: string, index: number) => string; +export type AxisLabelValueFormatter = ( + value: number, + index: number, + extra: AxisLabelFormatterExtraParams | NullUndefined, +) => string; +export type AxisLabelCategoryFormatter = ( + value: string, + index: number, + extra: NullUndefined, +) => string; +export type AxisLabelTimeFormatter = ( + value: number, + index: number, + extra: TimeAxisLabelFormatterExtraParams, +) => string; -// export type AxisLabelFormatterOption = string | ((value: OrdinalRawValue | number, index: number) => string); -type TimeAxisLabelUnitFormatter = AxisLabelValueFormatter | string[] | string; +export type AxisLabelFormatterExtraParams = {/* others if any */} & AxisLabelFormatterExtraBreakPart; +export type TimeAxisLabelFormatterExtraParams = { + time: TimeScaleTick['time'], + /** + * @deprecated Refactored to `time.level`, and keep it for backward compat, + * although `level` is never published in doc since it is introduced. + */ + level: number, +} & AxisLabelFormatterExtraParams; + +export type TimeAxisLabelLeveledFormatterOption = string[] | string; +export type TimeAxisLabelFormatterUpperDictionaryOption = + {[key in PrimaryTimeUnit]?: TimeAxisLabelLeveledFormatterOption}; +/** + * @see {parseTimeAxisLabelFormatterDictionary} + */ +export type TimeAxisLabelFormatterDictionaryOption = + {[key in PrimaryTimeUnit]?: TimeAxisLabelLeveledFormatterOption | TimeAxisLabelFormatterUpperDictionaryOption}; export type TimeAxisLabelFormatterOption = string - | ((value: number, index: number, extra: {level: number, breakStart: number, breakEnd: number}) => string) - | { - year?: TimeAxisLabelUnitFormatter, - month?: TimeAxisLabelUnitFormatter, - week?: TimeAxisLabelUnitFormatter, - day?: TimeAxisLabelUnitFormatter, - hour?: TimeAxisLabelUnitFormatter, - minute?: TimeAxisLabelUnitFormatter, - second?: TimeAxisLabelUnitFormatter, - millisecond?: TimeAxisLabelUnitFormatter, - inherit?: boolean - }; + | AxisLabelTimeFormatter + | TimeAxisLabelFormatterDictionaryOption; + +export type TimeAxisLabelFormatterParsed = string + | AxisLabelTimeFormatter + | TimeAxisLabelFormatterDictionary; + +// This is the parsed result from TimeAxisLabelFormatterDictionaryOption. +export type TimeAxisLabelFormatterDictionary = {[key in PrimaryTimeUnit]: TimeAxisLabelFormatterUpperDictionary}; +export type TimeAxisLabelFormatterUpperDictionary = {[key in PrimaryTimeUnit]: string[]}; type LabelFormatters = { value: AxisLabelValueFormatter | string @@ -226,20 +261,11 @@ type LabelFormatters = { time: TimeAxisLabelFormatterOption }; -export type AxisLabelBreakFormatter = ( - value: number | string, - index: number, - breakStart: number, - breakEnd: number, - breakGap: number -) => string; - interface AxisLabelBaseOption extends Omit { show?: boolean, // Whether axisLabel is inside the grid or outside the grid. inside?: boolean, rotate?: number, - breakFormatter?: AxisLabelBreakFormatter, // true | false | null/undefined (auto) showMinLabel?: boolean, // true | false | null/undefined (auto) @@ -252,7 +278,20 @@ interface AxisLabelBaseOption extends Omit { verticalAlignMinLabel?: TextVerticalAlign, // 'top' | 'middle' | 'bottom' | null/undefined (auto) verticalAlignMaxLabel?: TextVerticalAlign, + // The space between the axis and `[label.x, label.y]`. margin?: number, + /** + * The space around the axis label to escape from overlapping. + * Applied on the label local rect (rather than rotated enlarged rect) + * Follow the format defined by `format.ts#normalizeCssArray`. + * Introduce the name `textMargin` rather than reuse the existing names to avoid breaking change: + * - `axisLabel.margin` historically has been used to indicate the gap between the axis and label.x/.y. + * - `label.minMargin` conveys the same meaning as this `textMargin` but has a different nuance, + * it works like CSS margin collapse (gap = label1.minMargin/2 + label2.minMargin/2), + * and is applied on the rotated bounding rect rather than the original local rect. + * @see {LabelMarginType} + */ + textMargin?: number | number[], rich?: Dictionary /** * If hide overlapping labels. @@ -265,6 +304,9 @@ interface AxisLabelBaseOption extends Omit { } interface AxisLabelOption extends AxisLabelBaseOption { formatter?: LabelFormatters[TType] + interval?: TType extends 'category' + ? ('auto' | number | ((index: number, value: string) => boolean)) + : unknown // Reserved but not used. } interface MinorTickOption { @@ -298,4 +340,4 @@ interface SplitAreaOption { } export type AxisBaseOption = ValueAxisBaseOption | LogAxisBaseOption - | CategoryAxisBaseOption | TimeAxisBaseOption | AxisBaseOptionCommon; + | CategoryAxisBaseOption | TimeAxisBaseOption; diff --git a/src/coord/axisDefault.ts b/src/coord/axisDefault.ts index 8e5f9b240d..24f80f8f7d 100644 --- a/src/coord/axisDefault.ts +++ b/src/coord/axisDefault.ts @@ -65,7 +65,8 @@ const defaultOption: AxisBaseOption = { }, // The arrow at both ends the the axis. symbol: ['none', 'none'], - symbolSize: [10, 15] + symbolSize: [10, 15], + breakLine: true, }, axisTick: { show: true, @@ -88,7 +89,13 @@ const defaultOption: AxisBaseOption = { showMaxLabel: null, margin: 8, // formatter: null, - fontSize: 12 + fontSize: 12, + // In scenarios like axis labels, when labels text's progression direction matches the label + // layout direction (e.g., when all letters are in a single line), extra start/end margin is + // needed to prevent the text from appearing visually joined. In the other case, when lables + // are stacked (e.g., having rotation or horizontal labels on yAxis), the layout needs to be + // compact, so NO extra top/bottom margin should be applied. + textMargin: [0, 3], // Empirical default value. }, splitLine: { show: true, @@ -120,7 +127,11 @@ const defaultOption: AxisBaseOption = { zigzagAmplitude: 4, zigzagMinSpan: 4, zigzagMaxSpan: 20, - expandOnClick: true + zigzagZ: 100, + expandOnClick: true, + }, + breakLabelLayout: { + moveOverlap: 'auto', } }; diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 5d7d0ff7a9..195dc06f0d 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -32,21 +32,26 @@ import TimeScale from '../scale/Time'; import Model from '../model/Model'; import { AxisBaseModel } from './AxisBaseModel'; import LogScale from '../scale/Log'; -import Axis from './Axis'; +import type Axis from './Axis'; import { AxisBaseOption, - AxisLabelBreakFormatter, CategoryAxisBaseOption, LogAxisBaseOption, TimeAxisLabelFormatterOption, - ValueAxisBaseOption + AxisBaseOptionCommon, + AxisLabelCategoryFormatter, + AxisLabelValueFormatter, + AxisLabelFormatterExtraParams, } from './axisCommonTypes'; import CartesianAxisModel, { CartesianAxisPosition, inverseCartesianAxisPositionMap } from './cartesian/AxisModel'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; -import { Dictionary, DimensionName, ScaleTick, TimeScaleTick } from '../util/types'; +import { Dictionary, DimensionName, ScaleTick } from '../util/types'; import { ensureScaleRawExtentInfo } from './scaleRawExtentInfo'; import Axis2D from './cartesian/Axis2D'; +import { parseTimeAxisLabelFormatter } from '../util/time'; +import { getScaleBreakHelper } from '../scale/break'; +import { error } from '../util/log'; type BarWidthAndOffset = ReturnType; @@ -168,6 +173,7 @@ export function niceScaleExtent( const interval = model.get('interval'); const isIntervalOrTime = scaleType === 'interval' || scaleType === 'time'; + scale.setBreaksFromOption(retrieveAxisBreaksOption(model)); scale.setExtent(extent[0], extent[1]); scale.calcNiceExtent({ splitNumber: splitNumber, @@ -206,13 +212,10 @@ export function createScaleByModel(model: AxisBaseModel, axisType?: string): Sca return new TimeScale({ locale: model.ecModel.getLocaleModel(), useUTC: model.ecModel.get('useUTC'), - breaks: model.get('breaks') }); default: // case 'value'/'interval', 'log', or others. - return new (Scale.getClass(axisType) || IntervalScale)({ - breaks: model.get('breaks') - }); + return new (Scale.getClass(axisType) || IntervalScale)(); } } } @@ -236,40 +239,25 @@ export function ifAxisCrossZero(axis: Axis) { * return: {string} label string. */ export function makeLabelFormatter(axis: Axis): (tick: ScaleTick, idx?: number) => string { - const labelModel = axis.getLabelModel() as Model; - const labelFormatter = labelModel.get('formatter'); - const breakLabelFormatter = labelModel.get('breakFormatter'); - const categoryTickStart = axis.type === 'category' ? axis.scale.getExtent()[0] : null; + const labelFormatter = axis.getLabelModel().get('formatter'); - if (axis.scale.type === 'time') { - return (function (tpl, breakTpl) { - return function (tick: ScaleTick, idx: number) { - const formatter = tick.breakStart == null || !breakTpl - ? tpl - : breakTpl as TimeAxisLabelFormatterOption; - const result = (axis.scale as TimeScale).getFormattedLabel(tick, idx, formatter); - return result; - }; - })(labelFormatter as TimeAxisLabelFormatterOption, breakLabelFormatter); + if (axis.type === 'time') { + const parsed = parseTimeAxisLabelFormatter(labelFormatter as TimeAxisLabelFormatterOption); + return function (tick: ScaleTick, idx: number) { + return (axis.scale as TimeScale).getFormattedLabel(tick, idx, parsed); + }; } else if (zrUtil.isString(labelFormatter)) { - return (function (tpl, breakLabelFormatter: AxisLabelBreakFormatter) { - return function (tick: ScaleTick, idx: number) { - if (tick.breakStart != null && breakLabelFormatter) { - return breakLabelFormatter(tick.value, idx, tick.breakStart, tick.breakEnd, tick.breakGap); - } - - // For category axis, get raw value; for numeric axis, - // get formatted label like '1,333,444'. - const label = axis.scale.getLabel(tick); - const text = tpl.replace('{value}', label != null ? label : ''); - - return text; - }; - })(labelFormatter, breakLabelFormatter); + return function (tick: ScaleTick) { + // For category axis, get raw value; for numeric axis, + // get formatted label like '1,333,444'. + const label = axis.scale.getLabel(tick); + const text = labelFormatter.replace('{value}', label != null ? label : ''); + return text; + }; } else if (zrUtil.isFunction(labelFormatter)) { - return (function (cb, breakCb: AxisLabelBreakFormatter) { + if (axis.type === 'category') { return function (tick: ScaleTick, idx: number) { // The original intention of `idx` is "the index of the tick in all ticks". // But the previous implementation of category axis do not consider the @@ -277,57 +265,42 @@ export function makeLabelFormatter(axis: Axis): (tick: ScaleTick, idx?: number) // `1`, then the ticks "name5", "name7", "name9" are displayed, where the // corresponding `idx` are `0`, `2`, `4`, but not `0`, `1`, `2`. So we keep // the definition here for back compatibility. - if (categoryTickStart != null) { - idx = tick.value - categoryTickStart; - } - if (tick.breakStart != null && breakCb) { - return breakCb( - tick.value, - idx, - tick.breakStart, - tick.breakEnd, - tick.breakGap - ); - } - return cb( - getAxisRawValue(axis, tick) as number, - idx, - (tick as TimeScaleTick).level != null ? { - level: (tick as TimeScaleTick).level - } : null + return (labelFormatter as AxisLabelCategoryFormatter)( + getAxisRawValue(axis, tick), + tick.value - axis.scale.getExtent()[0], + null // Using `null` just for backward compat. ); }; - })( - labelFormatter as (...args: any[]) => string, - breakLabelFormatter as AxisLabelBreakFormatter - ); + } + const scaleBreakHelper = getScaleBreakHelper(); + return function (tick: ScaleTick, idx: number) { + // Using `null` just for backward compat. It's been found that in the `test/axis-customTicks.html`, + // there is a formatter `function (value, index, revers = true) { ... }`. Although the third param + // `revers` is incorrect and always `null`, changing it might introduce a breaking change. + let extra: AxisLabelFormatterExtraParams | null = null; + if (scaleBreakHelper) { + extra = scaleBreakHelper.makeAxisLabelFormatterParamBreak(extra, tick.break); + } + return (labelFormatter as AxisLabelValueFormatter)( + getAxisRawValue(axis, tick), + idx, + extra + ); + }; } else { return function (tick: ScaleTick) { - if (tick.breakStart != null) { - if (breakLabelFormatter) { - return breakLabelFormatter( - tick.value, - null, - tick.breakStart, - tick.breakEnd, - tick.breakGap - ); - } - else if (tick.breakGap === 0) { - return tick.breakStart + ' ~ ' + tick.breakEnd; - } - } return axis.scale.getLabel(tick); }; } } -export function getAxisRawValue(axis: Axis, tick: ScaleTick): number | string { +export function getAxisRawValue(axis: Axis, tick: ScaleTick): + TIsCategory extends true ? string : number { // In category axis with data zoom, tick is not the original // index of axis.data. So tick should not be exposed to user // in category axis. - return axis.type === 'category' ? axis.scale.getLabel(tick) : tick.value; + return axis.type === 'category' ? axis.scale.getLabel(tick) : tick.value as any; } /** @@ -586,4 +559,31 @@ export function computeReservedSpace( } } return reservedSpace; -} \ No newline at end of file +} + +export function retrieveAxisBreaksOption(model: AxisBaseModel): AxisBaseOptionCommon['breaks'] { + const option = model.get('breaks', true); + if (option != null) { + if (!getScaleBreakHelper()) { + if (__DEV__) { + error( + 'Must `import {AxisBreak} from "echarts/features"; use(AxisBreak);` first if using breaks option.' + ); + } + return undefined; + } + if (!isSupportAxisBreak(model.axis)) { + if (__DEV__) { + error(`Axis '${model.axis.dim}'-'${model.axis.type}' does not support break.`); + } + return undefined; + } + return option; + } +} + +function isSupportAxisBreak(axis: Axis): boolean { + // The polar radius axis can also support break feasibly. Do not do it until the requirements are met. + return (axis.dim === 'x' || axis.dim === 'y' || axis.dim === 'z' || axis.dim === 'single') + && axis.type !== 'category'; +} diff --git a/src/coord/axisModelCreator.ts b/src/coord/axisModelCreator.ts index f7d80720cb..55f3721205 100644 --- a/src/coord/axisModelCreator.ts +++ b/src/coord/axisModelCreator.ts @@ -25,11 +25,15 @@ import { fetchLayoutMode } from '../util/layout'; import OrdinalMeta from '../data/OrdinalMeta'; -import { DimensionName, BoxLayoutOptionMixin, OrdinalRawValue } from '../util/types'; +import { + DimensionName, BoxLayoutOptionMixin, OrdinalRawValue, +} from '../util/types'; import { AxisBaseOption, AXIS_TYPES, CategoryAxisBaseOption } from './axisCommonTypes'; import GlobalModel from '../model/Global'; import { each, merge } from 'zrender/src/core/util'; import { EChartsExtensionInstallRegisters } from '../extension'; +import { BaseAxisBreakPayload } from '../component/axis/axisAction'; +import { AxisBreakUpdateResult, getAxisBreakHelper } from '../component/axis/axisBreakHelper'; type Constructor = new (...args: any[]) => T; @@ -37,6 +41,7 @@ type Constructor = new (...args: any[]) => T; export interface AxisModelExtendedInCreator { getCategories(rawData?: boolean): OrdinalRawValue[] | CategoryAxisBaseOption['data'] getOrdinalMeta(): OrdinalMeta + updateAxisBreaks(payload: BaseAxisBreakPayload): AxisBreakUpdateResult; } /** @@ -112,6 +117,14 @@ export default function axisModelCreator< getOrdinalMeta(): OrdinalMeta { return this.__ordinalMeta; } + + updateAxisBreaks(payload: BaseAxisBreakPayload): AxisBreakUpdateResult { + const axisBreakHelper = getAxisBreakHelper(); + return axisBreakHelper + ? axisBreakHelper.updateModelAxisBreak(this, payload) + : {breaks: []}; + } + } registers.registerComponentModel(AxisModel); diff --git a/src/coord/axisTickLabelBuilder.ts b/src/coord/axisTickLabelBuilder.ts index ec4ce117bf..b1d4c3eba0 100644 --- a/src/coord/axisTickLabelBuilder.ts +++ b/src/coord/axisTickLabelBuilder.ts @@ -31,6 +31,8 @@ import { AxisBaseOption } from './axisCommonTypes'; import OrdinalScale from '../scale/Ordinal'; import { AxisBaseModel } from './AxisBaseModel'; import type Axis2D from './cartesian/Axis2D'; +import { NullUndefined, ScaleTick, VisualAxisBreak } from '../util/types'; +import { ScaleGetTicksOpt } from '../scale/Scale'; type CacheKey = string | number; @@ -74,12 +76,11 @@ function tickValuesToNumbers(axis: Axis, values: (number | string | Date)[]) { export function createAxisLabels(axis: Axis): { labels: { - level?: number, formattedLabel: string, rawLabel: string, tickValue: number, - breakStart?: number, - breakEnd?: number + time: ScaleTick['time'] | NullUndefined, + break: VisualAxisBreak | NullUndefined, }[], labelCategoryInterval?: number } { @@ -95,7 +96,9 @@ export function createAxisLabels(axis: Axis): { return { formattedLabel: labelFormatter(tick), rawLabel: axis.scale.getLabel(tick), - tickValue: numval + tickValue: numval, + time: undefined, + break: undefined, }; }) }; @@ -114,7 +117,11 @@ export function createAxisLabels(axis: Axis): { * tickCategoryInterval: number * } */ -export function createAxisTicks(axis: Axis, tickModel: AxisBaseModel): { +export function createAxisTicks( + axis: Axis, + tickModel: AxisBaseModel, + opt?: Pick +): { ticks: number[], tickCategoryInterval?: number } { @@ -127,30 +134,9 @@ export function createAxisTicks(axis: Axis, tickModel: AxisBaseModel): { }; } // Only ordinal scale support tick interval - if (axis.type === 'category') { - return makeCategoryTicks(axis, tickModel); - } - - const ticks = axis.scale.getTicks(); - // Remove the break axis ticks because it's rendered using zigzag line - const filteredTicks = []; - for (let i = 0; i < ticks.length; ++i) { - if (ticks[i].breakStart != null - || i > 0 && ticks[i - 1].breakStart != null - && ticks[i].value >= ticks[i - 1].breakStart - && ticks[i].value <= ticks[i - 1].breakEnd - || i < ticks.length - 1 && ticks[i + 1].breakStart != null - && ticks[i].value >= ticks[i + 1].breakStart - && ticks[i].value <= ticks[i + 1].breakEnd - ) { - // Current is in a break, ignore - continue; - } - else { - filteredTicks.push(ticks[i].value); - } - } - return {ticks: filteredTicks}; + return axis.type === 'category' + ? makeCategoryTicks(axis, tickModel) + : {ticks: zrUtil.map(axis.scale.getTicks(opt), tick => tick.value)}; } function makeCategoryLabels(axis: Axis) { @@ -237,12 +223,11 @@ function makeRealNumberLabels(axis: Axis) { return { labels: zrUtil.map(ticks, function (tick, idx) { return { - level: tick.level, formattedLabel: labelFormatter(tick, idx), rawLabel: axis.scale.getLabel(tick), tickValue: tick.value, - breakStart: tick.breakStart, - breakEnd: tick.breakEnd + time: tick.time, + break: tick.break, }; }) }; @@ -393,6 +378,8 @@ interface MakeLabelsResultObj { formattedLabel: string rawLabel: string tickValue: number + time: undefined + break: undefined } function makeLabelsByNumericCategoryInterval(axis: Axis, categoryInterval: number): MakeLabelsResultObj[]; @@ -405,7 +392,6 @@ function makeLabelsByNumericCategoryInterval(axis: Axis, categoryInterval: numbe const ordinalExtent = ordinalScale.getExtent(); const labelModel = axis.getLabelModel(); const result: (MakeLabelsResultObj | number)[] = []; - const scale = axis.scale as OrdinalScale; // TODO: axisType: ordinalTime, pick the tick from each month/day/year/... @@ -444,40 +430,18 @@ function makeLabelsByNumericCategoryInterval(axis: Axis, categoryInterval: numbe addItem(ordinalExtent[1]); } - const breaks = scale.getBreaks(); - const breakLabelFormatter = labelModel.get('breakFormatter'); - const categoryTickStart = axis.type === 'category' ? axis.scale.getExtent()[0] : null; - for (let i = 0; i < breaks.length; ++i) { - const brk = breaks[i]; - if (brk.isExpanded) { - continue; - } - const label = breakLabelFormatter ? breakLabelFormatter( - brk.start, - brk.start - categoryTickStart, - brk.start, - brk.end, - brk.gap - ) : ''; - result.push(onlyTick ? tickValue : { - formattedLabel: label, - rawLabel: ordinalScale.getLabel({ value: brk.start }), - tickValue: brk.start - }); - } - function addItem(tickValue: number) { - if (scale.getBreakIndex(tickValue) < 0) { - const tickObj = { value: tickValue }; - result.push(onlyTick - ? tickValue - : { - formattedLabel: labelFormatter(tickObj), - rawLabel: ordinalScale.getLabel(tickObj), - tickValue: tickValue - } - ); - } + const tickObj = { value: tickValue }; + result.push(onlyTick + ? tickValue + : { + formattedLabel: labelFormatter(tickObj), + rawLabel: ordinalScale.getLabel(tickObj), + tickValue: tickValue, + time: undefined, + break: undefined, + } + ); } return result; @@ -497,19 +461,20 @@ function makeLabelsByCustomizedCategoryInterval(axis: Axis, categoryInterval: Ca const ordinalScale = axis.scale; const labelFormatter = makeLabelFormatter(axis); const result: (MakeLabelsResultObj | number)[] = []; - const scale = axis.scale as OrdinalScale; zrUtil.each(ordinalScale.getTicks(), function (tick) { const rawLabel = ordinalScale.getLabel(tick); const tickValue = tick.value; - if (categoryInterval(tick.value, rawLabel) && scale.getBreakIndex(tickValue) < 0) { + if (categoryInterval(tick.value, rawLabel)) { result.push( onlyTick ? tickValue : { formattedLabel: labelFormatter(tick), rawLabel: rawLabel, - tickValue: tickValue + tickValue: tickValue, + time: undefined, + break: undefined, } ); } diff --git a/src/coord/cartesian/Cartesian2D.ts b/src/coord/cartesian/Cartesian2D.ts index 8b9ef6f319..a17694d398 100644 --- a/src/coord/cartesian/Cartesian2D.ts +++ b/src/coord/cartesian/Cartesian2D.ts @@ -32,7 +32,7 @@ import { applyTransform } from 'zrender/src/core/vector'; export const cartesian2DDimensions = ['x', 'y']; function canCalculateAffineTransform(scale: Scale) { - return scale.type === 'interval' || scale.type === 'time'; + return (scale.type === 'interval' || scale.type === 'time') && !scale.hasBreaks(); } class Cartesian2D extends Cartesian implements CoordinateSystem { @@ -121,20 +121,20 @@ class Cartesian2D extends Cartesian implements CoordinateSystem { out = out || []; const xVal = data[0]; const yVal = data[1]; - const xAxis = this.getAxis('x'); - const yAxis = this.getAxis('y'); - // Fast path + // [CAVEAT]: Do not add time consuming operation within and before fast path. + // Fast path. if (this._transform // It's supported that if data is like `[Inifity, 123]`, where only Y pixel calculated. && xVal != null && isFinite(xVal as number) && yVal != null && isFinite(yVal as number) - && xAxis.scale.getBreaks().length === 0 - && yAxis.scale.getBreaks().length === 0 ) { return applyTransform(out, data as number[], this._transform); } + + const xAxis = this.getAxis('x'); + const yAxis = this.getAxis('y'); out[0] = xAxis.toGlobalCoord(xAxis.dataToCoord(xVal, clamp)); out[1] = yAxis.toGlobalCoord(yAxis.dataToCoord(yVal, clamp)); return out; diff --git a/src/core/echarts.ts b/src/core/echarts.ts index d53ca4e093..0a12506f17 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -80,7 +80,8 @@ import { findComponentHighDownDispatchers, blurComponent, handleGlobalMouseOverForHighDown, - handleGlobalMouseOutForHighDown + handleGlobalMouseOutForHighDown, + SELECT_CHANGED_EVENT_TYPE } from '../util/states'; import * as modelUtil from '../util/model'; import {throttle} from '../util/throttle'; @@ -104,11 +105,11 @@ import { ComponentMainType, ComponentSubType, ColorString, - SelectChangedPayload, + SelectChangedEvent, ScaleDataValue, ZRElementEventName, ECElementEvent, - AnimationOption + AnimationOption, } from '../util/types'; import Displayable from 'zrender/src/graphic/Displayable'; import { seriesSymbolTask, dataSymbolTask } from '../visual/symbol'; @@ -1211,24 +1212,14 @@ class ECharts extends Eventful { this._zr.on(eveName, handler, this); }); - each(eventActionMap, (actionType, eventType) => { - this._messageCenter.on(eventType, function (event: Payload) { - (this as any).trigger(eventType, event); - }, this); + const messageCenter = this._messageCenter; + each(publicEventTypeMap, (_, eventType) => { + messageCenter.on(eventType, event => { + this.trigger(eventType, event); + }); }); - // Extra events - // TODO register? - each( - ['selectchanged'], - (eventType) => { - this._messageCenter.on(eventType, function (event: Payload) { - (this as any).trigger(eventType, event); - }, this); - } - ); - - handleLegacySelectEvents(this._messageCenter, this, this._api); + handleLegacySelectEvents(messageCenter, this, this._api); } isDisposed(): boolean { @@ -1402,7 +1393,7 @@ class ECharts extends Eventful { makeActionFromEvent(eventObj: ECActionEvent): Payload { const payload = extend({}, eventObj) as Payload; - payload.type = eventActionMap[eventObj.type]; + payload.type = connectionEventRevertMap[eventObj.type]; return payload; } @@ -1633,9 +1624,9 @@ class ECharts extends Eventful { } const query: QueryConditionKindA['query'] = {}; - query[mainType + 'Id'] = payload[mainType + 'Id']; - query[mainType + 'Index'] = payload[mainType + 'Index']; - query[mainType + 'Name'] = payload[mainType + 'Name']; + query[mainType + 'Id'] = payload[mainType + 'Id'] as any; + query[mainType + 'Index'] = payload[mainType + 'Index'] as any; + query[mainType + 'Name'] = payload[mainType + 'Name'] as any; const condition = {mainType: mainType, query: query} as QueryConditionKindA; subType && (condition.subType = subType); // subType may be '' by parseClassType; @@ -1669,7 +1660,7 @@ class ECharts extends Eventful { } else { const { focusSelf, dispatchers } = findComponentHighDownDispatchers( - model.mainType, model.componentIndex, payload.name, ecIns._api + model.mainType, model.componentIndex, payload.name as string, ecIns._api ); if (payload.type === HIGHLIGHT_ACTION_TYPE && focusSelf && !payload.notBlur) { blurComponent(model.mainType, model.componentIndex, ecIns._api); @@ -1953,8 +1944,7 @@ class ECharts extends Eventful { const ecModel = this.getModel(); const payloadType = payload.type; const escapeConnect = payload.escapeConnect; - const actionWrap = actions[payloadType]; - const actionInfo = actionWrap.actionInfo; + const actionInfo = actions[payloadType]; const cptTypeTmp = (actionInfo.update || 'update').split(':'); const updateMethod = cptTypeTmp.pop(); @@ -1976,6 +1966,8 @@ class ECharts extends Eventful { const eventObjBatch: ECEventData[] = []; let eventObj: ECActionEvent; + const actionResultBatch: ECActionEvent[] = []; + const nonRefinedEventType = actionInfo.nonRefinedEventType; const isSelectChange = isSelectChangePayload(payload); const isHighDown = isHighDownPayload(payload); @@ -1987,11 +1979,15 @@ class ECharts extends Eventful { each(payloads, (batchItem) => { // Action can specify the event by return it. - eventObj = actionWrap.action(batchItem, this._model, this._api) as ECActionEvent; - // Emit event outside + const actionResult = actionInfo.action(batchItem, ecModel, this._api) as ECActionEvent; + if (actionInfo.refineEvent) { + actionResultBatch.push(actionResult); + } + else { + eventObj = actionResult; + } eventObj = eventObj || extend({} as ECActionEvent, batchItem); - // Convert type to eventType - eventObj.type = actionInfo.event || eventObj.type; + eventObj.type = nonRefinedEventType; eventObjBatch.push(eventObj); // light update does not perform data process, layout and visual. @@ -2033,7 +2029,7 @@ class ECharts extends Eventful { // Follow the rule of action batch if (batched) { eventObj = { - type: actionInfo.event || payloadType, + type: nonRefinedEventType, escapeConnect: escapeConnect, batch: eventObjBatch }; @@ -2045,19 +2041,26 @@ class ECharts extends Eventful { this[IN_MAIN_PROCESS_KEY] = false; if (!silent) { + let refinedEvent: ECActionEvent; + if (actionInfo.refineEvent) { + const {eventContent} = actionInfo.refineEvent( + actionResultBatch, payload, ecModel, this._api + ); + assert(isObject(eventContent)); + refinedEvent = defaults({type: actionInfo.refinedEventType}, eventContent); + refinedEvent.fromAction = payload.type; + refinedEvent.fromActionPayload = payload; + refinedEvent.escapeConnect = true; + } + const messageCenter = this._messageCenter; + // - If `refineEvent` created a `refinedEvent`, `eventObj` (replicated from the original payload) + // is still needed to be triggered for the feature `connect`. But it will not be triggered to + // users in this case. + // - If no `refineEvent` used, `eventObj` will be triggered for both `connect` and users. messageCenter.trigger(eventObj.type, eventObj); - // Extra triggered 'selectchanged' event - if (isSelectChange) { - const newObj: SelectChangedPayload = { - type: 'selectchanged', - escapeConnect: escapeConnect, - selected: getAllSelectedIndices(ecModel), - isFromClick: payload.isFromClick || false, - fromAction: payload.type as 'select' | 'unselect' | 'toggleSelected', - fromActionPayload: payload - }; - messageCenter.trigger(newObj.type, newObj); + if (refinedEvent) { + messageCenter.trigger(refinedEvent.type, refinedEvent); } } }; @@ -2631,7 +2634,7 @@ class ECharts extends Eventful { } } - each(eventActionMap, function (actionType, eventType) { + each(connectionEventRevertMap, function (_, eventType) { chart._messageCenter.on(eventType, function (event: ECActionEvent) { if (connectedGroups[chart.group] && chart[CONNECT_STATUS_KEY] !== CONNECT_STATUS_PENDING) { if (event && event.escapeConnect) { @@ -2691,18 +2694,29 @@ function disposedWarning(id: string): void { } } - +/** + * @see {ActionInfo} + */ +type ActionInfoParsed = { + actionType: string; + nonRefinedEventType: string; + refinedEventType: string; + update: ActionInfo['update']; + action: ActionInfo['action']; + refineEvent: ActionInfo['refineEvent']; +}; const actions: { - [actionType: string]: { - action: ActionHandler, - actionInfo: ActionInfo - } + [actionType: string]: ActionInfoParsed } = {}; /** - * Map eventType to actionType + * Map event type to action type for reproducing action from event for `connect`. + */ +const connectionEventRevertMap: {[eventType: string]: string} = {}; +/** + * To remove duplication. */ -const eventActionMap: {[eventType: string]: string} = {}; +const publicEventTypeMap: {[eventType: string]: 1} = {}; const dataProcessorFuncs: StageHandlerInternal[] = []; @@ -2907,50 +2921,89 @@ export function registerUpdateLifecycle( * {type: 'someAction', event: 'someEvent', update: 'updateView'}, * function () { ... } * ); - * - * @param {(string|Object)} actionInfo - * @param {string} actionInfo.type - * @param {string} [actionInfo.event] - * @param {string} [actionInfo.update] - * @param {string} [eventName] - * @param {Function} action + * registerAction({ + * type: 'someAction', + * event: 'someEvent', + * update: 'updateView' + * action: function () { ... } + * refineEvent: function () { ... } + * }); + * @see {ActionInfo} for more details. */ -export function registerAction(type: string, eventName: string, action: ActionHandler): void; +export function registerAction(type: string, eventType: string, action: ActionHandler): void; export function registerAction(type: string, action: ActionHandler): void; -export function registerAction(actionInfo: ActionInfo, action: ActionHandler): void; +export function registerAction(actionInfo: ActionInfo, action?: ActionHandler): void; export function registerAction( - actionInfo: string | ActionInfo, - eventName: string | ActionHandler, + arg0: string | ActionInfo, + arg1: string | ActionHandler, action?: ActionHandler ): void { - if (isFunction(eventName)) { - action = eventName; - eventName = ''; + let actionType: ActionInfo['type']; + let publicEventType: ActionInfo['event']; + let refineEvent: ActionInfo['refineEvent']; + let update: ActionInfo['update']; + let publishNonRefinedEvent: ActionInfo['publishNonRefinedEvent']; + + if (isFunction(arg1)) { + action = arg1; + arg1 = ''; } - const actionType = isObject(actionInfo) - ? (actionInfo as ActionInfo).type - : ([actionInfo, actionInfo = { - event: eventName - } as ActionInfo][0]); - - // Event name is all lowercase - (actionInfo as ActionInfo).event = ( - (actionInfo as ActionInfo).event || actionType as string - ).toLowerCase(); - eventName = (actionInfo as ActionInfo).event; - - if (eventActionMap[eventName as string]) { - // Already registered. + + if (isObject(arg0)) { + actionType = arg0.type; + publicEventType = arg0.event; + update = arg0.update; + publishNonRefinedEvent = arg0.publishNonRefinedEvent; + if (!action) { + action = arg0.action; + } + refineEvent = arg0.refineEvent; + } + else { + actionType = arg0; + publicEventType = arg1; + } + + function createEventType(actionOrEventType: string) { + // Event type should be all lowercase + return actionOrEventType.toLowerCase(); + } + + publicEventType = createEventType(publicEventType || actionType); + // See comments on {ActionInfo} for the reason. + const nonRefinedEventType = refineEvent ? createEventType(actionType) : publicEventType; + + // Support calling `registerAction` multiple times with the same action + // type; subsequent calls have no effect. + if (actions[actionType]) { return; } // Validate action type and event name. - assert(ACTION_REG.test(actionType as string) && ACTION_REG.test(eventName)); + assert(ACTION_REG.test(actionType) && ACTION_REG.test(publicEventType)); + if (refineEvent) { + // An event replicated from the action will be triggered internally for `connect` in this case. + assert(publicEventType !== actionType); + } - if (!actions[actionType as string]) { - actions[actionType as string] = {action: action, actionInfo: actionInfo as ActionInfo}; + actions[actionType] = { + actionType, + refinedEventType: publicEventType, + nonRefinedEventType, + update, + action, + refineEvent, + }; + + publicEventTypeMap[publicEventType] = 1; + if (refineEvent && publishNonRefinedEvent) { + publicEventTypeMap[nonRefinedEventType] = 1; + } + + if (__DEV__ && connectionEventRevertMap[nonRefinedEventType]) { + error(`${nonRefinedEventType} must not be shared; use "refineEvent" if you intend to share an event name.`); } - eventActionMap[eventName as string] = actionType as string; + connectionEventRevertMap[nonRefinedEventType] = actionType; } export function registerCoordinateSystem( @@ -3141,21 +3194,41 @@ registerAction({ registerAction({ type: SELECT_ACTION_TYPE, - event: SELECT_ACTION_TYPE, - update: SELECT_ACTION_TYPE -}, noop); + event: SELECT_CHANGED_EVENT_TYPE, + update: SELECT_ACTION_TYPE, + action: noop, + refineEvent: makeSelectChangedEvent, + publishNonRefinedEvent: true, // Backward compat but deprecated. +}); registerAction({ type: UNSELECT_ACTION_TYPE, - event: UNSELECT_ACTION_TYPE, - update: UNSELECT_ACTION_TYPE -}, noop); + event: SELECT_CHANGED_EVENT_TYPE, + update: UNSELECT_ACTION_TYPE, + action: noop, + refineEvent: makeSelectChangedEvent, + publishNonRefinedEvent: true, // Backward compat but deprecated. +}); registerAction({ type: TOGGLE_SELECT_ACTION_TYPE, - event: TOGGLE_SELECT_ACTION_TYPE, - update: TOGGLE_SELECT_ACTION_TYPE -}, noop); + event: SELECT_CHANGED_EVENT_TYPE, + update: TOGGLE_SELECT_ACTION_TYPE, + action: noop, + refineEvent: makeSelectChangedEvent, + publishNonRefinedEvent: true, // Backward compat but deprecated. +}); + +function makeSelectChangedEvent( + actionResultBatch: ECEventData[], payload: Payload, ecModel: GlobalModel, api: ExtensionAPI +): {eventContent: Omit} { + return { + eventContent: { + selected: getAllSelectedIndices(ecModel), + isFromClick: (payload.isFromClick as boolean) || false, + } + }; +} // Default theme registerTheme('light', lightTheme); diff --git a/src/echarts.all.ts b/src/echarts.all.ts index 187f616991..adad6b725d 100644 --- a/src/echarts.all.ts +++ b/src/echarts.all.ts @@ -43,6 +43,7 @@ import { TreeChart, TreemapChart, GraphChart, + ChordChart, GaugeChart, FunnelChart, ParallelChart, @@ -62,7 +63,6 @@ import { GridComponent, PolarComponent, GeoComponent, - AxisComponent, SingleAxisComponent, ParallelComponent, CalendarComponent, @@ -90,7 +90,8 @@ import { import { UniversalTransition, - LabelLayout + LabelLayout, + AxisBreak } from './export/features'; @@ -128,6 +129,7 @@ use([ TreeChart, TreemapChart, GraphChart, + ChordChart, GaugeChart, FunnelChart, ParallelChart, @@ -147,8 +149,6 @@ use([ // Coordinate systems // ------------------- -use(AxisComponent); - // All of the axis modules have been included in the // coordinate system module below, do not need to // make extra import. @@ -355,3 +355,5 @@ use(UniversalTransition); // } // }) use(LabelLayout); + +use(AxisBreak); diff --git a/src/echarts.common.ts b/src/echarts.common.ts index d8be6f6d7c..3322daa82d 100644 --- a/src/echarts.common.ts +++ b/src/echarts.common.ts @@ -29,7 +29,6 @@ import {install as BarChart} from './chart/bar/install'; import {install as PieChart} from './chart/pie/install'; import {install as ScatterChart} from './chart/scatter/install'; - import {install as GridComponent} from './component/grid/install'; import {install as GraphicComponent} from './component/graphic/install'; import {install as ToolboxComponent} from './component/toolbox/install'; @@ -44,6 +43,8 @@ import {install as DataZoomComponent} from './component/dataZoom/install'; import {install as AriaComponent} from './component/aria/install'; import {install as DatasetComponent} from './component/dataset/install'; +import {installAxisBreak as AxisBreak} from './component/axis/installBreak'; + use([CanvasRenderer]); use([SVGRenderer]); @@ -68,5 +69,10 @@ use([ DataZoomComponent, ToolboxComponent, AriaComponent, - DatasetComponent + DatasetComponent, +]); + +// Features +use([ + AxisBreak ]); diff --git a/src/export/api/time.ts b/src/export/api/time.ts index 41ab2b7417..401ab79a31 100644 --- a/src/export/api/time.ts +++ b/src/export/api/time.ts @@ -19,4 +19,4 @@ export {parseDate as parse} from '../../util/number'; -export {format} from '../../util/time'; \ No newline at end of file +export {format, roundTime} from '../../util/time'; \ No newline at end of file diff --git a/src/export/charts.ts b/src/export/charts.ts index 4356de9147..3ccb850733 100644 --- a/src/export/charts.ts +++ b/src/export/charts.ts @@ -34,6 +34,7 @@ export {install as MapChart} from '../chart/map/install'; export {install as TreeChart} from '../chart/tree/install'; export {install as TreemapChart} from '../chart/treemap/install'; export {install as GraphChart} from '../chart/graph/install'; +export {install as ChordChart} from '../chart/chord/install'; export {install as GaugeChart} from '../chart/gauge/install'; export {install as FunnelChart} from '../chart/funnel/install'; export {install as ParallelChart} from '../chart/parallel/install'; @@ -83,6 +84,7 @@ export { TreeSeriesOption, TreemapSeriesOption, GraphSeriesOption, + ChordSeriesOption, GaugeSeriesOption, FunnelSeriesOption, ParallelSeriesOption, diff --git a/src/export/components.ts b/src/export/components.ts index 80008193b2..8e4e14e26b 100644 --- a/src/export/components.ts +++ b/src/export/components.ts @@ -25,7 +25,6 @@ export {install as RadarComponent} from '../component/radar/install'; export {install as GeoComponent} from '../component/geo/install'; -export {install as AxisComponent} from '../component/axis/install'; export {install as SingleAxisComponent} from '../component/singleAxis/install'; export {install as ParallelComponent} from '../component/parallel/install'; export {install as CalendarComponent} from '../component/calendar/install'; diff --git a/src/export/core.ts b/src/export/core.ts index 4e3b1c673c..7d42fe1abf 100644 --- a/src/export/core.ts +++ b/src/export/core.ts @@ -36,8 +36,17 @@ export { ECElementEvent, HighlightPayload, DownplayPayload, - SelectChangedPayload + SelectChangedPayload, + SelectChangedEvent, } from '../util/types'; +export { + AxisBreakChangedEvent, + ExpandAxisBreakPayload, + CollapseAxisBreakPayload, + ToggleAxisBreakPayload, +} from '../component/axis/axisAction'; + + export { LinearGradientObject } from 'zrender/src/graphic/LinearGradient'; export { RadialGradientObject } from 'zrender/src/graphic/RadialGradient'; export { PatternObject, ImagePatternObject, SVGPatternObject } from 'zrender/src/graphic/Pattern'; diff --git a/src/export/features.ts b/src/export/features.ts index 12dd1d24f0..2b03b8878f 100644 --- a/src/export/features.ts +++ b/src/export/features.ts @@ -20,4 +20,5 @@ // Module that exports complex but fancy features. export {installUniversalTransition as UniversalTransition} from '../animation/universalTransition'; -export {installLabelLayout as LabelLayout} from '../label/installLabelLayout'; \ No newline at end of file +export {installLabelLayout as LabelLayout} from '../label/installLabelLayout'; +export {installAxisBreak as AxisBreak} from '../component/axis/installBreak'; diff --git a/src/export/option.ts b/src/export/option.ts index d25e8197a3..a39fe92e6e 100644 --- a/src/export/option.ts +++ b/src/export/option.ts @@ -72,6 +72,7 @@ import type {MapSeriesOption as MapSeriesOptionInner} from '../chart/map/MapSeri import type {TreeSeriesOption as TreeSeriesOptionInner} from '../chart/tree/TreeSeries'; import type {TreemapSeriesOption as TreemapSeriesOptionInner} from '../chart/treemap/TreemapSeries'; import type {GraphSeriesOption as GraphSeriesOptionInner} from '../chart/graph/GraphSeries'; +import type {ChordSeriesOption as ChordSeriesOptionInner} from '../chart/chord/ChordSeries'; import type {GaugeSeriesOption as GaugeSeriesOptionInner} from '../chart/gauge/GaugeSeries'; import type {FunnelSeriesOption as FunnelSeriesOptionInner} from '../chart/funnel/FunnelSeries'; import type {ParallelSeriesOption as ParallelSeriesOptionInner} from '../chart/parallel/ParallelSeries'; @@ -187,6 +188,7 @@ export type MapSeriesOption = MapSeriesOptionInner & SeriesInjectedOption; export type TreeSeriesOption = TreeSeriesOptionInner & SeriesInjectedOption; export type TreemapSeriesOption = TreemapSeriesOptionInner & SeriesInjectedOption; export type GraphSeriesOption = GraphSeriesOptionInner & SeriesInjectedOption; +export type ChordSeriesOption = ChordSeriesOptionInner & SeriesInjectedOption; export type GaugeSeriesOption = GaugeSeriesOptionInner & SeriesInjectedOption; export type FunnelSeriesOption = FunnelSeriesOptionInner & SeriesInjectedOption; export type ParallelSeriesOption = ParallelSeriesOptionInner & SeriesInjectedOption; @@ -225,6 +227,7 @@ export interface RegisteredSeriesOption { tree: TreeSeriesOption treemap: TreemapSeriesOption graph: GraphSeriesOption + chord: ChordSeriesOption gauge: GaugeSeriesOption funnel: FunnelSeriesOption parallel: ParallelSeriesOption diff --git a/src/label/labelLayoutHelper.ts b/src/label/labelLayoutHelper.ts index d54131ed6f..d50a16e62c 100644 --- a/src/label/labelLayoutHelper.ts +++ b/src/label/labelLayoutHelper.ts @@ -18,9 +18,13 @@ */ import ZRText from 'zrender/src/graphic/Text'; -import { LabelLayoutOption } from '../util/types'; +import { LabelExtendedText, LabelLayoutOption, LabelMarginType, NullUndefined } from '../util/types'; import { BoundingRect, OrientedBoundingRect, Polyline } from '../util/graphic'; import type Element from 'zrender/src/Element'; +import { PointLike } from 'zrender/src/core/Point'; +import { map, retrieve2 } from 'zrender/src/core/util'; +import type { AxisBuilderCfg } from '../component/axis/AxisBuilder'; +import { normalizeCssArray } from '../util/format'; interface LabelLayoutListPrepareInput { label: ZRText @@ -49,7 +53,15 @@ export interface LabelLayoutInfo { transform: number[] } -export function prepareLayoutList(input: LabelLayoutListPrepareInput[]): LabelLayoutInfo[] { +export type LabelLayoutAntiTextJoin = number | 'auto' | NullUndefined; + +export function prepareLayoutList( + input: LabelLayoutListPrepareInput[], + opt?: { + alwaysOBB?: boolean, + ignoreTextMargin?: boolean, + } +): LabelLayoutInfo[] { const list: LabelLayoutInfo[] = []; for (let i = 0; i < input.length; i++) { @@ -61,18 +73,34 @@ export function prepareLayoutList(input: LabelLayoutListPrepareInput[]): LabelLa 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); + let localRect = label.getBoundingRect(); + const isAxisAligned = !transform || (Math.abs(transform[1]) < 1e-5 && Math.abs(transform[2]) < 1e-5); + + const marginType = (label as LabelExtendedText).__marginType; + if (opt && !opt.ignoreTextMargin && marginType === LabelMarginType.textMargin) { + const textMargin = normalizeCssArray(retrieve2(label.style.margin, [0, 0])); + localRect = localRect.clone(); + localRect.x -= textMargin[3]; + localRect.y -= textMargin[0]; + localRect.width += textMargin[1] + textMargin[3]; + localRect.height += textMargin[0] + textMargin[2]; + } - 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; + if (marginType == null || marginType === LabelMarginType.minMargin) { + // `minMargin` only support number value. + const minMargin = (label.style.margin as number) || 0; + globalRect.x -= minMargin / 2; + globalRect.y -= minMargin / 2; + globalRect.width += minMargin; + globalRect.height += minMargin; + } + + const obb = (!isAxisAligned || (opt && opt.alwaysOBB)) + ? new OrientedBoundingRect(localRect, transform) + : null; list.push({ label, @@ -317,16 +345,16 @@ export function hideOverlap(labelList: LabelLayoutInfo[]) { 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; + const touchThreshold = 0.05; + globalRect.width -= touchThreshold * 2; + globalRect.height -= touchThreshold * 2; + globalRect.x += touchThreshold; + globalRect.y += touchThreshold; - if (globalRect.width <= 0 || globalRect.height <= 0) { - hideEl(label); - labelLine && hideEl(labelLine); - continue; - } + // 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 obb = labelItem.obb; let overlapped = false; @@ -350,7 +378,7 @@ export function hideOverlap(labelList: LabelLayoutInfo[]) { obb = new OrientedBoundingRect(localRect, transform); } - if (obb.intersect(existsTextCfg.obb)) { + if (obb.intersect(existsTextCfg.obb, null, {touchThreshold})) { overlapped = true; break; } @@ -368,4 +396,52 @@ export function hideOverlap(labelList: LabelLayoutInfo[]) { displayedLabels.push(labelItem); } } -} \ No newline at end of file +} + + +/** + * If no intercection, return null/undefined. + * Otherwise return: + * - mtv (the output of OBB intersect). pair[1]+mtv can just resolve overlap. + * - corresponding layout info + */ +export function detectAxisLabelPairIntersection( + axisRotation: AxisBuilderCfg['rotation'], + labelPair: ZRText[], // [label0, label1] + touchThreshold: number, + ignoreTextMargin: boolean +): NullUndefined | { + mtv: PointLike; + layoutPair: LabelLayoutInfo[] +} { + if (!labelPair[0] || !labelPair[1]) { + return; + } + const layoutPair = prepareLayoutList(map(labelPair, label => { + return { + label, + priority: label.z2, + defaultAttr: { + ignore: label.ignore + } + }; + }), { + alwaysOBB: true, + ignoreTextMargin, + }); + + if (!layoutPair[0] || !layoutPair[1]) { // If either label is ignored + return; + } + + const mtv = {x: NaN, y: NaN}; + if (layoutPair[0].obb.intersect(layoutPair[1].obb, mtv, { + direction: -axisRotation, + touchThreshold, + // 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 {mtv, layoutPair}; + } +} diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index 205b3b809f..3f6aeb4fb6 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -477,7 +477,6 @@ export function createProgressiveLayout(seriesType: string): StageHandler { const cartesian = seriesModel.coordinateSystem as Cartesian2D; const baseAxis = cartesian.getBaseAxis(); - const isBaseCategoryAxis = baseAxis.type === 'category'; const valueAxis = cartesian.getOtherAxis(baseAxis); const valueDimIdx = data.getDimensionIndex(data.mapDimension(valueAxis.dim)); const baseDimIdx = data.getDimensionIndex(data.mapDimension(baseAxis.dim)); @@ -560,13 +559,7 @@ export function createProgressiveLayout(seriesType: string): StageHandler { } if (!isLarge) { - if (isBaseCategoryAxis && baseAxis.scale.getBreakIndex(baseValue) >= 0) { - // Bar is filtered in break axis - data.setItemLayout(dataIndex, null); - } - else { - data.setItemLayout(dataIndex, { x, y, width, height }); - } + data.setItemLayout(dataIndex, { x, y, width, height }); } else { largePoints[idxOffset] = x; diff --git a/src/legacy/dataSelectAction.ts b/src/legacy/dataSelectAction.ts index 14eeb1544e..7cfa953be2 100644 --- a/src/legacy/dataSelectAction.ts +++ b/src/legacy/dataSelectAction.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Payload, SelectChangedPayload } from '../util/types'; +import { Payload, SelectChangedEvent } from '../util/types'; import SeriesModel from '../model/Series'; import { extend, each, isArray, isString } from 'zrender/src/core/util'; import GlobalModel from '../model/Global'; @@ -66,7 +66,7 @@ function handleSeriesLegacySelectEvents( eventPostfix: 'selectchanged' | 'selected' | 'unselected', ecIns: EChartsType, ecModel: GlobalModel, - payload: SelectChangedPayload + payload: SelectChangedEvent ) { const legacyEventName = type + eventPostfix; if (!ecIns.isSilent(legacyEventName)) { @@ -96,7 +96,7 @@ function handleSeriesLegacySelectEvents( } export function handleLegacySelectEvents(messageCenter: Eventful, ecIns: EChartsType, api: ExtensionAPI) { - messageCenter.on('selectchanged', function (params: SelectChangedPayload) { + messageCenter.on('selectchanged', function (params: SelectChangedEvent) { const ecModel = api.getModel(); if (params.isFromClick) { handleSeriesLegacySelectEvents('map', 'selectchanged', ecIns, ecModel, params); diff --git a/src/model/Global.ts b/src/model/Global.ts index 86c56f2e2b..add0aac637 100644 --- a/src/model/Global.ts +++ b/src/model/Global.ts @@ -120,6 +120,7 @@ const BUILTIN_CHARTS_MAP = { tree: 'TreeChart', treemap: 'TreemapChart', graph: 'GraphChart', + chord: 'ChordChart', gauge: 'GaugeChart', funnel: 'FunnelChart', parallel: 'ParallelChart', diff --git a/src/model/Series.ts b/src/model/Series.ts index 52c6115a88..1b24c027e1 100644 --- a/src/model/Series.ts +++ b/src/model/Series.ts @@ -30,7 +30,8 @@ import { SeriesEncodeOptionMixin, OptionEncodeValue, ColorBy, - StatesOptionMixin + StatesOptionMixin, + DimensionLoose } from '../util/types'; import ComponentModel, { ComponentModelConstructor } from './Component'; import {PaletteMixin} from './mixin/palette'; @@ -447,7 +448,7 @@ class SeriesModel extends ComponentMode * @return If and only if multiple indices has * the same value, they are put to the result. */ - indicesOfNearest(axisDim: string, dim: string, value: number, maxDistance?: number): number[] { + indicesOfNearest(axisDim: DimensionName, dim: DimensionLoose, value: number, maxDistance?: number): number[] { const data = this.getData(); const coordSys = this.coordinateSystem; const axis = coordSys && coordSys.getAxis(axisDim); diff --git a/src/scale/Interval.ts b/src/scale/Interval.ts index b35b4b7409..ebc6ec73ae 100644 --- a/src/scale/Interval.ts +++ b/src/scale/Interval.ts @@ -20,13 +20,14 @@ import * as numberUtil from '../util/number'; import * as formatUtil from '../util/format'; -import Scale from './Scale'; +import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; import * as helper from './helper'; -import {ScaleTick, Dictionary} from '../util/types'; +import {ScaleTick, ParsedAxisBreakList, ScaleDataValue} from '../util/types'; +import { getScaleBreakHelper } from './break'; const roundNumber = numberUtil.round; -class IntervalScale = Dictionary> extends Scale { +class IntervalScale extends Scale { static type = 'interval'; type = 'interval'; @@ -37,8 +38,31 @@ class IntervalScale = Dictionary> e private _intervalPrecision: number = 2; - parse(val: number): number { - return val; + parse(val: ScaleDataValue): number { + // `Scale#parse` (and its overrids) are typically applied at the axis values input + // in echarts option. e.g., `axis.min/max`, `dataZoom.min/max`, etc. + // but `series.data` is not included, which uses `dataValueHelper.ts`#`parseDataValue`. + // `Scale#parse` originally introduced in fb8c813215098b9d2458966229bb95c510883d5e + // at 2016 for dataZoom start/end settings (See `parseAxisModelMinMax`). + // + // Historically `scale/Interval.ts` returns the input value directly. But numeric + // values (such as a number-like string '123') effectively passed through here and + // were involved in calculations, which was error-prone and inconsistent with the + // declared TS return type. Previously such issues are fixed separately in different + // places case by case (such as #2475). + // + // Now, we perform actual parse to ensure its `number` type here. The parsing rule + // follows the series data parsing rule (`dataValueHelper.ts`#`parseDataValue`) + // and maintains compatibility as much as possible (thus a more strict parsing + // `number.ts`#`numericToNumber` is not used here.) + // + // FIXME: `ScaleDataValue` also need to be modified to include numeric string type, + // since it effectively does. + return (val == null || val === '') + ? NaN + // If string (like '-'), using '+' parse to NaN + // If object, also parse to NaN + : Number(val); } contain(val: number): boolean { @@ -46,31 +70,11 @@ class IntervalScale = Dictionary> e } normalize(val: number): number { - return helper.normalize(val, this._extent, this._breaks); + return this._calculator.normalize(val, this._extent); } scale(val: number): number { - return helper.scale(val, this._extent, this._breaks); - } - - setExtent(start: number | string, end: number | string): void { - const thisExtent = this._extent; - // start,end may be a Number like '25',so... - if (!isNaN(start as any)) { - thisExtent[0] = parseFloat(start as any); - } - if (!isNaN(end as any)) { - thisExtent[1] = parseFloat(end as any); - } - } - - unionExtent(other: [number, number]): void { - const extent = this._extent; - other[0] < extent[0] && (extent[0] = other[0]); - other[1] > extent[1] && (extent[1] = other[1]); - - // unionExtent may called by it's sub classes - this.setExtent(extent[0], extent[1]); + return this._calculator.scale(val, this._extent); } getInterval(): number { @@ -87,13 +91,15 @@ class IntervalScale = Dictionary> e } /** - * @param expandToNicedExtent Whether expand the ticks to niced extent. + * @override */ - getTicks(expandToNicedExtent?: boolean): ScaleTick[] { + getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { + opt = opt || {}; const interval = this._interval; const extent = this._extent; const niceTickExtent = this._niceExtent; const intervalPrecision = this._intervalPrecision; + const scaleBreakHelper = getScaleBreakHelper(); const ticks = [] as ScaleTick[]; // If interval is 0, return []; @@ -101,11 +107,16 @@ class IntervalScale = Dictionary> e return ticks; } + if (opt.breakTicks === 'only_break' && scaleBreakHelper) { + scaleBreakHelper.addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent); + return ticks; + } + // Consider this case: using dataZoom toolbox, zoom and zoom. const safeLimit = 10000; if (extent[0] < niceTickExtent[0]) { - if (expandToNicedExtent) { + if (opt.expandToNicedExtent) { ticks.push({ value: roundNumber(niceTickExtent[0] - interval, intervalPrecision) }); @@ -116,16 +127,26 @@ class IntervalScale = Dictionary> e }); } } - let tick = niceTickExtent[0]; + const estimateNiceMultiple = (tickVal: number, targetTick: number) => { + return Math.round((targetTick - tickVal) / interval); + }; + + let tick = niceTickExtent[0]; while (tick <= niceTickExtent[1]) { - if (this.getBreakIndex(tick) < 0) { - ticks.push({ - value: tick - }); - } + ticks.push({ + value: tick + }); + // Avoid rounding error tick = roundNumber(tick + interval, intervalPrecision); + if (this._brkCtx) { + const moreMultiple = this._brkCtx.calcNiceTickMultiple(tick, estimateNiceMultiple); + if (moreMultiple >= 0) { + tick = roundNumber(tick + moreMultiple * interval, intervalPrecision); + } + } + if (ticks.length > 0 && tick === ticks[ticks.length - 1].value) { // Consider out of safe float point, e.g., // -3711126.9907707 + 2e-10 === -3711126.9907707 @@ -139,7 +160,7 @@ class IntervalScale = Dictionary> e // than niceTickExtent[1] and niceTickExtent[1] === extent[1]. const lastNiceTick = ticks.length ? ticks[ticks.length - 1].value : niceTickExtent[1]; if (extent[1] > lastNiceTick) { - if (expandToNicedExtent) { + if (opt.expandToNicedExtent) { ticks.push({ value: roundNumber(lastNiceTick + interval, intervalPrecision) }); @@ -151,17 +172,43 @@ class IntervalScale = Dictionary> e } } - return helper.addBreakTicks(ticks, this._breaks, this._interval); + if (scaleBreakHelper) { + scaleBreakHelper.pruneTicksByBreak( + opt.pruneByBreak, + ticks, + this._brkCtx!.breaks, + item => item.value, + this._interval, + this._extent + ); + } + if (opt.breakTicks !== 'none' && scaleBreakHelper) { + scaleBreakHelper.addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent); + } + + return ticks; } getMinorTicks(splitNumber: number): number[][] { - const ticks = this.getTicks(true); + const ticks = this.getTicks({ + expandToNicedExtent: true, + }); + // NOTE: In log-scale, do not support minor ticks when breaks exist. + // because currently log-scale minor ticks is calculated based on raw values + // rather than log-transformed value, due to an odd effect when breaks exist. const minorTicks = []; const extent = this.getExtent(); for (let i = 1; i < ticks.length; i++) { const nextTick = ticks[i]; const prevTick = ticks[i - 1]; + + if (prevTick.break || nextTick.break) { + // Do not build minor ticks to the adjacent ticks to breaks ticks, + // since the interval might be irregular. + continue; + } + let count = 0; const minorTicksGroup = []; const interval = nextTick.value - prevTick.value; @@ -176,12 +223,26 @@ class IntervalScale = Dictionary> e } count++; } + + const scaleBreakHelper = getScaleBreakHelper(); + scaleBreakHelper && scaleBreakHelper.pruneTicksByBreak( + 'auto', + minorTicksGroup, + this._getNonTransBreaks(), + value => value, + this._interval, + extent + ); minorTicks.push(minorTicksGroup); } return minorTicks; } + protected _getNonTransBreaks(): ParsedAxisBreakList { + return this._brkCtx ? this._brkCtx.breaks : []; + } + /** * @param opt.precision If 'auto', use nice presision. * @param opt.pad returns 1.50 but not 1.5 if precision is 2. @@ -219,8 +280,8 @@ class IntervalScale = Dictionary> e */ calcNiceTicks(splitNumber?: number, minInterval?: number, maxInterval?: number): void { splitNumber = splitNumber || 5; - const extent = this._extent; - let span = this.getExtentSpanWithoutBreaks(); + let extent = this._extent.slice() as [number, number]; + let span = this._getExtentSpanWithBreaks(); if (!isFinite(span)) { return; } @@ -229,6 +290,8 @@ class IntervalScale = Dictionary> e if (span < 0) { span = -span; extent.reverse(); + this._innerSetExtent(extent[0], extent[1]); + extent = this._extent.slice() as [number, number]; } const result = helper.intervalScaleNiceTicks( @@ -247,7 +310,7 @@ class IntervalScale = Dictionary> e minInterval?: number, maxInterval?: number }): void { - const extent = this._extent; + let extent = this._extent.slice() as [number, number]; // If extent start and end are same, expand them if (extent[0] === extent[1]) { if (extent[0] !== 0) { @@ -277,9 +340,10 @@ class IntervalScale = Dictionary> e extent[0] = 0; extent[1] = 1; } + this._innerSetExtent(extent[0], extent[1]); + extent = this._extent.slice() as [number, number]; this.calcNiceTicks(opt.splitNumber, opt.minInterval, opt.maxInterval); - // let extent = this._extent; const interval = this._interval; if (!opt.fixMin) { @@ -288,11 +352,13 @@ class IntervalScale = Dictionary> e if (!opt.fixMax) { extent[1] = roundNumber(Math.ceil(extent[1] / interval) * interval); } + this._innerSetExtent(extent[0], extent[1]); } - setNiceExtent(min: number, max: number) { + setNiceExtent(min: number, max: number): void { this._niceExtent = [min, max]; } + } Scale.registerClass(IntervalScale); diff --git a/src/scale/Log.ts b/src/scale/Log.ts index 536e114fc0..1055038a57 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -18,78 +18,96 @@ */ import * as zrUtil from 'zrender/src/core/util'; -import Scale from './Scale'; +import Scale, { ScaleGetTicksOpt } from './Scale'; import * as numberUtil from '../util/number'; -import * as scaleHelper from './helper'; // Use some method of IntervalScale import IntervalScale from './Interval'; +import { + DimensionLoose, DimensionName, ParsedAxisBreakList, AxisBreakOption, + ScaleTick +} from '../util/types'; +import { logTransform } from './helper'; import SeriesData from '../data/SeriesData'; -import { DimensionName, ScaleTick } from '../util/types'; - -const scaleProto = Scale.prototype; -// FIXME:TS refactor: not good to call it directly with `this`? -const intervalScaleProto = IntervalScale.prototype; - -const roundingErrorFix = numberUtil.round; +import { getScaleBreakHelper } from './break'; +const fixRound = numberUtil.round; const mathFloor = Math.floor; const mathCeil = Math.ceil; const mathPow = Math.pow; - const mathLog = Math.log; -class LogScale extends Scale { +class LogScale extends IntervalScale { + static type = 'log'; readonly type = 'log'; base = 10; - private _originalScale: IntervalScale = new IntervalScale(); + private _originalScale = new IntervalScale(); private _fixMin: boolean; private _fixMax: boolean; - // FIXME:TS actually used by `IntervalScale` - private _interval: number = 0; - // FIXME:TS actually used by `IntervalScale` - private _niceExtent: [number, number]; - - /** * @param Whether expand the ticks to niced extent. */ - getTicks(expandToNicedExtent?: boolean): ScaleTick[] { - const originalScale = this._originalScale; - const extent = this._extent; - const originalExtent = originalScale.getExtent(); + getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { + opt = opt || {}; + const extent = this._extent.slice() as [number, number]; + const originalExtent = this._originalScale.getExtent(); - const ticks = intervalScaleProto.getTicks.call(this, expandToNicedExtent); + const ticks = super.getTicks(opt); + const base = this.base; + const originalBreaks = this._originalScale._innerGetBreaks(); + const scaleBreakHelper = getScaleBreakHelper(); return zrUtil.map(ticks, function (tick) { const val = tick.value; - let powVal = numberUtil.round(mathPow(this.base, val)); + let powVal = fixRound(mathPow(base, val)); + let roundingCriterion = null; // Fix #4158 - powVal = (val === extent[0] && this._fixMin) - ? fixRoundingError(powVal, originalExtent[0]) - : powVal; - powVal = (val === extent[1] && this._fixMax) - ? fixRoundingError(powVal, originalExtent[1]) - : powVal; + if (val === extent[0] && this._fixMin) { + roundingCriterion = originalExtent[0]; + } + else if (val === extent[1] && this._fixMax) { + roundingCriterion = originalExtent[1]; + } + + let vBreak; + if (scaleBreakHelper) { + const transformed = scaleBreakHelper.getTicksLogTransformBreak( + tick, + base, + originalBreaks, + fixRoundingError + ); + vBreak = transformed.vBreak; + if (roundingCriterion == null) { + roundingCriterion = transformed.brkRoundingCriterion; + } + } + + if (roundingCriterion != null) { + powVal = fixRoundingError(powVal, roundingCriterion); + } return { - value: powVal + value: powVal, + break: vBreak, }; }, this); } + protected _getNonTransBreaks(): ParsedAxisBreakList { + return this._originalScale._innerGetBreaks(); + } + setExtent(start: number, end: number): void { - const base = mathLog(this.base); - // log(-Infinity) is NaN, so safe guard here - start = mathLog(Math.max(0, start)) / base; - end = mathLog(Math.max(0, end)) / base; - intervalScaleProto.setExtent.call(this, start, end); + this._originalScale.setExtent(start, end); + const loggedExtent = logTransform(this.base, [start, end]); + super.setExtent(loggedExtent[0], loggedExtent[1]); } /** @@ -97,32 +115,22 @@ class LogScale extends Scale { */ getExtent() { const base = this.base; - const extent = scaleProto.getExtent.call(this); + const extent = super.getExtent(); extent[0] = mathPow(base, extent[0]); extent[1] = mathPow(base, extent[1]); // Fix #4158 - const originalScale = this._originalScale; - const originalExtent = originalScale.getExtent(); + const originalExtent = this._originalScale.getExtent(); this._fixMin && (extent[0] = fixRoundingError(extent[0], originalExtent[0])); this._fixMax && (extent[1] = fixRoundingError(extent[1], originalExtent[1])); return extent; } - unionExtent(extent: [number, number]): void { - this._originalScale.unionExtent(extent); - - const base = this.base; - extent[0] = mathLog(extent[0]) / mathLog(base); - extent[1] = mathLog(extent[1]) / mathLog(base); - scaleProto.unionExtent.call(this, extent); - } - - unionExtentFromData(data: SeriesData, dim: DimensionName): void { - // TODO - // filter value that <= 0 - this.unionExtent(data.getApproximateExtent(dim)); + unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { + this._originalScale.unionExtentFromData(data, dim); + const loggedOther = logTransform(this.base, data.getApproximateExtent(dim)); + this._innerUnionExtent(loggedOther); } /** @@ -131,9 +139,9 @@ class LogScale extends Scale { */ calcNiceTicks(approxTickNum: number): void { approxTickNum = approxTickNum || 10; - const extent = this._extent; - const span = extent[1] - extent[0]; - if (span === Infinity || span <= 0) { + const extent = this._extent.slice() as [number, number]; + const span = this._getExtentSpanWithBreaks(); + if (!isFinite(span) || span <= 0) { return; } @@ -151,8 +159,8 @@ class LogScale extends Scale { } const niceExtent = [ - numberUtil.round(mathCeil(extent[0] / interval) * interval), - numberUtil.round(mathFloor(extent[1] / interval) * interval) + fixRound(mathCeil(extent[0] / interval) * interval), + fixRound(mathFloor(extent[1] / interval) * interval) ] as [number, number]; this._interval = interval; @@ -160,47 +168,53 @@ class LogScale extends Scale { } calcNiceExtent(opt: { - splitNumber: number, // By default 5. + splitNumber: number, fixMin?: boolean, fixMax?: boolean, minInterval?: number, maxInterval?: number }): void { - intervalScaleProto.calcNiceExtent.call(this, opt); + super.calcNiceExtent(opt); this._fixMin = opt.fixMin; this._fixMax = opt.fixMax; } - parse(val: any): number { - return val; - } - contain(val: number): boolean { val = mathLog(val) / mathLog(this.base); - return scaleHelper.contain(val, this._extent); + return super.contain(val); } normalize(val: number): number { val = mathLog(val) / mathLog(this.base); - return scaleHelper.normalize(val, this._extent, this._breaks); + return super.normalize(val); } scale(val: number): number { - val = scaleHelper.scale(val, this._extent, this._breaks); + val = super.scale(val); return mathPow(this.base, val); } - getMinorTicks: IntervalScale['getMinorTicks']; - getLabel: IntervalScale['getLabel']; -} + setBreaksFromOption( + breakOptionList: AxisBreakOption[], + ): void { + const scaleBreakHelper = getScaleBreakHelper(); + if (!scaleBreakHelper) { + return; + } + const {parsedOriginal, parsedLogged} = scaleBreakHelper.logarithmicParseBreaksFromOption( + breakOptionList, + this.base, + zrUtil.bind(this.parse, this) + ); + this._originalScale._innerSetBreak(parsedOriginal); + this._innerSetBreak(parsedLogged); + } -const proto = LogScale.prototype; -proto.getMinorTicks = intervalScaleProto.getMinorTicks; -proto.getLabel = intervalScaleProto.getLabel; +} function fixRoundingError(val: number, originalVal: number): number { - return roundingErrorFix(val, numberUtil.getPrecision(originalVal)); + return fixRound(val, numberUtil.getPrecision(originalVal)); } diff --git a/src/scale/Ordinal.ts b/src/scale/Ordinal.ts index dd7d255c88..5c45f869cf 100644 --- a/src/scale/Ordinal.ts +++ b/src/scale/Ordinal.ts @@ -26,12 +26,10 @@ import Scale from './Scale'; import OrdinalMeta from '../data/OrdinalMeta'; -import SeriesData from '../data/SeriesData'; import * as scaleHelper from './helper'; import { OrdinalRawValue, OrdinalNumber, - DimensionLoose, OrdinalSortInfo, OrdinalScaleTick, ScaleTick @@ -140,10 +138,9 @@ class OrdinalScale extends Scale { : Math.round(val); } - contain(rank: OrdinalRawValue | OrdinalNumber): boolean { - rank = this.parse(rank); - return scaleHelper.contain(rank, this._extent) - && this._ordinalMeta.categories[rank] != null; + contain(val: OrdinalNumber): boolean { + return scaleHelper.contain(val, this._extent) + && this._ordinalMeta.categories[val] != null; } /** @@ -151,9 +148,9 @@ class OrdinalScale extends Scale { * @param val raw ordinal number. * @return normalized value in [0, 1]. */ - normalize(val: OrdinalRawValue | OrdinalNumber): number { - val = this._getTickNumber(this.parse(val)); - return scaleHelper.normalize(val, this._extent); + normalize(val: OrdinalNumber): number { + val = this._getTickNumber(val); + return this._calculator.normalize(val, this._extent); } /** @@ -161,7 +158,7 @@ class OrdinalScale extends Scale { * @return raw ordinal number. */ scale(val: number): OrdinalNumber { - val = Math.round(scaleHelper.scale(val, this._extent)); + val = Math.round(this._calculator.scale(val, this._extent)); return this.getRawOrdinalNumber(val); } @@ -267,10 +264,6 @@ class OrdinalScale extends Scale { return this._extent[1] - this._extent[0] + 1; } - unionExtentFromData(data: SeriesData, dim: DimensionLoose) { - this.unionExtent(data.getApproximateExtent(dim)); - } - /** * @override * If value is in extent range diff --git a/src/scale/Scale.ts b/src/scale/Scale.ts index 25ae823796..089791b0f2 100644 --- a/src/scale/Scale.ts +++ b/src/scale/Scale.ts @@ -24,24 +24,51 @@ import SeriesData from '../data/SeriesData'; import { DimensionName, ScaleDataValue, - OptionDataValue, DimensionLoose, ScaleTick, - ScaleBreak + AxisBreakOption, + NullUndefined, + ParsedAxisBreakList, } from '../util/types'; -import { getExtentSpanWithoutBreaks } from './helper'; +import { + ScaleCalculator +} from './helper'; import { ScaleRawExtentInfo } from '../coord/scaleRawExtentInfo'; - - -abstract class Scale = Dictionary> { +import { bind } from 'zrender/src/core/util'; +import { ScaleBreakContext, AxisBreakParsingResult, getScaleBreakHelper, ParamPruneByBreak } from './break'; + +export type ScaleGetTicksOpt = { + // Whether expand the ticks to niced extent. + expandToNicedExtent?: boolean; + pruneByBreak?: ParamPruneByBreak; + // - not specified or undefined(default): insert the breaks as items into the tick array. + // - 'only-break': return break ticks only without any normal ticks. + // - 'none': return only normal ticks without any break ticks. Useful when creating split + // line / split area, where break area is rendered using zigzag line. + // NOTE: The returned break ticks do not outside axis extent. And if a break only intersects + // with axis extent at start or end, it does not count as a tick. + breakTicks?: 'only_break' | 'none' | NullUndefined; +}; + +export type ScaleSettingDefault = Dictionary; + +abstract class Scale { type: string; private _setting: SETTING; + // [CAVEAT]: Should update only by `_innerSetExtent`! protected _extent: [number, number]; - protected _breaks: ScaleBreak[]; + // FIXME: Effectively, both logorithmic scale and break scale are numeric axis transformation + // mechanisms. However, for historical reason, logorithmic scale is implemented as a subclass, + // while break scale is implemented inside the base class `Scale`. If more transformations + // need to be introduced in futher, we should probably refactor them for better orthogonal + // composition. (e.g. use decorator-like patterns rather than the current class inheritance?) + protected _brkCtx: ScaleBreakContext | NullUndefined; + + protected _calculator: ScaleCalculator = new ScaleCalculator(); private _isBlank: boolean; @@ -51,7 +78,11 @@ abstract class Scale = Dictionary> constructor(setting?: SETTING) { this._setting = setting || {} as SETTING; this._extent = [Infinity, -Infinity]; - this._breaks = this._setting.breaks as ScaleBreak[] || []; + const scaleBreakHelper = getScaleBreakHelper(); + if (scaleBreakHelper) { + this._brkCtx = scaleBreakHelper.createScaleBreakContext(); + this._brkCtx!.update(this._extent); + } } getSetting(name: KEY): SETTING[KEY] { @@ -65,17 +96,17 @@ abstract class Scale = Dictionary> * before extent set (like in dataZoom), it would be wrong. * Nevertheless, parse does not depend on extent generally. */ - abstract parse(val: OptionDataValue): number; + abstract parse(val: ScaleDataValue): number; /** * Whether contain the given value. */ - abstract contain(val: ScaleDataValue): boolean; + abstract contain(val: number): boolean; /** * Normalize value to linear [0, 1], return 0.5 if extent span is 0. */ - abstract normalize(val: ScaleDataValue): number; + abstract normalize(val: number): number; /** * Scale normalized value to extent. @@ -83,36 +114,40 @@ abstract class Scale = Dictionary> abstract scale(val: number): number; /** - * Set extent from data + * [CAVEAT]: It should not be overridden! */ - unionExtent(other: [number, number]): void { + _innerUnionExtent(other: [number, number]): void { const extent = this._extent; - other[0] < extent[0] && (extent[0] = other[0]); - other[1] > extent[1] && (extent[1] = other[1]); - // not setExtent because in log axis it may transformed to power - // this.setExtent(extent[0], extent[1]); + // Considered that number could be NaN and should not write into the extent. + this._innerSetExtent( + other[0] < extent[0] ? other[0] : extent[0], + other[1] > extent[1] ? other[1] : extent[1] + ); } /** * Set extent from data */ unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { - this.unionExtent(data.getApproximateExtent(dim)); + this._innerUnionExtent(data.getApproximateExtent(dim)); } /** - * Get extent - * + * Get a new slice of extent. * Extent is always in increase order. */ getExtent(): [number, number] { return this._extent.slice() as [number, number]; } + setExtent(start: number, end: number): void { + this._innerSetExtent(start, end); + } + /** - * Set extent + * [CAVEAT]: It should not be overridden! */ - setExtent(start: number, end: number): void { + protected _innerSetExtent(start: number, end: number): void { const thisExtent = this._extent; if (!isNaN(start)) { thisExtent[0] = start; @@ -120,65 +155,52 @@ abstract class Scale = Dictionary> if (!isNaN(end)) { thisExtent[1] = end; } + this._brkCtx && this._brkCtx.update(thisExtent); } - getBreaks() { - return this.type === 'ordinal' - ? [] - : this._breaks; - } - - expandBreak(breakStart: number, breakEnd: number) { - this.expandBreaks([{ start: breakStart, end: breakEnd, gap: 0 }]); - } - - expandBreaks(breaks: ScaleBreak[]): void { - for (let j = 0; j < breaks.length; j++) { - const expandBrk = breaks[j]; - for (let i = 0; i < this._breaks.length; i++) { - const brk = this._breaks[i]; - if (expandBrk.start === brk.start && expandBrk.end === brk.end) { - brk.isExpanded = true; - break; - } - } + /** + * Prerequisite: Scale#parse is ready. + */ + setBreaksFromOption( + breakOptionList: AxisBreakOption[], + ): void { + const scaleBreakHelper = getScaleBreakHelper(); + if (scaleBreakHelper) { + this._innerSetBreak( + scaleBreakHelper.parseAxisBreakOption(breakOptionList, bind(this.parse, this)) + ); } } /** - * Revert all breaks to their initial state + * [CAVEAT]: It should not be overridden! */ - revertBreaks(): void { - for (let i = 0; i < this._breaks.length; i++) { - const brk = this._breaks[i]; - brk.isExpanded = false; + _innerSetBreak(parsed: AxisBreakParsingResult) { + if (this._brkCtx) { + this._brkCtx.setBreaks(parsed); + this._calculator.updateMethods(this._brkCtx); + this._brkCtx.update(this._extent); } } - getExtentSpanWithoutBreaks() { - return getExtentSpanWithoutBreaks(this._extent, this._breaks); - } - - getBrokenExtentRatio(): number { - const realSpan = getExtentSpanWithoutBreaks(this._extent, this._breaks); - const totalSpan = this._extent[1] - this._extent[0]; - return totalSpan === 0 ? 1 : realSpan / totalSpan; + /** + * [CAVEAT]: It should not be overridden! + */ + _innerGetBreaks(): ParsedAxisBreakList { + return this._brkCtx ? this._brkCtx.breaks : []; } /** - * Whether a value is in broken range (not including either ends) - * @return break index if in any of the broken range, otherwise return -1 + * Do not expose the internal `_breaks` unless necessary. */ - getBreakIndex(val: number, includeEnd = false): number { - for (let i = 0; i < this._breaks.length; i++) { - const brk = this._breaks[i]; - if (!brk.isExpanded && brk.start <= val - && (brk.end > val || brk.end === val && includeEnd) - ) { - return i; - } - } - return -1; + hasBreaks(): boolean { + return this._brkCtx ? this._brkCtx.hasBreaks() : false; + } + + protected _getExtentSpanWithBreaks() { + return (this._brkCtx && this._brkCtx.hasBreaks()) + ? this._brkCtx.getExtentSpan() + : this._extent[1] - this._extent[0]; } /** @@ -235,7 +257,7 @@ abstract class Scale = Dictionary> */ abstract getLabel(tick: ScaleTick): string; - abstract getTicks(): ScaleTick[]; + abstract getTicks(opt?: ScaleGetTicksOpt): ScaleTick[]; abstract getMinorTicks(splitNumber: number): number[][]; diff --git a/src/scale/Time.ts b/src/scale/Time.ts index 734a031c8e..85768cacf2 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -49,7 +49,6 @@ import { leveledFormat, PrimaryTimeUnit, TimeUnit, - getUnitValue, timeUnits, fullLeveledFormatter, getPrimaryTimeUnit, @@ -68,17 +67,23 @@ import { dateGetterName, minutesGetterName, secondsGetterName, - millisecondsGetterName + millisecondsGetterName, + JSDateGetterNames, + JSDateSetterNames, + getUnitFromValue, + primaryTimeUnits, + roundTime } from '../util/time'; import * as scaleHelper from './helper'; import IntervalScale from './Interval'; -import Scale from './Scale'; -import {TimeScaleTick, ScaleTick, ScaleBreak} from '../util/types'; -import {TimeAxisLabelFormatterOption} from '../coord/axisCommonTypes'; +import Scale, { ScaleGetTicksOpt } from './Scale'; +import {TimeScaleTick, ScaleTick, AxisBreakOption, NullUndefined} from '../util/types'; +import {TimeAxisLabelFormatterParsed} from '../coord/axisCommonTypes'; import { warn } from '../util/log'; import { LocaleOption } from '../core/locale'; import Model from '../model/Model'; -import { filter, isNumber, map } from 'zrender/src/core/util'; +import { each, filter, indexOf, isNumber, map } from 'zrender/src/core/util'; +import { ScaleBreakContext, getScaleBreakHelper } from './break'; // FIXME 公用? const bisect = function ( @@ -102,7 +107,7 @@ const bisect = function ( type TimeScaleSetting = { locale: Model; useUTC: boolean; - breaks?: ScaleBreak[]; + modelAxisBreaks?: AxisBreakOption[]; }; class TimeScale extends IntervalScale { @@ -110,23 +115,12 @@ class TimeScale extends IntervalScale { static type = 'time'; readonly type = 'time'; - _approxInterval: number; + private _approxInterval: number; - _minLevelUnit: TimeUnit; + private _minLevelUnit: TimeUnit; constructor(settings?: TimeScaleSetting) { super(settings); - - // Normalize Date into timestamp in breaks - for (let i = 0; i < this._breaks.length; i++) { - const brk = this._breaks[i]; - if ((brk.start as unknown) instanceof Date) { - brk.start = (brk.start as unknown as Date).getTime(); - } - if ((brk.end as unknown) instanceof Date) { - brk.end = (brk.end as unknown as Date).getTime(); - } - } } /** @@ -145,9 +139,9 @@ class TimeScale extends IntervalScale { } getFormattedLabel( - tick: TimeScaleTick, + tick: ScaleTick, idx: number, - labelFormatter: TimeAxisLabelFormatterOption + labelFormatter: TimeAxisLabelFormatterParsed ): string { const isUTC = this.getSetting('useUTC'); const lang = this.getSetting('locale'); @@ -157,9 +151,12 @@ class TimeScale extends IntervalScale { /** * @override */ - getTicks(): TimeScaleTick[] { + getTicks(opt?: ScaleGetTicksOpt): TimeScaleTick[] { + opt = opt || {}; + const interval = this._interval; const extent = this._extent; + const scaleBreakHelper = getScaleBreakHelper(); let ticks = [] as TimeScaleTick[]; // If interval is 0, return []; @@ -167,28 +164,89 @@ class TimeScale extends IntervalScale { return ticks; } + const useUTC = this.getSetting('useUTC'); + + if (scaleBreakHelper && opt.breakTicks === 'only_break') { + getScaleBreakHelper().addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent); + return ticks; + } + + const extent0Unit = getUnitFromValue(extent[1], useUTC); ticks.push({ value: extent[0], - level: 0 + time: { + level: 0, + upperTimeUnit: extent0Unit, + lowerTimeUnit: extent0Unit, + } }); - const useUTC = this.getSetting('useUTC'); - const innerTicks = getIntervalTicks( this._minLevelUnit, this._approxInterval, useUTC, - extent + extent, + this._getExtentSpanWithBreaks(), + this._brkCtx ); ticks = ticks.concat(innerTicks); + const extent1Unit = getUnitFromValue(extent[1], useUTC); ticks.push({ value: extent[1], - level: 0 + time: { + level: 0, + upperTimeUnit: extent1Unit, + lowerTimeUnit: extent1Unit, + } }); - return scaleHelper.addBreakTicks(ticks, this._breaks, interval); + const isUTC = this.getSetting('useUTC'); + let upperUnitIndex = primaryTimeUnits.length - 1; + let maxLevel = 0; + each(ticks, tick => { + upperUnitIndex = Math.min(upperUnitIndex, indexOf(primaryTimeUnits, tick.time.upperTimeUnit)); + maxLevel = Math.max(maxLevel, tick.time.level); + }); + + if (scaleBreakHelper) { + getScaleBreakHelper().pruneTicksByBreak( + opt.pruneByBreak, + ticks, + this._brkCtx!.breaks, + item => item.value, + this._approxInterval, + this._extent + ); + } + if (scaleBreakHelper && opt.breakTicks !== 'none') { + getScaleBreakHelper().addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent, trimmedBrk => { + // @see `parseTimeAxisLabelFormatterDictionary`. + const lowerBrkUnitIndex = Math.max( + indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmin, isUTC)), + indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmax, isUTC)), + ); + let upperBrkUnitIndex = 0; + for (let unitIdx = 0; unitIdx < primaryTimeUnits.length; unitIdx++) { + if (!isPrimaryUnitValueAndGreaterSame( + primaryTimeUnits[unitIdx], trimmedBrk.vmin, trimmedBrk.vmax, isUTC + )) { + upperBrkUnitIndex = unitIdx; + break; + } + } + const upperIdx = Math.min(upperBrkUnitIndex, upperUnitIndex); + const lowerIdx = Math.max(upperIdx, lowerBrkUnitIndex); + return { + level: maxLevel, + lowerTimeUnit: primaryTimeUnits[lowerIdx], + upperTimeUnit: primaryTimeUnits[upperIdx], + }; + }); + } + + return ticks; } calcNiceExtent( @@ -200,7 +258,7 @@ class TimeScale extends IntervalScale { maxInterval?: number } ): void { - const extent = this._extent; + const extent = this.getExtent(); // If extent start and end are same, expand them if (extent[0] === extent[1]) { // Expand extent @@ -213,6 +271,7 @@ class TimeScale extends IntervalScale { extent[1] = +new Date(d.getFullYear(), d.getMonth(), d.getDate()); extent[0] = extent[1] - ONE_DAY; } + this._innerSetExtent(extent[0], extent[1]); this.calcNiceTicks(opt.splitNumber, opt.minInterval, opt.maxInterval); } @@ -220,8 +279,7 @@ class TimeScale extends IntervalScale { calcNiceTicks(approxTickNum: number, minInterval: number, maxInterval: number): void { approxTickNum = approxTickNum || 10; - const extent = this._extent; - const span = extent[1] - extent[0]; + const span = this._getExtentSpanWithBreaks(); this._approxInterval = span / approxTickNum; if (minInterval != null && this._approxInterval < minInterval) { @@ -250,15 +308,15 @@ class TimeScale extends IntervalScale { } contain(val: number): boolean { - return scaleHelper.contain(this.parse(val), this._extent); + return scaleHelper.contain(val, this._extent); } normalize(val: number): number { - return scaleHelper.normalize(this.parse(val), this._extent, this._breaks); + return this._calculator.normalize(val, this._extent); } scale(val: number): number { - return scaleHelper.scale(val, this._extent, this._breaks); + return this._calculator.scale(val, this._extent); } } @@ -286,48 +344,58 @@ const scaleIntervals: [TimeUnit, number][] = [ ['year', ONE_YEAR] // 1Y ]; -function isUnitValueSame( +function isPrimaryUnitValueAndGreaterSame( unit: PrimaryTimeUnit, valueA: number, valueB: number, isUTC: boolean ): boolean { - const dateA = numberUtil.parseDate(valueA) as any; - const dateB = numberUtil.parseDate(valueB) as any; - - const isSame = (unit: PrimaryTimeUnit) => { - return getUnitValue(dateA, unit, isUTC) - === getUnitValue(dateB, unit, isUTC); - }; - const isSameYear = () => isSame('year'); - // const isSameHalfYear = () => isSameYear() && isSame('half-year'); - // const isSameQuater = () => isSameYear() && isSame('quarter'); - const isSameMonth = () => isSameYear() && isSame('month'); - const isSameDay = () => isSameMonth() && isSame('day'); - // const isSameHalfDay = () => isSameDay() && isSame('half-day'); - const isSameHour = () => isSameDay() && isSame('hour'); - const isSameMinute = () => isSameHour() && isSame('minute'); - const isSameSecond = () => isSameMinute() && isSame('second'); - const isSameMilliSecond = () => isSameSecond() && isSame('millisecond'); - - switch (unit) { - case 'year': - return isSameYear(); - case 'month': - return isSameMonth(); - case 'day': - return isSameDay(); - case 'hour': - return isSameHour(); - case 'minute': - return isSameMinute(); - case 'second': - return isSameSecond(); - case 'millisecond': - return isSameMilliSecond(); - } + return roundTime(new Date(valueA), unit, isUTC).getTime() + === roundTime(new Date(valueB), unit, isUTC).getTime(); } +// function isUnitValueSame( +// unit: PrimaryTimeUnit, +// valueA: number, +// valueB: number, +// isUTC: boolean +// ): boolean { +// const dateA = numberUtil.parseDate(valueA) as any; +// const dateB = numberUtil.parseDate(valueB) as any; + +// const isSame = (unit: PrimaryTimeUnit) => { +// return getUnitValue(dateA, unit, isUTC) +// === getUnitValue(dateB, unit, isUTC); +// }; +// const isSameYear = () => isSame('year'); +// // const isSameHalfYear = () => isSameYear() && isSame('half-year'); +// // const isSameQuater = () => isSameYear() && isSame('quarter'); +// const isSameMonth = () => isSameYear() && isSame('month'); +// const isSameDay = () => isSameMonth() && isSame('day'); +// // const isSameHalfDay = () => isSameDay() && isSame('half-day'); +// const isSameHour = () => isSameDay() && isSame('hour'); +// const isSameMinute = () => isSameHour() && isSame('minute'); +// const isSameSecond = () => isSameMinute() && isSame('second'); +// const isSameMilliSecond = () => isSameSecond() && isSame('millisecond'); + +// switch (unit) { +// case 'year': +// return isSameYear(); +// case 'month': +// return isSameMonth(); +// case 'day': +// return isSameDay(); +// case 'hour': +// return isSameHour(); +// case 'minute': +// return isSameMinute(); +// case 'second': +// return isSameSecond(); +// case 'millisecond': +// return isSameMilliSecond(); +// } +// } + // const primaryUnitGetters = { // year: fullYearGetterName(), // month: monthGetterName(), @@ -419,36 +487,47 @@ function getMillisecondsInterval(approxInterval: number) { return numberUtil.nice(approxInterval, true); } -function getFirstTimestampOfUnit(date: Date, unitName: TimeUnit, isUTC: boolean) { - const outDate = new Date(date); - switch (getPrimaryTimeUnit(unitName)) { - case 'year': - case 'month': - outDate[monthSetterName(isUTC)](0); - case 'day': - outDate[dateSetterName(isUTC)](1); - case 'hour': - outDate[hoursSetterName(isUTC)](0); - case 'minute': - outDate[minutesSetterName(isUTC)](0); - case 'second': - outDate[secondsSetterName(isUTC)](0); - outDate[millisecondsSetterName(isUTC)](0); - } - return outDate.getTime(); +// e.g., if the input unit is 'day', start calculate ticks from the first day of +// that month to make ticks "nice". +function getFirstTimestampOfUnit(timestamp: number, unitName: TimeUnit, isUTC: boolean) { + const upperUnitIdx = Math.max(0, indexOf(primaryTimeUnits, unitName) - 1); + return roundTime(new Date(timestamp), primaryTimeUnits[upperUnitIdx], isUTC).getTime(); +} + +function createEstimateNiceMultiple( + setMethodName: JSDateSetterNames, + dateMethodInterval: number, +) { + const tmpDate = new Date(0); + tmpDate[setMethodName](1); + const tmpTime = tmpDate.getTime(); + tmpDate[setMethodName](1 + dateMethodInterval); + const approxTimeInterval = tmpDate.getTime() - tmpTime; + + return (tickVal: number, targetValue: number) => { + // Only in month that accurate result can not get by division of + // timestamp interval, but no need accurate here. + return Math.max( + 0, + Math.round((targetValue - tickVal) / approxTimeInterval) + ); + }; } function getIntervalTicks( bottomUnitName: TimeUnit, approxInterval: number, isUTC: boolean, - extent: number[] + extent: number[], + extentSpanWithBreaks: number, + brkCtx: ScaleBreakContext | NullUndefined, ): TimeScaleTick[] { const safeLimit = 10000; const unitNames = timeUnits; // const bottomPrimaryUnitName = getPrimaryTimeUnit(bottomUnitName); - interface InnerTimeTick extends TimeScaleTick { + interface InnerTimeTick { + value: TimeScaleTick['value'] notAdd?: boolean } @@ -456,15 +535,17 @@ function getIntervalTicks( function addTicksInSpan( interval: number, - minTimestamp: number, maxTimestamp: number, - getMethodName: string, - setMethodName: string, + minTimestamp: number, + maxTimestamp: number, + getMethodName: JSDateGetterNames, + setMethodName: JSDateSetterNames, isDate: boolean, out: InnerTimeTick[] ) { - const date = new Date(minTimestamp) as any; + const estimateNiceMultiple = createEstimateNiceMultiple(setMethodName, interval); + let dateTime = minTimestamp; - let d = date[getMethodName](); + const date = new Date(dateTime); // if (isDate) { // d -= 1; // Starts with 0; PENDING @@ -475,9 +556,23 @@ function getIntervalTicks( value: dateTime }); - d += interval; - date[setMethodName](d); + if (iter++ > safeLimit) { + if (__DEV__) { + warn('Exceed safe limit in time scale.'); + } + break; + } + + date[setMethodName](date[getMethodName]() + interval); dateTime = date.getTime(); + + if (brkCtx) { + const moreMultiple = brkCtx.calcNiceTickMultiple(dateTime, estimateNiceMultiple); + if (moreMultiple > 0) { + date[setMethodName](date[getMethodName]() + moreMultiple * interval); + dateTime = date.getTime(); + } + } } // This extra tick is for calcuating ticks of next level. Will not been added to the final result @@ -495,14 +590,13 @@ function getIntervalTicks( const newAddedTicks: ScaleTick[] = []; const isFirstLevel = !lastLevelTicks.length; - if (isUnitValueSame(getPrimaryTimeUnit(unitName), extent[0], extent[1], isUTC)) { + if (isPrimaryUnitValueAndGreaterSame(getPrimaryTimeUnit(unitName), extent[0], extent[1], isUTC)) { return; } if (isFirstLevel) { lastLevelTicks = [{ - // TODO Optimize. Not include so may ticks. - value: getFirstTimestampOfUnit(new Date(extent[0]), unitName, isUTC) + value: getFirstTimestampOfUnit(extent[0], unitName, isUTC), }, { value: extent[1] }]; @@ -516,8 +610,8 @@ function getIntervalTicks( } let interval: number; - let getterName; - let setterName; + let getterName: JSDateGetterNames; + let setterName: JSDateSetterNames; let isDate = false; switch (unitName) { @@ -565,9 +659,14 @@ function getIntervalTicks( break; } - addTicksInSpan( - interval, startTick, endTick, getterName, setterName, isDate, newAddedTicks - ); + // Notice: This expansion by `getFirstTimestampOfUnit` may cause too many ticks and + // iteration. e.g., when three levels of ticks is displayed, which can be caused by + // data zoom and axis breaks. Thus trim them here. + if (endTick >= extent[0] && startTick <= extent[1]) { + addTicksInSpan( + interval, startTick, endTick, getterName, setterName, isDate, newAddedTicks + ); + } if (unitName === 'year' && levelTicks.length > 1 && i === 0) { // Add nearest years to the left extent. @@ -580,8 +679,6 @@ function getIntervalTicks( for (let i = 0; i < newAddedTicks.length; i++) { levelTicks.push(newAddedTicks[i]); } - // newAddedTicks.length && console.log(unitName, newAddedTicks); - return newAddedTicks; } const levelsTicks: InnerTimeTick[][] = []; @@ -589,7 +686,7 @@ function getIntervalTicks( let tickCount = 0; let lastLevelTickCount = 0; - for (let i = 0; i < unitNames.length && iter++ < safeLimit; ++i) { + for (let i = 0; i < unitNames.length; ++i) { const primaryTimeUnit = getPrimaryTimeUnit(unitNames[i]); if (!isPrimaryTimeUnit(unitNames[i])) { // TODO continue; @@ -613,7 +710,7 @@ function getIntervalTicks( } } - const targetTickNum = (extent[1] - extent[0]) / approxInterval; + const targetTickNum = extentSpanWithBreaks / approxInterval; // Added too much in this level and not too less in last level if (tickCount > targetTickNum * 1.5 && lastLevelTickCount > targetTickNum / 1.5) { break; @@ -633,12 +730,6 @@ function getIntervalTicks( } - if (__DEV__) { - if (iter >= safeLimit) { - warn('Exceed safe limit.'); - } - } - const levelsTicksInExtent = filter(map(levelsTicks, levelTicks => { return filter(levelTicks, tick => tick.value >= extent[0] && tick.value <= extent[1] && !tick.notAdd); }), levelTicks => levelTicks.length > 0); @@ -648,9 +739,14 @@ function getIntervalTicks( for (let i = 0; i < levelsTicksInExtent.length; ++i) { const levelTicks = levelsTicksInExtent[i]; for (let k = 0; k < levelTicks.length; ++k) { + const unit = getUnitFromValue(levelTicks[k].value, isUTC); ticks.push({ value: levelTicks[k].value, - level: maxLevel - i + time: { + level: maxLevel - i, + upperTimeUnit: unit, + lowerTimeUnit: unit, + }, }); } } diff --git a/src/scale/break.ts b/src/scale/break.ts new file mode 100644 index 0000000000..6393c7060d --- /dev/null +++ b/src/scale/break.ts @@ -0,0 +1,147 @@ +/* +* 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 { AxisLabelFormatterExtraParams } from '../coord/axisCommonTypes'; +import type { + NullUndefined, ParsedAxisBreak, ParsedAxisBreakList, AxisBreakOption, + AxisBreakOptionIdentifierInAxis, ScaleTick, VisualAxisBreak, +} from '../util/types'; +import type Scale from './Scale'; + +/** + * @file The fasade of scale break. + * Separate the impl to reduce code size. + * + * @caution + * Must not import `scale/breakImpl.ts` directly or indirectly. + * Must not implement anything in this file. + */ + +export interface ScaleBreakContext { + + readonly breaks: ParsedAxisBreakList; + + setBreaks(parsed: AxisBreakParsingResult): void; + + update(scaleExtent: [number, number]): void; + + hasBreaks(): boolean; + + calcNiceTickMultiple( + tickVal: number, + estimateNiceMultiple: (tickVal: number, brkEnd: number) => number + ): number; + + getExtentSpan(): number; + + normalize(val: number): number; + + scale(val: number): number; + + elapse(val: number): number; + + unelapse(elapsedVal: number): number; + +}; + +export type AxisBreakParsingResult = { + breaks: ParsedAxisBreakList; +}; + +/** + * Whether to remove any normal ticks that are too close to axis breaks. + * - 'auto': Default. Remove any normal ticks that are too close to axis breaks. + * - 'no': Do nothing pruning. + * - 'exclude_scale_bound': Prune but keep scale extent boundary. + * For example: + * - For splitLine, if remove the tick on extent, split line on the bounary of cartesian + * will not be displayed, causing werid effect. + * - For labels, scale extent boundary should be pruned if in break, otherwise duplicated + * labels will displayed. + */ +export type ParamPruneByBreak = 'auto' | 'no' | 'preserve_extent_bound' | NullUndefined; + +export type ScaleBreakHelper = { + createScaleBreakContext(): ScaleBreakContext; + pruneTicksByBreak( + pruneByBreak: ParamPruneByBreak, + ticks: TItem[], + breaks: ParsedAxisBreakList, + getValue: (item: TItem) => number, + interval: number, + scaleExtent: [number, number] + ): void; + addBreaksToTicks( + ticks: ScaleTick[], + breaks: ParsedAxisBreakList, + scaleExtent: [number, number], + getTimeProps?: (clampedBrk: ParsedAxisBreak) => ScaleTick['time'], + ): void; + parseAxisBreakOption( + breakOptionList: AxisBreakOption[] | NullUndefined, + parse: Scale['parse'], + opt?: { + noNegative: boolean; + } + ): AxisBreakParsingResult; + identifyAxisBreak( + brk: AxisBreakOption, + identifier: AxisBreakOptionIdentifierInAxis + ): boolean; + serializeAxisBreakIdentifier( + identifier: AxisBreakOptionIdentifierInAxis + ): string; + retrieveAxisBreakPairs( + itemList: TItem[], + getVisualAxisBreak: (item: TItem) => VisualAxisBreak + ): TItem[][]; + getTicksLogTransformBreak( + tick: ScaleTick, + logBase: number, + logOriginalBreaks: ParsedAxisBreakList, + fixRoundingError: (val: number, originalVal: number) => number + ): { + brkRoundingCriterion: number | NullUndefined; + vBreak: VisualAxisBreak | NullUndefined; + }; + logarithmicParseBreaksFromOption( + breakOptionList: AxisBreakOption[], + logBase: number, + parse: Scale['parse'], + ): { + parsedOriginal: AxisBreakParsingResult; + parsedLogged: AxisBreakParsingResult; + }; + makeAxisLabelFormatterParamBreak( + extraParam: AxisLabelFormatterExtraParams | NullUndefined, + vBreak: VisualAxisBreak | NullUndefined + ): AxisLabelFormatterExtraParams | NullUndefined; +}; + +let _impl: ScaleBreakHelper = null; + +export function registerScaleBreakHelperImpl(impl: ScaleBreakHelper): void { + if (!_impl) { + _impl = impl; + } +} + +export function getScaleBreakHelper(): ScaleBreakHelper | NullUndefined { + return _impl; +} diff --git a/src/scale/breakImpl.ts b/src/scale/breakImpl.ts new file mode 100644 index 0000000000..b4421cca96 --- /dev/null +++ b/src/scale/breakImpl.ts @@ -0,0 +1,717 @@ +/* +* 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 { assert, clone, each, find, isString, map, trim } from 'zrender/src/core/util'; +import { + NullUndefined, ParsedAxisBreak, ParsedAxisBreakList, AxisBreakOption, + AxisBreakOptionIdentifierInAxis, ScaleTick, VisualAxisBreak, +} from '../util/types'; +import { error } from '../util/log'; +import type Scale from './Scale'; +import { ScaleBreakContext, AxisBreakParsingResult, registerScaleBreakHelperImpl, ParamPruneByBreak } from './break'; +import { round as fixRound } from '../util/number'; +import { AxisLabelFormatterExtraParams } from '../coord/axisCommonTypes'; + +/** + * @caution + * Must not export anything except `installScaleBreakHelper` + */ + +class ScaleBreakContextImpl implements ScaleBreakContext { + + // [CAVEAT]: Should set only by `ScaleBreakContext#setBreaks`! + readonly breaks: ParsedAxisBreakList = []; + + // [CAVEAT]: Should update only by `ScaleBreakContext#update`! + // They are the values that scaleExtent[0] and scaleExtent[1] are mapped to a numeric axis + // that breaks are applied, primarily for optimization of `Scale#normalize`. + private _elapsedExtent: [number, number] = [Infinity, -Infinity]; + + setBreaks(parsed: AxisBreakParsingResult): void { + // @ts-ignore + this.breaks = parsed.breaks; + } + + /** + * [CAVEAT]: Must be called immediately each time scale extent and breaks are updated! + */ + update(scaleExtent: [number, number]): void { + updateAxisBreakGapReal(this, scaleExtent); + const elapsedExtent = this._elapsedExtent; + elapsedExtent[0] = this.elapse(scaleExtent[0]); + elapsedExtent[1] = this.elapse(scaleExtent[1]); + } + + hasBreaks(): boolean { + return !!this.breaks.length; + } + + /** + * When iteratively generating ticks by nice interval, currently the `interval`, which is + * calculated by break-elapsed extent span, is probably very small comparing to the original + * extent, leading to a large number of iteration and tick generation, even over `safeLimit`. + * Thus stepping over breaks is necessary in that loop. + * + * "Nice" should be ensured on ticks when step over the breaks. Thus this method returns + * a integer multiple of the "nice tick interval". + * + * This method does little work; it is just for unifying and restricting the behavior. + */ + calcNiceTickMultiple( + tickVal: number, + estimateNiceMultiple: (tickVal: number, brkEnd: number) => number + ): number { + for (let idx = 0; idx < this.breaks.length; idx++) { + const brk = this.breaks[idx]; + if (brk.vmin < tickVal && tickVal < brk.vmax) { + const multiple = estimateNiceMultiple(tickVal, brk.vmax); + if (__DEV__) { + // If not, it may cause dead loop or not nice tick. + assert(multiple >= 0 && Math.round(multiple) === multiple); + } + return multiple; + } + } + return 0; + } + + getExtentSpan(): number { + return this._elapsedExtent[1] - this._elapsedExtent[0]; + } + + normalize(val: number): number { + const elapsedSpan = this._elapsedExtent[1] - this._elapsedExtent[0]; + // The same logic as `Scale#normalize`. + if (elapsedSpan === 0) { + return 0.5; + } + return (this.elapse(val) - this._elapsedExtent[0]) / elapsedSpan; + } + + scale(val: number): number { + return this.unelapse( + val * (this._elapsedExtent[1] - this._elapsedExtent[0]) + this._elapsedExtent[0] + ); + } + + /** + * Suppose: + * AXIS_BREAK_LAST_BREAK_END_BASE: 0 + * AXIS_BREAK_ELAPSED_BASE: 0 + * breaks: [ + * {start: -400, end: -300, gap: 27}, + * {start: -100, end: 100, gap: 10}, + * {start: 200, end: 400, gap: 300}, + * ] + * The mapping will be: + * | | + * 400 + -> + 237 + * | | | | (gap: 300) + * 200 + -> + -63 + * | | + * 100 + -> + -163 + * | | | | (gap: 10) + * -100 + -> + -173 + * | | + * -300 + -> + -373 + * | | | | (gap: 27) + * -400 + -> + -400 + * | | + * origianl elapsed + * + * Note: + * The mapping has nothing to do with "scale extent". + */ + elapse(val: number): number { + // If the value is in the break, return the normalized value in the break + let elapsedVal = AXIS_BREAK_ELAPSED_BASE; + let lastBreakEnd = AXIS_BREAK_LAST_BREAK_END_BASE; + let stillOver = true; + for (let i = 0; i < this.breaks.length; i++) { + const brk = this.breaks[i]; + if (val <= brk.vmax) { + if (val > brk.vmin) { + elapsedVal += brk.vmin - lastBreakEnd + + (val - brk.vmin) / (brk.vmax - brk.vmin) * brk.gapReal; + } + else { + elapsedVal += val - lastBreakEnd; + } + lastBreakEnd = brk.vmax; + stillOver = false; + break; + } + elapsedVal += brk.vmin - lastBreakEnd + brk.gapReal; + lastBreakEnd = brk.vmax; + } + if (stillOver) { + elapsedVal += val - lastBreakEnd; + } + return elapsedVal; + } + + unelapse(elapsedVal: number): number { + let lastElapsedEnd = AXIS_BREAK_ELAPSED_BASE; + let lastBreakEnd = AXIS_BREAK_LAST_BREAK_END_BASE; + let stillOver = true; + let unelapsedVal = 0; + for (let i = 0; i < this.breaks.length; i++) { + const brk = this.breaks[i]; + const elapsedStart = lastElapsedEnd + brk.vmin - lastBreakEnd; + const elapsedEnd = elapsedStart + brk.gapReal; + if (elapsedVal <= elapsedEnd) { + if (elapsedVal > elapsedStart) { + unelapsedVal = brk.vmin + + (elapsedVal - elapsedStart) / (elapsedEnd - elapsedStart) * (brk.vmax - brk.vmin); + } + else { + unelapsedVal = lastBreakEnd + elapsedVal - lastElapsedEnd; + } + lastBreakEnd = brk.vmax; + stillOver = false; + break; + } + lastElapsedEnd = elapsedEnd; + lastBreakEnd = brk.vmax; + } + if (stillOver) { + unelapsedVal = lastBreakEnd + elapsedVal - lastElapsedEnd; + } + return unelapsedVal; + } + +}; + +function createScaleBreakContext(): ScaleBreakContext { + return new ScaleBreakContextImpl(); +} + + +// Both can start with any finite value, and are not necessaryily equal. But they need to +// be the same in `axisBreakElapse` and `axisBreakUnelapse` respectively. +const AXIS_BREAK_ELAPSED_BASE = 0; +const AXIS_BREAK_LAST_BREAK_END_BASE = 0; + + +/** + * `gapReal` in brkCtx.breaks will be calculated. + */ +function updateAxisBreakGapReal( + brkCtx: ScaleBreakContext, + scaleExtent: [number, number] +): void { + // Considered the effect: + // - Use dataZoom to move some of the breaks outside the extent. + // - Some scenarios that `series.clip: false`. + // + // How to calculate `prctBrksGapRealSum`: + // Based on the formula: + // xxx.span = brk.vmax - brk.vmin + // xxx.tpPrct.val / xxx.tpAbs.val means ParsedAxisBreak['gapParsed']['val'] + // .S/.E means a break that is semi in scaleExtent[0] or scaleExtent[1] + // valP = ( + // + (fullyInExtBrksSum.tpAbs.gapReal - fullyInExtBrksSum.tpAbs.span) + // + (semiInExtBrk.S.tpAbs.gapReal - semiInExtBrk.S.tpAbs.span) * semiInExtBrk.S.tpAbs.inExtFrac + // + (semiInExtBrk.E.tpAbs.gapReal - semiInExtBrk.E.tpAbs.span) * semiInExtBrk.E.tpAbs.inExtFrac + // ) + // valQ = ( + // - fullyInExtBrksSum.tpPrct.span + // - semiInExtBrk.S.tpPrct.span * semiInExtBrk.S.tpPrct.inExtFrac + // - semiInExtBrk.E.tpPrct.span * semiInExtBrk.E.tpPrct.inExtFrac + // ) + // gapPrctSum = sum(xxx.tpPrct.val) + // gapPrctSum = prctBrksGapRealSum / ( + // + (scaleExtent[1] - scaleExtent[0]) + valP + valQ + // + fullyInExtBrksSum.tpPrct.gapReal + // + semiInExtBrk.S.tpPrct.gapReal * semiInExtBrk.S.tpPrct.inExtFrac + // + semiInExtBrk.E.tpPrct.gapReal * semiInExtBrk.E.tpPrct.inExtFrac + // ) + // Assume: + // xxx.tpPrct.gapReal = xxx.tpPrct.val / gapPrctSum * prctBrksGapRealSum + // (NOTE: This is not accurate when semi-in-extent break exist because its + // proportion is not linear, but this assumption approximately works.) + // Derived as follows: + // prctBrksGapRealSum = gapPrctSum * ( (scaleExtent[1] - scaleExtent[0]) + valP + valQ ) + // / (1 + // - fullyInExtBrksSum.tpPrct.val + // - semiInExtBrk.S.tpPrct.val * semiInExtBrk.S.tpPrct.inExtFrac + // - semiInExtBrk.E.tpPrct.val * semiInExtBrk.E.tpPrct.inExtFrac + // ) + + let gapPrctSum = 0; + const fullyInExtBrksSum = { + tpAbs: {span: 0, val: 0}, + tpPrct: {span: 0, val: 0}, + }; + const init = () => ({has: false, span: NaN, inExtFrac: NaN, val: NaN}); + const semiInExtBrk = { + S: {tpAbs: init(), tpPrct: init()}, + E: {tpAbs: init(), tpPrct: init()}, + }; + + each(brkCtx.breaks, brk => { + const gapParsed = brk.gapParsed; + + if (gapParsed.type === 'tpPrct') { + gapPrctSum += gapParsed.val; + } + + const clampedBrk = clampBreakByExtent(brk, scaleExtent); + if (clampedBrk) { + const vminClamped = clampedBrk.vmin !== brk.vmin; + const vmaxClamped = clampedBrk.vmax !== brk.vmax; + const clampedSpan = clampedBrk.vmax - clampedBrk.vmin; + + if (vminClamped && vmaxClamped) { + // Do nothing, which simply makes the result `gapReal` cover the entire scaleExtent. + // This transform is not consistent with the other cases but practically works. + } + else if (vminClamped || vmaxClamped) { + const sOrE = vminClamped ? 'S' : 'E'; + semiInExtBrk[sOrE][gapParsed.type].has = true; + semiInExtBrk[sOrE][gapParsed.type].span = clampedSpan; + semiInExtBrk[sOrE][gapParsed.type].inExtFrac = clampedSpan / (brk.vmax - brk.vmin); + semiInExtBrk[sOrE][gapParsed.type].val = gapParsed.val; + } + else { + fullyInExtBrksSum[gapParsed.type].span += clampedSpan; + fullyInExtBrksSum[gapParsed.type].val += gapParsed.val; + } + } + }); + + const prctBrksGapRealSum = gapPrctSum + * (0 + + (scaleExtent[1] - scaleExtent[0]) + + (fullyInExtBrksSum.tpAbs.val - fullyInExtBrksSum.tpAbs.span) + + (semiInExtBrk.S.tpAbs.has + ? (semiInExtBrk.S.tpAbs.val - semiInExtBrk.S.tpAbs.span) * semiInExtBrk.S.tpAbs.inExtFrac : 0 + ) + + (semiInExtBrk.E.tpAbs.has + ? (semiInExtBrk.E.tpAbs.val - semiInExtBrk.E.tpAbs.span) * semiInExtBrk.E.tpAbs.inExtFrac : 0 + ) + - fullyInExtBrksSum.tpPrct.span + - (semiInExtBrk.S.tpPrct.has ? semiInExtBrk.S.tpPrct.span * semiInExtBrk.S.tpPrct.inExtFrac : 0) + - (semiInExtBrk.E.tpPrct.has ? semiInExtBrk.E.tpPrct.span * semiInExtBrk.E.tpPrct.inExtFrac : 0) + ) / (1 + - fullyInExtBrksSum.tpPrct.val + - (semiInExtBrk.S.tpPrct.has ? semiInExtBrk.S.tpPrct.val * semiInExtBrk.S.tpPrct.inExtFrac : 0) + - (semiInExtBrk.E.tpPrct.has ? semiInExtBrk.E.tpPrct.val * semiInExtBrk.E.tpPrct.inExtFrac : 0) + ); + + each(brkCtx.breaks, brk => { + const gapParsed = brk.gapParsed; + if (gapParsed.type === 'tpPrct') { + brk.gapReal = gapPrctSum !== 0 + // prctBrksGapRealSum is supposed to be non-negative but add a safe guard + ? Math.max(prctBrksGapRealSum, 0) * gapParsed.val / gapPrctSum : 0; + } + if (gapParsed.type === 'tpAbs') { + brk.gapReal = gapParsed.val; + } + if (brk.gapReal == null) { + brk.gapReal = 0; + } + }); +} + +function pruneTicksByBreak( + pruneByBreak: ParamPruneByBreak, + ticks: TItem[], + breaks: ParsedAxisBreakList, + getValue: (item: TItem) => number, + interval: number, + scaleExtent: [number, number], +): void { + if (pruneByBreak === 'no') { + return; + } + each(breaks, brk => { + // break.vmin/vmax that out of extent must not impact the visible of + // normal ticks and labels. + const clampedBrk = clampBreakByExtent(brk, scaleExtent); + if (!clampedBrk) { + return; + } + // Remove some normal ticks to avoid zigzag shapes overlapping with split lines + // and to avoid break labels overlapping with normal tick labels (thouth it can + // also be avoided by `axisLabel.hideOverlap`). + // It's OK to O(n^2) since the number of `ticks` are small. + for (let j = ticks.length - 1; j >= 0; j--) { + const tick = ticks[j]; + const val = getValue(tick); + // 1. Ensure there is no ticks inside `break.vmin` and `break.vmax`. + // 2. Use an empirically gap value here. Theoritically `zigzagAmplitude` is + // supposed to be involved to provide better precision but it will brings + // more complexity. The empirically gap value is conservative because break + // labels and normal tick lables are prone to overlapping. + const gap = interval * 3 / 4; + if (val > clampedBrk.vmin - gap + && val < clampedBrk.vmax + gap + && ( + pruneByBreak !== 'preserve_extent_bound' + || ( + val !== scaleExtent[0] && val !== scaleExtent[1] + ) + ) + ) { + ticks.splice(j, 1); + } + } + }); +} + +function addBreaksToTicks( + // The input ticks should be in accending order. + ticks: ScaleTick[], + breaks: ParsedAxisBreakList, + scaleExtent: [number, number], + // Keep the break ends at the same level to avoid an awkward appearance. + getTimeProps?: (clampedBrk: ParsedAxisBreak) => ScaleTick['time'], +): void { + each(breaks, brk => { + const clampedBrk = clampBreakByExtent(brk, scaleExtent); + if (!clampedBrk) { + return; + } + + // - When neight `break.vmin` nor `break.vmax` is in scale extent, + // break label should not be displayed and we do not add them to the result. + // - When only one of `break.vmin` and `break.vmax` is inside the extent and the + // other is outsite, we comply with the extent and display only part of the breaks area, + // because the extent might be determined by user settings (such as `axis.min/max`) + ticks.push({ + value: clampedBrk.vmin, + break: { + type: 'vmin', + parsedBreak: clampedBrk, + }, + time: getTimeProps ? getTimeProps(clampedBrk) : undefined, + }); + // When gap is 0, start tick overlap with end tick, but we still count both of them. Break + // area shape can address that overlapping. `axisLabel` need draw both start and end separately, + // otherwise it brings complexity to the logic of label overlapping resolving (e.g., when label + // rotated), and introduces inconsistency to users in `axisLabel.formatter` between gap is 0 or not. + ticks.push({ + value: clampedBrk.vmax, + break: { + type: 'vmax', + parsedBreak: clampedBrk, + }, + time: getTimeProps ? getTimeProps(clampedBrk) : undefined, + }); + }); + if (breaks.length) { + ticks.sort((a, b) => a.value - b.value); + } +} + +/** + * If break and extent does not intersect, return null/undefined. + * If the intersection is only a point at scaleExtent[0] or scaleExtent[1], return null/undefined. + */ +function clampBreakByExtent( + brk: ParsedAxisBreak, + scaleExtent: [number, number] +): NullUndefined | ParsedAxisBreak { + const vmin = Math.max(brk.vmin, scaleExtent[0]); + const vmax = Math.min(brk.vmax, scaleExtent[1]); + return ( + vmin < vmax + || (vmin === vmax && vmin > scaleExtent[0] && vmin < scaleExtent[1]) + ) + ? { + vmin, + vmax, + breakOption: brk.breakOption, + gapParsed: brk.gapParsed, + gapReal: brk.gapReal, + } + : null; +} + +function parseAxisBreakOption( + // raw user input breaks, retrieved from axis model. + breakOptionList: AxisBreakOption[] | NullUndefined, + parse: Scale['parse'], + opt?: { + noNegative: boolean; + } +): AxisBreakParsingResult { + const parsedBreaks: ParsedAxisBreakList = []; + + if (!breakOptionList) { + return {breaks: parsedBreaks}; + } + + function validatePercent(normalizedPercent: number, msg: string): boolean { + if (normalizedPercent >= 0 && normalizedPercent < 1 - 1e-5) { // Avoid division error. + return true; + } + if (__DEV__) { + error(`${msg} must be >= 0 and < 1, rather than ${normalizedPercent} .`); + } + return false; + } + + each(breakOptionList, brkOption => { + if (!brkOption || brkOption.start == null || brkOption.end == null) { + if (__DEV__) { + error('The input axis breaks start/end should not be empty.'); + } + return; + } + if (brkOption.isExpanded) { + return; + } + + const parsedBrk: ParsedAxisBreak = { + breakOption: clone(brkOption), + vmin: parse(brkOption.start), + vmax: parse(brkOption.end), + gapParsed: {type: 'tpAbs', val: 0}, + gapReal: null + }; + + if (brkOption.gap != null) { + let isPrct = false; + if (isString(brkOption.gap)) { + const trimmedGap = trim(brkOption.gap); + if (trimmedGap.match(/%$/)) { + let normalizedPercent = parseFloat(trimmedGap) / 100; + if (!validatePercent(normalizedPercent, 'Percent gap')) { + normalizedPercent = 0; + } + parsedBrk.gapParsed.type = 'tpPrct'; + parsedBrk.gapParsed.val = normalizedPercent; + isPrct = true; + } + } + if (!isPrct) { + let absolute = parse(brkOption.gap); + if (!isFinite(absolute) || absolute < 0) { + if (__DEV__) { + error(`Axis breaks gap must positive finite rather than (${brkOption.gap}).`); + } + absolute = 0; + } + parsedBrk.gapParsed.type = 'tpAbs'; + parsedBrk.gapParsed.val = absolute; + } + } + if (parsedBrk.vmin === parsedBrk.vmax) { + parsedBrk.gapParsed.type = 'tpAbs'; + parsedBrk.gapParsed.val = 0; + } + + if (opt && opt.noNegative) { + each(['vmin', 'vmax'] as const, se => { + if (parsedBrk[se] < 0) { + if (__DEV__) { + error(`Axis break.${se} must not be negative.`); + } + parsedBrk[se] = 0; + } + }); + } + + // Ascending numerical order is the prerequisite of the calculation in Scale#normalize. + // User are allowed to input desending vmin/vmax for simplifying the usage. + if (parsedBrk.vmin > parsedBrk.vmax) { + const tmp = parsedBrk.vmax; + parsedBrk.vmax = parsedBrk.vmin; + parsedBrk.vmin = tmp; + } + + parsedBreaks.push(parsedBrk); + }); + + // Ascending numerical order is the prerequisite of the calculation in Scale#normalize. + parsedBreaks.sort((item1, item2) => item1.vmin - item2.vmin); + // Make sure that the intervals in breaks are not overlap. + let lastEnd = -Infinity; + each(parsedBreaks, (brk, idx) => { + if (lastEnd > brk.vmin) { + if (__DEV__) { + error('Axis breaks must not overlap.'); + } + parsedBreaks[idx] = null; + } + lastEnd = brk.vmax; + }); + + return { + breaks: parsedBreaks.filter(brk => !!brk), + }; +} + +function identifyAxisBreak( + brk: AxisBreakOption, + identifier: AxisBreakOptionIdentifierInAxis +): boolean { + return serializeAxisBreakIdentifier(identifier) === serializeAxisBreakIdentifier(brk); +} + +function serializeAxisBreakIdentifier(identifier: AxisBreakOptionIdentifierInAxis): string { + // We use user input start/end to identify break. Considered cases like `start: new Date(xxx)`, + // Theoretically `Scale#parse` should be used here, but not used currently to reduce dependencies, + // since simply converting to string happens to be correct. + return identifier.start + '_\0_' + identifier.end; +} + +/** + * - A break pair represents `[vmin, vmax]`, + * - Only both vmin and vmax item exist, they are counted as a pair. + */ +function retrieveAxisBreakPairs( + itemList: TItem[], + getVisualAxisBreak: (item: TItem) => VisualAxisBreak +): TItem[][] { + const breakLabelPairs: TItem[][] = []; + each(itemList, el => { + const vBreak = getVisualAxisBreak(el); + if (vBreak && vBreak.type === 'vmin') { + breakLabelPairs.push([el]); + } + }); + each(itemList, el => { + const vBreak = getVisualAxisBreak(el); + if (vBreak && vBreak.type === 'vmax') { + const pair = find( + breakLabelPairs, + // parsedBreak may be changed, can only use breakOption to match them. + pr => identifyAxisBreak( + getVisualAxisBreak(pr[0]).parsedBreak.breakOption, + vBreak.parsedBreak.breakOption + ) + ); + pair && pair.push(el); + } + }); + return breakLabelPairs; +} + +function getTicksLogTransformBreak( + tick: ScaleTick, + logBase: number, + logOriginalBreaks: ParsedAxisBreakList, + fixRoundingError: (val: number, originalVal: number) => number +): { + brkRoundingCriterion: number; + vBreak: VisualAxisBreak | NullUndefined; +} { + let vBreak: VisualAxisBreak | NullUndefined; + let brkRoundingCriterion: number; + + if (tick.break) { + const brk = tick.break.parsedBreak; + const originalBreak = find(logOriginalBreaks, brk => identifyAxisBreak( + brk.breakOption, tick.break.parsedBreak.breakOption + )); + const vmin = fixRoundingError(Math.pow(logBase, brk.vmin), originalBreak.vmin); + const vmax = fixRoundingError(Math.pow(logBase, brk.vmax), originalBreak.vmax); + const gapParsed = { + type: brk.gapParsed.type, + val: brk.gapParsed.type === 'tpAbs' + ? fixRound(Math.pow(logBase, brk.vmin + brk.gapParsed.val)) - vmin + : brk.gapParsed.val, + }; + vBreak = { + type: tick.break.type, + parsedBreak: { + breakOption: brk.breakOption, + vmin, + vmax, + gapParsed, + gapReal: brk.gapReal, + } + }; + brkRoundingCriterion = originalBreak[tick.break.type]; + } + + return { + brkRoundingCriterion, + vBreak, + }; +} + +function logarithmicParseBreaksFromOption( + breakOptionList: AxisBreakOption[], + logBase: number, + parse: Scale['parse'], +): { + parsedOriginal: AxisBreakParsingResult; + parsedLogged: AxisBreakParsingResult; +} { + const opt = {noNegative: true}; + const parsedOriginal = parseAxisBreakOption(breakOptionList, parse, opt); + + const parsedLogged = parseAxisBreakOption(breakOptionList, parse, opt); + const loggedBase = Math.log(logBase); + parsedLogged.breaks = map(parsedLogged.breaks, brk => { + const vmin = Math.log(brk.vmin) / loggedBase; + const vmax = Math.log(brk.vmax) / loggedBase; + const gapParsed = { + type: brk.gapParsed.type, + val: brk.gapParsed.type === 'tpAbs' + ? (Math.log(brk.vmin + brk.gapParsed.val) / loggedBase) - vmin + : brk.gapParsed.val, + }; + return { + vmin, + vmax, + gapParsed, + gapReal: brk.gapReal, + breakOption: brk.breakOption + }; + }); + + return {parsedOriginal, parsedLogged}; +} + +const BREAK_MIN_MAX_TO_PARAM = {vmin: 'start', vmax: 'end'} as const; +function makeAxisLabelFormatterParamBreak( + extraParam: AxisLabelFormatterExtraParams | NullUndefined, + vBreak: VisualAxisBreak | NullUndefined +): AxisLabelFormatterExtraParams | NullUndefined { + if (vBreak) { + extraParam = extraParam || ({} as AxisLabelFormatterExtraParams); + extraParam.break = { + type: BREAK_MIN_MAX_TO_PARAM[vBreak.type], + start: vBreak.parsedBreak.vmin, + end: vBreak.parsedBreak.vmax, + }; + } + return extraParam; +} + +export function installScaleBreakHelper(): void { + registerScaleBreakHelperImpl({ + createScaleBreakContext, + pruneTicksByBreak, + addBreaksToTicks, + parseAxisBreakOption, + identifyAxisBreak, + serializeAxisBreakIdentifier, + retrieveAxisBreakPairs, + getTicksLogTransformBreak, + logarithmicParseBreaksFromOption, + makeAxisLabelFormatterParamBreak, + }); +} diff --git a/src/scale/helper.ts b/src/scale/helper.ts index 23ad1b4b18..67cb3c5584 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -18,12 +18,11 @@ */ import {getPrecision, round, nice, quantityExponent} from '../util/number'; -import type { OrdinalNumber, ScaleBreak, ScaleTick } from '../util/types'; -import { warn } from '../util/log'; import IntervalScale from './Interval'; import LogScale from './Log'; -import Scale from './Scale'; -import { filter } from 'zrender/src/core/util'; +import type Scale from './Scale'; +import { bind } from 'zrender/src/core/util'; +import type { ScaleBreakContext } from './break'; type intervalScaleNiceTicksResult = { interval: number, @@ -51,7 +50,7 @@ export function isIntervalOrLogScale(scale: Scale): scale is LogScale | Interval */ export function intervalScaleNiceTicks( extent: [number, number], - spanWithoutBreaks: number, + spanWithBreaks: number, splitNumber: number, minInterval?: number, maxInterval?: number @@ -59,7 +58,7 @@ export function intervalScaleNiceTicks( const result = {} as intervalScaleNiceTicksResult; - let interval = result.interval = nice(spanWithoutBreaks / splitNumber, true); + let interval = result.interval = nice(spanWithBreaks / splitNumber, true); if (minInterval != null && interval < minInterval) { interval = result.interval = minInterval; } @@ -129,167 +128,46 @@ export function contain(val: number, extent: [number, number]): boolean { return val >= extent[0] && val <= extent[1]; } -export function normalize( - val: number, - extent: [number, number], - breaks?: ScaleBreak[] -): number { - if (extent[1] === extent[0]) { - return 0.5; - } - const unexpandedBreaks = filter(breaks || [], brk => !brk.isExpanded); - if (unexpandedBreaks.length === 0) { - return (val - extent[0]) / (extent[1] - extent[0]); - } +export class ScaleCalculator { - let beforeBreakRange = 0; - for (let i = 0; i < unexpandedBreaks.length; ++i) { - const brk = unexpandedBreaks[i]; - if (brk.gap < 0) { - warn('Break axis gap should not be negative'); - } - beforeBreakRange += brk.end - brk.start - brk.gap; - } - const beforeValueRange = Math.max(0, extent[1] - extent[0] - beforeBreakRange); + normalize: (val: number, extent: [number, number]) => number = normalize; + scale: (val: number, extent: [number, number]) => number = scale; - // If the value is in the break, return the normalized value in the break - let elapsedVal = 0; - let lastBreakEnd = extent[0]; - for (let i = 0; i < unexpandedBreaks.length; i++) { - const brk = unexpandedBreaks[i]; - if (val <= brk.end) { - if (val > brk.start) { - elapsedVal += brk.start - lastBreakEnd - + (val - brk.start) / (brk.end - brk.start) * brk.gap; - } - else { - elapsedVal += val - lastBreakEnd; - } - lastBreakEnd = brk.end; - break; + updateMethods(brkCtx: ScaleBreakContext) { + if (brkCtx.hasBreaks()) { + this.normalize = bind(brkCtx.normalize, brkCtx); + this.scale = bind(brkCtx.scale, brkCtx); + } + else { + this.normalize = normalize; + this.scale = scale; } - elapsedVal += brk.start - lastBreakEnd + brk.gap; - lastBreakEnd = brk.end; - } - const lastBreak = unexpandedBreaks[unexpandedBreaks.length - 1]; - if (val >= lastBreak.end) { - elapsedVal += val - lastBreak.end; } - return Math.min(1, elapsedVal / beforeValueRange); } -export function scale( +function normalize( val: number, extent: [number, number], - breaks?: ScaleBreak[] + // Dont use optional arguments for performance consideration here. ): number { - return val * getExtentSpanWithoutBreaks(extent, breaks) + extent[0]; -} - -export function getExtentSpanWithoutBreaks(extent: [number, number], breaks: ScaleBreak[]): number { - // When breaks is defined, calculate the sum of break gaps - let span = extent[1] - extent[0]; - if (!isFinite(span)) { - return span; - } - - if (!breaks) { - breaks = []; - } - for (let i = 0; i < breaks.length; i++) { - const brk = breaks[i]; - if (!brk.isExpanded) { - span -= breaks[i].end - breaks[i].start - breaks[i].gap; - } + if (extent[1] === extent[0]) { + return 0.5; } - return span; + return (val - extent[0]) / (extent[1] - extent[0]); } -export function adjustInBreakPosition( - normalizedData: OrdinalNumber, +function scale( + val: number, extent: [number, number], - breaks: ScaleBreak[], - breakIndex: number, - inBreakPosition: 'start' | 'center' | 'end' -) { - inBreakPosition = inBreakPosition || 'center'; - const span = getExtentSpanWithoutBreaks(extent, breaks); - const brk = breaks[breakIndex]; - const bandWidth = 1 / span * brk.gap; - - // if (inBreakPosition === 'start') { - // // return normalizedData - bandWidth; - // return normalizedData; - // } - if (inBreakPosition === 'end') { - return normalizedData + bandWidth; - } - if (inBreakPosition === 'center') { - return normalizedData + bandWidth / 2; - } - // if (normalizedData === 1) { - // return normalizedData + bandWidth; - // } - return normalizedData; +): number { + return val * (extent[1] - extent[0]) + extent[0]; } -export function addBreakTicks( - ticks: ScaleTick[], - breaks: ScaleBreak[], - interval: number -) { - let unexpandedBreaks = 0; - for (let i = 0; i < breaks.length; i++) { - const brk = breaks[i]; - if (brk.isExpanded) { - continue; - } - ticks.push({ - value: brk.start, - breakStart: brk.start, - breakEnd: brk.end, - breakGap: brk.gap - }); - if (brk.gap > 0) { - // When gap is 0, start tick is overlap with end tick, so do - // not count it. If developers want to solve overlapping when - // gap is larger than 0, they should write breakFormattter - ticks.push({ - value: brk.end, - breakStart: brk.start, - breakEnd: brk.end, - breakGap: brk.gap - }); - } - unexpandedBreaks++; - } - - // Sort ticks by value - if (unexpandedBreaks > 0) { - ticks.sort(function (a, b) { - // If there is a tick with break and a tick without break at the - // same position, the tick with break should be placed before the - // tick without break - if (a.value === b.value) { - return a.breakStart != null ? -1 : 1; - } - return a.value - b.value; - }); - // Remove non-break ticks that are too close to breaks - const newTicks = []; - let prevEnd: number = null; - for (let i = 0; i < ticks.length; i++) { - const tick = ticks[i]; - if (prevEnd != null && tick.value <= (prevEnd + interval)) { - continue; - } - newTicks.push(tick); - - if (tick.breakStart != null) { - prevEnd = tick.breakEnd; - } - } - return newTicks; - } - return ticks; +export function logTransform(base: number, extent: number[]): [number, number] { + const loggedBase = Math.log(base); + return [ + // log(negative) is NaN, so safe guard here + Math.log(Math.max(0, extent[0])) / loggedBase, + Math.log(Math.max(0, extent[1])) / loggedBase + ]; } diff --git a/src/util/graphic.ts b/src/util/graphic.ts index 09c84953c4..d2efa8baa2 100644 --- a/src/util/graphic.ts +++ b/src/util/graphic.ts @@ -63,7 +63,8 @@ import { keys, each, hasOwn, - isArray + isArray, + clone, } from 'zrender/src/core/util'; import { getECData } from './innerStore'; import ComponentModel from '../model/Component'; @@ -410,7 +411,7 @@ export function groupTransition( rotation: el.rotation }; if (isPath(el)) { - obj.shape = extend({}, el.shape); + obj.shape = clone(el.shape); } return obj; } diff --git a/src/util/layout.ts b/src/util/layout.ts index 4138f74eec..dcf2f77626 100644 --- a/src/util/layout.ts +++ b/src/util/layout.ts @@ -23,10 +23,13 @@ import * as zrUtil from 'zrender/src/core/util'; import BoundingRect, { RectLike } from 'zrender/src/core/BoundingRect'; import {parsePercent} from './number'; import * as formatUtil from './format'; -import { BoxLayoutOptionMixin, ComponentLayoutMode } from './types'; +import { BoxLayoutOptionMixin, CircleLayoutOptionMixin, ComponentLayoutMode, SeriesOption } from './types'; import Group from 'zrender/src/graphic/Group'; +import { SectorShape } from 'zrender/src/graphic/shape/Sector'; import Element from 'zrender/src/Element'; import { Dictionary } from 'zrender/src/core/types'; +import SeriesModel from '../model/Series'; +import ExtensionAPI from '../core/ExtensionAPI'; const each = zrUtil.each; @@ -189,6 +192,60 @@ export function getAvailableSize( }; } +export function getViewRect(seriesModel: SeriesModel, api: ExtensionAPI) { + return getLayoutRect( + seriesModel.getBoxLayoutParams(), { + width: api.getWidth(), + height: api.getHeight() + } + ); +} + +export function getCircleLayout( + seriesModel: SeriesModel, + api: ExtensionAPI +): + Pick { + const viewRect = getViewRect(seriesModel, api); + + // center can be string or number when coordinateSystem is specified + let center = seriesModel.get('center'); + let radius = seriesModel.get('radius'); + + if (!zrUtil.isArray(radius)) { + radius = [0, radius]; + } + const width = parsePercent(viewRect.width, api.getWidth()); + const height = parsePercent(viewRect.height, api.getHeight()); + const size = Math.min(width, height); + const r0 = parsePercent(radius[0], size / 2); + const r = parsePercent(radius[1], size / 2); + + let cx: number; + let cy: number; + const coordSys = seriesModel.coordinateSystem; + if (coordSys) { + // percentage is not allowed when coordinate system is specified + const point = coordSys.dataToPoint(center); + cx = point[0] || 0; + cy = point[1] || 0; + } + else { + if (!zrUtil.isArray(center)) { + center = [center, center]; + } + cx = parsePercent(center[0], width) + viewRect.x; + cy = parsePercent(center[1], height) + viewRect.y; + } + + return { + cx, + cy, + r0, + r + }; +} + /** * Parse position info. */ diff --git a/src/util/states.ts b/src/util/states.ts index 3c8675dd4f..be452d2cb7 100644 --- a/src/util/states.ts +++ b/src/util/states.ts @@ -91,6 +91,7 @@ export const DOWNPLAY_ACTION_TYPE = 'downplay'; export const SELECT_ACTION_TYPE = 'select'; export const UNSELECT_ACTION_TYPE = 'unselect'; export const TOGGLE_SELECT_ACTION_TYPE = 'toggleSelect'; +export const SELECT_CHANGED_EVENT_TYPE = 'selectchanged'; type ExtendedProps = { __highByOuter: number diff --git a/src/util/time.ts b/src/util/time.ts index 6ab846bb41..5157c63f41 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -18,11 +18,20 @@ */ import * as zrUtil from 'zrender/src/core/util'; -import {TimeAxisLabelFormatterOption} from './../coord/axisCommonTypes'; +import { + TimeAxisLabelFormatterDictionary, + TimeAxisLabelFormatterDictionaryOption, + TimeAxisLabelFormatterExtraParams, + TimeAxisLabelFormatterOption, + TimeAxisLabelFormatterParsed, + TimeAxisLabelFormatterUpperDictionary, + TimeAxisLabelLeveledFormatterOption, +} from './../coord/axisCommonTypes'; import * as numberUtil from './number'; -import {TimeScaleTick} from './types'; +import {NullUndefined, ScaleTick} from './types'; import { getDefaultLocaleModel, getLocaleModel, SYSTEM_LANG, LocaleOption } from '../core/locale'; import Model from '../model/Model'; +import { getScaleBreakHelper } from '../scale/break'; export const ONE_SECOND = 1000; export const ONE_MINUTE = ONE_SECOND * 60; @@ -30,7 +39,18 @@ export const ONE_HOUR = ONE_MINUTE * 60; export const ONE_DAY = ONE_HOUR * 24; export const ONE_YEAR = ONE_DAY * 365; -export const defaultLeveledFormatter = { + +const primaryTimeUnitFormatterMatchers: {[key in PrimaryTimeUnit]: RegExp} = { + year: /({yyyy}|{yy})/, + month: /({MMMM}|{MMM}|{MM}|{M})/, + day: /({dd}|{d})/, + hour: /({HH}|{H}|{hh}|{h})/, + minute: /({mm}|{m})/, + second: /({ss}|{s})/, + millisecond: /({SSS}|{S})/, +} as const; + +const defaultFormatterSeed: {[key in PrimaryTimeUnit]: string} = { year: '{yyyy}', month: '{MMM}', day: '{d}', @@ -38,33 +58,182 @@ export const defaultLeveledFormatter = { minute: '{HH}:{mm}', second: '{HH}:{mm}:{ss}', millisecond: '{HH}:{mm}:{ss} {SSS}', - none: '{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}' -}; +} as const; +const defaultFullFormatter = '{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}'; const fullDayFormatter = '{yyyy}-{MM}-{dd}'; export const fullLeveledFormatter = { year: '{yyyy}', month: '{yyyy}-{MM}', day: fullDayFormatter, - hour: fullDayFormatter + ' ' + defaultLeveledFormatter.hour, - minute: fullDayFormatter + ' ' + defaultLeveledFormatter.minute, - second: fullDayFormatter + ' ' + defaultLeveledFormatter.second, - millisecond: defaultLeveledFormatter.none + hour: fullDayFormatter + ' ' + defaultFormatterSeed.hour, + minute: fullDayFormatter + ' ' + defaultFormatterSeed.minute, + second: fullDayFormatter + ' ' + defaultFormatterSeed.second, + millisecond: defaultFullFormatter }; -export type PrimaryTimeUnit = 'millisecond' | 'second' | 'minute' | 'hour' - | 'day' | 'month' | 'year'; -export type TimeUnit = PrimaryTimeUnit | 'half-year' | 'quarter' | 'week' - | 'half-week' | 'half-day' | 'quarter-day'; - -export const primaryTimeUnits: PrimaryTimeUnit[] = [ +export type JSDateGetterNames = + 'getUTCFullYear' | 'getFullYear' + | 'getUTCMonth' | 'getMonth' + | 'getUTCDate' | 'getDate' + | 'getUTCHours' | 'getHours' + | 'getUTCMinutes' | 'getMinutes' + | 'getUTCSeconds' | 'getSeconds' + | 'getUTCMilliseconds' | 'getMilliseconds' +; +export type JSDateSetterNames = + 'setUTCFullYear' | 'setFullYear' + | 'setUTCMonth' | 'setMonth' + | 'setUTCDate' | 'setDate' + | 'setUTCHours' | 'setHours' + | 'setUTCMinutes' | 'setMinutes' + | 'setUTCSeconds' | 'setSeconds' + | 'setUTCMilliseconds' | 'setMilliseconds' +; + +export type PrimaryTimeUnit = (typeof primaryTimeUnits)[number]; + +export type TimeUnit = (typeof timeUnits)[number]; + +// Order must be ensured from big to small. +export const primaryTimeUnits = [ 'year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond' -]; -export const timeUnits: TimeUnit[] = [ +] as const; +export const timeUnits = [ 'year', 'half-year', 'quarter', 'month', 'week', 'half-week', 'day', 'half-day', 'quarter-day', 'hour', 'minute', 'second', 'millisecond' -]; +] as const; + + +export function parseTimeAxisLabelFormatter( + formatter: TimeAxisLabelFormatterOption +): TimeAxisLabelFormatterParsed { + // Keep the logic the same with function `leveledFormat`. + return (!zrUtil.isString(formatter) && !zrUtil.isFunction(formatter)) + ? parseTimeAxisLabelFormatterDictionary(formatter) + : formatter; +} + +/** + * The final generated dictionary is like: + * generated_dict = { + * year: { + * year: ['{yyyy}', ...] + * }, + * month: { + * year: ['{yyyy} {MMM}', ...], + * month: ['{MMM}', ...] + * }, + * day: { + * year: ['{yyyy} {MMM} {d}', ...], + * month: ['{MMM} {d}', ...], + * day: ['{d}', ...] + * }, + * ... + * } + * + * In echarts option, users can specify the entire dictionary or typically just: + * {formatter: { + * year: '{yyyy}', // Or an array of leveled templates: `['{yyyy}', '{bold1|{yyyy}}', ...]`, + * // corresponding to `[level0, level1, level2, ...]`. + * month: '{MMM}', + * day: '{d}', + * hour: '{HH}:{mm}', + * second: '{HH}:{mm}', + * ... + * }} + * If any time unit is not specified in echarts option, the default template is used, + * such as `['{yyyy}', {primary|{yyyy}']`. + * + * The `tick.level` is only used to read string from each array, meaning the style type. + * + * Let `lowerUnit = getUnitFromValue(tick.value)`. + * The non-break axis ticks only use `generated_dict[lowerUnit][lowerUnit][level]`. + * The break axis ticks may use `generated_dict[lowerUnit][upperUnit][level]`, because: + * Consider the case: the non-break ticks are `16th, 23th, Feb, 7th, ...`, where `Feb` is in the break + * range and pruned by breaks, and the break ends might be in lower time unit than day. e.g., break start + * is `Jan 25th 18:00`(in unit `hour`) and break end is `Feb 6th 18:30` (in unit `minute`). Thus the break + * label prefers `Jan 25th 18:00` and `Feb 6th 18:30` rather than only `18:00` and `18:30`, otherwise it + * causes misleading. + * In this case, the tick of the break start and end will both be: + * `{level: 1, lowerTimeUnit: 'minute', upperTimeUnit: 'month'}` + * And get the final template by `generated_dict[lowerTimeUnit][upperTimeUnit][level]`. + * Note that the time unit can not be calculated directly by a single tick value, since the two breaks have + * to be at the same time unit to avoid awkward appearance. i.e., `Jan 25th 18:00` is in the time unit "hour" + * but we need it to be "minute", following `Feb 6th 18:30`. + */ +function parseTimeAxisLabelFormatterDictionary( + dictOption: TimeAxisLabelFormatterDictionaryOption | NullUndefined +): TimeAxisLabelFormatterDictionary { + dictOption = dictOption || {}; + const dict = {} as TimeAxisLabelFormatterDictionary; + + // Currently if any template is specified by user, it may contain rich text tag, + // such as `'{my_bold|{YYYY}}'`, thus we do add highlight style to it. + // (Note that nested tag (`'{some|{some2|xxx}}'`) in rich text is not supported yet.) + let canAddHighlight = true; + zrUtil.each(primaryTimeUnits, lowestUnit => { + canAddHighlight &&= dictOption[lowestUnit] == null; + }); + + zrUtil.each(primaryTimeUnits, (lowestUnit, lowestUnitIdx) => { + const upperDictOption = dictOption[lowestUnit]; + dict[lowestUnit] = {} as TimeAxisLabelFormatterUpperDictionary; + + let lowerTpl: string | null = null; + for (let upperUnitIdx = lowestUnitIdx; upperUnitIdx >= 0; upperUnitIdx--) { + const upperUnit = primaryTimeUnits[upperUnitIdx]; + const upperDictItemOption: TimeAxisLabelLeveledFormatterOption = + (zrUtil.isObject(upperDictOption) && !zrUtil.isArray(upperDictOption)) + ? upperDictOption[upperUnit] + : upperDictOption; + + let tplArr: string[]; + if (zrUtil.isArray(upperDictItemOption)) { + tplArr = upperDictItemOption.slice(); + lowerTpl = tplArr[0] || ''; + } + else if (zrUtil.isString(upperDictItemOption)) { + lowerTpl = upperDictItemOption; + tplArr = [lowerTpl]; + } + else { + if (lowerTpl == null) { + lowerTpl = defaultFormatterSeed[lowestUnit]; + } + // Generate the dict by the rule as follows: + // If the user specify (or by default): + // {formatter: { + // year: '{yyyy}', + // month: '{MMM}', + // day: '{d}', + // ... + // }} + // Concat them to make the final dictionary: + // {formatter: { + // year: {year: ['{yyyy}']}, + // month: {year: ['{yyyy} {MMM}'], month: ['{MMM}']}, + // day: {year: ['{yyyy} {MMM} {d}'], month: ['{MMM} {d}'], day: ['{d}']} + // ... + // }} + // And then add `{primary|...}` to each array if from default template. + // This strategy is convinient for user configurating and works for most cases. + // If bad cases encountered, users can specify the entire dictionary themselves + // instead of going through this logic. + else if (!primaryTimeUnitFormatterMatchers[upperUnit].test(lowerTpl)) { + lowerTpl = `${dict[upperUnit][upperUnit][0]} ${lowerTpl}`; + } + tplArr = [lowerTpl]; + if (canAddHighlight) { + tplArr[1] = `{primary|${lowerTpl}}`; + } + } + dict[lowestUnit][upperUnit] = tplArr; + } + }); + return dict; +} export function pad(str: string | number, len: number): string { str += ''; @@ -160,9 +329,9 @@ export function format( } export function leveledFormat( - tick: TimeScaleTick, + tick: ScaleTick, idx: number, - formatter: TimeAxisLabelFormatterOption, + formatter: TimeAxisLabelFormatterParsed, lang: string | Model, isUTC: boolean ) { @@ -172,50 +341,26 @@ export function leveledFormat( template = formatter; } else if (zrUtil.isFunction(formatter)) { - // Callback formatter - template = formatter(tick.value, idx, { - level: tick.level, - breakStart: tick.breakStart, - breakEnd: tick.breakEnd - }); + const extra: TimeAxisLabelFormatterExtraParams = { + time: tick.time, + level: tick.time.level, + }; + const scaleBreakHelper = getScaleBreakHelper(); + if (scaleBreakHelper) { + scaleBreakHelper.makeAxisLabelFormatterParamBreak(extra, tick.break); + } + template = formatter(tick.value, idx, extra); } else { - const defaults = zrUtil.extend({}, defaultLeveledFormatter); - if (tick.level > 0) { - for (let i = 0; i < primaryTimeUnits.length; ++i) { - defaults[primaryTimeUnits[i]] = `{primary|${defaults[primaryTimeUnits[i]]}}`; - } - } - - const mergedFormatter = (formatter - ? (formatter.inherit === false - ? formatter // Use formatter with bigger units - : zrUtil.defaults(formatter, defaults) - ) - : defaults) as any; - - const unit = getUnitFromValue(tick.value, isUTC); - if (mergedFormatter[unit]) { - template = mergedFormatter[unit]; - } - else if (mergedFormatter.inherit) { - // Unit formatter is not defined and should inherit from bigger units - const targetId = timeUnits.indexOf(unit); - for (let i = targetId - 1; i >= 0; --i) { - if (mergedFormatter[unit]) { - template = mergedFormatter[unit]; - break; - } - } - template = template || defaults.none; + const tickTime = tick.time; + if (tickTime) { + const leveledTplArr = formatter[tickTime.lowerTimeUnit][tickTime.upperTimeUnit]; + template = leveledTplArr[Math.min(tickTime.level, leveledTplArr.length - 1)] || ''; } - - if (zrUtil.isArray(template)) { - let levelId = tick.level == null - ? 0 - : (tick.level >= 0 ? tick.level : template.length + tick.level); - levelId = Math.min(levelId, template.length - 1); - template = template[levelId]; + else { + // tick may be from customTicks or timeline therefore no tick.time. + const unit = getUnitFromValue(tick.value, isUTC); + template = formatter[unit][unit][0]; } } @@ -264,38 +409,63 @@ export function getUnitFromValue( } } -export function getUnitValue( - value: number | Date, - unit: TimeUnit, - isUTC: boolean -) : number { - const date = zrUtil.isNumber(value) - ? numberUtil.parseDate(value) - : value; - unit = unit || getUnitFromValue(value, isUTC); - - switch (unit) { +// export function getUnitValue( +// value: number | Date, +// unit: TimeUnit, +// isUTC: boolean +// ) : number { +// const date = zrUtil.isNumber(value) +// ? numberUtil.parseDate(value) +// : value; +// unit = unit || getUnitFromValue(value, isUTC); + +// switch (unit) { +// case 'year': +// return date[fullYearGetterName(isUTC)](); +// case 'half-year': +// return date[monthGetterName(isUTC)]() >= 6 ? 1 : 0; +// case 'quarter': +// return Math.floor((date[monthGetterName(isUTC)]() + 1) / 4); +// case 'month': +// return date[monthGetterName(isUTC)](); +// case 'day': +// return date[dateGetterName(isUTC)](); +// case 'half-day': +// return date[hoursGetterName(isUTC)]() / 24; +// case 'hour': +// return date[hoursGetterName(isUTC)](); +// case 'minute': +// return date[minutesGetterName(isUTC)](); +// case 'second': +// return date[secondsGetterName(isUTC)](); +// case 'millisecond': +// return date[millisecondsGetterName(isUTC)](); +// } +// } + +/** + * e.g., + * If timeUnit is 'year', return the Jan 1st 00:00:00 000 of that year. + * If timeUnit is 'day', return the 00:00:00 000 of that day. + * + * @return The input date. + */ +export function roundTime(date: Date, timeUnit: PrimaryTimeUnit, isUTC: boolean): Date { + switch (timeUnit) { case 'year': - return date[fullYearGetterName(isUTC)](); - case 'half-year': - return date[monthGetterName(isUTC)]() >= 6 ? 1 : 0; - case 'quarter': - return Math.floor((date[monthGetterName(isUTC)]() + 1) / 4); + date[monthSetterName(isUTC)](0); case 'month': - return date[monthGetterName(isUTC)](); + date[dateSetterName(isUTC)](1); case 'day': - return date[dateGetterName(isUTC)](); - case 'half-day': - return date[hoursGetterName(isUTC)]() / 24; + date[hoursSetterName(isUTC)](0); case 'hour': - return date[hoursGetterName(isUTC)](); + date[minutesSetterName(isUTC)](0); case 'minute': - return date[minutesGetterName(isUTC)](); + date[secondsSetterName(isUTC)](0); case 'second': - return date[secondsGetterName(isUTC)](); - case 'millisecond': - return date[millisecondsGetterName(isUTC)](); + date[millisecondsSetterName(isUTC)](0); } + return date; } export function fullYearGetterName(isUTC: boolean) { diff --git a/src/util/types.ts b/src/util/types.ts index 722b455184..656a6f8c0e 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -49,6 +49,7 @@ import { Source } from '../data/Source'; import Model from '../model/Model'; import { DataStoreDimensionType } from '../data/DataStore'; import { DimensionUserOuputEncode } from '../data/helper/dimensionHelper'; +import { PrimaryTimeUnit } from './time'; @@ -179,9 +180,22 @@ export interface PayloadAnimationPart { delay?: number } +export interface SelectChangedEvent extends ECActionRefinedEvent { + type: 'selectchanged' + isFromClick: boolean + fromAction: 'select' | 'unselect' | 'toggleSelected' + fromActionPayload: Payload + selected: { + seriesIndex: number + dataType?: SeriesDataType + dataIndex: number[] + }[] +} +/** + * @deprecated Backward compat. + */ export interface SelectChangedPayload extends Payload { type: 'selectchanged' - escapeConnect: boolean isFromClick: boolean fromAction: 'select' | 'unselect' | 'toggleSelected' fromActionPayload: Payload @@ -218,8 +232,23 @@ export interface ECActionEvent extends ECEventData { componentIndex?: number; seriesIndex?: number; escapeConnect?: boolean; - batch?: ECEventData; + batch?: ECEventData[]; +} +/** + * TODO: not applicable in `ECEventProcessor` yet. + */ +export interface ECActionRefinedEvent extends ECActionEvent { + // event type + type: string; + // action types. + fromAction: string; + fromActionPayload: Payload; } +export type ECActionRefinedEventContent = Omit< + TRefinedEvent, + 'type' | 'fromAction' | 'fromActionPayload' +>; + export interface ECEventData { // TODO use unknown [key: string]: any; @@ -235,17 +264,50 @@ export interface NormalizedEventQuery { otherQuery: EventQueryItem; } +/** + * The rule of creating "public event" and "event for connect": + * - If `refineEvent` provided, + * `refineEvent` creates the "public event", + * and "event for connect" is created internally by replicating the payload. + * This is because `makeActionFromEvent` requires the content of event to be + * the same as the original payload, while `refineEvent` creates a user-friend + * event that differs from the original payload. + * - Else if `ActionHandler` returns an object, + * it is both the "public event" and the "event for connect". + * (@deprecated, but keep this mechanism for backward compatibility). + * - Else, + * replicate the payload as both the "public event" and "event for connect". + */ export interface ActionInfo { // action type type: string; // If not provided, use the same string of `type`. event?: string; - // update method + // update method. update?: string; + // `ActionHandler` is designed to do nothing other than modify models. + action?: ActionHandler; + // - `refineEvent` is intended to create a user-friend event that differs from the original payload, + // while enabling feature `connect`, and being called at the last step of the "update" procedure + // to ensure the complete update of all models. + // - If multiple actions need to share one event name, `refineEvent` must be used. + // e.g., actions 'doxxx' 'undoxxx' 'togglexxx' share one event name 'xxxchanged'. + // - The design of refined event should not impose different handling for batch and non-batch on users. + refineEvent?: ActionRefineEvent; + // When `refineEvent` is provided, still publish the auto generated "event for connect" to users. + // Only for backward compatibility, do not use it in future actions and events. + publishNonRefinedEvent?: boolean; } export interface ActionHandler { (payload: Payload, ecModel: GlobalModel, api: ExtensionAPI): void | ECEventData; } +export interface ActionRefineEvent { + // `actionResult` is the return of the `ActionHandler` call, where some data can be carried. + // `actionResultBatch` corresponds to both batch payload and non-batch payload. + (actionResultBatch: ECEventData[], payload: Payload, ecModel: GlobalModel, api: ExtensionAPI): { + eventContent: ECActionRefinedEventContent + } +} export interface OptionPreprocessor { (option: ECUnitOption, isTheme: boolean): void @@ -371,7 +433,7 @@ export type OrdinalSortInfo = { * `OptionDataValue` are parsed (see `src/data/helper/dataValueHelper.parseDataValue`) * into `ParsedValue` and stored into `data/SeriesData` storage. * Note: - * (1) The term "parse" does not mean `src/scale/Scale['parse']`. + * (1) The term "parse" does not mean `src/scale/Scale['parse']`(@see `ScaleDataValue`). * (2) If a category dimension is not mapped to any axis, its raw value will NOT be * parsed to `OrdinalNumber` but keep the original `OrdinalRawValue` in `src/data/SeriesData` storage. */ @@ -379,38 +441,103 @@ export type ParsedValue = ParsedValueNumeric | OrdinalRawValue; export type ParsedValueNumeric = number | OrdinalNumber; /** - * `ScaleDataValue` means that the user input primitive value to `src/scale/Scale`. - * (For example, used in `axis.min`, `axis.max`, `convertToPixel`). - * Note: - * `ScaleDataValue` is a little different from `OptionDataValue`, because it will not go through - * `src/data/helper/dataValueHelper.parseDataValue`, but go through `src/scale/Scale['parse']`. + * `ScaleDataValue` represents the user input axis value in echarts API. + * (For example, used `axis.min`/`axis.max` in echarts option, `convertToPixel`). + * NOTICE: + * `ScaleDataValue` is slightly different from `OptionDataValue` for historical reason. + * `ScaleDataValue` should be parsed by `src/scale/Scale['parse']`. + * `OptionDataValue` should be parsed by `src/data/helper/dataValueHelper.parseDataValue`. + * FIXME: + * Make `ScaleDataValue` `OptionDataValue` consistent? Since numeric string (like `'123'`) is accepted + * in `series.data` and is effectively accepted in some axis relevant option (e.g., `axis.min/max`), + * `type ScaleDataValue` should also include it for consistency. But it might bring some breaking in + * TS interface (user callback) and need comprehensive checks for all of the parsing of `ScaleDataValue`. */ export type ScaleDataValue = ParsedValueNumeric | OrdinalRawValue | Date; -export type ScaleBreak = { - start: number, - end: number, - gap: number, - isExpanded?: boolean // undefined means false +export type AxisBreakOption = { + start: ScaleDataValue, + end: ScaleDataValue, + // - `number`: The unit is the same as data value, the same as `start`/`end`, not pixel. + // - `string`: + // - Like '35%'. A percent over the axis extent. Useful for keeping the pixel size of break areas + // consistent despite variations in `series.data`, which cannot be achieved by `number`. + // - Also support numeric string like `'123'`, means `123`, following convention. + // - If ommitted, means 0. + gap?: number | string, + // undefined means false + isExpanded?: boolean +}; +// Within an axis, this is the identifier among multiple breaks. +export type AxisBreakOptionIdentifierInAxis = Pick; + +// - Parsed from the breaks in axis model. +// - Never be null/undefined. +// - Contain only unexpanded breaks. +export type ParsedAxisBreakList = ParsedAxisBreak[]; +export type ParsedAxisBreak = { + // Keep breakOption.start/breakOption.end to identify the target break item in echarts action. + breakOption: AxisBreakOption, + // - Parsed start/end value. e.g. The user input start/end may be a data string + // '2021-12-12', and the parsed start/end are timestamp number. + // - `vmin <= vmax` is ensured in parsing. + vmin: number, + vmax: number, + // Parsed from `AxisBreakOption['gap']`. Need to save this intermediate value + // because LogScale need to logarithmically transform to them. + gapParsed: { + type: 'tpAbs' | 'tpPrct' + // If 'tpPrct', means percent, val is in 0~1. + // If 'tpAbs', means absolute value, val is numeric gap value from option. + val: number, + }, + // Final calculated gap. + gapReal: number | NullUndefined, +}; +export type VisualAxisBreak = { + type: 'vmin' | 'vmax', + parsedBreak: ParsedAxisBreak, +}; +export type AxisLabelFormatterExtraBreakPart = { + break?: { + type: 'start' | 'end', + start: ParsedAxisBreak['vmin'], + end: ParsedAxisBreak['vmax'], + // After parsing, the start and end may be reversed and thus `start` + // actually maps to `rawEnd`. It may causing confusion. And the param + // `value` in the label formatter is also parsed value (except category + // axis). So we only provide parsed break start/end to users. + } }; export interface ScaleTick { - level?: number, - breakStart?: number, - breakEnd?: number, - breakGap?: number, - value: number + value: number, + break?: VisualAxisBreak, + time?: TimeScaleTick['time'], }; export interface TimeScaleTick extends ScaleTick { - /** - * Level information is used for label formatting. - * For example, a time axis may contain labels like: Jan, 8th, 16th, 23th, - * Feb, and etc. In this case, month labels like Jan and Feb should be - * displayed in a more significant way than days. - * `level` is set to be 0 when it's the most significant level, like month - * labels in the above case. - */ - level?: number + time: { + /** + * Level information is used for label formatting. + * `level` is 0 or undefined by default, with higher value indicating greater significant. + * For example, a time axis may contain labels like: Jan, 8th, 16th, 23th, Feb, and etc. + * In this case, month labels like Jan and Feb should be displayed in a more significant + * way than days. The tick labels are: + * labels: `Jan 8th 16th 23th Feb` + * levels: `1 0 0 0 1 ` + * The label formatter can be configured as `{[timeUnit]: string | string[]}`, where the + * timeUnit is determined by the tick value itself by `time.ts#getUnitFromValue`, while + * the `level` is the index under that time unit. (i.e., `formatter[timeUnit][level]`). + */ + level: number, + /** + * An upper and lower time unit that is suggested to be displayed. + * Terms upper/lower means, such as 'year' is "upper" and 'month' is "lower". + * This is just suggestion. Time units that are out of this range can also be displayed. + */ + upperTimeUnit: PrimaryTimeUnit, + lowerTimeUnit: PrimaryTimeUnit, + } }; export interface OrdinalScaleTick extends ScaleTick { /** @@ -540,6 +667,7 @@ export type ECUnitOption = { darkMode?: boolean | 'auto' textStyle?: Pick useUTC?: boolean + hoverLayerThreshold?: number [key: string]: ComponentOption | ComponentOption[] | Dictionary | unknown @@ -1065,6 +1193,7 @@ export interface LabelOption extends TextCommonOption { /** * Min margin between labels. Used when label has layout. + * PENDING: @see {LabelMarginType} */ // It's minMargin instead of margin is for not breaking the previous code using margin. minMargin?: number @@ -1082,6 +1211,19 @@ export interface LabelOption extends TextCommonOption { rich?: Dictionary } +/** + * PENDING: Temporary impl. unify them? + * @see {AxisLabelBaseOption['textMargin']} + * @see {LabelOption['minMargin']} + */ +export const LabelMarginType = { + minMargin: 0, + textMargin: 1, +} as const; +export interface LabelExtendedText extends ZRText { + __marginType?: (typeof LabelMarginType)[keyof typeof LabelMarginType] +} + export interface SeriesLabelOption extends LabelOption { formatter?: string | LabelFormatterCallback } diff --git a/test/axis-break-2.html b/test/axis-break-2.html new file mode 100644 index 0000000000..66af68cdba --- /dev/null +++ b/test/axis-break-2.html @@ -0,0 +1,1812 @@ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/axis-break-3.html b/test/axis-break-3.html new file mode 100644 index 0000000000..aaeb0ace35 --- /dev/null +++ b/test/axis-break-3.html @@ -0,0 +1,552 @@ + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/axis-break-4.html b/test/axis-break-4.html new file mode 100644 index 0000000000..6c7c562033 --- /dev/null +++ b/test/axis-break-4.html @@ -0,0 +1,831 @@ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/axis-break.html b/test/axis-break.html index 7d185a4855..b0efdfeb2d 100644 --- a/test/axis-break.html +++ b/test/axis-break.html @@ -28,6 +28,7 @@ + @@ -36,26 +37,43 @@ -
+
+
+
-
+
+
+
+
+
+
+
-
+
+
+
-
- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/chord.html b/test/chord.html new file mode 100644 index 0000000000..2d7b6ceee5 --- /dev/null +++ b/test/chord.html @@ -0,0 +1,719 @@ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/connect.html b/test/connect.html index 2b61184d38..bc019d39a6 100644 --- a/test/connect.html +++ b/test/connect.html @@ -50,6 +50,7 @@ var chart1 = echarts.init(document.getElementById('chart1')); var chart2 = echarts.init(document.getElementById('chart2')); + var seriesSelectMode = 'single'; var data1 = []; @@ -111,6 +112,7 @@ { name: 'scatter', type: 'scatter', + selectedMode: seriesSelectMode, symbolSize: 30, data: data1 } @@ -163,6 +165,7 @@ { name: 'scatter', type: 'scatter', + selectedMode: seriesSelectMode, symbolSize: 30, data: data1 } diff --git a/test/dataSelect.html b/test/dataSelect.html index a6ed90d4cb..3b399af892 100644 --- a/test/dataSelect.html +++ b/test/dataSelect.html @@ -59,7 +59,7 @@ right: 0; top: 0; width: 200px; - height: 200px; + /*height: 200px;*/ background: rgba(0, 0, 0, 0.5); color: #fff; text-align: left; @@ -77,7 +77,7 @@

Tests for focus and blurScope

@@ -162,17 +165,19 @@ option: option }); - const coord = chart.convertToPixel({ - seriesIndex: 0, - angleAxisIndex: 0, - radiusAxisIndex: 0 - }, [30, 92]); - - chart.dispatchAction({ - type: 'showTip', - x: coord[0], - y: coord[1] - }); + if (chart) { + const coord = chart.convertToPixel({ + seriesIndex: 0, + angleAxisIndex: 0, + radiusAxisIndex: 0 + }, [30, 92]); + + chart.dispatchAction({ + type: 'showTip', + x: coord[0], + y: coord[1] + }); + } }); diff --git a/test/ut/spec/util/time.test.ts b/test/ut/spec/util/time.test.ts index db5aefcdfe..60d5467fc7 100755 --- a/test/ut/spec/util/time.test.ts +++ b/test/ut/spec/util/time.test.ts @@ -19,10 +19,9 @@ */ import { - format + format, roundTime } from '@/src/util/time'; - describe('util/time', function () { describe('format', function () { @@ -142,4 +141,50 @@ describe('util/time', function () { expect(format(oneMoreTime, '{A}', true)).toEqual('PM'); }); }); + + describe('roundTime', function () { + it('roundTime_UTC', function () { + expect(roundTime(new Date(0), 'year', true).toISOString()).toEqual('1970-01-01T00:00:00.000Z'); + + const time1 = 3600 * 1000 * 24 * 6122 + 12345678; // '1986-10-06T03:25:45.678Z' + expect(roundTime(new Date(time1), 'year', true).toISOString()).toEqual('1986-01-01T00:00:00.000Z'); + expect(roundTime(new Date(time1), 'month', true).toISOString()).toEqual('1986-10-01T00:00:00.000Z'); + expect(roundTime(new Date(time1), 'day', true).toISOString()).toEqual('1986-10-06T00:00:00.000Z'); + expect(roundTime(new Date(time1), 'hour', true).toISOString()).toEqual('1986-10-06T03:00:00.000Z'); + expect(roundTime(new Date(time1), 'minute', true).toISOString()).toEqual('1986-10-06T03:25:00.000Z'); + expect(roundTime(new Date(time1), 'second', true).toISOString()).toEqual('1986-10-06T03:25:45.000Z'); + expect(roundTime(new Date(time1), 'millisecond', true).toISOString()).toEqual('1986-10-06T03:25:45.678Z'); + }); + + it('roundTime_locale', function () { + const timezoneStr = getISOTimezone(); + const time1 = new Date(`1986-10-06T11:25:45.678${timezoneStr}`); + + expect(roundTime(new Date(time1), 'year', false).getTime()) + .toEqual(new Date(`1986-01-01T00:00:00.000${timezoneStr}`).getTime()); + expect(roundTime(new Date(time1), 'month', false).getTime()) + .toEqual(new Date(`1986-10-01T00:00:00.000${timezoneStr}`).getTime()); + expect(roundTime(new Date(time1), 'day', false).getTime()) + .toEqual(new Date(`1986-10-06T00:00:00.000${timezoneStr}`).getTime()); + expect(roundTime(new Date(time1), 'hour', false).getTime()) + .toEqual(new Date(`1986-10-06T11:00:00.000${timezoneStr}`).getTime()); + expect(roundTime(new Date(time1), 'minute', false).getTime()) + .toEqual(new Date(`1986-10-06T11:25:00.000${timezoneStr}`).getTime()); + expect(roundTime(new Date(time1), 'second', false).getTime()) + .toEqual(new Date(`1986-10-06T11:25:45.000${timezoneStr}`).getTime()); + expect(roundTime(new Date(time1), 'millisecond', false).getTime()) + .toEqual(new Date(`1986-10-06T11:25:45.678${timezoneStr}`).getTime()); + }); + }); }); + +// return timezone format like `'-06:00'` or `'+05:45'` +function getISOTimezone(): string { + const offsetMinutes = (new Date(0)).getTimezoneOffset(); + // Invert sign because getTimezoneOffset() returns minutes behind UTC + const sign = offsetMinutes > 0 ? '-' : '+'; + const absMinutes = Math.abs(offsetMinutes); + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +}