Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
6 changes: 4 additions & 2 deletions app/static/detail_panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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),
);
}
}
Expand Down
44 changes: 32 additions & 12 deletions app/static/icons.js
Original file line number Diff line number Diff line change
@@ -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];
Expand Down Expand Up @@ -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;
Expand Down
58 changes: 58 additions & 0 deletions app/static/silhouette.js
Original file line number Diff line number Diff line change
@@ -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<TYPE, [shape, scale]> (e.g. "B738")
// - typeDescriptionIcons: map<DESC | DESC-WTC | KIND, [shape, scale]>
// (e.g. "L1P", "L2J-M", "H")
// - categoryIcons: map<SET+CAT, [shape, scale]> (e.g. "A1")
// - typeDesignatorMeta: map<TYPE, { desc, wtc }> (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;
}
6 changes: 3 additions & 3 deletions app/static/update_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions dotnet/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions dotnet/src/FlightJar.Api/Hosting/RegistryWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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)
{
Expand Down Expand Up @@ -480,6 +488,8 @@ private RegistrySnapshot EnrichSnapshot(RegistrySnapshot snap)

aircraft.Add(ac with
{
TypeIcao = typeIcao,
TypeLong = typeLong,
Origin = origin,
Destination = destination,
OriginInfo = originInfo,
Expand Down
8 changes: 8 additions & 0 deletions dotnet/src/FlightJar.Core/State/Aircraft.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ public sealed class Aircraft
public string? Callsign { get; set; }
public int? Category { get; set; }

/// <summary>ADS-B emitter category set letter — 'A' / 'B' / 'C' / 'D',
/// derived from the BDS-08 typecode that delivered <see cref="Category"/>.
/// 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.</summary>
public char? CategorySet { get; set; }

public double? Lat { get; set; }
public double? Lon { get; set; }
public int? AltitudeBaro { get; set; }
Expand Down
14 changes: 14 additions & 0 deletions dotnet/src/FlightJar.Core/State/AircraftRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions dotnet/src/FlightJar.Core/State/PeerMerge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ public sealed record SnapshotAircraft
public string? Callsign { get; init; }
public int? Category { get; init; }

/// <summary>ADS-B emitter category set — "A" / "B" / "C" / "D". Pairs
/// with <see cref="Category"/> to form the full identifier ("A1",
/// "B6", etc.) used by the frontend silhouette fallback.</summary>
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; }
Expand Down
11 changes: 11 additions & 0 deletions dotnet/tests/FlightJar.Core.Tests/State/AircraftRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
22 changes: 21 additions & 1 deletion dotnet/tests/FlightJar.Core.Tests/State/FakeDecoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ internal static class FakeDecoder
{
public static readonly IReadOnlyDictionary<string, DecodedMessage> Messages = new Dictionary<string, DecodedMessage>
{
// DF17 identification (TC 4): callsign + category
// DF17 identification (TC 4): callsign + Set A category
["ID01"] = new()
{
Df = 17,
Expand All @@ -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()
{
Expand Down
15 changes: 15 additions & 0 deletions dotnet/tests/FlightJar.Core.Tests/State/PeerMergeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading
Loading