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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user