@andespindola/brainlink 0.1.0-beta.99 → 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 -3222
- 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,3430 +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 isMassive = nodeCount > massiveGraphNodeThreshold
|
|
786
|
-
const maxScale = isMassive
|
|
787
|
-
? massiveEcosystemClusterScaleThreshold
|
|
788
|
-
: ecosystemClusterScaleThreshold
|
|
789
|
-
const startScale = isMassive ? 0.82 : 0.18
|
|
790
|
-
const transitionCount = levelSizes.length - 1
|
|
791
|
-
const usableScale = Math.max(0.08, maxScale - startScale)
|
|
792
|
-
const step = usableScale / transitionCount
|
|
793
|
-
const stride = isMassive ? 0.9 : 0.78
|
|
794
|
-
const overlap = isMassive ? 1.28 : 1.75
|
|
795
|
-
const levels = []
|
|
796
|
-
for (let index = 0; index < transitionCount; index += 1) {
|
|
797
|
-
const start = startScale + step * index * stride
|
|
798
|
-
const end = Math.min(maxScale, start + step * overlap)
|
|
799
|
-
levels.push({
|
|
800
|
-
parentSize: levelSizes[index],
|
|
801
|
-
childSize: levelSizes[index + 1],
|
|
802
|
-
start,
|
|
803
|
-
end
|
|
615
|
+
state.renderWorker.postMessage({
|
|
616
|
+
type: 'camera',
|
|
617
|
+
camera: state.camera
|
|
804
618
|
})
|
|
805
|
-
}
|
|
806
|
-
return levels
|
|
619
|
+
})
|
|
807
620
|
}
|
|
808
621
|
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const angle = index * 2.399963229728653
|
|
814
|
-
const radius = spacing * Math.sqrt(index + 1)
|
|
815
|
-
return {
|
|
816
|
-
x: center.x + Math.cos(angle) * radius,
|
|
817
|
-
y: center.y + Math.sin(angle) * radius
|
|
622
|
+
const updateWorkerSize = () => {
|
|
623
|
+
updateGraphOverlays()
|
|
624
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
625
|
+
return
|
|
818
626
|
}
|
|
627
|
+
state.renderWorker.postMessage({
|
|
628
|
+
type: 'resize',
|
|
629
|
+
width: state.viewport.width,
|
|
630
|
+
height: state.viewport.height,
|
|
631
|
+
devicePixelRatio: state.viewport.ratio
|
|
632
|
+
})
|
|
819
633
|
}
|
|
820
634
|
|
|
821
|
-
const
|
|
822
|
-
const count = Math.max(nodes.length, 1)
|
|
823
|
-
const representative = selectEcosystemRepresentative(nodes)
|
|
635
|
+
const normalizeList = (items) => Array.isArray(items) ? items : []
|
|
824
636
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
nodeIds: nodes.map(node => node.id),
|
|
831
|
-
representative,
|
|
832
|
-
label: ecosystemKeyForNode(nodes[0] ?? representative ?? { path: '' })
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const buildEcosystemHubCluster = (hub, center) => hub
|
|
837
|
-
? {
|
|
838
|
-
id: 'ecosystem-hub',
|
|
839
|
-
x: center.x,
|
|
840
|
-
y: center.y,
|
|
841
|
-
count: 1,
|
|
842
|
-
size: 1,
|
|
843
|
-
nodeIds: [hub.id],
|
|
844
|
-
representative: hub,
|
|
845
|
-
label: hub.title || 'Memory Hub',
|
|
846
|
-
parentId: null,
|
|
847
|
-
parentX: null,
|
|
848
|
-
parentY: null,
|
|
849
|
-
isHub: true
|
|
850
|
-
}
|
|
851
|
-
: null
|
|
852
|
-
|
|
853
|
-
const buildEcosystemLevel = (sortedNodes, size, parentLookup, center) => {
|
|
854
|
-
const clusters = []
|
|
855
|
-
const clusterByNodeId = new Map()
|
|
856
|
-
const parentChildIndex = new Map()
|
|
857
|
-
|
|
858
|
-
for (let offset = 0; offset < sortedNodes.length; offset += size) {
|
|
859
|
-
const clusterNodes = sortedNodes.slice(offset, offset + size)
|
|
860
|
-
const parentCluster = parentLookup?.get(clusterNodes[0]?.id)
|
|
861
|
-
const siblingIndex = parentCluster
|
|
862
|
-
? (parentChildIndex.get(parentCluster.id) ?? 0)
|
|
863
|
-
: clusters.length
|
|
864
|
-
if (parentCluster) {
|
|
865
|
-
parentChildIndex.set(parentCluster.id, siblingIndex + 1)
|
|
866
|
-
}
|
|
867
|
-
const point = parentCluster
|
|
868
|
-
? ecosystemCompactPoint(siblingIndex, Math.ceil((parentCluster.count || size) / size), parentCluster, ecosystemLayoutSpacingForSize(size))
|
|
869
|
-
: ecosystemCompactPoint(clusters.length, Math.ceil(sortedNodes.length / size), center, ecosystemLayoutSpacingForSize(size))
|
|
870
|
-
const cluster = {
|
|
871
|
-
...buildEcosystemCluster(clusterNodes, clusters.length, point),
|
|
872
|
-
id: 'ecosystem-' + size + '-' + clusters.length,
|
|
873
|
-
size,
|
|
874
|
-
parentId: parentCluster?.id ?? null,
|
|
875
|
-
parentX: parentCluster?.x ?? null,
|
|
876
|
-
parentY: parentCluster?.y ?? null
|
|
877
|
-
}
|
|
878
|
-
clusters.push(cluster)
|
|
879
|
-
for (let index = 0; index < clusterNodes.length; index += 1) {
|
|
880
|
-
clusterByNodeId.set(clusterNodes[index].id, cluster)
|
|
881
|
-
}
|
|
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
|
|
882
642
|
}
|
|
883
643
|
|
|
884
|
-
|
|
885
|
-
|
|
644
|
+
const next = [...node]
|
|
645
|
+
next[2] = position.x
|
|
646
|
+
next[3] = position.y
|
|
647
|
+
return next
|
|
648
|
+
})
|
|
886
649
|
|
|
887
|
-
const
|
|
888
|
-
if (
|
|
889
|
-
return
|
|
890
|
-
clusters: [],
|
|
891
|
-
clustersBySize: new Map(),
|
|
892
|
-
nodeClusterBySize: new Map(),
|
|
893
|
-
levelSizes: [],
|
|
894
|
-
expansionLevels: [],
|
|
895
|
-
baseSize: ecosystemLevelNodeCap,
|
|
896
|
-
hubCluster: null
|
|
897
|
-
}
|
|
650
|
+
const updateNodePositionInChunk = (nodeId, x, y) => {
|
|
651
|
+
if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
652
|
+
return
|
|
898
653
|
}
|
|
899
654
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
.
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
for (let index = 0; index < levelSizes.length; index += 1) {
|
|
912
|
-
const size = levelSizes[index]
|
|
913
|
-
const level = buildEcosystemLevel(sortedNodes, size, parentLookup, center)
|
|
914
|
-
clustersBySize.set(size, level.clusters)
|
|
915
|
-
nodeClusterBySize.set(size, level.clusterByNodeId)
|
|
916
|
-
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
|
+
})
|
|
917
666
|
}
|
|
667
|
+
state.spatialIndex.key = ''
|
|
918
668
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
clustersBySize,
|
|
922
|
-
nodeClusterBySize,
|
|
923
|
-
levelSizes,
|
|
924
|
-
expansionLevels,
|
|
925
|
-
baseSize,
|
|
926
|
-
hubCluster
|
|
669
|
+
if (state.renderWorker && state.workerReady) {
|
|
670
|
+
state.renderWorker.postMessage({ type: 'move-node', id: nodeId, x, y })
|
|
927
671
|
}
|
|
672
|
+
updateGraphOverlays()
|
|
928
673
|
}
|
|
929
674
|
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
675
|
+
const focusNodeInViewport = (nodeId, nextScale = null) => {
|
|
676
|
+
const node = nodeByIdFromChunk().get(nodeId)
|
|
677
|
+
if (!node) {
|
|
678
|
+
return false
|
|
679
|
+
}
|
|
935
680
|
|
|
936
|
-
const
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
const ecosystemFocusPoint = () => {
|
|
942
|
-
const cursorPoint = cursorWorldPoint()
|
|
943
|
-
if (cursorPoint) {
|
|
944
|
-
return cursorPoint
|
|
945
|
-
}
|
|
946
|
-
const now = performance.now()
|
|
947
|
-
if (now - state.lastZoomFocus.at <= 1800) {
|
|
948
|
-
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
|
|
949
685
|
}
|
|
950
|
-
return viewportCenterWorldPoint()
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
const nearestEcosystemParentIds = (clusters, focusPoint, limit) =>
|
|
954
|
-
clusters
|
|
955
|
-
.map(cluster => ({
|
|
956
|
-
cluster,
|
|
957
|
-
distance: Math.max(
|
|
958
|
-
0,
|
|
959
|
-
Math.hypot(cluster.x - focusPoint.x, cluster.y - focusPoint.y) -
|
|
960
|
-
clusterRadiusPx(cluster) / Math.max(state.transform.scale, 0.0001)
|
|
961
|
-
)
|
|
962
|
-
}))
|
|
963
|
-
.sort((left, right) => left.distance - right.distance)
|
|
964
|
-
.slice(0, limit)
|
|
965
|
-
.map(item => item.cluster.id)
|
|
966
|
-
|
|
967
|
-
const smoothStep = value => {
|
|
968
|
-
const clamped = Math.max(0, Math.min(1, value))
|
|
969
|
-
return clamped * clamped * (3 - clamped * 2)
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
const zoomProgress = (scale, start, end) =>
|
|
973
|
-
smoothStep((scale - start) / Math.max(end - start, 0.0001))
|
|
974
686
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
const curve = Math.pow(progress, spreadExponent)
|
|
978
|
-
if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
|
|
979
|
-
return 0.12 + curve * 0.88
|
|
687
|
+
if (Number.isFinite(nextScale)) {
|
|
688
|
+
state.camera.scale = clampScale(Number(nextScale))
|
|
980
689
|
}
|
|
981
|
-
|
|
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
|
|
982
695
|
}
|
|
983
696
|
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
if (childSize >= Math.ceil(ecosystemLevelNodeCap / 2)) {
|
|
988
|
-
return 0.22 + eased * 0.78
|
|
697
|
+
const showTooltip = (node, pointer) => {
|
|
698
|
+
if (!elements.tooltip || !node) {
|
|
699
|
+
return
|
|
989
700
|
}
|
|
990
|
-
return eased
|
|
991
|
-
}
|
|
992
701
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const childClusters = state.ecosystemClustersBySize.get(childSize) ?? []
|
|
1001
|
-
const visibleChildClusters = childClusters
|
|
1002
|
-
.filter(cluster => expandedParentIds.has(cluster.parentId))
|
|
1003
|
-
.map(cluster => spreadChildClusterFromParent(cluster, childSize, progress, spread))
|
|
1004
|
-
.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
|
+
}
|
|
1005
709
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
710
|
+
const hideTooltip = () => {
|
|
711
|
+
if (elements.tooltip) {
|
|
712
|
+
elements.tooltip.hidden = true
|
|
1009
713
|
}
|
|
1010
714
|
}
|
|
1011
715
|
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
|
1019
727
|
|
|
1020
|
-
return
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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)
|
|
1026
737
|
}
|
|
1027
738
|
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
const hubClusters = state.ecosystemHubCluster ? [state.ecosystemHubCluster] : []
|
|
1032
|
-
const visibleClusters = [...visibleBaseClusters]
|
|
1033
|
-
|
|
1034
|
-
for (let index = 0; index < state.ecosystemExpansionLevels.length; index += 1) {
|
|
1035
|
-
const level = state.ecosystemExpansionLevels[index]
|
|
1036
|
-
const parentClusters = visibleClusters.filter(cluster => cluster.size === level.parentSize)
|
|
1037
|
-
if (parentClusters.length === 0) {
|
|
1038
|
-
continue
|
|
1039
|
-
}
|
|
1040
|
-
const progress = zoomProgress(state.transform.scale, level.start, level.end)
|
|
1041
|
-
const spread = semanticZoomSpread(progress, level.childSize)
|
|
1042
|
-
const expansion = expandFocusedClusters(parentClusters, level.childSize, progress, spread, viewport)
|
|
1043
|
-
visibleClusters.push(...expansion.childClusters)
|
|
739
|
+
const drawLabels = () => {
|
|
740
|
+
if (!elements.labels) {
|
|
741
|
+
return
|
|
1044
742
|
}
|
|
1045
743
|
|
|
1046
|
-
|
|
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('')
|
|
1047
750
|
}
|
|
1048
751
|
|
|
1049
|
-
const
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
if (cluster.isHub || !cluster.parentId) {
|
|
1054
|
-
continue
|
|
1055
|
-
}
|
|
1056
|
-
const siblings = byParent.get(cluster.parentId)
|
|
1057
|
-
if (siblings) {
|
|
1058
|
-
siblings.push(cluster)
|
|
1059
|
-
} else {
|
|
1060
|
-
byParent.set(cluster.parentId, [cluster])
|
|
1061
|
-
}
|
|
752
|
+
const drawMiniMap = () => {
|
|
753
|
+
const miniMap = elements.miniMap
|
|
754
|
+
if (!(miniMap instanceof HTMLCanvasElement)) {
|
|
755
|
+
return
|
|
1062
756
|
}
|
|
1063
|
-
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
.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)))
|
|
1068
|
-
for (let index = 0; index < ordered.length && edges.length < ecosystemSiblingEdgeLimit; index += 1) {
|
|
1069
|
-
const sourceCluster = ordered[index]
|
|
1070
|
-
const targetCluster = ordered[(index + 1) % ordered.length]
|
|
1071
|
-
if (!targetCluster || sourceCluster.id === targetCluster.id) {
|
|
1072
|
-
continue
|
|
1073
|
-
}
|
|
1074
|
-
const orderedIds = sourceCluster.id < targetCluster.id
|
|
1075
|
-
? [sourceCluster.id, targetCluster.id]
|
|
1076
|
-
: [targetCluster.id, sourceCluster.id]
|
|
1077
|
-
const key = orderedIds.join(':')
|
|
1078
|
-
if (existingEdges.has(key)) {
|
|
1079
|
-
continue
|
|
1080
|
-
}
|
|
1081
|
-
const edge = {
|
|
1082
|
-
id: key,
|
|
1083
|
-
sourceCluster,
|
|
1084
|
-
targetCluster,
|
|
1085
|
-
weight: 0.7,
|
|
1086
|
-
inferred: true
|
|
1087
|
-
}
|
|
1088
|
-
existingEdges.set(key, edge)
|
|
1089
|
-
edges.push(edge)
|
|
1090
|
-
}
|
|
757
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
758
|
+
const ctx = miniMap.getContext('2d')
|
|
759
|
+
if (!ctx || nodes.length === 0) {
|
|
760
|
+
return
|
|
1091
761
|
}
|
|
1092
762
|
|
|
1093
|
-
|
|
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))
|
|
1094
803
|
}
|
|
1095
804
|
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
for (let index = 0; index < edgeClusters.length; index += 1) {
|
|
1102
|
-
const cluster = edgeClusters[index]
|
|
1103
|
-
if (!cluster.size || cluster.isHub) continue
|
|
1104
|
-
if (!levelsBySize.some(level => level.size === cluster.size)) {
|
|
1105
|
-
levelsBySize.push({
|
|
1106
|
-
size: cluster.size,
|
|
1107
|
-
lookup: state.ecosystemNodeClusterBySize.get(cluster.size) ?? new Map()
|
|
1108
|
-
})
|
|
1109
|
-
}
|
|
805
|
+
const shouldDeferGraphOverlays = () => state.pointer.down || performance.now() - state.lastWheelAt < 150
|
|
806
|
+
|
|
807
|
+
const updateGraphOverlays = () => {
|
|
808
|
+
if (state.overlayScheduled) {
|
|
809
|
+
return
|
|
1110
810
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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)
|
|
1121
821
|
}
|
|
822
|
+
return
|
|
1122
823
|
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
const edge = state.visibleEdges[index]
|
|
1129
|
-
const sourceCluster = resolveClusterForNode(edge.source)
|
|
1130
|
-
const targetCluster = resolveClusterForNode(edge.target)
|
|
1131
|
-
if (!sourceCluster || !targetCluster || sourceCluster.id === targetCluster.id) {
|
|
1132
|
-
continue
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
const orderedIds = sourceCluster.id < targetCluster.id
|
|
1136
|
-
? [sourceCluster.id, targetCluster.id]
|
|
1137
|
-
: [targetCluster.id, sourceCluster.id]
|
|
1138
|
-
const key = orderedIds.join(':')
|
|
1139
|
-
const current = edgeByClusterPair.get(key)
|
|
1140
|
-
if (current) {
|
|
1141
|
-
current.weight += edgeWeight(edge)
|
|
1142
|
-
continue
|
|
824
|
+
elements.labels?.classList.remove('is-stale')
|
|
825
|
+
drawLabels()
|
|
826
|
+
if (state.miniMapDirty) {
|
|
827
|
+
drawMiniMap()
|
|
828
|
+
state.miniMapDirty = false
|
|
1143
829
|
}
|
|
830
|
+
})
|
|
831
|
+
}
|
|
1144
832
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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>'
|
|
1150
848
|
})
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
ecosystemSiblingEdgesForClusters(edgeClusters, edgeByClusterPair)
|
|
1154
|
-
const edges = Array.from(edgeByClusterPair.values())
|
|
1155
|
-
.sort((left, right) => right.weight - left.weight)
|
|
1156
|
-
.slice(0, ecosystemClusterEdgeLimit)
|
|
1157
|
-
const hubCluster = state.ecosystemHubCluster && clusterIds.has(state.ecosystemHubCluster.id)
|
|
1158
|
-
? state.ecosystemHubCluster
|
|
1159
|
-
: null
|
|
1160
|
-
if (!hubCluster) {
|
|
1161
|
-
return edges
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
const existingHubTargets = new Set(edges.flatMap(edge =>
|
|
1165
|
-
edge.sourceCluster.id === hubCluster.id
|
|
1166
|
-
? [edge.targetCluster.id]
|
|
1167
|
-
: edge.targetCluster.id === hubCluster.id
|
|
1168
|
-
? [edge.sourceCluster.id]
|
|
1169
|
-
: []
|
|
1170
|
-
))
|
|
1171
|
-
const syntheticHubEdges = edgeClusters
|
|
1172
|
-
.filter(cluster => cluster.id !== hubCluster.id && !existingHubTargets.has(cluster.id))
|
|
1173
|
-
.slice(0, ecosystemHubEdgeLimit)
|
|
1174
|
-
.map(cluster => ({
|
|
1175
|
-
id: hubCluster.id + ':' + cluster.id,
|
|
1176
|
-
sourceCluster: hubCluster,
|
|
1177
|
-
targetCluster: cluster,
|
|
1178
|
-
weight: 1,
|
|
1179
|
-
inferred: true
|
|
1180
|
-
}))
|
|
1181
|
-
return edges.concat(syntheticHubEdges)
|
|
849
|
+
.join('')
|
|
1182
850
|
}
|
|
1183
851
|
|
|
1184
|
-
const
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
+
]
|
|
1193
862
|
}
|
|
1194
863
|
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
if (scale < 0.022) return 240
|
|
1199
|
-
if (scale < 0.035) return 360
|
|
1200
|
-
return 520
|
|
1201
|
-
}
|
|
864
|
+
const listFacts = (facts) => facts
|
|
865
|
+
.map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
|
|
866
|
+
.join('')
|
|
1202
867
|
|
|
1203
|
-
const
|
|
1204
|
-
if (
|
|
1205
|
-
|
|
1206
|
-
if (scale < 0.09) return 520
|
|
1207
|
-
if (scale < 0.14) return 720
|
|
1208
|
-
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
1209
|
-
if (scale < 0.28) return renderNodeBudget
|
|
1210
|
-
if (scale < 0.45) return 1100
|
|
1211
|
-
if (scale < 0.7) return 1400
|
|
1212
|
-
if (scale < 1.05) return 1800
|
|
1213
|
-
return zoomedMassiveRenderNodeBudget
|
|
868
|
+
const listContextLinks = (links) => {
|
|
869
|
+
if (!Array.isArray(links) || links.length === 0) {
|
|
870
|
+
return '<li><small>No context links found.</small></li>'
|
|
1214
871
|
}
|
|
1215
|
-
return
|
|
872
|
+
return links
|
|
873
|
+
.map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
|
|
874
|
+
.join('')
|
|
1216
875
|
}
|
|
1217
876
|
|
|
1218
|
-
const
|
|
1219
|
-
const
|
|
1220
|
-
const
|
|
1221
|
-
const shellWidth = Math.max(0.24, 0.46 - normalized * 0.16)
|
|
1222
|
-
const coreRadius = Math.max(0.06, 0.1 + normalized * 0.22)
|
|
1223
|
-
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]))
|
|
1224
880
|
|
|
1225
|
-
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)
|
|
1226
890
|
}
|
|
1227
891
|
|
|
1228
|
-
const
|
|
1229
|
-
const
|
|
1230
|
-
|
|
1231
|
-
return sourceNodes
|
|
1232
|
-
}
|
|
892
|
+
const linkedNodes = (node) => {
|
|
893
|
+
const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
|
|
894
|
+
const edges = normalizeList(state.chunk.edges)
|
|
1233
895
|
|
|
1234
|
-
|
|
1235
|
-
const
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
+
}
|
|
1239
911
|
}
|
|
1240
|
-
return { node, distance }
|
|
1241
|
-
})
|
|
1242
|
-
|
|
1243
|
-
if (maxDistance <= 0.001) {
|
|
1244
|
-
return sourceNodes
|
|
1245
912
|
}
|
|
1246
913
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
...item,
|
|
1250
|
-
normalized: item.distance / maxDistance
|
|
1251
|
-
}))
|
|
1252
|
-
const desired = Math.max(260, Math.min(sourceNodes.length, targetCount * 2))
|
|
1253
|
-
const coreTarget = Math.max(36, Math.min(desired - 8, Math.floor(desired * focus.coreRatio)))
|
|
1254
|
-
const shellTarget = Math.max(12, desired - coreTarget)
|
|
1255
|
-
const shellHalf = focus.shellWidth / 2
|
|
914
|
+
return { outgoing, incoming }
|
|
915
|
+
}
|
|
1256
916
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
|
|
1261
|
-
const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
|
|
1262
|
-
if (leftScore !== rightScore) return rightScore - leftScore
|
|
1263
|
-
return left.node.id.localeCompare(right.node.id)
|
|
1264
|
-
})
|
|
1265
|
-
.slice(0, coreTarget)
|
|
1266
|
-
.map((item) => item.node)
|
|
917
|
+
const openContentDialog = () => {
|
|
918
|
+
elements.contentDialog.hidden = false
|
|
919
|
+
}
|
|
1267
920
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
const rightDelta = Math.abs(right.normalized - focus.shellCenter)
|
|
1272
|
-
const leftInside = leftDelta <= shellHalf ? 0 : 1
|
|
1273
|
-
const rightInside = rightDelta <= shellHalf ? 0 : 1
|
|
1274
|
-
if (leftInside !== rightInside) return leftInside - rightInside
|
|
1275
|
-
if (leftDelta !== rightDelta) return leftDelta - rightDelta
|
|
1276
|
-
const leftScore = state.nodeDegrees.get(left.node.id) ?? 0
|
|
1277
|
-
const rightScore = state.nodeDegrees.get(right.node.id) ?? 0
|
|
1278
|
-
if (leftScore !== rightScore) return rightScore - leftScore
|
|
1279
|
-
return left.node.id.localeCompare(right.node.id)
|
|
1280
|
-
})
|
|
1281
|
-
.slice(0, shellTarget)
|
|
1282
|
-
.map((item) => item.node)
|
|
921
|
+
const closeContentDialog = () => {
|
|
922
|
+
elements.contentDialog.hidden = true
|
|
923
|
+
}
|
|
1283
924
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
if (!node || ids.has(node.id)) return
|
|
1288
|
-
ids.add(node.id)
|
|
1289
|
-
merged.push(node)
|
|
925
|
+
const loadNodeDetails = async (nodeId) => {
|
|
926
|
+
if (!nodeId) {
|
|
927
|
+
return
|
|
1290
928
|
}
|
|
1291
929
|
|
|
1292
|
-
|
|
1293
|
-
|
|
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')
|
|
1294
933
|
}
|
|
1295
|
-
for (let index = 0; index < coreNodes.length; index += 1) pushUnique(coreNodes[index])
|
|
1296
|
-
for (let index = 0; index < shellNodes.length; index += 1) pushUnique(shellNodes[index])
|
|
1297
|
-
|
|
1298
|
-
return merged.length > 0 ? merged : sourceNodes
|
|
1299
|
-
}
|
|
1300
934
|
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
x: (viewport.minX + viewport.maxX) / 2,
|
|
1305
|
-
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')
|
|
1306
938
|
}
|
|
1307
|
-
}
|
|
1308
939
|
|
|
1309
|
-
const
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
})
|
|
940
|
+
const node = payload.node
|
|
941
|
+
state.selectedNodeId = node.id
|
|
942
|
+
setFocusedNodeIds(linkedNodeIds(node.id))
|
|
1313
943
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
return null
|
|
1317
|
-
}
|
|
1318
|
-
const rect = canvas.getBoundingClientRect()
|
|
1319
|
-
const screenX = state.cursor.x - rect.left
|
|
1320
|
-
const screenY = state.cursor.y - rect.top
|
|
1321
|
-
const width = Math.max(rect.width, 320)
|
|
1322
|
-
const height = Math.max(rect.height, 320)
|
|
1323
|
-
if (!Number.isFinite(screenX) || !Number.isFinite(screenY)) {
|
|
1324
|
-
return null
|
|
1325
|
-
}
|
|
1326
|
-
if (screenX < 0 || screenX > width || screenY < 0 || screenY > height) {
|
|
1327
|
-
return null
|
|
944
|
+
if (state.renderWorker && state.workerReady) {
|
|
945
|
+
state.renderWorker.postMessage({ type: 'select', id: node.id })
|
|
1328
946
|
}
|
|
1329
|
-
return screenToWorldPoint(screenX, screenY)
|
|
1330
|
-
}
|
|
1331
947
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
if (safeScale < 0.01) return Math.round(safeScale * 300_000)
|
|
1335
|
-
if (safeScale < 0.05) return Math.round(safeScale * 120_000)
|
|
1336
|
-
if (safeScale < 0.2) return Math.round(safeScale * 40_000)
|
|
1337
|
-
return Math.round(safeScale * 8_000)
|
|
1338
|
-
}
|
|
948
|
+
elements.contentTitle.textContent = node.title || 'Untitled'
|
|
949
|
+
elements.contentPath.textContent = node.path || ''
|
|
1339
950
|
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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 : ''
|
|
1349
964
|
|
|
1350
|
-
|
|
1351
|
-
const exitThreshold = macroGalaxyZoomThreshold * macroGalaxyExitHysteresis
|
|
1352
|
-
const shouldRender = state.macroViewActive
|
|
1353
|
-
? state.transform.scale <= exitThreshold
|
|
1354
|
-
: state.transform.scale <= enterThreshold
|
|
1355
|
-
state.macroViewActive = shouldRender
|
|
1356
|
-
return shouldRender
|
|
965
|
+
openContentDialog()
|
|
1357
966
|
}
|
|
1358
967
|
|
|
1359
|
-
const
|
|
1360
|
-
const
|
|
1361
|
-
|
|
968
|
+
const fitFromChunk = () => {
|
|
969
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
970
|
+
if (nodes.length === 0) {
|
|
971
|
+
return
|
|
972
|
+
}
|
|
1362
973
|
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
|
1366
985
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
986
|
+
if (x < minX) minX = x
|
|
987
|
+
if (y < minY) minY = y
|
|
988
|
+
if (x > maxX) maxX = x
|
|
989
|
+
if (y > maxY) maxY = y
|
|
1369
990
|
}
|
|
1370
991
|
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
}
|
|
1374
|
-
for (let index = 0; index < rightNodes.length && merged.length < limit; index += 1) {
|
|
1375
|
-
push(rightNodes[index])
|
|
992
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
|
993
|
+
return
|
|
1376
994
|
}
|
|
1377
995
|
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
+
})
|
|
1380
1031
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
return sourceNodes
|
|
1032
|
+
if (state.agentId) {
|
|
1033
|
+
params.set('agent', state.agentId)
|
|
1384
1034
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
const cursorPoint = cursorWorldPoint()
|
|
1388
|
-
const recentZoomFocus =
|
|
1389
|
-
now - state.lastZoomFocus.at <= 1500
|
|
1390
|
-
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
1391
|
-
: null
|
|
1392
|
-
const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
|
|
1393
|
-
const previousIds = new Set(state.renderNodes.map((node) => node.id))
|
|
1394
|
-
const preferAnchorDistance = state.visibleNodes.length > massiveGraphNodeThreshold && state.transform.scale >= 0.28
|
|
1395
|
-
|
|
1396
|
-
return [...sourceNodes]
|
|
1397
|
-
.sort((left, right) => {
|
|
1398
|
-
const leftWasVisible = previousIds.has(left.id) ? 1 : 0
|
|
1399
|
-
const rightWasVisible = previousIds.has(right.id) ? 1 : 0
|
|
1400
|
-
const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
|
|
1401
|
-
const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
|
|
1402
|
-
|
|
1403
|
-
if (preferAnchorDistance) {
|
|
1404
|
-
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1405
|
-
if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
|
|
1406
|
-
} else {
|
|
1407
|
-
if (leftWasVisible !== rightWasVisible) return rightWasVisible - leftWasVisible
|
|
1408
|
-
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
1412
|
-
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
1413
|
-
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
1414
|
-
|
|
1415
|
-
return left.id.localeCompare(right.id)
|
|
1416
|
-
})
|
|
1417
|
-
.slice(0, limit)
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
const selectAccessBridgeNodes = (sourceNodes, limit) => {
|
|
1421
|
-
if (limit <= 0 || sourceNodes.length === 0) {
|
|
1422
|
-
return []
|
|
1035
|
+
if (state.contextId) {
|
|
1036
|
+
params.set('context', state.contextId)
|
|
1423
1037
|
}
|
|
1424
1038
|
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
now - state.lastZoomFocus.at <= 1200
|
|
1429
|
-
? { x: state.lastZoomFocus.x, y: state.lastZoomFocus.y }
|
|
1430
|
-
: null
|
|
1431
|
-
const anchor = cursorPoint ?? recentZoomFocus ?? viewportCenterWorldPoint()
|
|
1432
|
-
return [...sourceNodes]
|
|
1433
|
-
.sort((left, right) => {
|
|
1434
|
-
const leftDistance = Math.hypot(left.x - anchor.x, left.y - anchor.y)
|
|
1435
|
-
const rightDistance = Math.hypot(right.x - anchor.x, right.y - anchor.y)
|
|
1436
|
-
if (leftDistance !== rightDistance) return leftDistance - rightDistance
|
|
1437
|
-
const leftDegree = state.nodeDegrees.get(left.id) ?? 0
|
|
1438
|
-
const rightDegree = state.nodeDegrees.get(right.id) ?? 0
|
|
1439
|
-
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
1440
|
-
return left.id.localeCompare(right.id)
|
|
1441
|
-
})
|
|
1442
|
-
.slice(0, limit)
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
const edgeIdentityKey = edge => {
|
|
1446
|
-
if (!edge.target) return ''
|
|
1447
|
-
const pair = edge.source < edge.target
|
|
1448
|
-
? edge.source + '|' + edge.target
|
|
1449
|
-
: edge.target + '|' + edge.source
|
|
1450
|
-
return pair + '|' + (edge.inferred ? 'mesh' : 'real')
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
const edgeRelevanceScore = edge => {
|
|
1454
|
-
let score = edgeWeight(edge) * 10
|
|
1455
|
-
if (!edge.inferred) {
|
|
1456
|
-
score += 8
|
|
1039
|
+
const requestKey = graphStreamRequestKey({ x, y, w, h })
|
|
1040
|
+
if (!fit && state.lastChunkRequestKey === requestKey && state.chunk.nodes.length > 0) {
|
|
1041
|
+
return
|
|
1457
1042
|
}
|
|
1458
1043
|
|
|
1459
|
-
const
|
|
1460
|
-
if (
|
|
1461
|
-
|
|
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')
|
|
1462
1047
|
}
|
|
1463
1048
|
|
|
1464
|
-
const
|
|
1465
|
-
if (
|
|
1466
|
-
|
|
1049
|
+
const chunk = await response.json()
|
|
1050
|
+
if (controller.signal.aborted) {
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
if (token !== state.fetchToken) {
|
|
1054
|
+
return
|
|
1467
1055
|
}
|
|
1468
1056
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
|
1472
1073
|
}
|
|
1473
1074
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1075
|
+
updateTotals()
|
|
1076
|
+
updateTagCount()
|
|
1476
1077
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
return []
|
|
1078
|
+
if (fit) {
|
|
1079
|
+
fitFromChunk()
|
|
1480
1080
|
}
|
|
1481
1081
|
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
const candidateEdges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
1488
|
-
for (let index = 0; index < candidateEdges.length; index += 1) {
|
|
1489
|
-
const edge = candidateEdges[index]
|
|
1490
|
-
if (!edge.target || !nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
1491
|
-
continue
|
|
1492
|
-
}
|
|
1493
|
-
const key = edgeIdentityKey(edge)
|
|
1494
|
-
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
|
+
}
|
|
1495
1087
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
})
|
|
1088
|
+
updateGraphOverlays()
|
|
1089
|
+
drawFallback()
|
|
1090
|
+
}
|
|
1500
1091
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1092
|
+
const scheduleChunkFetch = ({ fit } = { fit: false }) => {
|
|
1093
|
+
if (state.fetchTimer) {
|
|
1094
|
+
clearTimeout(state.fetchTimer)
|
|
1503
1095
|
}
|
|
1504
1096
|
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
|
1510
1106
|
}
|
|
1511
|
-
|
|
1512
|
-
const rightKey = edgeIdentityKey(right)
|
|
1513
|
-
return leftKey.localeCompare(rightKey)
|
|
1107
|
+
console.error(error)
|
|
1514
1108
|
})
|
|
1515
|
-
|
|
1109
|
+
}, delay)
|
|
1516
1110
|
}
|
|
1517
1111
|
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
if (scale < 0.2) return 0.14
|
|
1527
|
-
if (scale < 0.4) return 0.2
|
|
1528
|
-
if (scale < 0.7) return 0.28
|
|
1529
|
-
if (scale < 1.05) return 0.36
|
|
1530
|
-
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()
|
|
1531
1120
|
}
|
|
1532
1121
|
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1122
|
+
const pickFallbackNode = (screenX, screenY) => {
|
|
1123
|
+
const nodes = spatialCandidates(screenX, screenY)
|
|
1124
|
+
if (nodes.length === 0) {
|
|
1125
|
+
return null
|
|
1536
1126
|
}
|
|
1537
1127
|
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
const
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
+
}
|
|
1547
1145
|
}
|
|
1548
1146
|
|
|
1549
|
-
return
|
|
1147
|
+
return bestNode
|
|
1550
1148
|
}
|
|
1551
1149
|
|
|
1552
|
-
const
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y)
|
|
1556
|
-
ctx.lineTo(edge.targetNode.x, edge.targetNode.y)
|
|
1557
|
-
ctx.strokeStyle = edgeStrokeFor(edge, selectedEdge)
|
|
1558
|
-
ctx.lineWidth = edgeWidthFor(edge, selectedEdge)
|
|
1559
|
-
ctx.stroke()
|
|
1150
|
+
const pickFallbackNodeId = (screenX, screenY) => {
|
|
1151
|
+
const node = pickFallbackNode(screenX, screenY)
|
|
1152
|
+
return typeof node?.[0] === 'string' ? node[0] : ''
|
|
1560
1153
|
}
|
|
1561
1154
|
|
|
1562
|
-
const
|
|
1563
|
-
|
|
1155
|
+
const handlePickedNode = (node) => {
|
|
1156
|
+
const nodeId = typeof node?.id === 'string' ? node.id : typeof node?.[0] === 'string' ? node[0] : ''
|
|
1157
|
+
if (!nodeId) {
|
|
1564
1158
|
return
|
|
1565
1159
|
}
|
|
1566
1160
|
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
const
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
|
1572
1167
|
}
|
|
1573
|
-
ctx.strokeStyle = options.strokeStyle
|
|
1574
|
-
ctx.lineWidth = options.lineWidth
|
|
1575
|
-
ctx.stroke()
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
const regularEdgeBatchOptions = (edge) => ({
|
|
1579
|
-
strokeStyle: edgeStrokeFor(edge, false),
|
|
1580
|
-
lineWidth: edgeWidthFor(edge, false)
|
|
1581
|
-
})
|
|
1582
1168
|
|
|
1583
|
-
|
|
1584
|
-
const options = regularEdgeBatchOptions(edge)
|
|
1585
|
-
return options.strokeStyle + '|' + options.lineWidth.toFixed(2)
|
|
1169
|
+
loadNodeDetails(nodeId).catch((error) => console.error(error))
|
|
1586
1170
|
}
|
|
1587
1171
|
|
|
1588
|
-
const
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
const edge = state.renderEdges[index]
|
|
1594
|
-
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
1595
|
-
if (isSelected) {
|
|
1596
|
-
selectedEdges.push(edge)
|
|
1597
|
-
continue
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
const key = regularEdgeBatchKey(edge)
|
|
1601
|
-
const batch = edgeBatches.get(key)
|
|
1602
|
-
if (batch) {
|
|
1603
|
-
batch.edges.push(edge)
|
|
1604
|
-
} else {
|
|
1605
|
-
edgeBatches.set(key, {
|
|
1606
|
-
edges: [edge],
|
|
1607
|
-
options: regularEdgeBatchOptions(edge)
|
|
1608
|
-
})
|
|
1172
|
+
const pickAt = (screenX, screenY) => {
|
|
1173
|
+
if (state.rendererMode === 'fallback') {
|
|
1174
|
+
const node = pickFallbackNode(screenX, screenY)
|
|
1175
|
+
if (node) {
|
|
1176
|
+
handlePickedNode(node)
|
|
1609
1177
|
}
|
|
1178
|
+
return
|
|
1610
1179
|
}
|
|
1611
1180
|
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
for (let index = 0; index < selectedEdges.length; index += 1) {
|
|
1615
|
-
drawGraphEdge(selectedEdges[index])
|
|
1181
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
1182
|
+
return
|
|
1616
1183
|
}
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
const shouldDrawNodeLabels = (node, isSelected, isHovered) =>
|
|
1620
|
-
isSelected ||
|
|
1621
|
-
isHovered ||
|
|
1622
|
-
(state.nodes.length > largeGraphNodeThreshold && state.transform.scale >= 0.62 && state.renderNodes.length <= 1200) ||
|
|
1623
|
-
(state.nodes.length <= largeGraphNodeThreshold && (state.transform.scale > 1.18 || state.nodes.length <= 25))
|
|
1624
1184
|
|
|
1625
|
-
const
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
ctx.beginPath()
|
|
1634
|
-
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2)
|
|
1635
|
-
ctx.fillStyle = isSelected ? graphTheme.nodeSelected : isHovered ? graphTheme.nodeHover : graphTheme.node
|
|
1636
|
-
ctx.fill()
|
|
1637
|
-
ctx.lineWidth = isSelected ? 2.6 : 1.5
|
|
1638
|
-
ctx.strokeStyle = isSelected ? graphTheme.nodeStrokeActive : graphTheme.nodeStroke
|
|
1639
|
-
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
|
+
}
|
|
1640
1193
|
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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()
|
|
1648
1202
|
}
|
|
1649
1203
|
|
|
1650
|
-
const
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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)
|
|
1654
1268
|
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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
|
|
1662
1301
|
}
|
|
1663
|
-
ctx.fillStyle = graphTheme.nodeHalo
|
|
1664
|
-
ctx.fill()
|
|
1665
|
-
}
|
|
1666
1302
|
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
ctx.lineWidth = 1.25
|
|
1677
|
-
ctx.strokeStyle = graphTheme.nodeStroke
|
|
1678
|
-
ctx.stroke()
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
const drawGraphNodes = () => {
|
|
1682
|
-
const regularNodes = []
|
|
1683
|
-
const priorityNodes = []
|
|
1684
|
-
|
|
1685
|
-
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
1686
|
-
const node = state.renderNodes[index]
|
|
1687
|
-
const isPriority =
|
|
1688
|
-
state.selected?.id === node.id ||
|
|
1689
|
-
state.hovered?.id === node.id
|
|
1690
|
-
if (isPriority) {
|
|
1691
|
-
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)
|
|
1692
1312
|
} else {
|
|
1693
|
-
|
|
1313
|
+
hideTooltip()
|
|
1694
1314
|
}
|
|
1695
|
-
}
|
|
1315
|
+
})
|
|
1696
1316
|
|
|
1697
|
-
|
|
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)
|
|
1698
1324
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
ctx.textAlign = 'center'
|
|
1703
|
-
ctx.textBaseline = 'top'
|
|
1704
|
-
for (let index = 0; index < regularNodes.length; index += 1) {
|
|
1705
|
-
const node = regularNodes[index]
|
|
1706
|
-
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
|
|
1707
1328
|
}
|
|
1708
|
-
|
|
1329
|
+
if (shouldPersistNodePosition) {
|
|
1330
|
+
writeStoredNodePositions()
|
|
1331
|
+
persistNodePositionsToServer()
|
|
1332
|
+
return
|
|
1333
|
+
}
|
|
1334
|
+
if (shouldRefreshAfterDrag) {
|
|
1335
|
+
scheduleChunkFetch()
|
|
1336
|
+
}
|
|
1337
|
+
})
|
|
1709
1338
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1339
|
+
canvas.addEventListener('pointerleave', () => {
|
|
1340
|
+
state.hoveredNodeId = ''
|
|
1341
|
+
canvas.classList.remove('is-node-hover')
|
|
1342
|
+
hideTooltip()
|
|
1343
|
+
updateGraphOverlays()
|
|
1344
|
+
})
|
|
1712
1345
|
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
const selectedEdges = []
|
|
1346
|
+
canvas.addEventListener('pointercancel', (event) => {
|
|
1347
|
+
resetPointerState(event.pointerId)
|
|
1348
|
+
hideTooltip()
|
|
1349
|
+
updateGraphOverlays()
|
|
1350
|
+
})
|
|
1719
1351
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
state.selected?.id === node.id ||
|
|
1724
|
-
state.hovered?.id === node.id
|
|
1725
|
-
if (isPriority) {
|
|
1726
|
-
priorityNodes.push(node)
|
|
1727
|
-
} else {
|
|
1728
|
-
regularNodes.push(node)
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1352
|
+
canvas.addEventListener('lostpointercapture', () => {
|
|
1353
|
+
resetPointerState()
|
|
1354
|
+
})
|
|
1731
1355
|
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
if (isSelected) {
|
|
1736
|
-
selectedEdges.push(edge)
|
|
1737
|
-
} else if (edge.inferred) {
|
|
1738
|
-
inferredEdges.push(edge)
|
|
1739
|
-
} else {
|
|
1740
|
-
regularEdges.push(edge)
|
|
1356
|
+
elements.miniMap.addEventListener('click', (event) => {
|
|
1357
|
+
if (!state.miniMapView) {
|
|
1358
|
+
return
|
|
1741
1359
|
}
|
|
1742
|
-
|
|
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
|
+
})
|
|
1743
1375
|
|
|
1744
|
-
|
|
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
|
+
})
|
|
1745
1393
|
}
|
|
1746
1394
|
|
|
1747
|
-
const
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
}
|
|
1395
|
+
const setupControls = () => {
|
|
1396
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
1397
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
1398
|
+
})
|
|
1751
1399
|
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
ctx.textBaseline = 'top'
|
|
1756
|
-
for (let index = 0; index < nodes.length; index += 1) {
|
|
1757
|
-
const node = nodes[index]
|
|
1758
|
-
ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1400
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
1401
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
1402
|
+
})
|
|
1761
1403
|
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
}
|
|
1404
|
+
elements.fit.addEventListener('click', () => {
|
|
1405
|
+
fitFromChunk()
|
|
1406
|
+
scheduleChunkFetch()
|
|
1407
|
+
})
|
|
1766
1408
|
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
if (drawEdges) {
|
|
1771
|
-
webGlRenderer.drawLines(
|
|
1772
|
-
graphParts.regularEdges,
|
|
1773
|
-
rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
|
|
1774
|
-
width,
|
|
1775
|
-
height
|
|
1776
|
-
)
|
|
1777
|
-
webGlRenderer.drawLines(
|
|
1778
|
-
graphParts.inferredEdges,
|
|
1779
|
-
rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
|
|
1780
|
-
width,
|
|
1781
|
-
height
|
|
1782
|
-
)
|
|
1783
|
-
}
|
|
1784
|
-
webGlRenderer.drawPoints(
|
|
1785
|
-
graphParts.regularNodes,
|
|
1786
|
-
rgba(graphTheme.nodeHalo, 0.28),
|
|
1787
|
-
node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
|
|
1788
|
-
width,
|
|
1789
|
-
height
|
|
1790
|
-
)
|
|
1791
|
-
webGlRenderer.drawPoints(
|
|
1792
|
-
graphParts.regularNodes,
|
|
1793
|
-
rgba(graphTheme.node, 1),
|
|
1794
|
-
node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
|
|
1795
|
-
width,
|
|
1796
|
-
height
|
|
1797
|
-
)
|
|
1798
|
-
|
|
1799
|
-
ctx.save()
|
|
1800
|
-
ctx.translate(state.transform.x, state.transform.y)
|
|
1801
|
-
ctx.scale(state.transform.scale, state.transform.scale)
|
|
1802
|
-
if (drawEdges) {
|
|
1803
|
-
graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
|
|
1804
|
-
}
|
|
1805
|
-
drawGraphLabels(graphParts.regularNodes)
|
|
1806
|
-
graphParts.priorityNodes.forEach(node => drawSingleNode(node))
|
|
1807
|
-
ctx.restore()
|
|
1409
|
+
elements.releaseNode.addEventListener('click', () => {
|
|
1410
|
+
releaseSelectedNodePosition()
|
|
1411
|
+
})
|
|
1808
1412
|
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
+
})
|
|
1811
1420
|
|
|
1812
|
-
|
|
1813
|
-
|
|
1421
|
+
elements.contentClose.addEventListener('click', () => {
|
|
1422
|
+
closeContentDialog()
|
|
1423
|
+
})
|
|
1814
1424
|
|
|
1815
|
-
|
|
1816
|
-
|
|
1425
|
+
elements.contentDialog.addEventListener('click', (event) => {
|
|
1426
|
+
if (event.target === elements.contentDialog) {
|
|
1427
|
+
closeContentDialog()
|
|
1428
|
+
}
|
|
1429
|
+
})
|
|
1817
1430
|
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
const cellY = Math.floor(node.y / cellSize)
|
|
1822
|
-
const key = cellX + ':' + cellY
|
|
1823
|
-
const bucket = buckets.get(key)
|
|
1824
|
-
if (bucket) {
|
|
1825
|
-
bucket.push(node)
|
|
1826
|
-
} else {
|
|
1827
|
-
buckets.set(key, [node])
|
|
1431
|
+
elements.search.addEventListener('input', () => {
|
|
1432
|
+
if (state.searchTimer) {
|
|
1433
|
+
clearTimeout(state.searchTimer)
|
|
1828
1434
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
const meshCandidatesForNode = (node, buckets, cellSize) => {
|
|
1835
|
-
const cellX = Math.floor(node.x / cellSize)
|
|
1836
|
-
const cellY = Math.floor(node.y / cellSize)
|
|
1837
|
-
const candidates = []
|
|
1838
|
-
|
|
1839
|
-
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
|
1840
|
-
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
|
1841
|
-
const bucket = buckets.get((cellX + offsetX) + ':' + (cellY + offsetY))
|
|
1842
|
-
if (!bucket) continue
|
|
1843
|
-
for (let index = 0; index < bucket.length; index += 1) {
|
|
1844
|
-
const candidate = bucket[index]
|
|
1845
|
-
if (candidate.id !== node.id) {
|
|
1846
|
-
candidates.push(candidate)
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
return candidates
|
|
1435
|
+
state.searchTimer = setTimeout(() => {
|
|
1436
|
+
state.searchTimer = null
|
|
1437
|
+
runGraphSearch().catch((error) => console.error(error))
|
|
1438
|
+
}, 160)
|
|
1439
|
+
})
|
|
1853
1440
|
}
|
|
1854
1441
|
|
|
1855
|
-
const
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
if (edge.target) {
|
|
1864
|
-
existingKeys.add(edgePairKey(edge.source, edge.target))
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
const desiredBudget = Math.min(
|
|
1869
|
-
meshEdgeMaxBudget,
|
|
1870
|
-
Math.max(meshEdgeMinBudget, Math.floor(edgeBudgetForCurrentFrame() * 0.62))
|
|
1871
|
-
)
|
|
1872
|
-
const perNodeNeighborCount =
|
|
1873
|
-
state.transform.scale >= 1.05 ? 4
|
|
1874
|
-
: state.transform.scale >= 0.62 ? 3
|
|
1875
|
-
: 2
|
|
1876
|
-
const cellSize = Math.max(120, 280 / Math.max(state.transform.scale, 0.0001))
|
|
1877
|
-
const maxDistance = 980
|
|
1878
|
-
const maxDistanceSquared = maxDistance * maxDistance
|
|
1879
|
-
const buckets = meshNeighborBuckets(nodes, cellSize)
|
|
1880
|
-
const meshEdges = []
|
|
1881
|
-
const meshKeys = new Set()
|
|
1882
|
-
|
|
1883
|
-
for (let index = 0; index < nodes.length && meshEdges.length < desiredBudget; index += 1) {
|
|
1884
|
-
const node = nodes[index]
|
|
1885
|
-
const candidates = meshCandidatesForNode(node, buckets, cellSize)
|
|
1886
|
-
.map((candidate) => ({
|
|
1887
|
-
node: candidate,
|
|
1888
|
-
distanceSquared: (candidate.x - node.x) ** 2 + (candidate.y - node.y) ** 2
|
|
1889
|
-
}))
|
|
1890
|
-
.filter((candidate) => candidate.distanceSquared <= maxDistanceSquared)
|
|
1891
|
-
.sort((left, right) => left.distanceSquared - right.distanceSquared)
|
|
1892
|
-
|
|
1893
|
-
let linked = 0
|
|
1894
|
-
for (let candidateIndex = 0; candidateIndex < candidates.length && linked < perNodeNeighborCount && meshEdges.length < desiredBudget; candidateIndex += 1) {
|
|
1895
|
-
const candidate = candidates[candidateIndex].node
|
|
1896
|
-
const key = edgePairKey(node.id, candidate.id)
|
|
1897
|
-
if (existingKeys.has(key) || meshKeys.has(key)) {
|
|
1898
|
-
continue
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
meshKeys.add(key)
|
|
1902
|
-
meshEdges.push({
|
|
1903
|
-
source: node.id,
|
|
1904
|
-
target: candidate.id,
|
|
1905
|
-
targetTitle: candidate.title,
|
|
1906
|
-
weight: 1,
|
|
1907
|
-
priority: 'normal',
|
|
1908
|
-
sourceNode: node,
|
|
1909
|
-
targetNode: candidate,
|
|
1910
|
-
inferred: true
|
|
1911
|
-
})
|
|
1912
|
-
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: [] })
|
|
1913
1450
|
}
|
|
1451
|
+
return
|
|
1914
1452
|
}
|
|
1915
1453
|
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
const withMeshEdges = (nodes, edges) => {
|
|
1920
|
-
if (nodes.length === 0 || state.visibleNodes.length <= largeGraphNodeThreshold || state.transform.scale < meshEdgeScaleThreshold) {
|
|
1921
|
-
return edges
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
const meshEdges = buildMeshEdgesForNodes(nodes, edges)
|
|
1925
|
-
return meshEdges.length > 0 ? edges.concat(meshEdges) : edges
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
const fallbackViewportNodes = () => {
|
|
1929
|
-
const nodes = []
|
|
1930
|
-
const maxNodes = Math.min(renderNodeBudget, 220)
|
|
1931
|
-
const step = Math.max(1, Math.ceil(state.visibleNodes.length / maxNodes))
|
|
1932
|
-
|
|
1933
|
-
for (let index = 0; index < state.visibleNodes.length && nodes.length < maxNodes; index += step) {
|
|
1934
|
-
nodes.push(state.visibleNodes[index])
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
1938
|
-
nodes.push(state.selected)
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
return nodes
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
|
-
const sampleVisibleNodes = (limit = renderNodeBudget, sourceNodes = state.visibleNodes) => {
|
|
1945
|
-
if (sourceNodes.length === 0 || limit <= 0) {
|
|
1946
|
-
return []
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
const nodes = []
|
|
1950
|
-
const maxNodes = Math.min(Math.max(limit, 1), sourceNodes.length)
|
|
1951
|
-
const step = Math.max(1, Math.ceil(sourceNodes.length / maxNodes))
|
|
1952
|
-
|
|
1953
|
-
for (let index = 0; index < sourceNodes.length && nodes.length < maxNodes; index += step) {
|
|
1954
|
-
nodes.push(sourceNodes[index])
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
if (state.selected && !nodes.find(node => node.id === state.selected.id)) {
|
|
1958
|
-
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')
|
|
1959
1457
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
const enrichSampleWithNeighbors = (nodes) => {
|
|
1965
|
-
if (nodes.length === 0) {
|
|
1966
|
-
return {
|
|
1967
|
-
nodes,
|
|
1968
|
-
edges: []
|
|
1969
|
-
}
|
|
1458
|
+
const payload = await response.json()
|
|
1459
|
+
if (token !== state.searchToken) {
|
|
1460
|
+
return
|
|
1970
1461
|
}
|
|
1971
1462
|
|
|
1972
|
-
const
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
const node = nodes[index]
|
|
1978
|
-
const candidates = [...(state.visibleEdgeByNode.get(node.id) ?? [])]
|
|
1979
|
-
.filter((edge) => edge.target)
|
|
1980
|
-
.sort((left, right) => edgeWeight(right) - edgeWeight(left))
|
|
1981
|
-
.slice(0, 3)
|
|
1982
|
-
|
|
1983
|
-
for (let candidateIndex = 0; candidateIndex < candidates.length && expanded.length < maxNodes; candidateIndex += 1) {
|
|
1984
|
-
const edge = candidates[candidateIndex]
|
|
1985
|
-
const otherId = edge.source === node.id ? edge.target : edge.source
|
|
1986
|
-
|
|
1987
|
-
if (!otherId || ids.has(otherId)) {
|
|
1988
|
-
continue
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
const otherNode = state.nodeById.get(otherId)
|
|
1992
|
-
if (!otherNode) {
|
|
1993
|
-
continue
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
ids.add(otherId)
|
|
1997
|
-
expanded.push(otherNode)
|
|
1998
|
-
}
|
|
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 })
|
|
1999
1468
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
nodes: expanded,
|
|
2005
|
-
edges
|
|
1469
|
+
if (ids.length > 0 && state.graphMode === 'far') {
|
|
1470
|
+
state.camera.scale = Math.max(state.camera.scale, 0.82)
|
|
1471
|
+
updateWorkerCamera()
|
|
1472
|
+
scheduleChunkFetch()
|
|
2006
1473
|
}
|
|
2007
1474
|
}
|
|
2008
1475
|
|
|
2009
|
-
const
|
|
2010
|
-
const
|
|
2011
|
-
if (!
|
|
2012
|
-
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
const maxNodes = Math.max(1, Math.min(renderNodeBudget, limit))
|
|
2016
|
-
const merged = [...nodes]
|
|
2017
|
-
const ids = new Set(merged.map((node) => node.id))
|
|
2018
|
-
const protectedIds = new Set()
|
|
2019
|
-
|
|
2020
|
-
if (!ids.has(hub.id)) {
|
|
2021
|
-
if (merged.length < maxNodes) {
|
|
2022
|
-
merged.push(hub)
|
|
2023
|
-
ids.add(hub.id)
|
|
2024
|
-
} else {
|
|
2025
|
-
const replaceIndex = merged.findIndex((node) => node.id !== hub.id)
|
|
2026
|
-
if (replaceIndex >= 0) {
|
|
2027
|
-
ids.delete(merged[replaceIndex].id)
|
|
2028
|
-
merged[replaceIndex] = hub
|
|
2029
|
-
ids.add(hub.id)
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
1476
|
+
const loadAgents = async () => {
|
|
1477
|
+
const response = await fetch('/api/agents')
|
|
1478
|
+
if (!response.ok) {
|
|
1479
|
+
throw new Error('Failed to load agents')
|
|
2032
1480
|
}
|
|
2033
|
-
protectedIds.add(hub.id)
|
|
2034
1481
|
|
|
2035
|
-
const
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
const
|
|
2042
|
-
const
|
|
2043
|
-
|
|
2044
|
-
const rightDegree = state.nodeDegrees.get(rightOtherId ?? '') ?? 0
|
|
2045
|
-
if (leftDegree !== rightDegree) return rightDegree - leftDegree
|
|
2046
|
-
|
|
2047
|
-
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>'
|
|
2048
1491
|
})
|
|
1492
|
+
.join('')
|
|
2049
1493
|
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
continue
|
|
2055
|
-
}
|
|
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
|
|
2056
1498
|
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
if (merged.length < maxNodes) {
|
|
2063
|
-
ids.add(otherId)
|
|
2064
|
-
merged.push(otherNode)
|
|
2065
|
-
protectedIds.add(otherId)
|
|
2066
|
-
continue
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
const replaceIndex = (() => {
|
|
2070
|
-
for (let cursor = merged.length - 1; cursor >= 0; cursor -= 1) {
|
|
2071
|
-
const candidateId = merged[cursor]?.id
|
|
2072
|
-
if (candidateId && !protectedIds.has(candidateId)) {
|
|
2073
|
-
return cursor
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
return -1
|
|
2077
|
-
})()
|
|
2078
|
-
if (replaceIndex >= 0) {
|
|
2079
|
-
const replacedId = merged[replaceIndex]?.id
|
|
2080
|
-
if (replacedId) {
|
|
2081
|
-
ids.delete(replacedId)
|
|
2082
|
-
}
|
|
2083
|
-
merged[replaceIndex] = otherNode
|
|
2084
|
-
ids.add(otherId)
|
|
2085
|
-
protectedIds.add(otherId)
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
return merged
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
const ensureHubNodesInRenderedSet = (nodes) => {
|
|
2093
|
-
if (nodes.length === 0) {
|
|
2094
|
-
return nodes
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
const maxNodes = Math.max(Math.min(renderNodeBudget, nodes.length), 1)
|
|
2098
|
-
const ids = new Set(nodes.map((node) => node.id))
|
|
2099
|
-
const hubs = rankedHubNodes()
|
|
2100
|
-
const merged = [...nodes]
|
|
2101
|
-
|
|
2102
|
-
for (let index = 0; index < hubs.length; index += 1) {
|
|
2103
|
-
const hub = hubs[index]
|
|
2104
|
-
if (ids.has(hub.id)) {
|
|
2105
|
-
continue
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
if (merged.length < maxNodes) {
|
|
2109
|
-
merged.push(hub)
|
|
2110
|
-
ids.add(hub.id)
|
|
2111
|
-
continue
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
|
-
const replacementIndex = merged.findIndex((node) => !hubs.some((candidate) => candidate.id === node.id))
|
|
2115
|
-
if (replacementIndex >= 0) {
|
|
2116
|
-
ids.delete(merged[replacementIndex].id)
|
|
2117
|
-
merged[replacementIndex] = hub
|
|
2118
|
-
ids.add(hub.id)
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
return merged
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
const zoomCapByNodeCount = (nodeCount) => {
|
|
2126
|
-
if (nodeCount > 50000) return 5.4
|
|
2127
|
-
if (nodeCount > 20000) return 4.8
|
|
2128
|
-
if (nodeCount > 6000) return 4.2
|
|
2129
|
-
if (nodeCount > 2000) return 4
|
|
2130
|
-
return zoomRange.max
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
const zoomCapByHubDistance = (distance) => {
|
|
2134
|
-
if (!Number.isFinite(distance) || distance <= 0) {
|
|
2135
|
-
return zoomRange.max
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
const rect = canvas.getBoundingClientRect()
|
|
2139
|
-
const viewportWidth = Math.max(rect.width, 320)
|
|
2140
|
-
const viewportHeight = Math.max(rect.height, 320)
|
|
2141
|
-
const reference = Math.max(220, Math.min(viewportWidth, viewportHeight) * zoomCapTargetViewportShare)
|
|
2142
|
-
return Math.max(0.3, Math.min(zoomRange.max, reference / distance))
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
const currentZoomMax = () => {
|
|
2146
|
-
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
2147
|
-
const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
|
|
2148
|
-
? zoomCapByHubDistance(state.hubNeighborDistance)
|
|
2149
|
-
: zoomRange.max
|
|
2150
|
-
const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
|
|
2151
|
-
const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
|
|
2152
|
-
return Math.max(zoomRange.min * 2, capped)
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
const clampScale = value => Math.max(zoomRange.min, Math.min(currentZoomMax(), value))
|
|
2156
|
-
const isFiniteNumber = value => Number.isFinite(value)
|
|
2157
|
-
const isReasonableCoordinate = value => isFiniteNumber(value) && Math.abs(value) <= worldCoordinateLimit
|
|
2158
|
-
const clampTransformCoordinate = value => {
|
|
2159
|
-
if (!isFiniteNumber(value)) return 0
|
|
2160
|
-
if (value > transformCoordinateLimit) return transformCoordinateLimit
|
|
2161
|
-
if (value < -transformCoordinateLimit) return -transformCoordinateLimit
|
|
2162
|
-
return value
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
const graphBounds = nodes => {
|
|
2166
|
-
if (nodes.length === 0) return null
|
|
2167
|
-
let minX = Number.POSITIVE_INFINITY
|
|
2168
|
-
let maxX = Number.NEGATIVE_INFINITY
|
|
2169
|
-
let minY = Number.POSITIVE_INFINITY
|
|
2170
|
-
let maxY = Number.NEGATIVE_INFINITY
|
|
2171
|
-
|
|
2172
|
-
nodes.forEach(node => {
|
|
2173
|
-
const radius = baseNodeRadius(node)
|
|
2174
|
-
minX = Math.min(minX, node.x - radius)
|
|
2175
|
-
maxX = Math.max(maxX, node.x + radius)
|
|
2176
|
-
minY = Math.min(minY, node.y - radius)
|
|
2177
|
-
maxY = Math.max(maxY, node.y + radius)
|
|
2178
|
-
})
|
|
2179
|
-
|
|
2180
|
-
return {
|
|
2181
|
-
minX,
|
|
2182
|
-
maxX,
|
|
2183
|
-
minY,
|
|
2184
|
-
maxY,
|
|
2185
|
-
width: Math.max(maxX - minX, 1),
|
|
2186
|
-
height: Math.max(maxY - minY, 1)
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
const fitScaleBiasByNodeCount = nodeCount => {
|
|
2191
|
-
if (nodeCount <= 6) return 1.22
|
|
2192
|
-
if (nodeCount <= 20) return 1.12
|
|
2193
|
-
if (nodeCount <= 60) return 1.04
|
|
2194
|
-
if (nodeCount <= 180) return 1
|
|
2195
|
-
if (nodeCount <= 600) return 0.94
|
|
2196
|
-
if (nodeCount <= 2000) return 0.82
|
|
2197
|
-
if (nodeCount <= 6000) return 0.68
|
|
2198
|
-
return 0.56
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
const autoFitScaleRangeByNodeCount = nodeCount => {
|
|
2202
|
-
if (nodeCount <= 6) return { min: 0.4, max: 2.2 }
|
|
2203
|
-
if (nodeCount <= 20) return { min: 0.34, max: 1.65 }
|
|
2204
|
-
if (nodeCount <= 60) return { min: 0.25, max: 1.22 }
|
|
2205
|
-
if (nodeCount <= 180) return { min: 0.18, max: 0.92 }
|
|
2206
|
-
if (nodeCount <= 600) return { min: 0.12, max: 0.72 }
|
|
2207
|
-
if (nodeCount <= 2000) return { min: 0.08, max: 0.52 }
|
|
2208
|
-
if (nodeCount <= 6000) return { min: 0.06, max: 0.32 }
|
|
2209
|
-
return { min: 0.0012, max: 0.24 }
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: true }) => {
|
|
2213
|
-
const rect = canvas.getBoundingClientRect()
|
|
2214
|
-
const width = Math.max(rect.width, 320)
|
|
2215
|
-
const height = Math.max(rect.height, 320)
|
|
2216
|
-
const nodes = options.useFiltered ? filteredNodes() : state.nodes
|
|
2217
|
-
const bounds = graphBounds(nodes)
|
|
2218
|
-
|
|
2219
|
-
if (!bounds) {
|
|
2220
|
-
state.transform = { x: width / 2, y: height / 2, scale: 1 }
|
|
2221
|
-
state.offscreenFrameCount = 0
|
|
2222
|
-
state.recoveringViewport = false
|
|
2223
|
-
markRenderDirty()
|
|
2224
|
-
return
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
const paddingByNodeCount = nodeCount => {
|
|
2228
|
-
if (nodeCount <= 6) return 28
|
|
2229
|
-
if (nodeCount <= 20) return 44
|
|
2230
|
-
if (nodeCount <= 60) return 68
|
|
2231
|
-
if (nodeCount <= 180) return 86
|
|
2232
|
-
if (nodeCount <= 600) return 110
|
|
2233
|
-
if (nodeCount <= 2000) return 140
|
|
2234
|
-
return 180
|
|
2235
|
-
}
|
|
2236
|
-
const padding = paddingByNodeCount(nodes.length)
|
|
2237
|
-
const scaleX = width / (bounds.width + padding * 2)
|
|
2238
|
-
const scaleY = height / (bounds.height + padding * 2)
|
|
2239
|
-
const fitScale = Math.min(scaleX, scaleY)
|
|
2240
|
-
const biasedScale = clampScale(fitScale * fitScaleBiasByNodeCount(nodes.length))
|
|
2241
|
-
const scaleRange = autoFitScaleRangeByNodeCount(nodes.length)
|
|
2242
|
-
const baselineScale = clampScale(Math.min(scaleRange.max, Math.max(scaleRange.min, biasedScale)))
|
|
2243
|
-
const macroScale = nodes.length > massiveGraphNodeThreshold ? massiveAutoFitMacroScale : defaultMacroScale
|
|
2244
|
-
const scale = options.macro && nodes.length > 1
|
|
2245
|
-
? clampScale(Math.min(baselineScale, macroScale))
|
|
2246
|
-
: nodes.length > massiveGraphNodeThreshold
|
|
2247
|
-
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
2248
|
-
: baselineScale
|
|
2249
|
-
const hubCenter =
|
|
2250
|
-
options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
|
|
2251
|
-
? state.primaryHub
|
|
2252
|
-
: null
|
|
2253
|
-
const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
|
|
2254
|
-
const centerY = hubCenter ? hubCenter.y : (bounds.minY + bounds.maxY) / 2
|
|
2255
|
-
|
|
2256
|
-
state.transform = {
|
|
2257
|
-
x: clampTransformCoordinate(width / 2 - centerX * scale),
|
|
2258
|
-
y: clampTransformCoordinate(height / 2 - centerY * scale),
|
|
2259
|
-
scale: clampScale(scale)
|
|
2260
|
-
}
|
|
2261
|
-
state.offscreenFrameCount = 0
|
|
2262
|
-
state.recoveringViewport = false
|
|
2263
|
-
markRenderDirty()
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
const resetView = () => fitView({ useFiltered: false, macro: true, preferHubCenter: true })
|
|
2267
|
-
|
|
2268
|
-
const focusPrimaryHub = () => {
|
|
2269
|
-
const hub = state.primaryHub
|
|
2270
|
-
if (!hub) {
|
|
2271
|
-
fitView({ useFiltered: true, macro: false, preferHubCenter: true })
|
|
2272
|
-
return
|
|
2273
|
-
}
|
|
2274
|
-
|
|
2275
|
-
const rect = canvas.getBoundingClientRect()
|
|
2276
|
-
const width = Math.max(rect.width, 320)
|
|
2277
|
-
const height = Math.max(rect.height, 320)
|
|
2278
|
-
const targetScale = clampScale(Math.max(0.78, state.transform.scale))
|
|
2279
|
-
|
|
2280
|
-
state.transform = {
|
|
2281
|
-
x: clampTransformCoordinate(width / 2 - hub.x * targetScale),
|
|
2282
|
-
y: clampTransformCoordinate(height / 2 - hub.y * targetScale),
|
|
2283
|
-
scale: targetScale
|
|
2284
|
-
}
|
|
2285
|
-
state.offscreenFrameCount = 0
|
|
2286
|
-
markRenderDirty()
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
const layoutDensityScaleForNodeCount = (nodeCount) => {
|
|
2290
|
-
if (nodeCount > 50000) return 0.26
|
|
2291
|
-
if (nodeCount > 20000) return 0.3
|
|
2292
|
-
if (nodeCount > 6000) return 0.36
|
|
2293
|
-
if (nodeCount > 2000) return 0.42
|
|
2294
|
-
if (nodeCount > 600) return 0.5
|
|
2295
|
-
if (nodeCount > 180) return 0.58
|
|
2296
|
-
if (nodeCount > 60) return 0.68
|
|
2297
|
-
if (nodeCount > 20) return 0.78
|
|
2298
|
-
return 0.88
|
|
2299
|
-
}
|
|
2300
|
-
|
|
2301
|
-
const createLayout = graph => {
|
|
2302
|
-
const nodeRows = Array.isArray(graph.nodes) ? graph.nodes : []
|
|
2303
|
-
const edgeRows = Array.isArray(graph.edges) ? graph.edges : []
|
|
2304
|
-
const densityScale = layoutDensityScaleForNodeCount(nodeRows.length)
|
|
2305
|
-
const nodes = nodeRows.map(node => {
|
|
2306
|
-
if (Array.isArray(node)) {
|
|
2307
|
-
const [id, title, x, y, group, segment] = node
|
|
2308
|
-
return {
|
|
2309
|
-
id: typeof id === 'string' ? id : '',
|
|
2310
|
-
title: typeof title === 'string' ? title : 'Untitled',
|
|
2311
|
-
path: '',
|
|
2312
|
-
tags: [],
|
|
2313
|
-
group: typeof group === 'string' ? group : 'root',
|
|
2314
|
-
segment: typeof segment === 'string' ? segment : 'root',
|
|
2315
|
-
x: Number.isFinite(x) ? x * densityScale : 0,
|
|
2316
|
-
y: Number.isFinite(y) ? y * densityScale : 0,
|
|
2317
|
-
vx: 0,
|
|
2318
|
-
vy: 0
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
return {
|
|
2323
|
-
...node,
|
|
2324
|
-
path: typeof node.path === 'string' ? node.path : '',
|
|
2325
|
-
tags: Array.isArray(node.tags) ? node.tags : [],
|
|
2326
|
-
x: Number.isFinite(node.x) ? node.x * densityScale : 0,
|
|
2327
|
-
y: Number.isFinite(node.y) ? node.y * densityScale : 0,
|
|
2328
|
-
vx: Number.isFinite(node.vx) ? node.vx : 0,
|
|
2329
|
-
vy: Number.isFinite(node.vy) ? node.vy : 0
|
|
2330
|
-
}
|
|
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))
|
|
2331
1504
|
})
|
|
2332
|
-
const nodeMap = new Map(nodes.map(node => [node.id, node]))
|
|
2333
|
-
const edges = edgeRows
|
|
2334
|
-
.map(edge => {
|
|
2335
|
-
if (Array.isArray(edge)) {
|
|
2336
|
-
const [source, target, weight, priority] = edge
|
|
2337
|
-
return {
|
|
2338
|
-
source: typeof source === 'string' ? source : '',
|
|
2339
|
-
target: typeof target === 'string' ? target : null,
|
|
2340
|
-
targetTitle: '',
|
|
2341
|
-
weight: Number.isFinite(weight) ? weight : 1,
|
|
2342
|
-
priority: typeof priority === 'string' ? priority : 'normal'
|
|
2343
|
-
}
|
|
2344
|
-
}
|
|
2345
|
-
return edge
|
|
2346
|
-
})
|
|
2347
|
-
.filter(edge => edge.target && nodeMap.has(edge.source) && nodeMap.has(edge.target))
|
|
2348
|
-
.map(edge => ({ ...edge, sourceNode: nodeMap.get(edge.source), targetNode: nodeMap.get(edge.target) }))
|
|
2349
|
-
return { nodes, edges }
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
const encodeEntityTag = (value) => {
|
|
2353
|
-
const utf8 = new TextEncoder().encode(value)
|
|
2354
|
-
let binary = ''
|
|
2355
1505
|
|
|
2356
|
-
|
|
2357
|
-
binary += String.fromCharCode(utf8[index])
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
const graphSignature = graph => JSON.stringify({
|
|
2364
|
-
nodes: graph.nodes.map(node => [node.id, node.title, node.path, node.tags]),
|
|
2365
|
-
edges: graph.edges.map(edge => [edge.source, edge.target, edge.targetTitle, edge.weight, edge.priority])
|
|
2366
|
-
})
|
|
2367
|
-
|
|
2368
|
-
const resetContentFilter = () => {
|
|
2369
|
-
if (state.contentFilter.timer) {
|
|
2370
|
-
clearTimeout(state.contentFilter.timer)
|
|
2371
|
-
}
|
|
2372
|
-
state.contentFilter = {
|
|
2373
|
-
query: '',
|
|
2374
|
-
ids: null,
|
|
2375
|
-
token: state.contentFilter.token + 1,
|
|
2376
|
-
timer: null
|
|
2377
|
-
}
|
|
2378
|
-
recomputeVisibility()
|
|
1506
|
+
syncAgentInUrl(state.agentId)
|
|
2379
1507
|
}
|
|
2380
1508
|
|
|
2381
|
-
const
|
|
2382
|
-
const response = await fetch(
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
'&limit=' +
|
|
2386
|
-
encodeURIComponent(String(Math.max(state.nodes.length, 1))) +
|
|
2387
|
-
agentQuery('&')
|
|
2388
|
-
)
|
|
2389
|
-
|
|
2390
|
-
if (!response.ok || token !== state.contentFilter.token) {
|
|
2391
|
-
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')
|
|
2392
1513
|
}
|
|
2393
1514
|
|
|
2394
1515
|
const payload = await response.json()
|
|
2395
|
-
const
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
const scheduleContentFilterSync = () => {
|
|
2407
|
-
const query = normalizeQuery(state.query)
|
|
2408
|
-
if (!query) {
|
|
2409
|
-
resetContentFilter()
|
|
2410
|
-
return
|
|
2411
|
-
}
|
|
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
|
+
]
|
|
2412
1526
|
|
|
2413
|
-
|
|
2414
|
-
clearTimeout(state.contentFilter.timer)
|
|
2415
|
-
}
|
|
1527
|
+
elements.context.innerHTML = options.join('')
|
|
2416
1528
|
|
|
2417
|
-
const
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
if (state.filterWorker && state.filterReady) {
|
|
2424
|
-
state.filterWorker.postMessage({
|
|
2425
|
-
type: 'filter',
|
|
2426
|
-
query,
|
|
2427
|
-
token,
|
|
2428
|
-
limit: Math.max(state.nodes.length, 1)
|
|
2429
|
-
})
|
|
2430
|
-
}
|
|
2431
|
-
syncContentFilter(query, token).catch(() => {})
|
|
2432
|
-
}, 180)
|
|
2433
|
-
}
|
|
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)
|
|
2434
1535
|
}
|
|
2435
1536
|
|
|
2436
|
-
const
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
state.
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
if (!shouldRunPhysics) {
|
|
2444
|
-
return
|
|
2445
|
-
}
|
|
2446
|
-
const strength = Math.min(delta / 16, 2)
|
|
2447
|
-
|
|
2448
|
-
edges.forEach(edge => {
|
|
2449
|
-
const source = edge.sourceNode
|
|
2450
|
-
const target = edge.targetNode
|
|
2451
|
-
source.vx = Number.isFinite(source.vx) ? source.vx : 0
|
|
2452
|
-
source.vy = Number.isFinite(source.vy) ? source.vy : 0
|
|
2453
|
-
target.vx = Number.isFinite(target.vx) ? target.vx : 0
|
|
2454
|
-
target.vy = Number.isFinite(target.vy) ? target.vy : 0
|
|
2455
|
-
const dx = target.x - source.x
|
|
2456
|
-
const dy = target.y - source.y
|
|
2457
|
-
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
2458
|
-
const force = (distance - 150) * 0.002 * strength
|
|
2459
|
-
const fx = (dx / distance) * force
|
|
2460
|
-
const fy = (dy / distance) * force
|
|
2461
|
-
source.vx += fx
|
|
2462
|
-
source.vy += fy
|
|
2463
|
-
target.vx -= fx
|
|
2464
|
-
target.vy -= fy
|
|
2465
|
-
})
|
|
2466
|
-
|
|
2467
|
-
for (let i = 0; i < nodes.length; i += 1) {
|
|
2468
|
-
for (let j = i + 1; j < nodes.length; j += 1) {
|
|
2469
|
-
const a = nodes[i]
|
|
2470
|
-
const b = nodes[j]
|
|
2471
|
-
a.vx = Number.isFinite(a.vx) ? a.vx : 0
|
|
2472
|
-
a.vy = Number.isFinite(a.vy) ? a.vy : 0
|
|
2473
|
-
b.vx = Number.isFinite(b.vx) ? b.vx : 0
|
|
2474
|
-
b.vy = Number.isFinite(b.vy) ? b.vy : 0
|
|
2475
|
-
const dx = b.x - a.x
|
|
2476
|
-
const dy = b.y - a.y
|
|
2477
|
-
const distance = Math.max(Math.hypot(dx, dy), 1)
|
|
2478
|
-
const force = Math.min(2600 / (distance * distance), 0.12) * strength
|
|
2479
|
-
const fx = (dx / distance) * force
|
|
2480
|
-
const fy = (dy / distance) * force
|
|
2481
|
-
a.vx -= fx
|
|
2482
|
-
a.vy -= fy
|
|
2483
|
-
b.vx += fx
|
|
2484
|
-
b.vy += fy
|
|
2485
|
-
}
|
|
2486
|
-
}
|
|
2487
|
-
|
|
2488
|
-
nodes.forEach(node => {
|
|
2489
|
-
node.vx = Number.isFinite(node.vx) ? node.vx : 0
|
|
2490
|
-
node.vy = Number.isFinite(node.vy) ? node.vy : 0
|
|
2491
|
-
node.x = Number.isFinite(node.x) ? node.x : 0
|
|
2492
|
-
node.y = Number.isFinite(node.y) ? node.y : 0
|
|
2493
|
-
if (state.pointer.dragNode === node) {
|
|
2494
|
-
node.vx = 0
|
|
2495
|
-
node.vy = 0
|
|
2496
|
-
return
|
|
2497
|
-
}
|
|
2498
|
-
node.vx += -node.x * 0.0008 * strength
|
|
2499
|
-
node.vy += -node.y * 0.0008 * strength
|
|
2500
|
-
node.vx *= 0.88
|
|
2501
|
-
node.vy *= 0.88
|
|
2502
|
-
node.x += node.vx * strength
|
|
2503
|
-
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 })
|
|
2504
1544
|
})
|
|
2505
1545
|
}
|
|
2506
1546
|
|
|
2507
|
-
const
|
|
2508
|
-
const
|
|
2509
|
-
|
|
2510
|
-
}
|
|
2511
|
-
|
|
2512
|
-
const connectedNodeIdsFor = (nodeId) => {
|
|
2513
|
-
const edges = state.visibleEdgeByNode.get(nodeId) ?? []
|
|
2514
|
-
const ids = new Set()
|
|
1547
|
+
const setupRenderWorker = () => {
|
|
1548
|
+
const hasWorker = typeof Worker !== 'undefined'
|
|
1549
|
+
const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
|
|
2515
1550
|
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
ids.add(edge.target)
|
|
2521
|
-
} else if (edge.target === nodeId) {
|
|
2522
|
-
ids.add(edge.source)
|
|
2523
|
-
}
|
|
1551
|
+
if (!hasWorker || !canTransfer) {
|
|
1552
|
+
state.rendererMode = 'fallback'
|
|
1553
|
+
drawFallback()
|
|
1554
|
+
return
|
|
2524
1555
|
}
|
|
2525
1556
|
|
|
2526
|
-
|
|
2527
|
-
|
|
1557
|
+
try {
|
|
1558
|
+
const offscreen = canvas.transferControlToOffscreen()
|
|
1559
|
+
const worker = new Worker('/render-worker.js')
|
|
1560
|
+
state.renderWorker = worker
|
|
2528
1561
|
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
const scale = Math.max(state.transform.scale, 0.0001)
|
|
2535
|
-
const influenceRadius = Math.max(220, Math.min(920, 440 / scale))
|
|
2536
|
-
const influenceRadiusSquared = influenceRadius * influenceRadius
|
|
2537
|
-
const connectedIds = connectedNodeIdsFor(dragNode.id)
|
|
2538
|
-
const candidates = state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes
|
|
2539
|
-
let adjusted = 0
|
|
2540
|
-
|
|
2541
|
-
for (let index = 0; index < candidates.length && adjusted < dragNeighborhoodMaxAffected; index += 1) {
|
|
2542
|
-
const node = candidates[index]
|
|
2543
|
-
if (node.id === dragNode.id) continue
|
|
2544
|
-
|
|
2545
|
-
const isConnected = connectedIds.has(node.id)
|
|
2546
|
-
const dx = node.x - dragNode.x
|
|
2547
|
-
const dy = node.y - dragNode.y
|
|
2548
|
-
const distanceSquared = dx * dx + dy * dy
|
|
2549
|
-
const withinRadius = distanceSquared <= influenceRadiusSquared
|
|
2550
|
-
if (!isConnected && !withinRadius) continue
|
|
2551
|
-
|
|
2552
|
-
const distance = Math.max(Math.sqrt(distanceSquared), 0.0001)
|
|
2553
|
-
const proximity = withinRadius ? 1 - (distance / influenceRadius) : 0
|
|
2554
|
-
const coupledStrength = isConnected ? 0.28 : 0.12
|
|
2555
|
-
const influence = Math.min(0.46, coupledStrength + proximity * 0.34)
|
|
2556
|
-
node.x += deltaX * influence
|
|
2557
|
-
node.y += deltaY * influence
|
|
2558
|
-
node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + deltaX * influence * 0.06
|
|
2559
|
-
node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + deltaY * influence * 0.06
|
|
2560
|
-
adjusted += 1
|
|
2561
|
-
}
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
const settleNeighborhoodAroundNode = (dragNode) => {
|
|
2565
|
-
if (!dragNode) return
|
|
2566
|
-
|
|
2567
|
-
const scale = Math.max(state.transform.scale, 0.0001)
|
|
2568
|
-
const settleRadius = Math.max(240, Math.min(980, 520 / scale))
|
|
2569
|
-
const settleRadiusSquared = settleRadius * settleRadius
|
|
2570
|
-
const connectedIds = connectedNodeIdsFor(dragNode.id)
|
|
2571
|
-
const candidates = (state.renderNodes.length > 0 ? state.renderNodes : state.visibleNodes)
|
|
2572
|
-
.filter((node) => {
|
|
2573
|
-
if (node.id === dragNode.id) return true
|
|
2574
|
-
const dx = node.x - dragNode.x
|
|
2575
|
-
const dy = node.y - dragNode.y
|
|
2576
|
-
const distanceSquared = dx * dx + dy * dy
|
|
2577
|
-
return connectedIds.has(node.id) || distanceSquared <= settleRadiusSquared
|
|
2578
|
-
})
|
|
2579
|
-
.slice(0, dragNeighborhoodMaxAffected)
|
|
2580
|
-
|
|
2581
|
-
if (candidates.length <= 1) return
|
|
2582
|
-
|
|
2583
|
-
for (let round = 0; round < dragSettleRounds; round += 1) {
|
|
2584
|
-
for (let leftIndex = 0; leftIndex < candidates.length; leftIndex += 1) {
|
|
2585
|
-
const left = candidates[leftIndex]
|
|
2586
|
-
for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex += 1) {
|
|
2587
|
-
const right = candidates[rightIndex]
|
|
2588
|
-
const dx = right.x - left.x
|
|
2589
|
-
const dy = right.y - left.y
|
|
2590
|
-
const distance = Math.max(Math.hypot(dx, dy), 0.001)
|
|
2591
|
-
const minDistance = baseNodeRadius(left) + baseNodeRadius(right) + 10
|
|
2592
|
-
if (distance >= minDistance) continue
|
|
2593
|
-
|
|
2594
|
-
const push = (minDistance - distance) * 0.36
|
|
2595
|
-
const ux = dx / distance
|
|
2596
|
-
const uy = dy / distance
|
|
2597
|
-
if (left.id !== dragNode.id) {
|
|
2598
|
-
left.x -= ux * push
|
|
2599
|
-
left.y -= uy * push
|
|
2600
|
-
}
|
|
2601
|
-
if (right.id !== dragNode.id) {
|
|
2602
|
-
right.x += ux * push
|
|
2603
|
-
right.y += uy * push
|
|
2604
|
-
}
|
|
1562
|
+
worker.onmessage = (event) => {
|
|
1563
|
+
const payload = event.data
|
|
1564
|
+
if (!payload || typeof payload !== 'object') {
|
|
1565
|
+
return
|
|
2605
1566
|
}
|
|
2606
|
-
}
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
const hitNode = point => {
|
|
2611
|
-
computeRenderVisibility()
|
|
2612
|
-
if (state.renderClusters.length > 0) {
|
|
2613
|
-
return null
|
|
2614
|
-
}
|
|
2615
|
-
const hitScaleFloor = state.nodes.length > massiveGraphNodeThreshold
|
|
2616
|
-
? 0.2
|
|
2617
|
-
: state.nodes.length > largeGraphNodeThreshold
|
|
2618
|
-
? 0.34
|
|
2619
|
-
: 0
|
|
2620
|
-
if (state.transform.scale < hitScaleFloor) {
|
|
2621
|
-
return null
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
|
-
const nodes = state.renderNodes
|
|
2625
|
-
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
2626
|
-
const node = nodes[index]
|
|
2627
|
-
const radius = nodeRadius(node)
|
|
2628
|
-
if (Math.hypot(point.x - node.x, point.y - node.y) <= radius + 5) return node
|
|
2629
|
-
}
|
|
2630
|
-
return null
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
const baseNodeRadius = node => {
|
|
2634
|
-
const degree = state.nodeDegrees.get(node.id) ?? 0
|
|
2635
|
-
return 9 + Math.min(degree, 8) * 1.6
|
|
2636
|
-
}
|
|
2637
|
-
|
|
2638
|
-
const nodeRadius = node => Math.max(baseNodeRadius(node), minNodePixelRadius / Math.max(state.transform.scale, 0.0001))
|
|
2639
|
-
|
|
2640
|
-
const clusterRadiusPx = cluster => {
|
|
2641
|
-
if (cluster.id === 'macro-galaxy') {
|
|
2642
|
-
return 10
|
|
2643
|
-
}
|
|
2644
|
-
if (cluster.isHub) {
|
|
2645
|
-
return 3.8
|
|
2646
|
-
}
|
|
2647
|
-
if (String(cluster.id).startsWith('ecosystem-')) {
|
|
2648
|
-
const size = Math.max(1, Math.min(ecosystemLevelNodeCap, cluster.size || cluster.count || 1))
|
|
2649
|
-
const sizeBias = 0.56 + Math.log10(size + 1) * 0.28
|
|
2650
|
-
const densityBias = Math.log10((cluster.count || 1) + 1) * 0.12
|
|
2651
|
-
return Math.max(0.62, Math.min(2.4, sizeBias + densityBias))
|
|
2652
|
-
}
|
|
2653
|
-
return Math.max(8, Math.min(28, 8 + Math.log2(cluster.count + 1) * 3))
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
const clusterOpacity = cluster =>
|
|
2657
|
-
Math.max(0, Math.min(1, Number.isFinite(cluster.lodOpacity) ? cluster.lodOpacity : 1))
|
|
2658
|
-
|
|
2659
|
-
const worldViewportBounds = () => {
|
|
2660
|
-
const rect = canvas.getBoundingClientRect()
|
|
2661
|
-
const width = Math.max(rect.width, 320)
|
|
2662
|
-
const height = Math.max(rect.height, 320)
|
|
2663
|
-
const paddingMultiplier =
|
|
2664
|
-
state.nodes.length > massiveGraphNodeThreshold
|
|
2665
|
-
? (state.transform.scale >= 0.6 ? 2.8 : state.transform.scale >= 0.25 ? 2.35 : 1.9)
|
|
2666
|
-
: state.nodes.length > largeGraphNodeThreshold
|
|
2667
|
-
? 1.45
|
|
2668
|
-
: 1
|
|
2669
|
-
const padding = viewportPaddingPx * paddingMultiplier
|
|
2670
|
-
|
|
2671
|
-
return {
|
|
2672
|
-
minX: (-state.transform.x - padding) / state.transform.scale,
|
|
2673
|
-
maxX: (width - state.transform.x + padding) / state.transform.scale,
|
|
2674
|
-
minY: (-state.transform.y - padding) / state.transform.scale,
|
|
2675
|
-
maxY: (height - state.transform.y + padding) / state.transform.scale
|
|
2676
|
-
}
|
|
2677
|
-
}
|
|
2678
|
-
|
|
2679
|
-
const isNodeInViewport = (node, viewport) =>
|
|
2680
|
-
node.x >= viewport.minX &&
|
|
2681
|
-
node.x <= viewport.maxX &&
|
|
2682
|
-
node.y >= viewport.minY &&
|
|
2683
|
-
node.y <= viewport.maxY
|
|
2684
|
-
|
|
2685
|
-
const expandViewportBounds = (viewport, worldMargin) => ({
|
|
2686
|
-
minX: viewport.minX - worldMargin,
|
|
2687
|
-
maxX: viewport.maxX + worldMargin,
|
|
2688
|
-
minY: viewport.minY - worldMargin,
|
|
2689
|
-
maxY: viewport.maxY + worldMargin
|
|
2690
|
-
})
|
|
2691
|
-
|
|
2692
|
-
const viewportNodeStride = () => {
|
|
2693
|
-
if (state.nodes.length <= largeGraphNodeThreshold) {
|
|
2694
|
-
return 1
|
|
2695
|
-
}
|
|
2696
|
-
|
|
2697
|
-
if (state.transform.scale >= 0.95) {
|
|
2698
|
-
return 1
|
|
2699
|
-
}
|
|
2700
|
-
if (state.transform.scale >= 0.7) {
|
|
2701
|
-
return 2
|
|
2702
|
-
}
|
|
2703
|
-
if (state.transform.scale >= 0.48) {
|
|
2704
|
-
return 3
|
|
2705
|
-
}
|
|
2706
|
-
if (state.transform.scale >= 0.28) {
|
|
2707
|
-
return 5
|
|
2708
|
-
}
|
|
2709
|
-
|
|
2710
|
-
return 8
|
|
2711
|
-
}
|
|
2712
1567
|
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
if (!shouldRenderClusters(viewportNodes)) {
|
|
2718
|
-
return []
|
|
2719
|
-
}
|
|
2720
|
-
|
|
2721
|
-
const worldCellSize = Math.max(clusterCellPixelSize / Math.max(state.transform.scale, 0.0001), 1)
|
|
2722
|
-
const buckets = new Map()
|
|
2723
|
-
|
|
2724
|
-
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
2725
|
-
const node = viewportNodes[index]
|
|
2726
|
-
const keyX = Math.floor(node.x / worldCellSize)
|
|
2727
|
-
const keyY = Math.floor(node.y / worldCellSize)
|
|
2728
|
-
const key = keyX + ':' + keyY
|
|
2729
|
-
const current = buckets.get(key)
|
|
2730
|
-
if (current) {
|
|
2731
|
-
current.count += 1
|
|
2732
|
-
current.sumX += node.x
|
|
2733
|
-
current.sumY += node.y
|
|
2734
|
-
if ((state.nodeDegrees.get(node.id) ?? 0) > current.degree) {
|
|
2735
|
-
current.representative = node
|
|
2736
|
-
current.degree = state.nodeDegrees.get(node.id) ?? 0
|
|
1568
|
+
if (payload.type === 'ready') {
|
|
1569
|
+
state.workerReady = true
|
|
1570
|
+
scheduleChunkFetch({ fit: true })
|
|
1571
|
+
return
|
|
2737
1572
|
}
|
|
2738
|
-
continue
|
|
2739
|
-
}
|
|
2740
|
-
|
|
2741
|
-
buckets.set(key, {
|
|
2742
|
-
id: key,
|
|
2743
|
-
count: 1,
|
|
2744
|
-
sumX: node.x,
|
|
2745
|
-
sumY: node.y,
|
|
2746
|
-
representative: node,
|
|
2747
|
-
degree: state.nodeDegrees.get(node.id) ?? 0
|
|
2748
|
-
})
|
|
2749
|
-
}
|
|
2750
1573
|
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
.map((cluster) => ({
|
|
2755
|
-
id: cluster.id,
|
|
2756
|
-
x: cluster.sumX / Math.max(cluster.count, 1),
|
|
2757
|
-
y: cluster.sumY / Math.max(cluster.count, 1),
|
|
2758
|
-
count: cluster.count,
|
|
2759
|
-
representative: cluster.representative
|
|
2760
|
-
}))
|
|
2761
|
-
}
|
|
2762
|
-
|
|
2763
|
-
const representativeNodesFromClusters = (clusters, limit) => {
|
|
2764
|
-
const representatives = clusters
|
|
2765
|
-
.map((cluster) => cluster.representative)
|
|
2766
|
-
.filter((node) => Boolean(node))
|
|
2767
|
-
const merged = mergeUniqueNodes(
|
|
2768
|
-
representatives,
|
|
2769
|
-
state.renderNodes ?? [],
|
|
2770
|
-
Math.max(1, limit)
|
|
2771
|
-
)
|
|
2772
|
-
return ensureHubNodesInRenderedSet(merged)
|
|
2773
|
-
}
|
|
2774
|
-
|
|
2775
|
-
const computeRenderVisibility = () => {
|
|
2776
|
-
if (!hasValidTransform()) {
|
|
2777
|
-
fitView({ useFiltered: true })
|
|
2778
|
-
}
|
|
2779
|
-
const viewport = worldViewportBounds()
|
|
2780
|
-
const viewportKey =
|
|
2781
|
-
Math.round(viewport.minX * 10) + ':' +
|
|
2782
|
-
Math.round(viewport.maxX * 10) + ':' +
|
|
2783
|
-
Math.round(viewport.minY * 10) + ':' +
|
|
2784
|
-
Math.round(viewport.maxY * 10) + ':' +
|
|
2785
|
-
visibilityScaleBucket(state.transform.scale)
|
|
2786
|
-
|
|
2787
|
-
if (!state.renderVisibilityDirty && viewportKey === state.lastViewportKey) {
|
|
2788
|
-
return
|
|
2789
|
-
}
|
|
2790
|
-
state.lastViewportKey = viewportKey
|
|
2791
|
-
state.renderVisibilityDirty = false
|
|
2792
|
-
state.renderClusterEdges = []
|
|
2793
|
-
|
|
2794
|
-
const shouldRenderMacroGalaxy = shouldRenderMacroGalaxyView()
|
|
2795
|
-
|
|
2796
|
-
if (shouldRenderMacroGalaxy) {
|
|
2797
|
-
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2798
|
-
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
2799
|
-
const representative = state.primaryHub ?? state.macroRepresentative ?? sourceNodes[0] ?? null
|
|
2800
|
-
if (representative) {
|
|
2801
|
-
state.renderClusters = [
|
|
2802
|
-
{
|
|
2803
|
-
id: 'macro-galaxy',
|
|
2804
|
-
x: state.macroCenter.x,
|
|
2805
|
-
y: state.macroCenter.y,
|
|
2806
|
-
count: sourceNodes.length,
|
|
2807
|
-
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)
|
|
2808
1577
|
}
|
|
2809
|
-
]
|
|
2810
|
-
state.renderNodes = [representative]
|
|
2811
|
-
} else {
|
|
2812
|
-
state.renderClusters = []
|
|
2813
|
-
state.renderNodes = []
|
|
2814
|
-
}
|
|
2815
|
-
state.renderEdges = []
|
|
2816
|
-
state.renderClusterEdges = []
|
|
2817
|
-
return
|
|
2818
|
-
}
|
|
2819
|
-
|
|
2820
|
-
const ecosystemScaleThreshold = state.visibleNodes.length > massiveGraphNodeThreshold
|
|
2821
|
-
? massiveEcosystemClusterScaleThreshold
|
|
2822
|
-
: ecosystemClusterScaleThreshold
|
|
2823
|
-
if (
|
|
2824
|
-
state.visibleNodes.length > ecosystemActivationNodeThreshold &&
|
|
2825
|
-
state.transform.scale <= ecosystemScaleThreshold &&
|
|
2826
|
-
state.ecosystemClusters.length > 0
|
|
2827
|
-
) {
|
|
2828
|
-
const clusters = selectHierarchicalEcosystemClusters(viewport)
|
|
2829
|
-
.sort((left, right) => right.count - left.count)
|
|
2830
|
-
state.renderClusters = clusters
|
|
2831
|
-
state.renderClusterEdges = ecosystemEdgesForClusters(clusters)
|
|
2832
|
-
state.renderNodes = []
|
|
2833
|
-
state.renderEdges = []
|
|
2834
|
-
return
|
|
2835
|
-
}
|
|
2836
|
-
|
|
2837
|
-
if (state.visibleNodes.length <= 2000) {
|
|
2838
|
-
state.renderNodes = state.visibleNodes
|
|
2839
|
-
state.renderClusters = []
|
|
2840
|
-
state.renderClusterEdges = []
|
|
2841
|
-
const ids = new Set(state.renderNodes.map((node) => node.id))
|
|
2842
|
-
state.renderEdges = withMeshEdges(state.renderNodes, collectVisibleEdgesForNodes(ids))
|
|
2843
|
-
return
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
|
-
if (state.visibleNodes.length > massiveGraphNodeThreshold) {
|
|
2847
|
-
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2848
|
-
const sourceNodes = viewportNodes.length > 0 ? viewportNodes : state.visibleNodes
|
|
2849
|
-
const sampleLimit = nodeBudgetForScale(state.transform.scale)
|
|
2850
|
-
const carryMargin = Math.max(240, Math.min(1200, 340 / Math.max(state.transform.scale, 0.0001)))
|
|
2851
|
-
const carryViewport = expandViewportBounds(viewport, carryMargin)
|
|
2852
|
-
const carryOverLimit = Math.max(180, Math.min(sampleLimit, Math.floor(sampleLimit * 0.5)))
|
|
2853
|
-
const carryOverNodes = (state.renderNodes ?? [])
|
|
2854
|
-
.filter((node) => isNodeInViewport(node, carryViewport))
|
|
2855
|
-
.slice(0, carryOverLimit)
|
|
2856
|
-
const sourceWithCarry = mergeUniqueNodes(
|
|
2857
|
-
sourceNodes,
|
|
2858
|
-
carryOverNodes,
|
|
2859
|
-
Math.max(sampleLimit * 7, carryOverLimit)
|
|
2860
|
-
)
|
|
2861
|
-
const sourceWithCarryIds = new Set(sourceWithCarry.map((node) => node.id))
|
|
2862
|
-
const sampledRaw = selectStableSampleNodes(
|
|
2863
|
-
sourceWithCarry,
|
|
2864
|
-
sampleLimit
|
|
2865
|
-
)
|
|
2866
|
-
const continuityBudget = Math.max(24, Math.min(sampleLimit - 8, Math.floor(sampleLimit * 0.42)))
|
|
2867
|
-
const previousVisibleNodes = (state.renderNodes ?? [])
|
|
2868
|
-
.filter((node) => sourceWithCarryIds.has(node.id))
|
|
2869
|
-
const continuityNodes = selectStableSampleNodes(previousVisibleNodes, continuityBudget)
|
|
2870
|
-
const sampled = mergeUniqueNodes(
|
|
2871
|
-
continuityNodes,
|
|
2872
|
-
sampledRaw,
|
|
2873
|
-
sampleLimit
|
|
2874
|
-
)
|
|
2875
|
-
let sampledNodes = ensureHubNodesInRenderedSet(sampled)
|
|
2876
|
-
if (state.transform.scale < 0.035) {
|
|
2877
|
-
sampledNodes = includeHubPreviewNeighborhood(
|
|
2878
|
-
sampledNodes,
|
|
2879
|
-
Math.min(renderNodeBudget, sampleLimit + 160)
|
|
2880
|
-
)
|
|
2881
|
-
}
|
|
2882
|
-
const sampledIds = new Set(sampledNodes.map((node) => node.id))
|
|
2883
|
-
let sampledEdges = collectVisibleEdgesForNodes(sampledIds)
|
|
2884
|
-
|
|
2885
|
-
if (state.transform.scale >= 0.035 && sampledEdges.length === 0) {
|
|
2886
|
-
const enriched = enrichSampleWithNeighbors(sampledNodes)
|
|
2887
|
-
sampledNodes = ensureHubNodesInRenderedSet(enriched.nodes)
|
|
2888
|
-
const sampledWithHubsIds = new Set(sampledNodes.map((node) => node.id))
|
|
2889
|
-
sampledEdges = collectVisibleEdgesForNodes(sampledWithHubsIds)
|
|
2890
|
-
}
|
|
2891
|
-
|
|
2892
|
-
state.renderClusters = []
|
|
2893
|
-
state.renderClusterEdges = []
|
|
2894
|
-
state.renderNodes = sampledNodes
|
|
2895
|
-
state.renderEdges = withMeshEdges(sampledNodes, sampledEdges)
|
|
2896
|
-
return
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
if (state.transform.scale <= 0.0015) {
|
|
2900
|
-
const sampled = sampleVisibleNodes(Math.min(renderNodeBudget, 900))
|
|
2901
|
-
const sampledIds = new Set(sampled.map((node) => node.id))
|
|
2902
|
-
state.renderClusters = []
|
|
2903
|
-
state.renderClusterEdges = []
|
|
2904
|
-
state.renderNodes = sampled
|
|
2905
|
-
state.renderEdges = withMeshEdges(sampled, collectVisibleEdgesForNodes(sampledIds))
|
|
2906
|
-
return
|
|
2907
|
-
}
|
|
2908
|
-
|
|
2909
|
-
const viewportNodes = viewportNodesFromSpatialIndex(viewport)
|
|
2910
|
-
const clusters = clusterViewportNodes(viewportNodes)
|
|
2911
|
-
if (clusters.length > 0) {
|
|
2912
|
-
state.renderClusters = []
|
|
2913
|
-
state.renderClusterEdges = []
|
|
2914
|
-
state.renderNodes = representativeNodesFromClusters(clusters, Math.min(renderNodeBudget, 900))
|
|
2915
|
-
state.renderEdges = []
|
|
2916
|
-
return
|
|
2917
|
-
}
|
|
2918
|
-
state.renderClusters = []
|
|
2919
|
-
state.renderClusterEdges = []
|
|
2920
|
-
const stride = viewportNodeStride()
|
|
2921
|
-
const picked = []
|
|
2922
|
-
|
|
2923
|
-
for (let index = 0; index < viewportNodes.length; index += 1) {
|
|
2924
|
-
const node = viewportNodes[index]
|
|
2925
|
-
|
|
2926
|
-
const isPriority =
|
|
2927
|
-
node.id === state.selected?.id ||
|
|
2928
|
-
node.id === state.hovered?.id ||
|
|
2929
|
-
node.id === state.pointer.dragNode?.id
|
|
2930
|
-
if (isPriority || index % stride === 0) {
|
|
2931
|
-
picked.push(node)
|
|
2932
|
-
}
|
|
2933
|
-
}
|
|
2934
|
-
|
|
2935
|
-
const nodes = picked.length > renderNodeBudget
|
|
2936
|
-
? picked.slice(0, renderNodeBudget)
|
|
2937
|
-
: picked
|
|
2938
|
-
if (nodes.length === 0 && state.visibleNodes.length > 0) {
|
|
2939
|
-
const fallbackNodes = fallbackViewportNodes()
|
|
2940
|
-
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
2941
|
-
state.renderNodes = fallbackNodes
|
|
2942
|
-
state.renderClusters = []
|
|
2943
|
-
state.renderClusterEdges = []
|
|
2944
|
-
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
2945
|
-
return
|
|
2946
|
-
}
|
|
2947
|
-
|
|
2948
|
-
const normalizedNodes = ensureHubNodesInRenderedSet(nodes)
|
|
2949
|
-
const nodeIds = new Set(normalizedNodes.map((node) => node.id))
|
|
2950
|
-
const edges = collectVisibleEdgesForNodes(nodeIds)
|
|
2951
|
-
|
|
2952
|
-
state.renderNodes = normalizedNodes
|
|
2953
|
-
state.renderEdges = withMeshEdges(normalizedNodes, edges)
|
|
2954
|
-
|
|
2955
|
-
if (state.renderNodes.length === 0 && state.visibleNodes.length > 0) {
|
|
2956
|
-
const fallbackNodes = sampleVisibleNodes(Math.min(renderNodeBudget, 260))
|
|
2957
|
-
const fallbackIds = new Set(fallbackNodes.map((node) => node.id))
|
|
2958
|
-
state.renderClusters = []
|
|
2959
|
-
state.renderClusterEdges = []
|
|
2960
|
-
state.renderNodes = fallbackNodes
|
|
2961
|
-
state.renderEdges = withMeshEdges(fallbackNodes, collectVisibleEdgesForNodes(fallbackIds))
|
|
2962
|
-
}
|
|
2963
|
-
}
|
|
2964
|
-
|
|
2965
|
-
const isNodeVisibleOnScreen = (node, width, height) => {
|
|
2966
|
-
const radius = nodeRadius(node) * state.transform.scale
|
|
2967
|
-
const screenX = node.x * state.transform.scale + state.transform.x
|
|
2968
|
-
const screenY = node.y * state.transform.scale + state.transform.y
|
|
2969
|
-
|
|
2970
|
-
return (
|
|
2971
|
-
screenX + radius >= 0 &&
|
|
2972
|
-
screenX - radius <= width &&
|
|
2973
|
-
screenY + radius >= 0 &&
|
|
2974
|
-
screenY - radius <= height
|
|
2975
|
-
)
|
|
2976
|
-
}
|
|
2977
|
-
|
|
2978
|
-
const hasValidTransform = () =>
|
|
2979
|
-
isFiniteNumber(state.transform.x) &&
|
|
2980
|
-
isFiniteNumber(state.transform.y) &&
|
|
2981
|
-
isFiniteNumber(state.transform.scale) &&
|
|
2982
|
-
Math.abs(state.transform.x) <= transformCoordinateLimit &&
|
|
2983
|
-
Math.abs(state.transform.y) <= transformCoordinateLimit &&
|
|
2984
|
-
state.transform.scale > 0
|
|
2985
|
-
|
|
2986
|
-
const sanitizeNodePosition = node => {
|
|
2987
|
-
if (!isReasonableCoordinate(node.x)) node.x = 0
|
|
2988
|
-
if (!isReasonableCoordinate(node.y)) node.y = 0
|
|
2989
|
-
if (!isFiniteNumber(node.vx) || Math.abs(node.vx) > worldCoordinateLimit) node.vx = 0
|
|
2990
|
-
if (!isFiniteNumber(node.vy) || Math.abs(node.vy) > worldCoordinateLimit) node.vy = 0
|
|
2991
|
-
}
|
|
2992
|
-
|
|
2993
|
-
const sanitizeAllNodePositions = () => {
|
|
2994
|
-
state.nodes.forEach(sanitizeNodePosition)
|
|
2995
|
-
state.visibleNodes.forEach(sanitizeNodePosition)
|
|
2996
|
-
}
|
|
2997
|
-
|
|
2998
|
-
const sanitizeGraphState = () => {
|
|
2999
|
-
state.renderNodes.forEach(sanitizeNodePosition)
|
|
3000
|
-
}
|
|
3001
|
-
|
|
3002
|
-
const render = now => {
|
|
3003
|
-
const delta = now - state.last
|
|
3004
|
-
state.last = now
|
|
3005
|
-
const backgroundFrameIntervalMs =
|
|
3006
|
-
state.nodes.length > massiveGraphNodeThreshold
|
|
3007
|
-
? (state.transform.scale < 0.035 ? 130 : state.transform.scale < 0.08 ? 110 : 86)
|
|
3008
|
-
: state.nodes.length > largeGraphNodeThreshold
|
|
3009
|
-
? 64
|
|
3010
|
-
: 16
|
|
3011
|
-
const isInteracting =
|
|
3012
|
-
state.pointer.down ||
|
|
3013
|
-
state.renderVisibilityDirty ||
|
|
3014
|
-
state.recoveringViewport
|
|
3015
|
-
const minFrameIntervalMs = isInteracting ? 16 : backgroundFrameIntervalMs
|
|
3016
|
-
if (delta < minFrameIntervalMs) {
|
|
3017
|
-
requestAnimationFrame(render)
|
|
3018
|
-
return
|
|
3019
|
-
}
|
|
3020
|
-
const rect = canvas.getBoundingClientRect()
|
|
3021
|
-
const width = Math.max(rect.width, 320)
|
|
3022
|
-
const height = Math.max(rect.height, 320)
|
|
3023
|
-
sanitizeGraphState()
|
|
3024
|
-
if (!hasValidTransform()) {
|
|
3025
|
-
resetView()
|
|
3026
|
-
}
|
|
3027
|
-
ctx.clearRect(0, 0, width, height)
|
|
3028
|
-
webGlRenderer?.clear(width, height)
|
|
3029
|
-
if (state.nodes.length === 0) {
|
|
3030
|
-
ctx.fillStyle = '#99a5b5'
|
|
3031
|
-
ctx.font = '14px Inter, system-ui, sans-serif'
|
|
3032
|
-
ctx.textAlign = 'center'
|
|
3033
|
-
ctx.fillText('No indexed notes found', width / 2, height / 2)
|
|
3034
|
-
requestAnimationFrame(render)
|
|
3035
|
-
return
|
|
3036
|
-
}
|
|
3037
|
-
|
|
3038
|
-
computeRenderVisibility()
|
|
3039
|
-
tick(delta)
|
|
3040
|
-
const hasVisibleNodeOnScreen = state.renderNodes.some((node) => isNodeVisibleOnScreen(node, width, height))
|
|
3041
|
-
const manualZoomGuardActive = now - state.lastManualZoomAt < zoomRecoveryGuardMs
|
|
3042
|
-
const allowViewportAutoRecovery = state.nodes.length <= massiveGraphNodeThreshold
|
|
3043
|
-
if (allowViewportAutoRecovery && !hasVisibleNodeOnScreen && state.renderNodes.length > 0 && !manualZoomGuardActive) {
|
|
3044
|
-
state.offscreenFrameCount += 1
|
|
3045
|
-
if (state.offscreenFrameCount >= 22 && !state.recoveringViewport) {
|
|
3046
|
-
state.recoveringViewport = true
|
|
3047
|
-
fitView({ useFiltered: true })
|
|
3048
|
-
state.offscreenFrameCount = 0
|
|
3049
|
-
requestAnimationFrame(() => {
|
|
3050
|
-
state.recoveringViewport = false
|
|
3051
|
-
})
|
|
3052
|
-
}
|
|
3053
|
-
} else {
|
|
3054
|
-
state.offscreenFrameCount = 0
|
|
3055
|
-
}
|
|
3056
|
-
const minimumEdgeScale =
|
|
3057
|
-
state.nodes.length > massiveGraphNodeThreshold
|
|
3058
|
-
? 0
|
|
3059
|
-
: state.renderNodes.length > 1300
|
|
3060
|
-
? 0.12
|
|
3061
|
-
: state.renderNodes.length > 900
|
|
3062
|
-
? 0.085
|
|
3063
|
-
: state.renderNodes.length > 500
|
|
3064
|
-
? 0.05
|
|
3065
|
-
: 0
|
|
3066
|
-
const drawEdges =
|
|
3067
|
-
state.renderClusters.length === 0 &&
|
|
3068
|
-
state.transform.scale >= minimumEdgeScale
|
|
3069
|
-
if (drawAcceleratedGraph(width, height, drawEdges)) {
|
|
3070
|
-
// WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
|
|
3071
|
-
} else if (state.renderClusters.length > 0) {
|
|
3072
|
-
ctx.save()
|
|
3073
|
-
ctx.translate(state.transform.x, state.transform.y)
|
|
3074
|
-
ctx.scale(state.transform.scale, state.transform.scale)
|
|
3075
|
-
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
3076
|
-
if (state.renderClusterEdges.length > 0) {
|
|
3077
|
-
for (let index = 0; index < state.renderClusterEdges.length; index += 1) {
|
|
3078
|
-
const edge = state.renderClusterEdges[index]
|
|
3079
|
-
const edgeOpacity = Math.min(clusterOpacity(edge.sourceCluster), clusterOpacity(edge.targetCluster))
|
|
3080
|
-
if (edgeOpacity <= 0.01) {
|
|
3081
|
-
continue
|
|
3082
|
-
}
|
|
3083
|
-
ctx.beginPath()
|
|
3084
|
-
ctx.moveTo(edge.sourceCluster.x, edge.sourceCluster.y)
|
|
3085
|
-
ctx.lineTo(edge.targetCluster.x, edge.targetCluster.y)
|
|
3086
|
-
ctx.lineWidth = 1.2 / safeScale
|
|
3087
|
-
ctx.strokeStyle = 'rgba(153, 165, 181, ' + (edge.inferred ? 0.14 : 0.22) * edgeOpacity + ')'
|
|
3088
|
-
ctx.stroke()
|
|
3089
|
-
}
|
|
3090
|
-
}
|
|
3091
|
-
state.renderClusters.forEach(cluster => {
|
|
3092
|
-
const isMacro = cluster.id === 'macro-galaxy'
|
|
3093
|
-
const isEcosystem = String(cluster.id).startsWith('ecosystem-')
|
|
3094
|
-
const isHub = Boolean(cluster.isHub)
|
|
3095
|
-
const opacity = clusterOpacity(cluster)
|
|
3096
|
-
if (opacity <= 0.01) {
|
|
3097
1578
|
return
|
|
3098
1579
|
}
|
|
3099
|
-
const radiusPx = clusterRadiusPx(cluster)
|
|
3100
|
-
const radius = radiusPx / safeScale
|
|
3101
|
-
const haloRadius = (radiusPx + (isMacro ? 8 : isHub ? 4 : isEcosystem ? 1.1 : 4)) / safeScale
|
|
3102
|
-
ctx.globalAlpha = opacity
|
|
3103
|
-
if (isHub || !isEcosystem || state.transform.scale >= ecosystemSubgraphScaleThreshold) {
|
|
3104
|
-
ctx.beginPath()
|
|
3105
|
-
ctx.arc(cluster.x, cluster.y, haloRadius, 0, Math.PI * 2)
|
|
3106
|
-
ctx.fillStyle = isMacro ? 'rgba(243, 247, 251, 0.28)' : graphTheme.nodeHalo
|
|
3107
|
-
ctx.fill()
|
|
3108
|
-
}
|
|
3109
|
-
ctx.beginPath()
|
|
3110
|
-
ctx.arc(cluster.x, cluster.y, radius, 0, Math.PI * 2)
|
|
3111
|
-
ctx.fillStyle = isMacro ? '#f3f7fb' : graphTheme.node
|
|
3112
|
-
ctx.fill()
|
|
3113
|
-
ctx.lineWidth = (isEcosystem && !isHub ? 0.7 : 1.4) / safeScale
|
|
3114
|
-
ctx.strokeStyle = isMacro ? '#ffffff' : graphTheme.nodeStroke
|
|
3115
|
-
ctx.stroke()
|
|
3116
|
-
if (isMacro && cluster.representative?.title) {
|
|
3117
|
-
ctx.fillStyle = '#edf2f7'
|
|
3118
|
-
ctx.font = 12 / safeScale + 'px Inter, system-ui, sans-serif'
|
|
3119
|
-
ctx.textAlign = 'center'
|
|
3120
|
-
ctx.textBaseline = 'top'
|
|
3121
|
-
ctx.fillText(cluster.representative.title.slice(0, 28), cluster.x, cluster.y + (radiusPx + 9) / safeScale)
|
|
3122
|
-
}
|
|
3123
|
-
ctx.globalAlpha = 1
|
|
3124
|
-
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
3125
|
-
})
|
|
3126
|
-
ctx.restore()
|
|
3127
|
-
} else {
|
|
3128
|
-
ctx.save()
|
|
3129
|
-
ctx.translate(state.transform.x, state.transform.y)
|
|
3130
|
-
ctx.scale(state.transform.scale, state.transform.scale)
|
|
3131
|
-
if (drawEdges) {
|
|
3132
|
-
drawGraphEdges()
|
|
3133
|
-
}
|
|
3134
|
-
drawGraphNodes()
|
|
3135
|
-
ctx.restore()
|
|
3136
|
-
}
|
|
3137
|
-
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
3138
|
-
ctx.fillStyle = '#99a5b5'
|
|
3139
|
-
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
3140
|
-
ctx.textAlign = 'center'
|
|
3141
|
-
ctx.fillText('Move or zoom to reveal nearby notes', width / 2, height / 2)
|
|
3142
|
-
}
|
|
3143
|
-
requestAnimationFrame(render)
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
|
-
const list = items => items.length
|
|
3147
|
-
? 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('')
|
|
3148
|
-
: '<li><small>No links found.</small></li>'
|
|
3149
|
-
|
|
3150
|
-
const linkedNodes = node => {
|
|
3151
|
-
const nodeById = new Map(state.nodes.map(item => [item.id, item]))
|
|
3152
|
-
const withEdgeMeta = (linkedNode, edge) => linkedNode ? {
|
|
3153
|
-
...linkedNode,
|
|
3154
|
-
weight: edge.weight,
|
|
3155
|
-
priority: edge.priority
|
|
3156
|
-
} : null
|
|
3157
|
-
const outgoing = state.edges
|
|
3158
|
-
.filter(edge => edge.source === node.id)
|
|
3159
|
-
.map(edge => withEdgeMeta(edge.target ? nodeById.get(edge.target) : { title: (edge.targetTitle || 'Unknown') + ' (unresolved)', path: 'Missing note' }, edge))
|
|
3160
|
-
.filter(Boolean)
|
|
3161
|
-
const incoming = state.edges
|
|
3162
|
-
.filter(edge => edge.target === node.id)
|
|
3163
|
-
.map(edge => withEdgeMeta(nodeById.get(edge.source), edge))
|
|
3164
|
-
.filter(Boolean)
|
|
3165
|
-
|
|
3166
|
-
return { outgoing, incoming }
|
|
3167
|
-
}
|
|
3168
|
-
|
|
3169
|
-
const fetchNodeDetails = async node => {
|
|
3170
|
-
const cached = state.nodeDetails.get(node.id)
|
|
3171
|
-
if (cached) {
|
|
3172
|
-
return cached
|
|
3173
|
-
}
|
|
3174
|
-
|
|
3175
|
-
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(node.id) + agentQuery('&'))
|
|
3176
|
-
if (!response.ok) {
|
|
3177
|
-
throw new Error('Failed to load graph node details')
|
|
3178
|
-
}
|
|
3179
1580
|
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
throw new Error('Invalid graph node payload')
|
|
3184
|
-
}
|
|
3185
|
-
state.nodeDetails.set(detail.id, detail)
|
|
3186
|
-
return detail
|
|
3187
|
-
}
|
|
3188
|
-
|
|
3189
|
-
const wait = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
3190
|
-
|
|
3191
|
-
const openContentDialog = async node => {
|
|
3192
|
-
if (!node) return
|
|
3193
|
-
elements.contentTitle.textContent = node.title || 'Loading...'
|
|
3194
|
-
elements.contentPath.textContent = node.path || 'Loading...'
|
|
3195
|
-
elements.contentTags.innerHTML = Array.isArray(node.tags) && node.tags.length
|
|
3196
|
-
? node.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
3197
|
-
: '<span>No tags</span>'
|
|
3198
|
-
const initialLinks = linkedNodes(node)
|
|
3199
|
-
elements.contentOutgoing.innerHTML = list(initialLinks.outgoing)
|
|
3200
|
-
elements.contentIncoming.innerHTML = list(initialLinks.incoming)
|
|
3201
|
-
elements.contentBody.textContent = 'Loading note content...'
|
|
3202
|
-
if (!elements.contentDialog.open) {
|
|
3203
|
-
elements.contentDialog.showModal()
|
|
3204
|
-
}
|
|
3205
|
-
|
|
3206
|
-
const applyDetailToDialog = detail => {
|
|
3207
|
-
elements.contentTitle.textContent = detail.title
|
|
3208
|
-
elements.contentPath.textContent = detail.path
|
|
3209
|
-
elements.contentTags.innerHTML = detail.tags.length
|
|
3210
|
-
? detail.tags.map(tag => '<span>#' + escapeHtml(tag) + '</span>').join('')
|
|
3211
|
-
: '<span>No tags</span>'
|
|
3212
|
-
elements.contentBody.textContent = detail.content
|
|
3213
|
-
}
|
|
3214
|
-
|
|
3215
|
-
try {
|
|
3216
|
-
const detailedNode = await fetchNodeDetails(node)
|
|
3217
|
-
if (state.selected?.id !== node.id) {
|
|
3218
|
-
return
|
|
3219
|
-
}
|
|
3220
|
-
applyDetailToDialog(detailedNode)
|
|
3221
|
-
} catch {
|
|
3222
|
-
try {
|
|
3223
|
-
await wait(120)
|
|
3224
|
-
const retriedNode = await fetchNodeDetails(node)
|
|
3225
|
-
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
|
|
3226
1584
|
return
|
|
3227
1585
|
}
|
|
3228
|
-
applyDetailToDialog(retriedNode)
|
|
3229
|
-
} catch {
|
|
3230
|
-
elements.contentBody.textContent = 'Unable to load note content.'
|
|
3231
|
-
}
|
|
3232
|
-
}
|
|
3233
|
-
}
|
|
3234
|
-
|
|
3235
|
-
const selectNode = (node, options = { openContent: false }) => {
|
|
3236
|
-
state.selected = node
|
|
3237
|
-
if (node && options.openContent) {
|
|
3238
|
-
openContentDialog(node).catch(() => {
|
|
3239
|
-
elements.contentBody.textContent = 'Unable to load note content.'
|
|
3240
|
-
})
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3243
|
-
|
|
3244
|
-
const selectNodeById = id => {
|
|
3245
|
-
const node = state.nodes.find(item => item.id === id)
|
|
3246
|
-
if (node) selectNode(node, { openContent: true })
|
|
3247
|
-
}
|
|
3248
1586
|
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
const worldY = worldPointAtCursor.y
|
|
3259
|
-
state.lastZoomFocus = {
|
|
3260
|
-
x: worldX,
|
|
3261
|
-
y: worldY,
|
|
3262
|
-
at: performance.now()
|
|
3263
|
-
}
|
|
3264
|
-
state.transform.scale = clampScale(nextScale)
|
|
3265
|
-
state.transform.x = clampTransformCoordinate(screenX - worldX * nextScale)
|
|
3266
|
-
state.transform.y = clampTransformCoordinate(screenY - worldY * nextScale)
|
|
3267
|
-
state.offscreenFrameCount = 0
|
|
3268
|
-
markRenderDirty()
|
|
3269
|
-
}
|
|
3270
|
-
|
|
3271
|
-
const wheelZoomFactor = event => {
|
|
3272
|
-
const isModifierZoom = event.metaKey || event.ctrlKey
|
|
3273
|
-
const deltaModeFactor = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1
|
|
3274
|
-
const normalizedDelta = event.deltaY * deltaModeFactor
|
|
3275
|
-
|
|
3276
|
-
if (!Number.isFinite(normalizedDelta) || Math.abs(normalizedDelta) <= 0.0001) {
|
|
3277
|
-
return 1
|
|
3278
|
-
}
|
|
3279
|
-
|
|
3280
|
-
const isMassiveEcosystemZoom =
|
|
3281
|
-
state.visibleNodes.length > massiveGraphNodeThreshold &&
|
|
3282
|
-
state.transform.scale <= massiveEcosystemClusterScaleThreshold
|
|
3283
|
-
const sensitivityMultiplier = isMassiveEcosystemZoom ? 0.48 : 1
|
|
3284
|
-
const capMultiplier = isMassiveEcosystemZoom ? 0.34 : 1
|
|
3285
|
-
const sensitivity = wheelZoomExponent * (isModifierZoom ? wheelZoomModifierBoost : 1) * sensitivityMultiplier
|
|
3286
|
-
const exponentCap = wheelZoomExponentCap * capMultiplier
|
|
3287
|
-
const exponent = Math.max(
|
|
3288
|
-
-exponentCap,
|
|
3289
|
-
Math.min(exponentCap, -normalizedDelta * sensitivity)
|
|
3290
|
-
)
|
|
3291
|
-
return Math.exp(exponent)
|
|
3292
|
-
}
|
|
3293
|
-
|
|
3294
|
-
const handleWheelZoom = event => {
|
|
3295
|
-
if (elements.contentDialog?.open) {
|
|
3296
|
-
return
|
|
3297
|
-
}
|
|
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
|
+
}
|
|
3298
1596
|
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
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()
|
|
3310
1610
|
}
|
|
3311
|
-
|
|
3312
|
-
zoomAtPoint(cursorX, cursorY, factor, 'wheel')
|
|
3313
1611
|
}
|
|
3314
1612
|
|
|
3315
|
-
const
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
state.query = event.target.value
|
|
3319
|
-
recomputeVisibility()
|
|
3320
|
-
scheduleContentFilterSync()
|
|
3321
|
-
})
|
|
3322
|
-
elements.agent.addEventListener('change', event => {
|
|
3323
|
-
state.agentId = event.target.value
|
|
3324
|
-
writeStoredAgent(state.agentId)
|
|
3325
|
-
syncAgentInUrl(state.agentId)
|
|
3326
|
-
state.selected = null
|
|
3327
|
-
state.nodeDetails = new Map()
|
|
3328
|
-
resetContentFilter()
|
|
3329
|
-
recomputeVisibility()
|
|
3330
|
-
scheduleContentFilterSync()
|
|
3331
|
-
loadGraph({ reset: true }).catch(error => {
|
|
3332
|
-
console.error(error)
|
|
3333
|
-
})
|
|
3334
|
-
})
|
|
3335
|
-
elements.zoomIn.addEventListener('click', () => {
|
|
3336
|
-
const rect = canvas.getBoundingClientRect()
|
|
3337
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.055, 'button')
|
|
3338
|
-
})
|
|
3339
|
-
elements.zoomOut.addEventListener('click', () => {
|
|
3340
|
-
const rect = canvas.getBoundingClientRect()
|
|
3341
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.948, 'button')
|
|
3342
|
-
})
|
|
3343
|
-
if (elements.fit) {
|
|
3344
|
-
elements.fit.addEventListener('click', () => {
|
|
3345
|
-
focusPrimaryHub()
|
|
3346
|
-
})
|
|
3347
|
-
}
|
|
3348
|
-
elements.reset.addEventListener('click', () => {
|
|
3349
|
-
resetView()
|
|
3350
|
-
})
|
|
3351
|
-
elements.contentClose.addEventListener('click', () => elements.contentDialog.close())
|
|
3352
|
-
elements.contentDialog.addEventListener('click', event => {
|
|
1613
|
+
const wireNodeLinkClicks = () => {
|
|
1614
|
+
const dialog = elements.contentDialog
|
|
1615
|
+
dialog.addEventListener('click', (event) => {
|
|
3353
1616
|
const target = event.target
|
|
3354
|
-
if (target instanceof HTMLElement
|
|
3355
|
-
selectNodeById(target.dataset.nodeId)
|
|
3356
|
-
return
|
|
3357
|
-
}
|
|
3358
|
-
if (event.target === elements.contentDialog) elements.contentDialog.close()
|
|
3359
|
-
})
|
|
3360
|
-
canvas.addEventListener('wheel', handleWheelZoom, { passive: false })
|
|
3361
|
-
canvas.addEventListener('dblclick', event => {
|
|
3362
|
-
const point = worldPoint(event)
|
|
3363
|
-
const node = hitNode(point)
|
|
3364
|
-
if (node) {
|
|
3365
|
-
selectNode(node, { openContent: true })
|
|
1617
|
+
if (!(target instanceof HTMLElement)) {
|
|
3366
1618
|
return
|
|
3367
1619
|
}
|
|
3368
1620
|
|
|
3369
|
-
const
|
|
3370
|
-
|
|
3371
|
-
const cursorY = event.clientY - rect.top
|
|
3372
|
-
zoomAtPoint(cursorX, cursorY, 1.055)
|
|
3373
|
-
})
|
|
3374
|
-
canvas.addEventListener('pointerdown', event => {
|
|
3375
|
-
const point = worldPoint(event)
|
|
3376
|
-
const node = hitNode(point)
|
|
3377
|
-
state.pointer = { x: event.clientX, y: event.clientY, down: true, dragNode: node, moved: false }
|
|
3378
|
-
if (node) {
|
|
3379
|
-
node.x = point.x
|
|
3380
|
-
node.y = point.y
|
|
3381
|
-
markRenderDirty()
|
|
3382
|
-
}
|
|
3383
|
-
canvas.setPointerCapture(event.pointerId)
|
|
3384
|
-
})
|
|
3385
|
-
canvas.addEventListener('pointermove', event => {
|
|
3386
|
-
const point = worldPoint(event)
|
|
3387
|
-
const now = performance.now()
|
|
3388
|
-
const canHoverHitTest =
|
|
3389
|
-
!(state.nodes.length > massiveGraphNodeThreshold && state.transform.scale < 0.06)
|
|
3390
|
-
const shouldHitTest = canHoverHitTest &&
|
|
3391
|
-
(state.pointer.down || now - state.lastHoverHitAt >= hoverHitTestIntervalMs)
|
|
3392
|
-
if (shouldHitTest) {
|
|
3393
|
-
state.hovered = hitNode(point)
|
|
3394
|
-
state.lastHoverHitAt = now
|
|
3395
|
-
} else if (!canHoverHitTest) {
|
|
3396
|
-
state.hovered = null
|
|
3397
|
-
}
|
|
3398
|
-
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
3399
|
-
if (!state.pointer.down) return
|
|
3400
|
-
const dx = event.clientX - state.pointer.x
|
|
3401
|
-
const dy = event.clientY - state.pointer.y
|
|
3402
|
-
state.pointer.x = event.clientX
|
|
3403
|
-
state.pointer.y = event.clientY
|
|
3404
|
-
state.pointer.moved = state.pointer.moved || Math.abs(dx) + Math.abs(dy) > 3
|
|
3405
|
-
if (state.pointer.dragNode) {
|
|
3406
|
-
const dragNode = state.pointer.dragNode
|
|
3407
|
-
const previousX = dragNode.x
|
|
3408
|
-
const previousY = dragNode.y
|
|
3409
|
-
dragNode.x = point.x
|
|
3410
|
-
dragNode.y = point.y
|
|
3411
|
-
applyDragNeighborhoodAdjustment(dragNode, dragNode.x - previousX, dragNode.y - previousY)
|
|
3412
|
-
markRenderDirty()
|
|
3413
|
-
return
|
|
3414
|
-
}
|
|
3415
|
-
state.transform.x += dx
|
|
3416
|
-
state.transform.y += dy
|
|
3417
|
-
state.transform.x = clampTransformCoordinate(state.transform.x)
|
|
3418
|
-
state.transform.y = clampTransformCoordinate(state.transform.y)
|
|
3419
|
-
state.offscreenFrameCount = 0
|
|
3420
|
-
markRenderDirty()
|
|
3421
|
-
})
|
|
3422
|
-
canvas.addEventListener('pointerup', event => {
|
|
3423
|
-
const draggedNode = state.pointer.dragNode
|
|
3424
|
-
if (draggedNode && state.pointer.moved) {
|
|
3425
|
-
settleNeighborhoodAroundNode(draggedNode)
|
|
3426
|
-
markRenderDirty()
|
|
3427
|
-
}
|
|
3428
|
-
if (draggedNode && !state.pointer.moved) selectNode(draggedNode, { openContent: false })
|
|
3429
|
-
if (!draggedNode && !state.pointer.moved) selectNode(state.hovered, { openContent: false })
|
|
3430
|
-
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
3431
|
-
canvas.releasePointerCapture(event.pointerId)
|
|
3432
|
-
})
|
|
3433
|
-
canvas.addEventListener('pointercancel', () => {
|
|
3434
|
-
state.pointer = { x: 0, y: 0, down: false, dragNode: null, moved: false }
|
|
3435
|
-
})
|
|
3436
|
-
canvas.addEventListener('pointerenter', event => {
|
|
3437
|
-
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: true }
|
|
3438
|
-
})
|
|
3439
|
-
canvas.addEventListener('pointerleave', event => {
|
|
3440
|
-
state.cursor = { x: event.clientX, y: event.clientY, inCanvas: false }
|
|
3441
|
-
})
|
|
3442
|
-
window.addEventListener('keydown', event => {
|
|
3443
|
-
if (event.key === '+' || event.key === '=') {
|
|
3444
|
-
event.preventDefault()
|
|
3445
|
-
const rect = canvas.getBoundingClientRect()
|
|
3446
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 1.05)
|
|
3447
|
-
return
|
|
3448
|
-
}
|
|
3449
|
-
|
|
3450
|
-
if (event.key === '-' || event.key === '_') {
|
|
3451
|
-
event.preventDefault()
|
|
3452
|
-
const rect = canvas.getBoundingClientRect()
|
|
3453
|
-
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) {
|
|
3454
1623
|
return
|
|
3455
1624
|
}
|
|
3456
1625
|
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
1626
|
+
const id = button.getAttribute('data-node-id') || ''
|
|
1627
|
+
if (id) {
|
|
1628
|
+
loadNodeDetails(id).catch((error) => console.error(error))
|
|
3460
1629
|
}
|
|
3461
1630
|
})
|
|
3462
1631
|
}
|
|
3463
1632
|
|
|
3464
|
-
const
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
? preferredAgent
|
|
3472
|
-
: (agents.find(agent => agent.id === 'shared')?.id ?? agents[0]?.id ?? 'shared')
|
|
3473
|
-
const signature = JSON.stringify(agents.map(agent => [agent.id, agent.documentCount]))
|
|
3474
|
-
|
|
3475
|
-
state.agentId = selected
|
|
3476
|
-
writeStoredAgent(selected)
|
|
3477
|
-
syncAgentInUrl(selected)
|
|
3478
|
-
if (signature !== state.agentsSignature) {
|
|
3479
|
-
const formatAgentLabel = (agent) => agent.id
|
|
3480
|
-
elements.agent.innerHTML = agents.length
|
|
3481
|
-
? agents.map(agent => '<option value="' + escapeHtml(agent.id) + '">' + escapeHtml(formatAgentLabel(agent)) + '</option>').join('')
|
|
3482
|
-
: '<option value="shared">shared</option>'
|
|
3483
|
-
state.agentsSignature = signature
|
|
3484
|
-
}
|
|
3485
|
-
elements.agent.value = selected
|
|
3486
|
-
}
|
|
3487
|
-
|
|
3488
|
-
const loadGraph = async (options = { reset: false }) => {
|
|
3489
|
-
const response = await fetch('/api/graph-layout' + agentQuery(), {
|
|
3490
|
-
headers: state.graphSignature
|
|
3491
|
-
? {
|
|
3492
|
-
'if-none-match': encodeEntityTag(state.graphSignature)
|
|
3493
|
-
}
|
|
3494
|
-
: undefined
|
|
3495
|
-
})
|
|
3496
|
-
|
|
3497
|
-
if (response.status === 304) {
|
|
3498
|
-
return
|
|
3499
|
-
}
|
|
1633
|
+
const bootstrap = async () => {
|
|
1634
|
+
setViewportFromCanvas()
|
|
1635
|
+
setupRenderWorker()
|
|
1636
|
+
setupInput()
|
|
1637
|
+
setupControls()
|
|
1638
|
+
setupContextControl()
|
|
1639
|
+
wireNodeLinkClicks()
|
|
3500
1640
|
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
edges: Number.isFinite(payload?.totals?.edges) ? payload.totals.edges : (Array.isArray(graph.edges) ? graph.edges.length : 0)
|
|
3506
|
-
}
|
|
3507
|
-
const signature = payload?.signature ?? graphSignature(graph)
|
|
3508
|
-
if (!options.reset && signature === state.graphSignature) return
|
|
3509
|
-
const selectedId = state.selected?.id
|
|
3510
|
-
const layout = createLayout(graph)
|
|
3511
|
-
state.graphSignature = signature
|
|
3512
|
-
state.graph = graph
|
|
3513
|
-
state.nodes = layout.nodes
|
|
3514
|
-
state.nodeById = new Map(state.nodes.map((node) => [node.id, node]))
|
|
3515
|
-
state.edges = layout.edges
|
|
3516
|
-
state.nodeDegrees = state.edges.reduce((degrees, edge) => {
|
|
3517
|
-
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + edgeWeight(edge))
|
|
3518
|
-
if (edge.target) {
|
|
3519
|
-
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + edgeWeight(edge))
|
|
3520
|
-
}
|
|
3521
|
-
return degrees
|
|
3522
|
-
}, new Map())
|
|
3523
|
-
state.nodeDetails = new Map()
|
|
3524
|
-
pushNodesToFilterWorker()
|
|
3525
|
-
resetContentFilter()
|
|
3526
|
-
sanitizeAllNodePositions()
|
|
3527
|
-
recomputeVisibility()
|
|
3528
|
-
scheduleContentFilterSync()
|
|
3529
|
-
const tags = new Set(state.nodes.flatMap(node => node.tags))
|
|
3530
|
-
setGraphStatus(state.agentId + ' · ' + state.graphTotals.nodes + ' notes · ' + state.graphTotals.edges + ' links · live')
|
|
3531
|
-
elements.nodeCount.textContent = state.graphTotals.nodes
|
|
3532
|
-
elements.edgeCount.textContent = state.graphTotals.edges
|
|
3533
|
-
elements.tagCount.textContent = tags.size
|
|
3534
|
-
resize()
|
|
3535
|
-
if (options.reset) resetView()
|
|
3536
|
-
const selectedNode = state.nodes.find(node => node.id === selectedId) ?? null
|
|
3537
|
-
selectNode(selectedNode, { openContent: Boolean(selectedNode && elements.contentDialog.open) })
|
|
3538
|
-
if (!selectedNode && elements.contentDialog.open) {
|
|
3539
|
-
elements.contentDialog.close()
|
|
3540
|
-
}
|
|
3541
|
-
}
|
|
3542
|
-
|
|
3543
|
-
bindEvents()
|
|
3544
|
-
initFilterWorker()
|
|
3545
|
-
requestAnimationFrame(() => {
|
|
3546
|
-
resize()
|
|
3547
|
-
resetView()
|
|
3548
|
-
})
|
|
3549
|
-
|
|
3550
|
-
const pollIntervalMs = 5000
|
|
3551
|
-
let tickCounter = 0
|
|
3552
|
-
|
|
3553
|
-
const refreshGraphLoop = () => {
|
|
3554
|
-
if (document.hidden) {
|
|
3555
|
-
return
|
|
3556
|
-
}
|
|
1641
|
+
window.addEventListener('resize', () => {
|
|
1642
|
+
setViewportFromCanvas()
|
|
1643
|
+
scheduleChunkFetch()
|
|
1644
|
+
})
|
|
3557
1645
|
|
|
3558
|
-
|
|
1646
|
+
await loadAgents()
|
|
1647
|
+
await loadContexts()
|
|
1648
|
+
updateTotals()
|
|
1649
|
+
updateTagCount()
|
|
3559
1650
|
|
|
3560
|
-
|
|
3561
|
-
if (tickCounter % 3 === 0) {
|
|
3562
|
-
loadAgents().catch((error) => {
|
|
3563
|
-
console.error(error)
|
|
3564
|
-
})
|
|
3565
|
-
}
|
|
1651
|
+
scheduleChunkFetch({ fit: true })
|
|
3566
1652
|
}
|
|
3567
1653
|
|
|
3568
|
-
|
|
3569
|
-
.
|
|
3570
|
-
.then(() => {
|
|
3571
|
-
requestAnimationFrame(render)
|
|
3572
|
-
setInterval(refreshGraphLoop, pollIntervalMs)
|
|
3573
|
-
})
|
|
3574
|
-
.catch(error => {
|
|
3575
|
-
console.error(error)
|
|
3576
|
-
})
|
|
3577
|
-
|
|
3578
|
-
document.addEventListener('visibilitychange', () => {
|
|
3579
|
-
if (document.hidden) {
|
|
3580
|
-
return
|
|
3581
|
-
}
|
|
3582
|
-
|
|
3583
|
-
loadGraph({ reset: true }).catch(handleGraphRefreshError)
|
|
1654
|
+
bootstrap().catch((error) => {
|
|
1655
|
+
console.error(error)
|
|
3584
1656
|
})
|
|
3585
1657
|
`;
|