@andespindola/brainlink 1.0.5 → 1.0.6

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.
Files changed (51) hide show
  1. package/README.md +8 -0
  2. package/dist/application/add-note.js +2 -2
  3. package/dist/application/build-context.js +16 -10
  4. package/dist/application/canonical-context-links.js +44 -5
  5. package/dist/application/check-package-update.js +105 -0
  6. package/dist/application/frontend/client/chunk-fetch.js +236 -0
  7. package/dist/application/frontend/client/controls.js +178 -0
  8. package/dist/application/frontend/client/elements.js +122 -0
  9. package/dist/application/frontend/client/input.js +202 -0
  10. package/dist/application/frontend/client/node-details.js +191 -0
  11. package/dist/application/frontend/client/rendering.js +296 -0
  12. package/dist/application/frontend/client/scope-theme.js +114 -0
  13. package/dist/application/frontend/client/spatial.js +98 -0
  14. package/dist/application/frontend/client/storage.js +215 -0
  15. package/dist/application/frontend/client/upload.js +90 -0
  16. package/dist/application/frontend/client/worker-bootstrap.js +147 -0
  17. package/dist/application/frontend/client-js.js +24 -1837
  18. package/dist/application/frontend/client-render-worker-js.js +1 -1
  19. package/dist/application/index-vault-phases.js +189 -0
  20. package/dist/application/index-vault.js +44 -165
  21. package/dist/cli/commands/write/dedupe-commands.js +59 -0
  22. package/dist/cli/commands/write/index-commands.js +205 -0
  23. package/dist/cli/commands/write/link-commands.js +68 -0
  24. package/dist/cli/commands/write/note-commands.js +146 -0
  25. package/dist/cli/commands/write/server-commands.js +553 -0
  26. package/dist/cli/commands/write/shared.js +35 -0
  27. package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
  28. package/dist/cli/commands/write-commands.js +12 -1303
  29. package/dist/cli/main.js +39 -3
  30. package/dist/domain/context.js +39 -3
  31. package/dist/domain/embeddings.js +31 -5
  32. package/dist/domain/graph-contexts.js +62 -57
  33. package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
  34. package/dist/domain/graph-layout/collisions.js +100 -0
  35. package/dist/domain/graph-layout/hierarchy.js +135 -0
  36. package/dist/domain/graph-layout/metrics.js +111 -0
  37. package/dist/domain/graph-layout/segments.js +76 -0
  38. package/dist/domain/graph-layout/star-layout.js +110 -0
  39. package/dist/domain/graph-layout.js +4 -625
  40. package/dist/infrastructure/config.js +6 -0
  41. package/dist/infrastructure/file-index.js +13 -4
  42. package/dist/infrastructure/semantic-prefilter.js +24 -0
  43. package/dist/mcp/server.js +7 -0
  44. package/dist/mcp/tool-guard.js +29 -0
  45. package/dist/mcp/tools/maintenance-tools.js +409 -0
  46. package/dist/mcp/tools/read-tools.js +504 -0
  47. package/dist/mcp/tools/shared.js +216 -0
  48. package/dist/mcp/tools/write-tools.js +247 -0
  49. package/dist/mcp/tools.js +3 -1357
  50. package/docs/QUICKSTART.md +4 -0
  51. package/package.json +2 -2
package/README.md CHANGED
@@ -115,6 +115,11 @@ blink --help
115
115
 
116
116
  Use `brainlink` when clarity matters. Use `blink` for faster daily terminal usage.
117
117
 
118
+ Brainlink checks npm for a newer package version at most once per day and prints
119
+ an update notice to `stderr` when one is available. It does not update itself
120
+ silently. Disable this with `BRAINLINK_NO_UPDATE_CHECK=1` or with
121
+ `"autoUpdateCheck": false` in config.
122
+
118
123
  The npm package page may show `npm i @andespindola/brainlink`. That installs
119
124
  Brainlink as a project dependency. Use `-g` when you want the terminal commands
120
125
  available globally, or run it without a global install:
@@ -1088,6 +1093,8 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1088
1093
  "defaultAgent": "shared",
