| /* | |
| * 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. | |
| */ | |
| // TODO: move labels out of viewport. | |
| import { | |
| Text as ZRText, | |
| BoundingRect, | |
| Polyline, | |
| updateProps, | |
| initProps, | |
| isElementRemoved | |
| } from '../util/graphic'; | |
| import { getECData } from '../util/innerStore'; | |
| import ExtensionAPI from '../core/ExtensionAPI'; | |
| import { | |
| ZRTextAlign, | |
| ZRTextVerticalAlign, | |
| LabelLayoutOption, | |
| LabelLayoutOptionCallback, | |
| LabelLayoutOptionCallbackParams, | |
| LabelLineOption, | |
| Dictionary, | |
| ECElement, | |
| SeriesDataType | |
| } from '../util/types'; | |
| import { parsePercent } from '../util/number'; | |
| import ChartView from '../view/Chart'; | |
| import Element, { ElementTextConfig } from 'zrender/src/Element'; | |
| import { RectLike } from 'zrender/src/core/BoundingRect'; | |
| import Transformable from 'zrender/src/core/Transformable'; | |
| import { updateLabelLinePoints, setLabelLineStyle, getLabelLineStatesModels } from './labelGuideHelper'; | |
| import SeriesModel from '../model/Series'; | |
| import { makeInner } from '../util/model'; | |
| import { retrieve2, each, keys, isFunction, filter, indexOf } from 'zrender/src/core/util'; | |
| import { PathStyleProps } from 'zrender/src/graphic/Path'; | |
| import Model from '../model/Model'; | |
| import { prepareLayoutList, hideOverlap, shiftLayoutOnX, shiftLayoutOnY } from './labelLayoutHelper'; | |
| import { labelInner, animateLabelValue } from './labelStyle'; | |
| interface LabelDesc { | |
| label: ZRText | |
| labelLine: Polyline | |
| seriesModel: SeriesModel | |
| // Can be null if label doesn't represent any data. | |
| dataIndex?: number | |
| // Can be null if label doesn't represent any data. | |
| dataType?: SeriesDataType | |
| layoutOption: LabelLayoutOptionCallback | LabelLayoutOption | |
| computedLayoutOption: LabelLayoutOption | |
| hostRect: RectLike | |
| rect: RectLike | |
| priority: number | |
| defaultAttr: SavedLabelAttr | |
| } | |
| interface SavedLabelAttr { | |
| ignore: boolean | |
| labelGuideIgnore: boolean | |
| x: number | |
| y: number | |
| scaleX: number | |
| scaleY: number | |
| rotation: number | |
| style: { | |
| align: ZRTextAlign | |
| verticalAlign: ZRTextVerticalAlign | |
| width: number | |
| height: number | |
| fontSize: number | string | |
| x: number | |
| y: number | |
| } | |
| cursor: string | |
| // Configuration in attached element | |
| attachedPos: ElementTextConfig['position'] | |
| attachedRot: ElementTextConfig['rotation'] | |
| } | |
| function cloneArr(points: number[][]) { | |
| if (points) { | |
| const newPoints = []; | |
| for (let i = 0; i < points.length; i++) { | |
| newPoints.push(points[i].slice()); | |
| } | |
| return newPoints; | |
| } | |
| } | |
| function prepareLayoutCallbackParams(labelItem: LabelDesc, hostEl?: Element): LabelLayoutOptionCallbackParams { | |
| const label = labelItem.label; | |
| const labelLine = hostEl && hostEl.getTextGuideLine(); | |
| return { | |
| dataIndex: labelItem.dataIndex, | |
| dataType: labelItem.dataType, | |
| seriesIndex: labelItem.seriesModel.seriesIndex, | |
| text: labelItem.label.style.text, | |
| rect: labelItem.hostRect, | |
| labelRect: labelItem.rect, | |
| // x: labelAttr.x, | |
| // y: labelAttr.y, | |
| align: label.style.align, | |
| verticalAlign: label.style.verticalAlign, | |
| labelLinePoints: cloneArr(labelLine && labelLine.shape.points) | |
| }; | |
| } | |
| const LABEL_OPTION_TO_STYLE_KEYS = ['align', 'verticalAlign', 'width', 'height', 'fontSize'] as const; | |
| const dummyTransformable = new Transformable(); | |
| const labelLayoutInnerStore = makeInner<{ | |
| oldLayout: { | |
| x: number, | |
| y: number, | |
| rotation: number | |
| }, | |
| oldLayoutSelect?: { | |
| x?: number, | |
| y?: number, | |
| rotation?: number | |
| }, | |
| oldLayoutEmphasis?: { | |
| x?: number, | |
| y?: number, | |
| rotation?: number | |
| }, | |
| needsUpdateLabelLine?: boolean | |
| }, ZRText>(); | |
| const labelLineAnimationStore = makeInner<{ | |
| oldLayout: { | |
| points: number[][] | |
| } | |
| }, Polyline>(); | |
| type LabelLineOptionMixin = { | |
| labelLine: LabelLineOption, | |
| emphasis: { labelLine: LabelLineOption } | |
| }; | |
| function extendWithKeys(target: Dictionary<any>, source: Dictionary<any>, keys: string[]) { | |
| for (let i = 0; i < keys.length; i++) { | |
| const key = keys[i]; | |
| if (source[key] != null) { | |
| target[key] = source[key]; | |
| } | |
| } | |
| } | |
| const LABEL_LAYOUT_PROPS = ['x', 'y', 'rotation']; | |
| class LabelManager { | |
| private _labelList: LabelDesc[] = []; | |
| private _chartViewList: ChartView[] = []; | |
| constructor() {} | |
| clearLabels() { | |
| this._labelList = []; | |
| this._chartViewList = []; | |
| } | |
| /** | |
| * Add label to manager | |
| */ | |
| private _addLabel( | |
| dataIndex: number | null | undefined, | |
| dataType: SeriesDataType | null | undefined, | |
| seriesModel: SeriesModel, | |
| label: ZRText, | |
| layoutOption: LabelDesc['layoutOption'] | |
| ) { | |
| const labelStyle = label.style; | |
| const hostEl = label.__hostTarget; | |
| const textConfig = hostEl.textConfig || {}; | |
| // TODO: If label is in other state. | |
| const labelTransform = label.getComputedTransform(); | |
| const labelRect = label.getBoundingRect().plain(); | |
| BoundingRect.applyTransform(labelRect, labelRect, labelTransform); | |
| if (labelTransform) { | |
| dummyTransformable.setLocalTransform(labelTransform); | |
| } | |
| else { | |
| // Identity transform. | |
| dummyTransformable.x = dummyTransformable.y = dummyTransformable.rotation = | |
| dummyTransformable.originX = dummyTransformable.originY = 0; | |
| dummyTransformable.scaleX = dummyTransformable.scaleY = 1; | |
| } | |
| const host = label.__hostTarget; | |
| let hostRect; | |
| if (host) { | |
| hostRect = host.getBoundingRect().plain(); | |
| const transform = host.getComputedTransform(); | |
| BoundingRect.applyTransform(hostRect, hostRect, transform); | |
| } | |
| const labelGuide = hostRect && host.getTextGuideLine(); | |
| this._labelList.push({ | |
| label, | |
| labelLine: labelGuide, | |
| seriesModel, | |
| dataIndex, | |
| dataType, | |
| layoutOption, | |
| computedLayoutOption: null, | |
| rect: labelRect, | |
| hostRect, | |
| // Label with lower priority will be hidden when overlapped | |
| // Use rect size as default priority | |
| priority: hostRect ? hostRect.width * hostRect.height : 0, | |
| // Save default label attributes. | |
| // For restore if developers want get back to default value in callback. | |
| defaultAttr: { | |
| ignore: label.ignore, | |
| labelGuideIgnore: labelGuide && labelGuide.ignore, | |
| x: dummyTransformable.x, | |
| y: dummyTransformable.y, | |
| scaleX: dummyTransformable.scaleX, | |
| scaleY: dummyTransformable.scaleY, | |
| rotation: dummyTransformable.rotation, | |
| style: { | |
| x: labelStyle.x, | |
| y: labelStyle.y, | |
| align: labelStyle.align, | |
| verticalAlign: labelStyle.verticalAlign, | |
| width: labelStyle.width, | |
| height: labelStyle.height, | |
| fontSize: labelStyle.fontSize | |
| }, | |
| cursor: label.cursor, | |
| attachedPos: textConfig.position, | |
| attachedRot: textConfig.rotation | |
| } | |
| }); | |
| } | |
| addLabelsOfSeries(chartView: ChartView) { | |
| this._chartViewList.push(chartView); | |
| const seriesModel = chartView.__model; | |
| const layoutOption = seriesModel.get('labelLayout'); | |
| /** | |
| * Ignore layouting if it's not specified anything. | |
| */ | |
| if (!(isFunction(layoutOption) || keys(layoutOption).length)) { | |
| return; | |
| } | |
| 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 ecData = getECData(child); | |
| // Can only attach the text on the element with dataIndex | |
| if (textEl && !(textEl as ECElement).disableLabelLayout) { | |
| this._addLabel(ecData.dataIndex, ecData.dataType, seriesModel, textEl, layoutOption); | |
| } | |
| }); | |
| } | |
| updateLayoutConfig(api: ExtensionAPI) { | |
| const width = api.getWidth(); | |
| const height = api.getHeight(); | |
| function createDragHandler(el: Element, labelLineModel: Model) { | |
| return function () { | |
| updateLabelLinePoints(el, labelLineModel); | |
| }; | |
| } | |
| for (let i = 0; i < this._labelList.length; i++) { | |
| const labelItem = this._labelList[i]; | |
| const label = labelItem.label; | |
| const hostEl = label.__hostTarget; | |
| const defaultLabelAttr = labelItem.defaultAttr; | |
| let layoutOption; | |
| // TODO A global layout option? | |
| if (isFunction(labelItem.layoutOption)) { | |
| layoutOption = labelItem.layoutOption( | |
| prepareLayoutCallbackParams(labelItem, hostEl) | |
| ); | |
| } | |
| else { | |
| layoutOption = labelItem.layoutOption; | |
| } | |
| layoutOption = layoutOption || {}; | |
| labelItem.computedLayoutOption = layoutOption; | |
| const degreeToRadian = Math.PI / 180; | |
| // TODO hostEl should always exists. | |
| // Or label should not have parent because the x, y is all in global space. | |
| if (hostEl) { | |
| hostEl.setTextConfig({ | |
| // Force to set local false. | |
| local: false, | |
| // Ignore position and rotation config on the host el if x or y is changed. | |
| position: (layoutOption.x != null || layoutOption.y != null) | |
| ? null : defaultLabelAttr.attachedPos, | |
| // Ignore rotation config on the host el if rotation is changed. | |
| rotation: layoutOption.rotate != null | |
| ? layoutOption.rotate * degreeToRadian : defaultLabelAttr.attachedRot, | |
| offset: [layoutOption.dx || 0, layoutOption.dy || 0] | |
| }); | |
| } | |
| let needsUpdateLabelLine = false; | |
| if (layoutOption.x != null) { | |
| // TODO width of chart view. | |
| label.x = parsePercent(layoutOption.x, width); | |
| label.setStyle('x', 0); // Ignore movement in style. TODO: origin. | |
| needsUpdateLabelLine = true; | |
| } | |
| else { | |
| label.x = defaultLabelAttr.x; | |
| label.setStyle('x', defaultLabelAttr.style.x); | |
| } | |
| if (layoutOption.y != null) { | |
| // TODO height of chart view. | |
| label.y = parsePercent(layoutOption.y, height); | |
| label.setStyle('y', 0); // Ignore movement in style. | |
| needsUpdateLabelLine = true; | |
| } | |
| else { | |
| label.y = defaultLabelAttr.y; | |
| label.setStyle('y', defaultLabelAttr.style.y); | |
| } | |
| if (layoutOption.labelLinePoints) { | |
| const guideLine = hostEl.getTextGuideLine(); | |
| if (guideLine) { | |
| guideLine.setShape({ points: layoutOption.labelLinePoints }); | |
| // Not update | |
| needsUpdateLabelLine = false; | |
| } | |
| } | |
| const labelLayoutStore = labelLayoutInnerStore(label); | |
| labelLayoutStore.needsUpdateLabelLine = needsUpdateLabelLine; | |
| label.rotation = layoutOption.rotate != null | |
| ? layoutOption.rotate * degreeToRadian : defaultLabelAttr.rotation; | |
| label.scaleX = defaultLabelAttr.scaleX; | |
| label.scaleY = defaultLabelAttr.scaleY; | |
| 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.style[key]); | |
| } | |
| if (layoutOption.draggable) { | |
| label.draggable = true; | |
| label.cursor = 'move'; | |
| if (hostEl) { | |
| let hostModel: Model<LabelLineOptionMixin> = | |
| labelItem.seriesModel as SeriesModel<LabelLineOptionMixin>; | |
| if (labelItem.dataIndex != null) { | |
| const data = labelItem.seriesModel.getData(labelItem.dataType); | |
| hostModel = data.getItemModel<LabelLineOptionMixin>(labelItem.dataIndex); | |
| } | |
| label.on('drag', createDragHandler(hostEl, hostModel.getModel('labelLine'))); | |
| } | |
| } | |
| else { | |
| // TODO Other drag functions? | |
| label.off('drag'); | |
| label.cursor = defaultLabelAttr.cursor; | |
| } | |
| } | |
| } | |
| layout(api: ExtensionAPI) { | |
| const width = api.getWidth(); | |
| const height = api.getHeight(); | |
| const labelList = prepareLayoutList(this._labelList); | |
| const labelsNeedsAdjustOnX = filter(labelList, function (item) { | |
| return item.layoutOption.moveOverlap === 'shiftX'; | |
| }); | |
| const labelsNeedsAdjustOnY = filter(labelList, function (item) { | |
| return item.layoutOption.moveOverlap === 'shiftY'; | |
| }); | |
| shiftLayoutOnX(labelsNeedsAdjustOnX, 0, width); | |
| shiftLayoutOnY(labelsNeedsAdjustOnY, 0, height); | |
| const labelsNeedsHideOverlap = filter(labelList, function (item) { | |
| return item.layoutOption.hideOverlap; | |
| }); | |
| hideOverlap(labelsNeedsHideOverlap); | |
| } | |
| /** | |
| * Process all labels. Not only labels with layoutOption. | |
| */ | |
| processLabelsOverall() { | |
| each(this._chartViewList, (chartView) => { | |
| const seriesModel = chartView.__model; | |
| const ignoreLabelLineUpdate = chartView.ignoreLabelLineUpdate; | |
| const animationEnabled = seriesModel.isAnimationEnabled(); | |
| chartView.group.traverse((child) => { | |
| if (child.ignore && !(child as ECElement).forceLabelAnimation) { | |
| return true; // Stop traverse descendants. | |
| } | |
| let needsUpdateLabelLine = !ignoreLabelLineUpdate; | |
| const label = child.getTextContent(); | |
| if (!needsUpdateLabelLine && label) { | |
| needsUpdateLabelLine = labelLayoutInnerStore(label).needsUpdateLabelLine; | |
| } | |
| if (needsUpdateLabelLine) { | |
| this._updateLabelLine(child, seriesModel); | |
| } | |
| if (animationEnabled) { | |
| this._animateLabels(child, seriesModel); | |
| } | |
| }); | |
| }); | |
| } | |
| private _updateLabelLine(el: Element, seriesModel: SeriesModel) { | |
| // Only support label being hosted on graphic elements. | |
| const textEl = el.getTextContent(); | |
| // Update label line style. | |
| const ecData = getECData(el); | |
| const dataIndex = ecData.dataIndex; | |
| // Only support labelLine on the labels represent data. | |
| if (textEl && dataIndex != null) { | |
| const data = seriesModel.getData(ecData.dataType); | |
| const itemModel = data.getItemModel<LabelLineOptionMixin>(dataIndex); | |
| const defaultStyle: PathStyleProps = {}; | |
| const visualStyle = data.getItemVisual(dataIndex, 'style'); | |
| const visualType = data.getVisual('drawType'); | |
| // Default to be same with main color | |
| defaultStyle.stroke = visualStyle[visualType]; | |
| const labelLineModel = itemModel.getModel('labelLine'); | |
| setLabelLineStyle(el, getLabelLineStatesModels(itemModel), defaultStyle); | |
| updateLabelLinePoints(el, labelLineModel); | |
| } | |
| } | |
| private _animateLabels(el: Element, seriesModel: SeriesModel) { | |
| const textEl = el.getTextContent(); | |
| const guideLine = el.getTextGuideLine(); | |
| // Animate | |
| if (textEl | |
| // `forceLabelAnimation` has the highest priority | |
| && ((el as ECElement).forceLabelAnimation | |
| || !textEl.ignore | |
| && !textEl.invisible | |
| && !(el as ECElement).disableLabelAnimation | |
| && !isElementRemoved(el) | |
| ) | |
| ) { | |
| const layoutStore = labelLayoutInnerStore(textEl); | |
| const oldLayout = layoutStore.oldLayout; | |
| const ecData = getECData(el); | |
| const dataIndex = ecData.dataIndex; | |
| const newProps = { | |
| x: textEl.x, | |
| y: textEl.y, | |
| rotation: textEl.rotation | |
| }; | |
| const data = seriesModel.getData(ecData.dataType); | |
| if (!oldLayout) { | |
| textEl.attr(newProps); | |
| // Disable fade in animation if value animation is enabled. | |
| if (!labelInner(textEl).valueAnimation) { | |
| const oldOpacity = retrieve2(textEl.style.opacity, 1); | |
| // Fade in animation | |
| textEl.style.opacity = 0; | |
| initProps(textEl, { | |
| style: { opacity: oldOpacity } | |
| }, seriesModel, dataIndex); | |
| } | |
| } | |
| else { | |
| textEl.attr(oldLayout); | |
| // Make sure the animation from is in the right status. | |
| const prevStates = el.prevStates; | |
| if (prevStates) { | |
| if (indexOf(prevStates, 'select') >= 0) { | |
| textEl.attr(layoutStore.oldLayoutSelect); | |
| } | |
| if (indexOf(prevStates, 'emphasis') >= 0) { | |
| textEl.attr(layoutStore.oldLayoutEmphasis); | |
| } | |
| } | |
| updateProps(textEl, newProps, seriesModel, dataIndex); | |
| } | |
| layoutStore.oldLayout = newProps; | |
| if (textEl.states.select) { | |
| const layoutSelect = layoutStore.oldLayoutSelect = {}; | |
| extendWithKeys(layoutSelect, newProps, LABEL_LAYOUT_PROPS); | |
| extendWithKeys(layoutSelect, textEl.states.select, LABEL_LAYOUT_PROPS); | |
| } | |
| if (textEl.states.emphasis) { | |
| const layoutEmphasis = layoutStore.oldLayoutEmphasis = {}; | |
| extendWithKeys(layoutEmphasis, newProps, LABEL_LAYOUT_PROPS); | |
| extendWithKeys(layoutEmphasis, textEl.states.emphasis, LABEL_LAYOUT_PROPS); | |
| } | |
| animateLabelValue(textEl, dataIndex, data, seriesModel, seriesModel); | |
| } | |
| if (guideLine && !guideLine.ignore && !guideLine.invisible) { | |
| const layoutStore = labelLineAnimationStore(guideLine); | |
| const oldLayout = layoutStore.oldLayout; | |
| const newLayout = { points: guideLine.shape.points }; | |
| if (!oldLayout) { | |
| guideLine.setShape(newLayout); | |
| guideLine.style.strokePercent = 0; | |
| initProps(guideLine, { | |
| style: { strokePercent: 1 } | |
| }, seriesModel); | |
| } | |
| else { | |
| guideLine.attr({ shape: oldLayout }); | |
| updateProps(guideLine, { | |
| shape: newLayout | |
| }, seriesModel); | |
| } | |
| layoutStore.oldLayout = newLayout; | |
| } | |
| } | |
| } | |
| export default LabelManager; |