@bkstg/design-systems 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/tether/latest/README.md +44 -0
  3. package/tether/latest/charts-core.mjs +349 -0
  4. package/tether/latest/charts.js +58 -0
  5. package/tether/latest/motion.js +168 -0
  6. package/tether/latest/react/ActivityFeed.jsx +18 -0
  7. package/tether/latest/react/Alert.jsx +28 -0
  8. package/tether/latest/react/Annotations.jsx +24 -0
  9. package/tether/latest/react/AppShell.jsx +87 -0
  10. package/tether/latest/react/Avatar.jsx +19 -0
  11. package/tether/latest/react/Badge.jsx +14 -0
  12. package/tether/latest/react/Banner.jsx +17 -0
  13. package/tether/latest/react/Breadcrumbs.jsx +23 -0
  14. package/tether/latest/react/Button.jsx +31 -0
  15. package/tether/latest/react/ButtonGroup.jsx +29 -0
  16. package/tether/latest/react/Calendar.jsx +57 -0
  17. package/tether/latest/react/CardHeader.jsx +14 -0
  18. package/tether/latest/react/Chart.jsx +42 -0
  19. package/tether/latest/react/Chat.jsx +34 -0
  20. package/tether/latest/react/Checkbox.jsx +32 -0
  21. package/tether/latest/react/CodeSnippet.jsx +17 -0
  22. package/tether/latest/react/ColorPicker.jsx +35 -0
  23. package/tether/latest/react/CommandMenu.jsx +54 -0
  24. package/tether/latest/react/DatePicker.jsx +34 -0
  25. package/tether/latest/react/DeviceFrame.jsx +12 -0
  26. package/tether/latest/react/Dialog.jsx +43 -0
  27. package/tether/latest/react/Divider.jsx +10 -0
  28. package/tether/latest/react/Editor.jsx +85 -0
  29. package/tether/latest/react/EmptyState.jsx +13 -0
  30. package/tether/latest/react/Field.jsx +32 -0
  31. package/tether/latest/react/FileUpload.jsx +38 -0
  32. package/tether/latest/react/Filters.jsx +19 -0
  33. package/tether/latest/react/Infographics.jsx +54 -0
  34. package/tether/latest/react/InlineCTA.jsx +14 -0
  35. package/tether/latest/react/LineChart.jsx +23 -0
  36. package/tether/latest/react/Marketing.jsx +161 -0
  37. package/tether/latest/react/Menu.jsx +76 -0
  38. package/tether/latest/react/Metric.jsx +28 -0
  39. package/tether/latest/react/PageHeader.jsx +22 -0
  40. package/tether/latest/react/Pagination.jsx +32 -0
  41. package/tether/latest/react/Progress.jsx +26 -0
  42. package/tether/latest/react/RadioGroup.jsx +27 -0
  43. package/tether/latest/react/Reveal.jsx +17 -0
  44. package/tether/latest/react/SectionHeader.jsx +24 -0
  45. package/tether/latest/react/Select.jsx +19 -0
  46. package/tether/latest/react/Sheet.jsx +25 -0
  47. package/tether/latest/react/SideNav.jsx +37 -0
  48. package/tether/latest/react/Skeleton.jsx +16 -0
  49. package/tether/latest/react/Slider.jsx +27 -0
  50. package/tether/latest/react/Spinner.jsx +14 -0
  51. package/tether/latest/react/Steps.jsx +22 -0
  52. package/tether/latest/react/Switch.jsx +11 -0
  53. package/tether/latest/react/Table.jsx +24 -0
  54. package/tether/latest/react/Tabs.jsx +80 -0
  55. package/tether/latest/react/Tag.jsx +34 -0
  56. package/tether/latest/react/Tooltip.jsx +25 -0
  57. package/tether/latest/react/Tree.jsx +41 -0
  58. package/tether/latest/react/VideoPlayer.jsx +57 -0
  59. package/tether/latest/react/cx.js +4 -0
  60. package/tether/latest/react/index.js +59 -0
  61. package/tether/latest/system.json +44 -0
  62. package/tether/latest/tether.css +1661 -0
  63. package/tether/latest/tether.js +703 -0
  64. package/tether/v1/README.md +44 -0
  65. package/tether/v1/charts-core.mjs +349 -0
  66. package/tether/v1/charts.js +58 -0
  67. package/tether/v1/motion.js +168 -0
  68. package/tether/v1/react/ActivityFeed.jsx +18 -0
  69. package/tether/v1/react/Alert.jsx +28 -0
  70. package/tether/v1/react/Annotations.jsx +24 -0
  71. package/tether/v1/react/AppShell.jsx +87 -0
  72. package/tether/v1/react/Avatar.jsx +19 -0
  73. package/tether/v1/react/Badge.jsx +14 -0
  74. package/tether/v1/react/Banner.jsx +17 -0
  75. package/tether/v1/react/Breadcrumbs.jsx +23 -0
  76. package/tether/v1/react/Button.jsx +31 -0
  77. package/tether/v1/react/ButtonGroup.jsx +29 -0
  78. package/tether/v1/react/Calendar.jsx +57 -0
  79. package/tether/v1/react/CardHeader.jsx +14 -0
  80. package/tether/v1/react/Chart.jsx +42 -0
  81. package/tether/v1/react/Chat.jsx +34 -0
  82. package/tether/v1/react/Checkbox.jsx +32 -0
  83. package/tether/v1/react/CodeSnippet.jsx +17 -0
  84. package/tether/v1/react/ColorPicker.jsx +35 -0
  85. package/tether/v1/react/CommandMenu.jsx +54 -0
  86. package/tether/v1/react/DatePicker.jsx +34 -0
  87. package/tether/v1/react/DeviceFrame.jsx +12 -0
  88. package/tether/v1/react/Dialog.jsx +43 -0
  89. package/tether/v1/react/Divider.jsx +10 -0
  90. package/tether/v1/react/Editor.jsx +85 -0
  91. package/tether/v1/react/EmptyState.jsx +13 -0
  92. package/tether/v1/react/Field.jsx +32 -0
  93. package/tether/v1/react/FileUpload.jsx +38 -0
  94. package/tether/v1/react/Filters.jsx +19 -0
  95. package/tether/v1/react/Infographics.jsx +54 -0
  96. package/tether/v1/react/InlineCTA.jsx +14 -0
  97. package/tether/v1/react/LineChart.jsx +23 -0
  98. package/tether/v1/react/Marketing.jsx +161 -0
  99. package/tether/v1/react/Menu.jsx +76 -0
  100. package/tether/v1/react/Metric.jsx +28 -0
  101. package/tether/v1/react/PageHeader.jsx +22 -0
  102. package/tether/v1/react/Pagination.jsx +32 -0
  103. package/tether/v1/react/Progress.jsx +26 -0
  104. package/tether/v1/react/RadioGroup.jsx +27 -0
  105. package/tether/v1/react/Reveal.jsx +17 -0
  106. package/tether/v1/react/SectionHeader.jsx +24 -0
  107. package/tether/v1/react/Select.jsx +19 -0
  108. package/tether/v1/react/Sheet.jsx +25 -0
  109. package/tether/v1/react/SideNav.jsx +37 -0
  110. package/tether/v1/react/Skeleton.jsx +16 -0
  111. package/tether/v1/react/Slider.jsx +27 -0
  112. package/tether/v1/react/Spinner.jsx +14 -0
  113. package/tether/v1/react/Steps.jsx +22 -0
  114. package/tether/v1/react/Switch.jsx +11 -0
  115. package/tether/v1/react/Table.jsx +24 -0
  116. package/tether/v1/react/Tabs.jsx +80 -0
  117. package/tether/v1/react/Tag.jsx +34 -0
  118. package/tether/v1/react/Tooltip.jsx +25 -0
  119. package/tether/v1/react/Tree.jsx +41 -0
  120. package/tether/v1/react/VideoPlayer.jsx +57 -0
  121. package/tether/v1/react/cx.js +4 -0
  122. package/tether/v1/react/index.js +59 -0
  123. package/tether/v1/system.json +44 -0
  124. package/tether/v1/tether.css +1661 -0
  125. package/tether/v1/tether.js +703 -0