1089
1094
  "autoIndexOnWrite": true,
1090
1095
  "autoCanonicalContextLinks": true,
1096
+ "autoUpdateCheck": true,
1097
+ "updateCheckIntervalMs": 86400000,
1091
1098
  "defaultSearchLimit": 8,
1092
1099
  "defaultContextTokens": 1500,
1093
1100
  "defaultContextStrategy": "auto",
@@ -1120,6 +1127,7 @@ If no `vault` is configured and no `--vault` flag is passed, Brainlink uses `$HO
1120
1127
 
1121
1128
  `autoIndexOnWrite` is optional and defaults to `true`. Set it to `false` to defer indexing after writes.
1122
1129
  `autoCanonicalContextLinks` is optional and defaults to `true`. When enabled, `blink add`, `brainlink_add_note` and `brainlink_add_file` add a canonical `## Context Links` entry to the inferred context hub, creating that hub when needed.
1130
+ `autoUpdateCheck` is optional and defaults to `true`. Brainlink checks the npm registry no more often than `updateCheckIntervalMs` and prints an update notice to `stderr`; it never installs updates automatically.
1123
1131
 
1124
1132
  ## Remote MCP Server
1125
1133
 
@@ -2,7 +2,7 @@ import { writeMarkdownFile } from '../infrastructure/file-system-vault.js';
2
2
  import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
3
3
  import { validateNoteInput } from '../domain/note-safety.js';
4
4
  import { ensureVault } from '../infrastructure/file-system-vault.js';
5
- import { addCanonicalContextLinkToContent, ensureCanonicalContextHub } from './canonical-context-links.js';
5
+ import { addCanonicalContextLinkToContent, ensureCanonicalContextHub, loadVisualContextRules } from './canonical-context-links.js';
6
6
  const slugify = (title) => title
7
7
  .normalize('NFKD')
8
8
  .replace(/[\u0300-\u036f]/g, '')
@@ -31,7 +31,7 @@ export const addNoteWithMetadata = async (vaultPath, title, content, agentId = s
31
31
  await ensureVault(vaultPath);
32
32
  const canonical = options.autoContextLinks === false
33
33
  ? null
34
- : addCanonicalContextLinkToContent(title, content.trim());
34
+ : addCanonicalContextLinkToContent(title, content.trim(), await loadVisualContextRules(vaultPath), filename);
35
35
  const hub = canonical?.changed
36
36
  ? await ensureCanonicalContextHub(vaultPath, canonical.context, sanitizedAgentId)
37
37
  : null;
@@ -74,7 +74,7 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
74
74
  const cacheKey = toCacheKey(vaultPath, query, limit, maxTokens, agentId, mode, strategy);
75
75
  const dataSignature = await readContextDataSignature(vaultPath);
76
76
  const cached = contextCacheGet(cacheKey, dataSignature, contextCacheTtlMs);
77
- if (cached) {
77
+ if (cached && cached.sections.length > 0) {
78
78
  return cached;
79
79
  }
80
80
  const shouldUseContextPack = strategy === 'cag' || strategy === 'auto';
@@ -83,7 +83,7 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
83
83
  const packReadStart = performance.now();
84
84
  const pack = await readContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, dataSignature);
85
85
  packReadMs = elapsedMs(packReadStart);
86
- if (pack.status === 'hit') {
86
+ if (pack.status === 'hit' && pack.context.sections.length > 0) {
87
87
  const contextFromPack = {
88
88
  ...pack.context,
89
89
  strategy: 'cag',
@@ -133,8 +133,12 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
133
133
  : 'RAG was requested; Brainlink assembled context directly from current retrieval results.'
134
134
  }
135
135
  };
136
+ // Never persist an empty assembly: a transient empty context (e.g. an index
137
+ // that was momentarily unavailable) must not be cached as a fresh pack and
138
+ // then served on every later read until the data signature changes.
139
+ const hasSections = sections.length > 0;
136
140
  const packWriteStart = performance.now();
137
- const packPath = shouldUseContextPack
141
+ const packPath = shouldUseContextPack && hasSections
138
142
  ? await writeContextPack(vaultPath, { query, limit, maxTokens, agentId, mode }, dataSignature, context)
139
143
  : undefined;
140
144
  const packWriteMs = packPath ? elapsedMs(packWriteStart) : 0;
@@ -154,13 +158,15 @@ export const buildContextPackage = async (vaultPath, query, limit, maxTokens, ag
154
158
  packWriteMs
155
159
  })
156
160
  };
