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

+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);
},
};
}