@andespindola/brainlink 0.1.0-beta.47 → 0.1.0-beta.49
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
CHANGED
|
@@ -82,8 +82,9 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
82
82
|
- Local HTTP API.
|
|
83
83
|
- Realtime graph UI with agent selector and colored knowledge groups.
|
|
84
84
|
- Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
|
|
85
|
-
- Large graph layout API automatically uses compact payload encoding
|
|
85
|
+
- Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
|
|
86
86
|
- Zoomed-out graph LOD now clusters dense regions and progressively expands nodes as zoom increases.
|
|
87
|
+
- Graph reset starts in macro "galaxy" overview mode and progressively reveals nearby nodes as zoom increases, including smaller vaults.
|
|
87
88
|
- Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
|
|
88
89
|
- Edge rendering budgets adapt to zoom level to prevent frame spikes on large graph panoramas.
|
|
89
90
|
|
|
@@ -2,11 +2,14 @@ export const createClientJs = () => `const canvas = document.getElementById('gra
|
|
|
2
2
|
const ctx = canvas.getContext('2d')
|
|
3
3
|
const largeGraphNodeThreshold = 4000
|
|
4
4
|
const massiveGraphNodeThreshold = 20000
|
|
5
|
-
const largeGraphEdgeRenderLimit =
|
|
5
|
+
const largeGraphEdgeRenderLimit = 120000
|
|
6
6
|
const renderNodeBudget = 900
|
|
7
7
|
const renderEdgeBudget = 2400
|
|
8
8
|
const clusterActivationNodeThreshold = 600
|
|
9
9
|
const clusterZoomThreshold = 0.18
|
|
10
|
+
const macroGalaxyZoomThreshold = 0.012
|
|
11
|
+
const massiveAutoFitMacroScale = 0.006
|
|
12
|
+
const defaultMacroScale = 0.006
|
|
10
13
|
const clusterCellPixelSize = 64
|
|
11
14
|
const minNodePixelRadius = 2.3
|
|
12
15
|
const viewportPaddingPx = 280
|
|
@@ -45,6 +48,8 @@ const state = {
|
|
|
45
48
|
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
46
49
|
visibleEdgeByNode: new Map(),
|
|
47
50
|
overviewClusters: [],
|
|
51
|
+
macroCenter: { x: 0, y: 0 },
|
|
52
|
+
macroRepresentative: null,
|
|
48
53
|
filterWorker: null,
|
|
49
54
|
filterReady: false,
|
|
50
55
|
lastHoverHitAt: 0
|
|
@@ -245,6 +250,26 @@ const filteredNodes = () => {
|
|
|
245
250
|
return withPersistentHubNodes(localFilteredNodes(query))
|
|
246
251
|
}
|
|
247
252
|
|
|
253
|
+
const resolveMacroRepresentative = (nodes) => {
|
|
254
|
+
if (nodes.length === 0) {
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let best = nodes[0]
|
|
259
|
+
let bestDegree = state.nodeDegrees.get(best.id) ?? 0
|
|
260
|
+
|
|
261
|
+
for (let index = 1; index < nodes.length; index += 1) {
|
|
262
|
+
const node = nodes[index]
|
|
263
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
264
|
+
if (degree > bestDegree) {
|
|
265
|
+
best = node
|
|
266
|
+
bestDegree = degree
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return best
|
|
271
|
+
}
|
|
272
|
+
|
|
248
273
|
const recomputeVisibility = () => {
|
|
249
274
|
const nodes = filteredNodes()
|
|
250
275
|
const ids = new Set(nodes.map(node => node.id))
|
|
@@ -260,6 +285,14 @@ const recomputeVisibility = () => {
|
|
|
260
285
|
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
261
286
|
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
262
287
|
state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
|
|
288
|
+
const bounds = graphBounds(nodes)
|
|
289
|
+
state.macroCenter = bounds
|
|
290
|
+
? {
|
|
291
|
+
x: (bounds.minX + bounds.maxX) / 2,
|
|
292
|
+
y: (bounds.minY + bounds.maxY) / 2
|
|
293
|
+
}
|
|
294
|
+
: { x: 0, y: 0 }
|
|
295
|
+
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
263
296
|
markRenderDirty()
|
|
264
297
|
}
|
|
265
298
|
|
|
@@ -577,7 +610,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
|
577
610
|
return { min: 0.0008, max: 0.24 }
|
|
578
611
|
}
|
|
579
612
|
|
|
580
|
-
const fitView = (options = { useFiltered: true }) => {
|
|
613
|
+
const fitView = (options = { useFiltered: true, macro: false }) => {
|
|
581
614
|
const rect = canvas.getBoundingClientRect()
|
|
582
615
|
const width = Math.max(rect.width, 320)
|
|
583
616
|
const height = Math.max(rect.height, 320)
|
|
@@ -607,7 +640,13 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
607
640
|
const fitScale = Math.min(scaleX, scaleY)
|
|
608
641
|
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
609
642
|
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
610
|
-
const
|
|
643
|
+
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
644
|
+
const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
|
|
645
|
+
const scale = options.macro && nodes.length > 1
|
|
646
|
+
? clampScale(Math.min(baselineScale, macroScale))
|
|
647
|
+
: nodes.length > massiveGraphNodeThreshold
|
|
648
|
+
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
649
|
+
: baselineScale
|
|
611
650
|
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
612
651
|
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
613
652
|
|
|
@@ -621,7 +660,7 @@ const fitView = (options = { useFiltered: true }) => {
|
|
|
621
660
|
markRenderDirty()
|
|
622
661
|
}
|
|
623
662
|
|
|
624
|
-
const resetView = () => fitView({ useFiltered: false })
|
|
663
|
+
const resetView = () => fitView({ useFiltered: false, macro: true })
|
|
625
664
|
|
|
626
665
|
const createLayout = graph => {
|
|
627
666
|
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
@@ -970,6 +1009,32 @@ const computeRenderVisibility = () => {
|
|
|
970
1009
|
state.lastViewportKey = viewportKey
|
|
971
1010
|
state.renderVisibilityDirty = false
|
|
972
1011
|
|
|
1012
|
+
const shouldRenderMacroGalaxy =
|
|
1013
|
+
state.transform.scale <= macroGalaxyZoomThreshold && state.visibleNodes.length > 1
|
|
1014
|
+
|
|
1015
|
+
if (shouldRenderMacroGalaxy) {
|
|
1016
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1017
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
1018
|
+
const representative = state.macroRepresentative ?? sourceNodes[0] ?? null
|
|
1019
|
+
if (representative) {
|
|
1020
|
+
state.renderClusters = [
|
|
1021
|
+
{
|
|
1022
|
+
id: 'macro-galaxy',
|
|
1023
|
+
x: state.macroCenter.x,
|
|
1024
|
+
y: state.macroCenter.y,
|
|
1025
|
+
count: sourceNodes.length,
|
|
1026
|
+
representative
|
|
1027
|
+
}
|
|
1028
|
+
]
|
|
1029
|
+
state.renderNodes = [representative]
|
|
1030
|
+
} else {
|
|
1031
|
+
state.renderClusters = []
|
|
1032
|
+
state.renderNodes = []
|
|
1033
|
+
}
|
|
1034
|
+
state.renderEdges = []
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
|
|
973
1038
|
if (state.visibleNodes.length <= 2000) {
|
|
974
1039
|
state.renderNodes = state.visibleNodes
|
|
975
1040
|
state.renderClusters = []
|
|
@@ -1167,19 +1232,22 @@ const render = now => {
|
|
|
1167
1232
|
if (state.renderClusters.length > 0) {
|
|
1168
1233
|
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
1169
1234
|
state.renderClusters.forEach(cluster => {
|
|
1170
|
-
const
|
|
1235
|
+
const isMacro = cluster.id === 'macro-galaxy'
|
|
1236
|
+
const radiusPx = isMacro
|
|
1237
|
+
? 10
|
|
1238
|
+
: Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
1171
1239
|
const radius = radiusPx / safeScale
|
|
1172
|
-
const haloRadius = (radiusPx + 4) / safeScale
|
|
1240
|
+
const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
|
|
1173
1241
|
ctx.beginPath()
|
|
1174
1242
|
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
1175
|
-
ctx.fillStyle = graphTheme.nodeHalo
|
|
1243
|
+
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
1176
1244
|
ctx.fill()
|
|
1177
1245
|
ctx.beginPath()
|
|
1178
1246
|
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
1179
|
-
ctx.fillStyle = graphTheme.node
|
|
1247
|
+
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
1180
1248
|
ctx.fill()
|
|
1181
1249
|
ctx.lineWidth = 1.4 / safeScale
|
|
1182
|
-
ctx.strokeStyle = graphTheme.nodeStroke
|
|
1250
|
+
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
1183
1251
|
ctx.stroke()
|
|
1184
1252
|
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
1185
1253
|
})
|
|
@@ -1267,6 +1335,8 @@ const fetchNodeDetails = async node => {
|
|
|
1267
1335
|
return detail
|
|
1268
1336
|
}
|
|
1269
1337
|
|
|
1338
|
+
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
1339
|
+
|
|
1270
1340
|
const openContentDialog = async node => {
|
|
1271
1341
|
if (!node) return
|
|
1272
1342
|
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
@@ -1282,19 +1352,32 @@ const openContentDialog = async node => {
|
|
|
1282
1352
|
elements.contentDialog.showModal()
|
|
1283
1353
|
}
|
|
1284
1354
|
|
|
1355
|
+
const applyDetailToDialog = detail => {
|
|
1356
|
+
elements.contentTitle.textContent = detail.title
|
|
1357
|
+
elements.contentPath.textContent = detail.path
|
|
1358
|
+
elements.contentTags.innerHTML = detail.tags.length
|
|
1359
|
+
? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
1360
|
+
: '<span>No tags</span>'
|
|
1361
|
+
elements.contentBody.textContent = detail.content
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1285
1364
|
try {
|
|
1286
1365
|
const detailedNode = await fetchNodeDetails(node)
|
|
1287
1366
|
if (state.selected?.id !== node.id) {
|
|
1288
1367
|
return
|
|
1289
1368
|
}
|
|
1290
|
-
|
|
1291
|
-
elements.contentPath.textContent = detailedNode.path
|
|
1292
|
-
elements.contentTags.innerHTML = detailedNode.tags.length
|
|
1293
|
-
? detailedNode.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
1294
|
-
: '<span>No tags</span>'
|
|
1295
|
-
elements.contentBody.textContent = detailedNode.content
|
|
1369
|
+
applyDetailToDialog(detailedNode)
|
|
1296
1370
|
} catch {
|
|
1297
|
-
|
|
1371
|
+
try {
|
|
1372
|
+
await wait(120)
|
|
1373
|
+
const retriedNode = await fetchNodeDetails(node)
|
|
1374
|
+
if (state.selected?.id !== node.id) {
|
|
1375
|
+
return
|
|
1376
|
+
}
|
|
1377
|
+
applyDetailToDialog(retriedNode)
|
|
1378
|
+
} catch {
|
|
1379
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
1380
|
+
}
|
|
1298
1381
|
}
|
|
1299
1382
|
}
|
|
1300
1383
|
|
|
@@ -63,22 +63,62 @@ const compactGraphLayoutEdgeLimitFor = (nodeCount) => {
|
|
|
63
63
|
return 30_000;
|
|
64
64
|
return compactGraphLayoutEdgeLimit;
|
|
65
65
|
};
|
|
66
|
+
const edgeWeight = (weight) => Number.isFinite(weight) ? Number(weight) : 1;
|
|
67
|
+
const edgeKey = (source, target, priority) => `${source}|${target}|${priority}`;
|
|
68
|
+
const selectCompactEdges = (layout, limit) => {
|
|
69
|
+
const resolvedEdges = layout.edges.filter((edge) => typeof edge.target === 'string' && edge.target.length > 0);
|
|
70
|
+
if (resolvedEdges.length <= limit) {
|
|
71
|
+
return resolvedEdges;
|
|
72
|
+
}
|
|
73
|
+
const bestEdgeByEndpoint = new Map();
|
|
74
|
+
for (let index = 0; index < resolvedEdges.length; index += 1) {
|
|
75
|
+
const edge = resolvedEdges[index];
|
|
76
|
+
const endpoints = [edge.source, edge.target];
|
|
77
|
+
for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex += 1) {
|
|
78
|
+
const endpoint = endpoints[endpointIndex];
|
|
79
|
+
const previous = bestEdgeByEndpoint.get(endpoint);
|
|
80
|
+
if (!previous || edgeWeight(edge.weight) > edgeWeight(previous.weight)) {
|
|
81
|
+
bestEdgeByEndpoint.set(endpoint, edge);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const selected = new Map();
|
|
86
|
+
for (const edge of bestEdgeByEndpoint.values()) {
|
|
87
|
+
selected.set(edgeKey(edge.source, edge.target, edge.priority), edge);
|
|
88
|
+
}
|
|
89
|
+
if (selected.size > limit) {
|
|
90
|
+
return Array.from(selected.values())
|
|
91
|
+
.sort((left, right) => edgeWeight(right.weight) - edgeWeight(left.weight))
|
|
92
|
+
.slice(0, limit);
|
|
93
|
+
}
|
|
94
|
+
const byWeight = [...resolvedEdges].sort((left, right) => edgeWeight(right.weight) - edgeWeight(left.weight));
|
|
95
|
+
for (let index = 0; index < byWeight.length; index += 1) {
|
|
96
|
+
if (selected.size >= limit) {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const edge = byWeight[index];
|
|
100
|
+
const key = edgeKey(edge.source, edge.target, edge.priority);
|
|
101
|
+
if (!selected.has(key)) {
|
|
102
|
+
selected.set(key, edge);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return Array.from(selected.values());
|
|
106
|
+
};
|
|
66
107
|
const stripLayoutContent = (layout) => ({
|
|
67
108
|
...layout,
|
|
68
109
|
nodes: layout.nodes.map(({ content, ...node }) => node)
|
|
69
110
|
});
|
|
70
111
|
const compactLayoutPayload = (layout) => {
|
|
71
112
|
const edgeLimit = compactGraphLayoutEdgeLimitFor(layout.nodes.length);
|
|
113
|
+
const compactEdges = selectCompactEdges(layout, edgeLimit);
|
|
72
114
|
const compactNodes = layout.nodes.map((node) => [node.id, node.title, node.x, node.y, node.group, node.segment]);
|
|
73
|
-
const
|
|
74
|
-
.sort((left, right) => (right.weight ?? 1) - (left.weight ?? 1))
|
|
75
|
-
.slice(0, edgeLimit)
|
|
115
|
+
const compactEdgeRows = compactEdges
|
|
76
116
|
.map((edge) => [edge.source, edge.target, edge.weight, edge.priority]);
|
|
77
117
|
return {
|
|
78
118
|
compact: true,
|
|
79
119
|
layout: {
|
|
80
120
|
nodes: compactNodes,
|
|
81
|
-
edges:
|
|
121
|
+
edges: compactEdgeRows
|
|
82
122
|
},
|
|
83
123
|
totals: {
|
|
84
124
|
nodes: layout.nodes.length,
|
package/package.json
CHANGED