Add distinct marker and legend entry for ground vehicles on radar

Ground vehicles (ADS-B emitter category "C*") now render as filled
blue squares instead of the normal circle+arrowhead aircraft marker,
using a fixed color independent of altitude (they're practically
always at 0ft, which would otherwise collide with the low-altitude
green). A second legend line explains the marker, but only when
ground vehicles are actually visible (Flight Options > "Hide ground
vehicles" off) - the info bar height, and therefore the radar circle
size, is otherwise unchanged from before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-11 01:47:07 +02:00
parent aa07acebbf
commit 9f5da6e8b5
8 changed files with 85 additions and 5 deletions

View File

@@ -275,6 +275,13 @@ enum class StringId : uint8_t {
// gemeint ist (Preset-Name wie "Zuhause", nicht z.B. ein Ort/Adresse). // gemeint ist (Preset-Name wie "Zuhause", nicht z.B. ein Ort/Adresse).
LOCATION_NAME_HINT, LOCATION_NAME_HINT,
// Legenden-Eintrag auf dem Radar-Screen (radar_screen.cpp::drawLegend)
// fuer die blauen Quadrat-Marker der Bodenfahrzeuge - nur sichtbar,
// wenn "Bodenfahrzeuge ausblenden" AUS ist (Flugoptionen), da die
// Fahrzeuge dann als eigene Legenden-Zeile unter den drei
// Hoehen-Farbstufen erscheinen.
LEGEND_GROUND_VEHICLE,
COUNT COUNT
}; };

View File

@@ -229,6 +229,8 @@ static const char* const I18N_DE[] = {
" abgespeichert. Ab dem nächsten Start verbindet sich das WLAN automatisch.", " abgespeichert. Ab dem nächsten Start verbindet sich das WLAN automatisch.",
"z.B. Zuhause, Arbeit, Ferienhaus", "z.B. Zuhause, Arbeit, Ferienhaus",
"Bodenfahrzeug",
}; };
static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT,

View File

@@ -230,6 +230,8 @@ static const char* const I18N_EN[] = {
". From the next boot, WiFi will connect automatically.", ". From the next boot, WiFi will connect automatically.",
"e.g. Home, Work, Holiday home", "e.g. Home, Work, Holiday home",
"Ground vehicle",
}; };
static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT,

View File

@@ -229,6 +229,8 @@ static const char* const I18N_ES[] = {
". A partir del próximo inicio, el WiFi se conectará automáticamente.", ". A partir del próximo inicio, el WiFi se conectará automáticamente.",
"ej. Casa, Trabajo, Casa de vacaciones", "ej. Casa, Trabajo, Casa de vacaciones",
"Vehículo terrestre",
}; };
static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT,

View File

@@ -229,6 +229,8 @@ static const char* const I18N_FR[] = {
". À partir du prochain démarrage, le WiFi se connectera automatiquement.", ". À partir du prochain démarrage, le WiFi se connectera automatiquement.",
"ex. Maison, Travail, Maison de vacances", "ex. Maison, Travail, Maison de vacances",
"Véhicule au sol",
}; };
static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT,

View File

@@ -229,6 +229,8 @@ static const char* const I18N_IT[] = {
". Dal prossimo avvio, il WiFi si connetterà automaticamente.", ". Dal prossimo avvio, il WiFi si connetterà automaticamente.",
"es. Casa, Lavoro, Casa vacanze", "es. Casa, Lavoro, Casa vacanze",
"Veicolo a terra",
}; };
static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT,

View File

@@ -229,6 +229,8 @@ static const char* const I18N_TR[] = {
". Bir sonraki başlatmadan itibaren WiFi otomatik olarak bağlanacak.", ". Bir sonraki başlatmadan itibaren WiFi otomatik olarak bağlanacak.",
"örn. Ev, İş, Tatil evi", "örn. Ev, İş, Tatil evi",
"Kara aracı",
}; };
static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT, static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT,

View File

