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.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/bin/bgx.js +2 -0
- package/docs/examples/executors.example.json +15 -0
- package/docs/examples/view-spec.yaml +6 -0
- package/docs/release.md +15 -0
- package/docs/view-spec.md +28 -0
- package/integrations/README.md +20 -0
- package/integrations/claude/.claude/skills/backend-graphing/SKILL.md +56 -0
- package/integrations/claude/.claude/skills/backend-graphing-describe/SKILL.md +50 -0
- package/integrations/claude/.claude-plugin/marketplace.json +18 -0
- package/integrations/claude/.claude-plugin/plugin.json +9 -0
- package/integrations/claude/skills/backend-graphing/SKILL.md +56 -0
- package/integrations/claude/skills/backend-graphing-describe/SKILL.md +50 -0
- package/integrations/codex/skills/backend-graphing/SKILL.md +56 -0
- package/integrations/codex/skills/backend-graphing-describe/SKILL.md +50 -0
- package/package.json +49 -0
- package/packages/cli/src/index.js +415 -0
- package/packages/core/src/analyze-project.js +1238 -0
- package/packages/core/src/export.js +77 -0
- package/packages/core/src/index.js +4 -0
- package/packages/core/src/types.js +37 -0
- package/packages/core/src/view.js +86 -0
- package/packages/viewer/public/app.js +226 -0
- package/packages/viewer/public/canvas.js +181 -0
- package/packages/viewer/public/comments.js +193 -0
- package/packages/viewer/public/index.html +95 -0
- package/packages/viewer/public/layout.js +72 -0
- package/packages/viewer/public/minimap.js +92 -0
- package/packages/viewer/public/render.js +366 -0
- package/packages/viewer/public/sidebar.js +107 -0
- package/packages/viewer/public/styles.css +728 -0
- package/packages/viewer/public/theme.js +19 -0
- package/packages/viewer/public/tooltip.js +44 -0
- package/packages/viewer/src/index.js +590 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// comments.js — Comment thread overlay + task CRUD
|
|
2
|
+
|
|
3
|
+
let overlayEl = null;
|
|
4
|
+
let commentThread = null;
|
|
5
|
+
let commentOverlayTitle = null;
|
|
6
|
+
let closeCommentOverlayBtn = null;
|
|
7
|
+
let taskForm = null;
|
|
8
|
+
let taskPromptInput = null;
|
|
9
|
+
let executorInput = null;
|
|
10
|
+
|
|
11
|
+
let currentOverlayNodeId = null;
|
|
12
|
+
let onTasksReloaded = null; // callback to refresh pins
|
|
13
|
+
|
|
14
|
+
export function initComments({ onReload }) {
|
|
15
|
+
overlayEl = document.getElementById('commentOverlay');
|
|
16
|
+
commentThread = document.getElementById('commentThread');
|
|
17
|
+
commentOverlayTitle = document.getElementById('commentOverlayTitle');
|
|
18
|
+
closeCommentOverlayBtn = document.getElementById('closeCommentOverlayBtn');
|
|
19
|
+
taskForm = document.getElementById('taskForm');
|
|
20
|
+
taskPromptInput = document.getElementById('taskPrompt');
|
|
21
|
+
executorInput = document.getElementById('taskExecutor');
|
|
22
|
+
|
|
23
|
+
onTasksReloaded = onReload;
|
|
24
|
+
|
|
25
|
+
closeCommentOverlayBtn.addEventListener('click', closeCommentOverlay);
|
|
26
|
+
|
|
27
|
+
document.addEventListener('keydown', (e) => {
|
|
28
|
+
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
|
29
|
+
if (e.key === 'Escape') {
|
|
30
|
+
closeCommentOverlay();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
taskForm.addEventListener('submit', async (e) => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
if (!currentOverlayNodeId) return;
|
|
37
|
+
|
|
38
|
+
const userPrompt = taskPromptInput.value.trim();
|
|
39
|
+
if (!userPrompt) return;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await fetch('/api/tasks', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
nodeId: currentOverlayNodeId,
|
|
47
|
+
userPrompt,
|
|
48
|
+
executor: executorInput.value,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
taskPromptInput.value = '';
|
|
52
|
+
if (onTasksReloaded) await onTasksReloaded();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
alert(`Failed to create task: ${String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function openCommentOverlay(nodeId, tasks, worldPos, canvasTransform, graphSvg) {
|
|
60
|
+
currentOverlayNodeId = nodeId;
|
|
61
|
+
commentOverlayTitle.textContent = tasks.length
|
|
62
|
+
? `${tasks.length} comment${tasks.length > 1 ? 's' : ''}`
|
|
63
|
+
: 'New comment';
|
|
64
|
+
|
|
65
|
+
renderTasks(tasks);
|
|
66
|
+
|
|
67
|
+
// Position overlay near the pin in screen coords
|
|
68
|
+
const rect = graphSvg.getBoundingClientRect();
|
|
69
|
+
const screenX = rect.left + (worldPos.x + 90) * canvasTransform.scale + canvasTransform.x;
|
|
70
|
+
const screenY = rect.top + (worldPos.y - 36) * canvasTransform.scale + canvasTransform.y;
|
|
71
|
+
|
|
72
|
+
const OW = 320;
|
|
73
|
+
const OH = 440;
|
|
74
|
+
let left = screenX + 16;
|
|
75
|
+
let top = screenY - OH / 2;
|
|
76
|
+
|
|
77
|
+
if (left + OW > window.innerWidth - 8) left = screenX - OW - 16;
|
|
78
|
+
top = Math.max(8, Math.min(top, window.innerHeight - OH - 8));
|
|
79
|
+
|
|
80
|
+
overlayEl.style.left = `${left}px`;
|
|
81
|
+
overlayEl.style.top = `${top}px`;
|
|
82
|
+
overlayEl.classList.add('overlay--open');
|
|
83
|
+
overlayEl.setAttribute('aria-hidden', 'false');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function closeCommentOverlay() {
|
|
87
|
+
if (!overlayEl) return;
|
|
88
|
+
overlayEl.classList.remove('overlay--open');
|
|
89
|
+
overlayEl.setAttribute('aria-hidden', 'true');
|
|
90
|
+
currentOverlayNodeId = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isCommentOverlayOpen() {
|
|
94
|
+
return overlayEl?.classList.contains('overlay--open') ?? false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderTasksInOverlay(tasks) {
|
|
98
|
+
if (!commentThread) return;
|
|
99
|
+
renderTasks(tasks);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderTasks(tasks) {
|
|
103
|
+
const rows = tasks
|
|
104
|
+
.map(
|
|
105
|
+
(task) => `
|
|
106
|
+
<div class="task-card" data-task-id="${escHtml(task.id)}">
|
|
107
|
+
<div class="task-head">
|
|
108
|
+
<span class="task-id">${escHtml(task.id)}</span>
|
|
109
|
+
<span class="status status-${escHtml(task.status.toLowerCase())}">${escHtml(task.status)}</span>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="task-meta">${escHtml(task.target?.symbol ?? task.target?.nodeId ?? '-')}</div>
|
|
112
|
+
<div class="task-prompt">${escHtml(task.userPrompt)}</div>
|
|
113
|
+
<div class="task-actions">
|
|
114
|
+
<button data-action="approve">Approve</button>
|
|
115
|
+
<button data-action="run">Run</button>
|
|
116
|
+
<button data-action="logs">Logs</button>
|
|
117
|
+
<button data-action="delete">Remove</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
`,
|
|
121
|
+
)
|
|
122
|
+
.join('');
|
|
123
|
+
|
|
124
|
+
commentThread.innerHTML = rows || '<small class="no-tasks">No comments yet.</small>';
|
|
125
|
+
|
|
126
|
+
for (const button of commentThread.querySelectorAll('button[data-action]')) {
|
|
127
|
+
button.addEventListener('click', async (event) => {
|
|
128
|
+
const taskCard = event.target.closest('.task-card');
|
|
129
|
+
const taskId = taskCard?.dataset?.taskId;
|
|
130
|
+
if (!taskId) return;
|
|
131
|
+
const action = event.target.dataset.action;
|
|
132
|
+
await handleTaskAction(taskId, action);
|
|
133
|
+
if (onTasksReloaded) await onTasksReloaded();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function handleTaskAction(taskId, action) {
|
|
139
|
+
try {
|
|
140
|
+
if (action === 'approve') {
|
|
141
|
+
await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
142
|
+
method: 'PATCH',
|
|
143
|
+
headers: { 'Content-Type': 'application/json' },
|
|
144
|
+
body: JSON.stringify({ status: 'Approved' }),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (action === 'run') {
|
|
149
|
+
await fetch(`/api/tasks/${encodeURIComponent(taskId)}/run`, { method: 'POST' });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (action === 'delete') {
|
|
153
|
+
await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { method: 'DELETE' });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (action === 'logs') {
|
|
157
|
+
const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/logs`);
|
|
158
|
+
const payload = await response.json();
|
|
159
|
+
const message = (payload.logs || [])
|
|
160
|
+
.slice(-5)
|
|
161
|
+
.map((log) => `${log.at} ${log.type}: ${(log.message || '').slice(0, 120)}`)
|
|
162
|
+
.join('\n');
|
|
163
|
+
alert(message || 'No logs');
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
alert(`Task action failed: ${String(error)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Status priority for pin coloring (higher index = more urgent)
|
|
171
|
+
const STATUS_PRIORITY = ['done', 'approved', 'open', 'running', 'failed'];
|
|
172
|
+
|
|
173
|
+
export function getMostUrgentStatus(tasks) {
|
|
174
|
+
let best = 'open';
|
|
175
|
+
let bestIdx = STATUS_PRIORITY.indexOf('open');
|
|
176
|
+
for (const task of tasks) {
|
|
177
|
+
const idx = STATUS_PRIORITY.indexOf(task.status.toLowerCase());
|
|
178
|
+
if (idx > bestIdx) {
|
|
179
|
+
best = task.status.toLowerCase();
|
|
180
|
+
bestIdx = idx;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return best;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function escHtml(value) {
|
|
187
|
+
return String(value)
|
|
188
|
+
.replaceAll('&', '&')
|
|
189
|
+
.replaceAll('<', '<')
|
|
190
|
+
.replaceAll('>', '>')
|
|
191
|
+
.replaceAll('"', '"')
|
|
192
|
+
.replaceAll("'", ''');
|
|
193
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>BGX Viewer</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
|
|
11
|
+
<!-- Layer 1: Full-screen canvas -->
|
|
12
|
+
<div id="canvasRoot">
|
|
13
|
+
<svg id="graphSvg" role="img" aria-label="Graph" xmlns="http://www.w3.org/2000/svg">
|
|
14
|
+
<g id="canvasGroup"></g>
|
|
15
|
+
</svg>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<!-- Layer 2: Floating toolbar (Figma-style top bar) -->
|
|
19
|
+
<div id="toolbar">
|
|
20
|
+
<span id="projectName" class="toolbar-title">BGX Viewer</span>
|
|
21
|
+
|
|
22
|
+
<div id="toolbarControls">
|
|
23
|
+
<label class="ctrl-label">
|
|
24
|
+
<span class="ctrl-name">Group</span>
|
|
25
|
+
<select id="groupBy">
|
|
26
|
+
<option value="endpoint">Endpoint</option>
|
|
27
|
+
<option value="agent">Agent</option>
|
|
28
|
+
<option value="purpose">Purpose</option>
|
|
29
|
+
</select>
|
|
30
|
+
</label>
|
|
31
|
+
<label class="ctrl-label">
|
|
32
|
+
<span class="ctrl-name">Filter</span>
|
|
33
|
+
<select id="groupItem">
|
|
34
|
+
<option value="">All</option>
|
|
35
|
+
</select>
|
|
36
|
+
</label>
|
|
37
|
+
<label class="ctrl-label ctrl-search">
|
|
38
|
+
<span class="ctrl-name">Search</span>
|
|
39
|
+
<input id="search" type="text" placeholder="node label or kind…" />
|
|
40
|
+
</label>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div id="toolbarActions">
|
|
44
|
+
<button id="resetViewBtn" title="Reset canvas view (Space)" class="btn-ghost">↻ Reset</button>
|
|
45
|
+
<button id="themeToggleBtn" title="Toggle dark / light mode" class="btn-ghost">◑ Theme</button>
|
|
46
|
+
<span id="summaryBadge" class="summary-badge"></span>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- Layer 3: Code sidebar (slides in from right) -->
|
|
51
|
+
<aside id="codeSidebar" class="sidebar" aria-hidden="true">
|
|
52
|
+
<div class="sidebar__header">
|
|
53
|
+
<div class="sidebar__title">
|
|
54
|
+
<span id="sidebarNodeTitle"></span>
|
|
55
|
+
</div>
|
|
56
|
+
<button id="closeSidebarBtn" class="btn-icon" title="Close sidebar (Esc)">✕</button>
|
|
57
|
+
</div>
|
|
58
|
+
<div id="sidebarContent" class="sidebar__body"></div>
|
|
59
|
+
</aside>
|
|
60
|
+
|
|
61
|
+
<!-- Layer 4: Comment thread overlay (floats near pin) -->
|
|
62
|
+
<div id="commentOverlay" class="overlay" aria-hidden="true">
|
|
63
|
+
<div class="overlay__header">
|
|
64
|
+
<strong id="commentOverlayTitle">Comments</strong>
|
|
65
|
+
<button id="closeCommentOverlayBtn" class="btn-icon" title="Close">✕</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div id="commentThread" class="overlay__thread"></div>
|
|
68
|
+
<div class="overlay__form">
|
|
69
|
+
<form id="taskForm">
|
|
70
|
+
<div class="form-row">
|
|
71
|
+
<label class="ctrl-label">
|
|
72
|
+
<span class="ctrl-name">Executor</span>
|
|
73
|
+
<select id="taskExecutor">
|
|
74
|
+
<option value="claude">Claude</option>
|
|
75
|
+
<option value="codex">Codex</option>
|
|
76
|
+
</select>
|
|
77
|
+
</label>
|
|
78
|
+
</div>
|
|
79
|
+
<textarea id="taskPrompt" rows="3" placeholder="Describe a requested change or question…"></textarea>
|
|
80
|
+
<button type="submit" class="btn-primary">Add Comment</button>
|
|
81
|
+
</form>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Layer 5: Minimap -->
|
|
86
|
+
<div id="minimap">
|
|
87
|
+
<svg id="minimapSvg" xmlns="http://www.w3.org/2000/svg"></svg>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- Layer 6: Node tooltip -->
|
|
91
|
+
<div id="nodeTooltip" class="tooltip" aria-hidden="true"></div>
|
|
92
|
+
|
|
93
|
+
<script src="/app.js" type="module"></script>
|
|
94
|
+
</body>
|
|
95
|
+
</html>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// layout.js — DAG layout algorithm (unchanged from original app.js)
|
|
2
|
+
|
|
3
|
+
export function buildLayers(nodes, edges) {
|
|
4
|
+
const nodeIds = nodes.map((node) => node.id);
|
|
5
|
+
const inDegree = new Map(nodeIds.map((id) => [id, 0]));
|
|
6
|
+
const outgoing = new Map(nodeIds.map((id) => [id, []]));
|
|
7
|
+
|
|
8
|
+
for (const edge of edges) {
|
|
9
|
+
if (!inDegree.has(edge.to) || !outgoing.has(edge.from)) continue;
|
|
10
|
+
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
|
|
11
|
+
outgoing.get(edge.from).push(edge.to);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const queue = [];
|
|
15
|
+
for (const [id, count] of inDegree.entries()) {
|
|
16
|
+
if (count === 0) queue.push(id);
|
|
17
|
+
}
|
|
18
|
+
if (!queue.length) {
|
|
19
|
+
queue.push(nodeIds[0]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const layer = new Map();
|
|
23
|
+
for (const id of queue) layer.set(id, 0);
|
|
24
|
+
|
|
25
|
+
while (queue.length) {
|
|
26
|
+
const id = queue.shift();
|
|
27
|
+
const base = layer.get(id) ?? 0;
|
|
28
|
+
|
|
29
|
+
for (const next of outgoing.get(id) ?? []) {
|
|
30
|
+
const candidate = base + 1;
|
|
31
|
+
if (!layer.has(next) || candidate > layer.get(next)) {
|
|
32
|
+
layer.set(next, candidate);
|
|
33
|
+
}
|
|
34
|
+
inDegree.set(next, (inDegree.get(next) ?? 1) - 1);
|
|
35
|
+
if ((inDegree.get(next) ?? 0) <= 0) {
|
|
36
|
+
queue.push(next);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const id of nodeIds) {
|
|
42
|
+
if (!layer.has(id)) layer.set(id, 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return layer;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function layoutDag(nodes, edges) {
|
|
49
|
+
const layers = buildLayers(nodes, edges);
|
|
50
|
+
const grouped = new Map();
|
|
51
|
+
|
|
52
|
+
for (const node of nodes) {
|
|
53
|
+
const depth = layers.get(node.id) ?? 0;
|
|
54
|
+
if (!grouped.has(depth)) grouped.set(depth, []);
|
|
55
|
+
grouped.get(depth).push(node);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const points = new Map();
|
|
59
|
+
const sortedLayers = [...grouped.keys()].sort((a, b) => a - b);
|
|
60
|
+
|
|
61
|
+
for (const depth of sortedLayers) {
|
|
62
|
+
const items = grouped.get(depth).slice().sort((a, b) => a.label.localeCompare(b.label));
|
|
63
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
64
|
+
points.set(items[index].id, {
|
|
65
|
+
x: 180 + depth * 280,
|
|
66
|
+
y: 100 + index * 130,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return points;
|
|
72
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// minimap.js — Overview minimap at bottom-right
|
|
2
|
+
|
|
3
|
+
const MM_W = 180;
|
|
4
|
+
const MM_H = 120;
|
|
5
|
+
|
|
6
|
+
let minimapSvg = null;
|
|
7
|
+
let minimapViewport = null;
|
|
8
|
+
let viewRef = null; // reference to current view nodes
|
|
9
|
+
|
|
10
|
+
// Cached world bounds for click-to-navigate
|
|
11
|
+
let worldBounds = { minX: 0, minY: 0, mmScale: 1 };
|
|
12
|
+
|
|
13
|
+
export function initMinimap(view) {
|
|
14
|
+
minimapSvg = document.getElementById('minimapSvg');
|
|
15
|
+
minimapViewport = document.getElementById('minimapViewport');
|
|
16
|
+
viewRef = view;
|
|
17
|
+
|
|
18
|
+
minimapSvg.addEventListener('click', onMinimapClick);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setMinimapView(view) {
|
|
22
|
+
viewRef = view;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function updateMinimap(canvasTransform, nodePositions, graphSvg) {
|
|
26
|
+
if (!minimapSvg || !nodePositions.size) return;
|
|
27
|
+
|
|
28
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
29
|
+
for (const pos of nodePositions.values()) {
|
|
30
|
+
minX = Math.min(minX, pos.x - 90);
|
|
31
|
+
minY = Math.min(minY, pos.y - 30);
|
|
32
|
+
maxX = Math.max(maxX, pos.x + 90);
|
|
33
|
+
maxY = Math.max(maxY, pos.y + 30);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const worldW = maxX - minX + 80;
|
|
37
|
+
const worldH = maxY - minY + 80;
|
|
38
|
+
const mmScaleX = MM_W / worldW;
|
|
39
|
+
const mmScaleY = MM_H / worldH;
|
|
40
|
+
const mmScale = Math.min(mmScaleX, mmScaleY);
|
|
41
|
+
|
|
42
|
+
worldBounds = { minX, minY, mmScale };
|
|
43
|
+
|
|
44
|
+
// Clear and re-render node dots
|
|
45
|
+
minimapSvg.innerHTML = '';
|
|
46
|
+
|
|
47
|
+
for (const [nodeId, pos] of nodePositions) {
|
|
48
|
+
const node = viewRef?.nodes?.find((n) => n.id === nodeId);
|
|
49
|
+
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
50
|
+
dot.setAttribute('x', String((pos.x - minX) * mmScale));
|
|
51
|
+
dot.setAttribute('y', String((pos.y - minY) * mmScale));
|
|
52
|
+
dot.setAttribute('width', '8');
|
|
53
|
+
dot.setAttribute('height', '5');
|
|
54
|
+
dot.setAttribute('rx', '1');
|
|
55
|
+
dot.setAttribute('class', `mm-node mm-${node?.kind ?? 'default'}`);
|
|
56
|
+
minimapSvg.appendChild(dot);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Viewport rectangle
|
|
60
|
+
if (graphSvg) {
|
|
61
|
+
const rect = graphSvg.getBoundingClientRect();
|
|
62
|
+
const vpLeft = (-canvasTransform.x / canvasTransform.scale - minX) * mmScale;
|
|
63
|
+
const vpTop = (-canvasTransform.y / canvasTransform.scale - minY) * mmScale;
|
|
64
|
+
const vpRight = vpLeft + (rect.width / canvasTransform.scale) * mmScale;
|
|
65
|
+
const vpBottom = vpTop + (rect.height / canvasTransform.scale) * mmScale;
|
|
66
|
+
|
|
67
|
+
const vpEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
68
|
+
vpEl.setAttribute('x', String(Math.max(vpLeft, 0)));
|
|
69
|
+
vpEl.setAttribute('y', String(Math.max(vpTop, 0)));
|
|
70
|
+
vpEl.setAttribute('width', String(Math.max(0, Math.min(vpRight, MM_W) - Math.max(vpLeft, 0))));
|
|
71
|
+
vpEl.setAttribute('height', String(Math.max(0, Math.min(vpBottom, MM_H) - Math.max(vpTop, 0))));
|
|
72
|
+
vpEl.setAttribute('class', 'mm-viewport');
|
|
73
|
+
minimapSvg.appendChild(vpEl);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onMinimapClick(e) {
|
|
78
|
+
const rect = minimapSvg.getBoundingClientRect();
|
|
79
|
+
const mmX = e.clientX - rect.left;
|
|
80
|
+
const mmY = e.clientY - rect.top;
|
|
81
|
+
|
|
82
|
+
const { minX, minY, mmScale } = worldBounds;
|
|
83
|
+
if (mmScale === 0) return;
|
|
84
|
+
|
|
85
|
+
const worldX = mmX / mmScale + minX;
|
|
86
|
+
const worldY = mmY / mmScale + minY;
|
|
87
|
+
|
|
88
|
+
// Import canvasTransform at runtime to avoid circular dep — use global reference
|
|
89
|
+
if (window.__bgxPanToWorld) {
|
|
90
|
+
window.__bgxPanToWorld(worldX, worldY);
|
|
91
|
+
}
|
|
92
|
+
}
|