From 32f8e873a9119bde076b3401f6199734450a8790 Mon Sep 17 00:00:00 2001 From: Eiswolf-BG <37225519+Eiswolf-BG@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:52:22 +0200 Subject: [PATCH] Add flight route lookup, helicopter marker, and QR code for WebUI Detail panel now shows origin/destination via adsbdb.com callsign lookup. Rotorcraft (ADS-B category A7) get a distinct rotor-cross marker instead of a heading arrowhead, same rationale as the existing ground-vehicle marker. WebUI info screen gets a QR button (when WiFi is connected) that renders the local WebUI URL as a scannable code. Co-Authored-By: Claude Sonnet 5 --- platformio.ini | 1 + src/aircraft_details.cpp | 30 ++++++++++++++-- src/aircraft_details.h | 14 ++++++-- src/aircraft_list_screen.cpp | 2 +- src/i18n.h | 13 +++++++ src/i18n_de.h | 2 ++ src/i18n_en.h | 2 ++ src/i18n_es.h | 2 ++ src/i18n_fr.h | 2 ++ src/i18n_it.h | 2 ++ src/i18n_tr.h | 2 ++ src/radar_screen.cpp | 58 ++++++++++++++++++++++++++---- src/radar_screen.h | 2 +- src/webui_screen.cpp | 68 +++++++++++++++++++++++++++++++++++- 14 files changed, 186 insertions(+), 14 deletions(-) diff --git a/platformio.ini b/platformio.ini index 721caae..981cb62 100644 --- a/platformio.ini +++ b/platformio.ini @@ -18,6 +18,7 @@ lib_deps = https://github.com/PaulStoffregen/XPT2046_Touchscreen.git bblanchon/ArduinoJson @ ^7.0.4 mikalhart/TinyGPSPlus @ ^1.1.0 + ricmoo/QRCode @ ^0.0.1 build_flags = -D USER_SETUP_LOADED=1 diff --git a/src/aircraft_details.cpp b/src/aircraft_details.cpp index 53fea5b..b066cfc 100644 --- a/src/aircraft_details.cpp +++ b/src/aircraft_details.cpp @@ -12,6 +12,7 @@ namespace { SemaphoreHandle_t mutex = nullptr; char pendingHex[7] = {0}; + char pendingCallsign[9] = {0}; bool hasPending = false; char cachedHex[7] = {0}; @@ -33,11 +34,12 @@ namespace { } } -void request(const char* hex) { +void request(const char* hex, const char* callsign) { ensureMutex(); xSemaphoreTake(mutex, portMAX_DELAY); if (strcmp(cachedHex, hex) != 0 && strcmp(pendingHex, hex) != 0) { strncpy(pendingHex, hex, sizeof(pendingHex) - 1); + strncpy(pendingCallsign, callsign ? callsign : "", sizeof(pendingCallsign) - 1); hasPending = true; } xSemaphoreGive(mutex); @@ -60,11 +62,13 @@ void update() { ensureMutex(); char hex[7] = {0}; + char callsign[9] = {0}; bool doWork = false; xSemaphoreTake(mutex, portMAX_DELAY); if (hasPending) { strncpy(hex, pendingHex, sizeof(hex) - 1); + strncpy(callsign, pendingCallsign, sizeof(callsign) - 1); doWork = true; } xSemaphoreGive(mutex); @@ -116,6 +120,28 @@ void update() { } } + // Flugroute (Start-/Zielflughafen) - derselbe adsbdb.com-Dienst wie oben, + // aber ueber den Callsign-Endpunkt statt Hex-Code abgefragt (liefert + // dafuer origin/destination-Flughafendaten zurueck). Nur versuchen, wenn + // ueberhaupt ein Rufzeichen bekannt ist - Sichtflug-Maschinen ohne + // Callsign haben ohnehin keine darueber auswertbare Route. + String trimmedCallsign = String(callsign); + trimmedCallsign.trim(); + if (trimmedCallsign.length() > 0) { + client.setTimeout(4000); + String body3; + if (httpGetString(client, String("https://api.adsbdb.com/v0/callsign/") + trimmedCallsign, body3)) { + JsonDocument doc3; + DeserializationError err3 = deserializeJson(doc3, body3); + if (!err3) { + const char* originIcao = doc3["response"]["flightroute"]["origin"]["icao_code"] | ""; + const char* destIcao = doc3["response"]["flightroute"]["destination"]["icao_code"] | ""; + strncpy(result.routeOrigin, originIcao, sizeof(result.routeOrigin) - 1); + strncpy(result.routeDest, destIcao, sizeof(result.routeDest) - 1); + } + } + } + xSemaphoreTake(mutex, portMAX_DELAY); strncpy(cachedHex, hex, sizeof(cachedHex) - 1); cached = result; @@ -123,4 +149,4 @@ void update() { xSemaphoreGive(mutex); } -} \ No newline at end of file +} diff --git a/src/aircraft_details.h b/src/aircraft_details.h index 0436cd7..7a1b8c6 100644 --- a/src/aircraft_details.h +++ b/src/aircraft_details.h @@ -10,11 +10,19 @@ namespace AircraftDetails { struct Info { bool loading = false; char model[40] = {0}; // e.g. "Airbus A320 216", empty if unknown + + // Departure/destination airport (ICAO code, e.g. "KIAH"/"EDDF") of + // the current flight route, looked up by callsign via the same + // adsbdb.com service used for the model fallback above - empty if + // no callsign is known or no route was found for it. + char routeOrigin[8] = {0}; + char routeDest[8] = {0}; }; // Called from Core 1 (touch selection): marks that details should be - // fetched for this aircraft (if not already done). - void request(const char* hex); + // fetched for this aircraft (if not already done). callsign may be + // empty (no route lookup is attempted in that case). + void request(const char* hex, const char* callsign); // Called from Core 1 to get the current (possibly still incomplete) // state for 'hex'. @@ -24,4 +32,4 @@ namespace AircraftDetails { // (blocking HTTPS call, but that's fine - runs in the background and // only briefly delays the next ADS-B poll). void update(); -} \ No newline at end of file +} diff --git a/src/aircraft_list_screen.cpp b/src/aircraft_list_screen.cpp index 27810a4..41ef1da 100644 --- a/src/aircraft_list_screen.cpp +++ b/src/aircraft_list_screen.cpp @@ -214,7 +214,7 @@ bool run(TFT_eSPI& tft) { } else { for (uint8_t i = 0; i < visibleRowCount; i++) { if (rowRects[i].contains(tap.x, tap.y)) { - RadarScreen::selectAircraft(snapshot[scrollTop + i].hex); + RadarScreen::selectAircraft(snapshot[scrollTop + i].hex, snapshot[scrollTop + i].callsign); selected = true; break; } diff --git a/src/i18n.h b/src/i18n.h index b394a5a..e138236 100644 --- a/src/i18n.h +++ b/src/i18n.h @@ -303,6 +303,19 @@ enum class StringId : uint8_t { // instead of waiting it out. FIRST_RUN_COMPLETE_TAP_TO_SKIP, + // Zeilenpraefix im Radar-Detailpanel (radar_screen.cpp::drawDetailPanel) + // fuer die Flugroute (Start- -> Zielflughafen, ICAO-Code), abgefragt + // ueber AircraftDetails per Rufzeichen. Faellt wie MODEL/TYPE/SQUAWK auf + // DETAIL_UNKNOWN zurueck, wenn kein Rufzeichen bekannt ist oder keine + // Route gefunden wurde. + DETAIL_ROUTE, + + // Hinweiszeile ueber dem QR-Code auf dem neuen QR-Unterscreen der + // Logbuch/WebUI-Seite (webui_screen.cpp::runQrScreen) - der WEBUI_TITLE- + // String wird fuer die Kopfzeile wiederverwendet, kein eigener Titel + // noetig. + WEBUI_QR_HINT, + COUNT }; diff --git a/src/i18n_de.h b/src/i18n_de.h index 96be8c3..8360516 100644 --- a/src/i18n_de.h +++ b/src/i18n_de.h @@ -238,6 +238,8 @@ static const char* const I18N_DE[] = { "Lösche alle Daten...", "Zurücksetzen fehlgeschlagen.", "Tippen zum Überspringen", + "Route: ", + "Mit dem Handy scannen:", }; static_assert(sizeof(I18N_DE) / sizeof(I18N_DE[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_en.h b/src/i18n_en.h index fe07770..8f8355c 100644 --- a/src/i18n_en.h +++ b/src/i18n_en.h @@ -239,6 +239,8 @@ static const char* const I18N_EN[] = { "Deleting all data...", "Reset failed.", "Tap to skip", + "Route: ", + "Scan with your phone:", }; static_assert(sizeof(I18N_EN) / sizeof(I18N_EN[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_es.h b/src/i18n_es.h index 4939f14..2a206e8 100644 --- a/src/i18n_es.h +++ b/src/i18n_es.h @@ -238,6 +238,8 @@ static const char* const I18N_ES[] = { "Eliminando todos los datos...", "Error al reiniciar.", "Toca para saltar", + "Ruta: ", + "Escanea con el móvil:", }; static_assert(sizeof(I18N_ES) / sizeof(I18N_ES[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_fr.h b/src/i18n_fr.h index ef6125b..d9241e9 100644 --- a/src/i18n_fr.h +++ b/src/i18n_fr.h @@ -238,6 +238,8 @@ static const char* const I18N_FR[] = { "Suppression de toutes les données...", "Échec de la réinitialisation.", "Toucher pour passer", + "Trajet : ", + "Scanner avec le téléphone :", }; static_assert(sizeof(I18N_FR) / sizeof(I18N_FR[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_it.h b/src/i18n_it.h index fded9e0..f898835 100644 --- a/src/i18n_it.h +++ b/src/i18n_it.h @@ -238,6 +238,8 @@ static const char* const I18N_IT[] = { "Eliminazione di tutti i dati...", "Reimpostazione fallita.", "Tocca per saltare", + "Rotta: ", + "Scansiona con il telefono:", }; static_assert(sizeof(I18N_IT) / sizeof(I18N_IT[0]) == (size_t)StringId::COUNT, diff --git a/src/i18n_tr.h b/src/i18n_tr.h index aa96055..e0d7722 100644 --- a/src/i18n_tr.h +++ b/src/i18n_tr.h @@ -238,6 +238,8 @@ static const char* const I18N_TR[] = { "Tüm veriler siliniyor...", "Sıfırlama başarısız.", "Atlamak için dokun", + "Rota: ", + "Telefonla tarayın:", }; static_assert(sizeof(I18N_TR) / sizeof(I18N_TR[0]) == (size_t)StringId::COUNT, diff --git a/src/radar_screen.cpp b/src/radar_screen.cpp index 6ddf5f3..8cf3bee 100644 --- a/src/radar_screen.cpp +++ b/src/radar_screen.cpp @@ -57,7 +57,12 @@ namespace { return L; } - constexpr int16_t DETAIL_PANEL_H = 264; + // +22px gegenueber frueher (264) fuer die neue Route-Zeile (siehe + // drawDetailPanel()) - beeinflusst NUR die Groesse des Detail-Panel- + // Overlays, nicht das normale Radar-Layout (computeLayout() oben + // richtet sich ausschliesslich nach infoBarHeight(), nicht nach + // DETAIL_PANEL_H). + constexpr int16_t DETAIL_PANEL_H = 286; struct HitPoint { int16_t x, y; @@ -70,6 +75,7 @@ namespace { bool isEmergency; bool isWatched; bool isGroundVehicle; + bool isRotorcraft; }; constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT; HitPoint hitPoints[MAX_HIT_POINTS]; @@ -333,6 +339,19 @@ namespace { gfx.fillRect((int16_t)(x - HALF), (int16_t)(y - HALF), (int16_t)(2 * HALF), (int16_t)(2 * HALF), color); } + // Eigener Marker fuer Hubschrauber (ADS-B-Emitter-Kategorie "A7" = + // Rotorcraft) - ein gefuellter Kreis mit durchgehendem Rotorkreuz statt + // Pfeilkopf, da Hubschrauber im Schwebeflug keinen aussagekraeftigen + // "nach vorne"-Kurs wie ein Flugzeug haben (gleiche Ueberlegung wie beim + // Bodenfahrzeug-Marker, der aus demselben Grund ebenfalls ohne + // Kurslinie auskommt). + void drawHelicopterMarker(TFT_eSPI& gfx, int16_t x, int16_t y, uint16_t color) { + constexpr int16_t ROTOR_LEN = 8; + gfx.fillCircle(x, y, 4, color); + gfx.drawLine((int16_t)(x - ROTOR_LEN), y, (int16_t)(x + ROTOR_LEN), y, color); + gfx.drawLine(x, (int16_t)(y - ROTOR_LEN), x, (int16_t)(y + ROTOR_LEN), color); + } + 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) { @@ -507,7 +526,7 @@ namespace { bool valid = false; char hex[7] = {0}; String callsignText; - LineMarquee airline, model, type, alt, speed, climb, distHeading, squawk, seats; + LineMarquee airline, model, type, route, alt, speed, climb, distHeading, squawk, seats; }; PanelState lastPanel; @@ -580,6 +599,7 @@ namespace { advanceAndDrawMarqueeLine(gfx, lastPanel.airline); advanceAndDrawMarqueeLine(gfx, lastPanel.model); advanceAndDrawMarqueeLine(gfx, lastPanel.type); + advanceAndDrawMarqueeLine(gfx, lastPanel.route); advanceAndDrawMarqueeLine(gfx, lastPanel.alt); advanceAndDrawMarqueeLine(gfx, lastPanel.speed); advanceAndDrawMarqueeLine(gfx, lastPanel.climb); @@ -633,6 +653,24 @@ namespace { updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.type, typeLine, forceFull); y += LINE_H; + // Start-/Zielflughafen (ICAO-Code) ueber AircraftDetails::get() - + // wird zusammen mit dem Modell abgefragt (siehe aircraft_details.cpp), + // aber ueber das Rufzeichen statt den Hex-Code aufgeloest. Ohne + // Rufzeichen (z.B. manche Sichtflug-Maschinen) bleibt die Route + // grundsaetzlich unbekannt, kein Abruf noetig. + String routeLine; + if (!a.callsign[0]) { + routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN); + } else if (details.loading) { + routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_LOADING_DOTS); + } else if (details.routeOrigin[0] && details.routeDest[0]) { + routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + details.routeOrigin + " -> " + details.routeDest; + } else { + routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN); + } + updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.route, routeLine, forceFull); + y += LINE_H; + char buf[48]; snprintf(buf, sizeof(buf), "%s%.0fm / %.0fft", I18n::t(StringId::DETAIL_ALT), Units::feetToMeters((float)a.altBaroFt), (float)a.altBaroFt); @@ -860,6 +898,7 @@ void render(TFT_eSPI& tft, int16_t top) { // oben) - eigene Farbe/Marker statt der hoehenbasierten Flugzeug- // Darstellung, siehe drawGroundVehicleMarker()/colorForGroundVehicle(). bool isGroundVehicle = a.category[0] == 'C'; + bool isRotorcraft = a.category[0] == 'A' && a.category[1] == '7'; 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); @@ -873,6 +912,8 @@ void render(TFT_eSPI& tft, int16_t top) { } if (isGroundVehicle) { drawGroundVehicleMarker(tft, pt.x, pt.y, color); + } else if (isRotorcraft) { + drawHelicopterMarker(tft, pt.x, pt.y, color); } else { drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color); } @@ -898,6 +939,7 @@ void render(TFT_eSPI& tft, int16_t top) { hitPoints[i].isEmergency = isEmergency; hitPoints[i].isWatched = isWatched; hitPoints[i].isGroundVehicle = isGroundVehicle; + hitPoints[i].isRotorcraft = isRotorcraft; strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1); strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1); } @@ -1007,6 +1049,8 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) { // beruehren. if (hp.isGroundVehicle) { drawGroundVehicleMarker(tft, hp.x, hp.y, TFT_BLACK); + } else if (hp.isRotorcraft) { + drawHelicopterMarker(tft, hp.x, hp.y, TFT_BLACK); } else { drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK); } @@ -1023,6 +1067,8 @@ void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) { if (hp.isGroundVehicle) { drawGroundVehicleMarker(tft, hp.x, hp.y, hp.color); + } else if (hp.isRotorcraft) { + drawHelicopterMarker(tft, hp.x, hp.y, hp.color); } else { drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color); } @@ -1108,7 +1154,7 @@ bool handleTap(int16_t x, int16_t y, int16_t top) { 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); + AircraftDetails::request(hitPoints[i].hex, hitPoints[i].callsign); return true; } } @@ -1129,7 +1175,7 @@ bool handleTap(int16_t x, int16_t y, int16_t top) { 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); + AircraftDetails::request(hitPoints[i].hex, hitPoints[i].callsign); return true; } } @@ -1152,9 +1198,9 @@ bool handleTap(int16_t x, int16_t y, int16_t top) { return true; } -void selectAircraft(const char* hex) { +void selectAircraft(const char* hex, const char* callsign) { strncpy(selectedHex, hex, sizeof(selectedHex) - 1); - AircraftDetails::request(hex); + 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 diff --git a/src/radar_screen.h b/src/radar_screen.h index 1369788..15ca6d2 100644 --- a/src/radar_screen.h +++ b/src/radar_screen.h @@ -12,7 +12,7 @@ namespace RadarScreen { // aus, nicht per Antippen auf dem Radar) - damit beim naechsten render() // sofort das Detail-Panel fuer dieses Flugzeug erscheint, so als haette // man es direkt im Radar angetippt. - void selectAircraft(const char* hex); + void selectAircraft(const char* hex, const char* callsign); struct EmergencyInfo { bool active = false; diff --git a/src/webui_screen.cpp b/src/webui_screen.cpp index 1e276ad..e66e2a2 100644 --- a/src/webui_screen.cpp +++ b/src/webui_screen.cpp @@ -4,6 +4,7 @@ #include "config.h" #include "i18n.h" #include +#include namespace WebUiScreen { @@ -53,6 +54,63 @@ namespace { } return y; } + + // QR-Code-Unterscreen: zeigt die WebUI-URL als QR-Code, damit man sie + // nicht mehr von Hand ins Handy tippen muss. Nutzt die ricmoo/QRCode- + // Bibliothek (kein dynamisches Allozieren, Version fest auf 4/33x33 + // Module mit niedrigster Fehlerkorrektur, reicht locker fuer eine kurze + // "http://192.168.x.x/"-URL). + void runQrScreen(TFT_eSPI& tft, const String& url) { + QRCode qrcode; + uint8_t qrData[qrcode_getBufferSize(4)]; + qrcode_initText(&qrcode, qrData, 4, ECC_LOW, url.c_str()); + + constexpr int16_t MODULE_PX = 6; + constexpr int16_t QUIET_ZONE = 4; + int16_t qrPx = qrcode.size * MODULE_PX; + int16_t boxPx = qrPx + 2 * QUIET_ZONE * MODULE_PX; + int16_t boxX = (Config::SCREEN_WIDTH - boxPx) / 2; + int16_t boxY = 30; + + tft.fillScreen(TFT_BLACK); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.setCursor(10, 14); + tft.println(I18n::t(StringId::WEBUI_TITLE)); + + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_GREEN, TFT_BLACK); + tft.drawString(I18n::t(StringId::WEBUI_QR_HINT), Config::SCREEN_WIDTH / 2, boxY - 6); + tft.setTextDatum(TL_DATUM); + + tft.fillRect(boxX, boxY + 10, boxPx, boxPx, TFT_WHITE); + for (uint8_t y = 0; y < qrcode.size; y++) { + for (uint8_t x = 0; x < qrcode.size; x++) { + if (qrcode_getModule(&qrcode, x, y)) { + int16_t px = boxX + QUIET_ZONE * MODULE_PX + x * MODULE_PX; + int16_t py = boxY + 10 + QUIET_ZONE * MODULE_PX + y * MODULE_PX; + tft.fillRect(px, py, MODULE_PX, MODULE_PX, TFT_BLACK); + } + } + } + + int16_t urlY = boxY + 10 + boxPx + 16; + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.drawString(url, Config::SCREEN_WIDTH / 2, urlY); + tft.setTextDatum(TL_DATUM); + + Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40}; + drawButton(tft, backBtn, I18n::t(StringId::BACK)); + + while (true) { + TouchInput::Point tap; + if (TouchInput::wasTapped(tap)) { + if (backBtn.contains(tap.x, tap.y)) return; + } + MenuStars::update(tft); + delay(20); + } + } } void run(TFT_eSPI& tft) { @@ -90,6 +148,8 @@ void run(TFT_eSPI& tft) { Rect downBtn = {190, (int16_t)(Config::SCREEN_HEIGHT - 50), 38, 40}; constexpr int16_t SCROLL_STEP = 48; + Rect qrBtn = {(int16_t)(Config::SCREEN_WIDTH - 40), 2, 30, 24}; + auto redraw = [&]() { tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK); @@ -117,6 +177,9 @@ void run(TFT_eSPI& tft) { drawButton(tft, upBtn, "^"); drawButton(tft, downBtn, "v"); } + if (wifiConnected) { + drawButton(tft, qrBtn, "QR"); + } }; redraw(); @@ -125,7 +188,10 @@ void run(TFT_eSPI& tft) { TouchInput::Point tap; if (TouchInput::wasTapped(tap)) { if (backBtn.contains(tap.x, tap.y)) return; - if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) { + if (wifiConnected && qrBtn.contains(tap.x, tap.y)) { + runQrScreen(tft, urlLine); + redraw(); + } else if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) { scrollY -= SCROLL_STEP; if (scrollY < 0) scrollY = 0; redraw();