pmx-canvas 0.1.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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- package/src/shared/semantic-attention.ts +598 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spatial Semantics Layer for PMX Canvas
|
|
3
|
+
*
|
|
4
|
+
* Analyzes the spatial arrangement of nodes on the canvas to extract
|
|
5
|
+
* meaningful relationships: proximity clusters, reading order, and
|
|
6
|
+
* neighborhood context around pinned nodes.
|
|
7
|
+
*
|
|
8
|
+
* This makes the canvas promise — "spatial arrangement is communication" —
|
|
9
|
+
* actually real for agents. Instead of raw x/y coordinates, agents get
|
|
10
|
+
* semantic clusters, ordered context, and implicit human intent.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CanvasNodeState, CanvasEdge } from './canvas-state.js';
|
|
14
|
+
import { summarizeNodeForAgentContext } from './agent-context.js';
|
|
15
|
+
|
|
16
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface SpatialCluster {
|
|
19
|
+
/** Auto-generated cluster ID */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Node IDs in this cluster */
|
|
22
|
+
nodeIds: string[];
|
|
23
|
+
/** Human-readable label derived from node titles/types */
|
|
24
|
+
label: string;
|
|
25
|
+
/** Centroid of the cluster */
|
|
26
|
+
centroid: { x: number; y: number };
|
|
27
|
+
/** Bounding box of all nodes in the cluster */
|
|
28
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SpatialNeighbor {
|
|
32
|
+
id: string;
|
|
33
|
+
type: string;
|
|
34
|
+
title: string | null;
|
|
35
|
+
distance: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface NodeSpatialInfo {
|
|
39
|
+
id: string;
|
|
40
|
+
type: string;
|
|
41
|
+
title: string | null;
|
|
42
|
+
content: string | null;
|
|
43
|
+
clusterId: string | null;
|
|
44
|
+
/** Reading order index (top-left to bottom-right) */
|
|
45
|
+
readingOrder: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SpatialContext {
|
|
49
|
+
/** Total nodes on canvas */
|
|
50
|
+
totalNodes: number;
|
|
51
|
+
/** Detected proximity clusters */
|
|
52
|
+
clusters: SpatialCluster[];
|
|
53
|
+
/** All nodes in spatial reading order (top-left to bottom-right) */
|
|
54
|
+
nodesInReadingOrder: NodeSpatialInfo[];
|
|
55
|
+
/** For each pinned node, nearby unpinned nodes (the implicit context) */
|
|
56
|
+
pinnedNeighborhoods: {
|
|
57
|
+
pinnedNodeId: string;
|
|
58
|
+
pinnedNodeTitle: string | null;
|
|
59
|
+
neighbors: SpatialNeighbor[];
|
|
60
|
+
}[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** Euclidean distance between two node centers */
|
|
66
|
+
function centerDistance(a: CanvasNodeState, b: CanvasNodeState): number {
|
|
67
|
+
const ax = a.position.x + a.size.width / 2;
|
|
68
|
+
const ay = a.position.y + a.size.height / 2;
|
|
69
|
+
const bx = b.position.x + b.size.width / 2;
|
|
70
|
+
const by = b.position.y + b.size.height / 2;
|
|
71
|
+
return Math.sqrt((ax - bx) ** 2 + (ay - by) ** 2);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Gap distance — how far apart two nodes are edge-to-edge (0 if overlapping) */
|
|
75
|
+
function gapDistance(a: CanvasNodeState, b: CanvasNodeState): number {
|
|
76
|
+
const aRight = a.position.x + a.size.width;
|
|
77
|
+
const aBottom = a.position.y + a.size.height;
|
|
78
|
+
const bRight = b.position.x + b.size.width;
|
|
79
|
+
const bBottom = b.position.y + b.size.height;
|
|
80
|
+
|
|
81
|
+
const gapX = Math.max(0, Math.max(a.position.x, b.position.x) - Math.min(aRight, bRight));
|
|
82
|
+
const gapY = Math.max(0, Math.max(a.position.y, b.position.y) - Math.min(aBottom, bBottom));
|
|
83
|
+
|
|
84
|
+
return Math.sqrt(gapX ** 2 + gapY ** 2);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reading-order sort: top-to-bottom, then left-to-right (with row tolerance) */
|
|
88
|
+
function readingOrderSort(nodes: CanvasNodeState[]): CanvasNodeState[] {
|
|
89
|
+
const sorted = [...nodes];
|
|
90
|
+
// Row tolerance: nodes within 100px vertical are considered the same row
|
|
91
|
+
const ROW_TOLERANCE = 100;
|
|
92
|
+
sorted.sort((a, b) => {
|
|
93
|
+
const rowA = Math.floor(a.position.y / ROW_TOLERANCE);
|
|
94
|
+
const rowB = Math.floor(b.position.y / ROW_TOLERANCE);
|
|
95
|
+
if (rowA !== rowB) return rowA - rowB;
|
|
96
|
+
return a.position.x - b.position.x;
|
|
97
|
+
});
|
|
98
|
+
return sorted;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Derive a human-readable label for a cluster from its nodes */
|
|
102
|
+
function deriveClusterLabel(nodes: CanvasNodeState[]): string {
|
|
103
|
+
// Use the first node with a title, or fall back to type summary
|
|
104
|
+
const titled = nodes.find((n) => n.data.title && typeof n.data.title === 'string');
|
|
105
|
+
if (titled && nodes.length <= 3) {
|
|
106
|
+
const titles = nodes
|
|
107
|
+
.filter((n) => n.data.title)
|
|
108
|
+
.map((n) => n.data.title as string)
|
|
109
|
+
.slice(0, 3);
|
|
110
|
+
return titles.join(', ');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Summarize by type counts
|
|
114
|
+
const typeCounts: Record<string, number> = {};
|
|
115
|
+
for (const n of nodes) {
|
|
116
|
+
typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1;
|
|
117
|
+
}
|
|
118
|
+
const parts = Object.entries(typeCounts).map(([type, count]) =>
|
|
119
|
+
count === 1 ? type : `${count} ${type}`,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (titled) {
|
|
123
|
+
return `${titled.data.title} + ${parts.join(', ')}`;
|
|
124
|
+
}
|
|
125
|
+
return parts.join(', ');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Core Analysis ────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect proximity clusters using single-linkage clustering.
|
|
132
|
+
* Two nodes are "close" if their edge-to-edge gap is within the threshold.
|
|
133
|
+
*
|
|
134
|
+
* Default threshold: 200px (roughly "visually grouped" on a typical canvas).
|
|
135
|
+
*/
|
|
136
|
+
export function detectClusters(
|
|
137
|
+
nodes: CanvasNodeState[],
|
|
138
|
+
proximityThreshold = 200,
|
|
139
|
+
): SpatialCluster[] {
|
|
140
|
+
if (nodes.length === 0) return [];
|
|
141
|
+
|
|
142
|
+
// Union-Find for clustering
|
|
143
|
+
const parent = new Map<string, string>();
|
|
144
|
+
const find = (id: string): string => {
|
|
145
|
+
while (parent.get(id) !== id) {
|
|
146
|
+
const p = parent.get(id)!;
|
|
147
|
+
parent.set(id, parent.get(p)!); // path compression
|
|
148
|
+
id = p;
|
|
149
|
+
}
|
|
150
|
+
return id;
|
|
151
|
+
};
|
|
152
|
+
const union = (a: string, b: string): void => {
|
|
153
|
+
const ra = find(a);
|
|
154
|
+
const rb = find(b);
|
|
155
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Initialize each node as its own cluster
|
|
159
|
+
for (const node of nodes) {
|
|
160
|
+
parent.set(node.id, node.id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Compare all pairs (fine for canvas-scale node counts, typically < 200)
|
|
164
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
165
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
166
|
+
if (gapDistance(nodes[i], nodes[j]) <= proximityThreshold) {
|
|
167
|
+
union(nodes[i].id, nodes[j].id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Group by root
|
|
173
|
+
const groups = new Map<string, CanvasNodeState[]>();
|
|
174
|
+
for (const node of nodes) {
|
|
175
|
+
const root = find(node.id);
|
|
176
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
177
|
+
groups.get(root)!.push(node);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Build cluster objects (only clusters with 2+ nodes are interesting)
|
|
181
|
+
const clusters: SpatialCluster[] = [];
|
|
182
|
+
let clusterIdx = 0;
|
|
183
|
+
for (const [, members] of groups) {
|
|
184
|
+
if (members.length < 2) continue;
|
|
185
|
+
|
|
186
|
+
const xs = members.map((n) => n.position.x);
|
|
187
|
+
const ys = members.map((n) => n.position.y);
|
|
188
|
+
const rights = members.map((n) => n.position.x + n.size.width);
|
|
189
|
+
const bottoms = members.map((n) => n.position.y + n.size.height);
|
|
190
|
+
|
|
191
|
+
const minX = Math.min(...xs);
|
|
192
|
+
const minY = Math.min(...ys);
|
|
193
|
+
const maxRight = Math.max(...rights);
|
|
194
|
+
const maxBottom = Math.max(...bottoms);
|
|
195
|
+
|
|
196
|
+
clusters.push({
|
|
197
|
+
id: `cluster-${clusterIdx++}`,
|
|
198
|
+
nodeIds: members.map((n) => n.id),
|
|
199
|
+
label: deriveClusterLabel(members),
|
|
200
|
+
centroid: {
|
|
201
|
+
x: Math.round((minX + maxRight) / 2),
|
|
202
|
+
y: Math.round((minY + maxBottom) / 2),
|
|
203
|
+
},
|
|
204
|
+
bounds: {
|
|
205
|
+
x: minX,
|
|
206
|
+
y: minY,
|
|
207
|
+
width: maxRight - minX,
|
|
208
|
+
height: maxBottom - minY,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Sort clusters by reading order (top-left centroid first)
|
|
214
|
+
clusters.sort((a, b) => {
|
|
215
|
+
const rowA = Math.floor(a.centroid.y / 200);
|
|
216
|
+
const rowB = Math.floor(b.centroid.y / 200);
|
|
217
|
+
if (rowA !== rowB) return rowA - rowB;
|
|
218
|
+
return a.centroid.x - b.centroid.x;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return clusters;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Find the nearest unpinned nodes to each pinned node.
|
|
226
|
+
*/
|
|
227
|
+
export function findNeighborhoods(
|
|
228
|
+
nodes: CanvasNodeState[],
|
|
229
|
+
pinnedIds: Set<string>,
|
|
230
|
+
maxNeighbors = 5,
|
|
231
|
+
maxDistance = 600,
|
|
232
|
+
): SpatialContext['pinnedNeighborhoods'] {
|
|
233
|
+
const pinned = nodes.filter((n) => pinnedIds.has(n.id));
|
|
234
|
+
const unpinned = nodes.filter((n) => !pinnedIds.has(n.id));
|
|
235
|
+
|
|
236
|
+
return pinned.map((pin) => {
|
|
237
|
+
const withDist = unpinned
|
|
238
|
+
.map((n) => ({ node: n, distance: centerDistance(pin, n) }))
|
|
239
|
+
.filter((d) => d.distance <= maxDistance)
|
|
240
|
+
.sort((a, b) => a.distance - b.distance)
|
|
241
|
+
.slice(0, maxNeighbors);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
pinnedNodeId: pin.id,
|
|
245
|
+
pinnedNodeTitle: (pin.data.title as string) ?? null,
|
|
246
|
+
neighbors: withDist.map((d) => ({
|
|
247
|
+
id: d.node.id,
|
|
248
|
+
type: d.node.type,
|
|
249
|
+
title: (d.node.data.title as string) ?? null,
|
|
250
|
+
distance: Math.round(d.distance),
|
|
251
|
+
})),
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Full-text search across node titles and content.
|
|
258
|
+
* Returns matching nodes with relevance score.
|
|
259
|
+
*/
|
|
260
|
+
export function searchNodes(
|
|
261
|
+
nodes: CanvasNodeState[],
|
|
262
|
+
query: string,
|
|
263
|
+
): { id: string; type: string; title: string | null; snippet: string; score: number }[] {
|
|
264
|
+
const q = query.toLowerCase().trim();
|
|
265
|
+
if (!q) return [];
|
|
266
|
+
|
|
267
|
+
const terms = q.split(/\s+/);
|
|
268
|
+
const results: { id: string; type: string; title: string | null; snippet: string; score: number }[] = [];
|
|
269
|
+
|
|
270
|
+
for (const node of nodes) {
|
|
271
|
+
const title = ((node.data.title as string) ?? '').toLowerCase();
|
|
272
|
+
const content = ((node.data.content as string) ?? (node.data.fileContent as string) ?? '').toLowerCase();
|
|
273
|
+
const path = ((node.data.path as string) ?? '').toLowerCase();
|
|
274
|
+
const description = ((node.data.description as string) ?? '').toLowerCase();
|
|
275
|
+
const url = ((node.data.url as string) ?? '').toLowerCase();
|
|
276
|
+
|
|
277
|
+
let score = 0;
|
|
278
|
+
for (const term of terms) {
|
|
279
|
+
// Title matches are worth more
|
|
280
|
+
if (title.includes(term)) score += 3;
|
|
281
|
+
if (path.includes(term)) score += 2;
|
|
282
|
+
if (url.includes(term)) score += 2;
|
|
283
|
+
if (description.includes(term)) score += 1;
|
|
284
|
+
if (content.includes(term)) score += 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (score === 0) continue;
|
|
288
|
+
|
|
289
|
+
// Extract a snippet around the first match in content
|
|
290
|
+
let snippet = '';
|
|
291
|
+
const fullContent = (node.data.content as string) ?? (node.data.fileContent as string) ?? '';
|
|
292
|
+
const matchIdx = fullContent.toLowerCase().indexOf(terms[0]);
|
|
293
|
+
if (matchIdx >= 0) {
|
|
294
|
+
const start = Math.max(0, matchIdx - 40);
|
|
295
|
+
const end = Math.min(fullContent.length, matchIdx + 80);
|
|
296
|
+
snippet = (start > 0 ? '...' : '') +
|
|
297
|
+
fullContent.slice(start, end).replace(/\n/g, ' ') +
|
|
298
|
+
(end < fullContent.length ? '...' : '');
|
|
299
|
+
} else if (title) {
|
|
300
|
+
snippet = (node.data.title as string) ?? '';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
results.push({
|
|
304
|
+
id: node.id,
|
|
305
|
+
type: node.type,
|
|
306
|
+
title: (node.data.title as string) ?? null,
|
|
307
|
+
snippet,
|
|
308
|
+
score,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
results.sort((a, b) => b.score - a.score);
|
|
313
|
+
return results;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build the complete spatial context for the canvas.
|
|
318
|
+
*/
|
|
319
|
+
export function buildSpatialContext(
|
|
320
|
+
nodes: CanvasNodeState[],
|
|
321
|
+
_edges: CanvasEdge[],
|
|
322
|
+
pinnedIds: Set<string>,
|
|
323
|
+
): SpatialContext {
|
|
324
|
+
const clusters = detectClusters(nodes);
|
|
325
|
+
|
|
326
|
+
// Build a lookup: nodeId → clusterId
|
|
327
|
+
const nodeToCluster = new Map<string, string>();
|
|
328
|
+
for (const cluster of clusters) {
|
|
329
|
+
for (const nid of cluster.nodeIds) {
|
|
330
|
+
nodeToCluster.set(nid, cluster.id);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const ordered = readingOrderSort(nodes);
|
|
335
|
+
|
|
336
|
+
const nodesInReadingOrder: NodeSpatialInfo[] = ordered.map((n, i) => ({
|
|
337
|
+
id: n.id,
|
|
338
|
+
type: n.type,
|
|
339
|
+
title: (n.data.title as string) ?? null,
|
|
340
|
+
content: summarizeNodeForAgentContext(n, {
|
|
341
|
+
defaultTextLength: 320,
|
|
342
|
+
webpageTextLength: 640,
|
|
343
|
+
}) || null,
|
|
344
|
+
clusterId: nodeToCluster.get(n.id) ?? null,
|
|
345
|
+
readingOrder: i,
|
|
346
|
+
}));
|
|
347
|
+
|
|
348
|
+
const pinnedNeighborhoods = findNeighborhoods(nodes, pinnedIds);
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
totalNodes: nodes.length,
|
|
352
|
+
clusters,
|
|
353
|
+
nodesInReadingOrder,
|
|
354
|
+
pinnedNeighborhoods,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TraceManager — creates trace nodes and flow edges on the canvas
|
|
3
|
+
* as the agent calls tools and spawns subagents.
|
|
4
|
+
*
|
|
5
|
+
* Server-side singleton consumed by chat-view event wiring.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type CanvasEdge, type CanvasNodeState, canvasState } from './canvas-state.js';
|
|
9
|
+
import { emitPrimaryWorkbenchEvent } from './server.js';
|
|
10
|
+
|
|
11
|
+
const MAX_TRACE_NODES = 120;
|
|
12
|
+
const TRACE_NODE_WIDTH = 200;
|
|
13
|
+
const TRACE_NODE_HEIGHT = 56;
|
|
14
|
+
const TRACE_GAP_X = 24;
|
|
15
|
+
const TRACE_GAP_Y = 80;
|
|
16
|
+
const TRACE_MARGIN_TOP = 80;
|
|
17
|
+
const TRACE_MAX_COLS = 6; // 6 x (200+24) = 1344px fits standard 1440px viewport
|
|
18
|
+
|
|
19
|
+
// ── Category color coding ─────────────────────────────────────
|
|
20
|
+
type TraceCategory = 'mcp' | 'file' | 'subagent' | 'other';
|
|
21
|
+
|
|
22
|
+
function categorize(name: string, mcpServerName?: string | null): TraceCategory {
|
|
23
|
+
if (mcpServerName) return 'mcp';
|
|
24
|
+
const lower = name.toLowerCase();
|
|
25
|
+
if (
|
|
26
|
+
lower.includes('read') ||
|
|
27
|
+
lower.includes('write') ||
|
|
28
|
+
lower.includes('edit') ||
|
|
29
|
+
lower.includes('glob') ||
|
|
30
|
+
lower.includes('grep') ||
|
|
31
|
+
lower.includes('bash')
|
|
32
|
+
)
|
|
33
|
+
return 'file';
|
|
34
|
+
return 'other';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── ID generation ─────────────────────────────────────────────
|
|
38
|
+
let traceCounter = 0;
|
|
39
|
+
|
|
40
|
+
function nextTraceNodeId(): string {
|
|
41
|
+
return `trace-${Date.now().toString(36)}-${(traceCounter++).toString(36)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nextTraceEdgeId(): string {
|
|
45
|
+
return `tedge-${Date.now().toString(36)}-${(traceCounter++).toString(36)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Positioning ───────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function computeTraceOrigin(): { x: number; y: number } {
|
|
51
|
+
const layout = canvasState.getLayout();
|
|
52
|
+
let maxY = 0;
|
|
53
|
+
for (const node of layout.nodes) {
|
|
54
|
+
if (node.type === 'trace') continue;
|
|
55
|
+
const bottom = node.position.y + node.size.height;
|
|
56
|
+
if (bottom > maxY) maxY = bottom;
|
|
57
|
+
}
|
|
58
|
+
return { x: 40, y: maxY + TRACE_MARGIN_TOP };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── TraceManager class ────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
class TraceManager {
|
|
64
|
+
private _enabled = false;
|
|
65
|
+
private traceNodeIds: string[] = [];
|
|
66
|
+
private lastTraceNodeId: string | null = null;
|
|
67
|
+
private toolCallToNodeId = new Map<string, string>();
|
|
68
|
+
private traceOrigin: { x: number; y: number } | null = null;
|
|
69
|
+
private chainIndex = 0;
|
|
70
|
+
|
|
71
|
+
get enabled(): boolean {
|
|
72
|
+
return this._enabled;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setEnabled(value: boolean): void {
|
|
76
|
+
this._enabled = value;
|
|
77
|
+
if (value) {
|
|
78
|
+
this.traceOrigin = null; // recompute on next trace node
|
|
79
|
+
this.chainIndex = 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onToolStart(payload: {
|
|
84
|
+
name: string;
|
|
85
|
+
toolCallId?: string;
|
|
86
|
+
activity?: string;
|
|
87
|
+
mcpServerName?: string | null;
|
|
88
|
+
mcpToolName?: string | null;
|
|
89
|
+
}): void {
|
|
90
|
+
if (!this._enabled) return;
|
|
91
|
+
|
|
92
|
+
const id = nextTraceNodeId();
|
|
93
|
+
const category = categorize(payload.name, payload.mcpServerName);
|
|
94
|
+
const pos = this.nextPosition();
|
|
95
|
+
|
|
96
|
+
const node: CanvasNodeState = {
|
|
97
|
+
id,
|
|
98
|
+
type: 'trace',
|
|
99
|
+
position: pos,
|
|
100
|
+
size: { width: TRACE_NODE_WIDTH, height: TRACE_NODE_HEIGHT },
|
|
101
|
+
zIndex: 0,
|
|
102
|
+
collapsed: false,
|
|
103
|
+
pinned: true,
|
|
104
|
+
dockPosition: null,
|
|
105
|
+
data: {
|
|
106
|
+
toolName: payload.name,
|
|
107
|
+
category,
|
|
108
|
+
status: 'running',
|
|
109
|
+
activity: payload.activity ?? payload.name,
|
|
110
|
+
startedAt: Date.now(),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this.evictIfNeeded();
|
|
115
|
+
canvasState.addNode(node);
|
|
116
|
+
this.traceNodeIds.push(id);
|
|
117
|
+
|
|
118
|
+
// Flow edge from previous trace node
|
|
119
|
+
if (this.lastTraceNodeId) {
|
|
120
|
+
const edge: CanvasEdge = {
|
|
121
|
+
id: nextTraceEdgeId(),
|
|
122
|
+
from: this.lastTraceNodeId,
|
|
123
|
+
to: id,
|
|
124
|
+
type: 'flow',
|
|
125
|
+
animated: true,
|
|
126
|
+
};
|
|
127
|
+
canvasState.addEdge(edge);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (payload.toolCallId) {
|
|
131
|
+
this.toolCallToNodeId.set(payload.toolCallId, id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.lastTraceNodeId = id;
|
|
135
|
+
this.broadcastUpdate();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
onToolComplete(payload: {
|
|
139
|
+
name: string;
|
|
140
|
+
toolCallId?: string;
|
|
141
|
+
success?: boolean;
|
|
142
|
+
activity?: string;
|
|
143
|
+
error?: string;
|
|
144
|
+
}): void {
|
|
145
|
+
const nodeId = payload.toolCallId ? this.toolCallToNodeId.get(payload.toolCallId) : null;
|
|
146
|
+
if (!nodeId) return;
|
|
147
|
+
|
|
148
|
+
if (payload.toolCallId) {
|
|
149
|
+
this.toolCallToNodeId.delete(payload.toolCallId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const node = canvasState.getNode(nodeId);
|
|
153
|
+
if (!node || node.type !== 'trace') return;
|
|
154
|
+
|
|
155
|
+
const startedAt = (node.data.startedAt as number) || Date.now();
|
|
156
|
+
const durationMs = Math.max(0, Date.now() - startedAt);
|
|
157
|
+
const durationText =
|
|
158
|
+
durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
159
|
+
|
|
160
|
+
canvasState.updateNode(nodeId, {
|
|
161
|
+
data: {
|
|
162
|
+
...node.data,
|
|
163
|
+
status: payload.success === false ? 'failed' : 'success',
|
|
164
|
+
resultSummary: payload.activity ?? '',
|
|
165
|
+
error: payload.error,
|
|
166
|
+
duration: durationText,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Stop edge animation for completed edges ending at this node
|
|
171
|
+
for (const edge of canvasState.getEdgesForNode(nodeId)) {
|
|
172
|
+
if (edge.to === nodeId && edge.animated) {
|
|
173
|
+
canvasState.removeEdge(edge.id);
|
|
174
|
+
canvasState.addEdge({ ...edge, animated: false });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.broadcastUpdate();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
onSubagentStarted(payload: {
|
|
182
|
+
agentName: string;
|
|
183
|
+
agentDisplayName?: string;
|
|
184
|
+
}): void {
|
|
185
|
+
if (!this._enabled) return;
|
|
186
|
+
|
|
187
|
+
const id = nextTraceNodeId();
|
|
188
|
+
const origin = this.getOrigin();
|
|
189
|
+
// Subagent branch: offset below the full trace grid to avoid overlap
|
|
190
|
+
const parentNode = this.lastTraceNodeId ? canvasState.getNode(this.lastTraceNodeId) : null;
|
|
191
|
+
const gridRows = Math.floor(this.chainIndex / TRACE_MAX_COLS) + 1;
|
|
192
|
+
const gridBottomY = origin.y + gridRows * (TRACE_NODE_HEIGHT + TRACE_GAP_Y);
|
|
193
|
+
const pos = {
|
|
194
|
+
x: parentNode ? parentNode.position.x : origin.x,
|
|
195
|
+
y: gridBottomY,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const node: CanvasNodeState = {
|
|
199
|
+
id,
|
|
200
|
+
type: 'trace',
|
|
201
|
+
position: pos,
|
|
202
|
+
size: { width: TRACE_NODE_WIDTH, height: TRACE_NODE_HEIGHT },
|
|
203
|
+
zIndex: 0,
|
|
204
|
+
collapsed: false,
|
|
205
|
+
pinned: true,
|
|
206
|
+
dockPosition: null,
|
|
207
|
+
data: {
|
|
208
|
+
toolName: payload.agentDisplayName ?? payload.agentName,
|
|
209
|
+
category: 'subagent' as TraceCategory,
|
|
210
|
+
status: 'running',
|
|
211
|
+
activity: `Subagent: ${payload.agentDisplayName ?? payload.agentName}`,
|
|
212
|
+
startedAt: Date.now(),
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
this.evictIfNeeded();
|
|
217
|
+
canvasState.addNode(node);
|
|
218
|
+
this.traceNodeIds.push(id);
|
|
219
|
+
|
|
220
|
+
// Spawn edge from parent
|
|
221
|
+
if (this.lastTraceNodeId) {
|
|
222
|
+
const edge: CanvasEdge = {
|
|
223
|
+
id: nextTraceEdgeId(),
|
|
224
|
+
from: this.lastTraceNodeId,
|
|
225
|
+
to: id,
|
|
226
|
+
type: 'flow',
|
|
227
|
+
label: 'spawn',
|
|
228
|
+
animated: true,
|
|
229
|
+
};
|
|
230
|
+
canvasState.addEdge(edge);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.toolCallToNodeId.set(`subagent:${payload.agentName}`, id);
|
|
234
|
+
this.broadcastUpdate();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
onSubagentCompleted(payload: {
|
|
238
|
+
agentName: string;
|
|
239
|
+
agentDisplayName?: string;
|
|
240
|
+
durationMs?: number;
|
|
241
|
+
failed?: boolean;
|
|
242
|
+
}): void {
|
|
243
|
+
const nodeId = this.toolCallToNodeId.get(`subagent:${payload.agentName}`);
|
|
244
|
+
if (!nodeId) return;
|
|
245
|
+
this.toolCallToNodeId.delete(`subagent:${payload.agentName}`);
|
|
246
|
+
|
|
247
|
+
const node = canvasState.getNode(nodeId);
|
|
248
|
+
if (!node || node.type !== 'trace') return;
|
|
249
|
+
|
|
250
|
+
const startedAt = (node.data.startedAt as number) || Date.now();
|
|
251
|
+
const durationMs = payload.durationMs ?? Math.max(0, Date.now() - startedAt);
|
|
252
|
+
const durationText =
|
|
253
|
+
durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
254
|
+
|
|
255
|
+
canvasState.updateNode(nodeId, {
|
|
256
|
+
data: {
|
|
257
|
+
...node.data,
|
|
258
|
+
status: payload.failed ? 'failed' : 'success',
|
|
259
|
+
duration: durationText,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Stop edge animation
|
|
264
|
+
for (const edge of canvasState.getEdgesForNode(nodeId)) {
|
|
265
|
+
if (edge.to === nodeId && edge.animated) {
|
|
266
|
+
canvasState.removeEdge(edge.id);
|
|
267
|
+
canvasState.addEdge({ ...edge, animated: false });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.broadcastUpdate();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
clearTrace(): void {
|
|
275
|
+
const traceNodeIds = new Set(this.traceNodeIds);
|
|
276
|
+
for (const node of canvasState.getLayout().nodes) {
|
|
277
|
+
if (node.type === 'trace') {
|
|
278
|
+
traceNodeIds.add(node.id);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const id of traceNodeIds) {
|
|
283
|
+
canvasState.removeNode(id); // removeNode cascades edge deletion
|
|
284
|
+
}
|
|
285
|
+
this.traceNodeIds = [];
|
|
286
|
+
this.lastTraceNodeId = null;
|
|
287
|
+
this.toolCallToNodeId.clear();
|
|
288
|
+
this.traceOrigin = null;
|
|
289
|
+
this.chainIndex = 0;
|
|
290
|
+
this.broadcastUpdate();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
getTraceNodeCount(): number {
|
|
294
|
+
return canvasState.getLayout().nodes.filter((node) => node.type === 'trace').length;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Private helpers ───────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
private getOrigin(): { x: number; y: number } {
|
|
300
|
+
if (!this.traceOrigin) {
|
|
301
|
+
this.traceOrigin = computeTraceOrigin();
|
|
302
|
+
}
|
|
303
|
+
return this.traceOrigin;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private nextPosition(): { x: number; y: number } {
|
|
307
|
+
const origin = this.getOrigin();
|
|
308
|
+
const col = this.chainIndex % TRACE_MAX_COLS;
|
|
309
|
+
const row = Math.floor(this.chainIndex / TRACE_MAX_COLS);
|
|
310
|
+
const x = origin.x + col * (TRACE_NODE_WIDTH + TRACE_GAP_X);
|
|
311
|
+
const y = origin.y + row * (TRACE_NODE_HEIGHT + TRACE_GAP_Y);
|
|
312
|
+
this.chainIndex++;
|
|
313
|
+
return { x, y };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private evictIfNeeded(): void {
|
|
317
|
+
while (this.traceNodeIds.length >= MAX_TRACE_NODES) {
|
|
318
|
+
const oldest = this.traceNodeIds.shift();
|
|
319
|
+
if (oldest) {
|
|
320
|
+
canvasState.removeNode(oldest); // cascades edge deletion
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private broadcastUpdate(): void {
|
|
326
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', {
|
|
327
|
+
layout: canvasState.getLayout(),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Module-level singleton
|
|
333
|
+
export const traceManager = new TraceManager();
|