Files
flyradar/src/radar_screen.cpp
Eiswolf-BG 7137e50d66 v2.7.3: Flight logbook safety net, radar/clock/timezone fixes
New:
- Flight logbook safety net: disabled by default, requires confirming
  a full-screen warning before it can be turned on, and automatically
  switches off again after exactly 24 hours - survives restarts and
  power cycles (no valid enable timestamp = guaranteed off). Each
  activation now writes its own CSV file instead of endlessly
  appending to one growing file, and every file can be deleted
  individually from Logbook files.
- Adjustable display brightness (10-100% in 10% steps, Menu -> System
  -> "Brightness") with a live preview.
- About screen (Menu -> System -> "About") showing project name,
  description, and firmware version.

Fixed:
- Radar's red altitude band (>9100m/30000ft) no longer gets dimmed at
  night - the previous dim tone looked orange-brown and was hard to
  tell apart from yellow. Legend and markers are consistent again.
- Radius change no longer leaves a stale "tail" trail behind each
  aircraft between the old and new position.
- Splash screen no longer shows a time-of-day greeting that appeared
  too late in the boot sequence to be useful; the star animation now
  starts immediately instead of only near the end of boot.
- Clock stayed on raw UTC after the first boot instead of local time
  (missing DST handling in some timezones) because the location wait
  loop skipped the timezone lookup once a location was already saved.
- Header clock no longer leaves stale digit fragments behind the
  current time, most visible on the last minute digit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 14:02:01 +02:00

891 lines
35 KiB
C++

