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.
- package/README.md +37 -0
- package/SKILL.md +5 -0
- package/client.js +154 -31
- package/identity.md +12 -0
- package/index.js +203 -4
- package/memtree.mjs +47 -2
- package/package.json +5 -2
- package/public/sky/app.js +2336 -0
- package/public/sky/app.js.bak +2336 -0
- package/public/sky/index.html +286 -0
- package/public/sky/vendor/three.core.js +60586 -0
- package/public/sky/vendor/three.module.js +19719 -0
- package/sky.js +332 -0
|
@@ -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()
|