pmx-canvas 0.9.3 → 0.11.0

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 (237) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +9418 -2016
  3. package/CHANGELOG.md +54 -1
  4. package/dist/canvas/chunks/abnfDiagram-N423BO3Z-p8dtp6va.js +1 -0
  5. package/dist/canvas/chunks/architecture-TIHT7OUA-9tffvpsv.js +1 -0
  6. package/dist/canvas/chunks/architectureDiagram-T3A2C74G-5jhmxrzf.js +36 -0
  7. package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-pw1fa9yb.js +132 -0
  8. package/dist/canvas/chunks/c4Diagram-5PPSVZJV-x07nq0tt.js +10 -0
  9. package/dist/canvas/chunks/classDiagram-JCYQIIEL-nc6g5x1r.js +1 -0
  10. package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-0qnbpwvh.js +1 -0
  11. package/dist/canvas/chunks/cose-bilkent-JH36ORCC-nm39npq0.js +1 -0
  12. package/dist/canvas/chunks/cynefin-VYW2F7L2-ys8p46ts.js +1 -0
  13. package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-vdxrwahz.js +62 -0
  14. package/dist/canvas/chunks/dagre-VZM6K2ZE-bqdm7wpd.js +4 -0
  15. package/dist/canvas/chunks/diagram-7IWD3JNH-gs5gpr93.js +30 -0
  16. package/dist/canvas/chunks/diagram-B4RE2ZJO-cgwjg47s.js +3 -0
  17. package/dist/canvas/chunks/diagram-LBJQPF4R-w3vahpav.js +24 -0
  18. package/dist/canvas/chunks/diagram-Q27KOJAE-42837z6h.js +24 -0
  19. package/dist/canvas/chunks/diagram-UB23O5K3-4fz83q59.js +41 -0
  20. package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-zphtgegj.js +1 -0
  21. package/dist/canvas/chunks/erDiagram-JOGREHBK-zvbbdxp4.js +85 -0
  22. package/dist/canvas/chunks/eventmodeling-45OFAUF4-v9q6dn4c.js +1 -0
  23. package/dist/canvas/chunks/flowDiagram-UKHOOZJN-yrr5z8tc.js +1 -0
  24. package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-282jzycm.js +292 -0
  25. package/dist/canvas/chunks/gitGraph-TEB2WS4Q-kdwb8m0a.js +1 -0
  26. package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-vqqy6p1b.js +106 -0
  27. package/dist/canvas/chunks/index-0rk4gp1g.js +2 -0
  28. package/dist/canvas/chunks/index-194m3hc3.js +2 -0
  29. package/dist/canvas/chunks/index-1r72hdzb.js +301 -0
  30. package/dist/canvas/chunks/index-353m7b0p.js +2 -0
  31. package/dist/canvas/chunks/index-3cy1bzwh.js +2 -0
  32. package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
  33. package/dist/canvas/chunks/index-6ceya556.js +332 -0
  34. package/dist/canvas/chunks/index-6qmsnxwy.js +4 -0
  35. package/dist/canvas/chunks/index-d3b3gqe9.js +2 -0
  36. package/dist/canvas/chunks/index-fjtdggjn.js +2 -0
  37. package/dist/canvas/chunks/index-g3pwnqeb.js +2 -0
  38. package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
  39. package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
  40. package/dist/canvas/chunks/index-nprm96xp.js +16 -0
  41. package/dist/canvas/chunks/index-qj8fxwsf.js +2 -0
  42. package/dist/canvas/chunks/index-r2fyc9w1.js +89 -0
  43. package/dist/canvas/chunks/index-rft2hcz5.js +2 -0
  44. package/dist/canvas/chunks/index-rh7nv68n.js +2 -0
  45. package/dist/canvas/chunks/index-s4j678gj.js +2 -0
  46. package/dist/canvas/chunks/index-tb1wf525.js +179 -0
  47. package/dist/canvas/chunks/index-vv56h019.js +2 -0
  48. package/dist/canvas/chunks/index-w2mra17t.js +207 -0
  49. package/dist/canvas/chunks/index-wgv7en21.js +232 -0
  50. package/dist/canvas/chunks/index-yw8h9vha.js +157 -0
  51. package/dist/canvas/chunks/info-DKCQHKI2-wpzs6sz3.js +1 -0
  52. package/dist/canvas/chunks/infoDiagram-6WML65LV-v1yvggqj.js +2 -0
  53. package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-5hnq1a9p.js +70 -0
  54. package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-zjcffveh.js +139 -0
  55. package/dist/canvas/chunks/kanban-definition-27J2QSJJ-dakqtfp1.js +89 -0
  56. package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
  57. package/dist/canvas/chunks/mermaid.core-j2geswrd.js +12 -0
  58. package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-142xhr3k.js +96 -0
  59. package/dist/canvas/chunks/packet-7NZHBO7P-xn439f0s.js +1 -0
  60. package/dist/canvas/chunks/pegDiagram-VL7TDLO6-5wvk7vsd.js +1 -0
  61. package/dist/canvas/chunks/pie-RZYD4A2V-0kf16rwh.js +1 -0
  62. package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-ztdhwycc.js +39 -0
  63. package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-c5p9gmg6.js +7 -0
  64. package/dist/canvas/chunks/radar-I7S5WNFK-0g2pz36t.js +1 -0
  65. package/dist/canvas/chunks/railroad-3IZDKUUU-fcaran6j.js +1 -0
  66. package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-wgdya41q.js +1 -0
  67. package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-gy07a4ka.js +1 -0
  68. package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-ewk6wtkh.js +1 -0
  69. package/dist/canvas/chunks/railroadDiagram-AXF67PYL-xkmqkjwf.js +1 -0
  70. package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-9wm2dyaw.js +84 -0
  71. package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-2w19etgb.js +40 -0
  72. package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-0jn0vwmz.js +162 -0
  73. package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-x75epwdq.js +1 -0
  74. package/dist/canvas/chunks/stateDiagram-OKZ733FA-schjpy6d.js +1 -0
  75. package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-k8sfp2e7.js +1 -0
  76. package/dist/canvas/chunks/swimlanes-SLNWSIFB-gkw4xce3.js +1 -0
  77. package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-nqysw67j.js +8 -0
  78. package/dist/canvas/chunks/timeline-definition-Z64GVDOM-x7nkcy01.js +120 -0
  79. package/dist/canvas/chunks/treeView-QDETBFTQ-cesw6gp0.js +1 -0
  80. package/dist/canvas/chunks/treemap-6X3UGDF4-zdv4knp9.js +1 -0
  81. package/dist/canvas/chunks/vennDiagram-T6HMQDX7-x8dqn9yf.js +34 -0
  82. package/dist/canvas/chunks/wardley-OPB4EBWU-44pdddez.js +1 -0
  83. package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-t1j52t9r.js +78 -0
  84. package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-38t16pme.js +7 -0
  85. package/dist/canvas/global.css +389 -229
  86. package/dist/canvas/index.js +141 -211
  87. package/dist/canvas/mcp-app.html +4205 -554
  88. package/dist/canvas/mermaid-entry.js +338 -338
  89. package/dist/canvas/surface-theme.css +6 -6
  90. package/dist/json-render/index.css +1 -1
  91. package/dist/json-render/index.js +49 -49
  92. package/dist/types/client/canvas/BarHint.d.ts +5 -3
  93. package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
  94. package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
  95. package/dist/types/client/canvas/auto-fit.d.ts +4 -5
  96. package/dist/types/client/mermaid-entry.d.ts +1 -2
  97. package/dist/types/client/mermaid-render.d.ts +1 -0
  98. package/dist/types/client/nodes/BoardNode.d.ts +1 -1
  99. package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
  100. package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
  101. package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
  102. package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
  103. package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
  104. package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
  105. package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
  106. package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
  107. package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
  108. package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
  109. package/dist/types/client/state/canvas-store.d.ts +3 -1
  110. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  111. package/dist/types/client/state/presentation.d.ts +17 -6
  112. package/dist/types/json-render/server.d.ts +2 -1
  113. package/dist/types/mcp/canvas-access.d.ts +1 -12
  114. package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
  115. package/dist/types/mcp/server.d.ts +2 -0
  116. package/dist/types/server/ax-wait.d.ts +1 -0
  117. package/dist/types/server/canvas-operations.d.ts +1 -0
  118. package/dist/types/server/canvas-state.d.ts +2 -0
  119. package/dist/types/server/index.d.ts +11 -6
  120. package/dist/types/server/operations/http.d.ts +10 -0
  121. package/dist/types/server/operations/index.d.ts +2 -2
  122. package/dist/types/server/operations/invoker.d.ts +4 -3
  123. package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
  124. package/dist/types/server/operations/mcp.d.ts +2 -0
  125. package/dist/types/server/operations/ops/batch.d.ts +1 -0
  126. package/dist/types/server/operations/ops/boards.d.ts +0 -1
  127. package/dist/types/server/operations/registry.d.ts +2 -2
  128. package/dist/types/server/operations/types.d.ts +12 -2
  129. package/dist/types/server/server.d.ts +2 -0
  130. package/dist/types/server/tour-control.d.ts +13 -3
  131. package/dist/types/server/webpage-node.d.ts +1 -0
  132. package/dist/types/shared/ax-surface-protocol.d.ts +0 -9
  133. package/dist/types/shared/tour.d.ts +8 -0
  134. package/docs/api-reference.md +2 -2
  135. package/docs/cli.md +1 -1
  136. package/docs/design/Chrome.dc.html +32 -6
  137. package/docs/design/Types1.dc.html +3 -2
  138. package/docs/design/Types2.dc.html +1 -1
  139. package/docs/design/Types3.dc.html +1 -1
  140. package/docs/design/Zoom.dc.html +37 -60
  141. package/docs/design/canvas.json +1 -1
  142. package/docs/design.md +169 -4
  143. package/docs/http-api.md +82 -1
  144. package/docs/mcp.md +101 -21
  145. package/docs/node-types.md +38 -3
  146. package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
  147. package/docs/plans/plan-019-post-0.9-roadmap.md +402 -13
  148. package/docs/product-vision-2026-09.md +9 -0
  149. package/docs/sdk.md +5 -2
  150. package/package.json +2 -2
  151. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  152. package/plugins/pmx-canvas/plugin.json +1 -1
  153. package/plugins/pmx-canvas/runtime.json +1 -1
  154. package/plugins/pmx-canvas/tools.json +9418 -2016
  155. package/skills/pmx-canvas/SKILL.md +19 -21
  156. package/skills/pmx-canvas/references/full-reference.md +44 -6
  157. package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
  158. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +3 -5
  159. package/src/client/canvas/BarHint.tsx +6 -3
  160. package/src/client/canvas/CanvasNode.tsx +34 -47
  161. package/src/client/canvas/CanvasViewport.tsx +6 -7
  162. package/src/client/canvas/ContextMenu.tsx +63 -3
  163. package/src/client/canvas/EdgeLayer.tsx +365 -169
  164. package/src/client/canvas/ExpandedNodeOverlay.tsx +1 -2
  165. package/src/client/canvas/Presentation.tsx +53 -3
  166. package/src/client/canvas/ToolRail.tsx +2 -0
  167. package/src/client/canvas/TopBar.tsx +24 -9
  168. package/src/client/canvas/auto-fit.ts +9 -11
  169. package/src/client/canvas/canvas-area.ts +1 -1
  170. package/src/client/canvas/use-focus-trap.ts +6 -1
  171. package/src/client/mermaid-entry.ts +6 -20
  172. package/src/client/mermaid-render.ts +39 -0
  173. package/src/client/nodes/AxStepControls.tsx +45 -69
  174. package/src/client/nodes/BoardNode.tsx +65 -9
  175. package/src/client/nodes/ContextNode.tsx +88 -396
  176. package/src/client/nodes/ExtAppFrame.tsx +283 -1462
  177. package/src/client/nodes/FileNode.tsx +92 -81
  178. package/src/client/nodes/HtmlNode.tsx +8 -36
  179. package/src/client/nodes/LedgerNode.tsx +31 -17
  180. package/src/client/nodes/MarkdownNode.tsx +8 -17
  181. package/src/client/nodes/McpAppNode.tsx +48 -154
  182. package/src/client/nodes/MermaidNode.tsx +80 -69
  183. package/src/client/nodes/PromptNode.tsx +12 -14
  184. package/src/client/nodes/ResponseNode.tsx +8 -6
  185. package/src/client/nodes/StatusNode.tsx +22 -7
  186. package/src/client/nodes/StatusSummary.tsx +5 -3
  187. package/src/client/nodes/TraceNode.tsx +8 -8
  188. package/src/client/nodes/ViewerFrame.tsx +262 -0
  189. package/src/client/nodes/ViewerHost.tsx +41 -0
  190. package/src/client/nodes/WebpageNode.tsx +22 -24
  191. package/src/client/nodes/iframe-document-url.ts +20 -27
  192. package/src/client/nodes/primitive-copy.ts +97 -0
  193. package/src/client/nodes/use-iframe-content-height.ts +34 -10
  194. package/src/client/nodes/use-surface-frame.ts +22 -11
  195. package/src/client/nodes/viewer-runtime-state.ts +41 -35
  196. package/src/client/state/canvas-store.ts +18 -12
  197. package/src/client/state/intent-bridge.ts +1 -0
  198. package/src/client/state/presentation.ts +135 -15
  199. package/src/client/state/sse-bridge.ts +18 -19
  200. package/src/client/theme/global.css +389 -229
  201. package/src/client/theme/surface-theme.css +6 -6
  202. package/src/json-render/charts/tufte-components.tsx +44 -2
  203. package/src/json-render/renderer/index.css +62 -2
  204. package/src/json-render/renderer/index.tsx +31 -7
  205. package/src/json-render/server.ts +3 -2
  206. package/src/mcp/canvas-access.ts +6 -47
  207. package/src/mcp/resource-subscriptions.ts +181 -0
  208. package/src/mcp/server.ts +185 -219
  209. package/src/server/ax-wait.ts +21 -6
  210. package/src/server/board-export.ts +35 -24
  211. package/src/server/canvas-geometry.ts +1 -0
  212. package/src/server/canvas-operations.ts +6 -2
  213. package/src/server/canvas-state.ts +5 -0
  214. package/src/server/demo-state.json +106 -100
  215. package/src/server/html-primitives.ts +3 -9
  216. package/src/server/html-surface.ts +8 -0
  217. package/src/server/index.ts +30 -12
  218. package/src/server/operations/http.ts +69 -21
  219. package/src/server/operations/index.ts +8 -1
  220. package/src/server/operations/invoker.ts +53 -5
  221. package/src/server/operations/mcp-contracts.ts +354 -0
  222. package/src/server/operations/mcp.ts +153 -18
  223. package/src/server/operations/ops/ax-await.ts +18 -10
  224. package/src/server/operations/ops/ax-read.ts +2 -0
  225. package/src/server/operations/ops/ax-timeline.ts +2 -2
  226. package/src/server/operations/ops/batch.ts +27 -8
  227. package/src/server/operations/ops/boards.ts +1 -3
  228. package/src/server/operations/ops/nodes.ts +18 -3
  229. package/src/server/operations/ops/viewport.ts +11 -9
  230. package/src/server/operations/registry.ts +24 -4
  231. package/src/server/operations/types.ts +16 -2
  232. package/src/server/server.ts +17 -59
  233. package/src/server/tour-control.ts +66 -6
  234. package/src/server/webpage-node.ts +9 -4
  235. package/src/shared/ax-surface-protocol.ts +0 -11
  236. package/src/shared/content-height-reporter.ts +4 -3
  237. package/src/shared/tour.ts +8 -0
