mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
feat(fleet): draw airport ground vehicles by callsign and ground stations as a radio mast
Feeds carry many airport ground vehicles (type ZVEH) and ground stations (GRND) that were drawn as the generic plane, some at the 10 km airborne default. - types.json gains aircraft_callsign rules: a ZVEH callsign starting FIRE is a fire truck, PUSH/TUG a pushback tug, DEICER/SQUID/SQB a van, else a car. GRND draws a new 11.8 m radio mast (other/radio_mast.glb). - resolveAircraft and aircraftModelSpec take the callsign; a matched rule names the model outright, ahead of the type's class model. - The layer spec cache keys such types by the rule matched, so it holds at most one entry per rule. - These models (catalog category `other`) draw at their built size, as such models already do, and a contact with no altitude whose model is one now stands on the ground; the feed's own type is kept for such contacts, as no airframe lookup knows it. - Local ADS-B passes the callsign too.
This commit is contained in:
1 parent
083da40d88
commit
6e655b801d
14 files changed
+402
-24
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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'}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const specOf = (marker) =>
|
||||
aircraftLayerModelSpec({
|
||||
typeCode: marker?.typeCode,
|
||||
category: marker?.category,
|
||||
callsign: marker?.record?.callsign,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user