@brandonlukas/luminar 0.2.3 → 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 +8 -4
- package/bin/luminar.mjs +32 -73
- package/dist/assets/{index-DqXax9_P.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 +68 -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
|
@@ -18,15 +18,17 @@ A particle-flow visualization inspired by the bloom-heavy aesthetic of [lumap](h
|
|
|
18
18
|
|
|
19
19
|
## Quick Start
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
**Drag and drop** CSV files into the browser window:
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
|
-
npx @brandonlukas/luminar
|
|
24
|
+
npx @brandonlukas/luminar
|
|
25
25
|
```
|
|
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
|
-
|
|
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,40 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
3
2
|
import { resolve, dirname } from 'node:path'
|
|
4
3
|
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
function parseCsv(text) {
|
|
9
|
-
const lines = text.split(/\r?\n/).filter(Boolean)
|
|
10
|
-
const rows = []
|
|
11
|
-
let skippedHeader = false
|
|
12
|
-
|
|
13
|
-
for (const line of lines) {
|
|
14
|
-
const parts = line.split(/[,\s]+/).filter(Boolean)
|
|
15
|
-
if (parts.length < 4) continue
|
|
16
|
-
|
|
17
|
-
const [x, y, dx, dy] = parts.map(Number)
|
|
18
|
-
if ([x, y, dx, dy].some((n) => Number.isNaN(n))) {
|
|
19
|
-
if (!skippedHeader && rows.length === 0) {
|
|
20
|
-
skippedHeader = true
|
|
21
|
-
console.log('skipping header line:', line.substring(0, 60))
|
|
22
|
-
}
|
|
23
|
-
continue
|
|
24
|
-
}
|
|
25
|
-
rows.push({ x, y, dx, dy })
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return rows
|
|
29
|
-
}
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
import sirv from 'sirv'
|
|
6
|
+
import open from 'open'
|
|
30
7
|
|
|
31
8
|
const __filename = fileURLToPath(import.meta.url)
|
|
32
9
|
const __dirname = dirname(__filename)
|
|
33
10
|
const projectRoot = resolve(__dirname, '..')
|
|
11
|
+
const distPath = resolve(projectRoot, 'dist')
|
|
34
12
|
|
|
35
13
|
function parseArgs() {
|
|
36
14
|
const args = process.argv.slice(2)
|
|
37
|
-
const out = {
|
|
15
|
+
const out = { port: 5173, host: '0.0.0.0' }
|
|
38
16
|
|
|
39
17
|
for (let i = 0; i < args.length; i += 1) {
|
|
40
18
|
const arg = args[i]
|
|
@@ -42,61 +20,42 @@ function parseArgs() {
|
|
|
42
20
|
out.port = Number(args[++i]) || out.port
|
|
43
21
|
} else if (arg === '--host') {
|
|
44
22
|
out.host = args[++i] || out.host
|
|
45
|
-
} else if (arg === '--preview') {
|
|
46
|
-
out.preview = true
|
|
47
|
-
} else if (!arg.startsWith('-') && !out.file) {
|
|
48
|
-
// First positional argument is the file
|
|
49
|
-
out.file = arg
|
|
50
23
|
}
|
|
51
24
|
}
|
|
52
25
|
return out
|
|
53
26
|
}
|
|
54
27
|
|
|
55
|
-
function writeFieldJson(rows) {
|
|
56
|
-
const target = resolve(projectRoot, 'public', 'vector-field.json')
|
|
57
|
-
writeFileSync(target, JSON.stringify(rows, null, 2), 'utf8')
|
|
58
|
-
console.log(`wrote ${rows.length} vectors to ${target}`)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function runServer({ port, host, preview }) {
|
|
62
|
-
const cmd = 'npm'
|
|
63
|
-
const args = preview
|
|
64
|
-
? ['run', 'build-and-preview', '--', '--host', host, '--port', String(port)]
|
|
65
|
-
: ['run', 'dev', '--', '--host', host, '--port', String(port)]
|
|
66
|
-
console.log(`starting ${preview ? 'preview' : 'dev'} server on http://${host}:${port}`)
|
|
67
|
-
const child = spawn(cmd, args, {
|
|
68
|
-
stdio: 'inherit',
|
|
69
|
-
cwd: projectRoot,
|
|
70
|
-
env: process.env,
|
|
71
|
-
})
|
|
72
|
-
child.on('exit', (code) => process.exit(code ?? 0))
|
|
73
|
-
}
|
|
74
|
-
|
|
75
28
|
function main() {
|
|
76
|
-
const {
|
|
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
|
+
})
|
|
77
44
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(() => { })
|
|
84
53
|
}
|
|
85
|
-
|
|
86
|
-
const rows = parseCsv(text)
|
|
87
|
-
if (rows.length === 0) {
|
|
88
|
-
console.error('Parsed 0 rows; ensure CSV has x,y,dx,dy columns (header optional)')
|
|
89
|
-
process.exit(1)
|
|
90
|
-
}
|
|
91
|
-
writeFieldJson(rows)
|
|
92
|
-
console.log(`Loaded ${rows.length} vectors from ${resolved}`)
|
|
93
|
-
} else {
|
|
94
|
-
// No file provided - launch app with default empty state
|
|
95
|
-
console.log('No CSV file provided - launching with default empty state')
|
|
96
|
-
console.log('You can drag & drop CSV files in the webapp once it loads')
|
|
97
|
-
}
|
|
54
|
+
})
|
|
98
55
|
|
|
99
|
-
|
|
56
|
+
process.on('SIGINT', () => {
|
|
57
|
+
server.close(() => process.exit(0))
|
|
58
|
+
})
|
|
100
59
|
}
|
|
101
60
|
|
|
102
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.onLifetimeChange(),this.callbacks.onTrailToggle(this.params.trailsEnabled),this.callbacks.onTrailDecayChange(this.params.trailDecay);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')
|
|
@@ -292,9 +295,11 @@ export class ControlPanel {
|
|
|
292
295
|
Object.assign(this.params, defaultParams)
|
|
293
296
|
this.material.size = this.params.size
|
|
294
297
|
this.updateBloom()
|
|
298
|
+
this.callbacks.onParticleCountChange(this.params.particleCount)
|
|
295
299
|
this.callbacks.onLifetimeChange()
|
|
296
300
|
this.callbacks.onTrailToggle(this.params.trailsEnabled)
|
|
297
301
|
this.callbacks.onTrailDecayChange(this.params.trailDecay)
|
|
302
|
+
this.callbacks.onColorChange()
|
|
298
303
|
|
|
299
304
|
// Sync all controls
|
|
300
305
|
for (const [key, handle] of this.controlHandles.entries()) {
|
|
@@ -316,6 +321,11 @@ export class ControlPanel {
|
|
|
316
321
|
if (this.trailToggle) {
|
|
317
322
|
this.trailToggle.checked = this.params.trailsEnabled
|
|
318
323
|
}
|
|
324
|
+
|
|
325
|
+
if (this.advancedSection && this.advancedToggle) {
|
|
326
|
+
this.advancedSection.style.display = 'none'
|
|
327
|
+
this.advancedToggle.textContent = 'Show advanced'
|
|
328
|
+
}
|
|
319
329
|
}
|
|
320
330
|
|
|
321
331
|
private addToggle(parent: HTMLDivElement | HTMLElement, label: string, value: boolean, onChange: (value: boolean) => void) {
|
|
@@ -403,11 +413,41 @@ export class ControlPanel {
|
|
|
403
413
|
|
|
404
414
|
row.appendChild(text)
|
|
405
415
|
row.appendChild(select)
|
|
406
|
-
this.
|
|
416
|
+
this.content.appendChild(row)
|
|
407
417
|
|
|
408
418
|
return select
|
|
409
419
|
}
|
|
410
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
|
+
|
|
411
451
|
private updateBloom() {
|
|
412
452
|
this.bloomPass.strength = this.params.bloomStrength
|
|
413
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}
|