mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
29 files changed
+9600
-205
No files matched your search
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user