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,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
|
+
}
|