@andespindola/brainlink 0.1.0-beta.75 → 0.1.0-beta.77

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
@@ -594,7 +594,7 @@ The graph UI shows:
594
594
  - double-click on canvas zooms in at cursor position
595
595
  - floating graph totals (notes, links, tags) below the Brainlink title
596
596
  - large-graph rendering safeguards (edge draw caps, lower redraw rate, zoom-aware interaction)
597
- - massive-graph LOD progression: very low zoom uses spatial overview sampling to preserve whole-vault shape while keeping node visuals consistent, then progressively reveals nodes and edges as zoom increases
597
+ - 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 reveals nodes and edges as zoom increases
598
598
 
599
599
  The server indexes before starting by default. Use `--no-index` to skip that step:
600
600
 
@@ -579,6 +579,14 @@ const nodeBudgetForScale = (scale) => {
579
579
  return renderNodeBudget
580
580
  }
581
581
 
582
+ const massiveLowZoomNodeBudgetForScale = (scale) => {
583
+ if (scale < 0.004) return 780
584
+ if (scale < 0.01) return 860
585
+ if (scale < 0.02) return 900
586
+ if (scale < 0.035) return 900
587
+ return renderNodeBudget
588
+ }
589
+
582
590
  const layerFocusForScale = (scale) => {
583
591
  const normalized = Math.max(0, Math.min(1, (scale - 0.06) / 0.94))
584
592
  const shellCenter = Math.max(0.08, 0.96 - normalized * 0.86)
@@ -1110,6 +1118,89 @@ const enrichSampleWithNeighbors = (nodes) => {
1110
1118
  }
1111
1119
  }
1112
1120
 
