wip(mobile): checkpoint polish round in progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-23 09:59:48 +00:00
1 parent 4e1e838771
commit 90b962745e
29 files changed
+9600 -205

No files matched your search

+1 -1
View File
@@ -247,7 +247,7 @@ export function createDossier({
<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>` : ''}
${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>` : d.registryMissing ? `<div class="ds-privacy ds-registry">${icon('info')}<span><b>No public registry record found</b> for this airframe.${d.identity?.operator ? ` Operator from the callsign’s airline prefix.` : ''}</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>` : ''}
${
+17 -3
View File
@@ -155,14 +155,28 @@ export function createFeed(
const hd = $('[data-utc]', view);
if (hd) hd.textContent = u;
const dt = $('[data-date]', view);
if (dt)
dt.textContent = now
if (dt) {
// The kicker greets the hour: what the planet is doing for you now.
const hr = now.getHours();
const greet =
hr < 5
? 'Up late'
: hr < 12
? 'Good morning'
: hr < 17
? 'Good afternoon'
: hr < 22
? 'Good evening'
: 'Tonight';
const txt = `${now
.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
})
.toUpperCase();
.toUpperCase()} · ${greet.toUpperCase()}`;
if (dt.textContent !== txt) dt.textContent = txt;
}
const lt = $('[data-localtime]', view);
if (lt) {
const txt = now.toLocaleTimeString('en-US', {
+5
View File
@@ -198,6 +198,11 @@ function guard(engine, kind) {
},
cachedThumbnail: (s) => call('cachedThumbnail', null, s),
history: () => call('history', null),
loadHistory: () => asyncCall('loadHistory', null),
historyNear: (ms, lon, lat, km) => {
const v = call('historyNear', [], ms, lon, lat, km);
return Array.isArray(v) ? v : [];
},
trackOf: (id) => asyncCall('trackOf', [], id),
playback: {
play: (o) => {
+5 -2
View File
@@ -956,6 +956,8 @@ export function mountChatOverlay(core, el, ctx = {}) {
let seqN = 0;
let driven = false; // a player calls push(): ignore the engine's own clock
const full = ctx.moment || null;
const maxLines = ctx.maxLines || 9;
if (maxLines > 9) root.classList.add('co-tall');
function line(m, { quiet = false } = {}) {
const key = m.id || `${m.t}|${m.handle}`;
@@ -980,7 +982,8 @@ export function mountChatOverlay(core, el, ctx = {}) {
const node = h(lineHtml(msg, core.rt.me.handle));
if (!quiet) node.classList.add('in');
list.appendChild(node);
while (list.childElementCount > 9) list.firstElementChild.remove();
while (list.childElementCount > maxLines) list.firstElementChild.remove();
if (maxLines > 9) list.scrollTop = list.scrollHeight;
// Lines fade after a while, as on a stream overlay.
setTimeout(() => node.classList.add('old'), 9000);
// Chat and hearts go together.
@@ -1062,7 +1065,7 @@ export function mountChatOverlay(core, el, ctx = {}) {
// Lines already said: show the last few, quietly.
const said = Array.isArray(m?.chat) ? m.chat : [];
for (const c of said) shown.add(c.id || `${c.t}|${c.handle}`);
for (const c of said.slice(-6)) {
for (const c of said.slice(maxLines > 9 ? -24 : -6)) {
shown.delete(c.id || `${c.t}|${c.handle}`);
line(c, { quiet: true });
}
+85 -1
View File
@@ -158,6 +158,11 @@ export function createMomentPlayer({
</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>
<section class="mp-feed" aria-label="On the record, as it happened">
<header class="mp-fh tele"><span>${icon('receipt')}On the record</span><span class="mp-fn"></span></header>
<div class="mp-feed-list"></div>
</section>
<header class="mp-ch tele"><span><span class="live-dot"></span>Chat replay</span><span class="mp-chn"></span></header>
<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>
@@ -176,6 +181,8 @@ export function createMomentPlayer({
const chatEl = $('.mp-chat', el);
const recEl = $('.mp-record', el);
const capsEl = $('.mp-caps', el);
const feedEl = $('.mp-feed-list', el);
let feedKeys = new Set();
let m = null;
let raf = 0;
@@ -230,6 +237,55 @@ export function createMomentPlayer({
${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>`;
}
/** A capture as a compact row for the leading pane's feed. */
function capRow(c) {
const fe = c.firstEyes;
return `<button class="mp-fi mp-fcap ${fe ? 'first' : ''}" type="button" data-cap="${esc(c.id)}">
${coneThumb(c, m, { w: 96, hgt: 72 })}
<span class="mp-fbody"><span class="fe tele">${icon(fe ? 'sealFill' : c.live ? 'live' : 'seal')}${fe ? 'FIRST EYES' : c.live ? 'LIVE · SEALED' : 'SEALED CAPTURE'}</span>
<b>${esc(c.title)}</b><small>${esc(c.handle)} · ${esc(utc(c.t))} · ${esc(fmtKm((c.distanceM || 0) / 1000))}</small></span></button>`;
}
/**
* The leading pane's feed (tablet): captures and record posts stacked
* newest first as the clock passes them.
*/
function renderFeed() {
const items = [
...(m.captures || [])
.filter((c) => c.t <= t)
.map((c) => ({ key: `c:${c.id}`, t: c.t, html: capRow(c) })),
...(m.posts || [])
.filter((p) => p.t <= t)
.map((p) => ({
key: `p:${p.id || p.t}`,
t: p.t,
html: postHtml(p).replace('class="mp-post', 'class="mp-fi mp-post'),
})),
].sort((a, b) => b.t - a.t);
const keys = new Set(items.map((x) => x.key));
const same =
keys.size === feedKeys.size && [...keys].every((k) => feedKeys.has(k));
if (!same) {
const fresh = feedKeys.size > 0;
feedEl.innerHTML =
items
.slice(0, 12)
.map((x) =>
fresh && !feedKeys.has(x.key)
? x.html.replace('class="mp-fi', 'class="mp-fi in')
: x.html,
)
.join('') ||
'<p class="mp-empty tele">Sealed captures and record posts appear here at the moment they were made</p>';
feedKeys = keys;
feedEl.scrollTop = 0;
}
$('.mp-fn', el).textContent = items.length
? `${items.length} ITEM${items.length === 1 ? '' : 'S'}`
: '';
}
function drawScrub() {
const r = scrub.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
@@ -282,6 +338,24 @@ export function createMomentPlayer({
sctx.arc(x(p.t), mid + 9, 2, 0, Math.PI * 2);
sctx.fill();
}
// First eyes stamps its byline onto the timeline as the clock passes it.
for (const c of m.captures || []) {
if (!c.firstEyes || c.t > t) continue;
const k = clamp((t - c.t) / 6000, 0, 1);
const label = `FIRST EYES · ${c.handle || ''}`;
sctx.font = '700 8px "JetBrains Mono", ui-monospace, monospace';
const w = sctx.measureText(label).width + 12;
const bx = clamp(x(c.t) - w / 2, 2, W - w - 2);
sctx.globalAlpha = k;
sctx.fillStyle = 'rgba(235,199,119,0.95)';
sctx.beginPath();
sctx.roundRect(bx, 1 + (1 - k) * 4, w, 12, 6);
sctx.fill();
sctx.fillStyle = '#2a1a00';
sctx.textAlign = 'left';
sctx.fillText(label, bx + 6, 10 + (1 - k) * 4);
sctx.globalAlpha = 1;
}
for (const c of m.captures || []) {
sctx.fillStyle = c.t <= t ? '#ebc777' : 'rgba(235,199,119,0.4)';
sctx.beginPath();
@@ -377,6 +451,8 @@ export function createMomentPlayer({
$('[data-n="chat"]', el).textContent = nc ? ` ${nc}` : '';
$('[data-n="record"]', el).textContent = np ? ` ${np}` : '';
$('[data-n="captures"]', el).textContent = nk ? ` ${nk}` : '';
$('.mp-chn', el).textContent = nc ? `${nc} MESSAGES` : '';
if (isTwoPane()) renderFeed();
// Legend: live per-track readouts.
(m.tracks || []).forEach((tr) => {
const row = legend.querySelector(`[data-track="${CSS.escape(tr.id)}"]`);
@@ -483,6 +559,8 @@ export function createMomentPlayer({
.map((x) => `${x.name}${x.detail ? ` — ${x.detail}` : ''}`)
.join(' · ');
shown = { chat: 0, posts: 0, caps: 0 };
feedKeys = new Set();
feedEl.innerHTML = '';
chatEl.innerHTML = '';
recEl.innerHTML = emptyRec();
capsEl.innerHTML = '';
@@ -533,7 +611,13 @@ export function createMomentPlayer({
try {
const host = h('<div class="mp-ext"></div>');
slot.prepend(host);
ext = sm.chatOverlay(host, { moment: raw, globe, getTime: () => t });
ext = sm.chatOverlay(host, {
moment: raw,
globe,
getTime: () => t,
// A tablet's leading pane has the height for a real chat column.
maxLines: isTwoPane() ? 60 : 9,
});
ext?.setMoment?.({ ...raw, chat: [] });
slot.classList.add('has-ext');
el.classList.add('has-ext');
+3 -1
View File
@@ -259,7 +259,9 @@ export function installRound3(
async function loadHero() {
if (hero) return hero;
try {
const r = await fetch('/mobile/demo/moments/empire-state.json');
const r = await fetch(
new URL('../demo/moments/empire-state.json', import.meta.url).href,
);
if (r.ok && /json/.test(r.headers.get('content-type') || '')) {
const j = await r.json();
if (j?.tracks?.length) hero = j;
+1
View File
@@ -620,6 +620,7 @@ export function chatOverlay(el, ctx = {}) {
return mountChatOverlay(c, el, {
globe: ctx.globe || c.globe,
moment: ctx.moment,
maxLines: ctx.maxLines,
});
}
+128 -26
View File
@@ -59,8 +59,8 @@ export function createTimeRibbon(
<div class="rb-track tm-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
<canvas></canvas>
<div class="rb-head tm-head" aria-hidden="true"><i></i></div>
<div class="tm-callout tele" hidden></div>
</div>
<div class="tm-callout tele" hidden></div>
<div class="tm-cliprow" hidden>
<span class="tm-cliplen tele"></span>
<button class="pill-btn plain tm-clipcancel" type="button">Cancel</button>
@@ -82,6 +82,7 @@ export function createTimeRibbon(
let win = getWindow();
let rec = null; // { start, end }
let arch = null; // the bundled recording: { start, end, label }
let custom = null;
let pips = [];
let t = Date.now();
@@ -103,7 +104,11 @@ export function createTimeRibbon(
const start = () =>
custom
? custom.start
: Math.min(win.start.getTime(), rec?.start ?? Infinity);
: Math.min(
win.start.getTime(),
rec?.start ?? Infinity,
arch ? arch.start - HOUR : 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;
@@ -114,6 +119,21 @@ export function createTimeRibbon(
const hst = getHistory?.();
if (hst && Number.isFinite(+hst.start))
rec = { start: +hst.start, end: +hst.end || Date.now() };
const a = hst?.archive;
arch =
a && Number.isFinite(+a.start)
? {
start: +a.start,
end: +a.end,
label: `RECORDING · ${new Date(+a.start)
.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
})
.toUpperCase()}`,
}
: null;
} catch {
rec = null;
}
@@ -157,22 +177,64 @@ export function createTimeRibbon(
ctx.fillStyle = 'rgba(255,255,255,0.035)';
ctx.fillRect(xs, base - 12, xe - xs, 24);
if (!custom) {
// Recorded range: where the app remembers every contact.
// Range labels sit inside the band's lower edge. Each is placed only
// where it fits inside its own range and clear of the labels already
// placed, so they never run into each other ("RECORDEDCAST").
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
const placed = [];
const label = (texts, from, to, color, align = 'left') => {
// The longest wording that fits inside its range.
const all = [].concat(texts);
const text =
all.find(
(tx) =>
ctx.measureText(tx).width + 8 <=
Math.min(to, W - 24) - Math.max(from, 24),
) || all[all.length - 1];
const w = ctx.measureText(text).width;
const lo = Math.max(from, 24);
const hi = Math.min(to, W - 24);
if (hi - lo < w + 8) return;
const x0 = align === 'right' ? hi - 4 - w : lo + 4;
if (placed.some(([a, b]) => x0 < b + 8 && x0 + w > a - 8)) return;
placed.push([x0, x0 + w]);
ctx.fillStyle = color;
ctx.textAlign = 'left';
ctx.fillText(text, x0, base + 9);
};
const band = (a, b, top, bottom, edge) => {
const g = ctx.createLinearGradient(0, base - 12, 0, base + 12);
g.addColorStop(0, top);
g.addColorStop(1, bottom);
ctx.fillStyle = g;
ctx.fillRect(a, base - 12, b - a, 24);
ctx.fillStyle = edge;
ctx.fillRect(a, base - 12, b - a, 1.2);
};
// The bundled recording: real positions at their real times.
if (arch) {
const a = clamp(x(arch.start), -2, W + 2);
const b = clamp(x(Math.min(arch.end, n)), -2, W + 2);
if (b > a) {
band(
a,
b,
'rgba(122,231,199,0.2)',
'rgba(122,231,199,0.05)',
'rgba(122,231,199,0.8)',
);
}
}
// Recorded range: where this device 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);
}
band(
a,
xn,
'rgba(163,216,255,0.22)',
'rgba(163,216,255,0.06)',
'rgba(163,216,255,0.85)',
);
}
// Forecast
if (xn < W) {
@@ -181,13 +243,25 @@ export function createTimeRibbon(
g.addColorStop(1, 'rgba(179,157,255,0.02)');
ctx.fillStyle = g;
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);
}
}
// Labels: forecast first (anchored at now), then the recordings.
if (xn < W)
label('FORECAST', xn, Math.min(W, xe), 'rgba(199,180,255,0.75)');
if (rec)
label(
['RECORDED', 'REC'],
x(rec.start),
xn,
'rgba(163,216,255,0.8)',
'right',
);
if (arch)
label(
[arch.label, 'RECORDING', 'REC'],
x(arch.start),
x(Math.min(arch.end, n)),
'rgba(122,231,199,0.85)',
);
}
// Clip range
if (range) {
@@ -210,6 +284,7 @@ export function createTimeRibbon(
const t0 = Math.ceil(tAt(-10) / step) * step;
const t1 = tAt(W + 10);
ctx.textAlign = 'center';
let lastLabelEnd = -Infinity;
for (let m = t0; m <= t1; m += step) {
if (m < s - step || m > e + step) continue;
const px = x(m);
@@ -245,7 +320,17 @@ export function createTimeRibbon(
})
.toUpperCase()
: `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
ctx.fillText(label, px, base - 15);
const lw = ctx.measureText(label).width;
// Skip a label that would collide with the previous one or be
// cut by the edge fades.
if (
px - lw / 2 > lastLabelEnd + 6 &&
px - lw / 2 > 14 &&
px + lw / 2 < W - 14
) {
ctx.fillText(label, px, base - 15);
lastLabelEnd = px + lw / 2;
}
}
}
// NOW marker
@@ -300,7 +385,14 @@ export function createTimeRibbon(
);
return;
}
const abs = utc(new Date(live ? Date.now() : t));
const at = new Date(live ? Date.now() : t);
// Another UTC day: say which one, so a recording reads honestly.
const otherDay =
!live &&
at.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
const abs = otherDay
? `${at.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }).toUpperCase()} · ${utc(at)}`
: utc(at);
readD.textContent = live ? 'LIVE' : fmtDelta(t - Date.now());
readA.textContent = abs;
el.dataset.future = String(!live && t > Date.now());
@@ -526,7 +618,7 @@ export function createTimeRibbon(
}
if (!custom && Math.abs(target - Date.now()) < (8 / pxPerHour) * HOUR) {
setLive(true);
haptic('tick');
snapPulse();
return animateTo(Date.now(), () => emit(true));
}
const q = custom ? 1000 : stepFor() >= HOUR ? 5 * MIN : MIN / 4;
@@ -549,8 +641,18 @@ export function createTimeRibbon(
raf = requestAnimationFrame(step);
}
/** The NOW snap: a haptic tick and a pulse ring, like a detent landing. */
function snapPulse() {
haptic('medium');
el.classList.remove('snap');
void el.offsetWidth;
el.classList.add('snap');
clearTimeout(snapPulse.t);
snapPulse.t = setTimeout(() => el.classList.remove('snap'), 900);
}
function goLive() {
haptic('tick');
snapPulse();
stopPlay();
if (clipMode) setClipMode(false);
if (custom) {
+114 -12
View File
@@ -16,6 +16,66 @@ import { trackAt } from './stubMoments.js';
import { domeSvg } from './skySheet.js';
const LAST_KEY = 'gev.lastVisit';
const SKY_KEY = 'gev.sky.v1';
/**
* The hero Moment's card, so it paints in the first frame (the full Moment
* JSON is ~0.5 MB and arrives a moment later). Its picture is the same Esri
* span the live card draws on, bundled (mobile/demo/moments/empire-state.jpg).
*/
const HERO_CARD = {
id: 'empire-state',
title: 'Climbers on the Empire State Building',
place: 'Midtown Manhattan, New York',
minutes: 25,
tracks: 3,
captures: 8,
illustrative: true,
};
const HERO_IMG = new URL('../demo/moments/empire-state.jpg', import.meta.url)
.href;
/** The last sky snapshot (this device), so the card never says "Looking up…". */
function readSky() {
try {
const j = JSON.parse(localStorage.getItem(SKY_KEY) || 'null');
if (j?.sky && Date.now() - j.at < 12 * 3600e3)
return { ...j.sky, cachedAt: j.at };
} catch {
/* ignore */
}
return null;
}
function saveSky(sky) {
try {
const slim = {
at: sky.at,
observer: sky.observer,
aircraft: (sky.aircraft || []).slice(0, 16).map((a) => ({
id: a.id,
title: a.title,
kind: a.kind,
elevationDeg: a.elevationDeg,
azimuthDeg: a.azimuthDeg,
headingDeg: a.headingDeg,
})),
satellites: (sky.satellites || []).slice(0, 24).map((x) => ({
id: x.id,
title: x.title,
elevationDeg: x.elevationDeg,
azimuthDeg: x.azimuthDeg,
})),
iss: sky.iss
? { visibleNow: false, nextPass: sky.iss.nextPass || null }
: null,
};
localStorage.setItem(
SKY_KEY,
JSON.stringify({ at: Date.now(), sky: slim }),
);
} catch {
/* per-device convenience only */
}
}
const SEEN_KEY = 'gev.seenIds';
function readLast() {
@@ -85,6 +145,17 @@ export function createToday(
const mCanvas = $('.bf-mcanvas', slot);
const mctx = mCanvas.getContext('2d');
// First frame: the hero card from its bundled picture and a few facts.
{
const img = $('.bf-mimg', slot);
img.onload = () => img.classList.add('loaded');
img.src = HERO_IMG;
$('.bf-mtitle', slot).textContent = HERO_CARD.title;
$('.bf-msub', slot).textContent =
`${HERO_CARD.place} · ${HERO_CARD.minutes} min in 20 s · ${HERO_CARD.tracks} helicopters · ${HERO_CARD.captures} sealed captures`;
$('.bf-mtag', slot).innerHTML = '<span class="tag-ill">Illustrative</span>';
}
/* ---------- since you last looked ---------- */
function changes() {
const now = Date.now();
@@ -169,15 +240,19 @@ export function createToday(
const span = 0.02;
const img = $('.bf-mimg', slot);
const r = mBtn.getBoundingClientRect();
// The card is 16:11 whatever its width (today.css).
const w = Math.max(320, Math.round(r.width || 400));
const hgt = Math.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))),
);
const hgt = Math.round((w * 11) / 16);
if (m.id !== HERO_CARD.id) {
img.classList.remove('loaded');
img.src = esriThumb(
m.lon,
m.lat,
span,
Math.min(1024, w * 2),
Math.min(1024, Math.round((hgt / w) * Math.min(1024, w * 2))),
);
} else if (!img.src.includes('empire-state')) img.src = HERO_IMG;
img.onload = () => img.classList.add('loaded');
const dLon = span / Math.cos((m.lat * Math.PI) / 180);
const dLat = span * (hgt / w);
@@ -304,7 +379,9 @@ export function createToday(
}
function renderTonight() {
const rows = [];
const np = sky?.iss?.nextPass;
const pass = sky?.iss?.nextPass;
const np =
pass && (pass.end || pass.start + 600e3) > Date.now() ? pass : null;
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>`,
@@ -329,11 +406,28 @@ export function createToday(
}
$('[data-tonight]', slot).innerHTML =
rows.join('') ||
'<div class="bf-calm"><span>Clear skies, nothing scheduled.</span></div>';
(sky
? '<div class="bf-calm"><span>Clear skies, nothing scheduled.</span></div>'
: '<div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div>');
}
function renderSky() {
if (!sky) return;
const kicker = $('.bf-skyk', slot);
if (!sky) {
// Never looked from this device yet: an empty dome, counting.
const home = getHome();
$('[data-dome]', slot).innerHTML = domeSvg(
{ aircraft: [], satellites: [] },
{ size: 120, labels: false },
);
$('[data-skyh]', slot).textContent = 'Counting what’s above you';
$('[data-skyn]', slot).textContent =
`Aircraft within 50 km and satellites over ${home.label || home.name}`;
return;
}
kicker.innerHTML = sky.cachedAt
? `YOUR SKY · ${esc(ago(sky.cachedAt).toUpperCase())}`
: '<span class="live-dot"></span>YOUR SKY NOW';
$('[data-dome]', slot).innerHTML = domeSvg(sky, {
size: 120,
labels: false,
@@ -434,13 +528,21 @@ export function createToday(
async function refreshSky() {
if (!globe) return;
const home = getHome();
sky = await globe
const fresh = await globe
.skyNow({ lon: home.lon, lat: home.lat })
.catch(() => null);
if (fresh) {
sky = fresh;
saveSky(fresh);
}
renderSky();
renderTonight();
}
sky = readSky();
renderSky();
if (sky) renderTonight();
watch.onChange(() => renderWatch());
setInterval(() => {
if (!document.hidden && slot.offsetParent) {
+68
View File
@@ -471,8 +471,76 @@ export function createWatch({
if (!document.hidden) evaluate();
}, 30000);
/**
* Director: one deterministic banner. A real rule against live data when
* any rule matches now (fired before or not); else a real situation that
* a demo rule ("any emergency squawk anywhere") would catch; else a
* clearly labelled example. Sticky until tapped or the next beat.
*/
async function demoAlert() {
clearBanners();
// Never wait on a cold live fetch: after 1.2 s the last good snapshot
// stands in (its age is said in the banner).
let sits = [];
try {
const fresh = await Promise.race([
getSituations(),
new Promise((r) => setTimeout(() => r(null), 1200)),
]);
sits = fresh || (await globe.situationsCached?.()) || [];
sits = [...sits, ...(globe.detections?.() || [])];
} catch {
/* example below */
}
const live = sits.filter((s) => Number.isFinite(s.lat));
const age = (s) =>
s.cached && s.capturedAt ? ` · seen ${ago(+new Date(s.capturedAt))}` : '';
for (const r of state.rules) {
const w = what(r.what);
if (!r.on || !w.test) continue;
const pl = placeById(r.of);
const s = live.find(
(x) => w.test(x) && (r.within >= 20000 || distKm(pl, x) <= r.within),
);
if (s)
return banner({
title: s.title,
body: `${ruleText(r)} · ${fmtKm(distKm(pl, s))} from ${pl.name}${age(s)}`,
band: bandOf(s),
sticky: true,
onTap: () => onOpenSituation?.(s),
});
}
const home = placeById('home');
for (const id of ['emergency', 'circling', 'quake5', 'launch', 'fire']) {
const w = what(id);
const s = live
.filter((x) => w.test(x))
.sort((a, b) => distKm(home, a) - distKm(home, b))[0];
if (s)
return banner({
title: s.title,
body: `Demo rule: any ${w.label} anywhere · ${fmtKm(distKm(home, s))} from ${home.name}${age(s)}`,
band: bandOf(s),
sticky: true,
onTap: () => onOpenSituation?.(s),
});
}
return banner({
title: 'Squawk 7700 near Home',
body: 'Example alert · how a rule match looks when one fires',
band: 'sky',
sticky: true,
});
}
function clearBanners() {
stack.replaceChildren();
}
return {
banner,
demoAlert,
clearBanners,
evaluate,
open() {
render();