@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.
Files changed (210) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
  3. package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
  4. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  5. package/bundles/antigravity-plugin/skills/flow-studio/README.md +92 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +102 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +19 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +142 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
  32. package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
  35. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
  36. package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
  37. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +415 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +95 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +54 -0
  42. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +680 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  45. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +179 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  54. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
  55. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
  56. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
  57. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
  58. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  59. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  60. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  61. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  62. package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
  63. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +42 -0
  64. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
  65. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
  66. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +175 -0
  67. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +239 -0
  68. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +134 -0
  69. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
  70. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
  71. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +193 -0
  72. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +126 -0
  73. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +253 -0
  74. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +144 -0
  75. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +95 -0
  76. package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
  77. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
  78. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
  79. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +167 -0
  80. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +250 -0
  81. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +100 -0
  82. package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
  83. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
  84. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
  85. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
  86. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +203 -0
  87. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +33 -0
  88. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +43 -0
  89. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +135 -0
  90. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +86 -0
  91. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
  92. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
  93. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +73 -0
  94. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +45 -0
  95. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +190 -0
  96. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +40 -0
  97. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +40 -0
  98. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
  99. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
  100. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +2656 -0
  101. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +23 -0
  102. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
  103. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  104. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  105. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
  106. package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +92 -0
  108. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +102 -0
  109. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +19 -0
  110. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
  111. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
  112. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
  113. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
  114. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
  115. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
  116. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
  117. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
  118. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
  119. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
  120. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +142 -0
  121. package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
  122. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
  123. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
  124. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
  125. package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
  126. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
  127. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
  128. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
  129. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
  130. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
  131. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
  132. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
  133. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
  134. package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
  135. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
  136. package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
  137. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
  138. package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
  139. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +415 -0
  140. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
  141. package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
  142. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +95 -0
  143. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +54 -0
  144. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +680 -0
  145. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
  146. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  147. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  148. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  149. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  150. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
  151. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  152. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  153. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +179 -0
  154. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  155. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  156. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
  157. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
  158. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
  159. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
  160. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  161. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  162. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  163. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  164. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
  165. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +42 -0
  166. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
  167. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
  168. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +175 -0
  169. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +239 -0
  170. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +134 -0
  171. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
  172. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
  173. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +193 -0
  174. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +126 -0
  175. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +253 -0
  176. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +144 -0
  177. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +95 -0
  178. package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
  179. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
  180. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
  181. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +167 -0
  182. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +250 -0
  183. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +100 -0
  184. package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
  185. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
  186. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
  187. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
  188. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +203 -0
  189. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +33 -0
  190. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +43 -0
  191. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +135 -0
  192. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +86 -0
  193. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
  194. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
  195. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +73 -0
  196. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +45 -0
  197. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +190 -0
  198. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +40 -0
  199. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +40 -0
  200. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
  201. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
  202. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +2656 -0
  203. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +23 -0
  204. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
  205. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  206. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  207. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
  208. package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
  209. package/bundles/manifest.json +2 -2
  210. package/package.json +1 -1
