/** * Today (DESIGN.md §12.3): the daily brief above the feed. * * - Since you last looked: the three biggest changes (timeline() and a * situations diff against a stored last-visit time). * - Today's Moment: one replay a day, playing inline on its own canvas. * - Overhead tonight: the next ISS pass and the next launch (real data). * - Your sky now: a mini sky dome; opens the full sheet. * - Your watch: followed places and things, and whether anything is unusual. */ import { icon } from './icons.js'; import { $, esc, ago, haptic, esriThumb, reducedMotion, store } from './dom.js'; import { bandOf, utc, cardinal } from './bands.js'; import { trackAt } from './stubMoments.js'; import { domeSvg } from './skySheet.js'; const LAST_KEY = 'gev.lastVisit'; const SKY_KEY = 'gev.sky.v1'; /** * The hero Moment's card, so it paints in the first frame (the full Moment * JSON is ~0.5 MB and arrives a moment later). Its picture is the same Esri * span the live card draws on, bundled (mobile/demo/moments/empire-state.jpg). */ const HERO_CARD = { id: 'empire-state', title: 'Climbers on the Empire State Building', place: 'Midtown Manhattan, New York', minutes: 25, tracks: 3, captures: 8, illustrative: true, }; const HERO_IMG = new URL('../demo/moments/empire-state.jpg', import.meta.url) .href; /** The last sky snapshot (this device), so the card never says "Looking up…". */ function readSky() { try { const j = JSON.parse(localStorage.getItem(SKY_KEY) || 'null'); if (j?.sky && Date.now() - j.at < 12 * 3600e3) return { ...j.sky, cachedAt: j.at }; } catch { /* ignore */ } return null; } function saveSky(sky) { try { const slim = { at: sky.at, observer: sky.observer, aircraft: (sky.aircraft || []).slice(0, 16).map((a) => ({ id: a.id, title: a.title, kind: a.kind, elevationDeg: a.elevationDeg, azimuthDeg: a.azimuthDeg, headingDeg: a.headingDeg, })), satellites: (sky.satellites || []).slice(0, 24).map((x) => ({ id: x.id, title: x.title, elevationDeg: x.elevationDeg, azimuthDeg: x.azimuthDeg, })), iss: sky.iss ? { visibleNow: false, nextPass: sky.iss.nextPass || null } : null, }; localStorage.setItem( SKY_KEY, JSON.stringify({ at: Date.now(), sky: slim }), ); } catch { /* per-device convenience only */ } } const SEEN_KEY = 'gev.seenIds'; function readLast() { try { const v = Number(localStorage.getItem(LAST_KEY)); if (Number.isFinite(v) && v > 0) return v; } catch { /* ignore */ } return Date.now() - 8 * 3600e3; } function readSeen() { try { return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]')); } catch { return new Set(); } } export function createToday( slot, { getSituations, watch, getHome, onOpenSituation, onOpenEntity, onOpenMoment, onSky, onWatch, }, ) { const lastVisit = readLast(); const seen = readSeen(); let globe = null; let sits = []; let moment = null; let sky = null; let raf = 0; let visible = true; slot.innerHTML = `

Since you last looked

Overhead tonight

Your watch

`; const mBtn = $('.bf-moment', slot); const mCanvas = $('.bf-mcanvas', slot); const mctx = mCanvas.getContext('2d'); // First frame: the hero card from its bundled picture and a few facts. { const img = $('.bf-mimg', slot); img.onload = () => img.classList.add('loaded'); img.src = HERO_IMG; $('.bf-mtitle', slot).textContent = HERO_CARD.title; $('.bf-msub', slot).textContent = `${HERO_CARD.place} · ${HERO_CARD.minutes} min in 20 s · ${HERO_CARD.tracks} helicopters · ${HERO_CARD.captures} sealed captures`; $('.bf-mtag', slot).innerHTML = 'Illustrative'; } /* ---------- since you last looked ---------- */ function changes() { const now = Date.now(); const out = []; const used = new Set(); let evts = []; try { evts = globe ? globe.timeline({ from: lastVisit, to: now }) : []; } catch { evts = []; } const ranked = [...evts].sort( (a, b) => (b.severity || 0) - (a.severity || 0) || b.t - a.t, ); for (const e of ranked) { const key = e.momentId || e.situationId || e.entityId || e.title; if (used.has(key)) continue; used.add(key); const s = e.situationId ? sits.find((x) => String(x.id) === String(e.situationId)) : null; out.push({ t: e.t, title: s?.title || e.title, sub: s?.place || (e.momentId ? 'Moment · replay it' : e.kind), band: e.band || (s ? bandOf(s) : 'ground'), momentId: e.kind === 'moment' || e.kind === 'capture' || e.kind === 'circling' ? e.momentId : null, situationId: e.situationId, entityId: e.entityId, sev: e.severity || 0, }); if (out.length >= 3) break; } // New situations since the last visit fill the rest. if (out.length < 3) for (const s of [...sits].sort( (a, b) => (b.severity || 0) - (a.severity || 0), )) { if (out.length >= 3) break; if ( used.has(s.id) || used.has(s.momentId) || (seen.size && seen.has(String(s.id))) ) continue; used.add(s.id); out.push({ t: +new Date(s.updatedAt), title: s.title, sub: s.place, band: bandOf(s), situationId: s.id, sev: s.severity || 0, isNew: !seen.has(String(s.id)), }); } return out; } function renderChanges() { const list = changes(); $('[data-since]', slot).textContent = `${ago(lastVisit).toUpperCase()}`; $('[data-changes]', slot).innerHTML = list .map( (c, i) => ``, ) .join('') || `
${icon('checkmark')}Nothing unusual since you last looked.
`; slot._changes = list; } /* ---------- today's moment ---------- */ let geom = null; function setMoment(m) { moment = m; if (!m) return; const span = 0.02; const img = $('.bf-mimg', slot); const r = mBtn.getBoundingClientRect(); // The card is 16:11 whatever its width (today.css). const w = Math.max(320, Math.round(r.width || 400)); const hgt = Math.round((w * 11) / 16); if (m.id !== HERO_CARD.id) { img.classList.remove('loaded'); img.src = esriThumb( m.lon, m.lat, span, Math.min(1024, w * 2), Math.min(1024, Math.round((hgt / w) * Math.min(1024, w * 2))), ); } else if (!img.src.includes('empire-state')) img.src = HERO_IMG; img.onload = () => img.classList.add('loaded'); const dLon = span / Math.cos((m.lat * Math.PI) / 180); const dLat = span * (hgt / w); geom = { lon0: m.lon - dLon / 2, lat1: m.lat + dLat / 2, dLon, dLat }; $('.bf-mtitle', slot).textContent = m.title; const dur = Math.round((m.time.end - m.time.start) / 60e3); $('.bf-msub', slot).textContent = `${m.place} · ${dur} min in 20 s · ${(m.tracks || []).length} helicopters · ${(m.captures || []).length} sealed captures`; $('.bf-mtag', slot).innerHTML = m.illustrative ? 'Illustrative' : 'Recorded'; mBtn.classList.add('ready'); startLoop(); } const TRACK_COL = ['#a3d8ff', '#ffd27a', '#ff9db0', '#9be15d']; function drawMoment(ts) { raf = 0; if (!moment || !geom) return; const r = mCanvas.getBoundingClientRect(); const dpr = Math.min(2, window.devicePixelRatio || 1); const W = r.width; const H = r.height; if (mCanvas.width !== Math.round(W * dpr)) { mCanvas.width = Math.round(W * dpr); mCanvas.height = Math.round(H * dpr); } const ctx = mctx; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H); const m = moment; const loop = 20000; const k = reducedMotion() ? 0.55 : (ts % loop) / loop; const t = m.time.start + k * (m.time.end - m.time.start); const px = (lon, lat) => [ ((lon - geom.lon0) / geom.dLon) * W, ((geom.lat1 - lat) / geom.dLat) * H, ]; (m.tracks || []).forEach((tr, i) => { const head = trackAt(tr, t); if (!head) return; const col = TRACK_COL[i % TRACK_COL.length]; const pts = tr.points; for (let j = Math.max(1, head.i - 24); j <= head.i; j += 1) { const a = px(pts[j - 1].lon, pts[j - 1].lat); const b = px(pts[j].lon, pts[j].lat); const f = 1 - (head.i - j) / 24; ctx.strokeStyle = col; ctx.globalAlpha = 0.1 + 0.8 * f * f; ctx.lineWidth = 1 + 1.8 * f; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke(); } ctx.globalAlpha = 1; const [x, y] = px(head.lon, head.lat); ctx.save(); ctx.translate(x, y); ctx.rotate(((head.headingDeg || 0) * Math.PI) / 180); ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(0, -6); ctx.lineTo(4, 4.5); ctx.lineTo(0, 2.4); ctx.lineTo(-4, 4.5); ctx.closePath(); ctx.fill(); ctx.restore(); }); for (const c of m.captures || []) { if (c.t > t) continue; const [x, y] = px(c.lon, c.lat); const age = (t - c.t) / 60e3; const f = Math.max(0, 1 - age / 1.2); const hd = ((c.headingDeg || 0) - 90) * (Math.PI / 180); const fov = ((c.fovDeg || 52) * Math.PI) / 360; const len = 46; const g = ctx.createRadialGradient(x, y, 1, x, y, len); g.addColorStop(0, `rgba(235,199,119,${0.45 + f * 0.4})`); g.addColorStop(1, 'rgba(235,199,119,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(x, y); ctx.arc(x, y, len, hd - fov, hd + fov); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#ebc777'; ctx.beginPath(); ctx.arc(x, y, 3 + f * 3, 0, Math.PI * 2); ctx.fill(); } const s = Math.round((t - m.time.start) / 1000); const day = new Date(m.time.start).toDateString() === new Date().toDateString() ? 'TODAY' : new Date(m.time.start) .toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) .toUpperCase(); $('.bf-mclock', slot).textContent = `${day} · ${utc(t)} · +${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; if (!reducedMotion()) startLoop(); } function startLoop() { if (!raf && visible && !document.hidden) raf = requestAnimationFrame(drawMoment); } new IntersectionObserver((es) => { visible = es.some((e) => e.isIntersecting); if (visible) startLoop(); }).observe(mBtn); document.addEventListener('visibilitychange', startLoop); /* ---------- overhead tonight ---------- */ const hm = (t) => new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); function tMinus(t) { const d = t - Date.now(); if (d < 0) return 'NOW'; const hrs = Math.floor(d / 3600e3); const mins = Math.round((d % 3600e3) / 60e3); return `T−${hrs ? `${hrs} H ` : ''}${mins} MIN`; } function renderTonight() { const rows = []; const pass = sky?.iss?.nextPass; const np = pass && (pass.end || pass.start + 600e3) > Date.now() ? pass : null; if (sky?.iss?.visibleNow) rows.push( ``, ); else if (np) rows.push( ``, ); const launches = sits .filter((s) => /launch/.test(s.kind || '')) .map((s) => { const ev = (s.events || []).find((e) => +new Date(e.at) > Date.now()); return { s, at: ev ? +new Date(ev.at) : +new Date(s.time?.start || 0) }; }) .filter((x) => x.at > Date.now()) .sort((a, b) => a.at - b.at); if (launches[0]) { const { s, at } = launches[0]; rows.push( ``, ); } $('[data-tonight]', slot).innerHTML = rows.join('') || (sky ? '
Clear skies, nothing scheduled.
' : '
'); } function renderSky() { const kicker = $('.bf-skyk', slot); if (!sky) { // Never looked from this device yet: an empty dome, counting. const home = getHome(); $('[data-dome]', slot).innerHTML = domeSvg( { aircraft: [], satellites: [] }, { size: 120, labels: false }, ); $('[data-skyh]', slot).textContent = 'Counting what’s above you'; $('[data-skyn]', slot).textContent = `Aircraft within 50 km and satellites over ${home.label || home.name}`; return; } kicker.innerHTML = sky.cachedAt ? `YOUR SKY · ${esc(ago(sky.cachedAt).toUpperCase())}` : 'YOUR SKY NOW'; $('[data-dome]', slot).innerHTML = domeSvg(sky, { size: 120, labels: false, }); const home = getHome(); const ac = (sky.aircraft || []).length; const sats = (sky.satellites || []).length; const top = (sky.aircraft || [])[0]; $('[data-skyh]', slot).textContent = sats ? `${ac} aircraft and ${sats} satellite${sats === 1 ? '' : 's'} above you` : `${ac} aircraft above you right now`; $('[data-skyn]', slot).textContent = top ? `Highest: ${top.title}, ${Math.round(top.elevationDeg)}° up ${cardinal(top.azimuthDeg)} · ${home.label || home.name}` : `A quiet sky over ${home.label || home.name}`; $('[data-tonight-where]', slot).textContent = ( home.label || home.name ).toUpperCase(); } /* ---------- your watch ---------- */ async function renderWatch() { const home = getHome(); const rules = watch.rules().filter((r) => r.on && r.of === 'home'); const hits = rules.flatMap((r) => watch.hits(r.id).map((x) => ({ r, x }))); const rows = []; rows.push( hits.length ? `` : `
${icon('home')}${esc(home.name)} · quietNothing unusual within ${Math.max(...rules.map((r) => r.within || 0), 200).toLocaleString()} km${icon('checkmark')}
`, ); slot._hits = hits; const followed = store.get('followed') || []; const things = ( await Promise.all( followed .slice(0, 3) .map((id) => watch.statusOf(id, sits).catch(() => null)), ) ).filter(Boolean); for (const t of things) rows.push( ``, ); if (!things.length) rows.push( ``, ); $('[data-watch]', slot).innerHTML = rows.join(''); } /* ---------- input ---------- */ slot.addEventListener('click', (e) => { const ch = e.target.closest('[data-ch]'); if (ch) { haptic('light'); const c = slot._changes[Number(ch.dataset.ch)]; if (c.momentId) return onOpenMoment(c.momentId); if (c.situationId) return onOpenSituation(c.situationId); if (c.entityId) return onOpenEntity(c.entityId); return; } if (e.target.closest('.bf-moment')) { haptic('light'); if (moment) onOpenMoment(moment.id); return; } if (e.target.closest('.bf-sky, [data-sky]')) { haptic('light'); return onSky(); } const sit = e.target.closest('[data-sit]'); if (sit) return onOpenSituation(sit.dataset.sit); const th = e.target.closest('[data-thing]'); if (th) return onOpenEntity(th.dataset.thing); const hit = e.target.closest('[data-hit]'); if (hit && slot._hits?.[0]) return onOpenSituation(slot._hits[0].x.id); if (e.target.closest('[data-manage]')) onWatch(); }); /* ---------- lifecycle ---------- */ function persistVisit() { try { localStorage.setItem(LAST_KEY, String(Date.now())); localStorage.setItem( SEEN_KEY, JSON.stringify(sits.map((s) => String(s.id)).slice(0, 200)), ); } catch { /* ignore */ } } document.addEventListener( 'visibilitychange', () => document.visibilityState === 'hidden' && persistVisit(), ); window.addEventListener('pagehide', persistVisit); async function refreshSky() { if (!globe) return; const home = getHome(); const fresh = await globe .skyNow({ lon: home.lon, lat: home.lat }) .catch(() => null); if (fresh) { sky = fresh; saveSky(fresh); } renderSky(); renderTonight(); } sky = readSky(); renderSky(); if (sky) renderTonight(); watch.onChange(() => renderWatch()); setInterval(() => { if (!document.hidden && slot.offsetParent) { refreshSky(); renderChanges(); } }, 15000); return { setSituations(list) { sits = list || []; renderChanges(); renderTonight(); renderWatch(); }, /** Before the globe: the bundled hero paints the card at once. */ setMoment(m) { if (!moment && m) setMoment(m); }, async setGlobe(g) { globe = g; renderChanges(); const m = await g.momentOfTheDay(); if ( m && (!moment || m.id !== moment.id || m.time?.start !== moment.time?.start) ) setMoment(m); await refreshSky(); renderWatch(); }, refresh() { renderChanges(); refreshSky(); renderWatch(); }, get moment() { return moment; }, lastVisit, }; }