deepseek-plastic-memory 2.1.5 → 3.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2336 @@
1
+ /**
2
+ * 记忆星空 —— WebGL 星系
3
+ * 主星云 = 主分区,副星辰 = 子分区,小点是单条记忆。
4
+ * 画面全在显卡上跑;页面只读本机的可塑性记忆数据,不发任何模型请求。
5
+ */
6
+ import * as THREE from './vendor/three.module.js'
7
+
8
+ const $ = (id) => document.getElementById(id)
9
+ const API = '/api/memory-sky'
10
+
11
+ const damp = (a, b, lambda, dt) => a + (b - a) * (1 - Math.exp(-lambda * dt))
12
+ const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
13
+ const rnd = (seed) => {
14
+ const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453
15
+ return x - Math.floor(x)
16
+ }
17
+ let toastTimer = 0
18
+ function toast(text, kind = '') {
19
+ const el = $('toast')
20
+ el.textContent = text
21
+ el.className = 'glass show ' + kind
22
+ clearTimeout(toastTimer)
23
+ toastTimer = setTimeout(() => { el.className = 'glass' }, 2200)
24
+ }
25
+
26
+ const API_BASE = API
27
+
28
+ /* ---------------- 状态 ---------------- */
29
+ const state = {
30
+ scope: 'global',
31
+ scopes: [],
32
+ folders: [],
33
+ memories: [],
34
+ sel: null, // { kind:'main'|'sub'|'mem', k, id }
35
+ editing: false,
36
+ viewingMem: null, // 正在看详情的记忆
37
+ query: '',
38
+ matches: new Set(),
39
+ pinnedOnly: false,
40
+ bandView: null, // { kind:'fading'|'forgotten', folderName }
41
+ demo: { on: false, gear: 40, clean: false },
42
+ theme: (() => {
43
+ try {
44
+ const q = new URLSearchParams(location.search).get('theme')
45
+ if (q === 'starburst' || q === 'nebula' || q === 'neuron') return q
46
+ const saved = localStorage.getItem('memory-sky-theme')
47
+ if (saved === 'starburst' || saved === 'nebula' || saved === 'neuron') return saved
48
+ } catch {}
49
+ return 'nebula'
50
+ })(),
51
+ }
52
+
53
+ /* ---------------- Three ---------------- */
54
+ const canvas = $('sky')
55
+ let renderer, scene, camera
56
+ let dust, threads, threadMat
57
+ const mains = new Map() // k -> { group, sprite, label, pos, subs:[] }
58
+ const subs = new Map() // k -> { group, sprite, label, parentK, pos }
59
+ const mems = new Map() // id -> { sprite, base, op, pos, folder }
60
+ const pickables = [] // { kind, k, id, object3d }
61
+ const labelNodes = [] // 需要按距离缩放标签的节点
62
+ const tmpWorld = new THREE.Vector3()
63
+ let hotRanges = new Map() // memoryId -> [{start,count}]
64
+ const hotAttr = { array: null, needsUpdate: false }
65
+ let threadGeo = null
66
+
67
+ const cam = { yaw: 0.62, pitch: 0.5, dist: 118, tx: 0, ty: 0, tz: 0 }
68
+ const camCur = { yaw: 0.62, pitch: 0.5, dist: 118, tx: 0, ty: 0, tz: 0 }
69
+ const fit = { dist: 46 }
70
+ let hovered = null
71
+ let drag = null
72
+ let moved = 0
73
+ let lastT = 0
74
+
75
+ const PALETTE = [
76
+ [0.82, 0.88, 1.0], [1.0, 0.94, 0.84], [0.86, 0.92, 1.0],
77
+ [0.96, 0.92, 0.9], [0.9, 0.96, 1.0], [1.0, 0.97, 0.9],
78
+ [0.88, 0.9, 1.0], [0.94, 0.96, 1.0],
79
+ ]
80
+
81
+ function radialTexture(power = 2.3, size = 128) {
82
+ const c = document.createElement('canvas')
83
+ c.width = c.height = size
84
+ const ctx = c.getContext('2d')
85
+ const grad = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2)
86
+ for (let i = 0; i <= 16; i++) {
87
+ const t = i / 16
88
+ grad.addColorStop(t, `rgba(255,255,255,${Math.pow(1 - t, power).toFixed(4)})`)
89
+ }
90
+ ctx.fillStyle = grad
91
+ ctx.fillRect(0, 0, size, size)
92
+ const tex = new THREE.CanvasTexture(c)
93
+ tex.colorSpace = THREE.SRGBColorSpace
94
+ return tex
95
+ }
96
+
97
+ function ringTexture(size = 192) {
98
+ const c = document.createElement('canvas')
99
+ c.width = c.height = size
100
+ const g = c.getContext('2d')
101
+ const cx = size / 2
102
+ g.strokeStyle = 'rgba(170,205,255,0.85)'
103
+ g.lineWidth = size * 0.012
104
+ g.beginPath(); g.arc(cx, cx, size * 0.4, 0, Math.PI * 2); g.stroke()
105
+ g.strokeStyle = 'rgba(220,235,255,0.95)'
106
+ g.lineWidth = size * 0.02
107
+ for (let i = 0; i < 4; i++) {
108
+ const a = (i / 4) * Math.PI * 2
109
+ g.beginPath(); g.arc(cx, cx, size * 0.4, a, a + 0.13); g.stroke()
110
+ }
111
+ const tex = new THREE.CanvasTexture(c)
112
+ tex.colorSpace = THREE.SRGBColorSpace
113
+ return tex
114
+ }
115
+
116
+ function textSprite(text, { size = 34, color = '#dfe6ff', glow = 8, opacity = 0.95 } = {}) {
117
+ const pad = 14
118
+ const c = document.createElement('canvas')
119
+ const ctx0 = c.getContext('2d')
120
+ const font = `500 ${size}px "Microsoft YaHei UI","Microsoft YaHei",-apple-system,Segoe UI,sans-serif`
121
+ ctx0.font = font
122
+ c.width = Math.ceil(ctx0.measureText(text).width) + pad * 2
123
+ c.height = size + pad * 2
124
+ const g = c.getContext('2d')
125
+ g.font = font
126
+ g.textAlign = 'center'
127
+ g.textBaseline = 'middle'
128
+ g.shadowColor = 'rgba(0,0,0,0.95)'
129
+ g.shadowBlur = 14
130
+ g.fillStyle = 'rgba(0,0,0,0.75)'
131
+ g.globalAlpha = opacity
132
+ g.fillText(text, c.width / 2, c.height / 2)
133
+ g.shadowBlur = 4
134
+ g.shadowColor = color
135
+ g.fillStyle = color
136
+ g.fillText(text, c.width / 2, c.height / 2)
137
+ const tex = new THREE.CanvasTexture(c)
138
+ tex.colorSpace = THREE.SRGBColorSpace
139
+ tex.minFilter = THREE.LinearFilter
140
+ const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, depthTest: false, opacity }))
141
+ sp.scale.set(c.width * 0.014, c.height * 0.014, 1)
142
+ sp.renderOrder = 60
143
+ sp.userData.base = opacity
144
+ sp.userData.perInstance = true
145
+ return sp
146
+ }
147
+
148
+ function initRenderer() {
149
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' })
150
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
151
+ renderer.setSize(window.innerWidth, window.innerHeight, false)
152
+ renderer.setClearColor(0x03050c, 1)
153
+ scene = new THREE.Scene()
154
+ camera = new THREE.PerspectiveCamera(48, window.innerWidth / window.innerHeight, 0.1, 3200)
155
+ initPost()
156
+ buildSky(state.theme)
157
+ window.addEventListener('resize', () => {
158
+ camera.aspect = window.innerWidth / window.innerHeight
159
+ camera.updateProjectionMatrix()
160
+ renderer.setSize(window.innerWidth, window.innerHeight, false)
161
+ resizePost()
162
+ })
163
+ }
164
+
165
+ /* ---------------- 后期:泛光 + 色调映射 + 暗角,做出震撼感 ---------------- */
166
+ let rtScene = null
167
+ let rtBlurA = null
168
+ let rtBlurB = null
169
+ let quadScene = null
170
+ let quadCam = null
171
+ let quadMesh = null
172
+ let matBright = null
173
+ let matBlur = null
174
+ let matPost = null
175
+ const postUniforms = { uBloom: 0.34, uVignette: 0.55 }
176
+
177
+ function initPost() {
178
+ const size = new THREE.Vector2()
179
+ renderer.getDrawingBufferSize(size)
180
+ const opt = { type: THREE.HalfFloatType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter }
181
+ rtScene = new THREE.WebGLRenderTarget(size.x, size.y, opt)
182
+ rtBlurA = new THREE.WebGLRenderTarget(Math.max(2, size.x >> 1), Math.max(2, size.y >> 1), opt)
183
+ rtBlurB = new THREE.WebGLRenderTarget(Math.max(2, size.x >> 1), Math.max(2, size.y >> 1), opt)
184
+
185
+ quadScene = new THREE.Scene()
186
+ quadCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)
187
+ quadMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), null)
188
+ quadMesh.frustumCulled = false
189
+ quadScene.add(quadMesh)
190
+
191
+ const vert = `
192
+ varying vec2 vUv;
193
+ void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
194
+ `
195
+
196
+ matBright = new THREE.ShaderMaterial({
197
+ uniforms: { tDiffuse: { value: null }, uThreshold: { value: 0.72 } },
198
+ vertexShader: vert,
199
+ fragmentShader: `
200
+ varying vec2 vUv;
201
+ uniform sampler2D tDiffuse;
202
+ uniform float uThreshold;
203
+ void main() {
204
+ vec3 c = texture2D(tDiffuse, vUv).rgb;
205
+ float l = max(c.r, max(c.g, c.b));
206
+ float k = max(0.0, l - uThreshold) / max(l, 1e-4);
207
+ gl_FragColor = vec4(c * k, 1.0);
208
+ }
209
+ `,
210
+ })
211
+
212
+ matBlur = new THREE.ShaderMaterial({
213
+ uniforms: { tDiffuse: { value: null }, uDir: { value: new THREE.Vector2(1, 0) } },
214
+ vertexShader: vert,
215
+ fragmentShader: `
216
+ varying vec2 vUv;
217
+ uniform sampler2D tDiffuse;
218
+ uniform vec2 uDir;
219
+ void main() {
220
+ vec3 s = texture2D(tDiffuse, vUv).rgb * 0.227027;
221
+ s += texture2D(tDiffuse, vUv + uDir * 1.384615).rgb * 0.316216;
222
+ s += texture2D(tDiffuse, vUv - uDir * 1.384615).rgb * 0.316216;
223
+ s += texture2D(tDiffuse, vUv + uDir * 3.230769).rgb * 0.070270;
224
+ s += texture2D(tDiffuse, vUv - uDir * 3.230769).rgb * 0.070270;
225
+ gl_FragColor = vec4(s, 1.0);
226
+ }
227
+ `,
228
+ })
229
+
230
+ matPost = new THREE.ShaderMaterial({
231
+ uniforms: {
232
+ tScene: { value: null },
233
+ tBloom: { value: null },
234
+ uBloom: { value: postUniforms.uBloom },
235
+ uVignette: { value: postUniforms.uVignette },
236
+ },
237
+ vertexShader: vert,
238
+ fragmentShader: `
239
+ varying vec2 vUv;
240
+ uniform sampler2D tScene;
241
+ uniform sampler2D tBloom;
242
+ uniform float uBloom;
243
+ uniform float uVignette;
244
+ void main() {
245
+ vec3 base = texture2D(tScene, vUv).rgb;
246
+ vec3 bloom = texture2D(tBloom, vUv).rgb;
247
+ vec3 col = base * 1.06 + bloom * uBloom;
248
+ col = vec3(1.0) - exp(-col * 1.75);
249
+ col = pow(col, vec3(0.92));
250
+ vec2 p = vUv - 0.5;
251
+ float v = 1.0 - dot(p, p) * uVignette;
252
+ col *= clamp(v, 0.0, 1.0);
253
+ gl_FragColor = vec4(col, 1.0);
254
+ }
255
+ `,
256
+ })
257
+ }
258
+
259
+ function resizePost() {
260
+ const size = new THREE.Vector2()
261
+ renderer.getDrawingBufferSize(size)
262
+ rtScene.setSize(size.x, size.y)
263
+ rtBlurA.setSize(Math.max(2, size.x >> 1), Math.max(2, size.y >> 1))
264
+ rtBlurB.setSize(Math.max(2, size.x >> 1), Math.max(2, size.y >> 1))
265
+ }
266
+
267
+ function renderFrame() {
268
+ renderer.setRenderTarget(rtScene)
269
+ renderer.clear()
270
+ renderer.render(scene, camera)
271
+
272
+ quadMesh.material = matBright
273
+ matBright.uniforms.tDiffuse.value = rtScene.texture
274
+ renderer.setRenderTarget(rtBlurA)
275
+ renderer.render(quadScene, quadCam)
276
+
277
+ quadMesh.material = matBlur
278
+ // 三次乒乓模糊,半径铺开,光晕才是"晕"而不是重影
279
+ let src = rtBlurA
280
+ let dst = rtBlurB
281
+ for (let i = 0; i < 3; i++) {
282
+ matBlur.uniforms.tDiffuse.value = src.texture
283
+ matBlur.uniforms.uDir.value.set((1.2 + i * 1.1) / rtBlurA.width, 0)
284
+ renderer.setRenderTarget(dst)
285
+ renderer.render(quadScene, quadCam)
286
+
287
+ matBlur.uniforms.tDiffuse.value = dst.texture
288
+ matBlur.uniforms.uDir.value.set(0, (1.2 + i * 1.1) / rtBlurB.height)
289
+ renderer.setRenderTarget(src)
290
+ renderer.render(quadScene, quadCam)
291
+ }
292
+
293
+ matPost.uniforms.uBloom.value = postUniforms.uBloom * (0.97 + 0.04 * Math.sin(performance.now() * 0.00035))
294
+ renderer.setRenderTarget(null)
295
+ renderer.clear()
296
+ quadMesh.material = matPost
297
+ matPost.uniforms.tScene.value = rtScene.texture
298
+ matPost.uniforms.tBloom.value = rtBlurA.texture
299
+ renderer.setRenderTarget(null)
300
+ renderer.render(quadScene, quadCam)
301
+ }
302
+
303
+
304
+ function buildDust() {
305
+ const count = 2200
306
+ const pos = new Float32Array(count * 3)
307
+ const seed = new Float32Array(count)
308
+ for (let i = 0; i < count; i++) {
309
+ const r = 70 + rnd(i * 3.1) * 150
310
+ const th = rnd(i * 7.7) * Math.PI * 2
311
+ const ph = Math.acos(2 * rnd(i * 5.3) - 1)
312
+ pos[i * 3] = r * Math.sin(ph) * Math.cos(th)
313
+ pos[i * 3 + 1] = r * Math.cos(ph) * 0.7
314
+ pos[i * 3 + 2] = r * Math.sin(ph) * Math.sin(th)
315
+ seed[i] = rnd(i * 11.3)
316
+ }
317
+ const geo = new THREE.BufferGeometry()
318
+ geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
319
+ geo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1))
320
+ dust = new THREE.Points(geo, pointMaterial())
321
+ dust.frustumCulled = false
322
+ scene.add(dust)
323
+ }
324
+
325
+ /* ---------------- 纯程序化三维星云:全部由着色器现算,没有一张贴图 ---------------- */
326
+ let skyRig = new THREE.Group()
327
+ const cloudMeshes = []
328
+
329
+ const THEMES = {
330
+ nebula: { label: '星野', pitch: 0.26, dist: 108, kind: 'nebula' },
331
+ starburst: { label: '恒星暴', pitch: 0.4, dist: 150, kind: 'galaxy' },
332
+ neuron: { label: '神经云', pitch: 0.34, dist: 150, kind: 'neuron' },
333
+ }
334
+
335
+ /** 云气着色器:fbm 噪声 + 径向衰减,出来是有丝缕感的星云而不是光斑 */
336
+ function cloudMaterial(colorA, colorB, seed, opacity, dust = false) {
337
+ return new THREE.ShaderMaterial({
338
+ transparent: true, depthWrite: false, depthTest: true,
339
+ blending: dust ? THREE.NormalBlending : THREE.AdditiveBlending,
340
+ uniforms: {
341
+ uSeed: { value: seed },
342
+ uRot: { value: seed * 1.7 },
343
+ uTime: { value: 0 },
344
+ uColorA: { value: new THREE.Color(colorA[0], colorA[1], colorA[2]) },
345
+ uColorB: { value: new THREE.Color(colorB[0], colorB[1], colorB[2]) },
346
+ uOpacity: { value: opacity },
347
+ uDust: { value: dust ? 1 : 0 },
348
+ },
349
+ vertexShader: `
350
+ varying vec2 vUv;
351
+ varying vec3 vPos;
352
+ void main() {
353
+ vUv = uv;
354
+ vPos = position;
355
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
356
+ }
357
+ `,
358
+ fragmentShader: `
359
+ varying vec2 vUv;
360
+ varying vec3 vPos;
361
+ uniform float uSeed, uRot, uTime, uOpacity, uDust;
362
+ uniform vec3 uColorA, uColorB;
363
+
364
+ float hash(vec2 p) {
365
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
366
+ }
367
+ float noise(vec2 p) {
368
+ vec2 i = floor(p);
369
+ vec2 f = fract(p);
370
+ vec2 u = f * f * (3.0 - 2.0 * f);
371
+ return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
372
+ mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
373
+ }
374
+ float fbm(vec2 p) {
375
+ float v = 0.0;
376
+ float a = 0.55;
377
+ for (int i = 0; i < 5; i++) {
378
+ v += a * noise(p);
379
+ p *= 2.03;
380
+ a *= 0.5;
381
+ }
382
+ return v;
383
+ }
384
+ void main() {
385
+ vec2 p = vUv - 0.5;
386
+ float r = length(p) * 2.0;
387
+ float ca = cos(uRot), sa = sin(uRot);
388
+ vec2 q = mat2(ca, -sa, sa, ca) * p * 3.4 + vec2(uSeed, uSeed * 1.7);
389
+ float n = fbm(q + fbm(q * 1.7 + uTime * 0.01) * 0.6);
390
+ // 轮廓只由半径决定(一路缓降到 0),噪声只负责浓淡 —— 不会切出"海岸线"
391
+ float fall = pow(1.0 - smoothstep(0.0, 1.0, r), 1.7);
392
+ float wisp = fall * (0.5 + 0.5 * smoothstep(0.1, 1.0, n));
393
+ vec3 col = mix(uColorA, uColorB, clamp(n * 1.25, 0.0, 1.0));
394
+ gl_FragColor = vec4(col, wisp * uOpacity);
395
+ }
396
+ `,
397
+ })
398
+ }
399
+
400
+ function addCloudMesh(colorA, colorB, seed, opacity, size, x, y, z, dust = false) {
401
+ const geo = new THREE.CircleGeometry(0.5, 32)
402
+ const mat = cloudMaterial(colorA, colorB, seed, opacity, dust)
403
+ const mesh = new THREE.Mesh(geo, mat)
404
+ mesh.scale.setScalar(size)
405
+ mesh.position.set(x, y, z)
406
+ mesh.renderOrder = dust ? -5 : 1
407
+ skyRig.add(mesh)
408
+ cloudMeshes.push(mesh)
409
+ return mesh
410
+ }
411
+
412
+ /** 星点:自带色温与闪烁 */
413
+ function starField(count, rMin, rMax, sizeK) {
414
+ const pos = new Float32Array(count * 3)
415
+ const col = new Float32Array(count * 3)
416
+ const siz = new Float32Array(count)
417
+ const sd = new Float32Array(count)
418
+ for (let i = 0; i < count; i++) {
419
+ const r = rMin + Math.pow(rnd(i * 3.7), 1.3) * (rMax - rMin)
420
+ const th = rnd(i * 7.7) * Math.PI * 2
421
+ const ph = Math.acos(2 * rnd(i * 5.3) - 1)
422
+ pos[i * 3] = Math.sin(ph) * Math.cos(th) * r
423
+ pos[i * 3 + 1] = Math.cos(ph) * r * 0.85
424
+ pos[i * 3 + 2] = Math.sin(ph) * Math.sin(th) * r
425
+ const t = rnd(i * 11.3)
426
+ col[i * 3] = 0.7 + t * 0.3
427
+ col[i * 3 + 1] = 0.78 + t * 0.18
428
+ col[i * 3 + 2] = 1.0 - t * 0.32
429
+ siz[i] = sizeK * (0.5 + rnd(i * 13.1) * 1.4)
430
+ sd[i] = rnd(i * 17.9)
431
+ }
432
+ const g = new THREE.BufferGeometry()
433
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
434
+ g.setAttribute('aColor', new THREE.BufferAttribute(col, 3))
435
+ g.setAttribute('aSize', new THREE.BufferAttribute(siz, 1))
436
+ g.setAttribute('aSeed', new THREE.BufferAttribute(sd, 1))
437
+ const m = new THREE.ShaderMaterial({
438
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
439
+ uniforms: { uTime: { value: 0 }, uPixel: { value: renderer.getPixelRatio() } },
440
+ vertexShader: `
441
+ attribute vec3 aColor; attribute float aSize; attribute float aSeed;
442
+ uniform float uTime; uniform float uPixel;
443
+ varying vec3 vC; varying float vA;
444
+ void main() {
445
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
446
+ gl_Position = projectionMatrix * mv;
447
+ float tw = 0.8 + 0.2 * sin(uTime * 0.9 + aSeed * 60.0);
448
+ vC = aColor; vA = tw * (0.5 + 0.5 * aSeed);
449
+ gl_PointSize = aSize * uPixel * (420.0 / max(-mv.z, 1.0));
450
+ }
451
+ `,
452
+ fragmentShader: `
453
+ varying vec3 vC; varying float vA;
454
+ void main() {
455
+ vec2 p = gl_PointCoord - 0.5;
456
+ float d = length(p);
457
+ if (d > 0.5) discard;
458
+ float a = smoothstep(0.5, 0.0, d);
459
+ gl_FragColor = vec4(vC, a * a * vA);
460
+ }
461
+ `,
462
+ })
463
+ const pts = new THREE.Points(g, m)
464
+ pts.frustumCulled = false
465
+ skyRig.add(pts)
466
+ return m
467
+ }
468
+
469
+ /** 亮星的十字星芒,同样用着色器画 */
470
+ function spikeStar(x, y, z, size, warm) {
471
+ const geo = new THREE.PlaneGeometry(1, 1)
472
+ const mat = new THREE.ShaderMaterial({
473
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
474
+ uniforms: { uColor: { value: new THREE.Color(warm ? 1.0 : 0.8, warm ? 0.92 : 0.9, warm ? 0.76 : 1.0) } },
475
+ vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
476
+ fragmentShader: `
477
+ varying vec2 vUv; uniform vec3 uColor;
478
+ void main() {
479
+ vec2 p = vUv - 0.5;
480
+ float r = length(p);
481
+ float win = smoothstep(0.5, 0.12, r); // 边缘彻底归零,不留方框
482
+ float core = exp(-dot(p, p) * 240.0);
483
+ float rayH = exp(-abs(p.y) * 200.0) * exp(-abs(p.x) * 5.5);
484
+ float rayV = exp(-abs(p.x) * 200.0) * exp(-abs(p.y) * 5.5);
485
+ float a = core + (rayH + rayV) * 0.5;
486
+ gl_FragColor = vec4(uColor, clamp(a * win, 0.0, 1.0) * 0.85);
487
+ }
488
+ `,
489
+ })
490
+ const m = new THREE.Mesh(geo, mat)
491
+ m.scale.setScalar(size * 0.92)
492
+ m.position.set(x, y, z)
493
+ m.renderOrder = 2
494
+ skyRig.add(m)
495
+ cloudMeshes.push(m)
496
+ }
497
+
498
+ /** 星野:深空蓝底 + 铁锈色尘埃 + 玫红星云 + 大量亮星,对照第一张参考 */
499
+ function buildNebulaSky() {
500
+ starField(11000, 130, 380, 1.15)
501
+ starField(5200, 380, 760, 0.85)
502
+
503
+ const palettes = [
504
+ [[0.10, 0.28, 1.0], [0.45, 0.66, 1.0]],
505
+ [[0.62, 0.22, 0.09], [1.0, 0.56, 0.26]],
506
+ [[1.0, 0.26, 0.40], [1.0, 0.64, 0.70]],
507
+ [[0.10, 0.72, 0.96], [0.58, 0.96, 1.0]],
508
+ [[1.0, 0.60, 0.20], [1.0, 0.88, 0.58]],
509
+ [[0.42, 0.16, 0.86], [0.82, 0.52, 1.0]],
510
+ ]
511
+
512
+ for (let c = 0; c < 7; c++) {
513
+ const pal = palettes[c % palettes.length]
514
+ const ang = rnd(c * 4.1) * Math.PI * 2
515
+ const rad = 110 + rnd(c * 2.7) * 150
516
+ const cx = Math.cos(ang) * rad
517
+ const cy = (rnd(c * 6.3) - 0.5) * 95
518
+ const cz = Math.sin(ang) * rad
519
+ addCloudMesh(pal[1], pal[0], rnd(c * 3.3) * 10, 0.42, 70 + rnd(c) * 48, cx, cy, cz)
520
+ for (let i = 0; i < 9; i++) {
521
+ const a = rnd(c * 9 + i) * Math.PI * 2
522
+ const rr = Math.pow(rnd(c * 3 + i * 1.3), 0.6) * 82
523
+ addCloudMesh(
524
+ pal[0], pal[1],
525
+ rnd(i * 2.7 + c) * 10,
526
+ 0.18 + rnd(i + c) * 0.16,
527
+ 48 + rnd(i * 4.1) * 92,
528
+ cx + Math.cos(a) * rr,
529
+ cy + (rnd(i * 5.9) - 0.5) * 62,
530
+ cz + Math.sin(a) * rr,
531
+ )
532
+ }
533
+ }
534
+
535
+ // 暖色薄雾:只加光不压暗,避免出现暗片切出的硬边
536
+ for (let i = 0; i < 6; i++) {
537
+ const a = rnd(i * 3.9) * Math.PI * 2
538
+ const rr = 90 + rnd(i * 5.1) * 150
539
+ addCloudMesh([0.55, 0.26, 0.12], [1.0, 0.62, 0.32], rnd(i * 7.3) * 10,
540
+ 0.045 + rnd(i) * 0.035,
541
+ 120 + rnd(i * 2.3) * 110,
542
+ Math.cos(a) * rr, (rnd(i * 8.1) - 0.5) * 80, Math.sin(a) * rr)
543
+ }
544
+
545
+ for (let i = 0; i < 22; i++) {
546
+ const a = rnd(i * 4.9) * Math.PI * 2
547
+ const r = 110 + rnd(i * 8.1) * 240
548
+ spikeStar(Math.cos(a) * r, (rnd(i * 3.7) - 0.5) * 180, Math.sin(a) * r,
549
+ 30 + rnd(i * 6.1) * 42, rnd(i * 2.2) > 0.55)
550
+ }
551
+ }
552
+
553
+ /** 恒星暴:金白核 + 蓝白外环 + 玫红星爆团 + 尘埃,对照第二张参考 */
554
+ function buildGalaxySky() {
555
+ const count = 46000
556
+ const pos = new Float32Array(count * 3)
557
+ const col = new Float32Array(count * 3)
558
+ const siz = new Float32Array(count)
559
+ const sd = new Float32Array(count)
560
+ const arms = 2
561
+ for (let i = 0; i < count; i++) {
562
+ const t = Math.pow(rnd(i * 2.7), 0.6)
563
+ const r = 7 + t * 38
564
+ const arm = i % arms
565
+ const ang = arm * Math.PI + 2.4 * Math.log(1 + t * 6.0) + (rnd(i * 9.1) - 0.5) * (0.38 + t * 0.5)
566
+ const rr = r * (1 - 0.04 * rnd(i * 4.3))
567
+ pos[i * 3] = Math.cos(ang) * rr
568
+ pos[i * 3 + 1] = (rnd(i * 6.1) - 0.5) * (1.0 + t * 2.4)
569
+ pos[i * 3 + 2] = Math.sin(ang) * rr
570
+ const core = Math.pow(Math.max(0, 1 - t * 2.2), 2.2)
571
+ col[i * 3] = 0.62 + 0.38 * core + 0.06 * t
572
+ col[i * 3 + 1] = 0.70 + 0.18 * core + 0.04 * t
573
+ col[i * 3 + 2] = 1.0 - 0.26 * core
574
+ siz[i] = 0.55 + rnd(i * 13.1) * 1.2 + core * 0.7
575
+ sd[i] = rnd(i * 17.9)
576
+ }
577
+ const g = new THREE.BufferGeometry()
578
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
579
+ g.setAttribute('aColor', new THREE.BufferAttribute(col, 3))
580
+ g.setAttribute('aSize', new THREE.BufferAttribute(siz, 1))
581
+ g.setAttribute('aSeed', new THREE.BufferAttribute(sd, 1))
582
+ const galaxy = new THREE.Points(g, galaxyStarMaterial())
583
+ galaxy.frustumCulled = false
584
+ skyRig.add(galaxy)
585
+
586
+ // 外圈蓝白环
587
+ const ringCount = 9000
588
+ const rp = new Float32Array(ringCount * 3)
589
+ const rc = new Float32Array(ringCount * 3)
590
+ const rs = new Float32Array(ringCount)
591
+ const rd = new Float32Array(ringCount)
592
+ for (let i = 0; i < ringCount; i++) {
593
+ const a = rnd(i * 3.1) * Math.PI * 2
594
+ const r = 38 + rnd(i * 7.7) * 14
595
+ rp[i * 3] = Math.cos(a) * r + (rnd(i * 2.3) - 0.5) * 4
596
+ rp[i * 3 + 1] = (rnd(i * 5.1) - 0.5) * 3.5
597
+ rp[i * 3 + 2] = Math.sin(a) * r + (rnd(i * 8.3) - 0.5) * 4
598
+ rc[i * 3] = 0.6
599
+ rc[i * 3 + 1] = 0.78
600
+ rc[i * 3 + 2] = 1.0
601
+ rs[i] = 0.5 + rnd(i * 11.7) * 0.9
602
+ rd[i] = rnd(i * 19.3)
603
+ }
604
+ const rg = new THREE.BufferGeometry()
605
+ rg.setAttribute('position', new THREE.BufferAttribute(rp, 3))
606
+ rg.setAttribute('aColor', new THREE.BufferAttribute(rc, 3))
607
+ rg.setAttribute('aSize', new THREE.BufferAttribute(rs, 1))
608
+ rg.setAttribute('aSeed', new THREE.BufferAttribute(rd, 1))
609
+ const ring = new THREE.Points(rg, galaxyStarMaterial())
610
+ ring.frustumCulled = false
611
+ skyRig.add(ring)
612
+
613
+ // 星爆团
614
+ for (let i = 0; i < 130; i++) {
615
+ const t = Math.pow(rnd(i * 3.3), 0.58)
616
+ const r = 9 + t * 36
617
+ const arm = i % 2
618
+ const ang = arm * Math.PI + 2.4 * Math.log(1 + t * 6.0) + (rnd(i * 8.7) - 0.5) * 0.34
619
+ const rose = rnd(i * 1.7) > 0.3
620
+ addCloudMesh(
621
+ rose ? [1.0, 0.22, 0.32] : [0.42, 0.72, 1.0],
622
+ rose ? [1.0, 0.62, 0.5] : [0.85, 0.95, 1.0],
623
+ rnd(i * 5.7) * 10, 0.18 + rnd(i) * 0.14, 10 + rnd(i) * 16,
624
+ Math.cos(ang) * r, (rnd(i * 5.9) - 0.5) * 2.2, Math.sin(ang) * r,
625
+ )
626
+ }
627
+
628
+ // 尘埃带
629
+ for (let i = 0; i < 34; i++) {
630
+ const t = Math.pow(rnd(i * 2.9), 0.58)
631
+ const r = 9 + t * 38
632
+ const ang = (i % 2) * Math.PI + 2.4 * Math.log(1 + t * 6.0) - 0.32
633
+ addCloudMesh([0.09, 0.05, 0.02], [0.26, 0.16, 0.07], rnd(i * 6.3) * 10, 0.34,
634
+ 13 + rnd(i) * 24, Math.cos(ang) * r, (rnd(i * 3.3) - 0.5) * 2.0, Math.sin(ang) * r, true)
635
+ }
636
+
637
+ // 核球:外圈金、内芯白
638
+ addCloudMesh([1.0, 0.72, 0.35], [1.0, 0.9, 0.62], 3.3, 0.3, 40, 0, 0, 0)
639
+ addCloudMesh([1.0, 0.92, 0.78], [1.0, 1.0, 0.96], 7.1, 0.34, 16, 0, 0, 0)
640
+
641
+ starField(5000, 120, 460, 0.95)
642
+ }
643
+
644
+ function galaxyStarMaterial() {
645
+ return new THREE.ShaderMaterial({
646
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
647
+ uniforms: { uTime: { value: 0 }, uPixel: { value: renderer.getPixelRatio() } },
648
+ vertexShader: `
649
+ attribute vec3 aColor; attribute float aSize; attribute float aSeed;
650
+ uniform float uTime; uniform float uPixel;
651
+ varying vec3 vC; varying float vA;
652
+ void main() {
653
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
654
+ gl_Position = projectionMatrix * mv;
655
+ vC = aColor;
656
+ vA = 0.26 + 0.12 * sin(uTime * 0.6 + aSeed * 45.0);
657
+ gl_PointSize = aSize * uPixel * (420.0 / max(-mv.z, 1.0));
658
+ }
659
+ `,
660
+ fragmentShader: `
661
+ varying vec3 vC; varying float vA;
662
+ void main() {
663
+ vec2 p = gl_PointCoord - 0.5;
664
+ float d = length(p);
665
+ if (d > 0.5) discard;
666
+ gl_FragColor = vec4(vC, smoothstep(0.5, 0.0, d) * vA);
667
+ }
668
+ `,
669
+ })
670
+ }
671
+
672
+ /* ---------------- 神经云主题:中间主神经元,向四周发散出神经细胞 ---------------- */
673
+ const NEURO = {
674
+ group: new THREE.Group(),
675
+ cells: [],
676
+ regions: [],
677
+ somaMat: null,
678
+ lineMat: null,
679
+ hoverRegion: -1,
680
+ hoverCell: -1,
681
+ peekOwned: false,
682
+ }
683
+
684
+ const NEURO_HUES = [200, 280, 160, 30, 320, 240, 180, 60, 300, 120]
685
+
686
+ function neuroClear() {
687
+ while (NEURO.group.children.length) {
688
+ const c = NEURO.group.children.pop()
689
+ if (c.geometry) c.geometry.dispose()
690
+ if (c.material) c.material.dispose()
691
+ }
692
+ NEURO.cells.length = 0
693
+ NEURO.regions.length = 0
694
+ NEURO.hoverRegion = -1
695
+ NEURO.hoverCell = -1
696
+ }
697
+
698
+ function fibDir(i, n) {
699
+ const y = 1 - (i / Math.max(n - 1, 1)) * 2
700
+ const r = Math.sqrt(Math.max(0, 1 - y * y))
701
+ const phi = i * 2.399963
702
+ return new THREE.Vector3(Math.cos(phi) * r, y, Math.sin(phi) * r)
703
+ }
704
+
705
+ /* 每个主分区一片区域;区域内在子分区里长神经细胞 */
706
+ function buildNeuronSky() {
707
+ neuroClear()
708
+ const tops = state.folders.filter((f) => !f.parent)
709
+ const R = 46
710
+ const centers = tops.map((f, i) => {
711
+ const d = fibDir(i, tops.length)
712
+ return new THREE.Vector3(d.x * R, d.y * R * 0.55, d.z * R)
713
+ })
714
+
715
+ const somaPos = []
716
+ const somaCol = []
717
+ const somaSize = []
718
+ const somaDist = []
719
+ const somaRegion = []
720
+ const somaEmpty = []
721
+ const somaSeed = []
722
+
723
+ const linePos = []
724
+ const lineT = []
725
+ const lineDist = []
726
+ const lineRegion = []
727
+ const lineEmpty = []
728
+ const lineCol = []
729
+
730
+ const pushCol = (arr, c) => arr.push(c.r, c.g, c.b)
731
+
732
+ const maxD = R + 16
733
+ const memColFor = (hue, empty) => {
734
+ const c = new THREE.Color().setHSL(hue / 360, empty ? 0.35 : 0.72, empty ? 0.42 : 0.62)
735
+ return c
736
+ }
737
+
738
+ const addLine = (a, b, tA, tB, dist, region, empty, col) => {
739
+ linePos.push(a.x, a.y, a.z, b.x, b.y, b.z)
740
+ lineT.push(tA, tB)
741
+ lineDist.push(dist, dist)
742
+ lineRegion.push(region, region)
743
+ lineEmpty.push(empty, empty)
744
+ pushCol(lineCol, col)
745
+ pushCol(lineCol, col)
746
+ }
747
+
748
+ tops.forEach((top, ri) => {
749
+ const hue = NEURO_HUES[ri % NEURO_HUES.length]
750
+ const center = centers[ri]
751
+ NEURO.regions.push({ k: top.k, name: top.name, center, hue, count: 0 })
752
+
753
+ const list = state.memories.filter((m) => m.folder === top.k || m.parent === top.k)
754
+ // 记忆不够就先长空细胞,等以后有新记忆再补进来(空细胞不进任何分区内容)
755
+ const target = Math.max(list.length + 3, 11)
756
+ const cells = []
757
+ for (let i = 0; i < target; i++) {
758
+ const mem = list[i] || null
759
+ const dir = fibDir(i, target)
760
+ const rad = 8 + rnd(ri * 13 + i * 7) * 11
761
+ const pos = center.clone().add(new THREE.Vector3(dir.x * rad, dir.y * rad * 0.8, dir.z * rad))
762
+ cells.push({ pos, mem })
763
+ }
764
+
765
+ const distOf = (p) => clamp(p.length() / maxD, 0, 1)
766
+
767
+ cells.forEach((cell, i) => {
768
+ const empty = cell.mem ? 0 : 1
769
+ const col = memColFor(hue + (empty ? 0 : rnd(i * 3.1) * 24 - 12), !!empty)
770
+ somaPos.push(cell.pos.x, cell.pos.y, cell.pos.z)
771
+ pushCol(somaCol, col)
772
+ somaSize.push(empty ? 5.5 : 8 + rnd(i * 5.7) * 4)
773
+ somaDist.push(distOf(cell.pos))
774
+ somaRegion.push(ri)
775
+ somaEmpty.push(empty)
776
+ somaSeed.push(rnd(i * 7.3))
777
+ const rec = {
778
+ region: ri, memId: cell.mem ? cell.mem.id : null, pos: cell.pos, hue, empty: !!empty,
779
+ name: cell.mem ? cell.mem.text : '',
780
+ }
781
+ NEURO.cells.push(rec)
782
+ if (cell.mem) NEURO.regions[ri].count++
783
+
784
+ // 树突:每条细胞伸出 5 根弯曲的枝
785
+ const branches = 5
786
+ for (let b = 0; b < branches; b++) {
787
+ const a = (b / branches) * Math.PI * 2 + rnd(i * 3 + b) * 0.9
788
+ const tilt = 0.5 + rnd(i + b * 2.1) * 0.9
789
+ let p = cell.pos.clone()
790
+ const dirv = new THREE.Vector3(Math.cos(a), tilt * 0.55, Math.sin(a)).normalize()
791
+ const segs = 6
792
+ let tPrev = 0
793
+ for (let s = 1; s <= segs; s++) {
794
+ const step = (1.4 + rnd(i * 5 + b * 3 + s) * 1.7)
795
+ const next = p.clone().add(dirv.clone().multiplyScalar(step).add(new THREE.Vector3(
796
+ (rnd(i + b + s * 1.7) - 0.5) * 1.4,
797
+ (rnd(i * 2 + b + s) - 0.5) * 1.4,
798
+ (rnd(i + b * 2 + s * 2.3) - 0.5) * 1.4,
799
+ )))
800
+ const tNext = s / segs
801
+ addLine(p, next, tPrev, tNext, distOf(cell.pos), ri, empty, col)
802
+ p = next
803
+ tPrev = tNext
804
+ }
805
+ }
806
+ })
807
+
808
+ // 区域内部突触:把细胞串成网
809
+ for (let i = 0; i < NEURO.cells.length; i++) {
810
+ const a = NEURO.cells[i]
811
+ if (a.region !== ri) continue
812
+ const next = NEURO.cells[(i + 2) % NEURO.cells.length]
813
+ if (next && next.region === ri && next !== a) {
814
+ const col = new THREE.Color().setHSL(hue / 360, 0.7, 0.6)
815
+ addLine(a.pos, next.pos, 0, 1, distOf(a.pos), ri, 0, col)
816
+ }
817
+ }
818
+ })
819
+
820
+ // 主干:中央主神经元 → 每片区域
821
+ const hub = new THREE.Vector3(0, 0, 0)
822
+ somaPos.push(0, 0, 0)
823
+ pushCol(somaCol, new THREE.Color(0.85, 0.95, 1.0))
824
+ somaSize.push(30)
825
+ somaDist.push(0)
826
+ somaRegion.push(-1)
827
+ somaEmpty.push(0)
828
+ somaSeed.push(0.5)
829
+
830
+ tops.forEach((top, ri) => {
831
+ const col = new THREE.Color().setHSL(NEURO_HUES[ri % NEURO_HUES.length] / 360, 0.7, 0.66)
832
+ addLine(hub, centers[ri], 0, 1, 0.02, ri, 0, col)
833
+ const c = centers[ri]
834
+ somaPos.push(c.x, c.y, c.z)
835
+ pushCol(somaCol, col)
836
+ somaSize.push(17)
837
+ somaDist.push(clamp(c.length() / maxD, 0, 1))
838
+ somaRegion.push(ri)
839
+ somaEmpty.push(0)
840
+ somaSeed.push(rnd(ri * 3.7))
841
+ })
842
+
843
+ // 区域之间也连上,整片云团不分开
844
+ tops.forEach((top, ri) => {
845
+ const a = centers[ri]
846
+ for (let k = 1; k <= 2; k++) {
847
+ const b = centers[(ri + k) % centers.length]
848
+ if (!b || b === a) continue
849
+ const col = new THREE.Color().setHSL(NEURO_HUES[ri % NEURO_HUES.length] / 360, 0.55, 0.55)
850
+ addLine(a, b, 0, 1, clamp(a.length() / 60, 0, 1), ri, 0, col)
851
+ }
852
+ })
853
+
854
+ const somaGeo = new THREE.BufferGeometry()
855
+ somaGeo.setAttribute('position', new THREE.Float32BufferAttribute(somaPos, 3))
856
+ somaGeo.setAttribute('aColor', new THREE.Float32BufferAttribute(somaCol, 3))
857
+ somaGeo.setAttribute('aSize', new THREE.Float32BufferAttribute(somaSize, 1))
858
+ somaGeo.setAttribute('aDist', new THREE.Float32BufferAttribute(somaDist, 1))
859
+ somaGeo.setAttribute('aRegion', new THREE.Float32BufferAttribute(somaRegion, 1))
860
+ somaGeo.setAttribute('aEmpty', new THREE.Float32BufferAttribute(somaEmpty, 1))
861
+ somaGeo.setAttribute('aSeed', new THREE.Float32BufferAttribute(somaSeed, 1))
862
+
863
+ NEURO.somaMat = new THREE.ShaderMaterial({
864
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
865
+ uniforms: { uTime: { value: 0 }, uPixel: { value: renderer.getPixelRatio() }, uHover: { value: -1 } },
866
+ vertexShader: `
867
+ attribute vec3 aColor; attribute float aSize; attribute float aDist;
868
+ attribute float aRegion; attribute float aEmpty; attribute float aSeed;
869
+ uniform float uTime; uniform float uPixel; uniform float uHover;
870
+ varying vec3 vC; varying float vA; varying float vG;
871
+ void main() {
872
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
873
+ gl_Position = projectionMatrix * mv;
874
+ // 从中心向外扩散的波
875
+ float w = fract(uTime * 0.18 - aDist * 1.25);
876
+ float front = pow(1.0 - abs(w * 2.0 - 1.0), 9.0);
877
+ float tw = 0.75 + 0.25 * sin(uTime * 1.3 + aSeed * 40.0);
878
+ float on = (uHover < -0.5) ? 1.0 : (abs(aRegion - uHover) < 0.5 ? 1.0 : 0.16);
879
+ float base = (aEmpty > 0.5) ? 0.32 : 0.8;
880
+ vC = aColor;
881
+ vA = (base * 0.6 * tw + front * 1.0) * on;
882
+ vG = front * on;
883
+ gl_PointSize = aSize * uPixel * (1.0 + front * 0.8) * (330.0 / max(-mv.z, 1.0));
884
+ }
885
+ `,
886
+ fragmentShader: `
887
+ varying vec3 vC; varying float vA; varying float vG;
888
+ void main() {
889
+ vec2 p = gl_PointCoord - 0.5;
890
+ float d = length(p);
891
+ if (d > 0.5) discard;
892
+ float core = smoothstep(0.5, 0.0, d);
893
+ gl_FragColor = vec4(vC * (1.0 + vG * 1.6), core * core * vA);
894
+ }
895
+ `,
896
+ })
897
+ const somas = new THREE.Points(somaGeo, NEURO.somaMat)
898
+ somas.frustumCulled = false
899
+ NEURO.group.add(somas)
900
+
901
+ const lineGeo = new THREE.BufferGeometry()
902
+ lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(linePos, 3))
903
+ lineGeo.setAttribute('aT', new THREE.Float32BufferAttribute(lineT, 1))
904
+ lineGeo.setAttribute('aDist', new THREE.Float32BufferAttribute(lineDist, 1))
905
+ lineGeo.setAttribute('aRegion', new THREE.Float32BufferAttribute(lineRegion, 1))
906
+ lineGeo.setAttribute('aEmpty', new THREE.Float32BufferAttribute(lineEmpty, 1))
907
+ lineGeo.setAttribute('aColor', new THREE.Float32BufferAttribute(lineCol, 3))
908
+
909
+ NEURO.lineMat = new THREE.ShaderMaterial({
910
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
911
+ uniforms: { uTime: { value: 0 }, uHover: { value: -1 } },
912
+ vertexShader: `
913
+ attribute float aT; attribute float aDist; attribute float aRegion;
914
+ attribute float aEmpty; attribute vec3 aColor;
915
+ uniform float uTime; uniform float uHover;
916
+ varying vec3 vC; varying float vA;
917
+ void main() {
918
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
919
+ gl_Position = projectionMatrix * mv;
920
+ float w = fract(uTime * 0.2 - aDist * 1.2 - aT * 0.12);
921
+ float pulse = pow(1.0 - abs(w * 2.0 - 1.0), 7.0);
922
+ float on = (uHover < -0.5) ? 1.0 : (abs(aRegion - uHover) < 0.5 ? 1.0 : 0.12);
923
+ float base = (aEmpty > 0.5) ? 0.06 : 0.14;
924
+ vC = aColor;
925
+ vA = (base + pulse * 1.05) * on;
926
+ }
927
+ `,
928
+ fragmentShader: `
929
+ varying vec3 vC; varying float vA;
930
+ void main() { gl_FragColor = vec4(vC, vA); }
931
+ `,
932
+ })
933
+ const lines = new THREE.LineSegments(lineGeo, NEURO.lineMat)
934
+ lines.frustumCulled = false
935
+ NEURO.group.add(lines)
936
+
937
+ if (!NEURO.group.parent) scene.add(NEURO.group)
938
+ NEURO.built = true
939
+ }
940
+
941
+ function neuroSetHover(region, cell) {
942
+ NEURO.hoverRegion = region
943
+ NEURO.hoverCell = cell
944
+ if (NEURO.somaMat) NEURO.somaMat.uniforms.uHover.value = region
945
+ if (NEURO.lineMat) NEURO.lineMat.uniforms.uHover.value = region
946
+ }
947
+
948
+ function neuroPick(cx, cy) {
949
+ if (!NEURO.built) return null
950
+ let best = null
951
+ for (let i = 0; i < NEURO.cells.length; i++) {
952
+ const c = NEURO.cells[i]
953
+ tmpV.copy(c.pos).project(camera)
954
+ if (tmpV.z > 1) continue
955
+ const sx = (tmpV.x * 0.5 + 0.5) * window.innerWidth
956
+ const sy = (-tmpV.y * 0.5 + 0.5) * window.innerHeight
957
+ const d = Math.hypot(sx - cx, sy - cy)
958
+ if (d < 30 && (!best || d < best.d)) best = { d, cell: i, region: c.region }
959
+ }
960
+ if (best) return { kind: 'cell', cell: best.cell, region: best.region }
961
+ // 没点到细胞就按最近区域算
962
+ let near = null
963
+ for (let i = 0; i < NEURO.regions.length; i++) {
964
+ const r = NEURO.regions[i]
965
+ tmpV.copy(r.center).project(camera)
966
+ if (tmpV.z > 1) continue
967
+ const sx = (tmpV.x * 0.5 + 0.5) * window.innerWidth
968
+ const sy = (-tmpV.y * 0.5 + 0.5) * window.innerHeight
969
+ const d = Math.hypot(sx - cx, sy - cy)
970
+ if (d < 110 && (!near || d < near.d)) near = { d, region: i }
971
+ }
972
+ return near ? { kind: 'region', region: near.region } : null
973
+ }
974
+
975
+ /* 右侧预览卡 */
976
+ function neuroPeek(hit) {
977
+ const card = $('peek')
978
+ if (!card) return
979
+ if (!hit) {
980
+ card.classList.remove('show')
981
+ return
982
+ }
983
+ const r = NEURO.regions[hit.region]
984
+ if (!r) { card.classList.remove('show'); return }
985
+ card.innerHTML = ''
986
+ const t = document.createElement('div')
987
+ t.className = 'pk-t'
988
+ t.textContent = r.name
989
+ card.appendChild(t)
990
+ const meta = document.createElement('div')
991
+ meta.className = 'pk-m'
992
+ meta.textContent = `${r.count} 条记忆`
993
+ card.appendChild(meta)
994
+ if (hit.kind === 'cell' && hit.cell != null) {
995
+ const c = NEURO.cells[hit.cell]
996
+ if (c && c.memId) {
997
+ const p = document.createElement('div')
998
+ p.className = 'pk-p'
999
+ p.textContent = c.name
1000
+ card.appendChild(p)
1001
+ }
1002
+ }
1003
+ card.classList.add('show')
1004
+ }
1005
+
1006
+ function disposeTree(obj) {
1007
+ obj.traverse?.((o) => {
1008
+ // 只释放几何体;贴图(GLOW_TEX / dotTexture / 文字图集)是所有星点共享的,
1009
+ // 一释放,切一次主题之后星点就会变黑方块或消失 —— 这就是之前"错位/串场"的来源
1010
+ if (o.geometry) o.geometry.dispose()
1011
+ const mat = o.material
1012
+ if (!mat) return
1013
+ if (Array.isArray(mat)) mat.forEach((x) => x.dispose())
1014
+ else mat.dispose()
1015
+ })
1016
+ }
1017
+
1018
+ function clearSky() {
1019
+ cloudMeshes.length = 0
1020
+ if (skyRig) {
1021
+ scene.remove(skyRig)
1022
+ disposeTree(skyRig)
1023
+ }
1024
+ skyRig = new THREE.Group()
1025
+ }
1026
+
1027
+ // 切主题时把不属于「当前主题 + 星辰」的对象全部扫掉,杜绝背景串场
1028
+ function sweepScene() {
1029
+ const keep = new Set([camera])
1030
+ if (dust) keep.add(dust)
1031
+ for (const [, n] of mains) keep.add(n.group)
1032
+ for (const rec of webLines) keep.add(rec.line)
1033
+ if (NEURO && NEURO.group) keep.add(NEURO.group)
1034
+ for (let i = scene.children.length - 1; i >= 0; i--) {
1035
+ const c = scene.children[i]
1036
+ if (keep.has(c)) continue
1037
+ scene.remove(c)
1038
+ disposeTree(c)
1039
+ }
1040
+ }
1041
+
1042
+ function buildSky(themeName) {
1043
+ const theme = THEMES[themeName] || THEMES.nebula
1044
+ clearSky()
1045
+ sweepScene()
1046
+ if (theme.kind === 'neuron') buildNeuronSky()
1047
+ else if (theme.kind === 'galaxy') buildGalaxySky()
1048
+ else buildNebulaSky()
1049
+ if (!skyRig.parent) scene.add(skyRig)
1050
+ cam.pitch = theme.pitch
1051
+ cam.dist = theme.dist
1052
+ camCur.pitch = theme.pitch
1053
+ camCur.dist = theme.dist
1054
+ fit.dist = theme.dist
1055
+ }
1056
+
1057
+ /* ---------------- 星系构建 ---------------- */
1058
+ function clearScene() {
1059
+ for (const [, m] of mains) scene.remove(m.group)
1060
+ mains.clear()
1061
+ subs.clear()
1062
+ mems.clear()
1063
+ pickables.length = 0
1064
+ labelNodes.length = 0
1065
+ hotRanges = new Map()
1066
+ for (const rec of webLines) { scene.remove(rec.line); rec.line.material.dispose() }
1067
+ webLines.length = 0
1068
+ }
1069
+
1070
+ function buildGalaxy() {
1071
+ clearScene()
1072
+ const tops = state.folders.filter((f) => !f.parent)
1073
+ const byFolder = new Map()
1074
+ for (const m of state.memories) {
1075
+ if (!byFolder.has(m.folder)) byFolder.set(m.folder, [])
1076
+ byFolder.get(m.folder).push(m)
1077
+ }
1078
+ const glow = radialTexture(2.0)
1079
+ const topCount = Math.max(tops.length, 1)
1080
+
1081
+ tops.forEach((top, i) => {
1082
+ const arm = i % 2
1083
+ const t = topCount <= 1 ? 0.5 : i / (topCount - 1)
1084
+ const r = 11 + t * 19
1085
+ const ang = t * 2.6 + arm * Math.PI + 0.3
1086
+ const px = Math.cos(ang) * r
1087
+ const pz = Math.sin(ang) * r
1088
+ const py = (arm ? 1 : -1) * (1.1 + rnd(i * 5.3) * 1.6)
1089
+
1090
+ const color = PALETTE[i % PALETTE.length]
1091
+ const group = new THREE.Group()
1092
+ group.position.set(px, py, pz)
1093
+ scene.add(group)
1094
+
1095
+ // 云气
1096
+ for (let c = 0; c < 4; c++) {
1097
+ const cloud = new THREE.Sprite(new THREE.SpriteMaterial({
1098
+ map: glow,
1099
+ color: new THREE.Color(
1100
+ clamp(color[0] * (0.65 + rnd(c + i) * 0.5), 0, 1),
1101
+ clamp(color[1] * (0.65 + rnd(c + i + 3) * 0.5), 0, 1),
1102
+ clamp(color[2] * (0.65 + rnd(c + i + 6) * 0.5), 0, 1),
1103
+ ),
1104
+ transparent: true, opacity: 0.04 + rnd(c + i) * 0.045, depthWrite: false, blending: THREE.AdditiveBlending,
1105
+ }))
1106
+ const a = rnd(c * 5 + i) * Math.PI * 2
1107
+ const rr = 1.6 + rnd(c * 3 + i) * 4.2
1108
+ cloud.position.set(Math.cos(a) * rr, (rnd(c + i) - 0.5) * 1.6, Math.sin(a) * rr)
1109
+ cloud.scale.setScalar(5 + rnd(c + i) * 6)
1110
+ cloud.userData.base = cloud.material.opacity
1111
+ cloud.userData.isCloud = true
1112
+ group.add(cloud)
1113
+ }
1114
+
1115
+ const halo = new THREE.Sprite(new THREE.SpriteMaterial({
1116
+ map: glow, color: new THREE.Color(color[0] * 0.8, color[1] * 0.8, color[2] * 0.95),
1117
+ transparent: true, opacity: 0.5, depthWrite: false, blending: THREE.AdditiveBlending,
1118
+ }))
1119
+ halo.scale.setScalar(7)
1120
+ halo.userData.base = 0.3
1121
+ group.add(halo)
1122
+
1123
+ const core = new THREE.Sprite(new THREE.SpriteMaterial({
1124
+ map: glow, color: new THREE.Color(1, 1, 1),
1125
+ transparent: true, opacity: 0.95, depthWrite: false, blending: THREE.AdditiveBlending,
1126
+ }))
1127
+ core.scale.setScalar(1.9)
1128
+ core.userData.base = 0.95
1129
+ group.add(core)
1130
+
1131
+ const hit = new THREE.Sprite(new THREE.SpriteMaterial({
1132
+ map: glow, color: new THREE.Color(color[0], color[1], color[2]),
1133
+ transparent: true, opacity: 0.001, depthWrite: false,
1134
+ }))
1135
+ hit.scale.setScalar(5.2)
1136
+ group.add(hit)
1137
+
1138
+ const label = textSprite(top.name, { size: 52, color: '#f2f7ff', opacity: 0.98 })
1139
+ label.position.set(0, 4.2, 0)
1140
+ group.add(label)
1141
+
1142
+ const node = { k: top.k, name: top.name, group, color, pos: new THREE.Vector3(px, py, pz), subs: [], expand: 1, dim: 1, vel: 0, label, labelScale: label.scale.clone(), labelY: 4.2 }
1143
+ mains.set(top.k, node)
1144
+ labelNodes.push(node)
1145
+ pickables.push({ kind: 'main', k: top.k, object: hit, node })
1146
+ pickables.push({ kind: 'main', k: top.k, object: label, node })
1147
+
1148
+ // 子分区
1149
+ const kids = state.folders.filter((f) => f.parent === top.k)
1150
+ kids.forEach((kid, j) => {
1151
+ const a = (j / Math.max(kids.length, 1)) * Math.PI * 2 + 0.6
1152
+ const rr = 4.4 + rnd(j * 3.3) * 1.2
1153
+ const sg = new THREE.Group()
1154
+ sg.position.set(Math.cos(a) * rr, (rnd(j * 2.1) - 0.5) * 1.8, Math.sin(a) * rr)
1155
+ group.add(sg)
1156
+
1157
+ const sGlow = new THREE.Sprite(new THREE.SpriteMaterial({
1158
+ map: glow, color: new THREE.Color(0.7, 0.82, 1.0),
1159
+ transparent: true, opacity: 0.5, depthWrite: false, blending: THREE.AdditiveBlending,
1160
+ }))
1161
+ sGlow.scale.setScalar(3.2)
1162
+ sGlow.userData.base = 0.42
1163
+ sg.add(sGlow)
1164
+
1165
+ const sHit = new THREE.Sprite(new THREE.SpriteMaterial({
1166
+ map: glow, color: new THREE.Color(0.85, 0.92, 1), transparent: true, opacity: 0.001, depthWrite: false,
1167
+ }))
1168
+ sHit.scale.setScalar(2.4)
1169
+ sg.add(sHit)
1170
+
1171
+ const sLabel = textSprite(kid.name, { size: 38, color: '#e2ecff', opacity: 0.95 })
1172
+ sLabel.position.set(0, 1.9, 0)
1173
+ sg.add(sLabel)
1174
+
1175
+ const world = new THREE.Vector3()
1176
+ sg.getWorldPosition(world)
1177
+ const subNode = { k: kid.k, name: kid.name, parentK: top.k, group: sg, pos: world.clone(), expand: 1, dim: 1, vel: 0, label: sLabel, labelScale: sLabel.scale.clone(), labelY: 1.9 }
1178
+ subs.set(kid.k, subNode)
1179
+ labelNodes.push(subNode)
1180
+ node.subs.push(subNode)
1181
+ pickables.push({ kind: 'sub', k: kid.k, object: sHit, node: subNode })
1182
+ pickables.push({ kind: 'sub', k: kid.k, object: sLabel, node: subNode })
1183
+ })
1184
+
1185
+ // 记忆点
1186
+ const dots = byFolder.get(top.k) || []
1187
+ if (!kids.length) placeDots(group, top.k, 2.2, 3.6, dots)
1188
+ kids.forEach((kid) => {
1189
+ const g = subs.get(kid.k).group
1190
+ placeDots(g, kid.k, 1.35, 2.3, byFolder.get(kid.k) || [])
1191
+ })
1192
+
1193
+ // 更新子星辰世界坐标(等主组位置确定)
1194
+ node.subs.forEach((s) => s.group.getWorldPosition(s.pos))
1195
+ })
1196
+
1197
+ // 视角:把整条星系框进来
1198
+ const maxR = 38
1199
+ fit.dist = clamp(118, 90, 260)
1200
+ cam.dist = fit.dist
1201
+ camCur.dist = fit.dist
1202
+ cam.tx = cam.ty = cam.tz = 0
1203
+ camCur.tx = camCur.ty = camCur.tz = 0
1204
+
1205
+ buildWeb()
1206
+ applyDim()
1207
+ applyThemeGlyphs()
1208
+ if (state.theme === 'neuron') buildNeuronSky()
1209
+ }
1210
+
1211
+ function placeDots(parent, folderK, rMin, rMax, list) {
1212
+ const n = Math.max(list.length, 1)
1213
+ list.forEach((m, idx) => {
1214
+ const seed = (m.id.charCodeAt(0) + m.id.charCodeAt(1) * 7 + idx * 13) % 997
1215
+ const a = (idx / n) * Math.PI * 2 + rnd(seed) * 1.4
1216
+ const r = rMin + rnd(seed + 3.1) * (rMax - rMin)
1217
+ const y = (rnd(seed + 7.3) - 0.5) * 1.2
1218
+ const fixed = m.state === 'fixed'
1219
+ const forgotten = m.state === 'forgotten'
1220
+ const fading = m.state === 'fading'
1221
+ const color = fixed
1222
+ ? new THREE.Color(1.0, 0.83, 0.5)
1223
+ : forgotten ? new THREE.Color(0.5, 0.5, 0.62)
1224
+ : fading ? new THREE.Color(0.62, 0.72, 0.92)
1225
+ : new THREE.Color(0.82, 0.92, 1.0)
1226
+
1227
+ const sp = new THREE.Sprite(new THREE.SpriteMaterial({
1228
+ map: dotTexture, color, transparent: true,
1229
+ opacity: fixed ? 0.95 : forgotten ? 0.4 : fading ? 0.68 : 0.92,
1230
+ depthWrite: false, blending: THREE.AdditiveBlending,
1231
+ }))
1232
+ const base = fixed ? 0.62 : forgotten ? 0.34 : fading ? 0.42 : 0.52
1233
+ sp.scale.setScalar(base)
1234
+ sp.position.set(Math.cos(a) * r, y, Math.sin(a) * r)
1235
+ sp.userData.base = base
1236
+ sp.userData.baseOp = sp.material.opacity
1237
+ parent.add(sp)
1238
+
1239
+ const world = new THREE.Vector3()
1240
+ sp.getWorldPosition(world)
1241
+ mems.set(m.id, { id: m.id, folder: m.folder, folderName: m.folderName, sprite: sp, pos: world.clone(), base, mem: m, dim: 1 })
1242
+ pickables.push({ kind: 'mem', id: m.id, object: sp, node: mems.get(m.id) })
1243
+ })
1244
+ }
1245
+
1246
+ const dotTexture = (() => {
1247
+ const c = document.createElement('canvas')
1248
+ c.width = c.height = 64
1249
+ const g = c.getContext('2d')
1250
+ const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32)
1251
+ grad.addColorStop(0, 'rgba(255,255,255,1)')
1252
+ grad.addColorStop(0.35, 'rgba(255,255,255,0.9)')
1253
+ grad.addColorStop(1, 'rgba(255,255,255,0)')
1254
+ g.fillStyle = grad
1255
+ g.fillRect(0, 0, 64, 64)
1256
+ const tex = new THREE.CanvasTexture(c)
1257
+ tex.colorSpace = THREE.SRGBColorSpace
1258
+ return tex
1259
+ })()
1260
+
1261
+ /* ---------------- 丝线:常驻的乱网 ---------------- */
1262
+ const webLines = [] // { line, from, to, ids }
1263
+ const wa = new THREE.Vector3()
1264
+ const wb = new THREE.Vector3()
1265
+ const WEB_SEG = 24
1266
+
1267
+ const WEB_VERT = `
1268
+ uniform vec3 uFrom;
1269
+ uniform vec3 uTo;
1270
+ uniform float uLift;
1271
+ uniform float uHot;
1272
+ attribute float aT;
1273
+ varying float vT;
1274
+ varying float vHot;
1275
+ void main() {
1276
+ float t = aT;
1277
+ float u = 1.0 - t;
1278
+ vec3 mid = (uFrom + uTo) * 0.5 + vec3(0.0, uLift, 0.0);
1279
+ // 二次贝塞尔完全在显卡上算,端点在 uniform 里,天然跟得上星辰
1280
+ vec3 p = u * u * uFrom + 2.0 * u * t * mid + t * t * uTo;
1281
+ vT = t;
1282
+ vHot = uHot;
1283
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
1284
+ }
1285
+ `
1286
+
1287
+ const WEB_FRAG = `
1288
+ uniform float uTime;
1289
+ uniform float uHot;
1290
+ varying float vT;
1291
+ varying float vHot;
1292
+ void main() {
1293
+ float flow = fract(vT * 1.5 - uTime * 0.4);
1294
+ float pulse = pow(1.0 - abs(flow * 2.0 - 1.0), 4.0);
1295
+ float base = 0.09 + vHot * 0.42;
1296
+ float a = base + pulse * vHot * 1.2;
1297
+ vec3 col = mix(vec3(0.6, 0.78, 1.0), vec3(1.0, 0.97, 0.9), vHot) * (1.0 + vHot * 0.7);
1298
+ gl_FragColor = vec4(col, a);
1299
+ }
1300
+ `
1301
+
1302
+ let webGeo = null
1303
+
1304
+ function buildWeb() {
1305
+ for (const rec of webLines) {
1306
+ scene.remove(rec.line)
1307
+ rec.line.material.dispose()
1308
+ }
1309
+ webLines.length = 0
1310
+ hotRanges = new Map()
1311
+
1312
+ if (webGeo) webGeo.dispose()
1313
+ webGeo = new THREE.BufferGeometry()
1314
+ const ts = new Float32Array(WEB_SEG + 1)
1315
+ for (let i = 0; i <= WEB_SEG; i++) ts[i] = i / WEB_SEG
1316
+ webGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((WEB_SEG + 1) * 3), 3))
1317
+ webGeo.setAttribute('aT', new THREE.BufferAttribute(ts, 1))
1318
+
1319
+ const starRef = (folderK) => {
1320
+ if (subs.has(folderK)) return subs.get(folderK).group
1321
+ if (mains.has(folderK)) return mains.get(folderK).group
1322
+ return null
1323
+ }
1324
+
1325
+ const addLine = (fromObj, toObj, lift, ids) => {
1326
+ const mat = new THREE.ShaderMaterial({
1327
+ transparent: true, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending,
1328
+ uniforms: {
1329
+ uFrom: { value: new THREE.Vector3() },
1330
+ uTo: { value: new THREE.Vector3() },
1331
+ uLift: { value: lift },
1332
+ uHot: { value: 0.2 },
1333
+ uTime: { value: 0 },
1334
+ },
1335
+ vertexShader: WEB_VERT,
1336
+ fragmentShader: WEB_FRAG,
1337
+ })
1338
+ const line = new THREE.Line(webGeo, mat)
1339
+ line.frustumCulled = false
1340
+ line.renderOrder = 2
1341
+ scene.add(line)
1342
+ const rec = { line, from: fromObj, to: toObj, ids, fromObj, toObj }
1343
+ webLines.push(rec)
1344
+ for (const id of ids) {
1345
+ if (!hotRanges.has(id)) hotRanges.set(id, [])
1346
+ hotRanges.get(id).push(rec)
1347
+ }
1348
+ }
1349
+
1350
+ const byCluster = new Map()
1351
+ for (const [id, node] of mems) {
1352
+ const from = starRef(node.folder)
1353
+ if (!from) continue
1354
+ addLine(from, node.sprite, 0.9 + rnd(id.charCodeAt(0)) * 1.4, [id])
1355
+ if (!byCluster.has(node.folder)) byCluster.set(node.folder, [])
1356
+ byCluster.get(node.folder).push(node)
1357
+ }
1358
+
1359
+ for (const [, list] of byCluster) {
1360
+ if (list.length < 2) continue
1361
+ for (let i = 0; i < list.length; i++) {
1362
+ const a = list[i]
1363
+ const links = 1 + (i % 2)
1364
+ for (let k = 1; k <= links; k++) {
1365
+ const b = list[(i + k * 3 + 1) % list.length]
1366
+ if (!b || b.id === a.id) continue
1367
+ addLine(a.sprite, b.sprite, 1.6 + rnd(i + k) * 1.2, [a.id, b.id])
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ // 每条丝线归到它两端的星辰名下,标签要贴着这些线的最高点放
1373
+ const index = new Map()
1374
+ const push = (obj, rec) => {
1375
+ if (!obj) return
1376
+ if (!index.has(obj)) index.set(obj, [])
1377
+ if (!index.get(obj).includes(rec)) index.get(obj).push(rec)
1378
+ }
1379
+ for (const rec of webLines) {
1380
+ push(rec.fromObj, rec)
1381
+ push(rec.toObj, rec)
1382
+ }
1383
+ for (const [, n] of mains) n.starLines = index.get(n.group) || []
1384
+ for (const [, n] of subs) n.starLines = index.get(n.group) || []
1385
+
1386
+ updateWeb()
1387
+ }
1388
+
1389
+ /* 端点只更新 uniform,不在 CPU 上重算顶点 */
1390
+ function updateWeb(time) {
1391
+ for (const rec of webLines) {
1392
+ if (!rec.from.parent || !rec.to.parent) continue
1393
+ rec.from.getWorldPosition(wa)
1394
+ rec.to.getWorldPosition(wb)
1395
+ const u = rec.line.material.uniforms
1396
+ u.uFrom.value.copy(wa)
1397
+ u.uTo.value.copy(wb)
1398
+ if (time != null) u.uTime.value = time
1399
+ }
1400
+ }
1401
+
1402
+ function setHot(idsOrNull, level = 1.0) {
1403
+ for (const rec of webLines) rec.line.material.uniforms.uHot.value = 0.2
1404
+ if (!idsOrNull) return
1405
+ for (const id of idsOrNull) {
1406
+ for (const rec of hotRanges.get(id) || []) rec.line.material.uniforms.uHot.value = level
1407
+ }
1408
+ }
1409
+
1410
+ /* 某个星辰下面(含子分区)的所有记忆 id,用来点亮丝线 */
1411
+ function memIdsIn(k) {
1412
+ const ids = []
1413
+ for (const [id, node] of mems) {
1414
+ if (node.folder === k || (node.mem && node.mem.parent === k)) ids.push(id)
1415
+ }
1416
+ return ids.length ? ids : null
1417
+ }
1418
+
1419
+ /* ---------------- 命中:按屏幕距离,小范围 ---------------- */
1420
+ const tmpV = new THREE.Vector3()
1421
+ function hitTest(clientX, clientY) {
1422
+ const w = window.innerWidth
1423
+ const h = window.innerHeight
1424
+ const cands = []
1425
+ const push = (pick, radius) => {
1426
+ pick.object.getWorldPosition(tmpV)
1427
+ tmpV.project(camera)
1428
+ if (tmpV.z > 1) return
1429
+ const sx = (tmpV.x * 0.5 + 0.5) * w
1430
+ const sy = (-tmpV.y * 0.5 + 0.5) * h
1431
+ const d = Math.hypot(sx - clientX, sy - clientY)
1432
+ if (d < radius) cands.push({ pick, d, weight: d / radius })
1433
+ }
1434
+ for (const pick of pickables) {
1435
+ if (pick.kind === 'main') push(pick, 26)
1436
+ else if (pick.kind === 'sub') push(pick, 18)
1437
+ else push(pick, 15)
1438
+ }
1439
+ if (!cands.length) {
1440
+ let best = null
1441
+ const nearest = (pick) => {
1442
+ pick.object.getWorldPosition(tmpV)
1443
+ tmpV.project(camera)
1444
+ if (tmpV.z > 1) return
1445
+ const sx = (tmpV.x * 0.5 + 0.5) * w
1446
+ const sy = (-tmpV.y * 0.5 + 0.5) * h
1447
+ const d = Math.hypot(sx - clientX, sy - clientY)
1448
+ const limit = pick.kind === 'mem' ? 30 : 34
1449
+ if (d < limit && (!best || d < best.d)) best = { pick, d }
1450
+ }
1451
+ for (const pick of pickables) nearest(pick)
1452
+ return best ? best.pick : null
1453
+ }
1454
+ cands.sort((a, b) => a.weight - b.weight)
1455
+ return cands[0].pick
1456
+ }
1457
+
1458
+ /* ---------------- 交互 ---------------- */
1459
+ function bindEvents() {
1460
+ canvas.addEventListener('pointerleave', () => {
1461
+ if (state.theme === 'neuron') { neuroSetHover(-1, -1); neuroPeek(null) }
1462
+ })
1463
+
1464
+ canvas.addEventListener('pointerdown', (e) => {
1465
+ if (e.button !== 0) return
1466
+ jump.active = false
1467
+ drag = { x: e.clientX, y: e.clientY, yaw: cam.yaw, pitch: cam.pitch }
1468
+ moved = 0
1469
+ canvas.setPointerCapture(e.pointerId)
1470
+ canvas.classList.add('dragging')
1471
+ })
1472
+
1473
+ canvas.addEventListener('pointermove', (e) => {
1474
+ if (state.theme === 'neuron' && !drag) {
1475
+ const hit = neuroPick(e.clientX, e.clientY)
1476
+ if (hit) {
1477
+ neuroSetHover(hit.region, hit.kind === 'cell' ? hit.cell : -1)
1478
+ neuroPeek(hit)
1479
+ } else {
1480
+ neuroSetHover(-1, -1)
1481
+ neuroPeek(null)
1482
+ }
1483
+ canvas.style.cursor = hit ? 'pointer' : 'grab'
1484
+ }
1485
+ if (drag) {
1486
+ const dx = e.clientX - drag.x
1487
+ const dy = e.clientY - drag.y
1488
+ moved = Math.max(moved, Math.hypot(dx, dy))
1489
+ cam.yaw = drag.yaw - dx * 0.0052
1490
+ cam.pitch = clamp(drag.pitch + dy * 0.0042, -1.28, 1.28)
1491
+ return
1492
+ }
1493
+ const hit = hitTest(e.clientX, e.clientY)
1494
+ if (hit !== hovered) {
1495
+ hovered = hit
1496
+ canvas.style.cursor = hit ? 'pointer' : 'grab'
1497
+ if (hit && hit.kind === 'mem') setHot([hit.id], 0.9)
1498
+ else if (hit && (hit.kind === 'main' || hit.kind === 'sub')) setHot(memIdsIn(hit.k), 0.75)
1499
+ else if (!state.query) setHot(state.viewingMem ? [state.viewingMem.id] : null)
1500
+ }
1501
+ })
1502
+
1503
+ window.addEventListener('pointerup', (e) => {
1504
+ canvas.classList.remove('dragging')
1505
+ if (!drag) return
1506
+ const wasDrag = moved > 5
1507
+ drag = null
1508
+ if (wasDrag) return
1509
+ if (state.theme === 'neuron') {
1510
+ const nh = neuroPick(e.clientX, e.clientY)
1511
+ if (nh) {
1512
+ if (nh.kind === 'cell' && nh.cell != null) {
1513
+ const c = NEURO.cells[nh.cell]
1514
+ if (c.memId) {
1515
+ const m = state.memories.find((x) => x.id === c.memId)
1516
+ if (m) { state.viewingMem = m; state.sel = null; setHot([m.id], 0.9); renderPanel() }
1517
+ return
1518
+ }
1519
+ }
1520
+ const r = NEURO.regions[nh.region]
1521
+ if (r) {
1522
+ cam.tx = r.center.x; cam.ty = r.center.y; cam.tz = r.center.z
1523
+ cam.dist = 34
1524
+ }
1525
+ }
1526
+ return
1527
+ }
1528
+ const hit = hitTest(e.clientX, e.clientY)
1529
+ if (hit) select(hit)
1530
+ else if (state.sel) clearSelection()
1531
+ })
1532
+
1533
+ canvas.addEventListener('dblclick', (e) => {
1534
+ const hit = hitTest(e.clientX, e.clientY)
1535
+ if (!hit || hit.kind === 'mem') return
1536
+ const node = hit.kind === 'main' ? mains.get(hit.k) : subs.get(hit.k)
1537
+ if (!node) return
1538
+ node.expand = node.expand > 1.05 ? 1 : 1.9
1539
+ toast(node.expand > 1 ? '放大 ' + node.name : '收回 ' + node.name)
1540
+ })
1541
+
1542
+ canvas.addEventListener('wheel', (e) => {
1543
+ e.preventDefault()
1544
+ jump.active = false
1545
+ const k = Math.exp(clamp(e.deltaY, -160, 160) * 0.0016)
1546
+ cam.dist = clamp(cam.dist * k, 5, 220)
1547
+ }, { passive: false })
1548
+
1549
+ window.addEventListener('keydown', (e) => {
1550
+ if (e.key === 'Escape') {
1551
+ if (state.editing) { state.editing = false; renderPanel() }
1552
+ else if (state.viewingMem) { state.viewingMem = null; setHot(null); renderPanel() }
1553
+ else if (state.sel) clearSelection()
1554
+ else if (state.query) { $('search').value = ''; onSearch('') }
1555
+ }
1556
+ })
1557
+ }
1558
+
1559
+ /* ---------------- 选择 / 聚焦 / 明暗 ---------------- */
1560
+ function clearSelection() {
1561
+ state.sel = null
1562
+ state.editing = false
1563
+ state.viewingMem = null
1564
+ setHot(state.query ? state.matches : null)
1565
+ applyDim()
1566
+ renderPanel()
1567
+ $('panelTag').textContent = state.bandView ? '曲线' : '总览'
1568
+ }
1569
+
1570
+ function select(pick) {
1571
+ state.editing = false
1572
+ state.viewingMem = null
1573
+ if (pick.kind === 'mem') {
1574
+ state.sel = { kind: 'mem', id: pick.id }
1575
+ state.viewingMem = mems.get(pick.id)?.mem || null
1576
+ setHot([pick.id], 0.95)
1577
+ $('panelTag').textContent = '记忆'
1578
+ } else {
1579
+ state.sel = { kind: pick.kind, k: pick.k }
1580
+ const node = pick.kind === 'main' ? mains.get(pick.k) : subs.get(pick.k)
1581
+ if (node) {
1582
+ // 永远以星辰本体的世界坐标为中心(点文字也飞到这里),不受标签偏移影响
1583
+ const worldPos = new THREE.Vector3()
1584
+ node.group.getWorldPosition(worldPos)
1585
+ node.pos.copy(worldPos)
1586
+ startJump(worldPos, pick.kind === 'main' ? 15 : 8.5)
1587
+ }
1588
+ setHot(state.query ? state.matches : memIdsIn(pick.k), 0.9)
1589
+ $('panelTag').textContent = pick.kind === 'main' ? '主星云' : '副星辰'
1590
+ }
1591
+ applyDim()
1592
+ renderPanel()
1593
+ }
1594
+
1595
+ /* 神经云主题只看神经细胞:把原来的星点/文字藏起来 */
1596
+ function applyThemeGlyphs() {
1597
+ const neuron = state.theme === 'neuron'
1598
+ for (const [, node] of mains) {
1599
+ node.group.visible = !neuron
1600
+ if (node.label) node.label.visible = !neuron
1601
+ }
1602
+ for (const [, node] of subs) {
1603
+ node.group.visible = !neuron
1604
+ if (node.label) node.label.visible = !neuron
1605
+ }
1606
+ for (const [, node] of mems) node.sprite.visible = !neuron
1607
+ for (const rec of webLines) rec.line.visible = !neuron
1608
+ if (!neuron) {
1609
+ const card = $('peek')
1610
+ if (card) card.classList.remove('show')
1611
+ }
1612
+ }
1613
+
1614
+ function applyDim() {
1615
+ const sel = state.sel
1616
+ // 只有选中「星辰」才虚化别的星云;点开一条记忆不动星系明暗
1617
+ const starSel = sel && (sel.kind === 'main' || sel.kind === 'sub')
1618
+ const focusK = starSel ? (sel.kind === 'main' ? sel.k : (subs.get(sel.k)?.parentK || '')) : ''
1619
+ const focusMain = focusK ? (subs.has(focusK) ? subs.get(focusK).parentK : focusK) : ''
1620
+
1621
+ for (const [k, node] of mains) {
1622
+ const related = !focusMain || k === focusMain
1623
+ node.dimTarget = related ? 1 : 0.16
1624
+ }
1625
+ for (const [k, node] of subs) {
1626
+ const related = !focusMain || node.parentK === focusMain
1627
+ const onPath = sel && sel.kind === 'sub' ? sel.k === k : related
1628
+ node.dimTarget = onPath ? 1 : (related ? 0.55 : 0.12)
1629
+ }
1630
+ const dimMem = !!focusMain || state.pinnedOnly || !!state.query
1631
+ const onlyMem = !!(sel && sel.kind === 'mem')
1632
+ for (const [id, node] of mems) {
1633
+ let target = 1
1634
+ if (state.pinnedOnly && !node.mem.pinned) target = 0.15
1635
+ else if (state.query) target = state.matches.has(id) ? 1 : 0.12
1636
+ else if (onlyMem) target = sel.id === id ? 1 : 0.55
1637
+ else if (focusMain && node.folder !== focusK && !(subs.has(node.folder) && subs.get(node.folder).parentK === focusMain)) target = 0.18
1638
+ node.dimTarget = dimMem || sel ? target : 1
1639
+ }
1640
+ }
1641
+
1642
+ /* ---------------- 左面板 ---------------- */
1643
+ function el(tag, cls, text) {
1644
+ const n = document.createElement(tag)
1645
+ if (cls) n.className = cls
1646
+ if (text != null) n.textContent = text
1647
+ return n
1648
+ }
1649
+
1650
+ function renderPanel() {
1651
+ const body = $('panelBody')
1652
+ body.innerHTML = ''
1653
+ if (state.bandView) return renderBand(body)
1654
+ if (state.viewingMem) return renderMemory(body)
1655
+ if (state.sel && state.sel.kind !== 'mem') return renderStar(body)
1656
+ if (state.pinnedOnly) return renderPinned(body)
1657
+ renderOverview(body)
1658
+ $('panelTag').textContent = '总览'
1659
+ }
1660
+
1661
+ function renderOverview(body) {
1662
+ const total = state.memories.length
1663
+ const fixed = state.memories.filter((m) => m.pinned).length
1664
+ const aging = state.memories.filter((m) => !m.pinned && m.state !== 'fresh').length
1665
+ const st = el('div', 'stat')
1666
+ st.appendChild(el('span', null, '记忆 ')).appendChild(el('b', null, String(total)))
1667
+ st.appendChild(el('span', null, '固定 ')).appendChild(el('b', null, String(fixed)))
1668
+ st.appendChild(el('span', null, '在淡化 ')).appendChild(el('b', null, String(aging)))
1669
+ body.appendChild(st)
1670
+ body.appendChild(el('div', 'hr'))
1671
+ body.appendChild(el('div', 'label', '星辰 · 点一下跳过去'))
1672
+
1673
+ const tops = state.folders.filter((f) => !f.parent)
1674
+ for (const top of tops) {
1675
+ const count = state.memories.filter((m) => m.folder === top.k || (m.parent === top.k)).length
1676
+ const item = el('div', 'star-item' + (state.sel && state.sel.k === top.k ? ' on' : ''))
1677
+ item.appendChild(el('span', 'dot'))
1678
+ item.appendChild(el('span', 'nm', top.name))
1679
+ item.appendChild(el('span', 'ct', String(count)))
1680
+ item.onclick = () => { const p = pickables.find((x) => x.kind === 'main' && x.k === top.k); if (p) select(p) }
1681
+ body.appendChild(item)
1682
+
1683
+ for (const kid of state.folders.filter((f) => f.parent === top.k)) {
1684
+ const n = state.memories.filter((m) => m.folder === kid.k).length
1685
+ const sub = el('div', 'star-item sub' + (state.sel && state.sel.k === kid.k ? ' on' : ''))
1686
+ sub.appendChild(el('span', 'dot'))
1687
+ sub.appendChild(el('span', 'nm', kid.name))
1688
+ sub.appendChild(el('span', 'ct', String(n)))
1689
+ sub.onclick = () => { const p = pickables.find((x) => x.kind === 'sub' && x.k === kid.k); if (p) select(p) }
1690
+ body.appendChild(sub)
1691
+ }
1692
+ }
1693
+ const tip = el('div', 'muted', '拖拽转星系,滚轮缩放。左边的列表只跳星辰,记忆在选中星辰后看。')
1694
+ body.appendChild(tip)
1695
+ }
1696
+
1697
+ function renderStar(body) {
1698
+ const sel = state.sel
1699
+ const node = sel.kind === 'main' ? mains.get(sel.k) : subs.get(sel.k)
1700
+ if (!node) { renderOverview(body); return }
1701
+ const back = el('button', 'btn ghost', '← 总览')
1702
+ back.onclick = () => { clearSelection() }
1703
+ body.appendChild(back)
1704
+
1705
+ if (!state.editing) {
1706
+ const head = el('div', 'row')
1707
+ head.appendChild(el('span', 'label', sel.kind === 'main' ? '主星云' : '副星辰'))
1708
+ head.appendChild(el('span', 'pill', node.name || ''))
1709
+ body.appendChild(head)
1710
+ const btns = el('div', 'row end')
1711
+ const edit = el('button', 'btn', '修改名称')
1712
+ edit.onclick = () => { state.editing = true; renderPanel() }
1713
+ const del = el('button', 'btn', '删掉这颗星辰')
1714
+ del.onclick = async () => {
1715
+ const kids = state.folders.filter((f) => f.parent === node.k).length
1716
+ if (del.dataset.armed !== '1') {
1717
+ del.dataset.armed = '1'
1718
+ del.textContent = '再点确认删'
1719
+ del.classList.add('danger')
1720
+ setTimeout(() => {
1721
+ if (del.dataset.armed === '1') {
1722
+ del.dataset.armed = ''
1723
+ del.textContent = '删掉这颗星辰'
1724
+ del.classList.remove('danger')
1725
+ }
1726
+ }, 4000)
1727
+ toast(kids ? `再点一次就删,会连 ${kids} 个子星辰一起删` : '再点一次就删,记忆先进回收站')
1728
+ return
1729
+ }
1730
+ del.dataset.armed = ''
1731
+ del.textContent = '删掉这颗星辰'
1732
+ del.classList.remove('danger')
1733
+ const r = await fetch(`${API}/folder/rm`, {
1734
+ method: 'POST', headers: { 'content-type': 'application/json' },
1735
+ body: JSON.stringify({ scope: state.scope, k: node.k }),
1736
+ }).then((x) => x.json())
1737
+ if (r.ok) {
1738
+ toast(`删了 ${r.removed || 1} 个分区,转存 ${r.moved || 0} 条`, 'good')
1739
+ state.sel = null
1740
+ await loadData()
1741
+ } else toast(r.error || '没删掉', 'bad')
1742
+ }
1743
+ btns.appendChild(edit)
1744
+ btns.appendChild(del)
1745
+ body.appendChild(btns)
1746
+ } else {
1747
+ const input = el('input')
1748
+ input.type = 'text'
1749
+ input.value = node.name || ''
1750
+ body.appendChild(input)
1751
+ const btns = el('div', 'row end')
1752
+ const cancel = el('button', 'btn', '取消')
1753
+ cancel.onclick = () => { state.editing = false; renderPanel() }
1754
+ const save = el('button', 'btn primary', '保存')
1755
+ save.onclick = async () => {
1756
+ const name = input.value.trim()
1757
+ if (!name) return toast('名字不能空', 'bad')
1758
+ const r = await fetch(`${API}/folder`, {
1759
+ method: 'POST', headers: { 'content-type': 'application/json' },
1760
+ body: JSON.stringify({ scope: state.scope, k: node.k, name }),
1761
+ }).then((x) => x.json())
1762
+ if (r.ok) { toast('改名好了', 'good'); state.editing = false; await loadData() }
1763
+ else toast(r.error || '没改成', 'bad')
1764
+ }
1765
+ btns.appendChild(cancel); btns.appendChild(save)
1766
+ body.appendChild(btns)
1767
+ }
1768
+
1769
+ body.appendChild(el('div', 'hr'))
1770
+ const list = state.memories.filter((m) => m.folder === node.k || (sel.kind === 'main' && m.parent === node.k))
1771
+ body.appendChild(el('div', 'label', `记忆 · ${list.length}`))
1772
+ if (!list.length) body.appendChild(el('div', 'muted', '这个星辰还是空的。'))
1773
+ for (const m of list) {
1774
+ const row = el('div', 'mem')
1775
+ row.appendChild(el('div', 't', m.text))
1776
+ const meta = el('div', 'm')
1777
+ meta.appendChild(el('span', 'pill' + (m.pinned ? ' fixed' : ''), m.pinned ? (m.autoFixed ? '默认固定' : '固定') : (m.days + ' 天')))
1778
+ if (!m.pinned) meta.appendChild(el('span', null, stateWord(m.state)))
1779
+ if (m.folder !== node.k) meta.appendChild(el('span', null, m.folderName))
1780
+ row.appendChild(meta)
1781
+ const acts = el('div', 'acts')
1782
+ const view = el('button', 'btn', '查看')
1783
+ view.onclick = () => { state.viewingMem = m; state.editing = false; setHot([m.id], 0.95); renderPanel(); $('panelTag').textContent = '记忆' }
1784
+ acts.appendChild(view)
1785
+ row.appendChild(acts)
1786
+ body.appendChild(row)
1787
+ }
1788
+ }
1789
+
1790
+ function stateWord(s) {
1791
+ return s === 'forgotten' ? '即将遗忘' : s === 'fading' ? '7 天后淡化' : s === 'fixed' ? '固定' : '新鲜'
1792
+ }
1793
+
1794
+ function renderMemory(body) {
1795
+ const m = state.viewingMem
1796
+ if (!m) { renderOverview(body); return }
1797
+
1798
+ const back = el('button', 'btn ghost', '← 返回')
1799
+ back.onclick = () => { state.viewingMem = null; state.editing = false; setHot(state.query ? state.matches : null); renderPanel(); $('panelTag').textContent = state.sel && state.sel.kind !== 'mem' ? '星辰' : '总览' }
1800
+ body.appendChild(back)
1801
+
1802
+ body.appendChild(el('div', 'label', `来自 · ${m.folderName}`))
1803
+
1804
+ if (!state.editing) {
1805
+ const box = el('div', 'mem')
1806
+ box.appendChild(el('div', 't', m.text))
1807
+ body.appendChild(box)
1808
+ const meta = el('div', 'row')
1809
+ meta.appendChild(el('span', 'pill' + (m.pinned ? ' fixed' : ''), m.pinned ? (m.autoFixed ? '默认固定(不会被遗忘)' : '固定记忆') : `${m.days} 天 · ${stateWord(m.state)}`))
1810
+ body.appendChild(meta)
1811
+
1812
+ const btns = el('div', 'row end')
1813
+ const edit = el('button', 'btn', '修改')
1814
+ edit.onclick = () => { state.editing = true; renderPanel() }
1815
+ const pin = el('button', 'btn' + (m.pinned ? ' on' : ''), m.pinned ? '取消固定' : '固定记忆')
1816
+ pin.onclick = async () => {
1817
+ const r = await fetch(`${API}/pin`, {
1818
+ method: 'POST', headers: { 'content-type': 'application/json' },
1819
+ body: JSON.stringify({ scope: state.scope, folder: m.folder, id: m.id, pinned: !m.pinned }),
1820
+ }).then((x) => x.json())
1821
+ if (r.ok) { toast(m.pinned ? '放开了' : '固定住,不会忘', 'good'); const id = m.id; await loadData(); const nm = state.memories.find((x) => x.id === id); if (nm) state.viewingMem = nm; renderPanel() }
1822
+ else toast(r.error || '没成功', 'bad')
1823
+ }
1824
+ const del = el('button', 'btn', '删除')
1825
+ del.onclick = async () => {
1826
+ if (!confirm('删掉这条?会进回收站。')) return
1827
+ const r = await fetch(`${API}/del`, {
1828
+ method: 'POST', headers: { 'content-type': 'application/json' },
1829
+ body: JSON.stringify({ scope: state.scope, folder: m.folder, id: m.id }),
1830
+ }).then((x) => x.json())
1831
+ if (r.ok) { toast('删了', 'good'); state.viewingMem = null; await loadData() }
1832
+ else toast(r.error || '没删掉', 'bad')
1833
+ }
1834
+ btns.appendChild(del); btns.appendChild(pin); btns.appendChild(edit)
1835
+ body.appendChild(btns)
1836
+ return
1837
+ }
1838
+
1839
+ const ta = el('textarea')
1840
+ ta.rows = 6
1841
+ ta.value = m.text
1842
+ body.appendChild(ta)
1843
+ const btns = el('div', 'row end')
1844
+ const cancel = el('button', 'btn', '取消')
1845
+ cancel.onclick = () => { state.editing = false; renderPanel() }
1846
+ const save = el('button', 'btn primary', '保存')
1847
+ save.onclick = async () => {
1848
+ const text = ta.value.trim()
1849
+ if (!text) return toast('写点啥', 'bad')
1850
+ const r = await fetch(`${API}/save`, {
1851
+ method: 'POST', headers: { 'content-type': 'application/json' },
1852
+ body: JSON.stringify({ scope: state.scope, folder: m.folder, id: m.id, text }),
1853
+ }).then((x) => x.json())
1854
+ if (r.ok) { toast('改好了', 'good'); state.editing = false; const id = m.id; await loadData(); const nm = state.memories.find((x) => x.id === id); if (nm) state.viewingMem = nm; renderPanel() }
1855
+ else toast(r.error || '没存上', 'bad')
1856
+ }
1857
+ btns.appendChild(cancel); btns.appendChild(save)
1858
+ body.appendChild(btns)
1859
+ }
1860
+
1861
+ function renderPinned(body) {
1862
+ const list = state.memories.filter((m) => m.pinned)
1863
+ body.appendChild(el('div', 'label', `固定记忆 · ${list.length}(不会被遗忘)`))
1864
+ if (!list.length) body.appendChild(el('div', 'muted', '还没有固定记忆。'))
1865
+ for (const m of list) {
1866
+ const row = el('div', 'mem')
1867
+ row.appendChild(el('div', 't', m.text))
1868
+ const meta = el('div', 'm')
1869
+ meta.appendChild(el('span', 'pill fixed', m.autoFixed ? '默认固定' : '固定'))
1870
+ meta.appendChild(el('span', null, `${m.folderName} · ${m.days} 天`))
1871
+ row.appendChild(meta)
1872
+ row.onclick = () => { state.viewingMem = m; setHot([m.id], 0.95); renderPanel() }
1873
+ body.appendChild(row)
1874
+ }
1875
+ }
1876
+
1877
+ function renderBand(body) {
1878
+ const { kind, folderName } = state.bandView
1879
+ const labels = { fresh: '还没到 7 天', fading: '7 天后淡化', forgotten: '30 天后遗忘' }
1880
+ const list = state.memories.filter((m) => !m.pinned && m.state === kind && (!folderName || m.folderName === folderName))
1881
+ const back = el('button', 'btn ghost', '← 总览')
1882
+ back.onclick = () => { state.bandView = null; renderPanel(); $('panelTag').textContent = '总览' }
1883
+ body.appendChild(back)
1884
+ body.appendChild(el('div', 'label', `${labels[kind] || ''} · ${folderName || '全部'} · ${list.length}`))
1885
+ if (!list.length) body.appendChild(el('div', 'muted', '这一档没有要处理的。'))
1886
+ for (const m of list) {
1887
+ const row = el('div', 'mem')
1888
+ row.appendChild(el('div', 't', m.text))
1889
+ row.appendChild(el('div', 'm', `${m.folderName} · ${m.days} 天`))
1890
+ const acts = el('div', 'acts')
1891
+ const pin = el('button', 'btn', '固定住')
1892
+ pin.onclick = async (ev) => {
1893
+ ev.stopPropagation()
1894
+ const r = await fetch(`${API}/pin`, {
1895
+ method: 'POST', headers: { 'content-type': 'application/json' },
1896
+ body: JSON.stringify({ scope: state.scope, folder: m.folder, id: m.id, pinned: true }),
1897
+ }).then((x) => x.json())
1898
+ if (r.ok) { toast('固定住了', 'good'); state.bandView = { kind, folderName }; await loadData() }
1899
+ else toast(r.error || '没成功', 'bad')
1900
+ }
1901
+ acts.appendChild(pin)
1902
+ row.appendChild(acts)
1903
+ row.onclick = () => { state.viewingMem = m; state.bandView = null; setHot([m.id], 0.95); renderPanel() }
1904
+ body.appendChild(row)
1905
+ }
1906
+ }
1907
+
1908
+ /* ---------------- 遗忘曲线 ---------------- */
1909
+ function renderCurve() {
1910
+ const free = state.memories.filter((m) => !m.pinned)
1911
+ const fading = free.filter((m) => m.state === 'fading')
1912
+ const gone = free.filter((m) => m.state === 'forgotten')
1913
+ const soon = free.filter((m) => m.state === 'fresh' && m.days >= 3)
1914
+ $('curveCount').textContent = `淡化 ${fading.length} · 将忘 ${gone.length}`
1915
+
1916
+ const path = $('curvePath')
1917
+ const len = path.getTotalLength()
1918
+ const marks = [
1919
+ { t: 0.02, text: '新生', color: '#9fd0ff' },
1920
+ { t: 0.55, text: '7 天', color: '#93a7cc' },
1921
+ { t: 1.0, text: '30 天', color: '#7a80a0' },
1922
+ ]
1923
+ const dots = $('curveDots'); dots.innerHTML = ''
1924
+ const labelsEl = $('curveLabels'); labelsEl.innerHTML = ''
1925
+ const VB_W = 330
1926
+ const VB_H = 104
1927
+ for (const mk of marks) {
1928
+ const p = path.getPointAtLength(len * mk.t)
1929
+ const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
1930
+ c.setAttribute('cx', p.x); c.setAttribute('cy', p.y); c.setAttribute('r', 2.4); c.setAttribute('fill', mk.color)
1931
+ dots.appendChild(c)
1932
+ const tx = document.createElementNS('http://www.w3.org/2000/svg', 'text')
1933
+ // 靠右端的标签往回收,避免被 viewBox 裁掉又贴不到点上
1934
+ const endMark = mk.t >= 0.95
1935
+ const x = endMark ? p.x - 6 : p.x + 7
1936
+ const y = clamp(p.y - (endMark ? 13 : 7), 12, VB_H - 4)
1937
+ tx.setAttribute('x', String(clamp(x, 4, VB_W - 4)))
1938
+ tx.setAttribute('y', String(y))
1939
+ tx.setAttribute('text-anchor', endMark ? 'end' : 'start')
1940
+ tx.setAttribute('fill', mk.color)
1941
+ tx.setAttribute('font-size', '9')
1942
+ tx.setAttribute('opacity', '0.9')
1943
+ tx.textContent = mk.text
1944
+ labelsEl.appendChild(tx)
1945
+ }
1946
+
1947
+ const zones = $('curveZones')
1948
+ zones.innerHTML = ''
1949
+ const box = (title, list, kind, fallback) => {
1950
+ const z = el('div', 'zone')
1951
+ z.appendChild(el('div', 'zt', title))
1952
+ const zc = el('div', 'zc')
1953
+ const names = [...new Set(list.map((m) => m.folderName))]
1954
+ if (!names.length) {
1955
+ if (fallback && fallback.length) {
1956
+ const fnames = [...new Set(fallback.map((m) => m.folderName))]
1957
+ for (const name of fnames.slice(0, 4)) {
1958
+ const c = el('span', 'chip', name)
1959
+ c.style.opacity = '.55'
1960
+ c.title = '还没到这一档,快了'
1961
+ c.onclick = () => { state.bandView = { kind: 'fresh', folderName: name }; state.viewingMem = null; renderPanel(); $('panelTag').textContent = '曲线' }
1962
+ zc.appendChild(c)
1963
+ }
1964
+ } else {
1965
+ zc.appendChild(el('span', 'muted', '—'))
1966
+ }
1967
+ }
1968
+ for (const name of names.slice(0, 8)) {
1969
+ const c = el('span', 'chip', name)
1970
+ c.onclick = () => { state.bandView = { kind, folderName: name }; state.viewingMem = null; state.sel = null; renderPanel(); $('panelTag').textContent = '曲线' }
1971
+ zc.appendChild(c)
1972
+ }
1973
+ z.appendChild(zc)
1974
+ zones.appendChild(z)
1975
+ }
1976
+ box('7 天淡化', fading, 'fading', soon)
1977
+ box('30 天遗忘', gone, 'forgotten', null)
1978
+ }
1979
+
1980
+ /* ---------------- 检索 ---------------- */
1981
+ let searchTimer = 0
1982
+ function onSearch(q) {
1983
+ state.query = q.trim().toLowerCase()
1984
+ state.matches = new Set()
1985
+ if (state.query) {
1986
+ for (const m of state.memories) {
1987
+ const hay = `${m.text || ''} ${m.folderName || ''} ${m.mainName || ''}`.toLowerCase()
1988
+ if (hay.includes(state.query)) state.matches.add(m.id)
1989
+ }
1990
+ }
1991
+ setHot(state.query ? state.matches : (state.viewingMem ? [state.viewingMem.id] : null), 0.95)
1992
+ applyDim()
1993
+ $('hitInfo').textContent = state.query ? `${state.matches.size} 条` : ''
1994
+ if (state.query && !state.matches.size) toast('没找到')
1995
+ }
1996
+
1997
+ /* 相机跳转:cubic ease + 中途推拉 */
1998
+ const jump = { active: false, t: 0, dur: 0.95, fromT: new THREE.Vector3(), toT: new THREE.Vector3(), fromD: 0, toD: 0 }
1999
+
2000
+ function startJump(target, dist) {
2001
+ jump.active = true
2002
+ jump.t = 0
2003
+ jump.fromT.set(camCur.tx, camCur.ty, camCur.tz)
2004
+ jump.toT.copy(target)
2005
+ jump.fromD = camCur.dist
2006
+ jump.toD = dist
2007
+ cam.tx = target.x
2008
+ cam.ty = target.y
2009
+ cam.tz = target.z
2010
+ cam.dist = dist
2011
+ }
2012
+
2013
+ /* 星辰放大/收回:弹簧带一点回弹,标签反向缩放保持字号 */
2014
+ function springScale(node, dt) {
2015
+ const k = 140
2016
+ const c = 16
2017
+ const x = node.group.scale.x
2018
+ const target = node.expand || 1
2019
+ node.vel = (node.vel || 0) + ((target - x) * k - (node.vel || 0) * c) * dt
2020
+ const nx = x + node.vel * dt
2021
+ node.group.scale.setScalar(nx)
2022
+ }
2023
+
2024
+ /* ---------------- 循环 ---------------- */
2025
+ function animate(now) {
2026
+ requestAnimationFrame(animate)
2027
+ const dt = Math.min((now - lastT) / 1000 || 0.016, 0.05)
2028
+ lastT = now
2029
+ const t = now / 1000
2030
+
2031
+ const gearK = state.demo && state.demo.on ? state.demo.gear / 40 : 1
2032
+ if (!drag) cam.yaw += dt * 0.0045 * gearK
2033
+ camCur.yaw = damp(camCur.yaw, cam.yaw, 7, dt)
2034
+ camCur.pitch = damp(camCur.pitch, cam.pitch, 7, dt)
2035
+
2036
+ if (jump.active) {
2037
+ jump.t += dt
2038
+ const k = Math.min(1, jump.t / jump.dur)
2039
+ const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2 // easeInOutCubic
2040
+ camCur.tx = jump.fromT.x + (jump.toT.x - jump.fromT.x) * e
2041
+ camCur.ty = jump.fromT.y + (jump.toT.y - jump.fromT.y) * e
2042
+ camCur.tz = jump.fromT.z + (jump.toT.z - jump.fromT.z) * e
2043
+ // 中途略微拉远再贴上去,像镜头推近
2044
+ const push = Math.sin(Math.PI * k)
2045
+ camCur.dist = (jump.fromD + (jump.toD - jump.fromD) * e) * (1 + push * 0.35)
2046
+ camera.fov = 48 - push * 4.5
2047
+ camera.updateProjectionMatrix()
2048
+ if (k >= 1) {
2049
+ jump.active = false
2050
+ camera.fov = 48
2051
+ camera.updateProjectionMatrix()
2052
+ }
2053
+ } else {
2054
+ camCur.dist = damp(camCur.dist, cam.dist, 8, dt)
2055
+ camCur.tx = damp(camCur.tx, cam.tx, 6, dt)
2056
+ camCur.ty = damp(camCur.ty, cam.ty, 6, dt)
2057
+ camCur.tz = damp(camCur.tz, cam.tz, 6, dt)
2058
+ }
2059
+
2060
+ const cp = Math.cos(camCur.pitch)
2061
+ const breathe = 1 + Math.sin(t * 0.22) * 0.012
2062
+ camera.position.set(
2063
+ camCur.tx + Math.sin(camCur.yaw) * cp * camCur.dist * breathe,
2064
+ camCur.ty + Math.sin(camCur.pitch) * camCur.dist * breathe,
2065
+ camCur.tz + Math.cos(camCur.yaw) * cp * camCur.dist * breathe,
2066
+ )
2067
+ camera.lookAt(camCur.tx, camCur.ty, camCur.tz)
2068
+
2069
+ for (const [, node] of mains) {
2070
+ springScale(node, dt)
2071
+ node.group.rotation.y += dt * 0.01
2072
+ }
2073
+ for (const [, node] of subs) springScale(node, dt)
2074
+
2075
+ // 标签保持屏幕尺寸:越远越大、越近越小
2076
+ for (const node of labelNodes) {
2077
+ if (!node.label) continue
2078
+ const gs = Math.max(0.001, node.group.scale.x)
2079
+ node.group.getWorldPosition(tmpWorld)
2080
+ const d = tmpWorld.distanceTo(camera.position)
2081
+ const f = clamp(d / 110, 0.4, 2.6)
2082
+ node.label.scale.set((node.labelScale.x * f) / gs, (node.labelScale.y * f) / gs, 1)
2083
+
2084
+ // 该星辰头顶最高的那条丝线(贝塞尔顶点在 uFrom/uTo 中点上方 uLift/2)
2085
+ let topY = tmpWorld.y
2086
+ for (const rec of node.starLines || []) {
2087
+ const u = rec.line.material.uniforms
2088
+ const ay = (u.uFrom.value.y + u.uTo.value.y) * 0.5 + u.uLift.value * 0.5
2089
+ if (ay > topY) topY = ay
2090
+ }
2091
+ const wantY = Math.max(topY + 1.5, tmpWorld.y + 1.5)
2092
+ node.label.position.y = (wantY - tmpWorld.y) / gs
2093
+ }
2094
+ for (const [, node] of mains) {
2095
+ applyNodeDim(node, dt)
2096
+ }
2097
+ for (const [, node] of subs) applyNodeDim(node, dt)
2098
+ for (const [, node] of mems) {
2099
+ node.sprite.getWorldPosition(tmpWorld)
2100
+ const df = clamp(tmpWorld.distanceTo(camera.position) / 110, 0.42, 1.05)
2101
+ const target = node.base * df * (node.dim ?? 1) * (hovered && hovered.id === node.id ? 1.7 : 1)
2102
+ node.sprite.scale.setScalar(damp(node.sprite.scale.x, target, 6, dt))
2103
+ node.sprite.material.opacity = damp(node.sprite.material.opacity, (node.sprite.userData.baseOp || 0.9) * (node.dim ?? 1), 6, dt)
2104
+ }
2105
+ if (state.theme === 'neuron') {
2106
+ if (NEURO.somaMat) NEURO.somaMat.uniforms.uTime.value = t
2107
+ if (NEURO.lineMat) NEURO.lineMat.uniforms.uTime.value = t
2108
+ NEURO.group.rotation.y += dt * 0.02
2109
+ }
2110
+ updateWeb(t)
2111
+ skyRig.rotation.y += dt * (state.theme === 'starburst' ? 0.01 : 0.0035) * (state.demo && state.demo.on ? state.demo.gear / 40 : 1)
2112
+ for (const m of cloudMeshes) m.quaternion.copy(camera.quaternion)
2113
+ for (const c of skyRig.children) if (c.isPoints) c.material.uniforms.uTime.value = t
2114
+ renderFrame()
2115
+ }
2116
+
2117
+ function applyNodeDim(node, dt) {
2118
+ const want = node.dimTarget ?? 1
2119
+ node.dim = damp(node.dim ?? 1, want, 5, dt)
2120
+ node.group.traverse((o) => {
2121
+ if (!o.material || !o.material.opacity) return
2122
+ if (o.userData.base == null) return
2123
+ o.material.opacity = o.userData.base * node.dim
2124
+ })
2125
+ }
2126
+
2127
+ /* ---------------- 数据 ---------------- */
2128
+ async function loadScopes() {
2129
+ try {
2130
+ const r = await fetch(`${API}/scopes`, { cache: 'no-store' }).then((x) => x.json())
2131
+ state.scopes = r.scopes || []
2132
+ } catch { state.scopes = [{ id: 'global', label: '全局' }] }
2133
+ const sel = $('scope')
2134
+ sel.innerHTML = ''
2135
+ for (const s of state.scopes) {
2136
+ const o = document.createElement('option')
2137
+ o.value = s.id
2138
+ o.textContent = s.cwd ? `${s.label} · ${s.cwd}` : s.label
2139
+ sel.appendChild(o)
2140
+ }
2141
+ sel.value = state.scope
2142
+ const wsDel = $('wsDel')
2143
+ if (wsDel) wsDel.style.display = state.scope === 'global' ? 'none' : ''
2144
+ }
2145
+
2146
+ async function loadData() {
2147
+ const r = await fetch(`${API}/data?scope=${encodeURIComponent(state.scope)}`, { cache: 'no-store' }).then((x) => x.json())
2148
+ state.folders = r.folders || []
2149
+ state.memories = r.memories || []
2150
+ buildGalaxy()
2151
+ if (state.viewingMem) {
2152
+ state.viewingMem = state.memories.find((m) => m.id === state.viewingMem.id) || null
2153
+ }
2154
+ renderCurve()
2155
+ renderPanel()
2156
+ }
2157
+
2158
+ /* ---------------- 启动 ---------------- */
2159
+ async function boot() {
2160
+ try {
2161
+ initRenderer()
2162
+ bindEvents()
2163
+ const themeBtns = Array.from(document.querySelectorAll('#themes .tbtn'))
2164
+ const syncThemeBtns = () => themeBtns.forEach((b) => b.classList.toggle('on', b.dataset.theme === state.theme))
2165
+ syncThemeBtns()
2166
+ for (const b of themeBtns) {
2167
+ b.addEventListener('click', () => {
2168
+ if (b.dataset.theme === state.theme) return
2169
+ state.theme = b.dataset.theme
2170
+ try { localStorage.setItem('memory-sky-theme', state.theme) } catch {}
2171
+ buildSky(state.theme)
2172
+ syncThemeBtns()
2173
+ applyDim()
2174
+ applyThemeGlyphs()
2175
+ })
2176
+ }
2177
+ const demoEl = $('demo')
2178
+ const demoToggle = $('demoToggle')
2179
+ const demoSpeed = $('demoSpeed')
2180
+ const demoSpeedText = $('demoSpeedText')
2181
+ const demoClean = $('demoClean')
2182
+ const syncDemo = () => {
2183
+ if (!demoEl) return
2184
+ demoEl.classList.toggle('on', state.demo.on)
2185
+ demoToggle.classList.toggle('on', state.demo.on)
2186
+ demoSpeedText.textContent = String(state.demo.gear)
2187
+ demoClean.checked = state.demo.clean
2188
+ document.body.classList.toggle('demo-clean', state.demo.clean)
2189
+ }
2190
+ if (demoEl) {
2191
+ const params = new URLSearchParams(location.search)
2192
+ if (params.get('demo')) state.demo.on = true
2193
+ if (params.get('gear')) state.demo.gear = clamp(Number(params.get('gear')) || 40, 1, 100)
2194
+ if (params.get('clean')) state.demo.clean = true
2195
+ demoSpeed.value = String(state.demo.gear)
2196
+ demoToggle.addEventListener('click', () => { state.demo.on = !state.demo.on; syncDemo() })
2197
+ demoSpeed.addEventListener('input', () => {
2198
+ state.demo.gear = clamp(Number(demoSpeed.value) || 40, 1, 100)
2199
+ syncDemo()
2200
+ })
2201
+ demoClean.addEventListener('change', () => { state.demo.clean = demoClean.checked; syncDemo() })
2202
+ window.addEventListener('keydown', (e) => {
2203
+ if (e.key === 'Escape' && state.demo.clean) {
2204
+ state.demo.clean = false
2205
+ demoClean.checked = false
2206
+ syncDemo()
2207
+ }
2208
+ })
2209
+ syncDemo()
2210
+ }
2211
+
2212
+ // AI 分类:先确认(会烧 token),再把任务挂到宿主,下一轮对话自动执行
2213
+ const askEl = $('ask')
2214
+ const askNote = $('askNote')
2215
+ const aiBtn = $('aiClassify')
2216
+ const apiCall = async (path, body) => {
2217
+ try {
2218
+ const res = await fetch('/api/memory-sky' + path, body
2219
+ ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
2220
+ : {})
2221
+ return await res.json()
2222
+ } catch (e) {
2223
+ return { ok: false, error: '连不上宿主(可能需要重启 DeepSeek Harness)' }
2224
+ }
2225
+ }
2226
+ const refreshClassifyState = async () => {
2227
+ const r = await apiCall('/classify')
2228
+ if (r && r.ok && r.pending && askNote) {
2229
+ askNote.textContent = '已有一个待处理的分类任务:' + new Date(r.at).toLocaleString('zh-CN')
2230
+ } else if (askNote) {
2231
+ askNote.textContent = ''
2232
+ }
2233
+ if (aiBtn) aiBtn.classList.toggle('on', !!(r && r.ok && r.pending))
2234
+ }
2235
+ if (aiBtn) {
2236
+ aiBtn.addEventListener('click', () => {
2237
+ if (askEl) askEl.classList.add('show')
2238
+ refreshClassifyState()
2239
+ })
2240
+ }
2241
+ const askNo = $('askNo')
2242
+ if (askNo) askNo.addEventListener('click', () => askEl.classList.remove('show'))
2243
+ const askYes = $('askYes')
2244
+ if (askYes) {
2245
+ askYes.addEventListener('click', async () => {
2246
+ askEl.classList.remove('show')
2247
+ const r = await apiCall('/classify', {})
2248
+ if (r && r.ok) {
2249
+ toast('已排入队列:下一次对话时我会逐条重归类(会消耗 token)', 'good')
2250
+ refreshClassifyState()
2251
+ } else {
2252
+ toast((r && r.error) || '提交失败', 'bad')
2253
+ }
2254
+ })
2255
+ }
2256
+ if (askEl) {
2257
+ askEl.addEventListener('click', (e) => { if (e.target === askEl) askEl.classList.remove('show') })
2258
+ }
2259
+
2260
+ $('scope').addEventListener('change', async (e) => {
2261
+ state.scope = e.target.value
2262
+ state.sel = null; state.viewingMem = null; state.bandView = null
2263
+ await loadData()
2264
+ const wsDel = $('wsDel')
2265
+ if (wsDel) wsDel.style.display = state.scope === 'global' ? 'none' : ''
2266
+ })
2267
+ const wsDelBtn = $('wsDel')
2268
+ if (wsDelBtn) {
2269
+ wsDelBtn.addEventListener('click', async () => {
2270
+ if (state.scope === 'global') return
2271
+ const one = state.scopes.find((s) => s.id === state.scope)
2272
+ if (!confirm(`删掉工作区「${one ? one.label : state.scope}」的整份记忆?\n会移到 ws/.deleted,不是真销毁。`)) return
2273
+ const r = await fetch(`${API}/scope/rm`, {
2274
+ method: 'POST', headers: { 'content-type': 'application/json' },
2275
+ body: JSON.stringify({ scope: state.scope }),
2276
+ }).then((x) => x.json())
2277
+ if (r.ok) {
2278
+ toast('删了,挪到 ws/.deleted', 'good')
2279
+ state.scope = 'global'
2280
+ state.sel = null
2281
+ await loadScopes()
2282
+ await loadData()
2283
+ } else toast(r.error || '没删掉', 'bad')
2284
+ })
2285
+ }
2286
+ $('search').addEventListener('input', (e) => {
2287
+ clearTimeout(searchTimer)
2288
+ const v = e.target.value
2289
+ searchTimer = setTimeout(() => onSearch(v), 130)
2290
+ })
2291
+ $('pinnedBtn').addEventListener('click', () => {
2292
+ state.pinnedOnly = !state.pinnedOnly
2293
+ $('pinnedBtn').classList.toggle('on', state.pinnedOnly)
2294
+ state.sel = null; state.viewingMem = null; state.bandView = null
2295
+ applyDim(); renderPanel()
2296
+ $('panelTag').textContent = state.pinnedOnly ? '固定记忆' : '总览'
2297
+ })
2298
+ $('resetBtn').addEventListener('click', () => {
2299
+ cam.tx = cam.ty = cam.tz = 0
2300
+ cam.dist = fit.dist
2301
+ cam.yaw = 0.62; cam.pitch = 0.5
2302
+ state.sel = null; state.viewingMem = null; state.bandView = null; state.editing = false
2303
+ applyDim(); renderPanel()
2304
+ })
2305
+ $('curveHead').addEventListener('click', () => $('curve').classList.toggle('collapsed'))
2306
+
2307
+ await loadScopes()
2308
+ await loadData()
2309
+ requestAnimationFrame(animate)
2310
+
2311
+ const params = new URLSearchParams(location.search)
2312
+ const q0 = params.get('q')
2313
+ if (q0) { $('search').value = q0; onSearch(q0) }
2314
+ const focus = params.get('focus')
2315
+ if (focus) {
2316
+ const node = mems.get(focus)
2317
+ if (node) { select({ kind: 'mem', id: focus }) }
2318
+ }
2319
+ const star = params.get('star')
2320
+ if (star) {
2321
+ const pick = pickables.find((x) => (x.kind === 'main' || x.kind === 'sub') && x.k === star)
2322
+ if (pick) select(pick)
2323
+ }
2324
+ // 调试用:?expand=K 直接把某个星辰放到放大状态
2325
+ const expand = params.get('expand')
2326
+ if (expand) {
2327
+ const node = mains.get(expand) || subs.get(expand)
2328
+ if (node) node.expand = 1.9
2329
+ }
2330
+ } catch (e) {
2331
+ $('fallback').style.display = 'flex'
2332
+ console.error(e)
2333
+ }
2334
+ }
2335
+
2336
+ boot()