@andespindola/brainlink 0.1.0-beta.6 → 0.1.0-beta.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +58 -2
- package/CONTRIBUTING.md +2 -2
- package/COPYRIGHT.md +5 -0
- package/README.md +266 -20
- package/SECURITY.md +1 -1
- package/dist/application/add-note.js +62 -13
- package/dist/application/analyze-vault.js +95 -8
- package/dist/application/build-context.js +56 -1
- package/dist/application/dedupe-notes.js +226 -0
- package/dist/application/frontend/client-css.js +214 -100
- package/dist/application/frontend/client-html.js +60 -45
- package/dist/application/frontend/client-js.js +1853 -139
- package/dist/application/frontend/client-worker-js.js +66 -0
- package/dist/application/get-graph-layout.js +18 -6
- package/dist/application/get-graph-node.js +12 -0
- package/dist/application/get-graph-summary.js +12 -0
- package/dist/application/get-graph.js +3 -3
- package/dist/application/import-legacy-sqlite.js +296 -0
- package/dist/application/index-vault.js +252 -19
- package/dist/application/list-agents.js +3 -3
- package/dist/application/list-links.js +5 -5
- package/dist/application/migrate-vault.js +91 -0
- package/dist/application/offline-pack-backup.js +44 -0
- package/dist/application/search-graph-node-ids.js +12 -0
- package/dist/application/search-knowledge.js +75 -5
- package/dist/application/server/routes.js +102 -1
- package/dist/application/start-server.js +75 -4
- package/dist/application/watch-vault.js +23 -2
- package/dist/benchmarks/large-vault.js +1 -1
- package/dist/cli/commands/agent-commands.js +419 -0
- package/dist/cli/commands/config-commands.js +167 -0
- package/dist/cli/commands/read-commands.js +25 -8
- package/dist/cli/commands/write-commands.js +989 -10
- package/dist/cli/main.js +4 -0
- package/dist/cli/runtime.js +5 -2
- package/dist/domain/context.js +53 -11
- package/dist/domain/embeddings.js +2 -1
- package/dist/domain/graph-layout.js +62 -15
- package/dist/domain/markdown.js +36 -4
- package/dist/domain/middle-out.js +18 -0
- package/dist/infrastructure/config.js +132 -8
- package/dist/infrastructure/file-index.js +358 -0
- package/dist/infrastructure/file-system-vault.js +30 -0
- package/dist/infrastructure/index-state.js +56 -0
- package/dist/infrastructure/paths.js +9 -1
- package/dist/infrastructure/private-pack-codec.js +134 -0
- package/dist/infrastructure/search-packs.js +452 -0
- package/dist/infrastructure/session-state.js +172 -0
- package/dist/mcp/main.js +11 -3
- package/dist/mcp/server.js +27 -2
- package/dist/mcp/startup.js +35 -0
- package/dist/mcp/tools.js +633 -19
- package/docs/AGENT_USAGE.md +178 -16
- package/docs/ARCHITECTURE.md +37 -26
- package/docs/QUICKSTART.md +111 -0
- package/package.json +6 -4
- package/dist/infrastructure/sqlite/document-writer.js +0 -51
- package/dist/infrastructure/sqlite/graph-reader.js +0 -120
- package/dist/infrastructure/sqlite/schema.js +0 -111
- package/dist/infrastructure/sqlite/search-reader.js +0 -156
- package/dist/infrastructure/sqlite/types.js +0 -1
- package/dist/infrastructure/sqlite-index.js +0 -25
|
@@ -1,19 +1,68 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
2
|
const ctx = canvas.getContext('2d')
|
|
3
|
+
const largeGraphNodeThreshold = 4000
|
|
4
|
+
const massiveGraphNodeThreshold = 20000
|
|
5
|
+
const largeGraphEdgeRenderLimit = 120000
|
|
6
|
+
const renderNodeBudget = 900
|
|
7
|
+
const renderEdgeBudget = 2400
|
|
8
|
+
const clusterActivationNodeThreshold = 600
|
|
9
|
+
const clusterZoomThreshold = 0.18
|
|
10
|
+
const macroGalaxyZoomThreshold = 0.012
|
|
11
|
+
const massiveAutoFitMacroScale = 0.006
|
|
12
|
+
const defaultMacroScale = 0.006
|
|
13
|
+
const clusterCellPixelSize = 64
|
|
14
|
+
const minNodePixelRadius = 2.3
|
|
15
|
+
const viewportPaddingPx = 280
|
|
16
|
+
const worldCoordinateLimit = 5_000_000
|
|
17
|
+
const transformCoordinateLimit = 20_000_000
|
|
18
|
+
const hoverHitTestIntervalMs = 64
|
|
19
|
+
const overviewClusterMaxCount = 1400
|
|
20
|
+
const zoomRecoveryGuardMs = 1500
|
|
21
|
+
const zoomCapTargetViewportShare = 0.72
|
|
22
|
+
const meshEdgeScaleThreshold = 0.09
|
|
23
|
+
const meshEdgeMinBudget = 140
|
|
24
|
+
const meshEdgeMaxBudget = 1400
|
|
25
|
+
const layeredCoreScaleThreshold = 0.55
|
|
3
26
|
const state = {
|
|
4
27
|
graph: { nodes: [], edges: [] },
|
|
5
28
|
nodes: [],
|
|
29
|
+
nodeById: new Map(),
|
|
6
30
|
edges: [],
|
|
31
|
+
visibleNodes: [],
|
|
32
|
+
visibleEdges: [],
|
|
33
|
+
renderNodes: [],
|
|
34
|
+
renderEdges: [],
|
|
35
|
+
renderClusters: [],
|
|
36
|
+
nodeDegrees: new Map(),
|
|
7
37
|
selected: null,
|
|
8
38
|
hovered: null,
|
|
9
39
|
query: '',
|
|
40
|
+
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
10
41
|
agentId: '',
|
|
11
42
|
agentsSignature: '',
|
|
43
|
+
nodeDetails: new Map(),
|
|
12
44
|
transform: { x: 0, y: 0, scale: 1 },
|
|
13
45
|
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
46
|
+
cursor: { x: 0, y: 0, inCanvas: false },
|
|
14
47
|
graphSignature: '',
|
|
15
48
|
graphStatus: '',
|
|
16
|
-
|
|
49
|
+
graphTotals: { nodes: 0, edges: 0 },
|
|
50
|
+
last: performance.now(),
|
|
51
|
+
offscreenFrameCount: 0,
|
|
52
|
+
recoveringViewport: false,
|
|
53
|
+
renderVisibilityDirty: true,
|
|
54
|
+
lastViewportKey: '',
|
|
55
|
+
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
56
|
+
visibleEdgeByNode: new Map(),
|
|
57
|
+
overviewClusters: [],
|
|
58
|
+
macroCenter: { x: 0, y: 0 },
|
|
59
|
+
macroRepresentative: null,
|
|
60
|
+
primaryHub: null,
|
|
61
|
+
hubNeighborDistance: Number.POSITIVE_INFINITY,
|
|
62
|
+
filterWorker: null,
|
|
63
|
+
filterReady: false,
|
|
64
|
+
lastHoverHitAt: 0,
|
|
65
|
+
lastManualZoomAt: 0
|
|
17
66
|
}
|
|
18
67
|
|
|
19
68
|
const byId = id => document.getElementById(id)
|
|
@@ -24,97 +73,1119 @@ const escapeHtml = value => String(value)
|
|
|
24
73
|
.replaceAll('"', '"')
|
|
25
74
|
.replaceAll("'", ''')
|
|
26
75
|
const elements = {
|
|
27
|
-
stats: byId('stats'),
|
|
28
76
|
search: byId('search'),
|
|
29
77
|
agent: byId('agent'),
|
|
30
|
-
title: byId('title'),
|
|
31
|
-
path: byId('path'),
|
|
32
|
-
tags: byId('tags'),
|
|
33
|
-
notes: byId('notes'),
|
|
34
|
-
content: byId('content'),
|
|
35
|
-
outgoing: byId('outgoing'),
|
|
36
|
-
incoming: byId('incoming'),
|
|
37
78
|
nodeCount: byId('nodeCount'),
|
|
38
79
|
edgeCount: byId('edgeCount'),
|
|
39
80
|
tagCount: byId('tagCount'),
|
|
40
81
|
zoomIn: byId('zoomIn'),
|
|
41
82
|
zoomOut: byId('zoomOut'),
|
|
42
|
-
|
|
83
|
+
fit: byId('fit'),
|
|
84
|
+
reset: byId('reset'),
|
|
85
|
+
contentDialog: byId('contentDialog'),
|
|
86
|
+
contentTitle: byId('contentTitle'),
|
|
87
|
+
contentPath: byId('contentPath'),
|
|
88
|
+
contentTags: byId('contentTags'),
|
|
89
|
+
contentOutgoing: byId('contentOutgoing'),
|
|
90
|
+
contentIncoming: byId('contentIncoming'),
|
|
91
|
+
contentBody: byId('contentBody'),
|
|
92
|
+
contentClose: byId('contentClose')
|
|
43
93
|
}
|
|
44
94
|
|
|
45
|
-
const
|
|
95
|
+
const zoomRange = {
|
|
96
|
+
min: 0.0002,
|
|
97
|
+
max: 4.5
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const initialAgentFromUrl = (() => {
|
|
101
|
+
try {
|
|
102
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
103
|
+
const value = raw?.trim() ?? ''
|
|
104
|
+
return value.length > 0 ? value : ''
|
|
105
|
+
} catch {
|
|
106
|
+
return ''
|
|
107
|
+
}
|
|
108
|
+
})()
|
|
109
|
+
|
|
110
|
+
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
111
|
+
|
|
112
|
+
const readStoredAgent = () => {
|
|
113
|
+
try {
|
|
114
|
+
const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
|
|
115
|
+
return value.length > 0 ? value : ''
|
|
116
|
+
} catch {
|
|
117
|
+
return ''
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const writeStoredAgent = (agentId) => {
|
|
122
|
+
try {
|
|
123
|
+
if (!agentId) {
|
|
124
|
+
window.localStorage.removeItem(selectedAgentStorageKey)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
window.localStorage.setItem(selectedAgentStorageKey, agentId)
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const syncAgentInUrl = (agentId) => {
|
|
132
|
+
try {
|
|
133
|
+
const url = new URL(window.location.href)
|
|
134
|
+
if (agentId && agentId.trim().length > 0) {
|
|
135
|
+
url.searchParams.set('agent', agentId)
|
|
136
|
+
} else {
|
|
137
|
+
url.searchParams.delete('agent')
|
|
138
|
+
}
|
|
139
|
+
window.history.replaceState({}, '', url.toString())
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
46
144
|
|
|
47
145
|
const setGraphStatus = text => {
|
|
48
146
|
state.graphStatus = text
|
|
49
|
-
elements.stats.textContent = text
|
|
50
147
|
}
|
|
51
148
|
|
|
52
|
-
const handleGraphRefreshError = error => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
149
|
+
const handleGraphRefreshError = error => {
|
|
150
|
+
console.error(error)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const graphTheme = {
|
|
154
|
+
node: '#aeb8c5',
|
|
155
|
+
nodeSelected: '#f3f7fb',
|
|
156
|
+
nodeHover: '#cbd5e1',
|
|
157
|
+
nodeHalo: 'rgba(203, 213, 225, 0.14)',
|
|
158
|
+
nodeHaloActive: 'rgba(243, 247, 251, 0.2)',
|
|
159
|
+
nodeStroke: '#0d0f12',
|
|
160
|
+
nodeStrokeActive: '#ffffff',
|
|
161
|
+
edge: 'rgba(153, 165, 181, 0.16)',
|
|
162
|
+
edgeActive: 'rgba(226, 232, 240, 0.52)',
|
|
163
|
+
label: '#edf2f7'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const initFilterWorker = () => {
|
|
167
|
+
if (typeof Worker === 'undefined') {
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const worker = new Worker('/app-worker.js')
|
|
172
|
+
worker.onmessage = event => {
|
|
173
|
+
const payload = event.data
|
|
174
|
+
if (!payload || typeof payload !== 'object') return
|
|
175
|
+
|
|
176
|
+
if (payload.type === 'ready') {
|
|
177
|
+
state.filterReady = true
|
|
178
|
+
if (state.nodes.length > 0) {
|
|
179
|
+
worker.postMessage({
|
|
180
|
+
type: 'load-nodes',
|
|
181
|
+
nodes: state.nodes.map(node => ({
|
|
182
|
+
id: node.id,
|
|
183
|
+
title: node.title,
|
|
184
|
+
path: node.path || '',
|
|
185
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
186
|
+
}))
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (payload.type === 'filter-result') {
|
|
193
|
+
const token = payload.token
|
|
194
|
+
if (token !== state.contentFilter.token) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
|
|
199
|
+
state.contentFilter.query = normalizeQuery(state.query)
|
|
200
|
+
state.contentFilter.ids = new Set(ids)
|
|
201
|
+
recomputeVisibility()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
state.filterWorker = worker
|
|
205
|
+
} catch {
|
|
206
|
+
state.filterWorker = null
|
|
207
|
+
state.filterReady = false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const pushNodesToFilterWorker = () => {
|
|
212
|
+
if (!state.filterWorker || !state.filterReady) {
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
state.filterWorker.postMessage({
|
|
217
|
+
type: 'load-nodes',
|
|
218
|
+
nodes: state.nodes.map(node => ({
|
|
219
|
+
id: node.id,
|
|
220
|
+
title: node.title,
|
|
221
|
+
path: node.path || '',
|
|
222
|
+
tags: Array.isArray(node.tags) ? node.tags : []
|
|
223
|
+
}))
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const resize = () => {
|
|
228
|
+
const rect = canvas.getBoundingClientRect()
|
|
229
|
+
const width = Math.max(rect.width, 320)
|
|
230
|
+
const height = Math.max(rect.height, 320)
|
|
231
|
+
const ratio = window.devicePixelRatio || 1
|
|
232
|
+
canvas.width = Math.floor(width * ratio)
|
|
233
|
+
canvas.height = Math.floor(height * ratio)
|
|
234
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
235
|
+
markRenderDirty()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const normalizeQuery = value => value.trim().toLowerCase()
|
|
239
|
+
const hubNodeRetentionLimit = 2
|
|
240
|
+
const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
|
|
241
|
+
const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
|
|
242
|
+
|
|
243
|
+
const hubNodeScore = node => {
|
|
244
|
+
const title = node.title.trim().toLowerCase()
|
|
245
|
+
if (title === 'memory hub') return 6
|
|
246
|
+
if (title === 'knowledge hub') return 5
|
|
247
|
+
if (memoryHubPathPattern.test(node.path || '')) return 4
|
|
248
|
+
if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
|
|
249
|
+
if (/\bmoc\b/i.test(node.title)) return 2
|
|
250
|
+
return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
|
|
251
|
+
? 1
|
|
252
|
+
: 0
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const localFilteredNodes = query =>
|
|
256
|
+
state.nodes.filter(node =>
|
|
257
|
+
node.title.toLowerCase().includes(query) ||
|
|
258
|
+
(node.path || '').toLowerCase().includes(query) ||
|
|
259
|
+
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const rankedHubNodes = () => {
|
|
263
|
+
if (state.nodes.length === 0) {
|
|
264
|
+
return []
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const byTitleAndDegree = [...state.nodes]
|
|
268
|
+
.filter(node => hubNodeScore(node) > 0)
|
|
269
|
+
.sort((left, right) => {
|
|
270
|
+
const byHubScore = hubNodeScore(right) - hubNodeScore(left)
|
|
271
|
+
if (byHubScore !== 0) return byHubScore
|
|
272
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
273
|
+
if (byDegree !== 0) return byDegree
|
|
274
|
+
return left.title.localeCompare(right.title)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
if (byTitleAndDegree.length > 0) {
|
|
278
|
+
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return [...state.nodes]
|
|
282
|
+
.sort((left, right) => {
|
|
283
|
+
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
284
|
+
if (byDegree !== 0) return byDegree
|
|
285
|
+
return left.title.localeCompare(right.title)
|
|
286
|
+
})
|
|
287
|
+
.slice(0, 1)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const withPersistentHubNodes = nodes => {
|
|
291
|
+
if (nodes.length === 0) {
|
|
292
|
+
return rankedHubNodes()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
296
|
+
const hubsToKeep = rankedHubNodes().filter(node => !ids.has(node.id))
|
|
297
|
+
return nodes.concat(hubsToKeep)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const filteredNodes = () => {
|
|
301
|
+
const query = normalizeQuery(state.query)
|
|
302
|
+
if (!query) return state.nodes
|
|
303
|
+
if (state.contentFilter.query === query && state.contentFilter.ids instanceof Set) {
|
|
304
|
+
const matched = state.nodes.filter(node => state.contentFilter.ids.has(node.id))
|
|
305
|
+
return withPersistentHubNodes(matched)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return withPersistentHubNodes(localFilteredNodes(query))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const resolveMacroRepresentative = (nodes) => {
|
|
312
|
+
if (nodes.length === 0) {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const hubCandidate = state.primaryHub && nodes.some(node => node.id === state.primaryHub.id)
|
|
317
|
+
? state.primaryHub
|
|
318
|
+
: null
|
|
319
|
+
let best = hubCandidate ?? nodes[0]
|
|
320
|
+
let bestDegree = state.nodeDegrees.get(best.id) ?? 0
|
|
321
|
+
|
|
322
|
+
for (let index = 1; index < nodes.length; index += 1) {
|
|
323
|
+
const node = nodes[index]
|
|
324
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
325
|
+
if (degree > bestDegree) {
|
|
326
|
+
best = node
|
|
327
|
+
bestDegree = degree
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return best
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const nearestHubNeighborDistance = (hub, nodes) => {
|
|
335
|
+
if (!hub || nodes.length <= 1) {
|
|
336
|
+
return Number.POSITIVE_INFINITY
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let minimum = Number.POSITIVE_INFINITY
|
|
340
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
341
|
+
const node = nodes[index]
|
|
342
|
+
if (node.id === hub.id) continue
|
|
343
|
+
const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
|
|
344
|
+
if (distance < minimum) {
|
|
345
|
+
minimum = distance
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return minimum
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const recomputeVisibility = () => {
|
|
353
|
+
const nodes = filteredNodes()
|
|
354
|
+
const ids = new Set(nodes.map(node => node.id))
|
|
355
|
+
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
356
|
+
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
357
|
+
? [...edges]
|
|
358
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
359
|
+
.slice(0, largeGraphEdgeRenderLimit)
|
|
360
|
+
: edges
|
|
361
|
+
|
|
362
|
+
state.visibleNodes = nodes
|
|
363
|
+
state.visibleEdges = limitedEdges
|
|
364
|
+
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
365
|
+
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
366
|
+
state.overviewClusters = nodes.length > massiveGraphNodeThreshold ? buildOverviewClusters(nodes) : []
|
|
367
|
+
const primaryHub = rankedHubNodes()[0] ?? null
|
|
368
|
+
state.primaryHub = primaryHub
|
|
369
|
+
state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
|
|
370
|
+
const bounds = graphBounds(nodes)
|
|
371
|
+
state.macroCenter = bounds
|
|
372
|
+
? {
|
|
373
|
+
x: primaryHub ? primaryHub.x : (bounds.minX + bounds.maxX) / 2,
|
|
374
|
+
y: primaryHub ? primaryHub.y : (bounds.minY + bounds.maxY) / 2
|
|
375
|
+
}
|
|
376
|
+
: { x: 0, y: 0 }
|
|
377
|
+
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
378
|
+
markRenderDirty()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const edgeWeight = edge => Number.isFinite(edge.weight) ? Math.max(1, edge.weight) : 1
|
|
382
|
+
const markRenderDirty = () => {
|
|
383
|
+
state.renderVisibilityDirty = true
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const createSpatialIndex = nodes => {
|
|
387
|
+
if (nodes.length === 0) {
|
|
388
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const bounds = graphBounds(nodes)
|
|
392
|
+
if (!bounds) {
|
|
393
|
+
return { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const targetNodesPerCell = 18
|
|
397
|
+
const approximateCellArea = Math.max((bounds.width * bounds.height) / Math.max(nodes.length / targetNodesPerCell, 1), 1)
|
|
398
|
+
const cellSize = Math.max(90, Math.min(2200, Math.sqrt(approximateCellArea)))
|
|
399
|
+
const buckets = new Map()
|
|
400
|
+
|
|
401
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
402
|
+
const node = nodes[index]
|
|
403
|
+
const cellX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
404
|
+
const cellY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
405
|
+
const key = cellX + ':' + cellY
|
|
406
|
+
const bucket = buckets.get(key)
|
|
407
|
+
if (bucket) {
|
|
408
|
+
bucket.push(node)
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
buckets.set(key, [node])
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
cellSize,
|
|
416
|
+
minX: bounds.minX,
|
|
417
|
+
minY: bounds.minY,
|
|
418
|
+
maxX: bounds.maxX,
|
|
419
|
+
maxY: bounds.maxY,
|
|
420
|
+
buckets
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const viewportNodesFromSpatialIndex = viewport => {
|
|
425
|
+
if (state.visibleNodes.length <= 2500) {
|
|
426
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const spatial = state.visibleNodeSpatial
|
|
430
|
+
if (!spatial || spatial.buckets.size === 0) {
|
|
431
|
+
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
435
|
+
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
436
|
+
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
437
|
+
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
438
|
+
const nodes = []
|
|
439
|
+
|
|
440
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
441
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
442
|
+
const bucket = spatial.buckets.get(cellX + ':' + cellY)
|
|
443
|
+
if (!bucket) continue
|
|
444
|
+
|
|
445
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
446
|
+
const node = bucket[index]
|
|
447
|
+
if (isNodeInViewport(node, viewport)) {
|
|
448
|
+
nodes.push(node)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return nodes
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const createVisibleEdgeLookup = edges => {
|
|
458
|
+
const lookup = new Map()
|
|
459
|
+
|
|
460
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
461
|
+
const edge = edges[index]
|
|
462
|
+
if (!edge.target) continue
|
|
463
|
+
|
|
464
|
+
const sourceList = lookup.get(edge.source)
|
|
465
|
+
if (sourceList) {
|
|
466
|
+
sourceList.push(edge)
|
|
467
|
+
} else {
|
|
468
|
+
lookup.set(edge.source, [edge])
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const targetList = lookup.get(edge.target)
|
|
472
|
+
if (targetList) {
|
|
473
|
+
targetList.push(edge)
|
|
474
|
+
} else {
|
|
475
|
+
lookup.set(edge.target, [edge])
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return lookup
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const buildOverviewClusters = nodes => {
|
|
483
|
+
if (nodes.length === 0) {
|
|
484
|
+
return []
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const bounds = graphBounds(nodes)
|
|
488
|
+
if (!bounds) {
|
|
489
|
+
return []
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const longest = Math.max(bounds.width, bounds.height, 1)
|
|
493
|
+
const cellSize = Math.max(longest / 56, 900)
|
|
494
|
+
const buckets = new Map()
|
|
495
|
+
|
|
496
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
497
|
+
const node = nodes[index]
|
|
498
|
+
const keyX = Math.floor((node.x - bounds.minX) / cellSize)
|
|
499
|
+
const keyY = Math.floor((node.y - bounds.minY) / cellSize)
|
|
500
|
+
const key = keyX + ':' + keyY
|
|
501
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
502
|
+
const current = buckets.get(key)
|
|
503
|
+
if (current) {
|
|
504
|
+
current.count += 1
|
|
505
|
+
current.sumX += node.x
|
|
506
|
+
current.sumY += node.y
|
|
507
|
+
if (degree > current.degree) {
|
|
508
|
+
current.representative = node
|
|
509
|
+
current.degree = degree
|
|
510
|
+
}
|
|
511
|
+
continue
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
buckets.set(key, {
|
|
515
|
+
id: key,
|
|
516
|
+
count: 1,
|
|
517
|
+
sumX: node.x,
|
|
518
|
+
sumY: node.y,
|
|
519
|
+
representative: node,
|
|
520
|
+
degree
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return Array.from(buckets.values())
|
|
525
|
+
.sort((left, right) => right.count - left.count)
|
|
526
|
+
.slice(0, overviewClusterMaxCount)
|
|
527
|
+
.map((cluster) => ({
|
|
528
|
+
id: cluster.id,
|
|
529
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
530
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
531
|
+
count: cluster.count,
|
|
532
|
+
representative: cluster.representative
|
|
533
|
+
}))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const filterOverviewClustersByViewport = viewport =>
|
|
537
|
+
state.overviewClusters.filter((cluster) =>
|
|
538
|
+
cluster.x >= viewport.minX &&
|
|
539
|
+
cluster.x <= viewport.maxX &&
|
|
540
|
+
cluster.y >= viewport.minY &&
|
|
541
|
+
cluster.y <= viewport.maxY
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
const edgeBudgetForCurrentFrame = () => {
|
|
545
|
+
const zoom = state.transform.scale
|
|
546
|
+
if (zoom < 0.12) return 380
|
|
547
|
+
if (zoom < 0.18) return 900
|
|
548
|
+
if (zoom < 0.28) return 1700
|
|
549
|
+
if (zoom < 0.45) return 2800
|
|
550
|
+
if (zoom < 0.7) return 4200
|
|
551
|
+
if (zoom < 1.05) return 5600
|
|
552
|
+
return 7600
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const clusterBudgetForScale = (scale) => {
|
|
556
|
+
if (scale < 0.008) return 90
|
|
557
|
+
if (scale < 0.014) return 150
|
|
558
|
+
if (scale < 0.022) return 240
|
|
559
|
+
if (scale < 0.035) return 360
|
|
560
|
+
return 520
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const nodeBudgetForScale = (scale) => {
|
|
564
|
+
if (scale < 0.035) return 220
|
|
565
|
+
if (scale < 0.06) return 360
|
|
566
|
+
if (scale < 0.09) return 520
|
|
567
|
+
if (scale < 0.14) return 720
|
|
568
|
+
return renderNodeBudget
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const layerWindowForScale = (scale) => {
|
|
572
|
+
if (scale < 0.08) return { inner: 0.78, outer: 1 }
|
|
573
|
+
if (scale < 0.14) return { inner: 0.62, outer: 0.9 }
|
|
574
|
+
if (scale < 0.24) return { inner: 0.46, outer: 0.74 }
|
|
575
|
+
if (scale < 0.36) return { inner: 0.3, outer: 0.58 }
|
|
576
|
+
if (scale < layeredCoreScaleThreshold) return { inner: 0.16, outer: 0.42 }
|
|
577
|
+
if (scale < 0.9) return { inner: 0.06, outer: 0.26 }
|
|
578
|
+
return { inner: 0, outer: 0.14 }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const selectLayeredNodesForScale = (sourceNodes) => {
|
|
582
|
+
const hub = state.primaryHub
|
|
583
|
+
if (!hub || sourceNodes.length <= 1200 || state.visibleNodes.length <= massiveGraphNodeThreshold) {
|
|
584
|
+
return sourceNodes
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let maxDistance = 0
|
|
588
|
+
const distances = sourceNodes.map((node) => {
|
|
589
|
+
const distance = Math.hypot(node.x - hub.x, node.y - hub.y)
|
|
590
|
+
if (distance > maxDistance) {
|
|
591
|
+
maxDistance = distance
|
|
592
|
+
}
|
|
593
|
+
return { node, distance }
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
if (maxDistance <= 0.001) {
|
|
597
|
+
return sourceNodes
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const window = layerWindowForScale(state.transform.scale)
|
|
601
|
+
const inner = window.inner * maxDistance
|
|
602
|
+
const outer = window.outer * maxDistance
|
|
603
|
+
const layered = distances
|
|
604
|
+
.filter((item) => item.distance >= inner && item.distance <= outer)
|
|
605
|
+
.map((item) => item.node)
|
|
606
|
+
|
|
607
|
+
if (state.transform.scale >= layeredCoreScaleThreshold && !layered.some((node) => node.id === hub.id)) {
|
|
608
|
+
layered.push(hub)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (layered.length > 0) {
|
|
612
|
+
return layered
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const midpoint = (window.inner + window.outer) / 2
|
|
616
|
+
const fallback = [...distances]
|
|
617
|
+
.sort((left, right) => {
|
|
618
|
+
const leftNorm = left.distance / maxDistance
|
|
619
|
+
const rightNorm = right.distance / maxDistance
|
|
620
|
+
const leftDelta = Math.abs(leftNorm - midpoint)
|
|
621
|
+
const rightDelta = Math.abs(rightNorm - midpoint)
|
|
622
|
+
if (leftDelta !== rightDelta) return leftDelta - rightDelta
|
|
623
|
+
return left.node.id.localeCompare(right.node.id)
|
|
624
|
+
})
|
|
625
|
+
.slice(0, Math.min(900, sourceNodes.length))
|
|
626
|
+
.map((item) => item.node)
|
|
627
|
+
|
|
628
|
+
if (state.transform.scale >= layeredCoreScaleThreshold && !fallback.some((node) => node.id === hub.id)) {
|
|
629
|
+
fallback.push(hub)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return fallback
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const edgeIdentityKey = edge => {
|
|
636
|
+
if (!edge.target) return ''
|
|
637
|
+
const pair = edge.source < edge.target
|
|
638
|
+
? edge.source + '|' + edge.target
|
|
639
|
+
: edge.target + '|' + edge.source
|
|
640
|
+
return pair + '|' + (edge.inferred ? 'mesh' : 'real')
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const edgeRelevanceScore = edge => {
|
|
644
|
+
let score = edgeWeight(edge) * 10
|
|
645
|
+
if (!edge.inferred) {
|
|
646
|
+
score += 8
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const selectedId = state.selected?.id
|
|
650
|
+
if (selectedId && (edge.source === selectedId || edge.target === selectedId)) {
|
|
651
|
+
score += 120
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const hoveredId = state.hovered?.id
|
|
655
|
+
if (hoveredId && (edge.source === hoveredId || edge.target === hoveredId)) {
|
|
656
|
+
score += 70
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const hubId = state.primaryHub?.id
|
|
660
|
+
if (hubId && (edge.source === hubId || edge.target === hubId)) {
|
|
661
|
+
score += 42
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return score
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const collectVisibleEdgesForNodes = nodeIds => {
|
|
668
|
+
if (nodeIds.size === 0) {
|
|
669
|
+
return []
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const seen = new Set()
|
|
673
|
+
const candidates = []
|
|
674
|
+
const limit = edgeBudgetForCurrentFrame()
|
|
675
|
+
|
|
676
|
+
nodeIds.forEach(nodeId => {
|
|
677
|
+
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
678
|
+
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
679
|
+
const edge = candidateEdges[index]
|
|
680
|
+
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
681
|
+
continue
|
|
682
|
+
}
|
|
683
|
+
const key = edgeIdentityKey(edge)
|
|
684
|
+
if (seen.has(key)) continue
|
|
685
|
+
|
|
686
|
+
seen.add(key)
|
|
687
|
+
candidates.push(edge)
|
|
688
|
+
}
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
if (candidates.length <= limit) {
|
|
692
|
+
return candidates
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return candidates
|
|
696
|
+
.sort((left, right) => {
|
|
697
|
+
const scoreDelta = edgeRelevanceScore(right) - edgeRelevanceScore(left)
|
|
698
|
+
if (scoreDelta !== 0) {
|
|
699
|
+
return scoreDelta
|
|
700
|
+
}
|
|
701
|
+
const leftKey = edgeIdentityKey(left)
|
|
702
|
+
const rightKey = edgeIdentityKey(right)
|
|
703
|
+
return leftKey.localeCompare(rightKey)
|
|
704
|
+
})
|
|
705
|
+
.slice(0, limit)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const edgeOpacityForScale = (edge, scale) => {
|
|
709
|
+
if (edge.inferred) {
|
|
710
|
+
if (scale < 0.2) return 0.06
|
|
711
|
+
if (scale < 0.4) return 0.08
|
|
712
|
+
if (scale < 0.7) return 0.1
|
|
713
|
+
return 0.14
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (scale < 0.2) return 0.14
|
|
717
|
+
if (scale < 0.4) return 0.2
|
|
718
|
+
if (scale < 0.7) return 0.28
|
|
719
|
+
if (scale < 1.05) return 0.36
|
|
720
|
+
return 0.46
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const edgeStrokeFor = (edge, selectedEdge) => {
|
|
724
|
+
if (selectedEdge) {
|
|
725
|
+
return graphTheme.edgeActive
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const opacity = edgeOpacityForScale(edge, state.transform.scale)
|
|
729
|
+
return edge.inferred
|
|
730
|
+
? 'rgba(203, 213, 225, ' + opacity + ')'
|
|
731
|
+
: 'rgba(153, 165, 181, ' + opacity + ')'
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const edgeWidthFor = (edge, selectedEdge) => {
|
|
735
|
+
if (edge.inferred) {
|
|
736
|
+
return selectedEdge ? 1.22 : 0.84
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const drawGraphEdge = (edge) => {
|
|
743
|
+
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
744
|
+
ctx.beginPath()
|
|
745
|
+
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
746
|
+
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
747
|
+
ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
|
|
748
|
+
ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
|
|
749
|
+
ctx.stroke()
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const drawGraphEdges = () => {
|
|
753
|
+
const selectedEdges = []
|
|
754
|
+
const regularEdges = []
|
|
755
|
+
for (let index = 0; index < state.renderEdges.length; index += 1) {
|
|
756
|
+
const edge = state.renderEdges[index]
|
|
757
|
+
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
758
|
+
if (isSelected) {
|
|
759
|
+
selectedEdges.push(edge)
|
|
760
|
+
} else {
|
|
761
|
+
regularEdges.push(edge)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
for (let index = 0; index < regularEdges.length; index += 1) {
|
|
766
|
+
drawGraphEdge(regularEdges[index])
|
|
767
|
+
}
|
|
768
|
+
for (let index = 0; index < selectedEdges.length; index += 1) {
|
|
769
|
+
drawGraphEdge(selectedEdges[index])
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const edgePairKey = (source, target) =>
|
|
774
|
+
source < target ? source + '|' + target : target + '|' + source
|
|
775
|
+
|
|
776
|
+
const meshNeighborBuckets = (nodes, cellSize) => {
|
|
777
|
+
const buckets = new Map()
|
|
778
|
+
|
|
779
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
780
|
+
const node = nodes[index]
|
|
781
|
+
const cellX = Math.floor(node.x / cellSize)
|
|
782
|
+
const cellY = Math.floor(node.y / cellSize)
|
|
783
|
+
const key = cellX + ':' + cellY
|
|
784
|
+
const bucket = buckets.get(key)
|
|
785
|
+
if (bucket) {
|
|
786
|
+
bucket.push(node)
|
|
787
|
+
} else {
|
|
788
|
+
buckets.set(key, [node])
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return buckets
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const meshCandidatesForNode = (node, buckets, cellSize) => {
|
|
796
|
+
const cellX = Math.floor(node.x / cellSize)
|
|
797
|
+
const cellY = Math.floor(node.y / cellSize)
|
|
798
|
+
const candidates = []
|
|
799
|
+
|
|
800
|
+
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
801
|
+
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
802
|
+
const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
|
|
803
|
+
if (!bucket) continue
|
|
804
|
+
for (let index = 0; index < bucket.length; index += 1) {
|
|
805
|
+
const candidate = bucket[index]
|
|
806
|
+
if (candidate.id !== node.id) {
|
|
807
|
+
candidates.push(candidate)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return candidates
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const buildMeshEdgesForNodes = (nodes, existingEdges) => {
|
|
817
|
+
if (nodes.length < 2 || state.transform.scale < meshEdgeScaleThreshold) {
|
|
818
|
+
return []
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const existingKeys = new Set()
|
|
822
|
+
for (let index = 0; index < existingEdges.length; index += 1) {
|
|
823
|
+
const edge = existingEdges[index]
|
|
824
|
+
if (edge.target) {
|
|
825
|
+
existingKeys.add(edgePairKey(edge.source, edge.target))
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const desiredBudget = Math.min(
|
|
830
|
+
meshEdgeMaxBudget,
|
|
831
|
+
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
|
|
832
|
+
)
|
|
833
|
+
const perNodeNeighborCount =
|
|
834
|
+
state.transform.scale >= 1.05 ? 4
|
|
835
|
+
: state.transform.scale >= 0.62 ? 3
|
|
836
|
+
: 2
|
|
837
|
+
const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
|
|
838
|
+
const maxDistance = 980
|
|
839
|
+
const maxDistanceSquared = maxDistance * maxDistance
|
|
840
|
+
const buckets = meshNeighborBuckets(nodes, cellSize)
|
|
841
|
+
const meshEdges = []
|
|
842
|
+
const meshKeys = new Set()
|
|
843
|
+
|
|
844
|
+
for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
|
|
845
|
+
const node = nodes[index]
|
|
846
|
+
const candidates = meshCandidatesForNode(node, buckets, cellSize)
|
|
847
|
+
.map((candidate) => ({
|
|
848
|
+
node: candidate,
|
|
849
|
+
distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
|
|
850
|
+
}))
|
|
851
|
+
.filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
|
|
852
|
+
.sort((left, right) => left.distanceSquared - right.distanceSquared)
|
|
853
|
+
|
|
854
|
+
let linked = 0
|
|
855
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
|
|
856
|
+
const candidate = candidates[candidateIndex].node
|
|
857
|
+
const key = edgePairKey(node.id, candidate.id)
|
|
858
|
+
if (existingKeys.has(key) || meshKeys.has(key)) {
|
|
859
|
+
continue
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
meshKeys.add(key)
|
|
863
|
+
meshEdges.push({
|
|
864
|
+
source: node.id,
|
|
865
|
+
target: candidate.id,
|
|
866
|
+
targetTitle: candidate.title,
|
|
867
|
+
weight: 1,
|
|
868
|
+
priority: 'normal',
|
|
869
|
+
sourceNode: node,
|
|
870
|
+
targetNode: candidate,
|
|
871
|
+
inferred: true
|
|
872
|
+
})
|
|
873
|
+
linked += 1
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return meshEdges
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const withMeshEdges = (nodes, edges) => {
|
|
881
|
+
if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
|
|
882
|
+
return edges
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const meshEdges = buildMeshEdgesForNodes(nodes, edges)
|
|
886
|
+
return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const fallbackViewportNodes = () => {
|
|
890
|
+
const nodes = []
|
|
891
|
+
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
892
|
+
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
893
|
+
|
|
894
|
+
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
895
|
+
nodes.push(state.visibleNodes[index])
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
899
|
+
nodes.push(state.selected)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return nodes
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
|
|
906
|
+
if (sourceNodes.length === 0 || limit <= 0) {
|
|
907
|
+
return []
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const nodes = []
|
|
911
|
+
const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
|
|
912
|
+
const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
|
|
913
|
+
|
|
914
|
+
for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
|
|
915
|
+
nodes.push(sourceNodes[index])
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
919
|
+
nodes.push(state.selected)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return nodes
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const enrichSampleWithNeighbors = (nodes) => {
|
|
926
|
+
if (nodes.length === 0) {
|
|
927
|
+
return {
|
|
928
|
+
nodes,
|
|
929
|
+
edges: []
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const maxNodes = Math.min(renderNodeBudget, nodes.length + 200)
|
|
934
|
+
const expanded = [...nodes]
|
|
935
|
+
const ids = new Set(expanded.map((node) => node.id))
|
|
936
|
+
|
|
937
|
+
for (let index = 0; index < nodes.length && expanded.length < maxNodes; index += 1) {
|
|
938
|
+
const node = nodes[index]
|
|
939
|
+
const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
|
|
940
|
+
.filter((edge) => edge.target)
|
|
941
|
+
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
942
|
+
.slice(0, 3)
|
|
943
|
+
|
|
944
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
|
|
945
|
+
const edge = candidates[candidateIndex]
|
|
946
|
+
const otherId = edge.source === node.id ? edge.target : edge.source
|
|
947
|
+
|
|
948
|
+
if (!otherId || ids.has(otherId)) {
|
|
949
|
+
continue
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const otherNode = state.nodeById.get(otherId)
|
|
953
|
+
if (!otherNode) {
|
|
954
|
+
continue
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
ids.add(otherId)
|
|
958
|
+
expanded.push(otherNode)
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const edges = collectVisibleEdgesForNodes(ids)
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
nodes: expanded,
|
|
966
|
+
edges
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const ensureHubNodesInRenderedSet = (nodes) => {
|
|
971
|
+
if (nodes.length === 0) {
|
|
972
|
+
return nodes
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
|
|
976
|
+
const ids = new Set(nodes.map((node) => node.id))
|
|
977
|
+
const hubs = rankedHubNodes()
|
|
978
|
+
const merged = [...nodes]
|
|
979
|
+
|
|
980
|
+
for (let index = 0; index < hubs.length; index += 1) {
|
|
981
|
+
const hub = hubs[index]
|
|
982
|
+
if (ids.has(hub.id)) {
|
|
983
|
+
continue
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (merged.length < maxNodes) {
|
|
987
|
+
merged.push(hub)
|
|
988
|
+
ids.add(hub.id)
|
|
989
|
+
continue
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
|
|
993
|
+
if (replacementIndex >= 0) {
|
|
994
|
+
ids.delete(merged[replacementIndex].id)
|
|
995
|
+
merged[replacementIndex] = hub
|
|
996
|
+
ids.add(hub.id)
|
|
997
|
+
}
|
|
57
998
|
}
|
|
58
999
|
|
|
59
|
-
|
|
60
|
-
console.error(error)
|
|
1000
|
+
return merged
|
|
61
1001
|
}
|
|
62
1002
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
nodeStroke: '#0d0f12',
|
|
70
|
-
nodeStrokeActive: '#ffffff',
|
|
71
|
-
edge: 'rgba(153, 165, 181, 0.16)',
|
|
72
|
-
edgeActive: 'rgba(226, 232, 240, 0.52)',
|
|
73
|
-
label: '#edf2f7'
|
|
1003
|
+
const zoomCapByNodeCount = (nodeCount) => {
|
|
1004
|
+
if (nodeCount > 50000) return 2.6
|
|
1005
|
+
if (nodeCount > 20000) return 2.35
|
|
1006
|
+
if (nodeCount > 6000) return 2.1
|
|
1007
|
+
if (nodeCount > 2000) return 2.2
|
|
1008
|
+
return zoomRange.max
|
|
74
1009
|
}
|
|
75
1010
|
|
|
76
|
-
const
|
|
1011
|
+
const zoomCapByHubDistance = (distance) => {
|
|
1012
|
+
if (!Number.isFinite(distance) || distance <= 0) {
|
|
1013
|
+
return zoomRange.max
|
|
1014
|
+
}
|
|
1015
|
+
|
|
77
1016
|
const rect = canvas.getBoundingClientRect()
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
canvas.height = Math.floor(height * ratio)
|
|
83
|
-
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
1017
|
+
const viewportWidth = Math.max(rect.width, 320)
|
|
1018
|
+
const viewportHeight = Math.max(rect.height, 320)
|
|
1019
|
+
const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
|
|
1020
|
+
return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
|
|
84
1021
|
}
|
|
85
1022
|
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
1023
|
+
const currentZoomMax = () => {
|
|
1024
|
+
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
1025
|
+
const hubDistanceCap = zoomCapByHubDistance(state.hubNeighborDistance)
|
|
1026
|
+
const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
|
|
1027
|
+
const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
|
|
1028
|
+
return Math.max(zoomRange.min * 2, capped)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
|
|
1032
|
+
const isFiniteNumber = value => Number.isFinite(value)
|
|
1033
|
+
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
1034
|
+
const clampTransformCoordinate = value => {
|
|
1035
|
+
if (!isFiniteNumber(value)) return 0
|
|
1036
|
+
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
1037
|
+
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
1038
|
+
return value
|
|
94
1039
|
}
|
|
95
1040
|
|
|
96
|
-
const
|
|
1041
|
+
const graphBounds = nodes => {
|
|
1042
|
+
if (nodes.length === 0) return null
|
|
1043
|
+
let minX = Number.POSITIVE_INFINITY
|
|
1044
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
1045
|
+
let minY = Number.POSITIVE_INFINITY
|
|
1046
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
1047
|
+
|
|
1048
|
+
nodes.forEach(node => {
|
|
1049
|
+
const radius = baseNodeRadius(node)
|
|
1050
|
+
minX = Math.min(minX, node.x - radius)
|
|
1051
|
+
maxX = Math.max(maxX, node.x + radius)
|
|
1052
|
+
minY = Math.min(minY, node.y - radius)
|
|
1053
|
+
maxY = Math.max(maxY, node.y + radius)
|
|
1054
|
+
})
|
|
97
1055
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
1056
|
+
return {
|
|
1057
|
+
minX,
|
|
1058
|
+
maxX,
|
|
1059
|
+
minY,
|
|
1060
|
+
maxY,
|
|
1061
|
+
width: Math.max(maxX - minX, 1),
|
|
1062
|
+
height: Math.max(maxY - minY, 1)
|
|
1063
|
+
}
|
|
101
1064
|
}
|
|
102
1065
|
|
|
103
|
-
const
|
|
1066
|
+
const fitScaleBiasByNodeCount = nodeCount => {
|
|
1067
|
+
if (nodeCount <= 6) return 1.22
|
|
1068
|
+
if (nodeCount <= 20) return 1.12
|
|
1069
|
+
if (nodeCount <= 60) return 1.04
|
|
1070
|
+
if (nodeCount <= 180) return 1
|
|
1071
|
+
if (nodeCount <= 600) return 0.94
|
|
1072
|
+
if (nodeCount <= 2000) return 0.82
|
|
1073
|
+
if (nodeCount <= 6000) return 0.68
|
|
1074
|
+
return 0.56
|
|
1075
|
+
}
|
|
104
1076
|
|
|
105
|
-
const
|
|
1077
|
+
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
1078
|
+
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
1079
|
+
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
1080
|
+
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
1081
|
+
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
1082
|
+
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
1083
|
+
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
1084
|
+
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
1085
|
+
return { min: 0.0008, max: 0.24 }
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
|
|
106
1089
|
const rect = canvas.getBoundingClientRect()
|
|
107
|
-
|
|
1090
|
+
const width = Math.max(rect.width, 320)
|
|
1091
|
+
const height = Math.max(rect.height, 320)
|
|
1092
|
+
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
1093
|
+
const bounds = graphBounds(nodes)
|
|
1094
|
+
|
|
1095
|
+
if (!bounds) {
|
|
1096
|
+
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
1097
|
+
state.offscreenFrameCount = 0
|
|
1098
|
+
state.recoveringViewport = false
|
|
1099
|
+
markRenderDirty()
|
|
1100
|
+
return
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const paddingByNodeCount = nodeCount => {
|
|
1104
|
+
if (nodeCount <= 6) return 28
|
|
1105
|
+
if (nodeCount <= 20) return 44
|
|
1106
|
+
if (nodeCount <= 60) return 68
|
|
1107
|
+
if (nodeCount <= 180) return 86
|
|
1108
|
+
if (nodeCount <= 600) return 110
|
|
1109
|
+
if (nodeCount <= 2000) return 140
|
|
1110
|
+
return 180
|
|
1111
|
+
}
|
|
1112
|
+
const padding = paddingByNodeCount(nodes.length)
|
|
1113
|
+
const scaleX = width / (bounds.width + padding * 2)
|
|
1114
|
+
const scaleY = height / (bounds.height + padding * 2)
|
|
1115
|
+
const fitScale = Math.min(scaleX, scaleY)
|
|
1116
|
+
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
1117
|
+
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
1118
|
+
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
1119
|
+
const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
|
|
1120
|
+
const scale = options.macro && nodes.length > 1
|
|
1121
|
+
? clampScale(Math.min(baselineScale, macroScale))
|
|
1122
|
+
: nodes.length > massiveGraphNodeThreshold
|
|
1123
|
+
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
1124
|
+
: baselineScale
|
|
1125
|
+
const hubCenter =
|
|
1126
|
+
options.preferHubCenter && state.primaryHub && nodes.some((node) => node.id === state.primaryHub.id)
|
|
1127
|
+
? state.primaryHub
|
|
1128
|
+
: null
|
|
1129
|
+
const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
|
|
1130
|
+
const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
|
|
1131
|
+
|
|
1132
|
+
state.transform = {
|
|
1133
|
+
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
1134
|
+
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
1135
|
+
scale: clampScale(scale)
|
|
1136
|
+
}
|
|
1137
|
+
state.offscreenFrameCount = 0
|
|
1138
|
+
state.recoveringViewport = false
|
|
1139
|
+
markRenderDirty()
|
|
108
1140
|
}
|
|
109
1141
|
|
|
1142
|
+
const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
|
|
1143
|
+
|
|
110
1144
|
const createLayout = graph => {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
1145
|
+
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
1146
|
+
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
1147
|
+
const nodes = nodeRows.map(node => {
|
|
1148
|
+
if (Array.isArray(node)) {
|
|
1149
|
+
const [id, title, x, y, group, segment] = node
|
|
1150
|
+
return {
|
|
1151
|
+
id: typeof id === 'string' ? id : '',
|
|
1152
|
+
title: typeof title === 'string' ? title : 'Untitled',
|
|
1153
|
+
path: '',
|
|
1154
|
+
tags: [],
|
|
1155
|
+
group: typeof group === 'string' ? group : 'root',
|
|
1156
|
+
segment: typeof segment === 'string' ? segment : 'root',
|
|
1157
|
+
x: Number.isFinite(x) ? x : 0,
|
|
1158
|
+
y: Number.isFinite(y) ? y : 0,
|
|
1159
|
+
vx: 0,
|
|
1160
|
+
vy: 0
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return {
|
|
1165
|
+
...node,
|
|
1166
|
+
path: typeof node.path === 'string' ? node.path : '',
|
|
1167
|
+
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
1168
|
+
x: Number.isFinite(node.x) ? node.x : 0,
|
|
1169
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
1170
|
+
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
1171
|
+
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
1172
|
+
}
|
|
1173
|
+
})
|
|
116
1174
|
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
117
|
-
const edges =
|
|
1175
|
+
const edges = edgeRows
|
|
1176
|
+
.map(edge => {
|
|
1177
|
+
if (Array.isArray(edge)) {
|
|
1178
|
+
const [source, target, weight, priority] = edge
|
|
1179
|
+
return {
|
|
1180
|
+
source: typeof source === 'string' ? source : '',
|
|
1181
|
+
target: typeof target === 'string' ? target : null,
|
|
1182
|
+
targetTitle: '',
|
|
1183
|
+
weight: Number.isFinite(weight) ? weight : 1,
|
|
1184
|
+
priority: typeof priority === 'string' ? priority : 'normal'
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return edge
|
|
1188
|
+
})
|
|
118
1189
|
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
119
1190
|
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
120
1191
|
return { nodes, edges }
|
|
@@ -128,29 +1199,107 @@ const encodeEntityTag = (value) => {
|
|
|
128
1199
|
binary += String.fromCharCode(utf8[index])
|
|
129
1200
|
}
|
|
130
1201
|
|
|
131
|
-
return btoa(binary).
|
|
1202
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
132
1203
|
}
|
|
133
1204
|
|
|
134
1205
|
const graphSignature = graph => JSON.stringify({
|
|
135
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.
|
|
1206
|
+
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
136
1207
|
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
137
1208
|
})
|
|
138
1209
|
|
|
1210
|
+
const resetContentFilter = () => {
|
|
1211
|
+
if (state.contentFilter.timer) {
|
|
1212
|
+
clearTimeout(state.contentFilter.timer)
|
|
1213
|
+
}
|
|
1214
|
+
state.contentFilter = {
|
|
1215
|
+
query: '',
|
|
1216
|
+
ids: null,
|
|
1217
|
+
token: state.contentFilter.token + 1,
|
|
1218
|
+
timer: null
|
|
1219
|
+
}
|
|
1220
|
+
recomputeVisibility()
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const syncContentFilter = async (query, token) => {
|
|
1224
|
+
const response = await fetch(
|
|
1225
|
+
'/api/graph-filter?q=' +
|
|
1226
|
+
encodeURIComponent(query) +
|
|
1227
|
+
'&limit=' +
|
|
1228
|
+
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
1229
|
+
agentQuery('&')
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
if (!response.ok || token !== state.contentFilter.token) {
|
|
1233
|
+
return
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const payload = await response.json()
|
|
1237
|
+
const nodeIds = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter(id => typeof id === 'string') : []
|
|
1238
|
+
if (token !== state.contentFilter.token) {
|
|
1239
|
+
return
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
state.contentFilter.query = query
|
|
1243
|
+
const merged = new Set([...(state.contentFilter.ids instanceof Set ? state.contentFilter.ids : []), ...nodeIds])
|
|
1244
|
+
state.contentFilter.ids = merged
|
|
1245
|
+
recomputeVisibility()
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const scheduleContentFilterSync = () => {
|
|
1249
|
+
const query = normalizeQuery(state.query)
|
|
1250
|
+
if (!query) {
|
|
1251
|
+
resetContentFilter()
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (state.contentFilter.timer) {
|
|
1256
|
+
clearTimeout(state.contentFilter.timer)
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const token = state.contentFilter.token + 1
|
|
1260
|
+
state.contentFilter = {
|
|
1261
|
+
query: state.contentFilter.query,
|
|
1262
|
+
ids: state.contentFilter.ids,
|
|
1263
|
+
token,
|
|
1264
|
+
timer: setTimeout(() => {
|
|
1265
|
+
if (state.filterWorker && state.filterReady) {
|
|
1266
|
+
state.filterWorker.postMessage({
|
|
1267
|
+
type: 'filter',
|
|
1268
|
+
query,
|
|
1269
|
+
token,
|
|
1270
|
+
limit: Math.max(state.nodes.length, 1)
|
|
1271
|
+
})
|
|
1272
|
+
}
|
|
1273
|
+
syncContentFilter(query, token).catch(() => {})
|
|
1274
|
+
}, 180)
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
139
1278
|
const tick = delta => {
|
|
140
|
-
const nodes =
|
|
141
|
-
const
|
|
142
|
-
const
|
|
1279
|
+
const nodes = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
1280
|
+
const edges = state.renderEdges.length > 0 ? state.renderEdges : state.visibleEdges
|
|
1281
|
+
const shouldRunPhysics =
|
|
1282
|
+
state.nodes.length <= 8000 &&
|
|
1283
|
+
nodes.length <= 320 &&
|
|
1284
|
+
state.transform.scale >= 0.08
|
|
1285
|
+
if (!shouldRunPhysics) {
|
|
1286
|
+
return
|
|
1287
|
+
}
|
|
143
1288
|
const strength = Math.min(delta / 16, 2)
|
|
144
1289
|
|
|
145
1290
|
edges.forEach(edge => {
|
|
146
1291
|
const source = edge.sourceNode
|
|
147
1292
|
const target = edge.targetNode
|
|
1293
|
+
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
1294
|
+
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
1295
|
+
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
1296
|
+
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
148
1297
|
const dx = target.x - source.x
|
|
149
1298
|
const dy = target.y - source.y
|
|
150
1299
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
151
1300
|
const force = (distance - 150) * 0.002 * strength
|
|
152
|
-
const fx = dx * force
|
|
153
|
-
const fy = dy * force
|
|
1301
|
+
const fx = (dx / distance) * force
|
|
1302
|
+
const fy = (dy / distance) * force
|
|
154
1303
|
source.vx += fx
|
|
155
1304
|
source.vy += fy
|
|
156
1305
|
target.vx -= fx
|
|
@@ -161,6 +1310,10 @@ const tick = delta => {
|
|
|
161
1310
|
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
162
1311
|
const a = nodes[i]
|
|
163
1312
|
const b = nodes[j]
|
|
1313
|
+
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
1314
|
+
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
1315
|
+
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
1316
|
+
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
164
1317
|
const dx = b.x - a.x
|
|
165
1318
|
const dy = b.y - a.y
|
|
166
1319
|
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
@@ -175,6 +1328,10 @@ const tick = delta => {
|
|
|
175
1328
|
}
|
|
176
1329
|
|
|
177
1330
|
nodes.forEach(node => {
|
|
1331
|
+
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
1332
|
+
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
1333
|
+
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
1334
|
+
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
178
1335
|
if (state.pointer.dragNode === node) {
|
|
179
1336
|
node.vx = 0
|
|
180
1337
|
node.vy = 0
|
|
@@ -198,7 +1355,20 @@ const worldPoint = event => {
|
|
|
198
1355
|
}
|
|
199
1356
|
|
|
200
1357
|
const hitNode = point => {
|
|
201
|
-
|
|
1358
|
+
computeRenderVisibility()
|
|
1359
|
+
if (state.renderClusters.length > 0) {
|
|
1360
|
+
return null
|
|
1361
|
+
}
|
|
1362
|
+
const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
|
|
1363
|
+
? 0.2
|
|
1364
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1365
|
+
? 0.34
|
|
1366
|
+
: 0
|
|
1367
|
+
if (state.transform.scale < hitScaleFloor) {
|
|
1368
|
+
return null
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const nodes = state.renderNodes
|
|
202
1372
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
203
1373
|
const node = nodes[index]
|
|
204
1374
|
const radius = nodeRadius(node)
|
|
@@ -207,17 +1377,304 @@ const hitNode = point => {
|
|
|
207
1377
|
return null
|
|
208
1378
|
}
|
|
209
1379
|
|
|
210
|
-
const
|
|
211
|
-
const degree = state.
|
|
1380
|
+
const baseNodeRadius = node => {
|
|
1381
|
+
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
212
1382
|
return 9 + Math.min(degree, 8) * 1.6
|
|
213
1383
|
}
|
|
214
1384
|
|
|
1385
|
+
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
1386
|
+
|
|
1387
|
+
const worldViewportBounds = () => {
|
|
1388
|
+
const rect = canvas.getBoundingClientRect()
|
|
1389
|
+
const width = Math.max(rect.width, 320)
|
|
1390
|
+
const height = Math.max(rect.height, 320)
|
|
1391
|
+
const padding = viewportPaddingPx
|
|
1392
|
+
|
|
1393
|
+
return {
|
|
1394
|
+
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
1395
|
+
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
1396
|
+
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
1397
|
+
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const isNodeInViewport = (node, viewport) =>
|
|
1402
|
+
node.x >= viewport.minX &&
|
|
1403
|
+
node.x <= viewport.maxX &&
|
|
1404
|
+
node.y >= viewport.minY &&
|
|
1405
|
+
node.y <= viewport.maxY
|
|
1406
|
+
|
|
1407
|
+
const viewportNodeStride = () => {
|
|
1408
|
+
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
1409
|
+
return 1
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
if (state.transform.scale >= 0.95) {
|
|
1413
|
+
return 1
|
|
1414
|
+
}
|
|
1415
|
+
if (state.transform.scale >= 0.7) {
|
|
1416
|
+
return 2
|
|
1417
|
+
}
|
|
1418
|
+
if (state.transform.scale >= 0.48) {
|
|
1419
|
+
return 3
|
|
1420
|
+
}
|
|
1421
|
+
if (state.transform.scale >= 0.28) {
|
|
1422
|
+
return 5
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
return 8
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const shouldRenderClusters = viewportNodes =>
|
|
1429
|
+
state.transform.scale <= clusterZoomThreshold && viewportNodes.length >= clusterActivationNodeThreshold
|
|
1430
|
+
|
|
1431
|
+
const clusterViewportNodes = viewportNodes => {
|
|
1432
|
+
if (!shouldRenderClusters(viewportNodes)) {
|
|
1433
|
+
return []
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
1437
|
+
const buckets = new Map()
|
|
1438
|
+
|
|
1439
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
1440
|
+
const node = viewportNodes[index]
|
|
1441
|
+
const keyX = Math.floor(node.x / worldCellSize)
|
|
1442
|
+
const keyY = Math.floor(node.y / worldCellSize)
|
|
1443
|
+
const key = keyX + ':' + keyY
|
|
1444
|
+
const current = buckets.get(key)
|
|
1445
|
+
if (current) {
|
|
1446
|
+
current.count += 1
|
|
1447
|
+
current.sumX += node.x
|
|
1448
|
+
current.sumY += node.y
|
|
1449
|
+
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
1450
|
+
current.representative = node
|
|
1451
|
+
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
1452
|
+
}
|
|
1453
|
+
continue
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
buckets.set(key, {
|
|
1457
|
+
id: key,
|
|
1458
|
+
count: 1,
|
|
1459
|
+
sumX: node.x,
|
|
1460
|
+
sumY: node.y,
|
|
1461
|
+
representative: node,
|
|
1462
|
+
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
1463
|
+
})
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
return Array.from(buckets.values())
|
|
1467
|
+
.sort((left, right) => right.count - left.count)
|
|
1468
|
+
.slice(0, Math.min(renderNodeBudget, 900))
|
|
1469
|
+
.map((cluster) => ({
|
|
1470
|
+
id: cluster.id,
|
|
1471
|
+
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
1472
|
+
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
1473
|
+
count: cluster.count,
|
|
1474
|
+
representative: cluster.representative
|
|
1475
|
+
}))
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const computeRenderVisibility = () => {
|
|
1479
|
+
if (!hasValidTransform()) {
|
|
1480
|
+
fitView({ useFiltered: true })
|
|
1481
|
+
}
|
|
1482
|
+
const viewport = worldViewportBounds()
|
|
1483
|
+
const viewportKey =
|
|
1484
|
+
Math.round(viewport.minX * 10) + ':' +
|
|
1485
|
+
Math.round(viewport.maxX * 10) + ':' +
|
|
1486
|
+
Math.round(viewport.minY * 10) + ':' +
|
|
1487
|
+
Math.round(viewport.maxY * 10) + ':' +
|
|
1488
|
+
Math.round(state.transform.scale * 1000)
|
|
1489
|
+
|
|
1490
|
+
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
1491
|
+
return
|
|
1492
|
+
}
|
|
1493
|
+
state.lastViewportKey = viewportKey
|
|
1494
|
+
state.renderVisibilityDirty = false
|
|
1495
|
+
|
|
1496
|
+
const shouldRenderMacroGalaxy =
|
|
1497
|
+
state.transform.scale <= macroGalaxyZoomThreshold && state.visibleNodes.length > 1
|
|
1498
|
+
|
|
1499
|
+
if (shouldRenderMacroGalaxy) {
|
|
1500
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1501
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
1502
|
+
const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
|
|
1503
|
+
if (representative) {
|
|
1504
|
+
state.renderClusters = [
|
|
1505
|
+
{
|
|
1506
|
+
id: 'macro-galaxy',
|
|
1507
|
+
x: state.macroCenter.x,
|
|
1508
|
+
y: state.macroCenter.y,
|
|
1509
|
+
count: sourceNodes.length,
|
|
1510
|
+
representative
|
|
1511
|
+
}
|
|
1512
|
+
]
|
|
1513
|
+
state.renderNodes = [representative]
|
|
1514
|
+
} else {
|
|
1515
|
+
state.renderClusters = []
|
|
1516
|
+
state.renderNodes = []
|
|
1517
|
+
}
|
|
1518
|
+
state.renderEdges = []
|
|
1519
|
+
return
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (state.visibleNodes.length <= 2000) {
|
|
1523
|
+
state.renderNodes = state.visibleNodes
|
|
1524
|
+
state.renderClusters = []
|
|
1525
|
+
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
1526
|
+
state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
|
|
1527
|
+
return
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
1531
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1532
|
+
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
1533
|
+
const layeredNodes = selectLayeredNodesForScale(sourceNodes)
|
|
1534
|
+
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
1535
|
+
const sampled = layeredNodes.length > sampleLimit
|
|
1536
|
+
? sampleVisibleNodes(Math.min(sampleLimit, renderNodeBudget), layeredNodes)
|
|
1537
|
+
: layeredNodes.slice(0, Math.min(layeredNodes.length, renderNodeBudget))
|
|
1538
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1539
|
+
let sampledEdges = state.transform.scale >= 0.035 ? collectVisibleEdgesForNodes(sampledIds) : []
|
|
1540
|
+
let sampledNodes = ensureHubNodesInRenderedSet(sampled)
|
|
1541
|
+
|
|
1542
|
+
if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
|
|
1543
|
+
const enriched = enrichSampleWithNeighbors(sampledNodes)
|
|
1544
|
+
sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
|
|
1545
|
+
const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
|
|
1546
|
+
sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
state.renderClusters = []
|
|
1550
|
+
state.renderNodes = sampledNodes
|
|
1551
|
+
state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
|
|
1552
|
+
return
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
if (state.transform.scale <= 0.0015) {
|
|
1556
|
+
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
1557
|
+
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
1558
|
+
state.renderClusters = []
|
|
1559
|
+
state.renderNodes = sampled
|
|
1560
|
+
state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
|
|
1561
|
+
return
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
1565
|
+
const clusters = clusterViewportNodes(viewportNodes)
|
|
1566
|
+
if (clusters.length > 0) {
|
|
1567
|
+
state.renderClusters = clusters
|
|
1568
|
+
state.renderNodes = clusters.map(cluster => cluster.representative)
|
|
1569
|
+
state.renderEdges = []
|
|
1570
|
+
return
|
|
1571
|
+
}
|
|
1572
|
+
state.renderClusters = []
|
|
1573
|
+
const stride = viewportNodeStride()
|
|
1574
|
+
const picked = []
|
|
1575
|
+
|
|
1576
|
+
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
1577
|
+
const node = viewportNodes[index]
|
|
1578
|
+
|
|
1579
|
+
const isPriority =
|
|
1580
|
+
node.id === state.selected?.id ||
|
|
1581
|
+
node.id === state.hovered?.id ||
|
|
1582
|
+
node.id === state.pointer.dragNode?.id
|
|
1583
|
+
if (isPriority || index % stride === 0) {
|
|
1584
|
+
picked.push(node)
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const nodes = picked.length > renderNodeBudget
|
|
1589
|
+
? picked.slice(0, renderNodeBudget)
|
|
1590
|
+
: picked
|
|
1591
|
+
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
1592
|
+
const fallbackNodes = fallbackViewportNodes()
|
|
1593
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1594
|
+
state.renderNodes = fallbackNodes
|
|
1595
|
+
state.renderClusters = []
|
|
1596
|
+
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
1597
|
+
return
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
|
|
1601
|
+
const nodeIds = new Set(normalizedNodes.map((node) => node.id))
|
|
1602
|
+
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
1603
|
+
|
|
1604
|
+
state.renderNodes = normalizedNodes
|
|
1605
|
+
state.renderEdges = withMeshEdges(normalizedNodes, edges)
|
|
1606
|
+
|
|
1607
|
+
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
1608
|
+
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
1609
|
+
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
1610
|
+
state.renderClusters = []
|
|
1611
|
+
state.renderNodes = fallbackNodes
|
|
1612
|
+
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
1617
|
+
const radius = nodeRadius(node) * state.transform.scale
|
|
1618
|
+
const screenX = node.x * state.transform.scale + state.transform.x
|
|
1619
|
+
const screenY = node.y * state.transform.scale + state.transform.y
|
|
1620
|
+
|
|
1621
|
+
return (
|
|
1622
|
+
screenX + radius >= 0 &&
|
|
1623
|
+
screenX - radius <= width &&
|
|
1624
|
+
screenY + radius >= 0 &&
|
|
1625
|
+
screenY - radius <= height
|
|
1626
|
+
)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const hasValidTransform = () =>
|
|
1630
|
+
isFiniteNumber(state.transform.x) &&
|
|
1631
|
+
isFiniteNumber(state.transform.y) &&
|
|
1632
|
+
isFiniteNumber(state.transform.scale) &&
|
|
1633
|
+
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
1634
|
+
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
1635
|
+
state.transform.scale > 0
|
|
1636
|
+
|
|
1637
|
+
const sanitizeNodePosition = node => {
|
|
1638
|
+
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
1639
|
+
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
1640
|
+
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
1641
|
+
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const sanitizeAllNodePositions = () => {
|
|
1645
|
+
state.nodes.forEach(sanitizeNodePosition)
|
|
1646
|
+
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const sanitizeGraphState = () => {
|
|
1650
|
+
state.renderNodes.forEach(sanitizeNodePosition)
|
|
1651
|
+
}
|
|
1652
|
+
|
|
215
1653
|
const render = now => {
|
|
216
1654
|
const delta = now - state.last
|
|
217
1655
|
state.last = now
|
|
1656
|
+
const backgroundFrameIntervalMs =
|
|
1657
|
+
state.nodes.length > massiveGraphNodeThreshold
|
|
1658
|
+
? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
|
|
1659
|
+
: state.nodes.length > largeGraphNodeThreshold
|
|
1660
|
+
? 64
|
|
1661
|
+
: 16
|
|
1662
|
+
const isInteracting =
|
|
1663
|
+
state.pointer.down ||
|
|
1664
|
+
state.renderVisibilityDirty ||
|
|
1665
|
+
state.recoveringViewport
|
|
1666
|
+
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
1667
|
+
if (delta < minFrameIntervalMs) {
|
|
1668
|
+
requestAnimationFrame(render)
|
|
1669
|
+
return
|
|
1670
|
+
}
|
|
218
1671
|
const rect = canvas.getBoundingClientRect()
|
|
219
1672
|
const width = Math.max(rect.width, 320)
|
|
220
1673
|
const height = Math.max(rect.height, 320)
|
|
1674
|
+
sanitizeGraphState()
|
|
1675
|
+
if (!hasValidTransform()) {
|
|
1676
|
+
resetView()
|
|
1677
|
+
}
|
|
221
1678
|
ctx.clearRect(0, 0, width, height)
|
|
222
1679
|
if (state.nodes.length === 0) {
|
|
223
1680
|
ctx.fillStyle = '#99a5b5'
|
|
@@ -231,17 +1688,69 @@ const render = now => {
|
|
|
231
1688
|
ctx.translate(state.transform.x, state.transform.y)
|
|
232
1689
|
ctx.scale(state.transform.scale, state.transform.scale)
|
|
233
1690
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1691
|
+
computeRenderVisibility()
|
|
1692
|
+
tick(delta)
|
|
1693
|
+
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
1694
|
+
const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
|
|
1695
|
+
if (!hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
|
|
1696
|
+
state.offscreenFrameCount += 1
|
|
1697
|
+
if (state.offscreenFrameCount >= 6 && !state.recoveringViewport) {
|
|
1698
|
+
state.recoveringViewport = true
|
|
1699
|
+
fitView({ useFiltered: true })
|
|
1700
|
+
state.offscreenFrameCount = 0
|
|
1701
|
+
requestAnimationFrame(() => {
|
|
1702
|
+
state.recoveringViewport = false
|
|
1703
|
+
})
|
|
1704
|
+
}
|
|
1705
|
+
} else {
|
|
1706
|
+
state.offscreenFrameCount = 0
|
|
1707
|
+
}
|
|
1708
|
+
const minimumEdgeScale =
|
|
1709
|
+
state.renderNodes.length > 1300
|
|
1710
|
+
? 0.12
|
|
1711
|
+
: state.renderNodes.length > 900
|
|
1712
|
+
? 0.085
|
|
1713
|
+
: state.renderNodes.length > 500
|
|
1714
|
+
? 0.05
|
|
1715
|
+
: 0
|
|
1716
|
+
const drawEdges =
|
|
1717
|
+
state.renderClusters.length === 0 &&
|
|
1718
|
+
state.transform.scale >= minimumEdgeScale
|
|
1719
|
+
if (drawEdges) {
|
|
1720
|
+
drawGraphEdges()
|
|
1721
|
+
}
|
|
243
1722
|
|
|
244
|
-
|
|
1723
|
+
if (state.renderClusters.length > 0) {
|
|
1724
|
+
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
1725
|
+
state.renderClusters.forEach(cluster => {
|
|
1726
|
+
const isMacro = cluster.id === 'macro-galaxy'
|
|
1727
|
+
const radiusPx = isMacro
|
|
1728
|
+
? 10
|
|
1729
|
+
: Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
1730
|
+
const radius = radiusPx / safeScale
|
|
1731
|
+
const haloRadius = (radiusPx + (isMacro ? 8 : 4)) / safeScale
|
|
1732
|
+
ctx.beginPath()
|
|
1733
|
+
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
1734
|
+
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
1735
|
+
ctx.fill()
|
|
1736
|
+
ctx.beginPath()
|
|
1737
|
+
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
1738
|
+
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
1739
|
+
ctx.fill()
|
|
1740
|
+
ctx.lineWidth = 1.4 / safeScale
|
|
1741
|
+
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
1742
|
+
ctx.stroke()
|
|
1743
|
+
if (isMacro && cluster.representative?.title) {
|
|
1744
|
+
ctx.fillStyle = '#edf2f7'
|
|
1745
|
+
ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
|
|
1746
|
+
ctx.textAlign = 'center'
|
|
1747
|
+
ctx.textBaseline = 'top'
|
|
1748
|
+
ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
|
|
1749
|
+
}
|
|
1750
|
+
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
1751
|
+
})
|
|
1752
|
+
} else {
|
|
1753
|
+
state.renderNodes.forEach(node => {
|
|
245
1754
|
const radius = nodeRadius(node)
|
|
246
1755
|
const isSelected = state.selected?.id === node.id
|
|
247
1756
|
const isHovered = state.hovered?.id === node.id
|
|
@@ -257,16 +1766,28 @@ const render = now => {
|
|
|
257
1766
|
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
258
1767
|
ctx.stroke()
|
|
259
1768
|
|
|
260
|
-
|
|
1769
|
+
const shouldDrawLabels =
|
|
1770
|
+
isSelected ||
|
|
1771
|
+
isHovered ||
|
|
1772
|
+
(state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
|
|
1773
|
+
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
1774
|
+
if (shouldDrawLabels) {
|
|
261
1775
|
ctx.fillStyle = graphTheme.label
|
|
262
1776
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
263
1777
|
ctx.textAlign = 'center'
|
|
264
1778
|
ctx.textBaseline = 'top'
|
|
265
1779
|
ctx.fillText(node.title.slice(0, 34), node.x, node.y + radius + 8)
|
|
266
1780
|
}
|
|
267
|
-
|
|
1781
|
+
})
|
|
1782
|
+
}
|
|
268
1783
|
|
|
269
1784
|
ctx.restore()
|
|
1785
|
+
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
1786
|
+
ctx.fillStyle = '#99a5b5'
|
|
1787
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1788
|
+
ctx.textAlign = 'center'
|
|
1789
|
+
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
1790
|
+
}
|
|
270
1791
|
requestAnimationFrame(render)
|
|
271
1792
|
}
|
|
272
1793
|
|
|
@@ -274,88 +1795,209 @@ const list = items => items.length
|
|
|
274
1795
|
? 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('')
|
|
275
1796
|
: '<li><small>No links found.</small></li>'
|
|
276
1797
|
|
|
277
|
-
const
|
|
278
|
-
? state.nodes.map(node => '<li><button type="button" data-node-id="' + escapeHtml(node.id) + '">' + escapeHtml(node.title) + '</button><small>' + escapeHtml(node.path) + '</small></li>').join('')
|
|
279
|
-
: '<li><small>No notes indexed.</small></li>'
|
|
280
|
-
|
|
281
|
-
const selectNode = node => {
|
|
282
|
-
state.selected = node
|
|
283
|
-
if (!node) {
|
|
284
|
-
elements.title.textContent = 'Graph Overview'
|
|
285
|
-
elements.path.textContent = state.nodes.length + ' notes and ' + state.graph.edges.length + ' links indexed.'
|
|
286
|
-
elements.tags.innerHTML = ''
|
|
287
|
-
elements.notes.innerHTML = allNotesList()
|
|
288
|
-
elements.content.textContent = 'Selecione uma nota no grafo ou na lista para ver o Markdown completo, backlinks e links de saida.'
|
|
289
|
-
elements.outgoing.innerHTML = '<li><small>Select a note to inspect outgoing links.</small></li>'
|
|
290
|
-
elements.incoming.innerHTML = '<li><small>Select a note to inspect backlinks.</small></li>'
|
|
291
|
-
return
|
|
292
|
-
}
|
|
1798
|
+
const linkedNodes = node => {
|
|
293
1799
|
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
294
1800
|
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
295
1801
|
...linkedNode,
|
|
296
1802
|
weight: edge.weight,
|
|
297
1803
|
priority: edge.priority
|
|
298
1804
|
} : null
|
|
299
|
-
const outgoing = state.
|
|
1805
|
+
const outgoing = state.edges
|
|
300
1806
|
.filter(edge => edge.source === node.id)
|
|
301
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: edge.targetTitle + ' (unresolved)', path: 'Missing note' }, edge))
|
|
1807
|
+
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
302
1808
|
.filter(Boolean)
|
|
303
|
-
const incoming = state.
|
|
1809
|
+
const incoming = state.edges
|
|
304
1810
|
.filter(edge => edge.target === node.id)
|
|
305
1811
|
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
306
1812
|
.filter(Boolean)
|
|
307
1813
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1814
|
+
return { outgoing, incoming }
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const fetchNodeDetails = async node => {
|
|
1818
|
+
const cached = state.nodeDetails.get(node.id)
|
|
1819
|
+
if (cached) {
|
|
1820
|
+
return cached
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
1824
|
+
if (!response.ok) {
|
|
1825
|
+
throw new Error('Failed to load graph node details')
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const payload = await response.json()
|
|
1829
|
+
const detail = payload?.node
|
|
1830
|
+
if (!detail || !detail.id) {
|
|
1831
|
+
throw new Error('Invalid graph node payload')
|
|
1832
|
+
}
|
|
1833
|
+
state.nodeDetails.set(detail.id, detail)
|
|
1834
|
+
return detail
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
1838
|
+
|
|
1839
|
+
const openContentDialog = async node => {
|
|
1840
|
+
if (!node) return
|
|
1841
|
+
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
1842
|
+
elements.contentPath.textContent = node.path || 'Loading...'
|
|
1843
|
+
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
311
1844
|
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
312
1845
|
: '<span>No tags</span>'
|
|
313
|
-
|
|
314
|
-
elements.
|
|
315
|
-
elements.
|
|
316
|
-
elements.
|
|
1846
|
+
const initialLinks = linkedNodes(node)
|
|
1847
|
+
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
1848
|
+
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
1849
|
+
elements.contentBody.textContent = 'Loading note content...'
|
|
1850
|
+
if (!elements.contentDialog.open) {
|
|
1851
|
+
elements.contentDialog.showModal()
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
const applyDetailToDialog = detail => {
|
|
1855
|
+
elements.contentTitle.textContent = detail.title
|
|
1856
|
+
elements.contentPath.textContent = detail.path
|
|
1857
|
+
elements.contentTags.innerHTML = detail.tags.length
|
|
1858
|
+
? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
1859
|
+
: '<span>No tags</span>'
|
|
1860
|
+
elements.contentBody.textContent = detail.content
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
try {
|
|
1864
|
+
const detailedNode = await fetchNodeDetails(node)
|
|
1865
|
+
if (state.selected?.id !== node.id) {
|
|
1866
|
+
return
|
|
1867
|
+
}
|
|
1868
|
+
applyDetailToDialog(detailedNode)
|
|
1869
|
+
} catch {
|
|
1870
|
+
try {
|
|
1871
|
+
await wait(120)
|
|
1872
|
+
const retriedNode = await fetchNodeDetails(node)
|
|
1873
|
+
if (state.selected?.id !== node.id) {
|
|
1874
|
+
return
|
|
1875
|
+
}
|
|
1876
|
+
applyDetailToDialog(retriedNode)
|
|
1877
|
+
} catch {
|
|
1878
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
const selectNode = (node, options = { openContent: false }) => {
|
|
1884
|
+
state.selected = node
|
|
1885
|
+
if (node && options.openContent) {
|
|
1886
|
+
openContentDialog(node).catch(() => {
|
|
1887
|
+
elements.contentBody.textContent = 'Unable to load note content.'
|
|
1888
|
+
})
|
|
1889
|
+
}
|
|
317
1890
|
}
|
|
318
1891
|
|
|
319
1892
|
const selectNodeById = id => {
|
|
320
1893
|
const node = state.nodes.find(item => item.id === id)
|
|
321
|
-
if (node) selectNode(node)
|
|
1894
|
+
if (node) selectNode(node, { openContent: true })
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
1898
|
+
if (source === 'wheel') {
|
|
1899
|
+
state.lastManualZoomAt = performance.now()
|
|
1900
|
+
}
|
|
1901
|
+
const nextScale = clampScale(state.transform.scale * factor)
|
|
1902
|
+
if (nextScale === state.transform.scale) {
|
|
1903
|
+
return
|
|
1904
|
+
}
|
|
1905
|
+
const worldX = (screenX - state.transform.x) / state.transform.scale
|
|
1906
|
+
const worldY = (screenY - state.transform.y) / state.transform.scale
|
|
1907
|
+
state.transform.scale = clampScale(nextScale)
|
|
1908
|
+
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
1909
|
+
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
1910
|
+
state.offscreenFrameCount = 0
|
|
1911
|
+
markRenderDirty()
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
const wheelZoomFactor = event => {
|
|
1915
|
+
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
1916
|
+
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
1917
|
+
const absoluteDelta = Math.min(Math.abs(event.deltaY * deltaModeFactor), 1600)
|
|
1918
|
+
|
|
1919
|
+
if (absoluteDelta <= 0.0001) {
|
|
1920
|
+
return 1
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
const baseStep = Math.max(0.06, Math.min(0.45, absoluteDelta / 480))
|
|
1924
|
+
const adjustedStep = baseStep * (isModifierZoom ? 1.4 : 1)
|
|
1925
|
+
|
|
1926
|
+
return event.deltaY < 0 ? 1 + adjustedStep : 1 / (1 + adjustedStep)
|
|
322
1927
|
}
|
|
323
1928
|
|
|
324
|
-
const
|
|
325
|
-
|
|
1929
|
+
const handleWheelZoom = event => {
|
|
1930
|
+
if (elements.contentDialog?.open) {
|
|
1931
|
+
return
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
event.preventDefault()
|
|
1935
|
+
const rect = canvas.getBoundingClientRect()
|
|
1936
|
+
const rawCursorX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left
|
|
1937
|
+
const rawCursorY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top
|
|
1938
|
+
const cursorX = Math.max(0, Math.min(Math.max(rect.width, 320), rawCursorX))
|
|
1939
|
+
const cursorY = Math.max(0, Math.min(Math.max(rect.height, 320), rawCursorY))
|
|
1940
|
+
const factor = wheelZoomFactor(event)
|
|
1941
|
+
|
|
1942
|
+
if (!Number.isFinite(factor) || factor <= 0 || factor === 1) {
|
|
1943
|
+
return
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
zoomAtPoint(cursorX, cursorY, factor, 'wheel')
|
|
326
1947
|
}
|
|
327
1948
|
|
|
328
1949
|
const bindEvents = () => {
|
|
329
1950
|
window.addEventListener('resize', resize)
|
|
330
1951
|
elements.search.addEventListener('input', event => {
|
|
331
1952
|
state.query = event.target.value
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
: state.nodes.length + ' notes · ' + state.edges.length + ' links'
|
|
1953
|
+
recomputeVisibility()
|
|
1954
|
+
scheduleContentFilterSync()
|
|
335
1955
|
})
|
|
336
1956
|
elements.agent.addEventListener('change', event => {
|
|
337
1957
|
state.agentId = event.target.value
|
|
1958
|
+
writeStoredAgent(state.agentId)
|
|
1959
|
+
syncAgentInUrl(state.agentId)
|
|
338
1960
|
state.selected = null
|
|
1961
|
+
state.nodeDetails = new Map()
|
|
1962
|
+
resetContentFilter()
|
|
1963
|
+
recomputeVisibility()
|
|
1964
|
+
scheduleContentFilterSync()
|
|
339
1965
|
loadGraph({ reset: true }).catch(error => {
|
|
340
|
-
elements.stats.textContent = 'Failed to load agent graph'
|
|
341
1966
|
console.error(error)
|
|
342
1967
|
})
|
|
343
1968
|
})
|
|
344
|
-
elements.zoomIn.addEventListener('click', () =>
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1969
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
1970
|
+
const rect = canvas.getBoundingClientRect()
|
|
1971
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.3)
|
|
1972
|
+
})
|
|
1973
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
1974
|
+
const rect = canvas.getBoundingClientRect()
|
|
1975
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.77)
|
|
1976
|
+
})
|
|
1977
|
+
if (elements.fit) {
|
|
1978
|
+
elements.fit.addEventListener('click', () => {
|
|
1979
|
+
fitView({ useFiltered: true })
|
|
353
1980
|
})
|
|
1981
|
+
}
|
|
1982
|
+
elements.reset.addEventListener('click', () => {
|
|
1983
|
+
resetView()
|
|
1984
|
+
})
|
|
1985
|
+
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
1986
|
+
elements.contentDialog.addEventListener('click', event => {
|
|
1987
|
+
const target = event.target
|
|
1988
|
+
if (target instanceof HTMLElement && target.dataset.nodeId) {
|
|
1989
|
+
selectNodeById(target.dataset.nodeId)
|
|
1990
|
+
return
|
|
1991
|
+
}
|
|
1992
|
+
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
1993
|
+
})
|
|
1994
|
+
canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
1995
|
+
canvas.addEventListener('dblclick', event => {
|
|
1996
|
+
const rect = canvas.getBoundingClientRect()
|
|
1997
|
+
const cursorX = event.clientX - rect.left
|
|
1998
|
+
const cursorY = event.clientY - rect.top
|
|
1999
|
+
zoomAtPoint(cursorX, cursorY, 1.25)
|
|
354
2000
|
})
|
|
355
|
-
canvas.addEventListener('wheel', event => {
|
|
356
|
-
event.preventDefault()
|
|
357
|
-
zoom(event.deltaY < 0 ? 1.08 : 0.92)
|
|
358
|
-
}, { passive: false })
|
|
359
2001
|
canvas.addEventListener('pointerdown', event => {
|
|
360
2002
|
const point = worldPoint(event)
|
|
361
2003
|
const node = hitNode(point)
|
|
@@ -363,12 +2005,24 @@ const bindEvents = () => {
|
|
|
363
2005
|
if (node) {
|
|
364
2006
|
node.x = point.x
|
|
365
2007
|
node.y = point.y
|
|
2008
|
+
markRenderDirty()
|
|
366
2009
|
}
|
|
367
2010
|
canvas.setPointerCapture(event.pointerId)
|
|
368
2011
|
})
|
|
369
2012
|
canvas.addEventListener('pointermove', event => {
|
|
370
2013
|
const point = worldPoint(event)
|
|
371
|
-
|
|
2014
|
+
const now = performance.now()
|
|
2015
|
+
const canHoverHitTest =
|
|
2016
|
+
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
|
|
2017
|
+
const shouldHitTest = canHoverHitTest &&
|
|
2018
|
+
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
2019
|
+
if (shouldHitTest) {
|
|
2020
|
+
state.hovered = hitNode(point)
|
|
2021
|
+
state.lastHoverHitAt = now
|
|
2022
|
+
} else if (!canHoverHitTest) {
|
|
2023
|
+
state.hovered = null
|
|
2024
|
+
}
|
|
2025
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
372
2026
|
if (!state.pointer.down) return
|
|
373
2027
|
const dx = event.clientX - state.pointer.x
|
|
374
2028
|
const dy = event.clientY - state.pointer.y
|
|
@@ -378,34 +2032,72 @@ const bindEvents = () => {
|
|
|
378
2032
|
if (state.pointer.dragNode) {
|
|
379
2033
|
state.pointer.dragNode.x = point.x
|
|
380
2034
|
state.pointer.dragNode.y = point.y
|
|
2035
|
+
markRenderDirty()
|
|
381
2036
|
return
|
|
382
2037
|
}
|
|
383
2038
|
state.transform.x += dx
|
|
384
2039
|
state.transform.y += dy
|
|
2040
|
+
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
2041
|
+
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
2042
|
+
state.offscreenFrameCount = 0
|
|
2043
|
+
markRenderDirty()
|
|
385
2044
|
})
|
|
386
2045
|
canvas.addEventListener('pointerup', event => {
|
|
387
|
-
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode)
|
|
388
|
-
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered)
|
|
2046
|
+
if (state.pointer.dragNode && !state.pointer.moved) selectNode(state.pointer.dragNode, { openContent: true })
|
|
2047
|
+
if (!state.pointer.dragNode && !state.pointer.moved) selectNode(state.hovered, { openContent: true })
|
|
389
2048
|
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
390
2049
|
canvas.releasePointerCapture(event.pointerId)
|
|
391
2050
|
})
|
|
2051
|
+
canvas.addEventListener('pointercancel', () => {
|
|
2052
|
+
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
2053
|
+
})
|
|
2054
|
+
canvas.addEventListener('pointerenter', event => {
|
|
2055
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
2056
|
+
})
|
|
2057
|
+
canvas.addEventListener('pointerleave', event => {
|
|
2058
|
+
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
2059
|
+
})
|
|
2060
|
+
window.addEventListener('keydown', event => {
|
|
2061
|
+
if (event.key === '+' || event.key === '=') {
|
|
2062
|
+
event.preventDefault()
|
|
2063
|
+
const rect = canvas.getBoundingClientRect()
|
|
2064
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.25)
|
|
2065
|
+
return
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
if (event.key === '-' || event.key === '_') {
|
|
2069
|
+
event.preventDefault()
|
|
2070
|
+
const rect = canvas.getBoundingClientRect()
|
|
2071
|
+
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.8)
|
|
2072
|
+
return
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
if (event.key === '0') {
|
|
2076
|
+
event.preventDefault()
|
|
2077
|
+
resetView()
|
|
2078
|
+
}
|
|
2079
|
+
})
|
|
392
2080
|
}
|
|
393
2081
|
|
|
394
2082
|
const loadAgents = async () => {
|
|
395
2083
|
const response = await fetch('/api/agents')
|
|
396
2084
|
const payload = await response.json()
|
|
397
2085
|
const agents = Array.isArray(payload.agents) ? payload.agents : []
|
|
398
|
-
const
|
|
2086
|
+
const preferredAgent = state.agentId || initialAgentFromUrl || readStoredAgent()
|
|
2087
|
+
const currentExists = agents.some(agent => agent.id === preferredAgent)
|
|
399
2088
|
const selected = currentExists
|
|
400
|
-
?
|
|
2089
|
+
? preferredAgent
|
|
401
2090
|
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
402
2091
|
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
403
2092
|
|
|
404
2093
|
state.agentId = selected
|
|
2094
|
+
writeStoredAgent(selected)
|
|
2095
|
+
syncAgentInUrl(selected)
|
|
405
2096
|
if (signature !== state.agentsSignature) {
|
|
2097
|
+
const formatAgentLabel = (agent) => agent.id
|
|
406
2098
|
elements.agent.innerHTML = agents.length
|
|
407
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(agent
|
|
408
|
-
: '<option value="shared">shared
|
|
2099
|
+
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
2100
|
+
: '<option value="shared">shared</option>'
|
|
409
2101
|
state.agentsSignature = signature
|
|
410
2102
|
}
|
|
411
2103
|
elements.agent.value = selected
|
|
@@ -426,6 +2118,10 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
426
2118
|
|
|
427
2119
|
const payload = await response.json()
|
|
428
2120
|
const graph = payload?.layout ?? payload
|
|
2121
|
+
state.graphTotals = {
|
|
2122
|
+
nodes: Number.isFinite(payload?.totals?.nodes) ? payload.totals.nodes : (Array.isArray(graph.nodes) ? graph.nodes.length : 0),
|
|
2123
|
+
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
2124
|
+
}
|
|
429
2125
|
const signature = payload?.signature ?? graphSignature(graph)
|
|
430
2126
|
if (!options.reset && signature === state.graphSignature) return
|
|
431
2127
|
const selectedId = state.selected?.id
|
|
@@ -433,18 +2129,37 @@ const loadGraph = async (options = { reset: false }) => {
|
|
|
433
2129
|
state.graphSignature = signature
|
|
434
2130
|
state.graph = graph
|
|
435
2131
|
state.nodes = layout.nodes
|
|
2132
|
+
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
436
2133
|
state.edges = layout.edges
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
2134
|
+
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
2135
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
2136
|
+
if (edge.target) {
|
|
2137
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
2138
|
+
}
|
|
2139
|
+
return degrees
|
|
2140
|
+
}, new Map())
|
|
2141
|
+
state.nodeDetails = new Map()
|
|
2142
|
+
pushNodesToFilterWorker()
|
|
2143
|
+
resetContentFilter()
|
|
2144
|
+
sanitizeAllNodePositions()
|
|
2145
|
+
recomputeVisibility()
|
|
2146
|
+
scheduleContentFilterSync()
|
|
2147
|
+
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
2148
|
+
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
2149
|
+
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
2150
|
+
elements.edgeCount.textContent = state.graphTotals.edges
|
|
441
2151
|
elements.tagCount.textContent = tags.size
|
|
442
2152
|
resize()
|
|
443
2153
|
if (options.reset) resetView()
|
|
444
|
-
|
|
2154
|
+
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
2155
|
+
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
2156
|
+
if (!selectedNode && elements.contentDialog.open) {
|
|
2157
|
+
elements.contentDialog.close()
|
|
2158
|
+
}
|
|
445
2159
|
}
|
|
446
2160
|
|
|
447
2161
|
bindEvents()
|
|
2162
|
+
initFilterWorker()
|
|
448
2163
|
requestAnimationFrame(() => {
|
|
449
2164
|
resize()
|
|
450
2165
|
resetView()
|
|
@@ -475,7 +2190,6 @@ loadAgents()
|
|
|
475
2190
|
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
476
2191
|
})
|
|
477
2192
|
.catch(error => {
|
|
478
|
-
elements.stats.textContent = 'Failed to load graph'
|
|
479
2193
|
console.error(error)
|
|
480
2194
|
})
|
|
481
2195
|
|