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

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 only above 1000 notes: distant groups stay as small sand-like points and 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: graphs up to 1000 notes render directly; larger graphs use connected sand-like ecosystem points of up to 1000 notes, zoom-in spreads 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,13 @@ const worldCoordinateLimit = 5_000_000
22
22
  const transformCoordinateLimit = 20_000_000
23
23
  const hoverHitTestIntervalMs = 64
24
24
  const ecosystemGroupSize = 1000
25
+ const ecosystemActivationNodeThreshold = 1000
26
+ const ecosystemGroupSizes = [1000, 250, 60]
25
27
  const ecosystemClusterEdgeLimit = 520
26
- const ecosystemClusterScaleThreshold = 0.08
28
+ const ecosystemClusterScaleThreshold = 0.32
29
+ const ecosystemSubgraphScaleThreshold = 0.18
30
+ const ecosystemMicroScaleThreshold = 0.08
31
+ const ecosystemFocusedParentLimit = 3
27
32
  const zoomRecoveryGuardMs = 4200
28
33
  const zoomCapTargetViewportShare = 0.72
29
34
  const meshEdgeScaleThreshold = 0.09
@@ -68,7 +73,7 @@ const state = {
68
73
  visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
69
74
  visibleEdgeByNode: new Map(),
70
75
  ecosystemClusters: [],
71
- ecosystemEdges: [],
76
+ ecosystemClustersBySize: new Map(),
72
77
  macroCenter: { x: 0, y: 0 },
73
78
  macroRepresentative: null,
74
79
  primaryHub: null,
@@ -565,11 +570,11 @@ const recomputeVisibility = () => {
565
570
  state.visibleEdges = limitedEdges
566
571
  state.visibleNodeSpatial = createSpatialIndex(nodes)
567
572
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
568
- const ecosystemGraph = nodes.length > 1
569
- ? buildEcosystemGraph(nodes, limitedEdges)
570
- : { clusters: [], edges: [] }
573
+ const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
574
+ ? buildEcosystemGraph(nodes)
575
+ : { clusters: [], clustersBySize: new Map() }
571
576
  state.ecosystemClusters = ecosystemGraph.clusters
572
- state.ecosystemEdges = ecosystemGraph.edges
577
+ state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
573
578
  const primaryHub = rankedHubNodes()[0] ?? null
574
579
  state.primaryHub = primaryHub
575
580
  state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
@@ -738,32 +743,168 @@ const buildEcosystemCluster = (nodes, index) => {
738
743
  x: sum.x / count,
739
744
  y: sum.y / count,
740
745
  count,
746
+ nodeIds: nodes.map(node => node.id),
741
747
  representative,
742
748
  label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
743
749
  }
744
750
  }
745
751
 
746
- const buildEcosystemGraph = (nodes, edges) => {
747
- if (nodes.length === 0) {
748
- return { clusters: [], edges: [] }
749
- }
750
-
751
- const sortedNodes = [...nodes].sort(compareNodesForEcosystem)
752
+ const buildEcosystemLevel = (sortedNodes, size, parentLookup) => {
752
753
  const clusters = []
753
754
  const clusterByNodeId = new Map()
754
755
 
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)
756
+ for (let offset = 0; offset < sortedNodes.length; offset += size) {
757
+ const clusterNodes = sortedNodes.slice(offset, offset + size)
758
+ const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
759
+ const cluster = {
760
+ ...buildEcosystemCluster(clusterNodes, clusters.length),
761
+ id: 'ecosystem-' + size + '-' + clusters.length,
762
+ size,
763
+ parentId: parentCluster?.id ?? null,
764
+ parentX: parentCluster?.x ?? null,
765
+ parentY: parentCluster?.y ?? null
766
+ }
758
767
  clusters.push(cluster)
759
768
  for (let index = 0; index < clusterNodes.length; index += 1) {
760
769
  clusterByNodeId.set(clusterNodes[index].id, cluster)
761
770
  }
762
771
  }
763
772
 
773
+ return { clusters, clusterByNodeId }
774
+ }
775
+
776
+ const buildEcosystemGraph = (nodes) => {
777
+ if (nodes.length === 0) {
778
+ return { clusters: [], clustersBySize: new Map() }
779
+ }
780
+
781
+ const sortedNodes = [...nodes].sort(compareNodesForEcosystem)
782
+ const clustersBySize = new Map()
783
+ let parentLookup = null
784
+
785
+ for (let index = 0; index < ecosystemGroupSizes.length; index += 1) {
786
+ const size = ecosystemGroupSizes[index]
787
+ const level = buildEcosystemLevel(sortedNodes, size, parentLookup)
788
+ clustersBySize.set(size, level.clusters)
789
+ parentLookup = level.clusterByNodeId
790
+ }
791
+
792
+ return {
793
+ clusters: clustersBySize.get(ecosystemGroupSize) ?? [],
794
+ clustersBySize
795
+ }
796
+ }
797
+
798
+ const isClusterInViewport = (cluster, viewport) =>
799
+ cluster.x >= viewport.minX &&
800
+ cluster.x <= viewport.maxX &&
801
+ cluster.y >= viewport.minY &&
802
+ cluster.y <= viewport.maxY
803
+
804
+ const filterEcosystemClustersByViewport = (clusters, viewport) => {
805
+ const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
806
+ return visible.length > 0 ? visible : [...clusters]
807
+ }
808
+
809
+ const ecosystemFocusPoint = () => {
810
+ const now = performance.now()
811
+ if (now - state.lastZoomFocus.at <= 1800) {
812
+ return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
813
+ }
814
+ return viewportCenterWorldPoint()
815
+ }
816
+
817
+ const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
818
+ clusters
819
+ .map(cluster => ({
820
+ cluster,
821
+ distance: Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y)
822
+ }))
823
+ .sort((left, right) => left.distance - right.distance)
824
+ .slice(0, limit)
825
+ .map(item => item.cluster.id)
826
+
827
+ const smoothStep = value => {
828
+ const clamped = Math.max(0, Math.min(1, value))
829
+ return clamped * clamped * (3 - clamped * 2)
830
+ }
831
+
832
+ const zoomProgress = (scale, start, end) =>
833
+ smoothStep((scale - start) / Math.max(end - start, 0.0001))
834
+
835
+ const ecosystemPlanForScale = scale => {
836
+ if (scale <= ecosystemMicroScaleThreshold) {
837
+ return { baseSize: 1000, childSize: null, spread: 0 }
838
+ }
839
+ if (scale <= ecosystemSubgraphScaleThreshold) {
840
+ return {
841
+ baseSize: 1000,
842
+ childSize: 250,
843
+ spread: zoomProgress(scale, ecosystemMicroScaleThreshold, ecosystemSubgraphScaleThreshold)
844
+ }
845
+ }
846
+ return {
847
+ baseSize: 250,
848
+ childSize: 60,
849
+ spread: zoomProgress(scale, ecosystemSubgraphScaleThreshold, ecosystemClusterScaleThreshold)
850
+ }
851
+ }
852
+
853
+ const spreadChildClusterFromParent = (cluster, spread) => {
854
+ if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
855
+ return cluster
856
+ }
857
+
858
+ return {
859
+ ...cluster,
860
+ x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
861
+ y: cluster.parentY + (cluster.y - cluster.parentY) * spread
862
+ }
863
+ }
864
+
865
+ const selectHierarchicalEcosystemClusters = viewport => {
866
+ const plan = ecosystemPlanForScale(state.transform.scale)
867
+ const baseClusters = state.ecosystemClustersBySize.get(plan.baseSize) ?? state.ecosystemClusters
868
+ const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
869
+
870
+ if (!plan.childSize) {
871
+ return visibleBaseClusters
872
+ }
873
+
874
+ const focusPoint = ecosystemFocusPoint()
875
+ const expandedParentIds = new Set(nearestEcosystemParentIds(
876
+ visibleBaseClusters,
877
+ focusPoint,
878
+ ecosystemFocusedParentLimit
879
+ ))
880
+ const childClusters = state.ecosystemClustersBySize.get(plan.childSize) ?? []
881
+ const visibleChildClusters = childClusters.filter(cluster =>
882
+ expandedParentIds.has(cluster.parentId) &&
883
+ isClusterInViewport(cluster, viewport)
884
+ ).map(cluster => spreadChildClusterFromParent(cluster, plan.spread))
885
+
886
+ if (visibleChildClusters.length === 0) {
887
+ return visibleBaseClusters
888
+ }
889
+
890
+ return [
891
+ ...visibleBaseClusters.filter(cluster => !expandedParentIds.has(cluster.id)),
892
+ ...visibleChildClusters
893
+ ]
894
+ }
895
+
896
+ const ecosystemEdgesForClusters = clusters => {
897
+ const clusterByNodeId = new Map()
898
+ for (let clusterIndex = 0; clusterIndex < clusters.length; clusterIndex += 1) {
899
+ const cluster = clusters[clusterIndex]
900
+ for (let nodeIndex = 0; nodeIndex < cluster.nodeIds.length; nodeIndex += 1) {
901
+ clusterByNodeId.set(cluster.nodeIds[nodeIndex], cluster)
902
+ }
903
+ }
904
+
764
905
  const edgeByClusterPair = new Map()
765
- for (let index = 0; index < edges.length; index += 1) {
766
- const edge = edges[index]
906
+ for (let index = 0; index < state.visibleEdges.length; index += 1) {
907
+ const edge = state.visibleEdges[index]
767
908
  const sourceCluster = clusterByNodeId.get(edge.source)
768
909
  const targetCluster = clusterByNodeId.get(edge.target)
769
910
  if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
@@ -788,27 +929,9 @@ const buildEcosystemGraph = (nodes, edges) => {
788
929
  })
789
930
  }
790
931
 
791
- const aggregatedEdges = Array.from(edgeByClusterPair.values())
932
+ return Array.from(edgeByClusterPair.values())
792
933
  .sort((left, right) => right.weight - left.weight)
793
934
  .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
935
  }
