@andespindola/brainlink 0.1.0-beta.118 → 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
|
|
|
@@ -11,6 +11,7 @@ const massiveOverviewScaleThreshold = 0.065
|
|
|
11
11
|
const massiveSegmentedScaleThreshold = 0.45
|
|
12
12
|
const massiveSegmentRepresentativeBudget = 760
|
|
13
13
|
const massiveAutoFitMacroScale = 0.018
|
|
14
|
+
const hierarchyGroupScaleThreshold = 0.62
|
|
14
15
|
const minNodePixelRadius = 2.3
|
|
15
16
|
const viewportPaddingPx = 280
|
|
16
17
|
const worldCoordinateLimit = 5_000_000
|
|
@@ -38,6 +39,9 @@ const physicsStepDeltaCapMs = 96
|
|
|
38
39
|
const state = {
|
|
39
40
|
graph: { nodes: [], edges: [] },
|
|
40
41
|
nodes: [],
|
|
42
|
+
groups: [],
|
|
43
|
+
groupById: new Map(),
|
|
44
|
+
leafGroups: [],
|
|
41
45
|
nodeById: new Map(),
|
|
42
46
|
edges: [],
|
|
43
47
|
visibleNodes: [],
|
|
@@ -1151,6 +1155,7 @@ const drawAcceleratedGraph = (width, height, drawEdges) => {
|
|
|
1151
1155
|
ctx.save()
|
|
1152
1156
|
ctx.translate(state.transform.x, state.transform.y)
|
|
1153
1157
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
1158
|
+
drawHierarchyGroups()
|
|
1154
1159
|
if (drawEdges) {
|
|
1155
1160
|
graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
|
|
1156
1161
|
}
|
|
@@ -1161,6 +1166,101 @@ const drawAcceleratedGraph = (width, height, drawEdges) => {
|
|
|
1161
1166
|
return true
|
|
1162
1167
|
}
|
|
1163
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
|
+
|
|
1164
1264
|
const edgePairKey = (source, target) =>
|
|
1165
1265
|
source < target ? source + '|' + target : target + '|' + source
|
|
1166
1266
|
|
|
@@ -1767,6 +1867,44 @@ const layoutDensityScaleForNodeCount = (nodeCount) => {
|
|
|
1767
1867
|
return 0.88
|
|
1768
1868
|
}
|
|
1769
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
|
+
|
|
1770
1908
|
const createLayout = graph => {
|
|
1771
1909
|
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
1772
1910
|
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
@@ -1799,6 +1937,7 @@ const createLayout = graph => {
|
|
|
1799
1937
|
}
|
|
1800
1938
|
})
|
|
1801
1939
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
1940
|
+
const groups = createHierarchyGroups(graph, densityScale)
|
|
1802
1941
|
const edges = edgeRows
|
|
1803
1942
|
.map(edge => {
|
|
1804
1943
|
if (Array.isArray(edge)) {
|
|
@@ -1815,7 +1954,7 @@ const createLayout = graph => {
|
|
|
1815
1954
|
})
|
|
1816
1955
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
1817
1956
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
1818
|
-
return { nodes, edges }
|
|
1957
|
+
return { nodes, edges, groups }
|
|
1819
1958
|
}
|
|
1820
1959
|
|
|
1821
1960
|
const encodeEntityTag = (value) => {
|
|
@@ -2438,6 +2577,7 @@ const render = now => {
|
|
|
2438
2577
|
ctx.save()
|
|
2439
2578
|
ctx.translate(state.transform.x, state.transform.y)
|
|
2440
2579
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
2580
|
+
drawHierarchyGroups()
|
|
2441
2581
|
if (drawEdges) {
|
|
2442
2582
|
drawGraphEdges()
|
|
2443
2583
|
}
|
|
@@ -2843,6 +2983,9 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
2843
2983
|
state.graphSignature = signature
|
|
2844
2984
|
state.graph = graph
|
|
2845
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)
|
|
2846
2989
|
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
2847
2990
|
state.edges = layout.edges
|
|
2848
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