Something went wrong. Try again.
TV-friendly front end for the TimeGuessr daily
Something went wrong. Try again.
28 kB · 758 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759/* Temporamancer: game logic for the TimeGuessr daily on a TV. */(() => { 'use strict';
const YEAR_MIN = 1900; const YEAR_MAX = Math.max(2026, new Date().getFullYear()); const YEAR_DEFAULT = 1962; const STORAGE_PREFIX = 'temporamancer:v1:'; const TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; const TILE_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'; const WORLD_VIEW = { center: [22, 10], zoom: 2 }; const ROUND_MAX = 10000; const PART_MAX = 5000;
// --------------------------------------------------------------------- // Scoring, ported from timeguessr.com so points match the real game. // --------------------------------------------------------------------- const toRad = (deg) => (Math.PI / 180) * deg;
function haversineMeters(a, b) { const dLat = toRad(b.lat - a.lat); const dLng = toRad(b.lng - a.lng); const h = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); return 6371 * (2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h))) * 1000; }
function yearScore(guess, actual) { const n = Math.abs(guess - actual); if (n === 0) return 5000; if (n === 1) return 4950; if (n === 2) return 4800; if (n === 3) return 4600; if (n === 4) return 4300; if (n === 5) return 3900; if (n <= 7) return 3400; if (n <= 10) return 2500; if (n <= 15) return 2000; if (n <= 20) return 1000; return 0; }
function distanceScore(m) { if (m <= 50) return 5000; if (m <= 1e3) return Math.round(5000 - m * 0.02); if (m <= 5e3) return Math.round(4980 - m * 0.016); if (m <= 1e5) return Math.round(4900 - m * 0.004); if (m <= 1e6) return Math.round(4500 - m * 0.001); if (m <= 2e6) return Math.round(3500 - m * 5e-4); if (m <= 3e6) return Math.round(2500 - m * 33333e-8); if (m <= 6e6) return Math.round(1500 - m * 2e-4); return 12; }
function scoreRound(guess, round) { const actual = { lat: round.lat, lng: round.lng }; const ys = yearScore(guess.year, round.year); if (!guess.latlng) { return { guessYear: guess.year, actualYear: round.year, guess: null, actual, distanceMeters: 0, yearScore: ys, distanceScore: 0, total: ys, noGuess: true, }; } const d = haversineMeters(guess.latlng, actual); const ds = distanceScore(d); return { guessYear: guess.year, actualYear: round.year, guess: { lat: guess.latlng.lat, lng: guess.latlng.lng }, actual, distanceMeters: Math.round(d * 10) / 10, yearScore: ys, distanceScore: ds, total: ys + ds, noGuess: false, }; }
// --------------------------------------------------------------------- // Formatting // --------------------------------------------------------------------- const fmtInt = (n) => Math.round(n).toLocaleString('en-US'); const fmt1 = (x) => (Math.round(x * 10) / 10).toLocaleString('en-US', { maximumFractionDigits: 1 });
function formatDistance(m, units) { if (m < 1) return 'right on the spot'; if (units === 'imperial') { const ft = m * 3.28084; return ft < 5280 ? `${fmt1(ft)} ft` : `${fmt1(m / 1609.34)} mi`; } return m < 1000 ? `${fmt1(m)} m` : `${fmt1(m / 1000)} km`; }
function yearsOffText(guess, actual) { const n = Math.abs(guess - actual); if (n === 0) return 'spot on'; return `${n} ${n === 1 ? 'year' : 'years'} off`; }
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); }
// --------------------------------------------------------------------- // DOM + state // --------------------------------------------------------------------- const $ = (sel) => document.querySelector(sel); const el = { stage: $('#stage'), photoWrap: $('#photo-wrap'), photo: $('#photo'), photoStatus: $('#photo-status'), hudNo: $('#hud-no'), hudRound: $('#hud-round'), hudScore: $('#hud-score'), yearOut: $('#year-out'), yearRange: $('#year-range'), yearMin: $('#year-min'), yearMax: $('#year-max'), guessBtn: $('#guess-btn'), mapPanel: $('#map-panel'), mapPin: $('#map-pin'), fillBtn: $('#fill-btn'), results: $('#results'), resultsEyebrow: $('#results-eyebrow'), resultsHeadline: $('#results-headline'), resultsSub: $('#results-sub'), scoreYear: $('#score-year'), scoreDist: $('#score-dist'), scoreRound: $('#score-round'), barYear: $('#bar-year'), barDist: $('#bar-dist'), resultsThumb: $('#results-thumb'), resultsDesc: $('#results-desc'), resultsLicense: $('#results-license'), resultsTotal: $('#results-total'), nextBtn: $('#next-btn'), final: $('#final'), finalEyebrow: $('#final-eyebrow'), finalScore: $('#final-score'), finalRounds: $('#final-rounds'), help: $('#help'), toast: $('#toast'), };
const state = { daily: null, round: 0, results: [], phase: 'loading', // loading | guess | results | final | error year: YEAR_DEFAULT, latlng: null, units: readUnits(), lastResult: null, };
function readUnits() { try { const saved = localStorage.getItem(STORAGE_PREFIX + 'units'); if (saved === 'metric' || saved === 'imperial') return saved; } catch {} return navigator.language === 'en-US' ? 'imperial' : 'metric'; }
function storageKey() { const id = state.daily?.dailyId || state.daily?.no || 'unknown'; return `${STORAGE_PREFIX}daily:${id}`; } function saveProgress() { try { localStorage.setItem(storageKey(), JSON.stringify({ results: state.results, savedAt: Date.now() })); } catch {} } function loadProgress() { try { const raw = localStorage.getItem(storageKey()); if (!raw) return null; const parsed = JSON.parse(raw); return Array.isArray(parsed.results) ? parsed : null; } catch { return null; } } function clearProgress() { try { localStorage.removeItem(storageKey()); } catch {} }
const show = (node) => { node.hidden = false; }; const hide = (node) => { node.hidden = true; };
function setPhase(phase) { state.phase = phase; el.stage.className = `phase-${phase}`; hideToast(); updateGuessButton(); }
function setStatus(msg) { el.photoStatus.textContent = msg; el.photoStatus.hidden = !msg; }
let toastTimer = null; function hideToast() { clearTimeout(toastTimer); hide(el.toast); } function toast(msg, ms = 2400) { el.toast.textContent = msg; show(el.toast); clearTimeout(toastTimer); toastTimer = setTimeout(hideToast, ms); }
const totalScore = () => state.results.reduce((sum, r) => sum + r.total, 0);
// --------------------------------------------------------------------- // Maps // --------------------------------------------------------------------- let guessMap = null; let guessMarker = null; let resultsMap = null; let resultsLayer = null;
const pinIcon = (cls) => L.divIcon({ className: '', html: `<span class="pin ${cls}"></span>`, iconSize: [26, 26], iconAnchor: [13, 13] });
function tiles() { return L.tileLayer(TILE_URL, { maxZoom: 19, attribution: TILE_ATTR }); }
function initGuessMap() { guessMap = L.map('map', { center: WORLD_VIEW.center, zoom: WORLD_VIEW.zoom, minZoom: 2, maxZoom: 18, worldCopyJump: true, zoomControl: false, keyboard: false, }); tiles().addTo(guessMap); L.control.zoom({ position: 'topleft' }).addTo(guessMap); guessMap.on('click', (e) => { if (state.phase !== 'guess') return; placePin(e.latlng.wrap()); }); }
function placePin(latlng) { state.latlng = { lat: latlng.lat, lng: latlng.lng }; if (!guessMarker) guessMarker = L.marker(latlng, { icon: pinIcon('pin-guess'), keyboard: false }).addTo(guessMap); else guessMarker.setLatLng(latlng); updateGuessButton(); }
function clearPin() { if (guessMarker) { guessMarker.remove(); guessMarker = null; } state.latlng = null; updateGuessButton(); }
function resetGuessMapView() { guessMap.setView(WORLD_VIEW.center, WORLD_VIEW.zoom, { animate: false }); }
function ensureResultsMap() { if (resultsMap) return; resultsMap = L.map('results-map', { worldCopyJump: true, zoomControl: true, keyboard: false }); tiles().addTo(resultsMap); resultsLayer = L.layerGroup().addTo(resultsMap); }
function showResultOnMap(r) { ensureResultsMap(); resultsLayer.clearLayers(); const actual = L.latLng(r.actual.lat, r.actual.lng); L.marker(actual, { icon: pinIcon('pin-actual'), keyboard: false }).addTo(resultsLayer); if (r.guess) { const guess = L.latLng(r.guess.lat, r.guess.lng); L.marker(guess, { icon: pinIcon('pin-guess'), keyboard: false }).addTo(resultsLayer); L.polyline([guess, actual], { color: '#f3f2ee', weight: 3, dashArray: '8 10', opacity: 0.85 }).addTo(resultsLayer); } const fit = () => { resultsMap.invalidateSize(); if (r.guess) { resultsMap.fitBounds(L.latLngBounds([L.latLng(r.guess.lat, r.guess.lng), actual]), { padding: [70, 70], maxZoom: 14, animate: false }); } else { resultsMap.setView(actual, 5, { animate: false }); } }; fit(); setTimeout(fit, 60); // the overlay may still be laying out on the first pass }
// --------------------------------------------------------------------- // Photo zoom + pan // --------------------------------------------------------------------- const view = { zoom: 1, tx: 0, ty: 0 }; const ZOOM_MAX = 8;
function displayedSize() { const cw = el.photoWrap.clientWidth; const ch = el.photoWrap.clientHeight; const nw = el.photo.naturalWidth || cw; const nh = el.photo.naturalHeight || ch; const s = Math.min(cw / nw, ch / nh); return { cw, ch, dw: nw * s, dh: nh * s }; }
function clampView() { const { cw, ch, dw, dh } = displayedSize(); const maxX = Math.max(0, (dw * view.zoom - cw) / 2); const maxY = Math.max(0, (dh * view.zoom - ch) / 2); view.tx = Math.min(maxX, Math.max(-maxX, view.tx)); view.ty = Math.min(maxY, Math.max(-maxY, view.ty)); }
function applyView() { clampView(); el.photo.style.transform = `translate(${view.tx}px, ${view.ty}px) scale(${view.zoom})`; const zoomed = view.zoom > 1.001; el.photoWrap.classList.toggle('zoomed', zoomed); el.fillBtn.setAttribute('aria-pressed', zoomed ? 'true' : 'false'); }
function zoomAt(factor, cx, cy) { const { cw, ch } = displayedSize(); const px = cx ?? cw / 2; const py = cy ?? ch / 2; const z0 = view.zoom; const z1 = Math.min(ZOOM_MAX, Math.max(1, z0 * factor)); // Keep the image point under the cursor fixed while scaling. const ux = (px - cw / 2 - view.tx) / z0; const uy = (py - ch / 2 - view.ty) / z0; view.tx = px - cw / 2 - ux * z1; view.ty = py - ch / 2 - uy * z1; view.zoom = z1; if (z1 === 1) { view.tx = 0; view.ty = 0; } applyView(); }
function resetView() { view.zoom = 1; view.tx = 0; view.ty = 0; applyView(); }
function fillScreen() { if (view.zoom > 1.001) { resetView(); return; } const { cw, ch, dw, dh } = displayedSize(); view.zoom = Math.min(ZOOM_MAX, Math.max(cw / dw, ch / dh)); view.tx = 0; view.ty = 0; applyView(); }
function bindPhoto() { const wrap = el.photoWrap; wrap.addEventListener('wheel', (e) => { e.preventDefault(); zoomAt(e.deltaY < 0 ? 1.18 : 1 / 1.18, e.clientX, e.clientY); }, { passive: false });
let drag = null; wrap.addEventListener('pointerdown', (e) => { if (view.zoom <= 1.001 || e.button !== 0) return; drag = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty }; wrap.setPointerCapture(e.pointerId); wrap.classList.add('dragging'); }); wrap.addEventListener('pointermove', (e) => { if (!drag) return; view.tx = drag.tx + (e.clientX - drag.x); view.ty = drag.ty + (e.clientY - drag.y); applyView(); }); const endDrag = () => { drag = null; wrap.classList.remove('dragging'); }; wrap.addEventListener('pointerup', endDrag); wrap.addEventListener('pointercancel', endDrag); wrap.addEventListener('dblclick', (e) => { if (view.zoom > 1.001) resetView(); else zoomAt(2.5, e.clientX, e.clientY); }); }
// --------------------------------------------------------------------- // Year control // --------------------------------------------------------------------- let typed = ''; let typedTimer = null;
function setYear(y) { y = Math.min(YEAR_MAX, Math.max(YEAR_MIN, Math.round(y))); state.year = y; el.yearRange.value = String(y); el.yearOut.textContent = String(y); }
function endTyping() { typed = ''; clearTimeout(typedTimer); el.yearOut.classList.remove('typing'); el.yearOut.textContent = String(state.year); }
function typeDigit(d) { typed += d; clearTimeout(typedTimer); if (typed.length >= 4) { setYear(parseInt(typed, 10)); endTyping(); return; } el.yearOut.classList.add('typing'); el.yearOut.textContent = typed.padEnd(4, '·'); typedTimer = setTimeout(endTyping, 1800); }
function untypeDigit() { typed = typed.slice(0, -1); if (!typed) { endTyping(); return; } el.yearOut.textContent = typed.padEnd(4, '·'); clearTimeout(typedTimer); typedTimer = setTimeout(endTyping, 1800); }
function bindYear() { el.yearRange.min = String(YEAR_MIN); el.yearRange.max = String(YEAR_MAX); el.yearMin.textContent = String(YEAR_MIN); el.yearMax.textContent = String(YEAR_MAX); el.yearRange.addEventListener('input', () => { endTyping(); setYear(Number(el.yearRange.value)); });
for (const btn of document.querySelectorAll('.step')) { const step = Number(btn.dataset.step); let timer = null; let interval = null; let viaPointer = false; const stop = () => { clearTimeout(timer); clearInterval(interval); timer = interval = null; }; btn.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; e.preventDefault(); // keep focus where it is viaPointer = true; endTyping(); setYear(state.year + step); timer = setTimeout(() => { interval = setInterval(() => setYear(state.year + step), 70); }, 420); }); for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) btn.addEventListener(ev, stop); btn.addEventListener('click', () => { if (viaPointer) { viaPointer = false; return; } // already handled on pointerdown endTyping(); setYear(state.year + step); }); } }
// --------------------------------------------------------------------- // Map panel expand / pin // --------------------------------------------------------------------- let collapseTimer = null;
function setMapPinned(pinned) { el.mapPanel.classList.toggle('pinned', pinned); el.mapPin.setAttribute('aria-pressed', pinned ? 'true' : 'false'); guessMap.invalidateSize(); }
function bindMapPanel() { el.mapPanel.addEventListener('pointerenter', () => { clearTimeout(collapseTimer); el.mapPanel.classList.add('expanded'); }); el.mapPanel.addEventListener('pointerleave', () => { clearTimeout(collapseTimer); collapseTimer = setTimeout(() => el.mapPanel.classList.remove('expanded'), 350); }); el.mapPanel.addEventListener('transitionend', (e) => { if (e.propertyName === 'width' || e.propertyName === 'height') guessMap.invalidateSize(); }); el.mapPin.addEventListener('click', () => setMapPinned(!el.mapPanel.classList.contains('pinned'))); }
// --------------------------------------------------------------------- // Game flow // --------------------------------------------------------------------- function updateGuessButton() { const ready = state.phase === 'guess'; el.guessBtn.disabled = !ready; if (!ready) { el.guessBtn.textContent = 'Guess'; el.guessBtn.classList.remove('year-only'); return; } if (state.latlng) { el.guessBtn.textContent = 'Guess'; el.guessBtn.classList.remove('year-only'); } else { el.guessBtn.textContent = 'Guess year only'; el.guessBtn.classList.add('year-only'); } }
function roundImageUrl(round) { return round.resolvedUrl || round.url; }
async function loadDaily() { setPhase('loading'); setStatus('Loading today’s daily…'); const res = await fetch('/api/daily', { cache: 'no-store' }); if (!res.ok) { let msg = `server responded ${res.status}`; try { msg = (await res.json()).error || msg; } catch {} throw new Error(msg); } state.daily = await res.json(); if (!state.daily.rounds?.length) throw new Error('no rounds in today’s daily'); el.hudNo.textContent = state.daily.no ? `#${state.daily.no}` : '—'; document.title = state.daily.no ? `Temporamancer · Daily #${state.daily.no}` : 'Temporamancer'; for (const r of state.daily.rounds) { const img = new Image(); img.src = r.url; }
const saved = loadProgress(); state.results = saved ? saved.results.slice(0, state.daily.rounds.length) : []; state.round = state.results.length; if (state.round >= state.daily.rounds.length) showFinal(); else startRound(state.round); }
function startRound(idx) { state.round = idx; hide(el.results); hide(el.final); clearPin(); resetGuessMapView(); el.mapPanel.classList.remove('expanded'); endTyping(); setYear(YEAR_DEFAULT); el.hudRound.textContent = `${idx + 1}/${state.daily.rounds.length}`; el.hudScore.textContent = fmtInt(totalScore());
const round = state.daily.rounds[idx]; el.photo.classList.remove('ready'); setStatus('Loading photo…'); el.photo.onload = () => { el.photo.classList.add('ready'); setStatus(''); resetView(); }; el.photo.onerror = () => { if (!round.resolvedUrl) { // Hotlinking may be blocked; fall back to the server-side proxy once. round.resolvedUrl = `/api/image?url=${encodeURIComponent(round.url)}`; el.photo.src = round.resolvedUrl; return; } setStatus('Couldn’t load the photo. Press R to retry.'); }; el.photo.src = roundImageUrl(round); setPhase('guess'); el.guessBtn.blur(); setTimeout(() => guessMap.invalidateSize(), 0); }
function submitGuess() { if (state.phase !== 'guess') return; const round = state.daily.rounds[state.round]; const result = scoreRound({ year: state.year, latlng: state.latlng }, round); state.results.push(result); saveProgress(); showResults(result); }
function renderResultText(r) { el.resultsHeadline.textContent = r.noGuess ? 'No pin placed' : `${formatDistance(r.distanceMeters, state.units)} away`; const yearPart = r.guessYear === r.actualYear ? `You got the year spot on: ${r.actualYear}.` : `${yearsOffText(r.guessYear, r.actualYear)}: you said ${r.guessYear}, it was ${r.actualYear}.`; el.resultsSub.textContent = r.noGuess ? `No location points this round. ${yearPart}` : yearPart; }
function showResults(r) { state.lastResult = r; setPhase('results'); const n = state.round + 1; const total = state.daily.rounds.length; const round = state.daily.rounds[state.round];
el.resultsEyebrow.textContent = `Round ${n} of ${total}`; renderResultText(r); el.scoreYear.textContent = fmtInt(r.yearScore); el.scoreDist.textContent = fmtInt(r.distanceScore); el.scoreRound.textContent = fmtInt(r.total); el.barYear.style.width = '0%'; el.barDist.style.width = '0%'; el.resultsThumb.src = roundImageUrl(round); el.resultsDesc.textContent = round.description || ''; el.resultsLicense.textContent = round.license ? `Photo: ${round.license}` : ''; el.resultsTotal.textContent = fmtInt(totalScore()); el.hudScore.textContent = fmtInt(totalScore()); el.nextBtn.textContent = n >= total ? 'See final score' : 'Next round';
show(el.results); requestAnimationFrame(() => { showResultOnMap(r); requestAnimationFrame(() => { el.barYear.style.width = `${(r.yearScore / PART_MAX) * 100}%`; el.barDist.style.width = `${(r.distanceScore / PART_MAX) * 100}%`; }); }); el.nextBtn.focus({ preventScroll: true }); }
function nextRound() { if (state.phase !== 'results') return; const next = state.round + 1; if (next >= state.daily.rounds.length) showFinal(); else startRound(next); }
function renderFinalRounds() { el.finalRounds.innerHTML = state.results.map((r, i) => { const round = state.daily.rounds[i]; const where = r.noGuess ? 'no pin' : `${formatDistance(r.distanceMeters, state.units)} away`; const meta = [String(r.actualYear), round.country, where, yearsOffText(r.guessYear, r.actualYear)].filter(Boolean).join(' · '); return `<li class="final-round"> <img src="${esc(roundImageUrl(round))}" alt=""> <div class="fr-body"> <p class="fr-title">${esc(round.description)}</p> <p class="fr-meta">${esc(meta)}</p> </div> <div class="fr-score"><strong>${fmtInt(r.total)}</strong><small>${fmtInt(r.yearScore)} year · ${fmtInt(r.distanceScore)} location</small></div> </li>`; }).join(''); }
function showFinal() { setPhase('final'); hide(el.results); const n = state.daily.rounds.length; el.finalEyebrow.textContent = state.daily.no ? `Daily #${state.daily.no} complete` : 'Daily complete'; el.finalScore.textContent = fmtInt(totalScore()); el.final.querySelector('.final-total small').textContent = `/ ${fmtInt(n * ROUND_MAX)}`; renderFinalRounds(); el.hudRound.textContent = `${n}/${n}`; el.hudScore.textContent = fmtInt(totalScore()); show(el.final); }
let restartArmedAt = 0; function requestRestart() { const now = Date.now(); if (now - restartArmedAt < 2500) { restartArmedAt = 0; clearProgress(); state.results = []; hide(el.final); startRound(0); toast('Replaying today from round one'); return; } restartArmedAt = now; toast('Press Shift+R again to replay today from round one'); }
function toggleUnits() { state.units = state.units === 'imperial' ? 'metric' : 'imperial'; try { localStorage.setItem(STORAGE_PREFIX + 'units', state.units); } catch {} toast(state.units === 'imperial' ? 'Distances in miles' : 'Distances in kilometres'); if (state.phase === 'results' && state.lastResult) renderResultText(state.lastResult); if (state.phase === 'final') renderFinalRounds(); }
let enterArmedAt = 0; function enterPressed() { if (state.phase === 'results') { nextRound(); return; } if (state.phase !== 'guess') return; if (state.latlng) { submitGuess(); return; } // Guard against losing 5,000 location points to a stray Enter. const now = Date.now(); if (now - enterArmedAt < 3000) { enterArmedAt = 0; submitGuess(); return; } enterArmedAt = now; toast('No pin on the map. Press Enter again to guess the year only.', 3000); }
// --------------------------------------------------------------------- // Input // --------------------------------------------------------------------- function bindKeys() { document.addEventListener('keydown', (e) => { if (e.metaKey || e.ctrlKey || e.altKey) return; if (!el.help.hidden) { hide(el.help); e.preventDefault(); return; } if (e.key === '?') { show(el.help); e.preventDefault(); return; } if (e.key === 'R' && e.shiftKey && state.daily) { requestRestart(); e.preventDefault(); return; } if (e.key === 'u' || e.key === 'U') { toggleUnits(); return; } if (e.key === 'Enter') { enterPressed(); e.preventDefault(); return; }
if (state.phase === 'results') { if (e.key === ' ') { nextRound(); e.preventDefault(); } return; } if (state.phase === 'error') { if (e.key === 'r') boot(); return; } if (state.phase !== 'guess') return;
switch (e.key) { case 'ArrowLeft': endTyping(); setYear(state.year - (e.shiftKey ? 10 : 1)); break; case 'ArrowRight': endTyping(); setYear(state.year + (e.shiftKey ? 10 : 1)); break; case 'ArrowDown': case 'PageDown': endTyping(); setYear(state.year - 10); break; case 'ArrowUp': case 'PageUp': endTyping(); setYear(state.year + 10); break; case 'Backspace': if (typed) untypeDigit(); else resetView(); break; case 'Escape': clearPin(); el.mapPanel.classList.remove('expanded'); break; case 'm': case 'M': setMapPinned(!el.mapPanel.classList.contains('pinned')); break; case 'f': case 'F': fillScreen(); break; case '+': case '=': zoomAt(1.25); break; case '-': case '_': zoomAt(1 / 1.25); break; case 'r': if (!el.photo.classList.contains('ready')) el.photo.src = roundImageUrl(state.daily.rounds[state.round]); break; default: if (/^[0-9]$/.test(e.key)) typeDigit(e.key); else return; } e.preventDefault(); }); }
async function checkForNewDaily() { if (!state.daily?.dailyId) return; try { const res = await fetch('/api/daily', { cache: 'no-store' }); if (!res.ok) return; const fresh = await res.json(); if (fresh.dailyId && fresh.dailyId !== state.daily.dailyId) location.reload(); } catch {} }
function bindUI() { el.guessBtn.addEventListener('click', submitGuess); // A TV tends to stay on; pick up the next day's daily when the page is looked at again. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkForNewDaily(); }); setInterval(checkForNewDaily, 15 * 60 * 1000); el.nextBtn.addEventListener('click', nextRound); document.querySelector('#photo-tools').addEventListener('click', (e) => { const btn = e.target.closest('button[data-action]'); if (!btn) return; switch (btn.dataset.action) { case 'zoom-in': zoomAt(1.25); break; case 'zoom-out': zoomAt(1 / 1.25); break; case 'fill': fillScreen(); break; case 'help': show(el.help); break; } btn.blur(); }); el.help.addEventListener('click', () => hide(el.help)); window.addEventListener('resize', () => { applyView(); guessMap.invalidateSize(); if (resultsMap && !el.results.hidden) resultsMap.invalidateSize(); }); }
// --------------------------------------------------------------------- // Boot // --------------------------------------------------------------------- function boot() { loadDaily().catch((err) => { console.error(err); setPhase('error'); setStatus(`Couldn’t load today’s daily: ${err.message}. Press R to retry.`); }); }
if (typeof L === 'undefined') { setStatus('The map library failed to load. Check the internet connection and reload.'); return; } initGuessMap(); bindPhoto(); bindYear(); bindMapPanel(); bindKeys(); bindUI(); boot();})();