@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.
- package/README.md +8 -14
- package/dist/application/frontend/client-js.js +16 -1363
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 >
|
|
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
|
|
2786
|
-
|
|
2787
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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) *
|
|
3997
|
-
const exponentCap = wheelZoomExponentCap *
|
|
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)
|