Files
MeshCore-Solo/examples/companion_radio/Trail.h
MarekZegare4 57774d41f3 feat(companion): live location sharing, Locator geofencing, trail auto-pause
Squash merge of feat/location-beacon-alerts-autopause (v1.21).

Features:
- Live Location Sharing — broadcast position as movement-gated [LOC]
  messages to a channel or contact; live shares show as map pins with
  distance/bearing in Nearby Nodes and a status-bar indicator. [LOC] is
  parsed in DMs, channel messages and room messages; DM shares name the
  sender.
- Locator (geofence) — arm a geofence around a saved waypoint or a person
  (live [LOC] or last-known position), alert on arrive/leave or near/far,
  with an optional homing beeper (gated to arrive/both modes). Arm from
  Tools > Locator, Nearby Nodes, or Waypoints; target picker lists
  favourites first, clearable via a "None" entry. Active target is drawn
  as a flag on the map.
- One active target shared across Locator / Navigate / Map via a single
  resolver (resolvePersonPos / activeTargetPos) that prefers a live
  [LOC] share over the last-advertised GPS fix.
- Follow live contacts — Navigate to a live-sharing contact follows them
  as they move and adds an ETA line; quick-share your own position from
  the Map.
- Map & status-bar upgrades — home mini-map gets a north marker, scale
  tick, and a connected trail line (was disconnected dots); status line
  shows tracked-node count, an arrow + distance to the active Locator/Nav
  target (falling back to the nearest live-tracked contact); GPS fix icon
  in the top status bar, shown only on GPS boards with GPS enabled.
- Trail auto-pause — recording freezes on stops (banking elapsed time,
  breaking the map line across the idle gap) and resumes on movement
  without ending the session.
- Streaming trail simplification — GPS points are simplified in-stream
  via a fixed-corridor (Reumann-Witkam) pass tuned for fidelity: straight
  runs collapse to their endpoints, curves stay bounded to the Min-dist
  tolerance, so the 512-point buffer covers a far longer route than a
  flat point budget would suggest.
- Collapsible Tools (Location / Comms / System sections, fold-in-place
  like Settings) and page-indicator icons on the home carousel.
- Waypoint coordinate editor — add a waypoint by scroll-editing lat/lon
  digit by digit.

Fixes:
- Critical: low-heap hang and contact loss on RAM-tight builds. Halved
  message-history scrollback rings (recovering ~14 KB free heap) and
  made contacts/channels/prefs persistence atomic (temp file + rename),
  so an interrupted save can no longer corrupt or wipe the store.
- Serial.write() bounded so a stalled USB host can't hang the device.
- Nearby Nodes: live [LOC] senders respect the type filter, sort by
  shared position, and the list refreshes so live shares bubble up.
- Map: live contacts are labelled before waypoints.
- GPS status icon hidden when GPS is off in Settings.
- Splash screen no longer truncates a pre-release tag's own dash (e.g.
  v1.21-rc1) when stripping the build's commit-hash suffix.
- Null-guarded the Locator target picker; clamped loc-share channel
  index on load.

Under the hood:
- -Os size optimisation on the e-ink and GAT562 30S solo envs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:04:21 +02:00

