@andespindola/brainlink 0.1.0-beta.142 → 0.1.0-beta.144
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 +6 -5
- package/dist/application/frontend/client-css.js +1 -6
- package/dist/application/frontend/client-html.js +8 -1
- package/dist/application/frontend/client-js.js +640 -3323
- package/dist/application/frontend/client-render-worker-js.js +569 -0
- package/dist/application/get-graph-stream-chunk.js +285 -0
- package/dist/application/server/routes.js +31 -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'),
|
|
@@ -124,6 +14,8 @@ const elements = {
|
|
|
124
14
|
contentDialog: byId('contentDialog'),
|
|
125
15
|
contentTitle: byId('contentTitle'),
|
|
126
16
|
contentPath: byId('contentPath'),
|
|
17
|
+
contentFacts: byId('contentFacts'),
|
|
18
|
+
contentContextLinks: byId('contentContextLinks'),
|
|
127
19
|
contentTags: byId('contentTags'),
|
|
128
20
|
contentOutgoing: byId('contentOutgoing'),
|
|
129
21
|
contentIncoming: byId('contentIncoming'),
|
|
@@ -131,23 +23,61 @@ const elements = {
|
|
|
131
23
|
contentClose: byId('contentClose')
|
|
132
24
|
}
|
|
133
25
|
|
|
26
|
+
const state = {
|
|
27
|
+
camera: {
|
|
28
|
+
x: 0,
|
|
29
|
+
y: 0,
|
|
30
|
+
scale: 0.22
|
|
31
|
+
},
|
|
32
|
+
pointer: {
|
|
33
|
+
down: false,
|
|
34
|
+
moved: false,
|
|
35
|
+
x: 0,
|
|
36
|
+
y: 0,
|
|
37
|
+
worldAnchorX: 0,
|
|
38
|
+
worldAnchorY: 0
|
|
39
|
+
},
|
|
40
|
+
viewport: {
|
|
41
|
+
width: 320,
|
|
42
|
+
height: 320,
|
|
43
|
+
ratio: window.devicePixelRatio || 1
|
|
44
|
+
},
|
|
45
|
+
workerReady: false,
|
|
46
|
+
rendererMode: 'worker',
|
|
47
|
+
renderWorker: null,
|
|
48
|
+
agentId: '',
|
|
49
|
+
graphSignature: '',
|
|
50
|
+
graphMode: 'near',
|
|
51
|
+
chunk: {
|
|
52
|
+
nodes: [],
|
|
53
|
+
edges: []
|
|
54
|
+
},
|
|
55
|
+
selectedNodeId: null,
|
|
56
|
+
searchToken: 0,
|
|
57
|
+
fetchToken: 0,
|
|
58
|
+
fetchTimer: null,
|
|
59
|
+
lastVisibleNodes: 0,
|
|
60
|
+
lastVisibleEdges: 0,
|
|
61
|
+
totals: {
|
|
62
|
+
nodes: 0,
|
|
63
|
+
edges: 0
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
134
67
|
const zoomRange = {
|
|
135
68
|
min: 0.0002,
|
|
136
69
|
max: 4.5
|
|
137
70
|
}
|
|
138
71
|
|
|
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
72
|
const selectedAgentStorageKey = 'brainlink:selected-agent'
|
|
150
73
|
|
|
74
|
+
const escapeHtml = (value) => String(value)
|
|
75
|
+
.replaceAll('&', '&')
|
|
76
|
+
.replaceAll('<', '<')
|
|
77
|
+
.replaceAll('>', '>')
|
|
78
|
+
.replaceAll('"', '"')
|
|
79
|
+
.replaceAll("'", ''')
|
|
80
|
+
|
|
151
81
|
const readStoredAgent = () => {
|
|
152
82
|
try {
|
|
153
83
|
const value = window.localStorage.getItem(selectedAgentStorageKey)?.trim() ?? ''
|
|
@@ -179,3361 +109,748 @@ const syncAgentInUrl = (agentId) => {
|
|
|
179
109
|
} catch {}
|
|
180
110
|
}
|
|
181
111
|
|
|
182
|
-
const
|
|
112
|
+
const initialAgentFromUrl = (() => {
|
|
113
|
+
try {
|
|
114
|
+
const raw = new URL(window.location.href).searchParams.get('agent')
|
|
115
|
+
const value = raw?.trim() ?? ''
|
|
116
|
+
return value.length > 0 ? value : ''
|
|
117
|
+
} catch {
|
|
118
|
+
return ''
|
|
119
|
+
}
|
|
120
|
+
})()
|
|
183
121
|
|
|
184
|
-
const
|
|
185
|
-
state.graphStatus = text
|
|
186
|
-
}
|
|
122
|
+
const agentQuery = (separator = '?') => state.agentId ? separator + 'agent=' + encodeURIComponent(state.agentId) : ''
|
|
187
123
|
|
|
188
|
-
const
|
|
189
|
-
|
|
124
|
+
const parseColor = (hex) => {
|
|
125
|
+
const normalized = String(hex || '#ffffff').replace('#', '')
|
|
126
|
+
const expanded = normalized.length === 3
|
|
127
|
+
? normalized.split('').map((char) => char + char).join('')
|
|
128
|
+
: normalized.padEnd(6, 'f')
|
|
129
|
+
const value = Number.parseInt(expanded, 16)
|
|
130
|
+
return [
|
|
131
|
+
((value >> 16) & 255) / 255,
|
|
132
|
+
((value >> 8) & 255) / 255,
|
|
133
|
+
(value & 255) / 255,
|
|
134
|
+
1
|
|
135
|
+
]
|
|
190
136
|
}
|
|
191
137
|
|
|
192
138
|
const graphTheme = {
|
|
193
|
-
node: '#aeb8c5',
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
139
|
+
node: parseColor('#aeb8c5'),
|
|
140
|
+
nodeCluster: parseColor('#6bb7e8'),
|
|
141
|
+
nodeHighlight: parseColor('#f5c24a'),
|
|
142
|
+
nodeSelected: parseColor('#ffffff'),
|
|
143
|
+
edge: [0.58, 0.64, 0.74, 0.24],
|
|
144
|
+
edgeHeavy: [0.78, 0.84, 0.92, 0.44],
|
|
145
|
+
clear: parseColor('#0d0f12')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const clampScale = (scale) => Math.max(zoomRange.min, Math.min(zoomRange.max, scale))
|
|
149
|
+
|
|
150
|
+
const getZoomNodeBudget = () => {
|
|
151
|
+
const scale = state.camera.scale
|
|
152
|
+
if (scale < 0.06) return 900
|
|
153
|
+
if (scale < 0.12) return 1600
|
|
154
|
+
if (scale < 0.24) return 2600
|
|
155
|
+
if (scale < 0.7) return 4000
|
|
156
|
+
return 6000
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const getZoomEdgeBudget = () => {
|
|
160
|
+
const scale = state.camera.scale
|
|
161
|
+
if (scale < 0.06) return 2000
|
|
162
|
+
if (scale < 0.12) return 4800
|
|
163
|
+
if (scale < 0.24) return 9000
|
|
164
|
+
if (scale < 0.7) return 15000
|
|
165
|
+
return 26000
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const screenToWorld = (screenX, screenY) => ({
|
|
169
|
+
x: (screenX - state.camera.x) / state.camera.scale,
|
|
170
|
+
y: (screenY - state.camera.y) / state.camera.scale
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const worldToScreen = (x, y) => ({
|
|
174
|
+
x: x * state.camera.scale + state.camera.x,
|
|
175
|
+
y: y * state.camera.scale + state.camera.y
|
|
176
|
+
})
|
|
204
177
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
]
|
|
178
|
+
const drawFallback = () => {
|
|
179
|
+
if (state.rendererMode !== 'fallback' || !ctx2dFallback) {
|
|
180
|
+
return
|
|
218
181
|
}
|
|
182
|
+
const width = state.viewport.width
|
|
183
|
+
const height = state.viewport.height
|
|
184
|
+
const ratio = state.viewport.ratio
|
|
185
|
+
canvas.width = Math.floor(width * ratio)
|
|
186
|
+
canvas.height = Math.floor(height * ratio)
|
|
187
|
+
ctx2dFallback.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
188
|
+
ctx2dFallback.fillStyle = '#0d0f12'
|
|
189
|
+
ctx2dFallback.fillRect(0, 0, width, height)
|
|
219
190
|
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
191
|
+
const nodes = Array.isArray(state.chunk.nodes) ? state.chunk.nodes : []
|
|
192
|
+
const edges = Array.isArray(state.chunk.edges) ? state.chunk.edges : []
|
|
193
|
+
const nodeById = new Map()
|
|
194
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
195
|
+
nodeById.set(nodes[i][0], nodes[i])
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
ctx2dFallback.strokeStyle = 'rgba(150,165,190,0.2)'
|
|
199
|
+
ctx2dFallback.lineWidth = 1
|
|
200
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
201
|
+
const edge = edges[i]
|
|
202
|
+
const source = nodeById.get(edge[0])
|
|
203
|
+
const target = nodeById.get(edge[1])
|
|
204
|
+
if (!source || !target) continue
|
|
205
|
+
const from = worldToScreen(source[2], source[3])
|
|
206
|
+
const to = worldToScreen(target[2], target[3])
|
|
207
|
+
ctx2dFallback.beginPath()
|
|
208
|
+
ctx2dFallback.moveTo(from.x, from.y)
|
|
209
|
+
ctx2dFallback.lineTo(to.x, to.y)
|
|
210
|
+
ctx2dFallback.stroke()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
214
|
+
const node = nodes[i]
|
|
215
|
+
const p = worldToScreen(node[2], node[3])
|
|
216
|
+
const selected = state.selectedNodeId === node[0]
|
|
217
|
+
const color = node[6] === 'cluster' ? '#6bb7e8' : '#aeb8c5'
|
|
218
|
+
const radius = Math.max(2.4, Math.min(14, 4 + node[7] * 0.55))
|
|
219
|
+
|
|
220
|
+
ctx2dFallback.beginPath()
|
|
221
|
+
ctx2dFallback.fillStyle = selected ? '#ffffff' : color
|
|
222
|
+
ctx2dFallback.arc(p.x, p.y, radius, 0, Math.PI * 2)
|
|
223
|
+
ctx2dFallback.fill()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ctx2dFallback.fillStyle = '#edf2f7'
|
|
227
|
+
ctx2dFallback.font = '12px Inter, system-ui, sans-serif'
|
|
228
|
+
ctx2dFallback.textAlign = 'center'
|
|
229
|
+
ctx2dFallback.fillText('Fallback canvas mode', Math.max(width, 320) / 2, 24)
|
|
228
230
|
}
|
|
229
231
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
232
|
+
const updateTotals = () => {
|
|
233
|
+
elements.nodeCount.textContent = String(state.totals.nodes)
|
|
234
|
+
elements.edgeCount.textContent = String(state.totals.edges)
|
|
233
235
|
}
|
|
234
236
|
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
if (!shader) return null
|
|
238
|
-
gl.shaderSource(shader, source)
|
|
239
|
-
gl.compileShader(shader)
|
|
240
|
-
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
241
|
-
gl.deleteShader(shader)
|
|
242
|
-
return null
|
|
243
|
-
}
|
|
244
|
-
return shader
|
|
237
|
+
const updateTagCount = () => {
|
|
238
|
+
elements.tagCount.textContent = state.graphMode === 'far' ? 'clusters' : state.graphMode
|
|
245
239
|
}
|
|
246
240
|
|
|
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
|
|
241
|
+
const updateWorkerCamera = () => {
|
|
242
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
243
|
+
return
|
|
261
244
|
}
|
|
262
|
-
|
|
245
|
+
state.renderWorker.postMessage({
|
|
246
|
+
type: 'camera',
|
|
247
|
+
camera: state.camera
|
|
248
|
+
})
|
|
263
249
|
}
|
|
264
250
|
|
|
265
|
-
const
|
|
266
|
-
if (!
|
|
267
|
-
|
|
268
|
-
const gl = targetCanvas.getContext('webgl2', { alpha: true, antialias: true }) ||
|
|
269
|
-
targetCanvas.getContext('webgl', { alpha: true, antialias: true })
|
|
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)]
|
|
251
|
+
const updateWorkerSize = () => {
|
|
252
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
253
|
+
return
|
|
304
254
|
}
|
|
255
|
+
state.renderWorker.postMessage({
|
|
256
|
+
type: 'resize',
|
|
257
|
+
width: state.viewport.width,
|
|
258
|
+
height: state.viewport.height,
|
|
259
|
+
devicePixelRatio: state.viewport.ratio
|
|
260
|
+
})
|
|
261
|
+
}
|
|
305
262
|
|
|
306
|
-
|
|
307
|
-
(node.x * state.transform.scale + state.transform.x) * ratioX,
|
|
308
|
-
(node.y * state.transform.scale + state.transform.y) * ratioY
|
|
309
|
-
]
|
|
263
|
+
const normalizeList = (items) => Array.isArray(items) ? items : []
|
|
310
264
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
265
|
+
const list = (items) => {
|
|
266
|
+
const rows = normalizeList(items)
|
|
267
|
+
if (rows.length === 0) {
|
|
268
|
+
return '<li><small>No links found.</small></li>'
|
|
315
269
|
}
|
|
270
|
+
return rows
|
|
271
|
+
.map((item) => {
|
|
272
|
+
const title = typeof item?.title === 'string' ? item.title : 'Untitled'
|
|
273
|
+
const id = typeof item?.id === 'string' ? item.id : ''
|
|
274
|
+
const path = typeof item?.path === 'string' ? item.path : ''
|
|
275
|
+
const meta = item?.weight ? ' · weight ' + escapeHtml(item.weight) + ' · ' + escapeHtml(item.priority || 'normal') : ''
|
|
276
|
+
return '<li>' +
|
|
277
|
+
(id ? '<button type="button" data-node-id="' + escapeHtml(id) + '">' + escapeHtml(title) + '</button>' : escapeHtml(title)) +
|
|
278
|
+
'<small>' + escapeHtml(path) + meta + '</small>' +
|
|
279
|
+
'</li>'
|
|
280
|
+
})
|
|
281
|
+
.join('')
|
|
282
|
+
}
|
|
316
283
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
positions[offset + 1] = source[1]
|
|
328
|
-
positions[offset + 2] = target[0]
|
|
329
|
-
positions[offset + 3] = target[1]
|
|
284
|
+
const extractContextLinks = (content) => {
|
|
285
|
+
if (typeof content !== 'string' || content.length === 0) {
|
|
286
|
+
return []
|
|
287
|
+
}
|
|
288
|
+
const lines = content.split(/\\r?\\n/)
|
|
289
|
+
let start = -1
|
|
290
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
291
|
+
if (/^##\\s+context\\s+links\\b/i.test(lines[index].trim())) {
|
|
292
|
+
start = index + 1
|
|
293
|
+
break
|
|
330
294
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
|
|
335
|
-
gl.enableVertexAttribArray(linePositionLocation)
|
|
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)
|
|
295
|
+
}
|
|
296
|
+
if (start < 0) {
|
|
297
|
+
return []
|
|
341
298
|
}
|
|
342
299
|
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
const
|
|
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)
|
|
300
|
+
const links = []
|
|
301
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
302
|
+
const line = lines[index].trim()
|
|
303
|
+
if (!line) {
|
|
304
|
+
continue
|
|
355
305
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
306
|
+
if (/^#{1,6}\\s+/.test(line)) {
|
|
307
|
+
break
|
|
308
|
+
}
|
|
309
|
+
const match = line.match(/\\[\\[([^\\]]+)\\]\\]/)
|
|
310
|
+
if (!match) {
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
const title = match[1].trim()
|
|
314
|
+
if (!title) {
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
const priorityMatch = line.match(/#(critical|important)\\b|priority:\\s*(high|critical)/i)
|
|
318
|
+
const priority = priorityMatch ? String(priorityMatch[1] || priorityMatch[2] || 'normal').toLowerCase() : 'normal'
|
|
319
|
+
links.push({ title, priority })
|
|
369
320
|
}
|
|
321
|
+
return links
|
|
322
|
+
}
|
|
370
323
|
|
|
371
|
-
|
|
324
|
+
const buildFacts = (node, outgoingCount, incomingCount) => {
|
|
325
|
+
const content = typeof node?.content === 'string' ? node.content : ''
|
|
326
|
+
const words = content.trim().length > 0 ? content.trim().split(/\\s+/).length : 0
|
|
327
|
+
return [
|
|
328
|
+
{ label: 'Agent', value: typeof node?.agentId === 'string' && node.agentId ? node.agentId : 'shared' },
|
|
329
|
+
{ label: 'Words', value: String(words) },
|
|
330
|
+
{ label: 'Chars', value: String(content.length) },
|
|
331
|
+
{ label: 'Outgoing', value: String(outgoingCount) },
|
|
332
|
+
{ label: 'Backlinks', value: String(incomingCount) }
|
|
333
|
+
]
|
|
372
334
|
}
|
|
373
335
|
|
|
374
|
-
const
|
|
336
|
+
const listFacts = (facts) => facts
|
|
337
|
+
.map((fact) => '<li><strong>' + escapeHtml(fact.label) + ':</strong> <small>' + escapeHtml(fact.value) + '</small></li>')
|
|
338
|
+
.join('')
|
|
375
339
|
|
|
376
|
-
const
|
|
377
|
-
if (
|
|
378
|
-
return
|
|
340
|
+
const listContextLinks = (links) => {
|
|
341
|
+
if (!Array.isArray(links) || links.length === 0) {
|
|
342
|
+
return '<li><small>No context links found.</small></li>'
|
|
379
343
|
}
|
|
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
|
-
}
|
|
344
|
+
return links
|
|
345
|
+
.map((link) => '<li><span>' + escapeHtml(link.title) + '</span><small>' + escapeHtml(link.priority || 'normal') + '</small></li>')
|
|
346
|
+
.join('')
|
|
347
|
+
}
|
|
401
348
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return
|
|
406
|
-
}
|
|
349
|
+
const linkedNodes = (node) => {
|
|
350
|
+
const nodeById = new Map((state.chunk.nodes || []).map((item) => [item[0], item]))
|
|
351
|
+
const edges = normalizeList(state.chunk.edges)
|
|
407
352
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
353
|
+
const outgoing = []
|
|
354
|
+
const incoming = []
|
|
355
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
356
|
+
const edge = edges[index]
|
|
357
|
+
if (edge[0] === node.id) {
|
|
358
|
+
const target = nodeById.get(edge[1])
|
|
359
|
+
if (target) {
|
|
360
|
+
outgoing.push({ id: target[0], title: target[1], path: target[4] || '', weight: edge[2], priority: edge[3] })
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (edge[1] === node.id) {
|
|
364
|
+
const source = nodeById.get(edge[0])
|
|
365
|
+
if (source) {
|
|
366
|
+
incoming.push({ id: source[0], title: source[1], path: source[4] || '', weight: edge[2], priority: edge[3] })
|
|
412
367
|
}
|
|
413
368
|
}
|
|
414
|
-
state.filterWorker = worker
|
|
415
|
-
} catch {
|
|
416
|
-
state.filterWorker = null
|
|
417
|
-
state.filterReady = false
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const pushNodesToFilterWorker = () => {
|
|
422
|
-
if (!state.filterWorker || !state.filterReady) {
|
|
423
|
-
return
|
|
424
369
|
}
|
|
425
370
|
|
|
426
|
-
|
|
427
|
-
type: 'load-nodes',
|
|
428
|
-
nodes: state.nodes.map(node => ({
|
|
429
|
-
id: node.id,
|
|
430
|
-
title: node.title,
|
|
431
|
-
path: node.path || '',
|
|
432
|
-
tags: Array.isArray(node.tags) ? node.tags : []
|
|
433
|
-
}))
|
|
434
|
-
})
|
|
371
|
+
return { outgoing, incoming }
|
|
435
372
|
}
|
|
436
373
|
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
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)
|
|
374
|
+
const openContentDialog = () => {
|
|
375
|
+
const dialog = elements.contentDialog
|
|
376
|
+
if (!dialog.open) {
|
|
377
|
+
dialog.show()
|
|
447
378
|
}
|
|
448
|
-
state.viewport = { width, height }
|
|
449
|
-
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
450
|
-
markRenderDirty()
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const normalizeQuery = value => value.trim().toLowerCase()
|
|
454
|
-
const hubNodeRetentionLimit = 2
|
|
455
|
-
const hubNodePattern = /\b(memory\s*hub|knowledge\s*hub|hub|moc|map|memory\s*map|mapa)\b/i
|
|
456
|
-
const memoryHubPathPattern = /\bmemory[-_\s]*hub\b/i
|
|
457
|
-
|
|
458
|
-
const hubNodeScore = node => {
|
|
459
|
-
const title = node.title.trim().toLowerCase()
|
|
460
|
-
if (title === 'memory hub') return 6
|
|
461
|
-
if (title === 'knowledge hub') return 5
|
|
462
|
-
if (memoryHubPathPattern.test(node.path || '')) return 4
|
|
463
|
-
if (node.tags.some(tag => tag.trim().toLowerCase() === 'memory-hub')) return 3
|
|
464
|
-
if (/\bmoc\b/i.test(node.title)) return 2
|
|
465
|
-
return hubNodePattern.test(node.title) || hubNodePattern.test(node.path || '') || node.tags.some(tag => hubNodePattern.test(tag))
|
|
466
|
-
? 1
|
|
467
|
-
: 0
|
|
468
379
|
}
|
|
469
380
|
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
(node.path || '').toLowerCase().includes(query) ||
|
|
474
|
-
node.tags.some(tag => tag.toLowerCase().includes(query))
|
|
475
|
-
)
|
|
476
|
-
|
|
477
|
-
const rankedHubNodes = () => {
|
|
478
|
-
if (state.nodes.length === 0) {
|
|
479
|
-
return []
|
|
381
|
+
const loadNodeDetails = async (nodeId) => {
|
|
382
|
+
if (!nodeId) {
|
|
383
|
+
return
|
|
480
384
|
}
|
|
481
385
|
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
})
|
|
491
|
-
|
|
492
|
-
if (byTitleAndDegree.length > 0) {
|
|
493
|
-
return byTitleAndDegree.slice(0, hubNodeRetentionLimit)
|
|
386
|
+
const response = await fetch('/api/graph-node?id=' + encodeURIComponent(nodeId) + agentQuery('&'))
|
|
387
|
+
if (!response.ok) {
|
|
388
|
+
throw new Error('Failed to load graph node details')
|
|
494
389
|
}
|
|
495
390
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (byDegree !== 0) return byDegree
|
|
500
|
-
return left.title.localeCompare(right.title)
|
|
501
|
-
})
|
|
502
|
-
.slice(0, 1)
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const withPersistentHubNodes = nodes => {
|
|
506
|
-
if (nodes.length === 0) {
|
|
507
|
-
return rankedHubNodes()
|
|
391
|
+
const payload = await response.json()
|
|
392
|
+
if (!payload || typeof payload !== 'object' || !payload.node) {
|
|
393
|
+
throw new Error('Invalid graph node payload')
|
|
508
394
|
}
|
|
509
395
|
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
return nodes.concat(hubsToKeep)
|
|
513
|
-
}
|
|
396
|
+
const node = payload.node
|
|
397
|
+
state.selectedNodeId = node.id
|
|
514
398
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (!query) return state.nodes
|
|
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)
|
|
399
|
+
if (state.renderWorker && state.workerReady) {
|
|
400
|
+
state.renderWorker.postMessage({ type: 'select', id: node.id })
|
|
521
401
|
}
|
|
522
402
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const isDominantHub = (hub, nodeCount = state.visibleNodes.length) => {
|
|
527
|
-
if (!hub || nodeCount <= 0) {
|
|
528
|
-
return false
|
|
529
|
-
}
|
|
403
|
+
elements.contentTitle.textContent = node.title || 'Untitled'
|
|
404
|
+
elements.contentPath.textContent = node.path || ''
|
|
530
405
|
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}
|
|
406
|
+
const tags = Array.isArray(node.tags) ? node.tags : []
|
|
407
|
+
elements.contentTags.innerHTML = tags.length > 0
|
|
408
|
+
? tags.map((tag) => '<span>' + escapeHtml(tag) + '</span>').join('')
|
|
409
|
+
: '<span>No tags</span>'
|
|
536
410
|
|
|
537
|
-
const
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
state.visibleEdgeByNode = createVisibleEdgeLookup(edges)
|
|
546
|
-
const primaryHub = rankedHubNodes()[0] ?? null
|
|
547
|
-
state.primaryHub = primaryHub
|
|
548
|
-
markRenderDirty()
|
|
549
|
-
}
|
|
411
|
+
const related = linkedNodes(node)
|
|
412
|
+
const contextLinks = extractContextLinks(node.content)
|
|
413
|
+
const facts = buildFacts(node, related.outgoing.length, related.incoming.length)
|
|
414
|
+
elements.contentFacts.innerHTML = listFacts(facts)
|
|
415
|
+
elements.contentContextLinks.innerHTML = listContextLinks(contextLinks)
|
|
416
|
+
elements.contentOutgoing.innerHTML = list(related.outgoing)
|
|
417
|
+
elements.contentIncoming.innerHTML = list(related.incoming)
|
|
418
|
+
elements.contentBody.textContent = typeof node.content === 'string' ? node.content : ''
|
|
550
419
|
|
|
551
|
-
|
|
552
|
-
const markRenderDirty = () => {
|
|
553
|
-
state.renderVisibilityDirty = true
|
|
420
|
+
openContentDialog()
|
|
554
421
|
}
|
|
555
422
|
|
|
556
|
-
const
|
|
423
|
+
const fitFromChunk = () => {
|
|
424
|
+
const nodes = normalizeList(state.chunk.nodes)
|
|
557
425
|
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() }
|
|
426
|
+
return
|
|
564
427
|
}
|
|
565
428
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
429
|
+
let minX = Infinity
|
|
430
|
+
let minY = Infinity
|
|
431
|
+
let maxX = -Infinity
|
|
432
|
+
let maxY = -Infinity
|
|
570
433
|
|
|
571
434
|
for (let index = 0; index < nodes.length; index += 1) {
|
|
572
435
|
const node = nodes[index]
|
|
573
|
-
const
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
const bucket = buckets.get(key)
|
|
577
|
-
if (bucket) {
|
|
578
|
-
bucket.push(node)
|
|
436
|
+
const x = Number(node[2])
|
|
437
|
+
const y = Number(node[3])
|
|
438
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
579
439
|
continue
|
|
580
440
|
}
|
|
581
|
-
|
|
441
|
+
if (x < minX) minX = x
|
|
442
|
+
if (y < minY) minY = y
|
|
443
|
+
if (x > maxX) maxX = x
|
|
444
|
+
if (y > maxY) maxY = y
|
|
582
445
|
}
|
|
583
446
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
minX: bounds.minX,
|
|
587
|
-
minY: bounds.minY,
|
|
588
|
-
maxX: bounds.maxX,
|
|
589
|
-
maxY: bounds.maxY,
|
|
590
|
-
buckets
|
|
447
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
|
448
|
+
return
|
|
591
449
|
}
|
|
592
|
-
}
|
|
593
450
|
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
451
|
+
const width = Math.max(1, maxX - minX)
|
|
452
|
+
const height = Math.max(1, maxY - minY)
|
|
453
|
+
const scaleX = state.viewport.width / width
|
|
454
|
+
const scaleY = state.viewport.height / height
|
|
455
|
+
const scale = clampScale(Math.min(scaleX, scaleY) * 0.72)
|
|
456
|
+
|
|
457
|
+
state.camera.scale = scale
|
|
458
|
+
state.camera.x = state.viewport.width / 2 - (minX + width / 2) * scale
|
|
459
|
+
state.camera.y = state.viewport.height / 2 - (minY + height / 2) * scale
|
|
460
|
+
updateWorkerCamera()
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const fetchChunk = async ({ fit } = { fit: false }) => {
|
|
464
|
+
const token = ++state.fetchToken
|
|
465
|
+
const worldTopLeft = screenToWorld(0, 0)
|
|
466
|
+
const worldBottomRight = screenToWorld(state.viewport.width, state.viewport.height)
|
|
467
|
+
const x = Math.min(worldTopLeft.x, worldBottomRight.x)
|
|
468
|
+
const y = Math.min(worldTopLeft.y, worldBottomRight.y)
|
|
469
|
+
const w = Math.abs(worldBottomRight.x - worldTopLeft.x)
|
|
470
|
+
const h = Math.abs(worldBottomRight.y - worldTopLeft.y)
|
|
471
|
+
|
|
472
|
+
const params = new URLSearchParams({
|
|
473
|
+
x: String(x),
|
|
474
|
+
y: String(y),
|
|
475
|
+
w: String(Math.max(1, w)),
|
|
476
|
+
h: String(Math.max(1, h)),
|
|
477
|
+
scale: String(state.camera.scale),
|
|
478
|
+
nodeBudget: String(getZoomNodeBudget()),
|
|
479
|
+
edgeBudget: String(getZoomEdgeBudget())
|
|
480
|
+
})
|
|
598
481
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return state.visibleNodes.filter(node => isNodeInViewport(node, viewport))
|
|
482
|
+
if (state.agentId) {
|
|
483
|
+
params.set('agent', state.agentId)
|
|
602
484
|
}
|
|
603
485
|
|
|
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
|
-
}
|
|
486
|
+
const response = await fetch('/api/graph-stream?' + params.toString())
|
|
487
|
+
if (!response.ok) {
|
|
488
|
+
throw new Error('Failed to fetch graph stream chunk')
|
|
622
489
|
}
|
|
623
490
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const lookup = new Map()
|
|
491
|
+
const chunk = await response.json()
|
|
492
|
+
if (token !== state.fetchToken) {
|
|
493
|
+
return
|
|
494
|
+
}
|
|
629
495
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
496
|
+
state.graphSignature = typeof chunk.signature === 'string' ? chunk.signature : ''
|
|
497
|
+
state.graphMode = typeof chunk.mode === 'string' ? chunk.mode : 'near'
|
|
498
|
+
state.chunk = {
|
|
499
|
+
nodes: normalizeList(chunk.nodes),
|
|
500
|
+
edges: normalizeList(chunk.edges)
|
|
501
|
+
}
|
|
502
|
+
state.totals = {
|
|
503
|
+
nodes: Number.isFinite(chunk?.totals?.nodes) ? Number(chunk.totals.nodes) : state.chunk.nodes.length,
|
|
504
|
+
edges: Number.isFinite(chunk?.totals?.edges) ? Number(chunk.totals.edges) : state.chunk.edges.length
|
|
505
|
+
}
|
|
633
506
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
sourceList.push(edge)
|
|
637
|
-
} else {
|
|
638
|
-
lookup.set(edge.source, [edge])
|
|
639
|
-
}
|
|
507
|
+
updateTotals()
|
|
508
|
+
updateTagCount()
|
|
640
509
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
targetList.push(edge)
|
|
644
|
-
} else {
|
|
645
|
-
lookup.set(edge.target, [edge])
|
|
646
|
-
}
|
|
510
|
+
if (fit) {
|
|
511
|
+
fitFromChunk()
|
|
647
512
|
}
|
|
648
513
|
|
|
649
|
-
|
|
650
|
-
}
|
|
514
|
+
if (state.renderWorker && state.workerReady) {
|
|
515
|
+
state.renderWorker.postMessage({ type: 'chunk', chunk })
|
|
516
|
+
state.renderWorker.postMessage({ type: 'select', id: state.selectedNodeId })
|
|
517
|
+
}
|
|
651
518
|
|
|
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
|
|
519
|
+
drawFallback()
|
|
661
520
|
}
|
|
662
521
|
|
|
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
|
|
522
|
+
const scheduleChunkFetch = ({ fit } = { fit: false }) => {
|
|
523
|
+
if (state.fetchTimer) {
|
|
524
|
+
clearTimeout(state.fetchTimer)
|
|
673
525
|
}
|
|
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
526
|
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
527
|
+
const delay = fit ? 0 : (state.pointer.down ? 80 : 32)
|
|
528
|
+
state.fetchTimer = setTimeout(() => {
|
|
529
|
+
state.fetchTimer = null
|
|
530
|
+
fetchChunk({ fit }).catch((error) => {
|
|
531
|
+
console.error(error)
|
|
532
|
+
})
|
|
533
|
+
}, delay)
|
|
687
534
|
}
|
|
688
535
|
|
|
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
|
-
}
|
|
536
|
+
const setViewportFromCanvas = () => {
|
|
698
537
|
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)
|
|
538
|
+
state.viewport.width = Math.max(320, rect.width)
|
|
539
|
+
state.viewport.height = Math.max(320, rect.height)
|
|
540
|
+
state.viewport.ratio = window.devicePixelRatio || 1
|
|
541
|
+
updateWorkerSize()
|
|
542
|
+
drawFallback()
|
|
710
543
|
}
|
|
711
544
|
|
|
712
|
-
const
|
|
545
|
+
const pickAt = (screenX, screenY) => {
|
|
546
|
+
if (!state.renderWorker || !state.workerReady) {
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const requestId = Math.random().toString(36).slice(2)
|
|
551
|
+
state.renderWorker.postMessage({
|
|
552
|
+
type: 'pick',
|
|
553
|
+
requestId,
|
|
554
|
+
x: screenX,
|
|
555
|
+
y: screenY
|
|
556
|
+
})
|
|
557
|
+
}
|
|
713
558
|
|
|
714
|
-
const
|
|
715
|
-
const
|
|
716
|
-
|
|
559
|
+
const zoomAtPoint = (screenX, screenY, factor) => {
|
|
560
|
+
const clamped = Math.max(0.92, Math.min(1.09, factor))
|
|
561
|
+
const before = screenToWorld(screenX, screenY)
|
|
562
|
+
state.camera.scale = clampScale(state.camera.scale * clamped)
|
|
563
|
+
state.camera.x = screenX - before.x * state.camera.scale
|
|
564
|
+
state.camera.y = screenY - before.y * state.camera.scale
|
|
565
|
+
updateWorkerCamera()
|
|
566
|
+
scheduleChunkFetch()
|
|
717
567
|
}
|
|
718
568
|
|
|
719
|
-
const
|
|
720
|
-
const
|
|
721
|
-
|
|
569
|
+
const resolvePointer = (event) => {
|
|
570
|
+
const rect = canvas.getBoundingClientRect()
|
|
571
|
+
return {
|
|
572
|
+
x: event.clientX - rect.left,
|
|
573
|
+
y: event.clientY - rect.top
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const setupInput = () => {
|
|
578
|
+
canvas.addEventListener('wheel', (event) => {
|
|
579
|
+
event.preventDefault()
|
|
580
|
+
const pointer = resolvePointer(event)
|
|
581
|
+
const exponent = Math.max(-0.05, Math.min(0.05, -event.deltaY * 0.001))
|
|
582
|
+
zoomAtPoint(pointer.x, pointer.y, Math.exp(exponent))
|
|
583
|
+
}, { passive: false })
|
|
584
|
+
|
|
585
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
586
|
+
const pointer = resolvePointer(event)
|
|
587
|
+
state.pointer.down = true
|
|
588
|
+
state.pointer.moved = false
|
|
589
|
+
state.pointer.x = pointer.x
|
|
590
|
+
state.pointer.y = pointer.y
|
|
591
|
+
const world = screenToWorld(pointer.x, pointer.y)
|
|
592
|
+
state.pointer.worldAnchorX = world.x
|
|
593
|
+
state.pointer.worldAnchorY = world.y
|
|
594
|
+
canvas.setPointerCapture(event.pointerId)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
canvas.addEventListener('pointermove', (event) => {
|
|
598
|
+
const pointer = resolvePointer(event)
|
|
722
599
|
|
|
723
|
-
|
|
724
|
-
|
|
600
|
+
if (state.pointer.down) {
|
|
601
|
+
const dx = pointer.x - state.pointer.x
|
|
602
|
+
const dy = pointer.y - state.pointer.y
|
|
603
|
+
if (Math.abs(dx) + Math.abs(dy) > 2) {
|
|
604
|
+
state.pointer.moved = true
|
|
605
|
+
}
|
|
606
|
+
state.camera.x += dx
|
|
607
|
+
state.camera.y += dy
|
|
608
|
+
state.pointer.x = pointer.x
|
|
609
|
+
state.pointer.y = pointer.y
|
|
610
|
+
updateWorkerCamera()
|
|
611
|
+
scheduleChunkFetch()
|
|
612
|
+
drawFallback()
|
|
725
613
|
return
|
|
726
614
|
}
|
|
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
|
-
|
|
738
|
-
return merged
|
|
739
|
-
}
|
|
740
615
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
616
|
+
if (state.renderWorker && state.workerReady) {
|
|
617
|
+
state.renderWorker.postMessage({
|
|
618
|
+
type: 'pointer',
|
|
619
|
+
x: pointer.x,
|
|
620
|
+
y: pointer.y
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
})
|
|
745
624
|
|
|
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
|
-
}
|
|
625
|
+
canvas.addEventListener('pointerup', (event) => {
|
|
626
|
+
const pointer = resolvePointer(event)
|
|
627
|
+
const shouldPick = !state.pointer.moved
|
|
628
|
+
state.pointer.down = false
|
|
629
|
+
canvas.releasePointerCapture(event.pointerId)
|
|
764
630
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
631
|
+
if (shouldPick) {
|
|
632
|
+
pickAt(pointer.x, pointer.y)
|
|
633
|
+
}
|
|
634
|
+
})
|
|
768
635
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
.
|
|
772
|
-
}
|
|
636
|
+
canvas.addEventListener('dblclick', (event) => {
|
|
637
|
+
const pointer = resolvePointer(event)
|
|
638
|
+
zoomAtPoint(pointer.x, pointer.y, 1.065)
|
|
639
|
+
})
|
|
773
640
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
641
|
+
window.addEventListener('keydown', (event) => {
|
|
642
|
+
if (event.key === '+') {
|
|
643
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
if (event.key === '-') {
|
|
647
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
if (event.key === '0') {
|
|
651
|
+
scheduleChunkFetch({ fit: true })
|
|
652
|
+
}
|
|
653
|
+
})
|
|
780
654
|
}
|
|
781
655
|
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}
|
|
656
|
+
const setupControls = () => {
|
|
657
|
+
elements.zoomIn.addEventListener('click', () => {
|
|
658
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 1.06)
|
|
659
|
+
})
|
|
787
660
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
}
|
|
661
|
+
elements.zoomOut.addEventListener('click', () => {
|
|
662
|
+
zoomAtPoint(state.viewport.width / 2, state.viewport.height / 2, 0.944)
|
|
663
|
+
})
|
|
792
664
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
665
|
+
elements.fit.addEventListener('click', () => {
|
|
666
|
+
fitFromChunk()
|
|
667
|
+
scheduleChunkFetch()
|
|
668
|
+
})
|
|
797
669
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
670
|
+
elements.reset.addEventListener('click', () => {
|
|
671
|
+
state.camera = { x: 0, y: 0, scale: 0.22 }
|
|
672
|
+
updateWorkerCamera()
|
|
673
|
+
scheduleChunkFetch({ fit: true })
|
|
674
|
+
})
|
|
802
675
|
|
|
803
|
-
|
|
804
|
-
|
|
676
|
+
elements.contentClose.addEventListener('click', () => {
|
|
677
|
+
elements.contentDialog.close()
|
|
678
|
+
})
|
|
805
679
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
680
|
+
elements.contentDialog.addEventListener('click', (event) => {
|
|
681
|
+
if (event.target === elements.contentDialog) {
|
|
682
|
+
elements.contentDialog.close()
|
|
683
|
+
}
|
|
684
|
+
})
|
|
810
685
|
|
|
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 })
|
|
686
|
+
elements.search.addEventListener('input', () => {
|
|
687
|
+
const token = ++state.searchToken
|
|
688
|
+
const query = (elements.search.value || '').trim()
|
|
689
|
+
if (!query) {
|
|
690
|
+
if (state.renderWorker && state.workerReady) {
|
|
691
|
+
state.renderWorker.postMessage({ type: 'highlight', ids: [] })
|
|
838
692
|
}
|
|
693
|
+
return
|
|
839
694
|
}
|
|
695
|
+
|
|
696
|
+
fetch('/api/graph-filter?q=' + encodeURIComponent(query) + '&limit=1800' + agentQuery('&'))
|
|
697
|
+
.then((response) => response.json())
|
|
698
|
+
.then((payload) => {
|
|
699
|
+
if (token !== state.searchToken) {
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
const ids = Array.isArray(payload?.nodeIds) ? payload.nodeIds : []
|
|
703
|
+
if (state.renderWorker && state.workerReady) {
|
|
704
|
+
state.renderWorker.postMessage({ type: 'highlight', ids })
|
|
705
|
+
}
|
|
706
|
+
})
|
|
707
|
+
.catch((error) => {
|
|
708
|
+
console.error(error)
|
|
709
|
+
})
|
|
840
710
|
})
|
|
711
|
+
}
|
|
841
712
|
|
|
842
|
-
|
|
843
|
-
|
|
713
|
+
const loadAgents = async () => {
|
|
714
|
+
const response = await fetch('/api/agents')
|
|
715
|
+
if (!response.ok) {
|
|
716
|
+
throw new Error('Failed to load agents')
|
|
844
717
|
}
|
|
845
718
|
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const
|
|
853
|
-
const
|
|
854
|
-
return
|
|
719
|
+
const payload = await response.json()
|
|
720
|
+
const agents = Array.isArray(payload?.agents) ? payload.agents : []
|
|
721
|
+
|
|
722
|
+
elements.agent.innerHTML = agents
|
|
723
|
+
.map((agent) => {
|
|
724
|
+
const id = String(agent?.id || '')
|
|
725
|
+
const count = Number.isFinite(agent?.documentCount) ? agent.documentCount : 0
|
|
726
|
+
const label = id === 'shared' ? 'shared' : id
|
|
727
|
+
return '<option value="' + escapeHtml(id) + '">' + escapeHtml(label) + ' (' + count + ')</option>'
|
|
855
728
|
})
|
|
729
|
+
.join('')
|
|
856
730
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
731
|
+
const preferredAgent = initialAgentFromUrl || readStoredAgent()
|
|
732
|
+
const hasPreferred = preferredAgent && agents.some((agent) => agent?.id === preferredAgent)
|
|
733
|
+
state.agentId = hasPreferred ? preferredAgent : String(agents[0]?.id || '')
|
|
734
|
+
elements.agent.value = state.agentId
|
|
860
735
|
|
|
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)
|
|
736
|
+
elements.agent.addEventListener('change', () => {
|
|
737
|
+
state.agentId = elements.agent.value || ''
|
|
738
|
+
writeStoredAgent(state.agentId)
|
|
739
|
+
syncAgentInUrl(state.agentId)
|
|
740
|
+
scheduleChunkFetch({ fit: true })
|
|
873
741
|
})
|
|
874
742
|
|
|
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
|
|
743
|
+
syncAgentInUrl(state.agentId)
|
|
888
744
|
}
|
|
889
745
|
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
746
|
+
const setupRenderWorker = () => {
|
|
747
|
+
const hasWorker = typeof Worker !== 'undefined'
|
|
748
|
+
const canTransfer = typeof canvas.transferControlToOffscreen === 'function'
|
|
749
|
+
|
|
750
|
+
if (!hasWorker || !canTransfer) {
|
|
751
|
+
state.rendererMode = 'fallback'
|
|
752
|
+
drawFallback()
|
|
753
|
+
return
|
|
896
754
|
}
|
|
897
755
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
return 0.46
|
|
903
|
-
}
|
|
756
|
+
try {
|
|
757
|
+
const offscreen = canvas.transferControlToOffscreen()
|
|
758
|
+
const worker = new Worker('/render-worker.js')
|
|
759
|
+
state.renderWorker = worker
|
|
904
760
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
return 1
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
const edgeStrokeFor = (edge, selectedEdge) => {
|
|
914
|
-
if (selectedEdge) {
|
|
915
|
-
return graphTheme.edgeActive
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
const opacity = edgeOpacityForScale(edge, state.transform.scale) * edgeDepthOpacity(edge)
|
|
919
|
-
return edge.inferred
|
|
920
|
-
? 'rgba(203, 213, 225, ' + opacity + ')'
|
|
921
|
-
: 'rgba(153, 165, 181, ' + opacity + ')'
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
const edgeWidthFor = (edge, selectedEdge) => {
|
|
925
|
-
if (edge.inferred) {
|
|
926
|
-
const width = selectedEdge ? 1.22 : 0.84
|
|
927
|
-
return width * edgeDepthScale(edge)
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
const width = (selectedEdge ? 1.9 : 1.05) + Math.min(edgeWeight(edge) - 1, 8) * 0.24
|
|
931
|
-
return width * edgeDepthScale(edge)
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const drawGraphEdge = (edge) => {
|
|
935
|
-
const selectedEdge = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
936
|
-
ctx.beginPath()
|
|
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
|
-
}
|
|
761
|
+
worker.onmessage = (event) => {
|
|
762
|
+
const payload = event.data
|
|
763
|
+
if (!payload || typeof payload !== 'object') {
|
|
764
|
+
return
|
|
765
|
+
}
|
|
2595
766
|
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
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
|
-
}
|
|
767
|
+
if (payload.type === 'ready') {
|
|
768
|
+
state.workerReady = true
|
|
769
|
+
scheduleChunkFetch({ fit: true })
|
|
770
|
+
return
|
|
771
|
+
}
|
|
2630
772
|
|
|
2631
|
-
|
|
2632
|
-
|
|
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
|
|
773
|
+
if (payload.type === 'pick-result') {
|
|
774
|
+
if (payload.node && typeof payload.node.id === 'string' && payload.node.id.length > 0) {
|
|
775
|
+
loadNodeDetails(payload.node.id).catch((error) => console.error(error))
|
|
2671
776
|
}
|
|
777
|
+
return
|
|
2672
778
|
}
|
|
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
779
|
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
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) {
|
|
780
|
+
if (payload.type === 'frame-stats') {
|
|
781
|
+
state.lastVisibleNodes = Number.isFinite(payload.visibleNodes) ? payload.visibleNodes : state.lastVisibleNodes
|
|
782
|
+
state.lastVisibleEdges = Number.isFinite(payload.visibleEdges) ? payload.visibleEdges : state.lastVisibleEdges
|
|
3080
783
|
return
|
|
3081
784
|
}
|
|
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
785
|
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
786
|
+
if (payload.type === 'fatal') {
|
|
787
|
+
console.error(payload.message)
|
|
788
|
+
state.rendererMode = 'fallback'
|
|
789
|
+
state.workerReady = false
|
|
790
|
+
state.renderWorker.terminate()
|
|
791
|
+
state.renderWorker = null
|
|
792
|
+
drawFallback()
|
|
793
|
+
}
|
|
3166
794
|
}
|
|
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
795
|
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
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
|
|
796
|
+
worker.postMessage({
|
|
797
|
+
type: 'init',
|
|
798
|
+
canvas: offscreen,
|
|
799
|
+
width: state.viewport.width,
|
|
800
|
+
height: state.viewport.height,
|
|
801
|
+
devicePixelRatio: state.viewport.ratio,
|
|
802
|
+
camera: state.camera,
|
|
803
|
+
theme: graphTheme
|
|
804
|
+
}, [offscreen])
|
|
805
|
+
} catch (error) {
|
|
806
|
+
console.error(error)
|
|
807
|
+
state.rendererMode = 'fallback'
|
|
808
|
+
drawFallback()
|
|
3248
809
|
}
|
|
3249
|
-
|
|
3250
|
-
zoomAtPoint(cursorX, cursorY, factor, 'wheel')
|
|
3251
810
|
}
|
|
3252
811
|
|
|
3253
|
-
const
|
|
3254
|
-
|
|
3255
|
-
|
|
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 => {
|
|
812
|
+
const wireNodeLinkClicks = () => {
|
|
813
|
+
const dialog = elements.contentDialog
|
|
814
|
+
dialog.addEventListener('click', (event) => {
|
|
3293
815
|
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)
|
|
816
|
+
if (!(target instanceof HTMLElement)) {
|
|
3392
817
|
return
|
|
3393
818
|
}
|
|
3394
819
|
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
const rect = canvas.getBoundingClientRect()
|
|
3398
|
-
zoomAtPoint(Math.max(rect.width, 320) / 2, Math.max(rect.height, 320) / 2, 0.952)
|
|
820
|
+
const button = target.closest('button[data-node-id]')
|
|
821
|
+
if (!button) {
|
|
3399
822
|
return
|
|
3400
823
|
}
|
|
3401
824
|
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
825
|
+
const id = button.getAttribute('data-node-id') || ''
|
|
826
|
+
if (id) {
|
|
827
|
+
loadNodeDetails(id).catch((error) => console.error(error))
|
|
3405
828
|
}
|
|
3406
829
|
})
|
|
3407
830
|
}
|
|
3408
831
|
|
|
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
|
-
}
|
|
832
|
+
const bootstrap = async () => {
|
|
833
|
+
setViewportFromCanvas()
|
|
834
|
+
setupRenderWorker()
|
|
835
|
+
setupInput()
|
|
836
|
+
setupControls()
|
|
837
|
+
wireNodeLinkClicks()
|
|
3432
838
|
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
? {
|
|
3437
|
-
'if-none-match': encodeEntityTag(state.graphSignature)
|
|
3438
|
-
}
|
|
3439
|
-
: undefined
|
|
839
|
+
window.addEventListener('resize', () => {
|
|
840
|
+
setViewportFromCanvas()
|
|
841
|
+
scheduleChunkFetch()
|
|
3440
842
|
})
|
|
3441
843
|
|
|
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)
|
|
844
|
+
await loadAgents()
|
|
845
|
+
updateTotals()
|
|
846
|
+
updateTagCount()
|
|
3513
847
|
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
loadAgents().catch((error) => {
|
|
3517
|
-
console.error(error)
|
|
3518
|
-
})
|
|
848
|
+
if (state.rendererMode === 'fallback') {
|
|
849
|
+
scheduleChunkFetch({ fit: true })
|
|
3519
850
|
}
|
|
3520
851
|
}
|
|
3521
852
|
|
|
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)
|
|
853
|
+
bootstrap().catch((error) => {
|
|
854
|
+
console.error(error)
|
|
3538
855
|
})
|
|
3539
856
|
`;
|