@@ -0,0 +1,44 @@
1
+ # Tether
2
+
3
+ A design system exported from design-system-kit. Base: shadcn/ui. Themes: backstage + backstage-light (mode light).
4
+
5
+ ## Use it in a website or WordPress theme
6
+
7
+ ```html
8
+ <link rel="stylesheet" href="tether.css">
9
+ <script src="tether.js" defer></script> <!-- the interactive pieces: tabs, dialogs, menus … -->
10
+ <script src="motion.js" defer></script> <!-- animations: data-reveal and counters; optional -->
11
+ <script type="module" src="charts.js"></script> <!-- charts from attributes: <figure data-ui-viz="bar" ...>; optional -->
12
+ ```
13
+
14
+ Then use the `ui-` classes: see `system.json` and the workbench for examples.
15
+
16
+ ## Use it in a React app
17
+
18
+ Copy this folder into the app, import the stylesheet once at the root, and import components from `react/`:
19
+
20
+ ```js
21
+ import "./tether/tether.css";
22
+ import { Button, Badge, Alert, Field, Input } from "./tether/react"; // import what you use; react/index.js lists them all
23
+ ```
24
+
25
+ ## Light and dark
26
+
27
+ It starts light. Set `data-mode="light"` or `data-mode="dark"` on `<html>` to force one.
28
+
29
+ ## What was changed from the base
30
+
31
+ Nothing: the base as it ships.
32
+
33
+ ## Accessibility (contrast)
34
+
35
+ Checked 2026-10-04 against WCAG 2.1, which the ADA rule for state and local governments adopts at Level AA:
36
+
37
+ - Dark (backstage, custom colors): meets AA on all 96 contrast checks; AAA on 49 of 57 text checks.
38
+ - Light (backstage-light, custom colors): meets AA on all 96 contrast checks; AAA on 47 of 57 text checks.
39
+
40
+ Contrast is one part of WCAG. Keyboard use, focus order, text alternatives, captions, labels and the content itself are checked on the finished site.
41
+
42
+ ## Changing it
43
+
44
+ Edit the system in the workbench (the Design Systems page in the BKSTG Hub, or `node workbench.mjs` in design-system-kit), save, and export again. This folder is generated; edits made here are lost on the next export.
@@ -0,0 +1,349 @@
1
+ // charts-core.mjs: the kit's charts as pure geometry. One function turns data into a tree of SVG and HTML nodes;
2
+ // toHtml() writes it as a string (charts.js, for any site), toReact() builds the same nodes as React elements
3
+ // (react/Chart.jsx), so both draw the same chart. check-react.mjs renders one chart both ways and compares them.
4
+ //
5
+ // Every chart is drawn complete; animate() only plays it in (bars grow, lines draw, rings and donuts sweep, numbers
6
+ // count) when the motion level allows, so Still, the device's reduce-motion setting, or a stalled observer all
7
+ // leave the finished chart showing. Each chart carries a data table for screen readers; the SVG is decoration.
8
+ //
9
+ // Colors: one series uses the primary (--chart-line); several use --series-1.. in order, never cycled; a tone
10
+ // (good, warn, neg) is for data that means good or bad. Fills sit 2px apart in the card's color (WCAG 1.4.11:
11
+ // each then only needs 3:1 against the card). Sizes are in real pixels: the caller passes the width it has.
12
+
13
+ export const VIZ_TYPES = ["bar", "line", "area", "donut", "pie", "spark", "ring", "meter", "gauge", "heatmap", "funnel"];
14
+ const GAP = 2, R = 4; // the gap between fills, the rounding of a bar's free end
15
+
16
+ // ── data ─────────────────────────────────────────────────────────────────────
17
+ /** Read a chart from data attributes (any site, no code). Returns { ok, spec } or { ok: false, error }.
18
+ * data-ui-viz bar | line | area | donut | pie | spark | ring | meter
19
+ * data-label what the chart shows (the table's caption and the figure's name)
20
+ * data-labels categories, comma separated: "Jan,Feb,Mar"
21
+ * data-values one series: "12,18,9" or data-series several: "Desktop: 12,18,9; Mobile: 4,6,8"
22
+ * data-stacked stack the series (bar) data-horizontal bars run sideways (bar)
23
+ * data-value, data-max a ring's value and its whole (max defaults to 100)
24
+ * data-prefix, data-suffix, data-decimals how numbers read: "$", "%", 1
25
+ * data-tone good | warn | neg for one series that means good or bad
26
+ * data-center the words under a donut's or ring's middle number ("Leads")
27
+ * data-bars a sparkline of bars instead of a line; data-max on a meter: the whole (the rest shows as free)
28
+ * gauge data-value and data-max, like a ring, drawn as a half circle
29
+ * heatmap data-series, one row each ("Mon: 1,4,2; Tue: 3,0,5"), data-labels for the columns
30
+ * funnel data-values per stage, data-labels for the stages, widest first */
31
+ export function specFrom(get) {
32
+ const type = (get("data-ui-viz") || "").trim();
33
+ if (!VIZ_TYPES.includes(type)) return { ok: false, error: `data-ui-viz must be one of: ${VIZ_TYPES.join(", ")}.` };
34
+ const num = s => { const n = Number(String(s).trim()); return Number.isFinite(n) ? n : NaN; };
35
+ const labels = (get("data-labels") || "").split(",").map(s => s.trim()).filter(Boolean);
36
+ let series = [];
37
+ const raw = get("data-series");
38
+ if (raw) {
39
+ for (const part of raw.split(";").map(s => s.trim()).filter(Boolean)) {
40
+ const i = part.indexOf(":"); if (i < 1) return { ok: false, error: `data-series: "${part.slice(0, 30)}" needs a name, a colon, then numbers.` };
41
+ const values = part.slice(i + 1).split(",").map(num);
42
+ if (values.some(Number.isNaN)) return { ok: false, error: `data-series: ${part.slice(0, i)} has something that is not a number.` };
43
+ series.push({ name: part.slice(0, i).trim(), values });
44
+ }
45
+ } else if (get("data-values")) {
46
+ const values = get("data-values").split(",").map(num);
47
+ if (values.some(Number.isNaN)) return { ok: false, error: "data-values: use numbers separated by commas." };
48
+ series = [{ name: get("data-label") || "Value", values }];
49
+ }
50
+ const spec = { type, label: get("data-label") || "Chart", labels, series, stacked: get("data-stacked") !== null && get("data-stacked") !== undefined,
51
+ horizontal: get("data-horizontal") !== null && get("data-horizontal") !== undefined, bars: get("data-bars") !== null && get("data-bars") !== undefined, tone: get("data-tone") || "", center: get("data-center") || "",
52
+ prefix: get("data-prefix") || "", suffix: get("data-suffix") || "", decimals: +(get("data-decimals") || 0) };
53
+ if (type === "ring" || type === "gauge") { spec.value = num(get("data-value")); spec.max = get("data-max") ? num(get("data-max")) : 100;
54
+ if (Number.isNaN(spec.value) || Number.isNaN(spec.max) || spec.max <= 0) return { ok: false, error: "A ring or gauge needs data-value (and data-max, if not 100) as numbers." }; }
55
+ else if (!series.length || !series[0].values.length) return { ok: false, error: "Give data-values or data-series." };
56
+ if (type === "meter" && get("data-max")) { spec.max = num(get("data-max")); if (!(spec.max > 0)) return { ok: false, error: "data-max must be a number above 0." }; }
57
+ if (["donut", "pie", "meter", "funnel"].includes(type) && series.length > 1) return { ok: false, error: `A ${type} shows one set of parts: use data-values with data-labels.` };
58
+ if (["donut", "pie", "meter", "funnel", "heatmap"].includes(type) && series.some(x => x.values.some(v => v < 0))) return { ok: false, error: `A ${type}'s parts cannot be negative.` };
59
+ return { ok: true, spec };
60
+ }
61
+
62
+ // ── helpers ──────────────────────────────────────────────────────────────────
63
+ const el = (tag, attrs = {}, ...children) => ({ tag, attrs, children: children.flat().filter(c => c !== null && c !== undefined && c !== false) });
64
+ const f1 = n => Math.round(n * 10) / 10;
65
+ export const formatValue = (spec, v) => spec.prefix + Number(v).toLocaleString("en-US", { minimumFractionDigits: spec.decimals, maximumFractionDigits: spec.decimals }) + spec.suffix;
66
+ const shortNum = v => Math.abs(v) >= 1e6 ? f1(v / 1e6) + "M" : Math.abs(v) >= 1e3 ? f1(v / 1e3) + "k" : String(f1(v));
67
+ // one hue, light to dark, for amounts (heatmap) and stages in order (funnel): the primary mixed into the card.
68
+ // The darkest step is the primary itself (3:1 on the card); lighter steps rely on labels, hover and the table.
69
+ export const RAMP = [22, 40, 58, 78, 100];
70
+ const rampColor = pct => pct >= 100 ? "var(--chart-line)" : `color-mix(in srgb, var(--chart-line) ${pct}%, var(--p))`;
71
+ const colorOf = (spec, i) => spec.series.length > 1 && spec.type !== "heatmap" || ["donut", "pie", "meter"].includes(spec.type) ? `var(--series-${(i % 5) + 1})`
72
+ : spec.tone === "good" ? "var(--good)" : spec.tone === "warn" ? "var(--warn)" : spec.tone === "neg" ? "var(--neg)" : "var(--chart-line)";
73
+ // a tidy top for the value axis: 1, 2, 2.5 or 5 times a power of ten
74
+ function niceMax(v) { if (v <= 0) return 1; const p = 10 ** Math.floor(Math.log10(v)), m = v / p; return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p; }
75
+ // a bar: rounded at its free end only (the end away from the baseline), flat on the baseline
76
+ function barPath(x, y, w, h, horizontal, round) {
77
+ if (w <= 0 || h <= 0) return "";
78
+ const r = Math.min(round ? R : 0, horizontal ? w : h, horizontal ? h / 2 : w / 2);
79
+ if (horizontal) return `M${f1(x)} ${f1(y)}H${f1(x + w - r)}Q${f1(x + w)} ${f1(y)} ${f1(x + w)} ${f1(y + r)}V${f1(y + h - r)}Q${f1(x + w)} ${f1(y + h)} ${f1(x + w - r)} ${f1(y + h)}H${f1(x)}Z`;
80
+ return `M${f1(x)} ${f1(y + h)}V${f1(y + r)}Q${f1(x)} ${f1(y)} ${f1(x + r)} ${f1(y)}H${f1(x + w - r)}Q${f1(x + w)} ${f1(y)} ${f1(x + w)} ${f1(y + r)}V${f1(y + h)}Z`;
81
+ }
82
+
83
+ // ── the pieces around the plot: legend and data table ──────────────────────────
84
+ function legend(spec, names) {
85
+ if (names.length < 2) return null;
86
+ return el("ul", { class: "ui-viz-legend" }, names.map((n, i) => el("li", {}, el("span", { class: "ui-viz-key", style: `background:${colorOf(spec, i)}`, "aria-hidden": "true" }), n)));
87
+ }
88
+ function table(spec) {
89
+ const cap = el("caption", {}, spec.label);
90
+ if (spec.type === "ring" || spec.type === "gauge") return el("table", { class: "ui-sr-only" }, cap, el("tbody", {}, el("tr", {}, el("th", { scope: "row" }, spec.center || "Value"), el("td", {}, `${formatValue(spec, spec.value)} of ${formatValue(spec, spec.max)}`))));
91
+ const s = spec.series, cats = spec.labels.length ? spec.labels : s[0].values.map((_, i) => String(i + 1));
92
+ if (spec.type === "funnel") {
93
+ const first = s[0].values[0] || 1;
94
+ return el("table", { class: "ui-sr-only" }, cap, el("thead", {}, el("tr", {}, el("th", { scope: "col" }, "Stage"), el("th", { scope: "col" }, "Value"), el("th", { scope: "col" }, "Of the first"))),
95
+ el("tbody", {}, s[0].values.map((v, i) => el("tr", {}, el("th", { scope: "row" }, cats[i] || String(i + 1)), el("td", {}, formatValue(spec, v)), el("td", {}, `${Math.round(v / first * 100)}%`)))));
96
+ }
97
+ if (["donut", "pie", "meter"].includes(spec.type)) {
98
+ const total = s[0].values.reduce((a, b) => a + b, 0) || 1;
99
+ return el("table", { class: "ui-sr-only" }, cap, el("thead", {}, el("tr", {}, el("th", { scope: "col" }, "Part"), el("th", { scope: "col" }, "Value"), el("th", { scope: "col" }, "Share"))),
100
+ el("tbody", {}, s[0].values.map((v, i) => el("tr", {}, el("th", { scope: "row" }, cats[i] || String(i + 1)), el("td", {}, formatValue(spec, v)), el("td", {}, `${Math.round(v / total * 100)}%`)))));
101
+ }
102
+ return el("table", { class: "ui-sr-only" }, cap, el("thead", {}, el("tr", {}, el("th", { scope: "col" }, ""), s.map(x => el("th", { scope: "col" }, x.name)))),
103
+ el("tbody", {}, cats.map((c, i) => el("tr", {}, el("th", { scope: "row" }, c), s.map(x => el("td", {}, x.values[i] === undefined ? "" : formatValue(spec, x.values[i])))))));
104
+ }
105
+
106
+ // ── the plots ────────────────────────────────────────────────────────────────
107
+ function axisPlot(spec, width, height, type) {
108
+ const s = spec.series, n = Math.max(...s.map(x => x.values.length)), cats = spec.labels.length ? spec.labels : Array.from({ length: n }, (_, i) => String(i + 1));
109
+ const horizontal = type === "bar" && spec.horizontal, stacked = type === "bar" && spec.stacked && s.length > 1;
110
+ const top = stacked ? Math.max(...cats.map((_, i) => s.reduce((a, x) => a + Math.max(0, x.values[i] || 0), 0))) : Math.max(0, ...s.flatMap(x => x.values));
111
+ const max = niceMax(top), ticks = [0, 0.25, 0.5, 0.75, 1].map(k => k * max);
112
+ const pad = horizontal ? { t: 4, r: 12, b: 22, l: Math.min(140, 12 + 7 * Math.max(...cats.map(c => c.length))) } : { t: 10, r: 8, b: 26, l: 12 + 7 * Math.max(...ticks.map(t => shortNum(t).length)) };
113
+ const W = Math.max(120, width), H = Math.max(80, height), pw = W - pad.l - pad.r, ph = H - pad.t - pad.b;
114
+ const kids = [];
115
+ // grid and ticks, recessive
116
+ for (const t of ticks) {
117
+ if (horizontal) { const x = pad.l + t / max * pw; kids.push(el("line", { class: "ui-viz-grid", x1: f1(x), x2: f1(x), y1: pad.t, y2: f1(pad.t + ph) }), el("text", { class: "ui-viz-tick", x: f1(x), y: f1(H - 6), "text-anchor": "middle" }, shortNum(t))); }
118
+ else { const y = pad.t + ph - t / max * ph; kids.push(el("line", { class: "ui-viz-grid", x1: pad.l, x2: f1(W - pad.r), y1: f1(y), y2: f1(y) }), el("text", { class: "ui-viz-tick", x: pad.l - 8, y: f1(y + 4), "text-anchor": "end" }, shortNum(t))); }
119
+ }
120
+ const band = (horizontal ? ph : pw) / cats.length;
121
+ // show every Nth label so they never touch: about 7px a character at caption size, plus a 10px gap (no DOM measuring, so HTML and React match)
122
+ const every = Math.max(1, Math.ceil((Math.max(...cats.map(c => String(c).length)) * 7 + 10) / band));
123
+ cats.forEach((c, i) => {
124
+ if (horizontal) kids.push(el("text", { class: "ui-viz-tick", x: pad.l - 8, y: f1(pad.t + band * (i + 0.5) + 4), "text-anchor": "end" }, c));
125
+ else if (i % every === 0) kids.push(el("text", { class: "ui-viz-tick", x: f1(pad.l + band * (i + 0.5)), y: f1(H - 8), "text-anchor": "middle" }, c));
126
+ });
127
+ if (type === "bar") {
128
+ const group = band * (s.length > 1 && !stacked ? 0.78 : 0.56), k = stacked ? 1 : s.length, bw = Math.max(2, (group - GAP * (k - 1)) / k);
129
+ cats.forEach((c, i) => {
130
+ let acc = 0;
131
+ s.forEach((x, j) => {
132
+ const v = Math.max(0, x.values[i] || 0), len = v / max * (horizontal ? pw : ph), last = !stacked || j === s.length - 1 || s.slice(j + 1).every(y => !(y.values[i] > 0));
133
+ const start = band * i + (band - group) / 2 + (stacked ? 0 : j * (bw + GAP));
134
+ // stacked: every segment but the base gives up GAP at its start, so fills never touch
135
+ const lead = stacked && acc > 0 ? GAP : 0, sz = Math.max(0, len - lead);
136
+ const d = horizontal ? barPath(pad.l + acc + lead, pad.t + start, sz, bw, true, last) : barPath(pad.l + start, pad.t + ph - acc - len, bw, sz, false, last);
137
+ if (d) kids.push(el("path", { class: "ui-viz-mark ui-viz-bar", d, fill: colorOf(spec, j), "data-i": i, "data-s": j, "data-tip": `${c}${s.length > 1 ? ", " + x.name : ""}: ${formatValue(spec, x.values[i] || 0)}` }));
138
+ if (stacked) acc += len; // side by side, every bar starts at the baseline
139
+ });
140
+ });
141
+ } else {
142
+ // line and area: one path per series; the area fills to the baseline under its line
143
+ s.forEach((x, j) => {
144
+ const pts = x.values.map((v, i) => [pad.l + (cats.length === 1 ? pw / 2 : i / (cats.length - 1) * pw), pad.t + ph - Math.max(0, v) / max * ph]);
145
+ const line = pts.map((p, i) => (i ? "L" : "M") + f1(p[0]) + " " + f1(p[1])).join("");
146
+ if (type === "area") kids.push(el("path", { class: "ui-viz-area", d: `${line}L${f1(pts[pts.length - 1][0])} ${f1(pad.t + ph)}L${f1(pts[0][0])} ${f1(pad.t + ph)}Z`, fill: colorOf(spec, j) }));
147
+ kids.push(el("path", { class: "ui-viz-mark ui-viz-line", d: line, stroke: colorOf(spec, j), fill: "none", "data-s": j }));
148
+ });
149
+ kids.push(el("line", { class: "ui-viz-cross", x1: 0, x2: 0, y1: pad.t, y2: f1(pad.t + ph), hidden: "" }));
150
+ }
151
+ return { svg: el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false", "data-pad": `${pad.l},${pad.r}`, "data-n": cats.length }, kids), names: s.map(x => x.name) };
152
+ }
153
+
154
+ function roundPlot(spec, width, height) {
155
+ // donut, pie and ring: circles with a dash per part (a sweep is a dash growing), parts GAP apart
156
+ const size = Math.max(80, Math.min(width, height)), c = size / 2, ring = spec.type === "ring";
157
+ const thick = spec.type === "pie" ? c - 2 : ring ? Math.max(8, size * 0.1) : Math.max(14, size * 0.17), r = spec.type === "pie" ? (c - 2) / 2 : c - thick / 2 - 2;
158
+ const L = 2 * Math.PI * r, kids = [el("circle", { class: "ui-viz-track", cx: c, cy: c, r: f1(r), "stroke-width": f1(thick), fill: "none" })];
159
+ let middle = null;
160
+ if (ring) {
161
+ const part = Math.max(0, Math.min(1, spec.value / spec.max));
162
+ kids.push(el("circle", { class: "ui-viz-mark ui-viz-sweep", cx: c, cy: c, r: f1(r), fill: "none", stroke: colorOf(spec, 0), "stroke-width": f1(thick), "stroke-linecap": part > 0 && part < 1 ? "round" : "butt",
163
+ "stroke-dasharray": `${f1(part * L)} ${f1(L)}`, transform: `rotate(-90 ${c} ${c})`, "data-tip": `${formatValue(spec, spec.value)} of ${formatValue(spec, spec.max)}` }));
164
+ middle = { value: Math.round(part * 100), suffix: "%", label: spec.center };
165
+ } else {
166
+ const vals = spec.series[0].values, total = vals.reduce((a, b) => a + b, 0) || 1, cats = spec.labels;
167
+ let at = 0; const gap = vals.filter(v => v > 0).length > 1 ? GAP : 0;
168
+ vals.forEach((v, i) => {
169
+ const len = v / total * L;
170
+ if (len > gap) kids.push(el("circle", { class: "ui-viz-mark ui-viz-sweep", cx: c, cy: c, r: f1(r), fill: "none", stroke: colorOf(spec, i), "stroke-width": f1(thick),
171
+ "stroke-dasharray": `${f1(len - gap)} ${f1(L - len + gap)}`, "stroke-dashoffset": f1(-at), transform: `rotate(-90 ${c} ${c})`, "data-i": i, "data-tip": `${cats[i] || i + 1}: ${formatValue(spec, v)} (${Math.round(v / total * 100)}%)` }));
172
+ at += len;
173
+ });
174
+ if (spec.type === "donut") middle = { value: total, prefix: spec.prefix, suffix: spec.suffix, label: spec.center };
175
+ }
176
+ const svg = el("svg", { class: "ui-viz-svg", width: size, height: size, viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", focusable: "false" }, kids);
177
+ const mid = middle ? el("div", { class: "ui-viz-middle", "aria-hidden": "true" }, el("strong", { "data-count-to": middle.value, "data-count-prefix": middle.prefix || "", "data-count-suffix": middle.suffix || "" }, (middle.prefix || "") + Number(middle.value).toLocaleString("en-US") + (middle.suffix || "")), middle.label ? el("span", {}, middle.label) : null) : null;
178
+ return { svg: el("div", { class: "ui-viz-round", style: `width:${size}px;height:${size}px` }, svg, mid), names: ring ? [] : spec.labels };
179
+ }
180
+
181
+ function sparkPlot(spec, width, height) {
182
+ const v = spec.series[0].values, W = Math.max(40, width), H = Math.max(16, height), max = Math.max(...v, 0) || 1, min = Math.min(...v, 0);
183
+ const kids = [];
184
+ if (spec.horizontal || spec.stacked) { /* not meaningful for a sparkline; ignored */ }
185
+ if (spec.bars) {
186
+ const bw = Math.max(2, (W - GAP * (v.length - 1)) / v.length);
187
+ v.forEach((x, i) => { const h = Math.max(1, (x - min) / (max - min || 1) * (H - 2)); kids.push(el("path", { class: "ui-viz-mark ui-viz-bar", d: barPath(i * (bw + GAP), H - h, bw, h, false, true), fill: colorOf(spec, 0), "data-i": i, "data-tip": `${spec.labels[i] || i + 1}: ${formatValue(spec, x)}` })); });
188
+ } else {
189
+ const pts = v.map((x, i) => [v.length === 1 ? W / 2 : i / (v.length - 1) * (W - 4) + 2, 2 + (H - 4) * (1 - (x - min) / (max - min || 1))]);
190
+ kids.push(el("path", { class: "ui-viz-mark ui-viz-line", d: pts.map((p, i) => (i ? "L" : "M") + f1(p[0]) + " " + f1(p[1])).join(""), fill: "none", stroke: colorOf(spec, 0) }));
191
+ }
192
+ return { svg: el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids), names: [] };
193
+ }
194
+
195
+ function meterPlot(spec, width) {
196
+ // one bar of parts, GAP apart, rounded at the two outer ends; the rest of the whole is the track
197
+ const v = spec.series[0].values, W = Math.max(80, width), H = 10, total = spec.max || v.reduce((a, b) => a + b, 0) || 1;
198
+ const kids = [el("rect", { class: "ui-viz-trackbar", x: 0, y: 0, width: W, height: H, rx: H / 2 })];
199
+ let x = 0; const shown = v.filter(n => n > 0).length;
200
+ v.forEach((n, i) => {
201
+ const len = n / total * W; if (len <= 0) return;
202
+ const idx = v.slice(0, i).filter(k => k > 0).length, first = idx === 0, last = idx === shown - 1, w = Math.max(0, len - (last ? 0 : GAP));
203
+ kids.push(el("rect", { class: "ui-viz-mark ui-viz-seg", x: f1(x), y: 0, width: f1(w), height: H, rx: first || last ? H / 2 : 0, fill: colorOf(spec, i), "data-i": i, "data-tip": `${spec.labels[i] || i + 1}: ${formatValue(spec, n)}` }));
204
+ x += len;
205
+ });
206
+ return { svg: el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids), names: spec.labels };
207
+ }
208
+
209
+
210
+ function gaugePlot(spec, width) {
211
+ // a half ring: the track, then the value from the left end; the number sits in the middle of the arc
212
+ const W = Math.max(120, Math.min(width, 280)), thick = Math.max(10, W * 0.09), r = W / 2 - thick / 2 - 2, cx = W / 2, cy = W / 2, H = Math.round(cy + thick / 2 + 4);
213
+ const L = Math.PI * r, part = Math.max(0, Math.min(1, spec.value / spec.max)), arc = `M${f1(cx - r)} ${f1(cy)}A${f1(r)} ${f1(r)} 0 0 1 ${f1(cx + r)} ${f1(cy)}`;
214
+ const kids = [el("path", { class: "ui-viz-track", d: arc, fill: "none", "stroke-width": f1(thick), "stroke-linecap": "round" })];
215
+ if (part > 0) kids.push(el("path", { class: "ui-viz-mark ui-viz-sweep", d: arc, fill: "none", stroke: colorOf(spec, 0), "stroke-width": f1(thick), "stroke-linecap": "round",
216
+ "stroke-dasharray": `${f1(part * L)} ${f1(L)}`, "data-tip": `${formatValue(spec, spec.value)} of ${formatValue(spec, spec.max)}` }));
217
+ const svg = el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids);
218
+ const mid = el("div", { class: "ui-viz-middle ui-viz-middle--gauge", "aria-hidden": "true" }, el("strong", { "data-count-to": spec.value, "data-count-prefix": spec.prefix, "data-count-suffix": spec.suffix }, formatValue(spec, spec.value)), spec.center ? el("span", {}, spec.center) : null);
219
+ return { svg: el("div", { class: "ui-viz-round ui-viz-round--gauge", style: `width:${W}px;height:${H}px` }, svg, mid), names: [] };
220
+ }
221
+
222
+ function heatPlot(spec, width) {
223
+ // rows by columns of cells, GAP apart; each cell's step is its value's share of the largest
224
+ const rows = spec.series, cols = spec.labels.length ? spec.labels : rows[0].values.map((_, i) => String(i + 1));
225
+ const lw = Math.min(110, 10 + 7 * Math.max(...rows.map(r => r.name.length))), W = Math.max(160, width), cw = (W - lw) / cols.length, ch = Math.min(28, Math.max(14, cw));
226
+ const max = Math.max(1, ...rows.flatMap(r => r.values)), H = rows.length * ch + 18, kids = [];
227
+ rows.forEach((r, y) => {
228
+ kids.push(el("text", { class: "ui-viz-tick", x: lw - 8, y: f1(y * ch + ch / 2 + 4), "text-anchor": "end" }, r.name));
229
+ cols.forEach((c, x) => {
230
+ const v = r.values[x] || 0, step = RAMP[Math.min(RAMP.length - 1, Math.floor(v / max * (RAMP.length - 0.001)))];
231
+ kids.push(el("rect", { class: "ui-viz-mark ui-viz-cell", x: f1(lw + x * cw), y: f1(y * ch), width: f1(Math.max(1, cw - GAP)), height: f1(ch - GAP), rx: 3,
232
+ fill: v > 0 ? rampColor(step) : "var(--mid)", "data-i": x, "data-tip": `${r.name}, ${c}: ${formatValue(spec, v)}` }));
233
+ });
234
+ });
235
+ cols.forEach((c, x) => { if (cols.length <= 14 || x % Math.ceil(cols.length / 12) === 0) kids.push(el("text", { class: "ui-viz-tick", x: f1(lw + x * cw + cw / 2), y: f1(H - 4), "text-anchor": "middle" }, c)); });
236
+ const svg = el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids);
237
+ const key = el("div", { class: "ui-viz-ramp", "aria-hidden": "true" }, el("span", {}, "Less"), RAMP.map(p => el("i", { style: `background:${rampColor(p)}` })), el("span", {}, "More"));
238
+ return { svg, names: [], extra: key };
239
+ }
240
+
241
+ function funnelPlot(spec, width) {
242
+ // stages as centered bars, widest first, each a darker-to-lighter step of one hue; the label and value sit beside it
243
+ const v = spec.series[0].values, cats = spec.labels, W = Math.max(160, width), first = v[0] || 1, bh = 30, H = v.length * (bh + GAP * 3);
244
+ const lw = Math.min(150, 12 + 7 * Math.max(...cats.map(c => c.length), 4)), pw = W - lw - 90, kids = [];
245
+ v.forEach((n, i) => {
246
+ const w = Math.max(2, n / first * pw), x = lw + (pw - w) / 2, y = i * (bh + GAP * 3), step = RAMP[Math.max(0, RAMP.length - 1 - i)];
247
+ kids.push(el("text", { class: "ui-viz-tick ui-viz-tick--strong", x: lw - 10, y: f1(y + bh / 2 + 4), "text-anchor": "end" }, cats[i] || String(i + 1)));
248
+ kids.push(el("rect", { class: "ui-viz-mark ui-viz-seg", x: f1(x), y: f1(y), width: f1(w), height: bh, rx: R, fill: rampColor(step), "data-i": i, "data-tip": `${cats[i] || i + 1}: ${formatValue(spec, n)} (${Math.round(n / first * 100)}% of ${cats[0] || "the first"})` }));
249
+ kids.push(el("text", { class: "ui-viz-tick", x: f1(lw + pw + 10), y: f1(y + bh / 2 + 4), "text-anchor": "start" }, `${formatValue(spec, n)} · ${Math.round(n / first * 100)}%`));
250
+ });
251
+ return { svg: el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids), names: [] };
252
+ }
253
+
254
+ /** The whole chart for a width (and height, for axis charts): a list of nodes to put inside the figure */
255
+ export function chartNodes(spec, width = 600, height = 220) {
256
+ const t = spec.type;
257
+ const plot = t === "bar" || t === "line" || t === "area" ? axisPlot(spec, width, height, t) : t === "spark" ? sparkPlot(spec, width, height)
258
+ : t === "meter" ? meterPlot(spec, width) : t === "gauge" ? gaugePlot(spec, width) : t === "heatmap" ? heatPlot(spec, width) : t === "funnel" ? funnelPlot(spec, width) : roundPlot(spec, width, height);
259
+ return [el("div", { class: "ui-viz-plot" }, plot.svg, el("span", { class: "ui-viz-tip", "aria-hidden": "true", hidden: "" })), plot.extra || null, legend(spec, plot.names), table(spec)];
260
+ }
261
+ /** An error, shown where the chart would be, so a typo is never a silent blank */
262
+ export const errorNodes = msg => [el("p", { class: "ui-viz-error", role: "note" }, "Chart data could not be read: " + msg)];
263
+
264
+ // ── writing the tree ─────────────────────────────────────────────────────────
265
+ const escText = s => String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
266
+ const escAttr = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
267
+ export function toHtml(nodes) {
268
+ return nodes.filter(Boolean).map(n => typeof n === "object" ? `<${n.tag}${Object.entries(n.attrs).map(([k, v]) => ` ${k}="${escAttr(v)}"`).join("")}>${toHtml(n.children)}</${n.tag}>` : escText(n)).join("");
269
+ }
270
+ const REACT_NAME = { class: "className", "stroke-width": "strokeWidth", "stroke-dasharray": "strokeDasharray", "stroke-dashoffset": "strokeDashoffset", "stroke-linecap": "strokeLinecap", "text-anchor": "textAnchor", viewBox: "viewBox" };
271
+ const styleObj = s => Object.fromEntries(s.split(";").filter(Boolean).map(p => { const [k, ...v] = p.split(":"); return [k.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase()), v.join(":").trim()]; }));
272
+ /** The same tree as React elements: h is React.createElement */
273
+ export function toReact(nodes, h) {
274
+ return nodes.filter(Boolean).map((n, i) => {
275
+ if (typeof n !== "object") return n;
276
+ const props = { key: i };
277
+ for (const [k, v] of Object.entries(n.attrs)) {
278
+ if (k === "style") props.style = styleObj(v);
279
+ else if (k === "hidden") props.hidden = true;
280
+ else props[REACT_NAME[k] || k] = v;
281
+ }
282
+ return h(n.tag, props, ...toReact(n.children, h));
283
+ });
284
+ }
285
+
286
+ // ── hover: one value under the pointer (lines: the nearest category, every series) ──────────────────────────
287
+ export function hideTip(fig) {
288
+ if (!fig) return; const t = fig.querySelector(".ui-viz-tip"), x = fig.querySelector(".ui-viz-cross");
289
+ if (t) t.hidden = true; if (x) x.setAttribute("hidden", "");
290
+ }
291
+ export function showTip(fig, spec, e) {
292
+ const tip = fig.querySelector(".ui-viz-tip"), plot = fig.querySelector(".ui-viz-plot"); if (!tip || !plot) return;
293
+ const box = plot.getBoundingClientRect(); let text = "";
294
+ const mark = e.target.closest && e.target.closest("[data-tip]");
295
+ if (mark) text = mark.getAttribute("data-tip");
296
+ else if (spec.type === "line" || spec.type === "area") {
297
+ const svg = fig.querySelector(".ui-viz-svg"), [pl, pr] = (svg.getAttribute("data-pad") || "0,0").split(",").map(Number), n = +svg.getAttribute("data-n");
298
+ const w = svg.getBoundingClientRect().width, i = Math.round((e.clientX - box.left - pl) / Math.max(1, w - pl - pr) * (n - 1));
299
+ if (i >= 0 && i < n) {
300
+ const cx = pl + (n === 1 ? (w - pl - pr) / 2 : i / (n - 1) * (w - pl - pr)), cross = svg.querySelector(".ui-viz-cross");
301
+ cross.setAttribute("x1", cx); cross.setAttribute("x2", cx); cross.removeAttribute("hidden");
302
+ text = (spec.labels[i] ? spec.labels[i] + ": " : "") + spec.series.map(x => (spec.series.length > 1 ? x.name + " " : "") + (x.values[i] === undefined ? "" : formatValue(spec, x.values[i]))).join(", ");
303
+ }
304
+ }
305
+ if (!text) { hideTip(fig); return; }
306
+ tip.textContent = text; tip.hidden = false;
307
+ const x = e.clientX - box.left, y = e.clientY - box.top;
308
+ tip.style.left = Math.max(0, Math.min(box.width - tip.offsetWidth, x - tip.offsetWidth / 2)) + "px";
309
+ tip.style.top = Math.max(0, y - tip.offsetHeight - 12) + "px";
310
+ }
311
+
312
+ // ── motion: play a drawn chart in ────────────────────────────────────────────
313
+ /** level: "full" | "calm" | "still". Reads the kit's motion tokens; does nothing it cannot undo. */
314
+ export function animate(fig, level) {
315
+ if (!fig || typeof fig.animate !== "function" || level === "still") return;
316
+ if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
317
+ const css = getComputedStyle(fig), ms = (n, d) => { const v = css.getPropertyValue(n).trim(); return v ? (v.endsWith("ms") ? parseFloat(v) : parseFloat(v) * 1000) : d; };
318
+ const slow = ms("--dur-slow", 600), fast = ms("--dur-fast", 150), stagger = ms("--motion-stagger", 40), ease = css.getPropertyValue("--ease-enter").trim() || "cubic-bezier(.16,1,.3,1)";
319
+ const svg = fig.querySelector(".ui-viz-svg"); if (!svg) return;
320
+ if (level === "calm") { fig.querySelector(".ui-viz-plot")?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fast, easing: "ease-out" }); return; }
321
+ const horizontal = fig.classList.contains("ui-viz--horizontal");
322
+ const funnel = fig.classList.contains("ui-viz--funnel");
323
+ fig.querySelectorAll(".ui-viz-cell").forEach(c => c.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fast * 2, delay: +(c.getAttribute("data-i") || 0) * (stagger / 2), easing: "ease-out", fill: "backwards" }));
324
+ fig.querySelectorAll(".ui-viz-bar, .ui-viz-seg").forEach(b => {
325
+ const i = +(b.getAttribute("data-i") || 0);
326
+ b.style.transformBox = "fill-box"; b.style.transformOrigin = funnel ? "center center" : horizontal || b.classList.contains("ui-viz-seg") ? "left center" : "center bottom";
327
+ b.animate([{ transform: horizontal || b.classList.contains("ui-viz-seg") ? "scaleX(0)" : "scaleY(0)" }, { transform: "none" }], { duration: slow, delay: i * stagger, easing: ease, fill: "backwards" });
328
+ });
329
+ fig.querySelectorAll(".ui-viz-line").forEach(p => {
330
+ const len = typeof p.getTotalLength === "function" ? p.getTotalLength() : 0; if (!len) return;
331
+ p.animate([{ strokeDasharray: `${len} ${len}`, strokeDashoffset: len }, { strokeDasharray: `${len} ${len}`, strokeDashoffset: 0 }], { duration: slow * 1.4, easing: ease, fill: "backwards" });
332
+ });
333
+ fig.querySelectorAll(".ui-viz-area").forEach(a => a.animate([{ opacity: 0 }, { opacity: 1 }], { duration: slow, delay: slow * 0.4, easing: "ease-out", fill: "backwards" }));
334
+ fig.querySelectorAll(".ui-viz-sweep").forEach((c, i) => {
335
+ const [len, rest] = (c.getAttribute("stroke-dasharray") || "0 0").split(" ").map(Number);
336
+ c.animate([{ strokeDasharray: `0 ${len + rest}` }, { strokeDasharray: `${len} ${rest}` }], { duration: slow, delay: i * stagger, easing: ease, fill: "backwards" });
337
+ });
338
+ // the middle number counts up; the finished number is already in the text
339
+ fig.querySelectorAll("[data-count-to]").forEach(n => {
340
+ const to = +n.getAttribute("data-count-to"), pre = n.getAttribute("data-count-prefix") || "", suf = n.getAttribute("data-count-suffix") || "", t0 = performance.now(), dur = slow * 1.2;
341
+ const step = now => { const k = Math.min(1, (now - t0) / dur), e = 1 - (1 - k) ** 3; n.textContent = pre + Math.round(to * e).toLocaleString("en-US") + suf; if (k < 1) requestAnimationFrame(step); };
342
+ requestAnimationFrame(step);
343
+ });
344
+ }
345
+ /** The motion level of the page: the Look's setting, then the device's */
346
+ export function motionLevel() {
347
+ const m = typeof document !== "undefined" && document.documentElement.getAttribute("data-motion");
348
+ return m === "still" || m === "calm" ? m : "full";
349
+ }
@@ -0,0 +1,58 @@
1
+ // charts.js: the kit's charts on any page, no code. Load it once as a module:
2
+ // <script type="module" src="charts.js"></script>
3
+ // then describe a chart with attributes (see charts-core.mjs, specFrom, for every one):
4
+ // <figure data-ui-viz="bar" data-label="Monthly expenses" data-labels="Jan,Feb,Mar" data-values="4,7,5" data-prefix="$"></figure>
5
+ // It draws at the figure's real width, redraws when that changes, plays in once when it scrolls into view (only as
6
+ // far as the motion level allows), shows a value on hover, and keeps a data table for screen readers.
7
+ // window.UIViz = { init(scope), draw(figure) } for pages that add charts later (the workbench does).
8
+ import { specFrom, chartNodes, errorNodes, toHtml, animate, motionLevel, showTip, hideTip } from "./charts-core.mjs";
9
+
10
+ function draw(fig) {
11
+ fig.classList.add("ui-viz");
12
+ const r = specFrom(n => fig.getAttribute(n));
13
+ if (!r.ok) { fig.innerHTML = toHtml(errorNodes(r.error)); fig._viz = null; return; }
14
+ const spec = r.spec, css = getComputedStyle(fig);
15
+ fig._viz = spec;
16
+ fig.classList.add("ui-viz--" + spec.type);
17
+ fig.classList.toggle("ui-viz--horizontal", !!spec.horizontal);
18
+ if (fig.tagName !== "FIGURE" && !fig.hasAttribute("role")) fig.setAttribute("role", "figure");
19
+ fig.setAttribute("aria-label", spec.label);
20
+ const w = Math.round(fig.clientWidth) || 600, h = parseFloat(css.getPropertyValue("--viz-h")) || (spec.type === "spark" ? 40 : 220);
21
+ fig.innerHTML = toHtml(chartNodes(spec, w, h));
22
+ fig._vizW = w;
23
+ }
24
+
25
+ // play in once, when at least a quarter of the chart is on screen
26
+ const seen = typeof IntersectionObserver === "function" ? new IntersectionObserver(entries => {
27
+ for (const e of entries) if (e.isIntersecting) { seen.unobserve(e.target); animate(e.target, motionLevel()); }
28
+ }, { threshold: 0.25 }) : null;
29
+ // redraw when the width changes, without playing in again
30
+ const sized = typeof ResizeObserver === "function" ? new ResizeObserver(entries => {
31
+ for (const e of entries) { const fig = e.target, w = Math.round(e.contentRect.width); if (fig._viz && Math.abs(w - (fig._vizW || 0)) > 8) draw(fig); }
32
+ }) : null;
33
+
34
+ function init(scope = document) {
35
+ const list = scope.matches && scope.matches("[data-ui-viz]") ? [scope] : Array.from(scope.querySelectorAll("[data-ui-viz]"));
36
+ for (const fig of list) {
37
+ if (fig._vizWired) { draw(fig); continue; }
38
+ fig._vizWired = true; draw(fig);
39
+ if (sized) sized.observe(fig);
40
+ // data-viz-motion="none": the page animates it (GSAP, its own); the kit only draws it
41
+ if (seen && fig._viz && fig.getAttribute("data-viz-motion") !== "none") seen.observe(fig);
42
+ }
43
+ }
44
+
45
+ // hover: the value of the bar, part or point under the pointer (lines: the nearest category, every series)
46
+ document.addEventListener("pointermove", e => {
47
+ const fig = e.target.closest && e.target.closest(".ui-viz");
48
+ document.querySelectorAll(".ui-viz-tip:not([hidden])").forEach(t => { const f = t.closest(".ui-viz"); if (f !== fig) hideTip(f); });
49
+ if (fig && fig._viz) showTip(fig, fig._viz, e);
50
+ });
51
+
52
+ // charts added later (a workbench card, a page that renders on demand) are found and drawn too
53
+ if (typeof MutationObserver === "function") new MutationObserver(records => {
54
+ for (const r of records) for (const n of r.addedNodes) if (n.nodeType === 1 && (n.matches("[data-ui-viz]") || n.querySelector("[data-ui-viz]"))) init(n);
55
+ }).observe(document.documentElement, { childList: true, subtree: true });
56
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => init()); else init();
57
+
58
+ window.UIViz = { init, draw };