From 4c80d5df67fb052374223a962d7664f293572b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81gota=20F=C3=A1bi=C3=A1n?= Date: Tue, 7 Oct 2025 15:38:42 +0200 Subject: [PATCH 1/2] fix(lines): add symbols to lines series when polyline is set to true. close #19767 --- src/chart/helper/Line.ts | 110 +++-------------------- src/chart/helper/LineDraw.ts | 2 +- src/chart/helper/Polyline.ts | 130 ++++++++++++++++++++++++++- src/chart/helper/lineSymbolHelper.ts | 105 ++++++++++++++++++++++ test/lines-symbol.html | 45 +++++++++- 5 files changed, 288 insertions(+), 104 deletions(-) create mode 100644 src/chart/helper/lineSymbolHelper.ts diff --git a/src/chart/helper/Line.ts b/src/chart/helper/Line.ts index 07748206ab..4e50140e37 100644 --- a/src/chart/helper/Line.ts +++ b/src/chart/helper/Line.ts @@ -19,10 +19,9 @@ import { isArray, each, retrieve2 } 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 { toggleHoverEmphasis, enterEmphasis, leaveEmphasis } from '../../util/states'; import {getLabelStatesModels, setLabelStyle} from '../../label/labelStyle'; import {round} from '../../util/number'; import SeriesData from '../../data/SeriesData'; @@ -37,19 +36,18 @@ import { 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'; import tokens from '../../visual/tokens'; - -const SYMBOL_CATEGORIES = ['fromSymbol', 'toSymbol'] as const; - -type ECSymbol = ReturnType; - -type LineECSymbol = ECSymbol & { - __specifiedRotation: number -}; - -type LineList = SeriesData; +import { + SYMBOL_CATEGORIES, + LineECSymbol, + LineList, + createSymbol, + makeSymbolTypeKey, + makeSymbolTypeValue, + ECSymbol, + updateSymbol +} from './lineSymbolHelper'; export interface LineLabel extends graphic.Text { lineLabelOriginalOpacity: number @@ -62,64 +60,6 @@ interface InnerLineLabel extends LineLabel { __labelDistance: number[] } -function makeSymbolTypeKey(symbolCategory: 'fromSymbol' | 'toSymbol') { - return '_' + symbolCategory + 'Type' as '_fromSymbolType' | '_toSymbolType'; -} -function makeSymbolTypeValue(name: 'fromSymbol' | 'toSymbol', lineData: LineList, idx: number) { - const symbolType = lineData.getItemVisual(idx, name); - if (!symbolType || symbolType === 'none') { - return symbolType; - } - 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); - - return symbolType + symbolSizeArr + symbolOffsetArr + (symbolRotate || '') + (symbolKeepAspect || ''); -} - -/** - * @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', @@ -262,33 +202,7 @@ class Line extends graphic.Group { 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); + updateSymbol.bind(this)(lineStyle, line); const rawVal = seriesModel.getRawValue(idx) as number; setLabelStyle(this, labelStatesModels, { diff --git a/src/chart/helper/LineDraw.ts b/src/chart/helper/LineDraw.ts index 94c417f4f7..f72a087335 100644 --- a/src/chart/helper/LineDraw.ts +++ b/src/chart/helper/LineDraw.ts @@ -267,7 +267,7 @@ function isPointNaN(pt: number[]) { } function lineNeedsDraw(pts: number[][]) { - return pts && !isPointNaN(pts[0]) && !isPointNaN(pts[1]); + return pts && pts.length > 1 && !isPointNaN(pts[0]) && !isPointNaN(pts[1]); } diff --git a/src/chart/helper/Polyline.ts b/src/chart/helper/Polyline.ts index 731f262fc8..46857ba42e 100644 --- a/src/chart/helper/Polyline.ts +++ b/src/chart/helper/Polyline.ts @@ -17,23 +17,46 @@ * under the License. */ +import { each } from 'zrender/src/core/util'; import * as graphic from '../../util/graphic'; import { toggleHoverEmphasis } from '../../util/states'; import type { LineDrawSeriesScope, LineDrawModelOption } from './LineDraw'; import type SeriesData from '../../data/SeriesData'; import { BlurScope, DefaultEmphasisFocus } from '../../util/types'; +import SeriesModel from '../../model/Series'; +import { LineDataVisual } from '../../visual/commonVisualTypes'; +import { ECSymbol } from '../../util/symbol'; +import * as vector from 'zrender/src/core/vector'; +import { VectorArray } from 'zrender/src/core/vector'; +import { + SYMBOL_CATEGORIES, + LineECSymbol, + LineList, + createSymbol, + makeSymbolTypeKey, + makeSymbolTypeValue, + updateSymbol +} from './lineSymbolHelper'; class Polyline extends graphic.Group { + + private _fromSymbolType: string; + private _toSymbolType: string; + constructor(lineData: SeriesData, idx: number, seriesScope: LineDrawSeriesScope) { super(); - this._createPolyline(lineData, idx, seriesScope); + this._createPolyline(lineData as SeriesData, idx, seriesScope); } - private _createPolyline(lineData: SeriesData, idx: number, seriesScope: LineDrawSeriesScope) { + private _createPolyline( + lineData: SeriesData, + idx: number, + seriesScope: LineDrawSeriesScope) { // let seriesModel = lineData.hostModel; const points = lineData.getItemLayout(idx); const line = new graphic.Polyline({ + name: 'polyline', shape: { points: points } @@ -41,6 +64,15 @@ class Polyline extends graphic.Group { 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)] = makeSymbolTypeValue(symbolCategory, lineData, idx); + }, this); + this._updateCommonStl(lineData, idx, seriesScope); }; @@ -55,11 +87,23 @@ class Polyline extends graphic.Group { }; graphic.updateProps(line, target, seriesModel, idx); + each(SYMBOL_CATEGORIES, function (symbolCategory) { + const symbolType = makeSymbolTypeValue(symbolCategory, lineData as LineList, idx); + 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); }; _updateCommonStl(lineData: SeriesData, idx: number, seriesScope: LineDrawSeriesScope) { - const line = this.childAt(0) as graphic.Polyline; + const line = this.childOfName('polyline') as graphic.Polyline; const itemModel = lineData.getItemModel(idx); @@ -76,16 +120,94 @@ class Polyline extends graphic.Group { focus = emphasisModel.get('focus'); blurScope = emphasisModel.get('blurScope'); } - line.useStyle(lineData.getItemVisual(idx, 'style')); + const lineStyle = lineData.getItemVisual(idx, 'style'); + line.useStyle(lineStyle); line.style.fill = null; line.style.strokeNoScale = true; const lineEmphasisState = line.ensureState('emphasis'); lineEmphasisState.style = emphasisLineStyle; + updateSymbol.bind(this)(lineStyle, line); + toggleHoverEmphasis(this, focus, blurScope, emphasisDisabled); }; + beforeUpdate() { + const lineGroup = this; + const symbolFrom = lineGroup.childOfName('fromSymbol') as ECSymbol; + const symbolTo = lineGroup.childOfName('toSymbol') as ECSymbol; + + if (!symbolFrom && !symbolTo) { + return; + } + + let invScale = 1; + let parentNode = this.parent; + while (parentNode) { + if (parentNode.scaleX) { + invScale /= parentNode.scaleX; + } + parentNode = parentNode.parent; + } + + const polyline = lineGroup.childOfName('polyline') as graphic.Polyline; + // If line wasn't changed + if (!this.__dirty && !polyline.__dirty) { + return; + } + + const points = polyline.shape.points; + function setSymbolRotation(symbol: ECSymbol, percent: 0 | 1) { + const specifiedRotation = (symbol as LineECSymbol).__specifiedRotation; + if (specifiedRotation == null) { + const d = points.length; + if (d < 2) { + return; + } + + let tangent = [0, 0]; + let p1: VectorArray; + let p2: VectorArray; + + if (percent === 0) { + p1 = points[0]; + p2 = points[1]; + } + else { + p1 = points[d - 2]; + p2 = points[d - 1]; + } + + const p = [ p2[0] - p1[0], p2[1] - p1[1] ]; + tangent = vector.normalize(p, p); + symbol.attr('rotation', (percent === 1 ? -1 : 1) * Math.PI / 2 - Math.atan2( + tangent[1], tangent[0] + )); + } + else { + symbol.attr('rotation', specifiedRotation); + } + } + + const percent = polyline.shape.percent; + const fromPos = points[0]; + if (symbolFrom) { + symbolFrom.setPosition(fromPos); + setSymbolRotation(symbolFrom, 0); + symbolFrom.scaleX = symbolFrom.scaleY = invScale * percent; + symbolFrom.markRedraw(); + } + + const toPos = points[polyline.shape.points.length - 1]; + if (symbolTo) { + symbolTo.setPosition(toPos); + setSymbolRotation(symbolTo, 1); + symbolTo.scaleX = symbolTo.scaleY = invScale * percent; + symbolTo.markRedraw(); + } + } + updateLayout(lineData: SeriesData, idx: number) { const polyline = this.childAt(0) as graphic.Polyline; polyline.setShape('points', lineData.getItemLayout(idx)); diff --git a/src/chart/helper/lineSymbolHelper.ts b/src/chart/helper/lineSymbolHelper.ts new file mode 100644 index 0000000000..79035d3812 --- /dev/null +++ b/src/chart/helper/lineSymbolHelper.ts @@ -0,0 +1,105 @@ +import { each } from 'zrender/src/core/util'; +import SeriesData from '../../data/SeriesData'; +import { graphic, SeriesModel } from '../../echarts.all'; +import * as symbolUtil from '../../util/symbol'; +import { LineDataVisual } from '../../visual/commonVisualTypes'; +import { PathStyleProps } from 'zrender/src/graphic/Path'; +import { SPECIAL_STATES } from '../../util/states'; + +export const SYMBOL_CATEGORIES = ['fromSymbol', 'toSymbol'] as const; + +export type LineList = SeriesData; +export type LineECSymbol = symbolUtil.ECSymbol & { + __specifiedRotation: number +}; +export type ECSymbol = ReturnType; + +export function createSymbol( + name: 'fromSymbol' | 'toSymbol', + lineData: SeriesData, + 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; +} + +export function makeSymbolTypeKey(symbolCategory: 'fromSymbol' | 'toSymbol') { + return '_' + symbolCategory + 'Type' as '_fromSymbolType' | '_toSymbolType'; +} + +export function makeSymbolTypeValue(name: 'fromSymbol' | 'toSymbol', lineData: LineList, idx: number) { + const symbolType = lineData.getItemVisual(idx, name); + if (!symbolType || symbolType === 'none') { + return symbolType; + } + + 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); + + return symbolType + symbolSizeArr + symbolOffsetArr + (symbolRotate || '') + (symbolKeepAspect || ''); +} + +export function updateSymbol(this: graphic.Group, lineStyle: PathStyleProps, line: graphic.Polyline | graphic.Line) { + const visualColor = lineStyle.stroke; + 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); +} \ No newline at end of file diff --git a/test/lines-symbol.html b/test/lines-symbol.html index 560da4b6c8..a9371cb068 100644 --- a/test/lines-symbol.html +++ b/test/lines-symbol.html @@ -24,7 +24,8 @@ - + + @@ -36,6 +37,8 @@ }
+
+ + + \ No newline at end of file From a416f8957cef0d661d7824ba914cb01326db771a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81gota=20F=C3=A1bi=C3=A1n?= Date: Tue, 7 Oct 2025 15:43:19 +0200 Subject: [PATCH 2/2] fix(lines): add symbols to lines series when polyline is set to true. close #19767 --- src/chart/helper/Polyline.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/chart/helper/Polyline.ts b/src/chart/helper/Polyline.ts index 46857ba42e..430f60afcb 100644 --- a/src/chart/helper/Polyline.ts +++ b/src/chart/helper/Polyline.ts @@ -27,7 +27,6 @@ import SeriesModel from '../../model/Series'; import { LineDataVisual } from '../../visual/commonVisualTypes'; import { ECSymbol } from '../../util/symbol'; import * as vector from 'zrender/src/core/vector'; -import { VectorArray } from 'zrender/src/core/vector'; import { SYMBOL_CATEGORIES, LineECSymbol, @@ -167,8 +166,8 @@ class Polyline extends graphic.Group { } let tangent = [0, 0]; - let p1: VectorArray; - let p2: VectorArray; + let p1: vector.VectorArray; + let p2: vector.VectorArray; if (percent === 0) { p1 = points[0];