Files
gods-eye-view/mobile/globe/contacts.js
T
Claude a8b7680c7a feat(mobile): add the phone-tuned Cesium globe engine
createGlobe() implements the Globe API in mobile/DESIGN.md: Esri day
imagery blended with NASA Black Marble night lights across the
terminator, Google 3D tiles below 25 km when keyed, a single-pass
grade with half-res bloom and lenses, an adaptive quality governor,
live layers on the repo's /api routes and parsers, time scrubbing,
picking, AR contact queries and live situation cards.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
2026-10-18 05:27:33 +00:00

127 lines
3.8 KiB
JavaScript

/**
* contactsNear(): aircraft, satellites and ships around a point, for the AR
* Look mode. Reads the shared feeds directly (not layer state), so AR works
* whether or not those layers are on the globe, and nudges a stale feed to
* refresh in the background instead of blocking the caller's frame.
*/
import { twoline2satrec } from 'satellite.js';
import { satPosition } from './layers/orbit.js';
import { haversineKm, offsetLonLat } from './geo.js';
const SAT_GROUPS = ['stations', 'visual'];
export function createContactIndex(feeds, { active }) {
let satCache = { stamp: '', sats: [] };
function satrecs() {
const stamp = SAT_GROUPS.map((g) => feeds[g].updatedAt).join(':');
if (stamp === satCache.stamp) return satCache.sats;
const seen = new Set();
const sats = [];
for (const g of SAT_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 {
/* skip malformed */
}
}
}
satCache = { stamp, sats };
return sats;
}
function nudge(feed, maxAgeMs) {
if (!active()) return;
if (!feed.data || Date.now() - feed.updatedAt > maxAgeMs)
feed.get({ maxAgeMs }).catch(() => {});
}
return function contactsNear(
lon,
lat,
km = 50,
{ timeMs = Date.now() } = {},
) {
nudge(feeds.flights, 20_000);
nudge(feeds.military, 30_000);
nudge(feeds.vessels, 60_000);
for (const g of SAT_GROUPS) nudge(feeds[g], 6 * 3600_000);
const out = [];
const seen = new Set();
const now = Date.now();
const addMover = (r, kind, title) => {
if (seen.has(r.id)) return;
let [rlon, rlat] = [r.lon, r.lat];
if (r.speed && !r.onGround && r.seenAt) {
const dt = Math.min(90, Math.max(0, (now - r.seenAt) / 1000));
[rlon, rlat] = offsetLonLat(r.lon, r.lat, r.heading || 0, r.speed * dt);
}
const d = haversineKm(lat, lon, rlat, rlon);
if (d > km) return;
seen.add(r.id);
out.push({
kind,
id: r.id,
title,
lon: rlon,
lat: rlat,
altM: r.altM ?? 0,
headingDeg: r.heading ?? null,
speedMps: r.speed ?? null,
distanceKm: d,
});
};
for (const r of feeds.military.data?.records || [])
addMover(r, r.helicopter ? 'helicopter' : 'military', r.callsign);
for (const r of feeds.flights.data?.records || [])
addMover(r, r.helicopter ? 'helicopter' : 'aircraft', r.callsign);
for (const r of feeds.vessels.data?.records || [])
addMover(
{ ...r, altM: 0, seenAt: feeds.vessels.updatedAt },
'vessel',
r.name,
);
// Satellites: anything whose sub-point is within km plus the horizon
// reach of its altitude is potentially overhead.
for (const s of satrecs()) {
const p = satPosition(s.satrec, timeMs);
if (!p) continue;
const reachKm = km + Math.sqrt(2 * 6371 * (p.altM / 1000));
const d = haversineKm(lat, lon, p.lat, p.lon);
if (d > reachKm) continue;
const q = satPosition(s.satrec, timeMs + 10_000);
const heading = q
? (Math.atan2(
(q.lon - p.lon) * Math.cos((p.lat * Math.PI) / 180),
q.lat - p.lat,
) *
180) /
Math.PI +
360
: 0;
out.push({
kind: s.id === '25544' ? 'station' : 'satellite',
id: s.id,
title: s.name,
lon: p.lon,
lat: p.lat,
altM: p.altM,
headingDeg: heading % 360,
speedMps: p.speedMps,
distanceKm: d,
});
}
return out.sort((a, b) => a.distanceKm - b.distanceKm);
};
}