@andespindola/brainlink 0.1.0-beta.115 → 0.1.0-beta.117

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 keeps the same Bloom-like scene model for every graph size: nodes remain part of one flat exploration scene while rendering budgets decide how much visible detail is drawn.
87
+ - Graph rendering uses a rebuilt Bloom-like flat scene: no macro galaxy, no recursive subgraphs, no cluster replacement layer and no synthetic 3D projection.
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 fitted scene and zoom-in progressively reveals local detail.
91
- - Zoomed-out graph LOD samples visible nodes and priority links without switching to nested subgraphs.
91
+ - Zoomed-out graph LOD samples visible nodes and priority links without switching to nested subgraphs or cluster markers.
92
92
  - Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
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.
@@ -604,7 +604,7 @@ The graph UI shows:
604
604
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
605
605
  - 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
606
606
  - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
607
- - large graph LOD keeps the same scene model as smaller graphs: visible nodes are sampled near the viewport/focus, real links are prioritized, and no recursive graph-of-graphs or synthetic 3D projection is applied
607
+ - large graph LOD keeps the same scene model as smaller graphs: visible nodes are sampled near the viewport/focus, real links are prioritized, and no macro galaxy, recursive graph-of-graphs, cluster replacement layer or synthetic 3D projection is applied
608
608
 
609
609
  The server indexes before starting by default. Use `--no-index` to skip that step:
610
610
 
@@ -6,30 +6,18 @@ const massiveGraphNodeThreshold = 20000
6
6
  const largeGraphEdgeRenderLimit = 120000
7
7
  const renderNodeBudget = 900
8
8
  const zoomedMassiveRenderNodeBudget = 2200
9
- const renderEdgeBudget = 2400
10
- const clusterActivationNodeThreshold = 600
11
- const clusterZoomThreshold = 0.18
12
- const macroGalaxyZoomThreshold = 0.012
13
- const macroGalaxyEnterHysteresis = 0.86
14
- const macroGalaxyExitHysteresis = 1.24
15
- const galaxyDiscoveryEnabled = false
9
+ const massiveOverviewRenderNodeBudget = 1800
10
+ const massiveOverviewScaleThreshold = 0.065
16
11
  const massiveAutoFitMacroScale = 0.018
17
- const defaultMacroScale = 0.018
18
- const clusterCellPixelSize = 64
19
12
  const minNodePixelRadius = 2.3
20
13
  const viewportPaddingPx = 280
21
14
  const worldCoordinateLimit = 5_000_000
22
15
  const transformCoordinateLimit = 20_000_000
23
16
  const hoverHitTestIntervalMs = 64
24
- const ecosystemLevelNodeCap = 999
25
- const ecosystemActivationNodeThreshold = 1000
26
- const ecosystemSubgraphScaleThreshold = 0.18
27
17
  const zoomRecoveryGuardMs = 4200
28
- const zoomCapTargetViewportShare = 0.72
29
18
  const meshEdgeScaleThreshold = 0.09
30
19
  const meshEdgeMinBudget = 140
31
20
  const meshEdgeMaxBudget = 1400
32
- const layeredCoreScaleThreshold = 0.55
33
21
  const dragNeighborhoodMaxAffected = 180
34
22
  const dragSettleRounds = 3
35
23
  const wheelZoomExponent = 0.0009
