@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,193 @@
|
|
|
1
|
+
import { on, sv, type Ctx } from "./ctx.ts";
|
|
2
|
+
import { buildManualRoute, extendScenarioWithRoute, exitsOf, formatElapsed, planScenario, positionAt, slaState, suggestedExit, type Plan, type PlannedStep } from "./playback.ts";
|
|
3
|
+
import type { ScenarioDef } from "./model.ts";
|
|
4
|
+
|
|
5
|
+
export interface PlaybackController {
|
|
6
|
+
play(id?: string): void;
|
|
7
|
+
pause(): void;
|
|
8
|
+
step(): void;
|
|
9
|
+
seek(t: number): void;
|
|
10
|
+
playback(): { scenario: string | null; step: number; playing: boolean; t: number };
|
|
11
|
+
restore(state: ReturnType<PlaybackController["playback"]>): void;
|
|
12
|
+
reset(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function install(ctx: Ctx): PlaybackController {
|
|
16
|
+
const e = ctx.els;
|
|
17
|
+
const scenarios = ctx.model.scenarios ?? [];
|
|
18
|
+
e.playbar.hidden = !scenarios.length;
|
|
19
|
+
e.pbScenario.replaceChildren(...scenarios.map(s => { const o = document.createElement("option"); o.value = s.id; o.textContent = s.label; return o; }));
|
|
20
|
+
let scenario: ScenarioDef | undefined = scenarios[0];
|
|
21
|
+
let plan: Plan = scenario ? planScenario(ctx.model, scenario) : { steps: [], mode: "none", totalMs: 0 };
|
|
22
|
+
let t = 0, idx = -1, playing = false, frame = 0, timer = 0, stamp = 0, choicesKey = "";
|
|
23
|
+
// Only the elements marked in the last draw are cleared again, so a frame does not walk the whole graph.
|
|
24
|
+
let markedEdges: SVGPathElement[] = [];
|
|
25
|
+
let markedNodes: HTMLElement[] = [];
|
|
26
|
+
const token = sv("circle", { id: "token", class: "token", r: "6" }) as SVGCircleElement;
|
|
27
|
+
e.layer.appendChild(token);
|
|
28
|
+
const pathOf = (link: string): SVGPathElement | undefined => ctx.edgeEls[ctx.links.findIndex(l => l.id === link)];
|
|
29
|
+
const nodeOf = (id: string) => ctx.nodeEls.get(id);
|
|
30
|
+
const clearMarks = () => {
|
|
31
|
+
for (const p of markedEdges) p.classList.remove("playing", "visited");
|
|
32
|
+
for (const n of markedNodes) n.classList.remove("playing", "visited");
|
|
33
|
+
markedEdges = []; markedNodes = [];
|
|
34
|
+
};
|
|
35
|
+
function stop(): void {
|
|
36
|
+
playing = false; cancelAnimationFrame(frame); clearTimeout(timer);
|
|
37
|
+
document.body.classList.remove("playing");
|
|
38
|
+
e.pbPlay.setAttribute("aria-label", "Play"); e.pbPlay.textContent = "▶";
|
|
39
|
+
}
|
|
40
|
+
function pause(): void {
|
|
41
|
+
if (!playing) return;
|
|
42
|
+
stop();
|
|
43
|
+
// Redraw with playing false, so the branch buttons of the step we paused on come back.
|
|
44
|
+
const p = positionAt(plan, t);
|
|
45
|
+
draw(p.stepIndex, ctx.reduced ? 1 : p.progress);
|
|
46
|
+
}
|
|
47
|
+
function choices(step: PlannedStep | undefined): void {
|
|
48
|
+
if (playing || !step) {
|
|
49
|
+
// Hidden while playing; clear once, then leave the container alone for the rest of the frames.
|
|
50
|
+
if (choicesKey !== "") { e.pbChoices.replaceChildren(); choicesKey = ""; }
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const key = `${scenario?.id ?? ""}|${idx}|${plan.steps.length}|${plan.steps[idx + 1]?.link ?? ""}`;
|
|
54
|
+
if (key === choicesKey) return;
|
|
55
|
+
choicesKey = key;
|
|
56
|
+
e.pbChoices.replaceChildren();
|
|
57
|
+
const exits = exitsOf(ctx.model, step.to);
|
|
58
|
+
if (exits.length < 2) return;
|
|
59
|
+
const suggested = suggestedExit(exits, plan.steps[idx + 1]?.link);
|
|
60
|
+
for (const link of exits) {
|
|
61
|
+
const b = document.createElement("button"); b.type = "button";
|
|
62
|
+
b.textContent = link.label ?? ctx.nodes.get(link.to)?.title ?? link.to;
|
|
63
|
+
b.dataset.link = link.id;
|
|
64
|
+
if (link.id === suggested?.id) b.classList.add("suggested");
|
|
65
|
+
b.addEventListener("click", () => {
|
|
66
|
+
// A plan step exists only when a scenario does, so scenario is set here.
|
|
67
|
+
if (!scenario) return;
|
|
68
|
+
const route = buildManualRoute(ctx.model, link);
|
|
69
|
+
scenario = extendScenarioWithRoute(scenario, plan, idx, route);
|
|
70
|
+
plan = planScenario(ctx.model, scenario);
|
|
71
|
+
t = plan.steps[idx + 1]?.at ?? 0;
|
|
72
|
+
play();
|
|
73
|
+
});
|
|
74
|
+
e.pbChoices.appendChild(b);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function draw(stepIndex: number, progress: number): void {
|
|
78
|
+
const step = plan.steps[stepIndex];
|
|
79
|
+
clearMarks();
|
|
80
|
+
if (!step) { token.style.display = "none"; return; }
|
|
81
|
+
token.style.display = "";
|
|
82
|
+
for (let i = 0; i < stepIndex; i++) {
|
|
83
|
+
const s = plan.steps[i]!;
|
|
84
|
+
const p = pathOf(s.link);
|
|
85
|
+
if (p) { p.classList.add("visited"); markedEdges.push(p); }
|
|
86
|
+
const n = nodeOf(s.to);
|
|
87
|
+
if (n) { n.classList.add("visited"); markedNodes.push(n); }
|
|
88
|
+
}
|
|
89
|
+
const path = pathOf(step.link);
|
|
90
|
+
if (path) { path.classList.add("playing"); markedEdges.push(path); }
|
|
91
|
+
const node = nodeOf(step.to);
|
|
92
|
+
if (node) { node.classList.add("playing"); markedNodes.push(node); }
|
|
93
|
+
if (path && !path.classList.contains("off")) {
|
|
94
|
+
const p = path.getPointAtLength(path.getTotalLength() * progress);
|
|
95
|
+
token.setAttribute("cx", String(p.x)); token.setAttribute("cy", String(p.y));
|
|
96
|
+
} else token.style.display = "none";
|
|
97
|
+
e.pbCaption.textContent = step.caption;
|
|
98
|
+
e.pbScrub.value = String(plan.totalMs ? Math.round(t / plan.totalMs * 1000) : 0);
|
|
99
|
+
e.pbScrub.setAttribute("aria-valuetext", `Step ${stepIndex + 1} of ${plan.steps.length}: ${step.caption}`);
|
|
100
|
+
e.pbCounter.className = "";
|
|
101
|
+
e.pbCounter.hidden = plan.mode === "none";
|
|
102
|
+
if (plan.mode === "elapsed") {
|
|
103
|
+
const sec = scenario?.steps[step.index]?.at ?? 0;
|
|
104
|
+
const sla = ctx.nodes.get(step.to)?.sla;
|
|
105
|
+
e.pbCounter.textContent = sla === undefined ? formatElapsed(sec) : `${formatElapsed(sec)} / ${formatElapsed(sla)}`;
|
|
106
|
+
if (sla !== undefined) e.pbCounter.classList.add(slaState(sec, sla));
|
|
107
|
+
}
|
|
108
|
+
if (idx !== stepIndex) ctx.fn.announce(step.caption);
|
|
109
|
+
idx = stepIndex;
|
|
110
|
+
choices(step);
|
|
111
|
+
}
|
|
112
|
+
function rewind(): void {
|
|
113
|
+
stop(); t = 0; idx = -1; clearMarks(); choicesKey = "";
|
|
114
|
+
e.pbCaption.textContent = "";
|
|
115
|
+
e.pbCounter.hidden = true; e.pbCounter.className = "";
|
|
116
|
+
e.pbChoices.replaceChildren();
|
|
117
|
+
token.style.display = "none";
|
|
118
|
+
e.pbScrub.value = "0"; e.pbScrub.setAttribute("aria-valuetext", "");
|
|
119
|
+
}
|
|
120
|
+
function select(id: string): void {
|
|
121
|
+
scenario = scenarios.find(s => s.id === id) ?? scenarios[0];
|
|
122
|
+
plan = scenario ? planScenario(ctx.model, scenario) : { steps: [], mode: "none", totalMs: 0 };
|
|
123
|
+
e.pbScenario.value = scenario?.id ?? "";
|
|
124
|
+
rewind();
|
|
125
|
+
}
|
|
126
|
+
function seek(value: number): void {
|
|
127
|
+
stop(); t = Math.max(0, Math.min(plan.totalMs, Number.isFinite(value) ? value : 0));
|
|
128
|
+
const p = positionAt(plan, t); draw(p.stepIndex, ctx.reduced ? 1 : p.progress);
|
|
129
|
+
}
|
|
130
|
+
function tick(now: number): void {
|
|
131
|
+
if (!playing) return;
|
|
132
|
+
if (!stamp) stamp = now;
|
|
133
|
+
const delta = (now - stamp) * Number(e.pbSpeed.value || 1); stamp = now;
|
|
134
|
+
t = Math.min(plan.totalMs, t + delta);
|
|
135
|
+
const p = positionAt(plan, t);
|
|
136
|
+
if (p.done) { stop(); draw(p.stepIndex, 1); return; }
|
|
137
|
+
draw(p.stepIndex, p.progress);
|
|
138
|
+
frame = requestAnimationFrame(tick);
|
|
139
|
+
}
|
|
140
|
+
function reducedStep(): void {
|
|
141
|
+
if (!playing) return;
|
|
142
|
+
const next = Math.min(plan.steps.length - 1, idx + 1);
|
|
143
|
+
if (next < 0) { stop(); return; }
|
|
144
|
+
t = plan.steps[next]!.at + plan.steps[next]!.dur;
|
|
145
|
+
// Stop before the last draw so the branch buttons of the final step are built.
|
|
146
|
+
if (next === plan.steps.length - 1) stop();
|
|
147
|
+
draw(next, 1);
|
|
148
|
+
if (playing) timer = window.setTimeout(reducedStep, 1000 / Number(e.pbSpeed.value || 1));
|
|
149
|
+
}
|
|
150
|
+
function play(id?: string): void {
|
|
151
|
+
if (id && id !== scenario?.id) select(id);
|
|
152
|
+
// Cancel any loop already running, or two frames would advance t together.
|
|
153
|
+
stop();
|
|
154
|
+
if (!plan.steps.length) return;
|
|
155
|
+
if (t >= plan.totalMs) { t = 0; idx = -1; }
|
|
156
|
+
playing = true;
|
|
157
|
+
document.body.classList.add("playing");
|
|
158
|
+
e.pbPlay.setAttribute("aria-label", "Pause"); e.pbPlay.textContent = "❚❚";
|
|
159
|
+
e.pbChoices.replaceChildren(); choicesKey = "";
|
|
160
|
+
if (ctx.reduced) reducedStep(); else { stamp = 0; frame = requestAnimationFrame(tick); }
|
|
161
|
+
}
|
|
162
|
+
function step(): void {
|
|
163
|
+
stop();
|
|
164
|
+
const next = Math.min(idx + 1, plan.steps.length - 1);
|
|
165
|
+
if (next < 0) return;
|
|
166
|
+
t = plan.steps[next]!.at + plan.steps[next]!.dur;
|
|
167
|
+
draw(next, 1);
|
|
168
|
+
}
|
|
169
|
+
on(ctx, e.pbScenario, "change", () => select(e.pbScenario.value));
|
|
170
|
+
on(ctx, e.pbPlay, "click", () => playing ? pause() : play());
|
|
171
|
+
on(ctx, e.pbStep, "click", step);
|
|
172
|
+
// Reset rewinds the current plan (a manual branch stays); picking from the scenario list starts over.
|
|
173
|
+
on(ctx, e.pbReset, "click", () => rewind());
|
|
174
|
+
on(ctx, e.pbScrub, "input", () => seek(Number(e.pbScrub.value) / 1000 * plan.totalMs));
|
|
175
|
+
on(ctx, document, "keydown", (event: KeyboardEvent) => {
|
|
176
|
+
if (event.ctrlKey || event.metaKey || event.altKey || (event.target instanceof HTMLElement && (event.target.isContentEditable || /INPUT|TEXTAREA|SELECT/.test(event.target.tagName)))) return;
|
|
177
|
+
if (event.key.toLowerCase() === "p") { event.preventDefault(); playing ? pause() : play(); }
|
|
178
|
+
if (event.key === ".") { event.preventDefault(); step(); }
|
|
179
|
+
});
|
|
180
|
+
ctx.disposers.push(() => { stop(); token.remove(); });
|
|
181
|
+
select(scenario?.id ?? "");
|
|
182
|
+
return { play, pause, step, seek, playback: () => ({ scenario: scenario?.id ?? null, step: idx, playing, t }),
|
|
183
|
+
restore: state => {
|
|
184
|
+
if (!state.scenario || !scenarios.some(s => s.id === state.scenario)) return;
|
|
185
|
+
select(state.scenario);
|
|
186
|
+
const current = plan.steps[state.step];
|
|
187
|
+
if (!current) return;
|
|
188
|
+
t = Math.max(current.at, Math.min(current.at + current.dur, state.t));
|
|
189
|
+
draw(state.step, ctx.reduced ? 1 : current.dur > 0 ? (t - current.at) / current.dur : 1);
|
|
190
|
+
if (state.playing) play();
|
|
191
|
+
},
|
|
192
|
+
reset: () => rewind() };
|
|
193
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Builds the world (lanes, nodes, links) once, and paints it for the current view.
|
|
2
|
+
// Every model string goes through esc(), so a hostile title cannot become markup.
|
|
3
|
+
|
|
4
|
+
import { el, sv, on, type Ctx } from "./ctx.ts";
|
|
5
|
+
import { esc } from "./model.ts";
|
|
6
|
+
import type { NodeDef } from "./model.ts";
|
|
7
|
+
import { computeGeometry, DEFAULTS, type Placed } from "./layout.ts";
|
|
8
|
+
import { elbow, loopBack } from "./routing.ts";
|
|
9
|
+
import { cssId } from "./stateCss.ts";
|
|
10
|
+
import { baselineView, bucketOf, canonOf, effectiveLinks, isImproved, isNew, linkStateId, nodeStateId, nodeVisible } from "./rules.ts";
|
|
11
|
+
import { metricBadge, sparkline } from "./metrics.ts";
|
|
12
|
+
|
|
13
|
+
function loopRoute(ctx: Ctx, fromId: string, toId: string, from: Placed, to: Placed, channel: number): string {
|
|
14
|
+
const detourFor = (end: Placed, other: Placed) => {
|
|
15
|
+
const center = end.x + end.w / 2, bottom = end.y + end.h;
|
|
16
|
+
const blocker = ctx.model.nodes.filter(n => n.id !== fromId && n.id !== toId && nodeVisible(ctx.model, ctx.mode, n, ctx.collapsed))
|
|
17
|
+
.map(n => ctx.geom.nodes[n.id]).filter((p): p is Placed => !!p && p.x < center && center < p.x + p.w && p.y > bottom && p.y < channel)
|
|
18
|
+
.sort((a, b) => a.y - b.y)[0];
|
|
19
|
+
if (!blocker) return undefined;
|
|
20
|
+
return { x: other.x + other.w / 2 < center ? blocker.x - 10 : blocker.x + blocker.w + 10, y: (bottom + blocker.y) / 2 };
|
|
21
|
+
};
|
|
22
|
+
return loopBack(from.x + from.w / 2, from.y + from.h, to.x + to.w / 2, to.y + to.h, channel,
|
|
23
|
+
{ from: detourFor(from, to), to: detourFor(to, from) });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nodeHtml(ctx: Ctx, n: NodeDef): string {
|
|
27
|
+
const badge = metricBadge(n.metrics);
|
|
28
|
+
const spark = sparkline(n.metrics, 56, 12);
|
|
29
|
+
if (n.kind === "gateway") {
|
|
30
|
+
return `<svg class="gw" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"><polygon points="50,0 100,50 50,100 0,50"></polygon></svg><span class="gw-title">${esc(n.title)}</span>${badge}${spark}`;
|
|
31
|
+
}
|
|
32
|
+
const m = ctx.model;
|
|
33
|
+
const sid = nodeStateId(m, ctx.mode, n);
|
|
34
|
+
const word = m.states[sid]?.word ?? sid;
|
|
35
|
+
const bucket = bucketOf(m, sid);
|
|
36
|
+
const sub = n.subtitle ?? m.columns[n.col ?? 0]?.title ?? "";
|
|
37
|
+
const weight = Math.max(1, n.weight ?? 1);
|
|
38
|
+
const showWord = n.kind !== "placeholder" && n.kind !== "note";
|
|
39
|
+
const bar = weight > 1 && n.kind !== "chip"
|
|
40
|
+
? `<div class="bar"><i class="${["good", "neutral", "gap"].includes(bucket) ? bucket : "gap"}" style="width:100%"></i></div>`
|
|
41
|
+
: "";
|
|
42
|
+
const improved = isImproved(m, ctx.mode, n) ? '<span class="cor up" title="Gap filled in this view">▲</span>' : "";
|
|
43
|
+
const upgrade = n.upgrade && bucket !== "good" ? `<span class="cor meas" title="${esc("Can improve: " + n.upgrade)}">↗</span>` : "";
|
|
44
|
+
const arrow = n.kind === "chip" ? '<span aria-hidden="true">↑ </span>' : "";
|
|
45
|
+
const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> · ` : "";
|
|
46
|
+
const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">▸ ${n.contains.length}</span>` : "";
|
|
47
|
+
return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div>`
|
|
48
|
+
+ `<div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function build(ctx: Ctx): void {
|
|
52
|
+
const { model, els } = ctx;
|
|
53
|
+
ctx.geom = computeGeometry(model);
|
|
54
|
+
els.world.style.width = ctx.geom.world.w + "px";
|
|
55
|
+
els.world.style.height = ctx.geom.world.h + "px";
|
|
56
|
+
els.svg.setAttribute("width", String(ctx.geom.world.w));
|
|
57
|
+
els.svg.setAttribute("height", String(ctx.geom.world.h));
|
|
58
|
+
els.world.querySelectorAll(".node, .band, .elabel").forEach(n => n.remove());
|
|
59
|
+
els.layer.innerHTML = "";
|
|
60
|
+
ctx.nodeEls.clear(); ctx.laneEls.clear(); ctx.edgeEls = []; ctx.labelEls = [];
|
|
61
|
+
|
|
62
|
+
for (const l of model.lanes) {
|
|
63
|
+
const d = el("div", "band" + (l.gap ? " gap" : ""));
|
|
64
|
+
d.id = "lane_" + l.id;
|
|
65
|
+
const badge = l.badge ? `<span class="bd t-${["ok", "warn", "muted"].includes(l.badgeTone ?? "") ? l.badgeTone : "muted"}">${esc(l.badge)}</span>` : "";
|
|
66
|
+
const note = l.note ? `<span class="bn">${esc(l.note)}</span>` : "";
|
|
67
|
+
d.innerHTML = `<div class="bandlabel" role="button" tabindex="0" aria-label="${esc("Zoom to the lane " + l.title)}"><b>${esc(l.title)}</b>${badge}${note}</div>`;
|
|
68
|
+
const label = d.firstElementChild as HTMLElement;
|
|
69
|
+
on(ctx, label, "click", (e: Event) => { e.stopPropagation(); ctx.fn.zoomToBand(l.id); });
|
|
70
|
+
on(ctx, label, "keydown", (e: KeyboardEvent) => { if (e.key === "Enter") ctx.fn.zoomToBand(l.id); });
|
|
71
|
+
els.world.insertBefore(d, els.svg.parentElement === els.world ? els.svg : null);
|
|
72
|
+
ctx.laneEls.set(l.id, d);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const n of model.nodes) {
|
|
76
|
+
const p = ctx.geom.nodes[n.id];
|
|
77
|
+
if (!p) continue;
|
|
78
|
+
const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
|
|
79
|
+
d.id = "n_" + n.id;
|
|
80
|
+
d.tabIndex = 0;
|
|
81
|
+
d.setAttribute("role", "button");
|
|
82
|
+
d.style.setProperty("--c", String(n.col ?? 0));
|
|
83
|
+
d.style.left = p.x + "px"; d.style.top = p.y + "px"; d.style.width = p.w + "px"; d.style.height = p.h + "px";
|
|
84
|
+
on(ctx, d, "click", () => { if (ctx.moved > 4) return; ctx.fn.select(canonOf(ctx.nodes, n.id)); });
|
|
85
|
+
on(ctx, d, "dblclick", (e: Event) => { e.stopPropagation(); ctx.fn.select(canonOf(ctx.nodes, n.id)); ctx.fn.zoomToPath(); });
|
|
86
|
+
on(ctx, d, "keydown", (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); ctx.fn.select(canonOf(ctx.nodes, n.id)); } });
|
|
87
|
+
on(ctx, d, "mouseenter", () => ctx.fn.trace(n.id));
|
|
88
|
+
on(ctx, d, "mouseleave", () => ctx.fn.trace(ctx.selected));
|
|
89
|
+
on(ctx, d, "focus", () => ctx.fn.trace(n.id));
|
|
90
|
+
on(ctx, d, "blur", () => ctx.fn.trace(ctx.selected));
|
|
91
|
+
els.world.appendChild(d);
|
|
92
|
+
ctx.nodeEls.set(n.id, d);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const gap = DEFAULTS.gap;
|
|
96
|
+
ctx.links.forEach((l, i) => {
|
|
97
|
+
const A = ctx.geom.nodes[l.from], B = ctx.geom.nodes[l.to];
|
|
98
|
+
if (!A || !B) { console.warn(`flow-studio: link ${l.id} skipped (an end is missing)`); return; }
|
|
99
|
+
const x1 = A.x + A.w, y1 = A.y + A.h / 2, x2 = B.x, y2 = B.y + B.h / 2, tx = x1 + gap / 2;
|
|
100
|
+
const loop = l.kind === "loop-back";
|
|
101
|
+
const channel = ctx.geom.loops[l.id]?.channelY ?? Math.max(A.y + A.h, B.y + B.h) + 10;
|
|
102
|
+
const d = loop
|
|
103
|
+
? loopRoute(ctx, l.from, l.to, A, B, channel)
|
|
104
|
+
: elbow(x1, y1, x2 - 1, y2, tx);
|
|
105
|
+
const path = sv("path", { d, class: "edge" + (loop ? " loop" : ""), "data-i": String(i), "data-a": l.from, "data-b": l.to, "marker-end": "url(#arr)" }) as SVGPathElement;
|
|
106
|
+
els.layer.appendChild(path);
|
|
107
|
+
ctx.edgeEls[i] = path;
|
|
108
|
+
if (l.label) {
|
|
109
|
+
const lab = el("div", "elabel", esc(l.label));
|
|
110
|
+
lab.dataset.i = String(i); lab.dataset.a = l.from; lab.dataset.b = l.to;
|
|
111
|
+
lab.style.left = (loop ? (A.x + A.w / 2 + B.x + B.w / 2) / 2 : Math.abs(y2 - y1) < 1 ? (x1 + x2) / 2 : tx) + "px";
|
|
112
|
+
lab.style.top = (loop ? ctx.geom.loops[l.id]?.channelY ?? Math.max(A.y + A.h, B.y + B.h) + 10 : (y1 + y2) / 2) + "px";
|
|
113
|
+
els.world.appendChild(lab);
|
|
114
|
+
ctx.labelEls[i] = lab;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function paint(ctx: Ctx, fromSwitch = false): void {
|
|
120
|
+
const { model } = ctx;
|
|
121
|
+
const mode = ctx.mode;
|
|
122
|
+
const effective = effectiveLinks(model, mode, ctx.collapsed, ctx.nodes);
|
|
123
|
+
const effectiveById = new Map(effective.map(l => [l.id, l]));
|
|
124
|
+
ctx.geom = computeGeometry({ ...model, links: effective }, { visible: id => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, mode, n, ctx.collapsed); } });
|
|
125
|
+
ctx.els.world.style.height = ctx.geom.world.h + "px";
|
|
126
|
+
ctx.els.svg.setAttribute("height", String(ctx.geom.world.h));
|
|
127
|
+
document.body.classList.toggle("alt", !!model.views && mode !== baselineView(model));
|
|
128
|
+
|
|
129
|
+
for (const n of model.nodes) {
|
|
130
|
+
const d = ctx.nodeEls.get(n.id);
|
|
131
|
+
if (!d) continue;
|
|
132
|
+
const rect = ctx.geom.nodes[n.id];
|
|
133
|
+
if (rect) { d.style.left = rect.x + "px"; d.style.top = rect.y + "px"; }
|
|
134
|
+
const sid = nodeStateId(model, mode, n);
|
|
135
|
+
const show = nodeVisible(model, mode, n, ctx.collapsed);
|
|
136
|
+
const bucket = bucketOf(model, sid);
|
|
137
|
+
const cors = (isImproved(model, mode, n) ? 1 : 0) + (n.upgrade && bucket !== "good" ? 1 : 0);
|
|
138
|
+
const fresh = isNew(model, mode, n);
|
|
139
|
+
d.className = "node st-" + cssId(sid)
|
|
140
|
+
+ (n.kind === "chip" ? " ref" : "") + (n.kind === "gateway" ? " gateway" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "")
|
|
141
|
+
+ (show ? "" : " off") + (fresh ? " isnew" : "") + (cors ? (cors > 1 ? " hascor2" : " hascor") : "")
|
|
142
|
+
+ (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "")
|
|
143
|
+
+ (metricBadge(n.metrics) ? " has-metrics" : "");
|
|
144
|
+
d.innerHTML = nodeHtml(ctx, n);
|
|
145
|
+
d.setAttribute("aria-hidden", show ? "false" : "true");
|
|
146
|
+
d.tabIndex = show ? 0 : -1;
|
|
147
|
+
const word = model.states[sid]?.word ?? sid;
|
|
148
|
+
const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
|
|
149
|
+
d.setAttribute("aria-label", n.kind === "chip"
|
|
150
|
+
? `${n.title}: a reference to the node in the ${lane} lane. Press Enter to select it.`
|
|
151
|
+
: `${n.title}. ${n.subtitle ?? model.columns[n.col ?? 0]?.title ?? ""}, ${word}. Press Enter to inspect.`);
|
|
152
|
+
if (fromSwitch && fresh && !ctx.reduced) {
|
|
153
|
+
d.style.transitionDelay = (n.col ?? 0) * 60 + "ms";
|
|
154
|
+
d.classList.add("pulse");
|
|
155
|
+
window.setTimeout(() => { d.style.transitionDelay = ""; d.classList.remove("pulse"); }, 2200);
|
|
156
|
+
} else {
|
|
157
|
+
d.style.transitionDelay = "";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
ctx.links.forEach((l, i) => {
|
|
162
|
+
const p = ctx.edgeEls[i];
|
|
163
|
+
if (!p) return;
|
|
164
|
+
const edge = effectiveById.get(l.id);
|
|
165
|
+
const vis = !!edge;
|
|
166
|
+
const a = edge && ctx.geom.nodes[edge.from], b = edge && ctx.geom.nodes[edge.to];
|
|
167
|
+
const loop = edge?.kind === "loop-back";
|
|
168
|
+
if (a && b) {
|
|
169
|
+
p.dataset.a = edge!.from; p.dataset.b = edge!.to;
|
|
170
|
+
const channel = ctx.geom.loops[edge!.id]?.channelY ?? Math.max(a.y + a.h, b.y + b.h) + 10;
|
|
171
|
+
p.setAttribute("d", loop
|
|
172
|
+
? loopRoute(ctx, edge!.from, edge!.to, a, b, channel)
|
|
173
|
+
: elbow(a.x + a.w, a.y + a.h / 2, b.x - 1, b.y + b.h / 2, a.x + a.w + DEFAULTS.gap / 2));
|
|
174
|
+
const lab = ctx.labelEls[i];
|
|
175
|
+
if (lab) {
|
|
176
|
+
lab.dataset.a = edge!.from; lab.dataset.b = edge!.to;
|
|
177
|
+
lab.style.left = (loop ? (a.x + a.w / 2 + b.x + b.w / 2) / 2 : Math.abs(b.y + b.h / 2 - a.y - a.h / 2) < 1 ? (a.x + a.w + b.x) / 2 : a.x + a.w + DEFAULTS.gap / 2) + "px";
|
|
178
|
+
lab.style.top = (loop ? channel : (a.y + a.h / 2 + b.y + b.h / 2) / 2) + "px";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
p.setAttribute("class", "edge st-" + cssId(edge?.state ?? linkStateId(model, mode, l)) + (loop ? " loop" : "") + (vis ? "" : " off"));
|
|
182
|
+
ctx.labelEls[i]?.classList.toggle("off", !vis);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
for (const l of model.lanes) {
|
|
186
|
+
const d = ctx.laneEls.get(l.id);
|
|
187
|
+
const r = ctx.geom.lanes[l.id];
|
|
188
|
+
if (!d) continue;
|
|
189
|
+
d.classList.toggle("off", !r);
|
|
190
|
+
if (r) { d.style.left = r.x + "px"; d.style.top = r.y + "px"; d.style.width = r.w + "px"; d.style.height = r.h + "px"; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
ctx.fn.buildChrome();
|
|
194
|
+
ctx.fn.trace(ctx.selected);
|
|
195
|
+
ctx.fn.renderInspector();
|
|
196
|
+
ctx.fn.stickLabels();
|
|
197
|
+
ctx.fn.placeHeaders();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function install(ctx: Ctx): void {
|
|
201
|
+
ctx.fn.buildWorld = () => build(ctx);
|
|
202
|
+
ctx.fn.paint = (fromSwitch?: boolean) => paint(ctx, fromSwitch);
|
|
203
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { elbow, loopBack } from "./routing.ts";
|
|
4
|
+
|
|
5
|
+
test("same height is a straight line", () => assert.equal(elbow(0, 50, 100, 50, 50), "M0,50 H100"));
|
|
6
|
+
test("a small vertical step is a gentle S curve", () => assert.equal(elbow(0, 50, 100, 60, 50), "M0,50 C50,50 50,60 100,60"));
|
|
7
|
+
test("a large step is an elbow with a trunk at tx and rounded corners", () => {
|
|
8
|
+
assert.equal(elbow(0, 50, 100, 150, 40), "M0,50 H32 Q40,50 40,58 V142 Q40,150 48,150 H100");
|
|
9
|
+
});
|
|
10
|
+
test("going up mirrors the corners", () => {
|
|
11
|
+
assert.equal(elbow(0, 150, 100, 50, 40), "M0,150 H32 Q40,150 40,142 V58 Q40,50 48,50 H100");
|
|
12
|
+
});
|
|
13
|
+
test("every number in a path is finite", () => assert.ok(!/NaN|Infinity/.test(elbow(0, 0, 10, 300, 5))));
|
|
14
|
+
test("loop-back leaves and enters bottom edges for either horizontal direction", () => {
|
|
15
|
+
for (const [x1, x2] of [[100, 20], [20, 100], [20, 20]]) {
|
|
16
|
+
const p = loopBack(x1, 40, x2, 50, 90);
|
|
17
|
+
assert.match(p, new RegExp(`^M${x1},40\\b`));
|
|
18
|
+
assert.match(p, new RegExp(`50$`));
|
|
19
|
+
assert.ok(!/NaN|Infinity/.test(p));
|
|
20
|
+
assert.match(p, /Q/);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
test("loop-back clamps its corner radius for short channels", () => {
|
|
24
|
+
const p = loopBack(10, 20, 10, 20, 23);
|
|
25
|
+
assert.ok(!/NaN|Infinity/.test(p));
|
|
26
|
+
assert.match(p, /^M10,20/);
|
|
27
|
+
});
|
|
28
|
+
test("a loop-back can detour around a node below its source", () => {
|
|
29
|
+
const path = loopBack(100, 50, 20, 50, 120, { from: { x: 60, y: 54 } });
|
|
30
|
+
assert.match(path, /^M100,50 V54 H60 V/);
|
|
31
|
+
assert.match(path, /V50$/);
|
|
32
|
+
assert.ok(!/NaN|Infinity/.test(path));
|
|
33
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Link routing. A link leaves the right side of a node, runs to a vertical trunk at tx (half way
|
|
2
|
+
// across the column gap), and enters the left side of the next node. It is horizontal where it
|
|
3
|
+
// touches a node, so every arrowhead points right and the links of one node share one trunk.
|
|
4
|
+
|
|
5
|
+
export function elbow(x1: number, y1: number, x2: number, y2: number, tx: number): string {
|
|
6
|
+
const r = 8;
|
|
7
|
+
if (Math.abs(y2 - y1) < 1) return `M${x1},${y1} H${x2}`;
|
|
8
|
+
if (Math.abs(y2 - y1) < 2 * r + 2) return `M${x1},${y1} C${tx},${y1} ${tx},${y2} ${x2},${y2}`;
|
|
9
|
+
const dir = y2 > y1 ? 1 : -1;
|
|
10
|
+
return `M${x1},${y1} H${tx - r} Q${tx},${y1} ${tx},${y1 + dir * r} V${y2 - dir * r} Q${tx},${y2} ${tx + r},${y2} H${x2}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A return path leaves and enters the bottom of its nodes. The last segment points up. */
|
|
14
|
+
export function loopBack(x1: number, y1: number, x2: number, y2: number, channelY: number,
|
|
15
|
+
detour: { from?: { x: number; y: number }; to?: { x: number; y: number } } = {}): string {
|
|
16
|
+
// Validated models supply finite positions. Guard the pure helper too so malformed input never
|
|
17
|
+
// produces a path containing NaN or Infinity.
|
|
18
|
+
x1 = Number.isFinite(x1) ? x1 : 0;
|
|
19
|
+
x2 = Number.isFinite(x2) ? x2 : 0;
|
|
20
|
+
y1 = Number.isFinite(y1) ? y1 : 0;
|
|
21
|
+
y2 = Number.isFinite(y2) ? y2 : 0;
|
|
22
|
+
channelY = Number.isFinite(channelY) ? channelY : Math.max(y1, y2) + 10;
|
|
23
|
+
channelY = Math.max(channelY, y1, y2);
|
|
24
|
+
const from = detour.from, to = detour.to;
|
|
25
|
+
const startX = from?.x ?? x1, endX = to?.x ?? x2;
|
|
26
|
+
const start = from ? `M${x1},${y1} V${from.y} H${startX}` : `M${x1},${y1}`;
|
|
27
|
+
const finish = to ? ` V${to.y} H${x2} V${y2}` : ` V${y2}`;
|
|
28
|
+
if (from || to) {
|
|
29
|
+
const dir = endX >= startX ? 1 : -1;
|
|
30
|
+
const r = Math.max(0, Math.min(8, Math.abs(endX - startX) / 2, (channelY - (from?.y ?? y1)) / 2, (channelY - (to?.y ?? y2)) / 2));
|
|
31
|
+
return `${start} V${channelY - r} Q${startX},${channelY} ${startX + dir * r},${channelY} H${endX - dir * r} Q${endX},${channelY} ${endX},${channelY - r}${finish}`;
|
|
32
|
+
}
|
|
33
|
+
const dx = x2 - x1;
|
|
34
|
+
const dir = dx >= 0 ? 1 : -1;
|
|
35
|
+
const run = Math.abs(dx) || 32;
|
|
36
|
+
const radius = Math.min(8, run / 2, (channelY - y1) / 2, (channelY - y2) / 2);
|
|
37
|
+
const r = Math.max(0, radius);
|
|
38
|
+
if (dx === 0) {
|
|
39
|
+
// Two gentle opposite turns give a finite return even for vertically aligned boxes.
|
|
40
|
+
return `M${x1},${y1} V${channelY - r} Q${x1},${channelY} ${x1 + r},${channelY} H${x1 + 16} H${x1 - r} Q${x1},${channelY} ${x1},${channelY - r} V${y2}`;
|
|
41
|
+
}
|
|
42
|
+
return `M${x1},${y1} V${channelY - r} Q${x1},${channelY} ${x1 + dir * r},${channelY} H${x2 - dir * r} Q${x2},${channelY} ${x2},${channelY - r} V${y2}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { Model } from "./model.ts";
|
|
4
|
+
import { baselineView, defaultView, nodeVisible, nodeStateId, linkVisible, linkStateId, isNew, isImproved, bucketOf, canonOf, effectiveLinks, initialCollapsed, columnTitle, toggleGroupState } from "./rules.ts";
|
|
5
|
+
|
|
6
|
+
const m = (): Model => ({
|
|
7
|
+
version: 1,
|
|
8
|
+
meta: { title: "t", help: "", asOf: "", source: "" },
|
|
9
|
+
layout: "columns-lanes",
|
|
10
|
+
columns: [{ id: "c0", title: "A", width: 150 }, { id: "c1", title: "B", width: 150 }],
|
|
11
|
+
lanes: [{ id: "l1", title: "L" }],
|
|
12
|
+
states: {
|
|
13
|
+
good: { word: "GOOD", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" },
|
|
14
|
+
gap: { word: "GAP", tone: "warn", border: "dotted", edge: "dot", motion: "breathe", bucket: "gap" },
|
|
15
|
+
},
|
|
16
|
+
views: [{ id: "today", label: "Today" }, { id: "full", label: "Full", default: true }],
|
|
17
|
+
nodes: [
|
|
18
|
+
{ id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "good", title: "A" },
|
|
19
|
+
{ id: "b", kind: "card", lane: "l1", col: 1, row: 0, state: "good", stateIn: { today: "gap" }, title: "B" },
|
|
20
|
+
{ id: "c", kind: "card", lane: "l1", col: 1, row: 1.8, state: "good", visibleIn: ["full"], title: "C" },
|
|
21
|
+
{ id: "k", kind: "chip", lane: "l1", col: 0, row: 3.6, state: "good", ref: "a", title: "k" },
|
|
22
|
+
],
|
|
23
|
+
links: [
|
|
24
|
+
{ id: "a-b", from: "a", to: "b", state: "good" },
|
|
25
|
+
{ id: "a-c", from: "a", to: "c", state: "good", stateIn: { today: "gap" } },
|
|
26
|
+
{ id: "only-today", from: "a", to: "b", state: "gap", visibleIn: ["today"] },
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
const byId = (mm: Model) => new Map(mm.nodes.map(n => [n.id, n]));
|
|
30
|
+
|
|
31
|
+
test("the baseline is the first view and the default is the marked one", () => {
|
|
32
|
+
assert.equal(baselineView(m()), "today");
|
|
33
|
+
assert.equal(defaultView(m()), "full");
|
|
34
|
+
});
|
|
35
|
+
test("a model with no views has one implicit view", () => {
|
|
36
|
+
const mm = m(); delete mm.views;
|
|
37
|
+
assert.equal(baselineView(mm), "all");
|
|
38
|
+
assert.equal(defaultView(mm), "all");
|
|
39
|
+
assert.equal(nodeVisible(mm, "all", mm.nodes[0]!), true);
|
|
40
|
+
});
|
|
41
|
+
test("visibleIn hides a node in other views", () => {
|
|
42
|
+
const mm = m();
|
|
43
|
+
assert.equal(nodeVisible(mm, "today", mm.nodes[2]!), false);
|
|
44
|
+
assert.equal(nodeVisible(mm, "full", mm.nodes[2]!), true);
|
|
45
|
+
});
|
|
46
|
+
test("stateIn overrides the state in that view only", () => {
|
|
47
|
+
const mm = m();
|
|
48
|
+
assert.equal(nodeStateId(mm, "today", mm.nodes[1]!), "gap");
|
|
49
|
+
assert.equal(nodeStateId(mm, "full", mm.nodes[1]!), "good");
|
|
50
|
+
});
|
|
51
|
+
test("a link is hidden when an end is hidden", () => {
|
|
52
|
+
const mm = m(), nodes = byId(mm);
|
|
53
|
+
assert.equal(linkVisible(mm, "today", mm.links[1]!, nodes), false);
|
|
54
|
+
assert.equal(linkVisible(mm, "full", mm.links[1]!, nodes), true);
|
|
55
|
+
});
|
|
56
|
+
test("visibleIn on a link restricts it to those views", () => {
|
|
57
|
+
const mm = m(), nodes = byId(mm);
|
|
58
|
+
assert.equal(linkVisible(mm, "today", mm.links[2]!, nodes), true);
|
|
59
|
+
assert.equal(linkVisible(mm, "full", mm.links[2]!, nodes), false);
|
|
60
|
+
});
|
|
61
|
+
test("a link state can differ per view", () => {
|
|
62
|
+
const mm = m();
|
|
63
|
+
assert.equal(linkStateId(mm, "today", mm.links[1]!), "gap");
|
|
64
|
+
assert.equal(linkStateId(mm, "full", mm.links[1]!), "good");
|
|
65
|
+
});
|
|
66
|
+
test("isNew: visible now, not in the baseline view", () => {
|
|
67
|
+
const mm = m();
|
|
68
|
+
assert.equal(isNew(mm, "full", mm.nodes[2]!), true);
|
|
69
|
+
assert.equal(isNew(mm, "today", mm.nodes[2]!), false);
|
|
70
|
+
assert.equal(isNew(mm, "full", mm.nodes[0]!), false);
|
|
71
|
+
});
|
|
72
|
+
test("isImproved: a gap in the baseline that is not a gap now", () => {
|
|
73
|
+
const mm = m();
|
|
74
|
+
assert.equal(isImproved(mm, "full", mm.nodes[1]!), true);
|
|
75
|
+
assert.equal(isImproved(mm, "today", mm.nodes[1]!), false);
|
|
76
|
+
assert.equal(isImproved(mm, "full", mm.nodes[0]!), false);
|
|
77
|
+
});
|
|
78
|
+
test("bucketOf reads the bucket of a state, and an unknown state is a gap (never a good)", () => {
|
|
79
|
+
const mm = m();
|
|
80
|
+
assert.equal(bucketOf(mm, "good"), "good");
|
|
81
|
+
assert.equal(bucketOf(mm, "nope"), "gap");
|
|
82
|
+
});
|
|
83
|
+
test("canonOf maps a chip to its real node and leaves others alone", () => {
|
|
84
|
+
const mm = m(), nodes = byId(mm);
|
|
85
|
+
assert.equal(canonOf(nodes, "k"), "a");
|
|
86
|
+
assert.equal(canonOf(nodes, "b"), "b");
|
|
87
|
+
assert.equal(canonOf(nodes, "missing"), "missing");
|
|
88
|
+
});
|
|
89
|
+
test("collapsed groups replace members and merge duplicate links with the worst state", () => {
|
|
90
|
+
const mm = m();
|
|
91
|
+
mm.nodes.push({ id: "group", kind: "group", lane: "l1", col: 0, row: 5, state: "good", title: "Group", contains: ["a", "b"] });
|
|
92
|
+
mm.links.push({ id: "b-c", from: "b", to: "c", state: "gap" });
|
|
93
|
+
mm.links.push({ id: "a-b", from: "a", to: "b", state: "good" });
|
|
94
|
+
const collapsed = new Set(["group"]), nodes = byId(mm);
|
|
95
|
+
assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "a")!, collapsed), false);
|
|
96
|
+
assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "group")!, collapsed), true);
|
|
97
|
+
const links = effectiveLinks(mm, "full", collapsed, nodes);
|
|
98
|
+
assert.equal(links.filter(l => l.from === "group" && l.to === "c").length, 1);
|
|
99
|
+
assert.equal(links.find(l => l.from === "group" && l.to === "c")?.state, "gap");
|
|
100
|
+
assert.ok(!links.some(l => l.from === l.to));
|
|
101
|
+
collapsed.clear();
|
|
102
|
+
assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "a")!, collapsed), true);
|
|
103
|
+
assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "group")!, collapsed), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("groups with contains start collapsed unless collapsed is false", () => {
|
|
107
|
+
const mm = m();
|
|
108
|
+
mm.nodes.push({ id: "default", kind: "group", lane: "l1", state: "good", title: "Default", contains: ["a"] });
|
|
109
|
+
mm.nodes.push({ id: "expanded", kind: "group", lane: "l1", state: "good", title: "Expanded", contains: ["b"], collapsed: false });
|
|
110
|
+
const collapsed = initialCollapsed(mm);
|
|
111
|
+
assert.deepEqual([...collapsed], ["default"]);
|
|
112
|
+
assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "a")!, collapsed), false);
|
|
113
|
+
assert.equal(nodeVisible(mm, "full", mm.nodes.find(n => n.id === "b")!, collapsed), true);
|
|
114
|
+
});
|
|
115
|
+
test("a free node with no column has an empty table column", () => {
|
|
116
|
+
const mm = m(); mm.layout = "free";
|
|
117
|
+
delete mm.nodes[0]!.col;
|
|
118
|
+
assert.equal(columnTitle(mm, mm.nodes[0]!), "");
|
|
119
|
+
assert.equal(columnTitle(mm, mm.nodes[1]!), "B");
|
|
120
|
+
});
|
|
121
|
+
test("toggleGroup selects a visible child on expansion and its group on collapse", () => {
|
|
122
|
+
const mm = m();
|
|
123
|
+
mm.nodes.push({ id: "group", kind: "group", lane: "l1", state: "good", title: "Group", contains: ["a", "b"] });
|
|
124
|
+
const starting = new Set(["group"]);
|
|
125
|
+
const expanded = toggleGroupState(mm, "full", starting, "group")!;
|
|
126
|
+
assert.equal(expanded.expanding, true);
|
|
127
|
+
assert.equal(expanded.selected, "a");
|
|
128
|
+
assert.equal(expanded.collapsed.has("group"), false);
|
|
129
|
+
assert.equal(starting.has("group"), true);
|
|
130
|
+
const folded = toggleGroupState(mm, "full", expanded.collapsed, "group")!;
|
|
131
|
+
assert.equal(folded.expanding, false);
|
|
132
|
+
assert.equal(folded.selected, "group");
|
|
133
|
+
assert.equal(folded.collapsed.has("group"), true);
|
|
134
|
+
assert.equal(toggleGroupState(mm, "full", starting, "missing"), null);
|
|
135
|
+
});
|