Files
quiz/static/projector.js
ameer 9ea0a8b039 feat: anti-cheat + presence panel + projector view
Refinement pass on top of the validated v1.2 base.

Hardening / quick fixes
- Caddyfile.tpl: CSP / HSTS / X-Frame / Referrer-Policy / Permissions-Policy,
  1 MiB request body cap, X-Forwarded-For pass-through; stock-Caddy compatible.
- auth.py: STUDENT_MAX_AGE 1y -> 30d.
- bootstrap.sh: stage 5 prepends `git config --add safe.directory` so the
  re-bootstrap path no longer 'detected dubious ownership' faults.
- admin.js: drop the misleading `closedPayload?.state || session.state`
  shim; state derives from session only.

Anti-cheat
- New `student_events` audit table; new POST /api/session/{sid}/event for
  blur / visibility_hidden / focus / visibility_visible. quiz.js debounces
  events at 1.5s and uses sendBeacon for visibility_hidden so the event
  survives a navigation. Counts surface in admin presence + CSV export.
- First-claim-wins on join: add_participant raises DuplicateStudentId on
  PK violation; route returns 409 + records a duplicate_join audit event
  with attempted name + IP + UA. Admin dashboard surfaces a per-row red
  badge for hits on real participants and a top-of-page alert for orphan
  attempts.
- DELETE /admin/api/students/{id} as the recovery hatch: clears the
  participant + submissions, kicks active WS sockets so a stale cookie
  cannot continue submitting. quiz.js surfaces the FastAPI detail message
  in the join form so users see the 'already in use' guidance.

Presence panel
- New presence_update WS message; in-process presence map keyed on
  student_id tracks ws_count + last_seen_ms. Admin dashboard renders
  per-student rows: connected/idle/dropped dot, blur+hidden+duplicate
  badges, 'answered current Q' tick, and a clear-student button.

Projector view (public, read-only)
- /projector/?sid=..., GET /api/session/{sid}/projector, WS
  /ws/projector/{sid}. Single self-contained projector_state snapshot
  pushed on every state change. Public leaderboard strips student_id;
  QR rendered server-side as data: URL (CSP-compliant).
- Includes per-Q answer histogram, 8-bucket response-time distribution,
  10-bucket score distribution.
- static/projector.{html,css,js}: editorial-broadside design — masthead,
  registration crosses, conic-gradient countdown ring, SVG stepped-area
  score distribution with median tick, leaderboard row-stagger. Inherits
  light/dark tokens from style.css; honours prefers-reduced-motion. No
  scroll at 1366x768 / 1920x1080 / 3440x1440.

Tests
- tests/test_anti_cheat.py: blur logging + CSV count, unknown-kind 422,
  unauthenticated event 401, duplicate-join 409 + audit, admin
  clear-student happy + 404, server-side submit lockout regression.
- tests/test_projector.py: snapshot shape, leaderboard student_id
  redaction, WS push on state change, 404 for unknown sid, page redirect
  when no sid.
- Existing tests updated for the new presence_update snapshot frame +
  CSV header columns + first-claim-wins refusal of re-key.

57/57 pytest green; smoke-tested locally end-to-end.
2026-05-04 16:08:59 +08:00

