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