Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/chart/line/LineSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ export interface LineSeriesOption extends SeriesOption<LineStateOption<CallbackD

connectNulls?: boolean

/**
* Connect the end and start points of the line.
* Only effective in polar coordinate system.
*/
connectEnds?: boolean

showSymbol?: boolean
// false | 'auto': follow the label interval strategy.
// true: show all symbols.
Expand Down Expand Up @@ -201,6 +207,9 @@ class LineSeriesModel extends SeriesModel<LineSeriesOption> {
// 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',

Expand Down
7 changes: 5 additions & 2 deletions src/chart/line/LineView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -894,7 +896,8 @@ class LineView extends ChartView {
smooth,
stackedOnSmooth,
smoothMonotone,
connectNulls
connectNulls,
connectEnds
});

setStatesStylesFromModel(polygon, seriesModel, 'areaStyle');
Expand Down
118 changes: 115 additions & 3 deletions src/chart/line/poly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>,
startIndex: number,
len: number,
connectNulls: boolean
): ArrayLike<number> {
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<number>;
smooth = 0;
smoothConstraint = true;
smoothMonotone: 'x' | 'y' | 'none';
connectNulls: boolean;
connectEnds: boolean;
}

interface ECPolylineProps extends PathProps {
Expand Down Expand Up @@ -246,7 +319,7 @@ export class ECPolyline extends Path<ECPolylineProps> {
}

buildPath(ctx: PathProxy, shape: ECPolylineShape) {
const points = shape.points;
let points = shape.points;

let i = 0;
let len = points.length / 2;
Expand All @@ -266,6 +339,24 @@ export class ECPolyline extends Path<ECPolylineProps> {
}
}
}

// 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,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
95 changes: 70 additions & 25 deletions src/component/axis/RadiusAxisView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,36 +108,81 @@ const axisElementBuilders: Record<typeof selfBuilderAttrs[number], AxisElementBu
const angleExtent = angleAxis.getExtent();
const shapeType = Math.abs(angleExtent[1] - angleExtent[0]) === 360 ? 'Circle' : 'Arc';

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

const splitLines: graphic.Circle[][] = [];
lineColors = lineColors instanceof Array ? lineColors : [lineColors];

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
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
}));
}
}
},

Expand Down
8 changes: 8 additions & 0 deletions src/coord/polar/AxisModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 21 additions & 3 deletions src/coord/polar/polarCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
}
});

Expand All @@ -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]);
}
}
}

Expand Down