From 6d55dbf9cbba2492664a44bd8c969058f106cf35 Mon Sep 17 00:00:00 2001 From: Justin-ZS Date: Fri, 24 Oct 2025 14:56:51 +0800 Subject: [PATCH 1/2] feat: enhance polar line to simulate radar --- src/chart/line/LineSeries.ts | 9 ++ src/chart/line/LineView.ts | 7 +- src/chart/line/poly.ts | 118 ++++++++++++++++++++++++++- src/component/axis/RadiusAxisView.ts | 95 +++++++++++++++------ src/coord/polar/AxisModel.ts | 8 ++ src/coord/polar/polarCreator.ts | 24 +++++- 6 files changed, 228 insertions(+), 33 deletions(-) diff --git a/src/chart/line/LineSeries.ts b/src/chart/line/LineSeries.ts index bf1a6cb82f..2afedf0f46 100644 --- a/src/chart/line/LineSeries.ts +++ b/src/chart/line/LineSeries.ts @@ -113,6 +113,12 @@ export interface LineSeriesOption extends SeriesOption { // Whether to connect break point. connectNulls: false, + // Whether to connect end and start points in polar coordinate system. + connectEnds: false, + // Sampling for large data. Can be: 'average', 'max', 'min', 'sum', 'lttb'. sampling: 'none', diff --git a/src/chart/line/LineView.ts b/src/chart/line/LineView.ts index 8e317023d8..6bc42e95af 100644 --- a/src/chart/line/LineView.ts +++ b/src/chart/line/LineView.ts @@ -865,11 +865,13 @@ class LineView extends ChartView { const smooth = getSmooth(seriesModel.get('smooth')); const smoothMonotone = seriesModel.get('smoothMonotone'); + const connectEnds = isCoordSysPolar && seriesModel.get('connectEnds'); polyline.setShape({ smooth, smoothMonotone, - connectNulls + connectNulls, + connectEnds }); if (polygon) { @@ -894,7 +896,8 @@ class LineView extends ChartView { smooth, stackedOnSmooth, smoothMonotone, - connectNulls + connectNulls, + connectEnds }); setStatesStylesFromModel(polygon, seriesModel, 'areaStyle'); diff --git a/src/chart/line/poly.ts b/src/chart/line/poly.ts index aa6826de8d..46b7364e42 100644 --- a/src/chart/line/poly.ts +++ b/src/chart/line/poly.ts @@ -212,12 +212,85 @@ function drawSegment( return k; } +/** + * Extend points array for smooth closed curve by prepending last two points + * and appending first two points + */ +function extendPointsForClosedCurve( + points: ArrayLike, + startIndex: number, + len: number, + connectNulls: boolean +): ArrayLike { + const firstX = points[startIndex * 2]; + const firstY = points[startIndex * 2 + 1]; + const lastX = points[(len - 1) * 2]; + const lastY = points[(len - 1) * 2 + 1]; + + // Find the second-to-last valid point + let prevIdx = len - 2; + if (connectNulls) { + while (prevIdx >= startIndex && isPointNull(points[prevIdx * 2], points[prevIdx * 2 + 1])) { + prevIdx--; + } + } + + // Find the second valid point + let nextIdx = startIndex + 1; + if (connectNulls) { + while (nextIdx < len && isPointNull(points[nextIdx * 2], points[nextIdx * 2 + 1])) { + nextIdx++; + } + } + + // Build prepend part (2 points = 4 values) + const prependPart = new (points.constructor as any)(4); + if (prevIdx >= startIndex) { + prependPart[0] = points[prevIdx * 2]; + prependPart[1] = points[prevIdx * 2 + 1]; + } + else { + prependPart[0] = lastX; + prependPart[1] = lastY; + } + prependPart[2] = lastX; + prependPart[3] = lastY; + + // Build append part (2 points = 4 values) + const appendPart = new (points.constructor as any)(4); + appendPart[0] = firstX; + appendPart[1] = firstY; + if (nextIdx < len) { + appendPart[2] = points[nextIdx * 2]; + appendPart[3] = points[nextIdx * 2 + 1]; + } + else { + appendPart[2] = firstX; + appendPart[3] = firstY; + } + + // Create extended points array and merge all parts + const pointsLen = (len - startIndex) * 2; + const extendedLength = 4 + pointsLen + 4; + const extendedPoints = new (points.constructor as any)(extendedLength); + extendedPoints.set(prependPart, 0); + // Copy original points slice + const pointsSlice = (points as any).subarray + ? (points as any).subarray(startIndex * 2, len * 2) + : Array.prototype.slice.call(points, startIndex * 2, len * 2); + extendedPoints.set(pointsSlice, 4); + extendedPoints.set(appendPart, 4 + pointsLen); + + return extendedPoints; +} + class ECPolylineShape { points: ArrayLike; smooth = 0; smoothConstraint = true; smoothMonotone: 'x' | 'y' | 'none'; connectNulls: boolean; + connectEnds: boolean; } interface ECPolylineProps extends PathProps { @@ -246,7 +319,7 @@ export class ECPolyline extends Path { } buildPath(ctx: PathProxy, shape: ECPolylineShape) { - const points = shape.points; + let points = shape.points; let i = 0; let len = points.length / 2; @@ -266,6 +339,24 @@ export class ECPolyline extends Path { } } } + + // If connectEnds is enabled, extend points array for smooth closed curve + if (shape.connectEnds && len > 2) { + const firstX = points[i * 2]; + const firstY = points[i * 2 + 1]; + const lastX = points[(len - 1) * 2]; + const lastY = points[(len - 1) * 2 + 1]; + + // Only connect if first and last points are not null and not the same + if (!isPointNull(firstX, firstY) && !isPointNull(lastX, lastY) + && (firstX !== lastX || firstY !== lastY)) { + + points = extendPointsForClosedCurve(points, i, len, shape.connectNulls); + i = 2; + len = points.length / 2 - 1; + } + } + while (i < len) { i += drawSegment( ctx, points, i, len, len, @@ -370,8 +461,8 @@ export class ECPolygon extends Path { } buildPath(ctx: PathProxy, shape: ECPolygonShape) { - const points = shape.points; - const stackedOnPoints = shape.stackedOnPoints; + let points = shape.points; + let stackedOnPoints = shape.stackedOnPoints; let i = 0; let len = points.length / 2; @@ -390,6 +481,27 @@ export class ECPolygon extends Path { } } } + + // If connectEnds is enabled, extend both points and stackedOnPoints arrays + if (shape.connectEnds && len > 2) { + const firstX = points[i * 2]; + const firstY = points[i * 2 + 1]; + const lastX = points[(len - 1) * 2]; + const lastY = points[(len - 1) * 2 + 1]; + + // Only connect if first and last points are not null and not the same + if (!isPointNull(firstX, firstY) && !isPointNull(lastX, lastY) + && (firstX !== lastX || firstY !== lastY)) { + + points = extendPointsForClosedCurve(points, i, len, shape.connectNulls); + if (stackedOnPoints) { + stackedOnPoints = extendPointsForClosedCurve(stackedOnPoints, i, len, shape.connectNulls); + } + i = 2; + len = points.length / 2 - 1; + } + } + while (i < len) { const k = drawSegment( ctx, points, i, len, len, diff --git a/src/component/axis/RadiusAxisView.ts b/src/component/axis/RadiusAxisView.ts index f1c3f464c3..77e7bf0ffa 100644 --- a/src/component/axis/RadiusAxisView.ts +++ b/src/component/axis/RadiusAxisView.ts @@ -108,36 +108,81 @@ const axisElementBuilders: Record= 0 - r: Math.max(ticksCoords[i].coord, 0), - startAngle: -angleExtent[0] * RADIAN, - endAngle: -angleExtent[1] * RADIAN, - clockwise: angleAxis.inverse + if (usePolygon) { + // Use polyline shape for category angleAxis + const angleTicksCoords = angleAxis.getTicksCoords(); + const splitLines: graphic.Polyline[][] = []; + + for (let i = 0; i < ticksCoords.length; i++) { + const radius = Math.max(ticksCoords[i].coord, 0); + const colorIndex = (lineCount++) % lineColors.length; + splitLines[colorIndex] = splitLines[colorIndex] || []; + + // Create polyline points based on angle ticks + const points: [number, number][] = []; + for (let j = 0; j < angleTicksCoords.length; j++) { + const angle = -angleTicksCoords[j].coord * RADIAN; + const x = polar.cx + radius * Math.cos(angle); + const y = polar.cy + radius * Math.sin(angle); + points.push([x, y]); } - })); + + splitLines[colorIndex].push(new graphic.Polyline({ + shape: { + points: points + } + })); + } + + // Simple optimization + // Batching the lines if color are the same + for (let i = 0; i < splitLines.length; i++) { + group.add(graphic.mergePath(splitLines[i], { + style: zrUtil.defaults({ + stroke: lineColors[i % lineColors.length], + fill: null + }, lineStyleModel.getLineStyle()), + silent: true + })); + } } + else { + // Use default arc/circle shape + const splitLines: graphic.Circle[][] = []; + + for (let i = 0; i < ticksCoords.length; i++) { + const colorIndex = (lineCount++) % lineColors.length; + splitLines[colorIndex] = splitLines[colorIndex] || []; + splitLines[colorIndex].push(new graphic[shapeType]({ + shape: { + cx: polar.cx, + cy: polar.cy, + // ensure circle radius >= 0 + r: Math.max(ticksCoords[i].coord, 0), + startAngle: -angleExtent[0] * RADIAN, + endAngle: -angleExtent[1] * RADIAN, + clockwise: angleAxis.inverse + } + })); + } - // Simple optimization - // Batching the lines if color are the same - for (let i = 0; i < splitLines.length; i++) { - group.add(graphic.mergePath(splitLines[i], { - style: zrUtil.defaults({ - stroke: lineColors[i % lineColors.length], - fill: null - }, lineStyleModel.getLineStyle()), - silent: true - })); + // Simple optimization + // Batching the lines if color are the same + for (let i = 0; i < splitLines.length; i++) { + group.add(graphic.mergePath(splitLines[i], { + style: zrUtil.defaults({ + stroke: lineColors[i % lineColors.length], + fill: null + }, lineStyleModel.getLineStyle()), + silent: true + })); + } } }, diff --git a/src/coord/polar/AxisModel.ts b/src/coord/polar/AxisModel.ts index 40adf4adf3..d6808d5eb0 100644 --- a/src/coord/polar/AxisModel.ts +++ b/src/coord/polar/AxisModel.ts @@ -55,6 +55,14 @@ export type RadiusAxisOption = AxisBaseOption & { * Id of host polar component */ polarId?: string; + + splitLine?: AxisBaseOption['splitLine'] & { + /** + * Shape of splitLine: 'arc' | 'polygon' + * Default: 'arc' + */ + shape?: 'arc' | 'polygon'; + }; }; type PolarAxisOption = AngleAxisOption | RadiusAxisOption; diff --git a/src/coord/polar/polarCreator.ts b/src/coord/polar/polarCreator.ts index 224de9c3b0..dedd80ebbf 100644 --- a/src/coord/polar/polarCreator.ts +++ b/src/coord/polar/polarCreator.ts @@ -85,6 +85,8 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI) angleAxis.scale.setExtent(Infinity, -Infinity); radiusAxis.scale.setExtent(Infinity, -Infinity); + let hasConnectEnds = false; + ecModel.eachSeries(function (seriesModel) { if (seriesModel.coordinateSystem === polar) { const data = seriesModel.getData(); @@ -94,6 +96,11 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI) zrUtil.each(getDataDimensionsOnAxis(data, 'angle'), function (dim) { angleAxis.scale.unionExtentFromData(data, dim); }); + + // Check if any series uses connectEnds (for line series in polar) + if ((seriesModel as any).get('connectEnds')) { + hasConnectEnds = true; + } } }); @@ -103,9 +110,20 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI) // Fix extent of category angle axis if (angleAxis.type === 'category' && !angleAxis.onBand) { const extent = angleAxis.getExtent(); - const diff = 360 / (angleAxis.scale as OrdinalScale).count(); - angleAxis.inverse ? (extent[1] += diff) : (extent[1] -= diff); - angleAxis.setExtent(extent[0], extent[1]); + const count = (angleAxis.scale as OrdinalScale).count(); + const diff = 360 / count; + + if (hasConnectEnds) { + // When connectEnds is true, we want the axis to span full 360 degrees + // but we need to extend the scale's data extent so that points are + // distributed as if there's one more category, allowing proper connection + const scaleExtent = angleAxis.scale.getExtent(); + angleAxis.scale.setExtent(scaleExtent[0], scaleExtent[1] + 1); + } + else { + angleAxis.inverse ? (extent[1] += diff) : (extent[1] -= diff); + angleAxis.setExtent(extent[0], extent[1]); + } } } From 97a3d74f28348c64f82907c26f79320211fab580 Mon Sep 17 00:00:00 2001 From: Justin-ZS Date: Thu, 27 Nov 2025 17:56:50 +0800 Subject: [PATCH 2/2] test: add uts for new options --- test/polar-line-connectEnds.html | 301 ++++++++++++++++++++++++++ test/polar-line-radar-comparison.html | 297 +++++++++++++++++++++++++ test/polar-splitline-shape.html | 284 ++++++++++++++++++++++++ 3 files changed, 882 insertions(+) create mode 100644 test/polar-line-connectEnds.html create mode 100644 test/polar-line-radar-comparison.html create mode 100644 test/polar-splitline-shape.html diff --git a/test/polar-line-connectEnds.html b/test/polar-line-connectEnds.html new file mode 100644 index 0000000000..31424ae38d --- /dev/null +++ b/test/polar-line-connectEnds.html @@ -0,0 +1,301 @@ + + + + + + + + + + +
+
+
connectEnds: false
+
+
+
+
connectEnds: true
+
+
+
+
connectEnds: true + smooth
+
+
+
+
connectEnds: value angleAxis
+
+
+
+
connectEnds: boundaryGap true
+
+
+
+ + + + diff --git a/test/polar-line-radar-comparison.html b/test/polar-line-radar-comparison.html new file mode 100644 index 0000000000..a2bb8c59d9 --- /dev/null +++ b/test/polar-line-radar-comparison.html @@ -0,0 +1,297 @@ + + + + + + + Polar Line vs Radar Chart Comparison + + + + + +
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/test/polar-splitline-shape.html b/test/polar-splitline-shape.html new file mode 100644 index 0000000000..76083bb50e --- /dev/null +++ b/test/polar-splitline-shape.html @@ -0,0 +1,284 @@ + + + + + + + + + + +
+
+
shape: 'arc'
+
+
+
+
shape: 'polygon'
+
+
+
+
Category angle (boundaryGap: true)
+
+
+
+
Value angle axis (polygon does not apply)
+
+
+
+ + + +