@caddis/cli 0.4.45 → 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/skills/flow-studio/README.md +46 -7
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +80 -81
- package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +8 -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/incident-timeline.json +109 -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 +27 -8
- 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 +385 -49
- 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 +54 -14
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +21 -4
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +320 -28
- 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 +30 -2
- 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 +64 -4
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +115 -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/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 +5 -1
- 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 +11 -7
- package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +8 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +16 -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 +83 -32
- package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +23 -5
- package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +21 -8
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +49 -11
- 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 +62 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +101 -16
- package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +8 -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 +66 -18
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +21 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +32 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +48 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +52 -1
- 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 +7 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +35 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +21 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +986 -74
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +1 -39
- 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/codex/.codex/skills/frontend/flow-studio/README.md +46 -7
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +80 -81
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +8 -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/incident-timeline.json +109 -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 +27 -8
- 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 +385 -49
- 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 +54 -14
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +21 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +320 -28
- 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 +30 -2
- 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 +64 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +115 -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/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 +5 -1
- 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 +11 -7
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +8 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +16 -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 +83 -32
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +23 -5
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +21 -8
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +49 -11
- 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 +62 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +101 -16
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +8 -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 +66 -18
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +21 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +32 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +48 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +52 -1
- 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 +7 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +1 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +35 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +21 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +986 -74
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +1 -39
- 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/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
|
+
}
|
|
@@ -4,17 +4,36 @@
|
|
|
4
4
|
import { el, sv, on, type Ctx } from "./ctx.ts";
|
|
5
5
|
import { esc } from "./model.ts";
|
|
6
6
|
import type { NodeDef } from "./model.ts";
|
|
7
|
-
import { computeGeometry, DEFAULTS } from "./layout.ts";
|
|
8
|
-
import { elbow } from "./routing.ts";
|
|
7
|
+
import { computeGeometry, DEFAULTS, type Placed } from "./layout.ts";
|
|
8
|
+
import { elbow, loopBack } from "./routing.ts";
|
|
9
9
|
import { cssId } from "./stateCss.ts";
|
|
10
|
-
import { baselineView, bucketOf, canonOf, isImproved, isNew, linkStateId,
|
|
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
|
+
}
|
|
11
25
|
|
|
12
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
|
+
}
|
|
13
32
|
const m = ctx.model;
|
|
14
33
|
const sid = nodeStateId(m, ctx.mode, n);
|
|
15
34
|
const word = m.states[sid]?.word ?? sid;
|
|
16
35
|
const bucket = bucketOf(m, sid);
|
|
17
|
-
const sub = n.subtitle ?? m.columns[n.col]?.title ?? "";
|
|
36
|
+
const sub = n.subtitle ?? m.columns[n.col ?? 0]?.title ?? "";
|
|
18
37
|
const weight = Math.max(1, n.weight ?? 1);
|
|
19
38
|
const showWord = n.kind !== "placeholder" && n.kind !== "note";
|
|
20
39
|
const bar = weight > 1 && n.kind !== "chip"
|
|
@@ -24,8 +43,9 @@ function nodeHtml(ctx: Ctx, n: NodeDef): string {
|
|
|
24
43
|
const upgrade = n.upgrade && bucket !== "good" ? `<span class="cor meas" title="${esc("Can improve: " + n.upgrade)}">↗</span>` : "";
|
|
25
44
|
const arrow = n.kind === "chip" ? '<span aria-hidden="true">↑ </span>' : "";
|
|
26
45
|
const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> · ` : "";
|
|
27
|
-
|
|
28
|
-
|
|
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}`;
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
function build(ctx: Ctx): void {
|
|
@@ -59,7 +79,7 @@ function build(ctx: Ctx): void {
|
|
|
59
79
|
d.id = "n_" + n.id;
|
|
60
80
|
d.tabIndex = 0;
|
|
61
81
|
d.setAttribute("role", "button");
|
|
62
|
-
d.style.setProperty("--c", String(n.col));
|
|
82
|
+
d.style.setProperty("--c", String(n.col ?? 0));
|
|
63
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";
|
|
64
84
|
on(ctx, d, "click", () => { if (ctx.moved > 4) return; ctx.fn.select(canonOf(ctx.nodes, n.id)); });
|
|
65
85
|
on(ctx, d, "dblclick", (e: Event) => { e.stopPropagation(); ctx.fn.select(canonOf(ctx.nodes, n.id)); ctx.fn.zoomToPath(); });
|
|
@@ -77,14 +97,19 @@ function build(ctx: Ctx): void {
|
|
|
77
97
|
const A = ctx.geom.nodes[l.from], B = ctx.geom.nodes[l.to];
|
|
78
98
|
if (!A || !B) { console.warn(`flow-studio: link ${l.id} skipped (an end is missing)`); return; }
|
|
79
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;
|
|
80
|
-
const
|
|
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;
|
|
81
106
|
els.layer.appendChild(path);
|
|
82
107
|
ctx.edgeEls[i] = path;
|
|
83
108
|
if (l.label) {
|
|
84
109
|
const lab = el("div", "elabel", esc(l.label));
|
|
85
110
|
lab.dataset.i = String(i); lab.dataset.a = l.from; lab.dataset.b = l.to;
|
|
86
|
-
lab.style.left = (Math.abs(y2 - y1) < 1 ? (x1 + x2) / 2 : tx) + "px";
|
|
87
|
-
lab.style.top = (y1 + y2) / 2 + "px";
|
|
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";
|
|
88
113
|
els.world.appendChild(lab);
|
|
89
114
|
ctx.labelEls[i] = lab;
|
|
90
115
|
}
|
|
@@ -94,21 +119,28 @@ function build(ctx: Ctx): void {
|
|
|
94
119
|
function paint(ctx: Ctx, fromSwitch = false): void {
|
|
95
120
|
const { model } = ctx;
|
|
96
121
|
const mode = ctx.mode;
|
|
97
|
-
|
|
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));
|
|
98
127
|
document.body.classList.toggle("alt", !!model.views && mode !== baselineView(model));
|
|
99
128
|
|
|
100
129
|
for (const n of model.nodes) {
|
|
101
130
|
const d = ctx.nodeEls.get(n.id);
|
|
102
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"; }
|
|
103
134
|
const sid = nodeStateId(model, mode, n);
|
|
104
|
-
const show = nodeVisible(model, mode, n);
|
|
135
|
+
const show = nodeVisible(model, mode, n, ctx.collapsed);
|
|
105
136
|
const bucket = bucketOf(model, sid);
|
|
106
137
|
const cors = (isImproved(model, mode, n) ? 1 : 0) + (n.upgrade && bucket !== "good" ? 1 : 0);
|
|
107
138
|
const fresh = isNew(model, mode, n);
|
|
108
139
|
d.className = "node st-" + cssId(sid)
|
|
109
|
-
+ (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "")
|
|
140
|
+
+ (n.kind === "chip" ? " ref" : "") + (n.kind === "gateway" ? " gateway" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.kind === "placeholder" ? " placeholder" : "")
|
|
110
141
|
+ (show ? "" : " off") + (fresh ? " isnew" : "") + (cors ? (cors > 1 ? " hascor2" : " hascor") : "")
|
|
111
|
-
+ (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "")
|
|
142
|
+
+ (ctx.selected && canonOf(ctx.nodes, n.id) === ctx.selected ? " sel" : "")
|
|
143
|
+
+ (metricBadge(n.metrics) ? " has-metrics" : "");
|
|
112
144
|
d.innerHTML = nodeHtml(ctx, n);
|
|
113
145
|
d.setAttribute("aria-hidden", show ? "false" : "true");
|
|
114
146
|
d.tabIndex = show ? 0 : -1;
|
|
@@ -116,9 +148,9 @@ function paint(ctx: Ctx, fromSwitch = false): void {
|
|
|
116
148
|
const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
|
|
117
149
|
d.setAttribute("aria-label", n.kind === "chip"
|
|
118
150
|
? `${n.title}: a reference to the node in the ${lane} lane. Press Enter to select it.`
|
|
119
|
-
: `${n.title}. ${n.subtitle ?? model.columns[n.col]?.title ?? ""}, ${word}. Press Enter to inspect.`);
|
|
151
|
+
: `${n.title}. ${n.subtitle ?? model.columns[n.col ?? 0]?.title ?? ""}, ${word}. Press Enter to inspect.`);
|
|
120
152
|
if (fromSwitch && fresh && !ctx.reduced) {
|
|
121
|
-
d.style.transitionDelay = n.col * 60 + "ms";
|
|
153
|
+
d.style.transitionDelay = (n.col ?? 0) * 60 + "ms";
|
|
122
154
|
d.classList.add("pulse");
|
|
123
155
|
window.setTimeout(() => { d.style.transitionDelay = ""; d.classList.remove("pulse"); }, 2200);
|
|
124
156
|
} else {
|
|
@@ -129,8 +161,24 @@ function paint(ctx: Ctx, fromSwitch = false): void {
|
|
|
129
161
|
ctx.links.forEach((l, i) => {
|
|
130
162
|
const p = ctx.edgeEls[i];
|
|
131
163
|
if (!p) return;
|
|
132
|
-
const
|
|
133
|
-
|
|
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"));
|
|
134
182
|
ctx.labelEls[i]?.classList.toggle("off", !vis);
|
|
135
183
|
});
|
|
136
184
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { elbow } from "./routing.ts";
|
|
3
|
+
import { elbow, loopBack } from "./routing.ts";
|
|
4
4
|
|
|
5
5
|
test("same height is a straight line", () => assert.equal(elbow(0, 50, 100, 50, 50), "M0,50 H100"));
|
|
6
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"));
|
|
@@ -11,3 +11,23 @@ test("going up mirrors the corners", () => {
|
|
|
11
11
|
assert.equal(elbow(0, 150, 100, 50, 40), "M0,150 H32 Q40,150 40,142 V58 Q40,50 48,50 H100");
|
|
12
12
|
});
|
|
13
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
|
+
});
|
|
@@ -9,3 +9,35 @@ export function elbow(x1: number, y1: number, x2: number, y2: number, tx: number
|
|
|
9
9
|
const dir = y2 > y1 ? 1 : -1;
|
|
10
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
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import type { Model } from "./model.ts";
|
|
4
|
-
import { baselineView, defaultView, nodeVisible, nodeStateId, linkVisible, linkStateId, isNew, isImproved, bucketOf, canonOf } from "./rules.ts";
|
|
4
|
+
import { baselineView, defaultView, nodeVisible, nodeStateId, linkVisible, linkStateId, isNew, isImproved, bucketOf, canonOf, effectiveLinks, initialCollapsed, columnTitle, toggleGroupState } from "./rules.ts";
|
|
5
5
|
|
|
6
6
|
const m = (): Model => ({
|
|
7
7
|
version: 1,
|
|
@@ -86,3 +86,50 @@ test("canonOf maps a chip to its real node and leaves others alone", () => {
|
|
|
86
86
|
assert.equal(canonOf(nodes, "b"), "b");
|
|
87
87
|
assert.equal(canonOf(nodes, "missing"), "missing");
|
|
88
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
|
+
});
|
|
@@ -7,7 +7,35 @@ import type { Model, NodeDef, LinkDef, Bucket } from "./model.ts";
|
|
|
7
7
|
export const baselineView = (m: Model): string => m.views?.[0]?.id ?? "all";
|
|
8
8
|
export const defaultView = (m: Model): string => (m.views?.find(v => v.default) ?? m.views?.[0])?.id ?? "all";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/** A group that contains nodes starts folded unless it explicitly opts out. */
|
|
11
|
+
export const initialCollapsed = (m: Model): Set<string> => new Set(m.nodes
|
|
12
|
+
.filter(n => n.kind === "group" && n.contains && n.collapsed !== false).map(n => n.id));
|
|
13
|
+
export const columnTitle = (m: Model, n: NodeDef): string => n.col === undefined ? "" : m.columns[n.col]?.title ?? "";
|
|
14
|
+
|
|
15
|
+
export function groupFor(m: Model, nodeId: string): NodeDef | undefined {
|
|
16
|
+
return m.nodes.find(n => n.kind === "group" && n.contains?.includes(nodeId));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const nodeVisible = (m: Model, view: string, n: NodeDef, collapsed?: Set<string>): boolean => {
|
|
20
|
+
if (n.visibleIn && !n.visibleIn.includes(view)) return false;
|
|
21
|
+
if (!collapsed) return true;
|
|
22
|
+
if (n.kind === "group" && n.contains) return collapsed.has(n.id);
|
|
23
|
+
const owner = groupFor(m, n.id);
|
|
24
|
+
return !owner || !collapsed.has(owner.id);
|
|
25
|
+
};
|
|
26
|
+
export function toggleGroupState(m: Model, view: string, collapsed: Set<string>, id: string):
|
|
27
|
+
{ collapsed: Set<string>; selected: string | null; expanding: boolean } | null {
|
|
28
|
+
const group = m.nodes.find(n => n.id === id && n.kind === "group" && n.contains?.length);
|
|
29
|
+
if (!group) return null;
|
|
30
|
+
const next = new Set(collapsed);
|
|
31
|
+
const expanding = next.has(id);
|
|
32
|
+
if (expanding) next.delete(id); else next.add(id);
|
|
33
|
+
const selected = expanding ? group.contains?.find(child => {
|
|
34
|
+
const n = m.nodes.find(candidate => candidate.id === child);
|
|
35
|
+
return !!n && nodeVisible(m, view, n, next);
|
|
36
|
+
}) ?? null : id;
|
|
37
|
+
return { collapsed: next, selected, expanding };
|
|
38
|
+
}
|
|
11
39
|
export const nodeStateId = (_m: Model, view: string, n: NodeDef): string => n.stateIn?.[view] ?? n.state;
|
|
12
40
|
|
|
13
41
|
export function linkVisible(m: Model, view: string, l: LinkDef, nodes: Map<string, NodeDef>): boolean {
|
|
@@ -33,3 +61,26 @@ export const isImproved = (m: Model, view: string, n: NodeDef): boolean => {
|
|
|
33
61
|
|
|
34
62
|
/** A portal chip is an alias of the node it points at. */
|
|
35
63
|
export const canonOf = (nodes: Map<string, NodeDef>, id: string): string => nodes.get(id)?.ref ?? id;
|
|
64
|
+
|
|
65
|
+
/** Map hidden members to their visible group and merge parallel effective links. */
|
|
66
|
+
export function effectiveLinks(m: Model, view: string, collapsed: Set<string>, nodes: Map<string, NodeDef>): LinkDef[] {
|
|
67
|
+
const byPair = new Map<string, LinkDef>();
|
|
68
|
+
const rank = { good: 0, neutral: 1, gap: 2 };
|
|
69
|
+
for (const link of m.links) {
|
|
70
|
+
if (link.visibleIn && !link.visibleIn.includes(view)) continue;
|
|
71
|
+
const a = nodes.get(link.from), b = nodes.get(link.to);
|
|
72
|
+
if (!a || !b || (a.visibleIn && !a.visibleIn.includes(view)) || (b.visibleIn && !b.visibleIn.includes(view))) continue;
|
|
73
|
+
const groupA = groupFor(m, a.id), groupB = groupFor(m, b.id);
|
|
74
|
+
const from = groupA && collapsed.has(groupA.id) ? groupA.id : a.id;
|
|
75
|
+
const to = groupB && collapsed.has(groupB.id) ? groupB.id : b.id;
|
|
76
|
+
if (from === to) continue;
|
|
77
|
+
const fromNode = nodes.get(from), toNode = nodes.get(to);
|
|
78
|
+
if (!fromNode || !toNode || !nodeVisible(m, view, fromNode, collapsed) || !nodeVisible(m, view, toNode, collapsed)) continue;
|
|
79
|
+
const state = linkStateId(m, view, link);
|
|
80
|
+
const key = JSON.stringify([from, to]);
|
|
81
|
+
const prev = byPair.get(key);
|
|
82
|
+
if (!prev) byPair.set(key, { ...link, from, to, state, stateIn: undefined });
|
|
83
|
+
else if (rank[bucketOf(m, state)] > rank[bucketOf(m, prev.state)]) prev.state = state;
|
|
84
|
+
}
|
|
85
|
+
return [...byPair.values()];
|
|
86
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { notifySelection } from "./selection.ts";
|
|
4
|
+
|
|
5
|
+
test("selection callback reports changes and clears once", () => {
|
|
6
|
+
const seen: Array<string | null> = [];
|
|
7
|
+
const notify = (id: string | null) => seen.push(id);
|
|
8
|
+
notifySelection(null, "a", notify);
|
|
9
|
+
notifySelection("a", "a", notify);
|
|
10
|
+
notifySelection("a", null, notify);
|
|
11
|
+
assert.deepEqual(seen, ["a", null]);
|
|
12
|
+
});
|
|
@@ -21,6 +21,13 @@ test("each state gets a node rule, a word rule and an edge rule", () => {
|
|
|
21
21
|
assert.ok(css.includes(`path.edge.st-${id}`), `edge ${id}`);
|
|
22
22
|
}
|
|
23
23
|
});
|
|
24
|
+
test("gateway polygons receive each state's stroke and tint", () => {
|
|
25
|
+
const css = stateCss(states);
|
|
26
|
+
for (const id of Object.keys(states)) {
|
|
27
|
+
assert.match(css, new RegExp(`\\.node\\.st-${id}\\.gateway polygon \\{[^}]*stroke:var\\(--`));
|
|
28
|
+
assert.match(css, new RegExp(`\\.node\\.st-${id}\\.gateway polygon \\{[^}]*fill:color-mix\\(`));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
24
31
|
test("a dotted state is a tinted card with a dotted border", () => {
|
|
25
32
|
const css = stateCss(states);
|
|
26
33
|
assert.match(css, /\.node\.st-gap\s*\{[^}]*dotted/);
|
|
@@ -26,6 +26,7 @@ export function stateCss(states: Model["states"]): string {
|
|
|
26
26
|
const glow = s.bucket === "good" ? ` box-shadow:0 0 14px -4px color-mix(in srgb, ${T} 55%, transparent), var(--shadow);` : "";
|
|
27
27
|
out.push(`.node.st-${c} { border-left:4px ${s.border} ${T};${glow} --breath:${T}; }`);
|
|
28
28
|
}
|
|
29
|
+
out.push(`.node.st-${c}.gateway polygon { stroke:${T}; fill:color-mix(in srgb, ${T} 10%, var(--panel)); }`);
|
|
29
30
|
out.push(`.ev.st-${c} { color:${T}; }`);
|
|
30
31
|
if (s.motion === "breathe") out.push(`.node.st-${c}:not(.dim) { animation:breathe 3.2s ease-in-out infinite; }`);
|
|
31
32
|
|