@caddis/cli 0.4.44 → 0.4.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/README.md +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +102 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +19 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +142 -0
- package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +415 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +95 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +54 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +680 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +179 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +42 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +175 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +239 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +134 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +193 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +126 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +253 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +144 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +95 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +167 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +250 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +100 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +203 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +33 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +43 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +135 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +86 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +73 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +190 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +40 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +40 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +2656 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +23 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
- package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +102 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +19 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +142 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +415 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +95 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +54 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +680 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +179 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +42 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +175 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +239 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +134 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +193 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +126 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +253 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +144 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +95 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +167 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +250 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +100 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +203 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +33 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +43 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +135 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +86 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +73 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +190 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +40 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +40 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +2656 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +23 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// flow-studio engine entry. mount() draws a model into a page that has the shell's element ids and returns
|
|
2
|
+
// a small API (also placed on window.FlowStudio for the check harness). The generated demo page calls mount() itself.
|
|
3
|
+
|
|
4
|
+
import { validateModel, type Model, type NoteDef } from "./model.ts";
|
|
5
|
+
import { autoLayout } from "./autolayout.ts";
|
|
6
|
+
import type { Ctx, Els } from "./ctx.ts";
|
|
7
|
+
import type { View } from "./viewport.ts";
|
|
8
|
+
import type { Geometry } from "./layout.ts";
|
|
9
|
+
import { stateCss } from "./stateCss.ts";
|
|
10
|
+
import { defaultView, initialCollapsed } from "./rules.ts";
|
|
11
|
+
import { install as installInteract } from "./interact.ts";
|
|
12
|
+
import { install as installRender } from "./render.ts";
|
|
13
|
+
import { install as installControls } from "./controls.ts";
|
|
14
|
+
import { install as installInspector } from "./inspector.ts";
|
|
15
|
+
import { install as installChrome } from "./chrome.ts";
|
|
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";
|
|
21
|
+
|
|
22
|
+
export interface FlowStudio {
|
|
23
|
+
model: Model;
|
|
24
|
+
geometry(): Geometry;
|
|
25
|
+
view(): View;
|
|
26
|
+
setView(v: View, animate?: boolean): void;
|
|
27
|
+
fit(animate?: boolean): void;
|
|
28
|
+
select(id: string | null): void;
|
|
29
|
+
selected(): string | null;
|
|
30
|
+
mode(): string;
|
|
31
|
+
setMode(id: string): void;
|
|
32
|
+
simulate(actionId: string): void;
|
|
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[] };
|
|
40
|
+
destroy(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
declare global {
|
|
44
|
+
interface Window { FlowStudio?: FlowStudio; flowStudioMount?: typeof mount }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const IDS: Record<keyof Els, string> = {
|
|
48
|
+
vp: "viewport", world: "world", svg: "edges", layer: "edgeLayer", cols: "cols", tools: "tools", zoom: "zoomLabel",
|
|
49
|
+
inspector: "inspector", workspace: "workspace", live: "live", strip: "layerstrip", tiles: "kpis", legend: "legend",
|
|
50
|
+
ledger: "ledger", table: "tbl", q: "q", hops: "hops", matchinfo: "matchinfo", modeSwitch: "modeSwitch", drawer: "drawer",
|
|
51
|
+
notelist: "notelist", notesBtn: "notesBtn", theme: "theme", specbody: "specbody", spec: "spec", title: "title",
|
|
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",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function findEls(root: ParentNode): Els {
|
|
58
|
+
const out = {} as Record<string, Element>;
|
|
59
|
+
for (const [key, id] of Object.entries(IDS)) {
|
|
60
|
+
const e = root.querySelector("#" + id);
|
|
61
|
+
if (!e) throw new Error(`flow-studio: the page has no element #${id}`);
|
|
62
|
+
out[key] = e;
|
|
63
|
+
}
|
|
64
|
+
return out as unknown as Els;
|
|
65
|
+
}
|
|
66
|
+
|
|
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 {
|
|
69
|
+
if (!model || !Array.isArray(model.columns) || !Array.isArray(model.lanes) || !Array.isArray(model.nodes) || !Array.isArray(model.links) || !model.states) {
|
|
70
|
+
throw new Error("flow-studio: the model needs columns, lanes, states, nodes and links");
|
|
71
|
+
}
|
|
72
|
+
if (model.layout === "auto") model = autoLayout(model);
|
|
73
|
+
const els = findEls(root);
|
|
74
|
+
const issues = validateModel(model);
|
|
75
|
+
const errors = issues.filter(i => i.level === "error");
|
|
76
|
+
for (const i of issues) (i.level === "error" ? console.error : console.warn)(`flow-studio ${i.code} ${i.where}: ${i.message}`);
|
|
77
|
+
if (errors.length) els.help.textContent = `${errors.length} model error(s). See the console. The page shows what it can.`;
|
|
78
|
+
|
|
79
|
+
const rm = matchMedia("(prefers-reduced-motion: reduce)");
|
|
80
|
+
const ctx: Ctx = {
|
|
81
|
+
model, root, els, onSelect: opts.onSelect,
|
|
82
|
+
notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
|
|
83
|
+
nodes: new Map(model.nodes.map(n => [n.id, n])),
|
|
84
|
+
links: model.links,
|
|
85
|
+
geom: { xs: [], nodes: {}, lanes: {}, loops: {}, bounds: { x: 0, y: 0, w: 1, h: 1 }, world: { w: 1, h: 1 } },
|
|
86
|
+
view: { x: 0, y: 0, k: 1 },
|
|
87
|
+
reduced: rm.matches,
|
|
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",
|
|
92
|
+
spaceDown: false, userMoved: false, dismissed: false, simActive: false, moved: 0,
|
|
93
|
+
nodeEls: new Map(), laneEls: new Map(), edgeEls: [], labelEls: [], overlays: [], simTimers: [], disposers: [],
|
|
94
|
+
fn: {} as Ctx["fn"],
|
|
95
|
+
};
|
|
96
|
+
const onRm = (e: MediaQueryListEvent) => { ctx.reduced = e.matches; };
|
|
97
|
+
rm.addEventListener("change", onRm);
|
|
98
|
+
ctx.disposers.push(() => rm.removeEventListener("change", onRm));
|
|
99
|
+
|
|
100
|
+
let style = document.getElementById("state-css") as HTMLStyleElement | null;
|
|
101
|
+
if (!style) { style = document.createElement("style"); style.id = "state-css"; document.head.prepend(style); }
|
|
102
|
+
style.textContent = stateCss(model.states);
|
|
103
|
+
|
|
104
|
+
installInteract(ctx);
|
|
105
|
+
installRender(ctx);
|
|
106
|
+
installControls(ctx);
|
|
107
|
+
installInspector(ctx);
|
|
108
|
+
installChrome(ctx);
|
|
109
|
+
installNotes(ctx);
|
|
110
|
+
els.q.value = ctx.query;
|
|
111
|
+
els.hops.value = String(ctx.hops);
|
|
112
|
+
|
|
113
|
+
ctx.fn.buildWorld();
|
|
114
|
+
ctx.fn.paint(false);
|
|
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
|
+
}
|
|
140
|
+
|
|
141
|
+
const api: FlowStudio = {
|
|
142
|
+
model,
|
|
143
|
+
geometry: () => ctx.geom,
|
|
144
|
+
view: () => ({ ...ctx.view }),
|
|
145
|
+
setView: (v, animate) => ctx.fn.setView(v, animate),
|
|
146
|
+
fit: animate => { ctx.userMoved = false; ctx.fn.fit(animate); },
|
|
147
|
+
select: id => ctx.fn.select(id),
|
|
148
|
+
selected: () => ctx.selected,
|
|
149
|
+
mode: () => ctx.mode,
|
|
150
|
+
setMode: id => ctx.fn.setMode(id),
|
|
151
|
+
simulate: id => ctx.fn.simulate(id),
|
|
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
|
+
},
|
|
183
|
+
destroy: () => {
|
|
184
|
+
ctx.fn.clearSim();
|
|
185
|
+
ctx.disposers.forEach(d => d());
|
|
186
|
+
ctx.disposers = [];
|
|
187
|
+
style?.remove();
|
|
188
|
+
if (window.FlowStudio === api || window.FlowStudio?.model === api.model) delete window.FlowStudio;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
window.FlowStudio = api;
|
|
192
|
+
return api;
|
|
193
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The slide-in inspector. It uses the markup and class names of the host app's own inspector
|
|
2
|
+
// (.insp, .insp-h, .counts .cbtn, .insp-b, .drill, .pill, .meta), so it looks native when the page lives in an app.
|
|
3
|
+
// It is closed until a click, and it also opens to list search matches when nothing is selected.
|
|
4
|
+
|
|
5
|
+
import type { Ctx } from "./ctx.ts";
|
|
6
|
+
import { esc } from "./model.ts";
|
|
7
|
+
import type { NodeDef, RelationDef } from "./model.ts";
|
|
8
|
+
import { bucketOf, effectiveLinks, groupFor, isImproved, isNew, nodeStateId, nodeVisible } from "./rules.ts";
|
|
9
|
+
import { matches, relationSet, relationsOf } from "./interact.ts";
|
|
10
|
+
import { sparklinePoints } from "./metrics.ts";
|
|
11
|
+
|
|
12
|
+
const BUCKET_COLOR = { good: "ok", neutral: "muted", gap: "warn" } as const;
|
|
13
|
+
const PILL = { ok: "ok", warn: "warn", crit: "warn", info: "info", accent: "info", muted: "muted" } as const;
|
|
14
|
+
|
|
15
|
+
export function install(ctx: Ctx): void {
|
|
16
|
+
const { els, model } = ctx;
|
|
17
|
+
|
|
18
|
+
const matchesFilter = (n: NodeDef, f: RelationDef["filter"]): boolean =>
|
|
19
|
+
!f || ((f.col === undefined || n.col === f.col) && (f.kind === undefined || n.kind === f.kind));
|
|
20
|
+
|
|
21
|
+
function wire(box: HTMLElement): void {
|
|
22
|
+
box.querySelector("#hideInsp")?.addEventListener("click", () => {
|
|
23
|
+
if (ctx.selected) ctx.fn.select(null);
|
|
24
|
+
else { ctx.dismissed = true; ctx.fn.renderInspector(); }
|
|
25
|
+
});
|
|
26
|
+
box.querySelectorAll<HTMLElement>("[data-hl]").forEach(b => b.addEventListener("click", () => {
|
|
27
|
+
ctx.hl = ctx.hl === b.dataset.hl ? null : (b.dataset.hl ?? null);
|
|
28
|
+
ctx.fn.trace(ctx.selected);
|
|
29
|
+
ctx.fn.renderInspector();
|
|
30
|
+
}));
|
|
31
|
+
box.querySelectorAll<HTMLElement>("[data-g]").forEach(b => b.addEventListener("click", () => { ctx.fn.select(b.dataset.g ?? null); ctx.fn.zoomToPath(); }));
|
|
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 ?? "")));
|
|
34
|
+
box.querySelector("#simReset")?.addEventListener("click", () => { ctx.fn.clearSim(); ctx.fn.renderInspector(); ctx.fn.announce("Simulation reset."); });
|
|
35
|
+
box.querySelector("#zPath")?.addEventListener("click", () => ctx.fn.zoomToPath());
|
|
36
|
+
box.querySelector("#zBand")?.addEventListener("click", () => { const n = ctx.selected ? ctx.nodes.get(ctx.selected) : undefined; if (n?.lane) ctx.fn.zoomToBand(n.lane); });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderInspector(): void {
|
|
40
|
+
const box = els.inspector, ws = els.workspace;
|
|
41
|
+
const sel = ctx.selected ? ctx.nodes.get(ctx.selected) : undefined;
|
|
42
|
+
const head = `<div class="insp-h"><span>Inspector</span><button type="button" id="hideInsp" aria-label="Close the inspector">Close ✕</button></div>`;
|
|
43
|
+
|
|
44
|
+
if (!sel) {
|
|
45
|
+
const found = ctx.query && !ctx.dismissed ? model.nodes.filter(n => nodeVisible(model, ctx.mode, n, ctx.collapsed) && matches(ctx, n)) : [];
|
|
46
|
+
const open = found.length > 0;
|
|
47
|
+
box.classList.toggle("open", open);
|
|
48
|
+
ws.classList.toggle("inspopen", open);
|
|
49
|
+
box.setAttribute("aria-hidden", String(!open));
|
|
50
|
+
if (!open) return;
|
|
51
|
+
const rows = found.map(f => {
|
|
52
|
+
const b = bucketOf(model, nodeStateId(model, ctx.mode, f));
|
|
53
|
+
const hits = (f.members ?? []).filter(m => m.toLowerCase().includes(ctx.query)).length;
|
|
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>`;
|
|
55
|
+
}).join("");
|
|
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>`;
|
|
57
|
+
wire(box);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
box.classList.add("open");
|
|
62
|
+
ws.classList.add("inspopen");
|
|
63
|
+
box.setAttribute("aria-hidden", "false");
|
|
64
|
+
|
|
65
|
+
const sid = nodeStateId(model, ctx.mode, sel);
|
|
66
|
+
const st = model.states[sid];
|
|
67
|
+
const bucket = bucketOf(model, sid);
|
|
68
|
+
const lane = model.lanes.find(l => l.id === sel.lane);
|
|
69
|
+
const counts = relationsOf(ctx).slice(0, 3).map(r => {
|
|
70
|
+
const set = relationSet(ctx, r, sel.id);
|
|
71
|
+
let n = 0;
|
|
72
|
+
for (const id of set) {
|
|
73
|
+
const x = ctx.nodes.get(id);
|
|
74
|
+
if (!x) continue;
|
|
75
|
+
if (r.filter) { if (matchesFilter(x, r.filter)) n += Math.max(1, x.weight ?? 1); }
|
|
76
|
+
else if (id !== sel.id) n += 1;
|
|
77
|
+
}
|
|
78
|
+
const cls = r.id === "up" ? "cn--up" : r.id === "down" ? "cn--down" : "";
|
|
79
|
+
return `<button class="cbtn" data-hl="${esc(r.id)}" aria-pressed="${ctx.hl === r.id}"><span class="cn ${cls}">${n}</span><span class="cl">${esc(r.label)}</span></button>`;
|
|
80
|
+
}).join("");
|
|
81
|
+
|
|
82
|
+
const pills = `<span class="pill ${PILL[st?.tone ?? "muted"]}">${esc(st?.word ?? sid)}</span>`
|
|
83
|
+
+ (isImproved(model, ctx.mode, sel) ? '<span class="pill info">gap filled</span>' : "")
|
|
84
|
+
+ (isNew(model, ctx.mode, sel) ? '<span class="pill muted">new in this view</span>' : "");
|
|
85
|
+
const actions = (model.inspector?.actions ?? []).map(a => `<button type="button" class="danger" data-act="${esc(a.id)}">▶ ${esc(a.label)}</button>`).join("");
|
|
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>` : "";
|
|
100
|
+
|
|
101
|
+
box.innerHTML = head + `<div class="counts">${counts}</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>
|
|
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>` : ""}
|
|
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>` : ""}
|
|
108
|
+
<div class="acts"><button type="button" id="zPath">Zoom to path</button><button type="button" id="zBand">Go to lane</button></div>
|
|
109
|
+
${actions ? `<div class="acts">${actions}<button type="button" id="simReset"${ctx.simActive ? "" : " hidden"}>Reset</button></div>` : ""}</div>`;
|
|
110
|
+
wire(box);
|
|
111
|
+
|
|
112
|
+
const mq = box.querySelector<HTMLInputElement>("#mq");
|
|
113
|
+
const draw = () => {
|
|
114
|
+
const f = (mq?.value ?? "").toLowerCase();
|
|
115
|
+
const rows = members.filter(m => m.toLowerCase().includes(f));
|
|
116
|
+
const list = box.querySelector<HTMLElement>("#mlist");
|
|
117
|
+
if (list) list.innerHTML = rows.slice(0, 14).map(m => `<div class="dr"><span class="dbar" style="background:var(--${BUCKET_COLOR[bucket]})"></span><span class="dtx"><span class="dn mono" style="font-size:12px">${esc(m)}</span></span></div>`).join("");
|
|
118
|
+
const cnt = box.querySelector<HTMLElement>("#mcount");
|
|
119
|
+
const weight = sel.weight ?? 0;
|
|
120
|
+
if (cnt) cnt.textContent = `${Math.min(14, rows.length)} of ${rows.length}${weight > members.length ? ` named (${weight} members in all)` : ""}`;
|
|
121
|
+
};
|
|
122
|
+
if (mq) { mq.addEventListener("input", draw); draw(); }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
ctx.fn.renderInspector = renderInspector;
|
|
126
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Interaction: tracing a path, selecting, the draw-in trail, the what-if action, zooming to a path or lane.
|
|
2
|
+
|
|
3
|
+
import type { Ctx } from "./ctx.ts";
|
|
4
|
+
import type { NodeDef, RelationDef } from "./model.ts";
|
|
5
|
+
import type { Rect } from "./viewport.ts";
|
|
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";
|
|
9
|
+
|
|
10
|
+
export function relationsOf(ctx: Ctx): RelationDef[] {
|
|
11
|
+
const given = ctx.model.inspector?.relations;
|
|
12
|
+
if (given && given.length) return given;
|
|
13
|
+
const last = Math.max(0, ctx.model.columns.length - 1);
|
|
14
|
+
return [
|
|
15
|
+
{ id: "up", label: "Upstream", dir: "up" },
|
|
16
|
+
{ id: "down", label: "Downstream", dir: "down" },
|
|
17
|
+
{ id: "end", label: "At the end", dir: "down", filter: { col: last } },
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function visibleEdges(ctx: Ctx): Edge[] {
|
|
22
|
+
return effectiveLinks(ctx.model, ctx.mode, ctx.collapsed, ctx.nodes).map(l => ({ from: l.from, to: l.to }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function relationSet(ctx: Ctx, rel: RelationDef, start: string): Set<string> {
|
|
26
|
+
const canon = (id: string) => canonOf(ctx.nodes, id);
|
|
27
|
+
const set = relatives(visibleEdges(ctx), canon, start, rel.dir);
|
|
28
|
+
if (!rel.filter) return set;
|
|
29
|
+
const out = new Set<string>([canon(start)]);
|
|
30
|
+
for (const id of set) {
|
|
31
|
+
const n = ctx.nodes.get(id);
|
|
32
|
+
if (!n) continue;
|
|
33
|
+
if ((rel.filter.col === undefined || n.col === rel.filter.col) && (rel.filter.kind === undefined || n.kind === rel.filter.kind)) out.add(id);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function matches(ctx: Ctx, n: NodeDef): boolean {
|
|
39
|
+
const q = ctx.query;
|
|
40
|
+
return !!q && (n.title.toLowerCase().includes(q) || (n.members ?? []).some(m => m.toLowerCase().includes(q)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function install(ctx: Ctx): void {
|
|
44
|
+
const { els, model } = ctx;
|
|
45
|
+
const canon = (id: string) => canonOf(ctx.nodes, id);
|
|
46
|
+
const visibleNodes = () => model.nodes.filter(n => nodeVisible(model, ctx.mode, n, ctx.collapsed));
|
|
47
|
+
const inspInset = () => (els.inspector.classList.contains("open") ? 340 : 0);
|
|
48
|
+
|
|
49
|
+
function activeSet(id: string | null): Set<string> | null {
|
|
50
|
+
const edges = visibleEdges(ctx);
|
|
51
|
+
if (id) {
|
|
52
|
+
if (id === ctx.selected && ctx.hl) {
|
|
53
|
+
const rel = relationsOf(ctx).find(r => r.id === ctx.hl);
|
|
54
|
+
if (rel) return relationSet(ctx, rel, id);
|
|
55
|
+
}
|
|
56
|
+
return connected(edges, canon, id);
|
|
57
|
+
}
|
|
58
|
+
if (ctx.filter) {
|
|
59
|
+
return new Set(visibleNodes().filter(n => {
|
|
60
|
+
const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
|
|
61
|
+
return ctx.filter === "gap" ? b === "gap" : !!n.upgrade && b !== "good";
|
|
62
|
+
}).map(n => canon(n.id)));
|
|
63
|
+
}
|
|
64
|
+
if (ctx.query) return withinHops(edges, canon, visibleNodes().filter(n => matches(ctx, n)).map(n => n.id), ctx.hops);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function trace(id: string | null): void {
|
|
69
|
+
const set = activeSet(id);
|
|
70
|
+
const lit = !!id && !!set;
|
|
71
|
+
for (const n of model.nodes) {
|
|
72
|
+
const d = ctx.nodeEls.get(n.id);
|
|
73
|
+
if (!d) continue;
|
|
74
|
+
d.classList.toggle("dim", !!set && !set.has(canon(n.id)));
|
|
75
|
+
d.classList.toggle("lit", lit && !!set && set.has(canon(n.id)));
|
|
76
|
+
}
|
|
77
|
+
ctx.edgeEls.forEach(p => {
|
|
78
|
+
if (!p) return;
|
|
79
|
+
const inSet = !!set && set.has(canon(p.dataset.a ?? "")) && set.has(canon(p.dataset.b ?? ""));
|
|
80
|
+
p.classList.toggle("dim", !!set && !inSet);
|
|
81
|
+
p.classList.toggle("hot", lit && inSet);
|
|
82
|
+
});
|
|
83
|
+
ctx.labelEls.forEach(l => { if (l) l.classList.toggle("show", lit && !!set && set.has(canon(l.dataset.a ?? "")) && set.has(canon(l.dataset.b ?? ""))); });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function select(id: string | null): void {
|
|
87
|
+
clearSim();
|
|
88
|
+
const previous = ctx.selected;
|
|
89
|
+
ctx.selected = id ? canon(id) : null;
|
|
90
|
+
ctx.hl = null;
|
|
91
|
+
ctx.dismissed = false;
|
|
92
|
+
for (const n of model.nodes) ctx.nodeEls.get(n.id)?.classList.toggle("sel", !!ctx.selected && canon(n.id) === ctx.selected);
|
|
93
|
+
trace(ctx.selected);
|
|
94
|
+
ctx.fn.renderInspector();
|
|
95
|
+
drawTrail();
|
|
96
|
+
if (ctx.selected) revealNode(ctx.selected);
|
|
97
|
+
notifySelection(previous, ctx.selected, ctx.onSelect);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An overlay is a copy of one link drawn on top. pathLength=1 makes the draw-in independent of zoom. */
|
|
101
|
+
function overlay(edge: SVGPathElement, cls: string, delay: number, dur: number): SVGPathElement {
|
|
102
|
+
const o = edge.cloneNode(false) as SVGPathElement;
|
|
103
|
+
o.setAttribute("class", cls);
|
|
104
|
+
o.setAttribute("pathLength", "1");
|
|
105
|
+
o.removeAttribute("data-i");
|
|
106
|
+
els.layer.appendChild(o);
|
|
107
|
+
ctx.overlays.push(o);
|
|
108
|
+
if (ctx.reduced) return o;
|
|
109
|
+
o.style.strokeDasharray = "1 1";
|
|
110
|
+
o.style.strokeDashoffset = "1";
|
|
111
|
+
o.getBoundingClientRect();
|
|
112
|
+
o.style.transition = `stroke-dashoffset ${dur}s ${delay}s ease-out`;
|
|
113
|
+
o.style.strokeDashoffset = "0";
|
|
114
|
+
return o;
|
|
115
|
+
}
|
|
116
|
+
function dropOverlays(cls: string): void {
|
|
117
|
+
ctx.overlays.filter(o => o.classList.contains(cls)).forEach(o => o.remove());
|
|
118
|
+
ctx.overlays = ctx.overlays.filter(o => o.isConnected);
|
|
119
|
+
}
|
|
120
|
+
function drawTrail(): void {
|
|
121
|
+
dropOverlays("trail");
|
|
122
|
+
if (!ctx.selected || ctx.reduced) return;
|
|
123
|
+
const set = connected(visibleEdges(ctx), canon, ctx.selected);
|
|
124
|
+
const c0 = ctx.nodes.get(ctx.selected)?.col ?? 0;
|
|
125
|
+
for (const p of ctx.edgeEls) {
|
|
126
|
+
if (!p || p.classList.contains("off")) continue;
|
|
127
|
+
const a = canon(p.dataset.a ?? ""), b = canon(p.dataset.b ?? "");
|
|
128
|
+
if (!(set.has(a) && set.has(b))) continue;
|
|
129
|
+
const delay = Math.min(Math.abs((ctx.nodes.get(a)?.col ?? 0) - c0), Math.abs((ctx.nodes.get(b)?.col ?? 0) - c0)) * 0.09;
|
|
130
|
+
const o = overlay(p, "trail", delay, 0.55);
|
|
131
|
+
window.setTimeout(() => { o.style.transition = "opacity .6s"; o.style.opacity = "0"; window.setTimeout(() => o.remove(), 650); }, (delay + 0.55) * 1000 + 150);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function simulate(actionId: string): void {
|
|
136
|
+
clearSim();
|
|
137
|
+
if (!ctx.selected) return;
|
|
138
|
+
const action = model.inspector?.actions?.find(a => a.id === actionId);
|
|
139
|
+
const stale = action?.staleText ?? "potentially stale";
|
|
140
|
+
ctx.simActive = true;
|
|
141
|
+
const down = relatives(visibleEdges(ctx), canon, ctx.selected, "down");
|
|
142
|
+
const c0 = ctx.nodes.get(ctx.selected)?.col ?? 0;
|
|
143
|
+
for (const p of ctx.edgeEls) {
|
|
144
|
+
if (!p || p.classList.contains("off")) continue;
|
|
145
|
+
const a = canon(p.dataset.a ?? ""), b = canon(p.dataset.b ?? "");
|
|
146
|
+
if (down.has(a) && down.has(b)) overlay(p, "ripple", Math.abs((ctx.nodes.get(a)?.col ?? 0) - c0) * 0.11, 0.5);
|
|
147
|
+
}
|
|
148
|
+
down.forEach(id => {
|
|
149
|
+
if (id === ctx.selected) return;
|
|
150
|
+
const d = ctx.nodeEls.get(id);
|
|
151
|
+
if (!d) return;
|
|
152
|
+
const delay = ctx.reduced ? 0 : Math.abs((ctx.nodes.get(id)?.col ?? 0) - c0) * 110 + 450;
|
|
153
|
+
ctx.simTimers.push(window.setTimeout(() => {
|
|
154
|
+
d.classList.add("fail");
|
|
155
|
+
const sub = d.querySelector(".s");
|
|
156
|
+
if (sub) { d.dataset.s = sub.innerHTML; sub.textContent = stale; }
|
|
157
|
+
}, delay));
|
|
158
|
+
});
|
|
159
|
+
ctx.fn.announce(`Simulation: ${down.size - 1} groups downstream of ${ctx.nodes.get(ctx.selected)?.title ?? ""} would be ${stale}.`);
|
|
160
|
+
ctx.fn.renderInspector();
|
|
161
|
+
}
|
|
162
|
+
function clearSim(): void {
|
|
163
|
+
ctx.simTimers.forEach(t => clearTimeout(t));
|
|
164
|
+
ctx.simTimers = [];
|
|
165
|
+
dropOverlays("ripple");
|
|
166
|
+
document.querySelectorAll<HTMLElement>(".node.fail").forEach(d => {
|
|
167
|
+
d.classList.remove("fail");
|
|
168
|
+
const sub = d.querySelector(".s");
|
|
169
|
+
if (sub && d.dataset.s !== undefined) { sub.innerHTML = d.dataset.s; delete d.dataset.s; }
|
|
170
|
+
});
|
|
171
|
+
ctx.simActive = false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The inspector covers the right 340 px. If the group would sit under it, glide the view left. */
|
|
175
|
+
function revealNode(id: string): void {
|
|
176
|
+
const d = ctx.nodeEls.get(id);
|
|
177
|
+
if (!d) return;
|
|
178
|
+
const r = d.getBoundingClientRect(), v = els.vp.getBoundingClientRect(), edge = v.right - 340 - 24;
|
|
179
|
+
if (r.right > edge) {
|
|
180
|
+
ctx.userMoved = true;
|
|
181
|
+
ctx.fn.setView({ k: ctx.view.k, x: ctx.view.x + Math.max(edge - r.right, v.left + 60 - r.left), y: ctx.view.y }, true);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function rectOf(ids: string[]): Rect | null {
|
|
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));
|
|
187
|
+
if (!ps.length) return null;
|
|
188
|
+
const x1 = Math.min(...ps.map(p => p.x)), y1 = Math.min(...ps.map(p => p.y));
|
|
189
|
+
const x2 = Math.max(...ps.map(p => p.x + p.w)), y2 = Math.max(...ps.map(p => p.y + p.h));
|
|
190
|
+
return { x: x1 - 20, y: y1 - 20, w: x2 - x1 + 40, h: y2 - y1 + 40 };
|
|
191
|
+
}
|
|
192
|
+
function zoomToPath(): void {
|
|
193
|
+
if (!ctx.selected) return;
|
|
194
|
+
const ids = [...connected(visibleEdges(ctx), canon, ctx.selected)];
|
|
195
|
+
ids.push(...model.nodes.filter(n => n.ref && ids.includes(n.ref)).map(n => n.id));
|
|
196
|
+
const r = rectOf(ids);
|
|
197
|
+
if (r) { ctx.userMoved = true; ctx.fn.fit(true, r, inspInset()); ctx.fn.announce(`Zoomed to the path of ${ctx.nodes.get(ctx.selected)?.title ?? ""}`); }
|
|
198
|
+
}
|
|
199
|
+
function zoomToBand(id: string): void {
|
|
200
|
+
const r = ctx.geom.lanes[id];
|
|
201
|
+
if (!r) return;
|
|
202
|
+
ctx.userMoved = true;
|
|
203
|
+
ctx.fn.fit(true, { x: r.x - 10, y: r.y - 10, w: r.w + 20, h: r.h + 20 }, inspInset());
|
|
204
|
+
ctx.fn.announce(`Zoomed to the lane ${model.lanes.find(l => l.id === id)?.title ?? id}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function escape(): void {
|
|
208
|
+
clearSim();
|
|
209
|
+
select(null);
|
|
210
|
+
ctx.filter = null;
|
|
211
|
+
ctx.query = "";
|
|
212
|
+
els.q.value = "";
|
|
213
|
+
els.matchinfo.textContent = "";
|
|
214
|
+
ctx.fn.buildChrome();
|
|
215
|
+
trace(null);
|
|
216
|
+
ctx.fn.toggleNotes(false);
|
|
217
|
+
}
|
|
218
|
+
function setMode(id: string): void {
|
|
219
|
+
if (ctx.mode === id) return;
|
|
220
|
+
document.body.classList.remove("intro");
|
|
221
|
+
clearSim();
|
|
222
|
+
ctx.mode = id;
|
|
223
|
+
const sel = ctx.selected && ctx.nodes.get(ctx.selected);
|
|
224
|
+
if (sel && !nodeVisible(model, id, sel, ctx.collapsed)) select(null);
|
|
225
|
+
ctx.fn.paint(true);
|
|
226
|
+
ctx.userMoved = false;
|
|
227
|
+
ctx.fn.fitWidth(true);
|
|
228
|
+
const added = model.nodes.filter(n => nodeVisible(model, id, n, ctx.collapsed) && !nodeVisible(model, model.views?.[0]?.id ?? "all", n, ctx.collapsed)).length;
|
|
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.`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
ctx.fn.trace = trace;
|
|
233
|
+
ctx.fn.select = select;
|
|
234
|
+
ctx.fn.drawTrail = drawTrail;
|
|
235
|
+
ctx.fn.simulate = simulate;
|
|
236
|
+
ctx.fn.clearSim = clearSim;
|
|
237
|
+
ctx.fn.rectOf = rectOf;
|
|
238
|
+
ctx.fn.zoomToPath = zoomToPath;
|
|
239
|
+
ctx.fn.zoomToBand = zoomToBand;
|
|
240
|
+
ctx.fn.escape = escape;
|
|
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
|
+
};
|
|
252
|
+
ctx.fn.announce = (t: string) => { els.live.textContent = t; };
|
|
253
|
+
}
|