diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index 3d62ac7d..7b98e119 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -245,8 +245,12 @@ Ideas to come back to (2026-09-26): 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. +- [x] Map areas: a frame with corner handles picks the area to download + (the map pans / zooms under it; size, zoom range, tiles, trails in a + bar); downloaded areas are listed in /sdcard/maps/areas.txt (Map tools + > Map areas: show, rename, fill gaps, refresh, trails on / off, delete + -- tiles another area covers stay). Maps fetched before the list aren't + in it (re-download them). - [x] A grid under the map (and the minimap), its step a round distance in the metric / imperial unit, with a scale bar: position and scale where no tile is loaded. diff --git a/examples/companion_radio/ui-lvgl/MapAreas.h b/examples/companion_radio/ui-lvgl/MapAreas.h new file mode 100644 index 00000000..cb79e52c --- /dev/null +++ b/examples/companion_radio/ui-lvgl/MapAreas.h @@ -0,0 +1,489 @@ +#pragma once +// Map areas: picking an area to download with a frame on the map, and the +// list of downloaded areas (map/AreaStore.h) -- show, rename, fill gaps, +// refresh, trails on / off, delete. +// +// The frame: four corner handles on the map, dragged to size it; the map +// pans and zooms under it as usual, so an area can be larger than the screen. +// It is kept in world coordinates (tiles at zoom 0), so it stays on the same +// ground while the view moves. A bar along the bottom gives its size, the +// zoom range, the trails switch and Download; the areas already on the card +// are outlined meanwhile. +// +// Single-TU fragment: included by ui-lvgl/UITask.cpp after NavMap.h. + +namespace areas { + +enum : uint8_t { A_SHOW, A_RENAME, A_FILL, A_REFRESH, A_TRAILS, A_DELETE }; + +static bool s_sel = false; // the frame is up +static double s_fx0, s_fy0, s_fx1, s_fy1; // the frame, world units (tiles at z 0) +static int s_zmax = 16; +static bool s_trails = false; +static lv_obj_t* s_layer = nullptr; // draws the frame / outlines, over the tiles +static lv_obj_t* s_handle[4]; // TL, TR, BL, BR +static lv_obj_t* s_bar = nullptr; +static lv_obj_t* s_info = nullptr; +static lv_obj_t* s_zoom = nullptr; +static lv_obj_t* s_trails_btn = nullptr; +static int s_show = -1; // area outlined after Show +static int s_idx = -1; // area in the open popup +static lv_obj_t* s_del_lbl = nullptr; +static uint32_t s_del_armed_ms = 0; +static const int BAR_H = 84; +static const int HANDLE = 26; + +static double worldX(double lon) { return (lon + 180.0) / 360.0; } +static double worldY(double lat) { double r = lat * M_PI / 180.0; return (1.0 - asinh(tan(r)) / M_PI) / 2.0; } +static double lonOf(double wx) { return wx * 360.0 - 180.0; } +static double latOf(double wy) { return atan(sinh(M_PI * (1.0 - 2.0 * wy))) * 180.0 / M_PI; } + +// Deepest zoom at which a box `ww` x `wh` (world units) fits `w` x `h` px. +static int fitZoom(double ww, double wh, int w, int h) { + int z = mapview::MIN_Z; + for (int k = mapview::MAX_Z; k >= mapview::MIN_Z; k--) { + double s = (double)(1 << k) * mapview::TILE_PX; + if (ww * s <= w && wh * s <= h) { z = k; break; } + } + return z; +} + +static mapview::TileArea frameArea(int w, int h) { + mapview::TileArea a; + a.lon0 = lonOf(s_fx0); a.lon1 = lonOf(s_fx1); + a.lat1 = latOf(s_fy0); a.lat0 = latOf(s_fy1); + int zfit = fitZoom(s_fx1 - s_fx0, s_fy1 - s_fy0, w, h); + a.zmin = zfit - 4 < 5 ? (zfit < 5 ? zfit : 5) : zfit - 4; // a few overview levels (cheap) + a.zmax = s_zmax < a.zmin ? a.zmin : s_zmax; + return a; +} + +static void boxWorld(const mapview::TileArea& b, double& x0, double& y0, double& x1, double& y1) { + x0 = worldX(b.lon0); x1 = worldX(b.lon1); + y0 = worldY(b.lat1); y1 = worldY(b.lat0); +} + +static void fmtDate(char* out, size_t n, uint32_t t, const NodePrefs* p) { + if (t < 1000000000UL) { out[0] = '\0'; return; } + time_t tt = (time_t)((int64_t)t + (int64_t)(p ? p->tz_offset_hours : 0) * 3600); + struct tm tm = *gmtime(&tt); + static const char* const MON[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + snprintf(out, n, "%d %s %d", tm.tm_mday, MON[tm.tm_mon], tm.tm_year + 1900); +} + +static void onHandle(lv_event_t* e) { + lv_point_t v; + lv_indev_get_vect(lv_indev_active(), &v); + if (v.x || v.y) s_ui->areaHandleDrag((int)(uintptr_t)lv_event_get_user_data(e), v.x, v.y); +} +static void onCancel(lv_event_t* e) { (void)e; s_ui->areaSelectEnd(); } +static void onGo(lv_event_t* e) { (void)e; s_ui->areaSelectDownload(); } +static void onZMinus(lv_event_t* e) { (void)e; s_ui->areaSelectZmax(-1); } +static void onZPlus(lv_event_t* e) { (void)e; s_ui->areaSelectZmax(+1); } +static void onTrailsChip(lv_event_t* e) { (void)e; s_trails = !s_trails; s_ui->areaLayout(); } +static void onArea(lv_event_t* e) { s_ui->mapAreaPopup((int)(intptr_t)lv_event_get_user_data(e)); } +static void onNewArea(lv_event_t* e) { (void)e; s_ui->areaSelectBegin(); } +static void onAct(lv_event_t* e) { s_ui->mapAreaAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } +static void onRenameKb(lv_event_t* e) { s_ui->mapAreaRenameDone(lv_event_get_code(e) == LV_EVENT_READY); } + +// LV_EVENT_DRAW_MAIN of the layer: outlines, the frame and the dim outside it. +static void drawLayer(lv_event_t* e) { + if (!s_sel && s_show < 0) return; + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + double scale = 0; // world -> px + double left = mapview::s_left, top = mapview::s_top; + int z = s_ui->mapZoomLevel(); + scale = (double)(1 << z) * mapview::TILE_PX; + auto rect = [&](double x0, double y0, double x1, double y1, lv_area_t& r) { + r.x1 = a.x1 + (int32_t)lround(x0 * scale - left); + r.y1 = a.y1 + (int32_t)lround(y0 * scale - top); + r.x2 = a.x1 + (int32_t)lround(x1 * scale - left); + r.y2 = a.y1 + (int32_t)lround(y1 * scale - top); + }; + lv_draw_rect_dsc_t d; + // Areas on the card: thin outlines (the shown one in the accent colour). + for (int i = 0; i < mapview::s_areas.count(); i++) { + if (!s_sel && i != s_show) continue; + double x0, y0, x1, y1; + boxWorld(mapview::s_areas.at(i).box, x0, y0, x1, y1); + lv_area_t r; + rect(x0, y0, x1, y1, r); + lv_draw_rect_dsc_init(&d); + d.bg_opa = LV_OPA_TRANSP; + d.border_width = i == s_show ? 2 : 1; + d.border_color = lv_color_hex(i == s_show ? theme::ACCENT : 0x9A958C); + d.border_opa = LV_OPA_COVER; + lv_draw_rect(layer, &d, &r); + } + if (!s_sel) return; + lv_area_t f; + rect(s_fx0, s_fy0, s_fx1, s_fy1, f); + lv_draw_rect_dsc_init(&d); // dim everything outside the frame + d.bg_color = lv_color_hex(theme::BG); + d.bg_opa = LV_OPA_50; + lv_area_t r; + r = { a.x1, a.y1, a.x2, f.y1 - 1 }; if (r.y2 >= r.y1) lv_draw_rect(layer, &d, &r); + r = { a.x1, f.y2 + 1, a.x2, a.y2 }; if (r.y2 >= r.y1) lv_draw_rect(layer, &d, &r); + r = { a.x1, f.y1, f.x1 - 1, f.y2 }; if (r.x2 >= r.x1) lv_draw_rect(layer, &d, &r); + r = { f.x2 + 1, f.y1, a.x2, f.y2 }; if (r.x2 >= r.x1) lv_draw_rect(layer, &d, &r); + lv_draw_rect_dsc_init(&d); + d.bg_opa = LV_OPA_TRANSP; + d.border_width = 2; + d.border_color = lv_color_hex(theme::ACCENT); + d.border_opa = LV_OPA_COVER; + lv_draw_rect(layer, &d, &f); +} + +static lv_obj_t* barButton(lv_obj_t* row, const char* text, lv_event_cb_t cb, int w) { + lv_obj_t* b = lv_button_create(row); + lv_obj_set_size(b, w, 34); + lv_obj_set_style_pad_all(b, 0, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT)); + return b; +} + +static lv_obj_t* actButton(lv_obj_t* row, const char* text, uint8_t act, bool accent) { + lv_obj_t* b = lv_button_create(row); + lv_obj_set_height(b, 36); + lv_obj_set_flex_grow(b, 1); + lv_obj_set_style_pad_hor(b, 4, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT_DIM : theme::SURFACE), 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_add_event_cb(b, onAct, LV_EVENT_CLICKED, (void*)(uintptr_t)act); + lv_obj_t* l = label(b, text, THEME_FONT_SMALL, theme::TEXT); + lv_obj_center(l); + return l; +} + +} // namespace areas + +int UITask::mapZoomLevel() const { return _map_z; } + +// From buildMap(): the layer and the handles, hidden until wanted. +void UITask::areaBuildLayer(lv_obj_t* body) { + using namespace areas; + s_sel = false; + s_bar = s_info = s_zoom = s_trails_btn = nullptr; + s_layer = lv_obj_create(body); + lv_obj_remove_style_all(s_layer); + lv_obj_set_size(s_layer, LV_PCT(100), LV_PCT(100)); + lv_obj_remove_flag(s_layer, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(s_layer, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(s_layer, drawLayer, LV_EVENT_DRAW_MAIN, NULL); + for (int i = 0; i < 4; i++) { + lv_obj_t* h = lv_obj_create(body); + lv_obj_remove_style_all(h); + lv_obj_set_size(h, HANDLE, HANDLE); + lv_obj_set_style_radius(h, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(h, lv_color_hex(theme::ACCENT), 0); + lv_obj_set_style_bg_opa(h, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(h, lv_color_hex(theme::BG), 0); + lv_obj_set_style_border_width(h, 3, 0); + lv_obj_set_ext_click_area(h, 10); + lv_obj_add_flag(h, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(h, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(h, LV_OBJ_FLAG_GESTURE_BUBBLE); + lv_obj_add_event_cb(h, onHandle, LV_EVENT_PRESSING, (void*)(uintptr_t)i); + lv_obj_add_flag(h, LV_OBJ_FLAG_HIDDEN); + s_handle[i] = h; + } +} + +// Map tools > Download an area (or the Nodes map's download button): the +// frame over the middle of the view. +void UITask::areaSelectBegin() { + using namespace areas; + if (_nav_overlay) navClosePopup(); + if (_dl_overlay) mapDownloadClose(); + if (!_map_area || !s_layer || s_sel) return; + s_sel = true; + s_show = -1; + // Between the button columns and above the bar, the handles clear of the buttons. + int w = lv_obj_get_width(_map_area), h = lv_obj_get_height(_map_area) - BAR_H; + double scale = (double)(1 << _map_z) * mapview::TILE_PX; + s_fx0 = (mapview::s_left + 70) / scale; s_fx1 = (mapview::s_left + w - 70) / scale; + s_fy0 = (mapview::s_top + 30) / scale; s_fy1 = (mapview::s_top + h - 22) / scale; + int src_max = mapview::s_dl.sourceMaxZ(); + s_zmax = _map_z + 3 > src_max ? src_max : _map_z + 3; + s_trails = lvport::trailsOn(); + + s_bar = lv_obj_create(_map_area); + lv_obj_remove_style_all(s_bar); + lv_obj_remove_flag(s_bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(s_bar, LV_OBJ_FLAG_CLICKABLE); // presses on it don't pan the map + lv_obj_set_size(s_bar, LV_PCT(100), BAR_H); + lv_obj_align(s_bar, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(s_bar, lv_color_hex(theme::BG), 0); + lv_obj_set_style_bg_opa(s_bar, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(s_bar, 6, 0); + lv_obj_set_style_pad_row(s_bar, 4, 0); + lv_obj_set_flex_flow(s_bar, LV_FLEX_FLOW_COLUMN); + s_info = label(s_bar, "", THEME_FONT_SMALL, theme::TEXT); + lv_label_set_long_mode(s_info, LV_LABEL_LONG_DOT); + lv_obj_set_width(s_info, LV_PCT(100)); + lv_obj_t* row = lv_obj_create(s_bar); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_PCT(100), 34); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, 4, 0); + barButton(row, LV_SYMBOL_CLOSE, onCancel, 34); + barButton(row, LV_SYMBOL_MINUS, onZMinus, 30); + s_zoom = label(row, "", THEME_FONT_SMALL, theme::ACCENT); + lv_obj_set_width(s_zoom, 34); + lv_obj_set_style_text_align(s_zoom, LV_TEXT_ALIGN_CENTER, 0); + barButton(row, LV_SYMBOL_PLUS, onZPlus, 30); + s_trails_btn = barButton(row, "Trails", onTrailsChip, 58); + lv_obj_t* go = barButton(row, LV_SYMBOL_DOWNLOAD " Get", onGo, 10); + lv_obj_set_flex_grow(go, 1); + stylePrimary(go); + areaLayout(); +} + +void UITask::areaSelectEnd() { + using namespace areas; + s_sel = false; + if (s_bar) lv_obj_delete_async(s_bar); + s_bar = s_info = s_zoom = s_trails_btn = nullptr; + for (lv_obj_t* h : s_handle) if (h && _map_area) lv_obj_add_flag(h, LV_OBJ_FLAG_HIDDEN); + if (s_layer) lv_obj_invalidate(s_layer); +} + +bool UITask::areaSelecting() const { return areas::s_sel; } + +void UITask::areaSelectZmax(int d) { + using namespace areas; + int z = s_zmax + d; + if (z < mapview::MIN_Z || z > mapview::MAX_Z || z > mapview::s_dl.sourceMaxZ()) return; + s_zmax = z; + areaLayout(); +} + +// A corner dragged by (dx, dy) px; the opposite one stays. +void UITask::areaHandleDrag(int corner, int dx, int dy) { + using namespace areas; + double scale = (double)(1 << _map_z) * mapview::TILE_PX; + double min = 30 / scale; // not smaller than that on screen + if (corner == 0 || corner == 2) s_fx0 = fmin(s_fx0 + dx / scale, s_fx1 - min); + else s_fx1 = fmax(s_fx1 + dx / scale, s_fx0 + min); + if (corner == 0 || corner == 1) s_fy0 = fmin(s_fy0 + dy / scale, s_fy1 - min); + else s_fy1 = fmax(s_fy1 + dy / scale, s_fy0 + min); + areaLayout(); +} + +// From layoutMap(): handles and the bar follow the view. +void UITask::areaLayout() { + using namespace areas; + if (!s_layer || !_map_area) return; + lv_obj_invalidate(s_layer); + if (!s_sel) return; + double scale = (double)(1 << _map_z) * mapview::TILE_PX; + double xs[2] = { s_fx0 * scale - mapview::s_left, s_fx1 * scale - mapview::s_left }; + double ys[2] = { s_fy0 * scale - mapview::s_top, s_fy1 * scale - mapview::s_top }; + for (int i = 0; i < 4; i++) { + lv_obj_set_pos(s_handle[i], (int)lround(xs[i & 1]) - HANDLE / 2, (int)lround(ys[i >> 1]) - HANDLE / 2); + lv_obj_remove_flag(s_handle[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_handle[i]); + } + if (s_bar) lv_obj_move_foreground(s_bar); + if (!s_info) return; + int w = lv_obj_get_width(_map_area), h = lv_obj_get_height(_map_area); + mapview::TileArea a = frameArea(w, h); + uint32_t n = mapview::countTiles(a); + // Its size on the ground, at the frame's middle latitude. + double midlat = latOf((s_fy0 + s_fy1) / 2); + double km_w = (s_fx1 - s_fx0) * 40075.0 * cos(midlat * M_PI / 180.0); + double km_h = (s_fy1 - s_fy0) * 40075.0 * cos(midlat * M_PI / 180.0); + bool imperial = _prefs && _prefs->units_imperial; + char dim[32], size[16]; + if (imperial) snprintf(dim, sizeof(dim), "%.1f x %.1f mi", km_w * 0.621371, km_h * 0.621371); + else snprintf(dim, sizeof(dim), km_w < 10 ? "%.1f x %.1f km" : "%.0f x %.0f km", km_w, km_h); + uint64_t bytes = (uint64_t)n * mapview::AVG_TILE_BYTES; + if (bytes < 1024 * 1024) snprintf(size, sizeof(size), "%lu KB", (unsigned long)(bytes / 1024)); + else snprintf(size, sizeof(size), "%lu MB", (unsigned long)((bytes + 512 * 1024) >> 20)); + bool too = n > mapview::TileDownloader::MAX_TILES; + lv_label_set_text_fmt(s_info, "%s - z%d-%d - %lu tiles, %s%s", dim, a.zmin, a.zmax, (unsigned long)n, size, + too ? " - too large" : ""); + lv_obj_set_style_text_color(s_info, lv_color_hex(too ? theme::FAIL : theme::TEXT), 0); + lv_label_set_text_fmt(s_zoom, "z%d", a.zmax); + if (s_trails_btn) { + lv_obj_set_style_bg_color(s_trails_btn, lv_color_hex(s_trails ? theme::ACCENT_DIM : theme::SURFACE), 0); + lv_label_set_text(lv_obj_get_child(s_trails_btn, 0), s_trails ? LV_SYMBOL_OK " Trails" : "Trails"); + } +} + +// Start a download of `box` (a new area, or one on the list with `idx`). +bool UITask::areaStartDownload(const mapview::TileArea& box, bool force, bool trails) { + char ssid[33], pass[65]; + if (mapview::s_dl.active()) { showToast("A download is running"); return false; } + if (!lvport::wifiAllowed()) { showToast("WiFi is off - Settings > WiFi"); return false; } + if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showToast("Pick a WiFi network first - Settings > WiFi", 3000); return false; } + if (!lvport::mountStorage()) { showToast("No SD card"); return false; } + if (!mapview::s_dl.start(box, ssid, pass, force, trails ? 1 : 0)) { showToast(mapview::s_dl.message()); return false; } + return true; +} + +void UITask::areaSelectDownload() { + using namespace areas; + if (!_map_area) return; + mapview::TileArea a = frameArea(lv_obj_get_width(_map_area), lv_obj_get_height(_map_area)); + if (mapview::countTiles(a) > mapview::TileDownloader::MAX_TILES) { showToast("Too large - make it smaller or lower the zoom", 3000); return; } + if (!areaStartDownload(a, false, s_trails)) return; + int i = mapview::s_areas.add(mapview::s_dl.area(), s_trails, rtc_clock.getCurrentTime()); + char t[64]; + snprintf(t, sizeof(t), "Downloading %s - rename it in Map areas", mapview::s_areas.at(i).name); + showToast(t, 3000); + areaSelectEnd(); +} + +// ── The list ── + +void UITask::mapAreasPopup() { + using namespace areas; + lv_obj_t* panel = navPopupPanel("Map areas", true); + lv_obj_t* list = scrollList(panel); + int n = mapview::s_areas.count(); + if (mapview::s_areas.deleting()) { + char t[48]; + snprintf(t, sizeof(t), "Deleting... %lu files", (unsigned long)mapview::s_areas.deleted()); + label(list, t, THEME_FONT_SMALL, theme::ACCENT); + } + if (n) { + lv_obj_t* g = group(list, nullptr); + for (int i = n - 1; i >= 0; i--) { // newest first + const mapview::MapArea& m = mapview::s_areas.at(i); + char sub[64], date[16]; + fmtDate(date, sizeof(date), m.created, _prefs); + snprintf(sub, sizeof(sub), "z%d-%d%s%s%s%s", m.box.zmin, m.box.zmax, + (m.flags & mapview::AreaStore::F_TRAILS) ? " - trails" : "", + (m.flags & mapview::AreaStore::F_COMPLETE) ? "" : " - unfinished", date[0] ? " - " : "", date); + listRow(g, m.name, sub, onArea, (void*)(intptr_t)i); + } + } else { + lv_obj_t* l = label(list, "No areas yet. Maps downloaded before this list are still on the card.", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + } + lv_obj_t* add = lv_button_create(list); + lv_obj_set_size(add, LV_PCT(100), 38); + lv_obj_set_style_radius(add, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(add, 0, 0); + lv_obj_add_event_cb(add, onNewArea, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(add, LV_SYMBOL_PLUS " New area", THEME_FONT_BODY, theme::TEXT)); + stylePrimary(add); +} + +void UITask::mapAreaPopup(int idx) { + using namespace areas; + if (idx < 0 || idx >= mapview::s_areas.count()) return; + s_idx = idx; + const mapview::MapArea& m = mapview::s_areas.at(idx); + lv_obj_t* panel = navPopupPanel(m.name, false); + char info[128], date[16]; + fmtDate(date, sizeof(date), m.created, _prefs); + uint32_t n = mapview::countTiles(m.box); + snprintf(info, sizeof(info), "Zoom %d-%d, %lu tiles%s%s%s\n%s", m.box.zmin, m.box.zmax, (unsigned long)n, + (m.flags & mapview::AreaStore::F_TRAILS) ? ", trails" : "", date[0] ? " - " : "", date, + (m.flags & mapview::AreaStore::F_COMPLETE) ? "Downloaded" : "Unfinished - Fill gaps completes it"); + lv_obj_t* l = label(panel, info, THEME_FONT_SMALL, theme::TEXT); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + lv_obj_t* r = toolRow(panel); + actButton(r, UI_SYMBOL_MAP " Show", A_SHOW, true); + actButton(r, LV_SYMBOL_EDIT " Rename", A_RENAME, false); + r = toolRow(panel); + actButton(r, LV_SYMBOL_DOWNLOAD " Fill gaps", A_FILL, false); + actButton(r, LV_SYMBOL_REFRESH " Refresh", A_REFRESH, false); + r = toolRow(panel); + actButton(r, (m.flags & mapview::AreaStore::F_TRAILS) ? "Remove trails" : LV_SYMBOL_PLUS " Trails", A_TRAILS, false); + s_del_lbl = actButton(r, LV_SYMBOL_TRASH " Delete", A_DELETE, false); + s_del_armed_ms = 0; +} + +void UITask::mapAreaAction(uint8_t act) { + using namespace areas; + if (s_idx < 0 || s_idx >= mapview::s_areas.count()) return; + mapview::MapArea m = mapview::s_areas.at(s_idx); + bool trails = m.flags & mapview::AreaStore::F_TRAILS; + switch (act) { + case A_SHOW: { + navClosePopup(); + double x0, y0, x1, y1; + boxWorld(m.box, x0, y0, x1, y1); + int w = _map_area ? lv_obj_get_width(_map_area) : 320, h = _map_area ? lv_obj_get_height(_map_area) : 218; + _map_z = fitZoom(x1 - x0, y1 - y0, w - 40, h - 60); + double s = (double)(1 << _map_z); + _map_cx = (x0 + x1) / 2 * s; + _map_cy = (y0 + y1) / 2 * s; + _map_follow = false; + s_show = s_idx; + layoutMap(); + return; + } + case A_RENAME: { + lv_obj_t* panel = navPopupPanel("Area name", false); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard + _nav_ta = textField(panel); + lv_textarea_set_max_length(_nav_ta, sizeof(m.name) - 1); + lv_textarea_set_text(_nav_ta, m.name); + lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); + _nav_kb = kb::create(_nav_overlay, _prefs); + lv_obj_set_size(_nav_kb, LV_PCT(100), 124); + lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_keyboard_set_textarea(_nav_kb, _nav_ta); + lv_obj_add_event_cb(_nav_kb, onRenameKb, LV_EVENT_READY, NULL); + lv_obj_add_event_cb(_nav_kb, onRenameKb, LV_EVENT_CANCEL, NULL); + return; + } + case A_FILL: + case A_REFRESH: + if (!areaStartDownload(m.box, act == A_REFRESH, trails)) return; + navClosePopup(); + showToast(act == A_REFRESH ? "Downloading it all again" : "Fetching what's missing"); + return; + case A_TRAILS: + if (trails) { // off: its trail tiles go (unless another area has them) + if (mapview::s_areas.deleting()) { showToast("Still deleting - try again shortly"); return; } + mapview::s_areas.remove(s_idx, false, true); + navClosePopup(); + showToast("Removing its trails"); + return; + } + if (!areaStartDownload(m.box, false, true)) return; + mapview::s_areas.setFlag(s_idx, mapview::AreaStore::F_TRAILS, true); + navClosePopup(); + showToast("Fetching its trails"); + return; + case A_DELETE: + if (!s_del_armed_ms || millis() - s_del_armed_ms > 3000) { // a second tap within 3 s confirms + s_del_armed_ms = millis() | 1; + if (s_del_lbl) lv_label_set_text(s_del_lbl, "Delete?"); + return; + } + if (mapview::s_areas.deleting()) { showToast("Still deleting - try again shortly"); return; } + if (mapview::s_dl.active() && mapview::s_areas.find(mapview::s_dl.area()) == s_idx) mapview::s_dl.cancel(); + if (s_show == s_idx) s_show = -1; + else if (s_show > s_idx) s_show--; + mapview::s_areas.remove(s_idx, true, true); + navClosePopup(); + showToast("Deleting the area"); + return; + } +} + +void UITask::mapAreaRenameDone(bool ok) { + using namespace areas; + if (ok && _nav_ta) mapview::s_areas.rename(s_idx, lv_textarea_get_text(_nav_ta)); + navClosePopup(); + mapAreasPopup(); +} diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index f2bfda5d..ed039302 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -19,6 +19,7 @@ #include "map/TileCache.h" #include "map/LiveCache.h" #include "map/TileDownloader.h" +#include "map/AreaStore.h" namespace mapview { @@ -26,6 +27,7 @@ static RasterTileProvider s_raster("/sdcard/maps", LIVE_ROOT); static TileProvider* s_provider = &s_raster; // the one place to swap in a vector renderer static TileCache& s_cache = *new (psramBuf(1)) TileCache(); // decoded tiles, in PSRAM static TileDownloader s_dl("/sdcard/maps"); +static AreaStore s_areas("/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 @@ -329,6 +331,8 @@ void UITask::buildMap() { lv_obj_remove_flag(_map_me, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(_map_me, LV_OBJ_FLAG_HIDDEN); + areaBuildLayer(body); // download frame / area outlines, over the tiles (MapAreas.h) + // Controls lv_obj_t* back = mapButton(body, LV_SYMBOL_LEFT, onBack); lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6); @@ -486,8 +490,9 @@ void UITask::layoutMap() { : 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; - if (hint) { lv_label_set_text(_map_hint, hint); lv_obj_remove_flag(_map_hint, LV_OBJ_FLAG_HIDDEN); } + if (hint && !areaSelecting()) { lv_label_set_text(_map_hint, hint); lv_obj_remove_flag(_map_hint, LV_OBJ_FLAG_HIDDEN); } else lv_obj_add_flag(_map_hint, LV_OBJ_FLAG_HIDDEN); + areaLayout(); } // One tile decode per call, nearest to the centre first. @@ -677,8 +682,12 @@ static mapview::TileArea visibleArea(double cx, double cy, int z, int w, int h, static int dlZmin(int z) { return z - 4 < 5 ? (z < 5 ? z : 5) : z - 4; } // a few overview levels (cheap) +// The download button: progress / an unfinished job in a popup, else the +// frame to pick a new area (MapAreas.h). void UITask::mapDownloadPopup() { if (_dl_overlay) return; + mapview::TileArea job; + if (!mapview::s_dl.active() && !mapview::s_dl.savedJob(job)) { areaSelectBegin(); return; } int src_max = mapview::s_dl.sourceMaxZ(); if (_dl_zmax < _map_z) _dl_zmax = _map_z + 3; if (_dl_zmax > src_max) _dl_zmax = src_max; // the server has nothing finer @@ -848,20 +857,13 @@ void UITask::refreshDownloadPopup() { snprintf(last, sizeof(last), "\nLast: %s (%lu new)", dl.message(), (unsigned long)dl.downloaded()); lv_label_set_text_fmt(_dl_info, "z%d-%d: %lu tiles, about %s - %s\n%s%s", a.zmin, a.zmax, (unsigned long)n, size, host, net, last); - lv_label_set_text(_dl_start_lbl, n > mapview::TileDownloader::MAX_TILES ? "Too large - zoom in" - : LV_SYMBOL_DOWNLOAD " Download"); + (void)n; + lv_label_set_text(_dl_start_lbl, LV_SYMBOL_PLUS " New area"); } void UITask::mapDownloadStart() { if (mapview::s_dl.active()) { mapDownloadStop(); return; } - char ssid[33], pass[65]; - if (!lvport::wifiAllowed()) { showToast("WiFi is off - Settings > WiFi"); return; } - if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showToast("Pick a WiFi network first - Settings > WiFi", 3000); return; } - if (!lvport::mountStorage()) { showToast("No SD card"); return; } - int w = _map_area ? lv_obj_get_width(_map_area) : 320, h = _map_area ? lv_obj_get_height(_map_area) : 218; - mapview::TileArea a = visibleArea(_map_cx, _map_cy, _map_z, w, h, dlZmin(_map_z), _dl_zmax); - if (!mapview::s_dl.start(a, ssid, pass)) { showToast(mapview::s_dl.message()); return; } - refreshDownloadPopup(); + areaSelectBegin(); // a new area: picked with the frame } void UITask::mapDownloadResume() { @@ -928,6 +930,14 @@ void UITask::mapDownloadTick() { mapview::TileDownloader& dl = mapview::s_dl; dl.loop(); mapview::s_live_cache.service(6); // trims live tiles past the Storage limit + if (mapview::s_areas.service(8)) { // a deleted area's files are gone + char t[48]; + snprintf(t, sizeof(t), "Area deleted (%lu files)", (unsigned long)mapview::s_areas.deleted()); + showToast(t); + mapview::s_cache.invalidate(); + mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); + if (_screen == SCR_MAP) layoutMap(); + } if (dl.liveOn()) { int z, x, y; bool got = false; @@ -944,6 +954,8 @@ void UITask::mapDownloadTick() { bool was_active = _dl_last_state == mapview::TileDownloader::CONNECTING || _dl_last_state == mapview::TileDownloader::RUNNING; if (was_active && !dl.active()) { // just finished + if (st == mapview::TileDownloader::DONE) + mapview::s_areas.setFlag(mapview::s_areas.find(dl.area()), mapview::AreaStore::F_COMPLETE, true); char t[64]; snprintf(t, sizeof(t), "Map: %s, %lu new tiles", dl.message(), (unsigned long)dl.downloaded()); showToast(t, 4000); diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index 6fd598de..4861316b 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -779,7 +779,7 @@ void UITask::shareToMessage(const char* text) { namespace navmap { enum : uint8_t { TL_TRAIL_TOGGLE, TL_TRAIL_SAVE, TL_TRAIL_LOAD, TL_TRAIL_RESET, TL_TRAIL_GPX, TL_TRACKBACK, - TL_SHARE_TOGGLE, TL_SHARE_ONCE, TL_DOWNLOAD, TL_OPTIONS, + TL_SHARE_TOGGLE, TL_SHARE_ONCE, TL_DOWNLOAD, TL_AREAS, TL_OPTIONS, TL_WP_HERE, TL_WP_COORDS, TL_SPOT_ADD, TL_SPOT_GO, TL_ST_LOAD, TL_ST_GPX, TL_ST_DELETE }; @@ -973,7 +973,11 @@ void UITask::navToolsPopup() { 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, "Download an area", "Pick it with a frame, for use offline", onNavTool, (void*)(uintptr_t)navmap::TL_DOWNLOAD); + char ar[40]; + int na = mapview::s_areas.count(); + snprintf(ar, sizeof(ar), na ? "%d on the card - rename, refresh, delete" : "None yet", na); + listRow(g, "Map areas", ar, onNavTool, (void*)(uintptr_t)navmap::TL_AREAS); listRow(g, "Map options", "Trail, live sharing, arrival alert", onNavTool, (void*)(uintptr_t)navmap::TL_OPTIONS); _nav_reset_armed_ms = 0; @@ -1265,6 +1269,9 @@ void UITask::navToolAction(uint8_t act) { navClosePopup(); mapDownloadPopup(); return; + case navmap::TL_AREAS: + mapAreasPopup(); + return; case navmap::TL_OPTIONS: // back returns to the map showSchemaSettings(settings::PG_NAV); return; diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 5a53a666..d1037cd4 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -1389,6 +1389,7 @@ void UITask::back() { break; case SCR_MAP: if (_nav_overlay) navClosePopup(); + else if (areaSelecting()) areaSelectEnd(); else if (_dl_overlay) mapDownloadClose(); else if (!_map_nav) showNearby(); // the Nodes map opens from Nearby else showHome(); @@ -2181,6 +2182,7 @@ void UITask::nodeAction(uint8_t action) { static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below #include "MapScreen.h" #include "NavMap.h" +#include "MapAreas.h" #include "HomeScreen.h" #include "ClockScreen.h" #include "RadioScreen.h" @@ -3099,6 +3101,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { if (!strncmp(name, "page", 4)) { s_ui->showSchemaSettings(atoi(name + 4)); return; } if (!strcmp(name, "map")) { s_ui->openMap(true); return; } if (!strcmp(name, "maptools")) { s_ui->navToolsPopup(); return; } + if (!strcmp(name, "areasel")) { s_ui->areaSelectBegin(); return; } + if (!strcmp(name, "areas")) { s_ui->mapAreasPopup(); return; } if (!strcmp(name, "advert")) { s_ui->advertPopup(); return; } } // Scrolls the screen's (or a popup's) main list by dy; returns what was left to scroll. diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 933fc42e..ca630c3b 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -21,6 +21,8 @@ class UiCore; class NearbyModel; struct UiEvent; +namespace mapview { struct TileArea; } + class UITask : public UITaskBase, public UiCoreHost { public: UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : UITaskBase(board, serial) {} @@ -66,6 +68,21 @@ public: void mapPan(int dx, int dy); void mapOpenMarker(int idx); void mapDownloadPopup(); + // Map areas (MapAreas.h) + void areaBuildLayer(lv_obj_t* body); + void areaSelectBegin(); + void areaSelectEnd(); + bool areaSelecting() const; + void areaSelectDownload(); + void areaSelectZmax(int d); + void areaHandleDrag(int corner, int dx, int dy); + void areaLayout(); + bool areaStartDownload(const mapview::TileArea& box, bool force, bool trails); + void mapAreasPopup(); + void mapAreaPopup(int idx); + void mapAreaAction(uint8_t act); + void mapAreaRenameDone(bool ok); + int mapZoomLevel() const; void setTapWake(bool on); void mapDownloadClose(); void mapDownloadStart(); diff --git a/examples/companion_radio/ui-lvgl/map/AreaStore.h b/examples/companion_radio/ui-lvgl/map/AreaStore.h new file mode 100644 index 00000000..b9a997c5 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/map/AreaStore.h @@ -0,0 +1,199 @@ +#pragma once +// Downloaded map areas: what was fetched where, so it can be shown, named, +// refreshed, given trails or deleted later (Map tools > Map areas). One text +// line per area in /areas.txt: +// A lon0 lat0 lon1 lat1 zmin zmax flags created name +// An area is a box and a zoom range, whatever the tiles are; a vector source +// would keep its regions here too (a kind in `flags`). +// +// Deleting runs in steps (an area can be tens of thousands of files): a tile +// is removed only if no other area covers it at that zoom, so overlapping +// areas keep what they share. Packed columns (.pak, the PC tool) stay. +// +// Single-TU fragment: included by ui-lvgl/MapScreen.h after TileDownloader.h. + +namespace mapview { + +struct MapArea { + TileArea box; + uint8_t flags; + uint32_t created; // RTC seconds, 0 unknown + char name[32]; +}; + +class AreaStore { +public: + static const int MAX = 32; + enum : uint8_t { F_TRAILS = 1, F_COMPLETE = 2 }; + + explicit AreaStore(const char* root) : _root(root) {} + + int count() { load(); return _n; } + MapArea& at(int i) { load(); return _a[i]; } + + // A new area (the oldest goes when full); returns its index. + int add(const TileArea& box, bool trails, uint32_t now) { + load(); + if (_n >= MAX) { memmove(_a, _a + 1, sizeof(MapArea) * (MAX - 1)); _n--; } + MapArea& m = _a[_n]; + m.box = box; + m.flags = trails ? F_TRAILS : 0; + m.created = now; + int k = 1; // "Area n": the first number not taken + for (bool taken = true; taken; k++) { + taken = false; + char want[16]; + snprintf(want, sizeof(want), "Area %d", k); + for (int i = 0; i < _n; i++) if (!strcmp(_a[i].name, want)) taken = true; + if (!taken) snprintf(m.name, sizeof(m.name), "%s", want); + } + _n++; + save(); + return _n - 1; + } + + // The area a download job is for (same box and zooms). + int find(const TileArea& b) { + load(); + for (int i = _n - 1; i >= 0; i--) { + const TileArea& a = _a[i].box; + if (fabs(a.lon0 - b.lon0) < 1e-5 && fabs(a.lat0 - b.lat0) < 1e-5 && fabs(a.lon1 - b.lon1) < 1e-5 && + fabs(a.lat1 - b.lat1) < 1e-5 && a.zmin == b.zmin && a.zmax == b.zmax) return i; + } + return -1; + } + + void setFlag(int i, uint8_t f, bool on) { + if (i < 0 || i >= count()) return; + if (on) _a[i].flags |= f; else _a[i].flags &= ~f; + save(); + } + void rename(int i, const char* name) { + if (i < 0 || i >= count()) return; + while (*name == ' ') name++; + if (!*name) return; + snprintf(_a[i].name, sizeof(_a[i].name), "%s", name); + for (char* p = _a[i].name; *p; p++) if (*p == '\n' || *p == '\r') *p = ' '; + save(); + } + + // Removes area i from the list and starts deleting its files: the base + // tiles (`base`) and / or its trails, except what other areas cover. + void remove(int i, bool base, bool trails) { + if (i < 0 || i >= count()) return; + _del = _a[i].box; + _del_base = base; + _del_trails = trails; + if (base) { // gone from the list; only trails: the area stays, without them + memmove(_a + i, _a + i + 1, sizeof(MapArea) * (_n - i - 1)); + _n--; + } else { + _a[i].flags &= ~F_TRAILS; + } + save(); + _del_z = _del.zmin; + tileRange(_del, _del_z, _dx0, _dy0, _dx1, _dy1); + _dx = _dx0; _dy = _dy0; + _deleting = true; + _deleted = 0; + } + bool deleting() const { return _deleting; } + uint32_t deleted() const { return _deleted; } + + // A slice of the delete; true when it just finished. + bool service(uint32_t budget_ms) { + if (!_deleting) return false; + uint32_t t0 = millis(); + while (millis() - t0 < budget_ms) { + if (_del_z > _del.zmax) { _deleting = false; return true; } + char path[64]; + if (_del_base && !covered(_del_z, _dx, _dy, false)) { + snprintf(path, sizeof(path), "%s/%d/%d/%d.png", _root, _del_z, _dx, _dy); + if (::remove(path) == 0) _deleted++; + } + if (_del_trails && !covered(_del_z, _dx, _dy, true)) { + snprintf(path, sizeof(path), "%s/%d/%d/%d.png", TRAILS_ROOT, _del_z, _dx, _dy); + if (::remove(path) == 0) _deleted++; + } + if (++_dy > _dy1) { + _dy = _dy0; + if (++_dx > _dx1) { + if (++_del_z <= _del.zmax) { tileRange(_del, _del_z, _dx0, _dy0, _dx1, _dy1); _dx = _dx0; _dy = _dy0; } + } + } + } + return false; + } + +private: + const char* _root; + MapArea _a[MAX]; + int _n = 0; + bool _loaded = false; + TileArea _del = {0, 0, 0, 0, 0, 0}; + bool _deleting = false, _del_base = false, _del_trails = false; + int _del_z = 0, _dx = 0, _dy = 0, _dx0 = 0, _dy0 = 0, _dx1 = 0, _dy1 = 0; + uint32_t _deleted = 0; + + // Another listed area has this tile (its trails, with `trails`). + bool covered(int z, int x, int y, bool trails) { + for (int i = 0; i < _n; i++) { + const MapArea& m = _a[i]; + if (trails && !(m.flags & F_TRAILS)) continue; + if (z < m.box.zmin || z > m.box.zmax) continue; + int x0, y0, x1, y1; + tileRange(m.box, z, x0, y0, x1, y1); + if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return true; + } + return false; + } + + void path(char* out, size_t n) const { snprintf(out, n, "%s/areas.txt", _root); } + + void load() { + if (_loaded) return; + _loaded = true; + _n = 0; + char p[64]; + path(p, sizeof(p)); + FILE* f = fopen(p, "r"); + if (!f) return; + char line[160]; + while (_n < MAX && fgets(line, sizeof(line), f)) { + MapArea m; + int flags = 0, off = 0; + unsigned long created = 0; + if (sscanf(line, "A %lf %lf %lf %lf %d %d %d %lu %n", &m.box.lon0, &m.box.lat0, &m.box.lon1, &m.box.lat1, + &m.box.zmin, &m.box.zmax, &flags, &created, &off) < 8 || off <= 0) continue; + if (m.box.zmin < 0 || m.box.zmax > 22 || m.box.zmin > m.box.zmax) continue; + m.flags = (uint8_t)flags; + m.created = (uint32_t)created; + snprintf(m.name, sizeof(m.name), "%s", line + off); + size_t k = strlen(m.name); + while (k > 0 && (m.name[k - 1] == '\n' || m.name[k - 1] == '\r' || m.name[k - 1] == ' ')) m.name[--k] = '\0'; + if (!m.name[0]) snprintf(m.name, sizeof(m.name), "Area"); + _a[_n++] = m; + } + fclose(f); + } + + void save() { + char p[64], tmp[70]; + path(p, sizeof(p)); + makeParents(p); + snprintf(tmp, sizeof(tmp), "%s.tmp", p); + FILE* f = fopen(tmp, "w"); + if (!f) return; + for (int i = 0; i < _n; i++) { + const MapArea& m = _a[i]; + fprintf(f, "A %.6f %.6f %.6f %.6f %d %d %d %lu %s\n", m.box.lon0, m.box.lat0, m.box.lon1, m.box.lat1, + m.box.zmin, m.box.zmax, m.flags, (unsigned long)m.created, m.name); + } + bool ok = fclose(f) == 0; + if (!ok) { ::remove(tmp); return; } + ::remove(p); + ::rename(tmp, p); + } +}; + +} // namespace mapview diff --git a/examples/companion_radio/ui-lvgl/map/TileDownloader.h b/examples/companion_radio/ui-lvgl/map/TileDownloader.h index 5212a549..d52ca964 100644 --- a/examples/companion_radio/ui-lvgl/map/TileDownloader.h +++ b/examples/companion_radio/ui-lvgl/map/TileDownloader.h @@ -86,6 +86,8 @@ public: explicit TileDownloader(const char* root) : _root(root) {} State state() const { return _state; } + const TileArea& area() const { return _area; } // of the current / last job + bool withTrails() const { return _trails; } bool active() const { return _state == CONNECTING || _state == RUNNING; } uint32_t total() const { return _total; } uint32_t processed() const { return _done + _skipped + _failed; } @@ -110,7 +112,9 @@ public: _job_checked = true; } - bool start(const TileArea& a, const char* ssid, const char* pass) { + // `force`: fetch every tile again (a refresh), not only the missing ones. + // `trails`: 1 / 0 with or without the trails overlay, -1 as the map's toggle. + bool start(const TileArea& a, const char* ssid, const char* pass, bool force = false, int trails = -1) { if (active()) return false; if (_lv_fetching) { lvport::fetchAbandon(); _lv_fetching = false; } // the job takes the fetcher _lv_state = LV_IDLE; @@ -118,7 +122,8 @@ 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; + _trails = trails < 0 ? s_trails_on : trails > 0; + _force = force; _layer = 0; _total = countTiles(_area); if (_trails && _area.zmin <= TRAILS_MAX_Z) { @@ -245,7 +250,7 @@ public: char path[96]; tilePath(path, sizeof(path), _z, _x, _y, _layer); struct stat st; - if (stat(path, &st) == 0 && (st.st_size > 0 || _layer == 1)) { _skipped++; advance(); continue; } // an empty trails file counts + if (!_force && 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, _layer); @@ -265,6 +270,7 @@ private: 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 + bool _force = false; // refetches tiles already on the card 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;