archgraph 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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/bin/bgx.js +2 -0
  4. package/docs/examples/executors.example.json +15 -0
  5. package/docs/examples/view-spec.yaml +6 -0
  6. package/docs/release.md +15 -0
  7. package/docs/view-spec.md +28 -0
  8. package/integrations/README.md +20 -0
  9. package/integrations/claude/.claude/skills/backend-graphing/SKILL.md +56 -0
  10. package/integrations/claude/.claude/skills/backend-graphing-describe/SKILL.md +50 -0
  11. package/integrations/claude/.claude-plugin/marketplace.json +18 -0
  12. package/integrations/claude/.claude-plugin/plugin.json +9 -0
  13. package/integrations/claude/skills/backend-graphing/SKILL.md +56 -0
  14. package/integrations/claude/skills/backend-graphing-describe/SKILL.md +50 -0
  15. package/integrations/codex/skills/backend-graphing/SKILL.md +56 -0
  16. package/integrations/codex/skills/backend-graphing-describe/SKILL.md +50 -0
  17. package/package.json +49 -0
  18. package/packages/cli/src/index.js +415 -0
  19. package/packages/core/src/analyze-project.js +1238 -0
  20. package/packages/core/src/export.js +77 -0
  21. package/packages/core/src/index.js +4 -0
  22. package/packages/core/src/types.js +37 -0
  23. package/packages/core/src/view.js +86 -0
  24. package/packages/viewer/public/app.js +226 -0
  25. package/packages/viewer/public/canvas.js +181 -0
  26. package/packages/viewer/public/comments.js +193 -0
  27. package/packages/viewer/public/index.html +95 -0
  28. package/packages/viewer/public/layout.js +72 -0
  29. package/packages/viewer/public/minimap.js +92 -0
  30. package/packages/viewer/public/render.js +366 -0
  31. package/packages/viewer/public/sidebar.js +107 -0
  32. package/packages/viewer/public/styles.css +728 -0
  33. package/packages/viewer/public/theme.js +19 -0
  34. package/packages/viewer/public/tooltip.js +44 -0
  35. package/packages/viewer/src/index.js +590 -0
