mnfst 0.5.162 → 0.5.164

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.
@@ -1,33 +1,18 @@
1
- /* Manifest Charts
1
+ /* Manifest Charts — in-house SVG chart renderer (themeable, prerender-safe, a11y).
2
2
  /* By Andrew Matlock under MIT license
3
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.
4
+ /* d3-scale/shape/array micro-modules (ISC) lazy-loaded from esm.run on scroll-in.
12
5
  */
13
6
 
14
7
  (function () {
15
8
  'use strict';
16
9
 
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
- * ------------------------------------------------------------------ */
10
+ /* Shared localized-UI resolver: deep-merges every loaded source's `_ui[component]`
11
+ * onto the plugin's English fallbacks. Guarded; byte-identical across the picker
12
+ * copies (first plugin to load defines it). */
27
13
  if (!window.ManifestUI) {
28
14
  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`. */
15
+ /* Loaded data sources (current locale); never force-loads others. */
31
16
  _loadedSourceNames() {
32
17
  try {
33
18
  const store = window.ManifestDataStore && window.ManifestDataStore.rawDataStore;
@@ -35,8 +20,7 @@
35
20
  } catch (_) { }
36
21
  return [];
37
22
  },
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. */
23
+ /* Reads inside the caller's Alpine effect (if any) so $x/$locale stay reactive. */
40
24
  resolve(component, fallbacks) {
41
25
  const merged = JSON.parse(JSON.stringify(fallbacks || {}));
42
26
  try {
@@ -256,19 +240,15 @@
256
240
  t.appendChild(document.createTextNode(str == null ? '' : String(str))); // untrusted-safe
257
241
  return t;
258
242
  }
259
- // Entry animations run only on a chart's first draw. Reactive redraws
260
- // (a bound value changing, a resize) must paint the final state directly
261
- // — otherwise dragging a slider replays the reveal every frame and the
262
- // chart flickers. drawChart sets this before dispatching.
243
+ // Entry animations run only on first draw; redraws paint final state directly
244
+ // (else a bound value change replays the reveal every frame). Set by drawChart.
263
245
  let _suppressAnim = false;
264
246
  function animate(el, keyframes, opts) {
265
247
  if (_suppressAnim || prefersReducedMotion() || typeof el.animate !== 'function') return;
266
248
  try { el.animate(keyframes, Object.assign({ duration: 600, easing: 'cubic-bezier(0.22,1,0.36,1)', fill: 'backwards' }, opts)); } catch (_) { }
267
249
  }
268
- // Cursor-following tooltip. Manifest's x-tooltip relies on CSS anchor
269
- // positioning, which can't anchor to SVG child elements (no CSS-layout
270
- // box) — so charts use their own tip, themed to match, following the
271
- // pointer (better UX for dense charts). aria-label carries AT semantics.
250
+ // Cursor-following tooltip: x-tooltip's CSS anchor positioning can't target SVG
251
+ // children (no layout box), so charts use their own themed tip. aria-label for AT.
272
252
  function applyTip(seg, tip, cfg) {
273
253
  seg.setAttribute('aria-label', tip);
274
254
  if (!cfg.tooltip) return;
@@ -337,12 +317,8 @@
337
317
  if (self.el.id) _registry[self.el.id] = self.api;
338
318
  },
339
319
 
340
- // Coalesce to one draw per tick. Uses setTimeout (not rAF) so draws
341
- // still happen when the tab is backgrounded (rAF is paused for hidden
342
- // tabs), and does NOT reset a pending timer on re-entry — a
343
- // high-frequency reactive trigger (e.g. a plugin bumping the data-store
344
- // version every tick) would otherwise perpetually reschedule and starve
345
- // the draw.
320
+ // One draw per tick. setTimeout (not rAF) so backgrounded tabs still draw;
321
+ // doesn't reset a pending timer, so a high-frequency trigger can't starve it.
346
322
  schedule() { if (this._t) return; this._t = setTimeout(() => { this._t = null; this.draw(); }, 0); },
347
323
 
348
324
  renderError(msg) { this.el.innerHTML = ''; const d = document.createElement('small'); d.textContent = msg; this.el.appendChild(d); },
@@ -407,10 +383,8 @@
407
383
  return 4 + n * cfg.rowHeight + 22;
408
384
  }
409
385
 
410
- // Palette size is CSS-driven: count consecutive --color-chart-N custom
411
- // properties (themes can extend past 8 by defining --color-chart-9, …);
412
- // segment colours cycle through however many exist. Re-probed per draw
413
- // so per-scope overrides apply.
386
+ // Palette size = count of consecutive --color-chart-N tokens (themes may extend
387
+ // past 8); colours cycle through them. Re-probed per draw for per-scope overrides.
414
388
  let _paletteN = 8;
415
389
  function probePalette(el) {
416
390
  try {
@@ -649,10 +623,17 @@
649
623
  if (cfg.legend) drawLegend(state, labels);
650
624
  }
651
625
 
652
- // Gauge a single value swept across a 180° dome. Track + value arc
653
- // reuse the pie/donut arc primitive; optional `zones` paint threshold
654
- // bands (e.g. positive/warning/negative ranges). `unit` suffixes the
655
- // centered readout; `min`/`max` default 0–100.
626
+ // Widest rendered label in px (exact via getComputedTextLength, char-estimate
627
+ // fallback). Sizes axis gutters to their content so charts sit balanced.
628
+ function labelWidth(root, items, cls) {
629
+ if (!items || !items.length) return 0;
630
+ const probe = svg('text', cls ? { class: cls, x: -9999, y: -9999 } : { x: -9999, y: -9999 }, root);
631
+ let max = 0;
632
+ for (const s of items) { probe.textContent = String(s); let w = 0; try { w = probe.getComputedTextLength(); } catch (_) { } if (!w) w = String(s).length * 7; if (w > max) max = w; }
633
+ root.removeChild(probe);
634
+ return Math.ceil(max);
635
+ }
636
+
656
637
  function drawGauge(state, root, width, height) {
657
638
  const cfg = state.config, d3 = state.d3;
658
639
  const value = num(cfg.series[0] && cfg.series[0].data[0], 0);
@@ -661,9 +642,12 @@
661
642
  const scale = d3.scaleLinear().domain([min, max]).range([START, END]).clamp(true);
662
643
  const unit = cfg.unit || '';
663
644
 
664
- const r = Math.min(width / 2, height) - 8;
645
+ // Reserve a band below the dial for the min/max labels, then centre the
646
+ // whole dial (arc + labels) within the frame so nothing spills out.
647
+ const PAD = 8, labelBand = cfg.axis ? 20 : 0;
648
+ const r = Math.max(0, Math.min(width / 2 - PAD, height - PAD - labelBand));
665
649
  const thickness = Math.max(8, r * 0.22);
666
- const cx = width / 2, cy = 8 + r;
650
+ const cx = width / 2, cy = Math.max(PAD, (height - (r + labelBand)) / 2) + r;
667
651
  const g = svg('g', { transform: `translate(${cx},${cy})` }, root);
668
652
  const arc = d3.arc().innerRadius(r - thickness).outerRadius(r).cornerRadius(cssRadius(state.el));
669
653
 
@@ -679,15 +663,12 @@
679
663
  svg('path', { class: 'gauge-track', d: arc({ startAngle: START, endAngle: END }) }, g);
680
664
  }
681
665
 
682
- // Value arc — final geometry is set unconditionally so the gauge is
683
- // correct even if the entry animation never runs (background tab);
684
- // the sweep is a CSS reveal, never the source of the end state.
666
+ // Value arc — final geometry set unconditionally, so a background tab that
667
+ // skips the fade reveal still renders correctly.
685
668
  const color = (cfg.series[0] && cfg.series[0].color) || 'var(--color-chart-1)';
686
669
  const valueAngle = scale(value);
687
670
  const vArc = svg('path', { class: 'gauge-value', d: arc({ startAngle: START, endAngle: valueAngle }), style: `--color-chart-color:${color}` }, g);
688
671
  applyTip(vArc, (cfg.title ? cfg.title + ': ' : '') + value + unit, cfg);
689
- // Reveal via fade (WAAPI, origin-independent) — the final geometry above
690
- // is unconditional, so the gauge is correct even if this never runs.
691
672
  animate(vArc, [{ opacity: 0 }, { opacity: 1 }], { duration: 500 });
692
673
 
693
674
  // Centered readout + range end labels.
@@ -699,10 +680,8 @@
699
680
  }
700
681
  }
701
682
 
702
- // Heatmap — a matrix of cells: each series is a row, each datum a column
703
- // aligned to `labels`. Cell colour is a CSS color-mix between the two
704
- // --color-chart-heat-* tokens (no JS colour-interpolation dep), driven
705
- // by the per-cell `--heat` percentage.
683
+ // Heatmap — matrix of cells (row per series, column per datum). Cell colour is a
684
+ // CSS color-mix of the --color-chart-heat-* tokens via a per-cell `--heat` %.
706
685
  function drawHeatmap(state, root, width, height) {
707
686
  const cfg = state.config, d3 = state.d3;
708
687
  const rows = cfg.series;
@@ -710,7 +689,11 @@
710
689
  const rowName = (r, i) => r.name || String(i + 1);
711
690
  const showLabels = cfg.axis;
712
691
 
713
- const m = { top: 4, right: 4, bottom: showLabels ? 24 : 4, left: showLabels ? 64 : 4 };
692
+ // Gutters sized to content: a uniform pad all round, plus a left gutter
693
+ // measured to the row labels so the grid sits balanced, not lopsided.
694
+ const PAD = 8;
695
+ const yW = showLabels ? labelWidth(root, rows.map((r, i) => rowName(r, i)), '') : 0;
696
+ const m = { top: PAD, right: PAD, bottom: showLabels ? PAD + 16 : PAD, left: showLabels ? PAD + yW + 8 : PAD };
714
697
  const iw = width - m.left - m.right;
715
698
  const ih = height - m.top - m.bottom;
716
699
  // Tiles abut (padding 0); the gutter comes from insetting each rect
@@ -728,9 +711,8 @@
728
711
 
729
712
  const plot = svg('g', { transform: `translate(${m.left},${m.top})` }, root);
730
713
 
731
- // Round only the grid's outer corners: square tiles clipped to a single
732
- // rounded rect hugging the cell extent (right/bottom edge sits at the
733
- // last tile's inner edge, so the radius isn't clipping empty gutter).
714
+ // Round only the grid's outer corners: square tiles clipped to one rounded
715
+ // rect hugging the cell extent (so the radius doesn't clip empty gutter).
734
716
  const clipId = 'mnfst-heat-' + (++_uid);
735
717
  const cp = svg('clipPath', { id: clipId }, svg('defs', null, root));
736
718
  svg('rect', { x: 0, y: 0, width: Math.max(0, iw - gap), height: Math.max(0, ih - gap), rx: cssRadius(state.el) }, cp);
@@ -771,20 +753,22 @@
771
753
  state.el.appendChild(footer);
772
754
  }
773
755
 
774
- // Gantt — each series is a track (row); its data is an array
775
- // of `{ from, to, status?, label?, color? }` segments on a shared X axis.
776
- // The axis adapts to the data: numbers linear (0–10, distance, %),
777
- // date-ish values → time, other strings → category (ordinal stages). A
778
- // segment with no `to` is a point marker. Single track = a status strip;
779
- // many tracks = swimlanes / Gantt.
756
+ // Gantt — each series is a track of `{ from, to, status?, label?, color? }`
757
+ // segments on a shared X axis, adapting numbers→linear, dates→time, else
758
+ // category. A `to`-less segment is a point marker.
780
759
  function drawGantt(state, root, width, height) {
781
760
  const cfg = state.config, d3 = state.d3;
782
761
  const tracks = cfg.series;
783
762
  const trackName = (t, i) => t.name || String(i + 1);
784
763
  const showLabels = cfg.axis;
785
764
  const unit = cfg.unit || '';
786
- const m = { top: 4, right: 10, bottom: 22, left: showLabels ? 92 : 8 };
787
- const iw = width - m.left - m.right;
765
+ // Uniform pad all round; left gutter measured to the track labels, and
766
+ // extra headroom when markers carry a label drawn above the lanes.
767
+ const PAD = 8;
768
+ const hasMarkerLabels = Array.isArray(cfg.markers) && cfg.markers.some(mk => mk && mk.label);
769
+ const yW = showLabels ? labelWidth(root, tracks.map((t, i) => trackName(t, i)), 'gantt-track') : 0;
770
+ const m = { top: PAD + (hasMarkerLabels ? 10 : 0), right: PAD, bottom: 22, left: showLabels ? PAD + yW + 8 : PAD };
771
+ let iw = width - m.left - m.right;
788
772
  const ih = height - m.top - m.bottom;
789
773
 
790
774
  // Axis kind from the first segment's `from`.
@@ -794,18 +778,17 @@
794
778
  : typeof firstFrom === 'number' ? 'numeric'
795
779
  : (!isNaN(Date.parse(firstFrom)) ? 'time' : 'category');
796
780
 
797
- let x, ticks, fmt, pos;
781
+ // Domain is width-independent; the scale (and its ticks) is built from
782
+ // the current `iw` so it can be rebuilt after reserving label gutters.
783
+ let categoryDomain, dmin, dmax, conv, fmtBase;
798
784
  if (mode === 'category') {
799
785
  const seen = [];
800
786
  tracks.forEach(t => (t.data || []).forEach(s => [s.from, s.to].forEach(v => { if (v != null && seen.indexOf(String(v)) < 0) seen.push(String(v)); })));
801
- const domain = (cfg.labels && cfg.labels.length) ? cfg.labels.map(String) : seen;
802
- x = d3.scalePoint().domain(domain).range([0, iw]);
803
- pos = v => x(String(v));
804
- ticks = domain;
805
- fmt = v => String(v);
787
+ categoryDomain = (cfg.labels && cfg.labels.length) ? cfg.labels.map(String) : seen;
788
+ fmtBase = v => String(v);
806
789
  } else {
807
- const conv = mode === 'time' ? toTime : (v => num(v, 0));
808
- let dmin = Infinity, dmax = -Infinity;
790
+ conv = mode === 'time' ? toTime : (v => num(v, 0));
791
+ dmin = Infinity; dmax = -Infinity;
809
792
  tracks.forEach(t => (t.data || []).forEach(s => {
810
793
  const a = conv(s.from), b = conv(s.to != null ? s.to : s.from);
811
794
  if (a < dmin) dmin = a; if (b > dmax) dmax = b;
@@ -813,25 +796,38 @@
813
796
  if (cfg.min != null) dmin = conv(cfg.min);
814
797
  if (cfg.max != null) dmax = conv(cfg.max);
815
798
  if (!isFinite(dmin) || !isFinite(dmax) || dmin === dmax) { dmin = 0; dmax = 1; }
816
- x = (mode === 'time' ? d3.scaleTime() : d3.scaleLinear()).domain([dmin, dmax]).range([0, iw]);
817
- pos = v => x(conv(v));
818
- ticks = x.ticks(Math.max(2, Math.floor(iw / 80)));
819
- fmt = mode === 'time' ? ganttFmt(dmin, dmax) : (v => String(v) + unit);
799
+ fmtBase = mode === 'time' ? ganttFmt(dmin, dmax) : (v => String(v) + unit);
800
+ }
801
+ const buildX = () => {
802
+ if (mode === 'category') { const sc = d3.scalePoint().domain(categoryDomain).range([0, iw]); return { pos: v => sc(String(v)), ticks: categoryDomain, fmt: fmtBase }; }
803
+ const sc = (mode === 'time' ? d3.scaleTime() : d3.scaleLinear()).domain([dmin, dmax]).range([0, iw]);
804
+ return { pos: v => sc(conv(v)), ticks: sc.ticks(Math.max(2, Math.floor(iw / 80))), fmt: fmtBase };
805
+ };
806
+ let X = buildX();
807
+ // Reserve half of each end label so the natural (middle-anchored) edge
808
+ // ticks sit fully inside the frame — no overflow, no crushing.
809
+ if (showLabels && X.ticks.length) {
810
+ const halfFirst = Math.ceil(labelWidth(root, [X.fmt(X.ticks[0])]) / 2);
811
+ const halfLast = Math.ceil(labelWidth(root, [X.fmt(X.ticks[X.ticks.length - 1])]) / 2);
812
+ let changed = false;
813
+ if (PAD + halfFirst > m.left) { m.left = PAD + halfFirst; changed = true; }
814
+ if (PAD + halfLast > m.right) { m.right = PAD + halfLast; changed = true; }
815
+ if (changed) { iw = width - m.left - m.right; X = buildX(); }
820
816
  }
817
+ const pos = X.pos, ticks = X.ticks, fmt = X.fmt;
821
818
  const tipVal = v => mode === 'time' ? fmt(toTime(v)) : String(v) + (mode === 'numeric' ? unit : '');
822
819
 
823
820
  const lane = ih / Math.max(1, tracks.length);
824
821
  const pad = Math.min(6, lane * 0.18);
825
822
  const plot = svg('g', { transform: `translate(${m.left},${m.top})` }, root);
826
823
 
827
- // Grid lines + axis ticks (shared, at the bottom).
824
+ // Grid lines + axis ticks (shared, at the bottom). Labels sit naturally
825
+ // centred on their tick — the reserved gutters keep the ends inside.
828
826
  ticks.forEach(tk => {
829
827
  const xx = pos(tk);
830
828
  if (xx == null || isNaN(xx)) return;
831
829
  if (cfg.grid) svg('line', { x1: xx, x2: xx, y1: 0, y2: ih }, plot);
832
- // Edge ticks anchor inward so labels never spill past the plot.
833
- const anchor = xx <= 1 ? 'start' : xx >= iw - 1 ? 'end' : 'middle';
834
- text(plot, fmt(tk), { x: xx, y: ih + 14, 'text-anchor': anchor });
830
+ text(plot, fmt(tk), { x: xx, y: ih + 14, 'text-anchor': 'middle' });
835
831
  });
836
832
 
837
833
  // Status → colour, stable across tracks. A per-segment `color` wins;
@@ -931,9 +927,8 @@
931
927
  const valueById = {};
932
928
  entries.forEach(([key, val]) => resolveRegion(key, geo).ids.forEach(id => { valueById[id] = val; }));
933
929
 
934
- // Display-name resolution: `_ui.map.regions` override → localized
935
- // pack (per $locale) → English atlas name. Async packs trigger a
936
- // redraw when they arrive.
930
+ // Display name: `_ui.map.regions` override → localized pack ($locale) →
931
+ // English atlas name. Async packs trigger a redraw on arrival.
937
932
  const num2a2 = (geo.meta && geo.meta.num2a2) || {};
938
933
  const locale = chartLocale();
939
934
  const ui = (window.ManifestUI && window.ManifestUI.resolve) ? window.ManifestUI.resolve('map', {}) : {};
@@ -950,9 +945,8 @@
950
945
  return eng;
951
946
  };
952
947
 
953
- // Author-supplied place gazetteer (config `places` or `_ui.map.cities`)
954
- // for resolving point names → coords. No city data is bundled; authors
955
- // source it from a third-party library in their own project.
948
+ // Author-supplied place gazetteer (config `places` or `_ui.map.cities`) for
949
+ // resolving point names → coords. No city data is bundled.
956
950
  const placesSrc = cfg.places || ui.cities || null;
957
951
  let places = null;
958
952
  if (placesSrc) { places = {}; Object.keys(placesSrc).forEach(k => { places[k.toLowerCase()] = placesSrc[k]; }); }
@@ -1030,9 +1024,8 @@
1030
1024
  state.el.appendChild(footer);
1031
1025
  }
1032
1026
 
1033
- // Legend is a <footer> sibling below the SVG (inline flex), not an
1034
- // absolute overlay — so it never collides with axis labels. Each item is
1035
- // a <span> with an <i> swatch carrying the series colour.
1027
+ // Legend is a <footer> sibling below the SVG, not an overlay, so it never
1028
+ // collides with axis labels. Each item: a <span> with an <i> colour swatch.
1036
1029
  function drawLegend(state, labelsOverride) {
1037
1030
  const cfg = state.config;
1038
1031
  const items = labelsOverride || cfg.series.map(s => s.name).filter(Boolean);
@@ -1055,10 +1048,8 @@
1055
1048
  const state = createState(el, expression, modifiers);
1056
1049
  el._chartState = state;
1057
1050
  el.classList.add('chart');
1058
- // Reserve the chart's height up front (parsed from the config, default
1059
- // 240) so the empty container isn't 0-height a zero-height box doesn't
1060
- // reliably trigger the lazy-load IntersectionObserver, and reserving it
1061
- // also prevents a layout jump when the SVG renders.
1051
+ // Reserve height up front (config, default 240): a 0-height box doesn't
1052
+ // reliably trigger the lazy-load observer, and reserving avoids a layout jump.
1062
1053
  const hm = expression && /height\s*:\s*(\d+)/.exec(expression);
1063
1054
  el.style.minHeight = (hm ? +hm[1] : 240) + 'px';
1064
1055
  observer().observe(el);