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