mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
wip(mobile): checkpoint engine and experience round three in progress
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
83 files changed
+50005
-416
No files matched your search
@@ -0,0 +1,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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user