@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 and edge-cap transmission to reduce initial client load on very large vaults.
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 = 16000
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 scale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
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 radiusPx = Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
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
- elements.contentTitle.textContent = detailedNode.title
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
- elements.contentBody.textContent = 'Unable to load note content.'
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 compactEdges = [...layout.edges]
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: compactEdges
121
+ edges: compactEdgeRows
82
122
  },
83
123
  totals: {
84
124
  nodes: layout.nodes.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.47",
3
+ "version": "0.1.0-beta.49",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",