mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
83 files changed
+50005
-416
No files matched your search
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"> </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
@@ -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,
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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}/>`,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user