feat(nav): heads-up compass + always-available Trail-start waypoint

Compass: switch to a heads-up card. The direction of travel is now the
fixed "up" index at the top and the compass card rotates underneath it —
a North needle plus N/E/S/W letters placed at screen angle (bearing - cog),
so North spins as you turn rather than the arrow pointing at your course.

Trail waypoints: the nav list now always starts with a synthetic
"Trail start" row whenever a trail exists, so you can navigate back to
where you began without having marked it. The "Waypoints" action appears
when there are saved waypoints OR a trail to backtrack to. Rename/Delete
apply only to saved waypoints; the start row is navigate-only. List/nav
indexing goes through rowTarget()/wpIndex() helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-03 16:51:40 +02:00
parent 3110b59f93
commit 98ccf13336
2 changed files with 88 additions and 40 deletions

View File

@@ -67,9 +67,12 @@ public:
int cog;
bool have = _task->currentCourse(cog);
// Compass ring ticks: N/E/S/W as short marks; N drawn as a filled wedge top.
display.drawRect(cx - r, cy - r, 2 * r + 1, 2 * r + 1); // bounding box stands in for a ring
display.drawTextCentered(cx, cy - r - 1, "N");
display.drawRect(cx - r, cy - r, 2 * r + 1, 2 * r + 1); // ring stand-in
// Fixed forward index at the top: the direction you are travelling is
// always "up". The compass card (North) rotates underneath it.
display.fillRect(cx - 1, cy - r - 2, 3, 1);
display.fillRect(cx, cy - r - 1, 1, 2);
if (!have) {
display.drawTextCentered(cx, cy - display.getLineHeight() / 2, "move to");
@@ -77,20 +80,34 @@ public:
return 1000;
}
// Arrow from centre toward the course (0° = up = north, clockwise).
float rad = cog * (float)M_PI / 180.0f;
int ex = cx + (int)(sinf(rad) * (r - 2));
int ey = cy - (int)(cosf(rad) * (r - 2));
drawLine(display, cx, cy, ex, ey);
// Small arrowhead: two short lines back from the tip at ±150°.
// Heads-up compass: with "up" = course, an absolute bearing B sits at
// screen angle (B - cog). Draw the rotating card — a North needle plus the
// other three cardinals as letters around the ring.
auto cardPos = [&](int bearing, int rad_px, int& px, int& py) {
float a = (bearing - cog) * (float)M_PI / 180.0f;
px = cx + (int)(sinf(a) * rad_px);
py = cy - (int)(cosf(a) * rad_px);
};
// North needle from centre.
int nx, ny; cardPos(0, r - 2, nx, ny);
drawLine(display, cx, cy, nx, ny);
float nrad = (0 - cog) * (float)M_PI / 180.0f;
for (int da = -25; da <= 25; da += 50) {
float a = rad + (float)M_PI + da * (float)M_PI / 180.0f;
int hx = ex + (int)(sinf(a) * 4);
int hy = ey - (int)(cosf(a) * 4);
drawLine(display, ex, ey, hx, hy);
float a = nrad + (float)M_PI + da * (float)M_PI / 180.0f;
drawLine(display, nx, ny, nx + (int)(sinf(a) * 4), ny - (int)(cosf(a) * 4));
}
// Numeric readout below the ring.
// Cardinal letters around the rim (N at the needle tip).
const int cw = display.getCharWidth(), ch = display.getLineHeight();
struct { int b; const char* s; } card[4] = { {0,"N"},{90,"E"},{180,"S"},{270,"W"} };
for (int i = 0; i < 4; i++) {
int lx, ly; cardPos(card[i].b, r - 2, lx, ly);
display.setCursor(lx - cw / 2, ly - ch / 2);
display.print(card[i].s);
}
// Numeric readout below the ring — the absolute course you're travelling.
char buf[16];
snprintf(buf, sizeof(buf), "%d %s", cog, geo::bearingCardinal(cog));
display.setTextSize(2);

View File

