Mirrors the existing detail-panel star field (updateDetailPanelStars) with the same twinkle logic, but placed via rejection sampling outside the radar circle (radius + margin around the center) so it never collides with the sweep line, aircraft markers, or rings redrawn every tick(). Star positions are computed once on first tick() and never redistributed, same as the detail-panel field. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1122 lines
47 KiB
C++
1122 lines
47 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;
|
|
// 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 - 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;
|
|
bool isGroundVehicle;
|
|
};
|
|
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();
|
|
|
|
// Laufschrift fuer die Info-Zeile unten (Tap-Hinweis bzw. "Leerer
|
|
// Himmel"-Timer) - gleiches Grundprinzip wie der Naechster-Flughafen-
|
|
// Text in location_presets_screen.cpp (bewusst dupliziert, kein
|
|
// gemeinsames Modul, siehe CLAUDE.md-Konvention "jeder Screen
|
|
// unabhaengig lauffaehig"). Wird bei jedem tick()-Aufruf (alle 80ms)
|
|
// statt nur bei Aenderung neu gezeichnet, damit der Text fluessig und
|
|
// endlos durchlaeuft.
|
|
struct InfoMarquee {
|
|
String text;
|
|
String ring; // text + Luecke, doppelt aneinandergehaengt
|
|
bool needsScroll = false;
|
|
int32_t charOffset = 0;
|
|
uint32_t lastStepMs = 0;
|
|
};
|
|
InfoMarquee infoMarquee;
|
|
|
|
enum class InfoMsgKind { None, TapForDetails, EmptySky };
|
|
InfoMsgKind infoMarqueeKind = InfoMsgKind::None;
|
|
|
|
constexpr uint32_t INFO_MARQUEE_STEP_MS = 200; // alle 200ms ein Zeichen weiter
|
|
|
|
// Baut Text + Ring-Puffer neu auf und setzt den Scroll-Fortschritt
|
|
// zurueck - nur aufrufen, wenn sich die ART der Nachricht aendert
|
|
// (Tap-Hinweis <-> Leerer-Himmel-Timer), siehe updateInfoMarqueeText()
|
|
// fuer den Fall, dass sich nur der Sekundenwert aendert.
|
|
void setupInfoMarquee(TFT_eSPI& tft, const String& text, int16_t viewportW) {
|
|
tft.setTextSize(1);
|
|
infoMarquee.text = text;
|
|
infoMarquee.needsScroll = tft.textWidth(text) > viewportW;
|
|
String withGap = text + " "; // 3 Leerzeichen Luecke vor der Wiederholung
|
|
infoMarquee.ring = withGap + withGap;
|
|
infoMarquee.charOffset = 0;
|
|
infoMarquee.lastStepMs = millis();
|
|
}
|
|
|
|
// Aktualisiert nur den angezeigten Text (z.B. weil die Sekundenzahl des
|
|
// "Leerer Himmel"-Timers weitergezaehlt hat), OHNE den Scroll-
|
|
// Fortschritt zurueckzusetzen - so laeuft die Laufschrift trotz des
|
|
// sich staendig aendernden Zaehlers fluessig weiter, statt bei jedem
|
|
// Tick neu am Anfang zu beginnen. needsScroll wird trotzdem bei JEDEM
|
|
// Aufruf neu bestimmt (nicht nur einmalig in setupInfoMarquee) - sonst
|
|
// blieb der "Leerer Himmel"-Timer dauerhaft ohne Scrollen stehen, weil
|
|
// der allererste Text ("...seit 0s") noch kurz genug war und die
|
|
// Laufschrift-Funktion spaeter (z.B. bei "...seit 5min") nie erneut
|
|
// geprueft hat, ob der inzwischen laengere Text nun doch scrollen muss.
|
|
void updateInfoMarqueeText(TFT_eSPI& tft, const String& text, int16_t viewportW) {
|
|
tft.setTextSize(1);
|
|
infoMarquee.text = text;
|
|
infoMarquee.needsScroll = tft.textWidth(text) > viewportW;
|
|
String withGap = text + " ";
|
|
infoMarquee.ring = withGap + withGap;
|
|
}
|
|
|
|
// Liefert den laengsten Ausschnitt ab startIdx, der noch in maxWidth
|
|
// passt - OHNE "..." anzuhaengen.
|
|
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;
|
|
}
|
|
|
|
// Zeichnet die Info-Zeile neu - wenn der Text nicht scrollen muss, wird
|
|
// er einfach normal (fest) angezeigt.
|
|
void drawInfoMarquee(TFT_eSPI& tft, int16_t x, int16_t y, int16_t w) {
|
|
if (infoMarquee.text.length() == 0) return;
|
|
|
|
constexpr int16_t CLEAR_TOP = 16;
|
|
constexpr int16_t CLEAR_H = 20;
|
|
tft.fillRect(x, y - CLEAR_TOP, w, CLEAR_H, TFT_BLACK);
|
|
tft.setTextColor(TFT_WHITE, TFT_BLACK);
|
|
tft.setTextSize(1);
|
|
tft.setCursor(x, y);
|
|
|
|
if (!infoMarquee.needsScroll) {
|
|
tft.print(infoMarquee.text);
|
|
return;
|
|
}
|
|
|
|
uint32_t now = millis();
|
|
if (now - infoMarquee.lastStepMs >= INFO_MARQUEE_STEP_MS) {
|
|
infoMarquee.lastStepMs = now;
|
|
infoMarquee.charOffset++;
|
|
// Zurueck an den Anfang, sobald der erste (nicht doppelte)
|
|
// Text+Luecke-Block durchgelaufen ist - so entsteht die
|
|
// Endlosschleife.
|
|
int32_t singleLen = (int32_t)infoMarquee.text.length() + 3;
|
|
if (infoMarquee.charOffset >= singleLen) infoMarquee.charOffset = 0;
|
|
}
|
|
|
|
tft.print(infoMarqueeWindow(tft, infoMarquee.ring, infoMarquee.charOffset, w));
|
|
}
|
|
|
|
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.
|
|
//
|
|
// 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);
|
|
}
|
|
|
|
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);
|
|
|
|
// 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);
|
|
}
|
|
|
|
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;
|
|
|
|
// 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);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
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';
|
|
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 {
|
|
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;
|
|
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);
|
|
// Der eigentliche Text/Scroll-Fortschritt der Info-Zeile (Tap-
|
|
// Hinweis bzw. "Leerer Himmel"-Timer) wird weiterhin laufend in
|
|
// tick() aktualisiert (siehe dort) - HIER aber trotzdem sofort mit
|
|
// dem aktuellen Stand neu gezeichnet (statt bis zum naechsten
|
|
// tick() zu warten). Der obige fillRect() ganz oben in render()
|
|
// hat die Zeile naemlich gerade erst geloescht, und render() laeuft
|
|
// nur bei jedem Fetch-Zyklus (ca. alle 8s, zeitgleich mit dem
|
|
// Heartbeat-Blinken) - ohne dieses sofortige Nachzeichnen blieb die
|
|
// Zeile bis zu 80ms lang leer, sichtbar als kurzes Blinken im
|
|
// Heartbeat-Takt.
|
|
constexpr int16_t INFO_TEXT_X = 8;
|
|
constexpr int16_t INFO_TEXT_GAP = 6;
|
|
int16_t infoTextY = infoTop + 20;
|
|
int16_t infoTextW = L.rangeBtn.x - INFO_TEXT_X - INFO_TEXT_GAP;
|
|
drawInfoMarquee(tft, INFO_TEXT_X, infoTextY, infoTextW);
|
|
|
|
// Respektiert jetzt die Einheiten-Einstellung (Menue > Einheiten) -
|
|
// vorher immer "XXkm", auch bei Imperial (dort jetzt "XXnm").
|
|
char rangeLabel[8];
|
|
bool rangeMetric = LocationManager::useMetricUnits();
|
|
if (rangeMetric) {
|
|
snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm);
|
|
} else {
|
|
snprintf(rangeLabel, sizeof(rangeLabel), "%.0fnm", Units::kmToNm(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();
|
|
|
|
// 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 {
|
|
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 {
|
|
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
|
|
// durchlaufende Laufschrift - siehe InfoMarquee weiter oben.
|
|
// visibleCount wird hier bewusst erneut aus hitPoints[] gezaehlt
|
|
// (statt aus render() uebernommen), da tick() unabhaengig von render()
|
|
// laeuft und so auch den "Leerer Himmel"-Sekundenzaehler fluessig
|
|
// hochzaehlen kann, ohne auf den naechsten render()-Aufruf warten zu
|
|
// muessen.
|
|
uint8_t visibleCountNow = 0;
|
|
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) {
|
|
if (hitPoints[i].valid) visibleCountNow++;
|
|
}
|
|
if (visibleCountNow > 0) lastAircraftSeenMs = millis();
|
|
|
|
String infoText;
|
|
InfoMsgKind kind;
|
|
if (visibleCountNow == 0) {
|
|
kind = InfoMsgKind::EmptySky;
|
|
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) - 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);
|
|
}
|
|
infoText = String(I18n::t(StringId::RADAR_EMPTY_SKY_PREFIX)) + buf;
|
|
} else {
|
|
kind = InfoMsgKind::TapForDetails;
|
|
infoText = I18n::t(StringId::RADAR_TAP_FOR_DETAILS);
|
|
}
|
|
|
|
constexpr int16_t INFO_TEXT_X = 8;
|
|
constexpr int16_t INFO_TEXT_GAP = 6;
|
|
int16_t infoTextY = L.infoTop + 20;
|
|
int16_t infoTextW = L.rangeBtn.x - INFO_TEXT_X - INFO_TEXT_GAP;
|
|
|
|
if (kind != infoMarqueeKind) {
|
|
// Die ART der Nachricht hat sich geaendert (z.B. letztes Flugzeug
|
|
// verschwunden) - Laufschrift komplett neu aufsetzen und von vorne
|
|
// beginnen.
|
|
infoMarqueeKind = kind;
|
|
setupInfoMarquee(tft, infoText, infoTextW);
|
|
} else {
|
|
// Gleiche Art wie zuvor (z.B. weiterhin "Leerer Himmel", nur die
|
|
// Sekundenzahl hat sich geaendert) - Text aktualisieren, aber NICHT
|
|
// den Scroll-Fortschritt zuruecksetzen, sonst wuerde die
|
|
// Laufschrift bei jedem Sekundenwechsel neu von vorne beginnen statt
|
|
// fluessig durchzulaufen.
|
|
updateInfoMarqueeText(tft, infoText, infoTextW);
|
|
}
|
|
drawInfoMarquee(tft, INFO_TEXT_X, infoTextY, infoTextW);
|
|
|
|
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);
|
|
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);
|
|
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;
|
|
}
|
|
|
|
} |