@caddis/cli 0.4.44 → 0.4.45
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 +53 -0
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +103 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +11 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
- package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +123 -0
- package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +79 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +55 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +37 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +388 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +26 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +64 -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/validate.mjs +38 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +171 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +231 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +118 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +142 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +108 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +240 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +69 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +57 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +107 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +165 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +155 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +13 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +11 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +88 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +35 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +66 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +44 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +157 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +20 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +22 -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 +1744 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +61 -0
- package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +53 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +103 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +11 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +123 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +79 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +55 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +37 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +388 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +26 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +64 -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/validate.mjs +38 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +171 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +231 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +118 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +142 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +108 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +240 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +69 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +57 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +107 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +165 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +155 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +13 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +11 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +88 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +35 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +66 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +44 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +157 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +20 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +22 -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 +1744 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +61 -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,142 @@
|
|
|
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 type { Ctx, Els } from "./ctx.ts";
|
|
6
|
+
import type { View } from "./viewport.ts";
|
|
7
|
+
import type { Geometry } from "./layout.ts";
|
|
8
|
+
import { stateCss } from "./stateCss.ts";
|
|
9
|
+
import { defaultView } from "./rules.ts";
|
|
10
|
+
import { install as installInteract } from "./interact.ts";
|
|
11
|
+
import { install as installRender } from "./render.ts";
|
|
12
|
+
import { install as installControls } from "./controls.ts";
|
|
13
|
+
import { install as installInspector } from "./inspector.ts";
|
|
14
|
+
import { install as installChrome } from "./chrome.ts";
|
|
15
|
+
import { install as installNotes } from "./notes.ts";
|
|
16
|
+
|
|
17
|
+
export interface FlowStudio {
|
|
18
|
+
model: Model;
|
|
19
|
+
geometry(): Geometry;
|
|
20
|
+
view(): View;
|
|
21
|
+
setView(v: View, animate?: boolean): void;
|
|
22
|
+
fit(animate?: boolean): void;
|
|
23
|
+
select(id: string | null): void;
|
|
24
|
+
selected(): string | null;
|
|
25
|
+
mode(): string;
|
|
26
|
+
setMode(id: string): void;
|
|
27
|
+
simulate(actionId: string): void;
|
|
28
|
+
resetSimulation(): void;
|
|
29
|
+
destroy(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare global {
|
|
33
|
+
interface Window { FlowStudio?: FlowStudio; flowStudioMount?: typeof mount }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const IDS: Record<keyof Els, string> = {
|
|
37
|
+
vp: "viewport", world: "world", svg: "edges", layer: "edgeLayer", cols: "cols", tools: "tools", zoom: "zoomLabel",
|
|
38
|
+
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",
|
|
40
|
+
notelist: "notelist", notesBtn: "notesBtn", theme: "theme", specbody: "specbody", spec: "spec", title: "title",
|
|
41
|
+
readat: "readat", help: "help", footer: "footer",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function findEls(root: ParentNode): Els {
|
|
45
|
+
const out = {} as Record<string, Element>;
|
|
46
|
+
for (const [key, id] of Object.entries(IDS)) {
|
|
47
|
+
const e = root.querySelector("#" + id);
|
|
48
|
+
if (!e) throw new Error(`flow-studio: the page has no element #${id}`);
|
|
49
|
+
out[key] = e;
|
|
50
|
+
}
|
|
51
|
+
return out as unknown as Els;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[] } = {}): FlowStudio {
|
|
55
|
+
if (!model || !Array.isArray(model.columns) || !Array.isArray(model.lanes) || !Array.isArray(model.nodes) || !Array.isArray(model.links) || !model.states) {
|
|
56
|
+
throw new Error("flow-studio: the model needs columns, lanes, states, nodes and links");
|
|
57
|
+
}
|
|
58
|
+
const els = findEls(root);
|
|
59
|
+
const issues = validateModel(model);
|
|
60
|
+
const errors = issues.filter(i => i.level === "error");
|
|
61
|
+
for (const i of issues) (i.level === "error" ? console.error : console.warn)(`flow-studio ${i.code} ${i.where}: ${i.message}`);
|
|
62
|
+
if (errors.length) els.help.textContent = `${errors.length} model error(s). See the console. The page shows what it can.`;
|
|
63
|
+
|
|
64
|
+
const rm = matchMedia("(prefers-reduced-motion: reduce)");
|
|
65
|
+
const ctx: Ctx = {
|
|
66
|
+
model, root, els,
|
|
67
|
+
notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
|
|
68
|
+
nodes: new Map(model.nodes.map(n => [n.id, n])),
|
|
69
|
+
links: model.links,
|
|
70
|
+
geom: { xs: [], nodes: {}, lanes: {}, bounds: { x: 0, y: 0, w: 1, h: 1 }, world: { w: 1, h: 1 } },
|
|
71
|
+
view: { x: 0, y: 0, k: 1 },
|
|
72
|
+
reduced: rm.matches,
|
|
73
|
+
mode: defaultView(model),
|
|
74
|
+
selected: null, hl: null, query: "", filter: null, tool: "select",
|
|
75
|
+
spaceDown: false, userMoved: false, dismissed: false, simActive: false, moved: 0,
|
|
76
|
+
nodeEls: new Map(), laneEls: new Map(), edgeEls: [], labelEls: [], overlays: [], simTimers: [], disposers: [],
|
|
77
|
+
fn: {} as Ctx["fn"],
|
|
78
|
+
};
|
|
79
|
+
const onRm = (e: MediaQueryListEvent) => { ctx.reduced = e.matches; };
|
|
80
|
+
rm.addEventListener("change", onRm);
|
|
81
|
+
ctx.disposers.push(() => rm.removeEventListener("change", onRm));
|
|
82
|
+
|
|
83
|
+
let style = document.getElementById("state-css") as HTMLStyleElement | null;
|
|
84
|
+
if (!style) { style = document.createElement("style"); style.id = "state-css"; document.head.prepend(style); }
|
|
85
|
+
style.textContent = stateCss(model.states);
|
|
86
|
+
|
|
87
|
+
installInteract(ctx);
|
|
88
|
+
installRender(ctx);
|
|
89
|
+
installControls(ctx);
|
|
90
|
+
installInspector(ctx);
|
|
91
|
+
installChrome(ctx);
|
|
92
|
+
installNotes(ctx);
|
|
93
|
+
|
|
94
|
+
document.body.classList.add("intro");
|
|
95
|
+
window.setTimeout(() => document.body.classList.remove("intro"), 2100);
|
|
96
|
+
ctx.fn.buildWorld();
|
|
97
|
+
ctx.fn.paint(false);
|
|
98
|
+
ctx.fn.fitWidth(false);
|
|
99
|
+
window.setTimeout(() => ctx.fn.fitWidth(false), 120);
|
|
100
|
+
|
|
101
|
+
const api: FlowStudio = {
|
|
102
|
+
model,
|
|
103
|
+
geometry: () => ctx.geom,
|
|
104
|
+
view: () => ({ ...ctx.view }),
|
|
105
|
+
setView: (v, animate) => ctx.fn.setView(v, animate),
|
|
106
|
+
fit: animate => { ctx.userMoved = false; ctx.fn.fit(animate); },
|
|
107
|
+
select: id => ctx.fn.select(id),
|
|
108
|
+
selected: () => ctx.selected,
|
|
109
|
+
mode: () => ctx.mode,
|
|
110
|
+
setMode: id => ctx.fn.setMode(id),
|
|
111
|
+
simulate: id => ctx.fn.simulate(id),
|
|
112
|
+
resetSimulation: () => { ctx.fn.clearSim(); ctx.fn.renderInspector(); },
|
|
113
|
+
destroy: () => {
|
|
114
|
+
ctx.fn.clearSim();
|
|
115
|
+
ctx.disposers.forEach(d => d());
|
|
116
|
+
ctx.disposers = [];
|
|
117
|
+
style?.remove();
|
|
118
|
+
if (window.FlowStudio === api) delete window.FlowStudio;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
window.FlowStudio = api;
|
|
122
|
+
return api;
|
|
123
|
+
}
|
|
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();
|
|
@@ -0,0 +1,108 @@
|
|
|
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, isImproved, isNew, nodeStateId, nodeVisible } from "./rules.ts";
|
|
9
|
+
import { matches, relationSet, relationsOf } from "./interact.ts";
|
|
10
|
+
|
|
11
|
+
const BUCKET_COLOR = { good: "ok", neutral: "muted", gap: "warn" } as const;
|
|
12
|
+
const PILL = { ok: "ok", warn: "warn", crit: "warn", info: "info", accent: "info", muted: "muted" } as const;
|
|
13
|
+
|
|
14
|
+
export function install(ctx: Ctx): void {
|
|
15
|
+
const { els, model } = ctx;
|
|
16
|
+
|
|
17
|
+
const matchesFilter = (n: NodeDef, f: RelationDef["filter"]): boolean =>
|
|
18
|
+
!f || ((f.col === undefined || n.col === f.col) && (f.kind === undefined || n.kind === f.kind));
|
|
19
|
+
|
|
20
|
+
function wire(box: HTMLElement): void {
|
|
21
|
+
box.querySelector("#hideInsp")?.addEventListener("click", () => {
|
|
22
|
+
if (ctx.selected) ctx.fn.select(null);
|
|
23
|
+
else { ctx.dismissed = true; ctx.fn.renderInspector(); }
|
|
24
|
+
});
|
|
25
|
+
box.querySelectorAll<HTMLElement>("[data-hl]").forEach(b => b.addEventListener("click", () => {
|
|
26
|
+
ctx.hl = ctx.hl === b.dataset.hl ? null : (b.dataset.hl ?? null);
|
|
27
|
+
ctx.fn.trace(ctx.selected);
|
|
28
|
+
ctx.fn.renderInspector();
|
|
29
|
+
}));
|
|
30
|
+
box.querySelectorAll<HTMLElement>("[data-g]").forEach(b => b.addEventListener("click", () => { ctx.fn.select(b.dataset.g ?? null); ctx.fn.zoomToPath(); }));
|
|
31
|
+
box.querySelectorAll<HTMLElement>("[data-act]").forEach(b => b.addEventListener("click", () => ctx.fn.simulate(b.dataset.act ?? "")));
|
|
32
|
+
box.querySelector("#simReset")?.addEventListener("click", () => { ctx.fn.clearSim(); ctx.fn.renderInspector(); ctx.fn.announce("Simulation reset."); });
|
|
33
|
+
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); });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderInspector(): void {
|
|
38
|
+
const box = els.inspector, ws = els.workspace;
|
|
39
|
+
const sel = ctx.selected ? ctx.nodes.get(ctx.selected) : undefined;
|
|
40
|
+
const head = `<div class="insp-h"><span>Inspector</span><button type="button" id="hideInsp" aria-label="Close the inspector">Close ✕</button></div>`;
|
|
41
|
+
|
|
42
|
+
if (!sel) {
|
|
43
|
+
const found = ctx.query && !ctx.dismissed ? model.nodes.filter(n => nodeVisible(model, ctx.mode, n) && matches(ctx, n)) : [];
|
|
44
|
+
const open = found.length > 0;
|
|
45
|
+
box.classList.toggle("open", open);
|
|
46
|
+
ws.classList.toggle("inspopen", open);
|
|
47
|
+
box.setAttribute("aria-hidden", String(!open));
|
|
48
|
+
if (!open) return;
|
|
49
|
+
const rows = found.map(f => {
|
|
50
|
+
const b = bucketOf(model, nodeStateId(model, ctx.mode, f));
|
|
51
|
+
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>`;
|
|
53
|
+
}).join("");
|
|
54
|
+
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
|
+
wire(box);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
box.classList.add("open");
|
|
60
|
+
ws.classList.add("inspopen");
|
|
61
|
+
box.setAttribute("aria-hidden", "false");
|
|
62
|
+
|
|
63
|
+
const sid = nodeStateId(model, ctx.mode, sel);
|
|
64
|
+
const st = model.states[sid];
|
|
65
|
+
const bucket = bucketOf(model, sid);
|
|
66
|
+
const lane = model.lanes.find(l => l.id === sel.lane);
|
|
67
|
+
const counts = relationsOf(ctx).slice(0, 3).map(r => {
|
|
68
|
+
const set = relationSet(ctx, r, sel.id);
|
|
69
|
+
let n = 0;
|
|
70
|
+
for (const id of set) {
|
|
71
|
+
const x = ctx.nodes.get(id);
|
|
72
|
+
if (!x) continue;
|
|
73
|
+
if (r.filter) { if (matchesFilter(x, r.filter)) n += Math.max(1, x.weight ?? 1); }
|
|
74
|
+
else if (id !== sel.id) n += 1;
|
|
75
|
+
}
|
|
76
|
+
const cls = r.id === "up" ? "cn--up" : r.id === "down" ? "cn--down" : "";
|
|
77
|
+
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>`;
|
|
78
|
+
}).join("");
|
|
79
|
+
|
|
80
|
+
const pills = `<span class="pill ${PILL[st?.tone ?? "muted"]}">${esc(st?.word ?? sid)}</span>`
|
|
81
|
+
+ (isImproved(model, ctx.mode, sel) ? '<span class="pill info">gap filled</span>' : "")
|
|
82
|
+
+ (isNew(model, ctx.mode, sel) ? '<span class="pill muted">new in this view</span>' : "");
|
|
83
|
+
const actions = (model.inspector?.actions ?? []).map(a => `<button type="button" class="danger" data-act="${esc(a.id)}">▶ ${esc(a.label)}</button>`).join("");
|
|
84
|
+
const members = sel.members ?? [];
|
|
85
|
+
|
|
86
|
+
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>
|
|
88
|
+
<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>
|
|
89
|
+
${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>`}
|
|
90
|
+
<div class="acts"><button type="button" id="zPath">Zoom to path</button><button type="button" id="zBand">Go to lane</button></div>
|
|
91
|
+
${actions ? `<div class="acts">${actions}<button type="button" id="simReset"${ctx.simActive ? "" : " hidden"}>Reset</button></div>` : ""}</div>`;
|
|
92
|
+
wire(box);
|
|
93
|
+
|
|
94
|
+
const mq = box.querySelector<HTMLInputElement>("#mq");
|
|
95
|
+
const draw = () => {
|
|
96
|
+
const f = (mq?.value ?? "").toLowerCase();
|
|
97
|
+
const rows = members.filter(m => m.toLowerCase().includes(f));
|
|
98
|
+
const list = box.querySelector<HTMLElement>("#mlist");
|
|
99
|
+
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("");
|
|
100
|
+
const cnt = box.querySelector<HTMLElement>("#mcount");
|
|
101
|
+
const weight = sel.weight ?? 0;
|
|
102
|
+
if (cnt) cnt.textContent = `${Math.min(14, rows.length)} of ${rows.length}${weight > members.length ? ` named (${weight} members in all)` : ""}`;
|
|
103
|
+
};
|
|
104
|
+
if (mq) { mq.addEventListener("input", draw); draw(); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
ctx.fn.renderInspector = renderInspector;
|
|
108
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
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, type Edge } from "./trace.ts";
|
|
7
|
+
import { bucketOf, canonOf, linkVisible, nodeStateId, nodeVisible } from "./rules.ts";
|
|
8
|
+
|
|
9
|
+
export function relationsOf(ctx: Ctx): RelationDef[] {
|
|
10
|
+
const given = ctx.model.inspector?.relations;
|
|
11
|
+
if (given && given.length) return given;
|
|
12
|
+
const last = Math.max(0, ctx.model.columns.length - 1);
|
|
13
|
+
return [
|
|
14
|
+
{ id: "up", label: "Upstream", dir: "up" },
|
|
15
|
+
{ id: "down", label: "Downstream", dir: "down" },
|
|
16
|
+
{ id: "end", label: "At the end", dir: "down", filter: { col: last } },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function visibleEdges(ctx: Ctx): Edge[] {
|
|
21
|
+
return ctx.links.filter(l => linkVisible(ctx.model, ctx.mode, l, ctx.nodes)).map(l => ({ from: l.from, to: l.to }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function relationSet(ctx: Ctx, rel: RelationDef, start: string): Set<string> {
|
|
25
|
+
const canon = (id: string) => canonOf(ctx.nodes, id);
|
|
26
|
+
const set = relatives(visibleEdges(ctx), canon, start, rel.dir);
|
|
27
|
+
if (!rel.filter) return set;
|
|
28
|
+
const out = new Set<string>([canon(start)]);
|
|
29
|
+
for (const id of set) {
|
|
30
|
+
const n = ctx.nodes.get(id);
|
|
31
|
+
if (!n) continue;
|
|
32
|
+
if ((rel.filter.col === undefined || n.col === rel.filter.col) && (rel.filter.kind === undefined || n.kind === rel.filter.kind)) out.add(id);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function matches(ctx: Ctx, n: NodeDef): boolean {
|
|
38
|
+
const q = ctx.query;
|
|
39
|
+
return !!q && (n.title.toLowerCase().includes(q) || (n.members ?? []).some(m => m.toLowerCase().includes(q)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function install(ctx: Ctx): void {
|
|
43
|
+
const { els, model } = ctx;
|
|
44
|
+
const canon = (id: string) => canonOf(ctx.nodes, id);
|
|
45
|
+
const visibleNodes = () => model.nodes.filter(n => nodeVisible(model, ctx.mode, n));
|
|
46
|
+
const inspInset = () => (els.inspector.classList.contains("open") ? 340 : 0);
|
|
47
|
+
|
|
48
|
+
function activeSet(id: string | null): Set<string> | null {
|
|
49
|
+
const edges = visibleEdges(ctx);
|
|
50
|
+
if (id) {
|
|
51
|
+
if (id === ctx.selected && ctx.hl) {
|
|
52
|
+
const rel = relationsOf(ctx).find(r => r.id === ctx.hl);
|
|
53
|
+
if (rel) return relationSet(ctx, rel, id);
|
|
54
|
+
}
|
|
55
|
+
return connected(edges, canon, id);
|
|
56
|
+
}
|
|
57
|
+
if (ctx.filter) {
|
|
58
|
+
return new Set(visibleNodes().filter(n => {
|
|
59
|
+
const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
|
|
60
|
+
return ctx.filter === "gap" ? b === "gap" : !!n.upgrade && b !== "good";
|
|
61
|
+
}).map(n => canon(n.id)));
|
|
62
|
+
}
|
|
63
|
+
if (ctx.query) return new Set(visibleNodes().filter(n => matches(ctx, n)).map(n => canon(n.id)));
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function trace(id: string | null): void {
|
|
68
|
+
const set = activeSet(id);
|
|
69
|
+
const lit = !!id && !!set;
|
|
70
|
+
for (const n of model.nodes) {
|
|
71
|
+
const d = ctx.nodeEls.get(n.id);
|
|
72
|
+
if (!d) continue;
|
|
73
|
+
d.classList.toggle("dim", !!set && !set.has(canon(n.id)));
|
|
74
|
+
d.classList.toggle("lit", lit && !!set && set.has(canon(n.id)));
|
|
75
|
+
}
|
|
76
|
+
ctx.edgeEls.forEach(p => {
|
|
77
|
+
if (!p) return;
|
|
78
|
+
const inSet = !!set && set.has(canon(p.dataset.a ?? "")) && set.has(canon(p.dataset.b ?? ""));
|
|
79
|
+
p.classList.toggle("dim", !!set && !inSet);
|
|
80
|
+
p.classList.toggle("hot", lit && inSet);
|
|
81
|
+
});
|
|
82
|
+
ctx.labelEls.forEach(l => { if (l) l.classList.toggle("show", lit && !!set && set.has(canon(l.dataset.a ?? "")) && set.has(canon(l.dataset.b ?? ""))); });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function select(id: string | null): void {
|
|
86
|
+
clearSim();
|
|
87
|
+
ctx.selected = id ? canon(id) : null;
|
|
88
|
+
ctx.hl = null;
|
|
89
|
+
ctx.dismissed = false;
|
|
90
|
+
for (const n of model.nodes) ctx.nodeEls.get(n.id)?.classList.toggle("sel", !!ctx.selected && canon(n.id) === ctx.selected);
|
|
91
|
+
trace(ctx.selected);
|
|
92
|
+
ctx.fn.renderInspector();
|
|
93
|
+
drawTrail();
|
|
94
|
+
if (ctx.selected) revealNode(ctx.selected);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An overlay is a copy of one link drawn on top. pathLength=1 makes the draw-in independent of zoom. */
|
|
98
|
+
function overlay(edge: SVGPathElement, cls: string, delay: number, dur: number): SVGPathElement {
|
|
99
|
+
const o = edge.cloneNode(false) as SVGPathElement;
|
|
100
|
+
o.setAttribute("class", cls);
|
|
101
|
+
o.setAttribute("pathLength", "1");
|
|
102
|
+
o.removeAttribute("data-i");
|
|
103
|
+
els.layer.appendChild(o);
|
|
104
|
+
ctx.overlays.push(o);
|
|
105
|
+
if (ctx.reduced) return o;
|
|
106
|
+
o.style.strokeDasharray = "1 1";
|
|
107
|
+
o.style.strokeDashoffset = "1";
|
|
108
|
+
o.getBoundingClientRect();
|
|
109
|
+
o.style.transition = `stroke-dashoffset ${dur}s ${delay}s ease-out`;
|
|
110
|
+
o.style.strokeDashoffset = "0";
|
|
111
|
+
return o;
|
|
112
|
+
}
|
|
113
|
+
function dropOverlays(cls: string): void {
|
|
114
|
+
ctx.overlays.filter(o => o.classList.contains(cls)).forEach(o => o.remove());
|
|
115
|
+
ctx.overlays = ctx.overlays.filter(o => o.isConnected);
|
|
116
|
+
}
|
|
117
|
+
function drawTrail(): void {
|
|
118
|
+
dropOverlays("trail");
|
|
119
|
+
if (!ctx.selected || ctx.reduced) return;
|
|
120
|
+
const set = connected(visibleEdges(ctx), canon, ctx.selected);
|
|
121
|
+
const c0 = ctx.nodes.get(ctx.selected)?.col ?? 0;
|
|
122
|
+
for (const p of ctx.edgeEls) {
|
|
123
|
+
if (!p || p.classList.contains("off")) continue;
|
|
124
|
+
const a = canon(p.dataset.a ?? ""), b = canon(p.dataset.b ?? "");
|
|
125
|
+
if (!(set.has(a) && set.has(b))) continue;
|
|
126
|
+
const delay = Math.min(Math.abs((ctx.nodes.get(a)?.col ?? 0) - c0), Math.abs((ctx.nodes.get(b)?.col ?? 0) - c0)) * 0.09;
|
|
127
|
+
const o = overlay(p, "trail", delay, 0.55);
|
|
128
|
+
window.setTimeout(() => { o.style.transition = "opacity .6s"; o.style.opacity = "0"; window.setTimeout(() => o.remove(), 650); }, (delay + 0.55) * 1000 + 150);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function simulate(actionId: string): void {
|
|
133
|
+
clearSim();
|
|
134
|
+
if (!ctx.selected) return;
|
|
135
|
+
const action = model.inspector?.actions?.find(a => a.id === actionId);
|
|
136
|
+
const stale = action?.staleText ?? "potentially stale";
|
|
137
|
+
ctx.simActive = true;
|
|
138
|
+
const down = relatives(visibleEdges(ctx), canon, ctx.selected, "down");
|
|
139
|
+
const c0 = ctx.nodes.get(ctx.selected)?.col ?? 0;
|
|
140
|
+
for (const p of ctx.edgeEls) {
|
|
141
|
+
if (!p || p.classList.contains("off")) continue;
|
|
142
|
+
const a = canon(p.dataset.a ?? ""), b = canon(p.dataset.b ?? "");
|
|
143
|
+
if (down.has(a) && down.has(b)) overlay(p, "ripple", Math.abs((ctx.nodes.get(a)?.col ?? 0) - c0) * 0.11, 0.5);
|
|
144
|
+
}
|
|
145
|
+
down.forEach(id => {
|
|
146
|
+
if (id === ctx.selected) return;
|
|
147
|
+
const d = ctx.nodeEls.get(id);
|
|
148
|
+
if (!d) return;
|
|
149
|
+
const delay = ctx.reduced ? 0 : Math.abs((ctx.nodes.get(id)?.col ?? 0) - c0) * 110 + 450;
|
|
150
|
+
ctx.simTimers.push(window.setTimeout(() => {
|
|
151
|
+
d.classList.add("fail");
|
|
152
|
+
const sub = d.querySelector(".s");
|
|
153
|
+
if (sub) { d.dataset.s = sub.innerHTML; sub.textContent = stale; }
|
|
154
|
+
}, delay));
|
|
155
|
+
});
|
|
156
|
+
ctx.fn.announce(`Simulation: ${down.size - 1} groups downstream of ${ctx.nodes.get(ctx.selected)?.title ?? ""} would be ${stale}.`);
|
|
157
|
+
ctx.fn.renderInspector();
|
|
158
|
+
}
|
|
159
|
+
function clearSim(): void {
|
|
160
|
+
ctx.simTimers.forEach(t => clearTimeout(t));
|
|
161
|
+
ctx.simTimers = [];
|
|
162
|
+
dropOverlays("ripple");
|
|
163
|
+
document.querySelectorAll<HTMLElement>(".node.fail").forEach(d => {
|
|
164
|
+
d.classList.remove("fail");
|
|
165
|
+
const sub = d.querySelector(".s");
|
|
166
|
+
if (sub && d.dataset.s !== undefined) { sub.innerHTML = d.dataset.s; delete d.dataset.s; }
|
|
167
|
+
});
|
|
168
|
+
ctx.simActive = false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The inspector covers the right 340 px. If the group would sit under it, glide the view left. */
|
|
172
|
+
function revealNode(id: string): void {
|
|
173
|
+
const d = ctx.nodeEls.get(id);
|
|
174
|
+
if (!d) return;
|
|
175
|
+
const r = d.getBoundingClientRect(), v = els.vp.getBoundingClientRect(), edge = v.right - 340 - 24;
|
|
176
|
+
if (r.right > edge) {
|
|
177
|
+
ctx.userMoved = true;
|
|
178
|
+
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);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
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));
|
|
184
|
+
if (!ps.length) return null;
|
|
185
|
+
const x1 = Math.min(...ps.map(p => p.x)), y1 = Math.min(...ps.map(p => p.y));
|
|
186
|
+
const x2 = Math.max(...ps.map(p => p.x + p.w)), y2 = Math.max(...ps.map(p => p.y + p.h));
|
|
187
|
+
return { x: x1 - 20, y: y1 - 20, w: x2 - x1 + 40, h: y2 - y1 + 40 };
|
|
188
|
+
}
|
|
189
|
+
function zoomToPath(): void {
|
|
190
|
+
if (!ctx.selected) return;
|
|
191
|
+
const ids = [...connected(visibleEdges(ctx), canon, ctx.selected)];
|
|
192
|
+
ids.push(...model.nodes.filter(n => n.ref && ids.includes(n.ref)).map(n => n.id));
|
|
193
|
+
const r = rectOf(ids);
|
|
194
|
+
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 ?? ""}`); }
|
|
195
|
+
}
|
|
196
|
+
function zoomToBand(id: string): void {
|
|
197
|
+
const r = ctx.geom.lanes[id];
|
|
198
|
+
if (!r) return;
|
|
199
|
+
ctx.userMoved = true;
|
|
200
|
+
ctx.fn.fit(true, { x: r.x - 10, y: r.y - 10, w: r.w + 20, h: r.h + 20 }, inspInset());
|
|
201
|
+
ctx.fn.announce(`Zoomed to the lane ${model.lanes.find(l => l.id === id)?.title ?? id}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function escape(): void {
|
|
205
|
+
clearSim();
|
|
206
|
+
select(null);
|
|
207
|
+
ctx.filter = null;
|
|
208
|
+
ctx.query = "";
|
|
209
|
+
els.q.value = "";
|
|
210
|
+
els.matchinfo.textContent = "";
|
|
211
|
+
ctx.fn.buildChrome();
|
|
212
|
+
trace(null);
|
|
213
|
+
ctx.fn.toggleNotes(false);
|
|
214
|
+
}
|
|
215
|
+
function setMode(id: string): void {
|
|
216
|
+
if (ctx.mode === id) return;
|
|
217
|
+
document.body.classList.remove("intro");
|
|
218
|
+
clearSim();
|
|
219
|
+
ctx.mode = id;
|
|
220
|
+
const sel = ctx.selected && ctx.nodes.get(ctx.selected);
|
|
221
|
+
if (sel && !nodeVisible(model, id, sel)) select(null);
|
|
222
|
+
ctx.fn.paint(true);
|
|
223
|
+
ctx.userMoved = false;
|
|
224
|
+
ctx.fn.fitWidth(true);
|
|
225
|
+
const added = model.nodes.filter(n => nodeVisible(model, id, n) && !nodeVisible(model, model.views?.[0]?.id ?? "all", n)).length;
|
|
226
|
+
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
|
+
}
|
|
228
|
+
|
|
229
|
+
ctx.fn.trace = trace;
|
|
230
|
+
ctx.fn.select = select;
|
|
231
|
+
ctx.fn.drawTrail = drawTrail;
|
|
232
|
+
ctx.fn.simulate = simulate;
|
|
233
|
+
ctx.fn.clearSim = clearSim;
|
|
234
|
+
ctx.fn.rectOf = rectOf;
|
|
235
|
+
ctx.fn.zoomToPath = zoomToPath;
|
|
236
|
+
ctx.fn.zoomToBand = zoomToBand;
|
|
237
|
+
ctx.fn.escape = escape;
|
|
238
|
+
ctx.fn.setMode = setMode;
|
|
239
|
+
ctx.fn.announce = (t: string) => { els.live.textContent = t; };
|
|
240
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { computeGeometry } from "./layout.ts";
|
|
4
|
+
import type { Model } from "./model.ts";
|
|
5
|
+
|
|
6
|
+
const m = (): Model => ({
|
|
7
|
+
version: 1,
|
|
8
|
+
meta: { title: "t", help: "", asOf: "", source: "" },
|
|
9
|
+
layout: "columns-lanes",
|
|
10
|
+
columns: [{ id: "c0", title: "A", width: 150 }, { id: "c1", title: "B", width: 200 }],
|
|
11
|
+
lanes: [{ id: "l1", title: "One" }, { id: "l2", title: "Two" }],
|
|
12
|
+
states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
|
|
13
|
+
nodes: [
|
|
14
|
+
{ id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A" },
|
|
15
|
+
{ id: "b", kind: "card", lane: "l1", col: 1, row: 0, state: "ok", title: "B" },
|
|
16
|
+
{ id: "c", kind: "card", lane: "l2", col: 0, row: 1.8, state: "ok", title: "C" },
|
|
17
|
+
{ id: "w", kind: "placeholder", lane: "l2", col: 0, colSpan: 2, row: 2.8, state: "ok", title: "W" },
|
|
18
|
+
],
|
|
19
|
+
links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("columns are laid out left to right with the gap", () => {
|
|
23
|
+
const g = computeGeometry(m());
|
|
24
|
+
assert.equal(g.xs[0], 24);
|
|
25
|
+
assert.equal(g.xs[1], 24 + 150 + 34);
|
|
26
|
+
});
|
|
27
|
+
test("a node sits at its column and its row", () => {
|
|
28
|
+
const g = computeGeometry(m());
|
|
29
|
+
assert.equal(g.nodes.a!.x, 24);
|
|
30
|
+
assert.equal(g.nodes.a!.w, 150);
|
|
31
|
+
assert.ok(g.nodes.c!.y > g.nodes.a!.y);
|
|
32
|
+
});
|
|
33
|
+
test("a wide node spans its columns", () => {
|
|
34
|
+
const g = computeGeometry(m());
|
|
35
|
+
assert.equal(g.nodes.w!.w, 150 + 34 + 200);
|
|
36
|
+
});
|
|
37
|
+
test("a chip is shorter than a card", () => {
|
|
38
|
+
const mm = m();
|
|
39
|
+
mm.nodes.push({ id: "k", kind: "chip", lane: "l1", col: 0, row: 3.8, state: "ok", title: "k", ref: "a" });
|
|
40
|
+
const g = computeGeometry(mm);
|
|
41
|
+
assert.ok(g.nodes.k!.h < g.nodes.a!.h);
|
|
42
|
+
});
|
|
43
|
+
test("lane rectangles are computed from their nodes and do not overlap", () => {
|
|
44
|
+
const g = computeGeometry(m());
|
|
45
|
+
const a = g.lanes.l1!, b = g.lanes.l2!;
|
|
46
|
+
assert.ok(a.y + a.h <= b.y, `${a.y + a.h} <= ${b.y}`);
|
|
47
|
+
});
|
|
48
|
+
test("a lane without visible nodes has no rectangle", () => {
|
|
49
|
+
const g = computeGeometry(m(), { visible: id => id !== "c" && id !== "w" });
|
|
50
|
+
assert.equal(g.lanes.l2, undefined);
|
|
51
|
+
});
|
|
52
|
+
test("bounds cover every lane and the world is finite", () => {
|
|
53
|
+
const g = computeGeometry(m());
|
|
54
|
+
assert.ok(Object.values(g.lanes).every(l => l.y >= g.bounds.y && l.y + l.h <= g.bounds.y + g.bounds.h + 1e-6));
|
|
55
|
+
assert.ok(Number.isFinite(g.world.w) && Number.isFinite(g.world.h));
|
|
56
|
+
});
|
|
57
|
+
test("an empty model has a finite, empty geometry (review focus 2)", () => {
|
|
58
|
+
const mm = m();
|
|
59
|
+
mm.nodes = [];
|
|
60
|
+
mm.links = [];
|
|
61
|
+
const g = computeGeometry(mm);
|
|
62
|
+
assert.ok(Number.isFinite(g.bounds.w) && Number.isFinite(g.bounds.h) && Number.isFinite(g.world.h));
|
|
63
|
+
});
|
|
64
|
+
test("a node whose column is out of range does not produce NaN", () => {
|
|
65
|
+
const mm = m();
|
|
66
|
+
mm.nodes[0]!.col = 9;
|
|
67
|
+
const g = computeGeometry(mm);
|
|
68
|
+
assert.ok(Object.values(g.nodes).every(n => [n.x, n.y, n.w, n.h].every(Number.isFinite)));
|
|
69
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Geometry from a model: where every node, lane and column sits in world units. Pure, no DOM.
|
|
2
|
+
// Node sizes are fixed per kind, so layout never depends on fonts or text measurement.
|
|
3
|
+
|
|
4
|
+
import type { Model, Kind } from "./model.ts";
|
|
5
|
+
import type { Rect, Size } from "./viewport.ts";
|
|
6
|
+
|
|
7
|
+
export interface Placed extends Rect { id: string }
|
|
8
|
+
export interface Geometry {
|
|
9
|
+
xs: number[];
|
|
10
|
+
nodes: Record<string, Placed>;
|
|
11
|
+
lanes: Record<string, Rect>;
|
|
12
|
+
bounds: Rect;
|
|
13
|
+
world: Size;
|
|
14
|
+
}
|
|
15
|
+
export interface LayoutOptions { pitch: number; top: number; gap: number; padX: number; visible: (id: string) => boolean }
|
|
16
|
+
|
|
17
|
+
export const DEFAULTS: LayoutOptions = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
|
|
18
|
+
|
|
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
|
+
const LANE_PAD_TOP = 38; // room for the lane title
|
|
21
|
+
const LANE_PAD_BOTTOM = 14;
|
|
22
|
+
|
|
23
|
+
export function computeGeometry(m: Model, opt: Partial<LayoutOptions> = {}): Geometry {
|
|
24
|
+
const o: LayoutOptions = { ...DEFAULTS, ...opt };
|
|
25
|
+
const xs: number[] = [];
|
|
26
|
+
let x = o.padX;
|
|
27
|
+
for (const c of m.columns) { xs.push(x); x += c.width + o.gap; }
|
|
28
|
+
const world: Size = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
|
|
29
|
+
|
|
30
|
+
const nodes: Record<string, Placed> = {};
|
|
31
|
+
for (const n of m.nodes) {
|
|
32
|
+
const h = HEIGHT[n.kind] ?? 54;
|
|
33
|
+
const first = Math.min(Math.max(0, n.col), m.columns.length - 1);
|
|
34
|
+
const last = Math.min(m.columns.length - 1, first + (n.colSpan ?? 1) - 1);
|
|
35
|
+
const x0 = xs[first] ?? o.padX;
|
|
36
|
+
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 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const lanes: Record<string, Rect> = {};
|
|
41
|
+
for (const l of m.lanes) {
|
|
42
|
+
const mem = m.nodes.filter(n => n.lane === l.id && o.visible(n.id)).map(n => nodes[n.id]!);
|
|
43
|
+
if (!mem.length) continue;
|
|
44
|
+
const y1 = Math.min(...mem.map(p => p.y)) - LANE_PAD_TOP;
|
|
45
|
+
const y2 = Math.max(...mem.map(p => p.y + p.h)) + LANE_PAD_BOTTOM;
|
|
46
|
+
lanes[l.id] = { x: 8, y: y1, w: world.w - 16, h: y2 - y1 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const all = Object.values(lanes);
|
|
50
|
+
let bounds: Rect = { x: 0, y: 0, w: world.w, h: 1 };
|
|
51
|
+
if (all.length) {
|
|
52
|
+
const top = Math.min(...all.map(r => r.y));
|
|
53
|
+
bounds = { x: 8, y: top, w: world.w - 16, h: Math.max(...all.map(r => r.y + r.h)) - top };
|
|
54
|
+
}
|
|
55
|
+
world.h = Math.max(1, bounds.y + bounds.h + 70);
|
|
56
|
+
return { xs, nodes, lanes, bounds, world };
|
|
57
|
+
}
|