@andespindola/brainlink 1.0.5 → 1.0.7

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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +46 -0
  3. package/dist/application/add-note.js +2 -2
  4. package/dist/application/build-context.js +16 -10
  5. package/dist/application/canonical-context-links.js +44 -5
  6. package/dist/application/check-package-update.js +105 -0
  7. package/dist/application/find-similar-notes.js +75 -0
  8. package/dist/application/frontend/client/chunk-fetch.js +236 -0
  9. package/dist/application/frontend/client/controls.js +178 -0
  10. package/dist/application/frontend/client/elements.js +122 -0
  11. package/dist/application/frontend/client/input.js +202 -0
  12. package/dist/application/frontend/client/node-details.js +191 -0
  13. package/dist/application/frontend/client/rendering.js +296 -0
  14. package/dist/application/frontend/client/scope-theme.js +114 -0
  15. package/dist/application/frontend/client/spatial.js +98 -0
  16. package/dist/application/frontend/client/storage.js +215 -0
  17. package/dist/application/frontend/client/upload.js +90 -0
  18. package/dist/application/frontend/client/worker-bootstrap.js +147 -0
  19. package/dist/application/frontend/client-js.js +24 -1837
  20. package/dist/application/frontend/client-render-worker-js.js +1 -1
  21. package/dist/application/index-vault-phases.js +190 -0
  22. package/dist/application/index-vault.js +46 -167
  23. package/dist/application/memory-suggestions.js +4 -1
  24. package/dist/application/search-knowledge.js +19 -3
  25. package/dist/cli/commands/write/dedupe-commands.js +59 -0
  26. package/dist/cli/commands/write/index-commands.js +205 -0
  27. package/dist/cli/commands/write/link-commands.js +68 -0
  28. package/dist/cli/commands/write/note-commands.js +146 -0
  29. package/dist/cli/commands/write/server-commands.js +553 -0
  30. package/dist/cli/commands/write/shared.js +35 -0
  31. package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
  32. package/dist/cli/commands/write-commands.js +12 -1303
  33. package/dist/cli/main.js +39 -3
  34. package/dist/domain/context.js +60 -11
  35. package/dist/domain/diversity.js +64 -0
  36. package/dist/domain/embeddings.js +148 -6
  37. package/dist/domain/graph-contexts.js +62 -57
  38. package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
  39. package/dist/domain/graph-layout/collisions.js +100 -0
  40. package/dist/domain/graph-layout/hierarchy.js +135 -0
  41. package/dist/domain/graph-layout/metrics.js +111 -0
  42. package/dist/domain/graph-layout/segments.js +76 -0
  43. package/dist/domain/graph-layout/star-layout.js +110 -0
  44. package/dist/domain/graph-layout.js +4 -625
  45. package/dist/domain/markdown.js +10 -1
  46. package/dist/domain/scoring.js +42 -0
  47. package/dist/domain/tokens.js +17 -1
  48. package/dist/infrastructure/config.js +33 -1
  49. package/dist/infrastructure/file-index.js +227 -60
  50. package/dist/infrastructure/semantic-prefilter.js +24 -0
  51. package/dist/mcp/server.js +23 -1
  52. package/dist/mcp/tool-guard.js +29 -0
  53. package/dist/mcp/tools/maintenance-tools.js +409 -0
  54. package/dist/mcp/tools/read-tools.js +537 -0
  55. package/dist/mcp/tools/shared.js +216 -0
  56. package/dist/mcp/tools/write-tools.js +353 -0
  57. package/dist/mcp/tools.js +3 -1357
  58. package/docs/AGENT_USAGE.md +4 -1
  59. package/docs/ARCHITECTURE.md +4 -3
  60. package/docs/QUICKSTART.md +4 -0
  61. package/package.json +2 -2
