InkHUD: add offline map tile backgrounds and zoom controls (#10785)

* InkHUD: map tile background, zoom controls, and GPS live tracking

- Map background tiles are now rendered on the display when available, compressed with LZ4 to keep flash usage low
- If no map tiles are loaded, the map applets behave exactly as before
- Zoom in, zoom out, and reset zoom (back to auto-fit) are accessible from the menu, and only appear when the menu is opened from a map screen
- The map updates automatically whenever GPS gets a new position or your phone shares a location
- The Positions and Favorites map applets now also refresh when mesh position packets arrive for your own node
- The Favorites map now shows your position on the map even if you have no favorites yet

* README Update

* Update MapApplet.cpp

* Zoom fixed

* Zoom with no tiles fix

* CI fix
This commit is contained in:
HarukiToreda
2026-06-27 14:35:03 -05:00
committed by GitHub
co-authored by GitHub
parent ec5d230305
commit 25c71cb9f1
10 changed files with 533 additions and 85 deletions
+2
View File
@@ -128,6 +128,8 @@ class Applet : public GFX
virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification
virtual class MapApplet *asMapApplet() { return nullptr; } // Returns non-null only for MapApplet and its subclasses
static uint16_t getHeaderHeight(); // How tall the "standard" applet header is static uint16_t getHeaderHeight(); // How tall the "standard" applet header is
static AppletFont fontSmall, fontMedium, fontLarge; // The general purpose fonts, used by all applets static AppletFont fontSmall, fontMedium, fontLarge; // The general purpose fonts, used by all applets
@@ -1,18 +1,367 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD #ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./MapApplet.h" #include "./MapApplet.h"
#include "./MapTile.h"
#include <math.h>
#include <string.h>
using namespace NicheGraphics; using namespace NicheGraphics;
bool InkHUD::MapApplet::s_zoomLocked = false;
int InkHUD::MapApplet::s_lockedZoom = -1;
int InkHUD::MapApplet::s_lastRenderedZoom = -1;
int InkHUD::MapApplet::s_autoFitZoom = -1;
// Observe GPS position updates so the map redraws whenever a new location arrives.
InkHUD::MapApplet::MapApplet()
{
if (gpsStatus)
gpsStatusObserver.observe(&gpsStatus->onNewStatus);
}
int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status)
{
if (status->getStatusType() != STATUS_TYPE_GPS)
return 0;
if (!isActive() || !gpsStatus->getHasLock())
return 0;
requestUpdate();
return 0;
}
// Zoom in one step from the current display zoom.
void InkHUD::MapApplet::zoomIn()
{
int baseZoom = s_zoomLocked ? s_lockedZoom : s_lastRenderedZoom;
if (baseZoom < 0)
return;
if (map_tile_count == 0) {
if (baseZoom < ZOOM_MAX_NO_TILES) {
s_lockedZoom = baseZoom + 1;
s_zoomLocked = true;
}
return;
}
// Jump to the next tile zoom strictly above current, not just +1
int next = -1;
for (int i = 0; i < map_tile_count; i++) {
int z = map_tile_zooms[i];
if (z > baseZoom && (next < 0 || z < next))
next = z;
}
if (next < 0)
return;
s_lockedZoom = next;
s_zoomLocked = true;
}
void InkHUD::MapApplet::resetZoom()
{
s_zoomLocked = false;
s_lockedZoom = -1;
}
bool InkHUD::MapApplet::canZoomIn() const
{
if (s_lastRenderedZoom < 0)
return false;
int ref = s_zoomLocked ? s_lockedZoom : s_lastRenderedZoom;
if (map_tile_count == 0)
return ref < ZOOM_MAX_NO_TILES;
for (int i = 0; i < map_tile_count; i++) {
if (map_tile_zooms[i] > ref)
return true;
}
return false;
}
void InkHUD::MapApplet::zoomOut()
{
int baseZoom = s_zoomLocked ? s_lockedZoom : s_lastRenderedZoom;
if (baseZoom < 0) {
s_zoomLocked = false;
s_lockedZoom = -1;
return;
}
if (map_tile_count == 0) {
int floor = (s_autoFitZoom >= 0) ? s_autoFitZoom : baseZoom;
if (baseZoom > floor) {
s_lockedZoom = baseZoom - 1;
s_zoomLocked = true;
} else {
s_zoomLocked = false;
s_lockedZoom = -1;
}
return;
}
// Jump to the next tile zoom strictly below current, not just -1
int next = -1;
for (int i = 0; i < map_tile_count; i++) {
int z = map_tile_zooms[i];
if (z < baseZoom && (next < 0 || z > next))
next = z;
}
if (next < 0) {
s_zoomLocked = false;
s_lockedZoom = -1;
return;
}
s_lockedZoom = next;
s_zoomLocked = true;
}
bool InkHUD::MapApplet::canZoomOut() const
{
if (s_lastRenderedZoom < 0)
return false;
int ref = s_zoomLocked ? s_lockedZoom : s_lastRenderedZoom;
if (map_tile_count == 0)
return s_autoFitZoom >= 0 ? ref > s_autoFitZoom : false;
for (int i = 0; i < map_tile_count; i++) {
if (map_tile_zooms[i] < ref)
return true;
}
return false;
}
// Raw LZ4 block decompressor. Returns bytes written, or -1 on error.
static int lz4_decompress(const uint8_t *src, int src_len, uint8_t *dst, int dst_cap)
{
const uint8_t *s = src;
const uint8_t *s_end = src + src_len;
uint8_t *d = dst;
const uint8_t *d_end = dst + dst_cap;
while (s < s_end) {
uint8_t token = *s++;
int lit_len = (token >> 4) & 0xF;
if (lit_len == 15) {
uint8_t x;
do {
x = *s++;
lit_len += x;
} while (x == 255 && s < s_end);
}
if (d + lit_len > d_end || s + lit_len > s_end)
return -1;
memcpy(d, s, lit_len);
d += lit_len;
s += lit_len;
if (s >= s_end)
break;
if (s + 2 > s_end)
return -1;
int offset = (int)s[0] | ((int)s[1] << 8);
s += 2;
if (offset == 0 || d - offset < dst)
return -1;
int mat_len = (token & 0xF) + 4;
if (mat_len == 4 + 15) {
uint8_t x;
do {
x = *s++;
mat_len += x;
} while (x == 255 && s < s_end);
}
if (d + mat_len > d_end)
return -1;
const uint8_t *m = d - offset;
for (int i = 0; i < mat_len; i++)
*d++ = m[i];
}
return (int)(d - dst);
}
// Tiles are 1 bit/pixel, column-major: [bx=0..31][y=0..255], 8 pixels per byte.
static uint8_t s_tileCacheBuffer[8192];
static const uint8_t *decodeSparseTile(int tileIndex)
{
int n = lz4_decompress(map_tile_data[tileIndex], map_tile_sizes[tileIndex], s_tileCacheBuffer, sizeof(s_tileCacheBuffer));
return n == sizeof(s_tileCacheBuffer) ? s_tileCacheBuffer : nullptr;
}
// Draw tiles centered on latCenter/lngCenter. Falls back to the nearest available zoom if
// no tiles exist at exactly zoom (upsamples), enabling smooth zoom steps.
void InkHUD::MapApplet::drawMapTileBackground(int zoom)
{
if (map_tile_count == 0 || metersToPx <= 0.0f)
return;
const float R = 6378137.0f;
const float latRad = latCenter * DEG_TO_RAD;
const float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << zoom))) * cosf(latRad);
const float worldPxPerScreenPx = 1.0f / (metersToPx * mpp);
// Find best tile zoom: highest available <= zoom, or lowest available if none below.
int tileZoom = -1;
for (int i = 0; i < map_tile_count; i++) {
int z = map_tile_zooms[i];
if (z <= zoom && (tileZoom < 0 || z > tileZoom))
tileZoom = z;
}
if (tileZoom < 0) {
for (int i = 0; i < map_tile_count; i++) {
int z = map_tile_zooms[i];
if (tileZoom < 0 || z < tileZoom)
tileZoom = z;
}
}
if (tileZoom < 0)
return;
// Convert screen-pixel movement into tileZoom coordinate space.
// When tileZoom < zoom, tile pixels are upsampled (each tile pixel covers >1 screen px).
const float tileWorldPx = worldPxPerScreenPx * ((float)(1 << tileZoom) / (float)(1 << zoom));
const float sinLat = sinf(latRad);
const float gpxX = ((lngCenter + 180.0f) / 360.0f) * (float)(1 << tileZoom) * 256.0f;
const float gpxY = (0.5f - logf((1.0f + sinLat) / (1.0f - sinLat)) / (4.0f * M_PI)) * (float)(1 << tileZoom) * 256.0f;
const float minWx = gpxX - width() * 0.5f * tileWorldPx;
const float maxWx = gpxX + width() * 0.5f * tileWorldPx;
const float minWy = gpxY - height() * 0.5f * tileWorldPx;
const float maxWy = gpxY + height() * 0.5f * tileWorldPx;
for (int i = 0; i < map_tile_count; i++) {
if (map_tile_zooms[i] != tileZoom)
continue;
const int tx = map_tile_tx[i];
const int ty = map_tile_ty[i];
const float tileMinWx = tx * 256.0f;
const float tileMaxWx = tileMinWx + 256.0f;
const float tileMinWy = ty * 256.0f;
const float tileMaxWy = tileMinWy + 256.0f;
if (tileMaxWx < minWx || tileMinWx > maxWx || tileMaxWy < minWy || tileMinWy > maxWy)
continue;
const uint8_t *tile = decodeSparseTile(i);
if (!tile)
continue;
const int sxStart = max(0, (int)floorf(((tileMinWx - gpxX) / tileWorldPx) + width() * 0.5f));
const int sxEnd = min(width() - 1, (int)ceilf(((tileMaxWx - gpxX) / tileWorldPx) + width() * 0.5f) - 1);
const int syStart = max(0, (int)floorf(((tileMinWy - gpxY) / tileWorldPx) + height() * 0.5f));
const int syEnd = min(height() - 1, (int)ceilf(((tileMaxWy - gpxY) / tileWorldPx) + height() * 0.5f) - 1);
for (int sy = syStart; sy <= syEnd; sy++) {
const float wy = gpxY + (sy - height() * 0.5f) * tileWorldPx;
const int py = (int)(wy - tileMinWy);
if (py < 0 || py > 255)
continue;
for (int sx = sxStart; sx <= sxEnd; sx++) {
const float wx = gpxX + (sx - width() * 0.5f) * tileWorldPx;
const int px = (int)(wx - tileMinWx);
if (px < 0 || px > 255)
continue;
if (!(tile[(px / 8) * 256 + py] & (1 << (px % 8))))
continue;
drawPixel(sx, sy, BLACK);
}
}
}
}
void InkHUD::MapApplet::onRender(bool full) void InkHUD::MapApplet::onRender(bool full)
{ {
// Abort if no markers to render // Map center is always the node centroid — tiles are background only.
if (!enoughMarkers()) { getMapCenter(&latCenter, &lngCenter);
calculateAllMarkers();
// Show placeholder only if we have no position at all — no tiles, no own node
if (!enoughMarkers() && !centerIsOurNode) {
printAt(X(0.5), Y(0.5) - (getFont().lineHeight() / 2), "Node positions", CENTER, MIDDLE); printAt(X(0.5), Y(0.5) - (getFont().lineHeight() / 2), "Node positions", CENTER, MIDDLE);
printAt(X(0.5), Y(0.5) + (getFont().lineHeight() / 2), "will appear here", CENTER, MIDDLE); printAt(X(0.5), Y(0.5) + (getFont().lineHeight() / 2), "will appear here", CENTER, MIDDLE);
return; return;
} }
// Determine the metersToPx needed to fit all nodes on screen.
getMapSize(&widthMeters, &heightMeters);
calculateMapScale(); // metersToPx = fit-all-nodes scale
const float metersToPxFit = metersToPx;
// Pick the highest zoom whose native scale fits all nodes (no downsampling, no dither noise).
{
const float R = 6378137.0f;
const float latRad = latCenter * DEG_TO_RAD;
// Collect unique zooms, sort descending (highest detail first)
int zooms[16] = {};
int nzooms = 0;
for (int i = 0; i < map_tile_count && nzooms < 16; i++) {
bool found = false;
for (int j = 0; j < nzooms; j++) {
if (zooms[j] == map_tile_zooms[i]) {
found = true;
break;
}
}
if (!found)
zooms[nzooms++] = map_tile_zooms[i];
}
for (int i = 0; i < nzooms - 1; i++) {
for (int j = i + 1; j < nzooms; j++) {
if (zooms[j] > zooms[i]) {
int t = zooms[i];
zooms[i] = zooms[j];
zooms[j] = t;
}
}
}
int chosenZoom = (nzooms > 0) ? zooms[nzooms - 1] : 13; // fallback: widest zoom
float chosenMetersToPx = metersToPxFit; // fallback: fit-scale (may downsample)
if (s_zoomLocked && s_lockedZoom >= 0) {
// Use locked zoom at native 1:1 scale — never zoom out for new nodes
chosenZoom = s_lockedZoom;
float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad);
chosenMetersToPx = 1.0f / mpp;
} else if ((markers.empty() || metersToPxFit <= 0.0f) && nzooms > 0) {
// No spread to fit (own node only, or single remote node at map center). Use highest zoom at native scale.
chosenZoom = zooms[0];
float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad);
chosenMetersToPx = 1.0f / mpp;
} else {
for (int zi = 0; zi < nzooms; zi++) {
float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << zooms[zi]))) * cosf(latRad);
float nativeMetersToPx = 1.0f / mpp;
if (nativeMetersToPx <= metersToPxFit) {
// This zoom at native scale shows all nodes — use it (highest detail that fits)
chosenZoom = zooms[zi];
chosenMetersToPx = nativeMetersToPx;
break;
}
}
}
if (!s_zoomLocked)
s_autoFitZoom = chosenZoom;
metersToPx = chosenMetersToPx;
s_lastRenderedZoom = chosenZoom;
drawMapTileBackground(chosenZoom);
char zoomLabel[8];
snprintf(zoomLabel, sizeof(zoomLabel), "z%d", chosenZoom);
int16_t zoomLabelW = getTextWidth(zoomLabel);
int16_t zoomLabelH = getFont().lineHeight();
int16_t zoomLabelX = width() - zoomLabelW - 3;
int16_t zoomLabelY = 2;
fillRect(zoomLabelX - 2, zoomLabelY - 1, zoomLabelW + 4, zoomLabelH + 2, WHITE);
printAt(zoomLabelX, zoomLabelY, zoomLabel, LEFT, TOP);
}
// Helper: draw rounded rectangle centered at x,y // Helper: draw rounded rectangle centered at x,y
auto fillRoundedRect = [&](int16_t cx, int16_t cy, int16_t w, int16_t h, int16_t r, uint16_t color) { auto fillRoundedRect = [&](int16_t cx, int16_t cy, int16_t w, int16_t h, int16_t r, uint16_t color) {
int16_t x = cx - (w / 2); int16_t x = cx - (w / 2);
@@ -30,16 +379,10 @@ void InkHUD::MapApplet::onRender(bool full)
fillCircle(x + w - r - 1, y + h - r - 1, r, color); fillCircle(x + w - r - 1, y + h - r - 1, r, color);
}; };
// Find center of map
getMapCenter(&latCenter, &lngCenter);
calculateAllMarkers();
getMapSize(&widthMeters, &heightMeters);
calculateMapScale();
// Draw all markers first // Draw all markers first
for (Marker m : markers) { for (Marker m : markers) {
int16_t x = X(0.5) + (m.eastMeters * metersToPx); int16_t x = X(0.5) + (int16_t)(m.eastMeters * metersToPx);
int16_t y = Y(0.5) - (m.northMeters * metersToPx); int16_t y = Y(0.5) - (int16_t)(m.northMeters * metersToPx);
// Add white halo outline first // Add white halo outline first
constexpr int outlinePad = 1; constexpr int outlinePad = 1;
@@ -57,10 +400,8 @@ void InkHUD::MapApplet::onRender(bool full)
setTextColor(WHITE); setTextColor(WHITE);
// Draw actual marker on top // Draw actual marker on top
if (m.hasHopsAway && m.hopsAway > config.lora.hop_limit) { if (m.hopsAway > config.lora.hop_limit) {
printAt(x + 1, y + 1, "X", CENTER, MIDDLE); printAt(x + 1, y + 1, "X", CENTER, MIDDLE);
} else if (!m.hasHopsAway) {
printAt(x + 1, y + 1, "?", CENTER, MIDDLE);
} else { } else {
char hopStr[4]; char hopStr[4];
snprintf(hopStr, sizeof(hopStr), "%d", m.hopsAway); snprintf(hopStr, sizeof(hopStr), "%d", m.hopsAway);
@@ -73,6 +414,8 @@ void InkHUD::MapApplet::onRender(bool full)
} }
// Dual map scale bars // Dual map scale bars
if (metersToPx <= 0.0f)
return;
int16_t horizPx = width() * 0.25f; int16_t horizPx = width() * 0.25f;
int16_t vertPx = height() * 0.25f; int16_t vertPx = height() * 0.25f;
float horizMeters = horizPx / metersToPx; float horizMeters = horizPx / metersToPx;
@@ -136,11 +479,11 @@ void InkHUD::MapApplet::onRender(bool full)
printAt(vertBarX + (bottomLabelW / 2) + 1, bottomLabelY + (bottomLabelH / 2), vertBottomLabel, CENTER, MIDDLE); printAt(vertBarX + (bottomLabelW / 2) + 1, bottomLabelY + (bottomLabelH / 2), vertBottomLabel, CENTER, MIDDLE);
// Draw our node LAST with full white fill + outline // Draw our node LAST with full white fill + outline
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (centerIsOurNode) {
meshtastic_PositionLite ourSelfPos; const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) { meshtastic_PositionLite ourSelfPos;
Marker self = calculateMarker(ourSelfPos.latitude_i * 1e-7, ourSelfPos.longitude_i * 1e-7, false, 0); nodeDB->copyNodePosition(ourNode->num, ourSelfPos);
Marker self = calculateMarker(ourSelfPos.latitude_i * 1e-7, ourSelfPos.longitude_i * 1e-7, 0);
int16_t centerX = X(0.5) + (self.eastMeters * metersToPx); int16_t centerX = X(0.5) + (self.eastMeters * metersToPx);
int16_t centerY = Y(0.5) - (self.northMeters * metersToPx); int16_t centerY = Y(0.5) - (self.northMeters * metersToPx);
@@ -174,7 +517,9 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) { if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
*lat = ourSelfPos.latitude_i * 1e-7; *lat = ourSelfPos.latitude_i * 1e-7;
*lng = ourSelfPos.longitude_i * 1e-7; *lng = ourSelfPos.longitude_i * 1e-7;
centerIsOurNode = true;
} else { } else {
centerIsOurNode = false;
// Find mean lat long coords // Find mean lat long coords
// ============================ // ============================
// - assigning X, Y and Z values to position on Earth's surface in 3D space, relative to center of planet // - assigning X, Y and Z values to position on Earth's surface in 3D space, relative to center of planet
@@ -225,6 +570,8 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
} }
// All NodeDB processed, find mean values // All NodeDB processed, find mean values
if (positionCount == 0)
return;
xAvg /= positionCount; xAvg /= positionCount;
yAvg /= positionCount; yAvg /= positionCount;
zAvg /= positionCount; zAvg /= positionCount;
@@ -278,18 +625,43 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
latCenter = *lat; latCenter = *lat;
lngCenter = *lng; lngCenter = *lng;
// ---------------------------------------------- // When zoom is locked, keep center exactly on own node / zero-hop centroid.
// This has given us either: // Skip bounding-box shift so new distant nodes don't move the zoomed view.
// - our actual position (preferred), or if (s_zoomLocked) {
// - a mean position (fallback if we had no fix) // Own node has no position — re-center on zero-hop centroid instead.
// if (!centerIsOurNode) {
// What we actually want is to place our center so that our outermost nodes uint32_t count = 0;
// end up on the border of our map. The only real use of our "center" is to give float xAvg = 0, yAvg = 0, zAvg = 0;
// us a reference frame: which direction is east, and which is west. for (uint32_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
//------------------------------------------------ meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
if (!nodeDB->hasValidPosition(node) || !shouldDrawNode(node))
continue;
if (!node->has_hops_away || node->hops_away != 0)
continue;
meshtastic_PositionLite pos;
if (!nodeDB->copyNodePosition(node->num, pos))
continue;
float latRad2 = pos.latitude_i * 1e-7 * DEG_TO_RAD;
float lngRad2 = pos.longitude_i * 1e-7 * DEG_TO_RAD;
xAvg += cosf(latRad2) * cosf(lngRad2);
yAvg += cosf(latRad2) * sinf(lngRad2);
zAvg += sinf(latRad2);
count++;
}
if (count > 0) {
xAvg /= count;
yAvg /= count;
zAvg /= count;
*lng = atan2f(yAvg, xAvg) * RAD_TO_DEG;
*lat = atan2f(zAvg, sqrtf(xAvg * xAvg + yAvg * yAvg)) * RAD_TO_DEG;
latCenter = *lat;
lngCenter = *lng;
}
}
return; // Do not shift center based on bounding box
}
// Find furthest nodes from our center // Find furthest nodes from our center, shift center to midpoint of bounding box
// ========================================
float northernmost = latCenter; float northernmost = latCenter;
float southernmost = latCenter; float southernmost = latCenter;
float easternmost = lngCenter; float easternmost = lngCenter;
@@ -298,11 +670,8 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Skip if no position
if (!nodeDB->hasValidPosition(node)) if (!nodeDB->hasValidPosition(node))
continue; continue;
// Skip if derived applet doesn't want to show this node on the map
if (!shouldDrawNode(node)) if (!shouldDrawNode(node))
continue; continue;
@@ -310,15 +679,14 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
if (!nodeDB->copyNodePosition(node->num, pos)) if (!nodeDB->copyNodePosition(node->num, pos))
continue; continue;
// Check for a new top or bottom latitude
float latNode = pos.latitude_i * 1e-7; float latNode = pos.latitude_i * 1e-7;
float lngNode = pos.longitude_i * 1e-7;
northernmost = max(northernmost, latNode); northernmost = max(northernmost, latNode);
southernmost = min(southernmost, latNode); southernmost = min(southernmost, latNode);
// Longitude is trickier float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees east from center to node
float lngNode = pos.longitude_i * 1e-7; float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees west from center to node
float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees traveled east from lngCenter to reach node
float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees traveled west from lngCenter to reach node
if (degEastward < degWestward) if (degEastward < degWestward)
easternmost = max(easternmost, lngCenter + degEastward); easternmost = max(easternmost, lngCenter + degEastward);
else else
@@ -356,7 +724,7 @@ void InkHUD::MapApplet::getMapSize(uint32_t *widthMeters, uint32_t *heightMeters
// Convert and store info we need for drawing a marker // Convert and store info we need for drawing a marker
// Lat / long to "meters relative to map center", for position on screen // Lat / long to "meters relative to map center", for position on screen
// Info about hopsAway, for marker size // Info about hopsAway, for marker size
InkHUD::MapApplet::Marker InkHUD::MapApplet::calculateMarker(float lat, float lng, bool hasHopsAway, uint8_t hopsAway) InkHUD::MapApplet::Marker InkHUD::MapApplet::calculateMarker(float lat, float lng, uint8_t hopsAway)
{ {
assert(lat != 0 || lng != 0); // Not null island. Applets should check this before calling. assert(lat != 0 || lng != 0); // Not null island. Applets should check this before calling.
@@ -369,11 +737,9 @@ InkHUD::MapApplet::Marker InkHUD::MapApplet::calculateMarker(float lat, float ln
float northMeters = cos(bearingFromCenter) * distanceFromCenter; float northMeters = cos(bearingFromCenter) * distanceFromCenter;
float eastMeters = sin(bearingFromCenter) * distanceFromCenter; float eastMeters = sin(bearingFromCenter) * distanceFromCenter;
// Store this as a new marker
Marker m; Marker m;
m.eastMeters = eastMeters; m.eastMeters = eastMeters;
m.northMeters = northMeters; m.northMeters = northMeters;
m.hasHopsAway = hasHopsAway;
m.hopsAway = hopsAway; m.hopsAway = hopsAway;
return m; return m;
} }
@@ -385,11 +751,7 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
meshtastic_PositionLite pos; meshtastic_PositionLite pos;
const bool hasPos = nodeDB->copyNodePosition(node->num, pos); const bool hasPos = nodeDB->copyNodePosition(node->num, pos);
assert(hasPos); assert(hasPos);
Marker m = calculateMarker(pos.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style Marker m = calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away);
pos.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
node->has_hops_away, // Is the hopsAway number valid
node->hops_away // Hops away
);
// Convert to pixel coords // Convert to pixel coords
int16_t markerX = X(0.5) + (m.eastMeters * metersToPx); int16_t markerX = X(0.5) + (m.eastMeters * metersToPx);
@@ -412,8 +774,6 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
uint8_t markerSize; uint8_t markerSize;
bool tooManyHops = node->hops_away > config.lora.hop_limit; bool tooManyHops = node->hops_away > config.lora.hop_limit;
bool isOurNode = node->num == nodeDB->getNodeNum();
bool unknownHops = !node->has_hops_away && !isOurNode;
// Parse any non-ascii chars in the short name, // Parse any non-ascii chars in the short name,
// and use last 4 instead if unknown / can't render // and use last 4 instead if unknown / can't render
@@ -426,8 +786,6 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
// Pick emblem style // Pick emblem style
if (tooManyHops) if (tooManyHops)
markerSize = getTextWidth("!"); markerSize = getTextWidth("!");
else if (unknownHops)
markerSize = markerSizeMin;
else else
markerSize = map(node->hops_away, 0, config.lora.hop_limit, markerSizeMax, markerSizeMin); markerSize = map(node->hops_away, 0, config.lora.hop_limit, markerSizeMax, markerSizeMin);
@@ -482,27 +840,18 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
if (tooManyHops) if (tooManyHops)
printAt(markerX, markerY, "!", CENTER, MIDDLE); printAt(markerX, markerY, "!", CENTER, MIDDLE);
else else
drawCross(markerX, markerY, markerSize); // The fewer the hops, the larger the marker. Also handles unknownHops drawCross(markerX, markerY, markerSize);
} }
// Check if we actually have enough nodes which would be shown on the map // Check if we actually have enough nodes which would be shown on the map
// Need at least two, to draw a sensible map
bool InkHUD::MapApplet::enoughMarkers() bool InkHUD::MapApplet::enoughMarkers()
{ {
size_t count = 0;
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Count nodes
if (nodeDB->hasValidPosition(node) && shouldDrawNode(node)) if (nodeDB->hasValidPosition(node) && shouldDrawNode(node))
count++; return true;
// We need to find two
if (count == 2)
return true; // Two nodes is enough for a sensible map
} }
return false;
return false; // No nodes would be drawn (or just the one, uselessly at 0,0)
} }
// Calculate how far north and east of map center each node is // Calculate how far north and east of map center each node is
@@ -529,36 +878,30 @@ void InkHUD::MapApplet::calculateAllMarkers()
if (node->num == nodeDB->getNodeNum()) if (node->num == nodeDB->getNodeNum())
continue; continue;
// Skip nodes with unknown hop count — partial info, not useful to plot
if (!node->has_hops_away)
continue;
meshtastic_PositionLite pos; meshtastic_PositionLite pos;
if (!nodeDB->copyNodePosition(node->num, pos)) if (!nodeDB->copyNodePosition(node->num, pos))
continue; continue;
// Calculate marker and store it markers.push_back(calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away));
markers.push_back(calculateMarker(pos.latitude_i * 1e-7, // Lat, converted from Meshtastic's internal int32 style
pos.longitude_i * 1e-7, // Long, converted from Meshtastic's internal int32 style
node->has_hops_away, // Is the hopsAway number valid
node->hops_away // Hops away
));
} }
} }
// Determine the conversion factor between metres, and pixels on screen
// May be overridden by derived applet, if custom scale required (fixed map size?)
void InkHUD::MapApplet::calculateMapScale() void InkHUD::MapApplet::calculateMapScale()
{ {
// Aspect ratio of map and screen if (widthMeters == 0 || heightMeters == 0) {
// - larger = wide, smaller = tall metersToPx = 0;
// - used to set scale, so that widest map dimension fits in applet return;
}
float mapAspectRatio = (float)widthMeters / heightMeters; float mapAspectRatio = (float)widthMeters / heightMeters;
float appletAspectRatio = (float)width() / height(); float appletAspectRatio = (float)width() / height();
// "Shrink to fit"
// Scale the map so that the largest dimension is fully displayed
// Because aspect ratio will be maintained, the other dimension will appear "padded"
if (mapAspectRatio > appletAspectRatio) if (mapAspectRatio > appletAspectRatio)
metersToPx = (float)width() / widthMeters; // Too wide for applet. Constrain to fit width. metersToPx = (float)width() / widthMeters;
else else
metersToPx = (float)height() / heightMeters; // Too tall for applet. Constrain to fit height. metersToPx = (float)height() / heightMeters;
} }
// Draw an x, centered on a specific point // Draw an x, centered on a specific point
@@ -15,10 +15,13 @@ The base applet doesn't handle any events; this is left to the derived applets.
#pragma once #pragma once
#include "configuration.h" #include "configuration.h"
#include <list>
#include "graphics/niche/InkHUD/Applet.h" #include "graphics/niche/InkHUD/Applet.h"
#include "GPSStatus.h"
#include "MeshModule.h" #include "MeshModule.h"
#include "Observer.h"
#include "gps/GeoCoord.h" #include "gps/GeoCoord.h"
namespace NicheGraphics::InkHUD namespace NicheGraphics::InkHUD
@@ -27,33 +30,55 @@ namespace NicheGraphics::InkHUD
class MapApplet : public Applet class MapApplet : public Applet
{ {
public: public:
MapApplet();
void onRender(bool full) override; void onRender(bool full) override;
MapApplet *asMapApplet() override { return this; } // Identify as MapApplet without RTTI
// Zoom lock — shared across all MapApplet instances (static)
static constexpr int ZOOM_MAX_NO_TILES = 16;
void zoomIn();
void zoomOut();
void resetZoom();
bool isZoomLocked() const { return s_zoomLocked; }
bool canZoomIn() const;
bool canZoomOut() const;
protected: protected:
virtual bool shouldDrawNode(meshtastic_NodeInfoLite *node) { return true; } // Allow derived applets to filter the nodes virtual bool shouldDrawNode(meshtastic_NodeInfoLite *node) { return true; } // Allow derived applets to filter the nodes
virtual void getMapCenter(float *lat, float *lng); virtual void getMapCenter(float *lat, float *lng);
virtual void getMapSize(uint32_t *widthMeters, uint32_t *heightMeters); virtual void getMapSize(uint32_t *widthMeters, uint32_t *heightMeters);
bool enoughMarkers(); // Anything to draw? virtual bool enoughMarkers(); // Anything to draw?
void drawLabeledMarker(meshtastic_NodeInfoLite *node); // Highlight a specific marker void drawLabeledMarker(meshtastic_NodeInfoLite *node); // Highlight a specific marker
private: private:
int onGpsStatusUpdate(const meshtastic::Status *status);
CallbackObserver<MapApplet, const meshtastic::Status *> gpsStatusObserver =
CallbackObserver<MapApplet, const meshtastic::Status *>(this, &MapApplet::onGpsStatusUpdate);
static bool s_zoomLocked;
static int s_lockedZoom;
static int s_lastRenderedZoom;
static int s_autoFitZoom; // Zoom chosen by auto-fit (updated whenever not locked)
// Position and size of a marker to be drawn // Position and size of a marker to be drawn
struct Marker { struct Marker {
float eastMeters = 0; // Meters east of map center. Negative if west. float eastMeters = 0; // Meters east of map center. Negative if west.
float northMeters = 0; // Meters north of map center. Negative if south. float northMeters = 0; // Meters north of map center. Negative if south.
bool hasHopsAway = false; uint8_t hopsAway = 0; // Determines marker size
uint8_t hopsAway = 0; // Determines marker size
}; };
Marker calculateMarker(float lat, float lng, bool hasHopsAway, uint8_t hopsAway); Marker calculateMarker(float lat, float lng, uint8_t hopsAway);
void calculateAllMarkers(); void calculateAllMarkers();
void calculateMapScale(); // Conversion factor for meters to pixels void calculateMapScale(); // Conversion factor for meters to pixels
void drawMapTileBackground(int zoom); // Draw georeferenced tile at zoom
void drawCross(int16_t x, int16_t y, uint8_t size); // Draw the X used for most markers void drawCross(int16_t x, int16_t y, uint8_t size); // Draw the X used for most markers
float metersToPx = 0; // Conversion factor for meters to pixels float metersToPx = 0; // Conversion factor for meters to pixels
float latCenter = 0; // Map center: latitude float latCenter = 0; // Map center: latitude
float lngCenter = 0; // Map center: longitude float lngCenter = 0; // Map center: longitude
bool centerIsOurNode = false; // True if map is centered on our own position (GPS or phone)
std::list<Marker> markers; std::list<Marker> markers;
uint32_t widthMeters = 0; // Map width: meters uint32_t widthMeters = 0; // Map width: meters
@@ -0,0 +1,9 @@
#pragma once
#include <stdint.h>
static const int map_tile_count = 0;
static const int map_tile_zooms[] = {};
static const int map_tile_tx[] = {};
static const int map_tile_ty[] = {};
static const int map_tile_sizes[] = {};
static const uint8_t *map_tile_data[] = {};
@@ -130,6 +130,10 @@ enum MenuAction {
RESET_NODEDB_ALL, RESET_NODEDB_ALL,
RESET_NODEDB_KEEP_FAVORITES, RESET_NODEDB_KEEP_FAVORITES,
WIPE_MESSAGES_ALL, WIPE_MESSAGES_ALL,
// Map zoom (MapApplet and FavoritesMapApplet)
MAP_ZOOM_IN,
MAP_ZOOM_OUT,
MAP_ZOOM_RESET,
}; };
} // namespace NicheGraphics::InkHUD } // namespace NicheGraphics::InkHUD
@@ -10,6 +10,7 @@
#include "RTC.h" #include "RTC.h"
#include "Router.h" #include "Router.h"
#include "airtime.h" #include "airtime.h"
#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h"
#include "graphics/niche/Utils/FlashData.h" #include "graphics/niche/Utils/FlashData.h"
#include "main.h" #include "main.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h" #include "mesh/generated/meshtastic/deviceonly.pb.h"
@@ -1021,6 +1022,27 @@ void InkHUD::MenuApplet::execute(MenuItem item)
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true); inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true);
break; break;
case MAP_ZOOM_IN: {
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
if (mapApplet)
mapApplet->zoomIn();
break;
}
case MAP_ZOOM_OUT: {
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
if (mapApplet)
mapApplet->zoomOut();
break;
}
case MAP_ZOOM_RESET: {
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
if (mapApplet)
mapApplet->resetZoom();
break;
}
default: default:
LOG_WARN("Action not implemented"); LOG_WARN("Action not implemented");
} }
@@ -1046,6 +1068,20 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
items.push_back(MenuItem("Next Tile", MenuAction::NEXT_TILE, MenuPage::ROOT)); // Only if multiple applets shown items.push_back(MenuItem("Next Tile", MenuAction::NEXT_TILE, MenuPage::ROOT)); // Only if multiple applets shown
items.push_back(MenuItem("Send", MenuPage::SEND)); items.push_back(MenuItem("Send", MenuPage::SEND));
// Map zoom controls — only when viewing a map applet
{
MapApplet *mapApplet = borrowedTileOwner ? borrowedTileOwner->asMapApplet() : nullptr;
if (mapApplet) {
if (mapApplet->canZoomIn())
items.push_back(MenuItem("Zoom In", MenuAction::MAP_ZOOM_IN, MenuPage::EXIT));
if (mapApplet->canZoomOut())
items.push_back(MenuItem("Zoom Out", MenuAction::MAP_ZOOM_OUT, MenuPage::EXIT));
if (mapApplet->isZoomLocked())
items.push_back(MenuItem("Reset Zoom", MenuAction::MAP_ZOOM_RESET, MenuPage::EXIT));
}
}
items.push_back(MenuItem("Options", MenuPage::OPTIONS)); items.push_back(MenuItem("Options", MenuPage::OPTIONS));
// items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO // items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO
items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG)); items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG));
@@ -2,6 +2,7 @@
#include "./FavoritesMapApplet.h" #include "./FavoritesMapApplet.h"
#include "NodeDB.h" #include "NodeDB.h"
#include "configuration.h"
using namespace NicheGraphics; using namespace NicheGraphics;
@@ -11,6 +12,15 @@ bool InkHUD::FavoritesMapApplet::shouldDrawNode(meshtastic_NodeInfoLite *node)
return node && (node->num == nodeDB->getNodeNum() || nodeInfoLiteIsFavorite(node)); return node && (node->num == nodeDB->getNodeNum() || nodeInfoLiteIsFavorite(node));
} }
// Show map as long as our own node has a position, even with no favorites yet.
bool InkHUD::FavoritesMapApplet::enoughMarkers()
{
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
if (ourNode && nodeDB->hasValidPosition(ourNode))
return true;
return MapApplet::enoughMarkers();
}
void InkHUD::FavoritesMapApplet::onRender(bool full) void InkHUD::FavoritesMapApplet::onRender(bool full)
{ {
// Custom empty state text for favorites-only map. // Custom empty state text for favorites-only map.
@@ -27,7 +27,10 @@ class FavoritesMapApplet : public MapApplet, public SinglePortModule
void onRender(bool full) override; void onRender(bool full) override;
protected: protected:
void onActivate() override { loopbackOk = true; }
void onDeactivate() override { loopbackOk = false; }
bool shouldDrawNode(meshtastic_NodeInfoLite *node) override; bool shouldDrawNode(meshtastic_NodeInfoLite *node) override;
bool enoughMarkers() override;
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
NodeNum lastFrom = 0; // Sender of most recent favorited (non-local) position packet NodeNum lastFrom = 0; // Sender of most recent favorited (non-local) position packet
@@ -27,6 +27,8 @@ class PositionsApplet : public MapApplet, public SinglePortModule
void onRender(bool full) override; void onRender(bool full) override;
protected: protected:
void onActivate() override { loopbackOk = true; }
void onDeactivate() override { loopbackOk = false; }
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
NodeNum lastFrom = 0; // Sender of most recent (non-local) position packet NodeNum lastFrom = 0; // Sender of most recent (non-local) position packet
+14
View File
@@ -254,6 +254,20 @@ You will need to add these lines to any variants which will use your applet.
If you need to create several similar applets, it might make sense to create a reusable base class. Several of these already exist in `src/graphics/niche/InkHUD/Applets/Bases`, but use these with caution, as they may be modified in future. If you need to create several similar applets, it might make sense to create a reusable base class. Several of these already exist in `src/graphics/niche/InkHUD/Applets/Bases`, but use these with caution, as they may be modified in future.
#### Map Applet Base
`MapApplet` (`src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h`) is a base class for applets that plot node positions on a map. It handles tile rendering, zoom control, scale bars, and GPS tracking. `PositionsApplet` and `FavoritesMapApplet` both inherit from it.
##### Map Tiles
Map tiles are stored in `MapTile.h` (`src/graphics/niche/InkHUD/Applets/Bases/Map/MapTile.h`). The file committed to the repository contains no tile data by default — the map applets work without tiles, falling back to the original marker-only display.
Tiles are 256×256 pixels, 1-bit (column-major bit packing), compressed per tile with LZ4 to keep flash usage low.
##### Zoom Controls
When the menu is opened from a map applet, zoom controls appear automatically. Zoom In and Zoom Out step through the available tile zoom levels. Reset Zoom returns to the default auto-fit behavior, where the map scales to show all visible nodes.
#### System Applets #### System Applets
So far, we have been talking about "user applets". We also recognize a separate category of "system applets". These handle things like the menu, and the boot screen. These often need special handling, and need to be implemented manually. So far, we have been talking about "user applets". We also recognize a separate category of "system applets". These handle things like the menu, and the boot screen. These often need special handling, and need to be implemented manually.