File size: 35,612 Bytes
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dcf7da
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
839a886
 
 
 
 
 
b3b54df
 
839a886
 
 
 
 
 
b3b54df
839a886
 
b3b54df
839a886
 
 
 
 
 
 
 
 
 
 
 
b3b54df
839a886
 
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dcf7da
b3b54df
 
 
 
 
3dcf7da
b3b54df
 
 
 
 
 
 
9f42c7e
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ca4148
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f42c7e
 
 
 
 
 
9ca4148
9f42c7e
 
 
9ca4148
9f42c7e
 
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f42c7e
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4278052
 
b3b54df
 
9f42c7e
9ca4148
 
 
 
 
 
9f42c7e
 
 
4278052
b3b54df
 
 
 
 
 
 
 
9f42c7e
 
b3b54df
 
 
 
 
9ca4148
 
b3b54df
 
9ca4148
 
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ca4148
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ca4148
b3b54df
9ca4148
 
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ca4148
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
839a886
 
 
 
 
 
 
 
 
 
 
 
b3b54df
 
 
 
 
 
 
43d1530
b3b54df
43d1530
b3b54df
 
 
 
3dcf7da
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dcf7da
b3b54df
 
 
 
 
 
 
 
3dcf7da
b3b54df
 
 
 
 
 
3dcf7da
 
b3b54df
3dcf7da
 
 
b3b54df
 
3dcf7da
 
 
 
 
 
 
 
 
 
 
 
 
 
b3b54df
3dcf7da
 
 
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
839a886
 
 
 
 
 
 
 
 
 
 
 
 
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dcf7da
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dcf7da
 
 
 
 
b3b54df
3dcf7da
 
 
 
 
 
b3b54df
 
 
 
 
 
 
 
 
3dcf7da
 
 
 
 
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dcf7da
b3b54df
 
 
 
 
 
 
 
839a886
b3b54df
 
 
 
 
 
 
 
 
 
 
 
 
 
9e32425
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Lava Lamp</title>
<style>

  html,body{margin:0;height:100%;background:#05030a;display:flex;align-items:center;justify-content:center;overflow:hidden}

  canvas{width:min(480px,92vw);height:auto;aspect-ratio:480/760;border-radius:14px;background:#05030a;touch-action:none}

</style>
</head>
<body>
<canvas id="c" tabindex="0" aria-label="Animated lava lamp. Press space to toggle colour mode; use left and right arrow keys to adjust the wax amount."></canvas>
<script>

"use strict";

// polyfill for older browsers (ctx.roundRect) so the lamp never crashes

if(!CanvasRenderingContext2D.prototype.roundRect){

  CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){

    if(typeof r==='number')r={x:r,y:r};

    else if(!r)r={x:0,y:0};

    const k=0.5523,rx=r.x,ry=r.y;

    this.moveTo(x+rx,y);

    this.lineTo(x+w-rx,y);

    this.bezierCurveTo(x+w-rx+rx*k,y,x+w,y+ry-ry*k,x+w,y+ry);

    this.lineTo(x+w,y+h-ry);

    this.bezierCurveTo(x+w,y+h-ry+ry*k,x+w-rx+rx*k,y+h,x+w-rx,y+h);

    this.lineTo(x+rx,y+h);

    this.bezierCurveTo(x+rx-rx*k,y+h,x,y+h-ry+ry*k,x,y+h-ry);

    this.lineTo(x,y+ry);

    this.bezierCurveTo(x,y+ry-ry*k,x+rx-rx*k,y,x+rx,y);

    this.closePath();

    return this;

  };

}

const W=480,H=760,CX=W/2;

const canvas=document.getElementById('c');

const ctx=canvas.getContext('2d');

let DPR=1;

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);}

fit();addEventListener('resize',fit);



// ---- lamp geometry (px) ----

const LIQ_TOP=175,LIQ_BOT=632;

const MIN_HW=64,MAX_HW=122;          // tube half-widths: neck vs belly

const NECK_HW=MIN_HW-10;

function halfWidth(y){                // interior half-width of the liquid column

  const t=Math.max(0,Math.min(1,(y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP)));

  return MIN_HW+(MAX_HW-MIN_HW)*Math.pow(Math.sin(Math.PI*t),0.9);

}

function glassHalf(y){

  if(y<LIQ_TOP) return NECK_HW+4;

  if(y>LIQ_BOT) return halfWidth(LIQ_BOT)+4;

  return halfWidth(y)+5;

}

const liquidClip=(()=>{                 // clip path for everything inside the tube

  const p=new Path2D(),N=60;

  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);}

  for(let i=N;i>=0;i--){const y=LIQ_TOP+(LIQ_BOT-LIQ_TOP)*i/N;p.lineTo(CX+halfWidth(y)+3,y);}

  p.closePath();return p;

})();

function glassPath(){

  const p=new Path2D(),y0=126,y1=650,pts=[];

  for(let y=y0;y<=y1;y+=4)pts.push(y);

  p.moveTo(CX-glassHalf(pts[0]),pts[0]);

  for(const y of pts)p.lineTo(CX-glassHalf(y),y);

  for(let i=pts.length-1;i>=0;i--)p.lineTo(CX+glassHalf(pts[i]),pts[i]);

  p.closePath();return p;

}

const glass=glassPath();



// ---- blobs ----

const blobs=[];

function spawn(x,y,r,temp){blobs.push({x,y,vx:0,vy:0,r,temp,seed:Math.random()*1000});}

const startT=[0.92,0.85,0.35,0.22,0.62,0.75];

for(let i=0;i<6;i++){

  const y=LIQ_TOP+60+Math.random()*(LIQ_BOT-LIQ_TOP-130);

  let r=34+Math.random()*26;

  r=Math.min(r,Math.max(30,halfWidth(y)-14));

  spawn(CX+(Math.random()*2-1)*(halfWidth(y)-r-8),y,r,startT[i]);

}



// ---- bubbles (tiny, for life) ----

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});

// carbonation: air bubbles shed from the blobs' surfaces (purely cosmetic)

const blobBubs=[];



// ---- wax amount (unlabeled slider on the base, just under the LED) ----

