@loadmap/react 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/dist/index.js ADDED
@@ -0,0 +1,2070 @@
1
+ import { compare, headroom, reach } from "@loadmap/core";
2
+ import { fitLinear } from "@loadmap/core/calibrate";
3
+ import { compact, compact as compact$1, fmt, fmt as fmt$1, monthOf, multiple, multiple as multiple$1, multiples, pct, pct as pct$1, secs, secs as secs$1 } from "@loadmap/core/format";
4
+ import { anchor, cluster, defineView, edge, labelsAt, layout, legPath, localElk, node, pointAt, pool, structureKey } from "@loadmap/layout";
5
+ import { memo, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
+ import { createSim } from "@loadmap/sim";
8
+ export * from "@loadmap/core";
9
+ // src/scenarios.tsx
10
+ /** Scenario identity colours, in declaration order; a fifth and later scenario is drawn muted. */
11
+ const SERIES = [
12
+ "var(--lm-s1)",
13
+ "var(--lm-s2)",
14
+ "var(--lm-s3)",
15
+ "var(--lm-s4)"
16
+ ];
17
+ /** The colour of a scenario by label; values of the reader's own are drawn in the text colour. */
18
+ function scenarioColor(model, label) {
19
+ if (label == null) return "var(--lm-fg)";
20
+ const i = model.scenarios.findIndex((s) => s.label === label);
21
+ return SERIES[i] ?? "var(--lm-muted)";
22
+ }
23
+ const lineName = (l) => l.label ?? "These values";
24
+ /** A month for a table cell or an axis: "Mar 2031"; "now" for a crossing already reached. */
25
+ function when(model, x) {
26
+ const d = model.dateAt(x);
27
+ return d ? monthOf(d, true) : x != null && x <= 1 && model.growth ? "now" : "";
28
+ }
29
+ /** The months of both edges: "Sep 2029–Feb 2030", or one month when they agree. */
30
+ function whenRange(model, r) {
31
+ if (!r) return "";
32
+ const [lo, hi] = [when(model, r.low), when(model, r.high)];
33
+ return !hi || lo === hi ? lo : `${lo}–${hi}`;
34
+ }
35
+ /** The comparison at the shown load: crossings do not depend on it, the verdicts do. */
36
+ function useComparison(lm) {
37
+ return useMemo(() => compare(lm.model, lm.values.load), [lm.model, lm.values.load]);
38
+ }
39
+ /** One button per scenario: a reader switches designs without touching the controls. The load stays. */
40
+ function Scenarios({ lm }) {
41
+ const { model } = lm;
42
+ if (!model.scenarios.length) return null;
43
+ return /* @__PURE__ */ jsxs("fieldset", {
44
+ className: "lm-scenarios",
45
+ children: [
46
+ /* @__PURE__ */ jsx("legend", { children: "Scenario" }),
47
+ model.scenarios.map((s) => /* @__PURE__ */ jsxs("button", {
48
+ type: "button",
49
+ "aria-pressed": lm.scenario?.label === s.label,
50
+ onClick: () => lm.setScenario(s.label),
51
+ children: [/* @__PURE__ */ jsx("i", { style: { background: scenarioColor(model, s.label) } }), s.label]
52
+ }, s.label)),
53
+ !lm.scenario && /* @__PURE__ */ jsx("span", {
54
+ className: "lm-own",
55
+ children: "your own values"
56
+ })
57
+ ]
58
+ });
59
+ }
60
+ /** "+107 %" against the baseline; "same" within a rounding; nothing where either never runs out. */
61
+ function change(x) {
62
+ if (x == null || !Number.isFinite(x) || x === 0) return "";
63
+ if (Math.abs(x - 1) < .005) return "same";
64
+ return `${x > 1 ? "+" : "−"}${pct$1(Math.abs(x - 1))}`;
65
+ }
66
+ /** The scenarios side by side: which buys more headroom, where each first runs out, and per component. */
67
+ function Compare({ lm, title = "Scenarios compared" }) {
68
+ const { model } = lm;
69
+ const c = useComparison(lm);
70
+ if (c.rows.length < 2) return null;
71
+ const target = `${pct$1(model.thresholds.target)} target`;
72
+ const at = (r) => /* @__PURE__ */ jsxs(Fragment, { children: [r ? multiples(r, model.searchMax, true) : multiple$1(null), whenRange(model, r) && /* @__PURE__ */ jsx("small", { children: whenRange(model, r) })] });
73
+ return /* @__PURE__ */ jsxs("div", {
74
+ className: "lm-card lm-compare",
75
+ children: [
76
+ /* @__PURE__ */ jsx("h3", { children: title }),
77
+ /* @__PURE__ */ jsx("p", {
78
+ className: "lm-compare-text",
79
+ children: c.text
80
+ }),
81
+ /* @__PURE__ */ jsx("div", {
82
+ className: "lm-table-wrap",
83
+ children: /* @__PURE__ */ jsxs("table", {
84
+ className: "lm-table",
85
+ children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
86
+ /* @__PURE__ */ jsx("th", {
87
+ scope: "col",
88
+ children: "Scenario"
89
+ }),
90
+ /* @__PURE__ */ jsx("th", {
91
+ scope: "col",
92
+ children: "First limit"
93
+ }),
94
+ /* @__PURE__ */ jsx("th", {
95
+ scope: "col",
96
+ children: target
97
+ }),
98
+ /* @__PURE__ */ jsx("th", {
99
+ scope: "col",
100
+ children: "Runs out"
101
+ })
102
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: c.rows.map((r) => /* @__PURE__ */ jsxs("tr", {
103
+ "data-scenario": r.label,
104
+ "aria-current": lm.scenario?.label === r.label || void 0,
105
+ children: [
106
+ /* @__PURE__ */ jsxs("th", {
107
+ scope: "row",
108
+ children: [/* @__PURE__ */ jsx("i", { style: { background: scenarioColor(model, r.label) } }), r.label]
109
+ }),
110
+ /* @__PURE__ */ jsx("td", {
111
+ className: "lm-text",
112
+ children: r.firstLimit?.name ?? "—"
113
+ }),
114
+ /* @__PURE__ */ jsx("td", {
115
+ "data-multiple": r.target ?? "",
116
+ "data-high": r.targetRange?.high ?? "",
117
+ children: at(r.targetRange)
118
+ }),
119
+ /* @__PURE__ */ jsx("td", {
120
+ "data-multiple": r.full ?? "",
121
+ "data-high": r.fullRange?.high ?? "",
122
+ children: at(r.fullRange)
123
+ })
124
+ ]
125
+ }, r.label)) })]
126
+ })
127
+ }),
128
+ /* @__PURE__ */ jsxs("h3", { children: [target, ", by component"] }),
129
+ /* @__PURE__ */ jsx("div", {
130
+ className: "lm-table-wrap",
131
+ children: /* @__PURE__ */ jsxs("table", {
132
+ className: "lm-table",
133
+ children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("th", {
134
+ scope: "col",
135
+ children: "Component"
136
+ }), c.rows.map((r) => /* @__PURE__ */ jsx("th", {
137
+ scope: "col",
138
+ children: r.label
139
+ }, r.label))] }) }), /* @__PURE__ */ jsx("tbody", { children: model.componentIds.map((id, k) => /* @__PURE__ */ jsxs("tr", {
140
+ "data-component": id,
141
+ children: [/* @__PURE__ */ jsx("th", {
142
+ scope: "row",
143
+ children: c.rows[0].components[k].name
144
+ }), c.rows.map((r, i) => {
145
+ const x = r.components[k];
146
+ return /* @__PURE__ */ jsxs("td", {
147
+ "data-multiple": x.target ?? "",
148
+ children: [x.targetRange == null ? "off" : multiples(x.targetRange, model.searchMax, true), i > 0 && change(x.change) && /* @__PURE__ */ jsx("small", { children: change(x.change) })]
149
+ }, r.label);
150
+ })]
151
+ }, id)) })]
152
+ })
153
+ })
154
+ ]
155
+ });
156
+ }
157
+ /** The width of an element in CSS pixels, kept current: the chart draws at its own size so text stays legible. */
158
+ function useWidth(fallback) {
159
+ const ref = useRef(null);
160
+ const [w, setW] = useState(fallback);
161
+ useEffect(() => {
162
+ const el = ref.current;
163
+ if (!el) return;
164
+ const ro = new ResizeObserver(([e]) => {
165
+ const x = Math.round(e?.contentRect.width ?? el.clientWidth);
166
+ if (x > 0) setW(x);
167
+ });
168
+ ro.observe(el);
169
+ return () => ro.disconnect();
170
+ }, []);
171
+ return [ref, w];
172
+ }
173
+ /** 1, 2, 5, 10, 20, 50, … inside [from, to]; decades only when there is little room. */
174
+ function ticks(from, to, roomy) {
175
+ const out = [];
176
+ for (let d = Math.floor(Math.log10(from)); d <= Math.ceil(Math.log10(to)); d++) for (const k of roomy ? [
177
+ 1,
178
+ 2,
179
+ 5
180
+ ] : [1]) {
181
+ const v = k * 10 ** d;
182
+ if (v >= from * .999 && v <= to * 1.001) out.push(Number(v.toPrecision(6)));
183
+ }
184
+ return out;
185
+ }
186
+ /**
187
+ * Utilization over the load multiple. Each scenario's line is its highest utilization, so it crosses the target and
188
+ * 100 % exactly where its first limit does; the dots are those crossings, taken from the limits, not from the line.
189
+ * With a ± on a capacity, a band shows the line at every capacity's low and high bound, and a bracket on the target
190
+ * and 100 % lines spans where the band crosses them. The shown scenario's components are the thin lines under it.
191
+ * Hover or arrow keys read every line at one load.
192
+ */
193
+ function HeadroomChart({ lm, title = "Headroom" }) {
194
+ const { model, values } = lm;
195
+ const rest = JSON.stringify({
196
+ ...values,
197
+ load: null
198
+ });
199
+ const h = useMemo(() => headroom(model, {
200
+ ...values,
201
+ load: model.load.default
202
+ }), [model, rest]);
203
+ const [wrap, width] = useWidth(640);
204
+ const [hover, setHover] = useState(null);
205
+ const clip = useId().replace(/:/g, "");
206
+ const dated = !!model.growth;
207
+ const m = {
208
+ l: 44,
209
+ r: 14,
210
+ t: 14,
211
+ b: dated ? 40 : 26
212
+ };
213
+ const W = width;
214
+ const H = Math.round(Math.min(300, Math.max(200, W * .45)));
215
+ const pw = W - m.l - m.r;
216
+ const ph = H - m.t - m.b;
217
+ const top = Math.max(1.5, model.thresholds.full * 1.5);
218
+ const lx = (x) => m.l + pw * Math.log(x / h.from) / Math.log(h.to / h.from);
219
+ const ly = (u) => m.t + ph * (1 - Math.min(u, top * 1.05) / top);
220
+ const path = (s) => {
221
+ let d = "";
222
+ let pen = false;
223
+ for (const [x, u] of s) {
224
+ if (u == null) {
225
+ pen = false;
226
+ continue;
227
+ }
228
+ d += `${pen ? "L" : "M"}${lx(x).toFixed(1)} ${ly(u).toFixed(1)}`;
229
+ pen = true;
230
+ }
231
+ return d;
232
+ };
233
+ const xs = h.lines[0]?.envelope.map(([x]) => x) ?? [];
234
+ const selected = h.lines.find((l) => l.selected);
235
+ const roomy = W >= 480;
236
+ const tickList = ticks(h.from, h.to, roomy);
237
+ const labelled = (x) => roomy || Number.isInteger(Math.log10(x));
238
+ const near = (px) => {
239
+ let best = 0;
240
+ for (let i = 1; i < xs.length; i++) if (Math.abs(lx(xs[i]) - px) < Math.abs(lx(xs[best]) - px)) best = i;
241
+ return best;
242
+ };
243
+ const onMove = (e) => {
244
+ const r = e.currentTarget.ownerSVGElement.getBoundingClientRect();
245
+ setHover(near(e.clientX - r.left));
246
+ };
247
+ const onKey = (e) => {
248
+ const i = hover ?? near(lx(Math.min(h.to, Math.max(h.from, values.load))));
249
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
250
+ e.preventDefault();
251
+ setHover(Math.max(0, Math.min(xs.length - 1, i + (e.key === "ArrowRight" ? 1 : -1))));
252
+ } else if (e.key === "Escape") setHover(null);
253
+ };
254
+ const area = (b) => {
255
+ const both = b.upper.flatMap(([x, u], i) => {
256
+ const v = b.lower[i][1];
257
+ return u == null || v == null ? [] : [[
258
+ x,
259
+ u,
260
+ v
261
+ ]];
262
+ });
263
+ if (both.length < 2) return "";
264
+ const up = both.map(([x, u]) => `${lx(x).toFixed(1)} ${ly(u).toFixed(1)}`);
265
+ const down = both.map(([x, , v]) => `${lx(x).toFixed(1)} ${ly(v).toFixed(1)}`).reverse();
266
+ return `M${up.join("L")}L${down.join("L")}Z`;
267
+ };
268
+ const reaches = (r, x) => r && l0(r) ? multiples(r, model.searchMax) : multiple$1(x, model.searchMax);
269
+ const l0 = (r) => multiple$1(r.low, model.searchMax) !== multiple$1(r.high, model.searchMax);
270
+ const summary = h.lines.map((l) => `${lineName(l)} reaches the ${pct$1(model.thresholds.target)} target at ${reaches(l.targetRange, l.target)} and ${pct$1(model.thresholds.full)} at ${reaches(l.fullRange, l.full)}`).join("; ");
271
+ const loadX = lx(Math.min(h.to, Math.max(h.from, values.load)));
272
+ const hx = hover == null ? null : xs[hover];
273
+ return /* @__PURE__ */ jsxs("div", {
274
+ className: "lm-card lm-headroom",
275
+ children: [
276
+ /* @__PURE__ */ jsx("h3", { children: title }),
277
+ h.lines.length > 1 && /* @__PURE__ */ jsxs("div", {
278
+ className: "lm-legend",
279
+ children: [h.lines.map((l) => /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("b", {
280
+ className: "lm-key",
281
+ style: { background: scenarioColor(model, l.label) }
282
+ }), lineName(l)] }, lineName(l))), selected && /* @__PURE__ */ jsxs("span", { children: ["thin lines: each component of ", lineName(selected)] })]
283
+ }),
284
+ /* @__PURE__ */ jsxs("div", {
285
+ className: "lm-chart",
286
+ ref: wrap,
287
+ role: "slider",
288
+ "aria-label": `Utilization over the load multiple. ${summary}.`,
289
+ "aria-valuemin": h.from,
290
+ "aria-valuemax": h.to,
291
+ "aria-valuenow": hx ?? values.load,
292
+ "aria-valuetext": hx == null ? `${fmt$1(values.load)}×` : `${fmt$1(hx)}×: ${h.lines.map((l) => `${lineName(l)} ${l.envelope[hover][1] == null ? "off" : pct$1(l.envelope[hover][1])}`).join(", ")}`,
293
+ tabIndex: 0,
294
+ onKeyDown: onKey,
295
+ onBlur: () => setHover(null),
296
+ children: [/* @__PURE__ */ jsxs("svg", {
297
+ width: W,
298
+ height: H,
299
+ viewBox: `0 0 ${W} ${H}`,
300
+ "aria-hidden": "true",
301
+ children: [
302
+ /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("clipPath", {
303
+ id: clip,
304
+ children: /* @__PURE__ */ jsx("rect", {
305
+ x: m.l,
306
+ y: m.t - 4,
307
+ width: pw,
308
+ height: ph + 4
309
+ })
310
+ }) }),
311
+ [
312
+ 0,
313
+ model.thresholds.target,
314
+ model.thresholds.full
315
+ ].map((u) => /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("line", {
316
+ x1: m.l,
317
+ x2: W - m.r,
318
+ y1: ly(u),
319
+ y2: ly(u),
320
+ stroke: "var(--lm-line)"
321
+ }), /* @__PURE__ */ jsx("text", {
322
+ x: m.l - 6,
323
+ y: ly(u) + 4,
324
+ textAnchor: "end",
325
+ className: "lm-axis",
326
+ children: pct$1(u)
327
+ })] }, u)),
328
+ /* @__PURE__ */ jsx("text", {
329
+ x: W - m.r,
330
+ y: ly(model.thresholds.target) - 5,
331
+ textAnchor: "end",
332
+ className: "lm-axis",
333
+ children: "target"
334
+ }),
335
+ tickList.map((x) => /* @__PURE__ */ jsxs("g", {
336
+ className: "lm-axis",
337
+ children: [/* @__PURE__ */ jsx("line", {
338
+ x1: lx(x),
339
+ x2: lx(x),
340
+ y1: m.t + ph,
341
+ y2: m.t + ph + 4,
342
+ stroke: "var(--lm-line)"
343
+ }), labelled(x) && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("text", {
344
+ x: lx(x),
345
+ y: m.t + ph + 16,
346
+ textAnchor: "middle",
347
+ children: [fmt$1(x), "×"]
348
+ }), dated && x > 1 && /* @__PURE__ */ jsx("text", {
349
+ x: lx(x),
350
+ y: m.t + ph + 30,
351
+ textAnchor: "middle",
352
+ children: when(model, x)
353
+ })] })]
354
+ }, x)),
355
+ /* @__PURE__ */ jsxs("g", {
356
+ clipPath: `url(#${clip})`,
357
+ children: [
358
+ [...h.lines].sort((a, b) => Number(a.selected) - Number(b.selected)).map((l) => l.band ? /* @__PURE__ */ jsx("path", {
359
+ "data-band": lineName(l),
360
+ d: area(l.band),
361
+ fill: scenarioColor(model, l.label),
362
+ fillOpacity: l.selected ? .2 : .08
363
+ }, lineName(l)) : null),
364
+ selected?.components.map((c) => /* @__PURE__ */ jsx("path", {
365
+ d: path(c.points),
366
+ fill: "none",
367
+ stroke: "var(--lm-muted)",
368
+ strokeOpacity: ".55",
369
+ strokeWidth: "1"
370
+ }, c.id)),
371
+ [...h.lines].sort((a, b) => Number(a.selected) - Number(b.selected)).map((l) => /* @__PURE__ */ jsx("path", {
372
+ "data-line": lineName(l),
373
+ d: path(l.envelope),
374
+ fill: "none",
375
+ stroke: scenarioColor(model, l.label),
376
+ strokeWidth: "2",
377
+ strokeLinejoin: "round",
378
+ strokeLinecap: "round"
379
+ }, lineName(l)))
380
+ ]
381
+ }),
382
+ /* @__PURE__ */ jsx("line", {
383
+ x1: loadX,
384
+ x2: loadX,
385
+ y1: m.t,
386
+ y2: m.t + ph,
387
+ stroke: "var(--lm-fg)",
388
+ strokeOpacity: ".45"
389
+ }),
390
+ /* @__PURE__ */ jsxs("text", {
391
+ x: loadX + 4,
392
+ y: m.t + 10,
393
+ className: "lm-axis",
394
+ children: [
395
+ "shown: ",
396
+ fmt$1(values.load),
397
+ "×"
398
+ ]
399
+ }),
400
+ h.lines.flatMap((l) => [[
401
+ "target",
402
+ l.targetRange,
403
+ model.thresholds.target
404
+ ], [
405
+ "full",
406
+ l.fullRange,
407
+ model.thresholds.full
408
+ ]].map(([kind, r, u]) => {
409
+ if (!l.band || !r || !Number.isFinite(r.low) || r.low > h.to) return null;
410
+ const [x1, x2] = [lx(Math.max(h.from, r.low)), lx(Math.min(h.to, r.high))];
411
+ const y = ly(u);
412
+ return /* @__PURE__ */ jsxs("g", {
413
+ "data-range": kind,
414
+ "data-scenario": lineName(l),
415
+ "data-low": r.low,
416
+ "data-high": r.high,
417
+ stroke: scenarioColor(model, l.label),
418
+ strokeWidth: l.selected ? 3 : 2,
419
+ strokeOpacity: l.selected ? 1 : .6,
420
+ children: [
421
+ /* @__PURE__ */ jsx("line", {
422
+ x1,
423
+ x2,
424
+ y1: y,
425
+ y2: y
426
+ }),
427
+ /* @__PURE__ */ jsx("line", {
428
+ x1,
429
+ x2: x1,
430
+ y1: y - 5,
431
+ y2: y + 5
432
+ }),
433
+ Number.isFinite(r.high) && r.high <= h.to && /* @__PURE__ */ jsx("line", {
434
+ x1: x2,
435
+ x2,
436
+ y1: y - 5,
437
+ y2: y + 5
438
+ })
439
+ ]
440
+ }, `${lineName(l)}/${kind}/range`);
441
+ })),
442
+ h.lines.flatMap((l) => [[
443
+ "target",
444
+ l.target,
445
+ l.targetRange,
446
+ model.thresholds.target
447
+ ], [
448
+ "full",
449
+ l.full,
450
+ l.fullRange,
451
+ model.thresholds.full
452
+ ]].map(([kind, x, r, u]) => x != null && Number.isFinite(x) && x >= h.from && x <= h.to ? /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("circle", {
453
+ "data-crossing": kind,
454
+ "data-scenario": lineName(l),
455
+ "data-multiple": x,
456
+ cx: lx(x),
457
+ cy: ly(u),
458
+ r: "4",
459
+ fill: scenarioColor(model, l.label),
460
+ stroke: "var(--lm-surface)",
461
+ strokeWidth: "2"
462
+ }), l.selected && /* @__PURE__ */ jsx("text", {
463
+ x: lx(r && l0(r) ? Math.max(h.from, r.low) : x) - 6,
464
+ y: ly(u) - 8,
465
+ textAnchor: "end",
466
+ className: "lm-mark",
467
+ children: r && l0(r) ? multiples(r, model.searchMax, true) : multiple$1(x, model.searchMax)
468
+ })] }, `${lineName(l)}/${kind}`) : null)),
469
+ hx != null && /* @__PURE__ */ jsx("line", {
470
+ x1: lx(hx),
471
+ x2: lx(hx),
472
+ y1: m.t,
473
+ y2: m.t + ph,
474
+ stroke: "var(--lm-fg)",
475
+ strokeOpacity: ".8"
476
+ }),
477
+ /* @__PURE__ */ jsx("rect", {
478
+ x: m.l,
479
+ y: m.t,
480
+ width: pw,
481
+ height: ph,
482
+ fill: "transparent",
483
+ onPointerMove: onMove,
484
+ onPointerDown: onMove,
485
+ onPointerLeave: () => setHover(null)
486
+ })
487
+ ]
488
+ }), hover != null && hx != null && /* @__PURE__ */ jsxs("div", {
489
+ className: "lm-tip",
490
+ role: "status",
491
+ style: {
492
+ left: Math.min(lx(hx) + 10, W - 200),
493
+ top: m.t
494
+ },
495
+ children: [
496
+ /* @__PURE__ */ jsxs("b", { children: [
497
+ fmt$1(hx),
498
+ "×",
499
+ when(model, hx) && ` · ${when(model, hx)}`
500
+ ] }),
501
+ h.lines.map((l) => /* @__PURE__ */ jsxs("span", { children: [
502
+ /* @__PURE__ */ jsx("b", {
503
+ className: "lm-key",
504
+ style: { background: scenarioColor(model, l.label) }
505
+ }),
506
+ /* @__PURE__ */ jsx("strong", { children: l.envelope[hover][1] == null ? "off" : pct$1(l.envelope[hover][1]) }),
507
+ " ",
508
+ lineName(l)
509
+ ] }, lineName(l))),
510
+ selected?.components.map((c) => /* @__PURE__ */ jsxs("span", {
511
+ className: "lm-sub",
512
+ children: [
513
+ /* @__PURE__ */ jsx("strong", { children: c.points[hover][1] == null ? "off" : pct$1(c.points[hover][1]) }),
514
+ " ",
515
+ c.name
516
+ ]
517
+ }, c.id))
518
+ ]
519
+ })]
520
+ })
521
+ ]
522
+ });
523
+ }
524
+ // src/Diagram.tsx
525
+ const STATE_STROKE = {
526
+ off: "var(--lm-line)",
527
+ ok: "var(--lm-ok)",
528
+ warn: "var(--lm-warn)",
529
+ over: "var(--lm-drop)"
530
+ };
531
+ const tokenColor = (c) => [
532
+ "accent",
533
+ "alt",
534
+ "ok",
535
+ "warn",
536
+ "drop",
537
+ "muted"
538
+ ].includes(c) ? `var(--lm-${c})` : c;
539
+ /** The architecture: SVG for the structure, a canvas on top for the dots. Expands to full screen. */
540
+ function Diagram({ lm, legend = true }) {
541
+ const { scene, engine, reducedMotion } = lm;
542
+ const svgRef = useRef(null);
543
+ const canvasRef = useRef(null);
544
+ const wrapRef = useRef(null);
545
+ const boardRef = useRef(null);
546
+ const [full, setFull] = useState(false);
547
+ const [tip, setTip] = useState(null);
548
+ const detailId = useId();
549
+ useEffect(() => {
550
+ if (scene) engine.setScene(scene);
551
+ engine.bind(svgRef.current, canvasRef.current);
552
+ if (lm.root) engine.readColors(lm.root);
553
+ }, [
554
+ engine,
555
+ scene,
556
+ reducedMotion,
557
+ lm.root
558
+ ]);
559
+ useEffect(() => {
560
+ engine.drawnValues = JSON.stringify(lm.values);
561
+ engine.drawnFocus = lm.focus;
562
+ });
563
+ useEffect(() => {
564
+ const board = boardRef.current;
565
+ if (!board) return;
566
+ const ro = new ResizeObserver(() => engine.resize());
567
+ ro.observe(board);
568
+ return () => ro.disconnect();
569
+ }, [engine]);
570
+ useEffect(() => {
571
+ let raf = 0;
572
+ let last = 0;
573
+ const frame = (ts) => {
574
+ const dt = last ? Math.min(.05, (ts - last) / 1e3) : 0;
575
+ last = ts;
576
+ if (!engine.paused && engine.scene) {
577
+ engine.step(dt, { particles: !reducedMotion });
578
+ engine.draw({ particles: !reducedMotion });
579
+ }
580
+ raf = requestAnimationFrame(frame);
581
+ };
582
+ raf = requestAnimationFrame(frame);
583
+ return () => cancelAnimationFrame(raf);
584
+ }, [engine, reducedMotion]);
585
+ useEffect(() => {
586
+ const f = () => setFull(document.fullscreenElement === wrapRef.current);
587
+ document.addEventListener("fullscreenchange", f);
588
+ return () => document.removeEventListener("fullscreenchange", f);
589
+ }, []);
590
+ const toggle = () => {
591
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
592
+ else wrapRef.current?.requestFullscreen?.().catch(() => {});
593
+ };
594
+ return /* @__PURE__ */ jsxs("div", {
595
+ className: "lm-wrap",
596
+ ref: wrapRef,
597
+ children: [
598
+ /* @__PURE__ */ jsxs("div", {
599
+ className: "lm-bar",
600
+ children: [legend ? /* @__PURE__ */ jsx(Legend, { lm }) : /* @__PURE__ */ jsx("span", {}), /* @__PURE__ */ jsx("button", {
601
+ type: "button",
602
+ className: "lm-btn",
603
+ onClick: toggle,
604
+ "aria-pressed": full,
605
+ children: full ? "✕ Close" : "⤢ Full screen"
606
+ })]
607
+ }),
608
+ /* @__PURE__ */ jsxs("div", {
609
+ className: "lm-board",
610
+ ref: boardRef,
611
+ style: scene ? { aspectRatio: `${scene.width} / ${scene.height}` } : { minHeight: 240 },
612
+ children: [scene && /* @__PURE__ */ jsx(Structure, {
613
+ lm,
614
+ scene,
615
+ svgRef,
616
+ rates: reducedMotion,
617
+ setTip
618
+ }), /* @__PURE__ */ jsx("canvas", {
619
+ ref: canvasRef,
620
+ hidden: reducedMotion
621
+ })]
622
+ }),
623
+ scene && tip && /* @__PURE__ */ jsx(Detail, {
624
+ lm,
625
+ scene,
626
+ id: tip.id,
627
+ board: boardRef.current,
628
+ detailId
629
+ })
630
+ ]
631
+ });
632
+ }
633
+ function Legend({ lm }) {
634
+ const flows = lm.view.def.flows;
635
+ const grey = Object.values(lm.ev.overload).some((o) => o.merge) || Object.values(lm.ev.stages).some((s) => s.kind === "fanout");
636
+ return /* @__PURE__ */ jsxs("div", {
637
+ className: "lm-legend",
638
+ children: [
639
+ Object.entries(flows).map(([f, v]) => /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { style: { background: tokenColor(v.color) } }), v.label ?? f] }, f)),
640
+ /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { style: { background: "var(--lm-ok)" } }), "done"] }),
641
+ /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { style: { background: "var(--lm-drop)" } }), "dropped or rejected"] }),
642
+ grey && /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { style: { background: "var(--lm-muted)" } }), "superseded or unread"] }),
643
+ /* @__PURE__ */ jsx("span", { children: lm.reducedMotion ? "line width: rate" : `one dot ≈ ${lm.engine.sim.dotScale.toLocaleString("en-US")} a second · ${lm.engine.sim.speed}× speed` })
644
+ ]
645
+ });
646
+ }
647
+ /** The component a box stands for: its stages' component, or its own. */
648
+ function componentOf(lm, n) {
649
+ return n.stages.map((s) => lm.ev.stages[s]?.component).find(Boolean) ?? n.component ?? void 0;
650
+ }
651
+ /** The SVG. It does not read the detail's state, so a hover re-renders the detail, not every box and edge. */
652
+ const Structure = memo(function Structure({ lm, scene, svgRef, rates, setTip }) {
653
+ const marker = useId().replace(/:/g, "");
654
+ const { labels, ev } = lm;
655
+ const stateOf = (n) => {
656
+ const c = componentOf(lm, n);
657
+ return c ? ev.components[c]?.state ?? null : null;
658
+ };
659
+ const handlers = (n) => {
660
+ const cid = componentOf(lm, n);
661
+ const c = cid ? ev.components[cid] : void 0;
662
+ if (!c) return {};
663
+ const title = labels.nodes[n.id]?.title;
664
+ return {
665
+ tabIndex: 0,
666
+ role: "button",
667
+ className: "lm-node",
668
+ "aria-label": `${title && title !== c.name ? `${title}, ` : ""}${c.name}: ${c.util == null ? "switched off" : pct$1(c.util)}`,
669
+ onPointerEnter: (e) => e.pointerType === "mouse" && setTip((t) => t?.pinned ? t : {
670
+ id: n.id,
671
+ pinned: false
672
+ }),
673
+ onPointerLeave: (e) => e.pointerType === "mouse" && setTip((t) => t?.pinned ? t : null),
674
+ onFocus: () => setTip((t) => t?.pinned ? t : {
675
+ id: n.id,
676
+ pinned: false
677
+ }),
678
+ onBlur: () => setTip((t) => t?.pinned ? t : null),
679
+ onClick: () => setTip((t) => t?.pinned && t.id === n.id ? null : {
680
+ id: n.id,
681
+ pinned: true
682
+ }),
683
+ onKeyDown: (e) => {
684
+ if (e.key === "Escape") setTip(null);
685
+ if (e.key === "Enter" || e.key === " ") {
686
+ e.preventDefault();
687
+ setTip((t) => t?.pinned && t.id === n.id ? null : {
688
+ id: n.id,
689
+ pinned: true
690
+ });
691
+ }
692
+ }
693
+ };
694
+ };
695
+ const summary = scene.nodes.map((n) => labels.nodes[n.id]?.title).join(", ");
696
+ return /* @__PURE__ */ jsxs("svg", {
697
+ ref: svgRef,
698
+ viewBox: `0 0 ${scene.width} ${scene.height}`,
699
+ role: "group",
700
+ "aria-label": `Diagram: ${summary}`,
701
+ children: [
702
+ /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("marker", {
703
+ id: marker,
704
+ viewBox: "0 0 10 10",
705
+ refX: "9",
706
+ refY: "5",
707
+ markerWidth: "7",
708
+ markerHeight: "7",
709
+ orient: "auto-start-reverse",
710
+ children: /* @__PURE__ */ jsx("path", {
711
+ d: "M0 0 L10 5 L0 10 z",
712
+ fill: "var(--lm-muted)"
713
+ })
714
+ }) }),
715
+ rates && /* @__PURE__ */ jsx(RateStrokes, {
716
+ lm,
717
+ scene
718
+ }),
719
+ scene.edges.map((e) => /* @__PURE__ */ jsx(Edge, {
720
+ e,
721
+ text: labels.edges[e.id],
722
+ marker
723
+ }, e.id)),
724
+ scene.nodes.map((n) => /* @__PURE__ */ jsx(Node, {
725
+ n,
726
+ state: stateOf(n),
727
+ title: labels.nodes[n.id]?.title,
728
+ sub: labels.nodes[n.id]?.sub,
729
+ lm,
730
+ focused: !!lm.focus && componentOf(lm, n) === lm.focus,
731
+ handlers: handlers(n)
732
+ }, n.id))
733
+ ]
734
+ });
735
+ });
736
+ const pathOf = (pts) => pts.map(([x, y], i) => `${i ? "L" : "M"}${x} ${y}`).join(" ");
737
+ function Edge({ e, text, marker }) {
738
+ return /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("path", {
739
+ d: pathOf(e.points),
740
+ fill: "none",
741
+ stroke: "var(--lm-muted)",
742
+ strokeOpacity: ".7",
743
+ markerEnd: `url(#${marker})`
744
+ }), e.label && text && /* @__PURE__ */ jsx("text", {
745
+ x: e.label.x + e.label.w / 2,
746
+ y: e.label.y + e.label.h / 2 + 4,
747
+ textAnchor: "middle",
748
+ fontSize: "11",
749
+ fill: "var(--lm-muted)",
750
+ children: text
751
+ })] });
752
+ }
753
+ /** With reduced motion, each flow is a static stroke along its edges, its width scaled by its rate. */
754
+ function RateStrokes({ lm, scene }) {
755
+ const { ev } = lm;
756
+ const flows = lm.view.def.flows;
757
+ const max = Math.max(1e-9, ...Object.values(ev.flows));
758
+ const out = [];
759
+ for (const f of Object.keys(flows)) {
760
+ const seq = [scene.flowFrom[f], ...(ev.routes[f] ?? []).map((s) => scene.stageNode[s])].filter((n, i, a) => n && n !== a[i - 1]);
761
+ const rate = ev.flows[f] ?? 0;
762
+ const legs = seq.slice(1).map((to, i) => [
763
+ seq[i],
764
+ to,
765
+ rate
766
+ ]);
767
+ const last = (ev.routes[f] ?? []).at(-1);
768
+ const st = last ? ev.stages[last] : void 0;
769
+ if (st?.kind === "fanout") for (const b of st.to[f] ?? []) legs.push([
770
+ scene.stageNode[last],
771
+ scene.stageNode[b.stage],
772
+ rate * b.share
773
+ ]);
774
+ for (const [from, to, r] of legs) {
775
+ const e = scene.edges.find((x) => x.from === from && x.to === to);
776
+ if (e) out.push(/* @__PURE__ */ jsx("path", {
777
+ d: pathOf(e.points),
778
+ fill: "none",
779
+ stroke: tokenColor(flows[f].color),
780
+ strokeOpacity: ".45",
781
+ strokeWidth: 1.5 + 6 * Math.min(r, max) / max
782
+ }, `${f}:${e.id}`));
783
+ }
784
+ }
785
+ return /* @__PURE__ */ jsx("g", { children: out });
786
+ }
787
+ function Txt({ s, children, anchor = "middle", fill = "currentColor" }) {
788
+ return /* @__PURE__ */ jsx("text", {
789
+ x: s.x,
790
+ y: s.y,
791
+ fontSize: s.size,
792
+ fontWeight: s.weight,
793
+ textAnchor: anchor,
794
+ fill,
795
+ children
796
+ });
797
+ }
798
+ function Node({ n, state, title, sub, lm, focused, handlers }) {
799
+ const stroke = state ? STATE_STROKE[state] : "var(--lm-line)";
800
+ const flows = lm.view.def.flows;
801
+ const e = 6;
802
+ return /* @__PURE__ */ jsxs("g", {
803
+ style: { color: "var(--lm-fg)" },
804
+ "data-node": n.id,
805
+ "data-focus": focused || void 0,
806
+ ...handlers,
807
+ children: [
808
+ n.shape === "cylinder" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("path", {
809
+ d: `M${n.x} ${n.y + e} A${n.w / 2} ${e} 0 0 1 ${n.x + n.w} ${n.y + e} V${n.y + n.h - e} A${n.w / 2} ${e} 0 0 1 ${n.x} ${n.y + n.h - e} Z`,
810
+ fill: "var(--lm-surface)",
811
+ stroke,
812
+ strokeWidth: "1.5"
813
+ }), /* @__PURE__ */ jsx("path", {
814
+ d: `M${n.x} ${n.y + e} A${n.w / 2} ${e} 0 0 0 ${n.x + n.w} ${n.y + e}`,
815
+ fill: "none",
816
+ stroke,
817
+ strokeWidth: "1.5"
818
+ })] }) : /* @__PURE__ */ jsx("rect", {
819
+ x: n.x,
820
+ y: n.y,
821
+ width: n.w,
822
+ height: n.h,
823
+ rx: "6",
824
+ fill: "var(--lm-surface)",
825
+ stroke,
826
+ strokeWidth: "1.5"
827
+ }),
828
+ /* @__PURE__ */ jsx(Txt, {
829
+ s: n.title,
830
+ children: title
831
+ }),
832
+ n.sub && sub && /* @__PURE__ */ jsx(Txt, {
833
+ s: n.sub,
834
+ fill: "var(--lm-muted)",
835
+ children: sub
836
+ }),
837
+ n.members.map((m) => /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("rect", {
838
+ x: m.x,
839
+ y: m.y,
840
+ width: m.w,
841
+ height: m.h,
842
+ rx: "4",
843
+ fill: "none",
844
+ stroke: "var(--lm-line)"
845
+ }), /* @__PURE__ */ jsx(Txt, {
846
+ s: m.spot,
847
+ anchor: "start",
848
+ fill: "var(--lm-muted)",
849
+ children: m.label
850
+ })] }, m.label)),
851
+ n.groups.map((g) => /* @__PURE__ */ jsx(Txt, {
852
+ s: g.spot,
853
+ anchor: "start",
854
+ fill: tokenColor(flows[g.flow]?.color ?? "muted"),
855
+ children: g.text
856
+ }, `${g.flow}@${g.spot.x},${g.spot.y}`)),
857
+ n.cells.map((c) => /* @__PURE__ */ jsx("rect", {
858
+ x: c.x,
859
+ y: c.y,
860
+ width: c.w,
861
+ height: c.h,
862
+ rx: "1.5",
863
+ fill: "var(--lm-lane)",
864
+ stroke: tokenColor(flows[c.flow]?.color ?? "muted"),
865
+ strokeOpacity: ".6"
866
+ }, `${c.stage}/${c.flow}/${c.index}`)),
867
+ n.slots.map((s) => /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("rect", {
868
+ x: s.x,
869
+ y: s.y,
870
+ width: s.w,
871
+ height: s.h,
872
+ rx: "2",
873
+ fill: "var(--lm-surface)",
874
+ stroke: "currentColor",
875
+ strokeOpacity: ".45"
876
+ }), /* @__PURE__ */ jsx("rect", {
877
+ "data-slot": s.index,
878
+ "data-stage": n.stages[0],
879
+ "data-y": s.y,
880
+ "data-s": s.w,
881
+ x: s.x + 1,
882
+ y: s.y + s.h - 1,
883
+ width: s.w - 2,
884
+ height: 0,
885
+ rx: "1",
886
+ fill: "var(--lm-accent)"
887
+ })] }, s.index)),
888
+ focused && /* @__PURE__ */ jsx("rect", {
889
+ className: "lm-focus",
890
+ x: n.x - 5,
891
+ y: n.y - 5,
892
+ width: n.w + 10,
893
+ height: n.h + 10,
894
+ rx: "9",
895
+ fill: "none",
896
+ stroke: "var(--lm-accent)",
897
+ strokeWidth: "2",
898
+ strokeDasharray: "5 4"
899
+ })
900
+ ]
901
+ });
902
+ }
903
+ /**
904
+ * A box's component in words, over the board: utilization, demand against capacity and where it came from, where it
905
+ * reaches the target and 100 % (with dates, given growth), and its target in each scenario. HTML, so exports leave it out.
906
+ */
907
+ function Detail({ lm, scene, id, board, detailId }) {
908
+ const { model, ev } = lm;
909
+ const n = scene.nodes.find((x) => x.id === id);
910
+ const cid = n && componentOf(lm, n);
911
+ const comparison = useComparison(lm);
912
+ const rows = model.scenarios.length > 1 ? comparison.rows : [];
913
+ useEffect(() => {
914
+ const el = board?.querySelector(`[data-node="${CSS.escape(id)}"]`);
915
+ el?.setAttribute("aria-describedby", detailId);
916
+ return () => el?.removeAttribute("aria-describedby");
917
+ }, [
918
+ board,
919
+ id,
920
+ detailId
921
+ ]);
922
+ const c = cid ? ev.components[cid] : void 0;
923
+ if (!n || !c || !board) return null;
924
+ const k = board.clientWidth / scene.width;
925
+ const below = n.y + n.h / 2 < scene.height / 2;
926
+ const place = {
927
+ top: board.offsetTop + (below ? (n.y + n.h) * k + 6 : n.y * k - 6),
928
+ transform: below ? void 0 : "translateY(-100%)"
929
+ };
930
+ const at = (r, x) => r ? reach(r, model.searchMax, model.dateAt) : multiple$1(x, model.searchMax);
931
+ const num = (x) => Math.abs(x) >= 1e3 ? compact$1(x) : fmt$1(x);
932
+ const cap = c.capacity;
933
+ return /* @__PURE__ */ jsxs("div", {
934
+ id: detailId,
935
+ className: "lm-tip lm-detail",
936
+ role: "tooltip",
937
+ style: {
938
+ ...place,
939
+ left: `clamp(0px, ${board.offsetLeft + n.x * k}px, calc(100% - 17rem))`
940
+ },
941
+ children: [
942
+ /* @__PURE__ */ jsxs("b", { children: [c.name, c === ev.firstLimit && /* @__PURE__ */ jsx("span", {
943
+ className: "lm-tag",
944
+ "data-kind": "first",
945
+ children: "first limit"
946
+ })] }),
947
+ c.util == null || !cap ? /* @__PURE__ */ jsx("span", { children: "switched off" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
948
+ /* @__PURE__ */ jsxs("span", { children: [
949
+ /* @__PURE__ */ jsx("strong", { children: pct$1(c.util) }),
950
+ " · ",
951
+ num(c.demand ?? 0),
952
+ " of ",
953
+ fmt$1(cap.value),
954
+ " ",
955
+ cap.unit,
956
+ cap.within ? ` ±${pct$1(cap.within)}` : ""
957
+ ] }),
958
+ /* @__PURE__ */ jsxs("span", {
959
+ className: "lm-sub",
960
+ children: [
961
+ "capacity",
962
+ " ",
963
+ /* @__PURE__ */ jsx("span", {
964
+ className: "lm-prov",
965
+ "data-kind": cap.provenance,
966
+ children: cap.provenance
967
+ }),
968
+ cap.source ? `, ${cap.source}` : ""
969
+ ]
970
+ }),
971
+ /* @__PURE__ */ jsxs("span", { children: [
972
+ pct$1(model.thresholds.target),
973
+ " at ",
974
+ at(c.targetRange, c.target)
975
+ ] }),
976
+ /* @__PURE__ */ jsxs("span", { children: [
977
+ pct$1(model.thresholds.full),
978
+ " at ",
979
+ at(c.fullRange, c.full)
980
+ ] }),
981
+ c.over && /* @__PURE__ */ jsxs("span", {
982
+ className: "lm-sub",
983
+ children: [
984
+ "Past ",
985
+ pct$1(model.thresholds.full),
986
+ ": ",
987
+ c.over,
988
+ "."
989
+ ]
990
+ })
991
+ ] }),
992
+ rows.length > 0 && /* @__PURE__ */ jsxs("span", {
993
+ className: "lm-sub",
994
+ children: [
995
+ pct$1(model.thresholds.target),
996
+ " by scenario:",
997
+ " ",
998
+ rows.map((r, i) => {
999
+ const x = r.components.find((y) => y.id === cid);
1000
+ return /* @__PURE__ */ jsxs("span", {
1001
+ className: "lm-nowrap",
1002
+ children: [
1003
+ i > 0 && " · ",
1004
+ /* @__PURE__ */ jsx("b", {
1005
+ className: "lm-key",
1006
+ style: { background: scenarioColor(model, r.label) }
1007
+ }),
1008
+ r.label,
1009
+ " ",
1010
+ x?.targetRange == null ? "off" : multiples(x.targetRange, model.searchMax, true)
1011
+ ]
1012
+ }, r.label);
1013
+ })
1014
+ ]
1015
+ })
1016
+ ]
1017
+ });
1018
+ }
1019
+ // src/panels.tsx
1020
+ /** The model's one-sentence answer for the current values, and which capacity to measure when it is unsure. */
1021
+ function Verdict({ lm }) {
1022
+ const { measure } = lm.ev;
1023
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("p", {
1024
+ className: "lm-verdict",
1025
+ "data-tier": lm.ev.verdict.tier,
1026
+ "aria-live": "polite",
1027
+ children: lm.ev.verdict.text
1028
+ }), measure && /* @__PURE__ */ jsx("p", {
1029
+ className: "lm-measure",
1030
+ "data-component": measure.component,
1031
+ children: measure.text
1032
+ })] });
1033
+ }
1034
+ /** "±30 %" for a capacity with a range. */
1035
+ const plusMinus = (c) => c?.within ? `±${pct$1(c.within)}` : "";
1036
+ /** Each component's utilization in the order they reach the target, with where they cross it and 100 %. */
1037
+ function Limits({ lm, title = "Where it runs out, in order" }) {
1038
+ const { ev, model } = lm;
1039
+ const top = Math.max(1.5, model.thresholds.full * 1.5);
1040
+ const at = (u) => `${u / top * 100}%`;
1041
+ const span = (r, x) => r ? multiples(r, model.searchMax, true) : multiple$1(x, model.searchMax);
1042
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1043
+ /* @__PURE__ */ jsx("h3", { children: title }),
1044
+ /* @__PURE__ */ jsxs("div", {
1045
+ className: "lm-scale",
1046
+ "aria-hidden": "true",
1047
+ children: [
1048
+ /* @__PURE__ */ jsx("span", {}),
1049
+ /* @__PURE__ */ jsxs("div", { children: [
1050
+ /* @__PURE__ */ jsx("span", {
1051
+ style: {
1052
+ left: 0,
1053
+ transform: "none"
1054
+ },
1055
+ children: "0"
1056
+ }),
1057
+ /* @__PURE__ */ jsxs("span", {
1058
+ style: { left: at(model.thresholds.target) },
1059
+ children: [pct$1(model.thresholds.target), " target"]
1060
+ }),
1061
+ /* @__PURE__ */ jsx("span", {
1062
+ style: { left: at(model.thresholds.full) },
1063
+ children: pct$1(model.thresholds.full)
1064
+ }),
1065
+ /* @__PURE__ */ jsx("span", {
1066
+ style: {
1067
+ left: "100%",
1068
+ transform: "translateX(-100%)"
1069
+ },
1070
+ children: pct$1(top)
1071
+ })
1072
+ ] }),
1073
+ /* @__PURE__ */ jsx("span", {})
1074
+ ]
1075
+ }),
1076
+ /* @__PURE__ */ jsx("div", {
1077
+ className: "lm-limits",
1078
+ children: ev.ranked.map((c) => /* @__PURE__ */ jsxs("div", {
1079
+ className: "lm-lim",
1080
+ "data-component": c.id,
1081
+ children: [
1082
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("b", { children: [
1083
+ c.name,
1084
+ c === ev.firstLimit && /* @__PURE__ */ jsx("span", {
1085
+ className: "lm-tag",
1086
+ "data-kind": "first",
1087
+ children: "first limit"
1088
+ }),
1089
+ c.capacity?.provenance === "estimated" ? /* @__PURE__ */ jsxs("span", {
1090
+ className: "lm-tag",
1091
+ "data-kind": "estimated",
1092
+ children: ["estimate", c.capacity.within ? ` ${plusMinus(c.capacity)}` : ""]
1093
+ }) : !!c.capacity?.within && /* @__PURE__ */ jsx("span", {
1094
+ className: "lm-tag",
1095
+ "data-kind": "range",
1096
+ children: plusMinus(c.capacity)
1097
+ })
1098
+ ] }), /* @__PURE__ */ jsx("span", {
1099
+ className: "lm-sub",
1100
+ children: c.describe ?? (c.capacity ? `${fmt$1(c.capacity.value)} ${c.capacity.unit}` : "switched off")
1101
+ })] }),
1102
+ /* @__PURE__ */ jsxs("div", {
1103
+ className: "lm-track",
1104
+ role: "img",
1105
+ "aria-label": `${c.name} at ${c.util == null ? "off" : pct$1(c.util)}`,
1106
+ children: [
1107
+ /* @__PURE__ */ jsx("div", {
1108
+ className: "lm-fill",
1109
+ "data-state": c.state,
1110
+ style: { width: `${Math.min(100, (c.util ?? 0) / top * 100)}%` }
1111
+ }),
1112
+ /* @__PURE__ */ jsx("div", {
1113
+ className: "lm-tick",
1114
+ style: { left: at(model.thresholds.target) }
1115
+ }),
1116
+ /* @__PURE__ */ jsx("div", {
1117
+ className: "lm-tick",
1118
+ style: { left: at(model.thresholds.full) }
1119
+ })
1120
+ ]
1121
+ }),
1122
+ /* @__PURE__ */ jsxs("div", {
1123
+ className: "lm-val",
1124
+ children: [c.util == null ? "—" : `${pct$1(c.util)} · ${num(c.demand ?? 0)} ${c.capacity?.unit ?? ""}`, /* @__PURE__ */ jsx("small", { children: c.util == null ? "switched off" : `${pct$1(model.thresholds.target)} at ${span(c.targetRange, c.target)} · full at ${span(c.fullRange, c.full)}` })]
1125
+ })
1126
+ ]
1127
+ }, c.id))
1128
+ })
1129
+ ] });
1130
+ }
1131
+ /** Live numbers from the sim: lag and drops per queue, busy workers, rejected requests. */
1132
+ function Meters({ lm }) {
1133
+ const [, tick] = useState(0);
1134
+ useEffect(() => {
1135
+ const t = setInterval(() => tick((x) => x + 1), 250);
1136
+ return () => clearInterval(t);
1137
+ }, []);
1138
+ const { ev, engine, model } = lm;
1139
+ const sim = engine.sim;
1140
+ const flowLabel = (f) => model.def.flows[f]?.label ?? f;
1141
+ const items = [];
1142
+ for (const s of Object.values(ev.stages)) {
1143
+ if (s.kind === "queue") for (const f of model.flowIds.filter((x) => ev.routes[x]?.includes(s.id))) {
1144
+ const q = sim.queue(s.id, f);
1145
+ const rules = ev.overload[f] ?? {};
1146
+ items.push({
1147
+ key: `${s.id}/${f}/lag`,
1148
+ value: secs$1(q.lag),
1149
+ label: `${flowLabel(f)} lag in ${s.label}`,
1150
+ state: q.lag > 60 ? "over" : q.lag > 5 ? "warn" : "ok"
1151
+ });
1152
+ if (rules.merge) items.push({
1153
+ key: `${s.id}/${f}/merge`,
1154
+ value: compact$1(q.merged),
1155
+ label: `${flowLabel(f)} superseded /s (${rules.merge.reason ?? "newest kept"})`,
1156
+ state: q.merged > .5 ? "warn" : "ok"
1157
+ });
1158
+ if (rules.drop) items.push({
1159
+ key: `${s.id}/${f}/drop`,
1160
+ value: compact$1(q.dropped),
1161
+ label: `${flowLabel(f)} dropped /s (${rules.drop.reason ?? "too old"})`,
1162
+ state: q.dropped > .5 ? "over" : "ok"
1163
+ });
1164
+ }
1165
+ if (s.kind === "workers") {
1166
+ const b = sim.busy(s.id);
1167
+ items.push({
1168
+ key: `${s.id}/busy`,
1169
+ value: pct$1(b),
1170
+ label: `${s.label} busy`,
1171
+ state: b > .95 ? "over" : b > .5 ? "warn" : "ok"
1172
+ });
1173
+ }
1174
+ if (s.kind === "pass" && sim.shed(s.id) > 0) items.push({
1175
+ key: `${s.id}/shed`,
1176
+ value: pct$1(sim.shed(s.id)),
1177
+ label: `rejected at ${s.label}`,
1178
+ state: "over"
1179
+ });
1180
+ }
1181
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("h3", { children: "Live" }), /* @__PURE__ */ jsx("div", {
1182
+ className: "lm-meters",
1183
+ children: items.map((m) => /* @__PURE__ */ jsxs("div", {
1184
+ className: "lm-meter",
1185
+ "data-state": m.state,
1186
+ children: [/* @__PURE__ */ jsx("b", { children: m.value }), /* @__PURE__ */ jsx("span", { children: m.label })]
1187
+ }, m.key))
1188
+ })] });
1189
+ }
1190
+ /** The load slider, the presets and every parameter, greyed out where it does not apply. */
1191
+ function Controls({ lm }) {
1192
+ const { model, values, setValues, ev } = lm;
1193
+ const v = values;
1194
+ const load = model.load;
1195
+ return /* @__PURE__ */ jsxs("div", {
1196
+ className: "lm-card lm-controls",
1197
+ children: [
1198
+ /* @__PURE__ */ jsx("h3", { children: "Load" }),
1199
+ /* @__PURE__ */ jsxs("label", {
1200
+ className: "lm-range",
1201
+ children: [/* @__PURE__ */ jsxs("span", { children: [
1202
+ load.label,
1203
+ " ",
1204
+ /* @__PURE__ */ jsxs("output", { children: [fmt$1(v.load), "×"] })
1205
+ ] }), /* @__PURE__ */ jsx("input", {
1206
+ type: "range",
1207
+ min: load.min,
1208
+ max: load.max,
1209
+ step: load.step,
1210
+ value: v.load,
1211
+ onChange: (e) => setValues({ load: Number(e.target.value) })
1212
+ })]
1213
+ }),
1214
+ /* @__PURE__ */ jsx("div", {
1215
+ className: "lm-presets",
1216
+ children: ev.presets.map((p) => {
1217
+ const on = Object.entries(p.values).every(([k, x]) => v[k] === x);
1218
+ return /* @__PURE__ */ jsx("button", {
1219
+ type: "button",
1220
+ "aria-pressed": on,
1221
+ onClick: () => setValues(p.values),
1222
+ children: p.label
1223
+ }, p.label);
1224
+ })
1225
+ }),
1226
+ model.params.length > 1 && /* @__PURE__ */ jsx("h3", { children: "Design" }),
1227
+ model.params.map(({ id, def }) => {
1228
+ if (def.kind === "load") return null;
1229
+ const enabled = model.isEnabled(id, v);
1230
+ const tag = def.tag && /* @__PURE__ */ jsx("span", {
1231
+ className: "lm-tag",
1232
+ "data-kind": "estimated",
1233
+ children: def.tag
1234
+ });
1235
+ if (def.kind === "switch") return /* @__PURE__ */ jsxs("label", {
1236
+ className: `lm-switch${enabled ? "" : " lm-off"}`,
1237
+ children: [
1238
+ /* @__PURE__ */ jsx("input", {
1239
+ type: "checkbox",
1240
+ checked: Boolean(v[id]),
1241
+ disabled: !enabled,
1242
+ onChange: (e) => setValues({ [id]: e.target.checked })
1243
+ }),
1244
+ /* @__PURE__ */ jsxs("span", { children: [def.label, tag] }),
1245
+ def.hint && /* @__PURE__ */ jsx("small", { children: def.hint })
1246
+ ]
1247
+ }, id);
1248
+ if (def.kind === "select") return /* @__PURE__ */ jsxs("label", {
1249
+ className: `lm-select${enabled ? "" : " lm-off"}`,
1250
+ children: [
1251
+ /* @__PURE__ */ jsxs("span", { children: [def.label, tag] }),
1252
+ /* @__PURE__ */ jsx("select", {
1253
+ value: String(v[id]),
1254
+ disabled: !enabled,
1255
+ onChange: (e) => {
1256
+ const opt = def.options.find((o) => String(o) === e.target.value);
1257
+ setValues({ [id]: opt });
1258
+ },
1259
+ children: def.options.map((o) => /* @__PURE__ */ jsx("option", {
1260
+ value: String(o),
1261
+ children: def.format ? def.format(o) : String(o)
1262
+ }, String(o)))
1263
+ }),
1264
+ def.hint && /* @__PURE__ */ jsx("small", { children: def.hint })
1265
+ ]
1266
+ }, id);
1267
+ return /* @__PURE__ */ jsxs("label", {
1268
+ className: `lm-range${enabled ? "" : " lm-off"}`,
1269
+ children: [
1270
+ /* @__PURE__ */ jsxs("span", { children: [
1271
+ def.label,
1272
+ tag,
1273
+ " ",
1274
+ /* @__PURE__ */ jsx("output", { children: def.format ? def.format(v[id]) : fmt$1(v[id]) })
1275
+ ] }),
1276
+ /* @__PURE__ */ jsx("input", {
1277
+ type: "range",
1278
+ min: def.min,
1279
+ max: def.max,
1280
+ step: def.step,
1281
+ value: v[id],
1282
+ disabled: !enabled,
1283
+ onChange: (e) => setValues({ [id]: Number(e.target.value) })
1284
+ }),
1285
+ def.hint && /* @__PURE__ */ jsx("small", { children: def.hint })
1286
+ ]
1287
+ }, id);
1288
+ })
1289
+ ]
1290
+ });
1291
+ }
1292
+ /** What the model assumes: every capacity with its provenance, every fit, and anything the page adds. */
1293
+ function Assumptions({ lm, extra = [] }) {
1294
+ return /* @__PURE__ */ jsxs("div", {
1295
+ className: "lm-card",
1296
+ children: [/* @__PURE__ */ jsx("h3", { children: "What this assumes" }), /* @__PURE__ */ jsxs("ul", {
1297
+ className: "lm-facts",
1298
+ children: [extra.map((x, i) => /* @__PURE__ */ jsx("li", { children: x }, i)), lm.ev.assumptions.map((a) => /* @__PURE__ */ jsx("li", { children: describe(a) }, a.kind === "capacity" ? `c:${a.component}` : `f:${a.name}`))]
1299
+ })]
1300
+ });
1301
+ }
1302
+ function describe(a) {
1303
+ if (a.kind === "capacity") {
1304
+ const c = a.capacity;
1305
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1306
+ /* @__PURE__ */ jsxs("b", { children: [a.name, ":"] }),
1307
+ " ",
1308
+ fmt$1(c.value),
1309
+ " ",
1310
+ c.unit,
1311
+ c.within ? ` ${plusMinus(c)}` : "",
1312
+ ",",
1313
+ " ",
1314
+ /* @__PURE__ */ jsx("span", {
1315
+ className: "lm-prov",
1316
+ "data-kind": c.provenance,
1317
+ children: c.provenance
1318
+ }),
1319
+ c.source ? ` (${c.source})` : "",
1320
+ "."
1321
+ ] });
1322
+ }
1323
+ const f = a.fit;
1324
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1325
+ /* @__PURE__ */ jsxs("b", { children: [a.name, ":"] }),
1326
+ " ",
1327
+ f.y,
1328
+ " = ",
1329
+ sig(f.a),
1330
+ " + ",
1331
+ sig(f.b),
1332
+ " × ",
1333
+ f.x,
1334
+ ", r² ",
1335
+ f.r2.toFixed(3),
1336
+ " over ",
1337
+ f.n,
1338
+ " points,",
1339
+ " ",
1340
+ /* @__PURE__ */ jsx("span", {
1341
+ className: "lm-prov",
1342
+ "data-kind": "measured",
1343
+ children: "measured"
1344
+ }),
1345
+ f.source ? ` (${f.source})` : "",
1346
+ "."
1347
+ ] });
1348
+ }
1349
+ /** Exact below 1,000 (3.9 cores), compact above (12k req/s). */
1350
+ const num = (x) => Math.abs(x) >= 1e3 ? compact$1(x) : fmt$1(x);
1351
+ const sig = (x) => Number(x.toPrecision(3)).toString();
1352
+ // src/story.tsx
1353
+ /**
1354
+ * The model's story, a step at a time: the step's text, where it is, back and next. ← and → move through it while
1355
+ * the bar has the keyboard focus (the sliders keep their own arrows).
1356
+ */
1357
+ function Story({ lm }) {
1358
+ const bar = useRef(null);
1359
+ const { story } = lm.model;
1360
+ if (!story.length) return null;
1361
+ const i = lm.step;
1362
+ const step = i == null ? null : story[i];
1363
+ const last = story.length - 1;
1364
+ const go = (to) => {
1365
+ const next = Math.max(0, Math.min(last, to));
1366
+ if (next !== i) lm.setStep(next);
1367
+ };
1368
+ const keys = (e) => {
1369
+ if (e.key === "ArrowRight") go(i == null ? 0 : i + 1);
1370
+ else if (e.key === "ArrowLeft" && i != null) go(i - 1);
1371
+ else return;
1372
+ e.preventDefault();
1373
+ };
1374
+ return /* @__PURE__ */ jsxs("fieldset", {
1375
+ className: "lm-story",
1376
+ onKeyDown: keys,
1377
+ ref: bar,
1378
+ tabIndex: -1,
1379
+ children: [
1380
+ /* @__PURE__ */ jsx("legend", { children: "Story" }),
1381
+ /* @__PURE__ */ jsx("p", {
1382
+ "aria-live": "polite",
1383
+ children: step ? /* @__PURE__ */ jsxs(Fragment, { children: [
1384
+ /* @__PURE__ */ jsxs("b", { children: [
1385
+ i + 1,
1386
+ " of ",
1387
+ story.length
1388
+ ] }),
1389
+ " ",
1390
+ step.text,
1391
+ lm.stepChanged && /* @__PURE__ */ jsx("em", { children: " You changed the values since." })
1392
+ ] }) : `A walk through this model in ${story.length} steps.`
1393
+ }),
1394
+ /* @__PURE__ */ jsxs("span", {
1395
+ className: "lm-story-nav",
1396
+ children: [
1397
+ i != null && /* @__PURE__ */ jsx("button", {
1398
+ type: "button",
1399
+ onClick: () => go(i - 1),
1400
+ "aria-disabled": i === 0,
1401
+ children: "Back"
1402
+ }),
1403
+ /* @__PURE__ */ jsx("button", {
1404
+ type: "button",
1405
+ onClick: () => go(i == null ? 0 : i + 1),
1406
+ "aria-disabled": i === last,
1407
+ children: i == null ? "Start" : "Next"
1408
+ }),
1409
+ i != null && lm.stepChanged && /* @__PURE__ */ jsx("button", {
1410
+ type: "button",
1411
+ onClick: () => {
1412
+ lm.setStep(i);
1413
+ bar.current?.focus();
1414
+ },
1415
+ children: "Show again"
1416
+ }),
1417
+ i != null && /* @__PURE__ */ jsx("button", {
1418
+ type: "button",
1419
+ onClick: () => lm.setStep(null),
1420
+ "aria-label": "Leave the story",
1421
+ children: "Done"
1422
+ })
1423
+ ]
1424
+ })
1425
+ ]
1426
+ });
1427
+ }
1428
+ // src/styles.ts
1429
+ const LIGHT = `
1430
+ --lm-bg: #eef2f5; --lm-surface: #ffffff; --lm-fg: #17212b; --lm-muted: #5d6d7c; --lm-line: #c6d0da;
1431
+ --lm-accent: #0b6b8f; --lm-accent-soft: #d6e9f0; --lm-alt: #7a4f9a; --lm-alt-soft: #efe5f6;
1432
+ --lm-ok: #2a7f5a; --lm-warn: #b86e00; --lm-drop: #b7392e; --lm-lane: #e3e9ee;
1433
+ --lm-s1: #2a78d6; --lm-s2: #eb6834; --lm-s3: #6250d6; --lm-s4: #eda100; color-scheme: light;`;
1434
+ const DARK = `
1435
+ --lm-bg: #0d141b; --lm-surface: #151e27; --lm-fg: #e4ebf1; --lm-muted: #93a3b3; --lm-line: #2a3744;
1436
+ --lm-accent: #5fb7d6; --lm-accent-soft: #163442; --lm-alt: #c39be0; --lm-alt-soft: #2b2236;
1437
+ --lm-ok: #5fc08f; --lm-warn: #e2a24a; --lm-drop: #e26a5c; --lm-lane: #1b2631;
1438
+ --lm-s1: #3987e5; --lm-s2: #d95926; --lm-s3: #9085e9; --lm-s4: #c98500; color-scheme: dark;`;
1439
+ /** Theme tokens and the layout of every piece. Scoped to `.lm`; override any `--lm-*` token to restyle. */
1440
+ const LOADMAP_CSS = `
1441
+ .lm { ${LIGHT}
1442
+ --lm-font: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1443
+ --lm-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1444
+ font: 14px/1.45 var(--lm-font); color: var(--lm-fg); background: var(--lm-bg);
1445
+ display: grid; gap: 14px; padding: 16px; min-width: 0; box-sizing: border-box; }
1446
+ @media (prefers-color-scheme: dark) { .lm:not([data-theme="light"]) { ${DARK} } }
1447
+ .lm[data-theme="dark"] { ${DARK} }
1448
+ .lm *, .lm *::before, .lm *::after { box-sizing: border-box; }
1449
+ .lm h2 { font-size: 1.3rem; font-weight: 600; margin: 0; text-wrap: balance; }
1450
+ .lm h3 { font: 600 .74rem var(--lm-font); text-transform: uppercase; letter-spacing: .08em; color: var(--lm-muted); margin: 0; }
1451
+ .lm button:focus-visible, .lm input:focus-visible, .lm select:focus-visible { outline: 2px solid var(--lm-accent); outline-offset: 2px; }
1452
+ .lm-card { background: var(--lm-surface); border: 1px solid var(--lm-line); border-radius: 8px; padding: 14px 16px; display: grid; gap: 12px; align-content: start; min-width: 0; }
1453
+
1454
+ .lm-wrap { display: grid; gap: 8px; min-width: 0; position: relative; }
1455
+ .lm-wrap:fullscreen { background: var(--lm-bg); padding: 16px; align-content: center; overflow: auto; }
1456
+ .lm-bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
1457
+ .lm-btn { font: 500 .8rem var(--lm-font); color: var(--lm-fg); background: var(--lm-surface); border: 1px solid var(--lm-line); border-radius: 6px; padding: 6px 12px; cursor: pointer; white-space: nowrap; }
1458
+ .lm-board { position: relative; background: var(--lm-surface); border: 1px solid var(--lm-line); border-radius: 8px; overflow: hidden; margin: 0 auto; width: 100%; }
1459
+ .lm-wrap:fullscreen .lm-board { width: auto; max-width: 100%; max-height: calc(100vh - 90px); }
1460
+ .lm-board svg { display: block; width: 100%; height: auto; font-family: var(--lm-font); }
1461
+ .lm-board canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
1462
+ .lm-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: .78rem; color: var(--lm-muted); }
1463
+ .lm-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: -1px; margin-right: 5px; }
1464
+
1465
+ .lm-verdict { margin: 0; padding: 10px 14px; border-left: 4px solid var(--lm-ok); background: var(--lm-accent-soft); border-radius: 0 6px 6px 0; }
1466
+ .lm-verdict[data-tier="warn"] { border-color: var(--lm-warn); }
1467
+ .lm-verdict[data-tier="over"] { border-color: var(--lm-drop); }
1468
+ .lm-measure { margin: 6px 0 0; padding: 0 14px; font-size: .86rem; color: var(--lm-muted); }
1469
+
1470
+ .lm-strip { display: grid; grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr); gap: 14px; align-items: start; }
1471
+ @container (max-width: 860px) { .lm-strip { grid-template-columns: minmax(0, 1fr); } }
1472
+ .lm { container-type: inline-size; }
1473
+
1474
+ .lm-meters { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
1475
+ .lm-meter { padding: 8px 10px; border-radius: 6px; background: var(--lm-bg); min-width: 0; }
1476
+ .lm-meter b { display: block; font: 500 1.15rem var(--lm-mono); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1477
+ .lm-meter span { font-size: .74rem; color: var(--lm-muted); }
1478
+ .lm-meter[data-state="warn"] b { color: var(--lm-warn); } .lm-meter[data-state="over"] b { color: var(--lm-drop); }
1479
+
1480
+ .lm-limits { display: grid; gap: 10px; }
1481
+ .lm-lim, .lm-scale { display: grid; grid-template-columns: minmax(0, 14rem) minmax(0, 1fr) 10rem; gap: 4px 14px; align-items: center; }
1482
+ @container (max-width: 640px) { .lm-lim, .lm-scale { grid-template-columns: minmax(0, 1fr); } .lm-scale > :first-child, .lm-scale > :last-child { display: none; } }
1483
+ .lm-lim b { display: block; font-weight: 600; font-size: .88rem; }
1484
+ .lm-lim .lm-sub { display: block; font-size: .74rem; color: var(--lm-muted); }
1485
+ .lm-tag { font: 600 .64rem var(--lm-font); text-transform: uppercase; letter-spacing: .06em; border-radius: 3px; padding: 1px 5px; margin-left: 6px; vertical-align: 1px; }
1486
+ .lm-tag[data-kind="first"] { color: var(--lm-surface); background: var(--lm-drop); }
1487
+ .lm-tag[data-kind="estimated"] { color: var(--lm-warn); border: 1px solid currentColor; }
1488
+ .lm-tag[data-kind="range"] { color: var(--lm-muted); border: 1px solid currentColor; }
1489
+ .lm-track { position: relative; height: 16px; background: var(--lm-bg); border-radius: 3px; overflow: hidden; }
1490
+ .lm-fill { position: absolute; inset: 0 auto 0 0; background: var(--lm-ok); transition: width .25s ease; }
1491
+ .lm-fill[data-state="warn"] { background: var(--lm-warn); } .lm-fill[data-state="over"] { background: var(--lm-drop); }
1492
+ .lm-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--lm-fg); opacity: .55; }
1493
+ .lm-val { font: 500 .8rem var(--lm-mono); font-variant-numeric: tabular-nums; }
1494
+ .lm-val small { display: block; font: 400 .72rem var(--lm-font); color: var(--lm-muted); }
1495
+ .lm-scale { font: 400 .7rem var(--lm-mono); color: var(--lm-muted); }
1496
+ .lm-scale div { position: relative; height: 12px; }
1497
+ .lm-scale div span { position: absolute; transform: translateX(-50%); white-space: nowrap; }
1498
+
1499
+ .lm-controls label.lm-range { display: grid; gap: 4px; font-size: .84rem; }
1500
+ .lm-controls label.lm-range > span { display: flex; justify-content: space-between; gap: 8px; }
1501
+ .lm-controls output { font-family: var(--lm-mono); font-variant-numeric: tabular-nums; }
1502
+ .lm-controls input[type=range] { width: 100%; accent-color: var(--lm-accent); }
1503
+ .lm-presets { display: flex; flex-wrap: wrap; gap: 6px; }
1504
+ .lm-presets button { font: 500 .76rem var(--lm-font); background: var(--lm-accent-soft); color: var(--lm-fg); border: 1px solid transparent; border-radius: 5px; padding: 5px 10px; cursor: pointer; }
1505
+ .lm-presets button[aria-pressed="true"] { border-color: var(--lm-accent); }
1506
+ .lm-switch { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0 8px; align-items: start; font-size: .84rem; cursor: pointer; }
1507
+ .lm-switch input { margin: 3px 0 0; accent-color: var(--lm-accent); }
1508
+ .lm-switch small, .lm-select small { grid-column: 2; color: var(--lm-muted); font-size: .74rem; line-height: 1.3; }
1509
+ .lm-select { display: grid; gap: 4px; font-size: .84rem; }
1510
+ .lm-select select { width: 100%; min-width: 0; font: 400 .84rem var(--lm-font); color: var(--lm-fg); background: var(--lm-surface); border: 1px solid var(--lm-line); border-radius: 5px; padding: 5px 8px; }
1511
+ .lm-off { opacity: .45; cursor: default; }
1512
+
1513
+ .lm-facts { margin: 0; padding-left: 1.1em; font-size: .88rem; columns: 2; column-gap: 28px; }
1514
+ @container (max-width: 860px) { .lm-facts { columns: 1; } }
1515
+ .lm-facts li { margin-bottom: 8px; break-inside: avoid; }
1516
+ .lm-prov { font: 600 .66rem var(--lm-font); text-transform: uppercase; letter-spacing: .06em; }
1517
+ .lm-prov[data-kind="measured"] { color: var(--lm-ok); } .lm-prov[data-kind="estimated"] { color: var(--lm-warn); }
1518
+
1519
+ /* scenario identity colours (s1–s4) are checked for colour-blind separation on both surfaces; text never wears them */
1520
+ .lm-scenarios { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; border: 0; padding: 0; margin: 0; min-width: 0; }
1521
+ .lm-scenarios legend { float: left; font: 600 .74rem var(--lm-font); text-transform: uppercase; letter-spacing: .08em; color: var(--lm-muted); margin-right: 6px; padding: 0; }
1522
+ .lm-scenarios button { font: 500 .84rem var(--lm-font); color: var(--lm-fg); background: var(--lm-surface); border: 1px solid var(--lm-line); border-radius: 6px; padding: 6px 12px; cursor: pointer; }
1523
+ .lm-scenarios button[aria-pressed="true"] { border-color: var(--lm-fg); box-shadow: inset 0 0 0 1px var(--lm-fg); }
1524
+ .lm-scenarios i, .lm-table i { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: -1px; margin-right: 6px; }
1525
+ .lm-story { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0; min-width: 0; padding: 8px 12px; border: 1px solid var(--lm-line); border-left: 3px solid var(--lm-accent); border-radius: 6px; background: var(--lm-surface); }
1526
+ .lm-story legend { float: left; font: 600 .74rem var(--lm-font); text-transform: uppercase; letter-spacing: .08em; color: var(--lm-muted); padding: 0; }
1527
+ .lm-story p { flex: 1 1 240px; margin: 0; font: 400 .9rem/1.4 var(--lm-font); color: var(--lm-fg); }
1528
+ .lm-story b { font-weight: 600; color: var(--lm-muted); margin-right: 4px; white-space: nowrap; }
1529
+ .lm-story-nav { display: flex; gap: 6px; }
1530
+ .lm-story em { color: var(--lm-muted); }
1531
+ .lm-story button { font: 500 .84rem var(--lm-font); color: var(--lm-fg); background: var(--lm-surface); border: 1px solid var(--lm-line); border-radius: 6px; padding: 6px 12px; cursor: pointer; }
1532
+ .lm-story button[aria-disabled="true"] { opacity: .45; cursor: default; }
1533
+ .lm-own { font-size: .78rem; color: var(--lm-muted); }
1534
+ .lm-key { display: inline-block; width: 14px; height: 2px; border-radius: 1px; vertical-align: 3px; margin-right: 6px; }
1535
+
1536
+ .lm-compare-text { margin: 0; }
1537
+ .lm-table-wrap { overflow-x: auto; }
1538
+ .lm-table { border-collapse: collapse; width: 100%; font-size: .84rem; }
1539
+ .lm-table th, .lm-table td { text-align: left; padding: 6px 10px 6px 0; border-bottom: 1px solid var(--lm-line); vertical-align: top; }
1540
+ .lm-table thead th { font: 600 .7rem var(--lm-font); text-transform: uppercase; letter-spacing: .06em; color: var(--lm-muted); }
1541
+ .lm-table tbody th { font-weight: 600; white-space: nowrap; }
1542
+ .lm-table td { font-family: var(--lm-mono); font-variant-numeric: tabular-nums; }
1543
+ .lm-table td.lm-text { font-family: var(--lm-font); }
1544
+ .lm-table td small { display: block; font: 400 .72rem var(--lm-font); color: var(--lm-muted); }
1545
+ .lm-table tr[aria-current] th { text-decoration: underline; text-underline-offset: 3px; }
1546
+
1547
+ .lm-chart { position: relative; min-width: 0; }
1548
+ .lm-chart svg { display: block; overflow: visible; }
1549
+ .lm-chart:focus-visible { outline: 2px solid var(--lm-accent); outline-offset: 2px; }
1550
+ .lm-axis, .lm-axis text { font: 400 11px var(--lm-mono); fill: var(--lm-muted); }
1551
+ .lm-mark { font: 600 11px var(--lm-mono); fill: var(--lm-fg); }
1552
+ .lm-tip { position: absolute; z-index: 2; display: grid; gap: 2px; width: max-content; max-width: 17rem; padding: 8px 10px; font-size: .78rem; background: var(--lm-surface); color: var(--lm-fg); border: 1px solid var(--lm-line); border-radius: 6px; box-shadow: 0 4px 14px rgb(0 0 0 / .14); pointer-events: none; }
1553
+ .lm-tip strong { font: 600 .82rem var(--lm-mono); font-variant-numeric: tabular-nums; }
1554
+ .lm-tip .lm-sub { color: var(--lm-muted); }
1555
+ .lm-nowrap { white-space: nowrap; }
1556
+ .lm-node { cursor: pointer; outline: none; }
1557
+ .lm-node:focus-visible > :first-child { stroke-width: 3; }
1558
+
1559
+ @media (prefers-reduced-motion: reduce) { .lm-fill { transition: none; } }
1560
+ `;
1561
+ /** Adds the stylesheet once per document. */
1562
+ function injectStyles(doc = document) {
1563
+ if (doc.getElementById("loadmap-css")) return;
1564
+ const el = doc.createElement("style");
1565
+ el.id = "loadmap-css";
1566
+ el.textContent = LOADMAP_CSS;
1567
+ doc.head.appendChild(el);
1568
+ }
1569
+ // src/elk.ts
1570
+ let shared = null;
1571
+ /**
1572
+ * One ELK worker for every loadmap on the page, started on the first layout. When the worker cannot start or load
1573
+ * (no Worker, a CSP without worker-src, a dev server that pre-bundled this package away from the worker file), every
1574
+ * layout runs on the main thread instead: slower to start, the same result.
1575
+ */
1576
+ function pageElk() {
1577
+ shared ??= workerElk(() => new Worker(new URL("./elk.worker.js", import.meta.url), {
1578
+ type: "module",
1579
+ name: "loadmap-elk"
1580
+ }));
1581
+ return shared;
1582
+ }
1583
+ /** ELK in the worker `make` starts, falling back to the main thread. */
1584
+ function workerElk(make) {
1585
+ let worker = null;
1586
+ let broken = typeof Worker === "undefined";
1587
+ let next = 0;
1588
+ const pending = /* @__PURE__ */ new Map();
1589
+ const fallBack = () => {
1590
+ broken = true;
1591
+ worker?.terminate();
1592
+ worker = null;
1593
+ for (const [id, p] of pending) {
1594
+ pending.delete(id);
1595
+ localElk().then((elk) => elk.layout(p.graph)).then(p.resolve, p.reject);
1596
+ }
1597
+ };
1598
+ const start = () => {
1599
+ if (worker || broken) return worker;
1600
+ try {
1601
+ worker = make();
1602
+ } catch {
1603
+ fallBack();
1604
+ return null;
1605
+ }
1606
+ worker.onmessage = ({ data }) => {
1607
+ const p = pending.get(data.id);
1608
+ if (!p) return;
1609
+ pending.delete(data.id);
1610
+ if (data.error != null) p.reject(new Error(data.error));
1611
+ else p.resolve(data.graph);
1612
+ };
1613
+ worker.onerror = (e) => {
1614
+ e.preventDefault();
1615
+ fallBack();
1616
+ };
1617
+ return worker;
1618
+ };
1619
+ return { layout(graph) {
1620
+ const w = start();
1621
+ if (!w) return localElk().then((elk) => elk.layout(graph));
1622
+ return new Promise((resolve, reject) => {
1623
+ const id = next++;
1624
+ pending.set(id, {
1625
+ graph,
1626
+ resolve,
1627
+ reject
1628
+ });
1629
+ w.postMessage({
1630
+ id,
1631
+ graph
1632
+ });
1633
+ });
1634
+ } };
1635
+ }
1636
+ // src/engine.ts
1637
+ const TOKENS = [
1638
+ "accent",
1639
+ "alt",
1640
+ "ok",
1641
+ "warn",
1642
+ "drop",
1643
+ "muted",
1644
+ "fg",
1645
+ "surface",
1646
+ "line"
1647
+ ];
1648
+ /**
1649
+ * Everything that moves, without React: the sim, the dots on the canvas and the worker fills in the SVG. React
1650
+ * renders the structure once per scene; the engine updates the moving parts every frame, and export drives it frame
1651
+ * by frame instead of the animation loop.
1652
+ */
1653
+ var Engine = class {
1654
+ model;
1655
+ view;
1656
+ sim;
1657
+ ev;
1658
+ scene = null;
1659
+ /** The structure key the current values want; `scene.key` catches up once the layout is done. */
1660
+ wantKey = "";
1661
+ /** The values React last drew labels and colours for; a frame is complete once they match `ev.values`. */
1662
+ drawnValues = "";
1663
+ /** The component a story step points at, and the one the diagram last outlined: export waits for them to match. */
1664
+ wantFocus = null;
1665
+ /** The story step shown, set as soon as one is chosen: a recorder holding an old handle still reads it. */
1666
+ storyStep = null;
1667
+ drawnFocus = null;
1668
+ paused = false;
1669
+ colors = {};
1670
+ svg = null;
1671
+ canvas = null;
1672
+ #o;
1673
+ #slots = [];
1674
+ #paths = /* @__PURE__ */ new Map();
1675
+ constructor(model, view, ev, o = {}) {
1676
+ this.model = model;
1677
+ this.view = view;
1678
+ this.ev = ev;
1679
+ this.#o = o;
1680
+ this.sim = createSim(model, ev, o);
1681
+ }
1682
+ setEvaluation(ev) {
1683
+ if (ev === this.ev) return;
1684
+ const same = JSON.stringify(ev.values) === JSON.stringify(this.ev.values);
1685
+ this.ev = ev;
1686
+ if (!same) this.sim.update(ev);
1687
+ }
1688
+ setScene(scene) {
1689
+ if (scene === this.scene) return;
1690
+ this.scene = scene;
1691
+ this.#paths.clear();
1692
+ }
1693
+ /** Starts the sim over from empty queues, with this seed (default: the original one). */
1694
+ reset(seed) {
1695
+ this.sim = createSim(this.model, this.ev, {
1696
+ ...this.#o,
1697
+ seed: seed ?? this.#o.seed
1698
+ });
1699
+ this.#paths.clear();
1700
+ }
1701
+ bind(svg, canvas) {
1702
+ this.svg = svg;
1703
+ this.canvas = canvas;
1704
+ this.#slots = [];
1705
+ if (!svg) return;
1706
+ for (const el of svg.querySelectorAll("rect[data-slot]")) this.#slots.push({
1707
+ el,
1708
+ stage: el.dataset.stage,
1709
+ index: Number(el.dataset.slot),
1710
+ y: Number(el.dataset.y),
1711
+ s: Number(el.dataset.s)
1712
+ });
1713
+ this.resize();
1714
+ }
1715
+ /** Reads the theme tokens from the element that carries them. Call again when the theme changes. */
1716
+ readColors(root) {
1717
+ const cs = getComputedStyle(root);
1718
+ for (const t of TOKENS) this.colors[t] = cs.getPropertyValue(`--lm-${t}`).trim();
1719
+ }
1720
+ flowColor(flow) {
1721
+ const c = this.view.def.flows[flow]?.color ?? "accent";
1722
+ return this.colors[c] || c;
1723
+ }
1724
+ outcomeColor(outcome) {
1725
+ if (outcome === "dropped" || outcome === "rejected") return this.colors.drop || "#b7392e";
1726
+ if (outcome === "superseded" || outcome === "unread") return this.colors.muted || "#5d6d7c";
1727
+ return this.colors.ok || "#2a7f5a";
1728
+ }
1729
+ resize() {
1730
+ const c = this.canvas;
1731
+ if (!c || !this.scene) return;
1732
+ const r = c.getBoundingClientRect();
1733
+ if (!r.width) return;
1734
+ const dpr = Math.min(2, globalThis.devicePixelRatio || 1);
1735
+ c.width = Math.round(r.width * dpr);
1736
+ c.height = Math.round(r.height * dpr);
1737
+ }
1738
+ step(dtReal, o = {}) {
1739
+ this.sim.step(dtReal, o);
1740
+ }
1741
+ /** Draws the dots on the overlay canvas and the worker fills in the SVG. */
1742
+ draw(o = {}) {
1743
+ this.drawFills();
1744
+ const c = this.canvas;
1745
+ const ctx = c?.getContext("2d");
1746
+ if (!c || !ctx || !this.scene) return;
1747
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1748
+ ctx.clearRect(0, 0, c.width, c.height);
1749
+ if (o.particles === false) return;
1750
+ this.paintParticles(ctx, c.width / this.scene.width);
1751
+ }
1752
+ drawFills() {
1753
+ for (const sl of this.#slots) {
1754
+ const b = this.sim.slotBusy(sl.stage, sl.index);
1755
+ const h = b < .02 ? 0 : (sl.s - 2) * b;
1756
+ sl.el.setAttribute("y", (sl.y + sl.s - 1 - h).toFixed(2));
1757
+ sl.el.setAttribute("height", h.toFixed(2));
1758
+ }
1759
+ }
1760
+ /** Every dot as it is drawn now, in scene units: for the canvas, and for an SVG export. */
1761
+ dots() {
1762
+ const scene = this.scene;
1763
+ if (!scene) return [];
1764
+ const out = [];
1765
+ const live = /* @__PURE__ */ new Set();
1766
+ for (const d of this.sim.particles) {
1767
+ live.add(d.id);
1768
+ const [x, y] = this.#position(scene, d);
1769
+ const inCell = d.to.cell != null && d.phase !== "move";
1770
+ out.push({
1771
+ x,
1772
+ y: y - (d.phase === "fade" && d.outcome !== "delivered" ? d.t / d.dur * 8 : 0),
1773
+ r: inCell ? 3 : 3.4,
1774
+ color: d.phase === "fade" ? this.outcomeColor(d.outcome) : this.flowColor(d.flow),
1775
+ alpha: d.phase === "fade" ? Math.max(0, 1 - d.t / d.dur) : 1
1776
+ });
1777
+ }
1778
+ for (const id of this.#paths.keys()) if (!live.has(id)) this.#paths.delete(id);
1779
+ return out;
1780
+ }
1781
+ /** Paints the dots onto any 2D context, scene units × `scale`: the overlay, or an export frame. */
1782
+ paintParticles(ctx, scale) {
1783
+ if (!this.scene) return;
1784
+ ctx.save();
1785
+ ctx.transform(scale, 0, 0, scale, 0, 0);
1786
+ for (const d of this.dots()) {
1787
+ ctx.globalAlpha = d.alpha;
1788
+ ctx.fillStyle = d.color;
1789
+ ctx.beginPath();
1790
+ ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
1791
+ ctx.fill();
1792
+ }
1793
+ ctx.restore();
1794
+ }
1795
+ #position(scene, d) {
1796
+ if (d.phase !== "move") return anchor(scene, d.to, d.flow);
1797
+ const key = `${d.leg}|${d.from.stage}|${d.from.cell}|${d.to.stage}|${d.to.cell}`;
1798
+ let p = this.#paths.get(d.id);
1799
+ if (!p || p.key !== key) {
1800
+ p = {
1801
+ key,
1802
+ points: legPath(scene, d.from, d.to, d.flow)
1803
+ };
1804
+ this.#paths.set(d.id, p);
1805
+ }
1806
+ return pointAt(p.points, d.t / d.dur);
1807
+ }
1808
+ };
1809
+ // src/useLoadmap.ts
1810
+ /** The state behind a loadmap: values, the model's evaluation, the layout and the engine. */
1811
+ function useLoadmap(model, view, o = {}) {
1812
+ const init = () => ({
1813
+ ...model.defaults(),
1814
+ ...o.initial
1815
+ });
1816
+ const [state, setState] = useState(() => ({
1817
+ model,
1818
+ values: init()
1819
+ }));
1820
+ let values = state.values;
1821
+ if (state.model !== model) {
1822
+ values = init();
1823
+ setState({
1824
+ model,
1825
+ values
1826
+ });
1827
+ }
1828
+ const evaluated = useRef(null);
1829
+ const ev = useMemo(() => evaluated.current?.values === values ? evaluated.current.ev : model.evaluate(values), [model, values]);
1830
+ const engineRef = useRef(null);
1831
+ if (!engineRef.current || engineRef.current.model !== model || engineRef.current.view !== view) engineRef.current = new Engine(model, view, ev, o);
1832
+ const engine = engineRef.current;
1833
+ const [root, setRoot] = useState(null);
1834
+ const wide = o.direction ?? "RIGHT";
1835
+ const [direction, setDirection] = useState(wide);
1836
+ const [measure, setMeasure] = useState(null);
1837
+ const [laid, setLaid] = useState(null);
1838
+ const scene = laid?.view === view ? laid.scene : null;
1839
+ const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
1840
+ const narrowBelow = o.narrowBelow ?? 640;
1841
+ useEffect(() => engine.setEvaluation(ev), [engine, ev]);
1842
+ useEffect(() => {
1843
+ if (!root) return;
1844
+ setMeasure(() => canvasMeasure(getComputedStyle(root).fontFamily));
1845
+ const ro = new ResizeObserver(([e]) => {
1846
+ const w = e?.contentRect.width ?? root.clientWidth;
1847
+ setDirection(w > 0 && w < narrowBelow ? "DOWN" : wide);
1848
+ });
1849
+ ro.observe(root);
1850
+ return () => ro.disconnect();
1851
+ }, [
1852
+ root,
1853
+ narrowBelow,
1854
+ wide
1855
+ ]);
1856
+ const key = useMemo(() => measure ? structureKey(view, ev, {
1857
+ direction,
1858
+ measure
1859
+ }) : "", [
1860
+ view,
1861
+ ev,
1862
+ direction,
1863
+ measure
1864
+ ]);
1865
+ engine.wantKey = key;
1866
+ const elk = o.elk;
1867
+ useEffect(() => {
1868
+ if (!measure || !key) return;
1869
+ let live = true;
1870
+ layout(view, engine.ev, {
1871
+ direction,
1872
+ measure,
1873
+ elk: elk ?? pageElk()
1874
+ }).then((s) => {
1875
+ if (live) setLaid({
1876
+ view,
1877
+ scene: s
1878
+ });
1879
+ }, (err) => console.error("loadmap: layout failed", err));
1880
+ return () => {
1881
+ live = false;
1882
+ };
1883
+ }, [
1884
+ key,
1885
+ view,
1886
+ engine,
1887
+ direction,
1888
+ measure,
1889
+ elk
1890
+ ]);
1891
+ const labels = useMemo(() => labelsAt(view, ev), [view, ev]);
1892
+ const valuesRef = useRef(values);
1893
+ valuesRef.current = values;
1894
+ const setValues = useCallback((v) => {
1895
+ const next = {
1896
+ ...valuesRef.current,
1897
+ ...v
1898
+ };
1899
+ valuesRef.current = next;
1900
+ const nextEv = model.evaluate(next);
1901
+ evaluated.current = {
1902
+ values: next,
1903
+ ev: nextEv
1904
+ };
1905
+ engine.setEvaluation(nextEv);
1906
+ setState({
1907
+ model,
1908
+ values: next
1909
+ });
1910
+ }, [engine, model]);
1911
+ const scenario = useMemo(() => model.scenarioOf(values), [model, values]);
1912
+ const setScenario = useCallback((label) => {
1913
+ const s = model.scenarios.find((x) => x.label === label);
1914
+ if (s) setValues(s.values);
1915
+ }, [model, setValues]);
1916
+ const [stepState, setStepState] = useState({
1917
+ model,
1918
+ i: null
1919
+ });
1920
+ const step = stepState.model === model && stepState.i != null && stepState.i < model.story.length ? stepState.i : null;
1921
+ const setStep = useCallback((i) => {
1922
+ const st = i == null ? void 0 : model.story[i];
1923
+ if (st) setValues(st.values);
1924
+ engine.wantFocus = st?.focus ?? null;
1925
+ engine.storyStep = st ? i : null;
1926
+ setStepState({
1927
+ model,
1928
+ i: st ? i : null
1929
+ });
1930
+ }, [
1931
+ model,
1932
+ setValues,
1933
+ engine
1934
+ ]);
1935
+ const shown = step == null ? void 0 : model.story[step];
1936
+ const stepChanged = !!shown && Object.entries(shown.values).some(([k, v]) => values[k] !== v);
1937
+ const focus = shown && !stepChanged ? shown.focus ?? null : null;
1938
+ engine.wantFocus = focus;
1939
+ engine.storyStep = step;
1940
+ return {
1941
+ model,
1942
+ view,
1943
+ values,
1944
+ setValues,
1945
+ scenario,
1946
+ setScenario,
1947
+ step,
1948
+ stepChanged,
1949
+ setStep,
1950
+ focus,
1951
+ ev,
1952
+ scene,
1953
+ labels,
1954
+ direction,
1955
+ reducedMotion,
1956
+ engine,
1957
+ rootRef: setRoot,
1958
+ root
1959
+ };
1960
+ }
1961
+ function useMediaQuery(q) {
1962
+ const [on, setOn] = useState(() => typeof matchMedia === "function" && matchMedia(q).matches);
1963
+ useEffect(() => {
1964
+ if (typeof matchMedia !== "function") return;
1965
+ const m = matchMedia(q);
1966
+ const f = () => setOn(m.matches);
1967
+ f();
1968
+ m.addEventListener("change", f);
1969
+ return () => m.removeEventListener("change", f);
1970
+ }, [q]);
1971
+ return on;
1972
+ }
1973
+ function canvasMeasure(fontFamily) {
1974
+ const ctx = document.createElement("canvas").getContext("2d");
1975
+ if (!ctx) return (t, size) => t.length * size * .6;
1976
+ return (text, size, weight = 400) => {
1977
+ ctx.font = `${weight} ${size}px ${fontFamily}`;
1978
+ return Math.ceil(ctx.measureText(text).width);
1979
+ };
1980
+ }
1981
+ // src/Loadmap.tsx
1982
+ /** The root element: theme tokens, styles, and the size the layout direction follows. */
1983
+ function LoadmapRoot({ lm, theme = "auto", children }) {
1984
+ useEffect(() => injectStyles(), []);
1985
+ useEffect(() => {
1986
+ if (!lm.root) return;
1987
+ lm.engine.readColors(lm.root);
1988
+ const m = matchMedia("(prefers-color-scheme: dark)");
1989
+ const f = () => lm.root && lm.engine.readColors(lm.root);
1990
+ m.addEventListener("change", f);
1991
+ return () => m.removeEventListener("change", f);
1992
+ }, [
1993
+ lm.root,
1994
+ lm.engine,
1995
+ theme
1996
+ ]);
1997
+ return /* @__PURE__ */ jsx("div", {
1998
+ className: "lm",
1999
+ "data-theme": theme === "auto" ? void 0 : theme,
2000
+ ref: lm.rootRef,
2001
+ children
2002
+ });
2003
+ }
2004
+ /** The whole page piece: scenarios, diagram, verdict, headroom, live meters, limits, controls and assumptions. */
2005
+ function Loadmap({ model, view, theme, title, assumptions, headroom = true, compare = true, ref, ...o }) {
2006
+ const lm = useLoadmap(model, view, o);
2007
+ useImperativeHandle(ref, () => handleOf(lm), [lm]);
2008
+ const side = compare && model.scenarios.length > 1;
2009
+ return /* @__PURE__ */ jsxs(LoadmapRoot, {
2010
+ lm,
2011
+ theme,
2012
+ children: [
2013
+ title !== false && /* @__PURE__ */ jsx("h2", { children: title ?? model.title }),
2014
+ /* @__PURE__ */ jsx(Scenarios, { lm }),
2015
+ /* @__PURE__ */ jsx(Story, { lm }),
2016
+ /* @__PURE__ */ jsx(Diagram, { lm }),
2017
+ /* @__PURE__ */ jsx(Verdict, { lm }),
2018
+ (headroom || side) && /* @__PURE__ */ jsxs("div", {
2019
+ className: headroom && side ? "lm-strip" : void 0,
2020
+ children: [headroom && /* @__PURE__ */ jsx(HeadroomChart, { lm }), side && /* @__PURE__ */ jsx(Compare, { lm })]
2021
+ }),
2022
+ /* @__PURE__ */ jsxs("div", {
2023
+ className: "lm-strip",
2024
+ children: [/* @__PURE__ */ jsxs("div", {
2025
+ className: "lm-card",
2026
+ children: [/* @__PURE__ */ jsx(Meters, { lm }), /* @__PURE__ */ jsx(Limits, { lm })]
2027
+ }), /* @__PURE__ */ jsx(Controls, { lm })]
2028
+ }),
2029
+ /* @__PURE__ */ jsx(Assumptions, {
2030
+ lm,
2031
+ extra: assumptions
2032
+ })
2033
+ ]
2034
+ });
2035
+ }
2036
+ function handleOf(lm) {
2037
+ const { engine } = lm;
2038
+ return {
2039
+ engine,
2040
+ async ready(timeoutMs = 1e4) {
2041
+ const t0 = performance.now();
2042
+ const drawn = () => engine.scene && engine.scene.key === engine.wantKey && engine.svg && engine.drawnValues === JSON.stringify(engine.ev.values) && engine.drawnFocus === engine.wantFocus;
2043
+ while (!drawn()) {
2044
+ if (performance.now() - t0 > timeoutMs) throw new Error("loadmap: layout not ready");
2045
+ await new Promise((r) => requestAnimationFrame(r));
2046
+ }
2047
+ },
2048
+ pause: () => {
2049
+ engine.paused = true;
2050
+ },
2051
+ resume: () => {
2052
+ engine.paused = false;
2053
+ },
2054
+ values: () => engine.ev.values,
2055
+ setValues: (v) => lm.setValues(v),
2056
+ setStoryStep: (i) => lm.setStep(i),
2057
+ storyStep: () => engine.storyStep,
2058
+ reset: (seed) => engine.reset(seed),
2059
+ step: (dt) => {
2060
+ engine.step(dt, { particles: true });
2061
+ engine.draw({ particles: true });
2062
+ },
2063
+ board: () => engine.svg && engine.canvas && engine.scene ? {
2064
+ svg: engine.svg,
2065
+ canvas: engine.canvas,
2066
+ scene: engine.scene
2067
+ } : null
2068
+ };
2069
+ }
2070
+ export { Assumptions, Compare, Controls, Diagram, HeadroomChart, LOADMAP_CSS, Limits, Loadmap, LoadmapRoot, Meters, Scenarios, Story, Verdict, cluster, compact, defineView, edge, fitLinear, fmt, handleOf, injectStyles, multiple, node, pct, pool, scenarioColor, secs, useComparison, useLoadmap };