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,502 @@
1
+ // Radiant Charts - BarSeries
2
+ // Phase 2.2: Grouped, stacked, 100% normalized, horizontal, with corner radius
3
+
4
+ import { Node, BBox } from '../scene/Node';
5
+ import { Group, Rect, Text, Line } from '../scene/Shapes';
6
+ import { Scale, BandScale, safeBandwidth } from '../scale/Scale';
7
+ import { Animator } from '../core/Animator';
8
+
9
+ export interface BarSeriesOptions {
10
+ xKey: string;
11
+ yKey: string;
12
+ title?: string;
13
+ fill?: string;
14
+ stroke?: string;
15
+ grouped?: boolean;
16
+ stacked?: boolean;
17
+ /** 100% normalized stacking */
18
+ normalized?: boolean;
19
+ direction?: 'vertical' | 'horizontal';
20
+ cornerRadius?: number;
21
+ labels?: import('../RadiantChart').DataLabelOptions;
22
+ animation?: import('../RadiantChart').AnimationOptions;
23
+ shadow?: import('../RadiantChart').DropShadow;
24
+ }
25
+
26
+ /**
27
+ * Optimized node for rendering thousands of bars in a single draw call.
28
+ * Avoids the overhead of individual Scene Graph nodes and ctx.save/restore.
29
+ */
30
+ class BarBatchNode extends Node {
31
+ bars: { x: number; y: number; width: number; height: number; datum: any }[] = [];
32
+ fill: string = '';
33
+ stroke: string = 'transparent';
34
+ cornerRadius: number = 0;
35
+
36
+ renderShape(ctx: CanvasRenderingContext2D) {
37
+ if (this.bars.length === 0) return;
38
+
39
+ ctx.fillStyle = this.fill;
40
+ ctx.beginPath();
41
+
42
+ // Path batching: one beginPath and one fill for the whole dataset.
43
+ for (const b of this.bars) {
44
+ if (this.cornerRadius > 0 && b.width > 8 && b.height > 8) {
45
+ const r = Math.min(this.cornerRadius, b.width / 2, b.height / 2);
46
+ ctx.moveTo(b.x + r, b.y);
47
+ ctx.arcTo(b.x + b.width, b.y, b.x + b.width, b.y + b.height, r);
48
+ ctx.arcTo(b.x + b.width, b.y + b.height, b.x, b.y + b.height, r);
49
+ ctx.arcTo(b.x, b.y + b.height, b.x, b.y, r);
50
+ ctx.arcTo(b.x, b.y, b.x + b.width, b.y, r);
51
+ } else {
52
+ ctx.rect(b.x, b.y, b.width, b.height);
53
+ }
54
+ }
55
+
56
+ ctx.fill();
57
+ if (this.stroke !== 'transparent') {
58
+ ctx.strokeStyle = this.stroke;
59
+ ctx.stroke();
60
+ }
61
+ }
62
+
63
+ isPointInNode(x: number, y: number): boolean {
64
+ // Spatial search optimized for Cartesian layout (if we had scales).
65
+ // Even O(N) is faster here than recursive pickNode.
66
+ for (let i = this.bars.length - 1; i >= 0; i--) {
67
+ const b = this.bars[i];
68
+ if (x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height) {
69
+ this.datum = b.datum;
70
+ return true;
71
+ }
72
+ }
73
+ return false;
74
+ }
75
+
76
+ getBBox(): BBox {
77
+ if (this.bars.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
78
+ // Just return container bounds or compute from first/last
79
+ return { x: 0, y: 0, width: 1000, height: 1000 };
80
+ }
81
+ }
82
+
83
+ export class BarSeries {
84
+ private group = new Group();
85
+ private xKey: string;
86
+ private yKey: string;
87
+ fill: string = '#4e79a7';
88
+ private stroke: string = 'transparent';
89
+ private grouped: boolean = false;
90
+ private stacked: boolean = false;
91
+ private normalized: boolean = false;
92
+ private direction: 'vertical' | 'horizontal' = 'vertical';
93
+ private cornerRadius: number = 3;
94
+ private labelsOpts?: import('../RadiantChart').DataLabelOptions;
95
+ private animationOpts?: import('../RadiantChart').AnimationOptions;
96
+ private shadowOpts?: import('../RadiantChart').DropShadow;
97
+ private animator?: Animator;
98
+ private batchNode: BarBatchNode | null = null;
99
+ private bars: {
100
+ rect: Rect;
101
+ label?: Text;
102
+ rawY: number;
103
+ targetY: number; targetHeight: number; startY: number; startHeight: number;
104
+ targetX: number; targetWidth: number; startX: number; startWidth: number;
105
+ targetOpacity: number; startOpacity: number;
106
+ targetScaleX: number; startScaleX: number;
107
+ targetScaleY: number; startScaleY: number;
108
+ }[] = [];
109
+
110
+ constructor(options: BarSeriesOptions) {
111
+ this.xKey = options.xKey;
112
+ this.yKey = options.yKey;
113
+ this.grouped = options.grouped ?? false;
114
+ this.stacked = options.stacked ?? false;
115
+ this.normalized = options.normalized ?? false;
116
+ this.direction = options.direction ?? 'vertical';
117
+ this.cornerRadius = options.cornerRadius ?? 3;
118
+ this.labelsOpts = options.labels;
119
+ this.animationOpts = options.animation;
120
+ this.shadowOpts = options.shadow;
121
+ if (options.fill) this.fill = options.fill;
122
+ if (options.stroke) this.stroke = options.stroke;
123
+ }
124
+
125
+ getGroup() { return this.group; }
126
+
127
+ update(
128
+ data: readonly any[],
129
+ xScale: BandScale | Scale<any, number>,
130
+ yScale: Scale<number, number>,
131
+ seriesRect: { x: number; y: number; width: number; height: number },
132
+ animate: boolean = true,
133
+ seriesIndex: number = 0,
134
+ totalSeries: number = 1,
135
+ stackedOffsets?: number[],
136
+ /** Column totals for 100% normalization */
137
+ columnTotals?: number[],
138
+ options?: BarSeriesOptions
139
+ ) {
140
+ if (options) {
141
+ this.xKey = options.xKey;
142
+ this.yKey = options.yKey;
143
+ if (options.fill) this.fill = options.fill;
144
+ if (options.stroke) this.stroke = options.stroke;
145
+ if (options.grouped !== undefined) this.grouped = options.grouped;
146
+ if (options.stacked !== undefined) this.stacked = options.stacked;
147
+ if (options.normalized !== undefined) this.normalized = options.normalized;
148
+ if (options.direction !== undefined) this.direction = options.direction;
149
+ if (options.cornerRadius !== undefined) this.cornerRadius = options.cornerRadius;
150
+ if (options.labels) this.labelsOpts = options.labels;
151
+ if (options.animation) this.animationOpts = options.animation;
152
+ if (options.shadow !== undefined) this.shadowOpts = options.shadow;
153
+ }
154
+
155
+ const isInitial = this.bars.length === 0;
156
+ const isH = this.direction === 'horizontal';
157
+ const animType = this.animationOpts?.type || 'grow';
158
+ const oldLen = this.bars.length;
159
+ const newLen = data.length;
160
+ const isBigData = newLen > 1000;
161
+
162
+ // ── Pre-Phase: Handle Big Data Fast Path ────────────────────────────
163
+ if (isBigData) {
164
+ this.group.clear();
165
+ this.bars = []; // Drop the individual pool
166
+
167
+ if (!this.batchNode) {
168
+ this.batchNode = new BarBatchNode();
169
+ }
170
+ this.batchNode.fill = this.fill;
171
+ this.batchNode.stroke = this.stroke;
172
+ this.batchNode.cornerRadius = this.cornerRadius;
173
+ this.batchNode.seriesId = this.yKey;
174
+ this.batchNode.shadow = this.shadowOpts ?? null;
175
+
176
+ this.group.add(this.batchNode);
177
+
178
+ const batchBars: any[] = [];
179
+ data.forEach((datum, i) => {
180
+ let yVal = datum[this.yKey] as number;
181
+ if (this.normalized && columnTotals && columnTotals[i]) {
182
+ yVal = (yVal / columnTotals[i]) * 100;
183
+ }
184
+
185
+ const fullBW = safeBandwidth(xScale);
186
+ if (!isH) {
187
+ let bx = xScale.convert(datum[this.xKey]);
188
+ let bw = fullBW;
189
+ if (this.grouped && totalSeries > 1) {
190
+ bw = fullBW / totalSeries;
191
+ bx += seriesIndex * bw;
192
+ }
193
+ const ty = yScale.convert((this.stacked && stackedOffsets ? (stackedOffsets[i] ?? 0) : 0) + yVal);
194
+ const th = yScale.convert(this.stacked && stackedOffsets ? (stackedOffsets[i] ?? 0) : 0) - ty;
195
+ batchBars.push({ x: bx, y: ty, width: bw, height: th, datum });
196
+ } else {
197
+ // ... Horizontal logic similarly if needed, but keeping it simple for now
198
+ let by = xScale.convert(datum[this.xKey]);
199
+ let bh = fullBW;
200
+ if (this.grouped && totalSeries > 1) {
201
+ bh = fullBW / totalSeries;
202
+ by += seriesIndex * bh;
203
+ }
204
+ const tx = yScale.convert(this.stacked && stackedOffsets ? (stackedOffsets[i] ?? 0) : 0);
205
+ const tw = yScale.convert((this.stacked && stackedOffsets ? (stackedOffsets[i] ?? 0) : 0) + yVal) - tx;
206
+ batchBars.push({ x: tx, y: by, width: tw, height: bh, datum });
207
+ }
208
+ });
209
+
210
+ this.batchNode.bars = batchBars;
211
+
212
+ // Sampled labels: render every Nth label so the high-density view still
213
+ // carries context, without paying the full label-layout cost per bar.
214
+ if (this.labelsOpts?.enabled) {
215
+ const step = Math.max(1, Math.ceil(newLen / 100));
216
+ for (let i = 0; i < batchBars.length; i += step) {
217
+ const b = batchBars[i];
218
+ const rect = new Rect();
219
+ rect.x = b.x; rect.y = b.y; rect.width = b.width; rect.height = b.height;
220
+ const lbl = new Text();
221
+ this.group.add(lbl);
222
+ this.placeLabel(lbl, rect, b.datum[this.yKey]);
223
+ }
224
+ }
225
+ return;
226
+ }
227
+
228
+ // Individual node pool path (for < 500 rows)
229
+ if (this.batchNode) {
230
+ this.group.remove(this.batchNode);
231
+ this.batchNode = null;
232
+ }
233
+
234
+ // ── Phase 1: Compute target geometry for every data point ────────────
235
+ // We build an array of target values first, then reconcile with the pool.
236
+
237
+ const targets: {
238
+ datum: any; rawY: number;
239
+ tx: number; ty: number; tw: number; th: number;
240
+ }[] = [];
241
+
242
+ data.forEach((datum, i) => {
243
+ let yVal = datum[this.yKey] as number;
244
+
245
+ // 100% normalization
246
+ if (this.normalized && columnTotals && columnTotals[i]) {
247
+ yVal = (yVal / columnTotals[i]) * 100;
248
+ }
249
+
250
+ const fullBW = safeBandwidth(xScale);
251
+ let tx: number, ty: number, tw: number, th: number;
252
+
253
+ if (!isH) {
254
+ let bx = xScale.convert(datum[this.xKey]);
255
+ let bw = fullBW;
256
+ if (this.grouped && totalSeries > 1) {
257
+ bw = fullBW / totalSeries;
258
+ bx += seriesIndex * bw;
259
+ }
260
+ tx = bx; tw = bw;
261
+ if (this.stacked && stackedOffsets) {
262
+ const off = stackedOffsets[i] ?? 0;
263
+ const normalOff = this.normalized && columnTotals?.[i] ? (off / columnTotals[i]) * 100 : off;
264
+ ty = yScale.convert(normalOff + yVal);
265
+ th = yScale.convert(normalOff) - ty;
266
+ } else {
267
+ ty = yScale.convert(yVal);
268
+ th = yScale.convert(0) - ty;
269
+ }
270
+ } else {
271
+ let by = xScale.convert(datum[this.xKey]);
272
+ let bh = fullBW;
273
+ if (this.grouped && totalSeries > 1) {
274
+ bh = fullBW / totalSeries;
275
+ by += seriesIndex * bh;
276
+ }
277
+ ty = by; th = bh;
278
+ if (this.stacked && stackedOffsets) {
279
+ const off = stackedOffsets[i] ?? 0;
280
+ tx = yScale.convert(off);
281
+ tw = yScale.convert(off + yVal) - tx;
282
+ } else {
283
+ tx = yScale.convert(0);
284
+ tw = yScale.convert(yVal) - tx;
285
+ }
286
+ }
287
+
288
+ targets.push({ datum, rawY: datum[this.yKey] as number, tx, ty, tw, th });
289
+ });
290
+
291
+ // ── Phase 2: Pool reconciliation ─────────────────────────────────────
292
+ // Reuse existing Rects for indices that already have them, create new
293
+ // ones for growth, and remove excess for shrinkage.
294
+
295
+ // 2a. Remove excess bars when data shrinks
296
+ if (newLen < oldLen) {
297
+ for (let i = newLen; i < oldLen; i++) {
298
+ const old = this.bars[i];
299
+ this.group.remove(old.rect);
300
+ if (old.label) this.group.remove(old.label);
301
+ }
302
+ this.bars.length = newLen;
303
+ }
304
+
305
+ // 2b. Reconcile each data index
306
+ for (let i = 0; i < newLen; i++) {
307
+ const { datum, rawY, tx, ty, tw, th } = targets[i];
308
+
309
+ let barRect: Rect;
310
+ let labelNode: Text | undefined;
311
+ let sx: number, sy: number, sw: number, sh: number;
312
+ let so = 1, to = 1;
313
+ let ssx = 1, tsx = 1;
314
+ let ssy = 1, tsy = 1;
315
+
316
+ if (i < oldLen) {
317
+ // ── Reuse existing Rect ──────────────────────────────────────────
318
+ const existing = this.bars[i];
319
+ barRect = existing.rect;
320
+ labelNode = existing.label;
321
+
322
+ // Capture the Rect's *current* pixel state as the animation start.
323
+ // This is critical: if a previous animation is mid-flight the bar
324
+ // will smoothly redirect from wherever it is right now, rather than
325
+ // snapping to an old target or re-growing from zero.
326
+ sx = barRect.x;
327
+ sy = barRect.y;
328
+ sw = barRect.width;
329
+ sh = barRect.height;
330
+ so = barRect.opacity;
331
+ ssx = barRect.scaling.x;
332
+ ssy = barRect.scaling.y;
333
+ } else {
334
+ // ── Create a new Rect (data grew) ────────────────────────────────
335
+ barRect = new Rect();
336
+ this.group.add(barRect);
337
+
338
+ // Determine intro animation start
339
+ sx = tx; sy = ty; sw = tw; sh = th;
340
+ if (animType === 'grow') {
341
+ if (!isH) { sy = seriesRect.height; sh = 0; }
342
+ else { sx = 0; sw = 0; }
343
+ } else if (animType === 'fade') {
344
+ so = 0;
345
+ } else if (animType === 'slide') {
346
+ if (!isH) { sy = seriesRect.height + 50; }
347
+ else { sx = -50; }
348
+ } else if (animType === 'pop') {
349
+ ssx = 0; ssy = 0;
350
+ }
351
+
352
+ // Create label if needed
353
+ if (this.labelsOpts?.enabled) {
354
+ labelNode = new Text();
355
+ this.group.add(labelNode);
356
+ }
357
+ }
358
+
359
+ // Apply visual properties (cheap, no allocation)
360
+ barRect.fill = this.fill;
361
+ barRect.stroke = this.stroke;
362
+ barRect.cornerRadius = this.cornerRadius;
363
+ barRect.datum = datum;
364
+ barRect.seriesId = this.yKey;
365
+ barRect.opacity = so;
366
+ barRect.scaling = { x: ssx, y: ssy };
367
+ barRect.shadow = this.shadowOpts ?? null;
368
+
369
+ // For 'pop' animation, set translation to the bar's center so it
370
+ // scales from the centre outward.
371
+ let finalTX = tx, finalTY = ty;
372
+ if (animType === 'pop') {
373
+ barRect.translation = { x: tx + tw / 2, y: ty + th / 2 };
374
+ finalTX = -tw / 2;
375
+ finalTY = -th / 2;
376
+ sx = -tw / 2;
377
+ sy = -th / 2;
378
+ } else {
379
+ barRect.translation = { x: 0, y: 0 };
380
+ }
381
+
382
+ // Write back into the pool array (reuse the slot or push a new one)
383
+ const entry = {
384
+ rect: barRect, label: labelNode, rawY,
385
+ targetY: finalTY, targetHeight: th, startY: sy, startHeight: sh,
386
+ targetX: finalTX, targetWidth: tw, startX: sx, startWidth: sw,
387
+ targetOpacity: to, startOpacity: so,
388
+ targetScaleX: tsx, startScaleX: ssx,
389
+ targetScaleY: tsy, startScaleY: ssy,
390
+ };
391
+
392
+ if (i < oldLen) {
393
+ this.bars[i] = entry;
394
+ } else {
395
+ this.bars.push(entry);
396
+ }
397
+ }
398
+
399
+ // ── Phase 3: Animate or snap ─────────────────────────────────────────
400
+ if (animate && (this.animationOpts?.enabled !== false)) {
401
+ this.animator?.stop();
402
+ const duration = this.animationOpts?.duration ?? 500;
403
+ const easing = Animator.getEasing(this.animationOpts?.easing);
404
+
405
+ this.animator = new Animator({
406
+ duration,
407
+ easing,
408
+ onUpdate: (progress) => {
409
+ this.bars.forEach(b => {
410
+ b.rect.y = b.startY + (b.targetY - b.startY) * progress;
411
+ b.rect.height = b.startHeight + (b.targetHeight - b.startHeight) * progress;
412
+ b.rect.x = b.startX + (b.targetX - b.startX) * progress;
413
+ b.rect.width = b.startWidth + (b.targetWidth - b.startWidth) * progress;
414
+ b.rect.opacity = b.startOpacity + (b.targetOpacity - b.startOpacity) * progress;
415
+ b.rect.scaling = {
416
+ x: b.startScaleX + (b.targetScaleX - b.startScaleX) * progress,
417
+ y: b.startScaleY + (b.targetScaleY - b.startScaleY) * progress,
418
+ };
419
+ if (b.label) this.placeLabel(b.label, b.rect, b.rawY);
420
+ });
421
+ },
422
+ });
423
+ this.animator.start();
424
+ } else {
425
+ this.bars.forEach(b => {
426
+ b.rect.y = b.targetY;
427
+ b.rect.height = b.targetHeight;
428
+ b.rect.x = b.targetX;
429
+ b.rect.width = b.targetWidth;
430
+ b.rect.opacity = b.targetOpacity;
431
+ b.rect.scaling = { x: b.targetScaleX, y: b.targetScaleY };
432
+ if (b.label) this.placeLabel(b.label, b.rect, b.rawY);
433
+ });
434
+ }
435
+ }
436
+
437
+ private placeLabel(txt: Text, rect: Rect, rawValue: number) {
438
+ const pos = this.labelsOpts?.position || 'inside';
439
+ const isH = this.direction === 'horizontal';
440
+
441
+ // Formatting: abbreviate large numbers to prevent clutter
442
+ let fv = String(rawValue);
443
+ if (typeof rawValue === 'number') {
444
+ const abs = Math.abs(rawValue);
445
+ if (abs >= 1e9) fv = (rawValue / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
446
+ else if (abs >= 1e6) fv = (rawValue / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
447
+ else if (abs >= 1e3) fv = (rawValue / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
448
+ else fv = rawValue.toLocaleString();
449
+ }
450
+
451
+ txt.text = fv;
452
+ txt.fontSize = this.labelsOpts?.fontSize || 10;
453
+ txt.fontFamily = this.labelsOpts?.fontFamily || 'sans-serif';
454
+ txt.fontWeight = this.labelsOpts?.fontWeight || '600';
455
+ txt.rotation = ((this.labelsOpts?.rotation || 0) * Math.PI) / 180;
456
+ // Inside a colored bar should ideally be white, outside should be dark/muted
457
+ txt.fill = this.labelsOpts?.fill || (pos === 'inside' || pos === 'center' ? '#fff' : '#6b7280');
458
+ txt.textAlign = 'center';
459
+ txt.textBaseline = 'middle';
460
+
461
+ let ax = 0, ay = 0;
462
+ if (!isH) {
463
+ ax = rect.x + rect.width / 2;
464
+ if (pos === 'inside') {
465
+ ay = rect.y + 12;
466
+ } else if (pos === 'top' || pos === 'outside') {
467
+ ay = rect.y - 8;
468
+ txt.textBaseline = 'bottom';
469
+ txt.fill = this.labelsOpts?.fill || '#6b7280';
470
+ } else if (pos === 'center') {
471
+ ay = rect.y + rect.height / 2;
472
+ } else if (pos === 'bottom') {
473
+ ay = rect.y + rect.height - 10;
474
+ }
475
+ } else {
476
+ ay = rect.y + rect.height / 2;
477
+ if (pos === 'inside') {
478
+ ax = rect.x + rect.width - 12;
479
+ txt.textAlign = 'right';
480
+ } else if (pos === 'right' || pos === 'outside') {
481
+ ax = rect.x + rect.width + 6;
482
+ txt.textAlign = 'left';
483
+ txt.fill = this.labelsOpts?.fill || '#6b7280';
484
+ } else if (pos === 'center') {
485
+ ax = rect.x + rect.width / 2;
486
+ } else if (pos === 'left') {
487
+ ax = rect.x + 8;
488
+ txt.textAlign = 'left';
489
+ }
490
+ }
491
+
492
+ txt.translation = { x: ax, y: ay };
493
+ txt.x = 0;
494
+ txt.y = 0;
495
+
496
+ // Hide label if the bar is too thin to comfortably render text
497
+ if (pos === 'inside' || pos === 'center') {
498
+ if (!isH && (rect.width < 14 || rect.height < 14)) txt.text = '';
499
+ if (isH && (rect.height < 14 || rect.width < 14)) txt.text = '';
500
+ }
501
+ }
502
+ }