diff --git a/lib/tdeck_ui/UI/LXMF/MapViewModel.cpp b/lib/tdeck_ui/UI/LXMF/MapViewModel.cpp new file mode 100644 index 00000000..8719d609 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapViewModel.cpp @@ -0,0 +1,123 @@ +#include "MapViewModel.h" + +#include + +namespace Pyxis { +namespace MapView { +namespace { + +bool validLocation(const Telemetry::LocationTelemetry& location) { + return location.latitude_e6 >= -90000000 && + location.latitude_e6 <= 90000000 && + location.longitude_e6 >= -180000000 && + location.longitude_e6 <= 180000000; +} + +bool validRequest(const Request& request) { + if (!(std::isfinite(request.center.latitude) && + std::isfinite(request.center.longitude) && + request.center.latitude >= -90.0 && + request.center.latitude <= 90.0 && + request.width != 0 && request.height != 0 && + MapProjection::isValidZoom(request.zoom) && + request.peer_count <= MAX_MAP_PEERS && + (request.peer_count == 0 || request.peers != nullptr))) { + return false; + } + if (request.has_local_location && + !validLocation(request.local_location)) return false; + for (std::size_t index = 0; index < request.peer_count; ++index) { + if (!validLocation(request.peers[index].location)) return false; + } + return true; +} + +MapProjection::GeoPoint pointFromLocation( + const Telemetry::LocationTelemetry& location) { + MapProjection::GeoPoint point{}; + point.latitude = static_cast(location.latitude_e6) / 1000000.0; + point.longitude = static_cast(location.longitude_e6) / 1000000.0; + return point; +} + +void appendMarker(const Telemetry::LocationTelemetry& location, + MarkerKind kind, + const Telemetry::PeerId& peer, + bool has_approx_radius, + std::uint32_t approx_radius_meters, + const MapProjection::Viewport& viewport, + std::uint32_t zoom, + Frame& output) { + MapProjection::MarkerProjection projected{}; + if (MapProjection::projectMarker( + pointFromLocation(location), viewport, zoom, projected) != + MapProjection::Status::OK || + !projected.visible) { + return; + } + Marker& marker = output.markers[output.marker_count++]; + marker.kind = kind; + marker.peer = peer; + marker.screen_x = projected.screen_x; + marker.screen_y = projected.screen_y; + marker.has_approx_radius = has_approx_radius; + marker.approx_radius_meters = approx_radius_meters; +} + +} // namespace + +Result buildFrame(const Request& request, Frame& output) { + if (!validRequest(request)) { + return request.peer_count > MAX_MAP_PEERS + ? Result::CAPACITY_EXCEEDED + : Result::INVALID_ARGUMENT; + } + + MapProjection::GlobalPixel center_pixel{}; + if (MapProjection::latLonToGlobalPixel( + request.center, request.zoom, center_pixel) != + MapProjection::Status::OK) { + return Result::INVALID_ARGUMENT; + } + + MapProjection::Viewport viewport{}; + viewport.left = center_pixel.x - static_cast(request.width) / 2.0; + viewport.top = center_pixel.y - static_cast(request.height) / 2.0; + viewport.width = request.width; + viewport.height = request.height; + + std::size_t tile_count = 0; + const MapProjection::Status tile_status = MapProjection::viewportTiles( + viewport, request.zoom, request.include_tile_border, + output.tiles, MapProjection::MAX_VIEWPORT_TILES, tile_count); + if (tile_status != MapProjection::Status::OK) { + return tile_status == MapProjection::Status::VIEWPORT_TOO_LARGE || + tile_status == MapProjection::Status::CAPACITY_EXCEEDED + ? Result::VIEWPORT_TOO_LARGE + : Result::INVALID_ARGUMENT; + } + + output.viewport = viewport; + output.tile_count = tile_count; + output.marker_count = 0; + const Telemetry::PeerId empty_peer{}; + if (request.has_local_location) { + appendMarker(request.local_location, MarkerKind::LOCAL, empty_peer, + false, 0, viewport, request.zoom, output); + } + for (std::size_t index = 0; index < request.peer_count; ++index) { + const Telemetry::PeerLocationRecord& record = request.peers[index]; + if (record.has_expiry && + request.wall_now_millis >= record.expires_at_millis) { + continue; + } + appendMarker(record.location, MarkerKind::PEER, record.peer, + record.has_approx_radius, + record.approx_radius_meters, + viewport, request.zoom, output); + } + return Result::OK; +} + +} // namespace MapView +} // namespace Pyxis diff --git a/lib/tdeck_ui/UI/LXMF/MapViewModel.h b/lib/tdeck_ui/UI/LXMF/MapViewModel.h new file mode 100644 index 00000000..9caebacb --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapViewModel.h @@ -0,0 +1,67 @@ +#ifndef PYXIS_UI_LXMF_MAP_VIEW_MODEL_H +#define PYXIS_UI_LXMF_MAP_VIEW_MODEL_H + +#include +#include + +#include "MapProjection.h" +#include "Telemetry/LocationShareState.h" + +namespace Pyxis { +namespace MapView { + +enum { + MAX_MAP_PEERS = Telemetry::MAX_PEER_LOCATIONS, + MAX_MAP_MARKERS = Telemetry::MAX_PEER_LOCATIONS + 1 +}; + +enum class Result : std::uint8_t { + OK, + INVALID_ARGUMENT, + CAPACITY_EXCEEDED, + VIEWPORT_TOO_LARGE +}; + +enum class MarkerKind : std::uint8_t { + LOCAL, + PEER +}; + +struct Marker { + MarkerKind kind = MarkerKind::PEER; + Telemetry::PeerId peer{}; + double screen_x = 0.0; + double screen_y = 0.0; + bool has_approx_radius = false; + std::uint32_t approx_radius_meters = 0; +}; + +struct Request { + MapProjection::GeoPoint center{}; + std::uint32_t zoom = 0; + std::uint32_t width = 0; + std::uint32_t height = 0; + bool include_tile_border = false; + bool has_local_location = false; + Telemetry::LocationTelemetry local_location{}; + const Telemetry::PeerLocationRecord* peers = nullptr; + std::size_t peer_count = 0; + std::uint64_t wall_now_millis = 0; +}; + +struct Frame { + MapProjection::Viewport viewport{}; + MapProjection::TilePlacement tiles[MapProjection::MAX_VIEWPORT_TILES]{}; + std::size_t tile_count = 0; + Marker markers[MAX_MAP_MARKERS]{}; + std::size_t marker_count = 0; +}; + +// Builds into caller-owned fixed storage. Invalid/capacity failures leave output +// unchanged. Expiry is exclusive: a peer is hidden when wall_now >= expiry. +Result buildFrame(const Request& request, Frame& output); + +} // namespace MapView +} // namespace Pyxis + +#endif diff --git a/tests/native/test_map_view_model.cpp b/tests/native/test_map_view_model.cpp new file mode 100644 index 00000000..05e9bfc4 --- /dev/null +++ b/tests/native/test_map_view_model.cpp @@ -0,0 +1,110 @@ +#include +#include +#include +#include + +#include "UI/LXMF/MapViewModel.h" + +namespace { +int passed = 0; +int failures = 0; +#define CHECK(expr) do { if (expr) { ++passed; } else { ++failures; std::cerr << "FAIL line " << __LINE__ << ": " #expr << '\n'; } } while (false) + +Telemetry::PeerId peer(uint8_t seed) { + Telemetry::PeerId id{}; + for (std::size_t i = 0; i < Telemetry::PEER_ID_SIZE; ++i) { + id.bytes[i] = static_cast(seed + i); + } + return id; +} + +bool samePeer(const Telemetry::PeerId& left, const Telemetry::PeerId& right) { + for (std::size_t i = 0; i < Telemetry::PEER_ID_SIZE; ++i) { + if (left.bytes[i] != right.bytes[i]) return false; + } + return true; +} + +Telemetry::LocationTelemetry fix(double latitude, double longitude) { + Telemetry::LocationTelemetry value{}; + value.latitude_e6 = static_cast(latitude * 1000000.0); + value.longitude_e6 = static_cast(longitude * 1000000.0); + value.timestamp_seconds = 1700000000ULL; + return value; +} + +void buildsBoundedTilesAndVisibleMarkers() { + Pyxis::MapView::Request request{}; + request.center = {0.0, 179.9}; + request.zoom = 3; + request.width = 320; + request.height = 200; + request.include_tile_border = true; + request.local_location = fix(0.0, 179.9); + request.has_local_location = true; + request.wall_now_millis = 1700000000000ULL; + + Telemetry::PeerLocationRecord peers[3]{}; + peers[0].peer = peer(1); + peers[0].location = fix(0.0, -179.9); // visible across antimeridian + peers[0].has_approx_radius = true; + peers[0].approx_radius_meters = 0; + peers[1].peer = peer(2); + peers[1].location = fix(80.0, 0.0); // outside viewport + peers[2].peer = peer(3); + peers[2].location = fix(0.0, 179.8); + peers[2].has_expiry = true; + peers[2].expires_at_millis = request.wall_now_millis; // exclusive expiry + request.peers = peers; + request.peer_count = 3; + + Pyxis::MapView::Frame frame{}; + CHECK(Pyxis::MapView::buildFrame(request, frame) == + Pyxis::MapView::Result::OK); + CHECK(frame.tile_count > 0); + CHECK(frame.tile_count <= Pyxis::MapProjection::MAX_VIEWPORT_TILES); + CHECK(frame.marker_count == 2); + CHECK(frame.markers[0].kind == Pyxis::MapView::MarkerKind::LOCAL); + CHECK(frame.markers[1].kind == Pyxis::MapView::MarkerKind::PEER); + CHECK(samePeer(frame.markers[1].peer, peer(1))); + CHECK(frame.markers[1].has_approx_radius); + CHECK(frame.markers[1].approx_radius_meters == 0); +} + +void rejectsInvalidAndLeavesOutputUnchanged() { + Pyxis::MapView::Request request{}; + request.center = {0.0, 0.0}; + request.zoom = Pyxis::MapProjection::MAX_ZOOM + 1; + request.width = 320; + request.height = 200; + + Pyxis::MapView::Frame frame{}; + frame.tile_count = 17; + frame.marker_count = 9; + CHECK(Pyxis::MapView::buildFrame(request, frame) == + Pyxis::MapView::Result::INVALID_ARGUMENT); + CHECK(frame.tile_count == 17); + CHECK(frame.marker_count == 9); + + request.zoom = 3; + request.peer_count = Pyxis::MapView::MAX_MAP_PEERS + 1; + CHECK(Pyxis::MapView::buildFrame(request, frame) == + Pyxis::MapView::Result::CAPACITY_EXCEEDED); + CHECK(frame.tile_count == 17); + + request.peer_count = 0; + request.has_local_location = true; + request.local_location.latitude_e6 = 90000001; + CHECK(Pyxis::MapView::buildFrame(request, frame) == + Pyxis::MapView::Result::INVALID_ARGUMENT); + CHECK(frame.tile_count == 17); +} +} // namespace + +int main() { + buildsBoundedTilesAndVisibleMarkers(); + rejectsInvalidAndLeavesOutputUnchanged(); + std::cout << "map view model: " << passed << " passed, " << failures + << " failed\n"; + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/native/test_map_view_model.py b/tests/native/test_map_view_model.py new file mode 100644 index 00000000..ac04c177 --- /dev/null +++ b/tests/native/test_map_view_model.py @@ -0,0 +1,23 @@ +"""Compile and execute the fixed-capacity map view-model tests.""" +from pathlib import Path + +from native_test import compile_and_run + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def test_map_view_model(tmp_path): + ran = compile_and_run( + tmp_path, + name="test_map_view_model", + sources=[ + HERE / "test_map_view_model.cpp", + ROOT / "lib/tdeck_ui/UI/LXMF/MapViewModel.cpp", + ROOT / "lib/tdeck_ui/UI/LXMF/MapProjection.cpp", + ], + include_dirs=[ROOT / "lib/tdeck_ui"], + sanitize=True, + timeout=60, + ) + assert "0 failed" in ran.stdout