/* ============================================================ * 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 = `
Live Quiz

Projector view

Open /projector/?sid=<your-sid>

`; 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 = `
Live Quiz

Quiz unavailable

No live session at ${escapeText(sid)}.

`; 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) : `

State: ${escapeText(s.state)}

`; app.innerHTML = `
${renderTopbar(s)} ${view} ${renderFoot(s)}
`; // 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 `
Live Quiz

${escapeText(s.title || "Quiz")}

${showQ ? `Question ${idx + 1} of ${total}` : (total ? `${total} questions` : "") } ${escapeText(stateLabel)}
${s.sid ? `SID ${escapeText(s.sid)}` : ""} ${formatClock(s.server_ts)}
`; } 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 ` `; } // -------------------------------------------------------------- // 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 `

Scan to join

Open the quiz on your phone.

Point your camera at the code, or type the address below into a browser.

Join QR code
${escapeText(s.join_url || "")}

Joined so far

${n}
student${n === 1 ? "" : "s"} ready, ↳ waiting on instructor
    ${Array.from({ length: dots }).map((_, i) => { const d = (i % 24) * 18; return `
  1. `; }).join("")}
— how it runs —
${qcount}Questions
${time}sPer question
${escapeText(scoreFn)}Scoring
`; } // -------------------------------------------------------------- // State: QUESTION (open + closed/reveal) // -------------------------------------------------------------- function renderQuestion(s, revealed) { const q = s.question; if (!q) return `

Loading question…

`; 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 `

${escapeText(q.text)}

${revealed ? "0s" : initialSec + "s"}
    ${["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 `
  1. ${escapeText(q.options?.[k] || "")} ${v}${pct}%
  2. `; }).join("")}
${revealed && reveal?.explanation ? `

${escapeText(reveal.explanation)}

` : `
Submissions ${submitted}of ${s.live_histogram?.total_count || s.participant_count || 0}
` }

Response time

${renderResponseTime(s.response_time_distribution)}

Top 5

${renderLeaderboard((s.leaderboard || []).slice(0, 5))}

${submitted} of ${s.live_histogram?.total_count || s.participant_count || 0} answered

`; } // -------------------------------------------------------------- // 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 `

Score distribution

${renderScoreArea(s.score_distribution)}

${escapeText(next)}

Standings

${renderLeaderboard((s.leaderboard || []).slice(0, 10))}
`; } // -------------------------------------------------------------- // 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 `
— The Final Tally —

${headline}

${dist?.n ?? 0} student${(dist?.n ?? 0) === 1 ? "" : "s"} answered · max possible ${(dist?.max_total ?? 0).toFixed(1)} points

${renderScoreArea(dist)}

Final leaderboard

${renderLeaderboard(s.leaderboard || [])}
`; } // -------------------------------------------------------------- // Leaderboard // -------------------------------------------------------------- function renderLeaderboard(rows) { if (!rows || !rows.length) { return `
— no scores yet —

Standings appear after the first question is scored.

`; } return `
    ${rows.map((r, i) => `
  1. ${r.rank} ${escapeText(r.name)} ${fmtScore(r.score)}
  2. `).join("")}
`; } // -------------------------------------------------------------- // Charts // -------------------------------------------------------------- /** Vertical bar chart with axis baseline + gridlines (CSS-driven). */ function renderResponseTime(dist) { if (!dist || !dist.total) { return `
— awaiting submissions —
`; } 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 `
`; }).join(""); const nums = dist.buckets.map((b) => `${b.count}`).join(""); const labels = dist.buckets.map((b) => `${escapeText(b.label)}`).join(""); return `
${cells}
${nums}
${labels}
`; } /** * 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 `
— scores not yet tallied —

The distribution appears after the first question is scored.

`; } 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 ` ${v} `; }).join(""); // X-axis tick labels at each bucket centre. With 10 buckets across the // 1000-unit-wide SVG these read cleanly at projector scale; the SVG // stretches but the text rotates if we wanted, here it's horizontal // because the labels are short ("0.0-1.0" etc.). const xLabels = buckets.map((b, i) => { const cx = (xEdge(i) + xEdge(i + 1)) / 2; return `${escapeText(b.label)}`; }).join(""); // Per-bucket data points (small circles at the top of each band) — no // numeric labels above them. With small N the count labels collide // with the median tag and with each other when bars are short; the // x-axis labels + bottom legend (n / mean / max) carry that info now. const dataPoints = buckets.map((b, i) => { if (b.count === 0) return ""; const cx = (xEdge(i) + xEdge(i + 1)) / 2; return ``; }).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 = ` median `; } // 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 `
${yGrid} Score band (out of ${(dist.max_total || 0).toFixed(1)}) Students ${xLabels} ${dataPoints} ${medianMarks}
n = ${total} · mean ${mean.toFixed(2)} · max ${(dist.max_total || 0).toFixed(1)}
`; } // -------------------------------------------------------------- // 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();