@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 +2 -2
- package/dist/application/frontend/client-js.js +108 -51
- package/dist/application/get-graph-view.js +84 -39
- package/docs/AGENT_USAGE.md +1 -1
- package/package.json +1 -1
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 >=
|
|
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
|
|
|
@@ -1233,51 +1247,83 @@ const createGroupRenderNode = group => ({
|
|
|
1233
1247
|
radius: groupRenderRadius(group)
|
|
1234
1248
|
})
|
|
1235
1249
|
|
|
1236
|
-
const
|
|
1237
|
-
if (nodes.length
|
|
1238
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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,
|
|
1276
|
-
const projected = projectNodeIntoGroupGraph(node, group, bounds)
|
|
1322
|
+
const interpolateNodeFromGroup = (node, group, progress) => {
|
|
1277
1323
|
return {
|
|
1278
1324
|
...node,
|
|
1279
|
-
x: group.x + (
|
|
1280
|
-
y: group.y + (
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
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(
|
|
2001
|
-
const scaleRange = autoFitScaleRangeByNodeCount(
|
|
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
|
|
149
|
-
const
|
|
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
|
|
156
|
-
|
|
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
|
-
:
|
|
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 :
|
|
225
|
+
const groupsToRender = (visibleGroups.length > 0 ? visibleGroups : rootGroups).slice(0, nodeLimit);
|
|
181
226
|
const viewNodes = groupsToRender.map(groupNode);
|
|
182
227
|
return {
|
|
183
228
|
signature,
|
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
|
|
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