Upload index.html
Browse files- index.html +56 -20
index.html
CHANGED
|
@@ -92,12 +92,31 @@ function setWaxFromX(px){
|
|
| 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)
|
|
@@ -115,7 +134,7 @@ function step(dt){
|
|
| 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.
|
| 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
|
|
@@ -256,8 +275,8 @@ function renderBlobs(){
|
|
| 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.
|
| 260 |
-
const w=Math.min(1,(f-1.4)/0.8)*Math.min(1,(t-0.
|
| 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);
|
|
@@ -265,6 +284,11 @@ function renderBlobs(){
|
|
| 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);
|
|
@@ -279,7 +303,7 @@ function drawBackground(){
|
|
| 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,
|
| 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();
|
|
@@ -287,7 +311,7 @@ function drawBackground(){
|
|
| 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,
|
| 291 |
ctx.fillStyle=g;ctx.fillRect(CX-170,H-210,340,210);ctx.restore();
|
| 292 |
}
|
| 293 |
function drawCap(){
|
|
@@ -318,8 +342,11 @@ function drawBase(){
|
|
| 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 |
-
|
| 322 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
| 323 |
}
|
| 324 |
function drawSlider(){
|
| 325 |
const kx=SL.x-SL.w/2+slF*SL.w;
|
|
@@ -331,12 +358,12 @@ function drawSlider(){
|
|
| 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=
|
| 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,
|
| 338 |
-
g.addColorStop(0.45,
|
| 339 |
-
g.addColorStop(1,
|
| 340 |
ctx.fillStyle=g;ctx.beginPath();ctx.arc(kx,SL.y,r+1.5,0,Math.PI*2);ctx.fill();
|
| 341 |
ctx.restore();
|
| 342 |
}
|
|
@@ -349,14 +376,14 @@ function drawLiquid(){
|
|
| 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,
|
| 353 |
-
hg.addColorStop(0.6,
|
| 354 |
-
hg.addColorStop(1,
|
| 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,
|
| 358 |
-
led.addColorStop(0.35,
|
| 359 |
-
led.addColorStop(1,
|
| 360 |
ctx.fillStyle=led;ctx.fillRect(CX-170,LIQ_BOT-150,340,170);
|
| 361 |
|
| 362 |
// drifting shimmer
|
|
@@ -401,6 +428,15 @@ 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();
|
|
@@ -408,7 +444,7 @@ function frame(now){
|
|
| 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';
|
|
|
|
| 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(overLED(p)){RGB_MODE=!RGB_MODE;e.preventDefault();return;}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);ledHover=overLED(p);slHover=overSlider(p)&&!ledHover;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 |
+
// ---- clickable LED: toggles a slow RGB colour cycle on the whole lamp ----
|
| 102 |
+
let RGB_MODE=false,rgbMix=0,hue=140; // mix eases 0(default) <-> 1(full RGB)
|
| 103 |
+
const HUE_SPEED=360/16; // one full colour cycle every ~16 s
|
| 104 |
+
const DEF_L={r:255,g:90,b:45}; // the lamp's original warm colour
|
| 105 |
+
const LED_POS={x:CX,y:H-52};
|
| 106 |
+
let ledHover=false;
|
| 107 |
+
function overLED(p){return Math.hypot(p.x-LED_POS.x,p.y-LED_POS.y)<20;}
|
| 108 |
+
function hueRGB(h){ // pleasant saturated RGB from a hue (deg)
|
| 109 |
+
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;
|
| 110 |
+
let r,g,b;
|
| 111 |
+
if(h<60){r=c;g=x;b=0;}else if(h<120){r=x;g=c;b=0;}
|
| 112 |
+
else if(h<180){r=0;g=c;b=x;}else if(h<240){r=0;g=x;b=c;}
|
| 113 |
+
else if(h<300){r=x;g=0;b=c;}else{r=c;g=0;b=x;}
|
| 114 |
+
return{r:Math.round((r+m)*255),g:Math.round((g+m)*255),b:Math.round((b+m)*255)};
|
| 115 |
+
}
|
| 116 |
+
const rgba=(c,a)=>`rgba(${c.r|0},${c.g|0},${c.b|0},${a})`;
|
| 117 |
+
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});
|
| 118 |
+
let LIGHT={...DEF_L},LUM_L=1; // effective lamp colour, updated each frame
|
| 119 |
+
|
| 120 |
// ---- physics (px units) ----
|
| 121 |
let time=0,last=performance.now();
|
| 122 |
const SIM_SPEED=0.55; // slow, heavy wax (scales all motion + transitions)
|
|
|
|
| 134 |
// heater at the bottom: strongest right on the base, so even big blobs
|
| 135 |
// resting on their own floor get fully heated; cooler air up top
|
| 136 |
if(b.y>botY-38) b.temp+=dt*0.5*(b.y-(botY-38))/38;
|
| 137 |
+
else b.temp-=dt*(0.025+(t<0.3?0.06:0));
|
| 138 |
b.temp=Math.max(0,Math.min(1,b.temp));
|
| 139 |
// buoyancy: screen y grows downward, so negative ay = rising.
|
| 140 |
// hot = light (rises), cold = heavy (sinks); bigger blobs weigh more
|
|
|
|
| 275 |
g=(8+Math.pow(k,1.8)*150)*(0.55+t*0.6);
|
| 276 |
bl=(34-Math.pow(k,2)*14+k*k*70)*(0.6+t*0.5);
|
| 277 |
// white-hot core for very hot, deep wax (glowing from within)
|
| 278 |
+
if(f>1.4&&t>0.35){
|
| 279 |
+
const w=Math.min(1,(f-1.4)/0.8)*Math.min(1,(t-0.35)/0.5)*0.7;
|
| 280 |
r+=(255-r)*w;g+=(225-g)*w;bl+=(190-bl)*w;
|
| 281 |
}
|
| 282 |
a=255*Math.min(1,(f-0.52)/0.35);
|
|
|
|
| 284 |
const k=(f-0.3)/0.22;
|
| 285 |
r=255;g=60*k;bl=40*k;a=110*k*k;
|
| 286 |
}
|
| 287 |
+
if(rgbMix>0&&a>0){ // re-light the wax by the current lamp colour
|
| 288 |
+
const lum=0.299*r+0.587*g+0.114*bl;
|
| 289 |
+
if(lum>1){const s=Math.min(1.45,lum/LUM_L);
|
| 290 |
+
r+=(LIGHT.r*s-r)*rgbMix;g+=(LIGHT.g*s-g)*rgbMix;bl+=(LIGHT.b*s-bl)*rgbMix;}
|
| 291 |
+
}
|
| 292 |
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;
|
| 293 |
}
|
| 294 |
bctx.putImageData(img,0,0);
|
|
|
|
| 303 |
// warm light pool from the lamp onto the room
|
| 304 |
ctx.save();ctx.globalCompositeOperation='lighter';
|
| 305 |
const lg=ctx.createRadialGradient(CX,H-40,20,CX,H-40,H*0.62);
|
| 306 |
+
lg.addColorStop(0,rgba(LIGHT,0.16));lg.addColorStop(0.5,rgba(LIGHT,0.05));lg.addColorStop(1,rgba(LIGHT,0));
|
| 307 |
ctx.fillStyle=lg;ctx.fillRect(0,0,W,H);ctx.restore();
|
| 308 |
ctx.save();ctx.globalAlpha=0.55;ctx.fillStyle='#000';
|
| 309 |
ctx.beginPath();ctx.ellipse(CX,H-24,190,26,0,0,Math.PI*2);ctx.fill();ctx.restore();
|
|
|
|
| 311 |
function drawLED(){
|
| 312 |
ctx.save();ctx.globalCompositeOperation='lighter';
|
| 313 |
const g=ctx.createRadialGradient(CX,H-58,4,CX,H-58,150);
|
| 314 |
+
g.addColorStop(0,rgba(LIGHT,0.38));g.addColorStop(1,rgba(LIGHT,0));
|
| 315 |
ctx.fillStyle=g;ctx.fillRect(CX-170,H-210,340,210);ctx.restore();
|
| 316 |
}
|
| 317 |
function drawCap(){
|
|
|
|
| 342 |
rim.addColorStop(0,'#6a5590');rim.addColorStop(1,'#171026');
|
| 343 |
ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-MIN_HW-16,645,(MIN_HW+16)*2,16,8);ctx.fill();
|
| 344 |
const pulse=0.75+0.25*Math.sin(time*2);
|
| 345 |
+
const ledR=ledHover?4.3:3.4;
|
| 346 |
+
ctx.save();ctx.shadowColor=rgba(LIGHT,0.9);ctx.shadowBlur=12*pulse*(ledHover?1.6:1);
|
| 347 |
+
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();
|
| 348 |
+
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();
|
| 349 |
+
ctx.restore();
|
| 350 |
}
|
| 351 |
function drawSlider(){
|
| 352 |
const kx=SL.x-SL.w/2+slF*SL.w;
|
|
|
|
| 358 |
ctx.beginPath();ctx.roundRect(SL.x-SL.w/2+0.5,SL.y-1.3,SL.w-1,2.6,1.3);ctx.stroke();
|
| 359 |
// knob, echoing the LED above it
|
| 360 |
const r=(slDrag||slHover)?5:4;
|
| 361 |
+
ctx.shadowColor=rgba(LIGHT,0.9);
|
| 362 |
ctx.shadowBlur=slDrag?15:(slHover?11:7);
|
| 363 |
const g=ctx.createRadialGradient(kx,SL.y,0.5,kx,SL.y,r+1.5);
|
| 364 |
+
g.addColorStop(0,'rgba(255,240,225,0.95)');
|
| 365 |
+
g.addColorStop(0.45,rgba(LIGHT,0.85));
|
| 366 |
+
g.addColorStop(1,rgba(LIGHT,0));
|
| 367 |
ctx.fillStyle=g;ctx.beginPath();ctx.arc(kx,SL.y,r+1.5,0,Math.PI*2);ctx.fill();
|
| 368 |
ctx.restore();
|
| 369 |
}
|
|
|
|
| 376 |
// ---- bottom lamp glow: heat rising up the liquid column ----
|
| 377 |
ctx.globalCompositeOperation='lighter';
|
| 378 |
const hg=ctx.createLinearGradient(0,LIQ_BOT-190,0,LIQ_BOT+10);
|
| 379 |
+
hg.addColorStop(0,rgba(LIGHT,0));
|
| 380 |
+
hg.addColorStop(0.6,rgba(LIGHT,0.10));
|
| 381 |
+
hg.addColorStop(1,rgba(LIGHT,0.38));
|
| 382 |
ctx.fillStyle=hg;ctx.fillRect(CX-MAX_HW-8,LIQ_BOT-190,MAX_HW*2+16,200);
|
| 383 |
const led=ctx.createRadialGradient(CX,LIQ_BOT+6,4,CX,LIQ_BOT+6,150);
|
| 384 |
+
led.addColorStop(0,rgba(mixWhite(LIGHT,0.4),0.55));
|
| 385 |
+
led.addColorStop(0.35,rgba(LIGHT,0.24));
|
| 386 |
+
led.addColorStop(1,rgba(LIGHT,0));
|
| 387 |
ctx.fillStyle=led;ctx.fillRect(CX-170,LIQ_BOT-150,340,170);
|
| 388 |
|
| 389 |
// drifting shimmer
|
|
|
|
| 428 |
try{
|
| 429 |
const dt=Math.max(0,Math.min(0.05,(now-last)/1000));last=now;time+=dt;
|
| 430 |
step(dt*SIM_SPEED);
|
| 431 |
+
// ---- lamp colour: default warm, or slow RGB cycle when the LED is clicked ----
|
| 432 |
+
hue=(hue+dt*HUE_SPEED)%360;
|
| 433 |
+
const tm=RGB_MODE?1:0;
|
| 434 |
+
rgbMix+=(tm-rgbMix)*(1-Math.exp(-dt*2.8));
|
| 435 |
+
if(!RGB_MODE&&rgbMix<0.004)rgbMix=0;
|
| 436 |
+
if(RGB_MODE&&rgbMix>0.996)rgbMix=1;
|
| 437 |
+
const hc=hueRGB(hue);
|
| 438 |
+
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};
|
| 439 |
+
LUM_L=Math.max(1,0.299*LIGHT.r+0.587*LIGHT.g+0.114*LIGHT.b);
|
| 440 |
drawBackground();
|
| 441 |
drawLED();
|
| 442 |
drawCap();
|
|
|
|
| 444 |
drawGlass();
|
| 445 |
drawBase();
|
| 446 |
drawSlider();
|
| 447 |
+
canvas.style.cursor=(slDrag||slHover)?'ew-resize':(ledHover?'pointer':'default');
|
| 448 |
}catch(err){
|
| 449 |
const d=document.createElement('div');
|
| 450 |
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';
|