// volume multiplier 0.25x..4x; middle = as-is. Radius scales as cbrt(volume).

let WAXR=1;

const SL={x:CX,y:728,w:156};                  // slider track geometry (canvas px)

let slF=0.5,slHover=false,slDrag=false;

let slMode='amount';                          // 'amount' or 'color'

let slF_amount=0.5;                           // remembered position for amount mode

let waxHue=0;                                 // 0-360, current wax hue in color mode

// Toggle switch geometry (to the right of the slider)

const TG={x:SL.x+SL.w/2+24,y:SL.y-8,w:32,h:16};

let tgHover=false;

function setWaxFromX(px){

  slF=Math.max(0,Math.min(1,(px-SL.x+SL.w/2)/SL.w));

  if(slMode==='amount'){

    slF_amount=slF;

    WAXR=Math.cbrt(Math.pow(2,4*slF-2));       // f: 0→0.25x, 0.5→1x, 1→4x volume

  }else{

    waxHue=slF*360;

  }

}

// Compute slider position from current color (for switching back to color mode)

function sliderPosFromColor(){return waxHue/360;}

function overSlider(p){return Math.abs(p.x-SL.x)<SL.w/2+16&&Math.abs(p.y-SL.y)<18;}

function overToggle(p){return p.x>TG.x&&p.x<TG.x+TG.w&&p.y>TG.y&&p.y<TG.y+TG.h;}

function toggleSliderMode(){

  slMode=slMode==='amount'?'color':'amount';

  // Restore remembered position for the target mode

  if(slMode==='amount'){

    slF=slF_amount;

    WAXR=Math.cbrt(Math.pow(2,4*slF-2));

  }else{

    // Slider reflects current color, not remembered position

    slF=sliderPosFromColor();

  }

}

function canvasXY(e){const r=canvas.getBoundingClientRect();return{x:(e.clientX-r.left)*W/r.width,y:(e.clientY-r.top)*H/r.height};}

canvas.addEventListener('pointerdown',e=>{const p=canvasXY(e);if(overLED(p)){RGB_MODE=!RGB_MODE;e.preventDefault();return;}if(overToggle(p)){toggleSliderMode();e.preventDefault();return;}if(overSlider(p)){slDrag=true;try{canvas.setPointerCapture(e.pointerId);}catch(_){}setWaxFromX(p.x);e.preventDefault();}});

canvas.addEventListener('pointermove',e=>{const p=canvasXY(e);ledHover=overLED(p);slHover=overSlider(p)&&!ledHover;tgHover=overToggle(p)&&!ledHover&&!slHover;if(slDrag)setWaxFromX(p.x);});

const endSlDrag=e=>{if(slDrag){slDrag=false;try{canvas.releasePointerCapture(e.pointerId);}catch(_){}}};

canvas.addEventListener('pointerup',endSlDrag);

canvas.addEventListener('pointercancel',endSlDrag);

// keyboard: space/enter toggles the light, arrows adjust wax (same maths as dragging)

canvas.addEventListener('keydown',e=>{

  if(e.code==='Space'||e.code==='Enter'){RGB_MODE=!RGB_MODE;e.preventDefault();}

  else if(e.code==='ArrowRight'||e.code==='ArrowUp'){setWaxFromX(SL.x-SL.w/2+Math.min(1,slF+0.06)*SL.w);e.preventDefault();}

  else if(e.code==='ArrowLeft'||e.code==='ArrowDown'){setWaxFromX(SL.x-SL.w/2+Math.max(0,slF-0.06)*SL.w);e.preventDefault();}

});



// ---- clickable LED: toggles a slow RGB colour cycle on the whole lamp ----

let RGB_MODE=false,rgbMix=0,hue=140;        // mix eases 0(default) <-> 1(full RGB)

const RM=matchMedia('(prefers-reduced-motion: reduce)').matches;   // respect OS motion setting

const HUE_SPEED=(360/16)*(RM?0.5:1);        // one full colour cycle every ~16 s (slower for reduced motion)

const DEF_L={r:255,g:90,b:45};              // the lamp's original warm colour

const LED_POS={x:CX,y:H-52};

let ledHover=false;

function overLED(p){return Math.hypot(p.x-LED_POS.x,p.y-LED_POS.y)<20;}

function hueRGB(h){                         // pleasant saturated RGB from a hue (deg)

  h=((h%360)+360)%360;const s=0.84,v=1,c=v*s,x=c*(1-Math.abs((h/60)%2-1)),m=v-c;

  let r,g,b;

  if(h<60){r=c;g=x;b=0;}else if(h<120){r=x;g=c;b=0;}

  else if(h<180){r=0;g=c;b=x;}else if(h<240){r=0;g=x;b=c;}

  else if(h<300){r=x;g=0;b=c;}else{r=c;g=0;b=x;}

  return{r:Math.round((r+m)*255),g:Math.round((g+m)*255),b:Math.round((b+m)*255)};

}

const rgba=(c,a)=>`rgba(${c.r|0},${c.g|0},${c.b|0},${a})`;

const mixWhite=(c,t)=>({r:c.r+(255-c.r)*t,g:c.g+(255-c.g)*t,b:c.b+(255-c.b)*t});

// Blend a fixed colour toward the lamp light's hue as RGB mode fades in (rgbMix 0->1)

const tint=(orig)=>{const t=0.25*rgbMix;return{r:orig.r*(1-t)+LIGHT.r*t,g:orig.g*(1-t)+LIGHT.g*t,b:orig.b*(1-t)+LIGHT.b*t};};

let LIGHT={...DEF_L},LUM_L=1;               // effective lamp colour, updated each frame



// ---- physics (px units) ----

let time=0,last=performance.now();

const SIM_SPEED=0.55*(RM?0.5:1);       // slow, heavy wax (scales all motion + transitions)

const easeIO=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;

const lerp=(a,b,t)=>a+(b-a)*t;

const ABS_DUR=1.6,BUD_DUR=2.6;                            // merge/split transition durations (s)

let bonds=[];                                              // spring bonds between stuck-together blobs



