@andespindola/brainlink 0.1.0-beta.114 → 0.1.0-beta.116

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.
@@ -7,61 +7,13 @@ const largeGraphEdgeRenderLimit = 120000
7
7
  const renderNodeBudget = 900
8
8
  const zoomedMassiveRenderNodeBudget = 2200
9
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
16
10
  const massiveAutoFitMacroScale = 0.018
17
- const defaultMacroScale = 0.018
18
- const clusterCellPixelSize = 64
19
11
  const minNodePixelRadius = 2.3
20
12
  const viewportPaddingPx = 280
21
13
  const worldCoordinateLimit = 5_000_000
22
14
  const transformCoordinateLimit = 20_000_000
23
15
  const hoverHitTestIntervalMs = 64
24
- const ecosystemLevelNodeCap = 999
25
- const ecosystemActivationNodeThreshold = 1000
26
- const ecosystemClusterEdgeLimit = 520
27
- const ecosystemHubEdgeLimit = 120
28
- const ecosystemSiblingEdgeLimit = 180
29
- const ecosystemClusterScaleThreshold = 0.78
30
- const massiveEcosystemClusterScaleThreshold = 4.2
31
- const ecosystemClusterEnterHysteresis = 0.94
32
- const ecosystemClusterExitHysteresis = 1.1
33
- const ecosystemSubgraphScaleThreshold = 0.18
34
- const ecosystemMicroScaleThreshold = 0.08
35
- const ecosystemFocusedParentLimit = 2
36
- const ecosystemDepthNear = 80
37
- const ecosystemDepthFar = 2600
38
- const ecosystemDepthPerspective = 560
39
- const ecosystemDepthTiltY = 0.3
40
- const ecosystemDepthYaw = 0.3
41
- const ecosystemDepthPitch = 0.24
42
- const ecosystemDepthRadialGain = 0.13
43
- const ecosystemDepthOrbitalMaxOffset = 160
44
- const ecosystemDepthMinScale = 0.2
45
- const ecosystemDepthOpacityFloor = 0.16
46
- const graphDepthNear = 40
47
- const graphDepthFar = 1320
48
- const graphDepthPerspective = 430
49
- const graphDepthYaw = 0.42
50
- const graphDepthPitch = 0.3
51
- const graphDepthRadialGain = 0.24
52
- const graphDepthMinScale = 0.34
53
- const graphDepthOpacityFloor = 0.22
54
- const graphDepthEdgeOpacityFloor = 0.12
55
- const graphDepthProjectionNodeThreshold = 40
56
- const graphDepthProjectionNodeCap = 2600
57
- const graphDepthProjectionMinScale = 0.03
58
- const graphDepthProjectionMaxScale = 1.7
59
- const graphDepthProjectionEnterMinScale = graphDepthProjectionMinScale * 1.08
60
- const graphDepthProjectionExitMinScale = graphDepthProjectionMinScale * 0.88
61
- const graphDepthProjectionEnterMaxScale = graphDepthProjectionMaxScale * 0.92
62
- const graphDepthProjectionExitMaxScale = graphDepthProjectionMaxScale * 1.16
63
16
  const zoomRecoveryGuardMs = 4200
64
- const zoomCapTargetViewportShare = 0.72
65
17
  const meshEdgeScaleThreshold = 0.09
66
18
  const meshEdgeMinBudget = 140
67
19
  const meshEdgeMaxBudget = 1400
@@ -77,11 +29,6 @@ const zoomAnimationSlowLerp = 0.18
77
29
  const zoomAnimationFastLerp = 0.36
78
30
  const zoomAnimationScaleSnap = 0.00008
79
31
  const zoomAnimationPositionSnap = 0.14
80
- const bloomZoomFocusMaxScreenDistance = 260
81
- const bloomZoomFocusFarStrength = 0.66
82
- const bloomZoomFocusNearStrength = 0.34
83
- const bloomCameraParallaxNearStrength = 0.2
84
- const bloomCameraParallaxFarStrength = 0.06
85
32
  const physicsDragFrameIntervalMs = 16
86
33
  const physicsIdleFrameIntervalMs = 78
87
34
  const physicsLargeGraphIdleFrameIntervalMs = 108
