dsh-plugin-xujie-memory 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/ACCEPTANCE.md +37 -0
  2. package/BUILD-PROVENANCE.json +53 -0
  3. package/CHANGELOG.md +12 -0
  4. package/README.md +48 -23
  5. package/README_EN.md +32 -9
  6. package/lib/client.js +17219 -0
  7. package/lib/controller.js +307 -0
  8. package/lib/index.js +87 -22
  9. package/lib/typert.host.d.ts +3 -0
  10. package/lib/typert.host.js +15104 -0
  11. package/lib/typert.remote-client.d.ts +40 -0
  12. package/lib/typert.remote-client.d.ts.map +1 -0
  13. package/lib/typert.remote-client.js +15093 -0
  14. package/lib/types.d.ts +13 -0
  15. package/lib/types.js +1 -0
  16. package/package.json +49 -5
  17. package/python/xujie_memory/__init__.py +123 -123
  18. package/python/xujie_memory/engine.py +1333 -1312
  19. package/python/xujie_memory/management.py +203 -0
  20. package/python/xujie_memory/memory.py +27 -21
  21. package/python/xujie_memory/privacy.py +105 -0
  22. package/python/xujie_memory/schema.sql +492 -480
  23. package/python/xujie_memory/server.py +52 -0
  24. package/scripts/build.mjs +19 -0
  25. package/scripts/prepack.mjs +9 -0
  26. package/scripts/test-all.mjs +9 -0
  27. package/scripts/test-node.mjs +10 -0
  28. package/src/client/MemoryPanel.tsx +536 -0
  29. package/src/client/NeuralField.tsx +88 -0
  30. package/src/client/css.d.ts +4 -0
  31. package/src/client/icons.tsx +28 -0
  32. package/src/client/index.tsx +59 -0
  33. package/src/client/memory-adapter.ts +67 -0
  34. package/src/client/model.ts +79 -0
  35. package/src/client/neural/engine.ts +999 -0
  36. package/src/client/neural/morphology.ts +219 -0
  37. package/src/client/panel.css +337 -0
  38. package/src/client/types.ts +6 -0
  39. package/src/controller.ts +88 -0
  40. package/src/generated/typert.host.js +449 -0
  41. package/src/generated/typert.remote-client.js +438 -0
  42. package/src/index.ts +2 -0
  43. package/src/types.ts +13 -0
  44. package/test/cordis-fixture.mjs +5 -0
  45. package/test/management_regression.py +187 -0
  46. package/test/node-regression.entry.mjs +52 -0
  47. package/vendor/DSH-LICENSE.txt +21 -0
  48. package/vendor/THIRD-PARTY.md +6 -0
  49. package/vendor/dsh-protocol/index.ts +484 -0
  50. package/vendor/dsh-protocol/json-value.ts +53 -0
  51. package/vendor/dsh-protocol/owned-value.ts +39 -0
  52. package/vendor/dsh-protocol/principal.ts +17 -0
  53. package/vendor/dsh-protocol/remote-access.ts +38 -0
  54. package/vendor/dsh-protocol/remote-error.ts +49 -0
  55. package/vendor/dsh-protocol/remote-info.ts +161 -0
  56. package/vendor/dsh-protocol/types.ts +939 -0
  57. package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
  58. package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
  59. package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
  60. package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
  61. package/vendor/licenses/zod-MIT.txt +21 -0
