@brandonlukas/luminar 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,9 @@ npx @brandonlukas/luminar
26
26
 
27
27
  Then drag your CSV files onto the left or right side of the canvas to load Field A or Field B.
28
28
 
29
- Optional flags: `--port 5173`, `--host 0.0.0.0`, `--preview` (uses production build)
29
+ Optional flags: `--port 5173`, `--host 0.0.0.0`
30
+
31
+ **CLI behavior:** `npx @brandonlukas/luminar` serves the pre-built `/dist` bundle via a tiny static server (sirv). There is no runtime build step—just download and open.
30
32
 
31
33
  ## CSV Format
32
34
 
@@ -64,6 +66,8 @@ npm run build
64
66
  npm run preview # test production build locally
65
67
  ```
66
68
 
69
+ **Publishable CLI bundle:** The CLI serves the pre-built `/dist` folder. Run `npm run build` before publishing to ensure fresh assets are included.
70
+
67
71
  ## Architecture
68
72
 
69
73
  ### Performance
package/bin/luminar.mjs CHANGED
@@ -1,39 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { resolve, dirname } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
- import { spawn } from 'node:child_process'
5
-
6
- // Inline CSV parser to avoid module resolution issues
7
- function parseCsv(text) {
8
- const lines = text.split(/\r?\n/).filter(Boolean)
9
- const rows = []
10
- let skippedHeader = false
11
-
12
- for (const line of lines) {
13
- const parts = line.split(/[,\s]+/).filter(Boolean)
14
- if (parts.length < 4) continue
15
-
16
- const [x, y, dx, dy] = parts.map(Number)
17
- if ([x, y, dx, dy].some((n) => Number.isNaN(n))) {
18
- if (!skippedHeader && rows.length === 0) {
19
- skippedHeader = true
20
- console.log('skipping header line:', line.substring(0, 60))
21
- }
22
- continue
23
- }
24
- rows.push({ x, y, dx, dy })
25
- }
26
-
27
- return rows
28
- }
4
+ import http from 'node:http'
5
+ import sirv from 'sirv'
6
+ import open from 'open'
29
7
 
30
8
  const __filename = fileURLToPath(import.meta.url)
31
9
  const __dirname = dirname(__filename)
32
10
  const projectRoot = resolve(__dirname, '..')
11
+ const distPath = resolve(projectRoot, 'dist')
33
12
 
34
13
  function parseArgs() {
35
14
  const args = process.argv.slice(2)
36
- const out = { port: 5173, host: '0.0.0.0', preview: false }
15
+ const out = { port: 5173, host: '0.0.0.0' }
37
16
 
38
17
  for (let i = 0; i < args.length; i += 1) {
39
18
  const arg = args[i]
@@ -41,35 +20,42 @@ function parseArgs() {
41
20
  out.port = Number(args[++i]) || out.port
42
21
  } else if (arg === '--host') {
43
22
  out.host = args[++i] || out.host
44
- } else if (arg === '--preview') {
45
- out.preview = true
46
23
  }
47
24
  }
48
25
  return out
49
26
  }
50
27
 
51
- function runServer({ port, host, preview }) {
52
- const cmd = 'npm'
53
- const args = preview
54
- ? ['run', 'build-and-preview', '--', '--host', host, '--port', String(port)]
55
- : ['run', 'dev', '--', '--host', host, '--port', String(port)]
56
- console.log(`starting ${preview ? 'preview' : 'dev'} server on http://${host}:${port}`)
57
- const child = spawn(cmd, args, {
58
- stdio: 'inherit',
59
- cwd: projectRoot,
60
- env: process.env,
61
- })
62
- child.on('exit', (code) => process.exit(code ?? 0))
63
- }
64
-
65
28
  function main() {
66
- const { port, host, preview } = parseArgs()
29
+ const { port, host } = parseArgs()
30
+ const serve = sirv(distPath, { gzip: true, etag: true })
31
+
32
+ const server = http.createServer((req, res) => {
33
+ serve(req, res, () => {
34
+ // If no file found, serve index.html (SPA routing)
35
+ if (req.url !== '/' && !req.url.includes('.')) {
36
+ req.url = '/'
37
+ serve(req, res)
38
+ } else {
39
+ res.statusCode = 404
40
+ res.end('Not found')
41
+ }
42
+ })
43
+ })
67
44
 
68
- console.log(`🚀 Launching luminar on http://${host}:${port}`)
69
- console.log('📂 Drag & drop CSV files into the browser to visualize')
70
- console.log('')
45
+ server.listen(port, host, () => {
46
+ const url = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`
47
+ console.log(`🚀 Launching luminar on ${url}`)
48
+ console.log('📂 Drag & drop CSV files into the browser to visualize')
49
+ console.log('')
50
+ // Open browser on localhost (not 0.0.0.0)
51
+ if (host === '0.0.0.0' || host === 'localhost') {
52
+ open(`http://localhost:${port}`).catch(() => { })
53
+ }
54
+ })
71
55
 
72
- runServer({ port, host, preview })
56
+ process.on('SIGINT', () => {
57
+ server.close(() => process.exit(0))
58
+ })
73
59
  }
74
60
 
75
61
  main()