@@ -1,5 +1,5 @@
1
- import { type Signal, signal } from '@preact/signals';
2
- import { useEffect, useRef } from 'preact/hooks';
1
+ import { type Signal, computed, signal } from '@preact/signals';
2
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
3
3
  import {
4
4
  draggingEdge,
5
5
  edges,
@@ -146,6 +146,8 @@ export function edgeChromeScale(scale: number): number {
146
146
  return scale < 1 ? 1 / scale : 1;
147
147
  }
148
148
 
149
+ const edgeZoom = computed(() => viewport.value.scale);
150
+
149
151
  function dashArray(edge: CanvasEdge, scale: number): string | undefined {
150
152
  const dashed = `${8 * scale} ${4 * scale}`;
151
153
  if (edge.style === 'dashed') return dashed;
@@ -238,13 +240,56 @@ function bezierMidpoint(
238
240
  };
239
241
  }
240
242
 
243
+ interface LabelObstacle {
244
+ x: number;
245
+ y: number;
246
+ width: number;
247
+ height: number;
248
+ }
249
+
250
+ /** Nearest clear position along either axis through the original curve midpoint.
251
+ * Expanded obstacles describe forbidden pill centres. Boundary candidates include
252
+ * the outside of the whole occupied area, so even a crowded board has a solution.
253
+ * World-space placement is independent of pan; all three layers use the same order.
254
+ */
255
+ function placeLabel(
256
+ mid: { x: number; y: number },
257
+ width: number,
258
+ height: number,
259
+ obstacles: LabelObstacle[],
260
+ gap: number,
261
+ ) {
262
+ const forbidden = obstacles.map((box) => ({
263
+ left: box.x - width / 2 - gap,
264
+ right: box.x + box.width + width / 2 + gap,
265
+ top: box.y - height / 2 - gap,
266
+ bottom: box.y + box.height + height / 2 + gap,
267
+ }));
268
+ const clear = (point: { x: number; y: number }) =>
269
+ forbidden.every(
270
+ (box) => point.x <= box.left || point.x >= box.right || point.y <= box.top || point.y >= box.bottom,
271
+ );
272
+ if (clear(mid)) return mid;
273
+ const candidates = [mid];
274
+ for (const box of forbidden) {
275
+ if (mid.x > box.left && mid.x < box.right) {
276
+ candidates.push({ x: mid.x, y: box.top }, { x: mid.x, y: box.bottom });
277
+ }
278
+ if (mid.y > box.top && mid.y < box.bottom) {
279
+ candidates.push({ x: box.left, y: mid.y }, { x: box.right, y: mid.y });
280
+ }
281
+ }
282
+ const distance = (point: { x: number; y: number }) => (point.x - mid.x) ** 2 + (point.y - mid.y) ** 2;
283
+ candidates.sort((a, b) => distance(a) - distance(b));
284
+ return candidates.find(clear)!;
285
+ }
286
+
241
287
  // ── EdgePath component ───────────────────────────────────────
242
288
  interface EdgePathProps {
243
289
  edge: CanvasEdge;
244
290
  fromNode: CanvasNodeState;
245
291
  toNode: CanvasNodeState;
246
- start: Anchor;
247
- end: Anchor;
292
+ layout: ReturnType<typeof edgeLayout>;
248
293
  focused: boolean; // connected to the active node
249
294
  dimmed: boolean; // active node exists but this edge is NOT connected
250
295
  selected: boolean; // click-selected — Delete removes it
@@ -260,7 +305,14 @@ interface EdgePathProps {
260
305
  */
261
306
  export type EdgeLayerPart = 'lines' | 'pills' | 'pill-hits';
262
307
 
263
- function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selected, scale, part }: EdgePathProps) {
308
+ function edgeLayout(
309
+ edge: CanvasEdge,
310
+ start: Anchor,
311
+ end: Anchor,
312
+ scale: number,
313
+ obstacles: LabelObstacle[],
314
+ textWidth: (text: string, weight?: number) => number,
315
+ ) {
264
316
  const dx = end.x - start.x;
265
317
  const dy = end.y - start.y;
266
318
  const dist = Math.sqrt(dx * dx + dy * dy);
@@ -279,7 +331,7 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
279
331
  // label chrome caps like node chrome (2.2). Below 35% zoom the label goes and
280
332
  // only the link's marks stay (LinkAuthorship.dc.html).
281
333
  const labelScale = Math.min(scale, 2.2);
282
- const zoom = viewport.value.scale;
334
+ const zoom = edgeZoom.value;
283
335
  const marks = linkMarks(edge);
284
336
  // Reasons belong in the hint/inspector, not a long pill covering both endpoints.
285
337
  const label = edge.label ?? edge.kind;
@@ -287,9 +339,9 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
287
339
  // Pill layout in label units (× labelScale): the label, then a hairline and
288
340
  // "not seen by …", which keeps only its glyph on a short line or below 60% zoom.
289
341
  const pad = showLabel ? 9 : 5;
290
- const labelWidth = showLabel ? (label?.length ?? 0) * 7 : 0;
342
+ const labelWidth = showLabel ? textWidth(label) : 0;
291
343
  const notSeenText = `not seen by ${agentName()}`;
292
- const wordedNotSeenWidth = 12 + notSeenText.length * 6.2 + 4;
344
+ const wordedNotSeenWidth = 12 + textWidth(notSeenText, 500) + 4;
293
345
  const fullWidth = pad * 2 + labelWidth + (marks.notSeen ? (labelWidth ? 12 : 0) + wordedNotSeenWidth : 0);
294
346
  // A short line: the full pill plus some line on either side would not fit.
295
347
  const notSeenWords = marks.notSeen && zoom >= 0.6 && dist >= (fullWidth + 60) * labelScale;
@@ -298,7 +350,55 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
298
350
  const labelX = -pillWidth / 2 + pad;
299
351
  const notSeenX = labelX + labelWidth + (labelWidth ? 12 : 0);
300
352
  const hasPill = showLabel || marks.notSeen;
301
- const mid = hasPill ? bezierMidpoint(start.x, start.y, cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y) : null;
353
+ const curveMid = hasPill ? bezierMidpoint(start.x, start.y, cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y) : null;
354
+ const mid = curveMid ? placeLabel(curveMid, pillWidth * labelScale, 20 * labelScale, obstacles, 4 * scale) : null;
355
+ if (mid) {
356
+ obstacles.push({
357
+ x: mid.x - (pillWidth / 2) * labelScale,
358
+ y: mid.y - 10 * labelScale,
359
+ width: pillWidth * labelScale,
360
+ height: 20 * labelScale,
361
+ });
362
+ }
363
+ return {
364
+ d,
365
+ color,
366
+ directed,
367
+ dash,
368
+ labelScale,
369
+ marks,
370
+ label,
371
+ showLabel,
372
+ labelWidth,
373
+ notSeenText,
374
+ notSeenWords,
375
+ pillWidth,
376
+ labelX,
377
+ notSeenX,
378
+ curveMid,
379
+ mid,
380
+ };
381
+ }
382
+
383
+ function EdgePath({ edge, fromNode, toNode, layout, focused, dimmed, selected, scale, part }: EdgePathProps) {
384
+ const {
385
+ d,
386
+ color,
387
+ directed,
388
+ dash,
389
+ labelScale,
390
+ marks,
391
+ label,
392
+ showLabel,
393
+ labelWidth,
394
+ notSeenText,
395
+ notSeenWords,
396
+ pillWidth,
397
+ labelX,
398
+ notSeenX,
399
+ curveMid,
400
+ mid,
401
+ } = layout;
302
402
 
303
403
  const pathId = `edge-path-${edge.id}`;
304
404
  const select = (e: MouseEvent) => {
@@ -355,6 +455,7 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
355
455
  transform={`translate(${mid.x}, ${mid.y})`}
356
456
  class="edge-pill"
357
457
  data-testid="edge-pill"
458
+ data-edge-id={edge.id}
358
459
  opacity={dimmed && !selected ? 0.35 : 1}
359
460
  >
360
461
  <rect
@@ -416,6 +517,22 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
416
517
 
417
518
  return (
418
519
  <g>
520
+ {/* A displaced label remains attached to its original curve, not a new
521
+ relation. No arrow; same semantic ink, below cards like the curve. */}
522
+ {mid && curveMid && (mid.x !== curveMid.x || mid.y !== curveMid.y) && (
523
+ <line
524
+ data-testid="edge-label-leader"
525
+ data-edge-id={edge.id}
526
+ x1={curveMid.x}
527
+ y1={curveMid.y}
528
+ x2={mid.x}
529
+ y2={mid.y}
530
+ stroke={color}
531
+ stroke-width={scale}
532
+ stroke-dasharray={`${3 * scale} ${3 * scale}`}
533
+ opacity={dimmed && !selected ? 0.2 : 0.85}
534
+ />
535
+ )}
419
536
  {/* Invisible wide hitbox — the ONE interactive part of the edge layer
420
537
  (the svg itself is pointer-events:none): click selects the edge
421
538
  (Delete then removes it); usePanZoom owns right-click versus drag. */}
@@ -462,52 +579,120 @@ function EdgePath({ edge, fromNode, toNode, start, end, focused, dimmed, selecte
462
579
  interface EdgeLayerProps {
463
580
  nodes: Signal<Map<string, CanvasNodeState>>;
464
581
  edges: Signal<Map<string, CanvasEdge>>;
465
- part: EdgeLayerPart;
466
582
  }
467
583
 
468
- export function EdgeLayer({ nodes, edges, part }: EdgeLayerProps) {
469
- const nodeMap = nodes.value;
470
- const edgeList = Array.from(edges.value.values());
584
+ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
585
+ const textContext = useMemo(() => document.createElement('canvas').getContext('2d'), []);
586
+ const font = useMemo(
587
+ () =>
588
+ signal({
589
+ family: getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif',
590
+ version: 0,
591
+ }),
592
+ [],
593
+ );
594
+ useLayoutEffect(() => {
595
+ // Re-measure when Plex replaces the fallback font, without waiting for a
596
+ // pan/zoom. All three paint/hit layers share the same glyph advances.
597
+ const refresh = () => {
598
+ font.value = {
599
+ family: getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif',
600
+ version: font.value.version + 1,
601
+ };
602
+ };
603
+ document.fonts?.addEventListener('loadingdone', refresh);
604
+ return () => document.fonts?.removeEventListener('loadingdone', refresh);
605
+ }, []);
606
+ const geometry = useMemo(
607
+ () =>
608
+ computed(() => {
609
+ const family = font.value.family;
610
+ const textWidth = (text: string, weight = 400) => {
611
+ // Non-rendering DOMs have no canvas context. Keep their bounds conservative.
612
+ if (!textContext) return text.length * 11;
613
+ textContext.font = `${weight} 11px ${family}`;
614
+ const metrics = textContext.measureText(text);
615
+ return Math.ceil(Math.max(metrics.width, metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight));
616
+ };
617
+ const nodeMap = nodes.value;
618
+ const edgeList = Array.from(edges.value.values());
619
+ const scale = edgeChromeScale(edgeZoom.value);
620
+
621
+ // Allocate a port to every incident link, including parallel and reverse links.
622
+ const ports = new Map<string, { start: Anchor; end: Anchor }>();
623
+ const sides = new Map<string, Array<{ anchor: Anchor; node: CanvasNodeState; order: number; id: string }>>();
624
+ for (const edge of edgeList) {
625
+ if (edge.toBoardId) continue;
626
+ const from = visibleNodeFor(edge.from);
627
+ const to = visibleNodeFor(edge.to);
628
+ if (!from || !to || from.id === to.id) continue;
629
+ const start = computeAnchor(from, to);
630
+ const end = computeAnchor(to, from);
631
+ ports.set(edge.id, { start, end });
632
+ for (const [anchor, node, other] of [
633
+ [start, from, to],
634
+ [end, to, from],
635
+ ] as const) {
636
+ const key = `${node.id}:${anchor.side}`;
637
+ const vertical = anchor.side === 'left' || anchor.side === 'right';
638
+ const group = sides.get(key) ?? [];
639
+ group.push({ anchor, node, order: vertical ? other.position.y : other.position.x, id: edge.id });
640
+ sides.set(key, group);
641
+ }
642
+ }
643
+ for (const group of sides.values()) {
644
+ if (group.length < 2) continue;
645
+ group.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
646
+ group.forEach(({ anchor, node }, index) => {
647
+ const fraction = (index + 1) / (group.length + 1);
648
+ if (anchor.side === 'left' || anchor.side === 'right')
649
+ anchor.y = node.position.y + node.size.height * fraction;
650
+ else anchor.x = node.position.x + node.size.width * fraction;
651
+ });
652
+ }
653
+
654
+ // Reserve the actual four outward handle rectangles, not a padded card:
655
+ // padding the whole card falsely closes row gaps at fractional zoom.
656
+ // Expanded groups have only their 24px edge row, and no ports.
657
+ const obstacles: LabelObstacle[] = [];
658
+ const portSize = 32 / edgeZoom.value;
659
+ for (const node of nodeMap.values()) {
660
+ if (visibleNodeFor(node.id)?.id !== node.id) continue;
661
+ const frame = node.type === 'group' && !node.collapsed;
662
+ const { x, y } = node.position;
663
+ const { width, height } = node.size;
664
+ obstacles.push({
665
+ x,
666
+ y: y - (frame ? 12 : 0),
667
+ width,
668
+ height: frame ? 24 : height,
669
+ });
670
+ if (node.type !== 'group') {
671
+ for (const [px, py] of [
672
+ [x + width / 2 - portSize / 2, y - portSize],
673
+ [x + width / 2 - portSize / 2, y + height],
674
+ [x - portSize, y + height / 2 - portSize / 2],
675
+ [x + width, y + height / 2 - portSize / 2],
676
+ ]) {
677
+ obstacles.push({ x: px, y: py, width: portSize, height: portSize });
678
+ }
679
+ }
680
+ }
681
+ const layouts = new Map<string, ReturnType<typeof edgeLayout>>();
682
+ for (const edge of edgeList) {
683
+ const anchors = ports.get(edge.id);
684
+ if (anchors) layouts.set(edge.id, edgeLayout(edge, anchors.start, anchors.end, scale, obstacles, textWidth));
685
+ }
686
+ return { nodeMap, edgeList, layouts, scale };
687
+ }),
688
+ [nodes, edges, textContext, font],
689
+ );
690
+
691
+ const { nodeMap, edgeList, layouts, scale } = geometry.value;
471
692
  const focusId = relationFocus.value;
472
693
  const hasFocus = focusId !== null;
473
694
  const searchSet = searchHighlightIds.value;
474
695
  const hasSearch = searchSet !== null;
475
- const scale = edgeChromeScale(viewport.value.scale);
476
-
477
- // Allocate a port to every incident link, including parallel and reverse links.
478
- const ports = new Map<string, { start: Anchor; end: Anchor }>();
479
- const sides = new Map<string, Array<{ anchor: Anchor; node: CanvasNodeState; order: number; id: string }>>();
480
- for (const edge of edgeList) {
481
- if (edge.toBoardId) continue;
482
- const from = visibleNodeFor(edge.from);
483
- const to = visibleNodeFor(edge.to);
484
- if (!from || !to || from.id === to.id) continue;
485
- const start = computeAnchor(from, to);
486
- const end = computeAnchor(to, from);
487
- ports.set(edge.id, { start, end });
488
- for (const [anchor, node, other] of [
489
- [start, from, to],
490
- [end, to, from],
491
- ] as const) {
492
- const key = `${node.id}:${anchor.side}`;
493
- const vertical = anchor.side === 'left' || anchor.side === 'right';
494
- const group = sides.get(key) ?? [];
495
- group.push({ anchor, node, order: vertical ? other.position.y : other.position.x, id: edge.id });
496
- sides.set(key, group);
497
- }
498
- }
499
- for (const group of sides.values()) {
500
- if (group.length < 2) continue;
501
- group.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
502
- group.forEach(({ anchor, node }, index) => {
503
- const fraction = (index + 1) / (group.length + 1);
504
- if (anchor.side === 'left' || anchor.side === 'right') anchor.y = node.position.y + node.size.height * fraction;
505
- else anchor.x = node.position.x + node.size.width * fraction;
506
- });
507
- }
508
-
509
- // A drag-to-connect preview must draw on a board with no edges yet.
510
- if (edgeList.length === 0 && (part !== 'lines' || !draggingEdge.value)) return null;
511
696
 
512
697
  const PAD = 96;
513
698
  const worldNodes = Array.from(nodeMap.values());
@@ -520,128 +705,139 @@ export function EdgeLayer({ nodes, edges, part }: EdgeLayerProps) {
520
705
  const height = Math.max(1, maxY - minY);
521
706
 
522
707
  return (
523
- <svg
524
- aria-label={part === 'lines' ? 'Canvas connections' : undefined}
525
- aria-hidden={part !== 'lines' ? 'true' : undefined}
526
- role={part === 'lines' ? 'img' : undefined}
527
- viewBox={`${minX} ${minY} ${width} ${height}`}
528
- width={width}
529
- height={height}
530
- style={{
531
- position: 'absolute',
532
- top: `${minY}px`,
533
- left: `${minX}px`,
534
- // Painting stays unchanged. Hit priority is card > port > pill > group
535
- // interior > line, including raised and nested group frames.
536
- zIndex:
537
- part === 'pills' ? 1 : part === 'pill-hits' ? 'calc(var(--node-port-z, -1) - 1)' : 'var(--edge-line-z, -5)',
538
- pointerEvents: 'none',
539
- overflow: 'visible',
540
- '--edge-chrome-scale': scale.toFixed(3),
541
- '--edge-label-scale': Math.min(scale, 2.2).toFixed(3),
542
- }}
543
- >
544
- {part === 'lines' && <title>Canvas connections</title>}
545
- <defs>
546
- {/* userSpaceOnUse decouples the arrowhead from the UNCAPPED stroke
708
+ <>
709
+ {(['lines', 'pills', 'pill-hits'] as const).map((part) =>
710
+ // A drag-to-connect preview must draw on a board with no edges yet.
711
+ edgeList.length === 0 && (part !== 'lines' || !draggingEdge.value) ? null : (
712
+ <svg
713
+ key={part}
714
+ aria-label={part === 'lines' ? 'Canvas connections' : undefined}
715
+ aria-hidden={part !== 'lines' ? 'true' : undefined}
716
+ role={part === 'lines' ? 'img' : undefined}
717
+ viewBox={`${minX} ${minY} ${width} ${height}`}
718
+ width={width}
719
+ height={height}
720
+ style={{
721
+ position: 'absolute',
722
+ top: `${minY}px`,
723
+ left: `${minX}px`,
724
+ // Painting stays unchanged. Hit priority is card > port > pill > group
725
+ // interior > line, including raised and nested group frames.
726
+ zIndex:
727
+ part === 'pills'
728
+ ? 1
729
+ : part === 'pill-hits'
730
+ ? 'calc(var(--node-port-z, -1) - 1)'
731
+ : 'var(--edge-line-z, -5)',
732
+ pointerEvents: 'none',
733
+ overflow: 'visible',
734
+ '--edge-chrome-scale': scale.toFixed(3),
735
+ '--edge-label-scale': Math.min(scale, 2.2).toFixed(3),
736
+ }}
737
+ >
738
+ {part === 'lines' && <title>Canvas connections</title>}
739
+ <defs>
740
+ {/* userSpaceOnUse decouples the arrowhead from the UNCAPPED stroke
547
741
  compensation: the line stays a visible hairline at any zoom, but
548
742
  the head caps at the node-chrome scale (2.2) instead of dwarfing
549
743
  sliver nodes at overview zoom. */}
550
- <marker
551
- id="edge-arrow"
552
- viewBox="0 0 10 10"
553
- refX="9"
554
- refY="5"
555
- markerUnits="userSpaceOnUse"
556
- markerWidth={8 * Math.min(scale, 2.2)}
557
- markerHeight={8 * Math.min(scale, 2.2)}
558
- orient="auto-start-reverse"
559
- >
560
- <path d="M 0 1 L 10 5 L 0 9 z" fill="context-stroke" opacity="0.75" />
561
- </marker>
562
- </defs>
563
- {edgeList.map((edge) => {
564
- // Remote endpoints are navigable links, never a same-ID card on this board.
565
- if (edge.toBoardId) return null;
566
- // An endpoint hidden behind a collapsed group's chip is drawn to the chip.
567
- const fromNode = visibleNodeFor(edge.from);
568
- const toNode = visibleNodeFor(edge.to);
569
- const anchors = ports.get(edge.id);
570
- if (!fromNode || !toNode || !anchors) return null;
571
- const isConnected = hasFocus && (edge.from === focusId || edge.to === focusId);
572
- const searchDimmed = hasSearch && !(searchSet.has(edge.from) || searchSet.has(edge.to));
573
- const highlight = relationHighlight.value;
574
- return (
575
- <EdgePath
576
- key={edge.id}
577
- edge={edge}
578
- fromNode={fromNode}
579
- toNode={toNode}
580
- start={anchors.start}
581
- end={anchors.end}
582
- focused={isConnected}
583
- dimmed={(highlight !== null && !highlight.links.has(edge.id)) || searchDimmed}
584
- selected={selectedEdgeId.value === edge.id}
585
- scale={scale}
586
- part={part}
587
- />
588
- );
589
- })}
590
- {/* Live preview edge while drag-connecting */}
591
- {part === 'lines' &&
592
- draggingEdge.value &&
593
- (() => {
594
- const de = draggingEdge.value;
595
- // Route the preview EXACTLY like the committed edge will route
596
- // (side-tangent anchors against a 1px rect at the cursor) — the old
597
- // collinear preview made the line visibly JUMP on release.
598
- const fromNode = visibleNodeFor(de.fromId);
599
- const cursorRect = {
600
- position: { x: de.cursorX, y: de.cursorY },
601
- size: { width: 1, height: 1 },
602
- } as CanvasNodeState;
603
- let previewD: string;
604
- if (fromNode) {
605
- const start = computeAnchor(fromNode, cursorRect);
606
- const end = computeAnchor(cursorRect, fromNode);
607
- const pdx = end.x - start.x;
608
- const pdy = end.y - start.y;
609
- const pdist = Math.sqrt(pdx * pdx + pdy * pdy);
610
- const curvature = Math.min(Math.max(pdist * 0.35, 32), 160);
611
- const cp1 = sideControlPoint(start, curvature);
612
- const cp2 = sideControlPoint(end, curvature);
613
- previewD = `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
614
- } else {
615
- const dx = de.cursorX - de.fromX;
616
- const dy = de.cursorY - de.fromY;
617
- const dist = Math.sqrt(dx * dx + dy * dy);
618
- const curve = Math.min(dist * 0.25, 80);
619
- const nx = dx / (dist || 1);
620
- const ny = dy / (dist || 1);
621
- previewD = `M ${de.fromX} ${de.fromY} C ${de.fromX + nx * curve} ${de.fromY + ny * curve}, ${de.cursorX - nx * curve} ${de.cursorY - ny * curve}, ${de.cursorX} ${de.cursorY}`;
622
- }
623
- return (
624
- <g>
625
- <path
626
- d={previewD}
627
- fill="none"
628
- stroke="var(--c-accent)"
629
- stroke-width={6 * scale}
630
- opacity="0.1"
631
- style={{ filter: 'blur(3px)' }}
632
- />
633
- <path
634
- d={previewD}
635
- fill="none"
636
- stroke="var(--c-accent)"
637
- stroke-width={2 * scale}
638
- stroke-dasharray={`${6 * scale} ${4 * scale}`}
639
- opacity="0.8"
640
- />
641
- <circle cx={de.cursorX} cy={de.cursorY} r={5 * scale} fill="var(--c-accent)" opacity="0.5" />
642
- </g>
643
- );
644
- })()}
645
- </svg>
744
+ <marker
745
+ id="edge-arrow"
746
+ viewBox="0 0 10 10"
747
+ refX="9"
748
+ refY="5"
749
+ markerUnits="userSpaceOnUse"
750
+ markerWidth={8 * Math.min(scale, 2.2)}
751
+ markerHeight={8 * Math.min(scale, 2.2)}
752
+ orient="auto-start-reverse"
753
+ >
754
+ <path d="M 0 1 L 10 5 L 0 9 z" fill="context-stroke" opacity="0.75" />
755
+ </marker>
756
+ </defs>
757
+ {edgeList.map((edge) => {
758
+ // Remote endpoints are navigable links, never a same-ID card on this board.
759
+ if (edge.toBoardId) return null;
760
+ // An endpoint hidden behind a collapsed group's chip is drawn to the chip.
761
+ const fromNode = visibleNodeFor(edge.from);
762
+ const toNode = visibleNodeFor(edge.to);
763
+ const layout = layouts.get(edge.id);
764
+ if (!fromNode || !toNode || !layout) return null;
765
+ const isConnected = hasFocus && (edge.from === focusId || edge.to === focusId);
766
+ const searchDimmed = hasSearch && !(searchSet.has(edge.from) || searchSet.has(edge.to));
767
+ const highlight = relationHighlight.value;
768
+ return (
769
+ <EdgePath
770
+ key={edge.id}
771
+ edge={edge}
772
+ fromNode={fromNode}
773
+ toNode={toNode}
774
+ layout={layout}
775
+ focused={isConnected}
776
+ dimmed={(highlight !== null && !highlight.links.has(edge.id)) || searchDimmed}
777
+ selected={selectedEdgeId.value === edge.id}
778
+ scale={scale}
779
+ part={part}
780
+ />
781
+ );
782
+ })}
783
+ {/* Live preview edge while drag-connecting */}
784
+ {part === 'lines' &&
785
+ draggingEdge.value &&
786
+ (() => {
787
+ const de = draggingEdge.value;
788
+ // Route the preview EXACTLY like the committed edge will route
789
+ // (side-tangent anchors against a 1px rect at the cursor) — the old
790
+ // collinear preview made the line visibly JUMP on release.
791
+ const fromNode = visibleNodeFor(de.fromId);
792
+ const cursorRect = {
793
+ position: { x: de.cursorX, y: de.cursorY },
794
+ size: { width: 1, height: 1 },
795
+ } as CanvasNodeState;
796
+ let previewD: string;
797
+ if (fromNode) {
798
+ const start = computeAnchor(fromNode, cursorRect);
799
+ const end = computeAnchor(cursorRect, fromNode);
800
+ const pdx = end.x - start.x;
801
+ const pdy = end.y - start.y;
802
+ const pdist = Math.sqrt(pdx * pdx + pdy * pdy);
803
+ const curvature = Math.min(Math.max(pdist * 0.35, 32), 160);
804
+ const cp1 = sideControlPoint(start, curvature);
805
+ const cp2 = sideControlPoint(end, curvature);
806
+ previewD = `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
807
+ } else {
808
+ const dx = de.cursorX - de.fromX;
809
+ const dy = de.cursorY - de.fromY;
810
+ const dist = Math.sqrt(dx * dx + dy * dy);
811
+ const curve = Math.min(dist * 0.25, 80);
812
+ const nx = dx / (dist || 1);
813
+ const ny = dy / (dist || 1);
814
+ previewD = `M ${de.fromX} ${de.fromY} C ${de.fromX + nx * curve} ${de.fromY + ny * curve}, ${de.cursorX - nx * curve} ${de.cursorY - ny * curve}, ${de.cursorX} ${de.cursorY}`;
815
+ }
816
+ return (
817
+ <g>
818
+ <path
819
+ d={previewD}
820
+ fill="none"
821
+ stroke="var(--c-accent)"
822
+ stroke-width={6 * scale}
823
+ opacity="0.1"
824
+ style={{ filter: 'blur(3px)' }}
825
+ />
826
+ <path
827
+ d={previewD}
828
+ fill="none"
829
+ stroke="var(--c-accent)"
830
+ stroke-width={2 * scale}
831
+ stroke-dasharray={`${6 * scale} ${4 * scale}`}
832
+ opacity="0.8"
833
+ />
834
+ <circle cx={de.cursorX} cy={de.cursorY} r={5 * scale} fill="var(--c-accent)" opacity="0.5" />
835
+ </g>
836
+ );
837
+ })()}
838
+ </svg>
839
+ ),
840
+ )}
841
+ </>
646
842
  );
647
843
  }
@@ -242,8 +242,7 @@ export function ExpandedNodeOverlay() {
242
242
  };
243
243
  }, [handlePresentationKeyDown, presentationExitToken, presenting]);
244
244
 
245
- // Focus trap (item 18): restore to the node this overlay opened from — its
246
- // world element is unmounted while expanded and remounted on close.
245
+ // Restore to the same spatial card at every zoom.
247
246
  const panelRef = useRef<HTMLDivElement>(null);
248
247
  const restoreToNode = useCallback(
249
248
  () => (nodeId ? document.querySelector<HTMLElement>(`.canvas-node[data-node-id="${CSS.escape(nodeId)}"]`) : null),