@andespindola/brainlink 0.1.0-beta.81 → 0.1.0-beta.83

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
@@ -83,8 +83,10 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
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
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
+ - WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
86
87
  - Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
87
88
  - 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).
89
+ - Graph coordinates are visually compacted across graph sizes so reset starts from a stable macro mass and zoom-in progressively expands toward local detail.
88
90
  - Zoomed-out graph LOD clusters dense regions and progressively expands the focused viewport as zoom increases, including very large vaults.
89
91
  - Graph reset starts in macro "galaxy" overview mode and progressively reveals nearby nodes as zoom increases, including smaller vaults.
90
92
  - Graph filtering runs in a dedicated browser worker to keep the UI thread responsive during heavy datasets.
@@ -596,6 +598,8 @@ The graph UI shows:
596
598
  - double-click on canvas zooms in at cursor position
597
599
  - floating graph totals (notes, links, tags) below the Brainlink title
598
600
  - graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
601
+ - WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
602
+ - compact macro-to-micro density progression so reset keeps the graph mass oriented and zoom-in separates local neighborhoods progressively
599
603
  - massive-graph LOD progression: very low zoom uses spatial overview sampling plus hub-neighborhood edge previews to preserve whole-vault shape and orientation, then progressively raises the focused node budget as zoom increases so dense local areas keep nearby notes and links visible
600
604
 
601
605
  The server indexes before starting by default. Use `--no-index` to skip that step:
@@ -77,16 +77,33 @@ select {
77
77
  font-size: 18px;
78
78
  }
79
79
 
80
- #graph {
81
- display: block;
80
+ .graph-stage {
81
+ position: relative;
82
82
  width: 100%;
83
83
  height: 100%;
84
84
  background:
85
85
  radial-gradient(circle at 18% 20%, rgba(53, 208, 162, 0.12), transparent 28rem),
86
86
  linear-gradient(135deg, #0d0f12 0%, #12161c 55%, #0a0d10 100%);
87
+ overflow: hidden;
88
+ }
89
+
90
+ #graph,
91
+ #graphGl {
92
+ display: block;
93
+ position: absolute;
94
+ inset: 0;
95
+ width: 100%;
96
+ height: 100%;
97
+ }
98
+
99
+ #graph {
87
100
  cursor: grab;
88
101
  }
89
102
 
103
+ #graphGl {
104
+ pointer-events: none;
105
+ }
106
+
90
107
  #graph:active {
91
108
  cursor: grabbing;
92
109
  }
