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 @@
- Scale ring (endless encoder) -
- - - + Altitude +
+
-

Relative, endless — wraps past the smallest back to the largest. Scroll the stage too.

+ +

Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.

diff --git a/simulator/static/style.css b/simulator/static/style.css index da9ca47..4e41e39 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -48,10 +48,21 @@ label { display: block; margin: 0.4rem 0; } input[type=range], select { width: 100%; } #readout { background: #000; padding: 0.5rem; border-radius: 4px; font-size: 12px; white-space: pre-wrap; max-height: 240px; overflow: auto; } -.ring-control { display: flex; align-items: center; gap: 0.4rem; } -.ring-control button { flex: 0 0 auto; background: #1a2436; color: #9af; - border: 1px solid #345; border-radius: 4px; padding: 0.3rem 0.5rem; - cursor: pointer; font-size: 13px; } -.ring-control button:hover { background: #243352; } -.scale-name { flex: 1 1 auto; text-align: center; font-size: 12px; color: #cde; } +/* Altitude knob */ +.dial-wrap { display: flex; justify-content: center; padding: 0.3rem 0 0.1rem; } +#dial { width: 190px; height: 190px; touch-action: none; cursor: grab; user-select: none; } +#dial:active { cursor: grabbing; } +.dial-rim { fill: #0d1320; stroke: #243352; stroke-width: 1.2; } +.dial-body { fill: #16203200; stroke: #2c3c5c; stroke-width: 1; } +.dial-tick { stroke: #3a4d70; stroke-width: 0.8; } +.dial-label { fill: #789ac0; font-size: 6px; font-family: ui-monospace, monospace; + letter-spacing: 0.2px; cursor: pointer; } +.dial-label:hover { fill: #cde; } +.dial-label.active { fill: #9cf; font-weight: 700; } +.dial-caption { fill: #4d6184; font-size: 4.4px; letter-spacing: 1.2px; + font-family: ui-monospace, monospace; } +.dial-needle { fill: #9cf; } +.dial-needle polygon { filter: drop-shadow(0 0 1px #9cf); } +.dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; } +.scale-name { display: block; text-align: center; font-size: 12px; color: #cde; } .hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }