@andespindola/brainlink 0.1.0-beta.119 → 0.1.0-beta.120
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 +165 -59
- 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
|
|
91
|
+
- Zoomed-out graph LOD renders hierarchy groups as normal graph nodes and expands the focused group into child nodes progressively as zoom increases.
|
|
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.
|
|
@@ -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
|
|
607
|
+
- large graph LOD keeps a recursive graph-of-graphs model: zoom-out shows one level of group nodes, zoom-in expands the focused node into its child graph, and deeper zoom repeats the same visual rule without a background glow layer
|
|
608
608
|
|
|
609
609
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
610
610
|
|
|
@@ -12,6 +12,8 @@ const massiveSegmentedScaleThreshold = 0.45
|
|
|
12
12
|
const massiveSegmentRepresentativeBudget = 760
|
|
13
13
|
const massiveAutoFitMacroScale = 0.018
|
|
14
14
|
const hierarchyGroupScaleThreshold = 0.62
|
|
15
|
+
const hierarchyExpansionStartScale = 0.18
|
|
16
|
+
const hierarchyExpansionEndScale = 0.62
|
|
15
17
|
const minNodePixelRadius = 2.3
|
|
16
18
|
const viewportPaddingPx = 280
|
|
17
19
|
const worldCoordinateLimit = 5_000_000
|
|
@@ -42,6 +44,7 @@ const state = {
|
|
|
42
44
|
groups: [],
|
|
43
45
|
groupById: new Map(),
|
|
44
46
|
leafGroups: [],
|
|
47
|
+
nodeLeafGroupById: new Map(),
|
|
45
48
|
nodeById: new Map(),
|
|
46
49
|
edges: [],
|
|
47
50
|
visibleNodes: [],
|
|
@@ -1155,7 +1158,6 @@ const drawAcceleratedGraph = (width, height, drawEdges) => {
|
|
|
1155
1158
|
ctx.save()
|
|
1156
1159
|
ctx.translate(state.transform.x, state.transform.y)
|
|
1157
1160
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
1158
|
-
drawHierarchyGroups()
|
|
1159
1161
|
if (drawEdges) {
|
|
1160
1162
|
graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
|
|
1161
1163
|
}
|
|
@@ -1171,9 +1173,14 @@ const focusedGroup = () => {
|
|
|
1171
1173
|
return null
|
|
1172
1174
|
}
|
|
1173
1175
|
|
|
1176
|
+
const selectedGroupId = state.selected?.groupId ?? state.hovered?.groupId
|
|
1177
|
+
if (selectedGroupId) {
|
|
1178
|
+
return state.groupById.get(selectedGroupId) ?? null
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1174
1181
|
const selectedId = state.selected?.id ?? state.hovered?.id
|
|
1175
1182
|
if (selectedId) {
|
|
1176
|
-
const selectedGroup = state.
|
|
1183
|
+
const selectedGroup = state.nodeLeafGroupById.get(selectedId)
|
|
1177
1184
|
if (selectedGroup) {
|
|
1178
1185
|
return selectedGroup
|
|
1179
1186
|
}
|
|
@@ -1190,75 +1197,149 @@ const focusedGroup = () => {
|
|
|
1190
1197
|
.sort((left, right) => left.distance - right.distance)[0]?.group ?? null
|
|
1191
1198
|
}
|
|
1192
1199
|
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1200
|
+
const groupRenderNodeId = group => 'group:' + group.id
|
|
1201
|
+
|
|
1202
|
+
const hierarchyExpansionProgress = () => {
|
|
1203
|
+
const scale = state.transform.scale
|
|
1204
|
+
if (scale <= hierarchyExpansionStartScale) return 0
|
|
1205
|
+
if (scale >= hierarchyExpansionEndScale) return 1
|
|
1206
|
+
const progress = (scale - hierarchyExpansionStartScale) / (hierarchyExpansionEndScale - hierarchyExpansionStartScale)
|
|
1207
|
+
return progress * progress * (3 - 2 * progress)
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const groupRenderRadius = group => {
|
|
1211
|
+
const childCount = Math.max(group.nodeIds.length, group.childGroupIds.length, 1)
|
|
1212
|
+
return 10 + Math.min(Math.log2(childCount + 1) * 4.2, 22)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const createGroupRenderNode = group => ({
|
|
1216
|
+
id: groupRenderNodeId(group),
|
|
1217
|
+
groupId: group.id,
|
|
1218
|
+
isGroupNode: true,
|
|
1219
|
+
title: group.title,
|
|
1220
|
+
path: '',
|
|
1221
|
+
tags: [],
|
|
1222
|
+
group: group.group,
|
|
1223
|
+
segment: group.segment,
|
|
1224
|
+
x: group.x,
|
|
1225
|
+
y: group.y,
|
|
1226
|
+
vx: 0,
|
|
1227
|
+
vy: 0,
|
|
1228
|
+
radius: groupRenderRadius(group)
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
const interpolateNodeFromGroup = (node, group, progress) => ({
|
|
1232
|
+
...node,
|
|
1233
|
+
x: group.x + (node.x - group.x) * progress,
|
|
1234
|
+
y: group.y + (node.y - group.y) * progress,
|
|
1235
|
+
vx: 0,
|
|
1236
|
+
vy: 0
|
|
1237
|
+
})
|
|
1197
1238
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1239
|
+
const parentHierarchyGroups = () =>
|
|
1240
|
+
state.groups.filter(group => group.parentId === null)
|
|
1241
|
+
|
|
1242
|
+
const hierarchyGroupsForScale = () => {
|
|
1243
|
+
if (state.groups.length === 0) {
|
|
1244
|
+
return []
|
|
1201
1245
|
}
|
|
1202
|
-
if (
|
|
1203
|
-
return
|
|
1246
|
+
if (state.transform.scale < hierarchyExpansionStartScale) {
|
|
1247
|
+
return parentHierarchyGroups()
|
|
1204
1248
|
}
|
|
1205
|
-
|
|
1206
|
-
return base * 0.58
|
|
1249
|
+
return state.leafGroups
|
|
1207
1250
|
}
|
|
1208
1251
|
|
|
1209
|
-
const
|
|
1210
|
-
if (
|
|
1252
|
+
const groupEdgesForRenderedGroups = (groupNodes) => {
|
|
1253
|
+
if (groupNodes.length <= 1) {
|
|
1211
1254
|
return []
|
|
1212
1255
|
}
|
|
1213
1256
|
|
|
1214
|
-
const
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1217
|
-
group.
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1257
|
+
const groupByNodeId = new Map()
|
|
1258
|
+
const groupNodeIds = (group) => {
|
|
1259
|
+
if (!group) return []
|
|
1260
|
+
if (group.nodeIds.length > 0) return group.nodeIds
|
|
1261
|
+
return group.childGroupIds.flatMap(childGroupId => groupNodeIds(state.groupById.get(childGroupId)))
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
groupNodes.forEach((groupNode) => {
|
|
1265
|
+
const group = state.groupById.get(groupNode.groupId)
|
|
1266
|
+
groupNodeIds(group).forEach((nodeId) => {
|
|
1267
|
+
groupByNodeId.set(nodeId, groupNode)
|
|
1268
|
+
})
|
|
1269
|
+
})
|
|
1270
|
+
|
|
1271
|
+
const selected = new Map()
|
|
1272
|
+
for (let index = 0; index < state.visibleEdges.length; index += 1) {
|
|
1273
|
+
const edge = state.visibleEdges[index]
|
|
1274
|
+
if (!edge.target) continue
|
|
1275
|
+
const sourceGroup = groupByNodeId.get(edge.source)
|
|
1276
|
+
const targetGroup = groupByNodeId.get(edge.target)
|
|
1277
|
+
if (!sourceGroup || !targetGroup || sourceGroup.id === targetGroup.id) continue
|
|
1278
|
+
|
|
1279
|
+
const key = sourceGroup.id < targetGroup.id
|
|
1280
|
+
? sourceGroup.id + '|' + targetGroup.id
|
|
1281
|
+
: targetGroup.id + '|' + sourceGroup.id
|
|
1282
|
+
const current = selected.get(key)
|
|
1283
|
+
if (current && edgeWeight(current) >= edgeWeight(edge)) continue
|
|
1284
|
+
|
|
1285
|
+
selected.set(key, {
|
|
1286
|
+
source: sourceGroup.id,
|
|
1287
|
+
target: targetGroup.id,
|
|
1288
|
+
targetTitle: targetGroup.title,
|
|
1289
|
+
weight: edgeWeight(edge),
|
|
1290
|
+
priority: edge.priority || 'normal',
|
|
1291
|
+
sourceNode: sourceGroup,
|
|
1292
|
+
targetNode: targetGroup
|
|
1293
|
+
})
|
|
1294
|
+
}
|
|
1224
1295
|
|
|
1225
|
-
return
|
|
1226
|
-
.filter(group => group.level === level || group.parentId === null)
|
|
1227
|
-
.slice(0, 1400)
|
|
1296
|
+
return Array.from(selected.values()).slice(0, edgeBudgetForCurrentFrame())
|
|
1228
1297
|
}
|
|
1229
1298
|
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
return
|
|
1299
|
+
const computeHierarchyRenderVisibility = (viewport) => {
|
|
1300
|
+
if (state.groups.length === 0 || state.visibleNodes.length <= 1000 || state.transform.scale >= hierarchyGroupScaleThreshold) {
|
|
1301
|
+
return false
|
|
1234
1302
|
}
|
|
1235
1303
|
|
|
1304
|
+
const progress = hierarchyExpansionProgress()
|
|
1236
1305
|
const focus = focusedGroup()
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1306
|
+
const groups = hierarchyGroupsForScale()
|
|
1307
|
+
.filter(group =>
|
|
1308
|
+
group.x + group.radius >= viewport.minX &&
|
|
1309
|
+
group.x - group.radius <= viewport.maxX &&
|
|
1310
|
+
group.y + group.radius >= viewport.minY &&
|
|
1311
|
+
group.y - group.radius <= viewport.maxY
|
|
1312
|
+
)
|
|
1313
|
+
.slice(0, renderNodeBudget)
|
|
1314
|
+
const groupNodes = groups.map(createGroupRenderNode)
|
|
1315
|
+
|
|
1316
|
+
if (progress <= 0.02 || !focus) {
|
|
1317
|
+
state.renderNodes = groupNodes
|
|
1318
|
+
state.renderEdges = groupEdgesForRenderedGroups(groupNodes)
|
|
1319
|
+
return true
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const focusIds = new Set(focus.nodeIds)
|
|
1323
|
+
const childLimit = Math.max(160, Math.min(zoomedMassiveRenderNodeBudget, Math.floor(renderNodeBudget * progress * 2.4)))
|
|
1324
|
+
const childNodes = selectStableSampleNodes(
|
|
1325
|
+
state.visibleNodes.filter(node => focusIds.has(node.id)),
|
|
1326
|
+
childLimit
|
|
1327
|
+
).map(node => interpolateNodeFromGroup(node, focus, progress))
|
|
1328
|
+
const childIds = new Set(childNodes.map(node => node.id))
|
|
1329
|
+
const childById = new Map(childNodes.map(node => [node.id, node]))
|
|
1330
|
+
const visibleGroupNodes = groupNodes.filter(node => node.groupId !== focus.id || progress < 0.92)
|
|
1331
|
+
const groupEdges = groupEdgesForRenderedGroups(visibleGroupNodes)
|
|
1332
|
+
const childEdges = progress > 0.32
|
|
1333
|
+
? collectVisibleEdgesForNodes(childIds).map(edge => ({
|
|
1334
|
+
...edge,
|
|
1335
|
+
sourceNode: childById.get(edge.source) ?? edge.sourceNode,
|
|
1336
|
+
targetNode: childById.get(edge.target) ?? edge.targetNode
|
|
1337
|
+
}))
|
|
1338
|
+
: []
|
|
1252
1339
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
|
1340
|
+
state.renderNodes = mergeUniqueNodes(childNodes, visibleGroupNodes, Math.max(renderNodeBudget, childLimit + visibleGroupNodes.length))
|
|
1341
|
+
state.renderEdges = childEdges.concat(groupEdges).slice(0, edgeBudgetForCurrentFrame())
|
|
1342
|
+
return true
|
|
1262
1343
|
}
|
|
1263
1344
|
|
|
1264
1345
|
const edgePairKey = (source, target) =>
|
|
@@ -1651,6 +1732,7 @@ const ensureHubNodesInRenderedSet = (nodes) => {
|
|
|
1651
1732
|
}
|
|
1652
1733
|
|
|
1653
1734
|
const zoomCapByNodeCount = (nodeCount) => {
|
|
1735
|
+
if (state.groups.length > 0) return 512
|
|
1654
1736
|
if (nodeCount > 50000) return 5.4
|
|
1655
1737
|
if (nodeCount > 20000) return 4.8
|
|
1656
1738
|
if (nodeCount > 6000) return 4.2
|
|
@@ -2243,7 +2325,7 @@ const settleNeighborhoodAroundNode = (dragNode) => {
|
|
|
2243
2325
|
const hitNode = point => {
|
|
2244
2326
|
computeRenderVisibility()
|
|
2245
2327
|
const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
|
|
2246
|
-
? 0.2
|
|
2328
|
+
? (state.renderNodes.some(node => node.isGroupNode) ? 0 : 0.2)
|
|
2247
2329
|
: state.nodes.length > largeGraphNodeThreshold
|
|
2248
2330
|
? 0.34
|
|
2249
2331
|
: 0
|
|
@@ -2263,6 +2345,9 @@ const hitNode = point => {
|
|
|
2263
2345
|
}
|
|
2264
2346
|
|
|
2265
2347
|
const baseNodeRadius = node => {
|
|
2348
|
+
if (node.isGroupNode && Number.isFinite(node.radius)) {
|
|
2349
|
+
return node.radius
|
|
2350
|
+
}
|
|
2266
2351
|
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
2267
2352
|
return 9 + Math.min(degree, 8) * 1.6
|
|
2268
2353
|
}
|
|
@@ -2340,6 +2425,10 @@ const computeRenderVisibility = () => {
|
|
|
2340
2425
|
state.lastViewportKey = viewportKey
|
|
2341
2426
|
state.renderVisibilityDirty = false
|
|
2342
2427
|
|
|
2428
|
+
if (computeHierarchyRenderVisibility(viewport)) {
|
|
2429
|
+
return
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2343
2432
|
if (state.visibleNodes.length <= 2000) {
|
|
2344
2433
|
state.renderNodes = state.visibleNodes
|
|
2345
2434
|
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
@@ -2577,7 +2666,6 @@ const render = now => {
|
|
|
2577
2666
|
ctx.save()
|
|
2578
2667
|
ctx.translate(state.transform.x, state.transform.y)
|
|
2579
2668
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
2580
|
-
drawHierarchyGroups()
|
|
2581
2669
|
if (drawEdges) {
|
|
2582
2670
|
drawGraphEdges()
|
|
2583
2671
|
}
|
|
@@ -2639,7 +2727,7 @@ const fetchNodeDetails = async node => {
|
|
|
2639
2727
|
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
2640
2728
|
|
|
2641
2729
|
const openContentDialog = async node => {
|
|
2642
|
-
if (!node) return
|
|
2730
|
+
if (!node || node.isGroupNode) return
|
|
2643
2731
|
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
2644
2732
|
elements.contentPath.textContent = node.path || 'Loading...'
|
|
2645
2733
|
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
@@ -2683,6 +2771,23 @@ const openContentDialog = async node => {
|
|
|
2683
2771
|
}
|
|
2684
2772
|
|
|
2685
2773
|
const selectNode = (node, options = { openContent: false }) => {
|
|
2774
|
+
if (node?.isGroupNode) {
|
|
2775
|
+
state.selected = node
|
|
2776
|
+
const rect = canvas.getBoundingClientRect()
|
|
2777
|
+
const targetScale = clampScale(Math.max(state.transform.scale * 1.8, hierarchyExpansionStartScale * 1.08))
|
|
2778
|
+
state.zoomTransition = {
|
|
2779
|
+
active: true,
|
|
2780
|
+
source: 'group',
|
|
2781
|
+
screenX: Math.max(rect.width, 320) / 2,
|
|
2782
|
+
screenY: Math.max(rect.height, 320) / 2,
|
|
2783
|
+
worldX: node.x,
|
|
2784
|
+
worldY: node.y,
|
|
2785
|
+
targetScale
|
|
2786
|
+
}
|
|
2787
|
+
state.lastZoomFocus = { x: node.x, y: node.y, at: performance.now() }
|
|
2788
|
+
markRenderDirty()
|
|
2789
|
+
return
|
|
2790
|
+
}
|
|
2686
2791
|
state.selected = node
|
|
2687
2792
|
if (node && options.openContent) {
|
|
2688
2793
|
openContentDialog(node).catch(() => {
|
|
@@ -2986,6 +3091,7 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
2986
3091
|
state.groups = layout.groups
|
|
2987
3092
|
state.groupById = new Map(state.groups.map(group => [group.id, group]))
|
|
2988
3093
|
state.leafGroups = state.groups.filter(group => group.nodeIds.length > 0)
|
|
3094
|
+
state.nodeLeafGroupById = new Map(state.leafGroups.flatMap(group => group.nodeIds.map(nodeId => [nodeId, group])))
|
|
2989
3095
|
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
2990
3096
|
state.edges = layout.edges
|
|
2991
3097
|
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
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 groups of up to 1000 direct nodes, with recursive parent groups when a level exceeds 1000 groups; zoom-out
|
|
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 renders those groups as normal graph nodes, and zoom-in expands the focused group into its child graph without drawing a background glow layer.
|
|
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