@@ -0,0 +1,999 @@
1
+ import { buildMorphology, SOMA_RADIUS, STRATA, type LayerIndex, type Morphology } from './morphology.ts'
2
+
3
+ /** One real memory record placed in the field. */
4
+ export interface FieldNode {
5
+ id: string
6
+ layer: LayerIndex
7
+ /** Read-only derived records (state/summary/clarification) render as diamonds. */
8
+ derived: boolean
9
+ /** 0..1 visual weight from importance/priority. */
10
+ weight: number
11
+ label: string
12
+ kindLabel: string
13
+ }
14
+
15
+ /** One real relation returned by the snapshot. */
16
+ export interface FieldEdge { source: string; target: string }
17
+
18
+ export interface EngineOptions {
19
+ reducedMotion: boolean
20
+ onSelect: (id: string | null) => void
21
+ /** Accessible layer captions drawn inside the field. */
22
+ layerCaptions: readonly [string, string, string]
23
+ }
24
+
25
+ type RGB = readonly [number, number, number]
26
+ /** Semantic: cold blue. Episodic: pale amber. Working: cyan-teal. Trunk: silver. */
27
+ export const LAYER_RGB: readonly RGB[] = [[132, 168, 255], [238, 196, 128], [96, 220, 200], [206, 216, 236]]
28
+
29
+ const BANDS = 6
30
+ const LIGHT_CLASSES = 3
31
+ const WIDTH_CLASSES = 4
32
+ const GROUPS = 4
33
+ const EDGE_SAMPLES = 20
34
+ const TAU = Math.PI * 2
35
+
36
+ interface Pulse { path: Int32Array; cum: Float32Array; total: number; t: number; speed: number; group: number; node: number }
37
+ interface EdgePulse { edge: number; t: number; speed: number }
38
+
39
+ function sprite(rgb: RGB, core: number): HTMLCanvasElement {
40
+ const c = document.createElement('canvas')
41
+ c.width = c.height = 64
42
+ const g = c.getContext('2d')!
43
+ const grd = g.createRadialGradient(32, 32, 0, 32, 32, 32)
44
+ grd.addColorStop(0, `rgba(255,255,255,${core})`)
45
+ grd.addColorStop(0.12, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.9)`)
46
+ grd.addColorStop(0.35, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.28)`)
47
+ grd.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)`)
48
+ g.fillStyle = grd
49
+ g.fillRect(0, 0, 64, 64)
50
+ return c
51
+ }
52
+
53
+ /**
54
+ * Canvas renderer with genuine 3D geometry: every vertex is rotated by the
55
+ * camera (yaw/pitch), perspective-projected and painted back-to-front in
56
+ * depth bands so the soma occludes fibres and records behind it.
57
+ */
58
+ export class NeuralEngine {
59
+ private readonly ctx: CanvasRenderingContext2D
60
+ private readonly m: Morphology
61
+ private readonly sx: Float32Array
62
+ private readonly sy: Float32Array
63
+ private readonly sz: Float32Array
64
+ private readonly sp: Float32Array
65
+ private readonly light: Float32Array
66
+ private readonly segWidth: Uint8Array
67
+ private readonly buckets: Int32Array[]
68
+ private readonly bucketLen: Int32Array
69
+ private readonly sprites: HTMLCanvasElement[]
70
+ private readonly whiteSprite: HTMLCanvasElement
71
+
72
+ private nodes: FieldNode[] = []
73
+ private nodeIndex = new Map<string, number>()
74
+ private nodeVertex = new Int32Array(0)
75
+ private nx = new Float32Array(0)
76
+ private ny = new Float32Array(0)
77
+ private nz = new Float32Array(0)
78
+ private nsx = new Float32Array(0)
79
+ private nsy = new Float32Array(0)
80
+ private nsz = new Float32Array(0)
81
+ private nsp = new Float32Array(0)
82
+ private nOcc = new Uint8Array(0)
83
+ private flash = new Float32Array(0)
84
+ private order: number[] = []
85
+ private edges: Array<{ s: number; t: number; layer: number }> = []
86
+ private edgePts = new Float32Array(0)
87
+ private edgeScr = new Float32Array(0)
88
+ private neighbours = new Set<number>()
89
+ private pulses: Pulse[] = []
90
+ private edgePulses: EdgePulse[] = []
91
+ private pathCache = new Map<number, { path: Int32Array; cum: Float32Array; total: number }>()
92
+
93
+ private width = 1
94
+ private height = 1
95
+ private dpr = 1
96
+ private yaw = 0.55
97
+ private pitch = 0.34
98
+ private yawVel = 0
99
+ private pitchVel = 0
100
+ private zoom = 1
101
+ private panX = 0
102
+ private panY = 0
103
+ private yawTarget: number | null = null
104
+ private autoRotate = true
105
+ private time = 0
106
+ private last = 0
107
+ private raf = 0
108
+ private running = false
109
+ private dirty = true
110
+ private visible = true
111
+ private selected = -1
112
+ private hover = -1
113
+ private highlight: Set<number> | null = null
114
+ private dimmed = false
115
+ private readonly pointers = new Map<number, { x: number; y: number }>()
116
+ private press: { x: number; y: number; t: number; moved: boolean; button: number; shift: boolean } | null = null
117
+ private pinch = 0
118
+ private readonly disposers: Array<() => void> = []
119
+ private somaScreen = { x: 0, y: 0, r: 0, z: 0 }
120
+ private fBase = 1
121
+ private captionBoxes: Array<{ x: number; y: number; width: number; height: number }> = []
122
+
123
+ constructor(private readonly canvas: HTMLCanvasElement, private readonly tooltip: HTMLElement, private opts: EngineOptions) {
124
+ const ctx = canvas.getContext('2d', { alpha: true })
125
+ if (!ctx) throw new Error('canvas_2d_unavailable')
126
+ this.ctx = ctx
127
+ this.m = buildMorphology()
128
+ const n = this.m.count
129
+ this.sx = new Float32Array(n); this.sy = new Float32Array(n); this.sz = new Float32Array(n); this.sp = new Float32Array(n)
130
+ this.light = new Float32Array(n)
131
+ this.segWidth = new Uint8Array(this.m.segCount)
132
+ for (let i = 0; i < this.m.segCount; i++) {
133
+ const b = this.m.segB[i]!, rad = this.m.r[b]!
134
+ this.segWidth[i] = this.m.kind[b] === 1 ? 3 : rad >= 2.6 ? 0 : rad >= 1.3 ? 1 : rad >= 0.6 ? 2 : 3
135
+ }
136
+ const bucketCount = GROUPS * BANDS * WIDTH_CLASSES * LIGHT_CLASSES
137
+ this.buckets = Array.from({ length: bucketCount }, () => new Int32Array(this.m.segCount))
138
+ this.bucketLen = new Int32Array(bucketCount)
139
+ this.sprites = LAYER_RGB.map(rgb => sprite(rgb, 0.95))
140
+ this.whiteSprite = sprite([235, 242, 255], 1)
141
+ this.autoRotate = !opts.reducedMotion
142
+ this.bindInput()
143
+ this.seedPulses()
144
+ }
145
+
146
+ // ---------- public API ----------
147
+
148
+ setOptions(opts: EngineOptions): void {
149
+ const motionChanged = opts.reducedMotion !== this.opts.reducedMotion
150
+ this.opts = opts
151
+ if (motionChanged) { this.autoRotate = !opts.reducedMotion; this.pulses = []; this.edgePulses = []; if (!opts.reducedMotion) this.seedPulses() }
152
+ this.invalidate()
153
+ }
154
+
155
+ setData(nodes: FieldNode[], edges: FieldEdge[]): void {
156
+ const selectedId = this.selected >= 0 ? this.nodes[this.selected]?.id : undefined
157
+ const hl = this.highlight ? [...this.highlight].map(i => this.nodes[i]?.id).filter(Boolean) as string[] : null
158
+ this.nodes = nodes
159
+ this.nodeIndex = new Map(nodes.map((nd, i) => [nd.id, i]))
160
+ const count = nodes.length
161
+ this.nodeVertex = new Int32Array(count)
162
+ this.nx = new Float32Array(count); this.ny = new Float32Array(count); this.nz = new Float32Array(count)
163
+ this.nsx = new Float32Array(count); this.nsy = new Float32Array(count); this.nsz = new Float32Array(count); this.nsp = new Float32Array(count)
164
+ this.nOcc = new Uint8Array(count)
165
+ this.flash = new Float32Array(count)
166
+ this.placeNodes()
167
+ this.edges = []
168
+ for (const e of edges) {
169
+ const s = this.nodeIndex.get(e.source), t = this.nodeIndex.get(e.target)
170
+ if (s === undefined || t === undefined || s === t) continue
171
+ this.edges.push({ s, t, layer: nodes[t]!.layer })
172
+ }
173
+ this.buildEdgeCurves()
174
+ this.selected = selectedId !== undefined ? (this.nodeIndex.get(selectedId) ?? -1) : -1
175
+ this.highlight = hl ? new Set(hl.map(id => this.nodeIndex.get(id)).filter((v): v is number => v !== undefined)) : null
176
+ this.refreshNeighbours()
177
+ this.pathCache.clear()
178
+ this.pulses = []; this.edgePulses = []
179
+ if (!this.opts.reducedMotion) this.seedPulses()
180
+ this.invalidate()
181
+ }
182
+
183
+ setSelected(id: string | null, focus = true): void {
184
+ const idx = id === null ? -1 : (this.nodeIndex.get(id) ?? -1)
185
+ this.selected = idx
186
+ this.hideTooltip()
187
+ this.refreshNeighbours()
188
+ if (idx >= 0 && focus) {
189
+ const target = Math.atan2(-this.nx[idx]!, -this.nz[idx]!)
190
+ if (this.opts.reducedMotion) { this.yaw = target; this.yawTarget = null } else this.yawTarget = target
191
+ this.flash[idx] = 1
192
+ }
193
+ this.invalidate()
194
+ }
195
+
196
+ /** Emphasise search hits; null clears. Returns how many hits are inside the field. */
197
+ setHighlight(ids: readonly string[] | null): number {
198
+ if (!ids) { this.highlight = null; this.invalidate(); return 0 }
199
+ const set = new Set<number>()
200
+ for (const id of ids) {
201
+ const i = this.nodeIndex.get(id)
202
+ if (i !== undefined) set.add(i)
203
+ // A raw trace may also be projected as anchor/recent nodes.
204
+ for (const prefix of ['anchor:', 'working:']) { const j = this.nodeIndex.get(prefix + id); if (j !== undefined) set.add(j) }
205
+ }
206
+ this.highlight = set
207
+ this.invalidate()
208
+ return set.size
209
+ }
210
+
211
+ setDimmed(dimmed: boolean): void { this.dimmed = dimmed; this.invalidate() }
212
+ setAutoRotate(on: boolean): void { this.autoRotate = on && !this.opts.reducedMotion; this.invalidate() }
213
+ get isAutoRotating(): boolean { return this.autoRotate }
214
+ zoomBy(k: number): void { this.zoomAt(this.width / 2, this.height / 2, k) }
215
+ resetView(): void { this.yaw = 0.55; this.pitch = 0.34; this.zoom = 1; this.panX = 0; this.panY = 0; this.yawTarget = null; this.yawVel = this.pitchVel = 0; this.invalidate() }
216
+
217
+ resize(width: number, height: number): void {
218
+ this.dpr = Math.min(2, window.devicePixelRatio || 1)
219
+ this.width = Math.max(1, width); this.height = Math.max(1, height)
220
+ this.canvas.width = Math.round(this.width * this.dpr)
221
+ this.canvas.height = Math.round(this.height * this.dpr)
222
+ this.fBase = Math.min(this.width / 1060, this.height / 900)
223
+ this.invalidate()
224
+ }
225
+
226
+ start(): void {
227
+ if (this.running) return
228
+ this.running = true
229
+ this.last = performance.now()
230
+ const loop = (now: number) => {
231
+ if (!this.running) return
232
+ this.raf = requestAnimationFrame(loop)
233
+ const dt = Math.min(0.05, (now - this.last) / 1000)
234
+ this.last = now
235
+ if (!this.visible || document.hidden) return
236
+ const animating = !this.opts.reducedMotion
237
+ if (animating || this.dirty || this.press || Math.abs(this.yawVel) > 1e-4 || Math.abs(this.pitchVel) > 1e-4 || this.yawTarget !== null) {
238
+ this.step(dt)
239
+ this.render()
240
+ this.dirty = false
241
+ }
242
+ }
243
+ this.raf = requestAnimationFrame(loop)
244
+ }
245
+
246
+ setVisible(v: boolean): void { this.visible = v; if (v) this.invalidate() }
247
+
248
+ dispose(): void {
249
+ this.running = false
250
+ cancelAnimationFrame(this.raf)
251
+ for (const d of this.disposers) d()
252
+ this.disposers.length = 0
253
+ }
254
+
255
+ /** Test/QA hook: projected screen position of a node, or null. */
256
+ screenOf(id: string): { x: number; y: number } | null {
257
+ const i = this.nodeIndex.get(id)
258
+ return i === undefined ? null : { x: this.nsx[i]!, y: this.nsy[i]! }
259
+ }
260
+
261
+ /** Test/QA hook: camera state proves the motion is a 3D rotation, not a 2D canvas spin. */
262
+ cameraState(): { yaw: number; pitch: number; zoom: number } { return { yaw: this.yaw, pitch: this.pitch, zoom: this.zoom } }
263
+
264
+ /** Screen-space caption bounds for layout regression checks. */
265
+ captionBounds() { return this.captionBoxes.map(b => ({ ...b })) }
266
+
267
+ // ---------- data placement ----------
268
+
269
+ private placeNodes(): void {
270
+ const used = new Set<number>()
271
+ const placed: Array<[number, number, number]> = []
272
+ const hash = (s: string) => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) } return h >>> 0 }
273
+ const minGap = 13
274
+ this.nodes.forEach((node, i) => {
275
+ const sites = this.m.sites[node.layer]
276
+ let h = hash(node.id), chosen = -1
277
+ for (let attempt = 0; attempt < sites.length && attempt < 900; attempt++) {
278
+ const v = sites[(h + attempt * 2654435761) % sites.length >>> 0]!
279
+ if (used.has(v)) continue
280
+ const px = this.m.x[v]!, py = this.m.y[v]!, pz = this.m.z[v]!
281
+ const gap = attempt < 400 ? minGap : minGap * 0.4
282
+ if (placed.some(p => (p[0] - px) ** 2 + (p[1] - py) ** 2 + (p[2] - pz) ** 2 < gap * gap)) continue
283
+ chosen = v
284
+ break
285
+ }
286
+ if (chosen < 0) chosen = sites[h % Math.max(1, sites.length)] ?? 0
287
+ used.add(chosen)
288
+ // A bouton sits just beside its dendrite, offset along a stable direction.
289
+ const a = ((h >>> 8) % 628) / 100, b = ((h >>> 3) % 314) / 100 - 1.57
290
+ const off = 3.5 + this.m.r[chosen]!
291
+ this.nodeVertex[i] = chosen
292
+ this.nx[i] = this.m.x[chosen]! + Math.cos(a) * Math.cos(b) * off
293
+ this.ny[i] = this.m.y[chosen]! + Math.sin(b) * off
294
+ this.nz[i] = this.m.z[chosen]! + Math.sin(a) * Math.cos(b) * off
295
+ placed.push([this.nx[i]!, this.ny[i]!, this.nz[i]!])
296
+ })
297
+ }
298
+
299
+ private buildEdgeCurves(): void {
300
+ const e = this.edges.length
301
+ this.edgePts = new Float32Array(e * EDGE_SAMPLES * 3)
302
+ this.edgeScr = new Float32Array(e * EDGE_SAMPLES * 3)
303
+ this.edges.forEach((edge, k) => {
304
+ const ax = this.nx[edge.s]!, ay = this.ny[edge.s]!, az = this.nz[edge.s]!
305
+ const bx = this.nx[edge.t]!, by = this.ny[edge.t]!, bz = this.nz[edge.t]!
306
+ // Arcs bow toward the neuron axis so inter-stratum links read as passing through the core.
307
+ const cx = (ax + bx) * 0.28, cy = (ay + by) * 0.5, cz = (az + bz) * 0.28
308
+ for (let j = 0; j < EDGE_SAMPLES; j++) {
309
+ const t = j / (EDGE_SAMPLES - 1), u = 1 - t
310
+ const o = (k * EDGE_SAMPLES + j) * 3
311
+ this.edgePts[o] = u * u * ax + 2 * u * t * cx + t * t * bx
312
+ this.edgePts[o + 1] = u * u * ay + 2 * u * t * cy + t * t * by
313
+ this.edgePts[o + 2] = u * u * az + 2 * u * t * cz + t * t * bz
314
+ }
315
+ })
316
+ }
317
+
318
+ private refreshNeighbours(): void {
319
+ this.neighbours = new Set()
320
+ if (this.selected < 0) return
321
+ for (const e of this.edges) {
322
+ if (e.s === this.selected) this.neighbours.add(e.t)
323
+ if (e.t === this.selected) this.neighbours.add(e.s)
324
+ }
325
+ }
326
+
327
+ // ---------- pulses ----------
328
+
329
+ private pathTo(v: number) {
330
+ let cached = this.pathCache.get(v)
331
+ if (cached) return cached
332
+ const list: number[] = []
333
+ for (let c = v; c >= 0 && list.length < 400; c = this.m.parent[c]!) list.push(c)
334
+ list.reverse()
335
+ const path = Int32Array.from(list)
336
+ const cum = new Float32Array(path.length)
337
+ // The soma centre is the implicit origin of every path.
338
+ let total = Math.hypot(this.m.x[path[0]!]!, this.m.y[path[0]!]!, this.m.z[path[0]!]!) - SOMA_RADIUS * 0.5
339
+ cum[0] = total
340
+ for (let i = 1; i < path.length; i++) {
341
+ const a = path[i - 1]!, b = path[i]!
342
+ total += Math.hypot(this.m.x[b]! - this.m.x[a]!, this.m.y[b]! - this.m.y[a]!, this.m.z[b]! - this.m.z[a]!)
343
+ cum[i] = total
344
+ }
345
+ cached = { path, cum, total }
346
+ this.pathCache.set(v, cached)
347
+ return cached
348
+ }
349
+
350
+ private spawnPulse(): Pulse {
351
+ const r = Math.random()
352
+ let v: number, node = -1
353
+ if (this.nodes.length && r < 0.55) {
354
+ node = Math.floor(Math.random() * this.nodes.length)
355
+ v = this.nodeVertex[node]!
356
+ } else {
357
+ const layer = Math.floor(Math.random() * 3) as LayerIndex
358
+ const sites = this.m.sites[layer]
359
+ v = sites[Math.floor(Math.random() * sites.length)] ?? 0
360
+ }
361
+ const p = this.pathTo(v)
362
+ return { ...p, t: Math.random() * 0.15 * p.total, speed: 150 + Math.random() * 170, group: this.m.group[v]!, node }
363
+ }
364
+
365
+ private seedPulses(): void {
366
+ const target = 46
367
+ this.pulses = Array.from({ length: target }, () => { const p = this.spawnPulse(); p.t = Math.random() * p.total; return p })
368
+ this.edgePulses = []
369
+ }
370
+
371
+ // ---------- input ----------
372
+
373
+ private bindInput(): void {
374
+ const c = this.canvas
375
+ const on = <K extends keyof HTMLElementEventMap>(el: HTMLElement, type: K, fn: (e: HTMLElementEventMap[K]) => void, opt?: AddEventListenerOptions) => {
376
+ el.addEventListener(type, fn as EventListener, opt)
377
+ this.disposers.push(() => el.removeEventListener(type, fn as EventListener, opt))
378
+ }
379
+ const local = (e: PointerEvent | WheelEvent | MouseEvent) => { const b = c.getBoundingClientRect(); return { x: e.clientX - b.left, y: e.clientY - b.top } }
380
+ on(c, 'pointerdown', e => {
381
+ c.setPointerCapture(e.pointerId)
382
+ const p = local(e)
383
+ this.pointers.set(e.pointerId, p)
384
+ if (this.pointers.size === 2) {
385
+ const [a, b] = [...this.pointers.values()]
386
+ this.pinch = Math.hypot(a!.x - b!.x, a!.y - b!.y)
387
+ }
388
+ this.press = { x: p.x, y: p.y, t: performance.now(), moved: false, button: e.button, shift: e.shiftKey }
389
+ this.yawTarget = null
390
+ this.yawVel = this.pitchVel = 0
391
+ })
392
+ on(c, 'pointermove', e => {
393
+ const p = local(e)
394
+ const prev = this.pointers.get(e.pointerId)
395
+ if (!prev) { this.updateHover(p.x, p.y); return }
396
+ this.pointers.set(e.pointerId, p)
397
+ if (this.pointers.size === 2) {
398
+ const [a, b] = [...this.pointers.values()]
399
+ const d = Math.hypot(a!.x - b!.x, a!.y - b!.y)
400
+ if (this.pinch > 0) this.zoomAt((a!.x + b!.x) / 2, (a!.y + b!.y) / 2, d / this.pinch)
401
+ this.pinch = d
402
+ if (this.press) this.press.moved = true
403
+ return
404
+ }
405
+ const dx = p.x - prev.x, dy = p.y - prev.y
406
+ if (this.press && Math.hypot(p.x - this.press.x, p.y - this.press.y) > 4) this.press.moved = true
407
+ if (this.press && (this.press.button === 1 || this.press.button === 2 || this.press.shift)) {
408
+ this.panX += dx; this.panY += dy
409
+ } else {
410
+ this.yaw += dx * 0.0062
411
+ this.pitch = Math.max(-1.25, Math.min(1.25, this.pitch + dy * 0.0055))
412
+ this.yawVel = dx * 0.0062 * 60
413
+ this.pitchVel = dy * 0.0055 * 60
414
+ }
415
+ this.hideTooltip()
416
+ this.invalidate()
417
+ })
418
+ const end = (e: PointerEvent) => {
419
+ const press = this.press
420
+ this.pointers.delete(e.pointerId)
421
+ if (this.pointers.size < 2) this.pinch = 0
422
+ if (this.pointers.size === 0) this.press = null
423
+ // pointercancel = the browser took the gesture (e.g. a pan-y scroll on touch): never treat it as a click.
424
+ if (e.type !== 'pointercancel' && press && !press.moved && performance.now() - press.t < 600 && press.button === 0) {
425
+ const p = local(e)
426
+ const hit = this.pick(p.x, p.y)
427
+ this.opts.onSelect(hit >= 0 ? this.nodes[hit]!.id : null)
428
+ }
429
+ if (this.opts.reducedMotion) { this.yawVel = this.pitchVel = 0 }
430
+ }
431
+ on(c, 'pointerup', end)
432
+ on(c, 'pointercancel', end)
433
+ on(c, 'pointerleave', () => { if (!this.press) { this.hover = -1; this.hideTooltip(); this.invalidate() } })
434
+ on(c, 'contextmenu', e => e.preventDefault())
435
+ on(c, 'wheel', e => {
436
+ // In the narrow/stacked layout the canvas is pan-y: a plain wheel scrolls the panel instead of
437
+ // trapping it; pinch (ctrl+wheel) and the toolbar still zoom.
438
+ if (!e.ctrlKey && getComputedStyle(c).touchAction === 'pan-y') return
439
+ e.preventDefault()
440
+ const p = local(e)
441
+ this.zoomAt(p.x, p.y, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0014)))
442
+ }, { passive: false })
443
+ on(c, 'dblclick', () => this.resetView())
444
+ on(c, 'keydown', e => {
445
+ const k = e.key
446
+ if (k === 'ArrowLeft') this.yaw -= 0.12
447
+ else if (k === 'ArrowRight') this.yaw += 0.12
448
+ else if (k === 'ArrowUp') this.pitch = Math.max(-1.25, this.pitch - 0.1)
449
+ else if (k === 'ArrowDown') this.pitch = Math.min(1.25, this.pitch + 0.1)
450
+ else if (k === '+' || k === '=') this.zoomBy(1.15)
451
+ else if (k === '-' || k === '_') this.zoomBy(1 / 1.15)
452
+ else if (k === '0') this.resetView()
453
+ else return
454
+ e.preventDefault()
455
+ this.yawTarget = null
456
+ this.invalidate()
457
+ })
458
+ }
459
+
460
+ private zoomAt(mx: number, my: number, k: number): void {
461
+ const next = Math.max(0.55, Math.min(3.4, this.zoom * k))
462
+ const kk = next / this.zoom
463
+ const cx = this.width / 2, cy = this.height / 2
464
+ this.panX = mx - cx - (mx - cx - this.panX) * kk
465
+ this.panY = my - cy - (my - cy - this.panY) * kk
466
+ if (next <= 1.0001) { this.panX *= 0.85; this.panY *= 0.85 }
467
+ this.zoom = next
468
+ this.invalidate()
469
+ }
470
+
471
+ private pick(x: number, y: number): number {
472
+ let best = -1, bestScore = Infinity
473
+ for (let i = 0; i < this.nodes.length; i++) {
474
+ if (this.nOcc[i]) continue
475
+ const d = Math.hypot(this.nsx[i]! - x, this.nsy[i]! - y)
476
+ const reach = Math.max(12, this.nodeRadius(i) + 8)
477
+ if (d > reach) continue
478
+ const score = d + this.nsz[i]! * 0.004
479
+ if (score < bestScore) { bestScore = score; best = i }
480
+ }
481
+ return best
482
+ }
483
+
484
+ private updateHover(x: number, y: number): void {
485
+ const hit = this.pick(x, y)
486
+ if (hit !== this.hover) { this.hover = hit; this.invalidate() }
487
+ this.canvas.style.cursor = hit >= 0 ? 'pointer' : 'grab'
488
+ if (hit < 0) { this.hideTooltip(); return }
489
+ const node = this.nodes[hit]!
490
+ const tip = this.tooltip
491
+ if (tip.dataset.id !== node.id) {
492
+ tip.dataset.id = node.id
493
+ tip.replaceChildren()
494
+ const kind = document.createElement('span')
495
+ kind.className = 'xjm-tip-kind'
496
+ kind.dataset.layer = String(node.layer)
497
+ kind.textContent = node.kindLabel
498
+ const text = document.createElement('span')
499
+ text.className = 'xjm-tip-text'
500
+ text.textContent = node.label.length > 90 ? node.label.slice(0, 90) + '…' : node.label
501
+ tip.append(kind, text)
502
+ }
503
+ const tx = Math.min(this.width - 300, x + 14), ty = Math.max(8, y - 44)
504
+ tip.style.transform = `translate(${Math.max(8, tx)}px, ${ty}px)`
505
+ tip.hidden = false
506
+ }
507
+
508
+ private hideTooltip(): void { this.tooltip.hidden = true; delete this.tooltip.dataset.id }
509
+
510
+ private invalidate(): void { this.dirty = true }
511
+
512
+ // ---------- simulation ----------
513
+
514
+ private step(dt: number): void {
515
+ this.time += dt
516
+ const dragging = this.press !== null && this.pointers.size > 0
517
+ if (this.yawTarget !== null) {
518
+ let d = this.yawTarget - this.yaw
519
+ d = Math.atan2(Math.sin(d), Math.cos(d))
520
+ this.yaw += d * Math.min(1, dt * 4.2)
521
+ if (Math.abs(d) < 0.002) this.yawTarget = null
522
+ } else if (!dragging) {
523
+ if (Math.abs(this.yawVel) > 1e-4 || Math.abs(this.pitchVel) > 1e-4) {
524
+ this.yaw += this.yawVel * dt
525
+ this.pitch = Math.max(-1.25, Math.min(1.25, this.pitch + this.pitchVel * dt))
526
+ const decay = Math.exp(-dt * 3.2)
527
+ this.yawVel *= decay; this.pitchVel *= decay
528
+ }
529
+ if (this.autoRotate) this.yaw += dt * 0.085
530
+ }
531
+ if (this.opts.reducedMotion) return
532
+ for (let i = 0; i < this.flash.length; i++) if (this.flash[i]! > 0) this.flash[i] = Math.max(0, this.flash[i]! - dt * 1.4)
533
+ for (let i = 0; i < this.pulses.length; i++) {
534
+ const p = this.pulses[i]!
535
+ p.t += p.speed * dt
536
+ if (p.t >= p.total) {
537
+ if (p.node >= 0 && p.node < this.flash.length) this.flash[p.node] = 1
538
+ this.pulses[i] = this.spawnPulse()
539
+ }
540
+ }
541
+ if (this.edges.length) {
542
+ const want = Math.min(28, Math.ceil(this.edges.length * 0.35))
543
+ while (this.edgePulses.length < want) this.edgePulses.push({ edge: Math.floor(Math.random() * this.edges.length), t: -Math.random() * 1.6, speed: 0.35 + Math.random() * 0.3 })
544
+ for (let i = this.edgePulses.length - 1; i >= 0; i--) {
545
+ const ep = this.edgePulses[i]!
546
+ ep.t += ep.speed * dt
547
+ if (ep.t >= 1) {
548
+ const e = this.edges[ep.edge]
549
+ if (e) this.flash[e.t] = Math.max(this.flash[e.t]!, 0.8)
550
+ this.edgePulses.splice(i, 1)
551
+ }
552
+ }
553
+ }
554
+ }
555
+
556
+ // ---------- rendering ----------
557
+
558
+ private nodeRadius(i: number): number {
559
+ const nd = this.nodes[i]!
560
+ return (1.6 + nd.weight * 2.0) * Math.max(0.6, this.nsp[i]! * this.fBase * this.zoom * 1.15)
561
+ }
562
+
563
+ private render(): void {
564
+ const { ctx, m } = this
565
+ const W = this.width, H = this.height
566
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0)
567
+ ctx.clearRect(0, 0, W, H)
568
+
569
+ const f = this.fBase * this.zoom
570
+ const cx = W / 2 + this.panX, cy = H / 2 + this.panY + 8 * f
571
+ const cyaw = Math.cos(this.yaw), syaw = Math.sin(this.yaw)
572
+ const cp = Math.cos(this.pitch), spt = Math.sin(this.pitch)
573
+ const D = 1500
574
+ const project = (x: number, y: number, z: number, out: Float32Array, o: number) => {
575
+ const x1 = x * cyaw - z * syaw
576
+ const z1 = x * syaw + z * cyaw
577
+ const y2 = y * cp - z1 * spt
578
+ const z2 = y * spt + z1 * cp
579
+ const k = D / (D + z2)
580
+ out[o] = cx + x1 * f * k
581
+ out[o + 1] = cy + y2 * f * k
582
+ out[o + 2] = z2
583
+ return k
584
+ }
585
+ const tmp = new Float32Array(3)
586
+
587
+ // Orbiting point light in world space; brightness follows true 3D distance.
588
+ const lt = this.time * 0.22
589
+ const lx = Math.cos(lt) * 300, ly = -120 + Math.sin(lt * 0.7) * 110, lz = Math.sin(lt) * 300
590
+ for (let i = 0; i < m.count; i++) {
591
+ this.sp[i] = project(m.x[i]!, m.y[i]!, m.z[i]!, tmp, 0)
592
+ this.sx[i] = tmp[0]!; this.sy[i] = tmp[1]!; this.sz[i] = tmp[2]!
593
+ const dx = m.x[i]! - lx, dy = m.y[i]! - ly, dz = m.z[i]! - lz
594
+ const ds = m.x[i]! ** 2 + m.y[i]! ** 2 + m.z[i]! ** 2
595
+ this.light[i] = 0.42 + 0.95 / (1 + (dx * dx + dy * dy + dz * dz) / 52000) + 0.55 / (1 + ds / 26000)
596
+ }
597
+ const somaK = project(0, 0, 0, tmp, 0)
598
+ this.somaScreen = { x: tmp[0]!, y: tmp[1]!, r: SOMA_RADIUS * f * somaK, z: tmp[2]! }
599
+ const lightK = project(lx, ly, lz, tmp, 0)
600
+ const lightScreen = { x: tmp[0]!, y: tmp[1]!, z: tmp[2]!, k: lightK }
601
+
602
+ // Depth bands relative to the soma: 0..2 behind it, 3..5 in front.
603
+ const somaZ = this.somaScreen.z
604
+ const band = (z: number) => {
605
+ const d = z - somaZ
606
+ return d > 230 ? 0 : d > 110 ? 1 : d > 0 ? 2 : d > -110 ? 3 : d > -230 ? 4 : 5
607
+ }
608
+ const fogOf = (b: number) => [0.3, 0.48, 0.68, 0.84, 0.95, 1][b]!
609
+
610
+ this.bucketLen.fill(0)
611
+ for (let s = 0; s < m.segCount; s++) {
612
+ const a = m.segA[s]!, b = m.segB[s]!
613
+ const bd = band((this.sz[a]! + this.sz[b]!) * 0.5)
614
+ const li = (this.light[a]! + this.light[b]!) * 0.5
615
+ const lc = li < 0.78 ? 0 : li < 1.12 ? 1 : 2
616
+ const key = ((m.group[b]! * BANDS + bd) * WIDTH_CLASSES + this.segWidth[s]!) * LIGHT_CLASSES + lc
617
+ this.buckets[key]![this.bucketLen[key]!++] = s
618
+ }
619
+
620
+ // Records.
621
+ const nCount = this.nodes.length
622
+ for (let i = 0; i < nCount; i++) {
623
+ this.nsp[i] = project(this.nx[i]!, this.ny[i]!, this.nz[i]!, tmp, 0)
624
+ this.nsx[i] = tmp[0]!; this.nsy[i] = tmp[1]!; this.nsz[i] = tmp[2]!
625
+ const behind = this.nsz[i]! > somaZ
626
+ this.nOcc[i] = behind && Math.hypot(this.nsx[i]! - this.somaScreen.x, this.nsy[i]! - this.somaScreen.y) < this.somaScreen.r * 0.96 ? 1 : 0
627
+ }
628
+ for (let e = 0; e < this.edges.length; e++) {
629
+ for (let j = 0; j < EDGE_SAMPLES; j++) {
630
+ const o = (e * EDGE_SAMPLES + j) * 3
631
+ project(this.edgePts[o]!, this.edgePts[o + 1]!, this.edgePts[o + 2]!, this.edgeScr, o)
632
+ }
633
+ }
634
+ this.order = Array.from({ length: nCount }, (_, i) => i).sort((a, b) => this.nsz[b]! - this.nsz[a]!)
635
+
636
+ const dim = this.dimmed ? 0.45 : 1
637
+ const zoomW = Math.max(0.7, Math.min(2.4, f * 1.25))
638
+ this.drawStrata(project, dim, true)
639
+ this.drawDust(project, dim)
640
+
641
+ let nodeCursor = 0
642
+ for (let bd = 0; bd < BANDS; bd++) {
643
+ this.drawFibreBand(bd, fogOf(bd) * dim, zoomW)
644
+ this.drawEdgeBand(bd, band, fogOf(bd) * dim, zoomW)
645
+ // Records belonging to this band, back to front.
646
+ while (nodeCursor < this.order.length && band(this.nsz[this.order[nodeCursor]!]!) <= bd) {
647
+ this.drawNode(this.order[nodeCursor]!, fogOf(bd), dim)
648
+ nodeCursor++
649
+ }
650
+ if (bd === 2) { this.drawPulses(project, false, somaZ); this.drawSoma(lightScreen, dim) }
651
+ }
652
+ this.drawPulses(project, true, somaZ)
653
+ this.drawStrata(project, dim, false)
654
+ this.drawSelection()
655
+ }
656
+
657
+ private drawFibreBand(bd: number, fog: number, zoomW: number): void {
658
+ const { ctx, m } = this
659
+ const widths = [2.5, 1.35, 0.78, 0.42]
660
+ const lightAlpha = [0.34, 0.56, 0.86]
661
+ ctx.lineCap = 'round'
662
+ for (let g = 0; g < GROUPS; g++) {
663
+ const rgb = LAYER_RGB[g]!
664
+ for (let w = 0; w < WIDTH_CLASSES; w++) {
665
+ for (let lc = 0; lc < LIGHT_CLASSES; lc++) {
666
+ const key = ((g * BANDS + bd) * WIDTH_CLASSES + w) * LIGHT_CLASSES + lc
667
+ const len = this.bucketLen[key]!
668
+ if (!len) continue
669
+ const list = this.buckets[key]!
670
+ ctx.beginPath()
671
+ for (let i = 0; i < len; i++) {
672
+ const s = list[i]!, a = m.segA[s]!, b = m.segB[s]!
673
+ ctx.moveTo(this.sx[a]!, this.sy[a]!)
674
+ ctx.lineTo(this.sx[b]!, this.sy[b]!)
675
+ }
676
+ const alpha = lightAlpha[lc]! * fog * (w === 3 ? 0.7 : 1)
677
+ const lw = widths[w]! * zoomW * (0.82 + bd * 0.05)
678
+ if (w <= 1) {
679
+ ctx.globalCompositeOperation = 'lighter'
680
+ ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(alpha * 0.07).toFixed(3)})`
681
+ ctx.lineWidth = lw * 4.2
682
+ ctx.stroke()
683
+ }
684
+ ctx.globalCompositeOperation = bd >= 3 ? 'lighter' : 'source-over'
685
+ // Lit side is whiter; this sells the point light on thick trunks.
686
+ const lift = lc === 2 ? 0.32 : lc === 1 ? 0.12 : 0
687
+ const r = Math.round(rgb[0] + (255 - rgb[0]) * lift), gg = Math.round(rgb[1] + (255 - rgb[1]) * lift), b2 = Math.round(rgb[2] + (255 - rgb[2]) * lift)
688
+ ctx.strokeStyle = `rgba(${r},${gg},${b2},${alpha.toFixed(3)})`
689
+ ctx.lineWidth = lw
690
+ ctx.stroke()
691
+ }
692
+ }
693
+ }
694
+ ctx.globalCompositeOperation = 'source-over'
695
+ }
696
+
697
+ private drawEdgeBand(bd: number, band: (z: number) => number, fog: number, zoomW: number): void {
698
+ if (!this.edges.length) return
699
+ const { ctx } = this
700
+ const sel = this.selected
701
+ ctx.lineCap = 'round'
702
+ for (let pass = 0; pass < 2; pass++) {
703
+ for (let layer = 0; layer < 3; layer++) {
704
+ ctx.beginPath()
705
+ let any = false
706
+ for (let e = 0; e < this.edges.length; e++) {
707
+ const edge = this.edges[e]!
708
+ if (edge.layer !== layer) continue
709
+ const active = sel >= 0 && (edge.s === sel || edge.t === sel)
710
+ const hl = this.highlight !== null && (this.highlight.has(edge.s) || this.highlight.has(edge.t))
711
+ if ((pass === 1) !== (active || hl)) continue
712
+ let pen = false
713
+ for (let j = 0; j < EDGE_SAMPLES - 1; j++) {
714
+ const o = (e * EDGE_SAMPLES + j) * 3
715
+ const z = (this.edgeScr[o + 2]! + this.edgeScr[o + 5]!) * 0.5
716
+ if (band(z) !== bd) { pen = false; continue }
717
+ if (!pen) { ctx.moveTo(this.edgeScr[o]!, this.edgeScr[o + 1]!); pen = true }
718
+ ctx.lineTo(this.edgeScr[o + 3]!, this.edgeScr[o + 4]!)
719
+ any = true
720
+ }
721
+ }
722
+ if (!any) continue
723
+ const rgb = LAYER_RGB[layer]!
724
+ const quiet = sel >= 0 || this.highlight !== null
725
+ const alpha = pass === 1 ? 0.9 : quiet ? 0.1 : 0.27
726
+ ctx.globalCompositeOperation = 'lighter'
727
+ ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(alpha * fog * 0.35).toFixed(3)})`
728
+ ctx.lineWidth = (pass === 1 ? 5 : 3) * zoomW
729
+ ctx.stroke()
730
+ ctx.strokeStyle = `rgba(${Math.min(255, rgb[0] + 40)},${Math.min(255, rgb[1] + 30)},${Math.min(255, rgb[2] + 30)},${(alpha * fog).toFixed(3)})`
731
+ ctx.lineWidth = (pass === 1 ? 1.4 : 0.8) * zoomW
732
+ ctx.stroke()
733
+ }
734
+ }
735
+ ctx.globalCompositeOperation = 'source-over'
736
+ }
737
+
738
+ private drawNode(i: number, fog: number, dim: number): void {
739
+ const { ctx } = this
740
+ const nd = this.nodes[i]!
741
+ const x = this.nsx[i]!, y = this.nsy[i]!
742
+ let alpha = fog * (this.nOcc[i] ? 0 : 1)
743
+ if (alpha <= 0) return
744
+ const focusSet = this.highlight
745
+ const isSel = i === this.selected, isHover = i === this.hover
746
+ const related = isSel || this.neighbours.has(i)
747
+ if (focusSet && !focusSet.has(i)) alpha *= 0.22
748
+ else if (this.selected >= 0 && !related) alpha *= 0.45
749
+ alpha *= dim
750
+ const r = this.nodeRadius(i) * (isHover || isSel ? 1.35 : 1)
751
+ const fl = this.flash[i]!
752
+ ctx.globalCompositeOperation = 'lighter'
753
+ ctx.globalAlpha = Math.min(1, alpha * (0.38 + fl * 0.6 + (focusSet?.has(i) ? 0.4 : 0)))
754
+ const halo = r * (3.2 + fl * 3)
755
+ ctx.drawImage(this.sprites[nd.layer]!, x - halo, y - halo, halo * 2, halo * 2)
756
+ ctx.globalAlpha = Math.min(1, alpha)
757
+ ctx.globalCompositeOperation = 'source-over'
758
+ const rgb = LAYER_RGB[nd.layer]!
759
+ ctx.fillStyle = `rgb(${Math.round(rgb[0] * 0.35 + 165)},${Math.round(rgb[1] * 0.35 + 165)},${Math.round(rgb[2] * 0.35 + 165)})`
760
+ if (nd.derived) {
761
+ // Derived/read-only records: a diamond outline, never the same glyph as raw text.
762
+ ctx.beginPath()
763
+ ctx.moveTo(x, y - r * 1.25); ctx.lineTo(x + r * 1.25, y); ctx.lineTo(x, y + r * 1.25); ctx.lineTo(x - r * 1.25, y); ctx.closePath()
764
+ ctx.strokeStyle = ctx.fillStyle
765
+ ctx.lineWidth = Math.max(1, r * 0.42)
766
+ ctx.stroke()
767
+ } else {
768
+ ctx.beginPath()
769
+ ctx.arc(x, y, r, 0, TAU)
770
+ ctx.fill()
771
+ }
772
+ ctx.globalAlpha = 1
773
+ }
774
+
775
+ private drawSoma(light: { x: number; y: number }, dim: number): void {
776
+ const { ctx } = this
777
+ const { x, y, r } = this.somaScreen
778
+ // Bloom behind the cell body.
779
+ ctx.globalCompositeOperation = 'lighter'
780
+ const bloom = ctx.createRadialGradient(x, y, r * 0.6, x, y, r * 5.2)
781
+ bloom.addColorStop(0, `rgba(170,196,255,${0.26 * dim})`)
782
+ bloom.addColorStop(0.35, `rgba(120,150,230,${0.08 * dim})`)
783
+ bloom.addColorStop(1, 'rgba(90,110,200,0)')
784
+ ctx.fillStyle = bloom
785
+ ctx.fillRect(x - r * 5.2, y - r * 5.2, r * 10.4, r * 10.4)
786
+ ctx.globalCompositeOperation = 'source-over'
787
+ // Opaque membrane: occludes everything painted behind it.
788
+ const dx = light.x - x, dy = light.y - y, dl = Math.hypot(dx, dy) || 1
789
+ const hx = x + (dx / dl) * r * 0.42, hy = y + (dy / dl) * r * 0.42
790
+ const body = ctx.createRadialGradient(hx, hy, r * 0.05, x, y, r)
791
+ body.addColorStop(0, '#f3f6ff')
792
+ body.addColorStop(0.32, '#a9bce6')
793
+ body.addColorStop(0.72, '#46557c')
794
+ body.addColorStop(0.94, '#1a2032')
795
+ body.addColorStop(1, '#2a3555')
796
+ ctx.fillStyle = body
797
+ ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill()
798
+ // Nucleus and nucleolus, offset away from the light.
799
+ const nxp = x - (dx / dl) * r * 0.12, nyp = y - (dy / dl) * r * 0.12
800
+ const nuc = ctx.createRadialGradient(nxp, nyp, 0, nxp, nyp, r * 0.5)
801
+ nuc.addColorStop(0, 'rgba(40,52,88,0.55)')
802
+ nuc.addColorStop(0.8, 'rgba(60,76,120,0.35)')
803
+ nuc.addColorStop(1, 'rgba(60,76,120,0)')
804
+ ctx.fillStyle = nuc
805
+ ctx.beginPath(); ctx.arc(nxp, nyp, r * 0.5, 0, TAU); ctx.fill()
806
+ ctx.fillStyle = 'rgba(225,235,255,0.85)'
807
+ ctx.beginPath(); ctx.arc(nxp + r * 0.1, nyp - r * 0.06, Math.max(1.2, r * 0.1), 0, TAU); ctx.fill()
808
+ // Rim light.
809
+ ctx.strokeStyle = 'rgba(190,210,255,0.55)'
810
+ ctx.lineWidth = Math.max(1, r * 0.05)
811
+ ctx.beginPath(); ctx.arc(x, y, r * 0.98, 0, TAU); ctx.stroke()
812
+ }
813
+
814
+ private drawPulses(project: (x: number, y: number, z: number, out: Float32Array, o: number) => number, front: boolean, somaZ: number): void {
815
+ if (this.opts.reducedMotion) return
816
+ const { ctx, m } = this
817
+ const tmp = new Float32Array(3)
818
+ ctx.globalCompositeOperation = 'lighter'
819
+ const at = (p: Pulse, t: number): [number, number, number] | null => {
820
+ if (t < 0) return null
821
+ const cum = p.cum
822
+ let lo = 0, hi = cum.length - 1
823
+ if (t <= cum[0]!) {
824
+ const v = p.path[0]!, k = t / cum[0]!
825
+ return [m.x[v]! * k, m.y[v]! * k, m.z[v]! * k]
826
+ }
827
+ while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (cum[mid]! < t) lo = mid; else hi = mid }
828
+ const a = p.path[lo]!, b = p.path[hi]!
829
+ const k = (t - cum[lo]!) / Math.max(1e-3, cum[hi]! - cum[lo]!)
830
+ return [m.x[a]! + (m.x[b]! - m.x[a]!) * k, m.y[a]! + (m.y[b]! - m.y[a]!) * k, m.z[a]! + (m.z[b]! - m.z[a]!) * k]
831
+ }
832
+ for (const p of this.pulses) {
833
+ const head = at(p, p.t)
834
+ if (!head) continue
835
+ const k = project(head[0], head[1], head[2], tmp, 0)
836
+ if ((tmp[2]! <= somaZ) !== front) continue
837
+ const hx = tmp[0]!, hy = tmp[1]!
838
+ const fade = Math.min(1, p.t / 60) * Math.min(1, (p.total - p.t) / 40 + 0.3)
839
+ const rgb = LAYER_RGB[p.group]!
840
+ ctx.beginPath()
841
+ ctx.moveTo(hx, hy)
842
+ for (let s = 1; s <= 6; s++) {
843
+ const q = at(p, p.t - s * 9)
844
+ if (!q) break
845
+ project(q[0], q[1], q[2], tmp, 0)
846
+ ctx.lineTo(tmp[0]!, tmp[1]!)
847
+ }
848
+ ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(0.55 * fade).toFixed(3)})`
849
+ ctx.lineWidth = 1.6 * k * Math.max(0.8, this.fBase * this.zoom)
850
+ ctx.stroke()
851
+ const s = 9 * k * Math.max(0.7, this.fBase * this.zoom)
852
+ ctx.globalAlpha = 0.9 * fade
853
+ ctx.drawImage(this.whiteSprite, hx - s, hy - s, s * 2, s * 2)
854
+ ctx.globalAlpha = 1
855
+ }
856
+ for (const ep of this.edgePulses) {
857
+ if (ep.t < 0) continue
858
+ const pos = ep.t * (EDGE_SAMPLES - 1), j = Math.min(EDGE_SAMPLES - 2, Math.floor(pos)), u = pos - j
859
+ const o = (ep.edge * EDGE_SAMPLES + j) * 3
860
+ const x = this.edgeScr[o]! + (this.edgeScr[o + 3]! - this.edgeScr[o]!) * u
861
+ const y = this.edgeScr[o + 1]! + (this.edgeScr[o + 4]! - this.edgeScr[o + 1]!) * u
862
+ const z = this.edgeScr[o + 2]!
863
+ if ((z <= somaZ) !== front) continue
864
+ const edge = this.edges[ep.edge]!
865
+ const s = 11 * Math.max(0.7, this.fBase * this.zoom)
866
+ ctx.globalAlpha = 0.85
867
+ ctx.drawImage(this.sprites[edge.layer]!, x - s, y - s, s * 2, s * 2)
868
+ ctx.globalAlpha = 1
869
+ }
870
+ ctx.globalCompositeOperation = 'source-over'
871
+ }
872
+
873
+ private drawDust(project: (x: number, y: number, z: number, out: Float32Array, o: number) => number, dim: number): void {
874
+ const { ctx } = this
875
+ const tmp = new Float32Array(3)
876
+ ctx.globalCompositeOperation = 'lighter'
877
+ for (let li = 0; li < 3; li++) {
878
+ const d = this.m.dust[li]!
879
+ const rgb = LAYER_RGB[li]!
880
+ ctx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`
881
+ for (let i = 0; i < d.length; i += 4) {
882
+ const k = project(d[i]!, d[i + 1]!, d[i + 2]!, tmp, 0)
883
+ const tw = this.opts.reducedMotion ? 0.7 : 0.55 + 0.45 * Math.sin(this.time * 0.9 + d[i + 3]!)
884
+ const depth = Math.max(0.15, Math.min(1, 1 - (tmp[2]! - this.somaScreen.z) / 700))
885
+ ctx.globalAlpha = 0.32 * tw * depth * dim
886
+ const s = Math.max(0.8, 1.5 * k * this.fBase * this.zoom)
887
+ ctx.fillRect(tmp[0]! - s / 2, tmp[1]! - s / 2, s, s)
888
+ }
889
+ }
890
+ ctx.globalAlpha = 1
891
+ ctx.globalCompositeOperation = 'source-over'
892
+ }
893
+
894
+ /** Stratum boundary rings: back halves drawn first, front halves last, plus captions. */
895
+ private drawStrata(project: (x: number, y: number, z: number, out: Float32Array, o: number) => number, dim: number, back: boolean): void {
896
+ const { ctx } = this
897
+ const tmp = new Float32Array(3)
898
+ const steps = 120
899
+ const somaZ = this.somaScreen.z
900
+ const captions: Array<{ label: string; li: number; x: number; y: number; w: number; anchorX: number; anchorY: number }> = []
901
+ for (let li = 0; li < 3; li++) {
902
+ const st = STRATA[li]!
903
+ const rgb = LAYER_RGB[li]!
904
+ for (const [ratio, dash, alpha] of [[1.08, [], 0.3], [0.62, [2, 7], 0.17]] as Array<[number, number[], number]>) {
905
+ ctx.beginPath()
906
+ let pen = false
907
+ for (let i = 0; i <= steps; i++) {
908
+ const a = (i / steps) * TAU
909
+ project(Math.cos(a) * st.radius * ratio, st.y, Math.sin(a) * st.radius * ratio, tmp, 0)
910
+ const isBack = tmp[2]! > somaZ
911
+ if (isBack !== back) { pen = false; continue }
912
+ if (!pen) { ctx.moveTo(tmp[0]!, tmp[1]!); pen = true } else ctx.lineTo(tmp[0]!, tmp[1]!)
913
+ }
914
+ ctx.setLineDash(dash)
915
+ ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(alpha * (back ? 0.55 : 1) * dim).toFixed(3)})`
916
+ ctx.lineWidth = 1
917
+ ctx.stroke()
918
+ }
919
+ ctx.setLineDash([])
920
+ // Tick marks on the outer ring every 15 degrees.
921
+ ctx.beginPath()
922
+ for (let i = 0; i < 24; i++) {
923
+ const a = (i / 24) * TAU
924
+ const R1 = st.radius * 1.08, R2 = st.radius * (i % 6 === 0 ? 1.15 : 1.11)
925
+ project(Math.cos(a) * R1, st.y, Math.sin(a) * R1, tmp, 0)
926
+ if ((tmp[2]! > somaZ) !== back) continue
927
+ const x1 = tmp[0]!, y1 = tmp[1]!
928
+ project(Math.cos(a) * R2, st.y, Math.sin(a) * R2, tmp, 0)
929
+ ctx.moveTo(x1, y1); ctx.lineTo(tmp[0]!, tmp[1]!)
930
+ }
931
+ ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${(0.4 * (back ? 0.5 : 1) * dim).toFixed(3)})`
932
+ ctx.stroke()
933
+ if (!back) {
934
+ // Caption anchored at the ring's right-most projected point.
935
+ let bx = -Infinity, by = 0
936
+ for (let i = 0; i < 48; i++) {
937
+ const a = (i / 48) * TAU
938
+ project(Math.cos(a) * st.radius * 1.15, st.y, Math.sin(a) * st.radius * 1.15, tmp, 0)
939
+ if (tmp[0]! > bx) { bx = tmp[0]!; by = tmp[1]! }
940
+ }
941
+ const label = this.opts.layerCaptions[li]!
942
+ ctx.font = '600 14px ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'
943
+ const w = ctx.measureText(label).width
944
+ const lx = Math.max(12, Math.min(this.width - w - 18, bx + 12))
945
+ captions.push({ label, li, x: lx, y: by + 5, w, anchorX: bx, anchorY: by })
946
+ }
947
+ }
948
+ if (!back) {
949
+ // Resolve captions in screen space after all three strata project. Near edge-on / tilted
950
+ // views their natural anchors can coincide; labels must stay separate and in the viewport.
951
+ const gap = 32
952
+ const minY = Math.min(this.height * 0.35, this.width < 450 ? 174 : 116)
953
+ const maxY = Math.max(minY + gap * 2, this.height - 104)
954
+ captions.sort((a, b) => a.y - b.y)
955
+ for (let i = 0; i < captions.length; i++) {
956
+ captions[i]!.y = Math.max(minY, Math.min(maxY, captions[i]!.y), i ? captions[i - 1]!.y + gap : minY)
957
+ }
958
+ for (let i = captions.length - 1; i >= 0; i--) {
959
+ captions[i]!.y = Math.min(captions[i]!.y, i < captions.length - 1 ? captions[i + 1]!.y - gap : maxY)
960
+ }
961
+ this.captionBoxes = []
962
+ const textDim = Math.max(dim, 0.78)
963
+ for (const c of captions) {
964
+ const rgb = LAYER_RGB[c.li]!
965
+ if (Math.abs(c.y - c.anchorY) > 20) {
966
+ ctx.beginPath(); ctx.moveTo(c.anchorX, c.anchorY); ctx.lineTo(c.x - 10, c.y - 4)
967
+ ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.32)`; ctx.lineWidth = 1; ctx.stroke()
968
+ }
969
+ ctx.fillStyle = `rgba(8,10,16,${0.80 * textDim})`
970
+ ctx.beginPath(); ctx.roundRect(c.x - 8, c.y - 16, c.w + 16, 23, 6); ctx.fill()
971
+ ctx.font = '600 14px ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'
972
+ ctx.fillStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${0.97 * textDim})`
973
+ ctx.fillText(c.label, c.x, c.y)
974
+ this.captionBoxes.push({ x: c.x - 8, y: c.y - 16, width: c.w + 16, height: 23 })
975
+ }
976
+ }
977
+ }
978
+
979
+ private drawSelection(): void {
980
+ const { ctx } = this
981
+ const mark = (i: number, strong: boolean) => {
982
+ if (i < 0 || i >= this.nodes.length || this.nOcc[i]) return
983
+ const x = this.nsx[i]!, y = this.nsy[i]!, r = this.nodeRadius(i) * 1.35 + 7
984
+ const rgb = LAYER_RGB[this.nodes[i]!.layer]!
985
+ ctx.strokeStyle = `rgba(${Math.min(255, rgb[0] + 60)},${Math.min(255, rgb[1] + 50)},${Math.min(255, rgb[2] + 40)},${strong ? 0.95 : 0.6})`
986
+ ctx.lineWidth = strong ? 1.5 : 1
987
+ ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.stroke()
988
+ if (strong) {
989
+ ctx.beginPath()
990
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
991
+ ctx.moveTo(x + dx * (r + 3), y + dy * (r + 3)); ctx.lineTo(x + dx * (r + 9), y + dy * (r + 9))
992
+ }
993
+ ctx.stroke()
994
+ }
995
+ }
996
+ if (this.highlight) for (const i of this.highlight) if (i !== this.selected) mark(i, false)
997
+ mark(this.selected, true)
998
+ }
999
+ }