radiant-charts-core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LICENSE.md +21 -0
- package/dist/index.d.mts +431 -0
- package/dist/index.d.ts +431 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +9 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -0
- package/src/Declarative.tsx +503 -0
- package/src/RadiantChart.tsx +446 -0
- package/src/ResponsiveContainer.tsx +128 -0
- package/src/axes/CartesianAxis.ts +305 -0
- package/src/core/Animator.ts +119 -0
- package/src/core/ChartManager.ts +1062 -0
- package/src/core/CrosshairManager.ts +334 -0
- package/src/core/Legend.ts +269 -0
- package/src/core/ThemeManager.ts +98 -0
- package/src/index.ts +31 -0
- package/src/scale/Scale.ts +99 -0
- package/src/scene/Node.ts +183 -0
- package/src/scene/Scene.ts +197 -0
- package/src/scene/Shapes.ts +446 -0
- package/src/series/AreaSeries.ts +315 -0
- package/src/series/BarSeries.ts +502 -0
- package/src/series/LineSeries.ts +284 -0
- package/src/series/PieSeries.ts +203 -0
- package/src/series/ScatterSeries.ts +305 -0
- package/src/tooltip/TooltipContext.ts +22 -0
- package/src/tooltip/TooltipStore.ts +169 -0
- package/src/tooltip/__tests__/TooltipStore.test.ts +176 -0
- package/src/tooltip/coordUtils.ts +41 -0
- package/src/tooltip/index.ts +18 -0
- package/src/tooltip/types.ts +57 -0
- package/src/tooltip/useChartTooltip.ts +43 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Radiant Charts - LineSeries
|
|
2
|
+
// Phase 2.1: Standard, Step, and Smooth (Catmull-Rom spline) line variants
|
|
3
|
+
|
|
4
|
+
import { Group, Path, Marker, PathPoint, Text } from '../scene/Shapes';
|
|
5
|
+
import { formatAxisValue } from '../axes/CartesianAxis';
|
|
6
|
+
import { Scale, BandScale, safeBandwidth } from '../scale/Scale';
|
|
7
|
+
import { Animator } from '../core/Animator';
|
|
8
|
+
|
|
9
|
+
export interface LineSeriesOptions {
|
|
10
|
+
xKey: string;
|
|
11
|
+
yKey: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
stroke?: string;
|
|
14
|
+
strokeWidth?: number;
|
|
15
|
+
/** 'linear' | 'step' | 'smooth' */
|
|
16
|
+
interpolation?: 'linear' | 'step' | 'smooth';
|
|
17
|
+
/** @deprecated use interpolation:'step' */
|
|
18
|
+
step?: boolean;
|
|
19
|
+
marker?: {
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
size?: number;
|
|
22
|
+
fill?: string;
|
|
23
|
+
};
|
|
24
|
+
labels?: import('../RadiantChart').DataLabelOptions;
|
|
25
|
+
animation?: import('../RadiantChart').AnimationOptions;
|
|
26
|
+
shadow?: import('../RadiantChart').DropShadow;
|
|
27
|
+
/** When false, the line is severed at any null/NaN datapoint instead of bridging the gap. Default: true. */
|
|
28
|
+
connectMissingData?: boolean;
|
|
29
|
+
/** Configurable marker shape. Shape overrides the legacy dot-only renderer. */
|
|
30
|
+
markerShape?: 'circle' | 'square' | 'cross' | 'diamond' | 'triangle' | 'plus';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Catmull-Rom → cubic Bezier control points (maintains C1 continuity) */
|
|
34
|
+
function catmullRomToBezier(
|
|
35
|
+
pts: { x: number; y: number }[]
|
|
36
|
+
): PathPoint[] {
|
|
37
|
+
if (pts.length === 0) return [];
|
|
38
|
+
const result: PathPoint[] = [{ command: 'M', x: pts[0].x, y: pts[0].y }];
|
|
39
|
+
|
|
40
|
+
for (let i = 1; i < pts.length; i++) {
|
|
41
|
+
const p0 = pts[Math.max(i - 2, 0)];
|
|
42
|
+
const p1 = pts[i - 1];
|
|
43
|
+
const p2 = pts[i];
|
|
44
|
+
const p3 = pts[Math.min(i + 1, pts.length - 1)];
|
|
45
|
+
|
|
46
|
+
result.push({
|
|
47
|
+
command: 'C',
|
|
48
|
+
cp1x: p1.x + (p2.x - p0.x) / 6,
|
|
49
|
+
cp1y: p1.y + (p2.y - p0.y) / 6,
|
|
50
|
+
cp2x: p2.x - (p3.x - p1.x) / 6,
|
|
51
|
+
cp2y: p2.y - (p3.y - p1.y) / 6,
|
|
52
|
+
x: p2.x,
|
|
53
|
+
y: p2.y,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class LineSeries {
|
|
60
|
+
private group = new Group();
|
|
61
|
+
private xKey: string;
|
|
62
|
+
private yKey: string;
|
|
63
|
+
private stroke: string = '#4e79a7';
|
|
64
|
+
private strokeWidth: number = 2;
|
|
65
|
+
private interpolation: 'linear' | 'step' | 'smooth';
|
|
66
|
+
private markerOpts = { enabled: true, size: 6, fill: '' };
|
|
67
|
+
private labelsOpts?: import('../RadiantChart').DataLabelOptions;
|
|
68
|
+
private animationOpts?: import('../RadiantChart').AnimationOptions;
|
|
69
|
+
private shadowOpts?: import('../RadiantChart').DropShadow;
|
|
70
|
+
private connectMissingData: boolean = true;
|
|
71
|
+
private markerShape: 'circle' | 'square' | 'cross' | 'diamond' | 'triangle' | 'plus' = 'circle';
|
|
72
|
+
private animator?: Animator;
|
|
73
|
+
private linePath: Path = new Path();
|
|
74
|
+
|
|
75
|
+
constructor(options: LineSeriesOptions) {
|
|
76
|
+
this.xKey = options.xKey;
|
|
77
|
+
this.yKey = options.yKey;
|
|
78
|
+
// legacy 'step' boolean support
|
|
79
|
+
const legacy = options.step ? 'step' : 'linear';
|
|
80
|
+
this.interpolation = options.interpolation ?? legacy;
|
|
81
|
+
if (options.stroke) this.stroke = options.stroke;
|
|
82
|
+
if (options.strokeWidth) this.strokeWidth = options.strokeWidth;
|
|
83
|
+
if (options.marker) this.markerOpts = { ...this.markerOpts, ...options.marker };
|
|
84
|
+
this.markerOpts.fill = this.markerOpts.fill || this.stroke;
|
|
85
|
+
this.labelsOpts = options.labels;
|
|
86
|
+
this.animationOpts = options.animation;
|
|
87
|
+
this.shadowOpts = options.shadow;
|
|
88
|
+
this.connectMissingData = options.connectMissingData ?? true;
|
|
89
|
+
this.markerShape = options.markerShape ?? 'circle';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getGroup() { return this.group; }
|
|
93
|
+
|
|
94
|
+
update(
|
|
95
|
+
data: readonly any[],
|
|
96
|
+
xScale: BandScale | Scale<any, number>,
|
|
97
|
+
yScale: Scale<number, number>,
|
|
98
|
+
_seriesRect: { x: number; y: number; width: number; height: number },
|
|
99
|
+
animate: boolean = true,
|
|
100
|
+
options?: LineSeriesOptions
|
|
101
|
+
) {
|
|
102
|
+
if (options) {
|
|
103
|
+
this.xKey = options.xKey;
|
|
104
|
+
this.yKey = options.yKey;
|
|
105
|
+
if (options.stroke) this.stroke = options.stroke;
|
|
106
|
+
if (options.strokeWidth !== undefined) this.strokeWidth = options.strokeWidth;
|
|
107
|
+
if (options.interpolation) this.interpolation = options.interpolation;
|
|
108
|
+
if (options.marker) this.markerOpts = { ...this.markerOpts, ...options.marker };
|
|
109
|
+
if (options.labels) this.labelsOpts = options.labels;
|
|
110
|
+
if (options.animation) this.animationOpts = options.animation;
|
|
111
|
+
if (options.shadow !== undefined) this.shadowOpts = options.shadow;
|
|
112
|
+
if (options.connectMissingData !== undefined) this.connectMissingData = options.connectMissingData;
|
|
113
|
+
if (options.markerShape) this.markerShape = options.markerShape;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.group.clear();
|
|
117
|
+
if (data.length === 0) return;
|
|
118
|
+
|
|
119
|
+
// Continuous X scales (LinearScale, TimeScale) — used by grid-sync mode —
|
|
120
|
+
// don't implement getBandwidth(). safeBandwidth() returns 0 for those so
|
|
121
|
+
// points center directly at the converted X pixel; BandScale still gets
|
|
122
|
+
// its half-band offset.
|
|
123
|
+
const pts = data.map(d => {
|
|
124
|
+
const raw = d[this.yKey];
|
|
125
|
+
const missing = raw === null || raw === undefined || (typeof raw === 'number' && isNaN(raw));
|
|
126
|
+
const bw = safeBandwidth(xScale);
|
|
127
|
+
return {
|
|
128
|
+
x: (xScale as Scale<any, number>).convert(d[this.xKey]) + bw / 2,
|
|
129
|
+
y: yScale.convert(missing ? 0 : raw),
|
|
130
|
+
datum: d,
|
|
131
|
+
missing,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ─ Build path ─
|
|
136
|
+
const path = this.linePath;
|
|
137
|
+
path.pathData = [];
|
|
138
|
+
path.stroke = this.stroke;
|
|
139
|
+
path.strokeWidth = this.strokeWidth;
|
|
140
|
+
path.shadow = this.shadowOpts ?? null;
|
|
141
|
+
|
|
142
|
+
// Split into contiguous non-missing runs when gap-skipping is requested.
|
|
143
|
+
// Each run becomes its own sub-path (started by an 'M' command) so the
|
|
144
|
+
// line is visually severed at any null/NaN datum.
|
|
145
|
+
const runs: typeof pts[] = [];
|
|
146
|
+
if (this.connectMissingData) {
|
|
147
|
+
runs.push(pts.filter(p => !p.missing));
|
|
148
|
+
} else {
|
|
149
|
+
let current: typeof pts = [];
|
|
150
|
+
pts.forEach(p => {
|
|
151
|
+
if (p.missing) {
|
|
152
|
+
if (current.length > 0) { runs.push(current); current = []; }
|
|
153
|
+
} else {
|
|
154
|
+
current.push(p);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
if (current.length > 0) runs.push(current);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
runs.forEach(run => {
|
|
161
|
+
if (run.length === 0) return;
|
|
162
|
+
if (this.interpolation === 'smooth') {
|
|
163
|
+
path.pathData.push(...catmullRomToBezier(run));
|
|
164
|
+
} else if (this.interpolation === 'step') {
|
|
165
|
+
run.forEach((p, i) => {
|
|
166
|
+
if (i === 0) {
|
|
167
|
+
path.pathData.push({ command: 'M', x: p.x, y: p.y });
|
|
168
|
+
} else {
|
|
169
|
+
const prev = path.pathData[path.pathData.length - 1];
|
|
170
|
+
path.pathData.push({ command: 'L', x: p.x, y: prev.y });
|
|
171
|
+
path.pathData.push({ command: 'L', x: p.x, y: p.y });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
run.forEach((p, i) =>
|
|
176
|
+
path.pathData.push({ command: i === 0 ? 'M' : 'L', x: p.x, y: p.y })
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
this.group.add(path);
|
|
182
|
+
|
|
183
|
+
// ─ Markers & Labels (wrapped in a group for collective opacity) ─
|
|
184
|
+
const markersGroup = new Group();
|
|
185
|
+
this.group.add(markersGroup);
|
|
186
|
+
|
|
187
|
+
if (this.markerOpts.enabled || this.labelsOpts?.enabled) {
|
|
188
|
+
pts.forEach(p => {
|
|
189
|
+
if (p.missing) return;
|
|
190
|
+
if (this.markerOpts.enabled) {
|
|
191
|
+
const marker = new Marker();
|
|
192
|
+
marker.centerX = p.x;
|
|
193
|
+
marker.centerY = p.y;
|
|
194
|
+
marker.radius = (this.markerOpts.size || 6) / 2;
|
|
195
|
+
marker.shape = this.markerShape;
|
|
196
|
+
marker.fill = this.markerOpts.fill || this.stroke;
|
|
197
|
+
marker.stroke = '#fff';
|
|
198
|
+
marker.strokeWidth = 1.5;
|
|
199
|
+
marker.shadow = this.shadowOpts ?? null;
|
|
200
|
+
marker.datum = p.datum;
|
|
201
|
+
marker.seriesId = this.yKey;
|
|
202
|
+
markersGroup.add(marker);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (this.labelsOpts?.enabled) {
|
|
206
|
+
const lbl = new Text();
|
|
207
|
+
lbl.text = formatAxisValue(p.datum[this.yKey]);
|
|
208
|
+
lbl.fontSize = this.labelsOpts.fontSize || 10;
|
|
209
|
+
lbl.fontFamily = this.labelsOpts.fontFamily || 'sans-serif';
|
|
210
|
+
lbl.fontWeight = this.labelsOpts.fontWeight || '600';
|
|
211
|
+
lbl.rotation = ((this.labelsOpts.rotation || 0) * Math.PI) / 180;
|
|
212
|
+
lbl.fill = this.labelsOpts.fill || this.stroke;
|
|
213
|
+
|
|
214
|
+
const pos = this.labelsOpts.position || 'top';
|
|
215
|
+
const offset = (this.markerOpts.enabled ? this.markerOpts.size || 6 : 0) / 2 + 5;
|
|
216
|
+
|
|
217
|
+
let ax = p.x, ay = p.y;
|
|
218
|
+
if (pos === 'top') {
|
|
219
|
+
ay = p.y - offset;
|
|
220
|
+
lbl.textAlign = 'center'; lbl.textBaseline = 'bottom';
|
|
221
|
+
} else if (pos === 'bottom') {
|
|
222
|
+
ay = p.y + offset + 2;
|
|
223
|
+
lbl.textAlign = 'center'; lbl.textBaseline = 'top';
|
|
224
|
+
} else if (pos === 'left') {
|
|
225
|
+
ax = p.x - offset - 4;
|
|
226
|
+
lbl.textAlign = 'right'; lbl.textBaseline = 'middle';
|
|
227
|
+
} else if (pos === 'right') {
|
|
228
|
+
ax = p.x + offset + 4;
|
|
229
|
+
lbl.textAlign = 'left'; lbl.textBaseline = 'middle';
|
|
230
|
+
} else {
|
|
231
|
+
lbl.textAlign = 'center'; lbl.textBaseline = 'middle';
|
|
232
|
+
if (this.markerOpts.enabled) lbl.fill = '#fff';
|
|
233
|
+
}
|
|
234
|
+
lbl.translation = { x: ax, y: ay };
|
|
235
|
+
lbl.x = 0; lbl.y = 0;
|
|
236
|
+
markersGroup.add(lbl);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (animate && this.animationOpts?.enabled !== false) {
|
|
242
|
+
this.animator?.stop();
|
|
243
|
+
const animType = this.animationOpts?.type || 'draw';
|
|
244
|
+
const duration = this.animationOpts?.duration ?? 800;
|
|
245
|
+
const easing = Animator.getEasing(this.animationOpts?.easing);
|
|
246
|
+
|
|
247
|
+
if (animType === 'draw') {
|
|
248
|
+
// Approximate path length for drawing effect
|
|
249
|
+
let length = 0;
|
|
250
|
+
for (let i = 1; i < pts.length; i++) {
|
|
251
|
+
length += Math.sqrt(Math.pow(pts[i].x - pts[i-1].x, 2) + Math.pow(pts[i].y - pts[i-1].y, 2));
|
|
252
|
+
}
|
|
253
|
+
if (this.interpolation === 'step') length *= 1.5; // padding for steps
|
|
254
|
+
|
|
255
|
+
path.lineDash = [length, length];
|
|
256
|
+
path.lineDashOffset = length;
|
|
257
|
+
markersGroup.opacity = 0;
|
|
258
|
+
|
|
259
|
+
this.animator = new Animator({
|
|
260
|
+
duration,
|
|
261
|
+
easing,
|
|
262
|
+
onUpdate: (p) => {
|
|
263
|
+
path.lineDashOffset = length * (1 - p);
|
|
264
|
+
if (p > 0.8) markersGroup.opacity = (p - 0.8) * 5;
|
|
265
|
+
},
|
|
266
|
+
onComplete: () => {
|
|
267
|
+
path.lineDash = undefined;
|
|
268
|
+
markersGroup.opacity = 1;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
// Default fade
|
|
273
|
+
this.group.opacity = 0;
|
|
274
|
+
this.animator = new Animator({
|
|
275
|
+
duration,
|
|
276
|
+
easing,
|
|
277
|
+
onUpdate: (p) => { this.group.opacity = p; }
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
this.animator.start();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Radiant Charts - PieSeries
|
|
2
|
+
// Phase 2.4: Pie, Donut, Half-Donut / Semi-Circle with entrance animation
|
|
3
|
+
|
|
4
|
+
import { Group, Arc, Text } from '../scene/Shapes';
|
|
5
|
+
import { Animator } from '../core/Animator';
|
|
6
|
+
|
|
7
|
+
export interface PieSeriesOptions {
|
|
8
|
+
angleKey: string;
|
|
9
|
+
labelKey: string;
|
|
10
|
+
innerRadius?: number;
|
|
11
|
+
outerRadius?: number;
|
|
12
|
+
fills?: string[];
|
|
13
|
+
strokes?: string[];
|
|
14
|
+
/** Start angle in degrees (default -90 = top). Use 180 for half-donut */
|
|
15
|
+
startAngle?: number;
|
|
16
|
+
/** End angle in degrees (default 270 = full circle). Use 360 for half-donut */
|
|
17
|
+
endAngle?: number;
|
|
18
|
+
/** Gap between slices in radians */
|
|
19
|
+
padAngle?: number;
|
|
20
|
+
labels?: import('../RadiantChart').DataLabelOptions;
|
|
21
|
+
animation?: import('../RadiantChart').AnimationOptions;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEG = Math.PI / 180;
|
|
25
|
+
|
|
26
|
+
export class PieSeries {
|
|
27
|
+
private group = new Group();
|
|
28
|
+
private angleKey: string;
|
|
29
|
+
private labelKey: string;
|
|
30
|
+
private innerRadius: number = 0;
|
|
31
|
+
private outerRadius?: number;
|
|
32
|
+
private fills: string[] = ['#4e79a7','#f28e2c','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ac'];
|
|
33
|
+
private strokes: string[] = ['#fff'];
|
|
34
|
+
private startAngleDeg: number = -90;
|
|
35
|
+
private endAngleDeg: number = 270;
|
|
36
|
+
private padAngle: number = 0.008;
|
|
37
|
+
private labelsOpts?: import('../RadiantChart').DataLabelOptions;
|
|
38
|
+
private animationOpts?: import('../RadiantChart').AnimationOptions;
|
|
39
|
+
private animator?: Animator;
|
|
40
|
+
private slices: { arc: Arc, targetStart: number, targetEnd: number, label?: Text }[] = [];
|
|
41
|
+
|
|
42
|
+
constructor(options: PieSeriesOptions) {
|
|
43
|
+
this.angleKey = options.angleKey;
|
|
44
|
+
this.labelKey = options.labelKey;
|
|
45
|
+
if (options.innerRadius !== undefined) this.innerRadius = options.innerRadius;
|
|
46
|
+
if (options.outerRadius !== undefined) this.outerRadius = options.outerRadius;
|
|
47
|
+
if (options.fills) this.fills = options.fills;
|
|
48
|
+
if (options.strokes) this.strokes = options.strokes;
|
|
49
|
+
if (options.startAngle !== undefined) this.startAngleDeg = options.startAngle;
|
|
50
|
+
if (options.endAngle !== undefined) this.endAngleDeg = options.endAngle;
|
|
51
|
+
if (options.padAngle !== undefined) this.padAngle = options.padAngle;
|
|
52
|
+
this.labelsOpts = options.labels;
|
|
53
|
+
this.animationOpts = options.animation;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getGroup() { return this.group; }
|
|
57
|
+
|
|
58
|
+
update(data: readonly any[], seriesRect: { x: number; y: number; width: number; height: number }, animate: boolean = true, options?: PieSeriesOptions) {
|
|
59
|
+
if (options) {
|
|
60
|
+
this.angleKey = options.angleKey;
|
|
61
|
+
this.labelKey = options.labelKey;
|
|
62
|
+
if (options.innerRadius !== undefined) this.innerRadius = options.innerRadius;
|
|
63
|
+
if (options.outerRadius !== undefined) this.outerRadius = options.outerRadius;
|
|
64
|
+
if (options.fills) this.fills = options.fills;
|
|
65
|
+
if (options.strokes) this.strokes = options.strokes;
|
|
66
|
+
if (options.startAngle !== undefined) this.startAngleDeg = options.startAngle;
|
|
67
|
+
if (options.endAngle !== undefined) this.endAngleDeg = options.endAngle;
|
|
68
|
+
if (options.padAngle !== undefined) this.padAngle = options.padAngle;
|
|
69
|
+
if (options.labels) this.labelsOpts = options.labels;
|
|
70
|
+
if (options.animation) this.animationOpts = options.animation;
|
|
71
|
+
}
|
|
72
|
+
this.group.clear();
|
|
73
|
+
if (!data || data.length === 0) return;
|
|
74
|
+
|
|
75
|
+
const total = data.reduce((s, d) => s + (d[this.angleKey] || 0), 0);
|
|
76
|
+
if (total === 0) return;
|
|
77
|
+
|
|
78
|
+
const cx = seriesRect.x + seriesRect.width / 2;
|
|
79
|
+
const cy = seriesRect.y + seriesRect.height / 2;
|
|
80
|
+
const marginFactor = this.labelsOpts?.enabled !== false ? 0.72 : 0.88;
|
|
81
|
+
const outerR = this.outerRadius ?? (Math.min(seriesRect.width, seriesRect.height) / 2 * marginFactor);
|
|
82
|
+
// innerRadius ≤ 1.0 is treated as a fraction of outerR (e.g. 0.5 → 50% of outerR)
|
|
83
|
+
const innerR = this.innerRadius > 0 && this.innerRadius <= 1.0 ? this.innerRadius * outerR : this.innerRadius;
|
|
84
|
+
|
|
85
|
+
const startRad = this.startAngleDeg * DEG;
|
|
86
|
+
const totalArcRad = (this.endAngleDeg - this.startAngleDeg) * DEG;
|
|
87
|
+
|
|
88
|
+
this.slices = [];
|
|
89
|
+
let currentAngle = startRad;
|
|
90
|
+
|
|
91
|
+
data.forEach((datum, i) => {
|
|
92
|
+
const val = datum[this.angleKey] || 0;
|
|
93
|
+
const sliceAngle = (val / total) * totalArcRad - this.padAngle;
|
|
94
|
+
|
|
95
|
+
const slice = new Arc();
|
|
96
|
+
slice.centerX = cx;
|
|
97
|
+
slice.centerY = cy;
|
|
98
|
+
slice.innerRadius = innerR;
|
|
99
|
+
slice.outerRadius = outerR;
|
|
100
|
+
slice.startAngle = currentAngle;
|
|
101
|
+
slice.endAngle = currentAngle + sliceAngle;
|
|
102
|
+
slice.fill = this.fills[i % this.fills.length];
|
|
103
|
+
slice.stroke = this.strokes[i % this.strokes.length];
|
|
104
|
+
slice.strokeWidth = 2;
|
|
105
|
+
slice.datum = datum;
|
|
106
|
+
slice.seriesId = this.angleKey;
|
|
107
|
+
this.group.add(slice);
|
|
108
|
+
|
|
109
|
+
let labelNode: Text | undefined;
|
|
110
|
+
// Label
|
|
111
|
+
if (this.labelsOpts?.enabled !== false) {
|
|
112
|
+
const labelAngle = currentAngle + sliceAngle / 2;
|
|
113
|
+
const pos = this.labelsOpts?.position || 'outside';
|
|
114
|
+
const isInside = pos === 'inside' || pos === 'center';
|
|
115
|
+
|
|
116
|
+
const labelR = isInside ? innerR + (outerR - innerR) / 2 : outerR + 18;
|
|
117
|
+
const lx = cx + Math.cos(labelAngle) * labelR;
|
|
118
|
+
const ly = cy + Math.sin(labelAngle) * labelR;
|
|
119
|
+
|
|
120
|
+
labelNode = new Text();
|
|
121
|
+
labelNode.text = String(datum[this.labelKey] ?? '');
|
|
122
|
+
labelNode.fontSize = this.labelsOpts?.fontSize || 10;
|
|
123
|
+
labelNode.fontFamily = this.labelsOpts?.fontFamily || 'sans-serif';
|
|
124
|
+
labelNode.fontWeight = this.labelsOpts?.fontWeight || 'normal';
|
|
125
|
+
labelNode.rotation = ((this.labelsOpts?.rotation || 0) * Math.PI) / 180;
|
|
126
|
+
labelNode.fill = this.labelsOpts?.fill || (isInside ? '#fff' : '#6b7280');
|
|
127
|
+
|
|
128
|
+
// Dynamically adjust text alignment and max width to prevent container overflow
|
|
129
|
+
const cos = Math.cos(labelAngle);
|
|
130
|
+
labelNode.textAlign = isInside ? 'center' : (cos > 0.05 ? 'start' : cos < -0.05 ? 'end' : 'center');
|
|
131
|
+
labelNode.textBaseline = 'middle';
|
|
132
|
+
labelNode.translation = { x: lx, y: ly };
|
|
133
|
+
labelNode.x = 0;
|
|
134
|
+
labelNode.y = 0;
|
|
135
|
+
|
|
136
|
+
// Anti-collision: truncate long labels that would bleed off-canvas
|
|
137
|
+
if (!isInside) {
|
|
138
|
+
const availableWidth = cos > 0
|
|
139
|
+
? seriesRect.x + seriesRect.width - lx - 10
|
|
140
|
+
: lx - seriesRect.x - 10;
|
|
141
|
+
labelNode.maxWidth = Math.max(40, Math.min(availableWidth, seriesRect.width / 3));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (isInside && sliceAngle < 0.1) labelNode.text = '';
|
|
145
|
+
this.group.add(labelNode);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.slices.push({ arc: slice, targetStart: currentAngle, targetEnd: currentAngle + sliceAngle, label: labelNode });
|
|
149
|
+
currentAngle += sliceAngle + this.padAngle;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (animate && this.animationOpts?.enabled !== false) {
|
|
153
|
+
this.animator?.stop();
|
|
154
|
+
const animType = this.animationOpts?.type || 'grow';
|
|
155
|
+
const duration = this.animationOpts?.duration ?? 600;
|
|
156
|
+
const easing = Animator.getEasing(this.animationOpts?.easing || (animType === 'grow' ? 'back' : 'easeOut'));
|
|
157
|
+
|
|
158
|
+
if (animType === 'grow') {
|
|
159
|
+
this.slices.forEach(s => { s.arc.outerRadius = 0; if (s.label) s.label.opacity = 0; });
|
|
160
|
+
this.animator = new Animator({
|
|
161
|
+
duration, easing,
|
|
162
|
+
onUpdate: (p) => {
|
|
163
|
+
this.slices.forEach(s => {
|
|
164
|
+
s.arc.outerRadius = outerR * p;
|
|
165
|
+
if (s.label && p > 0.8) s.label.opacity = (p - 0.8) * 5;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
} else if (animType === 'radial') {
|
|
170
|
+
this.slices.forEach(s => { s.arc.endAngle = s.arc.startAngle; if (s.label) s.label.opacity = 0; });
|
|
171
|
+
this.animator = new Animator({
|
|
172
|
+
duration, easing,
|
|
173
|
+
onUpdate: (p) => {
|
|
174
|
+
this.slices.forEach(s => {
|
|
175
|
+
const fullAngle = s.targetEnd - s.targetStart;
|
|
176
|
+
s.arc.endAngle = s.targetStart + fullAngle * p;
|
|
177
|
+
if (s.label && p > 0.8) s.label.opacity = (p - 0.8) * 5;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
this.group.opacity = 0;
|
|
183
|
+
this.animator = new Animator({
|
|
184
|
+
duration, easing,
|
|
185
|
+
onUpdate: (p) => { this.group.opacity = p; }
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
this.animator.start();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getLegendData(theme: any, data: readonly any[]): import('../core/Legend').LegendItem[] {
|
|
193
|
+
if (!data) return [];
|
|
194
|
+
return data.map((d, i) => ({
|
|
195
|
+
id: `${this.angleKey}-${i}`,
|
|
196
|
+
label: String(d[this.labelKey] ?? `Item ${i + 1}`),
|
|
197
|
+
fill: this.fills[i % this.fills.length],
|
|
198
|
+
markerType: 'square'
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|