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.
- package/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +9418 -2016
- package/CHANGELOG.md +54 -1
- package/dist/canvas/chunks/abnfDiagram-N423BO3Z-p8dtp6va.js +1 -0
- package/dist/canvas/chunks/architecture-TIHT7OUA-9tffvpsv.js +1 -0
- package/dist/canvas/chunks/architectureDiagram-T3A2C74G-5jhmxrzf.js +36 -0
- package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-pw1fa9yb.js +132 -0
- package/dist/canvas/chunks/c4Diagram-5PPSVZJV-x07nq0tt.js +10 -0
- package/dist/canvas/chunks/classDiagram-JCYQIIEL-nc6g5x1r.js +1 -0
- package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-0qnbpwvh.js +1 -0
- package/dist/canvas/chunks/cose-bilkent-JH36ORCC-nm39npq0.js +1 -0
- package/dist/canvas/chunks/cynefin-VYW2F7L2-ys8p46ts.js +1 -0
- package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-vdxrwahz.js +62 -0
- package/dist/canvas/chunks/dagre-VZM6K2ZE-bqdm7wpd.js +4 -0
- package/dist/canvas/chunks/diagram-7IWD3JNH-gs5gpr93.js +30 -0
- package/dist/canvas/chunks/diagram-B4RE2ZJO-cgwjg47s.js +3 -0
- package/dist/canvas/chunks/diagram-LBJQPF4R-w3vahpav.js +24 -0
- package/dist/canvas/chunks/diagram-Q27KOJAE-42837z6h.js +24 -0
- package/dist/canvas/chunks/diagram-UB23O5K3-4fz83q59.js +41 -0
- package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-zphtgegj.js +1 -0
- package/dist/canvas/chunks/erDiagram-JOGREHBK-zvbbdxp4.js +85 -0
- package/dist/canvas/chunks/eventmodeling-45OFAUF4-v9q6dn4c.js +1 -0
- package/dist/canvas/chunks/flowDiagram-UKHOOZJN-yrr5z8tc.js +1 -0
- package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-282jzycm.js +292 -0
- package/dist/canvas/chunks/gitGraph-TEB2WS4Q-kdwb8m0a.js +1 -0
- package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-vqqy6p1b.js +106 -0
- package/dist/canvas/chunks/index-0rk4gp1g.js +2 -0
- package/dist/canvas/chunks/index-194m3hc3.js +2 -0
- package/dist/canvas/chunks/index-1r72hdzb.js +301 -0
- package/dist/canvas/chunks/index-353m7b0p.js +2 -0
- package/dist/canvas/chunks/index-3cy1bzwh.js +2 -0
- package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
- package/dist/canvas/chunks/index-6ceya556.js +332 -0
- package/dist/canvas/chunks/index-6qmsnxwy.js +4 -0
- package/dist/canvas/chunks/index-d3b3gqe9.js +2 -0
- package/dist/canvas/chunks/index-fjtdggjn.js +2 -0
- package/dist/canvas/chunks/index-g3pwnqeb.js +2 -0
- package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
- package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
- package/dist/canvas/chunks/index-nprm96xp.js +16 -0
- package/dist/canvas/chunks/index-qj8fxwsf.js +2 -0
- package/dist/canvas/chunks/index-r2fyc9w1.js +89 -0
- package/dist/canvas/chunks/index-rft2hcz5.js +2 -0
- package/dist/canvas/chunks/index-rh7nv68n.js +2 -0
- package/dist/canvas/chunks/index-s4j678gj.js +2 -0
- package/dist/canvas/chunks/index-tb1wf525.js +179 -0
- package/dist/canvas/chunks/index-vv56h019.js +2 -0
- package/dist/canvas/chunks/index-w2mra17t.js +207 -0
- package/dist/canvas/chunks/index-wgv7en21.js +232 -0
- package/dist/canvas/chunks/index-yw8h9vha.js +157 -0
- package/dist/canvas/chunks/info-DKCQHKI2-wpzs6sz3.js +1 -0
- package/dist/canvas/chunks/infoDiagram-6WML65LV-v1yvggqj.js +2 -0
- package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-5hnq1a9p.js +70 -0
- package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-zjcffveh.js +139 -0
- package/dist/canvas/chunks/kanban-definition-27J2QSJJ-dakqtfp1.js +89 -0
- package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
- package/dist/canvas/chunks/mermaid.core-j2geswrd.js +12 -0
- package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-142xhr3k.js +96 -0
- package/dist/canvas/chunks/packet-7NZHBO7P-xn439f0s.js +1 -0
- package/dist/canvas/chunks/pegDiagram-VL7TDLO6-5wvk7vsd.js +1 -0
- package/dist/canvas/chunks/pie-RZYD4A2V-0kf16rwh.js +1 -0
- package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-ztdhwycc.js +39 -0
- package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-c5p9gmg6.js +7 -0
- package/dist/canvas/chunks/radar-I7S5WNFK-0g2pz36t.js +1 -0
- package/dist/canvas/chunks/railroad-3IZDKUUU-fcaran6j.js +1 -0
- package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-wgdya41q.js +1 -0
- package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-gy07a4ka.js +1 -0
- package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-ewk6wtkh.js +1 -0
- package/dist/canvas/chunks/railroadDiagram-AXF67PYL-xkmqkjwf.js +1 -0
- package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-9wm2dyaw.js +84 -0
- package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-2w19etgb.js +40 -0
- package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-0jn0vwmz.js +162 -0
- package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-x75epwdq.js +1 -0
- package/dist/canvas/chunks/stateDiagram-OKZ733FA-schjpy6d.js +1 -0
- package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-k8sfp2e7.js +1 -0
- package/dist/canvas/chunks/swimlanes-SLNWSIFB-gkw4xce3.js +1 -0
- package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-nqysw67j.js +8 -0
- package/dist/canvas/chunks/timeline-definition-Z64GVDOM-x7nkcy01.js +120 -0
- package/dist/canvas/chunks/treeView-QDETBFTQ-cesw6gp0.js +1 -0
- package/dist/canvas/chunks/treemap-6X3UGDF4-zdv4knp9.js +1 -0
- package/dist/canvas/chunks/vennDiagram-T6HMQDX7-x8dqn9yf.js +34 -0
- package/dist/canvas/chunks/wardley-OPB4EBWU-44pdddez.js +1 -0
- package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-t1j52t9r.js +78 -0
- package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-38t16pme.js +7 -0
- package/dist/canvas/global.css +389 -229
- package/dist/canvas/index.js +141 -211
- package/dist/canvas/mcp-app.html +4205 -554
- package/dist/canvas/mermaid-entry.js +338 -338
- package/dist/canvas/surface-theme.css +6 -6
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +49 -49
- package/dist/types/client/canvas/BarHint.d.ts +5 -3
- package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
- package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
- package/dist/types/client/canvas/auto-fit.d.ts +4 -5
- package/dist/types/client/mermaid-entry.d.ts +1 -2
- package/dist/types/client/mermaid-render.d.ts +1 -0
- package/dist/types/client/nodes/BoardNode.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
- package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
- package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
- package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
- package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
- package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
- package/dist/types/client/state/canvas-store.d.ts +3 -1
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/presentation.d.ts +17 -6
- package/dist/types/json-render/server.d.ts +2 -1
- package/dist/types/mcp/canvas-access.d.ts +1 -12
- package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
- package/dist/types/mcp/server.d.ts +2 -0
- package/dist/types/server/ax-wait.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +2 -0
- package/dist/types/server/index.d.ts +11 -6
- package/dist/types/server/operations/http.d.ts +10 -0
- package/dist/types/server/operations/index.d.ts +2 -2
- package/dist/types/server/operations/invoker.d.ts +4 -3
- package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
- package/dist/types/server/operations/mcp.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +1 -0
- package/dist/types/server/operations/ops/boards.d.ts +0 -1
- package/dist/types/server/operations/registry.d.ts +2 -2
- package/dist/types/server/operations/types.d.ts +12 -2
- package/dist/types/server/server.d.ts +2 -0
- package/dist/types/server/tour-control.d.ts +13 -3
- package/dist/types/server/webpage-node.d.ts +1 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +0 -9
- package/dist/types/shared/tour.d.ts +8 -0
- package/docs/api-reference.md +2 -2
- package/docs/cli.md +1 -1
- package/docs/design/Chrome.dc.html +32 -6
- package/docs/design/Types1.dc.html +3 -2
- package/docs/design/Types2.dc.html +1 -1
- package/docs/design/Types3.dc.html +1 -1
- package/docs/design/Zoom.dc.html +37 -60
- package/docs/design/canvas.json +1 -1
- package/docs/design.md +169 -4
- package/docs/http-api.md +82 -1
- package/docs/mcp.md +101 -21
- package/docs/node-types.md +38 -3
- package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
- package/docs/plans/plan-019-post-0.9-roadmap.md +402 -13
- package/docs/product-vision-2026-09.md +9 -0
- package/docs/sdk.md +5 -2
- package/package.json +2 -2
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +9418 -2016
- package/skills/pmx-canvas/SKILL.md +19 -21
- package/skills/pmx-canvas/references/full-reference.md +44 -6
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +3 -5
- package/src/client/canvas/BarHint.tsx +6 -3
- package/src/client/canvas/CanvasNode.tsx +34 -47
- package/src/client/canvas/CanvasViewport.tsx +6 -7
- package/src/client/canvas/ContextMenu.tsx +63 -3
- package/src/client/canvas/EdgeLayer.tsx +365 -169
- package/src/client/canvas/ExpandedNodeOverlay.tsx +1 -2
- package/src/client/canvas/Presentation.tsx +53 -3
- package/src/client/canvas/ToolRail.tsx +2 -0
- package/src/client/canvas/TopBar.tsx +24 -9
- package/src/client/canvas/auto-fit.ts +9 -11
- package/src/client/canvas/canvas-area.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +6 -1
- package/src/client/mermaid-entry.ts +6 -20
- package/src/client/mermaid-render.ts +39 -0
- package/src/client/nodes/AxStepControls.tsx +45 -69
- package/src/client/nodes/BoardNode.tsx +65 -9
- package/src/client/nodes/ContextNode.tsx +88 -396
- package/src/client/nodes/ExtAppFrame.tsx +283 -1462
- package/src/client/nodes/FileNode.tsx +92 -81
- package/src/client/nodes/HtmlNode.tsx +8 -36
- package/src/client/nodes/LedgerNode.tsx +31 -17
- package/src/client/nodes/MarkdownNode.tsx +8 -17
- package/src/client/nodes/McpAppNode.tsx +48 -154
- package/src/client/nodes/MermaidNode.tsx +80 -69
- package/src/client/nodes/PromptNode.tsx +12 -14
- package/src/client/nodes/ResponseNode.tsx +8 -6
- package/src/client/nodes/StatusNode.tsx +22 -7
- package/src/client/nodes/StatusSummary.tsx +5 -3
- package/src/client/nodes/TraceNode.tsx +8 -8
- package/src/client/nodes/ViewerFrame.tsx +262 -0
- package/src/client/nodes/ViewerHost.tsx +41 -0
- package/src/client/nodes/WebpageNode.tsx +22 -24
- package/src/client/nodes/iframe-document-url.ts +20 -27
- package/src/client/nodes/primitive-copy.ts +97 -0
- package/src/client/nodes/use-iframe-content-height.ts +34 -10
- package/src/client/nodes/use-surface-frame.ts +22 -11
- package/src/client/nodes/viewer-runtime-state.ts +41 -35
- package/src/client/state/canvas-store.ts +18 -12
- package/src/client/state/intent-bridge.ts +1 -0
- package/src/client/state/presentation.ts +135 -15
- package/src/client/state/sse-bridge.ts +18 -19
- package/src/client/theme/global.css +389 -229
- package/src/client/theme/surface-theme.css +6 -6
- package/src/json-render/charts/tufte-components.tsx +44 -2
- package/src/json-render/renderer/index.css +62 -2
- package/src/json-render/renderer/index.tsx +31 -7
- package/src/json-render/server.ts +3 -2
- package/src/mcp/canvas-access.ts +6 -47
- package/src/mcp/resource-subscriptions.ts +181 -0
- package/src/mcp/server.ts +185 -219
- package/src/server/ax-wait.ts +21 -6
- package/src/server/board-export.ts +35 -24
- package/src/server/canvas-geometry.ts +1 -0
- package/src/server/canvas-operations.ts +6 -2
- package/src/server/canvas-state.ts +5 -0
- package/src/server/demo-state.json +106 -100
- package/src/server/html-primitives.ts +3 -9
- package/src/server/html-surface.ts +8 -0
- package/src/server/index.ts +30 -12
- package/src/server/operations/http.ts +69 -21
- package/src/server/operations/index.ts +8 -1
- package/src/server/operations/invoker.ts +53 -5
- package/src/server/operations/mcp-contracts.ts +354 -0
- package/src/server/operations/mcp.ts +153 -18
- package/src/server/operations/ops/ax-await.ts +18 -10
- package/src/server/operations/ops/ax-read.ts +2 -0
- package/src/server/operations/ops/ax-timeline.ts +2 -2
- package/src/server/operations/ops/batch.ts +27 -8
- package/src/server/operations/ops/boards.ts +1 -3
- package/src/server/operations/ops/nodes.ts +18 -3
- package/src/server/operations/ops/viewport.ts +11 -9
- package/src/server/operations/registry.ts +24 -4
- package/src/server/operations/types.ts +16 -2
- package/src/server/server.ts +17 -59
- package/src/server/tour-control.ts +66 -6
- package/src/server/webpage-node.ts +9 -4
- package/src/shared/ax-surface-protocol.ts +0 -11
- package/src/shared/content-height-reporter.ts +4 -3
- 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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
342
|
+
const labelWidth = showLabel ? textWidth(label) : 0;
|
|
291
343
|
const notSeenText = `not seen by ${agentName()}`;
|
|
292
|
-
const wordedNotSeenWidth = 12 + notSeenText
|
|
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
|
|
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
|
|
469
|
-
const
|
|
470
|
-
const
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
//
|
|
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),
|