This is an automated email from the ASF dual-hosted git repository. shenyi pushed a commit to branch label-enhancement in repository https://gitbox.apache.org/repos/asf/incubator-echarts.git
commit 580972089a8d73394247c1f82fe7c6c6056d849f Author: pissang <[email protected]> AuthorDate: Wed Apr 22 22:52:16 2020 +0800 feat: add label manager for each series to layout label. --- src/ExtensionAPI.ts | 45 +++--- src/chart/funnel/FunnelView.ts | 10 +- src/chart/graph/GraphView.ts | 2 + src/chart/sunburst/SunburstPiece.ts | 4 +- src/chart/sunburst/SunburstSeries.ts | 5 +- src/chart/tree/TreeView.ts | 2 + src/echarts.ts | 32 +++- src/model/Model.ts | 64 +++++--- src/model/mixin/dataFormat.ts | 30 ++-- src/stream/Scheduler.ts | 16 +- src/util/LabelManager.ts | 286 +++++++++++++++++++++++++++++++++++ src/util/graphic.ts | 107 +++++++++++-- src/util/types.ts | 49 +++++- test/animation-additive.html | 162 ++++++++++++++++++++ test/bar-stack.html | 31 +--- test/graph-label-rotate.html | 14 +- test/label-overlap.html | 207 +++++++++++++++++++++++++ 17 files changed, 936 insertions(+), 130 deletions(-) diff --git a/src/ExtensionAPI.ts b/src/ExtensionAPI.ts index dae64dd..cb72d65 100644 --- a/src/ExtensionAPI.ts +++ b/src/ExtensionAPI.ts @@ -23,32 +23,33 @@ import {CoordinateSystemMaster} from './coord/CoordinateSystem'; import Element from 'zrender/src/Element'; import ComponentModel from './model/Component'; -const availableMethods = { - getDom: 1, - getZr: 1, - getWidth: 1, - getHeight: 1, - getDevicePixelRatio: 1, - dispatchAction: 1, - isDisposed: 1, - on: 1, - off: 1, - getDataURL: 1, - getConnectedDataURL: 1, - getModel: 1, - getOption: 1, - getViewOfComponentModel: 1, - getViewOfSeriesModel: 1, - getId: 1 -}; - -interface ExtensionAPI extends Pick<EChartsType, keyof typeof availableMethods> {} +const availableMethods: (keyof EChartsType)[] = [ + 'getDom', + 'getZr', + 'getWidth', + 'getHeight', + 'getDevicePixelRatio', + 'dispatchAction', + 'isDisposed', + 'on', + 'off', + 'getDataURL', + 'getConnectedDataURL', + 'getModel', + 'getOption', + 'getViewOfComponentModel', + 'getViewOfSeriesModel', + 'getId', + 'updateLabelLayout' +]; + +interface ExtensionAPI extends Pick<EChartsType, (typeof availableMethods)[number]> {} abstract class ExtensionAPI { constructor(ecInstance: EChartsType) { - zrUtil.each(availableMethods, function (v, name: string) { - (this as any)[name] = zrUtil.bind((ecInstance as any)[name], ecInstance); + zrUtil.each(availableMethods, function (methodName: string) { + (this as any)[methodName] = zrUtil.bind((ecInstance as any)[methodName], ecInstance); }, this); } diff --git a/src/chart/funnel/FunnelView.ts b/src/chart/funnel/FunnelView.ts index 47e1209..2cb9297 100644 --- a/src/chart/funnel/FunnelView.ts +++ b/src/chart/funnel/FunnelView.ts @@ -23,7 +23,8 @@ import FunnelSeriesModel, {FunnelDataItemOption} from './FunnelSeries'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../ExtensionAPI'; import List from '../../data/List'; -import { ColorString } from '../../util/types'; +import { ColorString, LabelOption } from '../../util/types'; +import Model from '../../model/Model'; const opacityAccessPath = ['itemStyle', 'opacity'] as const; @@ -109,7 +110,8 @@ class FunnelPiece extends graphic.Group { const visualColor = data.getItemVisual(idx, 'style').fill as ColorString; graphic.setLabelStyle( - labelText, labelModel, labelHoverModel, + // position will not be used in setLabelStyle + labelText, labelModel as Model<LabelOption>, labelHoverModel as Model<LabelOption>, { labelFetcher: data.hostModel as FunnelSeriesModel, labelDataIndex: idx, @@ -151,10 +153,6 @@ class FunnelPiece extends graphic.Group { z2: 10 }); - labelText.ignore = !labelModel.get('show'); - const labelTextEmphasisState = labelText.ensureState('emphasis'); - labelTextEmphasisState.ignore = !labelHoverModel.get('show'); - labelLine.ignore = !labelLineModel.get('show'); const labelLineEmphasisState = labelLine.ensureState('emphasis'); labelLineEmphasisState.ignore = !labelLineHoverModel.get('show'); diff --git a/src/chart/graph/GraphView.ts b/src/chart/graph/GraphView.ts index 7d604a3..8f83896 100644 --- a/src/chart/graph/GraphView.ts +++ b/src/chart/graph/GraphView.ts @@ -437,6 +437,8 @@ class GraphView extends ChartView { this._updateNodeAndLinkScale(); adjustEdge(seriesModel.getGraph(), getNodeGlobalScale(seriesModel)); this._lineDraw.updateLayout(); + // Only update label layout on zoom + api.updateLabelLayout(); }); } diff --git a/src/chart/sunburst/SunburstPiece.ts b/src/chart/sunburst/SunburstPiece.ts index 86bf38a..9fbb3fd 100644 --- a/src/chart/sunburst/SunburstPiece.ts +++ b/src/chart/sunburst/SunburstPiece.ts @@ -218,9 +218,7 @@ class SunburstPiece extends graphic.Group { const labelHoverModel = itemModel.getModel(['emphasis', 'label']); let text = zrUtil.retrieve( - seriesModel.getFormattedLabel( - this.node.dataIndex, state, null, null, 'label' - ), + seriesModel.getFormattedLabel(this.node.dataIndex, state), this.node.name ); if (getLabelAttr('show') === false) { diff --git a/src/chart/sunburst/SunburstSeries.ts b/src/chart/sunburst/SunburstSeries.ts index be7b077..327a1ab 100644 --- a/src/chart/sunburst/SunburstSeries.ts +++ b/src/chart/sunburst/SunburstSeries.ts @@ -139,10 +139,7 @@ export interface SunburstSeriesOption extends SeriesOption, CircleLayoutOptionMi interface SunburstSeriesModel { getFormattedLabel( dataIndex: number, - state?: 'emphasis' | 'normal' | 'highlight' | 'downplay', - dataType?: string, - dimIndex?: number, - labelProp?: string + state?: 'emphasis' | 'normal' | 'highlight' | 'downplay' ): string } class SunburstSeriesModel extends SeriesModel<SunburstSeriesOption> { diff --git a/src/chart/tree/TreeView.ts b/src/chart/tree/TreeView.ts index 2d16448..1484889 100644 --- a/src/chart/tree/TreeView.ts +++ b/src/chart/tree/TreeView.ts @@ -351,6 +351,8 @@ class TreeView extends ChartView { originY: e.originY }); this._updateNodeAndLinkScale(seriesModel); + // Only update label layout on zoom + api.updateLabelLayout(); }); } diff --git a/src/echarts.ts b/src/echarts.ts index 9711a77..ecaa352 100644 --- a/src/echarts.ts +++ b/src/echarts.ts @@ -71,6 +71,7 @@ import IncrementalDisplayable from 'zrender/src/graphic/IncrementalDisplayable'; import 'zrender/src/canvas/canvas'; import { seriesSymbolTask, dataSymbolTask } from './visual/symbol'; import { getVisualFromData, getItemVisualFromData } from './visual/helper'; +import LabelManager from './util/LabelManager'; declare let global: any; type ModelFinder = modelUtil.ModelFinder; @@ -223,6 +224,8 @@ class ECharts extends Eventful { private _loadingFX: LoadingEffect; + private _labelManager: LabelManager; + private [OPTION_UPDATED]: boolean | {silent: boolean}; private [IN_MAIN_PROCESS]: boolean; private [CONNECT_STATUS_KEY]: ConnectStatus; @@ -287,6 +290,8 @@ class ECharts extends Eventful { this._messageCenter = new MessageCenter(); + this._labelManager = new LabelManager(); + // Init mouse events this._initEvents(); @@ -331,7 +336,7 @@ class ECharts extends Eventful { let remainTime = TEST_FRAME_REMAIN_TIME; const ecModel = this._model; const api = this._api; - scheduler.unfinished = +false; + scheduler.unfinished = false; do { const startTime = +new Date(); @@ -1050,6 +1055,12 @@ class ECharts extends Eventful { triggerUpdatedEvent.call(this, silent); } + updateLabelLayout() { + const labelManager = this._labelManager; + labelManager.updateLayoutConfig(this._api); + labelManager.layout(); + } + appendData(params: { seriesIndex: number, data: any @@ -1077,7 +1088,7 @@ class ECharts extends Eventful { // graphic elements have to be changed, which make the usage of // `appendData` meaningless. - this._scheduler.unfinished = +true; + this._scheduler.unfinished = true; } @@ -1637,7 +1648,11 @@ class ECharts extends Eventful { ): void { // Render all charts const scheduler = ecIns._scheduler; - let unfinished: number; + const labelManager = ecIns._labelManager; + + labelManager.clearLabels(); + + let unfinished: boolean; ecModel.eachSeries(function (seriesModel) { const chartView = ecIns._chartsMap[seriesModel.__viewId]; chartView.__alive = true; @@ -1649,7 +1664,7 @@ class ECharts extends Eventful { renderTask.dirty(); } - unfinished |= +renderTask.perform(scheduler.getPerformArgs(renderTask)); + unfinished = renderTask.perform(scheduler.getPerformArgs(renderTask)) || unfinished; chartView.group.silent = !!seriesModel.get('silent'); @@ -1658,8 +1673,15 @@ class ECharts extends Eventful { updateBlend(seriesModel, chartView); updateHoverEmphasisHandler(chartView); + + // Add albels. + labelManager.addLabelsOfSeries(chartView); }); - scheduler.unfinished |= unfinished; + + scheduler.unfinished = unfinished || scheduler.unfinished; + + labelManager.updateLayoutConfig(api); + labelManager.layout(); // If use hover layer updateHoverLayerStatus(ecIns, ecModel); diff --git a/src/model/Model.ts b/src/model/Model.ts index bd3e282..5660029 100644 --- a/src/model/Model.ts +++ b/src/model/Model.ts @@ -17,7 +17,6 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; import env from 'zrender/src/core/env'; import { enableClassExtend, @@ -33,8 +32,7 @@ import {ItemStyleMixin} from './mixin/itemStyle'; import GlobalModel from './Global'; import { ModelOption } from '../util/types'; import { Dictionary } from 'zrender/src/core/types'; - -const mixin = zrUtil.mixin; +import { mixin, clone, merge, extend, isFunction } from 'zrender/src/core/util'; // Since model.option can be not only `Dictionary` but also primary types, // we do this conditional type to avoid getting type 'never'; @@ -52,20 +50,11 @@ class Model<Opt extends ModelOption = ModelOption> { // TODO: TYPE use unkown // subclass overrided filed will be overwritten by this // class. That is, they should not be initialized here. - /** - * @readOnly - */ parentModel: Model; - /** - * @readOnly - */ - ecModel: GlobalModel;; + ecModel: GlobalModel; - /** - * @readOnly - */ - option: Opt; + option: Opt; // TODO Opt should only be object. constructor(option?: Opt, parentModel?: Model, ecModel?: GlobalModel) { this.parentModel = parentModel; @@ -89,7 +78,7 @@ class Model<Opt extends ModelOption = ModelOption> { // TODO: TYPE use unkown * Merge the input option to me. */ mergeOption(option: Opt, ecModel?: GlobalModel): void { - zrUtil.merge(this.option, option, true); + merge(this.option, option, true); } // FIXME:TS consider there is parentModel, @@ -169,6 +158,47 @@ class Model<Opt extends ModelOption = ModelOption> { // TODO: TYPE use unkown } /** + * Squash option stack into one. + * parentModel will be removed after squashed. + * + * NOTE: resolveParentPath will not be applied here for simplicity. DON'T use this function + * if resolveParentPath is modified. + * + * @param deepMerge If do deep merge. Default to be false. + */ + squash( + deepMerge?: boolean, + handleCallback?: (func: () => object) => object + ) { + const optionStack = []; + let model: Model = this; + while (model) { + if (model.option) { + optionStack.push(model.option); + } + model = model.parentModel; + } + + const newOption = {} as Opt; + let option; + while (option = optionStack.pop()) { // Top down merge + if (isFunction(option) && handleCallback) { + option = handleCallback(option); + } + if (deepMerge) { + merge(newOption, option); + } + else { + extend(newOption, option); + } + } + + // Remove parentModel + this.option = newOption; + this.parentModel = null; + } + + /** * If model has option */ isEmpty(): boolean { @@ -180,7 +210,7 @@ class Model<Opt extends ModelOption = ModelOption> { // TODO: TYPE use unkown // Pending clone(): Model<Opt> { const Ctor = this.constructor; - return new (Ctor as any)(zrUtil.clone(this.option)); + return new (Ctor as any)(clone(this.option)); } // setReadOnly(properties): void { @@ -204,7 +234,7 @@ class Model<Opt extends ModelOption = ModelOption> { // TODO: TYPE use unkown // FIXME:TS check whether put this method here isAnimationEnabled(): boolean { - if (!env.node) { + if (!env.node && this.option) { if (this.option.animation != null) { return !!this.option.animation; } diff --git a/src/model/mixin/dataFormat.ts b/src/model/mixin/dataFormat.ts index eca285f..5d12795 100644 --- a/src/model/mixin/dataFormat.ts +++ b/src/model/mixin/dataFormat.ts @@ -86,36 +86,38 @@ class DataFormatMixin { * @param dataIndex * @param status 'normal' by default * @param dataType - * @param dimIndex Only used in some chart that + * @param labelDimIndex Only used in some chart that * use formatter in different dimensions, like radar. - * @param labelProp 'label' by default - * @return If not formatter, return null/undefined + * @param formatter Formatter given outside. + * @return return null/undefined if no formatter */ getFormattedLabel( dataIndex: number, status?: DisplayState, dataType?: string, - dimIndex?: number, - labelProp?: string + labelDimIndex?: number, + formatter?: string | ((params: object) => string) ): string { status = status || 'normal'; const data = this.getData(dataType); - const itemModel = data.getItemModel(dataIndex); const params = this.getDataParams(dataIndex, dataType); - if (dimIndex != null && (params.value instanceof Array)) { - params.value = params.value[dimIndex]; + if (labelDimIndex != null && (params.value instanceof Array)) { + params.value = params.value[labelDimIndex]; } - // @ts-ignore FIXME:TooltipModel - const formatter = itemModel.get(status === 'normal' - ? [(labelProp || 'label'), 'formatter'] - : [status, labelProp || 'label', 'formatter'] - ); + if (!formatter) { + const itemModel = data.getItemModel(dataIndex); + // @ts-ignore + formatter = itemModel.get(status === 'normal' + ? ['label', 'formatter'] + : [status, 'label', 'formatter'] + ); + } if (typeof formatter === 'function') { params.status = status; - params.dimensionIndex = dimIndex; + params.dimensionIndex = labelDimIndex; return formatter(params); } else if (typeof formatter === 'string') { diff --git a/src/stream/Scheduler.ts b/src/stream/Scheduler.ts index b5759b4..ff156b7 100644 --- a/src/stream/Scheduler.ts +++ b/src/stream/Scheduler.ts @@ -106,7 +106,7 @@ class Scheduler { // Shared with echarts.js, should only be modified by // this file and echarts.js - unfinished: number; + unfinished: boolean; private _dataProcessorHandlers: StageHandlerInternal[]; private _visualHandlers: StageHandlerInternal[]; @@ -301,7 +301,7 @@ class Scheduler { opt?: PerformStageTaskOpt ): void { opt = opt || {}; - let unfinished: number; + let unfinished: boolean; const scheduler = this; each(stageHandlers, function (stageHandler, idx) { @@ -332,7 +332,7 @@ class Scheduler { agentStubMap.each(function (stub) { stub.perform(performArgs); }); - unfinished |= overallTask.perform(performArgs) as any; + unfinished = unfinished || overallTask.perform(performArgs); } else if (seriesTaskMap) { seriesTaskMap.each(function (task, pipelineId) { @@ -351,7 +351,7 @@ class Scheduler { performArgs.skip = !stageHandler.performRawSeries && ecModel.isSeriesFiltered(task.context.model); scheduler.updatePayload(task, payload); - unfinished |= task.perform(performArgs) as any; + unfinished = unfinished || task.perform(performArgs); }); } }); @@ -360,18 +360,18 @@ class Scheduler { return opt.setDirty && (!opt.dirtyMap || opt.dirtyMap.get(task.__pipeline.id)); } - this.unfinished |= unfinished; + this.unfinished = unfinished || this.unfinished; } performSeriesTasks(ecModel: GlobalModel): void { - let unfinished: number; + let unfinished: boolean; ecModel.eachSeries(function (seriesModel) { // Progress to the end for dataInit and dataRestore. - unfinished |= seriesModel.dataTask.perform() as any; + unfinished = seriesModel.dataTask.perform() || unfinished; }); - this.unfinished |= unfinished; + this.unfinished = unfinished || this.unfinished; } plan(): void { diff --git a/src/util/LabelManager.ts b/src/util/LabelManager.ts new file mode 100644 index 0000000..b8b9055 --- /dev/null +++ b/src/util/LabelManager.ts @@ -0,0 +1,286 @@ +/* +* 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 { OrientedBoundingRect, Text as ZRText, Point, BoundingRect, getECData } from './graphic'; +import { MatrixArray } from 'zrender/src/core/matrix'; +import ExtensionAPI from '../ExtensionAPI'; +import { + ZRTextAlign, + ZRTextVerticalAlign, + LabelLayoutOption, + LabelLayoutOptionCallback, + LabelLayoutOptionCallbackParams +} from './types'; +import { parsePercent } from './number'; +import SeriesModel from '../model/Series'; +import ChartView from '../view/Chart'; + +interface DisplayedLabelItem { + label: ZRText + rect: BoundingRect + localRect: BoundingRect + obb?: OrientedBoundingRect + axisAligned: boolean + transform: MatrixArray +} + +interface LabelItem { + label: ZRText + seriesIndex: number + dataIndex: number + layoutOption: LabelLayoutOptionCallback | LabelLayoutOption +} + +interface LabelLayoutInnerConfig { + overlap: LabelLayoutOption['overlap'] + overlapMargin: LabelLayoutOption['overlapMargin'] +} + +interface SavedLabelAttr { + x?: number + y?: number + rotation?: number + align?: ZRTextAlign + verticalAlign?: ZRTextVerticalAlign + width?: number + height?: number +} + +function prepareLayoutCallbackParams( + label: ZRText, + dataIndex: number, + seriesIndex: number +): LabelLayoutOptionCallbackParams { + const host = label.__hostTarget; + const labelTransform = label.getComputedTransform(); + const labelRect = label.getBoundingRect().plain(); + BoundingRect.applyTransform(labelRect, labelRect, labelTransform); + let x = 0; + let y = 0; + if (labelTransform) { + x = labelTransform[4]; + y = labelTransform[5]; + } + + let hostRect; + if (host) { + hostRect = host.getBoundingRect().plain(); + const transform = host.getComputedTransform(); + BoundingRect.applyTransform(hostRect, hostRect, transform); + } + + return { + dataIndex, + seriesIndex, + text: label.style.text, + rect: hostRect, + labelRect: labelRect, + x, y, + align: label.style.align, + verticalAlign: label.style.verticalAlign + }; +} + +const LABEL_OPTION_TO_STYLE_KEYS = ['align', 'verticalAlign', 'width', 'height'] as const; + +class LabelManager { + + private _labelList: LabelItem[] = []; + private _labelLayoutConfig: LabelLayoutInnerConfig[] = []; + + // Save default label attributes. + // For restore if developers want get back to default value in callback. + private _defaultLabelAttr: SavedLabelAttr[] = []; + + constructor() {} + + clearLabels() { + this._labelList = []; + this._labelLayoutConfig = []; + } + + /** + * Add label to manager + * @param dataIndex + * @param seriesIndex + * @param label + * @param layoutOption + */ + addLabel(dataIndex: number, seriesIndex: number, label: ZRText, layoutOption: LabelItem['layoutOption']) { + this._labelList.push({ + seriesIndex, + dataIndex, + label, + layoutOption + }); + // Push an empty config. Will be updated in updateLayoutConfig + this._labelLayoutConfig.push({} as LabelLayoutInnerConfig); + + const labelStyle = label.style; + this._defaultLabelAttr.push({ + x: label.x, + y: label.y, + rotation: label.rotation, + align: labelStyle.align, + verticalAlign: labelStyle.verticalAlign, + width: labelStyle.width, + height: labelStyle.height + }); + } + + addLabelsOfSeries(chartView: ChartView) { + const seriesModel = chartView.__model; + const layoutOption = seriesModel.get('labelLayout'); + chartView.group.traverse((child) => { + if (child.ignore) { + return true; // Stop traverse descendants. + } + + // Only support label being hosted on graphic elements. + const textEl = child.getTextContent(); + const dataIndex = getECData(child).dataIndex; + if (textEl && dataIndex != null) { + this.addLabel(dataIndex, seriesModel.seriesIndex, textEl, layoutOption); + } + }); + } + + updateLayoutConfig(api: ExtensionAPI) { + const width = api.getWidth(); + const height = api.getHeight(); + for (let i = 0; i < this._labelList.length; i++) { + const labelItem = this._labelList[i]; + const label = labelItem.label; + const hostEl = label.__hostTarget; + const layoutConfig = this._labelLayoutConfig[i]; + const defaultLabelAttr = this._defaultLabelAttr[i]; + let layoutOption; + if (typeof labelItem.layoutOption === 'function') { + layoutOption = labelItem.layoutOption( + prepareLayoutCallbackParams(label, labelItem.dataIndex, labelItem.seriesIndex) + ); + } + else { + layoutOption = labelItem.layoutOption; + } + + layoutOption = layoutOption || {}; + // if (hostEl) { + // // Ignore position and rotation config on the host el. + // hostEl.setTextConfig({ + // position: null, + // rotation: null + // }); + // } + // label.x = layoutOption.x != null + // ? parsePercent(layoutOption.x, width) + // // Restore to default value if developers don't given a value. + // : defaultLabelAttr.x; + + // label.y = layoutOption.y != null + // ? parsePercent(layoutOption.y, height) + // : defaultLabelAttr.y; + + // label.rotation = layoutOption.rotation != null + // ? layoutOption.rotation : defaultLabelAttr.rotation; + + // label.x += layoutOption.dx || 0; + // label.y += layoutOption.dy || 0; + + // for (let k = 0; k < LABEL_OPTION_TO_STYLE_KEYS.length; k++) { + // const key = LABEL_OPTION_TO_STYLE_KEYS[k]; + // label.setStyle(key, layoutOption[key] != null ? layoutOption[key] : defaultLabelAttr[key]); + // } + + layoutConfig.overlap = layoutOption.overlap; + layoutConfig.overlapMargin = layoutOption.overlapMargin; + } + } + + layout() { + // TODO: sort by priority + const labelList = this._labelList; + + const displayedLabels: DisplayedLabelItem[] = []; + const mvt = new Point(); + + for (let i = 0; i < labelList.length; i++) { + const labelItem = labelList[i]; + const layoutConfig = this._labelLayoutConfig[i]; + const label = labelItem.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); + + const globalRect = localRect.clone(); + globalRect.applyTransform(transform); + + let obb = isAxisAligned ? new OrientedBoundingRect(localRect, transform) : null; + let overlapped = false; + const overlapMargin = layoutConfig.overlapMargin || 0; + const marginSqr = overlapMargin * overlapMargin; + for (let j = 0; j < displayedLabels.length; j++) { + const existsTextCfg = displayedLabels[j]; + // Fast rejection. + if (!globalRect.intersect(existsTextCfg.rect, mvt) && mvt.lenSquare() > marginSqr) { + continue; + } + + if (isAxisAligned && existsTextCfg.axisAligned) { // Is overlapped + overlapped = true; + break; + } + + if (!existsTextCfg.obb) { // If self is not axis aligned. But other is. + existsTextCfg.obb = new OrientedBoundingRect(existsTextCfg.localRect, existsTextCfg.transform); + } + + if (!obb) { // If self is axis aligned. But other is not. + obb = new OrientedBoundingRect(localRect, transform); + } + + if (obb.intersect(existsTextCfg.obb, mvt) || mvt.lenSquare() < marginSqr) { + overlapped = true; + break; + } + } + + if (overlapped) { + label.hide(); + } + else { + label.show(); + displayedLabels.push({ + label, + rect: globalRect, + localRect, + obb, + axisAligned: isAxisAligned, + transform + }); + } + } + } +} + + + + +export default LabelManager; \ No newline at end of file diff --git a/src/util/graphic.ts b/src/util/graphic.ts index 9a35173..8020892 100644 --- a/src/util/graphic.ts +++ b/src/util/graphic.ts @@ -39,6 +39,8 @@ import CompoundPath from 'zrender/src/graphic/CompoundPath'; import LinearGradient from 'zrender/src/graphic/LinearGradient'; import RadialGradient from 'zrender/src/graphic/RadialGradient'; import BoundingRect from 'zrender/src/core/BoundingRect'; +import OrientedBoundingRect from 'zrender/src/core/OrientedBoundingRect'; +import Point from 'zrender/src/core/Point'; import IncrementalDisplayable from 'zrender/src/graphic/IncrementalDisplayable'; import * as subPixelOptimizeUtil from 'zrender/src/graphic/helper/subPixelOptimize'; import { Dictionary } from 'zrender/src/core/types'; @@ -605,19 +607,61 @@ interface SetLabelStyleOpt<LDI> extends TextCommonParams { ), // Fetch text by `opt.labelFetcher.getFormattedLabel(opt.labelDataIndex, 'normal'/'emphasis', null, opt.labelDimIndex)` labelFetcher?: { - getFormattedLabel?: ( + getFormattedLabel: ( // In MapDraw case it can be string (region name) labelDataIndex: LDI, - state: DisplayState, - dataType: string, - labelDimIndex: number + status: DisplayState, + dataType?: string, + labelDimIndex?: number, + formatter?: string | ((params: object) => string) ) => string + // getDataParams: (labelDataIndex: LDI, dataType?: string) => object }, labelDataIndex?: LDI, labelDimIndex?: number } +// function handleSquashCallback<LDI>( +// func: Function, +// labelDataIndex: LDI, +// labelFetcher: SetLabelStyleOpt<LDI>['labelFetcher'], +// rect: RectLike, +// status: DisplayState +// ) { +// let params: { +// status?: DisplayState +// rect?: RectLike +// }; +// if (labelFetcher && labelFetcher.getDataParams) { +// params = labelFetcher.getDataParams(labelDataIndex); +// } +// else { +// params = {}; +// } +// params.status = status; +// params.rect = rect; +// return func(params); +// } + +// function getGlobalBoundingRect(el: Element) { +// const rect = el.getBoundingRect().clone(); +// const transform = el.getComputedTransform(); +// if (transform) { +// rect.applyTransform(transform); +// } +// return rect; +// } + +type LabelModel = Model<LabelOption & { + formatter?: string | ((params: any) => string) +}>; +type LabelModelForText = Model<Omit< + // Remove + LabelOption, 'position' | 'rotate' +> & { + formatter?: string | ((params: any) => string) +}>; /** * Set normal styles and emphasis styles about text on target element * If target is a ZRText. It will create a new style object. @@ -627,10 +671,14 @@ interface SetLabelStyleOpt<LDI> extends TextCommonParams { * NOTICE: Because the style on ZRText will be replaced with new(only x, y are keeped). * So please use the style on ZRText after use this method. */ -export function setLabelStyle<LDI>( +// eslint-disable-next-line +function setLabelStyle<LDI>(targetEl: ZRText, normalModel: LabelModelForText, emphasisModel: LabelModelForText, opt?: SetLabelStyleOpt<LDI>, normalSpecified?: TextStyleProps, emphasisSpecified?: TextStyleProps): void; +// eslint-disable-next-line +function setLabelStyle<LDI>(targetEl: Element, normalModel: LabelModel, emphasisModel: LabelModel, opt?: SetLabelStyleOpt<LDI>, normalSpecified?: TextStyleProps, emphasisSpecified?: TextStyleProps): void; +function setLabelStyle<LDI>( targetEl: Element, - normalModel: Model, - emphasisModel: Model, + normalModel: LabelModel, + emphasisModel: LabelModel, opt?: SetLabelStyleOpt<LDI>, normalSpecified?: TextStyleProps, emphasisSpecified?: TextStyleProps @@ -639,6 +687,31 @@ export function setLabelStyle<LDI>( opt = opt || EMPTY_OBJ; const isSetOnText = targetEl instanceof ZRText; + const labelFetcher = opt.labelFetcher; + const labelDataIndex = opt.labelDataIndex; + const labelDimIndex = opt.labelDimIndex; + + // TODO Performance optimization + // normalModel.squash(false, function (func: Function) { + // return handleSquashCallback( + // func, + // labelDataIndex, + // labelFetcher, + // isSetOnText ? null : getGlobalBoundingRect(targetEl), + // 'normal' + // ); + // }); + + // emphasisModel.squash(false, function (func: Function) { + // return handleSquashCallback( + // func, + // labelDataIndex, + // labelFetcher, + // isSetOnText ? null : getGlobalBoundingRect(targetEl), + // 'emphasis' + // ); + // }); + const showNormal = normalModel.getShallow('show'); const showEmphasis = emphasisModel.getShallow('show'); @@ -647,13 +720,12 @@ export function setLabelStyle<LDI>( // label should be displayed, where text is fetched by `normal.formatter` or `opt.defaultText`. let richText = isSetOnText ? targetEl as ZRText : null; if (showNormal || showEmphasis) { - const labelFetcher = opt.labelFetcher; - const labelDataIndex = opt.labelDataIndex; - const labelDimIndex = opt.labelDimIndex; - let baseText; if (labelFetcher) { - baseText = labelFetcher.getFormattedLabel(labelDataIndex, 'normal', null, labelDimIndex); + baseText = labelFetcher.getFormattedLabel( + labelDataIndex, 'normal', null, labelDimIndex, + normalModel.get('formatter') + ); } if (baseText == null) { baseText = isFunction(opt.defaultText) ? opt.defaultText(labelDataIndex, opt) : opt.defaultText; @@ -661,7 +733,10 @@ export function setLabelStyle<LDI>( const normalStyleText = baseText; const emphasisStyleText = retrieve2( labelFetcher - ? labelFetcher.getFormattedLabel(labelDataIndex, 'emphasis', null, labelDimIndex) + ? labelFetcher.getFormattedLabel( + labelDataIndex, 'emphasis', null, labelDimIndex, + emphasisModel.get('formatter') + ) : null, baseText ); @@ -738,6 +813,7 @@ export function setLabelStyle<LDI>( targetEl.dirty(); } +export {setLabelStyle}; /** * Set basic textStyle properties. */ @@ -802,7 +878,7 @@ export function createTextConfig( } if (!textStyle.stroke) { textConfig.insideStroke = 'auto'; - // textConfig.outsideStroke = 'auto'; + textConfig.outsideStroke = 'auto'; } else if (opt.autoColor) { // TODO: stroke set to autoColor. if label is inside? @@ -1080,6 +1156,7 @@ function animateOrSetProps<Props>( delay: animationDelay || 0, easing: animationEasing, done: cb, + setToFinal: true, force: !!cb }) : (el.stopAnimation(), el.attr(props), cb && cb()); @@ -1440,5 +1517,7 @@ export { LinearGradient, RadialGradient, BoundingRect, + OrientedBoundingRect, + Point, Path }; \ No newline at end of file diff --git a/src/util/types.ts b/src/util/types.ts index 0503ec8..47e0277 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -798,6 +798,48 @@ export interface LabelGuideLineOption { lineStyle?: LineStyleOption } + +export interface LabelLayoutOptionCallbackParams { + dataIndex: number, + seriesIndex: number, + text: string + align: ZRTextAlign + verticalAlign: ZRTextVerticalAlign + rect: RectLike + labelRect: RectLike + x: number + y: number +}; + +export interface LabelLayoutOption { + overlap?: 'visible' | 'hidden' | 'blur' + /** + * Minimal margin between two labels which will be considered as overlapped. + */ + overlapMargin?: number + /** + * Can be absolute px number or percent string. + */ + x?: number | string + y?: number | string + /** + * offset on x based on the original position. + */ + dx?: number + /** + * offset on y based on the original position. + */ + dy?: number + rotation?: number + align?: ZRTextAlign + verticalAlign?: ZRTextVerticalAlign + width?: number + height?: number +} + +export type LabelLayoutOptionCallback = (params: LabelLayoutOptionCallbackParams) => LabelLayoutOption; + + interface TooltipFormatterCallback<T> { /** * For sync callback @@ -871,7 +913,7 @@ export interface CommonTooltipOption<FormatterParams> { * * Support to be a callback */ - position?: number[] | string[] | TooltipBuiltinPosition | PositionCallback | TooltipBoxLayoutOption + position?: (number | string)[] | TooltipBuiltinPosition | PositionCallback | TooltipBoxLayoutOption confine?: boolean @@ -1075,6 +1117,11 @@ export interface SeriesOption extends * @default 'column' */ seriesLayoutBy?: 'column' | 'row' + + /** + * Global label layout option in label layout stage. + */ + labelLayout?: LabelLayoutOption | LabelLayoutOptionCallback } export interface SeriesOnCartesianOptionMixin { diff --git a/test/animation-additive.html b/test/animation-additive.html new file mode 100644 index 0000000..ad62f73 --- /dev/null +++ b/test/animation-additive.html @@ -0,0 +1,162 @@ + +<!-- +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. +--> + +<html> + <head> + <meta charset="utf-8"> + <script src="lib/esl.js"></script> + <script src="lib/config.js"></script> + <script src="lib/jquery.min.js"></script> + <script src="lib/facePrint.js"></script> + <script src="lib/testHelper.js"></script> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + </head> + <body> + <style> + h1 { + line-height: 60px; + height: 60px; + background: #146402; + text-align: center; + font-weight: bold; + color: #eee; + font-size: 14px; + } + .chart { + height: 800px; + width: 50%; + float: left; + } + </style> + + <div id="additive" class="chart"></div> + <div id="non-additive" class="chart"></div> + <script> + + require(['echarts'], function (echarts) { + + function createChart(domId, additive) { + + var chart = echarts.init(document.getElementById(domId)); + + chart.setOption({ + title: { + text: additive ? 'Additive' : 'Normal', + left: 'center' + }, + grid: { + left: '3%', + right: '7%', + bottom: '3%', + containLabel: true + }, + legend: { + data: ['女性', '男性'], + left: 'right' + }, + xAxis: [ + { + type: 'value', + scale: true, + splitNumber: 5, + axisLabel: { + formatter: '{value} cm' + }, + splitLine: { + show: false + }, + + animationEasingUpdate: 'cubicInOut', + animationDurationUpdate: 1000, + animationAdditive: additive, + } + ], + yAxis: [ + { + type: 'value', + scale: true, + splitNumber: 5, + axisLabel: { + formatter: '{value} kg' + }, + splitLine: { + show: false + }, + + animationEasingUpdate: 'cubicInOut', + animationDurationUpdate: 1000, + animationAdditive: additive, + } + ], + series: [ + { + animationEasingUpdate: 'cubicInOut', + animationDurationUpdate: 1000, + animationAdditive: additive, + name: '女性', + type: 'scatter', + data: [[161.2, 51.6], [167.5, 59.0], [159.5, 49.2], [157.0, 63.0], [155.8, 53.6], [170.0, 59.0], [159.1, 47.6], [166.0, 69.8], [176.2, 66.8], [160.2, 75.2], [172.5, 55.2], [170.9, 54.2], [172.9, 62.5], [153.4, 42.0], [160.0, 50.0], [147.2, 49.8], [168.2, 49.2], [175.0, 73.2], [157.0, 47.8], [167.6, 68.8], [159.5, 50.6], [175.0, 82.5], [166.8, 57.2], [176.5, 87.8], [170.2, 72.8], [174.0, 54.5], [173.0, 59.8], [179.9, 67.3], [170.5, 67.8], [160.0, 47.0], [15 [...] + markLine: { + animationEasingUpdate: 'cubicInOut', + animationDurationUpdate: 1000, + animationAdditive: additive, + lineStyle: { + type: 'solid' + }, + data: [ + {type: 'average', name: '平均值'}, + { xAxis: 160 } + ] + } + }, + { + name: '男性', + type: 'scatter', + animationEasingUpdate: 'cubicInOut', + animationDurationUpdate: 1000, + animationAdditive: additive, + data: [[174.0, 65.6], [175.3, 71.8], [193.5, 80.7], [186.5, 72.6], [187.2, 78.8], [181.5, 74.8], [184.0, 86.4], [184.5, 78.4], [175.0, 62.0], [184.0, 81.6], [180.0, 76.6], [177.8, 83.6], [192.0, 90.0], [176.0, 74.6], [174.0, 71.0], [184.0, 79.6], [192.7, 93.8], [171.5, 70.0], [173.0, 72.4], [176.0, 85.9], [176.0, 78.8], [180.5, 77.8], [172.7, 66.2], [176.0, 86.4], [173.5, 81.8], [178.0, 89.6], [180.3, 82.8], [180.3, 76.4], [164.5, 63.2], [173.0, 60.9], [18 [...] + markLine: { + animationEasingUpdate: 'cubicInOut', + animationDurationUpdate: 1000, + animationAdditive: additive, + lineStyle: { + type: 'solid' + }, + data: [ + {type: 'average', name: '平均值'}, + { xAxis: 170 } + ] + } + } + ] + }); + + return chart; + } + + echarts.connect([ + createChart('additive', true), + createChart('non-additive', false) + ]); + }); + </script> + </body> +</html> \ No newline at end of file diff --git a/test/bar-stack.html b/test/bar-stack.html index 1176c30..2d339bc 100644 --- a/test/bar-stack.html +++ b/test/bar-stack.html @@ -46,33 +46,6 @@ under the License. ], function (echarts) { var option = { - "tooltip": { - "trigger": "axis", - "axisPointer": { - "type": "shadow" - } - }, - "toolbox": { - "show": true, - "feature": { - "dataZoom": { - "yAxisIndex": "none" - }, - "dataView": { - "readOnly": false - }, - "magicType": { - "type": [ - "line", - "bar", - "stack", - "tiled" - ] - }, - "restore": {}, - "saveAsImage": {} - } - }, "xAxis": { type: 'category' }, @@ -99,14 +72,14 @@ under the License. ], barMinHeight: 10, label: { - normal: {show: true} + show: true }, "name": "zly13" }, { "type": "bar", "stack": "all", label: { - normal: {show: true} + show: true }, "data": [ ["哪有那么多审批", 66], diff --git a/test/graph-label-rotate.html b/test/graph-label-rotate.html index dec5d01..5b89368 100644 --- a/test/graph-label-rotate.html +++ b/test/graph-label-rotate.html @@ -63,13 +63,13 @@ under the License. roam: true, label: { show: true, - rotate: 30, - fontWeight:5, - fontSize: 26, - color: "#000", - distance: 15, - position: 'inside', - verticalAlign: 'middle' + rotate: 30, + fontWeight:5, + fontSize: 26, + color: "#000", + distance: 15, + position: 'inside', + verticalAlign: 'middle' }, edgeSymbol: ['circle', 'arrow'], edgeSymbolSize: [4, 10], diff --git a/test/label-overlap.html b/test/label-overlap.html new file mode 100644 index 0000000..75de7c8 --- /dev/null +++ b/test/label-overlap.html @@ -0,0 +1,207 @@ +<!DOCTYPE html> +<!-- +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. +--> + + +<html> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <script src="lib/esl.js"></script> + <script src="lib/config.js"></script> + <script src="lib/jquery.min.js"></script> + <script src="lib/facePrint.js"></script> + <script src="lib/testHelper.js"></script> + <!-- <script src="ut/lib/canteen.js"></script> --> + <link rel="stylesheet" href="lib/reset.css" /> + </head> + <body> + <style> + </style> + + + + <div id="main0"></div> + <div id="main1"></div> + + + <!-- TODO: Tree, Sankey, Map --> + <div id="main2"></div> + + + + <script> + require(['echarts'/*, 'map/js/china' */], function (echarts) { + var option; + // $.getJSON('./data/nutrients.json', function (data) {}); + option = { + legend: { + data: ['直接访问', '邮件营销','联盟广告','视频广告','搜索引擎'] + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + containLabel: true + }, + xAxis: { + type: 'value' + }, + yAxis: { + type: 'category', + data: ['周一','周二','周三','周四','周五','周六','周日'] + }, + series: [ + { + name: '直接访问', + type: 'bar', + stack: '总量', + label: { + show: true + }, + data: [13244, 302, 301, 334, 390, 330, 320] + }, + { + name: '邮件营销', + type: 'bar', + stack: '总量', + label: { + show: true + }, + data: [120, 132, 101, 134, 90, 230, 210] + }, + { + name: '联盟广告', + type: 'bar', + stack: '总量', + label: { + show: true + }, + data: [220, 182, 191, 234, 290, 330, 310] + }, + { + name: '视频广告', + type: 'bar', + stack: '总量', + label: { + show: true + }, + data: [150, 212, 201, 154, 190, 330, 410] + }, + { + name: '搜索引擎', + type: 'bar', + stack: '总量', + label: { + show: true + }, + data: [820, 832, 901, 934, 1290, 1330, 1320] + } + ] + } + var chart = testHelper.create(echarts, 'main0', { + title: [ + 'Overlap of stacked bar.', + 'Case from #6514' + ], + option: option + }); + }); + </script> + + + + <script> + require(['echarts', 'extension/dataTool'], function (echarts, dataTool) { + $.get('./data/les-miserables.gexf', function (xml) { + var graph = dataTool.gexf.parse(xml); + var categories = []; + for (var i = 0; i < 9; i++) { + categories[i] = { + name: '类目' + i + }; + } + graph.nodes.forEach(function (node) { + delete node.itemStyle; + node.value = node.symbolSize; + node.category = node.attributes['modularity_class']; + }); + graph.links.forEach(function (link) { + delete link.lineStyle; + }); + var option = { + legend: [{}], + animationDurationUpdate: 1500, + animationEasingUpdate: 'quinticInOut', + + series : [ + { + name: 'Les Miserables', + type: 'graph', + layout: 'none', + data: graph.nodes, + links: graph.links, + categories: categories, + roam: true, + draggable: true, + + label: { + show: true, + formatter: '{b}', + position: 'right' + }, + + // labelLayout: function (params) { + // return { + // show: params.rect.width > 10, + // overlap: 'hidden' + // } + // }, + emphasis: { + label: { + show: true + } + }, + lineStyle: { + color: 'source', + curveness: 0.3 + }, + emphasis: { + lineStyle: { + width: 10 + } + } + } + ] + }; + + var chart = testHelper.create(echarts, 'main1', { + title: [ + 'Hide overlap in graph zooming.' + ], + height: 800, + option: option + }); + }); + }); + </script> + + </body> +</html> + --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
