Scrolling marquee for radar info line, clearer tap hint text
The bottom info line on the radar screen (tap hint / empty-sky timer) is now a continuously scrolling marquee instead of static text, consistent with the nearest-airport ticker on the Location Presets screen. The text is redrawn every tick() call (80ms) rather than only on render(), so it scrolls smoothly and the empty-sky second counter keeps advancing fluidly. Also clarifies the tap hint in all 6 languages to make it explicit that you need to tap an aircraft (e.g. "Tap an aircraft for more details" instead of the more ambiguous "Tap for details"). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -133,7 +133,7 @@ static const char* const I18N_DE[] = {
|
|||||||
"Das Gerät bietet auch eine kleine Webseite zum Exportieren des kompletten Flugbuchs an. Im selben WLAN einfach im Browser aufrufen:",
|
"Das Gerät bietet auch eine kleine Webseite zum Exportieren des kompletten Flugbuchs an. Im selben WLAN einfach im Browser aufrufen:",
|
||||||
"Keine WLAN-Verbindung - die Export-Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.",
|
"Keine WLAN-Verbindung - die Export-Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.",
|
||||||
|
|
||||||
"Für Details antippen",
|
"Für mehr Details ein Flugzeug antippen",
|
||||||
"Leerer Himmel seit ",
|
"Leerer Himmel seit ",
|
||||||
|
|
||||||
"Modell: ",
|
"Modell: ",
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ static const char* const I18N_EN[] = {
|
|||||||
"The device also runs a small web page for exporting the full logbook. While connected to the same WiFi network, open a browser and go to:",
|
"The device also runs a small web page for exporting the full logbook. While connected to the same WiFi network, open a browser and go to:",
|
||||||
"No WiFi connection - the export page is only reachable while connected to a network.",
|
"No WiFi connection - the export page is only reachable while connected to a network.",
|
||||||
|
|
||||||
"Tap for details",
|
"Tap an aircraft for more details",
|
||||||
"Empty sky for ",
|
"Empty sky for ",
|
||||||
|
|
||||||
"Model: ",
|
"Model: ",
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ static const char* const I18N_ES[] = {
|
|||||||
"El dispositivo también ofrece una pequeña página web para exportar el registro completo. Conectado a la misma red WiFi, simplemente abra un navegador y vaya a:",
|
"El dispositivo también ofrece una pequeña página web para exportar el registro completo. Conectado a la misma red WiFi, simplemente abra un navegador y vaya a:",
|
||||||
"Sin conexión WiFi - la página de exportación solo es accesible mientras haya una conexión de red.",
|
"Sin conexión WiFi - la página de exportación solo es accesible mientras haya una conexión de red.",
|
||||||
|
|
||||||
"Toque para ver detalles",
|
"Toque un avión para ver más detalles",
|
||||||
"Cielo vacío desde hace ",
|
"Cielo vacío desde hace ",
|
||||||
|
|
||||||
"Modelo: ",
|
"Modelo: ",
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ static const char* const I18N_FR[] = {
|
|||||||
"L'appareil propose aussi une petite page web pour exporter le journal complet. Connecté au même réseau WiFi, ouvrez simplement un navigateur et allez sur :",
|
"L'appareil propose aussi une petite page web pour exporter le journal complet. Connecté au même réseau WiFi, ouvrez simplement un navigateur et allez sur :",
|
||||||
"Pas de connexion WiFi - la page d'export n'est accessible que lorsqu'une connexion réseau est active.",
|
"Pas de connexion WiFi - la page d'export n'est accessible que lorsqu'une connexion réseau est active.",
|
||||||
|
|
||||||
"Touchez pour les détails",
|
"Touchez un avion pour plus de détails",
|
||||||
"Ciel vide depuis ",
|
"Ciel vide depuis ",
|
||||||
|
|
||||||
"Modèle : ",
|
"Modèle : ",
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ static const char* const I18N_IT[] = {
|
|||||||
"Il dispositivo offre anche una piccola pagina web per esportare l'intero registro. Connesso alla stessa rete WiFi, apri semplicemente un browser e vai su:",
|
"Il dispositivo offre anche una piccola pagina web per esportare l'intero registro. Connesso alla stessa rete WiFi, apri semplicemente un browser e vai su:",
|
||||||
"Nessuna connessione WiFi - la pagina di esportazione è raggiungibile solo quando è attiva una connessione di rete.",
|
"Nessuna connessione WiFi - la pagina di esportazione è raggiungibile solo quando è attiva una connessione di rete.",
|
||||||
|
|
||||||
"Tocca per i dettagli",
|
"Tocca un aereo per maggiori dettagli",
|
||||||
"Cielo vuoto da ",
|
"Cielo vuoto da ",
|
||||||
|
|
||||||
"Modello: ",
|
"Modello: ",
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ static const char* const I18N_TR[] = {
|
|||||||
"Cihaz ayrıca tam kayıt defterini dışa aktarmak için küçük bir web sayfası da sunar. Aynı WiFi ağındayken tarayıcıda şu adresi açmanız yeterli:",
|
"Cihaz ayrıca tam kayıt defterini dışa aktarmak için küçük bir web sayfası da sunar. Aynı WiFi ağındayken tarayıcıda şu adresi açmanız yeterli:",
|
||||||
"WiFi bağlantısı yok - dışa aktarma sayfasına yalnızca bir ağ bağlantısı varken ulaşılabilir.",
|
"WiFi bağlantısı yok - dışa aktarma sayfasına yalnızca bir ağ bağlantısı varken ulaşılabilir.",
|
||||||
|
|
||||||
"Detaylar için dokunun",
|
"Daha fazla ayrıntı için bir uçağa dokunun",
|
||||||
"Boş gökyüzü: ",
|
"Boş gökyüzü: ",
|
||||||
|
|
||||||
"Model: ",
|
"Model: ",
|
||||||
|
|||||||
@@ -73,6 +73,100 @@ namespace {
|
|||||||
|
|
||||||
bool ledBlinkOn = true;
|
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.
|
||||||
|
void updateInfoMarqueeText(const String& text) {
|
||||||
|
infoMarquee.text = text;
|
||||||
|
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) {
|
bool isEmergencySquawk(const char* squawk) {
|
||||||
if (!squawk[0]) return false;
|
if (!squawk[0]) return false;
|
||||||
for (uint8_t i = 0; i < Config::EMERGENCY_SQUAWK_COUNT; i++) {
|
for (uint8_t i = 0; i < Config::EMERGENCY_SQUAWK_COUNT; i++) {
|
||||||
@@ -653,10 +747,6 @@ void render(TFT_eSPI& tft, int16_t top) {
|
|||||||
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
|
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein
|
|
||||||
// Flugzeug sichtbar war (nach allen Filtern). Statisch, damit der Wert
|
|
||||||
// über mehrere render()-Aufrufe hinweg erhalten bleibt.
|
|
||||||
static uint32_t lastAircraftSeenMs = millis();
|
|
||||||
if (visibleCount > 0) lastAircraftSeenMs = millis();
|
if (visibleCount > 0) lastAircraftSeenMs = millis();
|
||||||
|
|
||||||
if (selectedHex[0] && !selectionStillPresent) {
|
if (selectedHex[0] && !selectionStillPresent) {
|
||||||
@@ -671,29 +761,11 @@ void render(TFT_eSPI& tft, int16_t top) {
|
|||||||
lastPanel.valid = false;
|
lastPanel.valid = false;
|
||||||
int16_t infoTop = L.infoTop;
|
int16_t infoTop = L.infoTop;
|
||||||
tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY);
|
tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY);
|
||||||
tft.setTextColor(TFT_WHITE, TFT_BLACK);
|
// Der Info-Text (Tap-Hinweis bzw. "Leerer Himmel"-Timer) wird NICHT
|
||||||
tft.setCursor(8, infoTop + 20);
|
// mehr hier gezeichnet, sondern laufend in tick() als Laufschrift
|
||||||
if (visibleCount == 0) {
|
// (siehe dort) - render() laeuft nur bei geaenderten Flugdaten
|
||||||
// Kein Flugzeug in Reichweite - statt des "Fuer Details
|
// (alle paar Sekunden), tick() dagegen alle 80ms, was fuer eine
|
||||||
// antippen"-Hinweises (der hier ohnehin ins Leere liefe) zeigen
|
// fluessige Laufschrift-Animation noetig ist.
|
||||||
// wir an, wie lange der Himmel schon leer ist.
|
|
||||||
uint32_t emptySec = (millis() - lastAircraftSeenMs) / 1000;
|
|
||||||
char buf[16];
|
|
||||||
if (emptySec < 60) {
|
|
||||||
snprintf(buf, sizeof(buf), "%lus", (unsigned long)emptySec);
|
|
||||||
} else {
|
|
||||||
// Ab der ersten vollen Minute NUR noch Minuten anzeigen (ohne
|
|
||||||
// Sekunden) - "1min 05s" war zu lang und stiess an den
|
|
||||||
// Reichweiten-Button rechts daneben. Abgerundet (60-119s =
|
|
||||||
// "1min", 120-179s = "2min", usw.) - intuitiv wie eine
|
|
||||||
// normale Stoppuhr-Minutenanzeige.
|
|
||||||
unsigned long minutes = emptySec / 60;
|
|
||||||
snprintf(buf, sizeof(buf), "%lumin", minutes);
|
|
||||||
}
|
|
||||||
tft.print(String(I18n::t(StringId::RADAR_EMPTY_SKY_PREFIX)) + buf);
|
|
||||||
} else {
|
|
||||||
tft.print(I18n::t(StringId::RADAR_TAP_FOR_DETAILS));
|
|
||||||
}
|
|
||||||
|
|
||||||
char rangeLabel[8];
|
char rangeLabel[8];
|
||||||
snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm);
|
snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm);
|
||||||
@@ -770,6 +842,61 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) {
|
|||||||
tft.setTextDatum(TL_DATUM);
|
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(infoText);
|
||||||
|
}
|
||||||
|
drawInfoMarquee(tft, INFO_TEXT_X, infoTextY, infoTextW);
|
||||||
|
|
||||||
tft.endWrite();
|
tft.endWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user