@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.
@@ -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: normalizeList(chunk.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 ? 120 : (recentlyWheeling ? 140 : 48))
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 pickFallbackNodeId = (screenX, screenY) => {
609
- const nodes = normalizeList(state.chunk.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 bestId = ''
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
- bestId = id
1034
+ bestNode = node
630
1035
  }
631
1036
  }
632
1037
 
633
- return bestId
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
- const world = screenToWorld(pointer.x, pointer.y)
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 })