mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
293 lines
8.0 KiB
JavaScript
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,
|
|
},
|
|
),
|
|
];
|
|
}
|