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.
677 lines
24 KiB
JavaScript
677 lines
24 KiB
JavaScript
/* ============================================================
|
||
* 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) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
})[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=<your-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">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 · 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 · ${n} buckets</span>
|
||
<span class="stat">n = <b>${total}</b> · mean <b>${mean.toFixed(2)}</b> · 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();
|