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

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

No files matched your search

+927
View File
@@ -0,0 +1,927 @@
#!/usr/bin/env node
/**
* Builds the hero Moment, mobile/demo/moments/empire-state.json:
* "Climbers on the Empire State Building" (DESIGN.md §12.4).
*
* ILLUSTRATIVE. Nothing here happened. The event, the helicopters, their
* callsigns and tail numbers, the witnesses, the chat and the posts are all
* fictional, and the file says so (`illustrative: true`). What is real is the
* place: the building's position and heights, the street corners, the
* heliports, and the neighbouring building massing (midtown-buildings.json).
*
* The helicopter tracks come from a small flight model, not from hand-drawn
* circles: each aircraft flies a constant airspeed in a steady wind, steers
* to hold an orbit radius with a rate-limited heading (about standard rate),
* and drifts in altitude. The ground track that results is what ADS-B shows
* for a real news orbit: stretched downwind, faster on the downwind side,
* with a few metres of position noise. Output is 1 Hz.
*
* node mobile/demo/build-empire-state.mjs
*
* Deterministic: the same seed always writes the same file.
*/
import { writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
/** JSON as the repo's Prettier would write it (so `prettier --check` passes). */
async function prettyJson(value) {
const text = JSON.stringify(value);
try {
const prettier = await import('prettier');
return await prettier.format(text, { parser: 'json' });
} catch {
return `${text}\n`;
}
}
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SEED = 0x35_0f1f7a;
/* ── Place (real) ───────────────────────────────────────────────── */
const ESB = { lon: -73.98566, lat: 40.74844 };
const GROUND_MSL = 15; // m, 5th Ave at 34th St
const ROOF = 381; // m above street (102nd floor roof)
const ANTENNA = 443; // m, antenna tip
const DECK_86 = 320; // m, 86th floor open-air deck
const DECK_102 = 373; // m, 102nd floor deck
const DMH = {
lon: -74.00905,
lat: 40.70137,
name: 'Downtown Manhattan Heliport',
};
const E34 = {
lon: -73.97175,
lat: 40.74245,
name: 'East 34th Street Heliport',
};
const NJ = { lon: -74.0742, lat: 40.7727, name: 'over Secaucus, NJ' };
/* ── Clock (fictional date) ─────────────────────────────────────── */
const T0 = Date.UTC(2026, 8, 18, 21, 18, 0); // 5:18 PM EDT
const DURATION_S = 25 * 60;
const at = (mmss) => {
const [m, s] = String(mmss).split(':').map(Number);
return T0 + (m * 60 + (s || 0)) * 1000;
};
/* ── Deterministic randomness ───────────────────────────────────── */
function mulberry32(a) {
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(SEED);
const gauss = () => {
let u = 0;
let v = 0;
while (!u) u = rand();
while (!v) v = rand();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
/* ── Local metric frame ─────────────────────────────────────────── */
const D2R = Math.PI / 180;
const KX = 111_320 * Math.cos(ESB.lat * D2R);
const KY = 110_574;
const toXY = (p) => [(p.lon - ESB.lon) * KX, (p.lat - ESB.lat) * KY];
const toLL = (x, y) => ({ lon: ESB.lon + x / KX, lat: ESB.lat + y / KY });
const KT = 0.514444;
const FT = 0.3048;
const wrap = (d) => (((d % 360) + 540) % 360) - 180;
const bearingXY = (dx, dy) => (Math.atan2(dx, dy) / D2R + 360) % 360;
/* ── Flight model ───────────────────────────────────────────────── */
// Wind at 1,000–1,500 ft over Midtown: 14 kt from 290° (blowing toward 110°).
const WIND = { fromDeg: 290, kt: 14 };
const windV = () => {
const to = (WIND.fromDeg + 180) * D2R;
return [Math.sin(to) * WIND.kt * KT, Math.cos(to) * WIND.kt * KT];
};
/**
* Fly a helicopter through `plan` (a list of legs) and return 1 Hz points.
* Legs: { until, mode:'ground'|'goto'|'orbit', ... }.
*/
function fly({ start, startAltFt, plan, tStart, tEnd, noiseM = 4 }) {
const dt = 0.2;
let [x, y] = toXY(start);
let alt = startAltFt * FT;
let hdg = plan[0].initialHeading ?? 0;
let air = 0; // airspeed m/s
const [wx, wy] = windV();
const out = [];
let leg = 0;
let nextOut = tStart;
let altPhase = rand() * Math.PI * 2;
for (let t = tStart; t <= tEnd; t += dt * 1000) {
while (leg < plan.length - 1 && t >= plan[leg].until) leg++;
const L = plan[leg];
let targetAir = (L.kt ?? 0) * KT;
let targetAlt = (L.altFt ?? startAltFt) * FT;
let desired = hdg;
if (L.mode === 'ground') {
targetAir = 0;
targetAlt = (L.altFt ?? 0) * FT;
} else if (L.mode === 'goto') {
const [tx, ty] = toXY(L.to);
desired = bearingXY(tx - x, ty - y);
// Arrive gently: slow down inside 600 m of the end of a goto leg.
const d = Math.hypot(tx - x, ty - y);
if (L.slowNear && d < 600)
targetAir = Math.max(L.slowNear * KT, targetAir * (d / 600));
} else if (L.mode === 'orbit') {
const [cx, cy] = toXY(L.center);
const dx = x - cx;
const dy = y - cy;
const d = Math.hypot(dx, dy);
const beta = bearingXY(dx, dy);
// Radius breathes a little: pilots never fly a perfect circle.
const r =
L.radius * (1 + 0.06 * Math.sin((t - tStart) / 1000 / 47 + L.radius));
const err = Math.atan((d - r) / 110) / D2R;
desired = L.dir === 'cw' ? beta + 90 + err : beta - 90 - err;
// The heading that holds that ground track in the wind (crab angle).
const gx = Math.sin(desired * D2R);
const gy = Math.cos(desired * D2R);
const cross = wx * gy - wy * gx; // wind across the track
const crab = Math.asin(
Math.max(-0.9, Math.min(0.9, cross / Math.max(8, targetAir))),
);
desired = desired + crab / D2R;
}
// Heading: rate-limited, about standard rate (3°/s), up to 5.5°/s.
const maxRate = (L.maxRate ?? 5.5) * dt;
const dh = wrap(desired - hdg);
hdg = (hdg + Math.max(-maxRate, Math.min(maxRate, dh)) + 360) % 360;
// Speed and altitude respond with first-order lags.
air += (targetAir - air) * Math.min(1, dt / 6);
altPhase += dt / 37;
const drift =
L.mode === 'orbit' ? (28 * Math.sin(altPhase) + 9 * gauss()) * FT : 0;
alt += (targetAlt + drift - alt) * Math.min(1, dt / 9);
if (L.mode === 'ground') {
air = 0;
alt += (0 - alt) * Math.min(1, dt / 4);
} else {
x += (Math.sin(hdg * D2R) * air + wx * Math.min(1, air / 10)) * dt;
y += (Math.cos(hdg * D2R) * air + wy * Math.min(1, air / 10)) * dt;
}
if (t >= nextOut) {
nextOut += 1000;
if (L.mode === 'hidden') continue;
const p = toLL(x + gauss() * noiseM, y + gauss() * noiseM);
// ADS-B altitude: 25 ft steps, MSL.
const altFt = Math.round((alt / FT + GROUND_MSL / FT) / 25) * 25;
out.push({
t: Math.round(t),
lon: Math.round(p.lon * 1e6) / 1e6,
lat: Math.round(p.lat * 1e6) / 1e6,
altM: Math.round(altFt * FT),
});
}
}
return out;
}
const near = (p, m, deg) => {
const [x, y] = toXY(p);
return toLL(x + Math.sin(deg * D2R) * m, y + Math.cos(deg * D2R) * m);
};
const orbitCenter = (m, deg) => near(ESB, m, deg);
/* ── The three helicopters (fictional) ──────────────────────────── */
const police = fly({
start: DMH,
startAltFt: 0,
tStart: T0,
tEnd: T0 + DURATION_S * 1000,
plan: [
{ mode: 'ground', until: at('0:20'), initialHeading: 20 },
// Up the west side, over the Hudson shoreline, then across to 34th St.
{
mode: 'goto',
to: { lon: -74.0135, lat: 40.7205 },
kt: 95,
altFt: 900,
until: at('0:55'),
},
{
mode: 'goto',
to: { lon: -74.0055, lat: 40.739 },
kt: 110,
altFt: 1100,
until: at('1:40'),
},
{
mode: 'goto',
to: near(ESB, 520, 240),
kt: 90,
altFt: 1100,
slowNear: 70,
until: at('2:10'),
},
{
mode: 'orbit',
center: orbitCenter(60, 200),
radius: 470,
kt: 64,
altFt: 1100,
dir: 'ccw',
until: at('14:00'),
},
// Tightens and steps down once officers reach the 102nd floor deck.
{
mode: 'orbit',
center: orbitCenter(30, 120),
radius: 410,
kt: 58,
altFt: 1000,
dir: 'ccw',
until: at('25:01'),
},
],
});
const news7 = fly({
start: NJ,
startAltFt: 900,
tStart: T0 + 85_000,
tEnd: T0 + DURATION_S * 1000,
plan: [
{
mode: 'goto',
to: { lon: -74.024, lat: 40.7585 },
kt: 120,
altFt: 1400,
until: at('3:20'),
initialHeading: 120,
},
{
mode: 'goto',
to: near(ESB, 700, 300),
kt: 105,
altFt: 1450,
slowNear: 80,
until: at('4:30'),
},
{
mode: 'orbit',
center: orbitCenter(120, 30),
radius: 660,
kt: 76,
altFt: 1450,
dir: 'ccw',
until: at('25:01'),
},
],
});
const sky4 = fly({
start: E34,
startAltFt: 0,
tStart: T0 + 330_000,
tEnd: T0 + DURATION_S * 1000,
plan: [
{ mode: 'ground', until: at('6:00'), initialHeading: 300 },
{
mode: 'goto',
to: near(ESB, 600, 140),
kt: 70,
altFt: 1250,
slowNear: 62,
until: at('7:25'),
},
{
mode: 'orbit',
center: orbitCenter(90, 160),
radius: 560,
kt: 68,
altFt: 1250,
dir: 'ccw',
until: at('21:00'),
},
// Leaves for the next story: back out over the East River, south.
{
mode: 'goto',
to: { lon: -73.962, lat: 40.728 },
kt: 110,
altFt: 1300,
until: at('22:30'),
},
{
mode: 'goto',
to: { lon: -73.99, lat: 40.69 },
kt: 120,
altFt: 1300,
until: at('25:01'),
},
],
});
const tracks = [
{
id: 'pd-14',
kind: 'helicopter',
label: 'PD-14 (illustrative)',
callsign: 'PD14',
registration: 'ILLUS-14',
type: 'Bell 429',
operator: 'City police aviation (fictional)',
role: 'police',
color: '#5EC8FF',
points: police,
},
{
id: 'news-7',
kind: 'helicopter',
label: 'NEWS-7 (illustrative)',
callsign: 'NEWS7',
registration: 'ILLUS-07',
type: 'Airbus H125',
operator: 'Channel 7 News (fictional)',
role: 'news',
color: '#FFD166',
points: news7,
},
{
id: 'sky-4',
kind: 'helicopter',
label: 'SKY-4 (illustrative)',
callsign: 'SKY4',
registration: 'ILLUS-04',
type: 'Airbus H130',
operator: 'Sky 4 News (fictional)',
role: 'news',
color: '#FF9F6B',
points: sky4,
},
];
/* ── Witness captures (fictional people, real corners) ──────────── */
function aim(p, altM, targetAltM) {
const [x, y] = toXY(p);
const d = Math.hypot(x, y);
return {
headingDeg: Math.round(bearingXY(-x, -y) * 10) / 10,
pitchDeg: Math.round((Math.atan2(targetAltM - altM, d) / D2R) * 10) / 10,
rangeM: Math.round(d),
};
}
const capture = (id, mmss, where, altM, targetAltM, fovDeg, rest) => ({
id,
t: at(mmss),
lon: where.lon,
lat: where.lat,
altM,
...aim(where, altM, targetAltM),
fovDeg,
sealed: true,
...rest,
});
const captures = [
capture(
'c1',
'0:52',
{ lon: -73.98795, lat: 40.74968 },
1.5,
DECK_86 + 10,
22,
{
title: 'Two figures outside the 86th floor fence',
handle: 'kvn_on_34th',
place: 'W 34th St & 6th Ave',
firstEyes: true,
kind: 'photo',
},
),
capture(
'c2',
'3:05',
{ lon: -73.98752, lat: 40.74482 },
1.5,
DECK_86 + 30,
30,
{
title: 'North face, police helicopter overhead',
handle: 'jules_flatiron',
place: '5th Ave & 29th St',
kind: 'clip',
},
),
capture('c3', '6:10', { lon: -73.99325, lat: 40.75195 }, 142, ROOF + 20, 18, {
title: 'On the mast, east side, from the 40th floor',
handle: 'penn_plaza_40th',
place: '1 Penn Plaza, 40th floor',
kind: 'clip',
}),
capture('c4', '8:40', { lon: -74.00355, lat: 40.75255 }, 9, ROOF, 14, {
title: 'Three helicopters around the spire',
handle: 'hellsk1tchen',
place: 'The High Line at W 30th St',
kind: 'photo',
}),
capture('c5', '12:20', { lon: -73.98805, lat: 40.74745 }, 55, DECK_102, 40, {
title: 'Officers on the 102nd floor deck',
handle: 'lena.ktown',
place: 'Rooftop, W 32nd St (Koreatown)',
kind: 'clip',
}),
capture('c6', '15:30', { lon: -73.98205, lat: 40.75112 }, 1.5, ROOF, 28, {
title: 'Crowd on 5th Ave looking up',
handle: 'midtown_mara',
place: '5th Ave & 38th St',
kind: 'photo',
}),
capture('c7', '19:45', { lon: -73.97765, lat: 40.74662 }, 48, DECK_102, 20, {
title: 'Both back over the rail at 102',
handle: 'rooftop_rafa',
place: 'Rooftop, Murray Hill',
kind: 'clip',
}),
capture(
'c8',
'21:10',
{ lon: -73.98875, lat: 40.74905 },
1.5,
ROOF - 40,
34,
{
title: 'SKY-4 peeling away east',
handle: 'deli_guy_dan',
place: 'Broadway & 33rd St (Herald Sq)',
kind: 'photo',
},
),
];
/* ── Live lane: chat (fictional handles) ────────────────────────── */
// [m:ss, handle, text, badge?]. Bursts follow the beats: first sighting,
// police arrival, news arrival, the mast climb, the top, officers on deck,
// coming down.
const CHAT = [
['0:12', 'deli_guy_dan', 'why is everyone on 34th looking up'],
[
'0:31',
'kvn_on_34th',
'theres PEOPLE on the empire state. outside the fence on 86',
'local',
],
['0:38', 'midtown_mara', 'wait what'],
['0:44', 'brklyn_bea', 'link??'],
[
'0:52',
'kvn_on_34th',
'sealed photo up. north side, two of them',
'first-eyes',
],
['0:58', 'throwaway_tom', 'thats a window washer rig'],
['1:04', 'kvn_on_34th', 'no rig. no ropes that I can see', 'first-eyes'],
['1:10', 'jules_flatiron', 'omg I see them from 29th'],
['1:19', 'nyc_scanner_nerd', 'scanner: units responding to 350 5th', 'local'],
['1:33', 'ess_ay_dee', '👀👀👀'],
['1:41', 'midtown_mara', 'this is insane'],
[
'1:52',
'avgeek_jfk',
'police helo just lifted out of the downtown heliport',
'pilot',
],
['2:04', 'brklyn_bea', 'I hear it'],
['2:10', 'nyc_scanner_nerd', 'PD-14 overhead now, orbiting', 'local'],
['2:12', 'deli_guy_dan', 'LOUD'],
['2:14', 'ess_ay_dee', '🔥🔥'],
['2:16', 'hellsk1tchen', 'can see the heli circling from the west side'],
['2:21', 'throwaway_tom', 'ok not window washers lol'],
['2:25', 'murrayhill_mo', 'is this a protest?'],
[
'2:31',
'kvn_on_34th',
'no banner, they just look like climbers. helmets',
'first-eyes',
],
['2:44', 'lena.ktown', 'going up to our roof'],
['2:58', 'jules_flatiron', 'clip sealed from 5th & 29th', 'witness'],
['3:12', 'brklyn_bea', 'how did they even get out there'],
[
'3:26',
'avgeek_jfk',
'orbit looks ~1,100 ft, tight, maybe 500 m radius',
'pilot',
],
['3:39', 'midtown_mara', 'they are not moving'],
['3:55', 'ess_ay_dee', 'pray for them fr'],
['4:08', 'nyc_scanner_nerd', 'second helo inbound from jersey', 'local'],
['4:22', 'deli_guy_dan', 'news is here'],
['4:30', 'avgeek_jfk', 'NEWS-7 up high, ~1,450', 'pilot'],
['4:33', 'murrayhill_mo', 'its on tv now'],
['4:35', 'brklyn_bea', 'channel 7 live rn'],
['4:37', 'ess_ay_dee', '😮'],
['4:41', 'throwaway_tom', 'tv is 40s behind this chat lmao'],
['4:52', 'hellsk1tchen', 'two helis circling opposite sides'],
['5:10', 'kvn_on_34th', 'THEY ARE CLIMBING. the mast', 'first-eyes'],
['5:12', 'midtown_mara', 'NO'],
['5:13', 'jules_flatiron', 'oh my god'],
['5:15', 'deli_guy_dan', 'nope nope nope'],
['5:17', 'ess_ay_dee', '😮😮😮'],
['5:20', 'brklyn_bea', 'im shaking'],
[
'5:24',
'lena.ktown',
'on the roof, I can see them on the east side',
'local',
],
['5:31', 'murrayhill_mo', 'how high is that'],
['5:36', 'avgeek_jfk', 'mast starts ~1,250 ft. antenna tip 1,454', 'pilot'],
['5:52', 'throwaway_tom', 'who does this'],
[
'6:02',
'nyc_scanner_nerd',
'third helo lifting from E 34th St heliport',
'local',
],
[
'6:10',
'penn_plaza_40th',
'from 1 Penn 40th fl. sealed. they are halfway up the mast',
'witness',
],
['6:14', 'deli_guy_dan', 'thats so close'],
['6:25', 'midtown_mara', 'is that a drone?'],
['6:40', 'hellsk1tchen', 'drone over by the river too'],
[
'6:58',
'avgeek_jfk',
'not a drone, thats SKY-4 coming off the E 34th pad',
'pilot',
],
['7:20', 'brklyn_bea', 'three helicopters now'],
['7:26', 'ess_ay_dee', '🔥🔥🔥'],
[
'7:41',
'metwx_paul',
'winds ~15 kt from WNW at 1,000+ ft. thats why the orbits look stretched east',
'met',
],
['7:58', 'jules_flatiron', 'the gusts up there must be brutal'],
['8:12', 'murrayhill_mo', 'they stopped'],
['8:30', 'kvn_on_34th', 'one is waving??', 'first-eyes'],
[
'8:40',
'hellsk1tchen',
'sealed from the high line, all three helis in frame',
'witness',
],
['8:51', 'deli_guy_dan', 'crowd on 5th is massive now'],
['9:05', 'brklyn_bea', 'please come down'],
['9:22', 'lena.ktown', 'they are going higher', 'witness'],
['9:34', 'throwaway_tom', 'HOW'],
['9:40', 'kvn_on_34th', 'ONE IS AT THE TOP OF THE MAST', 'first-eyes'],
['9:41', 'midtown_mara', 'NOOOO'],
['9:42', 'ess_ay_dee', '😮😮😮😮'],
['9:43', 'jules_flatiron', 'oh my god oh my god'],
['9:44', 'deli_guy_dan', 'cant watch'],
['9:46', 'murrayhill_mo', 'insane'],
['9:48', 'brklyn_bea', '😭'],
['9:51', 'hellsk1tchen', 'the whole avenue just gasped'],
['9:55', 'penn_plaza_40th', 'whole office is at the window', 'witness'],
[
'10:08',
'heli_dispatch_ret',
'three ships stacked 1,000/1,250/1,450, all left orbits. textbook',
'pilot',
],
['10:22', 'throwaway_tom', 'helmet cam. this is for a video 100%'],
[
'10:40',
'avgeek_jfk',
'NEWS-7 widening out, prob for the wide shot',
'pilot',
],
['11:05', 'midtown_mara', 'is the deck closed?'],
[
'11:20',
'nyc_scanner_nerd',
'scanner says deck cleared, ESU going up',
'local',
],
['11:42', 'brklyn_bea', 'come on come on'],
['12:05', 'murrayhill_mo', 'whats ESU'],
[
'12:11',
'nyc_scanner_nerd',
'emergency service unit. rescue specialists',
'local',
],
[
'12:20',
'lena.ktown',
'sealed: officers on the 102 deck right under them',
'witness',
],
['12:24', 'ess_ay_dee', '🙏'],
['12:35', 'deli_guy_dan', 'they are talking to them i think'],
['13:02', 'jules_flatiron', 'PD-14 dropping lower'],
[
'13:40',
'heli_dispatch_ret',
'police ship tightening the orbit, stepping down to ~1,000',
'pilot',
],
['14:10', 'throwaway_tom', 'third climber on the setback??'],
['14:14', 'midtown_mara', 'wait three??'],
['14:20', 'brklyn_bea', 'I see a third one'],
[
'14:48',
'jules_flatiron',
'thats the mast shadow lol. compare the 5th ave and high line shots',
'witness',
],
['15:02', 'hellsk1tchen', 'yeah shadow, sun is behind us', 'witness'],
[
'15:30',
'midtown_mara',
'sealed from 38th, the crowd is all phones',
'witness',
],
['15:51', 'deli_guy_dan', 'sold out of water lol'],
['16:20', 'murrayhill_mo', 'how long has it been'],
['16:24', 'kvn_on_34th', '16 min since I saw them', 'first-eyes'],
['16:50', 'ess_ay_dee', '❤️❤️'],
['17:15', 'brklyn_bea', 'they are moving'],
['17:30', 'lena.ktown', 'coming DOWN', 'witness'],
['17:32', 'midtown_mara', 'YES'],
['17:34', 'deli_guy_dan', 'thank god'],
['17:36', 'ess_ay_dee', '❤️❤️❤️'],
['17:55', 'throwaway_tom', 'slowly. slowly'],
['18:30', 'jules_flatiron', 'first one at the rail'],
['18:58', 'penn_plaza_40th', 'officers reaching over the rail', 'witness'],
['19:20', 'kvn_on_34th', 'ONE IS IN', 'first-eyes'],
['19:22', 'brklyn_bea', '😭😭'],
['19:24', 'midtown_mara', 'ok im crying'],
['19:40', 'lena.ktown', 'SECOND ONE IN', 'witness'],
['19:42', 'deli_guy_dan', 'LETS GOOO'],
['19:43', 'ess_ay_dee', '❤️🔥❤️🔥'],
['19:44', 'murrayhill_mo', 'the street is clapping'],
[
'19:45',
'rooftop_rafa',
'sealed from murray hill, both over the rail',
'witness',
],
['19:50', 'hellsk1tchen', 'cheering from the high line too'],
['20:10', 'throwaway_tom', 'they are so arrested'],
[
'20:40',
'nyc_scanner_nerd',
'scanner: two in custody, no injuries reported',
'local',
],
['21:00', 'avgeek_jfk', 'SKY-4 departing east, next story', 'pilot'],
['21:10', 'deli_guy_dan', 'sealed one of SKY-4 leaving', 'witness'],
['21:35', 'jules_flatiron', 'what a day for midtown'],
['22:10', 'midtown_mara', 'the replay of this is gonna be wild'],
['22:40', 'brklyn_bea', 'thank you everyone for the updates'],
[
'23:15',
'heli_dispatch_ret',
'NEWS-7 and PD-14 still up. will hold till the street clears',
'pilot',
],
['24:05', 'murrayhill_mo', 'traffic on 5th still stopped'],
[
'24:40',
'kvn_on_34th',
'cant believe I just looked up at the right time',
'first-eyes',
],
];
/* ── Record lane: posts ─────────────────────────────────────────── */
const POSTS = [
[
'0:58',
'kvn_on_34th',
'witness',
'Two people outside the safety fence on the 86th floor setback, north face. Sealed photo from W 34th St & 6th Ave, 22° zoom.',
412,
],
[
'2:40',
'midtown_mara',
'question',
'Is the observation deck being cleared? Is anyone still up there?',
88,
],
[
'3:30',
'nyc_scanner_nerd',
'context',
'Police aviation is on scene, orbiting at about 1,100 ft. News helicopters usually follow within minutes.',
230,
],
[
'6:15',
'penn_plaza_40th',
'witness',
'From 1 Penn Plaza, 40th floor: both are on the mooring mast, east side, about halfway up. Sealed clip.',
377,
],
[
'7:05',
'avgeek_jfk',
'correction',
'The "drone" in clips around 6:30 is SKY-4 lifting out of the East 34th St heliport. Its track matches.',
156,
],
[
'7:50',
'metwx_paul',
'context',
'Wind at 1,000–1,500 ft is about 15 kt from the WNW. That is why every orbit looks stretched toward the East River.',
141,
],
[
'10:15',
'heli_dispatch_ret',
'context',
'Three helicopters holding 1,000, 1,250 and 1,450 ft in left-hand orbits. Staggered altitudes are normal deconfliction.',
264,
],
[
'12:25',
'lena.ktown',
'witness',
'Rooftop in Koreatown: officers visible on the 102nd floor deck directly below the climbers. Sealed.',
198,
],
[
'14:55',
'jules_flatiron',
'correction',
"There is no third climber. The figure on the setback is the mast's shadow: compare the 5th Ave and High Line captures, taken 6 minutes apart.",
301,
],
['16:10', 'brklyn_bea', 'question', 'Has anyone been hurt?', 64],
[
'19:50',
'rooftop_rafa',
'witness',
'Both are back over the rail at the 102nd floor and officers have them. Sealed clip from Murray Hill.',
520,
],
[
'20:45',
'nyc_scanner_nerd',
'context',
'Scanner traffic: two in custody, no injuries reported. Unconfirmed until an official statement.',
187,
],
];
/* ── Beats (for timeline pips) ──────────────────────────────────── */
const BEATS = [
['0:52', 'First sealed capture: two figures outside the 86th floor fence'],
['2:10', 'Police helicopter on scene'],
['4:30', 'NEWS-7 arrives'],
['5:10', 'Climbers start up the mast'],
['7:25', 'SKY-4 joins: three helicopters orbiting'],
['9:40', 'One climber at the top of the mast'],
['12:20', 'Officers on the 102nd floor deck'],
['17:30', 'Climbers start down'],
['19:40', 'Both back over the rail'],
['21:00', 'SKY-4 departs'],
];
/* ── Cinematic camera path, keyed to time ───────────────────────── */
// Each key: bearing from the building, ground range, the camera's pitch
// down to the look point, and the look point's height (default the deck).
function camKey(
mmss,
bearingFromEsb,
rangeM,
pitchDownDeg,
lookAltM = DECK_86,
) {
const heightM = Math.round(lookAltM + Math.tan(pitchDownDeg * D2R) * rangeM);
const p = near(ESB, rangeM, bearingFromEsb);
const [x, y] = toXY(p);
const heading = bearingXY(-x, -y);
const pitch = Math.atan2(lookAltM - heightM, Math.hypot(x, y)) / D2R;
return {
t: at(mmss),
lon: Math.round(p.lon * 1e6) / 1e6,
lat: Math.round(p.lat * 1e6) / 1e6,
height: heightM,
heading: Math.round(heading * 10) / 10,
pitch: Math.round(pitch * 10) / 10,
};
}
const cameraPath = [
camKey('0:00', 212, 4200, 30, 150),
camKey('1:00', 218, 3200, 30, 200),
camKey('2:00', 228, 2300, 31, 260),
camKey('3:30', 248, 1800, 32),
camKey('5:00', 278, 1550, 31, ROOF),
camKey('6:30', 308, 1450, 30, ROOF),
camKey('8:00', 338, 1350, 29, ROOF),
camKey('9:40', 6, 1150, 26, ROOF + 20),
camKey('11:30', 38, 1350, 29, ROOF),
camKey('13:30', 72, 1650, 32),
camKey('15:30', 108, 1900, 34),
camKey('17:30', 142, 1600, 31, ROOF),
camKey('19:40', 172, 1300, 27, DECK_102),
camKey('21:30', 198, 2000, 31),
camKey('23:30', 216, 3000, 31, 220),
camKey('25:00', 224, 4000, 30, 150),
];
/* ── Assemble ───────────────────────────────────────────────────── */
const jitter = (t, s) => t + Math.round(gauss() * s * 1000);
const chat = CHAT.map(([mmss, handle, text, badge]) => ({
t: jitter(at(mmss), 1.2),
handle,
text,
...(badge ? { badge } : {}),
})).sort((a, b) => a.t - b.t);
const posts = POSTS.map(([mmss, handle, kind, text, votes], i) => ({
id: `p${i + 1}`,
t: at(mmss),
handle,
kind,
text,
votes,
...(captures.find(
(c) => c.handle === handle && Math.abs(c.t - at(mmss)) < 60_000,
)
? {
captureId: captures.find(
(c) => c.handle === handle && Math.abs(c.t - at(mmss)) < 60_000,
).id,
}
: {}),
}));
const moment = {
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: T0 + DURATION_S * 1000 },
illustrative: true,
illustrativeNote:
'Illustrative reconstruction. The event, aircraft, callsigns, people, chat and posts are fictional; the place, building heights and heliports are real.',
recommendedSpeed: 30,
groundMslM: GROUND_MSL,
subject: {
lon: ESB.lon,
lat: ESB.lat,
altM: DECK_86,
label: 'Empire State Building',
roofM: ROOF,
antennaM: ANTENNA,
decks: [DECK_86, DECK_102],
},
stage: { buildings: 'midtown-buildings.json', highlight: '1015862' },
wind: WIND,
cameraPath,
beats: BEATS.map(([mmss, title]) => ({ t: at(mmss), title })),
tracks,
captures,
chat,
posts,
sources: [
{
name: 'Illustrative reconstruction',
detail:
'Helicopter tracks are synthetic, from a flight model (constant airspeed, 14 kt wind, rate-limited turns) shaped like real news and police orbits. Callsigns and tail numbers are fictional and are not real registrations.',
},
{
name: 'NYC Open Data · Building Footprints',
detail:
'Real footprints and roof heights for Midtown (NYC OTI, dataset 5zhs-2jue).',
},
{
name: 'Esri World Imagery',
detail: 'Base imagery of the real place.',
},
{
name: 'Simulated Live and Record lanes',
detail:
'Chat, posts, handles and witness captures are simulated to show how a room forms around an event.',
},
],
};
await mkdir(path.join(HERE, 'moments'), { recursive: true });
const file = path.join(HERE, 'moments', 'empire-state.json');
await writeFile(file, await prettyJson(moment));
console.log(
`wrote ${file}: ${tracks.map((t) => `${t.id} ${t.points.length} pts`).join(', ')}, ` +
`${captures.length} captures, ${chat.length} chat, ${posts.length} posts`,
);
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* Builds mobile/demo/midtown-buildings.json: real building massing for the
* Empire State Moment, from NYC Open Data "Building Footprints" (DoITT,
* dataset 5zhs-2jue, roof heights from photogrammetry). Every building
* within 1.6 km of the Empire State Building whose roof is over 60 ft, and
* towers over 200 ft out to 2.6 km (so the massing thins out instead of
* ending at a hard circle).
*
* node mobile/demo/build-midtown.mjs [path/to/cached.json]
*
* Output: { origin:[lon,lat], source, fetchedAt, b:[[roofM, groundM, x0,y0,x1,y1,…]] }
* where x/y are integer metres east/north of `origin` (outer ring only,
* Douglas-Peucker simplified to 1 m).
*/
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
/** JSON as the repo's Prettier would write it (so `prettier --check` passes). */
async function prettyJson(value) {
const text = JSON.stringify(value);
try {
const prettier = await import('prettier');
return await prettier.format(text, { parser: 'json' });
} catch {
return `${text}\n`;
}
}
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ORIGIN = [-73.9857, 40.7484];
const FT = 0.3048;
async function load() {
const cached = process.argv[2];
if (cached) return JSON.parse(await readFile(cached, 'utf8'));
const url = new URL('https://data.cityofnewyork.us/resource/5zhs-2jue.json');
url.searchParams.set(
'$select',
'the_geom,height_roof,ground_elevation,bin,construction_year',
);
url.searchParams.set(
'$where',
`within_circle(the_geom,${ORIGIN[1]},${ORIGIN[0]},2600) AND height_roof > 60`,
);
url.searchParams.set('$limit', '20000');
const r = await fetch(url);
if (!r.ok) throw new Error(`NYC Open Data HTTP ${r.status}`);
return r.json();
}
const kx = 111_320 * Math.cos((ORIGIN[1] * Math.PI) / 180);
const ky = 110_574;
const toXY = ([lon, lat]) => [(lon - ORIGIN[0]) * kx, (lat - ORIGIN[1]) * ky];
function simplify(pts, tol) {
if (pts.length < 4) return pts;
const keep = new Uint8Array(pts.length);
keep[0] = keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
while (stack.length) {
const [a, b] = stack.pop();
const [ax, ay] = pts[a];
const [bx, by] = pts[b];
const dx = bx - ax;
const dy = by - ay;
const len = Math.hypot(dx, dy) || 1e-9;
let best = -1;
let bestD = tol;
for (let i = a + 1; i < b; i++) {
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
if (d > bestD) {
bestD = d;
best = i;
}
}
if (best > 0) {
keep[best] = 1;
stack.push([a, best], [best, b]);
}
}
return pts.filter((_, i) => keep[i]);
}
const rows = await load();
const out = [];
for (const row of rows) {
const roof = Number(row.height_roof) * FT;
if (!Number.isFinite(roof) || roof < 18) continue;
const ground = Number(row.ground_elevation) * FT;
const polys = row.the_geom?.coordinates || [];
// The largest outer ring stands for the building.
let ring = null;
for (const poly of polys) {
const r = poly[0];
if (r && (!ring || r.length > ring.length)) ring = r;
}
if (!ring || ring.length < 4) continue;
let pts = ring.map(toXY);
const [fx, fy] = pts[0];
const dist = Math.hypot(fx, fy);
if (dist > 2600) continue;
// Beyond 1.6 km only towers, thinning out with distance.
if (dist > 1600 && roof < 61 + ((dist - 1600) / 1000) * 60) continue;
// Open ring: split at the farthest vertex so both halves simplify cleanly.
pts = pts.slice(0, -1);
let far = 1;
for (let i = 1; i < pts.length; i++)
if (
Math.hypot(pts[i][0] - pts[0][0], pts[i][1] - pts[0][1]) >
Math.hypot(pts[far][0] - pts[0][0], pts[far][1] - pts[0][1])
)
far = i;
pts = [
...simplify(pts.slice(0, far + 1), 1.0),
...simplify([...pts.slice(far), pts[0]], 1.0).slice(1, -1),
];
if (pts.length < 3) continue;
const flat = [];
for (const [x, y] of pts) flat.push(Math.round(x), Math.round(y));
out.push([
Math.round(roof * 10) / 10,
Number.isFinite(ground) ? Math.round(ground * 10) / 10 : 15,
...flat,
]);
}
out.sort((a, b) => a[0] - b[0]);
const doc = {
origin: ORIGIN,
source:
'NYC Open Data · Building Footprints (NYC OTI/DoITT, 5zhs-2jue), roof heights from photogrammetry',
fetchedAt: new Date().toISOString(),
count: out.length,
b: out,
};
const file = path.join(HERE, 'midtown-buildings.json');
await writeFile(file, await prettyJson(doc));
console.log(`wrote ${out.length} buildings → ${file}`);
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Regenerates the bundled first-frame snapshot (DESIGN.md §12.8):
*
* mobile/demo/snapshot.json the live situations() result, with capturedAt
* mobile/demo/thumbs/<id>.jpg each card's composited thumbnail
*
* It drives the real engine in headless Chromium against a running dev
* server, so every card is real data at capture time.
*
* npm run dev # or: npx vite --port 4173
* node mobile/demo/capture-snapshot.mjs [--base http://localhost:4173]
* [--chrome /path/to/chrome] [--max 28] [--quality 0.8]
* [--lat 40.75 --lon -73.98] # a viewer position for distances
*
* Budget: thumbnails are 360×480 CSS px at 1.25× and JPEG-recompressed so
* the whole folder stays under ~3 MB.
*/
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(HERE, '..', '..', 'package.json'));
const args = Object.fromEntries(
process.argv
.slice(2)
.join(' ')
.split('--')
.filter(Boolean)
.map((kv) => {
const [k, ...v] = kv.trim().split(/\s+/);
return [k, v.join(' ') || true];
}),
);
const BASE = args.base || 'http://localhost:4173';
const MAX = Number(args.max || 28);
const QUALITY = Number(args.quality || 0.8);
const BUDGET = 3 * 1024 * 1024;
const puppeteer = (await import(require.resolve('puppeteer'))).default;
const browser = await puppeteer.launch({
executablePath: args.chrome || process.env.CHROME_PATH || undefined,
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--no-sandbox',
],
protocolTimeout: 600_000,
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 430, height: 932, deviceScaleFactor: 1 });
page.on('pageerror', (e) => console.warn('[page]', e.message));
await page.goto(`${BASE}/mobile/globe/dev.html?hud=0&labels=0`, {
waitUntil: 'domcontentloaded',
timeout: 120_000,
});
await page.waitForFunction('window.globe', { timeout: 120_000 });
console.log('engine up; building live situations (this takes ~30–60 s)…');
const viewer =
args.lat && args.lon
? { lat: Number(args.lat), lon: Number(args.lon) }
: null;
const result = await page.evaluate(
async ({ max, quality, viewer }) => {
if (viewer) globe.setViewer(viewer);
// Warm the recorder a little so detections have a chance to show.
const list = (await globe.situations()).slice(0, max);
const out = [];
for (const s of list) {
let thumb = null;
try {
const url = await globe.thumbnail(s, {
width: 360,
height: 480,
dpr: 1.25,
});
const img = await createImageBitmap(await (await fetch(url)).blob());
const c = document.createElement('canvas');
c.width = img.width;
c.height = img.height;
c.getContext('2d').drawImage(img, 0, 0);
const blob = await new Promise((r) =>
c.toBlob(r, 'image/jpeg', quality),
);
const buf = new Uint8Array(await blob.arrayBuffer());
let bin = '';
for (let i = 0; i < buf.length; i += 0x8000)
bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
thumb = btoa(bin);
} catch (error) {
console.warn('thumbnail failed', s.id, error?.message);
}
out.push({ situation: JSON.parse(JSON.stringify(s)), thumb });
}
return out;
},
{ max: MAX, quality: QUALITY, viewer },
);
const thumbsDir = path.join(HERE, 'thumbs');
await mkdir(thumbsDir, { recursive: true });
for (const f of await readdir(thumbsDir))
if (f.endsWith('.jpg')) await rm(path.join(thumbsDir, f));
let bytes = 0;
const situations = [];
for (const { situation, thumb } of result) {
const file = `${String(situation.id).replace(/[^\w.-]+/g, '_')}.jpg`;
if (thumb) {
const buf = Buffer.from(thumb, 'base64');
if (bytes + buf.length > BUDGET) {
console.warn(`budget reached; ${situation.id} ships without a thumb`);
} else {
bytes += buf.length;
await writeFile(path.join(thumbsDir, file), buf);
situation.thumbFile = `thumbs/${file}`;
}
}
situations.push(situation);
}
const snapshot = {
capturedAt: new Date().toISOString(),
source:
'Live God’s Eye feeds through the dev server (see each card’s source)',
note: 'Bundled first frame for demos. The shell shows capturedAt and replaces these with live cards as soon as situations() resolves.',
situations,
};
let text = JSON.stringify(snapshot);
try {
const prettier = await import('prettier');
text = await prettier.format(text, { parser: 'json' });
} catch {
text += '\n';
}
await writeFile(path.join(HERE, 'snapshot.json'), text);
console.log(
`wrote ${situations.length} situations, ${Math.round(bytes / 1024)} KB of thumbnails`,
);
} finally {
await browser.close();
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+165
View File
@@ -111,3 +111,168 @@ When NHC has published an advisory but its cone geometry is still pending, the c
- `?lat=&lon=` sets the viewer.
- `?only=cyclone,eye` filters cards by id prefix.
- `?w=330` sets the card width.
---
# Round three: Gotham for everyone (DESIGN.md §12)
Additive. Nothing above changes. Every call below exists on the object `createGlobe()` resolves to. Times are epoch ms unless a `Date` is shown.
## Instant first frame
```js
globe.situations({ cached: true }) → Promise<Situation[]>
// resolves at once from the last good IndexedDB snapshot (written by every live
// situations() call), else the bundled mobile/demo/snapshot.json (real data).
// Each cached Situation adds: cached: true, cacheOrigin: 'device' | 'bundled',
// capturedAt (epoch ms), live: false, thumbUrl (bundled /mobile/demo/thumbs/<id>.jpg
// or a Cache API blob URL) or null.
globe.situations() → Promise<Situation[]> // live, as before (also saves the cache)
globe.cachedThumbnail(situation) → string | null // sync; thumbUrl for a cached card
globe.thumbnail(cachedSituation) → resolves to the cached JPEG when the card has no live scene
```
The globe paints its first frame with imagery only. Live layers start right after it, and weather, transit, GNSS, Starlink and satellites start about a second later. Background warm-ups (jet, TCM, AQ, CCTV, rasters) start at 4 s.
## Recorder and time machine
```js
globe.history() → { start, end, entities, points, bytes } // recorded range; start/end null when empty
globe.setTime(t) // unchanged. Inside the recorded range, contacts are drawn at their interpolated
// recorded positions with 4-minute ghost trails (pickable: 'pick' kind is the
// contact's kind, meta.layer 'history', entityId). Beyond the record, dimmed as before.
globe.trackOf(entityId) → Promise<[{ t, lon, lat, altM, headingDeg, speed }]>
// recorder samples merged with /api/flights/track (OpenSky) or /api/military/track
// (adsb.lol traces); the array has .source
globe.playback.play({ speed = 60, from?, until? } = {}) → state
// speeds: 1, 10, 30, 60, 600. From live it replays the last 15 min. Reaching "now" goes live;
// inside a Moment it stops at the Moment's end.
globe.playback.stop() → state
globe.playback.state() → { playing, speed, t, live }
globe.playback.speeds // [1, 10, 30, 60, 600]
// event 'playback' → state, on start/stop and every 250 ms while playing.
// 'time' is also emitted every 250 ms while playing ({ live:false, time, playing:true }).
// Playing runs the render loop; at rest the globe is request-render again.
globe.timeline({ from, to } = {}) → [{ t, kind, band, title, severity, situationId?, entityId?, momentId?, lon?, lat? }]
// kind: 'quake' | 'quake-cluster' | 'launch' | 'squawk' | 'cyclone' | 'fire' | 'circling'
// | 'lightning' | 'moment' (a Moment beat) | 'capture' | 'post' | 'chat-burst'
// band: 'orbit' | 'sky' | 'ground' | 'inside'; severity 0..3; sorted by t.
// Defaults to timeWindow(). Moment items appear for Moments that were loaded and overlap the range.
```
The recorder keeps every poll for low traffic (helicopters, approaches, anything below about 6 km) and one fix every 50 s for cruise traffic. Samples older than 30 min are thinned. The buffer is capped at 1.2 M samples (about 24 MB) and persisted to IndexedDB about once a minute, so a reload keeps up to 6 h. When OpenSky is down, the recorder also polls adsb.lol snapshots around New York, London, Los Angeles and the viewer every 15 s, so the detector has real data.
Entity ids are layer-qualified: `flights:<icao24>`, `military:<hex>`, `satellites:<norad>`, `earthquakes:<id>`, `cctv:<id>`, `vessels:<mmsi>`, `situation:<id>`, `place:<slug>`, `moment:<momentId>`, `moment:<momentId>:<trackId>`, `capture:<momentId>:<captureId>`. Every `'pick'` payload can be passed to `entity()` as is.
## Detection
Detections run on recorder data every poll. They appear only when real data shows them.
- `circling`: two or more aircraft (rotorcraft and low-and-slow preferred, below about 8,000 ft and 155 kt), each turning through at least 360° within about 1.5 km of a common centre in the last 20 min. Card: `{ kind:'circling', band:'sky', archetype:'track', title:'3 helicopters circling Midtown Manhattan', momentId:'live-circling-…', members:[{ key, layer, callsign, helicopter }], entityIds, detected:true }`. Its thumbnail draws the recorded orbit tracks.
- `quake-cluster`: three or more M2.5+ earthquakes within 50 km in 24 h. Archetype `number`, with a magnitude series.
- `squawk` cards now carry `momentId: 'live-squawk-<icao24>'`.
```js
globe.detections() → Situation[] // current detections, sync
// event 'detections' → detector output, when the set of circling detections changes
```
## Moments
```js
globe.moments() → Promise<Moment[]> // live (circling, squawks) first, then the bundled hero
globe.moment(id) → Promise<Moment|null> // 'empire-state' | 'live-circling-…' | 'live-squawk-<hex>'
globe.momentOfTheDay() → Promise<Moment> // best live Moment if any, else the hero
globe.playMoment(idOrMoment, { speed, camera = true, autoplay = true, framing = 6 } = {}) → Promise<Moment>
// speed defaults to moment.recommendedSpeed (hero: 30 → 50 s). camera:false leaves the camera alone.
// framing: degrees the cinematic camera looks below the subject (puts it in the upper half).
// Hides every live layer, draws tracks (glyph, 90 s bright trail, faint flown path, drop line),
// capture view frustums that pop in at their t, the subject building and its beacon, and flies
// the camera path keyed to the clock. Touching the globe releases the camera.
globe.exitMoment({ restoreCamera = true } = {}) // back to live (or the time before) and the old camera
globe.activeMoment() → { id, title, illustrative, t, start, end, playing, speed, cinematic, moment } | null
globe.seekMoment(t) // epoch ms, or a 0..1 fraction of the Moment
globe.momentCamera(on = true) // re-attach (or release) the cinematic camera → boolean
// event 'moment' → { type, item, t, momentId }
// type: 'start' (item = Moment) | 'capture' | 'chat' | 'post' | 'beat' | 'end' | 'exit'
// | 'camera' (item = { cinematic:false } when the person takes the camera)
// Items fire as the clock passes them while playing, and on small seeks. A seek back, or a jump of
// more than 90 s while paused, repositions silently: render the Live/Record lanes by filtering
// activeMoment().moment.chat/posts/captures by t <= activeMoment().t.
```
```ts
Moment = {
id, title, place, lon, lat,
time: { start, end }, // epoch ms
illustrative: boolean, // true for the bundled hero; false for live detections
illustrativeNote?: string,
recommendedSpeed?: number,
subject?: { lon, lat, altM, label, roofM?, antennaM?, decks? },
cameraPath: [{ t, lon, lat, height /* m above ground */, heading, pitch }],
beats?: [{ t, title }],
tracks: [{ id, kind /* 'helicopter'|'aircraft' */, label, operator, callsign?, registration?, type?,
color?, entityId? /* live: 'flights:<hex>' */, points: [{ t, lon, lat, altM /* MSL */ }] }],
captures: [{ id, t, lon, lat, altM, headingDeg, pitchDeg, fovDeg, rangeM, title, handle, place,
sealed, firstEyes?, kind /* 'photo'|'clip' */, image? }],
chat: [{ t, handle, text, badge? /* 'first-eyes'|'witness'|'local'|'pilot'|'met' */ }],
posts: [{ id, t, handle, kind /* 'witness'|'context'|'question'|'correction' */, text, votes, captureId? }],
sources: [{ name, detail }],
}
```
The hero is `mobile/demo/moments/empire-state.json` (id `empire-state`): "Climbers on the Empire State Building", 25 min, 3 helicopters, 8 captures, 120 chat lines and 12 posts. Regenerate it with `node mobile/demo/build-empire-state.mjs`. Its Midtown massing is real NYC Open Data footprints and roof heights in `mobile/demo/midtown-buildings.json` (`node mobile/demo/build-midtown.mjs`).
## Entities and dossiers
```js
globe.entity(ref) → Promise<Dossier> // ref: entity id string, a 'pick' payload, or { kind, id }
Dossier = {
id, kind, title, subtitle,
identity: { icao24?, callsign?, registration?, type?, typeCode?, operator?, route?, country?, squawk?,
norad?, intlDesignator?, tle?, mmsi?, provider?, magnitude?, illustrative?, ... },
live: { lon, lat, altM, speed /* m/s */, heading, squawk?, updatedAt, speedKt?, elevationDeg? } | null,
track: [{ t, lon, lat, altM }],
events: [{ t, title }],
links: [{ id, kind, title, relation, distanceKm? }],
sources: [{ name, fetchedAt, url?, hash /* 'sha256:…' (or 'fnv1a:…' outside secure contexts) */, detail? }],
privacy?: 'pia' | 'ladd' | 'no-public-identity', // identity then holds only what the transponder broadcasts
trackPending?: true, // the server track was slow; call trackOf(id) later for the full one
nextPass?: { start, max, end, maxElevationDeg, visible }, // satellites, for the viewer
illustrative?: true, // hero Moment tracks and captures
}
globe.search(query) → Promise<[{ id, kind, title, subtitle }]> // ≤ 30; "N46567", "UAL", "7700",
// "M5.2", "ISS", "Midtown", "empire"… across live aircraft, the record, satellites, quakes,
// cameras, situations, places (neighbourhoods, airports, cities) and Moments
globe.graph(ref) → { center, nodes: [{ id, kind, title }], edges: [{ from, to, relation }] } // ≤ 40 nodes, sync
```
Kinds: `aircraft`, `helicopter`, `military`, `satellite`, `vessel`, `quake`, `camera`, `situation`, `place`, `moment`, `track`, `capture`. There are never person entities. Aircraft dossiers wait at most 2.5 s for the server track (aircraft identity comes from adsbdb, 3.5 s budget).
## Reactions and attention
```js
globe.react({ lon, lat, kind: 'heart'|'fire'|'eyes'|'wow', count = 1, altM = 0 }) → number
globe.attentionHeat(points | null) // points: [{ lon, lat, weight? }]; soft glow where viewers are
```
Reactions draw on a screen-space canvas that the globe owns (`.gev-reactions`, pointer-events none, above the globe and below your HTML). Each glyph stays anchored to its point as the globe moves. At most 300 are alive at once, and the canvas animates only while some are alive.
## Your sky now
```js
globe.skyNow({ lon, lat } = viewer) → Promise<{
at, observer, sunElevationDeg,
aircraft: [{ id, title, kind, lon, lat, altM, distanceKm, elevationDeg, azimuthDeg, headingDeg, speedMps, squawk }],
satellites: [{ id, title, norad, altKm, elevationDeg, azimuthDeg }],
iss: { visibleNow, aboveHorizon, elevationDeg, azimuthDeg,
nextPass: { start, max, end, maxElevationDeg, riseAzimuthDeg, visible } | null },
sources: [string],
}> // aircraft within 50 km, satellites above the horizon; each list sorted by elevation, highest first
```
## Dev harness
`mobile/globe/dev.html?moment=empire-state&speed=30` plays a Moment on load and prints its events in the HUD. `globe.debug` exposes `{ recorder, detector, stage, ghosts }` for tests. It is not part of the contract.
+147
View File
@@ -0,0 +1,147 @@
/**
* Instant first frame (DESIGN.md §12.8): the last good situations and their
* thumbnails, so the feed paints before any live feed answers.
*
* 1. IndexedDB: the last live situations() result in this browser
* 2. else the bundled snapshot, mobile/demo/snapshot.json (real data,
* captured by mobile/demo/capture-snapshot.mjs; see `capturedAt`)
*
* Thumbnails: the last rendered JPEG per situation id from the Cache API
* (secure contexts), else the bundled mobile/demo/thumbs/<id>.jpg.
*/
import { idbGet, idbPut } from './idb.js';
const SNAPSHOT_URL = new URL('../demo/snapshot.json', import.meta.url).href;
const BUNDLED_THUMBS = import.meta.glob('../demo/thumbs/*.jpg', {
eager: true,
query: '?url',
import: 'default',
});
const LAST_CACHE = 'gev-thumbs-last-v1';
const cacheApi = () =>
typeof caches !== 'undefined' && globalThis.isSecureContext;
const bundledThumb = new Map();
for (const [path, url] of Object.entries(BUNDLED_THUMBS)) {
const id = decodeURIComponent(
path
.split('/')
.pop()
.replace(/\.jpg$/, ''),
);
bundledThumb.set(id, url);
}
const cachedThumb = new Map(); // id → blob: URL from the Cache API
export function thumbFileName(id) {
return `${String(id).replace(/[^\w.-]+/g, '_')}.jpg`;
}
function bundledFor(id) {
return (
bundledThumb.get(thumbFileName(id).replace(/\.jpg$/, '')) ||
bundledThumb.get(id) ||
null
);
}
/** Sync: the best cached thumbnail URL for a situation, or null. */
export function cachedThumbnail(situation) {
const id = situation?.id;
if (!id) return null;
return cachedThumb.get(id) || bundledFor(id) || situation.thumbUrl || null;
}
/** Remember the last rendered thumbnail for an id (Cache API). */
export async function rememberThumbnail(id, blob) {
if (!cacheApi() || !blob) return;
try {
const c = await caches.open(LAST_CACHE);
await c.put(
`${location.origin}/__gev-last/${encodeURIComponent(id)}`,
new Response(blob, { headers: { 'Content-Type': 'image/jpeg' } }),
);
} catch {
/* quota or private mode */
}
}
async function hydrateThumbs(list) {
if (!cacheApi()) return;
try {
const c = await caches.open(LAST_CACHE);
await Promise.all(
list.map(async (s) => {
if (cachedThumb.has(s.id)) return;
const hit = await c.match(
`${location.origin}/__gev-last/${encodeURIComponent(s.id)}`,
);
if (hit) cachedThumb.set(s.id, URL.createObjectURL(await hit.blob()));
}),
);
} catch {
/* ignore */
}
}
let bundledPromise = null;
function loadBundled() {
if (!bundledPromise)
bundledPromise = fetch(SNAPSHOT_URL)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
return bundledPromise;
}
// Start the bundled fetch at import so a cached call never waits on it.
loadBundled();
function decorate(list, capturedAt, origin) {
return list.map((s) => ({
...s,
cached: true,
cacheOrigin: origin,
capturedAt,
live: false,
thumbUrl: cachedThumb.get(s.id) || bundledFor(s.id) || s.thumbUrl || null,
}));
}
/**
* The last good situations, fast. Resolves [] only when there is nothing at
* all (no IndexedDB snapshot and no bundled file).
*/
export async function loadCachedSituations() {
const stored = await Promise.race([
idbGet('kv', 'situations'),
new Promise((r) => setTimeout(() => r(null), 60)),
]);
if (stored?.list?.length) {
hydrateThumbs(stored.list);
return decorate(stored.list, stored.capturedAt, 'device');
}
const bundled = await loadBundled();
if (bundled?.situations?.length)
return decorate(
bundled.situations,
Date.parse(bundled.capturedAt) || bundled.capturedAt,
'bundled',
);
return [];
}
/** Store a live situations() result as the next first frame. */
export function saveSituations(list) {
if (!list?.length) return;
const clean = list.map((s) => {
const { thumbUrl, cached, cacheOrigin, ...rest } = s;
void thumbUrl;
void cached;
void cacheOrigin;
return rest;
});
idbPut('kv', 'situations', { capturedAt: Date.now(), list: clean }).catch(
() => {},
);
}
export const hasBundledThumb = (id) => Boolean(bundledFor(id));
+54
View File
@@ -24,6 +24,7 @@ import { cctvFrameUrl } from './feeds.js';
import { aqiColor, sampleField } from './feedsWeather.js';
import { strikeLevel } from './layers/weather.js';
import { BLACK_MARBLE_URL } from './imagery.js';
import { cachedThumbnail, rememberThumbnail } from './cache.js';
const D2R = Math.PI / 180;
const ESRI_TILE = (z, x, y) =>
@@ -1469,6 +1470,53 @@ const SCENE_DRAW = {
vignette(g, W, H, 0.5);
},
/** Detected circling: each aircraft's real recorded orbit track. */
async circling(g, view, sc, dpr) {
await base(g, view, 'sky');
tint(g, view.W, view.H, 'sky', 0.38);
const colors = [
'rgba(94,200,255,1)',
'rgba(255,209,102,1)',
'rgba(255,159,107,1)',
'rgba(155,225,93,1)',
'rgba(201,167,255,1)',
];
const [cx, cy] = view.project(sc.lon, sc.lat);
for (const km of [0.5, 1, 1.5]) {
g.beginPath();
g.arc(cx, cy, km * view.pxPerKm, 0, Math.PI * 2);
g.strokeStyle = 'rgba(160,210,255,0.28)';
g.setLineDash([2 * dpr, 5 * dpr]);
g.lineWidth = 1 * dpr;
g.stroke();
}
g.setLineDash([]);
(sc.tracks || []).forEach((tr, i) => {
const css = colors[i % colors.length];
if (tr.points?.length > 1) {
pathLonLat(g, view, tr.points);
glowStroke(g, css.replace(',1)', ',0.85)'), 2.2 * dpr, 10 * dpr);
}
const last = tr.points?.at(-1);
if (last) {
const [x, y] = view.project(last[0], last[1]);
if (tr.helicopter) heliGlyph(g, x, y, 15 * dpr, css);
else planeGlyph(g, x, y, tr.heading || 0, 13 * dpr, css);
}
});
g.save();
g.globalCompositeOperation = 'lighter';
g.drawImage(
glowSprite('rgba(255,209,102,0.9)', 64),
cx - 14 * dpr,
cy - 14 * dpr,
28 * dpr,
28 * dpr,
);
g.restore();
vignette(g, view.W, view.H);
},
async plain(g, view, sc) {
await base(g, view, sc.band || 'ground');
tint(g, view.W, view.H, sc.band || 'ground');
@@ -1556,6 +1604,11 @@ export function createCompositor({ feeds }) {
const d = Math.max(1, Math.min(3, dpr));
const W = Math.round(width * d);
const H = Math.round(height * d);
// A cached first-frame card has no scene: hand back its stored JPEG.
if (situation.cached && !SCENES.has(situation.id)) {
const url = cachedThumbnail(situation);
if (url) return Promise.resolve(url);
}
const key = keyFor(situation, W, H, d);
let p = mem.get(key);
if (p) return p;
@@ -1571,6 +1624,7 @@ export function createCompositor({ feeds }) {
}
}
const blob = await render(situation, W, H, d);
rememberThumbnail(situation.id, blob);
if (cacheApi)
caches
.open(CACHE_NAME)
+307
View File
@@ -0,0 +1,307 @@
/**
* Detection on real data (DESIGN.md §12.4). Runs over the recorder, never
* over anything invented: when nothing in the record matches, nothing is
* raised.
*
* Circling: an aircraft "orbits" when, within the last ~20 min, its ground
* track turns through at least 360° in one direction while its positions stay
* within ~1.5 km of their centroid. Rotorcraft and low-and-slow contacts are
* preferred (airliners in a holding stack are excluded by altitude and
* speed). Two or more orbiters whose centres lie within ~1.5 km of a common
* centre make a detection: "3 helicopters circling Midtown Manhattan". A
* single aircraft on its second full orbit is a lesser detection
* ("Helicopter circling Lower Manhattan").
*
* Quake clusters: three or more M2.5+ events within 50 km of each other in
* 24 h, from the USGS feed.
*/
import { haversineKm } from './geo.js';
import { localPlace, describePlace } from './gazetteer.js';
const WINDOW_MS = 20 * 60_000;
const MAX_CENTER_KM = 1.5;
const MAX_ALT_M = 2400; // ~8,000 ft
const MAX_SPEED_MPS = 80; // ~155 kt
const D2R = Math.PI / 180;
function bearing(a, b) {
const dx = (b.lon - a.lon) * Math.cos(a.lat * D2R);
const dy = b.lat - a.lat;
return (Math.atan2(dx, dy) / D2R + 360) % 360;
}
const metres = (a, b) =>
Math.hypot(
(b.lon - a.lon) * 111_320 * Math.cos(a.lat * D2R),
(b.lat - a.lat) * 110_574,
);
/**
* Orbit analysis of one track (samples sorted by t). Returns the most recent
* full orbit: { center:{lon,lat}, radiusM, turns, dir, from, to, points } or null.
*/
export function analyseOrbit(samples) {
if (samples.length < 6) return null;
// Thin to points at least 60 m apart so jitter does not fake turning.
const pts = [samples[0]];
for (const p of samples) if (metres(pts.at(-1), p) >= 60) pts.push(p);
if (pts.length < 6) return null;
// Walk backwards from the newest point accumulating signed turn.
let turn = 0;
let prevB = bearing(pts.at(-2), pts.at(-1));
let startIdx = -1;
for (let i = pts.length - 2; i >= 1; i--) {
// A gap in the record breaks the orbit.
if (pts[i + 1].t - pts[i].t > 4 * 60_000) break;
const b = bearing(pts[i - 1], pts[i]);
let d = ((prevB - b + 540) % 360) - 180;
// A step that turns > 150° is a sampling artefact, not a turn.
if (Math.abs(d) > 150) d = 0;
turn += d;
prevB = b;
if (Math.abs(turn) >= 360) {
startIdx = i - 1;
break;
}
}
if (startIdx < 0) return null;
const orbit = pts.slice(startIdx);
let lon = 0;
let lat = 0;
for (const p of orbit) {
lon += p.lon;
lat += p.lat;
}
const center = { lon: lon / orbit.length, lat: lat / orbit.length };
let r = 0;
let rMax = 0;
for (const p of orbit) {
const d = metres(center, p);
r += d;
rMax = Math.max(rMax, d);
}
r /= orbit.length;
if (r > MAX_CENTER_KM * 1000 || rMax > MAX_CENTER_KM * 1600) return null;
if (r < 80) return null; // hovering jitter, not an orbit
// Count full turns over the whole window (for "on its 6th orbit").
let total = 0;
for (let i = 2; i < pts.length; i++) {
let d =
((bearing(pts[i - 1], pts[i]) - bearing(pts[i - 2], pts[i - 1]) + 540) %
360) -
180;
if (Math.abs(d) > 150) d = 0;
total += d;
}
return {
center,
radiusM: Math.round(r),
turns: Math.max(1, Math.floor(Math.abs(total) / 360)),
dir: turn > 0 ? 'cw' : 'ccw',
from: orbit[0].t,
to: orbit.at(-1).t,
};
}
/** "near Port-Olry, Vanuatu" from a USGS place, else from the gazetteer. */
function clusterPlace(q) {
const usgs = q.place ? q.place.replace(/^.*\bof\s+/, '').trim() : '';
if (usgs) return `near ${usgs}`;
const p = describePlace(q.lat, q.lon);
if (!p) return '';
return /\bkm\b/.test(p) ? `, ${p}` : `near ${p}`;
}
const isCandidate = (meta, s) =>
meta &&
meta.kind !== 'vessel' &&
!meta.onGround &&
s.altM < MAX_ALT_M &&
(meta.helicopter || s.speed < MAX_SPEED_MPS);
/**
* @param {ReturnType<import('./recorder.js').createRecorder>} recorder
*/
export function createDetector(recorder) {
/** Detections by id (kept while they last, plus a history for pips). */
const live = new Map();
const history = [];
let lastRun = 0;
let lastOrbiters = [];
function circling(now = Date.now()) {
const from = now - WINDOW_MS;
const orbiters = [];
for (const key of recorder.keys()) {
const meta = recorder.meta(key);
if (!meta || meta.kind === 'vessel') continue;
const samples = recorder.samples(key, from, now);
if (samples.length < 6) continue;
const lastS = samples.at(-1);
if (now - lastS.t > 5 * 60_000) continue; // gone quiet
if (!isCandidate(meta, lastS)) continue;
const orbit = analyseOrbit(samples);
if (orbit) orbiters.push({ key, meta, orbit, last: lastS });
}
lastOrbiters = orbiters;
// Greedy clustering on orbit centres.
const used = new Set();
const groups = [];
orbiters.sort(
(a, b) =>
Number(b.meta.helicopter) - Number(a.meta.helicopter) ||
b.orbit.turns - a.orbit.turns,
);
for (const a of orbiters) {
if (used.has(a.key)) continue;
const g = [a];
for (const b of orbiters) {
if (b === a || used.has(b.key)) continue;
const km = haversineKm(
a.orbit.center.lat,
a.orbit.center.lon,
b.orbit.center.lat,
b.orbit.center.lon,
);
if (km <= MAX_CENTER_KM) g.push(b);
}
if (g.length >= 2) {
for (const m of g) used.add(m.key);
groups.push(g);
}
}
// A single aircraft on its second full orbit is a detection too (a
// police or news helicopter holding over a scene), ranked below groups.
const singles = orbiters
.filter((a) => !used.has(a.key) && a.orbit.turns >= 2)
.sort((a, b) => b.orbit.turns - a.orbit.turns)
.slice(0, 4);
for (const a of singles) groups.push([a]);
const out = [];
for (const g of groups) {
let lon = 0;
let lat = 0;
for (const m of g) {
lon += m.orbit.center.lon;
lat += m.orbit.center.lat;
}
lon /= g.length;
lat /= g.length;
const helis = g.filter((m) => m.meta.helicopter).length;
const place = localPlace(lat, lon);
const noun =
g.length === 1
? helis
? 'Helicopter'
: 'Aircraft'
: helis === g.length
? 'helicopters'
: helis
? 'aircraft'
: 'low aircraft';
const id = `circling-${lat.toFixed(2)}_${lon.toFixed(2)}`;
const firstOrbit = Math.min(...g.map((m) => m.orbit.from));
const prev = live.get(id);
const det = {
id,
kind: 'circling',
title:
g.length === 1
? `${noun} circling ${place.name}`
: `${g.length} ${noun} circling ${place.name}`,
single: g.length === 1,
place: place.name,
lon,
lat,
since: prev?.since ?? firstOrbit,
updatedAt: now,
members: g.map((m) => ({
key: m.key,
layer: m.meta.layer,
callsign: m.meta.callsign || m.key,
helicopter: Boolean(m.meta.helicopter),
radiusM: m.orbit.radiusM,
turns: m.orbit.turns,
dir: m.orbit.dir,
altM: Math.round(m.last.altM),
speed: m.last.speed,
lon: m.last.lon,
lat: m.last.lat,
})),
};
live.set(id, det);
if (!prev)
history.push({
t: firstOrbit,
kind: 'circling',
title: det.title,
situationId: id,
momentId: `live-${id}`,
lon,
lat,
});
out.push(det);
}
// Detections that stopped circling linger 10 min, then go.
for (const [id, d] of live)
if (now - d.updatedAt > 10 * 60_000) live.delete(id);
if (history.length > 200) history.splice(0, history.length - 200);
return out;
}
function quakeClusters(records, now = Date.now()) {
const recent = (records || []).filter(
(q) => Number.isFinite(q.time) && now - q.time < 86_400_000,
);
const used = new Set();
const out = [];
const sorted = recent.slice().sort((a, b) => b.mag - a.mag);
for (const q of sorted) {
if (used.has(q.stableId)) continue;
const near = recent.filter(
(o) =>
!used.has(o.stableId) &&
haversineKm(q.lat, q.lon, o.lat, o.lon) <= 50,
);
if (near.length < 3) continue;
for (const o of near) used.add(o.stableId);
const times = near.map((o) => o.time).sort((a, b) => a - b);
out.push({
id: `quake-cluster-${q.stableId}`,
kind: 'quake-cluster',
title: `${near.length} earthquakes in 24 h ${clusterPlace(q)}`.trim(),
lon: q.lon,
lat: q.lat,
maxMag: q.mag,
count: near.length,
since: times[0],
updatedAt: times.at(-1),
members: near.map((o) => ({
id: o.stableId,
mag: o.mag,
time: o.time,
lon: o.lon,
lat: o.lat,
place: o.place,
})),
});
}
return out;
}
return {
/** Run the circling detector at most every `minGapMs`. */
run({ force = false, minGapMs = 20_000 } = {}) {
const now = Date.now();
if (!force && now - lastRun < minGapMs) return [...live.values()];
lastRun = now;
circling(now);
return [...live.values()];
},
current: () => [...live.values()],
get: (id) => live.get(id) || null,
history: () => history.slice(),
/** Single orbiters seen in the last run (diagnostics and dossiers). */
orbiters: () => lastOrbiters.slice(),
quakeClusters,
};
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Situation cards for detections (circling, quake clusters). Same shape as
* situations.js cards, each with a compositor scene of its real data.
*/
import { SCENES } from './situations.js';
import { thumbnailAround } from './geo.js';
const M_TO_FT = 3.28084;
const MPS_TO_KT = 1 / 0.514444;
const hhmm = (ms) => new Date(ms).toISOString().slice(11, 16) + 'Z';
const fmtLL = (lat, lon) =>
`${Math.abs(lat).toFixed(2)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(2)}°${lon >= 0 ? 'E' : 'W'}`;
export function circlingCard(det, recorder) {
const helis = det.members.filter((m) => m.helicopter).length;
const alts = det.members.map((m) => m.altM).sort((a, b) => a - b);
const radii = det.members.map((m) => m.radiusM).sort((a, b) => a - b);
const turns = det.members.reduce((n, m) => n + m.turns, 0);
const lead = det.members[0];
const ft = (m) => Math.round((m * M_TO_FT) / 50) * 50;
const id = det.id;
const card = {
id,
kind: 'circling',
family: 'Sky',
section: 'now',
band: 'sky',
archetype: 'track',
telemetry: [
'SKY',
`${det.members.length} ${helis === det.members.length ? 'ROTOR' : 'AIRCRAFT'} ORBITING`,
`R ${radii[0]}–${radii.at(-1)} M`,
`${ft(alts[0]).toLocaleString('en-US')}–${ft(alts.at(-1)).toLocaleString('en-US')} FT`,
lead?.callsign ? String(lead.callsign).toUpperCase() : null,
]
.filter(Boolean)
.join(' · '),
title: det.title,
subtitle: `${det.members.map((m) => m.callsign).join(', ')} · ${turns} orbits since ${hhmm(det.since)} · detected from ADS-B`,
place: det.place,
lon: det.lon,
lat: det.lat,
camera: {
lon: det.lon,
lat: det.lat,
height: 2600,
heading: 25,
pitch: -32,
},
layers: ['helicopters', 'flights', 'ga', 'military'],
time: { start: det.since, end: det.updatedAt },
metrics: [
{ label: 'Aircraft', value: det.members.length, unit: '' },
{ label: 'Orbits', value: turns, unit: '' },
{
label: 'Radius',
value: radii[Math.floor(radii.length / 2)],
unit: 'm',
},
...(lead?.speed
? [
{
label: 'Speed',
value: Math.round(lead.speed * MPS_TO_KT),
unit: 'kt',
},
]
: []),
],
headline: det.single
? { value: turns, unit: '', label: 'orbits' }
: {
value: det.members.length,
unit: '',
label:
helis === det.members.length
? 'helicopters circling'
: 'aircraft circling',
},
thumbnail: thumbnailAround(det.lon, det.lat, 2),
live: true,
updatedAt: det.updatedAt,
source:
'Detected by God’s Eye from live ADS-B (OpenSky Network / adsb.lol, ODbL)',
severity: det.single ? 4.5 : 6.5,
momentId: `live-${det.id}`,
members: det.members.map(({ key, layer, callsign, helicopter }) => ({
key,
layer,
callsign,
helicopter,
})),
entityIds: det.members.map((m) => `${m.layer || 'flights'}:${m.key}`),
detected: true,
};
SCENES.set(id, {
type: 'circling',
lon: det.lon,
lat: det.lat,
spanKm: 3.2,
band: 'sky',
tracks: det.members.map((m) => {
const pts = recorder
.samples(m.key, det.updatedAt - 20 * 60_000, det.updatedAt)
.map((p) => [p.lon, p.lat]);
return { points: pts, helicopter: m.helicopter, heading: 0 };
}),
});
void fmtLL;
return card;
}
export function quakeClusterCard(c) {
const id = c.id;
const card = {
id,
kind: 'quake-cluster',
family: 'Hazards',
section: 'now',
band: 'ground',
archetype: 'number',
telemetry: `GROUND · ${c.count} EVENTS · 24 H · MAX M${c.maxMag.toFixed(1)} · 50 KM`,
title: c.title,
subtitle: `Largest M${c.maxMag.toFixed(1)} · first ${hhmm(c.since)} · USGS`,
place: c.members[0]?.place || fmtLL(c.lat, c.lon),
lon: c.lon,
lat: c.lat,
camera: { lon: c.lon, lat: c.lat, height: 260_000, heading: 0, pitch: -55 },
layers: ['earthquakes'],
time: { start: c.since, end: c.updatedAt },
metrics: [
{ label: 'Quakes', value: c.count, unit: '' },
{ label: 'Largest', value: c.maxMag.toFixed(1), unit: 'M' },
],
headline: { value: c.count, unit: '', label: 'quakes in 24 h' },
series: c.members
.slice()
.sort((a, b) => a.time - b.time)
.map((m) => ({ t: m.time, v: m.mag })),
seriesLabel: 'magnitude per event',
thumbnail: thumbnailAround(c.lon, c.lat, 60),
live: true,
updatedAt: c.updatedAt,
source: 'U.S. Geological Survey · cluster detected by God’s Eye',
severity: 3 + Math.max(0, c.maxMag - 4),
detected: true,
};
SCENES.set(id, {
type: 'quake',
lon: c.lon,
lat: c.lat,
spanKm: 160,
band: 'ground',
quake: { lon: c.lon, lat: c.lat, mag: c.maxMag },
nearby: c.members.map((m) => ({ lon: m.lon, lat: m.lat, mag: m.mag })),
});
return card;
}
+25
View File
@@ -59,3 +59,28 @@ if (params.get('labels') !== '0') {
globe.on('camera', drawLabels);
setInterval(drawLabels, 1000);
}
// ?moment=empire-state&speed=30 plays a Moment on load; its events print in the HUD.
const momentLines = [];
globe.on('moment', (e) => {
if (!showHud) return;
const it = e.item || {};
const line =
e.type === 'chat'
? `💬 ${it.handle}: ${it.text}`
: e.type === 'post'
? `▤ ${it.kind} · ${it.handle}: ${it.text}`
: e.type === 'capture'
? `◉ ${it.firstEyes ? 'FIRST EYES ' : ''}@${it.handle} · ${it.title}`
: e.type === 'beat'
? `● ${it.title}`
: `[${e.type}]`;
momentLines.push(line);
while (momentLines.length > 8) momentLines.shift();
hud.textContent = momentLines.join('\n');
});
if (params.get('moment'))
globe.playMoment(params.get('moment'), {
speed: Number(params.get('speed')) || undefined,
autoplay: params.get('autoplay') !== '0',
});
File diff suppressed because it is too large. Load diff
+5
View File
@@ -208,6 +208,11 @@ export function createMilitaryFeed() {
cls,
helicopter: cls === 'helicopter',
military: true,
// adsb.lol dbFlags: 4 = PIA, 8 = LADD (owner privacy programmes).
pia: Boolean(a.dbFlags & 4),
ladd: Boolean(a.dbFlags & 8),
seenAt:
Date.now() - (finite(a.seen_pos) ?? finite(a.seen) ?? 0) * 1000,
});
}
return {
+92
View File
@@ -245,3 +245,95 @@ export function describePlace(lat, lon, list) {
if (p.km < 15) return p.name;
return `${p.km.toLocaleString('en-US')} km ${p.dir} of ${p.name}`;
}
/**
* Neighbourhoods and landmarks in the cities where helicopters gather
* (news, police, tours) — [name, lon, lat, radiusKm]. Public centroids,
* rounded. Used to name a detection ("circling Midtown Manhattan").
*/
export const NEIGHBORHOODS = Object.freeze([
['Midtown Manhattan', -73.9851, 40.7549, 1.6],
['Lower Manhattan', -74.0091, 40.7075, 1.4],
['Upper East Side', -73.9566, 40.7736, 1.5],
['Upper West Side', -73.9754, 40.787, 1.5],
['Central Park', -73.9654, 40.7829, 1.2],
['Harlem', -73.9465, 40.8116, 1.6],
['Chelsea, Manhattan', -74.0014, 40.7465, 0.9],
['Greenwich Village', -74.0037, 40.7336, 0.9],
['the Lower East Side', -73.9861, 40.715, 1.0],
['Washington Heights', -73.9393, 40.8417, 1.4],
['the South Bronx', -73.9162, 40.8166, 2.0],
['the Bronx', -73.8648, 40.8448, 4.0],
['Downtown Brooklyn', -73.9857, 40.6928, 1.2],
['Williamsburg, Brooklyn', -73.9573, 40.7081, 1.4],
['Brooklyn', -73.9442, 40.6782, 5.0],
['Long Island City', -73.9485, 40.7447, 1.4],
['Queens', -73.7949, 40.7282, 6.0],
['Staten Island', -74.1502, 40.5795, 6.0],
['Jersey City', -74.0776, 40.7282, 2.5],
['Hoboken', -74.0324, 40.744, 1.0],
['Newark', -74.1724, 40.7357, 3.0],
['JFK Airport', -73.7781, 40.6413, 3.0],
['LaGuardia Airport', -73.874, 40.7769, 1.6],
['Newark Airport', -74.1745, 40.6895, 2.5],
['Downtown Los Angeles', -118.2468, 34.0407, 2.0],
['Hollywood', -118.3287, 34.0928, 2.0],
['Koreatown, Los Angeles', -118.3009, 34.0618, 1.5],
['South Los Angeles', -118.2827, 33.9897, 3.5],
['Santa Monica', -118.4912, 34.0195, 2.5],
['Venice, Los Angeles', -118.4695, 33.985, 1.6],
['Long Beach', -118.1937, 33.7701, 4.0],
['Pasadena', -118.1445, 34.1478, 3.0],
['Burbank', -118.309, 34.1808, 3.0],
['the San Fernando Valley', -118.45, 34.2, 8.0],
['Inglewood', -118.3531, 33.9617, 2.5],
['Compton', -118.2201, 33.8958, 2.5],
['LAX', -118.4085, 33.9416, 2.5],
['Anaheim', -117.9145, 33.8366, 3.5],
['Westminster, London', -0.1357, 51.4975, 1.4],
['the City of London', -0.0918, 51.5155, 1.2],
['Canary Wharf', -0.0235, 51.5054, 1.2],
['Camden, London', -0.1426, 51.539, 1.6],
['Hackney, London', -0.0553, 51.545, 2.0],
['Brixton, London', -0.1145, 51.4613, 1.6],
['Stratford, London', -0.0035, 51.5413, 1.6],
['Croydon', -0.0982, 51.3762, 3.0],
['Heathrow', -0.4543, 51.47, 3.0],
['the Loop, Chicago', -87.6298, 41.8837, 1.4],
['the South Side, Chicago', -87.62, 41.78, 5.0],
['the West Side, Chicago', -87.72, 41.88, 4.0],
['Downtown San Francisco', -122.4014, 37.7881, 1.5],
['the Mission, San Francisco', -122.4194, 37.7599, 1.4],
['Oakland', -122.2711, 37.8044, 4.0],
['Downtown Seattle', -122.3321, 47.6062, 1.5],
['Downtown Houston', -95.3698, 29.7604, 2.0],
['Downtown Dallas', -96.797, 32.7767, 2.0],
['Downtown Miami', -80.1918, 25.7743, 1.6],
['Miami Beach', -80.13, 25.7907, 2.5],
['Downtown Atlanta', -84.388, 33.749, 2.0],
['Downtown Phoenix', -112.074, 33.4484, 2.5],
['Las Vegas Strip', -115.1728, 36.1147, 2.5],
['Downtown Toronto', -79.3832, 43.6532, 2.0],
['Central Paris', 2.3488, 48.8534, 2.5],
['La Défense', 2.2385, 48.8918, 1.4],
['Mitte, Berlin', 13.4049, 52.52, 2.5],
['Central Sydney', 151.2093, -33.8688, 2.0],
['Shinjuku, Tokyo', 139.7036, 35.6938, 2.0],
['Chiyoda, Tokyo', 139.7536, 35.6938, 2.0],
]);
/**
* The most local name for a point: a neighbourhood when it is inside one,
* else "N km DIR of City". Returns { name, local:boolean }.
*/
export function localPlace(lat, lon) {
let best = null;
for (const [name, plon, plat, r] of NEIGHBORHOODS) {
if (Math.abs(plat - lat) > 0.2) continue;
const km = haversineKm(lat, lon, plat, plon);
if (km <= r * 1.35 && (!best || km / r < best.score))
best = { name, score: km / r };
}
if (best) return { name: best.name, local: true };
return { name: describePlace(lat, lon), local: false };
}
+172
View File
@@ -0,0 +1,172 @@
/**
* History view (DESIGN.md §12.5): when the clock is in the recorded past,
* every recorded contact is drawn where it actually was, interpolated
* between polls, with a ghost trail of the four minutes before.
*
* One BillboardCollection (glyphs move in place, keyed by entity) and one
* PolylineCollection for trails, rebuilt at most 4×/s and only for the
* contacts nearest the view centre.
*/
import * as Cesium from 'cesium';
import { glyph } from './glyphs.js';
const D2R = Math.PI / 180;
const GLYPH_CAP = 900;
const TRAIL_CAP = 160;
const TRAIL_MS = 4 * 60_000;
const NEAR_FAR = new Cesium.NearFarScalar(2e4, 1.2, 3e6, 0.6);
const COLORS = {
aircraft: Cesium.Color.fromCssColorString('#BFE6FF'),
helicopter: Cesium.Color.fromCssColorString('#9BE15D'),
military: Cesium.Color.fromCssColorString('#FFD166'),
vessel: Cesium.Color.fromCssColorString('#38E1C6'),
emergency: Cesium.Color.fromCssColorString('#FF3B30'),
};
export function createGhosts({ scene, recorder, requestRender }) {
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
root.show = false;
const glyphs = root.add(new Cesium.BillboardCollection());
const trails = root.add(new Cesium.PolylineCollection());
const byKey = new Map(); // key → { bb, state }
let lastTrails = 0;
let shown = false;
let lastMs = 0;
const colorOf = (meta) =>
meta?.emergency ? COLORS.emergency : COLORS[meta?.kind] || COLORS.aircraft;
function hide() {
if (!shown) return;
shown = false;
root.show = false;
glyphs.removeAll();
trails.removeAll();
byKey.clear();
requestRender();
}
function update(ms, view, { force = false } = {}) {
lastMs = ms;
const rect = view?.rect
? {
west: view.rect.west - 0.5,
east: view.rect.east + 0.5,
south: view.rect.south - 0.5,
north: view.rect.north + 0.5,
}
: null;
const list = recorder.snapshotAt(ms, { rect });
const c = view?.center || { lon: 0, lat: 0 };
const cos = Math.cos(c.lat * D2R);
for (const s of list)
s.d = (s.lat - c.lat) ** 2 + ((s.lon - c.lon) * cos) ** 2;
list.sort((a, b) => a.d - b.d);
const keep = list.slice(0, GLYPH_CAP);
const seen = new Set();
for (const s of keep) {
seen.add(s.key);
let e = byKey.get(s.key);
const pos = Cesium.Cartesian3.fromDegrees(s.lon, s.lat, s.altM || 0);
if (!e) {
const g = glyph(
s.meta?.kind === 'vessel'
? 'ship'
: s.meta?.helicopter
? 'heli'
: 'plane',
);
e = {
bb: glyphs.add({
position: pos,
image: g.image,
imageId: g.imageId,
color: colorOf(s.meta).withAlpha(0.92),
scale: s.meta?.helicopter ? 0.55 : 0.46,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
scaleByDistance: NEAR_FAR,
disableDepthTestDistance: 150_000,
id: { layer: 'history', key: s.key },
}),
};
byKey.set(s.key, e);
} else e.bb.position = pos;
e.bb.rotation = -(s.headingDeg || 0) * D2R;
e.state = s;
}
for (const [key, e] of byKey)
if (!seen.has(key)) {
glyphs.remove(e.bb);
byKey.delete(key);
}
const now = performance.now();
if (force || now - lastTrails > 250) {
lastTrails = now;
trails.removeAll();
for (const s of keep.slice(0, TRAIL_CAP)) {
const pts = recorder.trail(s.key, ms - TRAIL_MS, ms, 60);
if (pts.length < 2) continue;
trails.add({
positions: pts.map(([lo, la, h]) =>
Cesium.Cartesian3.fromDegrees(lo, la, h || 0),
),
width: 2.5,
material: Cesium.Material.fromType('PolylineGlow', {
color: colorOf(s.meta).withAlpha(0.7),
glowPower: 0.25,
taperPower: 0.4,
}),
});
}
}
shown = true;
root.show = true;
requestRender();
return keep.length;
}
return {
update,
hide,
get shown() {
return shown;
},
get glyphs() {
return glyphs;
},
pick(key) {
const e = byKey.get(key);
if (!e?.state) return null;
const s = e.state;
const m = s.meta || {};
const layer = m.layer || 'flights';
return {
kind: m.kind || 'aircraft',
id: key,
entityId: `${layer}:${key}`,
title: m.callsign || m.title || key.toUpperCase(),
subtitle: [
'Recorded',
new Date(lastMs).toISOString().slice(11, 19) + 'Z',
`${Math.round((s.altM * 3.28084) / 100) * 100} ft`,
`${Math.round(s.speed / 0.514444)} kt`,
].join(' · '),
lon: s.lon,
lat: s.lat,
meta: {
layer: 'history',
entityLayer: layer,
icao24: key,
altitudeM: s.altM,
speedMps: s.speed,
headingDeg: s.headingDeg,
recordedAt: lastMs,
},
};
},
destroy() {
scene.primitives.remove(root);
},
};
}
+124
View File
@@ -0,0 +1,124 @@
/**
* A minimal promise wrapper over IndexedDB for the engine's own stores
* (recorder segments, the last good situations snapshot). Every call fails
* soft: no IndexedDB (private mode, insecure frames) resolves to null.
*/
const DB_NAME = 'gev-globe';
const DB_VERSION = 1;
export const STORES = Object.freeze(['seg', 'meta', 'kv']);
let dbPromise = null;
function open() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
let req;
try {
req = globalThis.indexedDB?.open(DB_NAME, DB_VERSION);
} catch {
resolve(null);
return;
}
if (!req) {
resolve(null);
return;
}
req.onupgradeneeded = () => {
const db = req.result;
for (const name of STORES)
if (!db.objectStoreNames.contains(name)) db.createObjectStore(name);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
req.onblocked = () => resolve(null);
});
return dbPromise;
}
function done(req) {
return new Promise((resolve) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
}
export async function idbGet(store, key) {
const db = await open();
if (!db) return null;
try {
return await done(db.transaction(store).objectStore(store).get(key));
} catch {
return null;
}
}
export async function idbPut(store, key, value) {
const db = await open();
if (!db) return false;
try {
await done(
db.transaction(store, 'readwrite').objectStore(store).put(value, key),
);
return true;
} catch {
return false;
}
}
/** Put many [key, value] pairs in one transaction. */
export async function idbPutMany(store, entries) {
const db = await open();
if (!db || !entries.length) return false;
return new Promise((resolve) => {
try {
const tx = db.transaction(store, 'readwrite');
const os = tx.objectStore(store);
for (const [k, v] of entries) os.put(v, k);
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
tx.onabort = () => resolve(false);
} catch {
resolve(false);
}
});
}
/** All [key, value] pairs with lower <= key (numeric keys). */
export async function idbEntries(store, lower = null) {
const db = await open();
if (!db) return [];
return new Promise((resolve) => {
const out = [];
try {
const range =
lower == null ? null : globalThis.IDBKeyRange.lowerBound(lower);
const req = db.transaction(store).objectStore(store).openCursor(range);
req.onsuccess = () => {
const c = req.result;
if (!c) return resolve(out);
out.push([c.key, c.value]);
c.continue();
};
req.onerror = () => resolve(out);
} catch {
resolve(out);
}
});
}
/** Delete keys below `upper` (numeric keys). */
export async function idbDeleteBelow(store, upper) {
const db = await open();
if (!db) return false;
try {
await done(
db
.transaction(store, 'readwrite')
.objectStore(store)
.delete(globalThis.IDBKeyRange.upperBound(upper, true)),
);
return true;
} catch {
return false;
}
}
+918 -13
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -129,8 +129,12 @@ export class AircraftLayer extends Layer {
}
onTime(ms, live) {
// Aircraft are live-only: away from now they dim to show they are not
// Inside the recorded past the history layer draws where everything
// actually was, and inside a Moment its stage owns the sky: the live
// snapshot hides. Beyond the record, aircraft dim to show they are not
// a reconstruction of that moment.
const mode = this.ctx.timeMode?.(ms, live) || 'none';
this.root.show = this.on && mode === 'none';
this.renderer.setAlpha(live ? 1 : 0.3);
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Moments (DESIGN.md §12.4): 4D reconstructions on one clock.
*
* bundled the hero, "Climbers on the Empire State Building"
* (mobile/demo/moments/empire-state.json, illustrative: true)
* live built on demand from the recorder for every detection
* (circling, emergency squawk): real tracks, illustrative: false
*/
import { localPlace } from './gazetteer.js';
const HERO_URL = new URL('../demo/moments/empire-state.json', import.meta.url)
.href;
const BUILDINGS_URL = new URL('../demo/midtown-buildings.json', import.meta.url)
.href;
const D2R = Math.PI / 180;
let heroPromise = null;
let buildingsPromise = null;
export function loadHero() {
if (!heroPromise)
heroPromise = fetch(HERO_URL)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.status))))
.catch((error) => {
heroPromise = null;
throw error;
});
return heroPromise;
}
export function loadBuildings(name) {
if (name !== 'midtown-buildings.json') return Promise.resolve(null);
if (!buildingsPromise)
buildingsPromise = fetch(BUILDINGS_URL)
.then((r) => (r.ok ? r.json() : null))
.catch(() => {
buildingsPromise = null;
return null;
});
return buildingsPromise;
}
/** A slow orbit of camera keys around a point (for live Moments). */
export function orbitCameraPath(
lon,
lat,
start,
end,
{ rangeM = 2600, heightM = 1100, lookAltM = 250, startBearing = 200 } = {},
) {
const keys = [];
const n = 9;
const kx = 111_320 * Math.cos(lat * D2R);
for (let i = 0; i < n; i++) {
const f = i / (n - 1);
const b = (startBearing + 140 * f) % 360;
const r = rangeM * (1.2 - 0.35 * Math.sin(Math.PI * f));
const h = heightM * (1.15 - 0.3 * Math.sin(Math.PI * f));
const x = Math.sin(b * D2R) * r;
const y = Math.cos(b * D2R) * r;
keys.push({
t: Math.round(start + (end - start) * f),
lon: lon + x / kx,
lat: lat + y / 110_574,
height: Math.round(h),
heading: (b + 180) % 360,
pitch: (Math.atan2(lookAltM - h, r) * 180) / Math.PI,
});
}
return keys;
}
/**
* A live Moment from a circling detection, from the recorder's samples.
* @param {object} det detector output
* @param {object} recorder
*/
export function momentFromCircling(det, recorder) {
const start = Math.max(det.since - 4 * 60_000, Date.now() - 45 * 60_000);
const end = Math.max(det.updatedAt, Date.now());
const tracks = [];
for (const m of det.members) {
const pts = recorder
.samples(m.key, start, end)
.map((p) => ({ t: p.t, lon: p.lon, lat: p.lat, altM: p.altM }));
if (pts.length < 2) continue;
const meta = recorder.meta(m.key) || {};
tracks.push({
id: m.key,
entityId: `${meta.layer || 'flights'}:${m.key}`,
kind: meta.helicopter ? 'helicopter' : 'aircraft',
label: meta.callsign || m.key.toUpperCase(),
callsign: meta.callsign || null,
registration: meta.registration || null,
type: meta.type || null,
operator: '',
points: pts,
});
}
let maxAlt = 0;
for (const tr of tracks)
for (const p of tr.points) maxAlt = Math.max(maxAlt, p.altM);
const place = localPlace(det.lat, det.lon).name;
return {
id: `live-${det.id}`,
title: det.title,
place,
lon: det.lon,
lat: det.lat,
time: { start, end },
illustrative: false,
live: true,
situationId: det.id,
recommendedSpeed: 30,
subject: { lon: det.lon, lat: det.lat, altM: 0, label: place },
cameraPath: orbitCameraPath(det.lon, det.lat, start, end, {
rangeM: 2000,
heightM: Math.max(1400, maxAlt * 3),
lookAltM: maxAlt * 0.4,
}),
beats: [{ t: det.since, title: 'First complete orbit in the record' }],
tracks,
captures: [],
chat: [],
posts: [],
sources: [
{
name: 'God’s Eye recorder',
detail:
'Positions recorded from live ADS-B polls (OpenSky Network / adsb.lol, ODbL) in this browser.',
},
{
name: 'Circling detector',
detail: `${det.members.length} aircraft each completed at least one orbit within 1.5 km of a common centre.`,
},
],
};
}
/** A live Moment for one aircraft (an emergency squawk) from its track. */
export function momentFromAircraft({ id, title, key, points, meta, place }) {
if (!points?.length) return null;
const start = points[0].t;
const end = Math.max(points.at(-1).t, start + 60_000);
const last = points.at(-1);
return {
id,
title,
place: place || localPlace(last.lat, last.lon).name,
lon: last.lon,
lat: last.lat,
time: { start, end },
illustrative: false,
live: true,
recommendedSpeed: 60,
subject: null,
follow: { trackId: key, rangeM: 22_000, heightM: 7_000 },
cameraPath: [],
beats: [],
tracks: [
{
id: key,
entityId: `${meta?.layer || 'flights'}:${key}`,
kind: meta?.helicopter ? 'helicopter' : 'aircraft',
label: meta?.callsign || key.toUpperCase(),
operator: '',
points,
},
],
captures: [],
chat: [],
posts: [],
sources: [
{
name: 'God’s Eye recorder',
detail: 'Positions recorded from live ADS-B polls in this browser.',
},
],
};
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Geolocated reactions (DESIGN.md §12.6): hearts, fire, eyes and wow rise
* from the reactor's place on the globe. A screen-space canvas over the
* viewer (pointer-events: none) draws pre-rendered emoji sprites; each
* particle is re-projected every frame so the burst stays anchored to its
* point while the globe moves. The loop runs only while particles live.
*
* attentionHeat(points) draws a soft additive glow where viewers are; it is
* redrawn after every globe render, so it costs nothing at rest.
*/
const KINDS = { heart: '❤️', fire: '🔥', eyes: '👀', wow: '😮' };
const CAP = 300;
const LIFE_S = 2.6;
export function createReactions({ host, project, onFrame }) {
const canvas = document.createElement('canvas');
canvas.className = 'gev-reactions';
canvas.style.cssText =
'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;';
host.appendChild(canvas);
const g = canvas.getContext('2d');
const sprites = new Map();
const parts = [];
let heat = null;
let raf = 0;
let dpr = 1;
function sprite(kind) {
let s = sprites.get(kind);
if (s) return s;
const size = 64;
const c = document.createElement('canvas');
c.width = c.height = size;
const x = c.getContext('2d');
x.textAlign = 'center';
x.textBaseline = 'middle';
x.font = `${size * 0.78}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
x.shadowColor = 'rgba(0,0,0,0.45)';
x.shadowBlur = 6;
x.fillText(KINDS[kind] || KINDS.heart, size / 2, size / 2 + 3);
s = c;
sprites.set(kind, s);
return s;
}
function resize() {
dpr = Math.min(2, globalThis.devicePixelRatio || 1);
const w = Math.round(host.clientWidth * dpr);
const h = Math.round(host.clientHeight * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
}
function drawHeat() {
if (!heat?.length) return;
g.save();
g.globalCompositeOperation = 'lighter';
for (const p of heat) {
const s = project(p.lon, p.lat, 0);
if (!s) continue;
const r = (26 + 40 * Math.sqrt(p.weight ?? 1)) * dpr;
const grad = g.createRadialGradient(
s.x * dpr,
s.y * dpr,
0,
s.x * dpr,
s.y * dpr,
r,
);
grad.addColorStop(0, 'rgba(255,120,150,0.30)');
grad.addColorStop(0.45, 'rgba(255,90,140,0.12)');
grad.addColorStop(1, 'rgba(255,90,140,0)');
g.fillStyle = grad;
g.fillRect(s.x * dpr - r, s.y * dpr - r, r * 2, r * 2);
}
g.restore();
}
function draw(now) {
resize();
g.clearRect(0, 0, canvas.width, canvas.height);
drawHeat();
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
const age = (now - p.born) / 1000;
if (age > LIFE_S) {
parts.splice(i, 1);
continue;
}
if (age < 0) continue;
const s = project(p.lon, p.lat, p.altM);
if (!s) continue;
const f = age / LIFE_S;
const rise = p.rise * (1 - (1 - f) ** 2.2);
const x = s.x + p.dx * f + Math.sin(age * p.wobble + p.phase) * 9;
const y = s.y - rise;
const size = p.size * (age < 0.18 ? age / 0.18 : 1) * (1 - 0.25 * f);
g.globalAlpha = f < 0.7 ? 1 : 1 - (f - 0.7) / 0.3;
g.drawImage(
sprite(p.kind),
(x - size / 2) * dpr,
(y - size / 2) * dpr,
size * dpr,
size * dpr,
);
}
g.globalAlpha = 1;
}
function loop(now) {
draw(now);
raf = parts.length ? requestAnimationFrame(loop) : 0;
if (!parts.length) onFrame?.();
}
return {
react({ lon, lat, kind = 'heart', count = 1, altM = 0 } = {}) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return 0;
const k = KINDS[kind] ? kind : 'heart';
const n = Math.max(1, Math.min(40, Math.round(count)));
const now = performance.now();
for (let i = 0; i < n; i++) {
if (parts.length >= CAP) parts.shift();
parts.push({
lon,
lat,
altM,
kind: k,
born: now + i * (90 + Math.random() * 70),
rise: 120 + Math.random() * 110,
dx: (Math.random() - 0.5) * 70,
wobble: 2 + Math.random() * 3,
phase: Math.random() * 6.28,
size: 22 + Math.random() * 16,
});
}
if (!raf) raf = requestAnimationFrame(loop);
return n;
},
heat(points) {
heat = Array.isArray(points)
? points.filter(
(p) => Number.isFinite(p?.lon) && Number.isFinite(p?.lat),
)
: null;
if (!raf) {
resize();
g.clearRect(0, 0, canvas.width, canvas.height);
drawHeat();
}
},
/** Redraw the heat after a globe render (camera moved). */
afterRender() {
if (raf || !heat?.length) return;
resize();
g.clearRect(0, 0, canvas.width, canvas.height);
drawHeat();
},
get count() {
return parts.length;
},
destroy() {
cancelAnimationFrame(raf);
canvas.remove();
},
};
}
+548
View File
@@ -0,0 +1,548 @@
/**
* The recorder (DESIGN.md §12.5, "it remembers"): every live contact's
* position on every poll, kept for about six hours.
*
* Memory layout: one growable Int32Array per entity, five ints per sample:
* t (epoch s) · lon×1e6 · lat×1e6 · alt (m) · heading×10 << 12 | speed (dm/s)
* That is 20 bytes a sample. Low traffic (helicopters, approaches, anything
* below ~6 km) keeps every poll (≥ 6 s apart); cruise traffic keeps one fix
* every 50 s, since straight lines interpolate. Samples older than 30 min
* are thinned (1/min low, 1/3 min cruise), and the whole buffer is capped at
* 1.2 M samples (~24 MB; oldest minutes are evicted first).
*
* Persistence: once a minute the new samples go to IndexedDB as one segment
* (at most one sample per entity per 30 s, per 2 min for cruise), with each
* entity's metadata.
* Segments older than the window are deleted. On start the recorder replays
* the stored segments, so a reload keeps its history.
*/
import { idbDeleteBelow, idbEntries, idbPut, idbPutMany } from './idb.js';
const STRIDE = 5;
const MIN_GAP_S = 6;
// Cruise traffic (above ~6 km) flies straight lines: one fix a minute
// interpolates to within metres of the truth, so it is recorded sparsely.
// Low traffic (helicopters, approaches, circling) keeps every poll.
const CRUISE_ALT_M = 6000;
const CRUISE_GAP_S = 50;
const FINE_WINDOW_S = 30 * 60;
const COARSE_GAP_S = 60;
const CRUISE_COARSE_GAP_S = 180;
const PERSIST_GAP_S = 30;
const CRUISE_PERSIST_GAP_S = 120;
const MAX_INTERP_GAP_S = 6 * 60;
const D2R = Math.PI / 180;
function packHs(heading, speed) {
const h = Math.round(((((heading ?? 0) % 360) + 360) % 360) * 10) & 0xfff;
const s = Math.max(0, Math.min(4095, Math.round((speed ?? 0) * 10)));
return (h << 12) | s;
}
const unpackH = (v) => ((v >>> 12) & 0xfff) / 10;
const unpackS = (v) => (v & 0xfff) / 10;
class Track {
constructor(key, meta) {
this.key = key;
this.meta = meta;
this.buf = new Int32Array(STRIDE * 16);
this.n = 0;
this.persistedT = 0;
}
get firstT() {
return this.n ? this.buf[0] : 0;
}
get lastT() {
return this.n ? this.buf[(this.n - 1) * STRIDE] : 0;
}
push(t, lon, lat, alt, hs) {
if (this.n && t <= this.lastT) {
// Out-of-order (a merged backfill): insert in place.
return this.insert(t, lon, lat, alt, hs);
}
if ((this.n + 1) * STRIDE > this.buf.length) {
const next = new Int32Array(this.buf.length * 2);
next.set(this.buf);
this.buf = next;
}
const o = this.n * STRIDE;
this.buf[o] = t;
this.buf[o + 1] = lon;
this.buf[o + 2] = lat;
this.buf[o + 3] = alt;
this.buf[o + 4] = hs;
this.n++;
return true;
}
insert(t, lon, lat, alt, hs) {
const i = this.lowerBound(t);
if (i < this.n && Math.abs(this.buf[i * STRIDE] - t) < 2) return false;
if (i > 0 && Math.abs(this.buf[(i - 1) * STRIDE] - t) < 2) return false;
if ((this.n + 1) * STRIDE > this.buf.length) {
const next = new Int32Array(this.buf.length * 2);
next.set(this.buf);
this.buf = next;
}
this.buf.copyWithin((i + 1) * STRIDE, i * STRIDE, this.n * STRIDE);
const o = i * STRIDE;
this.buf[o] = t;
this.buf[o + 1] = lon;
this.buf[o + 2] = lat;
this.buf[o + 3] = alt;
this.buf[o + 4] = hs;
this.n++;
return true;
}
/** First index with t >= value. */
lowerBound(t) {
let lo = 0;
let hi = this.n;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.buf[mid * STRIDE] < t) lo = mid + 1;
else hi = mid;
}
return lo;
}
sample(i) {
const o = i * STRIDE;
const b = this.buf;
return {
t: b[o] * 1000,
lon: b[o + 1] / 1e6,
lat: b[o + 2] / 1e6,
altM: b[o + 3],
headingDeg: unpackH(b[o + 4]),
speed: unpackS(b[o + 4]),
};
}
/** Remove samples before t; thin samples between t and fineFrom. */
compact(dropBefore, fineFrom) {
const b = this.buf;
let w = 0;
let lastKept = -Infinity;
for (let i = 0; i < this.n; i++) {
const o = i * STRIDE;
const t = b[o];
if (t < dropBefore) continue;
const isLast = i === this.n - 1;
const gap = b[o + 3] > CRUISE_ALT_M ? CRUISE_COARSE_GAP_S : COARSE_GAP_S;
if (t < fineFrom && !isLast && t - lastKept < gap) continue;
if (w !== i) b.copyWithin(w * STRIDE, o, o + STRIDE);
lastKept = t;
w++;
}
const removed = this.n - w;
this.n = w;
if (this.buf.length > 64 && this.n * STRIDE * 4 < this.buf.length) {
this.buf = this.buf.slice(0, Math.max(STRIDE * 16, this.n * STRIDE * 2));
}
return removed;
}
}
const lerp = (a, b, f) => a + (b - a) * f;
function lerpAngle(a, b, f) {
const d = ((b - a + 540) % 360) - 180;
return (a + d * f + 360) % 360;
}
/**
* @param {{ windowMs?: number, maxSamples?: number, persist?: boolean }} [opts]
*/
export function createRecorder({
windowMs = 6 * 3600_000,
maxSamples = 1_200_000,
persist = true,
} = {}) {
const tracks = new Map();
let total = 0;
let pending = []; // [key, t, lon, lat, alt, hs]
let segSeq = 0;
const dirtyMeta = new Set();
const listeners = new Set();
const events = []; // { t, kind, key, title } (first squawk, first seen helicopters…)
let loaded = false;
let loading = null;
let minT = Infinity;
let maxT = -Infinity;
function bounds() {
minT = Infinity;
maxT = -Infinity;
for (const tr of tracks.values()) {
if (!tr.n) continue;
if (tr.firstT < minT) minT = tr.firstT;
if (tr.lastT > maxT) maxT = tr.lastT;
}
}
function append(key, meta, t, lon, lat, alt, hs, { fromStore = false } = {}) {
let tr = tracks.get(key);
if (!tr) {
tr = new Track(key, meta);
tracks.set(key, tr);
} else if (meta) {
const prev = tr.meta;
if (
!prev ||
prev.squawk !== meta.squawk ||
prev.callsign !== meta.callsign ||
(meta.registration && prev.registration !== meta.registration)
) {
tr.meta = { ...prev, ...meta };
if (!fromStore) dirtyMeta.add(key);
}
}
const cruise = alt > CRUISE_ALT_M;
if (
tr.n &&
t > tr.lastT &&
t - tr.lastT < (cruise && !fromStore ? CRUISE_GAP_S : MIN_GAP_S)
)
return false;
if (!tr.push(t, lon, lat, alt, hs)) return false;
total++;
if (t < minT) minT = t;
if (t > maxT) maxT = t;
if (
!fromStore &&
persist &&
t - tr.persistedT >= (cruise ? CRUISE_PERSIST_GAP_S : PERSIST_GAP_S)
) {
tr.persistedT = t;
pending.push([key, t, lon, lat, alt, hs]);
}
return true;
}
/**
* Record a feed's records. `kind` names the source family:
* 'aircraft' (flights / regional), 'military', 'vessel'.
*/
function ingest(records, { kind = 'aircraft', at = Date.now() } = {}) {
if (!records?.length) return 0;
let n = 0;
const nowS = Math.floor(at / 1000);
for (const r of records) {
if (!Number.isFinite(r.lon) || !Number.isFinite(r.lat)) continue;
// The transponder's own last-contact time is the truth: a stale
// upstream repeats old positions with old times, never "now".
const seen = Number.isFinite(r.seenAt)
? Math.floor(Math.min(r.seenAt, at + 5000) / 1000)
: nowS;
if (nowS - seen > 15 * 60) continue;
const key = kind === 'vessel' ? `v:${r.id}` : String(r.id);
const meta =
kind === 'vessel'
? { kind: 'vessel', layer: 'vessels', title: r.name, type: r.type }
: {
kind: r.military
? 'military'
: r.helicopter
? 'helicopter'
: 'aircraft',
layer: r.military ? 'military' : 'flights',
callsign: r.callsign,
registration: r.registration || undefined,
type: r.type || undefined,
country: r.country || undefined,
squawk: r.squawk || undefined,
helicopter: Boolean(r.helicopter),
military: Boolean(r.military),
ga: Boolean(r.ga),
category: r.category ?? undefined,
onGround: Boolean(r.onGround),
...(r.pia ? { pia: true } : {}),
...(r.ladd ? { ladd: true } : {}),
};
const before = tracks.get(key);
const hadEmergency = before?.meta?.emergency;
if (r.emergency) meta.emergency = true;
if (
append(
key,
meta,
seen,
Math.round(r.lon * 1e6),
Math.round(r.lat * 1e6),
Math.round(r.altM ?? 0),
packHs(r.heading, r.speed),
)
)
n++;
if (r.emergency && !hadEmergency)
events.push({
t: seen * 1000,
kind: 'squawk',
key,
title: `${r.callsign || r.id} squawking ${r.squawk}`,
lon: r.lon,
lat: r.lat,
});
}
if (events.length > 400) events.splice(0, events.length - 400);
if (n) for (const fn of listeners) fn({ added: n, kind });
return n;
}
/** Merge an externally fetched track ([{t, lon, lat, altM, headingDeg?, speed?}]). */
function merge(key, points, meta) {
let n = 0;
for (const p of points) {
if (!Number.isFinite(p.t) || !Number.isFinite(p.lon)) continue;
if (
append(
key,
meta,
Math.floor(p.t / 1000),
Math.round(p.lon * 1e6),
Math.round(p.lat * 1e6),
Math.round(p.altM ?? 0),
packHs(p.headingDeg, p.speed),
{ fromStore: true },
)
)
n++;
}
return n;
}
function compactAll() {
const nowS = Math.floor(Date.now() / 1000);
let dropBefore = nowS - Math.floor(windowMs / 1000);
const fineFrom = nowS - FINE_WINDOW_S;
let removed = 0;
for (const [key, tr] of tracks) {
removed += tr.compact(dropBefore, fineFrom);
if (!tr.n) tracks.delete(key);
}
total -= removed;
// Over budget: evict the oldest ten minutes at a time.
while (total > maxSamples && dropBefore < nowS - 600) {
dropBefore += 600;
for (const [key, tr] of tracks) {
total -= tr.compact(dropBefore, fineFrom);
if (!tr.n) tracks.delete(key);
}
}
bounds();
}
async function flush() {
if (!persist || !pending.length) return;
const rows = pending;
pending = [];
const keys = [];
const index = new Map();
const data = new Int32Array(rows.length * 6);
rows.forEach(([key, t, lon, lat, alt, hs], i) => {
let k = index.get(key);
if (k == null) {
k = keys.length;
keys.push(key);
index.set(key, k);
}
data.set([k, t, lon, lat, alt, hs], i * 6);
});
const segKey = rows[0][1] + (segSeq++ % 1000) / 1000;
await idbPut('seg', segKey, { keys, data: data.buffer });
if (dirtyMeta.size) {
const metas = [];
for (const key of dirtyMeta) {
const tr = tracks.get(key);
if (tr?.meta) metas.push([key, tr.meta]);
}
dirtyMeta.clear();
await idbPutMany('meta', metas);
}
idbDeleteBelow(
'seg',
Math.floor((Date.now() - windowMs) / 1000) - 60,
).catch(() => {});
}
async function load() {
if (loaded) return;
if (loading) return loading;
loading = (async () => {
if (!persist) return;
const since = Math.floor((Date.now() - windowMs) / 1000);
const [segs, metas] = await Promise.all([
idbEntries('seg', since),
idbEntries('meta'),
]);
const metaBy = new Map(metas);
for (const [, seg] of segs) {
if (!seg?.keys || !seg.data) continue;
const d = new Int32Array(seg.data);
for (let i = 0; i + 5 < d.length; i += 6) {
const key = seg.keys[d[i]];
append(
key,
metaBy.get(key) || { kind: 'aircraft' },
d[i + 1],
d[i + 2],
d[i + 3],
d[i + 4],
d[i + 5],
{ fromStore: true },
);
}
}
// Stored history is coarse (30 s); new polls continue each track.
for (const tr of tracks.values()) tr.persistedT = tr.lastT;
})().finally(() => {
loaded = true;
});
return loading;
}
const flushTimer = setInterval(() => flush().catch(() => {}), 60_000);
const compactTimer = setInterval(compactAll, 5 * 60_000);
const onHide = () => {
if (document.visibilityState === 'hidden') flush().catch(() => {});
};
document.addEventListener('visibilitychange', onHide);
/** Interpolated state of one track at `ms`, or null outside its record. */
function stateAt(tr, ms, { holdMs = 45_000 } = {}) {
if (!tr?.n) return null;
const s = ms / 1000;
const first = tr.firstT;
const last = tr.lastT;
if (s < first - 5 || s > last + holdMs / 1000) return null;
const i = tr.lowerBound(s);
if (i >= tr.n) {
// Past the last fix: dead-reckon briefly along the last heading.
const p = tr.sample(tr.n - 1);
const dt = s - last;
if (p.speed > 1 && dt > 0) {
const h = p.headingDeg * D2R;
const d = p.speed * Math.min(dt, 45);
p.lat += (d * Math.cos(h)) / 111_320;
p.lon += (d * Math.sin(h)) / (111_320 * Math.cos(p.lat * D2R));
}
p.t = ms;
return p;
}
if (i === 0) return { ...tr.sample(0), t: ms };
const a = tr.sample(i - 1);
const b = tr.sample(i);
const gap = (b.t - a.t) / 1000;
if (gap > MAX_INTERP_GAP_S) return null;
const f = (ms - a.t) / (b.t - a.t || 1);
return {
t: ms,
lon: lerp(a.lon, b.lon, f),
lat: lerp(a.lat, b.lat, f),
altM: lerp(a.altM, b.altM, f),
headingDeg: lerpAngle(a.headingDeg, b.headingDeg, f),
speed: lerp(a.speed, b.speed, f),
};
}
return {
ingest,
merge,
load,
flush,
compact: compactAll,
events: () => events.slice(),
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
/** { start, end, entities, points } in epoch ms; null start/end when empty. */
range() {
if (!Number.isFinite(minT)) bounds();
return {
start: Number.isFinite(minT) ? minT * 1000 : null,
end: Number.isFinite(maxT) ? maxT * 1000 : null,
entities: tracks.size,
points: total,
bytes: total * STRIDE * 4,
};
},
covers(ms, slackMs = 60_000) {
if (!Number.isFinite(minT)) return false;
return ms >= minT * 1000 - slackMs && ms <= maxT * 1000 + slackMs;
},
has: (key) => tracks.has(String(key)),
meta: (key) => tracks.get(String(key))?.meta || null,
keys: () => tracks.keys(),
/** All samples of one entity: [{ t, lon, lat, altM, headingDeg, speed }]. */
samples(key, fromMs = -Infinity, toMs = Infinity) {
const tr = tracks.get(String(key));
if (!tr) return [];
const out = [];
const i0 = Number.isFinite(fromMs) ? tr.lowerBound(fromMs / 1000) : 0;
for (let i = i0; i < tr.n; i++) {
const p = tr.sample(i);
if (p.t > toMs) break;
out.push(p);
}
return out;
},
stateAt(key, ms, opts) {
return stateAt(tracks.get(String(key)), ms, opts);
},
/**
* Every entity with a state at `ms` (optionally inside `rect`):
* [{ key, meta, lon, lat, altM, headingDeg, speed }].
*/
snapshotAt(ms, { rect = null, holdMs = 45_000 } = {}) {
const out = [];
for (const tr of tracks.values()) {
if (!tr.n) continue;
const s = ms / 1000;
if (s < tr.firstT - 5 || s > tr.lastT + holdMs / 1000) continue;
if (rect) {
// Cheap reject on the nearest raw fix before interpolating.
const i = Math.min(tr.n - 1, tr.lowerBound(s));
const lat = tr.buf[i * STRIDE + 2] / 1e6;
const lon = tr.buf[i * STRIDE + 1] / 1e6;
if (lat < rect.south - 1 || lat > rect.north + 1) continue;
if (
rect.west <= rect.east &&
(lon < rect.west - 1.5 || lon > rect.east + 1.5)
)
continue;
}
const p = stateAt(tr, ms, { holdMs });
if (!p) continue;
if (rect && !inRectLL(rect, p.lon, p.lat)) continue;
out.push({ key: tr.key, meta: tr.meta, ...p });
}
return out;
},
/** Positions of one entity between two instants (for ghost trails). */
trail(key, fromMs, toMs, maxPoints = 120) {
const tr = tracks.get(String(key));
if (!tr?.n) return [];
const i0 = tr.lowerBound(fromMs / 1000);
const i1 = tr.lowerBound(toMs / 1000);
const step = Math.max(1, Math.ceil((i1 - i0) / maxPoints));
const out = [];
for (let i = i0; i < i1; i += step) {
const p = tr.sample(i);
out.push([p.lon, p.lat, p.altM]);
}
const end = stateAt(tr, toMs);
if (end) out.push([end.lon, end.lat, end.altM]);
return out;
},
destroy() {
clearInterval(flushTimer);
clearInterval(compactTimer);
document.removeEventListener('visibilitychange', onHide);
flush().catch(() => {});
},
};
}
function inRectLL(rect, lon, lat) {
if (lat < rect.south || lat > rect.north) return false;
if (rect.west <= rect.east) return lon >= rect.west && lon <= rect.east;
return lon >= rect.west || lon <= rect.east;
}
+180
View File
@@ -0,0 +1,180 @@
/**
* "Your sky now" (DESIGN.md §12.3): what is above a person this minute.
* Aircraft within ~50 km from the live ADS-B feeds (fetching a regional
* snapshot around the person when the current one does not cover them),
* satellites above the horizon from CelesTrak elements (SGP4), and the ISS
* with its next pass.
*/
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from '../../src/data/issPass.js';
import { haversineKm, subsolarPoint } from './geo.js';
import { bearingDeg } from './gazetteer.js';
const R = 6_371_000;
const D2R = Math.PI / 180;
const ISS_NORAD = '25544';
function elevationDeg(distM, altM) {
// Flat-earth elevation corrected for the curvature drop over distM.
const drop = (distM * distM) / (2 * R);
return Math.atan2(altM - drop, distM) / D2R;
}
function sunElevation(lat, lon, ms) {
const s = subsolarPoint(ms);
const cosZ =
Math.sin(lat * D2R) * Math.sin(s.lat * D2R) +
Math.cos(lat * D2R) * Math.cos(s.lat * D2R) * Math.cos((lon - s.lon) * D2R);
return 90 - Math.acos(Math.max(-1, Math.min(1, cosZ))) / D2R;
}
const satCache = { stamp: '', sats: [] };
function satrecs(feeds) {
const groups = ['stations', 'visual'];
const stamp = groups.map((g) => feeds[g].updatedAt).join(':');
if (stamp === satCache.stamp) return satCache.sats;
const seen = new Set();
const sats = [];
for (const g of groups)
for (const e of feeds[g].data?.entries || []) {
const id = e.line1.slice(2, 7).trim();
if (seen.has(id)) continue;
seen.add(id);
try {
sats.push({
id,
name: e.name.trim(),
satrec: twoline2satrec(e.line1, e.line2),
});
} catch {
/* malformed */
}
}
satCache.stamp = stamp;
satCache.sats = sats;
return sats;
}
export async function skyNow(feeds, { lon, lat }, { km = 50 } = {}) {
if (!Number.isFinite(lon) || !Number.isFinite(lat))
throw new TypeError('skyNow needs lon and lat');
const now = Date.now();
// Aircraft: whatever the feeds hold near the person, plus a regional
// snapshot around them when nothing current covers the spot.
const lists = [];
const flights = feeds.flights.data;
if (flights?.records) lists.push(flights.records);
if (feeds.military.data?.records) lists.push(feeds.military.data.records);
for (const e of feeds.regionalFlights.entries?.() || [])
if (e.data?.records) lists.push(e.data.records);
const covered =
flights &&
(!flights.regional ||
(flights.anchor &&
haversineKm(lat, lon, flights.anchor.lat, flights.anchor.lon) < 300));
if (!covered) {
const key = `sky:${lat.toFixed(1)}:${lon.toFixed(1)}`;
const reg = await Promise.race([
feeds.regionalFlights.get(key, { lat, lon }).catch(() => null),
new Promise((r) => setTimeout(() => r(null), 9000)),
]);
if (reg?.records) lists.push(reg.records);
}
const seen = new Set();
const aircraft = [];
for (const list of lists)
for (const r of list) {
if (seen.has(r.id) || r.onGround) continue;
if (Math.abs(r.lat - lat) > km / 100 + 0.2) continue;
const d = haversineKm(lat, lon, r.lat, r.lon);
if (d > km) continue;
seen.add(r.id);
aircraft.push({
id: `${r.military ? 'military' : 'flights'}:${r.id}`,
title: r.callsign || r.id.toUpperCase(),
kind: r.military
? 'military'
: r.helicopter
? 'helicopter'
: 'aircraft',
lon: r.lon,
lat: r.lat,
altM: Math.round(r.altM ?? 0),
distanceKm: Math.round(d * 10) / 10,
elevationDeg: Math.round(elevationDeg(d * 1000, r.altM ?? 0) * 10) / 10,
azimuthDeg: Math.round(bearingDeg(lat, lon, r.lat, r.lon)),
headingDeg: r.heading ?? null,
speedMps: r.speed ?? null,
squawk: r.squawk || null,
});
}
aircraft.sort((a, b) => b.elevationDeg - a.elevationDeg);
// Satellites above the horizon.
// Cached elements answer at once; a cold catalog gets 4 s.
await Promise.race([
Promise.allSettled([feeds.stations.get(), feeds.visual.get()]),
new Promise((r) => setTimeout(r, 4000)),
]);
const satellites = [];
let iss = null;
for (const s of satrecs(feeds)) {
const look = lookAnglesAt(s.satrec, now, lat, lon);
if (!look) continue;
if (s.id === ISS_NORAD) iss = { s, look };
if (look.elevDeg <= 0) continue;
const p = look.satECI;
satellites.push({
id: `satellites:${s.id}`,
title: s.name,
norad: s.id,
altKm: p ? Math.round(Math.hypot(p.x, p.y, p.z) - 6371) : null,
elevationDeg: Math.round(look.elevDeg * 10) / 10,
azimuthDeg: Math.round(look.azDeg),
});
}
satellites.sort((a, b) => b.elevationDeg - a.elevationDeg);
const sunEl = sunElevation(lat, lon, now);
let issOut = {
visibleNow: false,
elevationDeg: null,
azimuthDeg: null,
nextPass: null,
};
if (iss) {
const pass = findNextIssPass({
satrec: iss.s.satrec,
latDeg: lat,
lonDeg: lon,
fromMs: now,
minElevDeg: 10,
horizonHours: 36,
});
issOut = {
visibleNow: iss.look.elevDeg > 10 && sunEl < -6,
aboveHorizon: iss.look.elevDeg > 0,
elevationDeg: Math.round(iss.look.elevDeg * 10) / 10,
azimuthDeg: Math.round(iss.look.azDeg),
nextPass: pass
? {
start: pass.riseMs,
max: pass.maxElevMs,
end: pass.setMs,
maxElevationDeg: Math.round(pass.maxElevDeg),
riseAzimuthDeg: Math.round(pass.riseAzDeg ?? 0),
visible: Boolean(pass.visible),
}
: null,
};
}
return {
at: now,
observer: { lon, lat },
sunElevationDeg: Math.round(sunEl * 10) / 10,
aircraft: aircraft.slice(0, 60),
satellites: satellites.slice(0, 40),
iss: issOut,
sources: ['OpenSky Network / adsb.lol (ODbL)', 'CelesTrak GP data, SGP4'],
};
}
+951
View File
@@ -0,0 +1,951 @@
/**
* The Moment stage (DESIGN.md §12.4): draws one Moment on the globe, keyed to
* the clock.
*
* tracks a glyph per aircraft at its interpolated position, a bright
* 90-second trail, the whole flown path faint behind it, and a
* hairline drop line to the ground
* captures a 3D view frustum per witness capture, from the phone to the
* subject, appearing (with a flash) when the clock passes it
* subject a highlighted building (real massing when the Moment bundles
* it), an antenna beacon, a pulsing ring at the subject height
* camera a cinematic path keyed to time (Catmull-Rom through the keys),
* released the moment the person touches the globe
*
* Everything is primitives, never Entities. Positions update in place each
* frame; the long "flown path" polylines rebuild at most 4×/s.
*/
import * as Cesium from 'cesium';
import { glyph } from './glyphs.js';
const D2R = Math.PI / 180;
const TRAIL_MS = 90_000;
const SCRATCH = new Cesium.Cartesian3();
const css = (c, a = 1) => Cesium.Color.fromCssColorString(c).withAlpha(a);
/** Index of the last point with t <= ms (binary search), -1 before the start. */
function indexAt(points, ms) {
let lo = 0;
let hi = points.length - 1;
if (!points.length || ms < points[0].t) return -1;
if (ms >= points[hi].t) return hi;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (points[mid].t <= ms) lo = mid;
else hi = mid - 1;
}
return lo;
}
/** Interpolated { lon, lat, altM, heading, speed } on a track at ms, or null. */
export function trackStateAt(points, ms, { holdMs = 30_000 } = {}) {
const i = indexAt(points, ms);
if (i < 0) return null;
const a = points[i];
const b = points[i + 1];
if (!b) {
if (ms - a.t > holdMs) return null;
const p = points[i - 1] || a;
return { ...a, heading: headingOf(p, a), speed: speedOf(p, a) };
}
if (b.t - a.t > 6 * 60_000) return null;
const f = (ms - a.t) / (b.t - a.t || 1);
// Heading and speed over a ~6-second baseline smooth out ADS-B jitter
// (on sparse live data: just the two fixes either side).
let j = i;
let k = i + 1;
while (j > 0 && a.t - points[j - 1].t <= 3000) j--;
while (k < points.length - 1 && points[k + 1].t - b.t <= 3000) k++;
return {
t: ms,
lon: a.lon + (b.lon - a.lon) * f,
lat: a.lat + (b.lat - a.lat) * f,
altM: a.altM + (b.altM - a.altM) * f,
heading: headingOf(points[j], points[k]),
speed: speedOf(points[j], points[k]),
};
}
function headingOf(a, b) {
const dx = (b.lon - a.lon) * Math.cos(a.lat * D2R);
const dy = b.lat - a.lat;
if (!dx && !dy) return 0;
return (Math.atan2(dx, dy) / D2R + 360) % 360;
}
function speedOf(a, b) {
const dt = (b.t - a.t) / 1000;
if (dt <= 0) return 0;
const dx = (b.lon - a.lon) * 111_320 * Math.cos(a.lat * D2R);
const dy = (b.lat - a.lat) * 110_574;
return Math.hypot(dx, dy) / dt;
}
/* ── Camera path ──────────────────────────────────────────────── */
function catmull(p0, p1, p2, p3, f) {
const f2 = f * f;
const f3 = f2 * f;
return (
0.5 *
(2 * p1 +
(-p0 + p2) * f +
(2 * p0 - 5 * p1 + 4 * p2 - p3) * f2 +
(-p0 + 3 * p1 - 3 * p2 + p3) * f3)
);
}
/** Camera { lon, lat, height, heading, pitch } on a keyed path at ms. */
export function cameraAt(path, ms) {
if (!path?.length) return null;
if (ms <= path[0].t) return { ...path[0] };
if (ms >= path.at(-1).t) return { ...path.at(-1) };
let i = 0;
while (i < path.length - 2 && path[i + 1].t <= ms) i++;
const k0 = path[Math.max(0, i - 1)];
const k1 = path[i];
const k2 = path[i + 1];
const k3 = path[Math.min(path.length - 1, i + 2)];
const raw = (ms - k1.t) / (k2.t - k1.t || 1);
// Unwrap headings around k1 so a 350° → 10° key pans the short way.
const un = (h) => k1.heading + (((h - k1.heading + 540) % 360) - 180);
const h0 = un(k0.heading);
const h2 = un(k2.heading);
const h3 = h2 + (((k3.heading - k2.heading + 540) % 360) - 180);
return {
lon: catmull(k0.lon, k1.lon, k2.lon, k3.lon, raw),
lat: catmull(k0.lat, k1.lat, k2.lat, k3.lat, raw),
height: catmull(k0.height, k1.height, k2.height, k3.height, raw),
heading: (catmull(h0, k1.heading, h2, h3, raw) + 360) % 360,
pitch: catmull(k0.pitch, k1.pitch, k2.pitch, k3.pitch, raw),
};
}
/* ── Frustum geometry ─────────────────────────────────────────── */
/** World-space apex and four far corners of a capture's view cone. */
function frustumCorners(c, groundH, lengthM) {
const origin = Cesium.Cartesian3.fromDegrees(
c.lon,
c.lat,
groundH + (c.altM || 1.5),
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
const h = (c.headingDeg || 0) * D2R;
const p = (c.pitchDeg || 0) * D2R;
const d = [Math.sin(h) * Math.cos(p), Math.cos(h) * Math.cos(p), Math.sin(p)];
const r = [Math.cos(h), -Math.sin(h), 0];
const u = [
r[1] * d[2] - r[2] * d[1],
r[2] * d[0] - r[0] * d[2],
r[0] * d[1] - r[1] * d[0],
];
// Portrait phone frame: fovDeg across the short side, 4:3.
const th = Math.tan(((c.fovDeg || 60) * D2R) / 2);
const tv = th * (4 / 3);
const corners = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
].map(([sx, sy]) => {
const local = new Cesium.Cartesian3(
lengthM * (d[0] + th * sx * r[0] + tv * sy * u[0]),
lengthM * (d[1] + th * sx * r[1] + tv * sy * u[1]),
lengthM * (d[2] + th * sx * r[2] + tv * sy * u[2]),
);
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
});
return { origin, corners };
}
function frustumGeometry(origin, corners) {
const pts = [origin, ...corners];
const values = new Float64Array(pts.length * 3);
pts.forEach((q, i) => {
values[i * 3] = q.x;
values[i * 3 + 1] = q.y;
values[i * 3 + 2] = q.z;
});
return new Cesium.Geometry({
attributes: {
position: new Cesium.GeometryAttribute({
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
componentsPerAttribute: 3,
values,
}),
},
// Four sides; the far face is left open so the subject shows through.
indices: new Uint16Array([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1]),
primitiveType: Cesium.PrimitiveType.TRIANGLES,
boundingSphere: Cesium.BoundingSphere.fromPoints(pts),
});
}
/* ── The subject building: stepped Art Deco massing ───────────── */
// Tiers as (fraction of the lot's long side, of its short side, from, to)
// in metres above the street, after the Empire State Building's setbacks.
const SUBJECT_TIERS = [
[1.0, 1.0, 0, 25],
[0.8, 0.86, 25, 80],
[0.66, 0.78, 80, 122],
[0.5, 0.7, 122, 286],
[0.36, 0.52, 286, 320],
];
const SUBJECT_MAST = [
[10, 320, 352],
[7, 352, 373],
[4.5, 373, 381],
];
function subjectMassing(row, olon, olat, kx, ky, base) {
const xs = [];
const ys = [];
for (let i = 2; i + 1 < row.length; i += 2) {
xs.push(row[i]);
ys.push(row[i + 1]);
}
const n = xs.length;
const cx = xs.reduce((a, b) => a + b, 0) / n;
const cy = ys.reduce((a, b) => a + b, 0) / n;
let sxx = 0;
let syy = 0;
let sxy = 0;
for (let i = 0; i < n; i++) {
sxx += (xs[i] - cx) ** 2;
syy += (ys[i] - cy) ** 2;
sxy += (xs[i] - cx) * (ys[i] - cy);
}
const th = 0.5 * Math.atan2(2 * sxy, sxx - syy);
const ux = Math.cos(th);
const uy = Math.sin(th);
let umax = 0;
let vmax = 0;
for (let i = 0; i < n; i++) {
const dx = xs[i] - cx;
const dy = ys[i] - cy;
umax = Math.max(umax, Math.abs(dx * ux + dy * uy));
vmax = Math.max(vmax, Math.abs(-dx * uy + dy * ux));
}
const toDeg = (x, y) => [olon + x / kx, olat + y / ky];
const rect = (fu, fv) => {
const out = [];
for (const [a, b] of [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]) {
const u = a * umax * fu;
const v = b * vmax * fv;
out.push(...toDeg(cx + u * ux - v * uy, cy + u * uy + v * ux));
}
return out;
};
const octagon = (r) => {
const out = [];
for (let k = 0; k < 8; k++) {
const a = th + (k * Math.PI) / 4 + Math.PI / 8;
out.push(...toDeg(cx + r * Math.cos(a), cy + r * Math.sin(a)));
}
return out;
};
const tiers = [
...SUBJECT_TIERS.map(([fu, fv, a, b]) => [rect(fu, fv), a, b]),
...SUBJECT_MAST.map(([r, a, b]) => [octagon(r), a, b]),
];
return tiers.map(([ring, from, to], i) => {
// The podium stays a deep amber; the tower brightens toward the mast.
const k = i === 0 ? -0.35 : i / (tiers.length - 1);
return new Cesium.GeometryInstance({
id: { layer: 'moment', key: 'subject' },
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(ring),
),
height: base + (from ? 3 + from : 0),
extrudedHeight: base + 3 + to,
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
new Cesium.Color(
0.86 + 0.14 * k,
0.72 + 0.22 * k,
0.46 + 0.36 * k,
1,
),
),
},
});
});
}
/* ── Stage ────────────────────────────────────────────────────── */
const TRACK_COLORS = ['#5EC8FF', '#FFD166', '#FF9F6B', '#9BE15D', '#C9A7FF'];
export function createStage({ scene, requestRender, emit }) {
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
root.show = false;
let massing = null; // Primitive (buildings)
let massingKey = '';
const lines = root.add(new Cesium.PolylineCollection());
const glyphs = root.add(new Cesium.BillboardCollection());
let coneRoot = root.add(new Cesium.PrimitiveCollection());
let moment = null;
let groundH = 0;
let altOffset = 0;
let tracks = [];
let captures = [];
let subject = null;
let lastPathRebuild = 0;
let cinematic = true;
let cameraTarget = null;
let framing = 6;
let lastT = 0;
const toCart = (lon, lat, altM, out) =>
Cesium.Cartesian3.fromDegrees(lon, lat, altM + altOffset, undefined, out);
function clear() {
lines.removeAll();
glyphs.removeAll();
root.remove(coneRoot);
coneRoot = root.add(new Cesium.PrimitiveCollection());
tracks = [];
captures = [];
subject = null;
}
async function sampleGround(lon, lat) {
try {
const provider = scene.globe.terrainProvider;
if (!provider || provider instanceof Cesium.EllipsoidTerrainProvider)
return 0;
const [c] = await Promise.race([
Cesium.sampleTerrainMostDetailed(provider, [
Cesium.Cartographic.fromDegrees(lon, lat),
]),
new Promise((r) => setTimeout(() => r([null]), 2500)),
]);
return Number.isFinite(c?.height) ? c.height : 0;
} catch {
return 0;
}
}
/** Real building massing ({ origin, b:[[roofM, groundM, x0,y0,…]] }). */
function buildMassing(data, highlightBin) {
if (massing) {
root.remove(massing);
massing = null;
}
if (!data?.b?.length) return;
const [olon, olat] = data.origin;
const kx = 111_320 * Math.cos(olat * D2R);
const ky = 110_574;
const subjectGround = 15;
const instances = [];
const sub = moment?.subject;
for (const row of data.b) {
const [roof, gnd] = row;
const ring = [];
for (let i = 2; i + 1 < row.length; i += 2)
ring.push(olon + row[i] / kx, olat + row[i + 1] / ky);
if (ring.length < 6) continue;
// Is this the subject building? (closest footprint to the subject)
const cx = row[2] / kx + olon;
const cy = row[3] / ky + olat;
const isSubject =
sub &&
roof > 300 &&
Math.abs(cx - sub.lon) < 0.0012 &&
Math.abs(cy - sub.lat) < 0.0012;
const base = groundH + (gnd - subjectGround) - 3;
if (isSubject) {
instances.push(...subjectMassing(row, olon, olat, kx, ky, base));
continue;
}
// Height-graded massing: low blocks dark, towers catch the light.
const k = Math.min(1, roof / 300);
// A little per-building variation so the massing reads as a city.
const j =
((((row[2] * 73856093) ^ (row[3] * 19349663)) >>> 0) % 1000) / 1000;
// Focus: the blocks around the subject catch a little more light,
// the city falls off into the dark with distance.
const dist = Math.hypot(
row[2] - (sub ? (sub.lon - olon) * kx : 0),
row[3] - (sub ? (sub.lat - olat) * ky : 0),
);
const v = (0.9 + 0.2 * j) * (1.18 - 0.5 * Math.min(1, dist / 2200));
const color = isSubject
? css('#FFE3A3', 1)
: new Cesium.Color(
(0.1 + 0.2 * k) * v,
(0.13 + 0.24 * k) * v,
(0.19 + 0.3 * k) * v,
1,
);
try {
instances.push(
new Cesium.GeometryInstance({
id: isSubject ? { layer: 'moment', key: 'subject' } : undefined,
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(ring),
),
height: base,
extrudedHeight: base + 3 + roof,
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
},
}),
);
} catch {
/* a degenerate footprint */
}
}
void highlightBin;
massing = root.add(
new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PerInstanceColorAppearance({
flat: false,
translucent: false,
closed: true,
}),
asynchronous: true,
shadows: Cesium.ShadowMode.DISABLED,
}),
);
massing.readyPromise?.then?.(requestRender);
}
function addSubject(s) {
if (!s) return;
const roof = s.roofM ?? s.altM ?? 0;
const top = s.antennaM ?? roof;
const base = toCart(s.lon, s.lat, groundH - altOffset);
// Antenna / marker mast: a glowing hairline from roof to tip.
if (top > roof + 5)
lines.add({
positions: [
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + roof),
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + top),
],
width: 3,
material: Cesium.Material.fromType('Color', {
color: css('#FFE3A3', 0.95),
}),
});
// A tall light column so the subject reads from 4 km out.
const column = lines.add({
positions: [
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + top),
Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + top + 900),
],
width: 8,
material: Cesium.Material.fromType('PolylineGlow', {
color: css('#FFD166', 0.5),
glowPower: 0.12,
taperPower: 0.25,
}),
});
void base;
const ring = glyph('ring');
const halo = glyph('dot');
const beacon = glyphs.add({
position: Cesium.Cartesian3.fromDegrees(s.lon, s.lat, groundH + top),
image: halo.image,
imageId: halo.imageId,
color: css('#FF4D4D'),
scale: 0.9,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const pulse = glyphs.add({
position: Cesium.Cartesian3.fromDegrees(
s.lon,
s.lat,
groundH + (s.altM ?? roof),
),
image: ring.image,
imageId: ring.imageId,
color: css('#FFD166', 0.9),
scale: 1.2,
id: { layer: 'moment', key: 'subject' },
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
subject = { ...s, beacon, pulse, column, base };
}
function addTracks(list) {
list.forEach((tr, i) => {
if (!tr.points?.length) return;
const color = tr.color || TRACK_COLORS[i % TRACK_COLORS.length];
const g = glyph(tr.kind === 'helicopter' ? 'heli' : 'plane');
const bb = glyphs.add({
position: Cesium.Cartesian3.ZERO,
image: g.image,
imageId: g.imageId,
color: css(color),
scale: tr.kind === 'helicopter' ? 0.62 : 0.55,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
show: false,
id: { layer: 'moment', key: `track:${tr.id}` },
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const halo = glyph('dot');
const glow = glyphs.add({
position: Cesium.Cartesian3.ZERO,
image: halo.image,
imageId: halo.imageId,
color: css(color, 0.55),
scale: 1.1,
show: false,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const cart = tr.points.map((p) =>
toCart(p.lon, p.lat, p.altM ?? 0, new Cesium.Cartesian3()),
);
const path = lines.add({
positions: [],
width: 1.6,
material: Cesium.Material.fromType('Color', {
color: css(color, 0.32),
}),
});
const trail = lines.add({
positions: [],
width: 5,
material: Cesium.Material.fromType('PolylineGlow', {
color: css(color, 0.95),
glowPower: 0.22,
taperPower: 0.55,
}),
});
const drop = lines.add({
positions: [],
width: 1,
material: Cesium.Material.fromType('Color', {
color: css(color, 0.35),
}),
});
tracks.push({
tr,
color,
bb,
glow,
cart,
path,
trail,
drop,
state: null,
});
});
}
function addCaptures(list) {
const target = moment?.subject;
for (const c of list || []) {
const lengthM = Math.max(
120,
(c.rangeM ??
(target
? Cesium.Cartesian3.distance(
Cesium.Cartesian3.fromDegrees(c.lon, c.lat),
Cesium.Cartesian3.fromDegrees(target.lon, target.lat),
)
: 300)) * 0.9,
);
const { origin, corners } = frustumCorners(c, groundH, lengthM);
const color = c.firstEyes ? '#FFD166' : '#FFC857';
const fill = coneRoot.add(
new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
id: { layer: 'moment', key: `capture:${c.id}` },
geometry: frustumGeometry(origin, corners),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
css(color, c.firstEyes ? 0.14 : 0.07),
),
},
}),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
closed: false,
renderState: { cull: { enabled: false } },
}),
asynchronous: false,
}),
);
fill.show = false;
const edgeMat = Cesium.Material.fromType('Color', {
color: css(color, c.firstEyes ? 0.95 : 0.6),
});
const edges = [];
for (let k = 0; k < 4; k++)
edges.push(
lines.add({
positions: [origin, corners[k]],
width: c.firstEyes ? 1.8 : 1.1,
material: edgeMat,
show: false,
}),
);
edges.push(
lines.add({
positions: [...corners, corners[0]],
width: c.firstEyes ? 2 : 1.2,
material: edgeMat,
show: false,
}),
);
const g = glyph('witness');
const pin = glyphs.add({
position: origin,
image: g.image,
imageId: g.imageId,
color: css(color),
scale: c.firstEyes ? 0.7 : 0.56,
show: false,
id: { layer: 'moment', key: `capture:${c.id}` },
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
const fl = glyph('dot');
const flash = glyphs.add({
position: origin,
image: fl.image,
imageId: fl.imageId,
color: css('#FFFFFF', 0),
scale: 0.1,
show: false,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
captures.push({
c,
fill,
edges,
edgeMat,
pin,
flash,
origin,
shownAt: 0,
});
}
}
function setCamera(t) {
if (!cinematic || !moment) return;
const follow =
moment.follow && tracks.find((x) => x.tr.id === moment.follow.trackId);
if (follow?.state) {
// Chase view: behind and above the aircraft, looking along its track.
const s = follow.state;
const range = moment.follow.rangeM ?? 18_000;
const up = moment.follow.heightM ?? 6_000;
const back = (s.heading + 180 + (moment.follow.offsetDeg ?? 25)) % 360;
const kx = 111_320 * Math.cos(s.lat * D2R);
const lon = s.lon + (Math.sin(back * D2R) * range) / kx;
const lat = s.lat + (Math.cos(back * D2R) * range) / 110_574;
scene.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, s.altM + up),
orientation: {
heading: ((back + 180) % 360) * D2R,
pitch: Math.atan2(-up, range),
roll: 0,
},
});
return;
}
if (!moment.cameraPath?.length) return;
const k = cameraAt(moment.cameraPath, t);
if (!k) return;
// Keys are framed for a landscape-ish view; a portrait phone has a
// narrower horizontal field, so pull back along the line of sight.
const aspect = canvas.clientWidth / Math.max(1, canvas.clientHeight);
const fit = Math.max(1, Math.min(1.6, 0.75 / Math.max(0.2, aspect)));
const sub = moment.subject || { lon: moment.lon, lat: moment.lat };
const kxm = 111_320 * Math.cos(sub.lat * D2R);
const dx = (k.lon - sub.lon) * kxm;
const dy = (k.lat - sub.lat) * 110_574;
const horiz = Math.hypot(dx, dy);
const lookAlt = k.height + Math.tan(k.pitch * D2R) * horiz;
const lon = sub.lon + (dx * fit) / kxm;
const lat = sub.lat + (dy * fit) / 110_574;
const height = lookAlt + (k.height - lookAlt) * fit;
if (subject?.column) {
const far = Math.hypot(horiz * fit, height);
subject.column.material.uniforms.color.alpha = Math.max(
0,
Math.min(0.5, (far - 2500) / 4000),
);
}
scene.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, groundH + height),
orientation: {
heading: k.heading * D2R,
// Look a little below the subject so it sits in the upper half,
// clear of the shell's bottom sheet.
pitch: (k.pitch - framing) * D2R,
roll: 0,
},
});
}
/** Move everything to time `t` (epoch ms). Returns true when drawn. */
function update(t, { realNow = performance.now() } = {}) {
if (!moment) return false;
const rebuildPath = realNow - lastPathRebuild > 250;
if (rebuildPath) lastPathRebuild = realNow;
for (const k of tracks) {
const s = trackStateAt(k.tr.points, t);
k.state = s;
if (!s) {
k.bb.show = k.glow.show = false;
k.trail.positions = [];
k.drop.positions = [];
if (rebuildPath) {
const i = indexAt(k.tr.points, t);
k.path.positions = i > 0 ? k.cart.slice(0, i + 1) : [];
}
continue;
}
const pos = toCart(s.lon, s.lat, s.altM, new Cesium.Cartesian3());
k.bb.position = pos;
k.glow.position = pos;
k.bb.rotation = -s.heading * D2R;
k.bb.show = k.glow.show = true;
const i = indexAt(k.tr.points, t);
// Bright trail: the last 90 s, ending exactly at the glyph.
let j = indexAt(k.tr.points, t - TRAIL_MS);
if (j < 0) j = 0;
const step = Math.max(1, Math.floor((i - j) / 90));
const trail = [];
for (let q = j; q <= i; q += step) trail.push(k.cart[q]);
trail.push(pos);
k.trail.positions = trail.length > 1 ? trail : [];
if (rebuildPath) {
const stride = Math.max(1, Math.floor(i / 600));
const path = [];
for (let q = 0; q <= i; q += stride) path.push(k.cart[q]);
path.push(pos);
k.path.positions = path.length > 1 ? path : [];
}
k.drop.positions = [
pos,
Cesium.Cartesian3.fromDegrees(
s.lon,
s.lat,
groundH,
undefined,
SCRATCH,
).clone(),
];
}
for (const k of captures) {
const on = t >= k.c.t;
if (on && !k.shownAt) k.shownAt = realNow;
if (!on) k.shownAt = 0;
k.fill.show = on;
for (const e of k.edges) e.show = on;
k.pin.show = on;
const age = on ? (realNow - k.shownAt) / 1000 : 9;
k.flash.show = on && age < 0.9;
if (k.flash.show) {
const f = age / 0.9;
k.flash.scale = 0.4 + 3.2 * f;
k.flash.color = css('#FFFFFF', 0.9 * (1 - f));
}
}
if (subject) {
const ph = (realNow / 1000) % 1.6;
subject.pulse.scale = 1.0 + 0.6 * (ph / 1.6);
subject.pulse.color = css('#FFD166', 0.95 * (1 - ph / 1.6));
subject.beacon.color = css(
'#FF4D4D',
(realNow / 1000) % 1.2 < 0.25 ? 1 : 0.25,
);
}
setCamera(t);
lastT = t;
requestRender();
return true;
}
// The person takes the camera: stop flying the path until asked again.
const canvas = scene.canvas;
const release = () => {
if (!moment || !cinematic) return;
cinematic = false;
emit?.('moment', {
type: 'camera',
item: { cinematic: false },
t: lastT,
momentId: moment.id,
});
};
canvas.addEventListener('pointerdown', release, { passive: true });
canvas.addEventListener('wheel', release, { passive: true });
return {
async load(m, { buildings = null } = {}) {
clear();
moment = m;
cinematic = true;
const s = m.subject || { lon: m.lon, lat: m.lat, altM: 0 };
groundH = await sampleGround(s.lon, s.lat);
// Tracks are MSL; draw them relative to the sampled ground.
altOffset = Number.isFinite(m.groundMslM) ? groundH - m.groundMslM : 0;
const key = m.stage?.buildings || '';
if (buildings && key !== massingKey) {
massingKey = key;
buildMassing(buildings, m.stage?.highlight);
} else if (!buildings && massing) {
root.remove(massing);
massing = null;
massingKey = '';
}
if (massing) massing.show = true;
addSubject(m.subject);
addTracks(m.tracks || []);
addCaptures(m.captures || []);
cameraTarget = s;
root.show = true;
update(m.time.start);
},
unload() {
clear();
moment = null;
root.show = false;
if (massing) massing.show = false;
requestRender();
},
update,
get active() {
return Boolean(moment);
},
get cinematic() {
return cinematic;
},
/** Degrees the cinematic camera looks below its subject (0 = centred). */
setFraming(deg) {
if (Number.isFinite(deg)) framing = deg;
setCamera(lastT);
},
setCinematic(on) {
cinematic = Boolean(on);
if (cinematic) setCamera(lastT);
},
groundHeight: () => groundH,
target: () => cameraTarget,
/** Label candidates: [{ id, title, detail?, lon, lat, altM, kind, score }]. */
labels() {
if (!moment) return [];
const out = [];
for (const k of tracks) {
if (!k.state) continue;
out.push({
id: `moment:${moment.id}:${k.tr.id}`,
kind: 'track',
title: k.tr.label,
detail: `${Math.round((k.state.altM * 3.28084) / 25) * 25} FT · ${Math.round(k.state.speed / 0.514444)} KT`,
lon: k.state.lon,
lat: k.state.lat,
altM: k.state.altM + altOffset,
score: -80,
});
}
for (const k of captures)
if (lastT >= k.c.t)
out.push({
id: `capture:${moment.id}:${k.c.id}`,
kind: 'capture',
title: `@${k.c.handle}`,
detail: k.c.firstEyes ? 'FIRST EYES · SEALED' : 'SEALED',
lon: k.c.lon,
lat: k.c.lat,
altM: groundH + (k.c.altM || 1.5),
score: k.c.firstEyes ? -70 : -40,
});
if (subject)
out.push({
id: `moment:${moment.id}:subject`,
kind: 'subject',
title: subject.label || moment.place,
lon: subject.lon,
lat: subject.lat,
altM: groundH + (subject.antennaM ?? subject.altM ?? 0),
score: -90,
});
return out;
},
/** Pick payload for a stage id key. */
pick(key) {
if (!moment) return null;
if (key === 'subject')
return {
kind: 'subject',
id: `moment:${moment.id}:subject`,
title: moment.subject?.label || moment.place,
subtitle: moment.title,
lon: moment.subject?.lon ?? moment.lon,
lat: moment.subject?.lat ?? moment.lat,
meta: { layer: 'moment', momentId: moment.id },
};
const [type, id] = key.split(':');
if (type === 'track') {
const k = tracks.find((x) => x.tr.id === id);
if (!k) return null;
const s = k.state || k.tr.points.at(-1);
return {
kind: 'track',
id: `moment:${moment.id}:${id}`,
title: k.tr.label,
subtitle: [k.tr.type, k.tr.operator].filter(Boolean).join(' · '),
lon: s.lon,
lat: s.lat,
meta: {
layer: 'moment',
momentId: moment.id,
trackId: id,
illustrative: Boolean(moment.illustrative),
altitudeM: s.altM,
},
};
}
if (type === 'capture') {
const k = captures.find((x) => x.c.id === id);
if (!k) return null;
return {
kind: 'capture',
id: `capture:${moment.id}:${id}`,
title: k.c.title,
subtitle: `@${k.c.handle} · ${k.c.place || ''}${k.c.sealed ? ' · Sealed' : ''}`,
lon: k.c.lon,
lat: k.c.lat,
meta: {
layer: 'moment',
momentId: moment.id,
captureId: id,
headingDeg: k.c.headingDeg,
fovDeg: k.c.fovDeg,
firstEyes: Boolean(k.c.firstEyes),
illustrative: Boolean(moment.illustrative),
},
};
}
return null;
},
destroy() {
canvas.removeEventListener('pointerdown', release);
canvas.removeEventListener('wheel', release);
scene.primitives.remove(root);
},
};
}
+18 -10
View File
@@ -39,11 +39,16 @@
<link rel="stylesheet" href="./styles/threads.css" />
<link rel="stylesheet" href="./styles/cards.css" />
<link rel="stylesheet" href="./styles/room.css" />
<link rel="stylesheet" href="./styles/today.css" />
<link rel="stylesheet" href="./styles/timemachine.css" />
<link rel="stylesheet" href="./styles/moment.css" />
<link rel="stylesheet" href="./styles/dossier.css" />
<link rel="stylesheet" href="./styles/watch.css" />
<link rel="stylesheet" href="./styles/layout.css" />
<script type="module" src="./main.js"></script>
</head>
<body>
<div id="app" class="app" data-tab="now" data-booting="true">
<div id="app" class="app" data-tab="today" data-booting="true">
<!-- The one persistent globe. Everything else is glass over it. -->
<div id="stage" class="stage" aria-label="Globe">
<div id="globe" class="globe-host"></div>
@@ -53,22 +58,22 @@
<main id="lead" class="lead">
<section
id="view-now"
id="view-today"
class="view view--scroll"
data-view="now"
aria-label="Now"
data-view="today"
aria-label="Today"
></section>
<section
id="view-globe"
id="view-live"
class="view view--scroll"
data-view="globe"
aria-label="Globe layers"
data-view="live"
aria-label="Live layers"
></section>
<section
id="view-threads"
id="view-rooms"
class="view view--scroll"
data-view="threads"
aria-label="Threads"
data-view="rooms"
aria-label="Rooms"
hidden
></section>
<section
@@ -88,6 +93,9 @@
hidden
></section>
<!-- Sheets that belong to the leading pane on a tablet (dossier, watch,
your sky); on a phone they cover the whole screen. -->
<div id="pane-sheets" class="sheets pane-sheets"></div>
<div id="sheets" class="sheets"></div>
<nav id="tabbar" class="tabbar" aria-label="Primary"></nav>
</div>
+807 -105
View File
File diff suppressed because it is too large. Load diff
+794
View File
@@ -0,0 +1,794 @@
/* ------------------------------------------------------------------ */
/* Dossiers, link graph and universal search (DESIGN.md §12.1) */
/* ------------------------------------------------------------------ */
.dossier-sheet {
--band-c: var(--ink-2);
}
.dossier-sheet[data-band='orbit'] {
--band-c: var(--b-orbit);
}
.dossier-sheet[data-band='sky'] {
--band-c: var(--b-sky);
}
.dossier-sheet[data-band='ground'] {
--band-c: var(--b-ground);
}
.dossier-sheet[data-band='inside'] {
--band-c: var(--b-inside);
}
.dossier-sheet::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: 120px;
background: radial-gradient(
80% 100% at 20% 0,
color-mix(in srgb, var(--band-c) 16%, transparent),
transparent 70%
);
pointer-events: none;
}
.ds-head {
position: relative;
padding-bottom: 10px;
}
.ds-back {
position: absolute;
left: -10px;
top: -6px;
width: 34px;
height: 34px;
color: var(--tint);
font-size: 1.05rem;
}
.ds-back + .ds-kicker {
padding-left: 26px;
}
.ds-kicker {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
color: var(--ink-3);
font-size: 0.5625rem;
min-height: 18px;
padding-right: 40px;
}
.ds-kicker .bd {
width: 7px;
height: 7px;
border-radius: 2px;
background: var(--band-c);
box-shadow: 0 0 8px var(--band-c);
}
.ds-kicker .live-tag {
display: inline-flex;
align-items: center;
gap: 4px;
color: #ff6b61;
}
.ds-title-row {
display: flex;
align-items: center;
gap: 12px;
margin: 6px 0 10px;
}
.ds-ic {
flex: none;
width: 44px;
height: 44px;
border-radius: 13px;
display: grid;
place-items: center;
font-size: 1.35rem;
color: var(--band-c);
background: color-mix(in srgb, var(--band-c) 15%, rgba(255, 255, 255, 0.03));
box-shadow: inset 0 0 0 0.5px
color-mix(in srgb, var(--band-c) 40%, transparent);
}
.ds-title-row .sheet-title {
font-family: var(--font-display);
margin: 0;
overflow-wrap: anywhere;
}
.ds-skel {
width: 60%;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.ds-seg {
display: flex;
}
.ds-seg button {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.ds-seg .i {
font-size: 0.95rem;
}
.ds-live {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
gap: 8px;
padding-top: 4px;
}
.ds-live div {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
}
.ds-live .tele {
color: var(--ink-3);
font-size: 0.5625rem;
}
.ds-live b {
font-family: var(--font-display);
font-size: 1.3125rem;
font-weight: 700;
letter-spacing: -0.02em;
white-space: nowrap;
}
.ds-live small {
margin-left: 3px;
color: var(--ink-3);
font-size: 0.6875rem;
font-weight: 600;
}
.ds-updated {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 2px 0;
color: var(--ink-3);
font-size: 0.5625rem;
}
.ds-actions {
display: flex;
gap: 8px;
padding: 14px 0 4px;
overflow-x: auto;
scrollbar-width: none;
}
.ds-act {
flex: 1 0 auto;
min-width: 70px;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 10px 8px;
border-radius: 14px;
color: var(--tint);
background: rgba(163, 216, 255, 0.1);
font-size: var(--t-cap2);
font-weight: 600;
transition:
background var(--dur-1) linear,
transform var(--dur-1) var(--ease-out);
}
.ds-act .i {
font-size: 1.25rem;
}
.ds-act:hover {
background: rgba(163, 216, 255, 0.16);
}
.ds-act:active {
transform: scale(0.96);
}
.ds-act[aria-pressed='true'] {
color: #2a1a00;
background: var(--b-inside);
}
.ds-act.primary {
color: #050607;
background: #fff;
}
.ds-privacy {
display: flex;
gap: 10px;
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--ink-2);
font-size: var(--t-cap);
}
.ds-block {
margin-top: 18px;
}
.ds-h {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
font-size: var(--t-sub);
font-weight: 700;
letter-spacing: -0.01em;
}
.ds-h .i {
align-self: center;
color: var(--ink-3);
}
.ds-h small {
margin-left: auto;
color: var(--ink-3);
font-weight: 500;
font-size: var(--t-cap2);
text-align: right;
}
.ds-track {
display: grid;
grid-template-columns: 1fr 96px;
gap: 8px;
}
.ds-fig {
margin: 0;
padding: 10px 10px 6px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
}
.ds-fig svg {
display: block;
width: 100%;
height: 70px;
}
.ds-ground svg {
height: 70px;
}
.ds-fig figcaption {
display: flex;
justify-content: space-between;
margin-top: 4px;
color: var(--ink-3);
font-size: 0.5rem;
}
.ds-area {
fill: color-mix(in srgb, var(--band-c) 22%, transparent);
}
.ds-line,
.ds-gline {
fill: none;
stroke: var(--band-c);
stroke-width: 1.6;
vector-effect: non-scaling-stroke;
stroke-linejoin: round;
}
.ds-end {
fill: #fff;
}
.ds-alt {
width: 100%;
height: 64px;
display: block;
}
.ds-id {
display: grid;
grid-template-columns: auto 1fr;
gap: 7px 16px;
margin: 0;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
font-size: var(--t-foot);
}
.ds-id dt {
color: var(--ink-3);
}
.ds-id dd {
margin: 0;
text-align: right;
overflow-wrap: anywhere;
}
.ds-events {
list-style: none;
margin: 0;
padding: 0 0 0 4px;
border-left: 1px solid var(--hairline-strong);
}
.ds-events button {
position: relative;
display: grid;
grid-template-columns: 52px 1fr auto;
align-items: baseline;
gap: 8px;
width: 100%;
padding: 8px 6px 8px 14px;
text-align: left;
color: var(--ink);
font-size: var(--t-foot);
border-radius: 10px;
}
.ds-events button:hover {
background: rgba(255, 255, 255, 0.04);
}
.ds-events button::before {
content: '';
position: absolute;
left: -8.5px;
top: 13px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--band-c);
box-shadow: 0 0 0 3px var(--ground-1);
}
.ds-events .tele {
color: var(--ink-3);
font-size: 0.625rem;
}
.ds-events small {
color: var(--ink-4);
font-size: var(--t-cap2);
}
.ds-links {
display: grid;
border-radius: 14px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
}
.ds-link {
position: relative;
display: flex;
align-items: center;
gap: 11px;
padding: 9px 12px;
text-align: left;
color: var(--ink);
--lc: var(--ink-2);
}
.ds-link + .ds-link::before {
content: '';
position: absolute;
top: 0;
left: 52px;
right: 0;
height: 0.5px;
background: var(--hairline);
}
.ds-link:hover {
background: rgba(255, 255, 255, 0.04);
}
.ds-link[data-band='orbit'] {
--lc: var(--b-orbit);
}
.ds-link[data-band='sky'] {
--lc: var(--b-sky);
}
.ds-link[data-band='ground'] {
--lc: var(--b-ground);
}
.ds-link[data-band='inside'] {
--lc: var(--b-inside);
}
.ds-link-ic {
flex: none;
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
color: var(--lc);
background: color-mix(in srgb, var(--lc) 14%, transparent);
}
.ds-link-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.ds-link-main b {
font-size: var(--t-foot);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ds-link-main small {
color: var(--ink-3);
font-size: var(--t-cap2);
}
.ds-link .chev {
color: var(--ink-4);
}
.ds-receipts {
display: grid;
gap: 6px;
}
.ds-rc {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
}
.ds-rc div {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ds-rc b {
font-size: var(--t-foot);
font-weight: 600;
}
.ds-rc small {
color: var(--ink-3);
font-size: var(--t-cap2);
}
.ds-rc a {
color: var(--tint);
}
.ds-hash {
flex: none;
padding: 6px 8px;
border-radius: 8px;
color: var(--b-inside);
background: rgba(235, 199, 119, 0.1);
font-size: 0.625rem;
}
.ds-hash:hover {
background: rgba(235, 199, 119, 0.18);
}
.ds-foot {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 18px 0 0;
color: var(--ink-3);
font-size: var(--t-cap);
line-height: 1.4;
}
.ds-foot .i {
flex: none;
margin-top: 1px;
}
/* Link graph */
.dossier-sheet[data-seg='graph'] .sheet-body {
display: flex;
flex-direction: column;
padding-bottom: calc(var(--safe-b) + 12px);
}
.ds-graph {
flex: 1;
min-height: 320px;
position: relative;
border-radius: 18px;
background:
radial-gradient(
70% 60% at 50% 50%,
rgba(163, 216, 255, 0.06),
transparent 70%
),
rgba(255, 255, 255, 0.025);
box-shadow: inset 0 0 0 0.5px var(--hairline);
overflow: hidden;
}
.lg {
position: absolute;
inset: 0;
}
.lg-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
touch-action: none;
}
.lg-tip {
position: absolute;
left: 0;
top: 0;
padding: 4px 8px;
border-radius: 6px;
background: rgba(8, 10, 14, 0.92);
box-shadow: inset 0 0 0 0.5px var(--hairline-strong);
color: var(--ink);
font-size: 0.5625rem;
white-space: nowrap;
pointer-events: none;
}
.lg-legend {
position: absolute;
left: 12px;
bottom: 10px;
display: flex;
gap: 10px;
color: var(--ink-3);
font-size: 0.5rem;
pointer-events: none;
}
.lg-legend span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.lg-legend i {
width: 7px;
height: 7px;
border-radius: 50%;
}
.lg-legend [data-band='orbit'] i {
background: var(--b-orbit);
}
.lg-legend [data-band='sky'] i {
background: var(--b-sky);
}
.lg-legend [data-band='ground'] i {
background: var(--b-ground);
}
.lg-legend [data-band='inside'] i {
background: var(--b-inside);
}
.ds-graph-note {
flex: none;
margin: 8px 0 0;
color: var(--ink-3);
font-size: 0.5625rem;
text-align: center;
}
/* ---------- Universal search ---------- */
.search-sheet .sheet-head {
padding-top: 4px;
}
.results-h {
display: flex;
align-items: baseline;
gap: 8px;
margin: 16px 2px 6px;
color: var(--ink-3);
font-size: var(--t-cap);
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.results-h small {
font-family: var(--mono);
font-weight: 500;
}
.results-h .link-btn {
margin-left: auto;
color: var(--tint);
text-transform: none;
letter-spacing: 0;
}
.sr-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sr-chips .chip {
height: 34px;
padding: 0 13px;
font-size: var(--t-foot);
color: var(--ink);
background: rgba(255, 255, 255, 0.07);
}
.sr-chips .chip:hover {
background: rgba(255, 255, 255, 0.12);
}
.sr-chips .chip .i {
color: var(--ink-3);
}
.sr-list {
display: grid;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
}
.sr {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
text-align: left;
color: var(--ink);
--sc: var(--ink-2);
}
.sr + .sr::before {
content: '';
position: absolute;
top: 0;
left: 56px;
right: 0;
height: 0.5px;
background: var(--hairline);
}
.sr:hover,
.sr[aria-selected='true'] {
background: rgba(255, 255, 255, 0.06);
}
.sr[data-band='orbit'] {
--sc: var(--b-orbit);
}
.sr[data-band='sky'] {
--sc: var(--b-sky);
}
.sr[data-band='ground'] {
--sc: var(--b-ground);
}
.sr[data-band='inside'] {
--sc: var(--b-inside);
}
.sr-ic {
flex: none;
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 1.05rem;
color: var(--sc);
background: color-mix(in srgb, var(--sc) 14%, transparent);
}
.sr-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.sr-main b {
font-size: var(--t-sub);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sr-main small {
color: var(--ink-3);
font-size: var(--t-cap);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sr-tag {
flex: none;
color: var(--ink-4);
font-size: 0.5rem;
text-transform: uppercase;
}
.sr-empty {
display: flex;
flex-direction: column;
gap: 4px;
padding: 40px 10px;
text-align: center;
color: var(--ink-3);
}
.sr-empty b {
color: var(--ink);
}
.sr-note {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 18px 4px 0;
color: var(--ink-3);
font-size: var(--t-cap);
line-height: 1.4;
}
.sr-note .i {
flex: none;
margin-top: 1px;
}
/* Grid rows never grow wider than their list. */
.sr-list,
.ds-links,
.ds-receipts {
grid-template-columns: minmax(0, 1fr);
}
+6 -6
View File
@@ -2,7 +2,7 @@
/* Now feed: header, altitude filter, masonry + wide rows */
/* ------------------------------------------------------------------ */
#view-now {
#view-today {
background: var(--ground);
}
@@ -39,13 +39,13 @@
letter-spacing: 0.04em;
}
#view-now .title-sub {
#view-today .title-sub {
gap: 7px;
color: var(--ink-3);
font-size: 0.625rem;
}
#view-now .title-sub .live-dot {
#view-today .title-sub .live-dot {
--fam: var(--ink);
width: 5px;
height: 5px;
@@ -83,11 +83,11 @@
transition: opacity var(--dur-1) linear;
}
#view-now.is-scrolled .bandbar::before {
#view-today.is-scrolled .bandbar::before {
opacity: 1;
}
#view-now.is-scrolled .navbar::before {
#view-today.is-scrolled .navbar::before {
border-bottom-color: transparent;
}
@@ -328,6 +328,6 @@
}
/* The Near lens: the feed becomes a distance-ordered list around you. */
#view-now.is-near .feed-count::after {
#view-today.is-near .feed-count::after {
content: ' · nearest first';
}
+77 -1
View File
@@ -51,7 +51,7 @@
left: var(--stage-left);
}
#view-globe {
#view-live {
display: block;
}
@@ -115,6 +115,82 @@
.hero {
border-radius: 0;
}
/* Dossier, watch and your-sky sheets live in the leading pane, so the
globe and the time machine stay live and in sync beside them. */
.pane-sheets {
right: auto;
width: var(--lead-w);
z-index: 45;
}
.pane-sheets .sheet {
left: 8px;
right: 8px;
width: auto;
margin: 0;
}
.pane-sheets .sheet-scrim {
opacity: 0 !important;
}
/* Moments: chat, record and captures take the leading pane. */
.app.is-moment .lead {
visibility: hidden !important;
}
.mp-stage {
left: var(--stage-left);
}
.mp-side {
left: 0;
right: auto;
top: 0;
bottom: 0;
width: var(--lead-w);
height: auto;
border-radius: 0;
padding-top: calc(var(--safe-t) + 8px);
background: rgba(7, 9, 12, 0.94);
box-shadow: inset -0.5px 0 0 var(--hairline-strong);
}
.mp-side-head {
padding: 10px 14px 8px;
}
.mp-pane {
padding: 0 16px 14px;
}
.mp-react {
bottom: calc(var(--safe-b) + 84px);
}
.mp-pops {
width: 230px;
}
.app.is-moment .gc-attr {
bottom: calc(var(--safe-b) + 80px);
}
.mp-legend {
width: 250px;
}
.mp-title {
font-size: 1.625rem;
}
.banners {
left: auto;
right: 0;
width: min(440px, 100%);
justify-items: end;
}
}
/* Vertical hinge (book posture): split exactly at the fold. */
+796
View File
@@ -0,0 +1,796 @@
/* ------------------------------------------------------------------ */
/* Moments player (DESIGN.md §12.4) */
/* ------------------------------------------------------------------ */
/* The globe is the screen; everything else steps aside. */
.app.is-moment .lead {
visibility: hidden;
}
.app.is-moment .lead .view {
pointer-events: none;
}
.app.is-moment .globe-chrome {
visibility: visible;
}
.app.is-moment .gc-top,
.app.is-moment .gc-bottom,
.app.is-moment .alt-rail,
.app.is-moment .world-back,
.app.is-moment .pick-card,
.app.is-moment .lens-pop {
visibility: hidden;
pointer-events: none;
}
.app.is-moment .tabbar {
transform: translateY(140%);
opacity: 0;
pointer-events: none;
}
.app.is-moment .globe-veil {
background:
linear-gradient(
to bottom,
rgba(5, 7, 11, 0.75) 0,
rgba(5, 7, 11, 0) calc(var(--safe-t) + 200px)
),
linear-gradient(to top, rgba(5, 7, 11, 0.85) 0, rgba(5, 7, 11, 0) 46%);
}
.mp {
position: absolute;
inset: 0;
z-index: 40;
pointer-events: none;
opacity: 0;
transition: opacity 320ms var(--ease-out);
}
.mp[hidden] {
display: none;
}
.mp.in {
opacity: 1;
}
.mp-stage {
position: absolute;
inset: 0;
}
.mp-stage > *,
.mp-side {
pointer-events: auto;
}
.mp-top {
position: absolute;
top: calc(var(--safe-t) + 10px);
left: calc(12px + var(--safe-l));
right: calc(12px + var(--safe-r));
display: flex;
align-items: flex-start;
gap: 10px;
}
.mp-top .icon-btn {
flex: none;
width: 42px;
height: 42px;
font-size: 1.15rem;
}
.mp-titles {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.mp-kicker {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
color: var(--b-inside);
font-size: 0.5625rem;
font-weight: 600;
letter-spacing: 0.1em;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.8);
}
.mp-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--b-inside);
box-shadow: 0 0 8px var(--b-inside);
}
.mp-title {
margin: 3px 0 0;
font-family: var(--font-display);
font-size: 1.3125rem;
font-weight: 750;
letter-spacing: -0.03em;
line-height: 1.12;
text-wrap: balance;
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.8);
}
.mp-clock {
position: absolute;
top: calc(var(--safe-t) + 92px);
left: calc(14px + var(--safe-l));
display: flex;
align-items: baseline;
gap: 8px;
text-shadow: 0 1px 10px rgba(0, 0, 0, 0.9);
pointer-events: none;
}
.mp-utc {
font-size: 1.375rem;
font-weight: 700;
color: #fff;
letter-spacing: 0.02em;
}
.mp-rel {
font-size: 0.6875rem;
color: var(--ink-2);
}
/* Tracks legend */
.mp-legend {
position: absolute;
top: calc(var(--safe-t) + 128px);
left: calc(10px + var(--safe-l));
width: min(230px, 54vw);
padding: 5px;
border-radius: 16px;
display: grid;
gap: 1px;
}
.mp-legend[hidden] {
display: none;
}
.mp-tr {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border-radius: 11px;
text-align: left;
color: var(--ink);
transition:
background var(--dur-1) linear,
opacity var(--dur-2) linear;
}
.mp-tr:hover {
background: rgba(255, 255, 255, 0.07);
}
.mp-tr.absent {
opacity: 0.4;
}
.mp-tr .sw {
flex: none;
width: 4px;
height: 26px;
border-radius: 2px;
background: var(--c);
box-shadow: 0 0 10px var(--c);
}
.mp-tr-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.mp-tr-main b {
font-family: var(--mono);
font-size: 0.6875rem;
font-weight: 700;
color: var(--c);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mp-tr-main small {
font-size: 0.625rem;
color: var(--ink-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mp-tr-v {
flex: none;
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 0.5625rem;
color: var(--ink-3);
}
.mp-tr-v b {
color: var(--ink);
font-size: 0.6875rem;
}
/* Capture pops */
.mp-pops {
position: absolute;
top: calc(var(--safe-t) + 128px);
right: calc(10px + var(--safe-r));
width: min(196px, 42vw);
display: grid;
gap: 8px;
pointer-events: none;
}
.mp-pops .mp-cap {
pointer-events: auto;
}
.mp-cap {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
border-radius: 16px;
text-align: left;
color: var(--ink);
background: rgba(10, 12, 16, 0.86);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
box-shadow:
inset 0 0 0 0.5px rgba(235, 199, 119, 0.45),
0 10px 30px rgba(0, 0, 0, 0.5);
}
.mp-cap.first {
box-shadow:
inset 0 0 0 1px rgba(235, 199, 119, 0.85),
0 0 24px rgba(235, 199, 119, 0.25),
0 10px 30px rgba(0, 0, 0, 0.5);
}
.mp-cap.pop {
animation: cap-pop 620ms var(--spring) both;
}
.mp-cap.pop.settle {
transition: opacity 600ms linear;
opacity: 0.85;
}
@keyframes cap-pop {
from {
opacity: 0;
transform: translateX(30px) scale(0.92);
}
}
.cone-thumb {
position: relative;
display: block;
width: 100%;
overflow: hidden;
background: #0b0e14;
}
.cone-thumb img,
.cone-thumb svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.cone-thumb img {
object-fit: cover;
filter: saturate(0.5) brightness(0.7) contrast(1.1);
}
.mp-cap-body {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 10px 10px;
}
.mp-cap-body .fe {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--b-inside);
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.1em;
--seal-ink: #2a1a00;
}
.mp-cap-body .fe .i {
font-size: 0.85rem;
}
.mp-cap-body b {
font-size: var(--t-cap);
font-weight: 650;
line-height: 1.25;
}
.mp-cap-body small {
font-family: var(--mono);
font-size: 0.5625rem;
color: var(--ink-3);
}
/* Reactions */
.mp-react {
position: absolute;
right: calc(10px + var(--safe-r));
bottom: calc(var(--safe-b) + 84px + 31vh);
display: flex;
flex-direction: column;
gap: 6px;
}
.mp-react button {
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 1.15rem;
background: rgba(10, 12, 16, 0.6);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: inset 0 0 0 0.5px var(--hairline-strong);
}
.mp-react button:hover {
background: rgba(30, 34, 44, 0.8);
}
/* Bottom: the Moment scrubber */
.mp-bar {
position: absolute;
left: calc(8px + var(--safe-l));
right: calc(8px + var(--safe-r));
bottom: calc(var(--safe-b) + 10px);
height: 60px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 20px;
}
.mp-play {
flex: none;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
color: #050607;
background: #fff;
font-size: 1.1rem;
}
.mp-play:active {
transform: scale(0.92);
}
.mp-scrub {
position: relative;
flex: 1;
min-width: 0;
height: 46px;
cursor: pointer;
touch-action: none;
}
.mp-scrub canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.mp-head {
position: absolute;
top: 6px;
bottom: 4px;
left: 0;
width: 2px;
margin-left: -1px;
border-radius: 1px;
background: #fff;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.6);
pointer-events: none;
}
.mp.scrubbing .mp-head {
width: 3px;
margin-left: -1.5px;
}
.mp-speeds {
flex: none;
display: flex;
padding: 2px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
}
.mp-speeds button {
min-width: 32px;
height: 30px;
border-radius: 8px;
font-family: var(--mono);
font-size: 0.625rem;
font-weight: 600;
color: var(--ink-3);
}
.mp-speeds button[aria-checked='true'] {
color: #050607;
background: var(--ink);
}
/* Side: chat · record · captures (bottom panel on a phone) */
.mp-side {
position: absolute;
left: calc(8px + var(--safe-l));
right: calc(8px + var(--safe-r));
bottom: calc(var(--safe-b) + 78px);
height: 31vh;
min-height: 210px;
display: flex;
flex-direction: column;
border-radius: 22px;
overflow: hidden;
background: rgba(7, 9, 12, 0.7);
}
.mp-side-head {
flex: none;
padding: 8px 8px 6px;
}
.mp-seg {
display: flex;
width: 100%;
}
.mp-seg button {
flex: 1;
}
.mp-n {
color: var(--ink-3);
font-family: var(--mono);
font-size: 0.625rem;
}
.mp-pane {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 12px 10px;
scrollbar-width: thin;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 22px);
mask-image: linear-gradient(to bottom, transparent 0, #000 22px);
}
.mp-pane[hidden] {
display: none;
}
.mp-pane.has-ext .mp-chat {
display: none;
}
.mp-chat {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100%;
gap: 4px;
padding-top: 16px;
}
.mp-msg {
font-size: var(--t-foot);
line-height: 1.35;
color: var(--ink-2);
overflow-wrap: anywhere;
}
.mp-msg.in {
animation: msg-in 280ms var(--ease-out) both;
}
@keyframes msg-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
.mp-who {
font-weight: 700;
color: var(--b-sky);
}
.mp-msg.b-first-eyes .mp-who,
.mp-msg.b-witness .mp-who {
color: var(--b-inside);
text-shadow: 0 0 10px rgba(235, 199, 119, 0.55);
}
.mp-msg.b-pilot .mp-who {
color: #9be15d;
}
.mp-msg.b-mod .mp-who {
color: #ff9db0;
}
.mp-badge {
display: inline-grid;
place-items: center;
width: 15px;
height: 15px;
margin-right: 4px;
border-radius: 4px;
vertical-align: -2px;
font-size: 0.65rem;
color: #2a1a00;
background: var(--b-inside);
}
.mp-msg.b-pilot .mp-badge {
background: #9be15d;
}
.mp-msg.b-local .mp-badge {
background: var(--b-sky);
color: #0a1a2a;
}
.mp-msg.b-mod .mp-badge {
background: #ff9db0;
}
.mp-txt {
color: var(--ink);
}
.mp-record,
.mp-caps {
display: grid;
gap: 8px;
padding-top: 12px;
}
.mp-caps {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.mp-post {
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
box-shadow: inset 0 0 0 0.5px var(--hairline);
}
.mp-post.in {
animation: msg-in 380ms var(--spring) both;
}
.mp-post header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.mp-pk {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--t-cap2);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-2);
}
.mp-post.k-witness .mp-pk {
color: var(--b-inside);
}
.mp-post.k-correction .mp-pk {
color: #ffb547;
}
.mp-post.k-question .mp-pk {
color: var(--b-sky);
}
.mp-post.k-synthesis {
background: linear-gradient(
135deg,
rgba(179, 157, 255, 0.14),
rgba(179, 157, 255, 0.04)
);
box-shadow: inset 0 0 0 0.5px rgba(179, 157, 255, 0.35);
}
.mp-post.k-synthesis .mp-pk {
color: var(--b-orbit);
}
.mp-post header .tele {
font-size: 0.5625rem;
color: var(--ink-3);
}
.mp-post p {
margin: 0;
font-size: var(--t-foot);
line-height: 1.4;
}
.mp-post footer {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 6px;
color: var(--ink-3);
font-size: 0.5625rem;
}
.mp-post footer span,
.mp-post footer button {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mp-post footer button {
color: var(--b-inside);
}
.mp-empty {
padding: 20px 6px;
color: var(--ink-3);
font-size: 0.625rem;
text-align: center;
}
.mp-src {
flex: none;
margin: 0;
padding: 6px 14px 10px;
color: var(--ink-4);
font-size: 0.5rem;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mp[data-illustrative='true'] .mp-src {
color: rgba(199, 180, 255, 0.6);
}
/* Short phones: the side panel shrinks first */
@media (max-height: 760px) {
.mp-side {
height: 26vh;
min-height: 170px;
}
.mp-react {
bottom: calc(var(--safe-b) + 84px + 26vh);
}
}
/* The social chat overlay brings its own reactions. */
.mp.has-ext .mp-react {
display: none;
}
/* Attribution stays visible, out of the title's way. */
.app.is-moment .gc-attr {
top: auto;
left: calc(14px + var(--safe-l));
bottom: calc(var(--safe-b) + 86px + 31vh);
}
@media (max-height: 760px) {
.app.is-moment .gc-attr {
bottom: calc(var(--safe-b) + 86px + 26vh);
}
}
.mp-legend .mp-tr:nth-child(n + 5) {
display: none;
}
.mp-more {
padding: 4px 10px 6px 20px;
color: var(--ink-3);
font-size: 0.5625rem;
}
.mp-cap.settled {
opacity: 0.88;
}
.mp-side-title {
display: none;
}
.mp-pane.has-ext {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.mp-pane.has-ext[hidden] {
display: none;
}
@media (min-width: 600px) {
.mp-side-title {
display: block;
flex: none;
padding: 6px 18px 4px;
}
.mp-st-k {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
color: var(--b-inside);
font-size: 0.5625rem;
letter-spacing: 0.08em;
}
.mp-side-title h3 {
margin: 6px 0 4px;
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 750;
letter-spacing: -0.03em;
line-height: 1.1;
}
.mp-st-s {
margin: 0;
color: var(--ink-3);
font-size: var(--t-foot);
}
.mp-stage .mp-titles {
visibility: hidden;
}
}
+4
View File
@@ -543,3 +543,7 @@
display: block;
margin-bottom: 4px;
}
.sheet:focus {
outline: none;
}
+4 -4
View File
@@ -91,22 +91,22 @@
}
/* The Globe tab's leading view only exists on regular widths. */
#view-globe {
#view-live {
display: none;
}
/* Phone: the Globe tab and an open situation reveal the stage. */
.app[data-tab='globe'] .lead,
.app[data-tab='live'] .lead,
.app.is-world .lead {
visibility: hidden;
}
.app[data-tab='globe'] .lead .view,
.app[data-tab='live'] .lead .view,
.app.is-world .lead .view {
pointer-events: none;
}
.app:not([data-tab='globe']):not(.is-world) .globe-chrome {
.app:not([data-tab='live']):not(.is-world) .globe-chrome {
visibility: hidden;
}
+285
View File
@@ -0,0 +1,285 @@
/* ------------------------------------------------------------------ */
/* Time machine (DESIGN.md §12.5) */
/* ------------------------------------------------------------------ */
.ribbon.tm {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0;
height: auto;
padding: 6px 6px 4px;
border-radius: 20px;
background: rgba(7, 9, 12, 0.78);
-webkit-backdrop-filter: blur(24px) saturate(1.5);
backdrop-filter: blur(24px) saturate(1.5);
box-shadow:
inset 0 0.5px 0 rgba(255, 255, 255, 0.2),
inset 0 0 0 0.5px var(--hairline-strong),
0 12px 40px rgba(0, 0, 0, 0.45);
}
.tm-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 40px;
}
.tm-play {
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
color: #050607;
background: #f4f7fb;
font-size: 1.05rem;
transition:
transform var(--dur-1) var(--ease-out),
background var(--dur-2) linear;
}
.tm-play:hover {
background: #fff;
}
.tm-play:active {
transform: scale(0.92);
}
.ribbon.tm.is-playing .tm-play {
background: var(--b-sky);
}
.tm-read {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.1;
padding-left: 4px;
}
.tm-delta {
font-family: var(--mono);
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
color: var(--ink);
}
.ribbon.tm.is-live .tm-delta {
color: #ff6b61;
}
.ribbon.tm[data-future='true'] .tm-delta {
color: var(--b-orbit);
}
.tm-abs {
font-family: var(--mono);
font-size: 0.6875rem;
font-weight: 500;
color: var(--ink-3);
letter-spacing: 0.05em;
white-space: nowrap;
}
.tm-speeds {
flex: none;
display: flex;
padding: 2px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
}
.tm-speeds button {
min-width: 34px;
height: 30px;
padding: 0 4px;
border-radius: 8px;
font-family: var(--mono);
font-size: 0.6875rem;
font-weight: 600;
color: var(--ink-3);
transition:
background var(--dur-1) linear,
color var(--dur-1) linear;
}
.tm-speeds button:hover {
color: var(--ink);
}
.tm-speeds button[aria-checked='true'] {
color: #050607;
background: var(--ink);
}
.tm-clip.icon-btn {
flex: none;
width: 34px;
height: 34px;
font-size: 1.05rem;
color: var(--ink-2);
background: rgba(255, 255, 255, 0.06);
}
.tm-clip[aria-pressed='true'] {
color: #2a1a00;
background: var(--b-inside);
}
.ribbon.tm .tm-now {
height: 34px;
padding: 0 10px;
border-radius: 10px;
}
.tm-track.rb-track {
flex: none;
height: 52px;
margin: 2px 0 0;
border-radius: 12px;
cursor: grab;
}
.ribbon.tm.scrubbing .tm-track {
cursor: grabbing;
}
.ribbon.tm.is-clipping .tm-track {
cursor: crosshair;
}
.tm-head.rb-head {
top: 4px;
bottom: 4px;
width: 2px;
margin-left: -1px;
background: #fff;
box-shadow: 0 0 12px rgba(255, 255, 255, 0.55);
}
.tm-head i {
position: absolute;
top: -2px;
left: 50%;
width: 9px;
height: 6px;
margin-left: -4.5px;
background: inherit;
clip-path: polygon(0 0, 100% 0, 50% 100%);
}
.ribbon.tm.is-live .tm-head {
background: #ff453a;
box-shadow: 0 0 12px rgba(255, 69, 58, 0.7);
}
.ribbon.tm.is-playing .tm-head {
background: var(--b-sky);
box-shadow: 0 0 14px rgba(163, 216, 255, 0.8);
animation: none;
}
.tm-callout {
position: absolute;
left: 50%;
bottom: calc(100% + 6px);
transform: translateX(-50%);
max-width: 82%;
padding: 5px 9px;
border-radius: 8px;
background: rgba(8, 10, 14, 0.92);
box-shadow: inset 0 0 0 0.5px var(--hairline-strong);
color: var(--ink);
font-size: 0.6875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-left: 3px solid var(--c, #fff);
pointer-events: none;
animation: tm-pop 260ms var(--spring) both;
}
@keyframes tm-pop {
from {
opacity: 0;
transform: translate(-50%, 6px) scale(0.96);
}
}
.tm-cliprow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 2px 2px;
}
.tm-cliprow[hidden] {
display: none;
}
.tm-cliplen {
flex: 1;
min-width: 0;
color: var(--ink-2);
font-size: 0.6875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tm-cliplen b {
color: var(--b-inside);
}
.tm-cliprow .pill-btn {
height: 34px;
padding: 0 12px;
font-size: var(--t-foot);
}
.tm-clipgo.pill-btn.primary {
background: var(--b-inside);
color: #2a1a00;
}
.tm-clipgo:disabled {
opacity: 0.4;
}
.ribbon.tm.custom .tm-speeds,
.ribbon.tm.custom .tm-clip,
.ribbon.tm.custom .tm-play {
display: none;
}
@media (max-width: 380px) {
.tm-speeds button {
min-width: 28px;
}
.tm-delta {
font-size: 0.9375rem;
}
}
@media (hover: hover) {
.tm-track:hover {
background: rgba(255, 255, 255, 0.02);
}
}
/* The tab-bar accessory takes a row above the tab bar: chrome sits above it. */
:root.has-accessory {
--chrome-bottom: calc(var(--tabbar-h) + var(--tabbar-gap) + 66px);
}
.tm-cliplen {
white-space: normal;
line-height: 1.35;
}
+806
View File
@@ -0,0 +1,806 @@
/* ------------------------------------------------------------------ */
/* Today: the daily brief (DESIGN.md §12.3) */
/* ------------------------------------------------------------------ */
/* Shared honesty labels */
.tag-ill,
.tag-real {
display: inline-flex;
align-items: center;
height: 17px;
padding: 0 6px;
border-radius: 5px;
font-family: var(--mono);
font-size: 0.5625rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
vertical-align: middle;
}
.tag-ill {
color: #e9d8ff;
background: rgba(179, 157, 255, 0.18);
box-shadow: inset 0 0 0 0.5px rgba(179, 157, 255, 0.45);
}
.tag-real {
color: #c9f7d6;
background: rgba(48, 209, 88, 0.16);
box-shadow: inset 0 0 0 0.5px rgba(48, 209, 88, 0.4);
}
.glass-card {
border-radius: 20px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.055),
rgba(255, 255, 255, 0.025)
);
box-shadow:
inset 0 0.5px 0 rgba(255, 255, 255, 0.14),
inset 0 0 0 0.5px var(--hairline);
}
/* Header: date kicker, greeting, UTC */
.today-hero {
align-items: flex-end;
flex-wrap: wrap;
row-gap: 0;
}
.today-date {
flex-basis: 100%;
padding: 0 calc(var(--gutter) + var(--safe-r)) 2px
calc(var(--gutter) + var(--safe-l));
margin-top: -4px;
color: var(--b-sky);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.1em;
}
.today-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 10px;
margin: 0;
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.today-time {
color: var(--ink-3);
font-weight: 600;
}
.today-hero .feed-clock b {
font-size: 0.6875rem;
color: var(--ink-3);
font-weight: 500;
}
.fresh-chip {
height: 28px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 14px;
color: #c9f7d6;
background: rgba(48, 209, 88, 0.14);
box-shadow: inset 0 0 0 0.5px rgba(48, 209, 88, 0.35);
font-size: 0.625rem;
white-space: nowrap;
}
.fresh-chip[hidden] {
display: none;
}
.fresh-chip.in {
animation: fresh-in 4.2s var(--ease-out) both;
}
@keyframes fresh-in {
0% {
opacity: 0;
transform: translateY(-6px) scale(0.96);
}
8%,
86% {
opacity: 1;
transform: none;
}
100% {
opacity: 0;
}
}
/* The brief */
.brief {
display: grid;
gap: 12px;
padding: 16px calc(12px + var(--safe-r)) 6px calc(12px + var(--safe-l));
}
.bf-h {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 13px 16px 6px;
}
.bf-h h3 {
margin: 0;
font-family: var(--font-display);
font-size: var(--t-headline);
font-weight: 700;
letter-spacing: -0.02em;
}
.bf-h .tele {
color: var(--ink-3);
font-size: 0.625rem;
}
.bf-h .chev {
color: var(--ink-3);
font-size: 0.9rem;
}
.bf-rows,
[data-tonight],
[data-watch] {
display: grid;
padding: 0 4px 6px;
}
.bf-row {
position: relative;
display: flex;
align-items: center;
gap: 11px;
width: 100%;
min-height: 52px;
padding: 8px 12px;
border-radius: 14px;
text-align: left;
color: var(--ink);
transition: background var(--dur-1) linear;
}
button.bf-row:hover {
background: rgba(255, 255, 255, 0.05);
}
button.bf-row:active {
background: rgba(255, 255, 255, 0.08);
}
.bf-row + .bf-row::before {
content: '';
position: absolute;
top: 0;
left: 34px;
right: 12px;
height: 0.5px;
background: var(--hairline);
}
.bf-row .bd {
flex: none;
width: 9px;
height: 9px;
border-radius: 3px;
background: var(--band, var(--ink-3));
box-shadow: 0 0 10px var(--band, transparent);
}
.bf-row[data-band='orbit'] {
--band: var(--b-orbit);
}
.bf-row[data-band='sky'] {
--band: var(--b-sky);
}
.bf-row[data-band='ground'] {
--band: var(--b-ground);
}
.bf-row[data-band='inside'] {
--band: var(--b-inside);
}
.bf-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.bf-main b {
font-size: var(--t-sub);
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bf-main small {
color: var(--ink-3);
font-size: var(--t-cap);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bf-when {
flex: none;
color: var(--ink-3);
font-size: 0.5625rem;
}
.bf-ic {
flex: none;
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
font-size: 1rem;
color: var(--band, var(--ink-2));
background: color-mix(in srgb, var(--band, #fff) 14%, transparent);
}
.bf-ok {
color: var(--ok);
}
.bf-row .chev {
color: var(--ink-4);
}
.bf-add {
color: var(--tint);
font-size: var(--t-foot);
}
.bf-add .i {
font-size: 1.05rem;
}
.bf-calm {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px 12px;
color: var(--ink-2);
font-size: var(--t-foot);
}
.bf-calm .i {
color: var(--ok);
}
.bf-ph i {
width: 9px;
height: 9px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
}
.bf-ph span {
flex: 1;
height: 12px;
border-radius: 6px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05),
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.05)
);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
to {
background-position: -200% 0;
}
}
/* Today's Moment: plays inline */
.bf-moment {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 11;
border-radius: 22px;
overflow: hidden;
text-align: left;
color: #fff;
background: radial-gradient(120% 90% at 50% 30%, #18243a, #07090d);
box-shadow:
inset 0 0 0 0.5px rgba(255, 255, 255, 0.16),
0 18px 40px rgba(0, 0, 0, 0.45);
isolation: isolate;
transition: transform var(--dur-2) var(--spring);
}
.bf-moment:active {
transform: scale(0.985);
}
.bf-mimg,
.bf-mcanvas,
.bf-mshade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.bf-mimg {
object-fit: cover;
opacity: 0;
filter: saturate(0.6) brightness(0.62) contrast(1.1);
transition: opacity 600ms linear;
}
.bf-mimg.loaded {
opacity: 1;
}
.bf-mshade {
background:
linear-gradient(180deg, rgba(5, 7, 11, 0.7) 0, rgba(5, 7, 11, 0) 34%),
linear-gradient(0deg, rgba(5, 7, 11, 0.92) 0, rgba(5, 7, 11, 0) 52%);
pointer-events: none;
}
.bf-mtop {
position: absolute;
top: 14px;
left: 16px;
right: 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.625rem;
}
.bf-mk {
color: var(--b-inside);
font-weight: 700;
letter-spacing: 0.12em;
}
.bf-mbody {
position: absolute;
left: 16px;
right: 72px;
bottom: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.bf-mtitle {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 750;
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
}
.bf-msub {
color: rgba(255, 255, 255, 0.7);
font-size: var(--t-cap);
line-height: 1.3;
}
.bf-mplay {
position: absolute;
right: 16px;
bottom: 18px;
width: 48px;
height: 48px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 1.2rem;
color: #050607;
background: #fff;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
transition: transform var(--dur-2) var(--spring);
}
.bf-moment:hover .bf-mplay {
transform: scale(1.06);
}
.bf-mclock {
position: absolute;
top: 36px;
left: 16px;
color: rgba(255, 255, 255, 0.6);
font-size: 0.5625rem;
}
.bf-moment:not(.ready) .bf-mplay {
opacity: 0.4;
}
/* Your sky now (a button) + Overhead tonight */
.bf-sky {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 12px 14px 12px 12px;
text-align: left;
color: var(--ink);
transition:
background var(--dur-1) linear,
transform var(--dur-2) var(--spring);
}
.bf-sky:hover {
background: rgba(255, 255, 255, 0.06);
}
.bf-sky:active {
transform: scale(0.99);
}
.bf-dome {
flex: none;
width: 92px;
height: 92px;
display: grid;
place-items: center;
}
.bf-dome .dome {
width: 92px;
height: 92px;
}
.bf-skytxt {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.bf-skyk {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--b-sky);
font-size: 0.5625rem;
font-weight: 600;
letter-spacing: 0.1em;
}
.bf-skyk .live-dot {
--fam: var(--b-sky);
width: 5px;
height: 5px;
}
.bf-skyh {
font-family: var(--font-display);
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.2;
text-wrap: balance;
}
.bf-skytxt small {
color: var(--ink-3);
font-size: var(--t-cap);
line-height: 1.3;
}
.bf-sky > .chev {
flex: none;
color: var(--ink-4);
}
.bf-tonight .bf-when {
color: var(--b-orbit);
font-size: 0.625rem;
font-weight: 600;
}
/* Sections below the brief */
.feed-section-h {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 22px calc(var(--gutter) + var(--safe-r)) 0
calc(var(--gutter) + var(--safe-l));
}
.feed-section-h h2 {
margin: 0;
font-family: var(--font-display);
font-size: var(--t-title3);
font-weight: 750;
letter-spacing: -0.025em;
}
.feed-section-h .tele {
color: var(--ink-3);
font-size: 0.5625rem;
}
#view-today .bandbar {
margin-top: 6px;
}
/* Sky dome (shared by the widget and the sheet) */
.dome .dm-ring {
fill: none;
stroke: rgba(255, 255, 255, 0.1);
stroke-width: 0.8;
}
.dome .dm-horizon {
fill: none;
stroke: rgba(163, 216, 255, 0.35);
stroke-width: 1;
}
.dome .dm-card {
fill: var(--ink-3);
font: 600 10px var(--mono);
text-anchor: middle;
}
.dome .dm-card.n {
fill: #ff6b61;
}
.dome .dm-el {
fill: var(--ink-4);
font: 500 8px var(--mono);
}
.dome .dm-ac path {
fill: var(--b-sky);
stroke: #050607;
stroke-width: 0.8;
}
.dome .dm-sat rect {
fill: var(--b-orbit);
}
.dome .dm-iss circle {
fill: #fff;
}
.dome .dm-iss .ring {
fill: none;
stroke: #fff;
stroke-opacity: 0.5;
}
.dome .dm-iss text {
fill: #fff;
font: 700 9px var(--mono);
}
.dome .dm-you {
fill: var(--b-inside);
}
/* Your sky now sheet */
.sky-kicker {
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-3);
font-size: 0.625rem;
padding-top: 2px;
}
.sky-top {
display: grid;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
gap: 10px;
padding: 8px 0 4px;
}
.sky-dome .dome {
width: min(300px, 78vw);
height: auto;
filter: drop-shadow(0 18px 40px rgba(0, 0, 0, 0.5));
}
.sky-count {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 100%;
max-width: 420px;
gap: 8px;
}
.sky-count div {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px 4px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
}
.sky-count b {
font-family: var(--font-display);
font-size: 1.375rem;
font-weight: 750;
letter-spacing: -0.02em;
}
.sky-count span {
color: var(--ink-3);
font-size: var(--t-cap2);
}
.sky-iss {
display: flex;
gap: 12px;
align-items: center;
margin: 12px 0 4px;
padding: 12px 14px;
border-radius: 16px;
background: linear-gradient(
135deg,
rgba(179, 157, 255, 0.18),
rgba(179, 157, 255, 0.05)
);
box-shadow: inset 0 0 0 0.5px rgba(179, 157, 255, 0.35);
}
.sky-iss > .i {
flex: none;
font-size: 1.4rem;
color: var(--b-orbit);
}
.sky-iss div {
display: flex;
flex-direction: column;
gap: 2px;
}
.sky-iss b {
font-size: var(--t-sub);
}
.sky-iss span {
color: var(--ink-2);
font-size: var(--t-cap);
}
.sky-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 54px;
padding: 8px 14px;
text-align: left;
color: var(--ink);
position: relative;
}
.sky-row + .sky-row::before {
content: '';
position: absolute;
top: 0;
left: 54px;
right: 0;
height: 0.5px;
background: var(--hairline);
}
.sky-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.sky-ic {
flex: none;
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
font-size: 1rem;
}
.sky-row[data-band='sky'] .sky-ic {
color: var(--b-sky);
background: rgba(163, 216, 255, 0.12);
}
.sky-row[data-band='orbit'] .sky-ic {
color: var(--b-orbit);
background: rgba(179, 157, 255, 0.12);
}
.sky-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sky-main b {
font-family: var(--mono);
font-size: var(--t-foot);
font-weight: 600;
}
.sky-main small {
color: var(--ink-3);
font-size: var(--t-cap);
}
.sky-el {
flex: none;
display: flex;
flex-direction: column;
align-items: flex-end;
color: var(--ink-3);
font-size: 0.5rem;
}
.sky-el b {
color: var(--ink);
font-size: 0.875rem;
}
.section-h small {
font-family: var(--mono);
}
@media (min-width: 600px) {
.bf-mtitle {
font-size: 1.375rem;
}
}
.bf-rows,
[data-tonight],
[data-watch],
.brief {
grid-template-columns: minmax(0, 1fr);
}
/* UTC rides on the date row, right-aligned; never wraps under the title. */
.today-hero {
position: relative;
}
.today-hero .feed-clock {
position: absolute;
top: -4px;
right: calc(var(--gutter) + var(--safe-r));
padding: 0;
}
+327
View File
@@ -0,0 +1,327 @@
/* ------------------------------------------------------------------ */
/* Watch & alerts, banners, director, pane sheets */
/* ------------------------------------------------------------------ */
/* Pane sheets: the whole screen on a phone (above the tab bar). */
.pane-sheets {
z-index: 58;
}
/* Rules as sentences with chips */
.rules {
display: grid;
gap: 8px;
}
.rule {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
align-items: center;
column-gap: 12px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.045);
box-shadow: inset 0 0 0 0.5px var(--hairline);
--rc: var(--ink-2);
}
.rule[data-band='orbit'] {
--rc: var(--b-orbit);
}
.rule[data-band='sky'] {
--rc: var(--b-sky);
}
.rule[data-band='ground'] {
--rc: var(--b-ground);
}
.rule .bd {
grid-row: 1 / span 2;
width: 4px;
height: 100%;
min-height: 34px;
border-radius: 2px;
background: var(--rc);
}
.rule p {
margin: 0;
font-size: var(--t-sub);
line-height: 1.9;
color: var(--ink-2);
}
.rule .switch {
grid-row: 1 / span 2;
grid-column: 3;
}
.rule-state {
grid-column: 2;
color: var(--ink-3);
font-size: 0.5625rem;
}
.rule-chip {
display: inline-flex;
align-items: center;
gap: 3px;
height: 28px;
padding: 0 6px 0 10px;
border-radius: 8px;
color: var(--ink);
font-weight: 600;
background: color-mix(in srgb, var(--rc) 18%, rgba(255, 255, 255, 0.04));
box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--rc) 45%, transparent);
vertical-align: baseline;
transition: background var(--dur-1) linear;
}
.rule-chip:hover {
background: color-mix(in srgb, var(--rc) 28%, rgba(255, 255, 255, 0.04));
}
.rule-chip .i {
font-size: 0.7rem;
color: var(--ink-3);
}
.new-rule {
margin-top: 10px;
}
.row-add {
color: var(--tint);
gap: 10px;
}
.row-icon[data-band='orbit'] {
color: var(--b-orbit);
}
.row-icon[data-band='sky'] {
color: var(--b-sky);
}
.row-icon[data-band='ground'] {
color: var(--b-ground);
}
/* iOS-style notification banners */
.banners {
position: absolute;
top: calc(var(--safe-t) + 8px);
left: 0;
right: 0;
z-index: 80;
display: grid;
justify-items: center;
gap: 8px;
padding: 0 10px;
pointer-events: none;
}
.banner {
pointer-events: auto;
width: min(420px, 100%);
display: flex;
align-items: flex-start;
gap: 11px;
padding: 12px 14px;
border-radius: 22px;
text-align: left;
color: var(--ink);
background: rgba(28, 32, 40, 0.78);
-webkit-backdrop-filter: blur(30px) saturate(1.8);
backdrop-filter: blur(30px) saturate(1.8);
box-shadow:
inset 0 0.5px 0 rgba(255, 255, 255, 0.25),
inset 0 0 0 0.5px rgba(255, 255, 255, 0.08),
0 16px 40px rgba(0, 0, 0, 0.5);
touch-action: none;
animation: banner-in 560ms var(--spring) both;
transition:
transform 260ms var(--ease-out),
opacity 260ms linear;
--bc: var(--b-sky);
}
.banner[data-band='orbit'] {
--bc: var(--b-orbit);
}
.banner[data-band='ground'] {
--bc: var(--b-ground);
}
.banner[data-band='inside'] {
--bc: var(--b-inside);
}
.banner.out {
opacity: 0;
transform: translateY(-40px) scale(0.96);
}
@keyframes banner-in {
from {
opacity: 0;
transform: translateY(-60px) scale(0.94);
}
}
.bn-app {
flex: none;
width: 38px;
height: 38px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 1.3rem;
color: #050607;
background: linear-gradient(145deg, #ffe3a3, var(--b-inside));
box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.4);
}
.bn-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.bn-top {
display: flex;
justify-content: space-between;
color: var(--ink-3);
font-size: var(--t-cap2);
letter-spacing: 0.02em;
}
.bn-top b {
font-weight: 600;
}
.bn-title {
display: flex;
align-items: center;
gap: 5px;
font-size: var(--t-sub);
font-weight: 650;
}
.bn-title .i {
flex: none;
color: var(--bc);
font-size: 0.95rem;
}
.bn-body {
color: var(--ink-2);
font-size: var(--t-foot);
line-height: 1.3;
}
/* Director */
.dir-kicker {
display: flex;
align-items: center;
gap: 6px;
color: #ff9db0;
font-size: 0.625rem;
padding-top: 2px;
}
.dir-beats {
list-style: none;
margin: 6px 0 0;
padding: 0;
display: grid;
gap: 6px;
}
.dir-beat {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border-radius: 14px;
text-align: left;
color: var(--ink);
background: rgba(255, 255, 255, 0.04);
transition: background var(--dur-1) linear;
}
.dir-beat:hover {
background: rgba(255, 255, 255, 0.08);
}
.dir-beat.done {
box-shadow: inset 0 0 0 1px rgba(255, 157, 176, 0.5);
}
.dir-n {
flex: none;
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
font-family: var(--mono);
font-size: 0.75rem;
font-weight: 700;
color: #050607;
background: var(--ink);
}
.dir-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.dir-main b {
font-size: var(--t-sub);
font-weight: 650;
}
.dir-main small {
color: var(--ink-3);
font-size: var(--t-cap);
}
.dir-beat > .i {
color: var(--ink-3);
}
.dir-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
justify-content: space-between;
margin-top: 16px;
}
.dir-foot .tele {
color: var(--ink-4);
font-size: 0.5rem;
}
/* Brief "as of" chip on an open situation */
.brief-asof {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 8px;
padding: 4px 8px;
border-radius: 7px;
color: var(--b-sky);
background: rgba(163, 216, 255, 0.1);
font-size: 0.5625rem;
}
.brief-asof[hidden] {
display: none;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Director (DESIGN.md §12.8): a presenter menu for demos. Press "." (or
* triple-tap the Today title) to open it; each beat sets up its state
* deterministically so the demo never depends on luck. Number keys 1–9 jump
* straight to a beat while the menu is closed and the director is armed.
*/
import { icon } from './icons.js';
import { esc, haptic, toast } from './dom.js';
import { createSheet } from './sheet.js';
export function createDirector({ beats, onReset }) {
const sheet = createSheet({
label: 'Director',
detents: ['large'],
modal: true,
dismissible: true,
closeButton: true,
className: 'director-sheet',
heights: { large: (H) => Math.min(Math.round(H * 0.9), 820) },
});
let armed = false;
let current = -1;
function render() {
sheet.head.innerHTML = `<div class="dir-kicker tele">${icon('director')}DIRECTOR · DEMO MODE</div><h2 class="sheet-title">The story, beat by beat</h2><div class="sheet-sub">Each beat sets its own state. Keys 1–9 jump; “.” reopens this menu.</div>`;
sheet.body.innerHTML = `<ol class="dir-beats">${beats
.map(
(
b,
i,
) => `<li><button class="dir-beat ${i === current ? 'done' : ''}" type="button" data-beat="${i}">
<span class="dir-n num">${i + 1}</span><span class="dir-main"><b>${esc(b.title)}</b><small>${esc(b.note || '')}</small></span>${icon(b.icon || 'chevronRight')}</button></li>`,
)
.join('')}</ol>
<div class="dir-foot"><button class="pill-btn plain" type="button" data-reset>${icon('refresh')}Reset demo</button><span class="tele">Real data shows its source and age · illustrative content is labelled</span></div>`;
}
async function run(i) {
const b = beats[i];
if (!b) return;
current = i;
armed = true;
haptic('medium');
if (sheet.isOpen) sheet.close();
try {
await b.run();
} catch (err) {
console.error('[director] beat failed', err);
toast(`Beat “${b.title}” could not be set up`, { icon: icon('info') });
}
}
sheet.body.addEventListener('click', async (e) => {
const b = e.target.closest('[data-beat]');
if (b) return run(Number(b.dataset.beat));
if (e.target.closest('[data-reset]')) {
sheet.close();
current = -1;
await onReset?.();
toast('Demo reset', { icon: icon('refresh') });
}
});
document.addEventListener('keydown', (e) => {
if (e.target.closest?.('input, textarea, [contenteditable]')) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === '.') {
e.preventDefault();
if (sheet.isOpen) sheet.close();
else open();
return;
}
if (armed && /^[1-9]$/.test(e.key) && !sheet.isOpen) run(Number(e.key) - 1);
});
function open() {
armed = true;
render();
sheet.open('large');
}
/** Triple-tap (or triple-click) on an element opens the director. */
function bindTripleTap(el) {
let taps = [];
el.addEventListener('pointerup', () => {
const n = performance.now();
taps = [...taps.filter((t) => n - t < 650), n];
if (taps.length >= 3) {
taps = [];
open();
}
});
}
return {
open,
run,
bindTripleTap,
get isOpen() {
return sheet.isOpen;
},
};
}
+473
View File
@@ -0,0 +1,473 @@
/**
* Dossiers (DESIGN.md §12.1): every object has identity, live state, a track
* history, events, links and receipts. Tapping any entity anywhere (a globe
* pick, a search result, a track in a Moment, a node in the link graph)
* opens one. The Graph segment shows globe.graph(ref); globe, time machine
* and graph share one selection.
*/
import { icon } from './icons.js';
import {
$,
$$,
esc,
ago,
fmtNum,
haptic,
toast,
store,
sparkPath,
} from './dom.js';
import { createSheet } from './sheet.js';
import { createLinkGraph } from './linkGraph.js';
import { BAND_NAME, utc, llShort, mgrs, cardinal } from './bands.js';
import { bandOfKind } from './shim3.js';
export const KIND_ICON = {
aircraft: 'sky',
helicopter: 'heli',
military: 'sky',
satellite: 'satellite',
station: 'satellite',
vessel: 'sea',
quake: 'hazards',
fire: 'flame',
camera: 'video',
situation: 'now',
place: 'pin',
moment: 'film',
track: 'scope',
capture: 'seal',
};
export const KIND_NAME = {
aircraft: 'Aircraft',
helicopter: 'Helicopter',
military: 'Military aircraft',
satellite: 'Satellite',
vessel: 'Vessel',
quake: 'Earthquake',
camera: 'Public camera',
situation: 'Situation',
place: 'Place',
moment: 'Moment',
track: 'Track',
capture: 'Sealed capture',
};
const ID_LABEL = {
icao24: 'ICAO 24-bit',
callsign: 'Callsign',
registration: 'Registration',
norad: 'NORAD ID',
mmsi: 'MMSI',
};
const cap = (x) => (x ? `${x[0].toUpperCase()}${x.slice(1)}` : x);
function liveRows(d) {
const L = d.live;
if (!L) return [];
const rows = [];
if (Number.isFinite(L.altM) && L.altM > 0)
rows.push([
'Altitude',
L.altM > 100e3
? fmtNum(Math.round(L.altM / 1000))
: fmtNum(Math.round((L.altM * 3.28084) / 10) * 10),
L.altM > 100e3 ? 'km' : 'ft',
]);
if (Number.isFinite(L.speed) && L.speed > 0)
rows.push(
d.kind === 'satellite'
? ['Speed', (L.speed / 1000).toFixed(2), 'km/s']
: ['Speed', fmtNum(Math.round(L.speed * 1.94384)), 'kt'],
);
if (Number.isFinite(L.heading))
rows.push([
'Heading',
`${Math.round(((L.heading % 360) + 360) % 360)}°`,
cardinal(L.heading),
]);
if (L.squawk) rows.push(['Squawk', L.squawk, '']);
if (!rows.length && Number.isFinite(L.lat))
rows.push(['Position', llShort(L.lat, L.lon, 3), '']);
return rows.slice(0, 4);
}
/** Altitude profile (area) + ground track (polyline), both from real points. */
function trackHtml(d) {
const tr = (d.track || []).filter((p) => Number.isFinite(p.t));
if (tr.length < 3) return '';
if (Number.isFinite(tr[0].v)) {
const W = 300;
const H = 64;
const path = sparkPath(
tr.map((p) => p.v),
W,
H,
3,
);
return `<section class="ds-block"><h3 class="ds-h">History<small>${tr.length} samples · ${esc(ago(tr[0].t))} → now</small></h3>
<svg class="ds-alt" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"><path class="ds-area" d="${path}L${W - 3} ${H}L3 ${H}Z"/><path class="ds-line" d="${path}"/></svg></section>`;
}
const W = 300;
const H = 70;
const alts = tr.map((p) => (Number.isFinite(p.altM) ? p.altM : 0));
const hi = Math.max(...alts);
const lo = Math.min(0, ...alts);
const t0 = tr[0].t;
const t1 = tr[tr.length - 1].t;
const px = (t) => 3 + ((t - t0) / Math.max(1, t1 - t0)) * (W - 6);
const py = (a) => H - 4 - ((a - lo) / Math.max(1, hi - lo)) * (H - 14);
const line = tr
.map(
(p, i) =>
`${i ? 'L' : 'M'}${px(p.t).toFixed(1)} ${py(alts[i]).toFixed(1)}`,
)
.join('');
// Ground track, equirectangular around its centre.
const lons = tr.map((p) => p.lon);
const lats = tr.map((p) => p.lat);
const cl = Math.cos(
((Math.min(...lats) + Math.max(...lats)) / 2) * (Math.PI / 180),
);
const spanX = (Math.max(...lons) - Math.min(...lons)) * cl || 1e-6;
const spanY = Math.max(...lats) - Math.min(...lats) || 1e-6;
const S = 84;
const k = (S - 12) / Math.max(spanX, spanY);
const gx = (lon) =>
S / 2 + (lon - (Math.min(...lons) + Math.max(...lons)) / 2) * cl * k;
const gy = (lat) =>
S / 2 - (lat - (Math.min(...lats) + Math.max(...lats)) / 2) * k;
const g = tr
.map(
(p, i) =>
`${i ? 'L' : 'M'}${gx(p.lon).toFixed(1)} ${gy(p.lat).toFixed(1)}`,
)
.join('');
const last = tr[tr.length - 1];
const isOrbit = d.kind === 'satellite';
return `<section class="ds-block"><h3 class="ds-h">Track<small>${tr.length} points · ${esc(utc(t0, { seconds: false }))} → ${esc(utc(t1, { seconds: false }))}</small></h3>
<div class="ds-track">
<figure class="ds-fig"><svg class="ds-alt" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-label="Altitude profile">
<path class="ds-area" d="${line}L${px(t1)} ${H}L${px(t0)} ${H}Z"/><path class="ds-line" d="${line}"/>
<circle class="ds-end" cx="${px(t1)}" cy="${py(alts[alts.length - 1])}" r="3"/></svg>
<figcaption class="tele"><span>${isOrbit ? 'ALT' : 'ALT PROFILE'}</span><span>${esc(isOrbit ? `${fmtNum(Math.round(hi / 1000))} KM` : `${fmtNum(Math.round((hi * 3.28084) / 10) * 10)} FT MAX`)}</span></figcaption></figure>
<figure class="ds-fig ds-ground"><svg viewBox="0 0 ${S} ${S}" aria-label="Ground track"><path class="ds-gline" d="${g}"/><circle class="ds-end" cx="${gx(last.lon)}" cy="${gy(last.lat)}" r="3.2"/></svg>
<figcaption class="tele"><span>GROUND</span></figcaption></figure>
</div></section>`;
}
export function createDossier({
globe,
host,
onOpenSituation,
onOpenMoment,
onOpenRoom,
onShowOnTime,
watch,
}) {
const sheet = createSheet({
host,
label: 'Dossier',
detents: ['medium', 'large'],
dismissible: true,
closeButton: true,
className: 'dossier-sheet',
heights: {
medium: (H) => Math.min(Math.round(H * 0.56), 560),
large: (H) => Math.round(H * 0.93),
},
});
let stack = [];
let d = null;
let seg = 'dossier';
let graph = null;
let refreshTimer = 0;
let token = 0;
sheet.on('close', () => {
clearInterval(refreshTimer);
graph?.destroy();
graph = null;
globe.select(null);
stack = [];
d = null;
});
function headHtml() {
const band = bandOfKind(d.kind);
const status = d.illustrative
? '<span class="tag-ill">Illustrative</span>'
: d.live && Date.now() - (d.live.updatedAt || 0) < 5 * 60e3
? '<span class="live-tag"><span class="live-dot"></span>Live</span>'
: '<span>Recorded</span>';
return `<div class="ds-head" data-band="${band}">
${stack.length > 1 ? `<button class="ds-back icon-btn" type="button" aria-label="Back to ${esc(stack[stack.length - 2].title || 'previous')}">${icon('chevronLeft')}</button>` : ''}
<div class="ds-kicker tele"><i class="bd"></i><span>${esc(KIND_NAME[d.kind] || d.kind)}</span><span>·</span><span>${esc(BAND_NAME[band])}</span>${status}</div>
<div class="ds-title-row"><span class="ds-ic">${icon(KIND_ICON[d.kind] || 'scope')}</span><div><h2 class="sheet-title">${esc(d.title)}</h2><div class="sheet-sub">${esc(d.subtitle || '')}</div></div></div>
<div class="segmented ds-seg" role="tablist" aria-label="View">
<button role="tab" type="button" data-seg="dossier" aria-selected="${seg === 'dossier'}">Dossier</button>
<button role="tab" type="button" data-seg="graph" aria-selected="${seg === 'graph'}">${icon('graph')}Links graph</button>
</div></div>`;
}
function followKey() {
return d.kind === 'situation' ? d.id.replace(/^situation:/, '') : d.id;
}
function bodyHtml() {
const rows = liveRows(d);
const followed = store.isFollowed(followKey());
const ident = Object.entries(d.identity || {}).filter(
([, v]) => v != null && v !== '',
);
const L = d.live;
const isMoment = d.kind === 'moment';
return `
${
rows.length
? `<div class="ds-live">${rows.map(([k, v, u]) => `<div><span class="tele">${esc(k)}</span><b class="num">${esc(v)}<small>${esc(u)}</small></b></div>`).join('')}</div>
<div class="ds-updated tele">${L?.updatedAt ? `<span class="live-dot"></span>UPDATED <span data-ago="${L.updatedAt}">${esc(ago(L.updatedAt).toUpperCase())}</span>` : ''}${Number.isFinite(L?.lat) ? ` · ${esc(mgrs(L.lat, L.lon, 4) || llShort(L.lat, L.lon, 3))}` : ''}</div>`
: ''
}
<div class="ds-actions" role="group" aria-label="Actions">
${isMoment ? `<button class="ds-act primary" type="button" data-ds="play">${icon('play')}<span>Play</span></button>` : ''}
<button class="ds-act" type="button" data-ds="follow" aria-pressed="${followed}">${icon(followed ? 'starFill' : 'star')}<span>${followed ? 'Following' : 'Follow'}</span></button>
<button class="ds-act" type="button" data-ds="alert">${icon('bell')}<span>Alert me</span></button>
<button class="ds-act" type="button" data-ds="room">${icon('rooms')}<span>Open room</span></button>
<button class="ds-act" type="button" data-ds="time">${icon('clock')}<span>Time machine</span></button>
</div>
${d.privacy ? `<div class="ds-privacy">${icon('lock')}<span><b>Owner privacy (${esc(String(d.privacy).toUpperCase())})</b> Identity is limited to what the transponder broadcasts. We never name aircraft on privacy lists.</span></div>` : ''}
${trackHtml(d)}
${ident.length ? `<section class="ds-block"><h3 class="ds-h">Identity</h3><dl class="ds-id">${ident.map(([k, v]) => `<dt>${esc(ID_LABEL[k] || cap(k))}</dt><dd class="${/icao|norad|mmsi|registration/.test(k) ? 'mono' : ''}">${esc(v)}</dd>`).join('')}</dl></section>` : ''}
${
d.events?.length
? `<section class="ds-block"><h3 class="ds-h">Events<small>tap to see it on the time machine</small></h3><ol class="ds-events">${d.events
.slice(-8)
.map(
(e) =>
`<li><button type="button" data-t="${e.t}"><span class="tele">${esc(utc(e.t, { seconds: false }))}</span><span>${esc(e.title)}</span><small>${esc(ago(e.t))}</small></button></li>`,
)
.join('')}</ol></section>`
: ''
}
${
d.links?.length
? `<section class="ds-block"><h3 class="ds-h">Links<small>${d.links.length} · things, places and events only</small></h3><div class="ds-links">${d.links
.slice(0, 12)
.map(
(l) =>
`<button class="ds-link" type="button" data-link="${esc(l.id)}" data-band="${bandOfKind(l.kind)}"><span class="ds-link-ic">${icon(KIND_ICON[l.kind] || 'scope')}</span><span class="ds-link-main"><b>${esc(l.title)}</b><small>${esc(cap(l.relation || ''))}${Number.isFinite(l.distanceKm) ? ` · ${esc(l.distanceKm < 1 ? `${Math.round(l.distanceKm * 1000)} m` : `${fmtNum(l.distanceKm, l.distanceKm < 10 ? 1 : 0)} km`)}` : ''}</small></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')}</div></section>`
: ''
}
<section class="ds-block"><h3 class="ds-h">${icon('receipt')}Receipts<small>every fact keeps its source</small></h3><div class="ds-receipts">${(
d.sources || []
)
.map(
(s) =>
`<div class="ds-rc"><div><b>${esc(s.name)}</b><small>${esc(s.fetchedAt ? `fetched ${ago(s.fetchedAt)}` : '')}${s.url ? ` · <a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(new URL(s.url, location.href).hostname)}</a>` : ''}</small></div><button class="ds-hash mono" type="button" data-hash="${esc(s.hash || '')}" title="Copy hash">${esc(
s.hash
? `${String(s.hash)
.replace(/^sha256:/, '')
.slice(0, 10)}…${String(s.hash).slice(-6)}`
: '—',
)}</button></div>`,
)
.join('')}</div></section>
<p class="ds-foot">${icon('lock')}We track things that broadcast publicly, public infrastructure and events. Never people: no person search, no faces.</p>`;
}
async function show(ref, { push = true, detent } = {}) {
const my = ++token;
if (!sheet.isOpen) {
sheet.head.innerHTML = `<div class="ds-head"><div class="ds-kicker tele">Opening dossier…</div><h2 class="sheet-title ds-skel">&nbsp;</h2></div>`;
sheet.body.innerHTML = '';
sheet.open(detent || 'medium');
}
const res = await globe.entity(ref);
if (my !== token) return;
if (!res) {
sheet.close();
toast('No dossier for that yet', { icon: icon('info') });
return;
}
d = res;
if (push) stack.push({ ref: d.id, title: d.title });
render();
if (detent) sheet.setDetent(detent);
sync();
clearInterval(refreshTimer);
if (d.live && !d.illustrative)
refreshTimer = setInterval(async () => {
if (!sheet.isOpen || seg !== 'dossier') return;
const nd = await globe.entity(d.id);
if (!nd || nd.id !== d?.id) return;
d = { ...nd, links: d.links };
const live = $('.ds-live', sheet.body);
if (live)
live.innerHTML = liveRows(d)
.map(
([k, v, u]) =>
`<div><span class="tele">${esc(k)}</span><b class="num">${esc(v)}<small>${esc(u)}</small></b></div>`,
)
.join('');
$$('[data-ago]', sheet.body).forEach((el) => {
el.dataset.ago = d.live?.updatedAt;
el.textContent = ago(d.live?.updatedAt).toUpperCase();
});
}, 5000);
}
function render() {
sheet.sheet.dataset.band = bandOfKind(d.kind);
sheet.sheet.dataset.seg = seg;
sheet.head.innerHTML = headHtml();
graph?.destroy();
graph = null;
if (seg === 'graph') {
sheet.body.innerHTML =
'<div class="ds-graph"></div><p class="ds-graph-note tele">Drag a node · tap one to open its dossier</p>';
graph = createLinkGraph($('.ds-graph', sheet.body), {
onOpen: (n) => {
haptic('light');
seg = 'dossier';
show(n.id);
},
onSelect: (n) => selectOnGlobe(n),
});
graph.set(globe.graph(d.id), d.id);
if (sheet.detent !== 'large') sheet.setDetent('large');
} else {
sheet.body.innerHTML = bodyHtml();
sheet.body.scrollTop = 0;
}
}
/** One selection: the globe follows the dossier. */
function sync() {
const L = d.live;
if (!L || !Number.isFinite(L.lat)) return;
// Historical (Moment) things are shown by the Moment player, not live.
if (d.illustrative && d.kind !== 'moment') return;
const raw = String(d.id).replace(/^[a-z]+:/, '');
globe.select(raw);
const height = {
satellite: 2600e3,
aircraft: 60e3,
military: 80e3,
helicopter: 4000,
vessel: 30e3,
place: 6000,
capture: 2500,
moment: 3500,
camera: 3000,
quake: 400e3,
}[d.kind];
if (height)
globe.flyTo({
lon: L.lon,
lat: L.lat,
height,
pitch: -55,
duration: 1.8,
});
}
function selectOnGlobe(n) {
globe.select(String(n.id).replace(/^[a-z]+:/, ''));
}
sheet.head.addEventListener('click', (e) => {
const s = e.target.closest('[data-seg]');
if (s && s.dataset.seg !== seg) {
haptic('tick');
seg = s.dataset.seg;
render();
return;
}
if (e.target.closest('.ds-back')) {
stack.pop();
const prev = stack[stack.length - 1];
if (prev) show(prev.ref, { push: false });
}
});
sheet.body.addEventListener('click', async (e) => {
const link = e.target.closest('[data-link]');
if (link) {
haptic('light');
const id = link.dataset.link;
if (/^situation:/.test(id) && onOpenSituation) {
const ok = await onOpenSituation(id.replace(/^situation:/, ''), {
fromDossier: true,
});
if (ok) return;
}
return show(id);
}
const ev = e.target.closest('[data-t]');
if (ev) {
haptic('tick');
onShowOnTime?.(d, Number(ev.dataset.t));
return;
}
const hash = e.target.closest('[data-hash]');
if (hash) {
try {
await navigator.clipboard.writeText(hash.dataset.hash);
toast('Hash copied', { icon: icon('receipt') });
} catch {
toast(hash.dataset.hash.slice(0, 32), { icon: icon('receipt') });
}
return;
}
const act = e.target.closest('[data-ds]')?.dataset.ds;
if (!act || !d) return;
haptic('light');
if (act === 'follow') {
const on = store.toggleFollow(followKey());
watch?.noteFollow?.(d, on);
toast(on ? `Following ${d.title}` : 'Unfollowed', {
icon: icon(on ? 'starFill' : 'star'),
});
render();
} else if (act === 'alert') watch?.ruleFor(d);
else if (act === 'room') onOpenRoom?.(d);
else if (act === 'time') onShowOnTime?.(d);
else if (act === 'play') onOpenMoment?.(d.id.replace(/^moment:/, ''));
});
// Picks on the globe highlight the same node in the graph.
globe.on('pick', (p) => {
if (!graph || !p) return;
const id = [
...[
`flights:${p.id}`,
`military:${p.id}`,
`satellites:${p.id}`,
`vessels:${p.id}`,
`situation:${p.id}`,
String(p.id),
],
].find((x) => graph.has(x));
if (id) graph.select(id);
});
return {
open: (ref, o = {}) => {
seg = o.graph ? 'graph' : 'dossier';
stack = [];
return show(ref, { detent: o.detent || (o.graph ? 'large' : 'medium') });
},
close: () => sheet.close(),
get isOpen() {
return sheet.isOpen;
},
get current() {
return d;
},
sheet,
};
}
+56 -11
View File
@@ -110,16 +110,19 @@ export function createFeed(
},
) {
view.innerHTML = `
<header class="navbar"><span class="nav-title">Now</span>
<div class="nav-trailing"><button type="button" class="near-chip" data-act="near" aria-pressed="false">${icon('location')}<span>Near you</span></button><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
<header class="navbar"><span class="nav-title">Today</span>
<div class="nav-trailing"><span class="fresh-chip tele" hidden></span><button type="button" class="near-chip" data-act="near" aria-pressed="false">${icon('location')}<span>Near you</span></button><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
</header>
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `<i style="--i:${i}"></i>`).join('')}</div></div>
<div class="feed-inner">
<div class="feed-hero">
<h1 class="large-title">Now</h1>
<div class="feed-clock tele" aria-hidden="true"><b data-utc></b><span data-date></span></div>
<div class="feed-hero today-hero">
<div class="today-date tele" data-date></div>
<h1 class="large-title today-title"><span>Your world</span><span class="today-time" data-localtime></span></h1>
<div class="feed-clock tele" aria-hidden="true"><b data-utc></b></div>
</div>
<div class="title-sub tele"><span class="live-dot"></span><span class="feed-count">Acquiring the planet…</span></div>
<div class="brief-slot"></div>
<div class="feed-section-h"><h2>Around the world</h2><span class="tele">LIVE · EVERY BAND</span></div>
<div class="bandbar" role="tablist" aria-label="Altitude" data-hscroll>
<button role="tab" type="button" data-band="all" aria-selected="true"><span class="bb-n">All</span><span class="bb-r tele" data-count="all">—</span></button>
${BANDS.map((b) => `<button role="tab" type="button" data-band="${b.id}" aria-selected="false"><span class="bb-n"><i></i>${b.name}</span><span class="bb-r tele"><span data-count="${b.id}">—</span> · ${BAND_RANGE[b.id]}</span></button>`).join('')}
@@ -150,16 +153,24 @@ export function createFeed(
const now = new Date();
const u = utc(now);
const hd = $('[data-utc]', view);
if (hd) hd.textContent = `${u} UTC`.replace('Z UTC', ' UTC');
if (hd) hd.textContent = u;
const dt = $('[data-date]', view);
if (dt)
dt.textContent = now
.toLocaleDateString('en-GB', {
weekday: 'short',
day: '2-digit',
month: 'short',
weekday: 'long',
day: 'numeric',
month: 'long',
})
.toUpperCase();
const lt = $('[data-localtime]', view);
if (lt) {
const txt = now.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
if (lt.textContent !== txt) lt.textContent = txt;
}
if (view.hidden) return;
$$('[data-clock]', fbody).forEach((el) => (el.textContent = u));
$$('[data-since]', fbody).forEach((el) => {
@@ -206,7 +217,12 @@ export function createFeed(
s,
archetype: archetypeOf(s),
band: bandOf(s),
prio: (s.severity || 0) * 10 - i * 0.6 + (s.live ? 2 : 0),
prio:
(s.severity || 0) * 10 -
i * 0.6 +
(s.live ? 2 : 0) +
// Detections from real data (circling, emergency squawks) lead.
(/circling|squawk|quake-cluster/.test(s.kind || '') ? 60 : 0),
}));
// The street is the most Citizen-like card: one live eye right after
// the two most severe situations.
@@ -214,6 +230,9 @@ export function createFeed(
const eye = ranked.find((x) => x.archetype === 'eye');
if (eye && ranked[1] && ranked.indexOf(eye) > 2)
eye.prio = ranked[1].prio - 0.1;
// A detection is the first card: never shuffled away by diversity.
for (const it of items)
if (/circling|squawk|quake-cluster/.test(it.s.kind || '')) it.lead = true;
return items;
}
@@ -294,7 +313,9 @@ export function createFeed(
let list = [...items, ...roomItems, ...wit];
if (band !== 'all') list = list.filter((x) => x.band === band);
list.sort((a, b) => b.prio - a.prio);
return near ? list : diversify(list);
if (near) return list;
const lead = list.filter((x) => x.lead);
return [...lead, ...diversify(list.filter((x) => !x.lead))];
}
/* ---------- layout ---------- */
@@ -763,8 +784,32 @@ export function createFeed(
if (loc) globe.setViewer?.({ lon: loc.lon, lat: loc.lat });
}
/** Instant first frame: paint cached situations before the live call. */
function prime(list) {
if (!Array.isArray(list) || !list.length || all.length) return false;
all = list;
render();
count.textContent = `${count.textContent} · last good snapshot`;
return true;
}
let freshTimer = 0;
function markUpdated() {
const chip = $('.fresh-chip', view);
chip.textContent = 'Updated just now';
chip.hidden = false;
chip.classList.remove('in');
void chip.offsetWidth;
chip.classList.add('in');
clearTimeout(freshTimer);
freshTimer = setTimeout(() => (chip.hidden = true), 4200);
}
return {
load,
prime,
markUpdated,
briefSlot: $('.brief-slot', view),
titleEl: $('.today-title', view),
refresh,
render,
refreshSocial: socialChanged,
+33 -2
View File
@@ -19,7 +19,7 @@ import {
import { createSheet } from './sheet.js';
import { createTimeRibbon } from './timeRibbon.js';
import { createEyeViewer } from './eyeViewer.js';
import { BAND_NAME, llShort, mgrs } from './bands.js';
import { BAND_NAME, BAND_COLOR, llShort, mgrs } from './bands.js';
/** Altitude → band: where the virtual sensor is. */
const bandAt = (h) => (h > 100e3 ? 'orbit' : h > 2500 ? 'sky' : 'ground');
@@ -79,6 +79,10 @@ export function createGlobeChrome({
getSituations,
isTwoPane,
onPin,
onClock,
onClip,
onTimelineEvent,
onPick,
}) {
const eyeViewer = createEyeViewer({
globe,
@@ -174,7 +178,22 @@ export function createGlobeChrome({
const ribbon = createTimeRibbon(bottom, {
getWindow: () => globe.timeWindow(),
onTime: (d) => globe.setTime(d),
onTime: (d) => {
// A playing Moment owns the clock until it closes.
if (globe.activeMoment()) return;
globe.setTime(d);
onClock?.(d);
},
playback: globe.playback,
getHistory: () => globe.history(),
onClip: (r) => onClip?.(r),
onPip: (p) => onTimelineEvent?.(p.ev || p),
});
// While the planet plays, the time machine (and everything else) follows.
globe.on('playback', (st) => {
if (!st || globe.activeMoment()) return;
ribbon.onPlayback(st);
onClock?.(ribbon.time);
});
/* ---------- HTML labels (engine draws none; ≤ 40, DESIGN.md §5) ---------- */
@@ -588,6 +607,7 @@ export function createGlobeChrome({
if (onPin?.(p)) return;
if (p.pinKind === 'eye') return nearestEye(p.lon, p.lat, p.title, 1);
}
if (onPick?.(p)) return;
if (p.kind === 'situation') {
const s = (await getSituations()).find(
(x) => String(x.id) === String(p.id),
@@ -731,6 +751,17 @@ export function createGlobeChrome({
basePips = p || [];
ribbon.setPips([...basePips, ...extraPips]);
},
/** Timeline events (globe.timeline()) become band-coloured pips. */
setTimeline(evts) {
basePips = (evts || []).map((ev) => ({
at: ev.t,
label: ev.title,
color: BAND_COLOR[ev.band] || '#fff',
severity: ev.severity || 0,
ev,
}));
ribbon.setPips([...basePips, ...extraPips]);
},
/** Room posts anchored in time (cleared with []). */
setExtraPips(p) {
extraPips = p || [];
+99
View File
@@ -7,6 +7,8 @@
* the shell down; mismatches are collected in `contractReport` for the lead.
*/
import { installRound3 } from './shim3.js';
const REQUIRED = [
'flyTo',
'home',
@@ -38,6 +40,28 @@ const ROUND_TWO = [
'eyeFrameUrl',
];
/** Round-three additions (mobile/globe/API.md, "Round three"), optional. */
const ROUND_THREE = [
'history',
'trackOf',
'timeline',
'detections',
'moments',
'moment',
'momentOfTheDay',
'playMoment',
'exitMoment',
'activeMoment',
'seekMoment',
'entity',
'search',
'graph',
'react',
'attentionHeat',
'skyNow',
'cachedThumbnail',
];
export const contractReport = {
engine: 'none',
missing: [],
@@ -166,6 +190,71 @@ function guard(engine, kind) {
},
project: (lon, lat, alt) => call('project', null, lon, lat, alt),
time: () => call('time', null),
/* ---- round three (API.md); the shell fills gaps with shim3.js ---- */
situationsCached: async () => {
const v = await asyncCall('situations', [], { cached: true });
return Array.isArray(v) ? v.map(normSituation) : [];
},
cachedThumbnail: (s) => call('cachedThumbnail', null, s),
history: () => call('history', null),
trackOf: (id) => asyncCall('trackOf', [], id),
playback: {
play: (o) => {
try {
engine.playback?.play?.(o);
} catch (err) {
note(`playback.play(): ${err?.message || err}`);
}
},
stop: () => {
try {
engine.playback?.stop?.();
} catch (err) {
note(`playback.stop(): ${err?.message || err}`);
}
},
state: () => {
try {
return (
engine.playback?.state?.() || { playing: false, speed: 1, t: null }
);
} catch {
return { playing: false, speed: 1, t: null };
}
},
},
timeline: (o) => {
const v = call('timeline', [], o);
return Array.isArray(v) ? v : [];
},
detections: () => {
const v = call('detections', []);
return Array.isArray(v) ? v.map(normSituation) : [];
},
moments: async () => {
const v = await asyncCall('moments', []);
return Array.isArray(v) ? v : [];
},
moment: (id) => asyncCall('moment', null, id),
momentOfTheDay: () => asyncCall('momentOfTheDay', null),
playMoment: (m, o) => asyncCall('playMoment', null, m, o),
exitMoment: () => call('exitMoment', undefined),
activeMoment: () => call('activeMoment', null),
seekMoment: (t) => call('seekMoment', undefined, t),
entity: (ref) => asyncCall('entity', null, ref),
search: async (q) => {
const v = await asyncCall('search', [], q);
return Array.isArray(v) ? v : [];
},
graph: (ref) => {
const v = call('graph', null, ref);
return v && Array.isArray(v.nodes) ? v : { nodes: [], edges: [] };
},
react: (o) => call('react', undefined, o),
attentionHeat: (p) => call('attentionHeat', undefined, p),
skyNow: (o) => asyncCall('skyNow', null, o),
addClip: (m) => call('_addClip', undefined, m),
};
}
@@ -196,6 +285,15 @@ export async function mountGlobe(container, opts = {}) {
opts.quality === 'battery' ? 'low' : opts.quality || 'auto';
const engine = await create(container, { quality });
contractReport.engine = 'cesium';
contractReport.roundThree = Object.fromEntries(
ROUND_THREE.map((m) => [
m,
typeof engine?.[m] === 'function' ? 'native' : 'shim',
]),
);
contractReport.roundThree.playback =
typeof engine?.playback?.play === 'function' ? 'native' : 'shim';
installRound3(engine, { kind: 'cesium' });
contractReport.missing = REQUIRED.filter(
(m) => typeof engine?.[m] !== 'function',
);
@@ -222,6 +320,7 @@ export async function mountGlobe(container, opts = {}) {
quality: opts.quality || 'auto',
});
contractReport.engine = 'stub';
contractReport.roundThree = 'stub (shim3 + native stub recorder)';
contractReport.roundTwo = Object.fromEntries(
ROUND_TWO.map((m) => [m, typeof engine?.[m] === 'function']),
);
+97 -2
View File
@@ -14,12 +14,13 @@ import {
camFrameUrl,
} from './stubSituations.js';
import { stubThumbnail } from './stubCompositor.js';
import { installRound3 } from './shim3.js';
const R_EARTH = 6371008.8;
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
const FOV_HALF = Math.tan(20 * D2R);
const MIN_H = 60e3;
const MIN_H = 1500;
const MAX_H = 42000e3;
const HOME = { lon: 20, lat: 18, height: 15500e3 };
@@ -719,7 +720,18 @@ export async function createGlobe(container, options = {}) {
const radiusPx = (height = cam.height) => {
const d = R_EARTH + height;
const tanT = R_EARTH / Math.sqrt(d * d - R_EARTH * R_EARTH);
return (tanT / FOV_HALF) * (Math.min(W, H) / 2) * 1.05;
const half = Math.min(W, H) / 2;
const sil = (tanT / FOV_HALF) * half * 1.05;
// Close in, the silhouette fit under-zooms badly (a 2.5 km camera saw
// 200 km). Blend toward the true nadir scale below 1,000 km so a city
// block reads at street altitude (Moments orbit at 400–700 m).
const nadir = (R_EARTH / (Math.max(1, height) * FOV_HALF)) * half;
if (nadir <= sil) return sil;
const k = Math.min(
1,
Math.max(0, Math.log(1000e3 / height) / Math.log(1000e3 / 30e3)),
);
return sil * (nadir / sil) ** k;
};
function basis() {
const lo = cam.lon * D2R;
@@ -1041,7 +1053,17 @@ export async function createGlobe(container, options = {}) {
}
drawWeather(ts);
drawPins(ts);
for (const fn of hooks) {
try {
fn(ctx, { project, t, ts, W, H, cam });
} catch (err) {
console.warn('[stub] overlay hook failed', err);
hooks.delete(fn);
}
}
}
/** Round three: shell overlays (Moment trails, reactions) draw here. */
const hooks = new Set();
/* ---------- weather (simulated look of the engine's weather family) ---------- */
const wx = (() => {
@@ -1658,12 +1680,85 @@ export async function createGlobe(container, options = {}) {
}
return out;
},
/* ---- round three (API.md): native stub pieces; the rest via shim3 ---- */
_hook(fn) {
hooks.add(fn);
kick();
return () => hooks.delete(fn);
},
history() {
const n = Date.now();
const pts = contacts.length * 720;
return {
start: n - 6 * 3600e3,
end: n,
entities: contacts.length,
points: pts,
};
},
async trackOf(id) {
const raw = String(id).replace(/^[a-z]+:/, '');
const c = contacts.find((x) => String(x.id) === raw);
if (!c) return [];
const t = Math.max(now(), Date.now());
const out = [];
const probe = { ...c, base: c.base, orbit: c.orbit };
// The simulated recorder: six hours at one point a minute.
for (let i = 360; i >= 0; i -= 1) {
const ti = t - i * 60e3;
propagate(probe, t0, ti);
out.push({
t: ti,
lon: probe.lon,
lat: probe.lat,
altM:
c.kind === 'satellite'
? c.altM
: Math.max(0, (c.altM || 0) + Math.sin(i / 9) * 90),
headingDeg: probe.headingDeg,
speed: c.speed,
});
}
propagate(c, t0, t);
return out;
},
/** Next ISS pass over a point: sampled every 30 s for 24 h, el > 10°. */
_issPass(lon, lat) {
const iss = contacts.find((c) => c.id === 'iss');
if (!iss) return null;
const probe = { ...iss };
const Re = R_EARTH;
const elev = (ti) => {
propagate(probe, t0, ti);
const d = haversine(lon, lat, probe.lon, probe.lat);
const th = d / Re;
return (
Math.atan2(Math.cos(th) - Re / (Re + iss.altM), Math.sin(th)) * R2D
);
};
const start = Date.now();
let pass = null;
for (let ti = start; ti < start + 24 * 3600e3; ti += 30e3) {
const e = elev(ti);
if (e > 10 && !pass) pass = { start: ti, max: ti, maxElevationDeg: e };
else if (pass && e > 10 && e > pass.maxElevationDeg) {
pass.max = ti;
pass.maxElevationDeg = e;
} else if (pass && e <= 10) {
pass.end = ti;
break;
}
}
if (pass) pass.maxElevationDeg = Math.round(pass.maxElevationDeg);
return pass;
},
destroy() {
api.pause();
ro.disconnect();
root.remove();
},
};
installRound3(api, { kind: 'stub', historyHours: 6 });
kick();
setTimeout(
() =>
+13
View File
@@ -95,6 +95,19 @@ const P = {
sendUp: `<path d="M12 19V5.6M6.6 11 12 5.6l5.4 5.4" stroke-width="2.2"/>`,
dot: `<circle cx="12" cy="12" r="4" ${F}/>`,
hand: `<path d="M8.4 12.6V5.8a1.4 1.4 0 0 1 2.8 0v5.4M11.2 10.8V4.4a1.4 1.4 0 0 1 2.8 0v6.4M14 10.8V5.6a1.4 1.4 0 0 1 2.8 0v7.6M8.4 12.6l-1.3-1.7a1.5 1.5 0 0 0-2.3 1.9l3.4 5.3c1.2 1.8 3 2.9 5.2 2.9h.4c2.6 0 4.9-2.1 4.9-4.8V8.5a1.4 1.4 0 0 0-2.8 0"/>`,
today: `<path class="f" d="M4 15.6a8 8 0 0 1 16 0Z"/><path d="M4 15.6a8 8 0 0 1 16 0M2.6 15.6h18.8M12 3.4v2.2M5.3 6.2l1.5 1.5M18.7 6.2l-1.5 1.5M6.4 19h11.2"/>`,
rooms: `<path class="f" d="M4.2 5.4h10.4c.9 0 1.6.7 1.6 1.6v6.2c0 .9-.7 1.6-1.6 1.6H9.1l-3.4 2.8v-2.8H4.2c-.9 0-1.6-.7-1.6-1.6V7c0-.9.7-1.6 1.6-1.6Z"/><path d="M4.2 5.4h10.4c.9 0 1.6.7 1.6 1.6v6.2c0 .9-.7 1.6-1.6 1.6H9.1l-3.4 2.8v-2.8H4.2c-.9 0-1.6-.7-1.6-1.6V7c0-.9.7-1.6 1.6-1.6Z"/><path d="M16.2 9.2h3.6c.9 0 1.6.7 1.6 1.6v5.4c0 .9-.7 1.6-1.6 1.6h-1.2v2.6l-3.1-2.6h-4.2c-.9 0-1.6-.7-1.6-1.6v-.6"/>`,
bell: `<path d="M6.2 16.4V10.6a5.8 5.8 0 0 1 11.6 0v5.8l1.6 2H4.6Z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>`,
graph: `<circle cx="6" cy="7" r="2.3"/><circle cx="18" cy="6" r="2.3"/><circle cx="12" cy="17.6" r="2.6"/><circle cx="19" cy="15" r="1.7"/><path d="M8.2 7.8 16 6.4M7.2 9l3.6 6.4M17 8.2l-3.8 7.4M14.6 17l2.9-1.4"/>`,
dome: `<path d="M3 17.2a9 9 0 0 1 18 0Z"/><path d="M7.2 17.2a4.8 4.8 0 0 1 9.6 0M12 8.2v9"/><circle cx="15.6" cy="11.4" r="1.1" ${F}/><circle cx="8.6" cy="13.4" r="0.9" ${F}/>`,
heli: `<path d="M3 5.4h14M10 5.4v3"/><path d="M6.6 11.2c0-1.6 1.3-2.8 3-2.8h2.2c2.6 0 4.6 1.8 4.6 4.2v.6H8.8c-1.2 0-2.2-.9-2.2-2Z" ${F}/><path d="M16.4 12.2h4.4M20.4 10.4v3.6M9.4 13.2l-1 3.2M13.6 13.2l1 3.2M6.8 16.4h10"/>`,
satellite: `<path d="m12 8.9 3.1 3.1-3.1 3.1L8.9 12Z" ${F}/><path d="M5.4 9.6 9.6 5.4 7 2.8 2.8 7ZM18.6 14.4l-4.2 4.2 2.6 2.6 4.2-4.2ZM10.4 10.4 7.6 7.6M13.6 13.6l2.8 2.8"/>`,
building: `<path d="M8 21V8.4l4-2.2 4 2.2V21M12 2.6v3.6M10 11h4M10 14h4M10 17h4M4.6 21h14.8"/>`,
film: `<rect x="3.4" y="5" width="17.2" height="14" rx="2.4"/><path d="M7.4 5v14M16.6 5v14M3.4 9.6h4M3.4 14.4h4M16.6 9.6h4M16.6 14.4h4"/>`,
scissors: `<circle cx="6.4" cy="7" r="2.6"/><circle cx="6.4" cy="17" r="2.6"/><path d="m8.6 8.4 11 8.8M8.6 15.6l11-8.8"/>`,
receipt: `<path d="M6 3.6h12v17l-2-1.4-2 1.4-2-1.4-2 1.4-2-1.4-2 1.4Z"/><path d="M9 8.4h6M9 11.6h6M9 14.8h3.6"/>`,
director: `<rect x="3.4" y="9.2" width="17.2" height="11" rx="1.8"/><path d="m3.6 9.2 2.2-5.4 15 2.6-1.1 2.8M8.4 4.6l1.6 3.6M13.4 5.4l1.6 3.6"/>`,
forward: `<path d="M4 6.6v10.8L11.4 12ZM12.6 6.6v10.8L20 12Z" ${F}/>`,
};
/**
+360
View File
@@ -0,0 +1,360 @@
/**
* Link graph (DESIGN.md §12.1): a hand-written force-directed graph of
* globe.graph(ref), ≤ 40 nodes, on one canvas. Nodes are coloured by
* altitude band, the root is larger, edges carry their relation on hover.
* Drag a node to pull the graph; tap a node to open its dossier. Selection
* is shared with the globe through `select(id)`.
*/
import { BAND_COLOR } from './bands.js';
import { bandOfKind } from './shim3.js';
import { reducedMotion } from './dom.js';
const KIND_GLYPH = {
aircraft: '✈',
helicopter: 'H',
military: '✈',
satellite: '◆',
vessel: '⛴',
quake: '◎',
camera: '◉',
situation: '!',
place: '⌖',
moment: '▶',
track: '·',
capture: '◐',
};
export function createLinkGraph(host, { onOpen, onSelect } = {}) {
const wrap = document.createElement('div');
wrap.className = 'lg';
wrap.innerHTML = `<canvas class="lg-canvas" aria-label="Link graph" role="img"></canvas><div class="lg-tip tele" hidden></div><div class="lg-legend tele"><span data-band="orbit"><i></i>Orbit</span><span data-band="sky"><i></i>Sky</span><span data-band="ground"><i></i>Ground</span><span data-band="inside"><i></i>Inside</span></div>`;
host.appendChild(wrap);
const canvas = wrap.querySelector('canvas');
const tip = wrap.querySelector('.lg-tip');
const ctx = canvas.getContext('2d');
let W = 0;
let H = 0;
let dpr = 1;
let nodes = [];
let edges = [];
let rootId = null;
let selected = null;
let hover = null;
let alpha = 1;
let raf = 0;
let dragN = null;
let destroyed = false;
function resize() {
const r = wrap.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
W = r.width;
H = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
kick(0.3);
}
const ro = new ResizeObserver(resize);
ro.observe(wrap);
function set(graph, root) {
rootId = root;
const prev = new Map(nodes.map((n) => [n.id, n]));
const deg = new Map();
for (const e of graph.edges) {
deg.set(e.from, (deg.get(e.from) || 0) + 1);
deg.set(e.to, (deg.get(e.to) || 0) + 1);
}
nodes = graph.nodes.slice(0, 40).map((n, i) => {
const p = prev.get(n.id);
const a = (i / Math.max(1, graph.nodes.length)) * Math.PI * 2;
const isRoot = n.id === root;
return {
...n,
band: bandOfKind(n.kind),
r: isRoot ? 15 : 7 + Math.min(5, (deg.get(n.id) || 1) * 0.9),
x: p?.x ?? (isRoot ? 0 : Math.cos(a) * 90),
y: p?.y ?? (isRoot ? 0 : Math.sin(a) * 90),
vx: 0,
vy: 0,
root: isRoot,
};
});
const idx = new Map(nodes.map((n, i) => [n.id, i]));
edges = graph.edges
.filter((e) => idx.has(e.from) && idx.has(e.to))
.map((e) => ({
...e,
a: nodes[idx.get(e.from)],
b: nodes[idx.get(e.to)],
}));
kick(1);
}
function kick(a = 0.6) {
alpha = Math.max(alpha, a);
if (!raf && !destroyed) raf = requestAnimationFrame(tick);
}
function step() {
const k = 1;
// Repulsion
for (let i = 0; i < nodes.length; i += 1) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j += 1) {
const b = nodes[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let d2 = dx * dx + dy * dy;
if (d2 < 0.01) {
dx = Math.random() - 0.5;
dy = Math.random() - 0.5;
d2 = 0.5;
}
const f = (4200 * k) / d2;
const d = Math.sqrt(d2);
a.vx -= (dx / d) * f;
a.vy -= (dy / d) * f;
b.vx += (dx / d) * f;
b.vy += (dy / d) * f;
}
}
// Springs
for (const e of edges) {
const dx = e.b.x - e.a.x;
const dy = e.b.y - e.a.y;
const d = Math.max(0.01, Math.hypot(dx, dy));
const rest = e.a.root || e.b.root ? 120 : 80;
const f = (d - rest) * 0.035;
e.a.vx += (dx / d) * f;
e.a.vy += (dy / d) * f;
e.b.vx -= (dx / d) * f;
e.b.vy -= (dy / d) * f;
}
for (const n of nodes) {
// Gravity to the centre; the root is pinned there.
n.vx -= n.x * 0.012;
n.vy -= n.y * 0.012;
if (n.root && n !== dragN) {
n.x *= 0.8;
n.y *= 0.8;
n.vx = 0;
n.vy = 0;
continue;
}
if (n === dragN) continue;
n.vx *= 0.62;
n.vy *= 0.62;
n.x += n.vx * alpha;
n.y += n.vy * alpha;
}
}
function view() {
// Fit the graph into the canvas with padding.
let minX = -60;
let maxX = 60;
let minY = -60;
let maxY = 60;
for (const n of nodes) {
minX = Math.min(minX, n.x - n.r);
maxX = Math.max(maxX, n.x + n.r);
minY = Math.min(minY, n.y - n.r);
maxY = Math.max(maxY, n.y + n.r);
}
const pad = 46;
const s = Math.min(
2.2,
(W - pad * 2) / (maxX - minX),
(H - pad * 2) / (maxY - minY),
);
return {
s,
cx: W / 2 - ((minX + maxX) / 2) * s,
cy: H / 2 - ((minY + maxY) / 2) * s,
};
}
let V = { s: 1, cx: 0, cy: 0 };
const sx = (n) => V.cx + n.x * V.s;
const sy = (n) => V.cy + n.y * V.s;
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
V = view();
const focus = hover || selected || rootId;
const relFocus = hover || selected;
const near = new Set();
if (focus)
for (const e of edges) {
if (e.a.id === focus) near.add(e.b.id);
if (e.b.id === focus) near.add(e.a.id);
}
// Edges
for (const e of edges) {
const hot = focus && (e.a.id === focus || e.b.id === focus);
ctx.strokeStyle = hot
? 'rgba(255,255,255,0.7)'
: 'rgba(255,255,255,0.16)';
ctx.lineWidth = hot ? 1.4 : 1;
ctx.beginPath();
ctx.moveTo(sx(e.a), sy(e.a));
ctx.lineTo(sx(e.b), sy(e.b));
ctx.stroke();
if (hot && e.relation && relFocus) {
const mx = (sx(e.a) + sx(e.b)) / 2;
const my = (sy(e.a) + sy(e.b)) / 2;
ctx.font = '600 9px "JetBrains Mono", ui-monospace, monospace';
const w = ctx.measureText(e.relation.toUpperCase()).width;
ctx.fillStyle = 'rgba(8,10,14,0.85)';
ctx.fillRect(mx - w / 2 - 4, my - 7, w + 8, 14);
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.textAlign = 'center';
ctx.fillText(e.relation.toUpperCase(), mx, my + 3);
}
}
// Nodes
for (const n of nodes) {
const x = sx(n);
const y = sy(n);
const col = BAND_COLOR[n.band] || '#fff';
const dim = focus && focus !== n.id && !near.has(n.id) && !n.root;
ctx.globalAlpha = dim ? 0.35 : 1;
if (n.id === selected || n.root) {
ctx.fillStyle = `${col}33`;
ctx.beginPath();
ctx.arc(x, y, n.r + 7, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = n.root ? col : '#0b0d11';
ctx.strokeStyle = col;
ctx.lineWidth = n.root ? 0 : 2;
ctx.beginPath();
ctx.arc(x, y, n.r, 0, Math.PI * 2);
ctx.fill();
if (!n.root) ctx.stroke();
ctx.fillStyle = n.root ? '#0b0d11' : col;
ctx.font = `700 ${n.root ? 13 : 9}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(KIND_GLYPH[n.kind] || '•', x, y + 0.5);
ctx.textBaseline = 'alphabetic';
const showLabel =
n.root || n.id === focus || near.has(n.id) || nodes.length <= 14;
if (showLabel) {
const short = n.root ? n.title : String(n.title).split(' · ')[0];
const label = short.length > 22 ? `${short.slice(0, 21)}…` : short;
ctx.font = `${n.root ? 650 : 550} ${n.root ? 12 : 10.5}px system-ui, sans-serif`;
ctx.fillStyle = n.root ? '#fff' : 'rgba(235,241,250,0.85)';
ctx.fillText(label, x, y + n.r + 13);
}
ctx.globalAlpha = 1;
}
}
function tick() {
raf = 0;
if (destroyed) return;
const steps = reducedMotion() ? 60 : alpha > 0.4 ? 6 : 2;
for (let i = 0; i < steps && alpha > 0.01; i += 1) {
step();
alpha *= reducedMotion() ? 0.92 : 0.985;
}
draw();
if (alpha > 0.01 || dragN) raf = requestAnimationFrame(tick);
}
function hit(e) {
const r = canvas.getBoundingClientRect();
const px = e.clientX - r.left;
const py = e.clientY - r.top;
let best = null;
let bd = 22;
for (const n of nodes) {
const d = Math.hypot(sx(n) - px, sy(n) - py) - n.r;
if (d < bd) {
bd = d;
best = n;
}
}
return { n: best, px, py };
}
let down = null;
canvas.addEventListener('pointerdown', (e) => {
const { n, px, py } = hit(e);
down = { n, px, py, moved: false };
if (n) {
dragN = n;
canvas.setPointerCapture(e.pointerId);
}
});
canvas.addEventListener('pointermove', (e) => {
if (down?.n) {
const r = canvas.getBoundingClientRect();
const px = e.clientX - r.left;
const py = e.clientY - r.top;
if (Math.hypot(px - down.px, py - down.py) > 4) down.moved = true;
if (down.moved) {
down.n.x = (px - V.cx) / V.s;
down.n.y = (py - V.cy) / V.s;
kick(0.4);
}
return;
}
if (e.pointerType === 'mouse') {
const { n } = hit(e);
const id = n?.id || null;
if (id !== hover) {
hover = id;
canvas.style.cursor = id ? 'pointer' : 'default';
if (n) {
tip.hidden = false;
tip.textContent = `${n.kind.toUpperCase()} · ${n.title}`;
tip.style.transform = `translate(${Math.round(sx(n))}px, ${Math.round(sy(n) - n.r - 30)}px) translateX(-50%)`;
} else tip.hidden = true;
draw();
}
}
});
const up = () => {
if (!down) return;
const d = down;
down = null;
dragN = null;
if (d.n && !d.moved) {
selected = d.n.id;
draw();
onSelect?.(d.n);
if (!d.n.root) onOpen?.(d.n);
}
};
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
canvas.addEventListener('pointerleave', () => {
if (hover) {
hover = null;
tip.hidden = true;
draw();
}
});
return {
set,
select(id) {
selected = id;
draw();
},
has: (id) => nodes.some((n) => n.id === id),
get size() {
return nodes.length;
},
destroy() {
destroyed = true;
cancelAnimationFrame(raf);
ro.disconnect();
wrap.remove();
},
};
}
+704
View File
@@ -0,0 +1,704 @@
/**
* The Moments player (DESIGN.md §12.4): a cinematic, full-screen replay of a
* 4D reconstruction. The globe plays the Moment (globe.playMoment); a scrubber
* covers just the Moment's window; captures pop in at their times with their
* First-eyes byline, seal and view cone; the chat replays beside it (the
* social agent's chatOverlay when present); record posts appear as they were
* made; a legend reads each track's callsign, operator, altitude and speed.
*
* On a phone it is an overlay on the globe; on a tablet the chat, captures and
* record move to the leading pane and the globe keeps the trailing pane.
*/
import { icon } from './icons.js';
import {
$,
$$,
h,
esc,
haptic,
toast,
clamp,
esriThumb,
reducedMotion,
emitter,
} from './dom.js';
import { utc, fmtKm } from './bands.js';
import { trackAt } from './stubMoments.js';
import { social } from './socialBridge.js';
const TRACK_COL = [
'#a3d8ff',
'#ffd27a',
'#ff9db0',
'#9be15d',
'#b39dff',
'#7cf0d0',
];
const SPEEDS = [1, 10, 30, 60];
const KIND = {
witness: ['witness', 'Witness'],
context: ['context', 'Context'],
question: ['question', 'Question'],
correction: ['correction', 'Correction'],
prediction: ['prediction', 'Prediction'],
synthesis: ['sparkles', 'Clerk · synthesis'],
};
const BADGE = {
'first-eyes': ['First eyes', 'seal'],
witness: ['Was there', 'witness'],
pilot: ['Pilot', 'sky'],
local: ['Local', 'pin'],
mod: ['Mod', 'checkmark'],
};
const at = (h) =>
!h || h.startsWith('@') || h === 'Clerk' || h === 'Record' ? h : `@${h}`;
/** The engine's Moment and the shell's share one shape after this. */
export function normMoment(m) {
const subject = m.subject || { lon: m.lon, lat: m.lat };
const R = 6371008.8;
const dist = (a) => {
const dLat = ((subject.lat - a.lat) * Math.PI) / 180;
const dLon = ((subject.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((subject.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
};
return {
...m,
subject,
tracks: (m.tracks || []).map((tr) => ({
...tr,
arriveAt: tr.arriveAt ?? tr.points?.find((p) => dist(p) < 900)?.t,
})),
captures: (m.captures || []).map((c) => ({
...c,
handle: at(c.handle),
where: c.where || c.place || '',
media: c.media || c.kind || 'photo',
live: c.live || c.kind === 'live',
distanceM: c.distanceM ?? c.rangeM ?? Math.round(dist(c)),
})),
chat: (m.chat || []).map((c) => ({ ...c, handle: at(c.handle) })),
posts: (m.posts || []).map((p) => ({
...p,
handle: at(p.handle),
votes:
typeof p.votes === 'number'
? { corroborate: p.votes, dispute: 0 }
: p.votes || { corroborate: 0, dispute: 0 },
})),
};
}
/** A capture's thumbnail: the place, with its view cone toward the subject. */
export function coneThumb(c, m, { w = 168, hgt = 120 } = {}) {
const span = 0.006;
const mPerPx = (span * 111320 * (hgt / w)) / hgt;
const len = clamp((c.distanceM || 250) / mPerPx, 26, w * 0.7);
const a = ((c.headingDeg || 0) - 90) * (Math.PI / 180);
const f = ((c.fovDeg || 52) / 2) * (Math.PI / 180);
const cx = w / 2;
const cy = hgt / 2;
const p1 = [cx + len * Math.cos(a - f), cy + len * Math.sin(a - f)];
const p2 = [cx + len * Math.cos(a + f), cy + len * Math.sin(a + f)];
const tx = cx + len * 0.92 * Math.cos(a);
const ty = cy + len * 0.92 * Math.sin(a);
return `<span class="cone-thumb" style="aspect-ratio:${w}/${hgt}">
<img src="${esc(esriThumb(c.lon, c.lat, span, w * 2, hgt * 2))}" alt="" loading="lazy" decoding="async"/>
<svg viewBox="0 0 ${w} ${hgt}" aria-hidden="true"><defs><linearGradient id="cg-${esc(c.id)}" x1="0" y1="0" x2="1" y2="0" gradientTransform="rotate(${(c.headingDeg || 0) - 90} .5 .5)"><stop offset="0" stop-color="#ebc777" stop-opacity=".75"/><stop offset="1" stop-color="#ebc777" stop-opacity=".08"/></linearGradient></defs>
<path d="M${cx} ${cy}L${p1[0].toFixed(1)} ${p1[1].toFixed(1)}A${len} ${len} 0 0 1 ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}Z" fill="url(#cg-${esc(c.id)})" stroke="#ebc777" stroke-opacity=".8" stroke-width="1"/>
<circle cx="${cx}" cy="${cy}" r="3.6" fill="#ebc777" stroke="#050607" stroke-width="1.4"/>
${m?.id === 'empire-state' ? `<path d="M${tx - 5} ${ty - 5}h3M${tx - 5} ${ty - 5}v3M${tx + 5} ${ty - 5}h-3M${tx + 5} ${ty - 5}v3M${tx - 5} ${ty + 5}h3M${tx - 5} ${ty + 5}v-3M${tx + 5} ${ty + 5}h-3M${tx + 5} ${ty + 5}v-3" stroke="#fff" stroke-width="1.1"/>` : ''}
</svg></span>`;
}
export function createMomentPlayer({
app,
globe,
onExit,
onOpenEntity,
onOpenRoom,
isTwoPane,
}) {
const ev = emitter();
const el = h(`<section class="mp" aria-label="Moment player" hidden>
<div class="mp-stage">
<header class="mp-top">
<button class="icon-btn glass-strong liquid mp-back" type="button" aria-label="Close Moment">${icon('chevronLeft')}</button>
<div class="mp-titles"><div class="mp-kicker tele"></div><h2 class="mp-title"></h2></div>
<button class="icon-btn glass-strong liquid mp-room" type="button" aria-label="Open the room">${icon('rooms')}</button>
</header>
<div class="mp-clock tele"><b class="num mp-utc">—</b><span class="mp-rel num"></span></div>
<div class="mp-legend glass-strong" role="list" aria-label="Tracks"></div>
<div class="mp-pops" aria-live="polite"></div>
<div class="mp-react" role="group" aria-label="React">
<button type="button" data-react="heart" aria-label="Heart">❤️</button><button type="button" data-react="fire" aria-label="Fire">🔥</button><button type="button" data-react="eyes" aria-label="Eyes">👀</button><button type="button" data-react="wow" aria-label="Wow">😮</button>
</div>
<div class="mp-bar glass-strong">
<button class="mp-play" type="button" aria-label="Pause">${icon('pause')}</button>
<div class="mp-scrub" role="slider" tabindex="0" aria-label="Moment time"><canvas></canvas><i class="mp-head"></i></div>
<div class="mp-speeds" role="radiogroup" aria-label="Speed">${SPEEDS.map((s) => `<button type="button" role="radio" data-speed="${s}" aria-checked="${s === 30}">${s}×</button>`).join('')}</div>
</div>
</div>
<aside class="mp-side glass-strong">
<div class="mp-side-title"><div class="mp-st-k tele"></div><h3></h3><p class="mp-st-s"></p></div>
<div class="mp-side-head">
<div class="segmented mp-seg" role="tablist"><button role="tab" type="button" data-seg="chat" aria-selected="true">Chat<span class="mp-n" data-n="chat"></span></button><button role="tab" type="button" data-seg="record" aria-selected="false">Record<span class="mp-n" data-n="record"></span></button><button role="tab" type="button" data-seg="captures" aria-selected="false">Captures<span class="mp-n" data-n="captures"></span></button></div>
</div>
<div class="mp-pane" data-pane="chat"><div class="mp-chat"></div></div>
<div class="mp-pane" data-pane="record" hidden><div class="mp-record"></div></div>
<div class="mp-pane" data-pane="captures" hidden><div class="mp-caps"></div></div>
<p class="mp-src tele"></p>
</aside>
</section>`);
app.appendChild(el);
const legend = $('.mp-legend', el);
const pops = $('.mp-pops', el);
const scrub = $('.mp-scrub', el);
const canvas = $('canvas', scrub);
const sctx = canvas.getContext('2d');
const chatEl = $('.mp-chat', el);
const recEl = $('.mp-record', el);
const capsEl = $('.mp-caps', el);
let m = null;
let raf = 0;
let t = 0;
let speed = 30;
let shown = { chat: 0, posts: 0, caps: 0 };
let ext = null; // social chatOverlay
let seg = 'chat';
let wasPlaying = false;
let reactCooldown = 0;
let offOwn = null;
/* ---------- rendering pieces ---------- */
const rel = (ms) => {
const s = Math.max(0, Math.round((ms - m.time.start) / 1000));
return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
function legendHtml() {
return (m.tracks || [])
.map(
(
tr,
i,
) => `<button class="mp-tr" type="button" role="listitem" data-track="${esc(tr.id)}" style="--c:${esc(tr.color || TRACK_COL[i % TRACK_COL.length])}">
<i class="sw"></i><span class="mp-tr-main"><b>${esc(tr.callsign || String(tr.label).replace(/\s*\(illustrative\)/i, ''))}</b><small>${esc(tr.operator || tr.type || tr.kind || '')}</small></span>
<span class="mp-tr-v tele"><b class="num" data-alt>—</b><span data-spd>—</span></span></button>`,
)
.join('');
}
function capCard(c, { pop = false } = {}) {
const fe = c.firstEyes;
return `<button class="mp-cap ${pop ? 'pop' : ''} ${fe ? 'first' : ''}" type="button" data-cap="${esc(c.id)}">
${coneThumb(c, m)}
<span class="mp-cap-body">
${c.self ? `<span class="fe tele">${icon('sealFill')}YOURS${fe ? ' · FIRST EYES' : ''}</span>` : fe ? `<span class="fe tele">${icon('sealFill')}FIRST EYES</span>` : `<span class="fe tele">${icon(c.live ? 'live' : 'seal')}${c.live ? 'LIVE · SEALED' : 'SEALED'}</span>`}
<b>${esc(c.title)}</b>
<small>${esc(c.handle)} · ${esc(utc(c.t))} · ${esc(fmtKm((c.distanceM || 0) / 1000))}</small>
</span></button>`;
}
function chatHtml(c) {
const b = c.badge && BADGE[c.badge];
return `<div class="mp-msg ${c.badge ? `b-${esc(c.badge)}` : ''}"><span class="mp-who">${b ? `<span class="mp-badge" title="${esc(b[0])}">${icon(b[1])}</span>` : ''}${esc(c.handle)}</span> <span class="mp-txt">${esc(c.text)}</span></div>`;
}
function postHtml(p) {
const k = KIND[p.kind] || KIND.context;
return `<article class="mp-post k-${esc(p.kind)}"><header><span class="mp-pk">${icon(k[0])}${esc(k[1])}</span><span class="tele">${esc(utc(p.t, { seconds: false }))}</span></header>
<p><b>${esc(p.handle)}</b> ${esc(p.text)}</p>
${p.kind !== 'synthesis' ? `<footer class="tele"><span>${icon('agree')}${p.votes?.corroborate ?? 0} corroborate</span><span>${icon('dispute')}${p.votes?.dispute ?? 0} dispute</span>${p.captureId ? `<button type="button" data-cap="${esc(p.captureId)}">${icon('seal')}Sealed capture</button>` : ''}</footer>` : '<footer class="tele"><span>AI synthesis · cites 5 captures and 37 messages</span></footer>'}</article>`;
}
function drawScrub() {
const r = scrub.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
const W = r.width;
const H = r.height;
if (canvas.width !== Math.round(W * dpr)) {
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
}
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sctx.clearRect(0, 0, W, H);
const s = m.time.start;
const e = m.time.end;
const x = (ms) => 6 + ((ms - s) / (e - s)) * (W - 12);
const mid = H / 2 + 6;
// Chat density (a quiet histogram).
const bins = new Array(60).fill(0);
for (const c of m.chat || [])
bins[Math.min(59, Math.floor(((c.t - s) / (e - s)) * 60))] += 1;
const peak = Math.max(1, ...bins);
sctx.fillStyle = 'rgba(255,255,255,0.12)';
bins.forEach((b, i) => {
const bh = (b / peak) * 12;
sctx.fillRect(
6 + (i / 60) * (W - 12),
mid - 6 - bh,
(W - 12) / 60 - 1,
bh,
);
});
// Track and progress
sctx.fillStyle = 'rgba(255,255,255,0.14)';
sctx.fillRect(6, mid - 1.5, W - 12, 3);
sctx.fillStyle = '#fff';
sctx.fillRect(6, mid - 1.5, x(t) - 6, 3);
// Arrivals, captures and posts
// Beats (the story's chapters), else arrivals.
const beats = m.beats?.length
? m.beats.map((b) => b.t)
: (m.tracks || []).map((tr) => tr.arriveAt).filter(Boolean);
for (const bt of beats) {
sctx.fillStyle =
bt <= t ? 'rgba(163,216,255,0.95)' : 'rgba(163,216,255,0.4)';
sctx.fillRect(x(bt) - 1, mid - 9, 2, 18);
}
for (const p of m.posts || []) {
sctx.fillStyle =
p.t <= t ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.35)';
sctx.beginPath();
sctx.arc(x(p.t), mid + 9, 2, 0, Math.PI * 2);
sctx.fill();
}
for (const c of m.captures || []) {
sctx.fillStyle = c.t <= t ? '#ebc777' : 'rgba(235,199,119,0.4)';
sctx.beginPath();
sctx.arc(x(c.t), mid, c.firstEyes ? 5 : 3.6, 0, Math.PI * 2);
sctx.fill();
sctx.strokeStyle = '#08090b';
sctx.lineWidth = 1.2;
sctx.stroke();
}
$('.mp-head', el).style.transform = `translateX(${x(t)}px)`;
}
/* ---------- the clock ---------- */
function sync(force = false) {
const nc = (m.chat || []).filter((c) => c.t <= t).length;
const np = (m.posts || []).filter((p) => p.t <= t).length;
const caps = (m.captures || []).filter((c) => c.t <= t);
const nk = caps.length;
// Seeking back re-renders; playing forward appends with a pop.
// The social overlay replays the raw chat by the clock itself.
if (ext) {
try {
ext.setTime?.(t);
} catch {
/* ignore */
}
}
if (force || nc < shown.chat) {
chatEl.innerHTML =
(m.chat || [])
.filter((c) => c.t <= t)
.slice(-40)
.map(chatHtml)
.join('') ||
`<p class="mp-empty tele">${(m.chat || []).length ? 'The chat starts as the Moment does' : 'No chat was recorded in this window'}</p>`;
} else
for (const c of (m.chat || []).slice(shown.chat, nc)) {
chatEl.querySelector('.mp-empty')?.remove();
chatEl.insertAdjacentHTML('beforeend', chatHtml(c));
const last = chatEl.lastElementChild;
last.classList.add('in');
while (chatEl.children.length > 60) chatEl.firstElementChild.remove();
// Reactions in chat rise from the city on the globe.
const k = /❤️|🙏|♥/.test(c.text)
? 'heart'
: /🔥/.test(c.text)
? 'fire'
: /👀/.test(c.text)
? 'eyes'
: /😮|wild|wow/i.test(c.text)
? 'wow'
: null;
if (k) burst(k, 4);
}
if (force || np < shown.posts)
recEl.innerHTML =
(m.posts || [])
.filter((p) => p.t <= t)
.map(postHtml)
.reverse()
.join('') || emptyRec();
else
for (const p of (m.posts || []).slice(shown.posts, np)) {
recEl.querySelector('.mp-empty')?.remove();
recEl.insertAdjacentHTML('afterbegin', postHtml(p));
recEl.firstElementChild.classList.add('in');
}
if (force || nk < shown.caps) {
capsEl.innerHTML =
caps
.map((c) => capCard(c))
.reverse()
.join('') ||
'<p class="mp-empty tele">Captures appear here at the moment they were sealed</p>';
// After a seek, the latest capture stays pinned (settled) for context.
pops.innerHTML = caps.length
? capCard(caps[caps.length - 1]).replace(
'class="mp-cap ',
'class="mp-cap settled ',
)
: '';
} else
for (const c of caps.slice(shown.caps)) {
capsEl.querySelector('.mp-empty')?.remove();
capsEl.insertAdjacentHTML('afterbegin', capCard(c));
popCapture(c);
}
if (nc !== shown.chat && seg === 'chat') {
const pane = chatEl.parentElement;
pane.scrollTop = pane.scrollHeight;
}
shown = { chat: nc, posts: np, caps: nk };
$('[data-n="chat"]', el).textContent = nc ? ` ${nc}` : '';
$('[data-n="record"]', el).textContent = np ? ` ${np}` : '';
$('[data-n="captures"]', el).textContent = nk ? ` ${nk}` : '';
// Legend: live per-track readouts.
(m.tracks || []).forEach((tr) => {
const row = legend.querySelector(`[data-track="${CSS.escape(tr.id)}"]`);
if (!row) return;
const p = trackAt(tr, t);
row.classList.toggle('absent', !p);
$('[data-alt]', row).textContent = p
? `${Math.round((p.altM * 3.28084) / 10) * 10} FT`
: '—';
$('[data-spd]', row).textContent = !p
? 'NOT YET'
: p.speedMps
? `${Math.round(p.speedMps * 1.94384)} KT`
: 'LANDED';
});
$('.mp-utc', el).textContent = utc(t);
$('.mp-rel', el).textContent = `${rel(t)} / ${rel(m.time.end)}`;
drawScrub();
}
const emptyRec = () =>
'<p class="mp-empty tele">Record posts appear as they were made</p>';
function popCapture(c) {
const card = h(capCard(c, { pop: true }));
pops.prepend(card);
haptic(c.firstEyes ? 'seal' : 'light');
while (pops.children.length > (isTwoPane() ? 3 : 1))
pops.lastElementChild.remove();
setTimeout(() => card.classList.add('settle'), 4200);
}
function burst(kind, count = 3) {
const now = performance.now();
if (now - reactCooldown < 200) return;
reactCooldown = now;
const jitter = () => (Math.random() - 0.5) * 0.01;
globe.react({
lon: (m.lon ?? 0) + jitter(),
lat: (m.lat ?? 0) + jitter(),
kind,
count,
});
}
function loop() {
raf = 0;
if (!m) return;
const a = globe.activeMoment();
const st = globe.playback.state();
const nt = Number.isFinite(a?.t) ? a.t : Number.isFinite(st?.t) ? st.t : t;
if (nt !== t) {
t = clamp(nt, m.time.start, m.time.end);
sync();
}
setPlayingUi(!!(a?.playing ?? st?.playing));
raf = requestAnimationFrame(loop);
}
function setPlayingUi(p) {
if (el.classList.contains('playing') === p) return;
el.classList.toggle('playing', p);
$('.mp-play', el).innerHTML = icon(p ? 'pause' : 'play');
$('.mp-play', el).setAttribute('aria-label', p ? 'Pause' : 'Play');
}
/* ---------- open / close ---------- */
async function open(
idOrMoment,
{ at: atT = null, speed: s = null, autoplay = true } = {},
) {
const raw =
typeof idOrMoment === 'string'
? await globe.moment(idOrMoment)
: idOrMoment;
const mm = raw ? normMoment(raw) : null;
if (!mm) {
toast('That Moment is not available', { icon: icon('film') });
return null;
}
m = mm;
speed =
s ?? (SPEEDS.includes(m.recommendedSpeed) ? m.recommendedSpeed : 30);
$$('[data-speed]', el).forEach((b) =>
b.setAttribute('aria-checked', String(Number(b.dataset.speed) === speed)),
);
el.dataset.illustrative = String(!!m.illustrative);
$('.mp-kicker', el).innerHTML =
`<span class="mp-dot"></span>MOMENT · ${esc(m.place || '')}${m.simulated ? ' <span class="tag-ill">Simulated</span>' : m.illustrative ? ' <span class="tag-ill">Illustrative</span>' : ' <span class="tag-real">Recorded</span>'}`;
$('.mp-title', el).textContent = m.title;
$('.mp-side-title h3', el).textContent = m.title;
$('.mp-st-k', el).innerHTML = $('.mp-kicker', el).innerHTML;
$('.mp-st-s', el).textContent =
`${Math.round((m.time.end - m.time.start) / 60e3)} min · ${(m.tracks || []).length} tracks · ${(m.captures || []).length} sealed captures · ${(m.posts || []).length} posts on the record`;
legend.innerHTML = legendHtml();
const extra = (m.tracks || []).length - 4;
if (extra > 0)
legend.insertAdjacentHTML(
'beforeend',
`<div class="mp-more tele">+${extra} more tracks on the globe</div>`,
);
legend.hidden = !(m.tracks || []).length;
$('.mp-src', el).textContent = (m.sources || [])
.map((x) => `${x.name}${x.detail ? ` — ${x.detail}` : ''}`)
.join(' · ');
shown = { chat: 0, posts: 0, caps: 0 };
chatEl.innerHTML = '';
recEl.innerHTML = emptyRec();
capsEl.innerHTML = '';
pops.innerHTML = '';
// The social agent's chat replay, when it exists.
ext = null;
const sm = await social();
// Your own sealed captures posted into this Moment join its timeline.
const addOwn = (list) => {
for (const c of list || []) {
const id = String(c.id).split(':').pop();
if (m.captures.some((x) => x.id === id)) continue;
m.captures.push(
normMoment({
...m,
captures: [
{
...c,
id,
t: clamp(+c.t || m.time.end, m.time.start, m.time.end),
self: true,
title: c.title || 'Your sealed capture',
},
],
}).captures[0],
);
}
m.captures.sort((a, b) => a.t - b.t);
};
try {
addOwn(await sm?.momentCaptures?.(m.id));
} catch {
/* optional */
}
offOwn?.();
offOwn =
sm?.onSocial?.('moment-capture', (e) => {
if (!m || e?.momentId !== m.id) return;
addOwn([e.capture]);
sync(true);
}) || null;
const slot = $('.mp-pane[data-pane="chat"]', el);
slot.classList.remove('has-ext');
el.classList.remove('has-ext');
$('.mp-ext', slot)?.remove();
// Clips carry no chat: the overlay would only invent one.
if (typeof sm?.chatOverlay === 'function' && (m.chat || []).length) {
try {
const host = h('<div class="mp-ext"></div>');
slot.prepend(host);
ext = sm.chatOverlay(host, { moment: raw, globe, getTime: () => t });
ext?.setMoment?.({ ...raw, chat: [] });
slot.classList.add('has-ext');
el.classList.add('has-ext');
} catch (err) {
console.warn(
'[moment] chatOverlay failed; using the built-in replay',
err,
);
ext = null;
}
}
el.hidden = false;
app.classList.add('is-moment');
void el.offsetWidth;
el.classList.add('in');
t = atT ?? m.time.start;
await globe.playMoment(raw, { speed, camera: true, autoplay: false });
if (atT != null) globe.seekMoment(atT);
sync(true);
if (autoplay) globe.playback.play({ speed });
cancelAnimationFrame(raf);
raf = requestAnimationFrame(loop);
ev.emit('open', m);
return m;
}
function close() {
if (!m) return;
cancelAnimationFrame(raf);
raf = 0;
globe.exitMoment();
offOwn?.();
offOwn = null;
const was = m;
m = null;
el.classList.remove('in');
app.classList.remove('is-moment');
setTimeout(
() => {
if (!m) el.hidden = true;
},
reducedMotion() ? 0 : 320,
);
ev.emit('close', was);
onExit?.(was);
}
/* ---------- input ---------- */
$('.mp-back', el).addEventListener('click', () => {
haptic('tick');
close();
});
$('.mp-room', el).addEventListener('click', () => m && onOpenRoom?.(m));
$('.mp-play', el).addEventListener('click', () => {
haptic('tick');
const st = globe.playback.state();
if (st.playing) globe.playback.stop();
else globe.playback.play({ speed });
});
$('.mp-speeds', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-speed]');
if (!b) return;
haptic('tick');
speed = Number(b.dataset.speed);
$$('[data-speed]', el).forEach((x) =>
x.setAttribute('aria-checked', String(x === b)),
);
if (globe.playback.state().playing) globe.playback.play({ speed });
});
$('.mp-react', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-react]');
if (!b || !m) return;
haptic('light');
reactCooldown = 0;
burst(b.dataset.react, 3);
b.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(1.35)' },
{ transform: 'scale(1)' },
],
{ duration: 320, easing: 'cubic-bezier(.32,1.25,.4,1)' },
);
});
const seekTo = (clientX) => {
const r = scrub.getBoundingClientRect();
const k = clamp((clientX - r.left - 6) / (r.width - 12), 0, 1);
const ms = m.time.start + k * (m.time.end - m.time.start);
globe.seekMoment(ms);
t = ms;
sync(true);
};
let dragging = false;
scrub.addEventListener('pointerdown', (e) => {
if (!m) return;
scrub.setPointerCapture(e.pointerId);
dragging = true;
wasPlaying = globe.playback.state().playing;
globe.playback.stop();
el.classList.add('scrubbing');
seekTo(e.clientX);
});
scrub.addEventListener('pointermove', (e) => dragging && seekTo(e.clientX));
const up = () => {
if (!dragging) return;
dragging = false;
el.classList.remove('scrubbing');
if (wasPlaying) globe.playback.play({ speed });
};
scrub.addEventListener('pointerup', up);
scrub.addEventListener('pointercancel', up);
scrub.addEventListener('keydown', (e) => {
if (!m) return;
const step = e.shiftKey ? 60e3 : 10e3;
if (e.key === 'ArrowRight')
globe.seekMoment(Math.min(m.time.end, t + step));
else if (e.key === 'ArrowLeft')
globe.seekMoment(Math.max(m.time.start, t - step));
else if (e.key === ' ') $('.mp-play', el).click();
else return;
e.preventDefault();
t = globe.activeMoment()?.t ?? t;
sync(true);
});
$('.mp-seg', el).addEventListener('click', (e) => {
const b = e.target.closest('[data-seg]');
if (!b) return;
haptic('tick');
seg = b.dataset.seg;
$$('[data-seg]', el).forEach((x) =>
x.setAttribute('aria-selected', String(x === b)),
);
$$('.mp-pane', el).forEach((p) => (p.hidden = p.dataset.pane !== seg));
});
el.addEventListener('click', (e) => {
const tr = e.target.closest('[data-track]');
if (tr && m) return onOpenEntity?.(`moment:${m.id}:${tr.dataset.track}`);
const c = e.target.closest('[data-cap]');
if (c && m) return onOpenEntity?.(`capture:${m.id}:${c.dataset.cap}`);
});
document.addEventListener('keydown', (e) => {
if (!m || e.target.closest?.('input, textarea')) return;
if (
e.key === 'Escape' &&
!document.querySelector(
'.sheet-layer:not([hidden]) .sheet[aria-modal="true"]',
)
)
close();
});
new ResizeObserver(() => m && drawScrub()).observe(scrub);
return {
open,
close,
on: ev.on,
get current() {
return m;
},
get time() {
return t;
},
/** Deterministic setup for the director. */
seek(ms, { play = false } = {}) {
if (!m) return;
globe.seekMoment(ms);
t = ms;
sync(true);
if (play) globe.playback.play({ speed });
else globe.playback.stop();
},
setSeg(s) {
$(`[data-seg="${s}"]`, el)?.click();
},
};
}
+215 -65
View File
@@ -1,84 +1,200 @@
/** Search: situations, layers and places (OpenStreetMap Nominatim via /api/geocode). */
/**
* Universal search (DESIGN.md §12.1, §12.7): instant results from
* globe.search(), grouped into Things, Situations, Places and Moments, plus
* lenses. Search leads to dossiers, not to a list of pins. Recent searches
* and suggestions ("7700", "ISS", "Piccadilly") fill the empty state.
*/
import { icon, FAMILY_ICON } from './icons.js';
import { $, esc, haptic } from './dom.js';
import { $, $$, esc, haptic } from './dom.js';
import { createSheet } from './sheet.js';
import { bandOfKind } from './shim3.js';
import { KIND_ICON, KIND_NAME } from './dossier.js';
import { bandOf } from './bands.js';
const PLACES = [
{ name: 'Kathmandu', sub: 'Nepal', lon: 85.324, lat: 27.7172 },
{ name: 'Strait of Hormuz', sub: 'Persian Gulf', lon: 56.4, lat: 26.5 },
{ name: 'Panama Canal', sub: 'Panama', lon: -79.68, lat: 9.08 },
{ name: 'Heathrow', sub: 'London, UK', lon: -0.4543, lat: 51.47 },
{ name: 'Cape Canaveral', sub: 'Florida, US', lon: -80.6, lat: 28.47 },
{ name: 'Mount Etna', sub: 'Sicily, Italy', lon: 14.995, lat: 37.751 },
const RECENT_KEY = 'gev.search.recent';
const SUGGEST = [
'7700',
'ISS',
'Piccadilly',
'Empire State',
'M6',
'Starlink',
'helicopter',
];
const THING = new Set([
'aircraft',
'helicopter',
'military',
'satellite',
'vessel',
'quake',
'camera',
'track',
'capture',
]);
const GROUPS = [
['things', 'Things'],
['situations', 'Situations'],
['places', 'Places'],
['moments', 'Moments'],
['lenses', 'Lenses'],
];
function recent() {
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
} catch {
return [];
}
}
function remember(q) {
const list = [
q,
...recent().filter((x) => x.toLowerCase() !== q.toLowerCase()),
].slice(0, 6);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list));
} catch {
/* ignore */
}
}
export function createSearch({
host,
getSituations,
globe,
onOpenSituation,
onPlace,
onOpenEntity,
onOpenMoment,
onLayer,
}) {
const sheet = createSheet({
host,
label: 'Search',
detents: ['large'],
modal: true,
dismissible: true,
className: 'search-sheet',
heights: { large: (H) => Math.round(H * 0.92) },
heights: { large: (H) => Math.round(H * 0.93) },
});
sheet.head.innerHTML = `<form class="search-row" role="search"><label class="search-field">${icon('search')}<input type="search" placeholder="Situations, lenses, places" aria-label="Search" enterkeyhint="search" autofocus data-no-drag/></label><button class="pill-btn plain search-cancel" type="button">Cancel</button></form>`;
sheet.head.innerHTML = `<form class="search-row" role="search"><label class="search-field">${icon('search')}<input type="search" placeholder="Aircraft, ships, places, 7700, M5.2…" aria-label="Search everything" enterkeyhint="search" autocomplete="off" spellcheck="false" autofocus data-no-drag/></label><button class="pill-btn plain search-cancel" type="button">Cancel</button></form>`;
const input = $('input', sheet.head);
let timer = 0;
let geo = [];
let items = [];
let seq = 0;
let cursor = -1;
function match(q, ...fields) {
const s = fields.join(' ').toLowerCase();
return q
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.every((w) => s.includes(w));
function row(r, i) {
const band =
r.kind === 'situation' ? r.band || 'ground' : bandOfKind(r.kind);
const ic =
r.kind === 'lens'
? FAMILY_ICON[r.family] || 'layers'
: KIND_ICON[r.kind] || 'scope';
const tag =
r.kind === 'lens' ? (r.on ? 'On' : 'Off') : KIND_NAME[r.kind] || r.kind;
return `<button class="sr" type="button" data-i="${i}" data-band="${esc(band)}" role="option" aria-selected="${i === cursor}"><span class="sr-ic">${icon(ic)}</span><span class="sr-main"><b>${esc(r.title)}</b><small>${esc(r.subtitle || '')}</small></span><span class="sr-tag tele">${esc(tag)}</span></button>`;
}
function emptyState() {
const rec = recent();
sheet.body.innerHTML = `
${rec.length ? `<div class="results-h">Recent<button class="link-btn" type="button" data-clear>Clear</button></div><div class="sr-chips">${rec.map((q) => `<button class="chip" type="button" data-q="${esc(q)}">${icon('clock')}${esc(q)}</button>`).join('')}</div>` : ''}
<div class="results-h">Try</div>
<div class="sr-chips">${SUGGEST.map((q) => `<button class="chip" type="button" data-q="${esc(q)}">${esc(q)}</button>`).join('')}</div>
<div class="results-h">Live now</div><div class="sr-list" data-live></div>
<p class="sr-note">${icon('lock')}Search finds things that broadcast publicly (transponders, AIS, orbital elements), places and events. It never finds people.</p>`;
getSituations().then((sits) => {
const live = $('[data-live]', sheet.body);
if (!live) return;
items = sits.slice(0, 5).map((s) => ({
id: `situation:${s.id}`,
kind: 'situation',
title: s.title,
subtitle: s.place,
band: bandOf(s),
}));
live.innerHTML = items.map(row).join('');
});
}
async function render() {
const q = input.value.trim();
const sits = await getSituations();
const s = q
? sits.filter((x) => match(q, x.title, x.place, x.kind, x.family))
: sits.slice(0, 5);
const layers = q
? globe
.layers()
.filter((l) => match(q, l.name, l.family))
.slice(0, 8)
: [];
const places = q
? PLACES.filter((p) => match(q, p.name, p.sub))
: PLACES.slice(0, 4);
sheet.body.innerHTML = `
<div class="results-h">${q ? 'Situations' : 'Live now'}</div>
${s.map((x) => `<button class="result" type="button" data-sit="${esc(x.id)}"><img class="result-thumb" src="${esc(x.thumbnail)}" alt="" loading="lazy"/><span><div class="result-title">${esc(x.title)}</div><div class="result-sub">${esc(x.place)}</div></span></button>`).join('') || '<p class="result-sub">No situations match.</p>'}
${layers.length ? `<div class="results-h">Lenses</div>${layers.map((l) => `<button class="result" type="button" data-layer="${esc(l.id)}" data-family="${esc(l.family)}"><span class="result-icon">${icon(FAMILY_ICON[l.family] || 'layers')}</span><span><div class="result-title">${esc(l.name)}</div><div class="result-sub">${esc(l.family)} · ${l.on ? 'on' : 'off'}${l.count != null ? ` · ${l.count}` : ''}</div></span></button>`).join('')}` : ''}
<div class="results-h">Places</div>
${[...places, ...geo].map((p, i) => `<button class="result" type="button" data-place="${i}"><span class="result-icon">${icon('pin')}</span><span><div class="result-title">${esc(p.name)}</div><div class="result-sub">${esc(p.sub || '')}</div></span></button>`).join('')}
${q.length >= 3 ? '<p class="result-sub" style="padding:10px 4px">Press search to look up places · © OpenStreetMap contributors</p>' : ''}`;
sheet.body._places = [...places, ...geo];
cursor = -1;
if (!q) return emptyState();
const my = ++seq;
const res = await globe.search(q);
if (my !== seq) return;
const sits = await getSituations().catch(() => []);
const ql = q.toLowerCase();
const lenses = globe
.layers()
.filter((l) => `${l.name} ${l.family}`.toLowerCase().includes(ql))
.slice(0, 4)
.map((l) => ({
id: l.id,
kind: 'lens',
title: l.name,
subtitle: `${l.family} lens${l.count != null ? ` · ${l.count}` : ''}`,
family: l.family,
on: l.on,
}));
const grouped = {
things: [],
situations: [],
places: [],
moments: [],
lenses,
};
for (const r of res) {
if (r.kind === 'situation') {
const s = sits.find((x) => `situation:${x.id}` === r.id);
grouped.situations.push({
...r,
band: r.band || (s ? bandOf(s) : 'ground'),
});
} else if (r.kind === 'place') grouped.places.push(r);
else if (r.kind === 'moment') grouped.moments.push(r);
else if (THING.has(r.kind)) grouped.things.push(r);
}
for (const g of geo) grouped.places.push(g);
items = [];
const html = GROUPS.map(([k, label]) => {
const list = grouped[k].slice(0, k === 'things' ? 8 : 5);
if (!list.length) return '';
const rows = list.map((r) => {
items.push(r);
return row(r, items.length - 1);
});
return `<div class="results-h">${label}<small>${grouped[k].length}</small></div><div class="sr-list" role="listbox">${rows.join('')}</div>`;
}).join('');
sheet.body.innerHTML =
html ||
`<div class="sr-empty"><b>No matches for “${esc(q)}”</b><span>Press return to look it up as a place.</span></div>`;
if (q.length >= 3 && !geo.length)
sheet.body.insertAdjacentHTML(
'beforeend',
`<p class="sr-note">Return looks “${esc(q)}” up as a place · © OpenStreetMap contributors</p>`,
);
}
async function geocode() {
const q = input.value.trim();
if (q.length < 3) return;
remember(q);
try {
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
if (!r.ok) throw new Error(String(r.status));
const j = await r.json();
geo = (j.results || [])
.slice(0, 5)
.map((x) => ({
name: x.formatted_address?.split(',')[0] || x.name || q,
sub: x.formatted_address || '',
.map((x, i) => ({
id: `geo:${i}`,
kind: 'place',
title: x.formatted_address?.split(',')[0] || x.name || q,
subtitle: x.formatted_address || '',
lat: Number(x.geometry?.location?.lat ?? x.lat),
lon: Number(x.geometry?.location?.lng ?? x.lon),
}))
@@ -89,51 +205,85 @@ export function createSearch({
render();
}
function choose(r) {
if (!r) return;
haptic('light');
const q = input.value.trim();
if (q) remember(q);
sheet.close();
setTimeout(() => {
if (r.kind === 'lens') onLayer(r.id);
else if (r.kind === 'situation')
onOpenSituation(r.id.replace(/^situation:/, ''));
else if (r.kind === 'moment') onOpenMoment(r.id.replace(/^moment:/, ''));
else if (/^geo:/.test(r.id))
globe.flyTo({ lon: r.lon, lat: r.lat, height: 30e3, duration: 2 });
else onOpenEntity(r.id);
}, 160);
}
input.addEventListener('input', () => {
geo = [];
clearTimeout(timer);
timer = setTimeout(render, 80);
timer = setTimeout(render, 60);
});
input.addEventListener('keydown', (e) => {
const rows = $$('.sr', sheet.body);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
cursor = Math.max(
0,
Math.min(rows.length - 1, cursor + (e.key === 'ArrowDown' ? 1 : -1)),
);
rows.forEach((r, i) =>
r.setAttribute('aria-selected', String(i === cursor)),
);
rows[cursor]?.scrollIntoView({ block: 'nearest' });
}
});
sheet.head.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
if (cursor >= 0 && items[cursor]) return choose(items[cursor]);
if (items.length && input.value.trim() && !$('.sr-empty', sheet.body))
return choose(items[0]);
geocode();
});
sheet.head
.querySelector('.search-cancel')
.addEventListener('click', () => sheet.close());
sheet.body.addEventListener('click', async (e) => {
const sit = e.target.closest('[data-sit]');
if (sit) {
const s = (await getSituations()).find(
(x) => String(x.id) === sit.dataset.sit,
);
sheet.close();
if (s) setTimeout(() => onOpenSituation(s), 200);
return;
}
const l = e.target.closest('[data-layer]');
if (l) {
sheet.body.addEventListener('click', (e) => {
const chip = e.target.closest('[data-q]');
if (chip) {
haptic('tick');
sheet.close();
onLayer(l.dataset.layer);
input.value = chip.dataset.q;
render();
return;
}
const p = e.target.closest('[data-place]');
if (p) {
const place = sheet.body._places[Number(p.dataset.place)];
sheet.close();
if (place) onPlace(place);
if (e.target.closest('[data-clear]')) {
try {
localStorage.removeItem(RECENT_KEY);
} catch {
/* ignore */
}
emptyState();
return;
}
const r = e.target.closest('[data-i]');
if (r) choose(items[Number(r.dataset.i)]);
});
return {
open() {
input.value = '';
open(q = '') {
input.value = q;
geo = [];
render();
sheet.open('large');
setTimeout(() => input.focus({ preventScroll: true }), 120);
},
close: () => sheet.close(),
get isOpen() {
return sheet.isOpen;
},
};
}
+6 -5
View File
@@ -27,7 +27,7 @@ export function createSheet(o) {
const root = h(`<div class="sheet-layer ${o.modal ? 'is-modal' : ''}" hidden>
${o.modal ? '<div class="sheet-scrim" aria-hidden="true"></div>' : ''}
<section class="sheet ${o.className || ''}" role="dialog" aria-modal="${o.modal ? 'true' : 'false'}" aria-label="${o.label}">
<section class="sheet ${o.className || ''}" tabindex="-1" role="dialog" aria-modal="${o.modal ? 'true' : 'false'}" aria-label="${o.label}">
<button class="sheet-grabber" type="button" aria-label="${o.label}: resize"><span></span></button>
${o.closeButton ? `<button class="sheet-close icon-btn" type="button" aria-label="Close">${icon('close')}</button>` : ''}
<div class="sheet-head"></div>
@@ -100,10 +100,11 @@ export function createSheet(o) {
if (o.modal)
setTimeout(
() =>
(
sheet.querySelector('[autofocus]') ||
root.querySelector('.sheet-grabber')
)?.focus({ preventScroll: true }),
// The dialog itself takes focus (no ring on the grabber); Tab
// reaches the grabber and every control from there.
(sheet.querySelector('[autofocus]') || sheet)?.focus({
preventScroll: true,
}),
60,
);
}
+1518
View File
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
/**
* Your sky now (DESIGN.md §12.3): what is above you this minute, on a sky
* dome (zenith in the centre, horizon at the rim, north up) and as a list.
* Live from globe.skyNow(); refreshes every 2 s while open.
*/
import { icon } from './icons.js';
import { $, esc, fmtNum, haptic } from './dom.js';
import { createSheet } from './sheet.js';
import { cardinal, utc } from './bands.js';
/** Sky dome SVG. size in px; items: [{ el, az, kind, id, label? }]. */
export function domeSvg(sky, { size = 300, labels = true, sel = null } = {}) {
const c = size / 2;
const R = c - (labels ? 18 : 4);
const pos = (el, az) => {
const r = R * (1 - Math.max(0, el) / 90);
const a = ((az - 90) * Math.PI) / 180;
return [c + r * Math.cos(a), c + r * Math.sin(a)];
};
const dots = [];
for (const s of sky?.satellites || []) {
const [x, y] = pos(s.elevationDeg, s.azimuthDeg);
dots.push(
`<g class="dm-sat${sel === s.id ? ' sel' : ''}" data-id="${esc(s.id)}"><rect x="${(x - 2.2).toFixed(1)}" y="${(y - 2.2).toFixed(1)}" width="4.4" height="4.4" transform="rotate(45 ${x.toFixed(1)} ${y.toFixed(1)})"/></g>`,
);
}
for (const a of sky?.aircraft || []) {
if (a.elevationDeg < 0) continue;
const [x, y] = pos(a.elevationDeg, a.azimuthDeg);
dots.push(
`<g class="dm-ac${sel === a.id ? ' sel' : ''}" data-id="${esc(a.id)}" transform="translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${Math.round(a.headingDeg || 0)})"><path d="M0 -5.5 3.6 4 0 2 -3.6 4Z"/></g>`,
);
}
const iss = sky?.iss;
if (iss && Number.isFinite(iss.elevationDeg) && iss.elevationDeg > 0) {
const [x, y] = pos(iss.elevationDeg, iss.azimuthDeg);
dots.push(
`<g class="dm-iss"><circle cx="${x}" cy="${y}" r="4"/><circle class="ring" cx="${x}" cy="${y}" r="9"/>${labels ? `<text x="${x + 12}" y="${y + 3}">ISS</text>` : ''}</g>`,
);
}
return `<svg class="dome" viewBox="0 0 ${size} ${size}" role="img" aria-label="Sky dome: ${(sky?.aircraft || []).length} aircraft and ${(sky?.satellites || []).length} satellites above you">
<defs><radialGradient id="dm-g${size}" cx="50%" cy="50%" r="50%"><stop offset="0" stop-color="#14203a"/><stop offset="0.75" stop-color="#0a1020"/><stop offset="1" stop-color="#05070b"/></radialGradient></defs>
<circle cx="${c}" cy="${c}" r="${R}" fill="url(#dm-g${size})" class="dm-sky"/>
${[30, 60].map((e) => `<circle cx="${c}" cy="${c}" r="${R * (1 - e / 90)}" class="dm-ring"/>`).join('')}
<path class="dm-ring" d="M${c} ${c - R}V${c + R}M${c - R} ${c}H${c + R}"/>
<circle cx="${c}" cy="${c}" r="${R}" class="dm-horizon"/>
${labels ? `<text class="dm-card n" x="${c}" y="${c - R - 6}">N</text><text class="dm-card" x="${c + R + 9}" y="${c + 3}">E</text><text class="dm-card" x="${c}" y="${c + R + 14}">S</text><text class="dm-card" x="${c - R - 9}" y="${c + 3}">W</text><text class="dm-el" x="${c + 3}" y="${c - R * (1 - 60 / 90) - 3}">60°</text><text class="dm-el" x="${c + 3}" y="${c - R * (1 - 30 / 90) - 3}">30°</text>` : ''}
${dots.join('')}
<circle cx="${c}" cy="${c}" r="2" class="dm-you"/>
</svg>`;
}
export function createSkySheet({ host, globe, getHome, onOpenEntity }) {
const sheet = createSheet({
host,
label: 'Your sky now',
detents: ['medium', 'large'],
modal: true,
dismissible: true,
closeButton: true,
className: 'sky-sheet',
heights: {
medium: (H) => Math.round(H * 0.62),
large: (H) => Math.round(H * 0.93),
},
});
let timer = 0;
let sky = null;
sheet.on('close', () => clearInterval(timer));
function rowAc(a) {
return `<button class="sky-row" type="button" data-ent="${esc(a.id)}" data-band="sky"><span class="sky-ic">${icon(a.kind === 'helicopter' ? 'heli' : 'sky')}</span>
<span class="sky-main"><b>${esc(a.title)}</b><small>${esc(fmtNum(Math.round((a.altM * 3.28084) / 100) * 100))} ft · ${esc(fmtNum(a.distanceKm, 1))} km ${esc(cardinal(a.azimuthDeg))}</small></span>
<span class="sky-el tele"><b class="num">${Math.round(a.elevationDeg)}°</b>UP</span></button>`;
}
function rowSat(s) {
return `<button class="sky-row" type="button" data-ent="${esc(s.id)}" data-band="orbit"><span class="sky-ic">${icon('satellite')}</span>
<span class="sky-main"><b>${esc(s.title)}</b><small>${esc(fmtNum(s.altKm))} km up · ${esc(fmtNum(s.rangeKm))} km away ${esc(cardinal(s.azimuthDeg))}</small></span>
<span class="sky-el tele"><b class="num">${Math.round(s.elevationDeg)}°</b>UP</span></button>`;
}
async function refresh() {
const home = getHome();
sky = await globe.skyNow({ lon: home.lon, lat: home.lat });
if (!sky) {
sheet.body.innerHTML =
'<p class="footnote">The sky feed is not available on this engine yet.</p>';
return;
}
const ac = sky.aircraft || [];
const sats = sky.satellites || [];
const iss = sky.iss || {};
const np = iss.nextPass;
const hm = (t) =>
new Date(t).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
});
$('.sky-dome', sheet.body).innerHTML = domeSvg(sky, { size: 300 });
$('.sky-count', sheet.body).innerHTML =
`<div><b class="num">${ac.length}</b><span>aircraft</span></div><div><b class="num">${sats.length}</b><span>satellites</span></div><div><b class="num">${iss.visibleNow ? 'Now' : np ? hm(np.start) : '—'}</b><span>${iss.visibleNow ? 'ISS overhead' : 'next ISS pass'}</span></div>`;
$('.sky-lists', sheet.body).innerHTML = `
${np || iss.visibleNow ? `<div class="sky-iss" data-band="orbit">${icon('satellite')}<div><b>${iss.visibleNow ? 'The ISS is above you right now' : `ISS passes over at ${hm(np.start)}`}</b><span>${np ? `Rises ${hm(np.start)} · highest ${np.maxElevationDeg ?? '—'}° at ${hm(np.max)} · sets ${np.end ? hm(np.end) : '—'}` : `${Math.round(iss.elevationDeg)}° up, ${cardinal(iss.azimuthDeg)}`}</span></div></div>` : ''}
<div class="section-h">Aircraft<small class="num">${ac.length} within 50 km</small></div>
<div class="group">${ac.slice(0, 12).map(rowAc).join('') || '<div class="row"><span class="row-sub">No aircraft overhead right now. A quiet sky.</span></div>'}</div>
<div class="section-h">Satellites<small class="num">${sats.length} above the horizon</small></div>
<div class="group">${sats.slice(0, 12).map(rowSat).join('') || '<div class="row"><span class="row-sub">None above the horizon.</span></div>'}</div>
<p class="footnote">Live ADS-B and orbital elements, computed for ${esc(getHome().label || getHome().name)}${getHome().demo ? ' (demo location)' : ''} · ${esc(utc(new Date()))}</p>`;
}
sheet.body.addEventListener('click', (e) => {
const r = e.target.closest('[data-ent]');
if (r) {
haptic('light');
sheet.close();
onOpenEntity?.(r.dataset.ent);
}
});
return {
async open() {
const home = getHome();
sheet.head.innerHTML = `<div class="sky-kicker tele"><span class="live-dot"></span>LIVE · ${esc(utc(new Date(), { seconds: false }))}</div><h2 class="sheet-title">Your sky now</h2><div class="sheet-sub">Above ${esc(home.label || home.name)}${home.demo ? ' · demo location' : ''}, this minute</div>`;
sheet.body.innerHTML = `<div class="sky-top"><div class="sky-dome"></div><div class="sky-count"></div></div><div class="sky-lists"></div>`;
sheet.open('medium');
await refresh();
clearInterval(timer);
timer = setInterval(() => sheet.isOpen && refresh(), 2000);
},
close: () => sheet.close(),
get isOpen() {
return sheet.isOpen;
},
};
}
+508
View File
@@ -0,0 +1,508 @@
/**
* The hero Moment (DESIGN.md §12.4): "Climbers on the Empire State Building".
*
* An ILLUSTRATIVE reconstruction used when the engine does not ship one
* (offline stub, or before mobile/demo/moments/empire-state.json lands).
* Tracks are synthetic but physically plausible: two news helicopters and one
* police helicopter orbiting at 1,000–1,500 ft, radii 400–700 m, 60–80 kt,
* with standard-rate-ish turns. Captures, chat and posts are invented and
* labelled as such everywhere they are shown.
*/
const R = 6371008.8;
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
export const ESB = { lon: -73.98566, lat: 40.74844 };
function dest(lon, lat, brg, dist) {
const d = dist / R;
const b = brg * D2R;
const p1 = lat * D2R;
const l1 = lon * D2R;
const p2 = Math.asin(
Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(b),
);
const l2 =
l1 +
Math.atan2(
Math.sin(b) * Math.sin(d) * Math.cos(p1),
Math.cos(d) - Math.sin(p1) * Math.sin(p2),
);
return [l2 * R2D, p2 * R2D];
}
export function bearing(a, b) {
const p1 = a.lat * D2R;
const p2 = b.lat * D2R;
const dl = (b.lon - a.lon) * D2R;
const y = Math.sin(dl) * Math.cos(p2);
const x =
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return (((Math.atan2(y, x) * R2D + 360) % 360) + 360) % 360;
}
export function distM(a, b) {
const dLat = (b.lat - a.lat) * D2R;
const dLon = (b.lon - a.lon) * D2R;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos(a.lat * D2R) * Math.cos(b.lat * D2R) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
}
/**
* One helicopter: a straight approach from its base to the orbit entry, then
* a constant-speed orbit. Points every 5 s from departure to the Moment end.
*/
function heli({
id,
label,
type,
operator,
base,
arriveAt,
radius,
altM,
kt,
dir,
phase,
t0,
t1,
}) {
const v = kt * 0.514444;
const entryBrg = bearing(ESB, base);
const [ex, ey] = dest(ESB.lon, ESB.lat, entryBrg, radius);
const entry = { lon: ex, lat: ey };
const approachM = distM(base, entry);
const depart = arriveAt - (approachM / v) * 1000;
const pts = [];
const step = 5000;
const omega = (v / radius) * R2D; // deg per second
for (
let t = Math.max(t0, Math.ceil(depart / step) * step);
t <= t1;
t += step
) {
if (t < arriveAt) {
const k = (t - depart) / (arriveAt - depart);
const lon = base.lon + (entry.lon - base.lon) * k;
const lat = base.lat + (entry.lat - base.lat) * k;
pts.push({ t, lon, lat, altM: Math.round(560 + (altM - 560) * k) });
} else {
const s = (t - arriveAt) / 1000;
const a = entryBrg + dir * omega * s + (phase || 0) * 0;
const [lon, lat] = dest(ESB.lon, ESB.lat, a, radius);
// A gentle porpoise of ±15 m reads as a real track, not a CAD circle.
pts.push({
t,
lon,
lat,
altM: Math.round(altM + 15 * Math.sin(s / 23 + (phase || 0))),
});
}
}
return {
id,
kind: 'helicopter',
label,
type,
operator,
speedKt: kt,
radiusM: radius,
arriveAt,
points: pts,
};
}
/** Interpolated position of a track at time t (null before it exists). */
export function trackAt(track, t) {
const p = track.points;
if (!p?.length || t < p[0].t) return null;
if (t >= p[p.length - 1].t) return { ...p[p.length - 1], i: p.length - 1 };
let lo = 0;
let hi = p.length - 1;
while (hi - lo > 1) {
const m = (lo + hi) >> 1;
if (p[m].t <= t) lo = m;
else hi = m;
}
const a = p[lo];
const b = p[hi];
const k = (t - a.t) / Math.max(1, b.t - a.t);
const pos = {
t,
lon: a.lon + (b.lon - a.lon) * k,
lat: a.lat + (b.lat - a.lat) * k,
altM: a.altM + (b.altM - a.altM) * k,
i: lo,
};
pos.headingDeg = bearing(a, b);
pos.speedMps = distM(a, b) / Math.max(0.001, (b.t - a.t) / 1000);
return pos;
}
export function buildHeroMoment(now = Date.now()) {
// The Moment happened earlier today: 20 minutes, ending ~75 min ago.
const t0 = Math.floor((now - 95 * 60e3) / 60e3) * 60e3;
const t1 = t0 + 20 * 60e3;
const at = (m, s = 0) => t0 + m * 60e3 + s * 1000;
const tracks = [
heli({
id: 'police6',
label: 'POLICE 6',
type: 'Bell 429',
operator: 'City police aviation unit',
base: { lon: -74.009, lat: 40.7013 },
arriveAt: at(2, 30),
radius: 450,
altM: 320,
kt: 68,
dir: -1,
t0,
t1,
}),
heli({
id: 'news4',
label: 'NEWSCOPTER 4',
type: 'Airbus H125',
operator: 'Metro 4 News',
base: { lon: -74.008, lat: 40.754 },
arriveAt: at(5, 10),
radius: 650,
altM: 442,
kt: 78,
dir: -1,
phase: 2,
t0,
t1,
}),
heli({
id: 'sky7',
label: 'SKY 7',
type: 'Bell 407',
operator: 'Harbor 7 Eyewitness',
base: { lon: -74.061, lat: 40.85 },
arriveAt: at(8, 40),
radius: 560,
altM: 395,
kt: 72,
dir: -1,
phase: 4,
t0,
t1,
}),
];
const cap = (o) => {
const pos = { lon: o.lon, lat: o.lat };
return {
fovDeg: 52,
sealed: true,
...o,
headingDeg: Math.round(bearing(pos, ESB)),
distanceM: Math.round(distM(pos, ESB)),
};
};
const captures = [
cap({
id: 'c1',
t: at(1, 12),
lon: -73.9883,
lat: 40.7497,
altM: 2,
title: 'Two figures on the north setback',
handle: '@harbor_cam_kid',
where: 'W 34th St & 6th Ave',
firstEyes: true,
media: 'photo',
}),
cap({
id: 'c2',
t: at(4, 31),
lon: -73.98745,
lat: 40.74645,
altM: 2,
title: 'Police helicopter holding over 5th Ave',
handle: '@5thave_dana',
where: '5th Ave & 31st St',
media: 'clip',
}),
cap({
id: 'c3',
t: at(9, 5),
lon: -73.9928,
lat: 40.7506,
altM: 158,
title: 'Level view from the 38th floor, 1 Penn',
handle: '@onepenn_38f',
where: '1 Penn Plaza, floor 38',
media: 'photo',
}),
cap({
id: 'c4',
t: at(11, 48),
lon: -73.9888,
lat: 40.7458,
altM: 64,
title: 'Live from a Broadway rooftop',
handle: '@roof_rodrigo',
where: 'Broadway & 30th St, roof',
media: 'live',
live: true,
}),
cap({
id: 'c5',
t: at(15, 20),
lon: -73.9849,
lat: 40.7499,
altM: 2,
title: 'Climbers brought in at the 86th floor',
handle: '@midtown_maya',
where: '5th Ave & 35th St',
media: 'clip',
}),
];
const chatScript = [
[
0,
40,
'@nyc_scanner_nerd',
'scanner: units requested to 350 5th, "persons on the exterior"',
'local',
],
[0, 58, '@lex_ave_lee', 'wait what, ON the building??'],
[
1,
20,
'@harbor_cam_kid',
'I can see them from 34th. two people. sealed photo up',
'first-eyes',
],
[1, 34, '@aero_ana', 'that photo is wild'],
[1, 50, '@k_ostrowski', '👀👀👀'],
[
2,
10,
'@nyc_scanner_nerd',
'aviation unit launching from downtown',
'local',
],
[
2,
41,
'@heli_hank',
'POLICE 6 inbound, left-hand orbit ~450 m. textbook',
'pilot',
],
[3, 2, '@5thave_dana', 'deck looks closed, people being moved inside'],
[3, 30, '@bk_bianca', 'is anyone hurt?'],
[
3,
44,
'@harbor_cam_kid',
'not that I can see. they look clipped in',
'first-eyes',
],
[4, 12, '@lex_ave_lee', 'stay safe up there 🙏'],
[
4,
40,
'@5thave_dana',
'clip from 5th, you can see POLICE 6 holding',
'witness',
],
[
5,
15,
'@heli_hank',
'NEWSCOPTER 4 just joined, wider orbit ~650 m',
'pilot',
],
[5, 40, '@aero_ana', 'two helis now'],
[6, 5, '@k_ostrowski', 'traffic on 34th is stopped'],
[6, 30, '@nyc_scanner_nerd', 'ESU on the 86th floor per scanner', 'local'],
[7, 3, '@midtown_maya', 'heading over, 5 min out'],
[7, 40, '@bk_bianca', 'who are they? protest?'],
[
8,
2,
'@mod_signal',
'Reminder: no names, no faces. Evidence, not identities.',
'mod',
],
[
8,
51,
'@heli_hank',
'SKY 7 makes three. all holding separation, nice',
'pilot',
],
[
9,
12,
'@onepenn_38f',
'level view from 1 Penn, 38th fl. they are lower than the spire',
'witness',
],
[9, 40, '@lex_ave_lee', 'that angle 🔥'],
[10, 15, '@aero_ana', 'so not the spire then'],
[10, 48, '@harbor_cam_kid', 'correct, setback above 86', 'first-eyes'],
[
11,
20,
'@k_ostrowski',
'hearts for the window washers who called it in ❤️',
],
[
11,
55,
'@roof_rodrigo',
'going live from the roof on Broadway',
'witness',
],
[12, 30, '@bk_bianca', 'watching the live, so steady'],
[
13,
10,
'@nyc_scanner_nerd',
'scanner: "contact made, cooperative"',
'local',
],
[13, 42, '@lex_ave_lee', 'phew'],
[14, 20, '@heli_hank', 'POLICE 6 tightening orbit to ~350 m', 'pilot'],
[
15,
25,
'@midtown_maya',
'they are being brought in through a window at 86',
'witness',
],
[15, 50, '@aero_ana', '😮'],
[16, 30, '@k_ostrowski', 'whole street clapping lol'],
[17, 15, '@5thave_dana', 'deck reopening soon per staff'],
[18, 0, '@heli_hank', 'news helis peeling off west', 'pilot'],
[18, 45, '@nyc_scanner_nerd', 'units clearing', 'local'],
[19, 20, '@bk_bianca', 'what a morning'],
];
const chat = chatScript.map(([m, s, handle, text, badge]) => ({
t: at(m, s),
handle,
text,
...(badge ? { badge } : {}),
}));
const posts = [
{
t: at(1, 40),
handle: '@harbor_cam_kid',
kind: 'witness',
text: 'Two people on the north setback above the 86th-floor deck. Sealed photo from W 34th St, 180 m from the base.',
votes: { corroborate: 41, dispute: 2 },
captureId: 'c1',
},
{
t: at(3, 15),
handle: '@bk_bianca',
kind: 'question',
text: 'Is the observation deck closed? Anyone hurt?',
votes: { corroborate: 12, dispute: 0 },
},
{
t: at(6, 10),
handle: '@nyc_scanner_nerd',
kind: 'context',
text: 'Police aviation on scene since the 2½-minute mark; this is the standard rope-rescue pattern for exterior climbers in Midtown.',
votes: { corroborate: 19, dispute: 1 },
},
{
t: at(10, 5),
handle: '@onepenn_38f',
kind: 'correction',
text: 'Not the spire. The level view from 1 Penn puts them on the setback just above 86, well below the mast.',
votes: { corroborate: 33, dispute: 3 },
captureId: 'c3',
},
{
t: at(15, 35),
handle: '@midtown_maya',
kind: 'witness',
text: 'Both climbers brought in through a window at the 86th floor. Clip sealed from 5th Ave & 35th.',
votes: { corroborate: 27, dispute: 0 },
captureId: 'c5',
},
{
t: at(18, 30),
handle: 'Clerk',
kind: 'synthesis',
text: 'What chat is saying: two climbers on the north setback (5 sealed captures agree); three helicopters on scene; no injuries reported; both brought inside at 86.',
votes: { corroborate: 0, dispute: 0 },
},
];
return {
id: 'empire-state',
title: 'Climbers on the Empire State Building',
place: 'Midtown Manhattan, New York',
lon: ESB.lon,
lat: ESB.lat,
time: { start: t0, end: t1 },
subject: { lon: ESB.lon, lat: ESB.lat, label: 'Empire State Building' },
illustrative: true,
cameraPath: [
{
t: t0,
lon: ESB.lon,
lat: ESB.lat - 0.012,
height: 4200,
heading: 10,
pitch: -38,
},
{
t: at(5),
lon: ESB.lon - 0.006,
lat: ESB.lat - 0.01,
height: 2600,
heading: 30,
pitch: -32,
},
{
t: at(12),
lon: ESB.lon + 0.008,
lat: ESB.lat - 0.009,
height: 2200,
heading: 340,
pitch: -30,
},
{
t: t1,
lon: ESB.lon,
lat: ESB.lat - 0.011,
height: 3000,
heading: 0,
pitch: -35,
},
],
tracks,
captures,
chat,
posts,
sources: [
{
name: 'Illustrative reconstruction',
detail:
'Synthetic helicopter tracks modelled on typical news and police orbits (400–700 m radius, 1,000–1,500 ft, 60–80 kt). No real aircraft, people or posts.',
},
{
name: 'Esri World Imagery',
detail: 'Base imagery of Midtown Manhattan',
},
{
name: 'Example captures',
detail: 'View cones, positions and times are illustrative',
},
],
};
}
+29
View File
@@ -348,6 +348,35 @@ export function buildStubSituations(now) {
severity: 1,
age: 0,
}),
S({
id: 'circling-midtown',
kind: 'circling',
family: 'sky',
band: 'sky',
archetype: 'track',
severity: 4,
live: false,
title: '3 helicopters circled Midtown Manhattan',
subtitle: 'Detected from orbiting tracks · replay the Moment',
place: 'Empire State Building, New York',
lon: -73.98566,
lat: 40.74844,
span: 0.05,
th: 520,
height: 6000,
layers: ['sky.helicopters', 'sky.commercial-flights'],
metrics: [
{ label: 'Rotorcraft', value: 3, unit: '' },
{ label: 'Orbit radius', value: '450–650', unit: 'm' },
{ label: 'Altitude', value: '1,050–1,450', unit: 'ft' },
],
headline: { value: 3, unit: 'helis', label: 'circling one point' },
telemetry: 'SKY · 3 ROTORCRAFT · R 450–650 M · 1,050–1,450 FT',
momentId: 'empire-state',
illustrative: true,
track: { headingDeg: 0, circuit: true },
age: 76,
}),
S({
id: 'novosel',
kind: 'helicopters',
+6 -6
View File
@@ -8,10 +8,10 @@ import { icon, FAMILY_ICON } from './icons.js';
import { $, $$, esc, ago, fmtNum, haptic } from './dom.js';
export const TABS = [
{ id: 'now', label: 'Now', icon: 'now' },
{ id: 'globe', label: 'Globe', icon: 'globe' },
{ id: 'today', label: 'Today', icon: 'today' },
{ id: 'live', label: 'Live', icon: 'globe' },
{ id: 'capture', label: 'Capture', icon: 'viewfinder' },
{ id: 'threads', label: 'Threads', icon: 'threads' },
{ id: 'rooms', label: 'Rooms', icon: 'rooms' },
{ id: 'you', label: 'You', icon: 'you' },
];
@@ -41,7 +41,7 @@ export function createTabBar(nav, { onSelect, onSearch, onAccessory }) {
const acc = $('.tb-accessory', nav);
const miniTab = $('.tb-mini-tab', nav);
const miniAcc = $('.tb-mini-acc', nav);
let current = 'now';
let current = 'today';
let minimized = false;
let accSituation = null;
@@ -74,9 +74,9 @@ export function createTabBar(nav, { onSelect, onSearch, onAccessory }) {
function accessoryHtml(s) {
const m = s.metrics?.[0];
return `<span class="acc-glyph" data-family="${esc(s.family)}">${icon(FAMILY_ICON[s.family] || 'now')}</span>
return `<span class="acc-glyph" data-family="${esc(s.family)}">${icon(s.icon || FAMILY_ICON[s.family] || 'now')}</span>
<span class="acc-text"><span class="acc-title">${esc(s.title)}</span>
<span class="acc-meta">${esc(s.place || '')} · ${esc(ago(s.updatedAt))}</span></span>
<span class="acc-meta">${esc(s.status || `${s.place || ''} · ${ago(s.updatedAt)}`)}</span></span>
${m ? `<span class="acc-metric num" data-family="${esc(s.family)}">${esc(fmtNum(m.value))}${m.unit ? ` <small>${esc(m.unit)}</small>` : ''}</span>` : ''}`;
}
+546 -183
View File
@@ -1,50 +1,123 @@
/**
* Time ribbon: −72 h ← NOW → +120 h with momentum drag, haptic hour detents,
* event pips and a LIVE pill that snaps back. Fast drags move in hours,
* slow drags in minutes. Calls `onTime(Date | null)` (null = live).
* The time machine (DESIGN.md §12.5).
*
* A fixed playhead over a moving track: recorded range shading (history()),
* forecast range, event pips coloured by altitude band with detents, momentum
* scrubbing, a NOW snap, play/pause at 1×/10×/60×/600×, a big readable time
* readout ("−42 MIN · 14:02:11Z"), and range select → "Clip this Moment".
* Works with touch, mouse drag, trackpad swipe (wheel) and pinch (ctrl+wheel).
*
* The public surface of the round-two ribbon is kept: setPips, setWindow,
* scrubTo, seek, goLive, time, isLive. Calls `onTime(Date | null)`.
*/
import { h, clamp, haptic, reducedMotion } from './dom.js';
import { h, clamp, haptic, reducedMotion, esc } from './dom.js';
import { icon } from './icons.js';
import { BAND_COLOR, utc } from './bands.js';
const MIN = 60e3;
const HOUR = 3600e3;
const SPEEDS = [1, 10, 60, 600];
const STEPS = [1, 5, 10, 15, 30, 60, 180, 360, 720, 1440].map((m) => m * MIN);
export function createTimeRibbon(parent, { getWindow, onTime }) {
/** "−42 MIN", "−3 H 12 MIN", "+2 H" */
export function fmtDelta(ms) {
const sign = ms < 0 ? '−' : '+';
if (Math.abs(ms) < MIN) return `${sign}${Math.round(Math.abs(ms) / 1000)} S`;
const a = Math.round(Math.abs(ms) / MIN);
if (a < 60) return `${sign}${a} MIN`;
const hrs = Math.floor(a / 60);
const m = a % 60;
if (hrs >= 48) return `${sign}${Math.round(hrs / 24)} D`;
return `${sign}${hrs} H${m ? ` ${m} MIN` : ''}`;
}
export function createTimeRibbon(
parent,
{
getWindow,
onTime,
playback = null,
getHistory = null,
onClip = null,
onPip = null,
},
) {
const el =
h(`<div class="ribbon glass-strong liquid" role="group" aria-label="Time">
<button class="rb-live is-live" type="button" aria-label="Return to live"><span class="live-dot"></span><span>LIVE</span></button>
<div class="rb-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
h(`<div class="ribbon tm glass-strong liquid is-live" role="group" aria-label="Time machine">
<div class="tm-row">
<button class="tm-play" type="button" aria-label="Play">${icon('play')}</button>
<div class="tm-read" aria-live="off">
<b class="tm-delta num">LIVE</b><span class="tm-abs num"></span>
</div>
<div class="tm-speeds" role="radiogroup" aria-label="Playback speed">
${SPEEDS.map((s) => `<button type="button" role="radio" data-speed="${s}" aria-checked="${s === 60}">${s}×</button>`).join('')}
</div>
<button class="tm-clip icon-btn" type="button" aria-pressed="false" aria-label="Select a range to clip">${icon('scissors')}</button>
<button class="rb-live tm-now is-live" type="button" aria-label="Return to now"><span class="live-dot"></span><span>NOW</span></button>
</div>
<div class="rb-track tm-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
<canvas></canvas>
<div class="rb-head" aria-hidden="true"></div>
<div class="rb-head tm-head" aria-hidden="true"><i></i></div>
<div class="tm-callout tele" hidden></div>
</div>
<div class="tm-cliprow" hidden>
<span class="tm-cliplen tele"></span>
<button class="pill-btn plain tm-clipcancel" type="button">Cancel</button>
<button class="pill-btn primary tm-clipgo" type="button">${icon('film')}Clip this Moment</button>
</div>
<div class="rb-read" aria-live="off"><b class="num">Now</b><span class="num"></span></div>
</div>`);
parent.appendChild(el);
const track = el.querySelector('.rb-track');
const track = el.querySelector('.tm-track');
const canvas = el.querySelector('canvas');
const ctx = canvas.getContext('2d');
const liveBtn = el.querySelector('.rb-live');
const readB = el.querySelector('.rb-read b');
const readS = el.querySelector('.rb-read span');
const playBtn = el.querySelector('.tm-play');
const nowBtn = el.querySelector('.tm-now');
const clipBtn = el.querySelector('.tm-clip');
const readD = el.querySelector('.tm-delta');
const readA = el.querySelector('.tm-abs');
const callout = el.querySelector('.tm-callout');
const clipRow = el.querySelector('.tm-cliprow');
const clipLen = el.querySelector('.tm-cliplen');
let win = getWindow();
let custom = null; // { start, end, label, pips, format }
let rec = null; // { start, end }
let custom = null;
let pips = [];
let t = Date.now(); // cursor time (ms)
let t = Date.now();
let live = true;
let pxPerHour = 7;
let pxPerHour = 80;
let W = 0;
let Hh = 0;
let dpr = 1;
let raf = 0;
let vel = 0; // ms per frame
let lastHour = Math.floor(t / HOUR);
let lastTick = 0;
let vel = 0;
let emitTimer = 0;
let speed = 60;
let playing = false;
let clipMode = false;
let range = null; // { a, b }
let hotPip = null;
let calloutTimer = 0;
const span = () => (custom ? custom.end - custom.start : win.end - win.start);
const start = () => (custom ? custom.start : win.start.getTime());
const start = () =>
custom
? custom.start
: Math.min(win.start.getTime(), rec?.start ?? Infinity);
const end = () => (custom ? custom.end : win.end.getTime());
const nowMs = () => (custom ? (custom.now ?? custom.start) : Date.now());
const x = (ms) => W / 2 + ((ms - t) / HOUR) * pxPerHour;
const tAt = (px) => t + ((px - W / 2) / pxPerHour) * HOUR;
function refreshHistory() {
try {
const hst = getHistory?.();
if (hst && Number.isFinite(+hst.start))
rec = { start: +hst.start, end: +hst.end || Date.now() };
} catch {
rec = null;
}
}
function resize() {
const r = track.getBoundingClientRect();
@@ -53,107 +126,149 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
Hh = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(Hh * dpr);
// Custom windows (minutes long) scale to fit ~1.6 track widths.
pxPerHour = custom ? (W * 1.6) / (span() / HOUR) : 7;
if (custom)
pxPerHour = (W * 0.9) / (Math.max(MIN, custom.end - custom.start) / HOUR);
else if (!resize.done) {
// Six recorded hours span about 1.3 track widths.
pxPerHour = clamp(W / 4.6, 40, 240);
resize.done = true;
}
draw();
}
function x(ms) {
return W / 2 + ((ms - t) / HOUR) * pxPerHour;
function stepFor() {
for (const s of STEPS) if ((s / HOUR) * pxPerHour >= 11) return s;
return STEPS[STEPS.length - 1];
}
function draw() {
if (!W) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, Hh);
const s = start();
const e = end();
const n = nowMs();
const mid = Hh / 2 + 4;
// Window body
ctx.fillStyle = 'rgba(255,255,255,0.04)';
const xs = Math.max(0, x(s));
const xe = Math.min(W, x(e));
ctx.fillRect(xs, mid - 9, xe - xs, 18);
// Forecast region
const base = Math.round(Hh * 0.6);
const xs = clamp(x(s), -2, W + 2);
const xe = clamp(x(e), -2, W + 2);
const xn = clamp(x(n), -2, W + 2);
// Body of the window
ctx.fillStyle = 'rgba(255,255,255,0.035)';
ctx.fillRect(xs, base - 12, xe - xs, 24);
if (!custom) {
const xn = x(n);
// Recorded range: where the app remembers every contact.
if (rec) {
const a = clamp(x(rec.start), -2, W + 2);
const g = ctx.createLinearGradient(0, base - 12, 0, base + 12);
g.addColorStop(0, 'rgba(163,216,255,0.22)');
g.addColorStop(1, 'rgba(163,216,255,0.06)');
ctx.fillStyle = g;
ctx.fillRect(a, base - 12, xn - a, 24);
ctx.fillStyle = 'rgba(163,216,255,0.85)';
ctx.fillRect(a, base - 12, xn - a, 1.2);
if (a > 4 && a < W - 60) {
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
ctx.fillStyle = 'rgba(163,216,255,0.8)';
ctx.textAlign = 'left';
ctx.fillText('RECORDED', a + 4, base + 9);
}
}
// Forecast
if (xn < W) {
const g = ctx.createLinearGradient(xn, 0, Math.min(W, xe), 0);
g.addColorStop(0, 'rgba(138,180,248,0.16)');
g.addColorStop(1, 'rgba(138,180,248,0.03)');
g.addColorStop(0, 'rgba(179,157,255,0.2)');
g.addColorStop(1, 'rgba(179,157,255,0.02)');
ctx.fillStyle = g;
ctx.fillRect(
Math.max(0, xn),
mid - 9,
Math.min(W, xe) - Math.max(0, xn),
18,
);
ctx.fillRect(xn, base - 12, Math.min(W, xe) - xn, 24);
if (xn > -40 && xn < W - 70) {
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
ctx.fillStyle = 'rgba(199,180,255,0.75)';
ctx.textAlign = 'left';
ctx.fillText('FORECAST', xn + 6, base + 9);
}
}
}
// Ticks
const stepMin = custom ? (span() / HOUR < 3 ? 5 : 15) : 60;
const step = stepMin * 60e3;
const first = Math.ceil((t - (W / 2 / pxPerHour) * HOUR) / step) * step;
const last = t + (W / 2 / pxPerHour) * HOUR;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
for (
let m = Math.max(first, s - (s % step));
m <= Math.min(last, e);
m += step
) {
const px = x(m);
const d = new Date(m);
const major = custom
? (m - s) % (stepMin * 4 * 60e3) === 0
: d.getHours() === 0;
const mid6 = !custom && d.getHours() % 6 === 0;
const future = !custom && m > n;
const len = major ? 14 : mid6 ? 9 : 5;
ctx.strokeStyle = future
? 'rgba(170,200,255,0.42)'
: major
? 'rgba(255,255,255,0.75)'
: 'rgba(255,255,255,0.32)';
ctx.lineWidth = major ? 1.3 : 1;
if (future) ctx.setLineDash([1.5, 2]);
ctx.beginPath();
ctx.moveTo(px, mid - len / 2);
ctx.lineTo(px, mid + len / 2);
ctx.stroke();
ctx.setLineDash([]);
if (major || (mid6 && pxPerHour > 4)) {
ctx.font = `${major ? 650 : 500} 9.5px ui-rounded, system-ui, sans-serif`;
ctx.fillStyle = major
? 'rgba(255,255,255,0.8)'
: 'rgba(255,255,255,0.38)';
const label = custom
? custom.format(m)
: major
? d
.toLocaleDateString(undefined, { weekday: 'short' })
.toUpperCase()
: `${String(d.getHours()).padStart(2, '0')}`;
ctx.fillText(label, px, mid - 12);
}
}
// NOW marker
if (!custom) {
const xn = x(n);
if (xn > -10 && xn < W + 10) {
ctx.fillStyle = '#FFFFFF';
// Clip range
if (range) {
const a = x(Math.min(range.a, range.b));
const b = x(Math.max(range.a, range.b));
ctx.fillStyle = 'rgba(235,199,119,0.24)';
ctx.fillRect(a, 2, b - a, Hh - 4);
ctx.fillStyle = '#ebc777';
for (const px of [a, b]) {
ctx.beginPath();
ctx.arc(xn, mid + 13, 2.4, 0, Math.PI * 2);
ctx.roundRect(px - 2.5, 3, 5, Hh - 6, 2.5);
ctx.fill();
}
}
// Event pips
// Ticks
const step = stepFor();
const major =
step *
(step < HOUR ? (step <= 5 * MIN ? 6 : 4) : step < 24 * HOUR ? 4 : 1);
const t0 = Math.ceil(tAt(-10) / step) * step;
const t1 = tAt(W + 10);
ctx.textAlign = 'center';
for (let m = t0; m <= t1; m += step) {
if (m < s - step || m > e + step) continue;
const px = x(m);
const isMajor = m % major === 0;
const day = m % (24 * HOUR) === 0;
const future = !custom && m > n;
const len = day ? 16 : isMajor ? 11 : 5;
ctx.strokeStyle = future
? 'rgba(199,180,255,0.45)'
: isMajor
? 'rgba(255,255,255,0.7)'
: 'rgba(255,255,255,0.28)';
ctx.lineWidth = isMajor ? 1.2 : 1;
if (future) ctx.setLineDash([1.5, 2]);
ctx.beginPath();
ctx.moveTo(px, base - len / 2);
ctx.lineTo(px, base + len / 2);
ctx.stroke();
ctx.setLineDash([]);
if (isMajor) {
const d = new Date(m);
ctx.font = `${day ? 700 : 550} 9.5px "JetBrains Mono", ui-monospace, monospace`;
ctx.fillStyle = day
? 'rgba(255,255,255,0.9)'
: 'rgba(255,255,255,0.55)';
const label = custom
? custom.format(m)
: day
? d
.toLocaleDateString('en-GB', {
weekday: 'short',
timeZone: 'UTC',
})
.toUpperCase()
: `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
ctx.fillText(label, px, base - 15);
}
}
// NOW marker
if (!custom && x(n) > -10 && x(n) < W + 10) {
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(x(n), base + 15, 2.6, 0, Math.PI * 2);
ctx.fill();
}
// Event pips (by altitude band, sized by severity)
for (const p of pips) {
const px = x(p.at);
if (px < -6 || px > W + 6) continue;
if (px < -8 || px > W + 8) continue;
const r = 2.8 + (p.severity || 0) * 0.9;
if (p === hotPip) {
ctx.fillStyle = `${p.color || '#fff'}44`;
ctx.beginPath();
ctx.arc(px, base, r + 6, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = p.color || '#FFD166';
ctx.beginPath();
ctx.arc(px, mid, 3.4, 0, Math.PI * 2);
ctx.arc(px, base, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(5,7,11,0.9)';
ctx.lineWidth = 1.2;
@@ -170,61 +285,39 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
ctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), Hh);
ctx.restore();
};
fade(0, 26);
fade(W, W - 26);
fade(0, 22);
fade(W, W - 22);
updateReadout();
}
function fmtRel(ms) {
const d = ms / 60e3;
const sign = d < 0 ? '−' : '+';
const a = Math.abs(d);
if (a < 60) return `${sign}${Math.round(a)} min`;
const hrs = Math.floor(a / 60);
const mins = Math.round(a % 60);
return `${sign}${hrs} h${mins ? ` ${mins}` : ''}`;
}
function updateReadout() {
if (custom) {
readB.textContent = custom.format(t);
readS.textContent = custom.label || '';
readD.textContent = custom.format(t);
readA.textContent = custom.label || '';
track.setAttribute(
'aria-valuetext',
`${custom.format(t)} ${custom.label || ''}`,
);
return;
}
if (live) {
readB.textContent = 'Now';
readS.textContent = new Date().toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
} else {
readB.textContent = fmtRel(t - Date.now());
readS.textContent = new Date(t).toLocaleString(undefined, {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
const abs = utc(new Date(live ? Date.now() : t));
readD.textContent = live ? 'LIVE' : fmtDelta(t - Date.now());
readA.textContent = abs;
el.dataset.future = String(!live && t > Date.now());
track.setAttribute(
'aria-valuetext',
live ? 'Live' : `${readB.textContent}, ${readS.textContent}`,
live ? `Live, ${abs}` : `${readD.textContent}, ${abs}`,
);
}
function setLive(v) {
live = v;
el.classList.toggle('is-live', v);
liveBtn.classList.toggle('is-live', v);
liveBtn.querySelector('span:last-child').textContent = v
? 'LIVE'
: custom
? 'RESET'
: 'LIVE';
liveBtn.setAttribute('aria-pressed', String(v));
nowBtn.classList.toggle('is-live', v);
nowBtn.querySelector('span:last-child').textContent = custom
? 'START'
: 'NOW';
nowBtn.setAttribute('aria-pressed', String(v));
}
function emit(immediate = false) {
@@ -235,84 +328,208 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
if (immediate) {
clearTimeout(emitTimer);
fire();
} else if (!emitTimer) emitTimer = setTimeout(fire, 90);
} else if (!emitTimer) emitTimer = setTimeout(fire, 60);
}
function setCursor(ms, { fromUser = false, quant = 0 } = {}) {
let lastDetent = 0;
function setCursor(ms, { fromUser = false } = {}) {
const s = start();
const e = end();
let v = ms;
if (v < s) v = s - (1 - 1 / (((s - v) / HOUR) * 0.2 + 1)) * HOUR * 2;
if (v > e) v = e + (1 - 1 / (((v - e) / HOUR) * 0.2 + 1)) * HOUR * 2;
if (quant) v = Math.round(v / quant) * quant;
if (v < s) v = s - (1 - 1 / (((s - v) / HOUR) * 0.4 + 1)) * HOUR * 0.6;
if (v > e) v = e + (1 - 1 / (((v - e) / HOUR) * 0.4 + 1)) * HOUR * 0.6;
t = v;
const hr = Math.floor(t / HOUR);
if (fromUser && hr !== lastHour) {
const now = performance.now();
if (now - lastTick > 55) {
haptic('tick');
lastTick = now;
}
lastHour = hr;
}
if (fromUser) {
const near = !custom && Math.abs(t - Date.now()) < 4 * 60e3;
setLive(near);
// Detents: a tick as the playhead crosses a pip.
const near = pips.find((p) => Math.abs(x(p.at) - W / 2) < 3);
if (near && near !== hotPip) {
const pn = performance.now();
if (pn - lastDetent > 70) haptic('tick');
lastDetent = pn;
}
hotPip = near || null;
const isNear =
!custom && Math.abs(t - Date.now()) < (6 / pxPerHour) * HOUR;
setLive(isNear);
if (playing) stopPlay();
emit();
}
draw();
}
/* ---------- drag with momentum ---------- */
/* ---------- gestures ---------- */
let drag = null;
let press = 0;
track.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
track.setPointerCapture(e.pointerId);
cancelAnimationFrame(raf);
const r = track.getBoundingClientRect();
const px = e.clientX - r.left;
drag = {
x: e.clientX,
px,
t0: t,
moved: false,
samples: [{ x: e.clientX, ts: performance.now() }],
handle: null,
};
if (range) {
const a = x(range.a);
const b = x(range.b);
if (Math.abs(px - a) < 16) drag.handle = 'a';
else if (Math.abs(px - b) < 16) drag.handle = 'b';
}
if (clipMode && !drag.handle) {
range = { a: tAt(px), b: tAt(px) };
drag.handle = 'b';
}
clearTimeout(press);
if (!clipMode && !drag.handle)
press = setTimeout(() => {
if (!drag || drag.moved) return;
setClipMode(true);
haptic('medium');
range = { a: tAt(drag.px), b: tAt(drag.px) };
drag.handle = 'b';
draw();
}, 420);
el.classList.add('scrubbing');
});
track.addEventListener('pointermove', (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
if (Math.abs(dx) > 3) drag.moved = true;
if (drag.handle) {
const r = track.getBoundingClientRect();
range[drag.handle] = tAt(e.clientX - r.left);
showClipRow();
draw();
return;
}
drag.samples.push({ x: e.clientX, ts: performance.now() });
if (drag.samples.length > 6) drag.samples.shift();
const a = drag.samples[0];
const b = drag.samples[drag.samples.length - 1];
const speed = b.ts > a.ts ? Math.abs((b.x - a.x) / (b.ts - a.ts)) : 0; // px/ms
const quant = custom ? 60e3 : speed > 0.6 ? HOUR : 5 * 60e3;
setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true, quant });
setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true });
});
const release = () => {
const release = (e) => {
clearTimeout(press);
if (!drag) return;
el.classList.remove('scrubbing');
if (drag.handle) {
drag = null;
if (range && Math.abs(range.b - range.a) < MIN) {
range = null;
showClipRow();
}
draw();
return;
}
if (!drag.moved) {
// A tap: on a pip jumps to it; elsewhere centres that instant.
const r = track.getBoundingClientRect();
const px = (e?.clientX ?? drag.x) - r.left;
drag = null;
const pip = pipAt(px);
if (pip) return jumpToPip(pip);
animateTo(tAt(px), () => settleLive());
return;
}
const a = drag.samples[0];
const b = drag.samples[drag.samples.length - 1];
const pxPerMs = b.ts > a.ts ? (b.x - a.x) / (b.ts - a.ts) : 0;
drag = null;
vel = (-(pxPerMs * 16.7) / pxPerHour) * HOUR;
const fast = Math.abs(pxPerMs) > 0.6;
if (reducedMotion() || Math.abs(pxPerMs) < 0.05) return settle(fast);
if (reducedMotion() || Math.abs(pxPerMs) < 0.08) return settle();
const stepFn = () => {
vel *= 0.93;
vel *= 0.94;
setCursor(t + vel, { fromUser: true });
if (Math.abs(vel) > 60e3 && t > start() && t < end())
if (Math.abs(vel) > (0.4 / pxPerHour) * HOUR && t > start() && t < end())
raf = requestAnimationFrame(stepFn);
else settle(fast);
else settle();
};
raf = requestAnimationFrame(stepFn);
};
track.addEventListener('pointerup', release);
track.addEventListener('pointercancel', release);
function settle(fast) {
const s = start();
const e = end();
const target = clamp(t, s, e);
const q = custom ? 60e3 : fast ? HOUR : 5 * 60e3;
// Trackpad: two-finger swipe scrubs, pinch (ctrl+wheel) zooms the scale.
track.addEventListener(
'wheel',
(e) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
pxPerHour = clamp(pxPerHour * Math.exp(-e.deltaY * 0.01), 6, 3000);
draw();
return;
}
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
setCursor(t + (d / pxPerHour) * HOUR, { fromUser: true });
clearTimeout(track._wt);
track._wt = setTimeout(settle, 160);
},
{ passive: false },
);
function pipAt(px) {
let best = null;
let bd = 12;
for (const p of pips) {
const d = Math.abs(x(p.at) - px);
if (d < bd) {
bd = d;
best = p;
}
}
return best;
}
function showCallout(p) {
if (!p?.label) return;
callout.textContent = p.label;
callout.style.setProperty('--c', p.color || '#fff');
callout.hidden = false;
clearTimeout(calloutTimer);
calloutTimer = setTimeout(() => (callout.hidden = true), 2600);
}
function jumpToPip(p) {
haptic('light');
hotPip = p;
showCallout(p);
setLive(false);
animateTo(p.at, () => {
emit(true);
onPip?.(p);
});
}
function settleLive() {
const isNear = !custom && Math.abs(t - Date.now()) < (6 / pxPerHour) * HOUR;
if (isNear) {
setLive(true);
animateTo(Date.now(), () => emit(true));
} else emit(true);
}
function settle() {
const target = clamp(t, start(), end());
// Detent: settle onto a pip within 10 px of the playhead.
const near = pips
.map((p) => ({ p, d: Math.abs(x(p.at) - x(target)) }))
.filter((o) => o.d < 10)
.sort((a, b) => a.d - b.d)[0];
if (near) {
haptic('light');
hotPip = near.p;
showCallout(near.p);
return animateTo(near.p.at, () => emit(true));
}
if (!custom && Math.abs(target - Date.now()) < (8 / pxPerHour) * HOUR) {
setLive(true);
haptic('tick');
return animateTo(Date.now(), () => emit(true));
}
const q = custom ? 1000 : stepFor() >= HOUR ? 5 * MIN : MIN / 4;
animateTo(Math.round(target / q) * q, () => emit(true));
}
@@ -320,7 +537,7 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
cancelAnimationFrame(raf);
const from = t;
const t0 = performance.now();
const dur = reducedMotion() ? 1 : 420;
const dur = reducedMotion() ? 1 : 460;
const step = () => {
const k = clamp((performance.now() - t0) / dur, 0, 1);
const e = 1 - (1 - k) ** 3;
@@ -334,6 +551,7 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
function goLive() {
haptic('tick');
stopPlay();
if (custom) {
animateTo(custom.start, () => emit(true));
return;
@@ -341,38 +559,153 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
setLive(true);
animateTo(Date.now(), () => emit(true));
}
liveBtn.addEventListener('click', goLive);
nowBtn.addEventListener('click', goLive);
/* ---------- playback ---------- */
function setPlaying(v) {
playing = v;
el.classList.toggle('is-playing', v);
playBtn.innerHTML = icon(v ? 'pause' : 'play');
playBtn.setAttribute('aria-label', v ? 'Pause' : 'Play');
}
function stopPlay() {
if (!playing) return;
playback?.stop();
setPlaying(false);
}
function play(s = speed) {
if (custom || !playback) return;
speed = s;
markSpeed();
if (live) {
// From live, replay the last hour of the recording.
const from = Math.max(rec?.start ?? -Infinity, Date.now() - HOUR);
t = from;
setLive(false);
onTime(new Date(t));
}
setPlaying(true);
playback.play({ speed });
haptic('light');
}
playBtn.addEventListener('click', () => (playing ? stopPlay() : play()));
function markSpeed() {
el.querySelectorAll('[data-speed]').forEach((b) =>
b.setAttribute('aria-checked', String(Number(b.dataset.speed) === speed)),
);
}
el.querySelector('.tm-speeds').addEventListener('click', (e) => {
const b = e.target.closest('[data-speed]');
if (!b) return;
haptic('tick');
speed = Number(b.dataset.speed);
markSpeed();
if (playing) playback?.play({ speed });
});
/** The engine reports its clock while playing. */
function onPlayback(st) {
if (!st) return;
if (st.playing !== playing) setPlaying(!!st.playing);
if (Number.isFinite(st.t) && st.playing) {
t = st.t;
setLive(false);
draw();
} else if (
!st.playing &&
(st.t == null || Math.abs(st.t - Date.now()) < 3000)
) {
setLive(true);
t = Date.now();
draw();
}
}
/* ---------- clip ---------- */
function setClipMode(v) {
clipMode = v;
clipBtn.setAttribute('aria-pressed', String(v));
el.classList.toggle('is-clipping', v);
if (!v) range = null;
showClipRow();
draw();
}
function showClipRow() {
const ok = !!range && Math.abs(range.b - range.a) >= MIN;
clipRow.hidden = !clipMode;
el.querySelector('.tm-clipgo').disabled = !ok;
clipLen.innerHTML = ok
? `${esc(utc(new Date(Math.min(range.a, range.b)), { seconds: false }).slice(0, 5))}–${esc(utc(new Date(Math.max(range.a, range.b)), { seconds: false }))}<br/><b>${esc(fmtDelta(Math.abs(range.b - range.a)).slice(1))}</b>`
: 'Drag across the track to select a range';
}
clipBtn.addEventListener('click', () => {
haptic('tick');
setClipMode(!clipMode);
});
el.querySelector('.tm-clipcancel').addEventListener('click', () =>
setClipMode(false),
);
el.querySelector('.tm-clipgo').addEventListener('click', () => {
if (!range) return;
const r = {
start: Math.min(range.a, range.b),
end: Math.max(range.a, range.b),
};
haptic('success');
setClipMode(false);
onClip?.(r);
});
/* ---------- keyboard ---------- */
track.addEventListener('keydown', (e) => {
const stepMs = custom ? 60e3 : e.shiftKey ? 6 * HOUR : HOUR;
const stepMs = custom ? 10e3 : e.shiftKey ? HOUR : 5 * MIN;
if (e.key === 'ArrowRight') setCursor(t + stepMs, { fromUser: true });
else if (e.key === 'ArrowLeft') setCursor(t - stepMs, { fromUser: true });
else if (e.key === 'Home' || e.key === 'End') goLive();
else if (e.key === 'End') goLive();
else if (e.key === ' ' || e.key === 'k') playing ? stopPlay() : play();
else return;
e.preventDefault();
});
// Live: keep the cursor glued to now while the planet turns.
// Live: keep the playhead glued to now while the planet turns.
setInterval(() => {
if (live && !custom && !drag) {
t = Date.now();
win = getWindow();
if (!custom && !drag) {
refreshHistory();
if (live) {
t = Date.now();
win = getWindow();
}
draw();
}
}, 15000);
}, 1000);
new ResizeObserver(resize).observe(track);
refreshHistory();
resize();
setLive(true);
return {
el,
/** [{ at, label, color, severity?, ev? }] */
setPips(list) {
pips = list.filter((p) => Number.isFinite(p.at));
draw();
},
/** Timeline events from globe.timeline(), coloured by band. */
setEvents(evts) {
this.setPips(
evts.map((ev) => ({
at: ev.t,
label: ev.title,
color: BAND_COLOR[ev.band] || '#fff',
severity: ev.severity || 0,
ev,
})),
);
},
/** Use a custom window (e.g. a reconstruction's schematic clock). */
setWindow(w) {
stopPlay();
custom = w
? {
...w,
@@ -388,24 +721,40 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
} else {
win = getWindow();
t = Date.now();
resize.done = false;
setLive(true);
}
resize();
},
/** Animate to a moment and tell the globe (post anchors). */
scrubTo(ms) {
const lo = start();
const hi = end();
const target = Math.min(hi, Math.max(lo, ms));
stopPlay();
setLive(false);
animateTo(target, () => emit());
animateTo(clamp(ms, start(), end()), () => emit(true));
},
/** Programmatic scrub (narrative playback). */
seek(ms) {
cancelAnimationFrame(raf);
t = ms;
draw();
},
/** Deterministic setup (director): jump, then play at a speed. */
jump(ms, { play: p = false, speed: s = speed } = {}) {
stopPlay();
cancelAnimationFrame(raf);
t = ms;
setLive(false);
onTime(new Date(t));
draw();
if (p) play(s);
},
selectRange(a, b) {
setClipMode(true);
range = { a, b };
showClipRow();
draw();
},
play,
stop: stopPlay,
onPlayback,
goLive,
get time() {
return live && !custom ? null : new Date(t);
@@ -413,8 +762,22 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
get isLive() {
return live && !custom;
},
get playing() {
return playing;
},
get debug() {
return { t, pxPerHour, W, Hh, live, custom: !!custom };
return {
t,
pxPerHour,
W,
Hh,
live,
custom: !!custom,
playing,
speed,
range,
rec,
};
},
};
}
+485
View File
@@ -0,0 +1,485 @@
/**
* Today (DESIGN.md §12.3): the daily brief above the feed.
*
* - Since you last looked: the three biggest changes (timeline() and a
* situations diff against a stored last-visit time).
* - Today's Moment: one replay a day, playing inline on its own canvas.
* - Overhead tonight: the next ISS pass and the next launch (real data).
* - Your sky now: a mini sky dome; opens the full sheet.
* - Your watch: followed places and things, and whether anything is unusual.
*/
import { icon } from './icons.js';
import { $, esc, ago, haptic, esriThumb, reducedMotion, store } from './dom.js';
import { bandOf, utc, cardinal } from './bands.js';
import { trackAt } from './stubMoments.js';
import { domeSvg } from './skySheet.js';
const LAST_KEY = 'gev.lastVisit';
const SEEN_KEY = 'gev.seenIds';
function readLast() {
try {
const v = Number(localStorage.getItem(LAST_KEY));
if (Number.isFinite(v) && v > 0) return v;
} catch {
/* ignore */
}
return Date.now() - 8 * 3600e3;
}
function readSeen() {
try {
return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]'));
} catch {
return new Set();
}
}
export function createToday(
slot,
{
getSituations,
watch,
getHome,
onOpenSituation,
onOpenEntity,
onOpenMoment,
onSky,
onWatch,
},
) {
const lastVisit = readLast();
const seen = readSeen();
let globe = null;
let sits = [];
let moment = null;
let sky = null;
let raf = 0;
let visible = true;
slot.innerHTML = `
<section class="brief" aria-label="Your brief">
<div class="bf-since glass-card" aria-label="Since you last looked">
<header class="bf-h"><h3>Since you last looked</h3><span class="tele" data-since></span></header>
<div class="bf-rows" data-changes><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div></div>
</div>
<button class="bf-moment" type="button" aria-label="Play today's Moment">
<img class="bf-mimg" alt="" decoding="async"/>
<canvas class="bf-mcanvas" aria-hidden="true"></canvas>
<span class="bf-mshade"></span>
<span class="bf-mtop tele"><span class="bf-mk">TODAY’S MOMENT</span><span class="bf-mtag"></span></span>
<span class="bf-mbody"><b class="bf-mtitle">Loading today’s Moment…</b><small class="bf-msub"></small></span>
<span class="bf-mplay">${icon('play')}</span>
<span class="bf-mclock tele"></span>
</button>
<button class="bf-sky glass-card" type="button" aria-label="Your sky now">
<span class="bf-dome" data-dome></span>
<span class="bf-skytxt"><span class="bf-skyk tele"><span class="live-dot"></span>YOUR SKY NOW</span><b class="bf-skyh" data-skyh>Looking up…</b><small data-skyn></small></span>
${icon('chevronRight', { cls: 'chev' })}
</button>
<div class="bf-tonight glass-card"><header class="bf-h"><h3>Overhead tonight</h3><span class="tele" data-tonight-where></span></header><div data-tonight><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div></div></div>
<div class="bf-watch glass-card"><header class="bf-h"><h3>Your watch</h3><button class="link-btn" type="button" data-manage>Manage</button></header><div data-watch></div></div>
</section>`;
const mBtn = $('.bf-moment', slot);
const mCanvas = $('.bf-mcanvas', slot);
const mctx = mCanvas.getContext('2d');
/* ---------- since you last looked ---------- */
function changes() {
const now = Date.now();
const out = [];
const used = new Set();
let evts = [];
try {
evts = globe ? globe.timeline({ from: lastVisit, to: now }) : [];
} catch {
evts = [];
}
const ranked = [...evts].sort(
(a, b) => (b.severity || 0) - (a.severity || 0) || b.t - a.t,
);
for (const e of ranked) {
const key = e.momentId || e.situationId || e.entityId || e.title;
if (used.has(key)) continue;
used.add(key);
const s = e.situationId
? sits.find((x) => String(x.id) === String(e.situationId))
: null;
out.push({
t: e.t,
title: s?.title || e.title,
sub: s?.place || (e.momentId ? 'Moment · replay it' : e.kind),
band: e.band || (s ? bandOf(s) : 'ground'),
momentId:
e.kind === 'moment' || e.kind === 'capture' || e.kind === 'circling'
? e.momentId
: null,
situationId: e.situationId,
entityId: e.entityId,
sev: e.severity || 0,
});
if (out.length >= 3) break;
}
// New situations since the last visit fill the rest.
if (out.length < 3)
for (const s of [...sits].sort(
(a, b) => (b.severity || 0) - (a.severity || 0),
)) {
if (out.length >= 3) break;
if (
used.has(s.id) ||
used.has(s.momentId) ||
(seen.size && seen.has(String(s.id)))
)
continue;
used.add(s.id);
out.push({
t: +new Date(s.updatedAt),
title: s.title,
sub: s.place,
band: bandOf(s),
situationId: s.id,
sev: s.severity || 0,
isNew: !seen.has(String(s.id)),
});
}
return out;
}
function renderChanges() {
const list = changes();
$('[data-since]', slot).textContent = `${ago(lastVisit).toUpperCase()}`;
$('[data-changes]', slot).innerHTML =
list
.map(
(c, i) =>
`<button class="bf-row" type="button" data-ch="${i}" data-band="${c.band}"><i class="bd"></i><span class="bf-main"><b>${esc(c.title)}</b><small>${esc(c.sub || '')}</small></span><span class="bf-when tele">${esc(c.t > Date.now() ? 'SOON' : ago(c.t).replace(' ago', '').toUpperCase())}</span></button>`,
)
.join('') ||
`<div class="bf-calm">${icon('checkmark')}<span><b>Nothing unusual</b> since you last looked.</span></div>`;
slot._changes = list;
}
/* ---------- today's moment ---------- */
let geom = null;
function setMoment(m) {
moment = m;
if (!m) return;
const span = 0.02;
const img = $('.bf-mimg', slot);
const r = mBtn.getBoundingClientRect();
const w = Math.max(320, Math.round(r.width || 400));
const hgt = Math.max(200, Math.round(r.height || 260));
img.src = esriThumb(
m.lon,
m.lat,
span,
Math.min(1024, w * 2),
Math.min(1024, Math.round((hgt / w) * Math.min(1024, w * 2))),
);
img.onload = () => img.classList.add('loaded');
const dLon = span / Math.cos((m.lat * Math.PI) / 180);
const dLat = span * (hgt / w);
geom = { lon0: m.lon - dLon / 2, lat1: m.lat + dLat / 2, dLon, dLat };
$('.bf-mtitle', slot).textContent = m.title;
const dur = Math.round((m.time.end - m.time.start) / 60e3);
$('.bf-msub', slot).textContent =
`${m.place} · ${dur} min in 20 s · ${(m.tracks || []).length} helicopters · ${(m.captures || []).length} sealed captures`;
$('.bf-mtag', slot).innerHTML = m.illustrative
? '<span class="tag-ill">Illustrative</span>'
: '<span class="tag-real">Recorded</span>';
mBtn.classList.add('ready');
startLoop();
}
const TRACK_COL = ['#a3d8ff', '#ffd27a', '#ff9db0', '#9be15d'];
function drawMoment(ts) {
raf = 0;
if (!moment || !geom) return;
const r = mCanvas.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
const W = r.width;
const H = r.height;
if (mCanvas.width !== Math.round(W * dpr)) {
mCanvas.width = Math.round(W * dpr);
mCanvas.height = Math.round(H * dpr);
}
const ctx = mctx;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const m = moment;
const loop = 20000;
const k = reducedMotion() ? 0.55 : (ts % loop) / loop;
const t = m.time.start + k * (m.time.end - m.time.start);
const px = (lon, lat) => [
((lon - geom.lon0) / geom.dLon) * W,
((geom.lat1 - lat) / geom.dLat) * H,
];
(m.tracks || []).forEach((tr, i) => {
const head = trackAt(tr, t);
if (!head) return;
const col = TRACK_COL[i % TRACK_COL.length];
const pts = tr.points;
for (let j = Math.max(1, head.i - 24); j <= head.i; j += 1) {
const a = px(pts[j - 1].lon, pts[j - 1].lat);
const b = px(pts[j].lon, pts[j].lat);
const f = 1 - (head.i - j) / 24;
ctx.strokeStyle = col;
ctx.globalAlpha = 0.1 + 0.8 * f * f;
ctx.lineWidth = 1 + 1.8 * f;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
ctx.globalAlpha = 1;
const [x, y] = px(head.lon, head.lat);
ctx.save();
ctx.translate(x, y);
ctx.rotate(((head.headingDeg || 0) * Math.PI) / 180);
ctx.fillStyle = col;
ctx.beginPath();
ctx.moveTo(0, -6);
ctx.lineTo(4, 4.5);
ctx.lineTo(0, 2.4);
ctx.lineTo(-4, 4.5);
ctx.closePath();
ctx.fill();
ctx.restore();
});
for (const c of m.captures || []) {
if (c.t > t) continue;
const [x, y] = px(c.lon, c.lat);
const age = (t - c.t) / 60e3;
const f = Math.max(0, 1 - age / 1.2);
const hd = ((c.headingDeg || 0) - 90) * (Math.PI / 180);
const fov = ((c.fovDeg || 52) * Math.PI) / 360;
const len = 46;
const g = ctx.createRadialGradient(x, y, 1, x, y, len);
g.addColorStop(0, `rgba(235,199,119,${0.45 + f * 0.4})`);
g.addColorStop(1, 'rgba(235,199,119,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.arc(x, y, len, hd - fov, hd + fov);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ebc777';
ctx.beginPath();
ctx.arc(x, y, 3 + f * 3, 0, Math.PI * 2);
ctx.fill();
}
const s = Math.round((t - m.time.start) / 1000);
const day =
new Date(m.time.start).toDateString() === new Date().toDateString()
? 'TODAY'
: new Date(m.time.start)
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
.toUpperCase();
$('.bf-mclock', slot).textContent =
`${day} · ${utc(t)} · +${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
if (!reducedMotion()) startLoop();
}
function startLoop() {
if (!raf && visible && !document.hidden)
raf = requestAnimationFrame(drawMoment);
}
new IntersectionObserver((es) => {
visible = es.some((e) => e.isIntersecting);
if (visible) startLoop();
}).observe(mBtn);
document.addEventListener('visibilitychange', startLoop);
/* ---------- overhead tonight ---------- */
const hm = (t) =>
new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
function tMinus(t) {
const d = t - Date.now();
if (d < 0) return 'NOW';
const hrs = Math.floor(d / 3600e3);
const mins = Math.round((d % 3600e3) / 60e3);
return `T−${hrs ? `${hrs} H ` : ''}${mins} MIN`;
}
function renderTonight() {
const rows = [];
const np = sky?.iss?.nextPass;
if (sky?.iss?.visibleNow)
rows.push(
`<button class="bf-row" type="button" data-sky data-band="orbit"><i class="bd"></i><span class="bf-main"><b>ISS is overhead now</b><small>${Math.round(sky.iss.elevationDeg)}° up, ${esc(cardinal(sky.iss.azimuthDeg))}</small></span></button>`,
);
else if (np)
rows.push(
`<button class="bf-row" type="button" data-sky data-band="orbit"><i class="bd"></i><span class="bf-main"><b>International Space Station</b><small>${np.maxElevationDeg != null ? `Rises to ${np.maxElevationDeg}° · ` : ''}visible ${Math.max(1, Math.round(((np.end || np.start + 360e3) - np.start) / 60e3))} min</small></span><span class="bf-when tele">${esc(hm(np.start).toUpperCase())}</span></button>`,
);
const launches = sits
.filter((s) => /launch/.test(s.kind || ''))
.map((s) => {
const ev = (s.events || []).find((e) => +new Date(e.at) > Date.now());
return { s, at: ev ? +new Date(ev.at) : +new Date(s.time?.start || 0) };
})
.filter((x) => x.at > Date.now())
.sort((a, b) => a.at - b.at);
if (launches[0]) {
const { s, at } = launches[0];
rows.push(
`<button class="bf-row" type="button" data-sit="${esc(s.id)}" data-band="orbit"><i class="bd"></i><span class="bf-main"><b>${esc(s.title)}</b><small>${esc(s.place || '')}</small></span><span class="bf-when tele">${esc(tMinus(at))}</span></button>`,
);
}
$('[data-tonight]', slot).innerHTML =
rows.join('') ||
'<div class="bf-calm"><span>Clear skies, nothing scheduled.</span></div>';
}
function renderSky() {
if (!sky) return;
$('[data-dome]', slot).innerHTML = domeSvg(sky, {
size: 120,
labels: false,
});
const home = getHome();
const ac = (sky.aircraft || []).length;
const sats = (sky.satellites || []).length;
const top = (sky.aircraft || [])[0];
$('[data-skyh]', slot).textContent = sats
? `${ac} aircraft and ${sats} satellite${sats === 1 ? '' : 's'} above you`
: `${ac} aircraft above you right now`;
$('[data-skyn]', slot).textContent = top
? `Highest: ${top.title}, ${Math.round(top.elevationDeg)}° up ${cardinal(top.azimuthDeg)} · ${home.label || home.name}`
: `A quiet sky over ${home.label || home.name}`;
$('[data-tonight-where]', slot).textContent = (
home.label || home.name
).toUpperCase();
}
/* ---------- your watch ---------- */
async function renderWatch() {
const home = getHome();
const rules = watch.rules().filter((r) => r.on && r.of === 'home');
const hits = rules.flatMap((r) => watch.hits(r.id).map((x) => ({ r, x })));
const rows = [];
rows.push(
hits.length
? `<button class="bf-row" type="button" data-hit="0" data-band="${esc(bandOf(hits[0].x))}"><span class="bf-ic">${icon('home')}</span><span class="bf-main"><b>${esc(home.name)} · ${hits.length} on watch</b><small>${esc(hits[0].x.title)}</small></span><span class="bf-when tele">${esc(hits[0].x.updatedAt ? ago(hits[0].x.updatedAt).replace(' ago', '').toUpperCase() : '')}</span></button>`
: `<div class="bf-row"><span class="bf-ic">${icon('home')}</span><span class="bf-main"><b>${esc(home.name)} · quiet</b><small>Nothing unusual within ${Math.max(...rules.map((r) => r.within || 0), 200).toLocaleString()} km</small></span><span class="bf-ok">${icon('checkmark')}</span></div>`,
);
slot._hits = hits;
const followed = store.get('followed') || [];
const things = (
await Promise.all(
followed
.slice(0, 3)
.map((id) => watch.statusOf(id, sits).catch(() => null)),
)
).filter(Boolean);
for (const t of things)
rows.push(
`<button class="bf-row" type="button" data-thing="${esc(t.id)}" data-band="${t.band}"><span class="bf-ic">${icon(t.icon)}</span><span class="bf-main"><b>${esc(t.title)}</b><small>${esc(t.status)}</small></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
);
if (!things.length)
rows.push(
`<button class="bf-row bf-add" type="button" data-manage>${icon('plus')}<span>Follow a place, a tail number, a ship or the ISS</span></button>`,
);
$('[data-watch]', slot).innerHTML = rows.join('');
}
/* ---------- input ---------- */
slot.addEventListener('click', (e) => {
const ch = e.target.closest('[data-ch]');
if (ch) {
haptic('light');
const c = slot._changes[Number(ch.dataset.ch)];
if (c.momentId) return onOpenMoment(c.momentId);
if (c.situationId) return onOpenSituation(c.situationId);
if (c.entityId) return onOpenEntity(c.entityId);
return;
}
if (e.target.closest('.bf-moment')) {
haptic('light');
if (moment) onOpenMoment(moment.id);
return;
}
if (e.target.closest('.bf-sky, [data-sky]')) {
haptic('light');
return onSky();
}
const sit = e.target.closest('[data-sit]');
if (sit) return onOpenSituation(sit.dataset.sit);
const th = e.target.closest('[data-thing]');
if (th) return onOpenEntity(th.dataset.thing);
const hit = e.target.closest('[data-hit]');
if (hit && slot._hits?.[0]) return onOpenSituation(slot._hits[0].x.id);
if (e.target.closest('[data-manage]')) onWatch();
});
/* ---------- lifecycle ---------- */
function persistVisit() {
try {
localStorage.setItem(LAST_KEY, String(Date.now()));
localStorage.setItem(
SEEN_KEY,
JSON.stringify(sits.map((s) => String(s.id)).slice(0, 200)),
);
} catch {
/* ignore */
}
}
document.addEventListener(
'visibilitychange',
() => document.visibilityState === 'hidden' && persistVisit(),
);
window.addEventListener('pagehide', persistVisit);
async function refreshSky() {
if (!globe) return;
const home = getHome();
sky = await globe
.skyNow({ lon: home.lon, lat: home.lat })
.catch(() => null);
renderSky();
renderTonight();
}
watch.onChange(() => renderWatch());
setInterval(() => {
if (!document.hidden && slot.offsetParent) {
refreshSky();
renderChanges();
}
}, 15000);
return {
setSituations(list) {
sits = list || [];
renderChanges();
renderTonight();
renderWatch();
},
/** Before the globe: the bundled hero paints the card at once. */
setMoment(m) {
if (!moment && m) setMoment(m);
},
async setGlobe(g) {
globe = g;
renderChanges();
const m = await g.momentOfTheDay();
if (
m &&
(!moment || m.id !== moment.id || m.time?.start !== moment.time?.start)
)
setMoment(m);
await refreshSky();
renderWatch();
},
refresh() {
renderChanges();
refreshSky();
renderWatch();
},
get moment() {
return moment;
},
lastVisit,
};
}
+544
View File
@@ -0,0 +1,544 @@
/**
* Watch & alerts (DESIGN.md §12.1, §12.2, §12.3).
*
* Follow places and things; write alert rules as plain chips
* ("Any [squawk 7700] within [200 km] of [Home]"). Rules are evaluated
* against live data while the app is open and surface as calm, iOS-style
* notification banners. "Nothing unusual near you" is a valid state.
*/
import { icon } from './icons.js';
import { h, esc, ago, haptic, toast, store, reducedMotion } from './dom.js';
import { createSheet } from './sheet.js';
import { distKm, fmtKm, utc, BAND_COLOR, bandOf } from './bands.js';
const RULES_KEY = 'gev.watch.v1';
export const WHAT = [
{
id: 'squawk7700',
label: 'squawk 7700',
test: (s) =>
/squawk/.test(s.kind) && /7700/.test(`${s.title} ${s.telemetry}`),
band: 'sky',
},
{
id: 'emergency',
label: 'any emergency squawk',
test: (s) => /squawk/.test(s.kind),
band: 'sky',
},
{
id: 'circling',
label: 'helicopters circling',
test: (s) => /circling/.test(s.kind),
band: 'sky',
},
{
id: 'quake5',
label: 'M5+ quake',
test: (s) => /quake/.test(s.kind) && magOf(s) >= 5,
band: 'ground',
},
{
id: 'quake6',
label: 'M6+ quake',
test: (s) => /quake/.test(s.kind) && magOf(s) >= 6,
band: 'ground',
},
{
id: 'fire',
label: 'wildfire',
test: (s) => /fire/.test(s.kind),
band: 'ground',
},
{
id: 'launch',
label: 'launch',
test: (s) => /launch/.test(s.kind),
band: 'orbit',
},
{ id: 'issPass', label: 'ISS visible pass', test: null, band: 'orbit' },
];
const RADII = [25, 50, 200, 500, 1000, 20000];
function magOf(s) {
const m = /M\s?(\d+(?:\.\d)?)/.exec(`${s.title} ${s.telemetry || ''}`);
if (m) return Number(m[1]);
const h = s.headline?.value;
return Number(String(h || '').replace(/[^\d.]/g, '')) || 0;
}
const DEMO_HOME = {
id: 'home',
name: 'Home',
label: 'Midtown, New York',
sub: 'Midtown, New York · demo location',
lon: -73.9851,
lat: 40.7549,
demo: true,
};
function load() {
try {
const j = JSON.parse(localStorage.getItem(RULES_KEY) || 'null');
if (j?.rules) return j;
} catch {
/* defaults */
}
return {
places: [DEMO_HOME],
rules: [
{ id: 'r1', what: 'squawk7700', within: 200, of: 'home', on: true },
{ id: 'r2', what: 'quake5', within: 500, of: 'home', on: true },
{ id: 'r3', what: 'circling', within: 25, of: 'home', on: true },
{ id: 'r4', what: 'issPass', within: 0, of: 'home', on: true },
],
fired: {},
};
}
export function createWatch({
host,
globe,
getSituations,
onOpenSituation,
onOpenEntity,
}) {
let state = load();
const save = () => {
try {
localStorage.setItem(RULES_KEY, JSON.stringify(state));
} catch {
/* per-viewer convenience only */
}
};
const placeById = (id) =>
state.places.find((p) => p.id === id) || state.places[0];
const what = (id) => WHAT.find((w) => w.id === id) || WHAT[0];
/* ---------- banners (iOS notification style) ---------- */
const stack = h('<div class="banners" aria-live="polite"></div>');
document.getElementById('app').appendChild(stack);
function banner({
title,
body,
band = 'sky',
iconName = 'bell',
onTap,
sticky = false,
}) {
const el =
h(`<button class="banner glass-strong" type="button" data-band="${esc(band)}">
<span class="bn-app">${icon('eyes')}</span>
<span class="bn-main"><span class="bn-top"><b>GOD'S EYE</b><span>now</span></span>
<span class="bn-title">${icon(iconName)}${esc(title)}</span><span class="bn-body">${esc(body)}</span></span>
</button>`);
stack.prepend(el);
haptic('success');
const dismiss = () => {
el.classList.add('out');
setTimeout(() => el.remove(), reducedMotion() ? 0 : 380);
};
el.addEventListener('click', () => {
dismiss();
onTap?.();
});
// Swipe up to dismiss (touch and mouse).
let y0 = null;
el.addEventListener('pointerdown', (e) => (y0 = e.clientY));
el.addEventListener('pointermove', (e) => {
if (y0 == null) return;
const dy = Math.min(0, e.clientY - y0);
el.style.transform = `translateY(${dy}px)`;
if (dy < -40) {
y0 = null;
dismiss();
}
});
el.addEventListener('pointerup', () => {
y0 = null;
el.style.transform = '';
});
if (!sticky) setTimeout(dismiss, 7000);
while (stack.children.length > 2) stack.lastElementChild.remove();
return { dismiss };
}
/* ---------- evaluation ---------- */
const hits = new Map(); // ruleId → [situations]
async function evaluate({ quiet = false } = {}) {
let sits = [];
try {
sits = [...(await getSituations()), ...globe.detections()];
} catch {
return;
}
const fresh = [];
for (const r of state.rules) {
if (!r.on) continue;
const pl = placeById(r.of);
const w = what(r.what);
if (w.id === 'issPass') {
const sky = await globe
.skyNow({ lon: pl.lon, lat: pl.lat })
.catch(() => null);
const np = sky?.iss?.nextPass;
const list = [];
if (sky?.iss?.visibleNow)
list.push({
id: 'iss-now',
title: 'The ISS is above you now',
when: Date.now(),
});
else if (
np &&
np.start - Date.now() < 45 * 60e3 &&
np.start > Date.now()
)
list.push({
id: `iss-${Math.round(np.start / 60e3)}`,
title: `ISS pass at ${new Date(np.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`,
when: np.start,
np,
});
hits.set(r.id, list);
for (const x of list)
if (!state.fired[`${r.id}:${x.id}`]) fresh.push({ r, x, pl, w });
continue;
}
const list = sits.filter((s) => {
if (!w.test?.(s) || !Number.isFinite(s.lat)) return false;
return r.within >= 20000 || distKm(pl, s) <= r.within;
});
hits.set(r.id, list);
for (const s of list)
if (!state.fired[`${r.id}:${s.id}`]) fresh.push({ r, s, pl, w });
}
if (!quiet)
for (const f of fresh.slice(0, 2)) {
state.fired[`${f.r.id}:${(f.s || f.x).id}`] = Date.now();
if (f.s)
banner({
title: f.s.title,
body: `${ruleText(f.r)} · ${fmtKm(distKm(f.pl, f.s))} from ${f.pl.name}`,
band: bandOf(f.s),
onTap: () => onOpenSituation?.(f.s),
});
else
banner({
title: f.x.title,
body: f.x.np
? `Max ${f.x.np.maxElevationDeg ?? '—'}° · look up from ${f.pl.name}`
: `Look up from ${f.pl.name}`,
band: 'orbit',
iconName: 'satellite',
});
}
else
for (const f of fresh)
state.fired[`${f.r.id}:${(f.s || f.x).id}`] = Date.now();
save();
listeners.forEach((fn) => fn());
}
const listeners = new Set();
function ruleText(r) {
const w = what(r.what);
const pl = placeById(r.of);
if (w.id === 'issPass') return `${w.label} over ${pl.name}`;
return `Any ${w.label} ${r.within >= 20000 ? 'anywhere' : `within ${r.within.toLocaleString()} km of ${pl.name}`}`;
}
/* ---------- sheet ---------- */
const sheet = createSheet({
host,
label: 'Watch and alerts',
detents: ['large'],
modal: true,
dismissible: true,
closeButton: true,
className: 'watch-sheet',
heights: { large: (H) => Math.round(H * 0.9) },
});
function chip(kind, rid, label) {
return `<button class="rule-chip" type="button" data-rule="${esc(rid)}" data-chip="${kind}">${esc(label)}${icon('chevronDown')}</button>`;
}
function ruleHtml(r) {
const w = what(r.what);
const pl = placeById(r.of);
const n = (hits.get(r.id) || []).length;
const sentence =
w.id === 'issPass'
? `${chip('what', r.id, w.label)} over ${chip('of', r.id, pl.name)}`
: `Any ${chip('what', r.id, w.label)} ${r.within >= 20000 ? chip('within', r.id, 'anywhere') : `within ${chip('within', r.id, `${r.within.toLocaleString()} km`)}`} of ${chip('of', r.id, pl.name)}`;
return `<div class="rule" data-band="${w.band}"><i class="bd"></i><p>${sentence}</p>
<span class="rule-state tele">${r.on ? (n ? `${n} NOW` : 'QUIET') : 'OFF'}</span>
<button class="switch" type="button" role="switch" aria-checked="${!!r.on}" aria-label="Rule on" data-toggle="${esc(r.id)}"></button></div>`;
}
async function render() {
sheet.head.innerHTML = `<h2 class="sheet-title">Watch</h2><div class="sheet-sub">Places and things you follow, and when to tap you on the shoulder</div>`;
const followed = store.get('followed') || [];
const sits = await getSituations().catch(() => []);
const things = await Promise.all(
followed.slice(0, 8).map((id) => statusOf(id, sits)),
);
sheet.body.innerHTML = `
<div class="section-h">Places</div>
<div class="group">${state.places
.map(
(p) =>
`<div class="row"><span class="row-icon">${icon(p.id === 'home' ? 'home' : 'pin')}</span><span class="row-main"><span class="row-title">${esc(p.name)}</span><span class="row-sub">${esc(p.sub || `${p.lat.toFixed(3)}, ${p.lon.toFixed(3)}`)}</span></span>${p.id === 'home' ? `<button class="pill-btn plain" type="button" data-home="locate">${icon('location')}Use my location</button>` : `<button class="icon-btn" type="button" data-rm-place="${esc(p.id)}" aria-label="Remove">${icon('minus')}</button>`}</div>`,
)
.join('')}
<button class="row row-add" type="button" data-add-place>${icon('plus')}<span>Watch the place in view on the globe</span></button></div>
<div class="section-h">Things</div>
<div class="group">${
things.filter(Boolean).length
? things
.filter(Boolean)
.map(
(t) =>
`<button class="row" type="button" data-thing="${esc(t.id)}"><span class="row-icon" data-band="${t.band}">${icon(t.icon)}</span><span class="row-main"><span class="row-title">${esc(t.title)}</span><span class="row-sub">${esc(t.status)}</span></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')
: '<div class="row"><span class="row-sub">Follow an aircraft, ship, satellite or situation from its dossier.</span></div>'
}</div>
<div class="section-h">Alert rules</div>
<div class="rules">${state.rules.map(ruleHtml).join('')}</div>
<button class="pill-btn tinted new-rule" type="button" data-new-rule>${icon('plus')}New rule</button>
<p class="footnote">Calm by default: rules are checked against live data while God’s Eye is open, at most two banners at a time, and never for anything outside your rules. A quiet rule means nothing unusual.</p>`;
}
async function statusOf(id, sits) {
const s = sits.find((x) => String(x.id) === String(id));
if (s) {
const m = s.metrics?.[0];
return {
id,
kind: 'situation',
title: s.title,
status: `${s.place || ''}${m ? ` · ${m.value}${m.unit ? ` ${m.unit}` : ''}` : ''} · ${ago(s.updatedAt)}`,
band: bandOf(s),
icon: 'now',
s,
};
}
const d = await globe.entity(id).catch(() => null);
if (!d) return null;
const L = d.live;
let status = d.subtitle || '';
if (L && Number.isFinite(L.altM) && L.altM > 0)
status =
L.altM > 100e3
? `${Math.round(L.altM / 1000)} km up · ${L.speed ? `${(L.speed / 1000).toFixed(2)} km/s` : ''}`
: `${Math.round(L.altM * 3.28084).toLocaleString()} ft · ${L.speed ? `${Math.round(L.speed * 1.944)} kt` : ''}`;
const band =
{
satellite: 'orbit',
aircraft: 'sky',
helicopter: 'sky',
military: 'sky',
}[d.kind] || 'ground';
return {
id,
kind: d.kind,
title: d.title,
status,
band,
icon:
{
satellite: 'satellite',
aircraft: 'sky',
helicopter: 'heli',
vessel: 'sea',
place: 'pin',
moment: 'film',
}[d.kind] || 'scope',
d,
};
}
function cycle(rule, kind) {
if (kind === 'what') {
const i = WHAT.findIndex((w) => w.id === rule.what);
rule.what = WHAT[(i + 1) % WHAT.length].id;
} else if (kind === 'within') {
const i = RADII.indexOf(rule.within);
rule.within = RADII[(i + 1) % RADII.length];
} else if (kind === 'of') {
const i = state.places.findIndex((p) => p.id === rule.of);
rule.of = state.places[(i + 1) % state.places.length].id;
}
}
sheet.body.addEventListener('click', async (e) => {
const c = e.target.closest('[data-chip]');
if (c) {
haptic('tick');
const r = state.rules.find((x) => x.id === c.dataset.rule);
cycle(r, c.dataset.chip);
save();
await evaluate({ quiet: true });
render();
return;
}
const t = e.target.closest('[data-toggle]');
if (t) {
haptic('tick');
const r = state.rules.find((x) => x.id === t.dataset.toggle);
r.on = !r.on;
save();
render();
return;
}
if (e.target.closest('[data-new-rule]')) {
state.rules.push({
id: `r${Date.now().toString(36)}`,
what: 'quake5',
within: 500,
of: state.places[0].id,
on: true,
});
save();
haptic('light');
render();
return;
}
if (e.target.closest('[data-add-place]')) {
const c2 = globe.raw?.camera?.() || null;
const v = lastCam || c2;
if (!v)
return toast('Open Live and frame a place first', {
icon: icon('globe'),
});
state.places.push({
id: `p${Date.now().toString(36)}`,
name: `Place ${state.places.length}`,
lon: v.lon,
lat: v.lat,
});
save();
toast('Watching this place', { icon: icon('pin') });
render();
return;
}
const rm = e.target.closest('[data-rm-place]');
if (rm) {
state.places = state.places.filter((p) => p.id !== rm.dataset.rmPlace);
state.rules = state.rules.filter((r) => r.of !== rm.dataset.rmPlace);
save();
render();
return;
}
if (e.target.closest('[data-home="locate"]')) {
navigator.geolocation?.getCurrentPosition(
(p) => {
setHome({ lat: p.coords.latitude, lon: p.coords.longitude });
render();
},
() =>
toast('Location unavailable · using the demo home', {
icon: icon('location'),
}),
);
return;
}
const th = e.target.closest('[data-thing]');
if (th) {
sheet.close();
onOpenEntity?.(th.dataset.thing);
}
});
let lastCam = null;
globe.on('camera', (c) => (lastCam = c));
function setHome(loc) {
const home = state.places.find((p) => p.id === 'home');
Object.assign(home, {
lon: loc.lon,
lat: loc.lat,
sub: 'Your location',
label: 'your location',
demo: false,
});
save();
}
setInterval(() => {
if (!document.hidden) evaluate();
}, 30000);
return {
banner,
evaluate,
open() {
render();
sheet.open('large');
},
home: () => placeById('home'),
/** One line for You: "Home · 4 rules · 1 on now". */
summary() {
const on = state.rules.filter((r) => r.on);
const now = on.reduce((a, r) => a + (hits.get(r.id) || []).length, 0);
return `${state.places.length} place${state.places.length === 1 ? '' : 's'} · ${on.length} rule${on.length === 1 ? '' : 's'}${now ? ` · ${now} on now` : ' · all quiet'}`;
},
setHome,
places: () => state.places,
rules: () => state.rules,
hits: (rid) => hits.get(rid) || [],
ruleText,
statusOf,
onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
/** From a dossier's "Alert me". */
ruleFor(d) {
const pl = d.live && Number.isFinite(d.live.lat) ? d.live : null;
let rule;
if (d.kind === 'satellite' && /iss|zarya/i.test(d.title))
rule = { what: 'issPass', within: 0, of: 'home' };
else if (pl) {
const id = `p${Date.now().toString(36)}`;
state.places.push({
id,
name: d.kind === 'place' ? d.title : `Near ${d.title}`,
lon: pl.lon,
lat: pl.lat,
});
rule = {
what: /quake/.test(d.kind)
? 'quake5'
: /heli|moment|capture/.test(d.kind)
? 'circling'
: 'emergency',
within: 50,
of: id,
};
} else rule = { what: 'emergency', within: 200, of: 'home' };
const r = { id: `r${Date.now().toString(36)}`, on: true, ...rule };
state.rules.push(r);
save();
haptic('success');
toast(`Alert set · ${ruleText(r)}`, { icon: icon('bell') });
evaluate({ quiet: true });
},
noteFollow() {
listeners.forEach((fn) => fn());
},
reset() {
try {
localStorage.removeItem(RULES_KEY);
} catch {
/* ignore */
}
state = load();
hits.clear();
},
bandColor: (b) => BAND_COLOR[b],
utc,
};
}
+22 -2
View File
@@ -248,7 +248,8 @@ export function createWorld({
<button role="tab" type="button" data-tab="brief" aria-selected="${tab === 'brief'}">Brief</button>
<button role="tab" type="button" data-tab="room" aria-selected="${tab === 'room'}">Room<span class="bt-n tele" data-room-n></span></button>
<span class="bt-here tele" data-room-here></span>
</div>`;
</div>
<div class="brief-asof tele" data-asof hidden></div>`;
}
function briefHtml(s) {
@@ -673,7 +674,7 @@ export function createWorld({
originCard && document.contains(originCard) ? originCard : getCard(s.id);
const T = targetRect();
const twoPane = isTwoPane();
if (!card || twoPane || app.dataset.tab !== 'now') {
if (!card || twoPane || app.dataset.tab !== 'today') {
app.classList.remove('is-world');
card?.classList.remove('hero-source');
current = null;
@@ -738,10 +739,29 @@ export function createWorld({
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
/** Everything follows the clock: the open sheet says what time it shows. */
let clockT = null;
function setClock(d) {
clockT = d ? +d : null;
const el = $('[data-asof]', sheet.head);
if (!el) return;
el.hidden = clockT == null;
if (clockT != null) {
const dm = Math.round((clockT - Date.now()) / 60e3);
const rel =
Math.abs(dm) < 60
? `${dm < 0 ? '−' : '+'}${Math.abs(dm)} MIN`
: `${dm < 0 ? '−' : '+'}${Math.floor(Math.abs(dm) / 60)} H ${Math.abs(dm) % 60} MIN`;
el.innerHTML = `${icon('clock')}SHOWING ${rel} · ${new Date(clockT).toISOString().slice(11, 19)}Z`;
}
}
ev.on('open', () => setTimeout(() => setClock(clockT), 0));
return {
open,
close,
sheet,
setClock,
get current() {
return current;
},