mnfst 0.5.121 → 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.
- package/lib/manifest.chart.css +195 -0
- package/lib/manifest.charts.js +593 -0
- package/lib/manifest.checkbox.css +2 -2
- package/lib/manifest.colorpicker.js +198 -41
- package/lib/manifest.css +755 -21
- package/lib/manifest.data.js +35 -7
- package/lib/manifest.datepicker.css +504 -0
- package/lib/manifest.datepicker.js +1208 -0
- package/lib/manifest.dialog.css +7 -4
- package/lib/manifest.dropdown.css +7 -10
- package/lib/manifest.integrity.json +9 -5
- package/lib/manifest.js +18 -4
- package/lib/manifest.localization.js +5 -1
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.payments.js +583 -0
- package/lib/manifest.schema.json +77 -0
- package/lib/manifest.sidebar.css +7 -6
- package/lib/manifest.status.js +680 -0
- package/lib/manifest.theme.css +6 -4
- package/lib/manifest.toast.css +1 -1
- package/lib/manifest.tooltip.css +48 -16
- package/lib/manifest.utilities.css +3 -2
- package/lib/manifest.utilities.js +18 -2
- package/package.json +3 -1
|
@@ -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-
|
|
39
|
-
mask-image: var(--icon-
|
|
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%;
|