#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 "sun_times.h" #include "world_map.h" #include "airline_filter.h" #include "aircraft_watchlist.h" #include "i18n.h" #include "menu_stars.h" #include "touch_input.h" #include #include #include 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; Rect listBtn; Rect altBtn; Rect searchBtn; Rect helpBtn; int16_t infoTop; }; 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 L; L.infoTop = Config::SCREEN_HEIGHT - infoBarHeight(); 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 - 72), (int16_t)(L.infoTop + 4), 64, 22}; L.listBtn = {128, (int16_t)(L.infoTop + 4), 36, 22}; L.altBtn = {88, (int16_t)(L.infoTop + 4), 36, 22}; L.searchBtn = {48, (int16_t)(L.infoTop + 4), 36, 22}; L.helpBtn = {8, (int16_t)(L.infoTop + 4), 36, 22}; return L; } // +22px gegenueber frueher (264) fuer die neue Route-Zeile (siehe // drawDetailPanel()) - beeinflusst NUR die Groesse des Detail-Panel- // Overlays, nicht das normale Radar-Layout (computeLayout() oben // richtet sich ausschliesslich nach infoBarHeight(), nicht nach // DETAIL_PANEL_H). constexpr int16_t DETAIL_PANEL_H = 286; 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; bool isGroundVehicle; bool isRotorcraft; bool isHeavy; }; 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; // "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein // Flugzeug sichtbar war (nach allen Filtern) - namespace-weit statt // lokal in render(), da tick() (siehe dort) den Wert bei jedem Tick // (alle 80ms) braucht, um den Sekundenzaehler fluessig hochzuzaehlen, // statt nur alle paar Sekunden bei einem render()-Aufruf. uint32_t lastAircraftSeenMs = millis(); // Wird von der Detail-Panel-Laufschrift (LineMarquee in drawDetailPanel) // fuer das Scrollen von Routentext verwendet. constexpr uint32_t INFO_MARQUEE_STEP_MS = 200; String infoMarqueeWindow(TFT_eSPI& tft, const String& src, int32_t startIdx, int16_t maxWidth) { String s = src.substring(startIdx); while (s.length() > 1 && tft.textWidth(s) > maxWidth) { s.remove(s.length() - 1); } return s; } bool isEmergencySquawk(const char* squawk) { 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; // Gleiche Logik wie main.cpp::isNightDimHours() - Nacht = zwischen // Sonnenuntergang und Sonnenaufgang am aktiven Standort (siehe // sun_times.h), mit Rueckfall auf ein festes 22:00-06:00-Fenster, // solange Standort/Uhrzeit noch nicht bekannt sind. 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); double lat = 0, lon = 0; LocationManager::getHomeLocation(lat, lon); if (lat != 0.0 || lon != 0.0) { SunTimes::Result sun = SunTimes::compute(lat, lon, tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday, LocationManager::utcOffsetSeconds()); if (sun.valid) { if (sun.alwaysDay) return false; if (sun.alwaysNight) return true; float hourNow = tmNow.tm_hour + tmNow.tm_min / 60.0f; return (hourNow < sun.sunriseHour) || (hourNow >= sun.sunsetHour); } } 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. // // 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; } // 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) // 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); } // 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); } // Eigener Marker fuer Hubschrauber (ADS-B-Emitter-Kategorie "A7" = // Rotorcraft) - ein gefuellter Kreis mit durchgehendem Rotorkreuz statt // Pfeilkopf, da Hubschrauber im Schwebeflug keinen aussagekraeftigen // "nach vorne"-Kurs wie ein Flugzeug haben (gleiche Ueberlegung wie beim // Bodenfahrzeug-Marker, der aus demselben Grund ebenfalls ohne // Kurslinie auskommt). void drawHelicopterMarker(TFT_eSPI& gfx, int16_t x, int16_t y, uint16_t color) { constexpr int16_t ROTOR_LEN = 8; gfx.fillCircle(x, y, 4, color); gfx.drawLine((int16_t)(x - ROTOR_LEN), y, (int16_t)(x + ROTOR_LEN), y, color); gfx.drawLine(x, (int16_t)(y - ROTOR_LEN), x, (int16_t)(y + ROTOR_LEN), color); } // Eigener Marker fuer "Heavy"-Flugzeuge (ADS-B-Emitter-Kategorie "A5" - // Flugzeuge ueber 136t Starthoechstgewicht, z.B. A380/B747/B777/A330). // Gleicher Aufbau wie drawAircraftMarker() (Kreis + Kurslinie mit // Pfeilkopf), aber mit groesserem Kreis und einem zusaetzlichen duennen // Aussenring - auf den ersten Blick als "groesser/schwerer" erkennbar, // ohne eine komplett neue Formsprache einzufuehren. Die Farbe bleibt wie // gewohnt die Hoehenfarbe (colorForAltitude()) - die Form transportiert // "Heavy", kein Alarm-/Auffaelligkeitszustand. void drawHeavyMarker(TFT_eSPI& gfx, int16_t x, int16_t y, float headingDeg, uint16_t color) { gfx.fillCircle(x, y, 6, color); gfx.drawCircle(x, y, 8, 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); 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); } 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 (Sonnenunter- bis -aufgang) 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); // Aussenspalten (gruen/rot) bleiben auf ihrer festen 1/3-Raster- // Position (x0 = i*segW+6). Die mittlere (gelbe) Spalte wurde // bisher genauso starr positioniert, sass dadurch aber optisch zu // weit rechts: ihr Label ("3000-9100") ist deutlich laenger als // die Aussenlabels, wodurch rechts kaum noch Luft zum roten // Eintrag blieb, waehrend links viel Freiraum zum kurzen gruenen // Label uebrig war. Jetzt wird die gelbe Spalte stattdessen mittig // in die Luecke zwischen Ende des gruenen Textes und Anfang des // roten Punkts gesetzt. int16_t x0Low = 0 * segW + 6; int16_t x0High = 2 * segW + 6; gfx.fillCircle(x0Low, y - 5, 3, items[0].color); gfx.setCursor(x0Low + 7, y); gfx.print(items[0].label); gfx.fillCircle(x0High, y - 5, 3, items[2].color); gfx.setCursor(x0High + 7, y); gfx.print(items[2].label); int16_t lowTextEndX = x0Low + 7 + gfx.textWidth(items[0].label); int16_t highDotStartX = x0High - 3; int16_t midBlockW = 10 + gfx.textWidth(items[1].label); // Punktdurchmesser (6) + 4px Abstand + Textbreite int16_t gap = highDotStartX - lowTextEndX; int16_t x0Mid = lowTextEndX + 3 + (gap > midBlockW ? (gap - midBlockW) / 2 : 0); gfx.fillCircle(x0Mid, y - 5, 3, items[1].color); gfx.setCursor(x0Mid + 7, y); gfx.print(items[1].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) { 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); // Ring-Beschriftungen (Zwischenabstaende) respektieren jetzt die // Einheiten-Einstellung (Menue > Einheiten) - vorher immer in km, // auch wenn Imperial (nm) eingestellt war. Gleiches Umrechnungs- // Muster wie beim Range-Button unten und der Legende oben. bool metric = LocationManager::useMetricUnits(); float displayRange = metric ? rangeKm : Units::kmToNm(rangeKm); char ringLabel[8]; snprintf(ringLabel, sizeof(ringLabel), "%.0f", displayRange / 3); gfx.drawString(ringLabel, L.cx, L.cy - L.radius / 3); snprintf(ringLabel, sizeof(ringLabel), "%.0f", displayRange * 2 / 3); gfx.drawString(ringLabel, L.cx, L.cy - L.radius * 2 / 3); gfx.setTextDatum(TL_DATUM); } // Laufschrift-Zustand fuer EINE Detail-Panel-Zeile - gleiches // Grundprinzip wie InfoMarquee oben, aber das Detail-Panel hat bis zu // neun solcher Zeilen gleichzeitig (Airline, Modell, Typ, Hoehe, // Geschw., Steig-/Sinkrate, Distanz/Peilung, Squawk, Sitzplaetze), // deshalb ein eigener Zustand pro Zeile statt einer einzigen globalen // Instanz. y/h/maxWidth/fg merken sich die Zeilen-Geometrie, damit // tickDetailPanelMarquees() (siehe unten) ohne Zusatzparameter weiss, // wo/wie jede Zeile neu zu zeichnen ist. struct LineMarquee { String text; String ring; bool needsScroll = false; int32_t charOffset = 0; uint32_t lastStepMs = 0; int16_t y = 0, h = 0, maxWidth = 0; uint16_t fg = TFT_GREEN; }; struct PanelState { bool valid = false; char hex[7] = {0}; String callsignText; LineMarquee airline, model, type, route, alt, speed, climb, distHeading, squawk, seats; }; PanelState lastPanel; // Zeichnet eine Detail-Panel-Zeile NEU, wenn sich ihr Text geaendert // hat (oder das Panel komplett neu aufgebaut wird) - merkt sich dabei // auch, ob der Text zu breit fuer die verfuegbare Breite ist // (needsScroll) und baut bei Bedarf den Ring-Puffer fuer die // Laufschrift auf (gleiches Muster wie infoMarqueeWindow() oben). // Passt zu breite Zeilen ("Modell: ...", "Distanz: ...") NICHT mehr // mit "..." ab, sondern laesst sie horizontal durchscrollen - siehe // tickDetailPanelMarquees() weiter unten fuer den Teil, der das // tatsaechliche Weiterscrollen zwischen zwei Datenaktualisierungen // uebernimmt (render() liefert i.d.R. nur alle ~300ms neue Werte, // das waere fuer eine fluessige Laufschrift viel zu selten). void updateMarqueeLine(TFT_eSPI& gfx, int16_t y, int16_t h, int16_t maxWidth, uint16_t fg, LineMarquee& m, const String& newText, bool forceFull) { // Geometrie/Farbe IMMER aktualisieren (auch ohne Textaenderung) - // tickDetailPanelMarquees() braucht diese Werte, um beim naechsten // Scroll-Schritt an der richtigen Stelle neu zu zeichnen. m.y = y; m.h = h; m.maxWidth = maxWidth; m.fg = fg; if (!forceFull && m.text == newText) return; m.text = newText; m.needsScroll = gfx.textWidth(newText) > maxWidth; String withGap = newText + " "; // 3 Leerzeichen Luecke vor der Wiederholung m.ring = withGap + withGap; m.charOffset = 0; m.lastStepMs = millis(); gfx.fillRect(0, y - 14, Config::SCREEN_WIDTH, h, TFT_BLACK); gfx.setTextColor(fg, TFT_BLACK); gfx.setCursor(8, y); gfx.print(m.needsScroll ? infoMarqueeWindow(gfx, m.ring, 0, maxWidth) : newText); } // Laesst eine einzelne Detail-Panel-Zeile weiterscrollen, falls sie zu // breit ist (needsScroll) und genug Zeit seit dem letzten Schritt // vergangen ist - sonst passiert nichts (kein unnoetiges Neuzeichnen // fuer Zeilen, die ohnehin komplett passen). Wird von // tickDetailPanelMarquees() fuer alle neun Zeilen aufgerufen. void advanceAndDrawMarqueeLine(TFT_eSPI& gfx, LineMarquee& m) { if (!m.needsScroll) return; uint32_t now = millis(); if (now - m.lastStepMs < INFO_MARQUEE_STEP_MS) return; m.lastStepMs = now; m.charOffset++; int32_t singleLen = (int32_t)m.text.length() + 3; if (m.charOffset >= singleLen) m.charOffset = 0; gfx.fillRect(0, m.y - 14, Config::SCREEN_WIDTH, m.h, TFT_BLACK); gfx.setTextColor(m.fg, TFT_BLACK); gfx.setCursor(8, m.y); gfx.print(infoMarqueeWindow(gfx, m.ring, m.charOffset, m.maxWidth)); } // Laesst alle Detail-Panel-Zeilen weiterscrollen - waehrend das Panel // offen ist, ruft tick() (alle ~80ms) das hier auf, DAZWISCHEN also // viel oefter als render() (das nur bei neuen Flugzeug-Daten, // ~alle 300ms, feuert) neue Werte liefert. Ohne diesen zusaetzlichen // Aufruf wuerde eine lange Zeile bei jedem render()-Aufruf zwar einen // Schritt weiterspringen, aber nicht fluessig durchlaufen. void tickDetailPanelMarquees(TFT_eSPI& gfx) { if (!lastPanel.valid) return; advanceAndDrawMarqueeLine(gfx, lastPanel.airline); advanceAndDrawMarqueeLine(gfx, lastPanel.model); advanceAndDrawMarqueeLine(gfx, lastPanel.type); advanceAndDrawMarqueeLine(gfx, lastPanel.route); advanceAndDrawMarqueeLine(gfx, lastPanel.alt); advanceAndDrawMarqueeLine(gfx, lastPanel.speed); advanceAndDrawMarqueeLine(gfx, lastPanel.climb); advanceAndDrawMarqueeLine(gfx, lastPanel.distHeading); advanceAndDrawMarqueeLine(gfx, lastPanel.squawk); advanceAndDrawMarqueeLine(gfx, lastPanel.seats); } 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; updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.airline, 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)); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.model, modelLine, forceFull); y += LINE_H; String typeLine = String(I18n::t(StringId::DETAIL_TYPE)) + (a.typeCode[0] ? a.typeCode : I18n::t(StringId::DETAIL_UNKNOWN)); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.type, typeLine, forceFull); y += LINE_H; // Start-/Zielflughafen (ICAO-Code) ueber AircraftDetails::get() - // wird zusammen mit dem Modell abgefragt (siehe aircraft_details.cpp), // aber ueber das Rufzeichen statt den Hex-Code aufgeloest. Ohne // Rufzeichen (z.B. manche Sichtflug-Maschinen) bleibt die Route // grundsaetzlich unbekannt, kein Abruf noetig. String routeLine; if (!a.callsign[0]) { routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN); } else if (details.loading) { routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_LOADING_DOTS); } else if (details.routeOrigin[0] && details.routeDest[0]) { routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + details.routeOrigin + " -> " + details.routeDest; } else { routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN); } updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.route, routeLine, 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); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.alt, 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); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.speed, 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); } updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.climb, climbLine, forceFull); y += LINE_H; // Zusaetzlich zu km/nm auch Meilen (mi) - nm allein war fuer // Laien-Nutzer aus Ländern mit imperialen Einheiten (v.a. USA) // nicht selbsterklaerend, "Meilen" sind dort die gebraeuchlichere // Alltags-Distanzeinheit (nm bleibt trotzdem stehen, da es zur // Geschwindigkeit in kt passt: 1 kt = 1 nm/h). snprintf(buf, sizeof(buf), "%s%.0fkm / %.0fnm / %.0fmi %s%.0f", I18n::t(StringId::DETAIL_DIST), a.distanceKm, Units::kmToNm(a.distanceKm), Units::kmToMi(a.distanceKm), I18n::t(StringId::DETAIL_HDG), a.headingDeg); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.distHeading, String(buf), forceFull); y += LINE_H; String squawkLine = String(I18n::t(StringId::DETAIL_SQUAWK)) + (a.squawk[0] ? a.squawk : I18n::t(StringId::DETAIL_UNKNOWN)); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.squawk, 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)); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.seats, 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); } } // Hintergrund-Sterne AUSSERHALB des Radarkreises (auf Wunsch: dasselbe // dekorative Sternenfunkeln wie im Detail-Panel/Menue, aber ohne mit // dem staendig neu gezeichneten Kreisinhalt zu kollidieren - genau DAS // wuerde "ruckeln" verursachen, da Sweep-Linie/Blips/Ringe innerhalb // des Kreises bei jedem tick() sowieso neu gezeichnet werden und dabei // die Sterne dort staendig wieder verschlucken wuerden). Ausserhalb // des Kreises bleibt der Hintergrund dauerhaft schwarz und wird von // sonst niemandem angefasst - dort koennen die Sterne frei vor sich // hin twinkeln, voellig unabhaengig von Sweep-Linie/Blip-Redraws. constexpr uint8_t BG_STAR_COUNT = 20; struct BgStar { int16_t x, y; uint8_t phase; uint8_t speed; }; BgStar bgStars[BG_STAR_COUNT]; bool bgStarsInitialized = false; // Platziert jeden Stern per Rejection-Sampling irgendwo im Bereich // [top..L.infoTop) x [0..SCREEN_WIDTH), aber nur wenn der Punkt // ausserhalb von Radius+MARGIN um den Kreismittelpunkt liegt (MARGIN // haelt zusaetzlich Abstand zum Ring und zum "N"-Kompasslabel knapp // ausserhalb des Kreises). Findet ein Versuch nach 25 Anlaeufen keinen // passenden Punkt (praktisch nie, da links/rechts vom Kreis immer // reichlich Platz ist), wird einfach der letzte Versuchspunkt // uebernommen - rein kosmetisches Feature, kein Grund fuer eine // Endlosschleife. void initBgStarsIfNeeded(const Layout& L, int16_t top) { if (bgStarsInitialized) return; randomSeed((uint32_t)esp_random()); constexpr int16_t MARGIN = 6; int32_t minDist = L.radius + MARGIN; int32_t minDistSq = minDist * minDist; for (uint8_t i = 0; i < BG_STAR_COUNT; i++) { int16_t x = 0, y = 0; for (uint8_t attempt = 0; attempt < 25; attempt++) { x = (int16_t)random(2, Config::SCREEN_WIDTH - 2); y = (int16_t)random(top + 2, L.infoTop - 2); int32_t dx = x - L.cx, dy = y - L.cy; if (dx * dx + dy * dy > minDistSq) break; } bgStars[i].x = x; bgStars[i].y = y; bgStars[i].phase = (uint8_t)random(0, 256); bgStars[i].speed = (uint8_t)(1 + random(0, 3)); } bgStarsInitialized = true; } void updateBgStars(TFT_eSPI& gfx, const Layout& L, int16_t top) { initBgStarsIfNeeded(L, top); for (uint8_t i = 0; i < BG_STAR_COUNT; i++) { bgStars[i].phase += bgStars[i].speed; uint8_t bright = (bgStars[i].phase < 128) ? (uint8_t)(bgStars[i].phase * 2) : (uint8_t)((255 - bgStars[i].phase) * 2); uint16_t color = gfx.color565(0, bright, 0); gfx.drawPixel(bgStars[i].x, bgStars[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; 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; uint8_t altMode = SettingsStore::altFilterIndex(); if (altMode == 1 && a.altBaroFt >= (int32_t)Config::COLOR_LOW_ALT_THRESHOLD_FT) continue; if (altMode == 2 && a.altBaroFt < (int32_t)Config::COLOR_LOW_ALT_THRESHOLD_FT) continue; visibleCount++; RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg}; RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm); // 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'; bool isRotorcraft = a.category[0] == 'A' && a.category[1] == '7'; // ADS-B-Emitter-Kategorie "A5" = "Heavy" - eigene Markerform (siehe // drawHeavyMarker()), unabhaengig von jeglicher Ring-Markierung. bool isHeavy = a.category[0] == 'A' && a.category[1] == '5'; uint16_t color = isGroundVehicle ? colorForGroundVehicle(tft) : 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); if (isSelected) { tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE); selectionStillPresent = true; selected = a; drawBearingIndicator(tft, L, a.bearingDeg); } if (isGroundVehicle) { drawGroundVehicleMarker(tft, pt.x, pt.y, color); } else if (isRotorcraft) { drawHelicopterMarker(tft, pt.x, pt.y, color); } else if (isHeavy) { drawHeavyMarker(tft, pt.x, pt.y, a.headingDeg, color); } else { 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; hitPoints[i].isGroundVehicle = isGroundVehicle; hitPoints[i].isRotorcraft = isRotorcraft; hitPoints[i].isHeavy = isHeavy; strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1); strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1); } 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); // Info-Bar Button-Leiste: [?] [S] [L] [50km] char rangeLabel[8]; bool rangeMetric = LocationManager::useMetricUnits(); if (rangeMetric) { snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm); } else { snprintf(rangeLabel, sizeof(rangeLabel), "%.0fnm", Units::kmToNm(rangeKm)); } uint8_t altIdx = SettingsStore::altFilterIndex(); const char* altLabels[] = {"All", "<10k", ">10k"}; drawButton(tft, L.helpBtn, "?"); drawButton(tft, L.searchBtn, "S"); drawButton(tft, L.listBtn, "L"); drawButton(tft, L.altBtn, altLabels[altIdx]); 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); // Laesst zu breite Detail-Panel-Zeilen (z.B. "Modell: ..." oder // "Distanz: ...") weiterscrollen, statt wie zuvor mit "..." starr // abgeschnitten zu bleiben - render() liefert neue Werte nur alle // ~300ms, das reicht fuer eine fluessige Laufschrift nicht aus, // deshalb hier zusaetzlich im 80ms-Tick weiterschieben. tickDetailPanelMarquees(tft); tft.endWrite(); return; } Layout L = computeLayout(top); float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()]; tft.startWrite(); // Twinkeln ausserhalb des Radarkreises - laeuft im selben 80ms-Takt wie // die Sweep-Linie, damit es fluessig wirkt. render() (bei jedem Aircraft- // Update, alle ~300ms) loescht den kompletten Inhaltsbereich per fillRect // und zeichnet ihn neu, OHNE die Sterne erneut zu setzen - das ist // bewusst so (wie beim Detail-Panel-Sternenfeld auch): die naechste // tick()-Runde (spaetestens 80ms spaeter) laesst sie einfach wieder // aufblitzen, das ist zu kurz, um als Ruckeln wahrgenommen zu werden. updateBgStars(tft, L, top); 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_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) { // Blink-Aus-Phase: frueher wurde hier ein pauschales 40x30px // schwarzes Rechteck gezeichnet, um den Marker zu "verstecken" - // das hat dabei aber auch die Radar-Ringe/Kompasslinien darunter // ueberdeckt (bei jeder Hoehenfarbe gleichermassen, da diese // Blink-Logik rein distanzbasiert ist). Stattdessen werden hier // jetzt exakt dieselben Formen, die beim normalen Zeichnen weiter // unten entstehen (Marker, Notfall-/Beobachtungs-Ring, Label), // einfach in Schwarz nachgezeichnet - das macht sie pixelgenau // wieder unsichtbar, ohne irgendetwas ausserhalb dieser Formen zu // beruehren. if (hp.isGroundVehicle) { drawGroundVehicleMarker(tft, hp.x, hp.y, TFT_BLACK); } else if (hp.isRotorcraft) { drawHelicopterMarker(tft, hp.x, hp.y, TFT_BLACK); } else if (hp.isHeavy) { drawHeavyMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK); } else { drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK); } if (hp.isEmergency || hp.isWatched) { tft.drawCircle(hp.x, hp.y, 12, TFT_BLACK); } tft.setTextColor(TFT_BLACK); tft.setTextDatum(BC_DATUM); const char* blinkLabel = hp.callsign[0] ? hp.callsign : hp.hex; tft.drawString(blinkLabel, hp.x, hp.y - 8); tft.setTextDatum(TL_DATUM); continue; } if (hp.isGroundVehicle) { drawGroundVehicleMarker(tft, hp.x, hp.y, hp.color); } else if (hp.isRotorcraft) { drawHelicopterMarker(tft, hp.x, hp.y, hp.color); } else if (hp.isHeavy) { drawHeavyMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color); } else { 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); } // Info-Zeile unten (Tap-Hinweis bzw. "Leerer Himmel"-Timer) als // Laufende Aktualisierung der letzten Sichtung fuer den "Leerer // Himmel"-Zaehler. uint8_t visibleCountNow = 0; for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) { if (hitPoints[i].valid) visibleCountNow++; } if (visibleCountNow > 0) lastAircraftSeenMs = millis(); tft.endWrite(); } bool handleTap(TFT_eSPI& tft, 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, hitPoints[i].callsign); return true; } } selectedHex[0] = 0; lastPanel.valid = false; return true; } if (L.helpBtn.contains(x, y)) { runHelp(tft, top); return true; } if (L.searchBtn.contains(x, y)) { runSearch(tft, top); return true; } if (L.listBtn.contains(x, y)) { bool hadSelection = runTable(tft, top); return true; } if (L.altBtn.contains(x, y)) { uint8_t idx = (SettingsStore::altFilterIndex() + 1) % Config::ALT_FILTER_COUNT; SettingsStore::setAltFilterIndex(idx); return true; } if (L.rangeBtn.contains(x, y)) { uint8_t idx = (SettingsStore::rangeIndex() + 1) % Config::RANGE_STEP_COUNT; SettingsStore::setRangeIndex(idx); 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, hitPoints[i].callsign); 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); lastEmptyTapMs = 0; } return true; } bool runTable(TFT_eSPI& tft, int16_t top) { constexpr int16_t ROW_H = 22; constexpr int16_t ROW_GAP = 3; constexpr int16_t PADDING = 6; constexpr int16_t TABLE_LEFT = PADDING; constexpr int16_t TABLE_RIGHT = Config::SCREEN_WIDTH - PADDING; constexpr int16_t TABLE_W = TABLE_RIGHT - TABLE_LEFT; int16_t TABLE_TOP = top + 4; constexpr int16_t NAV_H = 24; constexpr int16_t BTN_H = 30; constexpr int16_t BTN_Y = Config::SCREEN_HEIGHT - BTN_H - 4; constexpr int16_t NAV_Y = BTN_Y - ROW_H - ROW_GAP - 2; int16_t tableBottom = NAV_Y - 4; uint8_t rowsVisible = (tableBottom - TABLE_TOP) / (ROW_H + ROW_GAP); if (rowsVisible < 1) rowsVisible = 1; uint8_t scrollTop = 0; bool done = false; bool selected = false; MenuStars::reset(); while (!done && !selected) { static Aircraft snapshot[Config::MAX_TRACKED_AIRCRAFT]; uint8_t count = 0; float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()]; AircraftTable::lock(); Aircraft* table = AircraftTable::raw(); for (uint8_t i = 0; i < AircraftTable::capacity(); i++) { if (!table[i].valid) continue; if (table[i].distanceKm > rangeKm * 1.05f) continue; if (SettingsStore::hideGroundVehicles() && table[i].category[0] == 'C') continue; if (AirlineFilter::isHidden(table[i].callsign)) continue; uint8_t altMode = SettingsStore::altFilterIndex(); if (altMode == 1 && table[i].altBaroFt >= (int32_t)Config::COLOR_LOW_ALT_THRESHOLD_FT) continue; if (altMode == 2 && table[i].altBaroFt < (int32_t)Config::COLOR_LOW_ALT_THRESHOLD_FT) continue; snapshot[count++] = table[i]; } AircraftTable::unlock(); std::sort(snapshot, snapshot + count, [](const Aircraft& a, const Aircraft& b) { return a.distanceKm < b.distanceKm; }); if (scrollTop > 0 && scrollTop + rowsVisible > count) { scrollTop = (count > rowsVisible) ? (uint8_t)(count - rowsVisible) : 0; } tft.fillRect(TABLE_LEFT, TABLE_TOP, TABLE_W, tableBottom - TABLE_TOP, TFT_BLACK); bool metric = LocationManager::useMetricUnits(); int16_t y = TABLE_TOP; uint8_t drawn = 0; for (uint8_t i = scrollTop; i < count && drawn < rowsVisible; i++) { Aircraft& a = snapshot[i]; Rect r = {TABLE_LEFT, y, TABLE_W, ROW_H}; bool emergency = isEmergencySquawk(a.squawk); bool watched = AircraftWatchlist::isWatched(a.callsign); uint16_t borderColor = emergency ? TFT_RED : (watched ? TFT_CYAN : TFT_GREEN); tft.drawRoundRect(r.x, r.y, r.w, r.h, 3, borderColor); int16_t midY = r.y + r.h / 2; const char* label = a.callsign[0] ? a.callsign : a.hex; tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextDatum(ML_DATUM); tft.drawString(label, r.x + 4, midY); char buf[16]; if (metric) { snprintf(buf, sizeof(buf), "%.0fm", Units::feetToMeters((float)a.altBaroFt)); } else { snprintf(buf, sizeof(buf), "%.0fft", (float)a.altBaroFt); } tft.setTextColor(colorForAltitude(tft, a.altBaroFt), TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(buf, r.x + r.w / 2, midY); if (metric) { snprintf(buf, sizeof(buf), "%.1fkm", a.distanceKm); } else { snprintf(buf, sizeof(buf), "%.1fnm", Units::kmToNm(a.distanceKm)); } tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextDatum(MR_DATUM); tft.drawString(buf, r.x + r.w - 4, midY); tft.setTextDatum(TL_DATUM); y += ROW_H + ROW_GAP; drawn++; } if (count == 0) { tft.setTextColor(TFT_DARKGREY, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(I18n::t(StringId::AIRCRAFT_LIST_EMPTY), Config::SCREEN_WIDTH / 2, (int16_t)(TABLE_TOP + ROW_H + 10)); tft.setTextDatum(TL_DATUM); } bool canUp = scrollTop > 0; bool canDown = (uint16_t)(scrollTop + rowsVisible) < count; tft.fillRect(0, NAV_Y, Config::SCREEN_WIDTH, Config::SCREEN_HEIGHT - NAV_Y, TFT_BLACK); Rect upBtn = {TABLE_LEFT, NAV_Y, 44, NAV_H}; Rect downBtn = {(int16_t)(TABLE_LEFT + 48), NAV_Y, 44, NAV_H}; Rect radarBtn = {(int16_t)(TABLE_LEFT + 100), NAV_Y, (int16_t)(TABLE_W - 100), NAV_H}; drawButton(tft, upBtn, "^"); drawButton(tft, downBtn, "v"); drawButton(tft, radarBtn, I18n::t(StringId::BACK)); TouchInput::Point tap; while (true) { if (TouchInput::wasTapped(tap)) break; MenuStars::update(tft); delay(20); } if (canUp && upBtn.contains(tap.x, tap.y)) { scrollTop--; } else if (canDown && downBtn.contains(tap.x, tap.y)) { scrollTop++; } else if (radarBtn.contains(tap.x, tap.y)) { done = true; } else { y = TABLE_TOP; for (uint8_t i = scrollTop; i < count; i++) { uint8_t idx = (uint8_t)(i - scrollTop); if (idx >= rowsVisible) break; Rect r = {TABLE_LEFT, y, TABLE_W, ROW_H}; if (r.contains(tap.x, tap.y)) { selectAircraft(snapshot[i].hex, snapshot[i].callsign); selected = true; break; } y += ROW_H + ROW_GAP; } } } // Wait briefly to let the tap release propagate before render() runs delay(50); return selected; } void runSearch(TFT_eSPI& tft, int16_t top) { MenuStars::reset(); constexpr const char* ROW1 = "QWERTYUIOP"; constexpr const char* ROW2 = "ASDFGHJKL"; constexpr const char* ROW3 = "ZXCVBNM"; constexpr const char* ROW4 = "0123456789"; char buf[9] = {0}; uint8_t len = 0; constexpr uint8_t MAX_LEN = 8; constexpr int16_t KEY_H = 32; constexpr int16_t KEY_GAP = 3; constexpr int16_t ROW0_Y = 64; auto layoutRow = [&](const char* row, int16_t y, Rect* outRects, uint8_t n) { int16_t usableW = Config::SCREEN_WIDTH - 8; int16_t keyW = (usableW - (n - 1) * KEY_GAP) / n; int16_t x = 4; for (uint8_t i = 0; i < n; i++) { outRects[i] = {x, y, keyW, KEY_H}; x += keyW + KEY_GAP; } }; Rect row1Rects[10], row2Rects[9], row3Rects[7], row4Rects[10]; layoutRow(ROW1, ROW0_Y, row1Rects, 10); layoutRow(ROW2, ROW0_Y + KEY_H + KEY_GAP, row2Rects, 9); layoutRow(ROW3, ROW0_Y + 2 * (KEY_H + KEY_GAP), row3Rects, 7); layoutRow(ROW4, ROW0_Y + 3 * (KEY_H + KEY_GAP), row4Rects, 10); int16_t actionY = ROW0_Y + 4 * (KEY_H + KEY_GAP); Rect backspaceBtn = {4, actionY, 60, KEY_H}; Rect cancelBtn = {(int16_t)(Config::SCREEN_WIDTH / 2 - 48), actionY, 96, KEY_H}; Rect searchBtn = {(int16_t)(Config::SCREEN_WIDTH - 4 - 60), actionY, 60, KEY_H}; uint32_t notFoundUntil = 0; bool showingNotFound = false; auto redraw = [&]() { tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.fillRect(8, 30, Config::SCREEN_WIDTH - 16, 28, TFT_BLACK); tft.drawRect(8, 30, Config::SCREEN_WIDTH - 16, 28, TFT_GREEN); tft.setTextSize(2); tft.setCursor(14, 52); if (buf[0] == 0) { tft.setTextColor(TFT_DARKGREY, TFT_BLACK); tft.setTextSize(1); tft.setCursor(14, 49); tft.print("Flight number or hex code"); tft.setTextSize(2); } else { tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.print(buf); } tft.setTextSize(1); if (showingNotFound) { tft.setTextColor(TFT_RED, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString("Not found", Config::SCREEN_WIDTH / 2, actionY + KEY_H + 14); tft.setTextDatum(TL_DATUM); } for (uint8_t i = 0; i < 10; i++) drawButton(tft, row1Rects[i], String(ROW1[i])); for (uint8_t i = 0; i < 9; i++) drawButton(tft, row2Rects[i], String(ROW2[i])); for (uint8_t i = 0; i < 7; i++) drawButton(tft, row3Rects[i], String(ROW3[i])); for (uint8_t i = 0; i < 10; i++) drawButton(tft, row4Rects[i], String(ROW4[i])); drawButton(tft, backspaceBtn, "<-"); drawButton(tft, cancelBtn, I18n::t(StringId::CANCEL)); drawButton(tft, searchBtn, I18n::t(StringId::OK)); }; redraw(); while (true) { if (showingNotFound && millis() >= notFoundUntil) { showingNotFound = false; redraw(); } TouchInput::Point tap; if (!TouchInput::wasTapped(tap)) { MenuStars::update(tft); delay(20); continue; } if (showingNotFound) { showingNotFound = false; redraw(); continue; } bool handled = false; for (uint8_t i = 0; i < 10 && !handled; i++) { if (row1Rects[i].contains(tap.x, tap.y) && len < MAX_LEN) { buf[len++] = ROW1[i]; handled = true; } } for (uint8_t i = 0; i < 9 && !handled; i++) { if (row2Rects[i].contains(tap.x, tap.y) && len < MAX_LEN) { buf[len++] = ROW2[i]; handled = true; } } for (uint8_t i = 0; i < 7 && !handled; i++) { if (row3Rects[i].contains(tap.x, tap.y) && len < MAX_LEN) { buf[len++] = ROW3[i]; handled = true; } } for (uint8_t i = 0; i < 10 && !handled; i++) { if (row4Rects[i].contains(tap.x, tap.y) && len < MAX_LEN) { buf[len++] = ROW4[i]; handled = true; } } if (!handled && backspaceBtn.contains(tap.x, tap.y)) { if (len > 0) { len--; buf[len] = 0; } handled = true; } if (!handled && cancelBtn.contains(tap.x, tap.y)) { return; } if (!handled && searchBtn.contains(tap.x, tap.y) && len > 0) { auto prefixMatch = [&](const char* str) -> bool { for (uint8_t j = 0; j < len; j++) { if (str[j] == 0) return false; char a = str[j], b = buf[j]; if (a >= 'a' && a <= 'z') a -= 32; if (b >= 'a' && b <= 'z') b -= 32; if (a != b) return false; } return true; }; int bestIdx = -1; float bestDist = 1e9f; AircraftTable::lock(); Aircraft* table = AircraftTable::raw(); for (uint8_t i = 0; i < AircraftTable::capacity(); i++) { if (!table[i].valid) continue; if (!prefixMatch(table[i].callsign) && !prefixMatch(table[i].hex)) continue; if (table[i].distanceKm < bestDist) { bestDist = table[i].distanceKm; bestIdx = i; } } char mHex[7] = {0}, mCall[9] = {0}; if (bestIdx >= 0) { strncpy(mHex, table[bestIdx].hex, sizeof(mHex) - 1); strncpy(mCall, table[bestIdx].callsign, sizeof(mCall) - 1); } AircraftTable::unlock(); if (bestIdx >= 0) { selectAircraft(mHex, mCall); return; } else { showingNotFound = true; notFoundUntil = millis() + 1500; redraw(); continue; } } if (handled) redraw(); } } void runHelp(TFT_eSPI& tft, int16_t top) { tft.fillScreen(TFT_BLACK); constexpr int16_t X = 10; constexpr int16_t W = Config::SCREEN_WIDTH - 20; constexpr int16_t LBL_H = 14; int16_t y = 14; tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextSize(1); tft.setCursor(X, y); tft.print("Usage Guide"); y += 24; auto line = [&](const char* text) { tft.setCursor(X, y); tft.print(text); y += LBL_H; }; tft.setTextColor(TFT_WHITE, TFT_BLACK); line("Tap a plane on the radar to see"); line("its details (model, altitude,"); line("speed, route, squawk)."); y += 4; tft.setTextColor(TFT_GREEN, TFT_BLACK); line("[?] This help screen."); line("[S] Search by flight number or"); line(" hex code."); line("[L] List all planes by distance."); line("[km] Cycle radar range: 10, 25,"); line(" 50, 100 km."); y += 4; tft.setTextColor(TFT_GREEN, TFT_BLACK); line("Altitude colors:"); tft.setTextColor(TFT_GREEN, TFT_BLACK); line(" <10k ft "); tft.setTextColor(TFT_YELLOW, TFT_BLACK); line(" 10k - 30k ft "); tft.setTextColor(TFT_RED, TFT_BLACK); line(" >30k ft "); y += 4; tft.setTextColor(TFT_WHITE, TFT_BLACK); line("Menu > settings, WLAN, flight"); line("options, watchlist & more."); Rect okBtn = {(int16_t)((Config::SCREEN_WIDTH - 120) / 2), (int16_t)(Config::SCREEN_HEIGHT - 50), 120, 36}; drawButton(tft, okBtn, I18n::t(StringId::OK)); MenuStars::reset(); while (true) { TouchInput::Point tap; if (TouchInput::wasTapped(tap)) { if (okBtn.contains(tap.x, tap.y)) break; break; // any tap dismisses } MenuStars::update(tft); delay(20); } } void selectAircraft(const char* hex, const char* callsign) { strncpy(selectedHex, hex, sizeof(selectedHex) - 1); AircraftDetails::request(hex, callsign); // 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; } }