@andespindola/brainlink 0.1.0-beta.86 → 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,7 +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 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.
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.
@@ -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, 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
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,6 +22,7 @@ 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
25
26
  const ecosystemGroupSizes = [1000, 250, 60]
26
27
  const ecosystemClusterEdgeLimit = 520
27
28
  const ecosystemClusterScaleThreshold = 0.32
@@ -569,7 +570,7 @@ const recomputeVisibility = () => {
569
570
  state.visibleEdges = limitedEdges
570
571
  state.visibleNodeSpatial = createSpatialIndex(nodes)
571
572
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
572
- const ecosystemGraph = nodes.length > 1
573
+ const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
573
574
  ? buildEcosystemGraph(nodes)
574
575
  : { clusters: [], clustersBySize: new Map() }
575
576
  state.ecosystemClusters = ecosystemGraph.clusters
@@ -759,7 +760,9 @@ const buildEcosystemLevel = (sortedNodes, size, parentLookup) => {
759
760
  ...buildEcosystemCluster(clusterNodes, clusters.length),
760
761
  id: 'ecosystem-' + size + '-' + clusters.length,
761
762
  size,
762
- parentId: parentCluster?.id ?? null
763
+ parentId: parentCluster?.id ?? null,
764
+ parentX: parentCluster?.x ?? null,
765
+ parentY: parentCluster?.y ?? null
763
766
  }
764
767
  clusters.push(cluster)
765
768
  for (let index = 0; index < clusterNodes.length; index += 1) {
@@ -821,14 +824,42 @@ const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
821
824
  .slice(0, limit)
822
825
  .map(item => item.cluster.id)
823
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
+
824
835
  const ecosystemPlanForScale = scale => {
825
836
  if (scale <= ecosystemMicroScaleThreshold) {
826
- return { baseSize: 1000, childSize: null }
837
+ return { baseSize: 1000, childSize: null, spread: 0 }
827
838
  }
828
839
  if (scale <= ecosystemSubgraphScaleThreshold) {
829
- return { baseSize: 1000, childSize: 250 }
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
830
862
  }
831
- return { baseSize: 250, childSize: 60 }
832
863
  }
833
864
 
834
865
  const selectHierarchicalEcosystemClusters = viewport => {
@@ -850,7 +881,7 @@ const selectHierarchicalEcosystemClusters = viewport => {
850
881
  const visibleChildClusters = childClusters.filter(cluster =>
851
882
  expandedParentIds.has(cluster.parentId) &&
852
883
  isClusterInViewport(cluster, viewport)
853
- )
884
+ ).map(cluster => spreadChildClusterFromParent(cluster, plan.spread))
854
885
 
855
886
  if (visibleChildClusters.length === 0) {
856
887
  return visibleBaseClusters
@@ -2337,6 +2368,17 @@ const baseNodeRadius = node => {
2337
2368
 
2338
2369
  const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
2339
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
+
2340
2382
  const worldViewportBounds = () => {
2341
2383
  const rect = canvas.getBoundingClientRect()
2342
2384
  const width = Math.max(rect.width, 320)
@@ -2498,7 +2540,11 @@ const computeRenderVisibility = () => {
2498
2540
  return
2499
2541
  }
2500
2542
 
2501
- if (state.transform.scale <= ecosystemClusterScaleThreshold && state.ecosystemClusters.length > 0) {
2543
+ if (
2544
+ state.visibleNodes.length > ecosystemActivationNodeThreshold &&
2545
+ state.transform.scale <= ecosystemClusterScaleThreshold &&
2546
+ state.ecosystemClusters.length > 0
2547
+ ) {
2502
2548
  const clusters = selectHierarchicalEcosystemClusters(viewport)
2503
2549
  .sort((left, right) => right.count - left.count)
2504
2550
  state.renderClusters = clusters
@@ -2760,20 +2806,21 @@ const render = now => {
2760
2806
  }
2761
2807
  state.renderClusters.forEach(cluster => {
2762
2808
  const isMacro = cluster.id === 'macro-galaxy'
2763
- const radiusPx = isMacro
2764
- ? 10
2765
- : 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)
2766
2811
  const radius = radiusPx / safeScale
2767
- const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
2768
- ctx.beginPath()
2769
- ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
2770
- ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
2771
- 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
+ }
2772
2819
  ctx.beginPath()
2773
2820
  ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
2774
2821
  ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
2775
2822
  ctx.fill()
2776
- ctx.lineWidth = 1.4 / safeScale
2823
+ ctx.lineWidth = (isEcosystem ? 0.7 : 1.4) / safeScale
2777
2824
  ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
2778
2825
  ctx.stroke()
2779
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.86",
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",