From 3ffdfd64b6223e498fdd3c40fee4814d58625d51 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 22 Jun 2026 14:00:04 +0200 Subject: [PATCH] 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 --- .../tools_screen/tools_screen.md | 4 +- examples/companion_radio/ui-new/TrailScreen.h | 39 +++++++++++- examples/companion_radio/ui-new/UITask.cpp | 60 ++++++++++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 9174486b..7d218815 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -81,14 +81,14 @@ Because it is the same list, all the same keys apply — **UP/DOWN** to navigate Records your route in a RAM ring buffer (up to 512 points, sampled every 1 s). Tracking runs in the background — a blinking **G** appears in the status bar. The trail survives display auto-off but is lost on reboot unless saved to flash first. > [!TIP] -> The **Map** view is also reachable directly from the home carousel — the **Map** page shows a live mini-preview (your position, trail, and tracked contacts); press **Enter** to open the full Trail Map, **Back** returns home. +> The **Map** view is also reachable directly from the home carousel — the **Map** page shows a live mini-preview (your position, trail, and tracked contacts) with a **north marker** and a **scale bar**, and a status line with the GPS-fix state, tracked-node count and distance to the **nearest** tracked contact. Press **Enter** to open the full Trail Map; **Hold Enter** shares your position (see **Live Share**); **Back** returns home. Cycle views with **LEFT / RIGHT**: | View | Content | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Summary** | Distance, elapsed time, avg speed or pace, point count, tracking status | -| **Map** | Auto-fit dot-and-line plot with cos(lat) aspect correction; segment breaks marked; north arrow; square scale grid fitted to the map frame (toggle under **Hold Enter → Settings → Grid**, Map view only). Your **current GPS position**, all **waypoints**, and any **live-tracked contacts** (positions shared via Live Share) are always drawn — even with no trail recording — so the map is useful standalone | +| **Map** | Auto-fit dot-and-line plot with cos(lat) aspect correction; segment breaks marked; north arrow; square scale grid fitted to the map frame (toggle under **Hold Enter → Settings → Grid**, Map view only). Your **current GPS position**, all **waypoints**, and any **live-tracked contacts** (positions shared via Live Share) are always drawn — even with no trail recording — so the map is useful standalone. Point labels are auto-placed to avoid overlapping (a crowded cluster drops some labels rather than smearing them) | | **List** | Per-point rows showing local time (HH:MM) and delta distance from the previous point; segment-start rows show `start`; scroll with **UP/DOWN** | | OLED | E-Ink | diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index a1efe22f..f60e16f1 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -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); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 54b38257..3079478b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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");