@andespindola/brainlink 0.1.0-beta.124 → 0.1.0-beta.126

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,7 +84,7 @@ 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 keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level capped at 1000 nodes and recursive parent groups when a level itself exceeds 1000 groups.
87
+ - Graph rendering keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level capped at 1000 nodes, the macro level projected as its own graph, and 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.
@@ -606,7 +606,7 @@ The graph UI shows:
606
606
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
607
607
  - 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
608
608
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
609
- - large graph LOD keeps a recursive graph-of-graphs mesh model: zoom-out shows one level capped at 1000 group nodes, zoom-in expands the framed node into its child graph capped at 1000 nodes, micro view renders only that focused subgraph in a local frame anchored to the group node, and zoom-out restores sibling groups
609
+ - large graph LOD keeps a recursive graph-of-graphs mesh model: zoom-out shows one projected graph level capped at 1000 group nodes, zoom-in expands the framed node into its own radial child graph capped at 1000 nodes, micro view renders only that focused subgraph with dense-node label suppression in a local frame anchored to the group node, and zoom-out restores sibling groups
610
610
 
611
611
  The server indexes before starting by default. Use `--no-index` to skip that step:
612
612
 
@@ -960,7 +960,7 @@ const drawGraphEdges = () => {
960
960
  const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
961
961
  isSelected ||
962
962
  isHovered ||
963
- (state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
963
+ (state.nodes.length > largeGraphNodeThreshold && !state.renderNodes.some(item => !item.isGroupNode) && state.transform.scale >= 1.25 && state.renderNodes.length <= 420) ||
964
964
  (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
965
965
 
966
966
  const drawSingleNode = (node, options = { drawLabel: true }) => {
@@ -1048,7 +1048,14 @@ const drawGraphNodes = () => {
1048
1048
 
1049
1049
  drawNodeBatch(regularNodes)
1050
1050
 
1051
- if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
1051
+ const isRenderingHierarchyChildGraph = state.groups.length > 0 && state.renderNodes.some(node => !node.isGroupNode)
1052
+ const shouldDrawBatchLabels = isRenderingHierarchyChildGraph
1053
+ ? false
1054
+ : state.nodes.length > largeGraphNodeThreshold
1055
+ ? state.transform.scale >= 1.25 && state.renderNodes.length <= 420
1056
+ : state.transform.scale >= 0.62 && state.renderNodes.length <= 1200
1057
+
1058
+ if (shouldDrawBatchLabels) {
1052
1059
  ctx.fillStyle = graphTheme.label
1053
1060
  ctx.font = '12px Inter, system-ui, sans-serif'
1054
1061
  ctx.textAlign = 'center'
@@ -1102,7 +1109,14 @@ const partitionGraphForAcceleratedRenderer = () => {
1102
1109
  }
1103
1110
 
1104
1111
  const drawGraphLabels = nodes => {
1105
- if (!(state.transform.scale >= 0.62 && state.renderNodes.length <= 1200)) {
1112
+ const isRenderingHierarchyChildGraph = state.groups.length > 0 && state.renderNodes.some(node => !node.isGroupNode)
1113
+ const shouldDrawLabels = isRenderingHierarchyChildGraph
1114
+ ? false
1115
+ : state.nodes.length > largeGraphNodeThreshold
1116
+ ? state.transform.scale >= 1.25 && state.renderNodes.length <= 420
1117
+ : state.transform.scale >= 0.62 && state.renderNodes.length <= 1200
1118
+
1119
+ if (!shouldDrawLabels) {
1106
1120
  return
1107
1121
  }
1108
1122
 
@@ -1233,51 +1247,83 @@ const createGroupRenderNode = group => ({
1233
1247
  radius: groupRenderRadius(group)
1234
1248
  })
1235
1249
 
1236
- const localGraphBounds = nodes => {
1237
- if (nodes.length === 0) {
1238
- return { centerX: 0, centerY: 0, radius: 1 }
1239
- }
1240
-
1241
- const bounds = nodes.reduce(
1242
- (current, node) => ({
1243
- minX: Math.min(current.minX, node.x),
1244
- maxX: Math.max(current.maxX, node.x),
1245
- minY: Math.min(current.minY, node.y),
1246
- maxY: Math.max(current.maxY, node.y)
1247
- }),
1248
- {
1249
- minX: Number.POSITIVE_INFINITY,
1250
- maxX: Number.NEGATIVE_INFINITY,
1251
- minY: Number.POSITIVE_INFINITY,
1252
- maxY: Number.NEGATIVE_INFINITY
1253
- }
1254
- )
1255
- const centerX = (bounds.minX + bounds.maxX) / 2
1256
- const centerY = (bounds.minY + bounds.maxY) / 2
1257
- const radius = nodes.reduce(
1258
- (largest, node) => Math.max(largest, Math.hypot(node.x - centerX, node.y - centerY)),
1259
- 1
1260
- )
1250
+ const arrangeGraphLevelNodes = (nodes, radiusForNode = () => 1) => {
1251
+ if (nodes.length <= 1) {
1252
+ return nodes
1253
+ }
1261
1254
 
1262
- return { centerX, centerY, radius }
1255
+ const centerNode = nodes
1256
+ .map(node => ({
1257
+ node,
1258
+ score: Math.max(node.nodeIds?.length ?? 0, node.childGroupIds?.length ?? 0, 1) + (node.externalEdges?.length ?? 0)
1259
+ }))
1260
+ .sort((left, right) => right.score - left.score || left.node.title.localeCompare(right.node.title))[0]?.node
1261
+ const outerNodes = nodes
1262
+ .filter(node => node.id !== centerNode?.id)
1263
+ .sort((left, right) => left.segment.localeCompare(right.segment) || left.title.localeCompare(right.title))
1264
+ const baseRadius = Math.max(520, Math.min(2200, Math.sqrt(nodes.length) * 135))
1265
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5))
1266
+ const arranged = centerNode
1267
+ ? [{ ...centerNode, x: 0, y: 0, radius: radiusForNode(centerNode) }]
1268
+ : []
1269
+
1270
+ outerNodes.forEach((node, index) => {
1271
+ const ringRadius = baseRadius * Math.sqrt((index + 1) / Math.max(outerNodes.length, 1))
1272
+ const angle = index * goldenAngle
1273
+ arranged.push({
1274
+ ...node,
1275
+ x: Math.cos(angle) * ringRadius,
1276
+ y: Math.sin(angle) * ringRadius,
1277
+ radius: radiusForNode(node)
1278
+ })
1279
+ })
1280
+
1281
+ return arranged
1263
1282
  }
1264
1283
 
1265
- const projectNodeIntoGroupGraph = (node, group, bounds) => {
1284
+ const arrangeChildGraphNodes = (nodes, group) => {
1285
+ if (nodes.length <= 1) {
1286
+ return nodes.map(node => ({ ...node, x: group.x, y: group.y }))
1287
+ }
1288
+
1266
1289
  const targetRadius = childGraphRenderRadius(group)
1267
- const scale = targetRadius / Math.max(bounds.radius, 1)
1290
+ const centerNode = nodes
1291
+ .map(node => ({
1292
+ node,
1293
+ score: (state.nodeDegrees.get(node.id) ?? 0) + (node.tags?.length ?? 0)
1294
+ }))
1295
+ .sort((left, right) => right.score - left.score || left.node.title.localeCompare(right.node.title))[0]?.node
1296
+ const outerNodes = nodes
1297
+ .filter(node => node.id !== centerNode?.id)
1298
+ .sort((left, right) => {
1299
+ const leftDegree = state.nodeDegrees.get(left.id) ?? 0
1300
+ const rightDegree = state.nodeDegrees.get(right.id) ?? 0
1301
+ if (leftDegree !== rightDegree) return rightDegree - leftDegree
1302
+ return left.title.localeCompare(right.title)
1303
+ })
1304
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5))
1305
+ const arranged = centerNode
1306
+ ? [{ ...centerNode, x: group.x, y: group.y }]
1307
+ : []
1268
1308
 
1269
- return {
1270
- x: group.x + (node.x - bounds.centerX) * scale,
1271
- y: group.y + (node.y - bounds.centerY) * scale
1272
- }
1309
+ outerNodes.forEach((node, index) => {
1310
+ const ringRadius = targetRadius * Math.sqrt((index + 1) / Math.max(outerNodes.length, 1))
1311
+ const angle = index * goldenAngle
1312
+ arranged.push({
1313
+ ...node,
1314
+ x: group.x + Math.cos(angle) * ringRadius,
1315
+ y: group.y + Math.sin(angle) * ringRadius
1316
+ })
1317
+ })
1318
+
1319
+ return arranged
1273
1320
  }
1274
1321
 
1275
- const interpolateNodeFromGroup = (node, group, bounds, progress) => {
1276
- const projected = projectNodeIntoGroupGraph(node, group, bounds)
1322
+ const interpolateNodeFromGroup = (node, group, progress) => {
1277
1323
  return {
1278
1324
  ...node,
1279
- x: group.x + (projected.x - group.x) * progress,
1280
- y: group.y + (projected.y - group.y) * progress,
1325
+ x: group.x + (node.x - group.x) * progress,
1326
+ y: group.y + (node.y - group.y) * progress,
1281
1327
  vx: 0,
1282
1328
  vy: 0
1283
1329
  }
@@ -1291,9 +1337,9 @@ const hierarchyGroupsForScale = () => {
1291
1337
  return []
1292
1338
  }
1293
1339
  if (state.transform.scale < hierarchyExpansionStartScale) {
1294
- return parentHierarchyGroups()
1340
+ return arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius)
1295
1341
  }
1296
- return state.leafGroups
1342
+ return arrangeGraphLevelNodes(state.leafGroups, groupRenderRadius)
1297
1343
  }
1298
1344
 
1299
1345
  const groupViewportCoverage = (group, viewport) => {
@@ -1315,7 +1361,9 @@ const groupWithCoverage = (group, viewport) => ({
1315
1361
  })
1316
1362
 
1317
1363
  const updateHierarchyFocusGroup = (groups, viewport) => {
1318
- const current = state.hierarchyFocusGroupId ? state.groupById.get(state.hierarchyFocusGroupId) : null
1364
+ const current = state.hierarchyFocusGroupId
1365
+ ? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
1366
+ : null
1319
1367
  const currentCoverage = current ? groupViewportCoverage(current, viewport) : 0
1320
1368
 
1321
1369
  if (
@@ -1423,12 +1471,11 @@ const computeHierarchyRenderVisibility = (viewport) => {
1423
1471
 
1424
1472
  const focusIds = new Set(focus.nodeIds)
1425
1473
  const rawChildNodes = state.visibleNodes.filter(node => focusIds.has(node.id))
1426
- const childBounds = localGraphBounds(rawChildNodes)
1427
- const childLimit = Math.max(160, Math.min(zoomedMassiveRenderNodeBudget, Math.floor(renderNodeBudget * progress * 2.4)))
1474
+ const childLimit = Math.max(90, Math.min(renderNodeBudget, Math.floor(renderNodeBudget * progress)))
1428
1475
  const childNodes = selectStableSampleNodes(
1429
- rawChildNodes,
1476
+ arrangeChildGraphNodes(rawChildNodes, focus),
1430
1477
  childLimit
1431
- ).map(node => interpolateNodeFromGroup(node, focus, childBounds, progress))
1478
+ ).map(node => interpolateNodeFromGroup(node, focus, progress))
1432
1479
  const childIds = new Set(childNodes.map(node => node.id))
1433
1480
  const childById = new Map(childNodes.map(node => [node.id, node]))
1434
1481
  const isMicroView = progress >= 0.72
@@ -1973,7 +2020,11 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
1973
2020
  const width = Math.max(rect.width, 320)
1974
2021
  const height = Math.max(rect.height, 320)
1975
2022
  const nodes = options.useFiltered ? filteredNodes() : state.nodes
1976
- const bounds = graphBounds(nodes)
2023
+ const hierarchyFitNodes = state.groups.length > 0 && nodes.length > 1000
2024
+ ? arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius).map(createGroupRenderNode)
2025
+ : null
2026
+ const fitNodes = hierarchyFitNodes ?? nodes
2027
+ const bounds = graphBounds(fitNodes)
1977
2028
 
1978
2029
  if (!bounds) {
1979
2030
  state.transform = { x: width / 2, y: height / 2, scale: 1 }
@@ -1993,17 +2044,20 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
1993
2044
  if (nodeCount <= 2000) return 140
1994
2045
  return 180
1995
2046
  }
1996
- const padding = paddingByNodeCount(nodes.length)
2047
+ const padding = paddingByNodeCount(fitNodes.length)
1997
2048
  const scaleX = width / (bounds.width + padding * 2)
1998
2049
  const scaleY = height / (bounds.height + padding * 2)
1999
2050
  const fitScale = Math.min(scaleX, scaleY)
2000
- const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
2001
- const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
2051
+ const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(fitNodes.length))
2052
+ const scaleRange = autoFitScaleRangeByNodeCount(fitNodes.length)
2002
2053
  const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
2003
- const resolvedScale = nodes.length > massiveGraphNodeThreshold
2054
+ const resolvedScale = nodes.length > massiveGraphNodeThreshold && !hierarchyFitNodes
2004
2055
  ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
2005
2056
  : baselineScale
2006
2057
  const hubCenter =
2058
+ hierarchyFitNodes
2059
+ ? null
2060
+ :
2007
2061
  options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
2008
2062
  ? state.primaryHub
2009
2063
  : null
@@ -2456,6 +2510,9 @@ const baseNodeRadius = node => {
2456
2510
  if (node.isGroupNode && Number.isFinite(node.radius)) {
2457
2511
  return node.radius
2458
2512
  }
2513
+ if (state.groups.length > 0 && !node.isGroupNode) {
2514
+ return 4.8
2515
+ }
2459
2516
  const degree = state.nodeDegrees.get(node.id) ?? 0
2460
2517
  return 9 + Math.min(degree, 8) * 1.6
2461
2518
  }
@@ -24,6 +24,40 @@ const childGraphRenderRadius = (group) => {
24
24
  const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1);
25
25
  return Math.max(420, Math.min(1800, Math.sqrt(childCount) * 24));
26
26
  };
27
+ const groupRenderRadius = (group) => {
28
+ const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1);
29
+ return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22);
30
+ };
31
+ const arrangeGraphLevelGroups = (groups) => {
32
+ if (groups.length <= 1) {
33
+ return groups.map((group) => ({ ...group, radius: groupRenderRadius(group) }));
34
+ }
35
+ const centerGroup = groups
36
+ .map((group) => ({
37
+ group,
38
+ score: Math.max(group.nodeIds.length, group.childGroupIds.length, 1) + group.externalEdges.length
39
+ }))
40
+ .sort((left, right) => right.score - left.score || left.group.title.localeCompare(right.group.title))[0]?.group;
41
+ const outerGroups = groups
42
+ .filter((group) => group.id !== centerGroup?.id)
43
+ .sort((left, right) => left.segment.localeCompare(right.segment) || left.title.localeCompare(right.title));
44
+ const baseRadius = Math.max(520, Math.min(2200, Math.sqrt(groups.length) * 135));
45
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5));
46
+ const arranged = centerGroup
47
+ ? [{ ...centerGroup, x: 0, y: 0, radius: groupRenderRadius(centerGroup) }]
48
+ : [];
49
+ outerGroups.forEach((group, index) => {
50
+ const ringRadius = baseRadius * Math.sqrt((index + 1) / Math.max(outerGroups.length, 1));
51
+ const angle = index * goldenAngle;
52
+ arranged.push({
53
+ ...group,
54
+ x: Math.cos(angle) * ringRadius,
55
+ y: Math.sin(angle) * ringRadius,
56
+ radius: groupRenderRadius(group)
57
+ });
58
+ });
59
+ return arranged;
60
+ };
27
61
  const groupNode = (group) => [
28
62
  `group:${group.id}`,
29
63
  group.title,
@@ -42,34 +76,6 @@ const realNode = (node) => [
42
76
  node.segment,
43
77
  'node'
44
78
  ];
45
- const graphBounds = (nodes) => {
46
- if (nodes.length === 0) {
47
- return { centerX: 0, centerY: 0, radius: 1 };
48
- }
49
- const bounds = nodes.reduce((current, node) => ({
50
- minX: Math.min(current.minX, node.x),
51
- maxX: Math.max(current.maxX, node.x),
52
- minY: Math.min(current.minY, node.y),
53
- maxY: Math.max(current.maxY, node.y)
54
- }), {
55
- minX: Number.POSITIVE_INFINITY,
56
- maxX: Number.NEGATIVE_INFINITY,
57
- minY: Number.POSITIVE_INFINITY,
58
- maxY: Number.NEGATIVE_INFINITY
59
- });
60
- const centerX = (bounds.minX + bounds.maxX) / 2;
61
- const centerY = (bounds.minY + bounds.maxY) / 2;
62
- const radius = nodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - centerX, node.y - centerY)), 1);
63
- return { centerX, centerY, radius };
64
- };
65
- const projectNodeIntoGroupGraph = (node, group, bounds) => {
66
- const scale = childGraphRenderRadius(group) / Math.max(bounds.radius, 1);
67
- return {
68
- ...node,
69
- x: group.x + (node.x - bounds.centerX) * scale,
70
- y: group.y + (node.y - bounds.centerY) * scale
71
- };
72
- };
73
79
  const descendants = (group, groupById) => group.nodeIds.length > 0
