@caddis/cli 0.4.44 → 0.4.46
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/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/README.md +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +102 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +19 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +142 -0
- package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +415 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +95 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +54 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +680 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +179 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +42 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +175 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +239 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +134 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +193 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +126 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +253 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +144 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +95 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +167 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +250 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +100 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +203 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +33 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +43 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +135 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +86 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +73 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +190 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +40 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +40 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +2656 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +23 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
- package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +102 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +19 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +142 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +415 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +95 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +54 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +680 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +179 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +42 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +175 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +239 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +134 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +193 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +126 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +253 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +144 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +95 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +167 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +250 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +100 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +203 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +33 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +43 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +135 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +86 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +73 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +190 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +40 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +40 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +2656 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +23 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The page around the canvas: completeness strip, summary tiles, legend, ledger, table view, title,
|
|
2
|
+
// view switch, search box and theme toggle. Text comes from the model; every string goes through esc() or textContent.
|
|
3
|
+
|
|
4
|
+
import { el, on, type Ctx } from "./ctx.ts";
|
|
5
|
+
import { esc } from "./model.ts";
|
|
6
|
+
import { DASH, toneVar, cssId } from "./stateCss.ts";
|
|
7
|
+
import { bucketOf, columnTitle, effectiveLinks, nodeStateId, nodeVisible } from "./rules.ts";
|
|
8
|
+
import { matches } from "./interact.ts";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LABELS: Record<string, string> = {
|
|
11
|
+
tileGood: "links in a good state",
|
|
12
|
+
tileNeutral: "links in a neutral state",
|
|
13
|
+
tileGap: "groups with a gap: click to show",
|
|
14
|
+
tileUpgrade: "groups you can improve: click to show",
|
|
15
|
+
ledgerTitle: "What this view could not establish",
|
|
16
|
+
tableTitle: "Table view",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function install(ctx: Ctx): void {
|
|
20
|
+
const { els, model } = ctx;
|
|
21
|
+
const label = (k: string): string => model.meta.labels?.[k] ?? DEFAULT_LABELS[k] ?? k;
|
|
22
|
+
const visible = (id: string): boolean => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, ctx.mode, n, ctx.collapsed); };
|
|
23
|
+
|
|
24
|
+
function countUp(node: HTMLElement, to: number, ms = 700): void {
|
|
25
|
+
if (ctx.reduced) { node.textContent = String(to); return; }
|
|
26
|
+
const t0 = performance.now();
|
|
27
|
+
const step = (t: number) => {
|
|
28
|
+
const k = Math.min(1, (t - t0) / ms), e = 1 - Math.pow(1 - k, 3);
|
|
29
|
+
node.textContent = String(Math.round(to * e));
|
|
30
|
+
if (k < 1) requestAnimationFrame(step);
|
|
31
|
+
};
|
|
32
|
+
requestAnimationFrame(step);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildStrip(): void {
|
|
36
|
+
const box = els.strip;
|
|
37
|
+
box.style.display = model.columns.length ? "" : "none";
|
|
38
|
+
box.style.gridTemplateColumns = `repeat(${Math.max(1, model.columns.length)}, minmax(0, 1fr))`;
|
|
39
|
+
if (!box.firstChild) {
|
|
40
|
+
box.innerHTML = model.columns.map(c => `<div class="lcell"><div class="ln">${esc(c.title)}</div><div class="lb"><i class="good"></i><i class="neutral"></i><i class="gap"></i></div><div class="lv"></div></div>`).join("");
|
|
41
|
+
}
|
|
42
|
+
box.querySelectorAll<HTMLElement>(".lcell").forEach((cell, i) => {
|
|
43
|
+
cell.style.setProperty("--i", String(i));
|
|
44
|
+
let tot = 0, good = 0, neutral = 0, gap = 0;
|
|
45
|
+
for (const n of model.nodes) {
|
|
46
|
+
if (n.col !== i || n.kind === "chip") continue;
|
|
47
|
+
if (!visible(n.id)) continue;
|
|
48
|
+
const w = Math.max(1, n.weight ?? 1);
|
|
49
|
+
tot += w;
|
|
50
|
+
const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
|
|
51
|
+
if (b === "good") good += w; else if (b === "neutral") neutral += w; else gap += w;
|
|
52
|
+
}
|
|
53
|
+
const t = tot || 1, bars = cell.querySelectorAll<HTMLElement>("i");
|
|
54
|
+
requestAnimationFrame(() => {
|
|
55
|
+
if (bars[0]) bars[0].style.width = good / t * 100 + "%";
|
|
56
|
+
if (bars[1]) bars[1].style.width = neutral / t * 100 + "%";
|
|
57
|
+
if (bars[2]) bars[2].style.width = gap / t * 100 + "%";
|
|
58
|
+
});
|
|
59
|
+
const lv = cell.querySelector<HTMLElement>(".lv");
|
|
60
|
+
if (lv) lv.textContent = `${good + neutral} / ${tot}`;
|
|
61
|
+
cell.title = `${model.columns[i]?.title ?? ""}: ${good} good, ${neutral} neutral, ${gap} gap`;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildTiles(): void {
|
|
66
|
+
const k = els.tiles;
|
|
67
|
+
let good = 0, neutral = 0;
|
|
68
|
+
for (const l of effectiveLinks(model, ctx.mode, ctx.collapsed, ctx.nodes)) {
|
|
69
|
+
const b = bucketOf(model, l.state);
|
|
70
|
+
if (b === "good") good++; else if (b === "neutral") neutral++;
|
|
71
|
+
}
|
|
72
|
+
let gaps = 0, upgrades = 0;
|
|
73
|
+
for (const n of model.nodes) {
|
|
74
|
+
if (!visible(n.id) || n.kind === "chip") continue;
|
|
75
|
+
const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
|
|
76
|
+
if (b === "gap") gaps += 1;
|
|
77
|
+
if (n.upgrade && b !== "good") upgrades++;
|
|
78
|
+
}
|
|
79
|
+
k.innerHTML = "";
|
|
80
|
+
const tile = (cls: string, n: number, text: string, filter?: "gap" | "upgrade") => {
|
|
81
|
+
const b = el(filter ? "button" : "div", "tile " + cls, `<span class="tn">0</span><span class="tl">${esc(text)}</span>`);
|
|
82
|
+
b.style.setProperty("--i", String(k.children.length + 9));
|
|
83
|
+
if (filter) {
|
|
84
|
+
(b as HTMLButtonElement).type = "button";
|
|
85
|
+
b.setAttribute("aria-pressed", String(ctx.filter === filter));
|
|
86
|
+
b.addEventListener("click", () => {
|
|
87
|
+
ctx.filter = ctx.filter === filter ? null : filter;
|
|
88
|
+
ctx.fn.select(null);
|
|
89
|
+
buildTiles();
|
|
90
|
+
ctx.fn.trace(null);
|
|
91
|
+
ctx.fn.announce(ctx.filter ? "Filter on" : "Filter off");
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
k.appendChild(b);
|
|
95
|
+
countUp(b.querySelector<HTMLElement>(".tn") as HTMLElement, n);
|
|
96
|
+
};
|
|
97
|
+
tile("ok", good, label("tileGood"));
|
|
98
|
+
tile("", neutral, label("tileNeutral"));
|
|
99
|
+
tile("warn", gaps, label("tileGap"), "gap");
|
|
100
|
+
tile("", upgrades, label("tileUpgrade"), "upgrade");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildLegend(): void {
|
|
104
|
+
const parts = Object.entries(model.states).map(([id, s]) => {
|
|
105
|
+
const cap = s.edge === "dot" ? ' stroke-linecap="round"' : "";
|
|
106
|
+
return `<span><svg width="40" height="10" aria-hidden="true"><path d="M0 5 H40" stroke="${toneVar(s.tone)}" stroke-width="2" stroke-dasharray="${DASH[s.edge]}"${cap}/></svg><b class="ev st-${cssId(id)}">${esc(s.word)}</b></span>`;
|
|
107
|
+
});
|
|
108
|
+
parts.push('<span id="hintline">scroll = zoom · drag = pan · double-click = fit</span>');
|
|
109
|
+
els.legend.innerHTML = parts.join("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildLedger(): void {
|
|
113
|
+
let gaps = 0, total = 0;
|
|
114
|
+
for (const n of model.nodes) { if (n.kind === "chip" || !visible(n.id)) continue; total++; if (bucketOf(model, nodeStateId(model, ctx.mode, n)) === "gap") gaps++; }
|
|
115
|
+
const noLane = model.lanes.filter(l => l.gap).length;
|
|
116
|
+
els.ledger.innerHTML = `<h4>${esc(label("ledgerTitle"))}</h4><p><b>${gaps}</b> of <b>${total}</b> groups in this view have a gap: they are drawn dotted and are never guessed. ${noLane ? `<b>${noLane}</b> lane${noLane === 1 ? " has" : "s have"} no source document yet.` : ""}</p><p>${esc(model.meta.source)}</p>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildTable(): void {
|
|
120
|
+
const rows = model.nodes.filter(n => visible(n.id) && n.kind !== "chip").map(n => {
|
|
121
|
+
const sid = nodeStateId(model, ctx.mode, n);
|
|
122
|
+
const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
|
|
123
|
+
return `<tr><th scope="row">${esc(columnTitle(model, n))}</th><td>${esc(lane)}</td><td>${esc(n.title)}</td><td>${esc(model.states[sid]?.word ?? sid)}</td><td>${Math.max(1, n.weight ?? 1)}</td><td>${esc(n.subtitle ?? "")}</td><td>${esc(n.upgrade ?? "")}</td></tr>`;
|
|
124
|
+
}).join("");
|
|
125
|
+
els.table.innerHTML = `<thead><tr><th scope="col">Column</th><th scope="col">Lane</th><th scope="col">Group</th><th scope="col">State</th><th scope="col">Members</th><th scope="col">Detail</th><th scope="col">Can improve</th></tr></thead><tbody>${rows}</tbody>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
ctx.fn.buildChrome = () => { buildStrip(); buildTiles(); buildTable(); buildLedger(); };
|
|
129
|
+
|
|
130
|
+
// ---- page text and controls, set up once
|
|
131
|
+
document.title = model.meta.title;
|
|
132
|
+
els.title.textContent = model.meta.title;
|
|
133
|
+
els.help.textContent = model.meta.help;
|
|
134
|
+
els.readat.textContent = model.meta.asOf ? `as of ${model.meta.asOf}` : "";
|
|
135
|
+
els.footer.textContent = model.meta.source;
|
|
136
|
+
buildLegend();
|
|
137
|
+
|
|
138
|
+
const views = model.views ?? [];
|
|
139
|
+
els.modeSwitch.hidden = views.length < 2;
|
|
140
|
+
els.modeSwitch.innerHTML = "";
|
|
141
|
+
for (const v of views) {
|
|
142
|
+
const b = el("button", "seg");
|
|
143
|
+
b.type = "button"; b.setAttribute("role", "tab"); b.dataset.mode = v.id;
|
|
144
|
+
b.setAttribute("aria-selected", String(v.id === ctx.mode));
|
|
145
|
+
b.textContent = v.label;
|
|
146
|
+
on(ctx, b, "click", () => {
|
|
147
|
+
els.modeSwitch.querySelectorAll("button").forEach(x => x.setAttribute("aria-selected", String(x === b)));
|
|
148
|
+
ctx.fn.setMode(v.id);
|
|
149
|
+
});
|
|
150
|
+
els.modeSwitch.appendChild(b);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
on(ctx, els.q, "input", (e: Event) => {
|
|
154
|
+
ctx.query = (e.target as HTMLInputElement).value.trim().toLowerCase();
|
|
155
|
+
ctx.dismissed = false;
|
|
156
|
+
const n = model.nodes.filter(x => visible(x.id) && matches(ctx, x)).length;
|
|
157
|
+
els.matchinfo.textContent = ctx.query ? `${n} group${n === 1 ? "" : "s"} match` : "";
|
|
158
|
+
if (!ctx.selected) { ctx.fn.trace(null); ctx.fn.renderInspector(); }
|
|
159
|
+
});
|
|
160
|
+
on(ctx, els.hops, "change", () => {
|
|
161
|
+
ctx.hops = Number(els.hops.value);
|
|
162
|
+
ctx.fn.trace(ctx.selected);
|
|
163
|
+
});
|
|
164
|
+
on(ctx, els.q, "keydown", (e: KeyboardEvent) => {
|
|
165
|
+
if (e.key !== "Enter" || !ctx.query) return;
|
|
166
|
+
const ids = model.nodes.filter(x => visible(x.id) && matches(ctx, x)).map(x => x.id);
|
|
167
|
+
const r = ctx.fn.rectOf(ids);
|
|
168
|
+
if (r) { ctx.userMoved = true; ctx.fn.fit(true, r, els.inspector.classList.contains("open") ? 340 : 0); ctx.fn.announce(`Zoomed to ${ids.length} matching groups`); }
|
|
169
|
+
});
|
|
170
|
+
on(ctx, els.theme, "click", () => {
|
|
171
|
+
const r = document.documentElement;
|
|
172
|
+
const dark = r.dataset.theme ? r.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
|
|
173
|
+
r.dataset.theme = dark ? "light" : "dark";
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// The canvas controls: view (pan and zoom), pinned headers, pointer, wheel, pinch, keys and the tool strip.
|
|
2
|
+
// The maths lives in viewport.ts; this file only reads and writes the DOM.
|
|
3
|
+
|
|
4
|
+
import { on, el, type Ctx } from "./ctx.ts";
|
|
5
|
+
import { clampPan, fitRect, fitWidth, zoomAt, type Rect, type View } from "./viewport.ts";
|
|
6
|
+
|
|
7
|
+
const HEAD = 44; // height of the pinned header overlay
|
|
8
|
+
|
|
9
|
+
const ICON: Record<string, string> = {
|
|
10
|
+
select: '<path d="M5 3l12 6.5-5.2 1.6L9.5 17z"/>',
|
|
11
|
+
hand: '<path d="M8 11V5.5a1.5 1.5 0 013 0V10m0-5a1.5 1.5 0 013 0v5m0-3.5a1.5 1.5 0 013 0V13c0 4-2.5 7-6.5 7S5 17 5 14l-1.6-3.2a1.4 1.4 0 012.4-1.4L8 12"/>',
|
|
12
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
13
|
+
minus: '<path d="M5 12h14"/>',
|
|
14
|
+
fit: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
|
15
|
+
one: '<path d="M9 8l3-2v12"/><path d="M5 20h14"/>',
|
|
16
|
+
sel: '<circle cx="12" cy="12" r="3.2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/>',
|
|
17
|
+
full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function install(ctx: Ctx): void {
|
|
21
|
+
const { els, model } = ctx;
|
|
22
|
+
const vp = els.vp;
|
|
23
|
+
// The size is cached: reading clientWidth on every wheel event would force a layout of the whole page each time.
|
|
24
|
+
let cached = { w: vp.clientWidth, h: vp.clientHeight };
|
|
25
|
+
const size = () => cached;
|
|
26
|
+
// Same for the viewport rectangle: it is refreshed on resize, scroll and when the pointer enters.
|
|
27
|
+
let vpRect = vp.getBoundingClientRect();
|
|
28
|
+
const refreshRect = () => { vpRect = vp.getBoundingClientRect(); };
|
|
29
|
+
let animTimer = 0;
|
|
30
|
+
let raf = 0;
|
|
31
|
+
|
|
32
|
+
function applyView(): void {
|
|
33
|
+
const v = ctx.view;
|
|
34
|
+
els.world.style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`;
|
|
35
|
+
vp.style.setProperty("--inv", Math.max(1, 0.9 / v.k).toFixed(3));
|
|
36
|
+
vp.style.setProperty("--k", v.k.toFixed(4));
|
|
37
|
+
vp.classList.toggle("lod1", v.k < 0.62);
|
|
38
|
+
vp.classList.toggle("lod2", v.k < 0.4);
|
|
39
|
+
els.zoom.textContent = Math.round(v.k * 100) + "%";
|
|
40
|
+
ctx.fn.stickLabels();
|
|
41
|
+
ctx.fn.placeHeaders();
|
|
42
|
+
if (!raf) raf = requestAnimationFrame(() => { raf = 0; if (document.body.classList.contains("notes")) ctx.fn.placeMarkers(); });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setView(v: View, animate = false): void {
|
|
46
|
+
const c = clampPan(v, ctx.geom.bounds, size());
|
|
47
|
+
ctx.view = [c.x, c.y, c.k].every(Number.isFinite) ? c : { x: 0, y: 0, k: 1 };
|
|
48
|
+
const glide = animate && !ctx.reduced;
|
|
49
|
+
vp.classList.toggle("anim", glide);
|
|
50
|
+
if (glide) { clearTimeout(animTimer); animTimer = window.setTimeout(() => vp.classList.remove("anim"), 330); }
|
|
51
|
+
applyView();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fit(animate = false, rect?: Rect, inset = 0): void {
|
|
55
|
+
const r = rect ?? ctx.geom.bounds;
|
|
56
|
+
if (!(r.w > 0)) return;
|
|
57
|
+
const s = size();
|
|
58
|
+
setView(fitRect(r, { w: s.w - inset, h: s.h }, 28, HEAD + 8), animate);
|
|
59
|
+
}
|
|
60
|
+
function fitW(animate = false): void {
|
|
61
|
+
setView(fitWidth(ctx.geom.bounds, size(), 28, HEAD + 8), animate);
|
|
62
|
+
}
|
|
63
|
+
function zoomBy(f: number): void {
|
|
64
|
+
const s = size();
|
|
65
|
+
ctx.userMoved = true;
|
|
66
|
+
setView(zoomAt(ctx.view, f, s.w / 2, s.h / 2), true);
|
|
67
|
+
ctx.fn.announce("Zoom " + Math.round(ctx.view.k * 100) + " percent");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function placeHeaders(): void {
|
|
71
|
+
const cols = els.cols;
|
|
72
|
+
cols.style.display = model.columns.length ? "" : "none";
|
|
73
|
+
if (!cols.firstChild) {
|
|
74
|
+
model.columns.forEach((c, i) => { const h = el("div", "hd"); h.textContent = (c.short ?? c.title).toUpperCase(); h.title = c.title; h.dataset.i = String(i); cols.appendChild(h); });
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < model.columns.length) {
|
|
77
|
+
const name = model.columns[i]?.phase;
|
|
78
|
+
let j = i;
|
|
79
|
+
while (j + 1 < model.columns.length && model.columns[j + 1]?.phase === name) j++;
|
|
80
|
+
if (name) { const p = el("div", "ph"); p.innerHTML = "<span></span><i></i>"; (p.firstElementChild as HTMLElement).textContent = name; p.dataset.a = String(i); p.dataset.b = String(j); cols.appendChild(p); }
|
|
81
|
+
i = j + 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const v = ctx.view, xs = ctx.geom.xs;
|
|
85
|
+
cols.querySelectorAll<HTMLElement>(".hd").forEach(h => { const i = +(h.dataset.i ?? 0); h.style.left = v.x + (xs[i] ?? 0) * v.k + "px"; h.style.width = Math.max(0, (model.columns[i]?.width ?? 0) * v.k - 6) + "px"; });
|
|
86
|
+
cols.querySelectorAll<HTMLElement>(".ph").forEach(p => {
|
|
87
|
+
const a = +(p.dataset.a ?? 0), b = +(p.dataset.b ?? 0);
|
|
88
|
+
p.style.left = v.x + (xs[a] ?? 0) * v.k + "px";
|
|
89
|
+
p.style.width = Math.max(40, ((xs[b] ?? 0) + (model.columns[b]?.width ?? 0) - (xs[a] ?? 0)) * v.k) + "px";
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stickLabels(): void {
|
|
94
|
+
const wl = (52 - ctx.view.x) / ctx.view.k;
|
|
95
|
+
for (const l of model.lanes) {
|
|
96
|
+
const r = ctx.geom.lanes[l.id], d = ctx.laneEls.get(l.id);
|
|
97
|
+
if (!r || !d?.firstElementChild) continue;
|
|
98
|
+
(d.firstElementChild as HTMLElement).style.left = Math.min(Math.max(12, wl - r.x), Math.max(12, r.w - 300)) + "px";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- pointer: drag the background (or any spot with the hand tool, Space, or the middle button) to pan; pinch to zoom
|
|
103
|
+
const pointers = new Map<number, { x: number; y: number }>();
|
|
104
|
+
let drag: { x: number; y: number; vx: number; vy: number } | null = null;
|
|
105
|
+
let pinch: { d: number; view: View } | null = null;
|
|
106
|
+
|
|
107
|
+
on(ctx, vp, "pointerdown", (e: PointerEvent) => {
|
|
108
|
+
if ((e.target as HTMLElement).closest("#tools, .bandlabel")) return;
|
|
109
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
110
|
+
ctx.moved = 0;
|
|
111
|
+
if (pointers.size === 2) {
|
|
112
|
+
const [a, b] = [...pointers.values()] as [{ x: number; y: number }, { x: number; y: number }];
|
|
113
|
+
pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), view: { ...ctx.view } };
|
|
114
|
+
drag = null;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const onNode = !!(e.target as HTMLElement).closest(".node");
|
|
118
|
+
if (e.button === 1 || ctx.tool === "hand" || ctx.spaceDown || !onNode) {
|
|
119
|
+
drag = { x: e.clientX, y: e.clientY, vx: ctx.view.x, vy: ctx.view.y };
|
|
120
|
+
try { vp.setPointerCapture(e.pointerId); } catch { /* a synthetic pointer cannot be captured */ }
|
|
121
|
+
vp.classList.add("panning");
|
|
122
|
+
if (e.button === 1) e.preventDefault();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
on(ctx, vp, "pointermove", (e: PointerEvent) => {
|
|
126
|
+
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
127
|
+
if (pinch && pointers.size === 2) {
|
|
128
|
+
const [a, b] = [...pointers.values()] as [{ x: number; y: number }, { x: number; y: number }];
|
|
129
|
+
const d = Math.hypot(a.x - b.x, a.y - b.y), r = vpRect;
|
|
130
|
+
ctx.userMoved = true;
|
|
131
|
+
setView(zoomAt(pinch.view, d / pinch.d, (a.x + b.x) / 2 - r.left, (a.y + b.y) / 2 - r.top), false);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!drag) return;
|
|
135
|
+
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
|
136
|
+
ctx.moved = Math.max(ctx.moved, Math.hypot(dx, dy));
|
|
137
|
+
if (ctx.moved > 4) { ctx.userMoved = true; setView({ k: ctx.view.k, x: drag.vx + dx, y: drag.vy + dy }, false); }
|
|
138
|
+
});
|
|
139
|
+
const endDrag = (e: PointerEvent) => {
|
|
140
|
+
pointers.delete(e.pointerId);
|
|
141
|
+
if (pointers.size < 2) pinch = null;
|
|
142
|
+
if (drag) {
|
|
143
|
+
drag = null;
|
|
144
|
+
vp.classList.remove("panning");
|
|
145
|
+
try { vp.releasePointerCapture(e.pointerId); } catch { /* not captured */ }
|
|
146
|
+
window.setTimeout(() => { ctx.moved = 0; }, 0);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
on(ctx, vp, "pointerup", endDrag);
|
|
150
|
+
on(ctx, vp, "pointercancel", endDrag);
|
|
151
|
+
on(ctx, vp, "wheel", (e: WheelEvent) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
const r = vpRect, unit = e.deltaMode === 1 ? 16 : 1;
|
|
154
|
+
ctx.userMoved = true;
|
|
155
|
+
setView(zoomAt(ctx.view, Math.exp(-e.deltaY * unit * (e.ctrlKey ? 0.01 : 0.0012)), e.clientX - r.left, e.clientY - r.top), false);
|
|
156
|
+
}, { passive: false });
|
|
157
|
+
on(ctx, vp, "dblclick", (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".node, .bandlabel, #tools")) { ctx.userMoved = false; fit(true); } });
|
|
158
|
+
on(ctx, vp, "click", (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".node, .bandlabel, #tools") && ctx.moved <= 4) ctx.fn.select(null); });
|
|
159
|
+
|
|
160
|
+
const ro = new ResizeObserver(() => { cached = { w: vp.clientWidth, h: vp.clientHeight }; refreshRect(); if (!ctx.userMoved) fitW(false); else setView(ctx.view, false); });
|
|
161
|
+
ro.observe(vp);
|
|
162
|
+
on(ctx, vp, "pointerenter", refreshRect);
|
|
163
|
+
on(ctx, window, "scroll", refreshRect, { passive: true });
|
|
164
|
+
ctx.disposers.push(() => ro.disconnect());
|
|
165
|
+
|
|
166
|
+
// ---- tools
|
|
167
|
+
function setTool(id: "select" | "hand"): void {
|
|
168
|
+
ctx.tool = id;
|
|
169
|
+
vp.classList.toggle("hand", id === "hand");
|
|
170
|
+
els.tools.querySelectorAll<HTMLElement>("[data-id='select'], [data-id='hand']").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.id === id)));
|
|
171
|
+
ctx.fn.announce(id === "hand" ? "Hand tool: drag to pan" : "Select tool");
|
|
172
|
+
}
|
|
173
|
+
function toggleFull(): void {
|
|
174
|
+
const ws = els.workspace;
|
|
175
|
+
if (document.fullscreenElement) void document.exitFullscreen();
|
|
176
|
+
else if (ws.requestFullscreen) ws.requestFullscreen().catch(() => ws.classList.toggle("fs"));
|
|
177
|
+
else ws.classList.toggle("fs");
|
|
178
|
+
}
|
|
179
|
+
type Tool = [string, string, "tool" | "full" | (() => void)] | ["-"];
|
|
180
|
+
const TOOLS: Tool[] = [
|
|
181
|
+
["select", "Select (V)", "tool"], ["hand", "Hand: drag to pan (H)", "tool"], ["-"],
|
|
182
|
+
["plus", "Zoom in (+)", () => zoomBy(1.25)], ["minus", "Zoom out (-)", () => zoomBy(1 / 1.25)],
|
|
183
|
+
["fit", "Fit to screen (0)", () => { ctx.userMoved = false; fit(true); ctx.fn.announce("Fit to screen"); }],
|
|
184
|
+
["one", "Actual size (1)", () => { const s = size(); ctx.userMoved = true; setView(zoomAt(ctx.view, 1 / ctx.view.k, s.w / 2, s.h / 2), true); }],
|
|
185
|
+
["sel", "Zoom to selection (F)", () => ctx.fn.zoomToPath()], ["-"],
|
|
186
|
+
["full", "Fullscreen", "full"],
|
|
187
|
+
];
|
|
188
|
+
els.tools.innerHTML = "";
|
|
189
|
+
for (const t of TOOLS) {
|
|
190
|
+
if (t[0] === "-") { els.tools.appendChild(el("div", "sep")); continue; }
|
|
191
|
+
const [id, label, act] = t as [string, string, "tool" | "full" | (() => void)];
|
|
192
|
+
const b = el("button", "tb", `<svg viewBox="0 0 24 24" aria-hidden="true">${ICON[id] ?? ""}</svg>`);
|
|
193
|
+
b.type = "button"; b.title = label; b.setAttribute("aria-label", label); b.dataset.id = id;
|
|
194
|
+
if (act === "tool") { b.setAttribute("aria-pressed", String(ctx.tool === id)); on(ctx, b, "click", () => setTool(id as "select" | "hand")); }
|
|
195
|
+
else if (act === "full") on(ctx, b, "click", toggleFull);
|
|
196
|
+
else on(ctx, b, "click", act);
|
|
197
|
+
els.tools.appendChild(b);
|
|
198
|
+
}
|
|
199
|
+
requestAnimationFrame(() => { const r = els.tools.getBoundingClientRect(), v = vp.getBoundingClientRect(); els.zoom.style.top = r.bottom - v.top + 6 + "px"; });
|
|
200
|
+
on(ctx, document, "fullscreenchange", () => window.setTimeout(() => { if (!ctx.userMoved) fitW(false); }, 60));
|
|
201
|
+
|
|
202
|
+
// ---- keys
|
|
203
|
+
on(ctx, document, "keydown", (e: KeyboardEvent) => {
|
|
204
|
+
const typing = !!e.target && /INPUT|TEXTAREA/.test((e.target as HTMLElement).tagName);
|
|
205
|
+
if (typing) { if (e.key === "Escape") (e.target as HTMLElement).blur(); return; }
|
|
206
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return; // never hijack Ctrl+F, Ctrl+N, Ctrl+plus and the like
|
|
207
|
+
if (e.key === " " && document.activeElement === vp) { ctx.spaceDown = true; e.preventDefault(); vp.classList.add("hand"); return; }
|
|
208
|
+
const k = e.key, step = e.shiftKey ? 240 : 60;
|
|
209
|
+
if (k === "/") { e.preventDefault(); els.q.focus(); }
|
|
210
|
+
else if (k === "n" || k === "N") ctx.fn.toggleNotes();
|
|
211
|
+
else if (k === "v" || k === "V") setTool("select");
|
|
212
|
+
else if (k === "h" || k === "H") setTool("hand");
|
|
213
|
+
else if (k === "e" || k === "E") {
|
|
214
|
+
const selected = ctx.selected && ctx.nodes.get(ctx.selected);
|
|
215
|
+
if (selected) {
|
|
216
|
+
const group = selected.kind === "group" ? selected : ctx.model.nodes.find(n => n.kind === "group" && n.contains?.includes(selected.id));
|
|
217
|
+
if (group) ctx.fn.toggleGroup(group.id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (k === "+" || k === "=") zoomBy(1.25);
|
|
221
|
+
else if (k === "-" || k === "_") zoomBy(1 / 1.25);
|
|
222
|
+
else if (k === "0") { ctx.userMoved = false; fit(true); }
|
|
223
|
+
else if (k === "1") { const s = size(); ctx.userMoved = true; setView(zoomAt(ctx.view, 1 / ctx.view.k, s.w / 2, s.h / 2), true); }
|
|
224
|
+
else if (k === "f" || k === "F") ctx.fn.zoomToPath();
|
|
225
|
+
else if (k.startsWith("Arrow") && (document.activeElement === vp || document.activeElement === document.body)) {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
ctx.userMoved = true;
|
|
228
|
+
setView({ k: ctx.view.k, x: ctx.view.x + (k === "ArrowLeft" ? step : k === "ArrowRight" ? -step : 0), y: ctx.view.y + (k === "ArrowUp" ? step : k === "ArrowDown" ? -step : 0) }, false);
|
|
229
|
+
} else if (k === "Escape") ctx.fn.escape();
|
|
230
|
+
});
|
|
231
|
+
on(ctx, document, "keyup", (e: KeyboardEvent) => { if (e.key === " ") { ctx.spaceDown = false; if (ctx.tool !== "hand") vp.classList.remove("hand"); } });
|
|
232
|
+
|
|
233
|
+
ctx.fn.setView = setView;
|
|
234
|
+
ctx.fn.fit = fit;
|
|
235
|
+
ctx.fn.fitWidth = fitW;
|
|
236
|
+
ctx.fn.zoomBy = zoomBy;
|
|
237
|
+
ctx.fn.stickLabels = stickLabels;
|
|
238
|
+
ctx.fn.placeHeaders = placeHeaders;
|
|
239
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The engine's shared state. Each DOM module receives one Ctx, reads and writes it, and registers
|
|
2
|
+
// its functions on ctx.fn, so modules call each other without importing each other.
|
|
3
|
+
|
|
4
|
+
import type { Model, NodeDef, LinkDef, NoteDef } from "./model.ts";
|
|
5
|
+
import type { View, Rect } from "./viewport.ts";
|
|
6
|
+
import type { Geometry } from "./layout.ts";
|
|
7
|
+
|
|
8
|
+
export interface Els {
|
|
9
|
+
vp: HTMLElement;
|
|
10
|
+
world: HTMLElement;
|
|
11
|
+
svg: SVGSVGElement;
|
|
12
|
+
layer: SVGGElement;
|
|
13
|
+
cols: HTMLElement;
|
|
14
|
+
tools: HTMLElement;
|
|
15
|
+
zoom: HTMLElement;
|
|
16
|
+
inspector: HTMLElement;
|
|
17
|
+
workspace: HTMLElement;
|
|
18
|
+
live: HTMLElement;
|
|
19
|
+
strip: HTMLElement;
|
|
20
|
+
tiles: HTMLElement;
|
|
21
|
+
legend: HTMLElement;
|
|
22
|
+
ledger: HTMLElement;
|
|
23
|
+
table: HTMLElement;
|
|
24
|
+
q: HTMLInputElement;
|
|
25
|
+
hops: HTMLSelectElement;
|
|
26
|
+
matchinfo: HTMLElement;
|
|
27
|
+
modeSwitch: HTMLElement;
|
|
28
|
+
drawer: HTMLElement;
|
|
29
|
+
notelist: HTMLElement;
|
|
30
|
+
notesBtn: HTMLElement;
|
|
31
|
+
theme: HTMLElement;
|
|
32
|
+
specbody: HTMLElement;
|
|
33
|
+
spec: HTMLElement;
|
|
34
|
+
title: HTMLElement;
|
|
35
|
+
readat: HTMLElement;
|
|
36
|
+
help: HTMLElement;
|
|
37
|
+
footer: HTMLElement;
|
|
38
|
+
playbar: HTMLElement;
|
|
39
|
+
pbScenario: HTMLSelectElement;
|
|
40
|
+
pbPlay: HTMLButtonElement;
|
|
41
|
+
pbStep: HTMLButtonElement;
|
|
42
|
+
pbReset: HTMLButtonElement;
|
|
43
|
+
pbSpeed: HTMLSelectElement;
|
|
44
|
+
pbScrub: HTMLInputElement;
|
|
45
|
+
pbCaption: HTMLElement;
|
|
46
|
+
pbCounter: HTMLElement;
|
|
47
|
+
pbChoices: HTMLElement;
|
|
48
|
+
banner: HTMLElement;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type Filter = "gap" | "upgrade" | null;
|
|
52
|
+
|
|
53
|
+
export interface Fns {
|
|
54
|
+
paint(fromSwitch?: boolean): void;
|
|
55
|
+
buildWorld(): void;
|
|
56
|
+
trace(id: string | null): void;
|
|
57
|
+
select(id: string | null): void;
|
|
58
|
+
renderInspector(): void;
|
|
59
|
+
setView(v: View, animate?: boolean): void;
|
|
60
|
+
fit(animate?: boolean, rect?: Rect, inset?: number): void;
|
|
61
|
+
fitWidth(animate?: boolean): void;
|
|
62
|
+
zoomBy(factor: number): void;
|
|
63
|
+
zoomToPath(): void;
|
|
64
|
+
zoomToBand(id: string): void;
|
|
65
|
+
rectOf(ids: string[]): Rect | null;
|
|
66
|
+
drawTrail(): void;
|
|
67
|
+
simulate(actionId: string): void;
|
|
68
|
+
clearSim(): void;
|
|
69
|
+
announce(text: string): void;
|
|
70
|
+
stickLabels(): void;
|
|
71
|
+
placeHeaders(): void;
|
|
72
|
+
buildChrome(): void;
|
|
73
|
+
toggleNotes(on?: boolean): void;
|
|
74
|
+
placeMarkers(): void;
|
|
75
|
+
escape(): void;
|
|
76
|
+
setMode(id: string): void;
|
|
77
|
+
toggleGroup(id: string): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Ctx {
|
|
81
|
+
model: Model;
|
|
82
|
+
root: ParentNode;
|
|
83
|
+
els: Els;
|
|
84
|
+
onSelect?: (id: string | null) => void;
|
|
85
|
+
notes: NoteDef[];
|
|
86
|
+
nodes: Map<string, NodeDef>;
|
|
87
|
+
links: LinkDef[];
|
|
88
|
+
geom: Geometry;
|
|
89
|
+
view: View;
|
|
90
|
+
reduced: boolean;
|
|
91
|
+
mode: string;
|
|
92
|
+
collapsed: Set<string>;
|
|
93
|
+
selected: string | null;
|
|
94
|
+
hl: string | null;
|
|
95
|
+
query: string;
|
|
96
|
+
hops: number;
|
|
97
|
+
filter: Filter;
|
|
98
|
+
tool: "select" | "hand";
|
|
99
|
+
spaceDown: boolean;
|
|
100
|
+
userMoved: boolean;
|
|
101
|
+
dismissed: boolean;
|
|
102
|
+
simActive: boolean;
|
|
103
|
+
moved: number;
|
|
104
|
+
nodeEls: Map<string, HTMLElement>;
|
|
105
|
+
laneEls: Map<string, HTMLElement>;
|
|
106
|
+
edgeEls: SVGPathElement[];
|
|
107
|
+
labelEls: HTMLElement[];
|
|
108
|
+
overlays: SVGPathElement[];
|
|
109
|
+
simTimers: number[];
|
|
110
|
+
disposers: Array<() => void>;
|
|
111
|
+
fn: Fns;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const SVG_NS = "http://www.w3.org/2000/svg";
|
|
115
|
+
|
|
116
|
+
/** Make an element with a class and (trusted, already escaped) inner HTML. */
|
|
117
|
+
export function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string): HTMLElementTagNameMap[K] {
|
|
118
|
+
const e = document.createElement(tag);
|
|
119
|
+
if (cls) e.className = cls;
|
|
120
|
+
if (html !== undefined) e.innerHTML = html;
|
|
121
|
+
return e;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function sv(tag: string, attrs: Record<string, string>): SVGElement {
|
|
125
|
+
const e = document.createElementNS(SVG_NS, tag);
|
|
126
|
+
for (const k of Object.keys(attrs)) e.setAttribute(k, attrs[k] as string);
|
|
127
|
+
return e;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Listen on a target and remember how to stop, so destroy() removes every listener. */
|
|
131
|
+
export function on<T extends EventTarget>(ctx: Ctx, target: T, type: string, fn: (e: any) => void, opts?: AddEventListenerOptions): void {
|
|
132
|
+
target.addEventListener(type, fn, opts);
|
|
133
|
+
ctx.disposers.push(() => target.removeEventListener(type, fn, opts));
|
|
134
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { diffModels } from "./diff.ts";
|
|
4
|
+
import type { Model } from "./model.ts";
|
|
5
|
+
|
|
6
|
+
const base = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", sla: 30 }, { id: "b", state: "ok", title: "B", kind: "card" }] } as Model;
|
|
7
|
+
test("diff reports added, removed, and specified field changes", () => {
|
|
8
|
+
const next = { nodes: [{ ...base.nodes[0], metrics: { value: 2 } }, { id: "c", state: "ok", title: "C", kind: "card" }] } as Model;
|
|
9
|
+
assert.deepEqual(diffModels(base, next), { added: ["c"], removed: ["b"], changed: ["a"] });
|
|
10
|
+
assert.deepEqual(diffModels(base, structuredClone(base)), { added: [], removed: [], changed: [] });
|
|
11
|
+
});
|
|
12
|
+
test("diff detects change in sla", () => {
|
|
13
|
+
const next = { nodes: [{ ...base.nodes[0], sla: 60 }, base.nodes[1]] } as Model;
|
|
14
|
+
assert.deepEqual(diffModels(base, next), { added: [], removed: [], changed: ["a"] });
|
|
15
|
+
});
|
|
16
|
+
test("diff is invariant to key order in objects", () => {
|
|
17
|
+
const m1 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 10, unit: "ms", label: "latency" } }] } as Model;
|
|
18
|
+
const m2 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { label: "latency", unit: "ms", value: 10 } }] } as Model;
|
|
19
|
+
assert.deepEqual(diffModels(m1, m2), { added: [], removed: [], changed: [] });
|
|
20
|
+
});
|
|
21
|
+
test("an undefined-valued property counts as absent (a model comes from JSON, which cannot hold one)", () => {
|
|
22
|
+
const m1 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 1, unit: undefined } }] } as unknown as Model;
|
|
23
|
+
const m2 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 1 } }] } as Model;
|
|
24
|
+
assert.deepEqual(diffModels(m1, m2), { added: [], removed: [], changed: [] });
|
|
25
|
+
});
|
|
26
|
+
test("null, undefined and NaN stay distinct values in a diff", () => {
|
|
27
|
+
const mk = (metrics: unknown) => ({ nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics }] }) as unknown as Model;
|
|
28
|
+
assert.deepEqual(diffModels(mk({ value: 1, series: [null] }), mk({ value: 1, series: [undefined] })).changed, ["a"]);
|
|
29
|
+
assert.deepEqual(diffModels(mk({ value: NaN }), mk({ value: null })).changed, ["a"]);
|
|
30
|
+
assert.deepEqual(diffModels(mk({ value: NaN }), mk({ value: NaN })).changed, []);
|
|
31
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Model, NodeDef } from "./model.ts";
|
|
2
|
+
|
|
3
|
+
const fields: Array<keyof NodeDef> = ["state", "stateIn", "title", "subtitle", "weight", "metrics", "visibleIn", "lane", "sla"];
|
|
4
|
+
|
|
5
|
+
// A stable serializer for the diff. Unlike JSON.stringify it never returns undefined, and values
|
|
6
|
+
// JSON cannot hold stay distinct: undefined, NaN and Infinity each get their own text, so a real
|
|
7
|
+
// change between them cannot be hidden by "null" conflation.
|
|
8
|
+
function stableStringify(val: unknown): string {
|
|
9
|
+
if (val === undefined) return "undefined";
|
|
10
|
+
if (val === null) return "null";
|
|
11
|
+
if (typeof val === "number") return Number.isFinite(val) ? JSON.stringify(val) : String(val);
|
|
12
|
+
if (typeof val === "bigint") return String(val);
|
|
13
|
+
if (typeof val === "object") {
|
|
14
|
+
if (Array.isArray(val)) return `[${val.map(stableStringify).join(",")}]`;
|
|
15
|
+
// A model comes from JSON, which cannot hold an undefined property, so an explicit one counts as absent.
|
|
16
|
+
const rec = val as Record<string, unknown>;
|
|
17
|
+
const keys = Object.keys(rec).filter(k => rec[k] !== undefined).sort();
|
|
18
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(rec[k])}`).join(",")}}`;
|
|
19
|
+
}
|
|
20
|
+
return JSON.stringify(val) ?? "undefined";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function diffModels(a: Model, b: Model): { added: string[]; removed: string[]; changed: string[] } {
|
|
24
|
+
const old = new Map(a.nodes.map(n => [n.id, n]));
|
|
25
|
+
const now = new Map(b.nodes.map(n => [n.id, n]));
|
|
26
|
+
return {
|
|
27
|
+
added: b.nodes.filter(n => !old.has(n.id)).map(n => n.id),
|
|
28
|
+
removed: a.nodes.filter(n => !now.has(n.id)).map(n => n.id),
|
|
29
|
+
changed: b.nodes.filter(n => {
|
|
30
|
+
const prev = old.get(n.id);
|
|
31
|
+
return prev && fields.some(k => stableStringify(prev[k]) !== stableStringify(n[k]));
|
|
32
|
+
}).map(n => n.id),
|
|
33
|
+
};
|
|
34
|
+
}
|