/* * 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, getECData, Polyline, updateProps, initProps } from '../util/graphic'; import ExtensionAPI from '../ExtensionAPI'; import { ZRTextAlign, ZRTextVerticalAlign, LabelLayoutOption, LabelLayoutOptionCallback, LabelLayoutOptionCallbackParams, LabelLineOption, Dictionary, ECElement } 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'; interface LabelDesc { label: ZRText labelLine: Polyline seriesModel: SeriesModel dataIndex: number dataType: string layoutOption: LabelLayoutOptionCallback | LabelLayoutOption computedLayoutOption: LabelLayoutOption hostRect: RectLike priority: number defaultAttr: SavedLabelAttr } interface SavedLabelAttr { ignore: boolean labelGuideIgnore: boolean x: number y: 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'] rect: RectLike } 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 labelAttr = labelItem.defaultAttr; 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: labelAttr.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, source: Dictionary, 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, dataType: string, 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, 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, rotation: dummyTransformable.rotation, rect: labelRect, 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); const dataIndex = ecData.dataIndex; // Can only attach the text on the element with dataIndex if (textEl && dataIndex != null && !(textEl as ECElement).disableLabelLayout) { this._addLabel(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; 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; 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) { const data = labelItem.seriesModel.getData(labelItem.dataType); const itemModel = data.getItemModel(labelItem.dataIndex); label.on('drag', createDragHandler(hostEl, itemModel.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 === 'shift-x'; }); const labelsNeedsAdjustOnY = filter(labelList, function (item) { return item.layoutOption.moveOverlap === 'shift-y'; }); 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; if (textEl && dataIndex != null) { const data = seriesModel.getData(ecData.dataType); const itemModel = data.getItemModel(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) { const layoutStore = labelLayoutInnerStore(textEl); const oldLayout = layoutStore.oldLayout; const newProps = { x: textEl.x, y: textEl.y, rotation: textEl.rotation }; if (!oldLayout) { textEl.attr(newProps); const oldOpacity = retrieve2(textEl.style.opacity, 1); // Fade in animation textEl.style.opacity = 0; initProps(textEl, { style: { opacity: oldOpacity } }, seriesModel); } 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); } 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); } } 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;