electro/simulator.html
2026-08-24 14:09:36 -04:00

224 lines
12 KiB
HTML

<title>House Lighting, Simulator</title>
<style>
:root{ --warm:#f3efe6; --warmcore:#ffffff; --fire:#ff3d2e; --firecore:#ff6a4d;
--panel:#141821; --panel2:#1b2130;
--ink:#e8eaf0; --muted:#8b93a6; --rule:#2a3142; --accent:#ffb765; }
*{box-sizing:border-box}
body{margin:0;background:#0a0c12;color:var(--ink);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;padding:20px;}
.wrap{max-width:940px;margin:0 auto;}
h1{font-size:19px;margin:0 0 2px;} .sub{color:var(--muted);font-size:13px;margin:0 0 16px;}
.grid{display:grid;grid-template-columns:1.3fr 1fr;gap:18px;align-items:start;}
@media(max-width:760px){.grid{grid-template-columns:1fr;}}
.stage{background:linear-gradient(#0e1430 0%,#141a3a 45%,#20264a 100%);
border:1px solid var(--rule);border-radius:14px;overflow:hidden;position:relative;}
svg{display:block;width:100%;height:auto;}
.panel{background:var(--panel);border:1px solid var(--rule);border-radius:14px;padding:16px;}
.scene-name{font-size:22px;font-weight:700;letter-spacing:.3px;}
.scene-sub{color:var(--muted);font-size:12px;margin-bottom:14px;}
.row{display:flex;align-items:center;gap:10px;margin:9px 0;font-size:13px;}
.tag{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--muted);width:96px;flex:none;}
.bar{flex:1;height:10px;background:var(--panel2);border-radius:6px;overflow:hidden;}
.fill{height:100%;width:0;background:linear-gradient(90deg,#7a4a12,var(--warm));transition:width .06s linear;}
.val{font-family:ui-monospace,Menlo,monospace;font-size:12px;width:34px;text-align:right;color:var(--ink);}
.btns{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap;}
button{font:600 14px/1 inherit;color:#1a1205;background:var(--accent);border:0;
padding:11px 16px;border-radius:10px;cursor:pointer;}
button.ghost{background:transparent;color:var(--ink);border:1px solid var(--rule);}
button:active{transform:translateY(1px);}
.idle{display:flex;align-items:center;gap:10px;margin-top:16px;font-size:12px;color:var(--muted);}
.idle input{flex:1;}
.idlebar{height:4px;background:var(--panel2);border-radius:3px;margin-top:6px;overflow:hidden;}
.idlefill{height:100%;background:var(--muted);width:100%;}
.banner{margin-top:12px;padding:8px 12px;border-radius:8px;background:#2a1f10;color:var(--accent);
font-size:12.5px;display:none;}
.legend{margin-top:14px;font-size:11.5px;color:var(--muted);line-height:1.7;}
.legend b{color:var(--ink);font-weight:600;}
.hint{color:var(--muted);font-size:11.5px;margin-top:10px;}
</style>
<div class="wrap">
<h1>House Lighting, live simulator</h1>
<p class="sub">Runs the same logic as <code style="color:var(--muted)">townhouse_lights.ino</code>: scenes, PWM brightness, fireplace flicker, idle auto-off, boot self-check.</p>
<div class="grid">
<div class="stage">
<svg viewBox="0 0 360 380" role="img" aria-label="House with switchable lights">
<!-- moon -->
<circle cx="312" cy="42" r="16" fill="#e7ecff" opacity=".85"/>
<circle cx="306" cy="38" r="16" fill="#141a3a" opacity=".6"/>
<!-- ground -->
<rect x="0" y="338" width="360" height="42" fill="#0b1022"/>
<!-- house body -->
<rect x="70" y="150" width="220" height="190" fill="#242b3d"/>
<!-- roof -->
<polygon points="58,152 180,86 302,152" fill="#1c2233"/>
<!-- attic dormer -->
<polygon points="150,120 180,100 210,120" fill="#222a3c"/>
<rect x="164" y="118" width="32" height="26" fill="#2a3247"/>
<!-- chimney -->
<rect x="234" y="96" width="22" height="40" fill="#1a2030"/>
<!-- ==== EXTERIOR lights (data-zone=ext) ==== -->
<!-- porch lamp left of door -->
<circle class="light" data-zone="ext" cx="150" cy="286" r="7" fill="var(--warmcore)"/>
<!-- entry door glow -->
<rect class="light" data-zone="ext" x="166" y="290" width="28" height="50" rx="2" fill="var(--warm)"/>
<!-- porch lamp right -->
<circle class="light" data-zone="ext" cx="210" cy="286" r="7" fill="var(--warmcore)"/>
<!-- deck / side light -->
<circle class="light" data-zone="ext" cx="286" cy="300" r="6" fill="var(--warmcore)"/>
<!-- balcony light (upper) -->
<circle class="light" data-zone="ext" cx="96" cy="214" r="6" fill="var(--warmcore)"/>
<!-- ==== GROUND FLOOR interior (data-zone=gf) ==== -->
<rect class="light" data-zone="gf" x="92" y="270" width="40" height="40" rx="3" fill="var(--warm)"/>
<rect class="light" data-zone="gf" x="228" y="270" width="40" height="40" rx="3" fill="var(--warm)"/>
<!-- ==== UPSTAIRS interior (data-zone=up) ==== -->
<rect class="light" data-zone="up" x="96" y="176" width="38" height="40" rx="3" fill="var(--warm)"/>
<rect class="light" data-zone="up" x="226" y="176" width="38" height="40" rx="3" fill="var(--warm)"/>
<rect class="light" data-zone="up" x="166" y="120" width="28" height="24" rx="2" fill="var(--warm)"/>
<!-- ==== FIREPLACE (data-zone=fire) ==== -->
<rect class="light" data-zone="fire" x="168" y="312" width="24" height="26" rx="2" fill="var(--firecore)"/>
<circle class="light" data-zone="fire" cx="245" cy="96" r="9" fill="var(--fire)"/>
<!-- window frames (static, drawn over glow faintly) -->
<g stroke="#0e1322" stroke-width="2" opacity=".5" fill="none">
<line x1="112" y1="270" x2="112" y2="310"/><line x1="248" y1="270" x2="248" y2="310"/>
<line x1="115" y1="176" x2="115" y2="216"/><line x1="245" y1="176" x2="245" y2="216"/>
</g>
</svg>
</div>
<div class="panel">
<div class="scene-name" id="sceneName">Evening</div>
<div class="scene-sub" id="sceneSub">scene 1 of 4 · press the button to cycle</div>
<div class="row"><span class="tag">GF_INT · D3</span><div class="bar"><div class="fill" id="f_gf"></div></div><span class="val" id="v_gf">0</span></div>
<div class="row"><span class="tag">UP_INT · D5</span><div class="bar"><div class="fill" id="f_up"></div></div><span class="val" id="v_up">0</span></div>
<div class="row"><span class="tag">EXT · D6</span><div class="bar"><div class="fill" id="f_ext"></div></div><span class="val" id="v_ext">0</span></div>
<div class="row"><span class="tag">FIRE · D9</span><div class="bar"><div class="fill" id="f_fire"></div></div><span class="val" id="v_fire">0</span></div>
<div class="btns">
<button id="btnScene">⏻ Scene button (D2), next scene</button>
<button class="ghost" id="btnSect">◐ Section button (D4), spotlight next zone</button>
<button class="ghost" id="btnBoot">▶ Reset (boot self-check)</button>
</div>
<div class="idle">
<span>idle&nbsp;off</span>
<input type="range" id="idleSlider" min="5" max="60" value="20">
<span id="idleLbl" style="width:70px;text-align:right">20 s</span>
</div>
<div class="idlebar"><div class="idlefill" id="idleFill"></div></div>
<div class="banner" id="banner">💤 Idle, faded dark. Press the button to wake.</div>
<div class="legend">
<b>Scenes:</b> Evening → All&nbsp;On → Night → Off<br>
<b>Fireplace</b> flickers <code>55 + random(0,130)</code> each frame when its scene has fire on.<br>
<b>Idle:</b> slider stands in for the firmware's real <code>20&nbsp;min</code> auto-off.
</div>
<div class="hint">Tip: press <b>Space</b> to cycle scenes.</div>
</div>
</div>
</div>
<script>
// ---- scene table (mirrors townhouse_lights.ino) ----
const scenes = [
{name:'Evening', sub:'cozy, fire lit', gf:60, up:40, ext:140, fire:true},
{name:'All On', sub:'everything up', gf:90, up:90, ext:120, fire:true},
{name:'Night', sub:'porch + embers', gf:0, up:0, ext:30, fire:true},
{name:'Off', sub:'dark', gf:0, up:0, ext:0, fire:false},
];
let idx = 0;
let lastActive = performance.now();
let idleMs = 20000;
let booting = false;
let bootVals = null;
// section spotlight, mirrors BTN_SECT / applySpotlight() in townhouse_lights.ino
let mode = 'scene'; // 'scene' | 'section'
let sectIdx = 0; // 0=GF 1=UP 2=EXT 3=FIRE 4=all off
const MAN = {gf:90, up:90, ext:120}; // spotlight brightness, matches the .ino
const SECT_LBL = ['GF only','UP only','EXT only','FIRE only','all off'];
const $ = id => document.getElementById(id);
const idleSlider = $('idleSlider');
idleSlider.oninput = () => { idleMs = idleSlider.value*1000; $('idleLbl').textContent = idleSlider.value+' s'; };
function cycle(){ mode='scene'; idx = (idx+1) % scenes.length; lastActive = performance.now(); }
$('btnScene').onclick = cycle;
document.onkeydown = e => { if(e.code==='Space'){ e.preventDefault(); cycle(); } };
// section button: spotlight one zone at a time, then all off (matches D4 in firmware)
$('btnSect').onclick = () => { mode='section'; sectIdx = (sectIdx+1) % 5; lastActive = performance.now(); };
// boot self-check: pulse each zone in turn, then resume
$('btnBoot').onclick = () => {
if (booting) return;
booting = true; idx = 0; lastActive = performance.now();
const seq = ['gf','up','ext','fire'];
let i = 0;
const step = () => {
if (i >= seq.length){ booting = false; bootVals = null; return; }
bootVals = {gf:0,up:0,ext:0,fire:0}; bootVals[seq[i]] = 120;
i++; setTimeout(step, 220);
};
step();
};
// ---- render one zone's glow ----
function paint(zone, v){
// perceptual curve: a dim LED reads brighter than its raw duty cycle (e.g. Night porch at 30/255)
const t = Math.pow(Math.max(0, Math.min(1, v/255)), 0.55);
const glow = zone==='fire' ? '255,70,50' : '255,248,236'; // fire = red, rest = white
document.querySelectorAll(`[data-zone="${zone}"]`).forEach(el=>{
el.style.opacity = (0.05 + 0.95*t).toFixed(3);
el.style.filter = t>0.02
? `drop-shadow(0 0 ${(3+18*t).toFixed(1)}px rgba(${glow},${(0.85*t).toFixed(2)}))`
: 'none';
});
}
function bar(id,v){ $('f_'+id).style.width = (v/255*100)+'%'; $('v_'+id).textContent = Math.round(v); }
// ---- main loop ~33 Hz ----
function loop(){
let gf,up,ext,fireOn,fireV;
const idle = !booting && (performance.now()-lastActive > idleMs);
if (booting && bootVals){
({gf,up,ext} = bootVals); fireV = bootVals.fire; fireOn = bootVals.fire>0;
$('sceneName').textContent = 'Self-check…'; $('sceneSub').textContent = 'pulsing each zone in turn';
$('banner').style.display='none';
} else if (mode==='section'){
gf = idle?0:(sectIdx===0 ? MAN.gf : 0);
up = idle?0:(sectIdx===1 ? MAN.up : 0);
ext = idle?0:(sectIdx===2 ? MAN.ext : 0);
fireOn = !idle && sectIdx===3;
fireV = fireOn ? (55 + Math.floor(Math.random()*130)) : 0;
$('sceneName').textContent = idle ? 'Off (idle)' : 'Spotlight';
$('sceneSub').textContent = idle ? 'auto-faded dark' : SECT_LBL[sectIdx];
$('banner').style.display = idle ? 'block':'none';
} else {
const s = scenes[idx];
gf = idle?0:s.gf; up = idle?0:s.up; ext = idle?0:s.ext;
fireOn = idle?false:s.fire;
fireV = fireOn ? (55 + Math.floor(Math.random()*130)) : 0;
$('sceneName').textContent = idle ? 'Off (idle)' : s.name;
$('sceneSub').textContent = idle ? 'auto-faded dark' : `scene ${idx+1} of 4 · ${s.sub}`;
$('banner').style.display = idle ? 'block':'none';
}
paint('gf',gf); paint('up',up); paint('ext',ext); paint('fire',fireV);
bar('gf',gf); bar('up',up); bar('ext',ext); bar('fire',fireV);
// idle countdown bar
const remain = booting ? 1 : Math.max(0, 1-(performance.now()-lastActive)/idleMs);
$('idleFill').style.width = (remain*100)+'%';
requestAnimationFrame(loop);
}
loop();
</script>