@andespindola/brainlink 0.1.0-beta.117 → 0.1.0-beta.119
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/README.md
CHANGED
|
@@ -84,11 +84,11 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
84
84
|
- Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
|
|
85
85
|
- Canvas graph rendering uses the same batched node and edge pipeline for every graph size, reducing per-frame draw calls while keeping selected and hovered items highlighted.
|
|
86
86
|
- WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
|
|
87
|
-
- Graph rendering
|
|
87
|
+
- Graph rendering keeps the flat node scene and adds stable hierarchical groups for vaults above 1000 notes, with recursive parent groups when a level itself exceeds 1000 groups.
|
|
88
88
|
- Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
|
|
89
89
|
- Large-segment layout spacing now grows logarithmically to keep initial visual density consistent between medium and very large vaults (for example, ~1k vs ~50k notes).
|
|
90
90
|
- Graph coordinates are visually compacted across graph sizes so reset starts from a stable fitted scene and zoom-in progressively reveals local detail.
|
|
91
|
-
- Zoomed-out graph LOD samples visible nodes
|
|
91
|
+
- Zoomed-out graph LOD samples visible nodes, priority links and stable hierarchy groups without replacing the whole scene during zoom.
|
|
92
92
|
- Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
|
|
93
93
|
- Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
|
|
94
94
|
- Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
|
|
@@ -596,7 +596,7 @@ The graph UI shows:
|
|
|
596
596
|
- graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
|
|
597
597
|
- wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
|
|
598
598
|
- continuous target-scale interpolation for wheel/button zoom to avoid abrupt jumps while keeping cursor-anchored focus
|
|
599
|
-
- Bloom-like scene navigation: reset fits the current graph scene, wheel zoom stays anchored to the cursor, and WebGL acceleration
|
|
599
|
+
- Bloom-like scene navigation: reset fits the current graph scene, wheel zoom stays anchored to the cursor, and WebGL acceleration draws the dense node and edge layer faster
|
|
600
600
|
- zoom-out floor for large and massive graphs to keep the scene reachable without switching into a separate macro graph mode
|
|
601
601
|
- keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
|
|
602
602
|
- double-click on canvas zooms in at cursor position
|
|
@@ -604,7 +604,7 @@ The graph UI shows:
|
|
|
604
604
|
- graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
|
|
605
605
|
- adaptive CPU safeguards for large graphs: idle frame pacing, throttled background physics updates and cached viewport dimensions to reduce redraw/layout overhead while preserving interaction responsiveness
|
|
606
606
|
- WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
|
|
607
|
-
- large graph LOD keeps the same scene model as smaller graphs: visible nodes are sampled near the viewport/focus, real links are prioritized, and
|
|
607
|
+
- large graph LOD keeps the same real-node scene model as smaller graphs: visible nodes are sampled near the viewport/focus, real links are prioritized, and stable hierarchy groups provide zoom-out context without a synthetic 3D projection
|
|
608
608
|
|
|
609
609
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
610
610
|
|
|
@@ -8,7 +8,10 @@ const renderNodeBudget = 900
|
|
|
8
8
|
const zoomedMassiveRenderNodeBudget = 2200
|
|
9
9
|
const massiveOverviewRenderNodeBudget = 1800
|
|
10
10
|
const massiveOverviewScaleThreshold = 0.065
|
|
11
|
+
const massiveSegmentedScaleThreshold = 0.45
|
|
12
|
+
const massiveSegmentRepresentativeBudget = 760
|
|
11
13
|
const massiveAutoFitMacroScale = 0.018
|
|
14
|
+
const hierarchyGroupScaleThreshold = 0.62
|
|
12
15
|
const minNodePixelRadius = 2.3
|
|
13
16
|
const viewportPaddingPx = 280
|
|
14
17
|
const worldCoordinateLimit = 5_000_000
|
|
@@ -36,6 +39,9 @@ const physicsStepDeltaCapMs = 96
|
|
|
36
39
|
const state = {
|
|
37
40
|
graph: { nodes: [], edges: [] },
|
|
38
41
|
nodes: [],
|
|
42
|
+
groups: [],
|
|
43
|
+
groupById: new Map(),
|
|
44
|
+
leafGroups: [],
|
|
39
45
|
nodeById: new Map(),
|
|
40
46
|
edges: [],
|
|
41
47
|
visibleNodes: [],
|
|
@@ -1149,6 +1155,7 @@ const drawAcceleratedGraph = (width, height, drawEdges) => {
|
|
|
1149
1155
|
ctx.save()
|
|
1150
1156
|
ctx.translate(state.transform.x, state.transform.y)
|
|
1151
1157
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
1158
|
+
drawHierarchyGroups()
|
|
1152
1159
|
if (drawEdges) {
|
|
1153
1160
|
graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
|
|
1154
1161
|
}
|
|
@@ -1159,6 +1166,101 @@ const drawAcceleratedGraph = (width, height, drawEdges) => {
|
|
|
1159
1166
|
return true
|
|
1160
1167
|
}
|
|
1161
1168
|
|
|
1169
|
+
const focusedGroup = () => {
|
|
1170
|
+
if (state.groups.length === 0) {
|
|
1171
|
+
return null
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const selectedId = state.selected?.id ?? state.hovered?.id
|
|
1175
|
+
if (selectedId) {
|
|
1176
|
+
const selectedGroup = state.leafGroups.find(group => group.nodeIds.includes(selectedId))
|
|
1177
|
+
if (selectedGroup) {
|
|
1178
|
+
return selectedGroup
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const focusPoint = performance.now() - state.lastZoomFocus.at <= 1800
|
|
1183
|
+
? state.lastZoomFocus
|
|
1184
|
+
: viewportCenterWorldPoint()
|
|
1185
|
+
return state.leafGroups
|
|
1186
|
+
.map(group => ({
|
|
1187
|
+
group,
|
|
1188
|
+
distance: Math.hypot(group.x - focusPoint.x, group.y - focusPoint.y)
|
|
1189
|
+
}))
|
|
1190
|
+
.sort((left, right) => left.distance - right.distance)[0]?.group ?? null
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const hierarchyGroupOpacity = (group, focus) => {
|
|
1194
|
+
if (state.transform.scale >= hierarchyGroupScaleThreshold || state.groups.length === 0) {
|
|
1195
|
+
return 0
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const base = state.transform.scale < 0.08 ? 0.28 : state.transform.scale < 0.22 ? 0.2 : 0.12
|
|
1199
|
+
if (!focus) {
|
|
1200
|
+
return base
|
|
1201
|
+
}
|
|
1202
|
+
if (group.id === focus.id || group.childGroupIds.includes(focus.id) || group.id === focus.parentId) {
|
|
1203
|
+
return Math.min(0.36, base + 0.12)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
return base * 0.58
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const visibleHierarchyGroups = () => {
|
|
1210
|
+
if (state.groups.length === 0 || state.transform.scale >= hierarchyGroupScaleThreshold) {
|
|
1211
|
+
return []
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const viewport = expandViewportBounds(worldViewportBounds(), 900 / Math.max(state.transform.scale, 0.0001))
|
|
1215
|
+
const groups = state.groups.filter(group =>
|
|
1216
|
+
group.x + group.radius >= viewport.minX &&
|
|
1217
|
+
group.x - group.radius <= viewport.maxX &&
|
|
1218
|
+
group.y + group.radius >= viewport.minY &&
|
|
1219
|
+
group.y - group.radius <= viewport.maxY
|
|
1220
|
+
)
|
|
1221
|
+
const level = groups.some(group => group.parentId === null && group.childGroupIds.length > 0 && state.transform.scale < 0.18)
|
|
1222
|
+
? 1
|
|
1223
|
+
: 0
|
|
1224
|
+
|
|
1225
|
+
return groups
|
|
1226
|
+
.filter(group => group.level === level || group.parentId === null)
|
|
1227
|
+
.slice(0, 1400)
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const drawHierarchyGroups = () => {
|
|
1231
|
+
const groups = visibleHierarchyGroups()
|
|
1232
|
+
if (groups.length === 0) {
|
|
1233
|
+
return
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const focus = focusedGroup()
|
|
1237
|
+
for (let index = 0; index < groups.length; index += 1) {
|
|
1238
|
+
const group = groups[index]
|
|
1239
|
+
const opacity = hierarchyGroupOpacity(group, focus)
|
|
1240
|
+
if (opacity <= 0) {
|
|
1241
|
+
continue
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const isFocus = focus && (group.id === focus.id || group.childGroupIds.includes(focus.id) || group.id === focus.parentId)
|
|
1245
|
+
ctx.beginPath()
|
|
1246
|
+
ctx.arc(group.x, group.y, group.radius * (isFocus ? 1.04 : 1), 0, Math.PI * 2)
|
|
1247
|
+
ctx.fillStyle = 'rgba(148, 163, 184, ' + (opacity * 0.12) + ')'
|
|
1248
|
+
ctx.fill()
|
|
1249
|
+
ctx.lineWidth = isFocus ? 2.2 : 1.2
|
|
1250
|
+
ctx.strokeStyle = 'rgba(148, 163, 184, ' + opacity + ')'
|
|
1251
|
+
ctx.stroke()
|
|
1252
|
+
|
|
1253
|
+
if (state.transform.scale >= 0.12 && groups.length <= 220) {
|
|
1254
|
+
ctx.fillStyle = 'rgba(226, 232, 240, ' + Math.min(0.86, opacity + 0.34) + ')'
|
|
1255
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1256
|
+
ctx.textAlign = 'center'
|
|
1257
|
+
ctx.textBaseline = 'middle'
|
|
1258
|
+
ctx.fillText(group.title.slice(0, 36), group.x, group.y)
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
ctx.globalAlpha = 1
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1162
1264
|
const edgePairKey = (source, target) =>
|
|
1163
1265
|
source < target ? source + '|' + target : target + '|' + source
|
|
1164
1266
|
|
|
@@ -1316,6 +1418,77 @@ const sampleMassiveOverviewNodes = (limit) => {
|
|
|
1316
1418
|
return ensureHubNodesInRenderedSet(sampled)
|
|
1317
1419
|
}
|
|
1318
1420
|
|
|
1421
|
+
const representativeNodeFromBucket = bucket => {
|
|
1422
|
+
if (!bucket || bucket.length === 0) {
|
|
1423
|
+
return null
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
let representative = bucket[0]
|
|
1427
|
+
let representativeDegree = state.nodeDegrees.get(representative.id) ?? 0
|
|
1428
|
+
|
|
1429
|
+
for (let index = 1; index < bucket.length; index += 1) {
|
|
1430
|
+
const candidate = bucket[index]
|
|
1431
|
+
const candidateDegree = state.nodeDegrees.get(candidate.id) ?? 0
|
|
1432
|
+
if (candidateDegree <= representativeDegree) {
|
|
1433
|
+
continue
|
|
1434
|
+
}
|
|
1435
|
+
representative = candidate
|
|
1436
|
+
representativeDegree = candidateDegree
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
return representative
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const sampleMassiveSegmentRepresentatives = (limit) => {
|
|
1443
|
+
const spatial = state.visibleNodeSpatial
|
|
1444
|
+
if (!spatial || spatial.buckets.size === 0 || limit <= 0) {
|
|
1445
|
+
return []
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const keys = [...spatial.buckets.keys()].sort()
|
|
1449
|
+
const maxNodes = Math.min(limit, keys.length)
|
|
1450
|
+
const step = Math.max(1, Math.ceil(keys.length / maxNodes))
|
|
1451
|
+
const representatives = []
|
|
1452
|
+
|
|
1453
|
+
for (let index = 0; index < keys.length && representatives.length < maxNodes; index += step) {
|
|
1454
|
+
const representative = representativeNodeFromBucket(spatial.buckets.get(keys[index]))
|
|
1455
|
+
if (representative) {
|
|
1456
|
+
representatives.push(representative)
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
return representatives
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const massiveSegmentRepresentativeLimit = (scale, limit) => {
|
|
1464
|
+
if (scale >= massiveSegmentedScaleThreshold) {
|
|
1465
|
+
return 0
|
|
1466
|
+
}
|
|
1467
|
+
if (scale < 0.09) {
|
|
1468
|
+
return Math.min(massiveSegmentRepresentativeBudget, Math.floor(limit * 0.5))
|
|
1469
|
+
}
|
|
1470
|
+
if (scale < 0.18) {
|
|
1471
|
+
return Math.min(620, Math.floor(limit * 0.42))
|
|
1472
|
+
}
|
|
1473
|
+
if (scale < 0.28) {
|
|
1474
|
+
return Math.min(460, Math.floor(limit * 0.34))
|
|
1475
|
+
}
|
|
1476
|
+
return Math.min(260, Math.floor(limit * 0.22))
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const sampleMassiveSegmentedNodes = (limit, viewport) => {
|
|
1480
|
+
const representativeLimit = massiveSegmentRepresentativeLimit(state.transform.scale, limit)
|
|
1481
|
+
const representatives = sampleMassiveSegmentRepresentatives(representativeLimit)
|
|
1482
|
+
const localLimit = Math.max(1, limit - representatives.length)
|
|
1483
|
+
const localMargin = Math.max(520, Math.min(5200, 780 / Math.max(state.transform.scale, 0.0001)))
|
|
1484
|
+
const localViewport = expandViewportBounds(viewport, localMargin)
|
|
1485
|
+
const localViewportNodes = viewportNodesFromSpatialIndex(localViewport)
|
|
1486
|
+
const localSource = localViewportNodes.length > 0 ? localViewportNodes : state.visibleNodes
|
|
1487
|
+
const localNodes = selectStableSampleNodes(localSource, localLimit)
|
|
1488
|
+
|
|
1489
|
+
return mergeUniqueNodes(representatives, localNodes, limit)
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1319
1492
|
const enrichSampleWithNeighbors = (nodes) => {
|
|
1320
1493
|
if (nodes.length === 0) {
|
|
1321
1494
|
return {
|
|
@@ -1694,6 +1867,44 @@ const layoutDensityScaleForNodeCount = (nodeCount) => {
|
|
|
1694
1867
|
return 0.88
|
|
1695
1868
|
}
|
|
1696
1869
|
|
|
1870
|
+
const createHierarchyGroups = (graph, densityScale) => {
|
|
1871
|
+
const groupRows = Array.isArray(graph.groups) ? graph.groups : []
|
|
1872
|
+
|
|
1873
|
+
return groupRows.map(group => {
|
|
1874
|
+
if (Array.isArray(group)) {
|
|
1875
|
+
const [id, level, parentId, title, x, y, radius, segment, folderGroup, nodeIds, childGroupIds] = group
|
|
1876
|
+
return {
|
|
1877
|
+
id: typeof id === 'string' ? id : '',
|
|
1878
|
+
level: Number.isFinite(level) ? level : 0,
|
|
1879
|
+
parentId: typeof parentId === 'string' ? parentId : null,
|
|
1880
|
+
title: typeof title === 'string' ? title : 'Group',
|
|
1881
|
+
x: Number.isFinite(x) ? x * densityScale : 0,
|
|
1882
|
+
y: Number.isFinite(y) ? y * densityScale : 0,
|
|
1883
|
+
radius: Number.isFinite(radius) ? radius * densityScale : 120,
|
|
1884
|
+
segment: typeof segment === 'string' ? segment : 'root',
|
|
1885
|
+
group: typeof folderGroup === 'string' ? folderGroup : 'root',
|
|
1886
|
+
nodeIds: Array.isArray(nodeIds) ? nodeIds.filter(nodeId => typeof nodeId === 'string') : [],
|
|
1887
|
+
childGroupIds: Array.isArray(childGroupIds) ? childGroupIds.filter(groupId => typeof groupId === 'string') : []
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
return {
|
|
1892
|
+
...group,
|
|
1893
|
+
id: typeof group.id === 'string' ? group.id : '',
|
|
1894
|
+
level: Number.isFinite(group.level) ? group.level : 0,
|
|
1895
|
+
parentId: typeof group.parentId === 'string' ? group.parentId : null,
|
|
1896
|
+
title: typeof group.title === 'string' ? group.title : 'Group',
|
|
1897
|
+
x: Number.isFinite(group.x) ? group.x * densityScale : 0,
|
|
1898
|
+
y: Number.isFinite(group.y) ? group.y * densityScale : 0,
|
|
1899
|
+
radius: Number.isFinite(group.radius) ? group.radius * densityScale : 120,
|
|
1900
|
+
segment: typeof group.segment === 'string' ? group.segment : 'root',
|
|
1901
|
+
group: typeof group.group === 'string' ? group.group : 'root',
|
|
1902
|
+
nodeIds: Array.isArray(group.nodeIds) ? group.nodeIds.filter(nodeId => typeof nodeId === 'string') : [],
|
|
1903
|
+
childGroupIds: Array.isArray(group.childGroupIds) ? group.childGroupIds.filter(groupId => typeof groupId === 'string') : []
|
|
1904
|
+
}
|
|
1905
|
+
}).filter(group => group.id)
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1697
1908
|
const createLayout = graph => {
|
|
1698
1909
|
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
1699
1910
|
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
@@ -1726,6 +1937,7 @@ const createLayout = graph => {
|
|
|
1726
1937
|
}
|
|
1727
1938
|
})
|
|
1728
1939
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
1940
|
+
const groups = createHierarchyGroups(graph, densityScale)
|
|
1729
1941
|
const edges = edgeRows
|
|
1730
1942
|
.map(edge => {
|
|
1731
1943
|
if (Array.isArray(edge)) {
|
|
@@ -1742,7 +1954,7 @@ const createLayout = graph => {
|
|
|
1742
1954
|
})
|
|
1743
1955
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
1744
1956
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
1745
|
-
return { nodes, edges }
|
|
1957
|
+
return { nodes, edges, groups }
|
|
1746
1958
|
}
|
|
1747
1959
|
|
|
1748
1960
|
const encodeEntityTag = (value) => {
|
|
@@ -2146,7 +2358,16 @@ const computeRenderVisibility = () => {
|
|
|
2146
2358
|
}
|
|
2147
2359
|
|
|
2148
2360
|
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2149
|
-
const
|
|
2361
|
+
const segmentedNodes =
|
|
2362
|
+
state.transform.scale < massiveSegmentedScaleThreshold
|
|
2363
|
+
? sampleMassiveSegmentedNodes(sampleLimit, viewport)
|
|
2364
|
+
: []
|
|
2365
|
+
const sourceNodes =
|
|
2366
|
+
segmentedNodes.length > 0
|
|
2367
|
+
? segmentedNodes
|
|
2368
|
+
: viewportNodes.length > 0
|
|
2369
|
+
? viewportNodes
|
|
2370
|
+
: sampleMassiveOverviewNodes(sampleLimit)
|
|
2150
2371
|
const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
|
|
2151
2372
|
const carryViewport = expandViewportBounds(viewport, carryMargin)
|
|
2152
2373
|
const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
|
|
@@ -2356,6 +2577,7 @@ const render = now => {
|
|
|
2356
2577
|
ctx.save()
|
|
2357
2578
|
ctx.translate(state.transform.x, state.transform.y)
|
|
2358
2579
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
2580
|
+
drawHierarchyGroups()
|
|
2359
2581
|
if (drawEdges) {
|
|
2360
2582
|
drawGraphEdges()
|
|
2361
2583
|
}
|
|
@@ -2761,6 +2983,9 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
2761
2983
|
state.graphSignature = signature
|
|
2762
2984
|
state.graph = graph
|
|
2763
2985
|
state.nodes = layout.nodes
|
|
2986
|
+
state.groups = layout.groups
|
|
2987
|
+
state.groupById = new Map(state.groups.map(group => [group.id, group]))
|
|
2988
|
+
state.leafGroups = state.groups.filter(group => group.nodeIds.length > 0)
|
|
2764
2989
|
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
2765
2990
|
state.edges = layout.edges
|
|
2766
2991
|
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
@@ -114,11 +114,25 @@ const compactLayoutPayload = (layout) => {
|
|
|
114
114
|
const compactNodes = layout.nodes.map((node) => [node.id, node.title, node.x, node.y, node.group, node.segment]);
|
|
115
115
|
const compactEdgeRows = compactEdges
|
|
116
116
|
.map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
|
|
117
|
+
const compactGroups = layout.groups?.map((group) => [
|
|
118
|
+
group.id,
|
|
119
|
+
group.level,
|
|
120
|
+
group.parentId,
|
|
121
|
+
group.title,
|
|
122
|
+
group.x,
|
|
123
|
+
group.y,
|
|
124
|
+
group.radius,
|
|
125
|
+
group.segment,
|
|
126
|
+
group.group,
|
|
127
|
+
group.nodeIds,
|
|
128
|
+
group.childGroupIds
|
|
129
|
+
]);
|
|
117
130
|
return {
|
|
118
131
|
compact: true,
|
|
119
132
|
layout: {
|
|
120
133
|
nodes: compactNodes,
|
|
121
|
-
edges: compactEdgeRows
|
|
134
|
+
edges: compactEdgeRows,
|
|
135
|
+
...(compactGroups && compactGroups.length > 0 ? { groups: compactGroups } : {})
|
|
122
136
|
},
|
|
123
137
|
totals: {
|
|
124
138
|
nodes: layout.nodes.length,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const hierarchyGroupNodeLimit = 1000;
|
|
1
2
|
const groupLabels = {
|
|
2
3
|
'00-maps': 'maps',
|
|
3
4
|
'10-agent-memory': 'agent-memory',
|
|
@@ -192,6 +193,132 @@ const createSegmentNodes = (segments, degrees, segmentCount) => ([segment, nodes
|
|
|
192
193
|
});
|
|
193
194
|
};
|
|
194
195
|
const distanceBetween = (left, right) => Math.hypot(right.x - left.x, right.y - left.y);
|
|
196
|
+
const layoutBounds = (nodes) => {
|
|
197
|
+
if (nodes.length === 0) {
|
|
198
|
+
return { x: 0, y: 0, radius: 1 };
|
|
199
|
+
}
|
|
200
|
+
const bounds = nodes.reduce((current, node) => ({
|
|
201
|
+
minX: Math.min(current.minX, node.x),
|
|
202
|
+
maxX: Math.max(current.maxX, node.x),
|
|
203
|
+
minY: Math.min(current.minY, node.y),
|
|
204
|
+
maxY: Math.max(current.maxY, node.y)
|
|
205
|
+
}), {
|
|
206
|
+
minX: Number.POSITIVE_INFINITY,
|
|
207
|
+
maxX: Number.NEGATIVE_INFINITY,
|
|
208
|
+
minY: Number.POSITIVE_INFINITY,
|
|
209
|
+
maxY: Number.NEGATIVE_INFINITY
|
|
210
|
+
});
|
|
211
|
+
const x = (bounds.minX + bounds.maxX) / 2;
|
|
212
|
+
const y = (bounds.minY + bounds.maxY) / 2;
|
|
213
|
+
const radius = nodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - x, node.y - y)), 1);
|
|
214
|
+
return { x, y, radius: Math.max(radius + 72, 120) };
|
|
215
|
+
};
|
|
216
|
+
const edgeTouchesGroup = (edge, nodeIds) => nodeIds.has(edge.source) || Boolean(edge.target && nodeIds.has(edge.target));
|
|
217
|
+
const edgeInsideGroup = (edge, nodeIds) => nodeIds.has(edge.source) && Boolean(edge.target && nodeIds.has(edge.target));
|
|
218
|
+
const groupTitle = (segment, level, index, nodes) => nodes.length === 1
|
|
219
|
+
? nodes[0]?.title ?? segment
|
|
220
|
+
: `${segment} ${level + 1}.${index + 1}`;
|
|
221
|
+
const chunkNodes = (nodes, degrees, groupNodeLimit = hierarchyGroupNodeLimit) => {
|
|
222
|
+
const sortedNodes = [...nodes].sort((left, right) => {
|
|
223
|
+
const segmentDelta = left.segment.localeCompare(right.segment);
|
|
224
|
+
if (segmentDelta !== 0)
|
|
225
|
+
return segmentDelta;
|
|
226
|
+
const groupDelta = left.group.localeCompare(right.group);
|
|
227
|
+
if (groupDelta !== 0)
|
|
228
|
+
return groupDelta;
|
|
229
|
+
const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
|
|
230
|
+
if (degreeDelta !== 0)
|
|
231
|
+
return degreeDelta;
|
|
232
|
+
return left.title.localeCompare(right.title);
|
|
233
|
+
});
|
|
234
|
+
const chunks = [];
|
|
235
|
+
for (let index = 0; index < sortedNodes.length; index += groupNodeLimit) {
|
|
236
|
+
chunks.push(sortedNodes.slice(index, index + groupNodeLimit));
|
|
237
|
+
}
|
|
238
|
+
return chunks;
|
|
239
|
+
};
|
|
240
|
+
const groupEdges = (edges, nodeIds) => ({
|
|
241
|
+
internalEdges: edges.filter((edge) => edgeInsideGroup(edge, nodeIds)),
|
|
242
|
+
externalEdges: edges.filter((edge) => edgeTouchesGroup(edge, nodeIds) && !edgeInsideGroup(edge, nodeIds))
|
|
243
|
+
});
|
|
244
|
+
const groupBounds = (groups) => {
|
|
245
|
+
if (groups.length === 0) {
|
|
246
|
+
return { x: 0, y: 0, radius: 1 };
|
|
247
|
+
}
|
|
248
|
+
const nodes = groups.map((group) => ({
|
|
249
|
+
x: group.x,
|
|
250
|
+
y: group.y,
|
|
251
|
+
radius: group.radius
|
|
252
|
+
}));
|
|
253
|
+
const x = nodes.reduce((sum, node) => sum + node.x, 0) / nodes.length;
|
|
254
|
+
const y = nodes.reduce((sum, node) => sum + node.y, 0) / nodes.length;
|
|
255
|
+
const radius = nodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - x, node.y - y) + node.radius), 1);
|
|
256
|
+
return { x, y, radius: Math.max(radius + 120, 180) };
|
|
257
|
+
};
|
|
258
|
+
const descendantNodeIds = (groups) => groups.flatMap((group) => group.nodeIds);
|
|
259
|
+
const createParentGroups = (groups, edges, level, groupNodeLimit) => {
|
|
260
|
+
if (groups.length <= groupNodeLimit) {
|
|
261
|
+
return groups;
|
|
262
|
+
}
|
|
263
|
+
const parentGroups = [];
|
|
264
|
+
for (let index = 0; index < groups.length; index += groupNodeLimit) {
|
|
265
|
+
const chunk = groups.slice(index, index + groupNodeLimit);
|
|
266
|
+
const nodeIds = new Set(descendantNodeIds(chunk));
|
|
267
|
+
const bounds = groupBounds(chunk);
|
|
268
|
+
const segment = chunk[0]?.segment ?? 'root';
|
|
269
|
+
const group = chunk[0]?.group ?? 'root';
|
|
270
|
+
const groupIndex = index / groupNodeLimit;
|
|
271
|
+
const id = ['root', level, groupIndex, chunk[0]?.id ?? 'empty', chunk.length].join(':');
|
|
272
|
+
const edgeGroups = groupEdges(edges, nodeIds);
|
|
273
|
+
parentGroups.push({
|
|
274
|
+
id,
|
|
275
|
+
level,
|
|
276
|
+
parentId: null,
|
|
277
|
+
title: `${segment} ${level + 1}.${Math.floor(groupIndex) + 1}`,
|
|
278
|
+
segment,
|
|
279
|
+
group,
|
|
280
|
+
x: bounds.x,
|
|
281
|
+
y: bounds.y,
|
|
282
|
+
radius: bounds.radius,
|
|
283
|
+
nodeIds: [],
|
|
284
|
+
childGroupIds: chunk.map((child) => child.id),
|
|
285
|
+
internalEdges: edgeGroups.internalEdges,
|
|
286
|
+
externalEdges: edgeGroups.externalEdges
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const relinkedChildren = groups.map((group) => {
|
|
290
|
+
const parent = parentGroups.find((candidate) => candidate.childGroupIds.includes(group.id));
|
|
291
|
+
return parent ? { ...group, parentId: parent.id } : group;
|
|
292
|
+
});
|
|
293
|
+
return [...createParentGroups(parentGroups, edges, level + 1, groupNodeLimit), ...relinkedChildren];
|
|
294
|
+
};
|
|
295
|
+
export const createGraphLayoutHierarchy = (nodes, edges, degrees, groupNodeLimit = hierarchyGroupNodeLimit) => {
|
|
296
|
+
if (nodes.length <= groupNodeLimit) {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
const leafGroups = chunkNodes(nodes, degrees, groupNodeLimit).map((chunk, index) => {
|
|
300
|
+
const nodeIds = new Set(chunk.map((node) => node.id));
|
|
301
|
+
const bounds = layoutBounds(chunk);
|
|
302
|
+
const segment = chunk[0]?.segment ?? 'root';
|
|
303
|
+
const group = chunk[0]?.group ?? 'root';
|
|
304
|
+
const id = ['leaf', 0, index, chunk[0]?.id ?? 'empty', chunk.length].join(':');
|
|
305
|
+
return {
|
|
306
|
+
id,
|
|
307
|
+
level: 0,
|
|
308
|
+
parentId: null,
|
|
309
|
+
title: groupTitle(segment, 0, index, chunk),
|
|
310
|
+
segment,
|
|
311
|
+
group,
|
|
312
|
+
x: bounds.x,
|
|
313
|
+
y: bounds.y,
|
|
314
|
+
radius: bounds.radius,
|
|
315
|
+
nodeIds: chunk.map((node) => node.id),
|
|
316
|
+
childGroupIds: [],
|
|
317
|
+
...groupEdges(edges, nodeIds)
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
return createParentGroups(leafGroups, edges, 1, groupNodeLimit);
|
|
321
|
+
};
|
|
195
322
|
const resolveCollisionPair = (left, right, minDistance) => {
|
|
196
323
|
const dx = right.x - left.x;
|
|
197
324
|
const dy = right.y - left.y;
|
|
@@ -291,9 +418,11 @@ export const createCauliflowerGraphLayout = (graph) => {
|
|
|
291
418
|
const nodes = relaxCollisions(segmentGroups.flatMap(createSegmentNodes(segments, degrees, segmentGroups.length)));
|
|
292
419
|
const primaryHubId = selectPrimaryHubId(graph.nodes, degrees);
|
|
293
420
|
const centeredNodes = centerLayoutByNode(nodes, primaryHubId);
|
|
421
|
+
const groups = createGraphLayoutHierarchy(centeredNodes, graph.edges, degrees);
|
|
294
422
|
return {
|
|
295
423
|
nodes: centeredNodes,
|
|
296
|
-
edges: graph.edges
|
|
424
|
+
edges: graph.edges,
|
|
425
|
+
...(groups.length > 0 ? { groups } : {})
|
|
297
426
|
};
|
|
298
427
|
};
|
|
299
428
|
export const getMinimumLayoutDistance = (nodes) => nodes.reduce((minimumDistance, leftNode, leftIndex) => nodes.slice(leftIndex + 1).reduce((innerMinimum, rightNode) => Math.min(innerMinimum, distanceBetween(leftNode, rightNode)), minimumDistance), Number.POSITIVE_INFINITY);
|
package/docs/AGENT_USAGE.md
CHANGED
|
@@ -607,7 +607,7 @@ Without `--vault`, the graph UI serves `$HOME/.brainlink/vault`.
|
|
|
607
607
|
|
|
608
608
|
The frontend includes an agent selector that shows only the agent id. Selecting an agent calls the same read APIs with `agent=<agent-id>` and renders that namespace instead of merging every agent into one graph.
|
|
609
609
|
|
|
610
|
-
Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on canvas zooms in at cursor position. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open on click in a modal (tags, outgoing links, backlinks and Markdown content).
|
|
610
|
+
Graph navigation controls include zoom in, zoom out, fit visible nodes and reset-to-fit-all nodes. Mouse wheel zoom (including `cmd+scroll` and `ctrl+scroll`) is anchored to the cursor. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on canvas zooms in at cursor position. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open on click in a modal (tags, outgoing links, backlinks and Markdown content). Vaults above 1000 notes also expose stable hierarchy groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out draws those groups as context while preserving the real-node scene.
|
|
611
611
|
During graph filtering, Brainlink keeps hub context nodes visible (`Memory Hub`/`MOC`/high-degree fallback) so filtered views still show relationship anchors.
|
|
612
612
|
|
|
613
613
|
The command reindexes by default, then serves:
|
package/package.json
CHANGED