From b4ec5dcb54365a1d6adb880915e983141595c670 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:25:16 +0200 Subject: [PATCH] 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 --- docs/solo/navigation.md | 7 +++ examples/companion_radio/ui-lvgl/LvglPort.h | 3 ++ examples/companion_radio/ui-lvgl/MapScreen.h | 29 ++++++++++- examples/companion_radio/ui-lvgl/NavMap.h | 6 +++ examples/companion_radio/ui-lvgl/UITask.cpp | 4 +- examples/companion_radio/ui-lvgl/UITask.h | 1 + examples/companion_radio/ui-lvgl/lv_conf.h | 1 + .../companion_radio/ui-lvgl/map/AreaStore.h | 1 + .../companion_radio/ui-lvgl/map/LiveCache.h | 15 +++--- .../companion_radio/ui-lvgl/map/MapLayers.h | 50 +++++++++++++++++++ .../ui-lvgl/map/TileDownloader.h | 41 +++++++++------ .../ui-lvgl/map/TileProvider.h | 45 ++++++++++++++++- 12 files changed, 177 insertions(+), 26 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/map/MapLayers.h diff --git a/docs/solo/navigation.md b/docs/solo/navigation.md index 63f7b828..c62997e3 100644 --- a/docs/solo/navigation.md +++ b/docs/solo/navigation.md @@ -127,6 +127,13 @@ it: GPS, the trail's distance, live shares, and the distance to the target > Downloaded areas can be renamed, refreshed or deleted. > - **Live tiles**: with WiFi on, tiles for where you look are fetched and > cached, up to the size set in Settings › Storage. +> - **Map** (Map tools › Layers): what the map is drawn from. **Topo** +> (OpenTopoMap, the default), **Light** and **Dark** (Esri's grey +> canvases, no labels, up to zoom 16), **Satellite** (Esri World Imagery) +> and **Esri Topo**. Each keeps its own downloaded areas and live tiles +> (`/sdcard/maps-light`, `-dark`, `-sat`, `-esritopo`; Topo stays in +> `/sdcard/maps`). The Esri layers are meant for personal use: their terms +> don't cover downloading large areas. > - **Vector regions** (only in a build with `-D MAP_VECTOR`, unfinished): > whole regions as packs made with `tools/maps/osm_vector.py`, copied to > the card. diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index cc7e4ca1..91aaea96 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -993,6 +993,9 @@ static void setLiveTiles(bool on) { nvs::putBool("mc_wifi", "live", on); } // Map tools > Hiking trails: the Waymarked Trails overlay over the map. static bool trailsOn() { return nvs::getBool("mc_ui", "trails", false); } static void setTrailsOn(bool on) { nvs::putBool("mc_ui", "trails", on); } +// Map tools > Map: the base layer (map/MapLayers.h). +static int mapLayer() { return nvs::getU8("mc_ui", "layer", 0); } +static void setMapLayer(int i) { nvs::putU8("mc_ui", "layer", i); } // Map tools > Vector map (test). Unfinished: on only in a -D MAP_VECTOR build, // off (and its switch gone) otherwise, whatever was saved. #ifdef MAP_VECTOR diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index b1d7b118..436cbc81 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -32,6 +32,20 @@ static TileProvider* s_provider = &s_raster; // Map tools > Vector map (t static TileCache& s_cache = *new (psramBuf(1)) TileCache(); // decoded tiles, in PSRAM static PSRAM_BSS TileDownloader s_dl("/sdcard/maps"); static PSRAM_BSS AreaStore s_areas("/sdcard/maps"); + +// The base layer `i` (MapLayers.h) for everything that reads or writes tiles; +// false while a download or an area's deletion works in the current one's folder. +static bool applyLayer(int i) { + if (i < 0 || i >= LAYER_COUNT) i = 0; + if (i == s_layer) return true; + if (s_dl.active() || s_areas.deleting()) return false; + s_layer = (uint8_t)i; + s_raster.setRoots(LAYERS[i].root, LAYERS[i].live); + s_dl.setLayer(s_layer); + s_areas.setRoot(LAYERS[i].root); + s_cache.invalidate(); + return true; +} static const uint32_t AVG_TILE_BYTES = 22 * 1024; // OpenTopoMap-ish, for the size estimate // Hand a tile to the decoder (on the other core); false while one is in flight. @@ -720,6 +734,7 @@ void UITask::openMap(bool nav) { void UITask::showMap(bool frame) { _screen = SCR_MAP; mapview::s_trails_on = lvport::trailsOn(); + mapview::applyLayer(lvport::mapLayer()); // (a download running in another: that one, until it's done) mapview::s_provider = lvport::vectorOn() ? (mapview::TileProvider*)&mapview::s_vector : &mapview::s_raster; mapLiveBegin(); mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); @@ -983,7 +998,8 @@ void UITask::layoutMap() { (unsigned long)ld, (unsigned long)fl, (unsigned long)ln); } else setTextFmt(_map_zoom_lbl, "z%d", _map_z); - const char* hint = !have_provider ? "No map on the SD card.\nPut tiles in /maps (tools/maps)." + const char* hint = !have_provider ? (mapview::s_layer ? "Nothing of this map on the card -\ndownload an area or turn Live tiles on" + : "No map on the SD card.\nPut tiles in /maps (tools/maps).") : mapview::s_dl.liveQueued() > 0 ? nullptr // being fetched : (!_map_pending && shown == 0 && missing > 0) ? "No map here at this zoom -\nzoom out or download the area" : nullptr; @@ -1585,6 +1601,17 @@ void UITask::setTrails(bool on) { else showToast("Download an area again to add its trails", 3000); } +// Map tools > Map: another base layer, its tiles from its own folder. +void UITask::setMapLayer(int i) { + if (!mapview::applyLayer(i)) { showToast("Wait for the download to finish"); return; } + lvport::setMapLayer(i); + mapLiveBegin(); // live tiles: from the new layer's server + mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); + if (_screen == SCR_MAP) layoutMap(); + if (!lvport::liveTiles() || !lvport::wifiAllowed()) + showToast("Shows what is downloaded for this map - or turn Live tiles on", 3000); +} + // Map tools > Vector map (test): /sdcard/vmap drawn on the device where it has data. void UITask::setVectorMap(bool on) { lvport::setVectorOn(on); diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index 4a793c44..1f7a7dad 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -880,6 +880,11 @@ static void scanTrails() { static void onSavedTrail(lv_event_t* e) { s_ui->savedTrailPopup((int)(intptr_t)lv_event_get_user_data(e)); } static void onNavTools(lv_event_t* e) { (void)e; s_ui->navToolsPopup(); } +static void onMapLayer(lv_event_t* e) { // the map shows it at once: Map tools out of the way + int i = choiceSelected((lv_obj_t*)lv_event_get_target(e)); + s_ui->navClosePopup(); + s_ui->setMapLayer(i); +} static void onTrails(lv_event_t* e) { s_ui->setTrails(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); } @@ -976,6 +981,7 @@ void UITask::navToolsPopup(bool back) { lv_obj_add_event_cb(lsw, onLiveTiles, LV_EVENT_VALUE_CHANGED, NULL); g = group(list, "LAYERS"); + choiceRow(g, "Map", "Topo, light, dark or satellite", mapview::LAYER_OPTS, mapview::s_layer, onMapLayer); lv_obj_t* tsw = switchRow(g, "Hiking trails", "Marked routes in their colours", nullptr); if (lvport::trailsOn()) lv_obj_add_state(tsw, LV_STATE_CHECKED); lv_obj_add_event_cb(tsw, onTrails, LV_EVENT_VALUE_CHANGED, NULL); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 28280aa0..cc27a71b 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -95,6 +95,7 @@ static bool s_chats_stale = false; // Messages: a refresh waits for a held row #include "LvglPort.h" #include "lv_mem_psram.h" #include "HistoryStore.h" +#include "map/MapLayers.h" #include "map/LiveCache.h" static histstore::SdArchive s_archive; // message history on the SD card namespace storeview { static void stopWalk(); } // StorageScreen.h @@ -1429,7 +1430,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_scroll(int dy) { #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) // The simulator page opens a screen by name (screenshots of the layout): // home, chats, contacts, settings, settings:, nearby, map, navmap, places, clock, radio, -// gps, diag, compass, storage, repeater, wifi, bot, quick, node, channel. +// gps, diag, compass, storage, repeater, wifi, bot, quick, node, channel, layer:. extern "C" EMSCRIPTEN_KEEPALIVE int sim_ui_show(const char* name) { if (!s_ui || !name) return 0; UITask& u = *s_ui; @@ -1442,6 +1443,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_ui_show(const char* name) { else if (!strcmp(name, "map")) u.openMap(false); else if (!strcmp(name, "navmap")) u.openMap(true); else if (!strcmp(name, "places")) { u.openMap(true); u.navTargetsPopup(); } + else if (!strncmp(name, "layer:", 6)) u.setMapLayer(atoi(name + 6)); // the map's base layer (MapLayers.h) else if (!strcmp(name, "clock")) u.showClock(); else if (!strcmp(name, "radio")) u.showRadio(); else if (!strcmp(name, "gps")) u.showGps(false); diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 75a16bc8..63787762 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -169,6 +169,7 @@ public: void setLiveTiles(bool on); void setTrails(bool on); void setVectorMap(bool on); + void setMapLayer(int i); void simMapAt(const char* spec); // Map tools > Live tiles void simCompose(const char* text); // the open thread's field: this text, keyboard up void simDm(); // the first chat contact's thread diff --git a/examples/companion_radio/ui-lvgl/lv_conf.h b/examples/companion_radio/ui-lvgl/lv_conf.h index ccd73f93..19c2c54c 100644 --- a/examples/companion_radio/ui-lvgl/lv_conf.h +++ b/examples/companion_radio/ui-lvgl/lv_conf.h @@ -49,6 +49,7 @@ // PNG decoder for raster map tiles: the sim's, and the fallback on the L2 behind // PNGdec (ui-lvgl/TilePng.cpp). map/TileProvider.h calls lodepng directly. #define LV_USE_LODEPNG 1 +#define LV_USE_TJPGD 1 // map tiles: the Esri layers are JPEG (map/TileProvider.h) // Spans: message text with @[nick] mentions picked out (UITask.cpp msgText). #define LV_USE_SPAN 1 diff --git a/examples/companion_radio/ui-lvgl/map/AreaStore.h b/examples/companion_radio/ui-lvgl/map/AreaStore.h index b9a997c5..e687eb59 100644 --- a/examples/companion_radio/ui-lvgl/map/AreaStore.h +++ b/examples/companion_radio/ui-lvgl/map/AreaStore.h @@ -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]; } diff --git a/examples/companion_radio/ui-lvgl/map/LiveCache.h b/examples/companion_radio/ui-lvgl/map/LiveCache.h index 9ba21a16..38079265 100644 --- a/examples/companion_radio/ui-lvgl/map/LiveCache.h +++ b/examples/companion_radio/ui-lvgl/map/LiveCache.h @@ -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; } diff --git a/examples/companion_radio/ui-lvgl/map/MapLayers.h b/examples/companion_radio/ui-lvgl/map/MapLayers.h new file mode 100644 index 00000000..a4e78fd3 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/map/MapLayers.h @@ -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 /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 diff --git a/examples/companion_radio/ui-lvgl/map/TileDownloader.h b/examples/companion_radio/ui-lvgl/map/TileDownloader.h index 1931191f..7c71dbba 100644 --- a/examples/companion_radio/ui-lvgl/map/TileDownloader.h +++ b/examples/companion_radio/ui-lvgl/map/TileDownloader.h @@ -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 /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()); /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 /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: diff --git a/examples/companion_radio/ui-lvgl/map/TileProvider.h b/examples/companion_radio/ui-lvgl/map/TileProvider.h index b589e91d..ab3f52f8 100644 --- a/examples/companion_radio/ui-lvgl/map/TileProvider.h +++ b/examples/companion_radio/ui-lvgl/map/TileProvider.h @@ -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 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 /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;