feat(map): north + scale on home mini-map; avoid overlapping labels

Home Map page mini-preview gains a north mini-icon and a fillRect scale
bar with a round-distance label (metric/imperial), and its status line now
shows the distance to the nearest tracked contact alongside fix/track.

Trail map labels no longer smear in dense clusters: each placed label
reserves a rect and a new one tries four offsets around its marker,
skipping (marker still shown) when none fit without overlap.

Verified: WioTrackerL1_companion_solo_dual builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-22 14:00:04 +02:00
parent 92d4a7ae16
commit 3ffdfd64b6
3 changed files with 97 additions and 6 deletions

View File

@@ -577,6 +577,9 @@ 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.
LblRect occ[40]; int nocc = 0;
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);
@@ -584,7 +587,7 @@ 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);
}
// Live-tracked contacts ([LOC] shares): filled diamond + name initial,
// clamped to the frame like waypoints.
@@ -599,7 +602,7 @@ private:
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 };
drawWaypointLabel(display, s2, cx, cy, area_x, area_y, area_w, area_h);
drawLabelAvoiding(display, s2, cx, cy, area_x, area_y, area_w, area_h, occ, nocc, 40);
}
}
@@ -848,6 +851,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);
}

View File

@@ -554,6 +554,13 @@ public:
if (have_gps) fold(mla, mlo);
if (!init) return false;
// North marker — top-right, the same mini-icon as the full Trail map.
display.setColor(DisplayDriver::LIGHT);
{
const int ns = miniIconScale(display);
miniIconDrawTop(display, ax + aw - ICON_MAP_NORTH.w * ns - 1, ay + 1, ICON_MAP_NORTH);
}
int cx = ax + aw / 2, cy = ay + ah / 2;
// Degenerate: one coincident point — just centre the markers.
if (mnla == mxla && mnlo == mxlo) {
@@ -586,6 +593,37 @@ public:
miniIconDrawCentered(display, px, py, ICON_MAP_CONTACT);
}
if (have_gps) { int px, py; project(mla, mlo, px, py); miniIconDrawCentered(display, px, py, ICON_MAP_CURRENT); }
// Scale bar (bottom-left): a round distance ~1/3 of the width, drawn with
// fillRect ticks (gfx line isn't available this early in the TU) + a label,
// so the preview conveys rough scale / distance.
{
static const float M_PER_1E6 = 0.11132f; // metres per 1e-6° lat
float ppm = scale / M_PER_1E6; // pixels per metre
if (ppm > 0.0f) {
bool imp = _task->useImperial();
static const float MET_M[] = { 5,10,25,50,100,250,500,1000,2000,5000,10000,25000,50000 };
static const char* MET_L[] = { "5m","10m","25m","50m","100m","250m","500m","1km","2km","5km","10km","25km","50km" };
static const float IMP_M[] = { 4.572f,15.24f,30.48f,76.2f,152.4f,402.34f,804.67f,1609.34f,4828.0f,16093.4f,80467.2f };
static const char* IMP_L[] = { "15ft","50ft","100ft","250ft","500ft","1/4mi","1/2mi","1mi","3mi","10mi","50mi" };
const float* M = imp ? IMP_M : MET_M;
const char* const* L = imp ? IMP_L : MET_L;
int N = imp ? (int)(sizeof(IMP_M) / sizeof(IMP_M[0])) : (int)(sizeof(MET_M) / sizeof(MET_M[0]));
float target = (aw / 3.0f) / ppm; // ~1/3 of the width, in metres
int sel = 0;
for (int i = N - 1; i >= 0; i--) if (M[i] <= target) { sel = i; break; }
int barpx = (int)(M[sel] * ppm + 0.5f);
if (barpx < 6) barpx = 6;
if (barpx > aw - 2) barpx = aw - 2;
int bx = ax + 1, by = ay + ah - 1;
display.setColor(DisplayDriver::LIGHT);
display.fillRect(bx, by, barpx, 1); // bar
display.fillRect(bx, by - 2, 1, 3); // left tick
display.fillRect(bx + barpx - 1, by - 2, 1, 3); // right tick
display.setCursor(bx, by - 2 - display.getLineHeight());
display.print(L[sel]);
}
}
return true;
}
@@ -963,8 +1001,26 @@ public:
char info[40];
int32_t la, lo;
bool fix = _task->currentLocation(la, lo);
int trk = _task->liveTrack().active(rtc_clock.getCurrentTime());
snprintf(info, sizeof(info), "Fix:%s Track:%d", fix ? "y" : "n", trk);
uint32_t now_m = rtc_clock.getCurrentTime();
LiveTrackStore& lt = _task->liveTrack();
int trk = lt.active(now_m);
// Distance to the nearest tracked contact, so the page also answers
// "how far is the closest shared point".
float nearest = -1.0f;
if (fix) {
for (int i = 0; i < LiveTrackStore::CAPACITY; i++) {
if (!lt.isActive(i, now_m)) continue;
float d = geo::haversineKm(la, lo, lt.slotAt(i).lat_1e6, lt.slotAt(i).lon_1e6);
if (nearest < 0 || d < nearest) nearest = d;
}
}
if (nearest >= 0) {
char ds[12];
geo::fmtDist(ds, sizeof(ds), nearest, _task->useImperial());
snprintf(info, sizeof(info), "Fix:%s Trk:%d %s", fix ? "y" : "n", trk, ds);
} else {
snprintf(info, sizeof(info), "Fix:%s Track:%d", fix ? "y" : "n", trk);
}
display.setColor(DisplayDriver::LIGHT);
if (!drew)
display.drawTextCentered(display.width() / 2, content_y + area_h / 2, "No GPS / no trail");