Show the world map when no node locations are available
This commit is contained in:
1 parent
25a98ae9f5
commit
d08e5f51ef
8 files changed
+138
-68
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user