diff --git a/simulator/static/app.js b/simulator/static/app.js index e8606bc..c954652 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -389,6 +389,7 @@ function renderScaleReadout() { // scale id · the chosen pool member · position on the ring (pool size if >1) const poolTag = poolN > 1 ? ` · pool ${poolN}` : ""; $("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`; + renderDial(); } function controls() { @@ -500,6 +501,118 @@ function onWheel(e) { }, 90); } +// --- Altitude knob: the endless rotary encoder, drawn as a turnable dial --- +// cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales +// (cosmos → orbit → coast → reef → abyss) and past the deepest WRAPS back to the +// top — the same endless ring the server already models. The drag accumulates a +// rotation and commits whole detents on release (so a big spin becomes one fast +// blended pass, exactly like the wheel); a tap on a label jumps to that scale. +const dial = $("dial"); +const DIAL_C = 50, DIAL_LABEL_R = 41, DIAL_BODY_R = 27; +let needleDeg = 0; +let dialDrag = null; // {lastAng, accum, moved} while turning + +function dialStep() { return ring && ring.scales.length ? 360 / ring.scales.length : 360; } + +function _xy(r, deg) { + const a = (deg - 90) * Math.PI / 180; // -90° so 0° points UP + return [DIAL_C + r * Math.cos(a), DIAL_C + r * Math.sin(a)]; +} + +function buildDial() { + if (!dial || !ring) return; + dial.innerHTML = ""; + const n = ring.scales.length, step = 360 / n; + svg("circle", { cx: DIAL_C, cy: DIAL_C, r: DIAL_LABEL_R + 6, class: "dial-rim" }, dial); + svg("circle", { cx: DIAL_C, cy: DIAL_C, r: DIAL_BODY_R, class: "dial-body" }, dial); + for (let i = 0; i < n; i++) { + const deg = i * step; + const [tx0, ty0] = _xy(DIAL_BODY_R - 1.5, deg); + const [tx1, ty1] = _xy(DIAL_BODY_R + 2.5, deg); + svg("line", { x1: tx0, y1: ty0, x2: tx1, y2: ty1, class: "dial-tick" }, dial); + const [lx, ly] = _xy(DIAL_LABEL_R, deg); + const t = svg("text", { + x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central", + class: "dial-label", "data-index": String(i), + }, dial); + t.textContent = ring.scales[i].id; + } + svg("text", { x: DIAL_C, y: DIAL_C - 7, "text-anchor": "middle", + "dominant-baseline": "central", class: "dial-caption" }, dial) + .textContent = "ALTITUDE"; + const needle = svg("g", { id: "needle", class: "dial-needle" }, dial); + const tip = DIAL_C - (DIAL_BODY_R - 4); + svg("polygon", { points: `${DIAL_C - 2.4},${DIAL_C} ${DIAL_C + 2.4},${DIAL_C} ${DIAL_C},${tip}` }, needle); + svg("circle", { cx: DIAL_C, cy: DIAL_C, r: 3, class: "dial-hub" }, dial); + renderDial(); +} + +function setNeedle(deg) { + const needle = $("needle"); + if (needle) needle.setAttribute("transform", `rotate(${deg} ${DIAL_C} ${DIAL_C})`); +} + +function renderDial() { + if (!dial || !ring) return; + needleDeg = ringIndex * dialStep(); + setNeedle(needleDeg); + for (const el of dial.querySelectorAll(".dial-label")) { + el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex); + } +} + +function dialAngle(e) { + const r = dial.getBoundingClientRect(); + const dx = e.clientX - (r.left + r.width / 2); + const dy = e.clientY - (r.top + r.height / 2); + return (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360; // 0 at top, clockwise + +} +function angDelta(a, b) { + let d = a - b; + while (d > 180) d -= 360; + while (d < -180) d += 360; + return d; +} + +function onDialDown(e) { + if (busy || !ring || ring.scales.length < 2) return; + e.preventDefault(); + dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target }; +} +function onDialMove(e) { + if (!dialDrag) return; + const a = dialAngle(e); + const d = angDelta(a, dialDrag.lastAng); + dialDrag.lastAng = a; + dialDrag.accum += d; + dialDrag.moved += Math.abs(d); + setNeedle(ringIndex * dialStep() + dialDrag.accum); // live feedback while turning +} +function onDialUp(e) { + if (!dialDrag) return; + const { accum, moved, target } = dialDrag; + dialDrag = null; + if (moved < 6) { // a tap, not a turn + if (target && target.classList && target.classList.contains("dial-label")) { + jumpToScale(+target.getAttribute("data-index")); + } + renderDial(); + return; + } + const detents = Math.round(accum / dialStep()); + if (detents) advance(detents); else renderDial(); // snap back if it didn't cross a detent +} + +// Click a label → travel the SHORTEST signed way around the ring to that scale. +function jumpToScale(idx) { + if (!ring) return; + const n = ring.scales.length; + let d = (idx - ringIndex) % n; + if (d > n / 2) d -= n; + if (d < -n / 2) d += n; + if (d) advance(d); +} + // Dev live-reload: poll the asset version and reload when it changes, so an open // tab never keeps running a stale renderer while we iterate (the readout updates // from the live API and masks it otherwise). Reloads on my edits AND on a server @@ -520,12 +633,16 @@ async function main() { try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); } await loadData(); await landScale(); // pick the initial scale's pool member before first render + buildDial(); // draw the altitude knob from the ring's scales renderScaleReadout(); for (const id of ["content", "left", "right", "mood"]) { $(id).addEventListener("input", debounced); } - $("zoom-in").addEventListener("click", () => advance(1)); - $("zoom-out").addEventListener("click", () => advance(-1)); + // Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump. + dial.addEventListener("pointerdown", onDialDown); + window.addEventListener("pointermove", onDialMove); + window.addEventListener("pointerup", onDialUp); + dial.addEventListener("wheel", onWheel, { passive: false }); $("stage").addEventListener("wheel", onWheel, { passive: false }); update(); } diff --git a/simulator/static/index.html b/simulator/static/index.html index 986bef1..68e91ab 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -35,13 +35,12 @@