From cafb00db3769fdb2f88ada7c71a93f360d0e374c Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Mon, 13 Apr 2026 23:25:35 +0200 Subject: [PATCH 01/31] feat: add radar minimap screen showing nearby nodes by bearing and distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new RadarRenderer that draws a circular radar view with three range rings. The user's node sits at the centre; other nodes with valid GPS positions are plotted as 3×3 squares at their true bearing and proportional distance. A "N" indicator marks north, the info panel to the right shows the current scale, total node count, and the name and distance of the closest node. The screen is registered for non-E-Ink builds that have GPS (HAS_GPS) and can be hidden via the existing hiddenFrames mechanism. --- src/graphics/Screen.cpp | 8 ++ src/graphics/Screen.h | 4 + src/graphics/draw/RadarRenderer.cpp | 203 ++++++++++++++++++++++++++++ src/graphics/draw/RadarRenderer.h | 29 ++++ 4 files changed, 244 insertions(+) create mode 100644 src/graphics/draw/RadarRenderer.cpp create mode 100644 src/graphics/draw/RadarRenderer.h diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 80e00ed692c..017a8ad57f2 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -38,6 +38,7 @@ along with this program. If not, see . #include "draw/MessageRenderer.h" #include "draw/NodeListRenderer.h" #include "draw/NotificationRenderer.h" +#include "draw/RadarRenderer.h" #include "draw/UIRenderer.h" #include "graphics/TFTColorRegions.h" #include "modules/CannedMessageModule.h" @@ -1204,6 +1205,13 @@ void Screen::setFrames(FrameFocus focus) normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; indicatorIcons.push_back(icon_list); } +#endif +#ifndef USE_EINK + if (!hiddenFrames.nodelist_radar) { + fsi.positions.nodelist_radar = numframes; + normalFrames[numframes++] = graphics::RadarRenderer::drawRadarScreen; + indicatorIcons.push_back(icon_compass); + } #endif if (!hiddenFrames.gps) { fsi.positions.gps = numframes; diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index c2d64e50dff..23f285d9648 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -701,6 +701,7 @@ class Screen : public concurrency::OSThread uint8_t nodelist_hopsignal = 255; uint8_t nodelist_distance = 255; uint8_t nodelist_bearings = 255; + uint8_t nodelist_radar = 255; uint8_t clock = 255; uint8_t chirpy = 255; uint8_t firstFavorite = 255; @@ -730,6 +731,9 @@ class Screen : public concurrency::OSThread #if HAS_GPS #ifdef USE_EINK bool nodelist_bearings = false; +#endif +#ifndef USE_EINK + bool nodelist_radar = false; #endif bool gps = false; #endif diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp new file mode 100644 index 00000000000..a67dcdbb2e5 --- /dev/null +++ b/src/graphics/draw/RadarRenderer.cpp @@ -0,0 +1,203 @@ +#include "configuration.h" +#if HAS_SCREEN +#include "RadarRenderer.h" +#include "NodeDB.h" +#include "UIRenderer.h" +#include "gps/GeoCoord.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include +#include +#include + +namespace graphics +{ +namespace RadarRenderer +{ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Round maxDistM (metres) up to the nearest "nice" radar range. + * Returns the chosen scale in metres. + */ +static float niceScaleMeters(float maxDistM) +{ + // Increasing breakpoints; each entry is a usable full-scale range in metres. + static const float scales[] = {50, 100, 250, 500, 1000, 2000, + 5000, 10000, 25000, 50000, 100000, 250000, + 500000}; + for (float s : scales) { + if (maxDistM <= s) + return s; + } + return 1000000.0f; // fallback: 1 000 km +} + +/** Format a distance (metres) as a short human-readable string. */ +static void formatDistM(char *buf, size_t len, float metres) +{ + bool imperial = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); + if (imperial) { + float miles = metres / 1609.34f; + if (miles < 0.1f) { + snprintf(buf, len, "%dft", (int)(metres * 3.28084f)); + } else if (miles < 10.0f) { + snprintf(buf, len, "%.1fmi", miles); + } else { + snprintf(buf, len, "%dmi", (int)(miles + 0.5f)); + } + } else { + if (metres < 1000.0f) { + snprintf(buf, len, "%dm", (int)metres); + } else if (metres < 10000.0f) { + snprintf(buf, len, "%.1fkm", metres / 1000.0f); + } else { + snprintf(buf, len, "%dkm", (int)(metres / 1000.0f + 0.5f)); + } + } +} + +// --------------------------------------------------------------------------- +// Main draw function +// --------------------------------------------------------------------------- + +void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + graphics::drawCommonHeader(display, x, y, "Radar"); + + const int headerH = FONT_HEIGHT_SMALL - 1; + const int sw = SCREEN_WIDTH; + const int sh = SCREEN_HEIGHT; + const int contentH = sh - headerH; + + // ----------------------------------------------------------------------- + // Radar circle: a square area on the left side of the content region. + // Cap width at 2/3 of screen so the info panel always has room. + // ----------------------------------------------------------------------- + const int maxRadarDiam = (sw * 2) / 3; + const int radarDiam = std::min(contentH - 2, maxRadarDiam); + const int radarRadius = radarDiam / 2; + const int radarCX = x + radarRadius + 1; + const int radarCY = y + headerH + 1 + radarRadius; + + // Info panel occupies the space to the right of the radar circle. + const int infoPanelX = radarCX + radarRadius + 4; + + // ----------------------------------------------------------------------- + // Own position — required; show a message and return early if unavailable. + // ----------------------------------------------------------------------- + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (!ourNode || !nodeDB->hasValidPosition(ourNode)) { + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + sw / 2, y + sh / 2 - FONT_HEIGHT_SMALL / 2, "No GPS fix"); + return; + } + + const double myLat = ourNode->position.latitude_i * 1e-7; + const double myLon = ourNode->position.longitude_i * 1e-7; + + // ----------------------------------------------------------------------- + // Collect other nodes that have a valid position. + // ----------------------------------------------------------------------- + struct Entry { + meshtastic_NodeInfoLite *node; + float distM; + float bearingRad; // radians, 0 = north + }; + + std::vector entries; + float maxDistM = 1.0f; // 1 m minimum to avoid degenerate scale + + const int numNodes = nodeDB->getNumMeshNodes(); + for (int i = 0; i < numNodes; i++) { + meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); + if (!n || n->num == nodeDB->getNodeNum()) + continue; + if (!nodeDB->hasValidPosition(n)) + continue; + + const double nodeLat = n->position.latitude_i * 1e-7; + const double nodeLon = n->position.longitude_i * 1e-7; + const float dist = GeoCoord::latLongToMeter(myLat, myLon, nodeLat, nodeLon); + const float brg = GeoCoord::bearing(myLat, myLon, nodeLat, nodeLon); + + entries.push_back({n, dist, brg}); + if (dist > maxDistM) + maxDistM = dist; + } + + // ----------------------------------------------------------------------- + // Choose a scale and draw the radar chrome. + // ----------------------------------------------------------------------- + const float scale = niceScaleMeters(maxDistM); + + // Range rings — three concentric circles at 1/3, 2/3, and full scale. + for (int ring = 1; ring <= 3; ring++) { + display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); + } + + // North indicator just inside the outer ring at the top. + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(radarCX, radarCY - radarRadius + 1, "N"); + + // Own-node marker: filled 4×4 square at centre. + display->fillRect(radarCX - 2, radarCY - 2, 4, 4); + + // ----------------------------------------------------------------------- + // Plot each remote node. + // ----------------------------------------------------------------------- + for (const Entry &e : entries) { + // Normalise distance; clamp nodes beyond scale to the outer ring edge. + const float norm = std::min(e.distM / scale, 1.0f); + const int nx = radarCX + (int)(radarRadius * norm * sinf(e.bearingRad)); + const int ny = radarCY - (int)(radarRadius * norm * cosf(e.bearingRad)); + + // 3×3 square marker for each node. + display->fillRect(nx - 1, ny - 1, 3, 3); + } + + // ----------------------------------------------------------------------- + // Info panel (right of radar). + // ----------------------------------------------------------------------- + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + // Line 1: full-scale range label. + char scaleStr[12]; + formatDistM(scaleStr, sizeof(scaleStr), scale); + display->drawString(infoPanelX, y + headerH, scaleStr); + + // Line 2: node count. + char countStr[10]; + snprintf(countStr, sizeof(countStr), "%d node%s", (int)entries.size(), entries.size() == 1 ? "" : "s"); + display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL, countStr); + + // Lines 3–4: closest node name + distance. + if (!entries.empty()) { + const Entry &closest = *std::min_element(entries.begin(), entries.end(), + [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); + + char name[16] = ""; + if (closest.node->has_user && closest.node->user.short_name[0]) { + strncpy(name, closest.node->user.short_name, sizeof(name) - 1); + } else { + snprintf(name, sizeof(name), "%04X", (uint16_t)(closest.node->num & 0xFFFF)); + } + + char distStr[12]; + formatDistM(distStr, sizeof(distStr), closest.distM); + + display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 2, name); + display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 3, distStr); + } +} + +} // namespace RadarRenderer +} // namespace graphics +#endif // HAS_SCREEN diff --git a/src/graphics/draw/RadarRenderer.h b/src/graphics/draw/RadarRenderer.h new file mode 100644 index 00000000000..d07bc6e6233 --- /dev/null +++ b/src/graphics/draw/RadarRenderer.h @@ -0,0 +1,29 @@ +#pragma once + +#include "graphics/Screen.h" +#include +#include + +namespace graphics +{ + +/// Forward declarations +class Screen; + +/** + * @brief Radar/minimap view showing nearby nodes by bearing and distance. + * + * Renders a circular radar display with range rings. The user's node sits at + * the centre; other nodes with valid GPS positions are plotted as small squares + * at their true bearing and proportional distance. A north indicator is drawn + * at the top of the circle and a scale label shows the range of the outermost + * ring in the info panel to the right. + */ +namespace RadarRenderer +{ + +void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +} // namespace RadarRenderer + +} // namespace graphics From 7498c29965206f37f209b8ac035f93ebf2a7830f Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 14 Apr 2026 09:29:06 +0200 Subject: [PATCH 02/31] feat(radar): add IMU heading support for heading-up orientation When BMX160 (RAK12034) is connected, Screen::setHeading() is updated by BMX160Sensor::runOnce() after tilt-compensated compass fusion. The radar now reads that heading via screen->hasHeading()/getHeading() and rotates all node bearings and the "N" indicator accordingly, so the direction the device faces is always at the top of the display (heading-up mode). Falls back to GPS-estimated track heading, then north-up when neither is available. The info panel shows "HDG-UP" or "N-UP" so the current mode is always visible. --- src/graphics/draw/RadarRenderer.cpp | 112 +++++++++++++++++++--------- 1 file changed, 75 insertions(+), 37 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index a67dcdbb2e5..5f12be96275 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -10,6 +10,9 @@ #include #include +// Screen instance — owns hasHeading()/getHeading()/estimatedHeading() +extern graphics::Screen *screen; + namespace graphics { namespace RadarRenderer @@ -25,18 +28,17 @@ namespace RadarRenderer */ static float niceScaleMeters(float maxDistM) { - // Increasing breakpoints; each entry is a usable full-scale range in metres. - static const float scales[] = {50, 100, 250, 500, 1000, 2000, + static const float scales[] = {50, 100, 250, 500, 1000, 2000, 5000, 10000, 25000, 50000, 100000, 250000, 500000}; for (float s : scales) { if (maxDistM <= s) return s; } - return 1000000.0f; // fallback: 1 000 km + return 1000000.0f; } -/** Format a distance (metres) as a short human-readable string. */ +/** Format a distance (metres) as a compact human-readable string. */ static void formatDistM(char *buf, size_t len, float metres) { bool imperial = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); @@ -60,6 +62,23 @@ static void formatDistM(char *buf, size_t len, float metres) } } +/** + * Plot a point on the radar circle. + * + * @param bearingRad Absolute bearing to the point (radians, 0 = north). + * @param headingRad Device heading (radians). In heading-up mode we subtract + * this from bearingRad so the device's facing direction is + * always rendered at the top of the circle. + * @param norm Normalised distance [0..1] from centre to outer ring. + */ +static void plotPoint(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm) +{ + const float relBrg = bearingRad - headingRad; + const int px = cx + (int)(radius * norm * sinf(relBrg)); + const int py = cy - (int)(radius * norm * cosf(relBrg)); + display->fillRect(px - 1, py - 1, 3, 3); +} + // --------------------------------------------------------------------------- // Main draw function // --------------------------------------------------------------------------- @@ -75,20 +94,17 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, const int contentH = sh - headerH; // ----------------------------------------------------------------------- - // Radar circle: a square area on the left side of the content region. - // Cap width at 2/3 of screen so the info panel always has room. + // Radar circle geometry. + // Limit diameter to 2/3 of screen width so the info panel always fits. // ----------------------------------------------------------------------- - const int maxRadarDiam = (sw * 2) / 3; - const int radarDiam = std::min(contentH - 2, maxRadarDiam); + const int radarDiam = std::min(contentH - 2, (sw * 2) / 3); const int radarRadius = radarDiam / 2; const int radarCX = x + radarRadius + 1; const int radarCY = y + headerH + 1 + radarRadius; - - // Info panel occupies the space to the right of the radar circle. const int infoPanelX = radarCX + radarRadius + 4; // ----------------------------------------------------------------------- - // Own position — required; show a message and return early if unavailable. + // Own position — bail out gracefully if GPS is unavailable. // ----------------------------------------------------------------------- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (!ourNode || !nodeDB->hasValidPosition(ourNode)) { @@ -102,16 +118,30 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, const double myLon = ourNode->position.longitude_i * 1e-7; // ----------------------------------------------------------------------- - // Collect other nodes that have a valid position. + // Heading — IMU (BMX160 / RAK12034) is preferred; GPS-estimated movement + // heading is used as fallback. When neither is available the radar falls + // back to north-up (headingRad = 0). + // + // Screen::setHeading() is called by BMX160Sensor::runOnce() after tilt- + // compensated compass fusion, so screen->hasHeading() is true whenever the + // RAK12034 is connected and initialised. + // ----------------------------------------------------------------------- + const float headingRad = screen->hasHeading() + ? screen->getHeading() * DEG_TO_RAD + : screen->estimatedHeading(myLat, myLon); + const bool usingIMU = screen->hasHeading(); + + // ----------------------------------------------------------------------- + // Collect remote nodes that have valid positions. // ----------------------------------------------------------------------- struct Entry { meshtastic_NodeInfoLite *node; float distM; - float bearingRad; // radians, 0 = north + float bearingRad; // absolute bearing, radians, 0 = north }; std::vector entries; - float maxDistM = 1.0f; // 1 m minimum to avoid degenerate scale + float maxDistM = 1.0f; // 1 m floor prevents degenerate scale const int numNodes = nodeDB->getNumMeshNodes(); for (int i = 0; i < numNodes; i++) { @@ -132,53 +162,60 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, } // ----------------------------------------------------------------------- - // Choose a scale and draw the radar chrome. + // Scale and radar chrome. // ----------------------------------------------------------------------- const float scale = niceScaleMeters(maxDistM); - // Range rings — three concentric circles at 1/3, 2/3, and full scale. + // Three concentric range rings. for (int ring = 1; ring <= 3; ring++) { display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); } - // North indicator just inside the outer ring at the top. - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(radarCX, radarCY - radarRadius + 1, "N"); + // North ("N") indicator. + // In heading-up mode it rotates to show the true north direction. + // In north-up mode it sits at the top of the outer ring. + { + // North is at absolute bearing 0; relative bearing = 0 - headingRad + const float northBrg = -headingRad; + // Place label just inside the outer ring. + const int inset = FONT_HEIGHT_SMALL / 2 + 1; + const int nx = radarCX + (int)((radarRadius - inset) * sinf(northBrg)); + const int ny = radarCY - (int)((radarRadius - inset) * cosf(northBrg)); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(nx, ny - FONT_HEIGHT_SMALL / 2, "N"); + } // Own-node marker: filled 4×4 square at centre. display->fillRect(radarCX - 2, radarCY - 2, 4, 4); // ----------------------------------------------------------------------- - // Plot each remote node. + // Plot remote nodes. // ----------------------------------------------------------------------- for (const Entry &e : entries) { - // Normalise distance; clamp nodes beyond scale to the outer ring edge. const float norm = std::min(e.distM / scale, 1.0f); - const int nx = radarCX + (int)(radarRadius * norm * sinf(e.bearingRad)); - const int ny = radarCY - (int)(radarRadius * norm * cosf(e.bearingRad)); - - // 3×3 square marker for each node. - display->fillRect(nx - 1, ny - 1, 3, 3); + plotPoint(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, norm); } // ----------------------------------------------------------------------- - // Info panel (right of radar). + // Info panel. // ----------------------------------------------------------------------- display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - // Line 1: full-scale range label. + int infoY = y + headerH; + + // Line 1: scale of the outermost ring. char scaleStr[12]; formatDistM(scaleStr, sizeof(scaleStr), scale); - display->drawString(infoPanelX, y + headerH, scaleStr); + display->drawString(infoPanelX, infoY, scaleStr); + infoY += FONT_HEIGHT_SMALL; - // Line 2: node count. - char countStr[10]; - snprintf(countStr, sizeof(countStr), "%d node%s", (int)entries.size(), entries.size() == 1 ? "" : "s"); - display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL, countStr); + // Line 2: orientation mode. + display->drawString(infoPanelX, infoY, usingIMU ? "HDG-UP" : "N-UP"); + infoY += FONT_HEIGHT_SMALL; - // Lines 3–4: closest node name + distance. + // Lines 3–4: closest node name and distance. if (!entries.empty()) { const Entry &closest = *std::min_element(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); @@ -193,8 +230,9 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, char distStr[12]; formatDistM(distStr, sizeof(distStr), closest.distM); - display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 2, name); - display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 3, distStr); + display->drawString(infoPanelX, infoY, name); + infoY += FONT_HEIGHT_SMALL; + display->drawString(infoPanelX, infoY, distStr); } } From fb648adda63007af183ad6980a29c0609dbbe378 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 14 Apr 2026 10:00:37 +0200 Subject: [PATCH 03/31] feat(radar): maximise screen use for 128x64 OLED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Centre the radar circle horizontally, filling the full content height (radius=27 on 128×64, scales up on larger displays) - Replace the wide info panel with a compact ring legend to the right: three stacked distance labels (outer/middle/inner ring scale) plus a small "IMU" indicator when the BMX160 is active - Remove per-screen closest-node text — node positions on the rings now carry the distance information visually --- src/graphics/draw/RadarRenderer.cpp | 159 ++++++++++++---------------- 1 file changed, 66 insertions(+), 93 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 5f12be96275..24be435fd02 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -10,7 +10,7 @@ #include #include -// Screen instance — owns hasHeading()/getHeading()/estimatedHeading() +// Owns hasHeading() / getHeading() / estimatedHeading() extern graphics::Screen *screen; namespace graphics @@ -22,60 +22,45 @@ namespace RadarRenderer // Helpers // --------------------------------------------------------------------------- -/** - * Round maxDistM (metres) up to the nearest "nice" radar range. - * Returns the chosen scale in metres. - */ static float niceScaleMeters(float maxDistM) { - static const float scales[] = {50, 100, 250, 500, 1000, 2000, + static const float scales[] = {50, 100, 250, 500, 1000, 2000, 5000, 10000, 25000, 50000, 100000, 250000, 500000}; - for (float s : scales) { + for (float s : scales) if (maxDistM <= s) return s; - } return 1000000.0f; } -/** Format a distance (metres) as a compact human-readable string. */ static void formatDistM(char *buf, size_t len, float metres) { - bool imperial = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); + const bool imperial = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); if (imperial) { - float miles = metres / 1609.34f; - if (miles < 0.1f) { + const float miles = metres / 1609.34f; + if (miles < 0.1f) snprintf(buf, len, "%dft", (int)(metres * 3.28084f)); - } else if (miles < 10.0f) { + else if (miles < 10.0f) snprintf(buf, len, "%.1fmi", miles); - } else { + else snprintf(buf, len, "%dmi", (int)(miles + 0.5f)); - } } else { - if (metres < 1000.0f) { + if (metres < 1000.0f) snprintf(buf, len, "%dm", (int)metres); - } else if (metres < 10000.0f) { + else if (metres < 10000.0f) snprintf(buf, len, "%.1fkm", metres / 1000.0f); - } else { + else snprintf(buf, len, "%dkm", (int)(metres / 1000.0f + 0.5f)); - } } } -/** - * Plot a point on the radar circle. - * - * @param bearingRad Absolute bearing to the point (radians, 0 = north). - * @param headingRad Device heading (radians). In heading-up mode we subtract - * this from bearingRad so the device's facing direction is - * always rendered at the top of the circle. - * @param norm Normalised distance [0..1] from centre to outer ring. - */ -static void plotPoint(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm) +// Plot a 3×3 node marker at the given absolute bearing and normalised distance. +// headingRad rotates the radar so the device's facing direction is always up. +static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm) { - const float relBrg = bearingRad - headingRad; - const int px = cx + (int)(radius * norm * sinf(relBrg)); - const int py = cy - (int)(radius * norm * cosf(relBrg)); + const float rel = bearingRad - headingRad; + const int px = cx + (int)(radius * norm * sinf(rel)); + const int py = cy - (int)(radius * norm * cosf(rel)); display->fillRect(px - 1, py - 1, 3, 3); } @@ -94,17 +79,24 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, const int contentH = sh - headerH; // ----------------------------------------------------------------------- - // Radar circle geometry. - // Limit diameter to 2/3 of screen width so the info panel always fits. + // Radar geometry: circle is centred horizontally, fills the content height. + // On 128×64 this gives radius=27, leaving ~37 px on each side for labels. + // On larger displays the circle grows proportionally. // ----------------------------------------------------------------------- - const int radarDiam = std::min(contentH - 2, (sw * 2) / 3); + const int radarDiam = contentH - 2; // 1 px margin top + bottom const int radarRadius = radarDiam / 2; - const int radarCX = x + radarRadius + 1; + const int radarCX = x + sw / 2; // horizontally centred const int radarCY = y + headerH + 1 + radarRadius; - const int infoPanelX = radarCX + radarRadius + 4; + + // The ring legend sits to the right of the circle. + const int legendX = radarCX + radarRadius + 3; + // Three labels, each FONT_HEIGHT_SMALL tall, centred around radarCY. + const int legendY3 = radarCY - FONT_HEIGHT_SMALL - 3; // outer ring label + const int legendY2 = radarCY - 3; // middle ring label + const int legendY1 = radarCY + FONT_HEIGHT_SMALL - 3; // inner ring label // ----------------------------------------------------------------------- - // Own position — bail out gracefully if GPS is unavailable. + // Own position — bail gracefully if GPS is unavailable. // ----------------------------------------------------------------------- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (!ourNode || !nodeDB->hasValidPosition(ourNode)) { @@ -118,30 +110,24 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, const double myLon = ourNode->position.longitude_i * 1e-7; // ----------------------------------------------------------------------- - // Heading — IMU (BMX160 / RAK12034) is preferred; GPS-estimated movement - // heading is used as fallback. When neither is available the radar falls - // back to north-up (headingRad = 0). - // - // Screen::setHeading() is called by BMX160Sensor::runOnce() after tilt- - // compensated compass fusion, so screen->hasHeading() is true whenever the - // RAK12034 is connected and initialised. + // Heading — BMX160 via screen->setHeading() (tilt-compensated compass + // fusion). Falls back to GPS movement track, then north-up (0). // ----------------------------------------------------------------------- - const float headingRad = screen->hasHeading() - ? screen->getHeading() * DEG_TO_RAD - : screen->estimatedHeading(myLat, myLon); + const float headingRad = screen->hasHeading() ? screen->getHeading() * DEG_TO_RAD + : screen->estimatedHeading(myLat, myLon); const bool usingIMU = screen->hasHeading(); // ----------------------------------------------------------------------- - // Collect remote nodes that have valid positions. + // Collect remote nodes with valid GPS positions. // ----------------------------------------------------------------------- struct Entry { meshtastic_NodeInfoLite *node; float distM; - float bearingRad; // absolute bearing, radians, 0 = north + float bearingRad; }; std::vector entries; - float maxDistM = 1.0f; // 1 m floor prevents degenerate scale + float maxDistM = 1.0f; const int numNodes = nodeDB->getNumMeshNodes(); for (int i = 0; i < numNodes; i++) { @@ -161,24 +147,21 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, maxDistM = dist; } - // ----------------------------------------------------------------------- - // Scale and radar chrome. - // ----------------------------------------------------------------------- const float scale = niceScaleMeters(maxDistM); - // Three concentric range rings. - for (int ring = 1; ring <= 3; ring++) { + // ----------------------------------------------------------------------- + // Draw radar chrome: three concentric range rings. + // ----------------------------------------------------------------------- + for (int ring = 1; ring <= 3; ring++) display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); - } - // North ("N") indicator. - // In heading-up mode it rotates to show the true north direction. - // In north-up mode it sits at the top of the outer ring. + // ----------------------------------------------------------------------- + // North indicator — rotates with IMU heading so it always points true north. + // Placed just inside the outer ring to avoid clipping the header. + // ----------------------------------------------------------------------- { - // North is at absolute bearing 0; relative bearing = 0 - headingRad - const float northBrg = -headingRad; - // Place label just inside the outer ring. const int inset = FONT_HEIGHT_SMALL / 2 + 1; + const float northBrg = -headingRad; // bearing of north relative to "up" const int nx = radarCX + (int)((radarRadius - inset) * sinf(northBrg)); const int ny = radarCY - (int)((radarRadius - inset) * cosf(northBrg)); display->setFont(FONT_SMALL); @@ -186,7 +169,7 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(nx, ny - FONT_HEIGHT_SMALL / 2, "N"); } - // Own-node marker: filled 4×4 square at centre. + // Own-node marker: filled 4×4 square at the centre. display->fillRect(radarCX - 2, radarCY - 2, 4, 4); // ----------------------------------------------------------------------- @@ -194,45 +177,35 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, // ----------------------------------------------------------------------- for (const Entry &e : entries) { const float norm = std::min(e.distM / scale, 1.0f); - plotPoint(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, norm); + plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, norm); } // ----------------------------------------------------------------------- - // Info panel. + // Ring scale legend (right of radar circle). + // Three rows aligned with the centre of the radar, showing the distance + // each ring represents so the user can read off approximate distances. // ----------------------------------------------------------------------- - display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); - int infoY = y + headerH; - - // Line 1: scale of the outermost ring. - char scaleStr[12]; - formatDistM(scaleStr, sizeof(scaleStr), scale); - display->drawString(infoPanelX, infoY, scaleStr); - infoY += FONT_HEIGHT_SMALL; - - // Line 2: orientation mode. - display->drawString(infoPanelX, infoY, usingIMU ? "HDG-UP" : "N-UP"); - infoY += FONT_HEIGHT_SMALL; + char buf[10]; - // Lines 3–4: closest node name and distance. - if (!entries.empty()) { - const Entry &closest = *std::min_element(entries.begin(), entries.end(), - [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); + // Outer ring (full scale) + formatDistM(buf, sizeof(buf), scale); + display->drawString(legendX, legendY3, buf); - char name[16] = ""; - if (closest.node->has_user && closest.node->user.short_name[0]) { - strncpy(name, closest.node->user.short_name, sizeof(name) - 1); - } else { - snprintf(name, sizeof(name), "%04X", (uint16_t)(closest.node->num & 0xFFFF)); - } + // Middle ring (2/3 scale) + formatDistM(buf, sizeof(buf), scale * 2.0f / 3.0f); + display->drawString(legendX, legendY2, buf); - char distStr[12]; - formatDistM(distStr, sizeof(distStr), closest.distM); + // Inner ring (1/3 scale) + formatDistM(buf, sizeof(buf), scale / 3.0f); + display->drawString(legendX, legendY1, buf); - display->drawString(infoPanelX, infoY, name); - infoY += FONT_HEIGHT_SMALL; - display->drawString(infoPanelX, infoY, distStr); + // IMU active indicator below the legend — helps confirm the RAK12034 + // is working during first-time setup. + if (usingIMU) { + display->drawString(legendX, legendY1 + FONT_HEIGHT_SMALL, "IMU"); } } From b89e057b414c60e86454e0882c0e63dcee1376b3 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Wed, 15 Apr 2026 22:38:59 +0200 Subject: [PATCH 04/31] feat(radar): round ring labels, info panel, custom icon, menu toggle, long-press menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Scale table — all values now multiples of 3 so ring labels (scale/3 and scale*2/3) are always whole numbers (e.g. 30/60/90m, 100/200/300m). 2. Layout — radar shifted to the left edge; right panel shows three ring scale labels, closest node short name, distance, and a node-count + orientation badge (HDG / N^) in the bottom row. 3. Icon — replaced shared icon_compass with a new icon_radar (concentric rings with centre dot) so the radar has its own menu indicator. 4. Menu toggle — "Show/Hide Radar" added to the Show/Hide Frames menu via the existing toggleFrameVisibility / isFrameHidden pattern. 5. Long-press menu — pressing SELECT on the radar screen opens "Radar Options" with: Switch N-UP / HDG-UP (overrides IMU), Zoom In, Zoom Out. Zoom is clamped to ±2 steps from auto-scale; state is held in module- static variables in RadarRenderer. --- src/graphics/Screen.cpp | 13 +- src/graphics/draw/MenuHandler.cpp | 50 ++++++++ src/graphics/draw/MenuHandler.h | 1 + src/graphics/draw/RadarRenderer.cpp | 185 +++++++++++++++++++--------- src/graphics/draw/RadarRenderer.h | 40 ++++-- src/graphics/images.h | 12 ++ 6 files changed, 237 insertions(+), 64 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 017a8ad57f2..3d0e6281079 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1210,7 +1210,7 @@ void Screen::setFrames(FrameFocus focus) if (!hiddenFrames.nodelist_radar) { fsi.positions.nodelist_radar = numframes; normalFrames[numframes++] = graphics::RadarRenderer::drawRadarScreen; - indicatorIcons.push_back(icon_compass); + indicatorIcons.push_back(icon_radar); } #endif if (!hiddenFrames.gps) { @@ -1401,6 +1401,11 @@ void Screen::toggleFrameVisibility(const std::string &frameName) if (frameName == "nodelist_bearings") { hiddenFrames.nodelist_bearings = !hiddenFrames.nodelist_bearings; } +#endif +#ifndef USE_EINK + if (frameName == "nodelist_radar") { + hiddenFrames.nodelist_radar = !hiddenFrames.nodelist_radar; + } #endif if (frameName == "gps") { hiddenFrames.gps = !hiddenFrames.gps; @@ -1440,6 +1445,10 @@ bool Screen::isFrameHidden(const std::string &frameName) const #ifdef USE_EINK if (frameName == "nodelist_bearings") return hiddenFrames.nodelist_bearings; +#endif +#ifndef USE_EINK + if (frameName == "nodelist_radar") + return hiddenFrames.nodelist_radar; #endif if (frameName == "gps") return hiddenFrames.gps; @@ -1867,6 +1876,8 @@ int Screen::handleInputEvent(const InputEvent *event) this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { menuHandler::favoriteBaseMenu(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_radar) { + menuHandler::radarMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_nodes || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_lastheard || diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 386a4c077de..a12c687b763 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -13,6 +13,7 @@ #include "graphics/SharedUIDisplay.h" #include "graphics/TFTColorRegions.h" #include "graphics/draw/MessageRenderer.h" +#include "graphics/draw/RadarRenderer.h" #include "graphics/draw/UIRenderer.h" #include "input/RotaryEncoderInterruptImpl1.h" #include "input/UpDownInterruptImpl1.h" @@ -2401,6 +2402,7 @@ void menuHandler::frameTogglesMenu() nodelist_hopsignal, nodelist_distance, nodelist_bearings, + nodelist_radar, gps_position, lora, clock, @@ -2438,6 +2440,11 @@ void menuHandler::frameTogglesMenu() optionsEnumArray[options++] = nodelist_bearings; #endif +#ifndef USE_EINK + optionsArray[options] = screen->isFrameHidden("nodelist_radar") ? "Show Radar" : "Hide Radar"; + optionsEnumArray[options++] = nodelist_radar; +#endif + optionsArray[options] = screen->isFrameHidden("gps") ? "Show Position" : "Hide Position"; optionsEnumArray[options++] = gps_position; #endif @@ -2502,6 +2509,10 @@ void menuHandler::frameTogglesMenu() screen->toggleFrameVisibility("nodelist_bearings"); menuHandler::menuQueue = menuHandler::FrameToggles; screen->runNow(); + } else if (selected == nodelist_radar) { + screen->toggleFrameVisibility("nodelist_radar"); + menuHandler::menuQueue = menuHandler::FrameToggles; + screen->runNow(); } else if (selected == gps_position) { screen->toggleFrameVisibility("gps"); menuHandler::menuQueue = menuHandler::FrameToggles; @@ -2796,6 +2807,45 @@ void menuHandler::saveUIConfig() nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uiconfig); } +void menuHandler::radarMenu() +{ + enum optionsNumbers { Back, ToggleHeading, ZoomIn, ZoomOut }; + static const char *optionsArray[] = { + "Back", + nullptr, // filled dynamically based on current mode + "Zoom In", + "Zoom Out", + }; + static int optionsEnumArray[] = {Back, ToggleHeading, ZoomIn, ZoomOut}; + + optionsArray[ToggleHeading] = graphics::RadarRenderer::isNorthUp() ? "Switch to HDG-UP" : "Switch to N-UP"; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Radar Options"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 4; + bannerOptions.optionsEnumPtr = optionsEnumArray; + + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + screen->setFrames(Screen::FOCUS_PRESERVE); + } else if (selected == ToggleHeading) { + graphics::RadarRenderer::toggleNorthUp(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); + } else if (selected == ZoomIn) { + graphics::RadarRenderer::zoomIn(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); + } else if (selected == ZoomOut) { + graphics::RadarRenderer::zoomOut(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + } // namespace graphics #endif diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 3ac9e606e11..35fa1f2962a 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -90,6 +90,7 @@ class menuHandler static void BuzzerModeMenu(); static void switchToMUIMenu(); static void nodeListMenu(); + static void radarMenu(); static void resetNodeDBMenu(); static void BrightnessPickerMenu(); static void rebootMenu(); diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 24be435fd02..c7a8f31b423 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -10,7 +10,6 @@ #include #include -// Owns hasHeading() / getHeading() / estimatedHeading() extern graphics::Screen *screen; namespace graphics @@ -19,20 +18,64 @@ namespace RadarRenderer { // --------------------------------------------------------------------------- -// Helpers +// Runtime state (toggled by radarMenu) // --------------------------------------------------------------------------- -static float niceScaleMeters(float maxDistM) +static bool s_forceNorthUp = false; // override IMU → fixed north-up +static int s_zoomLevel = 0; // -2..+2, 0 = auto + +bool isNorthUp() +{ + return s_forceNorthUp; +} + +void toggleNorthUp() +{ + s_forceNorthUp = !s_forceNorthUp; +} + +void zoomIn() +{ + if (s_zoomLevel > -2) + s_zoomLevel--; +} + +void zoomOut() +{ + if (s_zoomLevel < 2) + s_zoomLevel++; +} + +// --------------------------------------------------------------------------- +// Scale helpers +// --------------------------------------------------------------------------- + +/** + * Return the smallest value from the scale table that is >= maxDistM, + * then apply the zoom offset. All values are multiples of 3 so that + * dividing by 3 (for ring labels) always yields whole numbers. + */ +static float niceScaleMeters(float maxDistM, int zoomLevel) { - static const float scales[] = {50, 100, 250, 500, 1000, 2000, - 5000, 10000, 25000, 50000, 100000, 250000, - 500000}; - for (float s : scales) - if (maxDistM <= s) - return s; - return 1000000.0f; + // Every entry is divisible by 3 → ring labels are always integers. + static const float scales[] = { + 30, 60, 90, 150, 300, 600, 900, + 1500, 3000, 6000, 9000, 15000, 30000, 90000, + 300000 + }; + constexpr int N = sizeof(scales) / sizeof(scales[0]); + + // Find the base auto-scale index. + int idx = 0; + while (idx < N - 1 && maxDistM > scales[idx]) + idx++; + + // Apply zoom offset (clamp to valid range). + idx = std::max(0, std::min(N - 1, idx + zoomLevel)); + return scales[idx]; } +/** Format metres as a compact string (metric or imperial). */ static void formatDistM(char *buf, size_t len, float metres) { const bool imperial = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); @@ -54,8 +97,7 @@ static void formatDistM(char *buf, size_t len, float metres) } } -// Plot a 3×3 node marker at the given absolute bearing and normalised distance. -// headingRad rotates the radar so the device's facing direction is always up. +/** Plot a 3×3 node marker. headingRad rotates the view (heading-up). */ static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm) { const float rel = bearingRad - headingRad; @@ -79,24 +121,22 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, const int contentH = sh - headerH; // ----------------------------------------------------------------------- - // Radar geometry: circle is centred horizontally, fills the content height. - // On 128×64 this gives radius=27, leaving ~37 px on each side for labels. - // On larger displays the circle grows proportionally. + // Layout: radar circle on the left, info panel on the right. + // + // The radar is a square area equal to the content height, leaving a panel + // of (sw - radarDiam) pixels on the right for labels. + // + // On 128×64 OLED (contentH=57): + // radarDiam = 55 radarRadius = 27 infoPanelX = 92 (36 px panel) // ----------------------------------------------------------------------- - const int radarDiam = contentH - 2; // 1 px margin top + bottom + const int radarDiam = contentH - 2; // 1 px margin top + bottom const int radarRadius = radarDiam / 2; - const int radarCX = x + sw / 2; // horizontally centred + const int radarCX = x + radarRadius + 1; // left-aligned with 1px margin const int radarCY = y + headerH + 1 + radarRadius; - - // The ring legend sits to the right of the circle. - const int legendX = radarCX + radarRadius + 3; - // Three labels, each FONT_HEIGHT_SMALL tall, centred around radarCY. - const int legendY3 = radarCY - FONT_HEIGHT_SMALL - 3; // outer ring label - const int legendY2 = radarCY - 3; // middle ring label - const int legendY1 = radarCY + FONT_HEIGHT_SMALL - 3; // inner ring label + const int infoPanelX = x + radarDiam + 4; // ----------------------------------------------------------------------- - // Own position — bail gracefully if GPS is unavailable. + // GPS — bail gracefully if unavailable. // ----------------------------------------------------------------------- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (!ourNode || !nodeDB->hasValidPosition(ourNode)) { @@ -110,15 +150,22 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, const double myLon = ourNode->position.longitude_i * 1e-7; // ----------------------------------------------------------------------- - // Heading — BMX160 via screen->setHeading() (tilt-compensated compass - // fusion). Falls back to GPS movement track, then north-up (0). + // Heading. + // + // Priority: + // 1. BMX160/RAK12034 tilt-compensated heading via screen->setHeading() + // 2. GPS movement track (estimatedHeading) + // 3. North-up fallback (0) + // + // s_forceNorthUp overrides (1) and (2) — set via the long-press menu. // ----------------------------------------------------------------------- - const float headingRad = screen->hasHeading() ? screen->getHeading() * DEG_TO_RAD - : screen->estimatedHeading(myLat, myLon); - const bool usingIMU = screen->hasHeading(); + const bool imuAvailable = screen->hasHeading(); + const bool headingUp = imuAvailable && !s_forceNorthUp; + const float headingRad = headingUp ? screen->getHeading() * DEG_TO_RAD + : (s_forceNorthUp ? 0.0f : screen->estimatedHeading(myLat, myLon)); // ----------------------------------------------------------------------- - // Collect remote nodes with valid GPS positions. + // Collect remote nodes with valid positions. // ----------------------------------------------------------------------- struct Entry { meshtastic_NodeInfoLite *node; @@ -147,7 +194,10 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, maxDistM = dist; } - const float scale = niceScaleMeters(maxDistM); + // ----------------------------------------------------------------------- + // Scale (respects zoom level set by long-press menu). + // ----------------------------------------------------------------------- + const float scale = niceScaleMeters(maxDistM, s_zoomLevel); // ----------------------------------------------------------------------- // Draw radar chrome: three concentric range rings. @@ -156,12 +206,11 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); // ----------------------------------------------------------------------- - // North indicator — rotates with IMU heading so it always points true north. - // Placed just inside the outer ring to avoid clipping the header. + // North indicator — rotates in heading-up mode. // ----------------------------------------------------------------------- { const int inset = FONT_HEIGHT_SMALL / 2 + 1; - const float northBrg = -headingRad; // bearing of north relative to "up" + const float northBrg = -headingRad; const int nx = radarCX + (int)((radarRadius - inset) * sinf(northBrg)); const int ny = radarCY - (int)((radarRadius - inset) * cosf(northBrg)); display->setFont(FONT_SMALL); @@ -169,43 +218,69 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(nx, ny - FONT_HEIGHT_SMALL / 2, "N"); } - // Own-node marker: filled 4×4 square at the centre. + // Own-node marker: filled 4×4 square at centre. display->fillRect(radarCX - 2, radarCY - 2, 4, 4); // ----------------------------------------------------------------------- // Plot remote nodes. // ----------------------------------------------------------------------- - for (const Entry &e : entries) { - const float norm = std::min(e.distM / scale, 1.0f); - plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, norm); - } + for (const Entry &e : entries) + plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, + std::min(e.distM / scale, 1.0f)); // ----------------------------------------------------------------------- - // Ring scale legend (right of radar circle). - // Three rows aligned with the centre of the radar, showing the distance - // each ring represents so the user can read off approximate distances. + // Info panel (right of radar). + // + // Line 1: outermost ring scale + // Line 2: middle ring scale + // Line 3: inner ring scale + // Line 4: closest node name + // Line 5: closest node distance + cardinal direction + // Line 6: node count + orientation badge (HDG/N↑) // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); - char buf[10]; - - // Outer ring (full scale) + // Ring scale labels — scale is always divisible by 3, so these are ints. + char buf[12]; formatDistM(buf, sizeof(buf), scale); - display->drawString(legendX, legendY3, buf); + display->drawString(infoPanelX, y + headerH, buf); - // Middle ring (2/3 scale) formatDistM(buf, sizeof(buf), scale * 2.0f / 3.0f); - display->drawString(legendX, legendY2, buf); + display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL, buf); - // Inner ring (1/3 scale) formatDistM(buf, sizeof(buf), scale / 3.0f); - display->drawString(legendX, legendY1, buf); + display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 2, buf); + + // Closest node info. + if (!entries.empty()) { + const Entry &closest = *std::min_element(entries.begin(), entries.end(), + [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); + + // Short name or last-4 of node ID. + char name[16] = ""; + if (closest.node->has_user && closest.node->user.short_name[0]) + strncpy(name, closest.node->user.short_name, sizeof(name) - 1); + else + snprintf(name, sizeof(name), "%04X", (uint16_t)(closest.node->num & 0xFFFF)); + + display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 3, name); + + // Distance string. + formatDistM(buf, sizeof(buf), closest.distM); + display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 4, buf); + } + + // Bottom row: node count + orientation badge. + { + const int bottomY = y + headerH + FONT_HEIGHT_SMALL * 5; + snprintf(buf, sizeof(buf), "%d", (int)entries.size()); + display->drawString(infoPanelX, bottomY, buf); - // IMU active indicator below the legend — helps confirm the RAK12034 - // is working during first-time setup. - if (usingIMU) { - display->drawString(legendX, legendY1 + FONT_HEIGHT_SMALL, "IMU"); + // Orientation badge: "HDG" when heading-up, "N^" when north-up. + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(x + sw - 1, bottomY, headingUp ? "HDG" : "N^"); + display->setTextAlignment(TEXT_ALIGN_LEFT); } } diff --git a/src/graphics/draw/RadarRenderer.h b/src/graphics/draw/RadarRenderer.h index d07bc6e6233..b6a02e40af8 100644 --- a/src/graphics/draw/RadarRenderer.h +++ b/src/graphics/draw/RadarRenderer.h @@ -7,23 +7,47 @@ namespace graphics { -/// Forward declarations class Screen; /** - * @brief Radar/minimap view showing nearby nodes by bearing and distance. + * @brief Radar/minimap screen showing nearby nodes by bearing and distance. * - * Renders a circular radar display with range rings. The user's node sits at - * the centre; other nodes with valid GPS positions are plotted as small squares - * at their true bearing and proportional distance. A north indicator is drawn - * at the top of the circle and a scale label shows the range of the outermost - * ring in the info panel to the right. + * Renders a circular radar with three labelled range rings. The user's node + * sits at the centre; other nodes with valid GPS positions are plotted as + * 3×3 squares at their true bearing and proportional distance. + * + * When the BMX160 (RAK12034) is connected the display is heading-up: the + * direction the device faces is always at the top. A "N" label rotates to + * show true north. Without IMU the display is north-up. + * + * The heading mode and zoom level can be overridden at runtime via the + * long-press menu (radarMenu in MenuHandler). */ namespace RadarRenderer { +// ---- Frame callback --------------------------------------------------------- void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -} // namespace RadarRenderer +// ---- Runtime state (controlled by radarMenu) -------------------------------- + +/** Returns true when forced north-up is active (overriding IMU). */ +bool isNorthUp(); + +/** Toggle forced north-up / heading-up mode. */ +void toggleNorthUp(); +/** + * Zoom level relative to the auto-calculated scale. + * 0 = auto (default) + * -1 = zoom in (scale halved) + * -2 = zoom in (scale quartered) + * +1 = zoom out (scale doubled) + * +2 = zoom out (scale quadrupled) + * Clamped to [-2, +2]. + */ +void zoomIn(); +void zoomOut(); + +} // namespace RadarRenderer } // namespace graphics diff --git a/src/graphics/images.h b/src/graphics/images.h index 66fcbc79c5d..69fcadc5f2f 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -113,6 +113,18 @@ const unsigned char icon_compass[] PROGMEM = { 0x3C // Row 7: ..####.. }; +// 📡 Radar Screen — concentric rings with centre dot +const uint8_t icon_radar[] PROGMEM = { + 0x18, // Row 0: ...##... + 0x24, // Row 1: ..#..#.. + 0x5A, // Row 2: .#.##.#. + 0xA5, // Row 3: #.#..#.# + 0xA5, // Row 4: #.#..#.# + 0x5A, // Row 5: .#.##.#. + 0x24, // Row 6: ..#..#.. + 0x18 // Row 7: ...##... +}; + const uint8_t icon_radio[] PROGMEM = { 0x0F, // Row 0: ####.... 0x10, // Row 1: ....#... From fcdff2baa46541e58eeb5d353d2ef518b6c5c8e2 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Wed, 15 Apr 2026 23:35:57 +0200 Subject: [PATCH 05/31] feat(radar): fix panel overflow, add cross marker for closest node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: on 128×64 OLED with FONT_SMALL≈13px, the 3 ring-label rows left no space for node info — name/distance were clipped off screen. Fix: - Info panel reduced to 4 rows: Row 0 outer ring scale (scale reference) Row 1 closest node: name (left) + distance (right-aligned) Row 2 2nd closest node: name + distance Row 3 node count + HDG/N^ badge - Entries sorted by distance ascending before drawing so rows 1/2 always show the two nearest nodes. - Closest node rendered as a + cross on the radar; all others keep the 3×3 filled-square marker. The cross lets the user match the top panel row to the correct dot without cluttering the radar with text labels. --- src/graphics/draw/RadarRenderer.cpp | 104 +++++++++++++++++----------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index c7a8f31b423..3b84e2a3b3b 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -97,13 +97,27 @@ static void formatDistM(char *buf, size_t len, float metres) } } -/** Plot a 3×3 node marker. headingRad rotates the view (heading-up). */ -static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm) +/** + * Plot a node marker. + * + * highlight=true → cross/plus shape (used for the closest node — matches + * the top entry in the info panel so the user can identify it) + * highlight=false → 3×3 filled square (all other nodes) + * + * headingRad rotates the view (heading-up mode). + */ +static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm, + bool highlight) { const float rel = bearingRad - headingRad; const int px = cx + (int)(radius * norm * sinf(rel)); const int py = cy - (int)(radius * norm * cosf(rel)); - display->fillRect(px - 1, py - 1, 3, 3); + if (highlight) { + display->drawLine(px - 3, py, px + 3, py); // horizontal arm + display->drawLine(px, py - 3, px, py + 3); // vertical arm + } else { + display->fillRect(px - 1, py - 1, 3, 3); + } } // --------------------------------------------------------------------------- @@ -221,65 +235,71 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, // Own-node marker: filled 4×4 square at centre. display->fillRect(radarCX - 2, radarCY - 2, 4, 4); + // ----------------------------------------------------------------------- + // Sort by distance so entries[0] is always the closest node. + // ----------------------------------------------------------------------- + std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); + // ----------------------------------------------------------------------- // Plot remote nodes. + // The closest node (entries[0]) gets a cross (+) marker so the user can + // match it to the top row of the info panel. All others get filled squares. // ----------------------------------------------------------------------- - for (const Entry &e : entries) - plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, - std::min(e.distM / scale, 1.0f)); + for (size_t i = 0; i < entries.size(); i++) + plotNode(display, radarCX, radarCY, radarRadius, entries[i].bearingRad, headingRad, + std::min(entries[i].distM / scale, 1.0f), /*highlight=*/i == 0); // ----------------------------------------------------------------------- - // Info panel (right of radar). + // Info panel (right of radar). Four rows fit on a 128×64 OLED. + // + // Row 0: outer ring scale (gives the user the overall distance reference) + // Row 1: closest node — short name (left) + distance (right) + // Row 2: 2nd closest node — short name (left) + distance (right) + // Row 3: node count (left) + orientation badge "HDG"/"N^" (right) // - // Line 1: outermost ring scale - // Line 2: middle ring scale - // Line 3: inner ring scale - // Line 4: closest node name - // Line 5: closest node distance + cardinal direction - // Line 6: node count + orientation badge (HDG/N↑) + // The closest node is also rendered as a + cross on the radar so the + // user can visually match the top row here to the correct dot. // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); - // Ring scale labels — scale is always divisible by 3, so these are ints. - char buf[12]; + char buf[16]; + + // Row 0 — outer ring scale. formatDistM(buf, sizeof(buf), scale); display->drawString(infoPanelX, y + headerH, buf); - formatDistM(buf, sizeof(buf), scale * 2.0f / 3.0f); - display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL, buf); - - formatDistM(buf, sizeof(buf), scale / 3.0f); - display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 2, buf); - - // Closest node info. - if (!entries.empty()) { - const Entry &closest = *std::min_element(entries.begin(), entries.end(), - [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); - - // Short name or last-4 of node ID. - char name[16] = ""; - if (closest.node->has_user && closest.node->user.short_name[0]) - strncpy(name, closest.node->user.short_name, sizeof(name) - 1); + // Rows 1–2 — up to two closest nodes. + auto drawNodeRow = [&](const Entry &e, int row) { + char name[10] = ""; + if (e.node->has_user && e.node->user.short_name[0]) + strncpy(name, e.node->user.short_name, sizeof(name) - 1); else - snprintf(name, sizeof(name), "%04X", (uint16_t)(closest.node->num & 0xFFFF)); + snprintf(name, sizeof(name), "%04X", (uint16_t)(e.node->num & 0xFFFF)); - display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 3, name); + char dist[10] = ""; + formatDistM(dist, sizeof(dist), e.distM); - // Distance string. - formatDistM(buf, sizeof(buf), closest.distM); - display->drawString(infoPanelX, y + headerH + FONT_HEIGHT_SMALL * 4, buf); - } + const int rowY = y + headerH + FONT_HEIGHT_SMALL * row; + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(infoPanelX, rowY, name); + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(x + sw - 1, rowY, dist); + }; + + if (entries.size() >= 1) + drawNodeRow(entries[0], 1); // entries already sorted by distance + if (entries.size() >= 2) + drawNodeRow(entries[1], 2); - // Bottom row: node count + orientation badge. + // Row 3 — node count + orientation badge. { - const int bottomY = y + headerH + FONT_HEIGHT_SMALL * 5; + const int rowY = y + headerH + FONT_HEIGHT_SMALL * 3; snprintf(buf, sizeof(buf), "%d", (int)entries.size()); - display->drawString(infoPanelX, bottomY, buf); - - // Orientation badge: "HDG" when heading-up, "N^" when north-up. + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(infoPanelX, rowY, buf); display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + sw - 1, bottomY, headingUp ? "HDG" : "N^"); + display->drawString(x + sw - 1, rowY, headingUp ? "HDG" : "N^"); display->setTextAlignment(TEXT_ALIGN_LEFT); } } From 6dfc4ddcff4e4d03c64bd57293fd2063b46405d0 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Wed, 15 Apr 2026 23:41:46 +0200 Subject: [PATCH 06/31] feat(radar): 5 stable node symbols matching radar dots to panel list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each node is assigned a persistent marker shape based on nodeNum % 5: 0 ■ filled square 1 + axis-aligned cross 2 × diagonal X 3 □ hollow square 4 ◆ diamond The same symbol appears on the radar dot AND in the right-panel list, so the user can visually match any dot on the map to its panel entry without text labels cluttering the radar. Info panel: Row 0 outer ring scale Row 1 [sym] closest node name (left) distance (right) Row 2 [sym] 2nd closest (left) distance (right) Row 3 node count (left) orientation badge (right) --- src/graphics/draw/RadarRenderer.cpp | 102 ++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 29 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 3b84e2a3b3b..e5d4596ccd2 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -97,27 +97,67 @@ static void formatDistM(char *buf, size_t len, float metres) } } +// --------------------------------------------------------------------------- +// Node marker shapes +// --------------------------------------------------------------------------- + /** - * Plot a node marker. + * Draw one of five distinct markers centred at (px, py). * - * highlight=true → cross/plus shape (used for the closest node — matches - * the top entry in the info panel so the user can identify it) - * highlight=false → 3×3 filled square (all other nodes) + * 0 ■ filled 3×3 square + * 1 + axis-aligned cross + * 2 × diagonal cross (X) + * 3 □ hollow 5×5 square + * 4 ◆ diamond (rotated square) * - * headingRad rotates the view (heading-up mode). + * All shapes fit within a 5×5 pixel bounding box. + */ +static void drawMarker(OLEDDisplay *display, int px, int py, uint8_t sym) +{ + switch (sym) { + case 0: // ■ + display->fillRect(px - 1, py - 1, 3, 3); + break; + case 1: // + + display->drawLine(px - 2, py, px + 2, py); + display->drawLine(px, py - 2, px, py + 2); + break; + case 2: // × + display->drawLine(px - 2, py - 2, px + 2, py + 2); + display->drawLine(px + 2, py - 2, px - 2, py + 2); + break; + case 3: // □ + display->drawLine(px - 2, py - 2, px + 2, py - 2); + display->drawLine(px + 2, py - 2, px + 2, py + 2); + display->drawLine(px + 2, py + 2, px - 2, py + 2); + display->drawLine(px - 2, py + 2, px - 2, py - 2); + break; + default: // ◆ + display->drawLine(px, py - 2, px + 2, py); + display->drawLine(px + 2, py, px, py + 2); + display->drawLine(px, py + 2, px - 2, py); + display->drawLine(px - 2, py, px, py - 2); + break; + } +} + +/** + * Stable marker index for a node. The same node number always maps to the + * same symbol regardless of distance ranking or screen refresh order. */ +static uint8_t nodeMarkerIndex(uint32_t nodeNum) +{ + return (uint8_t)(nodeNum % 5); +} + +/** Plot a node on the radar at the correct bearing/distance position. */ static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm, - bool highlight) + uint8_t markerIdx) { const float rel = bearingRad - headingRad; const int px = cx + (int)(radius * norm * sinf(rel)); const int py = cy - (int)(radius * norm * cosf(rel)); - if (highlight) { - display->drawLine(px - 3, py, px + 3, py); // horizontal arm - display->drawLine(px, py - 3, px, py + 3); // vertical arm - } else { - display->fillRect(px - 1, py - 1, 3, 3); - } + drawMarker(display, px, py, markerIdx); } // --------------------------------------------------------------------------- @@ -241,24 +281,22 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); // ----------------------------------------------------------------------- - // Plot remote nodes. - // The closest node (entries[0]) gets a cross (+) marker so the user can - // match it to the top row of the info panel. All others get filled squares. + // Plot remote nodes — each with its stable symbol. // ----------------------------------------------------------------------- - for (size_t i = 0; i < entries.size(); i++) - plotNode(display, radarCX, radarCY, radarRadius, entries[i].bearingRad, headingRad, - std::min(entries[i].distM / scale, 1.0f), /*highlight=*/i == 0); + for (const Entry &e : entries) + plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, + std::min(e.distM / scale, 1.0f), nodeMarkerIndex(e.node->num)); // ----------------------------------------------------------------------- - // Info panel (right of radar). Four rows fit on a 128×64 OLED. + // Info panel (right of radar). Four rows on a 128×64 OLED. // - // Row 0: outer ring scale (gives the user the overall distance reference) - // Row 1: closest node — short name (left) + distance (right) - // Row 2: 2nd closest node — short name (left) + distance (right) - // Row 3: node count (left) + orientation badge "HDG"/"N^" (right) + // Row 0: outer ring scale + // Row 1: closest node — [symbol] name (left) distance (right) + // Row 2: 2nd closest — [symbol] name (left) distance (right) + // Row 3: node count (left) + orientation badge (right) // - // The closest node is also rendered as a + cross on the radar so the - // user can visually match the top row here to the correct dot. + // Each node carries a stable symbol (nodeNum % 5) so the marker on the + // radar always matches the corresponding row in this panel. // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -269,8 +307,15 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, formatDistM(buf, sizeof(buf), scale); display->drawString(infoPanelX, y + headerH, buf); - // Rows 1–2 — up to two closest nodes. + // Draw one node row: symbol pixel-art (left) | name | distance (right). + // Symbol is centred vertically in the text row, 5×5 px, then name follows. auto drawNodeRow = [&](const Entry &e, int row) { + const int rowY = y + headerH + FONT_HEIGHT_SMALL * row; + const int symCX = infoPanelX + 3; // symbol horizontal centre + const int symCY = rowY + FONT_HEIGHT_SMALL / 2 - 1; // symbol vertical centre + + drawMarker(display, symCX, symCY, nodeMarkerIndex(e.node->num)); + char name[10] = ""; if (e.node->has_user && e.node->user.short_name[0]) strncpy(name, e.node->user.short_name, sizeof(name) - 1); @@ -280,15 +325,14 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, char dist[10] = ""; formatDistM(dist, sizeof(dist), e.distM); - const int rowY = y + headerH + FONT_HEIGHT_SMALL * row; display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawString(infoPanelX, rowY, name); + display->drawString(infoPanelX + 7, rowY, name); // 7 = 5px symbol + 2px gap display->setTextAlignment(TEXT_ALIGN_RIGHT); display->drawString(x + sw - 1, rowY, dist); }; if (entries.size() >= 1) - drawNodeRow(entries[0], 1); // entries already sorted by distance + drawNodeRow(entries[0], 1); if (entries.size() >= 2) drawNodeRow(entries[1], 2); From c46121d5feab543c899d58ea41d794a8fce55982 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Wed, 15 Apr 2026 23:57:44 +0200 Subject: [PATCH 07/31] feat(radar): use all 4 panel rows for node list, remove scale/count rows --- src/graphics/draw/RadarRenderer.cpp | 47 ++++++++--------------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index e5d4596ccd2..fb98777da27 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -288,31 +288,20 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, std::min(e.distM / scale, 1.0f), nodeMarkerIndex(e.node->num)); // ----------------------------------------------------------------------- - // Info panel (right of radar). Four rows on a 128×64 OLED. + // Info panel (right of radar). All 4 rows used for the node list. // - // Row 0: outer ring scale - // Row 1: closest node — [symbol] name (left) distance (right) - // Row 2: 2nd closest — [symbol] name (left) distance (right) - // Row 3: node count (left) + orientation badge (right) + // Rows 0–3: up to 4 closest nodes — [symbol] name (left) dist (right) // - // Each node carries a stable symbol (nodeNum % 5) so the marker on the - // radar always matches the corresponding row in this panel. + // Each node carries a stable symbol (nodeNum % 5) that matches its dot + // on the radar, so the user can identify any dot by reading the list. // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - char buf[16]; - - // Row 0 — outer ring scale. - formatDistM(buf, sizeof(buf), scale); - display->drawString(infoPanelX, y + headerH, buf); - - // Draw one node row: symbol pixel-art (left) | name | distance (right). - // Symbol is centred vertically in the text row, 5×5 px, then name follows. + // Draw one node row: symbol (left) | name | distance (right-aligned). auto drawNodeRow = [&](const Entry &e, int row) { const int rowY = y + headerH + FONT_HEIGHT_SMALL * row; - const int symCX = infoPanelX + 3; // symbol horizontal centre - const int symCY = rowY + FONT_HEIGHT_SMALL / 2 - 1; // symbol vertical centre + const int symCX = infoPanelX + 3; + const int symCY = rowY + FONT_HEIGHT_SMALL / 2 - 1; drawMarker(display, symCX, symCY, nodeMarkerIndex(e.node->num)); @@ -326,26 +315,16 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, formatDistM(dist, sizeof(dist), e.distM); display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawString(infoPanelX + 7, rowY, name); // 7 = 5px symbol + 2px gap + display->drawString(infoPanelX + 7, rowY, name); display->setTextAlignment(TEXT_ALIGN_RIGHT); display->drawString(x + sw - 1, rowY, dist); + display->setTextAlignment(TEXT_ALIGN_LEFT); }; - if (entries.size() >= 1) - drawNodeRow(entries[0], 1); - if (entries.size() >= 2) - drawNodeRow(entries[1], 2); - - // Row 3 — node count + orientation badge. - { - const int rowY = y + headerH + FONT_HEIGHT_SMALL * 3; - snprintf(buf, sizeof(buf), "%d", (int)entries.size()); - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawString(infoPanelX, rowY, buf); - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + sw - 1, rowY, headingUp ? "HDG" : "N^"); - display->setTextAlignment(TEXT_ALIGN_LEFT); - } + const int maxRows = 4; + const int count = (int)entries.size(); + for (int i = 0; i < count && i < maxRows; i++) + drawNodeRow(entries[i], i); } } // namespace RadarRenderer From f4f926122fcddc61a5604a3f1a21a3d82c9efbc3 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Wed, 15 Apr 2026 23:59:14 +0200 Subject: [PATCH 08/31] feat(radar): show up to 5 nodes by tightening row pitch to contentH/5 --- src/graphics/draw/RadarRenderer.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index fb98777da27..8d1283bf52c 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -288,20 +288,25 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, std::min(e.distM / scale, 1.0f), nodeMarkerIndex(e.node->num)); // ----------------------------------------------------------------------- - // Info panel (right of radar). All 4 rows used for the node list. + // Info panel (right of radar). All available height used for node list. // - // Rows 0–3: up to 4 closest nodes — [symbol] name (left) dist (right) + // Up to 5 closest nodes — [symbol] name (left) dist (right-aligned). + // Row pitch is tightened to contentH/5 so 5 rows fit without clipping + // (glyph height ≈10px < FONT_HEIGHT_SMALL ≈13px, so rows stay readable). // // Each node carries a stable symbol (nodeNum % 5) that matches its dot // on the radar, so the user can identify any dot by reading the list. // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); + const int maxRows = 5; + const int rowPitch = contentH / maxRows; // ≈10px on a 128×64 OLED + // Draw one node row: symbol (left) | name | distance (right-aligned). auto drawNodeRow = [&](const Entry &e, int row) { - const int rowY = y + headerH + FONT_HEIGHT_SMALL * row; + const int rowY = y + headerH + rowPitch * row; const int symCX = infoPanelX + 3; - const int symCY = rowY + FONT_HEIGHT_SMALL / 2 - 1; + const int symCY = rowY + rowPitch / 2; drawMarker(display, symCX, symCY, nodeMarkerIndex(e.node->num)); @@ -321,8 +326,7 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->setTextAlignment(TEXT_ALIGN_LEFT); }; - const int maxRows = 4; - const int count = (int)entries.size(); + const int count = (int)entries.size(); for (int i = 0; i < count && i < maxRows; i++) drawNodeRow(entries[i], i); } From e02b16ea292bde75ab57801c822b5668b8238062 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 21 Apr 2026 22:53:53 +0200 Subject: [PATCH 09/31] refactor(radar): merge radar into compass screen as a long-press toggle Instead of a separate screen slot in the rotation, the radar now lives inside the existing Position/Compass screen. - Remove standalone Radar frame and icon from screen rotation - Add 'Radar View' entry to the position long-press menu (positionBaseMenu) - Long-press in radar mode opens radarPositionMenu with: Compass View, N-UP/HDG-UP toggle, Zoom In, Zoom Out - UIRenderer::drawCompassAndLocationScreen delegates to RadarRenderer::drawRadarOverlay when uiconfig.radar_mode is true - Radar layout: node list (left) + radar circle (right, 2 px padding) - radar_mode persisted as bool field 20 in DeviceUIConfig (proto-compatible) - Removes nodelist_radar from hiddenFrames, framesetInfo.positions, and all frame-toggle machinery --- src/graphics/Screen.cpp | 25 +---- src/graphics/Screen.h | 4 - src/graphics/draw/MenuHandler.cpp | 104 ++++++++++--------- src/graphics/draw/MenuHandler.h | 2 +- src/graphics/draw/RadarRenderer.cpp | 91 ++++++++-------- src/graphics/draw/RadarRenderer.h | 32 +++--- src/graphics/draw/UIRenderer.cpp | 12 +++ src/graphics/images.h | 12 --- src/mesh/generated/meshtastic/device_ui.pb.h | 5 +- 9 files changed, 133 insertions(+), 154 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 3d0e6281079..8285278936b 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -38,7 +38,6 @@ along with this program. If not, see . #include "draw/MessageRenderer.h" #include "draw/NodeListRenderer.h" #include "draw/NotificationRenderer.h" -#include "draw/RadarRenderer.h" #include "draw/UIRenderer.h" #include "graphics/TFTColorRegions.h" #include "modules/CannedMessageModule.h" @@ -1205,13 +1204,6 @@ void Screen::setFrames(FrameFocus focus) normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; indicatorIcons.push_back(icon_list); } -#endif -#ifndef USE_EINK - if (!hiddenFrames.nodelist_radar) { - fsi.positions.nodelist_radar = numframes; - normalFrames[numframes++] = graphics::RadarRenderer::drawRadarScreen; - indicatorIcons.push_back(icon_radar); - } #endif if (!hiddenFrames.gps) { fsi.positions.gps = numframes; @@ -1401,11 +1393,6 @@ void Screen::toggleFrameVisibility(const std::string &frameName) if (frameName == "nodelist_bearings") { hiddenFrames.nodelist_bearings = !hiddenFrames.nodelist_bearings; } -#endif -#ifndef USE_EINK - if (frameName == "nodelist_radar") { - hiddenFrames.nodelist_radar = !hiddenFrames.nodelist_radar; - } #endif if (frameName == "gps") { hiddenFrames.gps = !hiddenFrames.gps; @@ -1445,10 +1432,6 @@ bool Screen::isFrameHidden(const std::string &frameName) const #ifdef USE_EINK if (frameName == "nodelist_bearings") return hiddenFrames.nodelist_bearings; -#endif -#ifndef USE_EINK - if (frameName == "nodelist_radar") - return hiddenFrames.nodelist_radar; #endif if (frameName == "gps") return hiddenFrames.gps; @@ -1856,7 +1839,11 @@ int Screen::handleInputEvent(const InputEvent *event) menuHandler::systemBaseMenu(); #if HAS_GPS } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps && gps) { - menuHandler::positionBaseMenu(); + if (uiconfig.radar_mode) { + menuHandler::radarPositionMenu(); + } else { + menuHandler::positionBaseMenu(); + } #endif } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.clock) { menuHandler::clockMenu(); @@ -1876,8 +1863,6 @@ int Screen::handleInputEvent(const InputEvent *event) this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { menuHandler::favoriteBaseMenu(); - } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_radar) { - menuHandler::radarMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_nodes || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_lastheard || diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 23f285d9648..c2d64e50dff 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -701,7 +701,6 @@ class Screen : public concurrency::OSThread uint8_t nodelist_hopsignal = 255; uint8_t nodelist_distance = 255; uint8_t nodelist_bearings = 255; - uint8_t nodelist_radar = 255; uint8_t clock = 255; uint8_t chirpy = 255; uint8_t firstFavorite = 255; @@ -731,9 +730,6 @@ class Screen : public concurrency::OSThread #if HAS_GPS #ifdef USE_EINK bool nodelist_bearings = false; -#endif -#ifndef USE_EINK - bool nodelist_radar = false; #endif bool gps = false; #endif diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index a12c687b763..9801ae76694 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -1249,7 +1249,8 @@ void menuHandler::positionBaseMenu() CompassCalibrate, GPSSmartPosition, GPSUpdateInterval, - GPSPositionBroadcast + GPSPositionBroadcast, + RadarToggle, }; static const PositionMenuOption baseOptions[] = { @@ -1260,6 +1261,7 @@ void menuHandler::positionBaseMenu() {"Update Interval", OptionsAction::Select, static_cast(PositionAction::GPSUpdateInterval)}, {"Broadcast Interval", OptionsAction::Select, static_cast(PositionAction::GPSPositionBroadcast)}, {"Compass", OptionsAction::Select, static_cast(PositionAction::CompassMenu)}, + {"Radar View", OptionsAction::Select, static_cast(PositionAction::RadarToggle)}, }; static const PositionMenuOption calibrateOptions[] = { @@ -1271,6 +1273,7 @@ void menuHandler::positionBaseMenu() {"Broadcast Interval", OptionsAction::Select, static_cast(PositionAction::GPSPositionBroadcast)}, {"Compass", OptionsAction::Select, static_cast(PositionAction::CompassMenu)}, {"Compass Calibrate", OptionsAction::Select, static_cast(PositionAction::CompassCalibrate)}, + {"Radar View", OptionsAction::Select, static_cast(PositionAction::RadarToggle)}, }; constexpr size_t baseCount = sizeof(baseOptions) / sizeof(baseOptions[0]); @@ -1322,6 +1325,11 @@ void menuHandler::positionBaseMenu() menuQueue = GpsPositionBroadcastMenu; screen->runNow(); break; + case PositionAction::RadarToggle: + uiconfig.radar_mode = true; + menuHandler::saveUIConfig(); + screen->runNow(); + break; } }; @@ -1339,6 +1347,51 @@ void menuHandler::positionBaseMenu() screen->showOverlayBanner(bannerOptions); } +void menuHandler::radarPositionMenu() +{ + enum optionsNumbers { Back, CompassView, ToggleHeading, ZoomIn, ZoomOut }; + static const char *optionsArray[] = { + "Back", + "Compass View", + nullptr, // filled dynamically + "Zoom In", + "Zoom Out", + }; + static int optionsEnumArray[] = {Back, CompassView, ToggleHeading, ZoomIn, ZoomOut}; + + optionsArray[ToggleHeading] = graphics::RadarRenderer::isNorthUp() ? "Switch to HDG-UP" : "Switch to N-UP"; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Radar Options"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 5; + bannerOptions.optionsEnumPtr = optionsEnumArray; + + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == Back) { + screen->setFrames(Screen::FOCUS_PRESERVE); + } else if (selected == CompassView) { + uiconfig.radar_mode = false; + menuHandler::saveUIConfig(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); + } else if (selected == ToggleHeading) { + graphics::RadarRenderer::toggleNorthUp(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); + } else if (selected == ZoomIn) { + graphics::RadarRenderer::zoomIn(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); + } else if (selected == ZoomOut) { + graphics::RadarRenderer::zoomOut(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::nodeListMenu() { enum optionsNumbers { Back, NodePicker, TraceRoute, Verify, Reset, NodeNameLength, enumEnd }; @@ -2402,7 +2455,6 @@ void menuHandler::frameTogglesMenu() nodelist_hopsignal, nodelist_distance, nodelist_bearings, - nodelist_radar, gps_position, lora, clock, @@ -2440,11 +2492,6 @@ void menuHandler::frameTogglesMenu() optionsEnumArray[options++] = nodelist_bearings; #endif -#ifndef USE_EINK - optionsArray[options] = screen->isFrameHidden("nodelist_radar") ? "Show Radar" : "Hide Radar"; - optionsEnumArray[options++] = nodelist_radar; -#endif - optionsArray[options] = screen->isFrameHidden("gps") ? "Show Position" : "Hide Position"; optionsEnumArray[options++] = gps_position; #endif @@ -2509,10 +2556,6 @@ void menuHandler::frameTogglesMenu() screen->toggleFrameVisibility("nodelist_bearings"); menuHandler::menuQueue = menuHandler::FrameToggles; screen->runNow(); - } else if (selected == nodelist_radar) { - screen->toggleFrameVisibility("nodelist_radar"); - menuHandler::menuQueue = menuHandler::FrameToggles; - screen->runNow(); } else if (selected == gps_position) { screen->toggleFrameVisibility("gps"); menuHandler::menuQueue = menuHandler::FrameToggles; @@ -2807,45 +2850,6 @@ void menuHandler::saveUIConfig() nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uiconfig); } -void menuHandler::radarMenu() -{ - enum optionsNumbers { Back, ToggleHeading, ZoomIn, ZoomOut }; - static const char *optionsArray[] = { - "Back", - nullptr, // filled dynamically based on current mode - "Zoom In", - "Zoom Out", - }; - static int optionsEnumArray[] = {Back, ToggleHeading, ZoomIn, ZoomOut}; - - optionsArray[ToggleHeading] = graphics::RadarRenderer::isNorthUp() ? "Switch to HDG-UP" : "Switch to N-UP"; - - BannerOverlayOptions bannerOptions; - bannerOptions.message = "Radar Options"; - bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 4; - bannerOptions.optionsEnumPtr = optionsEnumArray; - - bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == Back) { - screen->setFrames(Screen::FOCUS_PRESERVE); - } else if (selected == ToggleHeading) { - graphics::RadarRenderer::toggleNorthUp(); - screen->setFrames(Screen::FOCUS_PRESERVE); - screen->runNow(); - } else if (selected == ZoomIn) { - graphics::RadarRenderer::zoomIn(); - screen->setFrames(Screen::FOCUS_PRESERVE); - screen->runNow(); - } else if (selected == ZoomOut) { - graphics::RadarRenderer::zoomOut(); - screen->setFrames(Screen::FOCUS_PRESERVE); - screen->runNow(); - } - }; - screen->showOverlayBanner(bannerOptions); -} - } // namespace graphics #endif diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 35fa1f2962a..42e32ebf2b7 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -90,7 +90,7 @@ class menuHandler static void BuzzerModeMenu(); static void switchToMUIMenu(); static void nodeListMenu(); - static void radarMenu(); + static void radarPositionMenu(); static void resetNodeDBMenu(); static void BrightnessPickerMenu(); static void rebootMenu(); diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 8d1283bf52c..d730acfdf7b 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -18,7 +18,7 @@ namespace RadarRenderer { // --------------------------------------------------------------------------- -// Runtime state (toggled by radarMenu) +// Runtime state (toggled by radarPositionMenu) // --------------------------------------------------------------------------- static bool s_forceNorthUp = false; // override IMU → fixed north-up @@ -57,7 +57,6 @@ void zoomOut() */ static float niceScaleMeters(float maxDistM, int zoomLevel) { - // Every entry is divisible by 3 → ring labels are always integers. static const float scales[] = { 30, 60, 90, 150, 300, 600, 900, 1500, 3000, 6000, 9000, 15000, 30000, 90000, @@ -65,12 +64,10 @@ static float niceScaleMeters(float maxDistM, int zoomLevel) }; constexpr int N = sizeof(scales) / sizeof(scales[0]); - // Find the base auto-scale index. int idx = 0; while (idx < N - 1 && maxDistM > scales[idx]) idx++; - // Apply zoom offset (clamp to valid range). idx = std::max(0, std::min(N - 1, idx + zoomLevel)); return scales[idx]; } @@ -161,33 +158,39 @@ static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bea } // --------------------------------------------------------------------------- -// Main draw function +// Overlay renderer // --------------------------------------------------------------------------- -void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +/** + * Draw the radar overlay into the content area of the compass/position screen. + * + * Layout (128×64 OLED example): + * - Header row already drawn by the caller (FONT_HEIGHT_SMALL - 1 px) + * - Right side: circular radar with 2 px padding on all sides + * - Left side: node list (up to 4 closest nodes, marker + name + distance) + * + * Called from UIRenderer::drawCompassAndLocationScreen when uiconfig.radar_mode + * is true. The caller draws the header and footer; this function handles the + * content area only. + */ +void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) { - display->clear(); - graphics::drawCommonHeader(display, x, y, "Radar"); - const int headerH = FONT_HEIGHT_SMALL - 1; const int sw = SCREEN_WIDTH; const int sh = SCREEN_HEIGHT; const int contentH = sh - headerH; + const int pad = 2; // px padding around the radar circle // ----------------------------------------------------------------------- - // Layout: radar circle on the left, info panel on the right. - // - // The radar is a square area equal to the content height, leaving a panel - // of (sw - radarDiam) pixels on the right for labels. - // - // On 128×64 OLED (contentH=57): - // radarDiam = 55 radarRadius = 27 infoPanelX = 92 (36 px panel) + // Radar circle — right side, 2 px padding on all sides. // ----------------------------------------------------------------------- - const int radarDiam = contentH - 2; // 1 px margin top + bottom + const int radarDiam = contentH - 2 * pad; const int radarRadius = radarDiam / 2; - const int radarCX = x + radarRadius + 1; // left-aligned with 1px margin - const int radarCY = y + headerH + 1 + radarRadius; - const int infoPanelX = x + radarDiam + 4; + const int radarCX = x + sw - pad - radarRadius; + const int radarCY = y + headerH + pad + radarRadius; + + // Node list panel fills the space to the left of the radar circle. + const int listRight = radarCX - radarRadius - 4; // 4 px gap between list and circle // ----------------------------------------------------------------------- // GPS — bail gracefully if unavailable. @@ -207,7 +210,7 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, // Heading. // // Priority: - // 1. BMX160/RAK12034 tilt-compensated heading via screen->setHeading() + // 1. BMX160/RAK12034 tilt-compensated heading (screen->hasHeading()) // 2. GPS movement track (estimatedHeading) // 3. North-up fallback (0) // @@ -248,11 +251,11 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, maxDistM = dist; } - // ----------------------------------------------------------------------- - // Scale (respects zoom level set by long-press menu). - // ----------------------------------------------------------------------- const float scale = niceScaleMeters(maxDistM, s_zoomLevel); + // Sort by distance so entries[0] is always the closest node. + std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); + // ----------------------------------------------------------------------- // Draw radar chrome: three concentric range rings. // ----------------------------------------------------------------------- @@ -276,36 +279,28 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->fillRect(radarCX - 2, radarCY - 2, 4, 4); // ----------------------------------------------------------------------- - // Sort by distance so entries[0] is always the closest node. - // ----------------------------------------------------------------------- - std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); - - // ----------------------------------------------------------------------- - // Plot remote nodes — each with its stable symbol. + // Plot remote nodes. // ----------------------------------------------------------------------- for (const Entry &e : entries) plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, std::min(e.distM / scale, 1.0f), nodeMarkerIndex(e.node->num)); // ----------------------------------------------------------------------- - // Info panel (right of radar). All available height used for node list. + // Node list (left panel) — up to 4 closest nodes. // - // Up to 5 closest nodes — [symbol] name (left) dist (right-aligned). - // Row pitch is tightened to contentH/5 so 5 rows fit without clipping - // (glyph height ≈10px < FONT_HEIGHT_SMALL ≈13px, so rows stay readable). - // - // Each node carries a stable symbol (nodeNum % 5) that matches its dot - // on the radar, so the user can identify any dot by reading the list. + // Each row: stable marker symbol | short name | distance (right-aligned). + // The symbol matches the dot on the radar so the user can identify nodes. // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); - const int maxRows = 5; - const int rowPitch = contentH / maxRows; // ≈10px on a 128×64 OLED + const int maxRows = 4; + const int rowPitch = contentH / maxRows; - // Draw one node row: symbol (left) | name | distance (right-aligned). - auto drawNodeRow = [&](const Entry &e, int row) { - const int rowY = y + headerH + rowPitch * row; - const int symCX = infoPanelX + 3; + const int count = (int)entries.size(); + for (int i = 0; i < count && i < maxRows; i++) { + const Entry &e = entries[i]; + const int rowY = y + headerH + rowPitch * i; + const int symCX = x + 3; const int symCY = rowY + rowPitch / 2; drawMarker(display, symCX, symCY, nodeMarkerIndex(e.node->num)); @@ -320,15 +315,11 @@ void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, formatDistM(dist, sizeof(dist), e.distM); display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawString(infoPanelX + 7, rowY, name); + display->drawString(x + 7, rowY, name); display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + sw - 1, rowY, dist); + display->drawString(x + listRight, rowY, dist); display->setTextAlignment(TEXT_ALIGN_LEFT); - }; - - const int count = (int)entries.size(); - for (int i = 0; i < count && i < maxRows; i++) - drawNodeRow(entries[i], i); + } } } // namespace RadarRenderer diff --git a/src/graphics/draw/RadarRenderer.h b/src/graphics/draw/RadarRenderer.h index b6a02e40af8..e0978126573 100644 --- a/src/graphics/draw/RadarRenderer.h +++ b/src/graphics/draw/RadarRenderer.h @@ -10,26 +10,26 @@ namespace graphics class Screen; /** - * @brief Radar/minimap screen showing nearby nodes by bearing and distance. + * @brief Radar overlay for the compass/position screen. * - * Renders a circular radar with three labelled range rings. The user's node - * sits at the centre; other nodes with valid GPS positions are plotted as - * 3×3 squares at their true bearing and proportional distance. + * Draws a node list on the left and a circular radar minimap on the right, + * replacing the GPS text shown in compass mode. The user's node sits at the + * centre; remote nodes with valid positions are plotted as small markers at + * their true bearing and proportional distance. * - * When the BMX160 (RAK12034) is connected the display is heading-up: the - * direction the device faces is always at the top. A "N" label rotates to - * show true north. Without IMU the display is north-up. + * When the BMX160 (RAK12034) is connected the radar is heading-up (the + * direction the device faces is at the top). A "N" label rotates to show + * true north. Without IMU the display is north-up. * - * The heading mode and zoom level can be overridden at runtime via the - * long-press menu (radarMenu in MenuHandler). + * Heading mode and zoom level are toggled via the long-press radar menu. */ namespace RadarRenderer { -// ---- Frame callback --------------------------------------------------------- -void drawRadarScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +// ---- Content-area renderer (called from drawCompassAndLocationScreen) ------- +void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y); -// ---- Runtime state (controlled by radarMenu) -------------------------------- +// ---- Runtime state (controlled by radarPositionMenu) ------------------------ /** Returns true when forced north-up is active (overriding IMU). */ bool isNorthUp(); @@ -40,10 +40,10 @@ void toggleNorthUp(); /** * Zoom level relative to the auto-calculated scale. * 0 = auto (default) - * -1 = zoom in (scale halved) - * -2 = zoom in (scale quartered) - * +1 = zoom out (scale doubled) - * +2 = zoom out (scale quadrupled) + * -1 = zoom in + * -2 = zoom in (further) + * +1 = zoom out + * +2 = zoom out (further) * Clamped to [-2, +2]. */ void zoomIn(); diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index cfde101247f..db0713bc825 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -1,6 +1,7 @@ #include "configuration.h" #if HAS_SCREEN #include "CompassRenderer.h" +#include "RadarRenderer.h" #include "GPSStatus.h" #include "MeshService.h" #include "NodeDB.h" @@ -1555,6 +1556,17 @@ void UIRenderer::drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->clear(); + + // === Radar overlay mode === + // When radar_mode is enabled (toggled via long-press menu), replace the + // GPS text with a node list and draw a circular radar minimap on the right. + if (uiconfig.radar_mode) { + graphics::drawCommonHeader(display, x, y, "Radar"); + graphics::RadarRenderer::drawRadarOverlay(display, x, y); + graphics::drawCommonFooter(display, x, y); + return; + } + display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); int line = 1; diff --git a/src/graphics/images.h b/src/graphics/images.h index 69fcadc5f2f..66fcbc79c5d 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -113,18 +113,6 @@ const unsigned char icon_compass[] PROGMEM = { 0x3C // Row 7: ..####.. }; -// 📡 Radar Screen — concentric rings with centre dot -const uint8_t icon_radar[] PROGMEM = { - 0x18, // Row 0: ...##... - 0x24, // Row 1: ..#..#.. - 0x5A, // Row 2: .#.##.#. - 0xA5, // Row 3: #.#..#.# - 0xA5, // Row 4: #.#..#.# - 0x5A, // Row 5: .#.##.#. - 0x24, // Row 6: ..#..#.. - 0x18 // Row 7: ...##... -}; - const uint8_t icon_radio[] PROGMEM = { 0x0F, // Row 0: ####.... 0x10, // Row 1: ....#... diff --git a/src/mesh/generated/meshtastic/device_ui.pb.h b/src/mesh/generated/meshtastic/device_ui.pb.h index b99fb10b93b..194132716e4 100644 --- a/src/mesh/generated/meshtastic/device_ui.pb.h +++ b/src/mesh/generated/meshtastic/device_ui.pb.h @@ -193,6 +193,8 @@ typedef struct _meshtastic_DeviceUIConfig { bool is_clockface_analog; /* How the GPS coordinates are formatted on the OLED screen. */ meshtastic_DeviceUIConfig_GpsCoordinateFormat gps_format; + /* Show radar overlay on the compass/position screen instead of GPS text. */ + bool radar_mode; } meshtastic_DeviceUIConfig; @@ -298,7 +300,8 @@ X(a, STATIC, OPTIONAL, MESSAGE, map_data, 15) \ X(a, STATIC, SINGULAR, UENUM, compass_mode, 16) \ X(a, STATIC, SINGULAR, UINT32, screen_rgb_color, 17) \ X(a, STATIC, SINGULAR, BOOL, is_clockface_analog, 18) \ -X(a, STATIC, SINGULAR, UENUM, gps_format, 19) +X(a, STATIC, SINGULAR, UENUM, gps_format, 19) \ +X(a, STATIC, SINGULAR, BOOL, radar_mode, 20) #define meshtastic_DeviceUIConfig_CALLBACK NULL #define meshtastic_DeviceUIConfig_DEFAULT NULL #define meshtastic_DeviceUIConfig_node_filter_MSGTYPE meshtastic_NodeFilter From 884ecde3dbb8021c2bd7868cf67d07f32934a701 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 21 Apr 2026 23:11:02 +0200 Subject: [PATCH 10/31] fix(radar): move Radar View to top of position menu, show 5 nodes, 1px centre dot --- src/graphics/draw/MenuHandler.cpp | 4 ++-- src/graphics/draw/RadarRenderer.cpp | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 9801ae76694..aa9cb94428e 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -1255,17 +1255,18 @@ void menuHandler::positionBaseMenu() static const PositionMenuOption baseOptions[] = { {"Back", OptionsAction::Back}, + {"Radar View", OptionsAction::Select, static_cast(PositionAction::RadarToggle)}, {"On/Off Toggle", OptionsAction::Select, static_cast(PositionAction::GpsToggle)}, {"Format", OptionsAction::Select, static_cast(PositionAction::GpsFormat)}, {"Smart Position", OptionsAction::Select, static_cast(PositionAction::GPSSmartPosition)}, {"Update Interval", OptionsAction::Select, static_cast(PositionAction::GPSUpdateInterval)}, {"Broadcast Interval", OptionsAction::Select, static_cast(PositionAction::GPSPositionBroadcast)}, {"Compass", OptionsAction::Select, static_cast(PositionAction::CompassMenu)}, - {"Radar View", OptionsAction::Select, static_cast(PositionAction::RadarToggle)}, }; static const PositionMenuOption calibrateOptions[] = { {"Back", OptionsAction::Back}, + {"Radar View", OptionsAction::Select, static_cast(PositionAction::RadarToggle)}, {"On/Off Toggle", OptionsAction::Select, static_cast(PositionAction::GpsToggle)}, {"Format", OptionsAction::Select, static_cast(PositionAction::GpsFormat)}, {"Smart Position", OptionsAction::Select, static_cast(PositionAction::GPSSmartPosition)}, @@ -1273,7 +1274,6 @@ void menuHandler::positionBaseMenu() {"Broadcast Interval", OptionsAction::Select, static_cast(PositionAction::GPSPositionBroadcast)}, {"Compass", OptionsAction::Select, static_cast(PositionAction::CompassMenu)}, {"Compass Calibrate", OptionsAction::Select, static_cast(PositionAction::CompassCalibrate)}, - {"Radar View", OptionsAction::Select, static_cast(PositionAction::RadarToggle)}, }; constexpr size_t baseCount = sizeof(baseOptions) / sizeof(baseOptions[0]); diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index d730acfdf7b..6e69be248ac 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -275,28 +275,30 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) display->drawString(nx, ny - FONT_HEIGHT_SMALL / 2, "N"); } - // Own-node marker: filled 4×4 square at centre. - display->fillRect(radarCX - 2, radarCY - 2, 4, 4); + // Own-node marker: single pixel at centre. + display->setPixel(radarCX, radarCY); // ----------------------------------------------------------------------- - // Plot remote nodes. + // Plot remote nodes — cap at 5 to match the list panel. // ----------------------------------------------------------------------- - for (const Entry &e : entries) + const int maxRows = 5; + const int count = (int)entries.size(); + for (int i = 0; i < count && i < maxRows; i++) { + const Entry &e = entries[i]; plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, std::min(e.distM / scale, 1.0f), nodeMarkerIndex(e.node->num)); + } // ----------------------------------------------------------------------- - // Node list (left panel) — up to 4 closest nodes. + // Node list (left panel) — up to 5 closest nodes. // // Each row: stable marker symbol | short name | distance (right-aligned). // The symbol matches the dot on the radar so the user can identify nodes. // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); - const int maxRows = 4; const int rowPitch = contentH / maxRows; - const int count = (int)entries.size(); for (int i = 0; i < count && i < maxRows; i++) { const Entry &e = entries[i]; const int rowY = y + headerH + rowPitch * i; From 7c86a47fdc6e55e4b5f3b5f36f21e0e25fee8a0c Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Mon, 11 May 2026 23:22:53 +0200 Subject: [PATCH 11/31] fix(radar): scale from plotted nodes only; show outer-ring range label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: tester saw nodes in the list panel but no dots on the radar even after zooming in. Root cause: maxDistM was the farthest distance among ALL nodes with valid positions, but only the 5 closest are actually plotted. A single distant node pushed the auto-scale into a high bucket, so the close nodes ended up at norm ≈ 0 and piled on top of the own-node centre pixel — invisible. Fix: sort first, then compute maxDistM from only the top-N nodes that will actually be plotted. The far-away node still appears in the node list but no longer skews the scale. Also adds a small outer-ring scale label inside the radar so the user can see the current range at any zoom level (a direct answer to "what zoom level am I at?"). --- src/graphics/draw/RadarRenderer.cpp | 40 +++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 6e69be248ac..156ff21c29d 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -231,7 +231,6 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) }; std::vector entries; - float maxDistM = 1.0f; const int numNodes = nodeDB->getNumMeshNodes(); for (int i = 0; i < numNodes; i++) { @@ -247,15 +246,24 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const float brg = GeoCoord::bearing(myLat, myLon, nodeLat, nodeLon); entries.push_back({n, dist, brg}); - if (dist > maxDistM) - maxDistM = dist; } - const float scale = niceScaleMeters(maxDistM, s_zoomLevel); - // Sort by distance so entries[0] is always the closest node. std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); + // Auto-scale from only the nodes we will actually plot, so a single + // far-away node can't push the scale into a high bucket and squash all + // the close nodes into an invisible cluster at the centre. + constexpr int kMaxPlotted = 5; + float maxDistM = 1.0f; + const int plottedCount = std::min((int)entries.size(), kMaxPlotted); + for (int i = 0; i < plottedCount; i++) { + if (entries[i].distM > maxDistM) + maxDistM = entries[i].distM; + } + + const float scale = niceScaleMeters(maxDistM, s_zoomLevel); + // ----------------------------------------------------------------------- // Draw radar chrome: three concentric range rings. // ----------------------------------------------------------------------- @@ -279,16 +287,26 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) display->setPixel(radarCX, radarCY); // ----------------------------------------------------------------------- - // Plot remote nodes — cap at 5 to match the list panel. + // Plot remote nodes — cap at kMaxPlotted to match the list panel. // ----------------------------------------------------------------------- - const int maxRows = 5; - const int count = (int)entries.size(); - for (int i = 0; i < count && i < maxRows; i++) { + for (int i = 0; i < plottedCount; i++) { const Entry &e = entries[i]; plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, std::min(e.distM / scale, 1.0f), nodeMarkerIndex(e.node->num)); } + // ----------------------------------------------------------------------- + // Scale label — outer-ring distance, drawn just inside the bottom of the + // radar circle so the user knows the current range at this zoom level. + // ----------------------------------------------------------------------- + { + char scaleBuf[12] = ""; + formatDistM(scaleBuf, sizeof(scaleBuf), scale); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(radarCX, radarCY + radarRadius - FONT_HEIGHT_SMALL - 1, scaleBuf); + } + // ----------------------------------------------------------------------- // Node list (left panel) — up to 5 closest nodes. // @@ -297,9 +315,9 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); - const int rowPitch = contentH / maxRows; + const int rowPitch = contentH / kMaxPlotted; - for (int i = 0; i < count && i < maxRows; i++) { + for (int i = 0; i < plottedCount; i++) { const Entry &e = entries[i]; const int rowY = y + headerH + rowPitch * i; const int symCX = x + 3; From 73a5ec58ebf27e181a14f434afc0ee8670525b6e Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Mon, 11 May 2026 23:34:35 +0200 Subject: [PATCH 12/31] feat(radar): Compass Face picker, favorites filter, unique markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback round 2: 1. Compass/Radar toggle now follows the Clock Face pattern — long-press on the position screen opens a menu with a "Compass Face" entry, which opens a picker ("Compass" / "Radar"). Selection persists to uiconfig.radar_mode and applies immediately. Both the standard position menu and the radar long-press menu use the same picker. A small helper (setCompassFacePickerReturn) tracks where "Back" should go, mirroring the clockFacePicker → ClockMenu return path. 2. Marker symbols are now unique across the 5 plotted nodes. They are assigned by sort-position index (0..4), so each row in the list panel always gets a distinct shape and matches its dot on the radar. Previously markers came from (nodeNum % 5), which collided whenever two plotted nodes happened to share a residue. 3. New "Show: Favorites Only" toggle in the radar menu. When enabled the radar only plots favorite nodes — useful at large events where the user has deliberately marked the nodes they care about. Backed by a new uiconfig.radar_favorites_only field (tag 21). --- src/graphics/draw/MenuHandler.cpp | 91 ++++++++++++++++---- src/graphics/draw/MenuHandler.h | 6 ++ src/graphics/draw/RadarRenderer.cpp | 25 +++--- src/mesh/generated/meshtastic/device_ui.pb.h | 7 +- 4 files changed, 100 insertions(+), 29 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index aa9cb94428e..6dcd4c239ce 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -455,6 +455,56 @@ void menuHandler::showConfirmationBanner(const char *message, std::functionshowOverlayBanner(confirmBanner); } +// Where compassFacePicker should return when the user picks "Back". Set by +// the calling menu (PositionBaseMenu or the radar-mode banner) just before +// queueing the picker, so back behaves like the Clock Face → Clock Menu flow. +static menuHandler::screenMenus s_compassFacePickerReturn = menuHandler::MenuNone; + +void menuHandler::setCompassFacePickerReturn(screenMenus target) +{ + s_compassFacePickerReturn = target; +} + +void menuHandler::compassFacePicker() +{ + // Mirrors clockFacePicker: pick between the standard Compass screen and the + // Radar overlay. Stored in uiconfig.radar_mode (false=Compass, true=Radar). + static const ClockFaceOption compassFaceOptions[] = { + {"Back", OptionsAction::Back}, + {"Compass", OptionsAction::Select, false}, + {"Radar", OptionsAction::Select, true}, + }; + + constexpr size_t compassFaceCount = sizeof(compassFaceOptions) / sizeof(compassFaceOptions[0]); + static std::array compassFaceLabels{}; + + auto bannerOptions = createStaticBannerOptions("Which Face?", compassFaceOptions, compassFaceLabels, + [](const ClockFaceOption &option, int) -> void { + if (option.action == OptionsAction::Back) { + menuHandler::menuQueue = s_compassFacePickerReturn; + if (s_compassFacePickerReturn != MenuNone) + screen->runNow(); + return; + } + + if (!option.hasValue) { + return; + } + + if (uiconfig.radar_mode == option.value) { + return; + } + + uiconfig.radar_mode = option.value; + menuHandler::saveUIConfig(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); + }); + + bannerOptions.InitialSelected = uiconfig.radar_mode ? 2 : 1; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::clockFacePicker() { static const ClockFaceOption clockFaceOptions[] = { @@ -1250,12 +1300,12 @@ void menuHandler::positionBaseMenu() GPSSmartPosition, GPSUpdateInterval, GPSPositionBroadcast, - RadarToggle, + CompassFace, }; static const PositionMenuOption baseOptions[] = { {"Back", OptionsAction::Back}, - {"Radar View", OptionsAction::Select, static_cast(PositionAction::RadarToggle)}, + {"Compass Face", OptionsAction::Select, static_cast(PositionAction::CompassFace)}, {"On/Off Toggle", OptionsAction::Select, static_cast(PositionAction::GpsToggle)}, {"Format", OptionsAction::Select, static_cast(PositionAction::GpsFormat)}, {"Smart Position", OptionsAction::Select, static_cast(PositionAction::GPSSmartPosition)}, @@ -1266,7 +1316,7 @@ void menuHandler::positionBaseMenu() static const PositionMenuOption calibrateOptions[] = { {"Back", OptionsAction::Back}, - {"Radar View", OptionsAction::Select, static_cast(PositionAction::RadarToggle)}, + {"Compass Face", OptionsAction::Select, static_cast(PositionAction::CompassFace)}, {"On/Off Toggle", OptionsAction::Select, static_cast(PositionAction::GpsToggle)}, {"Format", OptionsAction::Select, static_cast(PositionAction::GpsFormat)}, {"Smart Position", OptionsAction::Select, static_cast(PositionAction::GPSSmartPosition)}, @@ -1325,9 +1375,9 @@ void menuHandler::positionBaseMenu() menuQueue = GpsPositionBroadcastMenu; screen->runNow(); break; - case PositionAction::RadarToggle: - uiconfig.radar_mode = true; - menuHandler::saveUIConfig(); + case PositionAction::CompassFace: + setCompassFacePickerReturn(PositionBaseMenu); + menuQueue = CompassFacePicker; screen->runNow(); break; } @@ -1349,36 +1399,44 @@ void menuHandler::positionBaseMenu() void menuHandler::radarPositionMenu() { - enum optionsNumbers { Back, CompassView, ToggleHeading, ZoomIn, ZoomOut }; + enum optionsNumbers { Back, CompassFace, ToggleHeading, ToggleFavorites, ZoomIn, ZoomOut }; static const char *optionsArray[] = { "Back", - "Compass View", - nullptr, // filled dynamically + "Compass Face", + nullptr, // ToggleHeading — filled dynamically below + nullptr, // ToggleFavorites — filled dynamically below "Zoom In", "Zoom Out", }; - static int optionsEnumArray[] = {Back, CompassView, ToggleHeading, ZoomIn, ZoomOut}; + static int optionsEnumArray[] = {Back, CompassFace, ToggleHeading, ToggleFavorites, ZoomIn, ZoomOut}; optionsArray[ToggleHeading] = graphics::RadarRenderer::isNorthUp() ? "Switch to HDG-UP" : "Switch to N-UP"; + optionsArray[ToggleFavorites] = uiconfig.radar_favorites_only ? "Show: All Nodes" : "Show: Favorites Only"; BannerOverlayOptions bannerOptions; bannerOptions.message = "Radar Options"; bannerOptions.optionsArrayPtr = optionsArray; - bannerOptions.optionsCount = 5; + bannerOptions.optionsCount = sizeof(optionsEnumArray) / sizeof(optionsEnumArray[0]); bannerOptions.optionsEnumPtr = optionsEnumArray; bannerOptions.bannerCallback = [](int selected) -> void { if (selected == Back) { screen->setFrames(Screen::FOCUS_PRESERVE); - } else if (selected == CompassView) { - uiconfig.radar_mode = false; - menuHandler::saveUIConfig(); - screen->setFrames(Screen::FOCUS_PRESERVE); + } else if (selected == CompassFace) { + // Radar menu has no own enum (invoked directly from input handler), + // so back from the picker just dismisses to the screen. + setCompassFacePickerReturn(MenuNone); + menuQueue = CompassFacePicker; screen->runNow(); } else if (selected == ToggleHeading) { graphics::RadarRenderer::toggleNorthUp(); screen->setFrames(Screen::FOCUS_PRESERVE); screen->runNow(); + } else if (selected == ToggleFavorites) { + uiconfig.radar_favorites_only = !uiconfig.radar_favorites_only; + menuHandler::saveUIConfig(); + screen->setFrames(Screen::FOCUS_PRESERVE); + screen->runNow(); } else if (selected == ZoomIn) { graphics::RadarRenderer::zoomIn(); screen->setFrames(Screen::FOCUS_PRESERVE); @@ -2728,6 +2786,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case ClockMenu: clockMenu(); break; + case CompassFacePicker: + compassFacePicker(); + break; case SystemBaseMenu: systemBaseMenu(); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 42e32ebf2b7..a1abecb4723 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -19,6 +19,7 @@ class menuHandler TwelveHourPicker, ClockFacePicker, ClockMenu, + CompassFacePicker, PositionBaseMenu, NodeBaseMenu, GpsToggleMenu, @@ -72,6 +73,11 @@ class menuHandler static void TZPicker(); static void twelveHourPicker(); static void clockFacePicker(); + static void compassFacePicker(); + // Set the menu to re-open when the user picks "Back" from compassFacePicker. + // Caller invokes this immediately before queueing CompassFacePicker so the + // picker can return to its actual parent menu (or MenuNone to dismiss). + static void setCompassFacePickerReturn(screenMenus target); static void messageResponseMenu(); static void messageViewModeMenu(); static void replyMenu(); diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 156ff21c29d..1185676bc3f 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -138,15 +138,6 @@ static void drawMarker(OLEDDisplay *display, int px, int py, uint8_t sym) } } -/** - * Stable marker index for a node. The same node number always maps to the - * same symbol regardless of distance ranking or screen refresh order. - */ -static uint8_t nodeMarkerIndex(uint32_t nodeNum) -{ - return (uint8_t)(nodeNum % 5); -} - /** Plot a node on the radar at the correct bearing/distance position. */ static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm, uint8_t markerIdx) @@ -232,11 +223,15 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) std::vector entries; + const bool favoritesOnly = uiconfig.radar_favorites_only; + const int numNodes = nodeDB->getNumMeshNodes(); for (int i = 0; i < numNodes; i++) { meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); if (!n || n->num == nodeDB->getNodeNum()) continue; + if (favoritesOnly && !n->is_favorite) + continue; if (!nodeDB->hasValidPosition(n)) continue; @@ -288,11 +283,16 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- // Plot remote nodes — cap at kMaxPlotted to match the list panel. + // + // Marker symbol is the sort-position index (0..4) so every plotted node + // gets a unique shape and matches its row in the list panel. Using the + // node number modulo 5 caused symbol collisions when several plotted + // nodes shared a residue. // ----------------------------------------------------------------------- for (int i = 0; i < plottedCount; i++) { const Entry &e = entries[i]; plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, - std::min(e.distM / scale, 1.0f), nodeMarkerIndex(e.node->num)); + std::min(e.distM / scale, 1.0f), (uint8_t)i); } // ----------------------------------------------------------------------- @@ -310,8 +310,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- // Node list (left panel) — up to 5 closest nodes. // - // Each row: stable marker symbol | short name | distance (right-aligned). - // The symbol matches the dot on the radar so the user can identify nodes. + // Each row: marker symbol (matches the radar dot) | short name | distance. // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); @@ -323,7 +322,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int symCX = x + 3; const int symCY = rowY + rowPitch / 2; - drawMarker(display, symCX, symCY, nodeMarkerIndex(e.node->num)); + drawMarker(display, symCX, symCY, (uint8_t)i); char name[10] = ""; if (e.node->has_user && e.node->user.short_name[0]) diff --git a/src/mesh/generated/meshtastic/device_ui.pb.h b/src/mesh/generated/meshtastic/device_ui.pb.h index 194132716e4..88331312e09 100644 --- a/src/mesh/generated/meshtastic/device_ui.pb.h +++ b/src/mesh/generated/meshtastic/device_ui.pb.h @@ -195,6 +195,8 @@ typedef struct _meshtastic_DeviceUIConfig { meshtastic_DeviceUIConfig_GpsCoordinateFormat gps_format; /* Show radar overlay on the compass/position screen instead of GPS text. */ bool radar_mode; + /* When true, the radar overlay only plots favorite nodes. */ + bool radar_favorites_only; } meshtastic_DeviceUIConfig; @@ -279,6 +281,8 @@ extern "C" { #define meshtastic_DeviceUIConfig_screen_rgb_color_tag 17 #define meshtastic_DeviceUIConfig_is_clockface_analog_tag 18 #define meshtastic_DeviceUIConfig_gps_format_tag 19 +#define meshtastic_DeviceUIConfig_radar_mode_tag 20 +#define meshtastic_DeviceUIConfig_radar_favorites_only_tag 21 /* Struct field encoding specification for nanopb */ #define meshtastic_DeviceUIConfig_FIELDLIST(X, a) \ @@ -301,7 +305,8 @@ X(a, STATIC, SINGULAR, UENUM, compass_mode, 16) \ X(a, STATIC, SINGULAR, UINT32, screen_rgb_color, 17) \ X(a, STATIC, SINGULAR, BOOL, is_clockface_analog, 18) \ X(a, STATIC, SINGULAR, UENUM, gps_format, 19) \ -X(a, STATIC, SINGULAR, BOOL, radar_mode, 20) +X(a, STATIC, SINGULAR, BOOL, radar_mode, 20) \ +X(a, STATIC, SINGULAR, BOOL, radar_favorites_only, 21) #define meshtastic_DeviceUIConfig_CALLBACK NULL #define meshtastic_DeviceUIConfig_DEFAULT NULL #define meshtastic_DeviceUIConfig_node_filter_MSGTYPE meshtastic_NodeFilter From 6542ea700251bf375b19db29f2e933a4c9bbb879 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Mon, 11 May 2026 23:46:51 +0200 Subject: [PATCH 13/31] refactor(radar): swap radar onto bearings frame, rename to Tracking View MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: the position screen is about "where am I" — unrelated to tracking other nodes — so radar conceptually belongs in the same slot as the bearings/distance frame, which is already a tracking view. Moving it there also avoids growing the global frame cycle. Changes: • uiconfig field renamed: radar_mode → bearings_view_radar (proto tag 20 unchanged, so persisted preferences continue to deserialize). The flag now controls drawDynamicListScreen_Location instead of drawCompassAndLocationScreen. • drawCompassAndLocationScreen reverts to pure position display — no more radar branch. • drawDynamicListScreen_Location renders the radar overlay when bearings_view_radar is true, otherwise the existing bearings/ distance cycle. • Long-press input routing updated: the GPS frame always opens positionBaseMenu (no more radar branch); the nodelist_location frame opens radarBearingsMenu when in radar mode, else nodeListMenu. • Renamed menu surface: "Compass Face" → "Tracking View" picker, with options "Bearings" and "Radar". Lives in nodeListMenu (so users can switch from any node list) and radarBearingsMenu (long-press while on the radar). Removed from positionBaseMenu. • Renamed: radarPositionMenu → radarBearingsMenu, compassFacePicker → trackingViewPicker, CompassFacePicker enum → TrackingViewPicker. --- src/graphics/Screen.cpp | 12 +-- src/graphics/draw/MenuHandler.cpp | 80 ++++++++++---------- src/graphics/draw/MenuHandler.h | 12 +-- src/graphics/draw/NodeListRenderer.cpp | 12 +++ src/graphics/draw/RadarRenderer.cpp | 4 +- src/graphics/draw/RadarRenderer.h | 13 ++-- src/graphics/draw/UIRenderer.cpp | 11 --- src/mesh/generated/meshtastic/device_ui.pb.h | 8 +- 8 files changed, 78 insertions(+), 74 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8285278936b..66665da1ea9 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1839,11 +1839,7 @@ int Screen::handleInputEvent(const InputEvent *event) menuHandler::systemBaseMenu(); #if HAS_GPS } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.gps && gps) { - if (uiconfig.radar_mode) { - menuHandler::radarPositionMenu(); - } else { - menuHandler::positionBaseMenu(); - } + menuHandler::positionBaseMenu(); #endif } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.clock) { menuHandler::clockMenu(); @@ -1863,6 +1859,12 @@ int Screen::handleInputEvent(const InputEvent *event) this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { menuHandler::favoriteBaseMenu(); + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location && + uiconfig.bearings_view_radar) { + // Bearings/distance frame is being drawn as a radar — use the + // radar-specific options menu (zoom, heading, favorites filter, + // Tracking View picker). + menuHandler::radarBearingsMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_nodes || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_location || this->ui->getUiState()->currentFrame == framesetInfo.positions.nodelist_lastheard || diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 6dcd4c239ce..dbd440aa488 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -455,34 +455,35 @@ void menuHandler::showConfirmationBanner(const char *message, std::functionshowOverlayBanner(confirmBanner); } -// Where compassFacePicker should return when the user picks "Back". Set by -// the calling menu (PositionBaseMenu or the radar-mode banner) just before -// queueing the picker, so back behaves like the Clock Face → Clock Menu flow. -static menuHandler::screenMenus s_compassFacePickerReturn = menuHandler::MenuNone; +// Where trackingViewPicker should return when the user picks "Back". Set by +// the calling menu (nodeListMenu or radarBearingsMenu) just before queueing +// the picker, so back behaves like the Clock Face → Clock Menu flow. +static menuHandler::screenMenus s_trackingViewPickerReturn = menuHandler::MenuNone; -void menuHandler::setCompassFacePickerReturn(screenMenus target) +void menuHandler::setTrackingViewPickerReturn(screenMenus target) { - s_compassFacePickerReturn = target; + s_trackingViewPickerReturn = target; } -void menuHandler::compassFacePicker() +void menuHandler::trackingViewPicker() { - // Mirrors clockFacePicker: pick between the standard Compass screen and the - // Radar overlay. Stored in uiconfig.radar_mode (false=Compass, true=Radar). - static const ClockFaceOption compassFaceOptions[] = { + // Mirrors clockFacePicker: pick which view the bearings/distance frame + // shows. Stored in uiconfig.bearings_view_radar (false=Bearings list, + // true=Radar overlay). + static const ClockFaceOption trackingOptions[] = { {"Back", OptionsAction::Back}, - {"Compass", OptionsAction::Select, false}, + {"Bearings", OptionsAction::Select, false}, {"Radar", OptionsAction::Select, true}, }; - constexpr size_t compassFaceCount = sizeof(compassFaceOptions) / sizeof(compassFaceOptions[0]); - static std::array compassFaceLabels{}; + constexpr size_t trackingCount = sizeof(trackingOptions) / sizeof(trackingOptions[0]); + static std::array trackingLabels{}; - auto bannerOptions = createStaticBannerOptions("Which Face?", compassFaceOptions, compassFaceLabels, + auto bannerOptions = createStaticBannerOptions("Tracking View?", trackingOptions, trackingLabels, [](const ClockFaceOption &option, int) -> void { if (option.action == OptionsAction::Back) { - menuHandler::menuQueue = s_compassFacePickerReturn; - if (s_compassFacePickerReturn != MenuNone) + menuHandler::menuQueue = s_trackingViewPickerReturn; + if (s_trackingViewPickerReturn != MenuNone) screen->runNow(); return; } @@ -491,17 +492,17 @@ void menuHandler::compassFacePicker() return; } - if (uiconfig.radar_mode == option.value) { + if (uiconfig.bearings_view_radar == option.value) { return; } - uiconfig.radar_mode = option.value; + uiconfig.bearings_view_radar = option.value; menuHandler::saveUIConfig(); screen->setFrames(Screen::FOCUS_PRESERVE); screen->runNow(); }); - bannerOptions.InitialSelected = uiconfig.radar_mode ? 2 : 1; + bannerOptions.InitialSelected = uiconfig.bearings_view_radar ? 2 : 1; screen->showOverlayBanner(bannerOptions); } @@ -1300,12 +1301,10 @@ void menuHandler::positionBaseMenu() GPSSmartPosition, GPSUpdateInterval, GPSPositionBroadcast, - CompassFace, }; static const PositionMenuOption baseOptions[] = { {"Back", OptionsAction::Back}, - {"Compass Face", OptionsAction::Select, static_cast(PositionAction::CompassFace)}, {"On/Off Toggle", OptionsAction::Select, static_cast(PositionAction::GpsToggle)}, {"Format", OptionsAction::Select, static_cast(PositionAction::GpsFormat)}, {"Smart Position", OptionsAction::Select, static_cast(PositionAction::GPSSmartPosition)}, @@ -1316,7 +1315,6 @@ void menuHandler::positionBaseMenu() static const PositionMenuOption calibrateOptions[] = { {"Back", OptionsAction::Back}, - {"Compass Face", OptionsAction::Select, static_cast(PositionAction::CompassFace)}, {"On/Off Toggle", OptionsAction::Select, static_cast(PositionAction::GpsToggle)}, {"Format", OptionsAction::Select, static_cast(PositionAction::GpsFormat)}, {"Smart Position", OptionsAction::Select, static_cast(PositionAction::GPSSmartPosition)}, @@ -1375,11 +1373,6 @@ void menuHandler::positionBaseMenu() menuQueue = GpsPositionBroadcastMenu; screen->runNow(); break; - case PositionAction::CompassFace: - setCompassFacePickerReturn(PositionBaseMenu); - menuQueue = CompassFacePicker; - screen->runNow(); - break; } }; @@ -1397,18 +1390,18 @@ void menuHandler::positionBaseMenu() screen->showOverlayBanner(bannerOptions); } -void menuHandler::radarPositionMenu() +void menuHandler::radarBearingsMenu() { - enum optionsNumbers { Back, CompassFace, ToggleHeading, ToggleFavorites, ZoomIn, ZoomOut }; + enum optionsNumbers { Back, TrackingView, ToggleHeading, ToggleFavorites, ZoomIn, ZoomOut }; static const char *optionsArray[] = { "Back", - "Compass Face", + "Tracking View", nullptr, // ToggleHeading — filled dynamically below nullptr, // ToggleFavorites — filled dynamically below "Zoom In", "Zoom Out", }; - static int optionsEnumArray[] = {Back, CompassFace, ToggleHeading, ToggleFavorites, ZoomIn, ZoomOut}; + static int optionsEnumArray[] = {Back, TrackingView, ToggleHeading, ToggleFavorites, ZoomIn, ZoomOut}; optionsArray[ToggleHeading] = graphics::RadarRenderer::isNorthUp() ? "Switch to HDG-UP" : "Switch to N-UP"; optionsArray[ToggleFavorites] = uiconfig.radar_favorites_only ? "Show: All Nodes" : "Show: Favorites Only"; @@ -1422,11 +1415,11 @@ void menuHandler::radarPositionMenu() bannerOptions.bannerCallback = [](int selected) -> void { if (selected == Back) { screen->setFrames(Screen::FOCUS_PRESERVE); - } else if (selected == CompassFace) { - // Radar menu has no own enum (invoked directly from input handler), - // so back from the picker just dismisses to the screen. - setCompassFacePickerReturn(MenuNone); - menuQueue = CompassFacePicker; + } else if (selected == TrackingView) { + // Radar bearings menu has no enum value (invoked directly from input + // handler), so back from the picker just dismisses to the screen. + setTrackingViewPickerReturn(MenuNone); + menuQueue = TrackingViewPicker; screen->runNow(); } else if (selected == ToggleHeading) { graphics::RadarRenderer::toggleNorthUp(); @@ -1452,7 +1445,7 @@ void menuHandler::radarPositionMenu() void menuHandler::nodeListMenu() { - enum optionsNumbers { Back, NodePicker, TraceRoute, Verify, Reset, NodeNameLength, enumEnd }; + enum optionsNumbers { Back, NodePicker, TrackingView, TraceRoute, Verify, Reset, NodeNameLength, enumEnd }; static const char *optionsArray[enumEnd] = {"Back"}; static int optionsEnumArray[enumEnd] = {Back}; int options = 1; @@ -1460,6 +1453,11 @@ void menuHandler::nodeListMenu() optionsArray[options] = "Node Actions / Settings"; optionsEnumArray[options++] = NodePicker; +#if HAS_GPS + optionsArray[options] = "Tracking View"; + optionsEnumArray[options++] = TrackingView; +#endif + if (currentResolution != ScreenResolution::UltraLow) { optionsArray[options] = "Show Long/Short Name"; optionsEnumArray[options++] = NodeNameLength; @@ -1476,6 +1474,10 @@ void menuHandler::nodeListMenu() if (selected == NodePicker) { menuQueue = NodePickerMenu; screen->runNow(); + } else if (selected == TrackingView) { + setTrackingViewPickerReturn(NodeBaseMenu); + menuQueue = TrackingViewPicker; + screen->runNow(); } else if (selected == Reset) { menuQueue = ResetNodeDbMenu; screen->runNow(); @@ -2786,8 +2788,8 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case ClockMenu: clockMenu(); break; - case CompassFacePicker: - compassFacePicker(); + case TrackingViewPicker: + trackingViewPicker(); break; case SystemBaseMenu: systemBaseMenu(); diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index a1abecb4723..506ea4b993f 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -19,7 +19,7 @@ class menuHandler TwelveHourPicker, ClockFacePicker, ClockMenu, - CompassFacePicker, + TrackingViewPicker, PositionBaseMenu, NodeBaseMenu, GpsToggleMenu, @@ -73,11 +73,11 @@ class menuHandler static void TZPicker(); static void twelveHourPicker(); static void clockFacePicker(); - static void compassFacePicker(); - // Set the menu to re-open when the user picks "Back" from compassFacePicker. - // Caller invokes this immediately before queueing CompassFacePicker so the + static void trackingViewPicker(); + // Set the menu to re-open when the user picks "Back" from trackingViewPicker. + // Caller invokes this immediately before queueing TrackingViewPicker so the // picker can return to its actual parent menu (or MenuNone to dismiss). - static void setCompassFacePickerReturn(screenMenus target); + static void setTrackingViewPickerReturn(screenMenus target); static void messageResponseMenu(); static void messageViewModeMenu(); static void replyMenu(); @@ -96,7 +96,7 @@ class menuHandler static void BuzzerModeMenu(); static void switchToMUIMenu(); static void nodeListMenu(); - static void radarPositionMenu(); + static void radarBearingsMenu(); static void resetNodeDBMenu(); static void BrightnessPickerMenu(); static void rebootMenu(); diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index d0b027c1356..100f28d3b2e 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -3,6 +3,7 @@ #include "CompassRenderer.h" #include "NodeDB.h" #include "NodeListRenderer.h" +#include "RadarRenderer.h" #if !MESHTASTIC_EXCLUDE_STATUS #include "modules/StatusMessageModule.h" #endif @@ -831,6 +832,17 @@ void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *st lastSwitchTime = now; } #endif + + // === Radar overlay mode === + // When bearings_view_radar is enabled (toggled via long-press menu → + // Tracking View), the bearings/distance frame is replaced by the + // circular radar minimap. + if (uiconfig.bearings_view_radar) { + graphics::drawCommonHeader(display, x, y, "Radar"); + graphics::RadarRenderer::drawRadarOverlay(display, x, y); + graphics::drawCommonFooter(display, x, y); + return; + } // On very first call (on boot or state enter) if (lastRenderedMode == MODE_COUNT_LOCATION) { currentMode_Location = MODE_DISTANCE; diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 1185676bc3f..c87a6ed3a55 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -18,7 +18,7 @@ namespace RadarRenderer { // --------------------------------------------------------------------------- -// Runtime state (toggled by radarPositionMenu) +// Runtime state (toggled by radarBearingsMenu) // --------------------------------------------------------------------------- static bool s_forceNorthUp = false; // override IMU → fixed north-up @@ -160,7 +160,7 @@ static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bea * - Right side: circular radar with 2 px padding on all sides * - Left side: node list (up to 4 closest nodes, marker + name + distance) * - * Called from UIRenderer::drawCompassAndLocationScreen when uiconfig.radar_mode + * Called from NodeListRenderer::drawDynamicListScreen_Location when uiconfig.bearings_view_radar * is true. The caller draws the header and footer; this function handles the * content area only. */ diff --git a/src/graphics/draw/RadarRenderer.h b/src/graphics/draw/RadarRenderer.h index e0978126573..482300fd4c4 100644 --- a/src/graphics/draw/RadarRenderer.h +++ b/src/graphics/draw/RadarRenderer.h @@ -10,12 +10,11 @@ namespace graphics class Screen; /** - * @brief Radar overlay for the compass/position screen. + * @brief Radar overlay shown in place of the bearings/distance frame. * - * Draws a node list on the left and a circular radar minimap on the right, - * replacing the GPS text shown in compass mode. The user's node sits at the - * centre; remote nodes with valid positions are plotted as small markers at - * their true bearing and proportional distance. + * Draws a node list on the left and a circular radar minimap on the right. + * The user's node sits at the centre; remote nodes with valid positions are + * plotted as small markers at their true bearing and proportional distance. * * When the BMX160 (RAK12034) is connected the radar is heading-up (the * direction the device faces is at the top). A "N" label rotates to show @@ -26,10 +25,10 @@ class Screen; namespace RadarRenderer { -// ---- Content-area renderer (called from drawCompassAndLocationScreen) ------- +// ---- Content-area renderer (called from drawDynamicListScreen_Location) ----- void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y); -// ---- Runtime state (controlled by radarPositionMenu) ------------------------ +// ---- Runtime state (controlled by radarBearingsMenu) ------------------------ /** Returns true when forced north-up is active (overriding IMU). */ bool isNorthUp(); diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index db0713bc825..7bb64c765a4 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -1,7 +1,6 @@ #include "configuration.h" #if HAS_SCREEN #include "CompassRenderer.h" -#include "RadarRenderer.h" #include "GPSStatus.h" #include "MeshService.h" #include "NodeDB.h" @@ -1557,16 +1556,6 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU { display->clear(); - // === Radar overlay mode === - // When radar_mode is enabled (toggled via long-press menu), replace the - // GPS text with a node list and draw a circular radar minimap on the right. - if (uiconfig.radar_mode) { - graphics::drawCommonHeader(display, x, y, "Radar"); - graphics::RadarRenderer::drawRadarOverlay(display, x, y); - graphics::drawCommonFooter(display, x, y); - return; - } - display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); int line = 1; diff --git a/src/mesh/generated/meshtastic/device_ui.pb.h b/src/mesh/generated/meshtastic/device_ui.pb.h index 88331312e09..9d6a050f158 100644 --- a/src/mesh/generated/meshtastic/device_ui.pb.h +++ b/src/mesh/generated/meshtastic/device_ui.pb.h @@ -193,8 +193,8 @@ typedef struct _meshtastic_DeviceUIConfig { bool is_clockface_analog; /* How the GPS coordinates are formatted on the OLED screen. */ meshtastic_DeviceUIConfig_GpsCoordinateFormat gps_format; - /* Show radar overlay on the compass/position screen instead of GPS text. */ - bool radar_mode; + /* When true, the bearings/distance frame is replaced by the radar overlay. */ + bool bearings_view_radar; /* When true, the radar overlay only plots favorite nodes. */ bool radar_favorites_only; } meshtastic_DeviceUIConfig; @@ -281,7 +281,7 @@ extern "C" { #define meshtastic_DeviceUIConfig_screen_rgb_color_tag 17 #define meshtastic_DeviceUIConfig_is_clockface_analog_tag 18 #define meshtastic_DeviceUIConfig_gps_format_tag 19 -#define meshtastic_DeviceUIConfig_radar_mode_tag 20 +#define meshtastic_DeviceUIConfig_bearings_view_radar_tag 20 #define meshtastic_DeviceUIConfig_radar_favorites_only_tag 21 /* Struct field encoding specification for nanopb */ @@ -305,7 +305,7 @@ X(a, STATIC, SINGULAR, UENUM, compass_mode, 16) \ X(a, STATIC, SINGULAR, UINT32, screen_rgb_color, 17) \ X(a, STATIC, SINGULAR, BOOL, is_clockface_analog, 18) \ X(a, STATIC, SINGULAR, UENUM, gps_format, 19) \ -X(a, STATIC, SINGULAR, BOOL, radar_mode, 20) \ +X(a, STATIC, SINGULAR, BOOL, bearings_view_radar, 20) \ X(a, STATIC, SINGULAR, BOOL, radar_favorites_only, 21) #define meshtastic_DeviceUIConfig_CALLBACK NULL #define meshtastic_DeviceUIConfig_DEFAULT NULL From 6daed6644008453c44875320a8da508454c71238 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 12 May 2026 09:38:30 +0200 Subject: [PATCH 14/31] feat(radar): move scale label to header title Drop the in-circle scale label that overlapped the inner ring and was hard to read against the chrome. RadarRenderer now owns the header so the title can carry the current outer-ring range as 'Radar Nkm'. NodeListRenderer no longer draws the header for the radar branch. --- src/graphics/draw/NodeListRenderer.cpp | 1 - src/graphics/draw/RadarRenderer.cpp | 42 ++++++++++++++------------ src/graphics/draw/RadarRenderer.h | 4 ++- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 100f28d3b2e..eddd7cf12a5 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -838,7 +838,6 @@ void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *st // Tracking View), the bearings/distance frame is replaced by the // circular radar minimap. if (uiconfig.bearings_view_radar) { - graphics::drawCommonHeader(display, x, y, "Radar"); graphics::RadarRenderer::drawRadarOverlay(display, x, y); graphics::drawCommonFooter(display, x, y); return; diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index c87a6ed3a55..9220ac11e20 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -153,16 +153,17 @@ static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bea // --------------------------------------------------------------------------- /** - * Draw the radar overlay into the content area of the compass/position screen. + * Draw the radar overlay (header + content) for the compass/position screen. * * Layout (128×64 OLED example): - * - Header row already drawn by the caller (FONT_HEIGHT_SMALL - 1 px) + * - Header row: "Radar " — drawn here so the title can include the + * current outer-ring range * - Right side: circular radar with 2 px padding on all sides - * - Left side: node list (up to 4 closest nodes, marker + name + distance) + * - Left side: node list (up to 5 closest nodes, marker + name + distance) * - * Called from NodeListRenderer::drawDynamicListScreen_Location when uiconfig.bearings_view_radar - * is true. The caller draws the header and footer; this function handles the - * content area only. + * Called from NodeListRenderer::drawDynamicListScreen_Location when + * uiconfig.bearings_view_radar is true. The caller draws the footer; this + * function owns the header and content area. */ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) { @@ -184,10 +185,12 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int listRight = radarCX - radarRadius - 4; // 4 px gap between list and circle // ----------------------------------------------------------------------- - // GPS — bail gracefully if unavailable. + // GPS — bail gracefully if unavailable. No fix → no scale to report, + // so the header stays plain. // ----------------------------------------------------------------------- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (!ourNode || !nodeDB->hasValidPosition(ourNode)) { + graphics::drawCommonHeader(display, x, y, "Radar"); display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_CENTER); display->drawString(x + sw / 2, y + sh / 2 - FONT_HEIGHT_SMALL / 2, "No GPS fix"); @@ -259,6 +262,19 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const float scale = niceScaleMeters(maxDistM, s_zoomLevel); + // ----------------------------------------------------------------------- + // Header — "Radar ", drawn now that we know the outer-ring range. + // Keeps the scale legible in the title bar instead of overlapping the + // inner ring. + // ----------------------------------------------------------------------- + { + char scaleBuf[12] = ""; + formatDistM(scaleBuf, sizeof(scaleBuf), scale); + char titleBuf[24]; + snprintf(titleBuf, sizeof(titleBuf), "Radar %s", scaleBuf); + graphics::drawCommonHeader(display, x, y, titleBuf); + } + // ----------------------------------------------------------------------- // Draw radar chrome: three concentric range rings. // ----------------------------------------------------------------------- @@ -295,18 +311,6 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) std::min(e.distM / scale, 1.0f), (uint8_t)i); } - // ----------------------------------------------------------------------- - // Scale label — outer-ring distance, drawn just inside the bottom of the - // radar circle so the user knows the current range at this zoom level. - // ----------------------------------------------------------------------- - { - char scaleBuf[12] = ""; - formatDistM(scaleBuf, sizeof(scaleBuf), scale); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(radarCX, radarCY + radarRadius - FONT_HEIGHT_SMALL - 1, scaleBuf); - } - // ----------------------------------------------------------------------- // Node list (left panel) — up to 5 closest nodes. // diff --git a/src/graphics/draw/RadarRenderer.h b/src/graphics/draw/RadarRenderer.h index 482300fd4c4..db45747fca3 100644 --- a/src/graphics/draw/RadarRenderer.h +++ b/src/graphics/draw/RadarRenderer.h @@ -25,7 +25,9 @@ class Screen; namespace RadarRenderer { -// ---- Content-area renderer (called from drawDynamicListScreen_Location) ----- +// ---- Header + content renderer (called from drawDynamicListScreen_Location). +// Draws its own header ("Radar ") so the title can carry the current +// outer-ring range; the caller still draws the footer. void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y); // ---- Runtime state (controlled by radarBearingsMenu) ------------------------ From 1cfd24f6a64f1c7aaa88478373b9f27eb0605c8d Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 12 May 2026 11:15:19 +0200 Subject: [PATCH 15/31] fix(radar): adapt to flattened NodeInfoLite schema Upstream develop dropped the nested user/position structs on NodeInfoLite. Position now lives in a side table accessed via copyNodePosition(), and user fields (short_name, long_name) plus the has_user / is_favorite flags moved to flat fields and bitfield-backed accessor helpers (nodeInfoLiteHasUser, nodeInfoLiteIsFavorite). --- src/graphics/draw/RadarRenderer.cpp | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 9220ac11e20..30ed4766832 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -189,7 +189,8 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // so the header stays plain. // ----------------------------------------------------------------------- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - if (!ourNode || !nodeDB->hasValidPosition(ourNode)) { + meshtastic_PositionLite ourPos; + if (!ourNode || !nodeDB->hasValidPosition(ourNode) || !nodeDB->copyNodePosition(ourNode->num, ourPos)) { graphics::drawCommonHeader(display, x, y, "Radar"); display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_CENTER); @@ -197,8 +198,8 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) return; } - const double myLat = ourNode->position.latitude_i * 1e-7; - const double myLon = ourNode->position.longitude_i * 1e-7; + const double myLat = ourPos.latitude_i * 1e-7; + const double myLon = ourPos.longitude_i * 1e-7; // ----------------------------------------------------------------------- // Heading. @@ -233,13 +234,14 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); if (!n || n->num == nodeDB->getNodeNum()) continue; - if (favoritesOnly && !n->is_favorite) + if (favoritesOnly && !nodeInfoLiteIsFavorite(n)) continue; - if (!nodeDB->hasValidPosition(n)) + meshtastic_PositionLite nodePos; + if (!nodeDB->hasValidPosition(n) || !nodeDB->copyNodePosition(n->num, nodePos)) continue; - const double nodeLat = n->position.latitude_i * 1e-7; - const double nodeLon = n->position.longitude_i * 1e-7; + const double nodeLat = nodePos.latitude_i * 1e-7; + const double nodeLon = nodePos.longitude_i * 1e-7; const float dist = GeoCoord::latLongToMeter(myLat, myLon, nodeLat, nodeLon); const float brg = GeoCoord::bearing(myLat, myLon, nodeLat, nodeLon); @@ -329,8 +331,8 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) drawMarker(display, symCX, symCY, (uint8_t)i); char name[10] = ""; - if (e.node->has_user && e.node->user.short_name[0]) - strncpy(name, e.node->user.short_name, sizeof(name) - 1); + if (nodeInfoLiteHasUser(e.node) && e.node->short_name[0]) + strncpy(name, e.node->short_name, sizeof(name) - 1); else snprintf(name, sizeof(name), "%04X", (uint16_t)(e.node->num & 0xFFFF)); From df0af7670d906cf98821af829e73870e1e598746 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 12:16:02 +0200 Subject: [PATCH 16/31] fix(radar): reserve footer space so 5th list row clears BT link icon drawCommonFooter() paints a black bar across the full width of the screen whenever the API is connected (BLE/Wi-Fi/Serial/etc.), which clipped the bottom of the last node-list row and the bottom of the radar circle. Subtract the matching footer height from contentH before computing the radar diameter and the row pitch. The reservation is conditional on isAPIConnected and scales with currentResolution to match the actual footer geometry on both small OLEDs (7 px) and high-res TFTs (14 px). Also consolidate the redundant hasValidPosition() + copyNodePosition() pair into a single side-table lookup with an inline lat/lon check. Both calls held the satelliteMutex independently, so the previous code acquired and released the lock twice per node per frame; collapsing them halves the contention on the hot render loop. --- src/graphics/draw/RadarRenderer.cpp | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 30ed4766832..7c96c665d33 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -1,11 +1,13 @@ #include "configuration.h" #if HAS_SCREEN #include "RadarRenderer.h" +#include "MeshService.h" #include "NodeDB.h" #include "UIRenderer.h" #include "gps/GeoCoord.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" #include #include #include @@ -170,7 +172,18 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int headerH = FONT_HEIGHT_SMALL - 1; const int sw = SCREEN_WIDTH; const int sh = SCREEN_HEIGHT; - const int contentH = sh - headerH; + + // Reserve space at the bottom for the BT/API connection icon footer. + // drawCommonFooter() paints a black bar across the full width when the API + // is connected, which would otherwise clip the last list row and the + // bottom of the radar circle. Matches the footer height computed in + // SharedUIDisplay::drawCommonFooter. + const int footerScale = (currentResolution == ScreenResolution::High) ? 2 : 1; + const int footerH = isAPIConnected(service ? service->api_state : 0) + ? (connection_icon_height * footerScale) + (2 * footerScale) + : 0; + + const int contentH = sh - headerH - footerH; const int pad = 2; // px padding around the radar circle // ----------------------------------------------------------------------- @@ -190,7 +203,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); meshtastic_PositionLite ourPos; - if (!ourNode || !nodeDB->hasValidPosition(ourNode) || !nodeDB->copyNodePosition(ourNode->num, ourPos)) { + if (!ourNode || !nodeDB->copyNodePosition(ourNode->num, ourPos) || (ourPos.latitude_i == 0 && ourPos.longitude_i == 0)) { graphics::drawCommonHeader(display, x, y, "Radar"); display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_CENTER); @@ -237,7 +250,9 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) if (favoritesOnly && !nodeInfoLiteIsFavorite(n)) continue; meshtastic_PositionLite nodePos; - if (!nodeDB->hasValidPosition(n) || !nodeDB->copyNodePosition(n->num, nodePos)) + if (!nodeDB->copyNodePosition(n->num, nodePos)) + continue; + if (nodePos.latitude_i == 0 && nodePos.longitude_i == 0) continue; const double nodeLat = nodePos.latitude_i * 1e-7; From 7ec9d1ccc8a22722c289c6ce2a4a92f86b931c16 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 14:27:13 +0200 Subject: [PATCH 17/31] fix(radar): one consistent layout, 1 px gap above BT link icon Previously the radar overlay had two layouts: a tall one when the phone wasn't connected and a squeezed one once BLE/API connected and the link-icon footer appeared. Make the layout invariant by always reserving the icon height plus a single pixel of breathing room. Reservation is now connection_icon_height * scale + 1 (was connection_icon_height * scale + 2 * scale, gated on isAPIConnected), gaining one row pixel back so the radar circle and list rows match the pre-footer-fix density while still clearing the BT icon by 1 px. MeshService include dropped since we no longer branch on api_state. --- src/graphics/draw/RadarRenderer.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 7c96c665d33..30738fb3394 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -1,7 +1,6 @@ #include "configuration.h" #if HAS_SCREEN #include "RadarRenderer.h" -#include "MeshService.h" #include "NodeDB.h" #include "UIRenderer.h" #include "gps/GeoCoord.h" @@ -173,15 +172,12 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int sw = SCREEN_WIDTH; const int sh = SCREEN_HEIGHT; - // Reserve space at the bottom for the BT/API connection icon footer. - // drawCommonFooter() paints a black bar across the full width when the API - // is connected, which would otherwise clip the last list row and the - // bottom of the radar circle. Matches the footer height computed in - // SharedUIDisplay::drawCommonFooter. + // Always reserve space for the BT/API connection icon at the bottom so + // the layout is identical whether or not a phone is connected. The + // reservation is icon-height + 1 px, leaving exactly one pixel of + // breathing room between the content bottom and the icon top. const int footerScale = (currentResolution == ScreenResolution::High) ? 2 : 1; - const int footerH = isAPIConnected(service ? service->api_state : 0) - ? (connection_icon_height * footerScale) + (2 * footerScale) - : 0; + const int footerH = (connection_icon_height * footerScale) + 1; const int contentH = sh - headerH - footerH; const int pad = 2; // px padding around the radar circle From e68a7a3d281b9151bf29cd0d41a7d4321b4cea18 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 14:52:23 +0200 Subject: [PATCH 18/31] fix(radar): full-size circle + reserved list area so 5th row clears icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the content height into two: - contentH (= sh - headerH) drives the radar circle, restoring the original dense diameter that existed before any footer reservation. - listContentH (= contentH - listFooterH) drives the list row pitch so the 5th row's text + descender stays clear of the BT link icon. listFooterH matches the actual footer height drawn by SharedUIDisplay::drawCommonFooter (icon + 2 px, scaled), which left the previous 'icon + 1' reservation 2 px too short — the bottom row's descender was clipped when the API was connected. The radar circle's bottom arc may have a few pixels overlaid by the footer's black fill while the icon is shown, but visually it remains the same large circle in both states. The list rows have stable pitch independent of API state. --- src/graphics/draw/RadarRenderer.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 30738fb3394..2c116d0e300 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -172,15 +172,17 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int sw = SCREEN_WIDTH; const int sh = SCREEN_HEIGHT; - // Always reserve space for the BT/API connection icon at the bottom so - // the layout is identical whether or not a phone is connected. The - // reservation is icon-height + 1 px, leaving exactly one pixel of - // breathing room between the content bottom and the icon top. + // Single layout — the radar circle always uses the full height below the + // header (matches the dense layout from before any footer reservation + // existed) so its size doesn't shift when the BT/API icon appears. Only + // the list rows on the left reserve space, since they live in the same + // column as the icon and would otherwise be clipped at the bottom. const int footerScale = (currentResolution == ScreenResolution::High) ? 2 : 1; - const int footerH = (connection_icon_height * footerScale) + 1; + const int listFooterH = (connection_icon_height * footerScale) + 2 * footerScale; - const int contentH = sh - headerH - footerH; - const int pad = 2; // px padding around the radar circle + const int contentH = sh - headerH; // full-height area for the radar + const int listContentH = contentH - listFooterH; // shorter area for list rows + const int pad = 2; // px padding around the radar circle // ----------------------------------------------------------------------- // Radar circle — right side, 2 px padding on all sides. @@ -331,7 +333,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); - const int rowPitch = contentH / kMaxPlotted; + const int rowPitch = listContentH / kMaxPlotted; for (int i = 0; i < plottedCount; i++) { const Entry &e = entries[i]; From da026c3264128cc8429be2908ccb90f53529449b Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 15:32:10 +0200 Subject: [PATCH 19/31] fix(radar): tighten list to use descender gap, center markers vertically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim listFooterH from 'icon + 2*scale' to 'icon + 1' so the row pitch goes from 8 to 9. Most font glyphs don't fill the bottom 3 px of their bbox (that space is reserved for descenders), which was showing up as a 3-4 px void between the last row's visible text and the BT link icon. With the tighter reservation the visible ink of common non-descender chars lands flush against the icon; descender chars (g, j, p, q, y) in the bottom row will clip a pixel or two, accepted as the tradeoff for using the available space. Marker centring switched from 'rowPitch/2' to '(FONT_HEIGHT_SMALL-2)/2'. The previous formula scaled with row pitch and floored to rowY+4 at pitch 8/9 — which is the bbox upper third, reading as top-aligned. The new offset (~rowY+6) targets the bbox visual center, lining the marker up with the cap-height middle of the row text. --- src/graphics/draw/RadarRenderer.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 2c116d0e300..56f5fc84be7 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -176,9 +176,13 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // header (matches the dense layout from before any footer reservation // existed) so its size doesn't shift when the BT/API icon appears. Only // the list rows on the left reserve space, since they live in the same - // column as the icon and would otherwise be clipped at the bottom. + // column as the icon and would otherwise be clipped at the bottom. The + // reservation is icon-height + 1 px (the +1 leaves a single pixel of + // breathing room above the icon); most font glyphs don't fill the bottom + // of their bbox, so the last row's visible ink lands flush with the icon + // instead of leaving the previous 3-4 px of unused descender space. const int footerScale = (currentResolution == ScreenResolution::High) ? 2 : 1; - const int listFooterH = (connection_icon_height * footerScale) + 2 * footerScale; + const int listFooterH = (connection_icon_height * footerScale) + 1; const int contentH = sh - headerH; // full-height area for the radar const int listContentH = contentH - listFooterH; // shorter area for list rows @@ -335,11 +339,16 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int rowPitch = listContentH / kMaxPlotted; + // Marker centred to the visible text height (rowY is the top of the + // glyph bbox; centring on rowPitch/2 read as "top-aligned" because the + // font's bbox is taller than its visible ink). + const int symOffsetY = (FONT_HEIGHT_SMALL - 2) / 2; + for (int i = 0; i < plottedCount; i++) { const Entry &e = entries[i]; const int rowY = y + headerH + rowPitch * i; const int symCX = x + 3; - const int symCY = rowY + rowPitch / 2; + const int symCY = rowY + symOffsetY; drawMarker(display, symCX, symCY, (uint8_t)i); From 6c7e920178a3fcd09f32f485ea427058fcefae0f Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 15:45:38 +0200 Subject: [PATCH 20/31] fix(radar): preserve radar arc and last list row by skipping footer wipe drawCommonFooter paints a black bar across the FULL screen width before drawing the BT icon. The icon glyph itself only occupies x=0..4 at scale=1, but the wipe was erasing: - the bottom 3 px arc of the radar circle (x=80..126, far from the icon's actual footprint) - the bottom 1 px of the last list row's text (descender region) Refactor SharedUIDisplay so the icon draw is separable from the wipe: - new drawConnectionIcon() draws only the glyph - drawCommonFooter() = wipe + drawConnectionIcon() NodeListRenderer's radar branch now calls drawConnectionIcon directly, keeping the icon visible without erasing the surrounding pixels. The radar and list content don't spatially overlap the icon's footprint, so the underlying pixels are safe to keep. --- src/graphics/SharedUIDisplay.cpp | 25 ++++++++++++++++++++----- src/graphics/SharedUIDisplay.h | 5 +++++ src/graphics/draw/NodeListRenderer.cpp | 8 +++++++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index becd3e75d4a..fe926e588c8 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -553,14 +553,17 @@ const int *getTextPositions(OLEDDisplay *display) // ************************* // * Common Footer Drawing * // ************************* -void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) + +// Draw the BT/API connection icon glyph at the bottom-left, without any +// surrounding background wipe. Callers that want the canonical full-width +// black bar use drawCommonFooter; callers like RadarRenderer that need to +// preserve their own pixels under the icon's row call this directly. +void drawConnectionIcon(OLEDDisplay *display, int16_t x, int16_t y) { if (!isAPIConnected(service->api_state)) return; const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; - const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale); - const int footerH = (connection_icon_height * scale) + (2 * scale); const int iconX = 0; const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale); const int iconW = connection_icon_width * scale; @@ -571,8 +574,6 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH); #endif - display->setColor(BLACK); - display->fillRect(0, footerY, SCREEN_WIDTH, footerH); display->setColor(WHITE); if (currentResolution == ScreenResolution::High) { const int bytesPerRow = (connection_icon_width + 7) / 8; @@ -593,6 +594,20 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) } } +void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) +{ + if (!isAPIConnected(service->api_state)) + return; + + const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; + const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale); + const int footerH = (connection_icon_height * scale) + (2 * scale); + + display->setColor(BLACK); + display->fillRect(0, footerY, SCREEN_WIDTH, footerH); + drawConnectionIcon(display, x, y); +} + bool isAllowedPunctuation(char c) { switch (c) { diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index 95244d09902..70a182cd6ab 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -59,6 +59,11 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti // Shared battery/time/mail header void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y); +// Just the BT/API connection icon glyph, no background wipe. drawCommonFooter +// uses this after clearing the footer area; callers that want the icon without +// erasing surrounding pixels (e.g. the radar overlay) can call this directly. +void drawConnectionIcon(OLEDDisplay *display, int16_t x, int16_t y); + const int *getTextPositions(OLEDDisplay *display); bool isAllowedPunctuation(char c); diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index eddd7cf12a5..47f52d517f9 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -839,7 +839,13 @@ void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *st // circular radar minimap. if (uiconfig.bearings_view_radar) { graphics::RadarRenderer::drawRadarOverlay(display, x, y); - graphics::drawCommonFooter(display, x, y); + // Draw just the BT/API icon glyph without the full-width black wipe + // drawCommonFooter would normally do. The wipe was erasing the + // bottom arc of the radar circle and a pixel of the last list row; + // the icon's actual 5x5 footprint (x=0..4 at scale=1) doesn't + // spatially overlap the radar or list content, so leaving the + // surrounding pixels alone is safe. + graphics::drawConnectionIcon(display, x, y); return; } // On very first call (on boot or state enter) From 4f7449ba270cf1af9349a4db942ff3fe08695706 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 15:47:57 +0200 Subject: [PATCH 21/31] Revert "fix(radar): preserve radar arc and last list row by skipping footer wipe" This reverts commit 6c7e920178a3fcd09f32f485ea427058fcefae0f. --- src/graphics/SharedUIDisplay.cpp | 25 +++++-------------------- src/graphics/SharedUIDisplay.h | 5 ----- src/graphics/draw/NodeListRenderer.cpp | 8 +------- 3 files changed, 6 insertions(+), 32 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index fe926e588c8..becd3e75d4a 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -553,17 +553,14 @@ const int *getTextPositions(OLEDDisplay *display) // ************************* // * Common Footer Drawing * // ************************* - -// Draw the BT/API connection icon glyph at the bottom-left, without any -// surrounding background wipe. Callers that want the canonical full-width -// black bar use drawCommonFooter; callers like RadarRenderer that need to -// preserve their own pixels under the icon's row call this directly. -void drawConnectionIcon(OLEDDisplay *display, int16_t x, int16_t y) +void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) { if (!isAPIConnected(service->api_state)) return; const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; + const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale); + const int footerH = (connection_icon_height * scale) + (2 * scale); const int iconX = 0; const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale); const int iconW = connection_icon_width * scale; @@ -574,6 +571,8 @@ void drawConnectionIcon(OLEDDisplay *display, int16_t x, int16_t y) setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH); #endif + display->setColor(BLACK); + display->fillRect(0, footerY, SCREEN_WIDTH, footerH); display->setColor(WHITE); if (currentResolution == ScreenResolution::High) { const int bytesPerRow = (connection_icon_width + 7) / 8; @@ -594,20 +593,6 @@ void drawConnectionIcon(OLEDDisplay *display, int16_t x, int16_t y) } } -void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) -{ - if (!isAPIConnected(service->api_state)) - return; - - const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; - const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale); - const int footerH = (connection_icon_height * scale) + (2 * scale); - - display->setColor(BLACK); - display->fillRect(0, footerY, SCREEN_WIDTH, footerH); - drawConnectionIcon(display, x, y); -} - bool isAllowedPunctuation(char c) { switch (c) { diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index 70a182cd6ab..95244d09902 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -59,11 +59,6 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti // Shared battery/time/mail header void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y); -// Just the BT/API connection icon glyph, no background wipe. drawCommonFooter -// uses this after clearing the footer area; callers that want the icon without -// erasing surrounding pixels (e.g. the radar overlay) can call this directly. -void drawConnectionIcon(OLEDDisplay *display, int16_t x, int16_t y); - const int *getTextPositions(OLEDDisplay *display); bool isAllowedPunctuation(char c); diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 47f52d517f9..eddd7cf12a5 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -839,13 +839,7 @@ void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *st // circular radar minimap. if (uiconfig.bearings_view_radar) { graphics::RadarRenderer::drawRadarOverlay(display, x, y); - // Draw just the BT/API icon glyph without the full-width black wipe - // drawCommonFooter would normally do. The wipe was erasing the - // bottom arc of the radar circle and a pixel of the last list row; - // the icon's actual 5x5 footprint (x=0..4 at scale=1) doesn't - // spatially overlap the radar or list content, so leaving the - // surrounding pixels alone is safe. - graphics::drawConnectionIcon(display, x, y); + graphics::drawCommonFooter(display, x, y); return; } // On very first call (on boot or state enter) From a21b4a4201360b2738c04dfd039a81bda2cff969 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 15:49:52 +0200 Subject: [PATCH 22/31] fix(radar): draw BT/API icon inside the overlay to avoid the footer wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-do the previous fix without modifying SharedUIDisplay. The shared drawCommonFooter still does its full-width black wipe — that's the correct behaviour for every other screen — but the radar overlay now owns its own icon-drawing path: - New static drawConnectionIconNoWipe() in RadarRenderer renders just the 5x5 BT/API glyph at x=0..4 with no surrounding fill. Replicates the icon-rendering half of drawCommonFooter inline so radar can stay self-contained. - drawRadarOverlay calls it at the end, after radar + list have been drawn. The icon's footprint (x=0..4) doesn't spatially overlap the radar circle (x=80..126) or list text (x>=7), so leaving the rest of the bottom row untouched preserves the radar arc and the last list row's text. - NodeListRenderer's radar branch drops its drawCommonFooter call — radar handles the icon itself now. SharedUIDisplay.cpp/.h are untouched, so other view modes and menus keep their original footer behaviour. --- src/graphics/draw/NodeListRenderer.cpp | 4 ++- src/graphics/draw/RadarRenderer.cpp | 43 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index eddd7cf12a5..3c9575fc011 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -838,8 +838,10 @@ void drawDynamicListScreen_Location(OLEDDisplay *display, OLEDDisplayUiState *st // Tracking View), the bearings/distance frame is replaced by the // circular radar minimap. if (uiconfig.bearings_view_radar) { + // RadarRenderer draws its own BT/API icon at the end of the overlay + // (without the full-width black wipe drawCommonFooter performs), so + // the radar arc and last list row stay intact when BT is connected. graphics::RadarRenderer::drawRadarOverlay(display, x, y); - graphics::drawCommonFooter(display, x, y); return; } // On very first call (on boot or state enter) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 56f5fc84be7..05b461af9d5 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -1,6 +1,7 @@ #include "configuration.h" #if HAS_SCREEN #include "RadarRenderer.h" +#include "MeshService.h" #include "NodeDB.h" #include "UIRenderer.h" #include "gps/GeoCoord.h" @@ -149,6 +150,43 @@ static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bea drawMarker(display, px, py, markerIdx); } +/** + * Draw just the BT/API connection icon glyph at the bottom-left, without the + * full-width black wipe that drawCommonFooter performs. The wipe was erasing + * the radar circle's bottom arc and the descender of the last list row even + * though the icon's actual 5×5 footprint (x=0..4 at scale=1) doesn't overlap + * the radar (x≈80..126) or the list text (x≥7). + * + * Replicates the icon-rendering half of SharedUIDisplay::drawCommonFooter so + * this overlay can own its own footer behaviour without touching shared UI. + */ +static void drawConnectionIconNoWipe(OLEDDisplay *display) +{ + if (!isAPIConnected(service ? service->api_state : 0)) + return; + + const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; + const int iconX = 0; + const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale); + + display->setColor(WHITE); + if (currentResolution == ScreenResolution::High) { + const int bytesPerRow = (connection_icon_width + 7) / 8; + for (int yy = 0; yy < connection_icon_height; ++yy) { + const uint8_t *rowPtr = connection_icon + yy * bytesPerRow; + for (int xx = 0; xx < connection_icon_width; ++xx) { + const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3)); + const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first + if (byteVal & bitMask) { + display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale); + } + } + } + } else { + display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon); + } +} + // --------------------------------------------------------------------------- // Overlay renderer // --------------------------------------------------------------------------- @@ -367,6 +405,11 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) display->drawString(x + listRight, rowY, dist); display->setTextAlignment(TEXT_ALIGN_LEFT); } + + // BT/API connection icon — drawn here (no surrounding wipe) so the radar + // circle and the last list row stay intact. NodeListRenderer's radar + // branch deliberately skips drawCommonFooter for the same reason. + drawConnectionIconNoWipe(display); } } // namespace RadarRenderer From 60574c5f899d2738869ad1886238979e8c8cf461 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 21:11:58 +0200 Subject: [PATCH 23/31] fix(radar): make ourNode pointer const to satisfy cppcheck CI cppcheck flagged constVariablePointer on line 244: Variable 'ourNode' can be declared as pointer to const We never mutate the node through the pointer (only read num and pass to copyNodePosition), so the stricter declaration is correct. --- src/graphics/draw/RadarRenderer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 05b461af9d5..b0a46ac5e20 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -241,7 +241,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // GPS — bail gracefully if unavailable. No fix → no scale to report, // so the header stays plain. // ----------------------------------------------------------------------- - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); meshtastic_PositionLite ourPos; if (!ourNode || !nodeDB->copyNodePosition(ourNode->num, ourPos) || (ourPos.latitude_i == 0 && ourPos.longitude_i == 0)) { graphics::drawCommonHeader(display, x, y, "Radar"); From 79e626477afeb008abccbe2173556180e8fc4350 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Sun, 17 May 2026 21:16:01 +0200 Subject: [PATCH 24/31] style(radar): apply clang-format to fix trunk CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's Trunk Check Runner flagged two files as unformatted: src/graphics/draw/MenuHandler.cpp src/graphics/draw/RadarRenderer.cpp Ran clang-format (version 17.0.6, project config .trunk/configs/.clang-format). Diff is style-only — include reordering (alphabetical within group) and compacting a single static const array to one line under the 130-col limit. No semantic changes. --- src/graphics/draw/MenuHandler.cpp | 6 ++---- src/graphics/draw/RadarRenderer.cpp | 19 +++++++------------ 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index dbd440aa488..b179fa791ac 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -1394,12 +1394,10 @@ void menuHandler::radarBearingsMenu() { enum optionsNumbers { Back, TrackingView, ToggleHeading, ToggleFavorites, ZoomIn, ZoomOut }; static const char *optionsArray[] = { - "Back", - "Tracking View", + "Back", "Tracking View", nullptr, // ToggleHeading — filled dynamically below nullptr, // ToggleFavorites — filled dynamically below - "Zoom In", - "Zoom Out", + "Zoom In", "Zoom Out", }; static int optionsEnumArray[] = {Back, TrackingView, ToggleHeading, ToggleFavorites, ZoomIn, ZoomOut}; diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index b0a46ac5e20..5acabec9387 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -1,8 +1,8 @@ #include "configuration.h" #if HAS_SCREEN -#include "RadarRenderer.h" #include "MeshService.h" #include "NodeDB.h" +#include "RadarRenderer.h" #include "UIRenderer.h" #include "gps/GeoCoord.h" #include "graphics/ScreenFonts.h" @@ -59,11 +59,7 @@ void zoomOut() */ static float niceScaleMeters(float maxDistM, int zoomLevel) { - static const float scales[] = { - 30, 60, 90, 150, 300, 600, 900, - 1500, 3000, 6000, 9000, 15000, 30000, 90000, - 300000 - }; + static const float scales[] = {30, 60, 90, 150, 300, 600, 900, 1500, 3000, 6000, 9000, 15000, 30000, 90000, 300000}; constexpr int N = sizeof(scales) / sizeof(scales[0]); int idx = 0; @@ -222,9 +218,9 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int footerScale = (currentResolution == ScreenResolution::High) ? 2 : 1; const int listFooterH = (connection_icon_height * footerScale) + 1; - const int contentH = sh - headerH; // full-height area for the radar + const int contentH = sh - headerH; // full-height area for the radar const int listContentH = contentH - listFooterH; // shorter area for list rows - const int pad = 2; // px padding around the radar circle + const int pad = 2; // px padding around the radar circle // ----------------------------------------------------------------------- // Radar circle — right side, 2 px padding on all sides. @@ -266,8 +262,8 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- const bool imuAvailable = screen->hasHeading(); const bool headingUp = imuAvailable && !s_forceNorthUp; - const float headingRad = headingUp ? screen->getHeading() * DEG_TO_RAD - : (s_forceNorthUp ? 0.0f : screen->estimatedHeading(myLat, myLon)); + const float headingRad = + headingUp ? screen->getHeading() * DEG_TO_RAD : (s_forceNorthUp ? 0.0f : screen->estimatedHeading(myLat, myLon)); // ----------------------------------------------------------------------- // Collect remote nodes with valid positions. @@ -364,8 +360,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- for (int i = 0; i < plottedCount; i++) { const Entry &e = entries[i]; - plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, - std::min(e.distM / scale, 1.0f), (uint8_t)i); + plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, std::min(e.distM / scale, 1.0f), (uint8_t)i); } // ----------------------------------------------------------------------- From 02eadc12a8c7973d115f420d64772e2e3b71e9c8 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 2 Jun 2026 11:27:04 +0200 Subject: [PATCH 25/31] feat(radar): 10 nodes, ring labels, 8 tick marks, padding, new symbols --- src/graphics/draw/RadarRenderer.cpp | 70 +++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 5acabec9387..91c5aaa1498 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -127,12 +127,34 @@ static void drawMarker(OLEDDisplay *display, int px, int py, uint8_t sym) display->drawLine(px + 2, py + 2, px - 2, py + 2); display->drawLine(px - 2, py + 2, px - 2, py - 2); break; - default: // ◆ + case 4: // ◆ diamond display->drawLine(px, py - 2, px + 2, py); display->drawLine(px + 2, py, px, py + 2); display->drawLine(px, py + 2, px - 2, py); display->drawLine(px - 2, py, px, py - 2); break; + case 5: // △ triangle up + display->drawLine(px, py - 2, px + 2, py + 2); + display->drawLine(px, py - 2, px - 2, py + 2); + display->drawLine(px - 2, py + 2, px + 2, py + 2); + break; + case 6: // ▽ triangle down + display->drawLine(px, py + 2, px + 2, py - 2); + display->drawLine(px, py + 2, px - 2, py - 2); + display->drawLine(px - 2, py - 2, px + 2, py - 2); + break; + case 7: // — horizontal bar + display->drawLine(px - 2, py, px + 2, py); + break; + case 8: // ○ hollow circle + display->drawCircle(px, py, 2); + break; + default: // * asterisk (+ and × combined) + display->drawLine(px - 2, py, px + 2, py); + display->drawLine(px, py - 2, px, py + 2); + display->drawLine(px - 2, py - 2, px + 2, py + 2); + display->drawLine(px + 2, py - 2, px - 2, py + 2); + break; } } @@ -220,7 +242,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int contentH = sh - headerH; // full-height area for the radar const int listContentH = contentH - listFooterH; // shorter area for list rows - const int pad = 2; // px padding around the radar circle + const int pad = 4; // px padding around the radar circle // ----------------------------------------------------------------------- // Radar circle — right side, 2 px padding on all sides. @@ -305,7 +327,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // Auto-scale from only the nodes we will actually plot, so a single // far-away node can't push the scale into a high bucket and squash all // the close nodes into an invisible cluster at the centre. - constexpr int kMaxPlotted = 5; + constexpr int kMaxPlotted = 10; float maxDistM = 1.0f; const int plottedCount = std::min((int)entries.size(), kMaxPlotted); for (int i = 0; i < plottedCount; i++) { @@ -334,6 +356,38 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) for (int ring = 1; ring <= 3; ring++) display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); + // ----------------------------------------------------------------------- + // Distance labels on inner two rings (outer ring range is in the header). + // Fixed in screen space at the SE quadrant — no conflict with N label. + // ----------------------------------------------------------------------- + { + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + for (int ring = 1; ring <= 2; ring++) { + const int ringR = (radarRadius * ring) / 3; + char ringLabel[12]; + formatDistM(ringLabel, sizeof(ringLabel), scale * ring / 3.0f); + const int lx = radarCX + (int)(ringR * 0.707f) + 1; + const int ly = radarCY + (int)(ringR * 0.707f) - FONT_HEIGHT_SMALL; + display->drawString(lx, ly, ringLabel); + } + } + + // ----------------------------------------------------------------------- + // 8 tick marks at 45° intervals on the outer ring, rotating with heading. + // ----------------------------------------------------------------------- + { + constexpr int kTickLen = 4; + for (int t = 0; t < 8; t++) { + const float tickAngle = (t * static_cast(M_PI) * 0.25f) - headingRad; + const float sA = sinf(tickAngle); + const float cA = cosf(tickAngle); + display->drawLine(radarCX + (int)(radarRadius * sA), radarCY - (int)(radarRadius * cA), + radarCX + (int)((radarRadius - kTickLen) * sA), + radarCY - (int)((radarRadius - kTickLen) * cA)); + } + } + // ----------------------------------------------------------------------- // North indicator — rotates in heading-up mode. // ----------------------------------------------------------------------- @@ -353,9 +407,9 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- // Plot remote nodes — cap at kMaxPlotted to match the list panel. // - // Marker symbol is the sort-position index (0..4) so every plotted node + // Marker symbol is the sort-position index (0..9) so every plotted node // gets a unique shape and matches its row in the list panel. Using the - // node number modulo 5 caused symbol collisions when several plotted + // node number modulo N caused symbol collisions when several plotted // nodes shared a residue. // ----------------------------------------------------------------------- for (int i = 0; i < plottedCount; i++) { @@ -364,7 +418,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) } // ----------------------------------------------------------------------- - // Node list (left panel) — up to 5 closest nodes. + // Node list (left panel) — up to 10 closest nodes. // // Each row: marker symbol (matches the radar dot) | short name | distance. // ----------------------------------------------------------------------- @@ -380,7 +434,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) for (int i = 0; i < plottedCount; i++) { const Entry &e = entries[i]; const int rowY = y + headerH + rowPitch * i; - const int symCX = x + 3; + const int symCX = x + 6; // 4 px left margin + 2 px to marker centre const int symCY = rowY + symOffsetY; drawMarker(display, symCX, symCY, (uint8_t)i); @@ -395,7 +449,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) formatDistM(dist, sizeof(dist), e.distM); display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawString(x + 7, rowY, name); + display->drawString(x + 11, rowY, name); // 3 px gap after marker right edge display->setTextAlignment(TEXT_ALIGN_RIGHT); display->drawString(x + listRight, rowY, dist); display->setTextAlignment(TEXT_ALIGN_LEFT); From 5c1b578d117bb210b621f9870991420e8c3fffa9 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 2 Jun 2026 11:37:28 +0200 Subject: [PATCH 26/31] feat(radar): adapt node count and decorations to screen size --- src/graphics/draw/RadarRenderer.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 91c5aaa1498..3d6a2d14a77 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -327,7 +327,8 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // Auto-scale from only the nodes we will actually plot, so a single // far-away node can't push the scale into a high bucket and squash all // the close nodes into an invisible cluster at the centre. - constexpr int kMaxPlotted = 10; + const int minDim = std::min(sw, sh); + const int kMaxPlotted = (minDim >= 230) ? 10 : (minDim > 128) ? 8 : 5; float maxDistM = 1.0f; const int plottedCount = std::min((int)entries.size(), kMaxPlotted); for (int i = 0; i < plottedCount; i++) { @@ -357,10 +358,12 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); // ----------------------------------------------------------------------- - // Distance labels on inner two rings (outer ring range is in the header). - // Fixed in screen space at the SE quadrant — no conflict with N label. + // Ring labels and tick marks — only on high-res screens where there is + // enough pixel real estate to render them legibly. // ----------------------------------------------------------------------- - { + if (currentResolution == ScreenResolution::High) { + // Distance labels on inner two rings (outer ring range is in header). + // Fixed in screen space at the SE quadrant — no conflict with N label. display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); for (int ring = 1; ring <= 2; ring++) { @@ -371,12 +374,8 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int ly = radarCY + (int)(ringR * 0.707f) - FONT_HEIGHT_SMALL; display->drawString(lx, ly, ringLabel); } - } - // ----------------------------------------------------------------------- - // 8 tick marks at 45° intervals on the outer ring, rotating with heading. - // ----------------------------------------------------------------------- - { + // 8 tick marks at 45° intervals on the outer ring, rotating with heading. constexpr int kTickLen = 4; for (int t = 0; t < 8; t++) { const float tickAngle = (t * static_cast(M_PI) * 0.25f) - headingRad; From b002495d6ea17f761cc8a26ff4d1e00e0da5d1d4 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 2 Jun 2026 22:31:27 +0200 Subject: [PATCH 27/31] fix(radar): reposition N between rings 2-3, remove ticks, unitless ring labels, list top pad, adaptive radar padding --- src/graphics/draw/RadarRenderer.cpp | 61 +++++++++++++++++------------ 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 3d6a2d14a77..dfe6bab3d3d 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -92,6 +92,28 @@ static void formatDistM(char *buf, size_t len, float metres) } } +/** Format metres as a number only (no unit suffix) — used for radar ring labels. */ +static void formatDistNum(char *buf, size_t len, float metres) +{ + const bool imperial = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); + if (imperial) { + const float miles = metres / 1609.34f; + if (miles < 0.1f) + snprintf(buf, len, "%d", (int)(metres * 3.28084f)); + else if (miles < 10.0f) + snprintf(buf, len, "%.1f", miles); + else + snprintf(buf, len, "%d", (int)(miles + 0.5f)); + } else { + if (metres < 1000.0f) + snprintf(buf, len, "%d", (int)metres); + else if (metres < 10000.0f) + snprintf(buf, len, "%.1f", metres / 1000.0f); + else + snprintf(buf, len, "%d", (int)(metres / 1000.0f + 0.5f)); + } +} + // --------------------------------------------------------------------------- // Node marker shapes // --------------------------------------------------------------------------- @@ -242,7 +264,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) const int contentH = sh - headerH; // full-height area for the radar const int listContentH = contentH - listFooterH; // shorter area for list rows - const int pad = 4; // px padding around the radar circle + const int pad = (currentResolution == ScreenResolution::High) ? 9 : 4; // ----------------------------------------------------------------------- // Radar circle — right side, 2 px padding on all sides. @@ -358,43 +380,33 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); // ----------------------------------------------------------------------- - // Ring labels and tick marks — only on high-res screens where there is - // enough pixel real estate to render them legibly. + // Ring distance labels — high-res only; numbers only, no unit suffix, + // right-aligned to the SE point of each ring so they sit on the arc. // ----------------------------------------------------------------------- if (currentResolution == ScreenResolution::High) { - // Distance labels on inner two rings (outer ring range is in header). - // Fixed in screen space at the SE quadrant — no conflict with N label. display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setTextAlignment(TEXT_ALIGN_RIGHT); for (int ring = 1; ring <= 2; ring++) { const int ringR = (radarRadius * ring) / 3; char ringLabel[12]; - formatDistM(ringLabel, sizeof(ringLabel), scale * ring / 3.0f); - const int lx = radarCX + (int)(ringR * 0.707f) + 1; + formatDistNum(ringLabel, sizeof(ringLabel), scale * ring / 3.0f); + // Right edge of text at the SE arc point; bottom edge at the arc line. + const int lx = radarCX + (int)(ringR * 0.707f); const int ly = radarCY + (int)(ringR * 0.707f) - FONT_HEIGHT_SMALL; display->drawString(lx, ly, ringLabel); } - - // 8 tick marks at 45° intervals on the outer ring, rotating with heading. - constexpr int kTickLen = 4; - for (int t = 0; t < 8; t++) { - const float tickAngle = (t * static_cast(M_PI) * 0.25f) - headingRad; - const float sA = sinf(tickAngle); - const float cA = cosf(tickAngle); - display->drawLine(radarCX + (int)(radarRadius * sA), radarCY - (int)(radarRadius * cA), - radarCX + (int)((radarRadius - kTickLen) * sA), - radarCY - (int)((radarRadius - kTickLen) * cA)); - } } // ----------------------------------------------------------------------- // North indicator — rotates in heading-up mode. + // Positioned at 5/6 of outer radius: sits between ring 2 (2R/3) and + // ring 3 (R), closer to ring 3. // ----------------------------------------------------------------------- { - const int inset = FONT_HEIGHT_SMALL / 2 + 1; const float northBrg = -headingRad; - const int nx = radarCX + (int)((radarRadius - inset) * sinf(northBrg)); - const int ny = radarCY - (int)((radarRadius - inset) * cosf(northBrg)); + const int nRadius = radarRadius * 5 / 6; + const int nx = radarCX + (int)(nRadius * sinf(northBrg)); + const int ny = radarCY - (int)(nRadius * cosf(northBrg)); display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_CENTER); display->drawString(nx, ny - FONT_HEIGHT_SMALL / 2, "N"); @@ -423,7 +435,8 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); - const int rowPitch = listContentH / kMaxPlotted; + constexpr int kListTopPad = 2; + const int rowPitch = (listContentH - kListTopPad) / kMaxPlotted; // Marker centred to the visible text height (rowY is the top of the // glyph bbox; centring on rowPitch/2 read as "top-aligned" because the @@ -432,7 +445,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) for (int i = 0; i < plottedCount; i++) { const Entry &e = entries[i]; - const int rowY = y + headerH + rowPitch * i; + const int rowY = y + headerH + kListTopPad + rowPitch * i; const int symCX = x + 6; // 4 px left margin + 2 px to marker centre const int symCY = rowY + symOffsetY; From cde583c583f5067ea918dd3cb2f4f7f7e0dc002d Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 2 Jun 2026 23:18:05 +0200 Subject: [PATCH 28/31] fix(radar): 5px list top pad, smaller ring labels, 3 rings, N closer to outer ring --- src/graphics/draw/RadarRenderer.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index dfe6bab3d3d..4ff687d0383 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -381,30 +381,31 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- // Ring distance labels — high-res only; numbers only, no unit suffix, - // right-aligned to the SE point of each ring so they sit on the arc. + // smallest available font, right-aligned flush inside the SE arc point. + // All 3 rings labelled; the outer ring number echoes the header scale. // ----------------------------------------------------------------------- if (currentResolution == ScreenResolution::High) { - display->setFont(FONT_SMALL); + display->setFont(FONT_SMALL_LOCAL); display->setTextAlignment(TEXT_ALIGN_RIGHT); - for (int ring = 1; ring <= 2; ring++) { + constexpr int kRingFontH = _fontHeight(FONT_SMALL_LOCAL); + for (int ring = 1; ring <= 3; ring++) { const int ringR = (radarRadius * ring) / 3; char ringLabel[12]; formatDistNum(ringLabel, sizeof(ringLabel), scale * ring / 3.0f); - // Right edge of text at the SE arc point; bottom edge at the arc line. + // Right edge at SE x; bottom edge at SE y — text sits just inside the arc. const int lx = radarCX + (int)(ringR * 0.707f); - const int ly = radarCY + (int)(ringR * 0.707f) - FONT_HEIGHT_SMALL; + const int ly = radarCY + (int)(ringR * 0.707f) - kRingFontH; display->drawString(lx, ly, ringLabel); } } // ----------------------------------------------------------------------- // North indicator — rotates in heading-up mode. - // Positioned at 5/6 of outer radius: sits between ring 2 (2R/3) and - // ring 3 (R), closer to ring 3. + // Top edge of the N glyph just touches ring 3 from inside. // ----------------------------------------------------------------------- { const float northBrg = -headingRad; - const int nRadius = radarRadius * 5 / 6; + const int nRadius = radarRadius - FONT_HEIGHT_SMALL / 2; const int nx = radarCX + (int)(nRadius * sinf(northBrg)); const int ny = radarCY - (int)(nRadius * cosf(northBrg)); display->setFont(FONT_SMALL); @@ -435,7 +436,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- display->setFont(FONT_SMALL); - constexpr int kListTopPad = 2; + constexpr int kListTopPad = 5; const int rowPitch = (listContentH - kListTopPad) / kMaxPlotted; // Marker centred to the visible text height (rowY is the top of the From 7485ab5bdbc1cac514ffcaf8981a494ff8e31910 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 2 Jun 2026 23:39:48 +0200 Subject: [PATCH 29/31] fix(radar): use const not constexpr for runtime font height --- src/graphics/draw/RadarRenderer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 4ff687d0383..e4df3f4b676 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -387,7 +387,7 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) if (currentResolution == ScreenResolution::High) { display->setFont(FONT_SMALL_LOCAL); display->setTextAlignment(TEXT_ALIGN_RIGHT); - constexpr int kRingFontH = _fontHeight(FONT_SMALL_LOCAL); + const int kRingFontH = _fontHeight(FONT_SMALL_LOCAL); for (int ring = 1; ring <= 3; ring++) { const int ringR = (radarRadius * ring) / 3; char ringLabel[12]; From 525828ab23c9081f32eec26390b57191677e4574 Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Tue, 2 Jun 2026 23:50:38 +0200 Subject: [PATCH 30/31] fix(radar): place ring labels opposite N, rotate with heading --- src/graphics/draw/RadarRenderer.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index e4df3f4b676..75fec9a9daa 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -386,15 +386,16 @@ void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) // ----------------------------------------------------------------------- if (currentResolution == ScreenResolution::High) { display->setFont(FONT_SMALL_LOCAL); - display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->setTextAlignment(TEXT_ALIGN_CENTER); const int kRingFontH = _fontHeight(FONT_SMALL_LOCAL); + const float oppNBrg = -headingRad + static_cast(M_PI); // 180° from N for (int ring = 1; ring <= 3; ring++) { const int ringR = (radarRadius * ring) / 3; char ringLabel[12]; formatDistNum(ringLabel, sizeof(ringLabel), scale * ring / 3.0f); - // Right edge at SE x; bottom edge at SE y — text sits just inside the arc. - const int lx = radarCX + (int)(ringR * 0.707f); - const int ly = radarCY + (int)(ringR * 0.707f) - kRingFontH; + // Centred on the ring arc, opposite N — just inside the line. + const int lx = radarCX + (int)(ringR * sinf(oppNBrg)); + const int ly = radarCY - (int)(ringR * cosf(oppNBrg)) - kRingFontH; display->drawString(lx, ly, ringLabel); } } From 0b4a52259d8051acbd451783f36e9bfd8f2b6d6f Mon Sep 17 00:00:00 2001 From: Egor Siniaev Date: Wed, 3 Jun 2026 14:27:37 +0200 Subject: [PATCH 31/31] style(radar): apply clang-format --- src/graphics/draw/RadarRenderer.cpp | 751 ++++++++++++++-------------- 1 file changed, 375 insertions(+), 376 deletions(-) diff --git a/src/graphics/draw/RadarRenderer.cpp b/src/graphics/draw/RadarRenderer.cpp index 75fec9a9daa..4b6fb4e2682 100644 --- a/src/graphics/draw/RadarRenderer.cpp +++ b/src/graphics/draw/RadarRenderer.cpp @@ -14,10 +14,8 @@ extern graphics::Screen *screen; -namespace graphics -{ -namespace RadarRenderer -{ +namespace graphics { +namespace RadarRenderer { // --------------------------------------------------------------------------- // Runtime state (toggled by radarBearingsMenu) @@ -26,26 +24,18 @@ namespace RadarRenderer static bool s_forceNorthUp = false; // override IMU → fixed north-up static int s_zoomLevel = 0; // -2..+2, 0 = auto -bool isNorthUp() -{ - return s_forceNorthUp; -} +bool isNorthUp() { return s_forceNorthUp; } -void toggleNorthUp() -{ - s_forceNorthUp = !s_forceNorthUp; -} +void toggleNorthUp() { s_forceNorthUp = !s_forceNorthUp; } -void zoomIn() -{ - if (s_zoomLevel > -2) - s_zoomLevel--; +void zoomIn() { + if (s_zoomLevel > -2) + s_zoomLevel--; } -void zoomOut() -{ - if (s_zoomLevel < 2) - s_zoomLevel++; +void zoomOut() { + if (s_zoomLevel < 2) + s_zoomLevel++; } // --------------------------------------------------------------------------- @@ -57,61 +47,63 @@ void zoomOut() * then apply the zoom offset. All values are multiples of 3 so that * dividing by 3 (for ring labels) always yields whole numbers. */ -static float niceScaleMeters(float maxDistM, int zoomLevel) -{ - static const float scales[] = {30, 60, 90, 150, 300, 600, 900, 1500, 3000, 6000, 9000, 15000, 30000, 90000, 300000}; - constexpr int N = sizeof(scales) / sizeof(scales[0]); - - int idx = 0; - while (idx < N - 1 && maxDistM > scales[idx]) - idx++; - - idx = std::max(0, std::min(N - 1, idx + zoomLevel)); - return scales[idx]; +static float niceScaleMeters(float maxDistM, int zoomLevel) { + static const float scales[] = {30, 60, 90, 150, 300, + 600, 900, 1500, 3000, 6000, + 9000, 15000, 30000, 90000, 300000}; + constexpr int N = sizeof(scales) / sizeof(scales[0]); + + int idx = 0; + while (idx < N - 1 && maxDistM > scales[idx]) + idx++; + + idx = std::max(0, std::min(N - 1, idx + zoomLevel)); + return scales[idx]; } /** Format metres as a compact string (metric or imperial). */ -static void formatDistM(char *buf, size_t len, float metres) -{ - const bool imperial = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); - if (imperial) { - const float miles = metres / 1609.34f; - if (miles < 0.1f) - snprintf(buf, len, "%dft", (int)(metres * 3.28084f)); - else if (miles < 10.0f) - snprintf(buf, len, "%.1fmi", miles); - else - snprintf(buf, len, "%dmi", (int)(miles + 0.5f)); - } else { - if (metres < 1000.0f) - snprintf(buf, len, "%dm", (int)metres); - else if (metres < 10000.0f) - snprintf(buf, len, "%.1fkm", metres / 1000.0f); - else - snprintf(buf, len, "%dkm", (int)(metres / 1000.0f + 0.5f)); - } +static void formatDistM(char *buf, size_t len, float metres) { + const bool imperial = (config.display.units == + meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); + if (imperial) { + const float miles = metres / 1609.34f; + if (miles < 0.1f) + snprintf(buf, len, "%dft", (int)(metres * 3.28084f)); + else if (miles < 10.0f) + snprintf(buf, len, "%.1fmi", miles); + else + snprintf(buf, len, "%dmi", (int)(miles + 0.5f)); + } else { + if (metres < 1000.0f) + snprintf(buf, len, "%dm", (int)metres); + else if (metres < 10000.0f) + snprintf(buf, len, "%.1fkm", metres / 1000.0f); + else + snprintf(buf, len, "%dkm", (int)(metres / 1000.0f + 0.5f)); + } } -/** Format metres as a number only (no unit suffix) — used for radar ring labels. */ -static void formatDistNum(char *buf, size_t len, float metres) -{ - const bool imperial = (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); - if (imperial) { - const float miles = metres / 1609.34f; - if (miles < 0.1f) - snprintf(buf, len, "%d", (int)(metres * 3.28084f)); - else if (miles < 10.0f) - snprintf(buf, len, "%.1f", miles); - else - snprintf(buf, len, "%d", (int)(miles + 0.5f)); - } else { - if (metres < 1000.0f) - snprintf(buf, len, "%d", (int)metres); - else if (metres < 10000.0f) - snprintf(buf, len, "%.1f", metres / 1000.0f); - else - snprintf(buf, len, "%d", (int)(metres / 1000.0f + 0.5f)); - } +/** Format metres as a number only (no unit suffix) — used for radar ring + * labels. */ +static void formatDistNum(char *buf, size_t len, float metres) { + const bool imperial = (config.display.units == + meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL); + if (imperial) { + const float miles = metres / 1609.34f; + if (miles < 0.1f) + snprintf(buf, len, "%d", (int)(metres * 3.28084f)); + else if (miles < 10.0f) + snprintf(buf, len, "%.1f", miles); + else + snprintf(buf, len, "%d", (int)(miles + 0.5f)); + } else { + if (metres < 1000.0f) + snprintf(buf, len, "%d", (int)metres); + else if (metres < 10000.0f) + snprintf(buf, len, "%.1f", metres / 1000.0f); + else + snprintf(buf, len, "%d", (int)(metres / 1000.0f + 0.5f)); + } } // --------------------------------------------------------------------------- @@ -129,65 +121,64 @@ static void formatDistNum(char *buf, size_t len, float metres) * * All shapes fit within a 5×5 pixel bounding box. */ -static void drawMarker(OLEDDisplay *display, int px, int py, uint8_t sym) -{ - switch (sym) { - case 0: // ■ - display->fillRect(px - 1, py - 1, 3, 3); - break; - case 1: // + - display->drawLine(px - 2, py, px + 2, py); - display->drawLine(px, py - 2, px, py + 2); - break; - case 2: // × - display->drawLine(px - 2, py - 2, px + 2, py + 2); - display->drawLine(px + 2, py - 2, px - 2, py + 2); - break; - case 3: // □ - display->drawLine(px - 2, py - 2, px + 2, py - 2); - display->drawLine(px + 2, py - 2, px + 2, py + 2); - display->drawLine(px + 2, py + 2, px - 2, py + 2); - display->drawLine(px - 2, py + 2, px - 2, py - 2); - break; - case 4: // ◆ diamond - display->drawLine(px, py - 2, px + 2, py); - display->drawLine(px + 2, py, px, py + 2); - display->drawLine(px, py + 2, px - 2, py); - display->drawLine(px - 2, py, px, py - 2); - break; - case 5: // △ triangle up - display->drawLine(px, py - 2, px + 2, py + 2); - display->drawLine(px, py - 2, px - 2, py + 2); - display->drawLine(px - 2, py + 2, px + 2, py + 2); - break; - case 6: // ▽ triangle down - display->drawLine(px, py + 2, px + 2, py - 2); - display->drawLine(px, py + 2, px - 2, py - 2); - display->drawLine(px - 2, py - 2, px + 2, py - 2); - break; - case 7: // — horizontal bar - display->drawLine(px - 2, py, px + 2, py); - break; - case 8: // ○ hollow circle - display->drawCircle(px, py, 2); - break; - default: // * asterisk (+ and × combined) - display->drawLine(px - 2, py, px + 2, py); - display->drawLine(px, py - 2, px, py + 2); - display->drawLine(px - 2, py - 2, px + 2, py + 2); - display->drawLine(px + 2, py - 2, px - 2, py + 2); - break; - } +static void drawMarker(OLEDDisplay *display, int px, int py, uint8_t sym) { + switch (sym) { + case 0: // ■ + display->fillRect(px - 1, py - 1, 3, 3); + break; + case 1: // + + display->drawLine(px - 2, py, px + 2, py); + display->drawLine(px, py - 2, px, py + 2); + break; + case 2: // × + display->drawLine(px - 2, py - 2, px + 2, py + 2); + display->drawLine(px + 2, py - 2, px - 2, py + 2); + break; + case 3: // □ + display->drawLine(px - 2, py - 2, px + 2, py - 2); + display->drawLine(px + 2, py - 2, px + 2, py + 2); + display->drawLine(px + 2, py + 2, px - 2, py + 2); + display->drawLine(px - 2, py + 2, px - 2, py - 2); + break; + case 4: // ◆ diamond + display->drawLine(px, py - 2, px + 2, py); + display->drawLine(px + 2, py, px, py + 2); + display->drawLine(px, py + 2, px - 2, py); + display->drawLine(px - 2, py, px, py - 2); + break; + case 5: // △ triangle up + display->drawLine(px, py - 2, px + 2, py + 2); + display->drawLine(px, py - 2, px - 2, py + 2); + display->drawLine(px - 2, py + 2, px + 2, py + 2); + break; + case 6: // ▽ triangle down + display->drawLine(px, py + 2, px + 2, py - 2); + display->drawLine(px, py + 2, px - 2, py - 2); + display->drawLine(px - 2, py - 2, px + 2, py - 2); + break; + case 7: // — horizontal bar + display->drawLine(px - 2, py, px + 2, py); + break; + case 8: // ○ hollow circle + display->drawCircle(px, py, 2); + break; + default: // * asterisk (+ and × combined) + display->drawLine(px - 2, py, px + 2, py); + display->drawLine(px, py - 2, px, py + 2); + display->drawLine(px - 2, py - 2, px + 2, py + 2); + display->drawLine(px + 2, py - 2, px - 2, py + 2); + break; + } } /** Plot a node on the radar at the correct bearing/distance position. */ -static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bearingRad, float headingRad, float norm, - uint8_t markerIdx) -{ - const float rel = bearingRad - headingRad; - const int px = cx + (int)(radius * norm * sinf(rel)); - const int py = cy - (int)(radius * norm * cosf(rel)); - drawMarker(display, px, py, markerIdx); +static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, + float bearingRad, float headingRad, float norm, + uint8_t markerIdx) { + const float rel = bearingRad - headingRad; + const int px = cx + (int)(radius * norm * sinf(rel)); + const int py = cy - (int)(radius * norm * cosf(rel)); + drawMarker(display, px, py, markerIdx); } /** @@ -200,31 +191,32 @@ static void plotNode(OLEDDisplay *display, int cx, int cy, int radius, float bea * Replicates the icon-rendering half of SharedUIDisplay::drawCommonFooter so * this overlay can own its own footer behaviour without touching shared UI. */ -static void drawConnectionIconNoWipe(OLEDDisplay *display) -{ - if (!isAPIConnected(service ? service->api_state : 0)) - return; - - const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; - const int iconX = 0; - const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale); - - display->setColor(WHITE); - if (currentResolution == ScreenResolution::High) { - const int bytesPerRow = (connection_icon_width + 7) / 8; - for (int yy = 0; yy < connection_icon_height; ++yy) { - const uint8_t *rowPtr = connection_icon + yy * bytesPerRow; - for (int xx = 0; xx < connection_icon_width; ++xx) { - const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3)); - const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first - if (byteVal & bitMask) { - display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale); - } - } +static void drawConnectionIconNoWipe(OLEDDisplay *display) { + if (!isAPIConnected(service ? service->api_state : 0)) + return; + + const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; + const int iconX = 0; + const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale); + + display->setColor(WHITE); + if (currentResolution == ScreenResolution::High) { + const int bytesPerRow = (connection_icon_width + 7) / 8; + for (int yy = 0; yy < connection_icon_height; ++yy) { + const uint8_t *rowPtr = connection_icon + yy * bytesPerRow; + for (int xx = 0; xx < connection_icon_width; ++xx) { + const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3)); + const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first + if (byteVal & bitMask) { + display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, + scale); } - } else { - display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon); + } } + } else { + display->drawXbm(iconX, iconY, connection_icon_width, + connection_icon_height, connection_icon); + } } // --------------------------------------------------------------------------- @@ -244,235 +236,242 @@ static void drawConnectionIconNoWipe(OLEDDisplay *display) * uiconfig.bearings_view_radar is true. The caller draws the footer; this * function owns the header and content area. */ -void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) -{ - const int headerH = FONT_HEIGHT_SMALL - 1; - const int sw = SCREEN_WIDTH; - const int sh = SCREEN_HEIGHT; - - // Single layout — the radar circle always uses the full height below the - // header (matches the dense layout from before any footer reservation - // existed) so its size doesn't shift when the BT/API icon appears. Only - // the list rows on the left reserve space, since they live in the same - // column as the icon and would otherwise be clipped at the bottom. The - // reservation is icon-height + 1 px (the +1 leaves a single pixel of - // breathing room above the icon); most font glyphs don't fill the bottom - // of their bbox, so the last row's visible ink lands flush with the icon - // instead of leaving the previous 3-4 px of unused descender space. - const int footerScale = (currentResolution == ScreenResolution::High) ? 2 : 1; - const int listFooterH = (connection_icon_height * footerScale) + 1; - - const int contentH = sh - headerH; // full-height area for the radar - const int listContentH = contentH - listFooterH; // shorter area for list rows - const int pad = (currentResolution == ScreenResolution::High) ? 9 : 4; - - // ----------------------------------------------------------------------- - // Radar circle — right side, 2 px padding on all sides. - // ----------------------------------------------------------------------- - const int radarDiam = contentH - 2 * pad; - const int radarRadius = radarDiam / 2; - const int radarCX = x + sw - pad - radarRadius; - const int radarCY = y + headerH + pad + radarRadius; - - // Node list panel fills the space to the left of the radar circle. - const int listRight = radarCX - radarRadius - 4; // 4 px gap between list and circle - - // ----------------------------------------------------------------------- - // GPS — bail gracefully if unavailable. No fix → no scale to report, - // so the header stays plain. - // ----------------------------------------------------------------------- - const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - meshtastic_PositionLite ourPos; - if (!ourNode || !nodeDB->copyNodePosition(ourNode->num, ourPos) || (ourPos.latitude_i == 0 && ourPos.longitude_i == 0)) { - graphics::drawCommonHeader(display, x, y, "Radar"); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(x + sw / 2, y + sh / 2 - FONT_HEIGHT_SMALL / 2, "No GPS fix"); - return; - } - - const double myLat = ourPos.latitude_i * 1e-7; - const double myLon = ourPos.longitude_i * 1e-7; - - // ----------------------------------------------------------------------- - // Heading. - // - // Priority: - // 1. BMX160/RAK12034 tilt-compensated heading (screen->hasHeading()) - // 2. GPS movement track (estimatedHeading) - // 3. North-up fallback (0) - // - // s_forceNorthUp overrides (1) and (2) — set via the long-press menu. - // ----------------------------------------------------------------------- - const bool imuAvailable = screen->hasHeading(); - const bool headingUp = imuAvailable && !s_forceNorthUp; - const float headingRad = - headingUp ? screen->getHeading() * DEG_TO_RAD : (s_forceNorthUp ? 0.0f : screen->estimatedHeading(myLat, myLon)); - - // ----------------------------------------------------------------------- - // Collect remote nodes with valid positions. - // ----------------------------------------------------------------------- - struct Entry { - meshtastic_NodeInfoLite *node; - float distM; - float bearingRad; - }; - - std::vector entries; - - const bool favoritesOnly = uiconfig.radar_favorites_only; - - const int numNodes = nodeDB->getNumMeshNodes(); - for (int i = 0; i < numNodes; i++) { - meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); - if (!n || n->num == nodeDB->getNodeNum()) - continue; - if (favoritesOnly && !nodeInfoLiteIsFavorite(n)) - continue; - meshtastic_PositionLite nodePos; - if (!nodeDB->copyNodePosition(n->num, nodePos)) - continue; - if (nodePos.latitude_i == 0 && nodePos.longitude_i == 0) - continue; - - const double nodeLat = nodePos.latitude_i * 1e-7; - const double nodeLon = nodePos.longitude_i * 1e-7; - const float dist = GeoCoord::latLongToMeter(myLat, myLon, nodeLat, nodeLon); - const float brg = GeoCoord::bearing(myLat, myLon, nodeLat, nodeLon); - - entries.push_back({n, dist, brg}); - } - - // Sort by distance so entries[0] is always the closest node. - std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); - - // Auto-scale from only the nodes we will actually plot, so a single - // far-away node can't push the scale into a high bucket and squash all - // the close nodes into an invisible cluster at the centre. - const int minDim = std::min(sw, sh); - const int kMaxPlotted = (minDim >= 230) ? 10 : (minDim > 128) ? 8 : 5; - float maxDistM = 1.0f; - const int plottedCount = std::min((int)entries.size(), kMaxPlotted); - for (int i = 0; i < plottedCount; i++) { - if (entries[i].distM > maxDistM) - maxDistM = entries[i].distM; - } - - const float scale = niceScaleMeters(maxDistM, s_zoomLevel); - - // ----------------------------------------------------------------------- - // Header — "Radar ", drawn now that we know the outer-ring range. - // Keeps the scale legible in the title bar instead of overlapping the - // inner ring. - // ----------------------------------------------------------------------- - { - char scaleBuf[12] = ""; - formatDistM(scaleBuf, sizeof(scaleBuf), scale); - char titleBuf[24]; - snprintf(titleBuf, sizeof(titleBuf), "Radar %s", scaleBuf); - graphics::drawCommonHeader(display, x, y, titleBuf); - } - - // ----------------------------------------------------------------------- - // Draw radar chrome: three concentric range rings. - // ----------------------------------------------------------------------- - for (int ring = 1; ring <= 3; ring++) - display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); - - // ----------------------------------------------------------------------- - // Ring distance labels — high-res only; numbers only, no unit suffix, - // smallest available font, right-aligned flush inside the SE arc point. - // All 3 rings labelled; the outer ring number echoes the header scale. - // ----------------------------------------------------------------------- - if (currentResolution == ScreenResolution::High) { - display->setFont(FONT_SMALL_LOCAL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - const int kRingFontH = _fontHeight(FONT_SMALL_LOCAL); - const float oppNBrg = -headingRad + static_cast(M_PI); // 180° from N - for (int ring = 1; ring <= 3; ring++) { - const int ringR = (radarRadius * ring) / 3; - char ringLabel[12]; - formatDistNum(ringLabel, sizeof(ringLabel), scale * ring / 3.0f); - // Centred on the ring arc, opposite N — just inside the line. - const int lx = radarCX + (int)(ringR * sinf(oppNBrg)); - const int ly = radarCY - (int)(ringR * cosf(oppNBrg)) - kRingFontH; - display->drawString(lx, ly, ringLabel); - } - } - - // ----------------------------------------------------------------------- - // North indicator — rotates in heading-up mode. - // Top edge of the N glyph just touches ring 3 from inside. - // ----------------------------------------------------------------------- - { - const float northBrg = -headingRad; - const int nRadius = radarRadius - FONT_HEIGHT_SMALL / 2; - const int nx = radarCX + (int)(nRadius * sinf(northBrg)); - const int ny = radarCY - (int)(nRadius * cosf(northBrg)); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nx, ny - FONT_HEIGHT_SMALL / 2, "N"); - } - - // Own-node marker: single pixel at centre. - display->setPixel(radarCX, radarCY); - - // ----------------------------------------------------------------------- - // Plot remote nodes — cap at kMaxPlotted to match the list panel. - // - // Marker symbol is the sort-position index (0..9) so every plotted node - // gets a unique shape and matches its row in the list panel. Using the - // node number modulo N caused symbol collisions when several plotted - // nodes shared a residue. - // ----------------------------------------------------------------------- - for (int i = 0; i < plottedCount; i++) { - const Entry &e = entries[i]; - plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, std::min(e.distM / scale, 1.0f), (uint8_t)i); - } - - // ----------------------------------------------------------------------- - // Node list (left panel) — up to 10 closest nodes. - // - // Each row: marker symbol (matches the radar dot) | short name | distance. - // ----------------------------------------------------------------------- +void drawRadarOverlay(OLEDDisplay *display, int16_t x, int16_t y) { + const int headerH = FONT_HEIGHT_SMALL - 1; + const int sw = SCREEN_WIDTH; + const int sh = SCREEN_HEIGHT; + + // Single layout — the radar circle always uses the full height below the + // header (matches the dense layout from before any footer reservation + // existed) so its size doesn't shift when the BT/API icon appears. Only + // the list rows on the left reserve space, since they live in the same + // column as the icon and would otherwise be clipped at the bottom. The + // reservation is icon-height + 1 px (the +1 leaves a single pixel of + // breathing room above the icon); most font glyphs don't fill the bottom + // of their bbox, so the last row's visible ink lands flush with the icon + // instead of leaving the previous 3-4 px of unused descender space. + const int footerScale = (currentResolution == ScreenResolution::High) ? 2 : 1; + const int listFooterH = (connection_icon_height * footerScale) + 1; + + const int contentH = sh - headerH; // full-height area for the radar + const int listContentH = contentH - listFooterH; // shorter area for list rows + const int pad = (currentResolution == ScreenResolution::High) ? 9 : 4; + + // ----------------------------------------------------------------------- + // Radar circle — right side, 2 px padding on all sides. + // ----------------------------------------------------------------------- + const int radarDiam = contentH - 2 * pad; + const int radarRadius = radarDiam / 2; + const int radarCX = x + sw - pad - radarRadius; + const int radarCY = y + headerH + pad + radarRadius; + + // Node list panel fills the space to the left of the radar circle. + const int listRight = + radarCX - radarRadius - 4; // 4 px gap between list and circle + + // ----------------------------------------------------------------------- + // GPS — bail gracefully if unavailable. No fix → no scale to report, + // so the header stays plain. + // ----------------------------------------------------------------------- + const meshtastic_NodeInfoLite *ourNode = + nodeDB->getMeshNode(nodeDB->getNodeNum()); + meshtastic_PositionLite ourPos; + if (!ourNode || !nodeDB->copyNodePosition(ourNode->num, ourPos) || + (ourPos.latitude_i == 0 && ourPos.longitude_i == 0)) { + graphics::drawCommonHeader(display, x, y, "Radar"); display->setFont(FONT_SMALL); - - constexpr int kListTopPad = 5; - const int rowPitch = (listContentH - kListTopPad) / kMaxPlotted; - - // Marker centred to the visible text height (rowY is the top of the - // glyph bbox; centring on rowPitch/2 read as "top-aligned" because the - // font's bbox is taller than its visible ink). - const int symOffsetY = (FONT_HEIGHT_SMALL - 2) / 2; - - for (int i = 0; i < plottedCount; i++) { - const Entry &e = entries[i]; - const int rowY = y + headerH + kListTopPad + rowPitch * i; - const int symCX = x + 6; // 4 px left margin + 2 px to marker centre - const int symCY = rowY + symOffsetY; - - drawMarker(display, symCX, symCY, (uint8_t)i); - - char name[10] = ""; - if (nodeInfoLiteHasUser(e.node) && e.node->short_name[0]) - strncpy(name, e.node->short_name, sizeof(name) - 1); - else - snprintf(name, sizeof(name), "%04X", (uint16_t)(e.node->num & 0xFFFF)); - - char dist[10] = ""; - formatDistM(dist, sizeof(dist), e.distM); - - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawString(x + 11, rowY, name); // 3 px gap after marker right edge - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + listRight, rowY, dist); - display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + sw / 2, y + sh / 2 - FONT_HEIGHT_SMALL / 2, + "No GPS fix"); + return; + } + + const double myLat = ourPos.latitude_i * 1e-7; + const double myLon = ourPos.longitude_i * 1e-7; + + // ----------------------------------------------------------------------- + // Heading. + // + // Priority: + // 1. BMX160/RAK12034 tilt-compensated heading (screen->hasHeading()) + // 2. GPS movement track (estimatedHeading) + // 3. North-up fallback (0) + // + // s_forceNorthUp overrides (1) and (2) — set via the long-press menu. + // ----------------------------------------------------------------------- + const bool imuAvailable = screen->hasHeading(); + const bool headingUp = imuAvailable && !s_forceNorthUp; + const float headingRad = + headingUp + ? screen->getHeading() * DEG_TO_RAD + : (s_forceNorthUp ? 0.0f : screen->estimatedHeading(myLat, myLon)); + + // ----------------------------------------------------------------------- + // Collect remote nodes with valid positions. + // ----------------------------------------------------------------------- + struct Entry { + meshtastic_NodeInfoLite *node; + float distM; + float bearingRad; + }; + + std::vector entries; + + const bool favoritesOnly = uiconfig.radar_favorites_only; + + const int numNodes = nodeDB->getNumMeshNodes(); + for (int i = 0; i < numNodes; i++) { + meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); + if (!n || n->num == nodeDB->getNodeNum()) + continue; + if (favoritesOnly && !nodeInfoLiteIsFavorite(n)) + continue; + meshtastic_PositionLite nodePos; + if (!nodeDB->copyNodePosition(n->num, nodePos)) + continue; + if (nodePos.latitude_i == 0 && nodePos.longitude_i == 0) + continue; + + const double nodeLat = nodePos.latitude_i * 1e-7; + const double nodeLon = nodePos.longitude_i * 1e-7; + const float dist = GeoCoord::latLongToMeter(myLat, myLon, nodeLat, nodeLon); + const float brg = GeoCoord::bearing(myLat, myLon, nodeLat, nodeLon); + + entries.push_back({n, dist, brg}); + } + + // Sort by distance so entries[0] is always the closest node. + std::sort(entries.begin(), entries.end(), + [](const Entry &a, const Entry &b) { return a.distM < b.distM; }); + + // Auto-scale from only the nodes we will actually plot, so a single + // far-away node can't push the scale into a high bucket and squash all + // the close nodes into an invisible cluster at the centre. + const int minDim = std::min(sw, sh); + const int kMaxPlotted = (minDim >= 230) ? 10 : (minDim > 128) ? 8 : 5; + float maxDistM = 1.0f; + const int plottedCount = std::min((int)entries.size(), kMaxPlotted); + for (int i = 0; i < plottedCount; i++) { + if (entries[i].distM > maxDistM) + maxDistM = entries[i].distM; + } + + const float scale = niceScaleMeters(maxDistM, s_zoomLevel); + + // ----------------------------------------------------------------------- + // Header — "Radar ", drawn now that we know the outer-ring range. + // Keeps the scale legible in the title bar instead of overlapping the + // inner ring. + // ----------------------------------------------------------------------- + { + char scaleBuf[12] = ""; + formatDistM(scaleBuf, sizeof(scaleBuf), scale); + char titleBuf[24]; + snprintf(titleBuf, sizeof(titleBuf), "Radar %s", scaleBuf); + graphics::drawCommonHeader(display, x, y, titleBuf); + } + + // ----------------------------------------------------------------------- + // Draw radar chrome: three concentric range rings. + // ----------------------------------------------------------------------- + for (int ring = 1; ring <= 3; ring++) + display->drawCircle(radarCX, radarCY, (radarRadius * ring) / 3); + + // ----------------------------------------------------------------------- + // Ring distance labels — high-res only; numbers only, no unit suffix, + // smallest available font, right-aligned flush inside the SE arc point. + // All 3 rings labelled; the outer ring number echoes the header scale. + // ----------------------------------------------------------------------- + if (currentResolution == ScreenResolution::High) { + display->setFont(FONT_SMALL_LOCAL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + const int kRingFontH = _fontHeight(FONT_SMALL_LOCAL); + const float oppNBrg = -headingRad + static_cast(M_PI); // 180° from N + for (int ring = 1; ring <= 3; ring++) { + const int ringR = (radarRadius * ring) / 3; + char ringLabel[12]; + formatDistNum(ringLabel, sizeof(ringLabel), scale * ring / 3.0f); + // Centred on the ring arc, opposite N — just inside the line. + const int lx = radarCX + (int)(ringR * sinf(oppNBrg)); + const int ly = radarCY - (int)(ringR * cosf(oppNBrg)) - kRingFontH; + display->drawString(lx, ly, ringLabel); } - - // BT/API connection icon — drawn here (no surrounding wipe) so the radar - // circle and the last list row stay intact. NodeListRenderer's radar - // branch deliberately skips drawCommonFooter for the same reason. - drawConnectionIconNoWipe(display); + } + + // ----------------------------------------------------------------------- + // North indicator — rotates in heading-up mode. + // Top edge of the N glyph just touches ring 3 from inside. + // ----------------------------------------------------------------------- + { + const float northBrg = -headingRad; + const int nRadius = radarRadius - FONT_HEIGHT_SMALL / 2; + const int nx = radarCX + (int)(nRadius * sinf(northBrg)); + const int ny = radarCY - (int)(nRadius * cosf(northBrg)); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(nx, ny - FONT_HEIGHT_SMALL / 2, "N"); + } + + // Own-node marker: single pixel at centre. + display->setPixel(radarCX, radarCY); + + // ----------------------------------------------------------------------- + // Plot remote nodes — cap at kMaxPlotted to match the list panel. + // + // Marker symbol is the sort-position index (0..9) so every plotted node + // gets a unique shape and matches its row in the list panel. Using the + // node number modulo N caused symbol collisions when several plotted + // nodes shared a residue. + // ----------------------------------------------------------------------- + for (int i = 0; i < plottedCount; i++) { + const Entry &e = entries[i]; + plotNode(display, radarCX, radarCY, radarRadius, e.bearingRad, headingRad, + std::min(e.distM / scale, 1.0f), (uint8_t)i); + } + + // ----------------------------------------------------------------------- + // Node list (left panel) — up to 10 closest nodes. + // + // Each row: marker symbol (matches the radar dot) | short name | distance. + // ----------------------------------------------------------------------- + display->setFont(FONT_SMALL); + + constexpr int kListTopPad = 5; + const int rowPitch = (listContentH - kListTopPad) / kMaxPlotted; + + // Marker centred to the visible text height (rowY is the top of the + // glyph bbox; centring on rowPitch/2 read as "top-aligned" because the + // font's bbox is taller than its visible ink). + const int symOffsetY = (FONT_HEIGHT_SMALL - 2) / 2; + + for (int i = 0; i < plottedCount; i++) { + const Entry &e = entries[i]; + const int rowY = y + headerH + kListTopPad + rowPitch * i; + const int symCX = x + 6; // 4 px left margin + 2 px to marker centre + const int symCY = rowY + symOffsetY; + + drawMarker(display, symCX, symCY, (uint8_t)i); + + char name[10] = ""; + if (nodeInfoLiteHasUser(e.node) && e.node->short_name[0]) + strncpy(name, e.node->short_name, sizeof(name) - 1); + else + snprintf(name, sizeof(name), "%04X", (uint16_t)(e.node->num & 0xFFFF)); + + char dist[10] = ""; + formatDistM(dist, sizeof(dist), e.distM); + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(x + 11, rowY, name); // 3 px gap after marker right edge + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(x + listRight, rowY, dist); + display->setTextAlignment(TEXT_ALIGN_LEFT); + } + + // BT/API connection icon — drawn here (no surrounding wipe) so the radar + // circle and the last list row stay intact. NodeListRenderer's radar + // branch deliberately skips drawCommonFooter for the same reason. + drawConnectionIconNoWipe(display); } } // namespace RadarRenderer