Files
gods-eye-view/mobile/globe/layers/air.js
T

293 lines
8.0 KiB
JavaScript

/**
* Sky family: commercial flights, military aircraft, helicopters, private/GA
* and emergency squawks. Every layer reads the shared flight and military
* feeds; derived layers (helicopters, GA, squawks) are filters, never extra
* downloads.
*/
import { ContactRenderer, Layer, color, FAMILY_COLORS } from './base.js';
import { haversineKm } from '../geo.js';
const SKY = FAMILY_COLORS.Sky;
const M_TO_FT = 3.28084;
const MPS_TO_KT = 1 / 0.514444;
const HISTORY = 24;
export class AircraftLayer extends Layer {
/**
* @param {object} ctx
* @param {object} def layer definition
* @param {{sources:{feed:object, filter:(r:object)=>boolean}[], tint:string,
* lodHeightM?:number, cap?:number, alwaysGlyph?:boolean, kind?:string}} opts
*/
constructor(ctx, def, opts) {
super(ctx, { pollMs: 12_000, ...def });
this.sources = opts.sources;
this.kind = opts.kind || 'aircraft';
this.renderer = new ContactRenderer(this.root, {
layerId: this.id,
tint: color(opts.tint || SKY),
glyphName: 'plane',
lodHeightM: opts.lodHeightM ?? 1_800_000,
cap: opts.cap ?? 700,
deadReckon: true,
dotScale: opts.dotScale ?? 0.36,
alwaysGlyph: opts.alwaysGlyph,
glyphScale: opts.glyphScale ?? 0.5,
});
this.records = new Map();
this.history = new Map();
this.groundTint = color(opts.tint || SKY).withAlpha(0.55);
}
async refresh() {
const results = await Promise.allSettled(
this.sources.map((s) => s.feed.get({ maxAgeMs: 9_000 })),
);
const flightData = this.sources[0].feed.data;
this._regionalNote = flightData?.regional
? 'OpenSky unavailable — adsb.lol snapshot within 250 nm of the view'
: '';
const records = [];
let anyOk = false;
this.sources.forEach((s, i) => {
this.fromFeed(s.feed);
if (results[i].status !== 'fulfilled') return;
anyOk = true;
for (const r of results[i].value.records)
if (s.filter(r)) records.push(r);
});
if (!anyOk) throw results[0].reason;
if (this._regionalNote && !this.message) this.message = this._regionalNote;
this.ingest(records);
}
ingest(records) {
const next = new Map();
for (const r of records) {
next.set(r.id, r);
let h = this.history.get(r.id);
if (!h) this.history.set(r.id, (h = []));
const last = h.at(-1);
if (!last || last[0] !== r.lon || last[1] !== r.lat) {
h.push([r.lon, r.lat, r.altM]);
if (h.length > HISTORY) h.shift();
}
}
for (const id of this.history.keys())
if (!next.has(id)) this.history.delete(id);
this.records = next;
this.count = next.size;
this.renderer.setItems(
records.map((r) => ({
key: r.id,
lon: r.lon,
lat: r.lat,
altM: r.altM,
heading: r.heading,
speed: r.speed,
onGround: r.onGround,
seenAt: r.seenAt || Date.now(),
glyph: r.helicopter ? 'heli' : 'plane',
// Parked traffic recedes so the apron does not bloom into a blob.
color: r.emergency
? color('#FF3B30')
: r.onGround
? this.groundTint
: undefined,
glyphScale: r.onGround
? 0.6
: r.helicopter
? 0.95
: r.cls === 'widebody'
? 1.15
: 1,
})),
);
}
onView(view) {
this.renderer.setView(view);
// A regional fallback snapshot follows the camera: refetch once the
// view has moved well away from the snapshot's anchor.
const data = this.sources[0].feed.data;
if (
this.on &&
data?.regional &&
data.anchor &&
view.center &&
haversineKm(
data.anchor.lat,
data.anchor.lon,
view.center.lat,
view.center.lon,
) > 300 &&
Date.now() - (this._regionalKick || 0) > 8_000
) {
this._regionalKick = Date.now();
this.kick();
}
}
onTime(ms, live) {
// 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);
}
onTick(nowMs, live) {
return live && this.renderer.tick(nowMs);
}
locate(key) {
const r = this.records.get(key);
if (!r) return null;
const it = this.renderer.byKey.get(key);
const [lon, lat] = it ? this.renderer.where(it) : [r.lon, r.lat];
return [lon, lat, r.altM];
}
trail(key) {
const h = this.history.get(key) || [];
const now = this.locate(key);
return now ? [...h, now] : h;
}
pick(key) {
const r = this.records.get(key);
if (!r) return null;
const [lon, lat, altM] = this.locate(key);
const alt = r.onGround
? 'On ground'
: `${Math.round((r.altM * M_TO_FT) / 100) * 100} ft`;
const spd = Number.isFinite(r.speed)
? `${Math.round(r.speed * MPS_TO_KT)} kt`
: null;
return {
kind: this.kind,
id: r.id,
title: r.callsign,
subtitle: [
r.military ? r.type || 'Military' : r.country,
alt,
spd,
r.emergency ? `SQUAWK ${r.squawk}` : null,
]
.filter(Boolean)
.join(' · '),
lon,
lat,
...(r.emergency ? { labelDetail: `SQ ${r.squawk}` } : {}),
meta: {
layer: this.id,
icao24: r.id,
altitudeM: r.altM,
speedMps: r.speed,
headingDeg: r.heading,
verticalRateMps: r.vRate ?? null,
squawk: r.squawk,
type: r.type || null,
class: r.cls,
country: r.country || null,
onGround: r.onGround,
source: this.source,
},
};
}
contacts() {
const out = [];
for (const r of this.records.values()) {
const it = this.renderer.byKey.get(r.id);
const [lon, lat] = it ? this.renderer.where(it) : [r.lon, r.lat];
out.push({
kind: this.kind,
id: r.id,
title: r.callsign,
lon,
lat,
altM: r.altM,
headingDeg: r.heading,
speedMps: r.speed,
layer: this.id,
});
}
return out;
}
onHide() {}
}
/** Build the Sky family. */
export function createSkyLayers(ctx, { flights, military }) {
const all = () => true;
return [
new AircraftLayer(
ctx,
{ id: 'flights', family: 'Sky', name: 'Commercial flights' },
{
sources: [{ feed: flights, filter: (r) => !r.ga && !r.helicopter }],
tint: SKY,
},
),
new AircraftLayer(
ctx,
{ id: 'military', family: 'Sky', name: 'Military aircraft' },
{
sources: [{ feed: military, filter: all }],
tint: '#E3F1FF',
kind: 'military',
lodHeightM: 6_000_000,
alwaysGlyph: true,
glyphScale: 0.62,
dotScale: 0.6,
},
),
new AircraftLayer(
ctx,
{ id: 'helicopters', family: 'Sky', name: 'Helicopters' },
{
sources: [
{ feed: flights, filter: (r) => r.helicopter },
{ feed: military, filter: (r) => r.helicopter },
],
tint: '#A8E0FF',
kind: 'helicopter',
alwaysGlyph: true,
glyphScale: 0.6,
},
),
new AircraftLayer(
ctx,
{ id: 'ga', family: 'Sky', name: 'Private / GA' },
{
sources: [{ feed: flights, filter: (r) => r.ga && !r.helicopter }],
tint: '#B9D9F5',
dotScale: 0.3,
glyphScale: 0.42,
},
),
new AircraftLayer(
ctx,
{ id: 'squawk', family: 'Sky', name: 'Emergency squawks' },
{
sources: [
{ feed: flights, filter: (r) => r.emergency },
{
feed: military,
filter: (r) => ['7500', '7600', '7700'].includes(r.squawk),
},
],
tint: '#FF3B30',
kind: 'emergency',
alwaysGlyph: true,
glyphScale: 0.8,
dotScale: 0.9,
},
),
];
}