From 578bedcbad92a64cb44b6acd5c5fe7565a33799d Mon Sep 17 00:00:00 2001 From: Trail Mate Dev Date: Tue, 15 Sep 2026 00:00:57 +0800 Subject: [PATCH] Load offline POI tiles through the shared map worker with bounded PSRAM storage --- docs/uiux/components/offline_poi_layer.md | 57 +++++ .../map_poi/poi_snapshot_builder.h | 20 ++ .../ui_map_runtime/map_poi/poi_tile_source.h | 28 +++ .../ui_map_runtime/map_poi/poi_types.h | 65 ++++++ .../ui_map_runtime/map_tiles/map_tile_types.h | 3 + .../src/map_poi/poi_snapshot_builder.cpp | 60 +++++ .../src/map_poi/poi_tile_source.cpp | 94 ++++++++ .../src/map_tiles/map_tile_resolver.cpp | 10 +- .../ui_presentation/map/map_poi_snapshot.h | 35 +++ .../include/ui/widgets/map/poi_overlay.h | 40 ++++ .../src/ui/widgets/map/map_viewport.cpp | 75 +++++++ .../src/ui/widgets/map/poi_overlay.cpp | 193 ++++++++++++++++ .../arduino_common/map_poi/cjson_poi_parser.h | 12 + .../include/ui/widgets/map/map_tiles.h | 27 +++ .../src/map_poi/cjson_poi_parser.cpp | 140 ++++++++++++ .../src/ui/widgets/map/map_tiles.cpp | 206 +++++++++++++++++- tests/map_poi/CMakeLists.txt | 26 +++ tests/map_poi/README.md | 31 +++ tests/map_poi/test_poi.cpp | 169 ++++++++++++++ tests/map_poi_view/CMakeLists.txt | 18 ++ tests/map_poi_view/lv_conf.h | 14 ++ .../stubs/ui/assets/fonts/font_utils.h | 9 + tests/map_poi_view/test_view.cpp | 86 ++++++++ 23 files changed, 1409 insertions(+), 9 deletions(-) create mode 100644 docs/uiux/components/offline_poi_layer.md create mode 100644 modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_snapshot_builder.h create mode 100644 modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_tile_source.h create mode 100644 modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_types.h create mode 100644 modules/ui_map_runtime/src/map_poi/poi_snapshot_builder.cpp create mode 100644 modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp create mode 100644 modules/ui_presentation/include/ui_presentation/map/map_poi_snapshot.h create mode 100644 modules/ui_shared/include/ui/widgets/map/poi_overlay.h create mode 100644 modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/map_poi/cjson_poi_parser.h create mode 100644 platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp create mode 100644 tests/map_poi/CMakeLists.txt create mode 100644 tests/map_poi/README.md create mode 100644 tests/map_poi/test_poi.cpp create mode 100644 tests/map_poi_view/CMakeLists.txt create mode 100644 tests/map_poi_view/lv_conf.h create mode 100644 tests/map_poi_view/stubs/ui/assets/fonts/font_utils.h create mode 100644 tests/map_poi_view/test_view.cpp diff --git a/docs/uiux/components/offline_poi_layer.md b/docs/uiux/components/offline_poi_layer.md new file mode 100644 index 00000000..9cc4d3c5 --- /dev/null +++ b/docs/uiux/components/offline_poi_layer.md @@ -0,0 +1,57 @@ +# Offline POI layer: architecture baseline + +Offline POIs are spatially indexed map-layer data, not contacts or team state. +Their XYZ index is a vector tile payload, not a PNG image. The manifest is +dataset metadata, not a synthetic tile at a reserved coordinate. + +The existing shared map path owns this feature: + +1. `ui_shared` map viewport supplies the camera and owns the overlay host. +2. The ESP tile backend uses the existing visible tile records and generation. +3. POI tile requests use the existing map command queue, worker, event queue, + stale-generation rejection and viewport lease lifecycle. +4. Worker-side POI source reads metadata/indexes through `IMapTileFileSystem`, + whose ESP implementation uses the existing SD runtime. No private SPI + ownership, direct SdFat access, or LVGL-callback file reads are introduced. +5. The worker parses JSON into a bounded typed payload. The UI never parses JSON. +6. Accepted tile records own their POI payload with RAII. Viewport snapshots are + bounded; projection uses the map backend's existing geographical projection. +7. The shared viewport displays fixed-size markers and a limited set of labels + with overlap checks, separately from the page's own position/team overlays. + +PNG and POI share scheduling/lifecycle, but have different payload formats and +decoders. A bad or missing POI resource must not mark the PNG tile missing. +Unselected zoom levels clear POIs immediately, without reusing adjacent levels. +Manifest v2's explicit `enabled_zoom_levels` wins, including an empty array; +v1 min/max is supported only as a migration rule. + +No GPS-page-only loader, second Mercator implementation, UI-side SD polling, +new POI thread, or unrelated firmware/USB behavior belongs in this change. + +## Memory constraints + +- No fixed 200-record object, whole-region catalog, or embedded large snapshot array. +- The worker borrows its existing PSRAM scratch for JSON and parsed records. +- A small header is followed by exactly the parsed record count. The existing + event queue allocates that exact size in PSRAM only and transfers ownership + directly to the tile record, without a second copy/allocation. +- Invisible tile POI buffers are evicted by the existing cache eviction stage; + deleting tile records or leaving the map releases ownership automatically. +- Presentation metadata is small; its item span is allocated on demand in PSRAM + and released when POIs are disabled, absent, or the viewport is destroyed. +- PSRAM failure suppresses/retries POIs; it never falls back to a large internal + RAM allocation. Only small handles, counters and bounded transient parser + allocations use the normal framework allocation path. +- The POI view has one LVGL object, no per-marker widgets, no label pool and no + additional framebuffer. It borrows the viewport snapshot. Its bounded label + layout uses bit masks instead of arrays; the view itself is at most 64 bytes. +- LVGL owns short transient label copies for asynchronous draw tasks. Clearing + the view detaches the borrowed snapshot before its owner releases PSRAM. + +## Hybrid map policy + +The Kunming verification package uses baked raster text at z1–15 and independent +POIs with completely text-free raster tiles at z16–18. The firmware reads the +package's explicit level set; it does not hard-code those levels. An empty set +disables all independent POIs. Regional PBF source coverage may leave low-zoom +world context incomplete; firmware must not fabricate labels for it. diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_snapshot_builder.h b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_snapshot_builder.h new file mode 100644 index 00000000..371528e9 --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_snapshot_builder.h @@ -0,0 +1,20 @@ +#pragma once +#include "ui_map_runtime/map_poi/poi_types.h" +#include "ui_presentation/map/map_poi_snapshot.h" + +namespace ui::map_poi +{ +class SnapshotBuilder +{ + public: + SnapshotBuilder(ui::map::MapPoiSnapshot& out, int width, int height); + void add(const Record& record, int x, int y); + void finish(); + + private: + ui::map::MapPoiSnapshot& out_; + int width_; + int height_; + bool better(uint8_t priority, int x, int y, const char* id, const ui::map::MapPoiItem& other) const; +}; +} // namespace ui::map_poi diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_tile_source.h b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_tile_source.h new file mode 100644 index 00000000..a0faa7b5 --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_tile_source.h @@ -0,0 +1,28 @@ +#pragma once + +#include "ui_map_runtime/map_poi/poi_types.h" +#include "ui_map_runtime/map_tiles/map_tile_resolver.h" +#include "ui_map_runtime/map_tiles/map_tile_source.h" + +namespace ui::map_poi +{ +// Worker-owned source. Uses the same filesystem boundary as raster tiles; +// returns typed POI data so the UI does no JSON parsing or file access. +class PoiTileSource final : public map_tiles::IMapTileSource +{ + public: + PoiTileSource(map_tiles::IMapTileFileSystem& files, const Parser& parser, const char* root = "/"); + map_tiles::MapTileLookupResult lookup(const map_tiles::MapTileRef& ref) const override; + map_tiles::MapTileReadResult read(const map_tiles::MapTileRef& ref, uint8_t* buffer, std::size_t capacity) const override; + void reset(); + + private: + map_tiles::IMapTileFileSystem& files_; + const Parser& parser_; + const char* root_; // Borrowed; the root string must outlive this source. + mutable bool policy_checked_ = false; + mutable bool policy_valid_ = false; + mutable Policy policy_{}; +}; +static_assert(sizeof(PoiTileSource) <= 64, "POI source must not retain path or JSON buffers"); +} // namespace ui::map_poi diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_types.h b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_types.h new file mode 100644 index 00000000..1d47321c --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/poi_types.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include + +namespace ui::map_poi +{ +struct Policy +{ + uint32_t enabled_levels = 0; + bool labels = true; + bool enabled(int zoom) const { return zoom >= 0 && zoom <= 18 && (enabled_levels & (1UL << zoom)) != 0; } +}; + +struct Record +{ + char id[48]{}; + char name[80]{}; + char category[16]{}; + double lat = 0; + double lon = 0; + uint8_t priority = 0; +}; + +// Small header followed by exactly `count` Records in an owned PSRAM buffer. +// There is deliberately no fixed array or large data object in this type. +struct alignas(8) TileHeader +{ + static constexpr uint32_t kMagic = 0x504F4932U; + static constexpr std::size_t kMaxRecords = 200; + uint32_t magic = kMagic; + Policy policy{}; + uint16_t count = 0; + uint16_t invalid_rows = 0; + bool truncated = false; + bool manifest_valid = false; +}; + +static_assert(sizeof(TileHeader) <= 32, "POI metadata must stay small"); +static_assert(sizeof(Record) <= 168, "POI records must remain compact"); +static_assert(std::is_trivially_copyable::value, "POI payload records have no hidden allocations"); + +inline const Record* payloadRecords(const uint8_t* data) +{ + return reinterpret_cast(data + sizeof(TileHeader)); +} +inline bool validPayload(const uint8_t* data, std::size_t size) +{ + if (!data || size < sizeof(TileHeader)) return false; + TileHeader header{}; + std::memcpy(&header, data, sizeof(header)); + return header.magic == TileHeader::kMagic && header.count <= TileHeader::kMaxRecords && + size == sizeof(TileHeader) + header.count * sizeof(Record); +} + +class Parser +{ + public: + virtual ~Parser() = default; + virtual bool manifest(const char* json, std::size_t size, Policy& out) const = 0; + virtual bool record(const char* json, std::size_t size, Record& out) const = 0; +}; +} // namespace ui::map_poi diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_types.h b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_types.h index 7c5725c0..38960be9 100644 --- a/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_types.h +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_tiles/map_tile_types.h @@ -24,12 +24,15 @@ enum class MapTileLayer : uint8_t ContourMinor20, ContourMinor10, ContourMinor5, + Poi, }; enum class MapTileFormat : uint8_t { Unknown, Png, + Jsonl, + PoiRecords, }; enum class MapTileStatus : uint8_t diff --git a/modules/ui_map_runtime/src/map_poi/poi_snapshot_builder.cpp b/modules/ui_map_runtime/src/map_poi/poi_snapshot_builder.cpp new file mode 100644 index 00000000..2a67a473 --- /dev/null +++ b/modules/ui_map_runtime/src/map_poi/poi_snapshot_builder.cpp @@ -0,0 +1,60 @@ +#include "ui_map_runtime/map_poi/poi_snapshot_builder.h" +#include +#include +#include + +namespace ui::map_poi +{ +SnapshotBuilder::SnapshotBuilder(ui::map::MapPoiSnapshot& out, int width, int height) + : out_(out), width_(width), height_(height) { out_.item_count = 0; } + +bool SnapshotBuilder::better(uint8_t priority, int x, int y, const char* id, const ui::map::MapPoiItem& other) const +{ + if (priority != other.priority) return priority > other.priority; + const int ax = x - width_ / 2, ay = y - height_ / 2; + const int bx = other.x - width_ / 2, by = other.y - height_ / 2; + const int ad = ax * ax + ay * ay, bd = bx * bx + by * by; + return ad != bd ? ad < bd : std::strcmp(id, other.id.c_str()) < 0; +} + +void SnapshotBuilder::add(const Record& record, int x, int y) +{ + if (!out_.items || out_.capacity == 0) return; + if (x < 0 || y < 0 || x >= width_ || y >= height_) return; + for (std::size_t i = 0; i < out_.item_count; ++i) + if (std::strcmp(record.id, out_.items[i].id.c_str()) == 0) return; + std::size_t slot = out_.item_count; + if (slot == std::min(out_.capacity, ui::map::MapPoiSnapshot::kMaxItems)) + { + out_.truncated = true; + slot = 0; + for (std::size_t i = 1; i < out_.item_count; ++i) + if (better(out_.items[slot].priority, out_.items[slot].x, out_.items[slot].y, out_.items[slot].id.c_str(), out_.items[i])) slot = i; + if (!better(record.priority, x, y, record.id, out_.items[slot])) return; + } + else ++out_.item_count; + auto& item = out_.items[slot]; + ui::copyText(item.id, record.id); + ui::copyText(item.label, record.name); + ui::copyText(item.category, record.category); + item.x = static_cast(x); + item.y = static_cast(y); + item.priority = record.priority; +} + +void SnapshotBuilder::finish() +{ + if (!out_.items) return; + std::sort(out_.items, out_.items + out_.item_count, [this](const auto& a, const auto& b) + { return better(a.priority, a.x, a.y, a.id.c_str(), b); }); + std::size_t count = 0; + for (std::size_t i = 0; i < out_.item_count; ++i) + { + bool overlaps = false; + for (std::size_t j = 0; j < count; ++j) + if (std::abs(out_.items[i].x - out_.items[j].x) < 18 && std::abs(out_.items[i].y - out_.items[j].y) < 18) overlaps = true; + if (!overlaps) out_.items[count++] = out_.items[i]; + } + out_.item_count = count; +} +} // namespace ui::map_poi diff --git a/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp b/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp new file mode 100644 index 00000000..919d874b --- /dev/null +++ b/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp @@ -0,0 +1,94 @@ +#include "ui_map_runtime/map_poi/poi_tile_source.h" + +#include +#include +#include +#include + +namespace ui::map_poi +{ +PoiTileSource::PoiTileSource(map_tiles::IMapTileFileSystem& files, const Parser& parser, const char* root) + : files_(files), parser_(parser), root_(root ? root : "") +{ +} + +void PoiTileSource::reset() +{ + policy_checked_ = policy_valid_ = false; + policy_ = {}; +} + +map_tiles::MapTileLookupResult PoiTileSource::lookup(const map_tiles::MapTileRef& ref) const +{ + char path[160]{}; + map_tiles::MapTileLookupResult result{}; + result.format = map_tiles::MapTileFormat::PoiRecords; + const map_tiles::MapTileResolver resolver(root_); + result.status = resolver.resolvePath(ref, path, sizeof(path)) && files_.exists(path) + ? map_tiles::MapTileStatus::Available + : map_tiles::MapTileStatus::Missing; + return result; +} + +map_tiles::MapTileReadResult PoiTileSource::read(const map_tiles::MapTileRef& ref, uint8_t* buffer, std::size_t capacity) const +{ + using namespace map_tiles; + constexpr std::size_t records_bytes = sizeof(TileHeader) + (TileHeader::kMaxRecords + 1) * sizeof(Record); + if (ref.layer != MapTileLayer::Poi || !buffer || capacity < records_bytes + 16384 || + reinterpret_cast(buffer) % alignof(TileHeader) != 0) + return {MapTileReadStatus::Invalid, 0, -1, MapTileFormat::PoiRecords}; + auto* raw = buffer + records_bytes; + const auto raw_capacity = capacity - records_bytes; + char path[160]{}; + if (!policy_checked_) + { + const auto length = std::strlen(root_); + const int written = std::snprintf(path, sizeof(path), "%s%smaps/poi/manifest.json", root_, + length && root_[length - 1] != '/' ? "/" : ""); + if (written < 0 || static_cast(written) >= sizeof(path)) + return {MapTileReadStatus::Invalid, 0, -2, MapTileFormat::PoiRecords}; + const auto read = files_.readFile(path, raw, raw_capacity); + if (read.status == MapTileReadStatus::RetryLater) return read; + policy_checked_ = true; + policy_valid_ = read.status == MapTileReadStatus::Ready && + parser_.manifest(reinterpret_cast(raw), read.size, policy_); + } + auto* output = new (buffer) TileHeader{}; + output->policy = policy_; + output->manifest_valid = policy_valid_; + const auto ready = [&]() + { + return MapTileReadResult{MapTileReadStatus::Ready, sizeof(TileHeader) + output->count * sizeof(Record), + 0, MapTileFormat::PoiRecords}; + }; + // Explicitly disabled levels and absent/invalid packages never load indexes. + if (!policy_valid_ || !policy_.enabled(ref.z)) return ready(); + const MapTileResolver resolver(root_); + if (!resolver.resolvePath(ref, path, sizeof(path))) return {MapTileReadStatus::Invalid, 0, -2, MapTileFormat::PoiRecords}; + const auto read = files_.readFile(path, raw, raw_capacity); + if (read.status == MapTileReadStatus::Missing) return ready(); + if (read.status != MapTileReadStatus::Ready) return read; + if (read.size > raw_capacity) return {MapTileReadStatus::Invalid, 0, -3, MapTileFormat::PoiRecords}; + std::size_t offset = 0; + while (offset < read.size) + { + const auto begin = offset; + while (offset < read.size && raw[offset] != '\n') ++offset; + auto length = offset - begin; + if (offset < read.size) ++offset; + if (length && raw[begin + length - 1] == '\r') --length; + if (length == 0) continue; + // Parsed records borrow the existing map worker scratch; even overflow + // parsing uses its extra slot, not a resident or task-stack buffer. + auto* target = new (buffer + sizeof(TileHeader) + output->count * sizeof(Record)) Record{}; + if (!parser_.record(reinterpret_cast(raw + begin), length, *target)) + { + if (output->invalid_rows < UINT16_MAX) ++output->invalid_rows; + continue; + } + if (output->count < TileHeader::kMaxRecords) ++output->count; + else output->truncated = true; + } + return ready(); +} +} // namespace ui::map_poi diff --git a/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp b/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp index b209a658..04e05d52 100644 --- a/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp +++ b/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp @@ -55,8 +55,7 @@ const char* contourLayerDir(MapTileLayer layer) const char* extensionFor(MapTileLayer layer) { - (void)layer; - return "png"; + return layer == MapTileLayer::Poi ? "jsonl" : "png"; } bool writePath(char* out_path, @@ -191,8 +190,7 @@ MapTileLayer mapTileContourLayerForZoom(int zoom, bool* out_supported) MapTileFormat mapTileFormatForLayer(MapTileLayer layer) { - (void)layer; - return MapTileFormat::Png; + return layer == MapTileLayer::Poi ? MapTileFormat::Jsonl : MapTileFormat::Png; } bool mapTileLayerIsContour(MapTileLayer layer) @@ -231,6 +229,8 @@ bool MapTileResolver::resolvePath(const MapTileRef& ref, char* out_path, std::size_t out_size) const { + if (ref.layer == MapTileLayer::Poi) + return writePath(out_path, out_size, root_prefix_, "maps/poi", "index", ref.z, ref.x, ref.y, extensionFor(ref.layer)); if (mapTileLayerIsContour(ref.layer)) { return writePath(out_path, @@ -259,6 +259,8 @@ bool MapTileResolver::resolveDirectory(MapTileLayer layer, char* out_path, std::size_t out_size) const { + if (layer == MapTileLayer::Poi) + return writeDirectory(out_path, out_size, root_prefix_, "maps/poi", "index"); if (mapTileLayerIsContour(layer)) { return writeDirectory(out_path, diff --git a/modules/ui_presentation/include/ui_presentation/map/map_poi_snapshot.h b/modules/ui_presentation/include/ui_presentation/map/map_poi_snapshot.h new file mode 100644 index 00000000..4ed02edd --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/map/map_poi_snapshot.h @@ -0,0 +1,35 @@ +#pragma once + +#include "ui_presentation/common/fixed_text.h" +#include "ui_presentation/common/snapshot_header.h" + +#include +#include + +namespace ui::map +{ +struct MapPoiItem +{ + ui::FixedText<48> id; + ui::FixedText<80> label; + ui::FixedText<16> category; + int16_t x = 0; + int16_t y = 0; + uint8_t priority = 0; +}; + +// Caller-owned snapshot: never returned by value on a small ESP task stack. +struct MapPoiSnapshot +{ + static constexpr std::size_t kMaxItems = 48; + ui::SnapshotHeader header; + bool enabled = false; + bool labels = true; + bool truncated = false; + std::size_t candidate_count = 0; + std::size_t item_count = 0; + std::size_t capacity = 0; + MapPoiItem* items = nullptr; // Caller-owned, on-demand PSRAM storage on ESP. +}; +static_assert(sizeof(MapPoiSnapshot) <= 80, "POI snapshots must not embed large buffers"); +} // namespace ui::map diff --git a/modules/ui_shared/include/ui/widgets/map/poi_overlay.h b/modules/ui_shared/include/ui/widgets/map/poi_overlay.h new file mode 100644 index 00000000..b0a3a749 --- /dev/null +++ b/modules/ui_shared/include/ui/widgets/map/poi_overlay.h @@ -0,0 +1,40 @@ +#pragma once +#include "lvgl.h" +#include "ui_presentation/map/map_poi_snapshot.h" + +namespace ui::widgets::map +{ +// Pure LVGL view: accepts a prepared snapshot, owns no filesystem or parser. +class PoiOverlay +{ + public: + PoiOverlay() = default; + ~PoiOverlay(); + PoiOverlay(const PoiOverlay&) = delete; + PoiOverlay& operator=(const PoiOverlay&) = delete; + void create(lv_obj_t* parent); + void update(const ui::map::MapPoiSnapshot& snapshot); + void clear(); + void translate(int dx, int dy); + + private: + static constexpr std::size_t kLabels = 20; + struct Rect + { + int x, y, w, h; + }; + lv_obj_t* layer_ = nullptr; + const ui::map::MapPoiSnapshot* snapshot_ = nullptr; // Viewport-owned PSRAM data. + const lv_font_t* font_ = nullptr; + uint64_t label_mask_ = 0; + uint64_t sides_low_ = 0; + uint32_t sides_high_ = 0; + static void on_delete(lv_event_t* event); + static void on_draw(lv_event_t* event); + static bool overlaps(const Rect& a, const Rect& b); + bool label_fits(const Rect& rect, const ui::map::MapPoiSnapshot& snapshot, std::size_t count, int width, int height) const; + bool label_rect(const ui::map::MapPoiItem& item, unsigned side, int width, Rect& out) const; + unsigned side_for(std::size_t index) const; +}; +static_assert(sizeof(PoiOverlay) <= 64, "POI view must not retain widget pools or bulk buffers"); +} // namespace ui::widgets::map diff --git a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp index 2fa6b483..946b64ef 100644 --- a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp +++ b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp @@ -11,7 +11,11 @@ #include "platform/ui/device_runtime.h" #include "ui/localization.h" #include "ui/widgets/map/map_tiles.h" +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) +#include "ui/widgets/map/poi_overlay.h" +#endif +#include #include #include #include @@ -26,6 +30,13 @@ namespace ui::widgets::map { +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) +struct PoiSnapshotDeleter +{ + void operator()(ui::map::MapPoiItem* data) const noexcept { heap_caps_free(data); } +}; +#endif + struct RuntimeImpl { #if defined(ESP_PLATFORM) @@ -52,6 +63,13 @@ struct RuntimeImpl Widgets widgets{}; Model model{}; ui::map::MapOverlaySnapshot overlay{}; +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + PoiOverlay poi_overlay; + ui::map::MapPoiSnapshot poi_snapshot{}; + std::unique_ptr poi_storage; + uint32_t poi_seen_revision = 0; + uint32_t poi_allocation_retry_ms = 0; +#endif MapAnchor anchor{}; std::vector tiles{}; ui::map_tiles::MapTileRenderQueue render_queue{}; @@ -654,6 +672,45 @@ void render_overlay(RuntimeImpl& impl) } } +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) +void refresh_poi_overlay(RuntimeImpl& impl, bool force) +{ + if (!force && impl.poi_seen_revision == impl.tile_ctx.poi_revision) return; + map_poi_snapshot(impl.tile_ctx, impl.poi_snapshot); + if (!impl.poi_snapshot.enabled || impl.poi_snapshot.candidate_count == 0) + { + impl.poi_overlay.clear(); + impl.poi_storage.reset(); + impl.poi_snapshot.items = nullptr; + impl.poi_snapshot.capacity = 0; + impl.poi_seen_revision = impl.tile_ctx.poi_revision; + return; + } + const auto needed = std::min(impl.poi_snapshot.candidate_count, ui::map::MapPoiSnapshot::kMaxItems); + if (impl.poi_snapshot.capacity < needed) + { + const uint32_t now = lv_tick_get(); + if (impl.poi_allocation_retry_ms && static_cast(impl.poi_allocation_retry_ms - now) > 0) return; + auto* buffer = static_cast(heap_caps_malloc(needed * sizeof(ui::map::MapPoiItem), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!buffer) + { + impl.poi_overlay.clear(); + impl.poi_allocation_retry_ms = now + 1000U; + std::printf("[MapViewport][POI] snapshot PSRAM unavailable\n"); + return; + } + for (std::size_t i = 0; i < needed; ++i) new (buffer + i) ui::map::MapPoiItem{}; + impl.poi_storage.reset(buffer); + impl.poi_snapshot.items = buffer; + impl.poi_snapshot.capacity = needed; + impl.poi_allocation_retry_ms = 0; + map_poi_snapshot(impl.tile_ctx, impl.poi_snapshot); + } + impl.poi_overlay.update(impl.poi_snapshot); + impl.poi_seen_revision = impl.tile_ctx.poi_revision; +} +#endif + void loader_timer_cb(lv_timer_t* timer) { auto* impl = static_cast(lv_timer_get_user_data(timer)); @@ -682,6 +739,9 @@ void loader_timer_cb(lv_timer_t* timer) if (!impl->loader_paused) { tile_loader_step(impl->tile_ctx); +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + refresh_poi_overlay(*impl, false); +#endif } uint8_t missing_source = 0; @@ -773,6 +833,9 @@ Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t loader_interval_ms) lv_obj_set_pos(impl->widgets.tile_layer, 0, 0); make_plain(impl->widgets.tile_layer); +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + impl->poi_overlay.create(impl->widgets.root); +#endif impl->widgets.overlay_layer = lv_obj_create(impl->widgets.root); lv_obj_set_size(impl->widgets.overlay_layer, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(impl->widgets.overlay_layer, 0, 0); @@ -903,6 +966,9 @@ void apply_model(Runtime& runtime, const Model& model) static_cast(impl->model.coord_system)); refresh_tiles(*impl, "apply_model", true); render_overlay(*impl); +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + refresh_poi_overlay(*impl, true); +#endif } void apply_model_lightweight(Runtime& runtime, const Model& model) @@ -929,6 +995,9 @@ void apply_model_lightweight(Runtime& runtime, const Model& model) static_cast(impl->model.coord_system)); translate_loaded_tiles(*impl, dx, dy); translate_children(impl->widgets.overlay_layer, dx, dy); +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + impl->poi_overlay.translate(dx, dy); +#endif impl->drag_preview_active = true; } @@ -955,6 +1024,12 @@ void clear(Runtime& runtime) impl->model.focus_point = GeoPoint{}; impl->overlay = ui::map::MapOverlaySnapshot{}; clear_overlay_layer(*impl); +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + impl->poi_overlay.clear(); + impl->poi_storage.reset(); + impl->poi_snapshot.items = nullptr; + impl->poi_snapshot.capacity = 0; +#endif cleanup_tiles(impl->tile_ctx); impl->anchor.valid = false; impl->drag_preview_active = false; diff --git a/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp b/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp new file mode 100644 index 00000000..b7bea9c2 --- /dev/null +++ b/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp @@ -0,0 +1,193 @@ +#include "ui/widgets/map/poi_overlay.h" +#include "ui/assets/fonts/font_utils.h" +#include +#include + +namespace ui::widgets::map +{ +namespace +{ +constexpr int kMarker = 18; +const char* symbol(const char* category) +{ + if (std::strcmp(category, "water") == 0) return "W"; + if (std::strcmp(category, "camp") == 0) return "C"; + if (std::strcmp(category, "shelter") == 0) return "H"; + if (std::strcmp(category, "peak") == 0) return "^"; + if (std::strcmp(category, "viewpoint") == 0) return "V"; + if (std::strcmp(category, "parking") == 0) return "P"; + if (std::strcmp(category, "toilet") == 0) return "T"; + if (std::strcmp(category, "emergency") == 0) return "+"; + if (std::strcmp(category, "trailhead") == 0) return "h"; + return "i"; +} +lv_color_t color(const char* category) +{ + if (std::strcmp(category, "water") == 0) return lv_color_hex(0x246BB2); + if (std::strcmp(category, "emergency") == 0) return lv_color_hex(0xBC3B35); + if (std::strcmp(category, "camp") == 0 || std::strcmp(category, "shelter") == 0) return lv_color_hex(0x347544); + return lv_color_hex(0x705337); +} +void draw_background(lv_layer_t* layer, const lv_area_t& area, lv_color_t fill, bool marker) +{ + lv_draw_rect_dsc_t dsc; + lv_draw_rect_dsc_init(&dsc); + dsc.bg_color = fill; + dsc.bg_opa = LV_OPA_COVER; + dsc.radius = marker ? LV_RADIUS_CIRCLE : 2; + dsc.border_width = marker ? 1 : 0; + dsc.border_color = lv_color_hex(0xFFFFFF); + dsc.border_opa = LV_OPA_COVER; + lv_draw_rect(layer, &dsc, &area); +} +void draw_text(lv_layer_t* layer, const lv_area_t& area, const char* text, const lv_font_t* font, bool marker) +{ + lv_draw_label_dsc_t dsc; + lv_draw_label_dsc_init(&dsc); + dsc.font = font; + dsc.text = text; + dsc.align = marker ? LV_TEXT_ALIGN_CENTER : LV_TEXT_ALIGN_LEFT; + dsc.color = lv_color_hex(marker ? 0xFFFFFF : 0x3A2A1A); + dsc.flag = LV_TEXT_FLAG_EXPAND; + // LVGL owns a short transient copy if a viewport refresh releases the PSRAM snapshot. + dsc.text_local = !marker; + dsc.text_static = marker; + lv_draw_label(layer, &dsc, &area); +} +} // namespace + +PoiOverlay::~PoiOverlay() +{ + if (layer_) lv_obj_del(layer_); +} +void PoiOverlay::on_delete(lv_event_t* event) +{ + auto* self = static_cast(lv_event_get_user_data(event)); + self->layer_ = nullptr; + self->snapshot_ = nullptr; +} +void PoiOverlay::create(lv_obj_t* parent) +{ + if (layer_) lv_obj_del(layer_); + layer_ = lv_obj_create(parent); + lv_obj_remove_style_all(layer_); + lv_obj_set_size(layer_, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(layer_, 0, 0); + lv_obj_clear_flag(layer_, static_cast(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE)); + lv_obj_add_event_cb(layer_, on_delete, LV_EVENT_DELETE, this); + lv_obj_add_event_cb(layer_, on_draw, LV_EVENT_DRAW_MAIN, this); + clear(); +} +bool PoiOverlay::overlaps(const Rect& a, const Rect& b) +{ + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; +} +unsigned PoiOverlay::side_for(std::size_t index) const +{ + return index < 32 ? static_cast((sides_low_ >> (index * 2)) & 3U) + : static_cast((sides_high_ >> ((index - 32) * 2)) & 3U); +} +bool PoiOverlay::label_rect(const ui::map::MapPoiItem& item, unsigned side, int width, Rect& out) const +{ + if (!font_ || item.label.empty()) return false; + lv_point_t size{}; + lv_text_get_size(&size, item.label.c_str(), font_, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + // Long names never enlarge a marker or create a scrolling/wrapping widget. + if (size.x + 2 > std::min(128, width / 2) || size.y > font_->line_height) return false; + out = {item.x + 12, item.y - size.y / 2, size.x + 2, size.y}; + if (side == 1) out.x = item.x - 12 - out.w; + if (side == 2) + { + out.x = item.x - out.w / 2; + out.y = item.y - 12 - out.h; + } + if (side == 3) + { + out.x = item.x - out.w / 2; + out.y = item.y + 12; + } + return true; +} +bool PoiOverlay::label_fits(const Rect& rect, const ui::map::MapPoiSnapshot& snapshot, std::size_t count, int width, int height) const +{ + if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > width || rect.y + rect.h > height) return false; + for (std::size_t i = 0; i < count; ++i) + { + const auto& item = snapshot.items[i]; + if (overlaps(rect, {item.x - kMarker / 2, item.y - kMarker / 2, kMarker, kMarker})) return false; + Rect previous{}; + if ((label_mask_ & (uint64_t{1} << i)) && label_rect(item, side_for(i), width, previous) && overlaps(rect, previous)) return false; + } + return true; +} +void PoiOverlay::update(const ui::map::MapPoiSnapshot& snapshot) +{ + if (!layer_) return; + clear(); + lv_obj_set_pos(layer_, 0, 0); + if (!snapshot.enabled || !snapshot.items || snapshot.item_count == 0) return; + snapshot_ = &snapshot; + const auto count = std::min(std::min(snapshot.item_count, snapshot.capacity), ui::map::MapPoiSnapshot::kMaxItems); + font_ = ui::fonts::ui_chrome_font(); + if (snapshot.labels) + { + // Resolve the shared content font during preparation, never from DRAW_MAIN. + for (std::size_t i = 0; i < count; ++i) + if (!snapshot.items[i].label.empty()) font_ = ui::fonts::content_font(snapshot.items[i].label.c_str(), ui::fonts::ui_chrome_font()); + const int width = lv_obj_get_width(layer_), height = lv_obj_get_height(layer_); + std::size_t placed = 0; + for (std::size_t i = 0; i < count && placed < kLabels; ++i) + { + for (unsigned side = 0; side < 4; ++side) + { + Rect rect{}; + if (!label_rect(snapshot.items[i], side, width, rect) || !label_fits(rect, snapshot, count, width, height)) continue; + label_mask_ |= uint64_t{1} << i; + if (i < 32) sides_low_ |= uint64_t{side} << (i * 2); + else sides_high_ |= uint32_t{side} << ((i - 32) * 2); + ++placed; + break; + } + } + } + lv_obj_clear_flag(layer_, LV_OBJ_FLAG_HIDDEN); + lv_obj_invalidate(layer_); +} +void PoiOverlay::on_draw(lv_event_t* event) +{ + const auto* self = static_cast(lv_event_get_user_data(event)); + if (!self->snapshot_ || !self->snapshot_->items) return; + const auto& snapshot = *self->snapshot_; + const auto count = std::min(std::min(snapshot.item_count, snapshot.capacity), ui::map::MapPoiSnapshot::kMaxItems); + auto* layer = lv_event_get_layer(event); + lv_area_t origin{}; + lv_obj_get_coords(self->layer_, &origin); + const auto* marker_font = &lv_font_montserrat_14; + for (std::size_t i = 0; i < count; ++i) + { + const auto& item = snapshot.items[i]; + lv_area_t area{origin.x1 + item.x - kMarker / 2, origin.y1 + item.y - kMarker / 2, + origin.x1 + item.x + kMarker / 2 - 1, origin.y1 + item.y + kMarker / 2 - 1}; + draw_background(layer, area, color(item.category.c_str()), true); + area.y1 += (kMarker - marker_font->line_height) / 2; + draw_text(layer, area, symbol(item.category.c_str()), marker_font, true); + if (!(self->label_mask_ & (uint64_t{1} << i))) continue; + Rect rect{}; + if (!self->label_rect(item, self->side_for(i), lv_obj_get_width(self->layer_), rect)) continue; + area = {origin.x1 + rect.x, origin.y1 + rect.y, origin.x1 + rect.x + rect.w - 1, origin.y1 + rect.y + rect.h - 1}; + draw_background(layer, area, lv_color_hex(0xFFF7E9), false); + draw_text(layer, area, item.label.c_str(), self->font_, false); + } +} +void PoiOverlay::clear() +{ + snapshot_ = nullptr; + label_mask_ = sides_low_ = 0; + sides_high_ = 0; + if (layer_) lv_obj_add_flag(layer_, LV_OBJ_FLAG_HIDDEN); +} +void PoiOverlay::translate(int dx, int dy) +{ + if (layer_) lv_obj_set_pos(layer_, lv_obj_get_x(layer_) + dx, lv_obj_get_y(layer_) + dy); +} +} // namespace ui::widgets::map diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/map_poi/cjson_poi_parser.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/map_poi/cjson_poi_parser.h new file mode 100644 index 00000000..8b743640 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/map_poi/cjson_poi_parser.h @@ -0,0 +1,12 @@ +#pragma once +#include "ui_map_runtime/map_poi/poi_types.h" + +namespace platform::esp::arduino_common::map_poi +{ +class CJsonPoiParser final : public ::ui::map_poi::Parser +{ + public: + bool manifest(const char* json, std::size_t size, ::ui::map_poi::Policy& out) const override; + bool record(const char* json, std::size_t size, ::ui::map_poi::Record& out) const override; +}; +} // namespace platform::esp::arduino_common::map_poi diff --git a/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h b/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h index d83faf3c..0c1ef797 100644 --- a/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h +++ b/platform/esp/arduino_common/include/ui/widgets/map/map_tiles.h @@ -20,6 +20,17 @@ #define TILE_CACHE_LIMIT 12 #define TILE_RECORD_LIMIT 48 +#if defined(ARDUINO_ARCH_ESP32) +#define TRAIL_MATE_MAP_POI_AVAILABLE 1 +#include "ui_map_runtime/map_poi/poi_types.h" +#include "ui_presentation/map/map_poi_snapshot.h" +#include +struct MapPoiPayloadDeleter +{ + void operator()(uint8_t* data) const noexcept; +}; +#endif + #ifndef M_PI #define M_PI 3.14159265358979323846 #endif @@ -54,6 +65,12 @@ struct DecodedTileCache // Map tile structure struct MapTile { +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + std::unique_ptr poi; + bool poi_checked = false; + bool poi_pending = false; + uint32_t poi_retry_not_before_ms = 0; +#endif int32_t x; // Tile X coordinate (wrapped, max 262,143 at zoom 18) int32_t y; // Tile Y coordinate (clamped, max 262,143 at zoom 18) int z; // Zoom level (0-18) @@ -90,8 +107,18 @@ struct TileContext bool* has_map_data; // Global: any tile ever loaded bool* has_visible_map_data; // Viewport: current visible tiles have PNG bool runtime_acquired = false; +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + ui::map_poi::Policy poi_policy{}; + bool poi_policy_known = false; + bool poi_available = false; + uint32_t poi_revision = 0; +#endif }; +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) +void map_poi_snapshot(TileContext& ctx, ui::map::MapPoiSnapshot& out); +#endif + // Core tile functions - implemented in map_tiles.cpp /** diff --git a/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp b/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp new file mode 100644 index 00000000..7326091d --- /dev/null +++ b/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp @@ -0,0 +1,140 @@ +#include "platform/esp/arduino_common/map_poi/cjson_poi_parser.h" +#include "cJSON.h" + +#include +#include +#include +#include +#include + +namespace platform::esp::arduino_common::map_poi +{ +namespace +{ +using Json = std::unique_ptr; + +Json parse(const char* text, std::size_t size) +{ + // Bound recursion before invoking cJSON on an ESP task stack. + bool quoted = false, escaped = false; + int depth = 0; + for (std::size_t i = 0; i < size; ++i) + { + const char c = text[i]; + if (quoted) + { + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') quoted = false; + } + else if (c == '"') quoted = true; + else if (c == '{' || c == '[') + { + if (++depth > 4) return {nullptr, cJSON_Delete}; + } + else if (c == '}' || c == ']') + { + if (--depth < 0) return {nullptr, cJSON_Delete}; + } + } + if (quoted || depth != 0) return {nullptr, cJSON_Delete}; + const char* end = nullptr; + Json root(cJSON_ParseWithLengthOpts(text, size, &end, false), cJSON_Delete); + if (!root || !end) return {nullptr, cJSON_Delete}; + while (end < text + size && std::isspace(static_cast(*end))) ++end; + if (end != text + size || !cJSON_IsObject(root.get())) return {nullptr, cJSON_Delete}; + return root; +} + +const char* string(cJSON* object, const char* key, const char* alternate = nullptr) +{ + auto* item = cJSON_GetObjectItemCaseSensitive(object, key); + if (!item && alternate) item = cJSON_GetObjectItemCaseSensitive(object, alternate); + return cJSON_IsString(item) ? item->valuestring : nullptr; +} + +bool integer(cJSON* item, int& value) +{ + if (!cJSON_IsNumber(item) || !std::isfinite(item->valuedouble) || item->valuedouble < -100000 || item->valuedouble > 100000 || + std::floor(item->valuedouble) != item->valuedouble) return false; + value = static_cast(item->valuedouble); + return true; +} + +void copy_utf8(char* destination, std::size_t capacity, const char* source) +{ + if (!source) + { + destination[0] = '\0'; + return; + } + std::size_t size = std::min(capacity - 1, std::strlen(source)); + while (size > 0 && (static_cast(source[size]) & 0xC0U) == 0x80U) --size; + std::memcpy(destination, source, size); + destination[size] = '\0'; +} +} // namespace + +bool CJsonPoiParser::manifest(const char* json, std::size_t size, ::ui::map_poi::Policy& out) const +{ + out = {}; + if (!json || size == 0 || size > 16384) return false; + const auto root = parse(json, size); + if (!root) return false; + int version = 0; + if (!integer(cJSON_GetObjectItemCaseSensitive(root.get(), "version"), version) || (version != 1 && version != 2)) return false; + auto* index = cJSON_GetObjectItemCaseSensitive(root.get(), "index"); + if (!cJSON_IsObject(index)) return false; + const char* scheme = string(index, "scheme"); + const char* format = string(index, "format"); + if (!scheme || std::strcmp(scheme, "web-mercator-xyz") != 0 || !format || std::strcmp(format, "jsonl") != 0) return false; + auto* levels = cJSON_GetObjectItemCaseSensitive(index, "enabled_zoom_levels"); + if (levels) + { + if (!cJSON_IsArray(levels) || cJSON_GetArraySize(levels) > 19) return false; + for (auto* item = levels->child; item; item = item->next) + { + int zoom = 0; + if (!integer(item, zoom) || zoom < 0 || zoom > 18) return false; + out.enabled_levels |= 1UL << zoom; + } + } + else + { + if (version != 1) return false; + int min = 0, max = 0; + if (!integer(cJSON_GetObjectItemCaseSensitive(index, "min_zoom"), min) || + !integer(cJSON_GetObjectItemCaseSensitive(index, "max_zoom"), max) || min < 0 || max < min || max > 24) return false; + for (int zoom = min; zoom <= std::min(max, 18); ++zoom) out.enabled_levels |= 1UL << zoom; + } + auto* labels = cJSON_GetObjectItemCaseSensitive(index, "include_labels"); + out.labels = !cJSON_IsBool(labels) || cJSON_IsTrue(labels); + return true; +} + +bool CJsonPoiParser::record(const char* json, std::size_t size, ::ui::map_poi::Record& out) const +{ + out = {}; + if (!json || size == 0 || size > 2048) return false; + const auto root = parse(json, size); + if (!root) return false; + const char* id = string(root.get(), "id"); + const char* category = string(root.get(), "type", "t"); + if (!id || !*id || std::strlen(id) >= sizeof(out.id) || !category || !*category || std::strlen(category) >= sizeof(out.category)) return false; + auto* lat = cJSON_GetObjectItemCaseSensitive(root.get(), "lat"); + auto* lon = cJSON_GetObjectItemCaseSensitive(root.get(), "lon"); + if (!cJSON_IsNumber(lat) || !cJSON_IsNumber(lon) || !std::isfinite(lat->valuedouble) || !std::isfinite(lon->valuedouble) || + lat->valuedouble < -85.05112878 || lat->valuedouble > 85.05112878 || lon->valuedouble < -180 || lon->valuedouble > 180) return false; + out.lat = lat->valuedouble; + out.lon = lon->valuedouble; + auto* priority = cJSON_GetObjectItemCaseSensitive(root.get(), "priority"); + if (!priority) priority = cJSON_GetObjectItemCaseSensitive(root.get(), "p"); + int rank = 0; + if (priority && !integer(priority, rank)) return false; + out.priority = static_cast(std::clamp(rank, 0, 255)); + std::strcpy(out.id, id); + std::strcpy(out.category, category); + copy_utf8(out.name, sizeof(out.name), string(root.get(), "name", "n")); + return true; +} +} // namespace platform::esp::arduino_common::map_poi diff --git a/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp b/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp index 330c2f90..f6e41b79 100644 --- a/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp +++ b/platform/esp/arduino_common/src/ui/widgets/map/map_tiles.cpp @@ -20,6 +20,11 @@ #include "ui_map_runtime/map_tiles/map_tile_decoder_cache.h" #include "ui_map_runtime/map_tiles/map_tile_geometry.h" #include "ui_map_runtime/map_tiles/map_tile_types.h" +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) +#include "platform/esp/arduino_common/map_poi/cjson_poi_parser.h" +#include "ui_map_runtime/map_poi/poi_snapshot_builder.h" +#include "ui_map_runtime/map_poi/poi_tile_source.h" +#endif #include #include @@ -29,6 +34,10 @@ #include #include +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) +void MapPoiPayloadDeleter::operator()(uint8_t* data) const noexcept { heap_caps_free(data); } +#endif + // Use LVGL's decoder API to decode PNG images // We'll decode PNG to RGB565 and cache it in RAM to avoid re-decoding on every render // This approach uses LVGL's built-in decoder, avoiding direct lodepng dependency @@ -242,6 +251,10 @@ const char* map_tile_format_name(ui::map_tiles::MapTileFormat format) { case ui::map_tiles::MapTileFormat::Png: return "png"; + case ui::map_tiles::MapTileFormat::Jsonl: + return "jsonl"; + case ui::map_tiles::MapTileFormat::PoiRecords: + return "poi-records"; case ui::map_tiles::MapTileFormat::Unknown: default: return "unknown"; @@ -258,6 +271,8 @@ const char* map_tile_layer_name(ui::map_tiles::MapTileLayer layer) return "satellite"; case ui::map_tiles::MapTileLayer::Osm: return "osm"; + case ui::map_tiles::MapTileLayer::Poi: + return "poi"; case ui::map_tiles::MapTileLayer::ContourMajor500: return "contour-major-500"; case ui::map_tiles::MapTileLayer::ContourMajor200: @@ -775,18 +790,40 @@ class MapTileEventQueue final : public ui::map_tiles::IMapTileEventSink event.payload.data != nullptr && event.payload.size > 0) { - uint8_t* payload = allocate_tile_payload(event.payload.size); + uint8_t* payload = nullptr; + int allocation_error = -12; +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + if (event.payload.format == ui::map_tiles::MapTileFormat::PoiRecords) + { + if (ui::map_poi::validPayload(event.payload.data, event.payload.size)) + payload = static_cast(heap_caps_malloc(event.payload.size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + else allocation_error = -22; + } + else +#endif + payload = allocate_tile_payload(event.payload.size); if (payload == nullptr) { - log_map_tile_event_failure("payload_alloc", owned, -12); + log_map_tile_event_failure("payload_alloc", owned, allocation_error); owned.kind = ui::map_tiles::MapTileAsyncEventKind::Failed; - owned.error = -12; + owned.error = allocation_error; owned.payload = {}; owned.payload_size = 0; } else { - std::memcpy(payload, event.payload.data, event.payload.size); +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + if (event.payload.format == ui::map_tiles::MapTileFormat::PoiRecords) + { + const auto* source_header = reinterpret_cast(event.payload.data); + new (payload) ui::map_poi::TileHeader(*source_header); + const auto* records = ui::map_poi::payloadRecords(event.payload.data); + for (std::size_t i = 0; i < source_header->count; ++i) + new (payload + sizeof(ui::map_poi::TileHeader) + i * sizeof(ui::map_poi::Record)) ui::map_poi::Record(records[i]); + } + else +#endif + std::memcpy(payload, event.payload.data, event.payload.size); owned.payload.data = payload; owned.payload.size = event.payload.size; owned.payload.format = event.payload.format; @@ -872,6 +909,9 @@ class EspMapTileWorkerBackend final : public ui::map_tiles::IMapTileWorkerBacken uint8_t* buffer, std::size_t capacity) override { +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + if (ref.layer == ui::map_tiles::MapTileLayer::Poi) return poi_source_.read(ref, buffer, capacity); +#endif ui::map_tiles::MapTileReadResult result{}; result.format = ui::map_tiles::mapTileFormatForLayer(ref.layer); if (map_tile_availability_memory().knownMissing(ref)) @@ -907,8 +947,20 @@ class EspMapTileWorkerBackend final : public ui::map_tiles::IMapTileWorkerBacken } } + void resetMetadata() + { +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + poi_source_.reset(); +#endif + } + private: ui::map_tiles::IMapTileSource& source_; +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + SdMapTileFileSystem poi_files_; + platform::esp::arduino_common::map_poi::CJsonPoiParser poi_parser_; + ui::map_poi::PoiTileSource poi_source_{poi_files_, poi_parser_, "/"}; +#endif }; class LvglDecodedTileCache final : public ui::map_tiles::IMapTileDecoderCache @@ -1254,6 +1306,7 @@ class MapTileAsyncHost final if (worker_ == nullptr) { + backend_.resetMetadata(); worker_ = new (std::nothrow) ui::map_tiles::MapTileWorker(backend_, events_, @@ -1955,7 +2008,7 @@ static MapTile& ensure_tile(TileContext& ctx, int x, int y, int z, int priority) t.contour_retry_not_before_ms = 0; t.cached_img = NULL; // No cached image initially t.contour_cached_img = NULL; - ctx.tiles->push_back(t); + ctx.tiles->push_back(std::move(t)); return ctx.tiles->back(); } @@ -2463,6 +2516,89 @@ static bool request_contour_tile_async(MapTile& tile) return false; } +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) +static bool apply_poi_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEvent& event) +{ + MapTile* tile = find_tile(ctx, static_cast(event.tile.x), static_cast(event.tile.y), event.tile.z); + if (!tile || !tile->visible || !ctx.anchor || event.tile.z != ctx.anchor->z) + { + if (tile) + { + tile->poi_pending = false; + tile->poi_retry_not_before_ms = 0; + } + release_tile_payload(event); + return false; + } + tile->poi_pending = false; + tile->poi_retry_not_before_ms = 0; + if (event.kind == ui::map_tiles::MapTileAsyncEventKind::RetryLater) + { + tile->poi_retry_not_before_ms = sys::millis_now() + kMapTileLayerBusyBackoffMs; + release_tile_payload(event); + return false; + } + tile->poi_checked = true; + tile->poi.reset(); + ++ctx.poi_revision; + if (event.kind != ui::map_tiles::MapTileAsyncEventKind::Ready || + event.payload.format != ui::map_tiles::MapTileFormat::PoiRecords || + !ui::map_poi::validPayload(event.payload.data, event.payload.size)) + { + if (event.error == -12) + { + tile->poi_checked = false; + tile->poi_retry_not_before_ms = sys::millis_now() + kMapTileLayerTransientBackoffMs; + } + log_map_tile_event_failure("poi_payload", event, event.error); + release_tile_payload(event); + return false; + } + const auto* header = reinterpret_cast(event.payload.data); + const uint16_t count = header->count; + ctx.poi_policy_known = true; + ctx.poi_policy = header->policy; + ctx.poi_available = header->manifest_valid; + if (count && ctx.poi_available && ctx.poi_policy.enabled(event.tile.z)) + { + // Transfer the event's exact-size PSRAM allocation to the tile record. + // No second allocation, fixed 200-record cache, or internal-RAM fallback. + tile->poi.reset(const_cast(event.payload.data)); + event.payload.data = nullptr; + event.payload.size = 0; + } + std::printf("[MapViewport][POI] z=%u x=%lu y=%lu available=%d enabled=%d records=%u\n", + static_cast(event.tile.z), static_cast(event.tile.x), + static_cast(event.tile.y), ctx.poi_available, ctx.poi_policy.enabled(event.tile.z), + tile->poi ? count : 0); + release_tile_payload(event); + return true; +} + +static void request_visible_poi_tile(TileContext& ctx) +{ + if (!ctx.tiles || !ctx.anchor || !ctx.anchor->valid) return; + if (ctx.poi_policy_known && (!ctx.poi_available || !ctx.poi_policy.enabled(ctx.anchor->z))) return; + MapTile* best = nullptr; + const uint32_t now = sys::millis_now(); + for (auto& tile : *ctx.tiles) + { + if (!tile.visible || tile.z != ctx.anchor->z || tile.map_source != g_active_map_source || + tile.poi_checked || + (tile.poi_retry_not_before_ms && static_cast(tile.poi_retry_not_before_ms - now) > 0)) continue; + // A full event queue can drop completion. Reuse the retry deadline as + // an in-flight lease so that a dropped event cannot stall this tile. + tile.poi_pending = false; + if (!best || tile.priority < best->priority) best = &tile; + } + if (!best) return; + ui::map_tiles::MapTileRef ref{ui::map_tiles::MapTileLayer::Poi, static_cast(best->z), + static_cast(best->x), static_cast(best->y)}; + best->poi_pending = map_tile_async_host().request(ref, g_map_tile_runtime_generation, ui::map_tiles::MapTileInteractionMode::Idle); + best->poi_retry_not_before_ms = now + (best->poi_pending ? 10000U : kMapTileLayerBusyBackoffMs); +} +#endif + static bool apply_map_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEvent& event) { if (event.generation != g_map_tile_runtime_generation) @@ -2472,6 +2608,9 @@ static bool apply_map_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEv return false; } +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + if (event.tile.layer == ui::map_tiles::MapTileLayer::Poi) return apply_poi_tile_event(ctx, event); +#endif const bool is_contour = ui::map_tiles::mapTileLayerIsContour(event.tile.layer); if (!is_contour && map_source_for_layer(event.tile.layer) != g_active_map_source) { @@ -2933,6 +3072,17 @@ static void layout_loaded_tile_objects(TileContext& ctx) static void evict_cache(TileContext& ctx) { if (!ctx.tiles) return; +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + for (auto& tile : *ctx.tiles) + { + if (!tile.visible && tile.poi) + { + tile.poi.reset(); + tile.poi_checked = false; + ++ctx.poi_revision; + } + } +#endif const size_t obj_limit = tile_object_cache_limit(ctx); const size_t record_limit = tile_record_limit(ctx); @@ -3323,10 +3473,51 @@ void tile_loader_step(TileContext& ctx) } } } +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + if (static_cast(sys::millis_now() - start_ms) < static_cast(budget_ms)) request_visible_poi_tile(ctx); +#endif update_visible_map_data_flag(ctx); rebuild_render_queue(ctx); } +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) +void map_poi_snapshot(TileContext& ctx, ui::map::MapPoiSnapshot& out) +{ + out.header.valid = ctx.poi_policy_known && ctx.poi_available; + out.header.version = 1; + out.header.generated_at_ms = sys::millis_now(); + out.labels = ctx.poi_policy.labels; + out.enabled = out.header.valid && ctx.anchor && ctx.anchor->valid && ctx.poi_policy.enabled(ctx.anchor->z); + out.truncated = false; + out.candidate_count = 0; + out.item_count = 0; + if (!out.enabled || !ctx.tiles || !ctx.map_container) return; + for (const auto& tile : *ctx.tiles) + { + if (!tile.visible || tile.z != ctx.anchor->z || !tile.poi) continue; + const auto* header = reinterpret_cast(tile.poi.get()); + out.candidate_count += header->count; + out.truncated = out.truncated || header->truncated; + } + if (!out.items || out.capacity == 0) return; + const int width = lv_obj_get_width(ctx.map_container), height = lv_obj_get_height(ctx.map_container); + ui::map_poi::SnapshotBuilder builder(out, width, height); + for (const auto& tile : *ctx.tiles) + { + if (!tile.visible || tile.z != ctx.anchor->z || !tile.poi) continue; + const auto* header = reinterpret_cast(tile.poi.get()); + const auto* records = ui::map_poi::payloadRecords(tile.poi.get()); + for (std::size_t i = 0; i < header->count; ++i) + { + const auto& record = records[i]; + int x = 0, y = 0; + if (gps_screen_pos(ctx, record.lat, record.lon, x, y)) builder.add(record, x, y); + } + } + builder.finish(); +} +#endif + /** * Initialize tile context */ @@ -3358,6 +3549,11 @@ void init_tile_context(TileContext& ctx, lv_obj_t* map_container, MapAnchor* anc void cleanup_tiles(TileContext& ctx) { if (!ctx.tiles) return; +#if defined(TRAIL_MATE_MAP_POI_AVAILABLE) + ctx.poi_policy_known = false; + ctx.poi_available = false; + ++ctx.poi_revision; +#endif for (auto& tile : *ctx.tiles) { diff --git a/tests/map_poi/CMakeLists.txt b/tests/map_poi/CMakeLists.txt new file mode 100644 index 00000000..03286504 --- /dev/null +++ b/tests/map_poi/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.20) +project(map_poi_contract LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +set(REPO "${CMAKE_CURRENT_LIST_DIR}/../..") +set(CJSON_DIR "" CACHE PATH "cJSON 1.7.17 source and header directory") +add_executable(map_poi_contract test_poi.cpp + ${CJSON_DIR}/cJSON.c + ${REPO}/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/poi_snapshot_builder.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_resolver.cpp + ${REPO}/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp) +target_include_directories(map_poi_contract PRIVATE ${CJSON_DIR} + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/ui_presentation/include + ${REPO}/platform/esp/arduino_common/include) +enable_testing() +add_test(NAME map_poi_contract COMMAND map_poi_contract) +add_executable(map_tile_async_regression + ${REPO}/modules/ui_map_runtime/tests/test_map_tile_async_runtime.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_async_runtime.cpp + ${REPO}/modules/ui_map_runtime/src/map_tiles/map_tile_render_queue.cpp) +target_include_directories(map_tile_async_regression PRIVATE + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/core_sys/include) +add_test(NAME map_tile_async_regression COMMAND map_tile_async_regression) +if(MSVC) + target_compile_options(map_poi_contract PRIVATE /utf-8 /EHsc) +endif() diff --git a/tests/map_poi/README.md b/tests/map_poi/README.md new file mode 100644 index 00000000..a1575079 --- /dev/null +++ b/tests/map_poi/README.md @@ -0,0 +1,31 @@ +# Offline POI contracts + +These host tests exercise the production cJSON adapter, bounded tile source, +snapshot selection, and the existing map worker/generation regression suite. +Supply cJSON 1.7.17's source and header (the version used by the ESP32 SDK). + +```sh +cmake -S tests/map_poi -B build/poi-host -DCJSON_DIR=/path/to/cJSON +cmake --build build/poi-host --config Debug +ctest --test-dir build/poi-host -C Debug --output-on-failure +``` + +Run `map_poi_contract` with a generated `maps/poi` directory to additionally +validate every JSONL row and the z16–18 hybrid package manifest. Use a Debug +build: these tests deliberately use assertions, like the existing map tests. + +The companion `tests/map_poi_view` target builds the production `PoiOverlay` +against LVGL 9.4.0 and a deterministic host font adapter. It verifies actual +marker pixels, repeated refresh, clearing, and parent-first destruction, while +asserting that no marker/label child widgets are ever created. + +```sh +cmake -S tests/map_poi_view -B build/poi-view -DLVGL_DIR=/path/to/lvgl +cmake --build build/poi-view --config Debug +ctest --test-dir build/poi-view -C Debug --output-on-failure +``` + +Optionally pass `font.bin` and its comma-separated single-codepoint `ranges.txt` +to `map_poi_view_contract`. It loads the actual binary through LVGL and checks +each declared glyph before running the view tests. This verifies the map-specific +font subset; it does not replace a device test of the shared font-pack manager. diff --git a/tests/map_poi/test_poi.cpp b/tests/map_poi/test_poi.cpp new file mode 100644 index 00000000..5475b009 --- /dev/null +++ b/tests/map_poi/test_poi.cpp @@ -0,0 +1,169 @@ +#include "platform/esp/arduino_common/map_poi/cjson_poi_parser.h" +#include "ui_map_runtime/map_poi/poi_snapshot_builder.h" +#include "ui_map_runtime/map_poi/poi_tile_source.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace ui::map_poi; +using namespace ui::map_tiles; +using platform::esp::arduino_common::map_poi::CJsonPoiParser; +const std::string manifest = R"({"version":2,"index":{"scheme":"web-mercator-xyz","format":"jsonl","enabled_zoom_levels":[16,17,18],"include_labels":true}})"; +const std::string row = u8R"({"id":"node/123","type":"water","name":"昆明饮水点","lat":25.04,"lon":102.7,"priority":90})"; + +struct Files final : IMapTileFileSystem +{ + std::map data; + mutable std::vector reads; + bool busy = false; + bool exists(const char* p) const override { return data.count(p) != 0; } + bool isDirectory(const char*) const override { return false; } + MapTileReadResult readFile(const char* p, uint8_t* out, std::size_t cap) const override + { + reads.emplace_back(p); + if (busy) return {MapTileReadStatus::RetryLater}; + auto it = data.find(p); + if (it == data.end()) return {MapTileReadStatus::Missing}; + if (it->second.size() > cap) return {MapTileReadStatus::Invalid}; + std::memcpy(out, it->second.data(), it->second.size()); + return {MapTileReadStatus::Ready, it->second.size(), 0}; + } +}; + +void parser_contract() +{ + CJsonPoiParser parser; + Policy policy; + assert(parser.manifest(manifest.data(), manifest.size(), policy)); + assert(policy.enabled(16) && policy.enabled(18) && !policy.enabled(15)); + for (const auto& levels : {std::string("[]"), std::string("[10,12,15]")}) + { + auto json = manifest; + json.replace(json.find("[16,17,18]"), 10, levels); + assert(parser.manifest(json.data(), json.size(), policy)); + assert(!policy.enabled(16)); + } + const std::string legacy = R"({"version":1,"index":{"scheme":"web-mercator-xyz","format":"jsonl","min_zoom":15,"max_zoom":18}})"; + assert(parser.manifest(legacy.data(), legacy.size(), policy) && policy.enabled(15)); + auto bad = legacy; + bad[bad.find(":1") + 1] = '2'; + assert(!parser.manifest(bad.data(), bad.size(), policy)); + Record record; + assert(parser.record(row.data(), row.size(), record)); + assert(std::strcmp(record.name, u8"昆明饮水点") == 0 && record.priority == 90); + const std::string compact = R"({"id":"n1","t":"camp","n":"camp","lat":25,"lon":102,"p":80})"; + assert(parser.record(compact.data(), compact.size(), record) && record.priority == 80); + for (const auto& invalid : {row + "garbage", std::string("{\"x\":[[[[[]]]]]}"), std::string("{\"id\":\"n\",\"type\":\"water\",\"lat\":91,\"lon\":1}")}) + assert(!parser.record(invalid.data(), invalid.size(), record)); +} + +void source_contract() +{ + Files files; + CJsonPoiParser parser; + files.data["/maps/poi/manifest.json"] = manifest; + files.data["/maps/poi/index/16/1/2.jsonl"] = row + "\r\nnot-json\n" + row; + PoiTileSource source(files, parser); + std::vector storage(192 * 1024 / 8); + auto* bytes = reinterpret_cast(storage.data()); + auto size = storage.size() * 8; + auto result = source.read({MapTileLayer::Poi, 15, 1, 2}, bytes, size); + assert(validPayload(bytes, result.size) && files.reads.size() == 1); + assert(reinterpret_cast(bytes)->count == 0); + result = source.read({MapTileLayer::Poi, 16, 1, 2}, bytes, size); + assert(validPayload(bytes, result.size)); + assert(result.size == sizeof(TileHeader) + 2 * sizeof(Record)); + assert(reinterpret_cast(bytes)->invalid_rows == 1); + assert(std::strcmp(payloadRecords(bytes)[0].name, u8"昆明饮水点") == 0); + files.data["/maps/poi/index/16/1/2.jsonl"].clear(); + for (int i = 0; i < 205; ++i) files.data["/maps/poi/index/16/1/2.jsonl"] += row + "\n"; + result = source.read({MapTileLayer::Poi, 16, 1, 2}, bytes, size); + assert(validPayload(bytes, result.size)); + assert(reinterpret_cast(bytes)->count == 200 && reinterpret_cast(bytes)->truncated); + files.busy = true; + source.reset(); + assert(source.read({MapTileLayer::Poi, 16, 1, 2}, bytes, size).status == MapTileReadStatus::RetryLater); + files.busy = false; + files.data.erase("/maps/poi/manifest.json"); + files.reads.clear(); + result = source.read({MapTileLayer::Poi, 16, 1, 2}, bytes, size); + assert(validPayload(bytes, result.size) && files.reads.size() == 1 && !reinterpret_cast(bytes)->manifest_valid); + assert(source.read({MapTileLayer::Osm, 16, 1, 2}, bytes, size).status == MapTileReadStatus::Invalid); +} + +void snapshot_contract() +{ + std::vector items(2); + ui::map::MapPoiSnapshot out; + out.items = items.data(); + out.capacity = items.size(); + SnapshotBuilder builder(out, 240, 135); + Record r; + std::strcpy(r.id, "a"); + r.priority = 50; + builder.add(r, 10, 10); + builder.add(r, 100, 100); // ID deduplication + std::strcpy(r.id, "b"); + r.priority = 70; + builder.add(r, 100, 100); + std::strcpy(r.id, "c"); + r.priority = 90; + builder.add(r, 200, 100); + std::strcpy(r.id, "d"); + r.priority = 100; + builder.add(r, -1, 100); + builder.finish(); + assert(out.item_count == 2 && out.truncated); + assert(items[0].priority == 90 && items[1].priority == 70); + SnapshotBuilder collision(out, 240, 135); + std::strcpy(r.id, "a"); + collision.add(r, 50, 50); + std::strcpy(r.id, "b"); + collision.add(r, 55, 55); + collision.finish(); + assert(out.item_count == 1); +} + +int main(int argc, char** argv) +{ + parser_contract(); + source_contract(); + snapshot_contract(); + if (argc > 1) + { + CJsonPoiParser parser; + Policy policy; + Record record; + const std::filesystem::path root(argv[1]); + std::ifstream input(root / "manifest.json"); + std::stringstream json; + json << input.rdbuf(); + auto text = json.str(); + assert(parser.manifest(text.data(), text.size(), policy)); + assert(!policy.enabled(15) && policy.enabled(16) && policy.enabled(17) && policy.enabled(18)); + std::size_t rows = 0, files = 0; + for (const auto& entry : std::filesystem::recursive_directory_iterator(root / "index")) + { + if (entry.path().extension() != ".jsonl") continue; + std::ifstream stream(entry.path()); + std::string line; + std::size_t count = 0; + while (std::getline(stream, line)) + { + assert(parser.record(line.data(), line.size(), record)); + ++rows; + ++count; + } + assert(count <= TileHeader::kMaxRecords); + ++files; + } + assert(files > 0); + std::cout << "Package files=" << files << " rows=" << rows << '\n'; + } + std::cout << "POI parser, source, bounds and snapshot contracts passed\n"; +} diff --git a/tests/map_poi_view/CMakeLists.txt b/tests/map_poi_view/CMakeLists.txt new file mode 100644 index 00000000..98609072 --- /dev/null +++ b/tests/map_poi_view/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.20) +project(map_poi_view_contract LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +set(REPO "${CMAKE_CURRENT_LIST_DIR}/../..") +set(LVGL_DIR "" CACHE PATH "LVGL 9.4 source directory") +set(LV_BUILD_CONF_PATH "${CMAKE_CURRENT_LIST_DIR}/lv_conf.h" CACHE PATH "" FORCE) +set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_USE_THORVG_INTERNAL OFF CACHE BOOL "" FORCE) +add_subdirectory(${LVGL_DIR} lvgl) +add_executable(map_poi_view_contract test_view.cpp ${REPO}/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp) +target_include_directories(map_poi_view_contract BEFORE PRIVATE stubs ${REPO}/modules/ui_shared/include ${REPO}/modules/ui_presentation/include) +target_link_libraries(map_poi_view_contract PRIVATE lvgl) +if(MSVC) + target_compile_options(map_poi_view_contract PRIVATE /utf-8 /EHsc) +endif() +enable_testing() +add_test(NAME map_poi_view_contract COMMAND map_poi_view_contract) diff --git a/tests/map_poi_view/lv_conf.h b/tests/map_poi_view/lv_conf.h new file mode 100644 index 00000000..f5780853 --- /dev/null +++ b/tests/map_poi_view/lv_conf.h @@ -0,0 +1,14 @@ +#ifndef LV_CONF_H +#define LV_CONF_H +#define LV_COLOR_DEPTH 16 +#define LV_USE_STDLIB_MALLOC LV_STDLIB_CLIB +#define LV_USE_STDLIB_STRING LV_STDLIB_CLIB +#define LV_USE_STDLIB_SPRINTF LV_STDLIB_CLIB +#define LV_USE_OS LV_OS_NONE +#define LV_USE_DRAW_SW 1 +#define LV_USE_FS_MEMFS 1 +#define LV_FS_MEMFS_LETTER 'M' +#define LV_USE_THORVG_INTERNAL 0 +#define LV_USE_LOG 0 +#define LV_FONT_MONTSERRAT_14 1 +#endif diff --git a/tests/map_poi_view/stubs/ui/assets/fonts/font_utils.h b/tests/map_poi_view/stubs/ui/assets/fonts/font_utils.h new file mode 100644 index 00000000..258c1511 --- /dev/null +++ b/tests/map_poi_view/stubs/ui/assets/fonts/font_utils.h @@ -0,0 +1,9 @@ +#pragma once +#include "lvgl.h" +// Device font loading is verified by the ESP build. This host adapter supplies +// a deterministic font to exercise the real PoiOverlay and LVGL draw/lifecycle. +namespace ui::fonts +{ +inline const lv_font_t* ui_chrome_font() { return &lv_font_montserrat_14; } +inline const lv_font_t* content_font(const char*, const lv_font_t*) { return &lv_font_montserrat_14; } +} // namespace ui::fonts diff --git a/tests/map_poi_view/test_view.cpp b/tests/map_poi_view/test_view.cpp new file mode 100644 index 00000000..f59c3685 --- /dev/null +++ b/tests/map_poi_view/test_view.cpp @@ -0,0 +1,86 @@ +#include "ui/widgets/map/poi_overlay.h" +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) +{ + lv_init(); + if (argc > 2) + { + std::ifstream binary(argv[1], std::ios::binary); + std::vector bytes((std::istreambuf_iterator(binary)), std::istreambuf_iterator()); + assert(!bytes.empty()); + auto* font = lv_binfont_create_from_buffer(bytes.data(), static_cast(bytes.size())); + assert(font); + std::ifstream ranges(argv[2]); + std::string codepoint; + std::size_t glyphs = 0; + while (std::getline(ranges, codepoint, ',')) + { + lv_font_glyph_dsc_t glyph{}; + assert(lv_font_get_glyph_dsc(font, &glyph, static_cast(std::stoul(codepoint, nullptr, 16)), 0)); + assert(glyph.box_w > 0 && glyph.box_h > 0); + ++glyphs; + } + assert(glyphs > 0); + lv_binfont_destroy(font); + } + auto* display = lv_display_create(480, 222); + lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565); + std::vector pixels(480 * 222); + lv_display_set_buffers(display, pixels.data(), nullptr, pixels.size() * sizeof(uint16_t), LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_flush_cb(display, [](lv_display_t* d, const lv_area_t*, uint8_t*) + { lv_display_flush_ready(d); }); + auto* root = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(root); + lv_obj_set_size(root, 480, 222); + lv_obj_set_style_bg_color(root, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_update_layout(root); + const auto initial = lv_obj_get_child_count(root); + ui::widgets::map::PoiOverlay overlay; + overlay.create(root); + lv_obj_update_layout(root); + std::vector items(48); + ui::map::MapPoiSnapshot snapshot; + snapshot.enabled = true; + snapshot.labels = true; + snapshot.items = items.data(); + snapshot.capacity = items.size(); + snapshot.item_count = items.size(); + for (std::size_t i = 0; i < items.size(); ++i) + { + items[i].x = 20 + (i % 12) * 38; + items[i].y = 25 + (i / 12) * 48; + ui::copyText(items[i].category, "water"); + ui::copyText(items[i].label, "Water"); + } + overlay.update(snapshot); + lv_refr_now(display); + assert(pixels[25 * 480 + 15] != 0xFFFF); // actual marker rasterisation + assert(lv_obj_get_child_count(root) == initial + 1); + auto* layer = lv_obj_get_child(root, -1); + assert(lv_obj_get_child_count(layer) == 0); // no hidden label/marker pool + for (int i = 0; i < 100; ++i) + { + overlay.translate(2, -1); + overlay.update(snapshot); + lv_refr_now(display); + assert(lv_obj_get_child_count(layer) == 0); + } + overlay.clear(); + lv_refr_now(display); + assert(pixels[25 * 480 + 15] == 0xFFFF); + snapshot.enabled = false; + overlay.update(snapshot); + lv_refr_now(display); + assert(lv_obj_has_flag(layer, LV_OBJ_FLAG_HIDDEN)); + lv_obj_delete(root); // parent-first deletion must detach the view safely + overlay.clear(); + lv_display_delete(display); + lv_deinit(); +}