@andespindola/brainlink 0.1.0-beta.86 → 0.1.0-beta.88
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 +2 -2
- package/dist/application/frontend/client-js.js +225 -61
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ 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 renders hierarchical ecosystem subgraphs:
|
|
87
|
+
- Graph zoom-out renders hierarchical ecosystem subgraphs only above 1000 notes: the memory hub stays centered, 1000-note groups stay as compact sand-like points, and focused groups gradually expand into smaller graph meshes before individual notes are rendered.
|
|
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 macro mass and zoom-in progressively expands toward local detail.
|
|
@@ -602,7 +602,7 @@ The graph UI shows:
|
|
|
602
602
|
- WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
|
|
603
603
|
- compact macro-to-micro density progression so reset keeps the graph mass oriented and zoom-in separates local neighborhoods progressively
|
|
604
604
|
- 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
|
|
605
|
-
- graph LOD progression:
|
|
605
|
+
- graph LOD progression: graphs up to 1000 notes render directly; larger graphs use a compact memory-hub-centered mesh of connected 1000-note points, zoom-in spreads only focused clusters into 250-note and 60-note subgraphs with aggregated real links, then progressively raises the focused node budget so local areas keep nearby notes and links visible
|
|
606
606
|
|
|
607
607
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
608
608
|
|
|
@@ -22,8 +22,10 @@ const worldCoordinateLimit = 5_000_000
|
|
|
22
22
|
const transformCoordinateLimit = 20_000_000
|
|
23
23
|
const hoverHitTestIntervalMs = 64
|
|
24
24
|
const ecosystemGroupSize = 1000
|
|
25
|
+
const ecosystemActivationNodeThreshold = 1000
|
|
25
26
|
const ecosystemGroupSizes = [1000, 250, 60]
|
|
26
27
|
const ecosystemClusterEdgeLimit = 520
|
|
28
|
+
const ecosystemHubEdgeLimit = 120
|
|
27
29
|
const ecosystemClusterScaleThreshold = 0.32
|
|
28
30
|
const ecosystemSubgraphScaleThreshold = 0.18
|
|
29
31
|
const ecosystemMicroScaleThreshold = 0.08
|
|
@@ -73,6 +75,8 @@ const state = {
|
|
|
73
75
|
visibleEdgeByNode: new Map(),
|
|
74
76
|
ecosystemClusters: [],
|
|
75
77
|
ecosystemClustersBySize: new Map(),
|
|
78
|
+
ecosystemNodeClusterBySize: new Map(),
|
|
79
|
+
ecosystemHubCluster: null,
|
|
76
80
|
macroCenter: { x: 0, y: 0 },
|
|
77
81
|
macroRepresentative: null,
|
|
78
82
|
primaryHub: null,
|
|
@@ -569,11 +573,6 @@ const recomputeVisibility = () => {
|
|
|
569
573
|
state.visibleEdges = limitedEdges
|
|
570
574
|
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
571
575
|
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
572
|
-
const ecosystemGraph = nodes.length > 1
|
|
573
|
-
? buildEcosystemGraph(nodes)
|
|
574
|
-
: { clusters: [], clustersBySize: new Map() }
|
|
575
|
-
state.ecosystemClusters = ecosystemGraph.clusters
|
|
576
|
-
state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
|
|
577
576
|
const primaryHub = rankedHubNodes()[0] ?? null
|
|
578
577
|
state.primaryHub = primaryHub
|
|
579
578
|
state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
|
|
@@ -585,6 +584,13 @@ const recomputeVisibility = () => {
|
|
|
585
584
|
y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
|
|
586
585
|
}
|
|
587
586
|
: { x: 0, y: 0 }
|
|
587
|
+
const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
|
|
588
|
+
? buildEcosystemGraph(nodes, state.macroCenter, primaryHub)
|
|
589
|
+
: { clusters: [], clustersBySize: new Map(), nodeClusterBySize: new Map(), hubCluster: null }
|
|
590
|
+
state.ecosystemClusters = ecosystemGraph.clusters
|
|
591
|
+
state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
|
|
592
|
+
state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
|
|
593
|
+
state.ecosystemHubCluster = ecosystemGraph.hubCluster
|
|
588
594
|
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
589
595
|
markRenderDirty()
|
|
590
596
|
}
|
|
@@ -729,18 +735,32 @@ const selectEcosystemRepresentative = nodes => {
|
|
|
729
735
|
return representative
|
|
730
736
|
}
|
|
731
737
|
|
|
732
|
-
const
|
|
738
|
+
const ecosystemLayoutSpacingForSize = size => {
|
|
739
|
+
if (size >= 1000) return 360
|
|
740
|
+
if (size >= 250) return 92
|
|
741
|
+
return 28
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const ecosystemCompactPoint = (index, total, center, spacing) => {
|
|
745
|
+
if (total <= 1) {
|
|
746
|
+
return { x: center.x, y: center.y }
|
|
747
|
+
}
|
|
748
|
+
const angle = index * 2.399963229728653
|
|
749
|
+
const radius = spacing * Math.sqrt(index + 1)
|
|
750
|
+
return {
|
|
751
|
+
x: center.x + Math.cos(angle) * radius,
|
|
752
|
+
y: center.y + Math.sin(angle) * radius
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const buildEcosystemCluster = (nodes, index, point) => {
|
|
733
757
|
const count = Math.max(nodes.length, 1)
|
|
734
|
-
const sum = nodes.reduce((accumulator, node) => ({
|
|
735
|
-
x: accumulator.x + node.x,
|
|
736
|
-
y: accumulator.y + node.y
|
|
737
|
-
}), { x: 0, y: 0 })
|
|
738
758
|
const representative = selectEcosystemRepresentative(nodes)
|
|
739
759
|
|
|
740
760
|
return {
|
|
741
761
|
id: 'ecosystem-' + index,
|
|
742
|
-
x:
|
|
743
|
-
y:
|
|
762
|
+
x: point.x,
|
|
763
|
+
y: point.y,
|
|
744
764
|
count,
|
|
745
765
|
nodeIds: nodes.map(node => node.id),
|
|
746
766
|
representative,
|
|
@@ -748,18 +768,47 @@ const buildEcosystemCluster = (nodes, index) => {
|
|
|
748
768
|
}
|
|
749
769
|
}
|
|
750
770
|
|
|
751
|
-
const
|
|
771
|
+
const buildEcosystemHubCluster = (hub, center) => hub
|
|
772
|
+
? {
|
|
773
|
+
id: 'ecosystem-hub',
|
|
774
|
+
x: center.x,
|
|
775
|
+
y: center.y,
|
|
776
|
+
count: 1,
|
|
777
|
+
size: 1,
|
|
778
|
+
nodeIds: [hub.id],
|
|
779
|
+
representative: hub,
|
|
780
|
+
label: hub.title || 'Memory Hub',
|
|
781
|
+
parentId: null,
|
|
782
|
+
parentX: null,
|
|
783
|
+
parentY: null,
|
|
784
|
+
isHub: true
|
|
785
|
+
}
|
|
786
|
+
: null
|
|
787
|
+
|
|
788
|
+
const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
|
|
752
789
|
const clusters = []
|
|
753
790
|
const clusterByNodeId = new Map()
|
|
791
|
+
const parentChildIndex = new Map()
|
|
754
792
|
|
|
755
793
|
for (let offset = 0; offset < sortedNodes.length; offset += size) {
|
|
756
794
|
const clusterNodes = sortedNodes.slice(offset, offset + size)
|
|
757
795
|
const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
|
|
796
|
+
const siblingIndex = parentCluster
|
|
797
|
+
? (parentChildIndex.get(parentCluster.id) ?? 0)
|
|
798
|
+
: clusters.length
|
|
799
|
+
if (parentCluster) {
|
|
800
|
+
parentChildIndex.set(parentCluster.id, siblingIndex + 1)
|
|
801
|
+
}
|
|
802
|
+
const point = parentCluster
|
|
803
|
+
? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
|
|
804
|
+
: ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
|
|
758
805
|
const cluster = {
|
|
759
|
-
...buildEcosystemCluster(clusterNodes, clusters.length),
|
|
806
|
+
...buildEcosystemCluster(clusterNodes, clusters.length, point),
|
|
760
807
|
id: 'ecosystem-' + size + '-' + clusters.length,
|
|
761
808
|
size,
|
|
762
|
-
parentId: parentCluster?.id ?? null
|
|
809
|
+
parentId: parentCluster?.id ?? null,
|
|
810
|
+
parentX: parentCluster?.x ?? null,
|
|
811
|
+
parentY: parentCluster?.y ?? null
|
|
763
812
|
}
|
|
764
813
|
clusters.push(cluster)
|
|
765
814
|
for (let index = 0; index < clusterNodes.length; index += 1) {
|
|
@@ -770,25 +819,32 @@ const buildEcosystemLevel = (sortedNodes, size, parentLookup) => {
|
|
|
770
819
|
return { clusters, clusterByNodeId }
|
|
771
820
|
}
|
|
772
821
|
|
|
773
|
-
const buildEcosystemGraph = (nodes) => {
|
|
822
|
+
const buildEcosystemGraph = (nodes, center, hub) => {
|
|
774
823
|
if (nodes.length === 0) {
|
|
775
|
-
return { clusters: [], clustersBySize: new Map() }
|
|
824
|
+
return { clusters: [], clustersBySize: new Map(), nodeClusterBySize: new Map(), hubCluster: null }
|
|
776
825
|
}
|
|
777
826
|
|
|
778
|
-
const
|
|
827
|
+
const hubCluster = buildEcosystemHubCluster(hub, center)
|
|
828
|
+
const sortedNodes = nodes
|
|
829
|
+
.filter(node => node.id !== hub?.id)
|
|
830
|
+
.sort(compareNodesForEcosystem)
|
|
779
831
|
const clustersBySize = new Map()
|
|
832
|
+
const nodeClusterBySize = new Map()
|
|
780
833
|
let parentLookup = null
|
|
781
834
|
|
|
782
835
|
for (let index = 0; index < ecosystemGroupSizes.length; index += 1) {
|
|
783
836
|
const size = ecosystemGroupSizes[index]
|
|
784
|
-
const level = buildEcosystemLevel(sortedNodes, size, parentLookup)
|
|
837
|
+
const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
|
|
785
838
|
clustersBySize.set(size, level.clusters)
|
|
839
|
+
nodeClusterBySize.set(size, level.clusterByNodeId)
|
|
786
840
|
parentLookup = level.clusterByNodeId
|
|
787
841
|
}
|
|
788
842
|
|
|
789
843
|
return {
|
|
790
844
|
clusters: clustersBySize.get(ecosystemGroupSize) ?? [],
|
|
791
|
-
clustersBySize
|
|
845
|
+
clustersBySize,
|
|
846
|
+
nodeClusterBySize,
|
|
847
|
+
hubCluster
|
|
792
848
|
}
|
|
793
849
|
}
|
|
794
850
|
|
|
@@ -821,61 +877,124 @@ const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
|
|
|
821
877
|
.slice(0, limit)
|
|
822
878
|
.map(item => item.cluster.id)
|
|
823
879
|
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
880
|
+
const smoothStep = value => {
|
|
881
|
+
const clamped = Math.max(0, Math.min(1, value))
|
|
882
|
+
return clamped * clamped * (3 - clamped * 2)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const zoomProgress = (scale, start, end) =>
|
|
886
|
+
smoothStep((scale - start) / Math.max(end - start, 0.0001))
|
|
887
|
+
|
|
888
|
+
const expandFocusedClusters = (parentClusters, childSize, spread, viewport) => {
|
|
889
|
+
const focusPoint = ecosystemFocusPoint()
|
|
890
|
+
const expandedParentIds = new Set(nearestEcosystemParentIds(
|
|
891
|
+
parentClusters,
|
|
892
|
+
focusPoint,
|
|
893
|
+
ecosystemFocusedParentLimit
|
|
894
|
+
))
|
|
895
|
+
const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
|
|
896
|
+
const visibleChildClusters = childClusters
|
|
897
|
+
.filter(cluster => expandedParentIds.has(cluster.parentId))
|
|
898
|
+
.map(cluster => spreadChildClusterFromParent(cluster, spread))
|
|
899
|
+
.filter(cluster => isClusterInViewport(cluster, viewport))
|
|
900
|
+
|
|
901
|
+
return {
|
|
902
|
+
expandedParentIds,
|
|
903
|
+
childClusters: visibleChildClusters
|
|
827
904
|
}
|
|
828
|
-
|
|
829
|
-
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const spreadChildClusterFromParent = (cluster, spread) => {
|
|
908
|
+
if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
|
|
909
|
+
return cluster
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
...cluster,
|
|
914
|
+
x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
|
|
915
|
+
y: cluster.parentY + (cluster.y - cluster.parentY) * spread
|
|
830
916
|
}
|
|
831
|
-
return { baseSize: 250, childSize: 60 }
|
|
832
917
|
}
|
|
833
918
|
|
|
834
919
|
const selectHierarchicalEcosystemClusters = viewport => {
|
|
835
|
-
const
|
|
836
|
-
const baseClusters = state.ecosystemClustersBySize.get(plan.baseSize) ?? state.ecosystemClusters
|
|
920
|
+
const baseClusters = state.ecosystemClustersBySize.get(ecosystemGroupSize) ?? state.ecosystemClusters
|
|
837
921
|
const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
|
|
922
|
+
const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
|
|
838
923
|
|
|
839
|
-
if (
|
|
840
|
-
return visibleBaseClusters
|
|
924
|
+
if (state.transform.scale <= ecosystemMicroScaleThreshold) {
|
|
925
|
+
return [...hubClusters, ...visibleBaseClusters]
|
|
841
926
|
}
|
|
842
927
|
|
|
843
|
-
const
|
|
844
|
-
const expandedParentIds = new Set(nearestEcosystemParentIds(
|
|
928
|
+
const midExpansion = expandFocusedClusters(
|
|
845
929
|
visibleBaseClusters,
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
expandedParentIds.has(cluster.parentId) &&
|
|
852
|
-
isClusterInViewport(cluster, viewport)
|
|
930
|
+
250,
|
|
931
|
+
state.transform.scale <= ecosystemSubgraphScaleThreshold
|
|
932
|
+
? zoomProgress(state.transform.scale, ecosystemMicroScaleThreshold, ecosystemSubgraphScaleThreshold)
|
|
933
|
+
: 1,
|
|
934
|
+
viewport
|
|
853
935
|
)
|
|
936
|
+
if (midExpansion.childClusters.length === 0) {
|
|
937
|
+
return [...hubClusters, ...visibleBaseClusters]
|
|
938
|
+
}
|
|
854
939
|
|
|
855
|
-
|
|
856
|
-
|
|
940
|
+
const remainingBaseClusters = visibleBaseClusters.filter(cluster => !midExpansion.expandedParentIds.has(cluster.id))
|
|
941
|
+
if (state.transform.scale <= ecosystemSubgraphScaleThreshold) {
|
|
942
|
+
return [
|
|
943
|
+
...hubClusters,
|
|
944
|
+
...remainingBaseClusters,
|
|
945
|
+
...midExpansion.childClusters
|
|
946
|
+
]
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const microExpansion = expandFocusedClusters(
|
|
950
|
+
midExpansion.childClusters,
|
|
951
|
+
60,
|
|
952
|
+
zoomProgress(state.transform.scale, ecosystemSubgraphScaleThreshold, ecosystemClusterScaleThreshold),
|
|
953
|
+
viewport
|
|
954
|
+
)
|
|
955
|
+
if (microExpansion.childClusters.length === 0) {
|
|
956
|
+
return [
|
|
957
|
+
...hubClusters,
|
|
958
|
+
...remainingBaseClusters,
|
|
959
|
+
...midExpansion.childClusters
|
|
960
|
+
]
|
|
857
961
|
}
|
|
858
962
|
|
|
859
963
|
return [
|
|
860
|
-
...
|
|
861
|
-
...
|
|
964
|
+
...hubClusters,
|
|
965
|
+
...remainingBaseClusters,
|
|
966
|
+
...midExpansion.childClusters.filter(cluster => !microExpansion.expandedParentIds.has(cluster.id)),
|
|
967
|
+
...microExpansion.childClusters
|
|
862
968
|
]
|
|
863
969
|
}
|
|
864
970
|
|
|
865
971
|
const ecosystemEdgesForClusters = clusters => {
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
972
|
+
const clusterById = new Map(clusters.map(cluster => [cluster.id, cluster]))
|
|
973
|
+
const clusterIds = new Set(clusterById.keys())
|
|
974
|
+
const levelBySize = new Map()
|
|
975
|
+
for (let index = 0; index < clusters.length; index += 1) {
|
|
976
|
+
const cluster = clusters[index]
|
|
977
|
+
if (!cluster.size || cluster.isHub) continue
|
|
978
|
+
levelBySize.set(cluster.size, state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map())
|
|
979
|
+
}
|
|
980
|
+
const resolveClusterForNode = nodeId => {
|
|
981
|
+
if (state.ecosystemHubCluster?.nodeIds.includes(nodeId) && clusterIds.has(state.ecosystemHubCluster.id)) {
|
|
982
|
+
return state.ecosystemHubCluster
|
|
983
|
+
}
|
|
984
|
+
for (const [size, lookup] of levelBySize) {
|
|
985
|
+
const cluster = lookup.get(nodeId)
|
|
986
|
+
if (cluster && clusterIds.has(cluster.id)) {
|
|
987
|
+
return clusterById.get(cluster.id) ?? cluster
|
|
988
|
+
}
|
|
871
989
|
}
|
|
990
|
+
return null
|
|
872
991
|
}
|
|
873
992
|
|
|
874
993
|
const edgeByClusterPair = new Map()
|
|
875
994
|
for (let index = 0; index < state.visibleEdges.length; index += 1) {
|
|
876
995
|
const edge = state.visibleEdges[index]
|
|
877
|
-
const sourceCluster =
|
|
878
|
-
const targetCluster =
|
|
996
|
+
const sourceCluster = resolveClusterForNode(edge.source)
|
|
997
|
+
const targetCluster = resolveClusterForNode(edge.target)
|
|
879
998
|
if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
|
|
880
999
|
continue
|
|
881
1000
|
}
|
|
@@ -898,9 +1017,34 @@ const ecosystemEdgesForClusters = clusters => {
|
|
|
898
1017
|
})
|
|
899
1018
|
}
|
|
900
1019
|
|
|
901
|
-
|
|
1020
|
+
const edges = Array.from(edgeByClusterPair.values())
|
|
902
1021
|
.sort((left, right) => right.weight - left.weight)
|
|
903
1022
|
.slice(0, ecosystemClusterEdgeLimit)
|
|
1023
|
+
const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
|
|
1024
|
+
? state.ecosystemHubCluster
|
|
1025
|
+
: null
|
|
1026
|
+
if (!hubCluster) {
|
|
1027
|
+
return edges
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const existingHubTargets = new Set(edges.flatMap(edge =>
|
|
1031
|
+
edge.sourceCluster.id === hubCluster.id
|
|
1032
|
+
? [edge.targetCluster.id]
|
|
1033
|
+
: edge.targetCluster.id === hubCluster.id
|
|
1034
|
+
? [edge.sourceCluster.id]
|
|
1035
|
+
: []
|
|
1036
|
+
))
|
|
1037
|
+
const syntheticHubEdges = clusters
|
|
1038
|
+
.filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
|
|
1039
|
+
.slice(0, ecosystemHubEdgeLimit)
|
|
1040
|
+
.map(cluster => ({
|
|
1041
|
+
id: hubCluster.id + ':' + cluster.id,
|
|
1042
|
+
sourceCluster: hubCluster,
|
|
1043
|
+
targetCluster: cluster,
|
|
1044
|
+
weight: 1,
|
|
1045
|
+
inferred: true
|
|
1046
|
+
}))
|
|
1047
|
+
return edges.concat(syntheticHubEdges)
|
|
904
1048
|
}
|
|
905
1049
|
|
|
906
1050
|
const edgeBudgetForCurrentFrame = () => {
|
|
@@ -2337,6 +2481,20 @@ const baseNodeRadius = node => {
|
|
|
2337
2481
|
|
|
2338
2482
|
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
2339
2483
|
|
|
2484
|
+
const clusterRadiusPx = cluster => {
|
|
2485
|
+
if (cluster.id === 'macro-galaxy') {
|
|
2486
|
+
return 10
|
|
2487
|
+
}
|
|
2488
|
+
if (cluster.isHub) {
|
|
2489
|
+
return 5.2
|
|
2490
|
+
}
|
|
2491
|
+
if (String(cluster.id).startsWith('ecosystem-')) {
|
|
2492
|
+
const base = cluster.size >= 1000 ? 2.4 : cluster.size >= 250 ? 2.1 : 1.8
|
|
2493
|
+
return Math.max(1.8, Math.min(4.2, base + Math.log10(cluster.count + 1) * 0.28))
|
|
2494
|
+
}
|
|
2495
|
+
return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2340
2498
|
const worldViewportBounds = () => {
|
|
2341
2499
|
const rect = canvas.getBoundingClientRect()
|
|
2342
2500
|
const width = Math.max(rect.width, 320)
|
|
@@ -2498,7 +2656,11 @@ const computeRenderVisibility = () => {
|
|
|
2498
2656
|
return
|
|
2499
2657
|
}
|
|
2500
2658
|
|
|
2501
|
-
if (
|
|
2659
|
+
if (
|
|
2660
|
+
state.visibleNodes.length > ecosystemActivationNodeThreshold &&
|
|
2661
|
+
state.transform.scale <= ecosystemClusterScaleThreshold &&
|
|
2662
|
+
state.ecosystemClusters.length > 0
|
|
2663
|
+
) {
|
|
2502
2664
|
const clusters = selectHierarchicalEcosystemClusters(viewport)
|
|
2503
2665
|
.sort((left, right) => right.count - left.count)
|
|
2504
2666
|
state.renderClusters = clusters
|
|
@@ -2760,20 +2922,22 @@ const render = now => {
|
|
|
2760
2922
|
}
|
|
2761
2923
|
state.renderClusters.forEach(cluster => {
|
|
2762
2924
|
const isMacro = cluster.id === 'macro-galaxy'
|
|
2763
|
-
const
|
|
2764
|
-
|
|
2765
|
-
|
|
2925
|
+
const isEcosystem = String(cluster.id).startsWith('ecosystem-')
|
|
2926
|
+
const isHub = Boolean(cluster.isHub)
|
|
2927
|
+
const radiusPx = clusterRadiusPx(cluster)
|
|
2766
2928
|
const radius = radiusPx / safeScale
|
|
2767
|
-
const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2929
|
+
const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
|
|
2930
|
+
if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
|
|
2931
|
+
ctx.beginPath()
|
|
2932
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
2933
|
+
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
2934
|
+
ctx.fill()
|
|
2935
|
+
}
|
|
2772
2936
|
ctx.beginPath()
|
|
2773
2937
|
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
2774
2938
|
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
2775
2939
|
ctx.fill()
|
|
2776
|
-
ctx.lineWidth = 1.4 / safeScale
|
|
2940
|
+
ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
|
|
2777
2941
|
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
2778
2942
|
ctx.stroke()
|
|
2779
2943
|
if (isMacro && cluster.representative?.title) {
|
package/package.json
CHANGED