Files
bubblechambersimart/fable/stack/simulator.html
2026-07-05 20:00:13 -04:00

130 lines
5.4 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>