@andespindola/brainlink 0.1.0-beta.136 → 0.1.0-beta.138

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.
@@ -89,6 +89,7 @@ const state = {
89
89
  lastHoverHitAt: 0,
90
90
  lastManualZoomAt: 0,
91
91
  lastZoomFocus: { x: 0, y: 0, at: 0 },
92
+ leafFocusRootNodeId: null,
92
93
  hierarchyFocusGroupId: null,
93
94
  hierarchyFocusStack: [],
94
95
  hierarchyRevealFocusGroupId: null,
@@ -814,14 +815,16 @@ const edgeRelevanceScore = edge => {
814
815
  return score
815
816
  }
816
817
 
817
- const collectVisibleEdgesForNodes = nodeIds => {
818
+ const collectVisibleEdgesForNodes = (nodeIds, options = { preferComplete: false }) => {
818
819
  if (nodeIds.size === 0) {
819
820
  return []
820
821
  }
821
822
 
822
823
  const seen = new Set()
823
824
  const candidates = []
824
- const limit = edgeBudgetForCurrentFrame()
825
+ const limit = options.preferComplete
826
+ ? hierarchyAbsoluteEdgeSafetyCap
827
+ : edgeBudgetForCurrentFrame()
825
828
 
826
829
  nodeIds.forEach(nodeId => {
827
830
  const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
@@ -1337,6 +1340,76 @@ const arrangeChildGraphNodes = (nodes, group, origin = group) => {
1337
1340
  return arranged
1338
1341
  }
1339
1342
 
1343
+ const projectNodesIntoChildGraph = (nodes, focusRenderNode, group) => {
1344
+ if (nodes.length <= 1) {
1345
+ return nodes.map(node => ({ ...node, x: focusRenderNode.x, y: focusRenderNode.y }))
1346
+ }
1347
+
1348
+ let minX = Number.POSITIVE_INFINITY
1349
+ let maxX = Number.NEGATIVE_INFINITY
1350
+ let minY = Number.POSITIVE_INFINITY
1351
+ let maxY = Number.NEGATIVE_INFINITY
1352
+
1353
+ for (let index = 0; index < nodes.length; index += 1) {
1354
+ const node = nodes[index]
1355
+ minX = Math.min(minX, node.x)
1356
+ maxX = Math.max(maxX, node.x)
1357
+ minY = Math.min(minY, node.y)
1358
+ maxY = Math.max(maxY, node.y)
1359
+ }
1360
+
1361
+ const centerX = (minX + maxX) / 2
1362
+ const centerY = (minY + maxY) / 2
1363
+ const maxDistanceFromCenter = nodes.reduce((maxDistance, node) => {
1364
+ const distance = Math.hypot(node.x - centerX, node.y - centerY)
1365
+ return Math.max(maxDistance, distance)
1366
+ }, 1)
1367
+ const subgraphFillByNodeCount = (nodeCount) => {
1368
+ if (nodeCount <= 24) return 0.62
1369
+ if (nodeCount <= 80) return 0.7
1370
+ if (nodeCount <= 200) return 0.78
1371
+ return 0.84
1372
+ }
1373
+ const targetRadius = Math.max(1, childGraphRenderRadius(group) * subgraphFillByNodeCount(nodes.length))
1374
+ const scale = targetRadius / Math.max(maxDistanceFromCenter, 1)
1375
+
1376
+ return nodes.map(node => ({
1377
+ ...node,
1378
+ x: focusRenderNode.x + (node.x - centerX) * scale,
1379
+ y: focusRenderNode.y + (node.y - centerY) * scale
1380
+ }))
1381
+ }
1382
+
1383
+ const recursiveLeafSubgraphNodes = (rootNodeId, maxNodes = renderNodeBudget) => {
1384
+ const root = state.nodeById.get(rootNodeId)
1385
+ if (!root) {
1386
+ return []
1387
+ }
1388
+
1389
+ const visited = new Set([rootNodeId])
1390
+ const queue = [rootNodeId]
1391
+
1392
+ while (queue.length > 0 && visited.size < maxNodes) {
1393
+ const currentId = queue.shift()
1394
+ const edges = [...(state.visibleEdgeByNode.get(currentId) ?? [])]
1395
+ .filter(edge => edge.target)
1396
+ .sort((left, right) => edgeWeight(right) - edgeWeight(left))
1397
+ for (let edgeIndex = 0; edgeIndex < edges.length && visited.size < maxNodes; edgeIndex += 1) {
1398
+ const edge = edges[edgeIndex]
1399
+ const nextId = edge.source === currentId ? edge.target : edge.source
1400
+ if (!nextId || visited.has(nextId) || !state.nodeById.has(nextId)) {
1401
+ continue
1402
+ }
1403
+ visited.add(nextId)
1404
+ queue.push(nextId)
1405
+ }
1406
+ }
1407
+
1408
+ return [...visited]
1409
+ .map(id => state.nodeById.get(id))
1410
+ .filter(Boolean)
1411
+ }
1412
+
1340
1413
  const arrangeChildGroupNodes = (groups, parentGroup, origin) => {
1341
1414
  if (groups.length <= 1) {
1342
1415
  return groups.map(group => ({
@@ -1502,6 +1575,7 @@ const updateHierarchyFocusGroup = (groups, _viewport) => {
1502
1575
  state.selected = null
1503
1576
  }
1504
1577
  }
1578
+ state.leafFocusRootNodeId = null
1505
1579
  state.hierarchyFocusGroupId = null
1506
1580
  return null
1507
1581
  }
@@ -1622,12 +1696,23 @@ const computeHierarchyRenderVisibility = (viewport) => {
1622
1696
  const focusChildGroups = focus.childGroupIds
1623
1697
  .map(groupId => state.groupById.get(groupId))
1624
1698
  .filter(Boolean)
1699
+ if (focusChildGroups.length > 0) {
1700
+ state.leafFocusRootNodeId = null
1701
+ } else if (state.leafFocusRootNodeId && !focusIds.has(state.leafFocusRootNodeId)) {
1702
+ state.leafFocusRootNodeId = null
1703
+ }
1625
1704
  const childTargetLimit = Math.min(renderNodeBudget, Math.max(1, Math.floor(renderNodeBudget * revealProgress)))
1626
1705
  const childLimit = updateHierarchyChildRevealBudget(focus.id, childTargetLimit)
1627
1706
  const focusRenderNode = groupNodes.find(node => node.groupId === focus.id) ?? createGroupRenderNode(focus)
1628
- const arrangedChildren = focusChildGroups.length > 0
1629
- ? arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
1630
- : arrangeChildGraphNodes(rawChildNodes, focus, focusRenderNode)
1707
+ const arrangedChildren = (() => {
1708
+ if (focusChildGroups.length > 0) {
1709
+ return arrangeChildGroupNodes(focusChildGroups, focus, focusRenderNode)
1710
+ }
1711
+ const recursiveNodes = state.leafFocusRootNodeId
1712
+ ? recursiveLeafSubgraphNodes(state.leafFocusRootNodeId, renderNodeBudget)
1713
+ : rawChildNodes
1714
+ return projectNodesIntoChildGraph(recursiveNodes, focusRenderNode, focus)
1715
+ })()
1631
1716
  const childNodes = selectStableSampleNodes(arrangedChildren, childLimit)
1632
1717
  .map(node => interpolateNodeFromGroup(node, focusRenderNode, revealProgress))
1633
1718
  const childIds = new Set(childNodes.map(node => node.id))
@@ -1640,7 +1725,7 @@ const computeHierarchyRenderVisibility = (viewport) => {
1640
1725
  const childEdges = (isMicroView || revealProgress > 0)
1641
1726
  ? focusChildGroups.length > 0
1642
1727
  ? groupEdgesForRenderedGroups(childNodes)
1643
- : collectVisibleEdgesForNodes(childIds).map(edge => ({
1728
+ : collectVisibleEdgesForNodes(childIds, { preferComplete: true }).map(edge => ({
1644
1729
  ...edge,
1645
1730
  sourceNode: childById.get(edge.source) ?? edge.sourceNode,
1646
1731
  targetNode: childById.get(edge.target) ?? edge.targetNode
@@ -2121,6 +2206,7 @@ const fitView = (options = { useFiltered: true, preferHubCenter: true }) => {
2121
2206
  const resetView = () => fitView({ useFiltered: false, preferHubCenter: false })
2122
2207
 
2123
2208
  const resetHierarchyFocus = () => {
2209
+ state.leafFocusRootNodeId = null
2124
2210
  state.hierarchyFocusGroupId = null
2125
2211
  state.hierarchyFocusStack = []
2126
2212
  state.hierarchyRevealFocusGroupId = null
@@ -3128,6 +3214,22 @@ const expandGroupNode = node => {
3128
3214
  }
3129
3215
  }
3130
3216
 
3217
+ const expandLeafNodeGraph = node => {
3218
+ if (!node || node.isGroupNode) return
3219
+ state.selected = node
3220
+ state.leafFocusRootNodeId = node.id
3221
+ state.zoomTransition = {
3222
+ active: true,
3223
+ source: 'group',
3224
+ screenX: state.viewport.width / 2,
3225
+ screenY: state.viewport.height / 2,
3226
+ worldX: node.x,
3227
+ worldY: node.y,
3228
+ targetScale: clampScale(Math.max(state.transform.scale * 1.08, hierarchyMicroExitScale * 1.02))
3229
+ }
3230
+ state.lastZoomFocus = { x: node.x, y: node.y, at: performance.now() }
3231
+ markRenderDirty()
3232
+ }
3131
3233
  const handleGroupNodePrimaryClick = node => {
3132
3234
  if (!node?.isGroupNode) return
3133
3235
  const alreadyFocused = state.selected?.isGroupNode && state.selected.groupId === node.groupId
@@ -3138,8 +3240,19 @@ const handleGroupNodePrimaryClick = node => {
3138
3240
  expandGroupNode(node)
3139
3241
  }
3140
3242
 
3243
+ const handleLeafNodePrimaryClick = node => {
3244
+ if (!node || node.isGroupNode) return
3245
+ const sameFocusedNode = state.leafFocusRootNodeId === node.id
3246
+ if (!sameFocusedNode) {
3247
+ expandLeafNodeGraph(node)
3248
+ return
3249
+ }
3250
+ selectNode(node, { openContent: true })
3251
+ }
3252
+
3141
3253
  const selectNode = (node, options = { openContent: false }) => {
3142
3254
  if (!node) {
3255
+ state.leafFocusRootNodeId = null
3143
3256
  state.selected = null
3144
3257
  if (elements.contentDialog.open) {
3145
3258
  elements.contentDialog.close()
@@ -3376,6 +3489,8 @@ const bindEvents = () => {
3376
3489
  const clickedNode = draggedNode ?? state.hovered
3377
3490
  if (clickedNode?.isGroupNode) {
3378
3491
  handleGroupNodePrimaryClick(clickedNode)
3492
+ } else if (clickedNode && state.hierarchyFocusGroupId) {
3493
+ handleLeafNodePrimaryClick(clickedNode)
3379
3494
  } else {
3380
3495
  selectNode(clickedNode, { openContent: true })
3381
3496
  }
@@ -3465,6 +3580,7 @@ const loadGraph = async (options = { reset: false }) => {
3465
3580
  state.graph = graph
3466
3581
  state.nodes = layout.nodes
3467
3582
  state.groups = layout.groups
3583
+ state.leafFocusRootNodeId = null
3468
3584
  state.hierarchyFocusGroupId = null
3469
3585
  state.hierarchyFocusStack = []
3470
3586
  state.hierarchyRevealFocusGroupId = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.136",
3
+ "version": "0.1.0-beta.138",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",