@dodlhuat/basix 1.2.0 → 1.2.2

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.
Files changed (93) hide show
  1. package/README.md +266 -6
  2. package/css/accordion.scss +86 -87
  3. package/css/alert.scss +137 -137
  4. package/css/button.scss +48 -0
  5. package/css/calendar.scss +957 -0
  6. package/css/card.scss +65 -65
  7. package/css/chart.scss +270 -157
  8. package/css/chat-bubbles.scss +134 -68
  9. package/css/chips.scss +109 -19
  10. package/css/colors.scss +32 -32
  11. package/css/datepicker.scss +336 -336
  12. package/css/defaults.scss +90 -90
  13. package/css/docs.scss +529 -0
  14. package/css/editor.scss +36 -0
  15. package/css/file-uploader.scss +1 -1
  16. package/css/flyout-menu.scss +361 -361
  17. package/css/form.scss +0 -15
  18. package/css/gallery.scss +65 -6
  19. package/css/grid.scss +41 -40
  20. package/css/group-picker.scss +345 -0
  21. package/css/guitar-chords.css +250 -250
  22. package/css/icons.scss +330 -330
  23. package/css/parameters.scss +3 -3
  24. package/css/placeholder.scss +33 -33
  25. package/css/popover.scss +206 -0
  26. package/css/progress.scss +76 -32
  27. package/css/properties.scss +51 -36
  28. package/css/push-menu.scss +302 -174
  29. package/css/reset.scss +39 -39
  30. package/css/scrollbar.scss +62 -5
  31. package/css/sidebar-nav.scss +92 -0
  32. package/css/spinner.scss +65 -65
  33. package/css/stepper.scss +48 -12
  34. package/css/style.css +3155 -254
  35. package/css/style.css.map +1 -1
  36. package/css/style.min.css +1 -1
  37. package/css/style.scss +51 -45
  38. package/css/table.scss +199 -199
  39. package/css/tabs.scss +154 -123
  40. package/css/timeline.scss +83 -38
  41. package/css/timepicker.scss +100 -5
  42. package/css/toast.scss +81 -81
  43. package/css/virtual-dropdown.scss +35 -29
  44. package/js/calendar.js +532 -0
  45. package/js/calendar.ts +706 -0
  46. package/js/chart.js +573 -257
  47. package/js/chart.ts +692 -0
  48. package/js/code-viewer.js +10 -10
  49. package/js/code-viewer.ts +188 -188
  50. package/js/datepicker.ts +627 -627
  51. package/js/docs-nav.js +204 -0
  52. package/js/dropdown.ts +179 -179
  53. package/js/editor.js +50 -6
  54. package/js/editor.ts +483 -444
  55. package/js/file-uploader.js +1 -0
  56. package/js/file-uploader.ts +1 -0
  57. package/js/flyout-menu.js +14 -14
  58. package/js/flyout-menu.ts +249 -249
  59. package/js/form-builder.js +106 -106
  60. package/js/gallery.js +14 -8
  61. package/js/gallery.ts +245 -236
  62. package/js/group-picker.js +342 -0
  63. package/js/group-picker.ts +447 -0
  64. package/js/guitar-chords.js +268 -268
  65. package/js/lazy-loader.js +121 -121
  66. package/js/modal.ts +166 -166
  67. package/js/popover.js +163 -0
  68. package/js/popover.ts +219 -0
  69. package/js/position.js +108 -0
  70. package/js/position.ts +111 -0
  71. package/js/push-menu.js +113 -0
  72. package/js/push-menu.ts +284 -145
  73. package/js/request.js +50 -50
  74. package/js/scroll.ts +47 -47
  75. package/js/scrollbar.js +13 -0
  76. package/js/scrollbar.ts +324 -307
  77. package/js/select.ts +216 -216
  78. package/js/sidebar-nav.js +41 -0
  79. package/js/sidebar-nav.ts +66 -0
  80. package/js/table.ts +452 -452
  81. package/js/tabs.ts +279 -279
  82. package/js/theme.js +17 -6
  83. package/js/theme.ts +234 -224
  84. package/js/toast.ts +137 -137
  85. package/js/tooltip.js +6 -60
  86. package/js/tooltip.ts +184 -251
  87. package/js/tsconfig.json +18 -18
  88. package/js/utils.ts +83 -83
  89. package/js/virtual-dropdown.js +25 -25
  90. package/js/virtual-dropdown.ts +365 -365
  91. package/package.json +37 -39
  92. package/js/index.js +0 -816
  93. package/js/index.ts +0 -987
