@anvpx/motion-scenes 0.0.0-stage → 0.1.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 (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -2
  3. package/dist/catalog.d.ts +17 -0
  4. package/dist/catalog.js +51 -0
  5. package/dist/custom.d.ts +86 -0
  6. package/dist/custom.js +176 -0
  7. package/dist/define-scene.d.ts +31 -0
  8. package/dist/define-scene.js +128 -0
  9. package/dist/html.d.ts +5 -0
  10. package/dist/html.js +14 -0
  11. package/dist/index.d.ts +11 -0
  12. package/dist/index.js +11 -0
  13. package/dist/position.d.ts +21 -0
  14. package/dist/position.js +16 -0
  15. package/dist/registry.d.ts +4 -0
  16. package/dist/registry.js +68 -0
  17. package/dist/runtime/compose.d.ts +21 -0
  18. package/dist/runtime/compose.js +43 -0
  19. package/dist/runtime/easing.d.ts +14 -0
  20. package/dist/runtime/easing.js +45 -0
  21. package/dist/runtime/mount.d.ts +50 -0
  22. package/dist/runtime/mount.js +153 -0
  23. package/dist/runtime/timeline.d.ts +147 -0
  24. package/dist/runtime/timeline.js +199 -0
  25. package/dist/runtime-files.d.ts +4 -0
  26. package/dist/runtime-files.js +18 -0
  27. package/dist/scene-contract.d.ts +7 -0
  28. package/dist/scene-contract.js +104 -0
  29. package/dist/scene3d/post.d.ts +12 -0
  30. package/dist/scene3d/post.js +16 -0
  31. package/dist/scene3d/reflector.d.ts +9 -0
  32. package/dist/scene3d/reflector.js +17 -0
  33. package/dist/scene3d/textures.d.ts +9 -0
  34. package/dist/scene3d/textures.js +11 -0
  35. package/dist/scene3d/volumetric.d.ts +9 -0
  36. package/dist/scene3d/volumetric.js +16 -0
  37. package/dist/scenes/bug/index.d.ts +1 -0
  38. package/dist/scenes/bug/index.js +45 -0
  39. package/dist/scenes/bug/scene.d.ts +2 -0
  40. package/dist/scenes/bug/scene.js +6 -0
  41. package/dist/scenes/bug/style.css +30 -0
  42. package/dist/scenes/bullets/index.d.ts +1 -0
  43. package/dist/scenes/bullets/index.js +128 -0
  44. package/dist/scenes/bullets/scene.d.ts +2 -0
  45. package/dist/scenes/bullets/scene.js +6 -0
  46. package/dist/scenes/bullets/style.css +91 -0
  47. package/dist/scenes/callout/index.d.ts +9 -0
  48. package/dist/scenes/callout/index.js +95 -0
  49. package/dist/scenes/callout/scene.d.ts +2 -0
  50. package/dist/scenes/callout/scene.js +43 -0
  51. package/dist/scenes/callout/style.css +97 -0
  52. package/dist/scenes/caption/index.d.ts +1 -0
  53. package/dist/scenes/caption/index.js +72 -0
  54. package/dist/scenes/caption/scene.d.ts +11 -0
  55. package/dist/scenes/caption/scene.js +33 -0
  56. package/dist/scenes/caption/style.css +33 -0
  57. package/dist/scenes/chapter/index.d.ts +1 -0
  58. package/dist/scenes/chapter/index.js +29 -0
  59. package/dist/scenes/chapter/scene.d.ts +2 -0
  60. package/dist/scenes/chapter/scene.js +6 -0
  61. package/dist/scenes/chapter/style.css +25 -0
  62. package/dist/scenes/chart/index.d.ts +1 -0
  63. package/dist/scenes/chart/index.js +24 -0
  64. package/dist/scenes/chart/render.d.ts +4 -0
  65. package/dist/scenes/chart/render.js +257 -0
  66. package/dist/scenes/chart/scale.d.ts +11 -0
  67. package/dist/scenes/chart/scale.js +53 -0
  68. package/dist/scenes/chart/scene.d.ts +2 -0
  69. package/dist/scenes/chart/scene.js +73 -0
  70. package/dist/scenes/chart/schema.d.ts +36 -0
  71. package/dist/scenes/chart/schema.js +109 -0
  72. package/dist/scenes/chart/style.css +425 -0
  73. package/dist/scenes/code/index.d.ts +1 -0
  74. package/dist/scenes/code/index.js +109 -0
  75. package/dist/scenes/code/scene.d.ts +2 -0
  76. package/dist/scenes/code/scene.js +108 -0
  77. package/dist/scenes/code/style.css +155 -0
  78. package/dist/scenes/code/tokenize.d.ts +8 -0
  79. package/dist/scenes/code/tokenize.js +145 -0
  80. package/dist/scenes/compare/index.d.ts +1 -0
  81. package/dist/scenes/compare/index.js +128 -0
  82. package/dist/scenes/compare/scene.d.ts +2 -0
  83. package/dist/scenes/compare/scene.js +58 -0
  84. package/dist/scenes/compare/style.css +187 -0
  85. package/dist/scenes/countdown/index.d.ts +1 -0
  86. package/dist/scenes/countdown/index.js +46 -0
  87. package/dist/scenes/countdown/scene.d.ts +2 -0
  88. package/dist/scenes/countdown/scene.js +21 -0
  89. package/dist/scenes/countdown/style.css +32 -0
  90. package/dist/scenes/flow/index.d.ts +1 -0
  91. package/dist/scenes/flow/index.js +66 -0
  92. package/dist/scenes/flow/inspect.d.ts +26 -0
  93. package/dist/scenes/flow/inspect.js +25 -0
  94. package/dist/scenes/flow/layout.d.ts +83 -0
  95. package/dist/scenes/flow/layout.js +222 -0
  96. package/dist/scenes/flow/measure.d.ts +15 -0
  97. package/dist/scenes/flow/measure.js +33 -0
  98. package/dist/scenes/flow/plan.d.ts +42 -0
  99. package/dist/scenes/flow/plan.js +117 -0
  100. package/dist/scenes/flow/scene.d.ts +2 -0
  101. package/dist/scenes/flow/scene.js +6 -0
  102. package/dist/scenes/flow/schema.d.ts +49 -0
  103. package/dist/scenes/flow/schema.js +60 -0
  104. package/dist/scenes/flow/style.css +54 -0
  105. package/dist/scenes/hero/index.d.ts +3 -0
  106. package/dist/scenes/hero/index.js +77 -0
  107. package/dist/scenes/hero/scene.d.ts +2 -0
  108. package/dist/scenes/hero/scene.js +6 -0
  109. package/dist/scenes/hero/style.css +143 -0
  110. package/dist/scenes/icon-grid/index.d.ts +1 -0
  111. package/dist/scenes/icon-grid/index.js +69 -0
  112. package/dist/scenes/icon-grid/scene.d.ts +2 -0
  113. package/dist/scenes/icon-grid/scene.js +6 -0
  114. package/dist/scenes/icon-grid/style.css +74 -0
  115. package/dist/scenes/kinetic/index.d.ts +1 -0
  116. package/dist/scenes/kinetic/index.js +62 -0
  117. package/dist/scenes/kinetic/scene.d.ts +2 -0
  118. package/dist/scenes/kinetic/scene.js +98 -0
  119. package/dist/scenes/kinetic/style.css +94 -0
  120. package/dist/scenes/kpi-grid/index.d.ts +1 -0
  121. package/dist/scenes/kpi-grid/index.js +161 -0
  122. package/dist/scenes/kpi-grid/scene.d.ts +2 -0
  123. package/dist/scenes/kpi-grid/scene.js +6 -0
  124. package/dist/scenes/kpi-grid/style.css +141 -0
  125. package/dist/scenes/lower-third/index.d.ts +1 -0
  126. package/dist/scenes/lower-third/index.js +33 -0
  127. package/dist/scenes/lower-third/scene.d.ts +2 -0
  128. package/dist/scenes/lower-third/scene.js +6 -0
  129. package/dist/scenes/lower-third/style.css +45 -0
  130. package/dist/scenes/media-frame/index.d.ts +1 -0
  131. package/dist/scenes/media-frame/index.js +99 -0
  132. package/dist/scenes/media-frame/scene.d.ts +2 -0
  133. package/dist/scenes/media-frame/scene.js +85 -0
  134. package/dist/scenes/media-frame/style.css +275 -0
  135. package/dist/scenes/outro/index.d.ts +1 -0
  136. package/dist/scenes/outro/index.js +54 -0
  137. package/dist/scenes/outro/scene.d.ts +2 -0
  138. package/dist/scenes/outro/scene.js +6 -0
  139. package/dist/scenes/outro/style.css +92 -0
  140. package/dist/scenes/poster/index.d.ts +3 -0
  141. package/dist/scenes/poster/index.js +62 -0
  142. package/dist/scenes/poster/scene.d.ts +2 -0
  143. package/dist/scenes/poster/scene.js +6 -0
  144. package/dist/scenes/poster/style.css +102 -0
  145. package/dist/scenes/progress/index.d.ts +1 -0
  146. package/dist/scenes/progress/index.js +40 -0
  147. package/dist/scenes/progress/scene.d.ts +2 -0
  148. package/dist/scenes/progress/scene.js +17 -0
  149. package/dist/scenes/progress/style.css +31 -0
  150. package/dist/scenes/quote/index.d.ts +3 -0
  151. package/dist/scenes/quote/index.js +69 -0
  152. package/dist/scenes/quote/scene.d.ts +2 -0
  153. package/dist/scenes/quote/scene.js +6 -0
  154. package/dist/scenes/quote/style.css +86 -0
  155. package/dist/scenes/scene3d/index.d.ts +1 -0
  156. package/dist/scenes/scene3d/index.js +59 -0
  157. package/dist/scenes/scene3d/scene.d.ts +2 -0
  158. package/dist/scenes/scene3d/scene.js +436 -0
  159. package/dist/scenes/scene3d/style.css +43 -0
  160. package/dist/scenes/stat/index.d.ts +4 -0
  161. package/dist/scenes/stat/index.js +72 -0
  162. package/dist/scenes/stat/scene.d.ts +2 -0
  163. package/dist/scenes/stat/scene.js +6 -0
  164. package/dist/scenes/stat/style.css +95 -0
  165. package/dist/scenes/steps/index.d.ts +1 -0
  166. package/dist/scenes/steps/index.js +147 -0
  167. package/dist/scenes/steps/scene.d.ts +2 -0
  168. package/dist/scenes/steps/scene.js +6 -0
  169. package/dist/scenes/steps/style.css +190 -0
  170. package/dist/scenes/sticker/index.d.ts +1 -0
  171. package/dist/scenes/sticker/index.js +48 -0
  172. package/dist/scenes/sticker/scene.d.ts +2 -0
  173. package/dist/scenes/sticker/scene.js +57 -0
  174. package/dist/scenes/sticker/style.css +88 -0
  175. package/dist/scenes/table/index.d.ts +1 -0
  176. package/dist/scenes/table/index.js +131 -0
  177. package/dist/scenes/table/scene.d.ts +2 -0
  178. package/dist/scenes/table/scene.js +6 -0
  179. package/dist/scenes/table/style.css +102 -0
  180. package/dist/scenes/timeline/index.d.ts +1 -0
  181. package/dist/scenes/timeline/index.js +68 -0
  182. package/dist/scenes/timeline/scene.d.ts +2 -0
  183. package/dist/scenes/timeline/scene.js +6 -0
  184. package/dist/scenes/timeline/style.css +161 -0
  185. package/dist/scenes/title/index.d.ts +1 -0
  186. package/dist/scenes/title/index.js +52 -0
  187. package/dist/scenes/title/scene.d.ts +2 -0
  188. package/dist/scenes/title/scene.js +6 -0
  189. package/dist/scenes/title/style.css +80 -0
  190. package/dist/scenes/waveform/index.d.ts +1 -0
  191. package/dist/scenes/waveform/index.js +39 -0
  192. package/dist/scenes/waveform/scene.d.ts +2 -0
  193. package/dist/scenes/waveform/scene.js +35 -0
  194. package/dist/scenes/waveform/style.css +44 -0
  195. package/dist/shared/fit-text.d.ts +24 -0
  196. package/dist/shared/fit-text.js +82 -0
  197. package/dist/shared/format.d.ts +24 -0
  198. package/dist/shared/format.js +59 -0
  199. package/dist/shared/frame.d.ts +14 -0
  200. package/dist/shared/frame.js +21 -0
  201. package/dist/shared/icon-search.d.ts +9 -0
  202. package/dist/shared/icon-search.js +26 -0
  203. package/dist/shared/icon.d.ts +15 -0
  204. package/dist/shared/icon.js +28 -0
  205. package/dist/shared/index.d.ts +11 -0
  206. package/dist/shared/index.js +7 -0
  207. package/dist/shared/markup.d.ts +12 -0
  208. package/dist/shared/markup.js +43 -0
  209. package/dist/shared/measure.d.ts +7 -0
  210. package/dist/shared/measure.js +34 -0
  211. package/dist/shared/motion.d.ts +94 -0
  212. package/dist/shared/motion.js +117 -0
  213. package/dist/source-hash.d.ts +4 -0
  214. package/dist/source-hash.js +26 -0
  215. package/dist/test-fixtures.d.ts +9 -0
  216. package/dist/test-fixtures.js +22 -0
  217. package/dist/text-file.d.ts +1 -0
  218. package/dist/text-file.js +3 -0
  219. package/dist/words.d.ts +11 -0
  220. package/dist/words.js +93 -0
  221. package/package.json +36 -3
@@ -0,0 +1,6 @@
1
+ // @ts-check
2
+ import { mountOverlay } from "../../runtime/mount.js";
3
+ /** @type {import("../../runtime/mount.js").CreateScene} */
4
+ export function createScene(root, config, ctx) {
5
+ return mountOverlay(root, config, ctx, { baseDelay: 0.1, staggerFrames: 4, rise: 24 });
6
+ }
@@ -0,0 +1,25 @@
1
+ #scene-root .mk-chip {
2
+ font-family: var(--mk-font-display, var(--mk-font-sans));
3
+ display: flex;
4
+ align-items: stretch;
5
+ overflow: hidden;
6
+ border-radius: var(--mk-radius-full);
7
+ border: var(--mk-stroke-card, 2px) solid var(--mk-color-divider);
8
+ background: var(--mk-color-card);
9
+ font-weight: 700;
10
+ }
11
+ #scene-root .mk-number {
12
+ display: grid;
13
+ place-items: center;
14
+ min-width: var(--mk-space-xxl);
15
+ padding: var(--mk-space-sm) var(--mk-space-md);
16
+ background: var(--mk-color-accent);
17
+ color: var(--mk-color-onAccent);
18
+ font-variant-numeric: var(--mk-numeric, tabular-nums);
19
+ }
20
+ #scene-root .mk-label {
21
+ display: grid;
22
+ align-items: center;
23
+ padding: var(--mk-space-sm) var(--mk-space-lg);
24
+ color: var(--mk-color-fg);
25
+ }
@@ -0,0 +1 @@
1
+ export declare const chartScene: import("@anvpx/motion-schema").ScenePlugin;
@@ -0,0 +1,24 @@
1
+ import { defineScene } from "../../define-scene.js";
2
+ import { readText } from "../../text-file.js";
3
+ import { render } from "./render.js";
4
+ import { configSchema } from "./schema.js";
5
+ export const chartScene = defineScene({
6
+ type: "chart",
7
+ configSchema,
8
+ meta: {
9
+ purpose: "Animated bar, line or donut chart for ranking, trend or share, with values shown and an optional highlight.",
10
+ example: {
11
+ kind: "bar",
12
+ title: "Signups by channel",
13
+ data: [
14
+ { label: "Search", value: 420 },
15
+ { label: "Referral", value: 310 },
16
+ { label: "Social", value: 190 },
17
+ ],
18
+ highlight: 0,
19
+ },
20
+ },
21
+ style: readText("./style.css", import.meta.url),
22
+ script: readText("./scene.js", import.meta.url),
23
+ render,
24
+ });
@@ -0,0 +1,4 @@
1
+ import type { EmitContext } from "@anvpx/motion-schema";
2
+ import type { Rendered } from "../../define-scene.ts";
3
+ import type { Props } from "./schema.ts";
4
+ export declare const render: (props: Props, ctx: EmitContext, root: string) => Rendered;
@@ -0,0 +1,257 @@
1
+ import { animAttrs, esc } from "../../html.js";
2
+ import { frameCss } from "../../shared/frame.js";
3
+ import { textWidth } from "../../shared/measure.js";
4
+ import { along, formatter, scaleOf } from "./scale.js";
5
+ const SM_PX = 28;
6
+ const MD_PX = 32;
7
+ const LG_PX = 48;
8
+ const ROUND = 100;
9
+ const PALETTE = 8;
10
+ /** Plot padding (percent) that keeps strokes and end dots inside the plot box. */
11
+ const X_PAD = 3;
12
+ const Y_PAD = 6;
13
+ /** SVG y of the plot floor (the lowest tick). */
14
+ const FLOOR = ROUND - Y_PAD;
15
+ const VALUE_PAD_PX = 40;
16
+ const LABEL_MAX_POINTS = 8;
17
+ /** Minimum vertical distance between end labels, in plot percent. */
18
+ const END_GAP = 9;
19
+ const round = (value) => Math.round(value * ROUND) / ROUND;
20
+ const sizeOf = (ctx, step, fallback) => ctx.theme.fontSizes[step] ?? fallback;
21
+ const slot = (index) => (index % PALETTE) + 1;
22
+ const ordered = (props) => {
23
+ const points = (props.data ?? []).map((point, index) => ({ ...point, index }));
24
+ if (props.sort === "asc")
25
+ return points.sort((a, b) => a.value - b.value);
26
+ return props.sort === "desc" ? points.sort((a, b) => b.value - a.value) : points;
27
+ };
28
+ const summary = (props, parts) => esc(`${props.kind} chart${props.title ? `: ${props.title}` : ""}. ${parts.join("; ")}${props.caption ? `. ${props.caption}` : ""}`);
29
+ const figure = (props, cls, parts, inner) => `<figure class="mk-chart ${cls}" role="img" aria-label="${summary(props, parts)}">\n${inner}\n</figure>`;
30
+ /** Count-up value cell: the text it ends on rides in data-count. */
31
+ const counted = (cls, step, shown) => `<span class="${cls}" data-step="${step}" data-count="${esc(shown)}">${esc(shown)}</span>`;
32
+ const emphasis = (props, point) => {
33
+ if (props.highlight === undefined)
34
+ return "";
35
+ return point.index === props.highlight ? " mk-hi" : " mk-lo";
36
+ };
37
+ /* ---------- bar ---------- */
38
+ const barRow = (props, point, step, pct, shown, vertical) => {
39
+ const attrs = animAttrs(vertical ? "fade" : "rise", step, `item-${point.index + 1}`);
40
+ const grow = `data-grow="${vertical ? "height" : "width"}" data-to="${pct}" data-step="${step}"`;
41
+ const value = !props.showValues ? "" : counted("mk-val", step, shown);
42
+ const name = `<span class="mk-name">${esc(point.label)}</span>`;
43
+ const mark = `<span class="mk-fill${emphasis(props, point)}"`;
44
+ if (vertical) {
45
+ const stack = `<div class="mk-stack" ${grow}>${value}${mark}></span></div>`;
46
+ return `<div class="mk-col" ${attrs}><div class="mk-well">${stack}</div>${name}</div>`;
47
+ }
48
+ const fill = `${mark} ${grow}></span>`;
49
+ return `<div class="mk-row" ${attrs}>${name}<div class="mk-track">${fill}</div>${value}</div>`;
50
+ };
51
+ const bars = (props, ctx, offset) => {
52
+ const points = ordered(props);
53
+ const values = points.map((point) => point.value);
54
+ const format = formatter(values, props.unit);
55
+ const max = Math.max(...values);
56
+ const vertical = props.orient === "v";
57
+ const rows = points.map((point, index) => {
58
+ const pct = max > 0 ? round((point.value / max) * ROUND) : 0;
59
+ return barRow(props, point, index + offset, pct, format(point.value), vertical);
60
+ });
61
+ const parts = points.map((point) => `${point.label} ${format(point.value)}`);
62
+ const valueW = Math.max(...points.map((p) => textWidth(format(p.value), sizeOf(ctx, "lg", LG_PX))));
63
+ return {
64
+ body: figure(props, `mk-bars ${vertical ? "mk-v" : "mk-h"}${!props.showValues ? " mk-noval" : ""}`, parts, rows.join("\n")),
65
+ css: vertical
66
+ ? `{root} .mk-bars.mk-v { grid-template-columns: repeat(${points.length}, minmax(0, 1fr)); }\n`
67
+ : `{root} .mk-bars.mk-h { --vw: ${valueW + VALUE_PAD_PX}px; }\n`,
68
+ };
69
+ };
70
+ /* ---------- line ---------- */
71
+ const xAt = (index, count) => round(X_PAD + (index / (count - 1)) * (ROUND - 2 * X_PAD));
72
+ const yAt = (value, scale) => round(Y_PAD + along(value, scale) * (ROUND - 2 * Y_PAD));
73
+ /** Moves labels apart (bottom to top) until neighbours keep `END_GAP` between them. */
74
+ const spread = (ys) => {
75
+ const order = ys.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y);
76
+ order.forEach((item, k) => {
77
+ const below = order[k - 1];
78
+ if (below !== undefined && item.y - below.y < END_GAP)
79
+ item.y = below.y + END_GAP;
80
+ });
81
+ const out = [];
82
+ for (const item of order)
83
+ out[item.i] = item.y;
84
+ return out;
85
+ };
86
+ const labelled = (count, values) => {
87
+ if (count <= LABEL_MAX_POINTS)
88
+ return new Set(values.map((_, i) => i));
89
+ return new Set([0, count - 1, values.indexOf(Math.max(...values))]);
90
+ };
91
+ const lineShapes = (values, scale, solid) => {
92
+ const pts = values.map((v, i) => `${xAt(i, values.length)},${round(ROUND - yAt(v, scale))}`);
93
+ const first = pts[0] ?? "0,100";
94
+ const last = pts[pts.length - 1] ?? "0,100";
95
+ const area = solid
96
+ ? `<polygon class="mk-area" points="${first.split(",")[0]},${FLOOR} ${pts.join(" ")} ${last.split(",")[0]},${FLOOR}"/>`
97
+ : "";
98
+ return `${area}<polyline class="mk-stroke" points="${pts.join(" ")}"/>`;
99
+ };
100
+ /** Where a point's value goes: above every point of a lone line, else right of each line's end. */
101
+ const labelKind = (props, single, index, values) => {
102
+ if (!props.showValues)
103
+ return undefined;
104
+ if (single)
105
+ return labelled(values.length, values).has(index) ? "above" : undefined;
106
+ return index === values.length - 1 ? "right" : undefined;
107
+ };
108
+ const lineLabels = (props, ctx, scale, step) => {
109
+ const { x = [], series = [] } = props;
110
+ const format = formatter(series.flatMap((one) => one.values), props.unit);
111
+ const single = series.length === 1;
112
+ const ends = spread(series.map((one) => yAt(one.values[x.length - 1] ?? 0, scale)));
113
+ const widest = Math.max(...series.map((one) => textWidth(format(one.values[x.length - 1] ?? 0), sizeOf(ctx, "md", MD_PX))));
114
+ const html = series.flatMap((one, s) => one.values.flatMap((value, i) => {
115
+ const at = round(xAt(i, x.length) / ROUND);
116
+ const attrs = `data-grow="dot" data-at="${at}" data-step="${step(s)}"`;
117
+ const left = xAt(i, x.length);
118
+ const spot = `left: ${left}%; bottom: ${yAt(value, scale)}%`;
119
+ const dot = `<span class="mk-dot mk-s${slot(s)}" style="${spot}" ${attrs}></span>`;
120
+ const kind = labelKind(props, single, i, one.values);
121
+ if (kind === undefined)
122
+ return [dot];
123
+ const pos = kind === "above" ? spot : `left: ${left}%; bottom: ${ends[s]}%`;
124
+ const label = `<span class="mk-pv mk-${kind}" style="${pos}" ${attrs}>${esc(format(value))}</span>`;
125
+ return [dot, label];
126
+ }));
127
+ return { html: html.join("\n"), room: single ? 56 : widest + 56 };
128
+ };
129
+ const line = (props, ctx, offset) => {
130
+ const { x = [], series = [] } = props;
131
+ const scale = scaleOf(series.flatMap((one) => one.values));
132
+ const tick = formatter(scale.ticks, props.unit);
133
+ const step = (s) => offset + 1 + s;
134
+ const yWidth = Math.max(...scale.ticks.map((t) => textWidth(tick(t), sizeOf(ctx, "sm", SM_PX))));
135
+ const labels = lineLabels(props, ctx, scale, step);
136
+ const grid = scale.ticks.map((t) => {
137
+ const bottom = yAt(t, scale);
138
+ return `<i class="mk-gl" style="bottom: ${bottom}%"></i>`;
139
+ });
140
+ const ticks = scale.ticks.map((t) => `<span class="mk-tick" style="bottom: ${yAt(t, scale)}%">${esc(tick(t))}</span>`);
141
+ const xs = x.map((label, i) => `<span class="mk-xl" style="left: ${xAt(i, x.length)}%">${esc(label)}</span>`);
142
+ const curves = series.map((one, s) => `<svg class="mk-curve mk-s${slot(s)}" viewBox="0 0 100 100" preserveAspectRatio="none" data-grow="line" data-step="${step(s)}" aria-hidden="true">${lineShapes(one.values, scale, series.length === 1)}</svg>`);
143
+ const legend = series.length > 1
144
+ ? `<ul class="mk-legend" ${animAttrs("fade", offset)}>${series
145
+ .map((one, s) => `<li><i class="mk-key mk-s${slot(s)}"></i>${esc(one.name)}</li>`)
146
+ .join("")}</ul>\n`
147
+ : "";
148
+ const axes = `<div class="mk-yaxis" ${animAttrs("fade", offset)}>${ticks.join("")}</div>
149
+ <div class="mk-plot"><div class="mk-gls" ${animAttrs("fade", offset)}>${grid.join("")}</div>${curves.join("")}\n${labels.html}</div>
150
+ <div class="mk-xaxis" ${animAttrs("fade", offset)}>${xs.join("")}</div>`;
151
+ const parts = series.map((one) => `${one.name}: ${x.map((label, i) => `${label} ${one.values[i]}`).join(", ")}`);
152
+ return {
153
+ body: figure(props, "mk-lines", parts, `${legend}<div class="mk-frame">\n${axes}\n</div>`),
154
+ css: `{root} .mk-lines .mk-frame { --yw: ${yWidth + 24}px; --mr: ${labels.room}px; }\n`,
155
+ };
156
+ };
157
+ /* ---------- bar + trend ---------- */
158
+ /** Bars stay the default; an opt-in trend draws one line per series below them. */
159
+ const barsWithTrend = (props, ctx, offset) => {
160
+ const base = bars(props, ctx, offset);
161
+ if (props.trend === undefined)
162
+ return base;
163
+ const points = ordered(props);
164
+ const nested = line({
165
+ ...props,
166
+ kind: "line",
167
+ x: points.map((point) => point.label),
168
+ series: props.trend.map((one) => ({
169
+ ...one,
170
+ values: points.map((point) => one.values[point.index] ?? 0),
171
+ })),
172
+ }, ctx, offset + points.length);
173
+ return { body: `${base.body}\n${nested.body}`, css: `${base.css ?? ""}${nested.css ?? ""}` };
174
+ };
175
+ /* ---------- donut ---------- */
176
+ const ARC_GAP = 0.6;
177
+ /** From this many slices the legend rows tighten so they fit the panel at any theme scale. */
178
+ const DENSE_POINTS = 7;
179
+ /** Tall frames: the legend sits below the ring at full width, so long labels wrap instead of colliding with values. */
180
+ const DONUT_TALL_CSS = `#scene-root .mk-donut { flex-direction: column; gap: var(--mk-space-xl); justify-content: center; }
181
+ #scene-root .mk-donut .mk-ring { width: min(100%, 600px); height: auto; aspect-ratio: 1; }
182
+ #scene-root .mk-donut .mk-core { padding: 0 22%; }
183
+ #scene-root .mk-donut .mk-legend-list { flex: none; width: 100%; max-width: none; }
184
+ #scene-root .mk-donut .mk-lname { overflow-wrap: anywhere; }`;
185
+ /** Square frames have no height for a stacked legend: ring and legend sit side by side, ring smaller, share column dropped (values stay). */
186
+ const DONUT_SQUARE_CSS = `#scene-root .mk-donut { flex-direction: row; gap: var(--mk-space-lg); }
187
+ #scene-root .mk-donut .mk-ring { width: 300px; height: 300px; aspect-ratio: auto; }
188
+ #scene-root .mk-donut .mk-core { padding: 0 56px; }
189
+ #scene-root .mk-donut .mk-total { font-size: var(--mk-font-size-md); }
190
+ #scene-root .mk-donut .mk-legend-list { flex: 1; width: auto; min-width: 0; gap: var(--mk-space-xs); }
191
+ #scene-root .mk-donut .mk-leg { grid-template-columns: 24px minmax(0, 1fr) var(--vw); column-gap: var(--mk-space-sm); padding: var(--mk-space-xs) var(--mk-space-sm); font-size: var(--mk-font-size-sm); }
192
+ #scene-root .mk-donut.mk-noval .mk-leg { grid-template-columns: 24px minmax(0, 1fr); }
193
+ #scene-root .mk-donut .mk-pct { display: none; }
194
+ #scene-root .mk-donut .mk-lname { line-height: 1.1; overflow-wrap: normal; hyphens: none; }`;
195
+ const share = (value, total) => {
196
+ const pct = (value / total) * ROUND;
197
+ return pct > 0 && pct < 1 ? "<1%" : `${Math.round(pct)}%`;
198
+ };
199
+ const donut = (props, ctx, offset) => {
200
+ const points = ordered(props);
201
+ const values = points.map((point) => point.value);
202
+ const total = values.reduce((sum, value) => sum + value, 0);
203
+ const format = formatter(values, props.unit);
204
+ const chosen = points.find((point) => point.index === props.highlight);
205
+ let from = 0;
206
+ const arcs = points.map((point) => {
207
+ const len = point.value / total;
208
+ const arc = `<circle class="mk-arc mk-c${slot(point.index)}${emphasis(props, point)}" cx="50" cy="50" r="38" pathLength="100" transform="rotate(-90 50 50)" data-grow="arc" data-from="${round(from)}" data-len="${round(len)}" data-gap="${ARC_GAP}" data-step="${offset}"/>`;
209
+ from += len;
210
+ return arc;
211
+ });
212
+ const centre = !props.showValues
213
+ ? ""
214
+ : chosen === undefined
215
+ ? counted("mk-total", offset, format(total))
216
+ : `${counted("mk-total", offset, share(chosen.value, total))}<span class="mk-core-label">${esc(chosen.label)}</span>`;
217
+ /** Values that already are percentages need no second share column. */
218
+ const shares = props.unit !== "%";
219
+ const rows = points.map((point, i) => {
220
+ const step = offset + 1 + i;
221
+ const attrs = animAttrs("rise", step, `item-${point.index + 1}`);
222
+ const nums = !props.showValues
223
+ ? ""
224
+ : `${counted("mk-lval", step, format(point.value))}${shares ? `<span class="mk-pct">${share(point.value, total)}</span>` : ""}`;
225
+ return `<li class="mk-leg${emphasis(props, point)}" ${attrs}><i class="mk-sw mk-c${slot(point.index)}"></i><span class="mk-lname">${esc(point.label)}</span>${nums}</li>`;
226
+ });
227
+ const ring = `<div class="mk-ring" ${animAttrs("fade", offset)}><svg viewBox="0 0 100 100" aria-hidden="true"><circle class="mk-bg" cx="50" cy="50" r="38"/>${arcs.join("")}</svg><div class="mk-core">${centre}</div></div>`;
228
+ const parts = points.map((point) => `${point.label} ${format(point.value)} (${share(point.value, total)})`);
229
+ const lead = textWidth(format(Math.max(...values)), sizeOf(ctx, "md", MD_PX));
230
+ return {
231
+ body: figure(props, `mk-donut${!props.showValues ? " mk-noval" : ""}${shares ? "" : " mk-nopct"}${points.length >= DENSE_POINTS ? " mk-dense" : ""}`, parts, `${ring}\n<ul class="mk-legend-list">\n${rows.join("\n")}\n</ul>`),
232
+ css: `{root} .mk-donut { --vw: ${lead + VALUE_PAD_PX}px; }\n${frameCss(ctx, "{root}", { tall: DONUT_TALL_CSS, square: DONUT_SQUARE_CSS })}`,
233
+ };
234
+ };
235
+ /* ---------- scene ---------- */
236
+ export const render = (props, ctx, root) => {
237
+ const offset = props.title ? 1 : 0;
238
+ const part = props.kind === "line"
239
+ ? line(props, ctx, offset)
240
+ : props.kind === "donut"
241
+ ? donut(props, ctx, offset)
242
+ : barsWithTrend(props, ctx, offset);
243
+ const head = props.title
244
+ ? `<div class="mk-head">
245
+ <h1 class="mk-heading" ${animAttrs("rise", 0, "title")}>${esc(props.title)}</h1>
246
+ <span class="mk-rule" ${animAttrs("grow", 0, "title")}></span>
247
+ </div>
248
+ `
249
+ : "";
250
+ const caption = props.caption
251
+ ? `\n<p class="mk-caption" ${animAttrs("fade", offset)}>${esc(props.caption)}</p>`
252
+ : "";
253
+ return {
254
+ body: `${head}<div class="mk-panel">\n${part.body}\n</div>${caption}`,
255
+ css: (part.css ?? "").replaceAll("{root}", root),
256
+ };
257
+ };
@@ -0,0 +1,11 @@
1
+ export interface Scale {
2
+ readonly ticks: readonly number[];
3
+ readonly lo: number;
4
+ readonly hi: number;
5
+ }
6
+ /** Axis ticks covering the data; the baseline is zero unless the data sits far above it. */
7
+ export declare const scaleOf: (values: readonly number[]) => Scale;
8
+ /** One formatter for a whole data set, so every label has the same decimals and scale. */
9
+ export declare const formatter: (values: readonly number[], unit: string | undefined) => ((value: number) => string);
10
+ /** Position of `value` on the axis, 0..1. */
11
+ export declare const along: (value: number, scale: Scale) => number;
@@ -0,0 +1,53 @@
1
+ import { fmt } from "../../shared/format.js";
2
+ const TICK_TARGET = 4;
3
+ const NICE_STEPS = [1, 2, 2.5, 5, 10];
4
+ const FLAT_SPAN = 1;
5
+ /** Data that starts above this share of its maximum gets a raised baseline. */
6
+ const ZERO_BASE_SHARE = 0.6;
7
+ const BASE_PAD = 0.15;
8
+ const COMPACT_FROM = 100_000;
9
+ const MAX_DECIMALS = 2;
10
+ const ROUND_DIGITS = 10;
11
+ /** "310.0K" reads better as "310K". */
12
+ const WHOLE_TIER = /\.0(?=[KMBT])/;
13
+ const niceStep = (raw) => {
14
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
15
+ const normal = raw / magnitude;
16
+ return (NICE_STEPS.find((step) => normal <= step) ?? 10) * magnitude;
17
+ };
18
+ const round = (value) => Number(value.toFixed(ROUND_DIGITS));
19
+ /** Axis ticks covering the data; the baseline is zero unless the data sits far above it. */
20
+ export const scaleOf = (values) => {
21
+ const min = Math.min(...values);
22
+ const max = Math.max(...values);
23
+ const range = max - min;
24
+ const zeroBased = min >= 0 && min <= max * ZERO_BASE_SHARE;
25
+ const from = zeroBased ? Math.min(0, min) : min - range * BASE_PAD;
26
+ const to = max > from ? max : from + FLAT_SPAN;
27
+ const step = niceStep((to - from) / TICK_TARGET);
28
+ const lo = round(Math.floor(from / step) * step);
29
+ const hi = round(Math.ceil(to / step) * step);
30
+ const ticks = [];
31
+ for (let at = lo; at <= hi + step / 2; at += step)
32
+ ticks.push(round(at));
33
+ return { ticks, lo, hi: ticks[ticks.length - 1] ?? hi };
34
+ };
35
+ const decimalsOf = (value) => {
36
+ const [, fraction = ""] = String(Math.abs(value)).split(".");
37
+ return fraction.includes("e") ? MAX_DECIMALS : Math.min(MAX_DECIMALS, fraction.length);
38
+ };
39
+ /** Unit words ("users") are set apart by a space; symbols ("$", "%") hug the number. */
40
+ const unitText = (unit) => unit !== undefined && /^[\p{L}]/u.test(unit) ? ` ${unit}` : unit;
41
+ /** One formatter for a whole data set, so every label has the same decimals and scale. */
42
+ export const formatter = (values, unit) => {
43
+ const compact = Math.max(...values.map(Math.abs)) >= COMPACT_FROM;
44
+ const decimals = compact ? undefined : Math.max(...values.map(decimalsOf));
45
+ const text = unitText(unit);
46
+ return (value) => fmt(value, {
47
+ compact,
48
+ ...(decimals === undefined ? {} : { decimals }),
49
+ ...(text === undefined ? {} : { unit: text }),
50
+ }).replace(WHOLE_TIER, "");
51
+ };
52
+ /** Position of `value` on the axis, 0..1. */
53
+ export const along = (value, scale) => (value - scale.lo) / (scale.hi - scale.lo);
@@ -0,0 +1,2 @@
1
+ /** @type {import("../../runtime/mount.js").CreateScene} */
2
+ export declare function createScene(root: HTMLElement, config: import("../../runtime/mount.js").SceneConfig, ctx: import("../../runtime/mount.js").RuntimeContext): import("../../runtime/mount.js").Runtime;
@@ -0,0 +1,73 @@
1
+ // @ts-check
2
+ import { mountTimeline } from "../../runtime/mount.js";
3
+ import { countTrack, sample } from "../../runtime/timeline.js";
4
+ import { clamp, wipe } from "../../shared/motion.js";
5
+ const PLAN = { baseDelay: 0.2, staggerFrames: 5, rise: 32 };
6
+ const DOT_RAMP = 24;
7
+ /** A dot is fully shown once the line has passed it; last dots sit just inside the end. */
8
+ const DOT_LEAD = 0.95;
9
+ const WHOLE = 100;
10
+ /**
11
+ * @param {HTMLElement | SVGElement} el
12
+ * @param {string} name
13
+ * @returns {number}
14
+ */
15
+ const num = (el, name) => Number(el.getAttribute(`data-${name}`) ?? 0);
16
+ /**
17
+ * Draws one growing mark at eased progress p (0..1).
18
+ * @param {HTMLElement | SVGElement} el
19
+ * @param {number} p
20
+ */
21
+ const draw = (el, p) => {
22
+ const kind = el.getAttribute("data-grow");
23
+ if (kind === "width")
24
+ el.style.width = `${num(el, "to") * p}%`;
25
+ else if (kind === "height")
26
+ el.style.height = `${num(el, "to") * p}%`;
27
+ else if (kind === "line")
28
+ el.style.clipPath = wipe(p);
29
+ else if (kind === "dot")
30
+ el.style.opacity = String(clamp((p - num(el, "at") * DOT_LEAD) * DOT_RAMP, 0, 1));
31
+ else if (kind === "arc") {
32
+ const full = num(el, "len") * WHOLE;
33
+ const gap = num(el, "gap");
34
+ const seen = clamp((p - num(el, "from")) / num(el, "len"), 0, 1);
35
+ const length = seen * Math.max(full - gap, 0);
36
+ el.style.strokeDasharray = `${length} ${WHOLE}`;
37
+ el.style.strokeDashoffset = String(-(num(el, "from") * WHOLE + gap / 2));
38
+ }
39
+ };
40
+ /** @type {import("../../runtime/mount.js").CreateScene} */
41
+ export function createScene(root, config, ctx) {
42
+ const base = mountTimeline(root, config, ctx, PLAN);
43
+ const ease = config.ease ?? root.dataset.ease ?? "out";
44
+ const fps = ctx.fps ?? Number(root.dataset.fps ?? 30);
45
+ const duration = ctx.duration ?? Number(root.dataset.duration ?? 0);
46
+ const marks = Array.from(root.querySelectorAll("[data-grow]"), (node) => {
47
+ const el = /** @type {HTMLElement | SVGElement} */ (node);
48
+ return { el, track: countTrack(num(el, "step"), PLAN, fps, ease, duration) };
49
+ });
50
+ /** @param {number} t */
51
+ const seek = (t) => {
52
+ base.seek(t);
53
+ for (const mark of marks)
54
+ draw(mark.el, sample(mark.track, t));
55
+ };
56
+ const dispose = () => {
57
+ base.dispose();
58
+ for (const { el } of marks) {
59
+ for (const name of [
60
+ "width",
61
+ "height",
62
+ "clip-path",
63
+ "opacity",
64
+ "stroke-dasharray",
65
+ "stroke-dashoffset",
66
+ ]) {
67
+ el.style.removeProperty(name);
68
+ }
69
+ }
70
+ };
71
+ seek(0);
72
+ return { seek, dispose, ready: base.ready };
73
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ export declare const configSchema: z.ZodObject<{
3
+ kind: z.ZodEnum<{
4
+ bar: "bar";
5
+ donut: "donut";
6
+ line: "line";
7
+ }>;
8
+ title: z.ZodOptional<z.ZodString>;
9
+ unit: z.ZodOptional<z.ZodString>;
10
+ showValues: z.ZodDefault<z.ZodBoolean>;
11
+ caption: z.ZodOptional<z.ZodString>;
12
+ data: z.ZodOptional<z.ZodArray<z.ZodObject<{
13
+ label: z.ZodString;
14
+ value: z.ZodNumber;
15
+ }, z.core.$strict>>>;
16
+ sort: z.ZodOptional<z.ZodEnum<{
17
+ asc: "asc";
18
+ desc: "desc";
19
+ none: "none";
20
+ }>>;
21
+ highlight: z.ZodOptional<z.ZodNumber>;
22
+ orient: z.ZodOptional<z.ZodEnum<{
23
+ h: "h";
24
+ v: "v";
25
+ }>>;
26
+ x: z.ZodOptional<z.ZodArray<z.ZodString>>;
27
+ series: z.ZodOptional<z.ZodArray<z.ZodObject<{
28
+ name: z.ZodString;
29
+ values: z.ZodArray<z.ZodNumber>;
30
+ }, z.core.$strict>>>;
31
+ trend: z.ZodOptional<z.ZodArray<z.ZodObject<{
32
+ name: z.ZodString;
33
+ values: z.ZodArray<z.ZodNumber>;
34
+ }, z.core.$strict>>>;
35
+ }, z.core.$strict>;
36
+ export type Props = z.output<typeof configSchema>;
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ import { textWidth } from "../../shared/measure.js";
3
+ import { limitWords, present, text } from "../../words.js";
4
+ const KINDS = ["bar", "line", "donut"];
5
+ const COUNT_MESSAGE = "need 2-8 data points";
6
+ const LABEL_MAX = 32;
7
+ const X_LABEL_MAX = 12;
8
+ /** Text px and usable plot width the label-fit checks assume at 1080p. */
9
+ const MIN_TEXT_PX = 28;
10
+ const TITLE_PX = 72;
11
+ const LINE_ROOM_PX = 1500;
12
+ const TITLE_ROOM_PX = 1700;
13
+ const LABEL_GAP_PX = 12;
14
+ const pointSchema = z.strictObject({ label: text.max(LABEL_MAX), value: z.number().min(0) });
15
+ const seriesSchema = z.strictObject({
16
+ name: text.max(LABEL_MAX),
17
+ values: z.array(z.number()).min(2).max(12),
18
+ });
19
+ const base = z.strictObject({
20
+ kind: z.enum(KINDS),
21
+ title: text.optional(),
22
+ unit: text.optional(),
23
+ showValues: z.boolean().default(true),
24
+ caption: text.optional(),
25
+ data: z.array(pointSchema).min(2, COUNT_MESSAGE).max(8, COUNT_MESSAGE).optional(),
26
+ sort: z.enum(["none", "asc", "desc"]).optional(),
27
+ highlight: z.number().int().min(0).optional(),
28
+ orient: z.enum(["h", "v"]).optional(),
29
+ x: z.array(text.max(X_LABEL_MAX)).min(2).max(12).optional(),
30
+ series: z.array(seriesSchema).min(1, "need 1-3 series").max(3, "need 1-3 series").optional(),
31
+ trend: z
32
+ .array(seriesSchema)
33
+ .min(1, "need 1-3 trend series")
34
+ .max(3, "need 1-3 trend series")
35
+ .optional(),
36
+ });
37
+ const fail = (ctx, path, message) => ctx.addIssue({ code: "custom", path, message });
38
+ const forbid = (props, ctx, keys) => {
39
+ for (const key of keys) {
40
+ if (props[key] !== undefined)
41
+ fail(ctx, [key], `not used by ${props.kind} charts`);
42
+ }
43
+ };
44
+ const checkPoints = (props, ctx) => {
45
+ if (props.data === undefined) {
46
+ fail(ctx, ["data"], `${props.kind} charts need data`);
47
+ return;
48
+ }
49
+ if (props.highlight !== undefined && props.highlight >= props.data.length) {
50
+ fail(ctx, ["highlight"], `highlight ${props.highlight} is outside data (0-${props.data.length - 1})`);
51
+ }
52
+ if (props.kind === "donut" && props.data.every((point) => point.value === 0)) {
53
+ fail(ctx, ["data"], "donut needs at least one value above 0");
54
+ }
55
+ };
56
+ const checkLine = (props, ctx) => {
57
+ if (props.x === undefined)
58
+ fail(ctx, ["x"], "line charts need x labels");
59
+ if (props.series === undefined)
60
+ fail(ctx, ["series"], "line charts need series");
61
+ const { x = [], series = [] } = props;
62
+ series.forEach((one, index) => {
63
+ if (one.values.length !== x.length) {
64
+ fail(ctx, ["series", index, "values"], `needs ${x.length} values (one per x label)`);
65
+ }
66
+ });
67
+ const slot = LINE_ROOM_PX / Math.max(x.length - 1, 1) - LABEL_GAP_PX;
68
+ x.forEach((label, index) => {
69
+ if (textWidth(label, MIN_TEXT_PX) > slot) {
70
+ fail(ctx, ["x", index], `label too wide for ${x.length} points; shorten it`);
71
+ }
72
+ });
73
+ };
74
+ const checkKind = (props, ctx) => {
75
+ if (props.kind === "line") {
76
+ forbid(props, ctx, ["data", "sort", "highlight", "orient", "trend"]);
77
+ checkLine(props, ctx);
78
+ return;
79
+ }
80
+ forbid(props, ctx, ["x", "series"]);
81
+ if (props.kind === "donut") {
82
+ forbid(props, ctx, ["orient", "trend"]);
83
+ checkPoints(props, ctx);
84
+ return;
85
+ }
86
+ checkPoints(props, ctx);
87
+ checkTrend(props, ctx);
88
+ };
89
+ const checkTrend = (props, ctx) => {
90
+ if (props.trend === undefined || props.data === undefined)
91
+ return;
92
+ const count = props.data.length;
93
+ props.trend.forEach((one, index) => {
94
+ if (one.values.length !== count) {
95
+ fail(ctx, ["trend", index, "values"], `needs ${count} values (one per bar)`);
96
+ }
97
+ });
98
+ };
99
+ export const configSchema = limitWords(base.superRefine((props, ctx) => {
100
+ checkKind(props, ctx);
101
+ if (props.title !== undefined && textWidth(props.title, TITLE_PX) > TITLE_ROOM_PX) {
102
+ fail(ctx, ["title"], "title too long for one line; shorten it");
103
+ }
104
+ }), (props) => [
105
+ ...present(props.title, props.unit, props.caption),
106
+ ...(props.data ?? []).flatMap((point) => present(point.label)),
107
+ ...(props.x ?? []),
108
+ ...(props.series ?? []).flatMap((one) => present(one.name)),
109
+ ]);