mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +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
+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