@@ -43,7 +43,10 @@ export const createClientHtml = () => `<!doctype html>
43
43
  </div>
44
44
  </div>
45
45
  </header>
46
- <canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
46
+ <div class="graph-stage">
47
+ <canvas id="graphGl" aria-hidden="true"></canvas>
48
+ <canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
49
+ </div>
47
50
  </section>
48
51
  </main>
49
52
  <footer class="app-footer" aria-label="Copyright notice">
@@ -1,4 +1,5 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
+ const glCanvas = document.getElementById('graphGl')
2
3
  const ctx = canvas.getContext('2d')
3
4
  const largeGraphNodeThreshold = 4000
4
5
  const massiveGraphNodeThreshold = 20000
@@ -175,6 +176,177 @@ const graphTheme = {
175
176
  label: '#edf2f7'
176
177
  }
177
178
 
179
+ const parseRgb = color => {
180
+ const normalized = color.trim()
181
+ if (normalized.startsWith('#')) {
182
+ const value = normalized.slice(1)
183
+ const expanded = value.length === 3
184
+ ? value.split('').map(char => char + char).join('')
185
+ : value
186
+ const parsed = Number.parseInt(expanded, 16)
187
+ return [
188
+ ((parsed >> 16) & 255) / 255,
189
+ ((parsed >> 8) & 255) / 255,
190
+ (parsed & 255) / 255
191
+ ]
192
+ }
193
+
194
+ const match = normalized.match(/rgba?\\(([^)]+)\\)/)
195
+ if (!match) return [1, 1, 1]
196
+ const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()))
197
+ return [
198
+ Math.max(0, Math.min(1, (parts[0] ?? 255) / 255)),
199
+ Math.max(0, Math.min(1, (parts[1] ?? 255) / 255)),
200
+ Math.max(0, Math.min(1, (parts[2] ?? 255) / 255))
201
+ ]
202
+ }
203
+
204
+ const rgba = (color, alpha = 1) => {
205
+ const [red, green, blue] = parseRgb(color)
206
+ return [red, green, blue, Math.max(0, Math.min(1, alpha))]
207
+ }
208
+
209
+ const createShader = (gl, type, source) => {
210
+ const shader = gl.createShader(type)
211
+ if (!shader) return null
212
+ gl.shaderSource(shader, source)
213
+ gl.compileShader(shader)
214
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
215
+ gl.deleteShader(shader)
216
+ return null
217
+ }
218
+ return shader
219
+ }
220
+
221
+ const createProgram = (gl, vertexSource, fragmentSource) => {
222
+ const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
223
+ const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
224
+ if (!vertexShader || !fragmentShader) return null
225
+ const program = gl.createProgram()
226
+ if (!program) return null
227
+ gl.attachShader(program, vertexShader)
228
+ gl.attachShader(program, fragmentShader)
229
+ gl.linkProgram(program)
230
+ gl.deleteShader(vertexShader)
231
+ gl.deleteShader(fragmentShader)
232
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
233
+ gl.deleteProgram(program)
234
+ return null
235
+ }
236
+ return program
237
+ }
238
+
239
+ const createWebGlRenderer = targetCanvas => {
240
+ if (!targetCanvas) return null
241
+
242
+ const gl = targetCanvas.getContext('webgl2', { alpha: true, antialias: true }) ||
243
+ targetCanvas.getContext('webgl', { alpha: true, antialias: true })
244
+ if (!gl) return null
245
+
246
+ const lineProgram = createProgram(
247
+ gl,
248
+ 'attribute vec2 a_position; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clip = zeroToOne * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); }',
249
+ 'precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }'
250
+ )
251
+ const pointProgram = createProgram(
252
+ gl,
253
+ 'attribute vec2 a_position; attribute float a_size; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clip = zeroToOne * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); gl_PointSize = a_size; }',
254
+ 'precision mediump float; uniform vec4 u_color; void main() { vec2 center = gl_PointCoord - vec2(0.5); float distanceFromCenter = length(center); if (distanceFromCenter > 0.5) discard; float edge = smoothstep(0.5, 0.42, distanceFromCenter); gl_FragColor = vec4(u_color.rgb, u_color.a * edge); }'
255
+ )
256
+
257
+ if (!lineProgram || !pointProgram) return null
258
+
259
+ const lineBuffer = gl.createBuffer()
260
+ const pointPositionBuffer = gl.createBuffer()
261
+ const pointSizeBuffer = gl.createBuffer()
262
+ if (!lineBuffer || !pointPositionBuffer || !pointSizeBuffer) return null
263
+
264
+ const linePositionLocation = gl.getAttribLocation(lineProgram, 'a_position')
265
+ const lineResolutionLocation = gl.getUniformLocation(lineProgram, 'u_resolution')
266
+ const lineColorLocation = gl.getUniformLocation(lineProgram, 'u_color')
267
+ const pointPositionLocation = gl.getAttribLocation(pointProgram, 'a_position')
268
+ const pointSizeLocation = gl.getAttribLocation(pointProgram, 'a_size')
269
+ const pointResolutionLocation = gl.getUniformLocation(pointProgram, 'u_resolution')
270
+ const pointColorLocation = gl.getUniformLocation(pointProgram, 'u_color')
271
+
272
+ gl.enable(gl.BLEND)
273
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
274
+
275
+ const setViewport = (width, height) => {
276
+ gl.viewport(0, 0, targetCanvas.width, targetCanvas.height)
277
+ return [targetCanvas.width / Math.max(width, 1), targetCanvas.height / Math.max(height, 1)]
278
+ }
279
+
280
+ const screenPoint = (node, ratioX, ratioY) => [
281
+ (node.x * state.transform.scale + state.transform.x) * ratioX,
282
+ (node.y * state.transform.scale + state.transform.y) * ratioY
283
+ ]
284
+
285
+ const clear = (width, height) => {
286
+ setViewport(width, height)
287
+ gl.clearColor(0, 0, 0, 0)
288
+ gl.clear(gl.COLOR_BUFFER_BIT)
289
+ }
290
+
291
+ const drawLines = (edges, color, width, height) => {
292
+ if (edges.length === 0) return
293
+ const [ratioX, ratioY] = setViewport(width, height)
294
+ const positions = new Float32Array(edges.length * 4)
295
+ for (let index = 0; index < edges.length; index += 1) {
296
+ const edge = edges[index]
297
+ const source = screenPoint(edge.sourceNode, ratioX, ratioY)
298
+ const target = screenPoint(edge.targetNode, ratioX, ratioY)
299
+ const offset = index * 4
300
+ positions[offset] = source[0]
301
+ positions[offset + 1] = source[1]
302
+ positions[offset + 2] = target[0]
303
+ positions[offset + 3] = target[1]
304
+ }
305
+
306
+ gl.useProgram(lineProgram)
307
+ gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer)
308
+ gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
309
+ gl.enableVertexAttribArray(linePositionLocation)
310
+ gl.vertexAttribPointer(linePositionLocation, 2, gl.FLOAT, false, 0, 0)
311
+ gl.uniform2f(lineResolutionLocation, targetCanvas.width, targetCanvas.height)
312
+ gl.uniform4fv(lineColorLocation, color)
313
+ gl.lineWidth(1)
314
+ gl.drawArrays(gl.LINES, 0, edges.length * 2)
315
+ }
316
+
317
+ const drawPoints = (nodes, color, sizeForNode, width, height) => {
318
+ if (nodes.length === 0) return
319
+ const [ratioX, ratioY] = setViewport(width, height)
320
+ const positions = new Float32Array(nodes.length * 2)
321
+ const sizes = new Float32Array(nodes.length)
322
+ for (let index = 0; index < nodes.length; index += 1) {
323
+ const node = nodes[index]
324
+ const point = screenPoint(node, ratioX, ratioY)
325
+ const offset = index * 2
326
+ positions[offset] = point[0]
327
+ positions[offset + 1] = point[1]
328
+ sizes[index] = sizeForNode(node) * ((ratioX + ratioY) / 2)
329
+ }
330
+
331
+ gl.useProgram(pointProgram)
332
+ gl.bindBuffer(gl.ARRAY_BUFFER, pointPositionBuffer)
333
+ gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
334
+ gl.enableVertexAttribArray(pointPositionLocation)
335
+ gl.vertexAttribPointer(pointPositionLocation, 2, gl.FLOAT, false, 0, 0)
336
+ gl.bindBuffer(gl.ARRAY_BUFFER, pointSizeBuffer)
337
+ gl.bufferData(gl.ARRAY_BUFFER, sizes, gl.STREAM_DRAW)
338
+ gl.enableVertexAttribArray(pointSizeLocation)
339
+ gl.vertexAttribPointer(pointSizeLocation, 1, gl.FLOAT, false, 0, 0)
340
+ gl.uniform2f(pointResolutionLocation, targetCanvas.width, targetCanvas.height)
341
+ gl.uniform4fv(pointColorLocation, color)
342
+ gl.drawArrays(gl.POINTS, 0, nodes.length)
343
+ }
344
+
345
+ return { clear, drawLines, drawPoints }
346
+ }
347
+
348
+ const webGlRenderer = createWebGlRenderer(glCanvas)
349
+
178
350
  const initFilterWorker = () => {
179
351
  if (typeof Worker === 'undefined') {
180
352
  return
@@ -243,6 +415,10 @@ const resize = () => {
243
415
  const ratio = window.devicePixelRatio || 1
244
416
  canvas.width = Math.floor(width * ratio)
245
417
  canvas.height = Math.floor(height * ratio)
418
+ if (glCanvas) {
419
+ glCanvas.width = Math.floor(width * ratio)
420
+ glCanvas.height = Math.floor(height * ratio)
421
+ }
246
422
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
247
423
  markRenderDirty()
248
424
  }
@@ -1065,6 +1241,105 @@ const drawGraphNodes = () => {
1065
1241
  priorityNodes.forEach(node => drawSingleNode(node))
1066
1242
  }
1067
1243
 
1244
+ const partitionGraphForAcceleratedRenderer = () => {
1245
+ const regularNodes = []
1246
+ const priorityNodes = []
1247
+ const regularEdges = []
1248
+ const inferredEdges = []
1249
+ const selectedEdges = []
1250
+
1251
+ for (let index = 0; index < state.renderNodes.length; index += 1) {
1252
+ const node = state.renderNodes[index]
1253
+ const isPriority =
1254
+ state.selected?.id === node.id ||
1255
+ state.hovered?.id === node.id
1256
+ if (isPriority) {
1257
+ priorityNodes.push(node)
1258
+ } else {
1259
+ regularNodes.push(node)
1260
+ }
1261
+ }
1262
+
1263
+ for (let index = 0; index < state.renderEdges.length; index += 1) {
1264
+ const edge = state.renderEdges[index]
1265
+ const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1266
+ if (isSelected) {
1267
+ selectedEdges.push(edge)
1268
+ } else if (edge.inferred) {
1269
+ inferredEdges.push(edge)
1270
+ } else {
1271
+ regularEdges.push(edge)
1272
+ }
1273
+ }
1274
+
1275
+ return { regularNodes, priorityNodes, regularEdges, inferredEdges, selectedEdges }
1276
+ }
1277
+
1278
+ const drawGraphLabels = nodes => {
1279
+ if (!(state.transform.scale >= 0.62 && state.renderNodes.length <= 1200)) {
1280
+ return
1281
+ }
1282
+
1283
+ ctx.fillStyle = graphTheme.label
1284
+ ctx.font = '12px Inter, system-ui, sans-serif'
1285
+ ctx.textAlign = 'center'
1286
+ ctx.textBaseline = 'top'
1287
+ for (let index = 0; index < nodes.length; index += 1) {
1288
+ const node = nodes[index]
1289
+ ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
1290
+ }
1291
+ }
1292
+
1293
+ const drawAcceleratedGraph = (width, height, drawEdges) => {
1294
+ if (!webGlRenderer || state.renderClusters.length > 0) {
1295
+ return false
1296
+ }
1297
+
1298
+ const graphParts = partitionGraphForAcceleratedRenderer()
1299
+ const scale = state.transform.scale
1300
+ webGlRenderer.clear(width, height)
1301
+ if (drawEdges) {
1302
+ webGlRenderer.drawLines(
1303
+ graphParts.regularEdges,
1304
+ rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
1305
+ width,
1306
+ height
1307
+ )
1308
+ webGlRenderer.drawLines(
1309
+ graphParts.inferredEdges,
1310
+ rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
1311
+ width,
1312
+ height
1313
+ )
1314
+ }
1315
+ webGlRenderer.drawPoints(
1316
+ graphParts.regularNodes,
1317
+ rgba(graphTheme.nodeHalo, 0.28),
1318
+ node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
1319
+ width,
1320
+ height
1321
+ )
1322
+ webGlRenderer.drawPoints(
1323
+ graphParts.regularNodes,
1324
+ rgba(graphTheme.node, 1),
1325
+ node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
1326
+ width,
1327
+ height
1328
+ )
1329
+
1330
+ ctx.save()
1331
+ ctx.translate(state.transform.x, state.transform.y)
1332
+ ctx.scale(state.transform.scale, state.transform.scale)
1333
+ if (drawEdges) {
1334
+ graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
1335
+ }
1336
+ drawGraphLabels(graphParts.regularNodes)
1337
+ graphParts.priorityNodes.forEach(node => drawSingleNode(node))
1338
+ ctx.restore()
1339
+
1340
+ return true
1341
+ }
1342
+
1068
1343
  const edgePairKey = (source, target) =>
1069
1344
  source < target ? source + '|' + target : target + '|' + source
1070
1345
 
@@ -1379,10 +1654,10 @@ const ensureHubNodesInRenderedSet = (nodes) => {
1379
1654
  }
1380
1655
 
1381
1656
  const zoomCapByNodeCount = (nodeCount) => {
1382
- if (nodeCount > 50000) return 2.6
1383
- if (nodeCount > 20000) return 2.35
1384
- if (nodeCount > 6000) return 2.1
1385
- if (nodeCount > 2000) return 2.2
1657
+ if (nodeCount > 50000) return 5.4
1658
+ if (nodeCount > 20000) return 4.8
1659
+ if (nodeCount > 6000) return 4.2
1660
+ if (nodeCount > 2000) return 4
1386
1661
  return zoomRange.max
1387
1662
  }
1388
1663
 
@@ -1541,10 +1816,15 @@ const focusPrimaryHub = () => {
1541
1816
  }
1542
1817
 
1543
1818
  const layoutDensityScaleForNodeCount = (nodeCount) => {
1544
- if (nodeCount > 50000) return 0.56
1545
- if (nodeCount > 20000) return 0.64
1546
- if (nodeCount > 6000) return 0.76
1547
- return 1
1819
+ if (nodeCount > 50000) return 0.26
1820
+ if (nodeCount > 20000) return 0.3
1821
+ if (nodeCount > 6000) return 0.36
1822
+ if (nodeCount > 2000) return 0.42
1823
+ if (nodeCount > 600) return 0.5
1824
+ if (nodeCount > 180) return 0.58
1825
+ if (nodeCount > 60) return 0.68
1826
+ if (nodeCount > 20) return 0.78
1827
+ return 0.88
1548
1828
  }
1549
1829
 
1550
1830
  const createLayout = graph => {
@@ -2256,6 +2536,7 @@ const render = now => {
2256
2536
  resetView()
2257
2537
  }
2258
2538
  ctx.clearRect(0, 0, width, height)
2539
+ webGlRenderer?.clear(width, height)
2259
2540
  if (state.nodes.length === 0) {
2260
2541
  ctx.fillStyle = '#99a5b5'
2261
2542
  ctx.font = '14px Inter, system-ui, sans-serif'
@@ -2264,9 +2545,6 @@ const render = now => {
2264
2545
  requestAnimationFrame(render)
2265
2546
  return
2266
2547
  }
2267
- ctx.save()
2268
- ctx.translate(state.transform.x, state.transform.y)
2269
- ctx.scale(state.transform.scale, state.transform.scale)
2270
2548
 
2271
2549
  computeRenderVisibility()
2272
2550
  tick(delta)
@@ -2299,11 +2577,12 @@ const render = now => {
2299
2577
  const drawEdges =
2300
2578
  state.renderClusters.length === 0 &&
2301
2579
  state.transform.scale >= minimumEdgeScale
2302
- if (drawEdges) {
2303
- drawGraphEdges()
2304
- }
2305
-
2306
- if (state.renderClusters.length > 0) {
2580
+ if (drawAcceleratedGraph(width, height, drawEdges)) {
2581
+ // WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
2582
+ } else if (state.renderClusters.length > 0) {
2583
+ ctx.save()
2584
+ ctx.translate(state.transform.x, state.transform.y)
2585
+ ctx.scale(state.transform.scale, state.transform.scale)
2307
2586
  const safeScale = Math.max(state.transform.scale, 0.0001)
2308
2587
  state.renderClusters.forEach(cluster => {
2309
2588
  const isMacro = cluster.id === 'macro-galaxy'
@@ -2332,11 +2611,17 @@ const render = now => {
2332
2611
  }
2333
2612
  // Keep cluster markers minimal and faster to draw on large graphs.
2334
2613
  })
2614
+ ctx.restore()
2335
2615
  } else {
2616
+ ctx.save()
2617
+ ctx.translate(state.transform.x, state.transform.y)
2618
+ ctx.scale(state.transform.scale, state.transform.scale)
2619
+ if (drawEdges) {
2620
+ drawGraphEdges()
2621
+ }
2336
2622
  drawGraphNodes()
2623
+ ctx.restore()
2337
2624
  }
2338
-
2339
- ctx.restore()
2340
2625
  if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
2341
2626
  ctx.fillStyle = '#99a5b5'
2342
2627
  ctx.font = '12px Inter, system-ui, sans-serif'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.81",
3
+ "version": "0.1.0-beta.83",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",