@andespindola/brainlink 0.1.0-beta.98 → 1.0.0
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 +6 -6
- package/CHANGELOG.md +14 -0
- package/README.md +186 -38
- package/dist/application/add-note.js +13 -44
- package/dist/application/analyze-vault.js +1 -1
- package/dist/application/auto-migrate-configured-vault.js +37 -0
- package/dist/application/build-context.js +119 -20
- package/dist/application/canonical-context-links.js +209 -0
- package/dist/application/frontend/client-css.js +212 -42
- package/dist/application/frontend/client-html.js +42 -28
- package/dist/application/frontend/client-js.js +1294 -3217
- package/dist/application/frontend/client-render-worker-js.js +676 -0
- package/dist/application/get-graph-contexts.js +33 -0
- package/dist/application/get-graph-layout.js +62 -8
- package/dist/application/get-graph-stream-chunk.js +326 -0
- package/dist/application/get-graph-view.js +246 -0
- package/dist/application/graph-view-state.js +66 -0
- package/dist/application/import-legacy-sqlite.js +3 -33
- package/dist/application/index-vault.js +35 -22
- package/dist/application/migrate-context-links.js +79 -0
- package/dist/application/search-graph-node-ids.js +63 -3
- package/dist/application/server/routes.js +197 -12
- package/dist/cli/commands/read-commands.js +39 -3
- package/dist/cli/commands/vault-commands.js +182 -0
- package/dist/cli/commands/write-commands.js +147 -12
- package/dist/cli/main.js +2 -0
- package/dist/cli/runtime.js +10 -2
- package/dist/domain/context.js +1 -0
- package/dist/domain/graph-contexts.js +180 -0
- package/dist/domain/graph-layout.js +347 -21
- package/dist/domain/markdown.js +53 -9
- package/dist/infrastructure/config.js +105 -6
- package/dist/infrastructure/context-packs.js +122 -0
- package/dist/infrastructure/file-index.js +6 -3
- package/dist/infrastructure/index-state.js +2 -0
- package/dist/infrastructure/vault-migration-state.js +69 -0
- package/dist/infrastructure/volatile-memory.js +100 -0
- package/dist/mcp/http-server.js +97 -0
- package/dist/mcp/runtime.js +20 -0
- package/dist/mcp/server.js +36 -13
- package/dist/mcp/tools.js +203 -14
- package/docs/AGENT_USAGE.md +50 -5
- package/docs/ARCHITECTURE.md +11 -0
- package/docs/QUICKSTART.md +3 -1
- package/docs/RELEASE.md +4 -3
- package/package.json +3 -1
|
@@ -1,118 +1,26 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
const largeGraphNodeThreshold = 4000
|
|
5
|
-
const massiveGraphNodeThreshold = 20000
|
|
6
|
-
const largeGraphEdgeRenderLimit = 120000
|
|
7
|
-
const renderNodeBudget = 900
|
|
8
|
-
const zoomedMassiveRenderNodeBudget = 2200
|
|
9
|
-
const renderEdgeBudget = 2400
|
|
10
|
-
const clusterActivationNodeThreshold = 600
|
|
11
|
-
const clusterZoomThreshold = 0.18
|
|
12
|
-
const macroGalaxyZoomThreshold = 0.012
|
|
13
|
-
const macroGalaxyEnterHysteresis = 0.86
|
|
14
|
-
const macroGalaxyExitHysteresis = 1.24
|
|
15
|
-
const galaxyDiscoveryEnabled = false
|
|
16
|
-
const massiveAutoFitMacroScale = 0.006
|
|
17
|
-
const defaultMacroScale = 0.006
|
|
18
|
-
const clusterCellPixelSize = 64
|
|
19
|
-
const minNodePixelRadius = 2.3
|
|
20
|
-
const viewportPaddingPx = 280
|
|
21
|
-
const worldCoordinateLimit = 5_000_000
|
|
22
|
-
const transformCoordinateLimit = 20_000_000
|
|
23
|
-
const hoverHitTestIntervalMs = 64
|
|
24
|
-
const ecosystemLevelNodeCap = 999
|
|
25
|
-
const ecosystemActivationNodeThreshold = 1000
|
|
26
|
-
const ecosystemClusterEdgeLimit = 520
|
|
27
|
-
const ecosystemHubEdgeLimit = 120
|
|
28
|
-
const ecosystemSiblingEdgeLimit = 180
|
|
29
|
-
const ecosystemClusterScaleThreshold = 0.78
|
|
30
|
-
const massiveEcosystemClusterScaleThreshold = 4.2
|
|
31
|
-
const ecosystemSubgraphScaleThreshold = 0.18
|
|
32
|
-
const ecosystemMicroScaleThreshold = 0.08
|
|
33
|
-
const ecosystemFocusedParentLimit = 2
|
|
34
|
-
const zoomRecoveryGuardMs = 4200
|
|
35
|
-
const zoomCapTargetViewportShare = 0.72
|
|
36
|
-
const meshEdgeScaleThreshold = 0.09
|
|
37
|
-
const meshEdgeMinBudget = 140
|
|
38
|
-
const meshEdgeMaxBudget = 1400
|
|
39
|
-
const layeredCoreScaleThreshold = 0.55
|
|
40
|
-
const dragNeighborhoodMaxAffected = 180
|
|
41
|
-
const dragSettleRounds = 3
|
|
42
|
-
const wheelZoomExponent = 0.0009
|
|
43
|
-
const wheelZoomExponentCap = 0.035
|
|
44
|
-
const wheelZoomModifierBoost = 1.08
|
|
45
|
-
const state = {
|
|
46
|
-
graph: { nodes: [], edges: [] },
|
|
47
|
-
nodes: [],
|
|
48
|
-
nodeById: new Map(),
|
|
49
|
-
edges: [],
|
|
50
|
-
visibleNodes: [],
|
|
51
|
-
visibleEdges: [],
|
|
52
|
-
renderNodes: [],
|
|
53
|
-
renderEdges: [],
|
|
54
|
-
renderClusters: [],
|
|
55
|
-
renderClusterEdges: [],
|
|
56
|
-
nodeDegrees: new Map(),
|
|
57
|
-
selected: null,
|
|
58
|
-
hovered: null,
|
|
59
|
-
query: '',
|
|
60
|
-
contentFilter: { query: '', ids: null, token: 0, timer: null },
|
|
61
|
-
agentId: '',
|
|
62
|
-
agentsSignature: '',
|
|
63
|
-
nodeDetails: new Map(),
|
|
64
|
-
transform: { x: 0, y: 0, scale: 1 },
|
|
65
|
-
pointer: { x: 0, y: 0, down: false, dragNode: null, moved: false },
|
|
66
|
-
cursor: { x: 0, y: 0, inCanvas: false },
|
|
67
|
-
graphSignature: '',
|
|
68
|
-
graphStatus: '',
|
|
69
|
-
graphTotals: { nodes: 0, edges: 0 },
|
|
70
|
-
last: performance.now(),
|
|
71
|
-
offscreenFrameCount: 0,
|
|
72
|
-
recoveringViewport: false,
|
|
73
|
-
renderVisibilityDirty: true,
|
|
74
|
-
lastViewportKey: '',
|
|
75
|
-
visibleNodeSpatial: { cellSize: 220, minX: 0, minY: 0, maxX: 0, maxY: 0, buckets: new Map() },
|
|
76
|
-
visibleEdgeByNode: new Map(),
|
|
77
|
-
ecosystemClusters: [],
|
|
78
|
-
ecosystemClustersBySize: new Map(),
|
|
79
|
-
ecosystemNodeClusterBySize: new Map(),
|
|
80
|
-
ecosystemLevelSizes: [],
|
|
81
|
-
ecosystemExpansionLevels: [],
|
|
82
|
-
ecosystemBaseSize: ecosystemLevelNodeCap,
|
|
83
|
-
ecosystemHubCluster: null,
|
|
84
|
-
macroCenter: { x: 0, y: 0 },
|
|
85
|
-
macroRepresentative: null,
|
|
86
|
-
primaryHub: null,
|
|
87
|
-
hubNeighborDistance: Number.POSITIVE_INFINITY,
|
|
88
|
-
filterWorker: null,
|
|
89
|
-
filterReady: false,
|
|
90
|
-
lastHoverHitAt: 0,
|
|
91
|
-
lastManualZoomAt: 0,
|
|
92
|
-
lastZoomFocus: { x: 0, y: 0, at: 0 },
|
|
93
|
-
macroViewActive: false
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const byId = id => document.getElementById(id)
|
|
97
|
-
const escapeHtml = value => String(value)
|
|
98
|
-
.replaceAll('&', '&')
|
|
99
|
-
.replaceAll('<', '<')
|
|
100
|
-
.replaceAll('>', '>')
|
|
101
|
-
.replaceAll('"', '"')
|
|
102
|
-
.replaceAll("'", ''')
|
|
2
|
+
let ctx2dFallback = null
|
|
3
|
+
const byId = (id) => document.getElementById(id)
|
|
103
4
|
const elements = {
|
|
104
5
|
search: byId('search'),
|
|
105
6
|
agent: byId('agent'),
|
|
7
|
+
context: byId('context'),
|
|
106
8
|
nodeCount: byId('nodeCount'),
|
|
107
9
|
edgeCount: byId('edgeCount'),
|
|
108
10
|
tagCount: byId('tagCount'),
|
|
109
11
|
zoomIn: byId('zoomIn'),
|
|
110
12
|
zoomOut: byId('zoomOut'),
|
|
111
13
|
fit: byId('fit'),
|
|
14
|
+
releaseNode: byId('releaseNode'),
|
|
112
15
|
reset: byId('reset'),
|
|
16
|
+
labels: byId('graphLabels'),
|
|
17
|
+
tooltip: byId('graphTooltip'),
|
|
18
|
+
miniMap: byId('miniMap'),
|
|
113
19
|
contentDialog: byId('contentDialog'),
|
|
114
20
|
contentTitle: byId('contentTitle'),
|
|
115
21
|
contentPath: byId('contentPath'),
|
|
22
|
+
contentFacts: byId('contentFacts'),
|
|
23
|
+
contentContextLinks: byId('contentContextLinks'),
|
|
116
24
|
contentTags: byId('contentTags'),
|
|
117
25
|
contentOutgoing: byId('contentOutgoing'),
|
|
118
26
|
contentIncoming: byId('contentIncoming'),
|
|
@@ -120,22 +28,91 @@ const elements = {
|
|
|
120
28
|
contentClose: byId('contentClose')
|
|
121
29
|
}
|
|
122
30
|
|
|
31
|
+
const state = {
|
|
32
|
+
camera: {
|
|
33
|
+
x: 0,
|
|
34
|
+
y: 0,
|
|
35
|
+
scale: 0.22
|
|
36
|
+
},
|
|
37
|
+
pointer: {
|
|
38
|
+
down: false,
|
|
39
|
+
moved: false,
|
|
40
|
+
dragging: false,
|
|
41
|
+
dragNodeId: '',
|
|
42
|
+
x: 0,
|
|
43
|
+
y: 0,
|
|
44
|
+
startX: 0,
|
|
45
|
+
startY: 0,
|
|
46
|
+
startWorldX: 0,
|
|
47
|
+
startWorldY: 0,
|
|
48
|
+
nodeStartX: 0,
|
|
49
|
+
nodeStartY: 0,
|
|
50
|
+
worldAnchorX: 0,
|
|
51
|
+
worldAnchorY: 0
|
|
52
|
+
},
|
|
53
|
+
viewport: {
|
|
54
|
+
width: 320,
|
|
55
|
+
height: 320,
|
|
56
|
+
ratio: window.devicePixelRatio || 1
|
|
57
|
+
},
|
|
58
|
+
workerReady: false,
|
|
59
|
+
rendererMode: 'worker',
|
|
60
|
+
renderWorker: null,
|
|
61
|
+
agentId: '',
|
|
62
|
+
contextId: '',
|
|
63
|
+
graphSignature: '',
|
|
64
|
+
graphMode: 'near',
|
|
65
|
+
nodePositionsSignature: '',
|
|
66
|
+
nodePositionsScope: '',
|
|
67
|
+
serverNodePositionsScope: '',
|
|
68
|
+
nodePositions: new Map(),
|
|
69
|
+
hoveredNodeId: '',
|
|
70
|
+
focusedNodeIds: new Set(),
|
|
71
|
+
spatialIndex: {
|
|
72
|
+
key: '',
|
|
73
|
+
cells: new Map()
|
|
74
|
+
},
|
|
75
|
+
miniMapView: null,
|
|
76
|
+
miniMapDirty: true,
|
|
77
|
+
overlayScheduled: false,
|
|
78
|
+
overlayIdleTimer: null,
|
|
79
|
+
chunk: {
|
|
80
|
+
nodes: [],
|
|
81
|
+
edges: []
|
|
82
|
+
},
|
|
83
|
+
selectedNodeId: null,
|
|
84
|
+
searchToken: 0,
|
|
85
|
+
searchTimer: null,
|
|
86
|
+
searchResultIds: new Set(),
|
|
87
|
+
fetchToken: 0,
|
|
88
|
+
fetchTimer: null,
|
|
89
|
+
fetchAbortController: null,
|
|
90
|
+
lastChunkRequestKey: '',
|
|
91
|
+
cameraSyncScheduled: false,
|
|
92
|
+
lastWheelAt: 0,
|
|
93
|
+
lastVisibleNodes: 0,
|
|
94
|
+
lastVisibleEdges: 0,
|
|
95
|
+
totals: {
|
|
96
|
+
nodes: 0,
|
|
97
|
+
edges: 0
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
123
101
|
const zoomRange = {
|
|
124
102
|
min: 0.0002,
|
|
125
103
|
max: 4.5
|
|
126
104
|
}
|
|
127
105
|
|
|
128
|
-
const initialAgentFromUrl = (() => {
|
|
129
|
-
try {
|
|
130
|
-
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
131
|
-
const value = raw?.trim() ?? ''
|
|
132
|
-
return value.length > 0 ? value : ''
|
|
133
|
-
} catch {
|
|
134
|
-
return ''
|
|
135
|
-
}
|
|
136
|
-
})()
|
|
137
|
-
|
|
138
106
|
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
107
|
+
const selectedContextStorageKey = 'brainlink:selected-context'
|
|
108
|
+
const nodePositionsStoragePrefix = 'brainlink:graph-node-positions:'
|
|
109
|
+
|
|
110
|
+
const escapeHtml = (value) => String(value)
|
|
111
|
+
.replaceAll('&', '&')
|
|
112
|
+
.replaceAll('<', '<')
|
|
113
|
+
.replaceAll('>', '>')
|
|
114
|
+
.replaceAll('"', '"')
|
|
115
|
+
.replaceAll("'", ''')
|
|
139
116
|
|
|
140
117
|
const readStoredAgent = () => {
|
|
141
118
|
try {
|
|
@@ -156,3425 +133,1525 @@ const writeStoredAgent = (agentId) => {
|
|
|
156
133
|
} catch {}
|
|
157
134
|
}
|
|
158
135
|
|
|
159
|
-
const
|
|
136
|
+
const readStoredContext = () => {
|
|
160
137
|
try {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
window.history.replaceState({}, '', url.toString())
|
|
168
|
-
} catch {}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
172
|
-
|
|
173
|
-
const setGraphStatus = text => {
|
|
174
|
-
state.graphStatus = text
|
|
138
|
+
const value = window.localStorage.getItem(selectedContextStorageKey)?.trim() ?? ''
|
|
139
|
+
return value.length > 0 ? value : ''
|
|
140
|
+
} catch {
|
|
141
|
+
return ''
|
|
142
|
+
}
|
|
175
143
|
}
|
|
176
144
|
|
|
177
|
-
const
|
|
178
|
-
|
|
145
|
+
const writeStoredContext = (contextId) => {
|
|
146
|
+
try {
|
|
147
|
+
if (!contextId) {
|
|
148
|
+
window.localStorage.removeItem(selectedContextStorageKey)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
window.localStorage.setItem(selectedContextStorageKey, contextId)
|
|
152
|
+
} catch {}
|
|
179
153
|
}
|
|
180
154
|
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
nodeStroke: '#0d0f12',
|
|
188
|
-
nodeStrokeActive: '#ffffff',
|
|
189
|
-
edge: 'rgba(153, 165, 181, 0.16)',
|
|
190
|
-
edgeActive: 'rgba(226, 232, 240, 0.52)',
|
|
191
|
-
label: '#edf2f7'
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const parseRgb = color => {
|
|
195
|
-
const normalized = color.trim()
|
|
196
|
-
if (normalized.startsWith('#')) {
|
|
197
|
-
const value = normalized.slice(1)
|
|
198
|
-
const expanded = value.length === 3
|
|
199
|
-
? value.split('').map(char => char + char).join('')
|
|
200
|
-
: value
|
|
201
|
-
const parsed = Number.parseInt(expanded, 16)
|
|
202
|
-
return [
|
|
203
|
-
((parsed >> 16) & 255) / 255,
|
|
204
|
-
((parsed >> 8) & 255) / 255,
|
|
205
|
-
(parsed & 255) / 255
|
|
206
|
-
]
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const match = normalized.match(/rgba?\\(([^)]+)\\)/)
|
|
210
|
-
if (!match) return [1, 1, 1]
|
|
211
|
-
const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()))
|
|
212
|
-
return [
|
|
213
|
-
Math.max(0, Math.min(1, (parts[0] ?? 255) / 255)),
|
|
214
|
-
Math.max(0, Math.min(1, (parts[1] ?? 255) / 255)),
|
|
215
|
-
Math.max(0, Math.min(1, (parts[2] ?? 255) / 255))
|
|
216
|
-
]
|
|
217
|
-
}
|
|
155
|
+
const nodePositionsStorageKey = () => [
|
|
156
|
+
nodePositionsStoragePrefix,
|
|
157
|
+
state.graphSignature || 'unknown',
|
|
158
|
+
state.agentId || 'all-agents',
|
|
159
|
+
state.contextId || 'all-contexts'
|
|
160
|
+
].join(':')
|
|
218
161
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
162
|
+
const readStoredNodePositions = () => {
|
|
163
|
+
try {
|
|
164
|
+
const raw = window.localStorage.getItem(nodePositionsStorageKey())
|
|
165
|
+
const parsed = raw ? JSON.parse(raw) : []
|
|
166
|
+
if (!Array.isArray(parsed)) {
|
|
167
|
+
return new Map()
|
|
168
|
+
}
|
|
223
169
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return
|
|
232
|
-
}
|
|
233
|
-
return shader
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const createProgram = (gl, vertexSource, fragmentSource) => {
|
|
237
|
-
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
|
|
238
|
-
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
|
|
239
|
-
if (!vertexShader || !fragmentShader) return null
|
|
240
|
-
const program = gl.createProgram()
|
|
241
|
-
if (!program) return null
|
|
242
|
-
gl.attachShader(program, vertexShader)
|
|
243
|
-
gl.attachShader(program, fragmentShader)
|
|
244
|
-
gl.linkProgram(program)
|
|
245
|
-
gl.deleteShader(vertexShader)
|
|
246
|
-
gl.deleteShader(fragmentShader)
|
|
247
|
-
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
248
|
-
gl.deleteProgram(program)
|
|
249
|
-
return null
|
|
170
|
+
return new Map(parsed.flatMap((entry) => {
|
|
171
|
+
const id = typeof entry?.[0] === 'string' ? entry[0] : ''
|
|
172
|
+
const x = Number(entry?.[1])
|
|
173
|
+
const y = Number(entry?.[2])
|
|
174
|
+
return id && Number.isFinite(x) && Number.isFinite(y) ? [[id, { x, y }]] : []
|
|
175
|
+
}))
|
|
176
|
+
} catch {
|
|
177
|
+
return new Map()
|
|
250
178
|
}
|
|
251
|
-
return program
|
|
252
179
|
}
|
|
253
180
|
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
targetCanvas.getContext('webgl', { alpha: true, antialias: true })
|
|
259
|
-
if (!gl) return null
|
|
260
|
-
|
|
261
|
-
const lineProgram = createProgram(
|
|
262
|
-
gl,
|
|
263
|
-
'attribute vec2 a_position; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clip = zeroToOne * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); }',
|
|
264
|
-
'precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }'
|
|
265
|
-
)
|
|
266
|
-
const pointProgram = createProgram(
|
|
267
|
-
gl,
|
|
268
|
-
'attribute vec2 a_position; attribute float a_size; uniform vec2 u_resolution; void main() { vec2 zeroToOne = a_position / u_resolution; vec2 clip = zeroToOne * 2.0 - 1.0; gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); gl_PointSize = a_size; }',
|
|
269
|
-
'precision mediump float; uniform vec4 u_color; void main() { vec2 center = gl_PointCoord - vec2(0.5); float distanceFromCenter = length(center); if (distanceFromCenter > 0.5) discard; float edge = smoothstep(0.5, 0.42, distanceFromCenter); gl_FragColor = vec4(u_color.rgb, u_color.a * edge); }'
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
if (!lineProgram || !pointProgram) return null
|
|
273
|
-
|
|
274
|
-
const lineBuffer = gl.createBuffer()
|
|
275
|
-
const pointPositionBuffer = gl.createBuffer()
|
|
276
|
-
const pointSizeBuffer = gl.createBuffer()
|
|
277
|
-
if (!lineBuffer || !pointPositionBuffer || !pointSizeBuffer) return null
|
|
278
|
-
|
|
279
|
-
const linePositionLocation = gl.getAttribLocation(lineProgram, 'a_position')
|
|
280
|
-
const lineResolutionLocation = gl.getUniformLocation(lineProgram, 'u_resolution')
|
|
281
|
-
const lineColorLocation = gl.getUniformLocation(lineProgram, 'u_color')
|
|
282
|
-
const pointPositionLocation = gl.getAttribLocation(pointProgram, 'a_position')
|
|
283
|
-
const pointSizeLocation = gl.getAttribLocation(pointProgram, 'a_size')
|
|
284
|
-
const pointResolutionLocation = gl.getUniformLocation(pointProgram, 'u_resolution')
|
|
285
|
-
const pointColorLocation = gl.getUniformLocation(pointProgram, 'u_color')
|
|
286
|
-
|
|
287
|
-
gl.enable(gl.BLEND)
|
|
288
|
-
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
|
289
|
-
|
|
290
|
-
const setViewport = (width, height) => {
|
|
291
|
-
gl.viewport(0, 0, targetCanvas.width, targetCanvas.height)
|
|
292
|
-
return [targetCanvas.width / Math.max(width, 1), targetCanvas.height / Math.max(height, 1)]
|
|
181
|
+
const ensureNodePositionsLoaded = () => {
|
|
182
|
+
const storageKey = nodePositionsStorageKey()
|
|
183
|
+
if (!state.graphSignature || (state.nodePositionsSignature === state.graphSignature && state.nodePositionsScope === storageKey)) {
|
|
184
|
+
return
|
|
293
185
|
}
|
|
294
186
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
187
|
+
state.nodePositions = readStoredNodePositions()
|
|
188
|
+
state.nodePositionsSignature = state.graphSignature
|
|
189
|
+
state.nodePositionsScope = storageKey
|
|
190
|
+
}
|
|
299
191
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const drawLines = (edges, color, width, height) => {
|
|
307
|
-
if (edges.length === 0) return
|
|
308
|
-
const [ratioX, ratioY] = setViewport(width, height)
|
|
309
|
-
const positions = new Float32Array(edges.length * 4)
|
|
310
|
-
for (let index = 0; index < edges.length; index += 1) {
|
|
311
|
-
const edge = edges[index]
|
|
312
|
-
const source = screenPoint(edge.sourceNode, ratioX, ratioY)
|
|
313
|
-
const target = screenPoint(edge.targetNode, ratioX, ratioY)
|
|
314
|
-
const offset = index * 4
|
|
315
|
-
positions[offset] = source[0]
|
|
316
|
-
positions[offset + 1] = source[1]
|
|
317
|
-
positions[offset + 2] = target[0]
|
|
318
|
-
positions[offset + 3] = target[1]
|
|
192
|
+
const writeStoredNodePositions = () => {
|
|
193
|
+
try {
|
|
194
|
+
if (!state.graphSignature) {
|
|
195
|
+
return
|
|
319
196
|
}
|
|
320
197
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
gl.enableVertexAttribArray(linePositionLocation)
|
|
325
|
-
gl.vertexAttribPointer(linePositionLocation, 2, gl.FLOAT, false, 0, 0)
|
|
326
|
-
gl.uniform2f(lineResolutionLocation, targetCanvas.width, targetCanvas.height)
|
|
327
|
-
gl.uniform4fv(lineColorLocation, color)
|
|
328
|
-
gl.lineWidth(1)
|
|
329
|
-
gl.drawArrays(gl.LINES, 0, edges.length * 2)
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const drawPoints = (nodes, color, sizeForNode, width, height) => {
|
|
333
|
-
if (nodes.length === 0) return
|
|
334
|
-
const [ratioX, ratioY] = setViewport(width, height)
|
|
335
|
-
const positions = new Float32Array(nodes.length * 2)
|
|
336
|
-
const sizes = new Float32Array(nodes.length)
|
|
337
|
-
for (let index = 0; index < nodes.length; index += 1) {
|
|
338
|
-
const node = nodes[index]
|
|
339
|
-
const point = screenPoint(node, ratioX, ratioY)
|
|
340
|
-
const offset = index * 2
|
|
341
|
-
positions[offset] = point[0]
|
|
342
|
-
positions[offset + 1] = point[1]
|
|
343
|
-
sizes[index] = sizeForNode(node) * ((ratioX + ratioY) / 2)
|
|
344
|
-
}
|
|
198
|
+
const entries = Array.from(state.nodePositions.entries())
|
|
199
|
+
.filter((entry) => Number.isFinite(entry[1]?.x) && Number.isFinite(entry[1]?.y))
|
|
200
|
+
.map((entry) => [entry[0], entry[1].x, entry[1].y])
|
|
345
201
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
gl.vertexAttribPointer(pointPositionLocation, 2, gl.FLOAT, false, 0, 0)
|
|
351
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, pointSizeBuffer)
|
|
352
|
-
gl.bufferData(gl.ARRAY_BUFFER, sizes, gl.STREAM_DRAW)
|
|
353
|
-
gl.enableVertexAttribArray(pointSizeLocation)
|
|
354
|
-
gl.vertexAttribPointer(pointSizeLocation, 1, gl.FLOAT, false, 0, 0)
|
|
355
|
-
gl.uniform2f(pointResolutionLocation, targetCanvas.width, targetCanvas.height)
|
|
356
|
-
gl.uniform4fv(pointColorLocation, color)
|
|
357
|
-
gl.drawArrays(gl.POINTS, 0, nodes.length)
|
|
358
|
-
}
|
|
202
|
+
if (entries.length === 0) {
|
|
203
|
+
window.localStorage.removeItem(nodePositionsStorageKey())
|
|
204
|
+
return
|
|
205
|
+
}
|
|
359
206
|
|
|
360
|
-
|
|
207
|
+
window.localStorage.setItem(nodePositionsStorageKey(), JSON.stringify(entries))
|
|
208
|
+
} catch {}
|
|
361
209
|
}
|
|
362
210
|
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
const initFilterWorker = () => {
|
|
366
|
-
if (typeof Worker === 'undefined') {
|
|
367
|
-
return
|
|
368
|
-
}
|
|
211
|
+
const clearStoredNodePositions = () => {
|
|
369
212
|
try {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const payload = event.data
|
|
373
|
-
if (!payload || typeof payload !== 'object') return
|
|
374
|
-
|
|
375
|
-
if (payload.type === 'ready') {
|
|
376
|
-
state.filterReady = true
|
|
377
|
-
if (state.nodes.length > 0) {
|
|
378
|
-
worker.postMessage({
|
|
379
|
-
type: 'load-nodes',
|
|
380
|
-
nodes: state.nodes.map(node => ({
|
|
381
|
-
id: node.id,
|
|
382
|
-
title: node.title,
|
|
383
|
-
path: node.path || '',
|
|
384
|
-
tags: Array.isArray(node.tags) ? node.tags : []
|
|
385
|
-
}))
|
|
386
|
-
})
|
|
387
|
-
}
|
|
388
|
-
return
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (payload.type === 'filter-result') {
|
|
392
|
-
const token = payload.token
|
|
393
|
-
if (token !== state.contentFilter.token) {
|
|
394
|
-
return
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const ids = Array.isArray(payload.ids) ? payload.ids.filter(id => typeof id === 'string') : []
|
|
398
|
-
state.contentFilter.query = normalizeQuery(state.query)
|
|
399
|
-
state.contentFilter.ids = new Set(ids)
|
|
400
|
-
recomputeVisibility()
|
|
401
|
-
}
|
|
213
|
+
if (state.graphSignature) {
|
|
214
|
+
window.localStorage.removeItem(nodePositionsStorageKey())
|
|
402
215
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
216
|
+
} catch {}
|
|
217
|
+
state.nodePositions = new Map()
|
|
218
|
+
state.nodePositionsSignature = state.graphSignature
|
|
219
|
+
state.nodePositionsScope = nodePositionsStorageKey()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const graphViewStateQuery = () => {
|
|
223
|
+
const params = new URLSearchParams({ signature: state.graphSignature })
|
|
224
|
+
if (state.agentId) {
|
|
225
|
+
params.set('agent', state.agentId)
|
|
226
|
+
}
|
|
227
|
+
if (state.contextId) {
|
|
228
|
+
params.set('context', state.contextId)
|
|
407
229
|
}
|
|
230
|
+
return params.toString()
|
|
408
231
|
}
|
|
409
232
|
|
|
410
|
-
const
|
|
411
|
-
if (!state.
|
|
233
|
+
const syncNodePositionsFromServer = async () => {
|
|
234
|
+
if (!state.graphSignature) {
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
const scope = nodePositionsStorageKey()
|
|
238
|
+
if (state.serverNodePositionsScope === scope) {
|
|
412
239
|
return
|
|
413
240
|
}
|
|
241
|
+
state.serverNodePositionsScope = scope
|
|
414
242
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch('/api/graph-view-state?' + graphViewStateQuery())
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
const payload = await response.json()
|
|
249
|
+
const positions = Array.isArray(payload?.positions) ? payload.positions : []
|
|
250
|
+
if (positions.length === 0) {
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
state.nodePositions = new Map(positions.flatMap((position) => {
|
|
254
|
+
const id = typeof position?.id === 'string' ? position.id : ''
|
|
255
|
+
const x = Number(position?.x)
|
|
256
|
+
const y = Number(position?.y)
|
|
257
|
+
return id && Number.isFinite(x) && Number.isFinite(y) ? [[id, { x, y }]] : []
|
|
422
258
|
}))
|
|
423
|
-
|
|
259
|
+
writeStoredNodePositions()
|
|
260
|
+
} catch {}
|
|
424
261
|
}
|
|
425
262
|
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const height = Math.max(rect.height, 320)
|
|
430
|
-
const ratio = window.devicePixelRatio || 1
|
|
431
|
-
canvas.width = Math.floor(width * ratio)
|
|
432
|
-
canvas.height = Math.floor(height * ratio)
|
|
433
|
-
if (glCanvas) {
|
|
434
|
-
glCanvas.width = Math.floor(width * ratio)
|
|
435
|
-
glCanvas.height = Math.floor(height * ratio)
|
|
263
|
+
const persistNodePositionsToServer = () => {
|
|
264
|
+
if (!state.graphSignature) {
|
|
265
|
+
return
|
|
436
266
|
}
|
|
437
|
-
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
438
|
-
markRenderDirty()
|
|
439
|
-
}
|
|
440
267
|
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
268
|
+
const positions = Array.from(state.nodePositions.entries()).map(([id, position]) => ({
|
|
269
|
+
id,
|
|
270
|
+
x: position.x,
|
|
271
|
+
y: position.y
|
|
272
|
+
}))
|
|
445
273
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
|
|
452
|
-
if (/\bmoc\b/i.test(node.title)) return 2
|
|
453
|
-
return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
|
|
454
|
-
? 1
|
|
455
|
-
: 0
|
|
274
|
+
fetch('/api/graph-view-state?' + graphViewStateQuery(), {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: { 'content-type': 'application/json' },
|
|
277
|
+
body: JSON.stringify({ positions })
|
|
278
|
+
}).catch(() => {})
|
|
456
279
|
}
|
|
457
280
|
|
|
458
|
-
const
|
|
459
|
-
state.
|
|
460
|
-
|
|
461
|
-
(node.path || '').toLowerCase().includes(query) ||
|
|
462
|
-
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
463
|
-
)
|
|
464
|
-
|
|
465
|
-
const rankedHubNodes = () => {
|
|
466
|
-
if (state.nodes.length === 0) {
|
|
467
|
-
return []
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const byTitleAndDegree = [...state.nodes]
|
|
471
|
-
.filter(node => hubNodeScore(node) > 0)
|
|
472
|
-
.sort((left, right) => {
|
|
473
|
-
const byHubScore = hubNodeScore(right) - hubNodeScore(left)
|
|
474
|
-
if (byHubScore !== 0) return byHubScore
|
|
475
|
-
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
476
|
-
if (byDegree !== 0) return byDegree
|
|
477
|
-
return left.title.localeCompare(right.title)
|
|
478
|
-
})
|
|
479
|
-
|
|
480
|
-
if (byTitleAndDegree.length > 0) {
|
|
481
|
-
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
281
|
+
const clearNodePositionsOnServer = () => {
|
|
282
|
+
if (!state.graphSignature) {
|
|
283
|
+
return
|
|
482
284
|
}
|
|
483
285
|
|
|
484
|
-
|
|
485
|
-
.sort((left, right) => {
|
|
486
|
-
const byDegree = (state.nodeDegrees.get(right.id) ?? 0) - (state.nodeDegrees.get(left.id) ?? 0)
|
|
487
|
-
if (byDegree !== 0) return byDegree
|
|
488
|
-
return left.title.localeCompare(right.title)
|
|
489
|
-
})
|
|
490
|
-
.slice(0, 1)
|
|
286
|
+
fetch('/api/graph-view-state?' + graphViewStateQuery(), { method: 'DELETE' }).catch(() => {})
|
|
491
287
|
}
|
|
492
288
|
|
|
493
|
-
const
|
|
494
|
-
if (
|
|
495
|
-
return
|
|
289
|
+
const releaseSelectedNodePosition = () => {
|
|
290
|
+
if (!state.selectedNodeId || !state.nodePositions.has(state.selectedNodeId)) {
|
|
291
|
+
return
|
|
496
292
|
}
|
|
497
293
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
294
|
+
state.nodePositions.delete(state.selectedNodeId)
|
|
295
|
+
writeStoredNodePositions()
|
|
296
|
+
persistNodePositionsToServer()
|
|
297
|
+
scheduleChunkFetch({ fit: false })
|
|
501
298
|
}
|
|
502
299
|
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
300
|
+
const syncAgentInUrl = (agentId) => {
|
|
301
|
+
try {
|
|
302
|
+
const url = new URL(window.location.href)
|
|
303
|
+
if (agentId && agentId.trim().length > 0) {
|
|
304
|
+
url.searchParams.set('agent', agentId)
|
|
305
|
+
} else {
|
|
306
|
+
url.searchParams.delete('agent')
|
|
307
|
+
}
|
|
308
|
+
window.history.replaceState({}, '', url.toString())
|
|
309
|
+
} catch {}
|
|
512
310
|
}
|
|
513
311
|
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
: null
|
|
522
|
-
let best = hubCandidate ?? nodes[0]
|
|
523
|
-
let bestDegree = state.nodeDegrees.get(best.id) ?? 0
|
|
524
|
-
|
|
525
|
-
for (let index = 1; index < nodes.length; index += 1) {
|
|
526
|
-
const node = nodes[index]
|
|
527
|
-
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
528
|
-
if (degree > bestDegree) {
|
|
529
|
-
best = node
|
|
530
|
-
bestDegree = degree
|
|
312
|
+
const syncContextInUrl = (contextId) => {
|
|
313
|
+
try {
|
|
314
|
+
const url = new URL(window.location.href)
|
|
315
|
+
if (contextId && contextId.trim().length > 0) {
|
|
316
|
+
url.searchParams.set('context', contextId)
|
|
317
|
+
} else {
|
|
318
|
+
url.searchParams.delete('context')
|
|
531
319
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
return best
|
|
320
|
+
window.history.replaceState({}, '', url.toString())
|
|
321
|
+
} catch {}
|
|
535
322
|
}
|
|
536
323
|
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
324
|
+
const initialAgentFromUrl = (() => {
|
|
325
|
+
try {
|
|
326
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
327
|
+
const value = raw?.trim() ?? ''
|
|
328
|
+
return value.length > 0 ? value : ''
|
|
329
|
+
} catch {
|
|
330
|
+
return ''
|
|
540
331
|
}
|
|
332
|
+
})()
|
|
541
333
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
334
|
+
const initialContextFromUrl = (() => {
|
|
335
|
+
try {
|
|
336
|
+
const raw = new URL(window.location.href).searchParams.get('context')
|
|
337
|
+
const value = raw?.trim() ?? ''
|
|
338
|
+
return value.length > 0 ? value : ''
|
|
339
|
+
} catch {
|
|
340
|
+
return ''
|
|
550
341
|
}
|
|
342
|
+
})()
|
|
551
343
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
344
|
+
const scopeQuery = (separator = '?') => {
|
|
345
|
+
const params = new URLSearchParams()
|
|
346
|
+
if (state.agentId) {
|
|
347
|
+
params.set('agent', state.agentId)
|
|
348
|
+
}
|
|
349
|
+
if (state.contextId) {
|
|
350
|
+
params.set('context', state.contextId)
|
|
558
351
|
}
|
|
352
|
+
const query = params.toString()
|
|
559
353
|
|
|
560
|
-
|
|
561
|
-
const minimumDegree = Math.max(18, Math.sqrt(nodeCount) * 1.8)
|
|
562
|
-
const degreeRatio = degree / Math.max(nodeCount, 1)
|
|
563
|
-
return degree >= minimumDegree || degreeRatio >= 0.035
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const recomputeVisibility = () => {
|
|
567
|
-
const nodes = filteredNodes()
|
|
568
|
-
const ids = new Set(nodes.map(node => node.id))
|
|
569
|
-
const edges = state.edges.filter(edge => ids.has(edge.source) && edge.target && ids.has(edge.target))
|
|
570
|
-
const limitedEdges = state.nodes.length > largeGraphNodeThreshold
|
|
571
|
-
? [...edges]
|
|
572
|
-
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
573
|
-
.slice(0, largeGraphEdgeRenderLimit)
|
|
574
|
-
: edges
|
|
575
|
-
|
|
576
|
-
state.visibleNodes = nodes
|
|
577
|
-
state.visibleEdges = limitedEdges
|
|
578
|
-
state.visibleNodeSpatial = createSpatialIndex(nodes)
|
|
579
|
-
state.visibleEdgeByNode = createVisibleEdgeLookup(limitedEdges)
|
|
580
|
-
const primaryHub = rankedHubNodes()[0] ?? null
|
|
581
|
-
state.primaryHub = primaryHub
|
|
582
|
-
state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
|
|
583
|
-
const bounds = graphBounds(nodes)
|
|
584
|
-
const macroHub = isDominantHub(primaryHub, nodes.length) ? primaryHub : null
|
|
585
|
-
state.macroCenter = bounds
|
|
586
|
-
? {
|
|
587
|
-
x: macroHub ? macroHub.x : (bounds.minX + bounds.maxX) / 2,
|
|
588
|
-
y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
|
|
589
|
-
}
|
|
590
|
-
: { x: 0, y: 0 }
|
|
591
|
-
const ecosystemGraph = nodes.length > ecosystemActivationNodeThreshold
|
|
592
|
-
? buildEcosystemGraph(nodes, state.macroCenter, primaryHub)
|
|
593
|
-
: {
|
|
594
|
-
clusters: [],
|
|
595
|
-
clustersBySize: new Map(),
|
|
596
|
-
nodeClusterBySize: new Map(),
|
|
597
|
-
levelSizes: [],
|
|
598
|
-
expansionLevels: [],
|
|
599
|
-
baseSize: ecosystemLevelNodeCap,
|
|
600
|
-
hubCluster: null
|
|
601
|
-
}
|
|
602
|
-
state.ecosystemClusters = ecosystemGraph.clusters
|
|
603
|
-
state.ecosystemClustersBySize = ecosystemGraph.clustersBySize
|
|
604
|
-
state.ecosystemNodeClusterBySize = ecosystemGraph.nodeClusterBySize
|
|
605
|
-
state.ecosystemLevelSizes = ecosystemGraph.levelSizes
|
|
606
|
-
state.ecosystemExpansionLevels = ecosystemGraph.expansionLevels
|
|
607
|
-
state.ecosystemBaseSize = ecosystemGraph.baseSize
|
|
608
|
-
state.ecosystemHubCluster = ecosystemGraph.hubCluster
|
|
609
|
-
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
610
|
-
markRenderDirty()
|
|
354
|
+
return query ? separator + query : ''
|
|
611
355
|
}
|
|
612
356
|
|
|
613
|
-
const
|
|
614
|
-
const
|
|
615
|
-
|
|
357
|
+
const parseColor = (hex) => {
|
|
358
|
+
const normalized = String(hex || '#ffffff').replace('#', '')
|
|
359
|
+
const expanded = normalized.length === 3
|
|
360
|
+
? normalized.split('').map((char) => char + char).join('')
|
|
361
|
+
: normalized.padEnd(6, 'f')
|
|
362
|
+
const value = Number.parseInt(expanded, 16)
|
|
363
|
+
return [
|
|
364
|
+
((value >> 16) & 255) / 255,
|
|
365
|
+
((value >> 8) & 255) / 255,
|
|
366
|
+
(value & 255) / 255,
|
|
367
|
+
1
|
|
368
|
+
]
|
|
616
369
|
}
|
|
617
370
|
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
371
|
+
const graphTheme = {
|
|
372
|
+
node: parseColor('#5aa8ff'),
|
|
373
|
+
nodeCluster: parseColor('#3f7fbd'),
|
|
374
|
+
nodeHighlight: parseColor('#ffcb67'),
|
|
375
|
+
nodeSelected: parseColor('#edf4ff'),
|
|
376
|
+
nodePalette: [
|
|
377
|
+
parseColor('#5aa8ff'),
|
|
378
|
+
parseColor('#5ecf92'),
|
|
379
|
+
parseColor('#ffb65c'),
|
|
380
|
+
parseColor('#ff7dac'),
|
|
381
|
+
parseColor('#a88fff'),
|
|
382
|
+
parseColor('#59d0dd'),
|
|
383
|
+
parseColor('#ff8f6a'),
|
|
384
|
+
parseColor('#a4b3c3'),
|
|
385
|
+
parseColor('#c9945f'),
|
|
386
|
+
parseColor('#7cb6ff')
|
|
387
|
+
],
|
|
388
|
+
edge: [0.59, 0.71, 0.83, 0.14],
|
|
389
|
+
edgeHeavy: [0.59, 0.71, 0.83, 0.3],
|
|
390
|
+
clear: parseColor('#08131d')
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const segmentPalette = ['#5aa8ff', '#5ecf92', '#ffb65c', '#ff7dac', '#a88fff', '#59d0dd', '#ff8f6a', '#a4b3c3', '#c9945f', '#7cb6ff']
|
|
394
|
+
|
|
395
|
+
const segmentColorIndex = (segment) => {
|
|
396
|
+
const value = String(segment || '')
|
|
397
|
+
let hash = 0
|
|
398
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
399
|
+
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
|
|
400
|
+
}
|
|
401
|
+
return Math.abs(hash) % segmentPalette.length
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const segmentColor = (segment) => segmentPalette[segmentColorIndex(segment)] || segmentPalette[0]
|
|
405
|
+
const nodeKind = (node) => node?.[6] === 'cluster' ? 'cluster' : 'node'
|
|
406
|
+
const isRealGraphNode = (node) => nodeKind(node) === 'node'
|
|
407
|
+
|
|
408
|
+
const clampScale = (scale) => Math.max(zoomRange.min, Math.min(zoomRange.max, scale))
|
|
409
|
+
|
|
410
|
+
const getZoomNodeBudget = () => {
|
|
411
|
+
const scale = state.camera.scale
|
|
412
|
+
if (scale < 0.06) return 900
|
|
413
|
+
if (scale < 0.12) return 1600
|
|
414
|
+
if (scale < 0.24) return 2600
|
|
415
|
+
if (scale < 0.7) return 4000
|
|
416
|
+
return 6000
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const getZoomEdgeBudget = () => {
|
|
420
|
+
const scale = state.camera.scale
|
|
421
|
+
if (scale < 0.06) return 2000
|
|
422
|
+
if (scale < 0.12) return 4800
|
|
423
|
+
if (scale < 0.24) return 9000
|
|
424
|
+
if (scale < 0.7) return 15000
|
|
425
|
+
return 26000
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const zoomDetailBand = () => {
|
|
429
|
+
const scale = state.camera.scale
|
|
430
|
+
if (scale < 0.06) return 'far'
|
|
431
|
+
if (scale < 0.12) return 'wide'
|
|
432
|
+
if (scale < 0.24) return 'mid'
|
|
433
|
+
if (scale < 0.7) return 'near'
|
|
434
|
+
return 'detail'
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const graphStreamRequestKey = ({ x, y, w, h }) => {
|
|
438
|
+
const grid = Math.max(80, Math.min(720, Math.max(w, h) / 6))
|
|
439
|
+
return [
|
|
440
|
+
state.agentId || '*',
|
|
441
|
+
state.contextId || '*',
|
|
442
|
+
zoomDetailBand(),
|
|
443
|
+
getZoomNodeBudget(),
|
|
444
|
+
getZoomEdgeBudget(),
|
|
445
|
+
Math.round(x / grid),
|
|
446
|
+
Math.round(y / grid),
|
|
447
|
+
Math.round(w / grid),
|
|
448
|
+
Math.round(h / grid)
|
|
449
|
+
].join(':')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const screenToWorld = (screenX, screenY) => ({
|
|
453
|
+
x: (screenX - state.camera.x) / state.camera.scale,
|
|
454
|
+
y: (screenY - state.camera.y) / state.camera.scale
|
|
455
|
+
})
|
|
622
456
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
457
|
+
const worldToScreen = (x, y) => ({
|
|
458
|
+
x: x * state.camera.scale + state.camera.x,
|
|
459
|
+
y: y * state.camera.scale + state.camera.y
|
|
460
|
+
})
|
|
627
461
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
462
|
+
const spatialIndexKey = () => [
|
|
463
|
+
state.graphSignature,
|
|
464
|
+
state.camera.x.toFixed(1),
|
|
465
|
+
state.camera.y.toFixed(1),
|
|
466
|
+
state.camera.scale.toFixed(4),
|
|
467
|
+
normalizeList(state.chunk.nodes).length
|
|
468
|
+
].join(':')
|
|
469
|
+
|
|
470
|
+
const rebuildSpatialIndex = () => {
|
|
471
|
+
const key = spatialIndexKey()
|
|
472
|
+
if (state.spatialIndex.key === key) {
|
|
473
|
+
return
|
|
474
|
+
}
|
|
632
475
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
const
|
|
476
|
+
const cellSize = 44
|
|
477
|
+
const cells = new Map()
|
|
478
|
+
normalizeList(state.chunk.nodes).forEach((node) => {
|
|
479
|
+
const id = typeof node?.[0] === 'string' ? node[0] : ''
|
|
480
|
+
if (!id) return
|
|
481
|
+
const x = Number(node?.[2])
|
|
482
|
+
const y = Number(node?.[3])
|
|
483
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
484
|
+
const point = worldToScreen(x, y)
|
|
485
|
+
const cellX = Math.floor(point.x / cellSize)
|
|
486
|
+
const cellY = Math.floor(point.y / cellSize)
|
|
487
|
+
const key = cellX + ',' + cellY
|
|
488
|
+
const bucket = cells.get(key)
|
|
639
489
|
if (bucket) {
|
|
640
490
|
bucket.push(node)
|
|
641
|
-
|
|
491
|
+
return
|
|
642
492
|
}
|
|
643
|
-
|
|
644
|
-
}
|
|
493
|
+
cells.set(key, [node])
|
|
494
|
+
})
|
|
645
495
|
|
|
646
|
-
|
|
647
|
-
cellSize,
|
|
648
|
-
minX: bounds.minX,
|
|
649
|
-
minY: bounds.minY,
|
|
650
|
-
maxX: bounds.maxX,
|
|
651
|
-
maxY: bounds.maxY,
|
|
652
|
-
buckets
|
|
653
|
-
}
|
|
496
|
+
state.spatialIndex = { key, cells }
|
|
654
497
|
}
|
|
655
498
|
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const spatial = state.visibleNodeSpatial
|
|
662
|
-
if (!spatial || spatial.buckets.size === 0) {
|
|
663
|
-
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
const minCellX = Math.floor((viewport.minX - spatial.minX) / spatial.cellSize)
|
|
667
|
-
const maxCellX = Math.floor((viewport.maxX - spatial.minX) / spatial.cellSize)
|
|
668
|
-
const minCellY = Math.floor((viewport.minY - spatial.minY) / spatial.cellSize)
|
|
669
|
-
const maxCellY = Math.floor((viewport.maxY - spatial.minY) / spatial.cellSize)
|
|
499
|
+
const spatialCandidates = (screenX, screenY) => {
|
|
500
|
+
rebuildSpatialIndex()
|
|
501
|
+
const cellSize = 44
|
|
502
|
+
const cellX = Math.floor(screenX / cellSize)
|
|
503
|
+
const cellY = Math.floor(screenY / cellSize)
|
|
670
504
|
const nodes = []
|
|
671
505
|
|
|
672
|
-
for (let
|
|
673
|
-
for (let
|
|
674
|
-
|
|
675
|
-
if (!bucket) continue
|
|
676
|
-
|
|
677
|
-
for (let index = 0; index < bucket.length; index += 1) {
|
|
678
|
-
const node = bucket[index]
|
|
679
|
-
if (isNodeInViewport(node, viewport)) {
|
|
680
|
-
nodes.push(node)
|
|
681
|
-
}
|
|
682
|
-
}
|
|
506
|
+
for (let y = cellY - 1; y <= cellY + 1; y += 1) {
|
|
507
|
+
for (let x = cellX - 1; x <= cellX + 1; x += 1) {
|
|
508
|
+
nodes.push(...(state.spatialIndex.cells.get(x + ',' + y) ?? []))
|
|
683
509
|
}
|
|
684
510
|
}
|
|
685
511
|
|
|
686
512
|
return nodes
|
|
687
513
|
}
|
|
688
514
|
|
|
689
|
-
const
|
|
690
|
-
const lookup = new Map()
|
|
515
|
+
const nodeByIdFromChunk = () => new Map(normalizeList(state.chunk.nodes).map((node) => [node[0], node]))
|
|
691
516
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
lookup.set(edge.source, [edge])
|
|
701
|
-
}
|
|
517
|
+
const linkedNodeIds = (nodeId) => {
|
|
518
|
+
const ids = new Set(nodeId ? [nodeId] : [])
|
|
519
|
+
normalizeList(state.chunk.edges).forEach((edge) => {
|
|
520
|
+
if (edge?.[0] === nodeId && typeof edge?.[1] === 'string') ids.add(edge[1])
|
|
521
|
+
if (edge?.[1] === nodeId && typeof edge?.[0] === 'string') ids.add(edge[0])
|
|
522
|
+
})
|
|
523
|
+
return ids
|
|
524
|
+
}
|
|
702
525
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
lookup.set(edge.target, [edge])
|
|
708
|
-
}
|
|
526
|
+
const setFocusedNodeIds = (ids) => {
|
|
527
|
+
state.focusedNodeIds = ids
|
|
528
|
+
if (state.renderWorker && state.workerReady) {
|
|
529
|
+
state.renderWorker.postMessage({ type: 'focus', ids: Array.from(ids) })
|
|
709
530
|
}
|
|
710
|
-
|
|
711
|
-
return lookup
|
|
531
|
+
updateGraphOverlays()
|
|
712
532
|
}
|
|
713
533
|
|
|
714
|
-
const
|
|
715
|
-
if (
|
|
716
|
-
return
|
|
534
|
+
const drawFallback = () => {
|
|
535
|
+
if (state.rendererMode !== 'fallback') {
|
|
536
|
+
return
|
|
717
537
|
}
|
|
718
|
-
|
|
719
|
-
|
|
538
|
+
ctx2dFallback = ctx2dFallback ?? canvas.getContext('2d')
|
|
539
|
+
if (!ctx2dFallback) {
|
|
540
|
+
return
|
|
720
541
|
}
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
542
|
+
const width = state.viewport.width
|
|
543
|
+
const height = state.viewport.height
|
|
544
|
+
const ratio = state.viewport.ratio
|
|
545
|
+
canvas.width = Math.floor(width * ratio)
|
|
546
|
+
canvas.height = Math.floor(height * ratio)
|
|
547
|
+
ctx2dFallback.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
548
|
+
ctx2dFallback.fillStyle = '#08131d'
|
|
549
|
+
ctx2dFallback.fillRect(0, 0, width, height)
|
|
727
550
|
|
|
728
|
-
const
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
return String(left.title || left.id).localeCompare(String(right.title || right.id))
|
|
735
|
-
}
|
|
551
|
+
const nodes = Array.isArray(state.chunk.nodes) ? state.chunk.nodes : []
|
|
552
|
+
const edges = Array.isArray(state.chunk.edges) ? state.chunk.edges : []
|
|
553
|
+
const nodeById = new Map()
|
|
554
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
555
|
+
nodeById.set(nodes[i][0], nodes[i])
|
|
556
|
+
}
|
|
736
557
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
let
|
|
558
|
+
ctx2dFallback.strokeStyle = 'rgba(151,181,212,0.18)'
|
|
559
|
+
ctx2dFallback.lineWidth = 1
|
|
560
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
561
|
+
const edge = edges[i]
|
|
562
|
+
const source = nodeById.get(edge[0])
|
|
563
|
+
const target = nodeById.get(edge[1])
|
|
564
|
+
if (!source || !target) continue
|
|
565
|
+
const from = worldToScreen(source[2], source[3])
|
|
566
|
+
const to = worldToScreen(target[2], target[3])
|
|
567
|
+
ctx2dFallback.beginPath()
|
|
568
|
+
ctx2dFallback.moveTo(from.x, from.y)
|
|
569
|
+
ctx2dFallback.lineTo(to.x, to.y)
|
|
570
|
+
ctx2dFallback.stroke()
|
|
571
|
+
}
|
|
740
572
|
|
|
741
|
-
for (let
|
|
742
|
-
const node = nodes[
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
573
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
574
|
+
const node = nodes[i]
|
|
575
|
+
const p = worldToScreen(node[2], node[3])
|
|
576
|
+
const selected = state.selectedNodeId === node[0]
|
|
577
|
+
const color = segmentColor(node[5] || node[4] || node[1])
|
|
578
|
+
const radius = Math.max(3.2, Math.min(16.5, 5 + node[7] * 0.65))
|
|
579
|
+
|
|
580
|
+
ctx2dFallback.beginPath()
|
|
581
|
+
ctx2dFallback.fillStyle = selected ? '#edf4ff' : color
|
|
582
|
+
ctx2dFallback.arc(p.x, p.y, radius, 0, Math.PI * 2)
|
|
583
|
+
ctx2dFallback.fill()
|
|
748
584
|
}
|
|
749
585
|
|
|
750
|
-
|
|
586
|
+
ctx2dFallback.fillStyle = '#97a9bd'
|
|
587
|
+
ctx2dFallback.font = '12px Inter, system-ui, sans-serif'
|
|
588
|
+
ctx2dFallback.textAlign = 'center'
|
|
589
|
+
ctx2dFallback.fillText('Fallback canvas mode', Math.max(width, 320) / 2, 24)
|
|
751
590
|
}
|
|
752
591
|
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if (size >= 120) return 64
|
|
757
|
-
if (size >= 48) return 34
|
|
758
|
-
if (size >= 18) return 18
|
|
759
|
-
if (size >= 8) return 11
|
|
760
|
-
return 7
|
|
592
|
+
const updateTotals = () => {
|
|
593
|
+
elements.nodeCount.textContent = String(state.totals.nodes)
|
|
594
|
+
elements.edgeCount.textContent = String(state.totals.edges)
|
|
761
595
|
}
|
|
762
596
|
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
597
|
+
const updateTagCount = () => {
|
|
598
|
+
elements.tagCount.textContent = state.graphMode === 'far' ? 'clusters' : state.graphMode
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const updateWorkerCamera = () => {
|
|
602
|
+
updateGraphOverlays()
|
|
603
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
if (state.cameraSyncScheduled) {
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
state.cameraSyncScheduled = true
|
|
610
|
+
requestAnimationFrame(() => {
|
|
611
|
+
state.cameraSyncScheduled = false
|
|
612
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
613
|
+
return
|
|
775
614
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
const buildEcosystemExpansionLevels = (levelSizes, nodeCount) => {
|
|
782
|
-
if (levelSizes.length <= 1) {
|
|
783
|
-
return []
|
|
784
|
-
}
|
|
785
|
-
const maxScale = nodeCount > massiveGraphNodeThreshold
|
|
786
|
-
? massiveEcosystemClusterScaleThreshold
|
|
787
|
-
: ecosystemClusterScaleThreshold
|
|
788
|
-
const startScale = 0.04
|
|
789
|
-
const transitionCount = levelSizes.length - 1
|
|
790
|
-
const usableScale = Math.max(0.08, maxScale - startScale)
|
|
791
|
-
const step = usableScale / transitionCount
|
|
792
|
-
const levels = []
|
|
793
|
-
for (let index = 0; index < transitionCount; index += 1) {
|
|
794
|
-
const start = startScale + step * index * 0.72
|
|
795
|
-
const end = Math.min(maxScale, start + step * 1.85)
|
|
796
|
-
levels.push({
|
|
797
|
-
parentSize: levelSizes[index],
|
|
798
|
-
childSize: levelSizes[index + 1],
|
|
799
|
-
start,
|
|
800
|
-
end
|
|
615
|
+
state.renderWorker.postMessage({
|
|
616
|
+
type: 'camera',
|
|
617
|
+
camera: state.camera
|
|
801
618
|
})
|
|
802
|
-
}
|
|
803
|
-
return levels
|
|
619
|
+
})
|
|
804
620
|
}
|
|
805
621
|
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
const angle = index * 2.399963229728653
|
|
811
|
-
const radius = spacing * Math.sqrt(index + 1)
|
|
812
|
-
return {
|
|
813
|
-
x: center.x + Math.cos(angle) * radius,
|
|
814
|
-
y: center.y + Math.sin(angle) * radius
|
|
622
|
+
const updateWorkerSize = () => {
|
|
623
|
+
updateGraphOverlays()
|
|
624
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
625
|
+
return
|
|
815
626
|
}
|
|
627
|
+
state.renderWorker.postMessage({
|
|
628
|
+
type: 'resize',
|
|
629
|
+
width: state.viewport.width,
|
|
630
|
+
height: state.viewport.height,
|
|
631
|
+
devicePixelRatio: state.viewport.ratio
|
|
632
|
+
})
|
|
816
633
|
}
|
|
817
634
|
|
|
818
|
-
const
|
|
819
|
-
const count = Math.max(nodes.length, 1)
|
|
820
|
-
const representative = selectEcosystemRepresentative(nodes)
|
|
635
|
+
const normalizeList = (items) => Array.isArray(items) ? items : []
|
|
821
636
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
nodeIds: nodes.map(node => node.id),
|
|
828
|
-
representative,
|
|
829
|
-
label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
const buildEcosystemHubCluster = (hub, center) => hub
|
|
834
|
-
? {
|
|
835
|
-
id: 'ecosystem-hub',
|
|
836
|
-
x: center.x,
|
|
837
|
-
y: center.y,
|
|
838
|
-
count: 1,
|
|
839
|
-
size: 1,
|
|
840
|
-
nodeIds: [hub.id],
|
|
841
|
-
representative: hub,
|
|
842
|
-
label: hub.title || 'Memory Hub',
|
|
843
|
-
parentId: null,
|
|
844
|
-
parentX: null,
|
|
845
|
-
parentY: null,
|
|
846
|
-
isHub: true
|
|
847
|
-
}
|
|
848
|
-
: null
|
|
849
|
-
|
|
850
|
-
const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
|
|
851
|
-
const clusters = []
|
|
852
|
-
const clusterByNodeId = new Map()
|
|
853
|
-
const parentChildIndex = new Map()
|
|
854
|
-
|
|
855
|
-
for (let offset = 0; offset < sortedNodes.length; offset += size) {
|
|
856
|
-
const clusterNodes = sortedNodes.slice(offset, offset + size)
|
|
857
|
-
const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
|
|
858
|
-
const siblingIndex = parentCluster
|
|
859
|
-
? (parentChildIndex.get(parentCluster.id) ?? 0)
|
|
860
|
-
: clusters.length
|
|
861
|
-
if (parentCluster) {
|
|
862
|
-
parentChildIndex.set(parentCluster.id, siblingIndex + 1)
|
|
863
|
-
}
|
|
864
|
-
const point = parentCluster
|
|
865
|
-
? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
|
|
866
|
-
: ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
|
|
867
|
-
const cluster = {
|
|
868
|
-
...buildEcosystemCluster(clusterNodes, clusters.length, point),
|
|
869
|
-
id: 'ecosystem-' + size + '-' + clusters.length,
|
|
870
|
-
size,
|
|
871
|
-
parentId: parentCluster?.id ?? null,
|
|
872
|
-
parentX: parentCluster?.x ?? null,
|
|
873
|
-
parentY: parentCluster?.y ?? null
|
|
874
|
-
}
|
|
875
|
-
clusters.push(cluster)
|
|
876
|
-
for (let index = 0; index < clusterNodes.length; index += 1) {
|
|
877
|
-
clusterByNodeId.set(clusterNodes[index].id, cluster)
|
|
878
|
-
}
|
|
637
|
+
const applyManualNodePositions = (nodes) => normalizeList(nodes).map((node) => {
|
|
638
|
+
const id = typeof node?.[0] === 'string' ? node[0] : ''
|
|
639
|
+
const position = id ? state.nodePositions.get(id) : null
|
|
640
|
+
if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) {
|
|
641
|
+
return node
|
|
879
642
|
}
|
|
880
643
|
|
|
881
|
-
|
|
882
|
-
|
|
644
|
+
const next = [...node]
|
|
645
|
+
next[2] = position.x
|
|
646
|
+
next[3] = position.y
|
|
647
|
+
return next
|
|
648
|
+
})
|
|
883
649
|
|
|
884
|
-
const
|
|
885
|
-
if (
|
|
886
|
-
return
|
|
887
|
-
clusters: [],
|
|
888
|
-
clustersBySize: new Map(),
|
|
889
|
-
nodeClusterBySize: new Map(),
|
|
890
|
-
levelSizes: [],
|
|
891
|
-
expansionLevels: [],
|
|
892
|
-
baseSize: ecosystemLevelNodeCap,
|
|
893
|
-
hubCluster: null
|
|
894
|
-
}
|
|
650
|
+
const updateNodePositionInChunk = (nodeId, x, y) => {
|
|
651
|
+
if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
652
|
+
return
|
|
895
653
|
}
|
|
896
654
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
.
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
for (let index = 0; index < levelSizes.length; index += 1) {
|
|
909
|
-
const size = levelSizes[index]
|
|
910
|
-
const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
|
|
911
|
-
clustersBySize.set(size, level.clusters)
|
|
912
|
-
nodeClusterBySize.set(size, level.clusterByNodeId)
|
|
913
|
-
parentLookup = level.clusterByNodeId
|
|
655
|
+
state.chunk = {
|
|
656
|
+
...state.chunk,
|
|
657
|
+
nodes: normalizeList(state.chunk.nodes).map((node) => {
|
|
658
|
+
if (node?.[0] !== nodeId) {
|
|
659
|
+
return node
|
|
660
|
+
}
|
|
661
|
+
const next = [...node]
|
|
662
|
+
next[2] = x
|
|
663
|
+
next[3] = y
|
|
664
|
+
return next
|
|
665
|
+
})
|
|
914
666
|
}
|
|
667
|
+
state.spatialIndex.key = ''
|
|
915
668
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
clustersBySize,
|
|
919
|
-
nodeClusterBySize,
|
|
920
|
-
levelSizes,
|
|
921
|
-
expansionLevels,
|
|
922
|
-
baseSize,
|
|
923
|
-
hubCluster
|
|
669
|
+
if (state.renderWorker && state.workerReady) {
|
|
670
|
+
state.renderWorker.postMessage({ type: 'move-node', id: nodeId, x, y })
|
|
924
671
|
}
|
|
672
|
+
updateGraphOverlays()
|
|
925
673
|
}
|
|
926
674
|
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
675
|
+
const focusNodeInViewport = (nodeId, nextScale = null) => {
|
|
676
|
+
const node = nodeByIdFromChunk().get(nodeId)
|
|
677
|
+
if (!node) {
|
|
678
|
+
return false
|
|
679
|
+
}
|
|
932
680
|
|
|
933
|
-
const
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
const ecosystemFocusPoint = () => {
|
|
939
|
-
const cursorPoint = cursorWorldPoint()
|
|
940
|
-
if (cursorPoint) {
|
|
941
|
-
return cursorPoint
|
|
942
|
-
}
|
|
943
|
-
const now = performance.now()
|
|
944
|
-
if (now - state.lastZoomFocus.at <= 1800) {
|
|
945
|
-
return { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
681
|
+
const x = Number(node[2])
|
|
682
|
+
const y = Number(node[3])
|
|
683
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
684
|
+
return false
|
|
946
685
|
}
|
|
947
|
-
return viewportCenterWorldPoint()
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
|
|
951
|
-
clusters
|
|
952
|
-
.map(cluster => ({
|
|
953
|
-
cluster,
|
|
954
|
-
distance: Math.max(
|
|
955
|
-
0,
|
|
956
|
-
Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
|
|
957
|
-
clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
|
|
958
|
-
)
|
|
959
|
-
}))
|
|
960
|
-
.sort((left, right) => left.distance - right.distance)
|
|
961
|
-
.slice(0, limit)
|
|
962
|
-
.map(item => item.cluster.id)
|
|
963
|
-
|
|
964
|
-
const smoothStep = value => {
|
|
965
|
-
const clamped = Math.max(0, Math.min(1, value))
|
|
966
|
-
return clamped * clamped * (3 - clamped * 2)
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
const zoomProgress = (scale, start, end) =>
|
|
970
|
-
smoothStep((scale - start) / Math.max(end - start, 0.0001))
|
|
971
686
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
|
|
975
|
-
return 0.12 + curve * 0.88
|
|
687
|
+
if (Number.isFinite(nextScale)) {
|
|
688
|
+
state.camera.scale = clampScale(Number(nextScale))
|
|
976
689
|
}
|
|
977
|
-
|
|
690
|
+
state.camera.x = state.viewport.width / 2 - x * state.camera.scale
|
|
691
|
+
state.camera.y = state.viewport.height / 2 - y * state.camera.scale
|
|
692
|
+
updateWorkerCamera()
|
|
693
|
+
scheduleChunkFetch()
|
|
694
|
+
return true
|
|
978
695
|
}
|
|
979
696
|
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
return 0.22 + eased * 0.78
|
|
697
|
+
const showTooltip = (node, pointer) => {
|
|
698
|
+
if (!elements.tooltip || !node) {
|
|
699
|
+
return
|
|
984
700
|
}
|
|
985
|
-
return eased
|
|
986
|
-
}
|
|
987
701
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
|
|
996
|
-
const visibleChildClusters = childClusters
|
|
997
|
-
.filter(cluster => expandedParentIds.has(cluster.parentId))
|
|
998
|
-
.map(cluster => spreadChildClusterFromParent(cluster, childSize, progress, spread))
|
|
999
|
-
.filter(cluster => isClusterInViewport(cluster, viewport))
|
|
702
|
+
elements.tooltip.hidden = false
|
|
703
|
+
elements.tooltip.innerHTML =
|
|
704
|
+
'<strong>' + escapeHtml(node[1] || node[0]) + '</strong>' +
|
|
705
|
+
'<small>' + escapeHtml(node[4] || node[5] || '') + '</small>'
|
|
706
|
+
elements.tooltip.style.left = Math.min(state.viewport.width - 24, pointer.x + 14) + 'px'
|
|
707
|
+
elements.tooltip.style.top = Math.min(state.viewport.height - 24, pointer.y + 14) + 'px'
|
|
708
|
+
}
|
|
1000
709
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
710
|
+
const hideTooltip = () => {
|
|
711
|
+
if (elements.tooltip) {
|
|
712
|
+
elements.tooltip.hidden = true
|
|
1004
713
|
}
|
|
1005
714
|
}
|
|
1006
715
|
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
716
|
+
const labelCandidates = () => {
|
|
717
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
718
|
+
const visible = nodes.filter((node) => {
|
|
719
|
+
const x = Number(node?.[2])
|
|
720
|
+
const y = Number(node?.[3])
|
|
721
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false
|
|
722
|
+
const point = worldToScreen(x, y)
|
|
723
|
+
return point.x >= -80 && point.x <= state.viewport.width + 80 && point.y >= -80 && point.y <= state.viewport.height + 80
|
|
724
|
+
})
|
|
725
|
+
const shouldShowMany = state.camera.scale >= 0.72 || visible.length <= 120
|
|
726
|
+
const focused = state.focusedNodeIds
|
|
1014
727
|
|
|
1015
|
-
return
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
728
|
+
return visible
|
|
729
|
+
.filter((node) => shouldShowMany || focused.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId || Number(node?.[7]) > 5.5)
|
|
730
|
+
.sort((left, right) => {
|
|
731
|
+
const leftFocused = focused.has(left[0]) || left[0] === state.hoveredNodeId || left[0] === state.selectedNodeId ? 1 : 0
|
|
732
|
+
const rightFocused = focused.has(right[0]) || right[0] === state.hoveredNodeId || right[0] === state.selectedNodeId ? 1 : 0
|
|
733
|
+
if (rightFocused !== leftFocused) return rightFocused - leftFocused
|
|
734
|
+
return Number(right?.[7] ?? 0) - Number(left?.[7] ?? 0)
|
|
735
|
+
})
|
|
736
|
+
.slice(0, state.camera.scale >= 0.72 ? 160 : 48)
|
|
1021
737
|
}
|
|
1022
738
|
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
|
|
1027
|
-
const visibleClusters = [...visibleBaseClusters]
|
|
1028
|
-
|
|
1029
|
-
for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
|
|
1030
|
-
const level = state.ecosystemExpansionLevels[index]
|
|
1031
|
-
const parentClusters = visibleClusters.filter(cluster => cluster.size === level.parentSize)
|
|
1032
|
-
if (parentClusters.length === 0) {
|
|
1033
|
-
continue
|
|
1034
|
-
}
|
|
1035
|
-
const progress = zoomProgress(state.transform.scale, level.start, level.end)
|
|
1036
|
-
const spread = semanticZoomSpread(progress, level.childSize)
|
|
1037
|
-
const expansion = expandFocusedClusters(parentClusters, level.childSize, progress, spread, viewport)
|
|
1038
|
-
visibleClusters.push(...expansion.childClusters)
|
|
739
|
+
const drawLabels = () => {
|
|
740
|
+
if (!elements.labels) {
|
|
741
|
+
return
|
|
1039
742
|
}
|
|
1040
743
|
|
|
1041
|
-
|
|
744
|
+
elements.labels.innerHTML = labelCandidates().map((node) => {
|
|
745
|
+
const point = worldToScreen(Number(node[2]), Number(node[3]))
|
|
746
|
+
const focused = state.focusedNodeIds.has(node[0]) || node[0] === state.hoveredNodeId || node[0] === state.selectedNodeId
|
|
747
|
+
return '<span class="graph-label' + (focused ? ' is-focused' : '') + '" style="left:' +
|
|
748
|
+
point.x.toFixed(1) + 'px;top:' + point.y.toFixed(1) + 'px">' + escapeHtml(node[1] || node[0]) + '</span>'
|
|
749
|
+
}).join('')
|
|
1042
750
|
}
|
|
1043
751
|
|
|
1044
|
-
const
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
if (cluster.isHub || !cluster.parentId) {
|
|
1049
|
-
continue
|
|
1050
|
-
}
|
|
1051
|
-
const siblings = byParent.get(cluster.parentId)
|
|
1052
|
-
if (siblings) {
|
|
1053
|
-
siblings.push(cluster)
|
|
1054
|
-
} else {
|
|
1055
|
-
byParent.set(cluster.parentId, [cluster])
|
|
1056
|
-
}
|
|
752
|
+
const drawMiniMap = () => {
|
|
753
|
+
const miniMap = elements.miniMap
|
|
754
|
+
if (!(miniMap instanceof HTMLCanvasElement)) {
|
|
755
|
+
return
|
|
1057
756
|
}
|
|
1058
|
-
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
.sort((left, right) => Math.atan2(left.y - (left.parentY ?? 0), left.x - (left.parentX ?? 0)) - Math.atan2(right.y - (right.parentY ?? 0), right.x - (right.parentX ?? 0)))
|
|
1063
|
-
for (let index = 0; index < ordered.length && edges.length < ecosystemSiblingEdgeLimit; index += 1) {
|
|
1064
|
-
const sourceCluster = ordered[index]
|
|
1065
|
-
const targetCluster = ordered[(index + 1) % ordered.length]
|
|
1066
|
-
if (!targetCluster || sourceCluster.id === targetCluster.id) {
|
|
1067
|
-
continue
|
|
1068
|
-
}
|
|
1069
|
-
const orderedIds = sourceCluster.id < targetCluster.id
|
|
1070
|
-
? [sourceCluster.id, targetCluster.id]
|
|
1071
|
-
: [targetCluster.id, sourceCluster.id]
|
|
1072
|
-
const key = orderedIds.join(':')
|
|
1073
|
-
if (existingEdges.has(key)) {
|
|
1074
|
-
continue
|
|
1075
|
-
}
|
|
1076
|
-
const edge = {
|
|
1077
|
-
id: key,
|
|
1078
|
-
sourceCluster,
|
|
1079
|
-
targetCluster,
|
|
1080
|
-
weight: 0.7,
|
|
1081
|
-
inferred: true
|
|
1082
|
-
}
|
|
1083
|
-
existingEdges.set(key, edge)
|
|
1084
|
-
edges.push(edge)
|
|
1085
|
-
}
|
|
757
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
758
|
+
const ctx = miniMap.getContext('2d')
|
|
759
|
+
if (!ctx || nodes.length === 0) {
|
|
760
|
+
return
|
|
1086
761
|
}
|
|
1087
762
|
|
|
1088
|
-
|
|
763
|
+
const ratio = window.devicePixelRatio || 1
|
|
764
|
+
const width = miniMap.clientWidth || 180
|
|
765
|
+
const height = miniMap.clientHeight || 120
|
|
766
|
+
miniMap.width = Math.floor(width * ratio)
|
|
767
|
+
miniMap.height = Math.floor(height * ratio)
|
|
768
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
769
|
+
ctx.clearRect(0, 0, width, height)
|
|
770
|
+
ctx.fillStyle = 'rgba(8, 19, 29, 0.88)'
|
|
771
|
+
ctx.fillRect(0, 0, width, height)
|
|
772
|
+
|
|
773
|
+
const xs = nodes.map((node) => Number(node[2])).filter(Number.isFinite)
|
|
774
|
+
const ys = nodes.map((node) => Number(node[3])).filter(Number.isFinite)
|
|
775
|
+
const minX = Math.min(...xs)
|
|
776
|
+
const maxX = Math.max(...xs)
|
|
777
|
+
const minY = Math.min(...ys)
|
|
778
|
+
const maxY = Math.max(...ys)
|
|
779
|
+
const graphWidth = Math.max(1, maxX - minX)
|
|
780
|
+
const graphHeight = Math.max(1, maxY - minY)
|
|
781
|
+
const scale = Math.min((width - 18) / graphWidth, (height - 18) / graphHeight)
|
|
782
|
+
const offsetX = (width - graphWidth * scale) / 2
|
|
783
|
+
const offsetY = (height - graphHeight * scale) / 2
|
|
784
|
+
const toMini = (x, y) => ({
|
|
785
|
+
x: offsetX + (x - minX) * scale,
|
|
786
|
+
y: offsetY + (y - minY) * scale
|
|
787
|
+
})
|
|
788
|
+
state.miniMapView = { minX, minY, scale, offsetX, offsetY, width, height }
|
|
789
|
+
|
|
790
|
+
ctx.fillStyle = 'rgba(90, 168, 255, 0.62)'
|
|
791
|
+
nodes.forEach((node) => {
|
|
792
|
+
const point = toMini(Number(node[2]), Number(node[3]))
|
|
793
|
+
ctx.fillRect(point.x - 1, point.y - 1, 2, 2)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
const worldTopLeft = screenToWorld(0, 0)
|
|
797
|
+
const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
|
|
798
|
+
const topLeft = toMini(Math.min(worldTopLeft.x, worldBottomRight.x), Math.min(worldTopLeft.y, worldBottomRight.y))
|
|
799
|
+
const bottomRight = toMini(Math.max(worldTopLeft.x, worldBottomRight.x), Math.max(worldTopLeft.y, worldBottomRight.y))
|
|
800
|
+
ctx.strokeStyle = 'rgba(90, 168, 255, 0.86)'
|
|
801
|
+
ctx.lineWidth = 1
|
|
802
|
+
ctx.strokeRect(topLeft.x, topLeft.y, Math.max(3, bottomRight.x - topLeft.x), Math.max(3, bottomRight.y - topLeft.y))
|
|
1089
803
|
}
|
|
1090
804
|
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
for (let index = 0; index < edgeClusters.length; index += 1) {
|
|
1097
|
-
const cluster = edgeClusters[index]
|
|
1098
|
-
if (!cluster.size || cluster.isHub) continue
|
|
1099
|
-
if (!levelsBySize.some(level => level.size === cluster.size)) {
|
|
1100
|
-
levelsBySize.push({
|
|
1101
|
-
size: cluster.size,
|
|
1102
|
-
lookup: state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map()
|
|
1103
|
-
})
|
|
1104
|
-
}
|
|
805
|
+
const shouldDeferGraphOverlays = () => state.pointer.down || performance.now() - state.lastWheelAt < 150
|
|
806
|
+
|
|
807
|
+
const updateGraphOverlays = () => {
|
|
808
|
+
if (state.overlayScheduled) {
|
|
809
|
+
return
|
|
1105
810
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
811
|
+
state.overlayScheduled = true
|
|
812
|
+
requestAnimationFrame(() => {
|
|
813
|
+
state.overlayScheduled = false
|
|
814
|
+
if (shouldDeferGraphOverlays()) {
|
|
815
|
+
elements.labels?.classList.add('is-stale')
|
|
816
|
+
if (!state.overlayIdleTimer) {
|
|
817
|
+
state.overlayIdleTimer = setTimeout(() => {
|
|
818
|
+
state.overlayIdleTimer = null
|
|
819
|
+
updateGraphOverlays()
|
|
820
|
+
}, 170)
|
|
1116
821
|
}
|
|
822
|
+
return
|
|
1117
823
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const edge = state.visibleEdges[index]
|
|
1124
|
-
const sourceCluster = resolveClusterForNode(edge.source)
|
|
1125
|
-
const targetCluster = resolveClusterForNode(edge.target)
|
|
1126
|
-
if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
|
|
1127
|
-
continue
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
const orderedIds = sourceCluster.id < targetCluster.id
|
|
1131
|
-
? [sourceCluster.id, targetCluster.id]
|
|
1132
|
-
: [targetCluster.id, sourceCluster.id]
|
|
1133
|
-
const key = orderedIds.join(':')
|
|
1134
|
-
const current = edgeByClusterPair.get(key)
|
|
1135
|
-
if (current) {
|
|
1136
|
-
current.weight += edgeWeight(edge)
|
|
1137
|
-
continue
|
|
824
|
+
elements.labels?.classList.remove('is-stale')
|
|
825
|
+
drawLabels()
|
|
826
|
+
if (state.miniMapDirty) {
|
|
827
|
+
drawMiniMap()
|
|
828
|
+
state.miniMapDirty = false
|
|
1138
829
|
}
|
|
830
|
+
})
|
|
831
|
+
}
|
|
1139
832
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
833
|
+
const list = (items) => {
|
|
834
|
+
const rows = normalizeList(items)
|
|
835
|
+
if (rows.length === 0) {
|
|
836
|
+
return '<li><small>No links found.</small></li>'
|
|
837
|
+
}
|
|
838
|
+
return rows
|
|
839
|
+
.map((item) => {
|
|
840
|
+
const title = typeof item?.title === 'string' ? item.title : 'Untitled'
|
|
841
|
+
const id = typeof item?.id === 'string' ? item.id : ''
|
|
842
|
+
const path = typeof item?.path === 'string' ? item.path : ''
|
|
843
|
+
const meta = item?.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : ''
|
|
844
|
+
return '<li>' +
|
|
845
|
+
(id ? '<button type="button" data-node-id="' + escapeHtml(id) + '">' + escapeHtml(title) + '</button>' : escapeHtml(title)) +
|
|
846
|
+
'<small>' + escapeHtml(path) + meta + '</small>' +
|
|
847
|
+
'</li>'
|
|
1145
848
|
})
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
ecosystemSiblingEdgesForClusters(edgeClusters, edgeByClusterPair)
|
|
1149
|
-
const edges = Array.from(edgeByClusterPair.values())
|
|
1150
|
-
.sort((left, right) => right.weight - left.weight)
|
|
1151
|
-
.slice(0, ecosystemClusterEdgeLimit)
|
|
1152
|
-
const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
|
|
1153
|
-
? state.ecosystemHubCluster
|
|
1154
|
-
: null
|
|
1155
|
-
if (!hubCluster) {
|
|
1156
|
-
return edges
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
const existingHubTargets = new Set(edges.flatMap(edge =>
|
|
1160
|
-
edge.sourceCluster.id === hubCluster.id
|
|
1161
|
-
? [edge.targetCluster.id]
|
|
1162
|
-
: edge.targetCluster.id === hubCluster.id
|
|
1163
|
-
? [edge.sourceCluster.id]
|
|
1164
|
-
: []
|
|
1165
|
-
))
|
|
1166
|
-
const syntheticHubEdges = edgeClusters
|
|
1167
|
-
.filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
|
|
1168
|
-
.slice(0, ecosystemHubEdgeLimit)
|
|
1169
|
-
.map(cluster => ({
|
|
1170
|
-
id: hubCluster.id + ':' + cluster.id,
|
|
1171
|
-
sourceCluster: hubCluster,
|
|
1172
|
-
targetCluster: cluster,
|
|
1173
|
-
weight: 1,
|
|
1174
|
-
inferred: true
|
|
1175
|
-
}))
|
|
1176
|
-
return edges.concat(syntheticHubEdges)
|
|
849
|
+
.join('')
|
|
1177
850
|
}
|
|
1178
851
|
|
|
1179
|
-
const
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
852
|
+
const buildFacts = (node, outgoingCount, incomingCount) => {
|
|
853
|
+
const content = typeof node?.content === 'string' ? node.content : ''
|
|
854
|
+
const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
|
|
855
|
+
return [
|
|
856
|
+
{ label: 'Agent', value: typeof node?.agentId === 'string' && node.agentId ? node.agentId : 'shared' },
|
|
857
|
+
{ label: 'Words', value: String(words) },
|
|
858
|
+
{ label: 'Chars', value: String(content.length) },
|
|
859
|
+
{ label: 'Outgoing', value: String(outgoingCount) },
|
|
860
|
+
{ label: 'Backlinks', value: String(incomingCount) }
|
|
861
|
+
]
|
|
1188
862
|
}
|
|
1189
863
|
|
|
1190
|
-
const
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
if (scale < 0.022) return 240
|
|
1194
|
-
if (scale < 0.035) return 360
|
|
1195
|
-
return 520
|
|
1196
|
-
}
|
|
864
|
+
const listFacts = (facts) => facts
|
|
865
|
+
.map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
|
|
866
|
+
.join('')
|
|
1197
867
|
|
|
1198
|
-
const
|
|
1199
|
-
if (
|
|
1200
|
-
|
|
1201
|
-
if (scale < 0.09) return 520
|
|
1202
|
-
if (scale < 0.14) return 720
|
|
1203
|
-
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
1204
|
-
if (scale < 0.28) return renderNodeBudget
|
|
1205
|
-
if (scale < 0.45) return 1100
|
|
1206
|
-
if (scale < 0.7) return 1400
|
|
1207
|
-
if (scale < 1.05) return 1800
|
|
1208
|
-
return zoomedMassiveRenderNodeBudget
|
|
868
|
+
const listContextLinks = (links) => {
|
|
869
|
+
if (!Array.isArray(links) || links.length === 0) {
|
|
870
|
+
return '<li><small>No context links found.</small></li>'
|
|
1209
871
|
}
|
|
1210
|
-
return
|
|
872
|
+
return links
|
|
873
|
+
.map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
|
|
874
|
+
.join('')
|
|
1211
875
|
}
|
|
1212
876
|
|
|
1213
|
-
const
|
|
1214
|
-
const
|
|
1215
|
-
const
|
|
1216
|
-
const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
|
|
1217
|
-
const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
|
|
1218
|
-
const coreRatio = Math.max(0.2, Math.min(0.72, 0.24 + normalized * 0.48))
|
|
877
|
+
const nodeContextLinks = (node, outgoing) => {
|
|
878
|
+
const titles = Array.isArray(node?.contextLinks) ? node.contextLinks : []
|
|
879
|
+
const outgoingByTitle = new Map(normalizeList(outgoing).map((link) => [String(link.title || '').toLowerCase(), link]))
|
|
1219
880
|
|
|
1220
|
-
return
|
|
881
|
+
return titles
|
|
882
|
+
.map((title) => {
|
|
883
|
+
const match = outgoingByTitle.get(String(title).toLowerCase())
|
|
884
|
+
return {
|
|
885
|
+
title,
|
|
886
|
+
priority: match?.priority || 'normal'
|
|
887
|
+
}
|
|
888
|
+
})
|
|
889
|
+
.filter((link) => typeof link.title === 'string' && link.title.trim().length > 0)
|
|
1221
890
|
}
|
|
1222
891
|
|
|
1223
|
-
const
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
return sourceNodes
|
|
1227
|
-
}
|
|
892
|
+
const linkedNodes = (node) => {
|
|
893
|
+
const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
|
|
894
|
+
const edges = normalizeList(state.chunk.edges)
|
|
1228
895
|
|
|
1229
|
-
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
896
|
+
const outgoing = []
|
|
897
|
+
const incoming = []
|
|
898
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
899
|
+
const edge = edges[index]
|
|
900
|
+
if (edge[0] === node.id) {
|
|
901
|
+
const target = nodeById.get(edge[1])
|
|
902
|
+
if (target) {
|
|
903
|
+
outgoing.push({ id: target[0], title: target[1], path: target[4] || '', weight: edge[2], priority: edge[3] })
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (edge[1] === node.id) {
|
|
907
|
+
const source = nodeById.get(edge[0])
|
|
908
|
+
if (source) {
|
|
909
|
+
incoming.push({ id: source[0], title: source[1], path: source[4] || '', weight: edge[2], priority: edge[3] })
|
|
910
|
+
}
|
|
1234
911
|
}
|
|
1235
|
-
return { node, distance }
|
|
1236
|
-
})
|
|
1237
|
-
|
|
1238
|
-
if (maxDistance <= 0.001) {
|
|
1239
|
-
return sourceNodes
|
|
1240
912
|
}
|
|
1241
913
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
...item,
|
|
1245
|
-
normalized: item.distance / maxDistance
|
|
1246
|
-
}))
|
|
1247
|
-
const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
|
|
1248
|
-
const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
|
|
1249
|
-
const shellTarget = Math.max(12, desired - coreTarget)
|
|
1250
|
-
const shellHalf = focus.shellWidth / 2
|
|
914
|
+
return { outgoing, incoming }
|
|
915
|
+
}
|
|
1251
916
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
|
|
1256
|
-
const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
|
|
1257
|
-
if (leftScore !== rightScore) return rightScore - leftScore
|
|
1258
|
-
return left.node.id.localeCompare(right.node.id)
|
|
1259
|
-
})
|
|
1260
|
-
.slice(0, coreTarget)
|
|
1261
|
-
.map((item) => item.node)
|
|
917
|
+
const openContentDialog = () => {
|
|
918
|
+
elements.contentDialog.hidden = false
|
|
919
|
+
}
|
|
1262
920
|
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
const rightDelta = Math.abs(right.normalized - focus.shellCenter)
|
|
1267
|
-
const leftInside = leftDelta <= shellHalf ? 0 : 1
|
|
1268
|
-
const rightInside = rightDelta <= shellHalf ? 0 : 1
|
|
1269
|
-
if (leftInside !== rightInside) return leftInside - rightInside
|
|
1270
|
-
if (leftDelta !== rightDelta) return leftDelta - rightDelta
|
|
1271
|
-
const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
|
|
1272
|
-
const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
|
|
1273
|
-
if (leftScore !== rightScore) return rightScore - leftScore
|
|
1274
|
-
return left.node.id.localeCompare(right.node.id)
|
|
1275
|
-
})
|
|
1276
|
-
.slice(0, shellTarget)
|
|
1277
|
-
.map((item) => item.node)
|
|
921
|
+
const closeContentDialog = () => {
|
|
922
|
+
elements.contentDialog.hidden = true
|
|
923
|
+
}
|
|
1278
924
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
if (!node || ids.has(node.id)) return
|
|
1283
|
-
ids.add(node.id)
|
|
1284
|
-
merged.push(node)
|
|
925
|
+
const loadNodeDetails = async (nodeId) => {
|
|
926
|
+
if (!nodeId) {
|
|
927
|
+
return
|
|
1285
928
|
}
|
|
1286
929
|
|
|
1287
|
-
|
|
1288
|
-
|
|
930
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + scopeQuery('&'))
|
|
931
|
+
if (!response.ok) {
|
|
932
|
+
throw new Error('Failed to load graph node details')
|
|
1289
933
|
}
|
|
1290
|
-
for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
|
|
1291
|
-
for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
|
|
1292
|
-
|
|
1293
|
-
return merged.length > 0 ? merged : sourceNodes
|
|
1294
|
-
}
|
|
1295
934
|
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
x: (viewport.minX + viewport.maxX) / 2,
|
|
1300
|
-
y: (viewport.minY + viewport.maxY) / 2
|
|
935
|
+
const payload = await response.json()
|
|
936
|
+
if (!payload || typeof payload !== 'object' || !payload.node) {
|
|
937
|
+
throw new Error('Invalid graph node payload')
|
|
1301
938
|
}
|
|
1302
|
-
}
|
|
1303
939
|
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
})
|
|
940
|
+
const node = payload.node
|
|
941
|
+
state.selectedNodeId = node.id
|
|
942
|
+
setFocusedNodeIds(linkedNodeIds(node.id))
|
|
1308
943
|
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
return null
|
|
1312
|
-
}
|
|
1313
|
-
const rect = canvas.getBoundingClientRect()
|
|
1314
|
-
const screenX = state.cursor.x - rect.left
|
|
1315
|
-
const screenY = state.cursor.y - rect.top
|
|
1316
|
-
const width = Math.max(rect.width, 320)
|
|
1317
|
-
const height = Math.max(rect.height, 320)
|
|
1318
|
-
if (!Number.isFinite(screenX) || !Number.isFinite(screenY)) {
|
|
1319
|
-
return null
|
|
1320
|
-
}
|
|
1321
|
-
if (screenX < 0 || screenX > width || screenY < 0 || screenY > height) {
|
|
1322
|
-
return null
|
|
944
|
+
if (state.renderWorker && state.workerReady) {
|
|
945
|
+
state.renderWorker.postMessage({ type: 'select', id: node.id })
|
|
1323
946
|
}
|
|
1324
|
-
return screenToWorldPoint(screenX, screenY)
|
|
1325
|
-
}
|
|
1326
947
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
if (safeScale < 0.01) return Math.round(safeScale * 300_000)
|
|
1330
|
-
if (safeScale < 0.05) return Math.round(safeScale * 120_000)
|
|
1331
|
-
if (safeScale < 0.2) return Math.round(safeScale * 40_000)
|
|
1332
|
-
return Math.round(safeScale * 8_000)
|
|
1333
|
-
}
|
|
948
|
+
elements.contentTitle.textContent = node.title || 'Untitled'
|
|
949
|
+
elements.contentPath.textContent = node.path || ''
|
|
1334
950
|
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
951
|
+
const tags = Array.isArray(node.tags) ? node.tags : []
|
|
952
|
+
elements.contentTags.innerHTML = tags.length > 0
|
|
953
|
+
? tags.map((tag) => '<span>' + escapeHtml(tag) + '</span>').join('')
|
|
954
|
+
: '<span>No tags</span>'
|
|
955
|
+
|
|
956
|
+
const related = linkedNodes(node)
|
|
957
|
+
const contextLinks = nodeContextLinks(node, related.outgoing)
|
|
958
|
+
const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
|
|
959
|
+
elements.contentFacts.innerHTML = listFacts(facts)
|
|
960
|
+
elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
|
|
961
|
+
elements.contentOutgoing.innerHTML = list(related.outgoing)
|
|
962
|
+
elements.contentIncoming.innerHTML = list(related.incoming)
|
|
963
|
+
elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
|
|
1344
964
|
|
|
1345
|
-
|
|
1346
|
-
const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
|
|
1347
|
-
const shouldRender = state.macroViewActive
|
|
1348
|
-
? state.transform.scale <= exitThreshold
|
|
1349
|
-
: state.transform.scale <= enterThreshold
|
|
1350
|
-
state.macroViewActive = shouldRender
|
|
1351
|
-
return shouldRender
|
|
965
|
+
openContentDialog()
|
|
1352
966
|
}
|
|
1353
967
|
|
|
1354
|
-
const
|
|
1355
|
-
const
|
|
1356
|
-
|
|
968
|
+
const fitFromChunk = () => {
|
|
969
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
970
|
+
if (nodes.length === 0) {
|
|
971
|
+
return
|
|
972
|
+
}
|
|
1357
973
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
974
|
+
let minX = Infinity
|
|
975
|
+
let minY = Infinity
|
|
976
|
+
let maxX = -Infinity
|
|
977
|
+
let maxY = -Infinity
|
|
978
|
+
|
|
979
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
980
|
+
const node = nodes[index]
|
|
981
|
+
const x = Number(node[2])
|
|
982
|
+
const y = Number(node[3])
|
|
983
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
984
|
+
continue
|
|
1361
985
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
986
|
+
if (x < minX) minX = x
|
|
987
|
+
if (y < minY) minY = y
|
|
988
|
+
if (x > maxX) maxX = x
|
|
989
|
+
if (y > maxY) maxY = y
|
|
1364
990
|
}
|
|
1365
991
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
}
|
|
1369
|
-
for (let index = 0; index < rightNodes.length && merged.length < limit; index += 1) {
|
|
1370
|
-
push(rightNodes[index])
|
|
992
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
|
993
|
+
return
|
|
1371
994
|
}
|
|
1372
995
|
|
|
1373
|
-
|
|
1374
|
-
|
|
996
|
+
const width = Math.max(1, maxX - minX)
|
|
997
|
+
const height = Math.max(1, maxY - minY)
|
|
998
|
+
const scaleX = state.viewport.width / width
|
|
999
|
+
const scaleY = state.viewport.height / height
|
|
1000
|
+
const scale = clampScale(Math.min(scaleX, scaleY) * 0.72)
|
|
1001
|
+
|
|
1002
|
+
state.camera.scale = scale
|
|
1003
|
+
state.camera.x = state.viewport.width / 2 - (minX + width / 2) * scale
|
|
1004
|
+
state.camera.y = state.viewport.height / 2 - (minY + height / 2) * scale
|
|
1005
|
+
updateWorkerCamera()
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const fetchChunk = async ({ fit } = { fit: false }) => {
|
|
1009
|
+
const token = ++state.fetchToken
|
|
1010
|
+
if (state.fetchAbortController) {
|
|
1011
|
+
state.fetchAbortController.abort()
|
|
1012
|
+
}
|
|
1013
|
+
const controller = new AbortController()
|
|
1014
|
+
state.fetchAbortController = controller
|
|
1015
|
+
const worldTopLeft = screenToWorld(0, 0)
|
|
1016
|
+
const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
|
|
1017
|
+
const x = Math.min(worldTopLeft.x, worldBottomRight.x)
|
|
1018
|
+
const y = Math.min(worldTopLeft.y, worldBottomRight.y)
|
|
1019
|
+
const w = Math.abs(worldBottomRight.x - worldTopLeft.x)
|
|
1020
|
+
const h = Math.abs(worldBottomRight.y - worldTopLeft.y)
|
|
1021
|
+
|
|
1022
|
+
const params = new URLSearchParams({
|
|
1023
|
+
x: String(x),
|
|
1024
|
+
y: String(y),
|
|
1025
|
+
w: String(Math.max(1, w)),
|
|
1026
|
+
h: String(Math.max(1, h)),
|
|
1027
|
+
scale: String(state.camera.scale),
|
|
1028
|
+
nodeBudget: String(getZoomNodeBudget()),
|
|
1029
|
+
edgeBudget: String(getZoomEdgeBudget())
|
|
1030
|
+
})
|
|
1375
1031
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
return sourceNodes
|
|
1032
|
+
if (state.agentId) {
|
|
1033
|
+
params.set('agent', state.agentId)
|
|
1379
1034
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
const cursorPoint = cursorWorldPoint()
|
|
1383
|
-
const recentZoomFocus =
|
|
1384
|
-
now - state.lastZoomFocus.at <= 1500
|
|
1385
|
-
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
1386
|
-
: null
|
|
1387
|
-
const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
|
|
1388
|
-
const previousIds = new Set(state.renderNodes.map((node) => node.id))
|
|
1389
|
-
const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
|
|
1390
|
-
|
|
1391
|
-
return [...sourceNodes]
|
|
1392
|
-
.sort((left, right) => {
|
|
1393
|
-
const leftWasVisible = previousIds.has(left.id) ? 1 : 0
|
|
1394
|
-
const rightWasVisible = previousIds.has(right.id) ? 1 : 0
|
|
1395
|
-
const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
|
|
1396
|
-
const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
|
|
1397
|
-
|
|
1398
|
-
if (preferAnchorDistance) {
|
|
1399
|
-
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1400
|
-
if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
|
|
1401
|
-
} else {
|
|
1402
|
-
if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
|
|
1403
|
-
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
1407
|
-
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
1408
|
-
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
1409
|
-
|
|
1410
|
-
return left.id.localeCompare(right.id)
|
|
1411
|
-
})
|
|
1412
|
-
.slice(0, limit)
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
const selectAccessBridgeNodes = (sourceNodes, limit) => {
|
|
1416
|
-
if (limit <= 0 || sourceNodes.length === 0) {
|
|
1417
|
-
return []
|
|
1035
|
+
if (state.contextId) {
|
|
1036
|
+
params.set('context', state.contextId)
|
|
1418
1037
|
}
|
|
1419
1038
|
|
|
1420
|
-
const
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
now - state.lastZoomFocus.at <= 1200
|
|
1424
|
-
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
1425
|
-
: null
|
|
1426
|
-
const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
|
|
1427
|
-
return [...sourceNodes]
|
|
1428
|
-
.sort((left, right) => {
|
|
1429
|
-
const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
|
|
1430
|
-
const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
|
|
1431
|
-
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1432
|
-
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
1433
|
-
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
1434
|
-
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
1435
|
-
return left.id.localeCompare(right.id)
|
|
1436
|
-
})
|
|
1437
|
-
.slice(0, limit)
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
const edgeIdentityKey = edge => {
|
|
1441
|
-
if (!edge.target) return ''
|
|
1442
|
-
const pair = edge.source < edge.target
|
|
1443
|
-
? edge.source + '|' + edge.target
|
|
1444
|
-
: edge.target + '|' + edge.source
|
|
1445
|
-
return pair + '|' + (edge.inferred ? 'mesh' : 'real')
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
const edgeRelevanceScore = edge => {
|
|
1449
|
-
let score = edgeWeight(edge) * 10
|
|
1450
|
-
if (!edge.inferred) {
|
|
1451
|
-
score += 8
|
|
1039
|
+
const requestKey = graphStreamRequestKey({ x, y, w, h })
|
|
1040
|
+
if (!fit && state.lastChunkRequestKey === requestKey && state.chunk.nodes.length > 0) {
|
|
1041
|
+
return
|
|
1452
1042
|
}
|
|
1453
1043
|
|
|
1454
|
-
const
|
|
1455
|
-
if (
|
|
1456
|
-
|
|
1044
|
+
const response = await fetch('/api/graph-stream?' + params.toString(), { signal: controller.signal })
|
|
1045
|
+
if (!response.ok) {
|
|
1046
|
+
throw new Error('Failed to fetch graph stream chunk')
|
|
1457
1047
|
}
|
|
1458
1048
|
|
|
1459
|
-
const
|
|
1460
|
-
if (
|
|
1461
|
-
|
|
1049
|
+
const chunk = await response.json()
|
|
1050
|
+
if (controller.signal.aborted) {
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
if (token !== state.fetchToken) {
|
|
1054
|
+
return
|
|
1462
1055
|
}
|
|
1463
1056
|
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1057
|
+
state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
|
|
1058
|
+
state.lastChunkRequestKey = requestKey
|
|
1059
|
+
ensureNodePositionsLoaded()
|
|
1060
|
+
await syncNodePositionsFromServer()
|
|
1061
|
+
state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
|
|
1062
|
+
const chunkNodes = applyManualNodePositions(chunk.nodes)
|
|
1063
|
+
state.chunk = {
|
|
1064
|
+
nodes: chunkNodes,
|
|
1065
|
+
edges: normalizeList(chunk.edges)
|
|
1066
|
+
}
|
|
1067
|
+
state.miniMapDirty = true
|
|
1068
|
+
state.spatialIndex.key = ''
|
|
1069
|
+
const renderChunk = { ...chunk, nodes: chunkNodes }
|
|
1070
|
+
state.totals = {
|
|
1071
|
+
nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
|
|
1072
|
+
edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
|
|
1467
1073
|
}
|
|
1468
1074
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1075
|
+
updateTotals()
|
|
1076
|
+
updateTagCount()
|
|
1471
1077
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
return []
|
|
1078
|
+
if (fit) {
|
|
1079
|
+
fitFromChunk()
|
|
1475
1080
|
}
|
|
1476
1081
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
1483
|
-
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
1484
|
-
const edge = candidateEdges[index]
|
|
1485
|
-
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
1486
|
-
continue
|
|
1487
|
-
}
|
|
1488
|
-
const key = edgeIdentityKey(edge)
|
|
1489
|
-
if (seen.has(key)) continue
|
|
1082
|
+
if (state.renderWorker && state.workerReady) {
|
|
1083
|
+
state.renderWorker.postMessage({ type: 'chunk', chunk: renderChunk })
|
|
1084
|
+
state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
|
|
1085
|
+
state.renderWorker.postMessage({ type: 'highlight', ids: Array.from(state.searchResultIds) })
|
|
1086
|
+
}
|
|
1490
1087
|
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
})
|
|
1088
|
+
updateGraphOverlays()
|
|
1089
|
+
drawFallback()
|
|
1090
|
+
}
|
|
1495
1091
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1092
|
+
const scheduleChunkFetch = ({ fit } = { fit: false }) => {
|
|
1093
|
+
if (state.fetchTimer) {
|
|
1094
|
+
clearTimeout(state.fetchTimer)
|
|
1498
1095
|
}
|
|
1499
1096
|
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1097
|
+
const now = performance.now()
|
|
1098
|
+
const recentlyWheeling = now - state.lastWheelAt < 320
|
|
1099
|
+
const heavyScene = state.lastVisibleNodes > 1200 || state.lastVisibleEdges > 3500
|
|
1100
|
+
const delay = fit ? 0 : (state.pointer.down ? 320 : (recentlyWheeling ? (heavyScene ? 420 : 300) : (heavyScene ? 120 : 72)))
|
|
1101
|
+
state.fetchTimer = setTimeout(() => {
|
|
1102
|
+
state.fetchTimer = null
|
|
1103
|
+
fetchChunk({ fit }).catch((error) => {
|
|
1104
|
+
if (error && error.name === 'AbortError') {
|
|
1105
|
+
return
|
|
1505
1106
|
}
|
|
1506
|
-
|
|
1507
|
-
const rightKey = edgeIdentityKey(right)
|
|
1508
|
-
return leftKey.localeCompare(rightKey)
|
|
1107
|
+
console.error(error)
|
|
1509
1108
|
})
|
|
1510
|
-
|
|
1109
|
+
}, delay)
|
|
1511
1110
|
}
|
|
1512
1111
|
|
|
1513
|
-
const
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
if (scale < 0.2) return 0.14
|
|
1522
|
-
if (scale < 0.4) return 0.2
|
|
1523
|
-
if (scale < 0.7) return 0.28
|
|
1524
|
-
if (scale < 1.05) return 0.36
|
|
1525
|
-
return 0.46
|
|
1112
|
+
const setViewportFromCanvas = () => {
|
|
1113
|
+
const rect = canvas.getBoundingClientRect()
|
|
1114
|
+
state.viewport.width = Math.max(320, rect.width)
|
|
1115
|
+
state.viewport.height = Math.max(320, rect.height)
|
|
1116
|
+
state.viewport.ratio = window.devicePixelRatio || 1
|
|
1117
|
+
state.miniMapDirty = true
|
|
1118
|
+
updateWorkerSize()
|
|
1119
|
+
drawFallback()
|
|
1526
1120
|
}
|
|
1527
1121
|
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1122
|
+
const pickFallbackNode = (screenX, screenY) => {
|
|
1123
|
+
const nodes = spatialCandidates(screenX, screenY)
|
|
1124
|
+
if (nodes.length === 0) {
|
|
1125
|
+
return null
|
|
1531
1126
|
}
|
|
1532
1127
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
const
|
|
1540
|
-
|
|
1541
|
-
|
|
1128
|
+
let bestNode = null
|
|
1129
|
+
let bestDistance = Infinity
|
|
1130
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1131
|
+
const node = nodes[index]
|
|
1132
|
+
const id = typeof node[0] === 'string' ? node[0] : ''
|
|
1133
|
+
if (!id) continue
|
|
1134
|
+
const x = Number(node[2])
|
|
1135
|
+
const y = Number(node[3])
|
|
1136
|
+
const weight = Number(node[7])
|
|
1137
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
|
|
1138
|
+
const point = worldToScreen(x, y)
|
|
1139
|
+
const radius = Math.max(3.2, Math.min(16.5, 5 + (Number.isFinite(weight) ? weight : 0) * 0.65))
|
|
1140
|
+
const distance = Math.hypot(screenX - point.x, screenY - point.y)
|
|
1141
|
+
if (distance <= radius && distance < bestDistance) {
|
|
1142
|
+
bestDistance = distance
|
|
1143
|
+
bestNode = node
|
|
1144
|
+
}
|
|
1542
1145
|
}
|
|
1543
1146
|
|
|
1544
|
-
return
|
|
1147
|
+
return bestNode
|
|
1545
1148
|
}
|
|
1546
1149
|
|
|
1547
|
-
const
|
|
1548
|
-
const
|
|
1549
|
-
|
|
1550
|
-
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
1551
|
-
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
1552
|
-
ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
|
|
1553
|
-
ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
|
|
1554
|
-
ctx.stroke()
|
|
1150
|
+
const pickFallbackNodeId = (screenX, screenY) => {
|
|
1151
|
+
const node = pickFallbackNode(screenX, screenY)
|
|
1152
|
+
return typeof node?.[0] === 'string' ? node[0] : ''
|
|
1555
1153
|
}
|
|
1556
1154
|
|
|
1557
|
-
const
|
|
1558
|
-
|
|
1155
|
+
const handlePickedNode = (node) => {
|
|
1156
|
+
const nodeId = typeof node?.id === 'string' ? node.id : typeof node?.[0] === 'string' ? node[0] : ''
|
|
1157
|
+
if (!nodeId) {
|
|
1559
1158
|
return
|
|
1560
1159
|
}
|
|
1561
1160
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
const
|
|
1565
|
-
|
|
1566
|
-
|
|
1161
|
+
const kind = typeof node?.kind === 'string' ? node.kind : nodeKind(node)
|
|
1162
|
+
if (kind === 'cluster') {
|
|
1163
|
+
const currentScale = state.camera.scale
|
|
1164
|
+
const targetScale = currentScale < 0.22 ? 0.28 : Math.min(1.1, currentScale * 1.6)
|
|
1165
|
+
focusNodeInViewport(nodeId, targetScale)
|
|
1166
|
+
return
|
|
1567
1167
|
}
|
|
1568
|
-
ctx.strokeStyle = options.strokeStyle
|
|
1569
|
-
ctx.lineWidth = options.lineWidth
|
|
1570
|
-
ctx.stroke()
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
const regularEdgeBatchOptions = (edge) => ({
|
|
1574
|
-
strokeStyle: edgeStrokeFor(edge, false),
|
|
1575
|
-
lineWidth: edgeWidthFor(edge, false)
|
|
1576
|
-
})
|
|
1577
1168
|
|
|
1578
|
-
|
|
1579
|
-
const options = regularEdgeBatchOptions(edge)
|
|
1580
|
-
return options.strokeStyle + '|' + options.lineWidth.toFixed(2)
|
|
1169
|
+
loadNodeDetails(nodeId).catch((error) => console.error(error))
|
|
1581
1170
|
}
|
|
1582
1171
|
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
const edge = state.renderEdges[index]
|
|
1589
|
-
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
1590
|
-
if (isSelected) {
|
|
1591
|
-
selectedEdges.push(edge)
|
|
1592
|
-
continue
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
const key = regularEdgeBatchKey(edge)
|
|
1596
|
-
const batch = edgeBatches.get(key)
|
|
1597
|
-
if (batch) {
|
|
1598
|
-
batch.edges.push(edge)
|
|
1599
|
-
} else {
|
|
1600
|
-
edgeBatches.set(key, {
|
|
1601
|
-
edges: [edge],
|
|
1602
|
-
options: regularEdgeBatchOptions(edge)
|
|
1603
|
-
})
|
|
1172
|
+
const pickAt = (screenX, screenY) => {
|
|
1173
|
+
if (state.rendererMode === 'fallback') {
|
|
1174
|
+
const node = pickFallbackNode(screenX, screenY)
|
|
1175
|
+
if (node) {
|
|
1176
|
+
handlePickedNode(node)
|
|
1604
1177
|
}
|
|
1178
|
+
return
|
|
1605
1179
|
}
|
|
1606
1180
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
for (let index = 0; index < selectedEdges.length; index += 1) {
|
|
1610
|
-
drawGraphEdge(selectedEdges[index])
|
|
1181
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
1182
|
+
return
|
|
1611
1183
|
}
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
|
|
1615
|
-
isSelected ||
|
|
1616
|
-
isHovered ||
|
|
1617
|
-
(state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
|
|
1618
|
-
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
1619
1184
|
|
|
1620
|
-
const
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
ctx.beginPath()
|
|
1629
|
-
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
|
|
1630
|
-
ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
|
|
1631
|
-
ctx.fill()
|
|
1632
|
-
ctx.lineWidth = isSelected ? 2.6 : 1.5
|
|
1633
|
-
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
1634
|
-
ctx.stroke()
|
|
1185
|
+
const requestId = Math.random().toString(36).slice(2)
|
|
1186
|
+
state.renderWorker.postMessage({
|
|
1187
|
+
type: 'pick',
|
|
1188
|
+
requestId,
|
|
1189
|
+
x: screenX,
|
|
1190
|
+
y: screenY
|
|
1191
|
+
})
|
|
1192
|
+
}
|
|
1635
1193
|
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1194
|
+
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
1195
|
+
const clamped = Math.max(0.92, Math.min(1.09, factor))
|
|
1196
|
+
const before = screenToWorld(screenX, screenY)
|
|
1197
|
+
state.camera.scale = clampScale(state.camera.scale * clamped)
|
|
1198
|
+
state.camera.x = screenX - before.x * state.camera.scale
|
|
1199
|
+
state.camera.y = screenY - before.y * state.camera.scale
|
|
1200
|
+
updateWorkerCamera()
|
|
1201
|
+
scheduleChunkFetch()
|
|
1643
1202
|
}
|
|
1644
1203
|
|
|
1645
|
-
const
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1204
|
+
const resolvePointer = (event) => {
|
|
1205
|
+
const rect = canvas.getBoundingClientRect()
|
|
1206
|
+
return {
|
|
1207
|
+
x: event.clientX - rect.left,
|
|
1208
|
+
y: event.clientY - rect.top
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const setupInput = () => {
|
|
1213
|
+
const dragActivationDistance = 6
|
|
1214
|
+
const resetPointerState = (pointerId = null) => {
|
|
1215
|
+
state.pointer.down = false
|
|
1216
|
+
state.pointer.dragging = false
|
|
1217
|
+
state.pointer.dragNodeId = ''
|
|
1218
|
+
canvas.classList.remove('is-node-dragging')
|
|
1219
|
+
if (pointerId !== null) {
|
|
1220
|
+
try {
|
|
1221
|
+
if (canvas.hasPointerCapture(pointerId)) {
|
|
1222
|
+
canvas.releasePointerCapture(pointerId)
|
|
1223
|
+
}
|
|
1224
|
+
} catch {}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
canvas.addEventListener('wheel', (event) => {
|
|
1229
|
+
event.preventDefault()
|
|
1230
|
+
state.lastWheelAt = performance.now()
|
|
1231
|
+
const pointer = resolvePointer(event)
|
|
1232
|
+
const exponent = Math.max(-0.05, Math.min(0.05, -event.deltaY * 0.001))
|
|
1233
|
+
zoomAtPoint(pointer.x, pointer.y, Math.exp(exponent))
|
|
1234
|
+
}, { passive: false })
|
|
1235
|
+
|
|
1236
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
1237
|
+
event.preventDefault()
|
|
1238
|
+
const pointer = resolvePointer(event)
|
|
1239
|
+
const candidateNode = pickFallbackNode(pointer.x, pointer.y)
|
|
1240
|
+
const candidateNodeId = isRealGraphNode(candidateNode) && typeof candidateNode?.[0] === 'string' ? candidateNode[0] : ''
|
|
1241
|
+
const candidateX = Number(candidateNode?.[2])
|
|
1242
|
+
const candidateY = Number(candidateNode?.[3])
|
|
1243
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
1244
|
+
state.pointer.down = true
|
|
1245
|
+
state.pointer.moved = false
|
|
1246
|
+
state.pointer.dragging = false
|
|
1247
|
+
state.pointer.dragNodeId = candidateNodeId
|
|
1248
|
+
state.pointer.x = pointer.x
|
|
1249
|
+
state.pointer.y = pointer.y
|
|
1250
|
+
state.pointer.startX = pointer.x
|
|
1251
|
+
state.pointer.startY = pointer.y
|
|
1252
|
+
state.pointer.startWorldX = world.x
|
|
1253
|
+
state.pointer.startWorldY = world.y
|
|
1254
|
+
state.pointer.nodeStartX = candidateNodeId && Number.isFinite(candidateX) ? candidateX : 0
|
|
1255
|
+
state.pointer.nodeStartY = candidateNodeId && Number.isFinite(candidateY) ? candidateY : 0
|
|
1256
|
+
state.pointer.worldAnchorX = world.x
|
|
1257
|
+
state.pointer.worldAnchorY = world.y
|
|
1258
|
+
try {
|
|
1259
|
+
canvas.setPointerCapture(event.pointerId)
|
|
1260
|
+
} catch {}
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
canvas.addEventListener('pointermove', (event) => {
|
|
1264
|
+
if (state.pointer.down) {
|
|
1265
|
+
event.preventDefault()
|
|
1266
|
+
}
|
|
1267
|
+
const pointer = resolvePointer(event)
|
|
1649
1268
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1269
|
+
if (state.pointer.down) {
|
|
1270
|
+
const dx = pointer.x - state.pointer.x
|
|
1271
|
+
const dy = pointer.y - state.pointer.y
|
|
1272
|
+
const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
|
|
1273
|
+
if (distanceFromStart >= dragActivationDistance) {
|
|
1274
|
+
state.pointer.moved = true
|
|
1275
|
+
state.pointer.dragging = true
|
|
1276
|
+
canvas.classList.toggle('is-node-dragging', Boolean(state.pointer.dragNodeId))
|
|
1277
|
+
}
|
|
1278
|
+
if (!state.pointer.dragging) {
|
|
1279
|
+
state.pointer.x = pointer.x
|
|
1280
|
+
state.pointer.y = pointer.y
|
|
1281
|
+
return
|
|
1282
|
+
}
|
|
1283
|
+
if (state.pointer.dragNodeId) {
|
|
1284
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
1285
|
+
const x = state.pointer.nodeStartX + world.x - state.pointer.startWorldX
|
|
1286
|
+
const y = state.pointer.nodeStartY + world.y - state.pointer.startWorldY
|
|
1287
|
+
state.nodePositions.set(state.pointer.dragNodeId, { x, y })
|
|
1288
|
+
updateNodePositionInChunk(state.pointer.dragNodeId, x, y)
|
|
1289
|
+
state.pointer.x = pointer.x
|
|
1290
|
+
state.pointer.y = pointer.y
|
|
1291
|
+
drawFallback()
|
|
1292
|
+
return
|
|
1293
|
+
}
|
|
1294
|
+
state.camera.x += dx
|
|
1295
|
+
state.camera.y += dy
|
|
1296
|
+
state.pointer.x = pointer.x
|
|
1297
|
+
state.pointer.y = pointer.y
|
|
1298
|
+
updateWorkerCamera()
|
|
1299
|
+
drawFallback()
|
|
1300
|
+
return
|
|
1657
1301
|
}
|
|
1658
|
-
ctx.fillStyle = graphTheme.nodeHalo
|
|
1659
|
-
ctx.fill()
|
|
1660
|
-
}
|
|
1661
1302
|
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
ctx.lineWidth = 1.25
|
|
1672
|
-
ctx.strokeStyle = graphTheme.nodeStroke
|
|
1673
|
-
ctx.stroke()
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
const drawGraphNodes = () => {
|
|
1677
|
-
const regularNodes = []
|
|
1678
|
-
const priorityNodes = []
|
|
1679
|
-
|
|
1680
|
-
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
1681
|
-
const node = state.renderNodes[index]
|
|
1682
|
-
const isPriority =
|
|
1683
|
-
state.selected?.id === node.id ||
|
|
1684
|
-
state.hovered?.id === node.id
|
|
1685
|
-
if (isPriority) {
|
|
1686
|
-
priorityNodes.push(node)
|
|
1303
|
+
const hovered = pickFallbackNode(pointer.x, pointer.y)
|
|
1304
|
+
const hoveredId = isRealGraphNode(hovered) && typeof hovered?.[0] === 'string' ? hovered[0] : ''
|
|
1305
|
+
if (state.hoveredNodeId !== hoveredId) {
|
|
1306
|
+
state.hoveredNodeId = hoveredId
|
|
1307
|
+
canvas.classList.toggle('is-node-hover', Boolean(hoveredId))
|
|
1308
|
+
updateGraphOverlays()
|
|
1309
|
+
}
|
|
1310
|
+
if (hoveredId) {
|
|
1311
|
+
showTooltip(hovered, pointer)
|
|
1687
1312
|
} else {
|
|
1688
|
-
|
|
1313
|
+
hideTooltip()
|
|
1689
1314
|
}
|
|
1690
|
-
}
|
|
1315
|
+
})
|
|
1691
1316
|
|
|
1692
|
-
|
|
1317
|
+
canvas.addEventListener('pointerup', (event) => {
|
|
1318
|
+
const pointer = resolvePointer(event)
|
|
1319
|
+
const distanceFromStart = Math.hypot(pointer.x - state.pointer.startX, pointer.y - state.pointer.startY)
|
|
1320
|
+
const shouldPick = !state.pointer.dragging && distanceFromStart < dragActivationDistance
|
|
1321
|
+
const shouldRefreshAfterDrag = state.pointer.dragging
|
|
1322
|
+
const shouldPersistNodePosition = state.pointer.dragging && Boolean(state.pointer.dragNodeId)
|
|
1323
|
+
resetPointerState(event.pointerId)
|
|
1693
1324
|
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
ctx.textAlign = 'center'
|
|
1698
|
-
ctx.textBaseline = 'top'
|
|
1699
|
-
for (let index = 0; index < regularNodes.length; index += 1) {
|
|
1700
|
-
const node = regularNodes[index]
|
|
1701
|
-
ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
|
|
1325
|
+
if (shouldPick) {
|
|
1326
|
+
pickAt(pointer.x, pointer.y)
|
|
1327
|
+
return
|
|
1702
1328
|
}
|
|
1703
|
-
|
|
1329
|
+
if (shouldPersistNodePosition) {
|
|
1330
|
+
writeStoredNodePositions()
|
|
1331
|
+
persistNodePositionsToServer()
|
|
1332
|
+
return
|
|
1333
|
+
}
|
|
1334
|
+
if (shouldRefreshAfterDrag) {
|
|
1335
|
+
scheduleChunkFetch()
|
|
1336
|
+
}
|
|
1337
|
+
})
|
|
1704
1338
|
|
|
1705
|
-
|
|
1706
|
-
|
|
1339
|
+
canvas.addEventListener('pointerleave', () => {
|
|
1340
|
+
state.hoveredNodeId = ''
|
|
1341
|
+
canvas.classList.remove('is-node-hover')
|
|
1342
|
+
hideTooltip()
|
|
1343
|
+
updateGraphOverlays()
|
|
1344
|
+
})
|
|
1707
1345
|
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
const selectedEdges = []
|
|
1346
|
+
canvas.addEventListener('pointercancel', (event) => {
|
|
1347
|
+
resetPointerState(event.pointerId)
|
|
1348
|
+
hideTooltip()
|
|
1349
|
+
updateGraphOverlays()
|
|
1350
|
+
})
|
|
1714
1351
|
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
state.selected?.id === node.id ||
|
|
1719
|
-
state.hovered?.id === node.id
|
|
1720
|
-
if (isPriority) {
|
|
1721
|
-
priorityNodes.push(node)
|
|
1722
|
-
} else {
|
|
1723
|
-
regularNodes.push(node)
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1352
|
+
canvas.addEventListener('lostpointercapture', () => {
|
|
1353
|
+
resetPointerState()
|
|
1354
|
+
})
|
|
1726
1355
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
if (isSelected) {
|
|
1731
|
-
selectedEdges.push(edge)
|
|
1732
|
-
} else if (edge.inferred) {
|
|
1733
|
-
inferredEdges.push(edge)
|
|
1734
|
-
} else {
|
|
1735
|
-
regularEdges.push(edge)
|
|
1356
|
+
elements.miniMap.addEventListener('click', (event) => {
|
|
1357
|
+
if (!state.miniMapView) {
|
|
1358
|
+
return
|
|
1736
1359
|
}
|
|
1737
|
-
|
|
1360
|
+
const rect = elements.miniMap.getBoundingClientRect()
|
|
1361
|
+
const x = event.clientX - rect.left
|
|
1362
|
+
const y = event.clientY - rect.top
|
|
1363
|
+
const worldX = state.miniMapView.minX + (x - state.miniMapView.offsetX) / state.miniMapView.scale
|
|
1364
|
+
const worldY = state.miniMapView.minY + (y - state.miniMapView.offsetY) / state.miniMapView.scale
|
|
1365
|
+
state.camera.x = state.viewport.width / 2 - worldX * state.camera.scale
|
|
1366
|
+
state.camera.y = state.viewport.height / 2 - worldY * state.camera.scale
|
|
1367
|
+
updateWorkerCamera()
|
|
1368
|
+
scheduleChunkFetch()
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
canvas.addEventListener('dblclick', (event) => {
|
|
1372
|
+
const pointer = resolvePointer(event)
|
|
1373
|
+
zoomAtPoint(pointer.x, pointer.y, 1.065)
|
|
1374
|
+
})
|
|
1738
1375
|
|
|
1739
|
-
|
|
1376
|
+
window.addEventListener('keydown', (event) => {
|
|
1377
|
+
if (event.key === 'Escape' && !elements.contentDialog.hidden) {
|
|
1378
|
+
closeContentDialog()
|
|
1379
|
+
return
|
|
1380
|
+
}
|
|
1381
|
+
if (event.key === '+') {
|
|
1382
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
1383
|
+
return
|
|
1384
|
+
}
|
|
1385
|
+
if (event.key === '-') {
|
|
1386
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
1387
|
+
return
|
|
1388
|
+
}
|
|
1389
|
+
if (event.key === '0') {
|
|
1390
|
+
scheduleChunkFetch({ fit: true })
|
|
1391
|
+
}
|
|
1392
|
+
})
|
|
1740
1393
|
}
|
|
1741
1394
|
|
|
1742
|
-
const
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
}
|
|
1395
|
+
const setupControls = () => {
|
|
1396
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
1397
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
1398
|
+
})
|
|
1746
1399
|
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
ctx.textBaseline = 'top'
|
|
1751
|
-
for (let index = 0; index < nodes.length; index += 1) {
|
|
1752
|
-
const node = nodes[index]
|
|
1753
|
-
ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1400
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
1401
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
1402
|
+
})
|
|
1756
1403
|
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
}
|
|
1404
|
+
elements.fit.addEventListener('click', () => {
|
|
1405
|
+
fitFromChunk()
|
|
1406
|
+
scheduleChunkFetch()
|
|
1407
|
+
})
|
|
1761
1408
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
if (drawEdges) {
|
|
1766
|
-
webGlRenderer.drawLines(
|
|
1767
|
-
graphParts.regularEdges,
|
|
1768
|
-
rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
|
|
1769
|
-
width,
|
|
1770
|
-
height
|
|
1771
|
-
)
|
|
1772
|
-
webGlRenderer.drawLines(
|
|
1773
|
-
graphParts.inferredEdges,
|
|
1774
|
-
rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
|
|
1775
|
-
width,
|
|
1776
|
-
height
|
|
1777
|
-
)
|
|
1778
|
-
}
|
|
1779
|
-
webGlRenderer.drawPoints(
|
|
1780
|
-
graphParts.regularNodes,
|
|
1781
|
-
rgba(graphTheme.nodeHalo, 0.28),
|
|
1782
|
-
node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
|
|
1783
|
-
width,
|
|
1784
|
-
height
|
|
1785
|
-
)
|
|
1786
|
-
webGlRenderer.drawPoints(
|
|
1787
|
-
graphParts.regularNodes,
|
|
1788
|
-
rgba(graphTheme.node, 1),
|
|
1789
|
-
node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
|
|
1790
|
-
width,
|
|
1791
|
-
height
|
|
1792
|
-
)
|
|
1793
|
-
|
|
1794
|
-
ctx.save()
|
|
1795
|
-
ctx.translate(state.transform.x, state.transform.y)
|
|
1796
|
-
ctx.scale(state.transform.scale, state.transform.scale)
|
|
1797
|
-
if (drawEdges) {
|
|
1798
|
-
graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
|
|
1799
|
-
}
|
|
1800
|
-
drawGraphLabels(graphParts.regularNodes)
|
|
1801
|
-
graphParts.priorityNodes.forEach(node => drawSingleNode(node))
|
|
1802
|
-
ctx.restore()
|
|
1409
|
+
elements.releaseNode.addEventListener('click', () => {
|
|
1410
|
+
releaseSelectedNodePosition()
|
|
1411
|
+
})
|
|
1803
1412
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1413
|
+
elements.reset.addEventListener('click', () => {
|
|
1414
|
+
clearStoredNodePositions()
|
|
1415
|
+
clearNodePositionsOnServer()
|
|
1416
|
+
state.camera = { x: 0, y: 0, scale: 0.22 }
|
|
1417
|
+
updateWorkerCamera()
|
|
1418
|
+
scheduleChunkFetch({ fit: true })
|
|
1419
|
+
})
|
|
1806
1420
|
|
|
1807
|
-
|
|
1808
|
-
|
|
1421
|
+
elements.contentClose.addEventListener('click', () => {
|
|
1422
|
+
closeContentDialog()
|
|
1423
|
+
})
|
|
1809
1424
|
|
|
1810
|
-
|
|
1811
|
-
|
|
1425
|
+
elements.contentDialog.addEventListener('click', (event) => {
|
|
1426
|
+
if (event.target === elements.contentDialog) {
|
|
1427
|
+
closeContentDialog()
|
|
1428
|
+
}
|
|
1429
|
+
})
|
|
1812
1430
|
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
const cellY = Math.floor(node.y / cellSize)
|
|
1817
|
-
const key = cellX + ':' + cellY
|
|
1818
|
-
const bucket = buckets.get(key)
|
|
1819
|
-
if (bucket) {
|
|
1820
|
-
bucket.push(node)
|
|
1821
|
-
} else {
|
|
1822
|
-
buckets.set(key, [node])
|
|
1431
|
+
elements.search.addEventListener('input', () => {
|
|
1432
|
+
if (state.searchTimer) {
|
|
1433
|
+
clearTimeout(state.searchTimer)
|
|
1823
1434
|
}
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
const meshCandidatesForNode = (node, buckets, cellSize) => {
|
|
1830
|
-
const cellX = Math.floor(node.x / cellSize)
|
|
1831
|
-
const cellY = Math.floor(node.y / cellSize)
|
|
1832
|
-
const candidates = []
|
|
1833
|
-
|
|
1834
|
-
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
1835
|
-
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
1836
|
-
const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
|
|
1837
|
-
if (!bucket) continue
|
|
1838
|
-
for (let index = 0; index < bucket.length; index += 1) {
|
|
1839
|
-
const candidate = bucket[index]
|
|
1840
|
-
if (candidate.id !== node.id) {
|
|
1841
|
-
candidates.push(candidate)
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
return candidates
|
|
1435
|
+
state.searchTimer = setTimeout(() => {
|
|
1436
|
+
state.searchTimer = null
|
|
1437
|
+
runGraphSearch().catch((error) => console.error(error))
|
|
1438
|
+
}, 160)
|
|
1439
|
+
})
|
|
1848
1440
|
}
|
|
1849
1441
|
|
|
1850
|
-
const
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
if (edge.target) {
|
|
1859
|
-
existingKeys.add(edgePairKey(edge.source, edge.target))
|
|
1860
|
-
}
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
const desiredBudget = Math.min(
|
|
1864
|
-
meshEdgeMaxBudget,
|
|
1865
|
-
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
|
|
1866
|
-
)
|
|
1867
|
-
const perNodeNeighborCount =
|
|
1868
|
-
state.transform.scale >= 1.05 ? 4
|
|
1869
|
-
: state.transform.scale >= 0.62 ? 3
|
|
1870
|
-
: 2
|
|
1871
|
-
const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
|
|
1872
|
-
const maxDistance = 980
|
|
1873
|
-
const maxDistanceSquared = maxDistance * maxDistance
|
|
1874
|
-
const buckets = meshNeighborBuckets(nodes, cellSize)
|
|
1875
|
-
const meshEdges = []
|
|
1876
|
-
const meshKeys = new Set()
|
|
1877
|
-
|
|
1878
|
-
for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
|
|
1879
|
-
const node = nodes[index]
|
|
1880
|
-
const candidates = meshCandidatesForNode(node, buckets, cellSize)
|
|
1881
|
-
.map((candidate) => ({
|
|
1882
|
-
node: candidate,
|
|
1883
|
-
distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
|
|
1884
|
-
}))
|
|
1885
|
-
.filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
|
|
1886
|
-
.sort((left, right) => left.distanceSquared - right.distanceSquared)
|
|
1887
|
-
|
|
1888
|
-
let linked = 0
|
|
1889
|
-
for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
|
|
1890
|
-
const candidate = candidates[candidateIndex].node
|
|
1891
|
-
const key = edgePairKey(node.id, candidate.id)
|
|
1892
|
-
if (existingKeys.has(key) || meshKeys.has(key)) {
|
|
1893
|
-
continue
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
meshKeys.add(key)
|
|
1897
|
-
meshEdges.push({
|
|
1898
|
-
source: node.id,
|
|
1899
|
-
target: candidate.id,
|
|
1900
|
-
targetTitle: candidate.title,
|
|
1901
|
-
weight: 1,
|
|
1902
|
-
priority: 'normal',
|
|
1903
|
-
sourceNode: node,
|
|
1904
|
-
targetNode: candidate,
|
|
1905
|
-
inferred: true
|
|
1906
|
-
})
|
|
1907
|
-
linked += 1
|
|
1442
|
+
const runGraphSearch = async () => {
|
|
1443
|
+
const token = ++state.searchToken
|
|
1444
|
+
const query = (elements.search.value || '').trim()
|
|
1445
|
+
if (!query) {
|
|
1446
|
+
state.searchResultIds = new Set()
|
|
1447
|
+
setFocusedNodeIds(new Set())
|
|
1448
|
+
if (state.renderWorker && state.workerReady) {
|
|
1449
|
+
state.renderWorker.postMessage({ type: 'highlight', ids: [] })
|
|
1908
1450
|
}
|
|
1451
|
+
return
|
|
1909
1452
|
}
|
|
1910
1453
|
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
const withMeshEdges = (nodes, edges) => {
|
|
1915
|
-
if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
|
|
1916
|
-
return edges
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
const meshEdges = buildMeshEdgesForNodes(nodes, edges)
|
|
1920
|
-
return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
const fallbackViewportNodes = () => {
|
|
1924
|
-
const nodes = []
|
|
1925
|
-
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
1926
|
-
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
1927
|
-
|
|
1928
|
-
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
1929
|
-
nodes.push(state.visibleNodes[index])
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
1933
|
-
nodes.push(state.selected)
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
return nodes
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
|
|
1940
|
-
if (sourceNodes.length === 0 || limit <= 0) {
|
|
1941
|
-
return []
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
|
-
const nodes = []
|
|
1945
|
-
const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
|
|
1946
|
-
const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
|
|
1947
|
-
|
|
1948
|
-
for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
|
|
1949
|
-
nodes.push(sourceNodes[index])
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
1953
|
-
nodes.push(state.selected)
|
|
1454
|
+
const response = await fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + scopeQuery('&'))
|
|
1455
|
+
if (!response.ok) {
|
|
1456
|
+
throw new Error('Failed to search graph')
|
|
1954
1457
|
}
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
const enrichSampleWithNeighbors = (nodes) => {
|
|
1960
|
-
if (nodes.length === 0) {
|
|
1961
|
-
return {
|
|
1962
|
-
nodes,
|
|
1963
|
-
edges: []
|
|
1964
|
-
}
|
|
1458
|
+
const payload = await response.json()
|
|
1459
|
+
if (token !== state.searchToken) {
|
|
1460
|
+
return
|
|
1965
1461
|
}
|
|
1966
1462
|
|
|
1967
|
-
const
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
const node = nodes[index]
|
|
1973
|
-
const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
|
|
1974
|
-
.filter((edge) => edge.target)
|
|
1975
|
-
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
1976
|
-
.slice(0, 3)
|
|
1977
|
-
|
|
1978
|
-
for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
|
|
1979
|
-
const edge = candidates[candidateIndex]
|
|
1980
|
-
const otherId = edge.source === node.id ? edge.target : edge.source
|
|
1981
|
-
|
|
1982
|
-
if (!otherId || ids.has(otherId)) {
|
|
1983
|
-
continue
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
const otherNode = state.nodeById.get(otherId)
|
|
1987
|
-
if (!otherNode) {
|
|
1988
|
-
continue
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
ids.add(otherId)
|
|
1992
|
-
expanded.push(otherNode)
|
|
1993
|
-
}
|
|
1463
|
+
const ids = Array.isArray(payload?.nodeIds) ? payload.nodeIds.filter((id) => typeof id === 'string' && id.length > 0) : []
|
|
1464
|
+
state.searchResultIds = new Set(ids)
|
|
1465
|
+
setFocusedNodeIds(state.searchResultIds)
|
|
1466
|
+
if (state.renderWorker && state.workerReady) {
|
|
1467
|
+
state.renderWorker.postMessage({ type: 'highlight', ids })
|
|
1994
1468
|
}
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
nodes: expanded,
|
|
2000
|
-
edges
|
|
1469
|
+
if (ids.length > 0 && state.graphMode === 'far') {
|
|
1470
|
+
state.camera.scale = Math.max(state.camera.scale, 0.82)
|
|
1471
|
+
updateWorkerCamera()
|
|
1472
|
+
scheduleChunkFetch()
|
|
2001
1473
|
}
|
|
2002
1474
|
}
|
|
2003
1475
|
|
|
2004
|
-
const
|
|
2005
|
-
const
|
|
2006
|
-
if (!
|
|
2007
|
-
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
|
|
2011
|
-
const merged = [...nodes]
|
|
2012
|
-
const ids = new Set(merged.map((node) => node.id))
|
|
2013
|
-
const protectedIds = new Set()
|
|
2014
|
-
|
|
2015
|
-
if (!ids.has(hub.id)) {
|
|
2016
|
-
if (merged.length < maxNodes) {
|
|
2017
|
-
merged.push(hub)
|
|
2018
|
-
ids.add(hub.id)
|
|
2019
|
-
} else {
|
|
2020
|
-
const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
|
|
2021
|
-
if (replaceIndex >= 0) {
|
|
2022
|
-
ids.delete(merged[replaceIndex].id)
|
|
2023
|
-
merged[replaceIndex] = hub
|
|
2024
|
-
ids.add(hub.id)
|
|
2025
|
-
}
|
|
2026
|
-
}
|
|
1476
|
+
const loadAgents = async () => {
|
|
1477
|
+
const response = await fetch('/api/agents')
|
|
1478
|
+
if (!response.ok) {
|
|
1479
|
+
throw new Error('Failed to load agents')
|
|
2027
1480
|
}
|
|
2028
|
-
protectedIds.add(hub.id)
|
|
2029
1481
|
|
|
2030
|
-
const
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
const
|
|
2037
|
-
const
|
|
2038
|
-
|
|
2039
|
-
const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
|
|
2040
|
-
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
2041
|
-
|
|
2042
|
-
return edgeIdentityKey(left).localeCompare(edgeIdentityKey(right))
|
|
1482
|
+
const payload = await response.json()
|
|
1483
|
+
const agents = Array.isArray(payload?.agents) ? payload.agents : []
|
|
1484
|
+
|
|
1485
|
+
elements.agent.innerHTML = agents
|
|
1486
|
+
.map((agent) => {
|
|
1487
|
+
const id = String(agent?.id || '')
|
|
1488
|
+
const count = Number.isFinite(agent?.documentCount) ? agent.documentCount : 0
|
|
1489
|
+
const label = id === 'shared' ? 'shared' : id
|
|
1490
|
+
return '<option value="' + escapeHtml(id) + '">' + escapeHtml(label) + ' (' + count + ')</option>'
|
|
2043
1491
|
})
|
|
1492
|
+
.join('')
|
|
2044
1493
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
continue
|
|
2050
|
-
}
|
|
1494
|
+
const preferredAgent = initialAgentFromUrl || readStoredAgent()
|
|
1495
|
+
const hasPreferred = preferredAgent && agents.some((agent) => agent?.id === preferredAgent)
|
|
1496
|
+
state.agentId = hasPreferred ? preferredAgent : String(agents[0]?.id || '')
|
|
1497
|
+
elements.agent.value = state.agentId
|
|
2051
1498
|
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
if (merged.length < maxNodes) {
|
|
2058
|
-
ids.add(otherId)
|
|
2059
|
-
merged.push(otherNode)
|
|
2060
|
-
protectedIds.add(otherId)
|
|
2061
|
-
continue
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
const replaceIndex = (() => {
|
|
2065
|
-
for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
|
|
2066
|
-
const candidateId = merged[cursor]?.id
|
|
2067
|
-
if (candidateId && !protectedIds.has(candidateId)) {
|
|
2068
|
-
return cursor
|
|
2069
|
-
}
|
|
2070
|
-
}
|
|
2071
|
-
return -1
|
|
2072
|
-
})()
|
|
2073
|
-
if (replaceIndex >= 0) {
|
|
2074
|
-
const replacedId = merged[replaceIndex]?.id
|
|
2075
|
-
if (replacedId) {
|
|
2076
|
-
ids.delete(replacedId)
|
|
2077
|
-
}
|
|
2078
|
-
merged[replaceIndex] = otherNode
|
|
2079
|
-
ids.add(otherId)
|
|
2080
|
-
protectedIds.add(otherId)
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
return merged
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
const ensureHubNodesInRenderedSet = (nodes) => {
|
|
2088
|
-
if (nodes.length === 0) {
|
|
2089
|
-
return nodes
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
|
|
2093
|
-
const ids = new Set(nodes.map((node) => node.id))
|
|
2094
|
-
const hubs = rankedHubNodes()
|
|
2095
|
-
const merged = [...nodes]
|
|
2096
|
-
|
|
2097
|
-
for (let index = 0; index < hubs.length; index += 1) {
|
|
2098
|
-
const hub = hubs[index]
|
|
2099
|
-
if (ids.has(hub.id)) {
|
|
2100
|
-
continue
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
if (merged.length < maxNodes) {
|
|
2104
|
-
merged.push(hub)
|
|
2105
|
-
ids.add(hub.id)
|
|
2106
|
-
continue
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
|
|
2110
|
-
if (replacementIndex >= 0) {
|
|
2111
|
-
ids.delete(merged[replacementIndex].id)
|
|
2112
|
-
merged[replacementIndex] = hub
|
|
2113
|
-
ids.add(hub.id)
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
return merged
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
const zoomCapByNodeCount = (nodeCount) => {
|
|
2121
|
-
if (nodeCount > 50000) return 5.4
|
|
2122
|
-
if (nodeCount > 20000) return 4.8
|
|
2123
|
-
if (nodeCount > 6000) return 4.2
|
|
2124
|
-
if (nodeCount > 2000) return 4
|
|
2125
|
-
return zoomRange.max
|
|
2126
|
-
}
|
|
2127
|
-
|
|
2128
|
-
const zoomCapByHubDistance = (distance) => {
|
|
2129
|
-
if (!Number.isFinite(distance) || distance <= 0) {
|
|
2130
|
-
return zoomRange.max
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
const rect = canvas.getBoundingClientRect()
|
|
2134
|
-
const viewportWidth = Math.max(rect.width, 320)
|
|
2135
|
-
const viewportHeight = Math.max(rect.height, 320)
|
|
2136
|
-
const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
|
|
2137
|
-
return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
const currentZoomMax = () => {
|
|
2141
|
-
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
2142
|
-
const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
|
|
2143
|
-
? zoomCapByHubDistance(state.hubNeighborDistance)
|
|
2144
|
-
: zoomRange.max
|
|
2145
|
-
const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
|
|
2146
|
-
const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
|
|
2147
|
-
return Math.max(zoomRange.min * 2, capped)
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
|
|
2151
|
-
const isFiniteNumber = value => Number.isFinite(value)
|
|
2152
|
-
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
2153
|
-
const clampTransformCoordinate = value => {
|
|
2154
|
-
if (!isFiniteNumber(value)) return 0
|
|
2155
|
-
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
2156
|
-
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
2157
|
-
return value
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
const graphBounds = nodes => {
|
|
2161
|
-
if (nodes.length === 0) return null
|
|
2162
|
-
let minX = Number.POSITIVE_INFINITY
|
|
2163
|
-
let maxX = Number.NEGATIVE_INFINITY
|
|
2164
|
-
let minY = Number.POSITIVE_INFINITY
|
|
2165
|
-
let maxY = Number.NEGATIVE_INFINITY
|
|
2166
|
-
|
|
2167
|
-
nodes.forEach(node => {
|
|
2168
|
-
const radius = baseNodeRadius(node)
|
|
2169
|
-
minX = Math.min(minX, node.x - radius)
|
|
2170
|
-
maxX = Math.max(maxX, node.x + radius)
|
|
2171
|
-
minY = Math.min(minY, node.y - radius)
|
|
2172
|
-
maxY = Math.max(maxY, node.y + radius)
|
|
2173
|
-
})
|
|
2174
|
-
|
|
2175
|
-
return {
|
|
2176
|
-
minX,
|
|
2177
|
-
maxX,
|
|
2178
|
-
minY,
|
|
2179
|
-
maxY,
|
|
2180
|
-
width: Math.max(maxX - minX, 1),
|
|
2181
|
-
height: Math.max(maxY - minY, 1)
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
const fitScaleBiasByNodeCount = nodeCount => {
|
|
2186
|
-
if (nodeCount <= 6) return 1.22
|
|
2187
|
-
if (nodeCount <= 20) return 1.12
|
|
2188
|
-
if (nodeCount <= 60) return 1.04
|
|
2189
|
-
if (nodeCount <= 180) return 1
|
|
2190
|
-
if (nodeCount <= 600) return 0.94
|
|
2191
|
-
if (nodeCount <= 2000) return 0.82
|
|
2192
|
-
if (nodeCount <= 6000) return 0.68
|
|
2193
|
-
return 0.56
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
2197
|
-
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
2198
|
-
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
2199
|
-
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
2200
|
-
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
2201
|
-
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
2202
|
-
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
2203
|
-
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
2204
|
-
return { min: 0.0012, max: 0.24 }
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
|
|
2208
|
-
const rect = canvas.getBoundingClientRect()
|
|
2209
|
-
const width = Math.max(rect.width, 320)
|
|
2210
|
-
const height = Math.max(rect.height, 320)
|
|
2211
|
-
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
2212
|
-
const bounds = graphBounds(nodes)
|
|
2213
|
-
|
|
2214
|
-
if (!bounds) {
|
|
2215
|
-
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
2216
|
-
state.offscreenFrameCount = 0
|
|
2217
|
-
state.recoveringViewport = false
|
|
2218
|
-
markRenderDirty()
|
|
2219
|
-
return
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
const paddingByNodeCount = nodeCount => {
|
|
2223
|
-
if (nodeCount <= 6) return 28
|
|
2224
|
-
if (nodeCount <= 20) return 44
|
|
2225
|
-
if (nodeCount <= 60) return 68
|
|
2226
|
-
if (nodeCount <= 180) return 86
|
|
2227
|
-
if (nodeCount <= 600) return 110
|
|
2228
|
-
if (nodeCount <= 2000) return 140
|
|
2229
|
-
return 180
|
|
2230
|
-
}
|
|
2231
|
-
const padding = paddingByNodeCount(nodes.length)
|
|
2232
|
-
const scaleX = width / (bounds.width + padding * 2)
|
|
2233
|
-
const scaleY = height / (bounds.height + padding * 2)
|
|
2234
|
-
const fitScale = Math.min(scaleX, scaleY)
|
|
2235
|
-
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
2236
|
-
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
2237
|
-
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
2238
|
-
const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
|
|
2239
|
-
const scale = options.macro && nodes.length > 1
|
|
2240
|
-
? clampScale(Math.min(baselineScale, macroScale))
|
|
2241
|
-
: nodes.length > massiveGraphNodeThreshold
|
|
2242
|
-
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
2243
|
-
: baselineScale
|
|
2244
|
-
const hubCenter =
|
|
2245
|
-
options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
|
|
2246
|
-
? state.primaryHub
|
|
2247
|
-
: null
|
|
2248
|
-
const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
|
|
2249
|
-
const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
|
|
2250
|
-
|
|
2251
|
-
state.transform = {
|
|
2252
|
-
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
2253
|
-
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
2254
|
-
scale: clampScale(scale)
|
|
2255
|
-
}
|
|
2256
|
-
state.offscreenFrameCount = 0
|
|
2257
|
-
state.recoveringViewport = false
|
|
2258
|
-
markRenderDirty()
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
|
|
2262
|
-
|
|
2263
|
-
const focusPrimaryHub = () => {
|
|
2264
|
-
const hub = state.primaryHub
|
|
2265
|
-
if (!hub) {
|
|
2266
|
-
fitView({ useFiltered: true, macro: false, preferHubCenter: true })
|
|
2267
|
-
return
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
|
-
const rect = canvas.getBoundingClientRect()
|
|
2271
|
-
const width = Math.max(rect.width, 320)
|
|
2272
|
-
const height = Math.max(rect.height, 320)
|
|
2273
|
-
const targetScale = clampScale(Math.max(0.78, state.transform.scale))
|
|
2274
|
-
|
|
2275
|
-
state.transform = {
|
|
2276
|
-
x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
|
|
2277
|
-
y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
|
|
2278
|
-
scale: targetScale
|
|
2279
|
-
}
|
|
2280
|
-
state.offscreenFrameCount = 0
|
|
2281
|
-
markRenderDirty()
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
const layoutDensityScaleForNodeCount = (nodeCount) => {
|
|
2285
|
-
if (nodeCount > 50000) return 0.26
|
|
2286
|
-
if (nodeCount > 20000) return 0.3
|
|
2287
|
-
if (nodeCount > 6000) return 0.36
|
|
2288
|
-
if (nodeCount > 2000) return 0.42
|
|
2289
|
-
if (nodeCount > 600) return 0.5
|
|
2290
|
-
if (nodeCount > 180) return 0.58
|
|
2291
|
-
if (nodeCount > 60) return 0.68
|
|
2292
|
-
if (nodeCount > 20) return 0.78
|
|
2293
|
-
return 0.88
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
const createLayout = graph => {
|
|
2297
|
-
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
2298
|
-
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
2299
|
-
const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
|
|
2300
|
-
const nodes = nodeRows.map(node => {
|
|
2301
|
-
if (Array.isArray(node)) {
|
|
2302
|
-
const [id, title, x, y, group, segment] = node
|
|
2303
|
-
return {
|
|
2304
|
-
id: typeof id === 'string' ? id : '',
|
|
2305
|
-
title: typeof title === 'string' ? title : 'Untitled',
|
|
2306
|
-
path: '',
|
|
2307
|
-
tags: [],
|
|
2308
|
-
group: typeof group === 'string' ? group : 'root',
|
|
2309
|
-
segment: typeof segment === 'string' ? segment : 'root',
|
|
2310
|
-
x: Number.isFinite(x) ? x * densityScale : 0,
|
|
2311
|
-
y: Number.isFinite(y) ? y * densityScale : 0,
|
|
2312
|
-
vx: 0,
|
|
2313
|
-
vy: 0
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
2316
|
-
|
|
2317
|
-
return {
|
|
2318
|
-
...node,
|
|
2319
|
-
path: typeof node.path === 'string' ? node.path : '',
|
|
2320
|
-
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
2321
|
-
x: Number.isFinite(node.x) ? node.x * densityScale : 0,
|
|
2322
|
-
y: Number.isFinite(node.y) ? node.y * densityScale : 0,
|
|
2323
|
-
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
2324
|
-
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
2325
|
-
}
|
|
1499
|
+
elements.agent.addEventListener('change', () => {
|
|
1500
|
+
state.agentId = elements.agent.value || ''
|
|
1501
|
+
writeStoredAgent(state.agentId)
|
|
1502
|
+
syncAgentInUrl(state.agentId)
|
|
1503
|
+
loadContexts().then(() => scheduleChunkFetch({ fit: true })).catch((error) => console.error(error))
|
|
2326
1504
|
})
|
|
2327
|
-
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
2328
|
-
const edges = edgeRows
|
|
2329
|
-
.map(edge => {
|
|
2330
|
-
if (Array.isArray(edge)) {
|
|
2331
|
-
const [source, target, weight, priority] = edge
|
|
2332
|
-
return {
|
|
2333
|
-
source: typeof source === 'string' ? source : '',
|
|
2334
|
-
target: typeof target === 'string' ? target : null,
|
|
2335
|
-
targetTitle: '',
|
|
2336
|
-
weight: Number.isFinite(weight) ? weight : 1,
|
|
2337
|
-
priority: typeof priority === 'string' ? priority : 'normal'
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
return edge
|
|
2341
|
-
})
|
|
2342
|
-
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
2343
|
-
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
2344
|
-
return { nodes, edges }
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
const encodeEntityTag = (value) => {
|
|
2348
|
-
const utf8 = new TextEncoder().encode(value)
|
|
2349
|
-
let binary = ''
|
|
2350
1505
|
|
|
2351
|
-
|
|
2352
|
-
binary += String.fromCharCode(utf8[index])
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
|
-
const graphSignature = graph => JSON.stringify({
|
|
2359
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
2360
|
-
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
2361
|
-
})
|
|
2362
|
-
|
|
2363
|
-
const resetContentFilter = () => {
|
|
2364
|
-
if (state.contentFilter.timer) {
|
|
2365
|
-
clearTimeout(state.contentFilter.timer)
|
|
2366
|
-
}
|
|
2367
|
-
state.contentFilter = {
|
|
2368
|
-
query: '',
|
|
2369
|
-
ids: null,
|
|
2370
|
-
token: state.contentFilter.token + 1,
|
|
2371
|
-
timer: null
|
|
2372
|
-
}
|
|
2373
|
-
recomputeVisibility()
|
|
1506
|
+
syncAgentInUrl(state.agentId)
|
|
2374
1507
|
}
|
|
2375
1508
|
|
|
2376
|
-
const
|
|
2377
|
-
const response = await fetch(
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
'&limit=' +
|
|
2381
|
-
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
2382
|
-
agentQuery('&')
|
|
2383
|
-
)
|
|
2384
|
-
|
|
2385
|
-
if (!response.ok || token !== state.contentFilter.token) {
|
|
2386
|
-
return
|
|
1509
|
+
const loadContexts = async () => {
|
|
1510
|
+
const response = await fetch('/api/graph-contexts' + (state.agentId ? '?agent=' + encodeURIComponent(state.agentId) : ''))
|
|
1511
|
+
if (!response.ok) {
|
|
1512
|
+
throw new Error('Failed to load graph contexts')
|
|
2387
1513
|
}
|
|
2388
1514
|
|
|
2389
1515
|
const payload = await response.json()
|
|
2390
|
-
const
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
const scheduleContentFilterSync = () => {
|
|
2402
|
-
const query = normalizeQuery(state.query)
|
|
2403
|
-
if (!query) {
|
|
2404
|
-
resetContentFilter()
|
|
2405
|
-
return
|
|
2406
|
-
}
|
|
1516
|
+
const contexts = Array.isArray(payload?.contexts) ? payload.contexts : []
|
|
1517
|
+
const options = [
|
|
1518
|
+
'<option value="">All contexts</option>',
|
|
1519
|
+
...contexts.map((context) => {
|
|
1520
|
+
const id = String(context?.id || '')
|
|
1521
|
+
const title = String(context?.title || id || 'Untitled')
|
|
1522
|
+
const count = Number.isFinite(context?.nodeCount) ? context.nodeCount : 0
|
|
1523
|
+
return '<option value="' + escapeHtml(id) + '">' + escapeHtml(title) + ' (' + count + ')</option>'
|
|
1524
|
+
})
|
|
1525
|
+
]
|
|
2407
1526
|
|
|
2408
|
-
|
|
2409
|
-
clearTimeout(state.contentFilter.timer)
|
|
2410
|
-
}
|
|
1527
|
+
elements.context.innerHTML = options.join('')
|
|
2411
1528
|
|
|
2412
|
-
const
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
if (state.filterWorker && state.filterReady) {
|
|
2419
|
-
state.filterWorker.postMessage({
|
|
2420
|
-
type: 'filter',
|
|
2421
|
-
query,
|
|
2422
|
-
token,
|
|
2423
|
-
limit: Math.max(state.nodes.length, 1)
|
|
2424
|
-
})
|
|
2425
|
-
}
|
|
2426
|
-
syncContentFilter(query, token).catch(() => {})
|
|
2427
|
-
}, 180)
|
|
2428
|
-
}
|
|
1529
|
+
const preferredContext = initialContextFromUrl || readStoredContext()
|
|
1530
|
+
const hasPreferred = preferredContext && contexts.some((context) => context?.id === preferredContext)
|
|
1531
|
+
state.contextId = hasPreferred ? preferredContext : ''
|
|
1532
|
+
elements.context.value = state.contextId
|
|
1533
|
+
writeStoredContext(state.contextId)
|
|
1534
|
+
syncContextInUrl(state.contextId)
|
|
2429
1535
|
}
|
|
2430
1536
|
|
|
2431
|
-
const
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
state.
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
if (!shouldRunPhysics) {
|
|
2439
|
-
return
|
|
2440
|
-
}
|
|
2441
|
-
const strength = Math.min(delta / 16, 2)
|
|
2442
|
-
|
|
2443
|
-
edges.forEach(edge => {
|
|
2444
|
-
const source = edge.sourceNode
|
|
2445
|
-
const target = edge.targetNode
|
|
2446
|
-
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
2447
|
-
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
2448
|
-
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
2449
|
-
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
2450
|
-
const dx = target.x - source.x
|
|
2451
|
-
const dy = target.y - source.y
|
|
2452
|
-
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
2453
|
-
const force = (distance - 150) * 0.002 * strength
|
|
2454
|
-
const fx = (dx / distance) * force
|
|
2455
|
-
const fy = (dy / distance) * force
|
|
2456
|
-
source.vx += fx
|
|
2457
|
-
source.vy += fy
|
|
2458
|
-
target.vx -= fx
|
|
2459
|
-
target.vy -= fy
|
|
2460
|
-
})
|
|
2461
|
-
|
|
2462
|
-
for (let i = 0; i < nodes.length; i += 1) {
|
|
2463
|
-
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
2464
|
-
const a = nodes[i]
|
|
2465
|
-
const b = nodes[j]
|
|
2466
|
-
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
2467
|
-
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
2468
|
-
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
2469
|
-
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
2470
|
-
const dx = b.x - a.x
|
|
2471
|
-
const dy = b.y - a.y
|
|
2472
|
-
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
2473
|
-
const force = Math.min(2600 / (distance * distance), 0.12) * strength
|
|
2474
|
-
const fx = (dx / distance) * force
|
|
2475
|
-
const fy = (dy / distance) * force
|
|
2476
|
-
a.vx -= fx
|
|
2477
|
-
a.vy -= fy
|
|
2478
|
-
b.vx += fx
|
|
2479
|
-
b.vy += fy
|
|
2480
|
-
}
|
|
2481
|
-
}
|
|
2482
|
-
|
|
2483
|
-
nodes.forEach(node => {
|
|
2484
|
-
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
2485
|
-
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
2486
|
-
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
2487
|
-
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
2488
|
-
if (state.pointer.dragNode === node) {
|
|
2489
|
-
node.vx = 0
|
|
2490
|
-
node.vy = 0
|
|
2491
|
-
return
|
|
2492
|
-
}
|
|
2493
|
-
node.vx += -node.x * 0.0008 * strength
|
|
2494
|
-
node.vy += -node.y * 0.0008 * strength
|
|
2495
|
-
node.vx *= 0.88
|
|
2496
|
-
node.vy *= 0.88
|
|
2497
|
-
node.x += node.vx * strength
|
|
2498
|
-
node.y += node.vy * strength
|
|
1537
|
+
const setupContextControl = () => {
|
|
1538
|
+
elements.context.addEventListener('change', () => {
|
|
1539
|
+
state.contextId = elements.context.value || ''
|
|
1540
|
+
state.selectedNodeId = null
|
|
1541
|
+
writeStoredContext(state.contextId)
|
|
1542
|
+
syncContextInUrl(state.contextId)
|
|
1543
|
+
scheduleChunkFetch({ fit: true })
|
|
2499
1544
|
})
|
|
2500
1545
|
}
|
|
2501
1546
|
|
|
2502
|
-
const
|
|
2503
|
-
const
|
|
2504
|
-
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
const connectedNodeIdsFor = (nodeId) => {
|
|
2508
|
-
const edges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
2509
|
-
const ids = new Set()
|
|
1547
|
+
const setupRenderWorker = () => {
|
|
1548
|
+
const hasWorker = typeof Worker !== 'undefined'
|
|
1549
|
+
const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
|
|
2510
1550
|
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
ids.add(edge.target)
|
|
2516
|
-
} else if (edge.target === nodeId) {
|
|
2517
|
-
ids.add(edge.source)
|
|
2518
|
-
}
|
|
1551
|
+
if (!hasWorker || !canTransfer) {
|
|
1552
|
+
state.rendererMode = 'fallback'
|
|
1553
|
+
drawFallback()
|
|
1554
|
+
return
|
|
2519
1555
|
}
|
|
2520
1556
|
|
|
2521
|
-
|
|
2522
|
-
|
|
1557
|
+
try {
|
|
1558
|
+
const offscreen = canvas.transferControlToOffscreen()
|
|
1559
|
+
const worker = new Worker('/render-worker.js')
|
|
1560
|
+
state.renderWorker = worker
|
|
2523
1561
|
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
const scale = Math.max(state.transform.scale, 0.0001)
|
|
2530
|
-
const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
|
|
2531
|
-
const influenceRadiusSquared = influenceRadius * influenceRadius
|
|
2532
|
-
const connectedIds = connectedNodeIdsFor(dragNode.id)
|
|
2533
|
-
const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
2534
|
-
let adjusted = 0
|
|
2535
|
-
|
|
2536
|
-
for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
|
|
2537
|
-
const node = candidates[index]
|
|
2538
|
-
if (node.id === dragNode.id) continue
|
|
2539
|
-
|
|
2540
|
-
const isConnected = connectedIds.has(node.id)
|
|
2541
|
-
const dx = node.x - dragNode.x
|
|
2542
|
-
const dy = node.y - dragNode.y
|
|
2543
|
-
const distanceSquared = dx * dx + dy * dy
|
|
2544
|
-
const withinRadius = distanceSquared <= influenceRadiusSquared
|
|
2545
|
-
if (!isConnected && !withinRadius) continue
|
|
2546
|
-
|
|
2547
|
-
const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
|
|
2548
|
-
const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
|
|
2549
|
-
const coupledStrength = isConnected ? 0.28 : 0.12
|
|
2550
|
-
const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
|
|
2551
|
-
node.x += deltaX * influence
|
|
2552
|
-
node.y += deltaY * influence
|
|
2553
|
-
node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
|
|
2554
|
-
node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
|
|
2555
|
-
adjusted += 1
|
|
2556
|
-
}
|
|
2557
|
-
}
|
|
2558
|
-
|
|
2559
|
-
const settleNeighborhoodAroundNode = (dragNode) => {
|
|
2560
|
-
if (!dragNode) return
|
|
2561
|
-
|
|
2562
|
-
const scale = Math.max(state.transform.scale, 0.0001)
|
|
2563
|
-
const settleRadius = Math.max(240, Math.min(980, 520 / scale))
|
|
2564
|
-
const settleRadiusSquared = settleRadius * settleRadius
|
|
2565
|
-
const connectedIds = connectedNodeIdsFor(dragNode.id)
|
|
2566
|
-
const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
|
|
2567
|
-
.filter((node) => {
|
|
2568
|
-
if (node.id === dragNode.id) return true
|
|
2569
|
-
const dx = node.x - dragNode.x
|
|
2570
|
-
const dy = node.y - dragNode.y
|
|
2571
|
-
const distanceSquared = dx * dx + dy * dy
|
|
2572
|
-
return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
|
|
2573
|
-
})
|
|
2574
|
-
.slice(0, dragNeighborhoodMaxAffected)
|
|
2575
|
-
|
|
2576
|
-
if (candidates.length <= 1) return
|
|
2577
|
-
|
|
2578
|
-
for (let round = 0; round < dragSettleRounds; round += 1) {
|
|
2579
|
-
for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
|
|
2580
|
-
const left = candidates[leftIndex]
|
|
2581
|
-
for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
|
|
2582
|
-
const right = candidates[rightIndex]
|
|
2583
|
-
const dx = right.x - left.x
|
|
2584
|
-
const dy = right.y - left.y
|
|
2585
|
-
const distance = Math.max(Math.hypot(dx, dy), 0.001)
|
|
2586
|
-
const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
|
|
2587
|
-
if (distance >= minDistance) continue
|
|
2588
|
-
|
|
2589
|
-
const push = (minDistance - distance) * 0.36
|
|
2590
|
-
const ux = dx / distance
|
|
2591
|
-
const uy = dy / distance
|
|
2592
|
-
if (left.id !== dragNode.id) {
|
|
2593
|
-
left.x -= ux * push
|
|
2594
|
-
left.y -= uy * push
|
|
2595
|
-
}
|
|
2596
|
-
if (right.id !== dragNode.id) {
|
|
2597
|
-
right.x += ux * push
|
|
2598
|
-
right.y += uy * push
|
|
2599
|
-
}
|
|
1562
|
+
worker.onmessage = (event) => {
|
|
1563
|
+
const payload = event.data
|
|
1564
|
+
if (!payload || typeof payload !== 'object') {
|
|
1565
|
+
return
|
|
2600
1566
|
}
|
|
2601
|
-
}
|
|
2602
|
-
}
|
|
2603
|
-
}
|
|
2604
|
-
|
|
2605
|
-
const hitNode = point => {
|
|
2606
|
-
computeRenderVisibility()
|
|
2607
|
-
if (state.renderClusters.length > 0) {
|
|
2608
|
-
return null
|
|
2609
|
-
}
|
|
2610
|
-
const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
|
|
2611
|
-
? 0.2
|
|
2612
|
-
: state.nodes.length > largeGraphNodeThreshold
|
|
2613
|
-
? 0.34
|
|
2614
|
-
: 0
|
|
2615
|
-
if (state.transform.scale < hitScaleFloor) {
|
|
2616
|
-
return null
|
|
2617
|
-
}
|
|
2618
|
-
|
|
2619
|
-
const nodes = state.renderNodes
|
|
2620
|
-
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
2621
|
-
const node = nodes[index]
|
|
2622
|
-
const radius = nodeRadius(node)
|
|
2623
|
-
if (Math.hypot(point.x - node.x, point.y - node.y) <= radius + 5) return node
|
|
2624
|
-
}
|
|
2625
|
-
return null
|
|
2626
|
-
}
|
|
2627
|
-
|
|
2628
|
-
const baseNodeRadius = node => {
|
|
2629
|
-
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
2630
|
-
return 9 + Math.min(degree, 8) * 1.6
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
2634
|
-
|
|
2635
|
-
const clusterRadiusPx = cluster => {
|
|
2636
|
-
if (cluster.id === 'macro-galaxy') {
|
|
2637
|
-
return 10
|
|
2638
|
-
}
|
|
2639
|
-
if (cluster.isHub) {
|
|
2640
|
-
return 3.8
|
|
2641
|
-
}
|
|
2642
|
-
if (String(cluster.id).startsWith('ecosystem-')) {
|
|
2643
|
-
const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
|
|
2644
|
-
const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
|
|
2645
|
-
const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
|
|
2646
|
-
return Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
|
|
2647
|
-
}
|
|
2648
|
-
return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
2649
|
-
}
|
|
2650
|
-
|
|
2651
|
-
const clusterOpacity = cluster =>
|
|
2652
|
-
Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
|
|
2653
|
-
|
|
2654
|
-
const worldViewportBounds = () => {
|
|
2655
|
-
const rect = canvas.getBoundingClientRect()
|
|
2656
|
-
const width = Math.max(rect.width, 320)
|
|
2657
|
-
const height = Math.max(rect.height, 320)
|
|
2658
|
-
const paddingMultiplier =
|
|
2659
|
-
state.nodes.length > massiveGraphNodeThreshold
|
|
2660
|
-
? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
|
|
2661
|
-
: state.nodes.length > largeGraphNodeThreshold
|
|
2662
|
-
? 1.45
|
|
2663
|
-
: 1
|
|
2664
|
-
const padding = viewportPaddingPx * paddingMultiplier
|
|
2665
|
-
|
|
2666
|
-
return {
|
|
2667
|
-
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
2668
|
-
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
2669
|
-
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
2670
|
-
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
|
|
2674
|
-
const isNodeInViewport = (node, viewport) =>
|
|
2675
|
-
node.x >= viewport.minX &&
|
|
2676
|
-
node.x <= viewport.maxX &&
|
|
2677
|
-
node.y >= viewport.minY &&
|
|
2678
|
-
node.y <= viewport.maxY
|
|
2679
|
-
|
|
2680
|
-
const expandViewportBounds = (viewport, worldMargin) => ({
|
|
2681
|
-
minX: viewport.minX - worldMargin,
|
|
2682
|
-
maxX: viewport.maxX + worldMargin,
|
|
2683
|
-
minY: viewport.minY - worldMargin,
|
|
2684
|
-
maxY: viewport.maxY + worldMargin
|
|
2685
|
-
})
|
|
2686
|
-
|
|
2687
|
-
const viewportNodeStride = () => {
|
|
2688
|
-
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
2689
|
-
return 1
|
|
2690
|
-
}
|
|
2691
|
-
|
|
2692
|
-
if (state.transform.scale >= 0.95) {
|
|
2693
|
-
return 1
|
|
2694
|
-
}
|
|
2695
|
-
if (state.transform.scale >= 0.7) {
|
|
2696
|
-
return 2
|
|
2697
|
-
}
|
|
2698
|
-
if (state.transform.scale >= 0.48) {
|
|
2699
|
-
return 3
|
|
2700
|
-
}
|
|
2701
|
-
if (state.transform.scale >= 0.28) {
|
|
2702
|
-
return 5
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
return 8
|
|
2706
|
-
}
|
|
2707
1567
|
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
if (!shouldRenderClusters(viewportNodes)) {
|
|
2713
|
-
return []
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2716
|
-
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
2717
|
-
const buckets = new Map()
|
|
2718
|
-
|
|
2719
|
-
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
2720
|
-
const node = viewportNodes[index]
|
|
2721
|
-
const keyX = Math.floor(node.x / worldCellSize)
|
|
2722
|
-
const keyY = Math.floor(node.y / worldCellSize)
|
|
2723
|
-
const key = keyX + ':' + keyY
|
|
2724
|
-
const current = buckets.get(key)
|
|
2725
|
-
if (current) {
|
|
2726
|
-
current.count += 1
|
|
2727
|
-
current.sumX += node.x
|
|
2728
|
-
current.sumY += node.y
|
|
2729
|
-
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
2730
|
-
current.representative = node
|
|
2731
|
-
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
1568
|
+
if (payload.type === 'ready') {
|
|
1569
|
+
state.workerReady = true
|
|
1570
|
+
scheduleChunkFetch({ fit: true })
|
|
1571
|
+
return
|
|
2732
1572
|
}
|
|
2733
|
-
continue
|
|
2734
|
-
}
|
|
2735
|
-
|
|
2736
|
-
buckets.set(key, {
|
|
2737
|
-
id: key,
|
|
2738
|
-
count: 1,
|
|
2739
|
-
sumX: node.x,
|
|
2740
|
-
sumY: node.y,
|
|
2741
|
-
representative: node,
|
|
2742
|
-
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
2743
|
-
})
|
|
2744
|
-
}
|
|
2745
1573
|
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
.map((cluster) => ({
|
|
2750
|
-
id: cluster.id,
|
|
2751
|
-
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
2752
|
-
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
2753
|
-
count: cluster.count,
|
|
2754
|
-
representative: cluster.representative
|
|
2755
|
-
}))
|
|
2756
|
-
}
|
|
2757
|
-
|
|
2758
|
-
const representativeNodesFromClusters = (clusters, limit) => {
|
|
2759
|
-
const representatives = clusters
|
|
2760
|
-
.map((cluster) => cluster.representative)
|
|
2761
|
-
.filter((node) => Boolean(node))
|
|
2762
|
-
const merged = mergeUniqueNodes(
|
|
2763
|
-
representatives,
|
|
2764
|
-
state.renderNodes ?? [],
|
|
2765
|
-
Math.max(1, limit)
|
|
2766
|
-
)
|
|
2767
|
-
return ensureHubNodesInRenderedSet(merged)
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2770
|
-
const computeRenderVisibility = () => {
|
|
2771
|
-
if (!hasValidTransform()) {
|
|
2772
|
-
fitView({ useFiltered: true })
|
|
2773
|
-
}
|
|
2774
|
-
const viewport = worldViewportBounds()
|
|
2775
|
-
const viewportKey =
|
|
2776
|
-
Math.round(viewport.minX * 10) + ':' +
|
|
2777
|
-
Math.round(viewport.maxX * 10) + ':' +
|
|
2778
|
-
Math.round(viewport.minY * 10) + ':' +
|
|
2779
|
-
Math.round(viewport.maxY * 10) + ':' +
|
|
2780
|
-
visibilityScaleBucket(state.transform.scale)
|
|
2781
|
-
|
|
2782
|
-
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
2783
|
-
return
|
|
2784
|
-
}
|
|
2785
|
-
state.lastViewportKey = viewportKey
|
|
2786
|
-
state.renderVisibilityDirty = false
|
|
2787
|
-
state.renderClusterEdges = []
|
|
2788
|
-
|
|
2789
|
-
const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
|
|
2790
|
-
|
|
2791
|
-
if (shouldRenderMacroGalaxy) {
|
|
2792
|
-
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2793
|
-
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
2794
|
-
const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
|
|
2795
|
-
if (representative) {
|
|
2796
|
-
state.renderClusters = [
|
|
2797
|
-
{
|
|
2798
|
-
id: 'macro-galaxy',
|
|
2799
|
-
x: state.macroCenter.x,
|
|
2800
|
-
y: state.macroCenter.y,
|
|
2801
|
-
count: sourceNodes.length,
|
|
2802
|
-
representative
|
|
1574
|
+
if (payload.type === 'pick-result') {
|
|
1575
|
+
if (payload.node && typeof payload.node.id === 'string' && payload.node.id.length > 0) {
|
|
1576
|
+
handlePickedNode(payload.node)
|
|
2803
1577
|
}
|
|
2804
|
-
]
|
|
2805
|
-
state.renderNodes = [representative]
|
|
2806
|
-
} else {
|
|
2807
|
-
state.renderClusters = []
|
|
2808
|
-
state.renderNodes = []
|
|
2809
|
-
}
|
|
2810
|
-
state.renderEdges = []
|
|
2811
|
-
state.renderClusterEdges = []
|
|
2812
|
-
return
|
|
2813
|
-
}
|
|
2814
|
-
|
|
2815
|
-
const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
|
|
2816
|
-
? massiveEcosystemClusterScaleThreshold
|
|
2817
|
-
: ecosystemClusterScaleThreshold
|
|
2818
|
-
if (
|
|
2819
|
-
state.visibleNodes.length > ecosystemActivationNodeThreshold &&
|
|
2820
|
-
state.transform.scale <= ecosystemScaleThreshold &&
|
|
2821
|
-
state.ecosystemClusters.length > 0
|
|
2822
|
-
) {
|
|
2823
|
-
const clusters = selectHierarchicalEcosystemClusters(viewport)
|
|
2824
|
-
.sort((left, right) => right.count - left.count)
|
|
2825
|
-
state.renderClusters = clusters
|
|
2826
|
-
state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
|
|
2827
|
-
state.renderNodes = []
|
|
2828
|
-
state.renderEdges = []
|
|
2829
|
-
return
|
|
2830
|
-
}
|
|
2831
|
-
|
|
2832
|
-
if (state.visibleNodes.length <= 2000) {
|
|
2833
|
-
state.renderNodes = state.visibleNodes
|
|
2834
|
-
state.renderClusters = []
|
|
2835
|
-
state.renderClusterEdges = []
|
|
2836
|
-
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
2837
|
-
state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
|
|
2838
|
-
return
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
2842
|
-
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2843
|
-
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
2844
|
-
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
2845
|
-
const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
|
|
2846
|
-
const carryViewport = expandViewportBounds(viewport, carryMargin)
|
|
2847
|
-
const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
|
|
2848
|
-
const carryOverNodes = (state.renderNodes ?? [])
|
|
2849
|
-
.filter((node) => isNodeInViewport(node, carryViewport))
|
|
2850
|
-
.slice(0, carryOverLimit)
|
|
2851
|
-
const sourceWithCarry = mergeUniqueNodes(
|
|
2852
|
-
sourceNodes,
|
|
2853
|
-
carryOverNodes,
|
|
2854
|
-
Math.max(sampleLimit * 7, carryOverLimit)
|
|
2855
|
-
)
|
|
2856
|
-
const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
|
|
2857
|
-
const sampledRaw = selectStableSampleNodes(
|
|
2858
|
-
sourceWithCarry,
|
|
2859
|
-
sampleLimit
|
|
2860
|
-
)
|
|
2861
|
-
const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
|
|
2862
|
-
const previousVisibleNodes = (state.renderNodes ?? [])
|
|
2863
|
-
.filter((node) => sourceWithCarryIds.has(node.id))
|
|
2864
|
-
const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
|
|
2865
|
-
const sampled = mergeUniqueNodes(
|
|
2866
|
-
continuityNodes,
|
|
2867
|
-
sampledRaw,
|
|
2868
|
-
sampleLimit
|
|
2869
|
-
)
|
|
2870
|
-
let sampledNodes = ensureHubNodesInRenderedSet(sampled)
|
|
2871
|
-
if (state.transform.scale < 0.035) {
|
|
2872
|
-
sampledNodes = includeHubPreviewNeighborhood(
|
|
2873
|
-
sampledNodes,
|
|
2874
|
-
Math.min(renderNodeBudget, sampleLimit + 160)
|
|
2875
|
-
)
|
|
2876
|
-
}
|
|
2877
|
-
const sampledIds = new Set(sampledNodes.map((node) => node.id))
|
|
2878
|
-
let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
|
|
2879
|
-
|
|
2880
|
-
if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
|
|
2881
|
-
const enriched = enrichSampleWithNeighbors(sampledNodes)
|
|
2882
|
-
sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
|
|
2883
|
-
const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
|
|
2884
|
-
sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
state.renderClusters = []
|
|
2888
|
-
state.renderClusterEdges = []
|
|
2889
|
-
state.renderNodes = sampledNodes
|
|
2890
|
-
state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
|
|
2891
|
-
return
|
|
2892
|
-
}
|
|
2893
|
-
|
|
2894
|
-
if (state.transform.scale <= 0.0015) {
|
|
2895
|
-
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
2896
|
-
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
2897
|
-
state.renderClusters = []
|
|
2898
|
-
state.renderClusterEdges = []
|
|
2899
|
-
state.renderNodes = sampled
|
|
2900
|
-
state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
|
|
2901
|
-
return
|
|
2902
|
-
}
|
|
2903
|
-
|
|
2904
|
-
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2905
|
-
const clusters = clusterViewportNodes(viewportNodes)
|
|
2906
|
-
if (clusters.length > 0) {
|
|
2907
|
-
state.renderClusters = []
|
|
2908
|
-
state.renderClusterEdges = []
|
|
2909
|
-
state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
|
|
2910
|
-
state.renderEdges = []
|
|
2911
|
-
return
|
|
2912
|
-
}
|
|
2913
|
-
state.renderClusters = []
|
|
2914
|
-
state.renderClusterEdges = []
|
|
2915
|
-
const stride = viewportNodeStride()
|
|
2916
|
-
const picked = []
|
|
2917
|
-
|
|
2918
|
-
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
2919
|
-
const node = viewportNodes[index]
|
|
2920
|
-
|
|
2921
|
-
const isPriority =
|
|
2922
|
-
node.id === state.selected?.id ||
|
|
2923
|
-
node.id === state.hovered?.id ||
|
|
2924
|
-
node.id === state.pointer.dragNode?.id
|
|
2925
|
-
if (isPriority || index % stride === 0) {
|
|
2926
|
-
picked.push(node)
|
|
2927
|
-
}
|
|
2928
|
-
}
|
|
2929
|
-
|
|
2930
|
-
const nodes = picked.length > renderNodeBudget
|
|
2931
|
-
? picked.slice(0, renderNodeBudget)
|
|
2932
|
-
: picked
|
|
2933
|
-
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
2934
|
-
const fallbackNodes = fallbackViewportNodes()
|
|
2935
|
-
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
2936
|
-
state.renderNodes = fallbackNodes
|
|
2937
|
-
state.renderClusters = []
|
|
2938
|
-
state.renderClusterEdges = []
|
|
2939
|
-
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
2940
|
-
return
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2943
|
-
const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
|
|
2944
|
-
const nodeIds = new Set(normalizedNodes.map((node) => node.id))
|
|
2945
|
-
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
2946
|
-
|
|
2947
|
-
state.renderNodes = normalizedNodes
|
|
2948
|
-
state.renderEdges = withMeshEdges(normalizedNodes, edges)
|
|
2949
|
-
|
|
2950
|
-
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
2951
|
-
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
2952
|
-
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
2953
|
-
state.renderClusters = []
|
|
2954
|
-
state.renderClusterEdges = []
|
|
2955
|
-
state.renderNodes = fallbackNodes
|
|
2956
|
-
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
2957
|
-
}
|
|
2958
|
-
}
|
|
2959
|
-
|
|
2960
|
-
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
2961
|
-
const radius = nodeRadius(node) * state.transform.scale
|
|
2962
|
-
const screenX = node.x * state.transform.scale + state.transform.x
|
|
2963
|
-
const screenY = node.y * state.transform.scale + state.transform.y
|
|
2964
|
-
|
|
2965
|
-
return (
|
|
2966
|
-
screenX + radius >= 0 &&
|
|
2967
|
-
screenX - radius <= width &&
|
|
2968
|
-
screenY + radius >= 0 &&
|
|
2969
|
-
screenY - radius <= height
|
|
2970
|
-
)
|
|
2971
|
-
}
|
|
2972
|
-
|
|
2973
|
-
const hasValidTransform = () =>
|
|
2974
|
-
isFiniteNumber(state.transform.x) &&
|
|
2975
|
-
isFiniteNumber(state.transform.y) &&
|
|
2976
|
-
isFiniteNumber(state.transform.scale) &&
|
|
2977
|
-
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
2978
|
-
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
2979
|
-
state.transform.scale > 0
|
|
2980
|
-
|
|
2981
|
-
const sanitizeNodePosition = node => {
|
|
2982
|
-
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
2983
|
-
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
2984
|
-
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
2985
|
-
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
2986
|
-
}
|
|
2987
|
-
|
|
2988
|
-
const sanitizeAllNodePositions = () => {
|
|
2989
|
-
state.nodes.forEach(sanitizeNodePosition)
|
|
2990
|
-
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
2991
|
-
}
|
|
2992
|
-
|
|
2993
|
-
const sanitizeGraphState = () => {
|
|
2994
|
-
state.renderNodes.forEach(sanitizeNodePosition)
|
|
2995
|
-
}
|
|
2996
|
-
|
|
2997
|
-
const render = now => {
|
|
2998
|
-
const delta = now - state.last
|
|
2999
|
-
state.last = now
|
|
3000
|
-
const backgroundFrameIntervalMs =
|
|
3001
|
-
state.nodes.length > massiveGraphNodeThreshold
|
|
3002
|
-
? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
|
|
3003
|
-
: state.nodes.length > largeGraphNodeThreshold
|
|
3004
|
-
? 64
|
|
3005
|
-
: 16
|
|
3006
|
-
const isInteracting =
|
|
3007
|
-
state.pointer.down ||
|
|
3008
|
-
state.renderVisibilityDirty ||
|
|
3009
|
-
state.recoveringViewport
|
|
3010
|
-
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
3011
|
-
if (delta < minFrameIntervalMs) {
|
|
3012
|
-
requestAnimationFrame(render)
|
|
3013
|
-
return
|
|
3014
|
-
}
|
|
3015
|
-
const rect = canvas.getBoundingClientRect()
|
|
3016
|
-
const width = Math.max(rect.width, 320)
|
|
3017
|
-
const height = Math.max(rect.height, 320)
|
|
3018
|
-
sanitizeGraphState()
|
|
3019
|
-
if (!hasValidTransform()) {
|
|
3020
|
-
resetView()
|
|
3021
|
-
}
|
|
3022
|
-
ctx.clearRect(0, 0, width, height)
|
|
3023
|
-
webGlRenderer?.clear(width, height)
|
|
3024
|
-
if (state.nodes.length === 0) {
|
|
3025
|
-
ctx.fillStyle = '#99a5b5'
|
|
3026
|
-
ctx.font = '14px Inter, system-ui, sans-serif'
|
|
3027
|
-
ctx.textAlign = 'center'
|
|
3028
|
-
ctx.fillText('No indexed notes found', width / 2, height / 2)
|
|
3029
|
-
requestAnimationFrame(render)
|
|
3030
|
-
return
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
computeRenderVisibility()
|
|
3034
|
-
tick(delta)
|
|
3035
|
-
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
3036
|
-
const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
|
|
3037
|
-
const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
|
|
3038
|
-
if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
|
|
3039
|
-
state.offscreenFrameCount += 1
|
|
3040
|
-
if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
|
|
3041
|
-
state.recoveringViewport = true
|
|
3042
|
-
fitView({ useFiltered: true })
|
|
3043
|
-
state.offscreenFrameCount = 0
|
|
3044
|
-
requestAnimationFrame(() => {
|
|
3045
|
-
state.recoveringViewport = false
|
|
3046
|
-
})
|
|
3047
|
-
}
|
|
3048
|
-
} else {
|
|
3049
|
-
state.offscreenFrameCount = 0
|
|
3050
|
-
}
|
|
3051
|
-
const minimumEdgeScale =
|
|
3052
|
-
state.nodes.length > massiveGraphNodeThreshold
|
|
3053
|
-
? 0
|
|
3054
|
-
: state.renderNodes.length > 1300
|
|
3055
|
-
? 0.12
|
|
3056
|
-
: state.renderNodes.length > 900
|
|
3057
|
-
? 0.085
|
|
3058
|
-
: state.renderNodes.length > 500
|
|
3059
|
-
? 0.05
|
|
3060
|
-
: 0
|
|
3061
|
-
const drawEdges =
|
|
3062
|
-
state.renderClusters.length === 0 &&
|
|
3063
|
-
state.transform.scale >= minimumEdgeScale
|
|
3064
|
-
if (drawAcceleratedGraph(width, height, drawEdges)) {
|
|
3065
|
-
// WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
|
|
3066
|
-
} else if (state.renderClusters.length > 0) {
|
|
3067
|
-
ctx.save()
|
|
3068
|
-
ctx.translate(state.transform.x, state.transform.y)
|
|
3069
|
-
ctx.scale(state.transform.scale, state.transform.scale)
|
|
3070
|
-
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
3071
|
-
if (state.renderClusterEdges.length > 0) {
|
|
3072
|
-
for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
|
|
3073
|
-
const edge = state.renderClusterEdges[index]
|
|
3074
|
-
const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
|
|
3075
|
-
if (edgeOpacity <= 0.01) {
|
|
3076
|
-
continue
|
|
3077
|
-
}
|
|
3078
|
-
ctx.beginPath()
|
|
3079
|
-
ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
|
|
3080
|
-
ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
|
|
3081
|
-
ctx.lineWidth = 1.2 / safeScale
|
|
3082
|
-
ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
|
|
3083
|
-
ctx.stroke()
|
|
3084
|
-
}
|
|
3085
|
-
}
|
|
3086
|
-
state.renderClusters.forEach(cluster => {
|
|
3087
|
-
const isMacro = cluster.id === 'macro-galaxy'
|
|
3088
|
-
const isEcosystem = String(cluster.id).startsWith('ecosystem-')
|
|
3089
|
-
const isHub = Boolean(cluster.isHub)
|
|
3090
|
-
const opacity = clusterOpacity(cluster)
|
|
3091
|
-
if (opacity <= 0.01) {
|
|
3092
1578
|
return
|
|
3093
1579
|
}
|
|
3094
|
-
const radiusPx = clusterRadiusPx(cluster)
|
|
3095
|
-
const radius = radiusPx / safeScale
|
|
3096
|
-
const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
|
|
3097
|
-
ctx.globalAlpha = opacity
|
|
3098
|
-
if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
|
|
3099
|
-
ctx.beginPath()
|
|
3100
|
-
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
3101
|
-
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
3102
|
-
ctx.fill()
|
|
3103
|
-
}
|
|
3104
|
-
ctx.beginPath()
|
|
3105
|
-
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
3106
|
-
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
3107
|
-
ctx.fill()
|
|
3108
|
-
ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
|
|
3109
|
-
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
3110
|
-
ctx.stroke()
|
|
3111
|
-
if (isMacro && cluster.representative?.title) {
|
|
3112
|
-
ctx.fillStyle = '#edf2f7'
|
|
3113
|
-
ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
|
|
3114
|
-
ctx.textAlign = 'center'
|
|
3115
|
-
ctx.textBaseline = 'top'
|
|
3116
|
-
ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
|
|
3117
|
-
}
|
|
3118
|
-
ctx.globalAlpha = 1
|
|
3119
|
-
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
3120
|
-
})
|
|
3121
|
-
ctx.restore()
|
|
3122
|
-
} else {
|
|
3123
|
-
ctx.save()
|
|
3124
|
-
ctx.translate(state.transform.x, state.transform.y)
|
|
3125
|
-
ctx.scale(state.transform.scale, state.transform.scale)
|
|
3126
|
-
if (drawEdges) {
|
|
3127
|
-
drawGraphEdges()
|
|
3128
|
-
}
|
|
3129
|
-
drawGraphNodes()
|
|
3130
|
-
ctx.restore()
|
|
3131
|
-
}
|
|
3132
|
-
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
3133
|
-
ctx.fillStyle = '#99a5b5'
|
|
3134
|
-
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
3135
|
-
ctx.textAlign = 'center'
|
|
3136
|
-
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
3137
|
-
}
|
|
3138
|
-
requestAnimationFrame(render)
|
|
3139
|
-
}
|
|
3140
|
-
|
|
3141
|
-
const list = items => items.length
|
|
3142
|
-
? 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('')
|
|
3143
|
-
: '<li><small>No links found.</small></li>'
|
|
3144
|
-
|
|
3145
|
-
const linkedNodes = node => {
|
|
3146
|
-
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
3147
|
-
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
3148
|
-
...linkedNode,
|
|
3149
|
-
weight: edge.weight,
|
|
3150
|
-
priority: edge.priority
|
|
3151
|
-
} : null
|
|
3152
|
-
const outgoing = state.edges
|
|
3153
|
-
.filter(edge => edge.source === node.id)
|
|
3154
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
3155
|
-
.filter(Boolean)
|
|
3156
|
-
const incoming = state.edges
|
|
3157
|
-
.filter(edge => edge.target === node.id)
|
|
3158
|
-
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
3159
|
-
.filter(Boolean)
|
|
3160
|
-
|
|
3161
|
-
return { outgoing, incoming }
|
|
3162
|
-
}
|
|
3163
|
-
|
|
3164
|
-
const fetchNodeDetails = async node => {
|
|
3165
|
-
const cached = state.nodeDetails.get(node.id)
|
|
3166
|
-
if (cached) {
|
|
3167
|
-
return cached
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
|
-
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
3171
|
-
if (!response.ok) {
|
|
3172
|
-
throw new Error('Failed to load graph node details')
|
|
3173
|
-
}
|
|
3174
1580
|
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
throw new Error('Invalid graph node payload')
|
|
3179
|
-
}
|
|
3180
|
-
state.nodeDetails.set(detail.id, detail)
|
|
3181
|
-
return detail
|
|
3182
|
-
}
|
|
3183
|
-
|
|
3184
|
-
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
3185
|
-
|
|
3186
|
-
const openContentDialog = async node => {
|
|
3187
|
-
if (!node) return
|
|
3188
|
-
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
3189
|
-
elements.contentPath.textContent = node.path || 'Loading...'
|
|
3190
|
-
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
3191
|
-
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
3192
|
-
: '<span>No tags</span>'
|
|
3193
|
-
const initialLinks = linkedNodes(node)
|
|
3194
|
-
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
3195
|
-
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
3196
|
-
elements.contentBody.textContent = 'Loading note content...'
|
|
3197
|
-
if (!elements.contentDialog.open) {
|
|
3198
|
-
elements.contentDialog.showModal()
|
|
3199
|
-
}
|
|
3200
|
-
|
|
3201
|
-
const applyDetailToDialog = detail => {
|
|
3202
|
-
elements.contentTitle.textContent = detail.title
|
|
3203
|
-
elements.contentPath.textContent = detail.path
|
|
3204
|
-
elements.contentTags.innerHTML = detail.tags.length
|
|
3205
|
-
? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
3206
|
-
: '<span>No tags</span>'
|
|
3207
|
-
elements.contentBody.textContent = detail.content
|
|
3208
|
-
}
|
|
3209
|
-
|
|
3210
|
-
try {
|
|
3211
|
-
const detailedNode = await fetchNodeDetails(node)
|
|
3212
|
-
if (state.selected?.id !== node.id) {
|
|
3213
|
-
return
|
|
3214
|
-
}
|
|
3215
|
-
applyDetailToDialog(detailedNode)
|
|
3216
|
-
} catch {
|
|
3217
|
-
try {
|
|
3218
|
-
await wait(120)
|
|
3219
|
-
const retriedNode = await fetchNodeDetails(node)
|
|
3220
|
-
if (state.selected?.id !== node.id) {
|
|
1581
|
+
if (payload.type === 'frame-stats') {
|
|
1582
|
+
state.lastVisibleNodes = Number.isFinite(payload.visibleNodes) ? payload.visibleNodes : state.lastVisibleNodes
|
|
1583
|
+
state.lastVisibleEdges = Number.isFinite(payload.visibleEdges) ? payload.visibleEdges : state.lastVisibleEdges
|
|
3221
1584
|
return
|
|
3222
1585
|
}
|
|
3223
|
-
applyDetailToDialog(retriedNode)
|
|
3224
|
-
} catch {
|
|
3225
|
-
elements.contentBody.textContent = 'Unable to load note content.'
|
|
3226
|
-
}
|
|
3227
|
-
}
|
|
3228
|
-
}
|
|
3229
|
-
|
|
3230
|
-
const selectNode = (node, options = { openContent: false }) => {
|
|
3231
|
-
state.selected = node
|
|
3232
|
-
if (node && options.openContent) {
|
|
3233
|
-
openContentDialog(node).catch(() => {
|
|
3234
|
-
elements.contentBody.textContent = 'Unable to load note content.'
|
|
3235
|
-
})
|
|
3236
|
-
}
|
|
3237
|
-
}
|
|
3238
|
-
|
|
3239
|
-
const selectNodeById = id => {
|
|
3240
|
-
const node = state.nodes.find(item => item.id === id)
|
|
3241
|
-
if (node) selectNode(node, { openContent: true })
|
|
3242
|
-
}
|
|
3243
1586
|
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
const worldY = worldPointAtCursor.y
|
|
3254
|
-
state.lastZoomFocus = {
|
|
3255
|
-
x: worldX,
|
|
3256
|
-
y: worldY,
|
|
3257
|
-
at: performance.now()
|
|
3258
|
-
}
|
|
3259
|
-
state.transform.scale = clampScale(nextScale)
|
|
3260
|
-
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
3261
|
-
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
3262
|
-
state.offscreenFrameCount = 0
|
|
3263
|
-
markRenderDirty()
|
|
3264
|
-
}
|
|
3265
|
-
|
|
3266
|
-
const wheelZoomFactor = event => {
|
|
3267
|
-
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
3268
|
-
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
3269
|
-
const normalizedDelta = event.deltaY * deltaModeFactor
|
|
3270
|
-
|
|
3271
|
-
if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
|
|
3272
|
-
return 1
|
|
3273
|
-
}
|
|
3274
|
-
|
|
3275
|
-
const isMassiveEcosystemZoom =
|
|
3276
|
-
state.visibleNodes.length > massiveGraphNodeThreshold &&
|
|
3277
|
-
state.transform.scale <= massiveEcosystemClusterScaleThreshold
|
|
3278
|
-
const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
|
|
3279
|
-
const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
|
|
3280
|
-
const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier
|
|
3281
|
-
const exponentCap = wheelZoomExponentCap * capMultiplier
|
|
3282
|
-
const exponent = Math.max(
|
|
3283
|
-
-exponentCap,
|
|
3284
|
-
Math.min(exponentCap, -normalizedDelta * sensitivity)
|
|
3285
|
-
)
|
|
3286
|
-
return Math.exp(exponent)
|
|
3287
|
-
}
|
|
3288
|
-
|
|
3289
|
-
const handleWheelZoom = event => {
|
|
3290
|
-
if (elements.contentDialog?.open) {
|
|
3291
|
-
return
|
|
3292
|
-
}
|
|
1587
|
+
if (payload.type === 'fatal') {
|
|
1588
|
+
console.error(payload.message)
|
|
1589
|
+
state.rendererMode = 'fallback'
|
|
1590
|
+
state.workerReady = false
|
|
1591
|
+
state.renderWorker.terminate()
|
|
1592
|
+
state.renderWorker = null
|
|
1593
|
+
drawFallback()
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
3293
1596
|
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
1597
|
+
worker.postMessage({
|
|
1598
|
+
type: 'init',
|
|
1599
|
+
canvas: offscreen,
|
|
1600
|
+
width: state.viewport.width,
|
|
1601
|
+
height: state.viewport.height,
|
|
1602
|
+
devicePixelRatio: state.viewport.ratio,
|
|
1603
|
+
camera: state.camera,
|
|
1604
|
+
theme: graphTheme
|
|
1605
|
+
}, [offscreen])
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
console.error(error)
|
|
1608
|
+
state.rendererMode = 'fallback'
|
|
1609
|
+
drawFallback()
|
|
3305
1610
|
}
|
|
3306
|
-
|
|
3307
|
-
zoomAtPoint(cursorX, cursorY, factor, 'wheel')
|
|
3308
1611
|
}
|
|
3309
1612
|
|
|
3310
|
-
const
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
state.query = event.target.value
|
|
3314
|
-
recomputeVisibility()
|
|
3315
|
-
scheduleContentFilterSync()
|
|
3316
|
-
})
|
|
3317
|
-
elements.agent.addEventListener('change', event => {
|
|
3318
|
-
state.agentId = event.target.value
|
|
3319
|
-
writeStoredAgent(state.agentId)
|
|
3320
|
-
syncAgentInUrl(state.agentId)
|
|
3321
|
-
state.selected = null
|
|
3322
|
-
state.nodeDetails = new Map()
|
|
3323
|
-
resetContentFilter()
|
|
3324
|
-
recomputeVisibility()
|
|
3325
|
-
scheduleContentFilterSync()
|
|
3326
|
-
loadGraph({ reset: true }).catch(error => {
|
|
3327
|
-
console.error(error)
|
|
3328
|
-
})
|
|
3329
|
-
})
|
|
3330
|
-
elements.zoomIn.addEventListener('click', () => {
|
|
3331
|
-
const rect = canvas.getBoundingClientRect()
|
|
3332
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.055, 'button')
|
|
3333
|
-
})
|
|
3334
|
-
elements.zoomOut.addEventListener('click', () => {
|
|
3335
|
-
const rect = canvas.getBoundingClientRect()
|
|
3336
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.948, 'button')
|
|
3337
|
-
})
|
|
3338
|
-
if (elements.fit) {
|
|
3339
|
-
elements.fit.addEventListener('click', () => {
|
|
3340
|
-
focusPrimaryHub()
|
|
3341
|
-
})
|
|
3342
|
-
}
|
|
3343
|
-
elements.reset.addEventListener('click', () => {
|
|
3344
|
-
resetView()
|
|
3345
|
-
})
|
|
3346
|
-
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
3347
|
-
elements.contentDialog.addEventListener('click', event => {
|
|
1613
|
+
const wireNodeLinkClicks = () => {
|
|
1614
|
+
const dialog = elements.contentDialog
|
|
1615
|
+
dialog.addEventListener('click', (event) => {
|
|
3348
1616
|
const target = event.target
|
|
3349
|
-
if (target instanceof HTMLElement
|
|
3350
|
-
selectNodeById(target.dataset.nodeId)
|
|
3351
|
-
return
|
|
3352
|
-
}
|
|
3353
|
-
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
3354
|
-
})
|
|
3355
|
-
canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
3356
|
-
canvas.addEventListener('dblclick', event => {
|
|
3357
|
-
const point = worldPoint(event)
|
|
3358
|
-
const node = hitNode(point)
|
|
3359
|
-
if (node) {
|
|
3360
|
-
selectNode(node, { openContent: true })
|
|
1617
|
+
if (!(target instanceof HTMLElement)) {
|
|
3361
1618
|
return
|
|
3362
1619
|
}
|
|
3363
1620
|
|
|
3364
|
-
const
|
|
3365
|
-
|
|
3366
|
-
const cursorY = event.clientY - rect.top
|
|
3367
|
-
zoomAtPoint(cursorX, cursorY, 1.055)
|
|
3368
|
-
})
|
|
3369
|
-
canvas.addEventListener('pointerdown', event => {
|
|
3370
|
-
const point = worldPoint(event)
|
|
3371
|
-
const node = hitNode(point)
|
|
3372
|
-
state.pointer = { x: event.clientX, y: event.clientY, down: true, dragNode: node, moved: false }
|
|
3373
|
-
if (node) {
|
|
3374
|
-
node.x = point.x
|
|
3375
|
-
node.y = point.y
|
|
3376
|
-
markRenderDirty()
|
|
3377
|
-
}
|
|
3378
|
-
canvas.setPointerCapture(event.pointerId)
|
|
3379
|
-
})
|
|
3380
|
-
canvas.addEventListener('pointermove', event => {
|
|
3381
|
-
const point = worldPoint(event)
|
|
3382
|
-
const now = performance.now()
|
|
3383
|
-
const canHoverHitTest =
|
|
3384
|
-
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
|
|
3385
|
-
const shouldHitTest = canHoverHitTest &&
|
|
3386
|
-
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
3387
|
-
if (shouldHitTest) {
|
|
3388
|
-
state.hovered = hitNode(point)
|
|
3389
|
-
state.lastHoverHitAt = now
|
|
3390
|
-
} else if (!canHoverHitTest) {
|
|
3391
|
-
state.hovered = null
|
|
3392
|
-
}
|
|
3393
|
-
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
3394
|
-
if (!state.pointer.down) return
|
|
3395
|
-
const dx = event.clientX - state.pointer.x
|
|
3396
|
-
const dy = event.clientY - state.pointer.y
|
|
3397
|
-
state.pointer.x = event.clientX
|
|
3398
|
-
state.pointer.y = event.clientY
|
|
3399
|
-
state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
|
|
3400
|
-
if (state.pointer.dragNode) {
|
|
3401
|
-
const dragNode = state.pointer.dragNode
|
|
3402
|
-
const previousX = dragNode.x
|
|
3403
|
-
const previousY = dragNode.y
|
|
3404
|
-
dragNode.x = point.x
|
|
3405
|
-
dragNode.y = point.y
|
|
3406
|
-
applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
|
|
3407
|
-
markRenderDirty()
|
|
3408
|
-
return
|
|
3409
|
-
}
|
|
3410
|
-
state.transform.x += dx
|
|
3411
|
-
state.transform.y += dy
|
|
3412
|
-
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
3413
|
-
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
3414
|
-
state.offscreenFrameCount = 0
|
|
3415
|
-
markRenderDirty()
|
|
3416
|
-
})
|
|
3417
|
-
canvas.addEventListener('pointerup', event => {
|
|
3418
|
-
const draggedNode = state.pointer.dragNode
|
|
3419
|
-
if (draggedNode && state.pointer.moved) {
|
|
3420
|
-
settleNeighborhoodAroundNode(draggedNode)
|
|
3421
|
-
markRenderDirty()
|
|
3422
|
-
}
|
|
3423
|
-
if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
|
|
3424
|
-
if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
|
|
3425
|
-
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
3426
|
-
canvas.releasePointerCapture(event.pointerId)
|
|
3427
|
-
})
|
|
3428
|
-
canvas.addEventListener('pointercancel', () => {
|
|
3429
|
-
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
3430
|
-
})
|
|
3431
|
-
canvas.addEventListener('pointerenter', event => {
|
|
3432
|
-
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
3433
|
-
})
|
|
3434
|
-
canvas.addEventListener('pointerleave', event => {
|
|
3435
|
-
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
3436
|
-
})
|
|
3437
|
-
window.addEventListener('keydown', event => {
|
|
3438
|
-
if (event.key === '+' || event.key === '=') {
|
|
3439
|
-
event.preventDefault()
|
|
3440
|
-
const rect = canvas.getBoundingClientRect()
|
|
3441
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.05)
|
|
3442
|
-
return
|
|
3443
|
-
}
|
|
3444
|
-
|
|
3445
|
-
if (event.key === '-' || event.key === '_') {
|
|
3446
|
-
event.preventDefault()
|
|
3447
|
-
const rect = canvas.getBoundingClientRect()
|
|
3448
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.952)
|
|
1621
|
+
const button = target.closest('button[data-node-id]')
|
|
1622
|
+
if (!button) {
|
|
3449
1623
|
return
|
|
3450
1624
|
}
|
|
3451
1625
|
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
1626
|
+
const id = button.getAttribute('data-node-id') || ''
|
|
1627
|
+
if (id) {
|
|
1628
|
+
loadNodeDetails(id).catch((error) => console.error(error))
|
|
3455
1629
|
}
|
|
3456
1630
|
})
|
|
3457
1631
|
}
|
|
3458
1632
|
|
|
3459
|
-
const
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
? preferredAgent
|
|
3467
|
-
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
3468
|
-
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
3469
|
-
|
|
3470
|
-
state.agentId = selected
|
|
3471
|
-
writeStoredAgent(selected)
|
|
3472
|
-
syncAgentInUrl(selected)
|
|
3473
|
-
if (signature !== state.agentsSignature) {
|
|
3474
|
-
const formatAgentLabel = (agent) => agent.id
|
|
3475
|
-
elements.agent.innerHTML = agents.length
|
|
3476
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
3477
|
-
: '<option value="shared">shared</option>'
|
|
3478
|
-
state.agentsSignature = signature
|
|
3479
|
-
}
|
|
3480
|
-
elements.agent.value = selected
|
|
3481
|
-
}
|
|
3482
|
-
|
|
3483
|
-
const loadGraph = async (options = { reset: false }) => {
|
|
3484
|
-
const response = await fetch('/api/graph-layout' + agentQuery(), {
|
|
3485
|
-
headers: state.graphSignature
|
|
3486
|
-
? {
|
|
3487
|
-
'if-none-match': encodeEntityTag(state.graphSignature)
|
|
3488
|
-
}
|
|
3489
|
-
: undefined
|
|
3490
|
-
})
|
|
3491
|
-
|
|
3492
|
-
if (response.status === 304) {
|
|
3493
|
-
return
|
|
3494
|
-
}
|
|
1633
|
+
const bootstrap = async () => {
|
|
1634
|
+
setViewportFromCanvas()
|
|
1635
|
+
setupRenderWorker()
|
|
1636
|
+
setupInput()
|
|
1637
|
+
setupControls()
|
|
1638
|
+
setupContextControl()
|
|
1639
|
+
wireNodeLinkClicks()
|
|
3495
1640
|
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
3501
|
-
}
|
|
3502
|
-
const signature = payload?.signature ?? graphSignature(graph)
|
|
3503
|
-
if (!options.reset && signature === state.graphSignature) return
|
|
3504
|
-
const selectedId = state.selected?.id
|
|
3505
|
-
const layout = createLayout(graph)
|
|
3506
|
-
state.graphSignature = signature
|
|
3507
|
-
state.graph = graph
|
|
3508
|
-
state.nodes = layout.nodes
|
|
3509
|
-
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
3510
|
-
state.edges = layout.edges
|
|
3511
|
-
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
3512
|
-
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
3513
|
-
if (edge.target) {
|
|
3514
|
-
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
3515
|
-
}
|
|
3516
|
-
return degrees
|
|
3517
|
-
}, new Map())
|
|
3518
|
-
state.nodeDetails = new Map()
|
|
3519
|
-
pushNodesToFilterWorker()
|
|
3520
|
-
resetContentFilter()
|
|
3521
|
-
sanitizeAllNodePositions()
|
|
3522
|
-
recomputeVisibility()
|
|
3523
|
-
scheduleContentFilterSync()
|
|
3524
|
-
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
3525
|
-
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
3526
|
-
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
3527
|
-
elements.edgeCount.textContent = state.graphTotals.edges
|
|
3528
|
-
elements.tagCount.textContent = tags.size
|
|
3529
|
-
resize()
|
|
3530
|
-
if (options.reset) resetView()
|
|
3531
|
-
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
3532
|
-
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
3533
|
-
if (!selectedNode && elements.contentDialog.open) {
|
|
3534
|
-
elements.contentDialog.close()
|
|
3535
|
-
}
|
|
3536
|
-
}
|
|
3537
|
-
|
|
3538
|
-
bindEvents()
|
|
3539
|
-
initFilterWorker()
|
|
3540
|
-
requestAnimationFrame(() => {
|
|
3541
|
-
resize()
|
|
3542
|
-
resetView()
|
|
3543
|
-
})
|
|
3544
|
-
|
|
3545
|
-
const pollIntervalMs = 5000
|
|
3546
|
-
let tickCounter = 0
|
|
3547
|
-
|
|
3548
|
-
const refreshGraphLoop = () => {
|
|
3549
|
-
if (document.hidden) {
|
|
3550
|
-
return
|
|
3551
|
-
}
|
|
1641
|
+
window.addEventListener('resize', () => {
|
|
1642
|
+
setViewportFromCanvas()
|
|
1643
|
+
scheduleChunkFetch()
|
|
1644
|
+
})
|
|
3552
1645
|
|
|
3553
|
-
|
|
1646
|
+
await loadAgents()
|
|
1647
|
+
await loadContexts()
|
|
1648
|
+
updateTotals()
|
|
1649
|
+
updateTagCount()
|
|
3554
1650
|
|
|
3555
|
-
|
|
3556
|
-
if (tickCounter % 3 === 0) {
|
|
3557
|
-
loadAgents().catch((error) => {
|
|
3558
|
-
console.error(error)
|
|
3559
|
-
})
|
|
3560
|
-
}
|
|
1651
|
+
scheduleChunkFetch({ fit: true })
|
|
3561
1652
|
}
|
|
3562
1653
|
|
|
3563
|
-
|
|
3564
|
-
.
|
|
3565
|
-
.then(() => {
|
|
3566
|
-
requestAnimationFrame(render)
|
|
3567
|
-
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
3568
|
-
})
|
|
3569
|
-
.catch(error => {
|
|
3570
|
-
console.error(error)
|
|
3571
|
-
})
|
|
3572
|
-
|
|
3573
|
-
document.addEventListener('visibilitychange', () => {
|
|
3574
|
-
if (document.hidden) {
|
|
3575
|
-
return
|
|
3576
|
-
}
|
|
3577
|
-
|
|
3578
|
-
loadGraph({ reset: true }).catch(handleGraphRefreshError)
|
|
1654
|
+
bootstrap().catch((error) => {
|
|
1655
|
+
console.error(error)
|
|
3579
1656
|
})
|
|
3580
1657
|
`;
|