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

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 (43) hide show
  1. package/AGENTS.md +3 -0
  2. package/CHANGELOG.md +24 -0
  3. package/COPYRIGHT.md +5 -0
  4. package/README.md +135 -7
  5. package/dist/application/auto-migrate-configured-vault.js +37 -0
  6. package/dist/application/build-context.js +64 -3
  7. package/dist/application/dedupe-notes.js +226 -0
  8. package/dist/application/frontend/client-css.js +111 -47
  9. package/dist/application/frontend/client-html.js +42 -26
  10. package/dist/application/frontend/client-js.js +788 -554
  11. package/dist/application/frontend/client-render-worker-js.js +569 -0
  12. package/dist/application/frontend/client-worker-js.js +66 -0
  13. package/dist/application/get-graph-layout.js +38 -5
  14. package/dist/application/get-graph-stream-chunk.js +289 -0
  15. package/dist/application/get-graph-view.js +243 -0
  16. package/dist/application/import-legacy-sqlite.js +296 -0
  17. package/dist/application/index-vault.js +249 -21
  18. package/dist/application/offline-pack-backup.js +44 -0
  19. package/dist/application/server/routes.js +187 -5
  20. package/dist/application/start-server.js +75 -4
  21. package/dist/application/watch-vault.js +23 -2
  22. package/dist/cli/commands/agent-commands.js +7 -0
  23. package/dist/cli/commands/write-commands.js +842 -8
  24. package/dist/cli/runtime.js +10 -2
  25. package/dist/domain/context.js +54 -11
  26. package/dist/domain/graph-layout.js +275 -3
  27. package/dist/domain/markdown.js +29 -9
  28. package/dist/domain/middle-out.js +18 -0
  29. package/dist/infrastructure/config.js +117 -4
  30. package/dist/infrastructure/file-index.js +70 -3
  31. package/dist/infrastructure/file-system-vault.js +15 -0
  32. package/dist/infrastructure/index-state.js +58 -0
  33. package/dist/infrastructure/private-pack-codec.js +71 -10
  34. package/dist/infrastructure/search-packs.js +286 -15
  35. package/dist/infrastructure/vault-migration-state.js +69 -0
  36. package/dist/infrastructure/volatile-memory.js +100 -0
  37. package/dist/mcp/runtime.js +20 -0
  38. package/dist/mcp/server.js +28 -10
  39. package/dist/mcp/tools.js +110 -0
  40. package/docs/AGENT_USAGE.md +87 -3
  41. package/docs/ARCHITECTURE.md +6 -0
  42. package/docs/QUICKSTART.md +7 -0
  43. package/package.json +7 -2
@@ -1,35 +1,6 @@
1
1
  export const createClientJs = () => `const canvas = document.getElementById('graph')
2
- const ctx = canvas.getContext('2d')
3
- const largeGraphNodeThreshold = 4000
4
- const largeGraphEdgeRenderLimit = 16000
5
- const state = {
6
- graph: { nodes: [], edges: [] },
7
- nodes: [],
8
- edges: [],
9
- visibleNodes: [],
10
- visibleEdges: [],
11
- nodeDegrees: new Map(),
12
- selected: null,
13
- hovered: null,
14
- query: '',
15
- contentFilter: { query: '', ids: null, token: 0, timer: null },
16
- agentId: '',
17
- agentsSignature: '',
18
- nodeDetails: new Map(),
19
- transform: { x: 0, y: 0, scale: 1 },
20
- pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
21
- graphSignature: '',
22
- graphStatus: '',
23
- last: performance.now()
24
- }
25
-
26
- const byId = id => document.getElementById(id)
27
- const escapeHtml = value => String(value)
28
- .replaceAll('&', '&')
29
- .replaceAll('<', '&lt;')
30
- .replaceAll('>', '&gt;')
31
- .replaceAll('"', '&quot;')
32
- .replaceAll("'", '&#039;')
2
+ const ctx2dFallback = canvas.getContext('2d')
3
+ const byId = (id) => document.getElementById(id)
33
4
  const elements = {
34
5
  search: byId('search'),
35
6
  agent: byId('agent'),
@@ -43,6 +14,8 @@ const elements = {
43
14
  contentDialog: byId('contentDialog'),
44
15
  contentTitle: byId('contentTitle'),
45
16
  contentPath: byId('contentPath'),
17
+ contentFacts: byId('contentFacts'),
18
+ contentContextLinks: byId('contentContextLinks'),
46
19
  contentTags: byId('contentTags'),
47
20
  contentOutgoing: byId('contentOutgoing'),
48
21
  contentIncoming: byId('contentIncoming'),
@@ -50,661 +23,922 @@ const elements = {
50
23
  contentClose: byId('contentClose')
51
24
  }
52
25
 
26
+ const state = {
27
+ camera: {
28
+ x: 0,
29
+ y: 0,
30
+ scale: 0.22
31
+ },
32
+ pointer: {
33
+ down: false,
34
+ moved: false,
35
+ dragging: false,
36
+ x: 0,
37
+ y: 0,
38
+ startX: 0,
39
+ startY: 0,
40
+ worldAnchorX: 0,
41
+ worldAnchorY: 0
42
+ },
43
+ viewport: {
44
+ width: 320,
45
+ height: 320,
46
+ ratio: window.devicePixelRatio || 1
47
+ },
48
+ workerReady: false,
49
+ rendererMode: 'worker',
50
+ renderWorker: null,
51
+ agentId: '',
52
+ graphSignature: '',
53
+ graphMode: 'near',
54
+ chunk: {
55
+ nodes: [],
56
+ edges: []
57
+ },
58
+ selectedNodeId: null,
59
+ searchToken: 0,
60
+ fetchToken: 0,
61
+ fetchTimer: null,
62
+ fetchAbortController: null,
63
+ cameraSyncScheduled: false,
64
+ lastDragFetchAt: 0,
65
+ lastWheelAt: 0,
66
+ lastVisibleNodes: 0,
67
+ lastVisibleEdges: 0,
68
+ totals: {
69
+ nodes: 0,
70
+ edges: 0
71
+ }
72
+ }
73
+
53
74
  const zoomRange = {
54
- min: 0.05,
75
+ min: 0.0002,
55
76
  max: 4.5
56
77
  }
57
78
 
58
- const agentQuery = () => state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''
79
+ const selectedAgentStorageKey = 'brainlink:selected-agent'
59
80
 
60
- const setGraphStatus = text => {
61
- state.graphStatus = text
81
+ const escapeHtml = (value) => String(value)
82
+ .replaceAll('&', '&amp;')
83
+ .replaceAll('<', '&lt;')
84
+ .replaceAll('>', '&gt;')
85
+ .replaceAll('"', '&quot;')
86
+ .replaceAll("'", '&#039;')
87
+
88
+ const readStoredAgent = () => {
89
+ try {
90
+ const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
91
+ return value.length > 0 ? value : ''
92
+ } catch {
93
+ return ''
94
+ }
62
95
  }
63
96
 
64
- const handleGraphRefreshError = error => {
65
- console.error(error)
97
+ const writeStoredAgent = (agentId) => {
98
+ try {
99
+ if (!agentId) {
100
+ window.localStorage.removeItem(selectedAgentStorageKey)
101
+ return
102
+ }
103
+ window.localStorage.setItem(selectedAgentStorageKey, agentId)
104
+ } catch {}
66
105
  }
67
106
 
68
- const graphTheme = {
69
- node: '#aeb8c5',
70
- nodeSelected: '#f3f7fb',
71
- nodeHover: '#cbd5e1',
72
- nodeHalo: 'rgba(203, 213, 225, 0.14)',
73
- nodeHaloActive: 'rgba(243, 247, 251, 0.2)',
74
- nodeStroke: '#0d0f12',
75
- nodeStrokeActive: '#ffffff',
76
- edge: 'rgba(153, 165, 181, 0.16)',
77
- edgeActive: 'rgba(226, 232, 240, 0.52)',
78
- label: '#edf2f7'
79
- }
80
-
81
- const resize = () => {
82
- const rect = canvas.getBoundingClientRect()
83
- const width = Math.max(rect.width, 320)
84
- const height = Math.max(rect.height, 320)
85
- const ratio = window.devicePixelRatio || 1
86
- canvas.width = Math.floor(width * ratio)
87
- canvas.height = Math.floor(height * ratio)
88
- ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
107
+ const syncAgentInUrl = (agentId) => {
108
+ try {
109
+ const url = new URL(window.location.href)
110
+ if (agentId && agentId.trim().length > 0) {
111
+ url.searchParams.set('agent', agentId)
112
+ } else {
113
+ url.searchParams.delete('agent')
114
+ }
115
+ window.history.replaceState({}, '', url.toString())
116
+ } catch {}
89
117
  }
90
118
 
91
- const normalizeQuery = value => value.trim().toLowerCase()
119
+ const initialAgentFromUrl = (() => {
120
+ try {
121
+ const raw = new URL(window.location.href).searchParams.get('agent')
122
+ const value = raw?.trim() ?? ''
123
+ return value.length > 0 ? value : ''
124
+ } catch {
125
+ return ''
126
+ }
127
+ })()
128
+
129
+ const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
130
+
131
+ const parseColor = (hex) => {
132
+ const normalized = String(hex || '#ffffff').replace('#', '')
133
+ const expanded = normalized.length === 3
134
+ ? normalized.split('').map((char) => char + char).join('')
135
+ : normalized.padEnd(6, 'f')
136
+ const value = Number.parseInt(expanded, 16)
137
+ return [
138
+ ((value >> 16) & 255) / 255,
139
+ ((value >> 8) & 255) / 255,
140
+ (value & 255) / 255,
141
+ 1
142
+ ]
143
+ }
92
144
 
93
- const localFilteredNodes = query =>
94
- state.nodes.filter(node =>
95
- node.title.toLowerCase().includes(query) ||
96
- node.path.toLowerCase().includes(query) ||
97
- node.tags.some(tag => tag.toLowerCase().includes(query))
98
- )
145
+ const graphTheme = {
146
+ node: parseColor('#aeb8c5'),
147
+ nodeCluster: parseColor('#6bb7e8'),
148
+ nodeHighlight: parseColor('#f5c24a'),
149
+ nodeSelected: parseColor('#ffffff'),
150
+ edge: [0.58, 0.64, 0.74, 0.24],
151
+ edgeHeavy: [0.78, 0.84, 0.92, 0.44],
152
+ clear: parseColor('#0d0f12')
153
+ }
99
154
 
100
- const filteredNodes = () => {
101
- const query = normalizeQuery(state.query)
102
- if (!query) return state.nodes
103
- if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
104
- return state.nodes.filter(node => state.contentFilter.ids.has(node.id))
105
- }
155
+ const clampScale = (scale) => Math.max(zoomRange.min, Math.min(zoomRange.max, scale))
106
156
 
107
- return localFilteredNodes(query)
157
+ const getZoomNodeBudget = () => {
158
+ const scale = state.camera.scale
159
+ if (scale < 0.06) return 900
160
+ if (scale < 0.12) return 1600
161
+ if (scale < 0.24) return 2600
162
+ if (scale < 0.7) return 4000
163
+ return 6000
108
164
  }
109
165
 
110
- const recomputeVisibility = () => {
111
- const nodes = filteredNodes()
112
- const ids = new Set(nodes.map(node => node.id))
113
- const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
114
- const limitedEdges = state.nodes.length > largeGraphNodeThreshold
115
- ? [...edges]
116
- .sort((left, right) => edgeWeight(right) - edgeWeight(left))
117
- .slice(0, largeGraphEdgeRenderLimit)
118
- : edges
119
-
120
- state.visibleNodes = nodes
121
- state.visibleEdges = limitedEdges
166
+ const getZoomEdgeBudget = () => {
167
+ const scale = state.camera.scale
168
+ if (scale < 0.06) return 2000
169
+ if (scale < 0.12) return 4800
170
+ if (scale < 0.24) return 9000
171
+ if (scale < 0.7) return 15000
172
+ return 26000
122
173
  }
123
174
 
124
- const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
175
+ const screenToWorld = (screenX, screenY) => ({
176
+ x: (screenX - state.camera.x) / state.camera.scale,
177
+ y: (screenY - state.camera.y) / state.camera.scale
178
+ })
125
179
 
126
- const clampScale = value => Math.max(zoomRange.min, Math.min(zoomRange.max, value))
180
+ const worldToScreen = (x, y) => ({
181
+ x: x * state.camera.scale + state.camera.x,
182
+ y: y * state.camera.scale + state.camera.y
183
+ })
127
184
 
128
- const graphBounds = nodes => {
129
- if (nodes.length === 0) return null
130
- let minX = Number.POSITIVE_INFINITY
131
- let maxX = Number.NEGATIVE_INFINITY
132
- let minY = Number.POSITIVE_INFINITY
133
- let maxY = Number.NEGATIVE_INFINITY
185
+ const drawFallback = () => {
186
+ if (state.rendererMode !== 'fallback' || !ctx2dFallback) {
187
+ return
188
+ }
189
+ const width = state.viewport.width
190
+ const height = state.viewport.height
191
+ const ratio = state.viewport.ratio
192
+ canvas.width = Math.floor(width * ratio)
193
+ canvas.height = Math.floor(height * ratio)
194
+ ctx2dFallback.setTransform(ratio, 0, 0, ratio, 0, 0)
195
+ ctx2dFallback.fillStyle = '#0d0f12'
196
+ ctx2dFallback.fillRect(0, 0, width, height)
134
197
 
135
- nodes.forEach(node => {
136
- const radius = nodeRadius(node)
137
- minX = Math.min(minX, node.x - radius)
138
- maxX = Math.max(maxX, node.x + radius)
139
- minY = Math.min(minY, node.y - radius)
140
- maxY = Math.max(maxY, node.y + radius)
141
- })
198
+ const nodes = Array.isArray(state.chunk.nodes) ? state.chunk.nodes : []
199
+ const edges = Array.isArray(state.chunk.edges) ? state.chunk.edges : []
200
+ const nodeById = new Map()
201
+ for (let i = 0; i < nodes.length; i += 1) {
202
+ nodeById.set(nodes[i][0], nodes[i])
203
+ }
142
204
 
143
- return {
144
- minX,
145
- maxX,
146
- minY,
147
- maxY,
148
- width: Math.max(maxX - minX, 1),
149
- height: Math.max(maxY - minY, 1)
205
+ ctx2dFallback.strokeStyle = 'rgba(150,165,190,0.2)'
206
+ ctx2dFallback.lineWidth = 1
207
+ for (let i = 0; i < edges.length; i += 1) {
208
+ const edge = edges[i]
209
+ const source = nodeById.get(edge[0])
210
+ const target = nodeById.get(edge[1])
211
+ if (!source || !target) continue
212
+ const from = worldToScreen(source[2], source[3])
213
+ const to = worldToScreen(target[2], target[3])
214
+ ctx2dFallback.beginPath()
215
+ ctx2dFallback.moveTo(from.x, from.y)
216
+ ctx2dFallback.lineTo(to.x, to.y)
217
+ ctx2dFallback.stroke()
150
218
  }
219
+
220
+ for (let i = 0; i < nodes.length; i += 1) {
221
+ const node = nodes[i]
222
+ const p = worldToScreen(node[2], node[3])
223
+ const selected = state.selectedNodeId === node[0]
224
+ const color = node[6] === 'cluster' ? '#6bb7e8' : '#aeb8c5'
225
+ const radius = Math.max(2.4, Math.min(14, 4 + node[7] * 0.55))
226
+
227
+ ctx2dFallback.beginPath()
228
+ ctx2dFallback.fillStyle = selected ? '#ffffff' : color
229
+ ctx2dFallback.arc(p.x, p.y, radius, 0, Math.PI * 2)
230
+ ctx2dFallback.fill()
231
+ }
232
+
233
+ ctx2dFallback.fillStyle = '#edf2f7'
234
+ ctx2dFallback.font = '12px Inter, system-ui, sans-serif'
235
+ ctx2dFallback.textAlign = 'center'
236
+ ctx2dFallback.fillText('Fallback canvas mode', Math.max(width, 320) / 2, 24)
151
237
  }
152
238
 
153
- const fitView = (options = { useFiltered: true }) => {
154
- const rect = canvas.getBoundingClientRect()
155
- const width = Math.max(rect.width, 320)
156
- const height = Math.max(rect.height, 320)
157
- const nodes = options.useFiltered ? filteredNodes() : state.nodes
158
- const bounds = graphBounds(nodes)
239
+ const updateTotals = () => {
240
+ elements.nodeCount.textContent = String(state.totals.nodes)
241
+ elements.edgeCount.textContent = String(state.totals.edges)
242
+ }
243
+
244
+ const updateTagCount = () => {
245
+ elements.tagCount.textContent = state.graphMode === 'far' ? 'clusters' : state.graphMode
246
+ }
159
247
 
160
- if (!bounds) {
161
- state.transform = { x: width / 2, y: height / 2, scale: 1 }
248
+ const updateWorkerCamera = () => {
249
+ if (!state.renderWorker || !state.workerReady) {
162
250
  return
163
251
  }
164
-
165
- const padding = 100
166
- const scaleX = width / (bounds.width + padding * 2)
167
- const scaleY = height / (bounds.height + padding * 2)
168
- const scale = clampScale(Math.min(scaleX, scaleY))
169
- const centerX = (bounds.minX + bounds.maxX) / 2
170
- const centerY = (bounds.minY + bounds.maxY) / 2
171
-
172
- state.transform = {
173
- x: width / 2 - centerX * scale,
174
- y: height / 2 - centerY * scale,
175
- scale
252
+ if (state.cameraSyncScheduled) {
253
+ return
176
254
  }
255
+ state.cameraSyncScheduled = true
256
+ requestAnimationFrame(() => {
257
+ state.cameraSyncScheduled = false
258
+ if (!state.renderWorker || !state.workerReady) {
259
+ return
260
+ }
261
+ state.renderWorker.postMessage({
262
+ type: 'camera',
263
+ camera: state.camera
264
+ })
265
+ })
177
266
  }
178
267
 
179
- const resetView = () => fitView({ useFiltered: false })
268
+ const updateWorkerSize = () => {
269
+ if (!state.renderWorker || !state.workerReady) {
270
+ return
271
+ }
272
+ state.renderWorker.postMessage({
273
+ type: 'resize',
274
+ width: state.viewport.width,
275
+ height: state.viewport.height,
276
+ devicePixelRatio: state.viewport.ratio
277
+ })
278
+ }
180
279
 
181
- const createLayout = graph => {
182
- const nodes = graph.nodes.map(node => ({
183
- ...node,
184
- x: Number.isFinite(node.x) ? node.x : 0,
185
- y: Number.isFinite(node.y) ? node.y : 0
186
- }))
187
- const nodeMap = new Map(nodes.map(node => [node.id, node]))
188
- const edges = graph.edges
189
- .filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
190
- .map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
191
- return { nodes, edges }
280
+ const normalizeList = (items) => Array.isArray(items) ? items : []
281
+
282
+ const list = (items) => {
283
+ const rows = normalizeList(items)
284
+ if (rows.length === 0) {
285
+ return '<li><small>No links found.</small></li>'
286
+ }
287
+ return rows
288
+ .map((item) => {
289
+ const title = typeof item?.title === 'string' ? item.title : 'Untitled'
290
+ const id = typeof item?.id === 'string' ? item.id : ''
291
+ const path = typeof item?.path === 'string' ? item.path : ''
292
+ const meta = item?.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : ''
293
+ return '<li>' +
294
+ (id ? '<button type="button" data-node-id="' + escapeHtml(id) + '">' + escapeHtml(title) + '</button>' : escapeHtml(title)) +
295
+ '<small>' + escapeHtml(path) + meta + '</small>' +
296
+ '</li>'
297
+ })
298
+ .join('')
192
299
  }
193
300
 
194
- const encodeEntityTag = (value) => {
195
- const utf8 = new TextEncoder().encode(value)
196
- let binary = ''
301
+ const extractContextLinks = (content) => {
302
+ if (typeof content !== 'string' || content.length === 0) {
303
+ return []
304
+ }
305
+ const lines = content.split(/\\r?\\n/)
306
+ let start = -1
307
+ for (let index = 0; index < lines.length; index += 1) {
308
+ if (/^#{1,6}\\s+(?:context\\s+links?|links?\\s+de\\s+contexto)\\b/i.test(lines[index].trim())) {
309
+ start = index + 1
310
+ break
311
+ }
312
+ }
313
+ if (start < 0) {
314
+ return []
315
+ }
197
316
 
198
- for (let index = 0; index < utf8.length; index += 1) {
199
- binary += String.fromCharCode(utf8[index])
317
+ const links = []
318
+ const seenTitles = new Set()
319
+ for (let index = start; index < lines.length; index += 1) {
320
+ const line = lines[index].trim()
321
+ if (!line) {
322
+ continue
323
+ }
324
+ if (/^#{1,6}\\s+/.test(line)) {
325
+ break
326
+ }
327
+ const matches = Array.from(line.matchAll(/\\[\\[([^\\]]+)\\]\\]/g))
328
+ if (matches.length === 0) {
329
+ continue
330
+ }
331
+ const priorityMatch = line.match(/#(critical|important)\\b|priority:\\s*(high|critical)/i)
332
+ const priority = priorityMatch ? String(priorityMatch[1] || priorityMatch[2] || 'normal').toLowerCase() : 'normal'
333
+
334
+ for (let matchIndex = 0; matchIndex < matches.length; matchIndex += 1) {
335
+ const title = String(matches[matchIndex][1] || '').trim()
336
+ if (!title || seenTitles.has(title.toLowerCase())) {
337
+ continue
338
+ }
339
+ seenTitles.add(title.toLowerCase())
340
+ links.push({ title, priority })
341
+ }
200
342
  }
343
+ return links
344
+ }
201
345
 
202
- return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
346
+ const buildFacts = (node, outgoingCount, incomingCount) => {
347
+ const content = typeof node?.content === 'string' ? node.content : ''
348
+ const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
349
+ return [
350
+ { label: 'Agent', value: typeof node?.agentId === 'string' && node.agentId ? node.agentId : 'shared' },
351
+ { label: 'Words', value: String(words) },
352
+ { label: 'Chars', value: String(content.length) },
353
+ { label: 'Outgoing', value: String(outgoingCount) },
354
+ { label: 'Backlinks', value: String(incomingCount) }
355
+ ]
203
356
  }
204
357
 
205
- const graphSignature = graph => JSON.stringify({
206
- nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
207
- edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
208
- })
358
+ const listFacts = (facts) => facts
359
+ .map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
360
+ .join('')
209
361
 
210
- const resetContentFilter = () => {
211
- if (state.contentFilter.timer) {
212
- clearTimeout(state.contentFilter.timer)
362
+ const listContextLinks = (links) => {
363
+ if (!Array.isArray(links) || links.length === 0) {
364
+ return '<li><small>No context links found.</small></li>'
213
365
  }
214
- state.contentFilter = {
215
- query: '',
216
- ids: null,
217
- token: state.contentFilter.token + 1,
218
- timer: null
366
+ return links
367
+ .map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
368
+ .join('')
369
+ }
370
+
371
+ const linkedNodes = (node) => {
372
+ const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
373
+ const edges = normalizeList(state.chunk.edges)
374
+
375
+ const outgoing = []
376
+ const incoming = []
377
+ for (let index = 0; index < edges.length; index += 1) {
378
+ const edge = edges[index]
379
+ if (edge[0] === node.id) {
380
+ const target = nodeById.get(edge[1])
381
+ if (target) {
382
+ outgoing.push({ id: target[0], title: target[1], path: target[4] || '', weight: edge[2], priority: edge[3] })
383
+ }
384
+ }
385
+ if (edge[1] === node.id) {
386
+ const source = nodeById.get(edge[0])
387
+ if (source) {
388
+ incoming.push({ id: source[0], title: source[1], path: source[4] || '', weight: edge[2], priority: edge[3] })
389
+ }
390
+ }
219
391
  }
220
- recomputeVisibility()
392
+
393
+ return { outgoing, incoming }
221
394
  }
222
395
 
223
- const syncContentFilter = async (query, token) => {
224
- const response = await fetch(
225
- '/api/graph-filter?q=' +
226
- encodeURIComponent(query) +
227
- '&limit=' +
228
- encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
229
- agentQuery()
230
- )
396
+ const openContentDialog = () => {
397
+ const dialog = elements.contentDialog
398
+ if (!dialog.open) {
399
+ dialog.show()
400
+ }
401
+ }
231
402
 
232
- if (!response.ok || token !== state.contentFilter.token) {
403
+ const loadNodeDetails = async (nodeId) => {
404
+ if (!nodeId) {
233
405
  return
234
406
  }
235
407
 
408
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + agentQuery('&'))
409
+ if (!response.ok) {
410
+ throw new Error('Failed to load graph node details')
411
+ }
412
+
236
413
  const payload = await response.json()
237
- const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
238
- if (token !== state.contentFilter.token) {
239
- return
414
+ if (!payload || typeof payload !== 'object' || !payload.node) {
415
+ throw new Error('Invalid graph node payload')
416
+ }
417
+
418
+ const node = payload.node
419
+ state.selectedNodeId = node.id
420
+
421
+ if (state.renderWorker && state.workerReady) {
422
+ state.renderWorker.postMessage({ type: 'select', id: node.id })
240
423
  }
241
424
 
242
- state.contentFilter.query = query
243
- state.contentFilter.ids = new Set(nodeIds)
244
- recomputeVisibility()
425
+ elements.contentTitle.textContent = node.title || 'Untitled'
426
+ elements.contentPath.textContent = node.path || ''
427
+
428
+ const tags = Array.isArray(node.tags) ? node.tags : []
429
+ elements.contentTags.innerHTML = tags.length > 0
430
+ ? tags.map((tag) => '<span>' + escapeHtml(tag) + '</span>').join('')
431
+ : '<span>No tags</span>'
432
+
433
+ const related = linkedNodes(node)
434
+ const contextLinks = extractContextLinks(node.content)
435
+ const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
436
+ elements.contentFacts.innerHTML = listFacts(facts)
437
+ elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
438
+ elements.contentOutgoing.innerHTML = list(related.outgoing)
439
+ elements.contentIncoming.innerHTML = list(related.incoming)
440
+ elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
441
+
442
+ openContentDialog()
245
443
  }
246
444
 
247
- const scheduleContentFilterSync = () => {
248
- const query = normalizeQuery(state.query)
249
- if (!query) {
250
- resetContentFilter()
445
+ const fitFromChunk = () => {
446
+ const nodes = normalizeList(state.chunk.nodes)
447
+ if (nodes.length === 0) {
251
448
  return
252
449
  }
253
450
 
254
- if (state.contentFilter.timer) {
255
- clearTimeout(state.contentFilter.timer)
451
+ let minX = Infinity
452
+ let minY = Infinity
453
+ let maxX = -Infinity
454
+ let maxY = -Infinity
455
+
456
+ for (let index = 0; index < nodes.length; index += 1) {
457
+ const node = nodes[index]
458
+ const x = Number(node[2])
459
+ const y = Number(node[3])
460
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
461
+ continue
462
+ }
463
+ if (x < minX) minX = x
464
+ if (y < minY) minY = y
465
+ if (x > maxX) maxX = x
466
+ if (y > maxY) maxY = y
256
467
  }
257
468
 
258
- const token = state.contentFilter.token + 1
259
- state.contentFilter = {
260
- query: state.contentFilter.query,
261
- ids: state.contentFilter.ids,
262
- token,
263
- timer: setTimeout(() => {
264
- syncContentFilter(query, token).catch(() => {})
265
- }, 180)
469
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
470
+ return
266
471
  }
472
+
473
+ const width = Math.max(1, maxX - minX)
474
+ const height = Math.max(1, maxY - minY)
475
+ const scaleX = state.viewport.width / width
476
+ const scaleY = state.viewport.height / height
477
+ const scale = clampScale(Math.min(scaleX, scaleY) * 0.72)
478
+
479
+ state.camera.scale = scale
480
+ state.camera.x = state.viewport.width / 2 - (minX + width / 2) * scale
481
+ state.camera.y = state.viewport.height / 2 - (minY + height / 2) * scale
482
+ updateWorkerCamera()
267
483
  }
268
484
 
269
- const tick = delta => {
270
- const nodes = state.visibleNodes
271
- const edges = state.visibleEdges
272
- if (nodes.length > 1200) {
485
+ const fetchChunk = async ({ fit } = { fit: false }) => {
486
+ const token = ++state.fetchToken
487
+ if (state.fetchAbortController) {
488
+ state.fetchAbortController.abort()
489
+ }
490
+ const controller = new AbortController()
491
+ state.fetchAbortController = controller
492
+ const worldTopLeft = screenToWorld(0, 0)
493
+ const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
494
+ const x = Math.min(worldTopLeft.x, worldBottomRight.x)
495
+ const y = Math.min(worldTopLeft.y, worldBottomRight.y)
496
+ const w = Math.abs(worldBottomRight.x - worldTopLeft.x)
497
+ const h = Math.abs(worldBottomRight.y - worldTopLeft.y)
498
+
499
+ const params = new URLSearchParams({
500
+ x: String(x),
501
+ y: String(y),
502
+ w: String(Math.max(1, w)),
503
+ h: String(Math.max(1, h)),
504
+ scale: String(state.camera.scale),
505
+ nodeBudget: String(getZoomNodeBudget()),
506
+ edgeBudget: String(getZoomEdgeBudget())
507
+ })
508
+
509
+ if (state.agentId) {
510
+ params.set('agent', state.agentId)
511
+ }
512
+
513
+ const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
514
+ if (!response.ok) {
515
+ throw new Error('Failed to fetch graph stream chunk')
516
+ }
517
+
518
+ const chunk = await response.json()
519
+ if (controller.signal.aborted) {
520
+ return
521
+ }
522
+ if (token !== state.fetchToken) {
273
523
  return
274
524
  }
275
- const strength = Math.min(delta / 16, 2)
276
-
277
- edges.forEach(edge => {
278
- const source = edge.sourceNode
279
- const target = edge.targetNode
280
- const dx = target.x - source.x
281
- const dy = target.y - source.y
282
- const distance = Math.max(Math.hypot(dx, dy), 1)
283
- const force = (distance - 150) * 0.002 * strength
284
- const fx = dx * force
285
- const fy = dy * force
286
- source.vx += fx
287
- source.vy += fy
288
- target.vx -= fx
289
- target.vy -= fy
290
- })
291
525
 
292
- for (let i = 0; i < nodes.length; i += 1) {
293
- for (let j = i + 1; j < nodes.length; j += 1) {
294
- const a = nodes[i]
295
- const b = nodes[j]
296
- const dx = b.x - a.x
297
- const dy = b.y - a.y
298
- const distance = Math.max(Math.hypot(dx, dy), 1)
299
- const force = Math.min(2600 / (distance * distance), 0.12) * strength
300
- const fx = (dx / distance) * force
301
- const fy = (dy / distance) * force
302
- a.vx -= fx
303
- a.vy -= fy
304
- b.vx += fx
305
- b.vy += fy
306
- }
526
+ state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
527
+ state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
528
+ state.chunk = {
529
+ nodes: normalizeList(chunk.nodes),
530
+ edges: normalizeList(chunk.edges)
531
+ }
532
+ state.totals = {
533
+ nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
534
+ edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
307
535
  }
308
536
 
309
- nodes.forEach(node => {
310
- if (state.pointer.dragNode === node) {
311
- node.vx = 0
312
- node.vy = 0
313
- return
314
- }
315
- node.vx += -node.x * 0.0008 * strength
316
- node.vy += -node.y * 0.0008 * strength
317
- node.vx *= 0.88
318
- node.vy *= 0.88
319
- node.x += node.vx * strength
320
- node.y += node.vy * strength
321
- })
537
+ updateTotals()
538
+ updateTagCount()
539
+
540
+ if (fit) {
541
+ fitFromChunk()
542
+ }
543
+
544
+ if (state.renderWorker && state.workerReady) {
545
+ state.renderWorker.postMessage({ type: 'chunk', chunk })
546
+ state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
547
+ }
548
+
549
+ drawFallback()
322
550
  }
323
551
 
324
- const worldPoint = event => {
325
- const rect = canvas.getBoundingClientRect()
326
- return {
327
- x: (event.clientX - rect.left - state.transform.x) / state.transform.scale,
328
- y: (event.clientY - rect.top - state.transform.y) / state.transform.scale
552
+ const scheduleChunkFetch = ({ fit } = { fit: false }) => {
553
+ if (state.fetchTimer) {
554
+ clearTimeout(state.fetchTimer)
329
555
  }
556
+
557
+ const now = performance.now()
558
+ const recentlyWheeling = now - state.lastWheelAt < 180
559
+ const delay = fit ? 0 : (state.pointer.down ? 120 : (recentlyWheeling ? 140 : 48))
560
+ state.fetchTimer = setTimeout(() => {
561
+ state.fetchTimer = null
562
+ fetchChunk({ fit }).catch((error) => {
563
+ if (error && error.name === 'AbortError') {
564
+ return
565
+ }
566
+ console.error(error)
567
+ })
568
+ }, delay)
569
+ }
570
+
571
+ const setViewportFromCanvas = () => {
572
+ const rect = canvas.getBoundingClientRect()
573
+ state.viewport.width = Math.max(320, rect.width)
574
+ state.viewport.height = Math.max(320, rect.height)
575
+ state.viewport.ratio = window.devicePixelRatio || 1
576
+ updateWorkerSize()
577
+ drawFallback()
330
578
  }
331
579
 
332
- const hitNode = point => {
333
- if (state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.55) {
334
- return null
580
+ const pickFallbackNodeId = (screenX, screenY) => {
581
+ const nodes = normalizeList(state.chunk.nodes)
582
+ if (nodes.length === 0) {
583
+ return ''
335
584
  }
336
585
 
337
- const nodes = state.visibleNodes
338
- for (let index = nodes.length - 1; index >= 0; index -= 1) {
586
+ let bestId = ''
587
+ let bestDistance = Infinity
588
+ for (let index = 0; index < nodes.length; index += 1) {
339
589
  const node = nodes[index]
340
- const radius = nodeRadius(node)
341
- if (Math.hypot(point.x - node.x, point.y - node.y) <= radius + 5) return node
590
+ const id = typeof node[0] === 'string' ? node[0] : ''
591
+ if (!id) continue
592
+ const x = Number(node[2])
593
+ const y = Number(node[3])
594
+ const weight = Number(node[7])
595
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue
596
+ const point = worldToScreen(x, y)
597
+ const radius = Math.max(2.4, Math.min(14, 4 + (Number.isFinite(weight) ? weight : 0) * 0.55))
598
+ const distance = Math.hypot(screenX - point.x, screenY - point.y)
599
+ if (distance <= radius && distance < bestDistance) {
600
+ bestDistance = distance
601
+ bestId = id
602
+ }
342
603
  }
343
- return null
344
- }
345
604
 
346
- const nodeRadius = node => {
347
- const degree = state.nodeDegrees.get(node.id) ?? 0
348
- return 9 + Math.min(degree, 8) * 1.6
605
+ return bestId
349
606
  }
350
607
 
351
- const render = now => {
352
- const delta = now - state.last
353
- state.last = now
354
- const minFrameIntervalMs = state.nodes.length > largeGraphNodeThreshold ? 180 : 16
355
- if (delta < minFrameIntervalMs) {
356
- requestAnimationFrame(render)
608
+ const pickAt = (screenX, screenY) => {
609
+ if (state.rendererMode === 'fallback') {
610
+ const nodeId = pickFallbackNodeId(screenX, screenY)
611
+ if (nodeId) {
612
+ loadNodeDetails(nodeId).catch((error) => console.error(error))
613
+ }
357
614
  return
358
615
  }
359
- const rect = canvas.getBoundingClientRect()
360
- const width = Math.max(rect.width, 320)
361
- const height = Math.max(rect.height, 320)
362
- ctx.clearRect(0, 0, width, height)
363
- if (state.nodes.length === 0) {
364
- ctx.fillStyle = '#99a5b5'
365
- ctx.font = '14px Inter, system-ui, sans-serif'
366
- ctx.textAlign = 'center'
367
- ctx.fillText('No indexed notes found', width / 2, height / 2)
368
- requestAnimationFrame(render)
616
+
617
+ if (!state.renderWorker || !state.workerReady) {
369
618
  return
370
619
  }
371
- ctx.save()
372
- ctx.translate(state.transform.x, state.transform.y)
373
- ctx.scale(state.transform.scale, state.transform.scale)
374
-
375
- tick(delta)
376
- const drawEdges = !(state.nodes.length > largeGraphNodeThreshold && state.transform.scale < 0.22)
377
- if (drawEdges) {
378
- state.visibleEdges.forEach(edge => {
379
- const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
380
- ctx.beginPath()
381
- ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
382
- ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
383
- ctx.strokeStyle = selectedEdge ? graphTheme.edgeActive : graphTheme.edge
384
- ctx.lineWidth = (selectedEdge ? 1.8 : 1) + Math.min(edgeWeight(edge) - 1, 8) * 0.22
385
- ctx.stroke()
386
- })
387
- }
388
620
 
389
- state.visibleNodes.forEach(node => {
390
- const radius = nodeRadius(node)
391
- const isSelected = state.selected?.id === node.id
392
- const isHovered = state.hovered?.id === node.id
393
- ctx.beginPath()
394
- ctx.arc(node.x, node.y, radius + (isSelected ? 7 : isHovered ? 4 : 0), 0, Math.PI * 2)
395
- ctx.fillStyle = isSelected || isHovered ? graphTheme.nodeHaloActive : graphTheme.nodeHalo
396
- ctx.fill()
397
- ctx.beginPath()
398
- ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
399
- ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
400
- ctx.fill()
401
- ctx.lineWidth = isSelected ? 2.6 : 1.5
402
- ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
403
- ctx.stroke()
404
-
405
- const shouldDrawLabels =
406
- isSelected ||
407
- isHovered ||
408
- (state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
409
- if (shouldDrawLabels) {
410
- ctx.fillStyle = graphTheme.label
411
- ctx.font = '12px Inter, system-ui, sans-serif'
412
- ctx.textAlign = 'center'
413
- ctx.textBaseline = 'top'
414
- ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
415
- }
621
+ const requestId = Math.random().toString(36).slice(2)
622
+ state.renderWorker.postMessage({
623
+ type: 'pick',
624
+ requestId,
625
+ x: screenX,
626
+ y: screenY
416
627
  })
628
+ }
417
629
 
418
- ctx.restore()
419
- requestAnimationFrame(render)
420
- }
421
-
422
- const list = items => items.length
423
- ? items.map(item => '<li>' + (item.id ? '<button type="button" data-node-id="' + escapeHtml(item.id) + '">' + escapeHtml(item.title) + '</button>' : escapeHtml(item.title)) + '<small>' + escapeHtml(item.path) + (item.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : '') + '</small></li>').join('')
424
- : '<li><small>No links found.</small></li>'
425
-
426
- const linkedNodes = node => {
427
- const nodeById = new Map(state.nodes.map(item => [item.id, item]))
428
- const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
429
- ...linkedNode,
430
- weight: edge.weight,
431
- priority: edge.priority
432
- } : null
433
- const outgoing = state.graph.edges
434
- .filter(edge => edge.source === node.id)
435
- .map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
436
- .filter(Boolean)
437
- const incoming = state.graph.edges
438
- .filter(edge => edge.target === node.id)
439
- .map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
440
- .filter(Boolean)
441
-
442
- return { outgoing, incoming }
630
+ const zoomAtPoint = (screenX, screenY, factor) => {
631
+ const clamped = Math.max(0.92, Math.min(1.09, factor))
632
+ const before = screenToWorld(screenX, screenY)
633
+ state.camera.scale = clampScale(state.camera.scale * clamped)
634
+ state.camera.x = screenX - before.x * state.camera.scale
635
+ state.camera.y = screenY - before.y * state.camera.scale
636
+ updateWorkerCamera()
637
+ scheduleChunkFetch()
443
638
  }
444
639
 
445
- const fetchNodeDetails = async node => {
446
- const cached = state.nodeDetails.get(node.id)
447
- if (cached) {
448
- return cached
640
+ const resolvePointer = (event) => {
641
+ const rect = canvas.getBoundingClientRect()
642
+ return {
643
+ x: event.clientX - rect.left,
644
+ y: event.clientY - rect.top
449
645
  }
646
+ }
450
647
 
451
- const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery())
452
- if (!response.ok) {
453
- throw new Error('Failed to load graph node details')
454
- }
648
+ const setupInput = () => {
649
+ const dragActivationDistance = 6
455
650
 
456
- const payload = await response.json()
457
- const detail = payload?.node
458
- if (!detail || !detail.id) {
459
- throw new Error('Invalid graph node payload')
460
- }
461
- state.nodeDetails.set(detail.id, detail)
462
- return detail
463
- }
651
+ canvas.addEventListener('wheel', (event) => {
652
+ event.preventDefault()
653
+ state.lastWheelAt = performance.now()
654
+ const pointer = resolvePointer(event)
655
+ const exponent = Math.max(-0.05, Math.min(0.05, -event.deltaY * 0.001))
656
+ zoomAtPoint(pointer.x, pointer.y, Math.exp(exponent))
657
+ }, { passive: false })
464
658
 
465
- const openContentDialog = async node => {
466
- if (!node) return
467
- const { outgoing, incoming } = linkedNodes(node)
468
- elements.contentTitle.textContent = node.title
469
- elements.contentPath.textContent = node.path
470
- elements.contentTags.innerHTML = node.tags.length
471
- ? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
472
- : '<span>No tags</span>'
473
- elements.contentOutgoing.innerHTML = list(outgoing)
474
- elements.contentIncoming.innerHTML = list(incoming)
475
- elements.contentBody.textContent = 'Loading note content...'
476
- if (!elements.contentDialog.open) {
477
- elements.contentDialog.showModal()
478
- }
659
+ canvas.addEventListener('pointerdown', (event) => {
660
+ const pointer = resolvePointer(event)
661
+ state.pointer.down = true
662
+ state.pointer.moved = false
663
+ state.pointer.dragging = false
664
+ state.pointer.x = pointer.x
665
+ state.pointer.y = pointer.y
666
+ state.pointer.startX = pointer.x
667
+ state.pointer.startY = pointer.y
668
+ const world = screenToWorld(pointer.x, pointer.y)
669
+ state.pointer.worldAnchorX = world.x
670
+ state.pointer.worldAnchorY = world.y
671
+ canvas.setPointerCapture(event.pointerId)
672
+ })
479
673
 
480
- try {
481
- const detailedNode = await fetchNodeDetails(node)
482
- if (state.selected?.id !== node.id) {
674
+ canvas.addEventListener('pointermove', (event) => {
675
+ const pointer = resolvePointer(event)
676
+
677
+ if (state.pointer.down) {
678
+ const dx = pointer.x - state.pointer.x
679
+ const dy = pointer.y - state.pointer.y
680
+ const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
681
+ if (distanceFromStart >= dragActivationDistance) {
682
+ state.pointer.moved = true
683
+ state.pointer.dragging = true
684
+ }
685
+ if (!state.pointer.dragging) {
686
+ state.pointer.x = pointer.x
687
+ state.pointer.y = pointer.y
688
+ return
689
+ }
690
+ state.camera.x += dx
691
+ state.camera.y += dy
692
+ state.pointer.x = pointer.x
693
+ state.pointer.y = pointer.y
694
+ updateWorkerCamera()
695
+ const now = performance.now()
696
+ if (now - state.lastDragFetchAt > 180) {
697
+ state.lastDragFetchAt = now
698
+ scheduleChunkFetch()
699
+ }
700
+ drawFallback()
483
701
  return
484
702
  }
485
- elements.contentBody.textContent = detailedNode.content
486
- } catch {
487
- elements.contentBody.textContent = 'Unable to load note content.'
488
- }
489
- }
490
703
 
491
- const selectNode = (node, options = { openContent: false }) => {
492
- state.selected = node
493
- if (node && options.openContent) {
494
- openContentDialog(node).catch(() => {
495
- elements.contentBody.textContent = 'Unable to load note content.'
496
- })
497
- }
498
- }
704
+ })
499
705
 
500
- const selectNodeById = id => {
501
- const node = state.nodes.find(item => item.id === id)
502
- if (node) selectNode(node, { openContent: true })
503
- }
706
+ canvas.addEventListener('pointerup', (event) => {
707
+ const pointer = resolvePointer(event)
708
+ const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
709
+ const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
710
+ const shouldRefreshAfterDrag = state.pointer.dragging
711
+ state.pointer.down = false
712
+ state.pointer.dragging = false
713
+ canvas.releasePointerCapture(event.pointerId)
504
714
 
505
- const zoomAtPoint = (screenX, screenY, factor) => {
506
- const nextScale = clampScale(state.transform.scale * factor)
507
- if (nextScale === state.transform.scale) return
508
- const worldX = (screenX - state.transform.x) / state.transform.scale
509
- const worldY = (screenY - state.transform.y) / state.transform.scale
510
- state.transform.scale = nextScale
511
- state.transform.x = screenX - worldX * nextScale
512
- state.transform.y = screenY - worldY * nextScale
513
- }
514
-
515
- const bindEvents = () => {
516
- window.addEventListener('resize', resize)
517
- elements.search.addEventListener('input', event => {
518
- state.query = event.target.value
519
- recomputeVisibility()
520
- scheduleContentFilterSync()
715
+ if (shouldPick) {
716
+ pickAt(pointer.x, pointer.y)
717
+ return
718
+ }
719
+ if (shouldRefreshAfterDrag) {
720
+ scheduleChunkFetch()
721
+ }
521
722
  })
522
- elements.agent.addEventListener('change', event => {
523
- state.agentId = event.target.value
524
- state.selected = null
525
- state.nodeDetails = new Map()
526
- resetContentFilter()
527
- recomputeVisibility()
528
- scheduleContentFilterSync()
529
- loadGraph({ reset: true }).catch(error => {
530
- console.error(error)
531
- })
723
+
724
+ canvas.addEventListener('dblclick', (event) => {
725
+ const pointer = resolvePointer(event)
726
+ zoomAtPoint(pointer.x, pointer.y, 1.065)
532
727
  })
728
+
729
+ window.addEventListener('keydown', (event) => {
730
+ if (event.key === '+') {
731
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
732
+ return
733
+ }
734
+ if (event.key === '-') {
735
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
736
+ return
737
+ }
738
+ if (event.key === '0') {
739
+ scheduleChunkFetch({ fit: true })
740
+ }
741
+ })
742
+ }
743
+
744
+ const setupControls = () => {
533
745
  elements.zoomIn.addEventListener('click', () => {
534
- const rect = canvas.getBoundingClientRect()
535
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.18)
746
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
536
747
  })
748
+
537
749
  elements.zoomOut.addEventListener('click', () => {
538
- const rect = canvas.getBoundingClientRect()
539
- zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.84)
750
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
540
751
  })
541
- if (elements.fit) {
542
- elements.fit.addEventListener('click', () => {
543
- fitView({ useFiltered: true })
544
- })
545
- }
752
+
753
+ elements.fit.addEventListener('click', () => {
754
+ fitFromChunk()
755
+ scheduleChunkFetch()
756
+ })
757
+
546
758
  elements.reset.addEventListener('click', () => {
547
- resetView()
759
+ state.camera = { x: 0, y: 0, scale: 0.22 }
760
+ updateWorkerCamera()
761
+ scheduleChunkFetch({ fit: true })
548
762
  })
549
- elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
550
- elements.contentDialog.addEventListener('click', event => {
551
- const target = event.target
552
- if (target instanceof HTMLElement && target.dataset.nodeId) {
553
- selectNodeById(target.dataset.nodeId)
554
- return
555
- }
556
- if (event.target === elements.contentDialog) elements.contentDialog.close()
763
+
764
+ elements.contentClose.addEventListener('click', () => {
765
+ elements.contentDialog.close()
557
766
  })
558
- canvas.addEventListener('wheel', event => {
559
- event.preventDefault()
560
- const rect = canvas.getBoundingClientRect()
561
- const cursorX = event.clientX - rect.left
562
- const cursorY = event.clientY - rect.top
563
- const factor = event.deltaY < 0 ? 1.08 : 0.92
564
- zoomAtPoint(cursorX, cursorY, factor)
565
- }, { passive: false })
566
- canvas.addEventListener('pointerdown', event => {
567
- const point = worldPoint(event)
568
- const node = hitNode(point)
569
- state.pointer = { x: event.clientX, y: event.clientY, down: true, dragNode: node, moved: false }
570
- if (node) {
571
- node.x = point.x
572
- node.y = point.y
767
+
768
+ elements.contentDialog.addEventListener('click', (event) => {
769
+ if (event.target === elements.contentDialog) {
770
+ elements.contentDialog.close()
573
771
  }
574
- canvas.setPointerCapture(event.pointerId)
575
772
  })
576
- canvas.addEventListener('pointermove', event => {
577
- const point = worldPoint(event)
578
- state.hovered = hitNode(point)
579
- if (!state.pointer.down) return
580
- const dx = event.clientX - state.pointer.x
581
- const dy = event.clientY - state.pointer.y
582
- state.pointer.x = event.clientX
583
- state.pointer.y = event.clientY
584
- state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
585
- if (state.pointer.dragNode) {
586
- state.pointer.dragNode.x = point.x
587
- state.pointer.dragNode.y = point.y
773
+
774
+ elements.search.addEventListener('input', () => {
775
+ const token = ++state.searchToken
776
+ const query = (elements.search.value || '').trim()
777
+ if (!query) {
778
+ if (state.renderWorker && state.workerReady) {
779
+ state.renderWorker.postMessage({ type: 'highlight', ids: [] })
780
+ }
588
781
  return
589
782
  }
590
- state.transform.x += dx
591
- state.transform.y += dy
592
- })
593
- canvas.addEventListener('pointerup', event => {
594
- if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
595
- if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
596
- state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
597
- canvas.releasePointerCapture(event.pointerId)
783
+
784
+ fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + agentQuery('&'))
785
+ .then((response) => response.json())
786
+ .then((payload) => {
787
+ if (token !== state.searchToken) {
788
+ return
789
+ }
790
+ const ids = Array.isArray(payload?.nodeIds) ? payload.nodeIds : []
791
+ if (state.renderWorker && state.workerReady) {
792
+ state.renderWorker.postMessage({ type: 'highlight', ids })
793
+ }
794
+ })
795
+ .catch((error) => {
796
+ console.error(error)
797
+ })
598
798
  })
599
799
  }
600
800
 
601
801
  const loadAgents = async () => {
602
802
  const response = await fetch('/api/agents')
603
- const payload = await response.json()
604
- const agents = Array.isArray(payload.agents) ? payload.agents : []
605
- const currentExists = agents.some(agent => agent.id === state.agentId)
606
- const selected = currentExists
607
- ? state.agentId
608
- : (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
609
- const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
610
-
611
- state.agentId = selected
612
- if (signature !== state.agentsSignature) {
613
- elements.agent.innerHTML = agents.length
614
- ? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent.id) + ' · ' + agent.documentCount + '</option>').join('')
615
- : '<option value="shared">shared · 0</option>'
616
- state.agentsSignature = signature
617
- }
618
- elements.agent.value = selected
619
- }
620
-
621
- const loadGraph = async (options = { reset: false }) => {
622
- const response = await fetch('/api/graph-layout' + agentQuery(), {
623
- headers: state.graphSignature
624
- ? {
625
- 'if-none-match': encodeEntityTag(state.graphSignature)
626
- }
627
- : undefined
628
- })
629
-
630
- if (response.status === 304) {
631
- return
803
+ if (!response.ok) {
804
+ throw new Error('Failed to load agents')
632
805
  }
633
806
 
634
807
  const payload = await response.json()
635
- const graph = payload?.layout ?? payload
636
- const signature = payload?.signature ?? graphSignature(graph)
637
- if (!options.reset && signature === state.graphSignature) return
638
- const selectedId = state.selected?.id
639
- const layout = createLayout(graph)
640
- state.graphSignature = signature
641
- state.graph = graph
642
- state.nodes = layout.nodes
643
- state.edges = layout.edges
644
- state.nodeDegrees = state.edges.reduce((degrees, edge) => {
645
- degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
646
- if (edge.target) {
647
- degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
648
- }
649
- return degrees
650
- }, new Map())
651
- state.nodeDetails = new Map()
652
- resetContentFilter()
653
- recomputeVisibility()
654
- scheduleContentFilterSync()
655
- const tags = new Set(graph.nodes.flatMap(node => node.tags))
656
- setGraphStatus(state.agentId + ' · ' + graph.nodes.length + ' notes · ' + graph.edges.length + ' links · live')
657
- elements.nodeCount.textContent = graph.nodes.length
658
- elements.edgeCount.textContent = graph.edges.length
659
- elements.tagCount.textContent = tags.size
660
- resize()
661
- if (options.reset) resetView()
662
- const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
663
- selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
664
- if (!selectedNode && elements.contentDialog.open) {
665
- elements.contentDialog.close()
666
- }
667
- }
808
+ const agents = Array.isArray(payload?.agents) ? payload.agents : []
809
+
810
+ elements.agent.innerHTML = agents
811
+ .map((agent) => {
812
+ const id = String(agent?.id || '')
813
+ const count = Number.isFinite(agent?.documentCount) ? agent.documentCount : 0
814
+ const label = id === 'shared' ? 'shared' : id
815
+ return '<option value="' + escapeHtml(id) + '">' + escapeHtml(label) + ' (' + count + ')</option>'
816
+ })
817
+ .join('')
818
+
819
+ const preferredAgent = initialAgentFromUrl || readStoredAgent()
820
+ const hasPreferred = preferredAgent && agents.some((agent) => agent?.id === preferredAgent)
821
+ state.agentId = hasPreferred ? preferredAgent : String(agents[0]?.id || '')
822
+ elements.agent.value = state.agentId
823
+
824
+ elements.agent.addEventListener('change', () => {
825
+ state.agentId = elements.agent.value || ''
826
+ writeStoredAgent(state.agentId)
827
+ syncAgentInUrl(state.agentId)
828
+ scheduleChunkFetch({ fit: true })
829
+ })
668
830
 
669
- bindEvents()
670
- requestAnimationFrame(() => {
671
- resize()
672
- resetView()
673
- })
831
+ syncAgentInUrl(state.agentId)
832
+ }
674
833
 
675
- const pollIntervalMs = 5000
676
- let tickCounter = 0
834
+ const setupRenderWorker = () => {
835
+ const hasWorker = typeof Worker !== 'undefined'
836
+ const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
677
837
 
678
- const refreshGraphLoop = () => {
679
- if (document.hidden) {
838
+ if (!hasWorker || !canTransfer) {
839
+ state.rendererMode = 'fallback'
840
+ drawFallback()
680
841
  return
681
842
  }
682
843
 
683
- loadGraph().catch(handleGraphRefreshError)
844
+ try {
845
+ const offscreen = canvas.transferControlToOffscreen()
846
+ const worker = new Worker('/render-worker.js')
847
+ state.renderWorker = worker
848
+
849
+ worker.onmessage = (event) => {
850
+ const payload = event.data
851
+ if (!payload || typeof payload !== 'object') {
852
+ return
853
+ }
854
+
855
+ if (payload.type === 'ready') {
856
+ state.workerReady = true
857
+ scheduleChunkFetch({ fit: true })
858
+ return
859
+ }
860
+
861
+ if (payload.type === 'pick-result') {
862
+ if (payload.node && typeof payload.node.id === 'string' && payload.node.id.length > 0) {
863
+ loadNodeDetails(payload.node.id).catch((error) => console.error(error))
864
+ }
865
+ return
866
+ }
867
+
868
+ if (payload.type === 'frame-stats') {
869
+ state.lastVisibleNodes = Number.isFinite(payload.visibleNodes) ? payload.visibleNodes : state.lastVisibleNodes
870
+ state.lastVisibleEdges = Number.isFinite(payload.visibleEdges) ? payload.visibleEdges : state.lastVisibleEdges
871
+ return
872
+ }
873
+
874
+ if (payload.type === 'fatal') {
875
+ console.error(payload.message)
876
+ state.rendererMode = 'fallback'
877
+ state.workerReady = false
878
+ state.renderWorker.terminate()
879
+ state.renderWorker = null
880
+ drawFallback()
881
+ }
882
+ }
684
883
 
685
- tickCounter += 1
686
- if (tickCounter % 3 === 0) {
687
- loadAgents().catch((error) => {
688
- console.error(error)
689
- })
884
+ worker.postMessage({
885
+ type: 'init',
886
+ canvas: offscreen,
887
+ width: state.viewport.width,
888
+ height: state.viewport.height,
889
+ devicePixelRatio: state.viewport.ratio,
890
+ camera: state.camera,
891
+ theme: graphTheme
892
+ }, [offscreen])
893
+ } catch (error) {
894
+ console.error(error)
895
+ state.rendererMode = 'fallback'
896
+ drawFallback()
690
897
  }
691
898
  }
692
899
 
693
- loadAgents()
694
- .then(() => loadGraph({ reset: true }))
695
- .then(() => {
696
- requestAnimationFrame(render)
697
- setInterval(refreshGraphLoop, pollIntervalMs)
900
+ const wireNodeLinkClicks = () => {
901
+ const dialog = elements.contentDialog
902
+ dialog.addEventListener('click', (event) => {
903
+ const target = event.target
904
+ if (!(target instanceof HTMLElement)) {
905
+ return
906
+ }
907
+
908
+ const button = target.closest('button[data-node-id]')
909
+ if (!button) {
910
+ return
911
+ }
912
+
913
+ const id = button.getAttribute('data-node-id') || ''
914
+ if (id) {
915
+ loadNodeDetails(id).catch((error) => console.error(error))
916
+ }
698
917
  })
699
- .catch(error => {
700
- console.error(error)
918
+ }
919
+
920
+ const bootstrap = async () => {
921
+ setViewportFromCanvas()
922
+ setupRenderWorker()
923
+ setupInput()
924
+ setupControls()
925
+ wireNodeLinkClicks()
926
+
927
+ window.addEventListener('resize', () => {
928
+ setViewportFromCanvas()
929
+ scheduleChunkFetch()
701
930
  })
702
931
 
703
- document.addEventListener('visibilitychange', () => {
704
- if (document.hidden) {
705
- return
932
+ await loadAgents()
933
+ updateTotals()
934
+ updateTagCount()
935
+
936
+ if (state.rendererMode === 'fallback') {
937
+ scheduleChunkFetch({ fit: true })
706
938
  }
939
+ }
707
940
 
708
- loadGraph({ reset: true }).catch(handleGraphRefreshError)
941
+ bootstrap().catch((error) => {
942
+ console.error(error)
709
943
  })
710
944
  `;