74
80
  ? group.nodeIds
75
81
  : group.childGroupIds.flatMap((childId) => {
@@ -101,6 +107,47 @@ const realEdges = (edges, nodeIds) => edges
101
107
  .filter((edge) => Boolean(edge.target && nodeIds.has(edge.source) && nodeIds.has(edge.target)))
102
108
  .slice(0, edgeLimit)
103
109
  .map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
110
+ const degreeMap = (edges) => edges.reduce((degrees, edge) => {
111
+ degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edge.weight);
112
+ if (edge.target) {
113
+ degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edge.weight);
114
+ }
115
+ return degrees;
116
+ }, new Map());
117
+ const arrangeChildGraphNodes = (nodes, group, degrees) => {
118
+ if (nodes.length <= 1) {
119
+ return nodes.map((node) => ({ ...node, x: group.x, y: group.y }));
120
+ }
121
+ const centerNode = nodes
122
+ .map((node) => ({
123
+ node,
124
+ score: (degrees.get(node.id) ?? 0) + node.tags.length
125
+ }))
126
+ .sort((left, right) => right.score - left.score || left.node.title.localeCompare(right.node.title))[0]?.node;
127
+ const outerNodes = nodes
128
+ .filter((node) => node.id !== centerNode?.id)
129
+ .sort((left, right) => {
130
+ const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
131
+ if (degreeDelta !== 0)
132
+ return degreeDelta;
133
+ return left.title.localeCompare(right.title);
134
+ });
135
+ const targetRadius = childGraphRenderRadius(group);
136
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5));
137
+ const arranged = centerNode
138
+ ? [{ ...centerNode, x: group.x, y: group.y }]
139
+ : [];
140
+ outerNodes.forEach((node, index) => {
141
+ const ringRadius = targetRadius * Math.sqrt((index + 1) / Math.max(outerNodes.length, 1));
142
+ const angle = index * goldenAngle;
143
+ arranged.push({
144
+ ...node,
145
+ x: group.x + Math.cos(angle) * ringRadius,
146
+ y: group.y + Math.sin(angle) * ringRadius
147
+ });
148
+ });
149
+ return arranged;
150
+ };
104
151
  const edgePairKey = (left, right) => left < right ? `${left}|${right}` : `${right}|${left}`;