157
- contextCacheSet({
158
- key: cacheKey,
159
- createdAt: Date.now(),
160
- dataSignature,
161
- strategy,
162
- context: contextWithMetrics
163
- });
161
+ if (hasSections) {
162
+ contextCacheSet({
163
+ key: cacheKey,
164
+ createdAt: Date.now(),
165
+ dataSignature,
166
+ strategy,
167
+ context: contextWithMetrics
168
+ });
169
+ }
164
170
  return contextWithMetrics;
165
171
  };
166
172
  export const buildContext = async (vaultPath, query, limit, maxTokens, agentId, mode, strategy, contextCacheTtlMs = 120_000) => {
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { inferVisualGraphContext } from '../domain/graph-contexts.js';
3
+ import { deriveVisualContextRules, inferVisualGraphContext } from '../domain/graph-contexts.js';
4
4
  import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
5
5
  import { extractContextLinkWeights, parseMarkdownDocument } from '../domain/markdown.js';
6
6
  import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
@@ -60,6 +60,42 @@ const buildHubContent = (hubTitle, contextTitle, agentId) => [
60
60
  `Canonical hub for the ${contextTitle} context. #memory #hub`,
61
61
  ''
62
62
  ].join('\n');
63
+ const isHubNotePath = (relativePath) => /(^|\/)[^/]*-hub\.md$/i.test(relativePath);
64
+ // Hub notes change rarely, but loadVisualContextRules runs on every auto-linked
65
+ // note write. Cache the derived rules per vault for a short window so a burst of
66
+ // writes reads the hub notes once. Creating a hub clears the cache so a new
67
+ // context is picked up immediately.
68
+ const visualContextRulesCacheTtlMs = 15_000;
69
+ const visualContextRulesCache = new Map();
70
+ const invalidateVisualContextRulesCache = () => {
71
+ visualContextRulesCache.clear();
72
+ };
73
+ // Build visual-context rules from the vault's hub notes only, so a single-note
74
+ // write path can classify into vault-specific contexts without reading the
75
+ // whole vault.
76
+ export const loadVisualContextRules = async (vaultPath) => {
77
+ const absoluteVaultPath = await ensureVault(vaultPath);
78
+ const cached = visualContextRulesCache.get(absoluteVaultPath);
79
+ if (cached && Date.now() - cached.createdAt <= visualContextRulesCacheTtlMs) {
80
+ return cached.rules;
81
+ }
82
+ const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
83
+ const documents = await Promise.all(summaries
84
+ .filter((summary) => isHubNotePath(summary.relativePath))
85
+ .map(async (summary) => {
86
+ const content = await readFile(summary.absolutePath, 'utf8');
87
+ return parseMarkdownDocument({
88
+ absolutePath: summary.absolutePath,
89
+ vaultPath: absoluteVaultPath,
90
+ content,
91
+ createdAt: summary.createdAt,
92
+ updatedAt: summary.updatedAt
93
+ });
94
+ }));
95
+ const rules = deriveVisualContextRules(documents);
96
+ visualContextRulesCache.set(absoluteVaultPath, { createdAt: Date.now(), rules });
97
+ return rules;
98
+ };
63
99
  const readNotes = async (vaultPath) => {
64
100
  const absoluteVaultPath = await ensureVault(vaultPath);
65
101
  const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
@@ -92,6 +128,7 @@ export const ensureCanonicalContextHub = async (vaultPath, contextTitle, agentId
92
128
  };
93
129
  }
94
130
  const path = await writeMarkdownFile(vaultPath, hubPath, buildHubContent(hubTitle, contextTitle, agentId));
131
+ invalidateVisualContextRulesCache();
95
132
  return {
96
133
  created: true,
97
134
  title: hubTitle,
@@ -102,6 +139,7 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
102
139
  const agentId = options.agentId ? sanitizeAgentId(options.agentId) : undefined;
103
140
  const createMissingHubs = options.createMissingHubs !== false;
104
141
  const notes = await readNotes(vaultPath);
142
+ const rules = deriveVisualContextRules(notes.map((note) => note.document));
105
143
  const scopedNotes = agentId ? notes.filter((note) => note.document.agentId === agentId) : notes;
106
144
  const knownTitles = new Set(notes.map((note) => normalizeTitle(note.document.title)));
107
145
  const entries = [];
@@ -124,6 +162,7 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
124
162
  knownTitles.add(normalizeTitle(hubTitle));
125
163
  if (!options.dryRun) {
126
164
  await writeMarkdownFile(vaultPath, path, buildHubContent(hubTitle, contextTitle, targetAgentId));
165
+ invalidateVisualContextRulesCache();
127
166
  }
128
167
  entries.push({
129
168
  path,
@@ -136,7 +175,7 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
136
175
  return true;
137
176
  };
138
177
  for (const note of scopedNotes) {
139
- const context = inferVisualGraphContext(note.document);
178
+ const context = inferVisualGraphContext(note.document, rules);
140
179
  const hubTitle = hubTitleForContext(context.title);
141
180
  const isHub = normalizeTitle(note.document.title) === normalizeTitle(hubTitle);
142
181
  if (isHub) {
@@ -189,15 +228,15 @@ export const canonicalizeContextLinks = async (vaultPath, options = {}) => {
189
228
  entries
190
229
  };
191
230
  };
192
- export const addCanonicalContextLinkToContent = (title, content) => {
231
+ export const addCanonicalContextLinkToContent = (title, content, rules = [], notePath = '') => {
193
232
  const context = inferVisualGraphContext({
194
233
  id: '',
195
234
  agentId: sharedAgentId,
196
235
  title,
197
- path: '',
236
+ path: notePath,
198
237
  content,
199
238
  tags: [],
200
- });
239
+ }, rules);
201
240
  const hubTitle = hubTitleForContext(context.title);
202
241
  const nextContent = normalizeTitle(title) === normalizeTitle(hubTitle) ? content : upsertCanonicalContextLink(content, hubTitle);
203
242
  return {
@@ -0,0 +1,105 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { getBrainlinkHomePath } from '../infrastructure/paths.js';
4
+ const defaultTimeoutMs = 800;
5
+ const registryBaseUrl = 'https://registry.npmjs.org';
6
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ const parseCache = (value) => isRecord(value) ? { checkedAt: typeof value.checkedAt === 'string' ? value.checkedAt : undefined, latestVersion: typeof value.latestVersion === 'string' ? value.latestVersion : undefined } : {};
8
+ const readCache = async (path) => {
9
+ try {
10
+ return parseCache(JSON.parse(await readFile(path, 'utf8')));
11
+ }
12
+ catch (error) {
13
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
14
+ return {};
15
+ }
16
+ return {};
17
+ }
18
+ };
19
+ const writeCache = async (path, cache) => {
20
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
21
+ await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
22
+ };
23
+ const parseVersionParts = (version) => {
24
+ const [core] = version.split('-');
25
+ const parts = core?.split('.').map((part) => Number.parseInt(part, 10)) ?? [];
26
+ return parts.length > 0 && parts.every((part) => Number.isFinite(part) && part >= 0) ? parts : null;
27
+ };
28
+ export const comparePackageVersions = (left, right) => {
29
+ const leftParts = parseVersionParts(left);
30
+ const rightParts = parseVersionParts(right);
31
+ if (!leftParts || !rightParts) {
32
+ return left.localeCompare(right);
33
+ }
34
+ const length = Math.max(leftParts.length, rightParts.length);
35
+ for (let index = 0; index < length; index += 1) {
36
+ const leftPart = leftParts[index] ?? 0;
37
+ const rightPart = rightParts[index] ?? 0;
38
+ if (leftPart !== rightPart) {
39
+ return leftPart > rightPart ? 1 : -1;
40
+ }
41
+ }
42
+ return 0;
43
+ };
44
+ const isCacheFresh = (cache, now, intervalMs) => {
45
+ if (!cache.checkedAt) {
46
+ return false;
47
+ }
48
+ const checkedAt = new Date(cache.checkedAt).getTime();
49
+ return Number.isFinite(checkedAt) && now.getTime() - checkedAt < intervalMs;
50
+ };
51
+ const createSkippedStatus = (input, now, reason, latestVersion = null) => ({
52
+ currentVersion: input.currentVersion,
53
+ latestVersion,
54
+ updateAvailable: latestVersion ? comparePackageVersions(latestVersion, input.currentVersion) > 0 : false,
55
+ checkedAt: now.toISOString(),
56
+ installCommand: `npm install -g ${input.packageName}@latest`,
57
+ skipped: true,
58
+ reason
59
+ });
60
+ const readLatestVersion = async (packageName, fetchImpl, timeoutMs) => {
61
+ const controller = new AbortController();
62
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
63
+ try {
64
+ const response = await fetchImpl(`${registryBaseUrl}/${encodeURIComponent(packageName)}/latest`, { signal: controller.signal });
65
+ if (!response.ok) {
66
+ return null;
67
+ }
68
+ const payload = await response.json();
69
+ return isRecord(payload) && typeof payload.version === 'string' ? payload.version : null;
70
+ }
71
+ finally {
72
+ clearTimeout(timeout);
73
+ }
74
+ };
75
+ export const checkPackageUpdate = async (input) => {
76
+ const now = input.now ?? new Date();
77
+ const cachePath = input.cachePath ?? join(getBrainlinkHomePath(), 'update-check.json');
78
+ const cache = await readCache(cachePath);
79
+ if (!input.enabled) {
80
+ return createSkippedStatus(input, now, 'disabled');
81
+ }
82
+ if (isCacheFresh(cache, now, input.intervalMs)) {
83
+ return createSkippedStatus(input, now, 'cache-fresh', cache.latestVersion ?? null);
84
+ }
85
+ const fetchImpl = input.fetch ?? globalThis.fetch;
86
+ if (!fetchImpl) {
87
+ return createSkippedStatus(input, now, 'fetch-unavailable');
88
+ }
89
+ const latestVersion = await readLatestVersion(input.packageName, fetchImpl, input.timeoutMs ?? defaultTimeoutMs);
90
+ if (!latestVersion) {
91
+ return createSkippedStatus(input, now, 'latest-unavailable');
92
+ }
93
+ await writeCache(cachePath, {
94
+ checkedAt: now.toISOString(),
95
+ latestVersion
96
+ });
97
+ return {
98
+ currentVersion: input.currentVersion,
99
+ latestVersion,
100
+ updateAvailable: comparePackageVersions(latestVersion, input.currentVersion) > 0,
101
+ checkedAt: now.toISOString(),
102
+ installCommand: `npm install -g ${input.packageName}@latest`,
103
+ skipped: false
104
+ };
105
+ };
@@ -0,0 +1,236 @@
1
+ export const createChunkFetchJs = () => `
2
+ const fitFromChunk = () => {
3
+ const nodes = normalizeList(state.chunk.nodes)
4
+ if (nodes.length === 0) {
5
+ return
6
+ }
7
+
8
+ let minX = Infinity
9
+ let minY = Infinity
10
+ let maxX = -Infinity
11
+ let maxY = -Infinity
12
+
13
+ for (let index = 0; index < nodes.length; index += 1) {
14
+ const node = nodes[index]
15
+ const x = Number(node[2])
16
+ const y = Number(node[3])
17
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
18
+ continue
19
+ }
20
+ if (x < minX) minX = x
21
+ if (y < minY) minY = y
22
+ if (x > maxX) maxX = x
23
+ if (y > maxY) maxY = y
24
+ }
25
+
26
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
27
+ return
28
+ }
29
+
30
+ const width = Math.max(1, maxX - minX)
31
+ const height = Math.max(1, maxY - minY)
32
+ const scaleX = state.viewport.width / width
33
+ const scaleY = state.viewport.height / height
34
+ const scale = clampScale(Math.min(scaleX, scaleY) * 0.72)
35
+
36
+ state.camera.scale = scale
37
+ state.camera.x = state.viewport.width / 2 - (minX + width / 2) * scale
38
+ state.camera.y = state.viewport.height / 2 - (minY + height / 2) * scale
39
+ updateWorkerCamera()
40
+ }
41
+
42
+ const fetchChunk = async ({ fit } = { fit: false }) => {
43
+ const token = ++state.fetchToken
44
+ if (state.fetchAbortController) {
45
+ state.fetchAbortController.abort()
46
+ }
47
+ const controller = new AbortController()
48
+ state.fetchAbortController = controller
49
+ const worldTopLeft = screenToWorld(0, 0)
50
+ const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
51
+ const x = Math.min(worldTopLeft.x, worldBottomRight.x)
52
+ const y = Math.min(worldTopLeft.y, worldBottomRight.y)
53
+ const w = Math.abs(worldBottomRight.x - worldTopLeft.x)
54
+ const h = Math.abs(worldBottomRight.y - worldTopLeft.y)
55
+
56
+ const params = new URLSearchParams({
57
+ x: String(x),
58
+ y: String(y),
59
+ w: String(Math.max(1, w)),
60
+ h: String(Math.max(1, h)),
61
+ scale: String(state.camera.scale),
62
+ nodeBudget: String(getZoomNodeBudget()),
63
+ edgeBudget: String(getZoomEdgeBudget())
64
+ })
65
+
66
+ if (state.agentId) {
67
+ params.set('agent', state.agentId)
68
+ }
69
+ if (state.contextId) {
70
+ params.set('context', state.contextId)
71
+ }
72
+
73
+ const requestKey = graphStreamRequestKey({ x, y, w, h })
74
+ if (!fit && state.lastChunkRequestKey === requestKey && state.chunk.nodes.length > 0) {
75
+ return
76
+ }
77
+
78
+ const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
79
+ if (!response.ok) {
80
+ throw new Error('Failed to fetch graph stream chunk')
81
+ }
82
+
83
+ const chunk = await response.json()
84
+ if (controller.signal.aborted) {
85
+ return
86
+ }
87
+ if (token !== state.fetchToken) {
88
+ return
89
+ }
90
+
91
+ state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
92
+ state.lastChunkRequestKey = requestKey
93
+ ensureNodePositionsLoaded()
94
+ await syncNodePositionsFromServer()
95
+ state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
96
+ const chunkNodes = applyManualNodePositions(chunk.nodes)
97
+ state.chunk = {
98
+ nodes: chunkNodes,
99
+ edges: normalizeList(chunk.edges)
100
+ }
101
+ state.miniMapDirty = true
102
+ state.spatialIndex.key = ''
103
+ const renderChunk = { ...chunk, nodes: chunkNodes }
104
+ state.totals = {
105
+ nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
106
+ edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
107
+ }
108
+
109
+ updateTotals()
110
+
111
+ if (fit) {
112
+ fitFromChunk()
113
+ }
114
+
115
+ if (state.renderWorker && state.workerReady) {
116
+ state.renderWorker.postMessage({ type: 'chunk', chunk: renderChunk })
117
+ state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
118
+ state.renderWorker.postMessage({ type: 'highlight', ids: Array.from(state.searchResultIds) })
119
+ }
120
+
121
+ updateGraphOverlays()
122
+ drawFallback()
123
+ }
124
+
125
+ const scheduleChunkFetch = ({ fit } = { fit: false }) => {
126
+ if (state.fetchTimer) {
127
+ clearTimeout(state.fetchTimer)
128
+ }
129
+
130
+ const now = performance.now()
131
+ const recentlyWheeling = now - state.lastWheelAt < 320
132
+ const heavyScene = state.lastVisibleNodes > 1200 || state.lastVisibleEdges > 3500
133
+ const delay = fit ? 0 : (state.pointer.down ? 320 : (recentlyWheeling ? (heavyScene ? 420 : 300) : (heavyScene ? 120 : 72)))
134
+ state.fetchTimer = setTimeout(() => {
135
+ state.fetchTimer = null
136
+ fetchChunk({ fit }).catch((error) => {
137
+ if (error && error.name === 'AbortError') {
138
+ return
139
+ }
140
+ console.error(error)
141
+ })
142
+ }, delay)
143
+ }
144
+
145
+ const setViewportFromCanvas = () => {
146
+ const rect = canvas.getBoundingClientRect()
147
+ state.viewport.width = Math.max(320, rect.width)
148
+ state.viewport.height = Math.max(320, rect.height)
149
+ state.viewport.ratio = window.devicePixelRatio || 1
150
+ state.miniMapDirty = true
151
+ updateWorkerSize()
152
+ drawFallback()
153
+ }
154
+
155
+ const pickFallbackNode = (screenX, screenY) => {
156
+ const nodes = spatialCandidates(screenX, screenY)
157
+ if (nodes.length === 0) {
158
+ return null
159
+ }
160
+
161
+ let bestNode = null
162
+ let bestDistance = Infinity
163
+ for (let index = 0; index < nodes.length; index += 1) {
164
+ const node = nodes[index]
165
+ const id = typeof node[0] === 'string' ? node[0] : ''
166
+ if (!id) continue
167
+ const x = Number(node[2])
168
+ const y = Number(node[3])
169
+ const weight = Number(node[7])
170
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue
171
+ const point = worldToScreen(x, y)
172
+ const radius = Math.max(3.2, Math.min(16.5, 5 + (Number.isFinite(weight) ? weight : 0) * 0.65))
173
+ const distance = Math.hypot(screenX - point.x, screenY - point.y)
174
+ if (distance <= radius && distance < bestDistance) {
175
+ bestDistance = distance
176
+ bestNode = node
177
+ }
178
+ }
179
+
180
+ return bestNode
181
+ }
182
+
183
+ const pickFallbackNodeId = (screenX, screenY) => {
184
+ const node = pickFallbackNode(screenX, screenY)
185
+ return typeof node?.[0] === 'string' ? node[0] : ''
186
+ }
187
+
188
+ const handlePickedNode = (node) => {
189
+ const nodeId = typeof node?.id === 'string' ? node.id : typeof node?.[0] === 'string' ? node[0] : ''
190
+ if (!nodeId) {
191
+ return
192
+ }
193
+
194
+ const kind = typeof node?.kind === 'string' ? node.kind : nodeKind(node)
195
+ if (kind === 'cluster') {
196
+ const currentScale = state.camera.scale
197
+ const targetScale = currentScale < 0.22 ? 0.28 : Math.min(1.1, currentScale * 1.6)
198
+ focusNodeInViewport(nodeId, targetScale)
199
+ return
200
+ }
201
+
202
+ loadNodeDetails(nodeId).catch((error) => console.error(error))
203
+ }
204
+
205
+ const pickAt = (screenX, screenY) => {
206
+ if (state.rendererMode === 'fallback') {
207
+ const node = pickFallbackNode(screenX, screenY)
208
+ if (node) {
209
+ handlePickedNode(node)
210
+ }
211
+ return
212
+ }
213
+
214
+ if (!state.renderWorker || !state.workerReady) {
215
+ return
216
+ }
217
+
218
+ const requestId = Math.random().toString(36).slice(2)
219
+ state.renderWorker.postMessage({
220
+ type: 'pick',
221
+ requestId,
222
+ x: screenX,
223
+ y: screenY
224
+ })
225
+ }
226
+
227
+ const zoomAtPoint = (screenX, screenY, factor) => {
228
+ const clamped = Math.max(0.92, Math.min(1.09, factor))
229
+ const before = screenToWorld(screenX, screenY)
230
+ state.camera.scale = clampScale(state.camera.scale * clamped)
231
+ state.camera.x = screenX - before.x * state.camera.scale
232
+ state.camera.y = screenY - before.y * state.camera.scale
233
+ updateWorkerCamera()
234
+ scheduleChunkFetch()
235
+ }
236
+ `;