mnfst 0.5.119 → 0.5.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,593 @@
1
+ /* Manifest Charts
2
+ /* By Andrew Matlock under MIT license
3
+ /* https://manifestx.dev
4
+ /*
5
+ /* An in-house SVG chart renderer. SVG (not canvas) so charts inherit theme
6
+ /* colors via CSS variables, are restylable with the same selector
7
+ /* conventions as every other element, survive static prerendering as real
8
+ /* DOM, and expose accessible <title>/<desc>. The only dependencies are the
9
+ /* d3-scale / d3-shape / d3-array micro-modules (ISC, ~22KB), lazy-loaded
10
+ /* from esm.run on first scroll-into-view — the same posture as the code
11
+ /* plugin loading highlight.js on demand.
12
+ */
13
+
14
+ (function () {
15
+ 'use strict';
16
+
17
+ /* ------------------------------------------------------------------ *
18
+ * Shared global: ManifestUI (universal `_ui` resolver). Defined guarded so
19
+ * charts works whether or not the date picker (which also defines it) is
20
+ * loaded. `_ui` is a reserved, self-identifying key: any loaded data source
21
+ * may carry a top-level `_ui` object, namespaced per element (`_ui.charts`,
22
+ * `_ui.colorpicker`, …); no manifest flag — overrides piggyback on the normal
23
+ * local-data/localization model. resolve() deep-merges every loaded source's
24
+ * `_ui[component]` onto the plugin's English fallbacks. Kept byte-identical
25
+ * across the date picker / color picker copies.
26
+ * ------------------------------------------------------------------ */
27
+ if (!window.ManifestUI) {
28
+ window.ManifestUI = {
29
+ /* Names of data sources that have loaded (current locale). Enumerates loaded
30
+ * sources only — never force-loads others just to scan them for `_ui`. */
31
+ _loadedSourceNames() {
32
+ try {
33
+ const store = window.ManifestDataStore && window.ManifestDataStore.rawDataStore;
34
+ if (store && typeof store.keys === 'function') return [...store.keys()];
35
+ } catch (_) { }
36
+ return [];
37
+ },
38
+ /* Deep-merge every loaded source's `_ui[component]` onto `fallbacks`.
39
+ * Reads inside the caller's Alpine effect (if any) so $x/$locale make it reactive. */
40
+ resolve(component, fallbacks) {
41
+ const merged = JSON.parse(JSON.stringify(fallbacks || {}));
42
+ try {
43
+ if (!window.Alpine || typeof Alpine.evaluate !== 'function') return merged;
44
+ try { Alpine.evaluate(document.body, '$locale && $locale.current'); } catch (_) { } // dep → re-resolve on locale switch
45
+ for (const name of this._loadedSourceNames()) {
46
+ let ui;
47
+ try { ui = Alpine.evaluate(document.body, `$x['${name}'] && $x['${name}']._ui && $x['${name}']._ui['${component}']`); } catch (_) { ui = null; }
48
+ if (ui && typeof ui === 'object' && !Array.isArray(ui)) this._deepOverlay(merged, ui);
49
+ }
50
+ } catch (_) { }
51
+ return merged;
52
+ },
53
+ _deepOverlay(target, src) {
54
+ for (const k of Object.keys(src)) {
55
+ if (k.startsWith('$') || k === 'contentType' || k === 'valueOf' || k === 'toString') continue;
56
+ const v = src[k];
57
+ if (typeof v === 'function') continue;
58
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
59
+ if (!target[k] || typeof target[k] !== 'object') target[k] = {};
60
+ this._deepOverlay(target[k], v);
61
+ } else if (v !== undefined && v !== null && v !== '') {
62
+ target[k] = v;
63
+ }
64
+ }
65
+ }
66
+ };
67
+ }
68
+
69
+ const SVGNS = 'http://www.w3.org/2000/svg';
70
+
71
+ /* ---- Lazy-load d3 micro-modules once ---------------------------- */
72
+ let d3Promise = null;
73
+ function loadD3() {
74
+ if (window.__manifestD3) return Promise.resolve(window.__manifestD3);
75
+ if (d3Promise) return d3Promise;
76
+ d3Promise = Promise.all([
77
+ import('https://esm.run/d3-scale@4'),
78
+ import('https://esm.run/d3-shape@3'),
79
+ import('https://esm.run/d3-array@3')
80
+ ]).then(([scale, shape, array]) => {
81
+ const d3 = { ...scale, ...shape, ...array };
82
+ window.__manifestD3 = d3;
83
+ return d3;
84
+ }).catch((err) => { d3Promise = null; throw err; });
85
+ return d3Promise;
86
+ }
87
+
88
+ const prefersReducedMotion = () => { try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; } };
89
+
90
+ function initializeChartsPlugin() {
91
+ const Alpine = window.Alpine;
92
+ const _registry = Alpine.reactive ? Alpine.reactive({}) : {};
93
+ let _uid = 0;
94
+
95
+ const _nullApi = { type: '', series: [], update() { }, redraw() { }, toString() { return ''; }, valueOf() { return ''; } };
96
+
97
+ function findAncestorState(el) { let n = el; while (n) { if (n._chartState) return n._chartState; n = n.parentElement; } return null; }
98
+
99
+ /* ---- One shared IntersectionObserver: defer load until visible ---- */
100
+ let io = null;
101
+ function observer() {
102
+ if (io) return io;
103
+ io = new IntersectionObserver((entries, obs) => {
104
+ for (const e of entries) {
105
+ if (!e.isIntersecting) continue;
106
+ const t = e.target;
107
+ if (typeof t.checkVisibility === 'function' && !t.checkVisibility()) continue;
108
+ obs.unobserve(t);
109
+ const state = t._chartState;
110
+ if (state) state.activate();
111
+ }
112
+ }, { rootMargin: '100px', threshold: 0 });
113
+ return io;
114
+ }
115
+
116
+ /* ---- Config normalization ----------------------------------- */
117
+ function num(v, d) { const n = Number(v); return isNaN(n) ? d : n; }
118
+
119
+ function configFromDom(el) {
120
+ // Declarative authoring: <figure x-chart.line><data series="Revenue" :values="..."></data></figure>
121
+ const cfg = { series: [], labels: [] };
122
+ const labelsAttr = el.getAttribute('labels');
123
+ if (labelsAttr) { try { cfg.labels = Alpine.evaluate(el, labelsAttr); } catch (_) { } }
124
+ el.querySelectorAll(':scope > data, :scope > .chart-series').forEach((node) => {
125
+ if (node.hasAttribute('labels')) {
126
+ const lx = node.getAttribute(':values') || node.getAttribute('values') || node.getAttribute('labels');
127
+ try { cfg.labels = Alpine.evaluate(el, lx); } catch (_) { }
128
+ return;
129
+ }
130
+ const name = node.getAttribute('series') || node.getAttribute('name') || '';
131
+ const valExpr = node.getAttribute(':values') || node.getAttribute('values');
132
+ let data = [];
133
+ if (valExpr) { try { data = Alpine.evaluate(el, valExpr) || []; } catch (_) { data = []; } }
134
+ const color = node.getAttribute('color') || node.getAttribute(':color') || '';
135
+ cfg.series.push({ name, data: Array.isArray(data) ? data : [], color });
136
+ });
137
+ return cfg;
138
+ }
139
+
140
+ function normalize(raw, el, typeFromModifier) {
141
+ const cfg = Object.assign({}, raw || {});
142
+ cfg.type = (cfg.type || typeFromModifier || 'line').toLowerCase();
143
+ cfg.height = num(cfg.height || el.getAttribute('height'), 240);
144
+ cfg.stacked = !!cfg.stacked;
145
+ cfg.legend = cfg.legend !== false;
146
+ cfg.axis = cfg.axis !== false;
147
+ cfg.grid = cfg.grid !== false;
148
+ cfg.tooltip = cfg.tooltip !== false; // hover tooltips on by default
149
+ cfg.dataLabels = !!cfg.dataLabels; // static value labels off by default
150
+ cfg.labels = Array.isArray(cfg.labels) ? cfg.labels : [];
151
+
152
+ // Series may be omitted in favor of a single `data` array.
153
+ if (!cfg.series && cfg.data) cfg.series = [{ name: cfg.name || '', data: cfg.data }];
154
+ if (!Array.isArray(cfg.series)) cfg.series = [];
155
+
156
+ // Pie/donut: accept [{label,value}] in data.
157
+ if ((cfg.type === 'pie' || cfg.type === 'donut') && cfg.series.length) {
158
+ const s = cfg.series[0];
159
+ if (Array.isArray(s.data) && s.data.length && typeof s.data[0] === 'object') {
160
+ cfg.labels = s.data.map(d => d.label);
161
+ s.data = s.data.map(d => num(d.value, 0));
162
+ }
163
+ }
164
+ return cfg;
165
+ }
166
+
167
+ /* ---- SVG helpers -------------------------------------------- */
168
+ function svg(tag, attrs, parent) {
169
+ const node = document.createElementNS(SVGNS, tag);
170
+ if (attrs) for (const k in attrs) { if (attrs[k] != null) node.setAttribute(k, attrs[k]); }
171
+ if (parent) parent.appendChild(node);
172
+ return node;
173
+ }
174
+ function text(parent, str, attrs) {
175
+ const t = svg('text', attrs, parent);
176
+ t.appendChild(document.createTextNode(str == null ? '' : String(str))); // untrusted-safe
177
+ return t;
178
+ }
179
+ function animate(el, keyframes, opts) {
180
+ if (prefersReducedMotion() || typeof el.animate !== 'function') return;
181
+ try { el.animate(keyframes, Object.assign({ duration: 600, easing: 'cubic-bezier(0.22,1,0.36,1)', fill: 'backwards' }, opts)); } catch (_) { }
182
+ }
183
+ // Cursor-following tooltip. Manifest's x-tooltip relies on CSS anchor
184
+ // positioning, which can't anchor to SVG child elements (no CSS-layout
185
+ // box) — so charts use their own tip, themed to match, following the
186
+ // pointer (better UX for dense charts). aria-label carries AT semantics.
187
+ function applyTip(seg, tip, cfg) {
188
+ seg.setAttribute('aria-label', tip);
189
+ if (!cfg.tooltip) return;
190
+ // .chart marker, not [x-chart] — modified directives (x-chart.line)
191
+ // are a different attribute name and closest() can't prefix-match.
192
+ const host = seg.closest('.chart');
193
+ if (!host) return;
194
+ const show = (e) => {
195
+ const state = host._chartState; if (!state) return;
196
+ let t = state.tip;
197
+ if (!t || !t.isConnected) { t = document.createElement('div'); t.className = 'tooltip'; t.setAttribute('role', 'tooltip'); host.appendChild(t); state.tip = t; }
198
+ t.textContent = tip; // untrusted-safe
199
+ t.classList.add('active');
200
+ const rect = host.getBoundingClientRect(), half = (t.offsetWidth || 0) / 2;
201
+ const x = Math.max(half + 2, Math.min(e.clientX - rect.left, rect.width - half - 2));
202
+ t.style.left = x + 'px';
203
+ t.style.top = (e.clientY - rect.top) + 'px';
204
+ };
205
+ seg.addEventListener('mouseenter', show);
206
+ seg.addEventListener('mousemove', show);
207
+ seg.addEventListener('mouseleave', () => { const t = host._chartState && host._chartState.tip; if (t) t.classList.remove('active'); });
208
+ }
209
+ function dataLabel(parent, str, x, y, anchor, baseline, cls) {
210
+ return text(parent, str, { class: 'value' + (cls ? ' ' + cls : ''), x: x, y: y, 'text-anchor': anchor || 'middle', 'dominant-baseline': baseline || 'auto' });
211
+ }
212
+
213
+ /* ---- Per-chart state ---------------------------------------- */
214
+ function createState(el, expression, modifiers) {
215
+ const state = {
216
+ el, expression, typeFromModifier: modifiers[0],
217
+ id: el.id || ('mnfst-chart-' + (++_uid)),
218
+ config: null, d3: null, active: false, _raf: 0,
219
+
220
+ get api() {
221
+ const self = this;
222
+ return {
223
+ get type() { return self.config ? self.config.type : ''; },
224
+ get series() { return self.config ? self.config.series : []; },
225
+ update(cfg) { self.config = normalize(cfg, self.el, self.typeFromModifier); self.draw(); },
226
+ redraw() { self.draw(); },
227
+ toString() { return self.config ? self.config.type : ''; }
228
+ };
229
+ },
230
+
231
+ activate() {
232
+ if (this.active) return;
233
+ this.active = true;
234
+ loadD3().then(d3 => { this.d3 = d3; this.bindReactive(); })
235
+ .catch(() => { this.renderError('Chart engine failed to load.'); });
236
+ },
237
+
238
+ bindReactive() {
239
+ const self = this;
240
+ const getCfg = self.expression ? Alpine.evaluateLater(self.el, self.expression) : null;
241
+ Alpine.effect(() => {
242
+ // Subscribe to the data store heartbeat so $x loads/locale reloads re-run.
243
+ try { void Alpine.store('data')?._dataVersion; } catch (_) { }
244
+ if (getCfg) getCfg(raw => { self.config = normalize(raw, self.el, self.typeFromModifier); self.schedule(); });
245
+ else { self.config = normalize(configFromDom(self.el), self.el, self.typeFromModifier); self.schedule(); }
246
+ });
247
+ // Re-render on locale (axis number/date formatting) and container resize.
248
+ self._onLocale = () => self.schedule();
249
+ window.addEventListener('localechange', self._onLocale);
250
+ self._ro = new ResizeObserver(() => self.schedule());
251
+ self._ro.observe(self.el);
252
+ if (self.el.id) _registry[self.el.id] = self.api;
253
+ },
254
+
255
+ // Coalesce to one draw per tick. Uses setTimeout (not rAF) so draws
256
+ // still happen when the tab is backgrounded (rAF is paused for hidden
257
+ // tabs), and does NOT reset a pending timer on re-entry — a
258
+ // high-frequency reactive trigger (e.g. a plugin bumping the data-store
259
+ // version every tick) would otherwise perpetually reschedule and starve
260
+ // the draw.
261
+ schedule() { if (this._t) return; this._t = setTimeout(() => { this._t = null; this.draw(); }, 0); },
262
+
263
+ renderError(msg) { this.el.innerHTML = ''; const d = document.createElement('small'); d.textContent = msg; this.el.appendChild(d); },
264
+
265
+ draw() { drawChart(this); }
266
+ };
267
+ return state;
268
+ }
269
+
270
+ /* ---- Main draw ---------------------------------------------- */
271
+ function drawChart(state) {
272
+ const cfg = state.config; const d3 = state.d3; const el = state.el;
273
+ if (!cfg || !d3) return;
274
+ const width = Math.max(120, el.clientWidth || el.getBoundingClientRect().width || 600);
275
+ const height = cfg.height;
276
+
277
+ el.innerHTML = '';
278
+ probePalette(el);
279
+
280
+ const hasData = cfg.series.some(s => Array.isArray(s.data) && s.data.length);
281
+ if (!hasData) { const d = document.createElement('small'); d.textContent = 'No data'; el.appendChild(d); return; }
282
+
283
+ // Label via aria-label (not an SVG <title>, which renders a native
284
+ // browser tooltip that conflicts with our cursor tooltip).
285
+ const root = svg('svg', { viewBox: `0 0 ${width} ${height}`, width: '100%', height: String(height), role: 'img', 'aria-label': cfg.title || (cfg.type + ' chart'), preserveAspectRatio: 'xMidYMid meet' }, el);
286
+
287
+ if (cfg.type === 'pie' || cfg.type === 'donut') drawPie(state, root, width, height);
288
+ else drawCartesian(state, root, width, height);
289
+ }
290
+
291
+ // Palette size is CSS-driven: count consecutive --color-chart-N custom
292
+ // properties (themes can extend past 8 by defining --color-chart-9, …);
293
+ // segment colours cycle through however many exist. Re-probed per draw
294
+ // so per-scope overrides apply.
295
+ let _paletteN = 8;
296
+ function probePalette(el) {
297
+ try {
298
+ const cs = getComputedStyle(el);
299
+ let n = 0;
300
+ while (n < 32 && cs.getPropertyValue('--color-chart-' + (n + 1)).trim()) n++;
301
+ _paletteN = n || 8;
302
+ } catch (_) { _paletteN = 8; }
303
+ }
304
+ function seriesColorVar(i, explicit) { return explicit || `var(--color-chart-${(i % _paletteN) + 1})`; }
305
+
306
+ // Line interpolation: monotone (smooth, default) | linear | step | natural.
307
+ function curveFor(d3, name) {
308
+ switch (String(name || '').toLowerCase()) {
309
+ case 'linear': case 'straight': return d3.curveLinear;
310
+ case 'step': case 'stepped': return d3.curveStep;
311
+ case 'step-after': return d3.curveStepAfter;
312
+ case 'step-before': return d3.curveStepBefore;
313
+ case 'natural': case 'spline': return d3.curveNatural;
314
+ default: return d3.curveMonotoneX;
315
+ }
316
+ }
317
+
318
+ // Per-series chart type (for combo): explicit `type` on the series, else
319
+ // the chart-level type.
320
+ function seriesType(cfg, s) { return (s && s.type) || (cfg.type === 'combo' ? 'bar' : cfg.type); }
321
+
322
+ // Parse OHLC rows: {o/open,h/high,l/low,c/close} or [o,h,l,c].
323
+ function candleData(data) {
324
+ return (data || []).map(d => Array.isArray(d)
325
+ ? { o: num(d[0], 0), h: num(d[1], 0), l: num(d[2], 0), c: num(d[3], 0) }
326
+ : { o: num(d.o != null ? d.o : d.open, 0), h: num(d.h != null ? d.h : d.high, 0), l: num(d.l != null ? d.l : d.low, 0), c: num(d.c != null ? d.c : d.close, 0) });
327
+ }
328
+
329
+ function drawCartesian(state, root, width, height) {
330
+ const cfg = state.config, d3 = state.d3;
331
+ const m = { top: 10, right: 14, bottom: 26, left: 40 };
332
+ const iw = width - m.left - m.right;
333
+ const ih = height - m.top - m.bottom;
334
+ const labels = cfg.labels.length ? cfg.labels : cfg.series[0].data.map((_, i) => i + 1);
335
+ const locale = (() => { try { return Alpine.store('locale')?.current || document.documentElement.lang || 'en'; } catch (_) { return 'en'; } })();
336
+ const nf = (() => { try { return new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 }); } catch (_) { return { format: String }; } })();
337
+
338
+ const isCandle = cfg.type === 'candlestick' || cfg.type === 'ohlc';
339
+
340
+ // y domain
341
+ let yMax = -Infinity, yMin = 0;
342
+ if (isCandle) {
343
+ const cs = candleData(cfg.series[0].data);
344
+ yMin = Infinity; yMax = -Infinity;
345
+ cs.forEach(c => { if (c.l < yMin) yMin = c.l; if (c.h > yMax) yMax = c.h; });
346
+ const padY = (yMax - yMin) * 0.05 || 1; yMin -= padY; yMax += padY;
347
+ } else if (cfg.stacked && (cfg.type === 'bar' || cfg.type === 'area')) {
348
+ const n = labels.length;
349
+ for (let i = 0; i < n; i++) { let sum = 0; cfg.series.forEach(s => sum += num(s.data[i], 0)); if (sum > yMax) yMax = sum; }
350
+ } else {
351
+ // In combo, only line/area/bar series carry plain numeric data.
352
+ cfg.series.forEach(s => (s.data || []).forEach(v => { if (v && typeof v === 'object') return; const n = num(v, 0); if (n > yMax) yMax = n; if (n < yMin) yMin = n; }));
353
+ }
354
+ if (yMax === -Infinity) yMax = 1;
355
+ if (yMax === yMin) yMax += 1;
356
+
357
+ const y = d3.scaleLinear().domain(isCandle ? [yMin, yMax] : [Math.min(0, yMin), yMax]).nice().range([ih, 0]);
358
+ const plot = svg('g', { transform: `translate(${m.left},${m.top})` }, root);
359
+
360
+ // Grid + y axis ticks
361
+ if (cfg.grid || cfg.axis) {
362
+ const ticks = y.ticks(5);
363
+ ticks.forEach(t => {
364
+ const yy = y(t);
365
+ if (cfg.grid) svg('line', { x1: 0, x2: iw, y1: yy, y2: yy }, plot);
366
+ if (cfg.axis) text(plot, nf.format(t), { x: -8, y: yy, 'text-anchor': 'end', 'dominant-baseline': 'central' });
367
+ });
368
+ }
369
+
370
+ if (isCandle) drawCandles(state, plot, labels, iw, ih, y);
371
+ else if (cfg.type === 'combo') drawCombo(state, plot, labels, iw, ih, y);
372
+ else if (cfg.type === 'bar') drawBars(state, plot, labels, iw, ih, y);
373
+ else if (cfg.type === 'scatter') drawScatter(state, plot, labels, iw, ih, y);
374
+ else drawLines(state, plot, labels, iw, ih, y); // line + area
375
+
376
+ // x axis labels (banded)
377
+ if (cfg.axis) {
378
+ const xb = d3.scaleBand().domain(labels.map(String)).range([0, iw]).padding(0.1);
379
+ labels.forEach(l => {
380
+ const cx = xb(String(l)) + xb.bandwidth() / 2;
381
+ text(plot, l, { x: cx, y: ih + 16, 'text-anchor': 'middle' });
382
+ });
383
+ }
384
+ if (cfg.legend && cfg.series.length > 1) drawLegend(state);
385
+ }
386
+
387
+ // `list` lets combo pass a subset of series with their original indices
388
+ // (preserving colours/legend). Defaults to every series.
389
+ function drawLines(state, plot, labels, iw, ih, y, list) {
390
+ const cfg = state.config, d3 = state.d3;
391
+ const x = d3.scalePoint().domain(labels.map(String)).range([0, iw]).padding(0.5);
392
+ const curve = curveFor(d3, cfg.curve);
393
+ const items = list || cfg.series.map((s, i) => ({ s, i, area: cfg.type === 'area' }));
394
+ items.forEach(({ s, i: si, area }) => {
395
+ const color = seriesColorVar(si, s.color);
396
+ const points = labels.map((l, i) => [x(String(l)), y(num(s.data[i], 0))]);
397
+ if (area) {
398
+ const areaGen = d3.area().x(p => p[0]).y0(ih).y1(p => p[1]).curve(curve);
399
+ const ap = svg('path', { class: 'area', d: areaGen(points), style: `--color-chart-color:${color}` }, plot);
400
+ animate(ap, [{ opacity: 0 }, { opacity: 1 }], { duration: 500 });
401
+ }
402
+ const line = d3.line().x(p => p[0]).y(p => p[1]).curve(curve);
403
+ const lp = svg('path', { class: 'line', d: line(points), style: `--color-chart-color:${color}`, fill: 'none' }, plot);
404
+ try { const len = lp.getTotalLength(); animate(lp, [{ strokeDashoffset: len, strokeDasharray: len }, { strokeDashoffset: 0, strokeDasharray: len }], { duration: 700 }); } catch (_) { }
405
+ // points
406
+ points.forEach((p, i) => {
407
+ const dot = svg('circle', { cx: p[0], cy: p[1], r: 3, style: `--color-chart-color:${color}` }, plot);
408
+ const v = num(s.data[i], 0);
409
+ applyTip(dot, (s.name ? s.name + ': ' : '') + v, cfg);
410
+ animate(dot, [{ opacity: 0, transform: 'scale(0)' }, { opacity: 1, transform: 'scale(1)' }], { duration: 300, delay: 200 + i * 20 });
411
+ if (cfg.dataLabels) dataLabel(plot, v, p[0], p[1] - 8);
412
+ });
413
+ });
414
+ }
415
+
416
+ // `list` (combo) is a subset of {s,i} keeping original indices. Stacking
417
+ // only applies to the full-series bar chart, not combo.
418
+ function drawBars(state, plot, labels, iw, ih, y, list) {
419
+ const cfg = state.config, d3 = state.d3;
420
+ const x0 = d3.scaleBand().domain(labels.map(String)).range([0, iw]).padding(0.2);
421
+ if (cfg.stacked && !list) {
422
+ labels.forEach((l, i) => {
423
+ let acc = 0;
424
+ cfg.series.forEach((s, si) => {
425
+ const v = num(s.data[i], 0);
426
+ const yTop = y(acc + v), yBot = y(acc);
427
+ const rect = svg('rect', { x: x0(String(l)), y: yTop, width: x0.bandwidth(), height: Math.max(0, yBot - yTop), style: `--color-chart-color:${seriesColorVar(si, s.color)}` }, plot);
428
+ applyTip(rect, (s.name ? s.name + ': ' : '') + v, cfg);
429
+ animateBar(rect, ih);
430
+ if (cfg.dataLabels && (yBot - yTop) > 14) dataLabel(plot, v, x0(String(l)) + x0.bandwidth() / 2, (yTop + yBot) / 2, 'middle', 'central', 'inverse');
431
+ acc += v;
432
+ });
433
+ });
434
+ } else {
435
+ const items = list || cfg.series.map((s, i) => ({ s, i }));
436
+ const x1 = d3.scaleBand().domain(items.map((_, k) => String(k))).range([0, x0.bandwidth()]).padding(0.08);
437
+ labels.forEach((l, i) => {
438
+ items.forEach((o, k) => {
439
+ const s = o.s, si = o.i;
440
+ const v = num(s.data[i], 0);
441
+ const yy = y(Math.max(0, v));
442
+ const rect = svg('rect', { x: x0(String(l)) + x1(String(k)), y: yy, width: x1.bandwidth(), height: Math.abs(y(v) - y(0)), style: `--color-chart-color:${seriesColorVar(si, s.color)}` }, plot);
443
+ applyTip(rect, (s.name ? s.name + ': ' : '') + v, cfg);
444
+ animateBar(rect, ih);
445
+ if (cfg.dataLabels) dataLabel(plot, v, x0(String(l)) + x1(String(k)) + x1.bandwidth() / 2, yy - 4);
446
+ });
447
+ });
448
+ }
449
+ }
450
+
451
+ // Combo: bars (grouped among bar series) + line/area overlays, shared axes.
452
+ function drawCombo(state, plot, labels, iw, ih, y) {
453
+ const cfg = state.config;
454
+ const bars = [], lines = [];
455
+ cfg.series.forEach((s, i) => {
456
+ const t = seriesType(cfg, s);
457
+ if (t === 'line' || t === 'area') lines.push({ s, i, area: t === 'area' });
458
+ else bars.push({ s, i });
459
+ });
460
+ if (bars.length) drawBars(state, plot, labels, iw, ih, y, bars);
461
+ if (lines.length) drawLines(state, plot, labels, iw, ih, y, lines);
462
+ }
463
+
464
+ // Candlestick / OHLC.
465
+ function drawCandles(state, plot, labels, iw, ih, y) {
466
+ const cfg = state.config, d3 = state.d3;
467
+ const cs = candleData(cfg.series[0].data);
468
+ const x0 = d3.scaleBand().domain(labels.map(String)).range([0, iw]).padding(0.3);
469
+ const bw = x0.bandwidth();
470
+ cs.forEach((c, i) => {
471
+ const cx = x0(String(labels[i])) + bw / 2;
472
+ const up = c.c >= c.o;
473
+ const g = svg('g', { class: up ? 'positive' : 'negative' }, plot);
474
+ svg('line', { x1: cx, x2: cx, y1: y(c.h), y2: y(c.l) }, g);
475
+ const yTop = Math.min(y(c.o), y(c.c)), bodyH = Math.max(1, Math.abs(y(c.c) - y(c.o)));
476
+ const rect = svg('rect', { x: cx - bw * 0.3, y: yTop, width: bw * 0.6, height: bodyH }, g);
477
+ applyTip(rect, `${labels[i]} · O ${c.o} H ${c.h} L ${c.l} C ${c.c}`, cfg);
478
+ animate(g, [{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: i * 15 });
479
+ });
480
+ }
481
+ function animateBar(rect, ih) {
482
+ if (prefersReducedMotion() || typeof rect.animate !== 'function') return;
483
+ rect.style.transformBox = 'fill-box'; rect.style.transformOrigin = 'center bottom';
484
+ try { rect.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], { duration: 600, easing: 'cubic-bezier(0.22,1,0.36,1)', fill: 'backwards' }); } catch (_) { }
485
+ }
486
+
487
+ function drawScatter(state, plot, labels, iw, ih, y) {
488
+ const cfg = state.config, d3 = state.d3;
489
+ const x = d3.scalePoint().domain(labels.map(String)).range([0, iw]).padding(0.5);
490
+ cfg.series.forEach((s, si) => {
491
+ labels.forEach((l, i) => {
492
+ const v = num(s.data[i], 0);
493
+ const dot = svg('circle', { class: 'scatter', cx: x(String(l)), cy: y(v), r: 5, style: `--color-chart-color:${seriesColorVar(si, s.color)}` }, plot);
494
+ applyTip(dot, (s.name ? s.name + ': ' : '') + v, cfg);
495
+ animate(dot, [{ opacity: 0, transform: 'scale(0)' }, { opacity: 1, transform: 'scale(1)' }], { duration: 350, delay: i * 25 });
496
+ if (cfg.dataLabels) dataLabel(plot, v, x(String(l)), y(v) - 9);
497
+ });
498
+ });
499
+ }
500
+
501
+ function drawPie(state, root, width, height) {
502
+ const cfg = state.config, d3 = state.d3;
503
+ const data = cfg.series[0].data.map(v => num(v, 0));
504
+ const labels = cfg.labels.length ? cfg.labels : data.map((_, i) => i + 1);
505
+ const r = Math.min(width, height) / 2 - 6;
506
+ const inner = cfg.type === 'donut' ? r * 0.6 : 0;
507
+ const g = svg('g', { transform: `translate(${width / 2},${height / 2})` }, root);
508
+ const pie = d3.pie().sort(null).value(d => d)(data);
509
+ const arc = d3.arc().innerRadius(inner).outerRadius(r);
510
+ pie.forEach((slice, i) => {
511
+ const path = svg('path', { class: 'slice', d: arc(slice), style: `--color-chart-color:${seriesColorVar(i)}` }, g);
512
+ applyTip(path, labels[i] + ': ' + data[i], cfg);
513
+ if (cfg.dataLabels) { const c = arc.centroid(slice); dataLabel(g, data[i], c[0], c[1], 'middle', 'central', 'inverse'); }
514
+ if (!prefersReducedMotion() && typeof path.animate === 'function') {
515
+ path.style.transformBox = 'fill-box'; path.style.transformOrigin = 'center';
516
+ try { path.animate([{ opacity: 0, transform: 'scale(0.85)' }, { opacity: 1, transform: 'scale(1)' }], { duration: 450, delay: i * 60, easing: 'cubic-bezier(0.22,1,0.36,1)', fill: 'backwards' }); } catch (_) { }
517
+ }
518
+ });
519
+ if (cfg.legend) drawLegend(state, labels);
520
+ }
521
+
522
+ // Legend is a <footer> sibling below the SVG (inline flex), not an
523
+ // absolute overlay — so it never collides with axis labels. Each item is
524
+ // a <span> with an <i> swatch carrying the series colour.
525
+ function drawLegend(state, labelsOverride) {
526
+ const cfg = state.config;
527
+ const items = labelsOverride || cfg.series.map(s => s.name).filter(Boolean);
528
+ if (!items.length) return;
529
+ const footer = document.createElement('footer');
530
+ items.forEach((label, i) => {
531
+ const item = document.createElement('span');
532
+ const sw = document.createElement('i');
533
+ sw.style.setProperty('--color-chart-color', seriesColorVar(i, cfg.series[i] && cfg.series[i].color));
534
+ const tx = document.createElement('span');
535
+ tx.textContent = label; // untrusted-safe
536
+ item.append(sw, tx); footer.appendChild(item);
537
+ });
538
+ state.el.appendChild(footer);
539
+ }
540
+
541
+ /* ---- Register directive + magic ----------------------------- */
542
+ Alpine.directive('chart', (el, { modifiers, expression }, { cleanup }) => {
543
+ if (el._chartState) return;
544
+ const state = createState(el, expression, modifiers);
545
+ el._chartState = state;
546
+ el.classList.add('chart');
547
+ // Reserve the chart's height up front (parsed from the config, default
548
+ // 240) so the empty container isn't 0-height — a zero-height box doesn't
549
+ // reliably trigger the lazy-load IntersectionObserver, and reserving it
550
+ // also prevents a layout jump when the SVG renders.
551
+ const hm = expression && /height\s*:\s*(\d+)/.exec(expression);
552
+ el.style.minHeight = (hm ? +hm[1] : 240) + 'px';
553
+ observer().observe(el);
554
+ cleanup(() => {
555
+ if (state._ro) state._ro.disconnect();
556
+ if (state._onLocale) window.removeEventListener('localechange', state._onLocale);
557
+ if (io) io.unobserve(el);
558
+ if (el.id) delete _registry[el.id];
559
+ delete el._chartState;
560
+ });
561
+ });
562
+
563
+ Alpine.magic('chart', (el) => {
564
+ const local = findAncestorState(el);
565
+ const byId = (id) => { if (!id) return local ? local.api : _nullApi; return _registry[id] || _nullApi; };
566
+ return new Proxy(byId, { get(fn, prop) { if (local && local.api && prop in local.api) return local.api[prop]; return fn[prop]; } });
567
+ });
568
+ }
569
+
570
+ /* ---- Bootstrap shim (loader contract) --------------------------- */
571
+ let chartsPluginInitialized = false;
572
+ let chartsAlpineHasWalked = false;
573
+ document.addEventListener('alpine:initialized', () => { chartsAlpineHasWalked = true; });
574
+
575
+ function ensureChartsPluginInitialized() {
576
+ if (chartsPluginInitialized) return;
577
+ if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
578
+ chartsPluginInitialized = true;
579
+ initializeChartsPlugin();
580
+ if (chartsAlpineHasWalked && typeof window.Alpine.initTree === 'function') {
581
+ document.querySelectorAll('[x-chart]').forEach(el => { if (!el.__x) window.Alpine.initTree(el); });
582
+ }
583
+ }
584
+ window.ensureChartsPluginInitialized = ensureChartsPluginInitialized;
585
+
586
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureChartsPluginInitialized);
587
+ document.addEventListener('alpine:init', ensureChartsPluginInitialized);
588
+ if (window.Alpine && typeof window.Alpine.directive === 'function') setTimeout(ensureChartsPluginInitialized, 0);
589
+ else {
590
+ const check = setInterval(() => { if (window.Alpine?.directive) { clearInterval(check); ensureChartsPluginInitialized(); } }, 10);
591
+ setTimeout(() => clearInterval(check), 5000);
592
+ }
593
+ })();
@@ -35,8 +35,8 @@
35
35
  width: 60%;
36
36
  height: 60%;
37
37
  background-color: var(--color-field-inverse, oklch(43.9% 0 0));
38
- -webkit-mask-image: var(--icon-checkbox, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='currentColor' d='m0 11l2-2l5 5L18 3l2 2L7 18z'/%3E%3C/svg%3E"));
39
- mask-image: var(--icon-checkbox, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='currentColor' d='m0 11l2-2l5 5L18 3l2 2L7 18z'/%3E%3C/svg%3E"));
38
+ -webkit-mask-image: var(--icon-check, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='currentColor' d='m0 11l2-2l5 5L18 3l2 2L7 18z'/%3E%3C/svg%3E"));
39
+ mask-image: var(--icon-check, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='currentColor' d='m0 11l2-2l5 5L18 3l2 2L7 18z'/%3E%3C/svg%3E"));
40
40
  -webkit-mask-repeat: no-repeat;
41
41
  mask-repeat: no-repeat;
42
42
  -webkit-mask-size: 100% 100%;