#include "radar_screen.h" #include "config.h" #include "aircraft.h" #include "aircraft_table.h" #include "airline_lookup.h" #include "aircraft_details.h" #include "radar_math.h" #include "units.h" #include "settings_store.h" #include "led_alert.h" #include "location_manager.h" #include "sun_times.h" #include "world_map.h" #include "airline_filter.h" #include "aircraft_watchlist.h" #include "i18n.h" #include "menu_stars.h" #include "touch_input.h" #include #include #include namespace RadarScreen { namespace { struct Rect { int16_t x, y, w, h; bool contains(int16_t px, int16_t py) const { return px >= x && px < x + w && py >= y && py < y + h; } }; struct Layout { int16_t cx, cy, radius; Rect rangeBtn; Rect listBtn; Rect altBtn; Rect searchBtn; Rect helpBtn; int16_t infoTop; }; constexpr int16_t INFO_BAR_H = 64; // 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() { return SettingsStore::hideGroundVehicles() ? INFO_BAR_H : (int16_t)(INFO_BAR_H + INFO_BAR_GROUND_ROW_H); } Layout computeLayout(int16_t top) { Layout L; L.infoTop = Config::SCREEN_HEIGHT - infoBarHeight(); constexpr int16_t TOP_LABEL_MARGIN = 10; int16_t maxRadiusByWidth = Config::SCREEN_WIDTH / 2 - 6; int16_t maxRadiusByHeight = (L.infoTop - top - TOP_LABEL_MARGIN) / 2 - 6; L.radius = min(maxRadiusByWidth, maxRadiusByHeight); L.cx = Config::SCREEN_WIDTH / 2; L.cy = top + L.radius + 6 + TOP_LABEL_MARGIN; L.rangeBtn = {(int16_t)(Config::SCREEN_WIDTH - 72), (int16_t)(L.infoTop + 4), 64, 22}; L.listBtn = {128, (int16_t)(L.infoTop + 4), 36, 22}; L.altBtn = {88, (int16_t)(L.infoTop + 4), 36, 22}; L.searchBtn = {48, (int16_t)(L.infoTop + 4), 36, 22}; L.helpBtn = {8, (int16_t)(L.infoTop + 4), 36, 22}; return L; } // +22px 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; struct HitPoint { int16_t x, y; bool valid; char hex[7]; char callsign[9]; uint16_t color; float headingDeg; float distanceKm; bool isEmergency; bool isWatched; bool isGroundVehicle; bool isRotorcraft; bool isHeavy; }; constexpr uint8_t MAX_HIT_POINTS = Config::MAX_TRACKED_AIRCRAFT; HitPoint hitPoints[MAX_HIT_POINTS]; char selectedHex[7] = {0}; uint32_t lastEmptyTapMs = 0; int16_t lastEmptyTapX = -1000; int16_t lastEmptyTapY = -1000; constexpr uint32_t DOUBLE_TAP_MS = 400; constexpr int16_t DOUBLE_TAP_RADIUS = 25; bool ledBlinkOn = true; // "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(); // 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) { String s = src.substring(startIdx); while (s.length() > 1 && tft.textWidth(s) > maxWidth) { s.remove(s.length() - 1); } return s; } bool isEmergencySquawk(const char* squawk) { for (uint8_t i = 0; i < Config::EMERGENCY_SQUAWK_COUNT; i++) { if (strcmp(squawk, Config::EMERGENCY_SQUAWKS[i]) == 0) return true; } return false; } float sweepAngleDeg = 0.0f; float prevSweepAngleDeg = -1.0f; constexpr float SWEEP_DEGREES_PER_SEC = 45.0f; // 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; // time not yet synced via NTP struct tm tmNow; localtime_r(&now, &tmNow); double lat = 0, lon = 0; LocationManager::getHomeLocation(lat, lon); if (lat != 0.0 || lon != 0.0) { SunTimes::Result sun = SunTimes::compute(lat, lon, tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday, LocationManager::utcOffsetSeconds()); if (sun.valid) { if (sun.alwaysDay) return false; if (sun.alwaysNight) return true; float hourNow = tmNow.tm_hour + tmNow.tm_min / 60.0f; return (hourNow < sun.sunriseHour) || (hourNow >= sun.sunsetHour); } } int hour = tmNow.tm_hour; return (hour >= 22 || hour < 6); } // 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(); } // Color by altitude - dimmed (darker green/olive) during // night dimming. // // 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) 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; } // 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; } // 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); // 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; // 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); 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); } // 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)); 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); } // 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); 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); // 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; 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); } // 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); } // 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); 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); } // 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); double rad = headingDeg * PI / 180.0; int16_t dx = (int16_t)(sin(rad) * 10); int16_t dy = (int16_t)(-cos(rad) * 10); int16_t tipX = x + dx; int16_t tipY = y + dy; gfx.drawLine(x, y, tipX, tipY, color); constexpr double WING_ANGLE_RAD = 150.0 * PI / 180.0; constexpr int16_t WING_LEN = 4; double wing1 = rad + WING_ANGLE_RAD; double wing2 = rad - WING_ANGLE_RAD; int16_t w1x = tipX + (int16_t)(sin(wing1) * WING_LEN); int16_t w1y = tipY + (int16_t)(-cos(wing1) * WING_LEN); int16_t w2x = tipX + (int16_t)(sin(wing2) * WING_LEN); int16_t w2y = tipY + (int16_t)(-cos(wing2) * WING_LEN); gfx.drawLine(tipX, tipY, w1x, w1y, color); gfx.drawLine(tipX, tipY, w2x, w2y, color); } void printLineTruncated(TFT_eSPI& gfx, int16_t x, int16_t y, int16_t maxWidth, const String& text) { String s = text; if (gfx.textWidth(s) > maxWidth) { while (s.length() > 1 && gfx.textWidth(s + "...") > maxWidth) { s.remove(s.length() - 1); } s += "..."; } gfx.setCursor(x, y); gfx.print(s); } void drawButton(TFT_eSPI& gfx, const Rect& r, const String& label) { gfx.fillRoundRect(r.x, r.y, r.w, r.h, 4, TFT_BLACK); gfx.drawRoundRect(r.x, r.y, r.w, r.h, 4, TFT_GREEN); gfx.setTextDatum(MC_DATUM); gfx.setTextColor(TFT_GREEN, TFT_BLACK); gfx.drawString(label, r.x + r.w / 2, r.y + r.h / 2); gfx.setTextDatum(TL_DATUM); } void drawLegend(TFT_eSPI& gfx, int16_t y) { bool metric = LocationManager::useMetricUnits(); char lowLabel[10], midLabel[10], highLabel[10]; if (metric) { int lowM = (int)(Units::feetToMeters(Config::COLOR_LOW_ALT_THRESHOLD_FT) / 100) * 100; int midM = (int)(Units::feetToMeters(Config::COLOR_MID_ALT_THRESHOLD_FT) / 100) * 100; snprintf(lowLabel, sizeof(lowLabel), "<%dm", lowM); snprintf(midLabel, sizeof(midLabel), "%d-%dm", lowM, midM); snprintf(highLabel, sizeof(highLabel), ">%dm", midM); } else { snprintf(lowLabel, sizeof(lowLabel), "<10k ft"); snprintf(midLabel, sizeof(midLabel), "10-30k"); snprintf(highLabel, sizeof(highLabel), ">30k ft"); } // 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}, {colorForAltitude(gfx, Config::COLOR_MID_ALT_THRESHOLD_FT), highLabel}, }; int16_t segW = Config::SCREEN_WIDTH / 3; gfx.setTextColor(TFT_WHITE, TFT_BLACK); // 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; 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); // 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); 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 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]; 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); } // 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; bool needsScroll = false; int32_t charOffset = 0; uint32_t lastStepMs = 0; int16_t y = 0, h = 0, maxWidth = 0; uint16_t fg = TFT_GREEN; }; struct PanelState { bool valid = false; char hex[7] = {0}; String callsignText; LineMarquee airline, model, type, route, alt, speed, climb, distHeading, squawk, seats; }; PanelState lastPanel; // 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) { // 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; m.fg = fg; if (!forceFull && m.text == newText) return; m.text = newText; m.needsScroll = gfx.textWidth(newText) > maxWidth; String withGap = newText + " "; // 3 space gap before repetition m.ring = withGap + withGap; m.charOffset = 0; m.lastStepMs = millis(); gfx.fillRect(0, y - 14, Config::SCREEN_WIDTH, h, TFT_BLACK); gfx.setTextColor(fg, TFT_BLACK); gfx.setCursor(8, y); gfx.print(m.needsScroll ? infoMarqueeWindow(gfx, m.ring, 0, maxWidth) : newText); } // 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; uint32_t now = millis(); if (now - m.lastStepMs < INFO_MARQUEE_STEP_MS) return; m.lastStepMs = now; m.charOffset++; int32_t singleLen = (int32_t)m.text.length() + 3; if (m.charOffset >= singleLen) m.charOffset = 0; gfx.fillRect(0, m.y - 14, Config::SCREEN_WIDTH, m.h, TFT_BLACK); gfx.setTextColor(m.fg, TFT_BLACK); gfx.setCursor(8, m.y); gfx.print(infoMarqueeWindow(gfx, m.ring, m.charOffset, m.maxWidth)); } // 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); advanceAndDrawMarqueeLine(gfx, lastPanel.model); advanceAndDrawMarqueeLine(gfx, lastPanel.type); advanceAndDrawMarqueeLine(gfx, lastPanel.route); advanceAndDrawMarqueeLine(gfx, lastPanel.alt); advanceAndDrawMarqueeLine(gfx, lastPanel.speed); advanceAndDrawMarqueeLine(gfx, lastPanel.climb); advanceAndDrawMarqueeLine(gfx, lastPanel.distHeading); advanceAndDrawMarqueeLine(gfx, lastPanel.squawk); advanceAndDrawMarqueeLine(gfx, lastPanel.seats); } void drawDetailPanel(TFT_eSPI& gfx, Aircraft& a) { AirlineLookup::resolve(a); AircraftDetails::Info details = AircraftDetails::get(a.hex); int16_t panelTop = Config::SCREEN_HEIGHT - DETAIL_PANEL_H; constexpr int16_t textMaxWidth = Config::SCREEN_WIDTH - 16; constexpr int16_t LINE_H = 22; bool forceFull = !lastPanel.valid || strcmp(lastPanel.hex, a.hex) != 0; if (forceFull) { gfx.fillRect(0, panelTop, Config::SCREEN_WIDTH, DETAIL_PANEL_H, TFT_BLACK); gfx.drawRect(0, panelTop, Config::SCREEN_WIDTH, DETAIL_PANEL_H, TFT_GREEN); lastPanel = PanelState{}; strncpy(lastPanel.hex, a.hex, sizeof(lastPanel.hex) - 1); } int16_t y = panelTop + 26; { String txt = a.callsign[0] ? a.callsign : a.hex; if (forceFull || lastPanel.callsignText != txt) { gfx.fillRect(0, y - 22, Config::SCREEN_WIDTH, 28, TFT_BLACK); gfx.setTextColor(TFT_GREEN, TFT_BLACK); gfx.setTextSize(2); printLineTruncated(gfx, 8, y, textMaxWidth, txt); gfx.setTextSize(1); lastPanel.callsignText = txt; } } y += 30; updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.airline, String(a.airlineName), forceFull); y += LINE_H; String modelLine = details.loading ? String(I18n::t(StringId::DETAIL_MODEL)) + I18n::t(StringId::DETAIL_LOADING_DOTS) : String(I18n::t(StringId::DETAIL_MODEL)) + (details.model[0] ? details.model : I18n::t(StringId::DETAIL_UNKNOWN)); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.model, modelLine, forceFull); y += LINE_H; String typeLine = String(I18n::t(StringId::DETAIL_TYPE)) + (a.typeCode[0] ? a.typeCode : I18n::t(StringId::DETAIL_UNKNOWN)); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.type, typeLine, forceFull); y += LINE_H; // 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); } else if (details.loading) { routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_LOADING_DOTS); } else if (details.routeOrigin[0] && details.routeDest[0]) { routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + details.routeOrigin + " -> " + details.routeDest; } else { routeLine = String(I18n::t(StringId::DETAIL_ROUTE)) + I18n::t(StringId::DETAIL_UNKNOWN); } updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.route, routeLine, forceFull); y += LINE_H; char buf[48]; snprintf(buf, sizeof(buf), "%s%.0fm / %.0fft", I18n::t(StringId::DETAIL_ALT), Units::feetToMeters((float)a.altBaroFt), (float)a.altBaroFt); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.alt, String(buf), forceFull); y += LINE_H; snprintf(buf, sizeof(buf), "%s%.0fkm/h / %.0fkt", I18n::t(StringId::DETAIL_SPEED), Units::ktToKmh(a.groundSpeedKt), a.groundSpeedKt); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.speed, String(buf), forceFull); y += LINE_H; String climbLine; if (a.vertRateFtMin > 100) { snprintf(buf, sizeof(buf), "%s+%dft/min", I18n::t(StringId::DETAIL_CLIMB), a.vertRateFtMin); climbLine = buf; } else if (a.vertRateFtMin < -100) { snprintf(buf, sizeof(buf), "%s%dft/min", I18n::t(StringId::DETAIL_DESCENT), a.vertRateFtMin); climbLine = buf; } else { climbLine = I18n::t(StringId::DETAIL_LEVEL); } updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.climb, climbLine, forceFull); y += LINE_H; // 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); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.distHeading, String(buf), forceFull); y += LINE_H; String squawkLine = String(I18n::t(StringId::DETAIL_SQUAWK)) + (a.squawk[0] ? a.squawk : I18n::t(StringId::DETAIL_UNKNOWN)); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.squawk, squawkLine, forceFull); y += LINE_H; String seatsLine = a.estSeats > 0 ? String(I18n::t(StringId::DETAIL_SEATS_EST)) + a.estSeats : String(I18n::t(StringId::DETAIL_SEATS_UNKNOWN)); updateMarqueeLine(gfx, y, LINE_H, textMaxWidth, TFT_GREEN, lastPanel.seats, seatsLine, forceFull); y += 22; if (forceFull) { gfx.setTextColor(TFT_DARKGREEN, TFT_BLACK); gfx.setCursor(8, y); gfx.print(I18n::t(StringId::DETAIL_TAP_CLOSE)); } lastPanel.valid = true; } constexpr uint8_t STAR_COUNT = 28; struct Star { int16_t x, y; uint8_t phase; uint8_t speed; }; Star stars[STAR_COUNT]; bool starsInitialized = false; void initStarsIfNeeded() { if (starsInitialized) return; int16_t panelTop = Config::SCREEN_HEIGHT - DETAIL_PANEL_H; randomSeed((uint32_t)esp_random()); for (uint8_t i = 0; i < STAR_COUNT; i++) { stars[i].x = 6 + random(0, Config::SCREEN_WIDTH - 12); stars[i].y = panelTop + 10 + random(0, DETAIL_PANEL_H - 20); stars[i].phase = (uint8_t)random(0, 256); stars[i].speed = 1 + random(0, 3); } starsInitialized = true; } void updateDetailPanelStars(TFT_eSPI& gfx) { initStarsIfNeeded(); for (uint8_t i = 0; i < STAR_COUNT; i++) { stars[i].phase += stars[i].speed; uint8_t bright = (stars[i].phase < 128) ? (uint8_t)(stars[i].phase * 2) : (uint8_t)((255 - stars[i].phase) * 2); uint16_t color = gfx.color565(0, bright, 0); gfx.drawPixel(stars[i].x, stars[i].y, color); } } // 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; uint8_t phase; uint8_t speed; }; BgStar bgStars[BG_STAR_COUNT]; bool bgStarsInitialized = false; // 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()); 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; // after all filters (range, ground vehicles, 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' || a.onGround)) continue; if (AirlineFilter::isHidden(a.callsign)) continue; uint8_t altMode = SettingsStore::altFilterIndex(); if (altMode == 1 && a.altBaroFt >= (int32_t)Config::COLOR_LOW_ALT_THRESHOLD_FT) continue; if (altMode == 2 && a.altBaroFt < (int32_t)Config::COLOR_LOW_ALT_THRESHOLD_FT) continue; visibleCount++; RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg}; RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm); // ADS-B emitter 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 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; bool isEmergency = SettingsStore::emergencyAlertEnabled() && isEmergencySquawk(a.squawk); bool isWatched = SettingsStore::watchlistAlertEnabled() && AircraftWatchlist::isWatched(a.callsign); if (isSelected) { tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE); selectionStillPresent = true; selected = a; drawBearingIndicator(tft, L, a.bearingDeg); } if (isGroundVehicle) { drawGroundVehicleMarker(tft, pt.x, pt.y, color); } else if (isRotorcraft) { drawHelicopterMarker(tft, pt.x, pt.y, color); } else if (isHeavy) { drawHeavyMarker(tft, pt.x, pt.y, a.headingDeg, color); } else { drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color); } if (isEmergency) { tft.drawCircle(pt.x, pt.y, 12, TFT_RED); } else if (isWatched) { tft.drawCircle(pt.x, pt.y, 12, TFT_CYAN); } tft.setTextColor(color); tft.setTextDatum(BC_DATUM); const char* label = a.callsign[0] ? a.callsign : a.hex; tft.drawString(label, pt.x, pt.y - 8); tft.setTextDatum(TL_DATUM); hitPoints[i].x = pt.x; hitPoints[i].y = pt.y; hitPoints[i].valid = true; hitPoints[i].color = color; hitPoints[i].headingDeg = a.headingDeg; hitPoints[i].distanceKm = a.distanceKm; hitPoints[i].isEmergency = isEmergency; hitPoints[i].isWatched = isWatched; hitPoints[i].isGroundVehicle = isGroundVehicle; hitPoints[i].isRotorcraft = isRotorcraft; hitPoints[i].isHeavy = isHeavy; strncpy(hitPoints[i].hex, a.hex, sizeof(hitPoints[i].hex) - 1); strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1); } if (visibleCount > 0) lastAircraftSeenMs = millis(); if (selectedHex[0] && !selectionStillPresent) { selectedHex[0] = 0; } if (selectionStillPresent) { tft.endWrite(); drawDetailPanel(tft, selected); tft.startWrite(); } else { lastPanel.valid = false; int16_t infoTop = L.infoTop; tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY); // Info bar button row: [?] [S] [L] [50km] char rangeLabel[8]; bool rangeMetric = LocationManager::useMetricUnits(); if (rangeMetric) { snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm); } else { snprintf(rangeLabel, sizeof(rangeLabel), "%.0fnm", Units::kmToNm(rangeKm)); } uint8_t altIdx = SettingsStore::altFilterIndex(); const char* altLabels[] = {"All", "<10k", ">10k"}; drawButton(tft, L.helpBtn, "?"); drawButton(tft, L.searchBtn, "S"); drawButton(tft, L.listBtn, "L"); drawButton(tft, L.altBtn, altLabels[altIdx]); drawButton(tft, L.rangeBtn, rangeLabel); drawLegend(tft, infoTop + 44); } tft.endWrite(); } void tick(TFT_eSPI& tft, int16_t top, uint32_t deltaMs) { if (selectedHex[0]) { tft.startWrite(); updateDetailPanelStars(tft); // 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; } Layout L = computeLayout(top); float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()]; tft.startWrite(); // 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) { 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-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) { drawHelicopterMarker(tft, hp.x, hp.y, TFT_BLACK); } else if (hp.isHeavy) { drawHeavyMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK); } else { drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, TFT_BLACK); } if (hp.isEmergency || hp.isWatched) { tft.drawCircle(hp.x, hp.y, 12, TFT_BLACK); } tft.setTextColor(TFT_BLACK); tft.setTextDatum(BC_DATUM); const char* blinkLabel = hp.callsign[0] ? hp.callsign : hp.hex; tft.drawString(blinkLabel, hp.x, hp.y - 8); tft.setTextDatum(TL_DATUM); continue; } if (hp.isGroundVehicle) { drawGroundVehicleMarker(tft, hp.x, hp.y, hp.color); } else if (hp.isRotorcraft) { drawHelicopterMarker(tft, hp.x, hp.y, hp.color); } else if (hp.isHeavy) { drawHeavyMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color); } else { drawAircraftMarker(tft, hp.x, hp.y, hp.headingDeg, hp.color); } if (hp.isEmergency) { tft.drawCircle(hp.x, hp.y, 12, TFT_RED); } else if (hp.isWatched) { tft.drawCircle(hp.x, hp.y, 12, TFT_CYAN); } tft.setTextColor(hp.color); tft.setTextDatum(BC_DATUM); const char* label = hp.callsign[0] ? hp.callsign : hp.hex; tft.drawString(label, hp.x, hp.y - 8); tft.setTextDatum(TL_DATUM); } // Info 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++; } if (visibleCountNow > 0) lastAircraftSeenMs = millis(); tft.endWrite(); } bool handleTap(TFT_eSPI& tft, int16_t x, int16_t y, int16_t top) { Layout L = computeLayout(top); if (selectedHex[0]) { for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) { if (!hitPoints[i].valid) continue; int16_t dx = x - hitPoints[i].x; int16_t dy = y - hitPoints[i].y; if (dx * dx + dy * dy <= 12 * 12) { strncpy(selectedHex, hitPoints[i].hex, sizeof(selectedHex) - 1); AircraftDetails::request(hitPoints[i].hex, hitPoints[i].callsign); return true; } } selectedHex[0] = 0; lastPanel.valid = false; return true; } if (L.helpBtn.contains(x, y)) { runHelp(tft, top); return true; } if (L.searchBtn.contains(x, y)) { runSearch(tft, top); return true; } if (L.listBtn.contains(x, y)) { bool hadSelection = runTable(tft, top); return true; } if (L.altBtn.contains(x, y)) { uint8_t idx = (SettingsStore::altFilterIndex() + 1) % Config::ALT_FILTER_COUNT; SettingsStore::setAltFilterIndex(idx); return true; } if (L.rangeBtn.contains(x, y)) { uint8_t idx = (SettingsStore::rangeIndex() + 1) % Config::RANGE_STEP_COUNT; SettingsStore::setRangeIndex(idx); return true; } for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) { if (!hitPoints[i].valid) continue; int16_t dx = x - hitPoints[i].x; int16_t dy = y - hitPoints[i].y; if (dx * dx + dy * dy <= 12 * 12) { strncpy(selectedHex, hitPoints[i].hex, sizeof(selectedHex) - 1); AircraftDetails::request(hitPoints[i].hex, hitPoints[i].callsign); return true; } } uint32_t nowMs = millis(); bool isDoubleTap = (nowMs - lastEmptyTapMs <= DOUBLE_TAP_MS) && (abs((int)x - (int)lastEmptyTapX) <= DOUBLE_TAP_RADIUS) && (abs((int)y - (int)lastEmptyTapY) <= DOUBLE_TAP_RADIUS); lastEmptyTapMs = nowMs; lastEmptyTapX = x; lastEmptyTapY = y; if (isDoubleTap) { uint8_t idx = SettingsStore::rangeIndex(); idx = (idx == 0) ? (Config::RANGE_STEP_COUNT - 1) : (idx - 1); SettingsStore::setRangeIndex(idx); lastEmptyTapMs = 0; } return true; } bool runTable(TFT_eSPI& tft, int16_t top) { constexpr int16_t ROW_H = 22; constexpr int16_t ROW_GAP = 3; constexpr int16_t PADDING = 6; constexpr int16_t TABLE_LEFT = PADDING; constexpr int16_t TABLE_RIGHT = Config::SCREEN_WIDTH - PADDING; constexpr int16_t TABLE_W = TABLE_RIGHT - TABLE_LEFT; int16_t TABLE_TOP = top + 4; constexpr int16_t NAV_H = 24; constexpr int16_t BTN_H = 30; constexpr int16_t BTN_Y = Config::SCREEN_HEIGHT - BTN_H - 4; constexpr int16_t NAV_Y = BTN_Y - ROW_H - ROW_GAP - 2; int16_t tableBottom = NAV_Y - 4; uint8_t rowsVisible = (tableBottom - TABLE_TOP) / (ROW_H + ROW_GAP); if (rowsVisible < 1) rowsVisible = 1; uint8_t scrollTop = 0; bool done = false; bool selected = false; MenuStars::reset(); while (!done && !selected) { static Aircraft snapshot[Config::MAX_TRACKED_AIRCRAFT]; uint8_t count = 0; float rangeKm = Config::RANGE_STEPS_KM[SettingsStore::rangeIndex()]; AircraftTable::lock(); Aircraft* table = AircraftTable::raw(); for (uint8_t i = 0; i < AircraftTable::capacity(); i++) { if (!table[i].valid) continue; if (table[i].distanceKm > rangeKm * 1.05f) continue; if (SettingsStore::hideGroundVehicles() && (table[i].category[0] == 'C' || table[i].onGround)) continue; if (AirlineFilter::isHidden(table[i].callsign)) continue; uint8_t altMode = SettingsStore::altFilterIndex(); if (altMode == 1 && table[i].altBaroFt >= (int32_t)Config::COLOR_LOW_ALT_THRESHOLD_FT) continue; if (altMode == 2 && table[i].altBaroFt < (int32_t)Config::COLOR_LOW_ALT_THRESHOLD_FT) continue; snapshot[count++] = table[i]; } AircraftTable::unlock(); std::sort(snapshot, snapshot + count, [](const Aircraft& a, const Aircraft& b) { return a.distanceKm < b.distanceKm; }); if (scrollTop > 0 && scrollTop + rowsVisible > count) { scrollTop = (count > rowsVisible) ? (uint8_t)(count - rowsVisible) : 0; } tft.fillRect(TABLE_LEFT, TABLE_TOP, TABLE_W, tableBottom - TABLE_TOP, TFT_BLACK); bool metric = LocationManager::useMetricUnits(); int16_t y = TABLE_TOP; uint8_t drawn = 0; for (uint8_t i = scrollTop; i < count && drawn < rowsVisible; i++) { Aircraft& a = snapshot[i]; Rect r = {TABLE_LEFT, y, TABLE_W, ROW_H}; bool emergency = isEmergencySquawk(a.squawk); bool watched = AircraftWatchlist::isWatched(a.callsign); uint16_t borderColor = emergency ? TFT_RED : (watched ? TFT_CYAN : TFT_GREEN); tft.drawRoundRect(r.x, r.y, r.w, r.h, 3, borderColor); int16_t midY = r.y + r.h / 2; const char* label = a.callsign[0] ? a.callsign : a.hex; tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextDatum(ML_DATUM); tft.drawString(label, r.x + 4, midY); char buf[16]; if (metric) { snprintf(buf, sizeof(buf), "%.0fm", Units::feetToMeters((float)a.altBaroFt)); } else { snprintf(buf, sizeof(buf), "%.0fft", (float)a.altBaroFt); } tft.setTextColor(colorForAltitude(tft, a.altBaroFt), TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(buf, r.x + r.w / 2, midY); if (metric) { snprintf(buf, sizeof(buf), "%.1fkm", a.distanceKm); } else { snprintf(buf, sizeof(buf), "%.1fnm", Units::kmToNm(a.distanceKm)); } tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextDatum(MR_DATUM); tft.drawString(buf, r.x + r.w - 4, midY); tft.setTextDatum(TL_DATUM); y += ROW_H + ROW_GAP; drawn++; } if (count == 0) { tft.setTextColor(TFT_DARKGREY, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(I18n::t(StringId::AIRCRAFT_LIST_EMPTY), Config::SCREEN_WIDTH / 2, (int16_t)(TABLE_TOP + ROW_H + 10)); tft.setTextDatum(TL_DATUM); } bool canUp = scrollTop > 0; bool canDown = (uint16_t)(scrollTop + rowsVisible) < count; tft.fillRect(0, NAV_Y, Config::SCREEN_WIDTH, Config::SCREEN_HEIGHT - NAV_Y, TFT_BLACK); Rect upBtn = {TABLE_LEFT, NAV_Y, 44, NAV_H}; Rect downBtn = {(int16_t)(TABLE_LEFT + 48), NAV_Y, 44, NAV_H}; Rect radarBtn = {(int16_t)(TABLE_LEFT + 100), NAV_Y, (int16_t)(TABLE_W - 100), NAV_H}; drawButton(tft, upBtn, "^"); drawButton(tft, downBtn, "v"); drawButton(tft, radarBtn, I18n::t(StringId::BACK)); TouchInput::Point tap; while (true) { if (TouchInput::wasTapped(tap)) break; MenuStars::update(tft); delay(20); } if (canUp && upBtn.contains(tap.x, tap.y)) { scrollTop--; } else if (canDown && downBtn.contains(tap.x, tap.y)) { scrollTop++; } else if (radarBtn.contains(tap.x, tap.y)) { done = true; } else { y = TABLE_TOP; for (uint8_t i = scrollTop; i < count; i++) { uint8_t idx = (uint8_t)(i - scrollTop); if (idx >= rowsVisible) break; Rect r = {TABLE_LEFT, y, TABLE_W, ROW_H}; if (r.contains(tap.x, tap.y)) { selectAircraft(snapshot[i].hex, snapshot[i].callsign); selected = true; break; } y += ROW_H + ROW_GAP; } } } // Wait briefly to let the tap release propagate before render() runs delay(50); return selected; } void runSearch(TFT_eSPI& tft, int16_t top) { MenuStars::reset(); constexpr const char* ROW1 = "QWERTYUIOP"; constexpr const char* ROW2 = "ASDFGHJKL"; constexpr const char* ROW3 = "ZXCVBNM"; constexpr const char* ROW4 = "0123456789"; char buf[9] = {0}; uint8_t len = 0; constexpr uint8_t MAX_LEN = 8; constexpr int16_t KEY_H = 32; constexpr int16_t KEY_GAP = 3; constexpr int16_t ROW0_Y = 64; auto layoutRow = [&](const char* row, int16_t y, Rect* outRects, uint8_t n) { int16_t usableW = Config::SCREEN_WIDTH - 8; int16_t keyW = (usableW - (n - 1) * KEY_GAP) / n; int16_t x = 4; for (uint8_t i = 0; i < n; i++) { outRects[i] = {x, y, keyW, KEY_H}; x += keyW + KEY_GAP; } }; Rect row1Rects[10], row2Rects[9], row3Rects[7], row4Rects[10]; layoutRow(ROW1, ROW0_Y, row1Rects, 10); layoutRow(ROW2, ROW0_Y + KEY_H + KEY_GAP, row2Rects, 9); layoutRow(ROW3, ROW0_Y + 2 * (KEY_H + KEY_GAP), row3Rects, 7); layoutRow(ROW4, ROW0_Y + 3 * (KEY_H + KEY_GAP), row4Rects, 10); int16_t actionY = ROW0_Y + 4 * (KEY_H + KEY_GAP); Rect backspaceBtn = {4, actionY, 60, KEY_H}; Rect cancelBtn = {(int16_t)(Config::SCREEN_WIDTH / 2 - 48), actionY, 96, KEY_H}; Rect searchBtn = {(int16_t)(Config::SCREEN_WIDTH - 4 - 60), actionY, 60, KEY_H}; uint32_t notFoundUntil = 0; bool showingNotFound = false; auto redraw = [&]() { tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.fillRect(8, 30, Config::SCREEN_WIDTH - 16, 28, TFT_BLACK); tft.drawRect(8, 30, Config::SCREEN_WIDTH - 16, 28, TFT_GREEN); tft.setTextSize(2); tft.setCursor(14, 52); if (buf[0] == 0) { tft.setTextColor(TFT_DARKGREY, TFT_BLACK); tft.setTextSize(1); tft.setCursor(14, 49); tft.print("Flight number or hex code"); tft.setTextSize(2); } else { tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.print(buf); } tft.setTextSize(1); if (showingNotFound) { tft.setTextColor(TFT_RED, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString("Not found", Config::SCREEN_WIDTH / 2, actionY + KEY_H + 14); tft.setTextDatum(TL_DATUM); } for (uint8_t i = 0; i < 10; i++) drawButton(tft, row1Rects[i], String(ROW1[i])); for (uint8_t i = 0; i < 9; i++) drawButton(tft, row2Rects[i], String(ROW2[i])); for (uint8_t i = 0; i < 7; i++) drawButton(tft, row3Rects[i], String(ROW3[i])); for (uint8_t i = 0; i < 10; i++) drawButton(tft, row4Rects[i], String(ROW4[i])); drawButton(tft, backspaceBtn, "<-"); drawButton(tft, cancelBtn, I18n::t(StringId::CANCEL)); drawButton(tft, searchBtn, I18n::t(StringId::OK)); }; redraw(); while (true) { if (showingNotFound && millis() >= notFoundUntil) { showingNotFound = false; redraw(); } TouchInput::Point tap; if (!TouchInput::wasTapped(tap)) { MenuStars::update(tft); delay(20); continue; } if (showingNotFound) { showingNotFound = false; redraw(); continue; } bool handled = false; for (uint8_t i = 0; i < 10 && !handled; i++) { if (row1Rects[i].contains(tap.x, tap.y) && len < MAX_LEN) { buf[len++] = ROW1[i]; handled = true; } } for (uint8_t i = 0; i < 9 && !handled; i++) { if (row2Rects[i].contains(tap.x, tap.y) && len < MAX_LEN) { buf[len++] = ROW2[i]; handled = true; } } for (uint8_t i = 0; i < 7 && !handled; i++) { if (row3Rects[i].contains(tap.x, tap.y) && len < MAX_LEN) { buf[len++] = ROW3[i]; handled = true; } } for (uint8_t i = 0; i < 10 && !handled; i++) { if (row4Rects[i].contains(tap.x, tap.y) && len < MAX_LEN) { buf[len++] = ROW4[i]; handled = true; } } if (!handled && backspaceBtn.contains(tap.x, tap.y)) { if (len > 0) { len--; buf[len] = 0; } handled = true; } if (!handled && cancelBtn.contains(tap.x, tap.y)) { return; } if (!handled && searchBtn.contains(tap.x, tap.y) && len > 0) { auto prefixMatch = [&](const char* str) -> bool { for (uint8_t j = 0; j < len; j++) { if (str[j] == 0) return false; char a = str[j], b = buf[j]; if (a >= 'a' && a <= 'z') a -= 32; if (b >= 'a' && b <= 'z') b -= 32; if (a != b) return false; } return true; }; int bestIdx = -1; float bestDist = 1e9f; AircraftTable::lock(); Aircraft* table = AircraftTable::raw(); for (uint8_t i = 0; i < AircraftTable::capacity(); i++) { if (!table[i].valid) continue; if (!prefixMatch(table[i].callsign) && !prefixMatch(table[i].hex)) continue; if (table[i].distanceKm < bestDist) { bestDist = table[i].distanceKm; bestIdx = i; } } char mHex[7] = {0}, mCall[9] = {0}; if (bestIdx >= 0) { strncpy(mHex, table[bestIdx].hex, sizeof(mHex) - 1); strncpy(mCall, table[bestIdx].callsign, sizeof(mCall) - 1); } AircraftTable::unlock(); if (bestIdx >= 0) { selectAircraft(mHex, mCall); return; } else { showingNotFound = true; notFoundUntil = millis() + 1500; redraw(); continue; } } if (handled) redraw(); } } void runHelp(TFT_eSPI& tft, int16_t top) { tft.fillScreen(TFT_BLACK); constexpr int16_t X = 10; constexpr int16_t W = Config::SCREEN_WIDTH - 20; constexpr int16_t LBL_H = 14; int16_t y = 14; tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.setTextSize(1); tft.setCursor(X, y); tft.print("Usage Guide"); y += 24; auto line = [&](const char* text) { tft.setCursor(X, y); tft.print(text); y += LBL_H; }; tft.setTextColor(TFT_WHITE, TFT_BLACK); line("Tap a plane on the radar to see"); line("its details (model, altitude,"); line("speed, route, squawk)."); y += 4; tft.setTextColor(TFT_GREEN, TFT_BLACK); line("[?] This help screen."); line("[S] Search by flight number or"); line(" hex code."); line("[L] List all planes by distance."); line("[km] Cycle radar range: 10, 25,"); line(" 50, 100 km."); y += 4; tft.setTextColor(TFT_GREEN, TFT_BLACK); line("Altitude colors:"); tft.setTextColor(TFT_GREEN, TFT_BLACK); line(" <10k ft "); tft.setTextColor(TFT_YELLOW, TFT_BLACK); line(" 10k - 30k ft "); tft.setTextColor(TFT_RED, TFT_BLACK); line(" >30k ft "); y += 4; tft.setTextColor(TFT_WHITE, TFT_BLACK); line("Menu > settings, WLAN, flight"); line("options, watchlist & more."); Rect okBtn = {(int16_t)((Config::SCREEN_WIDTH - 120) / 2), (int16_t)(Config::SCREEN_HEIGHT - 50), 120, 36}; drawButton(tft, okBtn, I18n::t(StringId::OK)); MenuStars::reset(); while (true) { TouchInput::Point tap; if (TouchInput::wasTapped(tap)) { if (okBtn.contains(tap.x, tap.y)) break; break; // any tap dismisses } MenuStars::update(tft); delay(20); } } void selectAircraft(const char* hex, const char* callsign) { strncpy(selectedHex, hex, sizeof(selectedHex) - 1); AircraftDetails::request(hex, callsign); // 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; } 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; } }