@andespindola/brainlink 0.1.0-beta.82 → 0.1.0-beta.84

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,7 +598,9 @@ 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
603
+ - 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
601
604
  - 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
605
 
603
606
  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
  }
@@ -361,6 +537,17 @@ const nearestHubNeighborDistance = (hub, nodes) => {
361
537
  return minimum
362
538
  }
363
539
 
540
+ const isDominantHub = (hub, nodeCount = state.visibleNodes.length) => {
541
+ if (!hub || nodeCount <= 0) {
542
+ return false
543
+ }
544
+
545
+ const degree = state.nodeDegrees.get(hub.id) ?? 0
546
+ const minimumDegree = Math.max(18, Math.sqrt(nodeCount) * 1.8)
547
+ const degreeRatio = degree / Math.max(nodeCount, 1)
548
+ return degree >= minimumDegree || degreeRatio >= 0.035
549
+ }
550
+
364
551
  const recomputeVisibility = () => {
365
552
  const nodes = filteredNodes()
366
553
  const ids = new Set(nodes.map(node => node.id))
@@ -380,10 +567,11 @@ const recomputeVisibility = () => {
380
567
  state.primaryHub = primaryHub
381
568
  state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
382
569
  const bounds = graphBounds(nodes)
570
+ const macroHub = isDominantHub(primaryHub, nodes.length) ? primaryHub : null
383
571
  state.macroCenter = bounds
384
572
  ? {
385
- x: primaryHub ? primaryHub.x : (bounds.minX + bounds.maxX) / 2,
386
- y: primaryHub ? primaryHub.y : (bounds.minY + bounds.maxY) / 2
573
+ x: macroHub ? macroHub.x : (bounds.minX + bounds.maxX) / 2,
574
+ y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
387
575
  }
388
576
  : { x: 0, y: 0 }
389
577
  state.macroRepresentative = resolveMacroRepresentative(nodes)
@@ -1065,6 +1253,105 @@ const drawGraphNodes = () => {
1065
1253
  priorityNodes.forEach(node => drawSingleNode(node))
1066
1254
  }
1067
1255
 
1256
+ const partitionGraphForAcceleratedRenderer = () => {
1257
+ const regularNodes = []
1258
+ const priorityNodes = []
1259
+ const regularEdges = []
1260
+ const inferredEdges = []
1261
+ const selectedEdges = []
1262
+
1263
+ for (let index = 0; index < state.renderNodes.length; index += 1) {
1264
+ const node = state.renderNodes[index]
1265
+ const isPriority =
1266
+ state.selected?.id === node.id ||
1267
+ state.hovered?.id === node.id
1268
+ if (isPriority) {
1269
+ priorityNodes.push(node)
1270
+ } else {
1271
+ regularNodes.push(node)
1272
+ }
1273
+ }
1274
+
1275
+ for (let index = 0; index < state.renderEdges.length; index += 1) {
1276
+ const edge = state.renderEdges[index]
1277
+ const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
1278
+ if (isSelected) {
1279
+ selectedEdges.push(edge)
1280
+ } else if (edge.inferred) {
1281
+ inferredEdges.push(edge)
1282
+ } else {
1283
+ regularEdges.push(edge)
1284
+ }
1285
+ }
1286
+
1287
+ return { regularNodes, priorityNodes, regularEdges, inferredEdges, selectedEdges }
1288
+ }
1289
+
1290
+ const drawGraphLabels = nodes => {
1291
+ if (!(state.transform.scale >= 0.62 && state.renderNodes.length <= 1200)) {
1292
+ return
1293
+ }
1294
+
1295
+ ctx.fillStyle = graphTheme.label
1296
+ ctx.font = '12px Inter, system-ui, sans-serif'
1297
+ ctx.textAlign = 'center'
1298
+ ctx.textBaseline = 'top'
1299
+ for (let index = 0; index < nodes.length; index += 1) {
1300
+ const node = nodes[index]
1301
+ ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
1302
+ }
1303
+ }
1304
+
1305
+ const drawAcceleratedGraph = (width, height, drawEdges) => {
1306
+ if (!webGlRenderer || state.renderClusters.length > 0) {
1307
+ return false
1308
+ }
1309
+
1310
+ const graphParts = partitionGraphForAcceleratedRenderer()
1311
+ const scale = state.transform.scale
1312
+ webGlRenderer.clear(width, height)
1313
+ if (drawEdges) {
1314
+ webGlRenderer.drawLines(
1315
+ graphParts.regularEdges,
1316
+ rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
1317
+ width,
1318
+ height
1319
+ )
1320
+ webGlRenderer.drawLines(
1321
+ graphParts.inferredEdges,
1322
+ rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
1323
+ width,
1324
+ height
1325
+ )
1326
+ }
1327
+ webGlRenderer.drawPoints(
1328
+ graphParts.regularNodes,
1329
+ rgba(graphTheme.nodeHalo, 0.28),
1330
+ node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
1331
+ width,
1332
+ height
1333
+ )
1334
+ webGlRenderer.drawPoints(
1335
+ graphParts.regularNodes,
1336
+ rgba(graphTheme.node, 1),
1337
+ node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
1338
+ width,
1339
+ height
1340
+ )
1341
+
1342
+ ctx.save()
1343
+ ctx.translate(state.transform.x, state.transform.y)
1344
+ ctx.scale(state.transform.scale, state.transform.scale)
1345
+ if (drawEdges) {
1346
+ graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
1347
+ }
1348
+ drawGraphLabels(graphParts.regularNodes)
1349
+ graphParts.priorityNodes.forEach(node => drawSingleNode(node))
1350
+ ctx.restore()
1351
+
1352
+ return true
1353
+ }
1354
+
1068
1355
  const edgePairKey = (source, target) =>
1069
1356
  source < target ? source + '|' + target : target + '|' + source
1070
1357
 
@@ -1400,7 +1687,9 @@ const zoomCapByHubDistance = (distance) => {
1400
1687
 
1401
1688
  const currentZoomMax = () => {
1402
1689
  const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
1403
- const hubDistanceCap = zoomCapByHubDistance(state.hubNeighborDistance)
1690
+ const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
1691
+ ? zoomCapByHubDistance(state.hubNeighborDistance)
1692
+ : zoomRange.max
1404
1693
  const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
1405
1694
  const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
1406
1695
  return Math.max(zoomRange.min * 2, capped)
@@ -1501,7 +1790,7 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
1501
1790
  ? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
1502
1791
  : baselineScale
1503
1792
  const hubCenter =
1504
- options.preferHubCenter && state.primaryHub && nodes.some((node) => node.id === state.primaryHub.id)
1793
+ options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
1505
1794
  ? state.primaryHub
1506
1795
  : null
1507
1796
  const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
@@ -2261,6 +2550,7 @@ const render = now => {
2261
2550
  resetView()
2262
2551
  }
2263
2552
  ctx.clearRect(0, 0, width, height)
2553
+ webGlRenderer?.clear(width, height)
2264
2554
  if (state.nodes.length === 0) {
2265
2555
  ctx.fillStyle = '#99a5b5'
2266
2556
  ctx.font = '14px Inter, system-ui, sans-serif'
@@ -2269,9 +2559,6 @@ const render = now => {
2269
2559
  requestAnimationFrame(render)
2270
2560
  return
2271
2561
  }
2272
- ctx.save()
2273
- ctx.translate(state.transform.x, state.transform.y)
2274
- ctx.scale(state.transform.scale, state.transform.scale)
2275
2562
 
2276
2563
  computeRenderVisibility()
2277
2564
  tick(delta)
@@ -2304,11 +2591,12 @@ const render = now => {
2304
2591
  const drawEdges =
2305
2592
  state.renderClusters.length === 0 &&
2306
2593
  state.transform.scale >= minimumEdgeScale
2307
- if (drawEdges) {
2308
- drawGraphEdges()
2309
- }
2310
-
2311
- if (state.renderClusters.length > 0) {
2594
+ if (drawAcceleratedGraph(width, height, drawEdges)) {
2595
+ // WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
2596
+ } else if (state.renderClusters.length > 0) {
2597
+ ctx.save()
2598
+ ctx.translate(state.transform.x, state.transform.y)
2599
+ ctx.scale(state.transform.scale, state.transform.scale)
2312
2600
  const safeScale = Math.max(state.transform.scale, 0.0001)
2313
2601
  state.renderClusters.forEach(cluster => {
2314
2602
  const isMacro = cluster.id === 'macro-galaxy'
@@ -2337,11 +2625,17 @@ const render = now => {
2337
2625
  }
2338
2626
  // Keep cluster markers minimal and faster to draw on large graphs.
2339
2627
  })
2628
+ ctx.restore()
2340
2629
  } else {
2630
+ ctx.save()
2631
+ ctx.translate(state.transform.x, state.transform.y)
2632
+ ctx.scale(state.transform.scale, state.transform.scale)
2633
+ if (drawEdges) {
2634
+ drawGraphEdges()
2635
+ }
2341
2636
  drawGraphNodes()
2637
+ ctx.restore()
2342
2638
  }
2343
-
2344
- ctx.restore()
2345
2639
  if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
2346
2640
  ctx.fillStyle = '#99a5b5'
2347
2641
  ctx.font = '12px Inter, system-ui, sans-serif'
@@ -2455,27 +2749,8 @@ const selectNodeById = id => {
2455
2749
  }
2456
2750
 
2457
2751
  const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
2458
- const resolveZoomFactor = () => {
2459
- if (state.nodes.length <= massiveGraphNodeThreshold) {
2460
- return factor
2461
- }
2462
-
2463
- const scale = state.transform.scale
2464
- if (factor > 1) {
2465
- if (scale < 0.006) return Math.max(factor, 1.48)
2466
- if (scale < 0.02) return Math.max(factor, 1.34)
2467
- if (scale < 0.08) return Math.max(factor, 1.22)
2468
- return factor
2469
- }
2470
-
2471
- if (scale < 0.006) return Math.min(factor, 0.68)
2472
- if (scale < 0.02) return Math.min(factor, 0.78)
2473
- if (scale < 0.08) return Math.min(factor, 0.86)
2474
- return factor
2475
- }
2476
-
2477
2752
  state.lastManualZoomAt = performance.now()
2478
- const effectiveFactor = resolveZoomFactor()
2753
+ const effectiveFactor = factor
2479
2754
  const nextScale = clampScale(state.transform.scale * effectiveFactor)
2480
2755
  if (nextScale === state.transform.scale) {
2481
2756
  return
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.84",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",