@@ -95,8 +42,6 @@ const state = {
95
42
  visibleEdges: [],
96
43
  renderNodes: [],
97
44
  renderEdges: [],
98
- renderClusters: [],
99
- renderClusterEdges: [],
100
45
  renderNodeDepthProjectionById: new Map(),
101
46
  nodeDegrees: new Map(),
102
47
  selected: null,
@@ -122,26 +67,12 @@ const state = {
122
67
  lastViewportKey: '',
123
68
  visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
124
69
  visibleEdgeByNode: new Map(),
125
- ecosystemClusters: [],
126
- ecosystemClustersBySize: new Map(),
127
- ecosystemNodeClusterBySize: new Map(),
128
- ecosystemLevelSizes: [],
129
- ecosystemLevelIndexBySize: new Map(),
130
- ecosystemHubNodeIds: new Set(),
131
- ecosystemExpansionLevels: [],
132
- ecosystemBaseSize: ecosystemLevelNodeCap,
133
- ecosystemHubCluster: null,
134
- macroCenter: { x: 0, y: 0 },
135
- macroRepresentative: null,
136
70
  primaryHub: null,
137
- hubNeighborDistance: Number.POSITIVE_INFINITY,
138
71
  filterWorker: null,
139
72
  filterReady: false,
140
73
  lastHoverHitAt: 0,
141
74
  lastManualZoomAt: 0,
142
75
  lastZoomFocus: { x: 0, y: 0, at: 0 },
143
- macroViewActive: false,
144
- ecosystemViewActive: false,
145
76
  depthProjectionActive: false,
146
77
  zoomTransition: {
147
78
  active: false,
@@ -573,47 +504,6 @@ const filteredNodes = () => {
573
504
  return withPersistentHubNodes(localFilteredNodes(query))
574
505
  }
575
506
 
576
- const resolveMacroRepresentative = (nodes) => {
577
- if (nodes.length === 0) {
578
- return null
579
- }
580
-
581
- const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
582
- ? state.primaryHub
583
- : null
584
- let best = hubCandidate ?? nodes[0]
585
- let bestDegree = state.nodeDegrees.get(best.id) ?? 0
586
-
587
- for (let index = 1; index < nodes.length; index += 1) {
588
- const node = nodes[index]
589
- const degree = state.nodeDegrees.get(node.id) ?? 0
590
- if (degree > bestDegree) {
591
- best = node
592
- bestDegree = degree
593
- }
594
- }
595
-
596
- return best
597
- }
598
-
599
- const nearestHubNeighborDistance = (hub, nodes) => {
600
- if (!hub || nodes.length <= 1) {
601
- return Number.POSITIVE_INFINITY
602
- }
603
-
604
- let minimum = Number.POSITIVE_INFINITY
605
- for (let index = 0; index < nodes.length; index += 1) {
606
- const node = nodes[index]
607
- if (node.id === hub.id) continue
608
- const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
609
- if (distance < minimum) {
610
- minimum = distance
611
- }
612
- }
613
-
614
- return minimum
615
- }
616
-
617
507
  const isDominantHub = (hub, nodeCount = state.visibleNodes.length) => {
618
508
  if (!hub || nodeCount <= 0) {
619
509
  return false
@@ -641,39 +531,6 @@ const recomputeVisibility = () => {
641
531
  state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
642
532
  const primaryHub = rankedHubNodes()[0] ?? null
643
533
  state.primaryHub = primaryHub
644
- state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
645
- const bounds = graphBounds(nodes)
646
- const macroHub = isDominantHub(primaryHub, nodes.length) ? primaryHub : null
647
- state.macroCenter = bounds
648
- ? {
649
- x: macroHub ? macroHub.x : (bounds.minX + bounds.maxX) / 2,
650
- y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
651
- }
652
- : { x: 0, y: 0 }
653
- const ecosystemGraph = nodes.length > 1
654
- ? buildEcosystemGraph(nodes, state.macroCenter, primaryHub)
655
- : {
656
- clusters: [],
657
- clustersBySize: new Map(),
658
- nodeClusterBySize: new Map(),
659
- levelSizes: [],
660
- expansionLevels: [],
661
- baseSize: ecosystemLevelNodeCap,
662
- hubCluster: null
663
- }
664
- state.ecosystemClusters = ecosystemGraph.clusters
665
- state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
666
- state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
667
- state.ecosystemLevelSizes = ecosystemGraph.levelSizes
668
- state.ecosystemLevelIndexBySize = ecosystemGraph.levelSizes.reduce((map, size, index) => {
669
- map.set(size, index)
670
- return map
671
- }, new Map())
672
- state.ecosystemHubNodeIds = new Set(ecosystemGraph.hubCluster?.nodeIds ?? [])
673
- state.ecosystemExpansionLevels = ecosystemGraph.expansionLevels
674
- state.ecosystemBaseSize = ecosystemGraph.baseSize
675
- state.ecosystemHubCluster = ecosystemGraph.hubCluster
676
- state.macroRepresentative = resolveMacroRepresentative(nodes)
677
534
  markRenderDirty()
678
535
  }
679
536
 
@@ -778,700 +635,12 @@ const createVisibleEdgeLookup = edges => {
778
635
  return lookup
779
636
  }
780
637
 
781
- const ecosystemKeyForNode = node => {
782
- if (typeof node.segment === 'string' && node.segment.trim()) {
783
- return node.segment.trim()
784
- }
785
- if (typeof node.group === 'string' && node.group.trim()) {
786
- return node.group.trim()
787
- }
788
- const pathParts = String(node.path || '')
789
- .split('/')
790
- .filter(part => part.trim())
791
- .slice(0, 2)
792
- return pathParts.length > 0 ? pathParts.join('/') : 'root'
793
- }
794
-
795
- const compareNodesForEcosystem = (left, right) => {
796
- const keyComparison = ecosystemKeyForNode(left).localeCompare(ecosystemKeyForNode(right))
797
- if (keyComparison !== 0) return keyComparison
798
- const leftDegree = state.nodeDegrees.get(left.id) ?? 0
799
- const rightDegree = state.nodeDegrees.get(right.id) ?? 0
800
- if (leftDegree !== rightDegree) return rightDegree - leftDegree
801
- return String(left.title || left.id).localeCompare(String(right.title || right.id))
802
- }
803
-
804
- const selectEcosystemRepresentative = nodes => {
805
- let representative = nodes[0] ?? null
806
- let representativeScore = Number.NEGATIVE_INFINITY
807
-
808
- for (let index = 0; index < nodes.length; index += 1) {
809
- const node = nodes[index]
810
- const score = (state.nodeDegrees.get(node.id) ?? 0) + hubNodeScore(node) * 1000
811
- if (score > representativeScore) {
812
- representative = node
813
- representativeScore = score
814
- }
815
- }
816
-
817
- return representative
818
- }
819
-
820
- const ecosystemLayoutSpacingForSize = size => {
821
- if (size >= ecosystemLevelNodeCap) return 260
822
- if (size >= 320) return 110
823
- if (size >= 120) return 64
824
- if (size >= 48) return 34
825
- if (size >= 18) return 18
826
- if (size >= 8) return 11
827
- return 7
828
- }
829
-
830
- const buildIntermediateEcosystemSizes = (fromSize, toSize) => {
831
- if (fromSize <= toSize + 1) {
832
- return []
833
- }
834
- const intermediate = []
835
- let current = fromSize
836
- while (current > toSize + 1) {
837
- const stepped = Math.max(toSize + 1, Math.ceil(current / 3))
838
- if (stepped >= current) {
839
- break
840
- }
841
- intermediate.push(stepped)
842
- current = stepped
843
- }
844
- return intermediate
845
- }
846
-
847
- const buildEcosystemLevelSizes = nodeCount => {
848
- if (nodeCount <= 0) return []
849
- const primarySizes = []
850
- let currentSize = Math.max(1, Math.ceil(nodeCount / ecosystemLevelNodeCap))
851
- while (currentSize >= 1) {
852
- primarySizes.push(currentSize)
853
- if (currentSize === 1) {
854
- break
855
- }
856
- const nextSize = Math.max(1, Math.ceil(currentSize / ecosystemLevelNodeCap))
857
- if (nextSize === currentSize) {
858
- break
859
- }
860
- currentSize = nextSize
861
- }
862
- const expandedSizes = []
863
- for (let index = 0; index < primarySizes.length; index += 1) {
864
- const size = primarySizes[index]
865
- if (expandedSizes.length === 0 || expandedSizes[expandedSizes.length - 1] !== size) {
866
- expandedSizes.push(size)
867
- }
868
- const nextSize = primarySizes[index + 1]
869
- if (!Number.isFinite(nextSize)) {
870
- continue
871
- }
872
- const intermediate = buildIntermediateEcosystemSizes(size, nextSize)
873
- for (let intermediateIndex = 0; intermediateIndex < intermediate.length; intermediateIndex += 1) {
874
- const candidate = intermediate[intermediateIndex]
875
- if (expandedSizes[expandedSizes.length - 1] !== candidate) {
876
- expandedSizes.push(candidate)
877
- }
878
- }
879
- }
880
- if (expandedSizes[expandedSizes.length - 1] !== 1) {
881
- expandedSizes.push(1)
882
- }
883
- return expandedSizes
884
- }
885
-
886
- const buildEcosystemExpansionLevels = (levelSizes, nodeCount) => {
887
- if (levelSizes.length <= 1) {
888
- return []
889
- }
890
- const isMassive = nodeCount > massiveGraphNodeThreshold
891
- const maxScale = isMassive
892
- ? massiveEcosystemClusterScaleThreshold
893
- : ecosystemClusterScaleThreshold
894
- const startScale = isMassive ? 1.12 : 0.24
895
- const transitionCount = levelSizes.length - 1
896
- const usableScale = Math.max(0.08, maxScale - startScale)
897
- const step = usableScale / transitionCount
898
- const stride = isMassive ? 0.93 : 0.82
899
- const overlap = isMassive ? 1.22 : 1.62
900
- const levels = []
901
- for (let index = 0; index < transitionCount; index += 1) {
902
- const start = startScale + step * index * stride
903
- const end = Math.min(maxScale, start + step * overlap)
904
- levels.push({
905
- parentSize: levelSizes[index],
906
- childSize: levelSizes[index + 1],
907
- start,
908
- end
909
- })
910
- }
911
- return levels
912
- }
913
-
914
- const ecosystemCompactPoint = (index, total, center, spacing) => {
915
- if (total <= 1) {
916
- return { x: center.x, y: center.y }
917
- }
918
- const angle = index * 2.399963229728653
919
- const radius = spacing * Math.sqrt(index + 1)
920
- return {
921
- x: center.x + Math.cos(angle) * radius,
922
- y: center.y + Math.sin(angle) * radius
923
- }
924
- }
925
-
926
- const buildEcosystemCluster = (nodes, index, point) => {
927
- const count = Math.max(nodes.length, 1)
928
- const representative = selectEcosystemRepresentative(nodes)
929
-
930
- return {
931
- id: 'ecosystem-' + index,
932
- x: point.x,
933
- y: point.y,
934
- count,
935
- nodeIds: nodes.map(node => node.id),
936
- representative,
937
- label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
938
- }
939
- }
940
-
941
- const buildEcosystemHubCluster = (hub, center) => hub
942
- ? {
943
- id: 'ecosystem-hub',
944
- x: center.x,
945
- y: center.y,
946
- count: 1,
947
- size: 1,
948
- nodeIds: [hub.id],
949
- representative: hub,
950
- label: hub.title || 'Memory Hub',
951
- parentId: null,
952
- parentX: null,
953
- parentY: null,
954
- isHub: true
955
- }
956
- : null
957
-
958
- const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
959
- const clusters = []
960
- const clusterByNodeId = new Map()
961
- const parentChildIndex = new Map()
962
-
963
- for (let offset = 0; offset < sortedNodes.length; offset += size) {
964
- const clusterNodes = sortedNodes.slice(offset, offset + size)
965
- const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
966
- const siblingIndex = parentCluster
967
- ? (parentChildIndex.get(parentCluster.id) ?? 0)
968
- : clusters.length
969
- if (parentCluster) {
970
- parentChildIndex.set(parentCluster.id, siblingIndex + 1)
971
- }
972
- const point = parentCluster
973
- ? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
974
- : ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
975
- const cluster = {
976
- ...buildEcosystemCluster(clusterNodes, clusters.length, point),
977
- id: 'ecosystem-' + size + '-' + clusters.length,
978
- size,
979
- parentId: parentCluster?.id ?? null,
980
- parentX: parentCluster?.x ?? null,
981
- parentY: parentCluster?.y ?? null
982
- }
983
- clusters.push(cluster)
984
- for (let index = 0; index < clusterNodes.length; index += 1) {
985
- clusterByNodeId.set(clusterNodes[index].id, cluster)
986
- }
987
- }
988
-
989
- return { clusters, clusterByNodeId }
990
- }
991
-
992
- const buildEcosystemGraph = (nodes, center, hub) => {
993
- if (nodes.length === 0) {
994
- return {
995
- clusters: [],
996
- clustersBySize: new Map(),
997
- nodeClusterBySize: new Map(),
998
- levelSizes: [],
999
- expansionLevels: [],
1000
- baseSize: ecosystemLevelNodeCap,
1001
- hubCluster: null
1002
- }
1003
- }
1004
-
1005
- const hubCluster = buildEcosystemHubCluster(hub, center)
1006
- const sortedNodes = nodes
1007
- .filter(node => node.id !== hub?.id)
1008
- .sort(compareNodesForEcosystem)
1009
- const levelSizes = buildEcosystemLevelSizes(sortedNodes.length)
1010
- const expansionLevels = buildEcosystemExpansionLevels(levelSizes, nodes.length)
1011
- const baseSize = levelSizes[0] ?? ecosystemLevelNodeCap
1012
- const clustersBySize = new Map()
1013
- const nodeClusterBySize = new Map()
1014
- let parentLookup = null
1015
-
1016
- for (let index = 0; index < levelSizes.length; index += 1) {
1017
- const size = levelSizes[index]
1018
- const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
1019
- clustersBySize.set(size, level.clusters)
1020
- nodeClusterBySize.set(size, level.clusterByNodeId)
1021
- parentLookup = level.clusterByNodeId
1022
- }
1023
-
1024
- return {
1025
- clusters: clustersBySize.get(baseSize) ?? [],
1026
- clustersBySize,
1027
- nodeClusterBySize,
1028
- levelSizes,
1029
- expansionLevels,
1030
- baseSize,
1031
- hubCluster
1032
- }
1033
- }
1034
-
1035
638
  const isClusterInViewport = (cluster, viewport) =>
1036
639
  cluster.x >= viewport.minX &&
1037
640
  cluster.x <= viewport.maxX &&
1038
641
  cluster.y >= viewport.minY &&
1039
642
  cluster.y <= viewport.maxY
1040
643
 
1041
- const filterEcosystemClustersByViewport = (clusters, viewport) => {
1042
- const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
1043
- return visible.length > 0 ? visible : [...clusters]
1044
- }
1045
-
1046
- const ecosystemFocusPoint = () => {
1047
- const cursorPoint = cursorWorldPoint()
1048
- if (cursorPoint) {
1049
- return cursorPoint
1050
- }
1051
- const now = performance.now()
1052
- if (now - state.lastZoomFocus.at <= 1800) {
1053
- return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
1054
- }
1055
- return viewportCenterWorldPoint()
1056
- }
1057
-
1058
- const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
1059
- clusters
1060
- .map(cluster => ({
1061
- cluster,
1062
- distance: Math.max(
1063
- 0,
1064
- Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
1065
- clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
1066
- )
1067
- }))
1068
- .sort((left, right) => left.distance - right.distance)
1069
- .slice(0, limit)
1070
- .map(item => item.cluster.id)
1071
-
1072
- const focusedParentCluster = (clusters, focusPoint) => {
1073
- if (clusters.length === 0) {
1074
- return null
1075
- }
1076
- let focused = clusters[0]
1077
- let nearestDistance = Number.POSITIVE_INFINITY
1078
- for (let index = 0; index < clusters.length; index += 1) {
1079
- const cluster = clusters[index]
1080
- const distance = Math.max(
1081
- 0,
1082
- Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
1083
- clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
1084
- )
1085
- if (distance < nearestDistance) {
1086
- nearestDistance = distance
1087
- focused = cluster
1088
- }
1089
- }
1090
- return focused
1091
- }
1092
-
1093
- const nearestSiblingScreenDistancePx = (focusedCluster, clusters) => {
1094
- let nearestDistancePx = Number.POSITIVE_INFINITY
1095
- for (let index = 0; index < clusters.length; index += 1) {
1096
- const cluster = clusters[index]
1097
- if (cluster.id === focusedCluster.id) continue
1098
- const distancePx = Math.hypot(
1099
- (cluster.x - focusedCluster.x) * state.transform.scale,
1100
- (cluster.y - focusedCluster.y) * state.transform.scale
1101
- )
1102
- if (distancePx < nearestDistancePx) {
1103
- nearestDistancePx = distancePx
1104
- }
1105
- }
1106
- return nearestDistancePx
1107
- }
1108
-
1109
- const ecosystemFocusReadiness = (parentClusters, focusPoint, childSize) => {
1110
- if (parentClusters.length <= 1) {
1111
- return 1
1112
- }
1113
- const focusedCluster = focusedParentCluster(parentClusters, focusPoint)
1114
- if (!focusedCluster) {
1115
- return 0
1116
- }
1117
- const nearestDistancePx = nearestSiblingScreenDistancePx(focusedCluster, parentClusters)
1118
- const sizeHalfCap = Math.ceil(ecosystemLevelNodeCap / 2)
1119
- const sizeEighthCap = Math.ceil(ecosystemLevelNodeCap / 8)
1120
- const focusDistanceTargetPx = childSize >= sizeHalfCap
1121
- ? 680
1122
- : childSize >= sizeEighthCap
1123
- ? 520
1124
- : 380
1125
- const focusDistanceRangePx = childSize >= sizeHalfCap ? 260 : 220
1126
- return smoothStep((nearestDistancePx - focusDistanceTargetPx) / focusDistanceRangePx)
1127
- }
1128
- const smoothStep = value => {
1129
- const clamped = Math.max(0, Math.min(1, value))
1130
- return clamped * clamped * (3 - clamped * 2)
1131
- }
1132
-
1133
- const zoomProgress = (scale, start, end) =>
1134
- smoothStep((scale - start) / Math.max(end - start, 0.0001))
1135
-
1136
- const semanticZoomSpread = (progress, childSize) => {
1137
- const spreadExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 5.6 : 4.2
1138
- const curve = Math.pow(progress, spreadExponent)
1139
- if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
1140
- return 0.12 + curve * 0.88
1141
- }
1142
- return curve
1143
- }
1144
-
1145
- const opacityForProgress = (progress, childSize) => {
1146
- const opacityExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 2.8 : 2.1
1147
- const eased = Math.pow(progress, opacityExponent)
1148
- if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
1149
- return 0.22 + eased * 0.78
1150
- }
1151
- return eased
1152
- }
1153
-
1154
- const expandFocusedClusters = (parentClusters, focusPoint, childSize, progress, spread, viewport) => {
1155
- const expandedParentIds = new Set(nearestEcosystemParentIds(
1156
- parentClusters,
1157
- focusPoint,
1158
- ecosystemFocusedParentLimit
1159
- ))
1160
- const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
1161
- const visibleChildClusters = []
1162
- for (let index = 0; index < childClusters.length; index += 1) {
1163
- const cluster = childClusters[index]
1164
- if (!expandedParentIds.has(cluster.parentId)) {
1165
- continue
1166
- }
1167
- const spreadCluster = spreadChildClusterFromParent(cluster, childSize, progress, spread)
1168
- if (!isClusterInViewport(spreadCluster, viewport)) {
1169
- continue
1170
- }
1171
- visibleChildClusters.push(spreadCluster)
1172
- }
1173
-
1174
- return {
1175
- expandedParentIds,
1176
- childClusters: visibleChildClusters
1177
- }
1178
- }
1179
-
1180
- const spreadChildClusterFromParent = (cluster, childSize, progress, spread) => {
1181
- if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
1182
- return {
1183
- ...cluster,
1184
- lodOpacity: opacityForProgress(progress, childSize)
1185
- }
1186
- }
1187
-
1188
- return {
1189
- ...cluster,
1190
- x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
1191
- y: cluster.parentY + (cluster.y - cluster.parentY) * spread,
1192
- lodOpacity: opacityForProgress(progress, childSize)
1193
- }
1194
- }
1195
-
1196
- const selectHierarchicalEcosystemClusters = viewport => {
1197
- const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
1198
- const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
1199
- const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
1200
- const visibleClusters = [...visibleBaseClusters]
1201
- const clustersBySize = new Map()
1202
- for (let index = 0; index < visibleBaseClusters.length; index += 1) {
1203
- const cluster = visibleBaseClusters[index]
1204
- const levelClusters = clustersBySize.get(cluster.size)
1205
- if (levelClusters) {
1206
- levelClusters.push(cluster)
1207
- } else {
1208
- clustersBySize.set(cluster.size, [cluster])
1209
- }
1210
- }
1211
- const focusPoint = ecosystemFocusPoint()
1212
-
1213
- for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
1214
- const level = state.ecosystemExpansionLevels[index]
1215
- const parentClusters = clustersBySize.get(level.parentSize) ?? []
1216
- if (parentClusters.length === 0) {
1217
- continue
1218
- }
1219
- const zoomLevelProgress = zoomProgress(state.transform.scale, level.start, level.end)
1220
- const focusReadiness = ecosystemFocusReadiness(parentClusters, focusPoint, level.childSize)
1221
- const progress = zoomLevelProgress * focusReadiness
1222
- if (progress <= 0.002) {
1223
- continue
1224
- }
1225
- const spread = semanticZoomSpread(progress, level.childSize)
1226
- const expansion = expandFocusedClusters(parentClusters, focusPoint, level.childSize, progress, spread, viewport)
1227
- visibleClusters.push(...expansion.childClusters)
1228
- if (expansion.childClusters.length > 0) {
1229
- const levelClusters = clustersBySize.get(level.childSize)
1230
- if (levelClusters) {
1231
- levelClusters.push(...expansion.childClusters)
1232
- } else {
1233
- clustersBySize.set(level.childSize, [...expansion.childClusters])
1234
- }
1235
- }
1236
- }
1237
-
1238
- return [...hubClusters, ...visibleClusters]
1239
- }
1240
-
1241
- const ecosystemLevelIndexBySize = () => state.ecosystemLevelIndexBySize
1242
-
1243
- const ecosystemDepthForCluster = (cluster, levelIndexMap) => {
1244
- if (cluster.isHub) {
1245
- return ecosystemDepthNear
1246
- }
1247
- const maxLevelIndex = Math.max(state.ecosystemLevelSizes.length - 1, 0)
1248
- const levelIndex = levelIndexMap.get(cluster.size) ?? 0
1249
- const reverseIndex = Math.max(0, maxLevelIndex - levelIndex)
1250
- const normalized = maxLevelIndex === 0 ? 0 : reverseIndex / maxLevelIndex
1251
- return ecosystemDepthNear + normalized * (ecosystemDepthFar - ecosystemDepthNear)
1252
- }
1253
-
1254
- const projectEcosystemPoint = (x, y, depth, anchor) => {
1255
- const safeDepth = Math.max(0, depth)
1256
- const dx = x - anchor.x
1257
- const dy = y - anchor.y
1258
- const yawSin = Math.sin(ecosystemDepthYaw)
1259
- const yawCos = Math.cos(ecosystemDepthYaw)
1260
- const pitchSin = Math.sin(ecosystemDepthPitch)
1261
- const pitchCos = Math.cos(ecosystemDepthPitch)
1262
- const rotatedX = dx * yawCos + safeDepth * yawSin
1263
- const rotatedZ = Math.max(0, safeDepth * yawCos - dx * yawSin)
1264
- const rotatedY = dy * pitchCos - rotatedZ * pitchSin
1265
- const projectedDepth = Math.max(0, rotatedZ + Math.max(0, dy * pitchSin))
1266
- const factor = ecosystemDepthPerspective / (ecosystemDepthPerspective + projectedDepth)
1267
- const verticalTilt = projectedDepth * ecosystemDepthTiltY
1268
- return {
1269
- x: anchor.x + rotatedX * factor,
1270
- y: anchor.y + rotatedY * factor - verticalTilt,
1271
- factor,
1272
- projectedDepth
1273
- }
1274
- }
1275
-
1276
- const applyEcosystemDepthProjection = (clusters, edges, anchor) => {
1277
- const levelIndexMap = ecosystemLevelIndexBySize()
1278
- const effectiveAnchor = applyBloomCameraParallax(anchor)
1279
- const projectedClusters = []
1280
- const clusterById = new Map()
1281
-
1282
- for (let index = 0; index < clusters.length; index += 1) {
1283
- const cluster = clusters[index]
1284
- const baseDepth = ecosystemDepthForCluster(cluster, levelIndexMap)
1285
- const radialDistance = Math.hypot(cluster.x - effectiveAnchor.x, cluster.y - effectiveAnchor.y)
1286
- const radialOffset = cluster.isHub ? 0 : Math.min(320, radialDistance * ecosystemDepthRadialGain)
1287
- const orbitalOffset = cluster.isHub
1288
- ? 0
1289
- : Math.sin(Math.atan2(cluster.y - effectiveAnchor.y, cluster.x - effectiveAnchor.x) * 2.2) * ecosystemDepthOrbitalMaxOffset
1290
- const depth = Math.max(0, baseDepth + radialOffset + orbitalOffset)
1291
- const projected = projectEcosystemPoint(cluster.x, cluster.y, depth, effectiveAnchor)
1292
- const baseOpacity = Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1
1293
- const depthScale = ecosystemDepthMinScale + (1 - ecosystemDepthMinScale) * projected.factor
1294
- const depthOpacity = Math.max(
1295
- ecosystemDepthOpacityFloor,
1296
- Math.min(1, depthScale * 1.08)
1297
- )
1298
- const projectedCluster = {
1299
- ...cluster,
1300
- x: projected.x,
1301
- y: projected.y,
1302
- lodOpacity: baseOpacity * depthOpacity,
1303
- depth: projected.projectedDepth,
1304
- depthScale
1305
- }
1306
- projectedClusters.push(projectedCluster)
1307
- clusterById.set(projectedCluster.id, projectedCluster)
1308
- }
1309
-
1310
- const projectedEdges = []
1311
- for (let index = 0; index < edges.length; index += 1) {
1312
- const edge = edges[index]
1313
- const sourceCluster = clusterById.get(edge.sourceCluster.id)
1314
- const targetCluster = clusterById.get(edge.targetCluster.id)
1315
- if (!sourceCluster || !targetCluster) {
1316
- continue
1317
- }
1318
- projectedEdges.push({
1319
- ...edge,
1320
- sourceCluster,
1321
- targetCluster
1322
- })
1323
- }
1324
-
1325
- return {
1326
- clusters: projectedClusters,
1327
- edges: projectedEdges
1328
- }
1329
- }
1330
-
1331
- const ecosystemSiblingEdgesForClusters = (clusters, existingEdges) => {
1332
- const byParent = new Map()
1333
- for (let index = 0; index < clusters.length; index += 1) {
1334
- const cluster = clusters[index]
1335
- if (cluster.isHub || !cluster.parentId) {
1336
- continue
1337
- }
1338
- const siblings = byParent.get(cluster.parentId)
1339
- if (siblings) {
1340
- siblings.push(cluster)
1341
- } else {
1342
- byParent.set(cluster.parentId, [cluster])
1343
- }
1344
- }
1345
-
1346
- const edges = []
1347
- for (const siblings of byParent.values()) {
1348
- const ordered = [...siblings]
1349
- .sort((left, right) => Math.atan2(left.y - (left.parentY ?? 0), left.x - (left.parentX ?? 0)) - Math.atan2(right.y - (right.parentY ?? 0), right.x - (right.parentX ?? 0)))
1350
- for (let index = 0; index < ordered.length && edges.length < ecosystemSiblingEdgeLimit; index += 1) {
1351
- const sourceCluster = ordered[index]
1352
- const targetCluster = ordered[(index + 1) % ordered.length]
1353
- if (!targetCluster || sourceCluster.id === targetCluster.id) {
1354
- continue
1355
- }
1356
- const orderedIds = sourceCluster.id < targetCluster.id
1357
- ? [sourceCluster.id, targetCluster.id]
1358
- : [targetCluster.id, sourceCluster.id]
1359
- const key = orderedIds.join(':')
1360
- if (existingEdges.has(key)) {
1361
- continue
1362
- }
1363
- const edge = {
1364
- id: key,
1365
- sourceCluster,
1366
- targetCluster,
1367
- weight: 0.7,
1368
- inferred: true
1369
- }
1370
- existingEdges.set(key, edge)
1371
- edges.push(edge)
1372
- }
1373
- }
1374
-
1375
- return edges
1376
- }
1377
-
1378
- const ecosystemEdgesForClusters = clusters => {
1379
- const edgeClusters = clusters.filter(cluster => cluster.isHub || clusterOpacity(cluster) > 0.018)
1380
- const clusterById = new Map(edgeClusters.map(cluster => [cluster.id, cluster]))
1381
- const clusterIds = new Set(clusterById.keys())
1382
- const levelsBySize = []
1383
- const seenSizes = new Set()
1384
- for (let index = 0; index < edgeClusters.length; index += 1) {
1385
- const cluster = edgeClusters[index]
1386
- if (!cluster.size || cluster.isHub) continue
1387
- if (seenSizes.has(cluster.size)) continue
1388
- seenSizes.add(cluster.size)
1389
- levelsBySize.push({
1390
- size: cluster.size,
1391
- lookup: state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map()
1392
- })
1393
- }
1394
- levelsBySize.sort((left, right) => left.size - right.size)
1395
- const resolvedNodeClusterById = new Map()
1396
- const resolveClusterForNode = nodeId => {
1397
- if (resolvedNodeClusterById.has(nodeId)) {
1398
- return resolvedNodeClusterById.get(nodeId)
1399
- }
1400
- if (state.ecosystemHubNodeIds.has(nodeId) && state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)) {
1401
- resolvedNodeClusterById.set(nodeId, state.ecosystemHubCluster)
1402
- return state.ecosystemHubCluster
1403
- }
1404
- for (let index = 0; index < levelsBySize.length; index += 1) {
1405
- const lookup = levelsBySize[index].lookup
1406
- const cluster = lookup.get(nodeId)
1407
- if (cluster && clusterIds.has(cluster.id)) {
1408
- const resolvedCluster = clusterById.get(cluster.id) ?? cluster
1409
- resolvedNodeClusterById.set(nodeId, resolvedCluster)
1410
- return resolvedCluster
1411
- }
1412
- }
1413
- resolvedNodeClusterById.set(nodeId, null)
1414
- return null
1415
- }
1416
-
1417
- const edgeByClusterPair = new Map()
1418
- for (let index = 0; index < state.visibleEdges.length; index += 1) {
1419
- const edge = state.visibleEdges[index]
1420
- const sourceCluster = resolveClusterForNode(edge.source)
1421
- const targetCluster = resolveClusterForNode(edge.target)
1422
- if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
1423
- continue
1424
- }
1425
-
1426
- const orderedIds = sourceCluster.id < targetCluster.id
1427
- ? [sourceCluster.id, targetCluster.id]
1428
- : [targetCluster.id, sourceCluster.id]
1429
- const key = orderedIds.join(':')
1430
- const current = edgeByClusterPair.get(key)
1431
- if (current) {
1432
- current.weight += edgeWeight(edge)
1433
- continue
1434
- }
1435
-
1436
- edgeByClusterPair.set(key, {
1437
- id: key,
1438
- sourceCluster,
1439
- targetCluster,
1440
- weight: edgeWeight(edge)
1441
- })
1442
- }
1443
-
1444
- ecosystemSiblingEdgesForClusters(edgeClusters, edgeByClusterPair)
1445
- const edges = Array.from(edgeByClusterPair.values())
1446
- .sort((left, right) => right.weight - left.weight)
1447
- .slice(0, ecosystemClusterEdgeLimit)
1448
- const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
1449
- ? state.ecosystemHubCluster
1450
- : null
1451
- if (!hubCluster) {
1452
- return edges
1453
- }
1454
-
1455
- const existingHubTargets = new Set(edges.flatMap(edge =>
1456
- edge.sourceCluster.id === hubCluster.id
1457
- ? [edge.targetCluster.id]
1458
- : edge.targetCluster.id === hubCluster.id
1459
- ? [edge.sourceCluster.id]
1460
- : []
1461
- ))
1462
- const syntheticHubEdges = edgeClusters
1463
- .filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
1464
- .slice(0, ecosystemHubEdgeLimit)
1465
- .map(cluster => ({
1466
- id: hubCluster.id + ':' + cluster.id,
1467
- sourceCluster: hubCluster,
1468
- targetCluster: cluster,
1469
- weight: 1,
1470
- inferred: true
1471
- }))
1472
- return edges.concat(syntheticHubEdges)
1473
- }
1474
-
1475
644
  const edgeBudgetForCurrentFrame = () => {
1476
645
  const zoom = state.transform.scale
1477
646
  if (zoom < 0.12) return 380
@@ -1620,128 +789,13 @@ const cursorWorldPoint = () => {
1620
789
  return screenToWorldPoint(screenX, screenY)
1621
790
  }
1622
791
 
1623
- const bloomZoomFocusStrength = (scale) => {
1624
- if (scale <= 0.08) return bloomZoomFocusFarStrength
1625
- if (scale <= 0.2) return 0.58
1626
- if (scale <= 0.45) return 0.5
1627
- if (scale <= 0.9) return 0.42
1628
- return bloomZoomFocusNearStrength
1629
- }
1630
-
1631
- const bloomCameraParallaxStrength = (scale) => {
1632
- if (scale <= 0.08) return bloomCameraParallaxNearStrength
1633
- if (scale <= 0.2) return 0.16
1634
- if (scale <= 0.45) return 0.12
1635
- return bloomCameraParallaxFarStrength
1636
- }
1637
-
1638
- const screenPointForWorld = (worldX, worldY) => ({
1639
- x: worldX * state.transform.scale + state.transform.x,
1640
- y: worldY * state.transform.scale + state.transform.y
1641
- })
1642
-
1643
- const bloomZoomFocusCandidate = (screenX, screenY) => {
1644
- const candidates = [state.hovered, state.selected, state.primaryHub].filter(Boolean)
1645
- if (candidates.length === 0) {
1646
- return null
1647
- }
1648
-
1649
- let bestNode = null
1650
- let bestDistance = Number.POSITIVE_INFINITY
1651
- for (let index = 0; index < candidates.length; index += 1) {
1652
- const node = candidates[index]
1653
- const point = screenPointForWorld(nodeRenderX(node), nodeRenderY(node))
1654
- const distance = Math.hypot(point.x - screenX, point.y - screenY)
1655
- if (distance < bestDistance) {
1656
- bestDistance = distance
1657
- bestNode = node
1658
- }
1659
- }
1660
-
1661
- if (!bestNode) {
1662
- return null
1663
- }
1664
-
1665
- const scale = state.transform.scale
1666
- const allowDistance = scale <= 0.16 ? bloomZoomFocusMaxScreenDistance * 1.7 : bloomZoomFocusMaxScreenDistance
1667
- if (bestDistance > allowDistance) {
1668
- return null
1669
- }
1670
-
1671
- return {
1672
- x: nodeRenderX(bestNode),
1673
- y: nodeRenderY(bestNode)
1674
- }
1675
- }
1676
-
1677
- const resolveZoomAnchorWorldPoint = (screenX, screenY, source) => {
1678
- const cursorPoint = screenToWorldPoint(screenX, screenY)
1679
- if (source !== 'wheel') {
1680
- return cursorPoint
1681
- }
1682
-
1683
- const focusCandidate = bloomZoomFocusCandidate(screenX, screenY)
1684
- if (!focusCandidate) {
1685
- return cursorPoint
1686
- }
1687
-
1688
- const strength = bloomZoomFocusStrength(state.transform.scale)
1689
- return {
1690
- x: cursorPoint.x + (focusCandidate.x - cursorPoint.x) * strength,
1691
- y: cursorPoint.y + (focusCandidate.y - cursorPoint.y) * strength
1692
- }
1693
- }
1694
-
1695
- const applyBloomCameraParallax = (anchor) => {
1696
- const cursorPoint = cursorWorldPoint()
1697
- if (!cursorPoint) {
1698
- return anchor
1699
- }
1700
-
1701
- const strength = bloomCameraParallaxStrength(state.transform.scale)
1702
- return {
1703
- x: anchor.x + (cursorPoint.x - anchor.x) * strength,
1704
- y: anchor.y + (cursorPoint.y - anchor.y) * strength
1705
- }
1706
- }
792
+ const resolveZoomAnchorWorldPoint = (screenX, screenY) => screenToWorldPoint(screenX, screenY)
1707
793
 
1708
794
  const visibilityScaleBucket = (scale) => {
1709
795
  const safeScale = Math.max(zoomRange.min, scale)
1710
796
  return Math.round(safeScale * 180_000)
1711
797
  }
1712
798
 
1713
- const shouldRenderEcosystemClusterView = (nodeCount, scale) => {
1714
- const baseThreshold = nodeCount > massiveGraphNodeThreshold
1715
- ? massiveEcosystemClusterScaleThreshold
1716
- : ecosystemClusterScaleThreshold
1717
- const enterThreshold = baseThreshold * ecosystemClusterEnterHysteresis
1718
- const exitThreshold = baseThreshold * ecosystemClusterExitHysteresis
1719
- const shouldRender = state.ecosystemViewActive
1720
- ? scale <= exitThreshold
1721
- : scale <= enterThreshold
1722
- state.ecosystemViewActive = shouldRender
1723
- return shouldRender
1724
- }
1725
-
1726
- const shouldRenderMacroGalaxyView = () => {
1727
- if (!galaxyDiscoveryEnabled) {
1728
- state.macroViewActive = false
1729
- return false
1730
- }
1731
- if (state.visibleNodes.length <= 1) {
1732
- state.macroViewActive = false
1733
- return false
1734
- }
1735
-
1736
- const enterThreshold = macroGalaxyZoomThreshold * macroGalaxyEnterHysteresis
1737
- const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
1738
- const shouldRender = state.macroViewActive
1739
- ? state.transform.scale <= exitThreshold
1740
- : state.transform.scale <= enterThreshold
1741
- state.macroViewActive = shouldRender
1742
- return shouldRender
1743
- }
1744
-
1745
799
  const mergeUniqueNodes = (leftNodes, rightNodes, limit) => {
1746
800
  const merged = []
1747
801
  const ids = new Set()
@@ -1917,20 +971,11 @@ const edgeOpacityForScale = (edge, scale) => {
1917
971
  }
1918
972
 
1919
973
  const edgeDepthOpacity = edge => {
1920
- if (!shouldProjectRenderNodesInDepth()) {
1921
- return 1
1922
- }
1923
- return Math.max(
1924
- graphDepthEdgeOpacityFloor,
1925
- Math.min(nodeRenderOpacity(edge.sourceNode), nodeRenderOpacity(edge.targetNode))
1926
- )
974
+ return 1
1927
975
  }
1928
976
 
1929
977
  const edgeDepthScale = edge => {
1930
- if (!shouldProjectRenderNodesInDepth()) {
1931
- return 1
1932
- }
1933
- return Math.max(0.62, Math.min(1.18, Math.min(nodeRenderScale(edge.sourceNode), nodeRenderScale(edge.targetNode))))
978
+ return 1
1934
979
  }
1935
980
 
1936
981
  const edgeStrokeFor = (edge, selectedEdge) => {
@@ -2113,10 +1158,6 @@ const drawGraphNodes = () => {
2113
1158
  }
2114
1159
  }
2115
1160
 
2116
- if (shouldProjectRenderNodesInDepth()) {
2117
- regularNodes.sort((left, right) => nodeRenderDepth(right) - nodeRenderDepth(left))
2118
- }
2119
-
2120
1161
  drawNodeBatch(regularNodes)
2121
1162
 
2122
1163
  if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
@@ -2193,7 +1234,7 @@ const drawGraphLabels = nodes => {
2193
1234
  }
2194
1235
 
2195
1236
  const drawAcceleratedGraph = (width, height, drawEdges) => {
2196
- if (!webGlRenderer || state.renderClusters.length > 0 || shouldProjectRenderNodesInDepth()) {
1237
+ if (!webGlRenderer) {
2197
1238
  return false
2198
1239
  }
2199
1240
 
@@ -2563,32 +1604,15 @@ const zoomCapByNodeCount = (nodeCount) => {
2563
1604
  return zoomRange.max
2564
1605
  }
2565
1606
 
2566
- const zoomCapByHubDistance = (distance) => {
2567
- if (!Number.isFinite(distance) || distance <= 0) {
2568
- return zoomRange.max
2569
- }
2570
-
2571
- const rect = canvas.getBoundingClientRect()
2572
- const viewportWidth = Math.max(rect.width, 320)
2573
- const viewportHeight = Math.max(rect.height, 320)
2574
- const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
2575
- return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
2576
- }
2577
-
2578
1607
  const currentZoomMax = () => {
2579
1608
  const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
2580
- const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
2581
- ? zoomCapByHubDistance(state.hubNeighborDistance)
2582
- : zoomRange.max
2583
- const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
2584
- const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
2585
- return Math.max(zoomRange.min * 2, capped)
1609
+ return Math.max(zoomRange.min * 2, zoomCapByNodeCount(nodeCount))
2586
1610
  }
2587
1611
 
2588
1612
  const zoomFloorByNodeCount = (nodeCount) => {
2589
1613
  if (nodeCount > massiveGraphNodeThreshold) return 0.018
2590
1614
  if (nodeCount > largeGraphNodeThreshold) return 0.0032
2591
- if (nodeCount > ecosystemActivationNodeThreshold) return 0.001
1615
+ if (nodeCount > 1000) return 0.001
2592
1616
  return zoomRange.min
2593
1617
  }
2594
1618
 
@@ -2700,56 +1724,6 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
2700
1724
  return { min: 0.0085, max: 0.36 }
2701
1725
  }
2702
1726
 
2703
- const macroFaceToFaceScale = (nodeCount, hubDistance) => {
2704
- if (!Number.isFinite(hubDistance) || hubDistance <= 0 || nodeCount <= ecosystemActivationNodeThreshold) {
2705
- return 0
2706
- }
2707
-
2708
- const rect = canvas.getBoundingClientRect()
2709
- const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
2710
- const share = nodeCount > massiveGraphNodeThreshold ? 0.2 : 0.17
2711
- const targetPx = Math.max(24, viewportReference * share)
2712
- return targetPx / hubDistance
2713
- }
2714
-
2715
- const nearestClusterNeighborDistance = (clusters) => {
2716
- if (!Array.isArray(clusters) || clusters.length < 2) {
2717
- return Number.POSITIVE_INFINITY
2718
- }
2719
-
2720
- let nearestDistance = Number.POSITIVE_INFINITY
2721
- for (let index = 0; index < clusters.length; index += 1) {
2722
- const source = clusters[index]
2723
- for (let neighborIndex = index + 1; neighborIndex < clusters.length; neighborIndex += 1) {
2724
- const target = clusters[neighborIndex]
2725
- const distance = Math.hypot(source.x - target.x, source.y - target.y)
2726
- if (distance > 0 && distance < nearestDistance) {
2727
- nearestDistance = distance
2728
- }
2729
- }
2730
- }
2731
-
2732
- return nearestDistance
2733
- }
2734
-
2735
- const macroEcosystemFaceScale = (nodeCount) => {
2736
- if (nodeCount <= ecosystemActivationNodeThreshold) {
2737
- return 0
2738
- }
2739
-
2740
- const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
2741
- const siblingClusters = baseClusters.filter(cluster => !cluster.isHub)
2742
- const nearestDistance = nearestClusterNeighborDistance(siblingClusters)
2743
- if (!Number.isFinite(nearestDistance) || nearestDistance <= 0) {
2744
- return 0
2745
- }
2746
-
2747
- const rect = canvas.getBoundingClientRect()
2748
- const viewportReference = Math.max(320, Math.min(rect.width, rect.height))
2749
- const targetShare = nodeCount > massiveGraphNodeThreshold ? 0.28 : 0.24
2750
- const targetPx = Math.max(30, viewportReference * targetShare)
2751
- return targetPx / nearestDistance
2752
- }
2753
1727
  const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
2754
1728
  const rect = canvas.getBoundingClientRect()
2755
1729
  const width = Math.max(rect.width, 320)
@@ -2782,21 +1756,9 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
2782
1756
  const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
2783
1757
  const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
2784
1758
  const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
2785
- const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
2786
- const scale = options.macro && nodes.length > 1
2787
- ? clampScale(Math.min(baselineScale, macroScale))
2788
- : nodes.length > massiveGraphNodeThreshold
2789
- ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
2790
- : baselineScale
2791
- const macroFloorScale = options.macro
2792
- ? clampScale(Math.max(
2793
- macroFaceToFaceScale(nodes.length, state.hubNeighborDistance),
2794
- macroEcosystemFaceScale(nodes.length)
2795
- ))
2796
- : 0
2797
- const resolvedScale = options.macro
2798
- ? clampScale(Math.max(scale, macroFloorScale))
2799
- : scale
1759
+ const resolvedScale = nodes.length > massiveGraphNodeThreshold
1760
+ ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
1761
+ : baselineScale
2800
1762
  const hubCenter =
2801
1763
  options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
2802
1764
  ? state.primaryHub
@@ -2815,7 +1777,7 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
2815
1777
  markRenderDirty()
2816
1778
  }
2817
1779
 
2818
- const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
1780
+ const resetView = () => fitView({ useFiltered: false, macro: false, preferHubCenter: false })
2819
1781
 
2820
1782
  const focusPrimaryHub = () => {
2821
1783
  const hub = state.primaryHub
@@ -3187,9 +2149,6 @@ const settleNeighborhoodAroundNode = (dragNode) => {
3187
2149
 
3188
2150
  const hitNode = point => {
3189
2151
  computeRenderVisibility()
3190
- if (state.renderClusters.length > 0) {
3191
- return null
3192
- }
3193
2152
  const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
3194
2153
  ? 0.2
3195
2154
  : state.nodes.length > largeGraphNodeThreshold
@@ -3217,124 +2176,13 @@ const baseNodeRadius = node => {
3217
2176
 
3218
2177
  const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
3219
2178
 
3220
- const clusterRadiusPx = cluster => {
3221
- if (cluster.id === 'macro-galaxy') {
3222
- return 10
3223
- }
3224
- if (cluster.isHub) {
3225
- return 3.8
3226
- }
3227
- if (String(cluster.id).startsWith('ecosystem-')) {
3228
- const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
3229
- const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
3230
- const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
3231
- const radius = Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
3232
- const depthScale = Number.isFinite(cluster.depthScale) ? cluster.depthScale : 1
3233
- return Math.max(0.56, Math.min(3.2, radius * depthScale))
3234
- }
3235
- return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
3236
- }
3237
-
3238
- const clusterOpacity = cluster =>
3239
- Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
3240
-
3241
- const clusterDepth = cluster => Number.isFinite(cluster.depth) ? cluster.depth : ecosystemDepthNear
3242
- const clusterDepthScale = cluster => Number.isFinite(cluster.depthScale) ? cluster.depthScale : 1
3243
-
3244
- const shouldProjectRenderNodesInDepth = () => {
3245
- const withinNodeCountWindow =
3246
- state.renderClusters.length === 0 &&
3247
- state.renderNodes.length >= graphDepthProjectionNodeThreshold &&
3248
- state.renderNodes.length <= graphDepthProjectionNodeCap &&
3249
- !state.pointer.down
3250
-
3251
- if (!withinNodeCountWindow) {
3252
- state.depthProjectionActive = false
3253
- return false
3254
- }
3255
-
3256
- const scale = state.transform.scale
3257
- const shouldProject = state.depthProjectionActive
3258
- ? scale >= graphDepthProjectionExitMinScale && scale <= graphDepthProjectionExitMaxScale
3259
- : scale >= graphDepthProjectionEnterMinScale && scale <= graphDepthProjectionEnterMaxScale
3260
- state.depthProjectionActive = shouldProject
3261
- return shouldProject
3262
- }
3263
-
3264
- const nodeProjectionAnchor = () => {
3265
- const hub = state.primaryHub
3266
- if (hub) {
3267
- return { x: hub.x, y: hub.y }
3268
- }
3269
- if (state.macroRepresentative) {
3270
- return { x: state.macroRepresentative.x, y: state.macroRepresentative.y }
3271
- }
3272
- return ecosystemFocusPoint()
3273
- }
3274
-
3275
- const projectGraphNodePoint = (x, y, depth, anchor) => {
3276
- const safeDepth = Math.max(0, depth)
3277
- const dx = x - anchor.x
3278
- const dy = y - anchor.y
3279
- const yawSin = Math.sin(graphDepthYaw)
3280
- const yawCos = Math.cos(graphDepthYaw)
3281
- const pitchSin = Math.sin(graphDepthPitch)
3282
- const pitchCos = Math.cos(graphDepthPitch)
3283
- const rotatedX = dx * yawCos + safeDepth * yawSin
3284
- const rotatedZ = Math.max(0, safeDepth * yawCos - dx * yawSin)
3285
- const rotatedY = dy * pitchCos - rotatedZ * pitchSin
3286
- const projectedDepth = Math.max(0, rotatedZ + Math.max(0, dy * pitchSin))
3287
- const factor = graphDepthPerspective / (graphDepthPerspective + projectedDepth)
3288
- return {
3289
- x: anchor.x + rotatedX * factor,
3290
- y: anchor.y + rotatedY * factor,
3291
- depth: projectedDepth,
3292
- scale: graphDepthMinScale + (1 - graphDepthMinScale) * factor
3293
- }
3294
- }
3295
-
3296
2179
  const refreshRenderNodeDepthProjection = () => {
3297
- if (!shouldProjectRenderNodesInDepth()) {
3298
- state.renderNodeDepthProjectionById = new Map()
3299
- return
3300
- }
3301
-
3302
- const anchor = applyBloomCameraParallax(nodeProjectionAnchor())
3303
- let maxDistance = 1
3304
- for (let index = 0; index < state.renderNodes.length; index += 1) {
3305
- const node = state.renderNodes[index]
3306
- const distance = Math.hypot(node.x - anchor.x, node.y - anchor.y)
3307
- if (distance > maxDistance) {
3308
- maxDistance = distance
3309
- }
3310
- }
3311
-
3312
- const projectionById = new Map()
3313
- for (let index = 0; index < state.renderNodes.length; index += 1) {
3314
- const node = state.renderNodes[index]
3315
- const radialDistance = Math.hypot(node.x - anchor.x, node.y - anchor.y)
3316
- const radialRatio = radialDistance / maxDistance
3317
- const degree = state.nodeDegrees.get(node.id) ?? 0
3318
- const degreeLift = Math.min(60, degree * 2.2)
3319
- const radialDepth = radialRatio * graphDepthFar * graphDepthRadialGain
3320
- const depth = Math.max(0, graphDepthNear + radialDepth - degreeLift)
3321
- const projected = projectGraphNodePoint(node.x, node.y, depth, anchor)
3322
- const opacity = Math.max(graphDepthOpacityFloor, Math.min(1, projected.scale * 1.08))
3323
- projectionById.set(node.id, {
3324
- x: projected.x,
3325
- y: projected.y,
3326
- depth: projected.depth,
3327
- scale: projected.scale,
3328
- opacity
3329
- })
3330
- }
3331
- state.renderNodeDepthProjectionById = projectionById
2180
+ state.renderNodeDepthProjectionById = new Map()
3332
2181
  }
3333
2182
 
3334
2183
  const projectedNode = node => state.renderNodeDepthProjectionById.get(node.id) ?? null
3335
2184
  const nodeRenderX = node => projectedNode(node)?.x ?? node.x
3336
2185
  const nodeRenderY = node => projectedNode(node)?.y ?? node.y
3337
- const nodeRenderDepth = node => projectedNode(node)?.depth ?? graphDepthNear
3338
2186
  const nodeRenderScale = node => projectedNode(node)?.scale ?? 1
3339
2187
  const nodeRenderOpacity = node => projectedNode(node)?.opacity ?? 1
3340
2188
  const worldViewportBounds = () => {
@@ -3390,68 +2238,6 @@ const viewportNodeStride = () => {
3390
2238
  return 8
3391
2239
  }
3392
2240
 
3393
- const shouldRenderClusters = viewportNodes =>
3394
- state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
3395
-
3396
- const clusterViewportNodes = viewportNodes => {
3397
- if (!shouldRenderClusters(viewportNodes)) {
3398
- return []
3399
- }
3400
-
3401
- const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
3402
- const buckets = new Map()
3403
-
3404
- for (let index = 0; index < viewportNodes.length; index += 1) {
3405
- const node = viewportNodes[index]
3406
- const keyX = Math.floor(node.x / worldCellSize)
3407
- const keyY = Math.floor(node.y / worldCellSize)
3408
- const key = keyX + ':' + keyY
3409
- const current = buckets.get(key)
3410
- if (current) {
3411
- current.count += 1
3412
- current.sumX += node.x
3413
- current.sumY += node.y
3414
- if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
3415
- current.representative = node
3416
- current.degree = state.nodeDegrees.get(node.id) ?? 0
3417
- }
3418
- continue
3419
- }
3420
-
3421
- buckets.set(key, {
3422
- id: key,
3423
- count: 1,
3424
- sumX: node.x,
3425
- sumY: node.y,
3426
- representative: node,
3427
- degree: state.nodeDegrees.get(node.id) ?? 0
3428
- })
3429
- }
3430
-
3431
- return Array.from(buckets.values())
3432
- .sort((left, right) => right.count - left.count)
3433
- .slice(0, Math.min(renderNodeBudget, 900))
3434
- .map((cluster) => ({
3435
- id: cluster.id,
3436
- x: cluster.sumX / Math.max(cluster.count, 1),
3437
- y: cluster.sumY / Math.max(cluster.count, 1),
3438
- count: cluster.count,
3439
- representative: cluster.representative
3440
- }))
3441
- }
3442
-
3443
- const representativeNodesFromClusters = (clusters, limit) => {
3444
- const representatives = clusters
3445
- .map((cluster) => cluster.representative)
3446
- .filter((node) => Boolean(node))
3447
- const merged = mergeUniqueNodes(
3448
- representatives,
3449
- state.renderNodes ?? [],
3450
- Math.max(1, limit)
3451
- )
3452
- return ensureHubNodesInRenderedSet(merged)
3453
- }
3454
-
3455
2241
  const computeRenderVisibility = () => {
3456
2242
  if (!hasValidTransform()) {
3457
2243
  fitView({ useFiltered: true })
@@ -3469,56 +2255,9 @@ const computeRenderVisibility = () => {
3469
2255
  }
3470
2256
  state.lastViewportKey = viewportKey
3471
2257
  state.renderVisibilityDirty = false
3472
- state.renderClusterEdges = []
3473
-
3474
- const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
3475
-
3476
- if (shouldRenderMacroGalaxy) {
3477
- const viewportNodes = viewportNodesFromSpatialIndex(viewport)
3478
- const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
3479
- const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
3480
- if (representative) {
3481
- state.renderClusters = [
3482
- {
3483
- id: 'macro-galaxy',
3484
- x: state.macroCenter.x,
3485
- y: state.macroCenter.y,
3486
- count: sourceNodes.length,
3487
- representative
3488
- }
3489
- ]
3490
- state.renderNodes = [representative]
3491
- } else {
3492
- state.renderClusters = []
3493
- state.renderNodes = []
3494
- }
3495
- state.renderEdges = []
3496
- state.renderClusterEdges = []
3497
- return
3498
- }
3499
-
3500
- if (
3501
- state.ecosystemExpansionLevels.length > 0 &&
3502
- shouldRenderEcosystemClusterView(state.visibleNodes.length, state.transform.scale) &&
3503
- state.ecosystemClusters.length > 0
3504
- ) {
3505
- const clusters = selectHierarchicalEcosystemClusters(viewport)
3506
- .sort((left, right) => right.count - left.count)
3507
- const edges = ecosystemEdgesForClusters(clusters)
3508
- const projectionAnchor = ecosystemFocusPoint()
3509
- const projected = applyEcosystemDepthProjection(clusters, edges, projectionAnchor)
3510
- state.renderClusters = projected.clusters
3511
- state.renderClusterEdges = projected.edges
3512
- state.renderNodes = []
3513
- state.renderEdges = []
3514
- return
3515
- }
3516
- state.ecosystemViewActive = false
3517
2258
 
3518
2259
  if (state.visibleNodes.length <= 2000) {
3519
2260
  state.renderNodes = state.visibleNodes
3520
- state.renderClusters = []
3521
- state.renderClusterEdges = []
3522
2261
  const ids = new Set(state.renderNodes.map((node) => node.id))
3523
2262
  state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
3524
2263
  return
@@ -3569,9 +2308,6 @@ const computeRenderVisibility = () => {
3569
2308
  const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
3570
2309
  sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
3571
2310
  }
3572
-
3573
- state.renderClusters = []
3574
- state.renderClusterEdges = []
3575
2311
  state.renderNodes = sampledNodes
3576
2312
  state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
3577
2313
  return
@@ -3580,24 +2316,12 @@ const computeRenderVisibility = () => {
3580
2316
  if (state.transform.scale <= 0.0015) {
3581
2317
  const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
3582
2318
  const sampledIds = new Set(sampled.map((node) => node.id))
3583
- state.renderClusters = []
3584
- state.renderClusterEdges = []
3585
2319
  state.renderNodes = sampled
3586
2320
  state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
3587
2321
  return
3588
2322
  }
3589
2323
 
3590
2324
  const viewportNodes = viewportNodesFromSpatialIndex(viewport)
3591
- const clusters = clusterViewportNodes(viewportNodes)
3592
- if (clusters.length > 0) {
3593
- state.renderClusters = []
3594
- state.renderClusterEdges = []
3595
- state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
3596
- state.renderEdges = []
3597
- return
3598
- }
3599
- state.renderClusters = []
3600
- state.renderClusterEdges = []
3601
2325
  const stride = viewportNodeStride()
3602
2326
  const picked = []
3603
2327
 
@@ -3620,8 +2344,6 @@ const computeRenderVisibility = () => {
3620
2344
  const fallbackNodes = fallbackViewportNodes()
3621
2345
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
3622
2346
  state.renderNodes = fallbackNodes
3623
- state.renderClusters = []
3624
- state.renderClusterEdges = []
3625
2347
  state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
3626
2348
  return
3627
2349
  }
@@ -3636,8 +2358,6 @@ const computeRenderVisibility = () => {
3636
2358
  if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
3637
2359
  const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
3638
2360
  const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
3639
- state.renderClusters = []
3640
- state.renderClusterEdges = []
3641
2361
  state.renderNodes = fallbackNodes
3642
2362
  state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
3643
2363
  }
@@ -3748,71 +2468,9 @@ const render = now => {
3748
2468
  : state.renderNodes.length > 500
3749
2469
  ? 0.05
3750
2470
  : 0
3751
- const drawEdges =
3752
- state.renderClusters.length === 0 &&
3753
- state.transform.scale >= minimumEdgeScale
2471
+ const drawEdges = state.transform.scale >= minimumEdgeScale
3754
2472
  if (drawAcceleratedGraph(width, height, drawEdges)) {
3755
2473
  // WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
3756
- } else if (state.renderClusters.length > 0) {
3757
- ctx.save()
3758
- ctx.translate(state.transform.x, state.transform.y)
3759
- ctx.scale(state.transform.scale, state.transform.scale)
3760
- const orderedClusters = [...state.renderClusters]
3761
- .sort((left, right) => clusterDepth(right) - clusterDepth(left))
3762
- const safeScale = Math.max(state.transform.scale, 0.0001)
3763
- if (state.renderClusterEdges.length > 0) {
3764
- for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
3765
- const edge = state.renderClusterEdges[index]
3766
- const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
3767
- if (edgeOpacity <= 0.01) {
3768
- continue
3769
- }
3770
- const depthScale = Math.min(clusterDepthScale(edge.sourceCluster), clusterDepthScale(edge.targetCluster))
3771
- const widthScale = 0.6 + depthScale * 0.9
3772
- ctx.beginPath()
3773
- ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
3774
- ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
3775
- ctx.lineWidth = (1.2 * widthScale) / safeScale
3776
- ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
3777
- ctx.stroke()
3778
- }
3779
- }
3780
- orderedClusters.forEach(cluster => {
3781
- const isMacro = cluster.id === 'macro-galaxy'
3782
- const isEcosystem = String(cluster.id).startsWith('ecosystem-')
3783
- const isHub = Boolean(cluster.isHub)
3784
- const opacity = clusterOpacity(cluster)
3785
- if (opacity <= 0.01) {
3786
- return
3787
- }
3788
- const radiusPx = clusterRadiusPx(cluster)
3789
- const radius = radiusPx / safeScale
3790
- const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
3791
- ctx.globalAlpha = opacity
3792
- if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
3793
- ctx.beginPath()
3794
- ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
3795
- ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
3796
- ctx.fill()
3797
- }
3798
- ctx.beginPath()
3799
- ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
3800
- ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
3801
- ctx.fill()
3802
- ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
3803
- ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
3804
- ctx.stroke()
3805
- if (isMacro && cluster.representative?.title) {
3806
- ctx.fillStyle = '#edf2f7'
3807
- ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
3808
- ctx.textAlign = 'center'
3809
- ctx.textBaseline = 'top'
3810
- ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
3811
- }
3812
- ctx.globalAlpha = 1
3813
- // Keep cluster markers minimal and faster to draw on large graphs.
3814
- })
3815
- ctx.restore()
3816
2474
  } else {
3817
2475
  ctx.save()
3818
2476
  ctx.translate(state.transform.x, state.transform.y)
@@ -3823,7 +2481,7 @@ const render = now => {
3823
2481
  drawGraphNodes()
3824
2482
  ctx.restore()
3825
2483
  }
3826
- if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
2484
+ if (state.renderNodes.length === 0) {
3827
2485
  ctx.fillStyle = '#99a5b5'
3828
2486
  ctx.font = '12px Inter, system-ui, sans-serif'
3829
2487
  ctx.textAlign = 'center'
@@ -3953,7 +2611,7 @@ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
3953
2611
  state.zoomTransition.screenX === screenX &&
3954
2612
  state.zoomTransition.screenY === screenY
3955
2613
  ? { x: state.zoomTransition.worldX, y: state.zoomTransition.worldY }
3956
- : resolveZoomAnchorWorldPoint(screenX, screenY, source)
2614
+ : resolveZoomAnchorWorldPoint(screenX, screenY)
3957
2615
  const worldX = worldPointAtCursor.x
3958
2616
  const worldY = worldPointAtCursor.y
3959
2617
  state.lastZoomFocus = {
@@ -3983,18 +2641,13 @@ const wheelZoomFactor = event => {
3983
2641
  return 1
3984
2642
  }
3985
2643
 
3986
- const isMassiveEcosystemZoom =
3987
- state.visibleNodes.length > massiveGraphNodeThreshold &&
3988
- state.transform.scale <= massiveEcosystemClusterScaleThreshold
3989
- const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
3990
- const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
3991
2644
  const isZoomOut = normalizedDelta > 0
3992
2645
  const currentScale = state.transform.scale
3993
2646
  const zoomOutDamping = isZoomOut
3994
2647
  ? (currentScale <= 0.03 ? 0.38 : currentScale <= 0.08 ? 0.52 : 0.68)
3995
2648
  : 1
3996
- const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier * zoomOutDamping
3997
- const exponentCap = wheelZoomExponentCap * capMultiplier * (isZoomOut ? 0.74 : 1)
2649
+ const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * zoomOutDamping
2650
+ const exponentCap = wheelZoomExponentCap * (isZoomOut ? 0.74 : 1)
3998
2651
  const exponent = Math.max(
3999
2652
  -exponentCap,
4000
2653
  Math.min(exponentCap, -normalizedDelta * sensitivity)