105
152
  const meshEdges = (nodes, existingEdges) => {
106
153
  if (nodes.length < 2) {
@@ -129,6 +176,7 @@ const withMeshEdges = (nodes, edges) => [...edges, ...meshEdges(nodes, edges)].s
129
176
  export const getGraphView = async (vaultPath, input) => {
130
177
  const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
131
178
  const groups = layout.groups ?? [];
179
+ const degrees = degreeMap(layout.edges);
132
180
  const groupById = new Map(groups.map((group) => [group.id, group]));
133
181
  if (groups.length === 0) {
134
182
  const nodes = layout.nodes.filter((node) => inViewport(node, input)).slice(0, nodeLimit);
@@ -145,25 +193,22 @@ export const getGraphView = async (vaultPath, input) => {
145
193
  }
146
194
  };
147
195
  }
148
- const visibleGroups = groups.filter((group) => group.parentId === null && inViewport(group, input));
149
- const focused = groups
196
+ const rootGroups = arrangeGraphLevelGroups(groups.filter((group) => group.parentId === null));
197
+ const leafGroups = arrangeGraphLevelGroups(groups.filter((group) => group.nodeIds.length > 0));
198
+ const visibleGroups = rootGroups.filter((group) => inViewport(group, input));
199
+ const focused = leafGroups
150
200
  .filter((group) => group.nodeIds.length > 0 && inViewport(group, input))
151
201
  .map((group) => ({ group, coverage: groupCoverage(group, input) }))
152
202
  .sort((left, right) => right.coverage - left.coverage)[0];
153
203
  if (focused && input.scale >= macroScale && focused.coverage >= microCoverage) {
154
204
  const nodeIds = new Set(focused.group.nodeIds);
155
- const projectedNodes = layout.nodes
156
- .filter((node) => nodeIds.has(node.id));
157
- const bounds = graphBounds(projectedNodes);
158
- const nodesInViewport = projectedNodes
159
- .map((node) => projectNodeIntoGroupGraph(node, focused.group, bounds))
205
+ const arrangedNodes = arrangeChildGraphNodes(layout.nodes.filter((node) => nodeIds.has(node.id)), focused.group, degrees);
206
+ const nodesInViewport = arrangedNodes
160
207
  .filter((node) => inViewport(node, input))
161
208
  .slice(0, nodeLimit);
162
209
  const nodes = nodesInViewport.length > 0
163
210
  ? nodesInViewport
164
- : projectedNodes
165
- .slice(0, nodeLimit)
166
- .map((node) => projectNodeIntoGroupGraph(node, focused.group, bounds));
211
+ : arrangedNodes.slice(0, nodeLimit);
167
212
  const visibleNodeIds = new Set(nodes.map((node) => node.id));
168
213
  const viewNodes = nodes.map(realNode);
169
214
  return {
@@ -177,7 +222,7 @@ export const getGraphView = async (vaultPath, input) => {
177
222
  }
178
223
  };
179
224
  }
180
- const groupsToRender = (visibleGroups.length > 0 ? visibleGroups : groups.filter((group) => group.parentId === null)).slice(0, nodeLimit);
225
+ const groupsToRender = (visibleGroups.length > 0 ? visibleGroups : rootGroups).slice(0, nodeLimit);
181
226
  const viewNodes = groupsToRender.map(groupNode);
182
227
  return {
183
228
  signature,
@@ -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). Vaults above 1000 notes also expose stable hierarchy mesh groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out renders those groups as normal mesh graph nodes, zoom-in expands a group only after it is framed in the viewport, and micro view renders only the focused subgraph in a local frame anchored to the group node until zoom-out restores sibling groups.
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 mesh groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out renders the macro level as a projected mesh graph of group nodes, zoom-in expands a group only after it is framed in the viewport, and micro view renders only the focused radial child graph with dense-node label suppression in a local frame anchored to the group node until zoom-out restores sibling groups.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.124",
3
+ "version": "0.1.0-beta.126",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",