@loadmap/react 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +75 -2
- package/dist/elk.worker.js +14 -0
- package/dist/index.d.ts +213 -0
- package/dist/index.js +2070 -0
- package/package.json +42 -3
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 };
|