fix(ui): trail sampling, avg speed, RTC elapsed, segment-aware map

Test feedback revealed four issues:

1. Sampling too sparse — defaults were 60 s interval + 25 m min-delta,
   so a walking pace dropped ~80% of samples. New defaults: 30 s + 5 m.
   Interval options expanded to { 30, 10, 20, 60, 300, 900 } s and
   min-delta to { 5, 10, 25, 100 } m so the user can dial it further
   from Settings (phase 5).

2. "Speed" was current-from-last-pair, misleading next to a Time field
   that grows monotonically. Switch to avgSpeedKmh = total / elapsed,
   labeled "Avg speed".

3. Time advanced only when a new sample landed (it used
   last().ts - first().ts). Now elapsedSeconds takes an optional
   now_ts and uses it whenever the trail is active, so the Time field
   in Summary ticks every render cycle. Sub-1h shown as m:ss for
   visible seconds.

4. Stop → start drew a straight line across the dead time. TrailPoint
   gains a flags byte; addPoint flags the first point and the first
   point after a re-arm as SEG_START. The map renderer skips the line
   from the predecessor for SEG_START points (still draws the start
   pixel). totalDistanceMeters also skips segment boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-25 10:10:03 +02:00
parent 9d038730df
commit 7f0985c63f
2 changed files with 61 additions and 29 deletions

View File

