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/bar/BarView.ts b/src/chart/bar/BarView.ts index a34cc153a0..fdb31f8679 100644 --- a/src/chart/bar/BarView.ts +++ b/src/chart/bar/BarView.ts @@ -240,6 +240,9 @@ class BarView extends ChartView { function createBackground(dataIndex: number) { const bgLayout = getLayout[coord.type](data, dataIndex); + if (!bgLayout) { + return null; + } const bgEl = createBackgroundEl(coord, isHorizontalOrRadial, bgLayout); bgEl.useStyle(backgroundModel.getItemStyle()); // Only cartesian2d support borderRadius. @@ -256,6 +259,9 @@ class BarView extends ChartView { .add(function (dataIndex) { const itemModel = data.getItemModel(dataIndex); const layout = getLayout[coord.type](data, dataIndex, itemModel); + if (!layout) { + return; + } if (drawBackground) { createBackground(dataIndex); @@ -327,6 +333,9 @@ class BarView extends ChartView { .update(function (newIndex, oldIndex) { const itemModel = data.getItemModel(newIndex); const layout = getLayout[coord.type](data, newIndex, itemModel); + if (!layout) { + return; + } if (drawBackground) { let bgEl: Rect | Sector; @@ -927,6 +936,10 @@ const getLayout: { // when calculating bar background layout. cartesian2d(data, dataIndex, itemModel?): RectLayout { const layout = data.getItemLayout(dataIndex) as RectLayout; + if (!layout) { + return null; + } + const fixedLineWidth = itemModel ? getLineWidth(itemModel, layout) : 0; // fix layout with lineWidth 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/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 736a87c8fc..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,13 +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; @@ -51,6 +64,11 @@ type AxisEventData = { value?: string | number dataIndex?: number tickIndex?: number +} & { + break?: { + start: ParsedAxisBreak['vmin'], + end: ParsedAxisBreak['vmax'], + } } & { [key in AxisIndexKey]?: number }; @@ -60,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'. @@ -134,9 +162,14 @@ class AxisBuilder { readonly group = new graphic.Group(); private _transformGroup: graphic.Group; + private _api: ExtensionAPI; - constructor(axisModel: AxisBaseModel, opt?: AxisBuilderCfg) { - + constructor( + axisModel: AxisBaseModel, + api: ExtensionAPI, + opt?: AxisBuilderCfg + ) { + this._api = api; this.opt = opt; this.axisModel = axisModel; @@ -175,7 +208,7 @@ class AxisBuilder { } add(name: keyof typeof builders) { - builders[name](this.opt, this.axisModel, this.group, this._transformGroup); + builders[name](this.opt, this.axisModel, this.group, this._transformGroup, this._api); } getGroup() { @@ -237,13 +270,14 @@ interface AxisElementsBuilder { opt: AxisBuilderCfg, axisModel: AxisBaseModel, group: graphic.Group, - transformGroup: graphic.Group + transformGroup: graphic.Group, + api: ExtensionAPI ):void } 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) { @@ -270,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']); @@ -344,30 +385,45 @@ const builders: Record<'axisLine' | 'axisTickLabel' | 'axisName', AxisElementsBu } }, - axisTickLabel(opt, axisModel, group, transformGroup) { + axisTickLabel(opt, axisModel, group, transformGroup, api) { const ticksEls = buildAxisMajorTicks(group, transformGroup, axisModel, opt); - const labelEls = buildAxisLabel(group, transformGroup, axisModel, opt); + const labelEls = buildAxisLabel(group, transformGroup, axisModel, opt, api); + + adjustBreakLabels(axisModel, opt.rotation, labelEls); - fixMinMaxLabelShow(axisModel, labelEls, ticksEls); + 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) { @@ -515,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; @@ -545,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); @@ -564,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); @@ -580,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, @@ -729,13 +776,14 @@ function buildAxisLabel( group: graphic.Group, transformGroup: graphic.Group, axisModel: AxisBaseModel, - opt: AxisBuilderCfg -) { + 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'); @@ -804,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 @@ -829,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, @@ -851,11 +904,22 @@ function buildAxisLabel( eventData.targetType = 'axisLabel'; eventData.value = rawLabel; eventData.tickIndex = index; + 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.break) { + addBreakEventHandler(axisModel, api, textEl, labelItem.break); + } } // FIXME @@ -872,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 abc3e8cff7..2e44150bf0 100644 --- a/src/component/axis/CartesianAxisView.ts +++ b/src/component/axis/CartesianAxisView.ts @@ -29,12 +29,13 @@ import CartesianAxisModel from '../../coord/cartesian/AxisModel'; import GridModel from '../../coord/cartesian/GridModel'; import { Payload } from '../../util/types'; import { isIntervalOrLogScale } from '../../scale/helper'; +import { getAxisBreakHelper } from './axisBreakHelper'; const axisBuilderAttrs = [ 'axisLine', 'axisTickLabel', 'axisName' ] as const; const selfBuilderAttrs = [ - 'splitArea', 'splitLine', 'minorSplitLine' + 'splitArea', 'splitLine', 'minorSplitLine', 'breakArea' ] as const; class CartesianAxisView extends AxisView { @@ -66,7 +67,7 @@ class CartesianAxisView extends AxisView { const layout = cartesianAxisHelper.layout(gridModel, axisModel); - const axisBuilder = new AxisBuilder(axisModel, zrUtil.extend({ + const axisBuilder = new AxisBuilder(axisModel, api, zrUtil.extend({ handleAutoShown(elementType) { const cartesians = gridModel.coordinateSystem.getCartesians(); for (let i = 0; i < cartesians.length; i++) { @@ -86,7 +87,7 @@ class CartesianAxisView extends AxisView { zrUtil.each(selfBuilderAttrs, function (name) { if (axisModel.get([name, 'show'])) { - axisElementBuilders[name](this, this._axisGroup, axisModel, gridModel); + axisElementBuilders[name](this, this._axisGroup, axisModel, gridModel, api); } }, this); @@ -108,12 +109,18 @@ class CartesianAxisView extends AxisView { } interface AxisElementBuilder { - (axisView: CartesianAxisView, axisGroup: graphic.Group, axisModel: CartesianAxisModel, gridModel: GridModel): void + ( + axisView: CartesianAxisView, + axisGroup: graphic.Group, + axisModel: CartesianAxisModel, + gridModel: GridModel, + api: ExtensionAPI + ): void } const axisElementBuilders: Record = { - splitLine(axisView, axisGroup, axisModel, gridModel) { + splitLine(axisView, axisGroup, axisModel, gridModel, api) { const axis = axisModel.axis; if (axis.scale.isBlank()) { @@ -134,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 new file mode 100644 index 0000000000..db8b94a56e --- /dev/null +++ b/src/component/axis/axisBreakHelper.ts @@ -0,0 +1,90 @@ +/* +* 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 * 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 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; + } + } + )[]; +}; + + +let _impl: AxisBreakHelper = null; + +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/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/axisPointer/axisTrigger.ts b/src/component/axisPointer/axisTrigger.ts index e3051b953d..082b61c0d9 100644 --- a/src/component/axisPointer/axisTrigger.ts +++ b/src/component/axisPointer/axisTrigger.ts @@ -273,7 +273,8 @@ function buildPayloadsBySeries(value: AxisValue, axisInfo: CollectedAxisInfo) { seriesNestestValue = result.nestestValue; } else { - dataIndices = series.getData().indicesOfNearest( + dataIndices = series.indicesOfNearest( + dim, dataDim[0], value as number, // Add a threshold to avoid find the wrong dataIndex diff --git a/src/component/marker/markerHelper.ts b/src/component/marker/markerHelper.ts index f5e5b7ddf3..00471f8079 100644 --- a/src/component/marker/markerHelper.ts +++ b/src/component/marker/markerHelper.ts @@ -54,6 +54,7 @@ function hasXAndY(item: MarkerPositionOption) { function markerTypeCalculatorWithExtent( markerType: MarkerStatisticType, data: SeriesData, + axisDim: string, otherDataDim: string, targetDataDim: string, otherCoordIndex: number, @@ -68,7 +69,13 @@ function markerTypeCalculatorWithExtent( const value = numCalculate(data, calcDataDim, markerType); - const dataIndex = data.indicesOfNearest(calcDataDim, value)[0]; + const seriesModel = data.hostModel as SeriesModel; + const dataIndex = seriesModel.indicesOfNearest( + axisDim, + calcDataDim, + value + )[0]; + coordArr[otherCoordIndex] = data.get(otherDataDim, dataIndex); coordArr[targetCoordIndex] = data.get(calcDataDim, dataIndex); const coordArrValue = data.get(targetDataDim, dataIndex); @@ -127,7 +134,7 @@ export function dataTransform( const targetCoordIndex = indexOf(dims, axisInfo.valueAxis.dim); const coordInfo = markerTypeCalculator[item.type]( - data, axisInfo.baseDataDim, axisInfo.valueDataDim, + data, axisInfo.valueAxis.dim, axisInfo.baseDataDim, axisInfo.valueDataDim, otherCoordIndex, targetCoordIndex ); item.coord = coordInfo[0]; diff --git a/src/component/radar/RadarView.ts b/src/component/radar/RadarView.ts index 1b3c11f6d1..13ea57f581 100644 --- a/src/component/radar/RadarView.ts +++ b/src/component/radar/RadarView.ts @@ -39,18 +39,18 @@ class RadarView extends ComponentView { const group = this.group; group.removeAll(); - this._buildAxes(radarModel); + this._buildAxes(radarModel, api); this._buildSplitLineAndArea(radarModel); } - _buildAxes(radarModel: RadarModel) { + _buildAxes(radarModel: RadarModel, api: ExtensionAPI) { const radar = radarModel.coordinateSystem; const indicatorAxes = radar.getIndicatorAxes(); const axisBuilders = zrUtil.map(indicatorAxes, function (indicatorAxis) { const axisName = indicatorAxis.model.get('showName') ? indicatorAxis.name : ''; // hide name - const axisBuilder = new AxisBuilder(indicatorAxis.model, { + const axisBuilder = new AxisBuilder(indicatorAxis.model, api, { axisName: axisName, position: [radar.cx, radar.cy], rotation: indicatorAxis.angle, 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 bdaca28735..bb8c302b3a 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -24,7 +24,7 @@ import { createAxisLabels, calculateCategoryInterval } from './axisTickLabelBuilder'; -import Scale from '../scale/Scale'; +import Scale, { ScaleGetTicksOpt } from '../scale/Scale'; import { DimensionName, ScaleDataValue, ScaleTick } from '../util/types'; import OrdinalScale from '../scale/Ordinal'; import Model from '../model/Model'; @@ -87,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)); } /** @@ -122,7 +122,7 @@ class Axis { dataToCoord(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]; @@ -168,12 +168,17 @@ 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 ticksCoords = map(ticks, function (tickVal) { @@ -289,7 +294,10 @@ function fixExtentWithBands(extent: [number, number], nTick: number): void { // to displayed labels. (So we should not use `getBandWidth` in this // case). function fixOnBandTicksCoords( - axis: Axis, ticksCoords: TickCoord[], alignWithLabel: boolean, clamp: boolean + axis: Axis, + ticksCoords: TickCoord[], + alignWithLabel: boolean, + clamp: boolean ) { const ticksLen = ticksCoords.length; 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 fe4e4fc6f6..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 + 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,6 +86,19 @@ export interface AxisBaseOptionCommon extends ComponentOption, max?: ScaleDataValue | 'dataMax' | ((extent: {min: number, max: number}) => ScaleDataValue); startValue?: number; + breaks?: AxisBreakOption[]; + breakArea?: { + show?: boolean; + itemStyle?: ItemStyleOption; + zigzagAmplitude?: number; + zigzagMinSpan?: number; + zigzagMaxSpan?: number; + zigzagZ: number; + expandOnClick?: boolean; + }; + breakLabelLayout?: { + moveOverlap?: 'auto' | boolean; + } } export interface NumericAxisBaseOptionCommon extends AxisBaseOptionCommon { @@ -121,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; @@ -178,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 { @@ -190,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}) => 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 @@ -234,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. @@ -247,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 { @@ -280,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 ae589f365a..24f80f8f7d 100644 --- a/src/coord/axisDefault.ts +++ b/src/coord/axisDefault.ts @@ -20,7 +20,6 @@ import * as zrUtil from 'zrender/src/core/util'; import { AxisBaseOption } from './axisCommonTypes'; - const defaultOption: AxisBaseOption = { show: true, // zlevel: 0, @@ -66,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, @@ -89,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, @@ -106,6 +112,26 @@ const defaultOption: AxisBaseOption = { areaStyle: { color: ['rgba(250,250,250,0.2)', 'rgba(210,219,238,0.2)'] } + }, + breakArea: { + show: true, + itemStyle: { + color: '#fff', + // Break border color should be darker than the splitLine + // because it has opacity and should be more prominent + borderColor: '#C2CADA', + borderWidth: 1, + borderType: [3, 3], + opacity: 0.6 + }, + zigzagAmplitude: 4, + zigzagMinSpan: 4, + zigzagMaxSpan: 20, + zigzagZ: 100, + expandOnClick: true, + }, + breakLabelLayout: { + moveOverlap: 'auto', } }; diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 124a9f1c38..195dc06f0d 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -32,20 +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, 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; @@ -167,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, @@ -204,7 +211,7 @@ export function createScaleByModel(model: AxisBaseModel, axisType?: string): Sca case 'time': return new TimeScale({ locale: model.ecModel.getLocaleModel(), - useUTC: model.ecModel.get('useUTC') + useUTC: model.ecModel.get('useUTC'), }); default: // case 'value'/'interval', 'log', or others. @@ -232,31 +239,25 @@ export function ifAxisCrossZero(axis: Axis) { * return: {string} label string. */ export function makeLabelFormatter(axis: Axis): (tick: ScaleTick, idx?: number) => string { - const labelFormatter = (axis.getLabelModel() as Model) - .get('formatter'); - const categoryTickStart = axis.type === 'category' ? axis.scale.getExtent()[0] : null; + const labelFormatter = axis.getLabelModel().get('formatter'); - if (axis.scale.type === 'time') { - return (function (tpl) { - return function (tick: ScaleTick, idx: number) { - return (axis.scale as TimeScale).getFormattedLabel(tick, idx, tpl); - }; - })(labelFormatter as TimeAxisLabelFormatterOption); + 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) { - 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 = tpl.replace('{value}', label != null ? label : ''); - - return text; - }; - })(labelFormatter); + 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) { + 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 @@ -264,18 +265,28 @@ 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; - } - 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); + } + 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) { @@ -284,11 +295,12 @@ export function makeLabelFormatter(axis: Axis): (tick: ScaleTick, idx?: number) } } -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; } /** @@ -547,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 329ad6447b..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,10 +76,11 @@ function tickValuesToNumbers(axis: Axis, values: (number | string | Date)[]) { export function createAxisLabels(axis: Axis): { labels: { - level?: number, formattedLabel: string, rawLabel: string, - tickValue: number + tickValue: number, + time: ScaleTick['time'] | NullUndefined, + break: VisualAxisBreak | NullUndefined, }[], labelCategoryInterval?: number } { @@ -93,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, }; }) }; @@ -112,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,7 +136,7 @@ export function createAxisTicks(axis: Axis, tickModel: AxisBaseModel): { // Only ordinal scale support tick interval return axis.type === 'category' ? makeCategoryTicks(axis, tickModel) - : {ticks: zrUtil.map(axis.scale.getTicks(), tick => tick.value) }; + : {ticks: zrUtil.map(axis.scale.getTicks(opt), tick => tick.value)}; } function makeCategoryLabels(axis: Axis) { @@ -214,10 +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 + tickValue: tick.value, + time: tick.time, + break: tick.break, }; }) }; @@ -368,6 +378,8 @@ interface MakeLabelsResultObj { formattedLabel: string rawLabel: string tickValue: number + time: undefined + break: undefined } function makeLabelsByNumericCategoryInterval(axis: Axis, categoryInterval: number): MakeLabelsResultObj[]; @@ -425,7 +437,9 @@ function makeLabelsByNumericCategoryInterval(axis: Axis, categoryInterval: numbe : { formattedLabel: labelFormatter(tickObj), rawLabel: ordinalScale.getLabel(tickObj), - tickValue: tickValue + tickValue: tickValue, + time: undefined, + break: undefined, } ); } @@ -458,7 +472,9 @@ function makeLabelsByCustomizedCategoryInterval(axis: Axis, categoryInterval: Ca : { 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 4072684d14..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,7 +121,8 @@ class Cartesian2D extends Cartesian implements CoordinateSystem { out = out || []; const xVal = data[0]; const yVal = data[1]; - // 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 @@ -131,6 +132,7 @@ class Cartesian2D extends Cartesian implements CoordinateSystem { ) { 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)); @@ -190,7 +192,6 @@ class Cartesian2D extends Cartesian implements CoordinateSystem { return new BoundingRect(x, y, width, height); } - }; interface Cartesian2DArea extends BoundingRect {} diff --git a/src/core/ExtendedElement.ts b/src/core/ExtendedElement.ts new file mode 100644 index 0000000000..97ceb01c22 --- /dev/null +++ b/src/core/ExtendedElement.ts @@ -0,0 +1,9 @@ +import Element, { ElementProps } from 'zrender/src/Element'; + +export interface ExtendedElement extends Element { + ignoreModelZ?: boolean; +} + +export interface ExtendedElementProps extends ElementProps { + ignoreModelZ?: boolean; +} diff --git a/src/core/echarts.ts b/src/core/echarts.ts index 0d5a5087f6..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'; @@ -133,7 +134,10 @@ import lifecycle, { import { platformApi, setPlatformAPI } from 'zrender/src/core/platform'; import { getImpl } from './impl'; import type geoSourceManager from '../coord/geo/geoSourceManager'; -import {registerCustomSeries as registerCustom} from '../chart/custom/customSeriesRegister'; +import { ExtendedElement } from './ExtendedElement'; +import { + registerCustomSeries as registerCustom +} from '../chart/custom/customSeriesRegister'; declare let global: any; @@ -1208,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 { @@ -1399,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; } @@ -1630,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; @@ -1666,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); @@ -1950,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(); @@ -1973,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); @@ -1984,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. @@ -2030,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 }; @@ -2042,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); } } }; @@ -2418,13 +2424,19 @@ class ECharts extends Eventful { const zlevel = model.get('zlevel') || 0; // Set z and zlevel view.eachRendered((el) => { - doUpdateZ(el, z, zlevel, -Infinity); + doUpdateZ(el, z, zlevel, -Infinity, false); // Don't traverse the children because it has been traversed in _updateZ. return true; }); }; - function doUpdateZ(el: Element, z: number, zlevel: number, maxZ2: number): number { + function doUpdateZ( + el: Element, + z: number, + zlevel: number, + maxZ2: number, + ignoreModelZ: boolean + ): number { // Group may also have textContent const label = el.getTextContent(); const labelLine = el.getTextGuideLine(); @@ -2434,10 +2446,25 @@ class ECharts extends Eventful { // set z & zlevel of children elements of Group const children = (el as graphic.Group).childrenRef(); for (let i = 0; i < children.length; i++) { - maxZ2 = Math.max(doUpdateZ(children[i], z, zlevel, maxZ2), maxZ2); + ignoreModelZ = ignoreModelZ || (el as ExtendedElement).ignoreModelZ; + maxZ2 = Math.max( + doUpdateZ( + children[i], + z, + zlevel, + maxZ2, + ignoreModelZ || (el as ExtendedElement).ignoreModelZ + ), + maxZ2 + ); } } else { + if (ignoreModelZ || (el as ExtendedElement).ignoreModelZ) { + // This child element will not be set z and zlevel of the group + return maxZ2; + } + // not Group (el as Displayable).z = z; (el as Displayable).zlevel = zlevel; @@ -2607,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) { @@ -2667,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[] = []; @@ -2883,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 = ''; + } + + 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; } - 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. + + 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; } - eventActionMap[eventName as string] = actionType as string; + + if (__DEV__ && connectionEventRevertMap[nonRefinedEventType]) { + error(`${nonRefinedEventType} must not be shared; use "refineEvent" if you intend to share an event name.`); + } + connectionEventRevertMap[nonRefinedEventType] = actionType; } export function registerCoordinateSystem( @@ -3117,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/data/DataStore.ts b/src/data/DataStore.ts index a0f306bd0e..38227916ed 100644 --- a/src/data/DataStore.ts +++ b/src/data/DataStore.ts @@ -564,63 +564,6 @@ class DataStore { return -1; } - - /** - * Retrieve the index of nearest value. - * @param dim - * @param value - * @param [maxDistance=Infinity] - * @return If and only if multiple indices have - * the same value, they are put to the result. - */ - indicesOfNearest( - dim: DimensionIndex, value: number, maxDistance?: number - ): number[] { - const chunks = this._chunks; - const dimData = chunks[dim]; - const nearestIndices: number[] = []; - - if (!dimData) { - return nearestIndices; - } - - if (maxDistance == null) { - maxDistance = Infinity; - } - - let minDist = Infinity; - let minDiff = -1; - let nearestIndicesLen = 0; - - // Check the test case of `test/ut/spec/data/SeriesData.js`. - for (let i = 0, len = this.count(); i < len; i++) { - const dataIndex = this.getRawIndex(i); - const diff = value - (dimData[dataIndex] as number); - const dist = Math.abs(diff); - if (dist <= maxDistance) { - // When the `value` is at the middle of `this.get(dim, i)` and `this.get(dim, i+1)`, - // we'd better not push both of them to `nearestIndices`, otherwise it is easy to - // get more than one item in `nearestIndices` (more specifically, in `tooltip`). - // So we choose the one that `diff >= 0` in this case. - // But if `this.get(dim, i)` and `this.get(dim, j)` get the same value, both of them - // should be push to `nearestIndices`. - if (dist < minDist - || (dist === minDist && diff >= 0 && minDiff < 0) - ) { - minDist = dist; - minDiff = diff; - nearestIndicesLen = 0; - } - if (diff === minDiff) { - nearestIndices[nearestIndicesLen++] = i; - } - } - } - nearestIndices.length = nearestIndicesLen; - - return nearestIndices; - } - getIndices(): ArrayLike { let newIndices; diff --git a/src/data/SeriesData.ts b/src/data/SeriesData.ts index bc48a9a4d6..68c17d6b17 100644 --- a/src/data/SeriesData.ts +++ b/src/data/SeriesData.ts @@ -873,20 +873,6 @@ class SeriesData< return rawIndex; } - /** - * Retrieve the index of nearest value - * @param dim - * @param value - * @param [maxDistance=Infinity] - * @return If and only if multiple indices has - * the same value, they are put to the result. - */ - indicesOfNearest(dim: DimensionLoose, value: number, maxDistance?: number): number[] { - return this._store.indicesOfNearest( - this._getStoreDimIndex(dim), - value, maxDistance - ); - } /** * Data iteration * @param ctx default this diff --git a/src/echarts.all.ts b/src/echarts.all.ts index f531897478..adad6b725d 100644 --- a/src/echarts.all.ts +++ b/src/echarts.all.ts @@ -90,7 +90,8 @@ import { import { UniversalTransition, - LabelLayout + LabelLayout, + AxisBreak } from './export/features'; @@ -148,8 +149,6 @@ use([ // Coordinate systems // ------------------- - - // All of the axis modules have been included in the // coordinate system module below, do not need to // make extra import. @@ -356,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/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/label/labelLayoutHelper.ts b/src/label/labelLayoutHelper.ts index 2f42db5c7c..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,10 +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; + + // 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; @@ -344,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; } @@ -362,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/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/Series.ts b/src/model/Series.ts index 94e630fc17..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'; @@ -140,7 +141,7 @@ class SeriesModel extends ComponentMode // @readonly type: string; - // Should be implenented in subclass. + // Should be impleneted in subclass. defaultOption: SeriesOption; // @readonly @@ -436,6 +437,62 @@ class SeriesModel extends ComponentMode return coordSys && coordSys.getBaseAxis && coordSys.getBaseAxis(); } + /** + * Retrieve the index of nearest value in the view coordinate. + * Data position is compared with each axis's dataToCoord. + * + * @param axisDim axis dimension + * @param dim data dimension + * @param value + * @param [maxDistance=Infinity] The maximum distance in view coordinate space + * @return If and only if multiple indices has + * the same value, they are put to the result. + */ + indicesOfNearest(axisDim: DimensionName, dim: DimensionLoose, value: number, maxDistance?: number): number[] { + const data = this.getData(); + const coordSys = this.coordinateSystem; + const axis = coordSys && coordSys.getAxis(axisDim); + if (!coordSys || !axis) { + return []; + } + const targetCoord = axis.dataToCoord(value); + + if (maxDistance == null) { + maxDistance = Infinity; + } + + const nearestIndices: number[] = []; + let minDist = Infinity; + let minDiff = -1; + let nearestIndicesLen = 0; + + data.each(dim, (dimValue, idx) => { + const dataCoord = axis.dataToCoord(dimValue); + const diff = targetCoord - dataCoord; + const dist = Math.abs(diff); + if (dist <= maxDistance) { + // When the `value` is at the middle of `this.get(dim, i)` and `this.get(dim, i+1)`, + // we'd better not push both of them to `nearestIndices`, otherwise it is easy to + // get more than one item in `nearestIndices` (more specifically, in `tooltip`). + // So we choose the one that `diff >= 0` in this case. + // But if `this.get(dim, i)` and `this.get(dim, j)` get the same value, both of them + // should be push to `nearestIndices`. + if (dist < minDist + || (dist === minDist && diff >= 0 && minDiff < 0) + ) { + minDist = dist; + minDiff = diff; + nearestIndicesLen = 0; + } + if (diff === minDiff) { + nearestIndices[nearestIndicesLen++] = idx; + } + } + }); + nearestIndices.length = nearestIndicesLen; + return nearestIndices; + } + /** * Default tooltip formatter * diff --git a/src/scale/Interval.ts b/src/scale/Interval.ts index 3fc00cf142..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); + return this._calculator.normalize(val, this._extent); } scale(val: number): number { - return helper.scale(val, this._extent); - } - - 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,15 +127,27 @@ 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]) { ticks.push({ value: tick }); + // Avoid rounding error tick = roundNumber(tick + interval, intervalPrecision); - if (tick === ticks[ticks.length - 1].value) { + 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 break; @@ -137,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) }); @@ -149,17 +172,43 @@ class IntervalScale = Dictionary> e } } + 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; @@ -174,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. @@ -217,8 +280,8 @@ class IntervalScale = Dictionary> e */ calcNiceTicks(splitNumber?: number, minInterval?: number, maxInterval?: number): void { splitNumber = splitNumber || 5; - const extent = this._extent; - let span = extent[1] - extent[0]; + let extent = this._extent.slice() as [number, number]; + let span = this._getExtentSpanWithBreaks(); if (!isFinite(span)) { return; } @@ -227,10 +290,12 @@ 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( - extent, splitNumber, minInterval, maxInterval + extent, span, splitNumber, minInterval, maxInterval ); this._intervalPrecision = result.intervalPrecision; @@ -245,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) { @@ -275,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) { @@ -286,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 b1f4d08b16..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); + return super.normalize(val); } scale(val: number): number { - val = scaleHelper.scale(val, this._extent); + 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 143f4c9b74..089791b0f2 100644 --- a/src/scale/Scale.ts +++ b/src/scale/Scale.ts @@ -24,21 +24,52 @@ import SeriesData from '../data/SeriesData'; import { DimensionName, ScaleDataValue, - OptionDataValue, DimensionLoose, - ScaleTick + ScaleTick, + AxisBreakOption, + NullUndefined, + ParsedAxisBreakList, } from '../util/types'; +import { + ScaleCalculator +} from './helper'; import { ScaleRawExtentInfo } from '../coord/scaleRawExtentInfo'; +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 = Dictionary> { +abstract class Scale { type: string; private _setting: SETTING; + // [CAVEAT]: Should update only by `_innerSetExtent`! protected _extent: [number, number]; + // 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; // Inject @@ -47,6 +78,11 @@ abstract class Scale = Dictionary> constructor(setting?: SETTING) { this._setting = setting || {} as SETTING; this._extent = [Infinity, -Infinity]; + const scaleBreakHelper = getScaleBreakHelper(); + if (scaleBreakHelper) { + this._brkCtx = scaleBreakHelper.createScaleBreakContext(); + this._brkCtx!.update(this._extent); + } } getSetting(name: KEY): SETTING[KEY] { @@ -60,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. @@ -78,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; @@ -115,6 +155,52 @@ abstract class Scale = Dictionary> if (!isNaN(end)) { thisExtent[1] = end; } + this._brkCtx && this._brkCtx.update(thisExtent); + } + + /** + * Prerequisite: Scale#parse is ready. + */ + setBreaksFromOption( + breakOptionList: AxisBreakOption[], + ): void { + const scaleBreakHelper = getScaleBreakHelper(); + if (scaleBreakHelper) { + this._innerSetBreak( + scaleBreakHelper.parseAxisBreakOption(breakOptionList, bind(this.parse, this)) + ); + } + } + + /** + * [CAVEAT]: It should not be overridden! + */ + _innerSetBreak(parsed: AxisBreakParsingResult) { + if (this._brkCtx) { + this._brkCtx.setBreaks(parsed); + this._calculator.updateMethods(this._brkCtx); + this._brkCtx.update(this._extent); + } + } + + /** + * [CAVEAT]: It should not be overridden! + */ + _innerGetBreaks(): ParsedAxisBreakList { + return this._brkCtx ? this._brkCtx.breaks : []; + } + + /** + * Do not expose the internal `_breaks` unless necessary. + */ + 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]; } /** @@ -171,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 2458490b2e..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} 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,6 +107,7 @@ const bisect = function ( type TimeScaleSetting = { locale: Model; useUTC: boolean; + modelAxisBreaks?: AxisBreakOption[]; }; class TimeScale extends IntervalScale { @@ -109,9 +115,9 @@ 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); @@ -133,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'); @@ -145,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 []; @@ -155,27 +164,88 @@ 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, + } + }); + + 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; } @@ -188,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 @@ -201,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); } @@ -208,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) { @@ -238,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); + return this._calculator.normalize(val, this._extent); } scale(val: number): number { - return scaleHelper.scale(val, this._extent); + return this._calculator.scale(val, this._extent); } } @@ -274,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(), @@ -407,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 } @@ -444,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 @@ -463,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 @@ -483,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] }]; @@ -504,8 +610,8 @@ function getIntervalTicks( } let interval: number; - let getterName; - let setterName; + let getterName: JSDateGetterNames; + let setterName: JSDateSetterNames; let isDate = false; switch (unitName) { @@ -553,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. @@ -568,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[][] = []; @@ -577,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; @@ -601,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; @@ -621,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); @@ -636,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 3302b165ee..67cb3c5584 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -20,7 +20,9 @@ import {getPrecision, round, nice, quantityExponent} from '../util/number'; import IntervalScale from './Interval'; import LogScale from './Log'; -import Scale from './Scale'; +import type Scale from './Scale'; +import { bind } from 'zrender/src/core/util'; +import type { ScaleBreakContext } from './break'; type intervalScaleNiceTicksResult = { interval: number, @@ -48,6 +50,7 @@ export function isIntervalOrLogScale(scale: Scale): scale is LogScale | Interval */ export function intervalScaleNiceTicks( extent: [number, number], + spanWithBreaks: number, splitNumber: number, minInterval?: number, maxInterval?: number @@ -55,8 +58,7 @@ export function intervalScaleNiceTicks( const result = {} as intervalScaleNiceTicksResult; - const span = extent[1] - extent[0]; - let interval = result.interval = nice(span / splitNumber, true); + let interval = result.interval = nice(spanWithBreaks / splitNumber, true); if (minInterval != null && interval < minInterval) { interval = result.interval = minInterval; } @@ -126,13 +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]): number { +export class ScaleCalculator { + + normalize: (val: number, extent: [number, number]) => number = normalize; + scale: (val: number, extent: [number, number]) => number = scale; + + 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; + } + } +} + +function normalize( + val: number, + extent: [number, number], + // Dont use optional arguments for performance consideration here. +): number { if (extent[1] === extent[0]) { return 0.5; } return (val - extent[0]) / (extent[1] - extent[0]); } -export function scale(val: number, extent: [number, number]): number { +function scale( + val: number, + extent: [number, number], +): number { return val * (extent[1] - extent[0]) + extent[0]; } + +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/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 2e1ba9b423..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,48 +341,26 @@ export function leveledFormat( template = formatter; } else if (zrUtil.isFunction(formatter)) { - // Callback formatter - template = formatter(tick.value, idx, { - level: tick.level - }); + 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]; } } @@ -262,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 1d085c198f..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,28 +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 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, - 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 { /** @@ -530,6 +667,7 @@ export type ECUnitOption = { darkMode?: boolean | 'auto' textStyle?: Pick useUTC?: boolean + hoverLayerThreshold?: number [key: string]: ComponentOption | ComponentOption[] | Dictionary | unknown @@ -1055,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 @@ -1072,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 new file mode 100644 index 0000000000..b0efdfeb2d --- /dev/null +++ b/test/axis-break.html @@ -0,0 +1,1109 @@ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+
+
+ +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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

+ + + + + + + + + + + + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/ut/spec/data/SeriesData.test.ts b/test/ut/spec/data/SeriesData.test.ts index 564772b310..d71fb3547f 100644 --- a/test/ut/spec/data/SeriesData.test.ts +++ b/test/ut/spec/data/SeriesData.test.ts @@ -1,4 +1,3 @@ - /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -265,26 +264,6 @@ describe('SeriesData', function () { }); }); - describe('Data read', function () { - it('indicesOfNearest', function () { - const data = new SeriesData(['value'], new Model()); - // ---- index: 0 1 2 3 4 5 6 7 - data.initData([10, 20, 30, 35, 40, 40, 35, 50]); - - expect(data.indicesOfNearest('value', 24.5)).toEqual([1]); - expect(data.indicesOfNearest('value', 25)).toEqual([1]); - expect(data.indicesOfNearest('value', 25.5)).toEqual([2]); - expect(data.indicesOfNearest('value', 25.5)).toEqual([2]); - expect(data.indicesOfNearest('value', 41)).toEqual([4, 5]); - expect(data.indicesOfNearest('value', 39)).toEqual([4, 5]); - expect(data.indicesOfNearest('value', 41)).toEqual([4, 5]); - expect(data.indicesOfNearest('value', 36)).toEqual([3, 6]); - - expect(data.indicesOfNearest('value', 50.6, 0.5)).toEqual([]); - expect(data.indicesOfNearest('value', 50.5, 0.5)).toEqual([7]); - }); - }); - describe('id_and_name', function () { function makeOneByOneChecker(list: SeriesData) { diff --git a/test/ut/spec/scale/interval.test.ts b/test/ut/spec/scale/interval.test.ts index 60481156b8..d374eed733 100755 --- a/test/ut/spec/scale/interval.test.ts +++ b/test/ut/spec/scale/interval.test.ts @@ -119,7 +119,8 @@ describe('scale_interval', function () { } function doSingleTest(extent: [number, number], splitNumber: number): void { - const result = intervalScaleNiceTicks(extent, splitNumber); + const span = extent[1] - extent[0]; + const result = intervalScaleNiceTicks(extent, span, splitNumber); const intervalPrecision = result.intervalPrecision; const resultInterval = result.interval; const niceTickExtent = result.niceTickExtent; 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')}`; +}