@caddis/cli 0.4.45 → 0.4.47
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 +81 -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 +177 -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/lib/styles.test.mjs +37 -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 +44 -10
- 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 +995 -82
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +1 -39
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +191 -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 +2492 -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 +81 -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 +177 -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/lib/styles.test.mjs +37 -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 +44 -10
- 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 +995 -82
- 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 +191 -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 +2492 -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
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
// a small API (also placed on window.FlowStudio for the check harness). The generated demo page calls mount() itself.
|
|
3
3
|
|
|
4
4
|
import { validateModel, type Model, type NoteDef } from "./model.ts";
|
|
5
|
+
import { autoLayout } from "./autolayout.ts";
|
|
5
6
|
import type { Ctx, Els } from "./ctx.ts";
|
|
6
7
|
import type { View } from "./viewport.ts";
|
|
7
8
|
import type { Geometry } from "./layout.ts";
|
|
8
9
|
import { stateCss } from "./stateCss.ts";
|
|
9
|
-
import { defaultView } from "./rules.ts";
|
|
10
|
+
import { defaultView, initialCollapsed } from "./rules.ts";
|
|
10
11
|
import { install as installInteract } from "./interact.ts";
|
|
11
12
|
import { install as installRender } from "./render.ts";
|
|
12
13
|
import { install as installControls } from "./controls.ts";
|
|
13
14
|
import { install as installInspector } from "./inspector.ts";
|
|
14
15
|
import { install as installChrome } from "./chrome.ts";
|
|
15
16
|
import { install as installNotes } from "./notes.ts";
|
|
17
|
+
import { install as installPlayback, type PlaybackController } from "./playbackui.ts";
|
|
18
|
+
import { diffModels } from "./diff.ts";
|
|
19
|
+
import { notifySelection } from "./selection.ts";
|
|
20
|
+
import type { Issue } from "./model.ts";
|
|
16
21
|
|
|
17
22
|
export interface FlowStudio {
|
|
18
23
|
model: Model;
|
|
@@ -26,6 +31,12 @@ export interface FlowStudio {
|
|
|
26
31
|
setMode(id: string): void;
|
|
27
32
|
simulate(actionId: string): void;
|
|
28
33
|
resetSimulation(): void;
|
|
34
|
+
play(scenarioId?: string): void;
|
|
35
|
+
pause(): void;
|
|
36
|
+
step(): void;
|
|
37
|
+
seek(tMs: number): void;
|
|
38
|
+
playback(): { scenario: string | null; step: number; playing: boolean; t: number };
|
|
39
|
+
update(model: Model, opts?: { animate?: boolean }): { ok: boolean; issues: Issue[] };
|
|
29
40
|
destroy(): void;
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -36,9 +47,11 @@ declare global {
|
|
|
36
47
|
const IDS: Record<keyof Els, string> = {
|
|
37
48
|
vp: "viewport", world: "world", svg: "edges", layer: "edgeLayer", cols: "cols", tools: "tools", zoom: "zoomLabel",
|
|
38
49
|
inspector: "inspector", workspace: "workspace", live: "live", strip: "layerstrip", tiles: "kpis", legend: "legend",
|
|
39
|
-
ledger: "ledger", table: "tbl", q: "q", matchinfo: "matchinfo", modeSwitch: "modeSwitch", drawer: "drawer",
|
|
50
|
+
ledger: "ledger", table: "tbl", q: "q", hops: "hops", matchinfo: "matchinfo", modeSwitch: "modeSwitch", drawer: "drawer",
|
|
40
51
|
notelist: "notelist", notesBtn: "notesBtn", theme: "theme", specbody: "specbody", spec: "spec", title: "title",
|
|
41
|
-
readat: "readat", help: "help", footer: "footer",
|
|
52
|
+
readat: "readat", help: "help", footer: "footer", playbar: "playbar", pbScenario: "pbScenario", pbPlay: "pbPlay",
|
|
53
|
+
pbStep: "pbStep", pbReset: "pbReset", pbSpeed: "pbSpeed", pbScrub: "pbScrub", pbCaption: "pbCaption",
|
|
54
|
+
pbCounter: "pbCounter", pbChoices: "pbChoices", banner: "banner",
|
|
42
55
|
};
|
|
43
56
|
|
|
44
57
|
function findEls(root: ParentNode): Els {
|
|
@@ -51,10 +64,12 @@ function findEls(root: ParentNode): Els {
|
|
|
51
64
|
return out as unknown as Els;
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
interface SavedState { view: View; mode: string; selected: string | null; inspectorOpen: boolean; query: string; hops: number; filter: Ctx["filter"]; collapsed: Set<string>; knownGroups: Set<string>; playback: ReturnType<PlaybackController["playback"]> }
|
|
68
|
+
export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; restore?: SavedState } = {}): FlowStudio {
|
|
55
69
|
if (!model || !Array.isArray(model.columns) || !Array.isArray(model.lanes) || !Array.isArray(model.nodes) || !Array.isArray(model.links) || !model.states) {
|
|
56
70
|
throw new Error("flow-studio: the model needs columns, lanes, states, nodes and links");
|
|
57
71
|
}
|
|
72
|
+
if (model.layout === "auto") model = autoLayout(model);
|
|
58
73
|
const els = findEls(root);
|
|
59
74
|
const issues = validateModel(model);
|
|
60
75
|
const errors = issues.filter(i => i.level === "error");
|
|
@@ -63,15 +78,17 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]
|
|
|
63
78
|
|
|
64
79
|
const rm = matchMedia("(prefers-reduced-motion: reduce)");
|
|
65
80
|
const ctx: Ctx = {
|
|
66
|
-
model, root, els,
|
|
81
|
+
model, root, els, onSelect: opts.onSelect,
|
|
67
82
|
notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
|
|
68
83
|
nodes: new Map(model.nodes.map(n => [n.id, n])),
|
|
69
84
|
links: model.links,
|
|
70
|
-
geom: { xs: [], nodes: {}, lanes: {}, bounds: { x: 0, y: 0, w: 1, h: 1 }, world: { w: 1, h: 1 } },
|
|
85
|
+
geom: { xs: [], nodes: {}, lanes: {}, loops: {}, bounds: { x: 0, y: 0, w: 1, h: 1 }, world: { w: 1, h: 1 } },
|
|
71
86
|
view: { x: 0, y: 0, k: 1 },
|
|
72
87
|
reduced: rm.matches,
|
|
73
|
-
mode: defaultView(model),
|
|
74
|
-
|
|
88
|
+
mode: opts.restore && (model.views?.some(v => v.id === opts.restore!.mode) || (!model.views?.length && opts.restore.mode === "all")) ? opts.restore.mode : defaultView(model),
|
|
89
|
+
collapsed: opts.restore ? new Set(model.nodes.filter(n => n.kind === "group" && n.contains && (opts.restore!.collapsed.has(n.id) || (!opts.restore!.knownGroups.has(n.id) && n.collapsed !== false))).map(n => n.id)) : initialCollapsed(model),
|
|
90
|
+
selected: opts.restore?.selected && model.nodes.some(n => n.id === opts.restore!.selected) ? opts.restore.selected : null,
|
|
91
|
+
hl: null, query: opts.restore?.query ?? "", hops: opts.restore?.hops ?? 0, filter: opts.restore?.filter ?? null, tool: "select",
|
|
75
92
|
spaceDown: false, userMoved: false, dismissed: false, simActive: false, moved: 0,
|
|
76
93
|
nodeEls: new Map(), laneEls: new Map(), edgeEls: [], labelEls: [], overlays: [], simTimers: [], disposers: [],
|
|
77
94
|
fn: {} as Ctx["fn"],
|
|
@@ -90,13 +107,36 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]
|
|
|
90
107
|
installInspector(ctx);
|
|
91
108
|
installChrome(ctx);
|
|
92
109
|
installNotes(ctx);
|
|
110
|
+
els.q.value = ctx.query;
|
|
111
|
+
els.hops.value = String(ctx.hops);
|
|
93
112
|
|
|
94
|
-
document.body.classList.add("intro");
|
|
95
|
-
window.setTimeout(() => document.body.classList.remove("intro"), 2100);
|
|
96
113
|
ctx.fn.buildWorld();
|
|
97
114
|
ctx.fn.paint(false);
|
|
98
|
-
ctx
|
|
99
|
-
|
|
115
|
+
const playback = installPlayback(ctx);
|
|
116
|
+
if (opts.restore) {
|
|
117
|
+
ctx.userMoved = true;
|
|
118
|
+
ctx.fn.setView(opts.restore.view, false);
|
|
119
|
+
playback.restore(opts.restore.playback);
|
|
120
|
+
if (opts.restore.inspectorOpen) {
|
|
121
|
+
els.inspector.classList.add("open");
|
|
122
|
+
els.workspace.classList.add("inspopen");
|
|
123
|
+
els.inspector.setAttribute("aria-hidden", "false");
|
|
124
|
+
ctx.fn.renderInspector();
|
|
125
|
+
} else {
|
|
126
|
+
els.inspector.classList.remove("open");
|
|
127
|
+
els.workspace.classList.remove("inspopen");
|
|
128
|
+
els.inspector.setAttribute("aria-hidden", "true");
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
// The entrance animation is for the first mount only; a live update restores and must not replay it.
|
|
132
|
+
document.body.classList.add("intro");
|
|
133
|
+
const introTimer = window.setTimeout(() => document.body.classList.remove("intro"), 2100);
|
|
134
|
+
// destroy() clears these timers, so an update inside the window cannot fire the old instance's callbacks.
|
|
135
|
+
ctx.disposers.push(() => { window.clearTimeout(introTimer); document.body.classList.remove("intro"); });
|
|
136
|
+
ctx.fn.fitWidth(false);
|
|
137
|
+
const fitTimer = window.setTimeout(() => { if (!ctx.userMoved) ctx.fn.fitWidth(false); }, 120);
|
|
138
|
+
ctx.disposers.push(() => window.clearTimeout(fitTimer));
|
|
139
|
+
}
|
|
100
140
|
|
|
101
141
|
const api: FlowStudio = {
|
|
102
142
|
model,
|
|
@@ -110,33 +150,44 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]
|
|
|
110
150
|
setMode: id => ctx.fn.setMode(id),
|
|
111
151
|
simulate: id => ctx.fn.simulate(id),
|
|
112
152
|
resetSimulation: () => { ctx.fn.clearSim(); ctx.fn.renderInspector(); },
|
|
153
|
+
play: id => playback.play(id),
|
|
154
|
+
pause: () => playback.pause(),
|
|
155
|
+
step: () => playback.step(),
|
|
156
|
+
seek: t => playback.seek(t),
|
|
157
|
+
playback: () => playback.playback(),
|
|
158
|
+
update: (nextModel, updateOpts) => {
|
|
159
|
+
const prepared = nextModel.layout === "auto" ? autoLayout(nextModel) : nextModel;
|
|
160
|
+
const nextIssues = validateModel(prepared);
|
|
161
|
+
const count = nextIssues.filter(i => i.level === "error").length;
|
|
162
|
+
if (count) {
|
|
163
|
+
els.banner.textContent = `Update failed: ${count} errors. Showing the last good graph.`;
|
|
164
|
+
els.banner.hidden = false;
|
|
165
|
+
return { ok: false, issues: nextIssues };
|
|
166
|
+
}
|
|
167
|
+
const changes = diffModels(ctx.model, prepared);
|
|
168
|
+
const saved: SavedState = { view: { ...ctx.view }, mode: ctx.mode, selected: ctx.selected, inspectorOpen: els.inspector.classList.contains("open"), query: ctx.query, hops: ctx.hops, filter: ctx.filter, collapsed: new Set(ctx.collapsed), knownGroups: new Set(ctx.model.nodes.filter(n => n.kind === "group").map(n => n.id)), playback: playback.playback() };
|
|
169
|
+
api.destroy();
|
|
170
|
+
const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
|
|
171
|
+
notifySelection(saved.selected, replacement.selected(), opts.onSelect);
|
|
172
|
+
// Refresh this api in place: callers keep their reference and window.FlowStudio stays this
|
|
173
|
+
// instance, so an update can never overwrite a different mounted engine.
|
|
174
|
+
Object.assign(api, replacement);
|
|
175
|
+
window.FlowStudio = api;
|
|
176
|
+
els.banner.hidden = true; els.banner.textContent = "";
|
|
177
|
+
if (updateOpts?.animate !== false && !ctx.reduced) {
|
|
178
|
+
for (const id of changes.changed) root.querySelector<HTMLElement>(`#n_${CSS.escape(id)}`)?.classList.add("updated");
|
|
179
|
+
for (const id of changes.added) root.querySelector<HTMLElement>(`#n_${CSS.escape(id)}`)?.classList.add("added");
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, issues: nextIssues };
|
|
182
|
+
},
|
|
113
183
|
destroy: () => {
|
|
114
184
|
ctx.fn.clearSim();
|
|
115
185
|
ctx.disposers.forEach(d => d());
|
|
116
186
|
ctx.disposers = [];
|
|
117
187
|
style?.remove();
|
|
118
|
-
if (window.FlowStudio === api) delete window.FlowStudio;
|
|
188
|
+
if (window.FlowStudio === api || window.FlowStudio?.model === api.model) delete window.FlowStudio;
|
|
119
189
|
},
|
|
120
190
|
};
|
|
121
191
|
window.FlowStudio = api;
|
|
122
192
|
return api;
|
|
123
193
|
}
|
|
124
|
-
|
|
125
|
-
function auto(): void {
|
|
126
|
-
const m = document.getElementById("flow-model");
|
|
127
|
-
if (!m) return;
|
|
128
|
-
try {
|
|
129
|
-
const model = JSON.parse(m.textContent ?? "{}") as Model;
|
|
130
|
-
const n = document.getElementById("flow-notes");
|
|
131
|
-
const notes = n ? (JSON.parse(n.textContent ?? "[]") as NoteDef[]) : undefined;
|
|
132
|
-
mount(document, model, { notes });
|
|
133
|
-
} catch (e) {
|
|
134
|
-
console.error(e);
|
|
135
|
-
const h = document.getElementById("help");
|
|
136
|
-
if (h) h.textContent = "This page could not be drawn: " + (e instanceof Error ? e.message : String(e));
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
window.flowStudioMount = mount;
|
|
141
|
-
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", auto);
|
|
142
|
-
else auto();
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
import type { Ctx } from "./ctx.ts";
|
|
6
6
|
import { esc } from "./model.ts";
|
|
7
7
|
import type { NodeDef, RelationDef } from "./model.ts";
|
|
8
|
-
import { bucketOf, isImproved, isNew, nodeStateId, nodeVisible } from "./rules.ts";
|
|
8
|
+
import { bucketOf, effectiveLinks, groupFor, isImproved, isNew, nodeStateId, nodeVisible } from "./rules.ts";
|
|
9
9
|
import { matches, relationSet, relationsOf } from "./interact.ts";
|
|
10
|
+
import { sparklinePoints } from "./metrics.ts";
|
|
10
11
|
|
|
11
12
|
const BUCKET_COLOR = { good: "ok", neutral: "muted", gap: "warn" } as const;
|
|
12
13
|
const PILL = { ok: "ok", warn: "warn", crit: "warn", info: "info", accent: "info", muted: "muted" } as const;
|
|
@@ -29,9 +30,10 @@ export function install(ctx: Ctx): void {
|
|
|
29
30
|
}));
|
|
30
31
|
box.querySelectorAll<HTMLElement>("[data-g]").forEach(b => b.addEventListener("click", () => { ctx.fn.select(b.dataset.g ?? null); ctx.fn.zoomToPath(); }));
|
|
31
32
|
box.querySelectorAll<HTMLElement>("[data-act]").forEach(b => b.addEventListener("click", () => ctx.fn.simulate(b.dataset.act ?? "")));
|
|
33
|
+
box.querySelectorAll<HTMLElement>("[data-toggle-group]").forEach(b => b.addEventListener("click", () => ctx.fn.toggleGroup(b.dataset.toggleGroup ?? "")));
|
|
32
34
|
box.querySelector("#simReset")?.addEventListener("click", () => { ctx.fn.clearSim(); ctx.fn.renderInspector(); ctx.fn.announce("Simulation reset."); });
|
|
33
35
|
box.querySelector("#zPath")?.addEventListener("click", () => ctx.fn.zoomToPath());
|
|
34
|
-
box.querySelector("#zBand")?.addEventListener("click", () => { const n = ctx.selected ? ctx.nodes.get(ctx.selected) : undefined; if (n) ctx.fn.zoomToBand(n.lane); });
|
|
36
|
+
box.querySelector("#zBand")?.addEventListener("click", () => { const n = ctx.selected ? ctx.nodes.get(ctx.selected) : undefined; if (n?.lane) ctx.fn.zoomToBand(n.lane); });
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function renderInspector(): void {
|
|
@@ -40,7 +42,7 @@ export function install(ctx: Ctx): void {
|
|
|
40
42
|
const head = `<div class="insp-h"><span>Inspector</span><button type="button" id="hideInsp" aria-label="Close the inspector">Close ✕</button></div>`;
|
|
41
43
|
|
|
42
44
|
if (!sel) {
|
|
43
|
-
const found = ctx.query && !ctx.dismissed ? model.nodes.filter(n => nodeVisible(model, ctx.mode, n) && matches(ctx, n)) : [];
|
|
45
|
+
const found = ctx.query && !ctx.dismissed ? model.nodes.filter(n => nodeVisible(model, ctx.mode, n, ctx.collapsed) && matches(ctx, n)) : [];
|
|
44
46
|
const open = found.length > 0;
|
|
45
47
|
box.classList.toggle("open", open);
|
|
46
48
|
ws.classList.toggle("inspopen", open);
|
|
@@ -49,7 +51,7 @@ export function install(ctx: Ctx): void {
|
|
|
49
51
|
const rows = found.map(f => {
|
|
50
52
|
const b = bucketOf(model, nodeStateId(model, ctx.mode, f));
|
|
51
53
|
const hits = (f.members ?? []).filter(m => m.toLowerCase().includes(ctx.query)).length;
|
|
52
|
-
return `<button type="button" class="dr" data-g="${esc(f.id)}"><span class="dbar" style="background:var(--${BUCKET_COLOR[b]})"></span><span class="dtx"><span class="dn">${esc(f.title)}</span><span class="dk">${hits} match · ${esc(model.columns[f.col]?.title ?? "")}</span></span></button>`;
|
|
54
|
+
return `<button type="button" class="dr" data-g="${esc(f.id)}"><span class="dbar" style="background:var(--${BUCKET_COLOR[b]})"></span><span class="dtx"><span class="dn">${esc(f.title)}</span><span class="dk">${hits} match · ${esc(model.columns[f.col ?? 0]?.title ?? "")}</span></span></button>`;
|
|
53
55
|
}).join("");
|
|
54
56
|
box.innerHTML = head + `<div class="insp-b"><div class="insp-sec"><b>${found.length}</b> group${found.length === 1 ? "" : "s"} match “${esc(ctx.query)}”</div><div class="drill">${rows}</div></div>`;
|
|
55
57
|
wire(box);
|
|
@@ -82,11 +84,27 @@ export function install(ctx: Ctx): void {
|
|
|
82
84
|
+ (isNew(model, ctx.mode, sel) ? '<span class="pill muted">new in this view</span>' : "");
|
|
83
85
|
const actions = (model.inspector?.actions ?? []).map(a => `<button type="button" class="danger" data-act="${esc(a.id)}">▶ ${esc(a.label)}</button>`).join("");
|
|
84
86
|
const members = sel.members ?? [];
|
|
87
|
+
const owner = groupFor(model, sel.id);
|
|
88
|
+
const foldAction = sel.kind === "group" && sel.contains?.length && ctx.collapsed.has(sel.id)
|
|
89
|
+
? `<button type="button" data-toggle-group="${esc(sel.id)}">Expand group</button>`
|
|
90
|
+
: owner && !ctx.collapsed.has(owner.id)
|
|
91
|
+
? `<button type="button" data-toggle-group="${esc(owner.id)}">Collapse into ${esc(owner.title)}</button>` : "";
|
|
92
|
+
const exits = sel.kind === "gateway" ? effectiveLinks(model, ctx.mode, ctx.collapsed, ctx.nodes).filter(l => l.from === sel.id).map(l => {
|
|
93
|
+
const target = ctx.nodes.get(l.to);
|
|
94
|
+
return `<div class="dr"><span class="dtx"><span class="dn">${esc(l.label ?? "Unlabelled")}</span><span class="dk">${esc(target?.title ?? l.to)}</span></span></div>`;
|
|
95
|
+
}).join("") : "";
|
|
96
|
+
const metric = sel.metrics;
|
|
97
|
+
const series = metric?.series ?? [];
|
|
98
|
+
const points = sparklinePoints(series, 300, 40);
|
|
99
|
+
const metricHtml = metric ? `<div class="insp-sec metrics"><b>Metrics</b><table class="meta"><tr><th>label</th><td>${esc(metric.label ?? "Metric")}</td></tr><tr><th>value</th><td>${esc(String(metric.value))} ${esc(metric.unit ?? "")}</td></tr>${metric.max != null ? `<tr><th>max</th><td>${esc(String(metric.max))} ${esc(metric.unit ?? "")}</td></tr>` : ""}</table>${points ? `<svg width="300" height="40" viewBox="0 0 300 40" role="img" aria-label="Metric trend"><polyline points="${points}"/></svg><div class="metric-range"><span>${esc(String(Math.min(...series)))}</span><span>${esc(String(Math.max(...series)))}</span></div>` : ""}</div>` : "";
|
|
85
100
|
|
|
86
101
|
box.innerHTML = head + `<div class="counts">${counts}</div>
|
|
87
|
-
<div class="insp-b"><div class="insp-sec"><p class="nname">${esc(sel.title)}</p><div class="nkind">${esc(model.columns[sel.col]?.title ?? "")} · ${esc(lane?.title ?? sel.lane)}</div><div class="pillrow">${pills}</div>${sel.subtitle ? `<p class="note">${esc(sel.subtitle)}</p>` : ""}</div>
|
|
102
|
+
<div class="insp-b"><div class="insp-sec"><p class="nname">${esc(sel.title)}</p><div class="nkind">${esc(model.columns[sel.col ?? 0]?.title ?? "")} · ${esc(lane?.title ?? sel.lane)}</div><div class="pillrow">${pills}</div>${sel.subtitle ? `<p class="note">${esc(sel.subtitle)}</p>` : ""}</div>
|
|
88
103
|
<div class="insp-sec"><b>Details</b><table class="meta"><tr><th>state</th><td>${esc(st?.word ?? sid)}</td></tr><tr><th>members</th><td>${Math.max(1, sel.weight ?? 1)}</td></tr><tr><th>lane</th><td>${esc(lane?.title ?? sel.lane)}${lane?.badge ? ` (${esc(lane.badge)})` : ""}</td></tr>${sel.upgrade && bucket !== "good" ? `<tr><th>can improve</th><td>${esc(sel.upgrade)}</td></tr>` : ""}</table></div>
|
|
104
|
+
${metricHtml}
|
|
105
|
+
${sel.kind === "gateway" ? `<div class="insp-sec"><b>Exits</b><div class="drill">${exits}</div></div>` : ""}
|
|
89
106
|
${members.length ? `<div class="insp-sec"><b>Members</b> (${Math.max(members.length, sel.weight ?? 0)})<input id="mq" type="search" placeholder="Filter ${members.length} members…" aria-label="Filter members"><div class="drill" id="mlist"></div><p class="drill-hint" id="mcount"></p></div>` : `<div class="insp-sec"><p class="note">No member names are recorded for this group.</p></div>`}
|
|
107
|
+
${foldAction ? `<div class="acts">${foldAction}</div>` : ""}
|
|
90
108
|
<div class="acts"><button type="button" id="zPath">Zoom to path</button><button type="button" id="zBand">Go to lane</button></div>
|
|
91
109
|
${actions ? `<div class="acts">${actions}<button type="button" id="simReset"${ctx.simActive ? "" : " hidden"}>Reset</button></div>` : ""}</div>`;
|
|
92
110
|
wire(box);
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
import type { Ctx } from "./ctx.ts";
|
|
4
4
|
import type { NodeDef, RelationDef } from "./model.ts";
|
|
5
5
|
import type { Rect } from "./viewport.ts";
|
|
6
|
-
import { connected, relatives, type Edge } from "./trace.ts";
|
|
7
|
-
import { bucketOf, canonOf,
|
|
6
|
+
import { connected, relatives, withinHops, type Edge } from "./trace.ts";
|
|
7
|
+
import { bucketOf, canonOf, effectiveLinks, nodeStateId, nodeVisible, toggleGroupState } from "./rules.ts";
|
|
8
|
+
import { notifySelection } from "./selection.ts";
|
|
8
9
|
|
|
9
10
|
export function relationsOf(ctx: Ctx): RelationDef[] {
|
|
10
11
|
const given = ctx.model.inspector?.relations;
|
|
@@ -18,7 +19,7 @@ export function relationsOf(ctx: Ctx): RelationDef[] {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function visibleEdges(ctx: Ctx): Edge[] {
|
|
21
|
-
return
|
|
22
|
+
return effectiveLinks(ctx.model, ctx.mode, ctx.collapsed, ctx.nodes).map(l => ({ from: l.from, to: l.to }));
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function relationSet(ctx: Ctx, rel: RelationDef, start: string): Set<string> {
|
|
@@ -42,7 +43,7 @@ export function matches(ctx: Ctx, n: NodeDef): boolean {
|
|
|
42
43
|
export function install(ctx: Ctx): void {
|
|
43
44
|
const { els, model } = ctx;
|
|
44
45
|
const canon = (id: string) => canonOf(ctx.nodes, id);
|
|
45
|
-
const visibleNodes = () => model.nodes.filter(n => nodeVisible(model, ctx.mode, n));
|
|
46
|
+
const visibleNodes = () => model.nodes.filter(n => nodeVisible(model, ctx.mode, n, ctx.collapsed));
|
|
46
47
|
const inspInset = () => (els.inspector.classList.contains("open") ? 340 : 0);
|
|
47
48
|
|
|
48
49
|
function activeSet(id: string | null): Set<string> | null {
|
|
@@ -60,7 +61,7 @@ export function install(ctx: Ctx): void {
|
|
|
60
61
|
return ctx.filter === "gap" ? b === "gap" : !!n.upgrade && b !== "good";
|
|
61
62
|
}).map(n => canon(n.id)));
|
|
62
63
|
}
|
|
63
|
-
if (ctx.query) return
|
|
64
|
+
if (ctx.query) return withinHops(edges, canon, visibleNodes().filter(n => matches(ctx, n)).map(n => n.id), ctx.hops);
|
|
64
65
|
return null;
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -84,6 +85,7 @@ export function install(ctx: Ctx): void {
|
|
|
84
85
|
|
|
85
86
|
function select(id: string | null): void {
|
|
86
87
|
clearSim();
|
|
88
|
+
const previous = ctx.selected;
|
|
87
89
|
ctx.selected = id ? canon(id) : null;
|
|
88
90
|
ctx.hl = null;
|
|
89
91
|
ctx.dismissed = false;
|
|
@@ -92,6 +94,7 @@ export function install(ctx: Ctx): void {
|
|
|
92
94
|
ctx.fn.renderInspector();
|
|
93
95
|
drawTrail();
|
|
94
96
|
if (ctx.selected) revealNode(ctx.selected);
|
|
97
|
+
notifySelection(previous, ctx.selected, ctx.onSelect);
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
/** An overlay is a copy of one link drawn on top. pathLength=1 makes the draw-in independent of zoom. */
|
|
@@ -180,7 +183,7 @@ export function install(ctx: Ctx): void {
|
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
function rectOf(ids: string[]): Rect | null {
|
|
183
|
-
const ps = ids.map(i => ctx.geom.nodes[i]).filter((p): p is NonNullable<typeof p> => !!p && !!ctx.nodes.get(p.id) && nodeVisible(model, ctx.mode, ctx.nodes.get(p.id) as NodeDef));
|
|
186
|
+
const ps = ids.map(i => ctx.geom.nodes[i]).filter((p): p is NonNullable<typeof p> => !!p && !!ctx.nodes.get(p.id) && nodeVisible(model, ctx.mode, ctx.nodes.get(p.id) as NodeDef, ctx.collapsed));
|
|
184
187
|
if (!ps.length) return null;
|
|
185
188
|
const x1 = Math.min(...ps.map(p => p.x)), y1 = Math.min(...ps.map(p => p.y));
|
|
186
189
|
const x2 = Math.max(...ps.map(p => p.x + p.w)), y2 = Math.max(...ps.map(p => p.y + p.h));
|
|
@@ -218,11 +221,11 @@ export function install(ctx: Ctx): void {
|
|
|
218
221
|
clearSim();
|
|
219
222
|
ctx.mode = id;
|
|
220
223
|
const sel = ctx.selected && ctx.nodes.get(ctx.selected);
|
|
221
|
-
if (sel && !nodeVisible(model, id, sel)) select(null);
|
|
224
|
+
if (sel && !nodeVisible(model, id, sel, ctx.collapsed)) select(null);
|
|
222
225
|
ctx.fn.paint(true);
|
|
223
226
|
ctx.userMoved = false;
|
|
224
227
|
ctx.fn.fitWidth(true);
|
|
225
|
-
const added = model.nodes.filter(n => nodeVisible(model, id, n) && !nodeVisible(model, model.views?.[0]?.id ?? "all", n)).length;
|
|
228
|
+
const added = model.nodes.filter(n => nodeVisible(model, id, n, ctx.collapsed) && !nodeVisible(model, model.views?.[0]?.id ?? "all", n, ctx.collapsed)).length;
|
|
226
229
|
ctx.fn.announce(added ? `${model.views?.find(v => v.id === id)?.label ?? id} view: ${added} groups added.` : `${model.views?.find(v => v.id === id)?.label ?? id} view.`);
|
|
227
230
|
}
|
|
228
231
|
|
|
@@ -236,5 +239,15 @@ export function install(ctx: Ctx): void {
|
|
|
236
239
|
ctx.fn.zoomToBand = zoomToBand;
|
|
237
240
|
ctx.fn.escape = escape;
|
|
238
241
|
ctx.fn.setMode = setMode;
|
|
242
|
+
ctx.fn.toggleGroup = (id: string) => {
|
|
243
|
+
const group = model.nodes.find(n => n.id === id && n.kind === "group" && n.contains?.length);
|
|
244
|
+
if (!group) return;
|
|
245
|
+
const change = toggleGroupState(model, ctx.mode, ctx.collapsed, id);
|
|
246
|
+
if (!change) return;
|
|
247
|
+
ctx.collapsed = change.collapsed;
|
|
248
|
+
ctx.fn.paint();
|
|
249
|
+
ctx.fn.select(change.selected);
|
|
250
|
+
ctx.fn.announce(change.expanding ? `Expanded ${group.title}.` : `Collapsed into ${group.title}.`);
|
|
251
|
+
};
|
|
239
252
|
ctx.fn.announce = (t: string) => { els.live.textContent = t; };
|
|
240
253
|
}
|
|
@@ -67,3 +67,78 @@ test("a node whose column is out of range does not produce NaN", () => {
|
|
|
67
67
|
const g = computeGeometry(mm);
|
|
68
68
|
assert.ok(Object.values(g.nodes).every(n => [n.x, n.y, n.w, n.h].every(Number.isFinite)));
|
|
69
69
|
});
|
|
70
|
+
test("a gateway has a 64 px diamond box", () => {
|
|
71
|
+
const mm = m(); mm.nodes[0]!.kind = "gateway";
|
|
72
|
+
assert.equal(computeGeometry(mm).nodes.a!.h, 64);
|
|
73
|
+
});
|
|
74
|
+
test("loop-back channels stay below nodes, inside their lane, and separate from the next lane", () => {
|
|
75
|
+
const mm = m();
|
|
76
|
+
mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
|
|
77
|
+
mm.links.push({ id: "again", from: "b", to: "a", state: "ok", kind: "loop-back" });
|
|
78
|
+
const g = computeGeometry(mm);
|
|
79
|
+
const y = g.loops.return!.channelY;
|
|
80
|
+
assert.ok(y >= g.nodes.a!.y + g.nodes.a!.h + 10);
|
|
81
|
+
assert.ok(y >= g.nodes.b!.y + g.nodes.b!.h + 10);
|
|
82
|
+
assert.equal(g.loops.again!.channelY, y + 8);
|
|
83
|
+
assert.ok(g.lanes.l1!.y + g.lanes.l1!.h > g.loops.again!.channelY);
|
|
84
|
+
assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a loop clears a lower node within its horizontal span", () => {
|
|
88
|
+
const mm = m();
|
|
89
|
+
mm.columns.push({ id: "c2", title: "C", width: 150 });
|
|
90
|
+
mm.nodes.find(n => n.id === "b")!.col = 2;
|
|
91
|
+
mm.nodes.push({ id: "middle", kind: "card", lane: "l1", col: 1, row: 1, state: "ok", title: "Middle" });
|
|
92
|
+
mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
|
|
93
|
+
const g = computeGeometry(mm);
|
|
94
|
+
assert.equal(g.loops.return!.channelY, g.nodes.middle!.y + g.nodes.middle!.h + 10);
|
|
95
|
+
assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a lower node outside the loop span does not lower its channel", () => {
|
|
99
|
+
const mm = m();
|
|
100
|
+
mm.columns.push({ id: "c2", title: "C", width: 150 });
|
|
101
|
+
mm.nodes.push({ id: "outside", kind: "card", lane: "l1", col: 2, row: 1, state: "ok", title: "Outside" });
|
|
102
|
+
mm.links.push({ id: "return", from: "b", to: "a", state: "ok", kind: "loop-back" });
|
|
103
|
+
const g = computeGeometry(mm);
|
|
104
|
+
assert.equal(g.loops.return!.channelY, g.nodes.a!.y + g.nodes.a!.h + 10);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("interleaved lanes calculate channels after all endpoint shifts", () => {
|
|
108
|
+
const mm = m();
|
|
109
|
+
mm.nodes = [
|
|
110
|
+
{ id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A" },
|
|
111
|
+
{ id: "deep", kind: "card", lane: "l1", col: 1, row: 5, state: "ok", title: "Deep" },
|
|
112
|
+
{ id: "b", kind: "card", lane: "l2", col: 0, row: 1, state: "ok", title: "B" },
|
|
113
|
+
];
|
|
114
|
+
mm.links = [{ id: "return", from: "deep", to: "b", state: "ok", kind: "loop-back" }];
|
|
115
|
+
const g = computeGeometry(mm);
|
|
116
|
+
assert.ok(g.loops.return!.channelY >= g.nodes.b!.y + g.nodes.b!.h + 10);
|
|
117
|
+
assert.ok(g.lanes.l1!.y + g.lanes.l1!.h <= g.lanes.l2!.y);
|
|
118
|
+
assert.ok(Object.values(g.nodes).every(n => [n.x, n.y, n.w, n.h].every(Number.isFinite)));
|
|
119
|
+
assert.ok(Object.values(g.loops).every(l => Number.isFinite(l.channelY)));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("free layout uses explicit world rectangles and works without columns", () => {
|
|
123
|
+
const mm = m();
|
|
124
|
+
mm.layout = "free"; mm.columns = [];
|
|
125
|
+
mm.nodes = [
|
|
126
|
+
{ id: "a", kind: "card", lane: "l1", x: 18, y: 70, w: 121, h: 47, state: "ok", title: "A" },
|
|
127
|
+
{ id: "b", kind: "card", lane: "l2", x: 250, y: 300, state: "ok", title: "B" },
|
|
128
|
+
];
|
|
129
|
+
const g = computeGeometry(mm);
|
|
130
|
+
assert.deepEqual(g.xs, []);
|
|
131
|
+
assert.deepEqual(g.nodes.a, { id: "a", x: 18, y: 70, w: 121, h: 47 });
|
|
132
|
+
assert.equal(g.nodes.b?.w, 190);
|
|
133
|
+
assert.ok(g.world.w >= 440);
|
|
134
|
+
assert.ok(g.lanes.l1 && g.lanes.l2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("free layout preserves finite negative node coordinates", () => {
|
|
138
|
+
const mm = m(); mm.layout = "free"; mm.columns = [];
|
|
139
|
+
mm.nodes = [{ id: "a", kind: "card", lane: "l1", x: -80, y: -40, w: 60, h: 30, state: "ok", title: "A" }];
|
|
140
|
+
const g = computeGeometry(mm);
|
|
141
|
+
assert.equal(g.nodes.a!.x, -80);
|
|
142
|
+
assert.equal(g.nodes.a!.y, -40);
|
|
143
|
+
assert.ok([g.bounds.x, g.bounds.y, g.bounds.w, g.bounds.h, g.world.w, g.world.h].every(Number.isFinite));
|
|
144
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Geometry from a model: where every node, lane and column sits in world units. Pure, no DOM.
|
|
2
2
|
// Node sizes are fixed per kind, so layout never depends on fonts or text measurement.
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import { NODE_HEIGHT, type Model } from "./model.ts";
|
|
5
5
|
import type { Rect, Size } from "./viewport.ts";
|
|
6
6
|
|
|
7
7
|
export interface Placed extends Rect { id: string }
|
|
@@ -9,6 +9,7 @@ export interface Geometry {
|
|
|
9
9
|
xs: number[];
|
|
10
10
|
nodes: Record<string, Placed>;
|
|
11
11
|
lanes: Record<string, Rect>;
|
|
12
|
+
loops: Record<string, { channelY: number }>;
|
|
12
13
|
bounds: Rect;
|
|
13
14
|
world: Size;
|
|
14
15
|
}
|
|
@@ -16,7 +17,6 @@ export interface LayoutOptions { pitch: number; top: number; gap: number; padX:
|
|
|
16
17
|
|
|
17
18
|
export const DEFAULTS: LayoutOptions = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
|
|
18
19
|
|
|
19
|
-
const HEIGHT: Record<Kind, number> = { card: 54, group: 54, gateway: 54, placeholder: 54, chip: 34, start: 40, end: 40, event: 40, note: 40 };
|
|
20
20
|
const LANE_PAD_TOP = 38; // room for the lane title
|
|
21
21
|
const LANE_PAD_BOTTOM = 14;
|
|
22
22
|
|
|
@@ -29,21 +29,59 @@ export function computeGeometry(m: Model, opt: Partial<LayoutOptions> = {}): Geo
|
|
|
29
29
|
|
|
30
30
|
const nodes: Record<string, Placed> = {};
|
|
31
31
|
for (const n of m.nodes) {
|
|
32
|
-
const h =
|
|
33
|
-
|
|
32
|
+
const h = NODE_HEIGHT[n.kind] ?? 54;
|
|
33
|
+
if (m.layout === "free") {
|
|
34
|
+
nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const first = Math.min(Math.max(0, n.col ?? 0), m.columns.length - 1);
|
|
34
38
|
const last = Math.min(m.columns.length - 1, first + (n.colSpan ?? 1) - 1);
|
|
35
39
|
const x0 = xs[first] ?? o.padX;
|
|
36
40
|
const x1 = (xs[last] ?? x0) + (m.columns[last]?.width ?? 0);
|
|
37
|
-
nodes[n.id] = { id: n.id, x: x0, y: o.top + 40 + n.row * o.pitch - h / 2, w: Math.max(1, x1 - x0), h };
|
|
41
|
+
nodes[n.id] = { id: n.id, x: x0, y: o.top + 40 + (n.row ?? 0) * o.pitch - h / 2, w: Math.max(1, x1 - x0), h };
|
|
38
42
|
}
|
|
43
|
+
if (m.layout === "free") world.w = Math.max(world.w, ...Object.values(nodes).map(p => p.x + p.w + o.padX), 1);
|
|
39
44
|
|
|
45
|
+
const loops: Geometry["loops"] = {};
|
|
40
46
|
const lanes: Record<string, Rect> = {};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
const visibleMembers = (id: string) => m.nodes.filter(n => n.lane === id && o.visible(n.id)).map(n => nodes[n.id]!);
|
|
48
|
+
const ordered = m.lanes.map(l => ({ l, mem: visibleMembers(l.id) })).filter(x => x.mem.length)
|
|
49
|
+
.sort((a, b) => Math.min(...a.mem.map(p => p.y)) - Math.min(...b.mem.map(p => p.y)));
|
|
50
|
+
let previousBottom = -Infinity;
|
|
51
|
+
for (const { l, mem } of ordered) {
|
|
52
|
+
const top = Math.min(...mem.map(p => p.y)) - LANE_PAD_TOP;
|
|
53
|
+
const shift = m.layout === "free" ? 0 : Math.max(0, previousBottom + 8 - top);
|
|
54
|
+
if (shift) for (const p of mem) p.y += shift;
|
|
55
|
+
const y1 = top + shift;
|
|
56
|
+
const laneNodeIds = new Set(mem.map(p => p.id));
|
|
57
|
+
// Reserve channel space before shifting the following lane. The exact channel
|
|
58
|
+
// positions are calculated only after every lane has reached its final y.
|
|
59
|
+
const possibleLoops = m.links.filter(link => link.kind === "loop-back" && o.visible(link.from) && o.visible(link.to)
|
|
60
|
+
&& (laneNodeIds.has(link.from) || laneNodeIds.has(link.to))).length;
|
|
61
|
+
const bottom = Math.max(...mem.map(p => p.y + p.h));
|
|
62
|
+
const y2 = bottom + (possibleLoops ? 10 + 8 * (possibleLoops - 1) : 0) + LANE_PAD_BOTTOM;
|
|
46
63
|
lanes[l.id] = { x: 8, y: y1, w: world.w - 16, h: y2 - y1 };
|
|
64
|
+
previousBottom = y2;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const channelCounts = new Map<string, number>();
|
|
68
|
+
for (const link of m.links) {
|
|
69
|
+
if (link.kind !== "loop-back" || !o.visible(link.from) || !o.visible(link.to)) continue;
|
|
70
|
+
const a = nodes[link.from], b = nodes[link.to];
|
|
71
|
+
if (!a || !b) continue;
|
|
72
|
+
const lower = a.y + a.h >= b.y + b.h ? a : b;
|
|
73
|
+
const laneId = m.nodes.find(n => n.id === lower.id)?.lane;
|
|
74
|
+
if (!laneId || !lanes[laneId]) continue;
|
|
75
|
+
const left = Math.min(a.x + a.w / 2, b.x + b.w / 2);
|
|
76
|
+
const right = Math.max(a.x + a.w / 2, b.x + b.w / 2);
|
|
77
|
+
const laneMembers = visibleMembers(laneId);
|
|
78
|
+
const crossed = laneMembers.filter(p => p.x < right && p.x + p.w > left);
|
|
79
|
+
const lowestBottom = Math.max(a.y + a.h, b.y + b.h, ...crossed.map(p => p.y + p.h));
|
|
80
|
+
const index = channelCounts.get(laneId) ?? 0;
|
|
81
|
+
loops[link.id] = { channelY: lowestBottom + 10 + 8 * index };
|
|
82
|
+
channelCounts.set(laneId, index + 1);
|
|
83
|
+
const lane = lanes[laneId];
|
|
84
|
+
lane.h = Math.max(lane.h, loops[link.id]!.channelY + LANE_PAD_BOTTOM - lane.y);
|
|
47
85
|
}
|
|
48
86
|
|
|
49
87
|
const all = Object.values(lanes);
|
|
@@ -53,5 +91,5 @@ export function computeGeometry(m: Model, opt: Partial<LayoutOptions> = {}): Geo
|
|
|
53
91
|
bounds = { x: 8, y: top, w: world.w - 16, h: Math.max(...all.map(r => r.y + r.h)) - top };
|
|
54
92
|
}
|
|
55
93
|
world.h = Math.max(1, bounds.y + bounds.h + 70);
|
|
56
|
-
return { xs, nodes, lanes, bounds, world };
|
|
94
|
+
return { xs, nodes, lanes, loops, bounds, world };
|
|
57
95
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Replaced with the shared template at library build time. */
|
|
2
|
+
export const SHELL_BODY_HTML = "/*__SHELL_BODY__*/";
|
|
3
|
+
export { mount, type FlowStudio } from "./engine.ts";
|
|
4
|
+
export * from "./model.ts";
|
|
5
|
+
export { autoLayout } from "./autolayout.ts";
|
|
6
|
+
export { computeGeometry } from "./layout.ts";
|
|
7
|
+
export type { Geometry, LayoutOptions, Placed } from "./layout.ts";
|
|
8
|
+
export * from "./viewport.ts";
|
|
9
|
+
export * from "./routing.ts";
|
|
10
|
+
export * from "./trace.ts";
|
|
11
|
+
export * from "./rules.ts";
|
|
12
|
+
export * from "./playback.ts";
|
|
13
|
+
export * from "./diff.ts";
|
|
14
|
+
export * as viewport from "./viewport.ts";
|
|
15
|
+
export * as routing from "./routing.ts";
|
|
16
|
+
export * as trace from "./trace.ts";
|
|
17
|
+
export * as rules from "./rules.ts";
|
|
18
|
+
export * as playback from "./playback.ts";
|
|
19
|
+
export * as diff from "./diff.ts";
|
|
20
|
+
export { stateCss } from "./stateCss.ts";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { sparklinePoints, metricBadge, sparkline } from "./metrics.ts";
|
|
4
|
+
|
|
5
|
+
test("sparkline handles flat, missing and varied series", () => {
|
|
6
|
+
assert.equal(sparklinePoints([], 56, 12), "");
|
|
7
|
+
assert.equal(sparklinePoints([4, 4], 56, 12), "0,6 56,6");
|
|
8
|
+
assert.equal(sparklinePoints([0, 10], 56, 12), "0,12 56,0");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("metricBadge renders value, unit, and consistent title", () => {
|
|
12
|
+
assert.equal(metricBadge(undefined), "");
|
|
13
|
+
const badge1 = metricBadge({ value: 92, unit: "ms", label: "Hit rate" });
|
|
14
|
+
assert.ok(badge1.includes('title="Hit rate: 92 ms"'), "Title includes label, value and unit");
|
|
15
|
+
assert.ok(badge1.includes(">92 ms"), "Badge body includes value and unit");
|
|
16
|
+
assert.ok(!badge1.includes("<i"), "No fill bar when max is omitted");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("metricBadge renders clamped fill bar when max is valid, ignores invalid max", () => {
|
|
20
|
+
const withMax = metricBadge({ value: 50, max: 100, label: "CPU" });
|
|
21
|
+
assert.ok(withMax.includes('<i style="width:50%"></i>'));
|
|
22
|
+
|
|
23
|
+
const clamped = metricBadge({ value: 150, max: 100 });
|
|
24
|
+
assert.ok(clamped.includes('<i style="width:100%"></i>'));
|
|
25
|
+
|
|
26
|
+
const negativeVal = metricBadge({ value: -10, max: 100 });
|
|
27
|
+
assert.ok(negativeVal.includes('<i style="width:0%"></i>'));
|
|
28
|
+
|
|
29
|
+
const nullMax = metricBadge({ value: 50, max: null as any });
|
|
30
|
+
assert.ok(!nullMax.includes("<i"), "null max produces no bar");
|
|
31
|
+
|
|
32
|
+
const zeroMax = metricBadge({ value: 50, max: 0 });
|
|
33
|
+
assert.ok(!zeroMax.includes("<i"), "zero max produces no bar");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("metricBadge escapes special HTML characters", () => {
|
|
37
|
+
const hostile = metricBadge({ value: 1, unit: '<script>"&', label: '<tag>&"' });
|
|
38
|
+
assert.ok(!hostile.includes("<script>"));
|
|
39
|
+
assert.ok(!hostile.includes("<tag>"));
|
|
40
|
+
assert.ok(hostile.includes("<script>"));
|
|
41
|
+
assert.ok(hostile.includes("<tag>"));
|
|
42
|
+
});
|
|
43
|
+
test("metricBadge escapes quotes so nothing can break out of the title attribute", () => {
|
|
44
|
+
// label and unit land inside title="…": a raw quote there would open attacker-controlled attributes.
|
|
45
|
+
const hostile = metricBadge({ value: 1, unit: '">&x', label: 'y" onmouseover="z' });
|
|
46
|
+
assert.ok(hostile.includes('title="y" onmouseover="z: 1 ">&x"'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("sparkline renders accessible SVG with aria-hidden", () => {
|
|
50
|
+
assert.equal(sparkline(undefined), "");
|
|
51
|
+
assert.equal(sparkline({ value: 10 }), "");
|
|
52
|
+
assert.equal(sparkline({ value: 10, series: [1] }), "");
|
|
53
|
+
|
|
54
|
+
const svg = sparkline({ value: 10, series: [2, 8] }, 56, 12);
|
|
55
|
+
assert.ok(svg.startsWith('<svg class="spark" width="56" height="12" viewBox="0 0 56 12" aria-hidden="true">'));
|
|
56
|
+
assert.ok(svg.includes('<polyline points="0,12 56,0"/>'));
|
|
57
|
+
});
|