Show the world map when no node locations are available

This commit is contained in:
mikecarper committed 2026-10-22 01:15:07 -07:00
1 parent 25a98ae9f5
commit d08e5f51ef
8 files changed
+138 -68

No files matched your search

+4 -4
View File
@@ -37,10 +37,10 @@ saved timeouts are retained; set `display.timeout` and `display.usb.timeout` to
`900` to use 15 minutes. The return-to-board delay is a separate setting.
The map centres on this node's location, or the first contact with a location.
Auto zoom shows a world overview when no locations are available, or a 200 km
view when a location is available. Selected zoom levels also work without
nodes; the map then centres on latitude 0, longitude 0 until a location is
available. Contacts appear as dots, with nearby names at closer zooms.
With no locations it shows a world overview, keeping the selected zoom saved
for when a location becomes available. Zoom controls require a location.
Auto zoom uses a 200 km view when a location is available. Contacts appear as
dots, with nearby names at closer zooms.
The view spans 20,000 km down to 5 km vertically;
coastline detail stays coarse at close zooms. It contains no streets, terrain,
regional downloads or ocean tiles. See the [map data notes](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/meshpocket_map/README.md)
+2 -2
View File
@@ -368,12 +368,12 @@ public:
normal(); footer();
}
void renderMapChrome(unsigned actual_height_km = 0) {
void renderMapChrome(unsigned actual_height_km = 0, const char* hint = nullptr) {
char range[24];
snprintf(range, sizeof(range), "%u km", actual_height_km != 0
? actual_height_km : (unsigned)mapHeightKm(_settings));
header("Map", range);
footer();
footer(hint);
}
void mapEmpty(const char* message) {
font(true); textCentre(_width / 2, 54, message);
+12 -1
View File
@@ -705,7 +705,12 @@ class HomeScreen : public UIScreen {
if (pocketLocation(latitude, longitude)) { located = true; break; }
}
}
if (_pocket_map_located != located) displayForceClean();
_pocket_map_located = located;
if (!located && _pocket_zooming) {
_pocket_zooming = false;
if (_pocket_dirty) flushPocketSettings();
}
MapView view = {located ? latitude : 0, located ? longitude : 0,
mapHeightKm(settings, located), _pocket.width(),
_pocket.bodyBottom() - _pocket.bodyTop()};
@@ -740,7 +745,9 @@ class HomeScreen : public UIScreen {
else snprintf(summary, sizeof(summary), "%sno node locations",
view.height_km == 20000 ? "World / " : "");
_pocket.label(3, _pocket.bodyTop() + 9, summary);
_pocket.renderMapChrome((unsigned)view.height_km);
_pocket.renderMapChrome((unsigned)view.height_km, located
? (_pocket_zooming ? "1x zoom in 2x out hold done" : "click next 2x back hold zoom")
: "click next 2x back");
}
int renderPocket(DisplayDriver& display) {
@@ -1676,6 +1683,10 @@ public:
}
if (_page == HomePage::MAP) {
if (key == KEY_ENTER) {
if (!_pocket_map_located) {
_task->showAlert("Set location in app to zoom", 1500);
return true;
}
_pocket_zooming = !_pocket_zooming;
if (!_pocket_zooming) flushPocketSettings();
return true;
+4 -1
View File
@@ -96,7 +96,9 @@ inline unsigned nameCharacters(const Settings& value) {
}
inline uint8_t mapZoomIndex(const Settings& value, bool located = true) {
return value.map_zoom < MAP_ZOOM_AUTO ? value.map_zoom : (located ? 3 : 0);
// A local range has no useful center until a node location is available.
if (!located) return 0;
return value.map_zoom < MAP_ZOOM_AUTO ? value.map_zoom : 3;
}
inline double mapHeightKm(const Settings& value, bool located = true) {
@@ -105,6 +107,7 @@ inline double mapHeightKm(const Settings& value, bool located = true) {
}
inline bool zoomMap(Settings& value, bool closer, bool located) {
if (!located) return false;
const uint8_t index = mapZoomIndex(value, located);
if ((closer && index == 8) || (!closer && index == 0)) return false;
value.map_zoom = closer ? index + 1 : index - 1;
+32 -13
View File
@@ -1,6 +1,7 @@
#include <helpers/ui/PocketGuiSettings.h>
#include <helpers/ui/UIScreen.h>
#include <cassert>
#include <cstring>
#include <initializer_list>
#define MAX_GROUP_CHANNELS 40
@@ -10,10 +11,14 @@ uint32_t millis() { return clock_millis; }
struct Task {
mesh::ui::pocket::Settings settings;
int reader_opens = 0;
int alerts = 0, alert_millis = 0;
const char* alert_text = nullptr;
mesh::ui::pocket::Settings& pocketSettings() { return settings; }
unsigned pocketBoardCount() const { return 3; }
void showReader() { ++reader_opens; }
void showAlert(const char*, int) {}
void showAlert(const char* text, int duration) {
++alerts; alert_text = text; alert_millis = duration;
}
};
struct Mesh {
bool dual = true;
@@ -53,18 +58,18 @@ static void key(HomeScreen& home, uint8_t value) {
}
static void mapZoomControls() {
for (bool located : {false, true}) {
{
Task task;
HomeScreen home(task);
home._page = HomeScreen::MAP;
home._pocket_map_located = located;
home._pocket_map_located = true;
key(home, KEY_ENTER);
assert(home._pocket_zooming && task.settings.map_zoom == 9);
key(home, KEY_NEXT);
assert(task.settings.map_zoom == (located ? 4 : 1));
assert(task.settings.map_zoom == 4);
assert(home._pocket_dirty && home.clean_count == 1);
key(home, KEY_PREV);
assert(task.settings.map_zoom == (located ? 3 : 0));
assert(task.settings.map_zoom == 3);
assert(home.clean_count == 2);
key(home, KEY_ENTER);
assert(!home._pocket_zooming && !home._pocket_dirty);
@@ -90,12 +95,25 @@ static void mapZoomControls() {
}
}
Task task;
HomeScreen home(task);
home._page = HomeScreen::MAP;
key(home, KEY_ENTER);
key(home, KEY_PREV);
assert(task.settings.map_zoom == 9 && !home._pocket_dirty && home.clean_count == 0);
// A hold with no location explains why zoom is unavailable. Subsequent
// clicks still navigate pages rather than becoming invisible zoom changes.
for (unsigned zoom = 0; zoom < 10; ++zoom) {
Task task;
task.settings.map_zoom = zoom;
HomeScreen home(task);
home._page = HomeScreen::MAP;
key(home, KEY_ENTER);
assert(!home._pocket_zooming && task.settings.map_zoom == zoom);
assert(!home._pocket_dirty && home.clean_count == 0 && home.save_count == 0);
assert(task.alerts == 1 && task.alert_millis == 1500);
assert(std::strcmp(task.alert_text, "Set location in app to zoom") == 0);
key(home, KEY_NEXT);
assert(home._page == HomeScreen::STATUS && task.settings.map_zoom == zoom);
key(home, KEY_PREV);
assert(home._page == HomeScreen::MAP);
key(home, KEY_PREV);
assert(home._page == HomeScreen::FIRST && task.settings.map_zoom == zoom);
}
}
int main() {
@@ -117,12 +135,13 @@ int main() {
assert(home.clean_count == 0);
key(home, KEY_NEXT);
assert(home._page == HomeScreen::MAP && home.clean_count == 1);
home._pocket_map_located = true;
key(home, KEY_ENTER);
assert(home._pocket_zooming);
key(home, KEY_NEXT);
assert(task.settings.map_zoom == 1 && home._pocket_dirty);
assert(task.settings.map_zoom == 4 && home._pocket_dirty);
key(home, KEY_PREV);
assert(task.settings.map_zoom == 0);
assert(task.settings.map_zoom == 3);
key(home, KEY_ENTER);
assert(!home._pocket_zooming && !home._pocket_dirty);
key(home, KEY_NEXT);
+65 -25
View File
@@ -54,6 +54,8 @@ bool projectWorldPoint(const MapView& view, double, double, int& x, int& y) {
class PocketGui {
public:
std::vector<std::string> labels;
std::string hint;
Settings _settings;
unsigned markers = 0, own_markers = 0, range = 0;
int width() const { return 250; }
int bodyTop() const { return 20; }
@@ -66,7 +68,15 @@ public:
++markers;
if (own) ++own_markers;
}
void renderMapChrome(unsigned height) { range = height; }
void header(const char* title, const char* range_label) {
assert(std::strcmp(title, "Map") == 0);
assert(std::sscanf(range_label, "%u km", &range) == 1);
}
void footer(const char* footer_hint) {
assert(footer_hint != nullptr);
hint = footer_hint;
}
// @MAP_CHROME_IMPLEMENTATION@
};
}}}
@@ -81,7 +91,11 @@ public:
Task* _task;
mesh::ui::pocket::PocketGui _pocket;
bool _pocket_map_located = false;
bool _pocket_zooming = false, _pocket_dirty = false;
unsigned save_count = 0, clean_count = 0;
explicit HomeScreen(Task& task) : _task(&task) {}
bool flushPocketSettings() { ++save_count; _pocket_dirty = false; return true; }
void displayForceClean() { ++clean_count; }
// @MAP_IMPLEMENTATION@
};
@@ -93,6 +107,9 @@ static void checkView(const HomeScreen& home, double latitude, double longitude,
assert(rendered_view.height_km == height && home._pocket.range == height);
assert(rendered_view.width == 250 && rendered_view.height == 82);
assert(home._pocket_map_located == located);
assert(home._pocket.hint == (!located ? "click next 2x back"
: home._pocket_zooming ? "1x zoom in 2x out hold done"
: "click next 2x back hold zoom"));
}
int main() {
@@ -102,54 +119,77 @@ int main() {
home.renderPocketMap();
assert(renders == 1 && home._pocket.markers == 0);
checkView(home, 0, 0, 20000, false);
assert(home.clean_count == 0);
assert(home._pocket.labels.back() == "World / no node locations");
// Executing the actual page binding is essential: valid zoom settings used
// to be discarded here whenever no contact or self location was available.
const unsigned heights[] = {20000, 5000, 1000, 200, 100, 50, 20, 10, 5};
// Even a saved 5 km preference must show the complete world without a
// location. Rendering the fallback must not overwrite that preference.
the_mesh.contacts = {{0, 0, "Unset"}, {91000000, 1000000, "Invalid"}};
for (unsigned zoom = 0; zoom < 9; ++zoom) {
for (unsigned zoom = 0; zoom < 10; ++zoom) {
task.settings.map_zoom = zoom;
home.renderPocketMap();
checkView(home, 0, 0, heights[zoom], false);
checkView(home, 0, 0, 20000, false);
assert(task.settings.map_zoom == zoom);
assert(home._pocket.markers == 0);
assert(home._pocket.labels.back() == (zoom == 0
? "World / no node locations" : "no node locations"));
assert(home._pocket.labels.back() == "World / no node locations");
assert(home.clean_count == 0);
}
task.settings = Settings{};
assert(zoomMap(task.settings, true, home._pocket_map_located));
home.renderPocketMap();
checkView(home, 0, 0, 5000, false);
assert(zoomMap(task.settings, false, home._pocket_map_located));
home.renderPocketMap();
checkView(home, 0, 0, 20000, false);
// Auto uses the real local center when it is known and returns to the
// overview if that location disappears. It never invents a node at 0,0.
task.settings = Settings{};
// The first frame with an own location resumes 5 km and immediately shows
// the zoom hint, even though the previous frame had no location.
task.settings.map_zoom = 8;
the_mesh.contacts.clear();
sensors = {47.5, -122.25};
home.renderPocketMap();
checkView(home, 47.5, -122.25, 200, true);
checkView(home, 47.5, -122.25, 5, true);
assert(home._pocket.own_markers == 1);
assert(home.clean_count == 1);
home._pocket_zooming = home._pocket_dirty = true;
home.renderPocketMap();
checkView(home, 47.5, -122.25, 5, true);
assert(home._pocket_zooming && home._pocket_dirty && home.save_count == 0);
assert(home.clean_count == 1);
// Losing the location while zooming saves the pending preference once and
// releases the controls for page navigation. The footer changes this frame.
sensors = {0, 0};
home.renderPocketMap();
checkView(home, 0, 0, 20000, false);
assert(home._pocket.own_markers == 1);
assert(task.settings.map_zoom == 8 && !home._pocket_zooming && !home._pocket_dirty);
assert(home.save_count == 1);
assert(home.clean_count == 2);
home.renderPocketMap();
assert(home.save_count == 1 && home.clean_count == 2);
home._pocket_zooming = true;
home.renderPocketMap();
assert(!home._pocket_zooming && home.save_count == 1 && home.clean_count == 2);
// With no own position, the first valid contact supplies the center; later
// contacts do not move it. Invalid or unset contact locations are skipped.
// contacts do not move it. The saved 5 km range resumes for this case too.
home._pocket.markers = home._pocket.own_markers = 0;
sensors = {std::numeric_limits<double>::quiet_NaN(), 999};
the_mesh.contacts = {{0, 0, "Unset"}, {91000000, 1000000, "Invalid"},
{37500000, 139100000, "First"},
{48000000, -120000000, "Later"}};
home.renderPocketMap();
checkView(home, 37.5, 139.1, 200, true);
assert(home._pocket.markers == 3 && home._pocket.own_markers == 1);
checkView(home, 37.5, 139.1, 5, true);
assert(home._pocket.markers == 2 && home._pocket.own_markers == 0);
assert(home.clean_count == 3);
sensors = {47.5, -122.25};
home.renderPocketMap();
checkView(home, 47.5, -122.25, 5, true);
assert(home._pocket.own_markers == 1);
assert(home.clean_count == 3);
// Auto still chooses a 200 km local view and the world fallback.
task.settings = Settings{};
home.renderPocketMap();
checkView(home, 47.5, -122.25, 200, true);
assert(home._pocket.own_markers == 2);
sensors = {0, 0};
the_mesh.contacts.clear();
home.renderPocketMap();
checkView(home, 0, 0, 20000, false);
assert(home.clean_count == 4);
}
+15 -21
View File
@@ -97,7 +97,8 @@ static void settingsRoundTrip() {
assert(decodeSettings(migrated, bytes, sizeof(bytes)));
if (version == 1) old.refresh = 3;
assert(equal(migrated, old));
assert(mapZoomIndex(migrated, false) == zoom);
assert(mapZoomIndex(migrated, true) == zoom);
assert(mapZoomIndex(migrated, false) == 0);
}
}
// Auto was not a valid choice in these formats, even with a valid CRC.
@@ -151,17 +152,9 @@ static void mapZoomBehavior() {
Settings value;
assert(mapZoomIndex(value, true) == 3 && mapZoomIndex(value, false) == 0);
// With no position, Auto starts at the world overview. The first click
// must select the adjacent range rather than skip to the 100 km setting.
assert(zoomMap(value, true, false));
assert(value.map_zoom == 1 && mapHeightKm(value, false) == 5000);
assert(zoomMap(value, false, false));
assert(value.map_zoom == 0 && mapHeightKm(value, false) == 20000);
const Settings world = value;
assert(!zoomMap(value, false, false) && equal(value, world));
value = Settings{};
// Without a location, show the world and leave the zoom preference alone.
const Settings automatic = value;
assert(!zoomMap(value, true, false) && equal(value, automatic));
assert(!zoomMap(value, false, false) && equal(value, automatic));
assert(zoomMap(value, true, true));
assert(value.map_zoom == 4 && mapHeightKm(value, true) == 100);
@@ -169,18 +162,19 @@ static void mapZoomBehavior() {
assert(zoomMap(value, false, true));
assert(value.map_zoom == 2 && mapHeightKm(value, true) == 1000);
// Manual selections behave the same before and after a location arrives.
// Every manual selection resumes when a location arrives. Falling back to
// the overview must not replace a saved close range with the world range.
for (unsigned zoom = 0; zoom < 9; ++zoom) {
value.map_zoom = zoom;
for (bool located : {false, true}) {
assert(mapZoomIndex(value, located) == zoom);
assert(mapHeightKm(value, located) == heights[zoom]);
Settings closer = value, farther = value;
assert(zoomMap(closer, true, located) == (zoom < 8));
assert(closer.map_zoom == (zoom < 8 ? zoom + 1 : 8));
assert(zoomMap(farther, false, located) == (zoom > 0));
assert(farther.map_zoom == (zoom > 0 ? zoom - 1 : 0));
}
assert(mapZoomIndex(value, false) == 0 && mapHeightKm(value, false) == 20000);
Settings closer = value, farther = value;
assert(!zoomMap(closer, true, false) && equal(closer, value));
assert(!zoomMap(farther, false, false) && equal(farther, value));
assert(mapZoomIndex(value, true) == zoom && mapHeightKm(value, true) == heights[zoom]);
assert(zoomMap(closer, true, true) == (zoom < 8));
assert(closer.map_zoom == (zoom < 8 ? zoom + 1 : 8));
assert(zoomMap(farther, false, true) == (zoom > 0));
assert(farther.map_zoom == (zoom > 0 ? zoom - 1 : 0));
}
value.map_zoom = 8;
+4 -1
View File
@@ -93,13 +93,16 @@ class PocketGuiTest(unittest.TestCase):
"-DCOMPANION_FEATURE_READER=1", "-DUI_NO_DISCOVER_SCREEN=1",
"-Wno-unused-parameter"])
def test_map_page_honors_zoom_without_node_locations(self):
def test_map_page_shows_world_without_locations_and_preserves_zoom(self):
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
implementation = "\n".join(extract_braced(source, signature) for signature in (
"static bool pocketLocation(",
"__attribute__((noinline)) void renderPocketMap() {"))
fixture = (FIXTURE / "test_pocket_map_binding.cpp").read_text()
fixture = fixture.replace("// @MAP_IMPLEMENTATION@", implementation)
gui = (ROOT / "examples/companion_radio/ui-new/PocketGui.h").read_text()
fixture = fixture.replace("// @MAP_CHROME_IMPLEMENTATION@",
extract_braced(gui, "void renderMapChrome("))
with tempfile.TemporaryDirectory(prefix="mesh-pocket-map-binding-") as directory:
generated = Path(directory) / "map.cpp"
generated.write_text(fixture)