#pragma once // GPS trail viewer. Tools › Trail. // Three live views — Summary, Map, List — cyclable with LEFT/RIGHT. // All settings and actions live in the Hold-Enter popup so a short Enter never // accidentally stops tracking. // Included by UITask.cpp after Trail store + ToolsScreen. #include "../Trail.h" #include "../GeoUtils.h" #include "GfxUtils.h" #include "icons.h" // scalable mini-icons (map markers, north arrow, grid dots) #include "NavView.h" #include "WaypointsView.h" #include #include // Bounds every Serial write the GPX export makes. A full trail dump is tens // of KB across hundreds of individual write() calls (one or more per point) — // without this, a serial monitor that's open but not actively reading (DTR // asserted, nothing draining the USB-CDC FIFO) would block each one, and the // main loop with it, indefinitely (see StreamUtils.h). Duck-typed to the // handful of Print methods Trail.h's GPX writers actually call, so it slots // into their `S& out` template parameter without any change there. // __FlashStringHelper content is plain flash on nRF52/ARM (no // Harvard-architecture PROGMEM split), so it's safe to read like any other // char*. // // The first stalled write gives up for the rest of the export (_gave_up), // rather than re-arming a fresh timeout on every one of those hundreds of // calls — a host that's truly stuck would otherwise cost calls × timeout_ms // in total instead of one bounded stall. struct BoundedSerialPrint { uint32_t timeout_ms; bool _gave_up; BoundedSerialPrint(uint32_t t) : timeout_ms(t), _gave_up(false) {} size_t write(const uint8_t* buf, size_t len) { if (_gave_up) return 0; size_t sent = streamWriteUntil(Serial, buf, len, millis() + timeout_ms); if (sent < len) _gave_up = true; return sent; } size_t print(const char* s) { return write((const uint8_t*)s, strlen(s)); } size_t print(const __FlashStringHelper* s) { const char* p = reinterpret_cast(s); return write((const uint8_t*)p, strlen(p)); } }; class TrailScreen : public UIScreen { UITask* _task; TrailStore* _store; enum View { V_SUMMARY = 0, V_MAP = 1, V_LIST = 2, V_COUNT }; uint8_t _view = V_SUMMARY; int _summary_scroll = 0; int _list_scroll = 0; // Top-of-scroll bound for each view, recomputed every render() from the // live display height (mirrors how _scroll itself is reclamped) — lets // handleInput() wrap top<->bottom without duplicating the layout math. int _summary_max_scroll = 0; int _list_max_scroll = 0; bool _cfg_dirty = false; bool _map_grid = true; // show scale grid in map view (Enter to toggle) bool _return_home = false; // entered via Home "Map" page → KEY_CANCEL returns to Home // Waypoint management UI (list / nav / add / mark / rename / delete / send) // lives in its own component; TrailScreen delegates to it while it's active. WaypointsView _wp; // Action popup (Hold Enter / KEY_CONTEXT_MENU). Carries both settings rows // (Min dist, Units — cycled with LEFT/RIGHT while focused) and actions // (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so // the labels live in member buffers that get refreshed in openActionMenu() // and after every LEFT/RIGHT cycle. enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_MARK_AVG, ACT_AUTOSAVE, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS, ACT_SHARE_NOW, ACT_TRACKBACK }; // The action popup is multi-level: a short main menu, plus "Trail file…" and // "Settings…" submenus. _menu_level tracks which is open so input is routed // correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter). // Live-share *config* lives in its own tool (Tools › Live Share); the map only // keeps the one-shot "Share my pos" action. enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS, ML_CONFIRM_GPS }; PopupMenu _action_menu; uint8_t _menu_level = ML_MAIN; uint8_t _act_map[16]; // max rows on any one level; pushAction guards the cap uint8_t _act_count = 0; char _act_min_dist_label[24]; char _act_units_label[24]; char _act_grid_label[16]; char _act_autopause_label[24]; char _act_mark_avg_label[24]; char _act_autosave_label[24]; char _act_toggle_label[20]; static const int SUMMARY_ITEM_COUNT = 5; public: TrailScreen(UITask* task, TrailStore* store) : _task(task), _store(store), _wp(task, store) {} void onShow() override { _view = V_SUMMARY; _summary_scroll = 0; _list_scroll = 0; _cfg_dirty = false; _action_menu.active = false; _menu_level = ML_MAIN; _return_home = false; _wp.reset(); } // Switch straight into the Map view (used by the Home "Map" page). Layered on // top of onShow()'s reset by gotoMapScreen(); remembers we came from Home so // KEY_CANCEL returns there, not to Tools. void showMapView() { _view = V_MAP; _return_home = true; } int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); // Waypoint management UI owns the screen while active. if (_wp.active()) return _wp.render(display); // Title carries the view counter so the bottom hint row can be reclaimed // for content. const char* base = (_view == V_MAP) ? (_map_grid ? "TRAIL MAP+" : "TRAIL MAP") : (_view == V_LIST) ? "TRAIL LIST" : "TRAIL"; char title[20]; snprintf(title, sizeof(title), "%s %d/%d", base, (int)_view + 1, (int)V_COUNT); display.drawCenteredHeader(title); if (_view == V_MAP) renderMap(display); else if (_view == V_LIST) renderList(display); else renderSummary(display); if (_action_menu.active) _action_menu.render(display); return _store->isActive() ? 1000 : 5000; } // Forward the loop tick to the waypoint UI so GPS averaging (Mark avg) can // sample independently of the render cadence (slow on e-ink). void poll() override { _wp.poll(); } bool handleInput(char c) override { // Waypoint management UI consumes all input while active. if (_wp.active()) return _wp.handleInput(c); // Action popup overrides everything else. if (_action_menu.active) { // LEFT/RIGHT cycles the focused value — only meaningful in the Settings // submenu; the popup stays open so the user can keep tapping. if (keyIsPrev(c) || keyIsNext(c)) { int dir = keyIsNext(c) ? 1 : -1; int idx = _action_menu.selectedIndex(); if (idx >= 0 && idx < _act_count && _menu_level == ML_SETTINGS) cycleSetting((ActionId)_act_map[idx], dir); return true; // swallow elsewhere } auto res = _action_menu.handleInput(c); if (res == PopupMenu::SELECTED) { // GPS-off confirmation popup: rows aren't ActionIds, route by level. if (_menu_level == ML_CONFIRM_GPS) { if (_action_menu.selectedIndex() == 0) { // "Enable GPS & start" _task->toggleGPS(); // off → on (persists, shows GPS alert) _store->setActive(true); _task->showAlert("GPS on, tracking started", 1200); } _menu_level = ML_MAIN; // popup already closed by handleInput() return true; } int sel = _action_menu.selectedIndex(); ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE; switch (act) { case ACT_FILE: buildFileMenu(); return true; // descend into submenu case ACT_SETTINGS: buildSettingsMenu(); return true; // Settings rows: Enter advances/toggles the value and keeps focus. case ACT_MIN_DIST: case ACT_UNITS: case ACT_GRID: case ACT_MARK_AVG: case ACT_AUTOSAVE: case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true; case ACT_SHARE_NOW: shareMyLocationNow(); break; case ACT_TOGGLE: // Starting a trail with GPS switched off logs nothing and just // sits on "Waiting for GPS fix" forever -- prompt to enable it // first (only on boards that actually have a toggleable GPS). if (!_store->isActive() && _task->hasGPS() && !_task->getGPSState()) { buildGpsConfirmMenu(); return true; } handleToggle(); break; case ACT_MARK: _wp.markHere(); break; case ACT_WAYPOINTS: _wp.openList(); break; case ACT_TRACKBACK: _wp.startTrackBack(); break; case ACT_SAVE: handleSave(); break; case ACT_LOAD: handleLoad(); break; case ACT_RESET: handleReset(); break; case ACT_EXPORT: handleExport(); break; case ACT_EXPORT_SAVED: handleExportSaved(); break; } _task->savePrefsIfDirty(_cfg_dirty); } else if (res == PopupMenu::CANCELLED) { _task->savePrefsIfDirty(_cfg_dirty); if (_menu_level != ML_MAIN) buildMainMenu(); // Cancel backs out of a submenu } return true; } if (c == KEY_CANCEL) { _task->savePrefsIfDirty(_cfg_dirty); if (_return_home) _task->gotoHomeScreen(); else _task->gotoToolsScreen(); return true; } if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; } if (keyIsPrev(c)) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; } if (keyIsNext(c)) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; } if (_view == V_SUMMARY && c == KEY_UP) { _summary_scroll = (_summary_scroll > 0) ? _summary_scroll - 1 : _summary_max_scroll; return true; } if (_view == V_SUMMARY && c == KEY_DOWN) { _summary_scroll = (_summary_scroll < _summary_max_scroll) ? _summary_scroll + 1 : 0; return true; } if (_view == V_LIST && c == KEY_UP) { _list_scroll = (_list_scroll > 0) ? _list_scroll - 1 : _list_max_scroll; return true; } if (_view == V_LIST && c == KEY_DOWN) { _list_scroll = (_list_scroll < _list_max_scroll) ? _list_scroll + 1 : 0; return true; } if (c == KEY_ENTER) { // Short Enter intentionally does nothing destructive — both start and // stop go through the Hold-Enter popup so a stray tap can't change the // tracking state. _task->showAlert("Hold Enter for menu", 1000); return true; } return false; } // True when there's a usable GPS fix (same source as ownPos). bool gpsHasFix() const { int32_t lat, lon; return ownPos(lat, lon); } private: void handleToggle() { bool was = _store->isActive(); _store->setActive(!was); _task->showAlert(was ? "Tracking stopped" : (gpsHasFix() ? "Tracking started" : "Waiting for GPS fix"), was ? 800 : 1000); } void handleReset() { if (_store->isActive()) _store->setActive(false); _store->clear(); _task->showAlert("Trail reset", 800); } void handleSave() { DataStore* ds = the_mesh.getDataStore(); if (!ds) { _task->showAlert("FS unavailable", 800); return; } File f = ds->openWrite(TRAIL_FILE); if (!f) { _task->showAlert("Save failed", 800); return; } bool ok = _store->writeTo(f); f.close(); _task->showAlert(ok ? "Trail saved" : "Save failed", 800); } void handleLoad() { DataStore* ds = the_mesh.getDataStore(); if (!ds) { _task->showAlert("FS unavailable", 800); return; } File f = ds->openRead(TRAIL_FILE); if (!f) { _task->showAlert("No saved trail", 800); return; } bool ok = _store->readFrom(f); f.close(); _task->showAlert(ok ? "Trail loaded" : "Load failed", 800); } void handleExport() { if (!Serial) { _task->showAlert("Connect USB first", 1200); return; } BoundedSerialPrint out{300}; size_t n = _store->exportGpx(out, _task->waypoints()); showExportAlert(n); } void handleExportSaved() { if (!Serial) { _task->showAlert("Connect USB first", 1200); return; } DataStore* ds = the_mesh.getDataStore(); if (!ds) { _task->showAlert("FS unavailable", 800); return; } File f = ds->openRead(TRAIL_FILE); if (!f) { _task->showAlert("No saved trail", 800); return; } BoundedSerialPrint out{300}; size_t n = TrailStore::exportGpxFromFile(f, out, _task->waypoints()); f.close(); if (n == 0) { _task->showAlert("Bad saved file", 1000); return; } showExportAlert(n); } void showExportAlert(size_t n) { // The companion app multiplexes BLE + USB on the same frame protocol. // When BLE is connected, USB-receive is ignored and the dump can't // collide. Without a BLE link the app might be on USB itself, so warn // the user — they may need to reconnect their app afterwards. char alert[28]; if (_task->hasConnection()) snprintf(alert, sizeof(alert), "GPX %u B (USB)", (unsigned)n); else snprintf(alert, sizeof(alert), "GPX %u B - disc. app", (unsigned)n); _task->showAlert(alert, 1500); } static constexpr const char* TRAIL_FILE = "/trail"; // Refresh the three settings/action labels in-place. Pointer identity is // preserved, so PopupMenu picks up the new text on the next render. void refreshActionLabels() { NodePrefs* p = _task->getNodePrefs(); snprintf(_act_min_dist_label, sizeof(_act_min_dist_label), "Min dist: %s", TrailStore::minDeltaLabel(p ? p->trail_min_delta_idx : 0, p && p->units_imperial)); // Unit system (km/mi) is the global Settings choice; here we only toggle // what the Summary readout shows — speed or pace. snprintf(_act_units_label, sizeof(_act_units_label), "Readout: %s", (p && p->trail_show_pace) ? "Pace" : "Speed"); snprintf(_act_grid_label, sizeof(_act_grid_label), "Grid: %s", _map_grid ? "ON" : "OFF"); snprintf(_act_autopause_label, sizeof(_act_autopause_label), "Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0)); snprintf(_act_mark_avg_label, sizeof(_act_mark_avg_label), "Mark avg: %s", NodePrefs::gpsAvgLabel(p ? p->gps_avg_idx : 0)); snprintf(_act_autosave_label, sizeof(_act_autosave_label), "Auto-save: %s", (p && p->trail_autosave_lowbatt) ? "ON" : "OFF"); snprintf(_act_toggle_label, sizeof(_act_toggle_label), "%s tracking", _store->isActive() ? "Stop" : "Start"); } // Append an action row, guarding both the id map and the popup capacity so // adding a new action can never silently overrun _act_map / PopupMenu. void pushAction(ActionId id, const char* label) { if (_act_count >= (int)sizeof(_act_map)) return; _act_map[_act_count++] = (uint8_t)id; _action_menu.addItem(label); } bool fileMenuHasItems() const { return !_store->empty() || savedTrailExists(); } // Hold-Enter entry point — always opens the short main menu. void openActionMenu() { buildMainMenu(); } void buildMainMenu() { refreshActionLabels(); _menu_level = ML_MAIN; _act_count = 0; _action_menu.begin("Trail", 4); pushAction(ACT_TOGGLE, _act_toggle_label); pushAction(ACT_MARK, "Mark here"); // "Waypoints" opens the nav list — always available; it hosts the list, // backtrack (Trail-start row) and the "+ Add by coords" entry. pushAction(ACT_WAYPOINTS, "Waypoints..."); // Track back retraces the recorded route to its start; needs ≥2 points. if (_store->count() >= 2) pushAction(ACT_TRACKBACK, "Track back"); pushAction(ACT_SHARE_NOW, "Share my pos"); if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file..."); pushAction(ACT_SETTINGS, "Settings..."); } // Confirmation shown when "Start tracking" is chosen with GPS off. Rows are // plain (not ActionIds); handled by _menu_level in the SELECTED branch. void buildGpsConfirmMenu() { _menu_level = ML_CONFIRM_GPS; _act_count = 0; _action_menu.begin("GPS is off", 2); _action_menu.addItem("Enable GPS & start"); _action_menu.addItem("Cancel"); } // Trail-file submenu — only the operations that make sense right now. void buildFileMenu() { _menu_level = ML_FILE; _act_count = 0; _action_menu.begin("Trail file", 4); bool saved = savedTrailExists(); if (!_store->empty()) pushAction(ACT_SAVE, "Save trail"); if (saved) pushAction(ACT_LOAD, "Load trail"); if (!_store->empty()) pushAction(ACT_EXPORT, "Export (live)"); if (saved) pushAction(ACT_EXPORT_SAVED, "Export (saved)"); if (!_store->empty()) pushAction(ACT_RESET, "Reset trail"); } // Settings submenu — values cycled with LEFT/RIGHT (or Enter). View-aware: // Grid only matters on the Map, Readout only on the Summary. void buildSettingsMenu() { refreshActionLabels(); _menu_level = ML_SETTINGS; _act_count = 0; _action_menu.begin("Settings", 4); pushAction(ACT_MIN_DIST, _act_min_dist_label); pushAction(ACT_AUTOPAUSE, _act_autopause_label); pushAction(ACT_MARK_AVG, _act_mark_avg_label); pushAction(ACT_AUTOSAVE, _act_autosave_label); if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label); if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label); } // Cycle a settings value. Returns true if `act` was a settings row. bool cycleSetting(ActionId act, int dir) { NodePrefs* p = _task->getNodePrefs(); if (act == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; } if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (act == ACT_MARK_AVG && p){ cycleMarkAvg(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (act == ACT_AUTOSAVE && p){ p->trail_autosave_lowbatt = !p->trail_autosave_lowbatt; _cfg_dirty = true; refreshActionLabels(); return true; } return false; } static bool savedTrailExists() { DataStore* ds = the_mesh.getDataStore(); if (!ds) return false; File f = ds->openRead(TRAIL_FILE); bool ok = (bool)f; if (f) f.close(); return ok; } // GPS / unit helpers shared by the trail views and the map. (Waypoint list / // navigation / add / mark / rename / delete / send moved to WaypointsView.) bool ownPos(int32_t& lat, int32_t& lon) const { return _task->currentLocation(lat, lon); } bool useImperial() const { return _task && _task->useImperial(); } // After Enter on a settings row, the popup auto-closes per PopupMenu's // semantics. Re-open the Settings submenu with focus restored to that row so // the user can continue cycling. void reopenSettingsAt(int sel) { buildSettingsMenu(); _action_menu.setSelected(sel); } void cycleMinDelta(NodePrefs* p, int dir) { uint8_t idx = p->trail_min_delta_idx; if (idx >= TrailStore::MIN_DELTA_COUNT) idx = 0; idx = (dir > 0) ? (idx + 1) % TrailStore::MIN_DELTA_COUNT : (idx + TrailStore::MIN_DELTA_COUNT - 1) % TrailStore::MIN_DELTA_COUNT; p->trail_min_delta_idx = idx; } // The trail "Readout" row toggles speed vs pace; the metric/imperial unit // comes from the global Settings preference, so this is a plain on/off flip. void cycleUnits(NodePrefs* p, int /*dir*/) { p->trail_show_pace ^= 1; } void cycleAutoPause(NodePrefs* p, int dir) { uint8_t idx = p->trail_autopause_idx; if (idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) idx = 0; idx = (dir > 0) ? (idx + 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT : (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT; p->trail_autopause_idx = idx; } void cycleMarkAvg(NodePrefs* p, int dir) { uint8_t idx = p->gps_avg_idx; if (idx >= NodePrefs::GPS_AVG_COUNT) idx = 0; idx = (dir > 0) ? (idx + 1) % NodePrefs::GPS_AVG_COUNT : (idx + NodePrefs::GPS_AVG_COUNT - 1) % NodePrefs::GPS_AVG_COUNT; p->gps_avg_idx = idx; } // One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages // screen, where the user picks a DM or channel recipient. (Auto live-share // config lives in Tools › Live Share.) void shareMyLocationNow() { int32_t lat, lon; if (!_task->currentLocation(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; } char text[40]; snprintf(text, sizeof(text), LOCATION_MSG_TAG "%.5f,%.5f", lat / 1e6, lon / 1e6); _task->shareToMessage(text); } // Render the average speed (or pace) in the globally-selected unit system. void formatAvgPaceOrSpeed(char* buf, size_t n, NodePrefs* p) const { bool imperial = p && p->units_imperial; bool pace = p && p->trail_show_pace; uint16_t kmh = _store->avgSpeedKmh(); if (!pace) { if (imperial) snprintf(buf, n, "Avg: %u mph", (unsigned)((float)kmh * 0.621371f + 0.5f)); else snprintf(buf, n, "Avg: %u km/h", (unsigned)kmh); return; } uint32_t d_m = _store->totalDistanceMeters(); uint32_t es_s = _store->elapsedSeconds(); const char* unit = imperial ? "/mi" : "/km"; float dist_unit = imperial ? (d_m / 1609.344f) : (d_m / 1000.0f); if (d_m == 0 || es_s == 0 || dist_unit <= 0) { snprintf(buf, n, "Pace: --:-- %s", unit); return; } float pace_min = (es_s / 60.0f) / dist_unit; unsigned pm = (unsigned)pace_min; unsigned ps = (unsigned)((pace_min - pm) * 60.0f); snprintf(buf, n, "Pace: %u:%02u %s", pm, ps, unit); } void summaryItem(int i, char* buf, size_t n) const { switch (i) { case 0: { const char* st; if (!_store->isActive()) st = "stopped"; else if (_store->isPaused()) st = "paused"; else if (_store->empty()) st = "waiting fix"; else st = "tracking"; snprintf(buf, n, "Status: %s", st); break; } case 1: snprintf(buf, n, "Points: %d / %d", _store->count(), TrailStore::CAPACITY); break; case 2: { uint32_t d = _store->totalDistanceMeters(); char ds[12]; geo::fmtDist(ds, sizeof(ds), d / 1000.0f, useImperial()); snprintf(buf, n, "Dist: %s", ds); break; } case 3: { uint32_t es = _store->elapsedSeconds(); // Unit-suffixed so "1h 05m" can't be misread as "1m 05s". if (es < 3600) snprintf(buf, n, "Time: %lum %02lus", (unsigned long)(es / 60), (unsigned long)(es % 60)); else snprintf(buf, n, "Time: %luh %02lum", (unsigned long)(es / 3600), (unsigned long)((es % 3600) / 60)); break; } case 4: formatAvgPaceOrSpeed(buf, n, _task->getNodePrefs()); break; default: buf[0] = '\0'; } } void renderSummary(DisplayDriver& display) { const int y0 = display.listStart(); const int step = display.lineStep(); const int avail = display.height() - y0 - 2; int visible = avail / step; if (visible < 1) visible = 1; if (visible > SUMMARY_ITEM_COUNT) visible = SUMMARY_ITEM_COUNT; int max_scroll = SUMMARY_ITEM_COUNT - visible; if (_summary_scroll > max_scroll) _summary_scroll = max_scroll; if (_summary_scroll < 0) _summary_scroll = 0; _summary_max_scroll = max_scroll; for (int i = 0; i < visible; i++) { int idx = _summary_scroll + i; if (idx >= SUMMARY_ITEM_COUNT) break; char buf[28]; summaryItem(idx, buf, sizeof(buf)); display.setCursor(2, y0 + i * step); display.print(buf); } drawScrollIndicator(display, y0, visible * step, SUMMARY_ITEM_COUNT, visible, _summary_scroll); } void renderList(DisplayDriver& display) { const int top = display.listStart(); const int step = display.lineStep(); const int avail = display.height() - top - 2; if (_store->empty()) { display.drawTextCentered(display.width() / 2, top + avail / 2, "No trail yet"); _list_max_scroll = 0; return; } int visible = avail / step; if (visible < 1) visible = 1; const int total = _store->count(); if (visible > total) visible = total; int max_scroll = total - visible; if (_list_scroll > max_scroll) _list_scroll = max_scroll; if (_list_scroll < 0) _list_scroll = 0; _list_max_scroll = max_scroll; NodePrefs* p = _task->getNodePrefs(); int32_t tz_off = (int32_t)(p ? p->tz_offset_hours : 0) * 3600; for (int i = 0; i < visible; i++) { int idx = total - 1 - (_list_scroll + i); if (idx < 0) break; const TrailPoint& pt = _store->at(idx); time_t lt = (time_t)((int32_t)pt.ts + tz_off); struct tm* ti = gmtime(<); char dist[12]; if (idx == 0 || (pt.flags & TRAIL_FLAG_SEG_START)) { snprintf(dist, sizeof(dist), "start"); } else { const TrailPoint& prev = _store->at(idx - 1); float d = TrailStore::haversineMeters(prev.lat_1e6, prev.lon_1e6, pt.lat_1e6, pt.lon_1e6); char ds[10]; geo::fmtDist(ds, sizeof(ds), d / 1000.0f, useImperial()); snprintf(dist, sizeof(dist), "+%s", ds); } char row[28]; snprintf(row, sizeof(row), "%02d:%02d %s", ti->tm_hour, ti->tm_min, dist); display.setCursor(2, top + i * step); display.print(row); } drawScrollIndicator(display, top, visible * step, total, visible, _list_scroll); } // Shared map projection: geographic (1e-6 deg) → screen pixels. The scale is // isotropic (cos(lat) folded into lon), so a metre maps to the same pixel // count on both axes. Built once in renderMap and handed to renderGrid and // the marker drawing so the projection equation lives in exactly one place. struct MapProjection { int32_t min_lat, max_lat, min_lon; float lon_scale_geo, scale; int off_x, off_y, area_x, area_y, area_w, area_h; void project(int32_t lat, int32_t lon, int& px, int& py) const { px = off_x + (int)((float)(lon - min_lon) * lon_scale_geo * scale); py = off_y + (int)((float)(max_lat - lat) * scale); } }; // Fold the trail / waypoints / live position into a bounding box. With a trail, // only the route + live position define the box (far waypoints clamp to the // edge when drawn); without one, the waypoints do. Returns false if empty. bool computeBounds(bool have_trail, bool have_gps, int32_t my_lat, int32_t my_lon, int32_t& min_lat, int32_t& min_lon, int32_t& max_lat, int32_t& max_lon) const { bool init = false; auto fold = [&](int32_t lat, int32_t lon) { if (!init) { min_lat = max_lat = lat; min_lon = max_lon = lon; init = true; } else { if (lat < min_lat) min_lat = lat; if (lat > max_lat) max_lat = lat; if (lon < min_lon) min_lon = lon; if (lon > max_lon) max_lon = lon; } }; if (have_trail) { int32_t a, b, c, d; _store->boundingBox(a, b, c, d); fold(a, b); fold(c, d); } else { WaypointStore& wp = _task->waypoints(); for (int i = 0; i < wp.count(); i++) fold(wp.at(i).lat_1e6, wp.at(i).lon_1e6); } if (have_gps) fold(my_lat, my_lon); // Fold in live-tracked contacts ([LOC] shares) so they stay in frame. { LiveTrackStore& lt = _task->liveTrack(); uint32_t now = rtc_clock.getCurrentTime(); for (int i = 0; i < LiveTrackStore::CAPACITY; i++) if (lt.isActive(i, now)) fold(lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6); } // Fold in the active target too, so a destination you set never sits off-frame. { int32_t tla, tlo; if (_task->activeTargetPos(tla, tlo)) fold(tla, tlo); } return init; } // Draw the saved waypoints (label's first two chars beside each, clamped to the // frame) and the current-position marker (live GPS, else last trail point). void drawMarkers(DisplayDriver& display, const MapProjection& proj, bool have_gps, int32_t my_lat, int32_t my_lon, bool have_trail) { const int area_x = proj.area_x, area_y = proj.area_y; const int area_w = proj.area_w, area_h = proj.area_h; WaypointStore& wp = _task->waypoints(); const int wp_x_max = area_x + area_w - 1, wp_y_max = area_y + area_h - 1; // Reserved rectangles of labels already placed this frame, so a dense // cluster of points doesn't smear their labels on top of one another. // Live-tracked contacts are labelled FIRST so a moving person's name wins a // slot over a nearby static waypoint (on a live map the person matters most). LblRect occ[40]; int nocc = 0; { LiveTrackStore& lt = _task->liveTrack(); uint32_t now = rtc_clock.getCurrentTime(); for (int i = 0; i < LiveTrackStore::CAPACITY; i++) { if (!lt.isActive(i, now)) continue; const LiveTrackStore::Entry& s = lt.slotAt(i); int cx, cy; proj.project(s.lat_1e6, s.lon_1e6, cx, cy); if (cx < area_x) cx = area_x; else if (cx > wp_x_max) cx = wp_x_max; if (cy < area_y) cy = area_y; else if (cy > wp_y_max) cy = wp_y_max; drawContactMarker(display, cx, cy); char s2[3] = { s.name[0], s.name[0] ? s.name[1] : (char)0, 0 }; drawLabelAvoiding(display, s2, cx, cy, area_x, area_y, area_w, area_h, occ, nocc, 40); } } for (int i = 0; i < wp.count(); i++) { const Waypoint& w = wp.at(i); int wx, wy; proj.project(w.lat_1e6, w.lon_1e6, wx, wy); if (wx < area_x) wx = area_x; else if (wx > wp_x_max) wx = wp_x_max; if (wy < area_y) wy = area_y; else if (wy > wp_y_max) wy = wp_y_max; drawWaypointMarker(display, wx, wy); char s[3] = { w.label[0], w.label[0] ? w.label[1] : (char)0, 0 }; drawLabelAvoiding(display, s, wx, wy, area_x, area_y, area_w, area_h, occ, nocc, 40); } if (have_gps) { int mx, my; proj.project(my_lat, my_lon, mx, my); drawCurrentMarker(display, mx, my); } else if (have_trail) { int ex, ey; proj.project(_store->last().lat_1e6, _store->last().lon_1e6, ex, ey); drawCurrentMarker(display, ex, ey); } // Active target flag, last so it stays legible atop any waypoint/contact it // coincides with. Clamped to the frame like the other off-edge markers. int32_t tla, tlo; if (_task->activeTargetPos(tla, tlo)) { int tx, ty; proj.project(tla, tlo, tx, ty); if (tx < area_x) tx = area_x; else if (tx > wp_x_max) tx = wp_x_max; if (ty < area_y) ty = area_y; else if (ty > wp_y_max) ty = wp_y_max; drawTargetMarker(display, tx, ty); } } void renderMap(DisplayDriver& display) { const int top = display.listStart(); const int bottom = display.height() - 2; WaypointStore& wp = _task->waypoints(); const int nwp = wp.count(); const bool have_trail = !_store->empty(); // Live GPS position — lets the map double as a "you + waypoints" view even // when no trail is being recorded. int32_t my_lat, my_lon; const bool have_gps = ownPos(my_lat, my_lon); const bool have_live = _task->liveTrack().active(rtc_clock.getCurrentTime()) > 0; int32_t tgt_lat, tgt_lon; const bool have_tgt = _task->activeTargetPos(tgt_lat, tgt_lon); if (!have_trail && nwp == 0 && !have_gps && !have_live && !have_tgt) { display.drawTextCentered(display.width() / 2, (top + bottom) / 2, "No GPS / no trail"); return; } const int area_x = 2; const int area_y = top + 1; const int area_w = display.width() - 4; const int area_h = bottom - top - 2; if (area_w < 6 || area_h < 6) return; // Bounding box spans the trail, every waypoint, and the live position, so // everything of interest stays in frame. int32_t min_lat = 0, min_lon = 0, max_lat = 0, max_lon = 0; computeBounds(have_trail, have_gps, my_lat, my_lon, min_lat, min_lon, max_lat, max_lon); // Degenerate: everything at one coordinate — just centre the markers. if (min_lat == max_lat && min_lon == max_lon) { int ccx = area_x + area_w / 2, ccy = area_y + area_h / 2; // Only pin a waypoint to the centre in the genuine "lone waypoint, no // trail" case. With a trail the centre is the trail's spot, and the // waypoints (not folded into this box) belong elsewhere — don't draw // them here, or they'd appear at the trail's location. if (nwp > 0 && !have_trail) { drawWaypointMarker(display, ccx, ccy); const char* lbl = wp.at(0).label; // single coincident spot — label it char s[3] = { lbl[0], lbl[0] ? lbl[1] : (char)0, 0 }; drawWaypointLabel(display, s, ccx, ccy, area_x, area_y, area_w, area_h); } if (have_gps || have_trail) drawCurrentMarker(display, ccx, ccy); { int32_t tla, tlo; if (_task->activeTargetPos(tla, tlo)) drawTargetMarker(display, ccx, ccy); } return; } float avg_lat_rad = ((min_lat + max_lat) / 2.0e6f) * (float)M_PI / 180.0f; float lon_scale_geo = cosf(avg_lat_rad); if (lon_scale_geo < 0.05f) lon_scale_geo = 0.05f; float lat_span = (float)(max_lat - min_lat); float lon_span = (float)(max_lon - min_lon) * lon_scale_geo; float scale_lat = (float)area_h / (lat_span > 0 ? lat_span : 1.0f); float scale_lon = (float)area_w / (lon_span > 0 ? lon_span : 1.0f); float scale = (scale_lat < scale_lon) ? scale_lat : scale_lon; int used_w = (int)(lon_span * scale); int used_h = (int)(lat_span * scale); MapProjection proj; proj.min_lat = min_lat; proj.max_lat = max_lat; proj.min_lon = min_lon; proj.lon_scale_geo = lon_scale_geo; proj.scale = scale; proj.off_x = area_x + (area_w - used_w) / 2; proj.off_y = area_y + (area_h - used_h) / 2; proj.area_x = area_x; proj.area_y = area_y; proj.area_w = area_w; proj.area_h = area_h; if (_map_grid) renderGrid(display, proj); if (have_trail) { int x0, y0; proj.project(_store->at(0).lat_1e6, _store->at(0).lon_1e6, x0, y0); miniIconDrawCentered(display, x0, y0, ICON_MAP_DOT); gfx::drawTrail(display, *_store, [&](int32_t la, int32_t lo, int& x, int& y) { proj.project(la, lo, x, y); }, [&](int xa, int ya, int xb, int yb) { drawFilledDot(display, xa, ya); drawOpenDot(display, xb, yb); }); int sx, sy; proj.project(_store->first().lat_1e6, _store->first().lon_1e6, sx, sy); drawStartMarker(display, sx, sy); } drawMarkers(display, proj, have_gps, my_lat, my_lon, have_trail); int nx, ny; northIconPos(display, area_x, area_w, area_y, nx, ny); miniIconDrawTop(display, nx, ny, ICON_MAP_NORTH); } // Top-left placement of the north marker — flush to the top-right corner of // the map area with a small buffer. Shared with renderGrid so its exclusion // box always matches exactly where the icon is actually drawn. static void northIconPos(DisplayDriver& d, int area_x, int area_w, int area_y, int& nx, int& ny) { const int s = miniIconScale(d); nx = area_x + area_w - ICON_MAP_NORTH.w * s - 1; ny = area_y + 1; } void renderGrid(DisplayDriver& display, const MapProjection& proj) { const int area_x = proj.area_x, area_y = proj.area_y; const int area_w = proj.area_w, area_h = proj.area_h; static constexpr float M_PER_1E6 = 0.11132f; // metres per 1e-6 deg lat float ppm = proj.scale / M_PER_1E6; // pixels per metre (isotropic) if (ppm <= 0.0f) return; // Round scale steps with matching labels. Imperial steps are stored as their // metre equivalents but labelled with their exact round ft/mi value. Both // tables are parallel (step ↔ label). static const float METRIC_STEPS[] = { 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000 }; static const char* METRIC_LABELS[] = { "1m", "2m", "5m", "10m", "20m", "50m", "100m", "200m", "500m", "1km", "2km", "5km", "10km", "20km", "50km", "100km" }; static const float IMPERIAL_STEPS[] = { // 10..2000 ft, then 1..100 mi (metres) 3.048f, 7.62f, 15.24f, 30.48f, 76.2f, 152.4f, 304.8f, 609.6f, 1609.344f, 3218.69f, 8046.72f, 16093.44f, 32186.88f, 80467.2f, 160934.4f }; static const char* IMPERIAL_LABELS[] = { "10ft", "25ft", "50ft", "100ft", "250ft", "500ft", "1000ft", "2000ft", "1mi", "2mi", "5mi", "10mi", "20mi", "50mi", "100mi" }; const bool imp = useImperial(); const float* STEPS = imp ? IMPERIAL_STEPS : METRIC_STEPS; const char* const* LABELS = imp ? IMPERIAL_LABELS : METRIC_LABELS; const int N_STEPS = imp ? (int)(sizeof(IMPERIAL_STEPS)/sizeof(IMPERIAL_STEPS[0])) : (int)(sizeof(METRIC_STEPS)/sizeof(METRIC_STEPS[0])); // Pick the round step nearest ~1/3 of the shorter side, then keep it sparse // enough to read on an OLED (≥ MIN_GRID_PX between intersections). static const float MIN_GRID_PX = 22.0f; int shorter_px = area_w < area_h ? area_w : area_h; float target_m = (shorter_px / ppm) / 3.0f; int sel = 0; for (int si = N_STEPS - 1; si >= 0; si--) { if (STEPS[si] <= target_m) { sel = si; break; } } while (sel < N_STEPS - 1 && STEPS[sel] * ppm < MIN_GRID_PX) sel++; // Square cells, snapped to the shorter side: divide the shorter dimension // into a whole number of equal cells (so the grid touches that pair of // borders), use that exact size as the square cell, and centre the whole // number of cells that fit along the longer dimension. Result: square grid, // reaches the frame top↔bottom (landscape), symmetric left↔right — no // one-sided drift, no rectangular cells. Cap so spacing stays ≥ MIN_GRID_PX. float step_px = STEPS[sel] * ppm; bool h_shorter = (area_h <= area_w); int short_px = h_shorter ? area_h : area_w; int long_px = h_shorter ? area_w : area_h; int max_short = (int)(short_px / MIN_GRID_PX); if (max_short < 1) max_short = 1; int n_short = (int)((float)short_px / step_px + 0.5f); if (n_short < 1) n_short = 1; if (n_short > max_short) n_short = max_short; float cell = (float)short_px / (float)n_short; // exact square size int n_long = (int)((float)long_px / cell); // whole cells that fit if (n_long < 1) n_long = 1; int off_long = (int)(((float)long_px - (float)n_long * cell) / 2.0f + 0.5f); // Map the short/long axes back to screen x/y. int x0 = h_shorter ? area_x + off_long : area_x; int y0 = h_shorter ? area_y : area_y + off_long; int cx = h_shorter ? n_long : n_short; // cell count along x int cy = h_shorter ? n_short : n_long; // cell count along y const char* lbl = LABELS[sel]; int lh = display.getLineHeight(); int cw = display.getCharWidth(); int x_max = area_x + area_w - 1, y_max = area_y + area_h - 1; // Exclusion boxes so grid dots don't smear the scale label (bottom-left) or // the north marker (top-right) — northIconPos() is the single source of // truth for where that icon actually lands, so this box can't drift out of // sync with it as the mini-icon scale changes. const int s = miniIconScale(display); int lbl_x2 = area_x + (int)strlen(lbl) * cw + 1; int lbl_y1 = area_y + area_h - lh - 1; int nx, ny; northIconPos(display, area_x, area_w, area_y, nx, ny); int arr_x1 = nx - 1; int arr_y2 = ny + ICON_MAP_NORTH.h * s + 1; // Grid dots are drawn at mini-icon scale too, so they stay visible instead // of shrinking to a single pixel on large-font (e-ink) layouts. auto fillSafe = [&](int x, int y) { if (x < area_x || x > x_max || y < area_y || y > y_max) return; if (x <= lbl_x2 && y >= lbl_y1) return; // under the scale label if (x >= arr_x1 && y <= arr_y2) return; // under the north arrow display.fillRect(x - s / 2, y - s / 2, s, s); }; // A single dot per intersection — cleaner and more legible on an OLED than // a 5-pixel cross (whose arms also clipped at the frame edges). for (int i = 0; i <= cx; i++) { int px = x0 + (int)((float)i * cell + 0.5f); if (px > x_max) px = x_max; for (int j = 0; j <= cy; j++) { int py = y0 + (int)((float)j * cell + 0.5f); if (py > y_max) py = y_max; fillSafe(px, py); } } display.setCursor(area_x, area_y + area_h - lh); display.print(lbl); } // Place a 1–2 char waypoint label beside its marker, kept inside the map // rect: prefer upper-right, flip to the left if it would clip the right // edge, and clamp vertically so edge/corner waypoints stay on the map. static void drawWaypointLabel(DisplayDriver& d, const char* s, int wx, int wy, int ax, int ay, int aw, int ah) { if (!s[0]) return; int tw = (int)d.getTextWidth(s); int ch = d.getLineHeight(); int lx = wx + 4; if (lx + tw > ax + aw) lx = wx - 4 - tw; // would clip right → flip left if (lx < ax) lx = ax; // clamp to left edge if (lx + tw > ax + aw) lx = ax + aw - tw; // clamp to right edge int ly = wy - 3; if (ly < ay) ly = ay; // clamp to top edge if (ly + ch > ay + ah) ly = ay + ah - ch; // clamp to bottom edge d.setCursor(lx, ly); d.print(s); } // Map label collision avoidance. Each placed label reserves a rectangle; a new // label tries up to four offsets around its marker (upper-right, upper-left, // lower-right, lower-left) and is skipped — the marker still shows — if none // fit on-frame without overlapping an already-placed one. On a dense map this // drops a few labels instead of smearing them into an unreadable blob. struct LblRect { int x, y, w, h; }; static bool rectsOverlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah; } static void drawLabelAvoiding(DisplayDriver& d, const char* s, int wx, int wy, int ax, int ay, int aw, int ah, LblRect* occ, int& nocc, int occ_max) { if (!s[0]) return; int tw = (int)d.getTextWidth(s); int ch = d.getLineHeight(); const int cand[4][2] = { { wx + 4, wy - 3 }, { wx - 4 - tw, wy - 3 }, { wx + 4, wy + 3 }, { wx - 4 - tw, wy + 3 } }; for (int c = 0; c < 4; c++) { int lx = cand[c][0], ly = cand[c][1]; if (lx < ax || lx + tw > ax + aw || ly < ay || ly + ch > ay + ah) continue; bool clash = false; for (int k = 0; k < nocc; k++) if (rectsOverlap(lx, ly, tw, ch, occ[k].x, occ[k].y, occ[k].w, occ[k].h)) { clash = true; break; } if (clash) continue; d.setCursor(lx, ly); d.print(s); if (nocc < occ_max) occ[nocc++] = { lx, ly, tw, ch }; return; } // No free slot — skip the label; the marker alone still conveys the point. } static void drawFilledDot(DisplayDriver& d, int cx, int cy) { miniIconDrawCentered(d, cx, cy, ICON_MAP_DOT); } // Hollow diamond — distinct from the trail's dots (○●), start (+) and // current (✕) markers. static void drawWaypointMarker(DisplayDriver& d, int cx, int cy) { miniIconDrawCentered(d, cx, cy, ICON_MAP_WAYPOINT); } static void drawOpenDot(DisplayDriver& d, int cx, int cy) { miniIconDrawCentered(d, cx, cy, ICON_MAP_RING); } static void drawStartMarker(DisplayDriver& d, int cx, int cy) { miniIconDrawCentered(d, cx, cy, ICON_MAP_START); } static void drawCurrentMarker(DisplayDriver& d, int cx, int cy) { miniIconDrawCentered(d, cx, cy, ICON_MAP_CURRENT); } // Filled diamond — a contact whose position arrived via a [LOC] share. static void drawContactMarker(DisplayDriver& d, int cx, int cy) { miniIconDrawCentered(d, cx, cy, ICON_MAP_CONTACT); } // Flag — the active Locator/Nav target. Drawn last (on top) so it stays // legible even when it lands on the same spot as a waypoint or contact. static void drawTargetMarker(DisplayDriver& d, int cx, int cy) { miniIconDrawCentered(d, cx, cy, ICON_MAP_TARGET); } };