677 lines
24 KiB
JavaScript
Raw 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.
/* ============================================================
* Projector view — front-of-room display.
*
* Read-only public WS client. The server is authoritative; we only
* receive `projector_state` snapshots and render them. There are no
* outbound mutations, no auth, no cookies.
*
* The projector is intentionally one-shot per state change: a render
* blows away `#projector-app` and re-builds it, except for two hot
* paths that need partial updates:
* 1) the countdown ring ticks at 4Hz (computed from deadline),
* 2) the lobby participant counter bumps on increment without
* rebuilding the whole lobby.
*
* Layout intent: one screen, no scroll, big-screen typography.
* ============================================================ */
const app = document.querySelector("#projector-app");
const params = new URLSearchParams(window.location.search);
const sid = params.get("sid");
const store = {
ws: null,
snapshot: null,
prevSnapshot: null,
countdownTimer: null,
connected: false,
};
// --------------------------------------------------------------
// Helpers
// --------------------------------------------------------------
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[c]);
}
const escapeAttr = escapeText;
function fmtScore(value) {
return Number(value || 0).toFixed(2);
}
function clamp(n, lo, hi) {
return Math.max(lo, Math.min(hi, n));
}
// --------------------------------------------------------------
// Boot + WS
// --------------------------------------------------------------
async function boot() {
if (!sid) {
app.innerHTML = `
<section class="projector-shell">
<span class="reg-tr"></span><span class="reg-bl"></span>
<header class="projector-topbar">
<div class="topbar-left"><span class="brand">Live Quiz</span></div>
<div class="topbar-mid"></div>
<div class="topbar-right"></div>
</header>
<div class="projector-card fatal-card">
<h1 class="lobby-headline">Projector view</h1>
<p class="lobby-sub">Open <code>/projector/?sid=&lt;your-sid&gt;</code></p>
</div>
<footer class="projector-foot">
<span class="left"><span class="dot dim"></span> offline</span>
<span class="center"></span>
<span class="right">no session</span>
</footer>
</section>`;
return;
}
try {
const r = await fetch(`/api/session/${sid}/projector`);
if (!r.ok) throw new Error("not found");
store.snapshot = await r.json();
render();
} catch {
app.innerHTML = `
<section class="projector-shell">
<span class="reg-tr"></span><span class="reg-bl"></span>
<header class="projector-topbar">
<div class="topbar-left"><span class="brand">Live Quiz</span></div>
<div class="topbar-mid"></div>
<div class="topbar-right"></div>
</header>
<div class="projector-card fatal-card">
<h1 class="lobby-headline">Quiz unavailable</h1>
<p class="lobby-sub">No live session at <code>${escapeText(sid)}</code>.</p>
</div>
<footer class="projector-foot">
<span class="left"><span class="dot dim"></span> offline</span>
<span class="center"></span>
<span class="right">${escapeText(sid)}</span>
</footer>
</section>`;
return;
}
connect();
}
function connect() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/projector/${sid}`);
store.ws = ws;
ws.addEventListener("open", () => {
store.connected = true;
refreshConnDot();
});
ws.addEventListener("message", (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "projector_state") {
store.prevSnapshot = store.snapshot;
store.snapshot = msg;
render();
}
} catch {}
});
ws.addEventListener("close", () => {
store.connected = false;
refreshConnDot();
setTimeout(connect, 2000);
});
// Periodic ping to keep proxies from idling the socket out.
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify({ type: "ping" })); } catch {}
}
}, 25_000);
}
function refreshConnDot() {
const dot = document.querySelector(".projector-foot .dot");
if (!dot) return;
dot.classList.toggle("dim", !store.connected);
const left = dot.parentElement;
if (left) {
const text = store.connected ? "live" : "reconnecting";
// last text node holds status word
const nodes = Array.from(left.childNodes);
const t = nodes.reverse().find((n) => n.nodeType === 3);
if (t) t.nodeValue = " " + text;
}
}
// --------------------------------------------------------------
// Top-level render
// --------------------------------------------------------------
function render() {
const s = store.snapshot;
if (!s) return;
stopCountdown();
const view =
s.state === "lobby" ? renderLobby(s)
: s.state === "question_open" ? renderQuestion(s, false)
: s.state === "question_closed" ? renderQuestion(s, true)
: s.state === "between_questions" ? renderBetween(s)
: s.state === "finished" ? renderFinished(s)
: `<div class="projector-card"><p class="muted">State: ${escapeText(s.state)}</p></div>`;
app.innerHTML = `
<section class="projector-shell" data-state="${escapeText(s.state)}">
<span class="reg-tr"></span><span class="reg-bl"></span>
${renderTopbar(s)}
${view}
${renderFoot(s)}
</section>
`;
// Lobby counter bump animation (post-mount): if the count went up
// since the previous snapshot, briefly mark .bump on the counter.
if (s.state === "lobby") {
const prev = store.prevSnapshot?.participant_count ?? -1;
if (prev >= 0 && s.participant_count > prev) {
const el = document.querySelector(".participant-count");
if (el) {
el.classList.remove("bump");
// force reflow then re-add to restart animation
// eslint-disable-next-line no-unused-expressions
void el.offsetWidth;
el.classList.add("bump");
}
}
}
// Start the countdown ticker for the question_open state
if (s.state === "question_open" && s.question) {
startCountdown(
Date.now() + (s.question.remaining_ms ?? 0),
s.question.time_limit ?? s.pool_meta?.time_limit_default ?? 60
);
} else if (s.state === "question_closed" && s.question) {
// freeze the ring at "spent"
const ring = document.querySelector(".countdown-ring");
if (ring) {
ring.style.setProperty("--pct", "0");
ring.classList.remove("urgent");
ring.classList.add("spent");
const num = ring.querySelector(".num");
if (num) num.textContent = "0s";
}
}
refreshConnDot();
}
// --------------------------------------------------------------
// Topbar (masthead)
// --------------------------------------------------------------
function renderTopbar(s) {
const idx = s.question?.idx ?? null;
const total = s.pool_meta?.question_count ?? s.question?.total_questions ?? 0;
const showQ = idx != null;
const stateLabel = ({
lobby: "Lobby",
question_open: "Live",
question_closed: "Reveal",
between_questions: "Between",
finished: "Finished",
})[s.state] || s.state;
return `
<header class="projector-topbar">
<div class="topbar-left">
<span class="brand">Live Quiz</span>
<h1 class="topbar-title">${escapeText(s.title || "Quiz")}</h1>
</div>
<div class="topbar-mid">
${showQ
? `<span class="folio">Question <b>${idx + 1}</b> of <b>${total}</b></span>`
: (total ? `<span class="folio"><b>${total}</b> questions</span>` : "")
}
<span class="state-badge state-${escapeText(s.state)}">${escapeText(stateLabel)}</span>
</div>
<div class="topbar-right">
${s.sid ? `<span class="folio">SID <b>${escapeText(s.sid)}</b></span>` : ""}
<span class="folio">${formatClock(s.server_ts)}</span>
</div>
</header>
`;
}
function formatClock(ts) {
if (!ts) return "";
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
// --------------------------------------------------------------
// Footer
// --------------------------------------------------------------
function renderFoot(s) {
const dotClass = store.connected ? "dot" : "dot dim";
const status = store.connected ? "live" : "reconnecting";
const right = (() => {
if (s.state === "lobby") return `awaiting start`;
if (s.state === "finished") return `quiz complete`;
if (s.state === "between_questions") return `interlude`;
if (s.state === "question_closed") return `answers revealed`;
if (s.state === "question_open" && s.live_histogram) {
const c = s.live_histogram;
return `${c.submitted_count}/${c.total_count} submitted`;
}
return "";
})();
return `
<footer class="projector-foot">
<span class="left"><span class="${dotClass}"></span> ${status}</span>
<span class="center">${escapeText(s.title || "")}</span>
<span class="right">${escapeText(right)}</span>
</footer>
`;
}
// --------------------------------------------------------------
// State: LOBBY
// --------------------------------------------------------------
function renderLobby(s) {
const n = s.participant_count || 0;
const dotMax = 96;
const dots = Math.min(n, dotMax);
const time = s.pool_meta?.time_limit_default ?? 60;
const qcount = s.pool_meta?.question_count ?? 0;
const scoreFn = (s.pool_meta?.score_fn || "linear").replace(/_/g, " ");
return `
<div class="projector-grid lobby">
<div class="projector-card join-card">
<div>
<p class="lobby-eyebrow">Scan to join</p>
<h2 class="lobby-headline">Open the quiz on your phone.</h2>
<p class="lobby-sub">Point your camera at the code, or type the address below into a browser.</p>
</div>
<div class="qr-frame">
<div class="qr-big"><img src="${escapeAttr(s.qr_url || "")}" alt="Join QR code"></div>
</div>
<div class="lobby-url">${escapeText(s.join_url || "")}</div>
</div>
<div class="projector-card lobby-status">
<p class="lobby-eyebrow">Joined so far</p>
<div class="participant-count">
<b>${n}</b>
<div class="label">
<span class="word">student${n === 1 ? "" : "s"} ready,</span>
<span class="meta">↳ waiting on instructor</span>
</div>
</div>
<ol class="constellation" aria-label="${n} participants joined">
${Array.from({ length: dots }).map((_, i) => {
const d = (i % 24) * 18;
return `<li style="--d:${d}ms"></li>`;
}).join("")}
</ol>
<div>
<div class="lobby-rule"> how it runs </div>
<div class="lobby-meta-grid">
<div class="cell"><span class="v">${qcount}</span><span class="k">Questions</span></div>
<div class="cell"><span class="v">${time}s</span><span class="k">Per question</span></div>
<div class="cell"><span class="v">${escapeText(scoreFn)}</span><span class="k">Scoring</span></div>
</div>
</div>
</div>
</div>
`;
}
// --------------------------------------------------------------
// State: QUESTION (open + closed/reveal)
// --------------------------------------------------------------
function renderQuestion(s, revealed) {
const q = s.question;
if (!q) return `<div class="projector-card"><p class="muted">Loading question</p></div>`;
const hist = s.live_histogram?.counts || { A: 0, B: 0, C: 0, D: 0 };
const submitted = s.live_histogram?.submitted_count || 0;
const total = Math.max(1, s.live_histogram?.total_count || 1);
const reveal = s.reveal;
const correct = reveal?.correct;
// Pre-vote state: nobody has submitted yet AND we're not revealed.
// Hide the bars to keep the layout calm during reading time.
const hasVotes = ["A", "B", "C", "D"].some((k) => (hist[k] || 0) > 0);
const preVote = !revealed && !hasVotes;
const limit = q.time_limit || s.pool_meta?.time_limit_default || 60;
const remainingMs = q.remaining_ms ?? 0;
const initialPct = revealed ? 0 : clamp(100 * (remainingMs / 1000) / limit, 0, 100);
const initialSec = Math.ceil(remainingMs / 1000);
const ringClass =
revealed ? "countdown-ring spent"
: (initialSec <= 10 && initialSec > 0) ? "countdown-ring urgent"
: "countdown-ring";
const submittedPct = clamp(100 * submitted / Math.max(1, s.live_histogram?.total_count || 1), 0, 100);
return `
<div class="projector-grid question">
<div class="projector-card question-card">
<div class="question-head">
<h2 class="big-question">${escapeText(q.text)}</h2>
<div class="${ringClass}" id="big-countdown"
style="--pct:${initialPct}"
role="timer" aria-label="time remaining">
<span class="num">${revealed ? "0s" : initialSec + "s"}</span>
</div>
</div>
<ol class="big-options ${revealed ? "revealed" : ""} ${preVote ? "pre-vote" : ""}">
${["A","B","C","D"].map((k) => {
const v = hist[k] || 0;
const pct = Math.round(100 * v / total);
const isCorrect = revealed && k === correct;
const isIncorrect = revealed && k !== correct;
const cls = [
isCorrect ? "correct" : "",
isIncorrect ? "incorrect" : "",
].filter(Boolean).join(" ");
return `
<li class="${cls}">
<span class="opt-key">${k}</span>
<span class="opt-text">${escapeText(q.options?.[k] || "")}</span>
<span class="opt-bar"><span class="opt-bar-fill" style="width:${pct}%"></span></span>
<span class="opt-count">${v}<small>${pct}%</small></span>
</li>
`;
}).join("")}
</ol>
${revealed && reveal?.explanation
? `<p class="big-explanation">${escapeText(reveal.explanation)}</p>`
: `<div class="submission-strip">
<span class="label">Submissions</span>
<span class="track"><span class="fill" style="--p:${submittedPct.toFixed(1)}%"></span></span>
<span class="nums">${submitted}<small>of ${s.live_histogram?.total_count || s.participant_count || 0}</small></span>
</div>`
}
</div>
<div class="projector-card side-card">
<p class="card-eyebrow">Response time</p>
${renderResponseTime(s.response_time_distribution)}
<p class="card-eyebrow">Top 5</p>
${renderLeaderboard((s.leaderboard || []).slice(0, 5))}
<p class="side-meta">${submitted} of ${s.live_histogram?.total_count || s.participant_count || 0} answered</p>
</div>
</div>
`;
}
// --------------------------------------------------------------
// State: BETWEEN
// --------------------------------------------------------------
function renderBetween(s) {
const next = (s.question?.idx ?? -1) >= 0
? `Next: question ${s.question.idx + 2} of ${s.pool_meta?.question_count ?? "?"}`
: "";
return `
<div class="projector-grid between">
<div class="projector-card">
<p class="card-eyebrow">Score distribution</p>
${renderScoreArea(s.score_distribution)}
<p class="side-meta">${escapeText(next)}</p>
</div>
<div class="projector-card">
<p class="card-eyebrow">Standings</p>
${renderLeaderboard((s.leaderboard || []).slice(0, 10))}
</div>
</div>
`;
}
// --------------------------------------------------------------
// State: FINISHED
// --------------------------------------------------------------
function renderFinished(s) {
const dist = s.score_distribution;
const top = (s.leaderboard || [])[0];
const headline = top
? `${escapeText(top.name)} took the broadside.`
: `The quiz is complete.`;
return `
<div class="projector-grid finished">
<div class="projector-card finished-grid">
<div class="finished-banner">
<span class="kicker">— The Final Tally —</span>
<h2>${headline}</h2>
<p class="summary">${dist?.n ?? 0} student${(dist?.n ?? 0) === 1 ? "" : "s"} answered &middot; max possible ${(dist?.max_total ?? 0).toFixed(1)} points</p>
</div>
${renderScoreArea(dist)}
</div>
<div class="projector-card">
<p class="card-eyebrow">Final leaderboard</p>
${renderLeaderboard(s.leaderboard || [])}
</div>
</div>
`;
}
// --------------------------------------------------------------
// Leaderboard
// --------------------------------------------------------------
function renderLeaderboard(rows) {
if (!rows || !rows.length) {
return `<div class="empty-state"><span class="glyph">— no scores yet —</span><p>Standings appear after the first question is scored.</p></div>`;
}
return `
<ol class="big-leaderboard">
${rows.map((r, i) => `
<li style="--d:${i * 35}ms">
<span class="rank">${r.rank}</span>
<span class="name">${escapeText(r.name)}</span>
<span class="score">${fmtScore(r.score)}</span>
</li>
`).join("")}
</ol>
`;
}
// --------------------------------------------------------------
// Charts
// --------------------------------------------------------------
/** Vertical bar chart with axis baseline + gridlines (CSS-driven). */
function renderResponseTime(dist) {
if (!dist || !dist.total) {
return `<div class="empty-state"><span class="glyph">— awaiting submissions —</span></div>`;
}
const max = Math.max(1, ...dist.buckets.map((b) => b.count));
const cells = dist.buckets.map((b) => {
const h = Math.max(2, Math.round(100 * b.count / max));
const empty = b.count === 0;
return `
<div class="bar-cell">
<span class="bar-fill" style="--h:${h}%" data-empty="${empty}"></span>
</div>`;
}).join("");
const nums = dist.buckets.map((b) => `<span class="bar-num">${b.count}</span>`).join("");
const labels = dist.buckets.map((b) => `<span class="bar-label">${escapeText(b.label)}</span>`).join("");
return `
<div class="bar-chart small">
<div class="bars">${cells}</div>
<div class="nums">${nums}</div>
<div class="labels">${labels}</div>
</div>
`;
}
/**
* Score distribution as a smoothed step-area chart. Gives a feel for the
* shape of the class result rather than 10 detached bars; reads well at
* lecture-hall distance because the silhouette is unambiguous.
*
* The SVG is intentionally drawn in a fixed 1000×360 box and stretched.
* We use a stepped path so each x-bucket looks like a flat top (since the
* bucket is a range, not a point), then close it down to the axis to fill.
*/
function renderScoreArea(dist) {
if (!dist || !dist.buckets || !dist.buckets.length) {
return `<div class="empty-state"><span class="glyph">— scores not yet tallied —</span><p>The distribution appears after the first question is scored.</p></div>`;
}
const W = 1000, H = 360;
const padL = 56, padR = 16, padT = 22, padB = 44;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
const buckets = dist.buckets;
const n = buckets.length;
const total = dist.n || buckets.reduce((a, b) => a + b.count, 0) || 0;
const max = Math.max(1, ...buckets.map((b) => b.count));
// X coords for the *edges* between buckets (n+1 edges)
const xEdge = (i) => padL + (innerW * i) / n;
const yFor = (count) => padT + innerH * (1 - count / max);
// Stepped polyline: for each bucket draw flat top from xEdge(i) to xEdge(i+1)
const linePath = [];
buckets.forEach((b, i) => {
const x0 = xEdge(i), x1 = xEdge(i + 1), y = yFor(b.count);
if (i === 0) linePath.push(`M ${x0} ${y}`);
else linePath.push(`L ${x0} ${y}`);
linePath.push(`L ${x1} ${y}`);
});
const fillPath = [
...linePath,
`L ${xEdge(n)} ${padT + innerH}`,
`L ${xEdge(0)} ${padT + innerH}`,
`Z`,
];
// Y gridlines at 0, .25, .5, .75, 1
const yGrid = [0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = padT + innerH * t;
const v = Math.round(max * (1 - t));
return `
<line class="grid-line" x1="${padL}" x2="${padL + innerW}" y1="${y}" y2="${y}"></line>
<text class="y-tick-label" x="${padL - 8}" y="${y}">${v}</text>
`;
}).join("");
// X-axis tick labels at each bucket centre
const xLabels = buckets.map((b, i) => {
const cx = (xEdge(i) + xEdge(i + 1)) / 2;
return `<text class="x-tick-label" x="${cx}" y="${H - padB + 16}">${escapeText(b.label)}</text>`;
}).join("");
// Per-bucket count labels above each top, only if non-zero
const dataLabels = buckets.map((b, i) => {
if (b.count === 0) return "";
const cx = (xEdge(i) + xEdge(i + 1)) / 2;
const cy = yFor(b.count) - 8;
return `<text class="data-label" x="${cx}" y="${cy - 12}">${b.count}</text>
<circle class="data-point" cx="${cx}" cy="${yFor(b.count)}" r="5.5"></circle>`;
}).join("");
// Median tag — find the bucket containing the cumulative midpoint
let medianIdx = -1;
if (total > 0) {
let acc = 0;
for (let i = 0; i < buckets.length; i++) {
acc += buckets[i].count;
if (acc >= total / 2) { medianIdx = i; break; }
}
}
let medianMarks = "";
if (medianIdx >= 0) {
const mx = (xEdge(medianIdx) + xEdge(medianIdx + 1)) / 2;
medianMarks = `
<line class="median-line" x1="${mx}" x2="${mx}" y1="${padT}" y2="${padT + innerH}"></line>
<text class="median-tag" x="${mx}" y="${padT - 6}" text-anchor="middle">median</text>
`;
}
// Summary stats
const mean = total ? buckets.reduce((acc, b, i) => {
// approximate bucket midpoint as i+0.5 normalized to max_total
const mid = ((i + 0.5) / n) * (dist.max_total || n);
return acc + b.count * mid;
}, 0) / total : 0;
return `
<div class="area-chart">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Score distribution">
${yGrid}
<line class="axis" x1="${padL}" x2="${padL + innerW}" y1="${padT + innerH}" y2="${padT + innerH}"></line>
<line class="axis" x1="${padL}" x2="${padL}" y1="${padT}" y2="${padT + innerH}"></line>
<text class="axis-title" x="${padL + innerW / 2}" y="${H - 6}" text-anchor="middle">Score band (out of ${(dist.max_total || 0).toFixed(1)})</text>
<text class="axis-title" x="${padL - 36}" y="${padT + innerH / 2}" transform="rotate(-90 ${padL - 36} ${padT + innerH / 2})" text-anchor="middle">Students</text>
<path class="area-fill" d="${fillPath.join(" ")}"></path>
<path class="area-line" d="${linePath.join(" ")}"></path>
${dataLabels}
${medianMarks}
</svg>
<div class="chart-legend">
<span>10 score bands &middot; ${n} buckets</span>
<span class="stat">n = <b>${total}</b> &middot; mean <b>${mean.toFixed(2)}</b> &middot; max <b>${(dist.max_total || 0).toFixed(1)}</b></span>
</div>
</div>
`;
}
// --------------------------------------------------------------
// Countdown ring (partial update, runs at 4Hz)
// --------------------------------------------------------------
function startCountdown(deadlineMs, totalSec) {
stopCountdown();
const tick = () => {
const ring = document.querySelector("#big-countdown");
if (!ring) return stopCountdown();
const remaining = Math.max(0, deadlineMs - Date.now());
const sec = Math.ceil(remaining / 1000);
const pct = clamp(100 * (remaining / 1000) / Math.max(1, totalSec), 0, 100);
ring.style.setProperty("--pct", pct.toFixed(2));
const num = ring.querySelector(".num");
if (num) num.textContent = `${sec}s`;
const isUrgent = remaining > 0 && remaining <= 10000;
ring.classList.toggle("urgent", isUrgent);
if (remaining <= 0) {
ring.classList.add("spent");
stopCountdown();
}
};
tick();
store.countdownTimer = setInterval(tick, 250);
}
function stopCountdown() {
if (store.countdownTimer) clearInterval(store.countdownTimer);
store.countdownTimer = null;
}
// --------------------------------------------------------------
// Boot
// --------------------------------------------------------------
boot();