@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.
- package/README.md +1 -1
- package/js/bottom-sheet.d.ts +37 -0
- package/js/calendar.d.ts +115 -0
- package/js/carousel.d.ts +34 -0
- package/js/chart.d.ts +73 -0
- package/js/code-viewer.d.ts +16 -0
- package/js/context-menu.d.ts +31 -0
- package/js/datepicker.d.ts +55 -0
- package/js/dropdown.d.ts +30 -0
- package/js/editor.d.ts +41 -0
- package/js/file-uploader.d.ts +48 -0
- package/js/flyout-menu.d.ts +37 -0
- package/js/gallery.d.ts +35 -0
- package/js/group-picker.d.ts +59 -0
- package/js/lightbox.d.ts +46 -0
- package/js/modal.d.ts +28 -0
- package/js/popover.d.ts +46 -0
- package/js/position.d.ts +31 -0
- package/js/push-menu.d.ts +31 -0
- package/js/range-slider.d.ts +9 -0
- package/js/scroll.d.ts +15 -0
- package/js/scrollbar.d.ts +48 -0
- package/js/select.d.ts +16 -0
- package/js/sidebar-nav.d.ts +22 -0
- package/js/stepper.d.ts +26 -0
- package/js/table.d.ts +98 -0
- package/js/tabs.d.ts +57 -0
- package/js/theme.d.ts +65 -0
- package/js/timepicker.d.ts +37 -0
- package/js/toast.d.ts +26 -0
- package/js/tooltip.d.ts +34 -0
- package/js/tree.d.ts +40 -0
- package/js/utils.d.ts +24 -0
- package/js/virtual-dropdown.d.ts +55 -0
- package/package.json +1 -1
- package/js/bottom-sheet.ts +0 -224
- package/js/calendar.ts +0 -774
- package/js/carousel.ts +0 -222
- package/js/chart.ts +0 -694
- package/js/code-viewer.ts +0 -188
- package/js/context-menu.ts +0 -252
- package/js/datepicker.ts +0 -640
- package/js/dropdown.ts +0 -180
- package/js/editor.ts +0 -492
- package/js/file-uploader.ts +0 -361
- package/js/flyout-menu.ts +0 -255
- package/js/gallery.ts +0 -237
- package/js/group-picker.ts +0 -451
- package/js/lightbox.ts +0 -333
- package/js/modal.ts +0 -171
- package/js/popover.ts +0 -221
- package/js/position.ts +0 -111
- package/js/push-menu.ts +0 -286
- package/js/range-slider.ts +0 -33
- package/js/scroll.ts +0 -47
- package/js/scrollbar.ts +0 -335
- package/js/select.ts +0 -235
- package/js/sidebar-nav.ts +0 -66
- package/js/stepper.ts +0 -109
- package/js/table.ts +0 -459
- package/js/tabs.ts +0 -280
- package/js/theme.ts +0 -235
- package/js/timepicker.ts +0 -202
- package/js/toast.ts +0 -134
- package/js/tooltip.ts +0 -196
- package/js/tree.ts +0 -244
- package/js/tsconfig.json +0 -18
- package/js/utils.ts +0 -119
- 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)} · ${((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 };
|