@andespindola/brainlink 0.1.0-beta.137 → 0.1.0-beta.139
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 +4 -5
- package/dist/application/frontend/client-js.js +85 -68
- package/docs/AGENT_USAGE.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,11 +85,10 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
85
85
|
- Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
|
|
86
86
|
- 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.
|
|
87
87
|
- WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
|
|
88
|
-
- 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.
|
|
89
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.
|
|
90
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).
|
|
91
90
|
- Graph coordinates are visually compacted across graph sizes so reset starts from a stable fitted scene and zoom-in progressively reveals local detail.
|
|
92
|
-
- Zoomed-out graph LOD
|
|
91
|
+
- Zoomed-out graph LOD keeps a single flat graph scene, with viewport-driven node sampling and edge coverage safeguards to preserve visible relationships without switching to nested subgraphs.
|
|
93
92
|
- Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
|
|
94
93
|
- Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
|
|
95
94
|
- Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
|
|
@@ -598,16 +597,16 @@ The graph UI shows:
|
|
|
598
597
|
- realtime refresh while `--watch` is enabled
|
|
599
598
|
- graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
|
|
600
599
|
- wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
|
|
601
|
-
-
|
|
600
|
+
- wheel/button zoom updates immediately at the cursor anchor without delayed focus-transition interpolation
|
|
602
601
|
- Bloom-like scene navigation: reset fits the current graph scene, wheel zoom stays anchored to the cursor, and WebGL acceleration draws the dense node and edge layer faster
|
|
603
602
|
- zoom-out floor for large and massive graphs to keep the scene reachable without switching into a separate macro graph mode
|
|
604
603
|
- keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
|
|
605
|
-
- click on a
|
|
604
|
+
- click on a node opens its details panel; double-click on empty canvas zooms in at cursor position
|
|
606
605
|
- floating graph totals (notes, links, tags) below the Brainlink title
|
|
607
606
|
- graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
|
|
608
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
|
|
609
608
|
- WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
|
|
610
|
-
- large graph LOD keeps a
|
|
609
|
+
- large graph LOD keeps a single-level graph model across zoom levels, preserving visual continuity while adjusting node/edge density from the active viewport
|
|
611
610
|
|
|
612
611
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
613
612
|
|
|
@@ -749,13 +749,7 @@ const selectStableSampleNodes = (sourceNodes, limit) => {
|
|
|
749
749
|
return sourceNodes
|
|
750
750
|
}
|
|
751
751
|
|
|
752
|
-
const
|
|
753
|
-
const cursorPoint = cursorWorldPoint()
|
|
754
|
-
const recentZoomFocus =
|
|
755
|
-
now - state.lastZoomFocus.at <= 1500
|
|
756
|
-
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
757
|
-
: null
|
|
758
|
-
const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
|
|
752
|
+
const anchor = viewportCenterWorldPoint()
|
|
759
753
|
const previousIds = new Set(state.renderNodes.map((node) => node.id))
|
|
760
754
|
const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
|
|
761
755
|
|
|
@@ -815,14 +809,17 @@ const edgeRelevanceScore = edge => {
|
|
|
815
809
|
return score
|
|
816
810
|
}
|
|
817
811
|
|
|
818
|
-
const collectVisibleEdgesForNodes = nodeIds => {
|
|
812
|
+
const collectVisibleEdgesForNodes = (nodeIds, options = { preferComplete: false }) => {
|
|
819
813
|
if (nodeIds.size === 0) {
|
|
820
814
|
return []
|
|
821
815
|
}
|
|
822
816
|
|
|
823
817
|
const seen = new Set()
|
|
824
818
|
const candidates = []
|
|
825
|
-
const
|
|
819
|
+
const bestEdgeByNode = new Map()
|
|
820
|
+
const limit = options.preferComplete
|
|
821
|
+
? hierarchyAbsoluteEdgeSafetyCap
|
|
822
|
+
: edgeBudgetForCurrentFrame()
|
|
826
823
|
|
|
827
824
|
nodeIds.forEach(nodeId => {
|
|
828
825
|
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
@@ -836,6 +833,15 @@ const collectVisibleEdgesForNodes = nodeIds => {
|
|
|
836
833
|
|
|
837
834
|
seen.add(key)
|
|
838
835
|
candidates.push(edge)
|
|
836
|
+
const score = edgeRelevanceScore(edge)
|
|
837
|
+
const currentSource = bestEdgeByNode.get(edge.source)
|
|
838
|
+
if (!currentSource || score > currentSource.score) {
|
|
839
|
+
bestEdgeByNode.set(edge.source, { edge, score })
|
|
840
|
+
}
|
|
841
|
+
const currentTarget = bestEdgeByNode.get(edge.target)
|
|
842
|
+
if (!currentTarget || score > currentTarget.score) {
|
|
843
|
+
bestEdgeByNode.set(edge.target, { edge, score })
|
|
844
|
+
}
|
|
839
845
|
}
|
|
840
846
|
})
|
|
841
847
|
|
|
@@ -843,7 +849,7 @@ const collectVisibleEdgesForNodes = nodeIds => {
|
|
|
843
849
|
return candidates
|
|
844
850
|
}
|
|
845
851
|
|
|
846
|
-
|
|
852
|
+
const ranked = candidates
|
|
847
853
|
.sort((left, right) => {
|
|
848
854
|
const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
|
|
849
855
|
if (scoreDelta !== 0) {
|
|
@@ -853,7 +859,38 @@ const collectVisibleEdgesForNodes = nodeIds => {
|
|
|
853
859
|
const rightKey = edgeIdentityKey(right)
|
|
854
860
|
return leftKey.localeCompare(rightKey)
|
|
855
861
|
})
|
|
856
|
-
|
|
862
|
+
|
|
863
|
+
if (options.preferComplete) {
|
|
864
|
+
return ranked.slice(0, limit)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const coverageFloor = Math.min(4_200, Math.max(limit, Math.floor(nodeIds.size * 1.6)))
|
|
868
|
+
const resolvedLimit = Math.min(coverageFloor, ranked.length)
|
|
869
|
+
const selected = []
|
|
870
|
+
const selectedKeys = new Set()
|
|
871
|
+
|
|
872
|
+
nodeIds.forEach(nodeId => {
|
|
873
|
+
const edge = bestEdgeByNode.get(nodeId)?.edge
|
|
874
|
+
if (!edge) return
|
|
875
|
+
const key = edgeIdentityKey(edge)
|
|
876
|
+
if (selectedKeys.has(key)) return
|
|
877
|
+
selectedKeys.add(key)
|
|
878
|
+
selected.push(edge)
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
if (selected.length >= resolvedLimit) {
|
|
882
|
+
return selected.slice(0, resolvedLimit)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
for (let index = 0; index < ranked.length && selected.length < resolvedLimit; index += 1) {
|
|
886
|
+
const edge = ranked[index]
|
|
887
|
+
const key = edgeIdentityKey(edge)
|
|
888
|
+
if (selectedKeys.has(key)) continue
|
|
889
|
+
selectedKeys.add(key)
|
|
890
|
+
selected.push(edge)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
return selected
|
|
857
894
|
}
|
|
858
895
|
|
|
859
896
|
const edgeOpacityForScale = (edge, scale) => {
|
|
@@ -1119,10 +1156,7 @@ const partitionGraphForAcceleratedRenderer = () => {
|
|
|
1119
1156
|
}
|
|
1120
1157
|
|
|
1121
1158
|
const drawGraphLabels = nodes => {
|
|
1122
|
-
const
|
|
1123
|
-
const shouldDrawLabels = isRenderingHierarchyChildGraph
|
|
1124
|
-
? false
|
|
1125
|
-
: state.nodes.length > largeGraphNodeThreshold
|
|
1159
|
+
const shouldDrawLabels = state.nodes.length > largeGraphNodeThreshold
|
|
1126
1160
|
? state.transform.scale >= 1.25 && state.renderNodes.length <= 420
|
|
1127
1161
|
: state.transform.scale >= 0.62 && state.renderNodes.length <= 1200
|
|
1128
1162
|
|
|
@@ -1358,10 +1392,18 @@ const projectNodesIntoChildGraph = (nodes, focusRenderNode, group) => {
|
|
|
1358
1392
|
|
|
1359
1393
|
const centerX = (minX + maxX) / 2
|
|
1360
1394
|
const centerY = (minY + maxY) / 2
|
|
1361
|
-
const
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1395
|
+
const maxDistanceFromCenter = nodes.reduce((maxDistance, node) => {
|
|
1396
|
+
const distance = Math.hypot(node.x - centerX, node.y - centerY)
|
|
1397
|
+
return Math.max(maxDistance, distance)
|
|
1398
|
+
}, 1)
|
|
1399
|
+
const subgraphFillByNodeCount = (nodeCount) => {
|
|
1400
|
+
if (nodeCount <= 24) return 0.62
|
|
1401
|
+
if (nodeCount <= 80) return 0.7
|
|
1402
|
+
if (nodeCount <= 200) return 0.78
|
|
1403
|
+
return 0.84
|
|
1404
|
+
}
|
|
1405
|
+
const targetRadius = Math.max(1, childGraphRenderRadius(group) * subgraphFillByNodeCount(nodes.length))
|
|
1406
|
+
const scale = targetRadius / Math.max(maxDistanceFromCenter, 1)
|
|
1365
1407
|
|
|
1366
1408
|
return nodes.map(node => ({
|
|
1367
1409
|
...node,
|
|
@@ -1715,7 +1757,7 @@ const computeHierarchyRenderVisibility = (viewport) => {
|
|
|
1715
1757
|
const childEdges = (isMicroView || revealProgress > 0)
|
|
1716
1758
|
? focusChildGroups.length > 0
|
|
1717
1759
|
? groupEdgesForRenderedGroups(childNodes)
|
|
1718
|
-
: collectVisibleEdgesForNodes(childIds).map(edge => ({
|
|
1760
|
+
: collectVisibleEdgesForNodes(childIds, { preferComplete: true }).map(edge => ({
|
|
1719
1761
|
...edge,
|
|
1720
1762
|
sourceNode: childById.get(edge.source) ?? edge.sourceNode,
|
|
1721
1763
|
targetNode: childById.get(edge.target) ?? edge.targetNode
|
|
@@ -2138,10 +2180,7 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
|
|
|
2138
2180
|
const width = Math.max(rect.width, 320)
|
|
2139
2181
|
const height = Math.max(rect.height, 320)
|
|
2140
2182
|
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
2141
|
-
const
|
|
2142
|
-
? arrangeGraphLevelNodes(parentHierarchyGroups(), groupRenderRadius).map(createGroupRenderNode)
|
|
2143
|
-
: null
|
|
2144
|
-
const fitNodes = hierarchyFitNodes ?? nodes
|
|
2183
|
+
const fitNodes = nodes
|
|
2145
2184
|
const bounds = graphBounds(fitNodes)
|
|
2146
2185
|
|
|
2147
2186
|
if (!bounds) {
|
|
@@ -2169,14 +2208,10 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
|
|
|
2169
2208
|
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(fitNodes.length))
|
|
2170
2209
|
const scaleRange = autoFitScaleRangeByNodeCount(fitNodes.length)
|
|
2171
2210
|
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
2172
|
-
const resolvedScale = nodes.length > massiveGraphNodeThreshold
|
|
2211
|
+
const resolvedScale = nodes.length > massiveGraphNodeThreshold
|
|
2173
2212
|
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
2174
2213
|
: baselineScale
|
|
2175
|
-
const hubCenter =
|
|
2176
|
-
hierarchyFitNodes
|
|
2177
|
-
? null
|
|
2178
|
-
:
|
|
2179
|
-
options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
|
|
2214
|
+
const hubCenter = options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
|
|
2180
2215
|
? state.primaryHub
|
|
2181
2216
|
: null
|
|
2182
2217
|
const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
|
|
@@ -2740,14 +2775,10 @@ const computeRenderVisibility = () => {
|
|
|
2740
2775
|
state.lastViewportKey = viewportKey
|
|
2741
2776
|
state.renderVisibilityDirty = false
|
|
2742
2777
|
|
|
2743
|
-
if (computeHierarchyRenderVisibility(viewport)) {
|
|
2744
|
-
return
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
2778
|
if (state.visibleNodes.length <= 2000) {
|
|
2748
2779
|
state.renderNodes = state.visibleNodes
|
|
2749
2780
|
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
2750
|
-
state.renderEdges = limitRenderEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
|
|
2781
|
+
state.renderEdges = limitRenderEdges(state.renderNodes, collectVisibleEdgesForNodes(ids, { preferComplete: true }))
|
|
2751
2782
|
return
|
|
2752
2783
|
}
|
|
2753
2784
|
|
|
@@ -2772,12 +2803,17 @@ const computeRenderVisibility = () => {
|
|
|
2772
2803
|
: viewportNodes.length > 0
|
|
2773
2804
|
? viewportNodes
|
|
2774
2805
|
: sampleMassiveOverviewNodes(sampleLimit)
|
|
2775
|
-
const
|
|
2806
|
+
const shouldCarryOverNodes = state.transform.scale < 0.18
|
|
2807
|
+
const carryMargin = Math.max(180, Math.min(640, 260 / Math.max(state.transform.scale, 0.0001)))
|
|
2776
2808
|
const carryViewport = expandViewportBounds(viewport, carryMargin)
|
|
2777
|
-
const carryOverLimit =
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2809
|
+
const carryOverLimit = shouldCarryOverNodes
|
|
2810
|
+
? Math.max(80, Math.min(sampleLimit, Math.floor(sampleLimit * 0.24)))
|
|
2811
|
+
: 0
|
|
2812
|
+
const carryOverNodes = shouldCarryOverNodes
|
|
2813
|
+
? (state.renderNodes ?? [])
|
|
2814
|
+
.filter((node) => isNodeInViewport(node, carryViewport))
|
|
2815
|
+
.slice(0, carryOverLimit)
|
|
2816
|
+
: []
|
|
2781
2817
|
const sourceWithCarry = mergeUniqueNodes(
|
|
2782
2818
|
sourceNodes,
|
|
2783
2819
|
carryOverNodes,
|
|
@@ -3276,23 +3312,15 @@ const selectNodeById = id => {
|
|
|
3276
3312
|
|
|
3277
3313
|
const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
3278
3314
|
state.lastManualZoomAt = performance.now()
|
|
3279
|
-
const baseScale = state.
|
|
3280
|
-
? state.zoomTransition.targetScale
|
|
3281
|
-
: state.transform.scale
|
|
3315
|
+
const baseScale = state.transform.scale
|
|
3282
3316
|
const boundedFactor = source === 'wheel'
|
|
3283
3317
|
? Math.max(wheelZoomInputFloorCap, Math.min(wheelZoomInputCeilCap, factor))
|
|
3284
3318
|
: factor
|
|
3285
3319
|
const nextScale = clampScale(baseScale * boundedFactor)
|
|
3286
|
-
if (nextScale === baseScale
|
|
3320
|
+
if (nextScale === baseScale) {
|
|
3287
3321
|
return
|
|
3288
3322
|
}
|
|
3289
|
-
const worldPointAtCursor =
|
|
3290
|
-
state.zoomTransition.active &&
|
|
3291
|
-
state.zoomTransition.source === source &&
|
|
3292
|
-
state.zoomTransition.screenX === screenX &&
|
|
3293
|
-
state.zoomTransition.screenY === screenY
|
|
3294
|
-
? { x: state.zoomTransition.worldX, y: state.zoomTransition.worldY }
|
|
3295
|
-
: resolveZoomAnchorWorldPoint(screenX, screenY)
|
|
3323
|
+
const worldPointAtCursor = resolveZoomAnchorWorldPoint(screenX, screenY)
|
|
3296
3324
|
const worldX = worldPointAtCursor.x
|
|
3297
3325
|
const worldY = worldPointAtCursor.y
|
|
3298
3326
|
state.lastZoomFocus = {
|
|
@@ -3300,15 +3328,10 @@ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
|
3300
3328
|
y: worldY,
|
|
3301
3329
|
at: performance.now()
|
|
3302
3330
|
}
|
|
3303
|
-
state.
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
screenY,
|
|
3308
|
-
worldX,
|
|
3309
|
-
worldY,
|
|
3310
|
-
targetScale: nextScale
|
|
3311
|
-
}
|
|
3331
|
+
state.transform.scale = nextScale
|
|
3332
|
+
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
3333
|
+
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
3334
|
+
clearZoomTransition()
|
|
3312
3335
|
state.offscreenFrameCount = 0
|
|
3313
3336
|
markRenderDirty()
|
|
3314
3337
|
}
|
|
@@ -3388,7 +3411,7 @@ const bindEvents = () => {
|
|
|
3388
3411
|
if (elements.fit) {
|
|
3389
3412
|
elements.fit.addEventListener('click', () => {
|
|
3390
3413
|
resetHierarchyFocus()
|
|
3391
|
-
|
|
3414
|
+
fitView({ useFiltered: true, preferHubCenter: false })
|
|
3392
3415
|
})
|
|
3393
3416
|
}
|
|
3394
3417
|
elements.reset.addEventListener('click', () => {
|
|
@@ -3477,13 +3500,7 @@ const bindEvents = () => {
|
|
|
3477
3500
|
}
|
|
3478
3501
|
if (!state.pointer.moved) {
|
|
3479
3502
|
const clickedNode = draggedNode ?? state.hovered
|
|
3480
|
-
|
|
3481
|
-
handleGroupNodePrimaryClick(clickedNode)
|
|
3482
|
-
} else if (clickedNode && state.hierarchyFocusGroupId) {
|
|
3483
|
-
handleLeafNodePrimaryClick(clickedNode)
|
|
3484
|
-
} else {
|
|
3485
|
-
selectNode(clickedNode, { openContent: true })
|
|
3486
|
-
}
|
|
3503
|
+
selectNode(clickedNode, { openContent: true })
|
|
3487
3504
|
}
|
|
3488
3505
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
3489
3506
|
canvas.releasePointerCapture(event.pointerId)
|
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 empty canvas zooms in at cursor position. Clicking a
|
|
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 and applied immediately without delayed focus interpolation. Keyboard shortcuts are `+` (zoom in), `-` (zoom out) and `0` (reset fit). Double-click on empty canvas zooms in at cursor position. Clicking a node opens its details panel. Totals for notes, links and tags stay visible as floating metrics under the Brainlink title, and node details open in a non-modal side panel (tags, outgoing links, backlinks and Markdown content), so zoom and pan remain available during inspection. Vaults above 1000 notes keep the same single graph scene and use viewport-driven LOD sampling with edge-coverage safeguards to preserve visible relationships without switching into nested subgraph views.
|
|
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