function step(dt){

  const midY=(LIQ_TOP+LIQ_BOT)/2;

  for(const b of blobs){

    if(b.splitAge!==undefined)b.splitAge+=dt;

    if(b.childOf)continue;                                // absorbed child is driven below

    const t=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);

    const er=b.r*WAXR;                                    // world radius at current wax amount

    const botY=LIQ_BOT-er*0.9-6;              // this blob's floor (size-dependent)

    // heater at the bottom: strongest right on the base, so even big blobs

    // resting on their own floor get fully heated; cooler air up top

    if(b.y>botY-38) b.temp+=dt*0.75*(b.y-(botY-38))/38;

    else            b.temp-=dt*(0.04+(t<0.3?0.15:0));

    b.temp=Math.max(0,Math.min(1,b.temp));

    // buoyancy: screen y grows downward, so negative ay = rising.

    // hot = light (rises), cold = heavy (sinks); bigger blobs weigh more

    const ay=(0.5-b.temp)*165-(b.r-42)*1.1;

    // melting: cold wax is thick/solid-ish, hot wax melts and flows freely

    const melt=b.temp;                                       // 0=cold(solid) → 1=hot(liquid)

    const drag=(1.35+(b.r-42)*0.012)*(1.35-0.7*melt);        // hot = less drag = more fluid

    b.vy+=ay*dt;b.vy*=Math.exp(-dt*drag);

    b.vx*=Math.exp(-dt*drag*0.55);                          // wax drifts sideways more easily than it falls

    // lateral turbulence: the curved walls shear the column, so blobs at

    // different heights get pushed left/right out of phase with each other

    const tn=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);

    const flow=Math.sin(time*0.21+tn*4.6+b.seed*0.15)*0.55

             +Math.sin(time*0.13-tn*8.1+b.seed*0.40)*0.30

             +Math.sin(time*0.34+tn*2.3)*0.15;

    b.vx+=(flow*9+Math.sin(time*1.3+b.seed)*4)*dt*(b.r/45)*(0.7+0.6*melt); // liquid wax rides the currents more

    const sp=Math.hypot(b.vx,b.vy);if(sp>140){b.vx*=140/sp;b.vy*=140/sp;}

    b.x+=b.vx*dt;b.y+=b.vy*dt;

    // walls follow the tube profile

    let hw=halfWidth(b.y)-er*0.92-4;

    if(hw<6){hw=6;b.vy+=(midY-b.y)*0.8*dt;}                  // too fat for the neck: drift to belly

    const lim=Math.abs(b.x-CX);

    if(lim>hw){b.x=CX+Math.sign(b.x-CX)*hw;b.vx*=-0.3;}

    const topY=LIQ_TOP+er*0.9+8;

    if(b.y<topY){b.y=topY;b.vy=Math.max(0,b.vy)*0.4;}

    if(b.y>botY){b.y=botY;b.vy=Math.min(0,b.vy)*0.4;}

  }



  // --- spring bonds: stuck-together blobs pull each other to stay close ---

  for(let i=bonds.length-1;i>=0;i--){

    const bn=bonds[i];

    if(!blobs.includes(bn.a)||!blobs.includes(bn.b)){bonds.splice(i,1);continue;}

    const dx=bn.b.x-bn.a.x,dy=bn.b.y-bn.a.y,d=Math.hypot(dx,dy)||0.001;

    // spring force: pull back toward ideal distance (they're stuck together)

    const f=(d-bn.ideal)*5.25,ux=dx/d,uy=dy/d;

    bn.a.vx+=ux*f*dt;bn.a.vy+=uy*f*dt;

    bn.b.vx-=ux*f*dt;bn.b.vy-=uy*f*dt;

    // break bond if they drift too far apart

    if(d>bn.ideal*4.5)bonds.splice(i,1);

  }



  // --- smooth transitions: blobs never appear, vanish or split instantly ---

  for(let i=blobs.length-1;i>=0;i--){

    const b=blobs[i];

    if(!b.abs)continue;

    b.abs.t+=dt/ABS_DUR;

    const k=easeIO(Math.min(1,b.abs.t));

    b.r=lerp(b.abs.rFrom,b.abs.rTo,k);

    b.x+=(b.abs.cx-b.x)*Math.min(1,dt*2.4);

    b.y+=(b.abs.cy-b.y)*Math.min(1,dt*2.4);

    const ch=b.abs.child;

    ch.x+=(b.x-ch.x)*Math.min(1,dt*4.5);

    ch.y+=(b.y-ch.y)*Math.min(1,dt*4.5);

    ch.r=ch.r0*(1-k);                                       // shrink to nothing (hidden)

    if(b.abs.t>=1){                                        // fully melted: drop it

      const idx=blobs.indexOf(ch);if(idx>-1)blobs.splice(idx,1);

      for(let bi=bonds.length-1;bi>=0;bi--)if(bonds[bi].a===ch||bonds[bi].b===ch)bonds.splice(bi,1);

      b.abs=null;

    }

  }

  for(const b of blobs){

    if(!b.bud)continue;

    const u=b.bud;

    if(!blobs.includes(u.child)){b.bud=null;continue;}

    u.t+=dt/(u.dur||BUD_DUR);

    const k=easeIO(Math.min(1,u.t));

    b.r=lerp(u.rFrom,u.rTo,k);

    u.child.r=u.r2*lerp(0.15,1,k);

    if(u.t>=1){                                            // free now - keep them from re-fusing mid-drift

      u.child.budding=false;u.child.partner=b;b.partner=u.child;b.bud=null;

      u.child.splitAge=0; // start the grace period timer

    }

  }



  // coalesce: deeply overlapping blobs start a slow absorption (no instant splice)

  for(let i=0;i<blobs.length;i++)for(let j=i+1;j<blobs.length;j++){

    const a=blobs[i],c=blobs[j];

    if(a.abs||a.childOf||c.abs||c.childOf||a.bud||c.bud||a.budding||c.budding)continue;

    // protect newly split-off blobs from immediate absorption (3s grace period)

    if((a.splitAge!==undefined&&a.splitAge<3)||(c.splitAge!==undefined&&c.splitAge<3))continue;

    // allow even tiny droplets to be absorbed so they don't float forever

    if(a.r<5||c.r<5)continue;

    if((a.partner&&a.partner===c)||(c.partner&&c.partner===a))continue;

    const dx=c.x-a.x,dy=c.y-a.y,d=Math.hypot(dx,dy)||0.001,rs=a.r+c.r;

    // small blobs that collide get stuck together (bond) instead of absorbing

    // but only if BOTH are small - large blobs are greedy and absorb small ones

    const bothSmall=a.r<35&&c.r<35;

    if(bothSmall&&!bonds.some(b=>(b.a===a&&b.b===c)||(b.a===c&&b.b===a))){

      // very small beads (r<15) get an even wider bond threshold to catch fast-moving passes

      const thresh=a.r<15||c.r<15?rs*1.3:rs*1.05;

      if(d<thresh)bonds.push({a,b:c,ideal:rs*0.6});

    }

    // skip absorption for bonded pairs (they stay as lumpy clusters)

    if(bonds.some(b=>(b.a===a&&b.b===c)||(b.a===c&&b.b===a)))continue;

    if(d<rs*0.40){

      const va=a.r**3,vc=c.r**3,v=va+vc;

      let P=vc>va?c:a,S=P===a?c:a;

      const wp=P===a?va:vc,ws=S===a?va:vc;

      P.vx=(P.vx*wp+S.vx*ws)/v;P.vy=(P.vy*wp+S.vy*ws)/v;

      P.temp=(P.temp*wp+S.temp*ws)/v;

      S.childOf=P;S.r0=S.r;

      P.abs={child:S,t:0,rFrom:P.r,rTo:Math.min(70,Math.cbrt(v)),

             cx:(a.x*va+c.x*vc)/v,cy:(a.y*va+c.y*vc)/v};

    }else if(d<rs*1.08){                                    // gentle cohesion pull (only when nearly touching)

      const f=(1-d/(rs*1.10))*7,ux=dx/d,uy=dy/d;

      a.vx+=ux*f*dt*a.r/45;a.vy+=uy*f*dt*a.r/45;

      c.vx-=ux*f*dt*c.r/45;c.vy-=uy*f*dt*c.r/45;

    }

  }



  // large blobs split into two big pieces (not strictly equal sized)

  // hotter wax is more liquid and splits more readily

  for(const b of blobs){

    if(b.abs||b.childOf||b.bud)continue;

    if(b.r>45&&Math.random()<dt*(0.8+b.temp*1.5)){

      const r2=b.r*(0.35+Math.random()*0.2),ang=Math.random()*Math.PI*2;

      const nx=b.x+Math.cos(ang)*b.r*0.72,ny=b.y+Math.sin(ang)*b.r*0.72;

      if(halfWidth(ny)-r2>10&&ny>LIQ_TOP+r2+6&&ny<LIQ_BOT-r2-6){

        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};

        blobs.push(child);

        b.bud={child,t:0,rFrom:b.r,rTo:Math.max(34,Math.cbrt(b.r**3-r2**3)),r2};

      }

    }

  }



  // boiling: a blob sitting right on the heater, hot enough, fizzes off tiny

  // droplets from its lower edge like it's hitting its bubbling point

  for(const b of blobs){

    if(b.abs||b.childOf||b.bud)continue;

    const er=b.r*WAXR;

    const botY=LIQ_BOT-er*0.9-6;              // this blob's floor (size-dependent)

    if(b.y>botY-16&&b.temp>0.45&&b.r>20&&Math.random()<dt*(b.temp-0.3)*0.3125){ // 75% fewer small beads

      const r2=b.r*(0.13+Math.random()*0.09); // small fizz droplet

      const ang=Math.PI/2+(Math.random()-0.5)*1.6;   // lower arc of the blob

      const nx=b.x+Math.cos(ang)*b.r*0.86,ny=b.y+Math.sin(ang)*b.r*0.86;

      if(halfWidth(ny)-r2>4&&ny<LIQ_BOT-r2-3){

        const child={x:nx,y:ny,vx:b.vx+Math.cos(ang)*(10+Math.random()*8),vy:-5-Math.random()*6,r:r2*0.15,temp:Math.min(1,b.temp+0.05),seed:Math.random()*1000,budding:true};

        blobs.push(child);

        b.bud={child,t:0,dur:0.9,rFrom:b.r,rTo:Math.max(18,Math.cbrt(b.r**3-r2**3)),r2};

        // burst of carbonated bubbles at the boil location

        const count=4+Math.floor(Math.random()*5);  // 4-8 bubbles for a bigger burst

        for(let i=0;i<count;i++){

          blobBubs.push({

            x:nx+(Math.random()-0.5)*6,

            y:ny+(Math.random()-0.5)*6,

            vy:-(20+Math.random()*18),

            r:0.7+Math.random()*1.8,

            ph:Math.random()*7,

            wob:2+Math.random()*2

          });

        }

      }

    }

  }



  for(const b of blobs){

    if(!b.partner)continue;

    const p=b.partner;

    if(Math.hypot(p.x-b.x,p.y-b.y)>(b.r+p.r)*1.25){b.partner=null;p.partner=null;}

  }



  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();}}



  // blobs fizz: hotter wax sheds tiny air bubbles off its surface, like

  // carbonation coming out of solution (no interaction with the blobs)

  // bigger blobs fizz more, tiny bits barely bubble

  for(const b of blobs){

    const sizeFactor=0.25+(b.r/50)*0.75; // r=10→0.4, r=30→0.7, r=50→1.0, r=80→1.25

    if(Math.random()<dt*(0.16+b.temp*0.55)*sizeFactor){ // ~1.9x more air bubbles

      const rr=b.r*WAXR;

      const ang=Math.random()*Math.PI*2;

      let x=b.x+Math.cos(ang)*rr*(1.02+Math.random()*0.1);

      const y=b.y+Math.sin(ang)*rr*(1.02+Math.random()*0.1);

      const hw=halfWidth(y)-4;                    // keep inside the glass

      if(Math.abs(x-CX)>hw)x=CX+(x>CX?hw:-hw);

      // spawn a small group of bubbles (like an air pocket bursting)

      const count=2+Math.floor(Math.random()*4);  // 2-5 bubbles

      for(let i=0;i<count;i++){

        blobBubs.push({

          x:x+(Math.random()-0.5)*3,

          y:y+(Math.random()-0.5)*3,

          vy:-(16+Math.random()*14),

          r:0.7+Math.random()*1.5,

          ph:Math.random()*7,

          wob:2+Math.random()*2

        });

      }

    }

  }

  for(let i=blobBubs.length-1;i>=0;i--){

    const q=blobBubs[i];

    q.vy-=dt*6;                                   // accelerate upward as it rises

    q.y+=q.vy*dt;

    q.x+=Math.sin(time*3+q.ph)*q.wob*dt;          // gentle sideways wiggle

    if(q.y<LIQ_TOP+4||blobBubs.length>180)blobBubs.splice(i,1);

  }

}



