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.
@@ -0,0 +1,315 @@
1
+ // Radiant Charts - AreaSeries
2
+ // Phase 2.3: Standard, stacked, step, and smooth curve 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 AreaSeriesOptions {
10
+ xKey: string;
11
+ yKey: string;
12
+ title?: string;
13
+ fill?: string;
14
+ stroke?: string;
15
+ strokeWidth?: number;
16
+ fillOpacity?: number;
17
+ stacked?: boolean;
18
+ /** When true, the stack is rescaled so each column sums to 100% — yielding a 100% stacked area chart. */
19
+ normalized?: boolean;
20
+ interpolation?: 'linear' | 'step' | 'smooth';
21
+ marker?: { enabled?: boolean; shape?: 'circle' | 'square' | 'cross' | 'diamond' | 'triangle' | 'plus'; size?: number; fill?: string; stroke?: string; strokeWidth?: number };
22
+ /** When false, the area path is severed at any null/NaN datapoint instead of bridging the gap. Default: true. */
23
+ connectMissingData?: boolean;
24
+ labels?: import('../RadiantChart').DataLabelOptions;
25
+ animation?: import('../RadiantChart').AnimationOptions;
26
+ shadow?: import('../RadiantChart').DropShadow;
27
+ }
28
+
29
+ function catmullRomBezier(pts: { x: number; y: number }[]): PathPoint[] {
30
+ if (pts.length === 0) return [];
31
+ const out: PathPoint[] = [{ command: 'M', x: pts[0].x, y: pts[0].y }];
32
+ for (let i = 1; i < pts.length; i++) {
33
+ const p0 = pts[Math.max(i - 2, 0)];
34
+ const p1 = pts[i - 1];
35
+ const p2 = pts[i];
36
+ const p3 = pts[Math.min(i + 1, pts.length - 1)];
37
+ out.push({
38
+ command: 'C',
39
+ cp1x: p1.x + (p2.x - p0.x) / 6,
40
+ cp1y: p1.y + (p2.y - p0.y) / 6,
41
+ cp2x: p2.x - (p3.x - p1.x) / 6,
42
+ cp2y: p2.y - (p3.y - p1.y) / 6,
43
+ x: p2.x,
44
+ y: p2.y,
45
+ });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export class AreaSeries {
51
+ private group = new Group();
52
+ private xKey: string;
53
+ private yKey: string;
54
+ fill: string = '#4e79a7';
55
+ private stroke: string = '#4e79a7';
56
+ private strokeWidth: number = 2;
57
+ private fillOpacity: number = 0.25;
58
+ private stacked: boolean = false;
59
+ private normalized: boolean = false;
60
+ private connectMissingData: boolean = true;
61
+ private interpolation: 'linear' | 'step' | 'smooth';
62
+ private markerOpts = { enabled: false, size: 6, fill: '' };
63
+ private labelsOpts?: import('../RadiantChart').DataLabelOptions;
64
+ private animationOpts?: import('../RadiantChart').AnimationOptions;
65
+ private shadowOpts?: import('../RadiantChart').DropShadow;
66
+ private animator?: Animator;
67
+ private path = new Path();
68
+ private markers: Marker[] = [];
69
+ private points: {
70
+ x: number;
71
+ targetY: number; startY: number; currentY: number;
72
+ targetBottomY: number; startBottomY: number; currentBottomY: number;
73
+ label?: Text;
74
+ datum: any;
75
+ /** True when the underlying y value is null / NaN — used to break the path. */
76
+ missing?: boolean;
77
+ }[] = [];
78
+
79
+ constructor(options: AreaSeriesOptions) {
80
+ this.xKey = options.xKey;
81
+ this.yKey = options.yKey;
82
+ this.stacked = options.stacked ?? false;
83
+ this.normalized = options.normalized ?? false;
84
+ this.connectMissingData = options.connectMissingData ?? true;
85
+ this.interpolation = options.interpolation ?? 'linear';
86
+ if (options.fill) this.fill = options.fill;
87
+ if (options.stroke) this.stroke = options.stroke;
88
+ if (options.strokeWidth !== undefined) this.strokeWidth = options.strokeWidth;
89
+ if (options.fillOpacity !== undefined) this.fillOpacity = options.fillOpacity;
90
+ if (options.marker) this.markerOpts = { ...this.markerOpts, ...options.marker };
91
+ this.markerOpts.fill = this.markerOpts.fill || this.fill;
92
+ this.labelsOpts = options.labels;
93
+ this.animationOpts = options.animation;
94
+ this.shadowOpts = options.shadow;
95
+ }
96
+
97
+ getGroup() { return this.group; }
98
+
99
+ update(
100
+ data: readonly any[],
101
+ xScale: BandScale | Scale<any, number>,
102
+ yScale: Scale<number, number>,
103
+ seriesRect: { x: number; y: number; width: number; height: number },
104
+ animate: boolean = true,
105
+ stackedOffsets?: number[],
106
+ options?: AreaSeriesOptions,
107
+ columnTotals?: number[],
108
+ ) {
109
+ if (options) {
110
+ this.xKey = options.xKey;
111
+ this.yKey = options.yKey;
112
+ if (options.fill) this.fill = options.fill;
113
+ if (options.stroke) this.stroke = options.stroke;
114
+ if (options.strokeWidth !== undefined) this.strokeWidth = options.strokeWidth;
115
+ if (options.fillOpacity !== undefined) this.fillOpacity = options.fillOpacity;
116
+ if (options.stacked !== undefined) this.stacked = options.stacked;
117
+ if (options.normalized !== undefined) this.normalized = options.normalized;
118
+ if (options.connectMissingData !== undefined) this.connectMissingData = options.connectMissingData;
119
+ if (options.interpolation) this.interpolation = options.interpolation;
120
+ if (options.marker) this.markerOpts = { ...this.markerOpts, ...options.marker };
121
+ if (options.labels) this.labelsOpts = options.labels;
122
+ if (options.animation) this.animationOpts = options.animation;
123
+ if (options.shadow !== undefined) this.shadowOpts = options.shadow;
124
+ }
125
+
126
+ const isInitial = this.points.length === 0;
127
+ this.group.clear();
128
+ if (data.length === 0) return;
129
+
130
+ this.path = new Path();
131
+ this.path.fill = this.fill;
132
+ this.path.stroke = this.stroke;
133
+ this.path.strokeWidth = this.strokeWidth;
134
+ this.path.opacity = this.fillOpacity;
135
+ this.path.shadow = this.shadowOpts ?? null;
136
+ this.group.add(this.path);
137
+
138
+ const newPts: typeof this.points = [];
139
+ this.markers = [];
140
+
141
+ data.forEach((datum, i) => {
142
+ const x = xScale.convert(datum[this.xKey]) + safeBandwidth(xScale) / 2;
143
+ let yVal = datum[this.yKey];
144
+ const isMissing = yVal === null || yVal === undefined || (typeof yVal === 'number' && isNaN(yVal));
145
+
146
+ let targetY: number, targetBottomY: number;
147
+ if (this.stacked && stackedOffsets) {
148
+ const off = stackedOffsets[i] ?? 0;
149
+ const total = columnTotals?.[i] ?? 0;
150
+ if (this.normalized && total > 0) {
151
+ // Normalize the running offset and current value into a 0..100 stack.
152
+ const normOff = (off / total) * 100;
153
+ const normVal = ((yVal ?? 0) / total) * 100;
154
+ targetBottomY = yScale.convert(normOff);
155
+ targetY = yScale.convert(normOff + normVal);
156
+ } else {
157
+ targetBottomY = yScale.convert(off);
158
+ targetY = yScale.convert(off + (yVal ?? 0));
159
+ }
160
+ } else {
161
+ targetY = yScale.convert(yVal ?? 0);
162
+ targetBottomY = yScale.convert(0);
163
+ }
164
+
165
+ const startY = isInitial ? yScale.convert(0) : (this.points[i]?.currentY ?? yScale.convert(0));
166
+ const startBottomY = isInitial ? yScale.convert(0) : (this.points[i]?.currentBottomY ?? yScale.convert(0));
167
+
168
+ let label: Text | undefined;
169
+ if (this.labelsOpts?.enabled) {
170
+ label = new Text();
171
+ this.group.add(label);
172
+ }
173
+
174
+ newPts.push({ x, targetY, startY, currentY: startY, targetBottomY, startBottomY, currentBottomY: startBottomY, label, datum, missing: isMissing });
175
+
176
+ if (this.markerOpts.enabled) {
177
+ const m = new Marker();
178
+ m.shape = (this.markerOpts as any).shape || 'circle';
179
+ m.centerX = x; m.centerY = targetY;
180
+ m.radius = (this.markerOpts.size || 6) / 2;
181
+ m.fill = this.markerOpts.fill;
182
+ if ((this.markerOpts as any).stroke) m.stroke = (this.markerOpts as any).stroke;
183
+ if ((this.markerOpts as any).strokeWidth !== undefined) m.strokeWidth = (this.markerOpts as any).strokeWidth;
184
+ m.shadow = this.shadowOpts ?? null;
185
+ m.datum = datum; m.seriesId = this.yKey;
186
+ this.markers.push(m);
187
+ this.group.add(m);
188
+ }
189
+ });
190
+
191
+ this.points = newPts;
192
+
193
+ if (animate && this.animationOpts?.enabled !== false) {
194
+ this.animator?.stop();
195
+ const animType = this.animationOpts?.type || 'grow';
196
+ const duration = this.animationOpts?.duration ?? 500;
197
+ const easing = Animator.getEasing(this.animationOpts?.easing);
198
+
199
+ if (animType === 'fade') {
200
+ this.group.opacity = 0;
201
+ this.animator = new Animator({
202
+ duration, easing,
203
+ onUpdate: (p) => {
204
+ this.buildPath(1);
205
+ this.group.opacity = p * this.fillOpacity;
206
+ if (this.labelsOpts?.enabled) this.renderLabels();
207
+ }
208
+ });
209
+ } else {
210
+ // Default grow
211
+ this.animator = new Animator({
212
+ duration,
213
+ easing,
214
+ onUpdate: (p) => {
215
+ this.buildPath(p);
216
+ if (this.labelsOpts?.enabled) this.renderLabels();
217
+ },
218
+ });
219
+ }
220
+ this.animator.start();
221
+ } else {
222
+ this.points.forEach((p, i) => {
223
+ p.label = newPts[i].label; // sync new label node
224
+ p.targetY = newPts[i].targetY; p.targetBottomY = newPts[i].targetBottomY;
225
+ p.startY = p.currentY; p.startBottomY = p.currentBottomY;
226
+ });
227
+ this.buildPath(1);
228
+ if (this.labelsOpts?.enabled) this.renderLabels();
229
+ }
230
+ }
231
+
232
+ private renderLabels() {
233
+ this.points.forEach(p => {
234
+ if (!p.label) return;
235
+ const lbl = p.label;
236
+ lbl.text = formatAxisValue(p.datum[this.yKey]);
237
+ lbl.fontSize = this.labelsOpts?.fontSize || 10;
238
+ lbl.fontFamily = this.labelsOpts?.fontFamily || 'sans-serif';
239
+ lbl.fontWeight = this.labelsOpts?.fontWeight || '600';
240
+ lbl.rotation = ((this.labelsOpts?.rotation || 0) * Math.PI) / 180;
241
+ lbl.fill = this.labelsOpts?.fill || this.stroke;
242
+ lbl.textAlign = 'center';
243
+ lbl.textBaseline = 'bottom';
244
+ lbl.translation = { x: p.x, y: p.currentY - 8 };
245
+ lbl.x = 0;
246
+ lbl.y = 0;
247
+ });
248
+ }
249
+
250
+ private buildPath(progress: number) {
251
+ // Update interpolated coordinates first so contiguous-segment splitting
252
+ // below can read currentY / currentBottomY directly.
253
+ this.points.forEach((p, i) => {
254
+ p.currentY = p.startY + (p.targetY - p.startY) * progress;
255
+ p.currentBottomY = p.startBottomY + (p.targetBottomY - p.startBottomY) * progress;
256
+ if (this.markers[i]) this.markers[i].centerY = p.currentY;
257
+ });
258
+
259
+ // Split the points into contiguous, non-missing runs when gap-skipping
260
+ // is requested. A run is rendered as one closed sub-path so the visual
261
+ // area is severed at any null/NaN.
262
+ const runs: typeof this.points[] = [];
263
+ if (this.connectMissingData) {
264
+ runs.push(this.points);
265
+ } else {
266
+ let current: typeof this.points = [];
267
+ this.points.forEach(p => {
268
+ if (p.missing) {
269
+ if (current.length > 0) { runs.push(current); current = []; }
270
+ } else {
271
+ current.push(p);
272
+ }
273
+ });
274
+ if (current.length > 0) runs.push(current);
275
+ }
276
+
277
+ const allPath: PathPoint[] = [];
278
+ runs.forEach(run => {
279
+ const topPts = run.map(p => ({ x: p.x, y: p.currentY }));
280
+ if (topPts.length === 0) return;
281
+
282
+ let topPath: PathPoint[];
283
+ if (this.interpolation === 'smooth') {
284
+ topPath = catmullRomBezier(topPts);
285
+ } else if (this.interpolation === 'step') {
286
+ topPath = [];
287
+ topPts.forEach((p, i) => {
288
+ if (i === 0) { topPath.push({ command: 'M', x: p.x, y: p.y }); }
289
+ else {
290
+ const prev = topPath[topPath.length - 1];
291
+ topPath.push({ command: 'L', x: p.x, y: prev.y });
292
+ topPath.push({ command: 'L', x: p.x, y: p.y });
293
+ }
294
+ });
295
+ } else {
296
+ topPath = topPts.map((p, i) => ({ command: (i === 0 ? 'M' : 'L') as 'M' | 'L', x: p.x, y: p.y }));
297
+ }
298
+
299
+ // Trace the run's bottom in reverse to close the polygon.
300
+ const bottomPath: PathPoint[] = [];
301
+ for (let i = run.length - 1; i >= 0; i--) {
302
+ bottomPath.push({ command: 'L', x: run[i].x, y: run[i].currentBottomY });
303
+ }
304
+ // Close this run by returning to its first top point so each sub-path
305
+ // is independently fillable.
306
+ bottomPath.push({ command: 'L', x: topPts[0].x, y: topPts[0].y });
307
+
308
+ allPath.push(...topPath, ...bottomPath);
309
+ });
310
+
311
+ this.path.pathData = allPath;
312
+ this.path.closed = true;
313
+ }
314
+ }
315
+