@@ -105,23 +105,26 @@ public:
return true;
}
// Waypoint Rename/Delete popup.
// Waypoint Rename/Delete popup. Operates on the saved-waypoint index
// (the synthetic Trail-start row, if any, is never editable).
if (_wp_ctx.active) {
auto res = _wp_ctx.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _wp_ctx.selectedIndex();
int wi = wpIndex();
if (sel == 0) { // Rename
if (_wp_sel < _task->waypoints().count()) {
_kb_rename_idx = _wp_sel;
_wp_kb.begin(_task->waypoints().at(_wp_sel).label, WAYPOINT_LABEL_LEN - 1);
if (wi >= 0 && wi < _task->waypoints().count()) {
_kb_rename_idx = wi;
_wp_kb.begin(_task->waypoints().at(wi).label, WAYPOINT_LABEL_LEN - 1);
_kb_active = true;
}
} else { // Delete
_task->waypoints().remove(_wp_sel);
_task->saveWaypoints();
if (_wp_sel >= _task->waypoints().count())
_wp_sel = _task->waypoints().count() - 1;
if (_wp_sel < 0) _wp_sel = 0;
if (wi >= 0 && wi < _task->waypoints().count()) {
_task->waypoints().remove(wi);
_task->saveWaypoints();
if (_wp_sel >= wpListCount()) _wp_sel = wpListCount() - 1;
if (_wp_sel < 0) _wp_sel = 0;
}
}
}
return true;
@@ -134,14 +137,15 @@ public:
return true;
}
// Waypoint list.
// Waypoint list (row 0 is the synthetic "Trail start" when a trail exists).
if (_wp_mode == WP_LIST) {
int n = _task->waypoints().count();
int n = wpListCount();
if (c == KEY_CANCEL) { _wp_mode = WP_OFF; return true; }
if (c == KEY_UP && _wp_sel > 0) { _wp_sel--; if (_wp_sel < _wp_scroll) _wp_scroll = _wp_sel; return true; }
if (c == KEY_DOWN && _wp_sel < n - 1) { _wp_sel++; return true; }
if (c == KEY_ENTER && n > 0) { _wp_mode = WP_NAV; return true; }
if (c == KEY_CONTEXT_MENU && n > 0) {
// Rename/Delete apply to saved waypoints only — not the Trail-start row.
if (c == KEY_CONTEXT_MENU && !selIsStart() && wpIndex() < _task->waypoints().count()) {
_wp_ctx.begin("Waypoint", 2);
_wp_ctx.addItem("Rename");
_wp_ctx.addItem("Delete");
@@ -307,7 +311,9 @@ private:
_act_map[_act_count++] = ACT_TOGGLE; _action_menu.addItem(_act_toggle_label);
// Waypoints: mark the current spot, and browse/navigate saved ones.
_act_map[_act_count++] = ACT_MARK; _action_menu.addItem("Mark here");
if (_task->waypoints().count() > 0) {
// "Waypoints" opens the nav list — useful when there are saved waypoints
// OR a trail to backtrack to (the list always includes a "Trail start" row).
if (_task->waypoints().count() > 0 || !_store->empty()) {
_act_map[_act_count++] = ACT_WAYPOINTS; _action_menu.addItem("Waypoints");
}
if (!_store->empty()) {
@@ -338,6 +344,14 @@ private:
}
// ── Waypoints ──────────────────────────────────────────────────────────────
// The nav list shows a synthetic "Trail start" row (index 0) whenever a trail
// exists, followed by the saved waypoints. These helpers map the combined
// row index (_wp_sel) onto that layout.
bool hasStart() const { return !_store->empty(); }
int wpListCount() const { return (hasStart() ? 1 : 0) + _task->waypoints().count(); }
bool selIsStart() const { return hasStart() && _wp_sel == 0; }
int wpIndex() const { return _wp_sel - (hasStart() ? 1 : 0); } // index into WaypointStore
static bool ownPos(int32_t& lat, int32_t& lon) {
#if ENV_INCLUDE_GPS == 1
LocationProvider* loc = sensors.getLocationProvider();
@@ -385,8 +399,7 @@ private:
display.drawTextCentered(display.width() / 2, 0, "WAYPOINTS");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
WaypointStore& wp = _task->waypoints();
int n = wp.count();
int n = wpListCount();
if (n == 0) {
display.drawTextCentered(display.width() / 2, display.height() / 2, "No waypoints");
return;
@@ -402,20 +415,21 @@ private:
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
for (int i = 0; i < vis && (_wp_scroll + i) < n; i++) {
int idx = _wp_scroll + i;
const Waypoint& w = wp.at(idx);
int row = _wp_scroll + i;
int32_t tlat, tlon; const char* label;
if (!rowTarget(row, tlat, tlon, label)) continue;
int y = top + i * step;
bool sel = (idx == _wp_sel);
bool sel = (row == _wp_sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
display.setCursor(0, y); display.print(sel ? ">" : " ");
char dist[12] = ""; int bw = 0;
if (have) {
geo::fmtDist(dist, sizeof(dist), geo::haversineKm(mylat, mylon, w.lat_1e6, w.lon_1e6));
geo::fmtDist(dist, sizeof(dist), geo::haversineKm(mylat, mylon, tlat, tlon));
bw = display.getTextWidth(dist) + 2;
}
char nm[WAYPOINT_LABEL_LEN];
display.translateUTF8ToBlocks(nm, w.label[0] ? w.label : "(unnamed)", sizeof(nm));
char nm[24];
display.translateUTF8ToBlocks(nm, label, sizeof(nm));
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw, nm);
if (dist[0]) { display.setCursor(display.width() - bw + 1, y); display.print(dist); }
display.setColor(DisplayDriver::LIGHT);
@@ -423,13 +437,30 @@ private:
}
void renderWpNav(DisplayDriver& display) {
WaypointStore& wp = _task->waypoints();
if (_wp_sel >= wp.count()) { _wp_mode = WP_LIST; return; }
const Waypoint& w = wp.at(_wp_sel);
int32_t tlat, tlon; const char* label;
if (!rowTarget(_wp_sel, tlat, tlon, label)) { _wp_mode = WP_LIST; return; }
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
int cog; bool cogv = _task->currentCourse(cog);
navview::draw(display, have, mylat, mylon, w.lat_1e6, w.lon_1e6,
w.label[0] ? w.label : "(unnamed)", cogv, cog);
navview::draw(display, have, mylat, mylon, tlat, tlon, label, cogv, cog);
}
// Resolve a combined-list row to a nav target. Row 0 is the trail start when
// a trail exists; the rest are saved waypoints.
bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) {
if (hasStart() && row == 0) {
const TrailPoint& s = _store->first();
lat = s.lat_1e6; lon = s.lon_1e6; label = "Trail start";
return true;
}
int wi = row - (hasStart() ? 1 : 0);
WaypointStore& wp = _task->waypoints();
if (wi >= 0 && wi < wp.count()) {
const Waypoint& w = wp.at(wi);
lat = w.lat_1e6; lon = w.lon_1e6;
label = w.label[0] ? w.label : "(unnamed)";
return true;
}
return false;
}
// After Enter on a settings row, the popup auto-closes per PopupMenu's