pmx-canvas 0.8.2 → 0.9.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 +601 -22
- package/CHANGELOG.md +49 -0
- package/Readme.md +131 -532
- package/dist/canvas/global.css +2724 -476
- package/dist/canvas/index.js +168 -107
- package/dist/canvas/mcp-app.html +2899 -596
- package/dist/json-render/index.js +20 -20
- package/dist/types/client/canvas/BarHint.d.ts +12 -2
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
- package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/canvas/CommandBar.d.ts +3 -4
- package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
- package/dist/types/client/canvas/ContextChip.d.ts +3 -0
- package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
- package/dist/types/client/canvas/EmptyState.d.ts +4 -4
- package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
- package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
- package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -7
- package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
- package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
- package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
- package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
- package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
- package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
- package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
- package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
- package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
- package/dist/types/client/canvas/canvas-area.d.ts +1 -1
- package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
- package/dist/types/client/canvas/graph-model.d.ts +37 -0
- package/dist/types/client/canvas/memory-layout.d.ts +23 -0
- package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
- package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
- package/dist/types/client/icons.d.ts +0 -10
- package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
- package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
- package/dist/types/client/state/boards-store.d.ts +81 -9
- package/dist/types/client/state/canvas-store.d.ts +14 -1
- package/dist/types/client/state/context-chip-store.d.ts +22 -0
- package/dist/types/client/state/context-status-store.d.ts +35 -0
- package/dist/types/client/state/intent-bridge.d.ts +11 -8
- package/dist/types/client/state/knowledge-store.d.ts +22 -0
- package/dist/types/client/state/near-pin-store.d.ts +44 -1
- package/dist/types/client/state/presence-store.d.ts +4 -4
- package/dist/types/client/state/presentation.d.ts +28 -0
- package/dist/types/client/state/relation-store.d.ts +10 -0
- package/dist/types/client/state/session-store.d.ts +41 -2
- package/dist/types/client/state/workspace-settings.d.ts +7 -0
- package/dist/types/client/types.d.ts +13 -1
- package/dist/types/client/utils/keyboard.d.ts +2 -0
- package/dist/types/client/utils/time-ago.d.ts +4 -0
- package/dist/types/mcp/context-uri-template.d.ts +3 -1
- package/dist/types/server/agent-presence.d.ts +10 -3
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +30 -7
- package/dist/types/server/canvas-operations.d.ts +24 -2
- package/dist/types/server/canvas-provenance.d.ts +22 -2
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +93 -15
- package/dist/types/server/canvas-validation.d.ts +1 -1
- package/dist/types/server/context-brief.d.ts +94 -6
- package/dist/types/server/context-reads.d.ts +33 -7
- package/dist/types/server/edit-summary.d.ts +20 -0
- package/dist/types/server/index.d.ts +71 -10
- package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
- package/dist/types/server/lifecycle-status.d.ts +10 -0
- package/dist/types/server/memory-graph.d.ts +9 -0
- package/dist/types/server/mutation-history.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
- package/dist/types/server/operations/ops/boards.d.ts +2 -1
- package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
- package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +5 -0
- package/dist/types/server/operations/ops/query.d.ts +23 -0
- package/dist/types/server/operations/registry.d.ts +1 -0
- package/dist/types/server/placement.d.ts +7 -0
- package/dist/types/server/scope-fence.d.ts +2 -1
- package/dist/types/server/spatial-analysis.d.ts +6 -3
- package/dist/types/server/tour-control.d.ts +32 -0
- package/dist/types/server/wiki-links.d.ts +14 -0
- package/dist/types/server/workbench-auth.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +2 -0
- package/dist/types/shared/agent-presence.d.ts +15 -12
- package/dist/types/shared/auto-arrange.d.ts +1 -0
- package/dist/types/shared/boards.d.ts +4 -4
- package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
- package/dist/types/shared/knowledge-presets.d.ts +6 -0
- package/dist/types/shared/lifecycle-status.d.ts +24 -0
- package/dist/types/shared/memory-graph.d.ts +53 -0
- package/dist/types/shared/relations.d.ts +68 -0
- package/dist/types/shared/semantic-attention.d.ts +2 -0
- package/dist/types/shared/tour.d.ts +38 -0
- package/dist/types/shared/workspace-settings.d.ts +11 -0
- package/docs/RELEASE.md +21 -3
- package/docs/adr-001-bun-only-runtime.md +7 -1
- package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
- package/docs/api-reference.md +109 -0
- package/docs/ax-host-adapter-contract.md +1 -1
- package/docs/ax-state-contract.md +5 -4
- package/docs/cli.md +153 -15
- package/docs/demos/pmx-canvas-demo.mp4 +0 -0
- package/docs/design/AgentContext.dc.html +3 -3
- package/docs/design/BoardPins.dc.html +37 -22
- package/docs/design/Categories.dc.html +208 -0
- package/docs/design/CategoryEditor.dc.html +33 -0
- package/docs/design/Chrome.dc.html +52 -9
- package/docs/design/ContextChip.dc.html +224 -0
- package/docs/design/ContextChip600.dc.html +136 -0
- package/docs/design/EmptyState.dc.html +45 -0
- package/docs/design/Flows.dc.html +23 -1
- package/docs/design/HeaderMarks.dc.html +183 -0
- package/docs/design/Home.dc.html +19 -1
- package/docs/design/HomeGraph.dc.html +86 -202
- package/docs/design/HomeMap.dc.html +23 -1
- package/docs/design/HomeTeam.dc.html +32 -14
- package/docs/design/Lifecycle.dc.html +22 -0
- package/docs/design/Lifecycle600.dc.html +18 -0
- package/docs/design/LifecycleVariants.dc.html +157 -0
- package/docs/design/LinkAuthorship.dc.html +139 -0
- package/docs/design/LinksChanged.dc.html +203 -0
- package/docs/design/LinksOptions.dc.html +137 -0
- package/docs/design/LinksOptions600.dc.html +134 -0
- package/docs/design/NearPin.dc.html +9 -10
- package/docs/design/Pane600.dc.html +4 -3
- package/docs/design/Relations.dc.html +3 -2
- package/docs/design/Restore.dc.html +152 -0
- package/docs/design/SeeChange.dc.html +267 -0
- package/docs/design/Uses.dc.html +12 -6
- package/docs/design/canvas.json +146 -19
- package/docs/design.md +449 -16
- package/docs/environment.md +0 -1
- package/docs/evals/curation-effect.md +128 -0
- package/docs/http-api.md +459 -37
- package/docs/mcp.md +146 -18
- package/docs/node-types.md +58 -3
- package/docs/plans/design-gaps-2026-10-08.md +53 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
- package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
- package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
- package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
- package/docs/product-context-vision-2026-09.md +17 -1
- package/docs/product-vision-2026-09.md +1082 -43
- package/docs/product-vision-review-2026-09.md +13 -1
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +62 -2
- package/package.json +1 -1
- 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 +601 -22
- package/skills/pmx-canvas/SKILL.md +216 -42
- package/skills/pmx-canvas/references/full-reference.md +120 -21
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
- package/skills/pmx-canvas/references/importing-context.md +132 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
- package/skills/pmx-canvas-testing/SKILL.md +20 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +12 -0
- package/src/cli/commands/boards.ts +87 -11
- package/src/cli/commands/edges.ts +28 -0
- package/src/cli/commands/groups.ts +7 -0
- package/src/cli/commands/nodes.ts +1 -0
- package/src/cli/commands/query.ts +34 -2
- package/src/cli/commands/record.ts +199 -48
- package/src/cli/index.ts +6 -1
- package/src/client/App.tsx +177 -70
- package/src/client/canvas/AttentionHistory.tsx +0 -2
- package/src/client/canvas/BarHint.tsx +40 -2
- package/src/client/canvas/BoardCopyDialog.tsx +1 -1
- package/src/client/canvas/BoardFolderDialog.tsx +7 -7
- package/src/client/canvas/BoardLinkDialog.tsx +138 -33
- package/src/client/canvas/BoardPinButton.tsx +33 -0
- package/src/client/canvas/BoardSwitcher.tsx +26 -40
- package/src/client/canvas/CanvasNode.tsx +360 -237
- package/src/client/canvas/CanvasViewport.tsx +45 -7
- package/src/client/canvas/CommandBar.tsx +29 -23
- package/src/client/canvas/CommandPalette.tsx +5 -12
- package/src/client/canvas/ConnectionBanner.tsx +3 -3
- package/src/client/canvas/ContextChip.tsx +296 -0
- package/src/client/canvas/ContextMenu.tsx +63 -5
- package/src/client/canvas/CrossBoardReferences.tsx +60 -0
- package/src/client/canvas/EdgeLayer.tsx +339 -76
- package/src/client/canvas/EmptyState.tsx +33 -18
- package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
- package/src/client/canvas/ExternalWriters.tsx +57 -42
- package/src/client/canvas/HomeGraphView.tsx +942 -0
- package/src/client/canvas/HomeMapView.tsx +641 -0
- package/src/client/canvas/HomeReviewView.tsx +167 -0
- package/src/client/canvas/HomeView.tsx +687 -160
- package/src/client/canvas/IntentLayer.tsx +19 -12
- package/src/client/canvas/KnowledgeDialog.tsx +488 -0
- package/src/client/canvas/LifecycleChrome.tsx +133 -0
- package/src/client/canvas/LifecycleFields.tsx +91 -0
- package/src/client/canvas/LifecyclePanel.tsx +533 -0
- package/src/client/canvas/MemoryInspector.tsx +104 -0
- package/src/client/canvas/Minimap.tsx +3 -3
- package/src/client/canvas/NearPinLayer.tsx +187 -0
- package/src/client/canvas/NodeContextMark.tsx +175 -97
- package/src/client/canvas/Presentation.tsx +23 -57
- package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
- package/src/client/canvas/RelationsPanel.tsx +82 -0
- package/src/client/canvas/SelectionBar.tsx +3 -6
- package/src/client/canvas/SessionPanel.tsx +90 -50
- package/src/client/canvas/SessionReceipt.tsx +311 -39
- package/src/client/canvas/ShortcutOverlay.tsx +1 -1
- package/src/client/canvas/SnapshotPanel.tsx +27 -36
- package/src/client/canvas/ToolRail.tsx +367 -192
- package/src/client/canvas/TopBar.tsx +354 -124
- package/src/client/canvas/canvas-area.ts +10 -3
- package/src/client/canvas/graph-geometry.ts +195 -0
- package/src/client/canvas/graph-model.ts +254 -0
- package/src/client/canvas/memory-layout.ts +106 -0
- package/src/client/canvas/use-chrome-height.ts +20 -0
- package/src/client/canvas/use-focus-trap.ts +31 -5
- package/src/client/canvas/use-memory-graph.ts +58 -0
- package/src/client/canvas/use-node-drag.ts +2 -1
- package/src/client/canvas/use-pan-zoom.ts +64 -7
- package/src/client/icons.tsx +0 -63
- package/src/client/mcp-app/style.css +0 -6
- package/src/client/nodes/AxStepControls.tsx +74 -50
- package/src/client/nodes/BoardNode.tsx +5 -1
- package/src/client/nodes/ContextNode.tsx +18 -11
- package/src/client/nodes/ExtAppFrame.tsx +0 -1
- package/src/client/nodes/FileNode.tsx +46 -42
- package/src/client/nodes/HtmlNode.tsx +32 -0
- package/src/client/nodes/ImageNode.tsx +1 -1
- package/src/client/nodes/InlineFormatBar.tsx +6 -9
- package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
- package/src/client/nodes/MarkdownNode.tsx +377 -109
- package/src/client/nodes/McpAppNode.tsx +50 -20
- package/src/client/nodes/MdFormatBar.tsx +21 -18
- package/src/client/nodes/PromptNode.tsx +1 -1
- package/src/client/nodes/ResponseNode.tsx +1 -1
- package/src/client/nodes/viewer-runtime-state.ts +54 -22
- package/src/client/state/attention-bridge.ts +64 -15
- package/src/client/state/boards-store.ts +193 -39
- package/src/client/state/canvas-store.ts +99 -12
- package/src/client/state/context-chip-store.ts +70 -0
- package/src/client/state/context-status-store.ts +53 -0
- package/src/client/state/intent-bridge.ts +51 -30
- package/src/client/state/knowledge-store.ts +43 -0
- package/src/client/state/near-pin-store.ts +115 -6
- package/src/client/state/presence-store.ts +7 -10
- package/src/client/state/presentation.ts +91 -0
- package/src/client/state/relation-store.ts +81 -0
- package/src/client/state/session-store.ts +144 -18
- package/src/client/state/sse-bridge.ts +206 -29
- package/src/client/state/workspace-settings.ts +11 -0
- package/src/client/theme/global.css +2724 -476
- package/src/client/types.ts +12 -0
- package/src/client/utils/keyboard.ts +10 -0
- package/src/client/utils/time-ago.ts +14 -0
- package/src/json-render/renderer/index.tsx +31 -0
- package/src/mcp/canvas-app.ts +12 -2
- package/src/mcp/context-uri-template.ts +7 -4
- package/src/mcp/server.ts +63 -41
- package/src/mcp/workbench-app.ts +39 -6
- package/src/server/agent-presence.ts +128 -28
- package/src/server/ax-context.ts +27 -13
- package/src/server/board-export.ts +12 -1
- package/src/server/canvas-db.ts +331 -42
- package/src/server/canvas-operations.ts +195 -83
- package/src/server/canvas-provenance.ts +53 -22
- package/src/server/canvas-serialization.ts +2 -0
- package/src/server/canvas-state.ts +950 -122
- package/src/server/canvas-validation.ts +9 -2
- package/src/server/context-brief.ts +345 -38
- package/src/server/context-reads.ts +178 -30
- package/src/server/demo-state.json +355 -24
- package/src/server/edit-summary.ts +74 -0
- package/src/server/html-surface.ts +8 -1
- package/src/server/index.ts +181 -11
- package/src/server/knowledge-lifecycle.ts +304 -0
- package/src/server/lifecycle-status.ts +232 -0
- package/src/server/memory-graph.ts +166 -0
- package/src/server/mutation-history.ts +18 -6
- package/src/server/operations/composites.ts +32 -11
- package/src/server/operations/http.ts +7 -2
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/mcp.ts +26 -1
- package/src/server/operations/ops/ax-presence.ts +1 -1
- package/src/server/operations/ops/ax-read.ts +57 -6
- package/src/server/operations/ops/ax-timeline.ts +28 -2
- package/src/server/operations/ops/backup.ts +5 -3
- package/src/server/operations/ops/batch.ts +35 -4
- package/src/server/operations/ops/boards.ts +188 -47
- package/src/server/operations/ops/canvas-wire.ts +31 -1
- package/src/server/operations/ops/edges.ts +36 -2
- package/src/server/operations/ops/groups.ts +6 -1
- package/src/server/operations/ops/lifecycle.ts +262 -0
- package/src/server/operations/ops/memory-graph.ts +45 -0
- package/src/server/operations/ops/nodes.ts +107 -24
- package/src/server/operations/ops/query.ts +281 -68
- package/src/server/operations/ops/snapshots.ts +73 -1
- package/src/server/operations/ops/validate.ts +5 -1
- package/src/server/operations/ops/viewport.ts +97 -3
- package/src/server/operations/registry.ts +74 -25
- package/src/server/placement.ts +29 -0
- package/src/server/scope-fence.ts +78 -6
- package/src/server/server.ts +115 -16
- package/src/server/spatial-analysis.ts +48 -23
- package/src/server/tour-control.ts +82 -0
- package/src/server/wiki-links.ts +137 -0
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/agent-context.ts +3 -0
- package/src/shared/agent-presence.ts +15 -15
- package/src/shared/auto-arrange.ts +2 -0
- package/src/shared/boards.ts +4 -4
- package/src/shared/knowledge-lifecycle.ts +22 -0
- package/src/shared/knowledge-presets.ts +80 -0
- package/src/shared/lifecycle-status.ts +26 -0
- package/src/shared/memory-graph.ts +74 -0
- package/src/shared/relations.ts +98 -0
- package/src/shared/semantic-attention.ts +15 -1
- package/src/shared/tour.ts +23 -5
- package/src/shared/workspace-settings.ts +13 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
- package/dist/types/client/canvas/kind-colors.d.ts +0 -3
- package/dist/types/server/agent-context.d.ts +0 -22
- package/src/client/canvas/ContextPinBar.tsx +0 -25
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { Point } from './memory-layout';
|
|
2
|
+
|
|
3
|
+
/** Painted bounds, collision clearance, ports and Fit share this footprint. */
|
|
4
|
+
export const GRAPH_NODE = { width: 220, height: 96, gap: 64 };
|
|
5
|
+
interface Rect {
|
|
6
|
+
left: number;
|
|
7
|
+
top: number;
|
|
8
|
+
right: number;
|
|
9
|
+
bottom: number;
|
|
10
|
+
}
|
|
11
|
+
export interface GraphRoute {
|
|
12
|
+
path: string;
|
|
13
|
+
points: Point[];
|
|
14
|
+
label?: Point & Rect;
|
|
15
|
+
}
|
|
16
|
+
interface Link {
|
|
17
|
+
id: string;
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
23
|
+
const inside = (point: Point, r: Rect) =>
|
|
24
|
+
point.x > r.left && point.x < r.right && point.y > r.top && point.y < r.bottom;
|
|
25
|
+
|
|
26
|
+
/** Open-interior intersection: following a rectangle boundary is permitted. */
|
|
27
|
+
function crosses(a: Point, b: Point, r: Rect) {
|
|
28
|
+
let low = 0,
|
|
29
|
+
high = 1;
|
|
30
|
+
for (const [start, delta, min, max] of [
|
|
31
|
+
[a.x, b.x - a.x, r.left, r.right],
|
|
32
|
+
[a.y, b.y - a.y, r.top, r.bottom],
|
|
33
|
+
]) {
|
|
34
|
+
if (!delta) {
|
|
35
|
+
if (start <= min || start >= max) return false;
|
|
36
|
+
} else {
|
|
37
|
+
const p = (min - start) / delta,
|
|
38
|
+
q = (max - start) / delta;
|
|
39
|
+
low = Math.max(low, Math.min(p, q));
|
|
40
|
+
high = Math.min(high, Math.max(p, q));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return low < high && high > 0 && low < 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Obstacle visibility is shared across routes and independent of zoom/hover. */
|
|
47
|
+
export function graphRoutes(positions: Map<string, Point>, links: Link[]) {
|
|
48
|
+
// Keep route clearance inside the larger node-spacing gap.
|
|
49
|
+
const clearance = 8;
|
|
50
|
+
const boxes = [...positions].map(([key, p]) => ({
|
|
51
|
+
key,
|
|
52
|
+
left: p.x - GRAPH_NODE.width / 2 - clearance,
|
|
53
|
+
right: p.x + GRAPH_NODE.width / 2 + clearance,
|
|
54
|
+
top: p.y - GRAPH_NODE.height / 2 - clearance,
|
|
55
|
+
bottom: p.y + GRAPH_NODE.height / 2 + clearance,
|
|
56
|
+
}));
|
|
57
|
+
const clear = (a: Point, b: Point) => !boxes.some((r) => crosses(a, b, r));
|
|
58
|
+
const corners = boxes
|
|
59
|
+
.flatMap((r) => [
|
|
60
|
+
{ x: r.left, y: r.top },
|
|
61
|
+
{ x: r.right, y: r.top },
|
|
62
|
+
{ x: r.right, y: r.bottom },
|
|
63
|
+
{ x: r.left, y: r.bottom },
|
|
64
|
+
])
|
|
65
|
+
.filter((p) => !boxes.some((r) => inside(p, r)));
|
|
66
|
+
const neighbors = new Map<number, Array<{ index: number; length: number }>>();
|
|
67
|
+
const ports = (p: Point, fraction: number) =>
|
|
68
|
+
[
|
|
69
|
+
{ x: p.x - GRAPH_NODE.width / 2, y: p.y + fraction * GRAPH_NODE.height },
|
|
70
|
+
{ x: p.x + GRAPH_NODE.width / 2, y: p.y + fraction * GRAPH_NODE.height },
|
|
71
|
+
{ x: p.x + fraction * GRAPH_NODE.width, y: p.y - GRAPH_NODE.height / 2 },
|
|
72
|
+
{ x: p.x + fraction * GRAPH_NODE.width, y: p.y + GRAPH_NODE.height / 2 },
|
|
73
|
+
].map((port, index) => ({
|
|
74
|
+
port,
|
|
75
|
+
stub: {
|
|
76
|
+
x: port.x + (index === 0 ? -clearance : index === 1 ? clearance : 0),
|
|
77
|
+
y: port.y + (index === 2 ? -clearance : index === 3 ? clearance : 0),
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
const result = new Map<string, GraphRoute>();
|
|
81
|
+
const labels: Rect[] = [];
|
|
82
|
+
const lanes = new Map<string, number>();
|
|
83
|
+
const pairs = new Map<string, number>();
|
|
84
|
+
for (const link of links) {
|
|
85
|
+
const pair = JSON.stringify([link.from, link.to].sort());
|
|
86
|
+
pairs.set(pair, (pairs.get(pair) ?? 0) + 1);
|
|
87
|
+
}
|
|
88
|
+
for (const link of links) {
|
|
89
|
+
const a = positions.get(link.from)!,
|
|
90
|
+
b = positions.get(link.to)!;
|
|
91
|
+
const pair = JSON.stringify([link.from, link.to].sort());
|
|
92
|
+
const lane = lanes.get(pair) ?? 0;
|
|
93
|
+
lanes.set(pair, lane + 1);
|
|
94
|
+
// Distinct boundary ports keep every reciprocal/parallel fact selectable.
|
|
95
|
+
const fraction = ((lane + 1) / (pairs.get(pair)! + 1) - 0.5) * 0.8;
|
|
96
|
+
const starts = ports(a, fraction),
|
|
97
|
+
ends = ports(b, fraction);
|
|
98
|
+
let points: Point[] | undefined;
|
|
99
|
+
let best = Infinity;
|
|
100
|
+
for (const s of starts)
|
|
101
|
+
for (const t of ends) {
|
|
102
|
+
const length = distance(s.stub, t.stub);
|
|
103
|
+
if (length && length < best && clear(s.stub, t.stub)) {
|
|
104
|
+
best = length;
|
|
105
|
+
points = [s.port, s.stub, t.stub, t.port];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!points) {
|
|
109
|
+
const distances = corners.map(() => Infinity),
|
|
110
|
+
previous = corners.map(() => -1);
|
|
111
|
+
const origins = corners.map(() => 0),
|
|
112
|
+
visited = new Set<number>();
|
|
113
|
+
for (let i = 0; i < corners.length; i++)
|
|
114
|
+
for (let s = 0; s < starts.length; s++) {
|
|
115
|
+
const length = distance(starts[s].stub, corners[i]);
|
|
116
|
+
if (length < distances[i] && clear(starts[s].stub, corners[i])) {
|
|
117
|
+
distances[i] = length;
|
|
118
|
+
origins[i] = s;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
let finish = -1,
|
|
122
|
+
endPort = 0;
|
|
123
|
+
while (visited.size < corners.length) {
|
|
124
|
+
let current = -1;
|
|
125
|
+
for (let i = 0; i < corners.length; i++)
|
|
126
|
+
if (!visited.has(i) && (current < 0 || distances[i] < distances[current])) current = i;
|
|
127
|
+
if (current < 0 || distances[current] >= best || !Number.isFinite(distances[current])) break;
|
|
128
|
+
visited.add(current);
|
|
129
|
+
for (let t = 0; t < ends.length; t++) {
|
|
130
|
+
const length = distances[current] + distance(corners[current], ends[t].stub);
|
|
131
|
+
if (length < best && clear(corners[current], ends[t].stub)) {
|
|
132
|
+
best = length;
|
|
133
|
+
finish = current;
|
|
134
|
+
endPort = t;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let peers = neighbors.get(current);
|
|
138
|
+
if (!peers) {
|
|
139
|
+
peers = corners.flatMap((point, index) =>
|
|
140
|
+
index !== current && clear(corners[current], point)
|
|
141
|
+
? [{ index, length: distance(corners[current], point) }]
|
|
142
|
+
: [],
|
|
143
|
+
);
|
|
144
|
+
neighbors.set(current, peers);
|
|
145
|
+
}
|
|
146
|
+
for (const peer of peers)
|
|
147
|
+
if (distances[current] + peer.length < distances[peer.index]) {
|
|
148
|
+
distances[peer.index] = distances[current] + peer.length;
|
|
149
|
+
previous[peer.index] = current;
|
|
150
|
+
origins[peer.index] = origins[current];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (finish >= 0) {
|
|
154
|
+
const path: Point[] = [];
|
|
155
|
+
for (let at = finish; at >= 0; at = previous[at]) path.unshift(corners[at]);
|
|
156
|
+
const s = starts[origins[finish]],
|
|
157
|
+
t = ends[endPort];
|
|
158
|
+
points = [s.port, s.stub, ...path, t.stub, t.port];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!points) continue; // Only overlapping footprints can seal every boundary port.
|
|
162
|
+
const half = Math.max(24, link.label.length * 3.6 + 6);
|
|
163
|
+
const segments = points
|
|
164
|
+
.slice(1)
|
|
165
|
+
.map((point, index) => ({ first: points![index], point, length: distance(points![index], point) }))
|
|
166
|
+
.filter((segment) => segment.length > 0)
|
|
167
|
+
.sort((a, b) => b.length - a.length);
|
|
168
|
+
let label: GraphRoute['label'];
|
|
169
|
+
// Expand only as needed. Finite obstacles cannot hide a required label forever.
|
|
170
|
+
for (let radius = 16; link.label && !label; radius += 20) {
|
|
171
|
+
const candidates = segments.flatMap(({ first, point, length }) =>
|
|
172
|
+
[0.5, 0.25, 0.75].flatMap((along) =>
|
|
173
|
+
[-radius, radius].map((offset) => {
|
|
174
|
+
const x = first.x + (point.x - first.x) * along - ((point.y - first.y) * offset) / length;
|
|
175
|
+
const y = first.y + (point.y - first.y) * along + ((point.x - first.x) * offset) / length;
|
|
176
|
+
return { x, y, left: x - half, right: x + half, top: y - 16, bottom: y + 8 };
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
label = candidates.find(
|
|
181
|
+
(p) =>
|
|
182
|
+
![...boxes, ...labels].some(
|
|
183
|
+
(r) => p.left < r.right && p.right > r.left && p.top < r.bottom && p.bottom > r.top,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (label) labels.push(label);
|
|
188
|
+
result.set(link.id, {
|
|
189
|
+
points,
|
|
190
|
+
path: points.map((p, index) => `${index ? 'L' : 'M'} ${p.x} ${p.y}`).join(' '),
|
|
191
|
+
label,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { MemoryGraphEdge, MemoryGraphNode } from '../../shared/memory-graph.js';
|
|
2
|
+
import { GRAPH_NODE } from './graph-geometry';
|
|
3
|
+
import type { Point } from './memory-layout';
|
|
4
|
+
|
|
5
|
+
export type GraphScene =
|
|
6
|
+
| { kind: 'overview' }
|
|
7
|
+
| { kind: 'board'; boardId: string }
|
|
8
|
+
| { kind: 'card'; boardId: string; cardId: string };
|
|
9
|
+
export const graphSceneKey = (scene: GraphScene) => (scene.kind === 'overview' ? 'overview' : JSON.stringify(scene));
|
|
10
|
+
export const pairKey = (a: string, b: string) => JSON.stringify([a, b].sort());
|
|
11
|
+
|
|
12
|
+
/** Partition original facts once; grouping never changes authored endpoints. */
|
|
13
|
+
export function graphEvidence(edges: MemoryGraphEdge[]) {
|
|
14
|
+
const pairs = new Map<string, { boardId: string; peerId: string; edges: MemoryGraphEdge[] }>();
|
|
15
|
+
const internal = new Map<string, number>();
|
|
16
|
+
const missing: MemoryGraphEdge[] = [];
|
|
17
|
+
const evidence = edges.filter((edge) => edge.kind !== 'member');
|
|
18
|
+
for (const edge of evidence) {
|
|
19
|
+
if (edge.missing) {
|
|
20
|
+
missing.push(edge);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (edge.source.boardId === edge.target.boardId) {
|
|
24
|
+
internal.set(edge.source.boardId, (internal.get(edge.source.boardId) ?? 0) + 1);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const key = pairKey(edge.source.boardId, edge.target.boardId);
|
|
28
|
+
const pair = pairs.get(key);
|
|
29
|
+
if (pair) pair.edges.push(edge);
|
|
30
|
+
else pairs.set(key, { boardId: edge.source.boardId, peerId: edge.target.boardId, edges: [edge] });
|
|
31
|
+
}
|
|
32
|
+
return { evidence, pairs, internal, missing };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stable seed, independent of traversal order and the host's random source. */
|
|
36
|
+
function seed(key: string) {
|
|
37
|
+
let hash = 2166136261;
|
|
38
|
+
for (let i = 0; i < key.length; i++) hash = Math.imul(hash ^ key.charCodeAt(i), 16777619);
|
|
39
|
+
return (hash >>> 0) / 4294967296;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function graphAdjacency(keys: string[], edges: ReadonlyArray<{ from: string; to: string; weight?: number }>) {
|
|
43
|
+
const adjacency = new Map(keys.map((key) => [key, new Map<string, number>()]));
|
|
44
|
+
// Sort before summing parallel facts so floating-point sums are order-independent too.
|
|
45
|
+
const links = edges
|
|
46
|
+
.filter((edge) => edge.from !== edge.to && adjacency.has(edge.from) && adjacency.has(edge.to))
|
|
47
|
+
.map((edge) => ({ ends: [edge.from, edge.to].sort(), weight: Math.max(0.1, edge.weight ?? 1) }))
|
|
48
|
+
.sort((a, b) => a.ends[0].localeCompare(b.ends[0]) || a.ends[1].localeCompare(b.ends[1]) || a.weight - b.weight);
|
|
49
|
+
for (const {
|
|
50
|
+
ends: [a, b],
|
|
51
|
+
weight,
|
|
52
|
+
} of links) {
|
|
53
|
+
adjacency.get(a)!.set(b, (adjacency.get(a)!.get(b) ?? 0) + weight);
|
|
54
|
+
adjacency.get(b)!.set(a, (adjacency.get(b)!.get(a) ?? 0) + weight);
|
|
55
|
+
}
|
|
56
|
+
return adjacency;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shared forces for the initial solve and interaction, in footprint-normalized coordinates. */
|
|
60
|
+
function graphForce(key: string, positions: Map<string, Point>, keys: string[], neighbors: Map<string, number>) {
|
|
61
|
+
const point = positions.get(key)!;
|
|
62
|
+
// Share a gentle pull across links instead of multiplying it by a node's degree.
|
|
63
|
+
const totalWeight = [...neighbors.values()].reduce((sum, weight) => sum + Math.sqrt(weight), 0);
|
|
64
|
+
let fx = 0,
|
|
65
|
+
fy = 0;
|
|
66
|
+
for (const other of keys) {
|
|
67
|
+
if (other === key) continue;
|
|
68
|
+
const peer = positions.get(other)!;
|
|
69
|
+
const dx = point.x - peer.x || (seed(key) - 0.5) * 0.01;
|
|
70
|
+
const dy = point.y - peer.y || (seed(`${key}:y`) - 0.5) * 0.01;
|
|
71
|
+
const distance = Math.max(1, Math.hypot(dx, dy));
|
|
72
|
+
const repulsion = 14000 / (distance * distance);
|
|
73
|
+
const weight = neighbors.get(other) ?? 0;
|
|
74
|
+
const attraction = weight ? ((distance - 290) * 0.006 * Math.sqrt(weight)) / totalWeight : 0;
|
|
75
|
+
fx += (dx / distance) * (repulsion - attraction);
|
|
76
|
+
fy += (dy / distance) * (repulsion - attraction);
|
|
77
|
+
const width = GRAPH_NODE.width + GRAPH_NODE.gap;
|
|
78
|
+
const height = ((GRAPH_NODE.height + GRAPH_NODE.gap) * GRAPH_NODE.width) / GRAPH_NODE.height;
|
|
79
|
+
if (Math.abs(dx) < width && Math.abs(dy) < height) {
|
|
80
|
+
if (width - Math.abs(dx) < height - Math.abs(dy)) fx += Math.sign(dx) * (width - Math.abs(dx)) * 0.2;
|
|
81
|
+
else fy += Math.sign(dy) * (height - Math.abs(dy)) * 0.2;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { x: fx, y: fy };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One animation step. The pointer/drop anchor stays fixed while its visible neighbors respond. */
|
|
88
|
+
export function relaxGraphPositions(
|
|
89
|
+
positions: Map<string, Point>,
|
|
90
|
+
edges: ReadonlyArray<{ from: string; to: string; weight?: number }>,
|
|
91
|
+
held: string,
|
|
92
|
+
cooling: number,
|
|
93
|
+
) {
|
|
94
|
+
const keys = [...positions.keys()].sort();
|
|
95
|
+
const adjacency = graphAdjacency(keys, edges);
|
|
96
|
+
const aspect = GRAPH_NODE.width / GRAPH_NODE.height;
|
|
97
|
+
const normalized = new Map([...positions].map(([key, point]) => [key, { x: point.x, y: point.y * aspect }]));
|
|
98
|
+
let movement = 0;
|
|
99
|
+
for (const key of keys) {
|
|
100
|
+
if (key === held) continue;
|
|
101
|
+
const point = normalized.get(key)!;
|
|
102
|
+
const force = graphForce(key, normalized, keys, adjacency.get(key)!);
|
|
103
|
+
const step = Math.min(1, 24 / Math.max(1, Math.hypot(force.x, force.y))) * cooling;
|
|
104
|
+
const dx = force.x * step,
|
|
105
|
+
dy = (force.y * step) / aspect;
|
|
106
|
+
positions.set(key, { x: point.x + dx, y: point.y / aspect + dy });
|
|
107
|
+
movement = Math.max(movement, Math.hypot(dx, dy));
|
|
108
|
+
}
|
|
109
|
+
// Resolve rectangular collisions, rather than letting springs seal a node's connection ports.
|
|
110
|
+
for (const [index, key] of keys.entries())
|
|
111
|
+
for (const other of keys.slice(index + 1)) {
|
|
112
|
+
const point = positions.get(key)!,
|
|
113
|
+
peer = positions.get(other)!;
|
|
114
|
+
const dx = point.x - peer.x,
|
|
115
|
+
dy = point.y - peer.y;
|
|
116
|
+
const width = GRAPH_NODE.width + GRAPH_NODE.gap - Math.abs(dx),
|
|
117
|
+
height = GRAPH_NODE.height + GRAPH_NODE.gap - Math.abs(dy);
|
|
118
|
+
if (width <= 0 || height <= 0) continue;
|
|
119
|
+
const push =
|
|
120
|
+
width < height ? { x: (Math.sign(dx) || -1) * width, y: 0 } : { x: 0, y: (Math.sign(dy) || -1) * height };
|
|
121
|
+
const a = other === held ? 1 : 0.5,
|
|
122
|
+
b = key === held ? 1 : 0.5;
|
|
123
|
+
if (key !== held) positions.set(key, { x: point.x + push.x * a, y: point.y + push.y * a });
|
|
124
|
+
if (other !== held) positions.set(other, { x: peer.x - push.x * b, y: peer.y - push.y * b });
|
|
125
|
+
movement = Math.max(movement, Math.hypot(push.x, push.y));
|
|
126
|
+
}
|
|
127
|
+
return movement;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Deterministic initial placement; live additions preserve retained coordinates. */
|
|
131
|
+
export function graphPositions(
|
|
132
|
+
nodes: MemoryGraphNode[],
|
|
133
|
+
edges: ReadonlyArray<{ from: string; to: string; weight?: number }>,
|
|
134
|
+
retained: Map<string, Point>,
|
|
135
|
+
): Map<string, Point> {
|
|
136
|
+
const keys = [...new Set(nodes.map((node) => node.key))].sort();
|
|
137
|
+
if (keys.every((key) => retained.has(key))) return new Map(keys.map((key) => [key, retained.get(key)!]));
|
|
138
|
+
const adjacency = graphAdjacency(keys, edges);
|
|
139
|
+
const components: string[][] = [];
|
|
140
|
+
const visited = new Set<string>();
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
if (visited.has(key)) continue;
|
|
143
|
+
const component = [key];
|
|
144
|
+
visited.add(key);
|
|
145
|
+
for (const member of component) {
|
|
146
|
+
for (const neighbor of adjacency.get(member)!.keys()) {
|
|
147
|
+
if (visited.has(neighbor)) continue;
|
|
148
|
+
visited.add(neighbor);
|
|
149
|
+
component.push(neighbor);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
components.push(component.sort());
|
|
153
|
+
}
|
|
154
|
+
const positions = new Map<string, Point>();
|
|
155
|
+
// Solve in footprint-normalized coordinates, then return screen-shaped rectangles.
|
|
156
|
+
const aspect = GRAPH_NODE.width / GRAPH_NODE.height;
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
const anchor = retained.get(key);
|
|
159
|
+
if (anchor) positions.set(key, { x: anchor.x, y: anchor.y * aspect });
|
|
160
|
+
}
|
|
161
|
+
for (const component of components) {
|
|
162
|
+
// Grow from anchors (or one seeded root), placing each newcomer near real neighbors.
|
|
163
|
+
if (!component.some((key) => positions.has(key))) positions.set(component[0], { x: 0, y: 0 });
|
|
164
|
+
while (component.some((key) => !positions.has(key))) {
|
|
165
|
+
for (const key of component) {
|
|
166
|
+
if (positions.has(key)) continue;
|
|
167
|
+
const neighbors = [...adjacency.get(key)!.keys()].filter((neighbor) => positions.has(neighbor));
|
|
168
|
+
if (!neighbors.length) continue;
|
|
169
|
+
positions.set(key, {
|
|
170
|
+
x:
|
|
171
|
+
neighbors.reduce((sum, neighbor) => sum + positions.get(neighbor)!.x, 0) / neighbors.length +
|
|
172
|
+
(seed(key) - 0.5) * 240,
|
|
173
|
+
y:
|
|
174
|
+
neighbors.reduce((sum, neighbor) => sum + positions.get(neighbor)!.y, 0) / neighbors.length +
|
|
175
|
+
(seed(`${key}:y`) - 0.5) * 240,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (let iteration = 0; iteration < 180; iteration++) {
|
|
180
|
+
const updates = new Map<string, Point>();
|
|
181
|
+
for (const key of component) {
|
|
182
|
+
if (retained.has(key)) continue;
|
|
183
|
+
const point = positions.get(key)!;
|
|
184
|
+
const { x: fx, y: fy } = graphForce(key, positions, component, adjacency.get(key)!);
|
|
185
|
+
const step = Math.min(1, 24 / Math.max(1, Math.hypot(fx, fy))) * (1 - iteration / 200);
|
|
186
|
+
updates.set(key, { x: point.x + fx * step, y: point.y + fy * step });
|
|
187
|
+
}
|
|
188
|
+
for (const [key, point] of updates) positions.set(key, point);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const [key, point] of positions) positions.set(key, retained.get(key) ?? { x: point.x, y: point.y / aspect });
|
|
192
|
+
// Exact footprint clearance after cooling. Anchors always win, even across components.
|
|
193
|
+
const occupied = keys.filter((key) => retained.has(key)).map((key) => positions.get(key)!);
|
|
194
|
+
const width = GRAPH_NODE.width + GRAPH_NODE.gap,
|
|
195
|
+
height = GRAPH_NODE.height + GRAPH_NODE.gap;
|
|
196
|
+
const overlaps = (a: Point, b: Point) => Math.abs(a.x - b.x) < width && Math.abs(a.y - b.y) < height;
|
|
197
|
+
let packLeft = 0,
|
|
198
|
+
packX = 0;
|
|
199
|
+
let packY = 0,
|
|
200
|
+
rowHeight = 0;
|
|
201
|
+
const rowWidth = Math.sqrt(keys.length) * 380;
|
|
202
|
+
const anchoredComponent = (component: string[]) => component.some((key) => retained.has(key));
|
|
203
|
+
components.sort((a, b) => Number(anchoredComponent(b)) - Number(anchoredComponent(a)));
|
|
204
|
+
let packing = false;
|
|
205
|
+
for (const component of components) {
|
|
206
|
+
const anchored = anchoredComponent(component);
|
|
207
|
+
if (!anchored && !packing) {
|
|
208
|
+
packLeft = occupied.length ? Math.max(...occupied.map((point) => point.x)) + 340 : 0;
|
|
209
|
+
packX = packLeft;
|
|
210
|
+
packing = true;
|
|
211
|
+
}
|
|
212
|
+
const obstacles = anchored ? occupied : [];
|
|
213
|
+
const placed: Point[] = [];
|
|
214
|
+
for (const key of component) {
|
|
215
|
+
if (retained.has(key)) continue;
|
|
216
|
+
const desired = positions.get(key)!;
|
|
217
|
+
let point = desired;
|
|
218
|
+
if (obstacles.some((peer) => overlaps(point, peer))) {
|
|
219
|
+
const candidates = obstacles.flatMap((peer) => [
|
|
220
|
+
{ x: peer.x - width, y: desired.y },
|
|
221
|
+
{ x: peer.x + width, y: desired.y },
|
|
222
|
+
{ x: desired.x, y: peer.y - height },
|
|
223
|
+
{ x: desired.x, y: peer.y + height },
|
|
224
|
+
]);
|
|
225
|
+
point = candidates
|
|
226
|
+
.filter((candidate) => !obstacles.some((peer) => overlaps(candidate, peer)))
|
|
227
|
+
.sort(
|
|
228
|
+
(a, b) => Math.hypot(a.x - desired.x, a.y - desired.y) - Math.hypot(b.x - desired.x, b.y - desired.y),
|
|
229
|
+
)[0];
|
|
230
|
+
}
|
|
231
|
+
positions.set(key, point);
|
|
232
|
+
obstacles.push(point);
|
|
233
|
+
placed.push(point);
|
|
234
|
+
}
|
|
235
|
+
if (!anchored && placed.length) {
|
|
236
|
+
const left = Math.min(...placed.map((point) => point.x));
|
|
237
|
+
const top = Math.min(...placed.map((point) => point.y));
|
|
238
|
+
for (const point of placed) {
|
|
239
|
+
point.x += packX - left;
|
|
240
|
+
point.y += packY - top;
|
|
241
|
+
}
|
|
242
|
+
occupied.push(...placed);
|
|
243
|
+
packX = Math.max(...placed.map((point) => point.x)) + 340;
|
|
244
|
+
rowHeight = Math.max(rowHeight, Math.max(...placed.map((point) => point.y)) - packY + 200);
|
|
245
|
+
if (packX - packLeft > rowWidth) {
|
|
246
|
+
packX = packLeft;
|
|
247
|
+
packY += rowHeight;
|
|
248
|
+
rowHeight = 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const [key, point] of positions) if (!retained.has(key)) retained.set(key, point);
|
|
253
|
+
return positions;
|
|
254
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { MemoryGraphNode } from '../../shared/memory-graph.js';
|
|
2
|
+
|
|
3
|
+
export type Point = { x: number; y: number };
|
|
4
|
+
export const MAP_CARD = { width: 220, height: 92 };
|
|
5
|
+
export interface MemoryRegion extends Point {
|
|
6
|
+
path: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
count: number;
|
|
10
|
+
depth: number;
|
|
11
|
+
collapsed: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Folder membership determines regions; authored Map positions never change membership. */
|
|
15
|
+
export function mapLayout(nodes: MemoryGraphNode[], collapsed: Set<string>) {
|
|
16
|
+
const boards = nodes.filter((node) => node.kind === 'board');
|
|
17
|
+
const positions = new Map<string, Point>();
|
|
18
|
+
const regions: MemoryRegion[] = [];
|
|
19
|
+
const hidden = new Set<string>();
|
|
20
|
+
const paths = new Set<string>();
|
|
21
|
+
for (const board of boards) {
|
|
22
|
+
const parts = (board.folder ?? '').split('/');
|
|
23
|
+
parts.forEach((_, index) => {
|
|
24
|
+
paths.add(parts.slice(0, index + 1).join('/'));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const inside = (node: MemoryGraphNode, path: string) =>
|
|
28
|
+
(node.folder ?? '') === path || (!!path && !!node.folder?.startsWith(`${path}/`));
|
|
29
|
+
const measure = (path: string, x: number, y: number, depth: number): { width: number; height: number } => {
|
|
30
|
+
const members = boards.filter((node) => inside(node, path));
|
|
31
|
+
const folded = collapsed.has(path);
|
|
32
|
+
const region: MemoryRegion = {
|
|
33
|
+
path,
|
|
34
|
+
x,
|
|
35
|
+
y,
|
|
36
|
+
width: 280,
|
|
37
|
+
height: 72,
|
|
38
|
+
count: members.length,
|
|
39
|
+
depth,
|
|
40
|
+
collapsed: folded,
|
|
41
|
+
};
|
|
42
|
+
regions.push(region);
|
|
43
|
+
if (folded) {
|
|
44
|
+
for (const node of members) hidden.add(node.key);
|
|
45
|
+
return region;
|
|
46
|
+
}
|
|
47
|
+
const direct = members
|
|
48
|
+
.filter((node) => (node.folder ?? '') === path)
|
|
49
|
+
.sort((a, b) => a.title.localeCompare(b.title));
|
|
50
|
+
const columns = Math.min(3, Math.max(1, Math.ceil(Math.sqrt(direct.length))));
|
|
51
|
+
direct.forEach((node, index) => {
|
|
52
|
+
const topLeft = node.position ?? {
|
|
53
|
+
x: x + 24 + (index % columns) * (MAP_CARD.width + 28),
|
|
54
|
+
y: y + 52 + Math.floor(index / columns) * (MAP_CARD.height + 28),
|
|
55
|
+
};
|
|
56
|
+
positions.set(node.key, { x: topLeft.x + MAP_CARD.width / 2, y: topLeft.y + MAP_CARD.height / 2 });
|
|
57
|
+
});
|
|
58
|
+
let nextY = y + 52 + Math.ceil(direct.length / columns) * (MAP_CARD.height + 28);
|
|
59
|
+
let width = direct.length ? columns * (MAP_CARD.width + 28) + 20 : 280;
|
|
60
|
+
const children = [...paths]
|
|
61
|
+
.filter((item) => item && item.slice(0, item.lastIndexOf('/')) === path && item.includes('/'))
|
|
62
|
+
.sort();
|
|
63
|
+
let childX = 20,
|
|
64
|
+
childHeight = 0;
|
|
65
|
+
for (const child of children) {
|
|
66
|
+
if (childX > 650) {
|
|
67
|
+
childX = 20;
|
|
68
|
+
nextY += childHeight + 24;
|
|
69
|
+
childHeight = 0;
|
|
70
|
+
}
|
|
71
|
+
const size = measure(child, x + childX, nextY, depth + 1);
|
|
72
|
+
childX += size.width + 24;
|
|
73
|
+
childHeight = Math.max(childHeight, size.height);
|
|
74
|
+
width = Math.max(width, childX);
|
|
75
|
+
}
|
|
76
|
+
nextY += childHeight + (children.length ? 20 : 0);
|
|
77
|
+
region.width = width;
|
|
78
|
+
region.height = Math.max(100, nextY - y + 4);
|
|
79
|
+
// Include saved positions and nested regions without discarding a person's arrangement.
|
|
80
|
+
const points = members.flatMap((node) => {
|
|
81
|
+
const point = positions.get(node.key);
|
|
82
|
+
return point ? [point] : [];
|
|
83
|
+
});
|
|
84
|
+
const right = Math.max(x + region.width, ...points.map((point) => point.x + MAP_CARD.width / 2 + 20));
|
|
85
|
+
const bottom = Math.max(y + region.height, ...points.map((point) => point.y + MAP_CARD.height / 2 + 20));
|
|
86
|
+
region.x = Math.min(x, ...points.map((point) => point.x - MAP_CARD.width / 2 - 20));
|
|
87
|
+
region.y = Math.min(y, ...points.map((point) => point.y - MAP_CARD.height / 2 - 48));
|
|
88
|
+
region.width = right - region.x;
|
|
89
|
+
region.height = bottom - region.y;
|
|
90
|
+
return region;
|
|
91
|
+
};
|
|
92
|
+
let x = 32,
|
|
93
|
+
y = 32,
|
|
94
|
+
rowHeight = 0;
|
|
95
|
+
for (const path of [...paths].filter((path) => !path.includes('/')).sort()) {
|
|
96
|
+
if (x > 1100) {
|
|
97
|
+
x = 32;
|
|
98
|
+
y += rowHeight + 36;
|
|
99
|
+
rowHeight = 0;
|
|
100
|
+
}
|
|
101
|
+
const size = measure(path, x, y, 0);
|
|
102
|
+
x += size.width + 36;
|
|
103
|
+
rowHeight = Math.max(rowHeight, size.height);
|
|
104
|
+
}
|
|
105
|
+
return { positions, regions, hidden };
|
|
106
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
|
+
|
|
3
|
+
/** Keep floating canvas controls clear when their content wraps or is hidden. */
|
|
4
|
+
export function useChromeHeight(property: string) {
|
|
5
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
6
|
+
useLayoutEffect(() => {
|
|
7
|
+
const element = ref.current;
|
|
8
|
+
const region = element?.parentElement;
|
|
9
|
+
if (!element || !region) return;
|
|
10
|
+
const measure = () => region.style.setProperty(property, `${element.getBoundingClientRect().height}px`);
|
|
11
|
+
measure();
|
|
12
|
+
const observer = new ResizeObserver(measure);
|
|
13
|
+
observer.observe(element);
|
|
14
|
+
return () => {
|
|
15
|
+
observer.disconnect();
|
|
16
|
+
region.style.removeProperty(property);
|
|
17
|
+
};
|
|
18
|
+
}, [property]);
|
|
19
|
+
return ref;
|
|
20
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
2
|
|
|
3
3
|
const FOCUSABLE =
|
|
4
|
-
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
4
|
+
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Focus trap for modal overlays (rail-chrome-v2 item 18): while `active`, Tab
|
|
@@ -12,15 +12,31 @@ const FOCUSABLE =
|
|
|
12
12
|
export function useFocusTrap(
|
|
13
13
|
ref: { current: HTMLElement | null },
|
|
14
14
|
active: boolean,
|
|
15
|
-
options: {
|
|
15
|
+
options: {
|
|
16
|
+
initial?: { current: HTMLElement | null };
|
|
17
|
+
restoreTo?: () => HTMLElement | null;
|
|
18
|
+
onEscape?: () => void;
|
|
19
|
+
} = {},
|
|
16
20
|
): void {
|
|
17
|
-
|
|
21
|
+
const escapeHandler = useRef(options.onEscape);
|
|
22
|
+
escapeHandler.current = options.onEscape;
|
|
23
|
+
useLayoutEffect(() => {
|
|
18
24
|
if (!active) return;
|
|
19
25
|
const root = ref.current;
|
|
20
26
|
if (!root) return;
|
|
21
27
|
const previous = document.activeElement as HTMLElement | null;
|
|
22
28
|
const restoreTo = options.restoreTo;
|
|
23
|
-
const focusables = () =>
|
|
29
|
+
const focusables = () =>
|
|
30
|
+
Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((item) => {
|
|
31
|
+
if (item.closest('[hidden], [inert]')) return false;
|
|
32
|
+
for (let parent: HTMLElement | null = item; parent && parent !== root; parent = parent.parentElement) {
|
|
33
|
+
if (getComputedStyle(parent).display === 'none' || getComputedStyle(parent).visibility === 'hidden')
|
|
34
|
+
return false;
|
|
35
|
+
if (parent.matches('details:not([open])') && !parent.querySelector(':scope > summary')?.contains(item))
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
});
|
|
24
40
|
const initial = options.initial?.current ?? focusables()[0];
|
|
25
41
|
if (initial && !root.contains(document.activeElement)) initial.focus();
|
|
26
42
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -38,9 +54,19 @@ export function useFocusTrap(
|
|
|
38
54
|
first.focus();
|
|
39
55
|
}
|
|
40
56
|
};
|
|
57
|
+
// Disabling the focused button during a save can move focus to body. Keep
|
|
58
|
+
// modal Escape ownership there; keys inside retain local/nested handling.
|
|
59
|
+
const onOutsideKey = (event: KeyboardEvent) => {
|
|
60
|
+
if (event.key !== 'Escape' || !escapeHandler.current || root.contains(event.target as Node)) return;
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
event.stopPropagation();
|
|
63
|
+
escapeHandler.current();
|
|
64
|
+
};
|
|
41
65
|
root.addEventListener('keydown', onKey);
|
|
66
|
+
window.addEventListener('keydown', onOutsideKey, true);
|
|
42
67
|
return () => {
|
|
43
68
|
root.removeEventListener('keydown', onKey);
|
|
69
|
+
window.removeEventListener('keydown', onOutsideKey, true);
|
|
44
70
|
// `restoreTo` wins: an overlay that replaced the element it was opened
|
|
45
71
|
// from (focus mode unmounts the node while expanded) names the element
|
|
46
72
|
// to return to, which may only exist after the next frame.
|