wip(mobile): checkpoint engine and experience round three in progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-23 07:29:27 +00:00
1 parent a6f98ce3c1
commit a720b10913
83 files changed
+50005 -416

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
/**
* Director (DESIGN.md §12.8): a presenter menu for demos. Press "." (or
* triple-tap the Today title) to open it; each beat sets up its state
* deterministically so the demo never depends on luck. Number keys 1–9 jump
* straight to a beat while the menu is closed and the director is armed.
*/
import { icon } from './icons.js';
import { esc, haptic, toast } from './dom.js';
import { createSheet } from './sheet.js';
export function createDirector({ beats, onReset }) {
const sheet = createSheet({
label: 'Director',
detents: ['large'],
modal: true,
dismissible: true,
closeButton: true,
className: 'director-sheet',
heights: { large: (H) => Math.min(Math.round(H * 0.9), 820) },
});
let armed = false;
let current = -1;
function render() {
sheet.head.innerHTML = `<div class="dir-kicker tele">${icon('director')}DIRECTOR · DEMO MODE</div><h2 class="sheet-title">The story, beat by beat</h2><div class="sheet-sub">Each beat sets its own state. Keys 1–9 jump; “.” reopens this menu.</div>`;
sheet.body.innerHTML = `<ol class="dir-beats">${beats
.map(
(
b,
i,
) => `<li><button class="dir-beat ${i === current ? 'done' : ''}" type="button" data-beat="${i}">
<span class="dir-n num">${i + 1}</span><span class="dir-main"><b>${esc(b.title)}</b><small>${esc(b.note || '')}</small></span>${icon(b.icon || 'chevronRight')}</button></li>`,
)
.join('')}</ol>
<div class="dir-foot"><button class="pill-btn plain" type="button" data-reset>${icon('refresh')}Reset demo</button><span class="tele">Real data shows its source and age · illustrative content is labelled</span></div>`;
}
async function run(i) {
const b = beats[i];
if (!b) return;
current = i;
armed = true;
haptic('medium');
if (sheet.isOpen) sheet.close();
try {
await b.run();
} catch (err) {
console.error('[director] beat failed', err);
toast(`Beat “${b.title}” could not be set up`, { icon: icon('info') });
}
}
sheet.body.addEventListener('click', async (e) => {
const b = e.target.closest('[data-beat]');
if (b) return run(Number(b.dataset.beat));
if (e.target.closest('[data-reset]')) {
sheet.close();
current = -1;
await onReset?.();
toast('Demo reset', { icon: icon('refresh') });
}
});
document.addEventListener('keydown', (e) => {
if (e.target.closest?.('input, textarea, [contenteditable]')) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === '.') {
e.preventDefault();
if (sheet.isOpen) sheet.close();
else open();
return;
}
if (armed && /^[1-9]$/.test(e.key) && !sheet.isOpen) run(Number(e.key) - 1);
});
function open() {
armed = true;
render();
sheet.open('large');
}
/** Triple-tap (or triple-click) on an element opens the director. */
function bindTripleTap(el) {
let taps = [];
el.addEventListener('pointerup', () => {
const n = performance.now();
taps = [...taps.filter((t) => n - t < 650), n];
if (taps.length >= 3) {
taps = [];
open();
}
});
}
return {
open,
run,
bindTripleTap,
get isOpen() {
return sheet.isOpen;
},
};
}
+473
View File
@@ -0,0 +1,473 @@
/**
* Dossiers (DESIGN.md §12.1): every object has identity, live state, a track
* history, events, links and receipts. Tapping any entity anywhere (a globe
* pick, a search result, a track in a Moment, a node in the link graph)
* opens one. The Graph segment shows globe.graph(ref); globe, time machine
* and graph share one selection.
*/
import { icon } from './icons.js';
import {
$,
$$,
esc,
ago,
fmtNum,
haptic,
toast,
store,
sparkPath,
} from './dom.js';
import { createSheet } from './sheet.js';
import { createLinkGraph } from './linkGraph.js';
import { BAND_NAME, utc, llShort, mgrs, cardinal } from './bands.js';
import { bandOfKind } from './shim3.js';
export const KIND_ICON = {
aircraft: 'sky',
helicopter: 'heli',
military: 'sky',
satellite: 'satellite',
station: 'satellite',
vessel: 'sea',
quake: 'hazards',
fire: 'flame',
camera: 'video',
situation: 'now',
place: 'pin',
moment: 'film',
track: 'scope',
capture: 'seal',
};
export const KIND_NAME = {
aircraft: 'Aircraft',
helicopter: 'Helicopter',
military: 'Military aircraft',
satellite: 'Satellite',
vessel: 'Vessel',
quake: 'Earthquake',
camera: 'Public camera',
situation: 'Situation',
place: 'Place',
moment: 'Moment',
track: 'Track',
capture: 'Sealed capture',
};
const ID_LABEL = {
icao24: 'ICAO 24-bit',
callsign: 'Callsign',
registration: 'Registration',
norad: 'NORAD ID',
mmsi: 'MMSI',
};
const cap = (x) => (x ? `${x[0].toUpperCase()}${x.slice(1)}` : x);
function liveRows(d) {
const L = d.live;
if (!L) return [];
const rows = [];
if (Number.isFinite(L.altM) && L.altM > 0)
rows.push([
'Altitude',
L.altM > 100e3
? fmtNum(Math.round(L.altM / 1000))
: fmtNum(Math.round((L.altM * 3.28084) / 10) * 10),
L.altM > 100e3 ? 'km' : 'ft',
]);
if (Number.isFinite(L.speed) && L.speed > 0)
rows.push(
d.kind === 'satellite'
? ['Speed', (L.speed / 1000).toFixed(2), 'km/s']
: ['Speed', fmtNum(Math.round(L.speed * 1.94384)), 'kt'],
);
if (Number.isFinite(L.heading))
rows.push([
'Heading',
`${Math.round(((L.heading % 360) + 360) % 360)}°`,
cardinal(L.heading),
]);
if (L.squawk) rows.push(['Squawk', L.squawk, '']);
if (!rows.length && Number.isFinite(L.lat))
rows.push(['Position', llShort(L.lat, L.lon, 3), '']);
return rows.slice(0, 4);
}
/** Altitude profile (area) + ground track (polyline), both from real points. */
function trackHtml(d) {
const tr = (d.track || []).filter((p) => Number.isFinite(p.t));
if (tr.length < 3) return '';
if (Number.isFinite(tr[0].v)) {
const W = 300;
const H = 64;
const path = sparkPath(
tr.map((p) => p.v),
W,
H,
3,
);
return `<section class="ds-block"><h3 class="ds-h">History<small>${tr.length} samples · ${esc(ago(tr[0].t))} → now</small></h3>
<svg class="ds-alt" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"><path class="ds-area" d="${path}L${W - 3} ${H}L3 ${H}Z"/><path class="ds-line" d="${path}"/></svg></section>`;
}
const W = 300;
const H = 70;
const alts = tr.map((p) => (Number.isFinite(p.altM) ? p.altM : 0));
const hi = Math.max(...alts);
const lo = Math.min(0, ...alts);
const t0 = tr[0].t;
const t1 = tr[tr.length - 1].t;
const px = (t) => 3 + ((t - t0) / Math.max(1, t1 - t0)) * (W - 6);
const py = (a) => H - 4 - ((a - lo) / Math.max(1, hi - lo)) * (H - 14);
const line = tr
.map(
(p, i) =>
`${i ? 'L' : 'M'}${px(p.t).toFixed(1)} ${py(alts[i]).toFixed(1)}`,
)
.join('');
// Ground track, equirectangular around its centre.
const lons = tr.map((p) => p.lon);
const lats = tr.map((p) => p.lat);
const cl = Math.cos(
((Math.min(...lats) + Math.max(...lats)) / 2) * (Math.PI / 180),
);
const spanX = (Math.max(...lons) - Math.min(...lons)) * cl || 1e-6;
const spanY = Math.max(...lats) - Math.min(...lats) || 1e-6;
const S = 84;
const k = (S - 12) / Math.max(spanX, spanY);
const gx = (lon) =>
S / 2 + (lon - (Math.min(...lons) + Math.max(...lons)) / 2) * cl * k;
const gy = (lat) =>
S / 2 - (lat - (Math.min(...lats) + Math.max(...lats)) / 2) * k;
const g = tr
.map(
(p, i) =>
`${i ? 'L' : 'M'}${gx(p.lon).toFixed(1)} ${gy(p.lat).toFixed(1)}`,
)
.join('');
const last = tr[tr.length - 1];
const isOrbit = d.kind === 'satellite';
return `<section class="ds-block"><h3 class="ds-h">Track<small>${tr.length} points · ${esc(utc(t0, { seconds: false }))} → ${esc(utc(t1, { seconds: false }))}</small></h3>
<div class="ds-track">
<figure class="ds-fig"><svg class="ds-alt" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-label="Altitude profile">
<path class="ds-area" d="${line}L${px(t1)} ${H}L${px(t0)} ${H}Z"/><path class="ds-line" d="${line}"/>
<circle class="ds-end" cx="${px(t1)}" cy="${py(alts[alts.length - 1])}" r="3"/></svg>
<figcaption class="tele"><span>${isOrbit ? 'ALT' : 'ALT PROFILE'}</span><span>${esc(isOrbit ? `${fmtNum(Math.round(hi / 1000))} KM` : `${fmtNum(Math.round((hi * 3.28084) / 10) * 10)} FT MAX`)}</span></figcaption></figure>
<figure class="ds-fig ds-ground"><svg viewBox="0 0 ${S} ${S}" aria-label="Ground track"><path class="ds-gline" d="${g}"/><circle class="ds-end" cx="${gx(last.lon)}" cy="${gy(last.lat)}" r="3.2"/></svg>
<figcaption class="tele"><span>GROUND</span></figcaption></figure>
</div></section>`;
}
export function createDossier({
globe,
host,
onOpenSituation,
onOpenMoment,
onOpenRoom,
onShowOnTime,
watch,
}) {
const sheet = createSheet({
host,
label: 'Dossier',
detents: ['medium', 'large'],
dismissible: true,
closeButton: true,
className: 'dossier-sheet',
heights: {
medium: (H) => Math.min(Math.round(H * 0.56), 560),
large: (H) => Math.round(H * 0.93),
},
});
let stack = [];
let d = null;
let seg = 'dossier';
let graph = null;
let refreshTimer = 0;
let token = 0;
sheet.on('close', () => {
clearInterval(refreshTimer);
graph?.destroy();
graph = null;
globe.select(null);
stack = [];
d = null;
});
function headHtml() {
const band = bandOfKind(d.kind);
const status = d.illustrative
? '<span class="tag-ill">Illustrative</span>'
: d.live && Date.now() - (d.live.updatedAt || 0) < 5 * 60e3
? '<span class="live-tag"><span class="live-dot"></span>Live</span>'
: '<span>Recorded</span>';
return `<div class="ds-head" data-band="${band}">
${stack.length > 1 ? `<button class="ds-back icon-btn" type="button" aria-label="Back to ${esc(stack[stack.length - 2].title || 'previous')}">${icon('chevronLeft')}</button>` : ''}
<div class="ds-kicker tele"><i class="bd"></i><span>${esc(KIND_NAME[d.kind] || d.kind)}</span><span>·</span><span>${esc(BAND_NAME[band])}</span>${status}</div>
<div class="ds-title-row"><span class="ds-ic">${icon(KIND_ICON[d.kind] || 'scope')}</span><div><h2 class="sheet-title">${esc(d.title)}</h2><div class="sheet-sub">${esc(d.subtitle || '')}</div></div></div>
<div class="segmented ds-seg" role="tablist" aria-label="View">
<button role="tab" type="button" data-seg="dossier" aria-selected="${seg === 'dossier'}">Dossier</button>
<button role="tab" type="button" data-seg="graph" aria-selected="${seg === 'graph'}">${icon('graph')}Links graph</button>
</div></div>`;
}
function followKey() {
return d.kind === 'situation' ? d.id.replace(/^situation:/, '') : d.id;
}
function bodyHtml() {
const rows = liveRows(d);
const followed = store.isFollowed(followKey());
const ident = Object.entries(d.identity || {}).filter(
([, v]) => v != null && v !== '',
);
const L = d.live;
const isMoment = d.kind === 'moment';
return `
${
rows.length
? `<div class="ds-live">${rows.map(([k, v, u]) => `<div><span class="tele">${esc(k)}</span><b class="num">${esc(v)}<small>${esc(u)}</small></b></div>`).join('')}</div>
<div class="ds-updated tele">${L?.updatedAt ? `<span class="live-dot"></span>UPDATED <span data-ago="${L.updatedAt}">${esc(ago(L.updatedAt).toUpperCase())}</span>` : ''}${Number.isFinite(L?.lat) ? ` · ${esc(mgrs(L.lat, L.lon, 4) || llShort(L.lat, L.lon, 3))}` : ''}</div>`
: ''
}
<div class="ds-actions" role="group" aria-label="Actions">
${isMoment ? `<button class="ds-act primary" type="button" data-ds="play">${icon('play')}<span>Play</span></button>` : ''}
<button class="ds-act" type="button" data-ds="follow" aria-pressed="${followed}">${icon(followed ? 'starFill' : 'star')}<span>${followed ? 'Following' : 'Follow'}</span></button>
<button class="ds-act" type="button" data-ds="alert">${icon('bell')}<span>Alert me</span></button>
<button class="ds-act" type="button" data-ds="room">${icon('rooms')}<span>Open room</span></button>
<button class="ds-act" type="button" data-ds="time">${icon('clock')}<span>Time machine</span></button>
</div>
${d.privacy ? `<div class="ds-privacy">${icon('lock')}<span><b>Owner privacy (${esc(String(d.privacy).toUpperCase())})</b> Identity is limited to what the transponder broadcasts. We never name aircraft on privacy lists.</span></div>` : ''}
${trackHtml(d)}
${ident.length ? `<section class="ds-block"><h3 class="ds-h">Identity</h3><dl class="ds-id">${ident.map(([k, v]) => `<dt>${esc(ID_LABEL[k] || cap(k))}</dt><dd class="${/icao|norad|mmsi|registration/.test(k) ? 'mono' : ''}">${esc(v)}</dd>`).join('')}</dl></section>` : ''}
${
d.events?.length
? `<section class="ds-block"><h3 class="ds-h">Events<small>tap to see it on the time machine</small></h3><ol class="ds-events">${d.events
.slice(-8)
.map(
(e) =>
`<li><button type="button" data-t="${e.t}"><span class="tele">${esc(utc(e.t, { seconds: false }))}</span><span>${esc(e.title)}</span><small>${esc(ago(e.t))}</small></button></li>`,
)
.join('')}</ol></section>`
: ''
}
${
d.links?.length
? `<section class="ds-block"><h3 class="ds-h">Links<small>${d.links.length} · things, places and events only</small></h3><div class="ds-links">${d.links
.slice(0, 12)
.map(
(l) =>
`<button class="ds-link" type="button" data-link="${esc(l.id)}" data-band="${bandOfKind(l.kind)}"><span class="ds-link-ic">${icon(KIND_ICON[l.kind] || 'scope')}</span><span class="ds-link-main"><b>${esc(l.title)}</b><small>${esc(cap(l.relation || ''))}${Number.isFinite(l.distanceKm) ? ` · ${esc(l.distanceKm < 1 ? `${Math.round(l.distanceKm * 1000)} m` : `${fmtNum(l.distanceKm, l.distanceKm < 10 ? 1 : 0)} km`)}` : ''}</small></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')}</div></section>`
: ''
}
<section class="ds-block"><h3 class="ds-h">${icon('receipt')}Receipts<small>every fact keeps its source</small></h3><div class="ds-receipts">${(
d.sources || []
)
.map(
(s) =>
`<div class="ds-rc"><div><b>${esc(s.name)}</b><small>${esc(s.fetchedAt ? `fetched ${ago(s.fetchedAt)}` : '')}${s.url ? ` · <a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(new URL(s.url, location.href).hostname)}</a>` : ''}</small></div><button class="ds-hash mono" type="button" data-hash="${esc(s.hash || '')}" title="Copy hash">${esc(
s.hash
? `${String(s.hash)
.replace(/^sha256:/, '')
.slice(0, 10)}…${String(s.hash).slice(-6)}`
: '—',
)}</button></div>`,
)
.join('')}</div></section>
<p class="ds-foot">${icon('lock')}We track things that broadcast publicly, public infrastructure and events. Never people: no person search, no faces.</p>`;
}
async function show(ref, { push = true, detent } = {}) {
const my = ++token;
if (!sheet.isOpen) {
sheet.head.innerHTML = `<div class="ds-head"><div class="ds-kicker tele">Opening dossier…</div><h2 class="sheet-title ds-skel">&nbsp;</h2></div>`;
sheet.body.innerHTML = '';
sheet.open(detent || 'medium');
}
const res = await globe.entity(ref);
if (my !== token) return;
if (!res) {
sheet.close();
toast('No dossier for that yet', { icon: icon('info') });
return;
}
d = res;
if (push) stack.push({ ref: d.id, title: d.title });
render();
if (detent) sheet.setDetent(detent);
sync();
clearInterval(refreshTimer);
if (d.live && !d.illustrative)
refreshTimer = setInterval(async () => {
if (!sheet.isOpen || seg !== 'dossier') return;
const nd = await globe.entity(d.id);
if (!nd || nd.id !== d?.id) return;
d = { ...nd, links: d.links };
const live = $('.ds-live', sheet.body);
if (live)
live.innerHTML = liveRows(d)
.map(
([k, v, u]) =>
`<div><span class="tele">${esc(k)}</span><b class="num">${esc(v)}<small>${esc(u)}</small></b></div>`,
)
.join('');
$$('[data-ago]', sheet.body).forEach((el) => {
el.dataset.ago = d.live?.updatedAt;
el.textContent = ago(d.live?.updatedAt).toUpperCase();
});
}, 5000);
}
function render() {
sheet.sheet.dataset.band = bandOfKind(d.kind);
sheet.sheet.dataset.seg = seg;
sheet.head.innerHTML = headHtml();
graph?.destroy();
graph = null;
if (seg === 'graph') {
sheet.body.innerHTML =
'<div class="ds-graph"></div><p class="ds-graph-note tele">Drag a node · tap one to open its dossier</p>';
graph = createLinkGraph($('.ds-graph', sheet.body), {
onOpen: (n) => {
haptic('light');
seg = 'dossier';
show(n.id);
},
onSelect: (n) => selectOnGlobe(n),
});
graph.set(globe.graph(d.id), d.id);
if (sheet.detent !== 'large') sheet.setDetent('large');
} else {
sheet.body.innerHTML = bodyHtml();
sheet.body.scrollTop = 0;
}
}
/** One selection: the globe follows the dossier. */
function sync() {
const L = d.live;
if (!L || !Number.isFinite(L.lat)) return;
// Historical (Moment) things are shown by the Moment player, not live.
if (d.illustrative && d.kind !== 'moment') return;
const raw = String(d.id).replace(/^[a-z]+:/, '');
globe.select(raw);
const height = {
satellite: 2600e3,
aircraft: 60e3,
military: 80e3,
helicopter: 4000,
vessel: 30e3,
place: 6000,
capture: 2500,
moment: 3500,
camera: 3000,
quake: 400e3,
}[d.kind];
if (height)
globe.flyTo({
lon: L.lon,
lat: L.lat,
height,
pitch: -55,
duration: 1.8,
});
}
function selectOnGlobe(n) {
globe.select(String(n.id).replace(/^[a-z]+:/, ''));
}
sheet.head.addEventListener('click', (e) => {
const s = e.target.closest('[data-seg]');
if (s && s.dataset.seg !== seg) {
haptic('tick');
seg = s.dataset.seg;
render();
return;
}
if (e.target.closest('.ds-back')) {
stack.pop();
const prev = stack[stack.length - 1];
if (prev) show(prev.ref, { push: false });
}
});
sheet.body.addEventListener('click', async (e) => {
const link = e.target.closest('[data-link]');
if (link) {
haptic('light');
const id = link.dataset.link;
if (/^situation:/.test(id) && onOpenSituation) {
const ok = await onOpenSituation(id.replace(/^situation:/, ''), {
fromDossier: true,
});
if (ok) return;
}
return show(id);
}
const ev = e.target.closest('[data-t]');
if (ev) {
haptic('tick');
onShowOnTime?.(d, Number(ev.dataset.t));
return;
}
const hash = e.target.closest('[data-hash]');
if (hash) {
try {
await navigator.clipboard.writeText(hash.dataset.hash);
toast('Hash copied', { icon: icon('receipt') });
} catch {
toast(hash.dataset.hash.slice(0, 32), { icon: icon('receipt') });
}
return;
}
const act = e.target.closest('[data-ds]')?.dataset.ds;
if (!act || !d) return;
haptic('light');
if (act === 'follow') {
const on = store.toggleFollow(followKey());
watch?.noteFollow?.(d, on);
toast(on ? `Following ${d.title}` : 'Unfollowed', {
icon: icon(on ? 'starFill' : 'star'),
});
render();
} else if (act === 'alert') watch?.ruleFor(d);
else if (act === 'room') onOpenRoom?.(d);
else if (act === 'time') onShowOnTime?.(d);
else if (act === 'play') onOpenMoment?.(d.id.replace(/^moment:/, ''));
});
// Picks on the globe highlight the same node in the graph.
globe.on('pick', (p) => {
if (!graph || !p) return;
const id = [
...[
`flights:${p.id}`,
`military:${p.id}`,
`satellites:${p.id}`,
`vessels:${p.id}`,
`situation:${p.id}`,
String(p.id),
],
].find((x) => graph.has(x));
if (id) graph.select(id);
});
return {
open: (ref, o = {}) => {
seg = o.graph ? 'graph' : 'dossier';
stack = [];
return show(ref, { detent: o.detent || (o.graph ? 'large' : 'medium') });
},
close: () => sheet.close(),
get isOpen() {
return sheet.isOpen;
},
get current() {
return d;
},
sheet,
};
}
+56 -11
View File
@@ -110,16 +110,19 @@ export function createFeed(
},
) {
view.innerHTML = `
<header class="navbar"><span class="nav-title">Now</span>
<div class="nav-trailing"><button type="button" class="near-chip" data-act="near" aria-pressed="false">${icon('location')}<span>Near you</span></button><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
<header class="navbar"><span class="nav-title">Today</span>
<div class="nav-trailing"><span class="fresh-chip tele" hidden></span><button type="button" class="near-chip" data-act="near" aria-pressed="false">${icon('location')}<span>Near you</span></button><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
</header>
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `<i style="--i:${i}"></i>`).join('')}</div></div>
<div class="feed-inner">
<div class="feed-hero">
<h1 class="large-title">Now</h1>
<div class="feed-clock tele" aria-hidden="true"><b data-utc></b><span data-date></span></div>
<div class="feed-hero today-hero">
<div class="today-date tele" data-date></div>
<h1 class="large-title today-title"><span>Your world</span><span class="today-time" data-localtime></span></h1>
<div class="feed-clock tele" aria-hidden="true"><b data-utc></b></div>
</div>
<div class="title-sub tele"><span class="live-dot"></span><span class="feed-count">Acquiring the planet…</span></div>
<div class="brief-slot"></div>
<div class="feed-section-h"><h2>Around the world</h2><span class="tele">LIVE · EVERY BAND</span></div>
<div class="bandbar" role="tablist" aria-label="Altitude" data-hscroll>
<button role="tab" type="button" data-band="all" aria-selected="true"><span class="bb-n">All</span><span class="bb-r tele" data-count="all">—</span></button>
${BANDS.map((b) => `<button role="tab" type="button" data-band="${b.id}" aria-selected="false"><span class="bb-n"><i></i>${b.name}</span><span class="bb-r tele"><span data-count="${b.id}">—</span> · ${BAND_RANGE[b.id]}</span></button>`).join('')}
@@ -150,16 +153,24 @@ export function createFeed(
const now = new Date();
const u = utc(now);
const hd = $('[data-utc]', view);
if (hd) hd.textContent = `${u} UTC`.replace('Z UTC', ' UTC');
if (hd) hd.textContent = u;
const dt = $('[data-date]', view);
if (dt)
dt.textContent = now
.toLocaleDateString('en-GB', {
weekday: 'short',
day: '2-digit',
month: 'short',
weekday: 'long',
day: 'numeric',
month: 'long',
})
.toUpperCase();
const lt = $('[data-localtime]', view);
if (lt) {
const txt = now.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
if (lt.textContent !== txt) lt.textContent = txt;
}
if (view.hidden) return;
$$('[data-clock]', fbody).forEach((el) => (el.textContent = u));
$$('[data-since]', fbody).forEach((el) => {
@@ -206,7 +217,12 @@ export function createFeed(
s,
archetype: archetypeOf(s),
band: bandOf(s),
prio: (s.severity || 0) * 10 - i * 0.6 + (s.live ? 2 : 0),
prio:
(s.severity || 0) * 10 -
i * 0.6 +
(s.live ? 2 : 0) +
// Detections from real data (circling, emergency squawks) lead.
(/circling|squawk|quake-cluster/.test(s.kind || '') ? 60 : 0),
}));
// The street is the most Citizen-like card: one live eye right after
// the two most severe situations.
@@ -214,6 +230,9 @@ export function createFeed(
const eye = ranked.find((x) => x.archetype === 'eye');
if (eye && ranked[1] && ranked.indexOf(eye) > 2)
eye.prio = ranked[1].prio - 0.1;
// A detection is the first card: never shuffled away by diversity.
for (const it of items)
if (/circling|squawk|quake-cluster/.test(it.s.kind || '')) it.lead = true;
return items;
}
@@ -294,7 +313,9 @@ export function createFeed(
let list = [...items, ...roomItems, ...wit];
if (band !== 'all') list = list.filter((x) => x.band === band);
list.sort((a, b) => b.prio - a.prio);
return near ? list : diversify(list);
if (near) return list;
const lead = list.filter((x) => x.lead);
return [...lead, ...diversify(list.filter((x) => !x.lead))];
}
/* ---------- layout ---------- */
@@ -763,8 +784,32 @@ export function createFeed(
if (loc) globe.setViewer?.({ lon: loc.lon, lat: loc.lat });
}
/** Instant first frame: paint cached situations before the live call. */
function prime(list) {
if (!Array.isArray(list) || !list.length || all.length) return false;
all = list;
render();
count.textContent = `${count.textContent} · last good snapshot`;
return true;
}
let freshTimer = 0;
function markUpdated() {
const chip = $('.fresh-chip', view);
chip.textContent = 'Updated just now';
chip.hidden = false;
chip.classList.remove('in');
void chip.offsetWidth;
chip.classList.add('in');
clearTimeout(freshTimer);
freshTimer = setTimeout(() => (chip.hidden = true), 4200);
}
return {
load,
prime,
markUpdated,
briefSlot: $('.brief-slot', view),
titleEl: $('.today-title', view),
refresh,
render,
refreshSocial: socialChanged,
+33 -2
View File
@@ -19,7 +19,7 @@ import {
import { createSheet } from './sheet.js';
import { createTimeRibbon } from './timeRibbon.js';
import { createEyeViewer } from './eyeViewer.js';
import { BAND_NAME, llShort, mgrs } from './bands.js';
import { BAND_NAME, BAND_COLOR, llShort, mgrs } from './bands.js';
/** Altitude → band: where the virtual sensor is. */
const bandAt = (h) => (h > 100e3 ? 'orbit' : h > 2500 ? 'sky' : 'ground');
@@ -79,6 +79,10 @@ export function createGlobeChrome({
getSituations,
isTwoPane,
onPin,
onClock,
onClip,
onTimelineEvent,
onPick,
}) {
const eyeViewer = createEyeViewer({
globe,
@@ -174,7 +178,22 @@ export function createGlobeChrome({
const ribbon = createTimeRibbon(bottom, {
getWindow: () => globe.timeWindow(),
onTime: (d) => globe.setTime(d),
onTime: (d) => {
// A playing Moment owns the clock until it closes.
if (globe.activeMoment()) return;
globe.setTime(d);
onClock?.(d);
},
playback: globe.playback,
getHistory: () => globe.history(),
onClip: (r) => onClip?.(r),
onPip: (p) => onTimelineEvent?.(p.ev || p),
});
// While the planet plays, the time machine (and everything else) follows.
globe.on('playback', (st) => {
if (!st || globe.activeMoment()) return;
ribbon.onPlayback(st);
onClock?.(ribbon.time);
});
/* ---------- HTML labels (engine draws none; ≤ 40, DESIGN.md §5) ---------- */
@@ -588,6 +607,7 @@ export function createGlobeChrome({
if (onPin?.(p)) return;
if (p.pinKind === 'eye') return nearestEye(p.lon, p.lat, p.title, 1);
}
if (onPick?.(p)) return;
if (p.kind === 'situation') {
const s = (await getSituations()).find(
(x) => String(x.id) === String(p.id),
@@ -731,6 +751,17 @@ export function createGlobeChrome({
basePips = p || [];
ribbon.setPips([...basePips, ...extraPips]);
},
/** Timeline events (globe.timeline()) become band-coloured pips. */
setTimeline(evts) {
basePips = (evts || []).map((ev) => ({
at: ev.t,
label: ev.title,
color: BAND_COLOR[ev.band] || '#fff',
severity: ev.severity || 0,
ev,
}));
ribbon.setPips([...basePips, ...extraPips]);
},
/** Room posts anchored in time (cleared with []). */
setExtraPips(p) {
extraPips = p || [];
+99
View File
@@ -7,6 +7,8 @@
* the shell down; mismatches are collected in `contractReport` for the lead.
*/
import { installRound3 } from './shim3.js';
const REQUIRED = [
'flyTo',
'home',
@@ -38,6 +40,28 @@ const ROUND_TWO = [
'eyeFrameUrl',
];
/** Round-three additions (mobile/globe/API.md, "Round three"), optional. */
const ROUND_THREE = [
'history',
'trackOf',
'timeline',
'detections',
'moments',
'moment',
'momentOfTheDay',
'playMoment',
'exitMoment',
'activeMoment',
'seekMoment',
'entity',
'search',
'graph',
'react',
'attentionHeat',
'skyNow',
'cachedThumbnail',
];
export const contractReport = {
engine: 'none',
missing: [],
@@ -166,6 +190,71 @@ function guard(engine, kind) {
},
project: (lon, lat, alt) => call('project', null, lon, lat, alt),
time: () => call('time', null),
/* ---- round three (API.md); the shell fills gaps with shim3.js ---- */
situationsCached: async () => {
const v = await asyncCall('situations', [], { cached: true });
return Array.isArray(v) ? v.map(normSituation) : [];
},
cachedThumbnail: (s) => call('cachedThumbnail', null, s),
history: () => call('history', null),
trackOf: (id) => asyncCall('trackOf', [], id),
playback: {
play: (o) => {
try {
engine.playback?.play?.(o);
} catch (err) {
note(`playback.play(): ${err?.message || err}`);
}
},
stop: () => {
try {
engine.playback?.stop?.();
} catch (err) {
note(`playback.stop(): ${err?.message || err}`);
}
},
state: () => {
try {
return (
engine.playback?.state?.() || { playing: false, speed: 1, t: null }
);
} catch {
return { playing: false, speed: 1, t: null };
}
},
},
timeline: (o) => {
const v = call('timeline', [], o);
return Array.isArray(v) ? v : [];
},
detections: () => {
const v = call('detections', []);
return Array.isArray(v) ? v.map(normSituation) : [];
},
moments: async () => {
const v = await asyncCall('moments', []);
return Array.isArray(v) ? v : [];
},
moment: (id) => asyncCall('moment', null, id),
momentOfTheDay: () => asyncCall('momentOfTheDay', null),
playMoment: (m, o) => asyncCall('playMoment', null, m, o),
exitMoment: () => call('exitMoment', undefined),
activeMoment: () => call('activeMoment', null),
seekMoment: (t) => call('seekMoment', undefined, t),
entity: (ref) => asyncCall('entity', null, ref),
search: async (q) => {
const v = await asyncCall('search', [], q);
return Array.isArray(v) ? v : [];
},
graph: (ref) => {
const v = call('graph', null, ref);
return v && Array.isArray(v.nodes) ? v : { nodes: [], edges: [] };
},
react: (o) => call('react', undefined, o),
attentionHeat: (p) => call('attentionHeat', undefined, p),
skyNow: (o) => asyncCall('skyNow', null, o),
addClip: (m) => call('_addClip', undefined, m),
};
}
@@ -196,6 +285,15 @@ export async function mountGlobe(container, opts = {}) {
opts.quality === 'battery' ? 'low' : opts.quality || 'auto';
const engine = await create(container, { quality });
contractReport.engine = 'cesium';
contractReport.roundThree = Object.fromEntries(
ROUND_THREE.map((m) => [
m,
typeof engine?.[m] === 'function' ? 'native' : 'shim',
]),
);
contractReport.roundThree.playback =
typeof engine?.playback?.play === 'function' ? 'native' : 'shim';
installRound3(engine, { kind: 'cesium' });
contractReport.missing = REQUIRED.filter(
(m) => typeof engine?.[m] !== 'function',
);
@@ -222,6 +320,7 @@ export async function mountGlobe(container, opts = {}) {
quality: opts.quality || 'auto',
});
contractReport.engine = 'stub';
contractReport.roundThree = 'stub (shim3 + native stub recorder)';
contractReport.roundTwo = Object.fromEntries(
ROUND_TWO.map((m) => [m, typeof engine?.[m] === 'function']),
);
+97 -2
View File
@@ -14,12 +14,13 @@ import {
camFrameUrl,
} from './stubSituations.js';
import { stubThumbnail } from './stubCompositor.js';
import { installRound3 } from './shim3.js';
const R_EARTH = 6371008.8;
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
const FOV_HALF = Math.tan(20 * D2R);
const MIN_H = 60e3;
const MIN_H = 1500;
const MAX_H = 42000e3;
const HOME = { lon: 20, lat: 18, height: 15500e3 };
@@ -719,7 +720,18 @@ export async function createGlobe(container, options = {}) {
const radiusPx = (height = cam.height) => {
const d = R_EARTH + height;
const tanT = R_EARTH / Math.sqrt(d * d - R_EARTH * R_EARTH);
return (tanT / FOV_HALF) * (Math.min(W, H) / 2) * 1.05;
const half = Math.min(W, H) / 2;
const sil = (tanT / FOV_HALF) * half * 1.05;
// Close in, the silhouette fit under-zooms badly (a 2.5 km camera saw
// 200 km). Blend toward the true nadir scale below 1,000 km so a city
// block reads at street altitude (Moments orbit at 400–700 m).
const nadir = (R_EARTH / (Math.max(1, height) * FOV_HALF)) * half;
if (nadir <= sil) return sil;
const k = Math.min(
1,
Math.max(0, Math.log(1000e3 / height) / Math.log(1000e3 / 30e3)),
);
return sil * (nadir / sil) ** k;
};
function basis() {
const lo = cam.lon * D2R;
@@ -1041,7 +1053,17 @@ export async function createGlobe(container, options = {}) {
}
drawWeather(ts);
drawPins(ts);
for (const fn of hooks) {
try {
fn(ctx, { project, t, ts, W, H, cam });
} catch (err) {
console.warn('[stub] overlay hook failed', err);
hooks.delete(fn);
}
}
}
/** Round three: shell overlays (Moment trails, reactions) draw here. */
const hooks = new Set();
/* ---------- weather (simulated look of the engine's weather family) ---------- */
const wx = (() => {
@@ -1658,12 +1680,85 @@ export async function createGlobe(container, options = {}) {
}
return out;
},
/* ---- round three (API.md): native stub pieces; the rest via shim3 ---- */
_hook(fn) {
hooks.add(fn);
kick();
return () => hooks.delete(fn);
},
history() {
const n = Date.now();
const pts = contacts.length * 720;
return {
start: n - 6 * 3600e3,
end: n,
entities: contacts.length,
points: pts,
};
},
async trackOf(id) {
const raw = String(id).replace(/^[a-z]+:/, '');
const c = contacts.find((x) => String(x.id) === raw);
if (!c) return [];
const t = Math.max(now(), Date.now());
const out = [];
const probe = { ...c, base: c.base, orbit: c.orbit };
// The simulated recorder: six hours at one point a minute.
for (let i = 360; i >= 0; i -= 1) {
const ti = t - i * 60e3;
propagate(probe, t0, ti);
out.push({
t: ti,
lon: probe.lon,
lat: probe.lat,
altM:
c.kind === 'satellite'
? c.altM
: Math.max(0, (c.altM || 0) + Math.sin(i / 9) * 90),
headingDeg: probe.headingDeg,
speed: c.speed,
});
}
propagate(c, t0, t);
return out;
},
/** Next ISS pass over a point: sampled every 30 s for 24 h, el > 10°. */
_issPass(lon, lat) {
const iss = contacts.find((c) => c.id === 'iss');
if (!iss) return null;
const probe = { ...iss };
const Re = R_EARTH;
const elev = (ti) => {
propagate(probe, t0, ti);
const d = haversine(lon, lat, probe.lon, probe.lat);
const th = d / Re;
return (
Math.atan2(Math.cos(th) - Re / (Re + iss.altM), Math.sin(th)) * R2D
);
};
const start = Date.now();
let pass = null;
for (let ti = start; ti < start + 24 * 3600e3; ti += 30e3) {
const e = elev(ti);
if (e > 10 && !pass) pass = { start: ti, max: ti, maxElevationDeg: e };
else if (pass && e > 10 && e > pass.maxElevationDeg) {
pass.max = ti;
pass.maxElevationDeg = e;
} else if (pass && e <= 10) {
pass.end = ti;
break;
}
}
if (pass) pass.maxElevationDeg = Math.round(pass.maxElevationDeg);
return pass;
},
destroy() {
api.pause();
ro.disconnect();
root.remove();
},
};
installRound3(api, { kind: 'stub', historyHours: 6 });
kick();
setTimeout(
() =>
+13
View File
@@ -95,6 +95,19 @@ const P = {
sendUp: `<path d="M12 19V5.6M6.6 11 12 5.6l5.4 5.4" stroke-width="2.2"/>`,
dot: `<circle cx="12" cy="12" r="4" ${F}/>`,
hand: `<path d="M8.4 12.6V5.8a1.4 1.4 0 0 1 2.8 0v5.4M11.2 10.8V4.4a1.4 1.4 0 0 1 2.8 0v6.4M14 10.8V5.6a1.4 1.4 0 0 1 2.8 0v7.6M8.4 12.6l-1.3-1.7a1.5 1.5 0 0 0-2.3 1.9l3.4 5.3c1.2 1.8 3 2.9 5.2 2.9h.4c2.6 0 4.9-2.1 4.9-4.8V8.5a1.4 1.4 0 0 0-2.8 0"/>`,
today: `<path class="f" d="M4 15.6a8 8 0 0 1 16 0Z"/><path d="M4 15.6a8 8 0 0 1 16 0M2.6 15.6h18.8M12 3.4v2.2M5.3 6.2l1.5 1.5M18.7 6.2l-1.5 1.5M6.4 19h11.2"/>`,
rooms: `<path class="f" d="M4.2 5.4h10.4c.9 0 1.6.7 1.6 1.6v6.2c0 .9-.7 1.6-1.6 1.6H9.1l-3.4 2.8v-2.8H4.2c-.9 0-1.6-.7-1.6-1.6V7c0-.9.7-1.6 1.6-1.6Z"/><path d="M4.2 5.4h10.4c.9 0 1.6.7 1.6 1.6v6.2c0 .9-.7 1.6-1.6 1.6H9.1l-3.4 2.8v-2.8H4.2c-.9 0-1.6-.7-1.6-1.6V7c0-.9.7-1.6 1.6-1.6Z"/><path d="M16.2 9.2h3.6c.9 0 1.6.7 1.6 1.6v5.4c0 .9-.7 1.6-1.6 1.6h-1.2v2.6l-3.1-2.6h-4.2c-.9 0-1.6-.7-1.6-1.6v-.6"/>`,
bell: `<path d="M6.2 16.4V10.6a5.8 5.8 0 0 1 11.6 0v5.8l1.6 2H4.6Z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>`,
graph: `<circle cx="6" cy="7" r="2.3"/><circle cx="18" cy="6" r="2.3"/><circle cx="12" cy="17.6" r="2.6"/><circle cx="19" cy="15" r="1.7"/><path d="M8.2 7.8 16 6.4M7.2 9l3.6 6.4M17 8.2l-3.8 7.4M14.6 17l2.9-1.4"/>`,
dome: `<path d="M3 17.2a9 9 0 0 1 18 0Z"/><path d="M7.2 17.2a4.8 4.8 0 0 1 9.6 0M12 8.2v9"/><circle cx="15.6" cy="11.4" r="1.1" ${F}/><circle cx="8.6" cy="13.4" r="0.9" ${F}/>`,
heli: `<path d="M3 5.4h14M10 5.4v3"/><path d="M6.6 11.2c0-1.6 1.3-2.8 3-2.8h2.2c2.6 0 4.6 1.8 4.6 4.2v.6H8.8c-1.2 0-2.2-.9-2.2-2Z" ${F}/><path d="M16.4 12.2h4.4M20.4 10.4v3.6M9.4 13.2l-1 3.2M13.6 13.2l1 3.2M6.8 16.4h10"/>`,
satellite: `<path d="m12 8.9 3.1 3.1-3.1 3.1L8.9 12Z" ${F}/><path d="M5.4 9.6 9.6 5.4 7 2.8 2.8 7ZM18.6 14.4l-4.2 4.2 2.6 2.6 4.2-4.2ZM10.4 10.4 7.6 7.6M13.6 13.6l2.8 2.8"/>`,
building: `<path d="M8 21V8.4l4-2.2 4 2.2V21M12 2.6v3.6M10 11h4M10 14h4M10 17h4M4.6 21h14.8"/>`,
film: `<rect x="3.4" y="5" width="17.2" height="14" rx="2.4"/><path d="M7.4 5v14M16.6 5v14M3.4 9.6h4M3.4 14.4h4M16.6 9.6h4M16.6 14.4h4"/>`,
scissors: `<circle cx="6.4" cy="7" r="2.6"/><circle cx="6.4" cy="17" r="2.6"/><path d="m8.6 8.4 11 8.8M8.6 15.6l11-8.8"/>`,
receipt: `<path d="M6 3.6h12v17l-2-1.4-2 1.4-2-1.4-2 1.4-2-1.4-2 1.4Z"/><path d="M9 8.4h6M9 11.6h6M9 14.8h3.6"/>`,
director: `<rect x="3.4" y="9.2" width="17.2" height="11" rx="1.8"/><path d="m3.6 9.2 2.2-5.4 15 2.6-1.1 2.8M8.4 4.6l1.6 3.6M13.4 5.4l1.6 3.6"/>`,
forward: `<path d="M4 6.6v10.8L11.4 12ZM12.6 6.6v10.8L20 12Z" ${F}/>`,
};
/**
+360
View File
@@ -0,0 +1,360 @@
/**
* Link graph (DESIGN.md §12.1): a hand-written force-directed graph of
* globe.graph(ref), ≤ 40 nodes, on one canvas. Nodes are coloured by
* altitude band, the root is larger, edges carry their relation on hover.
* Drag a node to pull the graph; tap a node to open its dossier. Selection
* is shared with the globe through `select(id)`.
*/
import { BAND_COLOR } from './bands.js';
import { bandOfKind } from './shim3.js';
import { reducedMotion } from './dom.js';
const KIND_GLYPH = {
aircraft: '✈',
helicopter: 'H',
military: '✈',
satellite: '◆',
vessel: '⛴',
quake: '◎',
camera: '◉',
situation: '!',
place: '⌖',
moment: '▶',
track: '·',
capture: '◐',
};
export function createLinkGraph(host, { onOpen, onSelect } = {}) {
const wrap = document.createElement('div');
wrap.className = 'lg';
wrap.innerHTML = `<canvas class="lg-canvas" aria-label="Link graph" role="img"></canvas><div class="lg-tip tele" hidden></div><div class="lg-legend tele"><span data-band="orbit"><i></i>Orbit</span><span data-band="sky"><i></i>Sky</span><span data-band="ground"><i></i>Ground</span><span data-band="inside"><i></i>Inside</span></div>`;
host.appendChild(wrap);
const canvas = wrap.querySelector('canvas');
const tip = wrap.querySelector('.lg-tip');
const ctx = canvas.getContext('2d');
let W = 0;
let H = 0;
let dpr = 1;
let nodes = [];
let edges = [];
let rootId = null;
let selected = null;
let hover = null;
let alpha = 1;
let raf = 0;
let dragN = null;
let destroyed = false;
function resize() {
const r = wrap.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
W = r.width;
H = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
kick(0.3);
}
const ro = new ResizeObserver(resize);
ro.observe(wrap);
function set(graph, root) {
rootId = root;
const prev = new Map(nodes.map((n) => [n.id, n]));
const deg = new Map();
for (const e of graph.edges) {
deg.set(e.from, (deg.get(e.from) || 0) + 1);
deg.set(e.to, (deg.get(e.to) || 0) + 1);
}
nodes = graph.nodes.slice(0, 40).map((n, i) => {
const p = prev.get(n.id);
const a = (i / Math.max(1, graph.nodes.length)) * Math.PI * 2;
const isRoot = n.id === root;
return {
...n,
band: bandOfKind(n.kind),
r: isRoot ? 15 : 7 + Math.min(5, (deg.get(n.id) || 1) * 0.9),
x: p?.x ?? (isRoot ? 0 : Math.cos(a) * 90),
y: p?.y ?? (isRoot ? 0 : Math.sin(a) * 90),
vx: 0,
vy: 0,
root: isRoot,
};
});
const idx = new Map(nodes.map((n, i) => [n.id, i]));
edges = graph.edges
.filter((e) => idx.has(e.from) && idx.has(e.to))
.map((e) => ({
...e,
a: nodes[idx.get(e.from)],
b: nodes[idx.get(e.to)],
}));
kick(1);
}
function kick(a = 0.6) {
alpha = Math.max(alpha, a);
if (!raf && !destroyed) raf = requestAnimationFrame(tick);
}
function step() {
const k = 1;
// Repulsion
for (let i = 0; i < nodes.length; i += 1) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j += 1) {
const b = nodes[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let d2 = dx * dx + dy * dy;
if (d2 < 0.01) {
dx = Math.random() - 0.5;
dy = Math.random() - 0.5;
d2 = 0.5;
}
const f = (4200 * k) / d2;
const d = Math.sqrt(d2);
a.vx -= (dx / d) * f;
a.vy -= (dy / d) * f;
b.vx += (dx / d) * f;
b.vy += (dy / d) * f;
}
}
// Springs
for (const e of edges) {
const dx = e.b.x - e.a.x;
const dy = e.b.y - e.a.y;
const d = Math.max(0.01, Math.hypot(dx, dy));
const rest = e.a.root || e.b.root ? 120 : 80;
const f = (d - rest) * 0.035;
e.a.vx += (dx / d) * f;
e.a.vy += (dy / d) * f;
e.b.vx -= (dx / d) * f;
e.b.vy -= (dy / d) * f;
}
for (const n of nodes) {
// Gravity to the centre; the root is pinned there.
n.vx -= n.x * 0.012;
n.vy -= n.y * 0.012;
if (n.root && n !== dragN) {
n.x *= 0.8;
n.y *= 0.8;
n.vx = 0;
n.vy = 0;
continue;
}
if (n === dragN) continue;
n.vx *= 0.62;
n.vy *= 0.62;
n.x += n.vx * alpha;
n.y += n.vy * alpha;
}
}
function view() {
// Fit the graph into the canvas with padding.
let minX = -60;
let maxX = 60;
let minY = -60;
let maxY = 60;
for (const n of nodes) {
minX = Math.min(minX, n.x - n.r);
maxX = Math.max(maxX, n.x + n.r);
minY = Math.min(minY, n.y - n.r);
maxY = Math.max(maxY, n.y + n.r);
}
const pad = 46;
const s = Math.min(
2.2,
(W - pad * 2) / (maxX - minX),
(H - pad * 2) / (maxY - minY),
);
return {
s,
cx: W / 2 - ((minX + maxX) / 2) * s,
cy: H / 2 - ((minY + maxY) / 2) * s,
};
}
let V = { s: 1, cx: 0, cy: 0 };
const sx = (n) => V.cx + n.x * V.s;
const sy = (n) => V.cy + n.y * V.s;
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
V = view();
const focus = hover || selected || rootId;
const relFocus = hover || selected;
const near = new Set();
if (focus)
for (const e of edges) {
if (e.a.id === focus) near.add(e.b.id);
if (e.b.id === focus) near.add(e.a.id);
}
// Edges
for (const e of edges) {
const hot = focus && (e.a.id === focus || e.b.id === focus);
ctx.strokeStyle = hot
? 'rgba(255,255,255,0.7)'
: 'rgba(255,255,255,0.16)';
ctx.lineWidth = hot ? 1.4 : 1;
ctx.beginPath();
ctx.moveTo(sx(e.a), sy(e.a));
ctx.lineTo(sx(e.b), sy(e.b));
ctx.stroke();
if (hot && e.relation && relFocus) {
const mx = (sx(e.a) + sx(e.b)) / 2;
const my = (sy(e.a) + sy(e.b)) / 2;
ctx.font = '600 9px "JetBrains Mono", ui-monospace, monospace';
const w = ctx.measureText(e.relation.toUpperCase()).width;
ctx.fillStyle = 'rgba(8,10,14,0.85)';
ctx.fillRect(mx - w / 2 - 4, my - 7, w + 8, 14);
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.textAlign = 'center';
ctx.fillText(e.relation.toUpperCase(), mx, my + 3);
}
}
// Nodes
for (const n of nodes) {
const x = sx(n);
const y = sy(n);
const col = BAND_COLOR[n.band] || '#fff';
const dim = focus && focus !== n.id && !near.has(n.id) && !n.root;
ctx.globalAlpha = dim ? 0.35 : 1;
if (n.id === selected || n.root) {
ctx.fillStyle = `${col}33`;
ctx.beginPath();
ctx.arc(x, y, n.r + 7, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = n.root ? col : '#0b0d11';
ctx.strokeStyle = col;
ctx.lineWidth = n.root ? 0 : 2;
ctx.beginPath();
ctx.arc(x, y, n.r, 0, Math.PI * 2);
ctx.fill();
if (!n.root) ctx.stroke();
ctx.fillStyle = n.root ? '#0b0d11' : col;
ctx.font = `700 ${n.root ? 13 : 9}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(KIND_GLYPH[n.kind] || '•', x, y + 0.5);
ctx.textBaseline = 'alphabetic';
const showLabel =
n.root || n.id === focus || near.has(n.id) || nodes.length <= 14;
if (showLabel) {
const short = n.root ? n.title : String(n.title).split(' · ')[0];
const label = short.length > 22 ? `${short.slice(0, 21)}…` : short;
ctx.font = `${n.root ? 650 : 550} ${n.root ? 12 : 10.5}px system-ui, sans-serif`;
ctx.fillStyle = n.root ? '#fff' : 'rgba(235,241,250,0.85)';
ctx.fillText(label, x, y + n.r + 13);
}
ctx.globalAlpha = 1;
}
}
function tick() {
raf = 0;
if (destroyed) return;
const steps = reducedMotion() ? 60 : alpha > 0.4 ? 6 : 2;
for (let i = 0; i < steps && alpha > 0.01; i += 1) {
step();
alpha *= reducedMotion() ? 0.92 : 0.985;
}
draw();
if (alpha > 0.01 || dragN) raf = requestAnimationFrame(tick);
}
function hit(e) {
const r = canvas.getBoundingClientRect();
const px = e.clientX - r.left;
const py = e.clientY - r.top;
let best = null;
let bd = 22;
for (const n of nodes) {
const d = Math.hypot(sx(n) - px, sy(n) - py) - n.r;
if (d < bd) {
bd = d;
best = n;
}
}
return { n: best, px, py };
}
let down = null;
canvas.addEventListener('pointerdown', (e) => {
const { n, px, py } = hit(e);
down = { n, px, py, moved: false };
if (n) {
dragN = n;
canvas.setPointerCapture(e.pointerId);
}
});
canvas.addEventListener('pointermove', (e) => {
if (down?.n) {
const r = canvas.getBoundingClientRect();
const px = e.clientX - r.left;
const py = e.clientY - r.top;
if (Math.hypot(px - down.px, py - down.py) > 4) down.moved = true;
if (down.moved) {
down.n.x = (px - V.cx) / V.s;
down.n.y = (py - V.cy) / V.s;
kick(0.4);
}
return;
}
if (e.pointerType === 'mouse') {
const { n } = hit(e);
const id = n?.id || null;
if (id !== hover) {
hover = id;
canvas.style.cursor = id ? 'pointer' : 'default';
if (n) {
tip.hidden = false;
tip.textContent = `${n.kind.toUpperCase()} · ${n.title}`;
tip.style.transform = `translate(${Math.round(sx(n))}px, ${Math.round(sy(n) - n.r - 30)}px) translateX(-50%)`;
} else tip.hidden = true;
draw();
}
}
});
const up = () => {
if (!down) return;
const d = down;
down = null;
dragN = null;
if (d.n && !d.moved) {
selected = d.n.id;
draw();
onSelect?.(d.n);
if (!d.n.root) onOpen?.(d.n);
}
};
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
canvas.addEventListener('pointerleave', () => {
if (hover) {
hover = null;
tip.hidden = true;
draw();
}
});
return {
set,
select(id) {
selected = id;
draw();
},
has: (id) => nodes.some((n) => n.id === id),
get size() {
return nodes.length;
},
destroy() {
destroyed = true;
cancelAnimationFrame(raf);
ro.disconnect();
wrap.remove();
},
};
}
+704
View File
@@ -0,0 +1,704 @@
/**
* The Moments player (DESIGN.md §12.4): a cinematic, full-screen replay of a
* 4D reconstruction. The globe plays the Moment (globe.playMoment); a scrubber
* covers just the Moment's window; captures pop in at their times with their
* First-eyes byline, seal and view cone; the chat replays beside it (the
* social agent's chatOverlay when present); record posts appear as they were
* made; a legend reads each track's callsign, operator, altitude and speed.
*
* On a phone it is an overlay on the globe; on a tablet the chat, captures and
* record move to the leading pane and the globe keeps the trailing pane.
*/
import { icon } from './icons.js';
import {
$,
$$,
h,
esc,
haptic,
toast,
clamp,
esriThumb,
reducedMotion,
emitter,
} from './dom.js';
import { utc, fmtKm } from './bands.js';
import { trackAt } from './stubMoments.js';
import { social } from './socialBridge.js';
const TRACK_COL = [
'#a3d8ff',
'#ffd27a',
'#ff9db0',
'#9be15d',
'#b39dff',
'#7cf0d0',
];
const SPEEDS = [1, 10, 30, 60];
const KIND = {
witness: ['witness', 'Witness'],
context: ['context', 'Context'],
question: ['question', 'Question'],
correction: ['correction', 'Correction'],
prediction: ['prediction', 'Prediction'],
synthesis: ['sparkles', 'Clerk · synthesis'],
};
const BADGE = {
'first-eyes': ['First eyes', 'seal'],
witness: ['Was there', 'witness'],
pilot: ['Pilot', 'sky'],
local: ['Local', 'pin'],
mod: ['Mod', 'checkmark'],
};
const at = (h) =>
!h || h.startsWith('@') || h === 'Clerk' || h === 'Record' ? h : `@${h}`;
/** The engine's Moment and the shell's share one shape after this. */
export function normMoment(m) {
const subject = m.subject || { lon: m.lon, lat: m.lat };
const R = 6371008.8;
const dist = (a) => {
const dLat = ((subject.lat - a.lat) * Math.PI) / 180;
const dLon = ((subject.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((subject.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
};
return {
...m,
subject,
tracks: (m.tracks || []).map((tr) => ({
...tr,
arriveAt: tr.arriveAt ?? tr.points?.find((p) => dist(p) < 900)?.t,
})),
captures: (m.captures || []).map((c) => ({
...c,
handle: at(c.handle),
where: c.where || c.place || '',
media: c.media || c.kind || 'photo',
live: c.live || c.kind === 'live',
distanceM: c.distanceM ?? c.rangeM ?? Math.round(dist(c)),
})),
chat: (m.chat || []).map((c) => ({ ...c, handle: at(c.handle) })),
posts: (m.posts || []).map((p) => ({
...p,
handle: at(p.handle),
votes:
typeof p.votes === 'number'
? { corroborate: p.votes, dispute: 0 }
: p.votes || { corroborate: 0, dispute: 0 },
})),
};
}
/** A capture's thumbnail: the place, with its view cone toward the subject. */
export function coneThumb(c, m, { w = 168, hgt = 120 } = {}) {
const span = 0.006;
const mPerPx = (span * 111320 * (hgt / w)) / hgt;
const len = clamp((c.distanceM || 250) / mPerPx, 26, w * 0.7);
const a = ((c.headingDeg || 0) - 90) * (Math.PI / 180);
const f = ((c.fovDeg || 52) / 2) * (Math.PI / 180);
const cx = w / 2;
const cy = hgt / 2;
const p1 = [cx + len * Math.cos(a - f), cy + len * Math.sin(a - f)];
const p2 = [cx + len * Math.cos(a + f), cy + len * Math.sin(a + f)];
const tx = cx + len * 0.92 * Math.cos(a);
const ty = cy + len * 0.92 * Math.sin(a);
return `<span class="cone-thumb" style="aspect-ratio:${w}/${hgt}">
<img src="${esc(esriThumb(c.lon, c.lat, span, w * 2, hgt * 2))}" alt="" loading="lazy" decoding="async"/>
<svg viewBox="0 0 ${w} ${hgt}" aria-hidden="true"><defs><linearGradient id="cg-${esc(c.id)}" x1="0" y1="0" x2="1" y2="0" gradientTransform="rotate(${(c.headingDeg || 0) - 90} .5 .5)"><stop offset="0" stop-color="#ebc777" stop-opacity=".75"/><stop offset="1" stop-color="#ebc777" stop-opacity=".08"/></linearGradient></defs>
<path d="M${cx} ${cy}L${p1[0].toFixed(1)} ${p1[1].toFixed(1)}A${len} ${len} 0 0 1 ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}Z" fill="url(#cg-${esc(c.id)})" stroke="#ebc777" stroke-opacity=".8" stroke-width="1"/>
<circle cx="${cx}" cy="${cy}" r="3.6" fill="#ebc777" stroke="#050607" stroke-width="1.4"/>
${m?.id === 'empire-state' ? `<path d="M${tx - 5} ${ty - 5}h3M${tx - 5} ${ty - 5}v3M${tx + 5} ${ty - 5}h-3M${tx + 5} ${ty - 5}v3M${tx - 5} ${ty + 5}h3M${tx - 5} ${ty + 5}v-3M${tx + 5} ${ty + 5}h-3M${tx + 5} ${ty + 5}v-3" stroke="#fff" stroke-width="1.1"/>` : ''}
</svg></span>`;
}
export function createMomentPlayer({
app,
globe,
onExit,
onOpenEntity,
onOpenRoom,
isTwoPane,
}) {
const ev = emitter();
const el = h(`<section class="mp" aria-label="Moment player" hidden>
<div class="mp-stage">
<header class="mp-top">
<button class="icon-btn glass-strong liquid mp-back" type="button" aria-label="Close Moment">${icon('chevronLeft')}</button>
<div class="mp-titles"><div class="mp-kicker tele"></div><h2 class="mp-title"></h2></div>
<button class="icon-btn glass-strong liquid mp-room" type="button" aria-label="Open the room">${icon('rooms')}</button>
</header>
<div class="mp-clock tele"><b class="num mp-utc">—</b><span class="mp-rel num"></span></div>
<div class="mp-legend glass-strong" role="list" aria-label="Tracks"></div>
<div class="mp-pops" aria-live="polite"></div>
<div class="mp-react" role="group" aria-label="React">
<button type="button" data-react="heart" aria-label="Heart">❤️</button><button type="button" data-react="fire" aria-label="Fire">🔥</button><button type="button" data-react="eyes" aria-label="Eyes">👀</button><button type="button" data-react="wow" aria-label="Wow">😮</button>
</div>
<div class="mp-bar glass-strong">
<button class="mp-play" type="button" aria-label="Pause">${icon('pause')}</button>
<div class="mp-scrub" role="slider" tabindex="0" aria-label="Moment time"><canvas></canvas><i class="mp-head"></i></div>
<div class="mp-speeds" role="radiogroup" aria-label="Speed">${SPEEDS.map((s) => `<button type="button" role="radio" data-speed="${s}" aria-checked="${s === 30}">${s}×</button>`).join('')}</div>
</div>
</div>
<aside class="mp-side glass-strong">
<div class="mp-side-title"><div class="mp-st-k tele"></div><h3></h3><p class="mp-st-s"></p></div>
<div class="mp-side-head">
<div class="segmented mp-seg" role="tablist"><button role="tab" type="button" data-seg="chat" aria-selected="true">Chat<span class="mp-n" data-n="chat"></span></button><button role="tab" type="button" data-seg="record" aria-selected="false">Record<span class="mp-n" data-n="record"></span></button><button role="tab" type="button" data-seg="captures" aria-selected="false">Captures<span class="mp-n" data-n="captures"></span></button></div>
</div>
<div class="mp-pane" data-pane="chat"><div class="mp-chat"></div></div>
<div class="mp-pane" data-pane="record" hidden><div class="mp-record"></div></div>
<div class="mp-pane" data-pane="captures" hidden><div class="mp-caps"></div></div>
<p class="mp-src tele"></p>
</aside>
</section>`);
app.appendChild(el);
const legend = $('.mp-legend', el);
const pops = $('.mp-pops', el);
const scrub = $('.mp-scrub', el);
const canvas = $('canvas', scrub);
const sctx = canvas.getContext('2d');
const chatEl = $('.mp-chat', el);
const recEl = $('.mp-record', el);
const capsEl = $('.mp-caps', el);
let m = null;
let raf = 0;
let t = 0;
let speed = 30;
let shown = { chat: 0, posts: 0, caps: 0 };
let ext = null; // social chatOverlay
let seg = 'chat';
let wasPlaying = false;
let reactCooldown = 0;
let offOwn = null;
/* ---------- rendering pieces ---------- */
const rel = (ms) => {
const s = Math.max(0, Math.round((ms - m.time.start) / 1000));
return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
function legendHtml() {
return (m.tracks || [])
.map(
(
tr,
i,
) => `<button class="mp-tr" type="button" role="listitem" data-track="${esc(tr.id)}" style="--c:${esc(tr.color || TRACK_COL[i % TRACK_COL.length])}">
<i class="sw"></i><span class="mp-tr-main"><b>${esc(tr.callsign || String(tr.label).replace(/\s*\(illustrative\)/i, ''))}</b><small>${esc(tr.operator || tr.type || tr.kind || '')}</small></span>
<span class="mp-tr-v tele"><b class="num" data-alt>—</b><span data-spd>—</span></span></button>`,
)
.join('');
}
function capCard(c, { pop = false } = {}) {
const fe = c.firstEyes;
return `<button class="mp-cap ${pop ? 'pop' : ''} ${fe ? 'first' : ''}" type="button" data-cap="${esc(c.id)}">
${coneThumb(c, m)}
<span class="mp-cap-body">
${c.self ? `<span class="fe tele">${icon('sealFill')}YOURS${fe ? ' · FIRST EYES' : ''}</span>` : fe ? `<span class="fe tele">${icon('sealFill')}FIRST EYES</span>` : `<span class="fe tele">${icon(c.live ? 'live' : 'seal')}${c.live ? 'LIVE · SEALED' : 'SEALED'}</span>`}
<b>${esc(c.title)}</b>
<small>${esc(c.handle)} · ${esc(utc(c.t))} · ${esc(fmtKm((c.distanceM || 0) / 1000))}</small>
</span></button>`;
}
function chatHtml(c) {
const b = c.badge && BADGE[c.badge];
return `<div class="mp-msg ${c.badge ? `b-${esc(c.badge)}` : ''}"><span class="mp-who">${b ? `<span class="mp-badge" title="${esc(b[0])}">${icon(b[1])}</span>` : ''}${esc(c.handle)}</span> <span class="mp-txt">${esc(c.text)}</span></div>`;
}
function postHtml(p) {
const k = KIND[p.kind] || KIND.context;
return `<article class="mp-post k-${esc(p.kind)}"><header><span class="mp-pk">${icon(k[0])}${esc(k[1])}</span><span class="tele">${esc(utc(p.t, { seconds: false }))}</span></header>
<p><b>${esc(p.handle)}</b> ${esc(p.text)}</p>
${p.kind !== 'synthesis' ? `<footer class="tele"><span>${icon('agree')}${p.votes?.corroborate ?? 0} corroborate</span><span>${icon('dispute')}${p.votes?.dispute ?? 0} dispute</span>${p.captureId ? `<button type="button" data-cap="${esc(p.captureId)}">${icon('seal')}Sealed capture</button>` : ''}</footer>` : '<footer class="tele"><span>AI synthesis · cites 5 captures and 37 messages</span></footer>'}</article>`;
}
function drawScrub() {
const r = scrub.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
const W = r.width;
const H = r.height;
if (canvas.width !== Math.round(W * dpr)) {
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
}
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sctx.clearRect(0, 0, W, H);
const s = m.time.start;
const e = m.time.end;
const x = (ms) => 6 + ((ms - s) / (e - s)) * (W - 12);
const mid = H / 2 + 6;
// Chat density (a quiet histogram).
const bins = new Array(60).fill(0);
for (const c of m.chat || [])
bins[Math.min(59, Math.floor(((c.t - s) / (e - s)) * 60))] += 1;
const peak = Math.max(1, ...bins);
sctx.fillStyle = 'rgba(255,255,255,0.12)';
bins.forEach((b, i) => {
const bh = (b / peak) * 12;
sctx.fillRect(
6 + (i / 60) * (W - 12),
mid - 6 - bh,
(W - 12) / 60 - 1,
bh,
);
});
// Track and progress
sctx.fillStyle = 'rgba(255,255,255,0.14)';
sctx.fillRect(6, mid - 1.5, W - 12, 3);
sctx.fillStyle = '#fff';
sctx.fillRect(6, mid - 1.5, x(t) - 6, 3);
// Arrivals, captures and posts
// Beats (the story's chapters), else arrivals.
const beats = m.beats?.length
? m.beats.map((b) => b.t)
: (m.tracks || []).map((tr) => tr.arriveAt).filter(Boolean);
for (const bt of beats) {
sctx.fillStyle =
bt <= t ? 'rgba(163,216,255,0.95)' : 'rgba(163,216,255,0.4)';
sctx.fillRect(x(bt) - 1, mid - 9, 2, 18);
}
for (const p of m.posts || []) {
sctx.fillStyle =
p.t <= t ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.35)';
sctx.beginPath();
sctx.arc(x(p.t), mid + 9, 2, 0, Math.PI * 2);
sctx.fill();
}
for (const c of m.captures || []) {
sctx.fillStyle = c.t <= t ? '#ebc777' : 'rgba(235,199,119,0.4)';
sctx.beginPath();
sctx.arc(x(c.t), mid, c.firstEyes ? 5 : 3.6, 0, Math.PI * 2);
sctx.fill();
sctx.strokeStyle = '#08090b';
sctx.lineWidth = 1.2;
sctx.stroke();
}
$('.mp-head', el).style.transform = `translateX(${x(t)}px)`;
}
/* ---------- the clock ---------- */
function sync(force = false) {
const nc = (m.chat || []).filter((c) => c.t <= t).length;
const np = (m.posts || []).filter((p) => p.t <= t).length;
const caps = (m.captures || []).filter((c) => c.t <= t);
const nk = caps.length;
// Seeking back re-renders; playing forward appends with a pop.
// The social overlay replays the raw chat by the clock itself.
if (ext) {
try {
ext.setTime?.(t);
} catch {
/* ignore */
}
}
if (force || nc < shown.chat) {
chatEl.innerHTML =
(m.chat || [])
.filter((c) => c.t <= t)
.slice(-40)
.map(chatHtml)
.join('') ||
`<p class="mp-empty tele">${(m.chat || []).length ? 'The chat starts as the Moment does' : 'No chat was recorded in this window'}</p>`;
} else
for (const c of (m.chat || []).slice(shown.chat, nc)) {
chatEl.querySelector('.mp-empty')?.remove();
chatEl.insertAdjacentHTML('beforeend', chatHtml(c));
const last = chatEl.lastElementChild;
last.classList.add('in');
while (chatEl.children.length > 60) chatEl.firstElementChild.remove();
// Reactions in chat rise from the city on the globe.
const k = /❤️|🙏|♥/.test(c.text)
? 'heart'
: /🔥/.test(c.text)
? 'fire'
: /👀/.test(c.text)
? 'eyes'
: /😮|wild|wow/i.test(c.text)
? 'wow'
: null;
if (k) burst(k, 4);
}
if (force || np < shown.posts)
recEl.innerHTML =
(m.posts || [])
.filter((p) => p.t <= t)
.map(postHtml)
.reverse()
.join('') || emptyRec();
else
for (const p of (m.posts || []).slice(shown.posts, np)) {
recEl.querySelector('.mp-empty')?.remove();
recEl.insertAdjacentHTML('afterbegin', postHtml(p));
recEl.firstElementChild.classList.add('in');
}
if (force || nk < shown.caps) {
capsEl.innerHTML =
caps
.map((c) => capCard(c))
.reverse()
.join('') ||
'<p class="mp-empty tele">Captures appear here at the moment they were sealed</p>';
// After a seek, the latest capture stays pinned (settled) for context.
pops.innerHTML = caps.length
? capCard(caps[caps.length - 1]).replace(
'class="mp-cap ',
'class="mp-cap settled ',
)
: '';
} else
for (const c of caps.slice(shown.caps)) {
capsEl.querySelector('.mp-empty')?.remove();
capsEl.insertAdjacentHTML('afterbegin', capCard(c));
popCapture(c);
}
if (nc !== shown.chat && seg === 'chat') {
const pane = chatEl.parentElement;
pane.scrollTop = pane.scrollHeight;
}
shown = { chat: nc, posts: np, caps: nk };
$('[data-n="chat"]', el).textContent = nc ? ` ${nc}` : '';
$('[data-n="record"]', el).textContent = np ? ` ${np}` : '';
$('[data-n="captures"]', el).textContent = nk ? ` ${nk}` : '';
// Legend: live per-track readouts.
(m.tracks || []).forEach((tr) => {
const row = legend.querySelector(`[data-track="${CSS.escape(tr.id)}"]`);
if (!row) return;
const p = trackAt(tr, t);
row.classList.toggle('absent', !p);
$('[data-alt]', row).textContent = p
? `${Math.round((p.altM * 3.28084) / 10) * 10} FT`
: '—';
$('[data-spd]', row).textContent = !p
? 'NOT YET'
: p.speedMps
? `${Math.round(p.speedMps * 1.94384)} KT`
: 'LANDED';
});
$('.mp-utc', el).textContent = utc(t);
$('.mp-rel', el).textContent = `${rel(t)} / ${rel(m.time.end)}`;
drawScrub();
}
const emptyRec = () =>
'<p class="mp-empty tele">Record posts appear as they were made</p>';
function popCapture(c) {
const card = h(capCard(c, { pop: true }));
pops.prepend(card);
haptic(c.firstEyes ? 'seal' : 'light');
while (pops.children.length > (isTwoPane() ? 3 : 1))
pops.lastElementChild.remove();
setTimeout(() => card.classList.add('settle'), 4200);
}
function burst(kind, count = 3) {
const now = performance.now();
if (now - reactCooldown < 200) return;
reactCooldown = now;
const jitter = () => (Math.random() - 0.5) * 0.01;
globe.react({
lon: (m.lon ?? 0) + jitter(),
lat: (m.lat ?? 0) + jitter(),
kind,
count,
});
}
function loop() {
raf = 0;
if (!m) return;
const a = globe.activeMoment();
const st = globe.playback.state();
const nt = Number.isFinite(a?.t) ? a.t : Number.isFinite(st?.t) ? st.t : t;
if (nt !== t) {
t = clamp(nt, m.time.start, m.time.end);
sync();
}
setPlayingUi(!!(a?.playing ?? st?.playing));
raf = requestAnimationFrame(loop);
}
function setPlayingUi(p) {
if (el.classList.contains('playing') === p) return;
el.classList.toggle('playing', p);
$('.mp-play', el).innerHTML = icon(p ? 'pause' : 'play');
$('.mp-play', el).setAttribute('aria-label', p ? 'Pause' : 'Play');
}
/* ---------- open / close ---------- */
async function open(
idOrMoment,
{ at: atT = null, speed: s = null, autoplay = true } = {},
) {
const raw =
typeof idOrMoment === 'string'
? await globe.moment(idOrMoment)
: idOrMoment;
const mm = raw ? normMoment(raw) : null;
if (!mm) {
toast('That Moment is not available', { icon: icon('film') });
return null;
}
m = mm;
speed =
s ?? (SPEEDS.includes(m.recommendedSpeed) ? m.recommendedSpeed : 30);
$$('[data-speed]', el).forEach((b) =>
b.setAttribute('aria-checked', String(Number(b.dataset.speed) === speed)),
);
el.dataset.illustrative = String(!!m.illustrative);
$('.mp-kicker', el).innerHTML =
`<span class="mp-dot"></span>MOMENT · ${esc(m.place || '')}${m.simulated ? ' <span class="tag-ill">Simulated</span>' : m.illustrative ? ' <span class="tag-ill">Illustrative</span>' : ' <span class="tag-real">Recorded</span>'}`;
$('.mp-title', el).textContent = m.title;
$('.mp-side-title h3', el).textContent = m.title;
$('.mp-st-k', el).innerHTML = $('.mp-kicker', el).innerHTML;
$('.mp-st-s', el).textContent =
`${Math.round((m.time.end - m.time.start) / 60e3)} min · ${(m.tracks || []).length} tracks · ${(m.captures || []).length} sealed captures · ${(m.posts || []).length} posts on the record`;
legend.innerHTML = legendHtml();
const extra = (m.tracks || []).length - 4;
if (extra > 0)
legend.insertAdjacentHTML(
'beforeend',
`<div class="mp-more tele">+${extra} more tracks on the globe</div>`,
);
legend.hidden = !(m.tracks || []).length;
$('.mp-src', el).textContent = (m.sources || [])
.map((x) => `${x.name}${x.detail ? ` — ${x.detail}` : ''}`)
.join(' · ');
shown = { chat: 0, posts: 0, caps: 0 };
chatEl.innerHTML = '';
recEl.innerHTML = emptyRec();
capsEl.innerHTML = '';
pops.innerHTML = '';
// The social agent's chat replay, when it exists.
ext = null;
const sm = await social();
// Your own sealed captures posted into this Moment join its timeline.
const addOwn = (list) => {
for (const c of list || []) {
const id = String(c.id).split(':').pop();
if (m.captures.some((x) => x.id === id)) continue;
m.captures.push(
normMoment({
...m,
captures: [
{
...c,
id,
t: clamp(+c.t || m.time.end, m.time.start, m.time.end),
self: true,
title: c.title || 'Your sealed capture',
},
],
}).captures[0],
);
}
m.captures.sort((a, b) => a.t - b.t);
};
try {
addOwn(await sm?.momentCaptures?.(m.id));
} catch {
/* optional */
}
offOwn?.();
offOwn =
sm?.onSocial?.('moment-capture', (e) => {
if (!m || e?.momentId !== m.id) return;
addOwn([e.capture]);
sync(true);
}) || null;
const slot = $('.mp-pane[data-pane="chat"]', el);
slot.classList.remove('has-ext');
el.classList.remove('has-ext');
$('.mp-ext', slot)?.remove();
// Clips carry no chat: the overlay would only invent one.
if (typeof sm?.chatOverlay === 'function' && (m.chat || []).length) {
try {
const host = h('<div class="mp-ext"></div>');
slot.prepend(host);
ext = sm.chatOverlay(host, { moment: raw, globe, getTime: () => t });
ext?.setMoment?.({ ...raw, chat: [] });
slot.classList.add('has-ext');
el.classList.add('has-ext');
} catch (err) {
console.warn(
'[moment] chatOverlay failed; using the built-in replay',
err,
);
ext = null;
}
}
el.hidden = false;
app.classList.add('is-moment');
void el.offsetWidth;
el.classList.add('in');
t = atT ?? m.time.start;
await globe.playMoment(raw, { speed, camera: true, autoplay: false });
if (atT != null) globe.seekMoment(atT);
sync(true);
if (autoplay) globe.playback.play({ speed });
cancelAnimationFrame(raf);
raf = requestAnimationFrame(loop);
ev.emit('open', m);
return m;
}
function close() {
if (!m) return;
cancelAnimationFrame(raf);
raf = 0;
globe.exitMoment();
offOwn?.();
offOwn = null;
const was = m;
m = null;
el.classList.remove('in');
app.classList.remove('is-moment');
setTimeout(
() => {
if (!m) el.hidden = true;
},
reducedMotion() ? 0 : 320,
);
ev.emit('close', was);
onExit?.(was);
}
/* ---------- input ---------- */
$('.mp-back', el).addEventListener('click', () => {
haptic('tick');
close();
});
$('.mp-room', el).addEventListener('click', () => m && onOpenRoom?.(m));
$('.mp-play', el).addEventListener('click', () => {
haptic('tick');
const st = globe.playback.state();
if (st.playing) globe.playback.stop();
else globe.playback.play({ speed });
});
$('.mp-speeds', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-speed]');
if (!b) return;
haptic('tick');
speed = Number(b.dataset.speed);
$$('[data-speed]', el).forEach((x) =>
x.setAttribute('aria-checked', String(x === b)),
);
if (globe.playback.state().playing) globe.playback.play({ speed });
});
$('.mp-react', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-react]');
if (!b || !m) return;
haptic('light');
reactCooldown = 0;
burst(b.dataset.react, 3);
b.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(1.35)' },
{ transform: 'scale(1)' },
],
{ duration: 320, easing: 'cubic-bezier(.32,1.25,.4,1)' },
);
});
const seekTo = (clientX) => {
const r = scrub.getBoundingClientRect();
const k = clamp((clientX - r.left - 6) / (r.width - 12), 0, 1);
const ms = m.time.start + k * (m.time.end - m.time.start);
globe.seekMoment(ms);
t = ms;
sync(true);
};
let dragging = false;
scrub.addEventListener('pointerdown', (e) => {
if (!m) return;
scrub.setPointerCapture(e.pointerId);
dragging = true;
wasPlaying = globe.playback.state().playing;
globe.playback.stop();
el.classList.add('scrubbing');
seekTo(e.clientX);
});
scrub.addEventListener('pointermove', (e) => dragging && seekTo(e.clientX));
const up = () => {
if (!dragging) return;
dragging = false;
el.classList.remove('scrubbing');
if (wasPlaying) globe.playback.play({ speed });
};
scrub.addEventListener('pointerup', up);
scrub.addEventListener('pointercancel', up);
scrub.addEventListener('keydown', (e) => {
if (!m) return;
const step = e.shiftKey ? 60e3 : 10e3;
if (e.key === 'ArrowRight')
globe.seekMoment(Math.min(m.time.end, t + step));
else if (e.key === 'ArrowLeft')
globe.seekMoment(Math.max(m.time.start, t - step));
else if (e.key === ' ') $('.mp-play', el).click();
else return;
e.preventDefault();
t = globe.activeMoment()?.t ?? t;
sync(true);
});
$('.mp-seg', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-seg]');
if (!b) return;
haptic('tick');
seg = b.dataset.seg;
$$('[data-seg]', el).forEach((x) =>
x.setAttribute('aria-selected', String(x === b)),
);
$$('.mp-pane', el).forEach((p) => (p.hidden = p.dataset.pane !== seg));
});
el.addEventListener('click', (e) => {
const tr = e.target.closest('[data-track]');
if (tr && m) return onOpenEntity?.(`moment:${m.id}:${tr.dataset.track}`);
const c = e.target.closest('[data-cap]');
if (c && m) return onOpenEntity?.(`capture:${m.id}:${c.dataset.cap}`);
});
document.addEventListener('keydown', (e) => {
if (!m || e.target.closest?.('input, textarea')) return;
if (
e.key === 'Escape' &&
!document.querySelector(
'.sheet-layer:not([hidden]) .sheet[aria-modal="true"]',
)
)
close();
});
new ResizeObserver(() => m && drawScrub()).observe(scrub);
return {
open,
close,
on: ev.on,
get current() {
return m;
},
get time() {
return t;
},
/** Deterministic setup for the director. */
seek(ms, { play = false } = {}) {
if (!m) return;
globe.seekMoment(ms);
t = ms;
sync(true);
if (play) globe.playback.play({ speed });
else globe.playback.stop();
},
setSeg(s) {
$(`[data-seg="${s}"]`, el)?.click();
},
};
}
+215 -65
View File
@@ -1,84 +1,200 @@
/** Search: situations, layers and places (OpenStreetMap Nominatim via /api/geocode). */
/**
* Universal search (DESIGN.md §12.1, §12.7): instant results from
* globe.search(), grouped into Things, Situations, Places and Moments, plus
* lenses. Search leads to dossiers, not to a list of pins. Recent searches
* and suggestions ("7700", "ISS", "Piccadilly") fill the empty state.
*/
import { icon, FAMILY_ICON } from './icons.js';
import { $, esc, haptic } from './dom.js';
import { $, $$, esc, haptic } from './dom.js';
import { createSheet } from './sheet.js';
import { bandOfKind } from './shim3.js';
import { KIND_ICON, KIND_NAME } from './dossier.js';
import { bandOf } from './bands.js';
const PLACES = [
{ name: 'Kathmandu', sub: 'Nepal', lon: 85.324, lat: 27.7172 },
{ name: 'Strait of Hormuz', sub: 'Persian Gulf', lon: 56.4, lat: 26.5 },
{ name: 'Panama Canal', sub: 'Panama', lon: -79.68, lat: 9.08 },
{ name: 'Heathrow', sub: 'London, UK', lon: -0.4543, lat: 51.47 },
{ name: 'Cape Canaveral', sub: 'Florida, US', lon: -80.6, lat: 28.47 },
{ name: 'Mount Etna', sub: 'Sicily, Italy', lon: 14.995, lat: 37.751 },
const RECENT_KEY = 'gev.search.recent';
const SUGGEST = [
'7700',
'ISS',
'Piccadilly',
'Empire State',
'M6',
'Starlink',
'helicopter',
];
const THING = new Set([
'aircraft',
'helicopter',
'military',
'satellite',
'vessel',
'quake',
'camera',
'track',
'capture',
]);
const GROUPS = [
['things', 'Things'],
['situations', 'Situations'],
['places', 'Places'],
['moments', 'Moments'],
['lenses', 'Lenses'],
];
function recent() {
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
} catch {
return [];
}
}
function remember(q) {
const list = [
q,
...recent().filter((x) => x.toLowerCase() !== q.toLowerCase()),
].slice(0, 6);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list));
} catch {
/* ignore */
}
}
export function createSearch({
host,
getSituations,
globe,
onOpenSituation,
onPlace,
onOpenEntity,
onOpenMoment,
onLayer,
}) {
const sheet = createSheet({
host,
label: 'Search',
detents: ['large'],
modal: true,
dismissible: true,
className: 'search-sheet',
heights: { large: (H) => Math.round(H * 0.92) },
heights: { large: (H) => Math.round(H * 0.93) },
});
sheet.head.innerHTML = `<form class="search-row" role="search"><label class="search-field">${icon('search')}<input type="search" placeholder="Situations, lenses, places" aria-label="Search" enterkeyhint="search" autofocus data-no-drag/></label><button class="pill-btn plain search-cancel" type="button">Cancel</button></form>`;
sheet.head.innerHTML = `<form class="search-row" role="search"><label class="search-field">${icon('search')}<input type="search" placeholder="Aircraft, ships, places, 7700, M5.2…" aria-label="Search everything" enterkeyhint="search" autocomplete="off" spellcheck="false" autofocus data-no-drag/></label><button class="pill-btn plain search-cancel" type="button">Cancel</button></form>`;
const input = $('input', sheet.head);
let timer = 0;
let geo = [];
let items = [];
let seq = 0;
let cursor = -1;
function match(q, ...fields) {
const s = fields.join(' ').toLowerCase();
return q
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.every((w) => s.includes(w));
function row(r, i) {
const band =
r.kind === 'situation' ? r.band || 'ground' : bandOfKind(r.kind);
const ic =
r.kind === 'lens'
? FAMILY_ICON[r.family] || 'layers'
: KIND_ICON[r.kind] || 'scope';
const tag =
r.kind === 'lens' ? (r.on ? 'On' : 'Off') : KIND_NAME[r.kind] || r.kind;
return `<button class="sr" type="button" data-i="${i}" data-band="${esc(band)}" role="option" aria-selected="${i === cursor}"><span class="sr-ic">${icon(ic)}</span><span class="sr-main"><b>${esc(r.title)}</b><small>${esc(r.subtitle || '')}</small></span><span class="sr-tag tele">${esc(tag)}</span></button>`;
}
function emptyState() {
const rec = recent();
sheet.body.innerHTML = `
${rec.length ? `<div class="results-h">Recent<button class="link-btn" type="button" data-clear>Clear</button></div><div class="sr-chips">${rec.map((q) => `<button class="chip" type="button" data-q="${esc(q)}">${icon('clock')}${esc(q)}</button>`).join('')}</div>` : ''}
<div class="results-h">Try</div>
<div class="sr-chips">${SUGGEST.map((q) => `<button class="chip" type="button" data-q="${esc(q)}">${esc(q)}</button>`).join('')}</div>
<div class="results-h">Live now</div><div class="sr-list" data-live></div>
<p class="sr-note">${icon('lock')}Search finds things that broadcast publicly (transponders, AIS, orbital elements), places and events. It never finds people.</p>`;
getSituations().then((sits) => {
const live = $('[data-live]', sheet.body);
if (!live) return;
items = sits.slice(0, 5).map((s) => ({
id: `situation:${s.id}`,
kind: 'situation',
title: s.title,
subtitle: s.place,
band: bandOf(s),
}));
live.innerHTML = items.map(row).join('');
});
}
async function render() {
const q = input.value.trim();
const sits = await getSituations();
const s = q
? sits.filter((x) => match(q, x.title, x.place, x.kind, x.family))
: sits.slice(0, 5);
const layers = q
? globe
.layers()
.filter((l) => match(q, l.name, l.family))
.slice(0, 8)
: [];
const places = q
? PLACES.filter((p) => match(q, p.name, p.sub))
: PLACES.slice(0, 4);
sheet.body.innerHTML = `
<div class="results-h">${q ? 'Situations' : 'Live now'}</div>
${s.map((x) => `<button class="result" type="button" data-sit="${esc(x.id)}"><img class="result-thumb" src="${esc(x.thumbnail)}" alt="" loading="lazy"/><span><div class="result-title">${esc(x.title)}</div><div class="result-sub">${esc(x.place)}</div></span></button>`).join('') || '<p class="result-sub">No situations match.</p>'}
${layers.length ? `<div class="results-h">Lenses</div>${layers.map((l) => `<button class="result" type="button" data-layer="${esc(l.id)}" data-family="${esc(l.family)}"><span class="result-icon">${icon(FAMILY_ICON[l.family] || 'layers')}</span><span><div class="result-title">${esc(l.name)}</div><div class="result-sub">${esc(l.family)} · ${l.on ? 'on' : 'off'}${l.count != null ? ` · ${l.count}` : ''}</div></span></button>`).join('')}` : ''}
<div class="results-h">Places</div>
${[...places, ...geo].map((p, i) => `<button class="result" type="button" data-place="${i}"><span class="result-icon">${icon('pin')}</span><span><div class="result-title">${esc(p.name)}</div><div class="result-sub">${esc(p.sub || '')}</div></span></button>`).join('')}
${q.length >= 3 ? '<p class="result-sub" style="padding:10px 4px">Press search to look up places · © OpenStreetMap contributors</p>' : ''}`;
sheet.body._places = [...places, ...geo];
cursor = -1;
if (!q) return emptyState();
const my = ++seq;
const res = await globe.search(q);
if (my !== seq) return;
const sits = await getSituations().catch(() => []);
const ql = q.toLowerCase();
const lenses = globe
.layers()
.filter((l) => `${l.name} ${l.family}`.toLowerCase().includes(ql))
.slice(0, 4)
.map((l) => ({
id: l.id,
kind: 'lens',
title: l.name,
subtitle: `${l.family} lens${l.count != null ? ` · ${l.count}` : ''}`,
family: l.family,
on: l.on,
}));
const grouped = {
things: [],
situations: [],
places: [],
moments: [],
lenses,
};
for (const r of res) {
if (r.kind === 'situation') {
const s = sits.find((x) => `situation:${x.id}` === r.id);
grouped.situations.push({
...r,
band: r.band || (s ? bandOf(s) : 'ground'),
});
} else if (r.kind === 'place') grouped.places.push(r);
else if (r.kind === 'moment') grouped.moments.push(r);
else if (THING.has(r.kind)) grouped.things.push(r);
}
for (const g of geo) grouped.places.push(g);
items = [];
const html = GROUPS.map(([k, label]) => {
const list = grouped[k].slice(0, k === 'things' ? 8 : 5);
if (!list.length) return '';
const rows = list.map((r) => {
items.push(r);
return row(r, items.length - 1);
});
return `<div class="results-h">${label}<small>${grouped[k].length}</small></div><div class="sr-list" role="listbox">${rows.join('')}</div>`;
}).join('');
sheet.body.innerHTML =
html ||
`<div class="sr-empty"><b>No matches for “${esc(q)}”</b><span>Press return to look it up as a place.</span></div>`;
if (q.length >= 3 && !geo.length)
sheet.body.insertAdjacentHTML(
'beforeend',
`<p class="sr-note">Return looks “${esc(q)}” up as a place · © OpenStreetMap contributors</p>`,
);
}
async function geocode() {
const q = input.value.trim();
if (q.length < 3) return;
remember(q);
try {
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
if (!r.ok) throw new Error(String(r.status));
const j = await r.json();
geo = (j.results || [])
.slice(0, 5)
.map((x) => ({
name: x.formatted_address?.split(',')[0] || x.name || q,
sub: x.formatted_address || '',
.map((x, i) => ({
id: `geo:${i}`,
kind: 'place',
title: x.formatted_address?.split(',')[0] || x.name || q,
subtitle: x.formatted_address || '',
lat: Number(x.geometry?.location?.lat ?? x.lat),
lon: Number(x.geometry?.location?.lng ?? x.lon),
}))
@@ -89,51 +205,85 @@ export function createSearch({
render();
}
function choose(r) {
if (!r) return;
haptic('light');
const q = input.value.trim();
if (q) remember(q);
sheet.close();
setTimeout(() => {
if (r.kind === 'lens') onLayer(r.id);
else if (r.kind === 'situation')
onOpenSituation(r.id.replace(/^situation:/, ''));
else if (r.kind === 'moment') onOpenMoment(r.id.replace(/^moment:/, ''));
else if (/^geo:/.test(r.id))
globe.flyTo({ lon: r.lon, lat: r.lat, height: 30e3, duration: 2 });
else onOpenEntity(r.id);
}, 160);
}
input.addEventListener('input', () => {
geo = [];
clearTimeout(timer);
timer = setTimeout(render, 80);
timer = setTimeout(render, 60);
});
input.addEventListener('keydown', (e) => {
const rows = $$('.sr', sheet.body);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
cursor = Math.max(
0,
Math.min(rows.length - 1, cursor + (e.key === 'ArrowDown' ? 1 : -1)),
);
rows.forEach((r, i) =>
r.setAttribute('aria-selected', String(i === cursor)),
);
rows[cursor]?.scrollIntoView({ block: 'nearest' });
}
});
sheet.head.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
if (cursor >= 0 && items[cursor]) return choose(items[cursor]);
if (items.length && input.value.trim() && !$('.sr-empty', sheet.body))
return choose(items[0]);
geocode();
});
sheet.head
.querySelector('.search-cancel')
.addEventListener('click', () => sheet.close());
sheet.body.addEventListener('click', async (e) => {
const sit = e.target.closest('[data-sit]');
if (sit) {
const s = (await getSituations()).find(
(x) => String(x.id) === sit.dataset.sit,
);
sheet.close();
if (s) setTimeout(() => onOpenSituation(s), 200);
return;
}
const l = e.target.closest('[data-layer]');
if (l) {
sheet.body.addEventListener('click', (e) => {
const chip = e.target.closest('[data-q]');
if (chip) {
haptic('tick');
sheet.close();
onLayer(l.dataset.layer);
input.value = chip.dataset.q;
render();
return;
}
const p = e.target.closest('[data-place]');
if (p) {
const place = sheet.body._places[Number(p.dataset.place)];
sheet.close();
if (place) onPlace(place);
if (e.target.closest('[data-clear]')) {
try {
localStorage.removeItem(RECENT_KEY);
} catch {
/* ignore */
}
emptyState();
return;
}
const r = e.target.closest('[data-i]');
if (r) choose(items[Number(r.dataset.i)]);
});
return {
open() {
input.value = '';
open(q = '') {
input.value = q;
geo = [];
render();
sheet.open('large');
setTimeout(() => input.focus({ preventScroll: true }), 120);
},
close: () => sheet.close(),
get isOpen() {
return sheet.isOpen;
},
};
}
+6 -5
View File
@@ -27,7 +27,7 @@ export function createSheet(o) {
const root = h(`<div class="sheet-layer ${o.modal ? 'is-modal' : ''}" hidden>
${o.modal ? '<div class="sheet-scrim" aria-hidden="true"></div>' : ''}
<section class="sheet ${o.className || ''}" role="dialog" aria-modal="${o.modal ? 'true' : 'false'}" aria-label="${o.label}">
<section class="sheet ${o.className || ''}" tabindex="-1" role="dialog" aria-modal="${o.modal ? 'true' : 'false'}" aria-label="${o.label}">
<button class="sheet-grabber" type="button" aria-label="${o.label}: resize"><span></span></button>
${o.closeButton ? `<button class="sheet-close icon-btn" type="button" aria-label="Close">${icon('close')}</button>` : ''}
<div class="sheet-head"></div>
@@ -100,10 +100,11 @@ export function createSheet(o) {
if (o.modal)
setTimeout(
() =>
(
sheet.querySelector('[autofocus]') ||
root.querySelector('.sheet-grabber')
)?.focus({ preventScroll: true }),
// The dialog itself takes focus (no ring on the grabber); Tab
// reaches the grabber and every control from there.
(sheet.querySelector('[autofocus]') || sheet)?.focus({
preventScroll: true,
}),
60,
);
}
+1518
View File
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
/**
* Your sky now (DESIGN.md §12.3): what is above you this minute, on a sky
* dome (zenith in the centre, horizon at the rim, north up) and as a list.
* Live from globe.skyNow(); refreshes every 2 s while open.
*/
import { icon } from './icons.js';
import { $, esc, fmtNum, haptic } from './dom.js';
import { createSheet } from './sheet.js';
import { cardinal, utc } from './bands.js';
/** Sky dome SVG. size in px; items: [{ el, az, kind, id, label? }]. */
export function domeSvg(sky, { size = 300, labels = true, sel = null } = {}) {
const c = size / 2;
const R = c - (labels ? 18 : 4);
const pos = (el, az) => {
const r = R * (1 - Math.max(0, el) / 90);
const a = ((az - 90) * Math.PI) / 180;
return [c + r * Math.cos(a), c + r * Math.sin(a)];
};
const dots = [];
for (const s of sky?.satellites || []) {
const [x, y] = pos(s.elevationDeg, s.azimuthDeg);
dots.push(
`<g class="dm-sat${sel === s.id ? ' sel' : ''}" data-id="${esc(s.id)}"><rect x="${(x - 2.2).toFixed(1)}" y="${(y - 2.2).toFixed(1)}" width="4.4" height="4.4" transform="rotate(45 ${x.toFixed(1)} ${y.toFixed(1)})"/></g>`,
);
}
for (const a of sky?.aircraft || []) {
if (a.elevationDeg < 0) continue;
const [x, y] = pos(a.elevationDeg, a.azimuthDeg);
dots.push(
`<g class="dm-ac${sel === a.id ? ' sel' : ''}" data-id="${esc(a.id)}" transform="translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${Math.round(a.headingDeg || 0)})"><path d="M0 -5.5 3.6 4 0 2 -3.6 4Z"/></g>`,
);
}
const iss = sky?.iss;
if (iss && Number.isFinite(iss.elevationDeg) && iss.elevationDeg > 0) {
const [x, y] = pos(iss.elevationDeg, iss.azimuthDeg);
dots.push(
`<g class="dm-iss"><circle cx="${x}" cy="${y}" r="4"/><circle class="ring" cx="${x}" cy="${y}" r="9"/>${labels ? `<text x="${x + 12}" y="${y + 3}">ISS</text>` : ''}</g>`,
);
}
return `<svg class="dome" viewBox="0 0 ${size} ${size}" role="img" aria-label="Sky dome: ${(sky?.aircraft || []).length} aircraft and ${(sky?.satellites || []).length} satellites above you">
<defs><radialGradient id="dm-g${size}" cx="50%" cy="50%" r="50%"><stop offset="0" stop-color="#14203a"/><stop offset="0.75" stop-color="#0a1020"/><stop offset="1" stop-color="#05070b"/></radialGradient></defs>
<circle cx="${c}" cy="${c}" r="${R}" fill="url(#dm-g${size})" class="dm-sky"/>
${[30, 60].map((e) => `<circle cx="${c}" cy="${c}" r="${R * (1 - e / 90)}" class="dm-ring"/>`).join('')}
<path class="dm-ring" d="M${c} ${c - R}V${c + R}M${c - R} ${c}H${c + R}"/>
<circle cx="${c}" cy="${c}" r="${R}" class="dm-horizon"/>
${labels ? `<text class="dm-card n" x="${c}" y="${c - R - 6}">N</text><text class="dm-card" x="${c + R + 9}" y="${c + 3}">E</text><text class="dm-card" x="${c}" y="${c + R + 14}">S</text><text class="dm-card" x="${c - R - 9}" y="${c + 3}">W</text><text class="dm-el" x="${c + 3}" y="${c - R * (1 - 60 / 90) - 3}">60°</text><text class="dm-el" x="${c + 3}" y="${c - R * (1 - 30 / 90) - 3}">30°</text>` : ''}
${dots.join('')}
<circle cx="${c}" cy="${c}" r="2" class="dm-you"/>
</svg>`;
}
export function createSkySheet({ host, globe, getHome, onOpenEntity }) {
const sheet = createSheet({
host,
label: 'Your sky now',
detents: ['medium', 'large'],
modal: true,
dismissible: true,
closeButton: true,
className: 'sky-sheet',
heights: {
medium: (H) => Math.round(H * 0.62),
large: (H) => Math.round(H * 0.93),
},
});
let timer = 0;
let sky = null;
sheet.on('close', () => clearInterval(timer));
function rowAc(a) {
return `<button class="sky-row" type="button" data-ent="${esc(a.id)}" data-band="sky"><span class="sky-ic">${icon(a.kind === 'helicopter' ? 'heli' : 'sky')}</span>
<span class="sky-main"><b>${esc(a.title)}</b><small>${esc(fmtNum(Math.round((a.altM * 3.28084) / 100) * 100))} ft · ${esc(fmtNum(a.distanceKm, 1))} km ${esc(cardinal(a.azimuthDeg))}</small></span>
<span class="sky-el tele"><b class="num">${Math.round(a.elevationDeg)}°</b>UP</span></button>`;
}
function rowSat(s) {
return `<button class="sky-row" type="button" data-ent="${esc(s.id)}" data-band="orbit"><span class="sky-ic">${icon('satellite')}</span>
<span class="sky-main"><b>${esc(s.title)}</b><small>${esc(fmtNum(s.altKm))} km up · ${esc(fmtNum(s.rangeKm))} km away ${esc(cardinal(s.azimuthDeg))}</small></span>
<span class="sky-el tele"><b class="num">${Math.round(s.elevationDeg)}°</b>UP</span></button>`;
}
async function refresh() {
const home = getHome();
sky = await globe.skyNow({ lon: home.lon, lat: home.lat });
if (!sky) {
sheet.body.innerHTML =
'<p class="footnote">The sky feed is not available on this engine yet.</p>';
return;
}
const ac = sky.aircraft || [];
const sats = sky.satellites || [];
const iss = sky.iss || {};
const np = iss.nextPass;
const hm = (t) =>
new Date(t).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
});
$('.sky-dome', sheet.body).innerHTML = domeSvg(sky, { size: 300 });
$('.sky-count', sheet.body).innerHTML =
`<div><b class="num">${ac.length}</b><span>aircraft</span></div><div><b class="num">${sats.length}</b><span>satellites</span></div><div><b class="num">${iss.visibleNow ? 'Now' : np ? hm(np.start) : '—'}</b><span>${iss.visibleNow ? 'ISS overhead' : 'next ISS pass'}</span></div>`;
$('.sky-lists', sheet.body).innerHTML = `
${np || iss.visibleNow ? `<div class="sky-iss" data-band="orbit">${icon('satellite')}<div><b>${iss.visibleNow ? 'The ISS is above you right now' : `ISS passes over at ${hm(np.start)}`}</b><span>${np ? `Rises ${hm(np.start)} · highest ${np.maxElevationDeg ?? '—'}° at ${hm(np.max)} · sets ${np.end ? hm(np.end) : '—'}` : `${Math.round(iss.elevationDeg)}° up, ${cardinal(iss.azimuthDeg)}`}</span></div></div>` : ''}
<div class="section-h">Aircraft<small class="num">${ac.length} within 50 km</small></div>
<div class="group">${ac.slice(0, 12).map(rowAc).join('') || '<div class="row"><span class="row-sub">No aircraft overhead right now. A quiet sky.</span></div>'}</div>
<div class="section-h">Satellites<small class="num">${sats.length} above the horizon</small></div>
<div class="group">${sats.slice(0, 12).map(rowSat).join('') || '<div class="row"><span class="row-sub">None above the horizon.</span></div>'}</div>
<p class="footnote">Live ADS-B and orbital elements, computed for ${esc(getHome().label || getHome().name)}${getHome().demo ? ' (demo location)' : ''} · ${esc(utc(new Date()))}</p>`;
}
sheet.body.addEventListener('click', (e) => {
const r = e.target.closest('[data-ent]');
if (r) {
haptic('light');
sheet.close();
onOpenEntity?.(r.dataset.ent);
}
});
return {
async open() {
const home = getHome();
sheet.head.innerHTML = `<div class="sky-kicker tele"><span class="live-dot"></span>LIVE · ${esc(utc(new Date(), { seconds: false }))}</div><h2 class="sheet-title">Your sky now</h2><div class="sheet-sub">Above ${esc(home.label || home.name)}${home.demo ? ' · demo location' : ''}, this minute</div>`;
sheet.body.innerHTML = `<div class="sky-top"><div class="sky-dome"></div><div class="sky-count"></div></div><div class="sky-lists"></div>`;
sheet.open('medium');
await refresh();
clearInterval(timer);
timer = setInterval(() => sheet.isOpen && refresh(), 2000);
},
close: () => sheet.close(),
get isOpen() {
return sheet.isOpen;
},
};
}
+508
View File
@@ -0,0 +1,508 @@
/**
* The hero Moment (DESIGN.md §12.4): "Climbers on the Empire State Building".
*
* An ILLUSTRATIVE reconstruction used when the engine does not ship one
* (offline stub, or before mobile/demo/moments/empire-state.json lands).
* Tracks are synthetic but physically plausible: two news helicopters and one
* police helicopter orbiting at 1,000–1,500 ft, radii 400–700 m, 60–80 kt,
* with standard-rate-ish turns. Captures, chat and posts are invented and
* labelled as such everywhere they are shown.
*/
const R = 6371008.8;
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
export const ESB = { lon: -73.98566, lat: 40.74844 };
function dest(lon, lat, brg, dist) {
const d = dist / R;
const b = brg * D2R;
const p1 = lat * D2R;
const l1 = lon * D2R;
const p2 = Math.asin(
Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(b),
);
const l2 =
l1 +
Math.atan2(
Math.sin(b) * Math.sin(d) * Math.cos(p1),
Math.cos(d) - Math.sin(p1) * Math.sin(p2),
);
return [l2 * R2D, p2 * R2D];
}
export function bearing(a, b) {
const p1 = a.lat * D2R;
const p2 = b.lat * D2R;
const dl = (b.lon - a.lon) * D2R;
const y = Math.sin(dl) * Math.cos(p2);
const x =
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return (((Math.atan2(y, x) * R2D + 360) % 360) + 360) % 360;
}
export function distM(a, b) {
const dLat = (b.lat - a.lat) * D2R;
const dLon = (b.lon - a.lon) * D2R;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos(a.lat * D2R) * Math.cos(b.lat * D2R) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
}
/**
* One helicopter: a straight approach from its base to the orbit entry, then
* a constant-speed orbit. Points every 5 s from departure to the Moment end.
*/
function heli({
id,
label,
type,
operator,
base,
arriveAt,
radius,
altM,
kt,
dir,
phase,
t0,
t1,
}) {
const v = kt * 0.514444;
const entryBrg = bearing(ESB, base);
const [ex, ey] = dest(ESB.lon, ESB.lat, entryBrg, radius);
const entry = { lon: ex, lat: ey };
const approachM = distM(base, entry);
const depart = arriveAt - (approachM / v) * 1000;
const pts = [];
const step = 5000;
const omega = (v / radius) * R2D; // deg per second
for (
let t = Math.max(t0, Math.ceil(depart / step) * step);
t <= t1;
t += step
) {
if (t < arriveAt) {
const k = (t - depart) / (arriveAt - depart);
const lon = base.lon + (entry.lon - base.lon) * k;
const lat = base.lat + (entry.lat - base.lat) * k;
pts.push({ t, lon, lat, altM: Math.round(560 + (altM - 560) * k) });
} else {
const s = (t - arriveAt) / 1000;
const a = entryBrg + dir * omega * s + (phase || 0) * 0;
const [lon, lat] = dest(ESB.lon, ESB.lat, a, radius);
// A gentle porpoise of ±15 m reads as a real track, not a CAD circle.
pts.push({
t,
lon,
lat,
altM: Math.round(altM + 15 * Math.sin(s / 23 + (phase || 0))),
});
}
}
return {
id,
kind: 'helicopter',
label,
type,
operator,
speedKt: kt,
radiusM: radius,
arriveAt,
points: pts,
};
}
/** Interpolated position of a track at time t (null before it exists). */
export function trackAt(track, t) {
const p = track.points;
if (!p?.length || t < p[0].t) return null;
if (t >= p[p.length - 1].t) return { ...p[p.length - 1], i: p.length - 1 };
let lo = 0;
let hi = p.length - 1;
while (hi - lo > 1) {
const m = (lo + hi) >> 1;
if (p[m].t <= t) lo = m;
else hi = m;
}
const a = p[lo];
const b = p[hi];
const k = (t - a.t) / Math.max(1, b.t - a.t);
const pos = {
t,
lon: a.lon + (b.lon - a.lon) * k,
lat: a.lat + (b.lat - a.lat) * k,
altM: a.altM + (b.altM - a.altM) * k,
i: lo,
};
pos.headingDeg = bearing(a, b);
pos.speedMps = distM(a, b) / Math.max(0.001, (b.t - a.t) / 1000);
return pos;
}
export function buildHeroMoment(now = Date.now()) {
// The Moment happened earlier today: 20 minutes, ending ~75 min ago.
const t0 = Math.floor((now - 95 * 60e3) / 60e3) * 60e3;
const t1 = t0 + 20 * 60e3;
const at = (m, s = 0) => t0 + m * 60e3 + s * 1000;
const tracks = [
heli({
id: 'police6',
label: 'POLICE 6',
type: 'Bell 429',
operator: 'City police aviation unit',
base: { lon: -74.009, lat: 40.7013 },
arriveAt: at(2, 30),
radius: 450,
altM: 320,
kt: 68,
dir: -1,
t0,
t1,
}),
heli({
id: 'news4',
label: 'NEWSCOPTER 4',
type: 'Airbus H125',
operator: 'Metro 4 News',
base: { lon: -74.008, lat: 40.754 },
arriveAt: at(5, 10),
radius: 650,
altM: 442,
kt: 78,
dir: -1,
phase: 2,
t0,
t1,
}),
heli({
id: 'sky7',
label: 'SKY 7',
type: 'Bell 407',
operator: 'Harbor 7 Eyewitness',
base: { lon: -74.061, lat: 40.85 },
arriveAt: at(8, 40),
radius: 560,
altM: 395,
kt: 72,
dir: -1,
phase: 4,
t0,
t1,
}),
];
const cap = (o) => {
const pos = { lon: o.lon, lat: o.lat };
return {
fovDeg: 52,
sealed: true,
...o,
headingDeg: Math.round(bearing(pos, ESB)),
distanceM: Math.round(distM(pos, ESB)),
};
};
const captures = [
cap({
id: 'c1',
t: at(1, 12),
lon: -73.9883,
lat: 40.7497,
altM: 2,
title: 'Two figures on the north setback',
handle: '@harbor_cam_kid',
where: 'W 34th St & 6th Ave',
firstEyes: true,
media: 'photo',
}),
cap({
id: 'c2',
t: at(4, 31),
lon: -73.98745,
lat: 40.74645,
altM: 2,
title: 'Police helicopter holding over 5th Ave',
handle: '@5thave_dana',
where: '5th Ave & 31st St',
media: 'clip',
}),
cap({
id: 'c3',
t: at(9, 5),
lon: -73.9928,
lat: 40.7506,
altM: 158,
title: 'Level view from the 38th floor, 1 Penn',
handle: '@onepenn_38f',
where: '1 Penn Plaza, floor 38',
media: 'photo',
}),
cap({
id: 'c4',
t: at(11, 48),
lon: -73.9888,
lat: 40.7458,
altM: 64,
title: 'Live from a Broadway rooftop',
handle: '@roof_rodrigo',
where: 'Broadway & 30th St, roof',
media: 'live',
live: true,
}),
cap({
id: 'c5',
t: at(15, 20),
lon: -73.9849,
lat: 40.7499,
altM: 2,
title: 'Climbers brought in at the 86th floor',
handle: '@midtown_maya',
where: '5th Ave & 35th St',
media: 'clip',
}),
];
const chatScript = [
[
0,
40,
'@nyc_scanner_nerd',
'scanner: units requested to 350 5th, "persons on the exterior"',
'local',
],
[0, 58, '@lex_ave_lee', 'wait what, ON the building??'],
[
1,
20,
'@harbor_cam_kid',
'I can see them from 34th. two people. sealed photo up',
'first-eyes',
],
[1, 34, '@aero_ana', 'that photo is wild'],
[1, 50, '@k_ostrowski', '👀👀👀'],
[
2,
10,
'@nyc_scanner_nerd',
'aviation unit launching from downtown',
'local',
],
[
2,
41,
'@heli_hank',
'POLICE 6 inbound, left-hand orbit ~450 m. textbook',
'pilot',
],
[3, 2, '@5thave_dana', 'deck looks closed, people being moved inside'],
[3, 30, '@bk_bianca', 'is anyone hurt?'],
[
3,
44,
'@harbor_cam_kid',
'not that I can see. they look clipped in',
'first-eyes',
],
[4, 12, '@lex_ave_lee', 'stay safe up there 🙏'],
[
4,
40,
'@5thave_dana',
'clip from 5th, you can see POLICE 6 holding',
'witness',
],
[
5,
15,
'@heli_hank',
'NEWSCOPTER 4 just joined, wider orbit ~650 m',
'pilot',
],
[5, 40, '@aero_ana', 'two helis now'],
[6, 5, '@k_ostrowski', 'traffic on 34th is stopped'],
[6, 30, '@nyc_scanner_nerd', 'ESU on the 86th floor per scanner', 'local'],
[7, 3, '@midtown_maya', 'heading over, 5 min out'],
[7, 40, '@bk_bianca', 'who are they? protest?'],
[
8,
2,
'@mod_signal',
'Reminder: no names, no faces. Evidence, not identities.',
'mod',
],
[
8,
51,
'@heli_hank',
'SKY 7 makes three. all holding separation, nice',
'pilot',
],
[
9,
12,
'@onepenn_38f',
'level view from 1 Penn, 38th fl. they are lower than the spire',
'witness',
],
[9, 40, '@lex_ave_lee', 'that angle 🔥'],
[10, 15, '@aero_ana', 'so not the spire then'],
[10, 48, '@harbor_cam_kid', 'correct, setback above 86', 'first-eyes'],
[
11,
20,
'@k_ostrowski',
'hearts for the window washers who called it in ❤️',
],
[
11,
55,
'@roof_rodrigo',
'going live from the roof on Broadway',
'witness',
],
[12, 30, '@bk_bianca', 'watching the live, so steady'],
[
13,
10,
'@nyc_scanner_nerd',
'scanner: "contact made, cooperative"',
'local',
],
[13, 42, '@lex_ave_lee', 'phew'],
[14, 20, '@heli_hank', 'POLICE 6 tightening orbit to ~350 m', 'pilot'],
[
15,
25,
'@midtown_maya',
'they are being brought in through a window at 86',
'witness',
],
[15, 50, '@aero_ana', '😮'],
[16, 30, '@k_ostrowski', 'whole street clapping lol'],
[17, 15, '@5thave_dana', 'deck reopening soon per staff'],
[18, 0, '@heli_hank', 'news helis peeling off west', 'pilot'],
[18, 45, '@nyc_scanner_nerd', 'units clearing', 'local'],
[19, 20, '@bk_bianca', 'what a morning'],
];
const chat = chatScript.map(([m, s, handle, text, badge]) => ({
t: at(m, s),
handle,
text,
...(badge ? { badge } : {}),
}));
const posts = [
{
t: at(1, 40),
handle: '@harbor_cam_kid',
kind: 'witness',
text: 'Two people on the north setback above the 86th-floor deck. Sealed photo from W 34th St, 180 m from the base.',
votes: { corroborate: 41, dispute: 2 },
captureId: 'c1',
},
{
t: at(3, 15),
handle: '@bk_bianca',
kind: 'question',
text: 'Is the observation deck closed? Anyone hurt?',
votes: { corroborate: 12, dispute: 0 },
},
{
t: at(6, 10),
handle: '@nyc_scanner_nerd',
kind: 'context',
text: 'Police aviation on scene since the 2½-minute mark; this is the standard rope-rescue pattern for exterior climbers in Midtown.',
votes: { corroborate: 19, dispute: 1 },
},
{
t: at(10, 5),
handle: '@onepenn_38f',
kind: 'correction',
text: 'Not the spire. The level view from 1 Penn puts them on the setback just above 86, well below the mast.',
votes: { corroborate: 33, dispute: 3 },
captureId: 'c3',
},
{
t: at(15, 35),
handle: '@midtown_maya',
kind: 'witness',
text: 'Both climbers brought in through a window at the 86th floor. Clip sealed from 5th Ave & 35th.',
votes: { corroborate: 27, dispute: 0 },
captureId: 'c5',
},
{
t: at(18, 30),
handle: 'Clerk',
kind: 'synthesis',
text: 'What chat is saying: two climbers on the north setback (5 sealed captures agree); three helicopters on scene; no injuries reported; both brought inside at 86.',
votes: { corroborate: 0, dispute: 0 },
},
];
return {
id: 'empire-state',
title: 'Climbers on the Empire State Building',
place: 'Midtown Manhattan, New York',
lon: ESB.lon,
lat: ESB.lat,
time: { start: t0, end: t1 },
subject: { lon: ESB.lon, lat: ESB.lat, label: 'Empire State Building' },
illustrative: true,
cameraPath: [
{
t: t0,
lon: ESB.lon,
lat: ESB.lat - 0.012,
height: 4200,
heading: 10,
pitch: -38,
},
{
t: at(5),
lon: ESB.lon - 0.006,
lat: ESB.lat - 0.01,
height: 2600,
heading: 30,
pitch: -32,
},
{
t: at(12),
lon: ESB.lon + 0.008,
lat: ESB.lat - 0.009,
height: 2200,
heading: 340,
pitch: -30,
},
{
t: t1,
lon: ESB.lon,
lat: ESB.lat - 0.011,
height: 3000,
heading: 0,
pitch: -35,
},
],
tracks,
captures,
chat,
posts,
sources: [
{
name: 'Illustrative reconstruction',
detail:
'Synthetic helicopter tracks modelled on typical news and police orbits (400–700 m radius, 1,000–1,500 ft, 60–80 kt). No real aircraft, people or posts.',
},
{
name: 'Esri World Imagery',
detail: 'Base imagery of Midtown Manhattan',
},
{
name: 'Example captures',
detail: 'View cones, positions and times are illustrative',
},
],
};
}
+29
View File
@@ -348,6 +348,35 @@ export function buildStubSituations(now) {
severity: 1,
age: 0,
}),
S({
id: 'circling-midtown',
kind: 'circling',
family: 'sky',
band: 'sky',
archetype: 'track',
severity: 4,
live: false,
title: '3 helicopters circled Midtown Manhattan',
subtitle: 'Detected from orbiting tracks · replay the Moment',
place: 'Empire State Building, New York',
lon: -73.98566,
lat: 40.74844,
span: 0.05,
th: 520,
height: 6000,
layers: ['sky.helicopters', 'sky.commercial-flights'],
metrics: [
{ label: 'Rotorcraft', value: 3, unit: '' },
{ label: 'Orbit radius', value: '450–650', unit: 'm' },
{ label: 'Altitude', value: '1,050–1,450', unit: 'ft' },
],
headline: { value: 3, unit: 'helis', label: 'circling one point' },
telemetry: 'SKY · 3 ROTORCRAFT · R 450–650 M · 1,050–1,450 FT',
momentId: 'empire-state',
illustrative: true,
track: { headingDeg: 0, circuit: true },
age: 76,
}),
S({
id: 'novosel',
kind: 'helicopters',
+6 -6
View File
@@ -8,10 +8,10 @@ import { icon, FAMILY_ICON } from './icons.js';
import { $, $$, esc, ago, fmtNum, haptic } from './dom.js';
export const TABS = [
{ id: 'now', label: 'Now', icon: 'now' },
{ id: 'globe', label: 'Globe', icon: 'globe' },
{ id: 'today', label: 'Today', icon: 'today' },
{ id: 'live', label: 'Live', icon: 'globe' },
{ id: 'capture', label: 'Capture', icon: 'viewfinder' },
{ id: 'threads', label: 'Threads', icon: 'threads' },
{ id: 'rooms', label: 'Rooms', icon: 'rooms' },
{ id: 'you', label: 'You', icon: 'you' },
];
@@ -41,7 +41,7 @@ export function createTabBar(nav, { onSelect, onSearch, onAccessory }) {
const acc = $('.tb-accessory', nav);
const miniTab = $('.tb-mini-tab', nav);
const miniAcc = $('.tb-mini-acc', nav);
let current = 'now';
let current = 'today';
let minimized = false;
let accSituation = null;
@@ -74,9 +74,9 @@ export function createTabBar(nav, { onSelect, onSearch, onAccessory }) {
function accessoryHtml(s) {
const m = s.metrics?.[0];
return `<span class="acc-glyph" data-family="${esc(s.family)}">${icon(FAMILY_ICON[s.family] || 'now')}</span>
return `<span class="acc-glyph" data-family="${esc(s.family)}">${icon(s.icon || FAMILY_ICON[s.family] || 'now')}</span>
<span class="acc-text"><span class="acc-title">${esc(s.title)}</span>
<span class="acc-meta">${esc(s.place || '')} · ${esc(ago(s.updatedAt))}</span></span>
<span class="acc-meta">${esc(s.status || `${s.place || ''} · ${ago(s.updatedAt)}`)}</span></span>
${m ? `<span class="acc-metric num" data-family="${esc(s.family)}">${esc(fmtNum(m.value))}${m.unit ? ` <small>${esc(m.unit)}</small>` : ''}</span>` : ''}`;
}
+546 -183
View File
@@ -1,50 +1,123 @@
/**
* Time ribbon: −72 h ← NOW → +120 h with momentum drag, haptic hour detents,
* event pips and a LIVE pill that snaps back. Fast drags move in hours,
* slow drags in minutes. Calls `onTime(Date | null)` (null = live).
* The time machine (DESIGN.md §12.5).
*
* A fixed playhead over a moving track: recorded range shading (history()),
* forecast range, event pips coloured by altitude band with detents, momentum
* scrubbing, a NOW snap, play/pause at 1×/10×/60×/600×, a big readable time
* readout ("−42 MIN · 14:02:11Z"), and range select → "Clip this Moment".
* Works with touch, mouse drag, trackpad swipe (wheel) and pinch (ctrl+wheel).
*
* The public surface of the round-two ribbon is kept: setPips, setWindow,
* scrubTo, seek, goLive, time, isLive. Calls `onTime(Date | null)`.
*/
import { h, clamp, haptic, reducedMotion } from './dom.js';
import { h, clamp, haptic, reducedMotion, esc } from './dom.js';
import { icon } from './icons.js';
import { BAND_COLOR, utc } from './bands.js';
const MIN = 60e3;
const HOUR = 3600e3;
const SPEEDS = [1, 10, 60, 600];
const STEPS = [1, 5, 10, 15, 30, 60, 180, 360, 720, 1440].map((m) => m * MIN);
export function createTimeRibbon(parent, { getWindow, onTime }) {
/** "−42 MIN", "−3 H 12 MIN", "+2 H" */
export function fmtDelta(ms) {
const sign = ms < 0 ? '−' : '+';
if (Math.abs(ms) < MIN) return `${sign}${Math.round(Math.abs(ms) / 1000)} S`;
const a = Math.round(Math.abs(ms) / MIN);
if (a < 60) return `${sign}${a} MIN`;
const hrs = Math.floor(a / 60);
const m = a % 60;
if (hrs >= 48) return `${sign}${Math.round(hrs / 24)} D`;
return `${sign}${hrs} H${m ? ` ${m} MIN` : ''}`;
}
export function createTimeRibbon(
parent,
{
getWindow,
onTime,
playback = null,
getHistory = null,
onClip = null,
onPip = null,
},
) {
const el =
h(`<div class="ribbon glass-strong liquid" role="group" aria-label="Time">
<button class="rb-live is-live" type="button" aria-label="Return to live"><span class="live-dot"></span><span>LIVE</span></button>
<div class="rb-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
h(`<div class="ribbon tm glass-strong liquid is-live" role="group" aria-label="Time machine">
<div class="tm-row">
<button class="tm-play" type="button" aria-label="Play">${icon('play')}</button>
<div class="tm-read" aria-live="off">
<b class="tm-delta num">LIVE</b><span class="tm-abs num"></span>
</div>
<div class="tm-speeds" role="radiogroup" aria-label="Playback speed">
${SPEEDS.map((s) => `<button type="button" role="radio" data-speed="${s}" aria-checked="${s === 60}">${s}×</button>`).join('')}
</div>
<button class="tm-clip icon-btn" type="button" aria-pressed="false" aria-label="Select a range to clip">${icon('scissors')}</button>
<button class="rb-live tm-now is-live" type="button" aria-label="Return to now"><span class="live-dot"></span><span>NOW</span></button>
</div>
<div class="rb-track tm-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
<canvas></canvas>
<div class="rb-head" aria-hidden="true"></div>
<div class="rb-head tm-head" aria-hidden="true"><i></i></div>
<div class="tm-callout tele" hidden></div>
</div>
<div class="tm-cliprow" hidden>
<span class="tm-cliplen tele"></span>
<button class="pill-btn plain tm-clipcancel" type="button">Cancel</button>
<button class="pill-btn primary tm-clipgo" type="button">${icon('film')}Clip this Moment</button>
</div>
<div class="rb-read" aria-live="off"><b class="num">Now</b><span class="num"></span></div>
</div>`);
parent.appendChild(el);
const track = el.querySelector('.rb-track');
const track = el.querySelector('.tm-track');
const canvas = el.querySelector('canvas');
const ctx = canvas.getContext('2d');
const liveBtn = el.querySelector('.rb-live');
const readB = el.querySelector('.rb-read b');
const readS = el.querySelector('.rb-read span');
const playBtn = el.querySelector('.tm-play');
const nowBtn = el.querySelector('.tm-now');
const clipBtn = el.querySelector('.tm-clip');
const readD = el.querySelector('.tm-delta');
const readA = el.querySelector('.tm-abs');
const callout = el.querySelector('.tm-callout');
const clipRow = el.querySelector('.tm-cliprow');
const clipLen = el.querySelector('.tm-cliplen');
let win = getWindow();
let custom = null; // { start, end, label, pips, format }
let rec = null; // { start, end }
let custom = null;
let pips = [];
let t = Date.now(); // cursor time (ms)
let t = Date.now();
let live = true;
let pxPerHour = 7;
let pxPerHour = 80;
let W = 0;
let Hh = 0;
let dpr = 1;
let raf = 0;
let vel = 0; // ms per frame
let lastHour = Math.floor(t / HOUR);
let lastTick = 0;
let vel = 0;
let emitTimer = 0;
let speed = 60;
let playing = false;
let clipMode = false;
let range = null; // { a, b }
let hotPip = null;
let calloutTimer = 0;
const span = () => (custom ? custom.end - custom.start : win.end - win.start);
const start = () => (custom ? custom.start : win.start.getTime());
const start = () =>
custom
? custom.start
: Math.min(win.start.getTime(), rec?.start ?? Infinity);
const end = () => (custom ? custom.end : win.end.getTime());
const nowMs = () => (custom ? (custom.now ?? custom.start) : Date.now());
const x = (ms) => W / 2 + ((ms - t) / HOUR) * pxPerHour;
const tAt = (px) => t + ((px - W / 2) / pxPerHour) * HOUR;
function refreshHistory() {
try {
const hst = getHistory?.();
if (hst && Number.isFinite(+hst.start))
rec = { start: +hst.start, end: +hst.end || Date.now() };
} catch {
rec = null;
}
}
function resize() {
const r = track.getBoundingClientRect();
@@ -53,107 +126,149 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
Hh = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(Hh * dpr);
// Custom windows (minutes long) scale to fit ~1.6 track widths.
pxPerHour = custom ? (W * 1.6) / (span() / HOUR) : 7;
if (custom)
pxPerHour = (W * 0.9) / (Math.max(MIN, custom.end - custom.start) / HOUR);
else if (!resize.done) {
// Six recorded hours span about 1.3 track widths.
pxPerHour = clamp(W / 4.6, 40, 240);
resize.done = true;
}
draw();
}
function x(ms) {
return W / 2 + ((ms - t) / HOUR) * pxPerHour;
function stepFor() {
for (const s of STEPS) if ((s / HOUR) * pxPerHour >= 11) return s;
return STEPS[STEPS.length - 1];
}
function draw() {
if (!W) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, Hh);
const s = start();
const e = end();
const n = nowMs();
const mid = Hh / 2 + 4;
// Window body
ctx.fillStyle = 'rgba(255,255,255,0.04)';
const xs = Math.max(0, x(s));
const xe = Math.min(W, x(e));
ctx.fillRect(xs, mid - 9, xe - xs, 18);
// Forecast region
const base = Math.round(Hh * 0.6);
const xs = clamp(x(s), -2, W + 2);
const xe = clamp(x(e), -2, W + 2);
const xn = clamp(x(n), -2, W + 2);
// Body of the window
ctx.fillStyle = 'rgba(255,255,255,0.035)';
ctx.fillRect(xs, base - 12, xe - xs, 24);
if (!custom) {
const xn = x(n);
// Recorded range: where the app remembers every contact.
if (rec) {
const a = clamp(x(rec.start), -2, W + 2);
const g = ctx.createLinearGradient(0, base - 12, 0, base + 12);
g.addColorStop(0, 'rgba(163,216,255,0.22)');
g.addColorStop(1, 'rgba(163,216,255,0.06)');
ctx.fillStyle = g;
ctx.fillRect(a, base - 12, xn - a, 24);
ctx.fillStyle = 'rgba(163,216,255,0.85)';
ctx.fillRect(a, base - 12, xn - a, 1.2);
if (a > 4 && a < W - 60) {
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
ctx.fillStyle = 'rgba(163,216,255,0.8)';
ctx.textAlign = 'left';
ctx.fillText('RECORDED', a + 4, base + 9);
}
}
// Forecast
if (xn < W) {
const g = ctx.createLinearGradient(xn, 0, Math.min(W, xe), 0);
g.addColorStop(0, 'rgba(138,180,248,0.16)');
g.addColorStop(1, 'rgba(138,180,248,0.03)');
g.addColorStop(0, 'rgba(179,157,255,0.2)');
g.addColorStop(1, 'rgba(179,157,255,0.02)');
ctx.fillStyle = g;
ctx.fillRect(
Math.max(0, xn),
mid - 9,
Math.min(W, xe) - Math.max(0, xn),
18,
);
ctx.fillRect(xn, base - 12, Math.min(W, xe) - xn, 24);
if (xn > -40 && xn < W - 70) {
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
ctx.fillStyle = 'rgba(199,180,255,0.75)';
ctx.textAlign = 'left';
ctx.fillText('FORECAST', xn + 6, base + 9);
}
}
}
// Ticks
const stepMin = custom ? (span() / HOUR < 3 ? 5 : 15) : 60;
const step = stepMin * 60e3;
const first = Math.ceil((t - (W / 2 / pxPerHour) * HOUR) / step) * step;
const last = t + (W / 2 / pxPerHour) * HOUR;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
for (
let m = Math.max(first, s - (s % step));
m <= Math.min(last, e);
m += step
) {
const px = x(m);
const d = new Date(m);
const major = custom
? (m - s) % (stepMin * 4 * 60e3) === 0
: d.getHours() === 0;
const mid6 = !custom && d.getHours() % 6 === 0;
const future = !custom && m > n;
const len = major ? 14 : mid6 ? 9 : 5;
ctx.strokeStyle = future
? 'rgba(170,200,255,0.42)'
: major
? 'rgba(255,255,255,0.75)'
: 'rgba(255,255,255,0.32)';
ctx.lineWidth = major ? 1.3 : 1;
if (future) ctx.setLineDash([1.5, 2]);
ctx.beginPath();
ctx.moveTo(px, mid - len / 2);
ctx.lineTo(px, mid + len / 2);
ctx.stroke();
ctx.setLineDash([]);
if (major || (mid6 && pxPerHour > 4)) {
ctx.font = `${major ? 650 : 500} 9.5px ui-rounded, system-ui, sans-serif`;
ctx.fillStyle = major
? 'rgba(255,255,255,0.8)'
: 'rgba(255,255,255,0.38)';
const label = custom
? custom.format(m)
: major
? d
.toLocaleDateString(undefined, { weekday: 'short' })
.toUpperCase()
: `${String(d.getHours()).padStart(2, '0')}`;
ctx.fillText(label, px, mid - 12);
}
}
// NOW marker
if (!custom) {
const xn = x(n);
if (xn > -10 && xn < W + 10) {
ctx.fillStyle = '#FFFFFF';
// Clip range
if (range) {
const a = x(Math.min(range.a, range.b));
const b = x(Math.max(range.a, range.b));
ctx.fillStyle = 'rgba(235,199,119,0.24)';
ctx.fillRect(a, 2, b - a, Hh - 4);
ctx.fillStyle = '#ebc777';
for (const px of [a, b]) {
ctx.beginPath();
ctx.arc(xn, mid + 13, 2.4, 0, Math.PI * 2);
ctx.roundRect(px - 2.5, 3, 5, Hh - 6, 2.5);
ctx.fill();
}
}
// Event pips
// Ticks
const step = stepFor();
const major =
step *
(step < HOUR ? (step <= 5 * MIN ? 6 : 4) : step < 24 * HOUR ? 4 : 1);
const t0 = Math.ceil(tAt(-10) / step) * step;
const t1 = tAt(W + 10);
ctx.textAlign = 'center';
for (let m = t0; m <= t1; m += step) {
if (m < s - step || m > e + step) continue;
const px = x(m);
const isMajor = m % major === 0;
const day = m % (24 * HOUR) === 0;
const future = !custom && m > n;
const len = day ? 16 : isMajor ? 11 : 5;
ctx.strokeStyle = future
? 'rgba(199,180,255,0.45)'
: isMajor
? 'rgba(255,255,255,0.7)'
: 'rgba(255,255,255,0.28)';
ctx.lineWidth = isMajor ? 1.2 : 1;
if (future) ctx.setLineDash([1.5, 2]);
ctx.beginPath();
ctx.moveTo(px, base - len / 2);
ctx.lineTo(px, base + len / 2);
ctx.stroke();
ctx.setLineDash([]);
if (isMajor) {
const d = new Date(m);
ctx.font = `${day ? 700 : 550} 9.5px "JetBrains Mono", ui-monospace, monospace`;
ctx.fillStyle = day
? 'rgba(255,255,255,0.9)'
: 'rgba(255,255,255,0.55)';
const label = custom
? custom.format(m)
: day
? d
.toLocaleDateString('en-GB', {
weekday: 'short',
timeZone: 'UTC',
})
.toUpperCase()
: `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
ctx.fillText(label, px, base - 15);
}
}
// NOW marker
if (!custom && x(n) > -10 && x(n) < W + 10) {
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(x(n), base + 15, 2.6, 0, Math.PI * 2);
ctx.fill();
}
// Event pips (by altitude band, sized by severity)
for (const p of pips) {
const px = x(p.at);
if (px < -6 || px > W + 6) continue;
if (px < -8 || px > W + 8) continue;
const r = 2.8 + (p.severity || 0) * 0.9;
if (p === hotPip) {
ctx.fillStyle = `${p.color || '#fff'}44`;
ctx.beginPath();
ctx.arc(px, base, r + 6, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = p.color || '#FFD166';
ctx.beginPath();
ctx.arc(px, mid, 3.4, 0, Math.PI * 2);
ctx.arc(px, base, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(5,7,11,0.9)';
ctx.lineWidth = 1.2;
@@ -170,61 +285,39 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
ctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), Hh);
ctx.restore();
};
fade(0, 26);
fade(W, W - 26);
fade(0, 22);
fade(W, W - 22);
updateReadout();
}
function fmtRel(ms) {
const d = ms / 60e3;
const sign = d < 0 ? '−' : '+';
const a = Math.abs(d);
if (a < 60) return `${sign}${Math.round(a)} min`;
const hrs = Math.floor(a / 60);
const mins = Math.round(a % 60);
return `${sign}${hrs} h${mins ? ` ${mins}` : ''}`;
}
function updateReadout() {
if (custom) {
readB.textContent = custom.format(t);
readS.textContent = custom.label || '';
readD.textContent = custom.format(t);
readA.textContent = custom.label || '';
track.setAttribute(
'aria-valuetext',
`${custom.format(t)} ${custom.label || ''}`,
);
return;
}
if (live) {
readB.textContent = 'Now';
readS.textContent = new Date().toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
} else {
readB.textContent = fmtRel(t - Date.now());
readS.textContent = new Date(t).toLocaleString(undefined, {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
const abs = utc(new Date(live ? Date.now() : t));
readD.textContent = live ? 'LIVE' : fmtDelta(t - Date.now());
readA.textContent = abs;
el.dataset.future = String(!live && t > Date.now());
track.setAttribute(
'aria-valuetext',
live ? 'Live' : `${readB.textContent}, ${readS.textContent}`,
live ? `Live, ${abs}` : `${readD.textContent}, ${abs}`,
);
}
function setLive(v) {
live = v;
el.classList.toggle('is-live', v);
liveBtn.classList.toggle('is-live', v);
liveBtn.querySelector('span:last-child').textContent = v
? 'LIVE'
: custom
? 'RESET'
: 'LIVE';
liveBtn.setAttribute('aria-pressed', String(v));
nowBtn.classList.toggle('is-live', v);
nowBtn.querySelector('span:last-child').textContent = custom
? 'START'
: 'NOW';
nowBtn.setAttribute('aria-pressed', String(v));
}
function emit(immediate = false) {
@@ -235,84 +328,208 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
if (immediate) {
clearTimeout(emitTimer);
fire();
} else if (!emitTimer) emitTimer = setTimeout(fire, 90);
} else if (!emitTimer) emitTimer = setTimeout(fire, 60);
}
function setCursor(ms, { fromUser = false, quant = 0 } = {}) {
let lastDetent = 0;
function setCursor(ms, { fromUser = false } = {}) {
const s = start();
const e = end();
let v = ms;
if (v < s) v = s - (1 - 1 / (((s - v) / HOUR) * 0.2 + 1)) * HOUR * 2;
if (v > e) v = e + (1 - 1 / (((v - e) / HOUR) * 0.2 + 1)) * HOUR * 2;
if (quant) v = Math.round(v / quant) * quant;
if (v < s) v = s - (1 - 1 / (((s - v) / HOUR) * 0.4 + 1)) * HOUR * 0.6;
if (v > e) v = e + (1 - 1 / (((v - e) / HOUR) * 0.4 + 1)) * HOUR * 0.6;
t = v;
const hr = Math.floor(t / HOUR);
if (fromUser && hr !== lastHour) {
const now = performance.now();
if (now - lastTick > 55) {
haptic('tick');
lastTick = now;
}
lastHour = hr;
}
if (fromUser) {
const near = !custom && Math.abs(t - Date.now()) < 4 * 60e3;
setLive(near);
// Detents: a tick as the playhead crosses a pip.
const near = pips.find((p) => Math.abs(x(p.at) - W / 2) < 3);
if (near && near !== hotPip) {
const pn = performance.now();
if (pn - lastDetent > 70) haptic('tick');
lastDetent = pn;
}
hotPip = near || null;
const isNear =
!custom && Math.abs(t - Date.now()) < (6 / pxPerHour) * HOUR;
setLive(isNear);
if (playing) stopPlay();
emit();
}
draw();
}
/* ---------- drag with momentum ---------- */
/* ---------- gestures ---------- */
let drag = null;
let press = 0;
track.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
track.setPointerCapture(e.pointerId);
cancelAnimationFrame(raf);
const r = track.getBoundingClientRect();
const px = e.clientX - r.left;
drag = {
x: e.clientX,
px,
t0: t,
moved: false,
samples: [{ x: e.clientX, ts: performance.now() }],
handle: null,
};
if (range) {
const a = x(range.a);
const b = x(range.b);
if (Math.abs(px - a) < 16) drag.handle = 'a';
else if (Math.abs(px - b) < 16) drag.handle = 'b';
}
if (clipMode && !drag.handle) {
range = { a: tAt(px), b: tAt(px) };
drag.handle = 'b';
}
clearTimeout(press);
if (!clipMode && !drag.handle)
press = setTimeout(() => {
if (!drag || drag.moved) return;
setClipMode(true);
haptic('medium');
range = { a: tAt(drag.px), b: tAt(drag.px) };
drag.handle = 'b';
draw();
}, 420);
el.classList.add('scrubbing');
});
track.addEventListener('pointermove', (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
if (Math.abs(dx) > 3) drag.moved = true;
if (drag.handle) {
const r = track.getBoundingClientRect();
range[drag.handle] = tAt(e.clientX - r.left);
showClipRow();
draw();
return;
}
drag.samples.push({ x: e.clientX, ts: performance.now() });
if (drag.samples.length > 6) drag.samples.shift();
const a = drag.samples[0];
const b = drag.samples[drag.samples.length - 1];
const speed = b.ts > a.ts ? Math.abs((b.x - a.x) / (b.ts - a.ts)) : 0; // px/ms
const quant = custom ? 60e3 : speed > 0.6 ? HOUR : 5 * 60e3;
setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true, quant });
setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true });
});
const release = () => {
const release = (e) => {
clearTimeout(press);
if (!drag) return;
el.classList.remove('scrubbing');
if (drag.handle) {
drag = null;
if (range && Math.abs(range.b - range.a) < MIN) {
range = null;
showClipRow();
}
draw();
return;
}
if (!drag.moved) {
// A tap: on a pip jumps to it; elsewhere centres that instant.
const r = track.getBoundingClientRect();
const px = (e?.clientX ?? drag.x) - r.left;
drag = null;
const pip = pipAt(px);
if (pip) return jumpToPip(pip);
animateTo(tAt(px), () => settleLive());
return;
}
const a = drag.samples[0];
const b = drag.samples[drag.samples.length - 1];
const pxPerMs = b.ts > a.ts ? (b.x - a.x) / (b.ts - a.ts) : 0;
drag = null;
vel = (-(pxPerMs * 16.7) / pxPerHour) * HOUR;
const fast = Math.abs(pxPerMs) > 0.6;
if (reducedMotion() || Math.abs(pxPerMs) < 0.05) return settle(fast);
if (reducedMotion() || Math.abs(pxPerMs) < 0.08) return settle();
const stepFn = () => {
vel *= 0.93;
vel *= 0.94;
setCursor(t + vel, { fromUser: true });
if (Math.abs(vel) > 60e3 && t > start() && t < end())
if (Math.abs(vel) > (0.4 / pxPerHour) * HOUR && t > start() && t < end())
raf = requestAnimationFrame(stepFn);
else settle(fast);
else settle();
};
raf = requestAnimationFrame(stepFn);
};
track.addEventListener('pointerup', release);
track.addEventListener('pointercancel', release);
function settle(fast) {
const s = start();
const e = end();
const target = clamp(t, s, e);
const q = custom ? 60e3 : fast ? HOUR : 5 * 60e3;
// Trackpad: two-finger swipe scrubs, pinch (ctrl+wheel) zooms the scale.
track.addEventListener(
'wheel',
(e) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
pxPerHour = clamp(pxPerHour * Math.exp(-e.deltaY * 0.01), 6, 3000);
draw();
return;
}
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
setCursor(t + (d / pxPerHour) * HOUR, { fromUser: true });
clearTimeout(track._wt);
track._wt = setTimeout(settle, 160);
},
{ passive: false },
);
function pipAt(px) {
let best = null;
let bd = 12;
for (const p of pips) {
const d = Math.abs(x(p.at) - px);
if (d < bd) {
bd = d;
best = p;
}
}
return best;
}
function showCallout(p) {
if (!p?.label) return;
callout.textContent = p.label;
callout.style.setProperty('--c', p.color || '#fff');
callout.hidden = false;
clearTimeout(calloutTimer);
calloutTimer = setTimeout(() => (callout.hidden = true), 2600);
}
function jumpToPip(p) {
haptic('light');
hotPip = p;
showCallout(p);
setLive(false);
animateTo(p.at, () => {
emit(true);
onPip?.(p);
});
}
function settleLive() {
const isNear = !custom && Math.abs(t - Date.now()) < (6 / pxPerHour) * HOUR;
if (isNear) {
setLive(true);
animateTo(Date.now(), () => emit(true));
} else emit(true);
}
function settle() {
const target = clamp(t, start(), end());
// Detent: settle onto a pip within 10 px of the playhead.
const near = pips
.map((p) => ({ p, d: Math.abs(x(p.at) - x(target)) }))
.filter((o) => o.d < 10)
.sort((a, b) => a.d - b.d)[0];
if (near) {
haptic('light');
hotPip = near.p;
showCallout(near.p);
return animateTo(near.p.at, () => emit(true));
}
if (!custom && Math.abs(target - Date.now()) < (8 / pxPerHour) * HOUR) {
setLive(true);
haptic('tick');
return animateTo(Date.now(), () => emit(true));
}
const q = custom ? 1000 : stepFor() >= HOUR ? 5 * MIN : MIN / 4;
animateTo(Math.round(target / q) * q, () => emit(true));
}
@@ -320,7 +537,7 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
cancelAnimationFrame(raf);
const from = t;
const t0 = performance.now();
const dur = reducedMotion() ? 1 : 420;
const dur = reducedMotion() ? 1 : 460;
const step = () => {
const k = clamp((performance.now() - t0) / dur, 0, 1);
const e = 1 - (1 - k) ** 3;
@@ -334,6 +551,7 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
function goLive() {
haptic('tick');
stopPlay();
if (custom) {
animateTo(custom.start, () => emit(true));
return;
@@ -341,38 +559,153 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
setLive(true);
animateTo(Date.now(), () => emit(true));
}
liveBtn.addEventListener('click', goLive);
nowBtn.addEventListener('click', goLive);
/* ---------- playback ---------- */
function setPlaying(v) {
playing = v;
el.classList.toggle('is-playing', v);
playBtn.innerHTML = icon(v ? 'pause' : 'play');
playBtn.setAttribute('aria-label', v ? 'Pause' : 'Play');
}
function stopPlay() {
if (!playing) return;
playback?.stop();
setPlaying(false);
}
function play(s = speed) {
if (custom || !playback) return;
speed = s;
markSpeed();
if (live) {
// From live, replay the last hour of the recording.
const from = Math.max(rec?.start ?? -Infinity, Date.now() - HOUR);
t = from;
setLive(false);
onTime(new Date(t));
}
setPlaying(true);
playback.play({ speed });
haptic('light');
}
playBtn.addEventListener('click', () => (playing ? stopPlay() : play()));
function markSpeed() {
el.querySelectorAll('[data-speed]').forEach((b) =>
b.setAttribute('aria-checked', String(Number(b.dataset.speed) === speed)),
);
}
el.querySelector('.tm-speeds').addEventListener('click', (e) => {
const b = e.target.closest('[data-speed]');
if (!b) return;
haptic('tick');
speed = Number(b.dataset.speed);
markSpeed();
if (playing) playback?.play({ speed });
});
/** The engine reports its clock while playing. */
function onPlayback(st) {
if (!st) return;
if (st.playing !== playing) setPlaying(!!st.playing);
if (Number.isFinite(st.t) && st.playing) {
t = st.t;
setLive(false);
draw();
} else if (
!st.playing &&
(st.t == null || Math.abs(st.t - Date.now()) < 3000)
) {
setLive(true);
t = Date.now();
draw();
}
}
/* ---------- clip ---------- */
function setClipMode(v) {
clipMode = v;
clipBtn.setAttribute('aria-pressed', String(v));
el.classList.toggle('is-clipping', v);
if (!v) range = null;
showClipRow();
draw();
}
function showClipRow() {
const ok = !!range && Math.abs(range.b - range.a) >= MIN;
clipRow.hidden = !clipMode;
el.querySelector('.tm-clipgo').disabled = !ok;
clipLen.innerHTML = ok
? `${esc(utc(new Date(Math.min(range.a, range.b)), { seconds: false }).slice(0, 5))}–${esc(utc(new Date(Math.max(range.a, range.b)), { seconds: false }))}<br/><b>${esc(fmtDelta(Math.abs(range.b - range.a)).slice(1))}</b>`
: 'Drag across the track to select a range';
}
clipBtn.addEventListener('click', () => {
haptic('tick');
setClipMode(!clipMode);
});
el.querySelector('.tm-clipcancel').addEventListener('click', () =>
setClipMode(false),
);
el.querySelector('.tm-clipgo').addEventListener('click', () => {
if (!range) return;
const r = {
start: Math.min(range.a, range.b),
end: Math.max(range.a, range.b),
};
haptic('success');
setClipMode(false);
onClip?.(r);
});
/* ---------- keyboard ---------- */
track.addEventListener('keydown', (e) => {
const stepMs = custom ? 60e3 : e.shiftKey ? 6 * HOUR : HOUR;
const stepMs = custom ? 10e3 : e.shiftKey ? HOUR : 5 * MIN;
if (e.key === 'ArrowRight') setCursor(t + stepMs, { fromUser: true });
else if (e.key === 'ArrowLeft') setCursor(t - stepMs, { fromUser: true });
else if (e.key === 'Home' || e.key === 'End') goLive();
else if (e.key === 'End') goLive();
else if (e.key === ' ' || e.key === 'k') playing ? stopPlay() : play();
else return;
e.preventDefault();
});
// Live: keep the cursor glued to now while the planet turns.
// Live: keep the playhead glued to now while the planet turns.
setInterval(() => {
if (live && !custom && !drag) {
t = Date.now();
win = getWindow();
if (!custom && !drag) {
refreshHistory();
if (live) {
t = Date.now();
win = getWindow();
}
draw();
}
}, 15000);
}, 1000);
new ResizeObserver(resize).observe(track);
refreshHistory();
resize();
setLive(true);
return {
el,
/** [{ at, label, color, severity?, ev? }] */
setPips(list) {
pips = list.filter((p) => Number.isFinite(p.at));
draw();
},
/** Timeline events from globe.timeline(), coloured by band. */
setEvents(evts) {
this.setPips(
evts.map((ev) => ({
at: ev.t,
label: ev.title,
color: BAND_COLOR[ev.band] || '#fff',
severity: ev.severity || 0,
ev,
})),
);
},
/** Use a custom window (e.g. a reconstruction's schematic clock). */
setWindow(w) {
stopPlay();
custom = w
? {
...w,
@@ -388,24 +721,40 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
} else {
win = getWindow();
t = Date.now();
resize.done = false;
setLive(true);
}
resize();
},
/** Animate to a moment and tell the globe (post anchors). */
scrubTo(ms) {
const lo = start();
const hi = end();
const target = Math.min(hi, Math.max(lo, ms));
stopPlay();
setLive(false);
animateTo(target, () => emit());
animateTo(clamp(ms, start(), end()), () => emit(true));
},
/** Programmatic scrub (narrative playback). */
seek(ms) {
cancelAnimationFrame(raf);
t = ms;
draw();
},
/** Deterministic setup (director): jump, then play at a speed. */
jump(ms, { play: p = false, speed: s = speed } = {}) {
stopPlay();
cancelAnimationFrame(raf);
t = ms;
setLive(false);
onTime(new Date(t));
draw();
if (p) play(s);
},
selectRange(a, b) {
setClipMode(true);
range = { a, b };
showClipRow();
draw();
},
play,
stop: stopPlay,
onPlayback,
goLive,
get time() {
return live && !custom ? null : new Date(t);
@@ -413,8 +762,22 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
get isLive() {
return live && !custom;
},
get playing() {
return playing;
},
get debug() {
return { t, pxPerHour, W, Hh, live, custom: !!custom };
return {
t,
pxPerHour,
W,
Hh,
live,
custom: !!custom,
playing,
speed,
range,
rec,
};
},
};
}
+485
View File
@@ -0,0 +1,485 @@
/**
* 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 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 = `
<section class="brief" aria-label="Your brief">
<div class="bf-since glass-card" aria-label="Since you last looked">
<header class="bf-h"><h3>Since you last looked</h3><span class="tele" data-since></span></header>
<div class="bf-rows" data-changes><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div></div>
</div>
<button class="bf-moment" type="button" aria-label="Play today's Moment">
<img class="bf-mimg" alt="" decoding="async"/>
<canvas class="bf-mcanvas" aria-hidden="true"></canvas>
<span class="bf-mshade"></span>
<span class="bf-mtop tele"><span class="bf-mk">TODAY’S MOMENT</span><span class="bf-mtag"></span></span>
<span class="bf-mbody"><b class="bf-mtitle">Loading today’s Moment…</b><small class="bf-msub"></small></span>
<span class="bf-mplay">${icon('play')}</span>
<span class="bf-mclock tele"></span>
</button>
<button class="bf-sky glass-card" type="button" aria-label="Your sky now">
<span class="bf-dome" data-dome></span>
<span class="bf-skytxt"><span class="bf-skyk tele"><span class="live-dot"></span>YOUR SKY NOW</span><b class="bf-skyh" data-skyh>Looking up…</b><small data-skyn></small></span>
${icon('chevronRight', { cls: 'chev' })}
</button>
<div class="bf-tonight glass-card"><header class="bf-h"><h3>Overhead tonight</h3><span class="tele" data-tonight-where></span></header><div data-tonight><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div></div></div>
<div class="bf-watch glass-card"><header class="bf-h"><h3>Your watch</h3><button class="link-btn" type="button" data-manage>Manage</button></header><div data-watch></div></div>
</section>`;
const mBtn = $('.bf-moment', slot);
const mCanvas = $('.bf-mcanvas', slot);
const mctx = mCanvas.getContext('2d');
/* ---------- 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) =>
`<button class="bf-row" type="button" data-ch="${i}" data-band="${c.band}"><i class="bd"></i><span class="bf-main"><b>${esc(c.title)}</b><small>${esc(c.sub || '')}</small></span><span class="bf-when tele">${esc(c.t > Date.now() ? 'SOON' : ago(c.t).replace(' ago', '').toUpperCase())}</span></button>`,
)
.join('') ||
`<div class="bf-calm">${icon('checkmark')}<span><b>Nothing unusual</b> since you last looked.</span></div>`;
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();
const w = Math.max(320, Math.round(r.width || 400));
const hgt = Math.max(200, Math.round(r.height || 260));
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))),
);
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
? '<span class="tag-ill">Illustrative</span>'
: '<span class="tag-real">Recorded</span>';
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 np = sky?.iss?.nextPass;
if (sky?.iss?.visibleNow)
rows.push(
`<button class="bf-row" type="button" data-sky data-band="orbit"><i class="bd"></i><span class="bf-main"><b>ISS is overhead now</b><small>${Math.round(sky.iss.elevationDeg)}° up, ${esc(cardinal(sky.iss.azimuthDeg))}</small></span></button>`,
);
else if (np)
rows.push(
`<button class="bf-row" type="button" data-sky data-band="orbit"><i class="bd"></i><span class="bf-main"><b>International Space Station</b><small>${np.maxElevationDeg != null ? `Rises to ${np.maxElevationDeg}° · ` : ''}visible ${Math.max(1, Math.round(((np.end || np.start + 360e3) - np.start) / 60e3))} min</small></span><span class="bf-when tele">${esc(hm(np.start).toUpperCase())}</span></button>`,
);
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(
`<button class="bf-row" type="button" data-sit="${esc(s.id)}" data-band="orbit"><i class="bd"></i><span class="bf-main"><b>${esc(s.title)}</b><small>${esc(s.place || '')}</small></span><span class="bf-when tele">${esc(tMinus(at))}</span></button>`,
);
}
$('[data-tonight]', slot).innerHTML =
rows.join('') ||
'<div class="bf-calm"><span>Clear skies, nothing scheduled.</span></div>';
}
function renderSky() {
if (!sky) return;
$('[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
? `<button class="bf-row" type="button" data-hit="0" data-band="${esc(bandOf(hits[0].x))}"><span class="bf-ic">${icon('home')}</span><span class="bf-main"><b>${esc(home.name)} · ${hits.length} on watch</b><small>${esc(hits[0].x.title)}</small></span><span class="bf-when tele">${esc(hits[0].x.updatedAt ? ago(hits[0].x.updatedAt).replace(' ago', '').toUpperCase() : '')}</span></button>`
: `<div class="bf-row"><span class="bf-ic">${icon('home')}</span><span class="bf-main"><b>${esc(home.name)} · quiet</b><small>Nothing unusual within ${Math.max(...rules.map((r) => r.within || 0), 200).toLocaleString()} km</small></span><span class="bf-ok">${icon('checkmark')}</span></div>`,
);
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(
`<button class="bf-row" type="button" data-thing="${esc(t.id)}" data-band="${t.band}"><span class="bf-ic">${icon(t.icon)}</span><span class="bf-main"><b>${esc(t.title)}</b><small>${esc(t.status)}</small></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
);
if (!things.length)
rows.push(
`<button class="bf-row bf-add" type="button" data-manage>${icon('plus')}<span>Follow a place, a tail number, a ship or the ISS</span></button>`,
);
$('[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();
sky = await globe
.skyNow({ lon: home.lon, lat: home.lat })
.catch(() => null);
renderSky();
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,
};
}
+544
View File
@@ -0,0 +1,544 @@
/**
* 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('<div class="banners" aria-live="polite"></div>');
document.getElementById('app').appendChild(stack);
function banner({
title,
body,
band = 'sky',
iconName = 'bell',
onTap,
sticky = false,
}) {
const el =
h(`<button class="banner glass-strong" type="button" data-band="${esc(band)}">
<span class="bn-app">${icon('eyes')}</span>
<span class="bn-main"><span class="bn-top"><b>GOD'S EYE</b><span>now</span></span>
<span class="bn-title">${icon(iconName)}${esc(title)}</span><span class="bn-body">${esc(body)}</span></span>
</button>`);
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 `<button class="rule-chip" type="button" data-rule="${esc(rid)}" data-chip="${kind}">${esc(label)}${icon('chevronDown')}</button>`;
}
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 `<div class="rule" data-band="${w.band}"><i class="bd"></i><p>${sentence}</p>
<span class="rule-state tele">${r.on ? (n ? `${n} NOW` : 'QUIET') : 'OFF'}</span>
<button class="switch" type="button" role="switch" aria-checked="${!!r.on}" aria-label="Rule on" data-toggle="${esc(r.id)}"></button></div>`;
}
async function render() {
sheet.head.innerHTML = `<h2 class="sheet-title">Watch</h2><div class="sheet-sub">Places and things you follow, and when to tap you on the shoulder</div>`;
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 = `
<div class="section-h">Places</div>
<div class="group">${state.places
.map(
(p) =>
`<div class="row"><span class="row-icon">${icon(p.id === 'home' ? 'home' : 'pin')}</span><span class="row-main"><span class="row-title">${esc(p.name)}</span><span class="row-sub">${esc(p.sub || `${p.lat.toFixed(3)}, ${p.lon.toFixed(3)}`)}</span></span>${p.id === 'home' ? `<button class="pill-btn plain" type="button" data-home="locate">${icon('location')}Use my location</button>` : `<button class="icon-btn" type="button" data-rm-place="${esc(p.id)}" aria-label="Remove">${icon('minus')}</button>`}</div>`,
)
.join('')}
<button class="row row-add" type="button" data-add-place>${icon('plus')}<span>Watch the place in view on the globe</span></button></div>
<div class="section-h">Things</div>
<div class="group">${
things.filter(Boolean).length
? things
.filter(Boolean)
.map(
(t) =>
`<button class="row" type="button" data-thing="${esc(t.id)}"><span class="row-icon" data-band="${t.band}">${icon(t.icon)}</span><span class="row-main"><span class="row-title">${esc(t.title)}</span><span class="row-sub">${esc(t.status)}</span></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')
: '<div class="row"><span class="row-sub">Follow an aircraft, ship, satellite or situation from its dossier.</span></div>'
}</div>
<div class="section-h">Alert rules</div>
<div class="rules">${state.rules.map(ruleHtml).join('')}</div>
<button class="pill-btn tinted new-rule" type="button" data-new-rule>${icon('plus')}New rule</button>
<p class="footnote">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.</p>`;
}
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,
};
}
+22 -2
View File
@@ -248,7 +248,8 @@ export function createWorld({
<button role="tab" type="button" data-tab="brief" aria-selected="${tab === 'brief'}">Brief</button>
<button role="tab" type="button" data-tab="room" aria-selected="${tab === 'room'}">Room<span class="bt-n tele" data-room-n></span></button>
<span class="bt-here tele" data-room-here></span>
</div>`;
</div>
<div class="brief-asof tele" data-asof hidden></div>`;
}
function briefHtml(s) {
@@ -673,7 +674,7 @@ export function createWorld({
originCard && document.contains(originCard) ? originCard : getCard(s.id);
const T = targetRect();
const twoPane = isTwoPane();
if (!card || twoPane || app.dataset.tab !== 'now') {
if (!card || twoPane || app.dataset.tab !== 'today') {
app.classList.remove('is-world');
card?.classList.remove('hero-source');
current = null;
@@ -738,10 +739,29 @@ export function createWorld({
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
/** Everything follows the clock: the open sheet says what time it shows. */
let clockT = null;
function setClock(d) {
clockT = d ? +d : null;
const el = $('[data-asof]', sheet.head);
if (!el) return;
el.hidden = clockT == null;
if (clockT != null) {
const dm = Math.round((clockT - Date.now()) / 60e3);
const rel =
Math.abs(dm) < 60
? `${dm < 0 ? '−' : '+'}${Math.abs(dm)} MIN`
: `${dm < 0 ? '−' : '+'}${Math.floor(Math.abs(dm) / 60)} H ${Math.abs(dm) % 60} MIN`;
el.innerHTML = `${icon('clock')}SHOWING ${rel} · ${new Date(clockT).toISOString().slice(11, 19)}Z`;
}
}
ev.on('open', () => setTimeout(() => setClock(clockT), 0));
return {
open,
close,
sheet,
setClock,
get current() {
return current;
},