@andespindola/brainlink 0.1.0-beta.82 → 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,6 +83,7 @@ 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).
88
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.
@@ -597,6 +598,7 @@ The graph UI shows:
597
598
  - double-click on canvas zooms in at cursor position
598
599
  - floating graph totals (notes, links, tags) below the Brainlink title
599
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
600
602
  - compact macro-to-micro density progression so reset keeps the graph mass oriented and zoom-in separates local neighborhoods progressively
601
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
602
604
 
@@ -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
 
@@ -2261,6 +2536,7 @@ const render = now => {
2261
2536
  resetView()
2262
2537
  }
2263
2538
  ctx.clearRect(0, 0, width, height)
2539
+ webGlRenderer?.clear(width, height)
2264
2540
  if (state.nodes.length === 0) {
2265
2541
  ctx.fillStyle = '#99a5b5'
2266
2542
  ctx.font = '14px Inter, system-ui, sans-serif'
@@ -2269,9 +2545,6 @@ const render = now => {
2269
2545
  requestAnimationFrame(render)
2270
2546
  return
2271
2547
  }
2272
- ctx.save()
2273
- ctx.translate(state.transform.x, state.transform.y)
2274
- ctx.scale(state.transform.scale, state.transform.scale)
2275
2548
 
2276
2549
  computeRenderVisibility()
2277
2550
  tick(delta)
@@ -2304,11 +2577,12 @@ const render = now => {
2304
2577
  const drawEdges =
2305
2578
  state.renderClusters.length === 0 &&
2306
2579
  state.transform.scale >= minimumEdgeScale
2307
- if (drawEdges) {
2308
- drawGraphEdges()
2309
- }
2310
-
2311
- 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)
2312
2586
  const safeScale = Math.max(state.transform.scale, 0.0001)
2313
2587
  state.renderClusters.forEach(cluster => {
2314
2588
  const isMacro = cluster.id === 'macro-galaxy'
@@ -2337,11 +2611,17 @@ const render = now => {
2337
2611
  }
2338
2612
  // Keep cluster markers minimal and faster to draw on large graphs.
2339
2613
  })
2614
+ ctx.restore()
2340
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
+ }
2341
2622
  drawGraphNodes()
2623
+ ctx.restore()
2342
2624
  }
2343
-
2344
- ctx.restore()
2345
2625
  if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
2346
2626
  ctx.fillStyle = '#99a5b5'
2347
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.82",
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",