Lamp / index.html
One's picture
Upload index.html
43d1530 verified
Raw History Blame Contribute Delete
35.6 kB
<!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>