/** * Watch & alerts (DESIGN.md §12.1, §12.2, §12.3). * * Follow places and things; write alert rules as plain chips * ("Any [squawk 7700] within [200 km] of [Home]"). Rules are evaluated * against live data while the app is open and surface as calm, iOS-style * notification banners. "Nothing unusual near you" is a valid state. */ import { icon } from './icons.js'; import { h, esc, ago, haptic, toast, store, reducedMotion } from './dom.js'; import { createSheet } from './sheet.js'; import { distKm, fmtKm, utc, BAND_COLOR, bandOf } from './bands.js'; const RULES_KEY = 'gev.watch.v1'; export const WHAT = [ { id: 'squawk7700', label: 'squawk 7700', test: (s) => /squawk/.test(s.kind) && /7700/.test(`${s.title} ${s.telemetry}`), band: 'sky', }, { id: 'emergency', label: 'any emergency squawk', test: (s) => /squawk/.test(s.kind), band: 'sky', }, { id: 'circling', label: 'helicopters circling', test: (s) => /circling/.test(s.kind), band: 'sky', }, { id: 'quake5', label: 'M5+ quake', test: (s) => /quake/.test(s.kind) && magOf(s) >= 5, band: 'ground', }, { id: 'quake6', label: 'M6+ quake', test: (s) => /quake/.test(s.kind) && magOf(s) >= 6, band: 'ground', }, { id: 'fire', label: 'wildfire', test: (s) => /fire/.test(s.kind), band: 'ground', }, { id: 'launch', label: 'launch', test: (s) => /launch/.test(s.kind), band: 'orbit', }, { id: 'issPass', label: 'ISS visible pass', test: null, band: 'orbit' }, ]; const RADII = [25, 50, 200, 500, 1000, 20000]; function magOf(s) { const m = /M\s?(\d+(?:\.\d)?)/.exec(`${s.title} ${s.telemetry || ''}`); if (m) return Number(m[1]); const h = s.headline?.value; return Number(String(h || '').replace(/[^\d.]/g, '')) || 0; } const DEMO_HOME = { id: 'home', name: 'Home', label: 'Midtown, New York', sub: 'Midtown, New York · demo location', lon: -73.9851, lat: 40.7549, demo: true, }; function load() { try { const j = JSON.parse(localStorage.getItem(RULES_KEY) || 'null'); if (j?.rules) return j; } catch { /* defaults */ } return { places: [DEMO_HOME], rules: [ { id: 'r1', what: 'squawk7700', within: 200, of: 'home', on: true }, { id: 'r2', what: 'quake5', within: 500, of: 'home', on: true }, { id: 'r3', what: 'circling', within: 25, of: 'home', on: true }, { id: 'r4', what: 'issPass', within: 0, of: 'home', on: true }, ], fired: {}, }; } export function createWatch({ host, globe, getSituations, onOpenSituation, onOpenEntity, }) { let state = load(); const save = () => { try { localStorage.setItem(RULES_KEY, JSON.stringify(state)); } catch { /* per-viewer convenience only */ } }; const placeById = (id) => state.places.find((p) => p.id === id) || state.places[0]; const what = (id) => WHAT.find((w) => w.id === id) || WHAT[0]; /* ---------- banners (iOS notification style) ---------- */ const stack = h('
'); document.getElementById('app').appendChild(stack); function banner({ title, body, band = 'sky', iconName = 'bell', onTap, sticky = false, }) { const el = h(``); stack.prepend(el); haptic('success'); const dismiss = () => { el.classList.add('out'); setTimeout(() => el.remove(), reducedMotion() ? 0 : 380); }; el.addEventListener('click', () => { dismiss(); onTap?.(); }); // Swipe up to dismiss (touch and mouse). let y0 = null; el.addEventListener('pointerdown', (e) => (y0 = e.clientY)); el.addEventListener('pointermove', (e) => { if (y0 == null) return; const dy = Math.min(0, e.clientY - y0); el.style.transform = `translateY(${dy}px)`; if (dy < -40) { y0 = null; dismiss(); } }); el.addEventListener('pointerup', () => { y0 = null; el.style.transform = ''; }); if (!sticky) setTimeout(dismiss, 7000); while (stack.children.length > 2) stack.lastElementChild.remove(); return { dismiss }; } /* ---------- evaluation ---------- */ const hits = new Map(); // ruleId → [situations] async function evaluate({ quiet = false } = {}) { let sits = []; try { sits = [...(await getSituations()), ...globe.detections()]; } catch { return; } const fresh = []; for (const r of state.rules) { if (!r.on) continue; const pl = placeById(r.of); const w = what(r.what); if (w.id === 'issPass') { const sky = await globe .skyNow({ lon: pl.lon, lat: pl.lat }) .catch(() => null); const np = sky?.iss?.nextPass; const list = []; if (sky?.iss?.visibleNow) list.push({ id: 'iss-now', title: 'The ISS is above you now', when: Date.now(), }); else if ( np && np.start - Date.now() < 45 * 60e3 && np.start > Date.now() ) list.push({ id: `iss-${Math.round(np.start / 60e3)}`, title: `ISS pass at ${new Date(np.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`, when: np.start, np, }); hits.set(r.id, list); for (const x of list) if (!state.fired[`${r.id}:${x.id}`]) fresh.push({ r, x, pl, w }); continue; } const list = sits.filter((s) => { if (!w.test?.(s) || !Number.isFinite(s.lat)) return false; return r.within >= 20000 || distKm(pl, s) <= r.within; }); hits.set(r.id, list); for (const s of list) if (!state.fired[`${r.id}:${s.id}`]) fresh.push({ r, s, pl, w }); } if (!quiet) for (const f of fresh.slice(0, 2)) { state.fired[`${f.r.id}:${(f.s || f.x).id}`] = Date.now(); if (f.s) banner({ title: f.s.title, body: `${ruleText(f.r)} · ${fmtKm(distKm(f.pl, f.s))} from ${f.pl.name}`, band: bandOf(f.s), onTap: () => onOpenSituation?.(f.s), }); else banner({ title: f.x.title, body: f.x.np ? `Max ${f.x.np.maxElevationDeg ?? '—'}° · look up from ${f.pl.name}` : `Look up from ${f.pl.name}`, band: 'orbit', iconName: 'satellite', }); } else for (const f of fresh) state.fired[`${f.r.id}:${(f.s || f.x).id}`] = Date.now(); save(); listeners.forEach((fn) => fn()); } const listeners = new Set(); function ruleText(r) { const w = what(r.what); const pl = placeById(r.of); if (w.id === 'issPass') return `${w.label} over ${pl.name}`; return `Any ${w.label} ${r.within >= 20000 ? 'anywhere' : `within ${r.within.toLocaleString()} km of ${pl.name}`}`; } /* ---------- sheet ---------- */ const sheet = createSheet({ host, label: 'Watch and alerts', detents: ['large'], modal: true, dismissible: true, closeButton: true, className: 'watch-sheet', heights: { large: (H) => Math.round(H * 0.9) }, }); function chip(kind, rid, label) { return ``; } function ruleHtml(r) { const w = what(r.what); const pl = placeById(r.of); const n = (hits.get(r.id) || []).length; const sentence = w.id === 'issPass' ? `${chip('what', r.id, w.label)} over ${chip('of', r.id, pl.name)}` : `Any ${chip('what', r.id, w.label)} ${r.within >= 20000 ? chip('within', r.id, 'anywhere') : `within ${chip('within', r.id, `${r.within.toLocaleString()} km`)}`} of ${chip('of', r.id, pl.name)}`; return `

${sentence}

${r.on ? (n ? `${n} NOW` : 'QUIET') : 'OFF'}
`; } async function render() { sheet.head.innerHTML = `

Watch

Places and things you follow, and when to tap you on the shoulder
`; const followed = store.get('followed') || []; const sits = await getSituations().catch(() => []); const things = await Promise.all( followed.slice(0, 8).map((id) => statusOf(id, sits)), ); sheet.body.innerHTML = `
Places
${state.places .map( (p) => `
${icon(p.id === 'home' ? 'home' : 'pin')}${esc(p.name)}${esc(p.sub || `${p.lat.toFixed(3)}, ${p.lon.toFixed(3)}`)}${p.id === 'home' ? `` : ``}
`, ) .join('')}
Things
${ things.filter(Boolean).length ? things .filter(Boolean) .map( (t) => ``, ) .join('') : '
Follow an aircraft, ship, satellite or situation from its dossier.
' }
Alert rules
${state.rules.map(ruleHtml).join('')}

Calm by default: rules are checked against live data while God’s Eye is open, at most two banners at a time, and never for anything outside your rules. A quiet rule means nothing unusual.

`; } async function statusOf(id, sits) { const s = sits.find((x) => String(x.id) === String(id)); if (s) { const m = s.metrics?.[0]; return { id, kind: 'situation', title: s.title, status: `${s.place || ''}${m ? ` · ${m.value}${m.unit ? ` ${m.unit}` : ''}` : ''} · ${ago(s.updatedAt)}`, band: bandOf(s), icon: 'now', s, }; } const d = await globe.entity(id).catch(() => null); if (!d) return null; const L = d.live; let status = d.subtitle || ''; if (L && Number.isFinite(L.altM) && L.altM > 0) status = L.altM > 100e3 ? `${Math.round(L.altM / 1000)} km up · ${L.speed ? `${(L.speed / 1000).toFixed(2)} km/s` : ''}` : `${Math.round(L.altM * 3.28084).toLocaleString()} ft · ${L.speed ? `${Math.round(L.speed * 1.944)} kt` : ''}`; const band = { satellite: 'orbit', aircraft: 'sky', helicopter: 'sky', military: 'sky', }[d.kind] || 'ground'; return { id, kind: d.kind, title: d.title, status, band, icon: { satellite: 'satellite', aircraft: 'sky', helicopter: 'heli', vessel: 'sea', place: 'pin', moment: 'film', }[d.kind] || 'scope', d, }; } function cycle(rule, kind) { if (kind === 'what') { const i = WHAT.findIndex((w) => w.id === rule.what); rule.what = WHAT[(i + 1) % WHAT.length].id; } else if (kind === 'within') { const i = RADII.indexOf(rule.within); rule.within = RADII[(i + 1) % RADII.length]; } else if (kind === 'of') { const i = state.places.findIndex((p) => p.id === rule.of); rule.of = state.places[(i + 1) % state.places.length].id; } } sheet.body.addEventListener('click', async (e) => { const c = e.target.closest('[data-chip]'); if (c) { haptic('tick'); const r = state.rules.find((x) => x.id === c.dataset.rule); cycle(r, c.dataset.chip); save(); await evaluate({ quiet: true }); render(); return; } const t = e.target.closest('[data-toggle]'); if (t) { haptic('tick'); const r = state.rules.find((x) => x.id === t.dataset.toggle); r.on = !r.on; save(); render(); return; } if (e.target.closest('[data-new-rule]')) { state.rules.push({ id: `r${Date.now().toString(36)}`, what: 'quake5', within: 500, of: state.places[0].id, on: true, }); save(); haptic('light'); render(); return; } if (e.target.closest('[data-add-place]')) { const c2 = globe.raw?.camera?.() || null; const v = lastCam || c2; if (!v) return toast('Open Live and frame a place first', { icon: icon('globe'), }); state.places.push({ id: `p${Date.now().toString(36)}`, name: `Place ${state.places.length}`, lon: v.lon, lat: v.lat, }); save(); toast('Watching this place', { icon: icon('pin') }); render(); return; } const rm = e.target.closest('[data-rm-place]'); if (rm) { state.places = state.places.filter((p) => p.id !== rm.dataset.rmPlace); state.rules = state.rules.filter((r) => r.of !== rm.dataset.rmPlace); save(); render(); return; } if (e.target.closest('[data-home="locate"]')) { navigator.geolocation?.getCurrentPosition( (p) => { setHome({ lat: p.coords.latitude, lon: p.coords.longitude }); render(); }, () => toast('Location unavailable · using the demo home', { icon: icon('location'), }), ); return; } const th = e.target.closest('[data-thing]'); if (th) { sheet.close(); onOpenEntity?.(th.dataset.thing); } }); let lastCam = null; globe.on('camera', (c) => (lastCam = c)); function setHome(loc) { const home = state.places.find((p) => p.id === 'home'); Object.assign(home, { lon: loc.lon, lat: loc.lat, sub: 'Your location', label: 'your location', demo: false, }); save(); } setInterval(() => { if (!document.hidden) evaluate(); }, 30000); return { banner, evaluate, open() { render(); sheet.open('large'); }, home: () => placeById('home'), /** One line for You: "Home · 4 rules · 1 on now". */ summary() { const on = state.rules.filter((r) => r.on); const now = on.reduce((a, r) => a + (hits.get(r.id) || []).length, 0); return `${state.places.length} place${state.places.length === 1 ? '' : 's'} · ${on.length} rule${on.length === 1 ? '' : 's'}${now ? ` · ${now} on now` : ' · all quiet'}`; }, setHome, places: () => state.places, rules: () => state.rules, hits: (rid) => hits.get(rid) || [], ruleText, statusOf, onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }, /** From a dossier's "Alert me". */ ruleFor(d) { const pl = d.live && Number.isFinite(d.live.lat) ? d.live : null; let rule; if (d.kind === 'satellite' && /iss|zarya/i.test(d.title)) rule = { what: 'issPass', within: 0, of: 'home' }; else if (pl) { const id = `p${Date.now().toString(36)}`; state.places.push({ id, name: d.kind === 'place' ? d.title : `Near ${d.title}`, lon: pl.lon, lat: pl.lat, }); rule = { what: /quake/.test(d.kind) ? 'quake5' : /heli|moment|capture/.test(d.kind) ? 'circling' : 'emergency', within: 50, of: id, }; } else rule = { what: 'emergency', within: 200, of: 'home' }; const r = { id: `r${Date.now().toString(36)}`, on: true, ...rule }; state.rules.push(r); save(); haptic('success'); toast(`Alert set · ${ruleText(r)}`, { icon: icon('bell') }); evaluate({ quiet: true }); }, noteFollow() { listeners.forEach((fn) => fn()); }, reset() { try { localStorage.removeItem(RULES_KEY); } catch { /* ignore */ } state = load(); hits.clear(); }, bandColor: (b) => BAND_COLOR[b], utc, }; }