@andespindola/brainlink 0.1.0-beta.15 → 0.1.0-beta.16

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.
@@ -39,6 +39,8 @@ select {
39
39
 
40
40
  .workspace {
41
41
  position: relative;
42
+ width: 100%;
43
+ height: 100%;
42
44
  min-width: 0;
43
45
  min-height: 0;
44
46
  }
@@ -2,12 +2,17 @@ export const createClientJs = () => `const canvas = document.getElementById('gra
2
2
  const ctx = canvas.getContext('2d')
3
3
  const largeGraphNodeThreshold = 4000
4
4
  const largeGraphEdgeRenderLimit = 16000
5
+ const renderNodeBudget = 1800
6
+ const minNodePixelRadius = 1.8
7
+ const viewportPaddingPx = 280
5
8
  const state = {
6
9
  graph: { nodes: [], edges: [] },
7
10
  nodes: [],
8
11
  edges: [],
9
12
  visibleNodes: [],
10
13
  visibleEdges: [],
14
+ renderNodes: [],
15
+ renderEdges: [],
11
16
  nodeDegrees: new Map(),
12
17
  selected: null,
13
18
  hovered: null,
@@ -133,7 +138,7 @@ const graphBounds = nodes => {
133
138
  let maxY = Number.NEGATIVE_INFINITY
134
139
 
135
140
  nodes.forEach(node => {
136
- const radius = nodeRadius(node)
141
+ const radius = baseNodeRadius(node)
137
142
  minX = Math.min(minX, node.x - radius)
138
143
  maxX = Math.max(maxX, node.x + radius)
139
144
  minY = Math.min(minY, node.y - radius)
@@ -165,7 +170,9 @@ const fitView = (options = { useFiltered: true }) => {
165
170
  const padding = 100
166
171
  const scaleX = width / (bounds.width + padding * 2)
167
172
  const scaleY = height / (bounds.height + padding * 2)
168
- const scale = clampScale(Math.min(scaleX, scaleY))
173
+ const fitScale = clampScale(Math.min(scaleX, scaleY))
174
+ const minimumLargeGraphScale = nodes.length > largeGraphNodeThreshold ? 0.13 : zoomRange.min
175
+ const scale = Math.max(fitScale, minimumLargeGraphScale)
169
176
  const centerX = (bounds.minX + bounds.maxX) / 2
170
177
  const centerY = (bounds.minY + bounds.maxY) / 2
171
178
 
@@ -267,8 +274,8 @@ const scheduleContentFilterSync = () => {
267
274
  }
268
275
 
269
276
  const tick = delta => {
270
- const nodes = state.visibleNodes
271
- const edges = state.visibleEdges
277
+ const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
278
+ const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
272
279
  if (nodes.length > 1200) {
273
280
  return
274
281
  }
@@ -334,7 +341,7 @@ const hitNode = point => {
334
341
  return null
335
342
  }
336
343
 
337
- const nodes = state.visibleNodes
344
+ const nodes = state.renderNodes
338
345
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
339
346
  const node = nodes[index]
340
347
  const radius = nodeRadius(node)
@@ -343,15 +350,88 @@ const hitNode = point => {
343
350
  return null
344
351
  }
345
352
 
346
- const nodeRadius = node => {
353
+ const baseNodeRadius = node => {
347
354
  const degree = state.nodeDegrees.get(node.id) ?? 0
348
355
  return 9 + Math.min(degree, 8) * 1.6
349
356
  }
350
357
 
358
+ const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
359
+
360
+ const worldViewportBounds = () => {
361
+ const rect = canvas.getBoundingClientRect()
362
+ const width = Math.max(rect.width, 320)
363
+ const height = Math.max(rect.height, 320)
364
+ const padding = viewportPaddingPx
365
+
366
+ return {
367
+ minX: (-state.transform.x - padding) / state.transform.scale,
368
+ maxX: (width - state.transform.x + padding) / state.transform.scale,
369
+ minY: (-state.transform.y - padding) / state.transform.scale,
370
+ maxY: (height - state.transform.y + padding) / state.transform.scale
371
+ }
372
+ }
373
+
374
+ const isNodeInViewport = (node, viewport) =>
375
+ node.x >= viewport.minX &&
376
+ node.x <= viewport.maxX &&
377
+ node.y >= viewport.minY &&
378
+ node.y <= viewport.maxY
379
+
380
+ const viewportNodeStride = () => {
381
+ if (state.nodes.length <= largeGraphNodeThreshold) {
382
+ return 1
383
+ }
384
+
385
+ if (state.transform.scale >= 0.95) {
386
+ return 1
387
+ }
388
+ if (state.transform.scale >= 0.7) {
389
+ return 2
390
+ }
391
+ if (state.transform.scale >= 0.48) {
392
+ return 3
393
+ }
394
+ if (state.transform.scale >= 0.28) {
395
+ return 5
396
+ }
397
+
398
+ return 8
399
+ }
400
+
401
+ const computeRenderVisibility = () => {
402
+ const viewport = worldViewportBounds()
403
+ const stride = viewportNodeStride()
404
+ const picked = []
405
+
406
+ for (let index = 0; index < state.visibleNodes.length; index += 1) {
407
+ const node = state.visibleNodes[index]
408
+ if (!isNodeInViewport(node, viewport)) {
409
+ continue
410
+ }
411
+
412
+ const isPriority =
413
+ node.id === state.selected?.id ||
414
+ node.id === state.hovered?.id ||
415
+ node.id === state.pointer.dragNode?.id
416
+ if (isPriority || index % stride === 0) {
417
+ picked.push(node)
418
+ }
419
+ }
420
+
421
+ const nodes = picked.length > renderNodeBudget
422
+ ? picked.slice(0, renderNodeBudget)
423
+ : picked
424
+ const nodeIds = new Set(nodes.map((node) => node.id))
425
+ const edges = state.visibleEdges.filter((edge) => nodeIds.has(edge.source) && edge.target && nodeIds.has(edge.target))
426
+
427
+ state.renderNodes = nodes
428
+ state.renderEdges = edges
429
+ }
430
+
351
431
  const render = now => {
352
432
  const delta = now - state.last
353
433
  state.last = now
354
- const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 180 : 16
434
+ const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 48 : 16
355
435
  if (delta < minFrameIntervalMs) {
356
436
  requestAnimationFrame(render)
357
437
  return
@@ -372,10 +452,11 @@ const render = now => {
372
452
  ctx.translate(state.transform.x, state.transform.y)
373
453
  ctx.scale(state.transform.scale, state.transform.scale)
374
454
 
455
+ computeRenderVisibility()
375
456
  tick(delta)
376
457
  const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
377
458
  if (drawEdges) {
378
- state.visibleEdges.forEach(edge => {
459
+ state.renderEdges.forEach(edge => {
379
460
  const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
380
461
  ctx.beginPath()
381
462
  ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
@@ -386,7 +467,7 @@ const render = now => {
386
467
  })
387
468
  }
388
469
 
389
- state.visibleNodes.forEach(node => {
470
+ state.renderNodes.forEach(node => {
390
471
  const radius = nodeRadius(node)
391
472
  const isSelected = state.selected?.id === node.id
392
473
  const isHovered = state.hovered?.id === node.id
@@ -416,6 +497,12 @@ const render = now => {
416
497
  })
417
498
 
418
499
  ctx.restore()
500
+ if (state.renderNodes.length === 0) {
501
+ ctx.fillStyle = '#99a5b5'
502
+ ctx.font = '12px Inter, system-ui, sans-serif'
503
+ ctx.textAlign = 'center'
504
+ ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
505
+ }
419
506
  requestAnimationFrame(render)
420
507
  }
421
508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.15",
3
+ "version": "0.1.0-beta.16",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",