@dodlhuat/basix 1.2.7 → 1.2.9

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