813
936
 
814
937
  const edgeBudgetForCurrentFrame = () => {
@@ -2245,6 +2368,17 @@ const baseNodeRadius = node => {
2245
2368
 
2246
2369
  const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
2247
2370
 
2371
+ const clusterRadiusPx = cluster => {
2372
+ if (cluster.id === 'macro-galaxy') {
2373
+ return 10
2374
+ }
2375
+ if (String(cluster.id).startsWith('ecosystem-')) {
2376
+ const base = cluster.size >= 1000 ? 2.4 : cluster.size >= 250 ? 2.1 : 1.8
2377
+ return Math.max(1.8, Math.min(4.2, base + Math.log10(cluster.count + 1) * 0.28))
2378
+ }
2379
+ return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2380
+ }
2381
+
2248
2382
  const worldViewportBounds = () => {
2249
2383
  const rect = canvas.getBoundingClientRect()
2250
2384
  const width = Math.max(rect.width, 320)
@@ -2406,12 +2540,13 @@ const computeRenderVisibility = () => {
2406
2540
  return
2407
2541
  }
2408
2542
 
2409
- if (state.transform.scale <= ecosystemClusterScaleThreshold && state.ecosystemClusters.length > 0) {
2410
- const viewportClusters = filterEcosystemClustersByViewport(viewport)
2543
+ if (
2544
+ state.visibleNodes.length > ecosystemActivationNodeThreshold &&
2545
+ state.transform.scale <= ecosystemClusterScaleThreshold &&
2546
+ state.ecosystemClusters.length > 0
2547
+ ) {
2548
+ const clusters = selectHierarchicalEcosystemClusters(viewport)
2411
2549
  .sort((left, right) => right.count - left.count)
2412
- const clusters = viewportClusters.length > 0
2413
- ? viewportClusters
2414
- : state.ecosystemClusters
2415
2550
  state.renderClusters = clusters
2416
2551
  state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
2417
2552
  state.renderNodes = []
@@ -2671,20 +2806,21 @@ const render = now => {
2671
2806
  }
2672
2807
  state.renderClusters.forEach(cluster => {
2673
2808
  const isMacro = cluster.id === 'macro-galaxy'
2674
- const radiusPx = isMacro
2675
- ? 10
2676
- : Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2809
+ const isEcosystem = String(cluster.id).startsWith('ecosystem-')
2810
+ const radiusPx = clusterRadiusPx(cluster)
2677
2811
  const radius = radiusPx / safeScale
2678
- const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
2679
- ctx.beginPath()
2680
- ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
2681
- ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
2682
- ctx.fill()
2812
+ const haloRadius = (radiusPx + (isMacro ? 8 : isEcosystem ? 1.1 : 4)) / safeScale
2813
+ if (!isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
2814
+ ctx.beginPath()
2815
+ ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
2816
+ ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
2817
+ ctx.fill()
2818
+ }
2683
2819
  ctx.beginPath()
2684
2820
  ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
2685
2821
  ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
2686
2822
  ctx.fill()
2687
- ctx.lineWidth = 1.4 / safeScale
2823
+ ctx.lineWidth = (isEcosystem ? 0.7 : 1.4) / safeScale
2688
2824
  ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
2689
2825
  ctx.stroke()
2690
2826
  if (isMacro && cluster.representative?.title) {
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.87",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",