refactor(ui): trail — settings back in popup, hold-Enter to stop

Per feedback the dedicated Config view added too much navigation. Roll
back to three views (Summary, Map, List) cyclable with LEFT/RIGHT, and
move every knob and action into the Hold-Enter (KEY_CONTEXT_MENU)
popup:

  Min dist: <value>    ← LEFT/RIGHT cycles, popup stays open
  Units: <value>       ← same
  Start/Stop tracking  ← Enter executes, popup closes
  Reset trail          ← Enter executes, only listed when not empty

Short Enter only starts a stopped trail; on an active trail it shows
"Hold Enter for menu" instead of stopping. Stop is therefore reachable
only through the popup, so a stray tap can never end a recording.

PopupMenu lacks LEFT/RIGHT semantics, so the screen intercepts those
keys when the focused row is a settings row, refreshes the label
in-place (same buffer pointer the menu already holds), and keeps the
popup open. Enter on a settings row re-opens the popup with focus
preserved on that row so the user can keep cycling with Enter too if
they prefer. Settings edits batch in _cfg_dirty and commit once on
popup close, Start, or Back.

Hint line shows current view N/3, the short-Enter action ("start" or
"act" depending on state), and a [Hold]menu reminder. Separator above
the hint keeps the row clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-25 17:37:43 +02:00
co-authored by Claude Sonnet 4.6
parent c07d7fed50
commit a7eb0eca7a
+131 -196
View File
@@ -1,7 +1,8 @@
#pragma once #pragma once
// GPS trail viewer. Tools Trail. // GPS trail viewer. Tools Trail.
// Config view (stopped): shows the Min-dist setting and lets Enter start tracking. // Three live views — Summary, Map, List — cyclable with LEFT/RIGHT.
// Active views (running): Summary counters, Map polyline, per-point List. // 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. // Included by UITask.cpp after Trail store + ToolsScreen.
#include "../Trail.h" #include "../Trail.h"
@@ -11,25 +12,24 @@ class TrailScreen : public UIScreen {
UITask* _task; UITask* _task;
TrailStore* _store; TrailStore* _store;
// All four views are always cyclable with LEFT/RIGHT. Config holds the enum View { V_SUMMARY = 0, V_MAP = 1, V_LIST = 2, V_COUNT };
// settings + stored-trail summary; the other three render the live or uint8_t _view = V_SUMMARY;
// last-recorded trail in different ways. int _summary_scroll = 0;
enum View { V_CONFIG = 0, V_SUMMARY = 1, V_MAP = 2, V_LIST = 3, V_COUNT }; int _list_scroll = 0;
bool _cfg_dirty = false;
uint8_t _view = V_CONFIG; // Action popup (Hold Enter / KEY_CONTEXT_MENU). Carries both settings rows
int _summary_scroll = 0; // top visible item index in Summary view // (Min dist, Units — cycled with LEFT/RIGHT while focused) and actions
int _list_scroll = 0; // top visible row in List view (newest = row 0) // (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
bool _cfg_dirty = false; // unsaved Config edits; flushed on start or back // the labels live in member buffers that get refreshed in openActionMenu()
// and after every LEFT/RIGHT cycle.
// Selected row in the Config view. UP/DOWN moves between rows; enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_TOGGLE, ACT_RESET };
// LEFT/RIGHT cycles the value of the focused row.
enum CfgRow { CFG_MIN_DIST = 0, CFG_UNITS = 1, CFG_ROW_COUNT };
uint8_t _cfg_sel = CFG_MIN_DIST;
// Action popup (Hold Enter): Reset trail, and later phase-4 entries
// (Save/Load/Export GPX). Owns the labels because PopupMenu stores
// const char* pointers verbatim.
PopupMenu _action_menu; PopupMenu _action_menu;
uint8_t _act_map[8];
uint8_t _act_count = 0;
char _act_min_dist_label[24];
char _act_units_label[24];
char _act_toggle_label[20];
static const int SUMMARY_ITEM_COUNT = 5; static const int SUMMARY_ITEM_COUNT = 5;
@@ -37,7 +37,7 @@ public:
TrailScreen(UITask* task, TrailStore* store) : _task(task), _store(store) {} TrailScreen(UITask* task, TrailStore* store) : _task(task), _store(store) {}
void enter() { void enter() {
_view = _store->isActive() ? V_SUMMARY : V_CONFIG; _view = V_SUMMARY;
_summary_scroll = 0; _summary_scroll = 0;
_list_scroll = 0; _list_scroll = 0;
_cfg_dirty = false; _cfg_dirty = false;
@@ -48,52 +48,58 @@ public:
display.setTextSize(1); display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
const char* title = (_view == V_MAP) ? "TRAIL MAP" const char* title = (_view == V_MAP) ? "TRAIL MAP"
: (_view == V_LIST) ? "TRAIL LIST" : (_view == V_LIST) ? "TRAIL LIST"
: "TRAIL"; : "TRAIL";
display.drawTextCentered(display.width() / 2, 0, title); display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
if (_view == V_CONFIG) renderConfig(display); if (_view == V_MAP) renderMap(display);
else if (_view == V_MAP) renderMap(display); else if (_view == V_LIST) renderList(display);
else if (_view == V_LIST) renderList(display); else renderSummary(display);
else renderSummary(display);
// Bottom hint with a thin separator above so the line never touches the // Bottom hint with separator above so content never touches it.
// content rendered just above it.
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
const int hint_y = display.height() - display.lineStep(); const int hint_y = display.height() - display.lineStep();
display.fillRect(0, hint_y - 2, display.width(), 1); display.fillRect(0, hint_y - 2, display.width(), 1);
display.setCursor(2, hint_y); display.setCursor(2, hint_y);
char hint[28]; char hint[28];
if (_view == V_CONFIG) { snprintf(hint, sizeof(hint), "<>%d/%d %s [Hold]menu",
snprintf(hint, sizeof(hint), "<>1/%d [Ent] %s", (int)V_COUNT, (int)_view + 1, (int)V_COUNT,
_store->isActive() ? "stop" : (_store->empty() ? "start" : "resume")); _store->isActive() ? "act" : "[Ent]start");
} else {
snprintf(hint, sizeof(hint), "<>%d/%d [Ent] %s",
(int)_view + 1, (int)V_COUNT,
_store->isActive() ? "stop" : "start");
}
display.print(hint); display.print(hint);
// Action popup overlay (Hold Enter): Reset / Save / Load / Export GPX.
if (_action_menu.active) _action_menu.render(display); if (_action_menu.active) _action_menu.render(display);
return _store->isActive() ? 1000 : 5000; return _store->isActive() ? 1000 : 5000;
} }
bool handleInput(char c) override { bool handleInput(char c) override {
// Action popup overrides everything else while open. // Action popup overrides everything else.
if (_action_menu.active) { if (_action_menu.active) {
// LEFT/RIGHT on settings rows cycles the value in-place and refreshes
// the popup label — the popup stays open so the user can keep tapping.
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) {
int idx = _action_menu.selectedIndex();
if (idx >= 0 && idx < _act_count) {
NodePrefs* p = _task->getNodePrefs();
int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1;
if (_act_map[idx] == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (_act_map[idx] == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
}
return true; // swallow on action rows
}
auto res = _action_menu.handleInput(c); auto res = _action_menu.handleInput(c);
if (res == PopupMenu::SELECTED) { if (res == PopupMenu::SELECTED) {
// Item 0 = "Reset trail"; future Save/Load/Export to come. int sel = _action_menu.selectedIndex();
if (_action_menu.selectedIndex() == 0) { if (sel >= 0 && sel < _act_count) {
if (_store->isActive()) _store->setActive(false); ActionId act = (ActionId)_act_map[sel];
_store->clear(); if (act == ACT_TOGGLE) { handleToggle(); }
_view = V_CONFIG; else if (act == ACT_RESET) { handleReset(); }
_task->showAlert("Trail reset", 800); else /* MIN_DIST / UNITS */ { reopenAt(sel); return true; } // re-open keeping focus
} }
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
} else if (res == PopupMenu::CANCELLED) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
} }
return true; return true;
} }
@@ -103,68 +109,29 @@ public:
_task->gotoToolsScreen(); _task->gotoToolsScreen();
return true; return true;
} }
if (c == KEY_CONTEXT_MENU) { if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
openActionMenu();
return true;
}
if (_view == V_CONFIG) { if (c == KEY_LEFT || c == KEY_PREV) { _view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT); return true; }
NodePrefs* p = _task->getNodePrefs(); if (c == KEY_RIGHT || c == KEY_NEXT) { _view = (uint8_t)((_view + 1) % V_COUNT); return true; }
if (c == KEY_UP && _cfg_sel > 0) { _cfg_sel--; return true; }
if (c == KEY_DOWN && _cfg_sel < CFG_ROW_COUNT - 1) { _cfg_sel++; return true; }
if ((c == KEY_LEFT || c == KEY_PREV) && p) { cycleCfg(p, -1); return true; }
if ((c == KEY_RIGHT || c == KEY_NEXT) && p) { cycleCfg(p, +1); return true; }
if (c == KEY_ENTER) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
bool was_active = _store->isActive();
_store->setActive(!was_active);
if (!was_active) {
_view = V_SUMMARY;
_task->showAlert(gpsHasFix() ? "Tracking started" : "Waiting for GPS fix", 1000);
} else {
_task->showAlert("Tracking stopped", 800);
}
return true;
}
return false;
}
// Summary / Map / List — cycle through all 4 views (including back to Config).
if (c == KEY_LEFT || c == KEY_PREV) {
_view = (uint8_t)((_view + V_COUNT - 1) % V_COUNT);
return true;
}
if (c == KEY_RIGHT || c == KEY_NEXT) {
_view = (uint8_t)((_view + 1) % V_COUNT);
return true;
}
if (_view == V_SUMMARY && c == KEY_UP && _summary_scroll > 0) { _summary_scroll--; return true; } if (_view == V_SUMMARY && c == KEY_UP && _summary_scroll > 0) { _summary_scroll--; return true; }
if (_view == V_SUMMARY && c == KEY_DOWN) { _summary_scroll++; return true; } if (_view == V_SUMMARY && c == KEY_DOWN) { _summary_scroll++; return true; }
if (_view == V_LIST && c == KEY_UP && _list_scroll > 0) { _list_scroll--; return true; } if (_view == V_LIST && c == KEY_UP && _list_scroll > 0) { _list_scroll--; return true; }
if (_view == V_LIST && c == KEY_DOWN) { _list_scroll++; return true; } if (_view == V_LIST && c == KEY_DOWN) { _list_scroll++; return true; }
if (c == KEY_ENTER) { if (c == KEY_ENTER) {
bool was_active = _store->isActive(); // Short Enter starts the trail if it's stopped — start is safe. Once
_store->setActive(!was_active); // active, short Enter does nothing on purpose: stop is only reachable
_task->showAlert(was_active ? "Tracking stopped" // through the popup so a stray tap can't end a recording.
: (gpsHasFix() ? "Tracking started" : "Waiting for GPS fix"), if (!_store->isActive()) {
was_active ? 800 : 1000); _store->setActive(true);
_task->showAlert(gpsHasFix() ? "Tracking started" : "Waiting for GPS fix", 1000);
} else {
_task->showAlert("Hold Enter for menu", 1000);
}
return true; return true;
} }
return false; return false;
} }
// Build and open the action popup. Items depend on what makes sense for the
// current state: Reset is only useful when there's something to reset.
void openActionMenu() {
_action_menu.begin("Trail actions", 3);
if (!_store->empty()) _action_menu.addItem("Reset trail");
// Phase 4 will add: Save trail, Load trail, Export GPX.
if (_action_menu._count == 0) {
_action_menu.active = false;
_task->showAlert("No actions yet", 800);
}
}
// True when the global SensorManager has a usable GPS fix. // True when the global SensorManager has a usable GPS fix.
static bool gpsHasFix() { static bool gpsHasFix() {
#if ENV_INCLUDE_GPS == 1 #if ENV_INCLUDE_GPS == 1
@@ -176,22 +143,64 @@ public:
} }
private: private:
// Cycle the focused Config row's value. Dirty flag commits on Start / Back. void handleToggle() {
void cycleCfg(NodePrefs* p, int dir) { bool was = _store->isActive();
if (_cfg_sel == CFG_MIN_DIST) { _store->setActive(!was);
uint8_t idx = p->trail_min_delta_idx; _task->showAlert(was ? "Tracking stopped"
if (idx >= TrailStore::MIN_DELTA_COUNT) idx = 0; : (gpsHasFix() ? "Tracking started" : "Waiting for GPS fix"),
idx = (dir > 0) ? (idx + 1) % TrailStore::MIN_DELTA_COUNT was ? 800 : 1000);
: (idx + TrailStore::MIN_DELTA_COUNT - 1) % TrailStore::MIN_DELTA_COUNT; }
p->trail_min_delta_idx = idx; void handleReset() {
} else if (_cfg_sel == CFG_UNITS) { if (_store->isActive()) _store->setActive(false);
uint8_t idx = p->trail_units_idx; _store->clear();
if (idx >= TrailStore::UNITS_COUNT) idx = 0; _task->showAlert("Trail reset", 800);
idx = (dir > 0) ? (idx + 1) % TrailStore::UNITS_COUNT }
: (idx + TrailStore::UNITS_COUNT - 1) % TrailStore::UNITS_COUNT;
p->trail_units_idx = idx; // 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));
snprintf(_act_units_label, sizeof(_act_units_label),
"Units: %s", TrailStore::unitLabel(p ? p->trail_units_idx : 0));
snprintf(_act_toggle_label, sizeof(_act_toggle_label),
"%s tracking", _store->isActive() ? "Stop" : "Start");
}
void openActionMenu() {
refreshActionLabels();
_act_count = 0;
_action_menu.begin("Trail", 4);
_act_map[_act_count++] = ACT_MIN_DIST; _action_menu.addItem(_act_min_dist_label);
_act_map[_act_count++] = ACT_UNITS; _action_menu.addItem(_act_units_label);
_act_map[_act_count++] = ACT_TOGGLE; _action_menu.addItem(_act_toggle_label);
if (!_store->empty()) {
_act_map[_act_count++] = ACT_RESET; _action_menu.addItem("Reset trail");
} }
_cfg_dirty = true; }
// After Enter on a settings row, the popup auto-closes per PopupMenu's
// semantics. Re-open it with focus restored to that row so the user can
// continue cycling.
void reopenAt(int sel) {
openActionMenu();
if (sel >= 0 && sel < _act_count) _action_menu._sel = 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;
}
void cycleUnits(NodePrefs* p, int dir) {
uint8_t idx = p->trail_units_idx;
if (idx >= TrailStore::UNITS_COUNT) idx = 0;
idx = (dir > 0) ? (idx + 1) % TrailStore::UNITS_COUNT
: (idx + TrailStore::UNITS_COUNT - 1) % TrailStore::UNITS_COUNT;
p->trail_units_idx = idx;
} }
// Render the average speed (or pace) in the user-selected units. // Render the average speed (or pace) in the user-selected units.
@@ -202,11 +211,9 @@ private:
if (u == TrailStore::UNITS_KMH) { if (u == TrailStore::UNITS_KMH) {
snprintf(buf, n, "Avg: %u km/h", (unsigned)kmh); snprintf(buf, n, "Avg: %u km/h", (unsigned)kmh);
} else if (u == TrailStore::UNITS_MPH) { } else if (u == TrailStore::UNITS_MPH) {
// 1 km = 0.621371 mi
unsigned mph = (unsigned)((float)kmh * 0.621371f + 0.5f); unsigned mph = (unsigned)((float)kmh * 0.621371f + 0.5f);
snprintf(buf, n, "Avg: %u mph", mph); snprintf(buf, n, "Avg: %u mph", mph);
} else { } else {
// Pace = elapsed / distance. Compute directly from raw totals for precision.
uint32_t d_m = _store->totalDistanceMeters(); uint32_t d_m = _store->totalDistanceMeters();
uint32_t es_s = _store->elapsedSeconds(); uint32_t es_s = _store->elapsedSeconds();
const char* unit = (u == TrailStore::UNITS_PACE_KM) ? "/km" : "/mi"; const char* unit = (u == TrailStore::UNITS_PACE_KM) ? "/km" : "/mi";
@@ -224,49 +231,6 @@ private:
} }
} }
void renderConfig(DisplayDriver& display) {
NodePrefs* p = _task->getNodePrefs();
const int y0 = display.listStart();
const int step = display.lineStep();
const int cw = display.getCharWidth();
char buf[24];
// Row 0 — Min dist
display.setCursor(0, y0);
display.print(_cfg_sel == CFG_MIN_DIST ? ">" : " ");
snprintf(buf, sizeof(buf), "Min dist: %s",
TrailStore::minDeltaLabel(p ? p->trail_min_delta_idx : 0));
display.setCursor(cw + 2, y0);
display.print(buf);
// Row 1 — Units
display.setCursor(0, y0 + step);
display.print(_cfg_sel == CFG_UNITS ? ">" : " ");
snprintf(buf, sizeof(buf), "Units: %s",
TrailStore::unitLabel(p ? p->trail_units_idx : 0));
display.setCursor(cw + 2, y0 + step);
display.print(buf);
// Existing trail summary (if any) so the user sees what Enter will resume.
if (!_store->empty()) {
snprintf(buf, sizeof(buf), "Stored: %d pts", _store->count());
display.setCursor(2, y0 + step * 3);
display.print(buf);
uint32_t d = _store->totalDistanceMeters();
if (d < 1000) snprintf(buf, sizeof(buf), "Dist: %lu m", (unsigned long)d);
else snprintf(buf, sizeof(buf), "Dist: %lu.%02lu km",
(unsigned long)(d / 1000), (unsigned long)((d % 1000) / 10));
display.setCursor(2, y0 + step * 4);
display.print(buf);
} else {
display.setCursor(2, y0 + step * 3);
display.print("No trail yet.");
}
}
// Format the i-th summary line into buf. Indices match SUMMARY_ITEM_COUNT.
void summaryItem(int i, char* buf, size_t n) const { void summaryItem(int i, char* buf, size_t n) const {
switch (i) { switch (i) {
case 0: { case 0: {
@@ -289,7 +253,6 @@ private:
} }
case 3: { case 3: {
uint32_t es = _store->elapsedSeconds(); uint32_t es = _store->elapsedSeconds();
// Below 1 h show m:ss so the seconds counter updates visibly each refresh.
if (es < 3600) snprintf(buf, n, "Time: %lu:%02lu", if (es < 3600) snprintf(buf, n, "Time: %lu:%02lu",
(unsigned long)(es / 60), (unsigned long)(es % 60)); (unsigned long)(es / 60), (unsigned long)(es % 60));
else snprintf(buf, n, "Time: %lu:%02lu", else snprintf(buf, n, "Time: %lu:%02lu",
@@ -313,7 +276,6 @@ private:
if (visible < 1) visible = 1; if (visible < 1) visible = 1;
if (visible > SUMMARY_ITEM_COUNT) visible = SUMMARY_ITEM_COUNT; if (visible > SUMMARY_ITEM_COUNT) visible = SUMMARY_ITEM_COUNT;
// Clamp scroll once we know visible count.
int max_scroll = SUMMARY_ITEM_COUNT - visible; int max_scroll = SUMMARY_ITEM_COUNT - visible;
if (_summary_scroll > max_scroll) _summary_scroll = max_scroll; if (_summary_scroll > max_scroll) _summary_scroll = max_scroll;
if (_summary_scroll < 0) _summary_scroll = 0; if (_summary_scroll < 0) _summary_scroll = 0;
@@ -338,8 +300,6 @@ private:
} }
} }
// Per-point list, newest first. Each row: HH:MM (local) + delta from the
// previous point in the same segment, or "start" for segment-start rows.
void renderList(DisplayDriver& display) { void renderList(DisplayDriver& display) {
const int top = display.listStart(); const int top = display.listStart();
const int step = display.lineStep(); const int step = display.lineStep();
@@ -364,7 +324,7 @@ private:
int32_t tz_off = (int32_t)(p ? p->tz_offset_hours : 0) * 3600; int32_t tz_off = (int32_t)(p ? p->tz_offset_hours : 0) * 3600;
for (int i = 0; i < visible; i++) { for (int i = 0; i < visible; i++) {
int idx = total - 1 - (_list_scroll + i); // newest first int idx = total - 1 - (_list_scroll + i);
if (idx < 0) break; if (idx < 0) break;
const TrailPoint& pt = _store->at(idx); const TrailPoint& pt = _store->at(idx);
@@ -400,8 +360,6 @@ private:
} }
} }
// Pixel-by-pixel polyline. Bounds are auto-fitted to the available area and
// longitude is scaled by cos(avg_lat) so high-latitude trails don't stretch.
void renderMap(DisplayDriver& display) { void renderMap(DisplayDriver& display) {
const int top = display.listStart(); const int top = display.listStart();
const int bottom = display.height() - display.lineStep() - 3; const int bottom = display.height() - display.lineStep() - 3;
@@ -415,31 +373,27 @@ private:
const int area_y = top + 1; const int area_y = top + 1;
const int area_w = display.width() - 4; const int area_w = display.width() - 4;
const int area_h = bottom - top - 2; const int area_h = bottom - top - 2;
if (area_w < 6 || area_h < 6) return; // no room to draw anything sensible if (area_w < 6 || area_h < 6) return;
int32_t min_lat, min_lon, max_lat, max_lon; int32_t min_lat, min_lon, max_lat, max_lon;
_store->boundingBox(min_lat, min_lon, max_lat, max_lon); _store->boundingBox(min_lat, min_lon, max_lat, max_lon);
if (_store->count() == 1 || (min_lat == max_lat && min_lon == max_lon)) { if (_store->count() == 1 || (min_lat == max_lat && min_lon == max_lon)) {
// Degenerate — single point or all samples colocated. Mark centre.
drawCurrentMarker(display, area_x + area_w / 2, area_y + area_h / 2); drawCurrentMarker(display, area_x + area_w / 2, area_y + area_h / 2);
return; return;
} }
// cos(lat) correction prevents east/west stretching at higher latitudes.
float avg_lat_rad = ((min_lat + max_lat) / 2.0e6f) * (float)M_PI / 180.0f; float avg_lat_rad = ((min_lat + max_lat) / 2.0e6f) * (float)M_PI / 180.0f;
float lon_scale_geo = cosf(avg_lat_rad); float lon_scale_geo = cosf(avg_lat_rad);
if (lon_scale_geo < 0.05f) lon_scale_geo = 0.05f; // guard near poles if (lon_scale_geo < 0.05f) lon_scale_geo = 0.05f;
float lat_span = (float)(max_lat - min_lat); float lat_span = (float)(max_lat - min_lat);
float lon_span = (float)(max_lon - min_lon) * lon_scale_geo; float lon_span = (float)(max_lon - min_lon) * lon_scale_geo;
// Pick the limiting axis so the polyline fills the area without distorting.
float scale_lat = (float)area_h / (lat_span > 0 ? lat_span : 1.0f); 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_lon = (float)area_w / (lon_span > 0 ? lon_span : 1.0f);
float scale = (scale_lat < scale_lon) ? scale_lat : scale_lon; float scale = (scale_lat < scale_lon) ? scale_lat : scale_lon;
// After scaling, the used area is smaller in the non-limiting dim; centre it.
int used_w = (int)(lon_span * scale); int used_w = (int)(lon_span * scale);
int used_h = (int)(lat_span * scale); int used_h = (int)(lat_span * scale);
int off_x = area_x + (area_w - used_w) / 2; int off_x = area_x + (area_w - used_w) / 2;
@@ -447,15 +401,11 @@ private:
auto project = [&](const TrailPoint& p, int& px, int& py) { auto project = [&](const TrailPoint& p, int& px, int& py) {
float dx = (float)(p.lon_1e6 - min_lon) * lon_scale_geo * scale; float dx = (float)(p.lon_1e6 - min_lon) * lon_scale_geo * scale;
float dy = (float)(max_lat - p.lat_1e6) * scale; // north = up float dy = (float)(max_lat - p.lat_1e6) * scale;
px = off_x + (int)dx; px = off_x + (int)dx;
py = off_y + (int)dy; py = off_y + (int)dy;
}; };
// Draw connected segments. A point flagged SEG_START starts a new segment;
// its predecessor is not joined to it. At every break we mark the segment
// end (filled dot) and the resume point (open dot) so the user can see
// where tracking paused.
int x0, y0; int x0, y0;
project(_store->at(0), x0, y0); project(_store->at(0), x0, y0);
display.fillRect(x0, y0, 1, 1); display.fillRect(x0, y0, 1, 1);
@@ -463,8 +413,8 @@ private:
int x1, y1; int x1, y1;
project(_store->at(i), x1, y1); project(_store->at(i), x1, y1);
if (_store->at(i).flags & TRAIL_FLAG_SEG_START) { if (_store->at(i).flags & TRAIL_FLAG_SEG_START) {
drawFilledDot(display, x0, y0); // end of the previous segment drawFilledDot(display, x0, y0);
drawOpenDot(display, x1, y1); // resume point drawOpenDot(display, x1, y1);
} else { } else {
drawLine(display, x0, y0, x1, y1); drawLine(display, x0, y0, x1, y1);
} }
@@ -478,12 +428,9 @@ private:
drawStartMarker(display, sx, sy); drawStartMarker(display, sx, sy);
drawCurrentMarker(display, ex, ey); drawCurrentMarker(display, ex, ey);
// North indicator in the top-right of the map area — the projection is
// strictly north-up (y inverted from latitude), so the arrow is fixed.
drawNorthArrow(display, area_x + area_w - 4, area_y + display.getLineHeight() + 1); drawNorthArrow(display, area_x + area_w - 4, area_y + display.getLineHeight() + 1);
} }
// Bresenham line via 1×1 fillRect. Acceptable for occasional view renders.
static void drawLine(DisplayDriver& d, int x0, int y0, int x1, int y1) { static void drawLine(DisplayDriver& d, int x0, int y0, int x1, int y1) {
int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1; int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1; int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
@@ -497,45 +444,33 @@ private:
} }
} }
// 3×3 filled square — end of a segment before a break.
static void drawFilledDot(DisplayDriver& d, int cx, int cy) { static void drawFilledDot(DisplayDriver& d, int cx, int cy) {
d.fillRect(cx - 1, cy - 1, 3, 3); d.fillRect(cx - 1, cy - 1, 3, 3);
} }
// 3×3 outline square — start of a segment after a break (resume point).
static void drawOpenDot(DisplayDriver& d, int cx, int cy) { static void drawOpenDot(DisplayDriver& d, int cx, int cy) {
d.fillRect(cx - 1, cy - 1, 3, 1); // top d.fillRect(cx - 1, cy - 1, 3, 1);
d.fillRect(cx - 1, cy + 1, 3, 1); // bottom d.fillRect(cx - 1, cy + 1, 3, 1);
d.fillRect(cx - 1, cy, 1, 1); // left d.fillRect(cx - 1, cy, 1, 1);
d.fillRect(cx + 1, cy, 1, 1); // right d.fillRect(cx + 1, cy, 1, 1);
} }
// 5×5 plus-sign marker for the start point.
static void drawStartMarker(DisplayDriver& d, int cx, int cy) { static void drawStartMarker(DisplayDriver& d, int cx, int cy) {
d.fillRect(cx - 2, cy, 5, 1); d.fillRect(cx - 2, cy, 5, 1);
d.fillRect(cx, cy - 2, 1, 5); d.fillRect(cx, cy - 2, 1, 5);
} }
// 5×5 cross marker for the current/last point.
static void drawCurrentMarker(DisplayDriver& d, int cx, int cy) { static void drawCurrentMarker(DisplayDriver& d, int cx, int cy) {
for (int i = -2; i <= 2; i++) { for (int i = -2; i <= 2; i++) {
d.fillRect(cx + i, cy + i, 1, 1); d.fillRect(cx + i, cy + i, 1, 1);
d.fillRect(cx + i, cy - i, 1, 1); d.fillRect(cx + i, cy - i, 1, 1);
} }
} }
// North arrow + "N" label. Tip at (cx, cy), arrow body extends down for 6 px;
// the "N" sits one line height above the tip.
static void drawNorthArrow(DisplayDriver& d, int cx, int cy) { static void drawNorthArrow(DisplayDriver& d, int cx, int cy) {
int lh = d.getLineHeight(); int lh = d.getLineHeight();
int cw = d.getCharWidth(); int cw = d.getCharWidth();
d.setCursor(cx - cw / 2, cy - lh); d.setCursor(cx - cw / 2, cy - lh);
d.print("N"); d.print("N");
// arrowhead
d.fillRect(cx, cy, 1, 1); d.fillRect(cx, cy, 1, 1);
d.fillRect(cx - 1, cy + 1, 3, 1); d.fillRect(cx - 1, cy + 1, 3, 1);
d.fillRect(cx - 2, cy + 2, 5, 1); d.fillRect(cx - 2, cy + 2, 5, 1);
// shaft
d.fillRect(cx, cy + 3, 1, 4); d.fillRect(cx, cy + 3, 1, 4);
} }
}; };