| /* |
| * 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 (typeof labelItem.layoutOption === 'function') { |
| 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) { |
| 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 |
| && !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; |