/* global React, ReactDOM */ // Bromont 2026 — On-Air Scorebug overlay. // // Consumed by OBS as a Browser Source at 1920×1080 with a transparent // background. State is broadcast from the timer app on the same origin // via BroadcastChannel("bromont.sj-timer.v1"). Two layouts are selectable // via query string: // ?layout=l3 lower-third bar (default) // ?layout=bug corner scorebug, top-left // // Optional URL params: // ?demo=1 — render a fake state without listening, for design QA // ?safe=0|1 — show 10% title-safe guides (1 = on; default off) const { useState, useEffect } = React; // Console-driven channel: the operator's console at /console/sj-timer/ is the // publisher (same machine / same origin). Nothing renders until the operator // puts the round on air (state.onAir === true). const BROADCAST_CHANNEL = "bromont.sj-timer.console.v1"; // -------------------------------------------------------------- // Helpers — identical math to app.jsx so the overlay looks correct // even before a state message arrives (we render zeros at idle). // -------------------------------------------------------------- function formatClock(ms) { const total = Math.max(0, ms || 0); const m = Math.floor(total / 60000); const s = Math.floor((total % 60000) / 1000); const t = Math.floor((total % 1000) / 100); return { m, s: String(s).padStart(2, "0"), t }; } function fmtFaults(n) { if (n == null) return "0"; if (Number.isInteger(n)) return String(n).padStart(2, "0"); return n.toFixed(1); } // -------------------------------------------------------------- // Default / idle state — also doubles as the demo payload. // -------------------------------------------------------------- const IDLE = { phase: "pre", rider: "Karl Slezak", horse: "Hot Bobo", country: "CAN", order: 14, totalOrder: 32, classLabel: "CCI4*-L", timeAllowed: 64, timeLimit: 128, elapsedMs: 0, adjustedElapsedMs: 0, rails: 0, refusals: 0, refusalLimit: 3, timeCorrection: 0, tFaults: 0, totalFaults: 0, overTime: false, nearLimit: false, eliminated: false, elimReason: null, }; // -------------------------------------------------------------- // Subscriber hook — listens on the BroadcastChannel and exposes // the latest state. Sends a "hello" message on mount so the timer // app replays its current snapshot (otherwise the overlay would // stay idle until the next state change in the timer). // -------------------------------------------------------------- function useScorebugState(demo) { const [state, setState] = useState(IDLE); useEffect(() => { if (demo) return; // freeze on the IDLE payload for design QA if (typeof BroadcastChannel === "undefined") return; const ch = new BroadcastChannel(BROADCAST_CHANNEL); ch.onmessage = (ev) => { if (ev?.data?.type === "state" && ev.data.payload) { setState(ev.data.payload); } }; ch.postMessage({ type: "hello" }); return () => ch.close(); }, [demo]); return state; } // -------------------------------------------------------------- // Status pill — LIVE / HELD / OVER / ELIM // -------------------------------------------------------------- function StatusPill({ state }) { let label = "STAND BY"; let cls = "neutral"; if (state.eliminated) { label = "ELIMINATED"; cls = "elim"; } else if (state.phase === "live" && state.overTime) { label = "OVER TIME"; cls = "warn"; } else if (state.phase === "live") { label = "ON COURSE"; cls = "live"; } else if (state.phase === "paused") { label = "HELD"; cls = "held"; } else if (state.phase === "done") { label = "FINISHED"; cls = "done"; } return (
{cls === "live" && } {label}
); } // -------------------------------------------------------------- // Lower-third — full-width bar pinned to bottom safe area. // -------------------------------------------------------------- function LowerThird({ state }) { const { m, s, t } = formatClock(state.elapsedMs); const faults = state.eliminated ? "EL" : fmtFaults(state.totalFaults); return (
{String(state.order).padStart(2, "0")}
OF {state.totalOrder}
{state.rider}
{state.horse} · {state.classLabel}
{state.country}
ALLOWED {state.timeAllowed}s
{m} : {s} .{t}
FAULTS
{faults}
R{state.rails} · F{state.refusals}/{state.refusalLimit} {state.timeCorrection > 0 && <> · +{state.timeCorrection}s}
); } // -------------------------------------------------------------- // Corner bug — compact stacked block, top-left. // -------------------------------------------------------------- function CornerBug({ state }) { const { m, s, t } = formatClock(state.elapsedMs); const faults = state.eliminated ? "EL" : fmtFaults(state.totalFaults); return (
#{String(state.order).padStart(2, "0")} / {state.totalOrder}
{state.rider}
{state.horse} {state.country}
{m} : {s} .{t}
{faults}
FAULTS
ALLOWED {state.timeAllowed}s · R{state.rails} · F{state.refusals}/{state.refusalLimit} {state.timeCorrection > 0 && ( <> · +{state.timeCorrection}s )} {state.classLabel}
); } // -------------------------------------------------------------- // Mount // -------------------------------------------------------------- function App() { const params = new URLSearchParams(location.search); const layout = (params.get("layout") || "l3").toLowerCase(); const demo = params.get("demo") === "1"; const safe = params.get("safe") === "1"; const state = useScorebugState(demo); // The console gates what reaches air: render nothing until onAir (demo overrides). const visible = demo || state.onAir; return (
{visible && (layout === "bug" ? : )} {safe && ); } ReactDOM.createRoot(document.getElementById("root")).render();