From b3b77a1f6d8a8372c86d69d3e14c739e8353ddf2 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:02:49 +0200 Subject: [PATCH] feat(l2): long trails with a card copy; map follow and redraw fixes Trail on the L2: 32768 points every 5 m, no simplification, no point spacing setting; copied to the card every minute and restored at power-on. The line keeps up with the position dot (incremental sync, pending point). Map: a tap's finger jitter no longer turns follow off; the GPS button shows when the map follows. The trail is drawn as one polyline with breaks and off-view chunks skipped (an lv_line queued a draw task per point). Fewer rebuilds: conversation bubbles patched (new messages, delivery marks), home mini-map dots reused, Nearby rows rewritten in place, history read in runs and each file opened once. Co-Authored-By: Claude Opus 5.5 --- docs/solo/developer/build-flags.md | 3 + docs/solo/navigation.md | 6 +- examples/companion_radio/Trail.h | 48 ++- .../companion_radio/ui-core/SettingsSchema.h | 4 + .../companion_radio/ui-core/TrailEngine.h | 4 + .../ui-lvgl/ConversationScreen.h | 2 +- .../companion_radio/ui-lvgl/HistoryStore.h | 87 ++++-- examples/companion_radio/ui-lvgl/HomeScreen.h | 63 +++- examples/companion_radio/ui-lvgl/MapScreen.h | 41 ++- examples/companion_radio/ui-lvgl/NavMap.h | 276 ++++++++++++++---- examples/companion_radio/ui-lvgl/UITask.cpp | 235 +++++++++++---- examples/companion_radio/ui-lvgl/UITask.h | 7 +- examples/companion_radio/ui-new/GfxUtils.h | 7 + solo/wio-tracker-l2/platformio.ini | 4 +- variants/sim/build_wasm.sh | 2 +- 15 files changed, 601 insertions(+), 188 deletions(-) diff --git a/docs/solo/developer/build-flags.md b/docs/solo/developer/build-flags.md index 1d1b836e..f68edf2b 100644 --- a/docs/solo/developer/build-flags.md +++ b/docs/solo/developer/build-flags.md @@ -28,6 +28,9 @@ per-user. Listed here so the rest of this page can assume them. | `MAX_CONTACTS=` | Size of the contact table. Default is 32 if unset; solo builds set 350. | | `MAX_GROUP_CHANNELS=` | Size of the channel list. Required for channel support to compile in at all — not optional the way the rest of this page is. | | `OFFLINE_QUEUE_SIZE=` | How many messages queue for later delivery while the phone app is disconnected. Default 16; solo builds set 256. | +| `TRAIL_CAPACITY=` | Points the GPS trail holds (16 B each). Default 512 for the nRF52's RAM; the L2 sets 32768 in PSRAM. At most 65535 (the save format's count). | +| `TRAIL_SIMPLIFY=0` | Keeps every trail sample instead of storing straight stretches as their two ends. For boards with the memory for it (the L2). | +| `TRAIL_FIXED_MIN_DELTA_M=` | A fixed distance between trail points instead of the Point spacing setting, which is then hidden (the L2: 5 m, against GPS jitter). | | `UI_SENSORS_PAGE=1` | Enables the on-device sensors dashboard page. | | `BLE_PIN_CODE=` | See below — not a plain fixed value in practice. | | `DISPLAY_CLASS=` | Selects the display driver (e.g. `SSD1306Display`, `GxEPDDisplay`, `ST7789Display` — see `src/helpers/ui/` for the full set). Fixed by whatever panel the board actually has; only relevant if you're wiring on a *different* display than stock, in which case the matching driver's `.cpp` also needs adding to `build_src_filter`. | diff --git a/docs/solo/navigation.md b/docs/solo/navigation.md index 4020c6f1..3c1a3ef2 100644 --- a/docs/solo/navigation.md +++ b/docs/solo/navigation.md @@ -36,7 +36,11 @@ Chrome or Edge and click **Connect device**, then on the device choose it's connected over USB. > [!NOTE] -> **Wio Tracker L2:** the trail holds 4096 points and is drawn on the map. +> **Wio Tracker L2:** the trail holds 32,768 points, every GPS fix 5 m or +> more from the last one (no simplification and no Min dist setting), about +> 160 km on foot. A copy on the SD card is updated every minute, so a restart +> or a flat battery doesn't lose it: the trail comes back at power-on, still +> recording if it was. It is drawn on the map and keeps up with your position. > Its controls (start / stop, save, load, track back, reset) are in the map's > **Map tools**, the settings in Settings › Map. **GPX** writes the trail to > `trails/` on the SD card; take it off with the card as a USB drive diff --git a/examples/companion_radio/Trail.h b/examples/companion_radio/Trail.h index ca0452f3..dcd2b736 100644 --- a/examples/companion_radio/Trail.h +++ b/examples/companion_radio/Trail.h @@ -23,11 +23,17 @@ // turn still gets a vertex roughly every min-delta of deviation — so CAPACITY // covers a much longer route than CAPACITY × min-delta would suggest. -// Boards with PSRAM raise it (the L2: -D TRAIL_CAPACITY=4096, 64 KB, since +// Boards with PSRAM raise it (the L2: -D TRAIL_CAPACITY=32768, 512 KB, since // UiCore is heap-allocated there, in PSRAM); the default stays for the nRF52. #ifndef TRAIL_CAPACITY #define TRAIL_CAPACITY 512 #endif +// Straight-run simplification (addPoint()): on by default, for the nRF52's +// 512 points. A board with room for every sample turns it off +// (-D TRAIL_SIMPLIFY=0): each sample past the min-delta gate is a vertex. +#ifndef TRAIL_SIMPLIFY +#define TRAIL_SIMPLIFY 1 +#endif struct TrailPoint { int32_t lat_1e6; @@ -140,8 +146,19 @@ public: _session_start_ms = 0; _paused = false; _has_pending = false; + _dist_m = 0; + _gen++; } + // For whoever mirrors the trail (the map's line, the L2's card copy): gen() + // changes when the points are replaced (clear, load), seq() counts every + // point ever added -- the newest seq() - old_seq points are new, unless the + // ring has also dropped its oldest (count() == CAPACITY). + uint32_t gen() const { return _gen; } + uint32_t seq() const { return _seq; } + // The simplifier's uncommitted candidate: where the trail really ends now. + bool pendingPoint(TrailPoint& out) const { if (_has_pending) out = _pending; return _has_pending; } + // Returns true if the sample was accepted (passed the min-delta gate) — // whether that landed it as a new committed vertex right away, or just // extended the pending candidate (see below). First point of the ring and @@ -192,6 +209,10 @@ public: return true; } +#if !TRAIL_SIMPLIFY + commitPoint(lat_1e6, lon_1e6, ts, 0); + return true; +#endif TrailPoint sample{ lat_1e6, lon_1e6, ts, 0 }; if (!_has_pending) { _pending = sample; // last in-corridor sample (the commit candidate) @@ -215,13 +236,10 @@ public: // uncommitted candidate (_pending) is counted too, so the live total doesn't // stall over a long straight run — where simplification holds the whole // stretch as one pending point until a bend forces a commit (see addPoint()). + // Kept as a running total (commitPoint()), not summed on every call: the + // L2's trail runs to tens of thousands of points. uint32_t totalDistanceMeters() const { - float d = 0; - for (int i = 1; i < _count; i++) { - if (at(i).flags & TRAIL_FLAG_SEG_START) continue; - d += haversineMeters(at(i - 1).lat_1e6, at(i - 1).lon_1e6, - at(i).lat_1e6, at(i).lon_1e6); - } + float d = _dist_m; if (_has_pending && _count > 0) d += haversineMeters(last().lat_1e6, last().lon_1e6, _pending.lat_1e6, _pending.lon_1e6); @@ -305,6 +323,11 @@ public: } _accumulated_ms = accum; _pending_seg_break = true; + _dist_m = 0; + for (int i = 1; i < _count; i++) + if (!(_buf[i].flags & TRAIL_FLAG_SEG_START)) + _dist_m += haversineMeters(_buf[i - 1].lat_1e6, _buf[i - 1].lon_1e6, _buf[i].lat_1e6, _buf[i].lon_1e6); + _gen++; return true; } @@ -464,6 +487,9 @@ private: bool _pending_seg_break = false; // next addPoint flags itself SEG_START uint32_t _accumulated_ms = 0; // banked active time across previous sessions uint32_t _session_start_ms = 0; // millis() of the current active session, 0 if none + float _dist_m = 0; // totalDistanceMeters() without the pending candidate + uint32_t _gen = 0; // see gen() / seq() + uint32_t _seq = 0; // Streaming simplification: a sample that passed the min-delta gate but // hasn't been committed as a real vertex yet — see addPoint(). _dir is the @@ -474,14 +500,20 @@ private: TrailPoint _dir; void commitPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint8_t flags) { + if (_count > 0 && !(flags & TRAIL_FLAG_SEG_START)) + _dist_m += haversineMeters(last().lat_1e6, last().lon_1e6, lat_1e6, lon_1e6); int pos; if (_count < CAPACITY) { pos = (_head + _count) % CAPACITY; _count++; - } else { + } else { // full: the oldest point goes, and the stretch from it to the next + if (!(at(1).flags & TRAIL_FLAG_SEG_START)) + _dist_m -= haversineMeters(at(0).lat_1e6, at(0).lon_1e6, at(1).lat_1e6, at(1).lon_1e6); + if (_dist_m < 0) _dist_m = 0; pos = _head; _head = (_head + 1) % CAPACITY; } + _seq++; _buf[pos].lat_1e6 = lat_1e6; _buf[pos].lon_1e6 = lon_1e6; _buf[pos].ts = ts; diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index b1b8c72f..17905e4b 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -61,9 +61,11 @@ struct Setting { }; // ── Value labels ────────────────────────────────────────────────────────────── +#ifndef TRAIL_FIXED_MIN_DELTA_M static void optMinDelta(uint8_t v, char* b, int n, const NodePrefs& p) { snprintf(b, n, "%s", TrailStore::minDeltaLabel(v, p.units_imperial)); } +#endif static void optAutoPause(uint8_t v, char* b, int n, const NodePrefs&) { uint16_t s = NodePrefs::trailAutoPauseSecs(v); if (s == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u min", (unsigned)(s / 60)); @@ -160,8 +162,10 @@ static void applySound(UiCore& c) { c.host()->applySoundPrefs(); } #define SW(label, hint, sec, f, chg) { label, hint, sec, NP_OFF(f), 2, nullptr, chg, nullptr, 1 } #define MAP(label, hint, sec, f, vals, opt, chg) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, chg, vals, NP_SIZE(f) } static const Setting ALL[] = { +#ifndef TRAIL_FIXED_MIN_DELTA_M // else every sample past a fixed jitter gate is kept IDX("Point spacing", "Between trail points", SEC_TRAIL, trail_min_delta_idx, TrailStore::MIN_DELTA_COUNT, optMinDelta, nullptr), +#endif IDX("Auto-pause", "Pause when standing still", SEC_TRAIL, trail_autopause_idx, NodePrefs::TRAIL_AUTOPAUSE_COUNT, optAutoPause, nullptr), SW("Save on low battery", "Save it on low battery", SEC_TRAIL, trail_autosave_lowbatt, nullptr), diff --git a/examples/companion_radio/ui-core/TrailEngine.h b/examples/companion_radio/ui-core/TrailEngine.h index 191317e9..34762ec8 100644 --- a/examples/companion_radio/ui-core/TrailEngine.h +++ b/examples/companion_radio/ui-core/TrailEngine.h @@ -21,7 +21,11 @@ public: _next_sample_ms = millis() + (uint32_t)TrailStore::SAMPLING_SECS * 1000UL; int32_t la, lo; if (!_course->currentLocation(la, lo)) return; +#ifdef TRAIL_FIXED_MIN_DELTA_M // no point spacing setting: a fixed GPS-jitter gate (the L2) + uint16_t md = TRAIL_FIXED_MIN_DELTA_M; +#else uint16_t md = TrailStore::minDeltaMeters(_prefs->trail_min_delta_idx, _prefs->units_imperial); +#endif // Auto-pause: freeze the trail once the device has stayed within // TRAIL_AUTOPAUSE_MOVE_M of one spot for the configured delay; resume on // the next real move. Its own coarse gate (not the trail min-delta) so diff --git a/examples/companion_radio/ui-lvgl/ConversationScreen.h b/examples/companion_radio/ui-lvgl/ConversationScreen.h index 2ff0d460..77fa5a15 100644 --- a/examples/companion_radio/ui-lvgl/ConversationScreen.h +++ b/examples/companion_radio/ui-lvgl/ConversationScreen.h @@ -50,7 +50,7 @@ static void onRoomRowHold(lv_event_t* e) { static void onConvThreadMenu(lv_event_t* e) { (void)e; s_ui->conversationMenu(nullptr); } static void onMsgHold(lv_event_t* e) { lv_indev_wait_release(lv_indev_active()); - s_ui->messageMenu((int)(uintptr_t)lv_event_get_user_data(e)); + s_ui->messageMenu(threadRowOf((lv_obj_t*)lv_event_get_current_target(e))); } static void onChatFilter(lv_event_t* e) { s_ui->toggleChatFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } diff --git a/examples/companion_radio/ui-lvgl/HistoryStore.h b/examples/companion_radio/ui-lvgl/HistoryStore.h index c6c87537..fce61f5d 100644 --- a/examples/companion_radio/ui-lvgl/HistoryStore.h +++ b/examples/companion_radio/ui-lvgl/HistoryStore.h @@ -121,22 +121,21 @@ public: } // A window of the conversation, oldest first: `n` records ending `skip` - // before the newest. Returns how many were read. + // before the newest. Returns how many were read; *total = records in the + // file (0 when missing or unreadable). template - int window(const char* path, int skip, int n, E* out) { + int window(const char* path, int skip, int n, E* out, int* total) { + *total = 0; FILE* f = fopen(path, "rb"); if (!f) return 0; Hdr h; int got = 0; - if (readHdr(f, h) && skip < (int)h.count) { - int avail = (int)h.count - skip; - int m = n < avail ? n : avail; - uint32_t k0 = h.count - skip - m; // index from the oldest - for (int i = 0; i < m; i++) { - uint32_t slot = (h.head + k0 + i) % h.cap; - if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) != 0) break; - if (fread(&out[got], sizeof(E), 1, f) != 1) break; - got++; + if (readHdr(f, h)) { + *total = (int)h.count; + if (skip < (int)h.count) { + int avail = (int)h.count - skip; + int m = n < avail ? n : avail; + got = (int)readRun(f, h, h.count - skip - m, (uint32_t)m, out); } } fclose(f); @@ -184,15 +183,34 @@ private: if (fseek(f, 0, SEEK_SET) != 0 || fread(&h, sizeof(h), 1, f) != 1) return false; return h.magic == MAGIC && h.ver == VERSION && h.rec_size == sizeof(E) && h.cap > 0 && h.count <= h.cap; } + // `m` records from the k0-th oldest into `out`: one read, two where the + // ring wraps. Returns how many were read. + template + static uint32_t readRun(FILE* f, const Hdr& h, uint32_t k0, uint32_t m, E* out) { + uint32_t got = 0; + while (got < m) { + uint32_t slot = (h.head + k0 + got) % h.cap; + uint32_t run = m - got < h.cap - slot ? m - got : h.cap - slot; + if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) != 0) break; + size_t r = fread(&out[got], sizeof(E), run, f); + got += (uint32_t)r; + if (r != run) break; + } + return got; + } static bool writeHdr(FILE* f, const Hdr& h) { return fseek(f, 0, SEEK_SET) == 0 && fwrite(&h, sizeof(h), 1, f) == 1; } template void append(const char* path, E& e) { - resize(path, _keep); // the setting changed since this file was written FILE* f = fopen(path, "r+b"); Hdr h; + if (f && readHdr(f, h) && h.cap != _keep) { // the setting changed since this file was written + fclose(f); + resize(path, _keep); + f = fopen(path, "r+b"); + } if (!f || !readHdr(f, h)) { // new (or unreadable: start it again) if (f) fclose(f); f = fopen(path, "w+b"); @@ -233,13 +251,7 @@ private: uint32_t m = h.count < cap ? h.count : cap; E* buf = m ? (E*)malloc((size_t)m * sizeof(E)) : nullptr; // PSRAM on the L2 (large block) if (m && !buf) { fclose(f); return; } - uint32_t k0 = h.count - m; - uint32_t got = 0; - for (uint32_t i = 0; i < m; i++) { - uint32_t slot = (h.head + k0 + i) % h.cap; - if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) != 0 || fread(&buf[got], sizeof(E), 1, f) != 1) break; - got++; - } + uint32_t got = m ? readRun(f, h, h.count - m, m, buf) : 0; fclose(f); char tmp[72]; snprintf(tmp, sizeof(tmp), "%s.tmp", path); @@ -323,20 +335,33 @@ private: fclose(f); } std::stable_sort(picks, picks + np, [](const Pick& a, const Pick& b) { return a.ts < b.ts; }); - int from = np > ring ? np - ring : 0; - for (int i = from; i < np; i++) { - FILE* f = fopen(paths[picks[i].file], "rb"); - if (!f) continue; - Hdr h; - E e; - if (readHdr(f, h)) { - uint32_t first = h.next_id - h.count; - uint32_t slot = (h.head + (picks[i].id - first)) % h.cap; - if (fseek(f, sizeof(Hdr) + (long)slot * sizeof(E), SEEK_SET) == 0 && fread(&e, sizeof(E), 1, f) == 1) - put(hist, e, slot_of[picks[i].file]); + int from = np > ring ? np - ring : 0, cnt = np - from; + // The chosen records read file by file (each opened once), then put + // back in time order. + E* recs = cnt ? (E*)malloc((size_t)cnt * sizeof(E)) : nullptr; + bool* ok = cnt ? (bool*)calloc(cnt, 1) : nullptr; + int* order = cnt ? (int*)malloc((size_t)cnt * sizeof(int)) : nullptr; + if (recs && ok && order) { + for (int i = 0; i < cnt; i++) order[i] = from + i; + std::sort(order, order + cnt, [&](int a, int b) { return picks[a].file != picks[b].file ? picks[a].file < picks[b].file : picks[a].id < picks[b].id; }); + for (int j = 0; j < cnt;) { + int fi = picks[order[j]].file, j1 = j; + while (j1 < cnt && picks[order[j1]].file == fi) j1++; + FILE* f = fopen(paths[fi], "rb"); + Hdr h; + if (f && readHdr(f, h)) { + uint32_t first = h.next_id - h.count; + for (; j < j1; j++) { + int k = order[j]; + ok[k - from] = picks[k].id >= first && readRun(f, h, picks[k].id - first, 1, &recs[k - from]) == 1; + } + } + if (f) fclose(f); + j = j1; } - fclose(f); + for (int i = 0; i < cnt; i++) if (ok[i]) put(hist, recs[i], slot_of[picks[from + i].file]); } + free(recs); free(ok); free(order); } free(picks); free(paths); diff --git a/examples/companion_radio/ui-lvgl/HomeScreen.h b/examples/companion_radio/ui-lvgl/HomeScreen.h index 5d2c4e0c..61612988 100644 --- a/examples/companion_radio/ui-lvgl/HomeScreen.h +++ b/examples/companion_radio/ui-lvgl/HomeScreen.h @@ -139,9 +139,11 @@ static lv_obj_t* s_imgs[COLS * ROWS]; static lv_obj_t* s_hint = nullptr; static lv_obj_t* s_caption = nullptr; static const int MAX_PTS = LiveTrackStore::CAPACITY + 2; -struct Pt { int32_t lat, lon; lv_obj_t* obj; }; +enum : uint8_t { P_ME, P_LAST, P_TARGET, P_LIVE }; // how a point's dot looks +struct Pt { int32_t lat, lon; uint8_t kind; lv_obj_t* obj; }; static Pt s_pts[MAX_PTS]; // [0] you, then the target, then live shares static int s_npts = 0; +static bool s_fitted = false; // homeMapFit() ran for this map static int s_z = mapview::DEFAULT_Z; static double s_cx = 0, s_cy = 0; // centre, in tiles at s_z static bool s_pending = false; @@ -150,7 +152,13 @@ static mapview::Grid s_grid; // under the tiles, as on the map static void onTap(lv_event_t* e) { (void)e; s_ui->openMap(true); } -static lv_obj_t* dot(int d, uint32_t col, uint32_t border) { +static lv_obj_t* dot(uint8_t kind) { + static const struct { uint8_t d; uint32_t col, border; } LOOK[] = { + { 14, theme::ACCENT, theme::BG }, { 14, theme::BG, theme::ACCENT }, // you; no fix: the last position, hollow + { 12, theme::BG, theme::ACCENT }, { 12, theme::OK, theme::BG }, // the target; live shares + }; + int d = LOOK[kind].d; + uint32_t col = LOOK[kind].col, border = LOOK[kind].border; lv_obj_t* o = lv_obj_create(s_area); lv_obj_remove_style_all(o); lv_obj_set_size(o, d, d); @@ -589,6 +597,7 @@ void UITask::buildHomeMap(lv_obj_t* box) { lv_image_set_pivot(s_imgs[i], 0, 0); } s_npts = 0; + s_fitted = false; s_hint = label(s_area, "", THEME_FONT_SMALL, theme::TEXT); lv_obj_set_style_bg_color(s_hint, lv_color_hex(theme::BG), 0); lv_obj_set_style_bg_opa(s_hint, LV_OPA_80, 0); @@ -612,42 +621,64 @@ void UITask::buildHomeMap(lv_obj_t* box) { homeMapLayout(); } -// Picks the points, the centre and the zoom that fits them. -void UITask::homeMapFit() { +// Picks the points, the centre and the zoom that fits them. False when +// nothing changed since the last call (no re-layout, no redraw): the dots +// stay, moved or restyled only when their points did. +bool UITask::homeMapFit() { using namespace home::mini; - if (!s_area) return; - for (int i = 0; i < s_npts; i++) if (s_pts[i].obj) lv_obj_delete(s_pts[i].obj); - s_npts = 0; + if (!s_area) return false; + Pt pts[MAX_PTS]; + int np = 0; int32_t lat, lon; bool me = _core->course.currentLocation(lat, lon); int live = 0; bool target = false; bool last = false; // no fix: the saved / app-set position, hollow - if (me) s_pts[s_npts++] = { lat, lon, dot(14, theme::ACCENT, theme::BG) }; + if (me) pts[np++] = { lat, lon, P_ME, nullptr }; else if (_sensors && (_sensors->node_lat != 0 || _sensors->node_lon != 0)) { last = true; - s_pts[s_npts++] = { (int32_t)lround(_sensors->node_lat * 1e6), (int32_t)lround(_sensors->node_lon * 1e6), dot(14, theme::BG, theme::ACCENT) }; + pts[np++] = { (int32_t)lround(_sensors->node_lat * 1e6), (int32_t)lround(_sensors->node_lon * 1e6), P_LAST, nullptr }; } if (_core->locator.activeTargetPos(lat, lon)) { target = true; - s_pts[s_npts++] = { lat, lon, dot(12, theme::BG, theme::ACCENT) }; + pts[np++] = { lat, lon, P_TARGET, nullptr }; } const LiveTrackStore& lt = _core->live_share.track(); uint32_t now = rtc_clock.getCurrentTime(); - for (int i = 0; i < LiveTrackStore::CAPACITY && s_npts < MAX_PTS; i++) { + for (int i = 0; i < LiveTrackStore::CAPACITY && np < MAX_PTS; i++) { if (!lt.isActive(i, now)) continue; const LiveTrackStore::Entry& e = lt.slotAt(i); - s_pts[s_npts++] = { e.lat_1e6, e.lon_1e6, dot(12, theme::OK, theme::BG) }; + pts[np++] = { e.lat_1e6, e.lon_1e6, P_LIVE, nullptr }; live++; } + bool same = np == s_npts; + for (int i = 0; i < np && same; i++) + same = pts[i].kind == s_pts[i].kind && pts[i].lat == s_pts[i].lat && pts[i].lon == s_pts[i].lon && s_pts[i].obj; + if (same && s_fitted) return false; + s_fitted = true; + bool made = false; + for (int i = 0; i < np; i++) { // the same dot where the kind matches + lv_obj_t* o = i < s_npts && s_pts[i].kind == pts[i].kind ? s_pts[i].obj : nullptr; + if (!o) { + if (i < s_npts && s_pts[i].obj) lv_obj_delete(s_pts[i].obj); + o = dot(pts[i].kind); + made = true; + } + pts[i].obj = o; + } + for (int i = np; i < s_npts; i++) if (s_pts[i].obj) lv_obj_delete(s_pts[i].obj); + memcpy(s_pts, pts, np * sizeof(Pt)); + s_npts = np; + if (made) { lv_obj_move_foreground(s_hint); lv_obj_move_foreground(s_caption); + } char cap[48]; int o = snprintf(cap, sizeof(cap), "%s", me ? "You" : last ? "Last position" : "No position yet"); if (live) o += snprintf(cap + o, sizeof(cap) - o, " " LV_SYMBOL_GPS " %d sharing", live); if (target) snprintf(cap + o, sizeof(cap) - o, " " UI_SYMBOL_FLAG " target"); - lv_label_set_text(s_caption, cap); + setText(s_caption, cap); if (s_npts == 0) { // nothing to show: where the map was left, else the default view if (_map_z) { @@ -659,7 +690,7 @@ void UITask::homeMapFit() { s_cx = mapview::lonToTileX(mapview::DEFAULT_LON, s_z); s_cy = mapview::latToTileY(mapview::DEFAULT_LAT, s_z); } - return; + return true; } // Bounds in world units (tiles at z 0), then the deepest zoom they fit at. double x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9; @@ -675,6 +706,7 @@ void UITask::homeMapFit() { } s_cx = (x0 + x1) / 2 * (1 << s_z); s_cy = (y0 + y1) / 2 * (1 << s_z); + return true; } // Places tiles and points; notes whether a tile still has to be decoded. @@ -740,8 +772,7 @@ void UITask::homeMapLoop() { if (!s_area || home::s_touching) return; if ((int32_t)(millis() - s_next_fit_ms) >= 0) { s_next_fit_ms = millis() + 5000; - homeMapFit(); - homeMapLayout(); + if (homeMapFit()) homeMapLayout(); } if (!s_pending) return; int w = lv_obj_get_width(s_area), h = lv_obj_get_height(s_area); diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index d3d5df39..e7a42414 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -198,15 +198,22 @@ enum : uint8_t { T_CLEAR, T_WAYPOINT, T_TRAILSTART, T_LIVE }; static int code(uint8_t type, int idx) { return (type << 8) | (idx & 0xFF); } } // namespace navmap +// A press only pans once it has moved DRAG_SLOP px: a tap's jitter (a pixel +// or two on the touch panel) would otherwise stop the map following you. +static const int DRAG_SLOP = 8; static void onMapPress(lv_event_t* e) { + static lv_point_t held; // movement within the slop, applied once it's a pan lv_event_code_t code = lv_event_get_code(e); - if (code == LV_EVENT_PRESSED) { mapview::s_drag = 0; return; } + if (code == LV_EVENT_PRESSED) { mapview::s_drag = 0; held = { 0, 0 }; return; } lv_point_t v; lv_indev_get_vect(lv_indev_active(), &v); if (v.x == 0 && v.y == 0) return; mapview::s_drag += abs(v.x) + abs(v.y); + held.x += v.x; held.y += v.y; + if (mapview::s_drag <= DRAG_SLOP) return; mapview::s_last_pan_ms = millis(); - s_ui->mapPan(v.x, v.y); + s_ui->mapPan(held.x, held.y); + held = { 0, 0 }; } static void onMapLongPress(lv_event_t* e) { if (mapview::s_drag > 8) return; // held after a pan: not a long-press on a spot @@ -233,6 +240,14 @@ static void onMapMarker(lv_event_t* e) { s_ui->mapOpenMarker((int)(uintptr_t)lv_event_get_user_data(e)); } +// The centre-on-me button in the accent colour while the map follows you. +static void followShown(lv_obj_t* btn, bool on) { + if (!btn) return; + lv_obj_t* l = lv_obj_get_child(btn, 0); + lv_color_t c = lv_color_hex(on ? theme::ACCENT : theme::TEXT); + if (!lv_color_eq(lv_obj_get_style_text_color(l, LV_PART_MAIN), c)) lv_obj_set_style_text_color(l, c, 0); +} + static lv_obj_t* mapButton(lv_obj_t* parent, const char* text, lv_event_cb_t cb) { lv_obj_t* b = lv_button_create(parent); lv_obj_set_size(b, 40, 40); @@ -351,7 +366,8 @@ void UITask::buildMap() { if (_map_nav) lv_obj_align(mapButton(body, LV_SYMBOL_BARS, onNavTools), LV_ALIGN_TOP_LEFT, 6, 98); else lv_obj_align(mapButton(body, LV_SYMBOL_DOWNLOAD, onMapDownload), LV_ALIGN_TOP_RIGHT, -6, 98); int bottom = _map_nav ? navmap::BAR_H : 0; // the nav bar takes the bottom edge - lv_obj_align(mapButton(body, LV_SYMBOL_GPS, onMapCenter), LV_ALIGN_BOTTOM_RIGHT, -6, -6 - bottom); + _map_center_btn = mapButton(body, LV_SYMBOL_GPS, onMapCenter); + lv_obj_align(_map_center_btn, LV_ALIGN_BOTTOM_RIGHT, -6, -6 - bottom); _map_dl_pill = mapPill(body, ""); // tap: the download popup lv_obj_set_style_text_color(_map_dl_pill, lv_color_hex(theme::ACCENT), 0); lv_obj_set_style_pad_ver(_map_dl_pill, 3, 0); @@ -485,6 +501,7 @@ void UITask::layoutMap() { } else { lv_obj_add_flag(_map_me, LV_OBJ_FLAG_HIDDEN); } + followShown(_map_center_btn, _map_follow); const int half = mapview::MARK_D / 2; for (int k = 0; k < mapview::s_mark_count; k++) { const mapview::Mark& m = mapview::s_marks[k]; @@ -515,18 +532,20 @@ void UITask::layoutMap() { // One tile decode per call, nearest to the centre first. void UITask::mapLoop() { + if (_map_follow && (int32_t)(millis() - _next_follow_ms) >= 0) { // following you: every fix + _next_follow_ms = millis() + 1000; + int32_t lat, lon; + if (_core->course.currentLocation(lat, lon)) { + double cx = mapview::lonToTileX(lon / 1e6, _map_z), cy = mapview::latToTileY(lat / 1e6, _map_z) + mapCenterBias(); + if (cx != _map_cx || cy != _map_cy) { _map_cx = cx; _map_cy = cy; layoutMap(); } + } + } if ((int32_t)(millis() - _next_map_marks_ms) >= 0) { _next_map_marks_ms = millis() + 3000; - if (_map_follow) { - int32_t lat, lon; - if (_core->course.currentLocation(lat, lon)) { - _map_cx = mapview::lonToTileX(lon / 1e6, _map_z); - _map_cy = mapview::latToTileY(lat / 1e6, _map_z) + mapCenterBias(); - } - } rebuildMapMarkers(); layoutMap(); } + if (_map_nav && navTrailSync()) layoutMap(); // a new trail point: the line keeps up with you if (_map_nav) navPollAveraging(); if (_map_nav && (int32_t)(millis() - _next_nav_bar_ms) >= 0) { _next_nav_bar_ms = millis() + 1000; @@ -668,7 +687,7 @@ void UITask::mapZoom(int delta) { void UITask::mapCenterOnMe() { int32_t lat, lon; - if (!ensureGps() || !_core->course.currentLocation(lat, lon)) { _map_follow = true; return; } // centres once a fix comes + if (!ensureGps() || !_core->course.currentLocation(lat, lon)) { _map_follow = true; followShown(_map_center_btn, true); return; } // centres once a fix comes _map_follow = true; _map_cx = mapview::lonToTileX(lon / 1e6, _map_z); _map_cy = mapview::latToTileY(lat / 1e6, _map_z) + mapCenterBias(); diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index e555802e..4672d4bc 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -18,19 +18,34 @@ namespace navmap { enum : uint8_t { WP_NAV, WP_RENAME, WP_SHARE, WP_DELETE }; -// Trail: normalised Web-Mercator coords (0..1) cached when the trail is -// re-read, so a pan only scales and offsets them. One lv_line per recorded -// segment (a pause starts a new one); extra segments join the last. -static const int TRAIL_SEGS = 32; // pauses / saves split the trail; beyond this, segments join +// Trail: normalised Web-Mercator coords (0..1) cached as points are added, so +// a redraw only scales and offsets them. Drawn by one object as a single +// polyline with breaks (LV_DRAW_LINE_POINT_NONE) where a pause starts a new +// segment and where a stretch lies outside the view: an lv_line per stretch +// would make LVGL queue one draw task per point, the queue walked to its end +// for each -- tens of thousands of points made every frame crawl. +static const int TRAIL_SEGS = 256; // pauses / saves split the trail; beyond this, segments join // Stored as float offsets from the first point (s_ox/s_oy): a float holds a // small offset exactly enough, but not a whole 0..1 coordinate at street zoom // (2^18 * 256 px across -- a float is off by pixels there). static float* s_nx = nullptr; // TrailStore::CAPACITY each, in PSRAM (first trail drawn) static float* s_ny = nullptr; static double s_ox = 0, s_oy = 0; +// Bounding box of each CHUNK points (and the next chunk's first, so the line +// joining them is inside too): a chunk off the view is skipped whole. +static const int CHUNK = 64; +static const int CHUNKS = (TrailStore::CAPACITY + CHUNK - 1) / CHUNK; +static float* s_cb = nullptr; // min x, min y, max x, max y per chunk +// Screen points of the visible stretches, with breaks. +static const int PTS_MAX = TrailStore::CAPACITY + 2 * CHUNKS + TRAIL_SEGS + 4; static lv_point_precise_t* s_pts = nullptr; -static int s_seg_first[TRAIL_SEGS], s_seg_len[TRAIL_SEGS], s_segs = 0; -static lv_obj_t* s_trail[TRAIL_SEGS]; +static int s_pts_n = 0; +static int32_t s_pts_x0 = 0, s_pts_y0 = 0; // the trail object's screen origin s_pts was made for +static int s_seg_first[TRAIL_SEGS], s_segs = 0; +// What navTrailSync() has converted so far (s_sync_gen reset: all of it again). +static uint32_t s_sync_gen = 0xFFFFFFFF, s_sync_seq = 0, s_sync_full_at = 0; +static int s_sync_n = 0; +static lv_obj_t* s_trail = nullptr; static lv_obj_t* s_target_line = nullptr; static lv_point_precise_t s_target_pts[2]; static lv_obj_t* s_target_ring = nullptr; @@ -53,6 +68,30 @@ static double normY(int32_t lat_e6) { return (1.0 - asinh(tan(r)) / M_PI) / 2.0; } +// The trail object's draw: s_pts in one draw task (the software renderer +// walks it a segment at a time, clipped to the band being drawn). +static void onTrailDraw(lv_event_t* e) { + if (s_pts_n < 2) return; + lv_obj_t* obj = lv_event_get_current_target_obj(e); + lv_area_t a; + lv_obj_get_coords(obj, &a); + if (a.x1 != s_pts_x0 || a.y1 != s_pts_y0) { // moved since the points were made + for (int i = 0; i < s_pts_n; i++) { + if (s_pts[i].x == LV_DRAW_LINE_POINT_NONE) continue; + s_pts[i].x += a.x1 - s_pts_x0; + s_pts[i].y += a.y1 - s_pts_y0; + } + s_pts_x0 = a.x1; s_pts_y0 = a.y1; + } + lv_draw_line_dsc_t d; + lv_draw_line_dsc_init(&d); + lv_obj_init_draw_line_dsc(obj, LV_PART_MAIN, &d); + d.base.layer = lv_event_get_layer(e); + d.points = s_pts; + d.point_cnt = s_pts_n; + lv_draw_line(d.base.layer, &d); +} + // Waypoint labels are 11 bytes of UTF-8 (Waypoint.h): reject input past that, // so a two-byte letter is never cut in half. static void onLabelInsert(lv_event_t* e) { @@ -77,16 +116,16 @@ static void onNavRenameKb(lv_event_t* e) { s_ui->navRenameDone(lv_event_get_code // ── Layers and controls (built by buildMap in nav mode) ─────────────────────── void UITask::buildNavLayers() { - for (int i = 0; i < navmap::TRAIL_SEGS; i++) { - lv_obj_t* l = lv_line_create(_map_marks); - lv_obj_set_size(l, LV_PCT(100), LV_PCT(100)); // clip to the view, whatever the points - lv_obj_set_style_line_width(l, 3, 0); - lv_obj_set_style_line_color(l, lv_color_hex(navmap::TRAIL_COLOR), 0); - lv_obj_set_style_line_rounded(l, true, 0); - lv_obj_remove_flag(l, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_flag(l, LV_OBJ_FLAG_HIDDEN); - navmap::s_trail[i] = l; - } + lv_obj_t* tr = lv_obj_create(_map_marks); + lv_obj_remove_style_all(tr); + lv_obj_set_size(tr, LV_PCT(100), LV_PCT(100)); // clip to the view, whatever the points + lv_obj_set_style_line_width(tr, 3, 0); + lv_obj_set_style_line_color(tr, lv_color_hex(navmap::TRAIL_COLOR), 0); + lv_obj_set_style_line_rounded(tr, true, 0); + lv_obj_remove_flag(tr, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(tr, navmap::onTrailDraw, LV_EVENT_DRAW_MAIN, nullptr); + navmap::s_trail = tr; + navmap::s_pts_n = 0; navmap::s_target_line = lv_line_create(_map_marks); lv_obj_set_size(navmap::s_target_line, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_line_width(navmap::s_target_line, 2, 0); @@ -115,6 +154,7 @@ void UITask::buildNavLayers() { lv_obj_remove_flag(navmap::s_target_dot, LV_OBJ_FLAG_CLICKABLE); lv_obj_center(navmap::s_target_dot); navmap::s_segs = 0; + navmap::s_sync_gen = 0xFFFFFFFF; // a new map: the whole trail converted again } void UITask::buildNavControls(lv_obj_t* body) { @@ -188,56 +228,110 @@ void UITask::rebuildNavMarkers() { addMapMark(mapview::MK_LIVE, i, e.lat_1e6, e.lon_1e6, theme::OK, t); } - // Trail -> normalised points in segments. + navTrailSync(); +} + +// Trail -> normalised points in segments, for layoutNav(). Only the points +// added since the last call are converted (the trail runs to tens of +// thousands of points); all of them when it was replaced (reset, load) or +// its ring, full, dropped its oldest -- at most every 10 s then. +bool UITask::navTrailSync() { + using namespace navmap; TrailStore& ts = _core->trail.store(); - navmap::s_segs = 0; + if (!s_pts) { + s_nx = psramBuf(TrailStore::CAPACITY); + s_ny = psramBuf(TrailStore::CAPACITY); + s_cb = psramBuf(4 * CHUNKS); + s_pts = psramBuf(PTS_MAX); + } + if (!s_nx || !s_ny || !s_cb || !s_pts) { s_segs = 0; s_sync_n = 0; return false; } int n = ts.count(); - if (!navmap::s_pts) { - navmap::s_nx = psramBuf(TrailStore::CAPACITY); - navmap::s_ny = psramBuf(TrailStore::CAPACITY); - navmap::s_pts = psramBuf(TrailStore::CAPACITY); - } - if (!navmap::s_nx || !navmap::s_ny || !navmap::s_pts) n = 0; - for (int i = 0; i < n; i++) { + if (ts.gen() == s_sync_gen && ts.seq() == s_sync_seq && n == s_sync_n) return false; + bool all = ts.gen() != s_sync_gen || n < s_sync_n || n - s_sync_n != (int)(ts.seq() - s_sync_seq); + if (all && ts.gen() == s_sync_gen && n == TrailStore::CAPACITY && millis() - s_sync_full_at < 10000) return false; + int from = all ? 0 : s_sync_n; + if (all) { s_segs = 0; s_sync_full_at = millis(); } + for (int i = from; i < n; i++) { const TrailPoint& p = ts.at(i); - if (i == 0) { navmap::s_ox = navmap::normX(p.lon_1e6); navmap::s_oy = navmap::normY(p.lat_1e6); } - navmap::s_nx[i] = (float)(navmap::normX(p.lon_1e6) - navmap::s_ox); - navmap::s_ny[i] = (float)(navmap::normY(p.lat_1e6) - navmap::s_oy); - bool new_seg = i == 0 || ((p.flags & TRAIL_FLAG_SEG_START) && navmap::s_segs < navmap::TRAIL_SEGS); - if (new_seg) { - navmap::s_seg_first[navmap::s_segs] = i; - navmap::s_seg_len[navmap::s_segs] = 0; - navmap::s_segs++; + if (i == 0) { s_ox = normX(p.lon_1e6); s_oy = normY(p.lat_1e6); } + float x = s_nx[i] = (float)(normX(p.lon_1e6) - s_ox); + float y = s_ny[i] = (float)(normY(p.lat_1e6) - s_oy); + float* b = &s_cb[4 * (i / CHUNK)]; + if (i % CHUNK == 0) { b[0] = b[2] = x; b[1] = b[3] = y; } + for (int k = i % CHUNK == 0 && i > 0 ? 2 : 1; k > 0; k--, b -= 4) { // this chunk; a first point also closes the last + if (x < b[0]) b[0] = x; + if (y < b[1]) b[1] = y; + if (x > b[2]) b[2] = x; + if (y > b[3]) b[3] = y; } - navmap::s_seg_len[navmap::s_segs - 1]++; + if (i == 0 || ((p.flags & TRAIL_FLAG_SEG_START) && s_segs < TRAIL_SEGS)) s_seg_first[s_segs++] = i; } + s_sync_gen = ts.gen(); + s_sync_seq = ts.seq(); + s_sync_n = n; + return true; +} + +// The trail's screen points for layoutNav(): only the chunks whose box meets +// the view, and of those only points 2 px or more from the last kept one +// (zoomed out, thousands of points shrink to the few hundred that show). A +// point dropped that way still ends its stretch, so the line reaches it. +static void layoutNavTrail(double scale) { + using namespace navmap; + s_pts_n = 0; + if (!s_trail) return; + lv_obj_invalidate(s_trail); + int n = s_sync_n; + if (!s_pts || n < 2) return; + lv_area_t a; + lv_obj_get_coords(s_trail, &a); + s_pts_x0 = a.x1; s_pts_y0 = a.y1; + const double M = 8; // past the edge: the line's width, rounded caps + float fs = (float)scale; + float ox = (float)(s_ox * scale - mapview::s_left) + a.x1; + float oy = (float)(s_oy * scale - mapview::s_top) + a.y1; + float vx0 = (float)((mapview::s_left - M) / scale - s_ox), vx1 = (float)((mapview::s_left + lv_area_get_width(&a) + M) / scale - s_ox); + float vy0 = (float)((mapview::s_top - M) / scale - s_oy), vy1 = (float)((mapview::s_top + lv_area_get_height(&a) + M) / scale - s_oy); + + bool open = false, held = false; // a stretch is being drawn; a thinned-out point is owed at its end + lv_point_precise_t hold = {0, 0}, last = {0, 0}; + auto push = [&](lv_value_precise_t x, lv_value_precise_t y) { s_pts[s_pts_n].x = x; s_pts[s_pts_n].y = y; s_pts_n++; }; + auto endRun = [&]() { + if (held) push(hold.x, hold.y); + if (open) push(LV_DRAW_LINE_POINT_NONE, LV_DRAW_LINE_POINT_NONE); + open = held = false; + }; + auto emit = [&](int i) { + lv_value_precise_t x = (lv_value_precise_t)lroundf(s_nx[i] * fs + ox); + lv_value_precise_t y = (lv_value_precise_t)lroundf(s_ny[i] * fs + oy); + if (open && fabsf((float)(x - last.x)) < 2 && fabsf((float)(y - last.y)) < 2) { hold.x = x; hold.y = y; held = true; return; } + push(x, y); + last.x = x; last.y = y; + open = true; held = false; + }; + int seg = 1; // s_seg_first[0] is the first point + for (int i0 = 0; i0 < n; i0 += CHUNK) { + int i1 = i0 + CHUNK < n ? i0 + CHUNK : n; + const float* b = &s_cb[4 * (i0 / CHUNK)]; + bool starts = seg < s_segs && s_seg_first[seg] == i0; + if (b[2] < vx0 || b[0] > vx1 || b[3] < vy0 || b[1] > vy1) { // off the view + if (open && !starts) emit(i0); // the last chunk's line to this one's first point + endRun(); + while (seg < s_segs && s_seg_first[seg] < i1) seg++; + continue; + } + for (int i = i0; i < i1; i++) { + if (seg < s_segs && s_seg_first[seg] == i) { endRun(); seg++; } + emit(i); + } + } + endRun(); } void UITask::layoutNav() { if (!navmap::s_target_line) return; double scale = (double)(1 << _map_z) * mapview::TILE_PX; - for (int s = 0; s < navmap::TRAIL_SEGS; s++) { - lv_obj_t* l = navmap::s_trail[s]; - if (s >= navmap::s_segs || navmap::s_seg_len[s] < 2) { lv_obj_add_flag(l, LV_OBJ_FLAG_HIDDEN); continue; } - // Points closer than 2 px to the last kept one are dropped (the last - // point always stays): zoomed out, thousands of points shrink to the few - // hundred that show, so a long trail doesn't slow every map redraw. - int f = navmap::s_seg_first[s], len = navmap::s_seg_len[s], k = 0; - for (int i = f; i < f + len; i++) { - lv_value_precise_t x = (lv_value_precise_t)lround((navmap::s_ox + navmap::s_nx[i]) * scale - mapview::s_left); - lv_value_precise_t y = (lv_value_precise_t)lround((navmap::s_oy + navmap::s_ny[i]) * scale - mapview::s_top); - if (k > 0 && i < f + len - 1) { - lv_point_precise_t& last = navmap::s_pts[f + k - 1]; - if (fabsf((float)(x - last.x)) < 2 && fabsf((float)(y - last.y)) < 2) continue; - } - navmap::s_pts[f + k].x = x; - navmap::s_pts[f + k].y = y; - k++; - } - if (k < 2) { lv_obj_add_flag(l, LV_OBJ_FLAG_HIDDEN); continue; } - lv_line_set_points(l, &navmap::s_pts[f], k); - lv_obj_remove_flag(l, LV_OBJ_FLAG_HIDDEN); - } + layoutNavTrail(scale); int32_t tlat, tlon, mlat, mlon; char name[8]; @@ -819,6 +913,7 @@ static void scanTrails() { while (struct dirent* de = readdir(d)) { size_t l = strlen(de->d_name); if (l < 5 || l >= sizeof(s_st_names[0]) || strcmp(de->d_name + l - 4, ".trl") != 0) continue; + if (de->d_name[0] == '.') continue; // the live trail's own copy (.live.trl) if (s_st_n < ST_MAX) snprintf(s_st_names[s_st_n++], sizeof(s_st_names[0]), "%s", de->d_name); } closedir(d); @@ -1015,6 +1110,75 @@ bool UITask::saveTrailToCard(char* name_out, size_t n) { return ok; } +// ── The live trail's copy on the card ── +// While there's a trail, /sdcard/trails/.live.trl holds a copy of it: the new +// points appended once a minute and at power-off, so a flat battery, a crash +// or a reboot doesn't lose the route. At boot the copy comes back, recording +// again if it was. A saved trail's format, the header's reserved byte (5) +// saying "recording"; count and time (6, 8) are patched in place. A full +// ring (the oldest points dropping off) is rewritten, at most every 10 min. +namespace navmap { +static const char* const LIVE_TRAIL = "/sdcard/trails/.live.trl"; +static const long LIVE_HDR = 12; // magic, version, reserved, count (uint16), time (uint32) +static uint32_t s_j_gen = 0xFFFFFFFF, s_j_seq = 0, s_j_next = 0, s_j_full_at = 0; +static int s_j_n = 0; +static uint8_t s_j_rec = 0; // the "recording" byte last written +} + +bool UITask::trailJournalTick(bool now) { + using namespace navmap; + if (!now && (int32_t)(millis() - s_j_next) < 0) return true; + s_j_next = millis() + 60000; + TrailStore& ts = _core->trail.store(); + int n = ts.count(); + uint8_t rec = ts.isActive() ? 1 : 0; + bool same = ts.gen() == s_j_gen && ts.seq() == s_j_seq && n == s_j_n; + if (same && (n == 0 || (!rec && s_j_rec == rec))) return true; // nothing to write (and no card mount tried) + if (!lvport::mountStorage()) return false; + if (n == 0) { // reset: no copy + if (s_j_n) remove(LIVE_TRAIL); // only a copy this run wrote (or restored) + s_j_gen = ts.gen(); s_j_seq = ts.seq(); s_j_n = 0; + return true; + } + bool all = ts.gen() != s_j_gen || n < s_j_n || n - s_j_n != (int)(ts.seq() - s_j_seq); + if (all && ts.gen() == s_j_gen && n == TrailStore::CAPACITY && !now && millis() - s_j_full_at < 600000) return true; + FILE* f = all ? nullptr : fopen(LIVE_TRAIL, "r+b"); + bool ok; + if (!f) { // the whole trail + mapview::makeParents(LIVE_TRAIL); + f = fopen(LIVE_TRAIL, "wb"); + if (!f) return false; + navmap::FileRW io{ f }; + ok = ts.writeTo(io); + s_j_full_at = millis(); + } else { // just what's new + ok = fseek(f, 0, SEEK_END) == 0; + for (int i = s_j_n; ok && i < n; i++) ok = fwrite(&ts.at(i), sizeof(TrailPoint), 1, f) == 1; + } + uint16_t cnt = (uint16_t)n; + uint32_t accum = ts.currentAccumulatedMs(); + ok = ok && fseek(f, 5, SEEK_SET) == 0 && fwrite(&rec, 1, 1, f) == 1 && fwrite(&cnt, 2, 1, f) == 1 && fwrite(&accum, 4, 1, f) == 1; + ok = (fclose(f) == 0) && ok; + if (ok) { s_j_gen = ts.gen(); s_j_seq = ts.seq(); s_j_n = n; s_j_rec = rec; } + else s_j_gen = 0xFFFFFFFF; // next time the whole trail again + return ok; +} + +void UITask::trailJournalRestore() { + using namespace navmap; + TrailStore& ts = _core->trail.store(); + if (!ts.empty() || !lvport::mountStorage()) return; + FILE* f = fopen(LIVE_TRAIL, "rb"); + if (!f) return; + uint8_t rec = 0; + navmap::FileRW io{ f }; + bool ok = ts.readFrom(io) && fseek(f, 5, SEEK_SET) == 0 && fread(&rec, 1, 1, f) == 1; + fclose(f); + if (!ok || ts.empty()) return; + if (rec & 1) ts.setActive(true); // it was recording: carries on (a gap for the time off) + s_j_gen = ts.gen(); s_j_seq = ts.seq(); s_j_n = ts.count(); s_j_rec = rec & 1; +} + // Map tools > Load, with a card: the saved trails, newest first, and the // device's own slot (manual saves without a card, the low-battery auto-save). void UITask::savedTrailsPopup() { diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 306920a8..d4ac3f43 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -893,6 +893,7 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs s_archive.restore(_core->history); _core->history.setArchive(&s_archive); } + trailJournalRestore(); // a trail cut off by a restart or a flat battery _tap_wake = lvport::loadTapWake(); msgtext::seedQuick(node_prefs); // "OK" in quick message 1 on first boot _nearby = new NearbyModel(); @@ -1080,6 +1081,7 @@ void UITask::loop() { checkLowBattery(); mapDownloadTick(); // a map download keeps running on any screen, and asleep otaTick(); // so does a firmware update + trailJournalTick(); // the live trail's copy on the card, once a minute if ((int32_t)(millis() - _next_trackback_ms) >= 0) { // walking the trail back, on any screen _next_trackback_ms = millis() + 1000; navPollTrackBack(); @@ -1152,7 +1154,7 @@ void UITask::shutdown(bool restart) { the_mesh.savePrefs(); the_mesh.saveRTCTime(); the_mesh.flushDirtyContacts(); - _core->trail.onShutdown(); + if (!trailJournalTick(true)) _core->trail.onShutdown(); // no card: the internal slot, if enabled #ifdef PIN_BUZZER _buzzer.shutdown(); // the goodbye sound, unless muted #ifndef SIM_PLATFORM // the sim's single thread would freeze the page @@ -1667,16 +1669,19 @@ static uint32_t s_wifi_test_ms = 0; // Settings > WiFi checking a just-saved n namespace home { static void leave(); } // HomeScreen.h +static int s_th_rows_n = 0; // conversation bubbles built (refreshThread) + lv_obj_t* UITask::newScreen(const char* title, bool with_back) { _home_clock = _home_date = _home_unread = nullptr; _thread_list = _compose_ta = _keyboard = nullptr; + s_th_rows_n = 0; // the bubbles went with the screen if (_shown_screen == SCR_SETTINGS && _body) s_settings_y = lv_obj_get_scroll_y(_body); pickerClose(); _header = _body = nullptr; _nearby_list = _nearby_status = _nearby_sort_lbl = _nearby_chips = nullptr; _node_info = _node_ping = _node_delete_lbl = nullptr; _scan_overlay = _scan_list = _scan_status = nullptr; // the popup went with the old screen - _map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = _map_dl_pill = nullptr; + _map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = _map_dl_pill = _map_center_btn = nullptr; _dl_overlay = _dl_info = _dl_zoom_lbl = _dl_start_lbl = _dl_job_row = _dl_job_lbl = nullptr; _nav_bar = _nav_title = _nav_info = _nav_clear = nullptr; _nav_overlay = _nav_ta = _nav_kb = _nav_del_lbl = _nav_rec = _nav_avg_pill = nullptr; @@ -2343,8 +2348,12 @@ void UITask::refreshNearbyList() { if (sig == _nearby_sig) return; _nearby_sig = sig; + // The same number of nodes (the usual case: ages, distances, signal + // change): the rows rewritten in place, not built again. + bool in_place = n > 0 && (int)lv_obj_get_child_count(_nearby_list) == n + && lv_obj_check_type(lv_obj_get_child(_nearby_list, 0), &lv_button_class); int32_t scroll = lv_obj_get_scroll_y(_nearby_list); - lv_obj_clean(_nearby_list); + if (!in_place) lv_obj_clean(_nearby_list); uint32_t now = rtc_clock.getCurrentTime(); bool imperial = _prefs && _prefs->units_imperial; for (int i = 0; i < n; i++) { @@ -2359,13 +2368,33 @@ void UITask::refreshNearbyList() { const char* kind = e.contact_idx >= 0 ? NearbyModel::typeName(e.type) : "not a contact"; snprintf(sub, sizeof(sub), "%s%s%s%s", kind, e.is_live ? " - live" : "", (e.dist_km >= 0.0f && age[0]) ? " - " : "", (e.dist_km >= 0.0f && age[0]) ? age : ""); + uint32_t right_col = e.is_live ? theme::OK : theme::TEXT_MUTED; + if (in_place) { + lv_obj_t* row = lv_obj_get_child(_nearby_list, i); + lv_obj_t* t = rowTitle(row); + setText(t, title); + lv_color_t tc = lv_color_hex(e.fav ? theme::ACCENT : theme::TEXT); + if (!lv_color_eq(lv_obj_get_style_text_color(t, LV_PART_MAIN), tc)) lv_obj_set_style_text_color(t, tc, 0); + setText(rowSub(row), sub); + lv_obj_t* r = lv_obj_get_child(row, 2); + if (!r && right[0]) { + r = label(row, right, THEME_FONT_SMALL, right_col); + lv_obj_align(r, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + } else if (r) { + setText(r, right); + lv_color_t rc = lv_color_hex(right_col); + if (!lv_color_eq(lv_obj_get_style_text_color(r, LV_PART_MAIN), rc)) lv_obj_set_style_text_color(r, rc, 0); + } + continue; + } lv_obj_t* row = listRow(_nearby_list, title, sub, onNearbyRow, (void*)(uintptr_t)i); if (e.fav) lv_obj_set_style_text_color(rowTitle(row), lv_color_hex(theme::ACCENT), 0); if (right[0]) { - lv_obj_t* r = label(row, right, THEME_FONT_SMALL, e.is_live ? theme::OK : theme::TEXT_MUTED); + lv_obj_t* r = label(row, right, THEME_FONT_SMALL, right_col); lv_obj_align(r, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); } } + if (in_place) return; if (n == 0) { lv_obj_t* l = label(_nearby_list, "Nobody here yet. Tap Scan to look around.", THEME_FONT_BODY, theme::TEXT_MUTED); @@ -2672,6 +2701,7 @@ void UITask::buildThread() { } _thread_list = scrollList(body); + s_th_rows_n = 0; lv_obj_set_style_pad_all(_thread_list, theme::PAD, 0); _compose_ta = nullptr; @@ -2750,25 +2780,33 @@ static ChHistEntry* s_th_ch = psramBuf(THREAD_MAX_SHOWN); static DmHistEntry* s_th_dm = psramBuf(THREAD_MAX_SHOWN); // What a held bubble is about: its entry (index into s_th_ch / s_th_dm), -// sender, position slot. +// sender, position slot. One per shown message, at its index. struct MsgMeta { int pos; bool channel; bool own; int loc; char from[32]; }; static MsgMeta* s_msg_meta = psramBuf(THREAD_MAX_SHOWN); static int s_msg_meta_n = 0; -static int noteMsgMeta(int pos, bool channel, bool own, int loc, const char* from) { - if (s_msg_meta_n >= THREAD_MAX_SHOWN) return -1; - MsgMeta& m = s_msg_meta[s_msg_meta_n]; - m.pos = pos; m.channel = channel; m.own = own; m.loc = loc; - snprintf(m.from, sizeof(m.from), "%s", from ? from : ""); - return s_msg_meta_n++; +// The bubbles built, one per shown message: which message (key), its +// delivery state, its row and age label. A refresh that only adds messages +// or changes a delivery mark touches just those bubbles -- rebuilding all 50 +// on every repeater echo made the conversation stutter. +struct ThreadRow { uint32_t key, state; lv_obj_t* row; lv_obj_t* age; }; +static ThreadRow* s_th_rows = psramBuf(THREAD_MAX_SHOWN); +static lv_obj_t* s_older_lbl = nullptr; // "Older messages (n)" on the page's top +static bool s_th_rows_newest = false; // built for the newest page (the one patched) + +// The shown message a bubble (or a button in it) belongs to, or -1. +static int threadRowOf(lv_obj_t* o) { + for (; o; o = lv_obj_get_parent(o)) + for (int r = 0; r < s_th_rows_n; r++) if (s_th_rows[r].row == o) return r; + return -1; } static void onMsgLoc(lv_event_t* e) { - uintptr_t v = (uintptr_t)lv_event_get_user_data(e); - s_ui->messageLocationAction((int)(v >> 1), (v & 1) != 0); + int r = threadRowOf((lv_obj_t*)lv_event_get_current_target(e)); + if (r >= 0) s_ui->messageLocationAction(s_msg_meta[r].loc, (uintptr_t)lv_event_get_user_data(e) != 0); } -static void msgLocButton(lv_obj_t* parent, const char* text, int idx, bool save, bool accent) { +static void msgLocButton(lv_obj_t* parent, const char* text, bool save, bool accent) { lv_obj_t* b = lv_button_create(parent); lv_obj_set_size(b, LV_SIZE_CONTENT, 28); lv_obj_set_style_pad_hor(b, 10, 0); @@ -2776,7 +2814,7 @@ static void msgLocButton(lv_obj_t* parent, const char* text, int idx, bool save, lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_shadow_width(b, 0, 0); lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT : theme::SURFACE_2), 0); - lv_obj_add_event_cb(b, onMsgLoc, LV_EVENT_CLICKED, (void*)(uintptr_t)((idx << 1) | (save ? 1 : 0))); + lv_obj_add_event_cb(b, onMsgLoc, LV_EVENT_CLICKED, (void*)(uintptr_t)(save ? 1 : 0)); lv_obj_center(label(b, text, THEME_FONT_SMALL, accent ? theme::BG : theme::TEXT)); } @@ -2834,10 +2872,11 @@ static lv_obj_t* msgText(lv_obj_t* parent, const char* text, bool own, int max_w } // One message bubble. Own messages right-aligned in amber, others left. -// loc_idx >= 0: the text carries a position (s_msg_locs[loc_idx]). -static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, - uint32_t ts, const char* status, uint32_t status_col, int loc_idx = -1, int meta_idx = -1, - int relays = 0) { +// loc: the text carries a position (Go / Save buttons); hold: a long press +// opens the message menu. Returns its row; *age_out = the age label. +static lv_obj_t* bubble(lv_obj_t* list, const char* from, const char* text, bool own, + uint32_t ts, const char* status, uint32_t status_col, bool loc, bool hold, + int relays, lv_obj_t** age_out) { // Full-width row that pushes the bubble to its side. lv_obj_t* row = lv_obj_create(list); styleSurface(row, theme::BG); @@ -2851,8 +2890,8 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, lv_obj_t* b = lv_obj_create(row); lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); - if (meta_idx >= 0) { // hold: reply / path / target (ConversationScreen.h) - lv_obj_add_event_cb(b, onMsgHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)meta_idx); + if (hold) { // hold: reply / path / target (ConversationScreen.h) + lv_obj_add_event_cb(b, onMsgHold, LV_EVENT_LONG_PRESSED, nullptr); lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); } else { lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); @@ -2877,7 +2916,7 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, lv_obj_t* hdr = flexBox(b, LV_FLEX_FLOW_ROW); lv_obj_set_style_pad_column(hdr, 8, 0); label(hdr, from, THEME_FONT_SMALL, theme::ACCENT); // names are <= 31 chars: fits the bubble - if (meta_in_header) label(hdr, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); + if (meta_in_header) *age_out = label(hdr, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); } bool mentions_me; msgText(b, text, own, 238, &mentions_me); @@ -2886,19 +2925,19 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, lv_obj_set_style_border_color(b, lv_color_hex(theme::ACCENT), 0); } - if (loc_idx >= 0) { // position: navigate there / keep it as a waypoint + if (loc) { // position: navigate there / keep it as a waypoint lv_obj_t* acts = flexBox(b, LV_FLEX_FLOW_ROW); lv_obj_set_style_pad_column(acts, 6, 0); lv_obj_set_style_pad_top(acts, 2, 0); - msgLocButton(acts, UI_SYMBOL_COMPASS " Go", loc_idx, false, true); - msgLocButton(acts, UI_SYMBOL_FLAG " Save", loc_idx, true, false); + msgLocButton(acts, UI_SYMBOL_COMPASS " Go", false, true); + msgLocButton(acts, UI_SYMBOL_FLAG " Save", true, false); } if (!meta_in_header) { // the age, then the delivery mark in its colour (as L1) lv_obj_t* line = flexBox(b, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(line, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(line, 6, 0); - label(line, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); + *age_out = label(line, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); if (relays > 0) { // as L1: the count alone says it got out, no check beside it lv_obj_t* c = lv_obj_create(line); lv_obj_remove_style_all(c); @@ -2916,16 +2955,16 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own, label(line, status, THEME_FONT_SMALL, status_col); } } + return row; } -// Remember the position in `text` (if any) for its bubble's buttons; label -// from a [WAY] tag, else the sender. Returns the slot or -1. -static int noteMsgLocation(const char* text, const char* sender) { - if (s_msg_loc_n >= THREAD_MAX_SHOWN) return -1; - MsgLoc& m = s_msg_locs[s_msg_loc_n]; +// Remember the position in `text` (if any) for its bubble's buttons, in +// slot r; label from a [WAY] tag, else the sender. Returns r or -1. +static int noteMsgLocation(int r, const char* text, const char* sender) { + MsgLoc& m = s_msg_locs[r]; if (!geo::parseLatLon(text, m.lat, m.lon, m.label, sizeof(m.label))) return -1; if (!m.label[0]) snprintf(m.label, sizeof(m.label), "%s", sender && sender[0] ? sender : "Msg loc"); - return s_msg_loc_n++; + return r; } void UITask::messageLocationAction(int idx, bool save) { @@ -2956,11 +2995,12 @@ int UITask::loadThreadPage(int& total) { char path[64]; if (_thread_is_channel) { int ring = h.histCountForChannel(_thread_channel); - int arc = s_archive.ready() && s_archive.chPath(_thread_channel, path, sizeof(path)) - ? s_archive.total(path) : 0; + int arc = 0; + int got = s_archive.ready() && s_archive.chPath(_thread_channel, path, sizeof(path)) + ? s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_ch, &arc) : 0; if (arc > 0 && arc >= ring) { total = arc; - return s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_ch); + return got; } total = ring; int m = ring - _thread_skip; @@ -2969,14 +3009,14 @@ int UITask::loadThreadPage(int& total) { return m > 0 ? m : 0; } int ring = h.dmHistCountForContact(_thread_key); - int arc = 0; + int arc = 0, got = 0; if (s_archive.ready()) { histstore::SdArchive::dmPath(_thread_key, path, sizeof(path)); - arc = s_archive.total(path); + got = s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_dm, &arc); } if (arc > 0 && arc >= ring) { total = arc; - return s_archive.window(path, _thread_skip, THREAD_MAX_SHOWN, s_th_dm); + return got; } total = ring; int m = ring - _thread_skip; @@ -3007,23 +3047,36 @@ void UITask::refreshThread() { if (!_thread_list) return; _thread_dirty = false; _thread_sig = threadSignature(); - lv_obj_clean(_thread_list); const MessageHistory& h = _core->history; - s_msg_loc_n = 0; - s_msg_meta_n = 0; int total = 0; int n = loadThreadPage(total); if (n == 0 && _thread_skip > 0) { // the page went away (history trimmed) _thread_skip = 0; n = loadThreadPage(total); - } if (total > _thread_skip + n) { - char t[40]; - snprintf(t, sizeof(t), LV_SYMBOL_UP " Older messages (%d)", total - _thread_skip - n); - pageButton(_thread_list, t, 1); } + ContactInfo tc; + bool room = !_thread_is_channel && MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM; - if (_thread_is_channel) { - for (int i = 0; i < n; i++) { // oldest first + // Which message entry i is, and the delivery state its bubble shows. + auto keyOf = [&](int i) { + uint32_t ts = _thread_is_channel ? s_th_ch[i].timestamp : s_th_dm[i].timestamp; + const char* t = _thread_is_channel ? s_th_ch[i].text : s_th_dm[i].text; + uint32_t k = 2166136261u ^ ts; + for (; *t; t++) k = (k ^ (uint8_t)*t) * 16777619u; + return k; + }; + auto stateOf = [&](int i) -> uint32_t { + if (_thread_is_channel) return s_th_ch[i].relay_status | (uint32_t)s_th_ch[i].path_len << 8; + return s_th_dm[i].outgoing ? 0x100u | h.dmEffectiveStatus(s_th_dm[i]) : 0; + }; + // Entry i's bubble (at the list's end) and its menu / position slots. + auto build = [&](int i) { + MsgMeta& m = s_msg_meta[i]; + const char* st = NULL; uint32_t col = theme::TEXT_MUTED; + int nrel = 0; + lv_obj_t* age = nullptr; + lv_obj_t* row; + if (_thread_is_channel) { const ChHistEntry& e = s_th_ch[i]; // Channel text is "Sender: body"; our own posts are filed as "Me: body". char from[40] = ""; @@ -3038,22 +3091,16 @@ void UITask::refreshThread() { // Own posts: once a repeater echoed it, how many distinct repeaters did // (markChannelRelayed), or a check if that's unknown; nothing before -- // no echo is normal. - const char* st = NULL; uint32_t col = theme::TEXT_MUTED; - int nrel = 0; if (own && e.relay_status == ACK_OK) { nrel = e.path_len & 63; st = LV_SYMBOL_OK; col = theme::OK; } else if (own) st = ""; - int loc = own ? -1 : noteMsgLocation(body, from); - bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col, - loc, noteMsgMeta(i, true, own, loc, own ? "" : from), nrel); - } - } else { - ContactInfo tc; - bool room = MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM; - for (int i = 0; i < n; i++) { + int loc = own ? -1 : noteMsgLocation(i, body, from); + m = { i, true, own, loc, "" }; + if (!own) snprintf(m.from, sizeof(m.from), "%s", from); + row = bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col, loc >= 0, true, nrel, &age); + } else { const DmHistEntry& e = s_th_dm[i]; - const char* st = NULL; uint32_t col = theme::TEXT_MUTED; if (e.outgoing) { switch (h.dmEffectiveStatus(e)) { case ACK_OK: st = LV_SYMBOL_OK; col = theme::OK; break; @@ -3067,11 +3114,71 @@ void UITask::refreshThread() { const char* text = e.text; if (!e.outgoing && room) text = contactctl::splitRoomPost(e.text, who, sizeof(who)); else if (!e.outgoing) contactName(_thread_key, who, sizeof(who)); - int loc = e.outgoing ? -1 : noteMsgLocation(text, who); - bubble(_thread_list, room && !e.outgoing ? who : NULL, text, e.outgoing, e.timestamp, st, col, - loc, e.outgoing ? -1 : noteMsgMeta(i, false, false, loc, who)); // nothing to show for our own DM + int loc = e.outgoing ? -1 : noteMsgLocation(i, text, who); + m = { i, false, false, loc, "" }; + snprintf(m.from, sizeof(m.from), "%s", who); + row = bubble(_thread_list, room && !e.outgoing ? who : NULL, text, e.outgoing, e.timestamp, st, col, + loc >= 0, !e.outgoing, 0, &age); // nothing to show for our own DM } + s_th_rows[i] = { keyOf(i), stateOf(i), row, age }; + }; + auto olderText = [&](char* t, int sz) { snprintf(t, sz, LV_SYMBOL_UP " Older messages (%d)", total - _thread_skip - n); }; + + // The same page as built, with messages added at the end (the oldest + // dropping off the top) and delivery marks changed: patch it. + int kept = -1; // bubbles still shown, after `drop` gone from the top + int drop = 0; + if (s_th_rows_newest && s_th_rows_n > 0 && n > 0 && _thread_skip == 0 && (total > n) == (s_older_lbl != nullptr)) { + for (drop = 0; drop < s_th_rows_n && kept < 0; drop++) { + int m = s_th_rows_n - drop; + if (m > n) continue; + bool same = true; + for (int r = 0; r < m && same; r++) same = s_th_rows[drop + r].key == keyOf(r); + if (same) kept = m; + } + drop--; } + if (kept > 0) { + for (int r = 0; r < drop; r++) lv_obj_delete(s_th_rows[r].row); + memmove(s_th_rows, s_th_rows + drop, kept * sizeof(ThreadRow)); + memmove(s_msg_meta, s_msg_meta + drop, kept * sizeof(MsgMeta)); + for (int r = 0; r < kept; r++) { + if (s_th_rows[r].state == stateOf(r)) { // unchanged: its slots, and a fresh age + const ChHistEntry* ce = _thread_is_channel ? &s_th_ch[r] : nullptr; + char meta[32]; + fmtMsgAge(meta, sizeof(meta), rtc_clock.getCurrentTime(), ce ? ce->timestamp : s_th_dm[r].timestamp, _prefs); + if (s_th_rows[r].age) setText(s_th_rows[r].age, meta); + s_msg_meta[r].pos = r; + if (s_msg_meta[r].loc >= 0 && drop > 0) { s_msg_locs[r] = s_msg_locs[r + drop]; s_msg_meta[r].loc = r; } + continue; + } + lv_obj_t* old = s_th_rows[r].row; // a delivery mark changed: that bubble anew, in its place + build(r); + lv_obj_move_to_index(s_th_rows[r].row, lv_obj_get_index(old)); + lv_obj_delete(old); + } + for (int r = kept; r < n; r++) build(r); + if (s_older_lbl) { char t[40]; olderText(t, sizeof(t)); setText(s_older_lbl, t); } + s_th_rows_n = s_msg_meta_n = s_msg_loc_n = n; + if (n > kept) { // new messages: down to them + lv_obj_update_layout(_thread_list); + lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF); + } + return; + } + + lv_obj_clean(_thread_list); + s_th_rows_n = 0; + s_older_lbl = nullptr; + if (total > _thread_skip + n) { + char t[40]; + olderText(t, sizeof(t)); + pageButton(_thread_list, t, 1); + s_older_lbl = lv_obj_get_child(lv_obj_get_child(_thread_list, -1), 0); + } + for (int i = 0; i < n; i++) build(i); // oldest first + s_th_rows_n = s_msg_meta_n = s_msg_loc_n = n; + s_th_rows_newest = _thread_skip == 0; // a page back is built whole each time if (_thread_skip > 0) pageButton(_thread_list, LV_SYMBOL_DOWN " Newer messages", -1); if (n == 0) label(_thread_list, "No messages yet", THEME_FONT_BODY, theme::TEXT_MUTED); lv_obj_update_layout(_thread_list); @@ -3521,6 +3628,12 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { if (!strcmp(name, "regions")) { s_ui->mapRegionsPopup(); return; } if (!strcmp(name, "region0")) { s_ui->mapRegionPopup(0); return; } if (!strcmp(name, "advert")) { s_ui->advertPopup(); return; } + if (!strncmp(name, "trail@", 6)) { // "trail@name.trl" from /sdcard/trails: Load, as the saved-trail popup does + navmap::scanTrails(); + for (int i = 0; i < navmap::s_st_n; i++) + if (!strcmp(navmap::s_st_names[i], name + 6)) { navmap::s_st_sel = i; s_ui->savedTrailAction(navmap::TL_ST_LOAD); } + return; + } } // Scrolls the screen's (or a popup's) main list by dy; returns what was left to scroll. static void dbgScrollable(lv_obj_t* o, lv_obj_t*& best, int& h) { diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 7601e5ed..70a2a4dc 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -312,7 +312,7 @@ private: void buildHomeClock(lv_obj_t* box); void buildHomeMap(lv_obj_t* box); void homeFieldText(uint8_t field, char* buf, int n); - void homeMapFit(); + bool homeMapFit(); void homeMapLayout(); void homeMapLoop(); void refreshHome(); @@ -349,6 +349,9 @@ private: void buildNavLayers(); void buildNavControls(lv_obj_t* body); void rebuildNavMarkers(); + bool navTrailSync(); + bool trailJournalTick(bool now = false); // NavMap.h: the live trail's copy on the card + void trailJournalRestore(); void layoutNav(); void refreshNavBar(); void navFrameTarget(); @@ -534,6 +537,8 @@ private: lv_obj_t* _map_marks = nullptr; lv_obj_t* _map_me = nullptr; lv_obj_t* _map_zoom_lbl = nullptr; + lv_obj_t* _map_center_btn = nullptr; // accent while the map follows you + uint32_t _next_follow_ms = 0; lv_obj_t* _map_hint = nullptr; lv_obj_t* _map_dl_pill = nullptr; // download progress over the map lv_obj_t* _dl_overlay = nullptr; // download popup diff --git a/examples/companion_radio/ui-new/GfxUtils.h b/examples/companion_radio/ui-new/GfxUtils.h index 3970b645..1bee7418 100644 --- a/examples/companion_radio/ui-new/GfxUtils.h +++ b/examples/companion_radio/ui-new/GfxUtils.h @@ -57,6 +57,13 @@ static void drawTrail(DisplayDriver& d, TrailStore& tr, Project project, OnBreak else drawLine(d, x0, y0, x1, y1); x0 = x1; y0 = y1; } + // The simplifier's pending point: the straight stretch walked since the + // last vertex, so the line reaches where you are instead of lagging behind. + TrailPoint pend; + if (tr.pendingPoint(pend)) { + int x1, y1; project(pend.lat_1e6, pend.lon_1e6, x1, y1); + drawLine(d, x0, y0, x1, y1); + } } } // namespace gfx diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index fd6aa53f..faff76c7 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -69,7 +69,9 @@ build_flags = -D OTA_ASSET_SUFFIX='"-Wio-Tracker-L2-ota.bin"' ; Settings > Firmware update ; Sized for the PSRAM (UiCore is heap-allocated, so these land there); ; the defaults in the headers are the nRF52 boards'. - -D TRAIL_CAPACITY=4096 ; 64 KB (+64 KB of map drawing buffers) + -D TRAIL_CAPACITY=32768 ; 512 KB (+512 KB of map drawing buffers): ~160 km at 5 m + -D TRAIL_SIMPLIFY=0 ; every point kept: no straight-run simplification + -D TRAIL_FIXED_MIN_DELTA_M=5 ; a fixed GPS-jitter gate instead of the point spacing setting -D WAYPOINT_CAPACITY=64 -D HIST_CH_MAX=256 ; all channels together, ~50 KB -D HIST_DM_MAX=128 ; all DMs together, ~27 KB diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index 4754ba7c..afb00a92 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -187,7 +187,7 @@ if [ "$SIM_UI" = "lvgl" ]; then done # The L2's PSRAM-sized limits (solo/wio-tracker-l2/platformio.ini). DEFINES=("${kept[@]}" -DDISPLAY_CLASS=SimLcdDisplay -DMAX_GROUP_CHANNELS=40 -DUI_ZOOM=1 -DLV_CONF_INCLUDE_SIMPLE - -DTRAIL_CAPACITY=4096 -DWAYPOINT_CAPACITY=64 -DHIST_CH_MAX=256 -DHIST_DM_MAX=128 -DNEARBY_MAX=64 -DHIST_ARCHIVE) + -DTRAIL_CAPACITY=32768 -DTRAIL_SIMPLIFY=0 -DTRAIL_FIXED_MIN_DELTA_M=5 -DWAYPOINT_CAPACITY=64 -DHIST_CH_MAX=256 -DHIST_DM_MAX=128 -DNEARBY_MAX=64 -DHIST_ARCHIVE) INCLUDES+=("-I$LVGL_DIR") LINK_EXTRA=(-sFETCH=1) # map downloads (LvglPort.h) go through emscripten_fetch fi