// ---- metaball rendering (gooey, temperature-tinted glow) ----

const pad=18,BW=Math.ceil(MAX_HW*2+pad*2),BH=Math.ceil(LIQ_BOT-LIQ_TOP+pad*2);

const buf=document.createElement('canvas');buf.width=BW;buf.height=BH;

const bctx=buf.getContext('2d');

const field=new Float32Array(BW*BH);

const fieldT=new Float32Array(BW*BH);   // strength weighted by wax temperature

function renderBlobs(){

  field.fill(0);fieldT.fill(0);

  const ox=CX-MAX_HW-pad,oy=LIQ_TOP-pad;

  for(const b of blobs){

    const bx=b.x-ox,by=b.y-oy,rr=b.r*WAXR*1.12;

    // organic undulation: low-frequency noise on the surface so blobs look

    // molten and lumpy instead of perfect circles

    const s1=b.seed,s2=b.seed*1.7+3.1,t1=time*1.6+s1,t2=-time*1.2+s2;

    const R=rr*1.13,R2=R*R;

    const x0=Math.max(0,Math.floor(bx-R)),x1=Math.min(BW-1,Math.ceil(bx+R));

    const y0=Math.max(0,Math.floor(by-R)),y1=Math.min(BH-1,Math.ceil(by+R));

    for(let y=y0;y<=y1;y++){

      const wy1=0.07*y+t1,wy2=-0.09*y+t2;

      for(let x=x0;x<=x1;x++){

        const dx=x-bx,dy=y-by,q=dx*dx+dy*dy;

        if(q>=R2)continue;

        const w=1+0.06*Math.sin(0.11*x+wy1)+0.05*Math.sin(0.17*x+wy2);

        const re=rr*w,re2=re*re;

        if(q<re2){const f=1-q/re2;const idx=y*BW+x;field[idx]+=f;fieldT[idx]+=f*b.temp;}

      }

    }

  }

  const img=bctx.createImageData(BW,BH),d=img.data;

  for(let i=0;i<BW*BH;i++){

    const f=field[i];let r=0,g=0,bl=0,a=0;

    if(f>0.52){                                              // molten core

      const k=Math.min(1,(f-0.52)/1.15);

      const t=f>0?fieldT[i]/f:0;                             // avg wax temperature here

      let baseR=(90+165*k)*(0.8+t*0.35);

      let baseG=(8+Math.pow(k,1.8)*150)*(0.55+t*0.6);

      let baseB=(34-Math.pow(k,2)*14+k*k*70)*(0.6+t*0.5);

      // Always apply selected color (persists across mode switches)

      const h=waxHue/60%6,s=0.85,l=0.55;

      const c=(1-Math.abs(2*l-1))*s,x=c*(1-Math.abs(h%2-1)),m=l-c/2;

      let hr=0,hg=0,hb=0;

      if(h<1){hr=c;hg=x;}else if(h<2){hr=x;hg=c;}else if(h<3){hg=c;hb=x;}

      else if(h<4){hg=x;hb=c;}else if(h<5){hr=x;hb=c;}else{hr=c;hb=x;}

      hr=(hr+m)*255;hg=(hg+m)*255;hb=(hb+m)*255;

      // Blend: 70% selected color, 30% temperature glow

      r=baseR*0.3+hr*0.7;g=baseG*0.3+hg*0.7;bl=baseB*0.3+hb*0.7;

      // white-hot core for very hot, deep wax (glowing from within)

      // gradual transition to avoid hard-edged blipping points

      if(f>1.0&&t>0.25){

        const w=Math.min(1,(f-1.0)/1.6)*Math.min(1,(t-0.25)/0.8)*0.45;

        r+=(255-r)*w;g+=(235-g)*w;bl+=(210-bl)*w;

      }

      a=255*Math.min(1,(f-0.52)/0.35);

    }else if(f>0.3){                                         // soft halo matching lamp light

      const k=(f-0.3)/0.22;

      r=LIGHT.r;g=LIGHT.g*k;bl=LIGHT.b*k;a=110*k*k;

    }

    if(rgbMix>0&&a>0){                                       // re-light the wax by the current lamp colour (50% blend)

      const lum=0.299*r+0.587*g+0.114*bl;

      if(lum>1){const s=Math.min(1.45,lum/LUM_L);

        const waxBlend=0.25*rgbMix; // only blend 25% toward RGB (halved)

        r+=(LIGHT.r*s-r)*waxBlend;g+=(LIGHT.g*s-g)*waxBlend;bl+=(LIGHT.b*s-bl)*waxBlend;}

    }

    // soft tone map to prevent overexposure blips (like a camera sensor flattening)

    const tone=v=>v<=230?v:230+25*(1-Math.exp(-(v-230)/8));

    d[i*4]=tone(r);d[i*4+1]=tone(g);d[i*4+2]=tone(bl);d[i*4+3]=a;

  }

  bctx.putImageData(img,0,0);

}



