Skip to content

Commit 6d55dbf

Browse files
committed
feat: enhance polar line to simulate radar
1 parent 6be0047 commit 6d55dbf

File tree

6 files changed

+228
-33
lines changed

6 files changed

+228
-33
lines changed

src/chart/line/LineSeries.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ export interface LineSeriesOption extends SeriesOption<LineStateOption<CallbackD
113113

114114
connectNulls?: boolean
115115

116+
/**
117+
* Connect the end and start points of the line.
118+
* Only effective in polar coordinate system.
119+
*/
120+
connectEnds?: boolean
121+
116122
showSymbol?: boolean
117123
// false | 'auto': follow the label interval strategy.
118124
// true: show all symbols.
@@ -201,6 +207,9 @@ class LineSeriesModel extends SeriesModel<LineSeriesOption> {
201207
// Whether to connect break point.
202208
connectNulls: false,
203209

210+
// Whether to connect end and start points in polar coordinate system.
211+
connectEnds: false,
212+
204213
// Sampling for large data. Can be: 'average', 'max', 'min', 'sum', 'lttb'.
205214
sampling: 'none',
206215

src/chart/line/LineView.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -865,11 +865,13 @@ class LineView extends ChartView {
865865

866866
const smooth = getSmooth(seriesModel.get('smooth'));
867867
const smoothMonotone = seriesModel.get('smoothMonotone');
868+
const connectEnds = isCoordSysPolar && seriesModel.get('connectEnds');
868869

869870
polyline.setShape({
870871
smooth,
871872
smoothMonotone,
872-
connectNulls
873+
connectNulls,
874+
connectEnds
873875
});
874876

875877
if (polygon) {
@@ -894,7 +896,8 @@ class LineView extends ChartView {
894896
smooth,
895897
stackedOnSmooth,
896898
smoothMonotone,
897-
connectNulls
899+
connectNulls,
900+
connectEnds
898901
});
899902

900903
setStatesStylesFromModel(polygon, seriesModel, 'areaStyle');

src/chart/line/poly.ts

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,85 @@ function drawSegment(
212212
return k;
213213
}
214214

215+
/**
216+
* Extend points array for smooth closed curve by prepending last two points
217+
* and appending first two points
218+
*/
219+
function extendPointsForClosedCurve(
220+
points: ArrayLike<number>,
221+
startIndex: number,
222+
len: number,
223+
connectNulls: boolean
224+
): ArrayLike<number> {
225+
const firstX = points[startIndex * 2];
226+
const firstY = points[startIndex * 2 + 1];
227+
const lastX = points[(len - 1) * 2];
228+
const lastY = points[(len - 1) * 2 + 1];
229+
230+
// Find the second-to-last valid point
231+
let prevIdx = len - 2;
232+
if (connectNulls) {
233+
while (prevIdx >= startIndex && isPointNull(points[prevIdx * 2], points[prevIdx * 2 + 1])) {
234+
prevIdx--;
235+
}
236+
}
237+
238+
// Find the second valid point
239+
let nextIdx = startIndex + 1;
240+
if (connectNulls) {
241+
while (nextIdx < len && isPointNull(points[nextIdx * 2], points[nextIdx * 2 + 1])) {
242+
nextIdx++;
243+
}
244+
}
245+
246+
// Build prepend part (2 points = 4 values)
247+
const prependPart = new (points.constructor as any)(4);
248+
if (prevIdx >= startIndex) {
249+
prependPart[0] = points[prevIdx * 2];
250+
prependPart[1] = points[prevIdx * 2 + 1];
251+
}
252+
else {
253+
prependPart[0] = lastX;
254+
prependPart[1] = lastY;
255+
}
256+
prependPart[2] = lastX;
257+
prependPart[3] = lastY;
258+
259+
// Build append part (2 points = 4 values)
260+
const appendPart = new (points.constructor as any)(4);
261+
appendPart[0] = firstX;
262+
appendPart[1] = firstY;
263+
if (nextIdx < len) {
264+
appendPart[2] = points[nextIdx * 2];
265+
appendPart[3] = points[nextIdx * 2 + 1];
266+
}
267+
else {
268+
appendPart[2] = firstX;
269+
appendPart[3] = firstY;
270+
}
271+
272+
// Create extended points array and merge all parts
273+
const pointsLen = (len - startIndex) * 2;
274+
const extendedLength = 4 + pointsLen + 4;
275+
const extendedPoints = new (points.constructor as any)(extendedLength);
276+
extendedPoints.set(prependPart, 0);
277+
// Copy original points slice
278+
const pointsSlice = (points as any).subarray
279+
? (points as any).subarray(startIndex * 2, len * 2)
280+
: Array.prototype.slice.call(points, startIndex * 2, len * 2);
281+
extendedPoints.set(pointsSlice, 4);
282+
extendedPoints.set(appendPart, 4 + pointsLen);
283+
284+
return extendedPoints;
285+
}
286+
215287
class ECPolylineShape {
216288
points: ArrayLike<number>;
217289
smooth = 0;
218290
smoothConstraint = true;
219291
smoothMonotone: 'x' | 'y' | 'none';
220292
connectNulls: boolean;
293+
connectEnds: boolean;
221294
}
222295

223296
interface ECPolylineProps extends PathProps {
@@ -246,7 +319,7 @@ export class ECPolyline extends Path<ECPolylineProps> {
246319
}
247320

248321
buildPath(ctx: PathProxy, shape: ECPolylineShape) {
249-
const points = shape.points;
322+
let points = shape.points;
250323

251324
let i = 0;
252325
let len = points.length / 2;
@@ -266,6 +339,24 @@ export class ECPolyline extends Path<ECPolylineProps> {
266339
}
267340
}
268341
}
342+
343+
// If connectEnds is enabled, extend points array for smooth closed curve
344+
if (shape.connectEnds && len > 2) {
345+
const firstX = points[i * 2];
346+
const firstY = points[i * 2 + 1];
347+
const lastX = points[(len - 1) * 2];
348+
const lastY = points[(len - 1) * 2 + 1];
349+
350+
// Only connect if first and last points are not null and not the same
351+
if (!isPointNull(firstX, firstY) && !isPointNull(lastX, lastY)
352+
&& (firstX !== lastX || firstY !== lastY)) {
353+
354+
points = extendPointsForClosedCurve(points, i, len, shape.connectNulls);
355+
i = 2;
356+
len = points.length / 2 - 1;
357+
}
358+
}
359+
269360
while (i < len) {
270361
i += drawSegment(
271362
ctx, points, i, len, len,
@@ -370,8 +461,8 @@ export class ECPolygon extends Path {
370461
}
371462

372463
buildPath(ctx: PathProxy, shape: ECPolygonShape) {
373-
const points = shape.points;
374-
const stackedOnPoints = shape.stackedOnPoints;
464+
let points = shape.points;
465+
let stackedOnPoints = shape.stackedOnPoints;
375466

376467
let i = 0;
377468
let len = points.length / 2;
@@ -390,6 +481,27 @@ export class ECPolygon extends Path {
390481
}
391482
}
392483
}
484+
485+
// If connectEnds is enabled, extend both points and stackedOnPoints arrays
486+
if (shape.connectEnds && len > 2) {
487+
const firstX = points[i * 2];
488+
const firstY = points[i * 2 + 1];
489+
const lastX = points[(len - 1) * 2];
490+
const lastY = points[(len - 1) * 2 + 1];
491+
492+
// Only connect if first and last points are not null and not the same
493+
if (!isPointNull(firstX, firstY) && !isPointNull(lastX, lastY)
494+
&& (firstX !== lastX || firstY !== lastY)) {
495+
496+
points = extendPointsForClosedCurve(points, i, len, shape.connectNulls);
497+
if (stackedOnPoints) {
498+
stackedOnPoints = extendPointsForClosedCurve(stackedOnPoints, i, len, shape.connectNulls);
499+
}
500+
i = 2;
501+
len = points.length / 2 - 1;
502+
}
503+
}
504+
393505
while (i < len) {
394506
const k = drawSegment(
395507
ctx, points, i, len, len,

src/component/axis/RadiusAxisView.ts

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -108,36 +108,81 @@ const axisElementBuilders: Record<typeof selfBuilderAttrs[number], AxisElementBu
108108
const angleExtent = angleAxis.getExtent();
109109
const shapeType = Math.abs(angleExtent[1] - angleExtent[0]) === 360 ? 'Circle' : 'Arc';
110110

111-
lineColors = lineColors instanceof Array ? lineColors : [lineColors];
111+
// Check if polygon shape is requested and angleAxis is category type
112+
const splitLineShape = splitLineModel.get('shape');
113+
const usePolygon = splitLineShape === 'polygon' && angleAxis.type === 'category';
112114

113-
const splitLines: graphic.Circle[][] = [];
115+
lineColors = lineColors instanceof Array ? lineColors : [lineColors];
114116

115-
for (let i = 0; i < ticksCoords.length; i++) {
116-
const colorIndex = (lineCount++) % lineColors.length;
117-
splitLines[colorIndex] = splitLines[colorIndex] || [];
118-
splitLines[colorIndex].push(new graphic[shapeType]({
119-
shape: {
120-
cx: polar.cx,
121-
cy: polar.cy,
122-
// ensure circle radius >= 0
123-
r: Math.max(ticksCoords[i].coord, 0),
124-
startAngle: -angleExtent[0] * RADIAN,
125-
endAngle: -angleExtent[1] * RADIAN,
126-
clockwise: angleAxis.inverse
117+
if (usePolygon) {
118+
// Use polyline shape for category angleAxis
119+
const angleTicksCoords = angleAxis.getTicksCoords();
120+
const splitLines: graphic.Polyline[][] = [];
121+
122+
for (let i = 0; i < ticksCoords.length; i++) {
123+
const radius = Math.max(ticksCoords[i].coord, 0);
124+
const colorIndex = (lineCount++) % lineColors.length;
125+
splitLines[colorIndex] = splitLines[colorIndex] || [];
126+
127+
// Create polyline points based on angle ticks
128+
const points: [number, number][] = [];
129+
for (let j = 0; j < angleTicksCoords.length; j++) {
130+
const angle = -angleTicksCoords[j].coord * RADIAN;
131+
const x = polar.cx + radius * Math.cos(angle);
132+
const y = polar.cy + radius * Math.sin(angle);
133+
points.push([x, y]);
127134
}
128-
}));
135+
136+
splitLines[colorIndex].push(new graphic.Polyline({
137+
shape: {
138+
points: points
139+
}
140+
}));
141+
}
142+
143+
// Simple optimization
144+
// Batching the lines if color are the same
145+
for (let i = 0; i < splitLines.length; i++) {
146+
group.add(graphic.mergePath(splitLines[i], {
147+
style: zrUtil.defaults({
148+
stroke: lineColors[i % lineColors.length],
149+
fill: null
150+
}, lineStyleModel.getLineStyle()),
151+
silent: true
152+
}));
153+
}
129154
}
155+
else {
156+
// Use default arc/circle shape
157+
const splitLines: graphic.Circle[][] = [];
158+
159+
for (let i = 0; i < ticksCoords.length; i++) {
160+
const colorIndex = (lineCount++) % lineColors.length;
161+
splitLines[colorIndex] = splitLines[colorIndex] || [];
162+
splitLines[colorIndex].push(new graphic[shapeType]({
163+
shape: {
164+
cx: polar.cx,
165+
cy: polar.cy,
166+
// ensure circle radius >= 0
167+
r: Math.max(ticksCoords[i].coord, 0),
168+
startAngle: -angleExtent[0] * RADIAN,
169+
endAngle: -angleExtent[1] * RADIAN,
170+
clockwise: angleAxis.inverse
171+
}
172+
}));
173+
}
130174

131-
// Simple optimization
132-
// Batching the lines if color are the same
133-
for (let i = 0; i < splitLines.length; i++) {
134-
group.add(graphic.mergePath(splitLines[i], {
135-
style: zrUtil.defaults({
136-
stroke: lineColors[i % lineColors.length],
137-
fill: null
138-
}, lineStyleModel.getLineStyle()),
139-
silent: true
140-
}));
175+
// Simple optimization
176+
// Batching the lines if color are the same
177+
for (let i = 0; i < splitLines.length; i++) {
178+
group.add(graphic.mergePath(splitLines[i], {
179+
style: zrUtil.defaults({
180+
stroke: lineColors[i % lineColors.length],
181+
fill: null
182+
}, lineStyleModel.getLineStyle()),
183+
silent: true
184+
}));
185+
}
141186
}
142187
},
143188

src/coord/polar/AxisModel.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export type RadiusAxisOption = AxisBaseOption & {
5555
* Id of host polar component
5656
*/
5757
polarId?: string;
58+
59+
splitLine?: AxisBaseOption['splitLine'] & {
60+
/**
61+
* Shape of splitLine: 'arc' | 'polygon'
62+
* Default: 'arc'
63+
*/
64+
shape?: 'arc' | 'polygon';
65+
};
5866
};
5967

6068
type PolarAxisOption = AngleAxisOption | RadiusAxisOption;

src/coord/polar/polarCreator.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI)
8585
angleAxis.scale.setExtent(Infinity, -Infinity);
8686
radiusAxis.scale.setExtent(Infinity, -Infinity);
8787

88+
let hasConnectEnds = false;
89+
8890
ecModel.eachSeries(function (seriesModel) {
8991
if (seriesModel.coordinateSystem === polar) {
9092
const data = seriesModel.getData();
@@ -94,6 +96,11 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI)
9496
zrUtil.each(getDataDimensionsOnAxis(data, 'angle'), function (dim) {
9597
angleAxis.scale.unionExtentFromData(data, dim);
9698
});
99+
100+
// Check if any series uses connectEnds (for line series in polar)
101+
if ((seriesModel as any).get('connectEnds')) {
102+
hasConnectEnds = true;
103+
}
97104
}
98105
});
99106

@@ -103,9 +110,20 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI)
103110
// Fix extent of category angle axis
104111
if (angleAxis.type === 'category' && !angleAxis.onBand) {
105112
const extent = angleAxis.getExtent();
106-
const diff = 360 / (angleAxis.scale as OrdinalScale).count();
107-
angleAxis.inverse ? (extent[1] += diff) : (extent[1] -= diff);
108-
angleAxis.setExtent(extent[0], extent[1]);
113+
const count = (angleAxis.scale as OrdinalScale).count();
114+
const diff = 360 / count;
115+
116+
if (hasConnectEnds) {
117+
// When connectEnds is true, we want the axis to span full 360 degrees
118+
// but we need to extend the scale's data extent so that points are
119+
// distributed as if there's one more category, allowing proper connection
120+
const scaleExtent = angleAxis.scale.getExtent();
121+
angleAxis.scale.setExtent(scaleExtent[0], scaleExtent[1] + 1);
122+
}
123+
else {
124+
angleAxis.inverse ? (extent[1] += diff) : (extent[1] -= diff);
125+
angleAxis.setExtent(extent[0], extent[1]);
126+
}
109127
}
110128
}
111129

0 commit comments

Comments
 (0)