| /* | |
| * 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 { isArray, each } from 'zrender/src/core/util'; | |
| import * as vector from 'zrender/src/core/vector'; | |
| import * as symbolUtil from '../../util/symbol'; | |
| import ECLinePath from './LinePath'; | |
| import * as graphic from '../../util/graphic'; | |
| import { toggleHoverEmphasis, enterEmphasis, leaveEmphasis, SPECIAL_STATES } from '../../util/states'; | |
| import {getLabelStatesModels, setLabelStyle} from '../../label/labelStyle'; | |
| import {round} from '../../util/number'; | |
| import SeriesData from '../../data/SeriesData'; | |
| import { | |
| ZRTextAlign, | |
| ZRTextVerticalAlign, | |
| LineLabelOption, | |
| ColorString, | |
| DefaultEmphasisFocus, | |
| BlurScope | |
| } from '../../util/types'; | |
| import SeriesModel from '../../model/Series'; | |
| import type { LineDrawSeriesScope, LineDrawModelOption } from './LineDraw'; | |
| import { TextStyleProps } from 'zrender/src/graphic/Text'; | |
| import { LineDataVisual } from '../../visual/commonVisualTypes'; | |
| import Model from '../../model/Model'; | |
| const SYMBOL_CATEGORIES = ['fromSymbol', 'toSymbol'] as const; | |
| type ECSymbol = ReturnType<typeof createSymbol>; | |
| type LineECSymbol = ECSymbol & { | |
| __specifiedRotation: number | |
| }; | |
| type LineList = SeriesData<SeriesModel, LineDataVisual>; | |
| export interface LineLabel extends graphic.Text { | |
| lineLabelOriginalOpacity: number | |
| } | |
| interface InnerLineLabel extends LineLabel { | |
| __align: TextStyleProps['align'] | |
| __verticalAlign: TextStyleProps['verticalAlign'] | |
| __position: LineLabelOption['position'] | |
| __labelDistance: number[] | |
| } | |
| function makeSymbolTypeKey(symbolCategory: 'fromSymbol' | 'toSymbol') { | |
| return '_' + symbolCategory + 'Type' as '_fromSymbolType' | '_toSymbolType'; | |
| } | |
| /** | |
| * @inner | |
| */ | |
| function createSymbol(name: 'fromSymbol' | 'toSymbol', lineData: LineList, idx: number) { | |
| const symbolType = lineData.getItemVisual(idx, name); | |
| if (!symbolType || symbolType === 'none') { | |
| return; | |
| } | |
| const symbolSize = lineData.getItemVisual(idx, name + 'Size' as 'fromSymbolSize' | 'toSymbolSize'); | |
| const symbolRotate = lineData.getItemVisual(idx, name + 'Rotate' as 'fromSymbolRotate' | 'toSymbolRotate'); | |
| const symbolOffset = lineData.getItemVisual(idx, name + 'Offset' as 'fromSymbolOffset' | 'toSymbolOffset'); | |
| const symbolKeepAspect = lineData.getItemVisual(idx, | |
| name + 'KeepAspect' as 'fromSymbolKeepAspect' | 'toSymbolKeepAspect'); | |
| const symbolSizeArr = symbolUtil.normalizeSymbolSize(symbolSize); | |
| const symbolOffsetArr = symbolUtil.normalizeSymbolOffset(symbolOffset || 0, symbolSizeArr); | |
| const symbolPath = symbolUtil.createSymbol( | |
| symbolType, | |
| -symbolSizeArr[0] / 2 + (symbolOffsetArr as number[])[0], | |
| -symbolSizeArr[1] / 2 + (symbolOffsetArr as number[])[1], | |
| symbolSizeArr[0], | |
| symbolSizeArr[1], | |
| null, | |
| symbolKeepAspect | |
| ); | |
| (symbolPath as LineECSymbol).__specifiedRotation = symbolRotate == null || isNaN(symbolRotate) | |
| ? void 0 | |
| : +symbolRotate * Math.PI / 180 || 0; | |
| symbolPath.name = name; | |
| return symbolPath; | |
| } | |
| function createLine(points: number[][]) { | |
| const line = new ECLinePath({ | |
| name: 'line', | |
| subPixelOptimize: true | |
| }); | |
| setLinePoints(line.shape, points); | |
| return line; | |
| } | |
| function setLinePoints(targetShape: ECLinePath['shape'], points: number[][]) { | |
| type CurveShape = ECLinePath['shape'] & { | |
| cpx1: number | |
| cpy1: number | |
| }; | |
| targetShape.x1 = points[0][0]; | |
| targetShape.y1 = points[0][1]; | |
| targetShape.x2 = points[1][0]; | |
| targetShape.y2 = points[1][1]; | |
| targetShape.percent = 1; | |
| const cp1 = points[2]; | |
| if (cp1) { | |
| (targetShape as CurveShape).cpx1 = cp1[0]; | |
| (targetShape as CurveShape).cpy1 = cp1[1]; | |
| } | |
| else { | |
| (targetShape as CurveShape).cpx1 = NaN; | |
| (targetShape as CurveShape).cpy1 = NaN; | |
| } | |
| } | |
| class Line extends graphic.Group { | |
| private _fromSymbolType: string; | |
| private _toSymbolType: string; | |
| constructor(lineData: SeriesData, idx: number, seriesScope?: LineDrawSeriesScope) { | |
| super(); | |
| this._createLine(lineData as LineList, idx, seriesScope); | |
| } | |
| _createLine(lineData: LineList, idx: number, seriesScope?: LineDrawSeriesScope) { | |
| const seriesModel = lineData.hostModel; | |
| const linePoints = lineData.getItemLayout(idx); | |
| const line = createLine(linePoints); | |
| line.shape.percent = 0; | |
| graphic.initProps(line, { | |
| shape: { | |
| percent: 1 | |
| } | |
| }, seriesModel, idx); | |
| this.add(line); | |
| each(SYMBOL_CATEGORIES, function (symbolCategory) { | |
| const symbol = createSymbol(symbolCategory, lineData, idx); | |
| // symbols must added after line to make sure | |
| // it will be updated after line#update. | |
| // Or symbol position and rotation update in line#beforeUpdate will be one frame slow | |
| this.add(symbol); | |
| this[makeSymbolTypeKey(symbolCategory)] = lineData.getItemVisual(idx, symbolCategory); | |
| }, this); | |
| this._updateCommonStl(lineData, idx, seriesScope); | |
| } | |
| // TODO More strict on the List type in parameters? | |
| updateData(lineData: SeriesData, idx: number, seriesScope: LineDrawSeriesScope) { | |
| const seriesModel = lineData.hostModel; | |
| const line = this.childOfName('line') as ECLinePath; | |
| const linePoints = lineData.getItemLayout(idx); | |
| const target = { | |
| shape: {} as ECLinePath['shape'] | |
| }; | |
| setLinePoints(target.shape, linePoints); | |
| graphic.updateProps(line, target, seriesModel, idx); | |
| each(SYMBOL_CATEGORIES, function (symbolCategory) { | |
| const symbolType = (lineData as LineList).getItemVisual(idx, symbolCategory); | |
| const key = makeSymbolTypeKey(symbolCategory); | |
| // Symbol changed | |
| if (this[key] !== symbolType) { | |
| this.remove(this.childOfName(symbolCategory)); | |
| const symbol = createSymbol(symbolCategory, lineData as LineList, idx); | |
| this.add(symbol); | |
| } | |
| this[key] = symbolType; | |
| }, this); | |
| this._updateCommonStl(lineData, idx, seriesScope); | |
| }; | |
| getLinePath() { | |
| return this.childAt(0) as graphic.Line; | |
| } | |
| _updateCommonStl(lineData: SeriesData, idx: number, seriesScope?: LineDrawSeriesScope) { | |
| const seriesModel = lineData.hostModel as SeriesModel; | |
| const line = this.childOfName('line') as ECLinePath; | |
| let emphasisLineStyle = seriesScope && seriesScope.emphasisLineStyle; | |
| let blurLineStyle = seriesScope && seriesScope.blurLineStyle; | |
| let selectLineStyle = seriesScope && seriesScope.selectLineStyle; | |
| let labelStatesModels = seriesScope && seriesScope.labelStatesModels; | |
| let emphasisDisabled = seriesScope && seriesScope.emphasisDisabled; | |
| let focus = (seriesScope && seriesScope.focus) as DefaultEmphasisFocus; | |
| let blurScope = (seriesScope && seriesScope.blurScope) as BlurScope; | |
| // Optimization for large dataset | |
| if (!seriesScope || lineData.hasItemOption) { | |
| const itemModel = lineData.getItemModel<LineDrawModelOption>(idx); | |
| const emphasisModel = itemModel.getModel('emphasis'); | |
| emphasisLineStyle = emphasisModel.getModel('lineStyle').getLineStyle(); | |
| blurLineStyle = itemModel.getModel(['blur', 'lineStyle']).getLineStyle(); | |
| selectLineStyle = itemModel.getModel(['select', 'lineStyle']).getLineStyle(); | |
| emphasisDisabled = emphasisModel.get('disabled'); | |
| focus = emphasisModel.get('focus'); | |
| blurScope = emphasisModel.get('blurScope'); | |
| labelStatesModels = getLabelStatesModels(itemModel); | |
| } | |
| const lineStyle = lineData.getItemVisual(idx, 'style'); | |
| const visualColor = lineStyle.stroke; | |
| line.useStyle(lineStyle); | |
| line.style.fill = null; | |
| line.style.strokeNoScale = true; | |
| line.ensureState('emphasis').style = emphasisLineStyle; | |
| line.ensureState('blur').style = blurLineStyle; | |
| line.ensureState('select').style = selectLineStyle; | |
| // Update symbol | |
| each(SYMBOL_CATEGORIES, function (symbolCategory) { | |
| const symbol = this.childOfName(symbolCategory) as ECSymbol; | |
| if (symbol) { | |
| // Share opacity and color with line. | |
| symbol.setColor(visualColor); | |
| symbol.style.opacity = lineStyle.opacity; | |
| for (let i = 0; i < SPECIAL_STATES.length; i++) { | |
| const stateName = SPECIAL_STATES[i]; | |
| const lineState = line.getState(stateName); | |
| if (lineState) { | |
| const lineStateStyle = lineState.style || {}; | |
| const state = symbol.ensureState(stateName); | |
| const stateStyle = state.style || (state.style = {}); | |
| if (lineStateStyle.stroke != null) { | |
| stateStyle[symbol.__isEmptyBrush ? 'stroke' : 'fill'] = lineStateStyle.stroke; | |
| } | |
| if (lineStateStyle.opacity != null) { | |
| stateStyle.opacity = lineStateStyle.opacity; | |
| } | |
| } | |
| } | |
| symbol.markRedraw(); | |
| } | |
| }, this); | |
| const rawVal = seriesModel.getRawValue(idx) as number; | |
| setLabelStyle(this, labelStatesModels, { | |
| labelDataIndex: idx, | |
| labelFetcher: { | |
| getFormattedLabel(dataIndex, stateName) { | |
| return seriesModel.getFormattedLabel(dataIndex, stateName, lineData.dataType); | |
| } | |
| }, | |
| inheritColor: visualColor as ColorString || '#000', | |
| defaultOpacity: lineStyle.opacity, | |
| defaultText: (rawVal == null | |
| ? lineData.getName(idx) | |
| : isFinite(rawVal) | |
| ? round(rawVal) | |
| : rawVal) + '' | |
| }); | |
| const label = this.getTextContent() as InnerLineLabel; | |
| // Always set `textStyle` even if `normalStyle.text` is null, because default | |
| // values have to be set on `normalStyle`. | |
| if (label) { | |
| const labelNormalModel = labelStatesModels.normal as unknown as Model<LineLabelOption>; | |
| label.__align = label.style.align; | |
| label.__verticalAlign = label.style.verticalAlign; | |
| // 'start', 'middle', 'end' | |
| label.__position = labelNormalModel.get('position') || 'middle'; | |
| let distance = labelNormalModel.get('distance'); | |
| if (!isArray(distance)) { | |
| distance = [distance, distance]; | |
| } | |
| label.__labelDistance = distance; | |
| } | |
| this.setTextConfig({ | |
| position: null, | |
| local: true, | |
| inside: false // Can't be inside for stroke element. | |
| }); | |
| toggleHoverEmphasis(this, focus, blurScope, emphasisDisabled); | |
| } | |
| highlight() { | |
| enterEmphasis(this); | |
| } | |
| downplay() { | |
| leaveEmphasis(this); | |
| } | |
| updateLayout(lineData: SeriesData, idx: number) { | |
| this.setLinePoints(lineData.getItemLayout(idx)); | |
| } | |
| setLinePoints(points: number[][]) { | |
| const linePath = this.childOfName('line') as ECLinePath; | |
| setLinePoints(linePath.shape, points); | |
| linePath.dirty(); | |
| } | |
| beforeUpdate() { | |
| const lineGroup = this; | |
| const symbolFrom = lineGroup.childOfName('fromSymbol') as ECSymbol; | |
| const symbolTo = lineGroup.childOfName('toSymbol') as ECSymbol; | |
| const label = lineGroup.getTextContent() as InnerLineLabel; | |
| // Quick reject | |
| if (!symbolFrom && !symbolTo && (!label || label.ignore)) { | |
| return; | |
| } | |
| let invScale = 1; | |
| let parentNode = this.parent; | |
| while (parentNode) { | |
| if (parentNode.scaleX) { | |
| invScale /= parentNode.scaleX; | |
| } | |
| parentNode = parentNode.parent; | |
| } | |
| const line = lineGroup.childOfName('line') as ECLinePath; | |
| // If line not changed | |
| // FIXME Parent scale changed | |
| if (!this.__dirty && !line.__dirty) { | |
| return; | |
| } | |
| const percent = line.shape.percent; | |
| const fromPos = line.pointAt(0); | |
| const toPos = line.pointAt(percent); | |
| const d = vector.sub([], toPos, fromPos); | |
| vector.normalize(d, d); | |
| function setSymbolRotation(symbol: ECSymbol, percent: 0 | 1) { | |
| // Fix #12388 | |
| // when symbol is set to be 'arrow' in markLine, | |
| // symbolRotate value will be ignored, and compulsively use tangent angle. | |
| // rotate by default if symbol rotation is not specified | |
| const specifiedRotation = (symbol as LineECSymbol).__specifiedRotation; | |
| if (specifiedRotation == null) { | |
| const tangent = line.tangentAt(percent); | |
| symbol.attr('rotation', (percent === 1 ? -1 : 1) * Math.PI / 2 - Math.atan2( | |
| tangent[1], tangent[0] | |
| )); | |
| } | |
| else { | |
| symbol.attr('rotation', specifiedRotation); | |
| } | |
| } | |
| if (symbolFrom) { | |
| symbolFrom.setPosition(fromPos); | |
| setSymbolRotation(symbolFrom, 0); | |
| symbolFrom.scaleX = symbolFrom.scaleY = invScale * percent; | |
| symbolFrom.markRedraw(); | |
| } | |
| if (symbolTo) { | |
| symbolTo.setPosition(toPos); | |
| setSymbolRotation(symbolTo, 1); | |
| symbolTo.scaleX = symbolTo.scaleY = invScale * percent; | |
| symbolTo.markRedraw(); | |
| } | |
| if (label && !label.ignore) { | |
| label.x = label.y = 0; | |
| label.originX = label.originY = 0; | |
| let textAlign: ZRTextAlign; | |
| let textVerticalAlign: ZRTextVerticalAlign; | |
| const distance = label.__labelDistance; | |
| const distanceX = distance[0] * invScale; | |
| const distanceY = distance[1] * invScale; | |
| const halfPercent = percent / 2; | |
| const tangent = line.tangentAt(halfPercent); | |
| const n = [tangent[1], -tangent[0]]; | |
| const cp = line.pointAt(halfPercent); | |
| if (n[1] > 0) { | |
| n[0] = -n[0]; | |
| n[1] = -n[1]; | |
| } | |
| const dir = tangent[0] < 0 ? -1 : 1; | |
| if (label.__position !== 'start' && label.__position !== 'end') { | |
| let rotation = -Math.atan2(tangent[1], tangent[0]); | |
| if (toPos[0] < fromPos[0]) { | |
| rotation = Math.PI + rotation; | |
| } | |
| label.rotation = rotation; | |
| } | |
| let dy; | |
| switch (label.__position) { | |
| case 'insideStartTop': | |
| case 'insideMiddleTop': | |
| case 'insideEndTop': | |
| case 'middle': | |
| dy = -distanceY; | |
| textVerticalAlign = 'bottom'; | |
| break; | |
| case 'insideStartBottom': | |
| case 'insideMiddleBottom': | |
| case 'insideEndBottom': | |
| dy = distanceY; | |
| textVerticalAlign = 'top'; | |
| break; | |
| default: | |
| dy = 0; | |
| textVerticalAlign = 'middle'; | |
| } | |
| switch (label.__position) { | |
| case 'end': | |
| label.x = d[0] * distanceX + toPos[0]; | |
| label.y = d[1] * distanceY + toPos[1]; | |
| textAlign = d[0] > 0.8 ? 'left' : (d[0] < -0.8 ? 'right' : 'center'); | |
| textVerticalAlign = d[1] > 0.8 ? 'top' : (d[1] < -0.8 ? 'bottom' : 'middle'); | |
| break; | |
| case 'start': | |
| label.x = -d[0] * distanceX + fromPos[0]; | |
| label.y = -d[1] * distanceY + fromPos[1]; | |
| textAlign = d[0] > 0.8 ? 'right' : (d[0] < -0.8 ? 'left' : 'center'); | |
| textVerticalAlign = d[1] > 0.8 ? 'bottom' : (d[1] < -0.8 ? 'top' : 'middle'); | |
| break; | |
| case 'insideStartTop': | |
| case 'insideStart': | |
| case 'insideStartBottom': | |
| label.x = distanceX * dir + fromPos[0]; | |
| label.y = fromPos[1] + dy; | |
| textAlign = tangent[0] < 0 ? 'right' : 'left'; | |
| label.originX = -distanceX * dir; | |
| label.originY = -dy; | |
| break; | |
| case 'insideMiddleTop': | |
| case 'insideMiddle': | |
| case 'insideMiddleBottom': | |
| case 'middle': | |
| label.x = cp[0]; | |
| label.y = cp[1] + dy; | |
| textAlign = 'center'; | |
| label.originY = -dy; | |
| break; | |
| case 'insideEndTop': | |
| case 'insideEnd': | |
| case 'insideEndBottom': | |
| label.x = -distanceX * dir + toPos[0]; | |
| label.y = toPos[1] + dy; | |
| textAlign = tangent[0] >= 0 ? 'right' : 'left'; | |
| label.originX = distanceX * dir; | |
| label.originY = -dy; | |
| break; | |
| } | |
| label.scaleX = label.scaleY = invScale; | |
| label.setStyle({ | |
| // Use the user specified text align and baseline first | |
| verticalAlign: label.__verticalAlign || textVerticalAlign, | |
| align: label.__align || textAlign | |
| }); | |
| } | |
| } | |
| } | |
| export default Line; |