@@ -33,10 +33,20 @@ namespace {
}; };
constexpr int16_t INFO_BAR_H = 64; constexpr int16_t INFO_BAR_H = 64;
// Zusaetzliche Hoehe fuer die zweite Legenden-Zeile (Bodenfahrzeug-
// Marker-Erklaerung), nur reserviert, wenn Bodenfahrzeuge tatsaechlich
// angezeigt werden (SettingsStore::hideGroundVehicles() == false) -
// ansonsten bleibt die Infoleiste genau so hoch wie bisher und der
// Radarkreis behaelt seine gewohnte Groesse.
constexpr int16_t INFO_BAR_GROUND_ROW_H = 18;
int16_t infoBarHeight() {
return SettingsStore::hideGroundVehicles() ? INFO_BAR_H : (int16_t)(INFO_BAR_H + INFO_BAR_GROUND_ROW_H);
}
Layout computeLayout(int16_t top) { Layout computeLayout(int16_t top) {
Layout L; Layout L;
L.infoTop = Config::SCREEN_HEIGHT - INFO_BAR_H; L.infoTop = Config::SCREEN_HEIGHT - infoBarHeight();
constexpr int16_t TOP_LABEL_MARGIN = 10; constexpr int16_t TOP_LABEL_MARGIN = 10;
int16_t maxRadiusByWidth = Config::SCREEN_WIDTH / 2 - 6; int16_t maxRadiusByWidth = Config::SCREEN_WIDTH / 2 - 6;
int16_t maxRadiusByHeight = (L.infoTop - top - TOP_LABEL_MARGIN) / 2 - 6; int16_t maxRadiusByHeight = (L.infoTop - top - TOP_LABEL_MARGIN) / 2 - 6;
@@ -59,6 +69,7 @@ namespace {
float distanceKm; float distanceKm;
bool isEmergency; bool isEmergency;
bool isWatched; bool isWatched;
bool isGroundVehicle;
}; };
constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT; constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT;
HitPoint hitPoints[MAX_HIT_POINTS]; HitPoint hitPoints[MAX_HIT_POINTS];
@@ -228,6 +239,17 @@ namespace {
return nightDimActiveNow() ? gfx.color565(0, 110, 0) : TFT_GREEN; return nightDimActiveNow() ? gfx.color565(0, 110, 0) : TFT_GREEN;
} }
// Feste Blau-Farbe fuer Bodenfahrzeug-Marker (ADS-B-Emitter-Kategorie
// "C*", z.B. Flughafen-Fahrzeuge) - bewusst unabhaengig von
// colorForAltitude(), da Bodenfahrzeuge praktisch immer auf 0ft stehen
// und sonst dieselbe Farbe wie niedrig fliegende Flugzeuge haetten,
// was die optische Unterscheidung (Quadrat vs. Kreis) unnoetig
// erschweren wuerde. Bei Nachtdimmung gedaempft, gleiches Muster wie
// colorForAltitude().
uint16_t colorForGroundVehicle(TFT_eSPI& gfx) {
return nightDimActiveNow() ? gfx.color565(0, 0, 160) : TFT_BLUE;
}
// Zeigt die PEILUNG (nicht zu verwechseln mit der Flugrichtung/heading) // Zeigt die PEILUNG (nicht zu verwechseln mit der Flugrichtung/heading)
// zum aktuell ausgewaehlten Flugzeug: eine duenne, gepunktete Linie vom // zum aktuell ausgewaehlten Flugzeug: eine duenne, gepunktete Linie vom
// Radar-Zentrum zum Kreisrand in Richtung bearingDeg, plus die Gradzahl // Radar-Zentrum zum Kreisrand in Richtung bearingDeg, plus die Gradzahl
@@ -302,6 +324,15 @@ namespace {
gfx.drawLine(tipX, tipY, w2x, w2y, color); gfx.drawLine(tipX, tipY, w2x, w2y, color);
} }
// Eigener Marker fuer Bodenfahrzeuge (ADS-B-Kategorie "C*") - ein
// gefuelltes Quadrat statt Kreis+Pfeilkopf, damit sie auf dem Radar
// klar von echten Flugzeugen zu unterscheiden sind (Heading/Kurs ist
// bei Bodenfahrzeugen ausserdem meist nicht aussagekraeftig).
void drawGroundVehicleMarker(TFT_eSPI& gfx, int16_t x, int16_t y, uint16_t color) {
constexpr int16_t HALF = 4;
gfx.fillRect((int16_t)(x - HALF), (int16_t)(y - HALF), (int16_t)(2 * HALF), (int16_t)(2 * HALF), color);
}
void printLineTruncated(TFT_eSPI& gfx, int16_t x, int16_t y, int16_t maxWidth, const String& text) { void printLineTruncated(TFT_eSPI& gfx, int16_t x, int16_t y, int16_t maxWidth, const String& text) {
String s = text; String s = text;
if (gfx.textWidth(s) > maxWidth) { if (gfx.textWidth(s) > maxWidth) {
@@ -358,6 +389,18 @@ namespace {
gfx.setCursor(x0 + 7, y); gfx.setCursor(x0 + 7, y);
gfx.print(items[i].label); gfx.print(items[i].label);
} }
// Zweite Legenden-Zeile fuer die blauen Bodenfahrzeug-Quadrate - nur
// wenn sie ueberhaupt sichtbar sind (Flugoptionen >
// "Bodenfahrzeuge ausblenden" == aus). infoBarHeight() reserviert
// den dafuer noetigen zusaetzlichen Platz nur in diesem Fall, siehe
// computeLayout().
if (!SettingsStore::hideGroundVehicles()) {
int16_t gy = (int16_t)(y + INFO_BAR_GROUND_ROW_H);
gfx.fillRect(3, (int16_t)(gy - 8), 6, 6, colorForGroundVehicle(gfx));
gfx.setCursor(13, gy);
gfx.print(I18n::t(StringId::LEGEND_GROUND_VEHICLE));
}
} }
void drawWorldMap(TFT_eSPI& gfx, const Layout& L) { void drawWorldMap(TFT_eSPI& gfx, const Layout& L) {
@@ -629,7 +672,12 @@ void render(TFT_eSPI& tft, int16_t top) {
RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg}; RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg};
RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm); RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm);
uint16_t color = colorForAltitude(tft, a.altBaroFt); // ADS-B-Emitter-Kategorie "C*" = Bodenfahrzeug (nur ueberhaupt
// sichtbar, wenn "Bodenfahrzeuge ausblenden" aus ist, siehe Filter
// oben) - eigene Farbe/Marker statt der hoehenbasierten Flugzeug-
// Darstellung, siehe drawGroundVehicleMarker()/colorForGroundVehicle().
bool isGroundVehicle = a.category[0] == 'C';
uint16_t color = isGroundVehicle ? colorForGroundVehicle(tft) : colorForAltitude(tft, a.altBaroFt);
bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0; bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0;
bool isEmergency = SettingsStore::emergencyAlertEnabled() && isEmergencySquawk(a.squawk); bool isEmergency = SettingsStore::emergencyAlertEnabled() && isEmergencySquawk(a.squawk);
bool isWatched = SettingsStore::watchlistAlertEnabled() && AircraftWatchlist::isWatched(a.callsign); bool isWatched = SettingsStore::watchlistAlertEnabled() && AircraftWatchlist::isWatched(a.callsign);
@@ -640,7 +688,11 @@ void render(TFT_eSPI& tft, int16_t top) {
selected = a; selected = a;
drawBearingIndicator(tft, L, a.bearingDeg); drawBearingIndicator(tft, L, a.bearingDeg);
} }
drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color); if (isGroundVehicle) {
drawGroundVehicleMarker(tft, pt.x, pt.y, color);
} else {
drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color);
}
if (isEmergency) { if (isEmergency) {
tft.drawCircle(pt.x, pt.y, 12, TFT_RED); tft.drawCircle(pt.x, pt.y, 12, TFT_RED);
@@ -662,6 +714,7 @@ void render(TFT_eSPI& tft, int16_t top) {
hitPoints[i].distanceKm = a.distanceKm; hitPoints[i].distanceKm = a.distanceKm;
hitPoints[i].isEmergency = isEmergency; hitPoints[i].isEmergency = isEmergency;
hitPoints[i].isWatched = isWatched; hitPoints[i].isWatched = isWatched;
hitPoints[i].isGroundVehicle = isGroundVehicle;
strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1); strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1);
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1); strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
} }
@@ -747,7 +800,11 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
// einfach in Schwarz nachgezeichnet - das macht sie pixelgenau // einfach in Schwarz nachgezeichnet - das macht sie pixelgenau
// wieder unsichtbar, ohne irgendetwas ausserhalb dieser Formen zu // wieder unsichtbar, ohne irgendetwas ausserhalb dieser Formen zu
// beruehren. // beruehren.
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK); if (hp.isGroundVehicle) {
drawGroundVehicleMarker(tft, hp.x, hp.y, TFT_BLACK);
} else {
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK);
}
if (hp.isEmergency || hp.isWatched) { if (hp.isEmergency || hp.isWatched) {
tft.drawCircle(hp.x, hp.y, 12, TFT_BLACK); tft.drawCircle(hp.x, hp.y, 12, TFT_BLACK);
} }
@@ -759,7 +816,11 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
continue; continue;
} }
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color); if (hp.isGroundVehicle) {
drawGroundVehicleMarker(tft, hp.x, hp.y, hp.color);
} else {
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color);
}
if (hp.isEmergency) { if (hp.isEmergency) {
tft.drawCircle(hp.x, hp.y, 12, TFT_RED); tft.drawCircle(hp.x, hp.y, 12, TFT_RED);