@andespindola/brainlink 0.1.0-beta.41 → 0.1.0-beta.42
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/CHANGELOG.md +5 -0
- package/README.md +7 -0
- package/dist/application/frontend/client-js.js +484 -47
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/server/routes.js +36 -1
- package/dist/application/start-server.js +75 -4
- package/dist/cli/commands/write-commands.js +41 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
- Fixed graph modal content loading by correcting agent query parameter composition for `/api/graph-node` and `/api/graph-filter` requests.
|
|
34
34
|
- Improved 50k+ graph rendering performance with viewport-aware spatial node culling, cached render visibility, and node-adjacent edge selection to avoid full graph scans every frame.
|
|
35
35
|
- Added incremental vault indexing with file snapshots to reuse unchanged documents/chunks/embeddings, plus adaptive search-pack rebuild thresholds to avoid full re-compression on small edits.
|
|
36
|
+
- Reduced large-graph HTTP payload size with compact `/api/graph-layout` encoding for high-node vaults and capped transmitted edges to improve UI load responsiveness.
|
|
37
|
+
- Added aggressive graph LOD clustering when zoomed out, dynamic per-zoom edge render budgets, and a dedicated frontend worker for off-main-thread graph filter matching.
|
|
38
|
+
- Improved Linux browser fallback launch stability by auto-applying Chromium compatibility flags (`--ozone-platform=x11`, `--disable-gpu`, `--disable-features=Vulkan,VaapiVideoDecoder`, `--disable-background-networking`) for app-window/browser modes.
|
|
39
|
+
- Improved massive-graph UI responsiveness with stricter render budgets, adaptive heavy-graph frame throttling, reduced interaction hit-test frequency, and URL-first agent selection on initial graph load.
|
|
40
|
+
- Improved 50k+ graph LOD behavior so zoomed-out views render lightweight cluster overviews and progressively reveal nodes/edges only as zoom increases.
|
|
36
41
|
|
|
37
42
|
## 0.1.0-beta.3
|
|
38
43
|
|
package/README.md
CHANGED
|
@@ -81,6 +81,10 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
81
81
|
- Local HTTP API.
|
|
82
82
|
- Realtime graph UI with agent selector and colored knowledge groups.
|
|
83
83
|
- Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
|
|
84
|
+
- Large graph layout API automatically uses compact payload encoding and edge-cap transmission to reduce initial client load on very large vaults.
|
|
85
|
+
- Zoomed-out graph LOD now clusters dense regions and progressively expands nodes as zoom increases.
|
|
86
|
+
- Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
|
|
87
|
+
- Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
|
|
84
88
|
|
|
85
89
|
## Install
|
|
86
90
|
|
|
@@ -566,6 +570,7 @@ By default, `blink server` tries to open the graph in a native desktop GUI windo
|
|
|
566
570
|
|
|
567
571
|
On Linux, native GUI is disabled by default for better startup performance. Enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
|
|
568
572
|
If native GUI launch is unavailable on your system, it falls back to dedicated app-window mode and then to the default browser.
|
|
573
|
+
For Chromium-family browsers on Linux (`chromium`, `chromium-browser`, `google-chrome`, `microsoft-edge`, `brave-browser`), Brainlink now auto-applies compatibility flags during launch (`--ozone-platform=x11`, `--disable-gpu`, `--disable-features=Vulkan,VaapiVideoDecoder`, `--disable-background-networking`) to avoid common Wayland/Vulkan/VAAPI startup issues.
|
|
569
574
|
Use `--no-open` to keep it headless.
|
|
570
575
|
When native GUI is used, the GUI window automatically closes when the `blink server` process stops.
|
|
571
576
|
|
|
@@ -585,6 +590,7 @@ The graph UI shows:
|
|
|
585
590
|
- double-click on canvas zooms in at cursor position
|
|
586
591
|
- floating graph totals (notes, links, tags) below the Brainlink title
|
|
587
592
|
- large-graph rendering safeguards (edge draw caps, lower redraw rate, zoom-aware interaction)
|
|
593
|
+
- massive-graph LOD progression: zoomed-out views prefer lightweight clusters, then progressively reveal nodes and edges as zoom increases
|
|
588
594
|
|
|
589
595
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
590
596
|
|
|
@@ -884,6 +890,7 @@ Starts the local read-only graph UI and HTTP API.
|
|
|
884
890
|
By default, it tries to open a native desktop GUI window for the graph URL.
|
|
885
891
|
On Linux, native GUI is disabled by default; enable it with `BRAINLINK_LINUX_NATIVE_GUI=1`.
|
|
886
892
|
If native GUI launch is unavailable, it falls back to dedicated app-window mode and then browser open.
|
|
893
|
+
When fallback opens Chromium-family browsers on Linux, Brainlink automatically uses compatibility launch flags for stable rendering on Ubuntu/Wayland setups.
|
|
887
894
|
Use `--no-open` to skip that behavior.
|
|
888
895
|
|
|
889
896
|
The HTTP server only binds to loopback hosts such as `127.0.0.1`, `localhost` or `::1`.
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
2
|
const ctx = canvas.getContext('2d')
|
|
3
3
|
const largeGraphNodeThreshold = 4000
|
|
4
|
+
const massiveGraphNodeThreshold = 20000
|
|
4
5
|
const largeGraphEdgeRenderLimit = 16000
|
|
5
|
-
const renderNodeBudget =
|
|
6
|
-
const renderEdgeBudget =
|
|
7
|
-
const
|
|
6
|
+
const renderNodeBudget = 900
|
|
7
|
+
const renderEdgeBudget = 2400
|
|
8
|
+
const clusterActivationNodeThreshold = 600
|
|
9
|
+
const clusterZoomThreshold = 0.18
|
|
10
|
+
const clusterCellPixelSize = 64
|
|
11
|
+
const minNodePixelRadius = 2.3
|
|
8
12
|
const viewportPaddingPx = 280
|
|
9
13
|
const worldCoordinateLimit = 5_000_000
|
|
10
14
|
const transformCoordinateLimit = 20_000_000
|
|
15
|
+
const hoverHitTestIntervalMs = 64
|
|
16
|
+
const overviewClusterMaxCount = 1400
|
|
11
17
|
const state = {
|
|
12
18
|
graph: { nodes: [], edges: [] },
|
|
13
19
|
nodes: [],
|
|
@@ -16,6 +22,7 @@ const state = {
|
|
|
16
22
|
visibleEdges: [],
|
|
17
23
|
renderNodes: [],
|
|
18
24
|
renderEdges: [],
|
|
25
|
+
renderClusters: [],
|
|
19
26
|
nodeDegrees: new Map(),
|
|
20
27
|
selected: null,
|
|
21
28
|
hovered: null,
|
|
@@ -29,13 +36,18 @@ const state = {
|
|
|
29
36
|
cursor: { x: 0, y: 0, inCanvas: false },
|
|
30
37
|
graphSignature: '',
|
|
31
38
|
graphStatus: '',
|
|
39
|
+
graphTotals: { nodes: 0, edges: 0 },
|
|
32
40
|
last: performance.now(),
|
|
33
41
|
offscreenFrameCount: 0,
|
|
34
42
|
recoveringViewport: false,
|
|
35
43
|
renderVisibilityDirty: true,
|
|
36
44
|
lastViewportKey: '',
|
|
37
45
|
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
38
|
-
visibleEdgeByNode: new Map()
|
|
46
|
+
visibleEdgeByNode: new Map(),
|
|
47
|
+
overviewClusters: [],
|
|
48
|
+
filterWorker: null,
|
|
49
|
+
filterReady: false,
|
|
50
|
+
lastHoverHitAt: 0
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
const byId = id => document.getElementById(id)
|
|
@@ -66,10 +78,20 @@ const elements = {
|
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
const zoomRange = {
|
|
69
|
-
min: 0.
|
|
81
|
+
min: 0.0002,
|
|
70
82
|
max: 4.5
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
const initialAgentFromUrl = (() => {
|
|
86
|
+
try {
|
|
87
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
88
|
+
const value = raw?.trim() ?? ''
|
|
89
|
+
return value.length > 0 ? value : ''
|
|
90
|
+
} catch {
|
|
91
|
+
return ''
|
|
92
|
+
}
|
|
93
|
+
})()
|
|
94
|
+
|
|
73
95
|
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
74
96
|
|
|
75
97
|
const setGraphStatus = text => {
|
|
@@ -93,6 +115,67 @@ const graphTheme = {
|
|
|
93
115
|
label: '#edf2f7'
|
|
94
116
|
}
|
|
95
117
|
|
|
118
|
+
const initFilterWorker = () => {
|
|
119
|
+
if (typeof Worker === 'undefined') {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const worker = new Worker('/app-worker.js')
|
|
124
|
+
worker.onmessage = event => {
|
|
125
|
+
const payload = event.data
|
|
126
|
+
if (!payload || typeof payload !== 'object') return
|
|
127
|
+
|
|
128
|
+
if (payload.type === 'ready') {
|
|
129
|
+
state.filterReady = true
|
|
130
|
+
if (state.nodes.length > 0) {
|
|
131
|
+
worker.postMessage({
|
|
132
|
+
type: 'load-nodes',
|
|
133
|
+
nodes: state.nodes.map(node => ({
|
|
134
|
+
id: node.id,
|
|
135
|
+
title: node.title,
|
|
136
|
+
path: node.path || '',
|
|
137
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
138
|
+
}))
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (payload.type === 'filter-result') {
|
|
145
|
+
const token = payload.token
|
|
146
|
+
if (token !== state.contentFilter.token) {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
|
|
151
|
+
state.contentFilter.query = normalizeQuery(state.query)
|
|
152
|
+
state.contentFilter.ids = new Set(ids)
|
|
153
|
+
recomputeVisibility()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
state.filterWorker = worker
|
|
157
|
+
} catch {
|
|
158
|
+
state.filterWorker = null
|
|
159
|
+
state.filterReady = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pushNodesToFilterWorker = () => {
|
|
164
|
+
if (!state.filterWorker || !state.filterReady) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
state.filterWorker.postMessage({
|
|
169
|
+
type: 'load-nodes',
|
|
170
|
+
nodes: state.nodes.map(node => ({
|
|
171
|
+
id: node.id,
|
|
172
|
+
title: node.title,
|
|
173
|
+
path: node.path || '',
|
|
174
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
175
|
+
}))
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
96
179
|
const resize = () => {
|
|
97
180
|
const rect = canvas.getBoundingClientRect()
|
|
98
181
|
const width = Math.max(rect.width, 320)
|
|
@@ -111,7 +194,7 @@ const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map
|
|
|
111
194
|
const localFilteredNodes = query =>
|
|
112
195
|
state.nodes.filter(node =>
|
|
113
196
|
node.title.toLowerCase().includes(query) ||
|
|
114
|
-
node.path.toLowerCase().includes(query) ||
|
|
197
|
+
(node.path || '').toLowerCase().includes(query) ||
|
|
115
198
|
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
116
199
|
)
|
|
117
200
|
|
|
@@ -176,6 +259,7 @@ const recomputeVisibility = () => {
|
|
|
176
259
|
state.visibleEdges = limitedEdges
|
|
177
260
|
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
178
261
|
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
262
|
+
state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
|
|
179
263
|
markRenderDirty()
|
|
180
264
|
}
|
|
181
265
|
|
|
@@ -280,6 +364,94 @@ const createVisibleEdgeLookup = edges => {
|
|
|
280
364
|
return lookup
|
|
281
365
|
}
|
|
282
366
|
|
|
367
|
+
const buildOverviewClusters = nodes => {
|
|
368
|
+
if (nodes.length === 0) {
|
|
369
|
+
return []
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const bounds = graphBounds(nodes)
|
|
373
|
+
if (!bounds) {
|
|
374
|
+
return []
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const longest = Math.max(bounds.width, bounds.height, 1)
|
|
378
|
+
const cellSize = Math.max(longest / 56, 900)
|
|
379
|
+
const buckets = new Map()
|
|
380
|
+
|
|
381
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
382
|
+
const node = nodes[index]
|
|
383
|
+
const keyX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
384
|
+
const keyY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
385
|
+
const key = keyX + ':' + keyY
|
|
386
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
387
|
+
const current = buckets.get(key)
|
|
388
|
+
if (current) {
|
|
389
|
+
current.count += 1
|
|
390
|
+
current.sumX += node.x
|
|
391
|
+
current.sumY += node.y
|
|
392
|
+
if (degree > current.degree) {
|
|
393
|
+
current.representative = node
|
|
394
|
+
current.degree = degree
|
|
395
|
+
}
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
buckets.set(key, {
|
|
400
|
+
id: key,
|
|
401
|
+
count: 1,
|
|
402
|
+
sumX: node.x,
|
|
403
|
+
sumY: node.y,
|
|
404
|
+
representative: node,
|
|
405
|
+
degree
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return Array.from(buckets.values())
|
|
410
|
+
.sort((left, right) => right.count - left.count)
|
|
411
|
+
.slice(0, overviewClusterMaxCount)
|
|
412
|
+
.map((cluster) => ({
|
|
413
|
+
id: cluster.id,
|
|
414
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
415
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
416
|
+
count: cluster.count,
|
|
417
|
+
representative: cluster.representative
|
|
418
|
+
}))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const filterOverviewClustersByViewport = viewport =>
|
|
422
|
+
state.overviewClusters.filter((cluster) =>
|
|
423
|
+
cluster.x >= viewport.minX &&
|
|
424
|
+
cluster.x <= viewport.maxX &&
|
|
425
|
+
cluster.y >= viewport.minY &&
|
|
426
|
+
cluster.y <= viewport.maxY
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
const edgeBudgetForCurrentFrame = () => {
|
|
430
|
+
const zoom = state.transform.scale
|
|
431
|
+
if (zoom < 0.12) return 380
|
|
432
|
+
if (zoom < 0.18) return 700
|
|
433
|
+
if (zoom < 0.28) return 1100
|
|
434
|
+
if (zoom < 0.45) return 1600
|
|
435
|
+
if (zoom < 0.7) return 2100
|
|
436
|
+
return renderEdgeBudget
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const clusterBudgetForScale = (scale) => {
|
|
440
|
+
if (scale < 0.008) return 90
|
|
441
|
+
if (scale < 0.014) return 150
|
|
442
|
+
if (scale < 0.022) return 240
|
|
443
|
+
if (scale < 0.035) return 360
|
|
444
|
+
return 520
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const nodeBudgetForScale = (scale) => {
|
|
448
|
+
if (scale < 0.035) return 220
|
|
449
|
+
if (scale < 0.06) return 360
|
|
450
|
+
if (scale < 0.09) return 520
|
|
451
|
+
if (scale < 0.14) return 720
|
|
452
|
+
return renderNodeBudget
|
|
453
|
+
}
|
|
454
|
+
|
|
283
455
|
const collectVisibleEdgesForNodes = nodeIds => {
|
|
284
456
|
if (nodeIds.size === 0) {
|
|
285
457
|
return []
|
|
@@ -287,6 +459,7 @@ const collectVisibleEdgesForNodes = nodeIds => {
|
|
|
287
459
|
|
|
288
460
|
const seen = new Set()
|
|
289
461
|
const collected = []
|
|
462
|
+
const limit = edgeBudgetForCurrentFrame()
|
|
290
463
|
|
|
291
464
|
nodeIds.forEach(nodeId => {
|
|
292
465
|
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
@@ -302,7 +475,7 @@ const collectVisibleEdgesForNodes = nodeIds => {
|
|
|
302
475
|
|
|
303
476
|
seen.add(key)
|
|
304
477
|
collected.push(edge)
|
|
305
|
-
if (collected.length >=
|
|
478
|
+
if (collected.length >= limit) {
|
|
306
479
|
return
|
|
307
480
|
}
|
|
308
481
|
}
|
|
@@ -327,9 +500,35 @@ const fallbackViewportNodes = () => {
|
|
|
327
500
|
return nodes
|
|
328
501
|
}
|
|
329
502
|
|
|
503
|
+
const sampleVisibleNodes = (limit = renderNodeBudget) => {
|
|
504
|
+
if (state.visibleNodes.length === 0 || limit <= 0) {
|
|
505
|
+
return []
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const nodes = []
|
|
509
|
+
const maxNodes = Math.min(Math.max(limit, 1), state.visibleNodes.length)
|
|
510
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
511
|
+
|
|
512
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
513
|
+
nodes.push(state.visibleNodes[index])
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
517
|
+
nodes.push(state.selected)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return nodes
|
|
521
|
+
}
|
|
522
|
+
|
|
330
523
|
const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
|
|
331
524
|
const isFiniteNumber = value => Number.isFinite(value)
|
|
332
525
|
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
526
|
+
const clampTransformCoordinate = value => {
|
|
527
|
+
if (!isFiniteNumber(value)) return 0
|
|
528
|
+
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
529
|
+
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
530
|
+
return value
|
|
531
|
+
}
|
|
333
532
|
|
|
334
533
|
const graphBounds = nodes => {
|
|
335
534
|
if (nodes.length === 0) return null
|
|
@@ -375,7 +574,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
|
375
574
|
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
376
575
|
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
377
576
|
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
378
|
-
return { min:
|
|
577
|
+
return { min: 0.0008, max: 0.24 }
|
|
379
578
|
}
|
|
380
579
|
|
|
381
580
|
const fitView = (options = { useFiltered: true }) => {
|
|
@@ -387,6 +586,8 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
387
586
|
|
|
388
587
|
if (!bounds) {
|
|
389
588
|
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
589
|
+
state.offscreenFrameCount = 0
|
|
590
|
+
state.recoveringViewport = false
|
|
390
591
|
markRenderDirty()
|
|
391
592
|
return
|
|
392
593
|
}
|
|
@@ -411,25 +612,62 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
411
612
|
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
412
613
|
|
|
413
614
|
state.transform = {
|
|
414
|
-
x: width / 2 - centerX * scale,
|
|
415
|
-
y: height / 2 - centerY * scale,
|
|
416
|
-
scale
|
|
615
|
+
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
616
|
+
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
617
|
+
scale: clampScale(scale)
|
|
417
618
|
}
|
|
619
|
+
state.offscreenFrameCount = 0
|
|
620
|
+
state.recoveringViewport = false
|
|
418
621
|
markRenderDirty()
|
|
419
622
|
}
|
|
420
623
|
|
|
421
624
|
const resetView = () => fitView({ useFiltered: false })
|
|
422
625
|
|
|
423
626
|
const createLayout = graph => {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
627
|
+
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
628
|
+
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
629
|
+
const nodes = nodeRows.map(node => {
|
|
630
|
+
if (Array.isArray(node)) {
|
|
631
|
+
const [id, title, x, y, group, segment] = node
|
|
632
|
+
return {
|
|
633
|
+
id: typeof id === 'string' ? id : '',
|
|
634
|
+
title: typeof title === 'string' ? title : 'Untitled',
|
|
635
|
+
path: '',
|
|
636
|
+
tags: [],
|
|
637
|
+
group: typeof group === 'string' ? group : 'root',
|
|
638
|
+
segment: typeof segment === 'string' ? segment : 'root',
|
|
639
|
+
x: Number.isFinite(x) ? x : 0,
|
|
640
|
+
y: Number.isFinite(y) ? y : 0,
|
|
641
|
+
vx: 0,
|
|
642
|
+
vy: 0
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
...node,
|
|
648
|
+
path: typeof node.path === 'string' ? node.path : '',
|
|
649
|
+
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
650
|
+
x: Number.isFinite(node.x) ? node.x : 0,
|
|
651
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
652
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
653
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
654
|
+
}
|
|
655
|
+
})
|
|
431
656
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
432
|
-
const edges =
|
|
657
|
+
const edges = edgeRows
|
|
658
|
+
.map(edge => {
|
|
659
|
+
if (Array.isArray(edge)) {
|
|
660
|
+
const [source, target, weight, priority] = edge
|
|
661
|
+
return {
|
|
662
|
+
source: typeof source === 'string' ? source : '',
|
|
663
|
+
target: typeof target === 'string' ? target : null,
|
|
664
|
+
targetTitle: '',
|
|
665
|
+
weight: Number.isFinite(weight) ? weight : 1,
|
|
666
|
+
priority: typeof priority === 'string' ? priority : 'normal'
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return edge
|
|
670
|
+
})
|
|
433
671
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
434
672
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
435
673
|
return { nodes, edges }
|
|
@@ -484,7 +722,8 @@ const syncContentFilter = async (query, token) => {
|
|
|
484
722
|
}
|
|
485
723
|
|
|
486
724
|
state.contentFilter.query = query
|
|
487
|
-
state.contentFilter.ids
|
|
725
|
+
const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
|
|
726
|
+
state.contentFilter.ids = merged
|
|
488
727
|
recomputeVisibility()
|
|
489
728
|
}
|
|
490
729
|
|
|
@@ -505,6 +744,14 @@ const scheduleContentFilterSync = () => {
|
|
|
505
744
|
ids: state.contentFilter.ids,
|
|
506
745
|
token,
|
|
507
746
|
timer: setTimeout(() => {
|
|
747
|
+
if (state.filterWorker && state.filterReady) {
|
|
748
|
+
state.filterWorker.postMessage({
|
|
749
|
+
type: 'filter',
|
|
750
|
+
query,
|
|
751
|
+
token,
|
|
752
|
+
limit: Math.max(state.nodes.length, 1)
|
|
753
|
+
})
|
|
754
|
+
}
|
|
508
755
|
syncContentFilter(query, token).catch(() => {})
|
|
509
756
|
}, 180)
|
|
510
757
|
}
|
|
@@ -513,7 +760,11 @@ const scheduleContentFilterSync = () => {
|
|
|
513
760
|
const tick = delta => {
|
|
514
761
|
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
515
762
|
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
516
|
-
|
|
763
|
+
const shouldRunPhysics =
|
|
764
|
+
state.nodes.length <= 8000 &&
|
|
765
|
+
nodes.length <= 320 &&
|
|
766
|
+
state.transform.scale >= 0.08
|
|
767
|
+
if (!shouldRunPhysics) {
|
|
517
768
|
return
|
|
518
769
|
}
|
|
519
770
|
const strength = Math.min(delta / 16, 2)
|
|
@@ -587,7 +838,10 @@ const worldPoint = event => {
|
|
|
587
838
|
|
|
588
839
|
const hitNode = point => {
|
|
589
840
|
computeRenderVisibility()
|
|
590
|
-
if (state.
|
|
841
|
+
if (state.renderClusters.length > 0) {
|
|
842
|
+
return null
|
|
843
|
+
}
|
|
844
|
+
if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.9) {
|
|
591
845
|
return null
|
|
592
846
|
}
|
|
593
847
|
|
|
@@ -648,7 +902,60 @@ const viewportNodeStride = () => {
|
|
|
648
902
|
return 8
|
|
649
903
|
}
|
|
650
904
|
|
|
905
|
+
const shouldRenderClusters = viewportNodes =>
|
|
906
|
+
state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
|
|
907
|
+
|
|
908
|
+
const clusterViewportNodes = viewportNodes => {
|
|
909
|
+
if (!shouldRenderClusters(viewportNodes)) {
|
|
910
|
+
return []
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
914
|
+
const buckets = new Map()
|
|
915
|
+
|
|
916
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
917
|
+
const node = viewportNodes[index]
|
|
918
|
+
const keyX = Math.floor(node.x / worldCellSize)
|
|
919
|
+
const keyY = Math.floor(node.y / worldCellSize)
|
|
920
|
+
const key = keyX + ':' + keyY
|
|
921
|
+
const current = buckets.get(key)
|
|
922
|
+
if (current) {
|
|
923
|
+
current.count += 1
|
|
924
|
+
current.sumX += node.x
|
|
925
|
+
current.sumY += node.y
|
|
926
|
+
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
927
|
+
current.representative = node
|
|
928
|
+
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
929
|
+
}
|
|
930
|
+
continue
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
buckets.set(key, {
|
|
934
|
+
id: key,
|
|
935
|
+
count: 1,
|
|
936
|
+
sumX: node.x,
|
|
937
|
+
sumY: node.y,
|
|
938
|
+
representative: node,
|
|
939
|
+
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return Array.from(buckets.values())
|
|
944
|
+
.sort((left, right) => right.count - left.count)
|
|
945
|
+
.slice(0, Math.min(renderNodeBudget, 900))
|
|
946
|
+
.map((cluster) => ({
|
|
947
|
+
id: cluster.id,
|
|
948
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
949
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
950
|
+
count: cluster.count,
|
|
951
|
+
representative: cluster.representative
|
|
952
|
+
}))
|
|
953
|
+
}
|
|
954
|
+
|
|
651
955
|
const computeRenderVisibility = () => {
|
|
956
|
+
if (!hasValidTransform()) {
|
|
957
|
+
fitView({ useFiltered: true })
|
|
958
|
+
}
|
|
652
959
|
const viewport = worldViewportBounds()
|
|
653
960
|
const viewportKey =
|
|
654
961
|
Math.round(viewport.minX * 10) + ':' +
|
|
@@ -665,12 +972,70 @@ const computeRenderVisibility = () => {
|
|
|
665
972
|
|
|
666
973
|
if (state.visibleNodes.length <= 2000) {
|
|
667
974
|
state.renderNodes = state.visibleNodes
|
|
975
|
+
state.renderClusters = []
|
|
668
976
|
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
669
977
|
state.renderEdges = collectVisibleEdgesForNodes(ids)
|
|
670
978
|
return
|
|
671
979
|
}
|
|
672
980
|
|
|
981
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale <= 0.035) {
|
|
982
|
+
const viewportClusters = filterOverviewClustersByViewport(viewport)
|
|
983
|
+
const clusters = viewportClusters.length > 0
|
|
984
|
+
? viewportClusters
|
|
985
|
+
: state.overviewClusters.slice(0, Math.min(220, state.overviewClusters.length))
|
|
986
|
+
const clusterLimit = clusterBudgetForScale(state.transform.scale)
|
|
987
|
+
const limitedClusters = clusters.slice(0, Math.min(clusterLimit, clusters.length))
|
|
988
|
+
if (limitedClusters.length > 0) {
|
|
989
|
+
state.renderClusters = limitedClusters
|
|
990
|
+
state.renderNodes = limitedClusters.map((cluster) => cluster.representative)
|
|
991
|
+
state.renderEdges = []
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale <= 0.06) {
|
|
997
|
+
const viewportClusters = filterOverviewClustersByViewport(viewport)
|
|
998
|
+
const clusters = viewportClusters.length > 0
|
|
999
|
+
? viewportClusters
|
|
1000
|
+
: state.overviewClusters.slice(0, Math.min(400, state.overviewClusters.length))
|
|
1001
|
+
const clusterLimit = clusterBudgetForScale(state.transform.scale)
|
|
1002
|
+
const limitedClusters = clusters.slice(0, Math.min(clusterLimit, clusters.length))
|
|
1003
|
+
if (limitedClusters.length > 0) {
|
|
1004
|
+
state.renderClusters = limitedClusters
|
|
1005
|
+
state.renderNodes = limitedClusters.map((cluster) => cluster.representative)
|
|
1006
|
+
state.renderEdges = []
|
|
1007
|
+
return
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
1012
|
+
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
1013
|
+
const sampled = sampleVisibleNodes(Math.min(sampleLimit, renderNodeBudget))
|
|
1014
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1015
|
+
state.renderClusters = []
|
|
1016
|
+
state.renderNodes = sampled
|
|
1017
|
+
state.renderEdges = state.transform.scale >= 0.12 ? collectVisibleEdgesForNodes(sampledIds) : []
|
|
1018
|
+
return
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (state.transform.scale <= 0.0015) {
|
|
1022
|
+
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
1023
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1024
|
+
state.renderClusters = []
|
|
1025
|
+
state.renderNodes = sampled
|
|
1026
|
+
state.renderEdges = collectVisibleEdgesForNodes(sampledIds)
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
|
|
673
1030
|
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1031
|
+
const clusters = clusterViewportNodes(viewportNodes)
|
|
1032
|
+
if (clusters.length > 0) {
|
|
1033
|
+
state.renderClusters = clusters
|
|
1034
|
+
state.renderNodes = clusters.map(cluster => cluster.representative)
|
|
1035
|
+
state.renderEdges = []
|
|
1036
|
+
return
|
|
1037
|
+
}
|
|
1038
|
+
state.renderClusters = []
|
|
674
1039
|
const stride = viewportNodeStride()
|
|
675
1040
|
const picked = []
|
|
676
1041
|
|
|
@@ -693,6 +1058,7 @@ const computeRenderVisibility = () => {
|
|
|
693
1058
|
const fallbackNodes = fallbackViewportNodes()
|
|
694
1059
|
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
695
1060
|
state.renderNodes = fallbackNodes
|
|
1061
|
+
state.renderClusters = []
|
|
696
1062
|
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
697
1063
|
return
|
|
698
1064
|
}
|
|
@@ -702,6 +1068,14 @@ const computeRenderVisibility = () => {
|
|
|
702
1068
|
|
|
703
1069
|
state.renderNodes = nodes
|
|
704
1070
|
state.renderEdges = edges
|
|
1071
|
+
|
|
1072
|
+
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
1073
|
+
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
1074
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1075
|
+
state.renderClusters = []
|
|
1076
|
+
state.renderNodes = fallbackNodes
|
|
1077
|
+
state.renderEdges = collectVisibleEdgesForNodes(fallbackIds)
|
|
1078
|
+
}
|
|
705
1079
|
}
|
|
706
1080
|
|
|
707
1081
|
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
@@ -732,16 +1106,29 @@ const sanitizeNodePosition = node => {
|
|
|
732
1106
|
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
733
1107
|
}
|
|
734
1108
|
|
|
735
|
-
const
|
|
1109
|
+
const sanitizeAllNodePositions = () => {
|
|
736
1110
|
state.nodes.forEach(sanitizeNodePosition)
|
|
737
1111
|
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const sanitizeGraphState = () => {
|
|
738
1115
|
state.renderNodes.forEach(sanitizeNodePosition)
|
|
739
1116
|
}
|
|
740
1117
|
|
|
741
1118
|
const render = now => {
|
|
742
1119
|
const delta = now - state.last
|
|
743
1120
|
state.last = now
|
|
744
|
-
const
|
|
1121
|
+
const backgroundFrameIntervalMs =
|
|
1122
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
1123
|
+
? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
|
|
1124
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1125
|
+
? 64
|
|
1126
|
+
: 16
|
|
1127
|
+
const isInteracting =
|
|
1128
|
+
state.pointer.down ||
|
|
1129
|
+
state.renderVisibilityDirty ||
|
|
1130
|
+
state.recoveringViewport
|
|
1131
|
+
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
745
1132
|
if (delta < minFrameIntervalMs) {
|
|
746
1133
|
requestAnimationFrame(render)
|
|
747
1134
|
return
|
|
@@ -782,7 +1169,9 @@ const render = now => {
|
|
|
782
1169
|
} else {
|
|
783
1170
|
state.offscreenFrameCount = 0
|
|
784
1171
|
}
|
|
785
|
-
const drawEdges =
|
|
1172
|
+
const drawEdges =
|
|
1173
|
+
state.renderClusters.length === 0 &&
|
|
1174
|
+
!(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
|
|
786
1175
|
if (drawEdges) {
|
|
787
1176
|
state.renderEdges.forEach(edge => {
|
|
788
1177
|
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
@@ -795,7 +1184,27 @@ const render = now => {
|
|
|
795
1184
|
})
|
|
796
1185
|
}
|
|
797
1186
|
|
|
798
|
-
state.
|
|
1187
|
+
if (state.renderClusters.length > 0) {
|
|
1188
|
+
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
1189
|
+
state.renderClusters.forEach(cluster => {
|
|
1190
|
+
const radiusPx = Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
1191
|
+
const radius = radiusPx / safeScale
|
|
1192
|
+
const haloRadius = (radiusPx + 4) / safeScale
|
|
1193
|
+
ctx.beginPath()
|
|
1194
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
1195
|
+
ctx.fillStyle = graphTheme.nodeHalo
|
|
1196
|
+
ctx.fill()
|
|
1197
|
+
ctx.beginPath()
|
|
1198
|
+
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
1199
|
+
ctx.fillStyle = graphTheme.node
|
|
1200
|
+
ctx.fill()
|
|
1201
|
+
ctx.lineWidth = 1.4 / safeScale
|
|
1202
|
+
ctx.strokeStyle = graphTheme.nodeStroke
|
|
1203
|
+
ctx.stroke()
|
|
1204
|
+
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
1205
|
+
})
|
|
1206
|
+
} else {
|
|
1207
|
+
state.renderNodes.forEach(node => {
|
|
799
1208
|
const radius = nodeRadius(node)
|
|
800
1209
|
const isSelected = state.selected?.id === node.id
|
|
801
1210
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -822,10 +1231,11 @@ const render = now => {
|
|
|
822
1231
|
ctx.textBaseline = 'top'
|
|
823
1232
|
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
824
1233
|
}
|
|
825
|
-
|
|
1234
|
+
})
|
|
1235
|
+
}
|
|
826
1236
|
|
|
827
1237
|
ctx.restore()
|
|
828
|
-
if (state.renderNodes.length === 0) {
|
|
1238
|
+
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
829
1239
|
ctx.fillStyle = '#99a5b5'
|
|
830
1240
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
831
1241
|
ctx.textAlign = 'center'
|
|
@@ -845,11 +1255,11 @@ const linkedNodes = node => {
|
|
|
845
1255
|
weight: edge.weight,
|
|
846
1256
|
priority: edge.priority
|
|
847
1257
|
} : null
|
|
848
|
-
const outgoing = state.
|
|
1258
|
+
const outgoing = state.edges
|
|
849
1259
|
.filter(edge => edge.source === node.id)
|
|
850
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
1260
|
+
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
851
1261
|
.filter(Boolean)
|
|
852
|
-
const incoming = state.
|
|
1262
|
+
const incoming = state.edges
|
|
853
1263
|
.filter(edge => edge.target === node.id)
|
|
854
1264
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
855
1265
|
.filter(Boolean)
|
|
@@ -879,14 +1289,14 @@ const fetchNodeDetails = async node => {
|
|
|
879
1289
|
|
|
880
1290
|
const openContentDialog = async node => {
|
|
881
1291
|
if (!node) return
|
|
882
|
-
|
|
883
|
-
elements.
|
|
884
|
-
elements.
|
|
885
|
-
elements.contentTags.innerHTML = node.tags.length
|
|
1292
|
+
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
1293
|
+
elements.contentPath.textContent = node.path || 'Loading...'
|
|
1294
|
+
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
886
1295
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
887
1296
|
: '<span>No tags</span>'
|
|
888
|
-
|
|
889
|
-
elements.
|
|
1297
|
+
const initialLinks = linkedNodes(node)
|
|
1298
|
+
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
1299
|
+
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
890
1300
|
elements.contentBody.textContent = 'Loading note content...'
|
|
891
1301
|
if (!elements.contentDialog.open) {
|
|
892
1302
|
elements.contentDialog.showModal()
|
|
@@ -897,6 +1307,11 @@ const openContentDialog = async node => {
|
|
|
897
1307
|
if (state.selected?.id !== node.id) {
|
|
898
1308
|
return
|
|
899
1309
|
}
|
|
1310
|
+
elements.contentTitle.textContent = detailedNode.title
|
|
1311
|
+
elements.contentPath.textContent = detailedNode.path
|
|
1312
|
+
elements.contentTags.innerHTML = detailedNode.tags.length
|
|
1313
|
+
? detailedNode.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
1314
|
+
: '<span>No tags</span>'
|
|
900
1315
|
elements.contentBody.textContent = detailedNode.content
|
|
901
1316
|
} catch {
|
|
902
1317
|
elements.contentBody.textContent = 'Unable to load note content.'
|
|
@@ -922,9 +1337,10 @@ const zoomAtPoint = (screenX, screenY, factor) => {
|
|
|
922
1337
|
if (nextScale === state.transform.scale) return
|
|
923
1338
|
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
924
1339
|
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
925
|
-
state.transform.scale = nextScale
|
|
926
|
-
state.transform.x = screenX - worldX * nextScale
|
|
927
|
-
state.transform.y = screenY - worldY * nextScale
|
|
1340
|
+
state.transform.scale = clampScale(nextScale)
|
|
1341
|
+
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
1342
|
+
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
1343
|
+
state.offscreenFrameCount = 0
|
|
928
1344
|
markRenderDirty()
|
|
929
1345
|
}
|
|
930
1346
|
|
|
@@ -1034,7 +1450,17 @@ const bindEvents = () => {
|
|
|
1034
1450
|
})
|
|
1035
1451
|
canvas.addEventListener('pointermove', event => {
|
|
1036
1452
|
const point = worldPoint(event)
|
|
1037
|
-
|
|
1453
|
+
const now = performance.now()
|
|
1454
|
+
const canHoverHitTest =
|
|
1455
|
+
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.12)
|
|
1456
|
+
const shouldHitTest = canHoverHitTest &&
|
|
1457
|
+
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
1458
|
+
if (shouldHitTest) {
|
|
1459
|
+
state.hovered = hitNode(point)
|
|
1460
|
+
state.lastHoverHitAt = now
|
|
1461
|
+
} else if (!canHoverHitTest) {
|
|
1462
|
+
state.hovered = null
|
|
1463
|
+
}
|
|
1038
1464
|
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
1039
1465
|
if (!state.pointer.down) return
|
|
1040
1466
|
const dx = event.clientX - state.pointer.x
|
|
@@ -1050,6 +1476,9 @@ const bindEvents = () => {
|
|
|
1050
1476
|
}
|
|
1051
1477
|
state.transform.x += dx
|
|
1052
1478
|
state.transform.y += dy
|
|
1479
|
+
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
1480
|
+
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
1481
|
+
state.offscreenFrameCount = 0
|
|
1053
1482
|
markRenderDirty()
|
|
1054
1483
|
})
|
|
1055
1484
|
canvas.addEventListener('pointerup', event => {
|
|
@@ -1093,9 +1522,10 @@ const loadAgents = async () => {
|
|
|
1093
1522
|
const response = await fetch('/api/agents')
|
|
1094
1523
|
const payload = await response.json()
|
|
1095
1524
|
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
1096
|
-
const
|
|
1525
|
+
const preferredAgent = state.agentId || initialAgentFromUrl
|
|
1526
|
+
const currentExists = agents.some(agent => agent.id === preferredAgent)
|
|
1097
1527
|
const selected = currentExists
|
|
1098
|
-
?
|
|
1528
|
+
? preferredAgent
|
|
1099
1529
|
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
1100
1530
|
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
1101
1531
|
|
|
@@ -1125,6 +1555,10 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
1125
1555
|
|
|
1126
1556
|
const payload = await response.json()
|
|
1127
1557
|
const graph = payload?.layout ?? payload
|
|
1558
|
+
state.graphTotals = {
|
|
1559
|
+
nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
|
|
1560
|
+
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
1561
|
+
}
|
|
1128
1562
|
const signature = payload?.signature ?? graphSignature(graph)
|
|
1129
1563
|
if (!options.reset && signature === state.graphSignature) return
|
|
1130
1564
|
const selectedId = state.selected?.id
|
|
@@ -1141,13 +1575,15 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
1141
1575
|
return degrees
|
|
1142
1576
|
}, new Map())
|
|
1143
1577
|
state.nodeDetails = new Map()
|
|
1578
|
+
pushNodesToFilterWorker()
|
|
1144
1579
|
resetContentFilter()
|
|
1580
|
+
sanitizeAllNodePositions()
|
|
1145
1581
|
recomputeVisibility()
|
|
1146
1582
|
scheduleContentFilterSync()
|
|
1147
|
-
const tags = new Set(
|
|
1148
|
-
setGraphStatus(state.agentId + ' · ' +
|
|
1149
|
-
elements.nodeCount.textContent =
|
|
1150
|
-
elements.edgeCount.textContent =
|
|
1583
|
+
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
1584
|
+
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
1585
|
+
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
1586
|
+
elements.edgeCount.textContent = state.graphTotals.edges
|
|
1151
1587
|
elements.tagCount.textContent = tags.size
|
|
1152
1588
|
resize()
|
|
1153
1589
|
if (options.reset) resetView()
|
|
@@ -1159,6 +1595,7 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
1159
1595
|
}
|
|
1160
1596
|
|
|
1161
1597
|
bindEvents()
|
|
1598
|
+
initFilterWorker()
|
|
1162
1599
|
requestAnimationFrame(() => {
|
|
1163
1600
|
resize()
|
|
1164
1601
|
resetView()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const createClientWorkerJs = () => `const normalize = value => String(value || '')
|
|
2
|
+
.normalize('NFKD')
|
|
3
|
+
.replace(/\\p{Diacritic}/gu, '')
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
|
|
6
|
+
let nodeIndex = []
|
|
7
|
+
|
|
8
|
+
const toNodeIndex = nodes =>
|
|
9
|
+
(Array.isArray(nodes) ? nodes : [])
|
|
10
|
+
.map(node => {
|
|
11
|
+
const id = typeof node.id === 'string' ? node.id : ''
|
|
12
|
+
if (!id) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
const title = normalize(node.title)
|
|
16
|
+
const path = normalize(node.path)
|
|
17
|
+
const tags = Array.isArray(node.tags) ? node.tags.map(tag => normalize(tag)) : []
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
text: [title, path, ...tags].join(' ')
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
|
|
25
|
+
const scoreText = (text, query) => {
|
|
26
|
+
if (!query) return 0
|
|
27
|
+
if (!text.includes(query)) return 0
|
|
28
|
+
if (text.startsWith(query)) return 4
|
|
29
|
+
return 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const filterIds = (query, limit) => {
|
|
33
|
+
const normalizedQuery = normalize(query).trim()
|
|
34
|
+
if (!normalizedQuery) {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
const rows = []
|
|
38
|
+
for (let index = 0; index < nodeIndex.length; index += 1) {
|
|
39
|
+
const row = nodeIndex[index]
|
|
40
|
+
const score = scoreText(row.text, normalizedQuery)
|
|
41
|
+
if (score > 0) {
|
|
42
|
+
rows.push({ id: row.id, score })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
rows.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
|
|
46
|
+
return rows.slice(0, Math.max(1, Number.isFinite(limit) ? limit : rows.length)).map(row => row.id)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
self.onmessage = event => {
|
|
50
|
+
const payload = event.data
|
|
51
|
+
if (!payload || typeof payload !== 'object') {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (payload.type === 'load-nodes') {
|
|
55
|
+
nodeIndex = toNodeIndex(payload.nodes)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (payload.type === 'filter') {
|
|
59
|
+
const token = payload.token
|
|
60
|
+
const ids = filterIds(payload.query, payload.limit)
|
|
61
|
+
self.postMessage({ type: 'filter-result', token, ids })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
self.postMessage({ type: 'ready' })
|
|
66
|
+
`;
|
|
@@ -11,6 +11,7 @@ import { loadBrainlinkConfig, sanitizeSearchMode } from '../../infrastructure/co
|
|
|
11
11
|
import { createClientCss } from '../frontend/client-css.js';
|
|
12
12
|
import { createClientHtml } from '../frontend/client-html.js';
|
|
13
13
|
import { createClientJs } from '../frontend/client-js.js';
|
|
14
|
+
import { createClientWorkerJs } from '../frontend/client-worker-js.js';
|
|
14
15
|
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
15
16
|
const readSearchMode = async (url) => {
|
|
16
17
|
const config = await loadBrainlinkConfig();
|
|
@@ -51,10 +52,41 @@ const sameEntityTag = (candidate, signature) => {
|
|
|
51
52
|
return decodeEntityTag(candidate) === signature;
|
|
52
53
|
};
|
|
53
54
|
const readAgentQuery = (url) => url.searchParams.get('agent') ?? undefined;
|
|
55
|
+
const compactGraphLayoutThreshold = 12_000;
|
|
56
|
+
const compactGraphLayoutEdgeLimit = 60_000;
|
|
57
|
+
const compactGraphLayoutEdgeLimitFor = (nodeCount) => {
|
|
58
|
+
if (nodeCount > 100_000)
|
|
59
|
+
return 15_000;
|
|
60
|
+
if (nodeCount > 50_000)
|
|
61
|
+
return 22_000;
|
|
62
|
+
if (nodeCount > 25_000)
|
|
63
|
+
return 30_000;
|
|
64
|
+
return compactGraphLayoutEdgeLimit;
|
|
65
|
+
};
|
|
54
66
|
const stripLayoutContent = (layout) => ({
|
|
55
67
|
...layout,
|
|
56
68
|
nodes: layout.nodes.map(({ content, ...node }) => node)
|
|
57
69
|
});
|
|
70
|
+
const compactLayoutPayload = (layout) => {
|
|
71
|
+
const edgeLimit = compactGraphLayoutEdgeLimitFor(layout.nodes.length);
|
|
72
|
+
const compactNodes = layout.nodes.map((node) => [node.id, node.title, node.x, node.y, node.group, node.segment]);
|
|
73
|
+
const compactEdges = [...layout.edges]
|
|
74
|
+
.sort((left, right) => (right.weight ?? 1) - (left.weight ?? 1))
|
|
75
|
+
.slice(0, edgeLimit)
|
|
76
|
+
.map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
|
|
77
|
+
return {
|
|
78
|
+
compact: true,
|
|
79
|
+
layout: {
|
|
80
|
+
nodes: compactNodes,
|
|
81
|
+
edges: compactEdges
|
|
82
|
+
},
|
|
83
|
+
totals: {
|
|
84
|
+
nodes: layout.nodes.length,
|
|
85
|
+
edges: layout.edges.length
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const encodeLayoutPayload = (layout) => layout.nodes.length > compactGraphLayoutThreshold ? compactLayoutPayload(layout) : { compact: false, layout: stripLayoutContent(layout) };
|
|
58
90
|
export const route = async (request, url, vaultPath) => {
|
|
59
91
|
if (isReadMethod(request) && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
60
92
|
return createResponse(createClientHtml(), 200, contentTypes['.html']);
|
|
@@ -65,6 +97,9 @@ export const route = async (request, url, vaultPath) => {
|
|
|
65
97
|
if (isReadMethod(request) && url.pathname === '/app.js') {
|
|
66
98
|
return createResponse(createClientJs(), 200, contentTypes['.js']);
|
|
67
99
|
}
|
|
100
|
+
if (isReadMethod(request) && url.pathname === '/app-worker.js') {
|
|
101
|
+
return createResponse(createClientWorkerJs(), 200, contentTypes['.js']);
|
|
102
|
+
}
|
|
68
103
|
if (isReadMethod(request) && url.pathname === '/api/graph') {
|
|
69
104
|
return createResponse(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
70
105
|
}
|
|
@@ -73,7 +108,7 @@ export const route = async (request, url, vaultPath) => {
|
|
|
73
108
|
const requestEtags = request.headers['if-none-match'];
|
|
74
109
|
const notModified = sameEntityTag(requestEtags, signature);
|
|
75
110
|
const etag = encodeEntityTag(signature);
|
|
76
|
-
const body = createJsonResponse({ signature,
|
|
111
|
+
const body = createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
|
|
77
112
|
const jsonResponse = createResponse(body, 200, contentTypes['.json']);
|
|
78
113
|
const notModifiedResponse = createResponse('', 304, contentTypes['.json']);
|
|
79
114
|
if (notModified) {
|
|
@@ -1,9 +1,78 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { brotliCompressSync, constants, gzipSync } from 'node:zlib';
|
|
2
3
|
import { indexVault } from './index-vault.js';
|
|
3
4
|
import { startVaultWatcher } from './watch-vault.js';
|
|
4
5
|
import { assertLoopbackHost } from './server/host-security.js';
|
|
5
6
|
import { contentTypes, createJsonResponse, isHttpError } from './server/http.js';
|
|
6
7
|
import { route } from './server/routes.js';
|
|
8
|
+
const compressionThresholdBytes = 1024;
|
|
9
|
+
const normalizeEncodingToken = (value) => value.trim().toLowerCase();
|
|
10
|
+
const supportsEncoding = (acceptEncoding, target) => {
|
|
11
|
+
if (!acceptEncoding) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return acceptEncoding
|
|
15
|
+
.split(',')
|
|
16
|
+
.map((entry) => entry.split(';')[0] ?? '')
|
|
17
|
+
.map(normalizeEncodingToken)
|
|
18
|
+
.includes(target);
|
|
19
|
+
};
|
|
20
|
+
const isCompressibleContentType = (contentType) => {
|
|
21
|
+
const normalized = contentType?.toLowerCase() ?? '';
|
|
22
|
+
return (normalized.includes('application/json') ||
|
|
23
|
+
normalized.includes('text/javascript') ||
|
|
24
|
+
normalized.includes('text/css') ||
|
|
25
|
+
normalized.includes('text/html') ||
|
|
26
|
+
normalized.startsWith('text/'));
|
|
27
|
+
};
|
|
28
|
+
const maybeCompressResponse = (requestHeaders, statusCode, headers, body) => {
|
|
29
|
+
if (statusCode === 204 || statusCode === 304) {
|
|
30
|
+
return { headers, body: '' };
|
|
31
|
+
}
|
|
32
|
+
if (!isCompressibleContentType(headers['content-type'])) {
|
|
33
|
+
return { headers, body };
|
|
34
|
+
}
|
|
35
|
+
const bodyBuffer = Buffer.from(body, 'utf8');
|
|
36
|
+
if (bodyBuffer.byteLength < compressionThresholdBytes) {
|
|
37
|
+
return { headers, body };
|
|
38
|
+
}
|
|
39
|
+
if (headers['content-encoding']) {
|
|
40
|
+
return { headers, body };
|
|
41
|
+
}
|
|
42
|
+
const acceptEncodingHeader = Array.isArray(requestHeaders['accept-encoding'])
|
|
43
|
+
? requestHeaders['accept-encoding'].join(',')
|
|
44
|
+
: requestHeaders['accept-encoding'];
|
|
45
|
+
const vary = headers.vary ? `${headers.vary}, Accept-Encoding` : 'Accept-Encoding';
|
|
46
|
+
const withVary = {
|
|
47
|
+
...headers,
|
|
48
|
+
vary
|
|
49
|
+
};
|
|
50
|
+
if (supportsEncoding(acceptEncodingHeader, 'br')) {
|
|
51
|
+
return {
|
|
52
|
+
headers: {
|
|
53
|
+
...withVary,
|
|
54
|
+
'content-encoding': 'br'
|
|
55
|
+
},
|
|
56
|
+
body: brotliCompressSync(bodyBuffer, {
|
|
57
|
+
params: {
|
|
58
|
+
[constants.BROTLI_PARAM_QUALITY]: 5
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (supportsEncoding(acceptEncodingHeader, 'gzip')) {
|
|
64
|
+
return {
|
|
65
|
+
headers: {
|
|
66
|
+
...withVary,
|
|
67
|
+
'content-encoding': 'gzip'
|
|
68
|
+
},
|
|
69
|
+
body: gzipSync(bodyBuffer, {
|
|
70
|
+
level: 6
|
|
71
|
+
})
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { headers: withVary, body };
|
|
75
|
+
};
|
|
7
76
|
export const startServer = async (input) => {
|
|
8
77
|
assertLoopbackHost(input.host);
|
|
9
78
|
if (input.shouldIndex) {
|
|
@@ -19,14 +88,16 @@ export const startServer = async (input) => {
|
|
|
19
88
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? input.host}`);
|
|
20
89
|
route(request, url, input.vaultPath)
|
|
21
90
|
.then((result) => {
|
|
22
|
-
|
|
23
|
-
response.
|
|
91
|
+
const encoded = maybeCompressResponse(request.headers, result.statusCode, result.headers, result.body);
|
|
92
|
+
response.writeHead(result.statusCode, encoded.headers);
|
|
93
|
+
response.end(encoded.body);
|
|
24
94
|
})
|
|
25
95
|
.catch((error) => {
|
|
26
96
|
const message = error instanceof Error ? error.message : String(error);
|
|
27
97
|
const statusCode = isHttpError(error) ? error.statusCode : 500;
|
|
28
|
-
|
|
29
|
-
response.
|
|
98
|
+
const fallback = maybeCompressResponse(request.headers, statusCode, { 'content-type': contentTypes['.json'] }, createJsonResponse({ error: message }));
|
|
99
|
+
response.writeHead(statusCode, fallback.headers);
|
|
100
|
+
response.end(fallback.body);
|
|
30
101
|
});
|
|
31
102
|
});
|
|
32
103
|
await new Promise((resolve, reject) => {
|
|
@@ -37,9 +37,13 @@ const parseScore = (value, fallback) => {
|
|
|
37
37
|
}
|
|
38
38
|
return parsed;
|
|
39
39
|
};
|
|
40
|
-
const spawnDetached = (command, args) => {
|
|
40
|
+
const spawnDetached = (command, args, envOverrides) => {
|
|
41
41
|
try {
|
|
42
|
-
const child = spawn(command, args, {
|
|
42
|
+
const child = spawn(command, args, {
|
|
43
|
+
detached: true,
|
|
44
|
+
stdio: 'ignore',
|
|
45
|
+
env: envOverrides ? { ...process.env, ...envOverrides } : process.env
|
|
46
|
+
});
|
|
43
47
|
child.unref();
|
|
44
48
|
return true;
|
|
45
49
|
}
|
|
@@ -219,6 +223,7 @@ const commandExists = (command) => {
|
|
|
219
223
|
};
|
|
220
224
|
const envFlagEnabled = (name) => process.env[name] === '1' || process.env[name] === 'true';
|
|
221
225
|
const spawnAnyDetached = (candidates) => candidates.some(([command, args]) => spawnDetached(command, args));
|
|
226
|
+
const spawnAnyDetachedWithEnv = (candidates) => candidates.some(([command, args, env]) => spawnDetached(command, args, env));
|
|
222
227
|
const windowsStartCandidates = (program, args = []) => [
|
|
223
228
|
['cmd', ['/c', 'start', '', program, ...args]]
|
|
224
229
|
];
|
|
@@ -318,6 +323,16 @@ const openGraphInAppWindow = (url) => {
|
|
|
318
323
|
]);
|
|
319
324
|
}
|
|
320
325
|
const appArgument = `--app=${url}`;
|
|
326
|
+
const linuxChromiumStableFlags = [
|
|
327
|
+
'--ozone-platform=x11',
|
|
328
|
+
'--disable-gpu',
|
|
329
|
+
'--disable-features=Vulkan,VaapiVideoDecoder',
|
|
330
|
+
'--disable-background-networking'
|
|
331
|
+
];
|
|
332
|
+
const linuxChromiumEnv = {
|
|
333
|
+
GDK_BACKEND: 'x11',
|
|
334
|
+
OZONE_PLATFORM: 'x11'
|
|
335
|
+
};
|
|
321
336
|
const linuxAppWindowCandidates = [
|
|
322
337
|
'microsoft-edge',
|
|
323
338
|
'microsoft-edge-stable',
|
|
@@ -327,7 +342,11 @@ const openGraphInAppWindow = (url) => {
|
|
|
327
342
|
'chromium-browser',
|
|
328
343
|
'brave-browser'
|
|
329
344
|
].filter((candidate) => commandExists(candidate));
|
|
330
|
-
return
|
|
345
|
+
return spawnAnyDetachedWithEnv(linuxAppWindowCandidates.map((command) => [
|
|
346
|
+
command,
|
|
347
|
+
[...linuxChromiumStableFlags, appArgument, '--new-window'],
|
|
348
|
+
linuxChromiumEnv
|
|
349
|
+
]));
|
|
331
350
|
};
|
|
332
351
|
const openGraphInDetectedBrowser = (url) => {
|
|
333
352
|
if (platform() === 'win32') {
|
|
@@ -339,18 +358,28 @@ const openGraphInDetectedBrowser = (url) => {
|
|
|
339
358
|
...windowsStartCandidates('brave', [url])
|
|
340
359
|
]);
|
|
341
360
|
}
|
|
361
|
+
const linuxChromiumStableFlags = [
|
|
362
|
+
'--ozone-platform=x11',
|
|
363
|
+
'--disable-gpu',
|
|
364
|
+
'--disable-features=Vulkan,VaapiVideoDecoder',
|
|
365
|
+
'--disable-background-networking'
|
|
366
|
+
];
|
|
367
|
+
const linuxChromiumEnv = {
|
|
368
|
+
GDK_BACKEND: 'x11',
|
|
369
|
+
OZONE_PLATFORM: 'x11'
|
|
370
|
+
};
|
|
342
371
|
const linuxBrowserCandidates = [
|
|
343
|
-
['microsoft-edge', [url]],
|
|
344
|
-
['microsoft-edge-stable', [url]],
|
|
345
|
-
['google-chrome', [url]],
|
|
346
|
-
['google-chrome-stable', [url]],
|
|
347
|
-
['chromium', [url]],
|
|
348
|
-
['chromium-browser', [url]],
|
|
349
|
-
['brave-browser', [url]],
|
|
350
|
-
['firefox', ['-new-window', url]]
|
|
372
|
+
['microsoft-edge', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
|
|
373
|
+
['microsoft-edge-stable', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
|
|
374
|
+
['google-chrome', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
|
|
375
|
+
['google-chrome-stable', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
|
|
376
|
+
['chromium', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
|
|
377
|
+
['chromium-browser', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
|
|
378
|
+
['brave-browser', [...linuxChromiumStableFlags, url], linuxChromiumEnv],
|
|
379
|
+
['firefox', ['-new-window', url], undefined]
|
|
351
380
|
];
|
|
352
381
|
const available = linuxBrowserCandidates.filter(([command]) => commandExists(command));
|
|
353
|
-
return
|
|
382
|
+
return spawnAnyDetachedWithEnv(available);
|
|
354
383
|
};
|
|
355
384
|
const openUrlInUi = (url, parentPid) => {
|
|
356
385
|
const openDisabled = process.env.BRAINLINK_NO_BROWSER === '1' ||
|
package/package.json
CHANGED