@andespindola/brainlink 0.1.0-beta.127 → 0.1.0-beta.129
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 keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level
|
|
87
|
+
- Graph rendering keeps the flat node scene and adds stable hierarchical mesh groups for vaults above 1000 notes, with every visible graph level filled toward 1000 nodes, each group capped at 1000 child nodes, 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.
|
|
91
|
-
- Zoomed-out graph LOD renders hierarchy groups as
|
|
91
|
+
- Zoomed-out graph LOD renders hierarchy groups as sparse relationship 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
|
|
609
|
+
- large graph LOD keeps a recursive graph-of-graphs model: zoom-out fills the projected macro level toward 1000 lightweight group nodes with sparse strongest-link edges, 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
|
|
|
@@ -22,9 +22,7 @@ const worldCoordinateLimit = 5_000_000
|
|
|
22
22
|
const transformCoordinateLimit = 20_000_000
|
|
23
23
|
const hoverHitTestIntervalMs = 64
|
|
24
24
|
const zoomRecoveryGuardMs = 4200
|
|
25
|
-
const
|
|
26
|
-
const meshEdgeMinBudget = 140
|
|
27
|
-
const meshEdgeMaxBudget = 1400
|
|
25
|
+
const hierarchyGroupEdgeLimit = 900
|
|
28
26
|
const dragNeighborhoodMaxAffected = 180
|
|
29
27
|
const dragSettleRounds = 3
|
|
30
28
|
const wheelZoomExponent = 0.0009
|
|
@@ -1360,6 +1358,33 @@ const groupWithCoverage = (group, viewport) => ({
|
|
|
1360
1358
|
coverage: groupViewportCoverage(group, viewport)
|
|
1361
1359
|
})
|
|
1362
1360
|
|
|
1361
|
+
const distanceToViewportCenter = (item, viewport) => {
|
|
1362
|
+
const centerX = (viewport.minX + viewport.maxX) / 2
|
|
1363
|
+
const centerY = (viewport.minY + viewport.maxY) / 2
|
|
1364
|
+
return Math.hypot(item.x - centerX, item.y - centerY)
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const selectViewportItemsWithFill = (items, viewport, limit = renderNodeBudget) => {
|
|
1368
|
+
const visible = items.filter(item =>
|
|
1369
|
+
item.x + item.radius >= viewport.minX &&
|
|
1370
|
+
item.x - item.radius <= viewport.maxX &&
|
|
1371
|
+
item.y + item.radius >= viewport.minY &&
|
|
1372
|
+
item.y - item.radius <= viewport.maxY
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
if (visible.length >= limit) {
|
|
1376
|
+
return visible.slice(0, limit)
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const selectedIds = new Set(visible.map(item => item.id))
|
|
1380
|
+
const fill = items
|
|
1381
|
+
.filter(item => !selectedIds.has(item.id))
|
|
1382
|
+
.sort((left, right) => distanceToViewportCenter(left, viewport) - distanceToViewportCenter(right, viewport) || left.id.localeCompare(right.id))
|
|
1383
|
+
.slice(0, Math.max(0, limit - visible.length))
|
|
1384
|
+
|
|
1385
|
+
return visible.concat(fill)
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1363
1388
|
const updateHierarchyFocusGroup = (groups, viewport) => {
|
|
1364
1389
|
const current = state.hierarchyFocusGroupId
|
|
1365
1390
|
? groups.find(group => group.id === state.hierarchyFocusGroupId) ?? null
|
|
@@ -1442,7 +1467,20 @@ const groupEdgesForRenderedGroups = (groupNodes) => {
|
|
|
1442
1467
|
})
|
|
1443
1468
|
}
|
|
1444
1469
|
|
|
1445
|
-
|
|
1470
|
+
const degreeCounts = new Map()
|
|
1471
|
+
return Array.from(selected.values())
|
|
1472
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left) || left.source.localeCompare(right.source) || left.target.localeCompare(right.target))
|
|
1473
|
+
.filter((edge) => {
|
|
1474
|
+
const sourceCount = degreeCounts.get(edge.source) ?? 0
|
|
1475
|
+
const targetCount = degreeCounts.get(edge.target) ?? 0
|
|
1476
|
+
if (sourceCount >= 3 || targetCount >= 3) {
|
|
1477
|
+
return false
|
|
1478
|
+
}
|
|
1479
|
+
degreeCounts.set(edge.source, sourceCount + 1)
|
|
1480
|
+
degreeCounts.set(edge.target, targetCount + 1)
|
|
1481
|
+
return true
|
|
1482
|
+
})
|
|
1483
|
+
.slice(0, Math.min(hierarchyGroupEdgeLimit, edgeBudgetForCurrentFrame()))
|
|
1446
1484
|
}
|
|
1447
1485
|
|
|
1448
1486
|
const computeHierarchyRenderVisibility = (viewport) => {
|
|
@@ -1451,14 +1489,7 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1451
1489
|
return false
|
|
1452
1490
|
}
|
|
1453
1491
|
|
|
1454
|
-
const groups = hierarchyGroupsForScale()
|
|
1455
|
-
.filter(group =>
|
|
1456
|
-
group.x + group.radius >= viewport.minX &&
|
|
1457
|
-
group.x - group.radius <= viewport.maxX &&
|
|
1458
|
-
group.y + group.radius >= viewport.minY &&
|
|
1459
|
-
group.y - group.radius <= viewport.maxY
|
|
1460
|
-
)
|
|
1461
|
-
.slice(0, renderNodeBudget)
|
|
1492
|
+
const groups = selectViewportItemsWithFill(hierarchyGroupsForScale(), viewport, renderNodeBudget)
|
|
1462
1493
|
const focus = updateHierarchyFocusGroup(groups, viewport)
|
|
1463
1494
|
const progress = focus ? hierarchyViewportProgress(focus, viewport) : 0
|
|
1464
1495
|
const groupNodes = groups.map(createGroupRenderNode)
|
|
@@ -1497,121 +1528,8 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1497
1528
|
return true
|
|
1498
1529
|
}
|
|
1499
1530
|
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
const meshNeighborBuckets = (nodes, cellSize) => {
|
|
1504
|
-
const buckets = new Map()
|
|
1505
|
-
|
|
1506
|
-
for (let index = 0; index < nodes.length; index += 1) {
|
|
1507
|
-
const node = nodes[index]
|
|
1508
|
-
const cellX = Math.floor(node.x / cellSize)
|
|
1509
|
-
const cellY = Math.floor(node.y / cellSize)
|
|
1510
|
-
const key = cellX + ':' + cellY
|
|
1511
|
-
const bucket = buckets.get(key)
|
|
1512
|
-
if (bucket) {
|
|
1513
|
-
bucket.push(node)
|
|
1514
|
-
} else {
|
|
1515
|
-
buckets.set(key, [node])
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
return buckets
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
const meshCandidatesForNode = (node, buckets, cellSize) => {
|
|
1523
|
-
const cellX = Math.floor(node.x / cellSize)
|
|
1524
|
-
const cellY = Math.floor(node.y / cellSize)
|
|
1525
|
-
const candidates = []
|
|
1526
|
-
|
|
1527
|
-
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
1528
|
-
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
1529
|
-
const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
|
|
1530
|
-
if (!bucket) continue
|
|
1531
|
-
for (let index = 0; index < bucket.length; index += 1) {
|
|
1532
|
-
const candidate = bucket[index]
|
|
1533
|
-
if (candidate.id !== node.id) {
|
|
1534
|
-
candidates.push(candidate)
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
return candidates
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
const buildMeshEdgesForNodes = (nodes, existingEdges) => {
|
|
1544
|
-
if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
|
|
1545
|
-
return []
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
const existingKeys = new Set()
|
|
1549
|
-
for (let index = 0; index < existingEdges.length; index += 1) {
|
|
1550
|
-
const edge = existingEdges[index]
|
|
1551
|
-
if (edge.target) {
|
|
1552
|
-
existingKeys.add(edgePairKey(edge.source, edge.target))
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
const desiredBudget = Math.min(
|
|
1557
|
-
meshEdgeMaxBudget,
|
|
1558
|
-
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
|
|
1559
|
-
)
|
|
1560
|
-
const perNodeNeighborCount =
|
|
1561
|
-
state.transform.scale >= 1.05 ? 4
|
|
1562
|
-
: state.transform.scale >= 0.62 ? 3
|
|
1563
|
-
: 2
|
|
1564
|
-
const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
|
|
1565
|
-
const maxDistance = 980
|
|
1566
|
-
const maxDistanceSquared = maxDistance * maxDistance
|
|
1567
|
-
const buckets = meshNeighborBuckets(nodes, cellSize)
|
|
1568
|
-
const meshEdges = []
|
|
1569
|
-
const meshKeys = new Set()
|
|
1570
|
-
|
|
1571
|
-
for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
|
|
1572
|
-
const node = nodes[index]
|
|
1573
|
-
const candidates = meshCandidatesForNode(node, buckets, cellSize)
|
|
1574
|
-
.map((candidate) => ({
|
|
1575
|
-
node: candidate,
|
|
1576
|
-
distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
|
|
1577
|
-
}))
|
|
1578
|
-
.filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
|
|
1579
|
-
.sort((left, right) => left.distanceSquared - right.distanceSquared)
|
|
1580
|
-
|
|
1581
|
-
let linked = 0
|
|
1582
|
-
for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
|
|
1583
|
-
const candidate = candidates[candidateIndex].node
|
|
1584
|
-
const key = edgePairKey(node.id, candidate.id)
|
|
1585
|
-
if (existingKeys.has(key) || meshKeys.has(key)) {
|
|
1586
|
-
continue
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
meshKeys.add(key)
|
|
1590
|
-
meshEdges.push({
|
|
1591
|
-
source: node.id,
|
|
1592
|
-
target: candidate.id,
|
|
1593
|
-
targetTitle: candidate.title,
|
|
1594
|
-
weight: 1,
|
|
1595
|
-
priority: 'normal',
|
|
1596
|
-
sourceNode: node,
|
|
1597
|
-
targetNode: candidate,
|
|
1598
|
-
inferred: true
|
|
1599
|
-
})
|
|
1600
|
-
linked += 1
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
return meshEdges
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
const withMeshEdges = (nodes, edges) => {
|
|
1608
|
-
const isHierarchyGraphLevel = state.groups.length > 0 && (state.visibleNodes.length > 1000 || state.hierarchyFocusGroupId)
|
|
1609
|
-
if (nodes.length === 0 || !isHierarchyGraphLevel || state.transform.scale < meshEdgeScaleThreshold) {
|
|
1610
|
-
return edges
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
const meshEdges = buildMeshEdgesForNodes(nodes, edges)
|
|
1614
|
-
return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
|
|
1531
|
+
const limitRenderEdges = (nodes, edges) => {
|
|
1532
|
+
return edges
|
|
1615
1533
|
}
|
|
1616
1534
|
|
|
1617
1535
|
const fallbackViewportNodes = () => {
|
|
@@ -2598,7 +2516,7 @@ const computeRenderVisibility = () => {
|
|
|
2598
2516
|
if (state.visibleNodes.length <= 2000) {
|
|
2599
2517
|
state.renderNodes = state.visibleNodes
|
|
2600
2518
|
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
2601
|
-
state.renderEdges =
|
|
2519
|
+
state.renderEdges = limitRenderEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
|
|
2602
2520
|
return
|
|
2603
2521
|
}
|
|
2604
2522
|
|
|
@@ -2608,7 +2526,7 @@ const computeRenderVisibility = () => {
|
|
|
2608
2526
|
const overviewNodes = sampleMassiveOverviewNodes(sampleLimit)
|
|
2609
2527
|
const overviewIds = new Set(overviewNodes.map((node) => node.id))
|
|
2610
2528
|
state.renderNodes = overviewNodes
|
|
2611
|
-
state.renderEdges =
|
|
2529
|
+
state.renderEdges = limitRenderEdges(overviewNodes, collectVisibleEdgesForNodes(overviewIds))
|
|
2612
2530
|
return
|
|
2613
2531
|
}
|
|
2614
2532
|
|
|
@@ -2665,7 +2583,7 @@ const computeRenderVisibility = () => {
|
|
|
2665
2583
|
sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
|
|
2666
2584
|
}
|
|
2667
2585
|
state.renderNodes = sampledNodes
|
|
2668
|
-
state.renderEdges =
|
|
2586
|
+
state.renderEdges = limitRenderEdges(sampledNodes, sampledEdges)
|
|
2669
2587
|
return
|
|
2670
2588
|
}
|
|
2671
2589
|
|
|
@@ -2673,7 +2591,7 @@ const computeRenderVisibility = () => {
|
|
|
2673
2591
|
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
2674
2592
|
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
2675
2593
|
state.renderNodes = sampled
|
|
2676
|
-
state.renderEdges =
|
|
2594
|
+
state.renderEdges = limitRenderEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
|
|
2677
2595
|
return
|
|
2678
2596
|
}
|
|
2679
2597
|
|
|
@@ -2700,7 +2618,7 @@ const computeRenderVisibility = () => {
|
|
|
2700
2618
|
const fallbackNodes = fallbackViewportNodes()
|
|
2701
2619
|
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
2702
2620
|
state.renderNodes = fallbackNodes
|
|
2703
|
-
state.renderEdges =
|
|
2621
|
+
state.renderEdges = limitRenderEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
2704
2622
|
return
|
|
2705
2623
|
}
|
|
2706
2624
|
|
|
@@ -2709,13 +2627,13 @@ const computeRenderVisibility = () => {
|
|
|
2709
2627
|
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
2710
2628
|
|
|
2711
2629
|
state.renderNodes = normalizedNodes
|
|
2712
|
-
state.renderEdges =
|
|
2630
|
+
state.renderEdges = limitRenderEdges(normalizedNodes, edges)
|
|
2713
2631
|
|
|
2714
2632
|
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
2715
2633
|
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
2716
2634
|
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
2717
2635
|
state.renderNodes = fallbackNodes
|
|
2718
|
-
state.renderEdges =
|
|
2636
|
+
state.renderEdges = limitRenderEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
2719
2637
|
}
|
|
2720
2638
|
}
|
|
2721
2639
|
|
|
@@ -4,12 +4,13 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { createCauliflowerGraphLayout } from '../domain/graph-layout.js';
|
|
5
5
|
import { indexStoragePath } from '../infrastructure/file-index.js';
|
|
6
6
|
import { getGraphSummary } from './get-graph-summary.js';
|
|
7
|
+
const graphLayoutVersion = 2;
|
|
7
8
|
const graphLayoutCache = new Map();
|
|
8
9
|
const graphLayoutStoragePath = (vaultPath, agentId) => join(vaultPath, '.brainlink', `graph-layout-${agentId?.replace(/[^a-zA-Z0-9_-]/g, '_') ?? 'all'}.json`);
|
|
9
10
|
const readPersistedLayout = async (vaultPath, databaseSignature, agentId) => {
|
|
10
11
|
try {
|
|
11
12
|
const parsed = JSON.parse(await readFile(graphLayoutStoragePath(vaultPath, agentId), 'utf8'));
|
|
12
|
-
return parsed.databaseSignature === databaseSignature ? parsed : null;
|
|
13
|
+
return parsed.databaseSignature === databaseSignature && parsed.layoutVersion === graphLayoutVersion ? parsed : null;
|
|
13
14
|
}
|
|
14
15
|
catch {
|
|
15
16
|
return null;
|
|
@@ -44,7 +45,7 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
44
45
|
const databaseSignature = await readDatabaseSignature(vaultPath);
|
|
45
46
|
const cacheKey = `${vaultPath}:${agentId ?? ''}`;
|
|
46
47
|
const cached = graphLayoutCache.get(cacheKey);
|
|
47
|
-
if (cached?.databaseSignature === databaseSignature) {
|
|
48
|
+
if (cached?.databaseSignature === databaseSignature && cached.layoutVersion === graphLayoutVersion) {
|
|
48
49
|
return {
|
|
49
50
|
signature: cached.signature,
|
|
50
51
|
layout: cached.layout
|
|
@@ -65,7 +66,7 @@ export const getGraphLayout = async (vaultPath, agentId) => {
|
|
|
65
66
|
...rawLayout,
|
|
66
67
|
nodes: rawLayout.nodes.map((node) => ({ ...node, content: '' }))
|
|
67
68
|
};
|
|
68
|
-
const nextCache = { databaseSignature, signature, layout };
|
|
69
|
+
const nextCache = { layoutVersion: graphLayoutVersion, databaseSignature, signature, layout };
|
|
69
70
|
graphLayoutCache.set(cacheKey, nextCache);
|
|
70
71
|
await writePersistedLayout(vaultPath, agentId, nextCache);
|
|
71
72
|
return {
|
|
@@ -2,8 +2,8 @@ import { getGraphLayout } from './get-graph-layout.js';
|
|
|
2
2
|
const macroScale = 0.24;
|
|
3
3
|
const microCoverage = 0.72;
|
|
4
4
|
const nodeLimit = 1000;
|
|
5
|
-
const edgeLimit =
|
|
6
|
-
const
|
|
5
|
+
const edgeLimit = 1400;
|
|
6
|
+
const groupEdgeLimit = 900;
|
|
7
7
|
const inViewport = (item, input) => {
|
|
8
8
|
const radius = item.radius ?? 48;
|
|
9
9
|
return (item.x + radius >= input.x &&
|
|
@@ -58,6 +58,23 @@ const arrangeGraphLevelGroups = (groups) => {
|
|
|
58
58
|
});
|
|
59
59
|
return arranged;
|
|
60
60
|
};
|
|
61
|
+
const distanceToViewportCenter = (item, input) => {
|
|
62
|
+
const centerX = input.x + input.width / 2;
|
|
63
|
+
const centerY = input.y + input.height / 2;
|
|
64
|
+
return Math.hypot(item.x - centerX, item.y - centerY);
|
|
65
|
+
};
|
|
66
|
+
const selectViewportItemsWithFill = (items, input, limit = nodeLimit) => {
|
|
67
|
+
const visible = items.filter((item) => inViewport(item, input));
|
|
68
|
+
if (visible.length >= limit) {
|
|
69
|
+
return visible.slice(0, limit);
|
|
70
|
+
}
|
|
71
|
+
const selectedIds = new Set(visible.map((item) => item.id));
|
|
72
|
+
const fill = items
|
|
73
|
+
.filter((item) => !selectedIds.has(item.id))
|
|
74
|
+
.sort((left, right) => distanceToViewportCenter(left, input) - distanceToViewportCenter(right, input) || left.id.localeCompare(right.id))
|
|
75
|
+
.slice(0, Math.max(0, limit - visible.length));
|
|
76
|
+
return visible.concat(fill);
|
|
77
|
+
};
|
|
61
78
|
const groupNode = (group) => [
|
|
62
79
|
`group:${group.id}`,
|
|
63
80
|
group.title,
|
|
@@ -101,7 +118,20 @@ const aggregateGroupEdges = (groups, edges, groupById) => {
|
|
|
101
118
|
return;
|
|
102
119
|
selected.set(key, [source, target, edge.weight, edge.priority]);
|
|
103
120
|
});
|
|
104
|
-
|
|
121
|
+
const degreeCounts = new Map();
|
|
122
|
+
return Array.from(selected.values())
|
|
123
|
+
.sort((left, right) => right[2] - left[2] || left[0].localeCompare(right[0]) || left[1].localeCompare(right[1]))
|
|
124
|
+
.filter((edge) => {
|
|
125
|
+
const sourceCount = degreeCounts.get(edge[0]) ?? 0;
|
|
126
|
+
const targetCount = degreeCounts.get(edge[1]) ?? 0;
|
|
127
|
+
if (sourceCount >= 3 || targetCount >= 3) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
degreeCounts.set(edge[0], sourceCount + 1);
|
|
131
|
+
degreeCounts.set(edge[1], targetCount + 1);
|
|
132
|
+
return true;
|
|
133
|
+
})
|
|
134
|
+
.slice(0, groupEdgeLimit);
|
|
105
135
|
};
|
|
106
136
|
const realEdges = (edges, nodeIds) => edges
|
|
107
137
|
.filter((edge) => Boolean(edge.target && nodeIds.has(edge.source) && nodeIds.has(edge.target)))
|
|
@@ -148,31 +178,7 @@ const arrangeChildGraphNodes = (nodes, group, degrees) => {
|
|
|
148
178
|
});
|
|
149
179
|
return arranged;
|
|
150
180
|
};
|
|
151
|
-
const
|
|
152
|
-
const meshEdges = (nodes, existingEdges) => {
|
|
153
|
-
if (nodes.length < 2) {
|
|
154
|
-
return [];
|
|
155
|
-
}
|
|
156
|
-
const existing = new Set(existingEdges.map((edge) => edgePairKey(edge[0], edge[1])));
|
|
157
|
-
const selected = [];
|
|
158
|
-
const selectedKeys = new Set();
|
|
159
|
-
const maxNeighbors = nodes.length > 500 ? 2 : 3;
|
|
160
|
-
const byX = [...nodes].sort((left, right) => left[2] - right[2] || left[3] - right[3] || left[0].localeCompare(right[0]));
|
|
161
|
-
for (let index = 0; index < nodes.length && selected.length < meshEdgeLimit; index += 1) {
|
|
162
|
-
const node = byX[index];
|
|
163
|
-
const candidates = byX.slice(index + 1, index + 1 + maxNeighbors);
|
|
164
|
-
candidates.forEach((candidate) => {
|
|
165
|
-
const key = edgePairKey(node[0], candidate[0]);
|
|
166
|
-
if (existing.has(key) || selectedKeys.has(key) || selected.length >= meshEdgeLimit) {
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
selectedKeys.add(key);
|
|
170
|
-
selected.push([node[0], candidate[0], 1, 'low']);
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
return selected;
|
|
174
|
-
};
|
|
175
|
-
const withMeshEdges = (nodes, edges) => [...edges, ...meshEdges(nodes, edges)].slice(0, edgeLimit);
|
|
181
|
+
const limitEdges = (edges) => edges.slice(0, edgeLimit);
|
|
176
182
|
export const getGraphView = async (vaultPath, input) => {
|
|
177
183
|
const { signature, layout } = await getGraphLayout(vaultPath, input.agentId);
|
|
178
184
|
const groups = layout.groups ?? [];
|
|
@@ -186,7 +192,7 @@ export const getGraphView = async (vaultPath, input) => {
|
|
|
186
192
|
signature,
|
|
187
193
|
mode: 'flat',
|
|
188
194
|
nodes: viewNodes,
|
|
189
|
-
edges:
|
|
195
|
+
edges: limitEdges(realEdges(layout.edges, nodeIds)),
|
|
190
196
|
totals: {
|
|
191
197
|
nodes: layout.nodes.length,
|
|
192
198
|
edges: layout.edges.length
|
|
@@ -195,7 +201,7 @@ export const getGraphView = async (vaultPath, input) => {
|
|
|
195
201
|
}
|
|
196
202
|
const rootGroups = arrangeGraphLevelGroups(groups.filter((group) => group.parentId === null));
|
|
197
203
|
const leafGroups = arrangeGraphLevelGroups(groups.filter((group) => group.nodeIds.length > 0));
|
|
198
|
-
const visibleGroups = rootGroups
|
|
204
|
+
const visibleGroups = selectViewportItemsWithFill(rootGroups, input);
|
|
199
205
|
const focused = leafGroups
|
|
200
206
|
.filter((group) => group.nodeIds.length > 0 && inViewport(group, input))
|
|
201
207
|
.map((group) => ({ group, coverage: groupCoverage(group, input) }))
|
|
@@ -215,20 +221,20 @@ export const getGraphView = async (vaultPath, input) => {
|
|
|
215
221
|
signature,
|
|
216
222
|
mode: 'micro',
|
|
217
223
|
nodes: viewNodes,
|
|
218
|
-
edges:
|
|
224
|
+
edges: limitEdges(realEdges(layout.edges, visibleNodeIds)),
|
|
219
225
|
totals: {
|
|
220
226
|
nodes: layout.nodes.length,
|
|
221
227
|
edges: layout.edges.length
|
|
222
228
|
}
|
|
223
229
|
};
|
|
224
230
|
}
|
|
225
|
-
const groupsToRender =
|
|
231
|
+
const groupsToRender = visibleGroups.slice(0, nodeLimit);
|
|
226
232
|
const viewNodes = groupsToRender.map(groupNode);
|
|
227
233
|
return {
|
|
228
234
|
signature,
|
|
229
235
|
mode: 'macro',
|
|
230
236
|
nodes: viewNodes,
|
|
231
|
-
edges:
|
|
237
|
+
edges: limitEdges(aggregateGroupEdges(groupsToRender, layout.edges, groupById)),
|
|
232
238
|
totals: {
|
|
233
239
|
nodes: layout.nodes.length,
|
|
234
240
|
edges: layout.edges.length
|
|
@@ -231,9 +231,13 @@ const chunkNodes = (nodes, degrees, groupNodeLimit = hierarchyGroupNodeLimit) =>
|
|
|
231
231
|
return degreeDelta;
|
|
232
232
|
return left.title.localeCompare(right.title);
|
|
233
233
|
});
|
|
234
|
+
const groupCountTarget = Math.min(groupNodeLimit, sortedNodes.length);
|
|
235
|
+
const chunkSize = sortedNodes.length <= groupNodeLimit * groupNodeLimit
|
|
236
|
+
? Math.max(1, Math.ceil(sortedNodes.length / groupCountTarget))
|
|
237
|
+
: groupNodeLimit;
|
|
234
238
|
const chunks = [];
|
|
235
|
-
for (let index = 0; index < sortedNodes.length; index +=
|
|
236
|
-
chunks.push(sortedNodes.slice(index, index +
|
|
239
|
+
for (let index = 0; index < sortedNodes.length; index += chunkSize) {
|
|
240
|
+
chunks.push(sortedNodes.slice(index, index + chunkSize));
|
|
237
241
|
}
|
|
238
242
|
return chunks;
|
|
239
243
|
};
|
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
|
|
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 that fill each visible graph level toward 1000 nodes while keeping every group capped at 1000 child nodes; zoom-out renders the macro level as a sparse strongest-link 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