130 lines
5.4 KiB
HTML
130 lines
5.4 KiB
HTML
<!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=<candidate-id></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>
|