1121
+ const includeHubPreviewNeighborhood = (nodes, limit) => {
1122
+ const hub = state.primaryHub
1123
+ if (!hub) {
1124
+ return nodes
1125
+ }
1126
+
1127
+ const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
1128
+ const merged = [...nodes]
1129
+ const ids = new Set(merged.map((node) => node.id))
1130
+ const protectedIds = new Set()
1131
+
1132
+ if (!ids.has(hub.id)) {
1133
+ if (merged.length < maxNodes) {
1134
+ merged.push(hub)
1135
+ ids.add(hub.id)
1136
+ } else {
1137
+ const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
1138
+ if (replaceIndex >= 0) {
1139
+ ids.delete(merged[replaceIndex].id)
1140
+ merged[replaceIndex] = hub
1141
+ ids.add(hub.id)
1142
+ }
1143
+ }
1144
+ }
1145
+ protectedIds.add(hub.id)
1146
+
1147
+ const hubEdges = [...(state.visibleEdgeByNode.get(hub.id) ?? [])]
1148
+ .filter((edge) => edge.target && (edge.source === hub.id || edge.target === hub.id))
1149
+ .sort((left, right) => {
1150
+ const byWeight = edgeWeight(right) - edgeWeight(left)
1151
+ if (byWeight !== 0) return byWeight
1152
+
1153
+ const leftOtherId = left.source === hub.id ? left.target : left.source
1154
+ const rightOtherId = right.source === hub.id ? right.target : right.source
1155
+ const leftDegree = state.nodeDegrees.get(leftOtherId ?? '') ?? 0
1156
+ const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
1157
+ if (leftDegree !== rightDegree) return rightDegree - leftDegree
1158
+
1159
+ return edgeIdentityKey(left).localeCompare(edgeIdentityKey(right))
1160
+ })
1161
+
1162
+ for (let index = 0; index < hubEdges.length && merged.length < maxNodes; index += 1) {
1163
+ const edge = hubEdges[index]
1164
+ const otherId = edge.source === hub.id ? edge.target : edge.source
1165
+ if (!otherId || ids.has(otherId)) {
1166
+ continue
1167
+ }
1168
+
1169
+ const otherNode = state.nodeById.get(otherId)
1170
+ if (!otherNode) {
1171
+ continue
1172
+ }
1173
+
1174
+ if (merged.length < maxNodes) {
1175
+ ids.add(otherId)
1176
+ merged.push(otherNode)
1177
+ protectedIds.add(otherId)
1178
+ continue
1179
+ }
1180
+
1181
+ const replaceIndex = (() => {
1182
+ for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
1183
+ const candidateId = merged[cursor]?.id
1184
+ if (candidateId && !protectedIds.has(candidateId)) {
1185
+ return cursor
1186
+ }
1187
+ }
1188
+ return -1
1189
+ })()
1190
+ if (replaceIndex >= 0) {
1191
+ const replacedId = merged[replaceIndex]?.id
1192
+ if (replacedId) {
1193
+ ids.delete(replacedId)
1194
+ }
1195
+ merged[replaceIndex] = otherNode
1196
+ ids.add(otherId)
1197
+ protectedIds.add(otherId)
1198
+ }
1199
+ }
1200
+
1201
+ return merged
1202
+ }
1203
+
1113
1204
  const ensureHubNodesInRenderedSet = (nodes) => {
1114
1205
  if (nodes.length === 0) {
1115
1206
  return nodes
@@ -1225,7 +1316,7 @@ const autoFitScaleRangeByNodeCount = nodeCount => {
1225
1316
  if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
1226
1317
  if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
1227
1318
  if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
1228
- return { min: 0.0008, max: 0.24 }
1319
+ return { min: 0.0012, max: 0.24 }
1229
1320
  }
1230
1321
 
1231
1322
  const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
@@ -1824,16 +1915,26 @@ const computeRenderVisibility = () => {
1824
1915
  if (state.visibleNodes.length > massiveGraphNodeThreshold) {
1825
1916
  const viewportNodes = viewportNodesFromSpatialIndex(viewport)
1826
1917
  if (state.transform.scale <= massiveOverviewClusterScaleThreshold) {
1918
+ const overviewLimit = Math.min(renderNodeBudget, massiveLowZoomNodeBudgetForScale(state.transform.scale))
1827
1919
  const overviewClusters = filterOverviewClustersByViewport(viewport)
1828
1920
  .sort((left, right) => right.count - left.count)
1829
- .slice(0, Math.min(renderNodeBudget, clusterBudgetForScale(state.transform.scale)))
1921
+ .slice(0, overviewLimit)
1830
1922
  if (overviewClusters.length > 0) {
1831
- state.renderClusters = []
1832
- state.renderNodes = representativeNodesFromClusters(
1923
+ const overviewNodes = representativeNodesFromClusters(
1833
1924
  overviewClusters,
1834
- Math.min(renderNodeBudget, clusterBudgetForScale(state.transform.scale))
1925
+ overviewLimit
1926
+ )
1927
+ const anchoredNodes = includeHubPreviewNeighborhood(
1928
+ overviewNodes,
1929
+ Math.min(renderNodeBudget, overviewLimit)
1835
1930
  )
1836
- state.renderEdges = []
1931
+ const enriched = enrichSampleWithNeighbors(anchoredNodes)
1932
+ const previewNodes = ensureHubNodesInRenderedSet(enriched.nodes)
1933
+ const previewIds = new Set(previewNodes.map((node) => node.id))
1934
+ const previewEdges = collectVisibleEdgesForNodes(previewIds)
1935
+ state.renderClusters = []
1936
+ state.renderNodes = previewNodes
1937
+ state.renderEdges = previewEdges
1837
1938
  return
1838
1939
  }
1839
1940
  }
@@ -1863,9 +1964,15 @@ const computeRenderVisibility = () => {
1863
1964
  sampledRaw,
1864
1965
  Math.min(sampleLimit, renderNodeBudget)
1865
1966
  )
1866
- const sampledIds = new Set(sampled.map((node) => node.id))
1867
- let sampledEdges = state.transform.scale >= 0.035 ? collectVisibleEdgesForNodes(sampledIds) : []
1868
1967
  let sampledNodes = ensureHubNodesInRenderedSet(sampled)
1968
+ if (state.transform.scale < 0.035) {
1969
+ sampledNodes = includeHubPreviewNeighborhood(
1970
+ sampledNodes,
1971
+ Math.min(renderNodeBudget, sampleLimit + 160)
1972
+ )
1973
+ }
1974
+ const sampledIds = new Set(sampledNodes.map((node) => node.id))
1975
+ let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
1869
1976
 
1870
1977
  if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
1871
1978
  const enriched = enrichSampleWithNeighbors(sampledNodes)
@@ -2035,13 +2142,15 @@ const render = now => {
2035
2142
  state.offscreenFrameCount = 0
2036
2143
  }
2037
2144
  const minimumEdgeScale =
2038
- state.renderNodes.length > 1300
2039
- ? 0.12
2040
- : state.renderNodes.length > 900
2041
- ? 0.085
2042
- : state.renderNodes.length > 500
2043
- ? 0.05
2044
- : 0
2145
+ state.nodes.length > massiveGraphNodeThreshold
2146
+ ? 0
2147
+ : state.renderNodes.length > 1300
2148
+ ? 0.12
2149
+ : state.renderNodes.length > 900
2150
+ ? 0.085
2151
+ : state.renderNodes.length > 500
2152
+ ? 0.05
2153
+ : 0
2045
2154
  const drawEdges =
2046
2155
  state.renderClusters.length === 0 &&
2047
2156
  state.transform.scale >= minimumEdgeScale
@@ -2224,8 +2333,28 @@ const selectNodeById = id => {
2224
2333
  }
2225
2334
 
2226
2335
  const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
2336
+ const resolveZoomFactor = () => {
2337
+ if (state.nodes.length <= massiveGraphNodeThreshold) {
2338
+ return factor
2339
+ }
2340
+
2341
+ const scale = state.transform.scale
2342
+ if (factor > 1) {
2343
+ if (scale < 0.006) return Math.max(factor, 1.48)
2344
+ if (scale < 0.02) return Math.max(factor, 1.34)
2345
+ if (scale < 0.08) return Math.max(factor, 1.22)
2346
+ return factor
2347
+ }
2348
+
2349
+ if (scale < 0.006) return Math.min(factor, 0.68)
2350
+ if (scale < 0.02) return Math.min(factor, 0.78)
2351
+ if (scale < 0.08) return Math.min(factor, 0.86)
2352
+ return factor
2353
+ }
2354
+
2227
2355
  state.lastManualZoomAt = performance.now()
2228
- const nextScale = clampScale(state.transform.scale * factor)
2356
+ const effectiveFactor = resolveZoomFactor()
2357
+ const nextScale = clampScale(state.transform.scale * effectiveFactor)
2229
2358
  if (nextScale === state.transform.scale) {
2230
2359
  return
2231
2360
  }
@@ -2302,11 +2431,11 @@ const bindEvents = () => {
2302
2431
  })
2303
2432
  elements.zoomIn.addEventListener('click', () => {
2304
2433
  const rect = canvas.getBoundingClientRect()
2305
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.14)
2434
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.14, 'button')
2306
2435
  })
2307
2436
  elements.zoomOut.addEventListener('click', () => {
2308
2437
  const rect = canvas.getBoundingClientRect()
2309
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.88)
2438
+ zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.88, 'button')
2310
2439
  })
2311
2440
  if (elements.fit) {
2312
2441
  elements.fit.addEventListener('click', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.75",
3
+ "version": "0.1.0-beta.77",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",