package/js/chart.ts ADDED
@@ -0,0 +1,692 @@
1
+ // ─── Types ──────────────────────────────────────────────────────────────────
2
+
3
+ export type ChartType = 'line' | 'area' | 'column' | 'bar' | 'pie';
4
+ export type ChartCurve = 'smooth' | 'linear' | 'step';
5
+
6
+ export interface ChartDataPoint {
7
+ label: string;
8
+ value: number;
9
+ }
10
+
11
+ export interface ChartSeries {
12
+ name: string;
13
+ data: ChartDataPoint[];
14
+ color?: string;
15
+ }
16
+
17
+ export interface ChartOptions {
18
+ type: ChartType;
19
+ series: ChartSeries[];
20
+ title?: string;
21
+ subtitle?: string;
22
+ /** Inner chart height in px. Default: 280 */
23
+ height?: number;
24
+ showLegend?: boolean;
25
+ showGrid?: boolean;
26
+ animate?: boolean;
27
+ /** Line interpolation for line/area charts. Default: 'smooth' */
28
+ curve?: ChartCurve;
29
+ /** Fixed y-axis minimum. Default: 0 */
30
+ yMin?: number;
31
+ /** Fixed y-axis maximum. Default: auto (max value × 1.1) */
32
+ yMax?: number;
33
+ onPointClick?: (series: ChartSeries, point: ChartDataPoint, index: number) => void;
34
+ }
35
+
36
+ // ─── Internal ───────────────────────────────────────────────────────────────
37
+
38
+ interface Point { x: number; y: number; }
39
+ interface Margin { top: number; right: number; bottom: number; left: number; }
40
+
41
+ const MARGIN_XY: Margin = { top: 16, right: 24, bottom: 44, left: 52 };
42
+ const MARGIN_BAR: Margin = { top: 8, right: 52, bottom: 24, left: 120 };
43
+ const MARGIN_PIE: Margin = { top: 8, right: 8, bottom: 8, left: 8 };
44
+
45
+ const FALLBACK_COLORS = [
46
+ '#3D63DD', '#2E8B57', '#C28A00', '#D64545',
47
+ '#8B5CF6', '#06B6D4', '#F97316', '#EC4899',
48
+ ];
49
+
50
+ const SVG_NS = 'http://www.w3.org/2000/svg';
51
+
52
+ // ─── Chart ──────────────────────────────────────────────────────────────────
53
+
54
+ class Chart {
55
+ private container: HTMLElement;
56
+ private opts: Required<ChartOptions>;
57
+ private tooltip!: HTMLElement;
58
+ private colors: string[] = [];
59
+ private abortController = new AbortController();
60
+ private resizeTimer: ReturnType<typeof setTimeout> | null = null;
61
+ private resizeObserver: ResizeObserver | null = null;
62
+
63
+ constructor(selector: string | HTMLElement, options: ChartOptions) {
64
+ const el = typeof selector === 'string'
65
+ ? document.querySelector<HTMLElement>(selector)
66
+ : selector;
67
+ if (!el) throw new Error(`Chart: element not found for "${selector}"`);
68
+
69
+ this.container = el;
70
+ this.opts = {
71
+ type: options.type,
72
+ series: options.series,
73
+ title: options.title ?? '',
74
+ subtitle: options.subtitle ?? '',
75
+ height: options.height ?? 280,
76
+ showLegend: options.showLegend ?? true,
77
+ showGrid: options.showGrid ?? true,
78
+ animate: options.animate ?? true,
79
+ curve: options.curve ?? 'smooth',
80
+ yMin: options.yMin ?? 0,
81
+ yMax: options.yMax ?? 0,
82
+ onPointClick: options.onPointClick ?? (() => {}),
83
+ };
84
+
85
+ this.render();
86
+ this.attachResizeObserver();
87
+ }
88
+
89
+ // ── Render ──────────────────────────────────────────────────────────────
90
+
91
+ private render(): void {
92
+ this.abortController.abort();
93
+ this.abortController = new AbortController();
94
+
95
+ this.container.innerHTML = '';
96
+ this.container.classList.add('chart');
97
+ this.resolveColors();
98
+
99
+ if (this.opts.title || this.opts.subtitle) {
100
+ this.container.appendChild(this.buildHeader());
101
+ }
102
+
103
+ const canvas = this.div('chart-canvas');
104
+ this.container.appendChild(canvas);
105
+
106
+ this.tooltip = this.div('chart-tooltip');
107
+ this.container.appendChild(this.tooltip);
108
+
109
+ switch (this.opts.type) {
110
+ case 'line': this.renderLineOrArea(canvas, false); break;
111
+ case 'area': this.renderLineOrArea(canvas, true); break;
112
+ case 'column': this.renderColumn(canvas); break;
113
+ case 'bar': this.renderBar(canvas); break;
114
+ case 'pie': this.renderPie(canvas); break;
115
+ }
116
+
117
+ if (this.opts.showLegend && this.opts.type !== 'pie') {
118
+ this.container.appendChild(this.buildLegend());
119
+ }
120
+ }
121
+
122
+ // ── Line / Area ──────────────────────────────────────────────────────────
123
+
124
+ private renderLineOrArea(canvas: HTMLElement, isArea: boolean): void {
125
+ const { series, height, showGrid, animate, yMin } = this.opts;
126
+ if (!series.length || !series[0].data.length) return;
127
+
128
+ const m = MARGIN_XY;
129
+ const svgW = canvas.clientWidth || 600;
130
+ const svgH = height + m.top + m.bottom;
131
+ const w = svgW - m.left - m.right;
132
+ const h = height;
133
+
134
+ const allValues = series.flatMap(s => s.data.map(d => d.value));
135
+ const yMax = this.opts.yMax || Math.max(...allValues) * 1.1;
136
+ const labels = series[0].data.map(d => d.label);
137
+
138
+ const svg = this.createSVG(canvas, svgW, svgH);
139
+
140
+ if (showGrid) this.renderHGrid(svg, m, w, h, yMin, yMax);
141
+ this.renderXAxisLine(svg, m, w, h);
142
+ this.renderXLabels(svg, m, w, h, labels);
143
+ this.renderYLabels(svg, m, h, yMin, yMax);
144
+
145
+ series.forEach((s, si) => {
146
+ const color = this.colors[si];
147
+ const numPts = s.data.length;
148
+ const pts: Point[] = s.data.map((d, i) => ({
149
+ x: m.left + (numPts > 1 ? (i / (numPts - 1)) * w : w / 2),
150
+ y: m.top + h - ((d.value - yMin) / (yMax - yMin)) * h,
151
+ }));
152
+
153
+ if (isArea) {
154
+ const areaD = `${this.buildPath(pts)} L ${pts[pts.length - 1].x} ${m.top + h} L ${pts[0].x} ${m.top + h} Z`;
155
+ svg.appendChild(this.svgEl('path', {
156
+ d: areaD, fill: color,
157
+ 'fill-opacity': '0.12', stroke: 'none',
158
+ class: 'chart-area',
159
+ }));
160
+ }
161
+
162
+ const linePath = this.svgEl('path', {
163
+ d: this.buildPath(pts), fill: 'none',
164
+ stroke: color, 'stroke-width': '2.5',
165
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
166
+ class: 'chart-line',
167
+ }) as SVGPathElement;
168
+
169
+ if (animate) {
170
+ requestAnimationFrame(() => {
171
+ const len = linePath.getTotalLength();
172
+ linePath.style.setProperty('--path-length', String(Math.ceil(len)));
173
+ });
174
+ }
175
+ svg.appendChild(linePath);
176
+
177
+ // Data point markers
178
+ s.data.forEach((d, i) => {
179
+ const g = this.svgEl('g', {
180
+ class: 'chart-point-group',
181
+ style: animate ? `animation-delay: ${i * 40}ms` : '',
182
+ });
183
+ const { x, y } = pts[i];
184
+
185
+ g.appendChild(this.svgEl('circle', {
186
+ cx: x, cy: y, r: 14,
187
+ fill: 'transparent', class: 'chart-hit',
188
+ }));
189
+ g.appendChild(this.svgEl('circle', {
190
+ cx: x, cy: y, r: 7,
191
+ fill: 'none', stroke: color, 'stroke-width': '2',
192
+ class: 'chart-point-ring',
193
+ }));
194
+ g.appendChild(this.svgEl('circle', {
195
+ cx: x, cy: y, r: 4,
196
+ fill: color, stroke: 'var(--background)', 'stroke-width': '2',
197
+ class: 'chart-point-dot',
198
+ }));
199
+
200
+ this.onPoint(g, s, d, i);
201
+ svg.appendChild(g);
202
+ });
203
+ });
204
+ }
205
+
206
+ // ── Column ───────────────────────────────────────────────────────────────
207
+
208
+ private renderColumn(canvas: HTMLElement): void {
209
+ const { series, height, showGrid, animate, yMin } = this.opts;
210
+ if (!series.length || !series[0].data.length) return;
211
+
212
+ const m = MARGIN_XY;
213
+ const svgW = canvas.clientWidth || 600;
214
+ const svgH = height + m.top + m.bottom;
215
+ const w = svgW - m.left - m.right;
216
+ const h = height;
217
+
218
+ const allValues = series.flatMap(s => s.data.map(d => d.value));
219
+ const yMax = this.opts.yMax || Math.max(...allValues) * 1.1;
220
+ const labels = series[0].data.map(d => d.label);
221
+ const numPts = labels.length;
222
+ const numSeries = series.length;
223
+
224
+ const svg = this.createSVG(canvas, svgW, svgH);
225
+
226
+ if (showGrid) this.renderHGrid(svg, m, w, h, yMin, yMax);
227
+ this.renderXAxisLine(svg, m, w, h);
228
+ this.renderXLabels(svg, m, w, h, labels);
229
+ this.renderYLabels(svg, m, h, yMin, yMax);
230
+
231
+ const groupW = w / numPts;
232
+ const innerPad = groupW * 0.18;
233
+ const barW = Math.max(2, (groupW - innerPad) / numSeries - 2);
234
+
235
+ series.forEach((s, si) => {
236
+ const color = this.colors[si];
237
+ s.data.forEach((d, i) => {
238
+ const barH = Math.max(0, ((d.value - yMin) / (yMax - yMin)) * h);
239
+ const x = m.left + i * groupW + innerPad / 2 + si * (barW + 2);
240
+ const y = m.top + h - barH;
241
+
242
+ const rect = this.svgEl('rect', {
243
+ x, y, width: barW, height: barH,
244
+ fill: color, rx: 3,
245
+ class: 'chart-bar chart-bar--vertical',
246
+ }) as SVGElement;
247
+
248
+ if (animate) {
249
+ const delay = (i * numSeries + si) * 50;
250
+ rect.style.setProperty('--animation-delay', `${delay}ms`);
251
+ rect.style.animationDelay = `${delay}ms`;
252
+ }
253
+
254
+ this.onBar(rect, s, d, i);
255
+ svg.appendChild(rect);
256
+ });
257
+ });
258
+ }
259
+
260
+ // ── Bar (horizontal) ─────────────────────────────────────────────────────
261
+
262
+ private renderBar(canvas: HTMLElement): void {
263
+ const { series, height, animate } = this.opts;
264
+ if (!series.length || !series[0].data.length) return;
265
+
266
+ const m = MARGIN_BAR;
267
+ const svgW = canvas.clientWidth || 600;
268
+ const svgH = height + m.top + m.bottom;
269
+ const w = svgW - m.left - m.right;
270
+ const h = height;
271
+
272
+ const allValues = series.flatMap(s => s.data.map(d => d.value));
273
+ const xMax = this.opts.yMax || Math.max(...allValues) * 1.1;
274
+ const labels = series[0].data.map(d => d.label);
275
+ const numPts = labels.length;
276
+ const numSeries = series.length;
277
+
278
+ const svg = this.createSVG(canvas, svgW, svgH);
279
+
280
+ // Vertical grid lines
281
+ const numTicks = 5;
282
+ for (let t = 0; t <= numTicks; t++) {
283
+ const x = m.left + (t / numTicks) * w;
284
+ svg.appendChild(this.svgEl('line', {
285
+ x1: x, x2: x, y1: m.top, y2: m.top + h,
286
+ stroke: 'var(--divider)', 'stroke-width': '1',
287
+ 'stroke-dasharray': t === 0 ? 'none' : '3 4',
288
+ class: 'chart-grid-line',
289
+ }));
290
+ const label = this.svgEl('text', {
291
+ x, y: m.top + h + 14,
292
+ 'text-anchor': 'middle', class: 'chart-axis-label',
293
+ });
294
+ label.textContent = this.fmt(xMax * t / numTicks);
295
+ svg.appendChild(label);
296
+ }
297
+
298
+ // Category labels on Y axis
299
+ const groupH = h / numPts;
300
+ labels.forEach((label, i) => {
301
+ const y = m.top + i * groupH + groupH / 2;
302
+ const text = this.svgEl('text', {
303
+ x: m.left - 10, y,
304
+ 'text-anchor': 'end', 'dominant-baseline': 'middle',
305
+ class: 'chart-axis-label',
306
+ });
307
+ text.textContent = label;
308
+ svg.appendChild(text);
309
+ });
310
+
311
+ // Bars
312
+ const innerPad = groupH * 0.18;
313
+ const barH = Math.max(2, (groupH - innerPad) / numSeries - 2);
314
+
315
+ series.forEach((s, si) => {
316
+ const color = this.colors[si];
317
+ s.data.forEach((d, i) => {
318
+ const barW = Math.max(0, (d.value / xMax) * w);
319
+ const x = m.left;
320
+ const y = m.top + i * groupH + innerPad / 2 + si * (barH + 2);
321
+
322
+ const rect = this.svgEl('rect', {
323
+ x, y, width: barW, height: barH,
324
+ fill: color, rx: 3,
325
+ class: 'chart-bar chart-bar--horizontal',
326
+ }) as SVGElement;
327
+
328
+ if (animate) {
329
+ const delay = (i * numSeries + si) * 50;
330
+ rect.style.setProperty('--animation-delay', `${delay}ms`);
331
+ rect.style.animationDelay = `${delay}ms`;
332
+ }
333
+
334
+ this.onBar(rect, s, d, i);
335
+ svg.appendChild(rect);
336
+ });
337
+ });
338
+ }
339
+
340
+ // ── Pie ──────────────────────────────────────────────────────────────────
341
+
342
+ private renderPie(canvas: HTMLElement): void {
343
+ const { series, height, animate, showLegend } = this.opts;
344
+ const s = series[0];
345
+ if (!s || !s.data.length) return;
346
+
347
+ const svgW = canvas.clientWidth || 400;
348
+ const m = MARGIN_PIE;
349
+ const svgH = height + m.top + m.bottom;
350
+ const cx = svgW / 2;
351
+ const cy = svgH / 2;
352
+ const r = Math.min(svgW, svgH) / 2 - Math.max(m.top, m.left) - 8;
353
+
354
+ const total = s.data.reduce((sum, d) => sum + d.value, 0);
355
+ const svg = this.createSVG(canvas, svgW, svgH);
356
+
357
+ let startAngle = -90; // start at 12 o'clock
358
+
359
+ s.data.forEach((d, i) => {
360
+ const color = this.colors[i % this.colors.length];
361
+ const sweep = (d.value / total) * 360;
362
+ const endAngle = startAngle + sweep;
363
+ const midAngle = startAngle + sweep / 2;
364
+
365
+ const path = this.svgEl('path', {
366
+ d: this.arcPath(cx, cy, r, startAngle, endAngle),
367
+ fill: color,
368
+ stroke: 'var(--background)',
369
+ 'stroke-width': '2',
370
+ class: 'chart-slice',
371
+ }) as SVGPathElement;
372
+
373
+ if (animate) {
374
+ const delay = i * 70;
375
+ path.style.animationDelay = `${delay}ms`;
376
+ }
377
+
378
+ // Hover: nudge slice outward
379
+ const { x: dx, y: dy } = this.polar(0, 0, 8, midAngle);
380
+ path.addEventListener('mouseenter', (e) => {
381
+ path.style.transform = `translate(${dx}px, ${dy}px)`;
382
+ this.showTooltip(e as MouseEvent,
383
+ `<strong>${d.label}</strong>${this.fmt(d.value)} &nbsp;·&nbsp; ${((d.value / total) * 100).toFixed(1)}%`
384
+ );
385
+ }, { signal: this.abortController.signal });
386
+
387
+ path.addEventListener('mouseleave', () => {
388
+ path.style.transform = '';
389
+ this.hideTooltip();
390
+ }, { signal: this.abortController.signal });
391
+
392
+ path.addEventListener('click', () => {
393
+ this.opts.onPointClick(s, d, i);
394
+ }, { signal: this.abortController.signal });
395
+
396
+ svg.appendChild(path);
397
+ startAngle = endAngle;
398
+ });
399
+
400
+ if (showLegend) {
401
+ this.container.appendChild(this.buildPieLegend(s, total));
402
+ }
403
+ }
404
+
405
+ // ── Axis helpers ─────────────────────────────────────────────────────────
406
+
407
+ private renderHGrid(svg: SVGSVGElement, m: Margin, w: number, h: number, yMin: number, yMax: number): void {
408
+ const numTicks = 5;
409
+ for (let i = 0; i <= numTicks; i++) {
410
+ const y = m.top + h - (i / numTicks) * h;
411
+ svg.appendChild(this.svgEl('line', {
412
+ x1: m.left, x2: m.left + w, y1: y, y2: y,
413
+ class: i === 0 ? 'chart-axis-line' : 'chart-grid-line',
414
+ }));
415
+ }
416
+ }
417
+
418
+ private renderXAxisLine(svg: SVGSVGElement, m: Margin, w: number, h: number): void {
419
+ svg.appendChild(this.svgEl('line', {
420
+ x1: m.left, x2: m.left + w,
421
+ y1: m.top + h, y2: m.top + h,
422
+ class: 'chart-axis-line',
423
+ }));
424
+ }
425
+
426
+ private renderXLabels(svg: SVGSVGElement, m: Margin, w: number, h: number, labels: string[]): void {
427
+ const n = labels.length;
428
+ const step = n > 1 ? w / (n - 1) : w / 2;
429
+ labels.forEach((label, i) => {
430
+ const x = m.left + (n > 1 ? i * step : w / 2);
431
+ const text = this.svgEl('text', {
432
+ x, y: m.top + h + 18,
433
+ 'text-anchor': 'middle', class: 'chart-axis-label',
434
+ });
435
+ text.textContent = label;
436
+ svg.appendChild(text);
437
+ });
438
+ }
439
+
440
+ private renderYLabels(svg: SVGSVGElement, m: Margin, h: number, yMin: number, yMax: number): void {
441
+ const numTicks = 5;
442
+ for (let i = 0; i <= numTicks; i++) {
443
+ const val = yMin + (yMax - yMin) * (i / numTicks);
444
+ const y = m.top + h - (i / numTicks) * h;
445
+ const text = this.svgEl('text', {
446
+ x: m.left - 8, y,
447
+ 'text-anchor': 'end', 'dominant-baseline': 'middle',
448
+ class: 'chart-axis-label',
449
+ });
450
+ text.textContent = this.fmt(val);
451
+ svg.appendChild(text);
452
+ }
453
+ }
454
+
455
+ // ── Geometry helpers ─────────────────────────────────────────────────────
456
+
457
+ private buildPath(pts: Point[]): string {
458
+ switch (this.opts.curve) {
459
+ case 'linear': return this.linearPath(pts);
460
+ case 'step': return this.stepPath(pts);
461
+ default: return this.smoothPath(pts);
462
+ }
463
+ }
464
+
465
+ private linearPath(pts: Point[]): string {
466
+ if (pts.length === 0) return '';
467
+ return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
468
+ }
469
+
470
+ private stepPath(pts: Point[]): string {
471
+ if (pts.length === 0) return '';
472
+ let d = `M ${pts[0].x} ${pts[0].y}`;
473
+ for (let i = 1; i < pts.length; i++) {
474
+ d += ` H ${pts[i].x} V ${pts[i].y}`;
475
+ }
476
+ return d;
477
+ }
478
+
479
+ /** Smooth cubic bezier path through points (Catmull-Rom → cubic bezier) */
480
+ private smoothPath(pts: Point[]): string {
481
+ if (pts.length === 0) return '';
482
+ if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
483
+ if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}`;
484
+
485
+ const t = 0.35;
486
+ let d = `M ${pts[0].x} ${pts[0].y}`;
487
+ for (let i = 0; i < pts.length - 1; i++) {
488
+ const p0 = pts[Math.max(0, i - 1)];
489
+ const p1 = pts[i];
490
+ const p2 = pts[i + 1];
491
+ const p3 = pts[Math.min(pts.length - 1, i + 2)];
492
+ const cp1x = p1.x + (p2.x - p0.x) * t;
493
+ const cp1y = p1.y + (p2.y - p0.y) * t;
494
+ const cp2x = p2.x - (p3.x - p1.x) * t;
495
+ const cp2y = p2.y - (p3.y - p1.y) * t;
496
+ d += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${p2.x} ${p2.y}`;
497
+ }
498
+ return d;
499
+ }
500
+
501
+ private arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number): string {
502
+ const start = this.polar(cx, cy, r, startDeg);
503
+ const end = this.polar(cx, cy, r, endDeg);
504
+ const large = (endDeg - startDeg) > 180 ? 1 : 0;
505
+ return `M ${cx} ${cy} L ${start.x.toFixed(2)} ${start.y.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${end.x.toFixed(2)} ${end.y.toFixed(2)} Z`;
506
+ }
507
+
508
+ private polar(cx: number, cy: number, r: number, deg: number): Point {
509
+ const rad = deg * Math.PI / 180;
510
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
511
+ }
512
+
513
+ // ── Legend builders ──────────────────────────────────────────────────────
514
+
515
+ private buildHeader(): HTMLElement {
516
+ const el = this.div('chart-header');
517
+ if (this.opts.title) {
518
+ const t = this.div('chart-title');
519
+ t.textContent = this.opts.title;
520
+ el.appendChild(t);
521
+ }
522
+ if (this.opts.subtitle) {
523
+ const s = this.div('chart-subtitle');
524
+ s.textContent = this.opts.subtitle;
525
+ el.appendChild(s);
526
+ }
527
+ return el;
528
+ }
529
+
530
+ private buildLegend(): HTMLElement {
531
+ const el = this.div('chart-legend');
532
+ this.opts.series.forEach((s, i) => {
533
+ const item = this.div('chart-legend-item');
534
+ const swatch = this.div('chart-legend-swatch');
535
+ swatch.style.background = this.colors[i];
536
+ const label = document.createElement('span');
537
+ label.textContent = s.name;
538
+ item.append(swatch, label);
539
+ el.appendChild(item);
540
+ });
541
+ return el;
542
+ }
543
+
544
+ private buildPieLegend(s: ChartSeries, total: number): HTMLElement {
545
+ const el = this.div('chart-pie-legend');
546
+ s.data.forEach((d, i) => {
547
+ const color = this.colors[i % this.colors.length];
548
+ const item = this.div('chart-pie-legend-item');
549
+ const swatch = this.div('chart-pie-legend-swatch');
550
+ swatch.style.background = color;
551
+ const label = document.createElement('span');
552
+ label.textContent = d.label;
553
+ const value = this.div('chart-pie-legend-value');
554
+ value.textContent = `${((d.value / total) * 100).toFixed(1)}%`;
555
+ item.append(swatch, label, value);
556
+ el.appendChild(item);
557
+ });
558
+ return el;
559
+ }
560
+
561
+ // ── Tooltip ──────────────────────────────────────────────────────────────
562
+
563
+ private showTooltip(e: MouseEvent, html: string): void {
564
+ this.tooltip.innerHTML = html;
565
+ this.tooltip.classList.add('is-visible');
566
+ this.moveTooltip(e);
567
+ }
568
+
569
+ private moveTooltip(e: MouseEvent): void {
570
+ const tt = this.tooltip;
571
+ const vw = window.innerWidth;
572
+ const vh = window.innerHeight;
573
+ let x = e.clientX + 14;
574
+ let y = e.clientY - 36;
575
+ // Keep inside viewport
576
+ if (x + 200 > vw) x = e.clientX - 14 - tt.offsetWidth;
577
+ if (y < 0) y = e.clientY + 14;
578
+ if (y + tt.offsetHeight > vh) y = vh - tt.offsetHeight - 8;
579
+ tt.style.left = `${x}px`;
580
+ tt.style.top = `${y}px`;
581
+ }
582
+
583
+ private hideTooltip(): void {
584
+ this.tooltip.classList.remove('is-visible');
585
+ }
586
+
587
+ // ── Event wiring ─────────────────────────────────────────────────────────
588
+
589
+ private onPoint(g: SVGElement, s: ChartSeries, d: ChartDataPoint, i: number): void {
590
+ const sig = { signal: this.abortController.signal };
591
+ g.addEventListener('mouseenter', (e) => {
592
+ this.showTooltip(e as MouseEvent, `<strong>${d.label}</strong>${s.name}: ${this.fmt(d.value)}`);
593
+ }, sig);
594
+ g.addEventListener('mousemove', (e) => this.moveTooltip(e as MouseEvent), sig);
595
+ g.addEventListener('mouseleave', () => this.hideTooltip(), sig);
596
+ g.addEventListener('click', () => this.opts.onPointClick(s, d, i), sig);
597
+ }
598
+
599
+ private onBar(rect: SVGElement, s: ChartSeries, d: ChartDataPoint, i: number): void {
600
+ const sig = { signal: this.abortController.signal };
601
+ rect.style.cursor = 'pointer';
602
+ rect.addEventListener('mouseenter', (e) => {
603
+ this.showTooltip(e as MouseEvent, `<strong>${d.label}</strong>${s.name}: ${this.fmt(d.value)}`);
604
+ }, sig);
605
+ rect.addEventListener('mousemove', (e) => this.moveTooltip(e as MouseEvent), sig);
606
+ rect.addEventListener('mouseleave', () => this.hideTooltip(), sig);
607
+ rect.addEventListener('click', () => this.opts.onPointClick(s, d, i), sig);
608
+ }
609
+
610
+ // ── Color resolution ─────────────────────────────────────────────────────
611
+
612
+ private resolveColors(): void {
613
+ const style = getComputedStyle(this.container);
614
+ this.colors = (this.opts.type === 'pie' ? this.opts.series[0]?.data ?? [] : this.opts.series)
615
+ .map((_, i) => {
616
+ const css = style.getPropertyValue(`--chart-color-${i + 1}`).trim();
617
+ return css || FALLBACK_COLORS[i % FALLBACK_COLORS.length];
618
+ });
619
+
620
+ // Allow per-series color override (not pie)
621
+ if (this.opts.type !== 'pie') {
622
+ this.opts.series.forEach((s, i) => {
623
+ if (s.color) this.colors[i] = s.color;
624
+ });
625
+ }
626
+ }
627
+
628
+ // ── DOM & SVG helpers ────────────────────────────────────────────────────
629
+
630
+ private div(className: string): HTMLElement {
631
+ const el = document.createElement('div');
632
+ el.className = className;
633
+ return el;
634
+ }
635
+
636
+ private createSVG(parent: HTMLElement, w: number, h: number): SVGSVGElement {
637
+ const svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement;
638
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
639
+ svg.setAttribute('height', String(h));
640
+ svg.setAttribute('preserveAspectRatio', 'none');
641
+ svg.classList.add('chart-svg');
642
+ parent.appendChild(svg);
643
+ return svg;
644
+ }
645
+
646
+ private svgEl<K extends keyof SVGElementTagNameMap>(
647
+ tag: K,
648
+ attrs: Record<string, string | number> = {}
649
+ ): SVGElementTagNameMap[K] {
650
+ const el = document.createElementNS(SVG_NS, tag);
651
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
652
+ return el;
653
+ }
654
+
655
+ private fmt(v: number): string {
656
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
657
+ if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
658
+ return v % 1 === 0 ? String(Math.round(v)) : v.toFixed(1);
659
+ }
660
+
661
+ // ── Resize ───────────────────────────────────────────────────────────────
662
+
663
+ private attachResizeObserver(): void {
664
+ this.resizeObserver = new ResizeObserver(() => {
665
+ if (this.resizeTimer) clearTimeout(this.resizeTimer);
666
+ this.resizeTimer = setTimeout(() => this.render(), 100);
667
+ });
668
+ this.resizeObserver.observe(this.container);
669
+ }
670
+
671
+ // ── Public API ───────────────────────────────────────────────────────────
672
+
673
+ public update(series: ChartSeries[]): void {
674
+ this.opts.series = series;
675
+ this.render();
676
+ }
677
+
678
+ public setType(type: ChartType): void {
679
+ this.opts.type = type;
680
+ this.render();
681
+ }
682
+
683
+ public destroy(): void {
684
+ this.abortController.abort();
685
+ this.resizeObserver?.disconnect();
686
+ if (this.resizeTimer) clearTimeout(this.resizeTimer);
687
+ this.container.innerHTML = '';
688
+ this.container.classList.remove('chart');
689
+ }
690
+ }
691
+
692
+ export { Chart };