dsh-memory-cloud 0.0.0-stage → 1.0.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/nebula.js ADDED
@@ -0,0 +1,1551 @@
1
+ /* Memory Nebula — 3D star-field renderer, no external dependencies.
2
+ * Each DSH Session is a nebula cluster; each conversation fragment is a star
3
+ * orbiting its cluster in 3D. Pure Canvas 2D with a hand-rolled perspective
4
+ * projection, painter's-algorithm depth sorting and additive glow. */
5
+ (function () {
6
+ 'use strict'
7
+
8
+ const canvas = document.getElementById('sky')
9
+ const ctx = canvas.getContext('2d', { alpha: false })
10
+ const el = {
11
+ subtitle: document.getElementById('subtitle'),
12
+ search: document.getElementById('search'),
13
+ autorotate: document.getElementById('autorotate'),
14
+ reset: document.getElementById('reset'),
15
+ close: document.getElementById('close'),
16
+ legend: document.getElementById('legend'),
17
+ relationKey: document.getElementById('relation-key'),
18
+ stats: document.getElementById('stats'),
19
+ skinButtons: [...document.querySelectorAll('.skin-btn')],
20
+ tooltip: document.getElementById('tooltip'),
21
+ detail: document.getElementById('detail'),
22
+ detailTitle: document.getElementById('detail-title'),
23
+ detailMeta: document.getElementById('detail-meta'),
24
+ detailFragments: document.getElementById('detail-fragments'),
25
+ detailClose: document.getElementById('detail-close'),
26
+ loading: document.getElementById('loading'),
27
+ }
28
+
29
+ // Deterministic hash → PRNG so a Session always lands in the same place.
30
+ function hash(str) {
31
+ let h = 2166136261
32
+ for (let i = 0; i < str.length; i += 1) {
33
+ h ^= str.charCodeAt(i)
34
+ h = Math.imul(h, 16777619)
35
+ }
36
+ return h >>> 0
37
+ }
38
+ function rng(seed) {
39
+ let s = seed || 1
40
+ return () => {
41
+ s ^= s << 13; s ^= s >>> 17; s ^= s << 5
42
+ return ((s >>> 0) % 100000) / 100000
43
+ }
44
+ }
45
+
46
+ const PALETTE = [
47
+ [120, 170, 255], [180, 130, 255], [110, 225, 200],
48
+ [255, 160, 110], [255, 130, 190], [140, 210, 255],
49
+ [230, 200, 120], [160, 160, 255], [120, 235, 170], [255, 190, 140],
50
+ ]
51
+ const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Soft radial sprite cache.
55
+ //
56
+ // A solid ellipse has a hard rim; a per-puff radial gradient is a natural
57
+ // feather but far too slow on Android WebView. Pre-render ONE small radial
58
+ // sprite per colour and reuse it via drawImage: soft NASA-style falloff with
59
+ // no per-frame gradient allocation and no hard edges.
60
+ // ---------------------------------------------------------------------------
61
+ const SPRITE_SIZE = 64
62
+ const spriteCache = new Map()
63
+ function softSprite(color) {
64
+ const key = `${color[0]},${color[1]},${color[2]}`
65
+ let sprite = spriteCache.get(key)
66
+ if (sprite) return sprite
67
+ const cv = document.createElement('canvas')
68
+ cv.width = cv.height = SPRITE_SIZE
69
+ const g = cv.getContext('2d')
70
+ const r = SPRITE_SIZE / 2
71
+ const grad = g.createRadialGradient(r, r, 0, r, r, r)
72
+ // A gentle, multi-stop falloff — no plateau, no rim.
73
+ grad.addColorStop(0, rgba(color, 1))
74
+ grad.addColorStop(0.18, rgba(color, 0.72))
75
+ grad.addColorStop(0.42, rgba(color, 0.32))
76
+ grad.addColorStop(0.68, rgba(color, 0.09))
77
+ grad.addColorStop(0.88, rgba(color, 0.02))
78
+ grad.addColorStop(1, rgba(color, 0))
79
+ g.fillStyle = grad
80
+ g.fillRect(0, 0, SPRITE_SIZE, SPRITE_SIZE)
81
+ sprite = cv
82
+ spriteCache.set(key, sprite)
83
+ return sprite
84
+ }
85
+
86
+ // One shared signal rhythm for BOTH skins: a slow, CONTINUOUS current.
87
+ // Two staggered brightness crests keep charge flowing at every instant —
88
+ // this is not a one-off lightning flash that lights everything then dies.
89
+ // Phase advances with the real wall clock (continuous, no quantisation), so
90
+ // the flow is as smooth as the display allows.
91
+ const SIGNAL_CYCLE = 7200
92
+ // A wider crest plus a longer wake reads as flowing liquid rather than a
93
+ // single bright bead; the gradient is sampled finely enough that the peak
94
+ // moves sub-stop instead of snapping from sample to sample.
95
+ const WAKE_DECAY = 0.18
96
+ const WAKE_CUTOFF = 0.34
97
+ const CREST_WIDTH = 0.085
98
+ const CRESTS = [0, 0.5]
99
+
100
+ /**
101
+ * Brightness at position t from a crest at p. Ahead of the crest (lag < 0)
102
+ * the wire is PURE BLACK — the old wrap-around made every link faintly lit
103
+ * all the time, which is why the currents never seemed to rest.
104
+ */
105
+ /**
106
+ * Brightness at position t from a crest at p. Ahead of the crest (lag < 0)
107
+ * the wire is PURE BLACK. Behind it the brightness decays as one continuous
108
+ * curve: a gaussian leading edge that hands over to an exponential wake with
109
+ * no step at the seam, and a soft window that fades it to zero before the
110
+ * cutoff. Hard discontinuities here show up as a visible hard line at the end
111
+ * of every lit band, which reads as stuttering rather than flow.
112
+ */
113
+ const EDGE_VALUE = Math.exp(-0.7)
114
+ const CUT_START = WAKE_CUTOFF * 0.62
115
+ function wakeAt(t, p) {
116
+ const lag = p - t
117
+ if (lag < 0) return 0
118
+ if (lag >= WAKE_CUTOFF) return 0
119
+ const base = lag < CREST_WIDTH
120
+ ? Math.exp(-((lag / CREST_WIDTH) ** 2) * 0.7)
121
+ : EDGE_VALUE * Math.exp(-(lag - CREST_WIDTH) / WAKE_DECAY)
122
+ if (lag <= CUT_START) return base
123
+ // Smoothstep the tail out so the band dissolves instead of being clipped.
124
+ const u = (lag - CUT_START) / (WAKE_CUTOFF - CUT_START)
125
+ const fade = 1 - u * u * (3 - 2 * u)
126
+ return base * fade
127
+ }
128
+
129
+ /** Total continuous current level at position t for a link's phase. */
130
+ function currentAt(t, phase) {
131
+ let level = 0
132
+ for (const c of CRESTS) {
133
+ level = Math.max(level, wakeAt(t, (phase + c) % 1))
134
+ }
135
+ // Below this threshold the wire is simply not drawn at all.
136
+ return level < 0.02 ? 0 : Math.min(1, level)
137
+ }
138
+
139
+ /**
140
+ * Shared renderer for BOTH skins.
141
+ *
142
+ * Performance (Android WebView): stroking ~56 segments x 3-4 passes meant
143
+ * 6000+ stroke() calls per frame and stuttered badly on mobile Canvas2D,
144
+ * while auto-rotation (one yaw value) stayed smooth. Instead we build ONE
145
+ * path per wire and ONE linear gradient whose stops encode the travelling
146
+ * brightness wave, then reuse that path for three strokes. That is 3 strokes
147
+ * per link regardless of detail, and the gradient interpolates brightness
148
+ * continuously — genuinely silky even when the signal steps at 30 fps.
149
+ */
150
+ function drawSignalWave(pointAt, color, gain, phase, segments, scale) {
151
+ const a = pointAt(0), b = pointAt(1)
152
+ if (Math.abs(b.x - a.x) + Math.abs(b.y - a.y) < 1) return
153
+
154
+ // Sample the wave; if the whole link is dark, draw nothing at all.
155
+ // The number of stops follows the on-screen length: a long link rendered
156
+ // with only 16 stops made the crest jump from stop to stop (the visible
157
+ // "stuttering"), so sample roughly one stop per ~6 px instead.
158
+ const screenLen = Math.hypot(b.x - a.x, b.y - a.y)
159
+ const pxPerStop = state.quality < 1 ? 12 : 6
160
+ const K = Math.max(12, Math.min(96, Math.round(screenLen / pxPerStop)))
161
+ const levels = []
162
+ let peak = 0
163
+ for (let k = 0; k <= K; k += 1) {
164
+ const v = currentAt(k / K, phase) * gain
165
+ levels.push(v)
166
+ if (v > peak) peak = v
167
+ }
168
+ if (peak < 0.02) return
169
+
170
+ const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y)
171
+ for (let k = 0; k <= K; k += 1) {
172
+ // Dark stops are fully transparent, not a dim colour, so no permanent
173
+ // hairline survives between currents. Lit stops stay below white.
174
+ grad.addColorStop(k / K, rgba(color, Math.min(0.9, levels[k] * 1.05)))
175
+ }
176
+
177
+ ctx.beginPath()
178
+ ctx.moveTo(a.x, a.y)
179
+ for (let i = 1; i <= segments; i += 1) {
180
+ const p = pointAt(i / segments)
181
+ ctx.lineTo(p.x, p.y)
182
+ }
183
+ ctx.strokeStyle = grad
184
+ ctx.lineCap = 'round'
185
+ ctx.lineJoin = 'round'
186
+
187
+ // Only two passes, both feathered by the gradient itself. A wide opaque
188
+ // stroke with round caps was what produced the hard white outline.
189
+ ctx.globalAlpha = 0.22 * scale
190
+ ctx.lineWidth = 6 * scale
191
+ ctx.stroke()
192
+ ctx.globalAlpha = 1
193
+ ctx.lineWidth = 0.9
194
+ ctx.stroke()
195
+ }
196
+
197
+ /** Phase of the continuous current for one link, shared by both skins. */
198
+ function signalPhase(seed) {
199
+ return ((state.time / SIGNAL_CYCLE) + (seed % 1000) / 1000) % 1
200
+ }
201
+
202
+ /**
203
+ * Continuous wall clock in milliseconds. An earlier version floored this to
204
+ * 30 Hz, which made the crest advance in visible steps; the phase must be a
205
+ * float that changes every animation frame.
206
+ */
207
+ function signalClock() {
208
+ return typeof performance !== 'undefined' ? performance.now() : Date.now()
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Level of detail
213
+ //
214
+ // Zoom is the drill-down control. Each band reveals strictly more memory:
215
+ // 0 overview clusters as gas, titles only
216
+ // 1 cluster titles + counts, relations readable
217
+ // 2 fragment digest sampled fragment text, one cluster in focus
218
+ // 3 full text complete untruncated fragments (fetched on demand)
219
+ // ---------------------------------------------------------------------------
220
+ const ZOOM_MIN = 0.35
221
+ const ZOOM_MAX = 9
222
+ const LOD_STEPS = [1.5, 2.8, 5]
223
+ function clampZoom(z) {
224
+ return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z))
225
+ }
226
+ function lodFor(zoom) {
227
+ let lod = 0
228
+ for (const step of LOD_STEPS) if (zoom >= step) lod += 1
229
+ return lod
230
+ }
231
+
232
+ // Minimal 3D vector helpers, used only to grow dendrite trees in world space.
233
+ const vsub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
234
+ const vadd = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
235
+ const vscale = (a, k) => [a[0] * k, a[1] * k, a[2] * k]
236
+ const vcross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
237
+ function vnorm(a) {
238
+ const l = Math.hypot(a[0], a[1], a[2]) || 1
239
+ return [a[0] / l, a[1] / l, a[2] / l]
240
+ }
241
+ function vperp(dir) {
242
+ const ref = Math.abs(dir[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
243
+ const u = vnorm(vcross(dir, ref))
244
+ return [u, vcross(dir, u)]
245
+ }
246
+
247
+ /**
248
+ * Grow one dendrite arbor: a trunk that recursively splits into thinner,
249
+ * shorter branches — the same shape as a tree or a real neuronal dendritic
250
+ * field. Everything is stored as world-space segments so the arbor keeps its
251
+ * 3D structure while the camera rotates.
252
+ */
253
+ function buildDendrites(center, seed, weight) {
254
+ const rand = rng(seed)
255
+ const segments = []
256
+ const tips = []
257
+ // A busier session grows a denser dendritic field (3..7 trunks, 2..3
258
+ // branch levels). This is the neuron skin's "memory count" made visible.
259
+ const trunkCount = 3 + Math.round(weight * 4)
260
+ const maxDepth = weight > 0.55 ? 3 : 2
261
+
262
+ function grow(from, dir, length, width, depth) {
263
+ const to = vadd(from, vscale(dir, length))
264
+ segments.push({ a: from, b: to, width, depth })
265
+ if (depth >= maxDepth || length < 6) {
266
+ tips.push({ p: to, depth })
267
+ return
268
+ }
269
+ const [u, v] = vperp(dir)
270
+ const branches = depth === 0 ? 3 : 2
271
+ for (let k = 0; k < branches; k += 1) {
272
+ // Spread children around the parent axis with a clear fork angle.
273
+ const spread = 0.45 + rand() * 0.5
274
+ const around = (k / branches) * Math.PI * 2 + rand() * 1.1
275
+ const side = vadd(vscale(u, Math.cos(around)), vscale(v, Math.sin(around)))
276
+ const childDir = vnorm(vadd(vscale(dir, Math.cos(spread)), vscale(side, Math.sin(spread))))
277
+ grow(to, childDir, length * (0.62 + rand() * 0.13), width * 0.6, depth + 1)
278
+ }
279
+ }
280
+
281
+ for (let i = 0; i < trunkCount; i += 1) {
282
+ const theta = (i / trunkCount) * Math.PI * 2 + rand() * 0.6
283
+ const phi = (rand() - 0.5) * 1.5
284
+ const dir = vnorm([Math.cos(theta) * Math.cos(phi), Math.sin(phi), Math.sin(theta) * Math.cos(phi)])
285
+ grow(center, dir, 30 + rand() * 16, 2.3, 0)
286
+ }
287
+ return { segments, tips }
288
+ }
289
+
290
+ /**
291
+ * Grow one irregular nebula cloud. There is deliberately NO spherical core
292
+ * and NO orbital ring: a nebula is lumpy gas — a few off-centre condensations
293
+ * joined by wispy filaments, with ragged edges and holes between the lobes.
294
+ * All points are world-space so the cloud keeps its volume while rotating.
295
+ */
296
+ function buildCloud(center, seed, activity, weight) {
297
+ const rand = rng(seed)
298
+ const puffs = []
299
+ const filaments = []
300
+ // More remembered fragments -> more condensations in the cloud.
301
+ const lobes = 2 + Math.round(weight * 4)
302
+ // A dominant stretch axis makes the whole cloud elongated, not round.
303
+ const axis = vnorm([(rand() - 0.5) * 2, (rand() - 0.5) * 1.2, (rand() - 0.5) * 2])
304
+ const [u, v] = vperp(axis)
305
+ const reach = 24 + activity * 12 + weight * 18
306
+
307
+ const lobeCenters = []
308
+ for (let l = 0; l < lobes; l += 1) {
309
+ const along = (rand() - 0.35) * reach * 1.5
310
+ const around = rand() * Math.PI * 2
311
+ const off = (0.35 + rand() * 0.65) * reach
312
+ const p = vadd(center, vadd(
313
+ vscale(axis, along),
314
+ vadd(vscale(u, Math.cos(around) * off), vscale(v, Math.sin(around) * off * 0.7)),
315
+ ))
316
+ lobeCenters.push(p)
317
+ // Each condensation is a ragged clump of soft gas puffs, stretched along
318
+ // the cloud axis. Sizes and opacities vary so edges stay fuzzy.
319
+ // Cap per-cloud puffs: hundreds of per-puff radial gradients per frame
320
+ // is exactly what stalls an Android WebView.
321
+ const count = 4 + Math.round(weight * 4) + Math.floor(rand() * 3)
322
+ for (let k = 0; k < count; k += 1) {
323
+ const jitter = [
324
+ (rand() - 0.5) * 26, (rand() - 0.5) * 20, (rand() - 0.5) * 26,
325
+ ]
326
+ // Bias puffs along the axis -> wispy, filamentary clumps.
327
+ const stretch = (rand() - 0.5) * 18
328
+ puffs.push({
329
+ pos: vadd(p, vadd(jitter, vscale(axis, stretch))),
330
+ r: 13 + rand() * 20,
331
+ alpha: 0.04 + rand() * 0.06,
332
+ // Some puffs are cooler/warmer, so the cloud is not one flat colour.
333
+ tint: rand() < 0.28 ? 'cool' : rand() < 0.16 ? 'warm' : 'main',
334
+ squash: 0.25 + rand() * 0.45,
335
+ angle: rand() * Math.PI,
336
+ })
337
+ }
338
+ // A filament runs from the cloud centre out to each condensation, with a
339
+ // kinked mid-point so it reads as gas, not a straight spoke.
340
+ const mid = vadd(vscale(vadd(center, p), 0.5), [
341
+ (rand() - 0.5) * 20, (rand() - 0.5) * 16, (rand() - 0.5) * 20,
342
+ ])
343
+ filaments.push([center.slice(), mid, p.slice()])
344
+ }
345
+ // A few loose filaments drifting off the lobes (ragged outer wisps).
346
+ const wisps = 2 + Math.round(weight * 4)
347
+ for (let f = 0; f < wisps; f += 1) {
348
+ const base = lobeCenters[Math.floor(rand() * lobeCenters.length)]
349
+ const tip = vadd(base, [
350
+ (rand() - 0.5) * 44, (rand() - 0.5) * 30, (rand() - 0.5) * 44,
351
+ ])
352
+ const mid = vadd(vscale(vadd(base, tip), 0.5), [(rand() - 0.5) * 14, (rand() - 0.5) * 12, (rand() - 0.5) * 14])
353
+ filaments.push([base.slice(), mid, tip])
354
+ }
355
+ return { puffs, filaments, lobeCenters, axis }
356
+ }
357
+
358
+ let state = {
359
+ clusters: [], // { session, color, center, cloud, stars, core }
360
+ stars: [], // flat, for hit-testing / sorting
361
+ workspaces: [], // { id, label, color, index }
362
+ stats: null,
363
+ query: '',
364
+ dimWorkspace: null,
365
+ autoRotate: true,
366
+ yaw: 0.6,
367
+ pitch: 0.32,
368
+ zoom: 1,
369
+ targetYaw: 0.6,
370
+ targetPitch: 0.32,
371
+ targetZoom: 1,
372
+ skin: 'nebula',
373
+ relations: [], // { source, target, weight, kind, kinds, shared, a, b }
374
+ // --- level of detail (zoom drives how much memory is revealed) ---
375
+ lod: 0, // 0 全景 · 1 星团 · 2 碎片摘要 · 3 碎片全文
376
+ focus: null, // cluster being drilled into
377
+ focusDepth: 0, // 0..1 eased blend of the focus translation
378
+ detailCache: new Map(), // sessionId -> full fragments (lazy)
379
+ detailPending: new Set(),
380
+ dragging: false,
381
+ lastX: 0,
382
+ lastY: 0,
383
+ moved: false,
384
+ hover: null,
385
+ selected: null,
386
+ w: 0, h: 0, dpr: 1,
387
+ time: 0,
388
+ quality: 1, // 1 = full crest sampling; 0.5 under sustained load
389
+ isMobile: false,
390
+ // Two-finger pinch state, kept separate from the single-finger drag.
391
+ pinch: null, // { startDist, startZoom }
392
+ pointers: new Map(),
393
+ }
394
+
395
+ const isTouchDevice = () =>
396
+ typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches
397
+ || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0)
398
+
399
+ /**
400
+ * Camera rig distance. Zoom is NOT applied here — `project()` multiplies by
401
+ * zoom as pure magnification. This only handles the mobile fit factor, which
402
+ * must stay constant so zooming behaves identically on every screen size.
403
+ */
404
+ function cameraDistance() {
405
+ if (!state.isMobile) return 900
406
+ // The cloud spans roughly ±300 world units. To fit it inside a viewport of
407
+ // `w` pixels the camera is pushed back on narrow screens.
408
+ const usable = Math.max(240, state.w - 24)
409
+ const fit = Math.max(1.6, Math.min(3.4, 1650 / usable))
410
+ return 900 * fit
411
+ }
412
+
413
+ // -------------------------------------------------------------------------
414
+ // Build the model from the aggregate payload
415
+ // -------------------------------------------------------------------------
416
+ function build(data) {
417
+ const workspaces = data.workspaces || []
418
+ const colorOf = new Map()
419
+ workspaces.forEach((ws, i) => colorOf.set(ws.id, PALETTE[i % PALETTE.length]))
420
+ state.workspaces = workspaces.map((ws, i) => ({ ...ws, color: PALETTE[i % PALETTE.length], index: i }))
421
+
422
+ const sessions = data.sessions || []
423
+ const golden = Math.PI * (3 - Math.sqrt(5))
424
+ // Normalise each session's actual memory volume against the busiest one.
425
+ // Everything visual (gas density, star count, dendrite complexity, cloud
426
+ // size) is scaled from this, so the picture reflects real memory data.
427
+ const maxFragments = Math.max(1, ...sessions.map(s => (s.fragments || []).length))
428
+ const maxEvents = Math.max(1, ...sessions.map(s => s.counts?.events || 0))
429
+ // Spread cluster centers on a fibonacci sphere so the nebula reads as a
430
+ // galaxy, with radius scaled gently by activity (busier Sessions drift out).
431
+ // On a narrow screen shrink the whole cloud so rotation rarely clips a
432
+ // cluster at the viewport edge.
433
+ const R = state.isMobile ? 205 : 250
434
+ state.clusters = sessions.map((session, i) => {
435
+ const rand = rng(hash(session.id))
436
+ const y = 1 - (i / Math.max(1, sessions.length - 1)) * 2
437
+ const radiusAtY = Math.sqrt(Math.max(0, 1 - y * y))
438
+ const theta = golden * i
439
+ const spread = 0.82 + rand() * 0.42
440
+ const activity = Math.min(1.6, 0.6 + Math.log10(1 + session.counts.events) / 2.2)
441
+ const center = [
442
+ Math.cos(theta) * radiusAtY * R * spread,
443
+ y * R * 0.62,
444
+ Math.sin(theta) * radiusAtY * R * spread,
445
+ ]
446
+ const fragments = session.fragments || []
447
+ const weight = Math.min(1,
448
+ 0.6 * (fragments.length / maxFragments) + 0.4 * ((session.counts?.events || 0) / maxEvents))
449
+ const color = colorOf.get(session.workspace) || PALETTE[i % PALETTE.length]
450
+ const cloud = buildCloud(center, hash(session.id) ^ 0x7e31, activity, weight)
451
+ const cluster = {
452
+ session, color, center, cloud, stars: [], weight,
453
+ dendrites: buildDendrites(center, hash(session.id) ^ 0x2c91, weight),
454
+ }
455
+
456
+ // Gas puffs themselves are the clickable "nebula" surface; pick the
457
+ // densest condensations as invisible hit anchors, never a glowing ball.
458
+ cloud.lobeCenters.forEach((p, li) => {
459
+ cluster.stars.push({
460
+ kind: 'anchor', cluster, isClusterCore: false, fragment: null,
461
+ pos: p.slice(), size: 1, phase: li, color,
462
+ })
463
+ })
464
+ const anchors = cluster.stars.slice()
465
+
466
+ // Young stars embedded *inside* the gas: pick a random condensation and
467
+ // scatter around it, so the stars do not form a ring around a nucleus.
468
+ fragments.forEach((fragment, fi) => {
469
+ const lobe = cloud.lobeCenters[Math.floor(rand() * cloud.lobeCenters.length)] || center
470
+ const spread = 8 + rand() * 20
471
+ const star = {
472
+ kind: 'star', cluster, fragment, isClusterCore: false,
473
+ pos: [
474
+ lobe[0] + (rand() - 0.5) * spread * 2,
475
+ lobe[1] + (rand() - 0.5) * spread * 1.4,
476
+ lobe[2] + (rand() - 0.5) * spread * 2,
477
+ ],
478
+ size: 0.9 + rand() * 1.5 + (fragment.kind === 'user' ? 0.6 : 0),
479
+ phase: rand() * Math.PI * 2,
480
+ color,
481
+ }
482
+ cluster.stars.push(star)
483
+ state.stars.push(star)
484
+ })
485
+ // Bright young stars scale with how much this session actually holds.
486
+ const brightCount = 1 + Math.round(weight * 3)
487
+ for (let s = 0; s < brightCount; s += 1) {
488
+ const lobe = cloud.lobeCenters[Math.floor(rand() * cloud.lobeCenters.length)] || center
489
+ state.stars.push({
490
+ kind: 'bright', cluster, fragment: null, isClusterCore: false,
491
+ pos: [lobe[0] + (rand() - 0.5) * 16, lobe[1] + (rand() - 0.5) * 12, lobe[2] + (rand() - 0.5) * 16],
492
+ size: 1.6 + rand() * 1.1, phase: rand() * Math.PI * 2, color,
493
+ })
494
+ }
495
+ // Fine dust speckles threaded through the gas, offset along filaments.
496
+ const dustPerFilament = 2 + Math.round(weight * 3)
497
+ cloud.filaments.forEach(([a, b, c]) => {
498
+ for (let d = 0; d < dustPerFilament; d += 1) {
499
+ const t = (d + rand()) / dustPerFilament
500
+ const u2 = 1 - t
501
+ const p = [
502
+ u2 * u2 * a[0] + 2 * u2 * t * b[0] + t * t * c[0] + (rand() - 0.5) * 12,
503
+ u2 * u2 * a[1] + 2 * u2 * t * b[1] + t * t * c[1] + (rand() - 0.5) * 9,
504
+ u2 * u2 * a[2] + 2 * u2 * t * b[2] + t * t * c[2] + (rand() - 0.5) * 12,
505
+ ]
506
+ state.stars.push({ kind: 'dust', cluster, fragment: null, isClusterCore: false, pos: p, size: 0.4 + rand() * 0.8, phase: rand() * Math.PI * 2, color })
507
+ }
508
+ })
509
+ anchors.forEach(a => state.stars.push(a))
510
+ // One unobtrusive hit target at the centre for tapping the whole cloud.
511
+ const core = {
512
+ kind: 'core', cluster, isClusterCore: true, fragment: null,
513
+ pos: center.slice(), size: 2.2, phase: rand() * Math.PI * 2, color,
514
+ }
515
+ cluster.core = core
516
+ state.stars.push(core)
517
+ return cluster
518
+ })
519
+ // Resolve relation endpoints to the live cluster objects.
520
+ const byId = new Map(state.clusters.map(c => [c.session.id, c]))
521
+ state.relations = (data.relations || [])
522
+ .map(r => ({ ...r, a: byId.get(r.source), b: byId.get(r.target) }))
523
+ .filter(r => r.a && r.b)
524
+ buildRelationKey(data.relations?.length || 0)
525
+
526
+ state.stats = data.stats
527
+ buildLegend()
528
+ buildStats(data.stats)
529
+ el.subtitle.textContent = state.isMobile
530
+ ? `${data.stats.sessions} 个会话 · ${data.stats.fragments} 颗记忆星 · 单指旋转 / 双指缩放`
531
+ : `${data.stats.sessions} 个会话记忆 · ${data.stats.fragments} 颗记忆星 · 拖拽以探索`
532
+ if (state.isMobile) el.search.placeholder = '搜索记忆'
533
+ updateHint()
534
+ }
535
+
536
+ const SKIN_KEY = 'dsh-memory-cloud:skin:v1'
537
+
538
+ function setSkin(skin) {
539
+ state.skin = skin === 'neuron' ? 'neuron' : 'nebula'
540
+ for (const btn of el.skinButtons) {
541
+ const active = btn.dataset.skin === state.skin
542
+ btn.classList.toggle('active', active)
543
+ btn.setAttribute('aria-pressed', String(active))
544
+ }
545
+ try { window.localStorage.setItem(SKIN_KEY, state.skin) } catch (_) { /* private mode */ }
546
+ updateHint()
547
+ }
548
+
549
+ function updateHint() {
550
+ const hint = document.getElementById('hint')
551
+ if (!hint) return
552
+ const neuron = state.skin === 'neuron'
553
+ hint.textContent = state.isMobile
554
+ ? (neuron ? '双指放大可看到更多记忆 · 轻点神经元展开' : '双指放大可看到更多记忆 · 轻点星团展开')
555
+ : (neuron ? '滚轮放大逐层展开记忆 · 双击神经元钻入全文' : '滚轮放大逐层展开记忆 · 双击星团钻入全文')
556
+ }
557
+
558
+ function buildRelationKey(count) {
559
+ if (!el.relationKey) return
560
+ const rows = [
561
+ ['fork', '分支血缘', '#ffd591', true],
562
+ ['topic', '共同话题', '#8aa0ff', false],
563
+ ['workspace', '同工作区', '#7de0c0', false],
564
+ ]
565
+ el.relationKey.innerHTML =
566
+ `<h3>会话关联</h3>` +
567
+ rows.map(([kind, label, color, dashed]) =>
568
+ `<div class="rk-row"><span class="rk-line ${kind}" style="border-top-color:${color};${dashed ? 'border-top-style:dashed' : ''}"></span>${label}</div>`).join('') +
569
+ `<div class="rk-count">${count} 条关联边</div>`
570
+ }
571
+
572
+ function buildLegend() {
573
+ el.legend.innerHTML = ''
574
+ for (const ws of state.workspaces) {
575
+ const item = document.createElement('div')
576
+ item.className = 'legend-item'
577
+ item.dataset.workspace = ws.id
578
+ const dot = document.createElement('span')
579
+ dot.className = 'dot'
580
+ dot.style.color = rgba(ws.color, 1)
581
+ dot.style.background = rgba(ws.color, 1)
582
+ const name = document.createElement('span')
583
+ name.className = 'name'
584
+ name.textContent = ws.label
585
+ name.title = ws.label
586
+ const count = document.createElement('span')
587
+ count.className = 'count'
588
+ count.textContent = `${ws.sessions}`
589
+ item.append(dot, name, count)
590
+ item.addEventListener('click', () => {
591
+ state.dimWorkspace = state.dimWorkspace === ws.id ? null : ws.id
592
+ refreshLegendDim()
593
+ })
594
+ el.legend.append(item)
595
+ }
596
+ }
597
+ function refreshLegendDim() {
598
+ for (const item of el.legend.children) {
599
+ item.classList.toggle('dim', state.dimWorkspace != null && item.dataset.workspace !== state.dimWorkspace)
600
+ }
601
+ }
602
+
603
+ function buildStats(s) {
604
+ if (!s) return
605
+ const fmt = n => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
606
+ const span = (s.oldest && s.newest)
607
+ ? `${new Date(s.oldest).toLocaleDateString()} — ${new Date(s.newest).toLocaleDateString()}`
608
+ : '—'
609
+ const cells = [
610
+ ['会话星团', fmt(s.sessions)],
611
+ ['记忆星', fmt(s.fragments)],
612
+ ['关联边', fmt(s.relations ?? 0)],
613
+ ['对话轮次', fmt(s.turns)],
614
+ ['用户消息', fmt(s.user)],
615
+ ['助手消息', fmt(s.assistant)],
616
+ ['工具调用', fmt(s.tools)],
617
+ ['时间跨度', span],
618
+ ]
619
+ el.stats.innerHTML = cells.map(([label, value]) =>
620
+ `<div class="stat"><b>${escapeHtml(value)}</b><span>${escapeHtml(label)}</span></div>`).join('')
621
+ }
622
+
623
+ function escapeHtml(str) {
624
+ return String(str).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
625
+ }
626
+
627
+ // -------------------------------------------------------------------------
628
+ // Match / filter
629
+ // -------------------------------------------------------------------------
630
+ function matches(cluster) {
631
+ if (state.dimWorkspace != null && cluster.session.workspace !== state.dimWorkspace) return false
632
+ const q = state.query.trim().toLowerCase()
633
+ if (q === '') return true
634
+ if (cluster.session.title.toLowerCase().includes(q)) return true
635
+ if (cluster.session.workspaceLabel.toLowerCase().includes(q)) return true
636
+ return (cluster.session.fragments || []).some(f => f.text.toLowerCase().includes(q))
637
+ }
638
+
639
+ // -------------------------------------------------------------------------
640
+ // 3D projection
641
+ // -------------------------------------------------------------------------
642
+ function project(p) {
643
+ const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw)
644
+ const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch)
645
+ // Drilling in translates the focused cluster toward the origin, so zooming
646
+ // actually flies INTO that nebula instead of just scaling a fixed rig.
647
+ const fd = state.focusDepth
648
+ const ox = state.focus && fd > 0 ? state.focus.center[0] * fd : 0
649
+ const oy = state.focus && fd > 0 ? state.focus.center[1] * fd : 0
650
+ const oz = state.focus && fd > 0 ? state.focus.center[2] * fd : 0
651
+ const dx = p[0] - ox, dy0 = p[1] - oy, dz = p[2] - oz
652
+ // rotate around Y (yaw)
653
+ let x = dx * cy - dz * sy
654
+ let z = dx * sy + dz * cy
655
+ // rotate around X (pitch)
656
+ let y = dy0 * cp - z * sp
657
+ z = dy0 * sp + z * cp
658
+ const camera = cameraDistance()
659
+ const denom = camera + z
660
+ // Perspective falloff times the zoom multiplier: zoom is pure magnification,
661
+ // so it can climb far enough to read individual memories.
662
+ const scale = (camera / Math.max(120, denom)) * state.zoom
663
+ return {
664
+ x: state.w / 2 + x * scale,
665
+ y: state.h / 2 + y * scale,
666
+ z,
667
+ scale,
668
+ }
669
+ }
670
+
671
+ // -------------------------------------------------------------------------
672
+ // Render loop
673
+ // -------------------------------------------------------------------------
674
+ function resize() {
675
+ state.w = window.innerWidth
676
+ state.h = window.innerHeight
677
+ // Android WebViews often report dpr 3. Rendering a full-screen `lighter`
678
+ // composite at 2x costs ~4x the pixels of 1x and is the single biggest
679
+ // cause of jank on mobile. Cap the backing store on small screens.
680
+ const raw = window.devicePixelRatio || 1
681
+ state.dpr = state.w <= 820 ? Math.min(raw, 1.25) : Math.min(2, raw)
682
+ state.isMobile = state.w <= 820 || (state.w <= 980 && isTouchDevice())
683
+ el.search.placeholder = state.isMobile ? '搜索记忆' : '搜索记忆 / 标题 / 工作区'
684
+ canvas.width = Math.floor(state.w * state.dpr)
685
+ canvas.height = Math.floor(state.h * state.dpr)
686
+ canvas.style.width = `${state.w}px`
687
+ canvas.style.height = `${state.h}px`
688
+ ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0)
689
+ bgGlow = null
690
+ }
691
+
692
+ let bgGlow = null
693
+ function drawBackground() {
694
+ ctx.fillStyle = '#070a1c'
695
+ ctx.fillRect(0, 0, state.w, state.h)
696
+ // The central glow is static: build its gradient once per resize instead
697
+ // of allocating a full-screen radial gradient every single frame.
698
+ if (bgGlow === null) {
699
+ bgGlow = ctx.createRadialGradient(state.w / 2, state.h / 2, 0, state.w / 2, state.h / 2, Math.max(state.w, state.h) * 0.6)
700
+ bgGlow.addColorStop(0, 'rgba(40, 50, 110, 0.35)')
701
+ bgGlow.addColorStop(1, 'rgba(7, 10, 28, 0)')
702
+ }
703
+ ctx.fillStyle = bgGlow
704
+ ctx.fillRect(0, 0, state.w, state.h)
705
+ // Distant backdrop stars: ONE path, ONE fill. Per-star fillRect was ~150
706
+ // fill() calls per frame and was a major Android WebView bottleneck.
707
+ const rand = rng(9871)
708
+ ctx.beginPath()
709
+ for (let i = 0; i < 110; i += 1) {
710
+ const x = rand() * state.w, y = rand() * state.h
711
+ ctx.rect(x, y, 1, 1)
712
+ }
713
+ ctx.fillStyle = 'rgba(190,205,255,0.14)'
714
+ ctx.fill()
715
+ }
716
+
717
+ // -------------------------------------------------------------------------
718
+ // Skin: nebula (default) — irregular gas clouds joined by electric arcs
719
+ // -------------------------------------------------------------------------
720
+
721
+ /**
722
+ * Screen-space arc between two projected points.
723
+ *
724
+ * The offset from the chord must be a SMOOTH function of t. The earlier
725
+ * version added an independent random kick per segment, which produced a
726
+ * saw-tooth polyline; a brightness band spanning a couple of those segments
727
+ * changed direction abruptly as it advanced, so the light looked like pixels
728
+ * hopping along the wire instead of fluid travelling through it. Two gentle
729
+ * harmonics (one bow plus one slow S-curve) keep every point at parameter t
730
+ * projecting onto the chord at exactly t, so the travelling gradient stays
731
+ * aligned with the drawn line.
732
+ */
733
+ function arcPoints(pa, pb, seed, segments) {
734
+ const dx = pb.x - pa.x, dy = pb.y - pa.y
735
+ const len = Math.hypot(dx, dy) || 1
736
+ const nx = -dy / len, ny = dx / len
737
+ const rand = rng(seed)
738
+ const sign = rand() < 0.5 ? -1 : 1
739
+ const bowAmp = Math.min(26, len * 0.09) * sign
740
+ const sAmp = Math.min(9, len * 0.03) * (rand() < 0.5 ? -1 : 1)
741
+ const sPhase = rand() * Math.PI * 2
742
+ const pts = [{ x: pa.x, y: pa.y }]
743
+ for (let i = 1; i < segments; i += 1) {
744
+ const t = i / segments
745
+ // Envelope pins both ends to the clusters, so links always meet their gas.
746
+ const env = Math.sin(t * Math.PI)
747
+ const off = env * (bowAmp + sAmp * Math.sin(t * Math.PI * 2 + sPhase))
748
+ pts.push({ x: pa.x + dx * t + nx * off, y: pa.y + dy * t + ny * off })
749
+ }
750
+ pts.push({ x: pb.x, y: pb.y })
751
+ return pts
752
+ }
753
+
754
+ function strokePolyline(pts, color, alpha, width) {
755
+ ctx.beginPath()
756
+ ctx.moveTo(pts[0].x, pts[0].y)
757
+ for (let i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i].x, pts[i].y)
758
+ ctx.strokeStyle = rgba(color, alpha)
759
+ ctx.lineWidth = width
760
+ ctx.lineJoin = 'round'
761
+ ctx.lineCap = 'round'
762
+ ctx.stroke()
763
+ }
764
+
765
+ /** Point on a polyline at normalised arc length t. */
766
+ function polylinePoint(pts, t) {
767
+ const f = Math.max(0, Math.min(0.9999, t)) * (pts.length - 1)
768
+ const i = Math.floor(f), u = f - i
769
+ const a = pts[i], b = pts[i + 1] || a
770
+ return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u }
771
+ }
772
+
773
+ function drawRelationArcs() {
774
+ ctx.save()
775
+ ctx.globalCompositeOperation = 'lighter'
776
+ state.relations.forEach(r => {
777
+ if (!r.a || !r.b) return
778
+ const active = matches(r.a) && matches(r.b)
779
+ const pa = project(r.a.center), pb = project(r.b.center)
780
+ const color = r.kind === 'fork' ? [255, 213, 145]
781
+ : r.kind === 'workspace' ? [125, 224, 192] : [140, 180, 255]
782
+ const seed = hash(r.source + r.target)
783
+ // 48 samples: the arc reads as a continuous curve rather than a chain of
784
+ // visible segments once a lit band crosses it.
785
+ const segments = 48
786
+ const pts = arcPoints(pa, pb, seed, segments)
787
+
788
+ // On a phone, 29 arcs in a narrow viewport easily drown the gas, so keep
789
+ // only the stronger links clearly visible there.
790
+ const strength = state.isMobile ? Math.max(0, (r.weight - 0.3) * 1.4) : 1
791
+ const gain = (0.35 + r.weight * 0.65) * strength
792
+ if (gain < 0.05) return
793
+
794
+ // Between currents the wire is black — no permanent channel glow.
795
+ if (!active) return
796
+ drawSignalWave(t => polylinePoint(pts, t), color, gain, signalPhase(seed), segments, 1)
797
+ })
798
+ ctx.restore()
799
+ }
800
+
801
+ /** Irregular gas: soft stretched puffs + wispy filaments, no round core. */
802
+ function drawNebulaClouds() {
803
+ ctx.save()
804
+ // Real nebula gas scatters light; it does not add without bound. Using
805
+ // 'lighter' made overlapping soft sprites saturate to pure white blobs
806
+ // with a hard bright edge. Source-over keeps colours layered and soft.
807
+ ctx.globalCompositeOperation = 'source-over'
808
+ for (const cluster of state.clusters) {
809
+ const visible = matches(cluster)
810
+ const alpha = visible ? 1 : 0.08
811
+ const cloud = cluster.cloud
812
+
813
+ // Wispy filaments, all in ONE path (one stroke per cloud).
814
+ ctx.beginPath()
815
+ for (const [a, b, c] of cloud.filaments) {
816
+ const pa = project(a), pb = project(b), pc = project(c)
817
+ ctx.moveTo(pa.x, pa.y)
818
+ ctx.quadraticCurveTo(pb.x, pb.y, pc.x, pc.y)
819
+ }
820
+ ctx.strokeStyle = rgba(cluster.color, 0.055 * alpha)
821
+ ctx.lineWidth = 2
822
+ ctx.lineCap = 'round'
823
+ ctx.stroke()
824
+
825
+ const c0 = project(cluster.center)
826
+
827
+ // A broad, axis-stretched soft body: pre-rendered sprite, no rim.
828
+ ctx.save()
829
+ ctx.globalAlpha = 0.34 * alpha
830
+ const bodySprite = softSprite(cluster.color)
831
+ const bw = 150 * c0.scale, bh = 78 * c0.scale
832
+ ctx.translate(c0.x, c0.y)
833
+ const ax = project(vadd(cluster.center, vscale(cloud.axis, 40)))
834
+ ctx.rotate(Math.atan2(ax.y - c0.y, ax.x - c0.x))
835
+ ctx.drawImage(bodySprite, -bw / 2, -bh / 2, bw, bh)
836
+ ctx.restore()
837
+
838
+ // Every puff is a soft feathered sprite. Overlapping sprites with gentle
839
+ // alpha build up a continuous, edge-less gas — like a real nebula photo.
840
+ ctx.globalAlpha = alpha
841
+ for (const puff of cloud.puffs) {
842
+ const p = project(puff.pos)
843
+ const tint = puff.tint === 'cool' ? [110, 160, 255]
844
+ : puff.tint === 'warm' ? [255, 150, 120] : cluster.color
845
+ const w = Math.max(6, puff.r * p.scale * 3.4)
846
+ const h = w * puff.squash
847
+ ctx.save()
848
+ ctx.globalAlpha = (0.075 + puff.alpha * 1.1) * alpha
849
+ ctx.translate(p.x, p.y)
850
+ ctx.rotate(puff.angle)
851
+ ctx.drawImage(softSprite(tint), -w / 2, -h / 2, w, h)
852
+ ctx.restore()
853
+ }
854
+ ctx.globalAlpha = 1
855
+ }
856
+ ctx.restore()
857
+ }
858
+
859
+ /** Young stars embedded in the gas; the click target draws no ball at all. */
860
+ function drawNebulaStars(projected) {
861
+ ctx.save()
862
+ ctx.globalCompositeOperation = 'lighter'
863
+ // Batch by colour: one path for dust, one for soft glows, one for cores.
864
+ // A per-star radial gradient was ~2000 fill() calls/frame and is the main
865
+ // reason the Android WebView stuttered while auto-rotation stayed smooth.
866
+ const byColor = new Map()
867
+ const keyOf = c => `${c[0]},${c[1]},${c[2]}`
868
+ for (const item of projected) {
869
+ const star = item.star
870
+ if (star.kind === 'core' || star.kind === 'anchor' || !item.visible) continue
871
+ const k = keyOf(star.color)
872
+ if (!byColor.has(k)) byColor.set(k, { color: star.color, dust: [], glow: [], core: [] })
873
+ byColor.get(k)[star.kind === 'dust' ? 'dust' : 'glow'].push(item)
874
+ }
875
+ // Soft sprites instead of solid discs: no hard rim on any star.
876
+ const sprite = softSprite([255, 255, 255])
877
+ for (const group of byColor.values()) {
878
+ const colored = softSprite(group.color)
879
+ ctx.globalAlpha = 0.22
880
+ for (const { star, pr } of group.dust) {
881
+ const twinkle = 0.78 + Math.sin(state.time * 0.002 + star.phase) * 0.22
882
+ const d = Math.max(3, star.size * pr.scale * twinkle * 5)
883
+ ctx.drawImage(colored, pr.x - d / 2, pr.y - d / 2, d, d)
884
+ }
885
+ for (const { star, pr } of group.glow) {
886
+ const twinkle = 0.78 + Math.sin(state.time * 0.002 + star.phase) * 0.22
887
+ const r = Math.max(0.5, star.size * pr.scale * twinkle)
888
+ const d = Math.max(4, r * (star.kind === 'bright' ? 9 : 5))
889
+ ctx.globalAlpha = 0.30
890
+ ctx.drawImage(colored, pr.x - d / 2, pr.y - d / 2, d, d)
891
+ ctx.globalAlpha = 0.34
892
+ const c = Math.max(2, r * 2.2)
893
+ ctx.drawImage(sprite, pr.x - c / 2, pr.y - c / 2, c, c)
894
+ }
895
+ }
896
+ ctx.globalAlpha = 1
897
+ // Selection / hover halo (one stroke, negligible cost).
898
+ for (const { star, pr, visible } of projected) {
899
+ if (!visible || (star !== state.hover && star.cluster !== state.selected)) continue
900
+ const r = Math.max(6, star.size * pr.scale + 6)
901
+ ctx.beginPath()
902
+ ctx.arc(pr.x, pr.y, r, 0, Math.PI * 2)
903
+ ctx.strokeStyle = 'rgba(255,255,255,0.8)'
904
+ ctx.lineWidth = 1
905
+ ctx.stroke()
906
+ }
907
+ ctx.restore()
908
+ }
909
+
910
+ // -------------------------------------------------------------------------
911
+ // Skin: neuron — each Session becomes a soma; relations become axons with
912
+ // travelling signal pulses; fragments become short dendritic twigs.
913
+ // -------------------------------------------------------------------------
914
+ function linkGeometry(r) {
915
+ const pa = project(r.a.center), pb = project(r.b.center)
916
+ const dx = pb.x - pa.x, dy = pb.y - pa.y
917
+ const len = Math.hypot(dx, dy) || 1
918
+ const bow = Math.min(38, len * 0.13)
919
+ return {
920
+ pa, pb,
921
+ cx: (pa.x + pb.x) / 2 - (dy / len) * bow,
922
+ cy: (pa.y + pb.y) / 2 + (dx / len) * bow,
923
+ }
924
+ }
925
+
926
+ function quadPoint(g, t) {
927
+ const u = 1 - t
928
+ return {
929
+ x: u * u * g.pa.x + 2 * u * t * g.cx + t * t * g.pb.x,
930
+ y: u * u * g.pa.y + 2 * u * t * g.cy + t * t * g.pb.y,
931
+ }
932
+ }
933
+
934
+ function drawNeuronSkin() {
935
+ ctx.save()
936
+ ctx.globalCompositeOperation = 'lighter'
937
+
938
+ // Axons use the exact same signal wave as the nebula skin. No permanent
939
+ // sheath: between currents the wire is genuinely absent, not dimly drawn.
940
+ for (const r of state.relations) {
941
+ if (!r.a || !r.b) continue
942
+ const active = matches(r.a) && matches(r.b)
943
+ if (!active) continue
944
+ const g = linkGeometry(r)
945
+ const color = r.kind === 'fork' ? [255, 213, 145] : [110, 170, 255]
946
+ const seed = hash(r.source + r.target)
947
+ const gain = (0.35 + r.weight * 0.65) * (state.isMobile ? Math.max(0, (r.weight - 0.3) * 1.4) : 1)
948
+ if (gain < 0.05) continue
949
+ drawSignalWave(t => quadPoint(g, t), color, gain, signalPhase(seed), 48, 0.85)
950
+ }
951
+
952
+ // Dendritic arbors: ONE path per depth across ALL neurons (4 strokes total
953
+ // instead of ~60). Colours are keyed per cluster, so group by depth+colour.
954
+ const dendriteGroups = new Map()
955
+ for (const cluster of state.clusters) {
956
+ if (!matches(cluster) || !cluster.dendrites) continue
957
+ for (const seg of cluster.dendrites.segments) {
958
+ const key = `${seg.depth}:${cluster.color[0]},${cluster.color[1]},${cluster.color[2]}`
959
+ if (!dendriteGroups.has(key)) dendriteGroups.set(key, { depth: seg.depth, color: cluster.color, segs: [] })
960
+ dendriteGroups.get(key).segs.push(seg)
961
+ }
962
+ }
963
+ for (const g of dendriteGroups.values()) {
964
+ ctx.beginPath()
965
+ let scale = 1
966
+ for (const seg of g.segs) {
967
+ const a = project(seg.a), b = project(seg.b)
968
+ scale = a.scale
969
+ ctx.moveTo(a.x, a.y)
970
+ ctx.lineTo(b.x, b.y)
971
+ }
972
+ ctx.strokeStyle = rgba(g.color, 0.26 * (1 - g.depth * 0.22))
973
+ ctx.lineWidth = Math.max(0.35, (2.3 * Math.pow(0.6, g.depth)) * scale * 0.5)
974
+ ctx.lineCap = 'round'
975
+ ctx.stroke()
976
+ }
977
+ // All synaptic boutons in a single fill path, grouped by colour.
978
+ const boutonGroups = new Map()
979
+ for (const cluster of state.clusters) {
980
+ if (!matches(cluster) || !cluster.dendrites) continue
981
+ const key = `${cluster.color[0]},${cluster.color[1]},${cluster.color[2]}`
982
+ if (!boutonGroups.has(key)) boutonGroups.set(key, { color: cluster.color, tips: [] })
983
+ boutonGroups.get(key).tips.push(...cluster.dendrites.tips)
984
+ }
985
+ for (const g of boutonGroups.values()) {
986
+ ctx.beginPath()
987
+ for (const tip of g.tips) {
988
+ const p = project(tip.p)
989
+ const rad = Math.max(0.8, 1.6 * p.scale)
990
+ ctx.moveTo(p.x + rad, p.y)
991
+ ctx.arc(p.x, p.y, rad, 0, Math.PI * 2)
992
+ }
993
+ ctx.fillStyle = rgba(g.color, 0.34)
994
+ ctx.fill()
995
+ }
996
+
997
+ // Somas: entirely soft sprites — no stroked ring, no hard membrane edge.
998
+ const whiteSprite = softSprite([255, 255, 255])
999
+ for (const cluster of state.clusters) {
1000
+ if (!matches(cluster)) continue
1001
+ const p = project(cluster.center)
1002
+ const pulse = 0.9 + Math.sin(state.time * 0.0016 + hash(cluster.session.id) % 7) * 0.1
1003
+ const R = (7 + Math.min(6, Math.log10(1 + cluster.session.counts.events) * 3)) * p.scale * pulse
1004
+ const halo = softSprite(cluster.color)
1005
+ const hd = R * 6
1006
+ ctx.globalAlpha = 0.55
1007
+ ctx.drawImage(halo, p.x - hd / 2, p.y - hd / 2, hd, hd)
1008
+ const bd = R * 2.6
1009
+ ctx.globalAlpha = 0.5
1010
+ ctx.drawImage(halo, p.x - bd / 2, p.y - bd / 2, bd, bd)
1011
+ const nd = Math.max(3, R * 1.1)
1012
+ ctx.globalAlpha = 0.6
1013
+ ctx.drawImage(whiteSprite, p.x - nd / 2, p.y - nd / 2, nd, nd)
1014
+ ctx.globalAlpha = 1
1015
+ }
1016
+
1017
+ ctx.restore()
1018
+ }
1019
+
1020
+ // -------------------------------------------------------------------------
1021
+ // Level-of-detail helpers
1022
+ // -------------------------------------------------------------------------
1023
+
1024
+ /** The cluster nearest the viewport centre — the one a deep zoom is about. */
1025
+ function clusterAtCenter() {
1026
+ let best = null, bestD = Infinity
1027
+ for (const cluster of state.clusters) {
1028
+ if (!matches(cluster)) continue
1029
+ const pr = project(cluster.center)
1030
+ const d = Math.hypot(pr.x - state.w / 2, pr.y - state.h / 2)
1031
+ if (d < bestD) { bestD = d; best = cluster }
1032
+ }
1033
+ return best
1034
+ }
1035
+
1036
+ /** Full, untruncated fragments for one cluster (lazy, cached). */
1037
+ function requestDetail(cluster) {
1038
+ const id = cluster.session.id
1039
+ if (state.detailCache.has(id) || state.detailPending.has(id)) return
1040
+ state.detailPending.add(id)
1041
+ fetch(`/memory-cloud/api/session/${encodeURIComponent(id)}`)
1042
+ .then(r => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
1043
+ .then(detail => {
1044
+ const bySeq = new Map((detail.fragments || []).map(f => [f.seq, f]))
1045
+ state.detailCache.set(id, bySeq)
1046
+ cluster.hasFullDetail = true
1047
+ })
1048
+ .catch(() => { /* keep the sampled digest on failure */ })
1049
+ .finally(() => state.detailPending.delete(id))
1050
+ }
1051
+
1052
+ /** Text for one fragment at the current detail level. */
1053
+ function fragmentText(fragment, cluster, lod) {
1054
+ const full = state.detailCache.get(cluster.session.id)
1055
+ const source = (lod >= 3 && full && full.get(fragment.seq)) || fragment
1056
+ const text = source.text || ''
1057
+ const cap = lod >= 3 ? 320 : 90
1058
+ return text.length > cap ? `${text.slice(0, cap).trimEnd()}…` : text
1059
+ }
1060
+
1061
+ /** Fragment captions over their own stars — the actual remembered words. */
1062
+ function drawFragmentCaptions(lod) {
1063
+ const cluster = state.focus
1064
+ if (!cluster || state.focusDepth < 0.35) return
1065
+ ctx.save()
1066
+ ctx.textBaseline = 'top'
1067
+ const fontSize = lod >= 3 ? 11.5 : 10.5
1068
+ ctx.font = `${fontSize}px Inter, "PingFang SC", system-ui, sans-serif`
1069
+ const maxW = Math.min(state.isMobile ? 150 : 230, state.w * 0.42)
1070
+ const limit = state.isMobile ? 8 : 14
1071
+
1072
+ // Collect candidates first, then place them greedily with collision
1073
+ // rejection: overlapping captions were an unreadable pile of text.
1074
+ const candidates = []
1075
+ for (const star of cluster.stars) {
1076
+ if (star.kind !== 'star' || !star.fragment) continue
1077
+ const pr = project(star.pos)
1078
+ if (pr.x < -60 || pr.x > state.w + 60 || pr.y < -30 || pr.y > state.h + 30) continue
1079
+ if (pr.scale < 0.5) continue
1080
+ const text = fragmentText(star.fragment, cluster, lod)
1081
+ if (text === '') continue
1082
+ candidates.push({ pr, text, isUser: star.fragment.kind === 'user' })
1083
+ }
1084
+ // Prefer the most central/most readable ones, then place top-to-bottom.
1085
+ candidates.sort((a, b) => a.pr.y - b.pr.y)
1086
+ const placed = []
1087
+ let drawn = 0
1088
+ for (const cand of candidates) {
1089
+ if (drawn >= limit) break
1090
+ const lines = wrapText(cand.text, maxW, ctx)
1091
+ const lineH = fontSize + 2.5
1092
+ const w = Math.max(...lines.map(l => ctx.measureText(l).width))
1093
+ const x = cand.pr.x + 7
1094
+ const y = cand.pr.y + 6
1095
+ const rect = { x: x - 2, y: y - 1, w: w + 4, h: lines.length * lineH + 2 }
1096
+ if (rect.x < 4 || rect.x + rect.w > state.w - 4) continue
1097
+ if (rect.y < 4 || rect.y + rect.h > state.h - 4) continue
1098
+ if (placed.some(r => overlaps(r, rect))) continue
1099
+ placed.push(rect)
1100
+ drawn += 1
1101
+
1102
+ for (let i = 0; i < lines.length; i += 1) {
1103
+ const ly = y + i * lineH
1104
+ ctx.globalAlpha = 0.94
1105
+ ctx.lineWidth = 3
1106
+ ctx.strokeStyle = 'rgba(6,9,22,0.96)'
1107
+ ctx.strokeText(lines[i], x, ly)
1108
+ ctx.fillStyle = cand.isUser ? '#bfe6ff' : '#dcd0ff'
1109
+ ctx.fillText(lines[i], x, ly)
1110
+ }
1111
+ // A hairline tying the caption to its star.
1112
+ ctx.globalAlpha = 0.3
1113
+ ctx.beginPath()
1114
+ ctx.moveTo(cand.pr.x, cand.pr.y)
1115
+ ctx.lineTo(x - 2, y + 3)
1116
+ ctx.strokeStyle = cand.isUser ? 'rgba(150,210,255,0.8)' : 'rgba(200,170,255,0.8)'
1117
+ ctx.lineWidth = 0.7
1118
+ ctx.stroke()
1119
+ }
1120
+ ctx.restore()
1121
+ }
1122
+
1123
+ function overlaps(a, b) {
1124
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
1125
+ }
1126
+
1127
+ /** Greedy wrap by measured width, capped at 3 lines. */
1128
+ function wrapText(text, maxW, context) {
1129
+ const lines = []
1130
+ let line = ''
1131
+ for (const ch of text) {
1132
+ const next = line + ch
1133
+ if (context.measureText(next).width > maxW && line !== '') {
1134
+ lines.push(line)
1135
+ line = ch
1136
+ if (lines.length >= 3) { lines[2] = `${lines[2].slice(0, -1)}…`; return lines }
1137
+ } else {
1138
+ line = next
1139
+ }
1140
+ }
1141
+ if (line !== '') lines.push(line)
1142
+ return lines
1143
+ }
1144
+
1145
+ const LOD_LABELS = ['全景', '星团', '记忆摘要', '记忆全文']
1146
+ function updateLodBadge() {
1147
+ const badge = document.getElementById('lod-badge')
1148
+ if (!badge) return
1149
+ badge.textContent = LOD_LABELS[state.lod] || LOD_LABELS[0]
1150
+ badge.dataset.lod = String(state.lod)
1151
+ const title = state.focus ? state.focus.session.title : ''
1152
+ badge.title = title ? `${LOD_LABELS[state.lod]} · ${title}` : LOD_LABELS[state.lod]
1153
+ }
1154
+
1155
+ let lastFrame = 0
1156
+ const frameGaps = []
1157
+ function render() {
1158
+ // Render at the display's own rate: a fixed 30 fps gate made the flowing
1159
+ // current visibly step on a 60/120 Hz screen.
1160
+ const now = signalClock()
1161
+ const dtMs = lastFrame === 0 ? 16 : Math.min(64, now - lastFrame)
1162
+ lastFrame = now
1163
+ state.time = now
1164
+ const dt = dtMs / 1000
1165
+ // Frame-rate independent easing: same perceived response at 60 or 120 Hz.
1166
+ const ease = 1 - Math.exp(-dt * 7.5)
1167
+ if (state.autoRotate && !state.dragging) state.targetYaw += 0.096 * dt
1168
+ state.yaw += (state.targetYaw - state.yaw) * ease
1169
+ state.pitch += (state.targetPitch - state.pitch) * ease
1170
+ state.zoom += (state.targetZoom - state.zoom) * ease
1171
+
1172
+ // Level of detail is a pure function of the eased zoom, so the bands feel
1173
+ // continuous rather than snapping at the moment you stop scrolling.
1174
+ // Level of detail follows the user's INTENDED zoom, not the eased value:
1175
+ // easing approaches its target asymptotically, so testing state.zoom made
1176
+ // the band boundary unreachable in practice (2.759 vs a 2.8 threshold).
1177
+ const lod = lodFor(state.targetZoom)
1178
+ if (lod !== state.lod) { state.lod = lod; updateLodBadge() }
1179
+
1180
+ // From band 2 on, one cluster becomes the subject of the close-up.
1181
+ if (lod >= 2) {
1182
+ if (state.focus === null) state.focus = clusterAtCenter()
1183
+ } else {
1184
+ state.focus = null
1185
+ }
1186
+ const wantFocus = lod >= 2 && state.focus !== null ? 1 : 0
1187
+ state.focusDepth += (wantFocus - state.focusDepth) * 0.12
1188
+ if (state.focus && lod >= 3) requestDetail(state.focus)
1189
+
1190
+ drawBackground()
1191
+
1192
+ // Project everything, then paint back-to-front.
1193
+ const projected = []
1194
+ for (const star of state.stars) {
1195
+ const visible = matches(star.cluster)
1196
+ const pr = project(star.pos)
1197
+ if (pr.x < -60 || pr.x > state.w + 60 || pr.y < -60 || pr.y > state.h + 60) continue
1198
+ projected.push({ star, pr, visible })
1199
+ }
1200
+ projected.sort((a, b) => b.pr.z - a.pr.z)
1201
+
1202
+ if (state.skin === 'neuron') {
1203
+ drawNeuronSkin()
1204
+ } else {
1205
+ drawNebulaClouds()
1206
+ drawRelationArcs()
1207
+ drawNebulaStars(projected)
1208
+ }
1209
+
1210
+ // Captions only make sense once a cluster fills the view.
1211
+ if (lod >= 2) drawFragmentCaptions(lod)
1212
+
1213
+ // Cluster labels. Higher bands show more: title, then counts, then the
1214
+ // dominant topics, so zooming literally reveals more of the memory.
1215
+ if (state.zoom > 0.75) {
1216
+ ctx.save()
1217
+ const baseSize = state.isMobile ? 10 : 11
1218
+ ctx.textBaseline = 'middle'
1219
+ let drawn = 0
1220
+ for (const cluster of state.clusters) {
1221
+ if (!matches(cluster)) continue
1222
+ // At deep zoom only the focused cluster is labelled; the rest are off
1223
+ // in the surrounding fog and would only clutter the close-up.
1224
+ if (lod >= 2 && cluster !== state.focus) continue
1225
+ if (state.isMobile && lod < 2 && drawn >= 8) break
1226
+ const pr = project(cluster.center)
1227
+ if (lod < 2 && pr.scale < (state.isMobile ? 0.30 : 0.55)) continue
1228
+ drawn += 1
1229
+
1230
+ const trunc = lod >= 2 ? 40 : state.isMobile ? 14 : 22
1231
+ const title = cluster.session.title
1232
+ const label = title.length > trunc ? `${title.slice(0, trunc)}…` : title
1233
+ const size = lod >= 2 ? baseSize + 3 : baseSize
1234
+ ctx.font = `${lod >= 2 ? 600 : 400} ${size}px Inter, "PingFang SC", system-ui, sans-serif`
1235
+ // Capped so a very deep zoom does not push the title off-screen.
1236
+ const ly = pr.y + Math.min(120, 30 * pr.scale) + 8
1237
+ ctx.globalAlpha = 0.88
1238
+ ctx.lineWidth = 3
1239
+ ctx.strokeStyle = 'rgba(7,10,28,0.92)'
1240
+ ctx.strokeText(label, pr.x + 6, ly)
1241
+ ctx.fillStyle = '#d6deff'
1242
+ ctx.fillText(label, pr.x + 6, ly)
1243
+
1244
+ // Band 1+: activity counts beside the title.
1245
+ if (lod >= 1) {
1246
+ const c = cluster.session.counts
1247
+ const meta = `${c.fragments} 条记忆 · ${c.user}/${c.assistant} 对话 · ${c.tools} 工具`
1248
+ ctx.font = `400 ${baseSize - 1}px Inter, system-ui, sans-serif`
1249
+ ctx.globalAlpha = 0.7
1250
+ ctx.strokeText(meta, pr.x + 6, ly + size + 4)
1251
+ ctx.fillStyle = '#9db0e6'
1252
+ ctx.fillText(meta, pr.x + 6, ly + size + 4)
1253
+ }
1254
+ // Band 2+: the topics that put this session where it is.
1255
+ if (lod >= 2 && (cluster.session.topics || []).length > 0) {
1256
+ const words = cluster.session.topics.slice(0, 6).map(t => t.word).join(' · ')
1257
+ ctx.font = `400 ${baseSize - 1}px Inter, system-ui, sans-serif`
1258
+ ctx.globalAlpha = 0.6
1259
+ ctx.strokeText(words, pr.x + 6, ly + size * 2 + 8)
1260
+ ctx.fillStyle = '#8fa6d8'
1261
+ ctx.fillText(words, pr.x + 6, ly + size * 2 + 8)
1262
+ }
1263
+ // Band 3: a footer telling the reader the text is now complete.
1264
+ if (lod >= 3) {
1265
+ const status = cluster.hasFullDetail ? '已载入完整记忆' : '正在载入完整记忆…'
1266
+ ctx.font = `400 ${baseSize - 1}px Inter, system-ui, sans-serif`
1267
+ ctx.globalAlpha = 0.55
1268
+ ctx.fillStyle = cluster.hasFullDetail ? '#8fd6b4' : '#d8c98a'
1269
+ ctx.fillText(status, pr.x + 6, ly + size * 3 + 12)
1270
+ }
1271
+ }
1272
+ ctx.restore()
1273
+ }
1274
+
1275
+ // Adaptive frame gate: stay uncapped while the device keeps up, and fall
1276
+ // back to 30 fps only after a sustained streak of expensive frames (an
1277
+ // Android WebView under load). It recovers on its own when frames get
1278
+ // cheap again, so a slow start never permanently caps the animation.
1279
+ // Adaptive quality ONLY — never the frame rate.
1280
+ //
1281
+ // An earlier version dropped the whole scene to 30 fps when frames got
1282
+ // expensive, then recovered as soon as they got cheap again. That feedback
1283
+ // loop oscillates between 60 and 30 fps, which reads as far worse
1284
+ // stuttering than staying at one rate. Frame pacing is now left to the
1285
+ // browser; the only adaptation is how finely the crest is sampled, decided
1286
+ // from the real frame interval with wide hysteresis so it cannot flap.
1287
+ frameGaps.push(dtMs)
1288
+ if (frameGaps.length > 30) frameGaps.shift()
1289
+ if (frameGaps.length === 30) {
1290
+ const sorted = [...frameGaps].sort((a, b) => a - b)
1291
+ const p50 = sorted[15]
1292
+ if (p50 > 22 && state.quality > 0.5) state.quality = 0.5
1293
+ else if (p50 < 15 && state.quality < 1) state.quality = 1
1294
+ }
1295
+
1296
+ requestAnimationFrame(render)
1297
+ }
1298
+
1299
+ // -------------------------------------------------------------------------
1300
+ // Hit testing & interaction
1301
+ // -------------------------------------------------------------------------
1302
+ function hitTest(x, y) {
1303
+ let best = null
1304
+ let bestDist = state.isMobile ? 40 : 24
1305
+ // A whole gas cloud should be tappable, not just its brightest star.
1306
+ // Anchors sit at the condensations; give them a cloud-sized hit radius.
1307
+ const touchPad = state.isMobile ? 18 : 6
1308
+ for (const star of state.stars) {
1309
+ if (!matches(star.cluster)) continue
1310
+ const pr = project(star.pos)
1311
+ const d = Math.hypot(pr.x - x, pr.y - y)
1312
+ const isCloudTarget = star.kind === 'anchor' || star.kind === 'core'
1313
+ // Capped: at deep zoom an uncapped cloud radius would swallow the whole
1314
+ // viewport and make nothing else selectable.
1315
+ const radius = isCloudTarget
1316
+ ? Math.min(state.isMobile ? 60 : 48, Math.max(state.isMobile ? 30 : 24, 30 * pr.scale))
1317
+ : Math.max(state.isMobile ? 20 : 8, star.size * pr.scale + touchPad)
1318
+ if (d < radius && d < bestDist) {
1319
+ bestDist = d
1320
+ best = star
1321
+ }
1322
+ }
1323
+ return best
1324
+ }
1325
+
1326
+ function showTooltip(star, x, y) {
1327
+ if (star == null) { el.tooltip.hidden = true; return }
1328
+ const s = star.cluster.session
1329
+ let title, sub, text
1330
+ if (star.isClusterCore || star.kind === 'anchor') {
1331
+ title = s.title
1332
+ sub = `${s.workspaceLabel} · ${s.counts.user} 用户消息 · ${s.fragments.length} 记忆星`
1333
+ text = '点击展开这段会话的全部记忆碎片'
1334
+ } else {
1335
+ title = s.title
1336
+ sub = star.fragment.kind === 'user' ? '用户记忆' : '助手记忆'
1337
+ text = star.fragment.text
1338
+ }
1339
+ el.tooltip.innerHTML = `<div class="tt-title">${escapeHtml(title)}</div><div class="tt-sub">${escapeHtml(sub)}</div><div class="tt-text">${escapeHtml(text)}</div>`
1340
+ el.tooltip.hidden = false
1341
+ const tw = el.tooltip.offsetWidth, th = el.tooltip.offsetHeight
1342
+ // On touch screens a finger sits below/right of the contact point, so float
1343
+ // the bubble above it; desktop keeps the classic lower-right placement.
1344
+ const left = Math.max(8, Math.min(state.w - tw - 8, x + (state.isMobile ? -tw / 2 : 14)))
1345
+ const top = state.isMobile
1346
+ ? Math.max(8, y - th - 18)
1347
+ : Math.max(8, Math.min(state.h - th - 12, y + 14))
1348
+ el.tooltip.style.left = `${left}px`
1349
+ el.tooltip.style.top = `${top}px`
1350
+ }
1351
+
1352
+ function openDetail(cluster) {
1353
+ const s = cluster.session
1354
+ state.selected = cluster
1355
+ state.focus = cluster
1356
+ // Opening a memory is a decision to look at THAT one; at a shallow zoom we
1357
+ // also drift toward its band so the close-up is ready when the user zooms.
1358
+ if (state.zoom < LOD_STEPS[0]) state.targetZoom = LOD_STEPS[0] + 0.4
1359
+ el.detailTitle.textContent = s.title
1360
+ const date = s.createdAt ? new Date(s.createdAt).toLocaleString() : '—'
1361
+ el.detailMeta.innerHTML = [
1362
+ `📁 ${escapeHtml(s.workspaceLabel)}`,
1363
+ `💬 ${s.counts.user} / ${s.counts.assistant} 条对话`,
1364
+ `🛠 ${s.counts.tools} 次工具调用`,
1365
+ `🕒 ${escapeHtml(date)}`,
1366
+ ].map(t => `<span>${t}</span>`).join('')
1367
+ el.detailFragments.innerHTML = (s.fragments || []).map(f =>
1368
+ `<div class="frag ${f.kind}"><span class="who">${f.kind === 'user' ? '你' : '助手'}${f.time ? ' · ' + escapeHtml(new Date(f.time).toLocaleString()) : ''}</span>${escapeHtml(f.text)}</div>`).join('')
1369
+ || '<div class="frag"><span class="who">—</span>这段会话没有可显示的文本记忆。</div>'
1370
+ el.detail.hidden = false
1371
+ }
1372
+ function closeDetail() {
1373
+ state.selected = null
1374
+ el.detail.hidden = true
1375
+ }
1376
+
1377
+ function stopAutoRotate() {
1378
+ state.autoRotate = false
1379
+ el.autorotate.classList.remove('active')
1380
+ el.autorotate.setAttribute('aria-pressed', 'false')
1381
+ }
1382
+
1383
+ function pointerDist() {
1384
+ const pts = [...state.pointers.values()]
1385
+ if (pts.length < 2) return 0
1386
+ return Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y)
1387
+ }
1388
+
1389
+ canvas.addEventListener('pointerdown', e => {
1390
+ state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
1391
+ try { canvas.setPointerCapture(e.pointerId) } catch (_) {}
1392
+ el.tooltip.hidden = true
1393
+ if (state.pointers.size === 2) {
1394
+ // Second finger down: begin a pinch, cancelling any single-finger drag.
1395
+ state.pinch = { startDist: pointerDist(), startZoom: state.targetZoom }
1396
+ state.dragging = false
1397
+ state.moved = true // a pinch must never be read as a tap
1398
+ canvas.classList.remove('dragging')
1399
+ return
1400
+ }
1401
+ if (state.pointers.size > 2) return
1402
+ state.dragging = true
1403
+ state.moved = false
1404
+ state.lastX = e.clientX
1405
+ state.lastY = e.clientY
1406
+ canvas.classList.add('dragging')
1407
+ })
1408
+
1409
+ canvas.addEventListener('pointermove', e => {
1410
+ const prev = state.pointers.get(e.pointerId)
1411
+ if (prev === undefined) {
1412
+ // Mouse hover (no button): show the tooltip.
1413
+ if (!state.dragging && e.pointerType === 'mouse') {
1414
+ const star = hitTest(e.clientX, e.clientY)
1415
+ state.hover = star
1416
+ showTooltip(star, e.clientX, e.clientY)
1417
+ canvas.style.cursor = star ? 'pointer' : 'grab'
1418
+ }
1419
+ return
1420
+ }
1421
+ state.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
1422
+
1423
+ if (state.pinch !== null && state.pointers.size >= 2) {
1424
+ const dist = pointerDist()
1425
+ if (state.pinch.startDist > 0 && dist > 0) {
1426
+ state.targetZoom = clampZoom(state.pinch.startZoom * (dist / state.pinch.startDist))
1427
+ stopAutoRotate()
1428
+ }
1429
+ return
1430
+ }
1431
+
1432
+ if (state.dragging) {
1433
+ const dx = e.clientX - state.lastX
1434
+ const dy = e.clientY - state.lastY
1435
+ state.lastX = e.clientX
1436
+ state.lastY = e.clientY
1437
+ if (Math.abs(dx) + Math.abs(dy) > 2) state.moved = true
1438
+ // A slightly slower rotation feels better under a finger than a mouse.
1439
+ const speed = state.isMobile ? 0.0042 : 0.005
1440
+ state.targetYaw += dx * speed
1441
+ state.targetPitch = Math.max(-1.3, Math.min(1.3, state.targetPitch + dy * speed))
1442
+ if (state.moved) stopAutoRotate()
1443
+ el.tooltip.hidden = true
1444
+ }
1445
+ })
1446
+
1447
+ function endPointer(e, cancelled) {
1448
+ const wasSingle = state.pointers.size === 1
1449
+ const wasDragging = state.dragging
1450
+ state.pointers.delete(e.pointerId)
1451
+ try { canvas.releasePointerCapture(e.pointerId) } catch (_) {}
1452
+
1453
+ if (state.pointers.size < 2) state.pinch = null
1454
+ if (state.pointers.size === 0) {
1455
+ state.dragging = false
1456
+ canvas.classList.remove('dragging')
1457
+ } else if (state.pointers.size === 1) {
1458
+ // One finger remains after a pinch: resume rotation from its position.
1459
+ const remaining = [...state.pointers.values()][0]
1460
+ state.dragging = true
1461
+ state.moved = true
1462
+ state.lastX = remaining.x
1463
+ state.lastY = remaining.y
1464
+ }
1465
+
1466
+ // A tap opens the cluster; only for a clean single-pointer, unmoved release.
1467
+ if (!cancelled && wasSingle && wasDragging && !state.moved && e.pointerType !== 'mouse') {
1468
+ const star = hitTest(e.clientX, e.clientY)
1469
+ if (star && (star.isClusterCore || star.kind === 'anchor' || star.fragment)) {
1470
+ el.tooltip.hidden = true
1471
+ openDetail(star.cluster)
1472
+ }
1473
+ }
1474
+ // Mouse click (desktop) is handled here too, since pointerup fires for it.
1475
+ if (!cancelled && wasSingle && wasDragging && !state.moved && e.pointerType === 'mouse') {
1476
+ const star = hitTest(e.clientX, e.clientY)
1477
+ if (star && (star.isClusterCore || star.kind === 'anchor' || star.fragment)) {
1478
+ el.tooltip.hidden = true
1479
+ openDetail(star.cluster)
1480
+ }
1481
+ }
1482
+ }
1483
+ canvas.addEventListener('pointerup', e => endPointer(e, false))
1484
+ canvas.addEventListener('pointercancel', e => endPointer(e, true))
1485
+ canvas.addEventListener('pointerleave', () => { el.tooltip.hidden = true; state.hover = null })
1486
+ canvas.addEventListener('wheel', e => {
1487
+ e.preventDefault()
1488
+ state.targetZoom = clampZoom(state.targetZoom * (e.deltaY > 0 ? 0.88 : 1.14))
1489
+ }, { passive: false })
1490
+ // Block the browser's own pinch-zoom / pull-to-refresh over the canvas.
1491
+ canvas.addEventListener('gesturestart', e => e.preventDefault())
1492
+ canvas.addEventListener('gesturechange', e => e.preventDefault())
1493
+
1494
+ // Double-click / double-tap a cluster to fly all the way in and read it.
1495
+ canvas.addEventListener('dblclick', e => {
1496
+ const star = hitTest(e.clientX, e.clientY)
1497
+ if (!star) return
1498
+ state.focus = star.cluster
1499
+ stopAutoRotate()
1500
+ state.targetZoom = ZOOM_MAX * 0.72
1501
+ if (state.zoom >= LOD_STEPS[2]) requestDetail(star.cluster)
1502
+ })
1503
+
1504
+ el.search.addEventListener('input', () => { state.query = el.search.value })
1505
+ for (const btn of el.skinButtons) {
1506
+ btn.addEventListener('click', () => setSkin(btn.dataset.skin))
1507
+ }
1508
+ el.autorotate.addEventListener('click', () => {
1509
+ state.autoRotate = !state.autoRotate
1510
+ el.autorotate.classList.toggle('active', state.autoRotate)
1511
+ el.autorotate.setAttribute('aria-pressed', String(state.autoRotate))
1512
+ })
1513
+ el.reset.addEventListener('click', () => {
1514
+ state.targetYaw = 0.6; state.targetPitch = 0.32; state.targetZoom = 1
1515
+ state.focus = null
1516
+ })
1517
+ el.detailClose.addEventListener('click', closeDetail)
1518
+ // Inside the Harness the ✕ asks the host overlay to close; opened directly
1519
+ // in a browser tab there is no host to dismiss, so hide the control there.
1520
+ const embedded = window.parent !== window
1521
+ if (!embedded) el.close.hidden = true
1522
+ el.close.addEventListener('click', () => {
1523
+ try {
1524
+ if (embedded) window.parent.postMessage({ source: 'dsh-memory-cloud', type: 'close' }, location.origin)
1525
+ } catch (_) {}
1526
+ })
1527
+ window.addEventListener('keydown', e => {
1528
+ if (e.key === 'Escape') {
1529
+ if (!el.detail.hidden) closeDetail()
1530
+ else if (window.parent !== window) window.parent.postMessage({ source: 'dsh-memory-cloud', type: 'close' }, location.origin)
1531
+ }
1532
+ })
1533
+ // The nebula is dark-only by design; no theme messages are exchanged.
1534
+ window.addEventListener('resize', resize)
1535
+ window.addEventListener('orientationchange', () => window.setTimeout(resize, 250))
1536
+
1537
+ // -------------------------------------------------------------------------
1538
+ // Boot
1539
+ // -------------------------------------------------------------------------
1540
+ resize()
1541
+ let savedSkin = 'nebula'
1542
+ try { savedSkin = window.localStorage.getItem(SKIN_KEY) || 'nebula' } catch (_) { /* private mode */ }
1543
+ setSkin(savedSkin)
1544
+ requestAnimationFrame(render)
1545
+ fetch('/memory-cloud/api/memories', { headers: { accept: 'application/json' } })
1546
+ .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
1547
+ .then(data => { build(data); el.loading.hidden = true })
1548
+ .catch(err => {
1549
+ el.loading.innerHTML = `<span>无法读取记忆数据:${escapeHtml(err.message)}</span>`
1550
+ })
1551
+ })()