// ---- drawing helpers ----

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;}

function drawBackground(){

  const g=ctx.createRadialGradient(CX,H*0.42,60,CX,H*0.42,H*0.75);

  g.addColorStop(0,'#181026');g.addColorStop(1,'#05030a');

  ctx.fillStyle=g;ctx.fillRect(0,0,W,H);

  // warm light pool from the lamp onto the room (scaled down in RGB mode)

  const glowScale=1-rgbMix*0.5; // reduce additive glows by 50% in RGB mode

  ctx.save();ctx.globalCompositeOperation='lighter';

  const lg=ctx.createRadialGradient(CX,H-40,20,CX,H-40,H*0.62);

  lg.addColorStop(0,rgba(LIGHT,0.16*glowScale));lg.addColorStop(0.5,rgba(LIGHT,0.05*glowScale));lg.addColorStop(1,rgba(LIGHT,0));

  ctx.fillStyle=lg;ctx.fillRect(0,0,W,H);ctx.restore();

  ctx.save();ctx.globalAlpha=0.55;ctx.fillStyle='#000';

  ctx.beginPath();ctx.ellipse(CX,H-24,190,26,0,0,Math.PI*2);ctx.fill();ctx.restore();

}

function drawLED(){

  const glowScale=1-rgbMix*0.5; // reduce additive glows by 50% in RGB mode

  ctx.save();ctx.globalCompositeOperation='lighter';

  const g=ctx.createRadialGradient(CX,H-58,4,CX,H-58,150);

  g.addColorStop(0,rgba(LIGHT,0.38*glowScale));g.addColorStop(1,rgba(LIGHT,0));

  ctx.fillStyle=g;ctx.fillRect(CX-170,H-210,340,210);ctx.restore();

}

