From ad890a49f6e17447ec65cba39623f6bf3f02c6c6 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:32:00 +0200 Subject: [PATCH] feat(ui-lvgl): hiking trails overlay on the map Waymarked Trails' transparent hiking layer, kept in /sdcard/maps-trails and blended over the base tile at render time (Map tools > Hiking trails, NVS). An area download fetches each tile's trails after its base tile, so re-running an area on the card adds just the trails; with live tiles on, a shown tile missing its trails has them fetched. Tiles without a trail are stored as 0-byte files (no cluster on FAT). Co-Authored-By: Claude Opus 5.5 --- docs/development/l2-roadmap.md | 10 ++- examples/companion_radio/ui-lvgl/HomeScreen.h | 1 + examples/companion_radio/ui-lvgl/LvglPort.h | 6 ++ examples/companion_radio/ui-lvgl/MapScreen.h | 28 +++++- examples/companion_radio/ui-lvgl/NavMap.h | 6 ++ .../companion_radio/ui-lvgl/StorageScreen.h | 2 +- examples/companion_radio/ui-lvgl/UITask.h | 3 +- .../companion_radio/ui-lvgl/map/TileCache.h | 6 ++ .../ui-lvgl/map/TileDownloader.h | 71 +++++++++++---- .../ui-lvgl/map/TileProvider.h | 87 +++++++++++++++++-- 10 files changed, 188 insertions(+), 32 deletions(-) diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index 45bfa152..0cef2808 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -241,7 +241,15 @@ Ideas to come back to (2026-09-26): Live map tiles: 16 / 64 / 256 MB / 1 GB, default 64 MB; Delete). Live tiles saved by earlier builds sit in /sdcard/maps and can't be told apart from downloaded ones. -- [ ] Vector maps and other tile sources (to think through). +- [x] Hiking trails: Waymarked Trails overlay in /sdcard/maps-trails, drawn + over the base map (Map tools > Hiking trails); fetched with an area + download (re-running one adds just the trails) and live; empty tiles + as 0-byte files. +- [ ] Area manager: named list of downloaded areas (regenerate, delete), + a draggable frame to pick an area instead of the visible screen. +- [ ] A grid (scale) under the map where there are no tiles. +- [ ] Vector maps: spike first (own compact format from OSM, roads / trails + / water / forest, no labels; measure render time). User list, second batch (2026-09-26): diff --git a/examples/companion_radio/ui-lvgl/HomeScreen.h b/examples/companion_radio/ui-lvgl/HomeScreen.h index 0070fa02..057542d3 100644 --- a/examples/companion_radio/ui-lvgl/HomeScreen.h +++ b/examples/companion_radio/ui-lvgl/HomeScreen.h @@ -397,6 +397,7 @@ void UITask::buildHomeMap(lv_obj_t* box) { lv_obj_set_style_pad_ver(s_caption, 2, 0); lv_obj_align(s_caption, LV_ALIGN_BOTTOM_LEFT, 6, -6); mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); + mapview::s_trails_on = lvport::trailsOn(); lv_obj_update_layout(s_area); homeMapFit(); homeMapLayout(); diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index 16e513a0..75549636 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -185,6 +185,9 @@ static void setWifiAllowed(bool on) { s_wifi_allowed = on; nvs::putBool("mc_wifi // Map tools > Live tiles: missing tiles fetched over WiFi while the map is open. static bool liveTiles() { return nvs::getBool("mc_wifi", "live", true); } 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); } // Screen-lock PIN (Settings > Display & power > Screen PIN): digits, "" = none. static void loadPin(char* out, size_t n) { nvs::getStr("mc_lock", "pin", out, n); } @@ -516,6 +519,9 @@ static void setWifiAllowed(bool on) { s_wifi_on = on; } static bool s_live_tiles = true; static bool liveTiles() { return s_live_tiles; } static void setLiveTiles(bool on) { s_live_tiles = on; } +static bool s_trails = false; +static bool trailsOn() { return s_trails; } +static void setTrailsOn(bool on) { s_trails = on; } static bool s_net_on = false; static void netBegin(const char*, const char*) { s_net_on = true; } static int netState() { return NET_UP; } diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index b4130031..da89a796 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -28,6 +28,14 @@ static TileCache& s_cache = *new (psramBuf(1)) TileCache(); static TileDownloader s_dl("/sdcard/maps"); static const uint32_t AVG_TILE_BYTES = 22 * 1024; // OpenTopoMap-ish, for the size estimate +// Decode a tile into the cache; with live tiles on, a base tile shown without +// its trails overlay has the overlay fetched. +static TileCache::Slot* loadTile(int z, int x, int y) { + TileCache::Slot* s = s_cache.load(*s_provider, z, x, y); + if (s_trails_on && s_provider == &s_raster && s_raster.overlayMissed()) s_dl.liveRequest(z, x, y, true); + return s; +} + static const int MIN_Z = 3, MAX_Z = 18; static const int GRID_COLS = 3, GRID_ROWS = 2; // covers 320x218 at any offset @@ -110,7 +118,7 @@ static bool prefetchOne(double left, double top, int w, int h, int z) { } } if (best >= 1e18) return false; - s_cache.load(*s_provider, z, bx, by); + loadTile(z, bx, by); return true; } @@ -198,6 +206,7 @@ void UITask::openMap(bool nav) { void UITask::showMap() { _screen = SCR_MAP; + mapview::s_trails_on = lvport::trailsOn(); mapLiveBegin(); mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); if (_map_z == 0) { // first open: own position, else the node's own advert position, else Poland @@ -436,7 +445,7 @@ void UITask::mapLoop() { } } if (best_d < 1e18) { - mapview::s_cache.load(*mapview::s_provider, _map_z, best_x, best_y); + mapview::loadTile(_map_z, best_x, best_y); } else { // every tile at this zoom looked up: decode a coarser one for a missing tile best_d = 1e18; int bz = -1; @@ -455,7 +464,7 @@ void UITask::mapLoop() { if (d < best_d) { best_d = d; bz = wz; best_x = ax; best_y = ay; } } } - if (bz >= 0) mapview::s_cache.load(*mapview::s_provider, bz, best_x, best_y); + if (bz >= 0) mapview::loadTile(bz, best_x, best_y); } layoutMap(); } @@ -797,6 +806,17 @@ void UITask::mapLiveBegin() { mapview::s_dl.liveBegin(ssid, pass); } +// Map tools > Hiking trails: the overlay on / off, every decoded tile redrawn. +void UITask::setTrails(bool on) { + lvport::setTrailsOn(on); + mapview::s_trails_on = on; + mapview::s_cache.invalidate(); + if (_screen == SCR_MAP) layoutMap(); + if (!on) showToast("Hiking trails off"); + else if (lvport::liveTiles() && lvport::wifiAllowed()) showToast("Trails load with the map over WiFi", 3000); + else showToast("Download an area again to add its trails", 3000); +} + void UITask::setLiveTiles(bool on) { lvport::setLiveTiles(on); if (on) { @@ -820,7 +840,7 @@ void UITask::mapDownloadTick() { if (dl.liveOn()) { int z, x, y; bool got = false; - while (dl.liveTake(z, x, y)) { mapview::s_cache.forgetMissing(z, x, y); got = true; } + while (dl.liveTake(z, x, y)) { mapview::s_cache.drop(z, x, y); got = true; } // a new tile, or its trails if (got && _screen == SCR_MAP) layoutMap(); // The WiFi goes 30 s after the map was left or the screen went off (a // quick look elsewhere keeps it). diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index b7fa0f68..6fd598de 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -873,6 +873,9 @@ 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 onTrails(lv_event_t* e) { + s_ui->setTrails(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); +} static void onLiveTiles(lv_event_t* e) { s_ui->setLiveTiles(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); } @@ -967,6 +970,9 @@ void UITask::navToolsPopup() { lv_obj_t* lsw = switchRow(g, "Live tiles", "Load missing tiles over WiFi", nullptr); if (lvport::liveTiles()) lv_obj_add_state(lsw, LV_STATE_CHECKED); lv_obj_add_event_cb(lsw, onLiveTiles, LV_EVENT_VALUE_CHANGED, NULL); + 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); listRow(g, "Download this area", "For use offline", onNavTool, (void*)(uintptr_t)navmap::TL_DOWNLOAD); listRow(g, "Map options", "Trail, live sharing, arrival alert", onNavTool, (void*)(uintptr_t)navmap::TL_OPTIONS); diff --git a/examples/companion_radio/ui-lvgl/StorageScreen.h b/examples/companion_radio/ui-lvgl/StorageScreen.h index be581f15..97dec812 100644 --- a/examples/companion_radio/ui-lvgl/StorageScreen.h +++ b/examples/companion_radio/ui-lvgl/StorageScreen.h @@ -71,7 +71,7 @@ static void startWalk() { // What a top-level folder counts as. static uint8_t topCat(const char* name) { - if (!strcmp(name, "maps")) return C_MAPS; + if (!strcmp(name, "maps") || !strcmp(name, "maps-trails")) return C_MAPS; // trails: the maps' overlay if (!strcmp(name, "maps-live")) return C_LIVE; if (!strcmp(name, "meshcore")) return C_MSGS; if (!strcmp(name, "trails")) return C_TRAILS; diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 6da57257..933fc42e 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -73,7 +73,8 @@ public: void mapDownloadResume(); void mapDownloadDiscard(); void mapDownloadZmax(int delta); - void setLiveTiles(bool on); // Map tools > Live tiles + void setLiveTiles(bool on); + void setTrails(bool on); // Map tools > Live tiles // Navigation map (NavMap.h) void navTargetsPopup(); void navClosePopup(); diff --git a/examples/companion_radio/ui-lvgl/map/TileCache.h b/examples/companion_radio/ui-lvgl/map/TileCache.h index a2f75642..85e17868 100644 --- a/examples/companion_radio/ui-lvgl/map/TileCache.h +++ b/examples/companion_radio/ui-lvgl/map/TileCache.h @@ -78,6 +78,12 @@ public: for (Missing& m : _missing) if (m.z == z && m.x == x && m.y == y) m.z = -1; } + // Forget one tile, decoded or missing (it changed on the card: redrawn next time). + void drop(int z, int x, int y) { + for (Slot& s : _slots) if (s.z == z && s.x == x && s.y == y) { s.z = -1; s.used = 0; } + forgetMissing(z, x, y); + } + // Forget everything (e.g. the card was swapped); keeps the buffers. void invalidate() { for (Slot& s : _slots) { s.z = -1; s.used = 0; } forgetMissing(); } diff --git a/examples/companion_radio/ui-lvgl/map/TileDownloader.h b/examples/companion_radio/ui-lvgl/map/TileDownloader.h index bd5c4462..5212a549 100644 --- a/examples/companion_radio/ui-lvgl/map/TileDownloader.h +++ b/examples/companion_radio/ui-lvgl/map/TileDownloader.h @@ -17,6 +17,11 @@ // default 19 -- or 17 for OpenTopoMap); without it, 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 +// after its base tile, into TRAILS_ROOT (TileProvider.h) -- empty ones as +// 0-byte files, so re-running an area already on the card adds just the +// trails. +// // Live tiles: while the map is open, single tiles it is missing are fetched // the same way (one at a time, between the job's steps when there is no job), // the WiFi connected on the first miss and dropped by the caller (liveEnd) @@ -113,12 +118,19 @@ public: _area = a; if (_area.zmax > _src_zmax) _area.zmax = _src_zmax; // the server has nothing finer if (_area.zmin > _area.zmax) { fail("The server has no finer zoom: zoom out"); return false; } + _trails = s_trails_on; + _layer = 0; _total = countTiles(_area); + if (_trails && _area.zmin <= TRAILS_MAX_Z) { + TileArea t = _area; + if (t.zmax > TRAILS_MAX_Z) t.zmax = TRAILS_MAX_Z; + _total += countTiles(t); + } _done = _skipped = _failed = _consec_fail = 0; _retried = false; _placeholder_hash = 0; _placeholder_hits = 0; _msg[0] = '\0'; - if (_total == 0 || _total > MAX_TILES) { fail("Area too large: use the PC tool"); return false; } + if (_total == 0 || _total > (_trails ? 2 : 1) * MAX_TILES) { fail("Area too large: use the PC tool"); return false; } writeAttribution(); writeJob(_area); _z = _area.zmin; @@ -157,14 +169,17 @@ public: bool liveConnecting() const { return _live && _lv_state == LV_CONNECTING; } int liveQueued() const { return _live ? _lv_n + (_lv_fetching ? 1 : 0) : 0; } // A tile the map is missing (duplicates, recent failures and a full queue ignored). - void liveRequest(int z, int x, int y) { - if (!_live || active() || z > _src_zmax) return; // past the server's zoom: the map magnifies - if (_lv_fetching && _lv_cur.z == z && _lv_cur.x == x && _lv_cur.y == y) return; - for (int i = 0; i < _lv_n; i++) if (_lv_q[i].z == z && _lv_q[i].x == x && _lv_q[i].y == y) return; - for (const LiveTile& f : _lv_failed) if (f.z == z && f.x == x && f.y == y) return; + // `trails`: the tile's trails overlay (its base is on the card already). + void liveRequest(int z, int x, int y, bool trails = false) { + if (!_live || active() || z > (trails ? TRAILS_MAX_Z : _src_zmax)) return; // past the server's zoom: the map magnifies + uint8_t layer = trails ? 1 : 0; + auto same = [&](const LiveTile& t) { return t.z == z && t.x == x && t.y == y && t.layer == layer; }; + if (_lv_fetching && same(_lv_cur)) return; + for (int i = 0; i < _lv_n; i++) if (same(_lv_q[i])) return; + for (const LiveTile& f : _lv_failed) if (same(f)) return; if (_lv_n >= LV_QUEUE) return; LiveTile& t = _lv_q[_lv_n++]; - t.z = (int16_t)z; t.x = x; t.y = y; + t.z = (int16_t)z; t.x = x; t.y = y; t.layer = layer; } // Next tile written since the last call (the map forgets it was missing). bool liveTake(int& z, int& x, int& y) { @@ -199,6 +214,10 @@ public: size_t len = 0; const uint8_t* data = lvport::fetchData(len); if (!looksLikeImage(data, len)) onFailure("Server didn't send a PNG tile"); + 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; } + _done++; _consec_fail = 0; + } else if (isNoTilePicture(data, len)) { _skipped++; _consec_fail = 0; } // "no tile here": nothing to keep else if (isPlaceholder(data, len)) { lvport::fetchRelease(); finish(FAILED, "Server sends a placeholder (blocked / key?)"); return; } else if (!writeTile(_z, _x, _y, data, len)) { lvport::fetchRelease(); finish(FAILED, "Can't write to the SD card"); return; } @@ -224,12 +243,12 @@ public: for (int i = 0; i < 16 && _state == RUNNING; i++) { if (_z > _area.zmax) { finish(DONE, "Done"); return; } char path[96]; - tilePath(path, sizeof(path), _z, _x, _y); + tilePath(path, sizeof(path), _z, _x, _y, _layer); struct stat st; - if (stat(path, &st) == 0 && st.st_size > 0) { _skipped++; advance(); continue; } + if (stat(path, &st) == 0 && (st.st_size > 0 || _layer == 1)) { _skipped++; advance(); continue; } // an empty trails file counts if ((int32_t)(millis() - _last_start) < 120) return; // be gentle with the server (a retry waits longer) char url[200]; - buildUrl(url, sizeof(url), _z, _x, _y); + buildUrl(url, sizeof(url), _z, _x, _y, _layer); int r = lvport::fetchStart(url); if (r > 0) { _fetching = true; _last_start = millis(); } else if (r < 0) finish(FAILED, "Out of memory (download task)"); @@ -245,6 +264,8 @@ private: uint8_t _consec_fail = 0; int _z = 0, _x = 0, _y = 0, _x0 = 0, _y0 = 0, _x1 = 0, _y1 = 0; bool _fetching = false; + bool _trails = false; // this job fetches the trails overlay too + uint8_t _layer = 0; // of the current tile: 0 the base map, 1 its trails bool _retried = false; // the current tile already failed once uint32_t _last_start = 0, _connect_started = 0; uint32_t _placeholder_hash = 0; @@ -258,7 +279,7 @@ private: bool _has_job = false, _job_checked = false; // Live tiles - struct LiveTile { int16_t z = -1; int32_t x = 0, y = 0; }; + struct LiveTile { int16_t z = -1; uint8_t layer = 0; int32_t x = 0, y = 0; }; // layer: 0 base, 1 trails enum LiveState : uint8_t { LV_IDLE, LV_CONNECTING, LV_UP, LV_BACKOFF }; static const int LV_QUEUE = 8, LV_FAILED = 16, LV_DONE = 8; bool _live = false; @@ -320,7 +341,14 @@ private: 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); - if (r > 0 && looksLikeImage(data, len) && !isNoTilePicture(data, len) && writeFile(lpath, data, len)) { + 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; + if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur; + } else { + 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 _lv_consec_fail = 0; if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur; @@ -335,7 +363,7 @@ private: for (int i = 1; i < _lv_n; i++) _lv_q[i - 1] = _lv_q[i]; _lv_n--; char url[200]; - buildUrl(url, sizeof(url), _lv_cur.z, _lv_cur.x, _lv_cur.y); + buildUrl(url, sizeof(url), _lv_cur.z, _lv_cur.x, _lv_cur.y, _lv_cur.layer); if (lvport::fetchStart(url) > 0) { _lv_fetching = true; _last_start = millis(); } else _lv_q[_lv_n++] = _lv_cur; // fetcher busy: back in the queue } @@ -401,6 +429,8 @@ private: void advance() { _retried = false; + if (_layer == 0 && _trails && _z <= TRAILS_MAX_Z) { _layer = 1; return; } // this tile's trails next + _layer = 0; if (++_y <= _y1) return; _y = _y0; if (++_x <= _x1) return; @@ -409,13 +439,13 @@ private: _x = _x0; _y = _y0; } - void tilePath(char* out, size_t n, int z, int x, int y) const { - snprintf(out, n, "%s/%d/%d/%d.png", _root, z, x, y); + void tilePath(char* out, size_t n, int z, int x, int y, uint8_t layer = 0) const { + snprintf(out, n, "%s/%d/%d/%d.png", layer ? TRAILS_ROOT : _root, z, x, y); } - void buildUrl(char* out, size_t n, int z, int x, int y) const { + void buildUrl(char* out, size_t n, int z, int x, int y, uint8_t layer = 0) const { size_t o = 0; - for (const char* p = _url_tpl; *p && o + 12 < n; p++) { + for (const char* p = layer ? TRAILS_URL : _url_tpl; *p && o + 12 < n; p++) { if (p[0] == '{' && p[1] && p[2] == '}') { int v = p[1] == 'z' ? z : p[1] == 'x' ? x : p[1] == 'y' ? y : -1; if (v >= 0) { o += snprintf(out + o, n - o, "%d", v); p += 2; continue; } @@ -479,6 +509,13 @@ private: return false; } + // A trails tile: as fetched when something is drawn on it, else 0 bytes. + bool writeTrails(int z, int x, int y, const uint8_t* d, size_t n) { + char path[96]; + tilePath(path, sizeof(path), z, x, y, 1); + return pngHasInk(d, n) ? writeFile(path, d, n) : writeFile(path, d, 0); + } + bool writeTile(int z, int x, int y, const uint8_t* d, size_t n) { char path[96]; tilePath(path, sizeof(path), z, x, y); diff --git a/examples/companion_radio/ui-lvgl/map/TileProvider.h b/examples/companion_radio/ui-lvgl/map/TileProvider.h index c9f75d80..4d3fff60 100644 --- a/examples/companion_radio/ui-lvgl/map/TileProvider.h +++ b/examples/companion_radio/ui-lvgl/map/TileProvider.h @@ -29,9 +29,42 @@ static bool isNoTilePicture(const uint8_t* d, size_t n) { return h == OTM_NOTILE_FNV; } - static const int TILE_PX = 256; +// Hiking trails: Waymarked Trails' transparent overlay (marked routes in their +// waymark colours), kept apart from the base map under TRAILS_ROOT in the same +// {z}/{x}/{y}.png layout, drawn over whatever base tile is shown while +// s_trails_on. A 0-byte file there means "fetched, no trail on this tile" -- +// most tiles, and an empty file takes no cluster on a FAT card. +static const char* const TRAILS_ROOT = "/sdcard/maps-trails"; +static const char* const TRAILS_URL = "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png"; +static const char* const TRAILS_ATTR = "Trails \xC2\xA9 waymarkedtrails.org (CC-BY-SA)"; +static const int TRAILS_MAX_Z = 18; +static bool s_trails_on = false; // Map tools > Hiking trails (NVS), set when the map opens + +// PNG -> RGBA (LVGL's lodepng: the result is an lv_draw_buf_t*, destroy it). +static lv_draw_buf_t* decodePng(const uint8_t* png, size_t len) { + unsigned char* res = nullptr; + unsigned w = 0, h = 0; + unsigned err = lodepng_decode32(&res, &w, &h, png, len); + lv_draw_buf_t* db = (lv_draw_buf_t*)res; + if (err || !db || w != TILE_PX || h != TILE_PX) { if (db) lv_draw_buf_destroy(db); return nullptr; } + return db; +} + +// An overlay tile with anything drawn on it (not fully transparent). +static bool pngHasInk(const uint8_t* png, size_t len) { + lv_draw_buf_t* db = decodePng(png, len); + if (!db) return false; + bool ink = false; + for (int row = 0; row < TILE_PX && !ink; row++) { + const uint8_t* p = db->data + row * db->header.stride + 3; + for (int i = 0; i < TILE_PX; i++, p += 4) if (*p) { ink = true; break; } + } + lv_draw_buf_destroy(db); + return ink; +} + class TileProvider { public: virtual ~TileProvider() {} @@ -69,19 +102,16 @@ public: } bool renderTile(int z, int x, int y, uint16_t* out) override { + _overlay_missed = false; uint32_t len = 0; uint8_t* png = readLoose(_root, z, x, y, len); if (!png) png = readPacked(z, x, y, len); if (!png && _live) png = readLoose(_live, z, x, y, len); if (!png) return false; - unsigned char* res = nullptr; - unsigned w = 0, h = 0; - unsigned err = lodepng_decode32(&res, &w, &h, png, len); + lv_draw_buf_t* db = decodePng(png, len); lv_free(png); - lv_draw_buf_t* db = (lv_draw_buf_t*)res; - if (err || !db) { if (db) lv_draw_buf_destroy(db); return false; } - if (w != TILE_PX || h != TILE_PX) { lv_draw_buf_destroy(db); return false; } + if (!db) return false; for (int row = 0; row < TILE_PX; row++) { const uint8_t* p = db->data + row * db->header.stride; // R, G, B, A @@ -90,15 +120,56 @@ public: o[i] = (uint16_t)(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3)); } lv_draw_buf_destroy(db); + if (s_trails_on && z <= TRAILS_MAX_Z) drawOverlay(z, x, y, out); return true; } - const char* attribution() const override { return _attr[0] ? _attr : "\xC2\xA9 OpenStreetMap contributors"; } + // The last renderTile() found no trails file for its tile (not fetched yet). + bool overlayMissed() const { return _overlay_missed; } + + const char* attribution() const override { + const char* base = _attr[0] ? _attr : "\xC2\xA9 OpenStreetMap contributors"; + if (!s_trails_on) return base; + snprintf(_attr_full, sizeof(_attr_full), "%s | %s", base, TRAILS_ATTR); + return _attr_full; + } private: const char* _root; const char* _live; char _attr[96] = ""; + mutable char _attr_full[160] = ""; + bool _overlay_missed = false; + + // The trails tile over `out`, blended by its alpha. + void drawOverlay(int z, int x, int y, uint16_t* out) { + char path[64]; + snprintf(path, sizeof(path), "%s/%d/%d/%d.png", TRAILS_ROOT, z, x, y); + FILE* f = fopen(path, "rb"); + if (!f) { _overlay_missed = true; return; } + fseek(f, 0, SEEK_END); + long sz = ftell(f); + uint8_t* png = sz > 0 ? readRange(f, 0, (uint32_t)sz) : nullptr; // 0 bytes: no trail here + fclose(f); + if (!png) return; + lv_draw_buf_t* db = decodePng(png, (size_t)sz); + lv_free(png); + if (!db) return; + for (int row = 0; row < TILE_PX; row++) { + const uint8_t* p = db->data + row * db->header.stride; + uint16_t* o = out + row * TILE_PX; + for (int i = 0; i < TILE_PX; i++, p += 4) { + unsigned a = p[3]; + if (!a) continue; + unsigned r = (o[i] >> 8) & 0xF8, g = (o[i] >> 3) & 0xFC, b = (o[i] << 3) & 0xF8; + r = (p[0] * a + r * (255 - a)) / 255; + g = (p[1] * a + g * (255 - a)) / 255; + b = (p[2] * a + b * (255 - a)) / 255; + o[i] = (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)); + } + } + lv_draw_buf_destroy(db); + } static uint8_t* readRange(FILE* f, long off, uint32_t len) { if (len == 0 || len > 512 * 1024) return nullptr;