512 lines
21 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#pragma once
#include <Arduino.h>
#include <math.h>
#include <stdint.h>
#include <time.h>
#include "Persist.h"
#include "GeoUtils.h"
// RAM-only GPS trail ring buffer.
// Storage cost: CAPACITY(512) × sizeof(TrailPoint)(16 B, padded) = 8 KB,
// always resident in .bss (UITask::_trail, and ui_task is a global object —
// see main.cpp). Because the nRF52 heap region starts just above .bss, every
// byte added here is a byte taken from the heap: at 1024 points (16 KB) free
// heap fell to ~4 KB and the input/menu path started failing its allocations
// while the periodic redraw kept running. Keep this conservative. The trail
// survives auto-off (only
// the display blanks) but is lost on reboot — user explicitly snapshots to a
// LittleFS slot before powering down to keep it.
//
// Samples are simplified in-stream as they arrive (see addPoint()): a straight
// stretch is stored as just its two endpoints, no matter how long, while a
// turn still gets a vertex roughly every min-delta of deviation — so CAPACITY
// covers a much longer route than CAPACITY × min-delta would suggest.
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 = 512;
// _count is serialised as uint16_t in the save header — fail the build loudly
// if CAPACITY is ever grown past what that can hold, rather than truncating.
static_assert(CAPACITY <= 0xFFFF, "TrailStore::CAPACITY must fit in the uint16_t save-header count");
// Fixed sampling cadence — matches the sensor manager's default GPS update
// rate (1 s). Density is controlled by the min-delta gate (settings) rather
// than by throttling the GPS poll. The NodePrefs::trail_interval_idx field
// is retained as reserved for backwards compatibility but no longer used.
static const uint16_t SAMPLING_SECS = 1;
// Min-delta (metres) gates samples too close to the previous one.
// Default 5 m: keeps walking jitter out, dense enough for a visible trail.
// The index is a unit-agnostic "level" (0=finest … 3=coarsest); the actual
// gate distance and its label follow the global metric/imperial preference.
static const uint8_t MIN_DELTA_COUNT = 4;
static uint16_t minDeltaMeters(uint8_t idx, bool imperial) {
static const uint16_t MET[MIN_DELTA_COUNT] = { 5, 10, 25, 100 };
static const uint16_t IMP[MIN_DELTA_COUNT] = { 5, 9, 23, 91 }; // ≈ 15/30/75/300 ft
if (idx >= MIN_DELTA_COUNT) idx = 0;
return imperial ? IMP[idx] : MET[idx];
}
static const char* minDeltaLabel(uint8_t idx, bool imperial) {
static const char* MET[MIN_DELTA_COUNT] = { "5 m", "10 m", "25 m", "100 m" };
static const char* IMP[MIN_DELTA_COUNT] = { "15 ft", "30 ft", "75 ft", "300 ft" };
if (idx >= MIN_DELTA_COUNT) idx = 0;
return imperial ? IMP[idx] : MET[idx];
}
// Speed / pace display units. UNITS_KMH / UNITS_MPH show speed; UNITS_PACE_KM
// / UNITS_PACE_MI show time per distance ("pace"). Index 0 = km/h default.
enum Units : uint8_t {
UNITS_KMH = 0,
UNITS_MPH = 1,
UNITS_PACE_KM = 2,
UNITS_PACE_MI = 3,
};
static const uint8_t UNITS_COUNT = 4;
static const char* unitLabel(uint8_t idx) {
static const char* L[UNITS_COUNT] = { "km/h", "mph", "min/km", "min/mi" };
return L[idx < UNITS_COUNT ? idx : 0];
}
static bool unitIsPace(uint8_t idx) { return idx == UNITS_PACE_KM || idx == UNITS_PACE_MI; }
bool isActive() const { return _active; }
void setActive(bool a) {
if (a && !_active) {
// off → on: start a new session timer.
_session_start_ms = millis();
_paused = false;
} else if (_active && !a) {
// on → off: bank the elapsed of this session and arm a segment break
// so the renderer doesn't draw a straight line through the dead time.
if (_session_start_ms != 0) {
_accumulated_ms += millis() - _session_start_ms;
_session_start_ms = 0;
}
flushPending();
_pending_seg_break = true;
_paused = false;
}
_active = a;
}
// Auto-pause: freeze the active trail without ending the session. Banks the
// running session time (so elapsedSeconds() stops advancing) and arms a
// segment break so the map doesn't draw a line across the idle gap; resuming
// restarts the session timer. Distinct from setActive() — the trail stays
// "on" (UI shows "paused", home-screen blink keeps going). No-op if inactive.
bool isPaused() const { return _paused; }
void setPaused(bool p) {
if (!_active || p == _paused) return;
if (p) {
if (_session_start_ms != 0) {
_accumulated_ms += millis() - _session_start_ms;
_session_start_ms = 0;
}
flushPending();
_pending_seg_break = true;
} else {
_session_start_ms = millis(); // resume timing from now
}
_paused = p;
}
int count() const { return _count; }
bool empty() const { return _count == 0; }
// i = 0 → oldest entry, i = count()-1 → newest.
const TrailPoint& at(int i) const { return _buf[(_head + i) % CAPACITY]; }
const TrailPoint& first() const { return at(0); }
const TrailPoint& last() const { return at(_count - 1); }
void clear() {
_head = 0; _count = 0;
_pending_seg_break = false;
_accumulated_ms = 0;
_session_start_ms = 0;
_paused = false;
_has_pending = false;
}
// 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
// the first point after a stop/start cycle get flagged TRAIL_FLAG_SEG_START
// so the map renderer breaks the line.
//
// Straight-run simplification (a fixed-corridor / ReumannWitkam pass): a
// sample is only ever a *candidate* vertex (_pending) until a later one
// proves the run has bent. The corridor is the straight line anchored at the
// last committed vertex and aimed at the first sample of this run (_dir);
// each new sample is tested against *that fixed line*. While it stays within
// CORRIDOR_FACTOR x min_delta_m of the corridor the run is still "straight
// enough", so drop the intermediate and extend; once a sample leaves the
// corridor the previous in-corridor sample (_pending) was the last good
// vertex, so commit it and open a fresh corridor from there.
//
// The corridor is deliberately tighter than the min-delta gate itself
// (CORRIDOR_FACTOR < 1): the gate is the noise floor for *rejecting* samples
// outright, while the corridor decides when a run has bent enough to need a
// new vertex. Field data showed plenty of headroom (4 km at a 10 m gate cost
// just 38 of the 512-point capacity), so a tighter corridor trades some of
// that headroom for a polyline that hugs the real track more closely.
//
// The fixed direction is the whole point: testing the newest sample against
// a line that re-aims at the newest sample (or testing the previous
// candidate, which always sits right beside that moving endpoint where the
// cross-track is near zero) lets a long gentle curve slip through one
// sub-min_delta step at a time and collapse to a single chord that deviates
// arbitrarily far. Anchoring the direction bounds the stored polyline to
// ~CORRIDOR_FACTOR x min_delta_m of the real track, while a straight road
// still costs just its two endpoints no matter how long it is.
// How much tighter the simplification corridor is than the min-delta gate
// (see addPoint() above). 1.0 would track the gate exactly; lowering this
// commits more vertices per route (more fidelity, less reduction).
static constexpr float CORRIDOR_FACTOR = 0.5f;
bool addPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint16_t min_delta_m) {
const TrailPoint* ref = _has_pending ? &_pending : (_count > 0 ? &last() : nullptr);
if (ref && !_pending_seg_break) {
float d = haversineMeters(ref->lat_1e6, ref->lon_1e6, lat_1e6, lon_1e6);
if (d < (float)min_delta_m) return false;
}
if (_count == 0 || _pending_seg_break) {
flushPending(); // shouldn't normally have one here, but never lose real distance
commitPoint(lat_1e6, lon_1e6, ts, TRAIL_FLAG_SEG_START);
_pending_seg_break = false;
return true;
}
TrailPoint sample{ lat_1e6, lon_1e6, ts, 0 };
if (!_has_pending) {
_pending = sample; // last in-corridor sample (the commit candidate)
_dir = sample; // fixes the corridor direction: last() → _dir
_has_pending = true;
return true;
}
if (crossTrackMeters(last(), _dir, sample) <= (float)min_delta_m * CORRIDOR_FACTOR) {
_pending = sample; // within corridor — extend
} else {
commitPoint(_pending.lat_1e6, _pending.lon_1e6, _pending.ts, 0); // left corridor — keep last good
_pending = sample; // the exiting sample opens the next run...
_dir = sample; // ...and fixes its corridor direction from the just-committed vertex
}
return true;
}
// Sum of pairwise Haversine deltas across the whole ring, skipping segment
// boundaries (a SEG_START point isn't reached from its predecessor). The
// 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()).
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);
}
if (_has_pending && _count > 0)
d += haversineMeters(last().lat_1e6, last().lon_1e6,
_pending.lat_1e6, _pending.lon_1e6);
return (uint32_t)d;
}
// Cumulative active tracking time across all start→stop sessions, in
// seconds. Counts ticks while the trail is on, freezes while it's off.
// millis()-based so it doesn't depend on RTC sync.
uint32_t elapsedSeconds() const {
uint32_t ms = _accumulated_ms;
if (_active && _session_start_ms != 0) ms += millis() - _session_start_ms;
return ms / 1000;
}
// Average speed in km/h = total distance / cumulative active time.
uint16_t avgSpeedKmh() const {
uint32_t es = elapsedSeconds();
if (es == 0) return 0;
return (uint16_t)((float)totalDistanceMeters() / (float)es * 3.6f);
}
// Compute bounding box across all points. Returns false if empty.
bool boundingBox(int32_t& min_lat, int32_t& min_lon,
int32_t& max_lat, int32_t& max_lon) const {
if (_count == 0) return false;
min_lat = max_lat = first().lat_1e6;
min_lon = max_lon = first().lon_1e6;
for (int i = 1; i < _count; i++) {
const auto& p = at(i);
if (p.lat_1e6 < min_lat) min_lat = p.lat_1e6;
if (p.lat_1e6 > max_lat) max_lat = p.lat_1e6;
if (p.lon_1e6 < min_lon) min_lon = p.lon_1e6;
if (p.lon_1e6 > max_lon) max_lon = p.lon_1e6;
}
return true;
}
// Persistent snapshot — single slot at the given filesystem path.
// Layout: 4-byte magic "TRAL", uint8 version, uint8 reserved, uint16 count,
// uint32 accumulated_ms, then `count` raw TrailPoint records. count is
// clamped to CAPACITY on load.
static const uint32_t SAVE_MAGIC = 0x4C415254; // "TRAL"
static const uint8_t SAVE_VERSION = 1;
// Caller supplies an opened, writable File (the FS-open call is
// platform-specific). Returns true if the header and every point wrote
// cleanly. The file is left open for the caller to close.
template <typename F>
bool writeTo(F& file) {
flushPending(); // the snapshot should include the latest position, not lag behind it
if (!persist::writeHeader(file, SAVE_MAGIC, SAVE_VERSION, (uint16_t)_count)) return false;
uint32_t accum = currentAccumulatedMs();
if (file.write((uint8_t*)&accum, sizeof(accum)) != sizeof(accum)) return false;
for (int i = 0; i < _count; i++) {
if (file.write((uint8_t*)&at(i), sizeof(TrailPoint)) != sizeof(TrailPoint)) return false;
}
return true;
}
template <typename F>
bool readFrom(F& file) {
uint16_t cnt = 0;
uint32_t accum = 0;
if (!persist::readHeader(file, SAVE_MAGIC, SAVE_VERSION, cnt)) return false;
if (file.read((uint8_t*)&accum, sizeof(accum)) != (int)sizeof(accum)) return false;
if (cnt > CAPACITY) return false;
if (_active) {
_active = false;
_session_start_ms = 0;
}
_paused = false;
_has_pending = false; // any candidate belonged to the session being replaced
_head = 0;
_count = 0;
for (int i = 0; i < cnt; i++) {
TrailPoint p;
int n = file.read((uint8_t*)&p, sizeof(TrailPoint));
if (n != (int)sizeof(TrailPoint)) break;
_buf[_count++] = p;
}
_accumulated_ms = accum;
_pending_seg_break = true;
return true;
}
// GPX writers — shared helpers so we can dump from RAM and from flash
// through the same formatting code.
template <typename S>
static size_t gpxHeader(S& out) {
size_t n = 0;
n += out.print(F("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
n += out.print(F("<gpx version=\"1.1\" creator=\"MeshCore\" "
"xmlns=\"http://www.topografix.com/GPX/1/1\">\n"));
return n;
}
template <typename S>
static size_t gpxTrackOpen(S& out, const char* name) {
size_t n = 0;
n += out.print(F("<trk><name>"));
n += out.print(name);
n += out.print(F("</name>\n"));
return n;
}
// Emit saved waypoints as <wpt> elements. In GPX 1.1 these must precede the
// <trk>. Duck-typed over any store exposing count()/at(i) whose entries have
// lat_1e6 / lon_1e6 / ts / label, so Trail.h stays decoupled from Waypoint.h.
template <typename S, typename WP>
static size_t gpxWaypoints(S& out, WP& store) {
size_t n = 0;
for (int i = 0; i < store.count(); i++) {
const auto& w = store.at(i);
// XML-escape the user label (&, <, > only).
char esc[64]; int e = 0;
for (const char* p = w.label; *p && e < (int)sizeof(esc) - 6; p++) {
if (*p == '&') { memcpy(esc + e, "&amp;", 5); e += 5; }
else if (*p == '<') { memcpy(esc + e, "&lt;", 4); e += 4; }
else if (*p == '>') { memcpy(esc + e, "&gt;", 4); e += 4; }
else esc[e++] = *p;
}
esc[e] = '\0';
char buf[160];
int len = snprintf(buf, sizeof(buf),
"<wpt lat=\"%.6f\" lon=\"%.6f\"><name>%s</name>",
w.lat_1e6 / 1.0e6, w.lon_1e6 / 1.0e6, esc);
if (len > 0) {
if ((size_t)len >= sizeof(buf)) len = sizeof(buf) - 1; // truncated: emit chars only, not the NUL
n += out.write((const uint8_t*)buf, (size_t)len);
}
if (w.ts > 1000000000UL) { // append <time> when the RTC was set
time_t t = (time_t)w.ts;
struct tm* gt = ::gmtime(&t);
if (gt) {
len = snprintf(buf, sizeof(buf),
"<time>%04d-%02d-%02dT%02d:%02d:%02dZ</time>",
gt->tm_year + 1900, gt->tm_mon + 1, gt->tm_mday,
gt->tm_hour, gt->tm_min, gt->tm_sec);
if (len > 0) { if ((size_t)len >= sizeof(buf)) len = sizeof(buf) - 1;
n += out.write((const uint8_t*)buf, (size_t)len); }
}
}
n += out.print(F("</wpt>\n"));
}
return n;
}
template <typename S>
static size_t gpxFooter(S& out, bool in_segment) {
size_t n = 0;
if (in_segment) n += out.print(F("</trkseg>\n"));
n += out.print(F("</trk></gpx>\n"));
return n;
}
// Emit a single <trkpt>; opens a <trkseg> on a segment boundary. Updates
// `in_segment` to track open/close pairing.
template <typename S>
static size_t gpxPoint(S& out, const TrailPoint& p, bool first, bool& in_segment) {
size_t n = 0;
bool seg_start = first || (p.flags & TRAIL_FLAG_SEG_START);
if (seg_start) {
if (in_segment) n += out.print(F("</trkseg>\n"));
n += out.print(F("<trkseg>\n"));
in_segment = true;
}
char buf[120];
time_t t = (time_t)p.ts;
struct tm* gt = ::gmtime(&t);
if (!gt) return n; // defensive: skip malformed timestamps
int len = snprintf(buf, sizeof(buf),
"<trkpt lat=\"%.6f\" lon=\"%.6f\"><time>%04d-%02d-%02dT%02d:%02d:%02dZ</time></trkpt>\n",
p.lat_1e6 / 1.0e6, p.lon_1e6 / 1.0e6,
gt->tm_year + 1900, gt->tm_mon + 1, gt->tm_mday,
gt->tm_hour, gt->tm_min, gt->tm_sec);
if (len < 0) return n;
if ((size_t)len >= sizeof(buf)) len = sizeof(buf) - 1; // truncated: emit chars only, not the NUL
n += out.write((const uint8_t*)buf, (size_t)len);
return n;
}
// Dump the live RAM ring as GPX (with saved waypoints). Returns bytes written.
template <typename S, typename WP>
size_t exportGpx(S& out, WP& wpts, const char* trk_name = "MeshCore Trail") {
size_t total = gpxHeader(out);
total += gpxWaypoints(out, wpts);
total += gpxTrackOpen(out, trk_name);
bool in_segment = false;
for (int i = 0; i < _count; i++) {
total += gpxPoint(out, at(i), i == 0, in_segment);
}
total += gpxFooter(out, in_segment);
return total;
}
// Stream a saved trail straight from the open file as GPX without
// touching the live RAM ring. Returns 0 on format mismatch.
template <typename F, typename S, typename WP>
static size_t exportGpxFromFile(F& file, S& out, WP& wpts, const char* trk_name = "MeshCore Trail") {
uint16_t cnt = 0;
uint32_t accum = 0;
if (!persist::readHeader(file, SAVE_MAGIC, SAVE_VERSION, cnt)) return 0;
if (file.read((uint8_t*)&accum, sizeof(accum)) != (int)sizeof(accum)) return 0;
if (cnt > CAPACITY) return 0;
size_t total = gpxHeader(out);
total += gpxWaypoints(out, wpts);
total += gpxTrackOpen(out, trk_name);
bool in_segment = false;
for (uint16_t i = 0; i < cnt; i++) {
TrailPoint p;
int n = file.read((uint8_t*)&p, sizeof(TrailPoint));
if (n != (int)sizeof(TrailPoint)) break;
total += gpxPoint(out, p, i == 0, in_segment);
}
total += gpxFooter(out, in_segment);
return total;
}
uint32_t currentAccumulatedMs() const {
uint32_t ms = _accumulated_ms;
if (_active && _session_start_ms != 0) ms += millis() - _session_start_ms;
return ms;
}
// Approximate great-circle distance in metres. Delegates to the shared
// geo:: Haversine (km) so there is a single implementation.
static float haversineMeters(int32_t la1, int32_t lo1, int32_t la2, int32_t lo2) {
return geo::haversineKm(la1, lo1, la2, lo2) * 1000.0f;
}
private:
TrailPoint _buf[CAPACITY];
int _head = 0;
int _count = 0;
bool _active = false;
bool _paused = false; // auto-paused (active but timer/sampling frozen)
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
// 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
// first sample of the current run; last()→_dir fixes the corridor direction
// every later sample of the run is tested against.
bool _has_pending = false;
TrailPoint _pending;
TrailPoint _dir;
void commitPoint(int32_t lat_1e6, int32_t lon_1e6, uint32_t ts, uint8_t flags) {
int pos;
if (_count < CAPACITY) {
pos = (_head + _count) % CAPACITY;
_count++;
} else {
pos = _head;
_head = (_head + 1) % CAPACITY;
}
_buf[pos].lat_1e6 = lat_1e6;
_buf[pos].lon_1e6 = lon_1e6;
_buf[pos].ts = ts;
_buf[pos].flags = flags;
}
// Commit the pending candidate (if any) as a real vertex. Called before a
// segment break (stop/pause/save) so the last stretch of a straight run is
// never silently dropped just because no bend came along to force a commit.
void flushPending() {
if (!_has_pending) return;
commitPoint(_pending.lat_1e6, _pending.lon_1e6, _pending.ts, 0);
_has_pending = false;
}
// Perpendicular ("cross-track") distance from q to the line through a→b, in
// metres. Planar approximation (equirectangular, centred at a) — accurate
// enough at trail scale, where consecutive points are metres to a few km
// apart. Falls back to a straight distance to a if a and b coincide.
static float crossTrackMeters(const TrailPoint& a, const TrailPoint& b, const TrailPoint& q) {
static const float M_PER_DEG = 111320.0f; // metres per degree of latitude
float lat_rad = (a.lat_1e6 * 1e-6f) * ((float)M_PI / 180.0f);
float lon_scale = cosf(lat_rad);
float bx = (b.lon_1e6 - a.lon_1e6) * 1e-6f * M_PER_DEG * lon_scale;
float by = (b.lat_1e6 - a.lat_1e6) * 1e-6f * M_PER_DEG;
float ab_len = sqrtf(bx * bx + by * by);
if (ab_len < 0.01f) return haversineMeters(a.lat_1e6, a.lon_1e6, q.lat_1e6, q.lon_1e6);
float qx = (q.lon_1e6 - a.lon_1e6) * 1e-6f * M_PER_DEG * lon_scale;
float qy = (q.lat_1e6 - a.lat_1e6) * 1e-6f * M_PER_DEG;
float cross = bx * qy - by * qx;
return fabsf(cross) / ab_len;
}
};