function drawCap(){

  const g=metalGrad(0,84,0,132,'#151022','#4d3c6b');

  ctx.fillStyle=g;

  ctx.beginPath();ctx.moveTo(CX-NECK_HW-6,127);ctx.quadraticCurveTo(CX,76,CX+NECK_HW+6,127);ctx.closePath();ctx.fill();

  const rim=ctx.createLinearGradient(0,118,0,132);

  rim.addColorStop(0,'#5a4880');rim.addColorStop(1,'#191226');

  ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-NECK_HW-8,118,(NECK_HW+8)*2,13,6);ctx.fill();

}

function drawBase(){

  const body=new Path2D();

  body.moveTo(CX-MIN_HW-14,659);

  body.quadraticCurveTo(CX-MAX_HW-40,H-70,CX-MAX_HW-52,H-38);

  body.quadraticCurveTo(CX-MAX_HW-52,H-20,CX-MAX_HW-30,H-18);

  body.lineTo(CX+MAX_HW+30,H-18);

  body.quadraticCurveTo(CX+MAX_HW+52,H-20,CX+MAX_HW+52,H-38);

  body.quadraticCurveTo(CX+MAX_HW+40,H-70,CX+MIN_HW+14,659);

  body.closePath();

  ctx.save();ctx.clip(body);

  const g=ctx.createLinearGradient(0,659,0,H);g.addColorStop(0,'#3c2d58');g.addColorStop(0.35,'#241736');g.addColorStop(1,'#0c0716');

  ctx.fillStyle=g;ctx.fillRect(0,650,W,H-650);

  const sheen=ctx.createLinearGradient(CX-MAX_HW-52,0,CX+MAX_HW+52,0);

  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)');

  ctx.fillStyle=sheen;ctx.fillRect(0,650,W,H-650);

  ctx.restore();

  const rim=ctx.createLinearGradient(0,645,0,661);

  rim.addColorStop(0,'#6a5590');rim.addColorStop(1,'#171026');

  ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-MIN_HW-16,645,(MIN_HW+16)*2,16,8);ctx.fill();

  const pulse=0.75+0.25*Math.sin(time*2);

  const ledR=ledHover?4.3:3.4;

  ctx.save();ctx.shadowColor=rgba(LIGHT,0.9);ctx.shadowBlur=12*pulse*(ledHover?1.6:1);

  ctx.fillStyle=rgba(LIGHT,Math.min(1,0.85*pulse*(ledHover?1.3:1)));ctx.beginPath();ctx.arc(CX,H-52,ledR,0,Math.PI*2);ctx.fill();

  ctx.fillStyle=rgba(mixWhite(LIGHT,0.6),0.9*pulse*(ledHover?1.15:1));ctx.beginPath();ctx.arc(CX,H-52,ledR*0.45,0,Math.PI*2);ctx.fill();

  ctx.restore();

}

function drawSlider(){

  const kx=SL.x-SL.w/2+slF*SL.w;

  ctx.save();

  // groove

  ctx.fillStyle='rgba(0,0,0,0.55)';

  ctx.beginPath();ctx.roundRect(SL.x-SL.w/2,SL.y-1.75,SL.w,3.5,1.75);ctx.fill();

  ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=1;

  ctx.beginPath();ctx.roundRect(SL.x-SL.w/2+0.5,SL.y-1.3,SL.w-1,2.6,1.3);ctx.stroke();

  // knob, echoing the LED above it

  const r=(slDrag||slHover)?5:4;

  ctx.shadowColor=rgba(LIGHT,0.9);

  ctx.shadowBlur=slDrag?15:(slHover?11:7);

  const g=ctx.createRadialGradient(kx,SL.y,0.5,kx,SL.y,r+1.5);

  g.addColorStop(0,'rgba(255,240,225,0.95)');

  g.addColorStop(0.45,rgba(LIGHT,0.85));

  g.addColorStop(1,rgba(LIGHT,0));

  ctx.fillStyle=g;ctx.beginPath();ctx.arc(kx,SL.y,r+1.5,0,Math.PI*2);ctx.fill();

  ctx.restore();

}

function drawToggle(){

  const on=slMode==='color';

  ctx.save();

  // Switch track

  ctx.fillStyle=on?'rgba(80,180,255,0.85)':'rgba(120,60,40,0.85)';

  ctx.beginPath();ctx.roundRect(TG.x,TG.y,TG.w,TG.h,TG.h/2);ctx.fill();

  // Switch knob

  const kx=on?TG.x+TG.w-TG.h/2:TG.x+TG.h/2;

  const ky=TG.y+TG.h/2;

  ctx.fillStyle='rgba(255,255,255,0.95)';

  ctx.beginPath();ctx.arc(kx,ky,TG.h/2-2,0,Math.PI*2);ctx.fill();

  ctx.restore();

}