#include "radar_screen.h"
#include "config.h"
#include "aircraft.h"
#include "aircraft_table.h"
#include "airline_lookup.h"
#include "aircraft_details.h"
#include "radar_math.h"
#include "units.h"
#include "settings_store.h"
#include "led_alert.h"
#include "location_manager.h"
#include "world_map.h"
#include "airline_filter.h"
#include "aircraft_watchlist.h"
#include "i18n.h"
#include <math.h>
#include <time.h>
namespace RadarScreen {
namespace {
struct Rect {
int16_t x, y, w, h;
bool contains(int16_t px, int16_t py) const {
return px >= x && px < x + w && py >= y && py < y + h;
}
};
struct Layout {
int16_t cx, cy, radius;
Rect rangeBtn;
int16_t infoTop;
};
constexpr int16_t INFO_BAR_H = 64;
Layout computeLayout(int16_t top) {
Layout L;
L.infoTop = Config::SCREEN_HEIGHT - INFO_BAR_H;
constexpr int16_t TOP_LABEL_MARGIN = 10;
int16_t maxRadiusByWidth = Config::SCREEN_WIDTH / 2 - 6;
int16_t maxRadiusByHeight = (L.infoTop - top - TOP_LABEL_MARGIN) / 2 - 6;
L.radius = min(maxRadiusByWidth, maxRadiusByHeight);
L.cx = Config::SCREEN_WIDTH / 2;
L.cy = top + L.radius + 6 + TOP_LABEL_MARGIN;
L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 70), (int16_t)(L.infoTop + 4), 62, 22};
return L;
}
constexpr int16_t DETAIL_PANEL_H = 264;
struct HitPoint {
int16_t x, y;
bool valid;
char hex[7];
char callsign[9];
uint16_t color;
float headingDeg;
float distanceKm;
bool isEmergency;
bool isWatched;
};
constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT;
HitPoint hitPoints[MAX_HIT_POINTS];
char selectedHex[7] = {0};
uint32_t lastEmptyTapMs = 0;
int16_t lastEmptyTapX = -1000;
int16_t lastEmptyTapY = -1000;
constexpr uint32_t DOUBLE_TAP_MS = 400;
constexpr int16_t DOUBLE_TAP_RADIUS = 25;
bool ledBlinkOn = true;
bool isEmergencySquawk(const char* squawk) {
if (!squawk[0]) return false;
for (uint8_t i = 0; i < Config::EMERGENCY_SQUAWK_COUNT; i++) {
if (strcmp(squawk, Config::EMERGENCY_SQUAWKS[i]) == 0) return true;
}
return false;
}
float sweepAngleDeg = 0.0f;
float prevSweepAngleDeg = -1.0f;
constexpr float SWEEP_DEGREES_PER_SEC = 45.0f;
// Gleiches Nachtdimm-Fenster wie main.cpp::isNightDimHours() (22:00-06:00,
// ueber Mitternacht hinweg gerechnet). Hier bewusst dupliziert statt
// geteilt, siehe CLAUDE.md-Konvention ("jeder Screen unabhaengig
// lauffaehig, kein gemeinsames Modul fuer solche Kleinigkeiten").
bool isNightHours() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return false; // Uhrzeit noch nicht per NTP synchronisiert
struct tm tmNow;
localtime_r(&now, &tmNow);
int hour = tmNow.tm_hour;
return (hour >= 22 || hour < 6);
}
// Nur aktiv, wenn der Nutzer die Nachtdimmung (Menue > System) eingeschaltet
// hat UND wir gerade im Nachtfenster sind - dieselbe Einstellung, die sonst
// nur die Hintergrundbeleuchtung dimmt, dimmt jetzt zusaetzlich auch die
// Radar-Farben (Flugzeug-Marker + Sweep-Linie), damit das Display nachts
// insgesamt weniger blendet.
bool nightDimActiveNow() {
return SettingsStore::nightDimmingEnabled() && isNightHours();
}
// Farbe je nach Flughoehe - gedaempft (dunkleres Gruen/Oliv) waehrend der
// Nachtdimmung. Bewusst eigene, etwas hellere Toene als
// dimColorForAltitude() weiter unten (das ist die IMMER dunklere
// Trail-Farbe hinter jedem Flugzeug) - sonst waeren Kopf-Marker und
// Trail nachts nicht mehr auseinanderzuhalten.
//
// Die rote Hoehenstufe wird bewusst NICHT gedaempft: der bisherige
// Dimm-Ton (160,30,0) sah auf dem Display eher orange-braun statt rot
// aus und war dadurch nicht mehr klar von der gelben Stufe zu
// unterscheiden. Rot bleibt deshalb Tag und Nacht der reine TFT_RED-Ton.
uint16_t colorForAltitude(TFT_eSPI& gfx, int32_t altFt) {
bool dim = nightDimActiveNow();
if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT)
return dim ? gfx.color565(0, 160, 0) : TFT_GREEN;
if (altFt < Config::COLOR_MID_ALT_THRESHOLD_FT)
return dim ? gfx.color565(160, 160, 0) : TFT_YELLOW;
return TFT_RED;
}
uint16_t sweepLineColor(TFT_eSPI& gfx) {
return nightDimActiveNow() ? gfx.color565(0, 110, 0) : TFT_GREEN;
}
// Zeigt die PEILUNG (nicht zu verwechseln mit der Flugrichtung/heading)
// zum aktuell ausgewaehlten Flugzeug: eine duenne, gepunktete Linie vom
// Radar-Zentrum zum Kreisrand in Richtung bearingDeg, plus die Gradzahl
// direkt ausserhalb des Rings. Hilft dabei, das Flugzeug tatsaechlich am
// Himmel zu finden ("in diese Richtung schauen"). Nur fuer den Moment
// des Zeichnens relevant - beim naechsten Sweep-Tick/Redraw wird sie vom
// normalen Hintergrund-Redraw automatisch mit geloescht und neu gesetzt.
void drawBearingIndicator(TFT_eSPI& gfx, const Layout& L, float bearingDeg) {
double rad = bearingDeg * PI / 180.0;
double s = sin(rad), c = cos(rad);
// Gepunktete Linie: kurze Segmente statt einer durchgezogenen Linie,
// damit sie sich optisch von den Kompass-Kreuzlinien und Ringen
// unterscheidet und nicht wie ein staendiges UI-Element wirkt.
constexpr uint8_t DOT_COUNT = 10;
for (uint8_t i = 0; i < DOT_COUNT; i++) {
if (i % 2 != 0) continue; // nur jedes zweite Segment zeichnen
float t0 = (float)i / DOT_COUNT;
float t1 = (float)(i + 1) / DOT_COUNT;
int16_t x0 = L.cx + (int16_t)lround(t0 * L.radius * s);
int16_t y0 = L.cy - (int16_t)lround(t0 * L.radius * c);
int16_t x1 = L.cx + (int16_t)lround(t1 * L.radius * s);
int16_t y1 = L.cy - (int16_t)lround(t1 * L.radius * c);
gfx.drawLine(x0, y0, x1, y1, TFT_WHITE);
}
// Gradzahl knapp ausserhalb des Rings, an der Stelle wo die Peilung
// den Kreisrand durchstoesst. Der Radius-Kreis nutzt fast die volle
// Bildschirmbreite (siehe computeLayout(): nur 6px Rand) - ein Label
// ausserhalb des Rings wuerde bei Ost/West-Peilungen ueber den
// sichtbaren Bereich hinausragen. Deshalb: Label-Position an die
// Bildschirmgrenzen clampen statt stur dem Winkel zu folgen.
int16_t labelX = L.cx + (int16_t)lround((L.radius + 10) * s);
int16_t labelY = L.cy - (int16_t)lround((L.radius + 10) * c);
labelX = constrain(labelX, (int16_t)14, (int16_t)(Config::SCREEN_WIDTH - 14));
labelY = constrain(labelY, (int16_t)10, (int16_t)(L.cy + L.radius + 8));
char buf[6];
snprintf(buf, sizeof(buf), "%.0f", bearingDeg);
gfx.setTextDatum(MC_DATUM);
gfx.setTextColor(TFT_WHITE, TFT_BLACK);
gfx.drawString(buf, labelX, labelY);
gfx.setTextDatum(TL_DATUM);
}
// kurzer Kursstrich in Flugrichtung (headingDeg) MIT Pfeilspitze am Ende -
// eine reine Linie ohne Spitze war nicht eindeutig lesbar (nicht erkennbar,
// welches Ende "vorne" ist). Die Spitze besteht aus zwei kurzen Strichen,
// die knapp vor der Linienspitze schraeg nach aussen abzweigen (klassisches
// Chevron-/Pfeilkopf-Symbol, wie bei ATC-Radardarstellungen ueblich).
void drawAircraftMarker(TFT_eSPI& gfx, int16_t x, int16_t y, float headingDeg, uint16_t color) {
gfx.fillCircle(x, y, 5, color);
double rad = headingDeg * PI / 180.0;
int16_t dx = (int16_t)(sin(rad) * 10);
int16_t dy = (int16_t)(-cos(rad) * 10);
int16_t tipX = x + dx;
int16_t tipY = y + dy;
gfx.drawLine(x, y, tipX, tipY, color);
// Pfeilspitze: zwei kurze Striche von der Spitze aus, je 150 Grad
// zur Kurslinie zurueckgeklappt (also leicht "nach hinten" zeigend,
// wie bei einem Pfeilkopf "^" ueblich).
constexpr double WING_ANGLE_RAD = 150.0 * PI / 180.0;
constexpr int16_t WING_LEN = 4;
double wing1 = rad + WING_ANGLE_RAD;
double wing2 = rad - WING_ANGLE_RAD;
int16_t w1x = tipX + (int16_t)(sin(wing1) * WING_LEN);
int16_t w1y = tipY + (int16_t)(-cos(wing1) * WING_LEN);
int16_t w2x = tipX + (int16_t)(sin(wing2) * WING_LEN);
int16_t w2y = tipY + (int16_t)(-cos(wing2) * WING_LEN);
gfx.drawLine(tipX, tipY, w1x, w1y, color);
gfx.drawLine(tipX, tipY, w2x, w2y, color);
}
uint16_t dimColorForAltitude(int32_t altFt) {
if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT) return TFT_DARKGREEN;
if (altFt < Config::COLOR_MID_ALT_THRESHOLD_FT) return TFT_OLIVE;
return TFT_MAROON;
}
constexpr uint8_t TRAIL_LEN = 4;
constexpr uint32_t TRAIL_STALE_MS = Config::FETCH_INTERVAL_MS * 3;
struct TrailEntry {
char hex[7] = {0};
bool active = false;
int16_t xs[TRAIL_LEN] = {0};
int16_t ys[TRAIL_LEN] = {0};
uint8_t count = 0;
uint32_t lastUpdateMs = 0;
uint16_t dimColor = TFT_DARKGREEN;
};
constexpr uint8_t MAX_TRAILS = Config::MAX_TRACKED_AIRCRAFT;
TrailEntry trails[MAX_TRAILS];
// Alle Trails verwerfen, z.B. wenn sich der Radius (rangeKm) aendert:
// die gespeicherten Punkte sind Bildschirm-Pixelkoordinaten, die unter
// der ALTEN Skalierung berechnet wurden. Ohne diesen Reset zieht jedes
// Flugzeug fuer ein paar Heartbeat-Takte eine falsche "Schwanz"-Linie
// von der alten zur neuen Position, bis sich der kurze Ringpuffer
// (TRAIL_LEN=4) mit neuen, konsistenten Punkten gefuellt hat.
void clearTrails() {
for (uint8_t i = 0; i < MAX_TRAILS; i++) trails[i] = TrailEntry{};
}
void pruneStaleTrails() {
uint32_t now = millis();
for (uint8_t i = 0; i < MAX_TRAILS; i++) {
if (trails[i].active && now - trails[i].lastUpdateMs > TRAIL_STALE_MS) {
trails[i] = TrailEntry{};
}
}
}
TrailEntry* findOrCreateTrail(const char* hex) {
int16_t freeIdx = -1;
for (uint8_t i = 0; i < MAX_TRAILS; i++) {
if (trails[i].active && strcmp(trails[i].hex, hex) == 0) return &trails[i];
if (!trails[i].active && freeIdx < 0) freeIdx = (int16_t)i;
}
if (freeIdx < 0) return nullptr;
trails[freeIdx] = TrailEntry{};
strncpy(trails[freeIdx].hex, hex, sizeof(trails[freeIdx].hex) - 1);
trails[freeIdx].active = true;
return &trails[freeIdx];
}
void pushTrailPoint(TrailEntry* t, int16_t x, int16_t y) {
if (!t) return;
if (t->count < TRAIL_LEN) {
t->xs[t->count] = x;
t->ys[t->count] = y;
t->count++;
} else {
for (uint8_t i = 1; i < TRAIL_LEN; i++) {
t->xs[i - 1] = t->xs[i];
t->ys[i - 1] = t->ys[i];
}
t->xs[TRAIL_LEN - 1] = x;
t->ys[TRAIL_LEN - 1] = y;
}
t->lastUpdateMs = millis();
}
void drawTrail(TFT_eSPI& gfx, const TrailEntry* t, uint16_t color) {
if (!t || t->count < 2) return;
for (uint8_t i = 1; i < t->count; i++) {
gfx.drawLine(t->xs[i - 1], t->ys[i - 1], t->xs[i], t->ys[i], color);
}
}
void printLineTruncated(TFT_eSPI& gfx, int16_t x, int16_t y, int16_t maxWidth, const String& text) {
String s = text;
if (gfx.textWidth(s) > maxWidth) {
while (s.length() > 1 && gfx.textWidth(s + "...") > maxWidth) {
s.remove(s.length() - 1);
}
s += "...";
}
gfx.setCursor(x, y);
gfx.print(s);
}
void drawButton(TFT_eSPI& gfx, const Rect& r, const String& label) {
gfx.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK);
gfx.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_GREEN);
gfx.setTextDatum(MC_DATUM);
gfx.setTextColor(TFT_GREEN, TFT_BLACK);
gfx.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
gfx.setTextDatum(TL_DATUM);
}
void drawLegend(TFT_eSPI& gfx, int16_t y) {
bool metric = LocationManager::useMetricUnits();
char lowLabel[10], midLabel[10], highLabel[10];
if (metric) {
int lowM = (int)(Units::feetToMeters(Config::COLOR_LOW_ALT_THRESHOLD_FT) / 100) * 100;
int midM = (int)(Units::feetToMeters(Config::COLOR_MID_ALT_THRESHOLD_FT) / 100) * 100;
snprintf(lowLabel, sizeof(lowLabel), "<%dm", lowM);
snprintf(midLabel, sizeof(midLabel), "%d-%dm", lowM, midM);
snprintf(highLabel, sizeof(highLabel), ">%dm", midM);
} else {
snprintf(lowLabel, sizeof(lowLabel), "<10k ft");
snprintf(midLabel, sizeof(midLabel), "10-30k");
snprintf(highLabel, sizeof(highLabel), ">30k ft");
}
// Dieselbe colorForAltitude()-Funktion wie fuer die Flugzeug-Marker
// selbst verwenden (mit einer typischen Hoehe je Band) - sonst zeigt
// die Legende bei aktiver Nachtdimmung (22-6 Uhr) die HELLEN Farben,
// waehrend die Marker/Beschriftungen auf dem Radar bereits gedaempft
// sind. Das fuehrte dazu, dass z.B. ein rotes Flugzeug nachts eher
// dunkelorange wirkte, obwohl die Legende noch reines Rot zeigte.
struct { uint16_t color; const char* label; } items[3] = {
{colorForAltitude(gfx, 0), lowLabel},
{colorForAltitude(gfx, Config::COLOR_LOW_ALT_THRESHOLD_FT), midLabel},
{colorForAltitude(gfx, Config::COLOR_MID_ALT_THRESHOLD_FT), highLabel},
};
int16_t segW = Config::SCREEN_WIDTH / 3;
gfx.setTextColor(TFT_WHITE, TFT_BLACK);
for (uint8_t i = 0; i < 3; i++) {
int16_t x0 = i * segW + 6;
gfx.fillCircle(x0, y - 5, 3, items[i].color);
gfx.setCursor(x0 + 7, y);
gfx.print(items[i].label);
}
}
void drawWorldMap(TFT_eSPI& gfx, const Layout& L) {
constexpr uint16_t dim = 0x0320;
float scaleX = (2.0f * L.radius) / WorldMap::GRID_W;
float scaleY = (2.0f * L.radius) / WorldMap::GRID_H;
int32_t radiusSq = (int32_t)(L.radius - 2) * (L.radius - 2);
for (uint8_t row = 0; row < WorldMap::GRID_H; row++) {
uint64_t bits = WorldMap::ROWS[row];
if (bits == 0) continue;
for (uint8_t col = 0; col < WorldMap::GRID_W; col++) {
if (!(bits & (1ULL << (WorldMap::GRID_W - 1 - col)))) continue;
int16_t px = L.cx - L.radius + (int16_t)((col + 0.5f) * scaleX);
int16_t py = L.cy - L.radius + (int16_t)((row + 0.5f) * scaleY);
int16_t dx = px - L.cx;
int16_t dy = py - L.cy;
if ((int32_t)dx * dx + (int32_t)dy * dy > radiusSq) continue;
gfx.drawPixel(px, py, dim);
}
}
}
void drawSweepLine(TFT_eSPI& gfx, const Layout& L, float angleDeg, uint16_t color) {
double rad = angleDeg * DEG_TO_RAD;
int16_t x2 = L.cx + (int16_t)(L.radius * sin(rad));
int16_t y2 = L.cy - (int16_t)(L.radius * cos(rad));
gfx.drawLine(L.cx, L.cy, x2, y2, color);
}
void drawStaticBackground(TFT_eSPI& gfx, const Layout& L, float rangeKm) {
gfx.drawCircle(L.cx, L.cy, L.radius, TFT_DARKGREY);
gfx.drawCircle(L.cx, L.cy, L.radius * 2 / 3, TFT_DARKGREY);
gfx.drawCircle(L.cx, L.cy, L.radius / 3, TFT_DARKGREY);
gfx.drawFastHLine(L.cx - L.radius, L.cy, L.radius * 2, TFT_DARKGREY);
gfx.drawFastVLine(L.cx, L.cy - L.radius, L.radius * 2, TFT_DARKGREY);
gfx.setTextColor(TFT_DARKGREY, TFT_BLACK);
gfx.setTextDatum(MC_DATUM);
gfx.drawString("N", L.cx, L.cy - L.radius - 8);
gfx.drawString("S", L.cx, L.cy + L.radius - 10);
gfx.drawString("E", L.cx + L.radius - 10, L.cy);
gfx.drawString("W", L.cx - L.radius + 10, L.cy);
char ringLabel[8];
snprintf(ringLabel, sizeof(ringLabel), "%.0f", rangeKm / 3);
gfx.drawString(ringLabel, L.cx, L.cy - L.radius / 3);
snprintf(ringLabel, sizeof(ringLabel), "%.0f", rangeKm * 2 / 3);
gfx.drawString(ringLabel, L.cx, L.cy - L.radius * 2 / 3);
gfx.setTextDatum(TL_DATUM);
}
struct PanelState {
bool valid = false;
char hex[7] = {0};
String callsignText, airlineText, modelText, typeText,
altText, speedText, climbText, distHeadingText, squawkText, seatsText;
};
PanelState lastPanel;
void updateLine(TFT_eSPI& gfx, int16_t y, int16_t h, int16_t maxWidth,
uint16_t fg, String& cached, const String& newText, bool forceFull) {
if (!forceFull && cached == newText) return;
gfx.fillRect(0, y - 14, Config::SCREEN_WIDTH, h, TFT_BLACK);
gfx.setTextColor(fg, TFT_BLACK);
printLineTruncated(gfx, 8, y, maxWidth, newText);
cached = newText;
}
void drawDetailPanel(TFT_eSPI& gfx, Aircraft& a) {
AirlineLookup::resolve(a);
AircraftDetails::Info details = AircraftDetails::get(a.hex);
int16_t panelTop = Config::SCREEN_HEIGHT - DETAIL_PANEL_H;
constexpr int16_t textMaxWidth = Config::SCREEN_WIDTH - 16;
constexpr int16_t LINE_H = 22;
bool forceFull = !lastPanel.valid || strcmp(lastPanel.hex, a.hex) != 0;
if (forceFull) {
gfx.fillRect(0, panelTop, Config::SCREEN_WIDTH, DETAIL_PANEL_H, TFT_BLACK);
gfx.drawRect(0, panelTop, Config::SCREEN_WIDTH, DETAIL_PANEL_H, TFT_GREEN);
lastPanel = PanelState{};
strncpy(lastPanel.hex, a.hex, sizeof(lastPanel.hex) - 1);
}
int16_t y = panelTop + 26;
{
String txt = a.callsign[0] ? a.callsign : a.hex;
if (forceFull || lastPanel.callsignText != txt) {
gfx.fillRect(0, y - 22, Config::SCREEN_WIDTH, 28, TFT_BLACK);
gfx.setTextColor(TFT_GREEN, TFT_BLACK);
gfx.setTextSize(2);
printLineTruncated(gfx, 8, y, textMaxWidth, txt);
gfx.setTextSize(1);
lastPanel.callsignText = txt;
}
}
y += 30;
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.airlineText,
String(a.airlineName), forceFull);
y += LINE_H;
String modelLine = details.loading
? String(I18n::t(StringId::DETAIL_MODEL)) + I18n::t(StringId::DETAIL_LOADING_DOTS)
: String(I18n::t(StringId::DETAIL_MODEL)) + (details.model[0] ? details.model : I18n::t(StringId::DETAIL_UNKNOWN));
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.modelText, modelLine, forceFull);
y += LINE_H;
String typeLine = String(I18n::t(StringId::DETAIL_TYPE)) + (a.typeCode[0] ? a.typeCode : I18n::t(StringId::DETAIL_UNKNOWN));
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.typeText, typeLine, forceFull);
y += LINE_H;
char buf[48];
snprintf(buf, sizeof(buf), "%s%.0fm / %.0fft", I18n::t(StringId::DETAIL_ALT),
Units::feetToMeters((float)a.altBaroFt), (float)a.altBaroFt);
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.altText, String(buf), forceFull);
y += LINE_H;
snprintf(buf, sizeof(buf), "%s%.0fkm/h / %.0fkt", I18n::t(StringId::DETAIL_SPEED),
Units::ktToKmh(a.groundSpeedKt), a.groundSpeedKt);
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.speedText, String(buf), forceFull);
y += LINE_H;
String climbLine;
if (a.vertRateFtMin > 100) {
snprintf(buf, sizeof(buf), "%s+%dft/min", I18n::t(StringId::DETAIL_CLIMB), a.vertRateFtMin);
climbLine = buf;
} else if (a.vertRateFtMin < -100) {
snprintf(buf, sizeof(buf), "%s%dft/min", I18n::t(StringId::DETAIL_DESCENT), a.vertRateFtMin);
climbLine = buf;
} else {
climbLine = I18n::t(StringId::DETAIL_LEVEL);
}
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.climbText, climbLine, forceFull);
y += LINE_H;
snprintf(buf, sizeof(buf), "%s%.0fkm / %.0fnm %s%.0f", I18n::t(StringId::DETAIL_DIST),
a.distanceKm, Units::kmToNm(a.distanceKm), I18n::t(StringId::DETAIL_HDG), a.headingDeg);
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.distHeadingText, String(buf), forceFull);
y += LINE_H;
String squawkLine = String(I18n::t(StringId::DETAIL_SQUAWK)) + (a.squawk[0] ? a.squawk : I18n::t(StringId::DETAIL_UNKNOWN));
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.squawkText, squawkLine, forceFull);
y += LINE_H;
String seatsLine = a.estSeats > 0
? String(I18n::t(StringId::DETAIL_SEATS_EST)) + a.estSeats
: String(I18n::t(StringId::DETAIL_SEATS_UNKNOWN));
updateLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.seatsText, seatsLine, forceFull);
y += 22;
if (forceFull) {
gfx.setTextColor(TFT_DARKGREEN, TFT_BLACK);
gfx.setCursor(8, y);
gfx.print(I18n::t(StringId::DETAIL_TAP_CLOSE));
}
lastPanel.valid = true;
}
constexpr uint8_t STAR_COUNT = 28;
struct Star {
int16_t x, y;
uint8_t phase;
uint8_t speed;
};
Star stars[STAR_COUNT];
bool starsInitialized = false;
void initStarsIfNeeded() {
if (starsInitialized) return;
int16_t panelTop = Config::SCREEN_HEIGHT - DETAIL_PANEL_H;
randomSeed((uint32_t)esp_random());
for (uint8_t i = 0; i < STAR_COUNT; i++) {
stars[i].x = 6 + random(0, Config::SCREEN_WIDTH - 12);
stars[i].y = panelTop + 10 + random(0, DETAIL_PANEL_H - 20);
stars[i].phase = (uint8_t)random(0, 256);
stars[i].speed = 1 + random(0, 3);
}
starsInitialized = true;
}
void updateDetailPanelStars(TFT_eSPI& gfx) {
initStarsIfNeeded();
for (uint8_t i = 0; i < STAR_COUNT; i++) {
stars[i].phase += stars[i].speed;
uint8_t bright = (stars[i].phase < 128)
? (uint8_t)(stars[i].phase * 2)
: (uint8_t)((255 - stars[i].phase) * 2);
uint16_t color = gfx.color565(0, bright, 0);
gfx.drawPixel(stars[i].x, stars[i].y, color);
}
}
}
void render(TFT_eSPI& tft, int16_t top) {
Layout L = computeLayout(top);
float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
bool panelAlreadyOpen = selectedHex[0] && lastPanel.valid &&
strcmp(lastPanel.hex, selectedHex) == 0;
if (panelAlreadyOpen) {
AircraftTable::lock();
Aircraft* table = AircraftTable::raw();
Aircraft selected{};
bool found = false;
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
if (table[i].valid && strcmp(table[i].hex, selectedHex) == 0) {
selected = table[i];
found = true;
break;
}
}
AircraftTable::unlock();
if (found && selected.distanceKm <= rangeKm * 1.05f) {
tft.startWrite();
drawDetailPanel(tft, selected);
tft.endWrite();
return;
}
selectedHex[0] = 0;
lastPanel.valid = false;
}
tft.startWrite();
tft.fillRect(0, top, Config::SCREEN_WIDTH, Config::SCREEN_HEIGHT - top, TFT_BLACK);
drawWorldMap(tft, L);
drawStaticBackground(tft, L, rangeKm);
drawSweepLine(tft, L, sweepAngleDeg, sweepLineColor(tft));
prevSweepAngleDeg = sweepAngleDeg;
tft.fillCircle(L.cx, L.cy, 3, TFT_WHITE);
static Aircraft snapshot[Config::MAX_TRACKED_AIRCRAFT];
uint8_t count = 0;
AircraftTable::lock();
Aircraft* table = AircraftTable::raw();
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
if (table[i].valid) snapshot[count++] = table[i];
}
AircraftTable::unlock();
bool selectionStillPresent = false;
Aircraft selected{};
uint8_t visibleCount = 0; // nach allen Filtern (Reichweite, Bodenfahrzeuge, Airline-Filter)
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) hitPoints[i].valid = false;
pruneStaleTrails();
for (uint8_t i = 0; i < count && i < MAX_HIT_POINTS; i++) {
Aircraft& a = snapshot[i];
if (a.distanceKm > rangeKm * 1.05f) continue;
if (SettingsStore::hideGroundVehicles() && a.category[0] == 'C') continue;
if (AirlineFilter::isHidden(a.callsign)) continue;
visibleCount++;
RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg};
RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm);
uint16_t color = colorForAltitude(tft, a.altBaroFt);
bool isSelected = selectedHex[0] && strcmp(a.hex, selectedHex) == 0;
bool isEmergency = SettingsStore::emergencyAlertEnabled() && isEmergencySquawk(a.squawk);
bool isWatched = SettingsStore::watchlistAlertEnabled() && AircraftWatchlist::isWatched(a.callsign);
TrailEntry* trail = findOrCreateTrail(a.hex);
uint16_t trailColor = dimColorForAltitude(a.altBaroFt);
drawTrail(tft, trail, trailColor);
pushTrailPoint(trail, pt.x, pt.y);
if (trail) trail->dimColor = trailColor;
if (isSelected) {
tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE);
selectionStillPresent = true;
selected = a;
drawBearingIndicator(tft, L, a.bearingDeg);
}
drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color);
if (isEmergency) {
tft.drawCircle(pt.x, pt.y, 12, TFT_RED);
} else if (isWatched) {
tft.drawCircle(pt.x, pt.y, 12, TFT_CYAN);
}
tft.setTextColor(color);
tft.setTextDatum(BC_DATUM);
const char* label = a.callsign[0] ? a.callsign : a.hex;
tft.drawString(label, pt.x, pt.y - 8);
tft.setTextDatum(TL_DATUM);
hitPoints[i].x = pt.x;
hitPoints[i].y = pt.y;
hitPoints[i].valid = true;
hitPoints[i].color = color;
hitPoints[i].headingDeg = a.headingDeg;
hitPoints[i].distanceKm = a.distanceKm;
hitPoints[i].isEmergency = isEmergency;
hitPoints[i].isWatched = isWatched;
strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1);
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
}
// "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein
// Flugzeug sichtbar war (nach allen Filtern). Statisch, damit der Wert
// über mehrere render()-Aufrufe hinweg erhalten bleibt.
static uint32_t lastAircraftSeenMs = millis();
if (visibleCount > 0) lastAircraftSeenMs = millis();
if (selectedHex[0] && !selectionStillPresent) {
selectedHex[0] = 0;
}
if (selectionStillPresent) {
tft.endWrite();
drawDetailPanel(tft, selected);
tft.startWrite();
} else {
lastPanel.valid = false;
int16_t infoTop = L.infoTop;
tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(8, infoTop + 20);
if (visibleCount == 0) {
// Kein Flugzeug in Reichweite - statt des "Fuer Details
// antippen"-Hinweises (der hier ohnehin ins Leere liefe) zeigen
// wir an, wie lange der Himmel schon leer ist.
uint32_t emptySec = (millis() - lastAircraftSeenMs) / 1000;
char buf[16];
if (emptySec < 60) {
snprintf(buf, sizeof(buf), "%lus", (unsigned long)emptySec);
} else {
// Ab der ersten vollen Minute NUR noch Minuten anzeigen (ohne
// Sekunden) - "1min 05s" war zu lang und stiess an den
// Reichweiten-Button rechts daneben. Abgerundet (60-119s =
// "1min", 120-179s = "2min", usw.) - intuitiv wie eine
// normale Stoppuhr-Minutenanzeige.
unsigned long minutes = emptySec / 60;
snprintf(buf, sizeof(buf), "%lumin", minutes);
}
tft.print(String(I18n::t(StringId::RADAR_EMPTY_SKY_PREFIX)) + buf);
} else {
tft.print(I18n::t(StringId::RADAR_TAP_FOR_DETAILS));
}
char rangeLabel[8];
snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm);
drawButton(tft, L.rangeBtn, rangeLabel);
drawLegend(tft, infoTop + 44);
}
tft.endWrite();
}
void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
if (selectedHex[0]) {
tft.startWrite();
updateDetailPanelStars(tft);
tft.endWrite();
return;
}
Layout L = computeLayout(top);
float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()];
tft.startWrite();
if (prevSweepAngleDeg >= 0.0f) {
drawSweepLine(tft, L, prevSweepAngleDeg, TFT_BLACK);
drawStaticBackground(tft, L, rangeKm);
tft.fillCircle(L.cx, L.cy, 3, TFT_WHITE);
}
sweepAngleDeg += SWEEP_DEGREES_PER_SEC * (deltaMs / 1000.0f);
if (sweepAngleDeg >= 360.0f) sweepAngleDeg -= 360.0f;
drawSweepLine(tft, L, sweepAngleDeg, sweepLineColor(tft));
prevSweepAngleDeg = sweepAngleDeg;
for (uint8_t i = 0; i < MAX_TRAILS; i++) {
if (!trails[i].active) continue;
bool stillVisible = false;
for (uint8_t j = 0; j < MAX_HIT_POINTS; j++) {
if (hitPoints[j].valid && strcmp(hitPoints[j].hex, trails[i].hex) == 0) {
stillVisible = true;
break;
}
}
if (stillVisible) {
drawTrail(tft, &trails[i], trails[i].dimColor);
}
}
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) {
if (!hitPoints[i].valid) continue;
const HitPoint& hp = hitPoints[i];
bool inAlertRange = hp.distanceKm <= Config::LED_ALERT_RADIUS_KM;
if (inAlertRange && !ledBlinkOn) {
tft.fillRect(hp.x - 20, hp.y - 18, 40, 30, TFT_BLACK);
continue;
}
drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color);
if (hp.isEmergency) {
tft.drawCircle(hp.x, hp.y, 12, TFT_RED);
} else if (hp.isWatched) {
tft.drawCircle(hp.x, hp.y, 12, TFT_CYAN);
}
tft.setTextColor(hp.color);
tft.setTextDatum(BC_DATUM);
const char* label = hp.callsign[0] ? hp.callsign : hp.hex;
tft.drawString(label, hp.x, hp.y - 8);
tft.setTextDatum(TL_DATUM);
}
tft.endWrite();
}
bool handleTap(int16_t x, int16_t y, int16_t top) {
Layout L = computeLayout(top);
if (selectedHex[0]) {
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) {
if (!hitPoints[i].valid) continue;
int16_t dx = x - hitPoints[i].x;
int16_t dy = y - hitPoints[i].y;
if (dx * dx + dy * dy <= 12 * 12) {
strncpy(selectedHex, hitPoints[i].hex, sizeof(selectedHex) - 1);
AircraftDetails::request(hitPoints[i].hex);
return true;
}
}
selectedHex[0] = 0;
lastPanel.valid = false;
return true;
}
if (L.rangeBtn.contains(x, y)) {
uint8_t idx = (SettingsStore::rangeIndex() + 1) % Config::RANGE_STEP_COUNT;
SettingsStore::setRangeIndex(idx);
clearTrails();
return true;
}
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) {
if (!hitPoints[i].valid) continue;
int16_t dx = x - hitPoints[i].x;
int16_t dy = y - hitPoints[i].y;
if (dx * dx + dy * dy <= 12 * 12) {
strncpy(selectedHex, hitPoints[i].hex, sizeof(selectedHex) - 1);
AircraftDetails::request(hitPoints[i].hex);
return true;
}
}
uint32_t nowMs = millis();
bool isDoubleTap = (nowMs - lastEmptyTapMs <= DOUBLE_TAP_MS) &&
(abs((int)x - (int)lastEmptyTapX) <= DOUBLE_TAP_RADIUS) &&
(abs((int)y - (int)lastEmptyTapY) <= DOUBLE_TAP_RADIUS);
lastEmptyTapMs = nowMs;
lastEmptyTapX = x;
lastEmptyTapY = y;
if (isDoubleTap) {
uint8_t idx = SettingsStore::rangeIndex();
idx = (idx == 0) ? (Config::RANGE_STEP_COUNT - 1) : (idx - 1);
SettingsStore::setRangeIndex(idx);
clearTrails();
lastEmptyTapMs = 0;
}
return true;
}
void selectAircraft(const char* hex) {
strncpy(selectedHex, hex, sizeof(selectedHex) - 1);
AircraftDetails::request(hex);
// Erzwingt einen kompletten Neuaufbau des Detail-Panels beim naechsten
// render() - wichtig, falls der Bildschirm zwischenzeitlich von einem
// anderen Screen (z.B. der Flugzeugliste) ueberschrieben wurde, sonst
// wuerden unveraenderte Zeilen faelschlich als "schon da" uebersprungen.
lastPanel.valid = false;
}
void updateProximityAlert(uint32_t nowMs) {
bool anyClose = false;
bool anyEmergency = false;
bool anyWatched = false;
bool proximityOn = SettingsStore::proximityAlertEnabled();
bool emergencyOn = SettingsStore::emergencyAlertEnabled();
bool watchlistOn = SettingsStore::watchlistAlertEnabled();
if (proximityOn || emergencyOn || watchlistOn) {
AircraftTable::lock();
Aircraft* table = AircraftTable::raw();
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
if (!table[i].valid) continue;
if (proximityOn && table[i].distanceKm <= Config::LED_ALERT_RADIUS_KM) anyClose = true;
if (emergencyOn && isEmergencySquawk(table[i].squawk)) anyEmergency = true;
if (watchlistOn && AircraftWatchlist::isWatched(table[i].callsign)) anyWatched = true;
}
AircraftTable::unlock();
}
LedAlert::Mode mode = anyEmergency ? LedAlert::Mode::EmergencyRed
: anyWatched ? LedAlert::Mode::WatchlistBlue
: anyClose ? LedAlert::Mode::ProximityGreen
: LedAlert::Mode::Off;
ledBlinkOn = LedAlert::update(mode, nowMs);
}
EmergencyInfo checkEmergency() {
EmergencyInfo info;
if (!SettingsStore::emergencyAlertEnabled()) return info;
AircraftTable::lock();
Aircraft* table = AircraftTable::raw();
for (uint8_t i = 0; i < AircraftTable::capacity(); i++) {
if (table[i].valid && isEmergencySquawk(table[i].squawk)) {
info.active = true;
strncpy(info.callsign, table[i].callsign[0] ? table[i].callsign : table[i].hex,
sizeof(info.callsign) - 1);
strncpy(info.squawk, table[i].squawk, sizeof(info.squawk) - 1);
break;
}
}
AircraftTable::unlock();
return info;
}
}