diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e5c7d..ca4a45f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,7 @@ jobs: # a complete page. Python interpreter is preinstalled on GitHub runners. env: TAR1090_SHAPES_DEST: ${{ github.workspace }}/app/static/tar1090_shapes.js + TAR1090_TYPES_DEST: ${{ github.workspace }}/app/static/icao_aircraft_types.js run: python3 scripts/fetch_plane_shapes.py - name: Publish backend for Playwright diff --git a/.gitignore b/.gitignore index a63d963..929bb2d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ app/aircraft_db.csv.gz app/airports.csv app/navaids.csv app/static/tar1090_shapes.js +app/static/icao_aircraft_types.js # Editors / OS .vscode/ diff --git a/app/static/detail_panel.js b/app/static/detail_panel.js index 9137037..2679229 100644 --- a/app/static/detail_panel.js +++ b/app/static/detail_panel.js @@ -884,7 +884,8 @@ export function openDetailPanel(icao) { if (entry) { entry.marker.setIcon( planeIcon(a.track, altColor(a.altitude), true, !!a.emergency, - !!a.position_source && a.position_source !== 'adsb', a.type_icao), + !!a.position_source && a.position_source !== 'adsb', + a.type_icao, a.category, a.category_set), ); } document.querySelectorAll('.ac-item').forEach(el => { @@ -924,7 +925,8 @@ export function closeDetailPanel() { const a = entry.data; entry.marker.setIcon( planeIcon(a.track, altColor(a.altitude), false, !!a.emergency, - !!a.position_source && a.position_source !== 'adsb', a.type_icao), + !!a.position_source && a.position_source !== 'adsb', + a.type_icao, a.category, a.category_set), ); } } diff --git a/app/static/icons.js b/app/static/icons.js index 677ff46..771ea8d 100644 --- a/app/static/icons.js +++ b/app/static/icons.js @@ -1,25 +1,45 @@ -// Marker icon rendering. Prefers tar1090's per-type silhouette when the -// bundle is loaded and the aircraft's ICAO type code has a match; -// otherwise falls back to a generic triangular arrow. +// Marker icon rendering. Prefers tar1090's per-type silhouette via the +// four-tier fallback in `resolveSilhouette` (designator → description → +// category); falls back to a generic triangular arrow when none match +// or the bundles haven't loaded yet. // -// tar1090_shapes.js is loaded asynchronously (it's a big static file -// and failing to fetch it shouldn't stop the app from running). Until -// it resolves we render the generic arrow for everyone; once it does, -// subsequent planeIcon() calls light up. +// tar1090_shapes.js and icao_aircraft_types.js are loaded asynchronously +// (they're big static files and failing to fetch them shouldn't stop the +// app from running). Until they resolve we render the generic arrow for +// everyone; once they do, subsequent planeIcon() calls light up. + +import { resolveSilhouette } from './silhouette.js'; let tar1090Shapes = null; let tar1090TypeIcons = null; +let tar1090TypeDescIcons = null; +let tar1090CategoryIcons = null; +let typeDesignatorMeta = null; import('./tar1090_shapes.js').then(mod => { tar1090Shapes = mod.shapes; tar1090TypeIcons = mod.TypeDesignatorIcons; + tar1090TypeDescIcons = mod.TypeDescriptionIcons; + tar1090CategoryIcons = mod.CategoryIcons; }).catch(e => { console.warn('tar1090 shapes unavailable — using generic arrow', e); }); -function tar1090ShapeFor(typeIcao) { - if (!tar1090Shapes || !tar1090TypeIcons || !typeIcao) return null; - const entry = tar1090TypeIcons[typeIcao.toUpperCase()]; +import('./icao_aircraft_types.js').then(mod => { + typeDesignatorMeta = mod.TypeDesignatorMeta; +}).catch(e => { + console.warn('ICAO type metadata unavailable — silhouette fallback narrowed', e); +}); + +function tar1090ShapeFor(typeIcao, category, categorySet) { + if (!tar1090Shapes) return null; + const entry = resolveSilhouette({ + typeIcao, category, categorySet, + typeDesignatorIcons: tar1090TypeIcons, + typeDescriptionIcons: tar1090TypeDescIcons, + categoryIcons: tar1090CategoryIcons, + typeDesignatorMeta, + }); if (!entry) return null; const [shapeName, scaleFactor] = entry; const shape = tar1090Shapes[shapeName]; @@ -86,8 +106,8 @@ function tar1090Icon(track, color, selected, emergency, relayed, shape, scaleFac const GENERIC_ARROW_PATH = 'M0,-10 L7,8 L0,4 L-7,8 Z'; const GENERIC_ARROW_SIZE = 26; -export function planeIcon(track, color, selected, emergency, relayed, typeIcao) { - const tar = tar1090ShapeFor(typeIcao); +export function planeIcon(track, color, selected, emergency, relayed, typeIcao, category, categorySet) { + const tar = tar1090ShapeFor(typeIcao, category, categorySet); if (tar) return tar1090Icon(track, color, selected, emergency, relayed, tar.shape, tar.scaleFactor); const rot = track == null ? 0 : track; diff --git a/app/static/silhouette.js b/app/static/silhouette.js new file mode 100644 index 0000000..5f45c89 --- /dev/null +++ b/app/static/silhouette.js @@ -0,0 +1,58 @@ +// Pure resolver for the tar1090 silhouette fallback chain. Lives apart +// from icons.js so it can be unit-tested without a Leaflet stub. +// +// `resolveSilhouette` mirrors tar1090's getBaseMarker fallback (minus the +// halloween / ATC-style branches we don't carry). Given an aircraft's +// type designator, ADS-B category + set letter, and the four lookup +// tables, returns the [shapeName, scaleFactor] entry that the marker +// renderer should use, or null when nothing matches and the caller +// should draw the generic arrow. +// +// Tables (all four are optional — pass null when one isn't loaded yet): +// - typeDesignatorIcons: map (e.g. "B738") +// - typeDescriptionIcons: map +// (e.g. "L1P", "L2J-M", "H") +// - categoryIcons: map (e.g. "A1") +// - typeDesignatorMeta: map (e.g. C172 -> { desc:"L1P", wtc:"L" }) + +export function resolveSilhouette({ + typeIcao, + category, + categorySet, + typeDesignatorIcons, + typeDescriptionIcons, + categoryIcons, + typeDesignatorMeta, +}) { + // 1. Most specific: ICAO type designator. + if (typeDesignatorIcons && typeIcao) { + const entry = typeDesignatorIcons[typeIcao.toUpperCase()]; + if (entry) return entry; + } + // 2. ICAO type description ("L1P", "L2J", "H1T", ...) ± wake turbulence. + if (typeDescriptionIcons && typeDesignatorMeta && typeIcao) { + const meta = typeDesignatorMeta[typeIcao.toUpperCase()]; + const desc = meta?.desc; + const wtc = meta?.wtc; + if (desc && desc.length === 3) { + if (wtc && wtc.length === 1) { + const withWtc = typeDescriptionIcons[`${desc}-${wtc}`]; + if (withWtc) return withWtc; + } + const bare = typeDescriptionIcons[desc]; + if (bare) return bare; + const basic = typeDescriptionIcons[desc.charAt(0)]; + if (basic) return basic; + } + } + // 3. ADS-B emitter category. The set letter ("A"/"B"/"C") came from the + // BDS-08 typecode in the backend; default to "A" when the backend + // didn't capture it (older snapshots, peer aircraft from a pre-field + // build) since A is by far the most common set. + if (categoryIcons && category != null && category > 0) { + const setLetter = (categorySet || 'A').toString().toUpperCase(); + const entry = categoryIcons[`${setLetter}${category}`]; + if (entry) return entry; + } + return null; +} diff --git a/app/static/update_loop.js b/app/static/update_loop.js index b70b488..e8f3a5e 100644 --- a/app/static/update_loop.js +++ b/app/static/update_loop.js @@ -89,10 +89,10 @@ export function update(snap) { // Any non-direct source — MLAT / TIS-B / ADS-R — gets the same // dashed amber marker. The detail panel chip distinguishes them. const isRelayed = a.position_source && a.position_source !== 'adsb'; - const iconFp = `${altBand}|${isSelected ? 1 : 0}|${a.emergency ? 1 : 0}|${isRelayed ? 1 : 0}|${a.type_icao || ''}`; + const iconFp = `${altBand}|${isSelected ? 1 : 0}|${a.emergency ? 1 : 0}|${isRelayed ? 1 : 0}|${a.type_icao || ''}|${a.category || ''}${a.category_set || ''}`; let entry = state.aircraft.get(a.icao); if (!entry) { - const icon = planeIcon(a.track, color, isSelected, !!a.emergency, isRelayed, a.type_icao); + const icon = planeIcon(a.track, color, isSelected, !!a.emergency, isRelayed, a.type_icao, a.category, a.category_set); const marker = L.marker([a.lat, a.lon], { icon }).addTo(state.map); const trail = L.layerGroup().addTo(state.map); marker.on('click', () => selectAircraft(a.icao)); @@ -140,7 +140,7 @@ export function update(snap) { // updates, rotate the existing element in place. if (entry.iconFp !== iconFp) { entry.marker.setIcon( - planeIcon(a.track, color, isSelected, !!a.emergency, isRelayed, a.type_icao) + planeIcon(a.track, color, isSelected, !!a.emergency, isRelayed, a.type_icao, a.category, a.category_set) ); entry.iconFp = iconFp; entry.lastTrack = a.track; diff --git a/dotnet/Dockerfile b/dotnet/Dockerfile index a11c43b..a46a39e 100644 --- a/dotnet/Dockerfile +++ b/dotnet/Dockerfile @@ -93,6 +93,7 @@ COPY app/static /out/static COPY scripts/fetch_plane_shapes.py /tmp/fetch_plane_shapes.py RUN echo "data fetch ${DATA_CACHEBUST}" \ && TAR1090_SHAPES_DEST=/out/static/tar1090_shapes.js \ + TAR1090_TYPES_DEST=/out/static/icao_aircraft_types.js \ python3 /tmp/fetch_plane_shapes.py # esbuild is a standalone Go binary; we pull it straight from npm's diff --git a/dotnet/src/FlightJar.Api/Hosting/RegistryWorker.cs b/dotnet/src/FlightJar.Api/Hosting/RegistryWorker.cs index 0c85fd1..d356b10 100644 --- a/dotnet/src/FlightJar.Api/Hosting/RegistryWorker.cs +++ b/dotnet/src/FlightJar.Api/Hosting/RegistryWorker.cs @@ -406,6 +406,8 @@ private RegistrySnapshot EnrichSnapshot(RegistrySnapshot snap) string? operatorCountry = null; string? countryIso = null; string? manufacturer = null; + string? typeIcao = ac.TypeIcao; + string? typeLong = ac.TypeLong; if (_adsbdb is not null) { @@ -430,6 +432,12 @@ private RegistrySnapshot EnrichSnapshot(RegistrySnapshot snap) operatorCountry = acCached.Data.OperatorCountry; countryIso = acCached.Data.OperatorCountryIso; manufacturer = acCached.Data.Manufacturer; + // Tar1090-db is the primary source for type designator + // because it's the most consistent (uppercase ICAO codes); + // adsbdb fills the gap for tails it doesn't ship — recent + // registrations, GA, military. + typeIcao ??= acCached.Data.IcaoType; + typeLong ??= acCached.Data.Type; } else if (!acCached.Known) { @@ -480,6 +488,8 @@ private RegistrySnapshot EnrichSnapshot(RegistrySnapshot snap) aircraft.Add(ac with { + TypeIcao = typeIcao, + TypeLong = typeLong, Origin = origin, Destination = destination, OriginInfo = originInfo, diff --git a/dotnet/src/FlightJar.Core/State/Aircraft.cs b/dotnet/src/FlightJar.Core/State/Aircraft.cs index a7cd792..89f98c3 100644 --- a/dotnet/src/FlightJar.Core/State/Aircraft.cs +++ b/dotnet/src/FlightJar.Core/State/Aircraft.cs @@ -12,6 +12,14 @@ public sealed class Aircraft public string? Callsign { get; set; } public int? Category { get; set; } + /// ADS-B emitter category set letter — 'A' / 'B' / 'C' / 'D', + /// derived from the BDS-08 typecode that delivered . + /// Pairs with the 3-bit subcategory: e.g. typecode 4 + subcategory 1 + /// → "A1" (Light). Needed by the frontend silhouette fallback to + /// distinguish Set B (gliders, balloons, UAVs) and Set C (surface + /// vehicles) from the default Set A. + public char? CategorySet { get; set; } + public double? Lat { get; set; } public double? Lon { get; set; } public int? AltitudeBaro { get; set; } diff --git a/dotnet/src/FlightJar.Core/State/AircraftRegistry.cs b/dotnet/src/FlightJar.Core/State/AircraftRegistry.cs index 18bb847..25931e7 100644 --- a/dotnet/src/FlightJar.Core/State/AircraftRegistry.cs +++ b/dotnet/src/FlightJar.Core/State/AircraftRegistry.cs @@ -157,6 +157,19 @@ private bool IngestAdsb(DecodedMessage r, double now, long? mlatTicks) if (r.Category is int cat) { ac.Category = cat; + // Typecode 1-4 maps to set D/C/B/A. Set A is "powered + // aircraft" (the vast majority of ADS-B traffic); Set B + // covers gliders / balloons / UAVs; Set C covers surface + // vehicles. The frontend uses the set letter to pick the + // right silhouette when no specific designator matches. + ac.CategorySet = tc switch + { + 4 => 'A', + 3 => 'B', + 2 => 'C', + 1 => 'D', + _ => ac.CategorySet, + }; } } else if (tc >= 5 && tc <= 8) @@ -623,6 +636,7 @@ public RegistrySnapshot Snapshot(double? now = null) Icao = ac.Icao, Callsign = ac.Callsign, Category = ac.Category, + CategorySet = ac.CategorySet?.ToString(), Registration = dbInfo?.Registration, TypeIcao = dbInfo?.TypeIcao, TypeLong = dbInfo?.TypeLong, diff --git a/dotnet/src/FlightJar.Core/State/PeerMerge.cs b/dotnet/src/FlightJar.Core/State/PeerMerge.cs index 94f599e..642465e 100644 --- a/dotnet/src/FlightJar.Core/State/PeerMerge.cs +++ b/dotnet/src/FlightJar.Core/State/PeerMerge.cs @@ -60,6 +60,7 @@ public static SnapshotAircraft Combine(SnapshotAircraft local, SnapshotAircraft // Identity / DB enrichment — peer fills gaps. Callsign = local.Callsign ?? peer.Callsign, Category = local.Category ?? peer.Category, + CategorySet = local.CategorySet ?? peer.CategorySet, Registration = local.Registration ?? peer.Registration, TypeIcao = local.TypeIcao ?? peer.TypeIcao, TypeLong = local.TypeLong ?? peer.TypeLong, diff --git a/dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs b/dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs index 8324415..e0f7178 100644 --- a/dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs +++ b/dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs @@ -84,6 +84,11 @@ public sealed record SnapshotAircraft public string? Callsign { get; init; } public int? Category { get; init; } + /// ADS-B emitter category set — "A" / "B" / "C" / "D". Pairs + /// with to form the full identifier ("A1", + /// "B6", etc.) used by the frontend silhouette fallback. + public string? CategorySet { get; init; } + // Enriched from the aircraft DB (optional; null when no DB attached). public string? Registration { get; init; } public string? TypeIcao { get; init; } diff --git a/dotnet/tests/FlightJar.Core.Tests/State/AircraftRegistryTests.cs b/dotnet/tests/FlightJar.Core.Tests/State/AircraftRegistryTests.cs index 0fcd3f8..9bd1adb 100644 --- a/dotnet/tests/FlightJar.Core.Tests/State/AircraftRegistryTests.cs +++ b/dotnet/tests/FlightJar.Core.Tests/State/AircraftRegistryTests.cs @@ -26,9 +26,20 @@ public void Ingest_AdsbIdentification_PopulatesCallsignAndCategory() var ac = reg.Aircraft["abc123"]; Assert.Equal("FLY123", ac.Callsign); Assert.Equal(3, ac.Category); + Assert.Equal('A', ac.CategorySet); // TC 4 → Set A (powered aircraft) Assert.Equal(1, ac.MsgCount); } + [Theory] + [InlineData("IDB1glider", "abcb01", 'B')] + [InlineData("IDC2surface", "abcc02", 'C')] + public void Ingest_AdsbIdentification_DerivesCategorySetFromTypecode(string hex, string icao, char expectedSet) + { + var reg = MakeRegistry(); + Assert.True(reg.Ingest(hex, now: 100.0)); + Assert.Equal(expectedSet, reg.Aircraft[icao].CategorySet); + } + [Fact] public void Ingest_Df4Altitude() { diff --git a/dotnet/tests/FlightJar.Core.Tests/State/FakeDecoder.cs b/dotnet/tests/FlightJar.Core.Tests/State/FakeDecoder.cs index 2d7b830..7976bba 100644 --- a/dotnet/tests/FlightJar.Core.Tests/State/FakeDecoder.cs +++ b/dotnet/tests/FlightJar.Core.Tests/State/FakeDecoder.cs @@ -11,7 +11,7 @@ internal static class FakeDecoder { public static readonly IReadOnlyDictionary Messages = new Dictionary { - // DF17 identification (TC 4): callsign + category + // DF17 identification (TC 4): callsign + Set A category ["ID01"] = new() { Df = 17, @@ -21,6 +21,26 @@ internal static class FakeDecoder Callsign = "FLY123__", Category = 3, }, + // DF17 identification (TC 3): Set B — e.g. glider / balloon / UAV. + ["IDB1"] = new() + { + Df = 17, + CrcValid = true, + Icao = "abcb01", + Typecode = 3, + Callsign = "GLIDER__", + Category = 1, + }, + // DF17 identification (TC 2): Set C — surface vehicle. + ["IDC2"] = new() + { + Df = 17, + CrcValid = true, + Icao = "abcc02", + Typecode = 2, + Callsign = "TUG123__", + Category = 2, + }, // DF17 airborne baro position (TC 11) ["AP01"] = new() { diff --git a/dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs b/dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs index 1fb16aa..a7a8d19 100644 --- a/dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs +++ b/dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs @@ -253,4 +253,19 @@ public void Combine_FillsAltitudeAndVelocityFromPeer() Assert.Equal(-200, merged.Vrate); Assert.Equal("1234", merged.Squawk); } + + [Fact] + public void Combine_FillsCategorySetFromPeer() + { + // Local: BDS-08 ident hasn't arrived yet (no category or set). + // Peer: already saw the ident. Both fields propagate; frontend + // uses them together for CategoryIcons["A1"]-style lookups. + var local = new SnapshotAircraft { Icao = "abc123" }; + var peer = new SnapshotAircraft { Icao = "abc123", Category = 6, CategorySet = "B" }; + + var merged = PeerMerge.Combine(local, peer); + + Assert.Equal(6, merged.Category); + Assert.Equal("B", merged.CategorySet); + } } diff --git a/scripts/fetch_plane_shapes.py b/scripts/fetch_plane_shapes.py index 3f37123..1f22909 100644 --- a/scripts/fetch_plane_shapes.py +++ b/scripts/fetch_plane_shapes.py @@ -5,11 +5,20 @@ `TypeDesignatorIcons`. We grab the raw source, turn its top-level data declarations into ES exports, and ship the result unchanged otherwise. -Source: https://github.com/wiedehopf/tar1090 (GPL-2.0+, compatible with -Flightjar's GPL-3.0). Override the URL via the TAR1090_MARKERS_URL env -var if needed. +Also fetches tar1090-db's `icao_aircraft_types.json` — the lookup that +turns an ICAO type designator (e.g. "C172") into its ICAO type +description ("L1P") + wake turbulence category ("L"). The frontend uses +these to drive tar1090's full fallback chain when a specific designator +isn't in `TypeDesignatorIcons`. + +Sources: +- https://github.com/wiedehopf/tar1090 (GPL-2.0+, compatible with FlightJar's GPL-3.0) +- https://github.com/wiedehopf/tar1090-db (CC0) + +Override the URLs via TAR1090_MARKERS_URL / TAR1090_TYPES_URL if needed. """ +import json import os import sys import urllib.request @@ -22,6 +31,14 @@ "TAR1090_SHAPES_DEST", "/app/app/static/tar1090_shapes.js", ) +TYPES_URL = os.environ.get( + "TAR1090_TYPES_URL", + "https://raw.githubusercontent.com/wiedehopf/tar1090-db/master/icao_aircraft_types.json", +) +TYPES_DEST = os.environ.get( + "TAR1090_TYPES_DEST", + "/app/app/static/icao_aircraft_types.js", +) # Top-level bindings we want to surface as ES module exports. Everything # else in markers.js (helper functions, sprite code, iconTest) is left # untouched — function declarations don't run on import, so they're @@ -61,8 +78,39 @@ def main() -> int: with open(DEST, "w", encoding="utf-8") as f: f.write(header + data) print(f"wrote {DEST} ({len(data)} bytes)", file=sys.stderr) + + write_types_module() return 0 +def write_types_module() -> None: + print(f"fetching {TYPES_URL}", file=sys.stderr) + with urllib.request.urlopen(TYPES_URL, timeout=60) as r: + types_raw = r.read().decode("utf-8") + types = json.loads(types_raw) + + # Drop empty entries to keep the bundle lean. The source has a small + # number of {} placeholders; rendering needs at least one of desc/wtc. + compact = { + designator: {k: v for k, v in entry.items() if k in ("desc", "wtc") and v} + for designator, entry in sorted(types.items()) + if any(entry.get(k) for k in ("desc", "wtc")) + } + body = "export const TypeDesignatorMeta = " + json.dumps( + compact, separators=(",", ":"), ensure_ascii=False + ) + ";\n" + header = ( + "// Auto-generated at build time by scripts/fetch_plane_shapes.py.\n" + "// Source: tar1090-db / icao_aircraft_types.json (CC0).\n" + "// https://github.com/wiedehopf/tar1090-db\n" + "// Maps ICAO type designator -> { desc: 'L2J', wtc: 'M' }, where\n" + "// `desc` is the ICAO type description and `wtc` is wake turbulence.\n" + ) + os.makedirs(os.path.dirname(TYPES_DEST), exist_ok=True) + with open(TYPES_DEST, "w", encoding="utf-8") as f: + f.write(header + body) + print(f"wrote {TYPES_DEST} ({len(body)} bytes, {len(compact)} entries)", file=sys.stderr) + + if __name__ == "__main__": sys.exit(main()) diff --git a/tests/js/silhouette.test.js b/tests/js/silhouette.test.js new file mode 100644 index 0000000..4d963a0 --- /dev/null +++ b/tests/js/silhouette.test.js @@ -0,0 +1,114 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { resolveSilhouette } from '../../app/static/silhouette.js'; + +// Minimal stand-in tables — just enough rows to exercise each fallback +// step in isolation. The real tables come from tar1090_shapes.js + +// icao_aircraft_types.js at build time; what we care about here is the +// dispatch order. +const TABLES = { + typeDesignatorIcons: { + B738: ['b738', 1.0], + A320: ['a320', 1.0], + }, + typeDescriptionIcons: { + L1P: ['cessna', 1.0], + 'L2J-M': ['airliner', 1.0], + L2J: ['airliner_bare', 1.0], + H: ['helicopter', 1.0], + }, + categoryIcons: { + A1: ['cessna', 1.0], + A2: ['jet_swept', 0.94], + A5: ['heavy_2e', 0.92], + A7: ['helicopter', 1.0], + B2: ['balloon', 1.0], + B6: ['uav', 1.0], + }, + typeDesignatorMeta: { + C172: { desc: 'L1P', wtc: 'L' }, + B738: { desc: 'L2J', wtc: 'M' }, + AS50: { desc: 'H1T', wtc: 'L' }, // 'H1T' not in description table; expect 'H' fallback + }, +}; + +function resolve(args) { + return resolveSilhouette({ ...TABLES, ...args }); +} + +test('step 1 — type designator hits TypeDesignatorIcons directly', () => { + assert.deepEqual(resolve({ typeIcao: 'B738' }), ['b738', 1.0]); + // Case-insensitive lookup. + assert.deepEqual(resolve({ typeIcao: 'b738' }), ['b738', 1.0]); +}); + +test('step 2 — unknown designator falls back to type description + WTC', () => { + // B738 IS in TypeDesignatorIcons, so that should win. Force the + // fallback by removing it from that table. + const out = resolveSilhouette({ + ...TABLES, + typeDesignatorIcons: {}, + typeIcao: 'B738', + }); + assert.deepEqual(out, ['airliner', 1.0]); // L2J-M with WTC suffix +}); + +test('step 2 — bare description matches when WTC suffix has no entry', () => { + const out = resolveSilhouette({ + ...TABLES, + typeDescriptionIcons: { + L1P: ['cessna', 1.0], + L2J: ['airliner_bare', 1.0], // no 'L2J-M' + }, + typeIcao: 'B738', + typeDesignatorIcons: {}, + }); + assert.deepEqual(out, ['airliner_bare', 1.0]); +}); + +test('step 2 — kind-letter fallback when neither description nor WTC suffix matches', () => { + // AS50 -> H1T which we deliberately omitted; expect kind 'H'. + const out = resolveSilhouette({ + ...TABLES, + typeIcao: 'AS50', + typeDesignatorIcons: {}, + }); + assert.deepEqual(out, ['helicopter', 1.0]); +}); + +test('step 1 — C172 with description but no designator entry falls to L1P', () => { + // This is the actual real-world case: tar1090-db knows C172 but + // TypeDesignatorIcons doesn't carry it. typeDesignatorMeta has + // { desc: "L1P", wtc: "L" }; the description table maps L1P to cessna. + const out = resolve({ typeIcao: 'C172' }); + assert.deepEqual(out, ['cessna', 1.0]); +}); + +test('step 3 — unknown type + category falls to CategoryIcons (default Set A)', () => { + assert.deepEqual(resolve({ typeIcao: 'XXXX', category: 1 }), ['cessna', 1.0]); + assert.deepEqual(resolve({ typeIcao: null, category: 5 }), ['heavy_2e', 0.92]); +}); + +test('step 3 — explicit Set B distinguishes balloons / UAVs from Set A planes', () => { + assert.deepEqual(resolve({ category: 2, categorySet: 'B' }), ['balloon', 1.0]); + assert.deepEqual(resolve({ category: 6, categorySet: 'B' }), ['uav', 1.0]); + // Same subcategory bits with no set defaults to A2 (jet_swept), not B2 (balloon). + assert.deepEqual(resolve({ category: 2 }), ['jet_swept', 0.94]); +}); + +test('returns null when nothing matches and category is 0/missing', () => { + assert.equal(resolve({}), null); + assert.equal(resolve({ category: 0 }), null); + assert.equal(resolve({ typeIcao: 'ZZZZ', category: null }), null); +}); + +test('null tables are tolerated (bundles still loading)', () => { + assert.equal(resolveSilhouette({ + typeIcao: 'B738', category: 5, + typeDesignatorIcons: null, + typeDescriptionIcons: null, + categoryIcons: null, + typeDesignatorMeta: null, + }), null); +});