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

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 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
+ - Graph zoom-out renders hierarchical ecosystem subgraphs: macro clusters of up to 1000 notes expand near the user's focus into smaller graph meshes before individual notes are rendered.
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 macro mass and zoom-in progressively expands toward local detail.
91
- - Zoomed-out graph LOD clusters dense regions and progressively expands the focused viewport as zoom increases, including very large vaults.
91
+ - Zoomed-out graph LOD renders nested subgraphs and progressively expands only the focused cluster as zoom increases, including very large vaults.
92
92
  - Graph reset starts in macro "galaxy" overview mode and progressively reveals nearby nodes as zoom increases, including smaller vaults.
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.
@@ -602,7 +602,7 @@ The graph UI shows:
602
602
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
603
603
  - compact macro-to-micro density progression so reset keeps the graph mass oriented and zoom-in separates local neighborhoods progressively
604
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
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
+ - graph LOD progression: very low zoom uses connected ecosystem clusters of up to 1000 notes, zoom-in expands only focused clusters into 250-note and 60-note subgraphs with aggregated real links, then progressively raises the focused node budget so local areas keep nearby notes and links visible
606
606
 
607
607
  The server indexes before starting by default. Use `--no-index` to skip that step:
608
608
 
@@ -22,8 +22,12 @@ const worldCoordinateLimit = 5_000_000
22
22
  const transformCoordinateLimit = 20_000_000
23
23
  const hoverHitTestIntervalMs = 64
24
24
  const ecosystemGroupSize = 1000
25
+ const ecosystemGroupSizes = [1000, 250, 60]
25
26
  const ecosystemClusterEdgeLimit = 520
26
- const ecosystemClusterScaleThreshold = 0.08
27
+ const ecosystemClusterScaleThreshold = 0.32
28
+ const ecosystemSubgraphScaleThreshold = 0.18
29
+ const ecosystemMicroScaleThreshold = 0.08
30
+ const ecosystemFocusedParentLimit = 3
27
31
  const zoomRecoveryGuardMs = 4200
28
32
  const zoomCapTargetViewportShare = 0.72
29
33
  const meshEdgeScaleThreshold = 0.09
