@andespindola/brainlink 0.1.0-beta.154 → 0.1.0-beta.156

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.
@@ -99,10 +99,87 @@ select {
99
99
  cursor: grab;
100
100
  }
101
101
 
102
+ #graph.is-node-hover {
103
+ cursor: pointer;
104
+ }
105
+
106
+ #graph.is-node-dragging {
107
+ cursor: move;
108
+ }
109
+
102
110
  #graph:active {
103
111
  cursor: grabbing;
104
112
  }
105
113
 
114
+ .graph-labels {
115
+ position: absolute;
116
+ inset: 0;
117
+ pointer-events: none;
118
+ overflow: hidden;
119
+ }
120
+
121
+ .graph-label {
122
+ position: absolute;
123
+ max-width: 220px;
124
+ transform: translate(-50%, calc(-100% - 10px));
125
+ padding: 4px 7px;
126
+ border: 1px solid rgba(129, 146, 170, 0.28);
127
+ border-radius: 6px;
128
+ background: rgba(13, 16, 20, 0.78);
129
+ color: var(--text);
130
+ font-size: 11px;
131
+ line-height: 1.25;
132
+ white-space: nowrap;
133
+ overflow: hidden;
134
+ text-overflow: ellipsis;
135
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28);
136
+ }
137
+
138
+ .graph-label.is-focused {
139
+ border-color: rgba(53, 208, 162, 0.72);
140
+ color: #dffbf3;
141
+ }
142
+
143
+ .graph-tooltip {
144
+ position: absolute;
145
+ z-index: 4;
146
+ max-width: min(320px, calc(100vw - 32px));
147
+ padding: 8px 10px;
148
+ border: 1px solid var(--line);
149
+ border-radius: 6px;
150
+ background: rgba(13, 16, 20, 0.94);
151
+ color: var(--text);
152
+ font-size: 12px;
153
+ line-height: 1.35;
154
+ pointer-events: none;
155
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.38);
156
+ }
157
+
158
+ .graph-tooltip strong,
159
+ .graph-tooltip small {
160
+ display: block;
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ }
164
+
165
+ .graph-tooltip small {
166
+ margin-top: 3px;
167
+ color: var(--muted);
168
+ }
169
+
170
+ .mini-map {
171
+ position: absolute;
172
+ right: 14px;
173
+ bottom: 14px;
174
+ z-index: 3;
175
+ width: 180px;
176
+ height: 120px;
177
+ border: 1px solid rgba(129, 146, 170, 0.28);
178
+ border-radius: 8px;
179
+ background: rgba(13, 16, 20, 0.78);
180
+ box-shadow: 0 16px 42px rgba(0, 0, 0, 0.38);
181
+ }
182
+
106
183
  .eyebrow {
107
184
  color: var(--muted);
108
185
  font-size: 12px;
@@ -42,12 +42,16 @@ export const createClientHtml = () => `<!doctype html>
42
42
  <button id="zoomIn" type="button" title="Zoom in">+</button>
43
43
  <button id="zoomOut" type="button" title="Zoom out">-</button>
44
44
  <button id="fit" type="button" title="Focus central hub">◎</button>
45
+ <button id="releaseNode" type="button" title="Release selected node">◇</button>
45
46
  <button id="reset" type="button" title="Reset view">⌂</button>
46
47
  </div>
47
48
  </div>
48
49
  </header>
49
50
  <div class="graph-stage">
50
51
  <canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
52
+ <div id="graphLabels" class="graph-labels" aria-hidden="true"></div>
53
+ <div id="graphTooltip" class="graph-tooltip" role="tooltip" hidden></div>
54
+ <canvas id="miniMap" class="mini-map" aria-label="Graph overview"></canvas>
51
55
  </div>
52
56
  </section>
53
57
  </main>
@@ -1,5 +1,5 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
- const ctx2dFallback = canvas.getContext('2d')
2
+ let ctx2dFallback = null
3
3
  const byId = (id) => document.getElementById(id)
4
4
  const elements = {
5
5
  search: byId('search'),
@@ -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('&', '&amp;')
@@ -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,8 +403,84 @@ 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
- if (state.rendererMode !== 'fallback' || !ctx2dFallback) {
479
+ if (state.rendererMode !== 'fallback') {
480
+ return
481
+ }
482
+ ctx2dFallback = ctx2dFallback ?? canvas.getContext('2d')
483
+ if (!ctx2dFallback) {
242
484
  return
243
485
  }
244
486
  const width = state.viewport.width
@@ -301,6 +543,7 @@ const updateTagCount = () => {
301
543
  }
302
544
 
303
545
  const updateWorkerCamera = () => {
546
+ updateGraphOverlays()
304
547
  if (!state.renderWorker || !state.workerReady) {
305
548
  return
306
549
  }
@@ -321,6 +564,7 @@ const updateWorkerCamera = () => {
321
564
  }
322
565
 
323
566
  const updateWorkerSize = () => {
567
+ updateGraphOverlays()
324
568
  if (!state.renderWorker || !state.workerReady) {
325
569
  return
326
570
  }
@@ -334,6 +578,164 @@ const updateWorkerSize = () => {
334
578
 
335
579
  const normalizeList = (items) => Array.isArray(items) ? items : []
336
580
 
581
+ const applyManualNodePositions = (nodes) => normalizeList(nodes).map((node) => {
582
+ const id = typeof node?.[0] === 'string' ? node[0] : ''
583
+ const position = id ? state.nodePositions.get(id) : null
584
+ if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
585
+ return node
586
+ }
587
+
588
+ const next = [...node]
589
+ next[2] = position.x
590
+ next[3] = position.y
591
+ return next
592
+ })
593
+
594
+ const updateNodePositionInChunk = (nodeId, x, y) => {
595
+ if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) {
596
+ return
597
+ }
598
+
599
+ state.chunk = {
600
+ ...state.chunk,
601
+ nodes: normalizeList(state.chunk.nodes).map((node) => {
602
+ if (node?.[0] !== nodeId) {
603
+ return node
604
+ }
605
+ const next = [...node]
606
+ next[2] = x
607
+ next[3] = y
608
+ return next
609
+ })
610
+ }
611
+ state.spatialIndex.key = ''
612
+
613
+ if (state.renderWorker && state.workerReady) {
614
+ state.renderWorker.postMessage({ type: 'move-node', id: nodeId, x, y })
615
+ }
616
+ updateGraphOverlays()
617
+ }
618
+
619
+ const showTooltip = (node, pointer) => {
620
+ if (!elements.tooltip || !node) {
621
+ return
622
+ }
623
+
624
+ elements.tooltip.hidden = false
625
+ elements.tooltip.innerHTML =
626
+ '<strong>' + escapeHtml(node[1] || node[0]) + '</strong>' +
627
+ '<small>' + escapeHtml(node[4] || node[5] || '') + '</small>'
628
+ elements.tooltip.style.left = Math.min(state.viewport.width - 24, pointer.x + 14) + 'px'
629
+ elements.tooltip.style.top = Math.min(state.viewport.height - 24, pointer.y + 14) + 'px'
630
+ }
631
+
632
+ const hideTooltip = () => {
633
+ if (elements.tooltip) {
634
+ elements.tooltip.hidden = true
635
+ }
636
+ }
637
+
638
+ const labelCandidates = () => {
639
+ const nodes = normalizeList(state.chunk.nodes)
640
+ const visible = nodes.filter((node) => {
641
+ const x = Number(node?.[2])
642
+ const y = Number(node?.[3])
643
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return false
644
+ const point = worldToScreen(x, y)
645
+ return point.x >= -80 && point.x <= state.viewport.width + 80 && point.y >= -80 && point.y <= state.viewport.height + 80
646
+ })
647
+ const shouldShowMany = state.camera.scale >= 0.72 || visible.length <= 120
648
+ const focused = state.focusedNodeIds
649
+
650
+ return visible
651
+ .filter((node) => shouldShowMany || focused.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId || Number(node?.[7]) > 5.5)
652
+ .sort((left, right) => {
653
+ const leftFocused = focused.has(left[0]) || left[0] === state.hoveredNodeId || left[0] === state.selectedNodeId ? 1 : 0
654
+ const rightFocused = focused.has(right[0]) || right[0] === state.hoveredNodeId || right[0] === state.selectedNodeId ? 1 : 0
655
+ if (rightFocused !== leftFocused) return rightFocused - leftFocused
656
+ return Number(right?.[7] ?? 0) - Number(left?.[7] ?? 0)
657
+ })
658
+ .slice(0, state.camera.scale >= 0.72 ? 160 : 48)
659
+ }
660
+
661
+ const drawLabels = () => {
662
+ if (!elements.labels) {
663
+ return
664
+ }
665
+
666
+ elements.labels.innerHTML = labelCandidates().map((node) => {
667
+ const point = worldToScreen(Number(node[2]), Number(node[3]))
668
+ const focused = state.focusedNodeIds.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId
669
+ return '<span class="graph-label' + (focused ? ' is-focused' : '') + '" style="left:' +
670
+ point.x.toFixed(1) + 'px;top:' + point.y.toFixed(1) + 'px">' + escapeHtml(node[1] || node[0]) + '</span>'
671
+ }).join('')
672
+ }
673
+
674
+ const drawMiniMap = () => {
675
+ const miniMap = elements.miniMap
676
+ if (!(miniMap instanceof HTMLCanvasElement)) {
677
+ return
678
+ }
679
+ const nodes = normalizeList(state.chunk.nodes)
680
+ const ctx = miniMap.getContext('2d')
681
+ if (!ctx || nodes.length === 0) {
682
+ return
683
+ }
684
+
685
+ const ratio = window.devicePixelRatio || 1
686
+ const width = miniMap.clientWidth || 180
687
+ const height = miniMap.clientHeight || 120
688
+ miniMap.width = Math.floor(width * ratio)
689
+ miniMap.height = Math.floor(height * ratio)
690
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
691
+ ctx.clearRect(0, 0, width, height)
692
+ ctx.fillStyle = 'rgba(13, 16, 20, 0.86)'
693
+ ctx.fillRect(0, 0, width, height)
694
+
695
+ const xs = nodes.map((node) => Number(node[2])).filter(Number.isFinite)
696
+ const ys = nodes.map((node) => Number(node[3])).filter(Number.isFinite)
697
+ const minX = Math.min(...xs)
698
+ const maxX = Math.max(...xs)
699
+ const minY = Math.min(...ys)
700
+ const maxY = Math.max(...ys)
701
+ const graphWidth = Math.max(1, maxX - minX)
702
+ const graphHeight = Math.max(1, maxY - minY)
703
+ const scale = Math.min((width - 18) / graphWidth, (height - 18) / graphHeight)
704
+ const offsetX = (width - graphWidth * scale) / 2
705
+ const offsetY = (height - graphHeight * scale) / 2
706
+ const toMini = (x, y) => ({
707
+ x: offsetX + (x - minX) * scale,
708
+ y: offsetY + (y - minY) * scale
709
+ })
710
+ state.miniMapView = { minX, minY, scale, offsetX, offsetY, width, height }
711
+
712
+ ctx.fillStyle = 'rgba(174, 184, 197, 0.62)'
713
+ nodes.forEach((node) => {
714
+ const point = toMini(Number(node[2]), Number(node[3]))
715
+ ctx.fillRect(point.x - 1, point.y - 1, 2, 2)
716
+ })
717
+
718
+ const worldTopLeft = screenToWorld(0, 0)
719
+ const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
720
+ const topLeft = toMini(Math.min(worldTopLeft.x, worldBottomRight.x), Math.min(worldTopLeft.y, worldBottomRight.y))
721
+ const bottomRight = toMini(Math.max(worldTopLeft.x, worldBottomRight.x), Math.max(worldTopLeft.y, worldBottomRight.y))
722
+ ctx.strokeStyle = 'rgba(53, 208, 162, 0.86)'
723
+ ctx.lineWidth = 1
724
+ ctx.strokeRect(topLeft.x, topLeft.y, Math.max(3, bottomRight.x - topLeft.x), Math.max(3, bottomRight.y - topLeft.y))
725
+ }
726
+
727
+ const updateGraphOverlays = () => {
728
+ if (state.overlayScheduled) {
729
+ return
730
+ }
731
+ state.overlayScheduled = true
732
+ requestAnimationFrame(() => {
733
+ state.overlayScheduled = false
734
+ drawLabels()
735
+ drawMiniMap()
736
+ })
737
+ }
738
+
337
739
  const list = (items) => {
338
740
  const rows = normalizeList(items)
339
741
  if (rows.length === 0) {
@@ -442,6 +844,7 @@ const loadNodeDetails = async (nodeId) => {
442
844
 
443
845
  const node = payload.node
444
846
  state.selectedNodeId = node.id
847
+ setFocusedNodeIds(linkedNodeIds(node.id))
445
848
 
446
849
  if (state.renderWorker && state.workerReady) {
447
850
  state.renderWorker.postMessage({ type: 'select', id: node.id })
@@ -552,11 +955,16 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
552
955
  }
553
956
 
554
957
  state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
958
+ ensureNodePositionsLoaded()
959
+ await syncNodePositionsFromServer()
555
960
  state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
961
+ const chunkNodes = applyManualNodePositions(chunk.nodes)
556
962
  state.chunk = {
557
- nodes: normalizeList(chunk.nodes),
963
+ nodes: chunkNodes,
558
964
  edges: normalizeList(chunk.edges)
559
965
  }
966
+ state.spatialIndex.key = ''
967
+ const renderChunk = { ...chunk, nodes: chunkNodes }
560
968
  state.totals = {
561
969
  nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
562
970
  edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
@@ -570,10 +978,11 @@ const fetchChunk = async ({ fit } = { fit: false }) => {
570
978
  }
571
979
 
572
980
  if (state.renderWorker && state.workerReady) {
573
- state.renderWorker.postMessage({ type: 'chunk', chunk })
981
+ state.renderWorker.postMessage({ type: 'chunk', chunk: renderChunk })
574
982
  state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
575
983
  }
576
984
 
985
+ updateGraphOverlays()
577
986
  drawFallback()
578
987
  }
579
988
 
@@ -584,7 +993,7 @@ const scheduleChunkFetch = ({ fit } = { fit: false }) => {
584
993
 
585
994
  const now = performance.now()
586
995
  const recentlyWheeling = now - state.lastWheelAt < 180
587
- const delay = fit ? 0 : (state.pointer.down ? 120 : (recentlyWheeling ? 140 : 48))
996
+ const delay = fit ? 0 : (state.pointer.down ? 260 : (recentlyWheeling ? 160 : 48))
588
997
  state.fetchTimer = setTimeout(() => {
589
998
  state.fetchTimer = null
590
999
  fetchChunk({ fit }).catch((error) => {
@@ -605,13 +1014,13 @@ const setViewportFromCanvas = () => {
605
1014
  drawFallback()
606
1015
  }
607
1016
 
608
- const pickFallbackNodeId = (screenX, screenY) => {
609
- const nodes = normalizeList(state.chunk.nodes)
1017
+ const pickFallbackNode = (screenX, screenY) => {
1018
+ const nodes = spatialCandidates(screenX, screenY)
610
1019
  if (nodes.length === 0) {
611
- return ''
1020
+ return null
612
1021
  }
613
1022
 
614
- let bestId = ''
1023
+ let bestNode = null
615
1024
  let bestDistance = Infinity
616
1025
  for (let index = 0; index < nodes.length; index += 1) {
617
1026
  const node = nodes[index]
@@ -626,11 +1035,16 @@ const pickFallbackNodeId = (screenX, screenY) => {
626
1035
  const distance = Math.hypot(screenX - point.x, screenY - point.y)
627
1036
  if (distance <= radius && distance < bestDistance) {
628
1037
  bestDistance = distance
629
- bestId = id
1038
+ bestNode = node
630
1039
  }
631
1040
  }
632
1041
 
633
- return bestId
1042
+ return bestNode
1043
+ }
1044
+
1045
+ const pickFallbackNodeId = (screenX, screenY) => {
1046
+ const node = pickFallbackNode(screenX, screenY)
1047
+ return typeof node?.[0] === 'string' ? node[0] : ''
634
1048
  }
635
1049
 
636
1050
  const pickAt = (screenX, screenY) => {
@@ -686,14 +1100,23 @@ const setupInput = () => {
686
1100
 
687
1101
  canvas.addEventListener('pointerdown', (event) => {
688
1102
  const pointer = resolvePointer(event)
1103
+ const candidateNode = pickFallbackNode(pointer.x, pointer.y)
1104
+ const candidateNodeId = candidateNode?.[6] === 'node' && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
1105
+ const candidateX = Number(candidateNode?.[2])
1106
+ const candidateY = Number(candidateNode?.[3])
1107
+ const world = screenToWorld(pointer.x, pointer.y)
689
1108
  state.pointer.down = true
690
1109
  state.pointer.moved = false
691
1110
  state.pointer.dragging = false
1111
+ state.pointer.dragNodeId = candidateNodeId
692
1112
  state.pointer.x = pointer.x
693
1113
  state.pointer.y = pointer.y
694
1114
  state.pointer.startX = pointer.x
695
1115
  state.pointer.startY = pointer.y
696
- const world = screenToWorld(pointer.x, pointer.y)
1116
+ state.pointer.startWorldX = world.x
1117
+ state.pointer.startWorldY = world.y
1118
+ state.pointer.nodeStartX = candidateNodeId && Number.isFinite(candidateX) ? candidateX : 0
1119
+ state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
697
1120
  state.pointer.worldAnchorX = world.x
698
1121
  state.pointer.worldAnchorY = world.y
699
1122
  canvas.setPointerCapture(event.pointerId)
@@ -709,26 +1132,45 @@ const setupInput = () => {
709
1132
  if (distanceFromStart >= dragActivationDistance) {
710
1133
  state.pointer.moved = true
711
1134
  state.pointer.dragging = true
1135
+ canvas.classList.toggle('is-node-dragging', Boolean(state.pointer.dragNodeId))
712
1136
  }
713
1137
  if (!state.pointer.dragging) {
714
1138
  state.pointer.x = pointer.x
715
1139
  state.pointer.y = pointer.y
716
1140
  return
717
1141
  }
1142
+ if (state.pointer.dragNodeId) {
1143
+ const world = screenToWorld(pointer.x, pointer.y)
1144
+ const x = state.pointer.nodeStartX + world.x - state.pointer.startWorldX
1145
+ const y = state.pointer.nodeStartY + world.y - state.pointer.startWorldY
1146
+ state.nodePositions.set(state.pointer.dragNodeId, { x, y })
1147
+ updateNodePositionInChunk(state.pointer.dragNodeId, x, y)
1148
+ state.pointer.x = pointer.x
1149
+ state.pointer.y = pointer.y
1150
+ drawFallback()
1151
+ return
1152
+ }
718
1153
  state.camera.x += dx
719
1154
  state.camera.y += dy
720
1155
  state.pointer.x = pointer.x
721
1156
  state.pointer.y = pointer.y
722
1157
  updateWorkerCamera()
723
- const now = performance.now()
724
- if (now - state.lastDragFetchAt > 180) {
725
- state.lastDragFetchAt = now
726
- scheduleChunkFetch()
727
- }
728
1158
  drawFallback()
729
1159
  return
730
1160
  }
731
1161
 
1162
+ const hovered = pickFallbackNode(pointer.x, pointer.y)
1163
+ const hoveredId = hovered?.[6] === 'node' && typeof hovered?.[0] === 'string' ? hovered[0] : ''
1164
+ if (state.hoveredNodeId !== hoveredId) {
1165
+ state.hoveredNodeId = hoveredId
1166
+ canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
1167
+ updateGraphOverlays()
1168
+ }
1169
+ if (hoveredId) {
1170
+ showTooltip(hovered, pointer)
1171
+ } else {
1172
+ hideTooltip()
1173
+ }
732
1174
  })
733
1175
 
734
1176
  canvas.addEventListener('pointerup', (event) => {
@@ -736,19 +1178,49 @@ const setupInput = () => {
736
1178
  const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
737
1179
  const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
738
1180
  const shouldRefreshAfterDrag = state.pointer.dragging
1181
+ const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
739
1182
  state.pointer.down = false
740
1183
  state.pointer.dragging = false
1184
+ canvas.classList.remove('is-node-dragging')
1185
+ state.pointer.dragNodeId = ''
741
1186
  canvas.releasePointerCapture(event.pointerId)
742
1187
 
743
1188
  if (shouldPick) {
744
1189
  pickAt(pointer.x, pointer.y)
745
1190
  return
746
1191
  }
1192
+ if (shouldPersistNodePosition) {
1193
+ writeStoredNodePositions()
1194
+ persistNodePositionsToServer()
1195
+ return
1196
+ }
747
1197
  if (shouldRefreshAfterDrag) {
748
1198
  scheduleChunkFetch()
749
1199
  }
750
1200
  })
751
1201
 
1202
+ canvas.addEventListener('pointerleave', () => {
1203
+ state.hoveredNodeId = ''
1204
+ canvas.classList.remove('is-node-hover')
1205
+ hideTooltip()
1206
+ updateGraphOverlays()
1207
+ })
1208
+
1209
+ elements.miniMap.addEventListener('click', (event) => {
1210
+ if (!state.miniMapView) {
1211
+ return
1212
+ }
1213
+ const rect = elements.miniMap.getBoundingClientRect()
1214
+ const x = event.clientX - rect.left
1215
+ const y = event.clientY - rect.top
1216
+ const worldX = state.miniMapView.minX + (x - state.miniMapView.offsetX) / state.miniMapView.scale
1217
+ const worldY = state.miniMapView.minY + (y - state.miniMapView.offsetY) / state.miniMapView.scale
1218
+ state.camera.x = state.viewport.width / 2 - worldX * state.camera.scale
1219
+ state.camera.y = state.viewport.height / 2 - worldY * state.camera.scale
1220
+ updateWorkerCamera()
1221
+ scheduleChunkFetch()
1222
+ })
1223
+
752
1224
  canvas.addEventListener('dblclick', (event) => {
753
1225
  const pointer = resolvePointer(event)
754
1226
  zoomAtPoint(pointer.x, pointer.y, 1.065)
@@ -783,7 +1255,13 @@ const setupControls = () => {
783
1255
  scheduleChunkFetch()
784
1256
  })
785
1257
 
1258
+ elements.releaseNode.addEventListener('click', () => {
1259
+ releaseSelectedNodePosition()
1260
+ })
1261
+
786
1262
  elements.reset.addEventListener('click', () => {
1263
+ clearStoredNodePositions()
1264
+ clearNodePositionsOnServer()
787
1265
  state.camera = { x: 0, y: 0, scale: 0.22 }
788
1266
  updateWorkerCamera()
789
1267
  scheduleChunkFetch({ fit: true })
@@ -16,6 +16,7 @@ const state = {
16
16
  radius: new Float32Array(0),
17
17
  visible: new Uint8Array(0),
18
18
  highlighted: new Uint8Array(0),
19
+ focused: new Uint8Array(0),
19
20
  selected: new Uint8Array(0),
20
21
  edgeSource: new Uint32Array(0),
21
22
  edgeTarget: new Uint32Array(0),
@@ -23,6 +24,7 @@ const state = {
23
24
  }
24
25
  const nodeIndexById = new Map()
25
26
  const highlightedIds = new Set()
27
+ const focusedIds = new Set()
26
28
  let selectedNodeId = null
27
29
  let dirty = true
28
30
  let renderScheduled = false
@@ -179,6 +181,7 @@ const ensureNodeCapacity = (count) => {
179
181
  state.radius = new Float32Array(nextCapacity)
180
182
  state.visible = new Uint8Array(nextCapacity)
181
183
  state.highlighted = new Uint8Array(nextCapacity)
184
+ state.focused = new Uint8Array(nextCapacity)
182
185
  state.selected = new Uint8Array(nextCapacity)
183
186
  }
184
187
 
@@ -230,6 +233,7 @@ const loadChunk = (chunk) => {
230
233
  state.radius[index] = nodeRadius(relevance, kind)
231
234
  state.visible[index] = 0
232
235
  state.highlighted[index] = highlightedIds.has(id) ? 1 : 0
236
+ state.focused[index] = focusedIds.has(id) ? 1 : 0
233
237
  state.selected[index] = selectedNodeId === id ? 1 : 0
234
238
  nodeIndexById.set(id, index)
235
239
  }
@@ -390,6 +394,12 @@ const renderFrame = (now) => {
390
394
  1.22
391
395
  )
392
396
 
397
+ drawNodeLayer(
398
+ (index) => state.visible[index] === 1 && state.focused[index] === 1,
399
+ theme.nodeHighlight,
400
+ 1.12
401
+ )
402
+
393
403
  drawNodeLayer(
394
404
  (index) => state.visible[index] === 1 && state.selected[index] === 1,
395
405
  theme.nodeSelected,
@@ -489,6 +499,23 @@ const setHighlights = (ids) => {
489
499
  requestRender()
490
500
  }
491
501
 
502
+ const setFocus = (ids) => {
503
+ focusedIds.clear()
504
+ const list = Array.isArray(ids) ? ids : []
505
+ for (let index = 0; index < list.length; index += 1) {
506
+ const id = list[index]
507
+ if (typeof id === 'string' && id.length > 0) {
508
+ focusedIds.add(id)
509
+ }
510
+ }
511
+
512
+ for (let index = 0; index < state.nodeCount; index += 1) {
513
+ state.focused[index] = focusedIds.has(state.ids[index]) ? 1 : 0
514
+ }
515
+ dirty = true
516
+ requestRender()
517
+ }
518
+
492
519
  const setSelected = (id) => {
493
520
  selectedNodeId = typeof id === 'string' && id.length > 0 ? id : null
494
521
  for (let index = 0; index < state.nodeCount; index += 1) {
@@ -498,6 +525,22 @@ const setSelected = (id) => {
498
525
  requestRender()
499
526
  }
500
527
 
528
+ const moveNode = (id, x, y) => {
529
+ if (typeof id !== 'string' || !Number.isFinite(x) || !Number.isFinite(y)) {
530
+ return
531
+ }
532
+
533
+ const index = nodeIndexById.get(id)
534
+ if (index === undefined) {
535
+ return
536
+ }
537
+
538
+ state.x[index] = x
539
+ state.y[index] = y
540
+ dirty = true
541
+ requestRender()
542
+ }
543
+
501
544
  self.onmessage = (event) => {
502
545
  const payload = event.data
503
546
  if (!payload || typeof payload !== 'object') {
@@ -542,11 +585,21 @@ self.onmessage = (event) => {
542
585
  return
543
586
  }
544
587
 
588
+ if (payload.type === 'focus') {
589
+ setFocus(payload.ids)
590
+ return
591
+ }
592
+
545
593
  if (payload.type === 'select') {
546
594
  setSelected(payload.id)
547
595
  return
548
596
  }
549
597
 
598
+ if (payload.type === 'move-node') {
599
+ moveNode(payload.id, Number(payload.x), Number(payload.y))
600
+ return
601
+ }
602
+
550
603
  if (payload.type === 'pick') {
551
604
  const node = pickNode(
552
605
  Number.isFinite(payload.x) ? Number(payload.x) : 0,
@@ -1,19 +1,33 @@
1
1
  import { getGraphLayout } from './get-graph-layout.js';
2
2
  export const getGraphContexts = async (vaultPath, agentId) => {
3
3
  const { layout } = await getGraphLayout(vaultPath, { agentId });
4
- const nodeIdsByContext = layout.nodes.reduce((contexts, node) => {
4
+ const nodeIdsByContext = new Map();
5
+ const contextByNodeId = new Map();
6
+ layout.nodes.forEach((node) => {
5
7
  const title = node.segment || node.group || 'root';
6
- const nodeIds = contexts.get(title) ?? new Set();
8
+ const nodeIds = nodeIdsByContext.get(title) ?? new Set();
7
9
  nodeIds.add(node.id);
8
- contexts.set(title, nodeIds);
9
- return contexts;
10
- }, new Map());
10
+ nodeIdsByContext.set(title, nodeIds);
11
+ contextByNodeId.set(node.id, title);
12
+ });
13
+ const edgeCountByContext = new Map();
14
+ layout.edges.forEach((edge) => {
15
+ if (!edge.target) {
16
+ return;
17
+ }
18
+ const sourceContext = contextByNodeId.get(edge.source);
19
+ const targetContext = contextByNodeId.get(edge.target);
20
+ if (!sourceContext || sourceContext !== targetContext) {
21
+ return;
22
+ }
23
+ edgeCountByContext.set(sourceContext, (edgeCountByContext.get(sourceContext) ?? 0) + 1);
24
+ });
11
25
  return Array.from(nodeIdsByContext.entries())
12
26
  .map(([title, nodeIds]) => ({
13
27
  id: title,
14
28
  title,
15
29
  nodeCount: nodeIds.size,
16
- edgeCount: layout.edges.filter((edge) => nodeIds.has(edge.source) && Boolean(edge.target && nodeIds.has(edge.target))).length
30
+ edgeCount: edgeCountByContext.get(title) ?? 0
17
31
  }))
18
32
  .sort((left, right) => right.nodeCount - left.nodeCount || left.title.localeCompare(right.title));
19
33
  };
@@ -5,7 +5,7 @@ import { addVisualContextEdges } from '../domain/graph-contexts.js';
5
5
  import { createStarGraphLayout } from '../domain/graph-layout.js';
6
6
  import { indexStoragePath } from '../infrastructure/file-index.js';
7
7
  import { getGraphSummary } from './get-graph-summary.js';
8
- const graphLayoutVersion = 5;
8
+ const graphLayoutVersion = 6;
9
9
  const graphLayoutCache = new Map();
10
10
  const safeCacheSegment = (value, fallback) => value?.replace(/[^a-zA-Z0-9_-]/g, '_') || fallback;
11
11
  const graphLayoutStoragePath = (vaultPath, options) => {
@@ -0,0 +1,66 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ const stateVersion = 1;
4
+ const graphViewStatePath = (vaultPath) => join(vaultPath, '.brainlink', 'graph-view-state.json');
5
+ const stateKey = (input) => [input.signature, input.agentId ?? 'all-agents', input.context ?? 'all-contexts'].join(':');
6
+ const emptyPersistedState = () => ({
7
+ version: stateVersion,
8
+ states: {}
9
+ });
10
+ const readPersistedState = async (vaultPath) => {
11
+ try {
12
+ const parsed = JSON.parse(await readFile(graphViewStatePath(vaultPath), 'utf8'));
13
+ return parsed.version === stateVersion && parsed.states && typeof parsed.states === 'object' ? parsed : emptyPersistedState();
14
+ }
15
+ catch {
16
+ return emptyPersistedState();
17
+ }
18
+ };
19
+ const writePersistedState = async (vaultPath, state) => {
20
+ const target = graphViewStatePath(vaultPath);
21
+ const temp = `${target}.tmp`;
22
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
23
+ await writeFile(temp, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600 });
24
+ await rename(temp, target);
25
+ };
26
+ const normalizePositions = (positions) => positions.flatMap((position) => {
27
+ const id = typeof position.id === 'string' ? position.id.trim() : '';
28
+ const x = Number(position.x);
29
+ const y = Number(position.y);
30
+ return id && Number.isFinite(x) && Number.isFinite(y) ? [{ id, x, y }] : [];
31
+ });
32
+ export const getGraphViewState = async (vaultPath, input) => {
33
+ const persisted = await readPersistedState(vaultPath);
34
+ const state = persisted.states[stateKey(input)];
35
+ return state ?? {
36
+ ...input,
37
+ positions: []
38
+ };
39
+ };
40
+ export const saveGraphViewState = async (vaultPath, input) => {
41
+ const persisted = await readPersistedState(vaultPath);
42
+ const nextState = {
43
+ ...input,
44
+ positions: normalizePositions(input.positions)
45
+ };
46
+ await writePersistedState(vaultPath, {
47
+ version: stateVersion,
48
+ states: {
49
+ ...persisted.states,
50
+ [stateKey(input)]: nextState
51
+ }
52
+ });
53
+ return nextState;
54
+ };
55
+ export const deleteGraphViewState = async (vaultPath, input) => {
56
+ const persisted = await readPersistedState(vaultPath);
57
+ const { [stateKey(input)]: _removed, ...states } = persisted.states;
58
+ await writePersistedState(vaultPath, {
59
+ version: stateVersion,
60
+ states
61
+ });
62
+ return {
63
+ ...input,
64
+ positions: []
65
+ };
66
+ };
@@ -6,6 +6,7 @@ import { getGraphNode } from '../get-graph-node.js';
6
6
  import { getGraphLayout } from '../get-graph-layout.js';
7
7
  import { getGraphView } from '../get-graph-view.js';
8
8
  import { getGraphStreamChunk } from '../get-graph-stream-chunk.js';
9
+ import { deleteGraphViewState, getGraphViewState, saveGraphViewState } from '../graph-view-state.js';
9
10
  import { listAgents } from '../list-agents.js';
10
11
  import { listBacklinks, listLinks } from '../list-links.js';
11
12
  import { searchGraphNodeIds } from '../search-graph-node-ids.js';
@@ -64,6 +65,21 @@ const parseNumber = (value, fallback) => {
64
65
  const parsed = Number(value);
65
66
  return Number.isFinite(parsed) ? parsed : fallback;
66
67
  };
68
+ const readJsonBody = async (request, limitBytes = 1_000_000) => {
69
+ let body = '';
70
+ for await (const chunk of request) {
71
+ body += String(chunk);
72
+ if (Buffer.byteLength(body, 'utf8') > limitBytes) {
73
+ throw Object.assign(new Error('Request body too large'), { statusCode: 413 });
74
+ }
75
+ }
76
+ return body.trim().length > 0 ? JSON.parse(body) : {};
77
+ };
78
+ const readGraphViewStateInput = (url) => ({
79
+ signature: url.searchParams.get('signature')?.trim() ?? '',
80
+ agentId: readAgentQuery(url),
81
+ context: readContextQuery(url)
82
+ });
67
83
  const compactGraphLayoutThreshold = 12_000;
68
84
  const compactGraphLayoutEdgeLimit = 60_000;
69
85
  const graphLayoutBodyCacheLimit = 8;
@@ -285,6 +301,35 @@ export const route = async (request, url, vaultPath) => {
285
301
  context: readContextQuery(url)
286
302
  })), 200, contentTypes['.json']);
287
303
  }
304
+ if (isReadMethod(request) && url.pathname === '/api/graph-view-state') {
305
+ const input = readGraphViewStateInput(url);
306
+ if (!input.signature) {
307
+ return createResponse(createJsonResponse({ error: 'Missing signature query parameter' }), 400, contentTypes['.json']);
308
+ }
309
+ return createResponse(createJsonResponse(await getGraphViewState(vaultPath, input)), 200, contentTypes['.json']);
310
+ }
311
+ if (request.method === 'POST' && url.pathname === '/api/graph-view-state') {
312
+ const input = readGraphViewStateInput(url);
313
+ if (!input.signature) {
314
+ return createResponse(createJsonResponse({ error: 'Missing signature query parameter' }), 400, contentTypes['.json']);
315
+ }
316
+ const body = await readJsonBody(request);
317
+ const positions = Array.isArray(body.positions)
318
+ ? body.positions.map((position) => ({
319
+ id: String(position.id ?? ''),
320
+ x: Number(position.x),
321
+ y: Number(position.y)
322
+ }))
323
+ : [];
324
+ return createResponse(createJsonResponse(await saveGraphViewState(vaultPath, { ...input, positions })), 200, contentTypes['.json']);
325
+ }
326
+ if (request.method === 'DELETE' && url.pathname === '/api/graph-view-state') {
327
+ const input = readGraphViewStateInput(url);
328
+ if (!input.signature) {
329
+ return createResponse(createJsonResponse({ error: 'Missing signature query parameter' }), 400, contentTypes['.json']);
330
+ }
331
+ return createResponse(createJsonResponse(await deleteGraphViewState(vaultPath, input)), 200, contentTypes['.json']);
332
+ }
288
333
  if (isReadMethod(request) && url.pathname === '/api/graph-node') {
289
334
  const id = url.searchParams.get('id')?.trim() ?? '';
290
335
  if (!id) {
@@ -356,8 +356,9 @@ const resolveCollisionPair = (left, right, minDistance) => {
356
356
  return;
357
357
  }
358
358
  const push = (minDistance - distance) / 2;
359
- const ux = dx / distance;
360
- const uy = dy / distance;
359
+ const fallbackAngle = Math.PI * 2 * (Math.abs(hashText(`${left.id}:${right.id}`) % 1000) / 1000);
360
+ const ux = Math.abs(dx) + Math.abs(dy) < 0.001 ? Math.cos(fallbackAngle) : dx / distance;
361
+ const uy = Math.abs(dx) + Math.abs(dy) < 0.001 ? Math.sin(fallbackAngle) : dy / distance;
361
362
  left.x -= ux * push;
362
363
  left.y -= uy * push;
363
364
  right.x += ux * push;
@@ -487,19 +488,35 @@ const createStarNodes = (nodes, segments, degrees, hubId, levels) => {
487
488
  y: 0
488
489
  }));
489
490
  }
490
- const ringRadius = Math.max(300 + (level - 1) * 360, (levelNodes.length * 112) / (Math.PI * 2));
491
- return levelNodes.map((node, index) => {
492
- const segment = segments.get(node.id) ?? groupLabel(groupKey(node));
493
- const segmentOffset = (segmentIndexByName.get(segment) ?? 0) / Math.max(segmentNames.length, 1);
494
- const angle = Math.PI * 2 * ((index / Math.max(levelNodes.length, 1) + segmentOffset * 0.18) % 1) - Math.PI / 2;
495
- const radialJitter = jitter(node.id, 48);
496
- return {
497
- ...node,
498
- group: groupLabel(groupKey(node)),
499
- segment,
500
- x: Math.cos(angle) * (ringRadius + radialJitter) + jitter(node.title, 18),
501
- y: Math.sin(angle) * (ringRadius + radialJitter) + jitter(node.path, 18)
502
- };
491
+ const levelNodesBySegment = segmentNames
492
+ .map((segment) => ({
493
+ segment,
494
+ nodes: levelNodes.filter((node) => (segments.get(node.id) ?? groupLabel(groupKey(node))) === segment)
495
+ }))
496
+ .filter((group) => group.nodes.length > 0);
497
+ const totalNodes = levelNodesBySegment.reduce((total, group) => total + group.nodes.length, 0);
498
+ const baseRadius = Math.max(360 + (level - 1) * 460, (levelNodes.length * 156) / (Math.PI * 2));
499
+ let arcCursor = -Math.PI / 2;
500
+ return levelNodesBySegment.flatMap((group) => {
501
+ const arcSize = (Math.PI * 2 * group.nodes.length) / Math.max(totalNodes, 1);
502
+ const arcPadding = Math.min(0.22, arcSize * 0.18);
503
+ const arcStart = arcCursor + arcPadding;
504
+ const arcEnd = arcCursor + arcSize - arcPadding;
505
+ const usableArc = Math.max(0.001, arcEnd - arcStart);
506
+ const segmentRadius = Math.max(baseRadius, (group.nodes.length * 156) / usableArc);
507
+ arcCursor += arcSize;
508
+ return group.nodes.map((node, index) => {
509
+ const lane = index % 3 - 1;
510
+ const angle = arcStart + usableArc * ((index + 0.5) / Math.max(group.nodes.length, 1)) + jitter(node.title, 0.035);
511
+ const radialJitter = jitter(node.id, 34);
512
+ return {
513
+ ...node,
514
+ group: groupLabel(groupKey(node)),
515
+ segment: group.segment,
516
+ x: Math.cos(angle) * (segmentRadius + lane * 52 + radialJitter) + jitter(node.title, 16),
517
+ y: Math.sin(angle) * (segmentRadius + lane * 52 + radialJitter) + jitter(node.path, 16)
518
+ };
519
+ });
503
520
  });
504
521
  });
505
522
  };
@@ -508,7 +525,7 @@ export const createStarGraphLayout = (graph) => {
508
525
  const hubId = selectPrimaryHubId(graph.nodes, degrees) ?? selectHighestDegreeNodeId(graph.nodes, degrees);
509
526
  const segments = assignSegments(graph.nodes, graph.edges, degrees);
510
527
  const levels = assignStarLevels(graph.nodes, graph.edges, hubId);
511
- const nodes = relaxCollisions(createStarNodes(graph.nodes, segments, degrees, hubId, levels), 132, 18);
528
+ const nodes = relaxCollisions(createStarNodes(graph.nodes, segments, degrees, hubId, levels), 156, 22);
512
529
  const centeredNodes = centerLayoutByNode(nodes, hubId);
513
530
  const groups = createGraphLayoutHierarchy(centeredNodes, graph.edges, degrees);
514
531
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.154",
3
+ "version": "0.1.0-beta.156",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",