@andespindola/brainlink 0.1.0-beta.84 → 0.1.0-beta.85

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,6 +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 zoom-out renders connected ecosystem clusters of up to 1000 notes before expanding into individual notes, keeping vaults visually oriented with a smaller render scope.
87
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.
88
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).
89
90
  - Graph coordinates are visually compacted across graph sizes so reset starts from a stable macro mass and zoom-in progressively expands toward local detail.
@@ -601,7 +602,7 @@ The graph UI shows:
601
602
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
602
603
  - compact macro-to-micro density progression so reset keeps the graph mass oriented and zoom-in separates local neighborhoods progressively
603
604
  - graph camera treats hub-centered navigation as structural only when the hub is dominant; diffuse stress graphs reset and zoom around the full graph mass
604
- - massive-graph LOD progression: very low zoom uses spatial overview sampling plus hub-neighborhood edge previews to preserve whole-vault shape and orientation, then progressively raises the focused node budget as zoom increases so dense local areas keep nearby notes and links visible
605
+ - graph LOD progression: very low zoom uses connected ecosystem clusters of up to 1000 notes with aggregated real links, then progressively raises the focused node budget as zoom increases so dense local areas keep nearby notes and links visible
605
606
 
606
607
  The server indexes before starting by default. Use `--no-index` to skip that step:
607
608
 
@@ -21,14 +21,15 @@ const viewportPaddingPx = 280
21
21
  const worldCoordinateLimit = 5_000_000
22
22
  const transformCoordinateLimit = 20_000_000
23
23
  const hoverHitTestIntervalMs = 64
24
- const overviewClusterMaxCount = 1400
24
+ const ecosystemGroupSize = 1000
25
+ const ecosystemClusterEdgeLimit = 520
26
+ const ecosystemClusterScaleThreshold = 0.08
25
27
  const zoomRecoveryGuardMs = 4200
26
28
  const zoomCapTargetViewportShare = 0.72
27
29
  const meshEdgeScaleThreshold = 0.09
28
30
  const meshEdgeMinBudget = 140
29
31
  const meshEdgeMaxBudget = 1400
30
32
  const layeredCoreScaleThreshold = 0.55
31
- const massiveOverviewClusterScaleThreshold = 0.035
32
33
  const dragNeighborhoodMaxAffected = 180
33
34
  const dragSettleRounds = 3
34
35
  const wheelZoomExponent = 0.0018
