One commited on
Commit
9e32425
·
verified ·
1 Parent(s): 3dc5734

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +424 -19
index.html CHANGED
@@ -1,19 +1,424 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <title>Lava Lamp</title>
6
+ <style>
7
+ html,body{margin:0;height:100%;background:#05030a;display:flex;align-items:center;justify-content:center;overflow:hidden}
8
+ canvas{width:min(480px,92vw);height:auto;aspect-ratio:480/760;border-radius:14px;background:#05030a;touch-action:none}
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <canvas id="c"></canvas>
13
+ <script>
14
+ "use strict";
15
+ // polyfill for older browsers (ctx.roundRect) so the lamp never crashes
16
+ if(!CanvasRenderingContext2D.prototype.roundRect){
17
+ CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
18
+ if(typeof r==='number')r={x:r,y:r};
19
+ else if(!r)r={x:0,y:0};
20
+ const k=0.5523,rx=r.x,ry=r.y;
21
+ this.moveTo(x+rx,y);
22
+ this.lineTo(x+w-rx,y);
23
+ this.bezierCurveTo(x+w-rx+rx*k,y,x+w,y+ry-ry*k,x+w,y+ry);
24
+ this.lineTo(x+w,y+h-ry);
25
+ this.bezierCurveTo(x+w,y+h-ry+ry*k,x+w-rx+rx*k,y+h,x+w-rx,y+h);
26
+ this.lineTo(x+rx,y+h);
27
+ this.bezierCurveTo(x+rx-rx*k,y+h,x,y+h-ry+ry*k,x,y+h-ry);
28
+ this.lineTo(x,y+ry);
29
+ this.bezierCurveTo(x,y+ry-ry*k,x+rx-rx*k,y,x+rx,y);
30
+ this.closePath();
31
+ return this;
32
+ };
33
+ }
34
+ const W=480,H=760,CX=W/2;
35
+ const canvas=document.getElementById('c');
36
+ const ctx=canvas.getContext('2d');
37
+ let DPR=1;
38
+ function fit(){DPR=Math.min(2,window.devicePixelRatio||1);canvas.width=W*DPR;canvas.height=H*DPR;ctx.setTransform(DPR,0,0,DPR,0,0);}
39
+ fit();addEventListener('resize',fit);
40
+
41
+ // ---- lamp geometry (px) ----
42
+ const LIQ_TOP=175,LIQ_BOT=632;
43
+ const MIN_HW=64,MAX_HW=122; // tube half-widths: neck vs belly
44
+ const NECK_HW=MIN_HW-10;
45
+ function halfWidth(y){ // interior half-width of the liquid column
46
+ const t=Math.max(0,Math.min(1,(y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP)));
47
+ return MIN_HW+(MAX_HW-MIN_HW)*Math.pow(Math.sin(Math.PI*t),0.9);
48
+ }
49
+ function glassHalf(y){
50
+ if(y<LIQ_TOP) return NECK_HW+4;
51
+ if(y>LIQ_BOT) return halfWidth(LIQ_BOT)+4;
52
+ return halfWidth(y)+5;
53
+ }
54
+ const liquidClip=(()=>{ // clip path for everything inside the tube
55
+ const p=new Path2D(),N=60;
56
+ for(let i=0;i<=N;i++){const y=LIQ_TOP+(LIQ_BOT-LIQ_TOP)*i/N;p[i?'lineTo':'moveTo'](CX-halfWidth(y)-3,y);}
57
+ for(let i=N;i>=0;i--){const y=LIQ_TOP+(LIQ_BOT-LIQ_TOP)*i/N;p.lineTo(CX+halfWidth(y)+3,y);}
58
+ p.closePath();return p;
59
+ })();
60
+ function glassPath(){
61
+ const p=new Path2D(),y0=126,y1=650,pts=[];
62
+ for(let y=y0;y<=y1;y+=4)pts.push(y);
63
+ p.moveTo(CX-glassHalf(pts[0]),pts[0]);
64
+ for(const y of pts)p.lineTo(CX-glassHalf(y),y);
65
+ for(let i=pts.length-1;i>=0;i--)p.lineTo(CX+glassHalf(pts[i]),pts[i]);
66
+ p.closePath();return p;
67
+ }
68
+ const glass=glassPath();
69
+
70
+ // ---- blobs ----
71
+ const blobs=[];
72
+ function spawn(x,y,r,temp){blobs.push({x,y,vx:0,vy:0,r,temp,seed:Math.random()*1000});}
73
+ const startT=[0.92,0.85,0.35,0.22,0.62,0.75];
74
+ for(let i=0;i<6;i++){
75
+ const y=LIQ_TOP+60+Math.random()*(LIQ_BOT-LIQ_TOP-130);
76
+ let r=34+Math.random()*26;
77
+ r=Math.min(r,Math.max(30,halfWidth(y)-14));
78
+ spawn(CX+(Math.random()*2-1)*(halfWidth(y)-r-8),y,r,startT[i]);
79
+ }
80
+
81
+ // ---- bubbles (tiny, for life) ----
82
+ const bubbles=[];for(let i=0;i<14;i++)bubbles.push({x:Math.random(),y:Math.random(),s:0.5+Math.random(),r:0.8+Math.random()*1.6,ph:Math.random()*7});
83
+
84
+ // ---- wax amount (unlabeled slider on the base, just under the LED) ----
85
+ // volume multiplier 0.25x..4x; middle = as-is. Radius scales as cbrt(volume).
86
+ let WAXR=1;
87
+ const SL={x:CX,y:728,w:156}; // slider track geometry (canvas px)
88
+ let slF=0.5,slHover=false,slDrag=false;
89
+ function setWaxFromX(px){
90
+ slF=Math.max(0,Math.min(1,(px-SL.x+SL.w/2)/SL.w));
91
+ WAXR=Math.cbrt(Math.pow(2,4*slF-2)); // f: 0→0.25x, 0.5→1x, 1→4x volume
92
+ }
93
+ function overSlider(p){return Math.abs(p.x-SL.x)<SL.w/2+16&&Math.abs(p.y-SL.y)<18;}
94
+ function canvasXY(e){const r=canvas.getBoundingClientRect();return{x:(e.clientX-r.left)*W/r.width,y:(e.clientY-r.top)*H/r.height};}
95
+ canvas.addEventListener('pointerdown',e=>{const p=canvasXY(e);if(overSlider(p)){slDrag=true;try{canvas.setPointerCapture(e.pointerId);}catch(_){}setWaxFromX(p.x);e.preventDefault();}});
96
+ canvas.addEventListener('pointermove',e=>{const p=canvasXY(e);slHover=overSlider(p);if(slDrag)setWaxFromX(p.x);});
97
+ const endSlDrag=e=>{if(slDrag){slDrag=false;try{canvas.releasePointerCapture(e.pointerId);}catch(_){}}};
98
+ canvas.addEventListener('pointerup',endSlDrag);
99
+ canvas.addEventListener('pointercancel',endSlDrag);
100
+
101
+ // ---- physics (px units) ----
102
+ let time=0,last=performance.now();
103
+ const SIM_SPEED=0.55; // slow, heavy wax (scales all motion + transitions)
104
+ const easeIO=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
105
+ const lerp=(a,b,t)=>a+(b-a)*t;
106
+ const ABS_DUR=1.6,BUD_DUR=2.6; // merge/split transition durations (s)
107
+
108
+ function step(dt){
109
+ const midY=(LIQ_TOP+LIQ_BOT)/2;
110
+ for(const b of blobs){
111
+ if(b.childOf)continue; // absorbed child is driven below
112
+ const t=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);
113
+ const er=b.r*WAXR; // world radius at current wax amount
114
+ const botY=LIQ_BOT-er*0.9-6; // this blob's floor (size-dependent)
115
+ // heater at the bottom: strongest right on the base, so even big blobs
116
+ // resting on their own floor get fully heated; cooler air up top
117
+ if(b.y>botY-38) b.temp+=dt*0.5*(b.y-(botY-38))/38;
118
+ else b.temp-=dt*(0.045+(t<0.3?0.10:0));
119
+ b.temp=Math.max(0,Math.min(1,b.temp));
120
+ // buoyancy: screen y grows downward, so negative ay = rising.
121
+ // hot = light (rises), cold = heavy (sinks); bigger blobs weigh more
122
+ const ay=(0.5-b.temp)*150-(b.r-42)*1.1;
123
+ const drag=1.35+(b.r-42)*0.012;
124
+ b.vy+=ay*dt;b.vy*=Math.exp(-dt*drag);
125
+ b.vx*=Math.exp(-dt*drag*0.55); // wax drifts sideways more easily than it falls
126
+ // lateral turbulence: the curved walls shear the column, so blobs at
127
+ // different heights get pushed left/right out of phase with each other
128
+ const tn=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);
129
+ const flow=Math.sin(time*0.21+tn*4.6+b.seed*0.15)*0.55
130
+ +Math.sin(time*0.13-tn*8.1+b.seed*0.40)*0.30
131
+ +Math.sin(time*0.34+tn*2.3)*0.15;
132
+ b.vx+=(flow*9+Math.sin(time*1.3+b.seed)*4)*dt*(b.r/45); // turbulence + fast wobble
133
+ const sp=Math.hypot(b.vx,b.vy);if(sp>140){b.vx*=140/sp;b.vy*=140/sp;}
134
+ b.x+=b.vx*dt;b.y+=b.vy*dt;
135
+ // walls follow the tube profile
136
+ let hw=halfWidth(b.y)-er*0.92-4;
137
+ if(hw<6){hw=6;b.vy+=(midY-b.y)*0.8*dt;} // too fat for the neck: drift to belly
138
+ const lim=Math.abs(b.x-CX);
139
+ if(lim>hw){b.x=CX+Math.sign(b.x-CX)*hw;b.vx*=-0.3;}
140
+ const topY=LIQ_TOP+er*0.9+8;
141
+ if(b.y<topY){b.y=topY;b.vy=Math.max(0,b.vy)*0.4;}
142
+ if(b.y>botY){b.y=botY;b.vy=Math.min(0,b.vy)*0.4;}
143
+ }
144
+
145
+ // --- smooth transitions: blobs never appear, vanish or split instantly ---
146
+ for(let i=blobs.length-1;i>=0;i--){
147
+ const b=blobs[i];
148
+ if(!b.abs)continue;
149
+ b.abs.t+=dt/ABS_DUR;
150
+ const k=easeIO(Math.min(1,b.abs.t));
151
+ b.r=lerp(b.abs.rFrom,b.abs.rTo,k);
152
+ b.x+=(b.abs.cx-b.x)*Math.min(1,dt*2.4);
153
+ b.y+=(b.abs.cy-b.y)*Math.min(1,dt*2.4);
154
+ const ch=b.abs.child;
155
+ ch.x+=(b.x-ch.x)*Math.min(1,dt*4.5);
156
+ ch.y+=(b.y-ch.y)*Math.min(1,dt*4.5);
157
+ ch.r=ch.r0*(1-k); // shrink to nothing (hidden)
158
+ if(b.abs.t>=1){ // fully melted: drop it
159
+ const idx=blobs.indexOf(ch);if(idx>-1)blobs.splice(idx,1);
160
+ b.abs=null;
161
+ }
162
+ }
163
+ for(const b of blobs){
164
+ if(!b.bud)continue;
165
+ const u=b.bud;
166
+ if(!blobs.includes(u.child)){b.bud=null;continue;}
167
+ u.t+=dt/BUD_DUR;
168
+ const k=easeIO(Math.min(1,u.t));
169
+ b.r=lerp(u.rFrom,u.rTo,k);
170
+ u.child.r=u.r2*lerp(0.15,1,k);
171
+ if(u.t>=1){ // free now - keep them from re-fusing mid-drift
172
+ u.child.budding=false;u.child.partner=b;b.partner=u.child;b.bud=null;
173
+ }
174
+ }
175
+
176
+ // coalesce: deeply overlapping blobs start a slow absorption (no instant splice)
177
+ for(let i=0;i<blobs.length;i++)for(let j=i+1;j<blobs.length;j++){
178
+ const a=blobs[i],c=blobs[j];
179
+ if(a.abs||a.childOf||c.abs||c.childOf||a.bud||c.bud||a.budding||c.budding)continue;
180
+ if((a.partner&&a.partner===c)||(c.partner&&c.partner===a))continue;
181
+ const dx=c.x-a.x,dy=c.y-a.y,d=Math.hypot(dx,dy)||0.001,rs=a.r+c.r;
182
+ if(d<rs*0.46){
183
+ const va=a.r**3,vc=c.r**3,v=va+vc;
184
+ let P=vc>va?c:a,S=P===a?c:a;
185
+ const wp=P===a?va:vc,ws=S===a?va:vc;
186
+ P.vx=(P.vx*wp+S.vx*ws)/v;P.vy=(P.vy*wp+S.vy*ws)/v;
187
+ P.temp=(P.temp*wp+S.temp*ws)/v;
188
+ S.childOf=P;S.r0=S.r;
189
+ P.abs={child:S,t:0,rFrom:P.r,rTo:Math.min(70,Math.cbrt(v)),
190
+ cx:(a.x*va+c.x*vc)/v,cy:(a.y*va+c.y*vc)/v};
191
+ }else if(d<rs*1.08){ // gentle cohesion pull (only when nearly touching)
192
+ const f=(1-d/(rs*1.08))*9,ux=dx/d,uy=dy/d;
193
+ a.vx+=ux*f*dt*a.r/45;a.vy+=uy*f*dt*a.r/45;
194
+ c.vx-=ux*f*dt*c.r/45;c.vy-=uy*f*dt*c.r/45;
195
+ }
196
+ }
197
+
198
+ // heavy, cold mega-blobs shed a droplet that SWELLS out of them (no instant split)
199
+ for(const b of blobs){
200
+ if(b.abs||b.childOf||b.bud)continue;
201
+ if(b.r>62&&b.temp<0.5&&Math.random()<dt*0.35){
202
+ const r2=b.r*0.48,ang=Math.random()*Math.PI*2;
203
+ const nx=b.x+Math.cos(ang)*b.r*0.72,ny=b.y+Math.sin(ang)*b.r*0.72;
204
+ if(halfWidth(ny)-r2>10&&ny>LIQ_TOP+r2+6&&ny<LIQ_BOT-r2-6){
205
+ const child={x:nx,y:ny,vx:b.vx+Math.cos(ang)*(20+Math.random()*14),vy:b.vy+Math.sin(ang)*(20+Math.random()*14)-10,r:r2*0.15,temp:b.temp,seed:Math.random()*1000,budding:true};
206
+ blobs.push(child);
207
+ b.bud={child,t:0,rFrom:b.r,rTo:Math.max(34,Math.cbrt(b.r**3-r2**3)),r2};
208
+ }
209
+ }
210
+ }
211
+
212
+ for(const b of blobs){
213
+ if(!b.partner)continue;
214
+ const p=b.partner;
215
+ if(Math.hypot(p.x-b.x,p.y-b.y)>(b.r+p.r)*1.25){b.partner=null;p.partner=null;}
216
+ }
217
+
218
+ for(const b of bubbles){b.y-=dt*(0.03+0.02*b.s);if(b.y<0){b.y=1;b.x=Math.random();}}
219
+ }
220
+
221
+ // ---- metaball rendering (gooey, temperature-tinted glow) ----
222
+ const pad=18,BW=Math.ceil(MAX_HW*2+pad*2),BH=Math.ceil(LIQ_BOT-LIQ_TOP+pad*2);
223
+ const buf=document.createElement('canvas');buf.width=BW;buf.height=BH;
224
+ const bctx=buf.getContext('2d');
225
+ const field=new Float32Array(BW*BH);
226
+ const fieldT=new Float32Array(BW*BH); // strength weighted by wax temperature
227
+ function renderBlobs(){
228
+ field.fill(0);fieldT.fill(0);
229
+ const ox=CX-MAX_HW-pad,oy=LIQ_TOP-pad;
230
+ for(const b of blobs){
231
+ const bx=b.x-ox,by=b.y-oy,rr=b.r*WAXR*1.12;
232
+ // organic undulation: low-frequency noise on the surface so blobs look
233
+ // molten and lumpy instead of perfect circles
234
+ const s1=b.seed,s2=b.seed*1.7+3.1,t1=time*1.6+s1,t2=-time*1.2+s2;
235
+ const R=rr*1.13,R2=R*R;
236
+ const x0=Math.max(0,Math.floor(bx-R)),x1=Math.min(BW-1,Math.ceil(bx+R));
237
+ const y0=Math.max(0,Math.floor(by-R)),y1=Math.min(BH-1,Math.ceil(by+R));
238
+ for(let y=y0;y<=y1;y++){
239
+ const wy1=0.07*y+t1,wy2=-0.09*y+t2;
240
+ for(let x=x0;x<=x1;x++){
241
+ const dx=x-bx,dy=y-by,q=dx*dx+dy*dy;
242
+ if(q>=R2)continue;
243
+ const w=1+0.06*Math.sin(0.11*x+wy1)+0.05*Math.sin(0.17*x+wy2);
244
+ const re=rr*w,re2=re*re;
245
+ if(q<re2){const f=1-q/re2;const idx=y*BW+x;field[idx]+=f;fieldT[idx]+=f*b.temp;}
246
+ }
247
+ }
248
+ }
249
+ const img=bctx.createImageData(BW,BH),d=img.data;
250
+ for(let i=0;i<BW*BH;i++){
251
+ const f=field[i];let r=0,g=0,bl=0,a=0;
252
+ if(f>0.52){ // molten core
253
+ const k=Math.min(1,(f-0.52)/1.15);
254
+ const t=f>0?fieldT[i]/f:0; // avg wax temperature here
255
+ r=(90+165*k)*(0.8+t*0.35);
256
+ g=(8+Math.pow(k,1.8)*150)*(0.55+t*0.6);
257
+ bl=(34-Math.pow(k,2)*14+k*k*70)*(0.6+t*0.5);
258
+ // white-hot core for very hot, deep wax (glowing from within)
259
+ if(f>1.4&&t>0.6){
260
+ const w=Math.min(1,(f-1.4)/0.8)*Math.min(1,(t-0.6)/0.35)*0.7;
261
+ r+=(255-r)*w;g+=(225-g)*w;bl+=(190-bl)*w;
262
+ }
263
+ a=255*Math.min(1,(f-0.52)/0.35);
264
+ }else if(f>0.3){ // soft red halo
265
+ const k=(f-0.3)/0.22;
266
+ r=255;g=60*k;bl=40*k;a=110*k*k;
267
+ }
268
+ d[i*4]=Math.min(255,r);d[i*4+1]=Math.min(255,g);d[i*4+2]=Math.min(255,bl);d[i*4+3]=a;
269
+ }
270
+ bctx.putImageData(img,0,0);
271
+ }
272
+
273
+ // ---- drawing helpers ----
274
+ function metalGrad(x0,y0,x1,y1,base,mid){const g=ctx.createLinearGradient(x0,y0,x1,y1);g.addColorStop(0,base);g.addColorStop(0.5,mid);g.addColorStop(1,base);return g;}
275
+ function drawBackground(){
276
+ const g=ctx.createRadialGradient(CX,H*0.42,60,CX,H*0.42,H*0.75);
277
+ g.addColorStop(0,'#181026');g.addColorStop(1,'#05030a');
278
+ ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
279
+ // warm light pool from the lamp onto the room
280
+ ctx.save();ctx.globalCompositeOperation='lighter';
281
+ const lg=ctx.createRadialGradient(CX,H-40,20,CX,H-40,H*0.62);
282
+ lg.addColorStop(0,'rgba(255,70,35,0.16)');lg.addColorStop(0.5,'rgba(255,60,30,0.05)');lg.addColorStop(1,'rgba(255,60,30,0)');
283
+ ctx.fillStyle=lg;ctx.fillRect(0,0,W,H);ctx.restore();
284
+ ctx.save();ctx.globalAlpha=0.55;ctx.fillStyle='#000';
285
+ ctx.beginPath();ctx.ellipse(CX,H-24,190,26,0,0,Math.PI*2);ctx.fill();ctx.restore();
286
+ }
287
+ function drawLED(){
288
+ ctx.save();ctx.globalCompositeOperation='lighter';
289
+ const g=ctx.createRadialGradient(CX,H-58,4,CX,H-58,150);
290
+ g.addColorStop(0,'rgba(255,60,35,0.38)');g.addColorStop(1,'rgba(255,60,35,0)');
291
+ ctx.fillStyle=g;ctx.fillRect(CX-170,H-210,340,210);ctx.restore();
292
+ }
293
+ function drawCap(){
294
+ const g=metalGrad(0,84,0,132,'#151022','#4d3c6b');
295
+ ctx.fillStyle=g;
296
+ ctx.beginPath();ctx.moveTo(CX-NECK_HW-6,127);ctx.quadraticCurveTo(CX,76,CX+NECK_HW+6,127);ctx.closePath();ctx.fill();
297
+ const rim=ctx.createLinearGradient(0,118,0,132);
298
+ rim.addColorStop(0,'#5a4880');rim.addColorStop(1,'#191226');
299
+ ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-NECK_HW-8,118,(NECK_HW+8)*2,13,6);ctx.fill();
300
+ }
301
+ function drawBase(){
302
+ const body=new Path2D();
303
+ body.moveTo(CX-MIN_HW-14,659);
304
+ body.quadraticCurveTo(CX-MAX_HW-40,H-70,CX-MAX_HW-52,H-38);
305
+ body.quadraticCurveTo(CX-MAX_HW-52,H-20,CX-MAX_HW-30,H-18);
306
+ body.lineTo(CX+MAX_HW+30,H-18);
307
+ body.quadraticCurveTo(CX+MAX_HW+52,H-20,CX+MAX_HW+52,H-38);
308
+ body.quadraticCurveTo(CX+MAX_HW+40,H-70,CX+MIN_HW+14,659);
309
+ body.closePath();
310
+ ctx.save();ctx.clip(body);
311
+ const g=ctx.createLinearGradient(0,659,0,H);g.addColorStop(0,'#3c2d58');g.addColorStop(0.35,'#241736');g.addColorStop(1,'#0c0716');
312
+ ctx.fillStyle=g;ctx.fillRect(0,650,W,H-650);
313
+ const sheen=ctx.createLinearGradient(CX-MAX_HW-52,0,CX+MAX_HW+52,0);
314
+ sheen.addColorStop(0,'rgba(255,255,255,0)');sheen.addColorStop(0.5,'rgba(255,255,255,0.13)');sheen.addColorStop(1,'rgba(255,255,255,0)');
315
+ ctx.fillStyle=sheen;ctx.fillRect(0,650,W,H-650);
316
+ ctx.restore();
317
+ const rim=ctx.createLinearGradient(0,645,0,661);
318
+ rim.addColorStop(0,'#6a5590');rim.addColorStop(1,'#171026');
319
+ ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-MIN_HW-16,645,(MIN_HW+16)*2,16,8);ctx.fill();
320
+ const pulse=0.75+0.25*Math.sin(time*2);
321
+ ctx.save();ctx.shadowColor='rgba(255,70,40,0.9)';ctx.shadowBlur=12*pulse;
322
+ ctx.fillStyle=`rgba(255,80,45,${0.85*pulse})`;ctx.beginPath();ctx.arc(CX,H-52,3.4,0,Math.PI*2);ctx.fill();ctx.restore();
323
+ }
324
+ function drawSlider(){
325
+ const kx=SL.x-SL.w/2+slF*SL.w;
326
+ ctx.save();
327
+ // groove
328
+ ctx.fillStyle='rgba(0,0,0,0.55)';
329
+ ctx.beginPath();ctx.roundRect(SL.x-SL.w/2,SL.y-1.75,SL.w,3.5,1.75);ctx.fill();
330
+ ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=1;
331
+ ctx.beginPath();ctx.roundRect(SL.x-SL.w/2+0.5,SL.y-1.3,SL.w-1,2.6,1.3);ctx.stroke();
332
+ // knob, echoing the LED above it
333
+ const r=(slDrag||slHover)?5:4;
334
+ ctx.shadowColor='rgba(255,80,40,0.9)';
335
+ ctx.shadowBlur=slDrag?15:(slHover?11:7);
336
+ const g=ctx.createRadialGradient(kx,SL.y,0.5,kx,SL.y,r+1.5);
337
+ g.addColorStop(0,'rgba(255,238,220,0.95)');
338
+ g.addColorStop(0.45,'rgba(255,130,70,0.85)');
339
+ g.addColorStop(1,'rgba(255,70,35,0)');
340
+ ctx.fillStyle=g;ctx.beginPath();ctx.arc(kx,SL.y,r+1.5,0,Math.PI*2);ctx.fill();
341
+ ctx.restore();
342
+ }
343
+ function drawLiquid(){
344
+ ctx.save();ctx.clip(liquidClip);
345
+ const g=ctx.createLinearGradient(0,LIQ_TOP,0,LIQ_BOT);
346
+ g.addColorStop(0,'#23094a');g.addColorStop(0.5,'#1b0640');g.addColorStop(1,'#12032c');
347
+ ctx.fillStyle=g;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);
348
+
349
+ // ---- bottom lamp glow: heat rising up the liquid column ----
350
+ ctx.globalCompositeOperation='lighter';
351
+ const hg=ctx.createLinearGradient(0,LIQ_BOT-190,0,LIQ_BOT+10);
352
+ hg.addColorStop(0,'rgba(255,80,45,0)');
353
+ hg.addColorStop(0.6,'rgba(255,70,40,0.10)');
354
+ hg.addColorStop(1,'rgba(255,110,60,0.38)');
355
+ ctx.fillStyle=hg;ctx.fillRect(CX-MAX_HW-8,LIQ_BOT-190,MAX_HW*2+16,200);
356
+ const led=ctx.createRadialGradient(CX,LIQ_BOT+6,4,CX,LIQ_BOT+6,150);
357
+ led.addColorStop(0,'rgba(255,150,95,0.55)');
358
+ led.addColorStop(0.35,'rgba(255,85,50,0.24)');
359
+ led.addColorStop(1,'rgba(255,60,40,0)');
360
+ ctx.fillStyle=led;ctx.fillRect(CX-170,LIQ_BOT-150,340,170);
361
+
362
+ // drifting shimmer
363
+ const sx=CX+Math.sin(time*0.4)*60;
364
+ const sh=ctx.createRadialGradient(sx,(LIQ_TOP+LIQ_BOT)/2,10,sx,(LIQ_TOP+LIQ_BOT)/2,220);
365
+ sh.addColorStop(0,'rgba(150,90,220,0.10)');sh.addColorStop(1,'rgba(150,90,220,0)');
366
+ ctx.fillStyle=sh;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);
367
+
368
+ // bubbles
369
+ ctx.globalCompositeOperation='source-over';
370
+ ctx.fillStyle='rgba(255,240,255,0.14)';
371
+ for(const b of bubbles){
372
+ const y=b.y*(LIQ_BOT-LIQ_TOP)+LIQ_TOP,hw=halfWidth(y)-6;
373
+ const x=CX+(b.x*2-1)*hw+Math.sin(time*2+b.ph)*3;
374
+ ctx.beginPath();ctx.arc(x,y,b.r,0,Math.PI*2);ctx.fill();
375
+ }
376
+
377
+ // ---- the lava: diffuse halo + glowing body ----
378
+ renderBlobs();
379
+ const bx=CX-MAX_HW-pad,by=LIQ_TOP-pad;
380
+ ctx.globalCompositeOperation='lighter';
381
+ ctx.globalAlpha=0.16;ctx.drawImage(buf,bx-24,by-24,BW+48,BH+48); // wide soft halo
382
+ ctx.globalAlpha=0.30;ctx.drawImage(buf,bx-10,by-10,BW+20,BH+20); // tighter glow
383
+ ctx.globalCompositeOperation='source-over';
384
+ ctx.globalAlpha=1;ctx.drawImage(buf,bx,by); // wax body
385
+ ctx.restore();
386
+ }
387
+ function drawGlass(){
388
+ ctx.save();
389
+ ctx.fillStyle='rgba(255,255,255,0.03)';ctx.fill(glass);
390
+ const g=ctx.createLinearGradient(CX-MAX_HW-8,0,CX+MAX_HW+8,0);
391
+ g.addColorStop(0,'rgba(255,255,255,0.02)');g.addColorStop(0.15,'rgba(255,255,255,0.22)');
392
+ g.addColorStop(0.5,'rgba(255,255,255,0.04)');g.addColorStop(0.85,'rgba(255,255,255,0.16)');g.addColorStop(1,'rgba(255,255,255,0.02)');
393
+ ctx.strokeStyle=g;ctx.lineWidth=2.4;ctx.stroke(glass);
394
+ const hl=(x,w,a)=>{const gr=ctx.createLinearGradient(x,LIQ_TOP,x,LIQ_BOT);gr.addColorStop(0,'rgba(255,255,255,0)');gr.addColorStop(0.5,`rgba(255,255,255,${a})`);gr.addColorStop(1,'rgba(255,255,255,0)');ctx.fillStyle=gr;ctx.beginPath();ctx.roundRect(x-w/2,LIQ_TOP+14,w,LIQ_BOT-LIQ_TOP-30,w/2);ctx.fill();};
395
+ hl(CX-MAX_HW*0.62,7,0.10);hl(CX+MAX_HW*0.55,4,0.05);
396
+ ctx.restore();
397
+ }
398
+
399
+ // ---- main loop ----
400
+ function frame(now){
401
+ try{
402
+ const dt=Math.max(0,Math.min(0.05,(now-last)/1000));last=now;time+=dt;
403
+ step(dt*SIM_SPEED);
404
+ drawBackground();
405
+ drawLED();
406
+ drawCap();
407
+ drawLiquid();
408
+ drawGlass();
409
+ drawBase();
410
+ drawSlider();
411
+ canvas.style.cursor=slDrag||slHover?'ew-resize':'default';
412
+ }catch(err){
413
+ const d=document.createElement('div');
414
+ d.style.cssText='position:fixed;left:50%;top:8px;transform:translateX(-50%);color:#ffb3a7;background:rgba(20,5,10,.9);padding:8px 14px;border-radius:8px;font:13px monospace;z-index:9';
415
+ d.textContent='Lava lamp error: '+err.message;
416
+ document.body.appendChild(d);
417
+ throw err;
418
+ }
419
+ requestAnimationFrame(frame);
420
+ }
421
+ requestAnimationFrame(frame);
422
+ </script>
423
+ </body>
424
+ </html>