@@ -4178,4 +4178,4 @@ void main() {
4178
4178
 
4179
4179
  gl_FragColor = max(texelNew, texelOld);
4180
4180
 
4181
- }`},Cc=class extends dc{constructor(e=.96){super(),this.uniforms=Or.clone(Sc.uniforms),this.damp=e,this.compFsMaterial=new jr({uniforms:this.uniforms,vertexShader:Sc.vertexShader,fragmentShader:Sc.fragmentShader}),this.copyFsMaterial=new jr({uniforms:Or.clone(uc.uniforms),vertexShader:uc.vertexShader,fragmentShader:uc.fragmentShader,blending:0,depthTest:!1,depthWrite:!1}),this._textureComp=new xt(window.innerWidth,window.innerHeight,{magFilter:r,type:g}),this._textureOld=new xt(window.innerWidth,window.innerHeight,{magFilter:r,type:g}),this._compFsQuad=new mc(this.compFsMaterial),this._copyFsQuad=new mc(this.copyFsMaterial)}get damp(){return this.uniforms.damp.value}set damp(e){this.uniforms.damp.value=e}render(e,t,n){this.uniforms.tOld.value=this._textureOld.texture,this.uniforms.tNew.value=n.texture,e.setRenderTarget(this._textureComp),this._compFsQuad.render(e),this._copyFsQuad.material.uniforms.tDiffuse.value=this._textureComp.texture,this.renderToScreen?(e.setRenderTarget(null),this._copyFsQuad.render(e)):(e.setRenderTarget(t),this.clear&&e.clear(),this._copyFsQuad.render(e));let r=this._textureOld;this._textureOld=this._textureComp,this._textureComp=r}setSize(e,t){this._textureComp.setSize(e,t),this._textureOld.setSize(e,t)}dispose(){this._textureComp.dispose(),this._textureOld.dispose(),this.compFsMaterial.dispose(),this.copyFsMaterial.dispose(),this._compFsQuad.dispose(),this._copyFsQuad.dispose()}},wc=class{constructor(e=Math){this.grad3=[[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]],this.grad4=[[0,1,1,1],[0,1,1,-1],[0,1,-1,1],[0,1,-1,-1],[0,-1,1,1],[0,-1,1,-1],[0,-1,-1,1],[0,-1,-1,-1],[1,0,1,1],[1,0,1,-1],[1,0,-1,1],[1,0,-1,-1],[-1,0,1,1],[-1,0,1,-1],[-1,0,-1,1],[-1,0,-1,-1],[1,1,0,1],[1,1,0,-1],[1,-1,0,1],[1,-1,0,-1],[-1,1,0,1],[-1,1,0,-1],[-1,-1,0,1],[-1,-1,0,-1],[1,1,1,0],[1,1,-1,0],[1,-1,1,0],[1,-1,-1,0],[-1,1,1,0],[-1,1,-1,0],[-1,-1,1,0],[-1,-1,-1,0]],this.p=[];for(let t=0;t<256;t++)this.p[t]=Math.floor(e.random()*256);this.perm=[];for(let e=0;e<512;e++)this.perm[e]=this.p[e&255];this.simplex=[[0,1,2,3],[0,1,3,2],[0,0,0,0],[0,2,3,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,3,0],[0,2,1,3],[0,0,0,0],[0,3,1,2],[0,3,2,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,3,2,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,0,3],[0,0,0,0],[1,3,0,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,3,0,1],[2,3,1,0],[1,0,2,3],[1,0,3,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,3,1],[0,0,0,0],[2,1,3,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,1,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,0,1,2],[3,0,2,1],[0,0,0,0],[3,1,2,0],[2,1,0,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,1,0,2],[0,0,0,0],[3,2,0,1],[3,2,1,0]]}noise(e,t){let n,r,i,a=.5*(Math.sqrt(3)-1),o=(e+t)*a,s=Math.floor(e+o),c=Math.floor(t+o),l=(3-Math.sqrt(3))/6,u=(s+c)*l,d=s-u,f=c-u,p=e-d,m=t-f,h,g;p>m?(h=1,g=0):(h=0,g=1);let _=p-h+l,v=m-g+l,y=p-1+2*l,b=m-1+2*l,x=s&255,S=c&255,C=this.perm[x+this.perm[S]]%12,w=this.perm[x+h+this.perm[S+g]]%12,T=this.perm[x+1+this.perm[S+1]]%12,E=.5-p*p-m*m;E<0?n=0:(E*=E,n=E*E*this._dot(this.grad3[C],p,m));let D=.5-_*_-v*v;D<0?r=0:(D*=D,r=D*D*this._dot(this.grad3[w],_,v));let ee=.5-y*y-b*b;return ee<0?i=0:(ee*=ee,i=ee*ee*this._dot(this.grad3[T],y,b)),70*(n+r+i)}noise3d(e,t,n){let r,i,a,o,s=(e+t+n)*(1/3),c=Math.floor(e+s),l=Math.floor(t+s),u=Math.floor(n+s),d=1/6,f=(c+l+u)*d,p=c-f,m=l-f,h=u-f,g=e-p,_=t-m,v=n-h,y,b,x,S,C,w;g>=_?_>=v?(y=1,b=0,x=0,S=1,C=1,w=0):g>=v?(y=1,b=0,x=0,S=1,C=0,w=1):(y=0,b=0,x=1,S=1,C=0,w=1):_<v?(y=0,b=0,x=1,S=0,C=1,w=1):g<v?(y=0,b=1,x=0,S=0,C=1,w=1):(y=0,b=1,x=0,S=1,C=1,w=0);let T=g-y+d,E=_-b+d,D=v-x+d,ee=g-S+2*d,O=_-C+2*d,k=v-w+2*d,te=g-1+3*d,ne=_-1+3*d,A=v-1+3*d,j=c&255,M=l&255,N=u&255,re=this.perm[j+this.perm[M+this.perm[N]]]%12,ie=this.perm[j+y+this.perm[M+b+this.perm[N+x]]]%12,P=this.perm[j+S+this.perm[M+C+this.perm[N+w]]]%12,ae=this.perm[j+1+this.perm[M+1+this.perm[N+1]]]%12,oe=.6-g*g-_*_-v*v;oe<0?r=0:(oe*=oe,r=oe*oe*this._dot3(this.grad3[re],g,_,v));let F=.6-T*T-E*E-D*D;F<0?i=0:(F*=F,i=F*F*this._dot3(this.grad3[ie],T,E,D));let I=.6-ee*ee-O*O-k*k;I<0?a=0:(I*=I,a=I*I*this._dot3(this.grad3[P],ee,O,k));let L=.6-te*te-ne*ne-A*A;return L<0?o=0:(L*=L,o=L*L*this._dot3(this.grad3[ae],te,ne,A)),32*(r+i+a+o)}noise4d(e,t,n,r){let i=this.grad4,a=this.simplex,o=this.perm,s=(Math.sqrt(5)-1)/4,c=(5-Math.sqrt(5))/20,l,u,d,f,p,m=(e+t+n+r)*s,h=Math.floor(e+m),g=Math.floor(t+m),_=Math.floor(n+m),v=Math.floor(r+m),y=(h+g+_+v)*c,b=h-y,x=g-y,S=_-y,C=v-y,w=e-b,T=t-x,E=n-S,D=r-C,ee=w>T?32:0,O=w>E?16:0,k=T>E?8:0,te=w>D?4:0,ne=T>D?2:0,A=E>D?1:0,j=ee+O+k+te+ne+A,M=a[j][0]>=3?1:0,N=a[j][1]>=3?1:0,re=a[j][2]>=3?1:0,ie=a[j][3]>=3?1:0,P=a[j][0]>=2?1:0,ae=a[j][1]>=2?1:0,oe=a[j][2]>=2?1:0,F=a[j][3]>=2?1:0,I=a[j][0]>=1?1:0,L=a[j][1]>=1?1:0,se=a[j][2]>=1?1:0,ce=a[j][3]>=1?1:0,le=w-M+c,ue=T-N+c,R=E-re+c,de=D-ie+c,fe=w-P+2*c,pe=T-ae+2*c,me=E-oe+2*c,he=D-F+2*c,ge=w-I+3*c,_e=T-L+3*c,ve=E-se+3*c,ye=D-ce+3*c,be=w-1+4*c,xe=T-1+4*c,Se=E-1+4*c,Ce=D-1+4*c,we=h&255,z=g&255,Te=_&255,B=v&255,Ee=o[we+o[z+o[Te+o[B]]]]%32,V=o[we+M+o[z+N+o[Te+re+o[B+ie]]]]%32,De=o[we+P+o[z+ae+o[Te+oe+o[B+F]]]]%32,H=o[we+I+o[z+L+o[Te+se+o[B+ce]]]]%32,U=o[we+1+o[z+1+o[Te+1+o[B+1]]]]%32,Oe=.6-w*w-T*T-E*E-D*D;Oe<0?l=0:(Oe*=Oe,l=Oe*Oe*this._dot4(i[Ee],w,T,E,D));let ke=.6-le*le-ue*ue-R*R-de*de;ke<0?u=0:(ke*=ke,u=ke*ke*this._dot4(i[V],le,ue,R,de));let Ae=.6-fe*fe-pe*pe-me*me-he*he;Ae<0?d=0:(Ae*=Ae,d=Ae*Ae*this._dot4(i[De],fe,pe,me,he));let je=.6-ge*ge-_e*_e-ve*ve-ye*ye;je<0?f=0:(je*=je,f=je*je*this._dot4(i[H],ge,_e,ve,ye));let Me=.6-be*be-xe*xe-Se*Se-Ce*Ce;return Me<0?p=0:(Me*=Me,p=Me*Me*this._dot4(i[U],be,xe,Se,Ce)),27*(l+u+d+f+p)}_dot(e,t,n){return e[0]*t+e[1]*n}_dot3(e,t,n,r){return e[0]*t+e[1]*n+e[2]*r}_dot4(e,t,n,r,i){return e[0]*t+e[1]*n+e[2]*r+e[3]*i}};const Tc=1.25,Ec=[{key:`luminous-violet`,label:`Luminous violet`,rgb:[.6,.25,.9]},{key:`pure-white`,label:`Pure white`,rgb:[1,1,1]},{key:`neon-cyan`,label:`Neon cyan`,rgb:[.25,.95,1]},{key:`electric-lime`,label:`Electric lime`,rgb:[.75,1,.25]},{key:`solar-flare`,label:`Solar flare`,rgb:[1,.55,.15]},{key:`aurora-mint`,label:`Aurora mint`,rgb:[.4,1,.85]},{key:`sunrise-coral`,label:`Sunrise coral`,rgb:[1,.6,.5]},{key:`ember-gold`,label:`Ember gold`,rgb:[1,.8,.2]}],Dc=`luminous-violet`,Oc={size:2,bloomStrength:1.2,bloomRadius:.35,lifeMin:.5,lifeMax:1.4,fieldValidDistance:.05,speed:6,particleCount:5e3,colorPresetA:Dc,colorPresetB:Dc,noiseStrength:0,trailsEnabled:!1,trailDecay:.9};var kc=class{positions;colors;lifetimes;fieldData=null;fieldTransform={scale:1,offsetX:0,offsetY:0};grid=new Map;gridCellSize=.1;geometry;params;viewOffsetX;activePalette=null;noise;noiseScale=.9;noiseTimeScale=.15;constructor(e,t){this.geometry=e,this.params=t,this.viewOffsetX=0,this.positions=new Float32Array(t.particleCount*3),this.colors=new Float32Array(t.particleCount*3),this.lifetimes=new Float32Array(t.particleCount),this.noise=new wc,this.geometry.setAttribute(`position`,new Zn(this.positions,3)),this.geometry.setAttribute(`color`,new Zn(this.colors,3))}init(){for(let e=0;e<this.params.particleCount;e+=1)this.resetParticle(e);this.updateBuffers()}setFieldData(e,t){this.fieldData=e,this.fieldTransform=t,this.buildSpatialGrid()}hasFieldData(){return this.fieldData!==null&&this.fieldData.length>0}setViewOffset(e){if(this.viewOffsetX!==e){this.viewOffsetX=e;for(let e=0;e<this.params.particleCount;e+=1)this.resetParticle(e);this.updateBuffers()}}update(e,t){let n=performance.now()*.001;this.activePalette=t;let r=this.params.noiseStrength>0,i=.85*this.params.speed*e,a=.015*e;for(let o=0;o<this.params.particleCount;o+=1){let s=o*3,c=this.positions[s],l=this.positions[s+1],u=this.sampleField(c,l,n);if(!u){this.resetParticle(o);continue}r&&this.applyNoise(u,c,l,n),c+=u.x*i+this.randomRange(-a,a),l+=u.y*i+this.randomRange(-a,a);let d=Math.hypot(u.x,u.y),f=Math.min(1,d*2.6);this.applyColor(s,f,t,!1),this.lifetimes[o]-=e,this.shouldResetParticle(o,c,l)?this.resetParticle(o):(this.positions[s]=c,this.positions[s+1]=l)}this.updateBuffers()}resizeBuffers(e){this.params.particleCount=e,this.positions=new Float32Array(e*3),this.colors=new Float32Array(e*3),this.lifetimes=new Float32Array(e),this.geometry.setAttribute(`position`,new Zn(this.positions,3)),this.geometry.setAttribute(`color`,new Zn(this.colors,3)),this.init()}reseedLifetimes(){for(let e=0;e<this.params.particleCount;e+=1)this.lifetimes[e]=this.randomRange(this.params.lifeMin,this.params.lifeMax)}shouldResetParticle(e,t,n){return this.lifetimes[e]<=0||Math.abs(t-this.viewOffsetX)>1.25||Math.abs(n)>1.25}applyNoise(e,t,n,r){let i=this.noise.noise3d(t*this.noiseScale,n*this.noiseScale,r*this.noiseTimeScale),a=this.noise.noise3d((t+10)*this.noiseScale,(n+10)*this.noiseScale,r*this.noiseTimeScale);e.x+=i*this.params.noiseStrength,e.y+=a*this.params.noiseStrength}resetParticle(e){let t=e*3,n=this.activePalette??this.getActiveColorPreset();this.hasFieldData()?this.resetParticleWithinField(t):this.resetParticleRandomly(t),this.lifetimes[e]=this.randomRange(this.params.lifeMin,this.params.lifeMax);let r=.4+Math.random()*.2;this.applyColor(t,r,n,!0)}resetParticleWithinField(e){let t=this.fieldData[Math.floor(Math.random()*this.fieldData.length)],n=this.params.fieldValidDistance*.3,r=t.x+this.randomRange(-n,n)/this.fieldTransform.scale,i=t.y+this.randomRange(-n,n)/this.fieldTransform.scale;this.positions[e]=this.dataToWorldX(r),this.positions[e+1]=this.dataToWorldY(i),this.positions[e+2]=0}resetParticleRandomly(e){this.positions[e]=this.randomRange(-Tc,Tc)+this.viewOffsetX,this.positions[e+1]=this.randomRange(-Tc,Tc),this.positions[e+2]=0}dataToWorldX(e){return e*this.fieldTransform.scale+this.fieldTransform.offsetX+this.viewOffsetX}dataToWorldY(e){return e*this.fieldTransform.scale+this.fieldTransform.offsetY}worldToDataX(e){return(e-this.viewOffsetX-this.fieldTransform.offsetX)/this.fieldTransform.scale}worldToDataY(e){return(e-this.fieldTransform.offsetY)/this.fieldTransform.scale}buildSpatialGrid(){if(this.grid.clear(),!this.fieldData||this.fieldData.length===0)return;let e=this.fieldData[0].x,t=this.fieldData[0].x,n=this.fieldData[0].y,r=this.fieldData[0].y;for(let i of this.fieldData)i.x<e&&(e=i.x),i.x>t&&(t=i.x),i.y<n&&(n=i.y),i.y>r&&(r=i.y);let i=(t-e+(r-n))/2,a=Math.ceil(Math.sqrt(this.fieldData.length));this.gridCellSize=Math.max(.01,i/a);for(let e of this.fieldData){let t=this.getGridKey(e.x,e.y),n=this.grid.get(t);n?n.push(e):this.grid.set(t,[e])}}getGridKey(e,t){return`${Math.floor(e/this.gridCellSize)},${Math.floor(t/this.gridCellSize)}`}sampleField(e,t,n){if(!this.hasFieldData())return{x:1,y:0};let r=this.worldToDataX(e),i=this.worldToDataY(t),a=this.findNearestFieldPoint(r,i);return a?{x:a.dx*this.fieldTransform.scale,y:a.dy*this.fieldTransform.scale}:null}findNearestFieldPoint(e,t){let n=null,r=Number.MAX_VALUE,i=(this.params.fieldValidDistance/this.fieldTransform.scale)**2,a=Math.floor(e/this.gridCellSize),o=Math.floor(t/this.gridCellSize);for(let i=-1;i<=1;i+=1)for(let s=-1;s<=1;s+=1){let c=this.grid.get(`${a+i},${o+s}`);if(c)for(let i of c){let a=i.x-e,o=i.y-t,s=a*a+o*o;s<r&&(r=s,n=i)}}return r<=i?n:null}applyColor(e,t,n,r){let i=Math.min(1,Math.max(0,t));if(n.key===`luminous-violet`){r?(this.colors[e]=.6*i,this.colors[e+1]=.25*i,this.colors[e+2]=.9*i):(this.colors[e]=.35+i*.9,this.colors[e+1]=.18+i*.45,this.colors[e+2]=.6+i*.35);return}let a=r?i:.35+i*.65,[o,s,c]=n.rgb;this.colors[e]=o*a,this.colors[e+1]=s*a,this.colors[e+2]=c*a}getActiveColorPreset(){return Ec.find(e=>e.key===this.params.colorPresetA)??Ec[0]}randomRange(e,t){return e+Math.random()*(t-e)}updateBuffers(){this.geometry.attributes.position.needsUpdate=!0,this.geometry.attributes.color.needsUpdate=!0,this.geometry.computeBoundingSphere()}},Ac=class{fieldStatusEl=null;onFieldLoaded;constructor(e){this.onFieldLoaded=e}setStatusElement(e){this.fieldStatusEl=e}async load(){try{let e=await fetch(`/vector-field.json`,{cache:`no-store`});if(!e.ok){this.updateStatus(`default (built-in)`);return}let t=await e.json();if(Array.isArray(t)&&t.length>0){let{transform:e,bounds:n}=this.computeFieldTransform(t);this.onFieldLoaded(t,e),this.updateStatus(`loaded ${t.length} vectors (${n.width.toFixed(1)}×${n.height.toFixed(1)})`),console.log(`Field bounds:`,n,`scale:`,e.scale)}else this.updateStatus(`default (empty file)`)}catch(e){console.error(`Failed to load vector field`,e),this.updateStatus(`default (load error)`)}}computeFieldTransform(e){let t=e[0].x,n=e[0].x,r=e[0].y,i=e[0].y;for(let a of e)a.x<t&&(t=a.x),a.x>n&&(n=a.x),a.y<r&&(r=a.y),a.y>i&&(i=a.y);let a=n-t,o=i-r,s=Math.max(a,o);Tc*1.8;let c=s>0?2.25/s:1;return{transform:{scale:c,offsetX:-(t+n)*.5*c,offsetY:-(r+i)*.5*c},bounds:{minX:t,maxX:n,minY:r,maxY:i,width:a,height:o}}}updateStatus(e){this.fieldStatusEl&&(this.fieldStatusEl.textContent=e)}},jc=class{panel;controlHandles=new Map;selectHandles=new Map;trailToggle;container;params;material;bloomPass;callbacks;constructor(e,t,n,r,i){this.container=e,this.params=t,this.material=n,this.bloomPass=r,this.callbacks=i}create(){this.panel=document.createElement(`div`),this.panel.className=`controls`;let e=document.createElement(`div`);e.className=`controls__header`;let t=document.createElement(`div`);t.className=`controls__title`,t.textContent=`Controls`;let n=document.createElement(`button`);n.className=`controls__toggle`,n.textContent=`−`,n.type=`button`,n.addEventListener(`click`,()=>{this.panel.classList.toggle(`controls--collapsed`),n.textContent=this.panel.classList.contains(`controls--collapsed`)?`+`:`−`,window.dispatchEvent(new Event(`resize`))}),e.appendChild(t),e.appendChild(n),this.panel.appendChild(e);let r=this.addSelect(`Field A color`,Ec,this.params.colorPresetA,e=>{this.params.colorPresetA=e,this.callbacks.onColorChange()}),i=this.addSelect(`Field B color`,Ec,this.params.colorPresetB,e=>{this.params.colorPresetB=e,this.callbacks.onColorChange()});this.selectHandles.set(`colorA`,r),this.selectHandles.set(`colorB`,i);let a=this.addSlider(this.panel,`Speed`,.1,8,.1,this.params.speed,e=>{this.params.speed=e});this.controlHandles.set(`speed`,a);let o=document.createElement(`button`);o.type=`button`,o.className=`controls__button`,o.textContent=`Show advanced`;let s=document.createElement(`div`);s.className=`controls__advanced`,s.style.display=`none`;let c=this.addSlider(s,`Noise`,0,1,.01,this.params.noiseStrength,e=>{this.params.noiseStrength=e});this.controlHandles.set(`noiseStrength`,c);let l=this.addSlider(s,`Size`,.5,4,.1,this.params.size,e=>{this.params.size=e,this.material.size=e});this.controlHandles.set(`size`,l);let u=this.addSlider(s,`Particle count`,100,8e3,100,this.params.particleCount,e=>{this.params.particleCount=Math.round(e),this.callbacks.onParticleCountChange(this.params.particleCount)});this.controlHandles.set(`particleCount`,u);let d=this.addSlider(s,`Bloom strength`,.2,2.5,.05,this.params.bloomStrength,e=>{this.params.bloomStrength=e,this.updateBloom()});this.controlHandles.set(`bloomStrength`,d);let f=this.addSlider(s,`Bloom radius`,0,1.2,.02,this.params.bloomRadius,e=>{this.params.bloomRadius=e,this.updateBloom()});this.controlHandles.set(`bloomRadius`,f);let p=this.addSlider(s,`Life min (s)`,.1,2,.05,this.params.lifeMin,e=>{if(this.params.lifeMin=e,this.params.lifeMin>this.params.lifeMax){this.params.lifeMax=e;let t=this.controlHandles.get(`lifeMax`);t&&this.syncSlider(t,this.params.lifeMax)}this.callbacks.onLifetimeChange()});this.controlHandles.set(`lifeMin`,p);let m=this.addSlider(s,`Life max (s)`,.2,5,.05,this.params.lifeMax,e=>{if(this.params.lifeMax=e,this.params.lifeMax<this.params.lifeMin){this.params.lifeMin=e;let t=this.controlHandles.get(`lifeMin`);t&&this.syncSlider(t,this.params.lifeMin)}this.callbacks.onLifetimeChange()});this.controlHandles.set(`lifeMax`,m);let h=this.addSlider(s,`Field border`,.01,.1,.01,this.params.fieldValidDistance,e=>{this.params.fieldValidDistance=e});this.controlHandles.set(`fieldDist`,h),this.trailToggle=this.addToggle(s,`Trails`,this.params.trailsEnabled,e=>{this.params.trailsEnabled=e,this.callbacks.onTrailToggle(e)});let g=this.addSlider(s,`Trail decay`,.7,.99,.005,this.params.trailDecay,e=>{this.params.trailDecay=e,this.callbacks.onTrailDecayChange(e)});this.controlHandles.set(`trailDecay`,g),o.addEventListener(`click`,()=>{let e=s.style.display===`none`;s.style.display=e?`block`:`none`,o.textContent=e?`Hide advanced`:`Show advanced`}),this.panel.appendChild(o),this.panel.appendChild(s);let _=document.createElement(`button`);_.type=`button`,_.className=`controls__button`,_.textContent=`Reset to defaults`,_.addEventListener(`click`,()=>this.reset()),this.panel.appendChild(_);let v=document.createElement(`div`);v.className=`controls__section`;let y=document.createElement(`div`);y.className=`controls__subtitle`,y.textContent=`Fields`,v.appendChild(y);let b=document.createElement(`div`);b.className=`controls__row`;let x=document.createElement(`span`);x.textContent=`Clear Field A`;let S=document.createElement(`button`);S.type=`button`,S.className=`controls__button`,S.textContent=`Clear`,S.addEventListener(`click`,()=>this.callbacks.onClearFieldA()),b.appendChild(x),b.appendChild(S),v.appendChild(b);let C=document.createElement(`div`);C.className=`controls__row`;let w=document.createElement(`span`);w.textContent=`Clear Field B`;let T=document.createElement(`button`);T.type=`button`,T.className=`controls__button`,T.textContent=`Clear`,T.addEventListener(`click`,()=>this.callbacks.onClearFieldB()),C.appendChild(w),C.appendChild(T),v.appendChild(C),this.panel.appendChild(v),this.container.appendChild(this.panel)}syncFieldValidDistance(e){this.params.fieldValidDistance=e;let t=this.controlHandles.get(`fieldDist`);t&&this.syncSlider(t,e)}reset(){Object.assign(this.params,Oc),this.material.size=this.params.size,this.updateBloom(),this.callbacks.onParticleCountChange(this.params.particleCount),this.callbacks.onLifetimeChange(),this.callbacks.onTrailToggle(this.params.trailsEnabled),this.callbacks.onTrailDecayChange(this.params.trailDecay),this.callbacks.onColorChange();for(let[e,t]of this.controlHandles.entries()){let n=e;typeof this.params[n]==`number`&&this.syncSlider(t,this.params[n])}for(let[e,t]of this.selectHandles.entries())e===`colorA`&&(t.value=this.params.colorPresetA),e===`colorB`&&(t.value=this.params.colorPresetB);this.trailToggle&&(this.trailToggle.checked=this.params.trailsEnabled)}addToggle(e,t,n,r){let i=document.createElement(`label`);i.className=`controls__row`;let a=document.createElement(`span`);a.textContent=t;let o=document.createElement(`input`);return o.type=`checkbox`,o.checked=n,o.addEventListener(`change`,e=>{let t=e.target.checked;r(t)}),i.appendChild(a),i.appendChild(o),e.appendChild(i),o}addSlider(e,t,n,r,i,a,o){let s=document.createElement(`label`);s.className=`controls__row`;let c=document.createElement(`span`);c.textContent=t;let l=document.createElement(`input`);l.type=`range`,l.min=String(n),l.max=String(r),l.step=String(i),l.value=String(a);let u=document.createElement(`span`);return u.className=`controls__value`,u.textContent=this.formatValue(a,i),l.addEventListener(`input`,e=>{let t=parseFloat(e.target.value);u.textContent=this.formatValue(t,i),o(t)}),s.appendChild(c),s.appendChild(l),s.appendChild(u),e.appendChild(s),{input:l,valueTag:u}}addSelect(e,t,n,r){let i=document.createElement(`label`);i.className=`controls__row`;let a=document.createElement(`span`);a.textContent=e;let o=document.createElement(`select`);o.className=`controls__select`;for(let e of t){let t=document.createElement(`option`);t.value=e.key,t.textContent=e.label,o.appendChild(t)}return o.value=n,o.addEventListener(`change`,e=>{let t=e.target.value;r(t)}),i.appendChild(a),i.appendChild(o),this.panel.appendChild(i),o}updateBloom(){this.bloomPass.strength=this.params.bloomStrength,this.bloomPass.radius=this.params.bloomRadius}formatValue(e,t){return t>=1?e.toFixed(0):e.toFixed(2)}syncSlider(e,t){e.input.value=String(t),e.valueTag.textContent=this.formatValue(t,parseFloat(e.input.step))}},Mc=class{mediaRecorder=null;recordedChunks=[];isRecording=!1;recordingStartTime=0;recordingDuration=5;recordingResolution=`current`;originalCanvasSize=null;recordButton=null;recordStatus=null;renderer;composer;bloomPass;onResize;constructor(e,t,n,r){this.renderer=e,this.composer=t,this.bloomPass=n,this.onResize=r}createControls(e){let t=document.createElement(`div`);t.className=`controls__section`,t.innerHTML=`<div class="controls__subtitle">Export (WebM)</div>`;let n=document.createElement(`label`);n.className=`controls__row`;let r=document.createElement(`span`);r.textContent=`Resolution`;let i=document.createElement(`select`);i.className=`controls__select`,i.innerHTML=`<option value="current">Current window</option><option value="1080p">1080p (Full HD)</option><option value="1440p">1440p (2K)</option><option value="4k">4K (Ultra HD)</option>`,i.value=this.recordingResolution,i.addEventListener(`change`,e=>{this.recordingResolution=e.target.value}),n.appendChild(r),n.appendChild(i),t.appendChild(n);let a=document.createElement(`label`);a.className=`controls__row`;let o=document.createElement(`span`);o.textContent=`Duration`;let s=document.createElement(`select`);s.className=`controls__select`,s.innerHTML=`<option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option><option value="15">15 seconds</option>`,s.value=String(this.recordingDuration),s.addEventListener(`change`,e=>{this.recordingDuration=parseInt(e.target.value)}),a.appendChild(o),a.appendChild(s),t.appendChild(a),this.recordButton=document.createElement(`button`),this.recordButton.type=`button`,this.recordButton.className=`controls__button controls__button--record`,this.recordButton.textContent=`⏺ Start recording`,this.recordButton.addEventListener(`click`,()=>{this.isRecording?this.stop():this.start()}),t.appendChild(this.recordButton),this.recordStatus=document.createElement(`div`),this.recordStatus.className=`controls__status`,this.recordStatus.style.display=`none`,t.appendChild(this.recordStatus),e.appendChild(t)}update(){if(this.isRecording){let e=(performance.now()-this.recordingStartTime)/1e3;this.updateStatus(e),e>=this.recordingDuration&&this.stop()}}start(){if(!this.isRecording)try{let e=this.renderer.domElement,t,n,r=e.width/e.height;if(this.recordingResolution===`current`)t=e.width,n=e.height;else{switch(this.originalCanvasSize={width:e.width,height:e.height},this.recordingResolution){case`1080p`:n=1080,t=Math.round(n*r);break;case`1440p`:n=1440,t=Math.round(n*r);break;case`4k`:n=2160,t=Math.round(n*r);break}this.renderer.setSize(t,n,!1),this.composer.setSize(t,n),this.bloomPass.setSize(t,n)}let i=e.captureStream(60),a=t*n,o=Math.min(25e6,Math.max(8e6,a*4));this.recordedChunks=[],this.mediaRecorder=new MediaRecorder(i,{mimeType:`video/webm;codecs=vp9`,videoBitsPerSecond:o}),this.mediaRecorder.ondataavailable=e=>{e.data.size>0&&this.recordedChunks.push(e.data)},this.mediaRecorder.onstop=()=>{this.originalCanvasSize&&(this.renderer.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height,!1),this.composer.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height),this.bloomPass.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height),this.originalCanvasSize=null,this.onResize());let e=new Blob(this.recordedChunks,{type:`video/webm`}),r=URL.createObjectURL(e),i=document.createElement(`a`);i.href=r,i.download=`luminar-${t}x${n}-${Date.now()}.webm`,i.click(),URL.revokeObjectURL(r),this.recordStatus&&(this.recordStatus.textContent=`Recording complete! Download started.`,setTimeout(()=>{this.recordStatus&&(this.recordStatus.style.display=`none`)},3e3))},this.mediaRecorder.start(),this.isRecording=!0,this.recordingStartTime=performance.now(),this.recordButton&&(this.recordButton.textContent=`⏹ Stop recording`,this.recordButton.style.opacity=`1`),this.recordStatus&&(this.recordStatus.style.display=`block`,this.recordStatus.textContent=`Recording at ${t}x${n} (${(o/1e6).toFixed(0)} Mbps): 0.0s / ${this.recordingDuration}s`)}catch(e){console.error(`Failed to start recording:`,e),this.originalCanvasSize&&(this.renderer.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height,!1),this.composer.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height),this.bloomPass.setSize(this.originalCanvasSize.width,this.originalCanvasSize.height),this.originalCanvasSize=null,this.onResize()),this.recordStatus&&(this.recordStatus.style.display=`block`,this.recordStatus.textContent=`Recording not supported in this browser.`)}}stop(){!this.isRecording||!this.mediaRecorder||(this.isRecording=!1,this.mediaRecorder.stop(),this.mediaRecorder=null,this.recordButton&&(this.recordButton.textContent=`▶ Start recording`,this.recordButton.style.opacity=`1`))}updateStatus(e){if(this.recordStatus){let t=e.toFixed(1),n=this.renderer.domElement,r=Math.min(25e6,Math.max(8e6,n.width*n.height*4));this.recordStatus.textContent=`Recording at ${n.width}x${n.height} (${(r/1e6).toFixed(0)} Mbps): ${t}s / ${this.recordingDuration}s`}}};function Nc(e){let t=e.split(/\r?\n/).filter(Boolean),n=[],r=!1;for(let e of t){let t=e.split(/[,\s]+/).filter(Boolean);if(t.length<4)continue;let[i,a,o,s]=t.map(Number);if([i,a,o,s].some(e=>Number.isNaN(e))){!r&&n.length===0&&(r=!0,console.log(`skipping header line:`,e.substring(0,60)));continue}n.push({x:i,y:a,dx:o,dy:s})}return n}var Pc=document.querySelector(`#app`);if(!Pc)throw Error(`Missing #app container`);var Fc={...Oc},Ic=1.8,Lc=new lc({antialias:!1,alpha:!0});Lc.setPixelRatio(Math.min(window.devicePixelRatio,2)),Pc.appendChild(Lc.domElement);var Rc=new Gr;Rc.background=new Z(132106);var zc=new Ai(-1,1,1,-1,.1,10);zc.position.z=2;var Bc=new vc(Lc),Vc=new yc(Rc,zc),Hc=new xc(new q(1,1),Fc.bloomStrength,.82,Fc.bloomRadius),Uc=new Cc(Fc.trailDecay);Bc.addPass(Vc),Bc.addPass(Hc),Bc.addPass(Uc),Uc.enabled=Fc.trailsEnabled,Uc.uniforms.damp.value=Fc.trailDecay;var Wc=new cr,Gc=new cr,Kc=new ti({size:Fc.size,sizeAttenuation:!0,vertexColors:!0,transparent:!0,opacity:.9,blending:2,depthWrite:!1}),qc=new oi(Wc,Kc),Jc=new oi(Gc,Kc);Rc.add(qc),Rc.add(Jc);var Yc=new kc(Wc,Fc),Xc=new kc(Gc,Fc);Yc.init(),Xc.init();var Zc=!1,Qc=!1,$c=new Ac((e,t)=>{Yc.setFieldData(e,t),Zc=Yc.hasFieldData(),ll()}),el=new Ac((e,t)=>{Xc.setFieldData(e,t),Qc=Xc.hasFieldData(),ll()}),tl=new jc(Pc,Fc,Kc,Hc,{onParticleCountChange:e=>{Yc.resizeBuffers(e),Xc.resizeBuffers(e)},onLifetimeChange:()=>{Yc.reseedLifetimes(),Xc.reseedLifetimes()},onTrailToggle:e=>ul(e,Fc.trailDecay),onTrailDecayChange:e=>ul(Fc.trailsEnabled,e),onClearFieldA:()=>sl(`left`),onClearFieldB:()=>sl(`right`),onColorChange:()=>nl()});function nl(){fl=Ec.find(e=>e.key===Fc.colorPresetA)??Ec[0],pl=Ec.find(e=>e.key===Fc.colorPresetB)??Ec[0]}var rl=new Mc(Lc,Bc,Hc,cl);function il(){if(!Pc)return;let e=document.createElement(`div`);e.className=`hud`,e.innerHTML=`<div class="title">luminar</div><div class="subtitle">2D vector field bloom study</div><div class="status">Field A: <span id="field-status-a">default (built-in)</span> · Field B: <span id="field-status-b">default (built-in)</span></div>`,Pc.appendChild(e),$c.setStatusElement(document.getElementById(`field-status-a`)),el.setStatusElement(document.getElementById(`field-status-b`))}function al(){if(!Pc)return;let e=document.createElement(`div`);e.className=`drop-overlay drop-overlay--left`,e.textContent=`Drop to load Field A (left)`,e.style.display=`none`,Pc.appendChild(e);let t=document.createElement(`div`);t.className=`drop-overlay drop-overlay--right`,t.textContent=`Drop to load Field B (right)`,t.style.display=`none`,Pc.appendChild(t);let n=n=>{n===`left`?(e.style.display=`flex`,t.style.display=`none`):(e.style.display=`none`,t.style.display=`flex`)},r=()=>{e.style.display=`none`,t.style.display=`none`},i=async(e,t)=>{try{let n=Nc(await e.text());if(!n.length){t===`left`?$c.updateStatus(`CSV empty or invalid`):el.updateStatus(`CSV empty or invalid`),r();return}let i=t===`left`?$c:el,{transform:a,bounds:o}=i.computeFieldTransform(n);t===`left`?(Yc.setFieldData(n,a),Zc=Yc.hasFieldData()):(Xc.setFieldData(n,a),Qc=Xc.hasFieldData()),i.updateStatus(`loaded ${n.length} vectors (${o.width.toFixed(1)}×${o.height.toFixed(1)})`),ll()}catch(e){console.error(`Failed to load dropped CSV`,e),t===`left`?$c.updateStatus(`CSV load error`):el.updateStatus(`CSV load error`)}finally{r()}};window.addEventListener(`dragover`,e=>{e.preventDefault(),n(e.clientX<window.innerWidth*.5?`left`:`right`)}),window.addEventListener(`dragleave`,e=>{e.preventDefault(),r()}),window.addEventListener(`drop`,e=>{e.preventDefault();let t=e.dataTransfer;if(t&&t.files&&t.files[0]){let n=e.clientX<window.innerWidth*.5?`left`:`right`;i(t.files[0],n)}})}function ol(){let e=Ic*(window.innerWidth/window.innerHeight)*2,t=e*.45/2,n=(e-258/window.innerWidth*e-.5)/2,r=Math.min(1.4,n);return Math.max(1,Math.min(t,r))}function sl(e){let t={scale:1,offsetX:0,offsetY:0};e===`left`?(Yc.setFieldData(null,t),Yc.reseedLifetimes(),$c.updateStatus(`default (cleared)`),Zc=!1):(Xc.setFieldData(null,t),Xc.reseedLifetimes(),el.updateStatus(`default (cleared)`),Qc=!1),ll()}function cl(){let e=window.innerWidth,t=window.innerHeight,n=e/t;zc.left=-Ic*n,zc.right=Ic*n,zc.top=Ic,zc.bottom=-Ic,zc.updateProjectionMatrix(),Lc.setSize(e,t,!1),Bc.setSize(e,t),Hc.setSize(e,t),ll()}function ll(){let e=(Zc?1:0)+(Qc?1:0),t=ol();e===2?(qc.visible=!0,Jc.visible=!0,Yc.setViewOffset(-t),Xc.setViewOffset(t)):e===1?Zc?(qc.visible=!0,Jc.visible=!1,Yc.setViewOffset(0)):(qc.visible=!1,Jc.visible=!0,Xc.setViewOffset(0)):(qc.visible=!0,Jc.visible=!1,Yc.setViewOffset(0),Xc.setViewOffset(0))}function ul(e,t){Fc.trailsEnabled=e,Fc.trailDecay=t,Uc.enabled=e,Uc.uniforms.damp.value=t}var dl=performance.now(),fl=Ec.find(e=>e.key===Fc.colorPresetA)??Ec[0],pl=Ec.find(e=>e.key===Fc.colorPresetB)??Ec[0];function ml(e){let t=e||performance.now(),n=Math.min(.033,(t-dl)/1e3);dl=t,Yc.update(n,fl),Xc.update(n,pl),Bc.render(),rl.update(),requestAnimationFrame(ml)}il(),tl.create(),rl.createControls(Pc.querySelector(`.controls`)),cl(),window.addEventListener(`resize`,cl),$c.load(),el.load(),al(),ml(0);
4181
+ }`},Cc=class extends dc{constructor(e=.96){super(),this.uniforms=Or.clone(Sc.uniforms),this.damp=e,this.compFsMaterial=new jr({uniforms:this.uniforms,vertexShader:Sc.vertexShader,fragmentShader:Sc.fragmentShader}),this.copyFsMaterial=new jr({uniforms:Or.clone(uc.uniforms),vertexShader:uc.vertexShader,fragmentShader:uc.fragmentShader,blending:0,depthTest:!1,depthWrite:!1}),this._textureComp=new xt(window.innerWidth,window.innerHeight,{magFilter:r,type:g}),this._textureOld=new xt(window.innerWidth,window.innerHeight,{magFilter:r,type:g}),this._compFsQuad=new mc(this.compFsMaterial),this._copyFsQuad=new mc(this.copyFsMaterial)}get damp(){return this.uniforms.damp.value}set damp(e){this.uniforms.damp.value=e}render(e,t,n){this.uniforms.tOld.value=this._textureOld.texture,this.uniforms.tNew.value=n.texture,e.setRenderTarget(this._textureComp),this._compFsQuad.render(e),this._copyFsQuad.material.uniforms.tDiffuse.value=this._textureComp.texture,this.renderToScreen?(e.setRenderTarget(null),this._copyFsQuad.render(e)):(e.setRenderTarget(t),this.clear&&e.clear(),this._copyFsQuad.render(e));let r=this._textureOld;this._textureOld=this._textureComp,this._textureComp=r}setSize(e,t){this._textureComp.setSize(e,t),this._textureOld.setSize(e,t)}dispose(){this._textureComp.dispose(),this._textureOld.dispose(),this.compFsMaterial.dispose(),this.copyFsMaterial.dispose(),this._compFsQuad.dispose(),this._copyFsQuad.dispose()}},wc=class{constructor(e=Math){this.grad3=[[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]],this.grad4=[[0,1,1,1],[0,1,1,-1],[0,1,-1,1],[0,1,-1,-1],[0,-1,1,1],[0,-1,1,-1],[0,-1,-1,1],[0,-1,-1,-1],[1,0,1,1],[1,0,1,-1],[1,0,-1,1],[1,0,-1,-1],[-1,0,1,1],[-1,0,1,-1],[-1,0,-1,1],[-1,0,-1,-1],[1,1,0,1],[1,1,0,-1],[1,-1,0,1],[1,-1,0,-1],[-1,1,0,1],[-1,1,0,-1],[-1,-1,0,1],[-1,-1,0,-1],[1,1,1,0],[1,1,-1,0],[1,-1,1,0],[1,-1,-1,0],[-1,1,1,0],[-1,1,-1,0],[-1,-1,1,0],[-1,-1,-1,0]],this.p=[];for(let t=0;t<256;t++)this.p[t]=Math.floor(e.random()*256);this.perm=[];for(let e=0;e<512;e++)this.perm[e]=this.p[e&255];this.simplex=[[0,1,2,3],[0,1,3,2],[0,0,0,0],[0,2,3,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,3,0],[0,2,1,3],[0,0,0,0],[0,3,1,2],[0,3,2,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,3,2,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,0,3],[0,0,0,0],[1,3,0,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,3,0,1],[2,3,1,0],[1,0,2,3],[1,0,3,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,3,1],[0,0,0,0],[2,1,3,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,1,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,0,1,2],[3,0,2,1],[0,0,0,0],[3,1,2,0],[2,1,0,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,1,0,2],[0,0,0,0],[3,2,0,1],[3,2,1,0]]}noise(e,t){let n,r,i,a=.5*(Math.sqrt(3)-1),o=(e+t)*a,s=Math.floor(e+o),c=Math.floor(t+o),l=(3-Math.sqrt(3))/6,u=(s+c)*l,d=s-u,f=c-u,p=e-d,m=t-f,h,g;p>m?(h=1,g=0):(h=0,g=1);let _=p-h+l,v=m-g+l,y=p-1+2*l,b=m-1+2*l,x=s&255,S=c&255,C=this.perm[x+this.perm[S]]%12,w=this.perm[x+h+this.perm[S+g]]%12,T=this.perm[x+1+this.perm[S+1]]%12,E=.5-p*p-m*m;E<0?n=0:(E*=E,n=E*E*this._dot(this.grad3[C],p,m));let D=.5-_*_-v*v;D<0?r=0:(D*=D,r=D*D*this._dot(this.grad3[w],_,v));let ee=.5-y*y-b*b;return ee<0?i=0:(ee*=ee,i=ee*ee*this._dot(this.grad3[T],y,b)),70*(n+r+i)}noise3d(e,t,n){let r,i,a,o,s=(e+t+n)*(1/3),c=Math.floor(e+s),l=Math.floor(t+s),u=Math.floor(n+s),d=1/6,f=(c+l+u)*d,p=c-f,m=l-f,h=u-f,g=e-p,_=t-m,v=n-h,y,b,x,S,C,w;g>=_?_>=v?(y=1,b=0,x=0,S=1,C=1,w=0):g>=v?(y=1,b=0,x=0,S=1,C=0,w=1):(y=0,b=0,x=1,S=1,C=0,w=1):_<v?(y=0,b=0,x=1,S=0,C=1,w=1):g<v?(y=0,b=1,x=0,S=0,C=1,w=1):(y=0,b=1,x=0,S=1,C=1,w=0);let T=g-y+d,E=_-b+d,D=v-x+d,ee=g-S+2*d,O=_-C+2*d,k=v-w+2*d,te=g-1+3*d,ne=_-1+3*d,A=v-1+3*d,j=c&255,M=l&255,N=u&255,re=this.perm[j+this.perm[M+this.perm[N]]]%12,ie=this.perm[j+y+this.perm[M+b+this.perm[N+x]]]%12,P=this.perm[j+S+this.perm[M+C+this.perm[N+w]]]%12,ae=this.perm[j+1+this.perm[M+1+this.perm[N+1]]]%12,oe=.6-g*g-_*_-v*v;oe<0?r=0:(oe*=oe,r=oe*oe*this._dot3(this.grad3[re],g,_,v));let F=.6-T*T-E*E-D*D;F<0?i=0:(F*=F,i=F*F*this._dot3(this.grad3[ie],T,E,D));let I=.6-ee*ee-O*O-k*k;I<0?a=0:(I*=I,a=I*I*this._dot3(this.grad3[P],ee,O,k));let L=.6-te*te-ne*ne-A*A;return L<0?o=0:(L*=L,o=L*L*this._dot3(this.grad3[ae],te,ne,A)),32*(r+i+a+o)}noise4d(e,t,n,r){let i=this.grad4,a=this.simplex,o=this.perm,s=(Math.sqrt(5)-1)/4,c=(5-Math.sqrt(5))/20,l,u,d,f,p,m=(e+t+n+r)*s,h=Math.floor(e+m),g=Math.floor(t+m),_=Math.floor(n+m),v=Math.floor(r+m),y=(h+g+_+v)*c,b=h-y,x=g-y,S=_-y,C=v-y,w=e-b,T=t-x,E=n-S,D=r-C,ee=w>T?32:0,O=w>E?16:0,k=T>E?8:0,te=w>D?4:0,ne=T>D?2:0,A=E>D?1:0,j=ee+O+k+te+ne+A,M=a[j][0]>=3?1:0,N=a[j][1]>=3?1:0,re=a[j][2]>=3?1:0,ie=a[j][3]>=3?1:0,P=a[j][0]>=2?1:0,ae=a[j][1]>=2?1:0,oe=a[j][2]>=2?1:0,F=a[j][3]>=2?1:0,I=a[j][0]>=1?1:0,L=a[j][1]>=1?1:0,se=a[j][2]>=1?1:0,ce=a[j][3]>=1?1:0,le=w-M+c,ue=T-N+c,R=E-re+c,de=D-ie+c,fe=w-P+2*c,pe=T-ae+2*c,me=E-oe+2*c,he=D-F+2*c,ge=w-I+3*c,_e=T-L+3*c,ve=E-se+3*c,ye=D-ce+3*c,be=w-1+4*c,xe=T-1+4*c,Se=E-1+4*c,Ce=D-1+4*c,we=h&255,z=g&255,Te=_&255,B=v&255,Ee=o[we+o[z+o[Te+o[B]]]]%32,V=o[we+M+o[z+N+o[Te+re+o[B+ie]]]]%32,De=o[we+P+o[z+ae+o[Te+oe+o[B+F]]]]%32,H=o[we+I+o[z+L+o[Te+se+o[B+ce]]]]%32,U=o[we+1+o[z+1+o[Te+1+o[B+1]]]]%32,Oe=.6-w*w-T*T-E*E-D*D;Oe<0?l=0:(Oe*=Oe,l=Oe*Oe*this._dot4(i[Ee],w,T,E,D));let ke=.6-le*le-ue*ue-R*R-de*de;ke<0?u=0:(ke*=ke,u=ke*ke*this._dot4(i[V],le,ue,R,de));let Ae=.6-fe*fe-pe*pe-me*me-he*he;Ae<0?d=0:(Ae*=Ae,d=Ae*Ae*this._dot4(i[De],fe,pe,me,he));let je=.6-ge*ge-_e*_e-ve*ve-ye*ye;je<0?f=0:(je*=je,f=je*je*this._dot4(i[H],ge,_e,ve,ye));let Me=.6-be*be-xe*xe-Se*Se-Ce*Ce;return Me<0?p=0:(Me*=Me,p=Me*Me*this._dot4(i[U],be,xe,Se,Ce)),27*(l+u+d+f+p)}_dot(e,t,n){return e[0]*t+e[1]*n}_dot3(e,t,n,r){return e[0]*t+e[1]*n+e[2]*r}_dot4(e,t,n,r,i){return e[0]*t+e[1]*n+e[2]*r+e[3]*i}};const Tc=1.25,Ec=[{key:`luminous-violet`,label:`Luminous violet`,rgb:[.6,.25,.9]},{key:`pure-white`,label:`Pure white`,rgb:[1,1,1]},{key:`neon-cyan`,label:`Neon cyan`,rgb:[.25,.95,1]},{key:`electric-lime`,label:`Electric lime`,rgb:[.75,1,.25]},{key:`solar-flare`,label:`Solar flare`,rgb:[1,.55,.15]},{key:`aurora-mint`,label:`Aurora mint`,rgb:[.4,1,.85]},{key:`sunrise-coral`,label:`Sunrise coral`,rgb:[1,.6,.5]},{key:`ember-gold`,label:`Ember gold`,rgb:[1,.8,.2]}],Dc=`luminous-violet`,Oc={size:2,bloomStrength:1.2,bloomRadius:.35,lifeMin:.5,lifeMax:1.4,fieldValidDistance:.05,speed:6,particleCount:5e3,colorPresetA:Dc,colorPresetB:Dc,noiseStrength:0,trailsEnabled:!1,trailDecay:.9};var kc=class{positions;colors;lifetimes;fieldData=null;fieldTransform={scale:1,offsetX:0,offsetY:0};grid=new Map;gridCellSize=.1;geometry;params;paletteKey;viewOffsetX;activePalette=null;noise;noiseScale=.9;noiseTimeScale=.15;constructor(e,t,n){this.geometry=e,this.params=t,this.paletteKey=n,this.viewOffsetX=0,this.positions=new Float32Array(t.particleCount*3),this.colors=new Float32Array(t.particleCount*3),this.lifetimes=new Float32Array(t.particleCount),this.noise=new wc,this.geometry.setAttribute(`position`,new Zn(this.positions,3)),this.geometry.setAttribute(`color`,new Zn(this.colors,3))}init(){for(let e=0;e<this.params.particleCount;e+=1)this.resetParticle(e);this.updateBuffers()}setFieldData(e,t){this.fieldData=e,this.fieldTransform=t,this.buildSpatialGrid()}hasFieldData(){return this.fieldData!==null&&this.fieldData.length>0}setViewOffset(e){if(this.viewOffsetX!==e){this.viewOffsetX=e;for(let e=0;e<this.params.particleCount;e+=1)this.resetParticle(e);this.updateBuffers()}}update(e,t){let n=performance.now()*.001;this.activePalette=t;let r=this.params.noiseStrength>0,i=.85*this.params.speed*e,a=.015*e;for(let o=0;o<this.params.particleCount;o+=1){let s=o*3,c=this.positions[s],l=this.positions[s+1],u=this.sampleField(c,l,n);if(!u){this.resetParticle(o);continue}r&&this.applyNoise(u,c,l,n),c+=u.x*i+this.randomRange(-a,a),l+=u.y*i+this.randomRange(-a,a);let d=Math.hypot(u.x,u.y),f=Math.min(1,d*2.6);this.applyColor(s,f,t,!1),this.lifetimes[o]-=e,this.shouldResetParticle(o,c,l)?this.resetParticle(o):(this.positions[s]=c,this.positions[s+1]=l)}this.updateBuffers()}resizeBuffers(e){this.params.particleCount=e,this.positions=new Float32Array(e*3),this.colors=new Float32Array(e*3),this.lifetimes=new Float32Array(e),this.geometry.setAttribute(`position`,new Zn(this.positions,3)),this.geometry.setAttribute(`color`,new Zn(this.colors,3)),this.init()}reseedLifetimes(){for(let e=0;e<this.params.particleCount;e+=1)this.lifetimes[e]=this.randomRange(this.params.lifeMin,this.params.lifeMax)}shouldResetParticle(e,t,n){return this.lifetimes[e]<=0||Math.abs(t-this.viewOffsetX)>1.25||Math.abs(n)>1.25}applyNoise(e,t,n,r){let i=this.noise.noise3d(t*this.noiseScale,n*this.noiseScale,r*this.noiseTimeScale),a=this.noise.noise3d((t+10)*this.noiseScale,(n+10)*this.noiseScale,r*this.noiseTimeScale);e.x+=i*this.params.noiseStrength,e.y+=a*this.params.noiseStrength}resetParticle(e){let t=e*3,n=this.activePalette??this.getActiveColorPreset();this.hasFieldData()?this.resetParticleWithinField(t):this.resetParticleRandomly(t),this.lifetimes[e]=this.randomRange(this.params.lifeMin,this.params.lifeMax);let r=.4+Math.random()*.2;this.applyColor(t,r,n,!0)}resetParticleWithinField(e){let t=this.fieldData[Math.floor(Math.random()*this.fieldData.length)],n=this.params.fieldValidDistance*.3,r=t.x+this.randomRange(-n,n)/this.fieldTransform.scale,i=t.y+this.randomRange(-n,n)/this.fieldTransform.scale;this.positions[e]=this.dataToWorldX(r),this.positions[e+1]=this.dataToWorldY(i),this.positions[e+2]=0}resetParticleRandomly(e){this.positions[e]=this.randomRange(-Tc,Tc)+this.viewOffsetX,this.positions[e+1]=this.randomRange(-Tc,Tc),this.positions[e+2]=0}dataToWorldX(e){return e*this.fieldTransform.scale+this.fieldTransform.offsetX+this.viewOffsetX}dataToWorldY(e){return e*this.fieldTransform.scale+this.fieldTransform.offsetY}worldToDataX(e){return(e-this.viewOffsetX-this.fieldTransform.offsetX)/this.fieldTransform.scale}worldToDataY(e){return(e-this.fieldTransform.offsetY)/this.fieldTransform.scale}buildSpatialGrid(){if(this.grid.clear(),!this.fieldData||this.fieldData.length===0)return;let e=this.fieldData[0].x,t=this.fieldData[0].x,n=this.fieldData[0].y,r=this.fieldData[0].y;for(let i of this.fieldData)i.x<e&&(e=i.x),i.x>t&&(t=i.x),i.y<n&&(n=i.y),i.y>r&&(r=i.y);let i=(t-e+(r-n))/2,a=Math.ceil(Math.sqrt(this.fieldData.length));this.gridCellSize=Math.max(.01,i/a);for(let e of this.fieldData){let t=this.getGridKey(e.x,e.y),n=this.grid.get(t);n?n.push(e):this.grid.set(t,[e])}}getGridKey(e,t){return`${Math.floor(e/this.gridCellSize)},${Math.floor(t/this.gridCellSize)}`}sampleField(e,t,n){if(!this.hasFieldData())return{x:1,y:0};let r=this.worldToDataX(e),i=this.worldToDataY(t),a=this.findNearestFieldPoint(r,i);return a?{x:a.dx*this.fieldTransform.scale,y:a.dy*this.fieldTransform.scale}:null}findNearestFieldPoint(e,t){let n=null,r=Number.MAX_VALUE,i=(this.params.fieldValidDistance/this.fieldTransform.scale)**2,a=Math.floor(e/this.gridCellSize),o=Math.floor(t/this.gridCellSize);for(let i=-1;i<=1;i+=1)for(let s=-1;s<=1;s+=1){let c=this.grid.get(`${a+i},${o+s}`);if(c)for(let i of c){let a=i.x-e,o=i.y-t,s=a*a+o*o;s<r&&(r=s,n=i)}}return r<=i?n:null}applyColor(e,t,n,r){let i=Math.min(1,Math.max(0,t));if(n.key===`luminous-violet`){r?(this.colors[e]=.6*i,this.colors[e+1]=.25*i,this.colors[e+2]=.9*i):(this.colors[e]=.35+i*.9,this.colors[e+1]=.18+i*.45,this.colors[e+2]=.6+i*.35);return}let a=r?i:.35+i*.65,[o,s,c]=n.rgb;this.colors[e]=o*a,this.colors[e+1]=s*a,this.colors[e+2]=c*a}getActiveColorPreset(){let e=this.paletteKey();return Ec.find(t=>t.key===e)??Ec[0]}randomRange(e,t){return e+Math.random()*(t-e)}updateBuffers(){this.geometry.attributes.position.needsUpdate=!0,this.geometry.attributes.color.needsUpdate=!0,this.geometry.computeBoundingSphere()}},Ac=class{fieldStatusEl=null;onFieldLoaded;constructor(e){this.onFieldLoaded=e}setStatusElement(e){this.fieldStatusEl=e}async load(){try{let e=await fetch(`/vector-field.json`,{cache:`no-store`});if(!e.ok){this.updateStatus(`default (built-in)`);return}let t=await e.json();if(Array.isArray(t)&&t.length>0){let{transform:e,bounds:n}=this.computeFieldTransform(t),r=`loaded ${t.length} vectors (${n.width.toFixed(1)}×${n.height.toFixed(1)})`;this.onFieldLoaded(t,e,r),this.updateStatus(r),console.log(`Field bounds:`,n,`scale:`,e.scale)}else this.updateStatus(`default (empty file)`)}catch(e){console.error(`Failed to load vector field`,e),this.updateStatus(`default (load error)`)}}computeFieldTransform(e){let t=e[0].x,n=e[0].x,r=e[0].y,i=e[0].y;for(let a of e)a.x<t&&(t=a.x),a.x>n&&(n=a.x),a.y<r&&(r=a.y),a.y>i&&(i=a.y);let a=n-t,o=i-r,s=Math.max(a,o);Tc*1.8;let c=s>0?2.25/s:1;return{transform:{scale:c,offsetX:-(t+n)*.5*c,offsetY:-(r+i)*.5*c},bounds:{minX:t,maxX:n,minY:r,maxY:i,width:a,height:o}}}updateStatus(e){this.fieldStatusEl&&(this.fieldStatusEl.textContent=e)}},jc=class{panel;content;controlHandles=new Map;selectHandles=new Map;trailToggle;fieldButtons={left:null,right:null};fieldStatuses={left:null,right:null};advancedSection;advancedToggle;container;params;material;bloomPass;callbacks;constructor(e,t,n,r,i){this.container=e,this.params=t,this.material=n,this.bloomPass=r,this.callbacks=i}create(){this.panel=document.createElement(`div`),this.panel.className=`controls`;let e=document.createElement(`div`);e.className=`controls__header`;let t=document.createElement(`div`);t.className=`controls__title`,t.textContent=`Controls`;let n=document.createElement(`button`);n.className=`controls__toggle`,n.textContent=`−`,n.type=`button`,n.addEventListener(`click`,()=>{this.panel.classList.toggle(`controls--collapsed`),n.textContent=this.panel.classList.contains(`controls--collapsed`)?`+`:`−`,window.dispatchEvent(new Event(`resize`))}),e.appendChild(t),e.appendChild(n),this.panel.appendChild(e),this.content=document.createElement(`div`),this.content.className=`controls__body`,this.panel.appendChild(this.content);let r=this.addSelect(`Field A color`,Ec,this.params.colorPresetA,e=>{this.params.colorPresetA=e,this.callbacks.onColorChange()}),i=this.addSelect(`Field B color`,Ec,this.params.colorPresetB,e=>{this.params.colorPresetB=e,this.callbacks.onColorChange()});this.selectHandles.set(`colorA`,r),this.selectHandles.set(`colorB`,i);let a=this.addSlider(this.content,`Speed`,.1,8,.1,this.params.speed,e=>{this.params.speed=e});this.controlHandles.set(`speed`,a);let o=document.createElement(`button`);o.type=`button`,o.className=`controls__button`,o.textContent=`Show advanced`,this.advancedToggle=o;let s=document.createElement(`div`);s.className=`controls__advanced`,s.style.display=`none`,this.advancedSection=s;let c=this.addSlider(s,`Noise`,0,1,.01,this.params.noiseStrength,e=>{this.params.noiseStrength=e});this.controlHandles.set(`noiseStrength`,c);let l=this.addSlider(s,`Size`,.5,4,.1,this.params.size,e=>{this.params.size=e,this.material.size=e});this.controlHandles.set(`size`,l);let u=this.addSlider(s,`Particle count`,100,8e3,100,this.params.particleCount,e=>{this.params.particleCount=Math.round(e),this.callbacks.onParticleCountChange(this.params.particleCount)});this.controlHandles.set(`particleCount`,u);let d=this.addSlider(s,`Bloom strength`,.2,2.5,.05,this.params.bloomStrength,e=>{this.params.bloomStrength=e,this.updateBloom()});this.controlHandles.set(`bloomStrength`,d);let f=this.addSlider(s,`Bloom radius`,0,1.2,.02,this.params.bloomRadius,e=>{this.params.bloomRadius=e,this.updateBloom()});this.controlHandles.set(`bloomRadius`,f);let p=this.addSlider(s,`Life min (s)`,.1,2,.05,this.params.lifeMin,e=>{if(this.params.lifeMin=e,this.params.lifeMin>this.params.lifeMax){this.params.lifeMax=e;let t=this.controlHandles.get(`lifeMax`);t&&this.syncSlider(t,this.params.lifeMax)}this.callbacks.onLifetimeChange()});this.controlHandles.set(`lifeMin`,p);let m=this.addSlider(s,`Life max (s)`,.2,5,.05,this.params.lifeMax,e=>{if(this.params.lifeMax=e,this.params.lifeMax<this.params.lifeMin){this.params.lifeMin=e;let t=this.controlHandles.get(`lifeMin`);t&&this.syncSlider(t,this.params.lifeMin)}this.callbacks.onLifetimeChange()});this.controlHandles.set(`lifeMax`,m);let h=this.addSlider(s,`Field border`,.01,.1,.01,this.params.fieldValidDistance,e=>{this.params.fieldValidDistance=e});this.controlHandles.set(`fieldDist`,h),this.trailToggle=this.addToggle(s,`Trails`,this.params.trailsEnabled,e=>{this.params.trailsEnabled=e,this.callbacks.onTrailToggle(e)});let g=this.addSlider(s,`Trail decay`,.7,.99,.005,this.params.trailDecay,e=>{this.params.trailDecay=e,this.callbacks.onTrailDecayChange(e)});this.controlHandles.set(`trailDecay`,g),o.addEventListener(`click`,()=>{let e=s.style.display===`none`;s.style.display=e?`block`:`none`,o.textContent=e?`Hide advanced`:`Show advanced`}),this.content.appendChild(o),this.content.appendChild(s);let _=document.createElement(`button`);_.type=`button`,_.className=`controls__button`,_.textContent=`Reset to defaults`,_.addEventListener(`click`,()=>this.reset()),this.content.appendChild(_);let v=document.createElement(`div`);v.className=`controls__section`;let y=document.createElement(`div`);y.className=`controls__subtitle`,y.textContent=`Fields`,v.appendChild(y);let b=this.buildFieldRow(`Field A`,`left`,()=>this.callbacks.onClearFieldA());v.appendChild(b);let x=this.buildFieldRow(`Field B`,`right`,()=>this.callbacks.onClearFieldB());v.appendChild(x),this.content.appendChild(v),this.container.appendChild(this.panel)}setFieldState(e,t){let n=this.fieldButtons[e],r=this.fieldStatuses[e];!n||!r||(n.textContent=t.loaded?`Clear`:`Empty`,n.disabled=!t.loaded,n.classList.toggle(`controls__button--empty`,!t.loaded),r.textContent=t.label)}syncFieldValidDistance(e){this.params.fieldValidDistance=e;let t=this.controlHandles.get(`fieldDist`);t&&this.syncSlider(t,e)}reset(){Object.assign(this.params,Oc),this.material.size=this.params.size,this.updateBloom(),this.callbacks.onParticleCountChange(this.params.particleCount),this.callbacks.onLifetimeChange(),this.callbacks.onTrailToggle(this.params.trailsEnabled),this.callbacks.onTrailDecayChange(this.params.trailDecay),this.callbacks.onColorChange();for(let[e,t]of this.controlHandles.entries()){let n=e;typeof this.params[n]==`number`&&this.syncSlider(t,this.params[n])}for(let[e,t]of this.selectHandles.entries())e===`colorA`&&(t.value=this.params.colorPresetA),e===`colorB`&&(t.value=this.params.colorPresetB);this.trailToggle&&(this.trailToggle.checked=this.params.trailsEnabled),this.advancedSection&&this.advancedToggle&&(this.advancedSection.style.display=`none`,this.advancedToggle.textContent=`Show advanced`)}addToggle(e,t,n,r){let i=document.createElement(`label`);i.className=`controls__row`;let a=document.createElement(`span`);a.textContent=t;let o=document.createElement(`input`);return o.type=`checkbox`,o.checked=n,o.addEventListener(`change`,e=>{let t=e.target.checked;r(t)}),i.appendChild(a),i.appendChild(o),e.appendChild(i),o}addSlider(e,t,n,r,i,a,o){let s=document.createElement(`label`);s.className=`controls__row`;let c=document.createElement(`span`);c.textContent=t;let l=document.createElement(`input`);l.type=`range`,l.min=String(n),l.max=String(r),l.step=String(i),l.value=String(a);let u=document.createElement(`span`);return u.className=`controls__value`,u.textContent=this.formatValue(a,i),l.addEventListener(`input`,e=>{let t=parseFloat(e.target.value);u.textContent=this.formatValue(t,i),o(t)}),s.appendChild(c),s.appendChild(l),s.appendChild(u),e.appendChild(s),{input:l,valueTag:u}}addSelect(e,t,n,r){let i=document.createElement(`label`);i.className=`controls__row`;let a=document.createElement(`span`);a.textContent=e;let o=document.createElement(`select`);o.className=`controls__select`;for(let e of t){let t=document.createElement(`option`);t.value=e.key,t.textContent=e.label,o.appendChild(t)}return o.value=n,o.addEventListener(`change`,e=>{let t=e.target.value;r(t)}),i.appendChild(a),i.appendChild(o),this.content.appendChild(i),o}buildFieldRow(e,t,n){let r=document.createElement(`div`);r.className=`controls__row controls__row--field`;let i=document.createElement(`div`);i.className=`controls__field-meta`;let a=document.createElement(`div`);a.className=`controls__field-label`,a.textContent=e;let o=document.createElement(`span`);o.className=`controls__field-status`,o.textContent=`Empty`,i.appendChild(a),i.appendChild(o);let s=document.createElement(`button`);return s.type=`button`,s.className=`controls__button controls__button--empty`,s.textContent=`Empty`,s.disabled=!0,s.addEventListener(`click`,()=>n()),this.fieldButtons[t]=s,this.fieldStatuses[t]=o,r.appendChild(i),r.appendChild(s),r}updateBloom(){this.bloomPass.strength=this.params.bloomStrength,this.bloomPass.radius=this.params.bloomRadius}formatValue(e,t){return t>=1?e.toFixed(0):e.toFixed(2)}syncSlider(e,t){e.input.value=String(t),e.valueTag.textContent=this.formatValue(t,parseFloat(e.input.step))}},Mc=class{mediaRecorder=null;recordedChunks=[];isRecording=!1;recordingStartTime=0;recordingDuration=5;recordingResolution=`current`;originalCanvasSize=null;recordButton=null;recordStatus=null;renderer;composer;bloomPass;onResize;setRenderSize(e,t){this.renderer.setSize(e,t,!1),this.composer.setSize(e,t),this.bloomPass.setSize(e,t)}restoreRenderSize(){if(!this.originalCanvasSize)return;let{width:e,height:t}=this.originalCanvasSize;this.setRenderSize(e,t),this.originalCanvasSize=null,this.onResize()}constructor(e,t,n,r){this.renderer=e,this.composer=t,this.bloomPass=n,this.onResize=r}createControls(e){let t=document.createElement(`div`);t.className=`controls__section`,t.innerHTML=`<div class="controls__subtitle">Export (WebM)</div>`;let n=document.createElement(`label`);n.className=`controls__row`;let r=document.createElement(`span`);r.textContent=`Resolution`;let i=document.createElement(`select`);i.className=`controls__select`,i.innerHTML=`<option value="current">Current window</option><option value="1080p">1080p (Full HD)</option><option value="1440p">1440p (2K)</option><option value="4k">4K (Ultra HD)</option>`,i.value=this.recordingResolution,i.addEventListener(`change`,e=>{this.recordingResolution=e.target.value}),n.appendChild(r),n.appendChild(i),t.appendChild(n);let a=document.createElement(`label`);a.className=`controls__row`;let o=document.createElement(`span`);o.textContent=`Duration`;let s=document.createElement(`select`);s.className=`controls__select`,s.innerHTML=`<option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option><option value="15">15 seconds</option>`,s.value=String(this.recordingDuration),s.addEventListener(`change`,e=>{this.recordingDuration=parseInt(e.target.value)}),a.appendChild(o),a.appendChild(s),t.appendChild(a),this.recordButton=document.createElement(`button`),this.recordButton.type=`button`,this.recordButton.className=`controls__button controls__button--record`,this.recordButton.textContent=`⏺ Start recording`,this.recordButton.addEventListener(`click`,()=>{this.isRecording?this.stop():this.start()}),t.appendChild(this.recordButton),this.recordStatus=document.createElement(`div`),this.recordStatus.className=`controls__status`,this.recordStatus.style.display=`none`,t.appendChild(this.recordStatus),e.appendChild(t)}update(){if(this.isRecording){let e=(performance.now()-this.recordingStartTime)/1e3;this.updateStatus(e),e>=this.recordingDuration&&this.stop()}}start(){if(!this.isRecording)try{let e=this.renderer.domElement,t,n,r=e.width/e.height;if(this.recordingResolution===`current`)t=e.width,n=e.height;else{switch(this.originalCanvasSize={width:e.width,height:e.height},this.recordingResolution){case`1080p`:n=1080,t=Math.round(n*r);break;case`1440p`:n=1440,t=Math.round(n*r);break;case`4k`:n=2160,t=Math.round(n*r);break}this.setRenderSize(t,n)}let i=e.captureStream(60),a=t*n,o=Math.min(25e6,Math.max(8e6,a*4));this.recordedChunks=[],this.mediaRecorder=new MediaRecorder(i,{mimeType:`video/webm;codecs=vp9`,videoBitsPerSecond:o}),this.mediaRecorder.ondataavailable=e=>{e.data.size>0&&this.recordedChunks.push(e.data)},this.mediaRecorder.onstop=()=>{this.restoreRenderSize();let e=new Blob(this.recordedChunks,{type:`video/webm`}),r=URL.createObjectURL(e),i=document.createElement(`a`);i.href=r,i.download=`luminar-${t}x${n}-${Date.now()}.webm`,i.click(),URL.revokeObjectURL(r),this.recordStatus&&(this.recordStatus.textContent=`Recording complete! Download started.`,setTimeout(()=>{this.recordStatus&&(this.recordStatus.style.display=`none`)},3e3))},this.mediaRecorder.start(),this.isRecording=!0,this.recordingStartTime=performance.now(),this.recordButton&&(this.recordButton.textContent=`⏹ Stop recording`,this.recordButton.style.opacity=`1`),this.recordStatus&&(this.recordStatus.style.display=`block`,this.recordStatus.textContent=`Recording at ${t}x${n} (${(o/1e6).toFixed(0)} Mbps): 0.0s / ${this.recordingDuration}s`)}catch(e){console.error(`Failed to start recording:`,e),this.restoreRenderSize(),this.recordStatus&&(this.recordStatus.style.display=`block`,this.recordStatus.textContent=`Recording not supported in this browser.`)}}stop(){!this.isRecording||!this.mediaRecorder||(this.isRecording=!1,this.mediaRecorder.stop(),this.mediaRecorder=null,this.recordButton&&(this.recordButton.textContent=`▶ Start recording`,this.recordButton.style.opacity=`1`))}updateStatus(e){if(this.recordStatus){let t=e.toFixed(1),n=this.renderer.domElement,r=Math.min(25e6,Math.max(8e6,n.width*n.height*4));this.recordStatus.textContent=`Recording at ${n.width}x${n.height} (${(r/1e6).toFixed(0)} Mbps): ${t}s / ${this.recordingDuration}s`}}};function Nc(e){let t=e.split(/\r?\n/).filter(Boolean),n=[],r=!1;for(let e of t){let t=e.split(/[,\s]+/).filter(Boolean);if(t.length<4)continue;let[i,a,o,s]=t.map(Number);if([i,a,o,s].some(e=>Number.isNaN(e))){!r&&n.length===0&&(r=!0,console.log(`skipping header line:`,e.substring(0,60)));continue}n.push({x:i,y:a,dx:o,dy:s})}return n}var Pc=document.querySelector(`#app`);if(!Pc)throw Error(`Missing #app container`);var Fc={...Oc},Ic=1.8,Lc=new lc({antialias:!1,alpha:!0});Lc.setPixelRatio(Math.min(window.devicePixelRatio,2)),Pc.appendChild(Lc.domElement);var Rc=new Gr;Rc.background=new Z(132106);var zc=new Ai(-1,1,1,-1,.1,10);zc.position.z=2;var Bc=new vc(Lc),Vc=new yc(Rc,zc),Hc=new xc(new q(1,1),Fc.bloomStrength,.82,Fc.bloomRadius),Uc=new Cc(Fc.trailDecay);Bc.addPass(Vc),Bc.addPass(Hc),Bc.addPass(Uc),Uc.enabled=Fc.trailsEnabled,Uc.uniforms.damp.value=Fc.trailDecay;var Wc=new cr,Gc=new cr,Kc=new ti({size:Fc.size,sizeAttenuation:!0,vertexColors:!0,transparent:!0,opacity:.9,blending:2,depthWrite:!1}),qc=new oi(Wc,Kc),Jc=new oi(Gc,Kc);Rc.add(qc),Rc.add(Jc);var Yc=new kc(Wc,Fc,()=>Fc.colorPresetA),Xc=new kc(Gc,Fc,()=>Fc.colorPresetB);Yc.init(),Xc.init();var Zc=!1,Qc=!1,$c=new Ac((e,t,n)=>il(`left`,e,t,n)),el=new Ac((e,t,n)=>il(`right`,e,t,n)),tl=new jc(Pc,Fc,Kc,Hc,{onParticleCountChange:e=>{Yc.resizeBuffers(e),Xc.resizeBuffers(e)},onLifetimeChange:()=>{Yc.reseedLifetimes(),Xc.reseedLifetimes()},onTrailToggle:e=>fl(e,Fc.trailDecay),onTrailDecayChange:e=>fl(Fc.trailsEnabled,e),onClearFieldA:()=>ll(`left`),onClearFieldB:()=>ll(`right`),onColorChange:()=>nl()});function nl(){ml=Ec.find(e=>e.key===Fc.colorPresetA)??Ec[0],hl=Ec.find(e=>e.key===Fc.colorPresetB)??Ec[0]}function rl(e){return e===`left`?{system:Yc,loader:$c}:{system:Xc,loader:el}}function il(e,t,n,r){let{system:i,loader:a}=rl(e);i.setFieldData(t,n);let o=i.hasFieldData();e===`left`?Zc=o:Qc=o,tl.setFieldState(e,{loaded:o,label:r}),a.updateStatus(r),dl()}var al=new Mc(Lc,Bc,Hc,ul);function ol(){if(!Pc)return;let e=document.createElement(`div`);e.className=`hud`,e.innerHTML=`<div class="title">luminar</div><div class="subtitle">2D vector field bloom study</div><div class="status">Field A: <span id="field-status-a">default (built-in)</span> · Field B: <span id="field-status-b">default (built-in)</span></div>`,Pc.appendChild(e),$c.setStatusElement(document.getElementById(`field-status-a`)),el.setStatusElement(document.getElementById(`field-status-b`))}function sl(){if(!Pc)return;let e=document.createElement(`div`);e.className=`drop-overlay drop-overlay--left`,e.textContent=`Drop to load Field A (left)`,e.style.display=`none`,Pc.appendChild(e);let t=document.createElement(`div`);t.className=`drop-overlay drop-overlay--right`,t.textContent=`Drop to load Field B (right)`,t.style.display=`none`,Pc.appendChild(t);let n=n=>{n===`left`?(e.style.display=`flex`,t.style.display=`none`):(e.style.display=`none`,t.style.display=`flex`)},r=()=>{e.style.display=`none`,t.style.display=`none`},i=async(e,t)=>{try{let n=Nc(await e.text());if(!n.length){(t===`left`?$c:el).updateStatus(`CSV empty or invalid`),tl.setFieldState(t,{loaded:!1,label:`CSV empty or invalid`}),r();return}let{transform:i,bounds:a}=(t===`left`?$c:el).computeFieldTransform(n);il(t,n,i,`${e.name} · ${n.length} vectors (${a.width.toFixed(1)}×${a.height.toFixed(1)})`)}catch(e){console.error(`Failed to load dropped CSV`,e),(t===`left`?$c:el).updateStatus(`CSV load error`),tl.setFieldState(t,{loaded:!1,label:`CSV load error`})}finally{r()}};window.addEventListener(`dragover`,e=>{e.preventDefault(),n(e.clientX<window.innerWidth*.5?`left`:`right`)}),window.addEventListener(`dragleave`,e=>{e.preventDefault(),r()}),window.addEventListener(`drop`,e=>{e.preventDefault();let t=e.dataTransfer;if(t&&t.files&&t.files[0]){let n=e.clientX<window.innerWidth*.5?`left`:`right`;i(t.files[0],n)}})}function cl(){let e=Ic*(window.innerWidth/window.innerHeight)*2,t=e*.45/2,n=(e-258/window.innerWidth*e-.5)/2,r=Math.min(1.4,n);return Math.max(1,Math.min(t,r))}function ll(e){let t={scale:1,offsetX:0,offsetY:0},{system:n,loader:r}=rl(e);n.reseedLifetimes(),il(e,null,t,`Empty`),r.updateStatus(`default (cleared)`)}function ul(){let e=window.innerWidth,t=window.innerHeight,n=e/t;zc.left=-Ic*n,zc.right=Ic*n,zc.top=Ic,zc.bottom=-Ic,zc.updateProjectionMatrix(),Lc.setSize(e,t,!1),Bc.setSize(e,t),Hc.setSize(e,t),dl()}function dl(){let e=(Zc?1:0)+(Qc?1:0),t=cl();e===2?(qc.visible=!0,Jc.visible=!0,Yc.setViewOffset(-t),Xc.setViewOffset(t)):e===1?Zc?(qc.visible=!0,Jc.visible=!1,Yc.setViewOffset(0)):(qc.visible=!1,Jc.visible=!0,Xc.setViewOffset(0)):(qc.visible=!0,Jc.visible=!1,Yc.setViewOffset(0),Xc.setViewOffset(0))}function fl(e,t){Fc.trailsEnabled=e,Fc.trailDecay=t,Uc.enabled=e,Uc.uniforms.damp.value=t}var pl=performance.now(),ml=Ec.find(e=>e.key===Fc.colorPresetA)??Ec[0],hl=Ec.find(e=>e.key===Fc.colorPresetB)??Ec[0];function gl(e){let t=e||performance.now(),n=Math.min(.033,(t-pl)/1e3);pl=t,Yc.update(n,ml),Xc.update(n,hl),Bc.render(),al.update(),requestAnimationFrame(gl)}ol(),tl.create(),al.createControls(Pc.querySelector(`.controls__body`)),ul(),window.addEventListener(`resize`,ul),$c.load(),el.load(),sl(),gl(0);
@@ -0,0 +1 @@
1
+ @import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap";:root{color:#e8f0ff;background-color:#02040a;font-family:Space Grotesk,Segoe UI,system-ui,sans-serif}*{box-sizing:border-box}body{background:radial-gradient(circle at 20% 20%,#508cff1f,#0000 35%),radial-gradient(circle at 80% 10%,#b464ff1f,#0000 30%),radial-gradient(circle at 50% 80%,#3cc8b424,#0000 32%),#02040a;min-height:100vh;margin:0;overflow:hidden}#app{position:fixed;inset:0;overflow:hidden}canvas{filter:saturate(1.05);width:100%;height:100%;display:block}.hud{color:#dfe8ff;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;mix-blend-mode:screen;text-shadow:0 0 12px #6eaaff4d;position:absolute;top:18px;left:18px}.hud .title{font-size:16px;font-weight:600}.hud .subtitle{opacity:.7;letter-spacing:.04em;margin-top:4px;font-size:12px;font-weight:400}.hud .status{opacity:.8;letter-spacing:.03em;color:#9fb7ff;margin-top:6px;font-size:11px;font-weight:400}.controls{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#dfe8ff;pointer-events:auto;background:#060a16d1;border:1px solid #78b4ff40;border-radius:12px;width:240px;max-height:calc(100vh - 36px);padding:14px 14px 12px;position:absolute;top:18px;right:18px;overflow:hidden;box-shadow:0 12px 30px #00000059,0 0 24px #64a0ff1f}.controls__title{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;margin-bottom:10px;font-size:12px}.controls__header{z-index:1;justify-content:space-between;align-items:baseline;margin-bottom:12px;padding:2px 0 6px;display:flex;position:sticky;top:0}.controls__body{max-height:calc(100vh - 72px);padding-bottom:30px;padding-right:2px;overflow-y:auto}.controls__section:last-child{margin-bottom:8px}.controls__toggle{color:#9fb7ff;cursor:pointer;background:linear-gradient(120deg,#78b4ff1f,#5078c814);border:1px solid #78b4ff4d;border-radius:6px;margin:0 0 0 8px;padding:6px 10px;font-size:14px;font-weight:600;line-height:1;transition:all .12s;display:inline-block}.controls__toggle:hover{color:#dfe8ff;background:linear-gradient(120deg,#78b4ff2e,#5078c81f);border-color:#8cc8ff99;box-shadow:0 0 12px #78b4ff33}.controls__toggle:active{transform:translateY(1px)}.controls--collapsed{width:auto!important;padding:10px 12px!important}.controls--collapsed .controls__header{margin-bottom:0}.controls--collapsed>:not(.controls__header){display:none!important}.controls__row{color:#e8f0ff;grid-template-columns:1fr 1fr auto;align-items:center;gap:8px;margin-bottom:8px;font-size:12px;display:grid}.controls__row--field{grid-template-columns:1fr auto;gap:10px;margin-bottom:10px}.controls__field-meta{flex-direction:column;gap:3px;display:flex}.controls__field-label{color:#e8f0ff}.controls__field-status{color:#9fb7ff;opacity:.9;font-size:11px}.controls__row input[type=range]{accent-color:#7cc4ff;width:100%}.controls__value{font-variant-numeric:tabular-nums;color:#9fb7ff;text-align:right;min-width:44px}.controls__button{color:#dfe8ff;letter-spacing:.04em;cursor:pointer;background:linear-gradient(120deg,#78b4ff29,#5078c81a);border:1px solid #78b4ff59;border-radius:10px;width:100%;margin-top:6px;padding:8px 10px;font-size:12px;transition:border-color .12s,transform .12s,box-shadow .2s}.controls__button:hover{border-color:#8cc8ffb3;box-shadow:0 0 18px #78b4ff40}.controls__button:active{transform:translateY(1px)}.controls__button:disabled{cursor:not-allowed;opacity:.55;box-shadow:none;background:linear-gradient(120deg,#78b4ff0f,#5078c80a);border-color:#78b4ff2e}.controls__button--empty{background:linear-gradient(120deg,#78b4ff14,#5078c80f);border-color:#78b4ff2e}.controls__button--record{background:linear-gradient(120deg,#ff507838,#c8507824);border-color:#ff789666}.controls__button--record:hover{border-color:#ff8caacc;box-shadow:0 0 18px #ff78964d}.controls__section{border-top:1px solid #78b4ff1a;margin-top:12px;padding-top:12px}.controls__subtitle{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;opacity:.85;margin-bottom:8px;font-size:11px}.controls__status{color:#b3d4ff;text-align:center;background:#64b4ff1a;border:1px solid #78b4ff33;border-radius:6px;margin-top:8px;padding:6px 8px;font-size:11px}.controls__select{color:#dfe8ff;cursor:pointer;background:#141e3280;border:1px solid #78b4ff40;border-radius:6px;width:100%;padding:4px 6px;font-size:11px}.controls__select:hover{border-color:#8cc8ff80}.controls__advanced{margin-top:8px}.drop-overlay{color:#d7e2ff;letter-spacing:.02em;z-index:20;pointer-events:none;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#508cff33;justify-content:center;align-items:center;width:50%;font-size:18px;font-weight:600;display:flex;position:fixed;inset:0}.drop-overlay--left{border-right:3px solid #78b4ff66;left:0;right:auto}.drop-overlay--right{border-left:3px solid #78b4ff66;left:auto;right:0}
package/dist/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>luminar</title>
9
- <script type="module" crossorigin src="/assets/index-DtdKjtzr.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BTv18fJQ.css">
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.4",
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
- particleSystemA.setFieldData(data, transform)
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
- if (target === 'left') {
153
- fieldLoaderA.updateStatus('CSV empty or invalid')
154
- } else {
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
- if (target === 'left') {
163
- particleSystemA.setFieldData(rows, transform)
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
- if (target === 'left') {
174
- fieldLoaderA.updateStatus('CSV load error')
175
- } else {
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
- if (target === 'left') {
234
- particleSystemA.setFieldData(null, transform)
235
- particleSystemA.reseedLifetimes()
236
- fieldLoaderA.updateStatus('default (cleared)')
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('.controls')!)
315
+ recordingManager.createControls(container.querySelector('.controls__body')!)
322
316
  resize()
323
317
  window.addEventListener('resize', resize)
324
318
  fieldLoaderA.load()
@@ -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.panel,
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.panel.appendChild(advancedToggle)
234
- this.panel.appendChild(advancedSection)
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.panel.appendChild(resetBtn)
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 = document.createElement('div')
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 = document.createElement('div')
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.panel.appendChild(fieldSection)
270
+ this.content.appendChild(fieldSection)
279
271
 
280
272
  this.container.appendChild(this.panel)
281
273
  }
282
274
 
275
+ setFieldState(side: 'left' | 'right', state: { label: string; loaded: boolean }) {
276
+ const button = this.fieldButtons[side]
277
+ const status = this.fieldStatuses[side]
278
+ if (!button || !status) return
279
+
280
+ button.textContent = state.loaded ? 'Clear' : 'Empty'
281
+ button.disabled = !state.loaded
282
+ button.classList.toggle('controls__button--empty', !state.loaded)
283
+ status.textContent = state.label
284
+ }
285
+
283
286
  syncFieldValidDistance(value: number) {
284
287
  this.params.fieldValidDistance = value
285
288
  const handle = this.controlHandles.get('fieldDist')
@@ -318,6 +321,11 @@ export class ControlPanel {
318
321
  if (this.trailToggle) {
319
322
  this.trailToggle.checked = this.params.trailsEnabled
320
323
  }
324
+
325
+ if (this.advancedSection && this.advancedToggle) {
326
+ this.advancedSection.style.display = 'none'
327
+ this.advancedToggle.textContent = 'Show advanced'
328
+ }
321
329
  }
322
330
 
323
331
  private addToggle(parent: HTMLDivElement | HTMLElement, label: string, value: boolean, onChange: (value: boolean) => void) {
@@ -405,11 +413,41 @@ export class ControlPanel {
405
413
 
406
414
  row.appendChild(text)
407
415
  row.appendChild(select)
408
- this.panel.appendChild(row)
416
+ this.content.appendChild(row)
409
417
 
410
418
  return select
411
419
  }
412
420
 
421
+ private buildFieldRow(label: string, side: 'left' | 'right', onClear: () => void) {
422
+ const row = document.createElement('div')
423
+ row.className = 'controls__row controls__row--field'
424
+
425
+ const meta = document.createElement('div')
426
+ meta.className = 'controls__field-meta'
427
+ const title = document.createElement('div')
428
+ title.className = 'controls__field-label'
429
+ title.textContent = label
430
+ const status = document.createElement('span')
431
+ status.className = 'controls__field-status'
432
+ status.textContent = 'Empty'
433
+ meta.appendChild(title)
434
+ meta.appendChild(status)
435
+
436
+ const button = document.createElement('button')
437
+ button.type = 'button'
438
+ button.className = 'controls__button controls__button--empty'
439
+ button.textContent = 'Empty'
440
+ button.disabled = true
441
+ button.addEventListener('click', () => onClear())
442
+
443
+ this.fieldButtons[side] = button
444
+ this.fieldStatuses[side] = status
445
+
446
+ row.appendChild(meta)
447
+ row.appendChild(button)
448
+ return row
449
+ }
450
+
413
451
  private updateBloom() {
414
452
  this.bloomPass.strength = this.params.bloomStrength
415
453
  this.bloomPass.radius = this.params.bloomRadius
@@ -3,10 +3,10 @@ import { WORLD_EXTENT } from '../lib/constants'
3
3
 
4
4
  export class FieldLoader {
5
5
  private fieldStatusEl: HTMLElement | null = null
6
- private onFieldLoaded: (data: VectorDatum[], transform: FieldTransform) => void
6
+ private onFieldLoaded: (data: VectorDatum[], transform: FieldTransform, label: string) => void
7
7
 
8
8
  constructor(
9
- onFieldLoaded: (data: VectorDatum[], transform: FieldTransform) => void,
9
+ onFieldLoaded: (data: VectorDatum[], transform: FieldTransform, label: string) => void,
10
10
  ) {
11
11
  this.onFieldLoaded = onFieldLoaded
12
12
  }
@@ -25,8 +25,9 @@ export class FieldLoader {
25
25
  const data = (await res.json()) as VectorDatum[]
26
26
  if (Array.isArray(data) && data.length > 0) {
27
27
  const { transform, bounds } = this.computeFieldTransform(data)
28
- this.onFieldLoaded(data, transform)
29
- this.updateStatus(`loaded ${data.length} vectors (${bounds.width.toFixed(1)}×${bounds.height.toFixed(1)})`)
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
- return COLOR_PRESETS.find((preset) => preset.key === this.params.colorPresetA) ?? COLOR_PRESETS[0]
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) {
@@ -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.renderer.setSize(recordWidth, recordHeight, false)
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
- if (this.originalCanvasSize) {
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
- if (this.originalCanvasSize) {
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 10px;
76
- background: rgba(6, 10, 22, 0.7);
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: 10px;
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.15);
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}