@brandonlukas/luminar 0.2.4 → 0.2.6
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 +5 -1
- package/bin/luminar.mjs +33 -47
- package/dist/assets/{index-DtdKjtzr.js → index-DUBGNNKo.js} +1 -1
- package/dist/assets/index-f3JJuz__.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +5 -3
- package/src/main.ts +39 -45
- package/src/modules/controls.ts +66 -28
- package/src/modules/field-loader.ts +5 -4
- package/src/modules/particle-system.ts +5 -1
- package/src/modules/recording.ts +17 -17
- package/src/style.css +56 -4
- package/dist/assets/index-BTv18fJQ.css +0 -1
package/README.md
CHANGED
|
@@ -26,7 +26,9 @@ npx @brandonlukas/luminar
|
|
|
26
26
|
|
|
27
27
|
Then drag your CSV files onto the left or right side of the canvas to load Field A or Field B.
|
|
28
28
|
|
|
29
|
-
Optional flags: `--port 5173`, `--host 0.0.0.0
|
|
29
|
+
Optional flags: `--port 5173`, `--host 0.0.0.0`
|
|
30
|
+
|
|
31
|
+
**CLI behavior:** `npx @brandonlukas/luminar` serves the pre-built `/dist` bundle via a tiny static server (sirv). There is no runtime build step—just download and open.
|
|
30
32
|
|
|
31
33
|
## CSV Format
|
|
32
34
|
|
|
@@ -64,6 +66,8 @@ npm run build
|
|
|
64
66
|
npm run preview # test production build locally
|
|
65
67
|
```
|
|
66
68
|
|
|
69
|
+
**Publishable CLI bundle:** The CLI serves the pre-built `/dist` folder. Run `npm run build` before publishing to ensure fresh assets are included.
|
|
70
|
+
|
|
67
71
|
## Architecture
|
|
68
72
|
|
|
69
73
|
### Performance
|
package/bin/luminar.mjs
CHANGED
|
@@ -1,39 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { resolve, dirname } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
function parseCsv(text) {
|
|
8
|
-
const lines = text.split(/\r?\n/).filter(Boolean)
|
|
9
|
-
const rows = []
|
|
10
|
-
let skippedHeader = false
|
|
11
|
-
|
|
12
|
-
for (const line of lines) {
|
|
13
|
-
const parts = line.split(/[,\s]+/).filter(Boolean)
|
|
14
|
-
if (parts.length < 4) continue
|
|
15
|
-
|
|
16
|
-
const [x, y, dx, dy] = parts.map(Number)
|
|
17
|
-
if ([x, y, dx, dy].some((n) => Number.isNaN(n))) {
|
|
18
|
-
if (!skippedHeader && rows.length === 0) {
|
|
19
|
-
skippedHeader = true
|
|
20
|
-
console.log('skipping header line:', line.substring(0, 60))
|
|
21
|
-
}
|
|
22
|
-
continue
|
|
23
|
-
}
|
|
24
|
-
rows.push({ x, y, dx, dy })
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return rows
|
|
28
|
-
}
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
import sirv from 'sirv'
|
|
6
|
+
import open from 'open'
|
|
29
7
|
|
|
30
8
|
const __filename = fileURLToPath(import.meta.url)
|
|
31
9
|
const __dirname = dirname(__filename)
|
|
32
10
|
const projectRoot = resolve(__dirname, '..')
|
|
11
|
+
const distPath = resolve(projectRoot, 'dist')
|
|
33
12
|
|
|
34
13
|
function parseArgs() {
|
|
35
14
|
const args = process.argv.slice(2)
|
|
36
|
-
const out = { port: 5173, host: '0.0.0.0'
|
|
15
|
+
const out = { port: 5173, host: '0.0.0.0' }
|
|
37
16
|
|
|
38
17
|
for (let i = 0; i < args.length; i += 1) {
|
|
39
18
|
const arg = args[i]
|
|
@@ -41,35 +20,42 @@ function parseArgs() {
|
|
|
41
20
|
out.port = Number(args[++i]) || out.port
|
|
42
21
|
} else if (arg === '--host') {
|
|
43
22
|
out.host = args[++i] || out.host
|
|
44
|
-
} else if (arg === '--preview') {
|
|
45
|
-
out.preview = true
|
|
46
23
|
}
|
|
47
24
|
}
|
|
48
25
|
return out
|
|
49
26
|
}
|
|
50
27
|
|
|
51
|
-
function runServer({ port, host, preview }) {
|
|
52
|
-
const cmd = 'npm'
|
|
53
|
-
const args = preview
|
|
54
|
-
? ['run', 'build-and-preview', '--', '--host', host, '--port', String(port)]
|
|
55
|
-
: ['run', 'dev', '--', '--host', host, '--port', String(port)]
|
|
56
|
-
console.log(`starting ${preview ? 'preview' : 'dev'} server on http://${host}:${port}`)
|
|
57
|
-
const child = spawn(cmd, args, {
|
|
58
|
-
stdio: 'inherit',
|
|
59
|
-
cwd: projectRoot,
|
|
60
|
-
env: process.env,
|
|
61
|
-
})
|
|
62
|
-
child.on('exit', (code) => process.exit(code ?? 0))
|
|
63
|
-
}
|
|
64
|
-
|
|
65
28
|
function main() {
|
|
66
|
-
const { port, host
|
|
29
|
+
const { port, host } = parseArgs()
|
|
30
|
+
const serve = sirv(distPath, { gzip: true, etag: true })
|
|
31
|
+
|
|
32
|
+
const server = http.createServer((req, res) => {
|
|
33
|
+
serve(req, res, () => {
|
|
34
|
+
// If no file found, serve index.html (SPA routing)
|
|
35
|
+
if (req.url !== '/' && !req.url.includes('.')) {
|
|
36
|
+
req.url = '/'
|
|
37
|
+
serve(req, res)
|
|
38
|
+
} else {
|
|
39
|
+
res.statusCode = 404
|
|
40
|
+
res.end('Not found')
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|
|
67
44
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
45
|
+
server.listen(port, host, () => {
|
|
46
|
+
const url = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`
|
|
47
|
+
console.log(`🚀 Launching luminar on ${url}`)
|
|
48
|
+
console.log('📂 Drag & drop CSV files into the browser to visualize')
|
|
49
|
+
console.log('')
|
|
50
|
+
// Open browser on localhost (not 0.0.0.0)
|
|
51
|
+
if (host === '0.0.0.0' || host === 'localhost') {
|
|
52
|
+
open(`http://localhost:${port}`).catch(() => { })
|
|
53
|
+
}
|
|
54
|
+
})
|
|
71
55
|
|
|
72
|
-
|
|
56
|
+
process.on('SIGINT', () => {
|
|
57
|
+
server.close(() => process.exit(0))
|
|
58
|
+
})
|
|
73
59
|
}
|
|
74
60
|
|
|
75
61
|
main()
|
|
@@ -4178,4 +4178,4 @@ void main() {
|
|
|
4178
4178
|
|
|
4179
4179
|
gl_FragColor = max(texelNew, texelOld);
|
|
4180
4180
|
|
|
4181
|
-
}`},Cc=class extends dc{constructor(e=.96){super(),this.uniforms=Or.clone(Sc.uniforms),this.damp=e,this.compFsMaterial=new jr({uniforms:this.uniforms,vertexShader:Sc.vertexShader,fragmentShader:Sc.fragmentShader}),this.copyFsMaterial=new jr({uniforms:Or.clone(uc.uniforms),vertexShader:uc.vertexShader,fragmentShader:uc.fragmentShader,blending:0,depthTest:!1,depthWrite:!1}),this._textureComp=new xt(window.innerWidth,window.innerHeight,{magFilter:r,type:g}),this._textureOld=new xt(window.innerWidth,window.innerHeight,{magFilter:r,type:g}),this._compFsQuad=new mc(this.compFsMaterial),this._copyFsQuad=new mc(this.copyFsMaterial)}get damp(){return this.uniforms.damp.value}set damp(e){this.uniforms.damp.value=e}render(e,t,n){this.uniforms.tOld.value=this._textureOld.texture,this.uniforms.tNew.value=n.texture,e.setRenderTarget(this._textureComp),this._compFsQuad.render(e),this._copyFsQuad.material.uniforms.tDiffuse.value=this._textureComp.texture,this.renderToScreen?(e.setRenderTarget(null),this._copyFsQuad.render(e)):(e.setRenderTarget(t),this.clear&&e.clear(),this._copyFsQuad.render(e));let r=this._textureOld;this._textureOld=this._textureComp,this._textureComp=r}setSize(e,t){this._textureComp.setSize(e,t),this._textureOld.setSize(e,t)}dispose(){this._textureComp.dispose(),this._textureOld.dispose(),this.compFsMaterial.dispose(),this.copyFsMaterial.dispose(),this._compFsQuad.dispose(),this._copyFsQuad.dispose()}},wc=class{constructor(e=Math){this.grad3=[[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]],this.grad4=[[0,1,1,1],[0,1,1,-1],[0,1,-1,1],[0,1,-1,-1],[0,-1,1,1],[0,-1,1,-1],[0,-1,-1,1],[0,-1,-1,-1],[1,0,1,1],[1,0,1,-1],[1,0,-1,1],[1,0,-1,-1],[-1,0,1,1],[-1,0,1,-1],[-1,0,-1,1],[-1,0,-1,-1],[1,1,0,1],[1,1,0,-1],[1,-1,0,1],[1,-1,0,-1],[-1,1,0,1],[-1,1,0,-1],[-1,-1,0,1],[-1,-1,0,-1],[1,1,1,0],[1,1,-1,0],[1,-1,1,0],[1,-1,-1,0],[-1,1,1,0],[-1,1,-1,0],[-1,-1,1,0],[-1,-1,-1,0]],this.p=[];for(let t=0;t<256;t++)this.p[t]=Math.floor(e.random()*256);this.perm=[];for(let e=0;e<512;e++)this.perm[e]=this.p[e&255];this.simplex=[[0,1,2,3],[0,1,3,2],[0,0,0,0],[0,2,3,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,3,0],[0,2,1,3],[0,0,0,0],[0,3,1,2],[0,3,2,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,3,2,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,0,3],[0,0,0,0],[1,3,0,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,3,0,1],[2,3,1,0],[1,0,2,3],[1,0,3,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,3,1],[0,0,0,0],[2,1,3,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,1,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,0,1,2],[3,0,2,1],[0,0,0,0],[3,1,2,0],[2,1,0,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,1,0,2],[0,0,0,0],[3,2,0,1],[3,2,1,0]]}noise(e,t){let n,r,i,a=.5*(Math.sqrt(3)-1),o=(e+t)*a,s=Math.floor(e+o),c=Math.floor(t+o),l=(3-Math.sqrt(3))/6,u=(s+c)*l,d=s-u,f=c-u,p=e-d,m=t-f,h,g;p>m?(h=1,g=0):(h=0,g=1);let _=p-h+l,v=m-g+l,y=p-1+2*l,b=m-1+2*l,x=s&255,S=c&255,C=this.perm[x+this.perm[S]]%12,w=this.perm[x+h+this.perm[S+g]]%12,T=this.perm[x+1+this.perm[S+1]]%12,E=.5-p*p-m*m;E<0?n=0:(E*=E,n=E*E*this._dot(this.grad3[C],p,m));let D=.5-_*_-v*v;D<0?r=0:(D*=D,r=D*D*this._dot(this.grad3[w],_,v));let ee=.5-y*y-b*b;return ee<0?i=0:(ee*=ee,i=ee*ee*this._dot(this.grad3[T],y,b)),70*(n+r+i)}noise3d(e,t,n){let r,i,a,o,s=(e+t+n)*(1/3),c=Math.floor(e+s),l=Math.floor(t+s),u=Math.floor(n+s),d=1/6,f=(c+l+u)*d,p=c-f,m=l-f,h=u-f,g=e-p,_=t-m,v=n-h,y,b,x,S,C,w;g>=_?_>=v?(y=1,b=0,x=0,S=1,C=1,w=0):g>=v?(y=1,b=0,x=0,S=1,C=0,w=1):(y=0,b=0,x=1,S=1,C=0,w=1):_<v?(y=0,b=0,x=1,S=0,C=1,w=1):g<v?(y=0,b=1,x=0,S=0,C=1,w=1):(y=0,b=1,x=0,S=1,C=1,w=0);let T=g-y+d,E=_-b+d,D=v-x+d,ee=g-S+2*d,O=_-C+2*d,k=v-w+2*d,te=g-1+3*d,ne=_-1+3*d,A=v-1+3*d,j=c&255,M=l&255,N=u&255,re=this.perm[j+this.perm[M+this.perm[N]]]%12,ie=this.perm[j+y+this.perm[M+b+this.perm[N+x]]]%12,P=this.perm[j+S+this.perm[M+C+this.perm[N+w]]]%12,ae=this.perm[j+1+this.perm[M+1+this.perm[N+1]]]%12,oe=.6-g*g-_*_-v*v;oe<0?r=0:(oe*=oe,r=oe*oe*this._dot3(this.grad3[re],g,_,v));let F=.6-T*T-E*E-D*D;F<0?i=0:(F*=F,i=F*F*this._dot3(this.grad3[ie],T,E,D));let I=.6-ee*ee-O*O-k*k;I<0?a=0:(I*=I,a=I*I*this._dot3(this.grad3[P],ee,O,k));let L=.6-te*te-ne*ne-A*A;return L<0?o=0:(L*=L,o=L*L*this._dot3(this.grad3[ae],te,ne,A)),32*(r+i+a+o)}noise4d(e,t,n,r){let i=this.grad4,a=this.simplex,o=this.perm,s=(Math.sqrt(5)-1)/4,c=(5-Math.sqrt(5))/20,l,u,d,f,p,m=(e+t+n+r)*s,h=Math.floor(e+m),g=Math.floor(t+m),_=Math.floor(n+m),v=Math.floor(r+m),y=(h+g+_+v)*c,b=h-y,x=g-y,S=_-y,C=v-y,w=e-b,T=t-x,E=n-S,D=r-C,ee=w>T?32:0,O=w>E?16:0,k=T>E?8:0,te=w>D?4:0,ne=T>D?2:0,A=E>D?1:0,j=ee+O+k+te+ne+A,M=a[j][0]>=3?1:0,N=a[j][1]>=3?1:0,re=a[j][2]>=3?1:0,ie=a[j][3]>=3?1:0,P=a[j][0]>=2?1:0,ae=a[j][1]>=2?1:0,oe=a[j][2]>=2?1:0,F=a[j][3]>=2?1:0,I=a[j][0]>=1?1:0,L=a[j][1]>=1?1:0,se=a[j][2]>=1?1:0,ce=a[j][3]>=1?1:0,le=w-M+c,ue=T-N+c,R=E-re+c,de=D-ie+c,fe=w-P+2*c,pe=T-ae+2*c,me=E-oe+2*c,he=D-F+2*c,ge=w-I+3*c,_e=T-L+3*c,ve=E-se+3*c,ye=D-ce+3*c,be=w-1+4*c,xe=T-1+4*c,Se=E-1+4*c,Ce=D-1+4*c,we=h&255,z=g&255,Te=_&255,B=v&255,Ee=o[we+o[z+o[Te+o[B]]]]%32,V=o[we+M+o[z+N+o[Te+re+o[B+ie]]]]%32,De=o[we+P+o[z+ae+o[Te+oe+o[B+F]]]]%32,H=o[we+I+o[z+L+o[Te+se+o[B+ce]]]]%32,U=o[we+1+o[z+1+o[Te+1+o[B+1]]]]%32,Oe=.6-w*w-T*T-E*E-D*D;Oe<0?l=0:(Oe*=Oe,l=Oe*Oe*this._dot4(i[Ee],w,T,E,D));let ke=.6-le*le-ue*ue-R*R-de*de;ke<0?u=0:(ke*=ke,u=ke*ke*this._dot4(i[V],le,ue,R,de));let Ae=.6-fe*fe-pe*pe-me*me-he*he;Ae<0?d=0:(Ae*=Ae,d=Ae*Ae*this._dot4(i[De],fe,pe,me,he));let je=.6-ge*ge-_e*_e-ve*ve-ye*ye;je<0?f=0:(je*=je,f=je*je*this._dot4(i[H],ge,_e,ve,ye));let Me=.6-be*be-xe*xe-Se*Se-Ce*Ce;return Me<0?p=0:(Me*=Me,p=Me*Me*this._dot4(i[U],be,xe,Se,Ce)),27*(l+u+d+f+p)}_dot(e,t,n){return e[0]*t+e[1]*n}_dot3(e,t,n,r){return e[0]*t+e[1]*n+e[2]*r}_dot4(e,t,n,r,i){return e[0]*t+e[1]*n+e[2]*r+e[3]*i}};const Tc=1.25,Ec=[{key:`luminous-violet`,label:`Luminous violet`,rgb:[.6,.25,.9]},{key:`pure-white`,label:`Pure white`,rgb:[1,1,1]},{key:`neon-cyan`,label:`Neon cyan`,rgb:[.25,.95,1]},{key:`electric-lime`,label:`Electric lime`,rgb:[.75,1,.25]},{key:`solar-flare`,label:`Solar flare`,rgb:[1,.55,.15]},{key:`aurora-mint`,label:`Aurora mint`,rgb:[.4,1,.85]},{key:`sunrise-coral`,label:`Sunrise coral`,rgb:[1,.6,.5]},{key:`ember-gold`,label:`Ember gold`,rgb:[1,.8,.2]}],Dc=`luminous-violet`,Oc={size:2,bloomStrength:1.2,bloomRadius:.35,lifeMin:.5,lifeMax:1.4,fieldValidDistance:.05,speed:6,particleCount:5e3,colorPresetA:Dc,colorPresetB:Dc,noiseStrength:0,trailsEnabled:!1,trailDecay:.9};var kc=class{positions;colors;lifetimes;fieldData=null;fieldTransform={scale:1,offsetX:0,offsetY:0};grid=new Map;gridCellSize=.1;geometry;params;viewOffsetX;activePalette=null;noise;noiseScale=.9;noiseTimeScale=.15;constructor(e,t){this.geometry=e,this.params=t,this.viewOffsetX=0,this.positions=new Float32Array(t.particleCount*3),this.colors=new Float32Array(t.particleCount*3),this.lifetimes=new Float32Array(t.particleCount),this.noise=new wc,this.geometry.setAttribute(`position`,new Zn(this.positions,3)),this.geometry.setAttribute(`color`,new Zn(this.colors,3))}init(){for(let e=0;e<this.params.particleCount;e+=1)this.resetParticle(e);this.updateBuffers()}setFieldData(e,t){this.fieldData=e,this.fieldTransform=t,this.buildSpatialGrid()}hasFieldData(){return this.fieldData!==null&&this.fieldData.length>0}setViewOffset(e){if(this.viewOffsetX!==e){this.viewOffsetX=e;for(let e=0;e<this.params.particleCount;e+=1)this.resetParticle(e);this.updateBuffers()}}update(e,t){let n=performance.now()*.001;this.activePalette=t;let r=this.params.noiseStrength>0,i=.85*this.params.speed*e,a=.015*e;for(let o=0;o<this.params.particleCount;o+=1){let s=o*3,c=this.positions[s],l=this.positions[s+1],u=this.sampleField(c,l,n);if(!u){this.resetParticle(o);continue}r&&this.applyNoise(u,c,l,n),c+=u.x*i+this.randomRange(-a,a),l+=u.y*i+this.randomRange(-a,a);let d=Math.hypot(u.x,u.y),f=Math.min(1,d*2.6);this.applyColor(s,f,t,!1),this.lifetimes[o]-=e,this.shouldResetParticle(o,c,l)?this.resetParticle(o):(this.positions[s]=c,this.positions[s+1]=l)}this.updateBuffers()}resizeBuffers(e){this.params.particleCount=e,this.positions=new Float32Array(e*3),this.colors=new Float32Array(e*3),this.lifetimes=new Float32Array(e),this.geometry.setAttribute(`position`,new Zn(this.positions,3)),this.geometry.setAttribute(`color`,new Zn(this.colors,3)),this.init()}reseedLifetimes(){for(let e=0;e<this.params.particleCount;e+=1)this.lifetimes[e]=this.randomRange(this.params.lifeMin,this.params.lifeMax)}shouldResetParticle(e,t,n){return this.lifetimes[e]<=0||Math.abs(t-this.viewOffsetX)>1.25||Math.abs(n)>1.25}applyNoise(e,t,n,r){let i=this.noise.noise3d(t*this.noiseScale,n*this.noiseScale,r*this.noiseTimeScale),a=this.noise.noise3d((t+10)*this.noiseScale,(n+10)*this.noiseScale,r*this.noiseTimeScale);e.x+=i*this.params.noiseStrength,e.y+=a*this.params.noiseStrength}resetParticle(e){let t=e*3,n=this.activePalette??this.getActiveColorPreset();this.hasFieldData()?this.resetParticleWithinField(t):this.resetParticleRandomly(t),this.lifetimes[e]=this.randomRange(this.params.lifeMin,this.params.lifeMax);let r=.4+Math.random()*.2;this.applyColor(t,r,n,!0)}resetParticleWithinField(e){let t=this.fieldData[Math.floor(Math.random()*this.fieldData.length)],n=this.params.fieldValidDistance*.3,r=t.x+this.randomRange(-n,n)/this.fieldTransform.scale,i=t.y+this.randomRange(-n,n)/this.fieldTransform.scale;this.positions[e]=this.dataToWorldX(r),this.positions[e+1]=this.dataToWorldY(i),this.positions[e+2]=0}resetParticleRandomly(e){this.positions[e]=this.randomRange(-Tc,Tc)+this.viewOffsetX,this.positions[e+1]=this.randomRange(-Tc,Tc),this.positions[e+2]=0}dataToWorldX(e){return e*this.fieldTransform.scale+this.fieldTransform.offsetX+this.viewOffsetX}dataToWorldY(e){return e*this.fieldTransform.scale+this.fieldTransform.offsetY}worldToDataX(e){return(e-this.viewOffsetX-this.fieldTransform.offsetX)/this.fieldTransform.scale}worldToDataY(e){return(e-this.fieldTransform.offsetY)/this.fieldTransform.scale}buildSpatialGrid(){if(this.grid.clear(),!this.fieldData||this.fieldData.length===0)return;let e=this.fieldData[0].x,t=this.fieldData[0].x,n=this.fieldData[0].y,r=this.fieldData[0].y;for(let i of this.fieldData)i.x<e&&(e=i.x),i.x>t&&(t=i.x),i.y<n&&(n=i.y),i.y>r&&(r=i.y);let i=(t-e+(r-n))/2,a=Math.ceil(Math.sqrt(this.fieldData.length));this.gridCellSize=Math.max(.01,i/a);for(let e of this.fieldData){let t=this.getGridKey(e.x,e.y),n=this.grid.get(t);n?n.push(e):this.grid.set(t,[e])}}getGridKey(e,t){return`${Math.floor(e/this.gridCellSize)},${Math.floor(t/this.gridCellSize)}`}sampleField(e,t,n){if(!this.hasFieldData())return{x:1,y:0};let r=this.worldToDataX(e),i=this.worldToDataY(t),a=this.findNearestFieldPoint(r,i);return a?{x:a.dx*this.fieldTransform.scale,y:a.dy*this.fieldTransform.scale}:null}findNearestFieldPoint(e,t){let n=null,r=Number.MAX_VALUE,i=(this.params.fieldValidDistance/this.fieldTransform.scale)**2,a=Math.floor(e/this.gridCellSize),o=Math.floor(t/this.gridCellSize);for(let i=-1;i<=1;i+=1)for(let s=-1;s<=1;s+=1){let c=this.grid.get(`${a+i},${o+s}`);if(c)for(let i of c){let a=i.x-e,o=i.y-t,s=a*a+o*o;s<r&&(r=s,n=i)}}return r<=i?n:null}applyColor(e,t,n,r){let i=Math.min(1,Math.max(0,t));if(n.key===`luminous-violet`){r?(this.colors[e]=.6*i,this.colors[e+1]=.25*i,this.colors[e+2]=.9*i):(this.colors[e]=.35+i*.9,this.colors[e+1]=.18+i*.45,this.colors[e+2]=.6+i*.35);return}let a=r?i:.35+i*.65,[o,s,c]=n.rgb;this.colors[e]=o*a,this.colors[e+1]=s*a,this.colors[e+2]=c*a}getActiveColorPreset(){return Ec.find(e=>e.key===this.params.colorPresetA)??Ec[0]}randomRange(e,t){return e+Math.random()*(t-e)}updateBuffers(){this.geometry.attributes.position.needsUpdate=!0,this.geometry.attributes.color.needsUpdate=!0,this.geometry.computeBoundingSphere()}},Ac=class{fieldStatusEl=null;onFieldLoaded;constructor(e){this.onFieldLoaded=e}setStatusElement(e){this.fieldStatusEl=e}async load(){try{let e=await fetch(`/vector-field.json`,{cache:`no-store`});if(!e.ok){this.updateStatus(`default (built-in)`);return}let t=await e.json();if(Array.isArray(t)&&t.length>0){let{transform:e,bounds:n}=this.computeFieldTransform(t);this.onFieldLoaded(t,e),this.updateStatus(`loaded ${t.length} vectors (${n.width.toFixed(1)}×${n.height.toFixed(1)})`),console.log(`Field bounds:`,n,`scale:`,e.scale)}else this.updateStatus(`default (empty file)`)}catch(e){console.error(`Failed to load vector field`,e),this.updateStatus(`default (load error)`)}}computeFieldTransform(e){let t=e[0].x,n=e[0].x,r=e[0].y,i=e[0].y;for(let a of e)a.x<t&&(t=a.x),a.x>n&&(n=a.x),a.y<r&&(r=a.y),a.y>i&&(i=a.y);let a=n-t,o=i-r,s=Math.max(a,o);Tc*1.8;let c=s>0?2.25/s:1;return{transform:{scale:c,offsetX:-(t+n)*.5*c,offsetY:-(r+i)*.5*c},bounds:{minX:t,maxX:n,minY:r,maxY:i,width:a,height:o}}}updateStatus(e){this.fieldStatusEl&&(this.fieldStatusEl.textContent=e)}},jc=class{panel;controlHandles=new Map;selectHandles=new Map;trailToggle;container;params;material;bloomPass;callbacks;constructor(e,t,n,r,i){this.container=e,this.params=t,this.material=n,this.bloomPass=r,this.callbacks=i}create(){this.panel=document.createElement(`div`),this.panel.className=`controls`;let e=document.createElement(`div`);e.className=`controls__header`;let t=document.createElement(`div`);t.className=`controls__title`,t.textContent=`Controls`;let n=document.createElement(`button`);n.className=`controls__toggle`,n.textContent=`−`,n.type=`button`,n.addEventListener(`click`,()=>{this.panel.classList.toggle(`controls--collapsed`),n.textContent=this.panel.classList.contains(`controls--collapsed`)?`+`:`−`,window.dispatchEvent(new Event(`resize`))}),e.appendChild(t),e.appendChild(n),this.panel.appendChild(e);let r=this.addSelect(`Field A color`,Ec,this.params.colorPresetA,e=>{this.params.colorPresetA=e,this.callbacks.onColorChange()}),i=this.addSelect(`Field B color`,Ec,this.params.colorPresetB,e=>{this.params.colorPresetB=e,this.callbacks.onColorChange()});this.selectHandles.set(`colorA`,r),this.selectHandles.set(`colorB`,i);let a=this.addSlider(this.panel,`Speed`,.1,8,.1,this.params.speed,e=>{this.params.speed=e});this.controlHandles.set(`speed`,a);let o=document.createElement(`button`);o.type=`button`,o.className=`controls__button`,o.textContent=`Show advanced`;let s=document.createElement(`div`);s.className=`controls__advanced`,s.style.display=`none`;let c=this.addSlider(s,`Noise`,0,1,.01,this.params.noiseStrength,e=>{this.params.noiseStrength=e});this.controlHandles.set(`noiseStrength`,c);let l=this.addSlider(s,`Size`,.5,4,.1,this.params.size,e=>{this.params.size=e,this.material.size=e});this.controlHandles.set(`size`,l);let u=this.addSlider(s,`Particle count`,100,8e3,100,this.params.particleCount,e=>{this.params.particleCount=Math.round(e),this.callbacks.onParticleCountChange(this.params.particleCount)});this.controlHandles.set(`particleCount`,u);let d=this.addSlider(s,`Bloom strength`,.2,2.5,.05,this.params.bloomStrength,e=>{this.params.bloomStrength=e,this.updateBloom()});this.controlHandles.set(`bloomStrength`,d);let f=this.addSlider(s,`Bloom radius`,0,1.2,.02,this.params.bloomRadius,e=>{this.params.bloomRadius=e,this.updateBloom()});this.controlHandles.set(`bloomRadius`,f);let p=this.addSlider(s,`Life min (s)`,.1,2,.05,this.params.lifeMin,e=>{if(this.params.lifeMin=e,this.params.lifeMin>this.params.lifeMax){this.params.lifeMax=e;let t=this.controlHandles.get(`lifeMax`);t&&this.syncSlider(t,this.params.lifeMax)}this.callbacks.onLifetimeChange()});this.controlHandles.set(`lifeMin`,p);let m=this.addSlider(s,`Life max (s)`,.2,5,.05,this.params.lifeMax,e=>{if(this.params.lifeMax=e,this.params.lifeMax<this.params.lifeMin){this.params.lifeMin=e;let t=this.controlHandles.get(`lifeMin`);t&&this.syncSlider(t,this.params.lifeMin)}this.callbacks.onLifetimeChange()});this.controlHandles.set(`lifeMax`,m);let h=this.addSlider(s,`Field border`,.01,.1,.01,this.params.fieldValidDistance,e=>{this.params.fieldValidDistance=e});this.controlHandles.set(`fieldDist`,h),this.trailToggle=this.addToggle(s,`Trails`,this.params.trailsEnabled,e=>{this.params.trailsEnabled=e,this.callbacks.onTrailToggle(e)});let g=this.addSlider(s,`Trail decay`,.7,.99,.005,this.params.trailDecay,e=>{this.params.trailDecay=e,this.callbacks.onTrailDecayChange(e)});this.controlHandles.set(`trailDecay`,g),o.addEventListener(`click`,()=>{let e=s.style.display===`none`;s.style.display=e?`block`:`none`,o.textContent=e?`Hide advanced`:`Show advanced`}),this.panel.appendChild(o),this.panel.appendChild(s);let _=document.createElement(`button`);_.type=`button`,_.className=`controls__button`,_.textContent=`Reset to defaults`,_.addEventListener(`click`,()=>this.reset()),this.panel.appendChild(_);let v=document.createElement(`div`);v.className=`controls__section`;let y=document.createElement(`div`);y.className=`controls__subtitle`,y.textContent=`Fields`,v.appendChild(y);let b=document.createElement(`div`);b.className=`controls__row`;let x=document.createElement(`span`);x.textContent=`Clear Field A`;let S=document.createElement(`button`);S.type=`button`,S.className=`controls__button`,S.textContent=`Clear`,S.addEventListener(`click`,()=>this.callbacks.onClearFieldA()),b.appendChild(x),b.appendChild(S),v.appendChild(b);let C=document.createElement(`div`);C.className=`controls__row`;let w=document.createElement(`span`);w.textContent=`Clear Field B`;let T=document.createElement(`button`);T.type=`button`,T.className=`controls__button`,T.textContent=`Clear`,T.addEventListener(`click`,()=>this.callbacks.onClearFieldB()),C.appendChild(w),C.appendChild(T),v.appendChild(C),this.panel.appendChild(v),this.container.appendChild(this.panel)}syncFieldValidDistance(e){this.params.fieldValidDistance=e;let t=this.controlHandles.get(`fieldDist`);t&&this.syncSlider(t,e)}reset(){Object.assign(this.params,Oc),this.material.size=this.params.size,this.updateBloom(),this.callbacks.onParticleCountChange(this.params.particleCount),this.callbacks.onLifetimeChange(),this.callbacks.onTrailToggle(this.params.trailsEnabled),this.callbacks.onTrailDecayChange(this.params.trailDecay),this.callbacks.onColorChange();for(let[e,t]of this.controlHandles.entries()){let n=e;typeof this.params[n]==`number`&&this.syncSlider(t,this.params[n])}for(let[e,t]of this.selectHandles.entries())e===`colorA`&&(t.value=this.params.colorPresetA),e===`colorB`&&(t.value=this.params.colorPresetB);this.trailToggle&&(this.trailToggle.checked=this.params.trailsEnabled)}addToggle(e,t,n,r){let i=document.createElement(`label`);i.className=`controls__row`;let a=document.createElement(`span`);a.textContent=t;let o=document.createElement(`input`);return o.type=`checkbox`,o.checked=n,o.addEventListener(`change`,e=>{let t=e.target.checked;r(t)}),i.appendChild(a),i.appendChild(o),e.appendChild(i),o}addSlider(e,t,n,r,i,a,o){let s=document.createElement(`label`);s.className=`controls__row`;let c=document.createElement(`span`);c.textContent=t;let l=document.createElement(`input`);l.type=`range`,l.min=String(n),l.max=String(r),l.step=String(i),l.value=String(a);let u=document.createElement(`span`);return u.className=`controls__value`,u.textContent=this.formatValue(a,i),l.addEventListener(`input`,e=>{let t=parseFloat(e.target.value);u.textContent=this.formatValue(t,i),o(t)}),s.appendChild(c),s.appendChild(l),s.appendChild(u),e.appendChild(s),{input:l,valueTag:u}}addSelect(e,t,n,r){let i=document.createElement(`label`);i.className=`controls__row`;let a=document.createElement(`span`);a.textContent=e;let o=document.createElement(`select`);o.className=`controls__select`;for(let e of t){let t=document.createElement(`option`);t.value=e.key,t.textContent=e.label,o.appendChild(t)}return o.value=n,o.addEventListener(`change`,e=>{let t=e.target.value;r(t)}),i.appendChild(a),i.appendChild(o),this.panel.appendChild(i),o}updateBloom(){this.bloomPass.strength=this.params.bloomStrength,this.bloomPass.radius=this.params.bloomRadius}formatValue(e,t){return t>=1?e.toFixed(0):e.toFixed(2)}syncSlider(e,t){e.input.value=String(t),e.valueTag.textContent=this.formatValue(t,parseFloat(e.input.step))}},Mc=class{mediaRecorder=null;recordedChunks=[];isRecording=!1;recordingStartTime=0;recordingDuration=5;recordingResolution=`current`;originalCanvasSize=null;recordButton=null;recordStatus=null;renderer;composer;bloomPass;onResize;constructor(e,t,n,r){this.renderer=e,this.composer=t,this.bloomPass=n,this.onResize=r}createControls(e){let t=document.createElement(`div`);t.className=`controls__section`,t.innerHTML=`<div class="controls__subtitle">Export (WebM)</div>`;let n=document.createElement(`label`);n.className=`controls__row`;let r=document.createElement(`span`);r.textContent=`Resolution`;let i=document.createElement(`select`);i.className=`controls__select`,i.innerHTML=`<option value="current">Current window</option><option value="1080p">1080p (Full HD)</option><option value="1440p">1440p (2K)</option><option value="4k">4K (Ultra HD)</option>`,i.value=this.recordingResolution,i.addEventListener(`change`,e=>{this.recordingResolution=e.target.value}),n.appendChild(r),n.appendChild(i),t.appendChild(n);let a=document.createElement(`label`);a.className=`controls__row`;let o=document.createElement(`span`);o.textContent=`Duration`;let s=document.createElement(`select`);s.className=`controls__select`,s.innerHTML=`<option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option><option value="15">15 seconds</option>`,s.value=String(this.recordingDuration),s.addEventListener(`change`,e=>{this.recordingDuration=parseInt(e.target.value)}),a.appendChild(o),a.appendChild(s),t.appendChild(a),this.recordButton=document.createElement(`button`),this.recordButton.type=`button`,this.recordButton.className=`controls__button controls__button--record`,this.recordButton.textContent=`⏺ Start recording`,this.recordButton.addEventListener(`click`,()=>{this.isRecording?this.stop():this.start()}),t.appendChild(this.recordButton),this.recordStatus=document.createElement(`div`),this.recordStatus.className=`controls__status`,this.recordStatus.style.display=`none`,t.appendChild(this.recordStatus),e.appendChild(t)}update(){if(this.isRecording){let e=(performance.now()-this.recordingStartTime)/1e3;this.updateStatus(e),e>=this.recordingDuration&&this.stop()}}start(){if(!this.isRecording)try{let e=this.renderer.domElement,t,n,r=e.width/e.height;if(this.recordingResolution===`current`)t=e.width,n=e.height;else{switch(this.originalCanvasSize={width:e.width,height:e.height},this.recordingResolution){case`1080p`:n=1080,t=Math.round(n*r);break;case`1440p`:n=1440,t=Math.round(n*r);break;case`4k`:n=2160,t=Math.round(n*r);break}this.renderer.setSize(t,n,!1),this.composer.setSize(t,n),this.bloomPass.setSize(t,n)}let i=e.captureStream(60),a=t*n,o=Math.min(25e6,Math.max(8e6,a*4));this.recordedChunks=[],this.mediaRecorder=new MediaRecorder(i,{mimeType:`video/webm;codecs=vp9`,videoBitsPerSecond:o}),this.mediaRecorder.ondataavailable=e=>{e.data.size>0&&this.recordedChunks.push(e.data)},this.mediaRecorder.onstop=()=>{this.originalCanvasSize&&(this.renderer.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height,!1),this.composer.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height),this.bloomPass.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height),this.originalCanvasSize=null,this.onResize());let e=new Blob(this.recordedChunks,{type:`video/webm`}),r=URL.createObjectURL(e),i=document.createElement(`a`);i.href=r,i.download=`luminar-${t}x${n}-${Date.now()}.webm`,i.click(),URL.revokeObjectURL(r),this.recordStatus&&(this.recordStatus.textContent=`Recording complete! Download started.`,setTimeout(()=>{this.recordStatus&&(this.recordStatus.style.display=`none`)},3e3))},this.mediaRecorder.start(),this.isRecording=!0,this.recordingStartTime=performance.now(),this.recordButton&&(this.recordButton.textContent=`⏹ Stop recording`,this.recordButton.style.opacity=`1`),this.recordStatus&&(this.recordStatus.style.display=`block`,this.recordStatus.textContent=`Recording at ${t}x${n} (${(o/1e6).toFixed(0)} Mbps): 0.0s / ${this.recordingDuration}s`)}catch(e){console.error(`Failed to start recording:`,e),this.originalCanvasSize&&(this.renderer.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height,!1),this.composer.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height),this.bloomPass.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height),this.originalCanvasSize=null,this.onResize()),this.recordStatus&&(this.recordStatus.style.display=`block`,this.recordStatus.textContent=`Recording not supported in this browser.`)}}stop(){!this.isRecording||!this.mediaRecorder||(this.isRecording=!1,this.mediaRecorder.stop(),this.mediaRecorder=null,this.recordButton&&(this.recordButton.textContent=`▶ Start recording`,this.recordButton.style.opacity=`1`))}updateStatus(e){if(this.recordStatus){let t=e.toFixed(1),n=this.renderer.domElement,r=Math.min(25e6,Math.max(8e6,n.width*n.height*4));this.recordStatus.textContent=`Recording at ${n.width}x${n.height} (${(r/1e6).toFixed(0)} Mbps): ${t}s / ${this.recordingDuration}s`}}};function Nc(e){let t=e.split(/\r?\n/).filter(Boolean),n=[],r=!1;for(let e of t){let t=e.split(/[,\s]+/).filter(Boolean);if(t.length<4)continue;let[i,a,o,s]=t.map(Number);if([i,a,o,s].some(e=>Number.isNaN(e))){!r&&n.length===0&&(r=!0,console.log(`skipping header line:`,e.substring(0,60)));continue}n.push({x:i,y:a,dx:o,dy:s})}return n}var Pc=document.querySelector(`#app`);if(!Pc)throw Error(`Missing #app container`);var Fc={...Oc},Ic=1.8,Lc=new lc({antialias:!1,alpha:!0});Lc.setPixelRatio(Math.min(window.devicePixelRatio,2)),Pc.appendChild(Lc.domElement);var Rc=new Gr;Rc.background=new Z(132106);var zc=new Ai(-1,1,1,-1,.1,10);zc.position.z=2;var Bc=new vc(Lc),Vc=new yc(Rc,zc),Hc=new xc(new q(1,1),Fc.bloomStrength,.82,Fc.bloomRadius),Uc=new Cc(Fc.trailDecay);Bc.addPass(Vc),Bc.addPass(Hc),Bc.addPass(Uc),Uc.enabled=Fc.trailsEnabled,Uc.uniforms.damp.value=Fc.trailDecay;var Wc=new cr,Gc=new cr,Kc=new ti({size:Fc.size,sizeAttenuation:!0,vertexColors:!0,transparent:!0,opacity:.9,blending:2,depthWrite:!1}),qc=new oi(Wc,Kc),Jc=new oi(Gc,Kc);Rc.add(qc),Rc.add(Jc);var Yc=new kc(Wc,Fc),Xc=new kc(Gc,Fc);Yc.init(),Xc.init();var Zc=!1,Qc=!1,$c=new Ac((e,t)=>{Yc.setFieldData(e,t),Zc=Yc.hasFieldData(),ll()}),el=new Ac((e,t)=>{Xc.setFieldData(e,t),Qc=Xc.hasFieldData(),ll()}),tl=new jc(Pc,Fc,Kc,Hc,{onParticleCountChange:e=>{Yc.resizeBuffers(e),Xc.resizeBuffers(e)},onLifetimeChange:()=>{Yc.reseedLifetimes(),Xc.reseedLifetimes()},onTrailToggle:e=>ul(e,Fc.trailDecay),onTrailDecayChange:e=>ul(Fc.trailsEnabled,e),onClearFieldA:()=>sl(`left`),onClearFieldB:()=>sl(`right`),onColorChange:()=>nl()});function nl(){fl=Ec.find(e=>e.key===Fc.colorPresetA)??Ec[0],pl=Ec.find(e=>e.key===Fc.colorPresetB)??Ec[0]}var rl=new Mc(Lc,Bc,Hc,cl);function il(){if(!Pc)return;let e=document.createElement(`div`);e.className=`hud`,e.innerHTML=`<div class="title">luminar</div><div class="subtitle">2D vector field bloom study</div><div class="status">Field A: <span id="field-status-a">default (built-in)</span> · Field B: <span id="field-status-b">default (built-in)</span></div>`,Pc.appendChild(e),$c.setStatusElement(document.getElementById(`field-status-a`)),el.setStatusElement(document.getElementById(`field-status-b`))}function al(){if(!Pc)return;let e=document.createElement(`div`);e.className=`drop-overlay drop-overlay--left`,e.textContent=`Drop to load Field A (left)`,e.style.display=`none`,Pc.appendChild(e);let t=document.createElement(`div`);t.className=`drop-overlay drop-overlay--right`,t.textContent=`Drop to load Field B (right)`,t.style.display=`none`,Pc.appendChild(t);let n=n=>{n===`left`?(e.style.display=`flex`,t.style.display=`none`):(e.style.display=`none`,t.style.display=`flex`)},r=()=>{e.style.display=`none`,t.style.display=`none`},i=async(e,t)=>{try{let n=Nc(await e.text());if(!n.length){t===`left`?$c.updateStatus(`CSV empty or invalid`):el.updateStatus(`CSV empty or invalid`),r();return}let i=t===`left`?$c:el,{transform:a,bounds:o}=i.computeFieldTransform(n);t===`left`?(Yc.setFieldData(n,a),Zc=Yc.hasFieldData()):(Xc.setFieldData(n,a),Qc=Xc.hasFieldData()),i.updateStatus(`loaded ${n.length} vectors (${o.width.toFixed(1)}×${o.height.toFixed(1)})`),ll()}catch(e){console.error(`Failed to load dropped CSV`,e),t===`left`?$c.updateStatus(`CSV load error`):el.updateStatus(`CSV load error`)}finally{r()}};window.addEventListener(`dragover`,e=>{e.preventDefault(),n(e.clientX<window.innerWidth*.5?`left`:`right`)}),window.addEventListener(`dragleave`,e=>{e.preventDefault(),r()}),window.addEventListener(`drop`,e=>{e.preventDefault();let t=e.dataTransfer;if(t&&t.files&&t.files[0]){let n=e.clientX<window.innerWidth*.5?`left`:`right`;i(t.files[0],n)}})}function ol(){let e=Ic*(window.innerWidth/window.innerHeight)*2,t=e*.45/2,n=(e-258/window.innerWidth*e-.5)/2,r=Math.min(1.4,n);return Math.max(1,Math.min(t,r))}function sl(e){let t={scale:1,offsetX:0,offsetY:0};e===`left`?(Yc.setFieldData(null,t),Yc.reseedLifetimes(),$c.updateStatus(`default (cleared)`),Zc=!1):(Xc.setFieldData(null,t),Xc.reseedLifetimes(),el.updateStatus(`default (cleared)`),Qc=!1),ll()}function cl(){let e=window.innerWidth,t=window.innerHeight,n=e/t;zc.left=-Ic*n,zc.right=Ic*n,zc.top=Ic,zc.bottom=-Ic,zc.updateProjectionMatrix(),Lc.setSize(e,t,!1),Bc.setSize(e,t),Hc.setSize(e,t),ll()}function ll(){let e=(Zc?1:0)+(Qc?1:0),t=ol();e===2?(qc.visible=!0,Jc.visible=!0,Yc.setViewOffset(-t),Xc.setViewOffset(t)):e===1?Zc?(qc.visible=!0,Jc.visible=!1,Yc.setViewOffset(0)):(qc.visible=!1,Jc.visible=!0,Xc.setViewOffset(0)):(qc.visible=!0,Jc.visible=!1,Yc.setViewOffset(0),Xc.setViewOffset(0))}function ul(e,t){Fc.trailsEnabled=e,Fc.trailDecay=t,Uc.enabled=e,Uc.uniforms.damp.value=t}var dl=performance.now(),fl=Ec.find(e=>e.key===Fc.colorPresetA)??Ec[0],pl=Ec.find(e=>e.key===Fc.colorPresetB)??Ec[0];function ml(e){let t=e||performance.now(),n=Math.min(.033,(t-dl)/1e3);dl=t,Yc.update(n,fl),Xc.update(n,pl),Bc.render(),rl.update(),requestAnimationFrame(ml)}il(),tl.create(),rl.createControls(Pc.querySelector(`.controls`)),cl(),window.addEventListener(`resize`,cl),$c.load(),el.load(),al(),ml(0);
|
|
4181
|
+
}`},Cc=class extends dc{constructor(e=.96){super(),this.uniforms=Or.clone(Sc.uniforms),this.damp=e,this.compFsMaterial=new jr({uniforms:this.uniforms,vertexShader:Sc.vertexShader,fragmentShader:Sc.fragmentShader}),this.copyFsMaterial=new jr({uniforms:Or.clone(uc.uniforms),vertexShader:uc.vertexShader,fragmentShader:uc.fragmentShader,blending:0,depthTest:!1,depthWrite:!1}),this._textureComp=new xt(window.innerWidth,window.innerHeight,{magFilter:r,type:g}),this._textureOld=new xt(window.innerWidth,window.innerHeight,{magFilter:r,type:g}),this._compFsQuad=new mc(this.compFsMaterial),this._copyFsQuad=new mc(this.copyFsMaterial)}get damp(){return this.uniforms.damp.value}set damp(e){this.uniforms.damp.value=e}render(e,t,n){this.uniforms.tOld.value=this._textureOld.texture,this.uniforms.tNew.value=n.texture,e.setRenderTarget(this._textureComp),this._compFsQuad.render(e),this._copyFsQuad.material.uniforms.tDiffuse.value=this._textureComp.texture,this.renderToScreen?(e.setRenderTarget(null),this._copyFsQuad.render(e)):(e.setRenderTarget(t),this.clear&&e.clear(),this._copyFsQuad.render(e));let r=this._textureOld;this._textureOld=this._textureComp,this._textureComp=r}setSize(e,t){this._textureComp.setSize(e,t),this._textureOld.setSize(e,t)}dispose(){this._textureComp.dispose(),this._textureOld.dispose(),this.compFsMaterial.dispose(),this.copyFsMaterial.dispose(),this._compFsQuad.dispose(),this._copyFsQuad.dispose()}},wc=class{constructor(e=Math){this.grad3=[[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]],this.grad4=[[0,1,1,1],[0,1,1,-1],[0,1,-1,1],[0,1,-1,-1],[0,-1,1,1],[0,-1,1,-1],[0,-1,-1,1],[0,-1,-1,-1],[1,0,1,1],[1,0,1,-1],[1,0,-1,1],[1,0,-1,-1],[-1,0,1,1],[-1,0,1,-1],[-1,0,-1,1],[-1,0,-1,-1],[1,1,0,1],[1,1,0,-1],[1,-1,0,1],[1,-1,0,-1],[-1,1,0,1],[-1,1,0,-1],[-1,-1,0,1],[-1,-1,0,-1],[1,1,1,0],[1,1,-1,0],[1,-1,1,0],[1,-1,-1,0],[-1,1,1,0],[-1,1,-1,0],[-1,-1,1,0],[-1,-1,-1,0]],this.p=[];for(let t=0;t<256;t++)this.p[t]=Math.floor(e.random()*256);this.perm=[];for(let e=0;e<512;e++)this.perm[e]=this.p[e&255];this.simplex=[[0,1,2,3],[0,1,3,2],[0,0,0,0],[0,2,3,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,3,0],[0,2,1,3],[0,0,0,0],[0,3,1,2],[0,3,2,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,3,2,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,0,3],[0,0,0,0],[1,3,0,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,3,0,1],[2,3,1,0],[1,0,2,3],[1,0,3,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,3,1],[0,0,0,0],[2,1,3,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,1,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,0,1,2],[3,0,2,1],[0,0,0,0],[3,1,2,0],[2,1,0,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,1,0,2],[0,0,0,0],[3,2,0,1],[3,2,1,0]]}noise(e,t){let n,r,i,a=.5*(Math.sqrt(3)-1),o=(e+t)*a,s=Math.floor(e+o),c=Math.floor(t+o),l=(3-Math.sqrt(3))/6,u=(s+c)*l,d=s-u,f=c-u,p=e-d,m=t-f,h,g;p>m?(h=1,g=0):(h=0,g=1);let _=p-h+l,v=m-g+l,y=p-1+2*l,b=m-1+2*l,x=s&255,S=c&255,C=this.perm[x+this.perm[S]]%12,w=this.perm[x+h+this.perm[S+g]]%12,T=this.perm[x+1+this.perm[S+1]]%12,E=.5-p*p-m*m;E<0?n=0:(E*=E,n=E*E*this._dot(this.grad3[C],p,m));let D=.5-_*_-v*v;D<0?r=0:(D*=D,r=D*D*this._dot(this.grad3[w],_,v));let ee=.5-y*y-b*b;return ee<0?i=0:(ee*=ee,i=ee*ee*this._dot(this.grad3[T],y,b)),70*(n+r+i)}noise3d(e,t,n){let r,i,a,o,s=(e+t+n)*(1/3),c=Math.floor(e+s),l=Math.floor(t+s),u=Math.floor(n+s),d=1/6,f=(c+l+u)*d,p=c-f,m=l-f,h=u-f,g=e-p,_=t-m,v=n-h,y,b,x,S,C,w;g>=_?_>=v?(y=1,b=0,x=0,S=1,C=1,w=0):g>=v?(y=1,b=0,x=0,S=1,C=0,w=1):(y=0,b=0,x=1,S=1,C=0,w=1):_<v?(y=0,b=0,x=1,S=0,C=1,w=1):g<v?(y=0,b=1,x=0,S=0,C=1,w=1):(y=0,b=1,x=0,S=1,C=1,w=0);let T=g-y+d,E=_-b+d,D=v-x+d,ee=g-S+2*d,O=_-C+2*d,k=v-w+2*d,te=g-1+3*d,ne=_-1+3*d,A=v-1+3*d,j=c&255,M=l&255,N=u&255,re=this.perm[j+this.perm[M+this.perm[N]]]%12,ie=this.perm[j+y+this.perm[M+b+this.perm[N+x]]]%12,P=this.perm[j+S+this.perm[M+C+this.perm[N+w]]]%12,ae=this.perm[j+1+this.perm[M+1+this.perm[N+1]]]%12,oe=.6-g*g-_*_-v*v;oe<0?r=0:(oe*=oe,r=oe*oe*this._dot3(this.grad3[re],g,_,v));let F=.6-T*T-E*E-D*D;F<0?i=0:(F*=F,i=F*F*this._dot3(this.grad3[ie],T,E,D));let I=.6-ee*ee-O*O-k*k;I<0?a=0:(I*=I,a=I*I*this._dot3(this.grad3[P],ee,O,k));let L=.6-te*te-ne*ne-A*A;return L<0?o=0:(L*=L,o=L*L*this._dot3(this.grad3[ae],te,ne,A)),32*(r+i+a+o)}noise4d(e,t,n,r){let i=this.grad4,a=this.simplex,o=this.perm,s=(Math.sqrt(5)-1)/4,c=(5-Math.sqrt(5))/20,l,u,d,f,p,m=(e+t+n+r)*s,h=Math.floor(e+m),g=Math.floor(t+m),_=Math.floor(n+m),v=Math.floor(r+m),y=(h+g+_+v)*c,b=h-y,x=g-y,S=_-y,C=v-y,w=e-b,T=t-x,E=n-S,D=r-C,ee=w>T?32:0,O=w>E?16:0,k=T>E?8:0,te=w>D?4:0,ne=T>D?2:0,A=E>D?1:0,j=ee+O+k+te+ne+A,M=a[j][0]>=3?1:0,N=a[j][1]>=3?1:0,re=a[j][2]>=3?1:0,ie=a[j][3]>=3?1:0,P=a[j][0]>=2?1:0,ae=a[j][1]>=2?1:0,oe=a[j][2]>=2?1:0,F=a[j][3]>=2?1:0,I=a[j][0]>=1?1:0,L=a[j][1]>=1?1:0,se=a[j][2]>=1?1:0,ce=a[j][3]>=1?1:0,le=w-M+c,ue=T-N+c,R=E-re+c,de=D-ie+c,fe=w-P+2*c,pe=T-ae+2*c,me=E-oe+2*c,he=D-F+2*c,ge=w-I+3*c,_e=T-L+3*c,ve=E-se+3*c,ye=D-ce+3*c,be=w-1+4*c,xe=T-1+4*c,Se=E-1+4*c,Ce=D-1+4*c,we=h&255,z=g&255,Te=_&255,B=v&255,Ee=o[we+o[z+o[Te+o[B]]]]%32,V=o[we+M+o[z+N+o[Te+re+o[B+ie]]]]%32,De=o[we+P+o[z+ae+o[Te+oe+o[B+F]]]]%32,H=o[we+I+o[z+L+o[Te+se+o[B+ce]]]]%32,U=o[we+1+o[z+1+o[Te+1+o[B+1]]]]%32,Oe=.6-w*w-T*T-E*E-D*D;Oe<0?l=0:(Oe*=Oe,l=Oe*Oe*this._dot4(i[Ee],w,T,E,D));let ke=.6-le*le-ue*ue-R*R-de*de;ke<0?u=0:(ke*=ke,u=ke*ke*this._dot4(i[V],le,ue,R,de));let Ae=.6-fe*fe-pe*pe-me*me-he*he;Ae<0?d=0:(Ae*=Ae,d=Ae*Ae*this._dot4(i[De],fe,pe,me,he));let je=.6-ge*ge-_e*_e-ve*ve-ye*ye;je<0?f=0:(je*=je,f=je*je*this._dot4(i[H],ge,_e,ve,ye));let Me=.6-be*be-xe*xe-Se*Se-Ce*Ce;return Me<0?p=0:(Me*=Me,p=Me*Me*this._dot4(i[U],be,xe,Se,Ce)),27*(l+u+d+f+p)}_dot(e,t,n){return e[0]*t+e[1]*n}_dot3(e,t,n,r){return e[0]*t+e[1]*n+e[2]*r}_dot4(e,t,n,r,i){return e[0]*t+e[1]*n+e[2]*r+e[3]*i}};const Tc=1.25,Ec=[{key:`luminous-violet`,label:`Luminous violet`,rgb:[.6,.25,.9]},{key:`pure-white`,label:`Pure white`,rgb:[1,1,1]},{key:`neon-cyan`,label:`Neon cyan`,rgb:[.25,.95,1]},{key:`electric-lime`,label:`Electric lime`,rgb:[.75,1,.25]},{key:`solar-flare`,label:`Solar flare`,rgb:[1,.55,.15]},{key:`aurora-mint`,label:`Aurora mint`,rgb:[.4,1,.85]},{key:`sunrise-coral`,label:`Sunrise coral`,rgb:[1,.6,.5]},{key:`ember-gold`,label:`Ember gold`,rgb:[1,.8,.2]}],Dc=`luminous-violet`,Oc={size:2,bloomStrength:1.2,bloomRadius:.35,lifeMin:.5,lifeMax:1.4,fieldValidDistance:.05,speed:6,particleCount:5e3,colorPresetA:Dc,colorPresetB:Dc,noiseStrength:0,trailsEnabled:!1,trailDecay:.9};var kc=class{positions;colors;lifetimes;fieldData=null;fieldTransform={scale:1,offsetX:0,offsetY:0};grid=new Map;gridCellSize=.1;geometry;params;paletteKey;viewOffsetX;activePalette=null;noise;noiseScale=.9;noiseTimeScale=.15;constructor(e,t,n){this.geometry=e,this.params=t,this.paletteKey=n,this.viewOffsetX=0,this.positions=new Float32Array(t.particleCount*3),this.colors=new Float32Array(t.particleCount*3),this.lifetimes=new Float32Array(t.particleCount),this.noise=new wc,this.geometry.setAttribute(`position`,new Zn(this.positions,3)),this.geometry.setAttribute(`color`,new Zn(this.colors,3))}init(){for(let e=0;e<this.params.particleCount;e+=1)this.resetParticle(e);this.updateBuffers()}setFieldData(e,t){this.fieldData=e,this.fieldTransform=t,this.buildSpatialGrid()}hasFieldData(){return this.fieldData!==null&&this.fieldData.length>0}setViewOffset(e){if(this.viewOffsetX!==e){this.viewOffsetX=e;for(let e=0;e<this.params.particleCount;e+=1)this.resetParticle(e);this.updateBuffers()}}update(e,t){let n=performance.now()*.001;this.activePalette=t;let r=this.params.noiseStrength>0,i=.85*this.params.speed*e,a=.015*e;for(let o=0;o<this.params.particleCount;o+=1){let s=o*3,c=this.positions[s],l=this.positions[s+1],u=this.sampleField(c,l,n);if(!u){this.resetParticle(o);continue}r&&this.applyNoise(u,c,l,n),c+=u.x*i+this.randomRange(-a,a),l+=u.y*i+this.randomRange(-a,a);let d=Math.hypot(u.x,u.y),f=Math.min(1,d*2.6);this.applyColor(s,f,t,!1),this.lifetimes[o]-=e,this.shouldResetParticle(o,c,l)?this.resetParticle(o):(this.positions[s]=c,this.positions[s+1]=l)}this.updateBuffers()}resizeBuffers(e){this.params.particleCount=e,this.positions=new Float32Array(e*3),this.colors=new Float32Array(e*3),this.lifetimes=new Float32Array(e),this.geometry.setAttribute(`position`,new Zn(this.positions,3)),this.geometry.setAttribute(`color`,new Zn(this.colors,3)),this.init()}reseedLifetimes(){for(let e=0;e<this.params.particleCount;e+=1)this.lifetimes[e]=this.randomRange(this.params.lifeMin,this.params.lifeMax)}shouldResetParticle(e,t,n){return this.lifetimes[e]<=0||Math.abs(t-this.viewOffsetX)>1.25||Math.abs(n)>1.25}applyNoise(e,t,n,r){let i=this.noise.noise3d(t*this.noiseScale,n*this.noiseScale,r*this.noiseTimeScale),a=this.noise.noise3d((t+10)*this.noiseScale,(n+10)*this.noiseScale,r*this.noiseTimeScale);e.x+=i*this.params.noiseStrength,e.y+=a*this.params.noiseStrength}resetParticle(e){let t=e*3,n=this.activePalette??this.getActiveColorPreset();this.hasFieldData()?this.resetParticleWithinField(t):this.resetParticleRandomly(t),this.lifetimes[e]=this.randomRange(this.params.lifeMin,this.params.lifeMax);let r=.4+Math.random()*.2;this.applyColor(t,r,n,!0)}resetParticleWithinField(e){let t=this.fieldData[Math.floor(Math.random()*this.fieldData.length)],n=this.params.fieldValidDistance*.3,r=t.x+this.randomRange(-n,n)/this.fieldTransform.scale,i=t.y+this.randomRange(-n,n)/this.fieldTransform.scale;this.positions[e]=this.dataToWorldX(r),this.positions[e+1]=this.dataToWorldY(i),this.positions[e+2]=0}resetParticleRandomly(e){this.positions[e]=this.randomRange(-Tc,Tc)+this.viewOffsetX,this.positions[e+1]=this.randomRange(-Tc,Tc),this.positions[e+2]=0}dataToWorldX(e){return e*this.fieldTransform.scale+this.fieldTransform.offsetX+this.viewOffsetX}dataToWorldY(e){return e*this.fieldTransform.scale+this.fieldTransform.offsetY}worldToDataX(e){return(e-this.viewOffsetX-this.fieldTransform.offsetX)/this.fieldTransform.scale}worldToDataY(e){return(e-this.fieldTransform.offsetY)/this.fieldTransform.scale}buildSpatialGrid(){if(this.grid.clear(),!this.fieldData||this.fieldData.length===0)return;let e=this.fieldData[0].x,t=this.fieldData[0].x,n=this.fieldData[0].y,r=this.fieldData[0].y;for(let i of this.fieldData)i.x<e&&(e=i.x),i.x>t&&(t=i.x),i.y<n&&(n=i.y),i.y>r&&(r=i.y);let i=(t-e+(r-n))/2,a=Math.ceil(Math.sqrt(this.fieldData.length));this.gridCellSize=Math.max(.01,i/a);for(let e of this.fieldData){let t=this.getGridKey(e.x,e.y),n=this.grid.get(t);n?n.push(e):this.grid.set(t,[e])}}getGridKey(e,t){return`${Math.floor(e/this.gridCellSize)},${Math.floor(t/this.gridCellSize)}`}sampleField(e,t,n){if(!this.hasFieldData())return{x:1,y:0};let r=this.worldToDataX(e),i=this.worldToDataY(t),a=this.findNearestFieldPoint(r,i);return a?{x:a.dx*this.fieldTransform.scale,y:a.dy*this.fieldTransform.scale}:null}findNearestFieldPoint(e,t){let n=null,r=Number.MAX_VALUE,i=(this.params.fieldValidDistance/this.fieldTransform.scale)**2,a=Math.floor(e/this.gridCellSize),o=Math.floor(t/this.gridCellSize);for(let i=-1;i<=1;i+=1)for(let s=-1;s<=1;s+=1){let c=this.grid.get(`${a+i},${o+s}`);if(c)for(let i of c){let a=i.x-e,o=i.y-t,s=a*a+o*o;s<r&&(r=s,n=i)}}return r<=i?n:null}applyColor(e,t,n,r){let i=Math.min(1,Math.max(0,t));if(n.key===`luminous-violet`){r?(this.colors[e]=.6*i,this.colors[e+1]=.25*i,this.colors[e+2]=.9*i):(this.colors[e]=.35+i*.9,this.colors[e+1]=.18+i*.45,this.colors[e+2]=.6+i*.35);return}let a=r?i:.35+i*.65,[o,s,c]=n.rgb;this.colors[e]=o*a,this.colors[e+1]=s*a,this.colors[e+2]=c*a}getActiveColorPreset(){let e=this.paletteKey();return Ec.find(t=>t.key===e)??Ec[0]}randomRange(e,t){return e+Math.random()*(t-e)}updateBuffers(){this.geometry.attributes.position.needsUpdate=!0,this.geometry.attributes.color.needsUpdate=!0,this.geometry.computeBoundingSphere()}},Ac=class{fieldStatusEl=null;onFieldLoaded;constructor(e){this.onFieldLoaded=e}setStatusElement(e){this.fieldStatusEl=e}async load(){try{let e=await fetch(`/vector-field.json`,{cache:`no-store`});if(!e.ok){this.updateStatus(`default (built-in)`);return}let t=await e.json();if(Array.isArray(t)&&t.length>0){let{transform:e,bounds:n}=this.computeFieldTransform(t),r=`loaded ${t.length} vectors (${n.width.toFixed(1)}×${n.height.toFixed(1)})`;this.onFieldLoaded(t,e,r),this.updateStatus(r),console.log(`Field bounds:`,n,`scale:`,e.scale)}else this.updateStatus(`default (empty file)`)}catch(e){console.error(`Failed to load vector field`,e),this.updateStatus(`default (load error)`)}}computeFieldTransform(e){let t=e[0].x,n=e[0].x,r=e[0].y,i=e[0].y;for(let a of e)a.x<t&&(t=a.x),a.x>n&&(n=a.x),a.y<r&&(r=a.y),a.y>i&&(i=a.y);let a=n-t,o=i-r,s=Math.max(a,o);Tc*1.8;let c=s>0?2.25/s:1;return{transform:{scale:c,offsetX:-(t+n)*.5*c,offsetY:-(r+i)*.5*c},bounds:{minX:t,maxX:n,minY:r,maxY:i,width:a,height:o}}}updateStatus(e){this.fieldStatusEl&&(this.fieldStatusEl.textContent=e)}},jc=class{panel;content;controlHandles=new Map;selectHandles=new Map;trailToggle;fieldButtons={left:null,right:null};fieldStatuses={left:null,right:null};advancedSection;advancedToggle;container;params;material;bloomPass;callbacks;constructor(e,t,n,r,i){this.container=e,this.params=t,this.material=n,this.bloomPass=r,this.callbacks=i}create(){this.panel=document.createElement(`div`),this.panel.className=`controls`;let e=document.createElement(`div`);e.className=`controls__header`;let t=document.createElement(`div`);t.className=`controls__title`,t.textContent=`Controls`;let n=document.createElement(`button`);n.className=`controls__toggle`,n.textContent=`−`,n.type=`button`,n.addEventListener(`click`,()=>{this.panel.classList.toggle(`controls--collapsed`),n.textContent=this.panel.classList.contains(`controls--collapsed`)?`+`:`−`,window.dispatchEvent(new Event(`resize`))}),e.appendChild(t),e.appendChild(n),this.panel.appendChild(e),this.content=document.createElement(`div`),this.content.className=`controls__body`,this.panel.appendChild(this.content);let r=this.addSelect(`Field A color`,Ec,this.params.colorPresetA,e=>{this.params.colorPresetA=e,this.callbacks.onColorChange()}),i=this.addSelect(`Field B color`,Ec,this.params.colorPresetB,e=>{this.params.colorPresetB=e,this.callbacks.onColorChange()});this.selectHandles.set(`colorA`,r),this.selectHandles.set(`colorB`,i);let a=this.addSlider(this.content,`Speed`,.1,8,.1,this.params.speed,e=>{this.params.speed=e});this.controlHandles.set(`speed`,a);let o=document.createElement(`button`);o.type=`button`,o.className=`controls__button`,o.textContent=`Show advanced`,this.advancedToggle=o;let s=document.createElement(`div`);s.className=`controls__advanced`,s.style.display=`none`,this.advancedSection=s;let c=this.addSlider(s,`Noise`,0,1,.01,this.params.noiseStrength,e=>{this.params.noiseStrength=e});this.controlHandles.set(`noiseStrength`,c);let l=this.addSlider(s,`Size`,.5,4,.1,this.params.size,e=>{this.params.size=e,this.material.size=e});this.controlHandles.set(`size`,l);let u=this.addSlider(s,`Particle count`,100,8e3,100,this.params.particleCount,e=>{this.params.particleCount=Math.round(e),this.callbacks.onParticleCountChange(this.params.particleCount)});this.controlHandles.set(`particleCount`,u);let d=this.addSlider(s,`Bloom strength`,.2,2.5,.05,this.params.bloomStrength,e=>{this.params.bloomStrength=e,this.updateBloom()});this.controlHandles.set(`bloomStrength`,d);let f=this.addSlider(s,`Bloom radius`,0,1.2,.02,this.params.bloomRadius,e=>{this.params.bloomRadius=e,this.updateBloom()});this.controlHandles.set(`bloomRadius`,f);let p=this.addSlider(s,`Life min (s)`,.1,2,.05,this.params.lifeMin,e=>{if(this.params.lifeMin=e,this.params.lifeMin>this.params.lifeMax){this.params.lifeMax=e;let t=this.controlHandles.get(`lifeMax`);t&&this.syncSlider(t,this.params.lifeMax)}this.callbacks.onLifetimeChange()});this.controlHandles.set(`lifeMin`,p);let m=this.addSlider(s,`Life max (s)`,.2,5,.05,this.params.lifeMax,e=>{if(this.params.lifeMax=e,this.params.lifeMax<this.params.lifeMin){this.params.lifeMin=e;let t=this.controlHandles.get(`lifeMin`);t&&this.syncSlider(t,this.params.lifeMin)}this.callbacks.onLifetimeChange()});this.controlHandles.set(`lifeMax`,m);let h=this.addSlider(s,`Field border`,.01,.1,.01,this.params.fieldValidDistance,e=>{this.params.fieldValidDistance=e});this.controlHandles.set(`fieldDist`,h),this.trailToggle=this.addToggle(s,`Trails`,this.params.trailsEnabled,e=>{this.params.trailsEnabled=e,this.callbacks.onTrailToggle(e)});let g=this.addSlider(s,`Trail decay`,.7,.99,.005,this.params.trailDecay,e=>{this.params.trailDecay=e,this.callbacks.onTrailDecayChange(e)});this.controlHandles.set(`trailDecay`,g),o.addEventListener(`click`,()=>{let e=s.style.display===`none`;s.style.display=e?`block`:`none`,o.textContent=e?`Hide advanced`:`Show advanced`}),this.content.appendChild(o),this.content.appendChild(s);let _=document.createElement(`button`);_.type=`button`,_.className=`controls__button`,_.textContent=`Reset to defaults`,_.addEventListener(`click`,()=>this.reset()),this.content.appendChild(_);let v=document.createElement(`div`);v.className=`controls__section`;let y=document.createElement(`div`);y.className=`controls__subtitle`,y.textContent=`Fields`,v.appendChild(y);let b=this.buildFieldRow(`Field A`,`left`,()=>this.callbacks.onClearFieldA());v.appendChild(b);let x=this.buildFieldRow(`Field B`,`right`,()=>this.callbacks.onClearFieldB());v.appendChild(x),this.content.appendChild(v),this.container.appendChild(this.panel)}setFieldState(e,t){let n=this.fieldButtons[e],r=this.fieldStatuses[e];!n||!r||(n.textContent=t.loaded?`Clear`:`Empty`,n.disabled=!t.loaded,n.classList.toggle(`controls__button--empty`,!t.loaded),r.textContent=t.label)}syncFieldValidDistance(e){this.params.fieldValidDistance=e;let t=this.controlHandles.get(`fieldDist`);t&&this.syncSlider(t,e)}reset(){Object.assign(this.params,Oc),this.material.size=this.params.size,this.updateBloom(),this.callbacks.onParticleCountChange(this.params.particleCount),this.callbacks.onLifetimeChange(),this.callbacks.onTrailToggle(this.params.trailsEnabled),this.callbacks.onTrailDecayChange(this.params.trailDecay),this.callbacks.onColorChange();for(let[e,t]of this.controlHandles.entries()){let n=e;typeof this.params[n]==`number`&&this.syncSlider(t,this.params[n])}for(let[e,t]of this.selectHandles.entries())e===`colorA`&&(t.value=this.params.colorPresetA),e===`colorB`&&(t.value=this.params.colorPresetB);this.trailToggle&&(this.trailToggle.checked=this.params.trailsEnabled),this.advancedSection&&this.advancedToggle&&(this.advancedSection.style.display=`none`,this.advancedToggle.textContent=`Show advanced`)}addToggle(e,t,n,r){let i=document.createElement(`label`);i.className=`controls__row`;let a=document.createElement(`span`);a.textContent=t;let o=document.createElement(`input`);return o.type=`checkbox`,o.checked=n,o.addEventListener(`change`,e=>{let t=e.target.checked;r(t)}),i.appendChild(a),i.appendChild(o),e.appendChild(i),o}addSlider(e,t,n,r,i,a,o){let s=document.createElement(`label`);s.className=`controls__row`;let c=document.createElement(`span`);c.textContent=t;let l=document.createElement(`input`);l.type=`range`,l.min=String(n),l.max=String(r),l.step=String(i),l.value=String(a);let u=document.createElement(`span`);return u.className=`controls__value`,u.textContent=this.formatValue(a,i),l.addEventListener(`input`,e=>{let t=parseFloat(e.target.value);u.textContent=this.formatValue(t,i),o(t)}),s.appendChild(c),s.appendChild(l),s.appendChild(u),e.appendChild(s),{input:l,valueTag:u}}addSelect(e,t,n,r){let i=document.createElement(`label`);i.className=`controls__row`;let a=document.createElement(`span`);a.textContent=e;let o=document.createElement(`select`);o.className=`controls__select`;for(let e of t){let t=document.createElement(`option`);t.value=e.key,t.textContent=e.label,o.appendChild(t)}return o.value=n,o.addEventListener(`change`,e=>{let t=e.target.value;r(t)}),i.appendChild(a),i.appendChild(o),this.content.appendChild(i),o}buildFieldRow(e,t,n){let r=document.createElement(`div`);r.className=`controls__row controls__row--field`;let i=document.createElement(`div`);i.className=`controls__field-meta`;let a=document.createElement(`div`);a.className=`controls__field-label`,a.textContent=e;let o=document.createElement(`span`);o.className=`controls__field-status`,o.textContent=`Empty`,i.appendChild(a),i.appendChild(o);let s=document.createElement(`button`);return s.type=`button`,s.className=`controls__button controls__button--empty`,s.textContent=`Empty`,s.disabled=!0,s.addEventListener(`click`,()=>n()),this.fieldButtons[t]=s,this.fieldStatuses[t]=o,r.appendChild(i),r.appendChild(s),r}updateBloom(){this.bloomPass.strength=this.params.bloomStrength,this.bloomPass.radius=this.params.bloomRadius}formatValue(e,t){return t>=1?e.toFixed(0):e.toFixed(2)}syncSlider(e,t){e.input.value=String(t),e.valueTag.textContent=this.formatValue(t,parseFloat(e.input.step))}},Mc=class{mediaRecorder=null;recordedChunks=[];isRecording=!1;recordingStartTime=0;recordingDuration=5;recordingResolution=`current`;originalCanvasSize=null;recordButton=null;recordStatus=null;renderer;composer;bloomPass;onResize;setRenderSize(e,t){this.renderer.setSize(e,t,!1),this.composer.setSize(e,t),this.bloomPass.setSize(e,t)}restoreRenderSize(){if(!this.originalCanvasSize)return;let{width:e,height:t}=this.originalCanvasSize;this.setRenderSize(e,t),this.originalCanvasSize=null,this.onResize()}constructor(e,t,n,r){this.renderer=e,this.composer=t,this.bloomPass=n,this.onResize=r}createControls(e){let t=document.createElement(`div`);t.className=`controls__section`,t.innerHTML=`<div class="controls__subtitle">Export (WebM)</div>`;let n=document.createElement(`label`);n.className=`controls__row`;let r=document.createElement(`span`);r.textContent=`Resolution`;let i=document.createElement(`select`);i.className=`controls__select`,i.innerHTML=`<option value="current">Current window</option><option value="1080p">1080p (Full HD)</option><option value="1440p">1440p (2K)</option><option value="4k">4K (Ultra HD)</option>`,i.value=this.recordingResolution,i.addEventListener(`change`,e=>{this.recordingResolution=e.target.value}),n.appendChild(r),n.appendChild(i),t.appendChild(n);let a=document.createElement(`label`);a.className=`controls__row`;let o=document.createElement(`span`);o.textContent=`Duration`;let s=document.createElement(`select`);s.className=`controls__select`,s.innerHTML=`<option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option><option value="15">15 seconds</option>`,s.value=String(this.recordingDuration),s.addEventListener(`change`,e=>{this.recordingDuration=parseInt(e.target.value)}),a.appendChild(o),a.appendChild(s),t.appendChild(a),this.recordButton=document.createElement(`button`),this.recordButton.type=`button`,this.recordButton.className=`controls__button controls__button--record`,this.recordButton.textContent=`⏺ Start recording`,this.recordButton.addEventListener(`click`,()=>{this.isRecording?this.stop():this.start()}),t.appendChild(this.recordButton),this.recordStatus=document.createElement(`div`),this.recordStatus.className=`controls__status`,this.recordStatus.style.display=`none`,t.appendChild(this.recordStatus),e.appendChild(t)}update(){if(this.isRecording){let e=(performance.now()-this.recordingStartTime)/1e3;this.updateStatus(e),e>=this.recordingDuration&&this.stop()}}start(){if(!this.isRecording)try{let e=this.renderer.domElement,t,n,r=e.width/e.height;if(this.recordingResolution===`current`)t=e.width,n=e.height;else{switch(this.originalCanvasSize={width:e.width,height:e.height},this.recordingResolution){case`1080p`:n=1080,t=Math.round(n*r);break;case`1440p`:n=1440,t=Math.round(n*r);break;case`4k`:n=2160,t=Math.round(n*r);break}this.setRenderSize(t,n)}let i=e.captureStream(60),a=t*n,o=Math.min(25e6,Math.max(8e6,a*4));this.recordedChunks=[],this.mediaRecorder=new MediaRecorder(i,{mimeType:`video/webm;codecs=vp9`,videoBitsPerSecond:o}),this.mediaRecorder.ondataavailable=e=>{e.data.size>0&&this.recordedChunks.push(e.data)},this.mediaRecorder.onstop=()=>{this.restoreRenderSize();let e=new Blob(this.recordedChunks,{type:`video/webm`}),r=URL.createObjectURL(e),i=document.createElement(`a`);i.href=r,i.download=`luminar-${t}x${n}-${Date.now()}.webm`,i.click(),URL.revokeObjectURL(r),this.recordStatus&&(this.recordStatus.textContent=`Recording complete! Download started.`,setTimeout(()=>{this.recordStatus&&(this.recordStatus.style.display=`none`)},3e3))},this.mediaRecorder.start(),this.isRecording=!0,this.recordingStartTime=performance.now(),this.recordButton&&(this.recordButton.textContent=`⏹ Stop recording`,this.recordButton.style.opacity=`1`),this.recordStatus&&(this.recordStatus.style.display=`block`,this.recordStatus.textContent=`Recording at ${t}x${n} (${(o/1e6).toFixed(0)} Mbps): 0.0s / ${this.recordingDuration}s`)}catch(e){console.error(`Failed to start recording:`,e),this.restoreRenderSize(),this.recordStatus&&(this.recordStatus.style.display=`block`,this.recordStatus.textContent=`Recording not supported in this browser.`)}}stop(){!this.isRecording||!this.mediaRecorder||(this.isRecording=!1,this.mediaRecorder.stop(),this.mediaRecorder=null,this.recordButton&&(this.recordButton.textContent=`▶ Start recording`,this.recordButton.style.opacity=`1`))}updateStatus(e){if(this.recordStatus){let t=e.toFixed(1),n=this.renderer.domElement,r=Math.min(25e6,Math.max(8e6,n.width*n.height*4));this.recordStatus.textContent=`Recording at ${n.width}x${n.height} (${(r/1e6).toFixed(0)} Mbps): ${t}s / ${this.recordingDuration}s`}}};function Nc(e){let t=e.split(/\r?\n/).filter(Boolean),n=[],r=!1;for(let e of t){let t=e.split(/[,\s]+/).filter(Boolean);if(t.length<4)continue;let[i,a,o,s]=t.map(Number);if([i,a,o,s].some(e=>Number.isNaN(e))){!r&&n.length===0&&(r=!0,console.log(`skipping header line:`,e.substring(0,60)));continue}n.push({x:i,y:a,dx:o,dy:s})}return n}var Pc=document.querySelector(`#app`);if(!Pc)throw Error(`Missing #app container`);var Fc={...Oc},Ic=1.8,Lc=new lc({antialias:!1,alpha:!0});Lc.setPixelRatio(Math.min(window.devicePixelRatio,2)),Pc.appendChild(Lc.domElement);var Rc=new Gr;Rc.background=new Z(132106);var zc=new Ai(-1,1,1,-1,.1,10);zc.position.z=2;var Bc=new vc(Lc),Vc=new yc(Rc,zc),Hc=new xc(new q(1,1),Fc.bloomStrength,.82,Fc.bloomRadius),Uc=new Cc(Fc.trailDecay);Bc.addPass(Vc),Bc.addPass(Hc),Bc.addPass(Uc),Uc.enabled=Fc.trailsEnabled,Uc.uniforms.damp.value=Fc.trailDecay;var Wc=new cr,Gc=new cr,Kc=new ti({size:Fc.size,sizeAttenuation:!0,vertexColors:!0,transparent:!0,opacity:.9,blending:2,depthWrite:!1}),qc=new oi(Wc,Kc),Jc=new oi(Gc,Kc);Rc.add(qc),Rc.add(Jc);var Yc=new kc(Wc,Fc,()=>Fc.colorPresetA),Xc=new kc(Gc,Fc,()=>Fc.colorPresetB);Yc.init(),Xc.init();var Zc=!1,Qc=!1,$c=new Ac((e,t,n)=>il(`left`,e,t,n)),el=new Ac((e,t,n)=>il(`right`,e,t,n)),tl=new jc(Pc,Fc,Kc,Hc,{onParticleCountChange:e=>{Yc.resizeBuffers(e),Xc.resizeBuffers(e)},onLifetimeChange:()=>{Yc.reseedLifetimes(),Xc.reseedLifetimes()},onTrailToggle:e=>fl(e,Fc.trailDecay),onTrailDecayChange:e=>fl(Fc.trailsEnabled,e),onClearFieldA:()=>ll(`left`),onClearFieldB:()=>ll(`right`),onColorChange:()=>nl()});function nl(){ml=Ec.find(e=>e.key===Fc.colorPresetA)??Ec[0],hl=Ec.find(e=>e.key===Fc.colorPresetB)??Ec[0]}function rl(e){return e===`left`?{system:Yc,loader:$c}:{system:Xc,loader:el}}function il(e,t,n,r){let{system:i,loader:a}=rl(e);i.setFieldData(t,n);let o=i.hasFieldData();e===`left`?Zc=o:Qc=o,tl.setFieldState(e,{loaded:o,label:r}),a.updateStatus(r),dl()}var al=new Mc(Lc,Bc,Hc,ul);function ol(){if(!Pc)return;let e=document.createElement(`div`);e.className=`hud`,e.innerHTML=`<div class="title">luminar</div><div class="subtitle">2D vector field bloom study</div><div class="status">Field A: <span id="field-status-a">default (built-in)</span> · Field B: <span id="field-status-b">default (built-in)</span></div>`,Pc.appendChild(e),$c.setStatusElement(document.getElementById(`field-status-a`)),el.setStatusElement(document.getElementById(`field-status-b`))}function sl(){if(!Pc)return;let e=document.createElement(`div`);e.className=`drop-overlay drop-overlay--left`,e.textContent=`Drop to load Field A (left)`,e.style.display=`none`,Pc.appendChild(e);let t=document.createElement(`div`);t.className=`drop-overlay drop-overlay--right`,t.textContent=`Drop to load Field B (right)`,t.style.display=`none`,Pc.appendChild(t);let n=n=>{n===`left`?(e.style.display=`flex`,t.style.display=`none`):(e.style.display=`none`,t.style.display=`flex`)},r=()=>{e.style.display=`none`,t.style.display=`none`},i=async(e,t)=>{try{let n=Nc(await e.text());if(!n.length){(t===`left`?$c:el).updateStatus(`CSV empty or invalid`),tl.setFieldState(t,{loaded:!1,label:`CSV empty or invalid`}),r();return}let{transform:i,bounds:a}=(t===`left`?$c:el).computeFieldTransform(n);il(t,n,i,`${e.name} · ${n.length} vectors (${a.width.toFixed(1)}×${a.height.toFixed(1)})`)}catch(e){console.error(`Failed to load dropped CSV`,e),(t===`left`?$c:el).updateStatus(`CSV load error`),tl.setFieldState(t,{loaded:!1,label:`CSV load error`})}finally{r()}};window.addEventListener(`dragover`,e=>{e.preventDefault(),n(e.clientX<window.innerWidth*.5?`left`:`right`)}),window.addEventListener(`dragleave`,e=>{e.preventDefault(),r()}),window.addEventListener(`drop`,e=>{e.preventDefault();let t=e.dataTransfer;if(t&&t.files&&t.files[0]){let n=e.clientX<window.innerWidth*.5?`left`:`right`;i(t.files[0],n)}})}function cl(){let e=Ic*(window.innerWidth/window.innerHeight)*2,t=e*.45/2,n=(e-258/window.innerWidth*e-.5)/2,r=Math.min(1.4,n);return Math.max(1,Math.min(t,r))}function ll(e){let t={scale:1,offsetX:0,offsetY:0},{system:n,loader:r}=rl(e);n.reseedLifetimes(),il(e,null,t,`Empty`),r.updateStatus(`default (cleared)`)}function ul(){let e=window.innerWidth,t=window.innerHeight,n=e/t;zc.left=-Ic*n,zc.right=Ic*n,zc.top=Ic,zc.bottom=-Ic,zc.updateProjectionMatrix(),Lc.setSize(e,t,!1),Bc.setSize(e,t),Hc.setSize(e,t),dl()}function dl(){let e=(Zc?1:0)+(Qc?1:0),t=cl();e===2?(qc.visible=!0,Jc.visible=!0,Yc.setViewOffset(-t),Xc.setViewOffset(t)):e===1?Zc?(qc.visible=!0,Jc.visible=!1,Yc.setViewOffset(0)):(qc.visible=!1,Jc.visible=!0,Xc.setViewOffset(0)):(qc.visible=!0,Jc.visible=!1,Yc.setViewOffset(0),Xc.setViewOffset(0))}function fl(e,t){Fc.trailsEnabled=e,Fc.trailDecay=t,Uc.enabled=e,Uc.uniforms.damp.value=t}var pl=performance.now(),ml=Ec.find(e=>e.key===Fc.colorPresetA)??Ec[0],hl=Ec.find(e=>e.key===Fc.colorPresetB)??Ec[0];function gl(e){let t=e||performance.now(),n=Math.min(.033,(t-pl)/1e3);pl=t,Yc.update(n,ml),Xc.update(n,hl),Bc.render(),al.update(),requestAnimationFrame(gl)}ol(),tl.create(),al.createControls(Pc.querySelector(`.controls__body`)),ul(),window.addEventListener(`resize`,ul),$c.load(),el.load(),sl(),gl(0);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap";:root{color:#e8f0ff;background-color:#02040a;font-family:Space Grotesk,Segoe UI,system-ui,sans-serif}*{box-sizing:border-box}body{background:radial-gradient(circle at 20% 20%,#508cff1f,#0000 35%),radial-gradient(circle at 80% 10%,#b464ff1f,#0000 30%),radial-gradient(circle at 50% 80%,#3cc8b424,#0000 32%),#02040a;min-height:100vh;margin:0;overflow:hidden}#app{position:fixed;inset:0;overflow:hidden}canvas{filter:saturate(1.05);width:100%;height:100%;display:block}.hud{color:#dfe8ff;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;mix-blend-mode:screen;text-shadow:0 0 12px #6eaaff4d;position:absolute;top:18px;left:18px}.hud .title{font-size:16px;font-weight:600}.hud .subtitle{opacity:.7;letter-spacing:.04em;margin-top:4px;font-size:12px;font-weight:400}.hud .status{opacity:.8;letter-spacing:.03em;color:#9fb7ff;margin-top:6px;font-size:11px;font-weight:400}.controls{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#dfe8ff;pointer-events:auto;background:#060a16d1;border:1px solid #78b4ff40;border-radius:12px;width:240px;max-height:calc(100vh - 36px);padding:14px 14px 12px;position:absolute;top:18px;right:18px;overflow:hidden;box-shadow:0 12px 30px #00000059,0 0 24px #64a0ff1f}.controls__title{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;margin-bottom:10px;font-size:12px}.controls__header{z-index:1;justify-content:space-between;align-items:baseline;margin-bottom:12px;padding:2px 0 6px;display:flex;position:sticky;top:0}.controls__body{max-height:calc(100vh - 72px);padding-bottom:30px;padding-right:2px;overflow-y:auto}.controls__section:last-child{margin-bottom:8px}.controls__toggle{color:#9fb7ff;cursor:pointer;background:linear-gradient(120deg,#78b4ff1f,#5078c814);border:1px solid #78b4ff4d;border-radius:6px;margin:0 0 0 8px;padding:6px 10px;font-size:14px;font-weight:600;line-height:1;transition:all .12s;display:inline-block}.controls__toggle:hover{color:#dfe8ff;background:linear-gradient(120deg,#78b4ff2e,#5078c81f);border-color:#8cc8ff99;box-shadow:0 0 12px #78b4ff33}.controls__toggle:active{transform:translateY(1px)}.controls--collapsed{width:auto!important;padding:10px 12px!important}.controls--collapsed .controls__header{margin-bottom:0}.controls--collapsed>:not(.controls__header){display:none!important}.controls__row{color:#e8f0ff;grid-template-columns:1fr 1fr auto;align-items:center;gap:8px;margin-bottom:8px;font-size:12px;display:grid}.controls__row--field{grid-template-columns:1fr auto;gap:10px;margin-bottom:10px}.controls__field-meta{flex-direction:column;gap:3px;display:flex}.controls__field-label{color:#e8f0ff}.controls__field-status{color:#9fb7ff;opacity:.9;font-size:11px}.controls__row input[type=range]{accent-color:#7cc4ff;width:100%}.controls__value{font-variant-numeric:tabular-nums;color:#9fb7ff;text-align:right;min-width:44px}.controls__button{color:#dfe8ff;letter-spacing:.04em;cursor:pointer;background:linear-gradient(120deg,#78b4ff29,#5078c81a);border:1px solid #78b4ff59;border-radius:10px;width:100%;margin-top:6px;padding:8px 10px;font-size:12px;transition:border-color .12s,transform .12s,box-shadow .2s}.controls__button:hover{border-color:#8cc8ffb3;box-shadow:0 0 18px #78b4ff40}.controls__button:active{transform:translateY(1px)}.controls__button:disabled{cursor:not-allowed;opacity:.55;box-shadow:none;background:linear-gradient(120deg,#78b4ff0f,#5078c80a);border-color:#78b4ff2e}.controls__button--empty{background:linear-gradient(120deg,#78b4ff14,#5078c80f);border-color:#78b4ff2e}.controls__button--record{background:linear-gradient(120deg,#ff507838,#c8507824);border-color:#ff789666}.controls__button--record:hover{border-color:#ff8caacc;box-shadow:0 0 18px #ff78964d}.controls__section{border-top:1px solid #78b4ff1a;margin-top:12px;padding-top:12px}.controls__subtitle{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;opacity:.85;margin-bottom:8px;font-size:11px}.controls__status{color:#b3d4ff;text-align:center;background:#64b4ff1a;border:1px solid #78b4ff33;border-radius:6px;margin-top:8px;padding:6px 8px;font-size:11px}.controls__select{color:#dfe8ff;cursor:pointer;background:#141e3280;border:1px solid #78b4ff40;border-radius:6px;width:100%;padding:4px 6px;font-size:11px}.controls__select:hover{border-color:#8cc8ff80}.controls__advanced{margin-top:8px}.drop-overlay{color:#d7e2ff;letter-spacing:.02em;z-index:20;pointer-events:none;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#508cff33;justify-content:center;align-items:center;width:50%;font-size:18px;font-weight:600;display:flex;position:fixed;inset:0}.drop-overlay--left{border-right:3px solid #78b4ff66;left:0;right:auto}.drop-overlay--right{border-left:3px solid #78b4ff66;left:auto;right:0}
|
package/dist/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>luminar</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-DUBGNNKo.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-f3JJuz__.css">
|
|
11
11
|
</head>
|
|
12
12
|
|
|
13
13
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brandonlukas/luminar",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Visualize 2D vector fields as looping particle flows with bloom and glow effects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Brandon Lukas",
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"vite": "npm:rolldown-vite@7.2.5"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"three": "^0.182.0"
|
|
48
|
+
"three": "^0.182.0",
|
|
49
|
+
"sirv": "^2.0.4",
|
|
50
|
+
"open": "^10.1.0"
|
|
49
51
|
}
|
|
50
|
-
}
|
|
52
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -60,25 +60,16 @@ scene.add(particlesA)
|
|
|
60
60
|
scene.add(particlesB)
|
|
61
61
|
|
|
62
62
|
// Initialize modules
|
|
63
|
-
const particleSystemA = new ParticleSystem(geometryA, params)
|
|
64
|
-
const particleSystemB = new ParticleSystem(geometryB, params)
|
|
63
|
+
const particleSystemA = new ParticleSystem(geometryA, params, () => params.colorPresetA)
|
|
64
|
+
const particleSystemB = new ParticleSystem(geometryB, params, () => params.colorPresetB)
|
|
65
65
|
particleSystemA.init()
|
|
66
66
|
particleSystemB.init()
|
|
67
67
|
|
|
68
68
|
let hasFieldA = false
|
|
69
69
|
let hasFieldB = false
|
|
70
70
|
|
|
71
|
-
const fieldLoaderA = new FieldLoader((data, transform) =>
|
|
72
|
-
|
|
73
|
-
hasFieldA = particleSystemA.hasFieldData()
|
|
74
|
-
updateLayout()
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
const fieldLoaderB = new FieldLoader((data, transform) => {
|
|
78
|
-
particleSystemB.setFieldData(data, transform)
|
|
79
|
-
hasFieldB = particleSystemB.hasFieldData()
|
|
80
|
-
updateLayout()
|
|
81
|
-
})
|
|
71
|
+
const fieldLoaderA = new FieldLoader((data, transform, label) => setFieldData('left', data, transform, label))
|
|
72
|
+
const fieldLoaderB = new FieldLoader((data, transform, label) => setFieldData('right', data, transform, label))
|
|
82
73
|
|
|
83
74
|
const controlPanel = new ControlPanel(container, params, material, bloomPass, {
|
|
84
75
|
onParticleCountChange: (count) => {
|
|
@@ -101,6 +92,28 @@ function updateColorPresetCache() {
|
|
|
101
92
|
cachedPresetB = COLOR_PRESETS.find((p) => p.key === params.colorPresetB) ?? COLOR_PRESETS[0]
|
|
102
93
|
}
|
|
103
94
|
|
|
95
|
+
function getFieldContext(side: 'left' | 'right') {
|
|
96
|
+
return side === 'left'
|
|
97
|
+
? { system: particleSystemA, loader: fieldLoaderA }
|
|
98
|
+
: { system: particleSystemB, loader: fieldLoaderB }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function setFieldData(side: 'left' | 'right', data: Parameters<FieldLoader['computeFieldTransform']>[0] | null, transform: { scale: number; offsetX: number; offsetY: number }, label: string) {
|
|
102
|
+
const { system, loader } = getFieldContext(side)
|
|
103
|
+
system.setFieldData(data, transform)
|
|
104
|
+
|
|
105
|
+
const loaded = system.hasFieldData()
|
|
106
|
+
if (side === 'left') {
|
|
107
|
+
hasFieldA = loaded
|
|
108
|
+
} else {
|
|
109
|
+
hasFieldB = loaded
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
controlPanel.setFieldState(side, { loaded, label })
|
|
113
|
+
loader.updateStatus(label)
|
|
114
|
+
updateLayout()
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
const recordingManager = new RecordingManager(renderer, composer, bloomPass, resize)
|
|
105
118
|
|
|
106
119
|
// HUD overlay
|
|
@@ -149,32 +162,21 @@ function setupDragAndDrop() {
|
|
|
149
162
|
const text = await file.text()
|
|
150
163
|
const rows = parseCsv(text)
|
|
151
164
|
if (!rows.length) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
fieldLoaderB.updateStatus('CSV empty or invalid')
|
|
156
|
-
}
|
|
165
|
+
const loader = target === 'left' ? fieldLoaderA : fieldLoaderB
|
|
166
|
+
loader.updateStatus('CSV empty or invalid')
|
|
167
|
+
controlPanel.setFieldState(target, { loaded: false, label: 'CSV empty or invalid' })
|
|
157
168
|
hideOverlay()
|
|
158
169
|
return
|
|
159
170
|
}
|
|
160
171
|
const loader = target === 'left' ? fieldLoaderA : fieldLoaderB
|
|
161
172
|
const { transform, bounds } = loader.computeFieldTransform(rows)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
hasFieldA = particleSystemA.hasFieldData()
|
|
165
|
-
} else {
|
|
166
|
-
particleSystemB.setFieldData(rows, transform)
|
|
167
|
-
hasFieldB = particleSystemB.hasFieldData()
|
|
168
|
-
}
|
|
169
|
-
loader.updateStatus(`loaded ${rows.length} vectors (${bounds.width.toFixed(1)}×${bounds.height.toFixed(1)})`)
|
|
170
|
-
updateLayout()
|
|
173
|
+
const label = `${file.name} · ${rows.length} vectors (${bounds.width.toFixed(1)}×${bounds.height.toFixed(1)})`
|
|
174
|
+
setFieldData(target, rows, transform, label)
|
|
171
175
|
} catch (error) {
|
|
172
176
|
console.error('Failed to load dropped CSV', error)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
fieldLoaderB.updateStatus('CSV load error')
|
|
177
|
-
}
|
|
177
|
+
const loader = target === 'left' ? fieldLoaderA : fieldLoaderB
|
|
178
|
+
loader.updateStatus('CSV load error')
|
|
179
|
+
controlPanel.setFieldState(target, { loaded: false, label: 'CSV load error' })
|
|
178
180
|
} finally {
|
|
179
181
|
hideOverlay()
|
|
180
182
|
}
|
|
@@ -230,18 +232,10 @@ function computeViewOffset(): number {
|
|
|
230
232
|
|
|
231
233
|
function clearField(target: 'left' | 'right') {
|
|
232
234
|
const transform = { scale: 1, offsetX: 0, offsetY: 0 }
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
hasFieldA = false
|
|
238
|
-
} else {
|
|
239
|
-
particleSystemB.setFieldData(null, transform)
|
|
240
|
-
particleSystemB.reseedLifetimes()
|
|
241
|
-
fieldLoaderB.updateStatus('default (cleared)')
|
|
242
|
-
hasFieldB = false
|
|
243
|
-
}
|
|
244
|
-
updateLayout()
|
|
235
|
+
const { system, loader } = getFieldContext(target)
|
|
236
|
+
system.reseedLifetimes()
|
|
237
|
+
setFieldData(target, null, transform, 'Empty')
|
|
238
|
+
loader.updateStatus('default (cleared)')
|
|
245
239
|
}
|
|
246
240
|
|
|
247
241
|
// Resize handler
|
|
@@ -318,7 +312,7 @@ function animate(timestamp: number) {
|
|
|
318
312
|
// Initialize
|
|
319
313
|
createOverlay()
|
|
320
314
|
controlPanel.create()
|
|
321
|
-
recordingManager.createControls(container.querySelector('.
|
|
315
|
+
recordingManager.createControls(container.querySelector('.controls__body')!)
|
|
322
316
|
resize()
|
|
323
317
|
window.addEventListener('resize', resize)
|
|
324
318
|
fieldLoaderA.load()
|
package/src/modules/controls.ts
CHANGED
|
@@ -5,9 +5,14 @@ import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPa
|
|
|
5
5
|
|
|
6
6
|
export class ControlPanel {
|
|
7
7
|
private panel!: HTMLDivElement
|
|
8
|
+
private content!: HTMLDivElement
|
|
8
9
|
private controlHandles = new Map<string, SliderHandle>()
|
|
9
10
|
private selectHandles = new Map<string, HTMLSelectElement>()
|
|
10
11
|
private trailToggle?: HTMLInputElement
|
|
12
|
+
private fieldButtons: Record<'left' | 'right', HTMLButtonElement | null> = { left: null, right: null }
|
|
13
|
+
private fieldStatuses: Record<'left' | 'right', HTMLSpanElement | null> = { left: null, right: null }
|
|
14
|
+
private advancedSection?: HTMLDivElement
|
|
15
|
+
private advancedToggle?: HTMLButtonElement
|
|
11
16
|
private container: HTMLElement
|
|
12
17
|
private params: ParticleParams
|
|
13
18
|
private material: PointsMaterial
|
|
@@ -70,6 +75,11 @@ export class ControlPanel {
|
|
|
70
75
|
header.appendChild(toggleBtn)
|
|
71
76
|
this.panel.appendChild(header)
|
|
72
77
|
|
|
78
|
+
// Scrollable content container (header stays sticky)
|
|
79
|
+
this.content = document.createElement('div')
|
|
80
|
+
this.content.className = 'controls__body'
|
|
81
|
+
this.panel.appendChild(this.content)
|
|
82
|
+
|
|
73
83
|
// Field colors
|
|
74
84
|
const colorControlA = this.addSelect('Field A color', COLOR_PRESETS, this.params.colorPresetA, (key) => {
|
|
75
85
|
this.params.colorPresetA = key
|
|
@@ -84,7 +94,7 @@ export class ControlPanel {
|
|
|
84
94
|
|
|
85
95
|
// Speed
|
|
86
96
|
const speedControl = this.addSlider(
|
|
87
|
-
this.
|
|
97
|
+
this.content,
|
|
88
98
|
'Speed',
|
|
89
99
|
0.1,
|
|
90
100
|
8,
|
|
@@ -101,10 +111,12 @@ export class ControlPanel {
|
|
|
101
111
|
advancedToggle.type = 'button'
|
|
102
112
|
advancedToggle.className = 'controls__button'
|
|
103
113
|
advancedToggle.textContent = 'Show advanced'
|
|
114
|
+
this.advancedToggle = advancedToggle
|
|
104
115
|
|
|
105
116
|
const advancedSection = document.createElement('div')
|
|
106
117
|
advancedSection.className = 'controls__advanced'
|
|
107
118
|
advancedSection.style.display = 'none'
|
|
119
|
+
this.advancedSection = advancedSection
|
|
108
120
|
|
|
109
121
|
// Noise strength
|
|
110
122
|
const noiseControl = this.addSlider(advancedSection, 'Noise', 0, 1, 0.01, this.params.noiseStrength, (value) => {
|
|
@@ -230,8 +242,8 @@ export class ControlPanel {
|
|
|
230
242
|
advancedToggle.textContent = isHidden ? 'Hide advanced' : 'Show advanced'
|
|
231
243
|
})
|
|
232
244
|
|
|
233
|
-
this.
|
|
234
|
-
this.
|
|
245
|
+
this.content.appendChild(advancedToggle)
|
|
246
|
+
this.content.appendChild(advancedSection)
|
|
235
247
|
|
|
236
248
|
// Reset button (right after advanced section)
|
|
237
249
|
const resetBtn = document.createElement('button')
|
|
@@ -239,7 +251,7 @@ export class ControlPanel {
|
|
|
239
251
|
resetBtn.className = 'controls__button'
|
|
240
252
|
resetBtn.textContent = 'Reset to defaults'
|
|
241
253
|
resetBtn.addEventListener('click', () => this.reset())
|
|
242
|
-
this.
|
|
254
|
+
this.content.appendChild(resetBtn)
|
|
243
255
|
|
|
244
256
|
// Field management
|
|
245
257
|
const fieldSection = document.createElement('div')
|
|
@@ -249,37 +261,28 @@ export class ControlPanel {
|
|
|
249
261
|
fieldSubtitle.textContent = 'Fields'
|
|
250
262
|
fieldSection.appendChild(fieldSubtitle)
|
|
251
263
|
|
|
252
|
-
const clearARow =
|
|
253
|
-
clearARow.className = 'controls__row'
|
|
254
|
-
const clearALabel = document.createElement('span')
|
|
255
|
-
clearALabel.textContent = 'Clear Field A'
|
|
256
|
-
const clearABtn = document.createElement('button')
|
|
257
|
-
clearABtn.type = 'button'
|
|
258
|
-
clearABtn.className = 'controls__button'
|
|
259
|
-
clearABtn.textContent = 'Clear'
|
|
260
|
-
clearABtn.addEventListener('click', () => this.callbacks.onClearFieldA())
|
|
261
|
-
clearARow.appendChild(clearALabel)
|
|
262
|
-
clearARow.appendChild(clearABtn)
|
|
264
|
+
const clearARow = this.buildFieldRow('Field A', 'left', () => this.callbacks.onClearFieldA())
|
|
263
265
|
fieldSection.appendChild(clearARow)
|
|
264
266
|
|
|
265
|
-
const clearBRow =
|
|
266
|
-
clearBRow.className = 'controls__row'
|
|
267
|
-
const clearBLabel = document.createElement('span')
|
|
268
|
-
clearBLabel.textContent = 'Clear Field B'
|
|
269
|
-
const clearBBtn = document.createElement('button')
|
|
270
|
-
clearBBtn.type = 'button'
|
|
271
|
-
clearBBtn.className = 'controls__button'
|
|
272
|
-
clearBBtn.textContent = 'Clear'
|
|
273
|
-
clearBBtn.addEventListener('click', () => this.callbacks.onClearFieldB())
|
|
274
|
-
clearBRow.appendChild(clearBLabel)
|
|
275
|
-
clearBRow.appendChild(clearBBtn)
|
|
267
|
+
const clearBRow = this.buildFieldRow('Field B', 'right', () => this.callbacks.onClearFieldB())
|
|
276
268
|
fieldSection.appendChild(clearBRow)
|
|
277
269
|
|
|
278
|
-
this.
|
|
270
|
+
this.content.appendChild(fieldSection)
|
|
279
271
|
|
|
280
272
|
this.container.appendChild(this.panel)
|
|
281
273
|
}
|
|
282
274
|
|
|
275
|
+
setFieldState(side: 'left' | 'right', state: { label: string; loaded: boolean }) {
|
|
276
|
+
const button = this.fieldButtons[side]
|
|
277
|
+
const status = this.fieldStatuses[side]
|
|
278
|
+
if (!button || !status) return
|
|
279
|
+
|
|
280
|
+
button.textContent = state.loaded ? 'Clear' : 'Empty'
|
|
281
|
+
button.disabled = !state.loaded
|
|
282
|
+
button.classList.toggle('controls__button--empty', !state.loaded)
|
|
283
|
+
status.textContent = state.label
|
|
284
|
+
}
|
|
285
|
+
|
|
283
286
|
syncFieldValidDistance(value: number) {
|
|
284
287
|
this.params.fieldValidDistance = value
|
|
285
288
|
const handle = this.controlHandles.get('fieldDist')
|
|
@@ -318,6 +321,11 @@ export class ControlPanel {
|
|
|
318
321
|
if (this.trailToggle) {
|
|
319
322
|
this.trailToggle.checked = this.params.trailsEnabled
|
|
320
323
|
}
|
|
324
|
+
|
|
325
|
+
if (this.advancedSection && this.advancedToggle) {
|
|
326
|
+
this.advancedSection.style.display = 'none'
|
|
327
|
+
this.advancedToggle.textContent = 'Show advanced'
|
|
328
|
+
}
|
|
321
329
|
}
|
|
322
330
|
|
|
323
331
|
private addToggle(parent: HTMLDivElement | HTMLElement, label: string, value: boolean, onChange: (value: boolean) => void) {
|
|
@@ -405,11 +413,41 @@ export class ControlPanel {
|
|
|
405
413
|
|
|
406
414
|
row.appendChild(text)
|
|
407
415
|
row.appendChild(select)
|
|
408
|
-
this.
|
|
416
|
+
this.content.appendChild(row)
|
|
409
417
|
|
|
410
418
|
return select
|
|
411
419
|
}
|
|
412
420
|
|
|
421
|
+
private buildFieldRow(label: string, side: 'left' | 'right', onClear: () => void) {
|
|
422
|
+
const row = document.createElement('div')
|
|
423
|
+
row.className = 'controls__row controls__row--field'
|
|
424
|
+
|
|
425
|
+
const meta = document.createElement('div')
|
|
426
|
+
meta.className = 'controls__field-meta'
|
|
427
|
+
const title = document.createElement('div')
|
|
428
|
+
title.className = 'controls__field-label'
|
|
429
|
+
title.textContent = label
|
|
430
|
+
const status = document.createElement('span')
|
|
431
|
+
status.className = 'controls__field-status'
|
|
432
|
+
status.textContent = 'Empty'
|
|
433
|
+
meta.appendChild(title)
|
|
434
|
+
meta.appendChild(status)
|
|
435
|
+
|
|
436
|
+
const button = document.createElement('button')
|
|
437
|
+
button.type = 'button'
|
|
438
|
+
button.className = 'controls__button controls__button--empty'
|
|
439
|
+
button.textContent = 'Empty'
|
|
440
|
+
button.disabled = true
|
|
441
|
+
button.addEventListener('click', () => onClear())
|
|
442
|
+
|
|
443
|
+
this.fieldButtons[side] = button
|
|
444
|
+
this.fieldStatuses[side] = status
|
|
445
|
+
|
|
446
|
+
row.appendChild(meta)
|
|
447
|
+
row.appendChild(button)
|
|
448
|
+
return row
|
|
449
|
+
}
|
|
450
|
+
|
|
413
451
|
private updateBloom() {
|
|
414
452
|
this.bloomPass.strength = this.params.bloomStrength
|
|
415
453
|
this.bloomPass.radius = this.params.bloomRadius
|
|
@@ -3,10 +3,10 @@ import { WORLD_EXTENT } from '../lib/constants'
|
|
|
3
3
|
|
|
4
4
|
export class FieldLoader {
|
|
5
5
|
private fieldStatusEl: HTMLElement | null = null
|
|
6
|
-
private onFieldLoaded: (data: VectorDatum[], transform: FieldTransform) => void
|
|
6
|
+
private onFieldLoaded: (data: VectorDatum[], transform: FieldTransform, label: string) => void
|
|
7
7
|
|
|
8
8
|
constructor(
|
|
9
|
-
onFieldLoaded: (data: VectorDatum[], transform: FieldTransform) => void,
|
|
9
|
+
onFieldLoaded: (data: VectorDatum[], transform: FieldTransform, label: string) => void,
|
|
10
10
|
) {
|
|
11
11
|
this.onFieldLoaded = onFieldLoaded
|
|
12
12
|
}
|
|
@@ -25,8 +25,9 @@ export class FieldLoader {
|
|
|
25
25
|
const data = (await res.json()) as VectorDatum[]
|
|
26
26
|
if (Array.isArray(data) && data.length > 0) {
|
|
27
27
|
const { transform, bounds } = this.computeFieldTransform(data)
|
|
28
|
-
|
|
29
|
-
this.
|
|
28
|
+
const label = `loaded ${data.length} vectors (${bounds.width.toFixed(1)}×${bounds.height.toFixed(1)})`
|
|
29
|
+
this.onFieldLoaded(data, transform, label)
|
|
30
|
+
this.updateStatus(label)
|
|
30
31
|
console.log('Field bounds:', bounds, 'scale:', transform.scale)
|
|
31
32
|
} else {
|
|
32
33
|
this.updateStatus('default (empty file)')
|
|
@@ -20,6 +20,7 @@ export class ParticleSystem {
|
|
|
20
20
|
// Three.js resources
|
|
21
21
|
public geometry: BufferGeometry
|
|
22
22
|
private params: ParticleParams
|
|
23
|
+
private readonly paletteKey: () => string
|
|
23
24
|
|
|
24
25
|
// View and rendering state
|
|
25
26
|
private viewOffsetX: number
|
|
@@ -33,9 +34,11 @@ export class ParticleSystem {
|
|
|
33
34
|
constructor(
|
|
34
35
|
geometry: BufferGeometry,
|
|
35
36
|
params: ParticleParams,
|
|
37
|
+
paletteKey: () => string,
|
|
36
38
|
) {
|
|
37
39
|
this.geometry = geometry
|
|
38
40
|
this.params = params
|
|
41
|
+
this.paletteKey = paletteKey
|
|
39
42
|
this.viewOffsetX = 0
|
|
40
43
|
this.positions = new Float32Array(params.particleCount * 3)
|
|
41
44
|
this.colors = new Float32Array(params.particleCount * 3)
|
|
@@ -314,7 +317,8 @@ export class ParticleSystem {
|
|
|
314
317
|
}
|
|
315
318
|
|
|
316
319
|
private getActiveColorPreset(): ColorPreset {
|
|
317
|
-
|
|
320
|
+
const key = this.paletteKey()
|
|
321
|
+
return COLOR_PRESETS.find((preset) => preset.key === key) ?? COLOR_PRESETS[0]
|
|
318
322
|
}
|
|
319
323
|
|
|
320
324
|
private randomRange(min: number, max: number) {
|
package/src/modules/recording.ts
CHANGED
|
@@ -17,6 +17,20 @@ export class RecordingManager {
|
|
|
17
17
|
private bloomPass: UnrealBloomPass
|
|
18
18
|
private onResize: () => void
|
|
19
19
|
|
|
20
|
+
private setRenderSize(width: number, height: number) {
|
|
21
|
+
this.renderer.setSize(width, height, false)
|
|
22
|
+
this.composer.setSize(width, height)
|
|
23
|
+
this.bloomPass.setSize(width, height)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private restoreRenderSize() {
|
|
27
|
+
if (!this.originalCanvasSize) return
|
|
28
|
+
const { width, height } = this.originalCanvasSize
|
|
29
|
+
this.setRenderSize(width, height)
|
|
30
|
+
this.originalCanvasSize = null
|
|
31
|
+
this.onResize()
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
constructor(
|
|
21
35
|
renderer: WebGLRenderer,
|
|
22
36
|
composer: EffectComposer,
|
|
@@ -129,9 +143,7 @@ export class RecordingManager {
|
|
|
129
143
|
break
|
|
130
144
|
}
|
|
131
145
|
|
|
132
|
-
this.
|
|
133
|
-
this.composer.setSize(recordWidth, recordHeight)
|
|
134
|
-
this.bloomPass.setSize(recordWidth, recordHeight)
|
|
146
|
+
this.setRenderSize(recordWidth, recordHeight)
|
|
135
147
|
}
|
|
136
148
|
|
|
137
149
|
const stream = canvas.captureStream(60)
|
|
@@ -151,13 +163,7 @@ export class RecordingManager {
|
|
|
151
163
|
}
|
|
152
164
|
|
|
153
165
|
this.mediaRecorder.onstop = () => {
|
|
154
|
-
|
|
155
|
-
this.renderer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height, false)
|
|
156
|
-
this.composer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
|
|
157
|
-
this.bloomPass.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
|
|
158
|
-
this.originalCanvasSize = null
|
|
159
|
-
this.onResize()
|
|
160
|
-
}
|
|
166
|
+
this.restoreRenderSize()
|
|
161
167
|
|
|
162
168
|
const blob = new Blob(this.recordedChunks, { type: 'video/webm' })
|
|
163
169
|
const url = URL.createObjectURL(blob)
|
|
@@ -189,13 +195,7 @@ export class RecordingManager {
|
|
|
189
195
|
}
|
|
190
196
|
} catch (error) {
|
|
191
197
|
console.error('Failed to start recording:', error)
|
|
192
|
-
|
|
193
|
-
this.renderer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height, false)
|
|
194
|
-
this.composer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
|
|
195
|
-
this.bloomPass.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
|
|
196
|
-
this.originalCanvasSize = null
|
|
197
|
-
this.onResize()
|
|
198
|
-
}
|
|
198
|
+
this.restoreRenderSize()
|
|
199
199
|
if (this.recordStatus) {
|
|
200
200
|
this.recordStatus.style.display = 'block'
|
|
201
201
|
this.recordStatus.textContent = 'Recording not supported in this browser.'
|
package/src/style.css
CHANGED
|
@@ -72,14 +72,16 @@ canvas {
|
|
|
72
72
|
top: 18px;
|
|
73
73
|
right: 18px;
|
|
74
74
|
width: 240px;
|
|
75
|
-
padding: 14px 14px
|
|
76
|
-
background: rgba(6, 10, 22, 0.
|
|
75
|
+
padding: 14px 14px 12px;
|
|
76
|
+
background: rgba(6, 10, 22, 0.82);
|
|
77
77
|
border: 1px solid rgba(120, 180, 255, 0.25);
|
|
78
78
|
border-radius: 12px;
|
|
79
79
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35), 0 0 24px rgba(100, 160, 255, 0.12);
|
|
80
80
|
backdrop-filter: blur(10px);
|
|
81
81
|
color: #dfe8ff;
|
|
82
82
|
pointer-events: auto;
|
|
83
|
+
max-height: calc(100vh - 36px);
|
|
84
|
+
overflow: hidden;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
.controls__title {
|
|
@@ -94,7 +96,22 @@ canvas {
|
|
|
94
96
|
display: flex;
|
|
95
97
|
justify-content: space-between;
|
|
96
98
|
align-items: baseline;
|
|
97
|
-
margin-bottom:
|
|
99
|
+
margin-bottom: 12px;
|
|
100
|
+
position: sticky;
|
|
101
|
+
top: 0;
|
|
102
|
+
padding: 2px 0 6px;
|
|
103
|
+
z-index: 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.controls__body {
|
|
107
|
+
max-height: calc(100vh - 72px);
|
|
108
|
+
overflow-y: auto;
|
|
109
|
+
padding-bottom: 30px;
|
|
110
|
+
padding-right: 2px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.controls__section:last-child {
|
|
114
|
+
margin-bottom: 8px;
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
.controls__toggle {
|
|
@@ -146,6 +163,28 @@ canvas {
|
|
|
146
163
|
color: #e8f0ff;
|
|
147
164
|
}
|
|
148
165
|
|
|
166
|
+
.controls__row--field {
|
|
167
|
+
grid-template-columns: 1fr auto;
|
|
168
|
+
gap: 10px;
|
|
169
|
+
margin-bottom: 10px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.controls__field-meta {
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
gap: 3px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.controls__field-label {
|
|
179
|
+
color: #e8f0ff;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.controls__field-status {
|
|
183
|
+
font-size: 11px;
|
|
184
|
+
color: #9fb7ff;
|
|
185
|
+
opacity: 0.9;
|
|
186
|
+
}
|
|
187
|
+
|
|
149
188
|
.controls__row input[type='range'] {
|
|
150
189
|
width: 100%;
|
|
151
190
|
accent-color: #7cc4ff;
|
|
@@ -181,6 +220,19 @@ canvas {
|
|
|
181
220
|
transform: translateY(1px);
|
|
182
221
|
}
|
|
183
222
|
|
|
223
|
+
.controls__button:disabled {
|
|
224
|
+
cursor: not-allowed;
|
|
225
|
+
opacity: 0.55;
|
|
226
|
+
border-color: rgba(120, 180, 255, 0.18);
|
|
227
|
+
background: linear-gradient(120deg, rgba(120, 180, 255, 0.06), rgba(80, 120, 200, 0.04));
|
|
228
|
+
box-shadow: none;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.controls__button--empty {
|
|
232
|
+
border-color: rgba(120, 180, 255, 0.18);
|
|
233
|
+
background: linear-gradient(120deg, rgba(120, 180, 255, 0.08), rgba(80, 120, 200, 0.06));
|
|
234
|
+
}
|
|
235
|
+
|
|
184
236
|
.controls__button--record {
|
|
185
237
|
background: linear-gradient(120deg, rgba(255, 80, 120, 0.22), rgba(200, 80, 120, 0.14));
|
|
186
238
|
border-color: rgba(255, 120, 150, 0.4);
|
|
@@ -194,7 +246,7 @@ canvas {
|
|
|
194
246
|
.controls__section {
|
|
195
247
|
margin-top: 12px;
|
|
196
248
|
padding-top: 12px;
|
|
197
|
-
border-top: 1px solid rgba(120, 180, 255, 0.
|
|
249
|
+
border-top: 1px solid rgba(120, 180, 255, 0.1);
|
|
198
250
|
}
|
|
199
251
|
|
|
200
252
|
.controls__subtitle {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
@import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap";:root{color:#e8f0ff;background-color:#02040a;font-family:Space Grotesk,Segoe UI,system-ui,sans-serif}*{box-sizing:border-box}body{background:radial-gradient(circle at 20% 20%,#508cff1f,#0000 35%),radial-gradient(circle at 80% 10%,#b464ff1f,#0000 30%),radial-gradient(circle at 50% 80%,#3cc8b424,#0000 32%),#02040a;min-height:100vh;margin:0;overflow:hidden}#app{position:fixed;inset:0;overflow:hidden}canvas{filter:saturate(1.05);width:100%;height:100%;display:block}.hud{color:#dfe8ff;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;mix-blend-mode:screen;text-shadow:0 0 12px #6eaaff4d;position:absolute;top:18px;left:18px}.hud .title{font-size:16px;font-weight:600}.hud .subtitle{opacity:.7;letter-spacing:.04em;margin-top:4px;font-size:12px;font-weight:400}.hud .status{opacity:.8;letter-spacing:.03em;color:#9fb7ff;margin-top:6px;font-size:11px;font-weight:400}.controls{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#dfe8ff;pointer-events:auto;background:#060a16b3;border:1px solid #78b4ff40;border-radius:12px;width:240px;padding:14px 14px 10px;position:absolute;top:18px;right:18px;box-shadow:0 12px 30px #00000059,0 0 24px #64a0ff1f}.controls__title{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;margin-bottom:10px;font-size:12px}.controls__header{justify-content:space-between;align-items:baseline;margin-bottom:10px;display:flex}.controls__toggle{color:#9fb7ff;cursor:pointer;background:linear-gradient(120deg,#78b4ff1f,#5078c814);border:1px solid #78b4ff4d;border-radius:6px;margin:0 0 0 8px;padding:6px 10px;font-size:14px;font-weight:600;line-height:1;transition:all .12s;display:inline-block}.controls__toggle:hover{color:#dfe8ff;background:linear-gradient(120deg,#78b4ff2e,#5078c81f);border-color:#8cc8ff99;box-shadow:0 0 12px #78b4ff33}.controls__toggle:active{transform:translateY(1px)}.controls--collapsed{width:auto!important;padding:10px 12px!important}.controls--collapsed .controls__header{margin-bottom:0}.controls--collapsed>:not(.controls__header){display:none!important}.controls__row{color:#e8f0ff;grid-template-columns:1fr 1fr auto;align-items:center;gap:8px;margin-bottom:8px;font-size:12px;display:grid}.controls__row input[type=range]{accent-color:#7cc4ff;width:100%}.controls__value{font-variant-numeric:tabular-nums;color:#9fb7ff;text-align:right;min-width:44px}.controls__button{color:#dfe8ff;letter-spacing:.04em;cursor:pointer;background:linear-gradient(120deg,#78b4ff29,#5078c81a);border:1px solid #78b4ff59;border-radius:10px;width:100%;margin-top:6px;padding:8px 10px;font-size:12px;transition:border-color .12s,transform .12s,box-shadow .2s}.controls__button:hover{border-color:#8cc8ffb3;box-shadow:0 0 18px #78b4ff40}.controls__button:active{transform:translateY(1px)}.controls__button--record{background:linear-gradient(120deg,#ff507838,#c8507824);border-color:#ff789666}.controls__button--record:hover{border-color:#ff8caacc;box-shadow:0 0 18px #ff78964d}.controls__section{border-top:1px solid #78b4ff26;margin-top:12px;padding-top:12px}.controls__subtitle{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;opacity:.85;margin-bottom:8px;font-size:11px}.controls__status{color:#b3d4ff;text-align:center;background:#64b4ff1a;border:1px solid #78b4ff33;border-radius:6px;margin-top:8px;padding:6px 8px;font-size:11px}.controls__select{color:#dfe8ff;cursor:pointer;background:#141e3280;border:1px solid #78b4ff40;border-radius:6px;width:100%;padding:4px 6px;font-size:11px}.controls__select:hover{border-color:#8cc8ff80}.controls__advanced{margin-top:8px}.drop-overlay{color:#d7e2ff;letter-spacing:.02em;z-index:20;pointer-events:none;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#508cff33;justify-content:center;align-items:center;width:50%;font-size:18px;font-weight:600;display:flex;position:fixed;inset:0}.drop-overlay--left{border-right:3px solid #78b4ff66;left:0;right:auto}.drop-overlay--right{border-left:3px solid #78b4ff66;left:auto;right:0}
|