@@ -13,36 +13,46 @@ struct TrailPoint {
int32_t lat_1e6;
int32_t lon_1e6;
uint32_t ts; // epoch seconds (RTC)
uint8_t flags; // bit 0 = SEG_START (don't draw a line from the previous point)
};
static const uint8_t TRAIL_FLAG_SEG_START = 0x01;
class TrailStore {
public:
static const int CAPACITY = 256;
// Interval options (seconds) and their settings labels. Index 0 default = 1 min.
static const uint8_t INTERVAL_COUNT = 4;
// Interval options (seconds) and their settings labels. Index 0 default = 30 s,
// a reasonable middle for walking/cycling without burning GPS frames.
static const uint8_t INTERVAL_COUNT = 6;
static uint16_t intervalSecs(uint8_t idx) {
static const uint16_t OPTS[INTERVAL_COUNT] = { 60, 30, 300, 900 };
static const uint16_t OPTS[INTERVAL_COUNT] = { 30, 10, 20, 60, 300, 900 };
return OPTS[idx < INTERVAL_COUNT ? idx : 0];
}
static const char* intervalLabel(uint8_t idx) {
static const char* L[INTERVAL_COUNT] = { "1 min", "30 s", "5 min", "15 min" };
static const char* L[INTERVAL_COUNT] = { "30 s", "10 s", "20 s", "1 min", "5 min", "15 min" };
return L[idx < INTERVAL_COUNT ? idx : 0];
}
// Min-delta (metres) gates samples too close to the previous one.
static const uint8_t MIN_DELTA_COUNT = 3;
// Default 5 m: keeps walking jitter out, dense enough for a visible trail.
static const uint8_t MIN_DELTA_COUNT = 4;
static uint16_t minDeltaMeters(uint8_t idx) {
static const uint16_t OPTS[MIN_DELTA_COUNT] = { 25, 5, 100 };
static const uint16_t OPTS[MIN_DELTA_COUNT] = { 5, 10, 25, 100 };
return OPTS[idx < MIN_DELTA_COUNT ? idx : 0];
}
static const char* minDeltaLabel(uint8_t idx) {
static const char* L[MIN_DELTA_COUNT] = { "25 m", "5 m", "100 m" };
static const char* L[MIN_DELTA_COUNT] = { "5 m", "10 m", "25 m", "100 m" };
return L[idx < MIN_DELTA_COUNT ? idx : 0];
}
bool isActive() const { return _active; }
void setActive(bool a) { _active = a; }
void setActive(bool a) {
// Re-arming after a stop marks the next addPoint as a segment start, so
// the renderer doesn't draw a straight line through the dead time.
if (_active && !a) _pending_seg_break = true;
_active = a;
}
int count() const { return _count; }
bool empty() const { return _count == 0; }
@@ -52,14 +62,19 @@ public:
const TrailPoint& first() const { return at(0); }
const TrailPoint& last() const { return at(_count - 1); }
void clear() { _head = 0; _count = 0; }
void clear() { _head = 0; _count = 0; _pending_seg_break = false; }
// Returns true if the point was stored (passed the min-delta gate).
// First point of the ring and the first point after a stop/start cycle
// get flagged TRAIL_FLAG_SEG_START so the map renderer breaks the line.
bool addPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint16_t min_delta_m) {
if (_count > 0) {
if (_count > 0 && !_pending_seg_break) {
float d = haversineMeters(last().lat_1e6, last().lon_1e6, lat_1e6, lon_1e6);
if (d < (float)min_delta_m) return false;
}
uint8_t flags = (_count == 0 || _pending_seg_break) ? TRAIL_FLAG_SEG_START : 0;
_pending_seg_break = false;
int pos;
if (_count < CAPACITY) {
pos = (_head + _count) % CAPACITY;
@@ -71,34 +86,38 @@ public:
_buf[pos].lat_1e6 = lat_1e6;
_buf[pos].lon_1e6 = lon_1e6;
_buf[pos].ts = ts;
_buf[pos].flags = flags;
return true;
}
// Sum of pairwise Haversine deltas across the whole ring.
// Sum of pairwise Haversine deltas across the whole ring, skipping segment
// boundaries (a SEG_START point isn't reached from its predecessor).
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);
}
return (uint32_t)d;
}
// Seconds between the first and last sample (0 if fewer than 2 points).
uint32_t elapsedSeconds() const {
if (_count < 2) return 0;
return last().ts - first().ts;
// Seconds between the first sample and either the most recent sample (when
// stopped) or the current RTC time passed in by the caller (when active).
// Using `now_ts` while active lets the UI advance the displayed time
// smoothly even when samples land on the floor of the min-delta gate.
uint32_t elapsedSeconds(uint32_t now_ts = 0) const {
if (_count == 0) return 0;
uint32_t start = first().ts;
uint32_t end = (_active && now_ts > start) ? now_ts : last().ts;
return (end > start) ? (end - start) : 0;
}
// Speed in km/h from the last pair of samples; 0 if only one point.
uint16_t currentSpeedKmh() const {
if (_count < 2) return 0;
const auto& a = at(_count - 2);
const auto& b = last();
float dt = (float)(b.ts - a.ts);
if (dt <= 0) return 0;
float d = haversineMeters(a.lat_1e6, a.lon_1e6, b.lat_1e6, b.lon_1e6);
return (uint16_t)(d / dt * 3.6f);
// Average speed in km/h = total distance / elapsed time.
uint16_t avgSpeedKmh(uint32_t now_ts = 0) const {
uint32_t es = elapsedSeconds(now_ts);
if (es == 0) return 0;
return (uint16_t)((float)totalDistanceMeters() / (float)es * 3.6f);
}
// Compute bounding box across all points. Returns false if empty.
@@ -137,4 +156,5 @@ private:
int _head = 0;
int _count = 0;
bool _active = false;
bool _pending_seg_break = false; // next addPoint flags itself SEG_START
};

View File

@@ -81,13 +81,17 @@ private:
break;
}
case 3: {
uint32_t es = _store->elapsedSeconds();
snprintf(buf, n, "Time: %lu:%02lu",
(unsigned long)(es / 3600), (unsigned long)((es % 3600) / 60));
uint32_t es = _store->elapsedSeconds((uint32_t)rtc_clock.getCurrentTime());
// Below 1 h show m:ss so the seconds counter updates visibly each refresh.
if (es < 3600) snprintf(buf, n, "Time: %lu:%02lu",
(unsigned long)(es / 60), (unsigned long)(es % 60));
else snprintf(buf, n, "Time: %lu:%02lu",
(unsigned long)(es / 3600), (unsigned long)((es % 3600) / 60));
break;
}
case 4:
snprintf(buf, n, "Speed: %u km/h", (unsigned)_store->currentSpeedKmh());
snprintf(buf, n, "Avg speed: %u km/h",
(unsigned)_store->avgSpeedKmh((uint32_t)rtc_clock.getCurrentTime()));
break;
default:
buf[0] = '\0';
@@ -181,12 +185,20 @@ private:
py = off_y + (int)dy;
};
// Draw connected segments. A point flagged SEG_START starts a new segment;
// its predecessor is not joined to it. Single-point segments still get a
// visible pixel.
int x0, y0;
project(_store->at(0), x0, y0);
display.fillRect(x0, y0, 1, 1);
for (int i = 1; i < _store->count(); i++) {
int x1, y1;
project(_store->at(i), x1, y1);
drawLine(display, x0, y0, x1, y1);
if (_store->at(i).flags & TRAIL_FLAG_SEG_START) {
display.fillRect(x1, y1, 1, 1); // mark the segment's first point
} else {
drawLine(display, x0, y0, x1, y1);
}
x0 = x1;
y0 = y1;
}