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