@andespindola/brainlink 0.1.0-beta.153 → 0.1.0-beta.155
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/AGENTS.md +2 -2
- package/README.md +23 -7
- package/dist/application/add-note.js +12 -3
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/frontend/client-css.js +77 -0
- package/dist/application/frontend/client-html.js +4 -0
- package/dist/application/frontend/client-js.js +490 -16
- package/dist/application/frontend/client-render-worker-js.js +53 -0
- package/dist/application/get-graph-layout.js +3 -2
- package/dist/application/graph-view-state.js +66 -0
- package/dist/application/search-graph-node-ids.js +14 -5
- package/dist/application/server/routes.js +46 -1
- package/dist/cli/commands/write-commands.js +47 -8
- package/dist/domain/graph-contexts.js +159 -0
- package/dist/domain/graph-layout.js +43 -17
- package/dist/infrastructure/config.js +4 -0
- package/dist/mcp/server.js +11 -1
- package/dist/mcp/tools.js +64 -5
- package/docs/AGENT_USAGE.md +7 -2
- package/package.json +1 -1
|
@@ -11,7 +11,11 @@ const elements = {
|
|
|
11
11
|
zoomIn: byId('zoomIn'),
|
|
12
12
|
zoomOut: byId('zoomOut'),
|
|
13
13
|
fit: byId('fit'),
|
|
14
|
+
releaseNode: byId('releaseNode'),
|
|
14
15
|
reset: byId('reset'),
|
|
16
|
+
labels: byId('graphLabels'),
|
|
17
|
+
tooltip: byId('graphTooltip'),
|
|
18
|
+
miniMap: byId('miniMap'),
|
|
15
19
|
contentDialog: byId('contentDialog'),
|
|
16
20
|
contentTitle: byId('contentTitle'),
|
|
17
21
|
contentPath: byId('contentPath'),
|
|
@@ -34,10 +38,15 @@ const state = {
|
|
|
34
38
|
down: false,
|
|
35
39
|
moved: false,
|
|
36
40
|
dragging: false,
|
|
41
|
+
dragNodeId: '',
|
|
37
42
|
x: 0,
|
|
38
43
|
y: 0,
|
|
39
44
|
startX: 0,
|
|
40
45
|
startY: 0,
|
|
46
|
+
startWorldX: 0,
|
|
47
|
+
startWorldY: 0,
|
|
48
|
+
nodeStartX: 0,
|
|
49
|
+
nodeStartY: 0,
|
|
41
50
|
worldAnchorX: 0,
|
|
42
51
|
worldAnchorY: 0
|
|
43
52
|
},
|
|
@@ -53,6 +62,18 @@ const state = {
|
|
|
53
62
|
contextId: '',
|
|
54
63
|
graphSignature: '',
|
|
55
64
|
graphMode: 'near',
|
|
65
|
+
nodePositionsSignature: '',
|
|
66
|
+
nodePositionsScope: '',
|
|
67
|
+
serverNodePositionsScope: '',
|
|
68
|
+
nodePositions: new Map(),
|
|
69
|
+
hoveredNodeId: '',
|
|
70
|
+
focusedNodeIds: new Set(),
|
|
71
|
+
spatialIndex: {
|
|
72
|
+
key: '',
|
|
73
|
+
cells: new Map()
|
|
74
|
+
},
|
|
75
|
+
miniMapView: null,
|
|
76
|
+
overlayScheduled: false,
|
|
56
77
|
chunk: {
|
|
57
78
|
nodes: [],
|
|
58
79
|
edges: []
|
|
@@ -63,7 +84,6 @@ const state = {
|
|
|
63
84
|
fetchTimer: null,
|
|
64
85
|
fetchAbortController: null,
|
|
65
86
|
cameraSyncScheduled: false,
|
|
66
|
-
lastDragFetchAt: 0,
|
|
67
87
|
lastWheelAt: 0,
|
|
68
88
|
lastVisibleNodes: 0,
|
|
69
89
|
lastVisibleEdges: 0,
|
|
@@ -80,6 +100,7 @@ const zoomRange = {
|
|
|
80
100
|
|
|
81
101
|
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
82
102
|
const selectedContextStorageKey = 'brainlink:selected-context'
|
|
103
|
+
const nodePositionsStoragePrefix = 'brainlink:graph-node-positions:'
|
|
83
104
|
|
|
84
105
|
const escapeHtml = (value) => String(value)
|
|
85
106
|
.replaceAll('&', '&')
|
|
@@ -126,6 +147,151 @@ const writeStoredContext = (contextId) => {
|
|
|
126
147
|
} catch {}
|
|
127
148
|
}
|
|
128
149
|
|
|
150
|
+
const nodePositionsStorageKey = () => [
|
|
151
|
+
nodePositionsStoragePrefix,
|
|
152
|
+
state.graphSignature || 'unknown',
|
|
153
|
+
state.agentId || 'all-agents',
|
|
154
|
+
state.contextId || 'all-contexts'
|
|
155
|
+
].join(':')
|
|
156
|
+
|
|
157
|
+
const readStoredNodePositions = () => {
|
|
158
|
+
try {
|
|
159
|
+
const raw = window.localStorage.getItem(nodePositionsStorageKey())
|
|
160
|
+
const parsed = raw ? JSON.parse(raw) : []
|
|
161
|
+
if (!Array.isArray(parsed)) {
|
|
162
|
+
return new Map()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return new Map(parsed.flatMap((entry) => {
|
|
166
|
+
const id = typeof entry?.[0] === 'string' ? entry[0] : ''
|
|
167
|
+
const x = Number(entry?.[1])
|
|
168
|
+
const y = Number(entry?.[2])
|
|
169
|
+
return id && Number.isFinite(x) && Number.isFinite(y) ? [[id, { x, y }]] : []
|
|
170
|
+
}))
|
|
171
|
+
} catch {
|
|
172
|
+
return new Map()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ensureNodePositionsLoaded = () => {
|
|
177
|
+
const storageKey = nodePositionsStorageKey()
|
|
178
|
+
if (!state.graphSignature || (state.nodePositionsSignature === state.graphSignature && state.nodePositionsScope === storageKey)) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
state.nodePositions = readStoredNodePositions()
|
|
183
|
+
state.nodePositionsSignature = state.graphSignature
|
|
184
|
+
state.nodePositionsScope = storageKey
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const writeStoredNodePositions = () => {
|
|
188
|
+
try {
|
|
189
|
+
if (!state.graphSignature) {
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const entries = Array.from(state.nodePositions.entries())
|
|
194
|
+
.filter((entry) => Number.isFinite(entry[1]?.x) && Number.isFinite(entry[1]?.y))
|
|
195
|
+
.map((entry) => [entry[0], entry[1].x, entry[1].y])
|
|
196
|
+
|
|
197
|
+
if (entries.length === 0) {
|
|
198
|
+
window.localStorage.removeItem(nodePositionsStorageKey())
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
window.localStorage.setItem(nodePositionsStorageKey(), JSON.stringify(entries))
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const clearStoredNodePositions = () => {
|
|
207
|
+
try {
|
|
208
|
+
if (state.graphSignature) {
|
|
209
|
+
window.localStorage.removeItem(nodePositionsStorageKey())
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
state.nodePositions = new Map()
|
|
213
|
+
state.nodePositionsSignature = state.graphSignature
|
|
214
|
+
state.nodePositionsScope = nodePositionsStorageKey()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const graphViewStateQuery = () => {
|
|
218
|
+
const params = new URLSearchParams({ signature: state.graphSignature })
|
|
219
|
+
if (state.agentId) {
|
|
220
|
+
params.set('agent', state.agentId)
|
|
221
|
+
}
|
|
222
|
+
if (state.contextId) {
|
|
223
|
+
params.set('context', state.contextId)
|
|
224
|
+
}
|
|
225
|
+
return params.toString()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const syncNodePositionsFromServer = async () => {
|
|
229
|
+
if (!state.graphSignature) {
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
const scope = nodePositionsStorageKey()
|
|
233
|
+
if (state.serverNodePositionsScope === scope) {
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
state.serverNodePositionsScope = scope
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const response = await fetch('/api/graph-view-state?' + graphViewStateQuery())
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const payload = await response.json()
|
|
244
|
+
const positions = Array.isArray(payload?.positions) ? payload.positions : []
|
|
245
|
+
if (positions.length === 0) {
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
state.nodePositions = new Map(positions.flatMap((position) => {
|
|
249
|
+
const id = typeof position?.id === 'string' ? position.id : ''
|
|
250
|
+
const x = Number(position?.x)
|
|
251
|
+
const y = Number(position?.y)
|
|
252
|
+
return id && Number.isFinite(x) && Number.isFinite(y) ? [[id, { x, y }]] : []
|
|
253
|
+
}))
|
|
254
|
+
writeStoredNodePositions()
|
|
255
|
+
} catch {}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const persistNodePositionsToServer = () => {
|
|
259
|
+
if (!state.graphSignature) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const positions = Array.from(state.nodePositions.entries()).map(([id, position]) => ({
|
|
264
|
+
id,
|
|
265
|
+
x: position.x,
|
|
266
|
+
y: position.y
|
|
267
|
+
}))
|
|
268
|
+
|
|
269
|
+
fetch('/api/graph-view-state?' + graphViewStateQuery(), {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'content-type': 'application/json' },
|
|
272
|
+
body: JSON.stringify({ positions })
|
|
273
|
+
}).catch(() => {})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const clearNodePositionsOnServer = () => {
|
|
277
|
+
if (!state.graphSignature) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fetch('/api/graph-view-state?' + graphViewStateQuery(), { method: 'DELETE' }).catch(() => {})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const releaseSelectedNodePosition = () => {
|
|
285
|
+
if (!state.selectedNodeId || !state.nodePositions.has(state.selectedNodeId)) {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
state.nodePositions.delete(state.selectedNodeId)
|
|
290
|
+
writeStoredNodePositions()
|
|
291
|
+
persistNodePositionsToServer()
|
|
292
|
+
scheduleChunkFetch({ fit: false })
|
|
293
|
+
}
|
|
294
|
+
|
|
129
295
|
const syncAgentInUrl = (agentId) => {
|
|
130
296
|
try {
|
|
131
297
|
const url = new URL(window.location.href)
|
|
@@ -237,6 +403,78 @@ const worldToScreen = (x, y) => ({
|
|
|
237
403
|
y: y * state.camera.scale + state.camera.y
|
|
238
404
|
})
|
|
239
405
|
|
|
406
|
+
const spatialIndexKey = () => [
|
|
407
|
+
state.graphSignature,
|
|
408
|
+
state.camera.x.toFixed(1),
|
|
409
|
+
state.camera.y.toFixed(1),
|
|
410
|
+
state.camera.scale.toFixed(4),
|
|
411
|
+
normalizeList(state.chunk.nodes).length
|
|
412
|
+
].join(':')
|
|
413
|
+
|
|
414
|
+
const rebuildSpatialIndex = () => {
|
|
415
|
+
const key = spatialIndexKey()
|
|
416
|
+
if (state.spatialIndex.key === key) {
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const cellSize = 44
|
|
421
|
+
const cells = new Map()
|
|
422
|
+
normalizeList(state.chunk.nodes).forEach((node) => {
|
|
423
|
+
const id = typeof node?.[0] === 'string' ? node[0] : ''
|
|
424
|
+
if (!id) return
|
|
425
|
+
const x = Number(node?.[2])
|
|
426
|
+
const y = Number(node?.[3])
|
|
427
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
428
|
+
const point = worldToScreen(x, y)
|
|
429
|
+
const cellX = Math.floor(point.x / cellSize)
|
|
430
|
+
const cellY = Math.floor(point.y / cellSize)
|
|
431
|
+
const key = cellX + ',' + cellY
|
|
432
|
+
const bucket = cells.get(key)
|
|
433
|
+
if (bucket) {
|
|
434
|
+
bucket.push(node)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
cells.set(key, [node])
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
state.spatialIndex = { key, cells }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const spatialCandidates = (screenX, screenY) => {
|
|
444
|
+
rebuildSpatialIndex()
|
|
445
|
+
const cellSize = 44
|
|
446
|
+
const cellX = Math.floor(screenX / cellSize)
|
|
447
|
+
const cellY = Math.floor(screenY / cellSize)
|
|
448
|
+
const nodes = []
|
|
449
|
+
|
|
450
|
+
for (let y = cellY - 1; y <= cellY + 1; y += 1) {
|
|
451
|
+
for (let x = cellX - 1; x <= cellX + 1; x += 1) {
|
|
452
|
+
nodes.push(...(state.spatialIndex.cells.get(x + ',' + y) ?? []))
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return nodes
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const nodeByIdFromChunk = () => new Map(normalizeList(state.chunk.nodes).map((node) => [node[0], node]))
|
|
460
|
+
|
|
461
|
+
const linkedNodeIds = (nodeId) => {
|
|
462
|
+
const ids = new Set(nodeId ? [nodeId] : [])
|
|
463
|
+
normalizeList(state.chunk.edges).forEach((edge) => {
|
|
464
|
+
if (edge?.[0] === nodeId && typeof edge?.[1] === 'string') ids.add(edge[1])
|
|
465
|
+
if (edge?.[1] === nodeId && typeof edge?.[0] === 'string') ids.add(edge[0])
|
|
466
|
+
})
|
|
467
|
+
return ids
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const setFocusedNodeIds = (ids) => {
|
|
471
|
+
state.focusedNodeIds = ids
|
|
472
|
+
if (state.renderWorker && state.workerReady) {
|
|
473
|
+
state.renderWorker.postMessage({ type: 'focus', ids: Array.from(ids) })
|
|
474
|
+
}
|
|
475
|
+
updateGraphOverlays()
|
|
476
|
+
}
|
|
477
|
+
|
|
240
478
|
const drawFallback = () => {
|
|
241
479
|
if (state.rendererMode !== 'fallback' || !ctx2dFallback) {
|
|
242
480
|
return
|
|
@@ -301,6 +539,7 @@ const updateTagCount = () => {
|
|
|
301
539
|
}
|
|
302
540
|
|
|
303
541
|
const updateWorkerCamera = () => {
|
|
542
|
+
updateGraphOverlays()
|
|
304
543
|
if (!state.renderWorker || !state.workerReady) {
|
|
305
544
|
return
|
|
306
545
|
}
|
|
@@ -321,6 +560,7 @@ const updateWorkerCamera = () => {
|
|
|
321
560
|
}
|
|
322
561
|
|
|
323
562
|
const updateWorkerSize = () => {
|
|
563
|
+
updateGraphOverlays()
|
|
324
564
|
if (!state.renderWorker || !state.workerReady) {
|
|
325
565
|
return
|
|
326
566
|
}
|
|
@@ -334,6 +574,164 @@ const updateWorkerSize = () => {
|
|
|
334
574
|
|
|
335
575
|
const normalizeList = (items) => Array.isArray(items) ? items : []
|
|
336
576
|
|
|
577
|
+
const applyManualNodePositions = (nodes) => normalizeList(nodes).map((node) => {
|
|
578
|
+
const id = typeof node?.[0] === 'string' ? node[0] : ''
|
|
579
|
+
const position = id ? state.nodePositions.get(id) : null
|
|
580
|
+
if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
|
|
581
|
+
return node
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const next = [...node]
|
|
585
|
+
next[2] = position.x
|
|
586
|
+
next[3] = position.y
|
|
587
|
+
return next
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
const updateNodePositionInChunk = (nodeId, x, y) => {
|
|
591
|
+
if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
state.chunk = {
|
|
596
|
+
...state.chunk,
|
|
597
|
+
nodes: normalizeList(state.chunk.nodes).map((node) => {
|
|
598
|
+
if (node?.[0] !== nodeId) {
|
|
599
|
+
return node
|
|
600
|
+
}
|
|
601
|
+
const next = [...node]
|
|
602
|
+
next[2] = x
|
|
603
|
+
next[3] = y
|
|
604
|
+
return next
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
state.spatialIndex.key = ''
|
|
608
|
+
|
|
609
|
+
if (state.renderWorker && state.workerReady) {
|
|
610
|
+
state.renderWorker.postMessage({ type: 'move-node', id: nodeId, x, y })
|
|
611
|
+
}
|
|
612
|
+
updateGraphOverlays()
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const showTooltip = (node, pointer) => {
|
|
616
|
+
if (!elements.tooltip || !node) {
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
elements.tooltip.hidden = false
|
|
621
|
+
elements.tooltip.innerHTML =
|
|
622
|
+
'<strong>' + escapeHtml(node[1] || node[0]) + '</strong>' +
|
|
623
|
+
'<small>' + escapeHtml(node[4] || node[5] || '') + '</small>'
|
|
624
|
+
elements.tooltip.style.left = Math.min(state.viewport.width - 24, pointer.x + 14) + 'px'
|
|
625
|
+
elements.tooltip.style.top = Math.min(state.viewport.height - 24, pointer.y + 14) + 'px'
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const hideTooltip = () => {
|
|
629
|
+
if (elements.tooltip) {
|
|
630
|
+
elements.tooltip.hidden = true
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const labelCandidates = () => {
|
|
635
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
636
|
+
const visible = nodes.filter((node) => {
|
|
637
|
+
const x = Number(node?.[2])
|
|
638
|
+
const y = Number(node?.[3])
|
|
639
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false
|
|
640
|
+
const point = worldToScreen(x, y)
|
|
641
|
+
return point.x >= -80 && point.x <= state.viewport.width + 80 && point.y >= -80 && point.y <= state.viewport.height + 80
|
|
642
|
+
})
|
|
643
|
+
const shouldShowMany = state.camera.scale >= 0.72 || visible.length <= 120
|
|
644
|
+
const focused = state.focusedNodeIds
|
|
645
|
+
|
|
646
|
+
return visible
|
|
647
|
+
.filter((node) => shouldShowMany || focused.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId || Number(node?.[7]) > 5.5)
|
|
648
|
+
.sort((left, right) => {
|
|
649
|
+
const leftFocused = focused.has(left[0]) || left[0] === state.hoveredNodeId || left[0] === state.selectedNodeId ? 1 : 0
|
|
650
|
+
const rightFocused = focused.has(right[0]) || right[0] === state.hoveredNodeId || right[0] === state.selectedNodeId ? 1 : 0
|
|
651
|
+
if (rightFocused !== leftFocused) return rightFocused - leftFocused
|
|
652
|
+
return Number(right?.[7] ?? 0) - Number(left?.[7] ?? 0)
|
|
653
|
+
})
|
|
654
|
+
.slice(0, state.camera.scale >= 0.72 ? 160 : 48)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const drawLabels = () => {
|
|
658
|
+
if (!elements.labels) {
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
elements.labels.innerHTML = labelCandidates().map((node) => {
|
|
663
|
+
const point = worldToScreen(Number(node[2]), Number(node[3]))
|
|
664
|
+
const focused = state.focusedNodeIds.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId
|
|
665
|
+
return '<span class="graph-label' + (focused ? ' is-focused' : '') + '" style="left:' +
|
|
666
|
+
point.x.toFixed(1) + 'px;top:' + point.y.toFixed(1) + 'px">' + escapeHtml(node[1] || node[0]) + '</span>'
|
|
667
|
+
}).join('')
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const drawMiniMap = () => {
|
|
671
|
+
const miniMap = elements.miniMap
|
|
672
|
+
if (!(miniMap instanceof HTMLCanvasElement)) {
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
676
|
+
const ctx = miniMap.getContext('2d')
|
|
677
|
+
if (!ctx || nodes.length === 0) {
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const ratio = window.devicePixelRatio || 1
|
|
682
|
+
const width = miniMap.clientWidth || 180
|
|
683
|
+
const height = miniMap.clientHeight || 120
|
|
684
|
+
miniMap.width = Math.floor(width * ratio)
|
|
685
|
+
miniMap.height = Math.floor(height * ratio)
|
|
686
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
687
|
+
ctx.clearRect(0, 0, width, height)
|
|
688
|
+
ctx.fillStyle = 'rgba(13, 16, 20, 0.86)'
|
|
689
|
+
ctx.fillRect(0, 0, width, height)
|
|
690
|
+
|
|
691
|
+
const xs = nodes.map((node) => Number(node[2])).filter(Number.isFinite)
|
|
692
|
+
const ys = nodes.map((node) => Number(node[3])).filter(Number.isFinite)
|
|
693
|
+
const minX = Math.min(...xs)
|
|
694
|
+
const maxX = Math.max(...xs)
|
|
695
|
+
const minY = Math.min(...ys)
|
|
696
|
+
const maxY = Math.max(...ys)
|
|
697
|
+
const graphWidth = Math.max(1, maxX - minX)
|
|
698
|
+
const graphHeight = Math.max(1, maxY - minY)
|
|
699
|
+
const scale = Math.min((width - 18) / graphWidth, (height - 18) / graphHeight)
|
|
700
|
+
const offsetX = (width - graphWidth * scale) / 2
|
|
701
|
+
const offsetY = (height - graphHeight * scale) / 2
|
|
702
|
+
const toMini = (x, y) => ({
|
|
703
|
+
x: offsetX + (x - minX) * scale,
|
|
704
|
+
y: offsetY + (y - minY) * scale
|
|
705
|
+
})
|
|
706
|
+
state.miniMapView = { minX, minY, scale, offsetX, offsetY, width, height }
|
|
707
|
+
|
|
708
|
+
ctx.fillStyle = 'rgba(174, 184, 197, 0.62)'
|
|
709
|
+
nodes.forEach((node) => {
|
|
710
|
+
const point = toMini(Number(node[2]), Number(node[3]))
|
|
711
|
+
ctx.fillRect(point.x - 1, point.y - 1, 2, 2)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
const worldTopLeft = screenToWorld(0, 0)
|
|
715
|
+
const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
|
|
716
|
+
const topLeft = toMini(Math.min(worldTopLeft.x, worldBottomRight.x), Math.min(worldTopLeft.y, worldBottomRight.y))
|
|
717
|
+
const bottomRight = toMini(Math.max(worldTopLeft.x, worldBottomRight.x), Math.max(worldTopLeft.y, worldBottomRight.y))
|
|
718
|
+
ctx.strokeStyle = 'rgba(53, 208, 162, 0.86)'
|
|
719
|
+
ctx.lineWidth = 1
|
|
720
|
+
ctx.strokeRect(topLeft.x, topLeft.y, Math.max(3, bottomRight.x - topLeft.x), Math.max(3, bottomRight.y - topLeft.y))
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const updateGraphOverlays = () => {
|
|
724
|
+
if (state.overlayScheduled) {
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
state.overlayScheduled = true
|
|
728
|
+
requestAnimationFrame(() => {
|
|
729
|
+
state.overlayScheduled = false
|
|
730
|
+
drawLabels()
|
|
731
|
+
drawMiniMap()
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
|
|
337
735
|
const list = (items) => {
|
|
338
736
|
const rows = normalizeList(items)
|
|
339
737
|
if (rows.length === 0) {
|
|
@@ -442,6 +840,7 @@ const loadNodeDetails = async (nodeId) => {
|
|
|
442
840
|
|
|
443
841
|
const node = payload.node
|
|
444
842
|
state.selectedNodeId = node.id
|
|
843
|
+
setFocusedNodeIds(linkedNodeIds(node.id))
|
|
445
844
|
|
|
446
845
|
if (state.renderWorker && state.workerReady) {
|
|
447
846
|
state.renderWorker.postMessage({ type: 'select', id: node.id })
|
|
@@ -552,11 +951,16 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
|
|
|
552
951
|
}
|
|
553
952
|
|
|
554
953
|
state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
|
|
954
|
+
ensureNodePositionsLoaded()
|
|
955
|
+
await syncNodePositionsFromServer()
|
|
555
956
|
state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
|
|
957
|
+
const chunkNodes = applyManualNodePositions(chunk.nodes)
|
|
556
958
|
state.chunk = {
|
|
557
|
-
nodes:
|
|
959
|
+
nodes: chunkNodes,
|
|
558
960
|
edges: normalizeList(chunk.edges)
|
|
559
961
|
}
|
|
962
|
+
state.spatialIndex.key = ''
|
|
963
|
+
const renderChunk = { ...chunk, nodes: chunkNodes }
|
|
560
964
|
state.totals = {
|
|
561
965
|
nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
|
|
562
966
|
edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
|
|
@@ -570,10 +974,11 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
|
|
|
570
974
|
}
|
|
571
975
|
|
|
572
976
|
if (state.renderWorker && state.workerReady) {
|
|
573
|
-
state.renderWorker.postMessage({ type: 'chunk', chunk })
|
|
977
|
+
state.renderWorker.postMessage({ type: 'chunk', chunk: renderChunk })
|
|
574
978
|
state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
|
|
575
979
|
}
|
|
576
980
|
|
|
981
|
+
updateGraphOverlays()
|
|
577
982
|
drawFallback()
|
|
578
983
|
}
|
|
579
984
|
|
|
@@ -584,7 +989,7 @@ const scheduleChunkFetch = ({ fit } = { fit: false }) => {
|
|
|
584
989
|
|
|
585
990
|
const now = performance.now()
|
|
586
991
|
const recentlyWheeling = now - state.lastWheelAt < 180
|
|
587
|
-
const delay = fit ? 0 : (state.pointer.down ?
|
|
992
|
+
const delay = fit ? 0 : (state.pointer.down ? 260 : (recentlyWheeling ? 160 : 48))
|
|
588
993
|
state.fetchTimer = setTimeout(() => {
|
|
589
994
|
state.fetchTimer = null
|
|
590
995
|
fetchChunk({ fit }).catch((error) => {
|
|
@@ -605,13 +1010,13 @@ const setViewportFromCanvas = () => {
|
|
|
605
1010
|
drawFallback()
|
|
606
1011
|
}
|
|
607
1012
|
|
|
608
|
-
const
|
|
609
|
-
const nodes =
|
|
1013
|
+
const pickFallbackNode = (screenX, screenY) => {
|
|
1014
|
+
const nodes = spatialCandidates(screenX, screenY)
|
|
610
1015
|
if (nodes.length === 0) {
|
|
611
|
-
return
|
|
1016
|
+
return null
|
|
612
1017
|
}
|
|
613
1018
|
|
|
614
|
-
let
|
|
1019
|
+
let bestNode = null
|
|
615
1020
|
let bestDistance = Infinity
|
|
616
1021
|
for (let index = 0; index < nodes.length; index += 1) {
|
|
617
1022
|
const node = nodes[index]
|
|
@@ -626,11 +1031,16 @@ const pickFallbackNodeId = (screenX, screenY) => {
|
|
|
626
1031
|
const distance = Math.hypot(screenX - point.x, screenY - point.y)
|
|
627
1032
|
if (distance <= radius && distance < bestDistance) {
|
|
628
1033
|
bestDistance = distance
|
|
629
|
-
|
|
1034
|
+
bestNode = node
|
|
630
1035
|
}
|
|
631
1036
|
}
|
|
632
1037
|
|
|
633
|
-
return
|
|
1038
|
+
return bestNode
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const pickFallbackNodeId = (screenX, screenY) => {
|
|
1042
|
+
const node = pickFallbackNode(screenX, screenY)
|
|
1043
|
+
return typeof node?.[0] === 'string' ? node[0] : ''
|
|
634
1044
|
}
|
|
635
1045
|
|
|
636
1046
|
const pickAt = (screenX, screenY) => {
|
|
@@ -686,14 +1096,23 @@ const setupInput = () => {
|
|
|
686
1096
|
|
|
687
1097
|
canvas.addEventListener('pointerdown', (event) => {
|
|
688
1098
|
const pointer = resolvePointer(event)
|
|
1099
|
+
const candidateNode = pickFallbackNode(pointer.x, pointer.y)
|
|
1100
|
+
const candidateNodeId = candidateNode?.[6] === 'node' && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
|
|
1101
|
+
const candidateX = Number(candidateNode?.[2])
|
|
1102
|
+
const candidateY = Number(candidateNode?.[3])
|
|
1103
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
689
1104
|
state.pointer.down = true
|
|
690
1105
|
state.pointer.moved = false
|
|
691
1106
|
state.pointer.dragging = false
|
|
1107
|
+
state.pointer.dragNodeId = candidateNodeId
|
|
692
1108
|
state.pointer.x = pointer.x
|
|
693
1109
|
state.pointer.y = pointer.y
|
|
694
1110
|
state.pointer.startX = pointer.x
|
|
695
1111
|
state.pointer.startY = pointer.y
|
|
696
|
-
|
|
1112
|
+
state.pointer.startWorldX = world.x
|
|
1113
|
+
state.pointer.startWorldY = world.y
|
|
1114
|
+
state.pointer.nodeStartX = candidateNodeId && Number.isFinite(candidateX) ? candidateX : 0
|
|
1115
|
+
state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
|
|
697
1116
|
state.pointer.worldAnchorX = world.x
|
|
698
1117
|
state.pointer.worldAnchorY = world.y
|
|
699
1118
|
canvas.setPointerCapture(event.pointerId)
|
|
@@ -709,26 +1128,45 @@ const setupInput = () => {
|
|
|
709
1128
|
if (distanceFromStart >= dragActivationDistance) {
|
|
710
1129
|
state.pointer.moved = true
|
|
711
1130
|
state.pointer.dragging = true
|
|
1131
|
+
canvas.classList.toggle('is-node-dragging', Boolean(state.pointer.dragNodeId))
|
|
712
1132
|
}
|
|
713
1133
|
if (!state.pointer.dragging) {
|
|
714
1134
|
state.pointer.x = pointer.x
|
|
715
1135
|
state.pointer.y = pointer.y
|
|
716
1136
|
return
|
|
717
1137
|
}
|
|
1138
|
+
if (state.pointer.dragNodeId) {
|
|
1139
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
1140
|
+
const x = state.pointer.nodeStartX + world.x - state.pointer.startWorldX
|
|
1141
|
+
const y = state.pointer.nodeStartY + world.y - state.pointer.startWorldY
|
|
1142
|
+
state.nodePositions.set(state.pointer.dragNodeId, { x, y })
|
|
1143
|
+
updateNodePositionInChunk(state.pointer.dragNodeId, x, y)
|
|
1144
|
+
state.pointer.x = pointer.x
|
|
1145
|
+
state.pointer.y = pointer.y
|
|
1146
|
+
drawFallback()
|
|
1147
|
+
return
|
|
1148
|
+
}
|
|
718
1149
|
state.camera.x += dx
|
|
719
1150
|
state.camera.y += dy
|
|
720
1151
|
state.pointer.x = pointer.x
|
|
721
1152
|
state.pointer.y = pointer.y
|
|
722
1153
|
updateWorkerCamera()
|
|
723
|
-
const now = performance.now()
|
|
724
|
-
if (now - state.lastDragFetchAt > 180) {
|
|
725
|
-
state.lastDragFetchAt = now
|
|
726
|
-
scheduleChunkFetch()
|
|
727
|
-
}
|
|
728
1154
|
drawFallback()
|
|
729
1155
|
return
|
|
730
1156
|
}
|
|
731
1157
|
|
|
1158
|
+
const hovered = pickFallbackNode(pointer.x, pointer.y)
|
|
1159
|
+
const hoveredId = hovered?.[6] === 'node' && typeof hovered?.[0] === 'string' ? hovered[0] : ''
|
|
1160
|
+
if (state.hoveredNodeId !== hoveredId) {
|
|
1161
|
+
state.hoveredNodeId = hoveredId
|
|
1162
|
+
canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
|
|
1163
|
+
updateGraphOverlays()
|
|
1164
|
+
}
|
|
1165
|
+
if (hoveredId) {
|
|
1166
|
+
showTooltip(hovered, pointer)
|
|
1167
|
+
} else {
|
|
1168
|
+
hideTooltip()
|
|
1169
|
+
}
|
|
732
1170
|
})
|
|
733
1171
|
|
|
734
1172
|
canvas.addEventListener('pointerup', (event) => {
|
|
@@ -736,19 +1174,49 @@ const setupInput = () => {
|
|
|
736
1174
|
const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
|
|
737
1175
|
const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
|
|
738
1176
|
const shouldRefreshAfterDrag = state.pointer.dragging
|
|
1177
|
+
const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
|
|
739
1178
|
state.pointer.down = false
|
|
740
1179
|
state.pointer.dragging = false
|
|
1180
|
+
canvas.classList.remove('is-node-dragging')
|
|
1181
|
+
state.pointer.dragNodeId = ''
|
|
741
1182
|
canvas.releasePointerCapture(event.pointerId)
|
|
742
1183
|
|
|
743
1184
|
if (shouldPick) {
|
|
744
1185
|
pickAt(pointer.x, pointer.y)
|
|
745
1186
|
return
|
|
746
1187
|
}
|
|
1188
|
+
if (shouldPersistNodePosition) {
|
|
1189
|
+
writeStoredNodePositions()
|
|
1190
|
+
persistNodePositionsToServer()
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
747
1193
|
if (shouldRefreshAfterDrag) {
|
|
748
1194
|
scheduleChunkFetch()
|
|
749
1195
|
}
|
|
750
1196
|
})
|
|
751
1197
|
|
|
1198
|
+
canvas.addEventListener('pointerleave', () => {
|
|
1199
|
+
state.hoveredNodeId = ''
|
|
1200
|
+
canvas.classList.remove('is-node-hover')
|
|
1201
|
+
hideTooltip()
|
|
1202
|
+
updateGraphOverlays()
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
elements.miniMap.addEventListener('click', (event) => {
|
|
1206
|
+
if (!state.miniMapView) {
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
const rect = elements.miniMap.getBoundingClientRect()
|
|
1210
|
+
const x = event.clientX - rect.left
|
|
1211
|
+
const y = event.clientY - rect.top
|
|
1212
|
+
const worldX = state.miniMapView.minX + (x - state.miniMapView.offsetX) / state.miniMapView.scale
|
|
1213
|
+
const worldY = state.miniMapView.minY + (y - state.miniMapView.offsetY) / state.miniMapView.scale
|
|
1214
|
+
state.camera.x = state.viewport.width / 2 - worldX * state.camera.scale
|
|
1215
|
+
state.camera.y = state.viewport.height / 2 - worldY * state.camera.scale
|
|
1216
|
+
updateWorkerCamera()
|
|
1217
|
+
scheduleChunkFetch()
|
|
1218
|
+
})
|
|
1219
|
+
|
|
752
1220
|
canvas.addEventListener('dblclick', (event) => {
|
|
753
1221
|
const pointer = resolvePointer(event)
|
|
754
1222
|
zoomAtPoint(pointer.x, pointer.y, 1.065)
|
|
@@ -783,7 +1251,13 @@ const setupControls = () => {
|
|
|
783
1251
|
scheduleChunkFetch()
|
|
784
1252
|
})
|
|
785
1253
|
|
|
1254
|
+
elements.releaseNode.addEventListener('click', () => {
|
|
1255
|
+
releaseSelectedNodePosition()
|
|
1256
|
+
})
|
|
1257
|
+
|
|
786
1258
|
elements.reset.addEventListener('click', () => {
|
|
1259
|
+
clearStoredNodePositions()
|
|
1260
|
+
clearNodePositionsOnServer()
|
|
787
1261
|
state.camera = { x: 0, y: 0, scale: 0.22 }
|
|
788
1262
|
updateWorkerCamera()
|
|
789
1263
|
scheduleChunkFetch({ fit: true })
|