@andespindola/brainlink 0.1.0-beta.154 → 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.
@@ -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>
@@ -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,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 })
@@ -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,
@@ -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.155",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",