dsh-plugin-xujie-memory 0.5.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.
- package/ACCEPTANCE.md +37 -0
- package/BUILD-PROVENANCE.json +53 -0
- package/CHANGELOG.md +40 -1
- package/LICENSE +21 -21
- package/README.md +67 -17
- package/README_EN.md +54 -8
- package/cordis.patch.yml +49 -49
- package/lib/client.js +17219 -0
- package/lib/controller.js +307 -0
- package/lib/index.js +200 -14
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +15104 -0
- package/lib/typert.remote-client.d.ts +40 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +15093 -0
- package/lib/types.d.ts +13 -0
- package/lib/types.js +1 -0
- package/package.json +49 -5
- package/python/specs/characters/aloof.json +14 -14
- package/python/specs/characters/gentle.json +14 -14
- package/python/specs/characters/tsundere.json +14 -14
- package/python/specs/debt.v1.json +31 -31
- package/python/specs/nickname.v1.json +16 -16
- package/python/specs/preference.v1.json +16 -16
- package/python/xujie_memory/__init__.py +123 -123
- package/python/xujie_memory/admission.py +211 -211
- package/python/xujie_memory/audit.py +377 -377
- package/python/xujie_memory/benchmark.py +281 -281
- package/python/xujie_memory/character.py +199 -199
- package/python/xujie_memory/console.py +41 -41
- package/python/xujie_memory/consolidate.py +356 -356
- package/python/xujie_memory/due.py +121 -121
- package/python/xujie_memory/emotion.py +573 -573
- package/python/xujie_memory/engine.py +1333 -1312
- package/python/xujie_memory/errors.py +98 -98
- package/python/xujie_memory/importance.py +104 -104
- package/python/xujie_memory/judge.py +350 -350
- package/python/xujie_memory/management.py +203 -0
- package/python/xujie_memory/memory.py +71 -10
- package/python/xujie_memory/orchestrator.py +690 -666
- package/python/xujie_memory/phrasing.py +292 -292
- package/python/xujie_memory/privacy.py +105 -0
- package/python/xujie_memory/schema.sql +492 -480
- package/python/xujie_memory/server.py +76 -0
- package/python/xujie_memory/specs.py +170 -170
- package/python/xujie_memory/summary.py +238 -238
- package/scripts/build.mjs +19 -0
- package/scripts/prepack.mjs +9 -0
- package/scripts/test-all.mjs +9 -0
- package/scripts/test-node.mjs +10 -0
- package/src/client/MemoryPanel.tsx +536 -0
- package/src/client/NeuralField.tsx +88 -0
- package/src/client/css.d.ts +4 -0
- package/src/client/icons.tsx +28 -0
- package/src/client/index.tsx +59 -0
- package/src/client/memory-adapter.ts +67 -0
- package/src/client/model.ts +79 -0
- package/src/client/neural/engine.ts +999 -0
- package/src/client/neural/morphology.ts +219 -0
- package/src/client/panel.css +337 -0
- package/src/client/types.ts +6 -0
- package/src/controller.ts +88 -0
- package/src/generated/typert.host.js +449 -0
- package/src/generated/typert.remote-client.js +438 -0
- package/src/index.ts +2 -0
- package/src/types.ts +13 -0
- package/test/cordis-fixture.mjs +5 -0
- package/test/management_regression.py +187 -0
- package/test/node-regression.entry.mjs +52 -0
- package/vendor/DSH-LICENSE.txt +21 -0
- package/vendor/THIRD-PARTY.md +6 -0
- package/vendor/dsh-protocol/index.ts +484 -0
- package/vendor/dsh-protocol/json-value.ts +53 -0
- package/vendor/dsh-protocol/owned-value.ts +39 -0
- package/vendor/dsh-protocol/principal.ts +17 -0
- package/vendor/dsh-protocol/remote-access.ts +38 -0
- package/vendor/dsh-protocol/remote-error.ts +49 -0
- package/vendor/dsh-protocol/remote-info.ts +161 -0
- package/vendor/dsh-protocol/types.ts +939 -0
- package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
- 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
|
+
}
|