@@ -54,9 +42,6 @@ const state = {
54
42
  visibleEdges: [],
55
43
  renderNodes: [],
56
44
  renderEdges: [],
57
- renderClusters: [],
58
- renderClusterEdges: [],
59
- renderNodeDepthProjectionById: new Map(),
60
45
  nodeDegrees: new Map(),
61
46
  selected: null,
62
47
  hovered: null,
@@ -81,27 +66,12 @@ const state = {
81
66
  lastViewportKey: '',
82
67
  visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
83
68
  visibleEdgeByNode: new Map(),
84
- ecosystemClusters: [],
85
- ecosystemClustersBySize: new Map(),
86
- ecosystemNodeClusterBySize: new Map(),
87
- ecosystemLevelSizes: [],
88
- ecosystemLevelIndexBySize: new Map(),
89
- ecosystemHubNodeIds: new Set(),
90
- ecosystemExpansionLevels: [],
91
- ecosystemBaseSize: ecosystemLevelNodeCap,
92
- ecosystemHubCluster: null,
93
- macroCenter: { x: 0, y: 0 },
94
- macroRepresentative: null,
95
69
  primaryHub: null,
96
- hubNeighborDistance: Number.POSITIVE_INFINITY,
97
70
  filterWorker: null,
98
71
  filterReady: false,
99
72
  lastHoverHitAt: 0,
100
73
  lastManualZoomAt: 0,
101
74
  lastZoomFocus: { x: 0, y: 0, at: 0 },
102
- macroViewActive: false,
103
- ecosystemViewActive: false,
104
- depthProjectionActive: false,
105
75
  zoomTransition: {
106
76
  active: false,
107
77
  source: 'generic',
@@ -532,47 +502,6 @@ const filteredNodes = () => {
532
502
  return withPersistentHubNodes(localFilteredNodes(query))
533
503
  }
534
504
 
535
- const resolveMacroRepresentative = (nodes) => {
536
- if (nodes.length === 0) {
537
- return null
538
- }
539
-
540
- const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
541
- ? state.primaryHub
542
- : null
543
- let best = hubCandidate ?? nodes[0]
544
- let bestDegree = state.nodeDegrees.get(best.id) ?? 0
545
-
546
- for (let index = 1; index < nodes.length; index += 1) {
547
- const node = nodes[index]
548
- const degree = state.nodeDegrees.get(node.id) ?? 0
549
- if (degree > bestDegree) {
550
- best = node
551
- bestDegree = degree
552
- }
553
- }
554
-
555
- return best
556
- }
557
-
558
- const nearestHubNeighborDistance = (hub, nodes) => {
559
- if (!hub || nodes.length <= 1) {
560
- return Number.POSITIVE_INFINITY
561
- }
562
-
563
- let minimum = Number.POSITIVE_INFINITY
564
- for (let index = 0; index < nodes.length; index += 1) {
565
- const node = nodes[index]
566
- if (node.id === hub.id) continue
567
- const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
568
- if (distance < minimum) {
569
- minimum = distance
570
- }
571
- }
572
-
573
- return minimum
574
- }
575
-
576
505
  const isDominantHub = (hub, nodeCount = state.visibleNodes.length) => {
577
506
  if (!hub || nodeCount <= 0) {
578
507
  return false
@@ -600,25 +529,6 @@ const recomputeVisibility = () => {
600
529
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
601
530
  const primaryHub = rankedHubNodes()[0] ?? null
602
531
  state.primaryHub = primaryHub
603
- state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
604
- const bounds = graphBounds(nodes)
605
- const macroHub = isDominantHub(primaryHub, nodes.length) ? primaryHub : null
606
- state.macroCenter = bounds
607
- ? {
608
- x: macroHub ? macroHub.x : (bounds.minX + bounds.maxX) / 2,
609
- y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
610
- }
611
- : { x: 0, y: 0 }
612
- state.ecosystemClusters = []
613
- state.ecosystemClustersBySize = new Map()
614
- state.ecosystemNodeClusterBySize = new Map()
615
- state.ecosystemLevelSizes = []
616
- state.ecosystemLevelIndexBySize = new Map()
617
- state.ecosystemHubNodeIds = new Set()
618
- state.ecosystemExpansionLevels = []
619
- state.ecosystemBaseSize = ecosystemLevelNodeCap
620
- state.ecosystemHubCluster = null
621
- state.macroRepresentative = resolveMacroRepresentative(nodes)
622
532
  markRenderDirty()
623
533
  }
624
534
 
@@ -723,24 +633,6 @@ const createVisibleEdgeLookup = edges => {
723
633
  return lookup
724
634
  }
725
635
 
726
- const isClusterInViewport = (cluster, viewport) =>
727
- cluster.x >= viewport.minX &&
728
- cluster.x <= viewport.maxX &&
729
- cluster.y >= viewport.minY &&
730
- cluster.y <= viewport.maxY
731
-
732
- const ecosystemFocusPoint = () => {
733
- const cursorPoint = cursorWorldPoint()
734
- if (cursorPoint) {
735
- return cursorPoint
736
- }
737
- const now = performance.now()
738
- if (now - state.lastZoomFocus.at <= 1800) {
739
- return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
740
- }
741
- return viewportCenterWorldPoint()
742
- }
743
-
744
636
  const edgeBudgetForCurrentFrame = () => {
745
637
  const zoom = state.transform.scale
746
638
  if (zoom < 0.12) return 380
@@ -752,112 +644,24 @@ const edgeBudgetForCurrentFrame = () => {
752
644
  return 7600
753
645
  }
754
646
 
755
- const clusterBudgetForScale = (scale) => {
756
- if (scale < 0.008) return 90
757
- if (scale < 0.014) return 150
758
- if (scale < 0.022) return 240
759
- if (scale < 0.035) return 360
760
- return 520
761
- }
762
-
763
647
  const nodeBudgetForScale = (scale) => {
764
- if (scale < 0.035) return 220
765
- if (scale < 0.06) return 360
766
- if (scale < 0.09) return 520
767
- if (scale < 0.14) return 720
768
648
  if (state.visibleNodes.length > massiveGraphNodeThreshold) {
649
+ if (scale < massiveOverviewScaleThreshold) return massiveOverviewRenderNodeBudget
650
+ if (scale < 0.09) return 1600
651
+ if (scale < 0.14) return 1800
769
652
  if (scale < 0.28) return renderNodeBudget
770
653
  if (scale < 0.45) return 1100
771
654
  if (scale < 0.7) return 1400
772
655
  if (scale < 1.05) return 1800
773
656
  return zoomedMassiveRenderNodeBudget
774
657
  }
658
+ if (scale < 0.035) return 220
659
+ if (scale < 0.06) return 360
660
+ if (scale < 0.09) return 520
661
+ if (scale < 0.14) return 720
775
662
  return renderNodeBudget
776
663
  }
777
664
 
778
- const layerFocusForScale = (scale) => {
779
- const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
780
- const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
781
- const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
782
- const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
783
- const coreRatio = Math.max(0.2, Math.min(0.72, 0.24 + normalized * 0.48))
784
-
785
- return { shellCenter, shellWidth, coreRadius, coreRatio }
786
- }
787
-
788
- const selectLayeredNodesForScale = (sourceNodes, targetCount) => {
789
- const hub = state.primaryHub
790
- if (!hub || sourceNodes.length <= 1200 || state.visibleNodes.length <= massiveGraphNodeThreshold) {
791
- return sourceNodes
792
- }
793
-
794
- let maxDistance = 0
795
- const distances = sourceNodes.map((node) => {
796
- const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
797
- if (distance > maxDistance) {
798
- maxDistance = distance
799
- }
800
- return { node, distance }
801
- })
802
-
803
- if (maxDistance <= 0.001) {
804
- return sourceNodes
805
- }
806
-
807
- const focus = layerFocusForScale(state.transform.scale)
808
- const normalizedRows = distances.map((item) => ({
809
- ...item,
810
- normalized: item.distance / maxDistance
811
- }))
812
- const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
813
- const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
814
- const shellTarget = Math.max(12, desired - coreTarget)
815
- const shellHalf = focus.shellWidth / 2
816
-
817
- const coreNodes = normalizedRows
818
- .filter((item) => item.normalized <= focus.coreRadius)
819
- .sort((left, right) => {
820
- const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
821
- const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
822
- if (leftScore !== rightScore) return rightScore - leftScore
823
- return left.node.id.localeCompare(right.node.id)
824
- })
825
- .slice(0, coreTarget)
826
- .map((item) => item.node)
827
-
828
- const shellNodes = normalizedRows
829
- .sort((left, right) => {
830
- const leftDelta = Math.abs(left.normalized - focus.shellCenter)
831
- const rightDelta = Math.abs(right.normalized - focus.shellCenter)
832
- const leftInside = leftDelta <= shellHalf ? 0 : 1
833
- const rightInside = rightDelta <= shellHalf ? 0 : 1
834
- if (leftInside !== rightInside) return leftInside - rightInside
835
- if (leftDelta !== rightDelta) return leftDelta - rightDelta
836
- const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
837
- const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
838
- if (leftScore !== rightScore) return rightScore - leftScore
839
- return left.node.id.localeCompare(right.node.id)
840
- })
841
- .slice(0, shellTarget)
842
- .map((item) => item.node)
843
-
844
- const merged = []
845
- const ids = new Set()
846
- const pushUnique = (node) => {
847
- if (!node || ids.has(node.id)) return
848
- ids.add(node.id)
849
- merged.push(node)
850
- }
851
-
852
- if (state.transform.scale >= layeredCoreScaleThreshold) {
853
- pushUnique(hub)
854
- }
855
- for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
856
- for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
857
-
858
- return merged.length > 0 ? merged : sourceNodes
859
- }
860
-
861
665
  const viewportCenterWorldPoint = () => {
862
666
  const viewport = worldViewportBounds()
863
667
  return {
@@ -896,30 +700,6 @@ const visibilityScaleBucket = (scale) => {
896
700
  return Math.round(safeScale * 180_000)
897
701
  }
898
702
 
899
- const shouldRenderEcosystemClusterView = (nodeCount, scale) => {
900
- state.ecosystemViewActive = false
901
- return false
902
- }
903
-
904
- const shouldRenderMacroGalaxyView = () => {
905
- if (!galaxyDiscoveryEnabled) {
906
- state.macroViewActive = false
907
- return false
908
- }
909
- if (state.visibleNodes.length <= 1) {
910
- state.macroViewActive = false
911
- return false
912
- }
913
-
914
- const enterThreshold = macroGalaxyZoomThreshold * macroGalaxyEnterHysteresis
915
- const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
916
- const shouldRender = state.macroViewActive
917
- ? state.transform.scale <= exitThreshold
918
- : state.transform.scale <= enterThreshold
919
- state.macroViewActive = shouldRender
920
- return shouldRender
921
- }
922
-
923
703
  const mergeUniqueNodes = (leftNodes, rightNodes, limit) => {
924
704
  const merged = []
925
705
  const ids = new Set()
@@ -981,31 +761,6 @@ const selectStableSampleNodes = (sourceNodes, limit) => {
981
761
  .slice(0, limit)
982
762
  }
983
763
 
984
- const selectAccessBridgeNodes = (sourceNodes, limit) => {
985
- if (limit <= 0 || sourceNodes.length === 0) {
986
- return []
987
- }
988
-
989
- const now = performance.now()
990
- const cursorPoint = cursorWorldPoint()
991
- const recentZoomFocus =
992
- now - state.lastZoomFocus.at <= 1200
993
- ? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
994
- : null
995
- const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
996
- return [...sourceNodes]
997
- .sort((left, right) => {
998
- const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
999
- const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
1000
- if (leftDistance !== rightDistance) return leftDistance - rightDistance
1001
- const leftDegree = state.nodeDegrees.get(left.id) ?? 0
1002
- const rightDegree = state.nodeDegrees.get(right.id) ?? 0
1003
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
1004
- return left.id.localeCompare(right.id)
1005
- })
1006
- .slice(0, limit)
1007
- }
1008
-
1009
764
  const edgeIdentityKey = edge => {
1010
765
  if (!edge.target) return ''
1011
766
  const pair = edge.source < edge.target
@@ -1126,8 +881,8 @@ const edgeWidthFor = (edge, selectedEdge) => {
1126
881
  const drawGraphEdge = (edge) => {
1127
882
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1128
883
  ctx.beginPath()
1129
- ctx.moveTo(nodeRenderX(edge.sourceNode), nodeRenderY(edge.sourceNode))
1130
- ctx.lineTo(nodeRenderX(edge.targetNode), nodeRenderY(edge.targetNode))
884
+ ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
885
+ ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1131
886
  ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
1132
887
  ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
1133
888
  ctx.stroke()
@@ -1141,8 +896,8 @@ const drawEdgeBatch = (edges, options) => {
1141
896
  ctx.beginPath()
1142
897
  for (let index = 0; index < edges.length; index += 1) {
1143
898
  const edge = edges[index]
1144
- ctx.moveTo(nodeRenderX(edge.sourceNode), nodeRenderY(edge.sourceNode))
1145
- ctx.lineTo(nodeRenderX(edge.targetNode), nodeRenderY(edge.targetNode))
899
+ ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
900
+ ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
1146
901
  }
1147
902
  ctx.strokeStyle = options.strokeStyle
1148
903
  ctx.lineWidth = options.lineWidth
@@ -1197,13 +952,11 @@ const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
1197
952
  (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
1198
953
 
1199
954
  const drawSingleNode = (node, options = { drawLabel: true }) => {
1200
- const radius = nodeRadius(node) * nodeRenderScale(node)
1201
- const x = nodeRenderX(node)
1202
- const y = nodeRenderY(node)
1203
- const opacity = nodeRenderOpacity(node)
955
+ const radius = nodeRadius(node)
956
+ const x = node.x
957
+ const y = node.y
1204
958
  const isSelected = state.selected?.id === node.id
1205
959
  const isHovered = state.hovered?.id === node.id
1206
- ctx.globalAlpha = opacity
1207
960
  ctx.beginPath()
1208
961
  ctx.arc(x, y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
1209
962
  ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
@@ -1217,7 +970,7 @@ const drawSingleNode = (node, options = { drawLabel: true }) => {
1217
970
  ctx.stroke()
1218
971
 
1219
972
  if (options.drawLabel && shouldDrawNodeLabels(node, isSelected, isHovered)) {
1220
- ctx.globalAlpha = Math.max(0.58, opacity)
973
+ ctx.globalAlpha = 1
1221
974
  ctx.fillStyle = graphTheme.label
1222
975
  ctx.font = '12px Inter, system-ui, sans-serif'
1223
976
  ctx.textAlign = 'center'
@@ -1236,10 +989,10 @@ const drawNodeBatch = (nodes) => {
1236
989
  if (drawHalos) {
1237
990
  for (let index = 0; index < nodes.length; index += 1) {
1238
991
  const node = nodes[index]
1239
- const radius = nodeRadius(node) * nodeRenderScale(node)
1240
- const x = nodeRenderX(node)
1241
- const y = nodeRenderY(node)
1242
- ctx.globalAlpha = Math.max(0.2, nodeRenderOpacity(node) * 0.5)
992
+ const radius = nodeRadius(node)
993
+ const x = node.x
994
+ const y = node.y
995
+ ctx.globalAlpha = 0.5
1243
996
  ctx.beginPath()
1244
997
  ctx.arc(x, y, radius + 3, 0, Math.PI * 2)
1245
998
  ctx.fillStyle = graphTheme.nodeHalo
@@ -1250,11 +1003,10 @@ const drawNodeBatch = (nodes) => {
1250
1003
 
1251
1004
  for (let index = 0; index < nodes.length; index += 1) {
1252
1005
  const node = nodes[index]
1253
- const radius = nodeRadius(node) * nodeRenderScale(node)
1254
- const x = nodeRenderX(node)
1255
- const y = nodeRenderY(node)
1256
- const opacity = nodeRenderOpacity(node)
1257
- ctx.globalAlpha = opacity
1006
+ const radius = nodeRadius(node)
1007
+ const x = node.x
1008
+ const y = node.y
1009
+ ctx.globalAlpha = 1
1258
1010
  ctx.beginPath()
1259
1011
  ctx.arc(x, y, radius, 0, Math.PI * 2)
1260
1012
  ctx.fillStyle = graphTheme.node
@@ -1291,10 +1043,10 @@ const drawGraphNodes = () => {
1291
1043
  ctx.textBaseline = 'top'
1292
1044
  for (let index = 0; index < regularNodes.length; index += 1) {
1293
1045
  const node = regularNodes[index]
1294
- const x = nodeRenderX(node)
1295
- const y = nodeRenderY(node)
1296
- const radius = nodeRadius(node) * nodeRenderScale(node)
1297
- ctx.globalAlpha = Math.max(0.6, nodeRenderOpacity(node))
1046
+ const x = node.x
1047
+ const y = node.y
1048
+ const radius = nodeRadius(node)
1049
+ ctx.globalAlpha = 1
1298
1050
  ctx.fillText(node.title.slice(0, 34), x, y + radius + 8)
1299
1051
  }
1300
1052
  ctx.globalAlpha = 1
@@ -1348,17 +1100,17 @@ const drawGraphLabels = nodes => {
1348
1100
  ctx.textBaseline = 'top'
1349
1101
  for (let index = 0; index < nodes.length; index += 1) {
1350
1102
  const node = nodes[index]
1351
- const x = nodeRenderX(node)
1352
- const y = nodeRenderY(node)
1353
- const radius = nodeRadius(node) * nodeRenderScale(node)
1354
- ctx.globalAlpha = Math.max(0.6, nodeRenderOpacity(node))
1103
+ const x = node.x
1104
+ const y = node.y
1105
+ const radius = nodeRadius(node)
1106
+ ctx.globalAlpha = 1
1355
1107
  ctx.fillText(node.title.slice(0, 34), x, y + radius + 8)
1356
1108
  }
1357
1109
  ctx.globalAlpha = 1
1358
1110
  }
1359
1111
 
1360
1112
  const drawAcceleratedGraph = (width, height, drawEdges) => {
1361
- if (!webGlRenderer || state.renderClusters.length > 0) {
1113
+ if (!webGlRenderer) {
1362
1114
  return false
1363
1115
  }
1364
1116
 
@@ -1559,6 +1311,11 @@ const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibl
1559
1311
  return nodes
1560
1312
  }
1561
1313
 
1314
+ const sampleMassiveOverviewNodes = (limit) => {
1315
+ const sampled = sampleVisibleNodes(limit, state.visibleNodes)
1316
+ return ensureHubNodesInRenderedSet(sampled)
1317
+ }
1318
+
1562
1319
  const enrichSampleWithNeighbors = (nodes) => {
1563
1320
  if (nodes.length === 0) {
1564
1321
  return {
@@ -1728,18 +1485,6 @@ const zoomCapByNodeCount = (nodeCount) => {
1728
1485
  return zoomRange.max
1729
1486
  }
1730
1487
 
1731
- const zoomCapByHubDistance = (distance) => {
1732
- if (!Number.isFinite(distance) || distance <= 0) {
1733
- return zoomRange.max
1734
- }
1735
-
1736
- const rect = canvas.getBoundingClientRect()
1737
- const viewportWidth = Math.max(rect.width, 320)
1738
- const viewportHeight = Math.max(rect.height, 320)
1739
- const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
1740
- return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
1741
- }
1742
-
1743
1488
  const currentZoomMax = () => {
1744
1489
  const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
1745
1490
  return Math.max(zoomRange.min * 2, zoomCapByNodeCount(nodeCount))
@@ -1748,7 +1493,7 @@ const currentZoomMax = () => {
1748
1493
  const zoomFloorByNodeCount = (nodeCount) => {
1749
1494
  if (nodeCount > massiveGraphNodeThreshold) return 0.018
1750
1495
  if (nodeCount > largeGraphNodeThreshold) return 0.0032
1751
- if (nodeCount > ecosystemActivationNodeThreshold) return 0.001
1496
+ if (nodeCount > 1000) return 0.001
1752
1497
  return zoomRange.min
1753
1498
  }
1754
1499
 
@@ -1860,57 +1605,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
1860
1605
  return { min: 0.0085, max: 0.36 }
1861
1606
  }
1862
1607
 
1863
- const macroFaceToFaceScale = (nodeCount, hubDistance) => {
1864
- if (!Number.isFinite(hubDistance) || hubDistance <= 0 || nodeCount <= ecosystemActivationNodeThreshold) {
1865
- return 0
1866
- }
1867
-
1868
- const rect = canvas.getBoundingClientRect()
1869
- const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
1870
- const share = nodeCount > massiveGraphNodeThreshold ? 0.2 : 0.17
1871
- const targetPx = Math.max(24, viewportReference * share)
1872
- return targetPx / hubDistance
1873
- }
1874
-
1875
- const nearestClusterNeighborDistance = (clusters) => {
1876
- if (!Array.isArray(clusters) || clusters.length < 2) {
1877
- return Number.POSITIVE_INFINITY
1878
- }
1879
-
1880
- let nearestDistance = Number.POSITIVE_INFINITY
1881
- for (let index = 0; index < clusters.length; index += 1) {
1882
- const source = clusters[index]
1883
- for (let neighborIndex = index + 1; neighborIndex < clusters.length; neighborIndex += 1) {
1884
- const target = clusters[neighborIndex]
1885
- const distance = Math.hypot(source.x - target.x, source.y - target.y)
1886
- if (distance > 0 && distance < nearestDistance) {
1887
- nearestDistance = distance
1888
- }
1889
- }
1890
- }
1891
-
1892
- return nearestDistance
1893
- }
1894
-
1895
- const macroEcosystemFaceScale = (nodeCount) => {
1896
- if (nodeCount <= ecosystemActivationNodeThreshold) {
1897
- return 0
1898
- }
1899
-
1900
- const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
1901
- const siblingClusters = baseClusters.filter(cluster => !cluster.isHub)
1902
- const nearestDistance = nearestClusterNeighborDistance(siblingClusters)
1903
- if (!Number.isFinite(nearestDistance) || nearestDistance <= 0) {
1904
- return 0
1905
- }
1906
-
1907
- const rect = canvas.getBoundingClientRect()
1908
- const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
1909
- const targetShare = nodeCount > massiveGraphNodeThreshold ? 0.28 : 0.24
1910
- const targetPx = Math.max(30, viewportReference * targetShare)
1911
- return targetPx / nearestDistance
1912
- }
1913
- const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
1608
+ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
1914
1609
  const rect = canvas.getBoundingClientRect()
1915
1610
  const width = Math.max(rect.width, 320)
1916
1611
  const height = Math.max(rect.height, 320)
@@ -1942,21 +1637,9 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
1942
1637
  const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
1943
1638
  const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
1944
1639
  const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
1945
- const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
1946
- const scale = options.macro && nodes.length > 1
1947
- ? clampScale(Math.min(baselineScale, macroScale))
1948
- : nodes.length > massiveGraphNodeThreshold
1949
- ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
1950
- : baselineScale
1951
- const macroFloorScale = options.macro
1952
- ? clampScale(Math.max(
1953
- macroFaceToFaceScale(nodes.length, state.hubNeighborDistance),
1954
- macroEcosystemFaceScale(nodes.length)
1955
- ))
1956
- : 0
1957
- const resolvedScale = options.macro
1958
- ? clampScale(Math.max(scale, macroFloorScale))
1959
- : scale
1640
+ const resolvedScale = nodes.length > massiveGraphNodeThreshold
1641
+ ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
1642
+ : baselineScale
1960
1643
  const hubCenter =
1961
1644
  options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
1962
1645
  ? state.primaryHub
@@ -1975,12 +1658,12 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
1975
1658
  markRenderDirty()
1976
1659
  }
1977
1660
 
1978
- const resetView = () => fitView({ useFiltered: false, macro: false, preferHubCenter: false })
1661
+ const resetView = () => fitView({ useFiltered: false, preferHubCenter: false })
1979
1662
 
1980
1663
  const focusPrimaryHub = () => {
1981
1664
  const hub = state.primaryHub
1982
1665
  if (!hub) {
1983
- fitView({ useFiltered: true, macro: false, preferHubCenter: true })
1666
+ fitView({ useFiltered: true, preferHubCenter: true })
1984
1667
  return
1985
1668
  }
1986
1669
 
@@ -2347,9 +2030,6 @@ const settleNeighborhoodAroundNode = (dragNode) => {
2347
2030
 
2348
2031
  const hitNode = point => {
2349
2032
  computeRenderVisibility()
2350
- if (state.renderClusters.length > 0) {
2351
- return null
2352
- }
2353
2033
  const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
2354
2034
  ? 0.2
2355
2035
  : state.nodes.length > largeGraphNodeThreshold
@@ -2362,9 +2042,9 @@ const hitNode = point => {
2362
2042
  const nodes = state.renderNodes
2363
2043
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
2364
2044
  const node = nodes[index]
2365
- const radius = nodeRadius(node) * nodeRenderScale(node)
2366
- const x = nodeRenderX(node)
2367
- const y = nodeRenderY(node)
2045
+ const radius = nodeRadius(node)
2046
+ const x = node.x
2047
+ const y = node.y
2368
2048
  if (Math.hypot(point.x - x, point.y - y) <= radius + 5) return node
2369
2049
  }
2370
2050
  return null
@@ -2377,36 +2057,6 @@ const baseNodeRadius = node => {
2377
2057
 
2378
2058
  const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
2379
2059
 
2380
- const clusterRadiusPx = cluster => {
2381
- if (cluster.id === 'macro-galaxy') {
2382
- return 10
2383
- }
2384
- if (cluster.isHub) {
2385
- return 3.8
2386
- }
2387
- if (String(cluster.id).startsWith('ecosystem-')) {
2388
- const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
2389
- const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
2390
- const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
2391
- const radius = Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
2392
- const depthScale = Number.isFinite(cluster.depthScale) ? cluster.depthScale : 1
2393
- return Math.max(0.56, Math.min(3.2, radius * depthScale))
2394
- }
2395
- return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
2396
- }
2397
-
2398
- const clusterOpacity = cluster =>
2399
- Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
2400
-
2401
- const refreshRenderNodeDepthProjection = () => {
2402
- state.renderNodeDepthProjectionById = new Map()
2403
- }
2404
-
2405
- const projectedNode = node => state.renderNodeDepthProjectionById.get(node.id) ?? null
2406
- const nodeRenderX = node => projectedNode(node)?.x ?? node.x
2407
- const nodeRenderY = node => projectedNode(node)?.y ?? node.y
2408
- const nodeRenderScale = node => projectedNode(node)?.scale ?? 1
2409
- const nodeRenderOpacity = node => projectedNode(node)?.opacity ?? 1
2410
2060
  const worldViewportBounds = () => {
2411
2061
  const width = Math.max(state.viewport.width, 320)
2412
2062
  const height = Math.max(state.viewport.height, 320)
@@ -2460,68 +2110,6 @@ const viewportNodeStride = () => {
2460
2110
  return 8
2461
2111
  }
2462
2112
 
2463
- const shouldRenderClusters = viewportNodes =>
2464
- state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
2465
-
2466
- const clusterViewportNodes = viewportNodes => {
2467
- if (!shouldRenderClusters(viewportNodes)) {
2468
- return []
2469
- }
2470
-
2471
- const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
2472
- const buckets = new Map()
2473
-
2474
- for (let index = 0; index < viewportNodes.length; index += 1) {
2475
- const node = viewportNodes[index]
2476
- const keyX = Math.floor(node.x / worldCellSize)
2477
- const keyY = Math.floor(node.y / worldCellSize)
2478
- const key = keyX + ':' + keyY
2479
- const current = buckets.get(key)
2480
- if (current) {
2481
- current.count += 1
2482
- current.sumX += node.x
2483
- current.sumY += node.y
2484
- if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
2485
- current.representative = node
2486
- current.degree = state.nodeDegrees.get(node.id) ?? 0
2487
- }
2488
- continue
2489
- }
2490
-
2491
- buckets.set(key, {
2492
- id: key,
2493
- count: 1,
2494
- sumX: node.x,
2495
- sumY: node.y,
2496
- representative: node,
2497
- degree: state.nodeDegrees.get(node.id) ?? 0
2498
- })
2499
- }
2500
-
2501
- return Array.from(buckets.values())
2502
- .sort((left, right) => right.count - left.count)
2503
- .slice(0, Math.min(renderNodeBudget, 900))
2504
- .map((cluster) => ({
2505
- id: cluster.id,
2506
- x: cluster.sumX / Math.max(cluster.count, 1),
2507
- y: cluster.sumY / Math.max(cluster.count, 1),
2508
- count: cluster.count,
2509
- representative: cluster.representative
2510
- }))
2511
- }
2512
-
2513
- const representativeNodesFromClusters = (clusters, limit) => {
2514
- const representatives = clusters
2515
- .map((cluster) => cluster.representative)
2516
- .filter((node) => Boolean(node))
2517
- const merged = mergeUniqueNodes(
2518
- representatives,
2519
- state.renderNodes ?? [],
2520
- Math.max(1, limit)
2521
- )
2522
- return ensureHubNodesInRenderedSet(merged)
2523
- }
2524
-
2525
2113
  const computeRenderVisibility = () => {
2526
2114
  if (!hasValidTransform()) {
2527
2115
  fitView({ useFiltered: true })
@@ -2539,49 +2127,26 @@ const computeRenderVisibility = () => {
2539
2127
  }
2540
2128
  state.lastViewportKey = viewportKey
2541
2129
  state.renderVisibilityDirty = false
2542
- state.renderClusterEdges = []
2543
-
2544
- const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
2545
-
2546
- if (shouldRenderMacroGalaxy) {
2547
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2548
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2549
- const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
2550
- if (representative) {
2551
- state.renderClusters = [
2552
- {
2553
- id: 'macro-galaxy',
2554
- x: state.macroCenter.x,
2555
- y: state.macroCenter.y,
2556
- count: sourceNodes.length,
2557
- representative
2558
- }
2559
- ]
2560
- state.renderNodes = [representative]
2561
- } else {
2562
- state.renderClusters = []
2563
- state.renderNodes = []
2564
- }
2565
- state.renderEdges = []
2566
- state.renderClusterEdges = []
2567
- return
2568
- }
2569
-
2570
- state.ecosystemViewActive = false
2571
2130
 
2572
2131
  if (state.visibleNodes.length <= 2000) {
2573
2132
  state.renderNodes = state.visibleNodes
2574
- state.renderClusters = []
2575
- state.renderClusterEdges = []
2576
2133
  const ids = new Set(state.renderNodes.map((node) => node.id))
2577
2134
  state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
2578
2135
  return
2579
2136
  }
2580
2137
 
2581
2138
  if (state.visibleNodes.length > massiveGraphNodeThreshold) {
2582
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2583
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
2584
2139
  const sampleLimit = nodeBudgetForScale(state.transform.scale)
2140
+ if (state.transform.scale < massiveOverviewScaleThreshold) {
2141
+ const overviewNodes = sampleMassiveOverviewNodes(sampleLimit)
2142
+ const overviewIds = new Set(overviewNodes.map((node) => node.id))
2143
+ state.renderNodes = overviewNodes
2144
+ state.renderEdges = withMeshEdges(overviewNodes, collectVisibleEdgesForNodes(overviewIds))
2145
+ return
2146
+ }
2147
+
2148
+ const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2149
+ const sourceNodes = viewportNodes.length > 0 ? viewportNodes : sampleMassiveOverviewNodes(sampleLimit)
2585
2150
  const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
2586
2151
  const carryViewport = expandViewportBounds(viewport, carryMargin)
2587
2152
  const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
@@ -2623,9 +2188,6 @@ const computeRenderVisibility = () => {
2623
2188
  const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
2624
2189
  sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
2625
2190
  }
2626
-
2627
- state.renderClusters = []
2628
- state.renderClusterEdges = []
2629
2191
  state.renderNodes = sampledNodes
2630
2192
  state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
2631
2193
  return
@@ -2634,24 +2196,12 @@ const computeRenderVisibility = () => {
2634
2196
  if (state.transform.scale <= 0.0015) {
2635
2197
  const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
2636
2198
  const sampledIds = new Set(sampled.map((node) => node.id))
2637
- state.renderClusters = []
2638
- state.renderClusterEdges = []
2639
2199
  state.renderNodes = sampled
2640
2200
  state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
2641
2201
  return
2642
2202
  }
2643
2203
 
2644
2204
  const viewportNodes = viewportNodesFromSpatialIndex(viewport)
2645
- const clusters = clusterViewportNodes(viewportNodes)
2646
- if (clusters.length > 0) {
2647
- state.renderClusters = []
2648
- state.renderClusterEdges = []
2649
- state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
2650
- state.renderEdges = []
2651
- return
2652
- }
2653
- state.renderClusters = []
2654
- state.renderClusterEdges = []
2655
2205
  const stride = viewportNodeStride()
2656
2206
  const picked = []
2657
2207
 
@@ -2674,8 +2224,6 @@ const computeRenderVisibility = () => {
2674
2224
  const fallbackNodes = fallbackViewportNodes()
2675
2225
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2676
2226
  state.renderNodes = fallbackNodes
2677
- state.renderClusters = []
2678
- state.renderClusterEdges = []
2679
2227
  state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2680
2228
  return
2681
2229
  }
@@ -2690,8 +2238,6 @@ const computeRenderVisibility = () => {
2690
2238
  if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
2691
2239
  const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
2692
2240
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
2693
- state.renderClusters = []
2694
- state.renderClusterEdges = []
2695
2241
  state.renderNodes = fallbackNodes
2696
2242
  state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
2697
2243
  }
@@ -2775,10 +2321,11 @@ const render = now => {
2775
2321
 
2776
2322
  computeRenderVisibility()
2777
2323
  tick(delta, now)
2778
- refreshRenderNodeDepthProjection()
2779
2324
  const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
2780
2325
  const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
2781
- const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
2326
+ const allowViewportAutoRecovery =
2327
+ state.nodes.length <= massiveGraphNodeThreshold ||
2328
+ state.transform.scale >= massiveOverviewScaleThreshold
2782
2329
  if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
2783
2330
  state.offscreenFrameCount += 1
2784
2331
  if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
@@ -2802,69 +2349,9 @@ const render = now => {
2802
2349
  : state.renderNodes.length > 500
2803
2350
  ? 0.05
2804
2351
  : 0
2805
- const drawEdges =
2806
- state.renderClusters.length === 0 &&
2807
- state.transform.scale >= minimumEdgeScale
2352
+ const drawEdges = state.transform.scale >= minimumEdgeScale
2808
2353
  if (drawAcceleratedGraph(width, height, drawEdges)) {
2809
2354
  // WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
2810
- } else if (state.renderClusters.length > 0) {
2811
- ctx.save()
2812
- ctx.translate(state.transform.x, state.transform.y)
2813
- ctx.scale(state.transform.scale, state.transform.scale)
2814
- const orderedClusters = [...state.renderClusters]
2815
- const safeScale = Math.max(state.transform.scale, 0.0001)
2816
- if (state.renderClusterEdges.length > 0) {
2817
- for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
2818
- const edge = state.renderClusterEdges[index]
2819
- const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
2820
- if (edgeOpacity <= 0.01) {
2821
- continue
2822
- }
2823
- const widthScale = 1
2824
- ctx.beginPath()
2825
- ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
2826
- ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
2827
- ctx.lineWidth = (1.2 * widthScale) / safeScale
2828
- ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
2829
- ctx.stroke()
2830
- }
2831
- }
2832
- orderedClusters.forEach(cluster => {
2833
- const isMacro = cluster.id === 'macro-galaxy'
2834
- const isEcosystem = String(cluster.id).startsWith('ecosystem-')
2835
- const isHub = Boolean(cluster.isHub)
2836
- const opacity = clusterOpacity(cluster)
2837
- if (opacity <= 0.01) {
2838
- return
2839
- }
2840
- const radiusPx = clusterRadiusPx(cluster)
2841
- const radius = radiusPx / safeScale
2842
- const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
2843
- ctx.globalAlpha = opacity
2844
- if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
2845
- ctx.beginPath()
2846
- ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
2847
- ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
2848
- ctx.fill()
2849
- }
2850
- ctx.beginPath()
2851
- ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
2852
- ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
2853
- ctx.fill()
2854
- ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
2855
- ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
2856
- ctx.stroke()
2857
- if (isMacro && cluster.representative?.title) {
2858
- ctx.fillStyle = '#edf2f7'
2859
- ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
2860
- ctx.textAlign = 'center'
2861
- ctx.textBaseline = 'top'
2862
- ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
2863
- }
2864
- ctx.globalAlpha = 1
2865
- // Keep cluster markers minimal and faster to draw on large graphs.
2866
- })
2867
- ctx.restore()
2868
2355
  } else {
2869
2356
  ctx.save()
2870
2357
  ctx.translate(state.transform.x, state.transform.y)
@@ -2875,7 +2362,7 @@ const render = now => {
2875
2362
  drawGraphNodes()
2876
2363
  ctx.restore()
2877
2364
  }
2878
- if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
2365
+ if (state.renderNodes.length === 0) {
2879
2366
  ctx.fillStyle = '#99a5b5'
2880
2367
  ctx.font = '12px Inter, system-ui, sans-serif'
2881
2368
  ctx.textAlign = 'center'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.115",
3
+ "version": "0.1.0-beta.117",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",