Files
bubblechambersimart/fable/stack/simulator.html

130 lines
5.4 KiB
HTML
Raw Normal View History

2026-07-05 20:00:13 -04:00
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>stack simulator</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; margin: 0; }
body { background:#0c0b09; color:#b8b0a0; font:12px/1.5 'JetBrains Mono',ui-monospace,monospace;
min-height:100vh; display:flex; }
#panel { width:250px; padding:16px; border-right:1px solid #26221c; flex-shrink:0; }
#panel h1 { font-size:13px; color:#e0d8c8; font-weight:600; margin-bottom:2px; }
#panel .sub { color:#6d6558; margin-bottom:14px; }
#panel label { display:block; margin:10px 0 2px; color:#8d8574; }
#panel input[type=range] { width:100%; accent-color:#c9803a; }
.plane-row { display:flex; gap:6px; align-items:baseline; margin:3px 0; cursor:pointer; }
.plane-row .z { color:#5d5548; width:44px; flex-shrink:0; }
.plane-row.off { opacity:0.35; }
button { background:#1c1914; color:#c9b98c; border:1px solid #3a342a; padding:5px 10px;
font:inherit; cursor:pointer; margin:2px 2px 0 0; }
button.on { background:#c9803a; color:#14100a; border-color:#c9803a; }
#stagewrap { flex:1; display:flex; align-items:center; justify-content:center;
perspective:1400px; overflow:hidden; }
#stage { position:relative; transform-style:preserve-3d; }
#stage img { position:absolute; left:0; top:0; width:100%; height:100%; user-select:none;
-webkit-user-drag:none; }
/* the light table — painted on the WRAP, not the stage: with preserve-3d a
stage background sits at z=0 and would occlude planes behind it */
#stagewrap.lit #light { background:radial-gradient(circle at 50% 46%, #fffdf4 0%, #f4ecd8 55%, #d8cdb2 100%); }
#stagewrap.unlit #light{ background:#b9b2a2; }
#stagewrap.unlit #stage img { filter:brightness(0.94) contrast(0.96); }
#light { position:absolute; inset:0; transform:translateZ(-400px) scale(1.6); }
#hint { position:fixed; right:14px; bottom:10px; color:#4d4639; }
</style>
</head>
<body>
<div id="panel">
<h1 id="title"></h1>
<div class="sub" id="seed"></div>
<div>
<button id="bl" class="on">backlit</button>
<button id="spin">auto-orbit</button>
</div>
<label>depth exaggeration <span id="zv"></span></label>
<input type="range" id="zscale" min="0" max="14" step="0.5" value="5">
<label>tilt range <span id="tv"></span></label>
<input type="range" id="tilt" min="0" max="24" step="1" value="10">
<label style="margin-top:14px">planes (click to toggle)</label>
<div id="planes"></div>
</div>
<div id="stagewrap" class="lit"><div id="stage"><div id="light"></div></div></div>
<div id="hint">move mouse to parallax · ?c=&lt;candidate-id&gt;</div>
<script>
const q = new URLSearchParams(location.search);
const cid = q.get('c') || '01-event-volume';
const stage = document.getElementById('stage');
const SZ = Math.min(innerHeight, innerWidth - 250) - 40;
stage.style.width = stage.style.height = SZ + 'px';
let planes = [], rx = 0, ry = 0, zs = 5, spin = false, t0 = performance.now();
fetch(`candidates/${cid}/manifest.json`).then(r => r.json()).then(m => {
document.getElementById('title').textContent = m.title;
document.getElementById('seed').textContent = `seed ${m.seed} · ${m.planes.length} planes`;
const list = document.getElementById('planes');
m.planes.forEach((p, i) => {
const img = document.createElement('img');
img.src = `candidates/${cid}/${p.file}`;
stage.appendChild(img);
const row = document.createElement('div');
row.className = 'plane-row';
row.innerHTML = `<span class="z">${p.zmm}mm</span><span>${p.sheet}-${p.face} · ${p.label}</span>`;
row.onclick = () => { img.style.display = img.style.display === 'none' ? '' : 'none';
row.classList.toggle('off'); };
list.appendChild(row);
planes.push({ img, zmm: p.zmm });
});
layout();
});
function layout() {
const mid = planes.length ? planes.reduce((s, p) => s + p.zmm, 0) / planes.length : 0;
for (const p of planes)
p.img.style.transform = `translateZ(${(p.zmm - mid) * zs}px)`;
stage.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
}
const zEl = document.getElementById('zscale'), tEl = document.getElementById('tilt');
const zv = document.getElementById('zv'), tv = document.getElementById('tv');
function syncLabels(){ zv.textContent = `×${zEl.value}`; tv.textContent = `±${tEl.value}°`; }
syncLabels();
zEl.oninput = () => { zs = +zEl.value; syncLabels(); layout(); };
tEl.oninput = syncLabels;
const staticMode = q.get('static') !== null;
addEventListener('mousemove', e => {
if (spin || staticMode) return;
const T = +tEl.value;
ry = ((e.clientX / innerWidth) - 0.5) * 2 * T;
rx = -((e.clientY / innerHeight) - 0.5) * 2 * T;
layout();
});
document.getElementById('bl').onclick = function () {
const wrap = document.getElementById('stagewrap');
const lit = wrap.classList.toggle('lit');
wrap.classList.toggle('unlit', !lit);
this.classList.toggle('on', lit);
};
document.getElementById('spin').onclick = function () {
spin = !spin; this.classList.toggle('on', spin);
if (spin) requestAnimationFrame(orbit);
};
function orbit(t) {
if (!spin) return;
const T = +tEl.value;
ry = Math.sin((t - t0) / 1400) * T;
rx = Math.cos((t - t0) / 2300) * T * 0.4;
layout();
requestAnimationFrame(orbit);
}
// static pose for headless screenshots: ?static=ry,rx
if (staticMode) {
const [a, b] = (q.get('static') || '').split(',').map(Number);
ry = isFinite(a) ? a : 8; rx = isFinite(b) ? b : -4;
setTimeout(layout, 300);
}
</script>
</body>
</html>