/* 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 = '© OpenStreetMap 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: ``, 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 `
${esc(round.description)}