@@ -44,6 +45,7 @@ const state = {
44
45
  renderNodes: [],
45
46
  renderEdges: [],
46
47
  renderClusters: [],
48
+ renderClusterEdges: [],
47
49
  nodeDegrees: new Map(),
48
50
  selected: null,
49
51
  hovered: null,
@@ -65,7 +67,8 @@ const state = {
65
67
  lastViewportKey: '',
66
68
  visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
67
69
  visibleEdgeByNode: new Map(),
68
- overviewClusters: [],
70
+ ecosystemClusters: [],
71
+ ecosystemEdges: [],
69
72
  macroCenter: { x: 0, y: 0 },
70
73
  macroRepresentative: null,
71
74
  primaryHub: null,
@@ -562,7 +565,11 @@ const recomputeVisibility = () => {
562
565
  state.visibleEdges = limitedEdges
563
566
  state.visibleNodeSpatial = createSpatialIndex(nodes)
564
567
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
565
- state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
568
+ const ecosystemGraph = nodes.length > 1
569
+ ? buildEcosystemGraph(nodes, limitedEdges)
570
+ : { clusters: [], edges: [] }
571
+ state.ecosystemClusters = ecosystemGraph.clusters
572
+ state.ecosystemEdges = ecosystemGraph.edges
566
573
  const primaryHub = rankedHubNodes()[0] ?? null
567
574
  state.primaryHub = primaryHub
568
575
  state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
@@ -679,68 +686,131 @@ const createVisibleEdgeLookup = edges => {
679
686
  return lookup
680
687
  }
681
688
 
682
- const buildOverviewClusters = nodes => {
683
- if (nodes.length === 0) {
684
- return []
689
+ const ecosystemKeyForNode = node => {
690
+ if (typeof node.segment === 'string' && node.segment.trim()) {
691
+ return node.segment.trim()
685
692
  }
686
-
687
- const bounds = graphBounds(nodes)
688
- if (!bounds) {
689
- return []
693
+ if (typeof node.group === 'string' && node.group.trim()) {
694
+ return node.group.trim()
690
695
  }
696
+ const pathParts = String(node.path || '')
697
+ .split('/')
698
+ .filter(part => part.trim())
699
+ .slice(0, 2)
700
+ return pathParts.length > 0 ? pathParts.join('/') : 'root'
701
+ }
691
702
 
692
- const longest = Math.max(bounds.width, bounds.height, 1)
693
- const cellSize = Math.max(longest / 56, 900)
694
- const buckets = new Map()
703
+ const compareNodesForEcosystem = (left, right) => {
704
+ const keyComparison = ecosystemKeyForNode(left).localeCompare(ecosystemKeyForNode(right))
705
+ if (keyComparison !== 0) return keyComparison
706
+ const leftDegree = state.nodeDegrees.get(left.id) ?? 0
707
+ const rightDegree = state.nodeDegrees.get(right.id) ?? 0
708
+ if (leftDegree !== rightDegree) return rightDegree - leftDegree
709
+ return String(left.title || left.id).localeCompare(String(right.title || right.id))
710
+ }
711
+
712
+ const selectEcosystemRepresentative = nodes => {
713
+ let representative = nodes[0] ?? null
714
+ let representativeScore = Number.NEGATIVE_INFINITY
695
715
 
696
716
  for (let index = 0; index < nodes.length; index += 1) {
697
717
  const node = nodes[index]
698
- const keyX = Math.floor((node.x - bounds.minX) / cellSize)
699
- const keyY = Math.floor((node.y - bounds.minY) / cellSize)
700
- const key = keyX + ':' + keyY
701
- const degree = state.nodeDegrees.get(node.id) ?? 0
702
- const current = buckets.get(key)
718
+ const score = (state.nodeDegrees.get(node.id) ?? 0) + hubNodeScore(node) * 1000
719
+ if (score > representativeScore) {
720
+ representative = node
721
+ representativeScore = score
722
+ }
723
+ }
724
+
725
+ return representative
726
+ }
727
+
728
+ const buildEcosystemCluster = (nodes, index) => {
729
+ const count = Math.max(nodes.length, 1)
730
+ const sum = nodes.reduce((accumulator, node) => ({
731
+ x: accumulator.x + node.x,
732
+ y: accumulator.y + node.y
733
+ }), { x: 0, y: 0 })
734
+ const representative = selectEcosystemRepresentative(nodes)
735
+
736
+ return {
737
+ id: 'ecosystem-' + index,
738
+ x: sum.x / count,
739
+ y: sum.y / count,
740
+ count,
741
+ representative,
742
+ label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
743
+ }
744
+ }
745
+
746
+ const buildEcosystemGraph = (nodes, edges) => {
747
+ if (nodes.length === 0) {
748
+ return { clusters: [], edges: [] }
749
+ }
750
+
751
+ const sortedNodes = [...nodes].sort(compareNodesForEcosystem)
752
+ const clusters = []
753
+ const clusterByNodeId = new Map()
754
+
755
+ for (let offset = 0; offset < sortedNodes.length; offset += ecosystemGroupSize) {
756
+ const clusterNodes = sortedNodes.slice(offset, offset + ecosystemGroupSize)
757
+ const cluster = buildEcosystemCluster(clusterNodes, clusters.length)
758
+ clusters.push(cluster)
759
+ for (let index = 0; index < clusterNodes.length; index += 1) {
760
+ clusterByNodeId.set(clusterNodes[index].id, cluster)
761
+ }
762
+ }
763
+
764
+ const edgeByClusterPair = new Map()
765
+ for (let index = 0; index < edges.length; index += 1) {
766
+ const edge = edges[index]
767
+ const sourceCluster = clusterByNodeId.get(edge.source)
768
+ const targetCluster = clusterByNodeId.get(edge.target)
769
+ if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
770
+ continue
771
+ }
772
+
773
+ const orderedIds = sourceCluster.id < targetCluster.id
774
+ ? [sourceCluster.id, targetCluster.id]
775
+ : [targetCluster.id, sourceCluster.id]
776
+ const key = orderedIds.join(':')
777
+ const current = edgeByClusterPair.get(key)
703
778
  if (current) {
704
- current.count += 1
705
- current.sumX += node.x
706
- current.sumY += node.y
707
- if (degree > current.degree) {
708
- current.representative = node
709
- current.degree = degree
710
- }
779
+ current.weight += edgeWeight(edge)
711
780
  continue
712
781
  }
713
782
 
714
- buckets.set(key, {
783
+ edgeByClusterPair.set(key, {
715
784
  id: key,
716
- count: 1,
717
- sumX: node.x,
718
- sumY: node.y,
719
- representative: node,
720
- degree
785
+ sourceCluster,
786
+ targetCluster,
787
+ weight: edgeWeight(edge)
721
788
  })
722
789
  }
723
790
 
724
- return Array.from(buckets.values())
725
- .sort((left, right) => right.count - left.count)
726
- .slice(0, overviewClusterMaxCount)
727
- .map((cluster) => ({
728
- id: cluster.id,
729
- x: cluster.sumX / Math.max(cluster.count, 1),
730
- y: cluster.sumY / Math.max(cluster.count, 1),
731
- count: cluster.count,
732
- representative: cluster.representative
733
- }))
791
+ const aggregatedEdges = Array.from(edgeByClusterPair.values())
792
+ .sort((left, right) => right.weight - left.weight)
793
+ .slice(0, ecosystemClusterEdgeLimit)
794
+
795
+ return { clusters, edges: aggregatedEdges }
734
796
  }
735
797
 
736
- const filterOverviewClustersByViewport = viewport =>
737
- state.overviewClusters.filter((cluster) =>
798
+ const filterEcosystemClustersByViewport = viewport =>
799
+ state.ecosystemClusters.filter((cluster) =>
738
800
  cluster.x >= viewport.minX &&
739
801
  cluster.x <= viewport.maxX &&
740
802
  cluster.y >= viewport.minY &&
741
803
  cluster.y <= viewport.maxY
742
804
  )
743
805
 
806
+ const ecosystemEdgesForClusters = clusters => {
807
+ const clusterIds = new Set(clusters.map(cluster => cluster.id))
808
+ return state.ecosystemEdges.filter(edge =>
809
+ clusterIds.has(edge.sourceCluster.id) &&
810
+ clusterIds.has(edge.targetCluster.id)
811
+ )
812
+ }
813
+
744
814
  const edgeBudgetForCurrentFrame = () => {
745
815
  const zoom = state.transform.scale
746
816
  if (zoom < 0.12) return 380
@@ -775,14 +845,6 @@ const nodeBudgetForScale = (scale) => {
775
845
  return renderNodeBudget
776
846
  }
777
847
 
778
- const massiveLowZoomNodeBudgetForScale = (scale) => {
779
- if (scale < 0.004) return 780
780
- if (scale < 0.01) return 860
781
- if (scale < 0.02) return 900
782
- if (scale < 0.035) return 900
783
- return renderNodeBudget
784
- }
785
-
786
848
  const layerFocusForScale = (scale) => {
787
849
  const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
788
850
  const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
@@ -2316,6 +2378,7 @@ const computeRenderVisibility = () => {
2316
2378
  }
2317
2379
  state.lastViewportKey = viewportKey
2318
2380
  state.renderVisibilityDirty = false
2381
+ state.renderClusterEdges = []
2319
2382
 
2320
2383
  const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
2321
2384
 
@@ -2339,12 +2402,27 @@ const computeRenderVisibility = () => {
2339
2402
  state.renderNodes = []
2340
2403
  }
2341
2404
  state.renderEdges = []
2405
+ state.renderClusterEdges = []
2406
+ return
2407
+ }
2408
+
2409
+ if (state.transform.scale <= ecosystemClusterScaleThreshold && state.ecosystemClusters.length > 0) {
2410
+ const viewportClusters = filterEcosystemClustersByViewport(viewport)
2411
+ .sort((left, right) => right.count - left.count)
2412
+ const clusters = viewportClusters.length > 0
2413
+ ? viewportClusters
2414
+ : state.ecosystemClusters
2415
+ state.renderClusters = clusters
2416
+ state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
2417
+ state.renderNodes = []
2418
+ state.renderEdges = []
2342
2419
  return
2343
2420
  }
2344
2421
 
2345
2422
  if (state.visibleNodes.length <= 2000) {
2346
2423
  state.renderNodes = state.visibleNodes
2347
2424
  state.renderClusters = []
2425
+ state.renderClusterEdges = []
2348
2426
  const ids = new Set(state.renderNodes.map((node) => node.id))
2349
2427
  state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2350
2428
  return
@@ -2352,30 +2430,6 @@ const computeRenderVisibility = () => {
2352
2430
 
2353
2431
  if (state.visibleNodes.length > massiveGraphNodeThreshold) {
2354
2432
  const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2355
- if (state.transform.scale <= massiveOverviewClusterScaleThreshold) {
2356
- const overviewLimit = Math.min(renderNodeBudget, massiveLowZoomNodeBudgetForScale(state.transform.scale))
2357
- const overviewClusters = filterOverviewClustersByViewport(viewport)
2358
- .sort((left, right) => right.count - left.count)
2359
- .slice(0, overviewLimit)
2360
- if (overviewClusters.length > 0) {
2361
- const overviewNodes = representativeNodesFromClusters(
2362
- overviewClusters,
2363
- overviewLimit
2364
- )
2365
- const anchoredNodes = includeHubPreviewNeighborhood(
2366
- overviewNodes,
2367
- Math.min(renderNodeBudget, overviewLimit)
2368
- )
2369
- const enriched = enrichSampleWithNeighbors(anchoredNodes)
2370
- const previewNodes = ensureHubNodesInRenderedSet(enriched.nodes)
2371
- const previewIds = new Set(previewNodes.map((node) => node.id))
2372
- const previewEdges = collectVisibleEdgesForNodes(previewIds)
2373
- state.renderClusters = []
2374
- state.renderNodes = previewNodes
2375
- state.renderEdges = previewEdges
2376
- return
2377
- }
2378
- }
2379
2433
  const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2380
2434
  const sampleLimit = nodeBudgetForScale(state.transform.scale)
2381
2435
  const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
@@ -2421,6 +2475,7 @@ const computeRenderVisibility = () => {
2421
2475
  }
2422
2476
 
2423
2477
  state.renderClusters = []
2478
+ state.renderClusterEdges = []
2424
2479
  state.renderNodes = sampledNodes
2425
2480
  state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
2426
2481
  return
@@ -2430,6 +2485,7 @@ const computeRenderVisibility = () => {
2430
2485
  const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
2431
2486
  const sampledIds = new Set(sampled.map((node) => node.id))
2432
2487
  state.renderClusters = []
2488
+ state.renderClusterEdges = []
2433
2489
  state.renderNodes = sampled
2434
2490
  state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2435
2491
  return
@@ -2439,11 +2495,13 @@ const computeRenderVisibility = () => {
2439
2495
  const clusters = clusterViewportNodes(viewportNodes)
2440
2496
  if (clusters.length > 0) {
2441
2497
  state.renderClusters = []
2498
+ state.renderClusterEdges = []
2442
2499
  state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
2443
2500
  state.renderEdges = []
2444
2501
  return
2445
2502
  }
2446
2503
  state.renderClusters = []
2504
+ state.renderClusterEdges = []
2447
2505
  const stride = viewportNodeStride()
2448
2506
  const picked = []
2449
2507
 
@@ -2467,6 +2525,7 @@ const computeRenderVisibility = () => {
2467
2525
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2468
2526
  state.renderNodes = fallbackNodes
2469
2527
  state.renderClusters = []
2528
+ state.renderClusterEdges = []
2470
2529
  state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2471
2530
  return
2472
2531
  }
@@ -2482,6 +2541,7 @@ const computeRenderVisibility = () => {
2482
2541
  const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
2483
2542
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2484
2543
  state.renderClusters = []
2544
+ state.renderClusterEdges = []
2485
2545
  state.renderNodes = fallbackNodes
2486
2546
  state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2487
2547
  }
@@ -2598,6 +2658,17 @@ const render = now => {
2598
2658
  ctx.translate(state.transform.x, state.transform.y)
2599
2659
  ctx.scale(state.transform.scale, state.transform.scale)
2600
2660
  const safeScale = Math.max(state.transform.scale, 0.0001)
2661
+ if (state.renderClusterEdges.length > 0) {
2662
+ ctx.beginPath()
2663
+ for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
2664
+ const edge = state.renderClusterEdges[index]
2665
+ ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
2666
+ ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
2667
+ }
2668
+ ctx.lineWidth = 1.2 / safeScale
2669
+ ctx.strokeStyle = 'rgba(153, 165, 181, 0.22)'
2670
+ ctx.stroke()
2671
+ }
2601
2672
  state.renderClusters.forEach(cluster => {
2602
2673
  const isMacro = cluster.id === 'macro-galaxy'
2603
2674
  const radiusPx = isMacro
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.84",
3
+ "version": "0.1.0-beta.85",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",