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 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 AppletFont fontSmall, fontMedium, fontLarge; // The general purpose fonts, used by all applets
@@ -1,18 +1,367 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./MapApplet.h"
#include "./MapTile.h"
#include <math.h>
#include <string.h>
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)
{
// Abort if no markers to render
if (!enoughMarkers()) {
// Map center is always the node centroid — tiles are background only.
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), "will appear here", CENTER, MIDDLE);
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
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);
@@ -30,16 +379,10 @@ void InkHUD::MapApplet::onRender(bool full)
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
for (Marker m : markers) {
int16_t x = X(0.5) + (m.eastMeters * metersToPx);
int16_t y = Y(0.5) - (m.northMeters * metersToPx);
int16_t x = X(0.5) + (int16_t)(m.eastMeters * metersToPx);
int16_t y = Y(0.5) - (int16_t)(m.northMeters * metersToPx);
// Add white halo outline first
constexpr int outlinePad = 1;
@@ -57,10 +400,8 @@ void InkHUD::MapApplet::onRender(bool full)
setTextColor(WHITE);
// 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);
} else if (!m.hasHopsAway) {
printAt(x + 1, y + 1, "?", CENTER, MIDDLE);
} else {
char hopStr[4];
snprintf(hopStr, sizeof(hopStr), "%d", m.hopsAway);
@@ -73,6 +414,8 @@ void InkHUD::MapApplet::onRender(bool full)
}
// Dual map scale bars
if (metersToPx <= 0.0f)
return;
int16_t horizPx = width() * 0.25f;
int16_t vertPx = height() * 0.25f;
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);
// Draw our node LAST with full white fill + outline
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
meshtastic_PositionLite ourSelfPos;
if (ourNode && nodeDB->hasValidPosition(ourNode) && nodeDB->copyNodePosition(ourNode->num, ourSelfPos)) {
Marker self = calculateMarker(ourSelfPos.latitude_i * 1e-7, ourSelfPos.longitude_i * 1e-7, false, 0);
if (centerIsOurNode) {
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
meshtastic_PositionLite ourSelfPos;
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 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)) {
*lat = ourSelfPos.latitude_i * 1e-7;
*lng = ourSelfPos.longitude_i * 1e-7;
centerIsOurNode = true;
} else {
centerIsOurNode = false;
// 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
@@ -225,6 +570,8 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
}
// All NodeDB processed, find mean values
if (positionCount == 0)
return;
xAvg /= positionCount;
yAvg /= positionCount;
zAvg /= positionCount;
@@ -278,18 +625,43 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
latCenter = *lat;
lngCenter = *lng;
// ----------------------------------------------
// This has given us either:
// - our actual position (preferred), or
// - a mean position (fallback if we had no fix)
//
// What we actually want is to place our center so that our outermost nodes
// end up on the border of our map. The only real use of our "center" is to give
// us a reference frame: which direction is east, and which is west.
//------------------------------------------------
// When zoom is locked, keep center exactly on own node / zero-hop centroid.
// Skip bounding-box shift so new distant nodes don't move the zoomed view.
if (s_zoomLocked) {
// Own node has no position — re-center on zero-hop centroid instead.
if (!centerIsOurNode) {
uint32_t count = 0;
float xAvg = 0, yAvg = 0, zAvg = 0;
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 southernmost = latCenter;
float easternmost = lngCenter;
@@ -298,11 +670,8 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Skip if no position
if (!nodeDB->hasValidPosition(node))
continue;
// Skip if derived applet doesn't want to show this node on the map
if (!shouldDrawNode(node))
continue;
@@ -310,15 +679,14 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
if (!nodeDB->copyNodePosition(node->num, pos))
continue;
// Check for a new top or bottom latitude
float latNode = pos.latitude_i * 1e-7;
float lngNode = pos.longitude_i * 1e-7;
northernmost = max(northernmost, latNode);
southernmost = min(southernmost, latNode);
// Longitude is trickier
float lngNode = pos.longitude_i * 1e-7;
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
float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees east from center to node
float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees west from center to node
if (degEastward < degWestward)
easternmost = max(easternmost, lngCenter + degEastward);
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
// Lat / long to "meters relative to map center", for position on screen
// 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.
@@ -369,11 +737,9 @@ InkHUD::MapApplet::Marker InkHUD::MapApplet::calculateMarker(float lat, float ln
float northMeters = cos(bearingFromCenter) * distanceFromCenter;
float eastMeters = sin(bearingFromCenter) * distanceFromCenter;
// Store this as a new marker
Marker m;
m.eastMeters = eastMeters;
m.northMeters = northMeters;
m.hasHopsAway = hasHopsAway;
m.hopsAway = hopsAway;
return m;
}
@@ -385,11 +751,7 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
meshtastic_PositionLite pos;
const bool hasPos = nodeDB->copyNodePosition(node->num, pos);
assert(hasPos);
Marker m = 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
);
Marker m = calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away);
// Convert to pixel coords
int16_t markerX = X(0.5) + (m.eastMeters * metersToPx);
@@ -412,8 +774,6 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
uint8_t markerSize;
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,
// and use last 4 instead if unknown / can't render
@@ -426,8 +786,6 @@ void InkHUD::MapApplet::drawLabeledMarker(meshtastic_NodeInfoLite *node)
// Pick emblem style
if (tooManyHops)
markerSize = getTextWidth("!");
else if (unknownHops)
markerSize = markerSizeMin;
else
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)
printAt(markerX, markerY, "!", CENTER, MIDDLE);
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
// Need at least two, to draw a sensible map
bool InkHUD::MapApplet::enoughMarkers()
{
size_t count = 0;
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Count nodes
if (nodeDB->hasValidPosition(node) && shouldDrawNode(node))
count++;
// We need to find two
if (count == 2)
return true; // Two nodes is enough for a sensible map
return true;
}
return false; // No nodes would be drawn (or just the one, uselessly at 0,0)
return false;
}
// 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())
continue;
// Skip nodes with unknown hop count — partial info, not useful to plot
if (!node->has_hops_away)
continue;
meshtastic_PositionLite pos;
if (!nodeDB->copyNodePosition(node->num, pos))
continue;
// Calculate marker and store it
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
));
markers.push_back(calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->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()
{
// Aspect ratio of map and screen
// - larger = wide, smaller = tall
// - used to set scale, so that widest map dimension fits in applet
if (widthMeters == 0 || heightMeters == 0) {
metersToPx = 0;
return;
}
float mapAspectRatio = (float)widthMeters / heightMeters;
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)
metersToPx = (float)width() / widthMeters; // Too wide for applet. Constrain to fit width.
metersToPx = (float)width() / widthMeters;
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
@@ -15,10 +15,13 @@ The base applet doesn't handle any events; this is left to the derived applets.
#pragma once
#include "configuration.h"
#include <list>
#include "graphics/niche/InkHUD/Applet.h"
#include "GPSStatus.h"
#include "MeshModule.h"
#include "Observer.h"
#include "gps/GeoCoord.h"
namespace NicheGraphics::InkHUD
@@ -27,33 +30,55 @@ namespace NicheGraphics::InkHUD
class MapApplet : public Applet
{
public:
MapApplet();
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:
virtual bool shouldDrawNode(meshtastic_NodeInfoLite *node) { return true; } // Allow derived applets to filter the nodes
virtual void getMapCenter(float *lat, float *lng);
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
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
struct Marker {
float eastMeters = 0; // Meters east of map center. Negative if west.
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 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
float metersToPx = 0; // Conversion factor for meters to pixels
float latCenter = 0; // Map center: latitude
float lngCenter = 0; // Map center: longitude
float metersToPx = 0; // Conversion factor for meters to pixels
float latCenter = 0; // Map center: latitude
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;
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_KEEP_FAVORITES,
WIPE_MESSAGES_ALL,
// Map zoom (MapApplet and FavoritesMapApplet)
MAP_ZOOM_IN,
MAP_ZOOM_OUT,
MAP_ZOOM_RESET,
};
} // namespace NicheGraphics::InkHUD
@@ -10,6 +10,7 @@
#include "RTC.h"
#include "Router.h"
#include "airtime.h"
#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h"
#include "graphics/niche/Utils/FlashData.h"
#include "main.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);
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:
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("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("Display Off", MenuPage::EXIT)); // TODO
items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG));
@@ -2,6 +2,7 @@
#include "./FavoritesMapApplet.h"
#include "NodeDB.h"
#include "configuration.h"
using namespace NicheGraphics;
@@ -11,6 +12,15 @@ bool InkHUD::FavoritesMapApplet::shouldDrawNode(meshtastic_NodeInfoLite *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)
{
// Custom empty state text for favorites-only map.
@@ -27,7 +27,10 @@ class FavoritesMapApplet : public MapApplet, public SinglePortModule
void onRender(bool full) override;
protected:
void onActivate() override { loopbackOk = true; }
void onDeactivate() override { loopbackOk = false; }
bool shouldDrawNode(meshtastic_NodeInfoLite *node) override;
bool enoughMarkers() override;
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
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;
protected:
void onActivate() override { loopbackOk = true; }
void onDeactivate() override { loopbackOk = false; }
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
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.
#### 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
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.