@@ -0,0 +1,175 @@
1
+ // The page around the canvas: completeness strip, summary tiles, legend, ledger, table view, title,
2
+ // view switch, search box and theme toggle. Text comes from the model; every string goes through esc() or textContent.
3
+
4
+ import { el, on, type Ctx } from "./ctx.ts";
5
+ import { esc } from "./model.ts";
6
+ import { DASH, toneVar, cssId } from "./stateCss.ts";
7
+ import { bucketOf, columnTitle, effectiveLinks, nodeStateId, nodeVisible } from "./rules.ts";
8
+ import { matches } from "./interact.ts";
9
+
10
+ const DEFAULT_LABELS: Record<string, string> = {
11
+ tileGood: "links in a good state",
12
+ tileNeutral: "links in a neutral state",
13
+ tileGap: "groups with a gap: click to show",
14
+ tileUpgrade: "groups you can improve: click to show",
15
+ ledgerTitle: "What this view could not establish",
16
+ tableTitle: "Table view",
17
+ };
18
+
19
+ export function install(ctx: Ctx): void {
20
+ const { els, model } = ctx;
21
+ const label = (k: string): string => model.meta.labels?.[k] ?? DEFAULT_LABELS[k] ?? k;
22
+ const visible = (id: string): boolean => { const n = ctx.nodes.get(id); return !!n && nodeVisible(model, ctx.mode, n, ctx.collapsed); };
23
+
24
+ function countUp(node: HTMLElement, to: number, ms = 700): void {
25
+ if (ctx.reduced) { node.textContent = String(to); return; }
26
+ const t0 = performance.now();
27
+ const step = (t: number) => {
28
+ const k = Math.min(1, (t - t0) / ms), e = 1 - Math.pow(1 - k, 3);
29
+ node.textContent = String(Math.round(to * e));
30
+ if (k < 1) requestAnimationFrame(step);
31
+ };
32
+ requestAnimationFrame(step);
33
+ }
34
+
35
+ function buildStrip(): void {
36
+ const box = els.strip;
37
+ box.style.display = model.columns.length ? "" : "none";
38
+ box.style.gridTemplateColumns = `repeat(${Math.max(1, model.columns.length)}, minmax(0, 1fr))`;
39
+ if (!box.firstChild) {
40
+ box.innerHTML = model.columns.map(c => `<div class="lcell"><div class="ln">${esc(c.title)}</div><div class="lb"><i class="good"></i><i class="neutral"></i><i class="gap"></i></div><div class="lv"></div></div>`).join("");
41
+ }
42
+ box.querySelectorAll<HTMLElement>(".lcell").forEach((cell, i) => {
43
+ cell.style.setProperty("--i", String(i));
44
+ let tot = 0, good = 0, neutral = 0, gap = 0;
45
+ for (const n of model.nodes) {
46
+ if (n.col !== i || n.kind === "chip") continue;
47
+ if (!visible(n.id)) continue;
48
+ const w = Math.max(1, n.weight ?? 1);
49
+ tot += w;
50
+ const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
51
+ if (b === "good") good += w; else if (b === "neutral") neutral += w; else gap += w;
52
+ }
53
+ const t = tot || 1, bars = cell.querySelectorAll<HTMLElement>("i");
54
+ requestAnimationFrame(() => {
55
+ if (bars[0]) bars[0].style.width = good / t * 100 + "%";
56
+ if (bars[1]) bars[1].style.width = neutral / t * 100 + "%";
57
+ if (bars[2]) bars[2].style.width = gap / t * 100 + "%";
58
+ });
59
+ const lv = cell.querySelector<HTMLElement>(".lv");
60
+ if (lv) lv.textContent = `${good + neutral} / ${tot}`;
61
+ cell.title = `${model.columns[i]?.title ?? ""}: ${good} good, ${neutral} neutral, ${gap} gap`;
62
+ });
63
+ }
64
+
65
+ function buildTiles(): void {
66
+ const k = els.tiles;
67
+ let good = 0, neutral = 0;
68
+ for (const l of effectiveLinks(model, ctx.mode, ctx.collapsed, ctx.nodes)) {
69
+ const b = bucketOf(model, l.state);
70
+ if (b === "good") good++; else if (b === "neutral") neutral++;
71
+ }
72
+ let gaps = 0, upgrades = 0;
73
+ for (const n of model.nodes) {
74
+ if (!visible(n.id) || n.kind === "chip") continue;
75
+ const b = bucketOf(model, nodeStateId(model, ctx.mode, n));
76
+ if (b === "gap") gaps += 1;
77
+ if (n.upgrade && b !== "good") upgrades++;
78
+ }
79
+ k.innerHTML = "";
80
+ const tile = (cls: string, n: number, text: string, filter?: "gap" | "upgrade") => {
81
+ const b = el(filter ? "button" : "div", "tile " + cls, `<span class="tn">0</span><span class="tl">${esc(text)}</span>`);
82
+ b.style.setProperty("--i", String(k.children.length + 9));
83
+ if (filter) {
84
+ (b as HTMLButtonElement).type = "button";
85
+ b.setAttribute("aria-pressed", String(ctx.filter === filter));
86
+ b.addEventListener("click", () => {
87
+ ctx.filter = ctx.filter === filter ? null : filter;
88
+ ctx.fn.select(null);
89
+ buildTiles();
90
+ ctx.fn.trace(null);
91
+ ctx.fn.announce(ctx.filter ? "Filter on" : "Filter off");
92
+ });
93
+ }
94
+ k.appendChild(b);
95
+ countUp(b.querySelector<HTMLElement>(".tn") as HTMLElement, n);
96
+ };
97
+ tile("ok", good, label("tileGood"));
98
+ tile("", neutral, label("tileNeutral"));
99
+ tile("warn", gaps, label("tileGap"), "gap");
100
+ tile("", upgrades, label("tileUpgrade"), "upgrade");
101
+ }
102
+
103
+ function buildLegend(): void {
104
+ const parts = Object.entries(model.states).map(([id, s]) => {
105
+ const cap = s.edge === "dot" ? ' stroke-linecap="round"' : "";
106
+ return `<span><svg width="40" height="10" aria-hidden="true"><path d="M0 5 H40" stroke="${toneVar(s.tone)}" stroke-width="2" stroke-dasharray="${DASH[s.edge]}"${cap}/></svg><b class="ev st-${cssId(id)}">${esc(s.word)}</b></span>`;
107
+ });
108
+ parts.push('<span id="hintline">scroll = zoom · drag = pan · double-click = fit</span>');
109
+ els.legend.innerHTML = parts.join("");
110
+ }
111
+
112
+ function buildLedger(): void {
113
+ let gaps = 0, total = 0;
114
+ for (const n of model.nodes) { if (n.kind === "chip" || !visible(n.id)) continue; total++; if (bucketOf(model, nodeStateId(model, ctx.mode, n)) === "gap") gaps++; }
115
+ const noLane = model.lanes.filter(l => l.gap).length;
116
+ els.ledger.innerHTML = `<h4>${esc(label("ledgerTitle"))}</h4><p><b>${gaps}</b> of <b>${total}</b> groups in this view have a gap: they are drawn dotted and are never guessed. ${noLane ? `<b>${noLane}</b> lane${noLane === 1 ? " has" : "s have"} no source document yet.` : ""}</p><p>${esc(model.meta.source)}</p>`;
117
+ }
118
+
119
+ function buildTable(): void {
120
+ const rows = model.nodes.filter(n => visible(n.id) && n.kind !== "chip").map(n => {
121
+ const sid = nodeStateId(model, ctx.mode, n);
122
+ const lane = model.lanes.find(l => l.id === n.lane)?.title ?? n.lane;
123
+ return `<tr><th scope="row">${esc(columnTitle(model, n))}</th><td>${esc(lane)}</td><td>${esc(n.title)}</td><td>${esc(model.states[sid]?.word ?? sid)}</td><td>${Math.max(1, n.weight ?? 1)}</td><td>${esc(n.subtitle ?? "")}</td><td>${esc(n.upgrade ?? "")}</td></tr>`;
124
+ }).join("");
125
+ els.table.innerHTML = `<thead><tr><th scope="col">Column</th><th scope="col">Lane</th><th scope="col">Group</th><th scope="col">State</th><th scope="col">Members</th><th scope="col">Detail</th><th scope="col">Can improve</th></tr></thead><tbody>${rows}</tbody>`;
126
+ }
127
+
128
+ ctx.fn.buildChrome = () => { buildStrip(); buildTiles(); buildTable(); buildLedger(); };
129
+
130
+ // ---- page text and controls, set up once
131
+ document.title = model.meta.title;
132
+ els.title.textContent = model.meta.title;
133
+ els.help.textContent = model.meta.help;
134
+ els.readat.textContent = model.meta.asOf ? `as of ${model.meta.asOf}` : "";
135
+ els.footer.textContent = model.meta.source;
136
+ buildLegend();
137
+
138
+ const views = model.views ?? [];
139
+ els.modeSwitch.hidden = views.length < 2;
140
+ els.modeSwitch.innerHTML = "";
141
+ for (const v of views) {
142
+ const b = el("button", "seg");
143
+ b.type = "button"; b.setAttribute("role", "tab"); b.dataset.mode = v.id;
144
+ b.setAttribute("aria-selected", String(v.id === ctx.mode));
145
+ b.textContent = v.label;
146
+ on(ctx, b, "click", () => {
147
+ els.modeSwitch.querySelectorAll("button").forEach(x => x.setAttribute("aria-selected", String(x === b)));
148
+ ctx.fn.setMode(v.id);
149
+ });
150
+ els.modeSwitch.appendChild(b);
151
+ }
152
+
153
+ on(ctx, els.q, "input", (e: Event) => {
154
+ ctx.query = (e.target as HTMLInputElement).value.trim().toLowerCase();
155
+ ctx.dismissed = false;
156
+ const n = model.nodes.filter(x => visible(x.id) && matches(ctx, x)).length;
157
+ els.matchinfo.textContent = ctx.query ? `${n} group${n === 1 ? "" : "s"} match` : "";
158
+ if (!ctx.selected) { ctx.fn.trace(null); ctx.fn.renderInspector(); }
159
+ });
160
+ on(ctx, els.hops, "change", () => {
161
+ ctx.hops = Number(els.hops.value);
162
+ ctx.fn.trace(ctx.selected);
163
+ });
164
+ on(ctx, els.q, "keydown", (e: KeyboardEvent) => {
165
+ if (e.key !== "Enter" || !ctx.query) return;
166
+ const ids = model.nodes.filter(x => visible(x.id) && matches(ctx, x)).map(x => x.id);
167
+ const r = ctx.fn.rectOf(ids);
168
+ if (r) { ctx.userMoved = true; ctx.fn.fit(true, r, els.inspector.classList.contains("open") ? 340 : 0); ctx.fn.announce(`Zoomed to ${ids.length} matching groups`); }
169
+ });
170
+ on(ctx, els.theme, "click", () => {
171
+ const r = document.documentElement;
172
+ const dark = r.dataset.theme ? r.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
173
+ r.dataset.theme = dark ? "light" : "dark";
174
+ });
175
+ }
@@ -0,0 +1,239 @@
1
+ // The canvas controls: view (pan and zoom), pinned headers, pointer, wheel, pinch, keys and the tool strip.
2
+ // The maths lives in viewport.ts; this file only reads and writes the DOM.
3
+
4
+ import { on, el, type Ctx } from "./ctx.ts";
5
+ import { clampPan, fitRect, fitWidth, zoomAt, type Rect, type View } from "./viewport.ts";
6
+
7
+ const HEAD = 44; // height of the pinned header overlay
8
+
9
+ const ICON: Record<string, string> = {
10
+ select: '<path d="M5 3l12 6.5-5.2 1.6L9.5 17z"/>',
11
+ hand: '<path d="M8 11V5.5a1.5 1.5 0 013 0V10m0-5a1.5 1.5 0 013 0v5m0-3.5a1.5 1.5 0 013 0V13c0 4-2.5 7-6.5 7S5 17 5 14l-1.6-3.2a1.4 1.4 0 012.4-1.4L8 12"/>',
12
+ plus: '<path d="M12 5v14M5 12h14"/>',
13
+ minus: '<path d="M5 12h14"/>',
14
+ fit: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
15
+ one: '<path d="M9 8l3-2v12"/><path d="M5 20h14"/>',
16
+ sel: '<circle cx="12" cy="12" r="3.2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/>',
17
+ full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
18
+ };
19
+
20
+ export function install(ctx: Ctx): void {
21
+ const { els, model } = ctx;
22
+ const vp = els.vp;
23
+ // The size is cached: reading clientWidth on every wheel event would force a layout of the whole page each time.
24
+ let cached = { w: vp.clientWidth, h: vp.clientHeight };
25
+ const size = () => cached;
26
+ // Same for the viewport rectangle: it is refreshed on resize, scroll and when the pointer enters.
27
+ let vpRect = vp.getBoundingClientRect();
28
+ const refreshRect = () => { vpRect = vp.getBoundingClientRect(); };
29
+ let animTimer = 0;
30
+ let raf = 0;
31
+
32
+ function applyView(): void {
33
+ const v = ctx.view;
34
+ els.world.style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`;
35
+ vp.style.setProperty("--inv", Math.max(1, 0.9 / v.k).toFixed(3));
36
+ vp.style.setProperty("--k", v.k.toFixed(4));
37
+ vp.classList.toggle("lod1", v.k < 0.62);
38
+ vp.classList.toggle("lod2", v.k < 0.4);
39
+ els.zoom.textContent = Math.round(v.k * 100) + "%";
40
+ ctx.fn.stickLabels();
41
+ ctx.fn.placeHeaders();
42
+ if (!raf) raf = requestAnimationFrame(() => { raf = 0; if (document.body.classList.contains("notes")) ctx.fn.placeMarkers(); });
43
+ }
44
+
45
+ function setView(v: View, animate = false): void {
46
+ const c = clampPan(v, ctx.geom.bounds, size());
47
+ ctx.view = [c.x, c.y, c.k].every(Number.isFinite) ? c : { x: 0, y: 0, k: 1 };
48
+ const glide = animate && !ctx.reduced;
49
+ vp.classList.toggle("anim", glide);
50
+ if (glide) { clearTimeout(animTimer); animTimer = window.setTimeout(() => vp.classList.remove("anim"), 330); }
51
+ applyView();
52
+ }
53
+
54
+ function fit(animate = false, rect?: Rect, inset = 0): void {
55
+ const r = rect ?? ctx.geom.bounds;
56
+ if (!(r.w > 0)) return;
57
+ const s = size();
58
+ setView(fitRect(r, { w: s.w - inset, h: s.h }, 28, HEAD + 8), animate);
59
+ }
60
+ function fitW(animate = false): void {
61
+ setView(fitWidth(ctx.geom.bounds, size(), 28, HEAD + 8), animate);
62
+ }
63
+ function zoomBy(f: number): void {
64
+ const s = size();
65
+ ctx.userMoved = true;
66
+ setView(zoomAt(ctx.view, f, s.w / 2, s.h / 2), true);
67
+ ctx.fn.announce("Zoom " + Math.round(ctx.view.k * 100) + " percent");
68
+ }
69
+
70
+ function placeHeaders(): void {
71
+ const cols = els.cols;
72
+ cols.style.display = model.columns.length ? "" : "none";
73
+ if (!cols.firstChild) {
74
+ model.columns.forEach((c, i) => { const h = el("div", "hd"); h.textContent = (c.short ?? c.title).toUpperCase(); h.title = c.title; h.dataset.i = String(i); cols.appendChild(h); });
75
+ let i = 0;
76
+ while (i < model.columns.length) {
77
+ const name = model.columns[i]?.phase;
78
+ let j = i;
79
+ while (j + 1 < model.columns.length && model.columns[j + 1]?.phase === name) j++;
80
+ if (name) { const p = el("div", "ph"); p.innerHTML = "<span></span><i></i>"; (p.firstElementChild as HTMLElement).textContent = name; p.dataset.a = String(i); p.dataset.b = String(j); cols.appendChild(p); }
81
+ i = j + 1;
82
+ }
83
+ }
84
+ const v = ctx.view, xs = ctx.geom.xs;
85
+ cols.querySelectorAll<HTMLElement>(".hd").forEach(h => { const i = +(h.dataset.i ?? 0); h.style.left = v.x + (xs[i] ?? 0) * v.k + "px"; h.style.width = Math.max(0, (model.columns[i]?.width ?? 0) * v.k - 6) + "px"; });
86
+ cols.querySelectorAll<HTMLElement>(".ph").forEach(p => {
87
+ const a = +(p.dataset.a ?? 0), b = +(p.dataset.b ?? 0);
88
+ p.style.left = v.x + (xs[a] ?? 0) * v.k + "px";
89
+ p.style.width = Math.max(40, ((xs[b] ?? 0) + (model.columns[b]?.width ?? 0) - (xs[a] ?? 0)) * v.k) + "px";
90
+ });
91
+ }
92
+
93
+ function stickLabels(): void {
94
+ const wl = (52 - ctx.view.x) / ctx.view.k;
95
+ for (const l of model.lanes) {
96
+ const r = ctx.geom.lanes[l.id], d = ctx.laneEls.get(l.id);
97
+ if (!r || !d?.firstElementChild) continue;
98
+ (d.firstElementChild as HTMLElement).style.left = Math.min(Math.max(12, wl - r.x), Math.max(12, r.w - 300)) + "px";
99
+ }
100
+ }
101
+
102
+ // ---- pointer: drag the background (or any spot with the hand tool, Space, or the middle button) to pan; pinch to zoom
103
+ const pointers = new Map<number, { x: number; y: number }>();
104
+ let drag: { x: number; y: number; vx: number; vy: number } | null = null;
105
+ let pinch: { d: number; view: View } | null = null;
106
+
107
+ on(ctx, vp, "pointerdown", (e: PointerEvent) => {
108
+ if ((e.target as HTMLElement).closest("#tools, .bandlabel")) return;
109
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
110
+ ctx.moved = 0;
111
+ if (pointers.size === 2) {
112
+ const [a, b] = [...pointers.values()] as [{ x: number; y: number }, { x: number; y: number }];
113
+ pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), view: { ...ctx.view } };
114
+ drag = null;
115
+ return;
116
+ }
117
+ const onNode = !!(e.target as HTMLElement).closest(".node");
118
+ if (e.button === 1 || ctx.tool === "hand" || ctx.spaceDown || !onNode) {
119
+ drag = { x: e.clientX, y: e.clientY, vx: ctx.view.x, vy: ctx.view.y };
120
+ try { vp.setPointerCapture(e.pointerId); } catch { /* a synthetic pointer cannot be captured */ }
121
+ vp.classList.add("panning");
122
+ if (e.button === 1) e.preventDefault();
123
+ }
124
+ });
125
+ on(ctx, vp, "pointermove", (e: PointerEvent) => {
126
+ if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
127
+ if (pinch && pointers.size === 2) {
128
+ const [a, b] = [...pointers.values()] as [{ x: number; y: number }, { x: number; y: number }];
129
+ const d = Math.hypot(a.x - b.x, a.y - b.y), r = vpRect;
130
+ ctx.userMoved = true;
131
+ setView(zoomAt(pinch.view, d / pinch.d, (a.x + b.x) / 2 - r.left, (a.y + b.y) / 2 - r.top), false);
132
+ return;
133
+ }
134
+ if (!drag) return;
135
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
136
+ ctx.moved = Math.max(ctx.moved, Math.hypot(dx, dy));
137
+ if (ctx.moved > 4) { ctx.userMoved = true; setView({ k: ctx.view.k, x: drag.vx + dx, y: drag.vy + dy }, false); }
138
+ });
139
+ const endDrag = (e: PointerEvent) => {
140
+ pointers.delete(e.pointerId);
141
+ if (pointers.size < 2) pinch = null;
142
+ if (drag) {
143
+ drag = null;
144
+ vp.classList.remove("panning");
145
+ try { vp.releasePointerCapture(e.pointerId); } catch { /* not captured */ }
146
+ window.setTimeout(() => { ctx.moved = 0; }, 0);
147
+ }
148
+ };
149
+ on(ctx, vp, "pointerup", endDrag);
150
+ on(ctx, vp, "pointercancel", endDrag);
151
+ on(ctx, vp, "wheel", (e: WheelEvent) => {
152
+ e.preventDefault();
153
+ const r = vpRect, unit = e.deltaMode === 1 ? 16 : 1;
154
+ ctx.userMoved = true;
155
+ setView(zoomAt(ctx.view, Math.exp(-e.deltaY * unit * (e.ctrlKey ? 0.01 : 0.0012)), e.clientX - r.left, e.clientY - r.top), false);
156
+ }, { passive: false });
157
+ on(ctx, vp, "dblclick", (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".node, .bandlabel, #tools")) { ctx.userMoved = false; fit(true); } });
158
+ on(ctx, vp, "click", (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".node, .bandlabel, #tools") && ctx.moved <= 4) ctx.fn.select(null); });
159
+
160
+ const ro = new ResizeObserver(() => { cached = { w: vp.clientWidth, h: vp.clientHeight }; refreshRect(); if (!ctx.userMoved) fitW(false); else setView(ctx.view, false); });
161
+ ro.observe(vp);
162
+ on(ctx, vp, "pointerenter", refreshRect);
163
+ on(ctx, window, "scroll", refreshRect, { passive: true });
164
+ ctx.disposers.push(() => ro.disconnect());
165
+
166
+ // ---- tools
167
+ function setTool(id: "select" | "hand"): void {
168
+ ctx.tool = id;
169
+ vp.classList.toggle("hand", id === "hand");
170
+ els.tools.querySelectorAll<HTMLElement>("[data-id='select'], [data-id='hand']").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.id === id)));
171
+ ctx.fn.announce(id === "hand" ? "Hand tool: drag to pan" : "Select tool");
172
+ }
173
+ function toggleFull(): void {
174
+ const ws = els.workspace;
175
+ if (document.fullscreenElement) void document.exitFullscreen();
176
+ else if (ws.requestFullscreen) ws.requestFullscreen().catch(() => ws.classList.toggle("fs"));
177
+ else ws.classList.toggle("fs");
178
+ }
179
+ type Tool = [string, string, "tool" | "full" | (() => void)] | ["-"];
180
+ const TOOLS: Tool[] = [
181
+ ["select", "Select (V)", "tool"], ["hand", "Hand: drag to pan (H)", "tool"], ["-"],
182
+ ["plus", "Zoom in (+)", () => zoomBy(1.25)], ["minus", "Zoom out (-)", () => zoomBy(1 / 1.25)],
183
+ ["fit", "Fit to screen (0)", () => { ctx.userMoved = false; fit(true); ctx.fn.announce("Fit to screen"); }],
184
+ ["one", "Actual size (1)", () => { const s = size(); ctx.userMoved = true; setView(zoomAt(ctx.view, 1 / ctx.view.k, s.w / 2, s.h / 2), true); }],
185
+ ["sel", "Zoom to selection (F)", () => ctx.fn.zoomToPath()], ["-"],
186
+ ["full", "Fullscreen", "full"],
187
+ ];
188
+ els.tools.innerHTML = "";
189
+ for (const t of TOOLS) {
190
+ if (t[0] === "-") { els.tools.appendChild(el("div", "sep")); continue; }
191
+ const [id, label, act] = t as [string, string, "tool" | "full" | (() => void)];
192
+ const b = el("button", "tb", `<svg viewBox="0 0 24 24" aria-hidden="true">${ICON[id] ?? ""}</svg>`);
193
+ b.type = "button"; b.title = label; b.setAttribute("aria-label", label); b.dataset.id = id;
194
+ if (act === "tool") { b.setAttribute("aria-pressed", String(ctx.tool === id)); on(ctx, b, "click", () => setTool(id as "select" | "hand")); }
195
+ else if (act === "full") on(ctx, b, "click", toggleFull);
196
+ else on(ctx, b, "click", act);
197
+ els.tools.appendChild(b);
198
+ }
199
+ requestAnimationFrame(() => { const r = els.tools.getBoundingClientRect(), v = vp.getBoundingClientRect(); els.zoom.style.top = r.bottom - v.top + 6 + "px"; });
200
+ on(ctx, document, "fullscreenchange", () => window.setTimeout(() => { if (!ctx.userMoved) fitW(false); }, 60));
201
+
202
+ // ---- keys
203
+ on(ctx, document, "keydown", (e: KeyboardEvent) => {
204
+ const typing = !!e.target && /INPUT|TEXTAREA/.test((e.target as HTMLElement).tagName);
205
+ if (typing) { if (e.key === "Escape") (e.target as HTMLElement).blur(); return; }
206
+ if (e.ctrlKey || e.metaKey || e.altKey) return; // never hijack Ctrl+F, Ctrl+N, Ctrl+plus and the like
207
+ if (e.key === " " && document.activeElement === vp) { ctx.spaceDown = true; e.preventDefault(); vp.classList.add("hand"); return; }
208
+ const k = e.key, step = e.shiftKey ? 240 : 60;
209
+ if (k === "/") { e.preventDefault(); els.q.focus(); }
210
+ else if (k === "n" || k === "N") ctx.fn.toggleNotes();
211
+ else if (k === "v" || k === "V") setTool("select");
212
+ else if (k === "h" || k === "H") setTool("hand");
213
+ else if (k === "e" || k === "E") {
214
+ const selected = ctx.selected && ctx.nodes.get(ctx.selected);
215
+ if (selected) {
216
+ const group = selected.kind === "group" ? selected : ctx.model.nodes.find(n => n.kind === "group" && n.contains?.includes(selected.id));
217
+ if (group) ctx.fn.toggleGroup(group.id);
218
+ }
219
+ }
220
+ else if (k === "+" || k === "=") zoomBy(1.25);
221
+ else if (k === "-" || k === "_") zoomBy(1 / 1.25);
222
+ else if (k === "0") { ctx.userMoved = false; fit(true); }
223
+ else if (k === "1") { const s = size(); ctx.userMoved = true; setView(zoomAt(ctx.view, 1 / ctx.view.k, s.w / 2, s.h / 2), true); }
224
+ else if (k === "f" || k === "F") ctx.fn.zoomToPath();
225
+ else if (k.startsWith("Arrow") && (document.activeElement === vp || document.activeElement === document.body)) {
226
+ e.preventDefault();
227
+ ctx.userMoved = true;
228
+ setView({ k: ctx.view.k, x: ctx.view.x + (k === "ArrowLeft" ? step : k === "ArrowRight" ? -step : 0), y: ctx.view.y + (k === "ArrowUp" ? step : k === "ArrowDown" ? -step : 0) }, false);
229
+ } else if (k === "Escape") ctx.fn.escape();
230
+ });
231
+ on(ctx, document, "keyup", (e: KeyboardEvent) => { if (e.key === " ") { ctx.spaceDown = false; if (ctx.tool !== "hand") vp.classList.remove("hand"); } });
232
+
233
+ ctx.fn.setView = setView;
234
+ ctx.fn.fit = fit;
235
+ ctx.fn.fitWidth = fitW;
236
+ ctx.fn.zoomBy = zoomBy;
237
+ ctx.fn.stickLabels = stickLabels;
238
+ ctx.fn.placeHeaders = placeHeaders;
239
+ }
@@ -0,0 +1,134 @@
1
+ // The engine's shared state. Each DOM module receives one Ctx, reads and writes it, and registers
2
+ // its functions on ctx.fn, so modules call each other without importing each other.
3
+
4
+ import type { Model, NodeDef, LinkDef, NoteDef } from "./model.ts";
5
+ import type { View, Rect } from "./viewport.ts";
6
+ import type { Geometry } from "./layout.ts";
7
+
8
+ export interface Els {
9
+ vp: HTMLElement;
10
+ world: HTMLElement;
11
+ svg: SVGSVGElement;
12
+ layer: SVGGElement;
13
+ cols: HTMLElement;
14
+ tools: HTMLElement;
15
+ zoom: HTMLElement;
16
+ inspector: HTMLElement;
17
+ workspace: HTMLElement;
18
+ live: HTMLElement;
19
+ strip: HTMLElement;
20
+ tiles: HTMLElement;
21
+ legend: HTMLElement;
22
+ ledger: HTMLElement;
23
+ table: HTMLElement;
24
+ q: HTMLInputElement;
25
+ hops: HTMLSelectElement;
26
+ matchinfo: HTMLElement;
27
+ modeSwitch: HTMLElement;
28
+ drawer: HTMLElement;
29
+ notelist: HTMLElement;
30
+ notesBtn: HTMLElement;
31
+ theme: HTMLElement;
32
+ specbody: HTMLElement;
33
+ spec: HTMLElement;
34
+ title: HTMLElement;
35
+ readat: HTMLElement;
36
+ help: HTMLElement;
37
+ footer: HTMLElement;
38
+ playbar: HTMLElement;
39
+ pbScenario: HTMLSelectElement;
40
+ pbPlay: HTMLButtonElement;
41
+ pbStep: HTMLButtonElement;
42
+ pbReset: HTMLButtonElement;
43
+ pbSpeed: HTMLSelectElement;
44
+ pbScrub: HTMLInputElement;
45
+ pbCaption: HTMLElement;
46
+ pbCounter: HTMLElement;
47
+ pbChoices: HTMLElement;
48
+ banner: HTMLElement;
49
+ }
50
+
51
+ export type Filter = "gap" | "upgrade" | null;
52
+
53
+ export interface Fns {
54
+ paint(fromSwitch?: boolean): void;
55
+ buildWorld(): void;
56
+ trace(id: string | null): void;
57
+ select(id: string | null): void;
58
+ renderInspector(): void;
59
+ setView(v: View, animate?: boolean): void;
60
+ fit(animate?: boolean, rect?: Rect, inset?: number): void;
61
+ fitWidth(animate?: boolean): void;
62
+ zoomBy(factor: number): void;
63
+ zoomToPath(): void;
64
+ zoomToBand(id: string): void;
65
+ rectOf(ids: string[]): Rect | null;
66
+ drawTrail(): void;
67
+ simulate(actionId: string): void;
68
+ clearSim(): void;
69
+ announce(text: string): void;
70
+ stickLabels(): void;
71
+ placeHeaders(): void;
72
+ buildChrome(): void;
73
+ toggleNotes(on?: boolean): void;
74
+ placeMarkers(): void;
75
+ escape(): void;
76
+ setMode(id: string): void;
77
+ toggleGroup(id: string): void;
78
+ }
79
+
80
+ export interface Ctx {
81
+ model: Model;
82
+ root: ParentNode;
83
+ els: Els;
84
+ onSelect?: (id: string | null) => void;
85
+ notes: NoteDef[];
86
+ nodes: Map<string, NodeDef>;
87
+ links: LinkDef[];
88
+ geom: Geometry;
89
+ view: View;
90
+ reduced: boolean;
91
+ mode: string;
92
+ collapsed: Set<string>;
93
+ selected: string | null;
94
+ hl: string | null;
95
+ query: string;
96
+ hops: number;
97
+ filter: Filter;
98
+ tool: "select" | "hand";
99
+ spaceDown: boolean;
100
+ userMoved: boolean;
101
+ dismissed: boolean;
102
+ simActive: boolean;
103
+ moved: number;
104
+ nodeEls: Map<string, HTMLElement>;
105
+ laneEls: Map<string, HTMLElement>;
106
+ edgeEls: SVGPathElement[];
107
+ labelEls: HTMLElement[];
108
+ overlays: SVGPathElement[];
109
+ simTimers: number[];
110
+ disposers: Array<() => void>;
111
+ fn: Fns;
112
+ }
113
+
114
+ export const SVG_NS = "http://www.w3.org/2000/svg";
115
+
116
+ /** Make an element with a class and (trusted, already escaped) inner HTML. */
117
+ export function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string): HTMLElementTagNameMap[K] {
118
+ const e = document.createElement(tag);
119
+ if (cls) e.className = cls;
120
+ if (html !== undefined) e.innerHTML = html;
121
+ return e;
122
+ }
123
+
124
+ export function sv(tag: string, attrs: Record<string, string>): SVGElement {
125
+ const e = document.createElementNS(SVG_NS, tag);
126
+ for (const k of Object.keys(attrs)) e.setAttribute(k, attrs[k] as string);
127
+ return e;
128
+ }
129
+
130
+ /** Listen on a target and remember how to stop, so destroy() removes every listener. */
131
+ export function on<T extends EventTarget>(ctx: Ctx, target: T, type: string, fn: (e: any) => void, opts?: AddEventListenerOptions): void {
132
+ target.addEventListener(type, fn, opts);
133
+ ctx.disposers.push(() => target.removeEventListener(type, fn, opts));
134
+ }
@@ -0,0 +1,31 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { diffModels } from "./diff.ts";
4
+ import type { Model } from "./model.ts";
5
+
6
+ const base = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", sla: 30 }, { id: "b", state: "ok", title: "B", kind: "card" }] } as Model;
7
+ test("diff reports added, removed, and specified field changes", () => {
8
+ const next = { nodes: [{ ...base.nodes[0], metrics: { value: 2 } }, { id: "c", state: "ok", title: "C", kind: "card" }] } as Model;
9
+ assert.deepEqual(diffModels(base, next), { added: ["c"], removed: ["b"], changed: ["a"] });
10
+ assert.deepEqual(diffModels(base, structuredClone(base)), { added: [], removed: [], changed: [] });
11
+ });
12
+ test("diff detects change in sla", () => {
13
+ const next = { nodes: [{ ...base.nodes[0], sla: 60 }, base.nodes[1]] } as Model;
14
+ assert.deepEqual(diffModels(base, next), { added: [], removed: [], changed: ["a"] });
15
+ });
16
+ test("diff is invariant to key order in objects", () => {
17
+ const m1 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 10, unit: "ms", label: "latency" } }] } as Model;
18
+ const m2 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { label: "latency", unit: "ms", value: 10 } }] } as Model;
19
+ assert.deepEqual(diffModels(m1, m2), { added: [], removed: [], changed: [] });
20
+ });
21
+ test("an undefined-valued property counts as absent (a model comes from JSON, which cannot hold one)", () => {
22
+ const m1 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 1, unit: undefined } }] } as unknown as Model;
23
+ const m2 = { nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics: { value: 1 } }] } as Model;
24
+ assert.deepEqual(diffModels(m1, m2), { added: [], removed: [], changed: [] });
25
+ });
26
+ test("null, undefined and NaN stay distinct values in a diff", () => {
27
+ const mk = (metrics: unknown) => ({ nodes: [{ id: "a", state: "ok", title: "A", kind: "card", metrics }] }) as unknown as Model;
28
+ assert.deepEqual(diffModels(mk({ value: 1, series: [null] }), mk({ value: 1, series: [undefined] })).changed, ["a"]);
29
+ assert.deepEqual(diffModels(mk({ value: NaN }), mk({ value: null })).changed, ["a"]);
30
+ assert.deepEqual(diffModels(mk({ value: NaN }), mk({ value: NaN })).changed, []);
31
+ });
@@ -0,0 +1,34 @@
1
+ import type { Model, NodeDef } from "./model.ts";
2
+
3
+ const fields: Array<keyof NodeDef> = ["state", "stateIn", "title", "subtitle", "weight", "metrics", "visibleIn", "lane", "sla"];
4
+
5
+ // A stable serializer for the diff. Unlike JSON.stringify it never returns undefined, and values
6
+ // JSON cannot hold stay distinct: undefined, NaN and Infinity each get their own text, so a real
7
+ // change between them cannot be hidden by "null" conflation.
8
+ function stableStringify(val: unknown): string {
9
+ if (val === undefined) return "undefined";
10
+ if (val === null) return "null";
11
+ if (typeof val === "number") return Number.isFinite(val) ? JSON.stringify(val) : String(val);
12
+ if (typeof val === "bigint") return String(val);
13
+ if (typeof val === "object") {
14
+ if (Array.isArray(val)) return `[${val.map(stableStringify).join(",")}]`;
15
+ // A model comes from JSON, which cannot hold an undefined property, so an explicit one counts as absent.
16
+ const rec = val as Record<string, unknown>;
17
+ const keys = Object.keys(rec).filter(k => rec[k] !== undefined).sort();
18
+ return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(rec[k])}`).join(",")}}`;
19
+ }
20
+ return JSON.stringify(val) ?? "undefined";
21
+ }
22
+
23
+ export function diffModels(a: Model, b: Model): { added: string[]; removed: string[]; changed: string[] } {
24
+ const old = new Map(a.nodes.map(n => [n.id, n]));
25
+ const now = new Map(b.nodes.map(n => [n.id, n]));
26
+ return {
27
+ added: b.nodes.filter(n => !old.has(n.id)).map(n => n.id),
28
+ removed: a.nodes.filter(n => !now.has(n.id)).map(n => n.id),
29
+ changed: b.nodes.filter(n => {
30
+ const prev = old.get(n.id);
31
+ return prev && fields.some(k => stableStringify(prev[k]) !== stableStringify(n[k]));
32
+ }).map(n => n.id),
33
+ };
34
+ }