localization

This commit is contained in:
2026-08-16 14:33:27 +02:00
parent 9c634b819e
commit fb09b2b7a1
65 changed files with 1621 additions and 1679 deletions

View File

@@ -41,11 +41,11 @@ int16_t cx, cy, radius;
};
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.
// Additional height for the second legend row (ground vehicle
// marker explanation), only reserved when ground vehicles are actually
// displayed (SettingsStore::hideGroundVehicles() == false) -
// otherwise the info bar stays exactly as tall as before and the
// radar circle keeps its usual size.
constexpr int16_t INFO_BAR_GROUND_ROW_H = 18;
int16_t infoBarHeight() {
@@ -69,10 +69,10 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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
// +22px compared to the previous value (264) for the new route line (see
// drawDetailPanel()) - only affects the size of the detail panel
// overlay, not the normal radar layout (computeLayout() above
// only depends on infoBarHeight(), not
// DETAIL_PANEL_H).
constexpr int16_t DETAIL_PANEL_H = 286;
@@ -103,15 +103,15 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// "Empty Sky" timer: remembers when at least one
// aircraft was last visible (after all filters) - namespace-wide instead of
// local to render(), since tick() (see there) needs the value on every tick
// (every 80ms) to count up the seconds smoothly,
// instead of only every few seconds on a render() call.
uint32_t lastAircraftSeenMs = millis();
// Wird von der Detail-Panel-Laufschrift (LineMarquee in drawDetailPanel)
// fuer das Scrollen von Routentext verwendet.
// Used by the detail panel marquee (LineMarquee in drawDetailPanel)
// for scrolling route text.
constexpr uint32_t INFO_MARQUEE_STEP_MS = 200;
String infoMarqueeWindow(TFT_eSPI& tft, const String& src, int32_t startIdx, int16_t maxWidth) {
@@ -133,16 +133,16 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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").
// Same logic as main.cpp::isNightDimHours() - night = between
// sunset and sunrise at the active location (see
// sun_times.h), with fallback to a fixed 22:00-06:00 window
// as long as location/time are not yet known. Deliberately
// duplicated instead of shared, see CLAUDE.md convention ("each screen
// independently runnable, no shared module for such
// small items").
bool isNightHours() {
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return false; // Uhrzeit noch nicht per NTP synchronisiert
if (now <= 8 * 3600 * 2) return false; // time not yet synced via NTP
struct tm tmNow;
localtime_r(&now, &tmNow);
@@ -164,22 +164,23 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Only active when the user has enabled night dimming (Menu > System)
// AND we are currently in the night window - the same setting that otherwise
// only dims the backlight, now additionally also dims the
// radar colors (aircraft markers + sweep line), so the display
// is less glaring overall at night.
bool nightDimActiveNow() {
return SettingsStore::nightDimmingEnabled() && isNightHours();
}
// Farbe je nach Flughoehe - gedaempft (dunkleres Gruen/Oliv) waehrend der
// Nachtdimmung.
// Color by altitude - dimmed (darker green/olive) during
// night dimming.
//
// 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.
// The red altitude level is deliberately NOT dimmed: the previous
// dimmed tone (160,30,0) looked orange-brown instead of red
// on the display and was therefore no longer clearly distinguishable
// from the yellow level. Red therefore remains the pure TFT_RED tone
// day and night.
uint16_t colorForAltitude(TFT_eSPI& gfx, int32_t altFt) {
bool dim = nightDimActiveNow();
if (altFt < Config::COLOR_LOW_ALT_THRESHOLD_FT)
@@ -193,34 +194,34 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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
// Fixed blue color for ground vehicle markers (ADS-B emitter category
// "C*", e.g. airport vehicles) - deliberately independent of
// colorForAltitude(), since ground vehicles are practically always at 0ft
// and would otherwise have the same color as low-flying aircraft,
// which would unnecessarily complicate the visual distinction
// (square vs. circle). Dimmed during night dimming, same pattern as
// 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.
// Shows the BEARING (not to be confused with the heading)
// to the currently selected aircraft: a thin, dotted line from the
// radar center to the circle edge in the direction of bearingDeg, plus the degree number
// directly outside the ring. Helps to actually find the aircraft
// in the sky ("look in this direction"). Only relevant for the moment
// of drawing - on the next sweep tick/redraw it is automatically
// erased and redrawn by the normal background redraw.
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.
// Dotted line: short segments instead of a solid line,
// so it visually differs from the compass cross lines and rings
// and does not look like a permanent UI element.
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
if (i % 2 != 0) continue; // only draw every second segment
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);
@@ -230,12 +231,12 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Bearing degree just outside the ring, at the point where the bearing
// line intersects the circle edge. The radius circle uses almost the full
// screen width (see computeLayout(): only 6px margin) - a label
// outside the ring would extend beyond the visible area
// for east/west bearings. Therefore: clamp label position to
// screen boundaries instead of blindly following the angle.
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));
@@ -248,11 +249,11 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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).
// Short course line in flight direction (headingDeg) WITH arrowhead at the end -
// a plain line without a tip was not clearly readable (not recognizable
// which end is "front"). The tip consists of two short strokes
// that branch diagonally outward just before the line tip (classic
// chevron/arrowhead symbol, as typical in ATC radar displays).
void drawAircraftMarker(TFT_eSPI& gfx, int16_t x, int16_t y, float headingDeg, uint16_t color) {
gfx.fillCircle(x, y, 5, color);
@@ -263,9 +264,9 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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).
// Arrowhead: two short strokes from the tip, each folded back 150 degrees
// to the course line (i.e. slightly pointing "backward",
// as is common for an arrowhead "^").
constexpr double WING_ANGLE_RAD = 150.0 * PI / 180.0;
constexpr int16_t WING_LEN = 4;
double wing1 = rad + WING_ANGLE_RAD;
@@ -278,21 +279,21 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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).
// Separate marker for ground vehicles (ADS-B category "C*") - a
// filled square instead of circle+arrowhead, so they are clearly
// distinguishable from real aircraft on the radar (heading/course is
// also usually not meaningful for ground vehicles).
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).
// Separate marker for helicopters (ADS-B emitter category "A7" =
// Rotorcraft) - a filled circle with a solid rotor cross instead of
// an arrowhead, since helicopters in hover have no meaningful
// "forward" course like a fixed-wing aircraft (same reasoning as the
// ground vehicle marker, which for the same reason also
// does without a course line).
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);
@@ -300,14 +301,14 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Separate marker for "Heavy" aircraft (ADS-B emitter category "A5" -
// aircraft over 136t maximum takeoff weight, e.g. A380/B747/B777/A330).
// Same structure as drawAircraftMarker() (circle + course line with
// arrowhead), but with a larger circle and an additional thin
// outer ring - recognizable at first glance as "larger/heavier",
// without introducing a completely new shape language. The color remains
// the usual altitude color (colorForAltitude()) - the shape conveys
// "Heavy", not an alarm/conspicuity state.
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);
@@ -368,12 +369,12 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Use the same colorForAltitude() function as for the aircraft markers
// themselves (with a typical altitude per band) - otherwise the
// legend would show the BRIGHT colors during active night dimming (sunset to sunrise),
// while the markers/labels on the radar are already dimmed.
// This caused e.g. a red aircraft to appear dark orange at night,
// even though the legend still showed pure red.
struct { uint16_t color; const char* label; } items[3] = {
{colorForAltitude(gfx, 0), lowLabel},
{colorForAltitude(gfx, Config::COLOR_LOW_ALT_THRESHOLD_FT), midLabel},
@@ -382,15 +383,15 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Outer columns (green/red) stay at their fixed 1/3 grid
// position (x0 = i*segW+6). The middle (yellow) column was
// previously positioned just as rigidly, but visually sat too
// far right: its label ("3000-9100") is noticeably longer than
// the outer labels, leaving hardly any space on the right to the
// red entry, while lots of empty space remained on the left next to the short green
// label. Now the yellow column is instead placed
// centered in the gap between the end of the green text and the start of the
// red dot.
int16_t x0Low = 0 * segW + 6;
int16_t x0High = 2 * segW + 6;
@@ -412,10 +413,10 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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
// Second legend row for the blue ground vehicle squares - only
// when they are actually visible (Flight Options >
// "Hide ground vehicles" == off). infoBarHeight() reserves
// additional space for this only in that case, see
// computeLayout().
if (!SettingsStore::hideGroundVehicles()) {
int16_t gy = (int16_t)(y + INFO_BAR_GROUND_ROW_H);
@@ -469,10 +470,10 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Ring labels (intermediate distances) now respect the
// unit setting (Menu > Units) - previously always in km,
// even when Imperial (nm) was set. Same conversion
// pattern as the range button below and the legend above.
bool metric = LocationManager::useMetricUnits();
float displayRange = metric ? rangeKm : Units::kmToNm(rangeKm);
char ringLabel[8];
@@ -483,14 +484,14 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Marquee state for ONE detail panel line - same basic
// principle as InfoMarquee above, but the detail panel has up to
// nine such lines simultaneously (airline, model, type, altitude,
// speed, climb/sink rate, distance/bearing, squawk, seats),
// hence a separate state per line instead of a single global
// instance. y/h/maxWidth/fg remember the line geometry so that
// tickDetailPanelMarquees() (see below) knows where/how to
// redraw each line without extra parameters.
struct LineMarquee {
String text;
String ring;
@@ -509,22 +510,22 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
};
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).
// Redraws a detail panel line when its text has changed
// (or the panel is being rebuilt) - remembers whether
// the text is too wide for the available width
// (needsScroll) and builds the ring buffer for the
// marquee if needed (same pattern as infoMarqueeWindow() above).
// Lines that are too wide ("Model: ...", "Distance: ...") are NO
// LONGER abbreviated with "..." but scrolled horizontally - see
// tickDetailPanelMarquees() below for the part that
// actually advances the scrolling between data updates
// (render() typically only supplies new values every ~300ms,
// which would be far too rare for smooth scrolling).
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.
// Always update geometry/color (even without text change) -
// tickDetailPanelMarquees() needs these values to redraw
// at the correct position on the next scroll step.
m.y = y;
m.h = h;
m.maxWidth = maxWidth;
@@ -534,7 +535,7 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
m.text = newText;
m.needsScroll = gfx.textWidth(newText) > maxWidth;
String withGap = newText + " "; // 3 Leerzeichen Luecke vor der Wiederholung
String withGap = newText + " "; // 3 space gap before repetition
m.ring = withGap + withGap;
m.charOffset = 0;
m.lastStepMs = millis();
@@ -545,11 +546,11 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Advances a single detail panel line if it is too
// wide (needsScroll) and enough time has passed since the last step
// - otherwise nothing happens (no unnecessary redrawing
// for lines that fit completely). Called by
// tickDetailPanelMarquees() for all nine lines.
void advanceAndDrawMarqueeLine(TFT_eSPI& gfx, LineMarquee& m) {
if (!m.needsScroll) return;
@@ -567,12 +568,12 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Lets all detail panel lines scroll - while the panel
// is open, tick() (every ~80ms) calls this, in BETWEEN much
// more often than render() (which only fires on new aircraft data,
// every ~300ms). Without this additional
// call, a long line would jump one step on each render() call
// but would not scroll smoothly.
void tickDetailPanelMarquees(TFT_eSPI& gfx) {
if (!lastPanel.valid) return;
advanceAndDrawMarqueeLine(gfx, lastPanel.airline);
@@ -632,11 +633,11 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Origin/destination airport (ICAO code) via AircraftDetails::get() -
// is queried together with the model (see aircraft_details.cpp),
// but resolved via the callsign instead of the hex code. Without
// a callsign (e.g. some VFR aircraft) the route remains
// fundamentally unknown, no lookup needed.
String routeLine;
if (!a.callsign[0]) {
routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN);
@@ -674,11 +675,11 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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).
// In addition to km/nm also miles (mi) - nm alone was
// not self-explanatory for lay users from countries with
// imperial units (especially the US), "miles" is the more common
// everyday distance unit there (nm still remains since it matches
// speed in kt: 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);
@@ -738,15 +739,15 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
}
}
// 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.
// Background stars OUTSIDE the radar circle (requested: the same
// decorative star twinkling as in the detail panel/menu, but without
// colliding with the constantly redrawn circle content - THAT would
// cause stuttering, since the sweep line/blips/rings within the
// circle are redrawn on every tick() anyway and would constantly
// swallow the stars there). Outside the circle the background remains
// permanently black and is touched by no one else - the stars can
// freely twinkle there, completely independent of sweep line/blip
// redraws.
constexpr uint8_t BG_STAR_COUNT = 20;
struct BgStar {
int16_t x, y;
@@ -756,15 +757,15 @@ L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64
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.
// Places each star via rejection sampling somewhere in the area
// [top..L.infoTop) x [0..SCREEN_WIDTH), but only if the point
// is outside radius+MARGIN around the circle center (MARGIN
// additionally keeps distance from the ring and the "N" compass label
// just outside the circle). If an attempt after 25 tries finds no
// suitable point (practically never, since there is always plenty
// of space left/right of the circle), the last attempted point is
// simply used - purely cosmetic feature, no reason for an
// infinite loop.
void initBgStarsIfNeeded(const Layout& L, int16_t top) {
if (bgStarsInitialized) return;
randomSeed((uint32_t)esp_random());
@@ -855,7 +856,7 @@ void render(TFT_eSPI& tft, int16_t top) {
bool selectionStillPresent = false;
Aircraft selected{};
uint8_t visibleCount = 0; // nach allen Filtern (Reichweite, Bodenfahrzeuge, Airline-Filter)
uint8_t visibleCount = 0; // after all filters (range, ground vehicles, airline filter)
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) hitPoints[i].valid = false;
@@ -876,14 +877,14 @@ if (AirlineFilter::isHidden(a.callsign)) continue;
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().
// ADS-B emitter category "C*" = ground vehicle (only ever
// visible if "Hide ground vehicles" is off, see filter
// above) - separate color/marker instead of altitude-based
// rendering, see 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.
// ADS-B emitter category "A5" = "Heavy" - separate marker shape (see
// drawHeavyMarker()), independent of any ring marking.
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;
@@ -947,7 +948,7 @@ if (AirlineFilter::isHidden(a.callsign)) continue;
lastPanel.valid = false;
int16_t infoTop = L.infoTop;
tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY);
// Info-Bar Button-Leiste: [?] [S] [L] [50km]
// Info bar button row: [?] [S] [L] [50km]
char rangeLabel[8];
bool rangeMetric = LocationManager::useMetricUnits();
if (rangeMetric) {
@@ -973,11 +974,11 @@ 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.
// Lets overly wide detail panel lines (e.g. "Model: ..." or
// "Distance: ...") scroll, instead of being statically truncated
// with "..." - render() only supplies new values every
// ~300ms, which is not enough for smooth scrolling,
// so it is additionally advanced here in the 80ms tick.
tickDetailPanelMarquees(tft);
tft.endWrite();
return;
@@ -988,13 +989,13 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
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.
// Twinkling outside the radar circle - runs at the same 80ms rate as
// the sweep line so it looks smooth. render() (on every aircraft
// update, every ~300ms) clears the entire content area with fillRect
// and redraws it WITHOUT re-setting the stars - this is
// intentional (just like the detail panel star field): the next
// tick() round (at most 80ms later) will simply make them
// twinkle again, which is too brief to be perceived as stuttering.
updateBgStars(tft, L, top);
if (prevSweepAngleDeg >= 0.0f) {
@@ -1015,16 +1016,16 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
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.
// Blink-off phase: previously a blanket 40x30px
// black rectangle was drawn to "hide" the marker -
// but that also covered the radar rings/compass lines
// underneath (equally for all altitude colors, since this
// blink logic is purely distance-based). Instead, exactly
// the same shapes that are drawn during normal rendering
// below (marker, emergency/watchlist ring, label) are
// now drawn in black - this makes them pixel-perfectly
// invisible again without touching anything outside
// those shapes.
if (hp.isGroundVehicle) {
drawGroundVehicleMarker(tft, hp.x, hp.y, TFT_BLACK);
} else if (hp.isRotorcraft) {
@@ -1068,9 +1069,9 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
tft.setTextDatum(TL_DATUM);
}
// Info-Zeile unten (Tap-Hinweis bzw. "Leerer Himmel"-Timer) als
// Laufende Aktualisierung der letzten Sichtung fuer den "Leerer
// Himmel"-Zaehler.
// Info line at bottom (tap hint or "Empty Sky" timer) as
// continuous update of the last sighting for the "Empty Sky"
// counter.
uint8_t visibleCountNow = 0;
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) {
if (hitPoints[i].valid) visibleCountNow++;
@@ -1539,10 +1540,10 @@ void runHelp(TFT_eSPI& tft, int16_t top) {
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.
// Forces a complete rebuild of the detail panel on the next
// render() - important if the screen was overwritten by another
// screen (e.g. the aircraft list), otherwise unchanged lines would
// be incorrectly skipped as "already there".
lastPanel.valid = false;
}