|
|
|
@@ -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();
|
|
|
|
|
}
|
|
|
|
|