@@ -0,0 +1,202 @@
1
+ export const createInputJs = () => `
2
+ const resolvePointer = (event) => {
3
+ const rect = canvas.getBoundingClientRect()
4
+ return {
5
+ x: event.clientX - rect.left,
6
+ y: event.clientY - rect.top
7
+ }
8
+ }
9
+
10
+ const setupInput = () => {
11
+ const dragActivationDistance = 6
12
+ const resetPointerState = (pointerId = null) => {
13
+ state.pointer.down = false
14
+ state.pointer.dragging = false
15
+ state.pointer.dragNodeId = ''
16
+ canvas.classList.remove('is-node-dragging')
17
+ if (pointerId !== null) {
18
+ try {
19
+ if (canvas.hasPointerCapture(pointerId)) {
20
+ canvas.releasePointerCapture(pointerId)
21
+ }
22
+ } catch {}
23
+ }
24
+ }
25
+
26
+ canvas.addEventListener('wheel', (event) => {
27
+ event.preventDefault()
28
+ state.lastWheelAt = performance.now()
29
+ const pointer = resolvePointer(event)
30
+ const exponent = Math.max(-0.05, Math.min(0.05, -event.deltaY * 0.001))
31
+ zoomAtPoint(pointer.x, pointer.y, Math.exp(exponent))
32
+ }, { passive: false })
33
+
34
+ canvas.addEventListener('pointerdown', (event) => {
35
+ event.preventDefault()
36
+ const pointer = resolvePointer(event)
37
+ const candidateNode = pickFallbackNode(pointer.x, pointer.y)
38
+ const candidateNodeId = isRealGraphNode(candidateNode) && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
39
+ const candidateX = Number(candidateNode?.[2])
40
+ const candidateY = Number(candidateNode?.[3])
41
+ const world = screenToWorld(pointer.x, pointer.y)
42
+ state.pointer.down = true
43
+ state.pointer.moved = false
44
+ state.pointer.dragging = false
45
+ state.pointer.dragNodeId = candidateNodeId
46
+ state.pointer.x = pointer.x
47
+ state.pointer.y = pointer.y
48
+ state.pointer.startX = pointer.x
49
+ state.pointer.startY = pointer.y
50
+ state.pointer.startWorldX = world.x
51
+ state.pointer.startWorldY = world.y
52
+ state.pointer.nodeStartX = candidateNodeId && Number.isFinite(candidateX) ? candidateX : 0
53
+ state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
54
+ state.pointer.worldAnchorX = world.x
55
+ state.pointer.worldAnchorY = world.y
56
+ try {
57
+ canvas.setPointerCapture(event.pointerId)
58
+ } catch {}
59
+ })
60
+
61
+ canvas.addEventListener('pointermove', (event) => {
62
+ if (state.pointer.down) {
63
+ event.preventDefault()
64
+ }
65
+ const pointer = resolvePointer(event)
66
+
67
+ if (state.pointer.down) {
68
+ const dx = pointer.x - state.pointer.x
69
+ const dy = pointer.y - state.pointer.y
70
+ const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
71
+ if (distanceFromStart >= dragActivationDistance) {
72
+ state.pointer.moved = true
73
+ state.pointer.dragging = true
74
+ canvas.classList.toggle('is-node-dragging', Boolean(state.pointer.dragNodeId))
75
+ }
76
+ if (!state.pointer.dragging) {
77
+ state.pointer.x = pointer.x
78
+ state.pointer.y = pointer.y
79
+ return
80
+ }
81
+ if (state.pointer.dragNodeId) {
82
+ const world = screenToWorld(pointer.x, pointer.y)
83
+ const x = state.pointer.nodeStartX + world.x - state.pointer.startWorldX
84
+ const y = state.pointer.nodeStartY + world.y - state.pointer.startWorldY
85
+ state.nodePositions.set(state.pointer.dragNodeId, { x, y })
86
+ updateNodePositionInChunk(state.pointer.dragNodeId, x, y)
87
+ state.pointer.x = pointer.x
88
+ state.pointer.y = pointer.y
89
+ drawFallback()
90
+ return
91
+ }
92
+ state.camera.x += dx
93
+ state.camera.y += dy
94
+ state.pointer.x = pointer.x
95
+ state.pointer.y = pointer.y
96
+ updateWorkerCamera()
97
+ drawFallback()
98
+ return
99
+ }
100
+
101
+ const hovered = pickFallbackNode(pointer.x, pointer.y)
102
+ const hoveredId = isRealGraphNode(hovered) && typeof hovered?.[0] === 'string' ? hovered[0] : ''
103
+ if (state.hoveredNodeId !== hoveredId) {
104
+ state.hoveredNodeId = hoveredId
105
+ canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
106
+ updateGraphOverlays()
107
+ }
108
+ if (hoveredId) {
109
+ showTooltip(hovered, pointer)
110
+ } else {
111
+ hideTooltip()
112
+ }
113
+ })
114
+
115
+ canvas.addEventListener('pointerup', (event) => {
116
+ const pointer = resolvePointer(event)
117
+ const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
118
+ const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
119
+ const shouldRefreshAfterDrag = state.pointer.dragging
120
+ const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
121
+ resetPointerState(event.pointerId)
122
+
123
+ if (shouldPick) {
124
+ pickAt(pointer.x, pointer.y)
125
+ return
126
+ }
127
+ if (shouldPersistNodePosition) {
128
+ writeStoredNodePositions()
129
+ persistNodePositionsToServer()
130
+ return
131
+ }
132
+ if (shouldRefreshAfterDrag) {
133
+ scheduleChunkFetch()
134
+ }
135
+ })
136
+
137
+ canvas.addEventListener('pointerleave', () => {
138
+ state.hoveredNodeId = ''
139
+ canvas.classList.remove('is-node-hover')
140
+ hideTooltip()
141
+ updateGraphOverlays()
142
+ })
143
+
144
+ canvas.addEventListener('pointercancel', (event) => {
145
+ resetPointerState(event.pointerId)
146
+ hideTooltip()
147
+ updateGraphOverlays()
148
+ })
149
+
150
+ canvas.addEventListener('lostpointercapture', () => {
151
+ resetPointerState()
152
+ })
153
+
154
+ // Listeners globais (mini mapa e teclado) só podem ser ligados uma vez —
155
+ // setupInput pode ser re-executado ao trocar o canvas no fallback.
156
+ if (!inputGlobalsBound) {
157
+ elements.miniMap.addEventListener('click', (event) => {
158
+ if (!state.miniMapView) {
159
+ return
160
+ }
161
+ const rect = elements.miniMap.getBoundingClientRect()
162
+ const x = event.clientX - rect.left
163
+ const y = event.clientY - rect.top
164
+ const worldX = state.miniMapView.minX + (x - state.miniMapView.offsetX) / state.miniMapView.scale
165
+ const worldY = state.miniMapView.minY + (y - state.miniMapView.offsetY) / state.miniMapView.scale
166
+ state.camera.x = state.viewport.width / 2 - worldX * state.camera.scale
167
+ state.camera.y = state.viewport.height / 2 - worldY * state.camera.scale
168
+ updateWorkerCamera()
169
+ scheduleChunkFetch()
170
+ })
171
+
172
+ window.addEventListener('keydown', (event) => {
173
+ if (event.key === 'Escape' && !elements.uploadDialog.hidden) {
174
+ closeUploadDialog()
175
+ return
176
+ }
177
+ if (event.key === 'Escape' && !elements.contentDialog.hidden) {
178
+ closeContentDialog()
179
+ return
180
+ }
181
+ if (event.key === '+') {
182
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
183
+ return
184
+ }
185
+ if (event.key === '-') {
186
+ zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
187
+ return
188
+ }
189
+ if (event.key === '0') {
190
+ scheduleChunkFetch({ fit: true })
191
+ }
192
+ })
193
+
194
+ inputGlobalsBound = true
195
+ }
196
+
197
+ canvas.addEventListener('dblclick', (event) => {
198
+ const pointer = resolvePointer(event)
199
+ zoomAtPoint(pointer.x, pointer.y, 1.065)
200
+ })
201
+ }
202
+ `;
@@ -0,0 +1,191 @@
1
+ export const createNodeDetailsJs = () => `
2
+ const list = (items) => {
3
+ const rows = normalizeList(items)
4
+ if (rows.length === 0) {
5
+ return '<li><small>No links found.</small></li>'
6
+ }
7
+ return rows
8
+ .map((item) => {
9
+ const title = typeof item?.title === 'string' ? item.title : 'Untitled'
10
+ const id = typeof item?.id === 'string' ? item.id : ''
11
+ const path = typeof item?.path === 'string' ? item.path : ''
12
+ const meta = item?.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : ''
13
+ return '<li>' +
14
+ (id ? '<button type="button" data-node-id="' + escapeHtml(id) + '">' + escapeHtml(title) + '</button>' : escapeHtml(title)) +
15
+ '<small>' + escapeHtml(path) + meta + '</small>' +
16
+ '</li>'
17
+ })
18
+ .join('')
19
+ }
20
+
21
+ const buildFacts = (node, outgoingCount, incomingCount) => {
22
+ const content = typeof node?.content === 'string' ? node.content : ''
23
+ const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
24
+ return [
25
+ { label: 'Agent', value: typeof node?.agentId === 'string' && node.agentId ? node.agentId : 'shared' },
26
+ { label: 'Words', value: String(words) },
27
+ { label: 'Chars', value: String(content.length) },
28
+ { label: 'Outgoing', value: String(outgoingCount) },
29
+ { label: 'Backlinks', value: String(incomingCount) }
30
+ ]
31
+ }
32
+
33
+ const listFacts = (facts) => facts
34
+ .map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
35
+ .join('')
36
+
37
+ const listContextLinks = (links) => {
38
+ if (!Array.isArray(links) || links.length === 0) {
39
+ return '<li><small>No context links found.</small></li>'
40
+ }
41
+ return links
42
+ .map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
43
+ .join('')
44
+ }
45
+
46
+ const nodeContextLinks = (node, outgoing) => {
47
+ const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
48
+ const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
49
+
50
+ return titles
51
+ .map((title) => {
52
+ const match = outgoingByTitle.get(String(title).toLowerCase())
53
+ return {
54
+ title,
55
+ priority: match?.priority || 'normal'
56
+ }
57
+ })
58
+ .filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
59
+ }
60
+
61
+ const linkedNodes = (node) => {
62
+ const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
63
+ const edges = normalizeList(state.chunk.edges)
64
+
65
+ const outgoing = []
66
+ const incoming = []
67
+ for (let index = 0; index < edges.length; index += 1) {
68
+ const edge = edges[index]
69
+ if (edge[0] === node.id) {
70
+ const target = nodeById.get(edge[1])
71
+ if (target) {
72
+ outgoing.push({ id: target[0], title: target[1], path: target[4] || '', weight: edge[2], priority: edge[3] })
73
+ }
74
+ }
75
+ if (edge[1] === node.id) {
76
+ const source = nodeById.get(edge[0])
77
+ if (source) {
78
+ incoming.push({ id: source[0], title: source[1], path: source[4] || '', weight: edge[2], priority: edge[3] })
79
+ }
80
+ }
81
+ }
82
+
83
+ return { outgoing, incoming }
84
+ }
85
+
86
+ const openContentDialog = () => {
87
+ elements.contentDialog.hidden = false
88
+ }
89
+
90
+ const closeContentDialog = () => {
91
+ elements.contentDialog.hidden = true
92
+ }
93
+
94
+ const selectedNode = () => {
95
+ if (!state.selectedNodeId) {
96
+ return null
97
+ }
98
+
99
+ const packed = nodeByIdFromChunk().get(state.selectedNodeId)
100
+ if (!packed) {
101
+ return null
102
+ }
103
+
104
+ return {
105
+ id: packed[0],
106
+ title: packed[1],
107
+ path: packed[4] || ''
108
+ }
109
+ }
110
+
111
+ const copySelectedWikiLink = async () => {
112
+ const node = selectedNode()
113
+ if (!node) {
114
+ elements.contentActionStatus.textContent = 'Select a note first.'
115
+ return
116
+ }
117
+
118
+ const value = '[[' + node.title + ']]'
119
+ try {
120
+ await navigator.clipboard.writeText(value)
121
+ elements.contentActionStatus.textContent = 'Copied ' + value
122
+ } catch {
123
+ elements.contentActionStatus.textContent = value
124
+ }
125
+ }
126
+
127
+ const loadSelectedLinkSuggestions = async () => {
128
+ const content = elements.contentBody.textContent || ''
129
+ if (!content.trim()) {
130
+ elements.contentActionStatus.textContent = 'Selected note has no content.'
131
+ return
132
+ }
133
+
134
+ elements.contentActionStatus.textContent = 'Loading suggestions...'
135
+ const response = await fetch('/api/suggest-links?limit=5&content=' + encodeURIComponent(content.slice(0, 2000)) + scopeQuery('&'))
136
+ if (!response.ok) {
137
+ throw new Error('Failed to load link suggestions')
138
+ }
139
+ const payload = await response.json()
140
+ const suggestions = Array.isArray(payload.suggestions) ? payload.suggestions : []
141
+ elements.contentLinkSuggestions.innerHTML = suggestions.length > 0
142
+ ? suggestions.map((item) => '<li><button type="button" data-title="' + escapeHtml(item.title) + '">[[' + escapeHtml(item.title) + ']]</button></li>').join('')
143
+ : '<li>No strong suggestions</li>'
144
+ elements.contentActionStatus.textContent = suggestions.length > 0 ? 'Suggested Context Links' : 'No strong suggestions found.'
145
+ }
146
+
147
+ const loadNodeDetails = async (nodeId) => {
148
+ if (!nodeId) {
149
+ return
150
+ }
151
+
152
+ const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
153
+ if (!response.ok) {
154
+ throw new Error('Failed to load graph node details')
155
+ }
156
+
157
+ const payload = await response.json()
158
+ if (!payload || typeof payload !== 'object' || !payload.node) {
159
+ throw new Error('Invalid graph node payload')
160
+ }
161
+
162
+ const node = payload.node
163
+ state.selectedNodeId = node.id
164
+ setFocusedNodeIds(linkedNodeIds(node.id))
165
+
166
+ if (state.renderWorker && state.workerReady) {
167
+ state.renderWorker.postMessage({ type: 'select', id: node.id })
168
+ }
169
+
170
+ elements.contentTitle.textContent = node.title || 'Untitled'
171
+ elements.contentPath.textContent = node.path || ''
172
+
173
+ const tags = Array.isArray(node.tags) ? node.tags : []
174
+ elements.contentTags.innerHTML = tags.length > 0
175
+ ? tags.map((tag) => '<span>' + escapeHtml(tag) + '</span>').join('')
176
+ : '<span>No tags</span>'
177
+
178
+ const related = linkedNodes(node)
179
+ const contextLinks = nodeContextLinks(node, related.outgoing)
180
+ const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
181
+ elements.contentFacts.innerHTML = listFacts(facts)
182
+ elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
183
+ elements.contentOutgoing.innerHTML = list(related.outgoing)
184
+ elements.contentIncoming.innerHTML = list(related.incoming)
185
+ elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
186
+ elements.contentActionStatus.textContent = ''
187
+ elements.contentLinkSuggestions.innerHTML = ''
188
+
189
+ openContentDialog()
190
+ }
191
+ `;
@@ -0,0 +1,296 @@
1
+ export const createRenderingJs = () => `
2
+ const drawFallback = () => {
3
+ if (state.rendererMode !== 'fallback') {
4
+ return
5
+ }
6
+ ctx2dFallback = ctx2dFallback ?? canvas.getContext('2d')
7
+ if (!ctx2dFallback) {
8
+ return
9
+ }
10
+ const width = state.viewport.width
11
+ const height = state.viewport.height
12
+ const ratio = state.viewport.ratio
13
+ canvas.width = Math.floor(width * ratio)
14
+ canvas.height = Math.floor(height * ratio)
15
+ ctx2dFallback.setTransform(ratio, 0, 0, ratio, 0, 0)
16
+ ctx2dFallback.fillStyle = '#08131d'
17
+ ctx2dFallback.fillRect(0, 0, width, height)
18
+
19
+ const nodes = Array.isArray(state.chunk.nodes) ? state.chunk.nodes : []
20
+ const edges = Array.isArray(state.chunk.edges) ? state.chunk.edges : []
21
+ const nodeById = new Map()
22
+ for (let i = 0; i < nodes.length; i += 1) {
23
+ nodeById.set(nodes[i][0], nodes[i])
24
+ }
25
+
26
+ ctx2dFallback.strokeStyle = 'rgba(151,181,212,0.18)'
27
+ ctx2dFallback.lineWidth = 1
28
+ for (let i = 0; i < edges.length; i += 1) {
29
+ const edge = edges[i]
30
+ const source = nodeById.get(edge[0])
31
+ const target = nodeById.get(edge[1])
32
+ if (!source || !target) continue
33
+ const from = worldToScreen(source[2], source[3])
34
+ const to = worldToScreen(target[2], target[3])
35
+ ctx2dFallback.beginPath()
36
+ ctx2dFallback.moveTo(from.x, from.y)
37
+ ctx2dFallback.lineTo(to.x, to.y)
38
+ ctx2dFallback.stroke()
39
+ }
40
+
41
+ for (let i = 0; i < nodes.length; i += 1) {
42
+ const node = nodes[i]
43
+ const p = worldToScreen(node[2], node[3])
44
+ const selected = state.selectedNodeId === node[0]
45
+ const color = segmentColor(node[5] || node[4] || node[1])
46
+ const radius = Math.max(3.2, Math.min(16.5, 5 + node[7] * 0.65))
47
+
48
+ ctx2dFallback.beginPath()
49
+ ctx2dFallback.fillStyle = selected ? '#edf4ff' : color
50
+ ctx2dFallback.arc(p.x, p.y, radius, 0, Math.PI * 2)
51
+ ctx2dFallback.fill()
52
+ }
53
+
54
+ ctx2dFallback.fillStyle = '#97a9bd'
55
+ ctx2dFallback.font = '12px Inter, system-ui, sans-serif'
56
+ ctx2dFallback.textAlign = 'center'
57
+ ctx2dFallback.fillText('Fallback canvas mode', Math.max(width, 320) / 2, 24)
58
+ }
59
+
60
+ const updateTotals = () => {
61
+ elements.nodeCount.textContent = String(state.totals.nodes)
62
+ elements.edgeCount.textContent = String(state.totals.edges)
63
+ }
64
+
65
+ const updateWorkerCamera = () => {
66
+ updateGraphOverlays()
67
+ if (!state.renderWorker || !state.workerReady) {
68
+ return
69
+ }
70
+ if (state.cameraSyncScheduled) {
71
+ return
72
+ }
73
+ state.cameraSyncScheduled = true
74
+ requestAnimationFrame(() => {
75
+ state.cameraSyncScheduled = false
76
+ if (!state.renderWorker || !state.workerReady) {
77
+ return
78
+ }
79
+ state.renderWorker.postMessage({
80
+ type: 'camera',
81
+ camera: state.camera
82
+ })
83
+ })
84
+ }
85
+
86
+ const updateWorkerSize = () => {
87
+ updateGraphOverlays()
88
+ if (!state.renderWorker || !state.workerReady) {
89
+ return
90
+ }
91
+ state.renderWorker.postMessage({
92
+ type: 'resize',
93
+ width: state.viewport.width,
94
+ height: state.viewport.height,
95
+ devicePixelRatio: state.viewport.ratio
96
+ })
97
+ }
98
+
99
+ const normalizeList = (items) => Array.isArray(items) ? items : []
100
+
101
+ const applyManualNodePositions = (nodes) => normalizeList(nodes).map((node) => {
102
+ const id = typeof node?.[0] === 'string' ? node[0] : ''
103
+ const position = id ? state.nodePositions.get(id) : null
104
+ if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
105
+ return node
106
+ }
107
+
108
+ const next = [...node]
109
+ next[2] = position.x
110
+ next[3] = position.y
111
+ return next
112
+ })
113
+
114
+ const updateNodePositionInChunk = (nodeId, x, y) => {
115
+ if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) {
116
+ return
117
+ }
118
+
119
+ state.chunk = {
120
+ ...state.chunk,
121
+ nodes: normalizeList(state.chunk.nodes).map((node) => {
122
+ if (node?.[0] !== nodeId) {
123
+ return node
124
+ }
125
+ const next = [...node]
126
+ next[2] = x
127
+ next[3] = y
128
+ return next
129
+ })
130
+ }
131
+ state.spatialIndex.key = ''
132
+
133
+ if (state.renderWorker && state.workerReady) {
134
+ state.renderWorker.postMessage({ type: 'move-node', id: nodeId, x, y })
135
+ }
136
+ updateGraphOverlays()
137
+ }
138
+
139
+ const focusNodeInViewport = (nodeId, nextScale = null) => {
140
+ const node = nodeByIdFromChunk().get(nodeId)
141
+ if (!node) {
142
+ return false
143
+ }
144
+
145
+ const x = Number(node[2])
146
+ const y = Number(node[3])
147
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
148
+ return false
149
+ }
150
+
151
+ if (Number.isFinite(nextScale)) {
152
+ state.camera.scale = clampScale(Number(nextScale))
153
+ }
154
+ state.camera.x = state.viewport.width / 2 - x * state.camera.scale
155
+ state.camera.y = state.viewport.height / 2 - y * state.camera.scale
156
+ updateWorkerCamera()
157
+ scheduleChunkFetch()
158
+ return true
159
+ }
160
+
161
+ const showTooltip = (node, pointer) => {
162
+ if (!elements.tooltip || !node) {
163
+ return
164
+ }
165
+
166
+ elements.tooltip.hidden = false
167
+ elements.tooltip.innerHTML =
168
+ '<strong>' + escapeHtml(node[1] || node[0]) + '</strong>' +
169
+ '<small>' + escapeHtml(node[4] || node[5] || '') + '</small>'
170
+ elements.tooltip.style.left = Math.min(state.viewport.width - 24, pointer.x + 14) + 'px'
171
+ elements.tooltip.style.top = Math.min(state.viewport.height - 24, pointer.y + 14) + 'px'
172
+ }
173
+
174
+ const hideTooltip = () => {
175
+ if (elements.tooltip) {
176
+ elements.tooltip.hidden = true
177
+ }
178
+ }
179
+
180
+ const labelCandidates = () => {
181
+ const nodes = normalizeList(state.chunk.nodes)
182
+ const visible = nodes.filter((node) => {
183
+ const x = Number(node?.[2])
184
+ const y = Number(node?.[3])
185
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return false
186
+ const point = worldToScreen(x, y)
187
+ return point.x >= -80 && point.x <= state.viewport.width + 80 && point.y >= -80 && point.y <= state.viewport.height + 80
188
+ })
189
+ const shouldShowMany = state.camera.scale >= 0.72 || visible.length <= 120
190
+ const focused = state.focusedNodeIds
191
+
192
+ return visible
193
+ .filter((node) => shouldShowMany || focused.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId || Number(node?.[7]) > 5.5)
194
+ .sort((left, right) => {
195
+ const leftFocused = focused.has(left[0]) || left[0] === state.hoveredNodeId || left[0] === state.selectedNodeId ? 1 : 0
196
+ const rightFocused = focused.has(right[0]) || right[0] === state.hoveredNodeId || right[0] === state.selectedNodeId ? 1 : 0
197
+ if (rightFocused !== leftFocused) return rightFocused - leftFocused
198
+ return Number(right?.[7] ?? 0) - Number(left?.[7] ?? 0)
199
+ })
200
+ .slice(0, state.camera.scale >= 0.72 ? 160 : 48)
201
+ }
202
+
203
+ const drawLabels = () => {
204
+ if (!elements.labels) {
205
+ return
206
+ }
207
+
208
+ elements.labels.innerHTML = labelCandidates().map((node) => {
209
+ const point = worldToScreen(Number(node[2]), Number(node[3]))
210
+ const focused = state.focusedNodeIds.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId
211
+ return '<span class="graph-label' + (focused ? ' is-focused' : '') + '" style="left:' +
212
+ point.x.toFixed(1) + 'px;top:' + point.y.toFixed(1) + 'px">' + escapeHtml(node[1] || node[0]) + '</span>'
213
+ }).join('')
214
+ }
215
+
216
+ const drawMiniMap = () => {
217
+ const miniMap = elements.miniMap
218
+ if (!(miniMap instanceof HTMLCanvasElement)) {
219
+ return
220
+ }
221
+ const nodes = normalizeList(state.chunk.nodes)
222
+ const ctx = miniMap.getContext('2d')
223
+ if (!ctx || nodes.length === 0) {
224
+ return
225
+ }
226
+
227
+ const ratio = window.devicePixelRatio || 1
228
+ const width = miniMap.clientWidth || 180
229
+ const height = miniMap.clientHeight || 120
230
+ miniMap.width = Math.floor(width * ratio)
231
+ miniMap.height = Math.floor(height * ratio)
232
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
233
+ ctx.clearRect(0, 0, width, height)
234
+ ctx.fillStyle = 'rgba(8, 19, 29, 0.88)'
235
+ ctx.fillRect(0, 0, width, height)
236
+
237
+ const xs = nodes.map((node) => Number(node[2])).filter(Number.isFinite)
238
+ const ys = nodes.map((node) => Number(node[3])).filter(Number.isFinite)
239
+ const minX = Math.min(...xs)
240
+ const maxX = Math.max(...xs)
241
+ const minY = Math.min(...ys)
242
+ const maxY = Math.max(...ys)
243
+ const graphWidth = Math.max(1, maxX - minX)
244
+ const graphHeight = Math.max(1, maxY - minY)
245
+ const scale = Math.min((width - 18) / graphWidth, (height - 18) / graphHeight)
246
+ const offsetX = (width - graphWidth * scale) / 2
247
+ const offsetY = (height - graphHeight * scale) / 2
248
+ const toMini = (x, y) => ({
249
+ x: offsetX + (x - minX) * scale,
250
+ y: offsetY + (y - minY) * scale
251
+ })
252
+ state.miniMapView = { minX, minY, scale, offsetX, offsetY, width, height }
253
+
254
+ ctx.fillStyle = 'rgba(90, 168, 255, 0.62)'
255
+ nodes.forEach((node) => {
256
+ const point = toMini(Number(node[2]), Number(node[3]))
257
+ ctx.fillRect(point.x - 1, point.y - 1, 2, 2)
258
+ })
259
+
260
+ const worldTopLeft = screenToWorld(0, 0)
261
+ const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
262
+ const topLeft = toMini(Math.min(worldTopLeft.x, worldBottomRight.x), Math.min(worldTopLeft.y, worldBottomRight.y))
263
+ const bottomRight = toMini(Math.max(worldTopLeft.x, worldBottomRight.x), Math.max(worldTopLeft.y, worldBottomRight.y))
264
+ ctx.strokeStyle = 'rgba(90, 168, 255, 0.86)'
265
+ ctx.lineWidth = 1
266
+ ctx.strokeRect(topLeft.x, topLeft.y, Math.max(3, bottomRight.x - topLeft.x), Math.max(3, bottomRight.y - topLeft.y))
267
+ }
268
+
269
+ const shouldDeferGraphOverlays = () => state.pointer.down || performance.now() - state.lastWheelAt < 150
270
+
271
+ const updateGraphOverlays = () => {
272
+ if (state.overlayScheduled) {
273
+ return
274
+ }
275
+ state.overlayScheduled = true
276
+ requestAnimationFrame(() => {
277
+ state.overlayScheduled = false
278
+ if (shouldDeferGraphOverlays()) {
279
+ elements.labels?.classList.add('is-stale')
280
+ if (!state.overlayIdleTimer) {
281
+ state.overlayIdleTimer = setTimeout(() => {
282
+ state.overlayIdleTimer = null
283
+ updateGraphOverlays()
284
+ }, 170)
285
+ }
286
+ return
287
+ }
288
+ elements.labels?.classList.remove('is-stale')
289
+ drawLabels()
290
+ if (state.miniMapDirty) {
291
+ drawMiniMap()
292
+ state.miniMapDirty = false
293
+ }
294
+ })
295
+ }
296
+ `;