@andespindola/brainlink 0.1.0-beta.125 → 0.1.0-beta.127
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 +2 -2
- package/dist/application/frontend/client-js.js +60 -45
- package/dist/application/get-graph-view.js +45 -36
- package/docs/AGENT_USAGE.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
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 renders hierarchy groups as normal mesh graph nodes and expands a group only after it is framed
|
|
91
|
+
- Zoomed-out graph LOD renders hierarchy groups as normal mesh graph nodes and expands a group from the focused node's current viewport position only after it is framed, progressively hiding sibling groups in micro view.
|
|
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.
|
|
@@ -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 projected graph 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 from its current viewport position 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 rendered 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 >=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -1267,51 +1281,49 @@ const arrangeGraphLevelNodes = (nodes, radiusForNode = () => 1) => {
|
|
|
1267
1281
|
return arranged
|
|
1268
1282
|
}
|
|
1269
1283
|
|
|
1270
|
-
const
|
|
1271
|
-
if (nodes.length
|
|
1272
|
-
return {
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
const bounds = nodes.reduce(
|
|
1276
|
-
(current, node) => ({
|
|
1277
|
-
minX: Math.min(current.minX, node.x),
|
|
1278
|
-
maxX: Math.max(current.maxX, node.x),
|
|
1279
|
-
minY: Math.min(current.minY, node.y),
|
|
1280
|
-
maxY: Math.max(current.maxY, node.y)
|
|
1281
|
-
}),
|
|
1282
|
-
{
|
|
1283
|
-
minX: Number.POSITIVE_INFINITY,
|
|
1284
|
-
maxX: Number.NEGATIVE_INFINITY,
|
|
1285
|
-
minY: Number.POSITIVE_INFINITY,
|
|
1286
|
-
maxY: Number.NEGATIVE_INFINITY
|
|
1287
|
-
}
|
|
1288
|
-
)
|
|
1289
|
-
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
1290
|
-
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
1291
|
-
const radius = nodes.reduce(
|
|
1292
|
-
(largest, node) => Math.max(largest, Math.hypot(node.x - centerX, node.y - centerY)),
|
|
1293
|
-
1
|
|
1294
|
-
)
|
|
1295
|
-
|
|
1296
|
-
return { centerX, centerY, radius }
|
|
1297
|
-
}
|
|
1284
|
+
const arrangeChildGraphNodes = (nodes, group, origin = group) => {
|
|
1285
|
+
if (nodes.length <= 1) {
|
|
1286
|
+
return nodes.map(node => ({ ...node, x: origin.x, y: origin.y }))
|
|
1287
|
+
}
|
|
1298
1288
|
|
|
1299
|
-
const projectNodeIntoGroupGraph = (node, group, bounds) => {
|
|
1300
1289
|
const targetRadius = childGraphRenderRadius(group)
|
|
1301
|
-
const
|
|
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: origin.x, y: origin.y }]
|
|
1307
|
+
: []
|
|
1302
1308
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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: origin.x + Math.cos(angle) * ringRadius,
|
|
1315
|
+
y: origin.y + Math.sin(angle) * ringRadius
|
|
1316
|
+
})
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
return arranged
|
|
1307
1320
|
}
|
|
1308
1321
|
|
|
1309
|
-
const interpolateNodeFromGroup = (node,
|
|
1310
|
-
const projected = projectNodeIntoGroupGraph(node, group, bounds)
|
|
1322
|
+
const interpolateNodeFromGroup = (node, origin, progress) => {
|
|
1311
1323
|
return {
|
|
1312
1324
|
...node,
|
|
1313
|
-
x:
|
|
1314
|
-
y:
|
|
1325
|
+
x: origin.x + (node.x - origin.x) * progress,
|
|
1326
|
+
y: origin.y + (node.y - origin.y) * progress,
|
|
1315
1327
|
vx: 0,
|
|
1316
1328
|
vy: 0
|
|
1317
1329
|
}
|
|
@@ -1459,12 +1471,12 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1459
1471
|
|
|
1460
1472
|
const focusIds = new Set(focus.nodeIds)
|
|
1461
1473
|
const rawChildNodes = state.visibleNodes.filter(node => focusIds.has(node.id))
|
|
1462
|
-
const
|
|
1463
|
-
const
|
|
1474
|
+
const childLimit = Math.max(90, Math.min(renderNodeBudget, Math.floor(renderNodeBudget * progress)))
|
|
1475
|
+
const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
|
|
1464
1476
|
const childNodes = selectStableSampleNodes(
|
|
1465
|
-
rawChildNodes,
|
|
1477
|
+
arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode),
|
|
1466
1478
|
childLimit
|
|
1467
|
-
).map(node => interpolateNodeFromGroup(node,
|
|
1479
|
+
).map(node => interpolateNodeFromGroup(node, focusRenderNode, progress))
|
|
1468
1480
|
const childIds = new Set(childNodes.map(node => node.id))
|
|
1469
1481
|
const childById = new Map(childNodes.map(node => [node.id, node]))
|
|
1470
1482
|
const isMicroView = progress >= 0.72
|
|
@@ -2499,6 +2511,9 @@ const baseNodeRadius = node => {
|
|
|
2499
2511
|
if (node.isGroupNode && Number.isFinite(node.radius)) {
|
|
2500
2512
|
return node.radius
|
|
2501
2513
|
}
|
|
2514
|
+
if (state.groups.length > 0 && !node.isGroupNode) {
|
|
2515
|
+
return 4.8
|
|
2516
|
+
}
|
|
2502
2517
|
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
2503
2518
|
return 9 + Math.min(degree, 8) * 1.6
|
|
2504
2519
|
}
|
|
@@ -76,34 +76,6 @@ const realNode = (node) => [
|
|
|
76
76
|
node.segment,
|
|
77
77
|
'node'
|
|
78
78
|
];
|
|
79
|
-
const graphBounds = (nodes) => {
|
|
80
|
-
if (nodes.length === 0) {
|
|
81
|
-
return { centerX: 0, centerY: 0, radius: 1 };
|
|
82
|
-
}
|
|
83
|
-
const bounds = nodes.reduce((current, node) => ({
|
|
84
|
-
minX: Math.min(current.minX, node.x),
|
|
85
|
-
maxX: Math.max(current.maxX, node.x),
|
|
86
|
-
minY: Math.min(current.minY, node.y),
|
|
87
|
-
maxY: Math.max(current.maxY, node.y)
|
|
88
|
-
}), {
|
|
89
|
-
minX: Number.POSITIVE_INFINITY,
|
|
90
|
-
maxX: Number.NEGATIVE_INFINITY,
|
|
91
|
-
minY: Number.POSITIVE_INFINITY,
|
|
92
|
-
maxY: Number.NEGATIVE_INFINITY
|
|
93
|
-
});
|
|
94
|
-
const centerX = (bounds.minX + bounds.maxX) / 2;
|
|
95
|
-
const centerY = (bounds.minY + bounds.maxY) / 2;
|
|
96
|
-
const radius = nodes.reduce((largest, node) => Math.max(largest, Math.hypot(node.x - centerX, node.y - centerY)), 1);
|
|
97
|
-
return { centerX, centerY, radius };
|
|
98
|
-
};
|
|
99
|
-
const projectNodeIntoGroupGraph = (node, group, bounds) => {
|
|
100
|
-
const scale = childGraphRenderRadius(group) / Math.max(bounds.radius, 1);
|
|
101
|
-
return {
|
|
102
|
-
...node,
|
|
103
|
-
x: group.x + (node.x - bounds.centerX) * scale,
|
|
104
|
-
y: group.y + (node.y - bounds.centerY) * scale
|
|
105
|
-
};
|
|
106
|
-
};
|
|
107
79
|
const descendants = (group, groupById) => group.nodeIds.length > 0
|
|
108
80
|
? group.nodeIds
|
|
109
81
|
: group.childGroupIds.flatMap((childId) => {
|
|
@@ -135,6 +107,47 @@ const realEdges = (edges, nodeIds) => edges
|
|
|
135
107
|
.filter((edge) => Boolean(edge.target && nodeIds.has(edge.source) && nodeIds.has(edge.target)))
|
|
136
108
|
.slice(0, edgeLimit)
|
|
137
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
|
+
};
|
|
138
151
|
const edgePairKey = (left, right) => left < right ? `${left}|${right}` : `${right}|${left}`;
|
|
139
152
|
const meshEdges = (nodes, existingEdges) => {
|
|
140
153
|
if (nodes.length < 2) {
|
|
@@ -163,6 +176,7 @@ const withMeshEdges = (nodes, edges) => [...edges, ...meshEdges(nodes, edges)].s
|
|
|
163
176
|
export const getGraphView = async (vaultPath, input) => {
|
|
164
177
|
const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
|
|
165
178
|
const groups = layout.groups ?? [];
|
|
179
|
+
const degrees = degreeMap(layout.edges);
|
|
166
180
|
const groupById = new Map(groups.map((group) => [group.id, group]));
|
|
167
181
|
if (groups.length === 0) {
|
|
168
182
|
const nodes = layout.nodes.filter((node) => inViewport(node, input)).slice(0, nodeLimit);
|
|
@@ -188,18 +202,13 @@ export const getGraphView = async (vaultPath, input) => {
|
|
|
188
202
|
.sort((left, right) => right.coverage - left.coverage)[0];
|
|
189
203
|
if (focused && input.scale >= macroScale && focused.coverage >= microCoverage) {
|
|
190
204
|
const nodeIds = new Set(focused.group.nodeIds);
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
const bounds = graphBounds(projectedNodes);
|
|
194
|
-
const nodesInViewport = projectedNodes
|
|
195
|
-
.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
|
|
196
207
|
.filter((node) => inViewport(node, input))
|
|
197
208
|
.slice(0, nodeLimit);
|
|
198
209
|
const nodes = nodesInViewport.length > 0
|
|
199
210
|
? nodesInViewport
|
|
200
|
-
:
|
|
201
|
-
.slice(0, nodeLimit)
|
|
202
|
-
.map((node) => projectNodeIntoGroupGraph(node, focused.group, bounds));
|
|
211
|
+
: arrangedNodes.slice(0, nodeLimit);
|
|
203
212
|
const visibleNodeIds = new Set(nodes.map((node) => node.id));
|
|
204
213
|
const viewNodes = nodes.map(realNode);
|
|
205
214
|
return {
|
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). 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
|
|
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 from the focused node's current viewport position only after it is framed, and micro view renders only the focused radial child graph with dense-node label suppression in a local frame anchored to the rendered 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