/**
* 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
? ``
: `