225 lines
12 KiB
HTML
225 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 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 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 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>
|