Files
MeshCore-Solo/examples/companion_radio/ui-new/TrailScreen.h
MarekZegare4 b8a7b5dcc0 refactor: dedup persistence header, harden action menu, tidy small items
- Persist.h: shared writeHeader()/readHeader() for the magic+version+count
  on-disk header; Waypoint and Trail snapshots now share one implementation
  (byte layout unchanged; each keeps its own count clamp/reject + extra fields).
- TrailScreen: pushAction() guards _act_map / PopupMenu capacity, so adding a
  new action can't silently overrun the fixed array.
- PopupMenu: add count()/setSelected() accessors; replace the remaining direct
  _sel/_count field pokes in NearbyScreen, TrailScreen and QuickMsgScreen.
- Trail summary: unit-suffixed time ("1h 05m" / "5m 03s") so hours can't be
  misread as minutes.
- Trail::haversineMeters delegates to geo::haversineKm — single Haversine
  implementation (also trims a little flash).

Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo builds compile clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 13:37:03 +02:00

1066 lines
45 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
// GPS trail viewer. Tools Trail.
// Three live views — Summary, Map, List — cyclable with LEFT/RIGHT.
// 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.
#include "../Trail.h"
#include "../GeoUtils.h"
#include "GfxUtils.h"
#include "NavView.h"
#include <math.h>
class TrailScreen : public UIScreen {
UITask* _task;
TrailStore* _store;
enum View { V_SUMMARY = 0, V_MAP = 1, V_LIST = 2, V_COUNT };
uint8_t _view = V_SUMMARY;
int _summary_scroll = 0;
int _list_scroll = 0;
bool _cfg_dirty = false;
bool _map_grid = true; // show scale grid in map view (Enter to toggle)
// Waypoint sub-screens layered over the trail views. WP_OFF = normal trail.
enum WpMode { WP_OFF, WP_LIST, WP_NAV, WP_ADD };
uint8_t _wp_mode = WP_OFF;
int _wp_sel = 0;
int _wp_scroll = 0;
PopupMenu _wp_ctx; // Rename / Delete on a selected waypoint
KeyboardWidget _wp_kb; // label entry (mark new / rename)
bool _kb_active = false;
int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index
int32_t _mark_lat = 0, _mark_lon = 0; // pending new-mark position
uint32_t _mark_ts = 0;
// WP_ADD form — type a waypoint by coordinates. The keyboard has no comma or
// minus, so lat/lon are entered as magnitude + a hemisphere toggle (LEFT/RIGHT).
char _add_lat[12] = "", _add_lon[12] = ""; // magnitude strings
char _add_label[WAYPOINT_LABEL_LEN] = "";
bool _add_lat_neg = false; // true = S
bool _add_lon_neg = false; // true = W
int _add_sel = 0; // 0=Lat 1=Lon 2=Label 3=Save
int _kb_field = -1; // which WP_ADD field the keyboard edits (-1 = label/rename)
// Action popup (Hold Enter / KEY_CONTEXT_MENU). Carries both settings rows
// (Min dist, Units — cycled with LEFT/RIGHT while focused) and actions
// (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_WP_CLEAR };
PopupMenu _action_menu;
uint8_t _act_map[16]; // 12 used today; pad so adding an action doesn't OOB
uint8_t _act_count = 0;
char _act_min_dist_label[24];
char _act_units_label[24];
char _act_grid_label[16];
char _act_toggle_label[20];
static const int SUMMARY_ITEM_COUNT = 5;
public:
TrailScreen(UITask* task, TrailStore* store) : _task(task), _store(store) {}
void enter() {
_view = V_SUMMARY;
_summary_scroll = 0;
_list_scroll = 0;
_cfg_dirty = false;
_action_menu.active = false;
_wp_mode = WP_OFF;
_wp_ctx.active = false;
_kb_active = false;
_kb_field = -1;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
// Label keyboard (mark / rename) owns the whole screen while open.
if (_kb_active) return _wp_kb.render(display);
// Waypoint sub-screens replace the trail views.
if (_wp_mode == WP_ADD) { renderAddForm(display); return 1000; }
if (_wp_mode == WP_NAV) { renderWpNav(display); return 1000; }
if (_wp_mode == WP_LIST) {
renderWpList(display);
if (_wp_ctx.active) _wp_ctx.render(display);
return 1000;
}
// Title carries the view counter so the bottom hint row can be reclaimed
// for content.
const char* base = (_view == V_MAP) ? (_map_grid ? "TRAIL MAP+" : "TRAIL MAP")
: (_view == V_LIST) ? "TRAIL LIST"
: "TRAIL";
char title[20];
snprintf(title, sizeof(title), "%s %d/%d", base, (int)_view + 1, (int)V_COUNT);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
if (_view == V_MAP) renderMap(display);
else if (_view == V_LIST) renderList(display);
else renderSummary(display);
if (_action_menu.active) _action_menu.render(display);
return _store->isActive() ? 1000 : 5000;
}
bool handleInput(char c) override {
// Label keyboard (mark new / rename) takes all input first.
if (_kb_active) {
auto r = _wp_kb.handleInput(c);
if (r == KeyboardWidget::DONE) {
if (_kb_field >= 0) applyAddField(_wp_kb.buf); // WP_ADD field edit
else commitLabel(_wp_kb.buf); // mark / rename label
_kb_active = false; _kb_field = -1;
} else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false; _kb_field = -1;
}
return true;
}
// 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 (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 if (sel == 1) { // Delete
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;
}
} else { // Send (share in a message)
if (wi >= 0 && wi < _task->waypoints().count()) {
const Waypoint& w = _task->waypoints().at(wi);
double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0;
char text[80];
if (w.label[0]) snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f %s", lat, lon, w.label);
else snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon);
_task->shareToMessage(text); // hands off to the Messages screen
}
}
}
return true;
}
// WP_ADD form: type lat/lon (magnitude) + hemisphere toggle + label.
if (_wp_mode == WP_ADD) {
if (c == KEY_CANCEL) { _wp_mode = WP_OFF; return true; }
if (c == KEY_UP && _add_sel > 0) { _add_sel--; return true; }
if (c == KEY_DOWN && _add_sel < 3) { _add_sel++; return true; }
if (c == KEY_LEFT || c == KEY_RIGHT) {
if (_add_sel == 0) _add_lat_neg = !_add_lat_neg; // N <-> S
else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W
return true;
}
if (c == KEY_ENTER) {
if (_add_sel == 0) { _kb_field = 0; _wp_kb.begin(_add_lat, 11); _kb_active = true; }
else if (_add_sel == 1) { _kb_field = 1; _wp_kb.begin(_add_lon, 11); _kb_active = true; }
else if (_add_sel == 2) { _kb_field = 2; _wp_kb.begin(_add_label, WAYPOINT_LABEL_LEN - 1); _kb_active = true; }
else { commitAddForm(); }
return true;
}
return true;
}
// Waypoint navigation view — any nav key returns to the list.
if (_wp_mode == WP_NAV) {
if (c == KEY_CANCEL || c == KEY_LEFT || c == KEY_PREV ||
c == KEY_RIGHT || c == KEY_NEXT) { _wp_mode = WP_LIST; }
return true;
}
// Waypoint list (row 0 is the synthetic "Trail start" when a trail exists;
// the final row is "+ Add by coords").
if (_wp_mode == WP_LIST) {
int n = wpListCount();
int total = n + 1; // + the "Add by coords" row
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 < total - 1) { _wp_sel++; return true; }
if (c == KEY_ENTER) {
if (_wp_sel == n) handleAddByCoords(); // last row → open the add form
else _wp_mode = WP_NAV; // a waypoint / Trail-start row
return true;
}
// Rename/Delete/Send apply to saved waypoints only — not Trail-start or Add.
if (c == KEY_CONTEXT_MENU && !selIsStart() && wpIndex() < _task->waypoints().count()) {
_wp_ctx.begin("Waypoint", 3);
_wp_ctx.addItem("Rename");
_wp_ctx.addItem("Delete");
_wp_ctx.addItem("Send");
return true;
}
return true;
}
// Action popup overrides everything else.
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; }
if (_act_map[idx] == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; }
}
return true; // swallow on action rows
}
auto res = _action_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _action_menu.selectedIndex();
if (sel >= 0 && sel < _act_count) {
ActionId act = (ActionId)_act_map[sel];
if (act == ACT_TOGGLE) { handleToggle(); }
else if (act == ACT_SAVE) { handleSave(); }
else if (act == ACT_LOAD) { handleLoad(); }
else if (act == ACT_RESET) { handleReset(); }
else if (act == ACT_GRID) { _map_grid = !_map_grid; }
else if (act == ACT_EXPORT) { handleExport(); }
else if (act == ACT_EXPORT_SAVED) { handleExportSaved(); }
else if (act == ACT_MARK) { handleMarkHere(); }
else if (act == ACT_WAYPOINTS) { _wp_mode = WP_LIST; _wp_sel = 0; _wp_scroll = 0; }
else if (act == ACT_WP_CLEAR) { _task->waypoints().clear(); _task->saveWaypoints();
_task->showAlert("Waypoints cleared", 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;
}
if (c == KEY_CANCEL) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
_task->gotoToolsScreen();
return true;
}
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
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_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_DOWN) { _list_scroll++; return true; }
if (c == KEY_ENTER) {
// Short Enter intentionally does nothing destructive — both start and
// stop go through the Hold-Enter popup so a stray tap can't change the
// tracking state.
_task->showAlert("Hold Enter for menu", 1000);
return true;
}
return false;
}
// True when there's a usable GPS fix (same source as ownPos).
bool gpsHasFix() const { int32_t lat, lon; return ownPos(lat, lon); }
private:
void handleToggle() {
bool was = _store->isActive();
_store->setActive(!was);
_task->showAlert(was ? "Tracking stopped"
: (gpsHasFix() ? "Tracking started" : "Waiting for GPS fix"),
was ? 800 : 1000);
}
void handleReset() {
if (_store->isActive()) _store->setActive(false);
_store->clear();
_task->showAlert("Trail reset", 800);
}
void handleSave() {
DataStore* ds = the_mesh.getDataStore();
if (!ds) { _task->showAlert("FS unavailable", 800); return; }
File f = ds->openWrite(TRAIL_FILE);
if (!f) { _task->showAlert("Save failed", 800); return; }
bool ok = _store->writeTo(f);
f.close();
_task->showAlert(ok ? "Trail saved" : "Save failed", 800);
}
void handleLoad() {
DataStore* ds = the_mesh.getDataStore();
if (!ds) { _task->showAlert("FS unavailable", 800); return; }
File f = ds->openRead(TRAIL_FILE);
if (!f) { _task->showAlert("No saved trail", 800); return; }
bool ok = _store->readFrom(f);
f.close();
_task->showAlert(ok ? "Trail loaded" : "Load failed", 800);
}
void handleExport() {
if (!Serial) { _task->showAlert("Connect USB first", 1200); return; }
size_t n = _store->exportGpx(Serial, _task->waypoints());
showExportAlert(n);
}
void handleExportSaved() {
if (!Serial) { _task->showAlert("Connect USB first", 1200); return; }
DataStore* ds = the_mesh.getDataStore();
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());
f.close();
if (n == 0) { _task->showAlert("Bad saved file", 1000); return; }
showExportAlert(n);
}
void showExportAlert(size_t n) {
// The companion app multiplexes BLE + USB on the same frame protocol.
// When BLE is connected, USB-receive is ignored and the dump can't
// collide. Without a BLE link the app might be on USB itself, so warn
// the user — they may need to reconnect their app afterwards.
char alert[28];
if (_task->hasConnection()) snprintf(alert, sizeof(alert), "GPX %u B (USB)", (unsigned)n);
else snprintf(alert, sizeof(alert), "GPX %u B - disc. app", (unsigned)n);
_task->showAlert(alert, 1500);
}
static constexpr const char* TRAIL_FILE = "/trail";
// 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,
p && p->units_imperial));
// Unit system (km/mi) is the global Settings choice; here we only toggle
// what the Summary readout shows — speed or pace.
snprintf(_act_units_label, sizeof(_act_units_label),
"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_toggle_label, sizeof(_act_toggle_label),
"%s tracking", _store->isActive() ? "Stop" : "Start");
}
// Append an action row, guarding both the id map and the popup capacity so
// adding a new action can never silently overrun _act_map / PopupMenu.
void pushAction(ActionId id, const char* label) {
if (_act_count >= (int)sizeof(_act_map)) return;
_act_map[_act_count++] = (uint8_t)id;
_action_menu.addItem(label);
}
void openActionMenu() {
refreshActionLabels();
_act_count = 0;
_action_menu.begin("Trail", 4);
pushAction(ACT_MIN_DIST, _act_min_dist_label);
pushAction(ACT_UNITS, _act_units_label);
pushAction(ACT_GRID, _act_grid_label);
pushAction(ACT_TOGGLE, _act_toggle_label);
// Waypoints: mark the current spot, and browse/navigate saved ones.
pushAction(ACT_MARK, "Mark here");
// "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");
if (_task->waypoints().count() > 0) pushAction(ACT_WP_CLEAR, "Clear waypoints");
if (!_store->empty()) pushAction(ACT_SAVE, "Save trail");
bool saved = savedTrailExists();
if (saved) pushAction(ACT_LOAD, "Load trail");
if (!_store->empty()) pushAction(ACT_EXPORT, "Export (live)");
if (saved) pushAction(ACT_EXPORT_SAVED, "Export (saved)");
if (!_store->empty()) pushAction(ACT_RESET, "Reset trail");
}
static bool savedTrailExists() {
DataStore* ds = the_mesh.getDataStore();
if (!ds) return false;
File f = ds->openRead(TRAIL_FILE);
bool ok = (bool)f;
if (f) f.close();
return ok;
}
// ── 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
bool ownPos(int32_t& lat, int32_t& lon) const { return _task->currentLocation(lat, lon); }
bool useImperial() const { return _task && _task->useImperial(); }
void handleMarkHere() {
int32_t lat, lon;
if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime();
_kb_rename_idx = -1;
_wp_kb.begin("", WAYPOINT_LABEL_LEN - 1);
_kb_active = true;
}
// Add a waypoint by coordinates (no GPS fix needed). Opens the WP_ADD form.
void handleAddByCoords() {
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_add_lat[0] = _add_lon[0] = _add_label[0] = '\0';
_add_lat_neg = _add_lon_neg = false;
_add_sel = 0;
_wp_mode = WP_ADD;
}
// Store the just-typed value into the focused WP_ADD field.
void applyAddField(const char* buf) {
char* dst = (_kb_field == 0) ? _add_lat
: (_kb_field == 1) ? _add_lon : _add_label;
int cap = (_kb_field == 2) ? WAYPOINT_LABEL_LEN : (int)sizeof(_add_lat);
strncpy(dst, buf, cap - 1);
dst[cap - 1] = '\0';
}
// Parse a numeric field: true only when the whole string is a number (so a
// stray letter from the full keyboard can't silently read as 0).
static bool parseNum(const char* s, double& out) {
if (!s || !s[0]) return false;
char* end = nullptr;
out = strtod(s, &end);
if (end == s) return false; // nothing numeric parsed
while (*end == ' ') end++;
return *end == '\0'; // no trailing non-numeric chars
}
// Validate the form and save the waypoint.
void commitAddForm() {
double la, lo;
if (!parseNum(_add_lat, la) || !parseNum(_add_lon, lo)) {
_task->showAlert("Lat/Lon invalid", 1200); return;
}
if (_add_lat_neg) la = -la;
if (_add_lon_neg) lo = -lo;
if (la < -90.0 || la > 90.0 || lo < -180.0 || lo > 180.0) {
_task->showAlert("Out of range", 1200); return;
}
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
int32_t lat = (int32_t)(la * 1e6 + (la < 0 ? -0.5 : 0.5));
int32_t lon = (int32_t)(lo * 1e6 + (lo < 0 ? -0.5 : 0.5));
char label[WAYPOINT_LABEL_LEN];
if (_add_label[0]) { strncpy(label, _add_label, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; }
else snprintf(label, sizeof(label), "WP%d", _task->waypoints().count() + 1);
if (_task->addWaypoint(lat, lon, label)) { _wp_mode = WP_OFF; }
}
void renderAddForm(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "ADD WAYPOINT");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
const int top = display.listStart();
const int step = display.lineStep();
const int cw = display.getCharWidth();
for (int i = 0; i < 4; i++) {
int y = top + i * step;
bool sel = (i == _add_sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
display.setCursor(0, y); display.print(sel ? ">" : " ");
char row[28];
if (i == 0) snprintf(row, sizeof(row), "Lat: %c %s", _add_lat_neg ? 'S' : 'N', _add_lat[0] ? _add_lat : "--");
else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %s", _add_lon_neg ? 'W' : 'E', _add_lon[0] ? _add_lon : "--");
else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)");
else snprintf(row, sizeof(row), "[Save]");
display.setCursor(cw + 2, y); display.print(row);
display.setColor(DisplayDriver::LIGHT);
}
}
void commitLabel(const char* buf) {
if (_kb_rename_idx >= 0) {
_task->waypoints().rename(_kb_rename_idx, buf);
_task->saveWaypoints();
_kb_rename_idx = -1;
return;
}
char auto_lbl[WAYPOINT_LABEL_LEN];
if (!buf || !buf[0]) {
snprintf(auto_lbl, sizeof(auto_lbl), "WP%d", _task->waypoints().count() + 1);
buf = auto_lbl;
}
_task->addWaypoint(_mark_lat, _mark_lon, _mark_ts, buf);
}
void renderWpList(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
char title[24];
snprintf(title, sizeof(title), "WAYPOINTS %d/%d",
_task->waypoints().count(), WaypointStore::CAPACITY);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
int n = wpListCount();
int total = n + 1; // final row = "+ Add by coords"
const int top = display.listStart();
const int step = display.lineStep();
const int cw = display.getCharWidth();
int vis = display.listVisible();
if (vis < 1) vis = 1;
if (_wp_sel < _wp_scroll) _wp_scroll = _wp_sel;
if (_wp_sel >= _wp_scroll + vis) _wp_scroll = _wp_sel - vis + 1;
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
for (int i = 0; i < vis && (_wp_scroll + i) < total; i++) {
int row = _wp_scroll + i;
int y = top + i * step;
bool sel = (row == _wp_sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
display.setCursor(0, y); display.print(sel ? ">" : " ");
if (row == n) { // the synthetic "Add" row
display.setCursor(cw + 2, y); display.print("+ Add by coords");
display.setColor(DisplayDriver::LIGHT);
continue;
}
int32_t tlat, tlon; const char* label;
if (!rowTarget(row, tlat, tlon, label)) continue;
char dist[12] = ""; int bw = 0;
if (have) {
geo::fmtDist(dist, sizeof(dist), geo::haversineKm(mylat, mylon, tlat, tlon), useImperial());
bw = display.getTextWidth(dist) + 2;
}
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);
}
}
void renderWpNav(DisplayDriver& display) {
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, tlat, tlon, label, cogv, cog, useImperial());
}
// 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
// semantics. Re-open it with focus restored to that row so the user can
// continue cycling.
void reopenAt(int sel) {
openActionMenu();
_action_menu.setSelected(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;
}
// 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; }
// Render the average speed (or pace) in the globally-selected unit system.
void formatAvgPaceOrSpeed(char* buf, size_t n, NodePrefs* p) const {
bool imperial = p && p->units_imperial;
bool pace = p && p->trail_show_pace;
uint16_t kmh = _store->avgSpeedKmh();
if (!pace) {
if (imperial) snprintf(buf, n, "Avg: %u mph", (unsigned)((float)kmh * 0.621371f + 0.5f));
else snprintf(buf, n, "Avg: %u km/h", (unsigned)kmh);
return;
}
uint32_t d_m = _store->totalDistanceMeters();
uint32_t es_s = _store->elapsedSeconds();
const char* unit = imperial ? "/mi" : "/km";
float dist_unit = imperial ? (d_m / 1609.344f) : (d_m / 1000.0f);
if (d_m == 0 || es_s == 0 || dist_unit <= 0) {
snprintf(buf, n, "Pace: --:-- %s", unit);
return;
}
float pace_min = (es_s / 60.0f) / dist_unit;
unsigned pm = (unsigned)pace_min;
unsigned ps = (unsigned)((pace_min - pm) * 60.0f);
snprintf(buf, n, "Pace: %u:%02u %s", pm, ps, unit);
}
void summaryItem(int i, char* buf, size_t n) const {
switch (i) {
case 0: {
const char* st;
if (!_store->isActive()) st = "stopped";
else if (_store->empty()) st = "waiting fix";
else st = "tracking";
snprintf(buf, n, "Status: %s", st);
break;
}
case 1:
snprintf(buf, n, "Points: %d / %d", _store->count(), TrailStore::CAPACITY);
break;
case 2: {
uint32_t d = _store->totalDistanceMeters();
char ds[12];
geo::fmtDist(ds, sizeof(ds), d / 1000.0f, useImperial());
snprintf(buf, n, "Dist: %s", ds);
break;
}
case 3: {
uint32_t es = _store->elapsedSeconds();
// Unit-suffixed so "1h 05m" can't be misread as "1m 05s".
if (es < 3600) snprintf(buf, n, "Time: %lum %02lus",
(unsigned long)(es / 60), (unsigned long)(es % 60));
else snprintf(buf, n, "Time: %luh %02lum",
(unsigned long)(es / 3600), (unsigned long)((es % 3600) / 60));
break;
}
case 4:
formatAvgPaceOrSpeed(buf, n, _task->getNodePrefs());
break;
default:
buf[0] = '\0';
}
}
void renderSummary(DisplayDriver& display) {
const int y0 = display.listStart();
const int step = display.lineStep();
const int avail = display.height() - y0 - 2;
int visible = avail / step;
if (visible < 1) visible = 1;
if (visible > SUMMARY_ITEM_COUNT) visible = SUMMARY_ITEM_COUNT;
int max_scroll = SUMMARY_ITEM_COUNT - visible;
if (_summary_scroll > max_scroll) _summary_scroll = max_scroll;
if (_summary_scroll < 0) _summary_scroll = 0;
for (int i = 0; i < visible; i++) {
int idx = _summary_scroll + i;
if (idx >= SUMMARY_ITEM_COUNT) break;
char buf[28];
summaryItem(idx, buf, sizeof(buf));
display.setCursor(2, y0 + i * step);
display.print(buf);
}
display.drawScrollArrows(y0, y0 + (visible - 1) * step,
_summary_scroll > 0,
_summary_scroll + visible < SUMMARY_ITEM_COUNT);
}
void renderList(DisplayDriver& display) {
const int top = display.listStart();
const int step = display.lineStep();
const int avail = display.height() - top - 2;
if (_store->empty()) {
display.drawTextCentered(display.width() / 2, top + avail / 2, "No trail yet");
return;
}
int visible = avail / step;
if (visible < 1) visible = 1;
const int total = _store->count();
if (visible > total) visible = total;
int max_scroll = total - visible;
if (_list_scroll > max_scroll) _list_scroll = max_scroll;
if (_list_scroll < 0) _list_scroll = 0;
NodePrefs* p = _task->getNodePrefs();
int32_t tz_off = (int32_t)(p ? p->tz_offset_hours : 0) * 3600;
for (int i = 0; i < visible; i++) {
int idx = total - 1 - (_list_scroll + i);
if (idx < 0) break;
const TrailPoint& pt = _store->at(idx);
time_t lt = (time_t)((int32_t)pt.ts + tz_off);
struct tm* ti = gmtime(&lt);
char dist[12];
if (idx == 0 || (pt.flags & TRAIL_FLAG_SEG_START)) {
snprintf(dist, sizeof(dist), "start");
} else {
const TrailPoint& prev = _store->at(idx - 1);
float d = TrailStore::haversineMeters(prev.lat_1e6, prev.lon_1e6,
pt.lat_1e6, pt.lon_1e6);
char ds[10];
geo::fmtDist(ds, sizeof(ds), d / 1000.0f, useImperial());
snprintf(dist, sizeof(dist), "+%s", ds);
}
char row[28];
snprintf(row, sizeof(row), "%02d:%02d %s",
ti->tm_hour, ti->tm_min, dist);
display.setCursor(2, top + i * step);
display.print(row);
}
display.drawScrollArrows(top, top + (visible - 1) * step,
_list_scroll > 0, _list_scroll + visible < total);
}
void renderMap(DisplayDriver& display) {
const int top = display.listStart();
const int bottom = display.height() - 2;
WaypointStore& wp = _task->waypoints();
const int nwp = wp.count();
const bool have_trail = !_store->empty();
// Live GPS position — lets the map double as a "you + waypoints" view even
// when no trail is being recorded.
int32_t my_lat, my_lon;
const bool have_gps = ownPos(my_lat, my_lon);
if (!have_trail && nwp == 0 && !have_gps) {
display.drawTextCentered(display.width() / 2, (top + bottom) / 2, "No GPS / no trail");
return;
}
const int area_x = 2;
const int area_y = top + 1;
const int area_w = display.width() - 4;
const int area_h = bottom - top - 2;
if (area_w < 6 || area_h < 6) return;
// Bounding box spans the trail, every waypoint, and the live position, so
// everything of interest stays in frame.
int32_t min_lat = 0, min_lon = 0, max_lat = 0, max_lon = 0;
bool init = false;
auto fold = [&](int32_t lat, int32_t lon) {
if (!init) { min_lat = max_lat = lat; min_lon = max_lon = lon; init = true; }
else {
if (lat < min_lat) min_lat = lat; if (lat > max_lat) max_lat = lat;
if (lon < min_lon) min_lon = lon; if (lon > max_lon) max_lon = lon;
}
};
// With a trail, frame the recorded route (and live position) and let far
// waypoints clamp to the map edge — they must not blow up the scale. Only
// when there's no trail do waypoints define the view.
if (have_trail) {
int32_t a, b, c, d; _store->boundingBox(a, b, c, d);
fold(a, b); fold(c, d);
} else {
for (int i = 0; i < nwp; i++) fold(wp.at(i).lat_1e6, wp.at(i).lon_1e6);
}
if (have_gps) fold(my_lat, my_lon);
// Degenerate: everything at one coordinate — just centre the markers.
if (min_lat == max_lat && min_lon == max_lon) {
int ccx = area_x + area_w / 2, ccy = area_y + area_h / 2;
// Only pin a waypoint to the centre in the genuine "lone waypoint, no
// trail" case. With a trail the centre is the trail's spot, and the
// waypoints (not folded into this box) belong elsewhere — don't draw
// them here, or they'd appear at the trail's location.
if (nwp > 0 && !have_trail) {
drawWaypointMarker(display, ccx, ccy);
const char* lbl = wp.at(0).label; // single coincident spot — label it
char s[3] = { lbl[0], lbl[0] ? lbl[1] : (char)0, 0 };
drawWaypointLabel(display, s, ccx, ccy, area_x, area_y, area_w, area_h);
}
if (have_gps || have_trail) drawCurrentMarker(display, ccx, ccy);
return;
}
float avg_lat_rad = ((min_lat + max_lat) / 2.0e6f) * (float)M_PI / 180.0f;
float lon_scale_geo = cosf(avg_lat_rad);
if (lon_scale_geo < 0.05f) lon_scale_geo = 0.05f;
float lat_span = (float)(max_lat - min_lat);
float lon_span = (float)(max_lon - min_lon) * lon_scale_geo;
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 = (scale_lat < scale_lon) ? scale_lat : scale_lon;
int used_w = (int)(lon_span * scale);
int used_h = (int)(lat_span * scale);
int off_x = area_x + (area_w - used_w) / 2;
int off_y = area_y + (area_h - used_h) / 2;
auto projectLL = [&](int32_t lat, int32_t lon, int& px, int& py) {
px = off_x + (int)((float)(lon - min_lon) * lon_scale_geo * scale);
py = off_y + (int)((float)(max_lat - lat) * scale);
};
auto project = [&](const TrailPoint& p, int& px, int& py) {
projectLL(p.lat_1e6, p.lon_1e6, px, py);
};
if (_map_grid)
renderGrid(display, area_x, area_y, area_w, area_h,
min_lat, max_lat, min_lon, lon_scale_geo, scale, off_x, off_y);
if (have_trail) {
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);
if (_store->at(i).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;
}
int sx, sy;
project(_store->first(), sx, sy);
drawStartMarker(display, sx, sy);
}
// Waypoints, with the first two label characters beside the marker so
// nearby waypoints can be told apart. A waypoint that falls outside the
// (trail-framed) view is clamped to the nearest edge instead of being lost.
const int wp_x_max = area_x + area_w - 1, wp_y_max = area_y + area_h - 1;
for (int i = 0; i < nwp; i++) {
const Waypoint& w = wp.at(i);
int wx, wy; projectLL(w.lat_1e6, w.lon_1e6, wx, wy);
if (wx < area_x) wx = area_x; else if (wx > wp_x_max) wx = wp_x_max;
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);
}
// Current position marker on top — live GPS if we have a fix, otherwise the
// last recorded trail point.
if (have_gps) {
int mx, my; projectLL(my_lat, my_lon, mx, my);
drawCurrentMarker(display, mx, my);
} else if (have_trail) {
int ex, ey; project(_store->last(), ex, ey);
drawCurrentMarker(display, ex, ey);
}
drawNorthArrow(display, area_x + area_w - 4, area_y + display.getLineHeight() + 1);
}
void renderGrid(DisplayDriver& display,
int area_x, int area_y, int area_w, int area_h,
int32_t min_lat, int32_t max_lat, int32_t min_lon,
float lon_scale_geo, float scale, int off_x, int off_y) {
static constexpr float M_PER_1E6 = 0.11132f; // metres per 1e-6 deg lat
float ppm = scale / M_PER_1E6;
if (ppm <= 0.0f) return;
float shorter_m = (float)(area_w < area_h ? area_w : area_h) / ppm;
float target_m = shorter_m / 3.0f;
// Round scale steps with matching labels. The grid geometry works in metres,
// so imperial steps are stored as their metre equivalents but labelled with
// their exact round ft/mi value. Both tables are parallel (step ↔ label).
static const float METRIC_STEPS[] = {
1, 2, 5, 10, 20, 50, 100, 200, 500,
1000, 2000, 5000, 10000, 20000, 50000, 100000
};
static const char* METRIC_LABELS[] = {
"1m", "2m", "5m", "10m", "20m", "50m", "100m", "200m", "500m",
"1km", "2km", "5km", "10km", "20km", "50km", "100km"
};
static const float IMPERIAL_STEPS[] = { // 10..2000 ft, then 1..100 mi (metres)
3.048f, 7.62f, 15.24f, 30.48f, 76.2f, 152.4f, 304.8f, 609.6f,
1609.344f, 3218.69f, 8046.72f, 16093.44f, 32186.88f, 80467.2f, 160934.4f
};
static const char* IMPERIAL_LABELS[] = {
"10ft", "25ft", "50ft", "100ft", "250ft", "500ft", "1000ft", "2000ft",
"1mi", "2mi", "5mi", "10mi", "20mi", "50mi", "100mi"
};
const bool imp = useImperial();
const float* STEPS = imp ? IMPERIAL_STEPS : METRIC_STEPS;
const char* const* LABELS = imp ? IMPERIAL_LABELS : METRIC_LABELS;
const int N_STEPS = imp ? (int)(sizeof(IMPERIAL_STEPS)/sizeof(IMPERIAL_STEPS[0]))
: (int)(sizeof(METRIC_STEPS)/sizeof(METRIC_STEPS[0]));
// Pick largest step ≤ target_m (gives ~3 intervals across shorter dim).
int sel = 0;
for (int si = N_STEPS - 1; si >= 0; si--) {
if (STEPS[si] <= target_m) { sel = si; break; }
}
// Bump up until pixel spacing between intersections is at least MIN_GRID_PX.
// This keeps OLED grids sparse even when target_m selects a fine step.
static const float MIN_GRID_PX = 22.0f;
while (sel < N_STEPS - 1 && STEPS[sel] / M_PER_1E6 * scale < MIN_GRID_PX)
sel++;
// Clamp back down: ensure at least 2 intervals (3 visible lines) across
// the shorter dimension. MIN_GRID_PX must not leave only 1 interval.
float shorter_px = (float)(area_w < area_h ? area_w : area_h);
while (sel > 0 && STEPS[sel] / M_PER_1E6 * scale > shorter_px / 2.0f)
sel--;
float vis_lat_max = (float)max_lat + (float)(off_y - area_y) / scale;
float vis_lat_min = (float)max_lat - (float)(area_y + area_h - off_y) / scale;
float vis_lon_min = (float)min_lon - (float)(off_x - area_x) / (lon_scale_geo * scale);
float vis_lon_max = (float)min_lon + (float)(area_x + area_w - off_x) / (lon_scale_geo * scale);
// Final guard: a very elongated trail (lat_span ≪ lon_span or vice-versa)
// shares a single isotropic scale, so the visible window in the *short*
// direction can span many grid_m. Bump the step up until the resulting
// line count fits the static buffers (40×40 = ~1600 intersections, plenty
// for any sane display).
static const int MAX_GRID_LINES = 40;
float grid_m = STEPS[sel];
float gs_lat = grid_m / M_PER_1E6;
float gs_lon = grid_m / (M_PER_1E6 * lon_scale_geo);
int lat_n = (int)((vis_lat_max - vis_lat_min) / gs_lat) + 2;
int lon_n = (int)((vis_lon_max - vis_lon_min) / gs_lon) + 2;
while ((lat_n > MAX_GRID_LINES || lon_n > MAX_GRID_LINES) && sel < N_STEPS - 1) {
sel++;
grid_m = STEPS[sel];
gs_lat = grid_m / M_PER_1E6;
gs_lon = grid_m / (M_PER_1E6 * lon_scale_geo);
lat_n = (int)((vis_lat_max - vis_lat_min) / gs_lat) + 2;
lon_n = (int)((vis_lon_max - vis_lon_min) / gs_lon) + 2;
}
if (lat_n > MAX_GRID_LINES || lon_n > MAX_GRID_LINES) return; // huge step still won't fit — give up
// Anchor to display edges so lines always start at the border and space inward.
float first_lat = vis_lat_min;
float first_lon = vis_lon_min;
int x_max = area_x + area_w - 1;
int y_max = area_y + area_h - 1;
auto fillSafe = [&](int x, int y) {
if (x >= area_x && x <= x_max && y >= area_y && y <= y_max)
display.fillRect(x, y, 1, 1);
};
// Label is the exact round step value for the selected unit system.
char lbl[12];
snprintf(lbl, sizeof(lbl), "%s", LABELS[sel]);
int lh = display.getLineHeight();
int cw = display.getCharWidth();
// Scale label bbox (bottom-left corner)
int lbl_x1 = area_x;
int lbl_x2 = area_x + (int)strlen(lbl) * cw + 1;
int lbl_y1 = area_y + area_h - lh - 1;
int lbl_y2 = area_y + area_h;
// North-arrow bbox — mirrors drawNorthArrow(cx = area_x+area_w-4, cy = area_y+lh+1)
int arr_cx = area_x + area_w - 4;
int arr_cy = area_y + lh + 1;
int arr_x1 = arr_cx - cw - 1;
int arr_x2 = x_max;
int arr_y1 = area_y;
int arr_y2 = arr_cy + 7;
for (int i = 0; i < lat_n; i++) {
float lat_val = first_lat + (float)i * gs_lat;
int py = off_y + (int)(((float)max_lat - lat_val) * scale);
if (py < area_y - 1 || py > y_max + 1) continue;
for (int j = 0; j < lon_n; j++) {
float lon_val = first_lon + (float)j * gs_lon;
int px = off_x + (int)((lon_val - (float)min_lon) * lon_scale_geo * scale);
if (px < area_x - 1 || px > x_max + 1) continue;
if (px >= lbl_x1 && px <= lbl_x2 && py >= lbl_y1 && py <= lbl_y2) continue;
if (px >= arr_x1 && px <= arr_x2 && py >= arr_y1 && py <= arr_y2) continue;
fillSafe(px, py);
fillSafe(px - 1, py);
fillSafe(px + 1, py);
fillSafe(px, py - 1);
fillSafe(px, py + 1);
}
}
display.setCursor(area_x, area_y + area_h - lh);
display.print(lbl);
}
// Place a 12 char waypoint label beside its marker, kept inside the map
// rect: prefer upper-right, flip to the left if it would clip the right
// edge, and clamp vertically so edge/corner waypoints stay on the map.
static void drawWaypointLabel(DisplayDriver& d, const char* s, int wx, int wy,
int ax, int ay, int aw, int ah) {
if (!s[0]) return;
int tw = (int)d.getTextWidth(s);
int ch = d.getLineHeight();
int lx = wx + 4;
if (lx + tw > ax + aw) lx = wx - 4 - tw; // would clip right → flip left
if (lx < ax) lx = ax; // clamp to left edge
if (lx + tw > ax + aw) lx = ax + aw - tw; // clamp to right edge
int ly = wy - 3;
if (ly < ay) ly = ay; // clamp to top edge
if (ly + ch > ay + ah) ly = ay + ah - ch; // clamp to bottom edge
d.setCursor(lx, ly);
d.print(s);
}
static void drawFilledDot(DisplayDriver& d, int cx, int cy) {
d.fillRect(cx - 1, cy - 1, 3, 3);
}
// Hollow diamond — distinct from the trail's dots (○●), start (+) and
// current (✕) markers.
static void drawWaypointMarker(DisplayDriver& d, int cx, int cy) {
d.fillRect(cx, cy - 2, 1, 1);
d.fillRect(cx - 1, cy - 1, 1, 1); d.fillRect(cx + 1, cy - 1, 1, 1);
d.fillRect(cx - 2, cy, 1, 1); d.fillRect(cx + 2, cy, 1, 1);
d.fillRect(cx - 1, cy + 1, 1, 1); d.fillRect(cx + 1, cy + 1, 1, 1);
d.fillRect(cx, cy + 2, 1, 1);
}
static void drawOpenDot(DisplayDriver& d, int cx, int cy) {
d.fillRect(cx - 1, cy - 1, 3, 1);
d.fillRect(cx - 1, cy + 1, 3, 1);
d.fillRect(cx - 1, cy, 1, 1);
d.fillRect(cx + 1, cy, 1, 1);
}
static void drawStartMarker(DisplayDriver& d, int cx, int cy) {
d.fillRect(cx - 2, cy, 5, 1);
d.fillRect(cx, cy - 2, 1, 5);
}
static void drawCurrentMarker(DisplayDriver& d, int cx, int cy) {
for (int i = -2; i <= 2; i++) {
d.fillRect(cx + i, cy + i, 1, 1);
d.fillRect(cx + i, cy - i, 1, 1);
}
}
static void drawNorthArrow(DisplayDriver& d, int cx, int cy) {
int lh = d.getLineHeight();
int cw = d.getCharWidth();
d.setCursor(cx - cw / 2, cy - lh);
d.print("N");
d.fillRect(cx, cy, 1, 1);
d.fillRect(cx - 1, cy + 1, 3, 1);
d.fillRect(cx - 2, cy + 2, 5, 1);
d.fillRect(cx, cy + 3, 1, 4);
}
};