@@ -0,0 +1,77 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+
3
+ function escapeCypher(value) {
4
+ return String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"');
5
+ }
6
+
7
+ function escapeMermaid(value) {
8
+ return String(value).replaceAll('"', '\\"');
9
+ }
10
+
11
+ function toCypher(graph) {
12
+ const lines = [];
13
+ lines.push('// backend-graphing cypher export');
14
+ lines.push('BEGIN;');
15
+
16
+ const nodes = [...graph.nodes].sort((a, b) => a.id.localeCompare(b.id));
17
+ for (const node of nodes) {
18
+ const label = `n_${node.kind}`.replace(/[^A-Za-z0-9_]/g, '_');
19
+ lines.push(`MERGE (n:${label} {id: "${escapeCypher(node.id)}"})`);
20
+ lines.push(`SET n.label = "${escapeCypher(node.label)}", n.kind = "${escapeCypher(node.kind)}"`);
21
+ if (node.file) {
22
+ lines.push(`SET n.file = "${escapeCypher(node.file)}"`);
23
+ }
24
+ if (node.symbol) {
25
+ lines.push(`SET n.symbol = "${escapeCypher(node.symbol)}"`);
26
+ }
27
+ if (node.external) {
28
+ lines.push('SET n.external = true');
29
+ }
30
+ }
31
+
32
+ const edges = [...graph.edges].sort((a, b) => a.id.localeCompare(b.id));
33
+ for (const edge of edges) {
34
+ const rel = `E_${edge.kind.toUpperCase()}`.replace(/[^A-Z0-9_]/g, '_');
35
+ lines.push(`MATCH (a {id: "${escapeCypher(edge.from)}"}), (b {id: "${escapeCypher(edge.to)}"})`);
36
+ lines.push(`MERGE (a)-[:${rel}]->(b)`);
37
+ }
38
+
39
+ lines.push('COMMIT;');
40
+ return `${lines.join('\n')}\n`;
41
+ }
42
+
43
+ function toMermaid(graph) {
44
+ const lines = [];
45
+ lines.push('graph TD');
46
+
47
+ const nodes = [...graph.nodes].sort((a, b) => a.id.localeCompare(b.id));
48
+ for (const node of nodes) {
49
+ lines.push(` ${node.id}["${escapeMermaid(node.label)}"]`);
50
+ }
51
+
52
+ const edges = [...graph.edges].sort((a, b) => a.id.localeCompare(b.id));
53
+ for (const edge of edges) {
54
+ lines.push(` ${edge.from} -- ${edge.kind} --> ${edge.to}`);
55
+ }
56
+
57
+ return `${lines.join('\n')}\n`;
58
+ }
59
+
60
+ export async function exportGraph(graph, format, outPath) {
61
+ if (format === 'graph-json') {
62
+ await writeFile(outPath, `${JSON.stringify(graph, null, 2)}\n`, 'utf8');
63
+ return;
64
+ }
65
+
66
+ if (format === 'cypher') {
67
+ await writeFile(outPath, toCypher(graph), 'utf8');
68
+ return;
69
+ }
70
+
71
+ if (format === 'mermaid') {
72
+ await writeFile(outPath, toMermaid(graph), 'utf8');
73
+ return;
74
+ }
75
+
76
+ throw new Error(`Unsupported format: ${format}`);
77
+ }
@@ -0,0 +1,4 @@
1
+ export { analyzeProject } from './analyze-project.js';
2
+ export { buildView } from './view.js';
3
+ export { exportGraph } from './export.js';
4
+ export { SUPPORTED_NODE_KINDS, SUPPORTED_EDGE_KINDS } from './types.js';
@@ -0,0 +1,37 @@
1
+ /** @typedef {'endpoint'|'route_handler'|'function'|'method'|'class'|'object'|'agent'|'langgraph_node'|'mcp_tool'|'module'|'file'|'ui_route'|'ui_component'|'group'|'condition'} NodeKind */
2
+ /** @typedef {'declares_endpoint'|'handles'|'calls'|'imports'|'invokes_agent'|'uses_mcp_tool'|'langgraph_edge'|'belongs_to_group'|'ui_calls_api'|'frontend_proxies_to_backend'|'evaluates_condition'|'condition_true'|'condition_false'|'condition_case'} EdgeKind */
3
+
4
+ export const SUPPORTED_NODE_KINDS = [
5
+ 'endpoint',
6
+ 'route_handler',
7
+ 'function',
8
+ 'method',
9
+ 'class',
10
+ 'object',
11
+ 'agent',
12
+ 'langgraph_node',
13
+ 'mcp_tool',
14
+ 'module',
15
+ 'file',
16
+ 'ui_route',
17
+ 'ui_component',
18
+ 'group',
19
+ 'condition',
20
+ ];
21
+
22
+ export const SUPPORTED_EDGE_KINDS = [
23
+ 'declares_endpoint',
24
+ 'handles',
25
+ 'calls',
26
+ 'imports',
27
+ 'invokes_agent',
28
+ 'uses_mcp_tool',
29
+ 'langgraph_edge',
30
+ 'belongs_to_group',
31
+ 'ui_calls_api',
32
+ 'frontend_proxies_to_backend',
33
+ 'evaluates_condition',
34
+ 'condition_true',
35
+ 'condition_false',
36
+ 'condition_case',
37
+ ];
@@ -0,0 +1,86 @@
1
+ function buildAdjacency(edges) {
2
+ const out = new Map();
3
+ for (const edge of edges) {
4
+ if (!out.has(edge.from)) out.set(edge.from, []);
5
+ out.get(edge.from).push(edge.to);
6
+ if (!out.has(edge.to)) out.set(edge.to, []);
7
+ }
8
+ return out;
9
+ }
10
+
11
+ function bfs(startNodes, adjacency, depth) {
12
+ const visited = new Set();
13
+ const queue = startNodes.map((id) => ({ id, depth: 0 }));
14
+
15
+ while (queue.length) {
16
+ const current = queue.shift();
17
+ if (visited.has(current.id)) continue;
18
+ visited.add(current.id);
19
+ if (current.depth >= depth) continue;
20
+ for (const next of adjacency.get(current.id) ?? []) {
21
+ if (!visited.has(next)) {
22
+ queue.push({ id: next, depth: current.depth + 1 });
23
+ }
24
+ }
25
+ }
26
+
27
+ return visited;
28
+ }
29
+
30
+ export function buildView(graph, spec) {
31
+ const depth = Number.isFinite(spec.depth) ? Number(spec.depth) : 2;
32
+ const includeKinds = spec.includeNodeKinds?.length ? new Set(spec.includeNodeKinds) : null;
33
+ const excludeExternal = Boolean(spec.excludeExternal);
34
+
35
+ const adjacency = buildAdjacency(graph.edges);
36
+
37
+ let startNodes;
38
+ if (spec.focus) {
39
+ startNodes = [spec.focus];
40
+ } else {
41
+ startNodes = graph.nodes.filter((n) => n.kind === 'endpoint').map((n) => n.id);
42
+ if (!startNodes.length) {
43
+ startNodes = graph.nodes.slice(0, 1).map((n) => n.id);
44
+ }
45
+ }
46
+
47
+ const visible = bfs(startNodes, adjacency, Math.max(depth, 0));
48
+
49
+ let nodes = graph.nodes.filter((node) => visible.has(node.id));
50
+ if (includeKinds) {
51
+ nodes = nodes.filter((node) => includeKinds.has(node.kind));
52
+ }
53
+ if (excludeExternal) {
54
+ nodes = nodes.filter((node) => !node.external);
55
+ }
56
+
57
+ const nodeIds = new Set(nodes.map((n) => n.id));
58
+ const edges = graph.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to));
59
+
60
+ let groups = graph.groups;
61
+ if (spec.groupBy) {
62
+ groups = groups.filter((group) => group.kind === spec.groupBy);
63
+ }
64
+ groups = groups
65
+ .map((group) => ({
66
+ ...group,
67
+ memberNodeIds: group.memberNodeIds.filter((id) => nodeIds.has(id)),
68
+ }))
69
+ .filter((group) => group.memberNodeIds.length > 0);
70
+
71
+ return {
72
+ spec: {
73
+ version: '1',
74
+ depth,
75
+ focus: spec.focus,
76
+ groupBy: spec.groupBy,
77
+ includeNodeKinds: spec.includeNodeKinds,
78
+ excludeExternal,
79
+ collapsePatterns: spec.collapsePatterns,
80
+ labelMode: spec.labelMode,
81
+ },
82
+ nodes,
83
+ edges,
84
+ groups,
85
+ };
86
+ }
@@ -0,0 +1,226 @@
1
+ // app.js — BGX Viewer entry point
2
+
3
+ import { initCanvas, canvasTransform, nodePositions, applyCanvasTransform } from './canvas.js';
4
+ import { initMinimap, setMinimapView, updateMinimap } from './minimap.js';
5
+ import { initTooltip } from './tooltip.js';
6
+ import { initSidebar, openCodeSidebar } from './sidebar.js';
7
+ import { initComments } from './comments.js';
8
+ import {
9
+ initRender,
10
+ setRenderView,
11
+ setSelectedNode,
12
+ setCurrentTasks,
13
+ renderGraph,
14
+ renderCommentPins,
15
+ applyCollapse,
16
+ } from './render.js';
17
+ import { initTheme, toggleTheme } from './theme.js';
18
+
19
+ // ─── Global state ─────────────────────────────────────────────────────────────
20
+ let graph = null;
21
+ let view = null;
22
+ let descriptions = null;
23
+ let selectedNodeId = null;
24
+ let currentTasks = [];
25
+ let currentNodes = [];
26
+ let currentEdges = [];
27
+
28
+ // ─── DOM refs ──────────────────────────────────────────────────────────────────
29
+ const graphSvg = document.getElementById('graphSvg');
30
+ const canvasGroup = document.getElementById('canvasGroup');
31
+ const groupByInput = document.getElementById('groupBy');
32
+ const groupItemInput = document.getElementById('groupItem');
33
+ const searchInput = document.getElementById('search');
34
+ const summaryBadge = document.getElementById('summaryBadge');
35
+ const projectNameEl = document.getElementById('projectName');
36
+ const resetViewBtn = document.getElementById('resetViewBtn');
37
+ const themeToggleBtn = document.getElementById('themeToggleBtn');
38
+
39
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
40
+ function escapeHtml(value) {
41
+ return String(value)
42
+ .replaceAll('&', '&')
43
+ .replaceAll('<', '&lt;')
44
+ .replaceAll('>', '&gt;')
45
+ .replaceAll('"', '&quot;')
46
+ .replaceAll("'", '&#39;');
47
+ }
48
+
49
+ // ─── Group/filter helpers ─────────────────────────────────────────────────────
50
+ function listGroups(groupBy) {
51
+ const groups = view.groups.filter((g) => g.kind === groupBy);
52
+ const previous = groupItemInput.value;
53
+
54
+ groupItemInput.innerHTML = ['<option value="">All</option>']
55
+ .concat(
56
+ groups
57
+ .slice()
58
+ .sort((a, b) => a.name.localeCompare(b.name))
59
+ .map((g) => `<option value="${escapeHtml(g.id)}">${escapeHtml(g.name)}</option>`),
60
+ )
61
+ .join('');
62
+
63
+ if (previous && groups.some((g) => g.id === previous)) {
64
+ groupItemInput.value = previous;
65
+ }
66
+ }
67
+
68
+ function getVisibleGraph() {
69
+ const groupBy = groupByInput.value;
70
+ const search = searchInput.value.trim().toLowerCase();
71
+
72
+ const groups = view.groups.filter((g) => g.kind === groupBy);
73
+ const selectedGroup = groups.find((g) => g.id === groupItemInput.value) ?? null;
74
+
75
+ let visibleSet = null;
76
+ if (selectedGroup) {
77
+ visibleSet = new Set(selectedGroup.memberNodeIds);
78
+ }
79
+
80
+ let nodes = view.nodes.filter((node) => (visibleSet ? visibleSet.has(node.id) : true));
81
+
82
+ if (search) {
83
+ nodes = nodes.filter(
84
+ (node) =>
85
+ node.label.toLowerCase().includes(search) || node.kind.toLowerCase().includes(search),
86
+ );
87
+ }
88
+
89
+ const nodeIds = new Set(nodes.map((n) => n.id));
90
+ const edges = view.edges.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to));
91
+
92
+ return applyCollapse(nodes, edges);
93
+ }
94
+
95
+ // ─── Node selection ───────────────────────────────────────────────────────────
96
+ async function selectNode(nodeId) {
97
+ selectedNodeId = nodeId;
98
+ setSelectedNode(nodeId);
99
+
100
+ // Re-render to show selection highlight
101
+ renderGraph(currentNodes, currentEdges);
102
+ renderCommentPins(currentTasks);
103
+
104
+ try {
105
+ const response = await fetch(`/api/node/${encodeURIComponent(nodeId)}/code`);
106
+ const payload = await response.json();
107
+ openCodeSidebar(payload, descriptions);
108
+ } catch (error) {
109
+ console.error('Failed to load code for node:', error);
110
+ }
111
+ }
112
+
113
+ // ─── Task loading ─────────────────────────────────────────────────────────────
114
+ async function loadTasks() {
115
+ try {
116
+ const response = await fetch('/api/tasks');
117
+ const payload = await response.json();
118
+ currentTasks = payload.tasks || [];
119
+ setCurrentTasks(currentTasks);
120
+ renderCommentPins(currentTasks);
121
+ } catch (error) {
122
+ console.error('Failed to load tasks:', error);
123
+ }
124
+ }
125
+
126
+ // ─── Main render ──────────────────────────────────────────────────────────────
127
+ async function render() {
128
+ const { nodes, edges } = getVisibleGraph();
129
+ currentNodes = nodes;
130
+ currentEdges = edges;
131
+
132
+ summaryBadge.textContent = `${nodes.length} nodes · ${edges.length} edges`;
133
+
134
+ renderGraph(nodes, edges);
135
+ renderCommentPins(currentTasks);
136
+ updateMinimap(canvasTransform, nodePositions, graphSvg);
137
+ }
138
+
139
+ // ─── Data loading ─────────────────────────────────────────────────────────────
140
+ async function loadData() {
141
+ const [graphRes, viewRes, descRes] = await Promise.all([
142
+ fetch('/api/graph'),
143
+ fetch('/api/view'),
144
+ fetch('/api/descriptions'),
145
+ ]);
146
+ graph = await graphRes.json();
147
+ view = await viewRes.json();
148
+ descriptions = await descRes.json();
149
+
150
+ projectNameEl.textContent = graph.project?.name ?? 'BGX Viewer';
151
+ document.title = `BGX — ${graph.project?.name ?? 'Viewer'}`;
152
+
153
+ setRenderView(view);
154
+ setMinimapView(view);
155
+
156
+ listGroups(groupByInput.value);
157
+ await render();
158
+ await loadTasks();
159
+ }
160
+
161
+ // ─── Reset view ───────────────────────────────────────────────────────────────
162
+ function doResetView() {
163
+ nodePositions.clear();
164
+ canvasTransform.x = 60;
165
+ canvasTransform.y = 60;
166
+ canvasTransform.scale = 1.0;
167
+ applyCanvasTransform();
168
+ renderGraph(currentNodes, currentEdges);
169
+ renderCommentPins(currentTasks);
170
+ }
171
+
172
+ // ─── Global hooks (used by render.js and minimap.js to avoid circular deps) ───
173
+ window.__bgxPanToWorld = (worldX, worldY) => {
174
+ const rect = graphSvg.getBoundingClientRect();
175
+ canvasTransform.x = rect.width / 2 - worldX * canvasTransform.scale;
176
+ canvasTransform.y = rect.height / 2 - worldY * canvasTransform.scale;
177
+ applyCanvasTransform();
178
+ };
179
+
180
+ window.__bgxRender = async () => {
181
+ await render();
182
+ };
183
+
184
+ // ─── Initialization ───────────────────────────────────────────────────────────
185
+ initTheme();
186
+ initTooltip();
187
+
188
+ initCanvas(graphSvg, canvasGroup, {
189
+ onNodeClick: (nodeId) => selectNode(nodeId),
190
+ onResetView: doResetView,
191
+ });
192
+
193
+ initRender(graphSvg, canvasGroup, {
194
+ onSelect: (nodeId) => selectNode(nodeId),
195
+ });
196
+
197
+ initSidebar();
198
+
199
+ initComments({
200
+ onReload: async () => {
201
+ await loadTasks();
202
+ },
203
+ });
204
+
205
+ initMinimap(null); // view passed after load via setMinimapView
206
+
207
+ resetViewBtn.addEventListener('click', doResetView);
208
+ themeToggleBtn.addEventListener('click', toggleTheme);
209
+
210
+ groupByInput.addEventListener('change', async () => {
211
+ listGroups(groupByInput.value);
212
+ await render();
213
+ });
214
+
215
+ groupItemInput.addEventListener('change', async () => {
216
+ await render();
217
+ });
218
+
219
+ searchInput.addEventListener('input', async () => {
220
+ await render();
221
+ });
222
+
223
+ loadData().catch((error) => {
224
+ summaryBadge.textContent = `Failed to load: ${String(error)}`;
225
+ summaryBadge.style.color = '#b42318';
226
+ });
@@ -0,0 +1,181 @@
1
+ // canvas.js — Pan, zoom, and node drag system
2
+
3
+ import { updateMinimap } from './minimap.js';
4
+ import { hideTooltip } from './tooltip.js';
5
+ import { closeCommentOverlay } from './comments.js';
6
+
7
+ export const canvasTransform = { x: 60, y: 60, scale: 1.0 };
8
+ export const nodePositions = new Map(); // nodeId → {x, y}
9
+
10
+ let graphSvg = null;
11
+ let canvasGroup = null;
12
+ let onNodeClick = null; // callback(nodeId)
13
+ let onResetView = null; // callback()
14
+
15
+ let isPanning = false;
16
+ let panStart = { x: 0, y: 0, tx: 0, ty: 0 };
17
+
18
+ let isDraggingNode = false;
19
+ let draggingNodeId = null;
20
+ let hasDragged = false;
21
+ let dragStart = { mx: 0, my: 0, nx: 0, ny: 0 };
22
+ let dragPointerId = null;
23
+
24
+ export function initCanvas(svgEl, groupEl, { onNodeClick: clickCb, onResetView: resetCb }) {
25
+ graphSvg = svgEl;
26
+ canvasGroup = groupEl;
27
+ onNodeClick = clickCb;
28
+ onResetView = resetCb;
29
+
30
+ graphSvg.addEventListener('pointerdown', onCanvasPointerDown);
31
+ graphSvg.addEventListener('pointermove', onCanvasPointerMove);
32
+ graphSvg.addEventListener('pointerup', onCanvasPointerUp);
33
+ graphSvg.addEventListener('pointercancel', onCanvasPointerUp);
34
+
35
+ graphSvg.addEventListener('wheel', onCanvasWheel, { passive: false });
36
+
37
+ document.addEventListener('keydown', onKeyDown);
38
+
39
+ applyCanvasTransform();
40
+ }
41
+
42
+ export function applyCanvasTransform() {
43
+ const { x, y, scale } = canvasTransform;
44
+ canvasGroup.setAttribute('transform', `translate(${x},${y}) scale(${scale})`);
45
+ updateMinimap(canvasTransform, nodePositions, graphSvg);
46
+ }
47
+
48
+ export function resetView(nodes, edges, renderGraphFn) {
49
+ nodePositions.clear();
50
+ canvasTransform.x = 60;
51
+ canvasTransform.y = 60;
52
+ canvasTransform.scale = 1.0;
53
+ applyCanvasTransform();
54
+ renderGraphFn(nodes, edges);
55
+ }
56
+
57
+ function getSvgRect() {
58
+ return graphSvg.getBoundingClientRect();
59
+ }
60
+
61
+ function clientToWorld(clientX, clientY) {
62
+ const rect = getSvgRect();
63
+ return {
64
+ x: (clientX - rect.left - canvasTransform.x) / canvasTransform.scale,
65
+ y: (clientY - rect.top - canvasTransform.y) / canvasTransform.scale,
66
+ };
67
+ }
68
+
69
+ function onCanvasPointerDown(e) {
70
+ // Check if we're clicking on a node
71
+ const nodeGroup = e.target.closest('.node');
72
+ if (nodeGroup) {
73
+ // Node drag
74
+ e.stopPropagation();
75
+ const nodeId = nodeGroup.dataset.nodeId;
76
+ if (!nodeId) return;
77
+
78
+ isDraggingNode = true;
79
+ draggingNodeId = nodeId;
80
+ hasDragged = false;
81
+ dragPointerId = e.pointerId;
82
+
83
+ const world = clientToWorld(e.clientX, e.clientY);
84
+ const pos = nodePositions.get(nodeId) ?? { x: 0, y: 0 };
85
+ dragStart = { mx: world.x, my: world.y, nx: pos.x, ny: pos.y };
86
+
87
+ graphSvg.setPointerCapture(e.pointerId);
88
+ hideTooltip();
89
+ return;
90
+ }
91
+
92
+ // Check collapse toggle — handled by render.js
93
+ if (e.target.closest('.collapse-toggle')) return;
94
+ // Check comment pin — handled by render.js
95
+ if (e.target.closest('.comment-pin')) return;
96
+
97
+ // Canvas pan
98
+ isPanning = true;
99
+ panStart = { x: e.clientX, y: e.clientY, tx: canvasTransform.x, ty: canvasTransform.y };
100
+ graphSvg.setPointerCapture(e.pointerId);
101
+ graphSvg.style.cursor = 'grabbing';
102
+ closeCommentOverlay();
103
+ }
104
+
105
+ function onCanvasPointerMove(e) {
106
+ if (isDraggingNode && e.pointerId === dragPointerId) {
107
+ const world = clientToWorld(e.clientX, e.clientY);
108
+ const dx = world.x - dragStart.mx;
109
+ const dy = world.y - dragStart.my;
110
+
111
+ if (!hasDragged && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
112
+ hasDragged = true;
113
+ }
114
+
115
+ if (hasDragged) {
116
+ nodePositions.set(draggingNodeId, {
117
+ x: dragStart.nx + dx,
118
+ y: dragStart.ny + dy,
119
+ });
120
+ // repositionNodeAndEdges is imported dynamically to avoid circular deps
121
+ if (window.__bgxRepositionNode) {
122
+ window.__bgxRepositionNode(draggingNodeId);
123
+ }
124
+ }
125
+ return;
126
+ }
127
+
128
+ if (isPanning) {
129
+ const dx = e.clientX - panStart.x;
130
+ const dy = e.clientY - panStart.y;
131
+ canvasTransform.x = panStart.tx + dx;
132
+ canvasTransform.y = panStart.ty + dy;
133
+ applyCanvasTransform();
134
+ }
135
+ }
136
+
137
+ function onCanvasPointerUp(e) {
138
+ if (isDraggingNode && e.pointerId === dragPointerId) {
139
+ if (!hasDragged && onNodeClick) {
140
+ onNodeClick(draggingNodeId);
141
+ }
142
+ isDraggingNode = false;
143
+ draggingNodeId = null;
144
+ hasDragged = false;
145
+ dragPointerId = null;
146
+ graphSvg.releasePointerCapture(e.pointerId);
147
+ return;
148
+ }
149
+
150
+ if (isPanning) {
151
+ isPanning = false;
152
+ graphSvg.style.cursor = '';
153
+ graphSvg.releasePointerCapture(e.pointerId);
154
+ }
155
+ }
156
+
157
+ function onCanvasWheel(e) {
158
+ e.preventDefault();
159
+
160
+ const factor = e.deltaY < 0 ? 1.1 : 0.9;
161
+ const rect = getSvgRect();
162
+ const mouseX = e.clientX - rect.left;
163
+ const mouseY = e.clientY - rect.top;
164
+
165
+ const newScale = Math.min(4.0, Math.max(0.1, canvasTransform.scale * factor));
166
+
167
+ canvasTransform.x = mouseX - (mouseX - canvasTransform.x) * (newScale / canvasTransform.scale);
168
+ canvasTransform.y = mouseY - (mouseY - canvasTransform.y) * (newScale / canvasTransform.scale);
169
+ canvasTransform.scale = newScale;
170
+
171
+ applyCanvasTransform();
172
+ }
173
+
174
+ function onKeyDown(e) {
175
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
176
+
177
+ if (e.key === ' ') {
178
+ e.preventDefault();
179
+ if (onResetView) onResetView();
180
+ }
181
+ }