@andespindola/brainlink 1.6.1 → 1.6.3

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.
@@ -0,0 +1,690 @@
1
+ // Renderer WebGL do grafo, escrito como factory dirigida por um "host" para
2
+ // poder rodar em dois lugares sem duplicar código:
3
+ // - dentro do Web Worker (host liga em self.postMessage / self.requestAnimationFrame);
4
+ // - na própria thread principal, quando o caminho do worker não está
5
+ // disponível ou falha (host liga em callbacks diretos + window.requestAnimationFrame).
6
+ // O contrato de mensagens (init/resize/camera/chunk/highlight/focus/select/
7
+ // move-node/pick/pointer de entrada; ready/fatal/frame-stats/pick-result de
8
+ // saída) é idêntico ao antigo worker, então os dois modos são intercambiáveis.
9
+ export const createGraphRendererJs = () => `
10
+ function createGraphRenderer(host) {
11
+ let canvas = null
12
+ let gl = null
13
+ let viewportWidth = 320
14
+ let viewportHeight = 320
15
+ let devicePixelRatio = 1
16
+ const camera = { x: 0, y: 0, scale: 1 }
17
+ const state = {
18
+ nodeCount: 0,
19
+ edgeCount: 0,
20
+ ids: [],
21
+ titles: [],
22
+ kinds: [],
23
+ x: new Float32Array(0),
24
+ y: new Float32Array(0),
25
+ relevance: new Float32Array(0),
26
+ radius: new Float32Array(0),
27
+ colorIndex: new Uint8Array(0),
28
+ visible: new Uint8Array(0),
29
+ highlighted: new Uint8Array(0),
30
+ focused: new Uint8Array(0),
31
+ selected: new Uint8Array(0),
32
+ edgeSource: new Uint32Array(0),
33
+ edgeTarget: new Uint32Array(0),
34
+ edgeWeight: new Float32Array(0)
35
+ }
36
+ const nodeIndexById = new Map()
37
+ const highlightedIds = new Set()
38
+ const focusedIds = new Set()
39
+ let selectedNodeId = null
40
+ let dirty = true
41
+ let renderScheduled = false
42
+ let hoverX = null
43
+ let hoverY = null
44
+ let lastFrameAt = 0
45
+ let lastVisibleEdges = 0
46
+ let interactionUntil = 0
47
+ let settledRenderTimer = null
48
+ let edgePositionsBuffer = new Float32Array(0)
49
+ let pointPositionsBuffer = new Float32Array(0)
50
+ let pointSizesBuffer = new Float32Array(0)
51
+
52
+ const defaultTheme = {
53
+ node: [0.30, 0.56, 0.85, 1],
54
+ nodeCluster: [0.18, 0.44, 0.71, 1],
55
+ nodeHighlight: [0.95, 0.70, 0.25, 1],
56
+ nodeSelected: [0.09, 0.13, 0.20, 1],
57
+ nodePalette: [
58
+ [0.30, 0.56, 0.85, 1],
59
+ [0.40, 0.73, 0.43, 1],
60
+ [0.94, 0.64, 0.23, 1],
61
+ [0.85, 0.37, 0.55, 1],
62
+ [0.55, 0.45, 0.85, 1],
63
+ [0.33, 0.75, 0.77, 1],
64
+ [0.93, 0.42, 0.34, 1],
65
+ [0.60, 0.65, 0.70, 1],
66
+ [0.72, 0.51, 0.33, 1],
67
+ [0.44, 0.62, 0.85, 1]
68
+ ],
69
+ edge: [0.36, 0.46, 0.60, 0.26],
70
+ edgeHeavy: [0.40, 0.52, 0.68, 0.5],
71
+ clear: [0.031, 0.075, 0.114, 1]
72
+ }
73
+
74
+ const theme = { ...defaultTheme }
75
+
76
+ const createShader = (type, source) => {
77
+ const shader = gl.createShader(type)
78
+ if (!shader) return null
79
+ gl.shaderSource(shader, source)
80
+ gl.compileShader(shader)
81
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
82
+ gl.deleteShader(shader)
83
+ return null
84
+ }
85
+ return shader
86
+ }
87
+
88
+ const createProgram = (vertexSource, fragmentSource) => {
89
+ const vertexShader = createShader(gl.VERTEX_SHADER, vertexSource)
90
+ const fragmentShader = createShader(gl.FRAGMENT_SHADER, fragmentSource)
91
+ if (!vertexShader || !fragmentShader) return null
92
+ const program = gl.createProgram()
93
+ if (!program) return null
94
+ gl.attachShader(program, vertexShader)
95
+ gl.attachShader(program, fragmentShader)
96
+ gl.linkProgram(program)
97
+ gl.deleteShader(vertexShader)
98
+ gl.deleteShader(fragmentShader)
99
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
100
+ gl.deleteProgram(program)
101
+ return null
102
+ }
103
+ return program
104
+ }
105
+
106
+ let lineProgram = null
107
+ let pointProgram = null
108
+ let lineBuffer = null
109
+ let pointPositionBuffer = null
110
+ let pointSizeBuffer = null
111
+ let linePositionLocation = -1
112
+ let lineResolutionLocation = null
113
+ let lineColorLocation = null
114
+ let pointPositionLocation = -1
115
+ let pointSizeLocation = -1
116
+ let pointResolutionLocation = null
117
+ let pointColorLocation = null
118
+
119
+ const initWebGl = () => {
120
+ if (!canvas) {
121
+ return false
122
+ }
123
+
124
+ gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: false, stencil: false }) ||
125
+ canvas.getContext('webgl', { alpha: false, antialias: true, depth: false, stencil: false })
126
+
127
+ if (!gl) {
128
+ return false
129
+ }
130
+
131
+ lineProgram = createProgram(
132
+ '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); }',
133
+ 'precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }'
134
+ )
135
+
136
+ pointProgram = createProgram(
137
+ '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; }',
138
+ '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 alpha = smoothstep(0.5, 0.38, distanceFromCenter); gl_FragColor = vec4(u_color.rgb, u_color.a * alpha); }'
139
+ )
140
+
141
+ if (!lineProgram || !pointProgram) {
142
+ return false
143
+ }
144
+
145
+ lineBuffer = gl.createBuffer()
146
+ pointPositionBuffer = gl.createBuffer()
147
+ pointSizeBuffer = gl.createBuffer()
148
+ if (!lineBuffer || !pointPositionBuffer || !pointSizeBuffer) {
149
+ return false
150
+ }
151
+
152
+ linePositionLocation = gl.getAttribLocation(lineProgram, 'a_position')
153
+ lineResolutionLocation = gl.getUniformLocation(lineProgram, 'u_resolution')
154
+ lineColorLocation = gl.getUniformLocation(lineProgram, 'u_color')
155
+ pointPositionLocation = gl.getAttribLocation(pointProgram, 'a_position')
156
+ pointSizeLocation = gl.getAttribLocation(pointProgram, 'a_size')
157
+ pointResolutionLocation = gl.getUniformLocation(pointProgram, 'u_resolution')
158
+ pointColorLocation = gl.getUniformLocation(pointProgram, 'u_color')
159
+
160
+ gl.enable(gl.BLEND)
161
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
162
+ gl.disable(gl.DEPTH_TEST)
163
+
164
+ return true
165
+ }
166
+
167
+ const ensureFloat32Capacity = (buffer, neededLength) => {
168
+ if (buffer.length >= neededLength) {
169
+ return buffer
170
+ }
171
+ const next = Math.max(neededLength, Math.ceil(buffer.length * 1.6), 1024)
172
+ return new Float32Array(next)
173
+ }
174
+
175
+ const resizeCanvas = (width, height, ratio) => {
176
+ viewportWidth = Math.max(320, Number.isFinite(width) ? width : viewportWidth)
177
+ viewportHeight = Math.max(320, Number.isFinite(height) ? height : viewportHeight)
178
+ devicePixelRatio = Math.max(1, Number.isFinite(ratio) ? ratio : devicePixelRatio)
179
+
180
+ if (!canvas) return
181
+
182
+ canvas.width = Math.floor(viewportWidth * devicePixelRatio)
183
+ canvas.height = Math.floor(viewportHeight * devicePixelRatio)
184
+ if (gl) {
185
+ gl.viewport(0, 0, canvas.width, canvas.height)
186
+ }
187
+ dirty = true
188
+ requestRender()
189
+ }
190
+
191
+ const toScreenPoint = (x, y) => {
192
+ const sx = (x * camera.scale + camera.x) * devicePixelRatio
193
+ const sy = (y * camera.scale + camera.y) * devicePixelRatio
194
+ return [sx, sy]
195
+ }
196
+
197
+ const ensureNodeCapacity = (count) => {
198
+ if (state.x.length >= count) {
199
+ return
200
+ }
201
+
202
+ const nextCapacity = Math.max(count, Math.ceil(state.x.length * 1.5), 512)
203
+ state.x = new Float32Array(nextCapacity)
204
+ state.y = new Float32Array(nextCapacity)
205
+ state.relevance = new Float32Array(nextCapacity)
206
+ state.radius = new Float32Array(nextCapacity)
207
+ state.colorIndex = new Uint8Array(nextCapacity)
208
+ state.visible = new Uint8Array(nextCapacity)
209
+ state.highlighted = new Uint8Array(nextCapacity)
210
+ state.focused = new Uint8Array(nextCapacity)
211
+ state.selected = new Uint8Array(nextCapacity)
212
+ }
213
+
214
+ const ensureEdgeCapacity = (count) => {
215
+ if (state.edgeSource.length >= count) {
216
+ return
217
+ }
218
+
219
+ const nextCapacity = Math.max(count, Math.ceil(state.edgeSource.length * 1.5), 1024)
220
+ state.edgeSource = new Uint32Array(nextCapacity)
221
+ state.edgeTarget = new Uint32Array(nextCapacity)
222
+ state.edgeWeight = new Float32Array(nextCapacity)
223
+ }
224
+
225
+ const nodeRadius = (relevance, kind) => {
226
+ const base = kind === 'cluster' ? 11.2 : 7.2
227
+ const modifier = Math.min(7.6, Math.max(0, relevance * 0.78))
228
+ return base + modifier
229
+ }
230
+
231
+ const segmentColorIndex = (segment) => {
232
+ const value = String(segment || '')
233
+ let hash = 0
234
+ for (let index = 0; index < value.length; index += 1) {
235
+ hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
236
+ }
237
+ const palette = Array.isArray(theme.nodePalette) && theme.nodePalette.length > 0 ? theme.nodePalette : [theme.node]
238
+ return Math.abs(hash) % palette.length
239
+ }
240
+
241
+ const loadChunk = (chunk) => {
242
+ const nodes = Array.isArray(chunk?.nodes) ? chunk.nodes : []
243
+ const edges = Array.isArray(chunk?.edges) ? chunk.edges : []
244
+
245
+ ensureNodeCapacity(nodes.length)
246
+ nodeIndexById.clear()
247
+ state.ids = new Array(nodes.length)
248
+ state.titles = new Array(nodes.length)
249
+ state.kinds = new Array(nodes.length)
250
+
251
+ for (let index = 0; index < nodes.length; index += 1) {
252
+ const row = nodes[index]
253
+ const id = typeof row?.[0] === 'string' ? row[0] : ''
254
+ if (!id) {
255
+ continue
256
+ }
257
+ const title = typeof row?.[1] === 'string' ? row[1] : id
258
+ const x = Number.isFinite(row?.[2]) ? Number(row[2]) : 0
259
+ const y = Number.isFinite(row?.[3]) ? Number(row[3]) : 0
260
+ const segment = typeof row?.[5] === 'string' ? row[5] : ''
261
+ const kind = row?.[6] === 'cluster' ? 'cluster' : 'node'
262
+ const relevance = Number.isFinite(row?.[7]) ? Number(row[7]) : 0
263
+
264
+ state.ids[index] = id
265
+ state.titles[index] = title
266
+ state.kinds[index] = kind
267
+ state.x[index] = x
268
+ state.y[index] = y
269
+ state.relevance[index] = relevance
270
+ state.radius[index] = nodeRadius(relevance, kind)
271
+ state.colorIndex[index] = segmentColorIndex(segment || title)
272
+ state.visible[index] = 0
273
+ state.highlighted[index] = highlightedIds.has(id) ? 1 : 0
274
+ state.focused[index] = focusedIds.has(id) ? 1 : 0
275
+ state.selected[index] = selectedNodeId === id ? 1 : 0
276
+ nodeIndexById.set(id, index)
277
+ }
278
+
279
+ state.nodeCount = nodes.length
280
+
281
+ ensureEdgeCapacity(edges.length)
282
+ let edgeCount = 0
283
+ for (let index = 0; index < edges.length; index += 1) {
284
+ const row = edges[index]
285
+ const sourceId = typeof row?.[0] === 'string' ? row[0] : ''
286
+ const targetId = typeof row?.[1] === 'string' ? row[1] : ''
287
+ const source = nodeIndexById.get(sourceId)
288
+ const target = nodeIndexById.get(targetId)
289
+ if (source === undefined || target === undefined || source === target) {
290
+ continue
291
+ }
292
+ state.edgeSource[edgeCount] = source
293
+ state.edgeTarget[edgeCount] = target
294
+ state.edgeWeight[edgeCount] = Number.isFinite(row?.[2]) ? Number(row[2]) : 1
295
+ edgeCount += 1
296
+ }
297
+
298
+ state.edgeCount = edgeCount
299
+ dirty = true
300
+ }
301
+
302
+ const cullVisibleNodes = () => {
303
+ const minX = -280
304
+ const minY = -280
305
+ const maxX = viewportWidth + 280
306
+ const maxY = viewportHeight + 280
307
+
308
+ for (let index = 0; index < state.nodeCount; index += 1) {
309
+ const radius = state.radius[index] * camera.scale
310
+ const [sx, sy] = toScreenPoint(state.x[index], state.y[index])
311
+ const screenX = sx / devicePixelRatio
312
+ const screenY = sy / devicePixelRatio
313
+ state.visible[index] = screenX + radius >= minX && screenX - radius <= maxX && screenY + radius >= minY && screenY - radius <= maxY ? 1 : 0
314
+ }
315
+ }
316
+
317
+ const drawEdges = () => {
318
+ if (!gl || state.edgeCount === 0) return
319
+
320
+ edgePositionsBuffer = ensureFloat32Capacity(edgePositionsBuffer, state.edgeCount * 4)
321
+ let cursor = 0
322
+ let visibleEdges = 0
323
+ for (let index = 0; index < state.edgeCount; index += 1) {
324
+ const source = state.edgeSource[index]
325
+ const target = state.edgeTarget[index]
326
+ if (state.visible[source] === 0 && state.visible[target] === 0) {
327
+ continue
328
+ }
329
+ const [sx, sy] = toScreenPoint(state.x[source], state.y[source])
330
+ const [tx, ty] = toScreenPoint(state.x[target], state.y[target])
331
+ edgePositionsBuffer[cursor] = sx
332
+ edgePositionsBuffer[cursor + 1] = sy
333
+ edgePositionsBuffer[cursor + 2] = tx
334
+ edgePositionsBuffer[cursor + 3] = ty
335
+ cursor += 4
336
+ visibleEdges += 1
337
+ }
338
+
339
+ lastVisibleEdges = visibleEdges
340
+ if (cursor === 0) return
341
+
342
+ gl.useProgram(lineProgram)
343
+ gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer)
344
+ gl.bufferData(gl.ARRAY_BUFFER, edgePositionsBuffer.subarray(0, cursor), gl.STREAM_DRAW)
345
+ gl.enableVertexAttribArray(linePositionLocation)
346
+ gl.vertexAttribPointer(linePositionLocation, 2, gl.FLOAT, false, 0, 0)
347
+ gl.uniform2f(lineResolutionLocation, canvas.width, canvas.height)
348
+ gl.uniform4fv(lineColorLocation, state.nodeCount > 4000 ? theme.edge : theme.edgeHeavy)
349
+ gl.lineWidth(1)
350
+ gl.drawArrays(gl.LINES, 0, cursor / 2)
351
+ }
352
+
353
+ const drawNodeLayer = (predicate, color, radiusBoost = 1) => {
354
+ if (!gl || state.nodeCount === 0) return
355
+
356
+ pointPositionsBuffer = ensureFloat32Capacity(pointPositionsBuffer, state.nodeCount * 2)
357
+ pointSizesBuffer = ensureFloat32Capacity(pointSizesBuffer, state.nodeCount)
358
+ let positionCursor = 0
359
+ let sizeCursor = 0
360
+ for (let index = 0; index < state.nodeCount; index += 1) {
361
+ if (!predicate(index)) continue
362
+ const [sx, sy] = toScreenPoint(state.x[index], state.y[index])
363
+ pointPositionsBuffer[positionCursor] = sx
364
+ pointPositionsBuffer[positionCursor + 1] = sy
365
+ // Screen-space floor so every node stays a visible dot at any zoom (a low
366
+ // floor made nodes sub-pixel until zoomed in). Scaled by devicePixelRatio.
367
+ pointSizesBuffer[sizeCursor] = Math.max(3.0 * devicePixelRatio, state.radius[index] * camera.scale * devicePixelRatio * radiusBoost)
368
+ positionCursor += 2
369
+ sizeCursor += 1
370
+ }
371
+
372
+ if (positionCursor === 0) return
373
+
374
+ gl.useProgram(pointProgram)
375
+ gl.bindBuffer(gl.ARRAY_BUFFER, pointPositionBuffer)
376
+ gl.bufferData(gl.ARRAY_BUFFER, pointPositionsBuffer.subarray(0, positionCursor), gl.STREAM_DRAW)
377
+ gl.enableVertexAttribArray(pointPositionLocation)
378
+ gl.vertexAttribPointer(pointPositionLocation, 2, gl.FLOAT, false, 0, 0)
379
+
380
+ gl.bindBuffer(gl.ARRAY_BUFFER, pointSizeBuffer)
381
+ gl.bufferData(gl.ARRAY_BUFFER, pointSizesBuffer.subarray(0, sizeCursor), gl.STREAM_DRAW)
382
+ gl.enableVertexAttribArray(pointSizeLocation)
383
+ gl.vertexAttribPointer(pointSizeLocation, 1, gl.FLOAT, false, 0, 0)
384
+
385
+ gl.uniform2f(pointResolutionLocation, canvas.width, canvas.height)
386
+ gl.uniform4fv(pointColorLocation, color)
387
+ gl.drawArrays(gl.POINTS, 0, positionCursor / 2)
388
+ }
389
+
390
+ const drawColoredNodeLayer = (predicate, radiusBoost = 1) => {
391
+ const palette = Array.isArray(theme.nodePalette) && theme.nodePalette.length > 0 ? theme.nodePalette : [theme.node]
392
+ for (let colorIndex = 0; colorIndex < palette.length; colorIndex += 1) {
393
+ drawNodeLayer((index) => predicate(index) && state.colorIndex[index] === colorIndex, palette[colorIndex], radiusBoost)
394
+ }
395
+ }
396
+
397
+ const clear = () => {
398
+ if (!gl || !canvas) return
399
+ gl.viewport(0, 0, canvas.width, canvas.height)
400
+ gl.clearColor(theme.clear[0], theme.clear[1], theme.clear[2], theme.clear[3])
401
+ gl.clear(gl.COLOR_BUFFER_BIT)
402
+ }
403
+
404
+ const isCameraInteracting = (now) => now < interactionUntil
405
+
406
+ const scheduleSettledRender = (now) => {
407
+ if (settledRenderTimer) {
408
+ return
409
+ }
410
+ const delay = Math.max(32, interactionUntil - now + 16)
411
+ settledRenderTimer = setTimeout(() => {
412
+ settledRenderTimer = null
413
+ dirty = true
414
+ requestRender()
415
+ }, delay)
416
+ }
417
+
418
+ const renderFrame = (now) => {
419
+ renderScheduled = false
420
+ if (!dirty) {
421
+ return
422
+ }
423
+
424
+ const delta = now - lastFrameAt
425
+ const minInterval = state.nodeCount > 20000 ? 22 : 16
426
+ if (delta < minInterval) {
427
+ requestRender()
428
+ return
429
+ }
430
+ lastFrameAt = now
431
+
432
+ if (!gl) {
433
+ return
434
+ }
435
+
436
+ cullVisibleNodes()
437
+ clear()
438
+ const cameraInteracting = isCameraInteracting(now)
439
+ if (!cameraInteracting || state.edgeCount < 1200) {
440
+ drawEdges()
441
+ } else {
442
+ lastVisibleEdges = 0
443
+ scheduleSettledRender(now)
444
+ }
445
+
446
+ drawColoredNodeLayer(
447
+ (index) => state.visible[index] === 1 && state.kinds[index] !== 'cluster' && state.selected[index] === 0 && state.highlighted[index] === 0 && state.focused[index] === 0,
448
+ 1
449
+ )
450
+
451
+ drawColoredNodeLayer(
452
+ (index) => state.visible[index] === 1 && state.kinds[index] === 'cluster' && state.selected[index] === 0,
453
+ 1.15
454
+ )
455
+
456
+ drawNodeLayer(
457
+ (index) => state.visible[index] === 1 && state.highlighted[index] === 1,
458
+ theme.nodeHighlight,
459
+ 1.22
460
+ )
461
+
462
+ drawNodeLayer(
463
+ (index) => state.visible[index] === 1 && state.focused[index] === 1,
464
+ theme.nodeHighlight,
465
+ 1.12
466
+ )
467
+
468
+ drawNodeLayer(
469
+ (index) => state.visible[index] === 1 && state.selected[index] === 1,
470
+ theme.nodeSelected,
471
+ 1.32
472
+ )
473
+
474
+ if (dirty) {
475
+ host.postMessage({
476
+ type: 'frame-stats',
477
+ visibleNodes: (() => {
478
+ let count = 0
479
+ for (let index = 0; index < state.nodeCount; index += 1) {
480
+ if (state.visible[index] === 1) count += 1
481
+ }
482
+ return count
483
+ })(),
484
+ visibleEdges: lastVisibleEdges
485
+ })
486
+ dirty = false
487
+ }
488
+ }
489
+
490
+ const requestRender = () => {
491
+ if (renderScheduled) {
492
+ return
493
+ }
494
+ renderScheduled = true
495
+ const raf = host.requestAnimationFrame
496
+ if (typeof raf === 'function') {
497
+ raf(renderFrame)
498
+ return
499
+ }
500
+ setTimeout(() => renderFrame(performance.now()), 16)
501
+ }
502
+
503
+ const setCamera = (nextCamera) => {
504
+ if (!nextCamera || typeof nextCamera !== 'object') {
505
+ return
506
+ }
507
+ camera.x = Number.isFinite(nextCamera.x) ? Number(nextCamera.x) : camera.x
508
+ camera.y = Number.isFinite(nextCamera.y) ? Number(nextCamera.y) : camera.y
509
+ camera.scale = Number.isFinite(nextCamera.scale) ? Math.max(0.0002, Math.min(8, Number(nextCamera.scale))) : camera.scale
510
+ interactionUntil = performance.now() + 140
511
+ dirty = true
512
+ requestRender()
513
+ }
514
+
515
+ const worldAtScreen = (screenX, screenY) => {
516
+ const x = (screenX - camera.x) / camera.scale
517
+ const y = (screenY - camera.y) / camera.scale
518
+ return [x, y]
519
+ }
520
+
521
+ const pickNode = (screenX, screenY) => {
522
+ const [worldX, worldY] = worldAtScreen(screenX, screenY)
523
+ let bestIndex = -1
524
+ let bestDistance = Infinity
525
+
526
+ for (let index = 0; index < state.nodeCount; index += 1) {
527
+ if (state.visible[index] === 0) continue
528
+ const dx = state.x[index] - worldX
529
+ const dy = state.y[index] - worldY
530
+ const distance = Math.hypot(dx, dy)
531
+ const maxDistance = state.radius[index] * 1.2
532
+ if (distance <= maxDistance && distance < bestDistance) {
533
+ bestDistance = distance
534
+ bestIndex = index
535
+ }
536
+ }
537
+
538
+ if (bestIndex < 0) {
539
+ return null
540
+ }
541
+
542
+ return {
543
+ id: state.ids[bestIndex],
544
+ title: state.titles[bestIndex],
545
+ kind: state.kinds[bestIndex],
546
+ x: state.x[bestIndex],
547
+ y: state.y[bestIndex]
548
+ }
549
+ }
550
+
551
+ const setHighlights = (ids) => {
552
+ highlightedIds.clear()
553
+ const list = Array.isArray(ids) ? ids : []
554
+ for (let index = 0; index < list.length; index += 1) {
555
+ const id = list[index]
556
+ if (typeof id === 'string' && id.length > 0) {
557
+ highlightedIds.add(id)
558
+ }
559
+ }
560
+
561
+ for (let index = 0; index < state.nodeCount; index += 1) {
562
+ state.highlighted[index] = highlightedIds.has(state.ids[index]) ? 1 : 0
563
+ }
564
+ dirty = true
565
+ requestRender()
566
+ }
567
+
568
+ const setFocus = (ids) => {
569
+ focusedIds.clear()
570
+ const list = Array.isArray(ids) ? ids : []
571
+ for (let index = 0; index < list.length; index += 1) {
572
+ const id = list[index]
573
+ if (typeof id === 'string' && id.length > 0) {
574
+ focusedIds.add(id)
575
+ }
576
+ }
577
+
578
+ for (let index = 0; index < state.nodeCount; index += 1) {
579
+ state.focused[index] = focusedIds.has(state.ids[index]) ? 1 : 0
580
+ }
581
+ dirty = true
582
+ requestRender()
583
+ }
584
+
585
+ const setSelected = (id) => {
586
+ selectedNodeId = typeof id === 'string' && id.length > 0 ? id : null
587
+ for (let index = 0; index < state.nodeCount; index += 1) {
588
+ state.selected[index] = selectedNodeId === state.ids[index] ? 1 : 0
589
+ }
590
+ dirty = true
591
+ requestRender()
592
+ }
593
+
594
+ const moveNode = (id, x, y) => {
595
+ if (typeof id !== 'string' || !Number.isFinite(x) || !Number.isFinite(y)) {
596
+ return
597
+ }
598
+
599
+ const index = nodeIndexById.get(id)
600
+ if (index === undefined) {
601
+ return
602
+ }
603
+
604
+ state.x[index] = x
605
+ state.y[index] = y
606
+ dirty = true
607
+ requestRender()
608
+ }
609
+
610
+ const handleMessage = (payload) => {
611
+ if (!payload || typeof payload !== 'object') {
612
+ return
613
+ }
614
+
615
+ if (payload.type === 'init') {
616
+ canvas = payload.canvas
617
+ if (payload.theme && typeof payload.theme === 'object') {
618
+ Object.assign(theme, payload.theme)
619
+ }
620
+ const initialized = initWebGl()
621
+ if (!initialized) {
622
+ host.postMessage({ type: 'fatal', message: 'WebGL is not available in render worker.' })
623
+ return
624
+ }
625
+ resizeCanvas(payload.width, payload.height, payload.devicePixelRatio)
626
+ setCamera(payload.camera)
627
+ requestRender()
628
+ host.postMessage({ type: 'ready' })
629
+ return
630
+ }
631
+
632
+ if (payload.type === 'resize') {
633
+ resizeCanvas(payload.width, payload.height, payload.devicePixelRatio)
634
+ return
635
+ }
636
+
637
+ if (payload.type === 'camera') {
638
+ setCamera(payload.camera)
639
+ return
640
+ }
641
+
642
+ if (payload.type === 'chunk') {
643
+ loadChunk(payload.chunk)
644
+ requestRender()
645
+ return
646
+ }
647
+
648
+ if (payload.type === 'highlight') {
649
+ setHighlights(payload.ids)
650
+ return
651
+ }
652
+
653
+ if (payload.type === 'focus') {
654
+ setFocus(payload.ids)
655
+ return
656
+ }
657
+
658
+ if (payload.type === 'select') {
659
+ setSelected(payload.id)
660
+ return
661
+ }
662
+
663
+ if (payload.type === 'move-node') {
664
+ moveNode(payload.id, Number(payload.x), Number(payload.y))
665
+ return
666
+ }
667
+
668
+ if (payload.type === 'pick') {
669
+ const node = pickNode(
670
+ Number.isFinite(payload.x) ? Number(payload.x) : 0,
671
+ Number.isFinite(payload.y) ? Number(payload.y) : 0
672
+ )
673
+ host.postMessage({
674
+ type: 'pick-result',
675
+ requestId: payload.requestId,
676
+ node
677
+ })
678
+ return
679
+ }
680
+
681
+ if (payload.type === 'pointer') {
682
+ hoverX = Number.isFinite(payload.x) ? Number(payload.x) : null
683
+ hoverY = Number.isFinite(payload.y) ? Number(payload.y) : null
684
+ return
685
+ }
686
+ }
687
+
688
+ return { handleMessage }
689
+ }
690
+ `;