@loadmap/export 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaime Rojas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # @loadmap/export
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ PNG, SVG, GIF, WebM and MP4 of a [loadmap](https://jaim3r0jas.github.io/loadmap/), made frame by frame on a fixed
4
+ clock: the same options give the same file, byte for byte.
5
+
6
+ ```ts
7
+ import { download, recordVideo } from '@loadmap/export'
8
+
9
+ const gif = await recordVideo(handle, {
10
+ format: 'gif',
11
+ seconds: 6,
12
+ caption: true,
13
+ script: [{ at: 2, set: { load: 30 } }],
14
+ })
15
+ download(gif, 'shop.gif')
16
+ ```
17
+
18
+ `handle` is the `ref` of a `<Loadmap>`. `expose(handle)` lets `loadmap record` (from `@loadmap/cli`) drive the page
19
+ from the command line. MIT.
@@ -0,0 +1,102 @@
1
+ import { Values } from "@loadmap/core";
2
+ import { LoadmapHandle } from "@loadmap/react";
3
+ /** A title line above the board and the verdict below it, so a frame says what it shows without the page. */
4
+ interface Caption {
5
+ /** Default: the model's title. */
6
+ title?: string;
7
+ /** Said below the board instead of the verdict: a story step's text. */
8
+ text?: string;
9
+ /** Before the scenario and the load: "2 of 3". */
10
+ step?: string;
11
+ }
12
+ /**
13
+ * The board as an SVG file: the structure as drawn, the dots where they are now, and with `caption` the title line
14
+ * and the verdict. Text goes in as text nodes, never as markup, so a label cannot break the file.
15
+ */
16
+ export declare function exportSvg(handle: LoadmapHandle, o?: {
17
+ caption?: boolean | Caption;
18
+ }): Promise<string>;
19
+ export interface ScriptStep {
20
+ /** Seconds into the recording. */
21
+ at: number;
22
+ /** A scenario's values, by its label; `set` goes on top. The load stays. */
23
+ scenario?: string;
24
+ set?: Partial<Values<any>>;
25
+ /** A story step, by index: its values and the box it points at, instead of `scenario` and `set`. */
26
+ step?: number;
27
+ }
28
+ export interface FrameOptions {
29
+ /** Pixels per scene unit. Default 2 for stills, 1 for video. */
30
+ scale?: number;
31
+ /** A fixed frame size: the board is scaled to fit and centred. A video keeps its first frame's size. */
32
+ size?: {
33
+ width: number;
34
+ height: number;
35
+ };
36
+ /** The title, scenario and load above the board and the verdict below it, so the frame stands alone. */
37
+ caption?: boolean | Caption;
38
+ }
39
+ export interface RecordOptions extends FrameOptions {
40
+ /** Required without `story`. */
41
+ seconds?: number;
42
+ /**
43
+ * Plays the model's story: each step for its seconds, its text below the board when there is a caption. Takes the
44
+ * place of `script` and `seconds`.
45
+ */
46
+ story?: boolean;
47
+ /** Default 30 for video, 15 for GIF. A GIF takes at most 50: its frame delays are whole hundredths. */
48
+ fps?: number;
49
+ format?: 'webm' | 'mp4' | 'gif';
50
+ /** Value changes during the recording, e.g. the load rising from 1× to 40×. */
51
+ script?: readonly ScriptStep[];
52
+ /** The sim starts over with this seed, so two recordings match frame for frame. Default 1. */
53
+ seed?: number;
54
+ /** Seconds of sim run before the first frame, so queues and dots are already flowing. Default 2. */
55
+ warmup?: number;
56
+ /** Called with every composed frame, e.g. to hash it. */
57
+ onFrame?: (index: number, frame: HTMLCanvasElement) => void | Promise<void>;
58
+ }
59
+ /** The board as one image: the SVG rasterised with its theme colours resolved, the dots painted on top. */
60
+ export declare function composeFrame(handle: LoadmapHandle, o?: FrameOptions, into?: HTMLCanvasElement): Promise<HTMLCanvasElement>;
61
+ /** A PNG of the board as it is now. */
62
+ export declare function exportPng(handle: LoadmapHandle, o?: FrameOptions): Promise<Blob>;
63
+ /**
64
+ * A video or GIF of a scenario, made frame by frame on a fixed clock: the live animation is paused, the sim advances
65
+ * exactly 1/fps per frame, and each frame is encoded at its own timestamp. Nothing depends on how fast the machine
66
+ * is, so the same options give the same frames. The values the script changed are put back afterwards.
67
+ */
68
+ export declare function recordVideo(handle: LoadmapHandle, o: RecordOptions): Promise<Blob>;
69
+ export interface StillOptions extends FrameOptions {
70
+ /** A scenario's values, by label; `set` goes on top. */
71
+ scenario?: string;
72
+ set?: Partial<Values<any>>;
73
+ seed?: number;
74
+ /** Seconds of sim run before the picture. Default 4. */
75
+ warmup?: number;
76
+ }
77
+ /** A PNG of the board after `set` and a warm-up on a fixed clock: the same options give the same picture. */
78
+ export declare function recordStill(handle: LoadmapHandle, o?: StillOptions): Promise<Blob>;
79
+ /**
80
+ * A poster: the board after a warm-up, with the title, the verdict, where each component runs out and the scenarios
81
+ * compared, as one PNG. The same options give the same bytes. `scale` is pixels per CSS pixel (default 2).
82
+ */
83
+ export declare function recordPoster(handle: LoadmapHandle, o?: Omit<StillOptions, 'size' | 'caption'>): Promise<Blob>;
84
+ declare global {
85
+ var __loadmap: Record<string, PageRecorder> | undefined;
86
+ }
87
+ /** What `expose` puts on the page for `loadmap record`: results come back base64-encoded. */
88
+ export interface PageRecorder {
89
+ record(o: RecordOptions): Promise<string>;
90
+ still(o: StillOptions): Promise<string>;
91
+ poster(o: StillOptions): Promise<string>;
92
+ /** The SVG's text, not base64. */
93
+ svg(o: StillOptions): Promise<string>;
94
+ }
95
+ /**
96
+ * Lets `loadmap record` drive this loadmap: puts a recorder on `globalThis.__loadmap[name]`.
97
+ * Returns a function that takes it off again.
98
+ */
99
+ export declare function expose(handle: LoadmapHandle, name?: string): () => void;
100
+ /** Saves a blob through the browser's download. */
101
+ export declare function download(blob: Blob, name: string): void;
102
+ export type { Caption };
package/dist/index.js ADDED
@@ -0,0 +1,632 @@
1
+ import { GIFEncoder, applyPalette, quantize } from "gifenc";
2
+ import { BufferTarget, CanvasSource, Mp4OutputFormat, Output, QUALITY_HIGH, WebMOutputFormat, canEncodeVideo } from "mediabunny";
3
+ import { compare, parseAsOf } from "@loadmap/core";
4
+ import { fmt, monthOf, multiple, multiples, pct } from "@loadmap/core/format";
5
+ // src/media.ts
6
+ const captionOf = (c) => c === true ? {} : c ? c : null;
7
+ /** The caption's bands for a frame this wide: fixed heights (three verdict lines), so a video keeps its size. */
8
+ function bands(width) {
9
+ const u = Math.max(1, width / 900);
10
+ return {
11
+ u,
12
+ top: Math.round(44 * u),
13
+ bottom: Math.round(84 * u)
14
+ };
15
+ }
16
+ /** The theme as the page computed it: canvas and serialised SVG cannot read CSS variables. */
17
+ function tokens(el) {
18
+ const cs = getComputedStyle(el);
19
+ const t = (k, fallback) => cs.getPropertyValue(`--lm-${k}`).trim() || fallback;
20
+ return {
21
+ font: cs.fontFamily || "sans-serif",
22
+ mono: t("mono", "monospace"),
23
+ bg: t("bg", "#eef2f5"),
24
+ surface: t("surface", "#ffffff"),
25
+ fg: t("fg", "#17212b"),
26
+ muted: t("muted", "#5d6d7c"),
27
+ line: t("line", "#c6d0da"),
28
+ ok: t("ok", "#2a7f5a"),
29
+ warn: t("warn", "#b86e00"),
30
+ drop: t("drop", "#b7392e"),
31
+ series: [
32
+ 1,
33
+ 2,
34
+ 3,
35
+ 4
36
+ ].map((i) => t(`s${i}`, "#5d6d7c"))
37
+ };
38
+ }
39
+ const tierColor = (k, tier) => tier === "over" ? k.drop : tier === "warn" ? k.warn : k.ok;
40
+ /** What the caption says: the title, the scenario and load, and the verdict. */
41
+ function captionText(handle, c) {
42
+ const { model, ev } = handle.engine;
43
+ const scenario = model.scenarioOf(ev.values);
44
+ return {
45
+ title: c.title ?? model.title ?? "",
46
+ where: [
47
+ c.step,
48
+ scenario?.label,
49
+ `${fmt(ev.values.load)}× today`
50
+ ].filter(Boolean).join(" · "),
51
+ verdict: c.text ?? ev.verdict.text,
52
+ tier: ev.verdict.tier
53
+ };
54
+ }
55
+ /** Lines of at most `width`, broken at spaces; past `max` lines the last ends in "…". */
56
+ function wrap(measure, text, width, max = Infinity) {
57
+ const lines = [];
58
+ let line = "";
59
+ for (const word of text.split(/\s+/).filter(Boolean)) {
60
+ const next = line ? `${line} ${word}` : word;
61
+ if (!line || measure(next) <= width) line = next;
62
+ else {
63
+ lines.push(line);
64
+ line = word;
65
+ }
66
+ }
67
+ if (line) lines.push(line);
68
+ if (lines.length <= max) return lines;
69
+ const keep = lines.slice(0, max);
70
+ let last = keep[max - 1];
71
+ while (last && measure(`${last}…`) > width) last = last.slice(0, -1);
72
+ keep[max - 1] = `${last.trimEnd()}…`;
73
+ return keep;
74
+ }
75
+ /** Draws the caption's bands onto a frame of `w` × `h`. */
76
+ function drawCaption(ctx, handle, c, w, h) {
77
+ const k = tokens(handle.board().svg);
78
+ const { u, top, bottom } = bands(w);
79
+ const t = captionText(handle, c);
80
+ const pad = 14 * u;
81
+ ctx.save();
82
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
83
+ ctx.fillStyle = k.line;
84
+ ctx.fillRect(0, top - u, w, u);
85
+ ctx.fillRect(0, h - bottom, w, u);
86
+ ctx.textBaseline = "middle";
87
+ ctx.font = `500 ${13 * u}px ${k.font}`;
88
+ ctx.fillStyle = k.muted;
89
+ ctx.textAlign = "right";
90
+ ctx.fillText(t.where, w - pad, top / 2);
91
+ const whereW = ctx.measureText(t.where).width;
92
+ ctx.font = `600 ${16 * u}px ${k.font}`;
93
+ ctx.fillStyle = k.fg;
94
+ ctx.textAlign = "left";
95
+ ctx.fillText(t.title, pad, top / 2, Math.max(0, w - 3 * pad - whereW));
96
+ ctx.fillStyle = tierColor(k, t.tier);
97
+ ctx.fillRect(pad, h - bottom + 12 * u, 4 * u, bottom - 24 * u);
98
+ ctx.font = `400 ${14 * u}px ${k.font}`;
99
+ ctx.fillStyle = k.fg;
100
+ ctx.textBaseline = "top";
101
+ wrap((s) => ctx.measureText(s).width, t.verdict, w - 2 * pad - 14 * u, 3).forEach((l, i) => {
102
+ ctx.fillText(l, pad + 14 * u, h - bottom + (14 + i * 20) * u);
103
+ });
104
+ ctx.restore();
105
+ }
106
+ const PAINT = [
107
+ "fill",
108
+ "stroke",
109
+ "color"
110
+ ];
111
+ /**
112
+ * A copy of the board's SVG that needs no page: every colour that comes from a theme token is written in as the value
113
+ * the page computed, and the font, size and namespace are set on the root.
114
+ */
115
+ function standalone(svg, w, h) {
116
+ const copy = svg.cloneNode(true);
117
+ const src = [svg, ...svg.querySelectorAll("*")];
118
+ const dst = [copy, ...copy.querySelectorAll("*")];
119
+ src.forEach((el, i) => {
120
+ const cs = getComputedStyle(el);
121
+ const out = dst[i];
122
+ for (const p of PAINT) {
123
+ const a = out.getAttribute(p);
124
+ if (a && (a.includes("var(") || a === "currentColor")) out.setAttribute(p, cs.getPropertyValue(p));
125
+ }
126
+ if (out.style?.color) out.style.color = cs.color;
127
+ for (const a of [
128
+ "tabindex",
129
+ "aria-describedby",
130
+ "class"
131
+ ]) out.removeAttribute(a);
132
+ });
133
+ const root = getComputedStyle(svg);
134
+ copy.setAttribute("font-family", root.fontFamily);
135
+ copy.setAttribute("color", root.color);
136
+ copy.setAttribute("width", String(w));
137
+ copy.setAttribute("height", String(h));
138
+ copy.setAttribute("xmlns", "http://www.w3.org/2000/svg");
139
+ return copy;
140
+ }
141
+ const NS = "http://www.w3.org/2000/svg";
142
+ function el(name, attrs, text) {
143
+ const e = document.createElementNS(NS, name);
144
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
145
+ if (text != null) e.textContent = text;
146
+ return e;
147
+ }
148
+ /**
149
+ * The board as an SVG file: the structure as drawn, the dots where they are now, and with `caption` the title line
150
+ * and the verdict. Text goes in as text nodes, never as markup, so a label cannot break the file.
151
+ */
152
+ async function exportSvg(handle, o = {}) {
153
+ await handle.ready();
154
+ const b = handle.board();
155
+ const { width: sw, height: sh } = b.scene;
156
+ const board = standalone(b.svg, sw, sh);
157
+ const dots = el("g", {});
158
+ for (const d of handle.engine.dots()) dots.appendChild(el("circle", {
159
+ cx: d.x.toFixed(2),
160
+ cy: d.y.toFixed(2),
161
+ r: d.r,
162
+ fill: d.color,
163
+ ...d.alpha < 1 ? { "fill-opacity": d.alpha.toFixed(3) } : {}
164
+ }));
165
+ board.appendChild(dots);
166
+ const cap = captionOf(o.caption);
167
+ if (!cap) return new XMLSerializer().serializeToString(board);
168
+ const k = tokens(b.svg);
169
+ const { top, bottom } = bands(sw);
170
+ const h = sh + top + bottom;
171
+ const t = captionText(handle, cap);
172
+ const out = el("svg", {
173
+ xmlns: NS,
174
+ width: sw,
175
+ height: h,
176
+ viewBox: `0 0 ${sw} ${h}`,
177
+ "font-family": k.font
178
+ });
179
+ out.appendChild(el("rect", {
180
+ width: sw,
181
+ height: h,
182
+ fill: k.surface
183
+ }));
184
+ out.appendChild(el("text", {
185
+ x: 14,
186
+ y: top / 2 + 5,
187
+ "font-size": 16,
188
+ "font-weight": 600,
189
+ fill: k.fg
190
+ }, t.title));
191
+ out.appendChild(el("text", {
192
+ x: sw - 14,
193
+ y: top / 2 + 5,
194
+ "font-size": 13,
195
+ "text-anchor": "end",
196
+ fill: k.muted
197
+ }, t.where));
198
+ out.appendChild(el("rect", {
199
+ x: 0,
200
+ y: top - 1,
201
+ width: sw,
202
+ height: 1,
203
+ fill: k.line
204
+ }));
205
+ board.setAttribute("y", String(top));
206
+ out.appendChild(board);
207
+ out.appendChild(el("rect", {
208
+ x: 0,
209
+ y: top + sh,
210
+ width: sw,
211
+ height: 1,
212
+ fill: k.line
213
+ }));
214
+ out.appendChild(el("rect", {
215
+ x: 14,
216
+ y: top + sh + 12,
217
+ width: 4,
218
+ height: bottom - 24,
219
+ fill: tierColor(k, t.tier)
220
+ }));
221
+ const ctx = document.createElement("canvas").getContext("2d");
222
+ ctx.font = `400 14px ${k.font}`;
223
+ wrap((s) => ctx.measureText(s).width, t.verdict, sw - 42, 3).forEach((line, i) => {
224
+ out.appendChild(el("text", {
225
+ x: 28,
226
+ y: top + sh + 28 + i * 20,
227
+ "font-size": 14,
228
+ fill: k.fg
229
+ }, line));
230
+ });
231
+ return new XMLSerializer().serializeToString(out);
232
+ }
233
+ /**
234
+ * One picture that answers the question on its own: the title, the scenario and load, the board, the verdict, where
235
+ * each component runs out (with dates, given growth), the scenarios compared, and which capacities are estimates.
236
+ * `scale` is pixels per CSS pixel (default 2).
237
+ */
238
+ /** The poster's width and the board's place in it, in CSS pixels: one source for the board image and the drawing. */
239
+ function posterLayout(scene) {
240
+ const W = Math.max(720, Math.min(1400, scene.width + 48));
241
+ const pad = 24;
242
+ const inner = W - 48;
243
+ return {
244
+ W,
245
+ pad,
246
+ inner,
247
+ boardH: scene.height * inner / scene.width
248
+ };
249
+ }
250
+ async function drawPoster(handle, image, scale = 2) {
251
+ const b = handle.board();
252
+ const { model, ev } = handle.engine;
253
+ const k = tokens(b.svg);
254
+ const { W, pad, inner, boardH } = posterLayout(b.scene);
255
+ const date = (x) => {
256
+ const d = model.dateAt(x);
257
+ return d ? ` (${monthOf(d, true)})` : "";
258
+ };
259
+ const at = (r, x) => r ? `${multiples(r, model.searchMax, true)}${date(r.low)}` : `${multiple(x, model.searchMax)}${date(x)}`;
260
+ const t = captionText(handle, {});
261
+ const g = model.growth;
262
+ const sub = [t.where, g && `load grows ${fmt(g.rate * 100)} % a ${g.per} from ${monthOf(parseAsOf(g.asOf))}`].filter(Boolean).join(" · ");
263
+ const rows = model.scenarios.length > 1 ? compare(model, ev.values.load) : null;
264
+ const estimates = ev.assumptions.flatMap((a) => a.kind === "capacity" && a.capacity.provenance === "estimated" ? [a.name] : []);
265
+ const measure = document.createElement("canvas").getContext("2d");
266
+ const font = (weight, size, mono = false) => `${weight} ${size}px ${mono ? k.mono : k.font}`;
267
+ const lines = (text, f, width) => {
268
+ measure.font = f;
269
+ return wrap((s) => measure.measureText(s).width, text, width);
270
+ };
271
+ const verdict = lines(t.verdict, font(400, 15), inner - 20);
272
+ const hint = ev.measure ? lines(ev.measure.text, font(400, 13), inner - 20) : [];
273
+ const compared = rows ? lines(rows.text, font(400, 14), inner) : [];
274
+ const blocks = [];
275
+ const text = (ctx, s, x, y, f, color, align = "left") => {
276
+ ctx.font = f;
277
+ ctx.fillStyle = color;
278
+ ctx.textAlign = align;
279
+ ctx.fillText(s, x, y);
280
+ };
281
+ const heading = (s) => blocks.push([30, (ctx, y) => text(ctx, s.toUpperCase(), pad, y + 20, font(600, 11), k.muted)]);
282
+ blocks.push([34, (ctx, y) => text(ctx, t.title, pad, y + 26, font(600, 22), k.fg)]);
283
+ blocks.push([26, (ctx, y) => text(ctx, sub, pad, y + 16, font(400, 13), k.muted)]);
284
+ blocks.push([boardH + 16, (ctx, y) => {
285
+ ctx.fillStyle = k.line;
286
+ ctx.fillRect(pad - 1, y + 7, inner + 2, boardH + 2);
287
+ ctx.drawImage(image, pad, y + 8, inner, boardH);
288
+ }]);
289
+ blocks.push([verdict.length * 22 + 20, (ctx, y) => {
290
+ ctx.fillStyle = tierColor(k, t.tier);
291
+ ctx.fillRect(pad, y + 6, 4, verdict.length * 22 + 8);
292
+ verdict.forEach((l, i) => {
293
+ text(ctx, l, pad + 16, y + 24 + i * 22, font(400, 15), k.fg);
294
+ });
295
+ }]);
296
+ if (hint.length) blocks.push([hint.length * 18 + 6, (ctx, y) => {
297
+ hint.forEach((l, i) => {
298
+ text(ctx, l, pad + 16, y + 14 + i * 18, font(400, 13), k.muted);
299
+ });
300
+ }]);
301
+ heading("Where it runs out, in order");
302
+ const top = Math.max(1.5, model.thresholds.full * 1.5);
303
+ blocks.push([16, (ctx, y) => {
304
+ const th = model.thresholds;
305
+ text(ctx, "0", pad, y + 10, font(400, 11, true), k.muted);
306
+ text(ctx, `${pct(th.target)} target`, pad + inner * th.target / top, y + 10, font(400, 11, true), k.muted, "center");
307
+ text(ctx, pct(th.full), pad + inner * th.full / top, y + 10, font(400, 11, true), k.muted, "center");
308
+ text(ctx, pct(top), W - pad, y + 10, font(400, 11, true), k.muted, "right");
309
+ }]);
310
+ for (const c of ev.ranked) blocks.push([46, (ctx, y) => {
311
+ const first = c === ev.firstLimit ? " · first limit" : "";
312
+ text(ctx, `${c.name}${first}`, pad, y + 16, font(600, 14), k.fg);
313
+ const right = c.util == null ? "switched off" : `${pct(c.util)} now · ${pct(model.thresholds.target)} at ${at(c.targetRange, c.target)} · ${pct(model.thresholds.full)} at ${at(c.fullRange, c.full)}`;
314
+ text(ctx, right, W - pad, y + 16, font(400, 12, true), k.muted, "right");
315
+ ctx.fillStyle = k.bg;
316
+ ctx.fillRect(pad, y + 24, inner, 10);
317
+ ctx.fillStyle = c.state === "over" ? k.drop : c.state === "warn" ? k.warn : k.ok;
318
+ ctx.fillRect(pad, y + 24, inner * Math.min(top, c.util ?? 0) / top, 10);
319
+ ctx.fillStyle = k.fg;
320
+ for (const th of [model.thresholds.target, model.thresholds.full]) ctx.fillRect(pad + inner * th / top, y + 22, 1, 14);
321
+ }]);
322
+ if (rows) {
323
+ heading("Scenarios compared");
324
+ blocks.push([compared.length * 20 + 6, (ctx, y) => {
325
+ compared.forEach((l, i) => {
326
+ text(ctx, l, pad, y + 15 + i * 20, font(400, 14), k.fg);
327
+ });
328
+ }]);
329
+ rows.rows.forEach((r, i) => {
330
+ blocks.push([26, (ctx, y) => {
331
+ ctx.fillStyle = k.series[i] ?? k.muted;
332
+ ctx.beginPath();
333
+ ctx.arc(pad + 5, y + 13, 5, 0, Math.PI * 2);
334
+ ctx.fill();
335
+ text(ctx, r.label, pad + 18, y + 18, font(600, 14), k.fg);
336
+ const what = `first limit ${r.firstLimit?.name ?? "—"} · ${pct(model.thresholds.target)} at ${at(r.targetRange, r.target)} · runs out at ${at(r.fullRange, r.full)}`;
337
+ text(ctx, what, W - pad, y + 18, font(400, 12, true), k.muted, "right");
338
+ }]);
339
+ });
340
+ }
341
+ if (estimates.length) blocks.push([30, (ctx, y) => text(ctx, `Estimated capacities: ${estimates.join(", ")}.`, pad, y + 22, font(400, 12), k.muted)]);
342
+ const H = Math.ceil(blocks.reduce((a, [h]) => a + h, 0) + 2 * pad);
343
+ const canvas = document.createElement("canvas");
344
+ canvas.width = Math.round(W * scale);
345
+ canvas.height = Math.round(H * scale);
346
+ const ctx = canvas.getContext("2d");
347
+ ctx.scale(scale, scale);
348
+ ctx.fillStyle = k.surface;
349
+ ctx.fillRect(0, 0, W, H);
350
+ ctx.textBaseline = "alphabetic";
351
+ let y = pad;
352
+ for (const [h, draw] of blocks) {
353
+ draw(ctx, y);
354
+ y += h;
355
+ }
356
+ return canvas;
357
+ }
358
+ // src/index.ts
359
+ /** A script step: a story step, or values. */
360
+ function apply(handle, s) {
361
+ if (s.step == null) return handle.setValues(valuesOf(handle, s));
362
+ if (!handle.engine.model.story[s.step]) throw new Error(`loadmap: the story has no step ${s.step + 1}`);
363
+ handle.setStoryStep(s.step);
364
+ }
365
+ /** What a script step or a still sets: a scenario's values, then `set`. An unknown scenario names the known ones. */
366
+ function valuesOf(handle, o) {
367
+ if (o.scenario == null) return { ...o.set };
368
+ const model = handle.engine.model;
369
+ const s = model.scenarios.find((x) => x.label === o.scenario);
370
+ if (!s) {
371
+ const known = model.scenarios.map((x) => `"${x.label}"`).join(", ");
372
+ throw new Error(`loadmap: no scenario "${o.scenario}"; ${known ? `the model has ${known}` : "the model has none"}`);
373
+ }
374
+ return {
375
+ ...s.values,
376
+ ...o.set
377
+ };
378
+ }
379
+ /** The board as one image: the SVG rasterised with its theme colours resolved, the dots painted on top. */
380
+ async function composeFrame(handle, o = {}, into) {
381
+ await handle.ready();
382
+ const b = handle.board();
383
+ const cap = captionOf(o.caption);
384
+ const w = o.size?.width ?? even(b.scene.width * (o.scale ?? 2));
385
+ const band = cap ? bands(w) : {
386
+ top: 0,
387
+ bottom: 0
388
+ };
389
+ const h = o.size?.height ?? even(b.scene.height * (o.scale ?? 2) + band.top + band.bottom);
390
+ const room = h - band.top - band.bottom;
391
+ const scale = Math.min(w / b.scene.width, room / b.scene.height);
392
+ const dw = b.scene.width * scale;
393
+ const dh = b.scene.height * scale;
394
+ const c = into ?? document.createElement("canvas");
395
+ if (c.width !== w || c.height !== h) {
396
+ c.width = w;
397
+ c.height = h;
398
+ }
399
+ const ctx = c.getContext("2d");
400
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
401
+ ctx.fillStyle = getComputedStyle(b.svg).getPropertyValue("--lm-surface").trim() || "#fff";
402
+ ctx.fillRect(0, 0, w, h);
403
+ const img = await rasterise(b.svg, Math.round(dw), Math.round(dh));
404
+ ctx.translate((w - dw) / 2, band.top + (room - dh) / 2);
405
+ ctx.drawImage(img, 0, 0, dw, dh);
406
+ handle.engine.paintParticles(ctx, scale);
407
+ if (cap) drawCaption(ctx, handle, cap, w, h);
408
+ return c;
409
+ }
410
+ /** A PNG of the board as it is now. */
411
+ async function exportPng(handle, o = {}) {
412
+ return toPng(await composeFrame(handle, o));
413
+ }
414
+ /**
415
+ * A video or GIF of a scenario, made frame by frame on a fixed clock: the live animation is paused, the sim advances
416
+ * exactly 1/fps per frame, and each frame is encoded at its own timestamp. Nothing depends on how fast the machine
417
+ * is, so the same options give the same frames. The values the script changed are put back afterwards.
418
+ */
419
+ async function recordVideo(handle, o) {
420
+ const format = o.format ?? "webm";
421
+ const fps = o.fps ?? (format === "gif" ? 15 : 30);
422
+ const story = o.story ? handle.engine.model.story : null;
423
+ if (story && !story.length) throw new Error("loadmap: the model has no story");
424
+ const starts = story?.map((_, i) => story.slice(0, i).reduce((t, s) => t + s.seconds, 0));
425
+ const seconds = story ? story.reduce((t, s) => t + s.seconds, 0) : o.seconds;
426
+ if (!(fps > 0) || !(seconds != null && seconds > 0)) throw new Error("loadmap: seconds and fps must be positive");
427
+ if (format === "gif" && fps > 50) throw new Error("loadmap: a GIF plays at most 50 frames a second");
428
+ const script = story ? starts.map((at, step) => ({
429
+ at,
430
+ step
431
+ })) : [...o.script ?? []].sort((a, b) => a.at - b.at);
432
+ const cap = captionOf(o.caption);
433
+ /** The caption for the step shown: its text instead of the verdict. */
434
+ const captionNow = () => {
435
+ const i = handle.storyStep();
436
+ const st = story && i != null ? story[i] : void 0;
437
+ return cap && st ? {
438
+ ...cap,
439
+ text: st.text,
440
+ step: `${i + 1} of ${story.length}`
441
+ } : o.caption;
442
+ };
443
+ await handle.ready();
444
+ const before = handle.values();
445
+ const beforeStep = handle.storyStep();
446
+ handle.pause();
447
+ try {
448
+ for (const s of script.filter((s) => s.at <= 0)) apply(handle, s);
449
+ await warmUp(handle, o.seed ?? 1, o.warmup ?? 2, fps);
450
+ const canvas = document.createElement("canvas");
451
+ if (format === "gif") canvas.getContext("2d", { willReadFrequently: true });
452
+ await composeFrame(handle, {
453
+ scale: o.scale ?? 1,
454
+ caption: captionNow()
455
+ }, canvas);
456
+ const sink = format === "gif" ? gifSink(canvas, fps) : await videoSink(canvas, format, fps);
457
+ const frames = Math.round(seconds * fps);
458
+ let next = script.findIndex((s) => s.at > 0);
459
+ for (let i = 0; i < frames; i++) {
460
+ const t = i / fps;
461
+ while (next >= 0 && next < script.length && script[next].at <= t) {
462
+ apply(handle, script[next]);
463
+ next++;
464
+ }
465
+ await handle.ready();
466
+ handle.step(1 / fps);
467
+ await composeFrame(handle, {
468
+ size: {
469
+ width: canvas.width,
470
+ height: canvas.height
471
+ },
472
+ caption: captionNow()
473
+ }, canvas);
474
+ await o.onFrame?.(i, canvas);
475
+ await sink.add(t);
476
+ }
477
+ return await sink.finish();
478
+ } finally {
479
+ if (handle.storyStep() !== beforeStep) handle.setStoryStep(beforeStep);
480
+ handle.setValues(before);
481
+ handle.resume();
482
+ }
483
+ }
484
+ /** A PNG of the board after `set` and a warm-up on a fixed clock: the same options give the same picture. */
485
+ async function recordStill(handle, o = {}) {
486
+ return settled(handle, o, () => exportPng(handle, o));
487
+ }
488
+ /**
489
+ * A poster: the board after a warm-up, with the title, the verdict, where each component runs out and the scenarios
490
+ * compared, as one PNG. The same options give the same bytes. `scale` is pixels per CSS pixel (default 2).
491
+ */
492
+ async function recordPoster(handle, o = {}) {
493
+ return settled(handle, o, async () => {
494
+ const scale = o.scale ?? 2;
495
+ const { inner, boardH } = posterLayout(handle.board().scene);
496
+ return toPng(await drawPoster(handle, await composeFrame(handle, { size: {
497
+ width: even(inner * scale),
498
+ height: even(boardH * scale)
499
+ } }), scale));
500
+ });
501
+ }
502
+ /** Sets the values, warms the sim up on a fixed clock, makes the picture, and puts the values back. */
503
+ async function settled(handle, o, make) {
504
+ await handle.ready();
505
+ const before = handle.values();
506
+ handle.pause();
507
+ try {
508
+ if (o.set || o.scenario != null) handle.setValues(valuesOf(handle, o));
509
+ await warmUp(handle, o.seed ?? 1, o.warmup ?? 4, 30);
510
+ return await make();
511
+ } finally {
512
+ handle.setValues(before);
513
+ handle.resume();
514
+ }
515
+ }
516
+ function toPng(c) {
517
+ return new Promise((resolve, reject) => c.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("loadmap: PNG failed")), "image/png"));
518
+ }
519
+ /**
520
+ * Lets `loadmap record` drive this loadmap: puts a recorder on `globalThis.__loadmap[name]`.
521
+ * Returns a function that takes it off again.
522
+ */
523
+ function expose(handle, name = "default") {
524
+ const recorder = {
525
+ record: async (o) => base64(await recordVideo(handle, o)),
526
+ still: async (o) => base64(await recordStill(handle, o)),
527
+ poster: async (o) => base64(await recordPoster(handle, o)),
528
+ svg: (o) => settled(handle, o, () => exportSvg(handle, o))
529
+ };
530
+ globalThis.__loadmap ??= {};
531
+ globalThis.__loadmap[name] = recorder;
532
+ return () => {
533
+ if (globalThis.__loadmap?.[name] === recorder) delete globalThis.__loadmap[name];
534
+ };
535
+ }
536
+ async function warmUp(handle, seed, seconds, fps) {
537
+ await handle.ready();
538
+ handle.reset(seed);
539
+ for (let i = 0, n = Math.round(seconds * fps); i < n; i++) handle.step(1 / fps);
540
+ }
541
+ async function videoSink(canvas, format, fps) {
542
+ const codec = await pickCodec(format, canvas.width, canvas.height);
543
+ const output = new Output({
544
+ format: format === "mp4" ? new Mp4OutputFormat({ fastStart: "in-memory" }) : new WebMOutputFormat(),
545
+ target: new BufferTarget()
546
+ });
547
+ const source = new CanvasSource(canvas, {
548
+ codec,
549
+ bitrate: QUALITY_HIGH,
550
+ keyFrameInterval: 2
551
+ });
552
+ output.addVideoTrack(source, { frameRate: fps });
553
+ await output.start();
554
+ return {
555
+ add: (t) => source.add(t, 1 / fps),
556
+ finish: async () => {
557
+ source.close();
558
+ await output.finalize();
559
+ const buf = output.target.buffer;
560
+ return new Blob([buf], { type: format === "mp4" ? "video/mp4" : "video/webm" });
561
+ }
562
+ };
563
+ }
564
+ /** A GIF with a palette per frame, so flow colours stay true; pure JavaScript, so its bytes are reproducible. */
565
+ function gifSink(canvas, fps) {
566
+ const gif = GIFEncoder();
567
+ const ctx = canvas.getContext("2d");
568
+ const cs = (i) => Math.round(100 * i / fps);
569
+ let i = 0;
570
+ return {
571
+ add: async () => {
572
+ const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height);
573
+ const palette = quantize(data, 256);
574
+ const delay = 10 * (cs(i + 1) - cs(i));
575
+ i++;
576
+ gif.writeFrame(applyPalette(data, palette), width, height, {
577
+ palette,
578
+ delay,
579
+ repeat: 0
580
+ });
581
+ },
582
+ finish: async () => {
583
+ gif.finish();
584
+ return new Blob([gif.bytes()], { type: "image/gif" });
585
+ }
586
+ };
587
+ }
588
+ async function base64(b) {
589
+ const bytes = new Uint8Array(await b.arrayBuffer());
590
+ let s = "";
591
+ for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
592
+ return btoa(s);
593
+ }
594
+ /** Saves a blob through the browser's download. */
595
+ function download(blob, name) {
596
+ const a = document.createElement("a");
597
+ a.href = URL.createObjectURL(blob);
598
+ a.download = name;
599
+ a.click();
600
+ setTimeout(() => URL.revokeObjectURL(a.href), 1e4);
601
+ }
602
+ const even = (x) => Math.max(2, Math.round(x / 2) * 2);
603
+ async function pickCodec(format, width, height) {
604
+ const options = format === "mp4" ? [
605
+ "avc",
606
+ "vp9",
607
+ "av1"
608
+ ] : [
609
+ "vp9",
610
+ "vp8",
611
+ "av1"
612
+ ];
613
+ for (const c of options) if (await canEncodeVideo(c, {
614
+ width,
615
+ height
616
+ })) return c;
617
+ throw new Error(`loadmap: this browser cannot encode ${format} video`);
618
+ }
619
+ /** An SVG drawn as an image cannot see the page's CSS: it is drawn from a copy with the theme written in. */
620
+ async function rasterise(svg, w, h) {
621
+ const copy = standalone(svg, w, h);
622
+ const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(copy)], { type: "image/svg+xml" }));
623
+ try {
624
+ const img = new Image();
625
+ img.src = url;
626
+ await img.decode();
627
+ return img;
628
+ } finally {
629
+ URL.revokeObjectURL(url);
630
+ }
631
+ }
632
+ export { composeFrame, download, exportPng, exportSvg, expose, recordPoster, recordStill, recordVideo };
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@loadmap/export",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "PNG, GIF, WebM and MP4 of a loadmap, recorded on a fixed clock so the same options give the same file.",
5
+ "keywords": [
6
+ "loadmap",
7
+ "gif",
8
+ "video",
9
+ "export"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Jaime Rojas",
13
+ "homepage": "https://jaim3r0jas.github.io/loadmap/",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/jaim3r0jas/loadmap.git",
17
+ "directory": "packages/export"
18
+ },
19
+ "bugs": "https://github.com/jaim3r0jas/loadmap/issues",
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "dependencies": {
32
+ "@loadmap/core": "0.1.1",
33
+ "@loadmap/react": "0.1.1",
34
+ "gifenc": "^1.0.3",
35
+ "mediabunny": "^1.65.0"
36
+ },
37
+ "peerDependencies": {},
38
+ "scripts": {
39
+ "build": "tsdown"
40
+ }
6
41
  }