@andespindola/brainlink 0.1.0-beta.82 → 0.1.0-beta.84
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
CHANGED
|
@@ -83,6 +83,7 @@ Legacy `.jsonl.gz` packs are upgraded to `.blpk` automatically on first search/c
|
|
|
83
83
|
- Realtime graph UI with agent selector and colored knowledge groups.
|
|
84
84
|
- Graph renderer optimized for large datasets with viewport-driven node culling and edge lookup by visible nodes.
|
|
85
85
|
- Canvas graph rendering uses the same batched node and edge pipeline for every graph size, reducing per-frame draw calls while keeping selected and hovered items highlighted.
|
|
86
|
+
- WebGL acceleration is used when available for dense node and edge drawing, with Canvas 2D preserved as the interaction and fallback layer.
|
|
86
87
|
- Large graph layout API automatically uses compact payload encoding with link-coverage-aware edge selection to reduce initial client load without hiding major relationships.
|
|
87
88
|
- Large-segment layout spacing now grows logarithmically to keep initial visual density consistent between medium and very large vaults (for example, ~1k vs ~50k notes).
|
|
88
89
|
- Graph coordinates are visually compacted across graph sizes so reset starts from a stable macro mass and zoom-in progressively expands toward local detail.
|
|
@@ -597,7 +598,9 @@ The graph UI shows:
|
|
|
597
598
|
- double-click on canvas zooms in at cursor position
|
|
598
599
|
- floating graph totals (notes, links, tags) below the Brainlink title
|
|
599
600
|
- graph rendering safeguards (batched canvas drawing across graph sizes, edge draw caps, lower redraw rate, zoom-aware interaction)
|
|
601
|
+
- WebGL node and edge acceleration when supported, falling back to Canvas 2D without changing graph behavior
|
|
600
602
|
- compact macro-to-micro density progression so reset keeps the graph mass oriented and zoom-in separates local neighborhoods progressively
|
|
603
|
+
- graph camera treats hub-centered navigation as structural only when the hub is dominant; diffuse stress graphs reset and zoom around the full graph mass
|
|
601
604
|
- massive-graph LOD progression: very low zoom uses spatial overview sampling plus hub-neighborhood edge previews to preserve whole-vault shape and orientation, then progressively raises the focused node budget as zoom increases so dense local areas keep nearby notes and links visible
|
|
602
605
|
|
|
603
606
|
The server indexes before starting by default. Use `--no-index` to skip that step:
|
|
@@ -77,16 +77,33 @@ select {
|
|
|
77
77
|
font-size: 18px;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
.graph-stage {
|
|
81
|
+
position: relative;
|
|
82
82
|
width: 100%;
|
|
83
83
|
height: 100%;
|
|
84
84
|
background:
|
|
85
85
|
radial-gradient(circle at 18% 20%, rgba(53, 208, 162, 0.12), transparent 28rem),
|
|
86
86
|
linear-gradient(135deg, #0d0f12 0%, #12161c 55%, #0a0d10 100%);
|
|
87
|
+
overflow: hidden;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#graph,
|
|
91
|
+
#graphGl {
|
|
92
|
+
display: block;
|
|
93
|
+
position: absolute;
|
|
94
|
+
inset: 0;
|
|
95
|
+
width: 100%;
|
|
96
|
+
height: 100%;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#graph {
|
|
87
100
|
cursor: grab;
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
#graphGl {
|
|
104
|
+
pointer-events: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
#graph:active {
|
|
91
108
|
cursor: grabbing;
|
|
92
109
|
}
|
|
@@ -43,7 +43,10 @@ export const createClientHtml = () => `<!doctype html>
|
|
|
43
43
|
</div>
|
|
44
44
|
</div>
|
|
45
45
|
</header>
|
|
46
|
-
<
|
|
46
|
+
<div class="graph-stage">
|
|
47
|
+
<canvas id="graphGl" aria-hidden="true"></canvas>
|
|
48
|
+
<canvas id="graph" aria-label="Brainlink knowledge graph"></canvas>
|
|
49
|
+
</div>
|
|
47
50
|
</section>
|
|
48
51
|
</main>
|
|
49
52
|
<footer class="app-footer" aria-label="Copyright notice">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const createClientJs = () => `const canvas = document.getElementById('graph')
|
|
2
|
+
const glCanvas = document.getElementById('graphGl')
|
|
2
3
|
const ctx = canvas.getContext('2d')
|
|
3
4
|
const largeGraphNodeThreshold = 4000
|
|
4
5
|
const massiveGraphNodeThreshold = 20000
|
|
@@ -175,6 +176,177 @@ const graphTheme = {
|
|
|
175
176
|
label: '#edf2f7'
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
const parseRgb = color => {
|
|
180
|
+
const normalized = color.trim()
|
|
181
|
+
if (normalized.startsWith('#')) {
|
|
182
|
+
const value = normalized.slice(1)
|
|
183
|
+
const expanded = value.length === 3
|
|
184
|
+
? value.split('').map(char => char + char).join('')
|
|
185
|
+
: value
|
|
186
|
+
const parsed = Number.parseInt(expanded, 16)
|
|
187
|
+
return [
|
|
188
|
+
((parsed >> 16) & 255) / 255,
|
|
189
|
+
((parsed >> 8) & 255) / 255,
|
|
190
|
+
(parsed & 255) / 255
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const match = normalized.match(/rgba?\\(([^)]+)\\)/)
|
|
195
|
+
if (!match) return [1, 1, 1]
|
|
196
|
+
const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()))
|
|
197
|
+
return [
|
|
198
|
+
Math.max(0, Math.min(1, (parts[0] ?? 255) / 255)),
|
|
199
|
+
Math.max(0, Math.min(1, (parts[1] ?? 255) / 255)),
|
|
200
|
+
Math.max(0, Math.min(1, (parts[2] ?? 255) / 255))
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rgba = (color, alpha = 1) => {
|
|
205
|
+
const [red, green, blue] = parseRgb(color)
|
|
206
|
+
return [red, green, blue, Math.max(0, Math.min(1, alpha))]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const createShader = (gl, type, source) => {
|
|
210
|
+
const shader = gl.createShader(type)
|
|
211
|
+
if (!shader) return null
|
|
212
|
+
gl.shaderSource(shader, source)
|
|
213
|
+
gl.compileShader(shader)
|
|
214
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
215
|
+
gl.deleteShader(shader)
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
return shader
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const createProgram = (gl, vertexSource, fragmentSource) => {
|
|
222
|
+
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
|
|
223
|
+
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
|
|
224
|
+
if (!vertexShader || !fragmentShader) return null
|
|
225
|
+
const program = gl.createProgram()
|
|
226
|
+
if (!program) return null
|
|
227
|
+
gl.attachShader(program, vertexShader)
|
|
228
|
+
gl.attachShader(program, fragmentShader)
|
|
229
|
+
gl.linkProgram(program)
|
|
230
|
+
gl.deleteShader(vertexShader)
|
|
231
|
+
gl.deleteShader(fragmentShader)
|
|
232
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
233
|
+
gl.deleteProgram(program)
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
return program
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const createWebGlRenderer = targetCanvas => {
|
|
240
|
+
if (!targetCanvas) return null
|
|
241
|
+
|
|
242
|
+
const gl = targetCanvas.getContext('webgl2', { alpha: true, antialias: true }) ||
|
|
243
|
+
targetCanvas.getContext('webgl', { alpha: true, antialias: true })
|
|
244
|
+
if (!gl) return null
|
|
245
|
+
|
|
246
|
+
const lineProgram = createProgram(
|
|
247
|
+
gl,
|
|
248
|
+
'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); }',
|
|
249
|
+
'precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }'
|
|
250
|
+
)
|
|
251
|
+
const pointProgram = createProgram(
|
|
252
|
+
gl,
|
|
253
|
+
'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; }',
|
|
254
|
+
'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); }'
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if (!lineProgram || !pointProgram) return null
|
|
258
|
+
|
|
259
|
+
const lineBuffer = gl.createBuffer()
|
|
260
|
+
const pointPositionBuffer = gl.createBuffer()
|
|
261
|
+
const pointSizeBuffer = gl.createBuffer()
|
|
262
|
+
if (!lineBuffer || !pointPositionBuffer || !pointSizeBuffer) return null
|
|
263
|
+
|
|
264
|
+
const linePositionLocation = gl.getAttribLocation(lineProgram, 'a_position')
|
|
265
|
+
const lineResolutionLocation = gl.getUniformLocation(lineProgram, 'u_resolution')
|
|
266
|
+
const lineColorLocation = gl.getUniformLocation(lineProgram, 'u_color')
|
|
267
|
+
const pointPositionLocation = gl.getAttribLocation(pointProgram, 'a_position')
|
|
268
|
+
const pointSizeLocation = gl.getAttribLocation(pointProgram, 'a_size')
|
|
269
|
+
const pointResolutionLocation = gl.getUniformLocation(pointProgram, 'u_resolution')
|
|
270
|
+
const pointColorLocation = gl.getUniformLocation(pointProgram, 'u_color')
|
|
271
|
+
|
|
272
|
+
gl.enable(gl.BLEND)
|
|
273
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
|
274
|
+
|
|
275
|
+
const setViewport = (width, height) => {
|
|
276
|
+
gl.viewport(0, 0, targetCanvas.width, targetCanvas.height)
|
|
277
|
+
return [targetCanvas.width / Math.max(width, 1), targetCanvas.height / Math.max(height, 1)]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const screenPoint = (node, ratioX, ratioY) => [
|
|
281
|
+
(node.x * state.transform.scale + state.transform.x) * ratioX,
|
|
282
|
+
(node.y * state.transform.scale + state.transform.y) * ratioY
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
const clear = (width, height) => {
|
|
286
|
+
setViewport(width, height)
|
|
287
|
+
gl.clearColor(0, 0, 0, 0)
|
|
288
|
+
gl.clear(gl.COLOR_BUFFER_BIT)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const drawLines = (edges, color, width, height) => {
|
|
292
|
+
if (edges.length === 0) return
|
|
293
|
+
const [ratioX, ratioY] = setViewport(width, height)
|
|
294
|
+
const positions = new Float32Array(edges.length * 4)
|
|
295
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
296
|
+
const edge = edges[index]
|
|
297
|
+
const source = screenPoint(edge.sourceNode, ratioX, ratioY)
|
|
298
|
+
const target = screenPoint(edge.targetNode, ratioX, ratioY)
|
|
299
|
+
const offset = index * 4
|
|
300
|
+
positions[offset] = source[0]
|
|
301
|
+
positions[offset + 1] = source[1]
|
|
302
|
+
positions[offset + 2] = target[0]
|
|
303
|
+
positions[offset + 3] = target[1]
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
gl.useProgram(lineProgram)
|
|
307
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer)
|
|
308
|
+
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
|
|
309
|
+
gl.enableVertexAttribArray(linePositionLocation)
|
|
310
|
+
gl.vertexAttribPointer(linePositionLocation, 2, gl.FLOAT, false, 0, 0)
|
|
311
|
+
gl.uniform2f(lineResolutionLocation, targetCanvas.width, targetCanvas.height)
|
|
312
|
+
gl.uniform4fv(lineColorLocation, color)
|
|
313
|
+
gl.lineWidth(1)
|
|
314
|
+
gl.drawArrays(gl.LINES, 0, edges.length * 2)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const drawPoints = (nodes, color, sizeForNode, width, height) => {
|
|
318
|
+
if (nodes.length === 0) return
|
|
319
|
+
const [ratioX, ratioY] = setViewport(width, height)
|
|
320
|
+
const positions = new Float32Array(nodes.length * 2)
|
|
321
|
+
const sizes = new Float32Array(nodes.length)
|
|
322
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
323
|
+
const node = nodes[index]
|
|
324
|
+
const point = screenPoint(node, ratioX, ratioY)
|
|
325
|
+
const offset = index * 2
|
|
326
|
+
positions[offset] = point[0]
|
|
327
|
+
positions[offset + 1] = point[1]
|
|
328
|
+
sizes[index] = sizeForNode(node) * ((ratioX + ratioY) / 2)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
gl.useProgram(pointProgram)
|
|
332
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pointPositionBuffer)
|
|
333
|
+
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
|
|
334
|
+
gl.enableVertexAttribArray(pointPositionLocation)
|
|
335
|
+
gl.vertexAttribPointer(pointPositionLocation, 2, gl.FLOAT, false, 0, 0)
|
|
336
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pointSizeBuffer)
|
|
337
|
+
gl.bufferData(gl.ARRAY_BUFFER, sizes, gl.STREAM_DRAW)
|
|
338
|
+
gl.enableVertexAttribArray(pointSizeLocation)
|
|
339
|
+
gl.vertexAttribPointer(pointSizeLocation, 1, gl.FLOAT, false, 0, 0)
|
|
340
|
+
gl.uniform2f(pointResolutionLocation, targetCanvas.width, targetCanvas.height)
|
|
341
|
+
gl.uniform4fv(pointColorLocation, color)
|
|
342
|
+
gl.drawArrays(gl.POINTS, 0, nodes.length)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { clear, drawLines, drawPoints }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const webGlRenderer = createWebGlRenderer(glCanvas)
|
|
349
|
+
|
|
178
350
|
const initFilterWorker = () => {
|
|
179
351
|
if (typeof Worker === 'undefined') {
|
|
180
352
|
return
|
|
@@ -243,6 +415,10 @@ const resize = () => {
|
|
|
243
415
|
const ratio = window.devicePixelRatio || 1
|
|
244
416
|
canvas.width = Math.floor(width * ratio)
|
|
245
417
|
canvas.height = Math.floor(height * ratio)
|
|
418
|
+
if (glCanvas) {
|
|
419
|
+
glCanvas.width = Math.floor(width * ratio)
|
|
420
|
+
glCanvas.height = Math.floor(height * ratio)
|
|
421
|
+
}
|
|
246
422
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
|
247
423
|
markRenderDirty()
|
|
248
424
|
}
|
|
@@ -361,6 +537,17 @@ const nearestHubNeighborDistance = (hub, nodes) => {
|
|
|
361
537
|
return minimum
|
|
362
538
|
}
|
|
363
539
|
|
|
540
|
+
const isDominantHub = (hub, nodeCount = state.visibleNodes.length) => {
|
|
541
|
+
if (!hub || nodeCount <= 0) {
|
|
542
|
+
return false
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const degree = state.nodeDegrees.get(hub.id) ?? 0
|
|
546
|
+
const minimumDegree = Math.max(18, Math.sqrt(nodeCount) * 1.8)
|
|
547
|
+
const degreeRatio = degree / Math.max(nodeCount, 1)
|
|
548
|
+
return degree >= minimumDegree || degreeRatio >= 0.035
|
|
549
|
+
}
|
|
550
|
+
|
|
364
551
|
const recomputeVisibility = () => {
|
|
365
552
|
const nodes = filteredNodes()
|
|
366
553
|
const ids = new Set(nodes.map(node => node.id))
|
|
@@ -380,10 +567,11 @@ const recomputeVisibility = () => {
|
|
|
380
567
|
state.primaryHub = primaryHub
|
|
381
568
|
state.hubNeighborDistance = nearestHubNeighborDistance(primaryHub, nodes)
|
|
382
569
|
const bounds = graphBounds(nodes)
|
|
570
|
+
const macroHub = isDominantHub(primaryHub, nodes.length) ? primaryHub : null
|
|
383
571
|
state.macroCenter = bounds
|
|
384
572
|
? {
|
|
385
|
-
x:
|
|
386
|
-
y:
|
|
573
|
+
x: macroHub ? macroHub.x : (bounds.minX + bounds.maxX) / 2,
|
|
574
|
+
y: macroHub ? macroHub.y : (bounds.minY + bounds.maxY) / 2
|
|
387
575
|
}
|
|
388
576
|
: { x: 0, y: 0 }
|
|
389
577
|
state.macroRepresentative = resolveMacroRepresentative(nodes)
|
|
@@ -1065,6 +1253,105 @@ const drawGraphNodes = () => {
|
|
|
1065
1253
|
priorityNodes.forEach(node => drawSingleNode(node))
|
|
1066
1254
|
}
|
|
1067
1255
|
|
|
1256
|
+
const partitionGraphForAcceleratedRenderer = () => {
|
|
1257
|
+
const regularNodes = []
|
|
1258
|
+
const priorityNodes = []
|
|
1259
|
+
const regularEdges = []
|
|
1260
|
+
const inferredEdges = []
|
|
1261
|
+
const selectedEdges = []
|
|
1262
|
+
|
|
1263
|
+
for (let index = 0; index < state.renderNodes.length; index += 1) {
|
|
1264
|
+
const node = state.renderNodes[index]
|
|
1265
|
+
const isPriority =
|
|
1266
|
+
state.selected?.id === node.id ||
|
|
1267
|
+
state.hovered?.id === node.id
|
|
1268
|
+
if (isPriority) {
|
|
1269
|
+
priorityNodes.push(node)
|
|
1270
|
+
} else {
|
|
1271
|
+
regularNodes.push(node)
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
for (let index = 0; index < state.renderEdges.length; index += 1) {
|
|
1276
|
+
const edge = state.renderEdges[index]
|
|
1277
|
+
const isSelected = state.selected && (edge.source === state.selected.id || edge.target === state.selected.id)
|
|
1278
|
+
if (isSelected) {
|
|
1279
|
+
selectedEdges.push(edge)
|
|
1280
|
+
} else if (edge.inferred) {
|
|
1281
|
+
inferredEdges.push(edge)
|
|
1282
|
+
} else {
|
|
1283
|
+
regularEdges.push(edge)
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
return { regularNodes, priorityNodes, regularEdges, inferredEdges, selectedEdges }
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const drawGraphLabels = nodes => {
|
|
1291
|
+
if (!(state.transform.scale >= 0.62 && state.renderNodes.length <= 1200)) {
|
|
1292
|
+
return
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
ctx.fillStyle = graphTheme.label
|
|
1296
|
+
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
1297
|
+
ctx.textAlign = 'center'
|
|
1298
|
+
ctx.textBaseline = 'top'
|
|
1299
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1300
|
+
const node = nodes[index]
|
|
1301
|
+
ctx.fillText(node.title.slice(0, 34), node.x, node.y + nodeRadius(node) + 8)
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const drawAcceleratedGraph = (width, height, drawEdges) => {
|
|
1306
|
+
if (!webGlRenderer || state.renderClusters.length > 0) {
|
|
1307
|
+
return false
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const graphParts = partitionGraphForAcceleratedRenderer()
|
|
1311
|
+
const scale = state.transform.scale
|
|
1312
|
+
webGlRenderer.clear(width, height)
|
|
1313
|
+
if (drawEdges) {
|
|
1314
|
+
webGlRenderer.drawLines(
|
|
1315
|
+
graphParts.regularEdges,
|
|
1316
|
+
rgba('rgb(153, 165, 181)', edgeOpacityForScale({ inferred: false }, scale)),
|
|
1317
|
+
width,
|
|
1318
|
+
height
|
|
1319
|
+
)
|
|
1320
|
+
webGlRenderer.drawLines(
|
|
1321
|
+
graphParts.inferredEdges,
|
|
1322
|
+
rgba('rgb(203, 213, 225)', edgeOpacityForScale({ inferred: true }, scale)),
|
|
1323
|
+
width,
|
|
1324
|
+
height
|
|
1325
|
+
)
|
|
1326
|
+
}
|
|
1327
|
+
webGlRenderer.drawPoints(
|
|
1328
|
+
graphParts.regularNodes,
|
|
1329
|
+
rgba(graphTheme.nodeHalo, 0.28),
|
|
1330
|
+
node => Math.max((nodeRadius(node) + 3) * state.transform.scale * 2, 1.5),
|
|
1331
|
+
width,
|
|
1332
|
+
height
|
|
1333
|
+
)
|
|
1334
|
+
webGlRenderer.drawPoints(
|
|
1335
|
+
graphParts.regularNodes,
|
|
1336
|
+
rgba(graphTheme.node, 1),
|
|
1337
|
+
node => Math.max(nodeRadius(node) * state.transform.scale * 2, 1.2),
|
|
1338
|
+
width,
|
|
1339
|
+
height
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
ctx.save()
|
|
1343
|
+
ctx.translate(state.transform.x, state.transform.y)
|
|
1344
|
+
ctx.scale(state.transform.scale, state.transform.scale)
|
|
1345
|
+
if (drawEdges) {
|
|
1346
|
+
graphParts.selectedEdges.forEach(edge => drawGraphEdge(edge))
|
|
1347
|
+
}
|
|
1348
|
+
drawGraphLabels(graphParts.regularNodes)
|
|
1349
|
+
graphParts.priorityNodes.forEach(node => drawSingleNode(node))
|
|
1350
|
+
ctx.restore()
|
|
1351
|
+
|
|
1352
|
+
return true
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1068
1355
|
const edgePairKey = (source, target) =>
|
|
1069
1356
|
source < target ? source + '|' + target : target + '|' + source
|
|
1070
1357
|
|
|
@@ -1400,7 +1687,9 @@ const zoomCapByHubDistance = (distance) => {
|
|
|
1400
1687
|
|
|
1401
1688
|
const currentZoomMax = () => {
|
|
1402
1689
|
const nodeCount = state.visibleNodes.length > 0 ? state.visibleNodes.length : state.nodes.length
|
|
1403
|
-
const hubDistanceCap =
|
|
1690
|
+
const hubDistanceCap = isDominantHub(state.primaryHub, nodeCount)
|
|
1691
|
+
? zoomCapByHubDistance(state.hubNeighborDistance)
|
|
1692
|
+
: zoomRange.max
|
|
1404
1693
|
const minimumUsefulCap = nodeCount > massiveGraphNodeThreshold ? 1.9 : nodeCount > largeGraphNodeThreshold ? 1.35 : 0.8
|
|
1405
1694
|
const capped = Math.min(zoomCapByNodeCount(nodeCount), Math.max(minimumUsefulCap, hubDistanceCap))
|
|
1406
1695
|
return Math.max(zoomRange.min * 2, capped)
|
|
@@ -1501,7 +1790,7 @@ const fitView = (options = { useFiltered: true, macro: false, preferHubCenter: t
|
|
|
1501
1790
|
? clampScale(Math.min(baselineScale, massiveAutoFitMacroScale))
|
|
1502
1791
|
: baselineScale
|
|
1503
1792
|
const hubCenter =
|
|
1504
|
-
options.preferHubCenter && state.primaryHub && nodes.some((node) => node.id === state.primaryHub.id)
|
|
1793
|
+
options.preferHubCenter && isDominantHub(state.primaryHub, nodes.length) && nodes.some((node) => node.id === state.primaryHub.id)
|
|
1505
1794
|
? state.primaryHub
|
|
1506
1795
|
: null
|
|
1507
1796
|
const centerX = hubCenter ? hubCenter.x : (bounds.minX + bounds.maxX) / 2
|
|
@@ -2261,6 +2550,7 @@ const render = now => {
|
|
|
2261
2550
|
resetView()
|
|
2262
2551
|
}
|
|
2263
2552
|
ctx.clearRect(0, 0, width, height)
|
|
2553
|
+
webGlRenderer?.clear(width, height)
|
|
2264
2554
|
if (state.nodes.length === 0) {
|
|
2265
2555
|
ctx.fillStyle = '#99a5b5'
|
|
2266
2556
|
ctx.font = '14px Inter, system-ui, sans-serif'
|
|
@@ -2269,9 +2559,6 @@ const render = now => {
|
|
|
2269
2559
|
requestAnimationFrame(render)
|
|
2270
2560
|
return
|
|
2271
2561
|
}
|
|
2272
|
-
ctx.save()
|
|
2273
|
-
ctx.translate(state.transform.x, state.transform.y)
|
|
2274
|
-
ctx.scale(state.transform.scale, state.transform.scale)
|
|
2275
2562
|
|
|
2276
2563
|
computeRenderVisibility()
|
|
2277
2564
|
tick(delta)
|
|
@@ -2304,11 +2591,12 @@ const render = now => {
|
|
|
2304
2591
|
const drawEdges =
|
|
2305
2592
|
state.renderClusters.length === 0 &&
|
|
2306
2593
|
state.transform.scale >= minimumEdgeScale
|
|
2307
|
-
if (drawEdges) {
|
|
2308
|
-
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
|
-
|
|
2594
|
+
if (drawAcceleratedGraph(width, height, drawEdges)) {
|
|
2595
|
+
// WebGL handles the dense node/edge layer; the 2D canvas remains the interaction overlay.
|
|
2596
|
+
} else if (state.renderClusters.length > 0) {
|
|
2597
|
+
ctx.save()
|
|
2598
|
+
ctx.translate(state.transform.x, state.transform.y)
|
|
2599
|
+
ctx.scale(state.transform.scale, state.transform.scale)
|
|
2312
2600
|
const safeScale = Math.max(state.transform.scale, 0.0001)
|
|
2313
2601
|
state.renderClusters.forEach(cluster => {
|
|
2314
2602
|
const isMacro = cluster.id === 'macro-galaxy'
|
|
@@ -2337,11 +2625,17 @@ const render = now => {
|
|
|
2337
2625
|
}
|
|
2338
2626
|
// Keep cluster markers minimal and faster to draw on large graphs.
|
|
2339
2627
|
})
|
|
2628
|
+
ctx.restore()
|
|
2340
2629
|
} else {
|
|
2630
|
+
ctx.save()
|
|
2631
|
+
ctx.translate(state.transform.x, state.transform.y)
|
|
2632
|
+
ctx.scale(state.transform.scale, state.transform.scale)
|
|
2633
|
+
if (drawEdges) {
|
|
2634
|
+
drawGraphEdges()
|
|
2635
|
+
}
|
|
2341
2636
|
drawGraphNodes()
|
|
2637
|
+
ctx.restore()
|
|
2342
2638
|
}
|
|
2343
|
-
|
|
2344
|
-
ctx.restore()
|
|
2345
2639
|
if (state.renderNodes.length === 0 && state.renderClusters.length === 0) {
|
|
2346
2640
|
ctx.fillStyle = '#99a5b5'
|
|
2347
2641
|
ctx.font = '12px Inter, system-ui, sans-serif'
|
|
@@ -2455,27 +2749,8 @@ const selectNodeById = id => {
|
|
|
2455
2749
|
}
|
|
2456
2750
|
|
|
2457
2751
|
const zoomAtPoint = (screenX, screenY, factor, source = 'generic') => {
|
|
2458
|
-
const resolveZoomFactor = () => {
|
|
2459
|
-
if (state.nodes.length <= massiveGraphNodeThreshold) {
|
|
2460
|
-
return factor
|
|
2461
|
-
}
|
|
2462
|
-
|
|
2463
|
-
const scale = state.transform.scale
|
|
2464
|
-
if (factor > 1) {
|
|
2465
|
-
if (scale < 0.006) return Math.max(factor, 1.48)
|
|
2466
|
-
if (scale < 0.02) return Math.max(factor, 1.34)
|
|
2467
|
-
if (scale < 0.08) return Math.max(factor, 1.22)
|
|
2468
|
-
return factor
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
if (scale < 0.006) return Math.min(factor, 0.68)
|
|
2472
|
-
if (scale < 0.02) return Math.min(factor, 0.78)
|
|
2473
|
-
if (scale < 0.08) return Math.min(factor, 0.86)
|
|
2474
|
-
return factor
|
|
2475
|
-
}
|
|
2476
|
-
|
|
2477
2752
|
state.lastManualZoomAt = performance.now()
|
|
2478
|
-
const effectiveFactor =
|
|
2753
|
+
const effectiveFactor = factor
|
|
2479
2754
|
const nextScale = clampScale(state.transform.scale * effectiveFactor)
|
|
2480
2755
|
if (nextScale === state.transform.scale) {
|
|
2481
2756
|
return
|
package/package.json
CHANGED