diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h index 6b727f273..2aa5bc640 100644 --- a/src/graphics/niche/InkHUD/Applet.h +++ b/src/graphics/niche/InkHUD/Applet.h @@ -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 diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp index 881371e2d..35b0f567e 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp @@ -1,18 +1,367 @@ #ifdef MESHTASTIC_INCLUDE_INKHUD #include "./MapApplet.h" +#include "./MapTile.h" + +#include +#include 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 diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h index 11dfb39d9..fe1ff8d7a 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h @@ -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 #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 gpsStatusObserver = + CallbackObserver(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 markers; uint32_t widthMeters = 0; // Map width: meters diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapTile.h b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapTile.h new file mode 100644 index 000000000..fee0f60f9 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapTile.h @@ -0,0 +1,9 @@ +#pragma once +#include + +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[] = {}; diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index 4c70c434f..e3011b0e2 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -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 diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 288dc0037..2024f17c6 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -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)); diff --git a/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.cpp index 290546a6f..8002219d5 100644 --- a/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.cpp @@ -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. diff --git a/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h b/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h index da5fb0dc3..3f6d3381a 100644 --- a/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h +++ b/src/graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h @@ -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 diff --git a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h index d0d3e5f07..5cb20ed3b 100644 --- a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h +++ b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h @@ -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 diff --git a/src/graphics/niche/InkHUD/docs/README.md b/src/graphics/niche/InkHUD/docs/README.md index 2ebff37de..817b76c3f 100644 --- a/src/graphics/niche/InkHUD/docs/README.md +++ b/src/graphics/niche/InkHUD/docs/README.md @@ -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.