Merge pull request #1729 from bilawalsidhu/fleet/ground-vehicles

Draw airport ground vehicles by callsign and ground stations as a radio mast
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-13 21:38:58 -08:00
commit c5cf579174
15 files changed
+407 -24

No files matched your search

+1 -1
View File
@@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat
- **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit.
- **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click.
- **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes.
- **🛫 3D fleet:** 121 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
- **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in.
- **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir.
- **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view.
- **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry.
@@ -0,0 +1,5 @@
- Draw airport ground vehicles and ground stations as what they are instead of
the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback
tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m
radio mast, and one with no altitude stands on the ground instead of at the
10 km airborne default (samehkhamis, #1729).
+1 -1
View File
@@ -7429,7 +7429,7 @@ pins and share links already supply complete boxes.
normal four-samples-per-250-ms budget. Retry timestamps more than the maximum
30-second backoff ahead of the current clock are ignored. A smaller rollback
can retain a bounded retry delay of up to 30 seconds.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 121 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): its ICAO type's entry in `types.json`, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/121 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the model's own vertex colour ~6%; military models are flat amber, or emergency red. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
+19
View File
@@ -1785,6 +1785,25 @@
"tris": 1172,
"icon": "heli"
},
"radio_mast": {
"category": "other",
"file": "other/radio_mast.glb",
"min": [
-0.7,
-0.815,
0.0
],
"max": [
1.2,
0.815,
11.8
],
"length": 1.9,
"beam": 1.63,
"clearance": 0.0,
"tris": 940,
"icon": "vehicle"
},
"regional_dmu": {
"category": "transit",
"file": "transit/regional_dmu.glb",
Binary file not shown.
+36
View File
@@ -983,6 +983,14 @@
"SHIP": [
"blimp",
75.0
],
"ZVEH": [
"car",
4.61
],
"GRND": [
"radio_mast",
1.9
]
},
"helicopters": {
@@ -1143,6 +1151,34 @@
5.0
]
},
"aircraft_callsign": {
"ZVEH": [
{
"prefixes": [
"FIRE"
],
"model": "fire_truck",
"length": 11.01
},
{
"prefixes": [
"PUSH",
"TUG"
],
"model": "pushback",
"length": 6.0
},
{
"prefixes": [
"DEICER",
"SQUID",
"SQB"
],
"model": "van",
"length": 5.51
}
]
},
"vessels": [
{
"types": [
+3 -1
View File
@@ -4815,7 +4815,9 @@
"src/data/approxDistance.js",
"src/data/renderAltitude.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js"
"src/data/aircraftEmergency.js",
"src/data/fleetModels.js",
"src/data/fleetTrailAnchors.js"
],
"external": []
},
+73 -9
View File
@@ -85,16 +85,24 @@ const TEXT_SHIP_TYPES = [
];
/**
* The model for an aircraft of ICAO type `type` (A320, B738, R44) and ADS-B
* emitter category `category`: the type's, else the category's, else
* `default_aircraft`.
* The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B
* emitter category `category` and callsign `callsign`: the first of the
* type's callsign rules (`aircraft_callsign`) the callsign matches, else the
* type's, else the category's, else `default_aircraft`. An airport vehicle
* (type ZVEH) is a fire truck, tug or van by its callsign, else a car.
* @param {object} types Parsed `types.json`.
* @param {string} [type]
* @param {string} [category]
* @param {string} [callsign]
* @returns {{model: string, length: number|null}}
*/
export function resolveAircraft(types, type, category) {
export function resolveAircraft(types, type, category, callsign) {
const t = type?.trim().toUpperCase();
const index = aircraftCallsignRule(types, t, callsign);
if (index >= 0) {
const rule = types.aircraft_callsign[t][index];
return { model: rule.model, length: rule.length };
}
const [model, length] =
(t && (types.aircraft[t] ?? types.helicopters[t])) ||
(category && types.categories[category]) ||
@@ -102,6 +110,51 @@ export function resolveAircraft(types, type, category) {
return { model, length };
}
/**
* A callsign as the rules read it: upper case, spaces removed.
* @param {string} callsign
* @returns {string}
*/
function callsignKey(callsign) {
return String(callsign).toUpperCase().replace(/\s+/g, '');
}
/**
* Which of type `type`'s callsign rules (`types.json`'s
* `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match
* wins) `callsign` matches: its index, -1 when none does or the type has
* none. A callsign matches a rule when, upper-cased with spaces removed, it
* starts with one of the rule's prefixes.
* @param {object} types Parsed `types.json`.
* @param {string|null} [type] ICAO type.
* @param {string|null} [callsign]
* @returns {number}
*/
export function aircraftCallsignRule(types, type, callsign) {
const t = String(type ?? '')
.trim()
.toUpperCase();
const rules = t ? types.aircraft_callsign?.[t] : undefined;
if (!rules || !callsign) return -1;
const c = callsignKey(callsign);
return rules.findIndex((rule) =>
rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))),
);
}
/**
* Whether an aircraft of ICAO type `type` picks its model by callsign.
* @param {object} types Parsed `types.json`.
* @param {string|null} [type]
* @returns {boolean}
*/
export function aircraftModelByCallsign(types, type) {
const t = String(type ?? '')
.trim()
.toUpperCase();
return Boolean(t && types.aircraft_callsign?.[t]);
}
/**
* A reported length as one a hull can have, else undefined (drawn as built).
* @param {number} [length]
@@ -306,24 +359,32 @@ function specOf(set, resolved, fallback, options) {
}
/**
* The model an aircraft is drawn with. The type's entry first; then, for a
* The model an aircraft is drawn with. The type's callsign rules (an airport
* vehicle by its callsign), then the type's entry; then, for a
* type the map lacks, the model of its class (a fast jet, a helicopter, a
* glider) when the type names one; then the emitter category's; then
* `default_aircraft`.
* @param {{catalog: object, types: object}} set
* @param {{typeCode?: string|null, type?: string|null,
* category?: string|number|null}} aircraft
* category?: string|number|null, callsign?: string|null}} aircraft
* @returns {object} {@link fleetModelSpec}
*/
export function aircraftModelSpec(set, { typeCode, type, category } = {}) {
export function aircraftModelSpec(
set,
{ typeCode, type, category, callsign } = {},
) {
const { types, catalog } = set;
const code = String(typeCode ?? type ?? '')
.trim()
.toUpperCase();
const emitter = emitterCategory(category);
let resolved = resolveAircraft(types, code, emitter);
const sign = callsign ? String(callsign) : undefined;
let resolved = resolveAircraft(types, code, emitter, sign);
// A callsign rule names the model outright; a type's class only stands in
// for a type the map lacks.
const ruled = aircraftCallsignRule(types, code, sign) >= 0;
const typed = code && (types.aircraft[code] || types.helicopters[code]);
if (!typed && code) {
if (!ruled && !typed && code) {
const model = CLASS_MODELS[classifyAircraft({ typeCode: code })];
if (model && catalog[model])
resolved = { model, length: catalog[model].length };
@@ -387,6 +448,9 @@ export function namedFleetModels(types) {
...pairs(types.aircraft),
...pairs(types.helicopters),
...pairs(types.categories),
...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) =>
rules.map((rule) => rule.model),
),
...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)),
types.default_aircraft[0],
types.default_vessel,
+76
View File
@@ -51,8 +51,17 @@ const sliceTypes = {
A21N: ['a320', 44.51],
A319: ['a320', 33.84],
B38M: ['b738', 39.52],
ZVEH: ['car', 4.61],
GRND: ['radio_mast', 1.9],
},
helicopters: { EC35: ['ec35', 10.2] },
aircraft_callsign: {
ZVEH: [
{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 },
{ prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 },
{ prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 },
],
},
categories: { A1: ['c172', 8.3] },
vessels: [
{
@@ -112,6 +121,33 @@ test('aircraft resolve by type, then emitter category, then the generic plane',
});
});
test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => {
const zveh = (callsign) =>
resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign);
assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 });
assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), {
model: 'fire_truck',
length: 11.01,
});
assert.equal(zveh('PUSH 14').model, 'pushback');
assert.equal(zveh('TUG3').model, 'pushback');
assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 });
assert.equal(zveh('SQUID20').model, 'van');
assert.equal(zveh('SQB9').model, 'van');
assert.equal(zveh('S Q B 9').model, 'van');
assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 });
assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 });
// Callsign rules belong to their type: a fire-named airliner is still an airliner.
assert.equal(
resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model,
'a320',
);
assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), {
model: 'radio_mast',
length: 1.9,
});
});
test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => {
assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship');
assert.equal(
@@ -225,6 +261,46 @@ test('transit bodies mark the share of the route colour they take; other models
}
});
test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => {
const zveh = (callsign) =>
aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign })
.model;
assert.equal(zveh('FIRE 2'), 'fire_truck');
assert.equal(zveh('PUSHBACK1'), 'pushback');
assert.equal(zveh('TUG 4'), 'pushback');
assert.equal(zveh('DEICER 4'), 'van');
assert.equal(zveh('SQUID20'), 'van');
assert.equal(zveh('SQB9'), 'van');
assert.equal(zveh('LEADER 7'), 'car');
assert.equal(zveh('AOPS'), 'car');
assert.equal(zveh(null), 'car');
const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' });
assert.equal(mast.model, 'radio_mast');
assert.equal(mast.scale, 1);
});
test('a callsign rule outranks the class model of a type the map lacks', () => {
const ruled = {
...set,
types: {
...set.types,
aircraft_callsign: {
...set.types.aircraft_callsign,
PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }],
},
},
};
assert.equal(
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model,
'fire_truck',
);
// No rule matched: the class model, as before.
assert.equal(
aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model,
'generic_heli',
);
});
test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => {
assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320');
assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744');
+1
View File
@@ -55,6 +55,7 @@ export const FLEET_TRAIL_ANCHORS = Object.freeze({
pc12: Object.freeze([6.6416, 0.5288, 0]),
pushback: Object.freeze([3, 0.2499, 0]),
r44: Object.freeze([4.4219, -0.3001, 0]),
radio_mast: Object.freeze([1.2, 0, 0]),
s76: Object.freeze([6.6001, 0.1915, 0]),
sr22: Object.freeze([3.6762, 0.2376, 0]),
t38: Object.freeze([7.0246, -0.3791, 0]),
+46 -1
View File
@@ -57,7 +57,14 @@ test('every typed aircraft renders at its published length, a real-world aircraf
);
// A grounded aircraft stands on its wheels: its origin the scaled
// clearance above the ground, never below it.
assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
// A model built standing on its origin (a ground vehicle, a ground
// station's mast: its lowest point at z 0) has no clearance.
if (set.catalog[model].min[2] > -0.05)
assert.ok(
spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale,
`${type}: built standing, clearance ${spec.clearanceM}`,
);
else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`);
assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`);
}
}
@@ -296,3 +303,41 @@ test('a layer asking every tick gets one cached spec per type and category', asy
assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a);
assert.ok(Object.isFrozen(a));
});
test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => {
const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import(
'../layers/flights/modelSpec.js'
);
const tender = aircraftLayerModelSpec(
{ typeCode: 'ZVEH', callsign: 'FIRE 2' },
set,
);
const car = aircraftLayerModelSpec(
{ typeCode: 'ZVEH', callsign: 'LEADER 7' },
set,
);
assert.equal(tender.model, 'fire_truck');
assert.equal(car.model, 'car');
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set),
aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set),
);
assert.notEqual(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
);
// Keyed by the rule matched, not the callsign: at most one entry per rule
// and one for the type's own model.
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
);
assert.equal(
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set),
aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set),
);
assert.equal(
aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set),
tender,
);
});
+23 -7
View File
@@ -1,4 +1,9 @@
import { aircraftModelSpec, fleetModelSet } from '../../data/fleetModels.js';
import {
aircraftCallsignRule,
aircraftModelByCallsign,
aircraftModelSpec,
fleetModelSet,
} from '../../data/fleetModels.js';
import { MODEL_COLOR_BLEND_AMOUNT } from './policy.js';
/**
@@ -25,12 +30,14 @@ export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({
/**
* The fleet model, life-size scale and measured geometry an aircraft draws
* with, chosen by its ICAO type and emitter category (`fleetModels.js`).
* with, chosen by its ICAO type and emitter category, and by its callsign
* for the types that pick by callsign (`fleetModels.js`).
* Shared by the Flights, Military Flights and Local ADS-B layers so all three
* draw the same model for the same aircraft. `bellyM` is the model's scaled
* clearance: how far a grounded aircraft's origin stands above the ground.
* @param {{typeCode?: string|null, type?: string|null,
* category?: string|number|null}|null|undefined} aircraft
* category?: string|number|null,
* callsign?: string|null}|null|undefined} aircraft
* @param {object|null} [set] The loaded fleet model set.
* @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before
* the set has loaded.
@@ -40,12 +47,13 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
// Layers ask on every tick; each type and category is resolved once per set.
let cache = specCaches.get(set);
if (!cache) specCaches.set(set, (cache = new Map()));
const key = aircraftModelSpecCacheKey(aircraft);
const key = aircraftModelSpecCacheKey(aircraft, set);
let layerSpec = cache.get(key);
if (!layerSpec) {
const spec = aircraftModelSpec(set, {
typeCode: aircraft?.typeCode ?? aircraft?.type ?? null,
category: aircraft?.category ?? null,
callsign: aircraft?.callsign ?? null,
});
layerSpec = Object.freeze({
...spec,
@@ -61,10 +69,18 @@ export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) {
const specCaches = new WeakMap();
/**
* Cache key of an aircraft's model spec: what selects its model.
* Cache key of an aircraft's model spec: what selects its model. For a type
* that picks its model by callsign (an airport vehicle) the key adds the
* rule its callsign matches, or `type` for none, so such a type caches at
* most one entry per rule and one for its own model.
* @param {object|null|undefined} aircraft
* @param {object|null} [set] The loaded fleet model set.
* @returns {string}
*/
export function aircraftModelSpecCacheKey(aircraft) {
return `${aircraft?.typeCode ?? aircraft?.type ?? ''}|${aircraft?.category ?? ''}`;
export function aircraftModelSpecCacheKey(aircraft, set = fleetModelSet()) {
const type = aircraft?.typeCode ?? aircraft?.type ?? '';
const key = `${type}|${aircraft?.category ?? ''}`;
if (!set || !aircraftModelByCallsign(set.types, type)) return key;
const rule = aircraftCallsignRule(set.types, type, aircraft?.callsign);
return `${key}|${rule >= 0 ? `r${rule}` : 'type'}`;
}
+62 -3
View File
@@ -5,6 +5,11 @@ import {
} from '../../data/renderAltitude.js';
import { classifyAircraft } from '../../data/aircraftClass.js';
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
import {
aircraftCallsignRule,
aircraftModelSpec,
fleetModelSet,
} from '../../data/fleetModels.js';
import {
approxDistanceKm,
GROUND_FLOOR_CLAMP_RADIUS_KM,
@@ -21,6 +26,7 @@ export class FlightRecords {
cachedGroundFloor,
floorAltitudeM,
now = () => Date.now(),
fleetModels = fleetModelSet,
}) {
this.services = {
geoidHeight,
@@ -29,6 +35,8 @@ export class FlightRecords {
};
/** The time records are judged at: the layer's clock. */
this.now = now;
/** The loaded fleet model set, or null before it loads. */
this.fleetModels = fleetModels;
this.data = new Map();
// Facts about an airframe, not a moment, kept across a reset.
this.airframes = new Map();
@@ -92,7 +100,6 @@ export class FlightRecords {
verticalRateMps: vertical_rate,
} = observation;
const onGround = on_ground === true;
this.missingPolls.delete(icao24);
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for
// aircraft it still positions — hold last-known-good instead of
@@ -102,6 +109,27 @@ export class FlightRecords {
// The airframe's facts: this record's, or those kept across a reset.
const airframe = prevMeta ?? this.airframes.get(icao24);
this.airframes.delete(icao24);
// A ground vehicle or ground station (a feed's ZVEH or GRND: a model
// built standing, catalog category `other`) takes the feed's own type,
// which no airframe lookup knows; every other aircraft keeps waiting for
// its looked-up type. With no altitude at all it stands on the ground,
// where it is, instead of at the airborne default.
const groundModel = (typeCode) =>
drawnAsGroundModel(
this.fleetModels(),
typeCode,
category,
stickyText(callsign, prevMeta?.callsign),
);
const feedType = observation.typeCode || null;
const typeCode =
airframe?.typeCode ??
(feedType && groundModel(feedType) ? feedType : null);
const onGround =
on_ground === true ||
(!Number.isFinite(baro_alt) &&
!Number.isFinite(geo_alt) &&
groundModel(typeCode));
// Grounded planes with no baro reading sit at 0 m, not the 10 km
// airborne default (a parked plane must never float).
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
@@ -319,8 +347,9 @@ export class FlightRecords {
prevMeta?.lastContactEpochMs,
null,
),
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
typeCode: airframe?.typeCode ?? null,
// adsbdb enrichment — written by the enrichment callbacks, carried
// across polls; a ground vehicle's or station's from the feed:
typeCode,
typeName: airframe?.typeName ?? null,
registration: airframe?.registration ?? null,
airline: routeHeld ? (airframe.airline ?? null) : null,
@@ -408,3 +437,33 @@ export class FlightRecords {
this.geoidNCache.delete(id);
}
}
/** Per loaded fleet model set: whether a type, category and callsign rule draw a ground model. */
const groundModelCaches = new WeakMap();
/**
* Whether an aircraft of this type, emitter category and callsign is drawn
* with a model built standing on the ground (catalog category `other`: an
* airport vehicle, a ground station's mast). False before the set loads.
* Answered once per type, category and callsign rule matched.
* @param {object|null} set The loaded fleet model set.
* @param {string|null} typeCode
* @param {string|number|null} category
* @param {string|null} callsign
* @returns {boolean}
*/
function drawnAsGroundModel(set, typeCode, category, callsign) {
if (!set || !typeCode) return false;
let cache = groundModelCaches.get(set);
if (!cache) groundModelCaches.set(set, (cache = new Map()));
const rule = aircraftCallsignRule(set.types, typeCode, callsign);
const key = `${typeCode}|${category ?? ''}|${rule}`;
let ground = cache.get(key);
if (ground === undefined) {
ground =
aircraftModelSpec(set, { typeCode, category, callsign }).category ===
'other';
cache.set(key, ground);
}
return ground;
}
+60 -1
View File
@@ -1,13 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { FlightRecords } from './records.js';
function records() {
const FLEET = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../public/models/fleet',
);
const fleetSet = {
catalog: JSON.parse(
fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8'),
),
types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')),
};
function records(fleetModels = () => null) {
return new FlightRecords({
geoidHeight: () => 40,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
approxDistanceKm: () => 0,
fleetModels,
});
}
const view = () => ({
@@ -260,3 +275,47 @@ test('a repeated fix time marks the contact as not updated; removal is unchanged
store.forget('abc123');
assert.equal(store.fixRepeated.has('abc123'), false);
});
test('a ground vehicle or station with no altitude stands on the ground; an aircraft does not', () => {
const store = records(() => fleetSet);
const noAltitude = { baroAltitudeM: null, ellipsoidAltitudeM: null };
const vehicle = store.receive(
observation({
id: 'v1',
typeCode: 'ZVEH',
callsign: 'FIRE 2',
...noAltitude,
}),
view(),
).meta;
assert.equal(vehicle.onGround, true);
assert.equal(vehicle.typeCode, 'ZVEH');
assert.equal(vehicle.altitude, 0);
const mast = store.receive(
observation({ id: 'm1', typeCode: 'GRND', callsign: 'RX5', ...noAltitude }),
view(),
).meta;
assert.equal(mast.onGround, true);
assert.equal(mast.typeCode, 'GRND');
// An airliner with no altitude keeps the airborne default, and its feed
// type still waits for the airframe lookup.
const plane = store.receive(
observation({ id: 'p1', typeCode: 'A320', ...noAltitude }),
view(),
).meta;
assert.equal(plane.onGround, false);
assert.equal(plane.altitude, 10000);
assert.equal(plane.typeCode, null);
// A vehicle reporting an altitude is placed by it.
const reported = store.receive(
observation({ id: 'v2', typeCode: 'ZVEH', baroAltitudeM: 12 }),
view(),
).meta;
assert.equal(reported.onGround, false);
// Before the model set loads nothing is grounded by its model.
const unloaded = records().receive(
observation({ id: 'v3', typeCode: 'ZVEH', ...noAltitude }),
view(),
).meta;
assert.equal(unloaded.onGround, false);
});
+1
View File
@@ -22,6 +22,7 @@ const specOf = (marker) =>
aircraftLayerModelSpec({
typeCode: marker?.typeCode,
category: marker?.category,
callsign: marker?.record?.callsign,
});
/**