// film grain overlay - subtle animated noise for that analog feel (larger grains)

// Film grain: GPU-optimized with full-resolution noise for v14-matching visuals

// Two separate noise canvases: one small tile for base grain pattern, one full-res for wax grain

const NOISE_TILE=96;

const baseNoiseCanvas=document.createElement('canvas');

baseNoiseCanvas.width=NOISE_TILE;baseNoiseCanvas.height=NOISE_TILE;

const baseNoiseCtx=baseNoiseCanvas.getContext('2d');



const waxNoiseCanvas=document.createElement('canvas');

const waxNoiseCtx=waxNoiseCanvas.getContext('2d');



function regenerateBaseNoise(){

  const nd=baseNoiseCtx.createImageData(NOISE_TILE,NOISE_TILE);

  for(let i=0;i<nd.data.length;i+=4){

    const v=Math.random()*255|0;

    nd.data[i]=v;nd.data[i+1]=v;nd.data[i+2]=v;nd.data[i+3]=255;

  }

  baseNoiseCtx.putImageData(nd,0,0);

}

regenerateBaseNoise();



// Pre-create grain overlay canvas for base grain (reuse every frame)

const grainOverlayCanvas=document.createElement('canvas');

const grainOCtx=grainOverlayCanvas.getContext('2d');

let baseNoisePattern=null;



function drawGrain(){

  const cw=canvas.width,ch=canvas.height;

  

  // Regenerate base noise tile for flicker (fast: only 96x96 pixels)

  regenerateBaseNoise();

  baseNoisePattern=baseNoiseCtx.createPattern(baseNoiseCanvas,'repeat');

  

  // Size grain overlay canvas if needed

  if(grainOverlayCanvas.width!==cw||grainOverlayCanvas.height!==ch){

    grainOverlayCanvas.width=cw;grainOverlayCanvas.height=ch;

  }

  

  // Clear and draw base grain using pattern fill (GPU-accelerated)

  grainOCtx.clearRect(0,0,cw,ch);

  grainOCtx.globalAlpha=0.06; // Base grain intensity (v15 x 0.25 - reduced by 75%)

  grainOCtx.globalCompositeOperation='source-over';

  grainOCtx.save();

  grainOCtx.translate(Math.random()*NOISE_TILE,Math.random()*NOISE_TILE);

  grainOCtx.fillStyle=baseNoisePattern;

  grainOCtx.fillRect(-NOISE_TILE,-NOISE_TILE,cw+2*NOISE_TILE,ch+2*NOISE_TILE);

  grainOCtx.restore();

  

  // Draw base grain overlay to main canvas

  ctx.drawImage(grainOverlayCanvas,0,0);

  

  // Wax-masked grain: stronger where wax is (full-res noise for v14 match)

  const bctx=buf.getContext('2d');

  const bw=buf.width,bh=buf.height;

  

  // Size and regenerate wax noise canvas at full resolution

  if(waxNoiseCanvas.width!==bw||waxNoiseCanvas.height!==bh){

    waxNoiseCanvas.width=bw;waxNoiseCanvas.height=bh;

  }

  const nd2=waxNoiseCtx.createImageData(bw,bh);

  for(let i=0;i<nd2.data.length;i+=4){

    const v=Math.random()*255|0;

    nd2.data[i]=v;nd2.data[i+1]=v;nd2.data[i+2]=v;nd2.data[i+3]=255;

  }

  waxNoiseCtx.putImageData(nd2,0,0);

  

  bctx.save();

  bctx.globalAlpha=0.14; // Wax grain intensity (v15 x 0.25 - reduced by 75%)

  bctx.globalCompositeOperation='source-atop'; // Only affects opaque wax pixels

  bctx.drawImage(waxNoiseCanvas,0,0);

  bctx.restore();

}

function drawLiquid(){

  ctx.save();ctx.clip(liquidClip);

  const g=ctx.createLinearGradient(0,LIQ_TOP,0,LIQ_BOT);

  // base liquid follows the lamp light's hue in RGB mode; stays purple by default (almost transparent)

  g.addColorStop(0,rgba(tint({r:35,g:9,b:74}),0.15));

  g.addColorStop(0.5,rgba(tint({r:27,g:6,b:64}),0.15));

  g.addColorStop(1,rgba(tint({r:18,g:3,b:44}),0.15));

  ctx.fillStyle=g;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);



  // ---- bottom lamp glow: heat rising up the liquid column (scaled for subtler RGB) ----

  const glowScale=1-rgbMix*0.5; // reduce additive glows by 50% in RGB mode

  ctx.globalCompositeOperation='lighter';

  const hg=ctx.createLinearGradient(0,LIQ_BOT-190,0,LIQ_BOT+10);

  hg.addColorStop(0,rgba(LIGHT,0));

  hg.addColorStop(0.6,rgba(LIGHT,0.05*glowScale));

  hg.addColorStop(1,rgba(LIGHT,0.18*glowScale));

  ctx.fillStyle=hg;ctx.fillRect(CX-MAX_HW-8,LIQ_BOT-190,MAX_HW*2+16,200);

  const led=ctx.createRadialGradient(CX,LIQ_BOT+6,4,CX,LIQ_BOT+6,150);

  led.addColorStop(0,rgba(mixWhite(LIGHT,0.4),0.28*glowScale));

  led.addColorStop(0.35,rgba(LIGHT,0.12*glowScale));

  led.addColorStop(1,rgba(LIGHT,0));

  ctx.fillStyle=led;ctx.fillRect(CX-170,LIQ_BOT-150,340,170);



  // drifting shimmer

  const sx=CX+Math.sin(time*0.4)*60;

  const sh=ctx.createRadialGradient(sx,(LIQ_TOP+LIQ_BOT)/2,10,sx,(LIQ_TOP+LIQ_BOT)/2,220);

  const shim=tint({r:150,g:90,b:220});

  sh.addColorStop(0,rgba(shim,0.10));sh.addColorStop(1,rgba(shim,0));

  ctx.fillStyle=sh;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);



  // bubbles

  ctx.globalCompositeOperation='source-over';

  ctx.fillStyle='rgba(255,240,255,0.14)';

  for(const b of bubbles){

    const y=b.y*(LIQ_BOT-LIQ_TOP)+LIQ_TOP,hw=halfWidth(y)-6;

    const x=CX+(b.x*2-1)*hw+Math.sin(time*2+b.ph)*3;

    ctx.beginPath();ctx.arc(x,y,b.r,0,Math.PI*2);ctx.fill();

  }



  // carbonation bubbles shed by the blobs (same look, fade so they "pop")

  for(const q of blobBubs){

    ctx.globalAlpha=Math.min(1,(q.y-LIQ_TOP)/30);

    ctx.beginPath();ctx.arc(q.x,q.y,q.r,0,Math.PI*2);ctx.fill();

  }

  ctx.globalAlpha=1;



  // ---- the lava: diffuse halo + glowing body ----

  renderBlobs();

  const bx=CX-MAX_HW-pad,by=LIQ_TOP-pad;

  ctx.globalCompositeOperation='lighter';

  ctx.globalAlpha=0.16;ctx.drawImage(buf,bx-24,by-24,BW+48,BH+48);   // wide soft halo

  ctx.globalAlpha=0.30;ctx.drawImage(buf,bx-10,by-10,BW+20,BH+20);   // tighter glow

  ctx.globalCompositeOperation='source-over';

  ctx.globalAlpha=1;ctx.drawImage(buf,bx,by);                          // wax body

  ctx.restore();

}

