@andespindola/brainlink 0.1.0-beta.113 → 0.1.0-beta.115
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 -13
- package/dist/application/frontend/client-js.js +23 -885
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,12 +84,12 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
84
84
|
- Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
|
|
85
85
|
- Canvas graph rendering uses the same batched node and edge pipeline for every graph size, reducing per-frame draw calls while keeping selected and hovered items highlighted.
|
|
86
86
|
- WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
|
|
87
|
-
- Graph zoom-out
|
|
87
|
+
- Graph zoom-out keeps the same Bloom-like scene model for every graph size: nodes remain part of one flat exploration scene while rendering budgets decide how much visible detail is drawn.
|
|
88
88
|
- Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
|
|
89
89
|
- Large-segment layout spacing now grows logarithmically to keep initial visual density consistent between medium and very large vaults (for example, ~1k vs ~50k notes).
|
|
90
|
-
- Graph coordinates are visually compacted across graph sizes so reset starts from a stable
|
|
91
|
-
- Zoomed-out graph LOD
|
|
92
|
-
- Graph reset
|
|
90
|
+
- Graph coordinates are visually compacted across graph sizes so reset starts from a stable fitted scene and zoom-in progressively reveals local detail.
|
|
91
|
+
- Zoomed-out graph LOD samples visible nodes and priority links without switching to nested subgraphs.
|
|
92
|
+
- Graph reset fits the full graph scene instead of starting in a separate macro overview mode.
|
|
93
93
|
- Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
|
|
94
94
|
- Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
|
|
95
95
|
|
|
@@ -595,21 +595,16 @@ The graph UI shows:
|
|
|
595
595
|
- realtime refresh while `--watch` is enabled
|
|
596
596
|
- graph controls for zoom in, zoom out, fit visible nodes and reset-to-fit-all
|
|
597
597
|
- wheel zoom (including `cmd+scroll` and `ctrl+scroll`) anchored to cursor position for faster navigation in large graphs
|
|
598
|
-
- continuous target-scale interpolation for wheel/button zoom to avoid abrupt
|
|
599
|
-
-
|
|
598
|
+
- continuous target-scale interpolation for wheel/button zoom to avoid abrupt jumps while keeping cursor-anchored focus
|
|
599
|
+
- Bloom-like scene navigation: reset fits the current graph scene, wheel zoom stays anchored to the cursor, and WebGL acceleration is used only to draw the same flat graph faster
|
|
600
|
+
- zoom-out floor for large and massive graphs to keep the scene reachable without switching into a separate macro graph mode
|
|
600
601
|
- keyboard shortcuts: `+` zoom in, `-` zoom out, `0` reset fit
|
|
601
602
|
- double-click on canvas zooms in at cursor position
|
|
602
603
|
- floating graph totals (notes, links, tags) below the Brainlink title
|
|
603
604
|
- graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
|
|
604
605
|
- adaptive CPU safeguards for large graphs: idle frame pacing, throttled background physics updates and cached viewport dimensions to reduce redraw/layout overhead while preserving interaction responsiveness
|
|
605
|
-
- hierarchical hot-path optimizations reduce per-frame allocations and repeated scans during layered cluster expansion and edge projection
|
|
606
|
-
- hierarchical edge projection now caches hub membership and node-to-cluster resolution per frame to keep large recursive subgraph rendering smooth during continuous zoom and pan
|
|
607
|
-
- hierarchical projection now uses stronger perspective yaw/pitch and depth-based render ordering so layered subgraphs read as a true 3D field instead of a flat expansion
|
|
608
|
-
- node rendering also applies depth projection cues on large visible sets (position, scale, opacity and edge depth weighting) so the graph keeps 3D perception when leaving cluster-only LOD layers
|
|
609
606
|
- WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
|
|
610
|
-
-
|
|
611
|
-
- graph camera treats hub-centered navigation as structural only when the hub is dominant; diffuse stress graphs reset and zoom around the full graph mass
|
|
612
|
-
- graph LOD progression: hierarchical rendering now follows one recursive graph-of-graphs standard whenever a graph has more than one hierarchy level; each level expands through intermediate subgraph sizes (instead of jumping directly to leaves), starts from a memory-hub-centered mesh, and each supernode can expand into another same-shape subgraph level (up to 999 children) with latent fade-in, aggregated real links and local sibling mesh links so org-heavy-like and stress-50k-like structures share the same layered behavior at different depths; layered clusters also receive stronger perspective depth projection (Z-depth) with vertical camera tilt/parallax so expansion reads as a true depth field instead of a flat 2D switch; for massive graphs the first expansion starts deeper in zoom and is additionally gated by focus readiness (screen-space isolation of the focused parent) so child levels open only when that subgraph is truly centered and separated in view
|
|
607
|
+
- large graph LOD keeps the same scene model as smaller graphs: visible nodes are sampled near the viewport/focus, real links are prioritized, and no recursive graph-of-graphs or synthetic 3D projection is applied
|
|
613
608
|
|
|
614
609
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
615
610
|
|
|
@@ -23,43 +23,7 @@ const transformCoordinateLimit = 20_000_000
|
|
|
23
23
|
const hoverHitTestIntervalMs = 64
|
|
24
24
|
const ecosystemLevelNodeCap = 999
|
|
25
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
26
|
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
27
|
const zoomRecoveryGuardMs = 4200
|
|
64
28
|
const zoomCapTargetViewportShare = 0.72
|
|
65
29
|
const meshEdgeScaleThreshold = 0.09
|
|
@@ -645,29 +609,15 @@ const recomputeVisibility = () => {
|
|
|
645
609
|
y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
|
|
646
610
|
}
|
|
647
611
|
: { x: 0, y: 0 }
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
hubCluster: null
|
|
658
|
-
}
|
|
659
|
-
state.ecosystemClusters = ecosystemGraph.clusters
|
|
660
|
-
state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
|
|
661
|
-
state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
|
|
662
|
-
state.ecosystemLevelSizes = ecosystemGraph.levelSizes
|
|
663
|
-
state.ecosystemLevelIndexBySize = ecosystemGraph.levelSizes.reduce((map, size, index) => {
|
|
664
|
-
map.set(size, index)
|
|
665
|
-
return map
|
|
666
|
-
}, new Map())
|
|
667
|
-
state.ecosystemHubNodeIds = new Set(ecosystemGraph.hubCluster?.nodeIds ?? [])
|
|
668
|
-
state.ecosystemExpansionLevels = ecosystemGraph.expansionLevels
|
|
669
|
-
state.ecosystemBaseSize = ecosystemGraph.baseSize
|
|
670
|
-
state.ecosystemHubCluster = ecosystemGraph.hubCluster
|
|
612
|
+
state.ecosystemClusters = []
|
|
613
|
+
state.ecosystemClustersBySize = new Map()
|
|
614
|
+
state.ecosystemNodeClusterBySize = new Map()
|
|
615
|
+
state.ecosystemLevelSizes = []
|
|
616
|
+
state.ecosystemLevelIndexBySize = new Map()
|
|
617
|
+
state.ecosystemHubNodeIds = new Set()
|
|
618
|
+
state.ecosystemExpansionLevels = []
|
|
619
|
+
state.ecosystemBaseSize = ecosystemLevelNodeCap
|
|
620
|
+
state.ecosystemHubCluster = null
|
|
671
621
|
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
672
622
|
markRenderDirty()
|
|
673
623
|
}
|
|
@@ -773,271 +723,12 @@ const createVisibleEdgeLookup = edges => {
|
|
|
773
723
|
return lookup
|
|
774
724
|
}
|
|
775
725
|
|
|
776
|
-
const ecosystemKeyForNode = node => {
|
|
777
|
-
if (typeof node.segment === 'string' && node.segment.trim()) {
|
|
778
|
-
return node.segment.trim()
|
|
779
|
-
}
|
|
780
|
-
if (typeof node.group === 'string' && node.group.trim()) {
|
|
781
|
-
return node.group.trim()
|
|
782
|
-
}
|
|
783
|
-
const pathParts = String(node.path || '')
|
|
784
|
-
.split('/')
|
|
785
|
-
.filter(part => part.trim())
|
|
786
|
-
.slice(0, 2)
|
|
787
|
-
return pathParts.length > 0 ? pathParts.join('/') : 'root'
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const compareNodesForEcosystem = (left, right) => {
|
|
791
|
-
const keyComparison = ecosystemKeyForNode(left).localeCompare(ecosystemKeyForNode(right))
|
|
792
|
-
if (keyComparison !== 0) return keyComparison
|
|
793
|
-
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
794
|
-
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
795
|
-
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
796
|
-
return String(left.title || left.id).localeCompare(String(right.title || right.id))
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const selectEcosystemRepresentative = nodes => {
|
|
800
|
-
let representative = nodes[0] ?? null
|
|
801
|
-
let representativeScore = Number.NEGATIVE_INFINITY
|
|
802
|
-
|
|
803
|
-
for (let index = 0; index < nodes.length; index += 1) {
|
|
804
|
-
const node = nodes[index]
|
|
805
|
-
const score = (state.nodeDegrees.get(node.id) ?? 0) + hubNodeScore(node) * 1000
|
|
806
|
-
if (score > representativeScore) {
|
|
807
|
-
representative = node
|
|
808
|
-
representativeScore = score
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
return representative
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
const ecosystemLayoutSpacingForSize = size => {
|
|
816
|
-
if (size >= ecosystemLevelNodeCap) return 260
|
|
817
|
-
if (size >= 320) return 110
|
|
818
|
-
if (size >= 120) return 64
|
|
819
|
-
if (size >= 48) return 34
|
|
820
|
-
if (size >= 18) return 18
|
|
821
|
-
if (size >= 8) return 11
|
|
822
|
-
return 7
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
const buildIntermediateEcosystemSizes = (fromSize, toSize) => {
|
|
826
|
-
if (fromSize <= toSize + 1) {
|
|
827
|
-
return []
|
|
828
|
-
}
|
|
829
|
-
const intermediate = []
|
|
830
|
-
let current = fromSize
|
|
831
|
-
while (current > toSize + 1) {
|
|
832
|
-
const stepped = Math.max(toSize + 1, Math.ceil(current / 3))
|
|
833
|
-
if (stepped >= current) {
|
|
834
|
-
break
|
|
835
|
-
}
|
|
836
|
-
intermediate.push(stepped)
|
|
837
|
-
current = stepped
|
|
838
|
-
}
|
|
839
|
-
return intermediate
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
const buildEcosystemLevelSizes = nodeCount => {
|
|
843
|
-
if (nodeCount <= 0) return []
|
|
844
|
-
const primarySizes = []
|
|
845
|
-
let currentSize = Math.max(1, Math.ceil(nodeCount / ecosystemLevelNodeCap))
|
|
846
|
-
while (currentSize >= 1) {
|
|
847
|
-
primarySizes.push(currentSize)
|
|
848
|
-
if (currentSize === 1) {
|
|
849
|
-
break
|
|
850
|
-
}
|
|
851
|
-
const nextSize = Math.max(1, Math.ceil(currentSize / ecosystemLevelNodeCap))
|
|
852
|
-
if (nextSize === currentSize) {
|
|
853
|
-
break
|
|
854
|
-
}
|
|
855
|
-
currentSize = nextSize
|
|
856
|
-
}
|
|
857
|
-
const expandedSizes = []
|
|
858
|
-
for (let index = 0; index < primarySizes.length; index += 1) {
|
|
859
|
-
const size = primarySizes[index]
|
|
860
|
-
if (expandedSizes.length === 0 || expandedSizes[expandedSizes.length - 1] !== size) {
|
|
861
|
-
expandedSizes.push(size)
|
|
862
|
-
}
|
|
863
|
-
const nextSize = primarySizes[index + 1]
|
|
864
|
-
if (!Number.isFinite(nextSize)) {
|
|
865
|
-
continue
|
|
866
|
-
}
|
|
867
|
-
const intermediate = buildIntermediateEcosystemSizes(size, nextSize)
|
|
868
|
-
for (let intermediateIndex = 0; intermediateIndex < intermediate.length; intermediateIndex += 1) {
|
|
869
|
-
const candidate = intermediate[intermediateIndex]
|
|
870
|
-
if (expandedSizes[expandedSizes.length - 1] !== candidate) {
|
|
871
|
-
expandedSizes.push(candidate)
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
if (expandedSizes[expandedSizes.length - 1] !== 1) {
|
|
876
|
-
expandedSizes.push(1)
|
|
877
|
-
}
|
|
878
|
-
return expandedSizes
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
const buildEcosystemExpansionLevels = (levelSizes, nodeCount) => {
|
|
882
|
-
if (levelSizes.length <= 1) {
|
|
883
|
-
return []
|
|
884
|
-
}
|
|
885
|
-
const isMassive = nodeCount > massiveGraphNodeThreshold
|
|
886
|
-
const maxScale = isMassive
|
|
887
|
-
? massiveEcosystemClusterScaleThreshold
|
|
888
|
-
: ecosystemClusterScaleThreshold
|
|
889
|
-
const startScale = isMassive ? 1.12 : 0.24
|
|
890
|
-
const transitionCount = levelSizes.length - 1
|
|
891
|
-
const usableScale = Math.max(0.08, maxScale - startScale)
|
|
892
|
-
const step = usableScale / transitionCount
|
|
893
|
-
const stride = isMassive ? 0.93 : 0.82
|
|
894
|
-
const overlap = isMassive ? 1.22 : 1.62
|
|
895
|
-
const levels = []
|
|
896
|
-
for (let index = 0; index < transitionCount; index += 1) {
|
|
897
|
-
const start = startScale + step * index * stride
|
|
898
|
-
const end = Math.min(maxScale, start + step * overlap)
|
|
899
|
-
levels.push({
|
|
900
|
-
parentSize: levelSizes[index],
|
|
901
|
-
childSize: levelSizes[index + 1],
|
|
902
|
-
start,
|
|
903
|
-
end
|
|
904
|
-
})
|
|
905
|
-
}
|
|
906
|
-
return levels
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
const ecosystemCompactPoint = (index, total, center, spacing) => {
|
|
910
|
-
if (total <= 1) {
|
|
911
|
-
return { x: center.x, y: center.y }
|
|
912
|
-
}
|
|
913
|
-
const angle = index * 2.399963229728653
|
|
914
|
-
const radius = spacing * Math.sqrt(index + 1)
|
|
915
|
-
return {
|
|
916
|
-
x: center.x + Math.cos(angle) * radius,
|
|
917
|
-
y: center.y + Math.sin(angle) * radius
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
const buildEcosystemCluster = (nodes, index, point) => {
|
|
922
|
-
const count = Math.max(nodes.length, 1)
|
|
923
|
-
const representative = selectEcosystemRepresentative(nodes)
|
|
924
|
-
|
|
925
|
-
return {
|
|
926
|
-
id: 'ecosystem-' + index,
|
|
927
|
-
x: point.x,
|
|
928
|
-
y: point.y,
|
|
929
|
-
count,
|
|
930
|
-
nodeIds: nodes.map(node => node.id),
|
|
931
|
-
representative,
|
|
932
|
-
label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
const buildEcosystemHubCluster = (hub, center) => hub
|
|
937
|
-
? {
|
|
938
|
-
id: 'ecosystem-hub',
|
|
939
|
-
x: center.x,
|
|
940
|
-
y: center.y,
|
|
941
|
-
count: 1,
|
|
942
|
-
size: 1,
|
|
943
|
-
nodeIds: [hub.id],
|
|
944
|
-
representative: hub,
|
|
945
|
-
label: hub.title || 'Memory Hub',
|
|
946
|
-
parentId: null,
|
|
947
|
-
parentX: null,
|
|
948
|
-
parentY: null,
|
|
949
|
-
isHub: true
|
|
950
|
-
}
|
|
951
|
-
: null
|
|
952
|
-
|
|
953
|
-
const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
|
|
954
|
-
const clusters = []
|
|
955
|
-
const clusterByNodeId = new Map()
|
|
956
|
-
const parentChildIndex = new Map()
|
|
957
|
-
|
|
958
|
-
for (let offset = 0; offset < sortedNodes.length; offset += size) {
|
|
959
|
-
const clusterNodes = sortedNodes.slice(offset, offset + size)
|
|
960
|
-
const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
|
|
961
|
-
const siblingIndex = parentCluster
|
|
962
|
-
? (parentChildIndex.get(parentCluster.id) ?? 0)
|
|
963
|
-
: clusters.length
|
|
964
|
-
if (parentCluster) {
|
|
965
|
-
parentChildIndex.set(parentCluster.id, siblingIndex + 1)
|
|
966
|
-
}
|
|
967
|
-
const point = parentCluster
|
|
968
|
-
? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
|
|
969
|
-
: ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
|
|
970
|
-
const cluster = {
|
|
971
|
-
...buildEcosystemCluster(clusterNodes, clusters.length, point),
|
|
972
|
-
id: 'ecosystem-' + size + '-' + clusters.length,
|
|
973
|
-
size,
|
|
974
|
-
parentId: parentCluster?.id ?? null,
|
|
975
|
-
parentX: parentCluster?.x ?? null,
|
|
976
|
-
parentY: parentCluster?.y ?? null
|
|
977
|
-
}
|
|
978
|
-
clusters.push(cluster)
|
|
979
|
-
for (let index = 0; index < clusterNodes.length; index += 1) {
|
|
980
|
-
clusterByNodeId.set(clusterNodes[index].id, cluster)
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
return { clusters, clusterByNodeId }
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
const buildEcosystemGraph = (nodes, center, hub) => {
|
|
988
|
-
if (nodes.length === 0) {
|
|
989
|
-
return {
|
|
990
|
-
clusters: [],
|
|
991
|
-
clustersBySize: new Map(),
|
|
992
|
-
nodeClusterBySize: new Map(),
|
|
993
|
-
levelSizes: [],
|
|
994
|
-
expansionLevels: [],
|
|
995
|
-
baseSize: ecosystemLevelNodeCap,
|
|
996
|
-
hubCluster: null
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
const hubCluster = buildEcosystemHubCluster(hub, center)
|
|
1001
|
-
const sortedNodes = nodes
|
|
1002
|
-
.filter(node => node.id !== hub?.id)
|
|
1003
|
-
.sort(compareNodesForEcosystem)
|
|
1004
|
-
const levelSizes = buildEcosystemLevelSizes(sortedNodes.length)
|
|
1005
|
-
const expansionLevels = buildEcosystemExpansionLevels(levelSizes, nodes.length)
|
|
1006
|
-
const baseSize = levelSizes[0] ?? ecosystemLevelNodeCap
|
|
1007
|
-
const clustersBySize = new Map()
|
|
1008
|
-
const nodeClusterBySize = new Map()
|
|
1009
|
-
let parentLookup = null
|
|
1010
|
-
|
|
1011
|
-
for (let index = 0; index < levelSizes.length; index += 1) {
|
|
1012
|
-
const size = levelSizes[index]
|
|
1013
|
-
const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
|
|
1014
|
-
clustersBySize.set(size, level.clusters)
|
|
1015
|
-
nodeClusterBySize.set(size, level.clusterByNodeId)
|
|
1016
|
-
parentLookup = level.clusterByNodeId
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
return {
|
|
1020
|
-
clusters: clustersBySize.get(baseSize) ?? [],
|
|
1021
|
-
clustersBySize,
|
|
1022
|
-
nodeClusterBySize,
|
|
1023
|
-
levelSizes,
|
|
1024
|
-
expansionLevels,
|
|
1025
|
-
baseSize,
|
|
1026
|
-
hubCluster
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
726
|
const isClusterInViewport = (cluster, viewport) =>
|
|
1031
727
|
cluster.x >= viewport.minX &&
|
|
1032
728
|
cluster.x <= viewport.maxX &&
|
|
1033
729
|
cluster.y >= viewport.minY &&
|
|
1034
730
|
cluster.y <= viewport.maxY
|
|
1035
731
|
|
|
1036
|
-
const filterEcosystemClustersByViewport = (clusters, viewport) => {
|
|
1037
|
-
const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
|
|
1038
|
-
return visible.length > 0 ? visible : [...clusters]
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
732
|
const ecosystemFocusPoint = () => {
|
|
1042
733
|
const cursorPoint = cursorWorldPoint()
|
|
1043
734
|
if (cursorPoint) {
|
|
@@ -1050,422 +741,6 @@ const ecosystemFocusPoint = () => {
|
|
|
1050
741
|
return viewportCenterWorldPoint()
|
|
1051
742
|
}
|
|
1052
743
|
|
|
1053
|
-
const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
|
|
1054
|
-
clusters
|
|
1055
|
-
.map(cluster => ({
|
|
1056
|
-
cluster,
|
|
1057
|
-
distance: Math.max(
|
|
1058
|
-
0,
|
|
1059
|
-
Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
|
|
1060
|
-
clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
|
|
1061
|
-
)
|
|
1062
|
-
}))
|
|
1063
|
-
.sort((left, right) => left.distance - right.distance)
|
|
1064
|
-
.slice(0, limit)
|
|
1065
|
-
.map(item => item.cluster.id)
|
|
1066
|
-
|
|
1067
|
-
const focusedParentCluster = (clusters, focusPoint) => {
|
|
1068
|
-
if (clusters.length === 0) {
|
|
1069
|
-
return null
|
|
1070
|
-
}
|
|
1071
|
-
let focused = clusters[0]
|
|
1072
|
-
let nearestDistance = Number.POSITIVE_INFINITY
|
|
1073
|
-
for (let index = 0; index < clusters.length; index += 1) {
|
|
1074
|
-
const cluster = clusters[index]
|
|
1075
|
-
const distance = Math.max(
|
|
1076
|
-
0,
|
|
1077
|
-
Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
|
|
1078
|
-
clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
|
|
1079
|
-
)
|
|
1080
|
-
if (distance < nearestDistance) {
|
|
1081
|
-
nearestDistance = distance
|
|
1082
|
-
focused = cluster
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
return focused
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
const nearestSiblingScreenDistancePx = (focusedCluster, clusters) => {
|
|
1089
|
-
let nearestDistancePx = Number.POSITIVE_INFINITY
|
|
1090
|
-
for (let index = 0; index < clusters.length; index += 1) {
|
|
1091
|
-
const cluster = clusters[index]
|
|
1092
|
-
if (cluster.id === focusedCluster.id) continue
|
|
1093
|
-
const distancePx = Math.hypot(
|
|
1094
|
-
(cluster.x - focusedCluster.x) * state.transform.scale,
|
|
1095
|
-
(cluster.y - focusedCluster.y) * state.transform.scale
|
|
1096
|
-
)
|
|
1097
|
-
if (distancePx < nearestDistancePx) {
|
|
1098
|
-
nearestDistancePx = distancePx
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
return nearestDistancePx
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
const ecosystemFocusReadiness = (parentClusters, focusPoint, childSize) => {
|
|
1105
|
-
if (parentClusters.length <= 1) {
|
|
1106
|
-
return 1
|
|
1107
|
-
}
|
|
1108
|
-
const focusedCluster = focusedParentCluster(parentClusters, focusPoint)
|
|
1109
|
-
if (!focusedCluster) {
|
|
1110
|
-
return 0
|
|
1111
|
-
}
|
|
1112
|
-
const nearestDistancePx = nearestSiblingScreenDistancePx(focusedCluster, parentClusters)
|
|
1113
|
-
const sizeHalfCap = Math.ceil(ecosystemLevelNodeCap / 2)
|
|
1114
|
-
const sizeEighthCap = Math.ceil(ecosystemLevelNodeCap / 8)
|
|
1115
|
-
const focusDistanceTargetPx = childSize >= sizeHalfCap
|
|
1116
|
-
? 680
|
|
1117
|
-
: childSize >= sizeEighthCap
|
|
1118
|
-
? 520
|
|
1119
|
-
: 380
|
|
1120
|
-
const focusDistanceRangePx = childSize >= sizeHalfCap ? 260 : 220
|
|
1121
|
-
return smoothStep((nearestDistancePx - focusDistanceTargetPx) / focusDistanceRangePx)
|
|
1122
|
-
}
|
|
1123
|
-
const smoothStep = value => {
|
|
1124
|
-
const clamped = Math.max(0, Math.min(1, value))
|
|
1125
|
-
return clamped * clamped * (3 - clamped * 2)
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
const zoomProgress = (scale, start, end) =>
|
|
1129
|
-
smoothStep((scale - start) / Math.max(end - start, 0.0001))
|
|
1130
|
-
|
|
1131
|
-
const semanticZoomSpread = (progress, childSize) => {
|
|
1132
|
-
const spreadExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 5.6 : 4.2
|
|
1133
|
-
const curve = Math.pow(progress, spreadExponent)
|
|
1134
|
-
if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
|
|
1135
|
-
return 0.12 + curve * 0.88
|
|
1136
|
-
}
|
|
1137
|
-
return curve
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
const opacityForProgress = (progress, childSize) => {
|
|
1141
|
-
const opacityExponent = childSize <= Math.ceil(ecosystemLevelNodeCap / 12) ? 2.8 : 2.1
|
|
1142
|
-
const eased = Math.pow(progress, opacityExponent)
|
|
1143
|
-
if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
|
|
1144
|
-
return 0.22 + eased * 0.78
|
|
1145
|
-
}
|
|
1146
|
-
return eased
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
const expandFocusedClusters = (parentClusters, focusPoint, childSize, progress, spread, viewport) => {
|
|
1150
|
-
const expandedParentIds = new Set(nearestEcosystemParentIds(
|
|
1151
|
-
parentClusters,
|
|
1152
|
-
focusPoint,
|
|
1153
|
-
ecosystemFocusedParentLimit
|
|
1154
|
-
))
|
|
1155
|
-
const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
|
|
1156
|
-
const visibleChildClusters = []
|
|
1157
|
-
for (let index = 0; index < childClusters.length; index += 1) {
|
|
1158
|
-
const cluster = childClusters[index]
|
|
1159
|
-
if (!expandedParentIds.has(cluster.parentId)) {
|
|
1160
|
-
continue
|
|
1161
|
-
}
|
|
1162
|
-
const spreadCluster = spreadChildClusterFromParent(cluster, childSize, progress, spread)
|
|
1163
|
-
if (!isClusterInViewport(spreadCluster, viewport)) {
|
|
1164
|
-
continue
|
|
1165
|
-
}
|
|
1166
|
-
visibleChildClusters.push(spreadCluster)
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
return {
|
|
1170
|
-
expandedParentIds,
|
|
1171
|
-
childClusters: visibleChildClusters
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
const spreadChildClusterFromParent = (cluster, childSize, progress, spread) => {
|
|
1176
|
-
if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
|
|
1177
|
-
return {
|
|
1178
|
-
...cluster,
|
|
1179
|
-
lodOpacity: opacityForProgress(progress, childSize)
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
return {
|
|
1184
|
-
...cluster,
|
|
1185
|
-
x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
|
|
1186
|
-
y: cluster.parentY + (cluster.y - cluster.parentY) * spread,
|
|
1187
|
-
lodOpacity: opacityForProgress(progress, childSize)
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
const selectHierarchicalEcosystemClusters = viewport => {
|
|
1192
|
-
const baseClusters = state.ecosystemClustersBySize.get(state.ecosystemBaseSize) ?? state.ecosystemClusters
|
|
1193
|
-
const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
|
|
1194
|
-
const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
|
|
1195
|
-
const visibleClusters = [...visibleBaseClusters]
|
|
1196
|
-
const clustersBySize = new Map()
|
|
1197
|
-
for (let index = 0; index < visibleBaseClusters.length; index += 1) {
|
|
1198
|
-
const cluster = visibleBaseClusters[index]
|
|
1199
|
-
const levelClusters = clustersBySize.get(cluster.size)
|
|
1200
|
-
if (levelClusters) {
|
|
1201
|
-
levelClusters.push(cluster)
|
|
1202
|
-
} else {
|
|
1203
|
-
clustersBySize.set(cluster.size, [cluster])
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
const focusPoint = ecosystemFocusPoint()
|
|
1207
|
-
|
|
1208
|
-
for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
|
|
1209
|
-
const level = state.ecosystemExpansionLevels[index]
|
|
1210
|
-
const parentClusters = clustersBySize.get(level.parentSize) ?? []
|
|
1211
|
-
if (parentClusters.length === 0) {
|
|
1212
|
-
continue
|
|
1213
|
-
}
|
|
1214
|
-
const zoomLevelProgress = zoomProgress(state.transform.scale, level.start, level.end)
|
|
1215
|
-
const focusReadiness = ecosystemFocusReadiness(parentClusters, focusPoint, level.childSize)
|
|
1216
|
-
const progress = zoomLevelProgress * focusReadiness
|
|
1217
|
-
if (progress <= 0.002) {
|
|
1218
|
-
continue
|
|
1219
|
-
}
|
|
1220
|
-
const spread = semanticZoomSpread(progress, level.childSize)
|
|
1221
|
-
const expansion = expandFocusedClusters(parentClusters, focusPoint, level.childSize, progress, spread, viewport)
|
|
1222
|
-
visibleClusters.push(...expansion.childClusters)
|
|
1223
|
-
if (expansion.childClusters.length > 0) {
|
|
1224
|
-
const levelClusters = clustersBySize.get(level.childSize)
|
|
1225
|
-
if (levelClusters) {
|
|
1226
|
-
levelClusters.push(...expansion.childClusters)
|
|
1227
|
-
} else {
|
|
1228
|
-
clustersBySize.set(level.childSize, [...expansion.childClusters])
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
return [...hubClusters, ...visibleClusters]
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
const ecosystemLevelIndexBySize = () => state.ecosystemLevelIndexBySize
|
|
1237
|
-
|
|
1238
|
-
const ecosystemDepthForCluster = (cluster, levelIndexMap) => {
|
|
1239
|
-
if (cluster.isHub) {
|
|
1240
|
-
return ecosystemDepthNear
|
|
1241
|
-
}
|
|
1242
|
-
const maxLevelIndex = Math.max(state.ecosystemLevelSizes.length - 1, 0)
|
|
1243
|
-
const levelIndex = levelIndexMap.get(cluster.size) ?? 0
|
|
1244
|
-
const reverseIndex = Math.max(0, maxLevelIndex - levelIndex)
|
|
1245
|
-
const normalized = maxLevelIndex === 0 ? 0 : reverseIndex / maxLevelIndex
|
|
1246
|
-
return ecosystemDepthNear + normalized * (ecosystemDepthFar - ecosystemDepthNear)
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
const projectEcosystemPoint = (x, y, depth, anchor) => {
|
|
1250
|
-
const safeDepth = Math.max(0, depth)
|
|
1251
|
-
const dx = x - anchor.x
|
|
1252
|
-
const dy = y - anchor.y
|
|
1253
|
-
const yawSin = Math.sin(ecosystemDepthYaw)
|
|
1254
|
-
const yawCos = Math.cos(ecosystemDepthYaw)
|
|
1255
|
-
const pitchSin = Math.sin(ecosystemDepthPitch)
|
|
1256
|
-
const pitchCos = Math.cos(ecosystemDepthPitch)
|
|
1257
|
-
const rotatedX = dx * yawCos + safeDepth * yawSin
|
|
1258
|
-
const rotatedZ = Math.max(0, safeDepth * yawCos - dx * yawSin)
|
|
1259
|
-
const rotatedY = dy * pitchCos - rotatedZ * pitchSin
|
|
1260
|
-
const projectedDepth = Math.max(0, rotatedZ + Math.max(0, dy * pitchSin))
|
|
1261
|
-
const factor = ecosystemDepthPerspective / (ecosystemDepthPerspective + projectedDepth)
|
|
1262
|
-
const verticalTilt = projectedDepth * ecosystemDepthTiltY
|
|
1263
|
-
return {
|
|
1264
|
-
x: anchor.x + rotatedX * factor,
|
|
1265
|
-
y: anchor.y + rotatedY * factor - verticalTilt,
|
|
1266
|
-
factor,
|
|
1267
|
-
projectedDepth
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
const applyEcosystemDepthProjection = (clusters, edges, anchor) => {
|
|
1272
|
-
const levelIndexMap = ecosystemLevelIndexBySize()
|
|
1273
|
-
const projectedClusters = []
|
|
1274
|
-
const clusterById = new Map()
|
|
1275
|
-
|
|
1276
|
-
for (let index = 0; index < clusters.length; index += 1) {
|
|
1277
|
-
const cluster = clusters[index]
|
|
1278
|
-
const baseDepth = ecosystemDepthForCluster(cluster, levelIndexMap)
|
|
1279
|
-
const radialDistance = Math.hypot(cluster.x - anchor.x, cluster.y - anchor.y)
|
|
1280
|
-
const radialOffset = cluster.isHub ? 0 : Math.min(320, radialDistance * ecosystemDepthRadialGain)
|
|
1281
|
-
const orbitalOffset = cluster.isHub
|
|
1282
|
-
? 0
|
|
1283
|
-
: Math.sin(Math.atan2(cluster.y - anchor.y, cluster.x - anchor.x) * 2.2) * ecosystemDepthOrbitalMaxOffset
|
|
1284
|
-
const depth = Math.max(0, baseDepth + radialOffset + orbitalOffset)
|
|
1285
|
-
const projected = projectEcosystemPoint(cluster.x, cluster.y, depth, anchor)
|
|
1286
|
-
const baseOpacity = Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1
|
|
1287
|
-
const depthScale = ecosystemDepthMinScale + (1 - ecosystemDepthMinScale) * projected.factor
|
|
1288
|
-
const depthOpacity = Math.max(
|
|
1289
|
-
ecosystemDepthOpacityFloor,
|
|
1290
|
-
Math.min(1, depthScale * 1.08)
|
|
1291
|
-
)
|
|
1292
|
-
const projectedCluster = {
|
|
1293
|
-
...cluster,
|
|
1294
|
-
x: projected.x,
|
|
1295
|
-
y: projected.y,
|
|
1296
|
-
lodOpacity: baseOpacity * depthOpacity,
|
|
1297
|
-
depth: projected.projectedDepth,
|
|
1298
|
-
depthScale
|
|
1299
|
-
}
|
|
1300
|
-
projectedClusters.push(projectedCluster)
|
|
1301
|
-
clusterById.set(projectedCluster.id, projectedCluster)
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
const projectedEdges = []
|
|
1305
|
-
for (let index = 0; index < edges.length; index += 1) {
|
|
1306
|
-
const edge = edges[index]
|
|
1307
|
-
const sourceCluster = clusterById.get(edge.sourceCluster.id)
|
|
1308
|
-
const targetCluster = clusterById.get(edge.targetCluster.id)
|
|
1309
|
-
if (!sourceCluster || !targetCluster) {
|
|
1310
|
-
continue
|
|
1311
|
-
}
|
|
1312
|
-
projectedEdges.push({
|
|
1313
|
-
...edge,
|
|
1314
|
-
sourceCluster,
|
|
1315
|
-
targetCluster
|
|
1316
|
-
})
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
return {
|
|
1320
|
-
clusters: projectedClusters,
|
|
1321
|
-
edges: projectedEdges
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
const ecosystemSiblingEdgesForClusters = (clusters, existingEdges) => {
|
|
1326
|
-
const byParent = new Map()
|
|
1327
|
-
for (let index = 0; index < clusters.length; index += 1) {
|
|
1328
|
-
const cluster = clusters[index]
|
|
1329
|
-
if (cluster.isHub || !cluster.parentId) {
|
|
1330
|
-
continue
|
|
1331
|
-
}
|
|
1332
|
-
const siblings = byParent.get(cluster.parentId)
|
|
1333
|
-
if (siblings) {
|
|
1334
|
-
siblings.push(cluster)
|
|
1335
|
-
} else {
|
|
1336
|
-
byParent.set(cluster.parentId, [cluster])
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
const edges = []
|
|
1341
|
-
for (const siblings of byParent.values()) {
|
|
1342
|
-
const ordered = [...siblings]
|
|
1343
|
-
.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)))
|
|
1344
|
-
for (let index = 0; index < ordered.length && edges.length < ecosystemSiblingEdgeLimit; index += 1) {
|
|
1345
|
-
const sourceCluster = ordered[index]
|
|
1346
|
-
const targetCluster = ordered[(index + 1) % ordered.length]
|
|
1347
|
-
if (!targetCluster || sourceCluster.id === targetCluster.id) {
|
|
1348
|
-
continue
|
|
1349
|
-
}
|
|
1350
|
-
const orderedIds = sourceCluster.id < targetCluster.id
|
|
1351
|
-
? [sourceCluster.id, targetCluster.id]
|
|
1352
|
-
: [targetCluster.id, sourceCluster.id]
|
|
1353
|
-
const key = orderedIds.join(':')
|
|
1354
|
-
if (existingEdges.has(key)) {
|
|
1355
|
-
continue
|
|
1356
|
-
}
|
|
1357
|
-
const edge = {
|
|
1358
|
-
id: key,
|
|
1359
|
-
sourceCluster,
|
|
1360
|
-
targetCluster,
|
|
1361
|
-
weight: 0.7,
|
|
1362
|
-
inferred: true
|
|
1363
|
-
}
|
|
1364
|
-
existingEdges.set(key, edge)
|
|
1365
|
-
edges.push(edge)
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
return edges
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
const ecosystemEdgesForClusters = clusters => {
|
|
1373
|
-
const edgeClusters = clusters.filter(cluster => cluster.isHub || clusterOpacity(cluster) > 0.018)
|
|
1374
|
-
const clusterById = new Map(edgeClusters.map(cluster => [cluster.id, cluster]))
|
|
1375
|
-
const clusterIds = new Set(clusterById.keys())
|
|
1376
|
-
const levelsBySize = []
|
|
1377
|
-
const seenSizes = new Set()
|
|
1378
|
-
for (let index = 0; index < edgeClusters.length; index += 1) {
|
|
1379
|
-
const cluster = edgeClusters[index]
|
|
1380
|
-
if (!cluster.size || cluster.isHub) continue
|
|
1381
|
-
if (seenSizes.has(cluster.size)) continue
|
|
1382
|
-
seenSizes.add(cluster.size)
|
|
1383
|
-
levelsBySize.push({
|
|
1384
|
-
size: cluster.size,
|
|
1385
|
-
lookup: state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map()
|
|
1386
|
-
})
|
|
1387
|
-
}
|
|
1388
|
-
levelsBySize.sort((left, right) => left.size - right.size)
|
|
1389
|
-
const resolvedNodeClusterById = new Map()
|
|
1390
|
-
const resolveClusterForNode = nodeId => {
|
|
1391
|
-
if (resolvedNodeClusterById.has(nodeId)) {
|
|
1392
|
-
return resolvedNodeClusterById.get(nodeId)
|
|
1393
|
-
}
|
|
1394
|
-
if (state.ecosystemHubNodeIds.has(nodeId) && state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)) {
|
|
1395
|
-
resolvedNodeClusterById.set(nodeId, state.ecosystemHubCluster)
|
|
1396
|
-
return state.ecosystemHubCluster
|
|
1397
|
-
}
|
|
1398
|
-
for (let index = 0; index < levelsBySize.length; index += 1) {
|
|
1399
|
-
const lookup = levelsBySize[index].lookup
|
|
1400
|
-
const cluster = lookup.get(nodeId)
|
|
1401
|
-
if (cluster && clusterIds.has(cluster.id)) {
|
|
1402
|
-
const resolvedCluster = clusterById.get(cluster.id) ?? cluster
|
|
1403
|
-
resolvedNodeClusterById.set(nodeId, resolvedCluster)
|
|
1404
|
-
return resolvedCluster
|
|
1405
|
-
}
|
|
1406
|
-
}
|
|
1407
|
-
resolvedNodeClusterById.set(nodeId, null)
|
|
1408
|
-
return null
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
const edgeByClusterPair = new Map()
|
|
1412
|
-
for (let index = 0; index < state.visibleEdges.length; index += 1) {
|
|
1413
|
-
const edge = state.visibleEdges[index]
|
|
1414
|
-
const sourceCluster = resolveClusterForNode(edge.source)
|
|
1415
|
-
const targetCluster = resolveClusterForNode(edge.target)
|
|
1416
|
-
if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
|
|
1417
|
-
continue
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
const orderedIds = sourceCluster.id < targetCluster.id
|
|
1421
|
-
? [sourceCluster.id, targetCluster.id]
|
|
1422
|
-
: [targetCluster.id, sourceCluster.id]
|
|
1423
|
-
const key = orderedIds.join(':')
|
|
1424
|
-
const current = edgeByClusterPair.get(key)
|
|
1425
|
-
if (current) {
|
|
1426
|
-
current.weight += edgeWeight(edge)
|
|
1427
|
-
continue
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
edgeByClusterPair.set(key, {
|
|
1431
|
-
id: key,
|
|
1432
|
-
sourceCluster,
|
|
1433
|
-
targetCluster,
|
|
1434
|
-
weight: edgeWeight(edge)
|
|
1435
|
-
})
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
ecosystemSiblingEdgesForClusters(edgeClusters, edgeByClusterPair)
|
|
1439
|
-
const edges = Array.from(edgeByClusterPair.values())
|
|
1440
|
-
.sort((left, right) => right.weight - left.weight)
|
|
1441
|
-
.slice(0, ecosystemClusterEdgeLimit)
|
|
1442
|
-
const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
|
|
1443
|
-
? state.ecosystemHubCluster
|
|
1444
|
-
: null
|
|
1445
|
-
if (!hubCluster) {
|
|
1446
|
-
return edges
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
const existingHubTargets = new Set(edges.flatMap(edge =>
|
|
1450
|
-
edge.sourceCluster.id === hubCluster.id
|
|
1451
|
-
? [edge.targetCluster.id]
|
|
1452
|
-
: edge.targetCluster.id === hubCluster.id
|
|
1453
|
-
? [edge.sourceCluster.id]
|
|
1454
|
-
: []
|
|
1455
|
-
))
|
|
1456
|
-
const syntheticHubEdges = edgeClusters
|
|
1457
|
-
.filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
|
|
1458
|
-
.slice(0, ecosystemHubEdgeLimit)
|
|
1459
|
-
.map(cluster => ({
|
|
1460
|
-
id: hubCluster.id + ':' + cluster.id,
|
|
1461
|
-
sourceCluster: hubCluster,
|
|
1462
|
-
targetCluster: cluster,
|
|
1463
|
-
weight: 1,
|
|
1464
|
-
inferred: true
|
|
1465
|
-
}))
|
|
1466
|
-
return edges.concat(syntheticHubEdges)
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
744
|
const edgeBudgetForCurrentFrame = () => {
|
|
1470
745
|
const zoom = state.transform.scale
|
|
1471
746
|
if (zoom < 0.12) return 380
|
|
@@ -1614,22 +889,16 @@ const cursorWorldPoint = () => {
|
|
|
1614
889
|
return screenToWorldPoint(screenX, screenY)
|
|
1615
890
|
}
|
|
1616
891
|
|
|
892
|
+
const resolveZoomAnchorWorldPoint = (screenX, screenY) => screenToWorldPoint(screenX, screenY)
|
|
893
|
+
|
|
1617
894
|
const visibilityScaleBucket = (scale) => {
|
|
1618
895
|
const safeScale = Math.max(zoomRange.min, scale)
|
|
1619
896
|
return Math.round(safeScale * 180_000)
|
|
1620
897
|
}
|
|
1621
898
|
|
|
1622
899
|
const shouldRenderEcosystemClusterView = (nodeCount, scale) => {
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
: ecosystemClusterScaleThreshold
|
|
1626
|
-
const enterThreshold = baseThreshold * ecosystemClusterEnterHysteresis
|
|
1627
|
-
const exitThreshold = baseThreshold * ecosystemClusterExitHysteresis
|
|
1628
|
-
const shouldRender = state.ecosystemViewActive
|
|
1629
|
-
? scale <= exitThreshold
|
|
1630
|
-
: scale <= enterThreshold
|
|
1631
|
-
state.ecosystemViewActive = shouldRender
|
|
1632
|
-
return shouldRender
|
|
900
|
+
state.ecosystemViewActive = false
|
|
901
|
+
return false
|
|
1633
902
|
}
|
|
1634
903
|
|
|
1635
904
|
const shouldRenderMacroGalaxyView = () => {
|
|
@@ -1826,20 +1095,11 @@ const edgeOpacityForScale = (edge, scale) => {
|
|
|
1826
1095
|
}
|
|
1827
1096
|
|
|
1828
1097
|
const edgeDepthOpacity = edge => {
|
|
1829
|
-
|
|
1830
|
-
return 1
|
|
1831
|
-
}
|
|
1832
|
-
return Math.max(
|
|
1833
|
-
graphDepthEdgeOpacityFloor,
|
|
1834
|
-
Math.min(nodeRenderOpacity(edge.sourceNode), nodeRenderOpacity(edge.targetNode))
|
|
1835
|
-
)
|
|
1098
|
+
return 1
|
|
1836
1099
|
}
|
|
1837
1100
|
|
|
1838
1101
|
const edgeDepthScale = edge => {
|
|
1839
|
-
|
|
1840
|
-
return 1
|
|
1841
|
-
}
|
|
1842
|
-
return Math.max(0.62, Math.min(1.18, Math.min(nodeRenderScale(edge.sourceNode), nodeRenderScale(edge.targetNode))))
|
|
1102
|
+
return 1
|
|
1843
1103
|
}
|
|
1844
1104
|
|
|
1845
1105
|
const edgeStrokeFor = (edge, selectedEdge) => {
|
|
@@ -2022,10 +1282,6 @@ const drawGraphNodes = () => {
|
|
|
2022
1282
|
}
|
|
2023
1283
|
}
|
|
2024
1284
|
|
|
2025
|
-
if (shouldProjectRenderNodesInDepth()) {
|
|
2026
|
-
regularNodes.sort((left, right) => nodeRenderDepth(right) - nodeRenderDepth(left))
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
1285
|
drawNodeBatch(regularNodes)
|
|
2030
1286
|
|
|
2031
1287
|
if (state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) {
|
|
@@ -2102,7 +1358,7 @@ const drawGraphLabels = nodes => {
|
|
|
2102
1358
|
}
|
|
2103
1359
|
|
|
2104
1360
|
const drawAcceleratedGraph = (width, height, drawEdges) => {
|
|
2105
|
-
if (!webGlRenderer || state.renderClusters.length > 0
|
|
1361
|
+
if (!webGlRenderer || state.renderClusters.length > 0) {
|
|
2106
1362
|
return false
|
|
2107
1363
|
}
|
|
2108
1364
|
|
|
@@ -2486,12 +1742,7 @@ const zoomCapByHubDistance = (distance) => {
|
|
|
2486
1742
|
|
|
2487
1743
|
const currentZoomMax = () => {
|
|
2488
1744
|
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
2489
|
-
|
|
2490
|
-
? zoomCapByHubDistance(state.hubNeighborDistance)
|
|
2491
|
-
: zoomRange.max
|
|
2492
|
-
const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
|
|
2493
|
-
const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
|
|
2494
|
-
return Math.max(zoomRange.min * 2, capped)
|
|
1745
|
+
return Math.max(zoomRange.min * 2, zoomCapByNodeCount(nodeCount))
|
|
2495
1746
|
}
|
|
2496
1747
|
|
|
2497
1748
|
const zoomFloorByNodeCount = (nodeCount) => {
|
|
@@ -2724,7 +1975,7 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
|
|
|
2724
1975
|
markRenderDirty()
|
|
2725
1976
|
}
|
|
2726
1977
|
|
|
2727
|
-
const resetView = () => fitView({ useFiltered: false, macro:
|
|
1978
|
+
const resetView = () => fitView({ useFiltered: false, macro: false, preferHubCenter: false })
|
|
2728
1979
|
|
|
2729
1980
|
const focusPrimaryHub = () => {
|
|
2730
1981
|
const hub = state.primaryHub
|
|
@@ -3147,103 +2398,13 @@ const clusterRadiusPx = cluster => {
|
|
|
3147
2398
|
const clusterOpacity = cluster =>
|
|
3148
2399
|
Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
|
|
3149
2400
|
|
|
3150
|
-
const clusterDepth = cluster => Number.isFinite(cluster.depth) ? cluster.depth : ecosystemDepthNear
|
|
3151
|
-
const clusterDepthScale = cluster => Number.isFinite(cluster.depthScale) ? cluster.depthScale : 1
|
|
3152
|
-
|
|
3153
|
-
const shouldProjectRenderNodesInDepth = () => {
|
|
3154
|
-
const withinNodeCountWindow =
|
|
3155
|
-
state.renderClusters.length === 0 &&
|
|
3156
|
-
state.renderNodes.length >= graphDepthProjectionNodeThreshold &&
|
|
3157
|
-
state.renderNodes.length <= graphDepthProjectionNodeCap &&
|
|
3158
|
-
!state.pointer.down
|
|
3159
|
-
|
|
3160
|
-
if (!withinNodeCountWindow) {
|
|
3161
|
-
state.depthProjectionActive = false
|
|
3162
|
-
return false
|
|
3163
|
-
}
|
|
3164
|
-
|
|
3165
|
-
const scale = state.transform.scale
|
|
3166
|
-
const shouldProject = state.depthProjectionActive
|
|
3167
|
-
? scale >= graphDepthProjectionExitMinScale && scale <= graphDepthProjectionExitMaxScale
|
|
3168
|
-
: scale >= graphDepthProjectionEnterMinScale && scale <= graphDepthProjectionEnterMaxScale
|
|
3169
|
-
state.depthProjectionActive = shouldProject
|
|
3170
|
-
return shouldProject
|
|
3171
|
-
}
|
|
3172
|
-
|
|
3173
|
-
const nodeProjectionAnchor = () => {
|
|
3174
|
-
const hub = state.primaryHub
|
|
3175
|
-
if (hub) {
|
|
3176
|
-
return { x: hub.x, y: hub.y }
|
|
3177
|
-
}
|
|
3178
|
-
if (state.macroRepresentative) {
|
|
3179
|
-
return { x: state.macroRepresentative.x, y: state.macroRepresentative.y }
|
|
3180
|
-
}
|
|
3181
|
-
return ecosystemFocusPoint()
|
|
3182
|
-
}
|
|
3183
|
-
|
|
3184
|
-
const projectGraphNodePoint = (x, y, depth, anchor) => {
|
|
3185
|
-
const safeDepth = Math.max(0, depth)
|
|
3186
|
-
const dx = x - anchor.x
|
|
3187
|
-
const dy = y - anchor.y
|
|
3188
|
-
const yawSin = Math.sin(graphDepthYaw)
|
|
3189
|
-
const yawCos = Math.cos(graphDepthYaw)
|
|
3190
|
-
const pitchSin = Math.sin(graphDepthPitch)
|
|
3191
|
-
const pitchCos = Math.cos(graphDepthPitch)
|
|
3192
|
-
const rotatedX = dx * yawCos + safeDepth * yawSin
|
|
3193
|
-
const rotatedZ = Math.max(0, safeDepth * yawCos - dx * yawSin)
|
|
3194
|
-
const rotatedY = dy * pitchCos - rotatedZ * pitchSin
|
|
3195
|
-
const projectedDepth = Math.max(0, rotatedZ + Math.max(0, dy * pitchSin))
|
|
3196
|
-
const factor = graphDepthPerspective / (graphDepthPerspective + projectedDepth)
|
|
3197
|
-
return {
|
|
3198
|
-
x: anchor.x + rotatedX * factor,
|
|
3199
|
-
y: anchor.y + rotatedY * factor,
|
|
3200
|
-
depth: projectedDepth,
|
|
3201
|
-
scale: graphDepthMinScale + (1 - graphDepthMinScale) * factor
|
|
3202
|
-
}
|
|
3203
|
-
}
|
|
3204
|
-
|
|
3205
2401
|
const refreshRenderNodeDepthProjection = () => {
|
|
3206
|
-
|
|
3207
|
-
state.renderNodeDepthProjectionById = new Map()
|
|
3208
|
-
return
|
|
3209
|
-
}
|
|
3210
|
-
|
|
3211
|
-
const anchor = nodeProjectionAnchor()
|
|
3212
|
-
let maxDistance = 1
|
|
3213
|
-
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
3214
|
-
const node = state.renderNodes[index]
|
|
3215
|
-
const distance = Math.hypot(node.x - anchor.x, node.y - anchor.y)
|
|
3216
|
-
if (distance > maxDistance) {
|
|
3217
|
-
maxDistance = distance
|
|
3218
|
-
}
|
|
3219
|
-
}
|
|
3220
|
-
|
|
3221
|
-
const projectionById = new Map()
|
|
3222
|
-
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
3223
|
-
const node = state.renderNodes[index]
|
|
3224
|
-
const radialDistance = Math.hypot(node.x - anchor.x, node.y - anchor.y)
|
|
3225
|
-
const radialRatio = radialDistance / maxDistance
|
|
3226
|
-
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
3227
|
-
const degreeLift = Math.min(60, degree * 2.2)
|
|
3228
|
-
const radialDepth = radialRatio * graphDepthFar * graphDepthRadialGain
|
|
3229
|
-
const depth = Math.max(0, graphDepthNear + radialDepth - degreeLift)
|
|
3230
|
-
const projected = projectGraphNodePoint(node.x, node.y, depth, anchor)
|
|
3231
|
-
const opacity = Math.max(graphDepthOpacityFloor, Math.min(1, projected.scale * 1.08))
|
|
3232
|
-
projectionById.set(node.id, {
|
|
3233
|
-
x: projected.x,
|
|
3234
|
-
y: projected.y,
|
|
3235
|
-
depth: projected.depth,
|
|
3236
|
-
scale: projected.scale,
|
|
3237
|
-
opacity
|
|
3238
|
-
})
|
|
3239
|
-
}
|
|
3240
|
-
state.renderNodeDepthProjectionById = projectionById
|
|
2402
|
+
state.renderNodeDepthProjectionById = new Map()
|
|
3241
2403
|
}
|
|
3242
2404
|
|
|
3243
2405
|
const projectedNode = node => state.renderNodeDepthProjectionById.get(node.id) ?? null
|
|
3244
2406
|
const nodeRenderX = node => projectedNode(node)?.x ?? node.x
|
|
3245
2407
|
const nodeRenderY = node => projectedNode(node)?.y ?? node.y
|
|
3246
|
-
const nodeRenderDepth = node => projectedNode(node)?.depth ?? graphDepthNear
|
|
3247
2408
|
const nodeRenderScale = node => projectedNode(node)?.scale ?? 1
|
|
3248
2409
|
const nodeRenderOpacity = node => projectedNode(node)?.opacity ?? 1
|
|
3249
2410
|
const worldViewportBounds = () => {
|
|
@@ -3406,22 +2567,6 @@ const computeRenderVisibility = () => {
|
|
|
3406
2567
|
return
|
|
3407
2568
|
}
|
|
3408
2569
|
|
|
3409
|
-
if (
|
|
3410
|
-
state.ecosystemExpansionLevels.length > 0 &&
|
|
3411
|
-
shouldRenderEcosystemClusterView(state.visibleNodes.length, state.transform.scale) &&
|
|
3412
|
-
state.ecosystemClusters.length > 0
|
|
3413
|
-
) {
|
|
3414
|
-
const clusters = selectHierarchicalEcosystemClusters(viewport)
|
|
3415
|
-
.sort((left, right) => right.count - left.count)
|
|
3416
|
-
const edges = ecosystemEdgesForClusters(clusters)
|
|
3417
|
-
const projectionAnchor = ecosystemFocusPoint()
|
|
3418
|
-
const projected = applyEcosystemDepthProjection(clusters, edges, projectionAnchor)
|
|
3419
|
-
state.renderClusters = projected.clusters
|
|
3420
|
-
state.renderClusterEdges = projected.edges
|
|
3421
|
-
state.renderNodes = []
|
|
3422
|
-
state.renderEdges = []
|
|
3423
|
-
return
|
|
3424
|
-
}
|
|
3425
2570
|
state.ecosystemViewActive = false
|
|
3426
2571
|
|
|
3427
2572
|
if (state.visibleNodes.length <= 2000) {
|
|
@@ -3667,7 +2812,6 @@ const render = now => {
|
|
|
3667
2812
|
ctx.translate(state.transform.x, state.transform.y)
|
|
3668
2813
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
3669
2814
|
const orderedClusters = [...state.renderClusters]
|
|
3670
|
-
.sort((left, right) => clusterDepth(right) - clusterDepth(left))
|
|
3671
2815
|
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
3672
2816
|
if (state.renderClusterEdges.length > 0) {
|
|
3673
2817
|
for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
|
|
@@ -3676,8 +2820,7 @@ const render = now => {
|
|
|
3676
2820
|
if (edgeOpacity <= 0.01) {
|
|
3677
2821
|
continue
|
|
3678
2822
|
}
|
|
3679
|
-
const
|
|
3680
|
-
const widthScale = 0.6 + depthScale * 0.9
|
|
2823
|
+
const widthScale = 1
|
|
3681
2824
|
ctx.beginPath()
|
|
3682
2825
|
ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
|
|
3683
2826
|
ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
|
|
@@ -3862,7 +3005,7 @@ const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
|
3862
3005
|
state.zoomTransition.screenX === screenX &&
|
|
3863
3006
|
state.zoomTransition.screenY === screenY
|
|
3864
3007
|
? { x: state.zoomTransition.worldX, y: state.zoomTransition.worldY }
|
|
3865
|
-
:
|
|
3008
|
+
: resolveZoomAnchorWorldPoint(screenX, screenY)
|
|
3866
3009
|
const worldX = worldPointAtCursor.x
|
|
3867
3010
|
const worldY = worldPointAtCursor.y
|
|
3868
3011
|
state.lastZoomFocus = {
|
|
@@ -3892,18 +3035,13 @@ const wheelZoomFactor = event => {
|
|
|
3892
3035
|
return 1
|
|
3893
3036
|
}
|
|
3894
3037
|
|
|
3895
|
-
const isMassiveEcosystemZoom =
|
|
3896
|
-
state.visibleNodes.length > massiveGraphNodeThreshold &&
|
|
3897
|
-
state.transform.scale <= massiveEcosystemClusterScaleThreshold
|
|
3898
|
-
const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
|
|
3899
|
-
const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
|
|
3900
3038
|
const isZoomOut = normalizedDelta > 0
|
|
3901
3039
|
const currentScale = state.transform.scale
|
|
3902
3040
|
const zoomOutDamping = isZoomOut
|
|
3903
3041
|
? (currentScale <= 0.03 ? 0.38 : currentScale <= 0.08 ? 0.52 : 0.68)
|
|
3904
3042
|
: 1
|
|
3905
|
-
const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) *
|
|
3906
|
-
const exponentCap = wheelZoomExponentCap *
|
|
3043
|
+
const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * zoomOutDamping
|
|
3044
|
+
const exponentCap = wheelZoomExponentCap * (isZoomOut ? 0.74 : 1)
|
|
3907
3045
|
const exponent = Math.max(
|
|
3908
3046
|
-exponentCap,
|
|
3909
3047
|
Math.min(exponentCap, -normalizedDelta * sensitivity)
|
package/package.json
CHANGED