@andespindola/brainlink 0.1.0-beta.85 → 0.1.0-beta.87
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 +186 -50
- 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 zoom-out renders
|
|
87
|
+
- Graph zoom-out renders hierarchical ecosystem subgraphs only above 1000 notes: distant groups stay as small sand-like points and expand near the user's focus 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.
|
|
91
|
-
- Zoomed-out graph LOD
|
|
91
|
+
- Zoomed-out graph LOD renders nested subgraphs and progressively expands only the focused cluster as zoom increases, including very large vaults.
|
|
92
92
|
- Graph reset starts in macro "galaxy" overview mode and progressively reveals nearby nodes as zoom increases, including smaller vaults.
|
|
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.
|
|
@@ -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 connected sand-like ecosystem points of up to 1000 notes, 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,13 @@ 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
|
|
26
|
+
const ecosystemGroupSizes = [1000, 250, 60]
|
|
25
27
|
const ecosystemClusterEdgeLimit = 520
|
|
26
|
-
const ecosystemClusterScaleThreshold = 0.
|
|
28
|
+
const ecosystemClusterScaleThreshold = 0.32
|
|
29
|
+
const ecosystemSubgraphScaleThreshold = 0.18
|
|
30
|
+
const ecosystemMicroScaleThreshold = 0.08
|
|
31
|
+
const ecosystemFocusedParentLimit = 3
|
|
27
32
|
const zoomRecoveryGuardMs = 4200
|
|
28
33
|
const zoomCapTargetViewportShare = 0.72
|
|
29
34
|
const meshEdgeScaleThreshold = 0.09
|
|
@@ -68,7 +73,7 @@ const state = {
|
|
|
68
73
|
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
69
74
|
visibleEdgeByNode: new Map(),
|
|
70
75
|
ecosystemClusters: [],
|
|
71
|
-
|
|
76
|
+
ecosystemClustersBySize: new Map(),
|
|
72
77
|
macroCenter: { x: 0, y: 0 },
|
|
73
78
|
macroRepresentative: null,
|
|
74
79
|
primaryHub: null,
|
|
@@ -565,11 +570,11 @@ const recomputeVisibility = () => {
|
|
|
565
570
|
state.visibleEdges = limitedEdges
|
|
566
571
|
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
567
572
|
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
568
|
-
const ecosystemGraph = nodes.length >
|
|
569
|
-
? buildEcosystemGraph(nodes
|
|
570
|
-
: { clusters: [],
|
|
573
|
+
const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
|
|
574
|
+
? buildEcosystemGraph(nodes)
|
|
575
|
+
: { clusters: [], clustersBySize: new Map() }
|
|
571
576
|
state.ecosystemClusters = ecosystemGraph.clusters
|
|
572
|
-
state.
|
|
577
|
+
state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
|
|
573
578
|
const primaryHub = rankedHubNodes()[0] ?? null
|
|
574
579
|
state.primaryHub = primaryHub
|
|
575
580
|
state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
|
|
@@ -738,32 +743,168 @@ const buildEcosystemCluster = (nodes, index) => {
|
|
|
738
743
|
x: sum.x / count,
|
|
739
744
|
y: sum.y / count,
|
|
740
745
|
count,
|
|
746
|
+
nodeIds: nodes.map(node => node.id),
|
|
741
747
|
representative,
|
|
742
748
|
label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
|
|
743
749
|
}
|
|
744
750
|
}
|
|
745
751
|
|
|
746
|
-
const
|
|
747
|
-
if (nodes.length === 0) {
|
|
748
|
-
return { clusters: [], edges: [] }
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
const sortedNodes = [...nodes].sort(compareNodesForEcosystem)
|
|
752
|
+
const buildEcosystemLevel = (sortedNodes, size, parentLookup) => {
|
|
752
753
|
const clusters = []
|
|
753
754
|
const clusterByNodeId = new Map()
|
|
754
755
|
|
|
755
|
-
for (let offset = 0; offset < sortedNodes.length; offset +=
|
|
756
|
-
const clusterNodes = sortedNodes.slice(offset, offset +
|
|
757
|
-
const
|
|
756
|
+
for (let offset = 0; offset < sortedNodes.length; offset += size) {
|
|
757
|
+
const clusterNodes = sortedNodes.slice(offset, offset + size)
|
|
758
|
+
const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
|
|
759
|
+
const cluster = {
|
|
760
|
+
...buildEcosystemCluster(clusterNodes, clusters.length),
|
|
761
|
+
id: 'ecosystem-' + size + '-' + clusters.length,
|
|
762
|
+
size,
|
|
763
|
+
parentId: parentCluster?.id ?? null,
|
|
764
|
+
parentX: parentCluster?.x ?? null,
|
|
765
|
+
parentY: parentCluster?.y ?? null
|
|
766
|
+
}
|
|
758
767
|
clusters.push(cluster)
|
|
759
768
|
for (let index = 0; index < clusterNodes.length; index += 1) {
|
|
760
769
|
clusterByNodeId.set(clusterNodes[index].id, cluster)
|
|
761
770
|
}
|
|
762
771
|
}
|
|
763
772
|
|
|
773
|
+
return { clusters, clusterByNodeId }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const buildEcosystemGraph = (nodes) => {
|
|
777
|
+
if (nodes.length === 0) {
|
|
778
|
+
return { clusters: [], clustersBySize: new Map() }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const sortedNodes = [...nodes].sort(compareNodesForEcosystem)
|
|
782
|
+
const clustersBySize = new Map()
|
|
783
|
+
let parentLookup = null
|
|
784
|
+
|
|
785
|
+
for (let index = 0; index < ecosystemGroupSizes.length; index += 1) {
|
|
786
|
+
const size = ecosystemGroupSizes[index]
|
|
787
|
+
const level = buildEcosystemLevel(sortedNodes, size, parentLookup)
|
|
788
|
+
clustersBySize.set(size, level.clusters)
|
|
789
|
+
parentLookup = level.clusterByNodeId
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
clusters: clustersBySize.get(ecosystemGroupSize) ?? [],
|
|
794
|
+
clustersBySize
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const isClusterInViewport = (cluster, viewport) =>
|
|
799
|
+
cluster.x >= viewport.minX &&
|
|
800
|
+
cluster.x <= viewport.maxX &&
|
|
801
|
+
cluster.y >= viewport.minY &&
|
|
802
|
+
cluster.y <= viewport.maxY
|
|
803
|
+
|
|
804
|
+
const filterEcosystemClustersByViewport = (clusters, viewport) => {
|
|
805
|
+
const visible = clusters.filter(cluster => isClusterInViewport(cluster, viewport))
|
|
806
|
+
return visible.length > 0 ? visible : [...clusters]
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const ecosystemFocusPoint = () => {
|
|
810
|
+
const now = performance.now()
|
|
811
|
+
if (now - state.lastZoomFocus.at <= 1800) {
|
|
812
|
+
return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
813
|
+
}
|
|
814
|
+
return viewportCenterWorldPoint()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
|
|
818
|
+
clusters
|
|
819
|
+
.map(cluster => ({
|
|
820
|
+
cluster,
|
|
821
|
+
distance: Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y)
|
|
822
|
+
}))
|
|
823
|
+
.sort((left, right) => left.distance - right.distance)
|
|
824
|
+
.slice(0, limit)
|
|
825
|
+
.map(item => item.cluster.id)
|
|
826
|
+
|
|
827
|
+
const smoothStep = value => {
|
|
828
|
+
const clamped = Math.max(0, Math.min(1, value))
|
|
829
|
+
return clamped * clamped * (3 - clamped * 2)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const zoomProgress = (scale, start, end) =>
|
|
833
|
+
smoothStep((scale - start) / Math.max(end - start, 0.0001))
|
|
834
|
+
|
|
835
|
+
const ecosystemPlanForScale = scale => {
|
|
836
|
+
if (scale <= ecosystemMicroScaleThreshold) {
|
|
837
|
+
return { baseSize: 1000, childSize: null, spread: 0 }
|
|
838
|
+
}
|
|
839
|
+
if (scale <= ecosystemSubgraphScaleThreshold) {
|
|
840
|
+
return {
|
|
841
|
+
baseSize: 1000,
|
|
842
|
+
childSize: 250,
|
|
843
|
+
spread: zoomProgress(scale, ecosystemMicroScaleThreshold, ecosystemSubgraphScaleThreshold)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
baseSize: 250,
|
|
848
|
+
childSize: 60,
|
|
849
|
+
spread: zoomProgress(scale, ecosystemSubgraphScaleThreshold, ecosystemClusterScaleThreshold)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const spreadChildClusterFromParent = (cluster, spread) => {
|
|
854
|
+
if (!Number.isFinite(cluster.parentX) || !Number.isFinite(cluster.parentY)) {
|
|
855
|
+
return cluster
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
...cluster,
|
|
860
|
+
x: cluster.parentX + (cluster.x - cluster.parentX) * spread,
|
|
861
|
+
y: cluster.parentY + (cluster.y - cluster.parentY) * spread
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const selectHierarchicalEcosystemClusters = viewport => {
|
|
866
|
+
const plan = ecosystemPlanForScale(state.transform.scale)
|
|
867
|
+
const baseClusters = state.ecosystemClustersBySize.get(plan.baseSize) ?? state.ecosystemClusters
|
|
868
|
+
const visibleBaseClusters = filterEcosystemClustersByViewport(baseClusters, viewport)
|
|
869
|
+
|
|
870
|
+
if (!plan.childSize) {
|
|
871
|
+
return visibleBaseClusters
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const focusPoint = ecosystemFocusPoint()
|
|
875
|
+
const expandedParentIds = new Set(nearestEcosystemParentIds(
|
|
876
|
+
visibleBaseClusters,
|
|
877
|
+
focusPoint,
|
|
878
|
+
ecosystemFocusedParentLimit
|
|
879
|
+
))
|
|
880
|
+
const childClusters = state.ecosystemClustersBySize.get(plan.childSize) ?? []
|
|
881
|
+
const visibleChildClusters = childClusters.filter(cluster =>
|
|
882
|
+
expandedParentIds.has(cluster.parentId) &&
|
|
883
|
+
isClusterInViewport(cluster, viewport)
|
|
884
|
+
).map(cluster => spreadChildClusterFromParent(cluster, plan.spread))
|
|
885
|
+
|
|
886
|
+
if (visibleChildClusters.length === 0) {
|
|
887
|
+
return visibleBaseClusters
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return [
|
|
891
|
+
...visibleBaseClusters.filter(cluster => !expandedParentIds.has(cluster.id)),
|
|
892
|
+
...visibleChildClusters
|
|
893
|
+
]
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const ecosystemEdgesForClusters = clusters => {
|
|
897
|
+
const clusterByNodeId = new Map()
|
|
898
|
+
for (let clusterIndex = 0; clusterIndex < clusters.length; clusterIndex += 1) {
|
|
899
|
+
const cluster = clusters[clusterIndex]
|
|
900
|
+
for (let nodeIndex = 0; nodeIndex < cluster.nodeIds.length; nodeIndex += 1) {
|
|
901
|
+
clusterByNodeId.set(cluster.nodeIds[nodeIndex], cluster)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
764
905
|
const edgeByClusterPair = new Map()
|
|
765
|
-
for (let index = 0; index <
|
|
766
|
-
const edge =
|
|
906
|
+
for (let index = 0; index < state.visibleEdges.length; index += 1) {
|
|
907
|
+
const edge = state.visibleEdges[index]
|
|
767
908
|
const sourceCluster = clusterByNodeId.get(edge.source)
|
|
768
909
|
const targetCluster = clusterByNodeId.get(edge.target)
|
|
769
910
|
if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
|
|
@@ -788,27 +929,9 @@ const buildEcosystemGraph = (nodes, edges) => {
|
|
|
788
929
|
})
|
|
789
930
|
}
|
|
790
931
|
|
|
791
|
-
|
|
932
|
+
return Array.from(edgeByClusterPair.values())
|
|
792
933
|
.sort((left, right) => right.weight - left.weight)
|
|
793
934
|
.slice(0, ecosystemClusterEdgeLimit)
|
|
794
|
-
|
|
795
|
-
return { clusters, edges: aggregatedEdges }
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const filterEcosystemClustersByViewport = viewport =>
|
|
799
|
-
state.ecosystemClusters.filter((cluster) =>
|
|
800
|
-
cluster.x >= viewport.minX &&
|
|
801
|
-
cluster.x <= viewport.maxX &&
|
|
802
|
-
cluster.y >= viewport.minY &&
|
|
803
|
-
cluster.y <= viewport.maxY
|
|
804
|
-
)
|
|
805
|
-
|
|
806
|
-
const ecosystemEdgesForClusters = clusters => {
|
|
807
|
-
const clusterIds = new Set(clusters.map(cluster => cluster.id))
|
|
808
|
-
return state.ecosystemEdges.filter(edge =>
|
|
809
|
-
clusterIds.has(edge.sourceCluster.id) &&
|
|
810
|
-
clusterIds.has(edge.targetCluster.id)
|
|
811
|
-
)
|
|
812
935
|
}
|
|
813
936
|
|
|
814
937
|
const edgeBudgetForCurrentFrame = () => {
|
|
@@ -2245,6 +2368,17 @@ const baseNodeRadius = node => {
|
|
|
2245
2368
|
|
|
2246
2369
|
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
2247
2370
|
|
|
2371
|
+
const clusterRadiusPx = cluster => {
|
|
2372
|
+
if (cluster.id === 'macro-galaxy') {
|
|
2373
|
+
return 10
|
|
2374
|
+
}
|
|
2375
|
+
if (String(cluster.id).startsWith('ecosystem-')) {
|
|
2376
|
+
const base = cluster.size >= 1000 ? 2.4 : cluster.size >= 250 ? 2.1 : 1.8
|
|
2377
|
+
return Math.max(1.8, Math.min(4.2, base + Math.log10(cluster.count + 1) * 0.28))
|
|
2378
|
+
}
|
|
2379
|
+
return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2248
2382
|
const worldViewportBounds = () => {
|
|
2249
2383
|
const rect = canvas.getBoundingClientRect()
|
|
2250
2384
|
const width = Math.max(rect.width, 320)
|
|
@@ -2406,12 +2540,13 @@ const computeRenderVisibility = () => {
|
|
|
2406
2540
|
return
|
|
2407
2541
|
}
|
|
2408
2542
|
|
|
2409
|
-
if (
|
|
2410
|
-
|
|
2543
|
+
if (
|
|
2544
|
+
state.visibleNodes.length > ecosystemActivationNodeThreshold &&
|
|
2545
|
+
state.transform.scale <= ecosystemClusterScaleThreshold &&
|
|
2546
|
+
state.ecosystemClusters.length > 0
|
|
2547
|
+
) {
|
|
2548
|
+
const clusters = selectHierarchicalEcosystemClusters(viewport)
|
|
2411
2549
|
.sort((left, right) => right.count - left.count)
|
|
2412
|
-
const clusters = viewportClusters.length > 0
|
|
2413
|
-
? viewportClusters
|
|
2414
|
-
: state.ecosystemClusters
|
|
2415
2550
|
state.renderClusters = clusters
|
|
2416
2551
|
state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
|
|
2417
2552
|
state.renderNodes = []
|
|
@@ -2671,20 +2806,21 @@ const render = now => {
|
|
|
2671
2806
|
}
|
|
2672
2807
|
state.renderClusters.forEach(cluster => {
|
|
2673
2808
|
const isMacro = cluster.id === 'macro-galaxy'
|
|
2674
|
-
const
|
|
2675
|
-
|
|
2676
|
-
: Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
2809
|
+
const isEcosystem = String(cluster.id).startsWith('ecosystem-')
|
|
2810
|
+
const radiusPx = clusterRadiusPx(cluster)
|
|
2677
2811
|
const radius = radiusPx / safeScale
|
|
2678
|
-
const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2812
|
+
const haloRadius = (radiusPx + (isMacro ? 8 : isEcosystem ? 1.1 : 4)) / safeScale
|
|
2813
|
+
if (!isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
|
|
2814
|
+
ctx.beginPath()
|
|
2815
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
2816
|
+
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
2817
|
+
ctx.fill()
|
|
2818
|
+
}
|
|
2683
2819
|
ctx.beginPath()
|
|
2684
2820
|
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
2685
2821
|
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
2686
2822
|
ctx.fill()
|
|
2687
|
-
ctx.lineWidth = 1.4 / safeScale
|
|
2823
|
+
ctx.lineWidth = (isEcosystem ? 0.7 : 1.4) / safeScale
|
|
2688
2824
|
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
2689
2825
|
ctx.stroke()
|
|
2690
2826
|
if (isMacro && cluster.representative?.title) {
|
package/package.json
CHANGED