mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
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>
This commit is contained in:
@@ -11,8 +11,41 @@
|
||||
#include "icons.h" // scalable mini-icons (map markers, north arrow, grid dots)
|
||||
#include "NavView.h"
|
||||
#include "WaypointsView.h"
|
||||
#include <helpers/StreamUtils.h>
|
||||
#include <math.h>
|
||||
|
||||
// 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<const char*>(s);
|
||||
return write((const uint8_t*)p, strlen(p));
|
||||
}
|
||||
};
|
||||
|
||||
class TrailScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
TrailStore* _store;
|
||||
@@ -28,6 +61,7 @@ class TrailScreen : public UIScreen {
|
||||
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.
|
||||
@@ -38,10 +72,13 @@ class TrailScreen : public UIScreen {
|
||||
// (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_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS };
|
||||
// The action popup is two-level: a short main menu, plus "Trail file…" and
|
||||
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS,
|
||||
ACT_SHARE_NOW };
|
||||
// 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 };
|
||||
PopupMenu _action_menu;
|
||||
uint8_t _menu_level = ML_MAIN;
|
||||
@@ -50,6 +87,7 @@ class TrailScreen : public UIScreen {
|
||||
char _act_min_dist_label[24];
|
||||
char _act_units_label[24];
|
||||
char _act_grid_label[16];
|
||||
char _act_autopause_label[24];
|
||||
char _act_toggle_label[20];
|
||||
|
||||
static const int SUMMARY_ITEM_COUNT = 5;
|
||||
@@ -65,9 +103,14 @@ public:
|
||||
_cfg_dirty = false;
|
||||
_action_menu.active = false;
|
||||
_menu_level = ML_MAIN;
|
||||
_return_home = false;
|
||||
_wp.reset();
|
||||
}
|
||||
|
||||
// Enter straight into the Map view (used by the Home "Map" page). Remembers
|
||||
// that we came from Home so KEY_CANCEL returns there, not to Tools.
|
||||
void enterMap() { enter(); _view = V_MAP; _return_home = true; }
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -101,11 +144,10 @@ public:
|
||||
// LEFT/RIGHT cycles the focused value — only meaningful in the Settings
|
||||
// submenu; the popup stays open so the user can keep tapping.
|
||||
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) {
|
||||
if (_menu_level == ML_SETTINGS) {
|
||||
int idx = _action_menu.selectedIndex();
|
||||
if (idx >= 0 && idx < _act_count)
|
||||
cycleSetting((ActionId)_act_map[idx], (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1);
|
||||
}
|
||||
int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 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);
|
||||
@@ -113,12 +155,14 @@ public:
|
||||
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;
|
||||
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: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
|
||||
case ACT_GRID:
|
||||
case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
|
||||
case ACT_SHARE_NOW: shareMyLocationNow(); break;
|
||||
case ACT_TOGGLE: handleToggle(); break;
|
||||
case ACT_MARK: _wp.markHere(); break;
|
||||
case ACT_WAYPOINTS: _wp.openList(); break;
|
||||
@@ -138,7 +182,8 @@ public:
|
||||
|
||||
if (c == KEY_CANCEL) {
|
||||
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
|
||||
_task->gotoToolsScreen();
|
||||
if (_return_home) _task->gotoHomeScreen();
|
||||
else _task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
|
||||
@@ -207,7 +252,8 @@ private:
|
||||
}
|
||||
void handleExport() {
|
||||
if (!Serial) { _task->showAlert("Connect USB first", 1200); return; }
|
||||
size_t n = _store->exportGpx(Serial, _task->waypoints());
|
||||
BoundedSerialPrint out{300};
|
||||
size_t n = _store->exportGpx(out, _task->waypoints());
|
||||
showExportAlert(n);
|
||||
}
|
||||
|
||||
@@ -217,7 +263,8 @@ private:
|
||||
if (!ds) { _task->showAlert("FS unavailable", 800); return; }
|
||||
File f = ds->openRead(TRAIL_FILE);
|
||||
if (!f) { _task->showAlert("No saved trail", 800); return; }
|
||||
size_t n = TrailStore::exportGpxFromFile(f, Serial, _task->waypoints());
|
||||
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);
|
||||
@@ -249,6 +296,8 @@ private:
|
||||
"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_toggle_label, sizeof(_act_toggle_label),
|
||||
"%s tracking", _store->isActive() ? "Stop" : "Start");
|
||||
}
|
||||
@@ -276,6 +325,7 @@ private:
|
||||
// "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...");
|
||||
pushAction(ACT_SHARE_NOW, "Share my pos");
|
||||
if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file...");
|
||||
pushAction(ACT_SETTINGS, "Settings...");
|
||||
}
|
||||
@@ -299,8 +349,9 @@ private:
|
||||
refreshActionLabels();
|
||||
_menu_level = ML_SETTINGS;
|
||||
_act_count = 0;
|
||||
_action_menu.begin("Settings", 3);
|
||||
pushAction(ACT_MIN_DIST, _act_min_dist_label);
|
||||
_action_menu.begin("Settings", 4);
|
||||
pushAction(ACT_MIN_DIST, _act_min_dist_label);
|
||||
pushAction(ACT_AUTOPAUSE, _act_autopause_label);
|
||||
if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label);
|
||||
if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label);
|
||||
}
|
||||
@@ -311,6 +362,7 @@ private:
|
||||
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; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -346,6 +398,24 @@ private:
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -376,6 +446,7 @@ private:
|
||||
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);
|
||||
@@ -523,6 +594,15 @@ private:
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -534,6 +614,25 @@ private:
|
||||
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);
|
||||
@@ -541,8 +640,9 @@ private:
|
||||
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 };
|
||||
drawWaypointLabel(display, s, wx, wy, area_x, area_y, area_w, area_h);
|
||||
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);
|
||||
@@ -550,6 +650,16 @@ private:
|
||||
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) {
|
||||
@@ -564,8 +674,11 @@ private:
|
||||
// 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) {
|
||||
if (!have_trail && nwp == 0 && !have_gps && !have_live && !have_tgt) {
|
||||
display.drawTextCentered(display.width() / 2, (top + bottom) / 2, "No GPS / no trail");
|
||||
return;
|
||||
}
|
||||
@@ -595,6 +708,7 @@ private:
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -626,19 +740,9 @@ private:
|
||||
int x0, y0;
|
||||
proj.project(_store->at(0).lat_1e6, _store->at(0).lon_1e6, x0, y0);
|
||||
miniIconDrawCentered(display, x0, y0, ICON_MAP_DOT);
|
||||
for (int i = 1; i < _store->count(); i++) {
|
||||
const TrailPoint& pt = _store->at(i);
|
||||
int x1, y1;
|
||||
proj.project(pt.lat_1e6, pt.lon_1e6, x1, y1);
|
||||
if (pt.flags & TRAIL_FLAG_SEG_START) {
|
||||
drawFilledDot(display, x0, y0);
|
||||
drawOpenDot(display, x1, y1);
|
||||
} else {
|
||||
gfx::drawLine(display, x0, y0, x1, y1);
|
||||
}
|
||||
x0 = x1;
|
||||
y0 = y1;
|
||||
}
|
||||
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);
|
||||
@@ -787,6 +891,38 @@ private:
|
||||
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);
|
||||
}
|
||||
@@ -804,4 +940,13 @@ private:
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user