@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.
- package/dist/application/frontend/client-css.js +77 -0
- package/dist/application/frontend/client-html.js +4 -0
- package/dist/application/frontend/client-js.js +496 -18
- package/dist/application/frontend/client-render-worker-js.js +53 -0
- package/dist/application/get-graph-contexts.js +20 -6
- package/dist/application/get-graph-layout.js +1 -1
- package/dist/application/graph-view-state.js +66 -0
- package/dist/application/server/routes.js +45 -0
- package/dist/domain/graph-layout.js +33 -16
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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('&', '&')
|
|
@@ -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'
|
|
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:
|
|
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 ?
|
|
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
|
|
609
|
-
const 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
|
|
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
|
-
|
|
1038
|
+
bestNode = node
|
|
630
1039
|
}
|
|
631
1040
|
}
|
|
632
1041
|
|
|
633
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
8
|
+
const nodeIds = nodeIdsByContext.get(title) ?? new Set();
|
|
7
9
|
nodeIds.add(node.id);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
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:
|
|
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 =
|
|
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
|
|
360
|
-
const
|
|
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
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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),
|
|
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