@@ -68,7 +72,7 @@ const state = {
68
72
  visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
69
73
  visibleEdgeByNode: new Map(),
70
74
  ecosystemClusters: [],
71
- ecosystemEdges: [],
75
+ ecosystemClustersBySize: new Map(),
72
76
  macroCenter: { x: 0, y: 0 },
73
77
  macroRepresentative: null,
74
78
  primaryHub: null,
@@ -566,10 +570,10 @@ const recomputeVisibility = () => {
566
570
  state.visibleNodeSpatial = createSpatialIndex(nodes)
567
571
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
568
572
  const ecosystemGraph = nodes.length > 1
569
- ? buildEcosystemGraph(nodes, limitedEdges)
570
- : { clusters: [], edges: [] }
573
+ ? buildEcosystemGraph(nodes)
574
+ : { clusters: [], clustersBySize: new Map() }
571
575
  state.ecosystemClusters = ecosystemGraph.clusters
572
- state.ecosystemEdges = ecosystemGraph.edges
576
+ state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
573
577
  const primaryHub = rankedHubNodes()[0] ?? null
574
578
  state.primaryHub = primaryHub
575
579
  state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
@@ -738,32 +742,138 @@ const buildEcosystemCluster = (nodes, index) => {
738
742
  x: sum.x / count,
739
743
  y: sum.y / count,
740
744
  count,
745
+ nodeIds: nodes.map(node => node.id),
741
746
  representative,
742
747
  label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
743
748
  }
744
749
  }
745
750
 
746
- const buildEcosystemGraph = (nodes, edges) => {
747
- if (nodes.length === 0) {
748
- return { clusters: [], edges: [] }
749
- }
750
-
751
- const sortedNodes = [...nodes].sort(compareNodesForEcosystem)
751
+ const buildEcosystemLevel = (sortedNodes, size, parentLookup) => {
752
752
  const clusters = []
753
753
  const clusterByNodeId = new Map()
754
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)
755
+ for (let offset = 0; offset < sortedNodes.length; offset += size) {
756
+ const clusterNodes = sortedNodes.slice(offset, offset + size)
757
+ const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
758
+ const cluster = {
759
+ ...buildEcosystemCluster(clusterNodes, clusters.length),
760
+ id: 'ecosystem-' + size + '-' + clusters.length,
761
+ size,
762
+ parentId: parentCluster?.id ?? null
763
+ }
758
764
  clusters.push(cluster)
759
765
  for (let index = 0; index < clusterNodes.length; index += 1) {
760
766
  clusterByNodeId.set(clusterNodes[index].id, cluster)
761
767
  }
762
768
  }
763
769
 
770
+ return { clusters, clusterByNodeId }
771
+ }
772
+
773
+ const buildEcosystemGraph = (nodes) => {
774
+ if (nodes.length === 0) {
775
+ return { clusters: [], clustersBySize: new Map() }
776
+ }
777
+
778
+ const sortedNodes = [...nodes].sort(compareNodesForEcosystem)
779
+ const clustersBySize = new Map()
780
+ let parentLookup = null
781
+
782
+ for (let index = 0; index < ecosystemGroupSizes.length; index += 1) {
783
+ const size = ecosystemGroupSizes[index]
784
+ const level = buildEcosystemLevel(sortedNodes, size, parentLookup)
785
+ clustersBySize.set(size, level.clusters)
786
+ parentLookup = level.clusterByNodeId
787
+ }
788
+
789
+ return {
790
+ clusters: clustersBySize.get(ecosystemGroupSize) ?? [],
791
+ clustersBySize
792
+ }
793
+ }
794
+
795
+ const isClusterInViewport = (cluster, viewport) =>
796
+ cluster.x >= viewport.minX &&
797
+ cluster.x <= viewport.maxX &&
798
+ cluster.y >= viewport.minY &&
799
+ cluster.y <= viewport.maxY
800
+
801
+ const filterEcosystemClustersByViewport = (clusters, viewport) => {
802
+ const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
803
+ return visible.length > 0 ? visible : [...clusters]
804
+ }
805
+
806
+ const ecosystemFocusPoint = () => {
807
+ const now = performance.now()
808
+ if (now - state.lastZoomFocus.at <= 1800) {
809
+ return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
810
+ }
811
+ return viewportCenterWorldPoint()
812
+ }
813
+
814
+ const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
815
+ clusters
816
+ .map(cluster => ({
817
+ cluster,
818
+ distance: Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y)
819
+ }))
820
+ .sort((left, right) => left.distance - right.distance)
821
+ .slice(0, limit)
822
+ .map(item => item.cluster.id)
823
+
824
+ const ecosystemPlanForScale = scale => {
825
+ if (scale <= ecosystemMicroScaleThreshold) {
826
+ return { baseSize: 1000, childSize: null }
827
+ }
828
+ if (scale <= ecosystemSubgraphScaleThreshold) {
829
+ return { baseSize: 1000, childSize: 250 }
830
+ }
831
+ return { baseSize: 250, childSize: 60 }
832
+ }
833
+
834
+ const selectHierarchicalEcosystemClusters = viewport => {
835
+ const plan = ecosystemPlanForScale(state.transform.scale)
836
+ const baseClusters = state.ecosystemClustersBySize.get(plan.baseSize) ?? state.ecosystemClusters
837
+ const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
838
+
839
+ if (!plan.childSize) {
840
+ return visibleBaseClusters
841
+ }
842
+
843
+ const focusPoint = ecosystemFocusPoint()
844
+ const expandedParentIds = new Set(nearestEcosystemParentIds(
845
+ visibleBaseClusters,
846
+ focusPoint,
847
+ ecosystemFocusedParentLimit
848
+ ))
849
+ const childClusters = state.ecosystemClustersBySize.get(plan.childSize) ?? []
850
+ const visibleChildClusters = childClusters.filter(cluster =>
851
+ expandedParentIds.has(cluster.parentId) &&
852
+ isClusterInViewport(cluster, viewport)
853
+ )
854
+
855
+ if (visibleChildClusters.length === 0) {
856
+ return visibleBaseClusters
857
+ }
858
+
859
+ return [
860
+ ...visibleBaseClusters.filter(cluster => !expandedParentIds.has(cluster.id)),
861
+ ...visibleChildClusters
862
+ ]
863
+ }
864
+
865
+ const ecosystemEdgesForClusters = clusters => {
866
+ const clusterByNodeId = new Map()
867
+ for (let clusterIndex = 0; clusterIndex < clusters.length; clusterIndex += 1) {
868
+ const cluster = clusters[clusterIndex]
869
+ for (let nodeIndex = 0; nodeIndex < cluster.nodeIds.length; nodeIndex += 1) {
870
+ clusterByNodeId.set(cluster.nodeIds[nodeIndex], cluster)
871
+ }
872
+ }
873
+
764
874
  const edgeByClusterPair = new Map()
765
- for (let index = 0; index < edges.length; index += 1) {
766
- const edge = edges[index]
875
+ for (let index = 0; index < state.visibleEdges.length; index += 1) {
876
+ const edge = state.visibleEdges[index]
767
877
  const sourceCluster = clusterByNodeId.get(edge.source)
768
878
  const targetCluster = clusterByNodeId.get(edge.target)
769
879
  if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
@@ -788,27 +898,9 @@ const buildEcosystemGraph = (nodes, edges) => {
788
898
  })
789
899
  }
790
900
 
791
- const aggregatedEdges = Array.from(edgeByClusterPair.values())
901
+ return Array.from(edgeByClusterPair.values())
792
902
  .sort((left, right) => right.weight - left.weight)
793
903
  .slice(0, ecosystemClusterEdgeLimit)
794
-
795
- return { clusters, edges: aggregatedEdges }
796
- }
797
-
798
- const filterEcosystemClustersByViewport = viewport =>
799
- state.ecosystemClusters.filter((cluster) =>
800
- cluster.x >= viewport.minX &&
801
- cluster.x <= viewport.maxX &&
802
- cluster.y >= viewport.minY &&
803
- cluster.y <= viewport.maxY
804
- )
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
904
  }
813
905
 
814
906
  const edgeBudgetForCurrentFrame = () => {
@@ -2407,11 +2499,8 @@ const computeRenderVisibility = () => {
2407
2499
  }
2408
2500
 
2409
2501
  if (state.transform.scale <= ecosystemClusterScaleThreshold && state.ecosystemClusters.length > 0) {
2410
- const viewportClusters = filterEcosystemClustersByViewport(viewport)
2502
+ const clusters = selectHierarchicalEcosystemClusters(viewport)
2411
2503
  .sort((left, right) => right.count - left.count)
2412
- const clusters = viewportClusters.length > 0
2413
- ? viewportClusters
2414
- : state.ecosystemClusters
2415
2504
  state.renderClusters = clusters
2416
2505
  state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
2417
2506
  state.renderNodes = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.85",
3
+ "version": "0.1.0-beta.86",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",