1599 lines
64 KiB
C++
1599 lines
64 KiB
C++
#include "radar_screen.h"
|
|
#include "config.h"
|
|
#include "aircraft.h"
|
|
#include "aircraft_table.h"
|
|
#include "airline_lookup.h"
|
|
#include "aircraft_details.h"
|
|
#include "radar_math.h"
|
|
#include "units.h"
|
|
#include "settings_store.h"
|
|
#include "led_alert.h"
|
|
#include "location_manager.h"
|
|
#include "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 <math.h>
|
|
#include <time.h>
|
|
#include <algorithm>
|
|
|
|
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;
|
|
}
|
|
|
|
} |