feat(map): base layers - Topo, Light, Dark, Satellite, Esri Topo

Map tools > Layers > Map picks what the map is drawn from:
- Topo: OpenTopoMap, as before.
- Light and Dark: Esri's grey canvases, no labels, up to z16.
- Satellite: Esri World Imagery.
- Esri Topo.

Each layer has its own folder for downloaded areas (/sdcard/maps-light,
-dark, -sat, -esritopo; Topo stays in /sdcard/maps) and its own live-tile
cache. Live-cache records carry the layer.

Esri serves JPEG: decoded with LVGL's tjpgd (LV_USE_TJPGD), whatever the
file name says. The layer is kept in NVS (mc_ui/layer); switching waits
while a download or a deletion runs in the current folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-14 17:25:16 +02:00
co-authored by Claude Opus 5.5
parent 85ef06b6be
commit b4ec5dcb54
12 changed files with 177 additions and 26 deletions
@@ -27,6 +27,7 @@ public:
enum : uint8_t { F_TRAILS = 1, F_COMPLETE = 2 };
explicit AreaStore(const char* root) : _root(root) {}
void setRoot(const char* root) { if (_root != root) { _root = root; _loaded = false; } } // another base layer
int count() { load(); return _n; }
MapArea& at(int i) { load(); return _a[i]; }
@@ -1,6 +1,7 @@
#pragma once
// Live map tiles (fetched while browsing online) live apart from downloaded
// maps, under LIVE_ROOT/{z}/{x}/{y}.png, and only up to a size limit
// maps, under LIVE_ROOT/{z}/{x}/{y}.png (another base layer: a folder of
// its own in it, MapLayers.h), and only up to a size limit
// (Settings > Storage): an index remembers the order they came in, and the
// oldest are deleted once the tiles add up to more than the limit. Maps
// downloaded on purpose (TileDownloader jobs, the PC tool) are never touched.
@@ -34,11 +35,11 @@ public:
uint32_t count() { load(); return _h.count; }
bool clearing() const { return _clearing; }
// A tile was written to LIVE_ROOT: remember it (eviction follows in service()).
void add(int z, int x, int y, uint32_t size) {
// A tile was written to `layer`'s live folder: remember it (eviction follows in service()).
void add(int z, int x, int y, uint32_t size, uint8_t layer) {
load();
if (_h.count >= RING) evictOne(); // the ring is full: make room right away
Rec r = { (int32_t)z, x, y, size };
Rec r = { (int32_t)z | (int32_t)layer << 8, x, y, size }; // (records from before layers: 0, Topo)
uint32_t slot = (_h.head + _h.count) % RING;
_h.count++;
_h.bytes += size;
@@ -63,8 +64,8 @@ public:
if (_clearing && _h.count == 0) { _clearing = false; _h.bytes = 0; writeHdr(); }
}
static void tilePath(char* out, size_t n, int z, int x, int y) {
snprintf(out, n, "%s/%d/%d/%d.png", LIVE_ROOT, z, x, y);
static void tilePath(char* out, size_t n, int z, int x, int y, uint8_t layer) {
snprintf(out, n, "%s/%d/%d/%d.png", mapview::layer(layer).live, z, x, y);
}
private:
@@ -124,7 +125,7 @@ private:
Rec r;
if (readRec(_h.head, r)) {
char path[128];
tilePath(path, sizeof(path), r.z, r.x, r.y);
tilePath(path, sizeof(path), r.z & 0xFF, r.x, r.y, (uint8_t)(r.z >> 8));
remove(path);
_h.bytes = _h.bytes > r.size ? _h.bytes - r.size : 0;
}
@@ -0,0 +1,50 @@
#pragma once
// Base map layers (Map tools > Map): the tile servers the map can show, each
// in a folder of its own on the card -- its downloaded areas, its job, its
// attribution -- and its live tiles in a folder of their own under LIVE_ROOT
// (LiveCache.h; one size limit for them all). Topo is the original layout:
// /sdcard/maps and the top of /sdcard/maps-live, with <root>/source.txt still
// able to name another server (TileDownloader.h).
//
// Tiles keep the .png name whatever the server sends: the Esri layers are
// JPEG (all but Topo), told apart by their first bytes when decoded (TileProvider.h).
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only.
namespace mapview {
struct MapLayer {
const char* name; // in Map tools
const char* root; // downloaded areas
const char* live; // live tiles (LIVE_ROOT or a folder in it)
const char* url; // {z} {x} {y}
const char* attr;
int8_t zmax; // the server's last zoom level
};
static const MapLayer LAYERS[] = {
{ "Topo", "/sdcard/maps", "/sdcard/maps-live",
"https://a.tile.opentopomap.org/{z}/{x}/{y}.png",
"\xC2\xA9 OpenStreetMap contributors, SRTM | \xC2\xA9 OpenTopoMap (CC-BY-SA)", 17 },
// Monochrome: Esri's grey canvases (CARTO's Positron / Dark Matter now want
// an API key). Nothing past z16: the server answers "Map data not yet available".
{ "Light", "/sdcard/maps-light", "/sdcard/maps-live/light",
"https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}",
"\xC2\xA9 Esri, HERE, Garmin, \xC2\xA9 OpenStreetMap contributors", 16 },
{ "Dark", "/sdcard/maps-dark", "/sdcard/maps-live/dark",
"https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}",
"\xC2\xA9 Esri, HERE, Garmin, \xC2\xA9 OpenStreetMap contributors", 16 },
{ "Satellite", "/sdcard/maps-sat", "/sdcard/maps-live/sat",
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
"Imagery \xC2\xA9 Esri, Maxar, Earthstar Geographics", 19 },
{ "Esri Topo", "/sdcard/maps-esritopo", "/sdcard/maps-live/esritopo",
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
"\xC2\xA9 Esri, HERE, Garmin, \xC2\xA9 OpenStreetMap contributors", 19 },
};
static const int LAYER_COUNT = sizeof(LAYERS) / sizeof(LAYERS[0]);
static const char* const LAYER_OPTS = "Topo\nLight\nDark\nSatellite\nEsri Topo"; // as LAYERS
static uint8_t s_layer = 0; // the one shown (lvport::mapLayer())
static const MapLayer& layer(int i = -1) { return LAYERS[i < 0 || i >= LAYER_COUNT ? s_layer : i]; }
} // namespace mapview
@@ -12,9 +12,10 @@
// itself runs asynchronously in the board's fetcher (lvport::fetch*), so the
// mesh keeps being serviced during a long download.
//
// The server comes from <root>/source.txt (line 1: URL template with {z} {x}
// {y}, line 2: attribution, optional line 3: the server's last zoom level,
// default 19 -- or 17 for OpenTopoMap); without it, OpenTopoMap. Whatever the source,
// The server is the base layer's (MapLayers.h; setLayer()); <root>/source.txt
// can name another (line 1: URL template with {z} {x} {y}, line 2:
// attribution, optional line 3: the server's last zoom level, default 19 --
// or 17 for OpenTopoMap). Whatever the source,
// its attribution is written to <root>/attribution.txt for the map to show.
//
// Hiking trails (s_trails_on): each tile's trails overlay is fetched right
@@ -37,8 +38,6 @@
namespace mapview {
static const char* const DEFAULT_TILE_URL = "https://a.tile.opentopomap.org/{z}/{x}/{y}.png";
static const char* const DEFAULT_TILE_ATTR = "\xC2\xA9 OpenStreetMap contributors, SRTM | \xC2\xA9 OpenTopoMap (CC-BY-SA)";
struct TileArea {
double lon0, lat0, lon1, lat1; // west, south, east, north
@@ -92,6 +91,16 @@ public:
static const int RETRY_ROUNDS = 3; // goes at the tiles that failed, after the first pass
explicit TileDownloader(const char* root) : _root(root) {}
// Another base layer: its folder, its server. Not while a job runs (the caller checks).
void setLayer(uint8_t layer) {
if (active()) return;
if (_live) liveEnd(); // the caller starts it again, for the new folder
_layer_id = layer;
_root = mapview::layer(layer).root;
_job_checked = false; // its own unfinished job
_lv_failed_next = 0;
for (LiveTile& t : _lv_failed) t = LiveTile();
}
State state() const { return _state; }
const TileArea& area() const { return _area; } // of the current / last job
@@ -232,7 +241,7 @@ public:
if (r > 0) {
size_t len = 0;
const uint8_t* data = lvport::fetchData(len);
if (!looksLikeImage(data, len)) reason = "server didn't send a PNG tile"; // an error page: passing too
if (!looksLikeImage(data, len)) reason = "server didn't send a tile"; // an error page: passing too
else {
if (_layer == 1) { // trails: the overlay, or an empty marker
if (!writeTrails(_z, _x, _y, data, len)) { lvport::fetchRelease(); finish(FAILED, "Can't write to the SD card"); return; }
@@ -317,6 +326,7 @@ public:
private:
const char* _root;
uint8_t _layer_id = 0; // the base layer (MapLayers.h)
State _state = IDLE;
TileArea _area = {0, 0, 0, 0, 0, 0};
uint32_t _total = 0, _done = 0, _skipped = 0, _failed = 0;
@@ -416,8 +426,8 @@ private:
_lv_fetching = false;
size_t len = 0;
const uint8_t* data = r > 0 ? lvport::fetchData(len) : nullptr;
char lpath[64];
LiveCache::tilePath(lpath, sizeof(lpath), _lv_cur.z, _lv_cur.x, _lv_cur.y);
char lpath[80];
LiveCache::tilePath(lpath, sizeof(lpath), _lv_cur.z, _lv_cur.x, _lv_cur.y, _layer_id);
if (_lv_cur.layer == 1) { // trails over a tile already shown
if (r > 0 && looksLikeImage(data, len) && writeTrails(_lv_cur.z, _lv_cur.x, _lv_cur.y, data, len)) {
_lv_consec_fail = 0;
@@ -426,7 +436,7 @@ private:
liveFailed(_lv_cur);
}
} else if (r > 0 && looksLikeImage(data, len) && !isNoTilePicture(data, len) && writeFile(lpath, data, len)) {
s_live_cache.add(_lv_cur.z, _lv_cur.x, _lv_cur.y, (uint32_t)len); // kept apart, within the Storage limit
s_live_cache.add(_lv_cur.z, _lv_cur.x, _lv_cur.y, (uint32_t)len, _layer_id); // kept apart, within the Storage limit
_lv_consec_fail = 0;
if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur;
} else {
@@ -560,14 +570,15 @@ private:
}
void loadSource() {
snprintf(_url_tpl, sizeof(_url_tpl), "%s", DEFAULT_TILE_URL);
snprintf(_attr, sizeof(_attr), "%s", DEFAULT_TILE_ATTR);
_src_zmax = -1;
const MapLayer& L = mapview::layer(_layer_id);
snprintf(_url_tpl, sizeof(_url_tpl), "%s", L.url);
snprintf(_attr, sizeof(_attr), "%s", L.attr);
_src_zmax = L.zmax;
char path[64];
snprintf(path, sizeof(path), "%s/source.txt", _root);
if (FILE* f = fopen(path, "r")) {
char line[256];
if (fgets(line, sizeof(line), f)) { trim(line); if (strstr(line, "{z}")) snprintf(_url_tpl, sizeof(_url_tpl), "%s", line); }
if (fgets(line, sizeof(line), f)) { trim(line); if (strstr(line, "{z}")) { snprintf(_url_tpl, sizeof(_url_tpl), "%s", line); _src_zmax = -1; } }
if (fgets(line, sizeof(line), f)) { trim(line); if (line[0]) snprintf(_attr, sizeof(_attr), "%s", line); }
if (fgets(line, sizeof(line), f)) { int z = atoi(line); if (z >= 1 && z <= 22) _src_zmax = z; }
fclose(f);
@@ -593,9 +604,9 @@ private:
if (FILE* f = fopen(path, "w")) { fprintf(f, "%s\n", _attr); fclose(f); }
}
// PNG only for now: RasterTileProvider has no JPEG decoder yet.
// PNG, or JPEG (the Esri layers; RasterTileProvider tells them apart).
static bool looksLikeImage(const uint8_t* d, size_t n) {
return d && n >= 8 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G';
return d && n >= 8 && ((d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G') || isJpeg(d, n));
}
// A blocked / key-required server answers every tile with the same picture:
@@ -14,6 +14,7 @@
#define LODEPNG_NO_COMPILE_CPP
extern "C" {
#include "src/libs/lodepng/lodepng.h"
#include "src/libs/tjpgd/tjpgd.h" // LVGL's TJpgDec (LV_USE_TJPGD), for JPEG tiles
}
#if defined(ESP32)
@@ -92,6 +93,41 @@ static bool pngHasInk(const uint8_t* png, size_t len) {
return ink;
}
// A JPEG tile (the Esri layers) into out[TILE_PX * TILE_PX] RGB565, with
// LVGL's TJpgDec: RGB888 out of it a block at a time. One decode at a time
// (the tile cache's), so one work pool.
static bool isJpeg(const uint8_t* d, size_t n) { return n > 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF; }
struct JpegSrc { const uint8_t* d; size_t n, at; uint16_t* out; };
static size_t jpegIn(JDEC* jd, uint8_t* buf, size_t n) {
JpegSrc* s = (JpegSrc*)jd->device;
if (n > s->n - s->at) n = s->n - s->at;
if (buf) memcpy(buf, s->d + s->at, n);
s->at += n;
return n;
}
static int jpegOut(JDEC* jd, void* bitmap, JRECT* r) {
JpegSrc* s = (JpegSrc*)jd->device;
const uint8_t* p = (const uint8_t*)bitmap;
const int w = r->right - r->left + 1;
for (int y = r->top; y <= r->bottom; y++) {
if (y >= TILE_PX) { p += w * 3; continue; }
uint16_t* o = s->out + y * TILE_PX + r->left;
for (int x = 0; x < w; x++, p += 3)
if (r->left + x < TILE_PX) o[x] = (uint16_t)(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3));
}
return 1;
}
static bool decodeJpeg(const uint8_t* d, size_t n, uint16_t* out) {
static const size_t POOL = 8192;
static void* pool = nullptr;
if (!pool && !(pool = lv_malloc(POOL))) return false;
JDEC jd;
JpegSrc src = { d, n, 0, out };
if (jd_prepare(&jd, jpegIn, pool, POOL, &src) != JDR_OK) return false;
if (jd.width != TILE_PX || jd.height != TILE_PX) return false;
return jd_decomp(&jd, jpegOut, 0) == JDR_OK;
}
class TileProvider {
public:
virtual ~TileProvider() {}
@@ -111,6 +147,7 @@ public:
// `live`: a second folder in the same layout (live tiles, LiveCache.h), read
// when <root> has no tile.
explicit RasterTileProvider(const char* root, const char* live = nullptr) : _root(root), _live(live) {}
void setRoots(const char* root, const char* live) { _root = root; _live = live; } // another base layer
// Also reads <root>/attribution.txt (written by tools/maps/fetch_tiles.py and
// the on-device downloader): the tile set's credit line, shown on the map.
@@ -148,8 +185,12 @@ public:
#endif
bool ok = false;
if (isJpeg(png, len)) { // (under the .png name: MapLayers.h)
ok = decodeJpeg(png, len, out);
if (!ok) { lv_free(png); return false; }
}
#if defined(ESP32)
ok = pngdecRgb565(png, len, out, TILE_PX); // lodepng below if it can't
if (!ok) ok = pngdecRgb565(png, len, out, TILE_PX); // lodepng below if it can't
#endif
if (!ok) {
lv_draw_buf_t* db = decodePng(png, len);
@@ -180,7 +221,7 @@ public:
bool overlayMissed() const override { return _overlay_missed; }
const char* attribution() const override {
const char* base = _attr[0] ? _attr : "\xC2\xA9 OpenStreetMap contributors";
const char* base = _attr[0] ? _attr : layer().attr; // nothing on the card yet: the layer's own
if (!s_trails_on) return base;
snprintf(_attr_full, sizeof(_attr_full), "%s | %s", base, TRAILS_ATTR);
return _attr_full;