function drawGlass(){

  ctx.save();

  ctx.fillStyle='rgba(255,255,255,0.03)';ctx.fill(glass);

  const g=ctx.createLinearGradient(CX-MAX_HW-8,0,CX+MAX_HW+8,0);

  g.addColorStop(0,'rgba(255,255,255,0.02)');g.addColorStop(0.15,'rgba(255,255,255,0.22)');

  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)');

  ctx.strokeStyle=g;ctx.lineWidth=2.4;ctx.stroke(glass);

  // subtle curved reflection to suggest glass curvature (skeuomorphic)

  const refl=ctx.createLinearGradient(CX-MAX_HW*0.7,LIQ_TOP,CX-MAX_HW*0.3,LIQ_BOT);

  refl.addColorStop(0,'rgba(255,255,255,0)');

  refl.addColorStop(0.4,'rgba(255,255,255,0.08)');

  refl.addColorStop(0.6,'rgba(255,255,255,0.12)');

  refl.addColorStop(1,'rgba(255,255,255,0)');

  ctx.fillStyle=refl;

  ctx.beginPath();

  // curved highlight on the left side (flipped: now faces left "(" with a touch more curve)

  ctx.moveTo(CX-MAX_HW*0.35,LIQ_TOP+20);

  ctx.quadraticCurveTo(CX-MAX_HW*0.60,(LIQ_TOP+LIQ_BOT)/2,CX-MAX_HW*0.42,LIQ_BOT-30);

  ctx.lineTo(CX-MAX_HW*0.65,LIQ_BOT-20);

  ctx.quadraticCurveTo(CX-MAX_HW*0.80,(LIQ_TOP+LIQ_BOT)/2,CX-MAX_HW*0.58,LIQ_TOP+30);

  ctx.closePath();

  ctx.fill();

  // subtle right-side reflection

  const refl2=ctx.createLinearGradient(CX+MAX_HW*0.4,LIQ_TOP,CX+MAX_HW*0.7,LIQ_BOT);

  refl2.addColorStop(0,'rgba(255,255,255,0)');

  refl2.addColorStop(0.5,'rgba(255,255,255,0.05)');

  refl2.addColorStop(1,'rgba(255,255,255,0)');

  ctx.fillStyle=refl2;

  ctx.beginPath();

  ctx.moveTo(CX+MAX_HW*0.45,LIQ_TOP+40);

  ctx.quadraticCurveTo(CX+MAX_HW*0.6,(LIQ_TOP+LIQ_BOT)/2,CX+MAX_HW*0.5,LIQ_BOT-40);

  ctx.lineTo(CX+MAX_HW*0.38,LIQ_BOT-35);

  ctx.quadraticCurveTo(CX+MAX_HW*0.48,(LIQ_TOP+LIQ_BOT)/2,CX+MAX_HW*0.36,LIQ_TOP+50);

  ctx.closePath();

  ctx.fill();

  ctx.restore();

}



// ---- main loop ----

function frame(now){

  try{

    const dt=Math.max(0,Math.min(0.05,(now-last)/1000));last=now;time+=dt;

    step(dt*SIM_SPEED);

    // ---- lamp colour: default warm, or slow RGB cycle when the LED is clicked ----

    hue=(hue+dt*HUE_SPEED)%360;

    const tm=RGB_MODE?1:0;

    rgbMix+=(tm-rgbMix)*(1-Math.exp(-dt*2.8));

    if(!RGB_MODE&&rgbMix<0.004)rgbMix=0;

    if(RGB_MODE&&rgbMix>0.996)rgbMix=1;

    const hc=hueRGB(hue);

    LIGHT={r:DEF_L.r+(hc.r-DEF_L.r)*rgbMix,g:DEF_L.g+(hc.g-DEF_L.g)*rgbMix,b:DEF_L.b+(hc.b-DEF_L.b)*rgbMix};

    LUM_L=Math.max(1,0.299*LIGHT.r+0.587*LIGHT.g+0.114*LIGHT.b);

    drawBackground();

    drawLED();

    drawCap();

    drawLiquid();

    drawGlass();

    drawBase();

    drawSlider();

    drawToggle();

    drawGrain();

    canvas.style.cursor=(slDrag||slHover)?'ew-resize':(ledHover?'pointer':'default');

  }catch(err){

    const d=document.createElement('div');

    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';

    d.textContent='Lava lamp error: '+err.message;

    document.body.appendChild(d);

    throw err;

  }

  requestAnimationFrame(frame);

}

requestAnimationFrame(frame);

</script>
</body>
</html>