diff --git a/docs/uiux/components/map_annotation_policy.md b/docs/uiux/components/map_annotation_policy.md new file mode 100644 index 00000000..ce9f9f9e --- /dev/null +++ b/docs/uiux/components/map_annotation_policy.md @@ -0,0 +1,97 @@ +# 地图标注策略:全级别无字底图方案 + +状态:2026-09-15 用户已同意本方案,正在实施。当前已交付验证包仍是 z1–15 带字、z16–18 无字加点状 POI;本规格不是当前固件的完成声明。 + +## 需求与对象边界 + +用户接受在标注规则合理的前提下,将 z1–18 全部改成无字瓦片。z16–18 道路名称是必要信息;当前图标密集、名称稀疏和先出现后消失的行为需要一起解决。 + +- 地理数据:源数据存在的道路、聚落、区域、设施及兴趣点。数据存在不等于每个缩放级别都应显示。 +- 地图标注:地理对象的名称及屏幕表现,分为地名/区域名、道路名、点状 POI 名称与图标。道路不能退化成“只在道路中点放一个普通 POI”。 +- 显示资格:按类别、缩放级别及用户预设确定,不由内存分配成功与否决定。 +- 排布结果:在给定屏幕上满足可读性和稳定性的标注集合。名称未排下不代表数据缺失,不能由此删除数据或标记底图缺失。 +- 资源上限:约束候选、缓存和绘制成本,不充当类别筛选规则。 + +始终复用共享地图视口、坐标投影、后台空间瓦片读取、事件/代际机制和资源生命周期。固件工作在 `feat/wio-tracker-l2`;不新建页面私有加载链路。 + +## 默认逐级显示资格 + +下表是初始样式方案。名称必须来自数据,行政级别/道路等级/人口或重要性用于分层;不是对所有数据源完整性的保证。具备资格仍需通过屏幕布局。 + +| z | 地名、区域与点状 POI | 道路标注 | +|---|---|---| +| 1 | 大洲、主要国家;区域包可保留一个选区定位地名 | 无 | +| 2 | 国家、主要首都 | 无 | +| 3 | 国家、首都及少量重要城市 | 无 | +| 4 | 省/州级区域、省会及重要城市 | 无 | +| 5 | 主要城市、重要大型湖泊/山系 | 无 | +| 6 | 城市、主要县级城市及大型自然地标 | 无 | +| 7 | 县级聚落与重要城镇 | 少量重要高速/国道编号 | +| 8 | 城镇及重要乡镇;少量大型景区定位名 | 高速/国道编号,保持稀疏 | +| 9 | 乡镇、大村庄、重要山峰与景区 | 主要干线编号 | +| 10 | 村庄;重要登山入口、营地、避难所等户外目的地 | 重要干线编号或简短名称 | +| 11 | 村庄、重要自然地标;主要户外服务设施 | 干线名称、编号 | +| 12 | 社区/片区、主要公园;医院、主要停车场及户外设施 | 城市主干道、主要道路名称 | +| 13 | 社区、公园、景点;交通枢纽及主要公共设施 | 主次干道名称 | +| 14 | 街区/村庄、公园;常用公共设施与户外服务点 | 主次干道、重要支路名称 | +| 15 | 街区、重要建筑/目的地;水源、厕所、停车等实用 POI | 支路、主要居民街道名称 | +| 16 | 当前附近有意义的命名设施、景点及户外 POI | 主干道和普通有名街道必须具备显示资格;优先保证可见道路的名称 | +| 17 | 更细的附近设施、入口、小公园、建筑/目的地名称 | 普通街道、小路及有名称的步行通道 | +| 18 | 门前/路口级别设施、入口、命名建筑和微观地标 | 服务道路、支路、步行路径等有名称的细部道路 | + +用户位置、当前路线、选中的目的地使用原有导航覆盖层,优先级独立于一般地理标注。普通医院/应急设施不应仅凭类别在国家尺度挤掉国家或城市名。 + +区域 PBF 不等于全球地名数据。z1–3 的国家/首都展示需要有对应数据源;仅有云南 PBF 时可显示真实选区定位地名,不能伪造全球覆盖。需要全球概览时,Center 要另行准备轻量地名数据,仍不依赖第三方成品栅格瓦片。 + +## 解决图标密、文字稀的问题 + +1. 道路名和地区名直接作为文字布局,不先创建图标占位。 +2. 有名称的 POI 将“小图标 + 名称”作为一个组合候选进行布局。尚未选中的候选图标不能阻挡其他候选的文字。 +3. 无名厕所、停车等类别图标只补充实用信息,不无条件铺满屏幕。同类密集点优先选择有用的代表,必要时用带数量的聚合表示;不同语义类别不盲目混成一个点。 +4. 名称太长时进行 UTF-8 安全省略并保留可识别前缀,不因超过固定宽度就直接全部删除;不放大图标,不使用滚动文字。 +5. 先完成碰撞布局再确定显示集合;被拒绝的候选释放位置后,允许后续合适候选补位。去掉“先取前 48 个,再避碰且不补位”的流程。 +6. 道路、地名和 POI 有初始显示预算,未使用的预算允许其他类别使用。避免 48 个图标挤占空间而只剩几条名称。 +7. 图标初始试用 10–12 px,常规文字试用 14–16 px,均为需要实机确认的样式参数。根据用户补充要求,所有道路、地名和 POI 文字必须透明背景,不绘制白色/米白色底板,也不绘制白色描边或光晕。可读性依靠字号、字重、颜色和避碰。 + +Pager 480×222 上,初始排布目标为:低级别约 4–10 条有效文字,中级别约 10–18 条,高级别约 20–30 条总文字。高级别可先为道路名预留约 6–10 条机会,其余给地名与 POI。它们是有足够数据且空间允许时的调试目标,不是承诺或强制填满的数量;实际地图视口比整屏小,最终应按可用面积及实机耗时调整。 + +## 道路名称的数据与布局 + +- Center 保存道路身份、名称/编号、道路等级、缩放资格、简化线段和多个候选位置。 +- 仅合并拓扑连续且语义一致的同名路段;不能把不同地区同名道路合并。 +- 候选位置覆盖长道路的多个部分,使道路中点在视野外时,视野内路段仍能显示名称。 +- 设备只处理视野附近的简化候选;文字位置与道路匹配,始终保持屏幕可读方向,不继承底图整体旋转。 +- 同一路段名称去重,并控制沿道路重复标注的屏幕间距;文字应完整落在可用视口内。 +- “z16–18 道路名重要”通过显示资格与布局预算体现,不通过把某几条道路名称硬编码成例外实现。 + +## 时间稳定性与失败语义 + +- 使用稳定地理对象 ID 保持上一轮选择。仍合格、仍在视野内、没有实质冲突的标注优先保留。 +- 新候选只有在重要性明显更高或出现足够可用空间时替换旧候选;不能因中心距离差一两像素反复互换。 +- 邻近瓦片异步到达时合并更新。静止视野的数据逐渐加载不应持续让已显示名称闪现、消失。 +- 仅在视野/级别、相关数据、字体度量或样式变化时重新排布;普通重绘复用已有结果。 +- PSRAM 分配失败或暂时读取失败时,在旧内容仍对应当前视野/级别的前提下保留可用结果并延后更新。不得将错误状态当作“当前没有标注”。旧视野或旧级别的内容不能为了防闪而永久保留。 + +## Center、数据包与内存 + +- 默认方案调整为“全无字底图 + 地图标注”。地名、道路名、点状 POI 分别配置;关闭设施 POI 不应把道路名一起关掉。 +- 提供户外/城市预设及各组最小/最大级别,默认规则覆盖 18 级;不要求用户逐个维护 18×所有类别的开关。 +- 城市预设可加入公共交通、学校、公园、命名目的地等;商店/餐饮等生活类 POI 由预设及类别选择控制,不承诺当前 9 类提取器已经具备这些能力。 +- 扩展现有空间索引载荷,使统一地图标注管线能区分地名、道路和点状 POI。具体磁盘版本与迁移方式需随实现验证,不把旧 POI 文件和新标注文件变成两个同时生效的权威来源。 +- 同一份缩放/样式规则用于 Center 标注预览、导出和设备消费;在线预览背景不能冒充本地最终无字底图效果。 +- 字体资源覆盖所有导出标注名称,包含道路与地名;通过既有字体包机制加载。复制到 SD 的交付包需携带缺失的必要资源,不能依赖手工修补昆明专用字表。 +- PBF 几何处理、简化、候选生成与级别分类放在 Center;设备不解析全区域路网,不建立常驻大对象或道路全量表。 +- 缓存、候选和布局缓冲按需使用有硬预算的 PSRAM,复用后台读取 scratch;保持单个绘制层,不增加图标/文字控件池或额外整屏画布。 +- 内存预算和显示密度分开:先改善布局算法,再按真实设备成本决定候选上限,不用简单调大 48/20 代替修正。 + +## 实施顺序与验收 + +1. 先打通道路、地区名从 PBF 到统一标注索引,再到共享地图绘制层的完整路径。 +2. 修正文字组合布局、长名称省略、补位和稳定选择;明确失败时的保留/清空行为。 +3. 接入逐级规则与 Center 预设、预览和字体资源打包。 +4. 标注链路具备覆盖能力后,才生成 z1–18 全无字的下一版验证包,避免先删掉低级别文字而没有动态标注补上。 +5. 用同一个昆明范围验证 z1–18;特别检查 z16–18 的普通街道名,长道路跨瓦片/视口边缘,同名道路去重,密集设施及长名称。 +6. 静止地图逐步加载、轻微定位抖动、平移、缩放、出入页面、字体延迟加载和受限内存场景均需验证。记录候选数、实际道路/地名/POI 文字数与拒绝原因。 +7. 检查原生 RAM/PSRAM 高水位、单次布局耗时、绘制耗时与任务栈。主机测试不能替代这些实机性能测量。 + +用户已同意逐级规则与改进方向,并明确禁止文字底板和白色描边。字号与密度参数仍需设备验证,本文不表示上述下一版功能已经交付。 diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_frame.h b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_frame.h new file mode 100644 index 00000000..464adb75 --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_frame.h @@ -0,0 +1,46 @@ +#pragma once + +#include "ui_map_runtime/map_poi/annotation_layout.h" +#include "ui_presentation/map/map_poi_snapshot.h" + +namespace ui::map_poi +{ +// Ownership and publication of a prepared frame. The platform supplies a strict +// PSRAM allocator; host tests inject allocation failures at this same boundary. +class AnnotationFrame +{ + public: + using Allocate = void* (*)(std::size_t, void*); + using Free = void (*)(void*, void*); + AnnotationFrame(Allocate allocate, Free release, void* context = nullptr); + ~AnnotationFrame(); + AnnotationFrame(const AnnotationFrame&) = delete; + AnnotationFrame& operator=(const AnnotationFrame&) = delete; + bool begin(std::size_t needed, const ui::map::MapPoiSnapshot& metadata); + void add(const AnnotationCandidate& candidate); + bool finish(const AnnotationLayoutOptions& options, AnnotationMeasure measure, void* context); + void clear(); + const ui::map::MapPoiSnapshot& snapshot() const { return snapshot_; } + const AnnotationLayoutResult& result() const { return result_; } + AnnotationCandidate* candidates() { return candidates_; } + std::size_t candidate_count() const { return count_; } + bool same_view(const ui::map::MapPoiSnapshot& metadata) const; + + private: + bool reserve(std::size_t candidates); + bool better(const AnnotationCandidate& a, const AnnotationCandidate& b) const; + int cell(const AnnotationCandidate& c) const; + Allocate allocate_; + Free release_; + void* context_; + AnnotationCandidate* candidates_ = nullptr; + AnnotationPlacement* placements_ = nullptr; + AnnotationPlacement* previous_ = nullptr; + ui::map::MapPoiItem* front_ = nullptr; + ui::map::MapPoiItem* back_ = nullptr; + std::size_t capacity_ = 0, count_ = 0, previous_count_ = 0; + ui::map::MapPoiSnapshot snapshot_{}; + ui::map::MapPoiSnapshot next_{}; + AnnotationLayoutResult result_{}; +}; +} // namespace ui::map_poi diff --git a/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_layout.h b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_layout.h new file mode 100644 index 00000000..e0b218e4 --- /dev/null +++ b/modules/ui_map_runtime/include/ui_map_runtime/map_poi/annotation_layout.h @@ -0,0 +1,74 @@ +#pragma once + +#include "ui_presentation/map/map_annotation_kind.h" +#include +#include + +namespace ui::map_poi +{ +struct AnnotationCandidate +{ + uint64_t key = 0; + uint64_t feature_key = 0; + const char* name = nullptr; + const char* category = nullptr; + int16_t x = 0, y = 0; + int16_t path[16]{}; // Already projected screen points; no projection in layout. + uint8_t path_points = 0; + uint8_t priority = 0; + ui::map::AnnotationKind kind = ui::map::AnnotationKind::Poi; + bool processed = false; + bool retained = false; +}; + +struct AnnotationPlacement +{ + uint64_t key = 0; + uint64_t feature_key = 0; + uint16_t candidate = 0; + int16_t x = 0, y = 0; + int16_t text_x = 0, text_y = 0, text_width = 0, text_height = 0; + uint16_t cluster_count = 1; + uint8_t text_bytes = 0; + bool ellipsis = false; + bool marker = false; +}; + +struct AnnotationLayoutOptions +{ + static constexpr std::size_t kMaxCandidates = 192; + static constexpr std::size_t kMaxPlacements = 48; + int16_t width = 0, height = 0; + uint8_t max_labels = 32; + uint8_t max_unnamed_markers = 6; + uint8_t road_reservation = 8; + uint8_t place_reservation = 2; + uint8_t poi_reservation = 12; + uint8_t marker_size = 12; + uint8_t padding = 2; + uint8_t max_text_width = 128; + uint8_t retention_bonus = 12; +}; + +struct AnnotationLayoutResult +{ + std::size_t count = 0; + uint16_t labels = 0, road_labels = 0, place_labels = 0, poi_labels = 0; + uint16_t unnamed_markers = 0, collisions = 0, outside = 0, truncated = 0; + bool metrics_pending = false; +}; + +// Measures a bounded UTF-8 prefix, optionally followed by an ellipsis. +using AnnotationMeasure = bool (*)(void*, const char*, std::size_t, bool, int16_t&, int16_t&); +using AnnotationConsumer = void (*)(void*, const AnnotationCandidate&); + +// All bulk storage is caller-owned. Previous placements may alias output: only +// stable keys are read into candidate flags before any placement is overwritten. +AnnotationLayoutResult layout_annotations(AnnotationCandidate* candidates, std::size_t candidate_count, + const AnnotationPlacement* previous, std::size_t previous_count, + AnnotationPlacement* output, std::size_t capacity, const AnnotationLayoutOptions& options, + AnnotationMeasure measure, void* measure_context); + +static_assert(sizeof(AnnotationCandidate) <= 96, "Candidate geometry must stay compact"); +static_assert(sizeof(AnnotationPlacement) <= 48, "Layout must not embed strings or UI objects"); +} // 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 index 1d47321c..9c783c7b 100644 --- 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 @@ -1,5 +1,6 @@ #pragma once +#include "ui_presentation/map/map_annotation_kind.h" #include #include #include @@ -11,6 +12,7 @@ struct Policy { uint32_t enabled_levels = 0; bool labels = true; + uint8_t schema_version = 1; bool enabled(int zoom) const { return zoom >= 0 && zoom <= 18 && (enabled_levels & (1UL << zoom)) != 0; } }; @@ -18,10 +20,16 @@ struct Record { char id[48]{}; char name[80]{}; - char category[16]{}; + char category[24]{}; double lat = 0; double lon = 0; uint8_t priority = 0; + ui::map::AnnotationKind kind = ui::map::AnnotationKind::Poi; + bool explicit_kind = false; + uint8_t path_points = 0; + uint64_t feature_key = 0; + uint64_t key = 0; + int16_t path[16]{}; // At most eight tile-local points, only used by roads. }; // Small header followed by exactly `count` Records in an owned PSRAM buffer. @@ -39,7 +47,7 @@ struct alignas(8) TileHeader }; static_assert(sizeof(TileHeader) <= 32, "POI metadata must stay small"); -static_assert(sizeof(Record) <= 168, "POI records must remain compact"); +static_assert(sizeof(Record) <= 224, "Annotation records must remain bounded and compact"); static_assert(std::is_trivially_copyable::value, "POI payload records have no hidden allocations"); inline const Record* payloadRecords(const uint8_t* data) diff --git a/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp b/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp new file mode 100644 index 00000000..8981ce48 --- /dev/null +++ b/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp @@ -0,0 +1,221 @@ +#include "ui_map_runtime/map_poi/annotation_frame.h" +#include +#include +#include +#include +#include + +namespace ui::map_poi +{ +AnnotationFrame::AnnotationFrame(Allocate allocate, Free release, void* context) + : allocate_(allocate), release_(release), context_(context) {} +AnnotationFrame::~AnnotationFrame() { clear(); } + +bool AnnotationFrame::reserve(std::size_t needed) +{ + if (needed <= capacity_) return true; + const auto places = std::min(needed, AnnotationLayoutOptions::kMaxPlacements); + auto* candidates = static_cast(allocate_(needed * sizeof(AnnotationCandidate), context_)); + auto* placements = static_cast(allocate_(places * sizeof(AnnotationPlacement), context_)); + auto* previous = static_cast(allocate_(places * sizeof(AnnotationPlacement), context_)); + auto* front = static_cast(allocate_(places * sizeof(ui::map::MapPoiItem), context_)); + auto* back = static_cast(allocate_(places * sizeof(ui::map::MapPoiItem), context_)); + if (!candidates || !placements || !previous || !front || !back) + { + release_(candidates, context_); + release_(placements, context_); + release_(previous, context_); + release_(front, context_); + release_(back, context_); + return false; + } + for (std::size_t i = 0; i < needed; ++i) new (candidates + i) AnnotationCandidate{}; + for (std::size_t i = 0; i < places; ++i) + { + new (placements + i) AnnotationPlacement{}; + new (previous + i) AnnotationPlacement{}; + new (front + i) ui::map::MapPoiItem{}; + new (back + i) ui::map::MapPoiItem{}; + } + if (snapshot_.item_count) std::copy_n(front_, snapshot_.item_count, front); + if (previous_count_) std::copy_n(previous_, previous_count_, previous); + // Publish equivalent existing contents before releasing its previous address. + snapshot_.items = front; + snapshot_.capacity = places; + release_(candidates_, context_); + release_(placements_, context_); + release_(previous_, context_); + release_(front_, context_); + release_(back_, context_); + candidates_ = candidates; + placements_ = placements; + previous_ = previous; + front_ = front; + back_ = back; + capacity_ = needed; + return true; +} + +bool AnnotationFrame::same_view(const ui::map::MapPoiSnapshot& m) const +{ + return snapshot_.enabled && snapshot_.view_key == m.view_key && snapshot_.compatibility_key == m.compatibility_key && + snapshot_.width == m.width && snapshot_.height == m.height; +} + +bool AnnotationFrame::begin(std::size_t needed, const ui::map::MapPoiSnapshot& metadata) +{ + needed = std::min(needed, AnnotationLayoutOptions::kMaxCandidates); + if (!allocate_ || !release_ || (needed && !reserve(needed))) return false; + next_ = metadata; + next_.items = nullptr; + next_.item_count = 0; + count_ = 0; + return true; +} + +int AnnotationFrame::cell(const AnnotationCandidate& c) const +{ + return std::clamp(c.x * 8 / std::max(1, next_.width), 0, 7) + + 8 * std::clamp(c.y * 4 / std::max(1, next_.height), 0, 3); +} +bool AnnotationFrame::better(const AnnotationCandidate& a, const AnnotationCandidate& b) const +{ + const int ap = a.priority + (a.retained ? 12 : 0), bp = b.priority + (b.retained ? 12 : 0); + if (ap != bp) return ap > bp; + const int ax = (a.x - next_.width / 2) / 8, ay = (a.y - next_.height / 2) / 8; + const int bx = (b.x - next_.width / 2) / 8, by = (b.y - next_.height / 2) / 8; + const int ad = ax * ax + ay * ay, bd = bx * bx + by * by; + return ad != bd ? ad < bd : a.key < b.key; +} + +void AnnotationFrame::add(const AnnotationCandidate& input) +{ + if (!capacity_) return; + // Small candidate only; record strings remain borrowed until finish copies + // selected text into the owned frame. No source Record is copied on stack. + AnnotationCandidate c = input; + if (c.kind == ui::map::AnnotationKind::Road) + { + if (c.path_points < 2) return; + int minx = INT16_MAX, maxx = INT16_MIN, miny = INT16_MAX, maxy = INT16_MIN; + for (unsigned i = 0; i < std::min(c.path_points, 8); ++i) + { + minx = std::min(minx, c.path[i * 2]); + maxx = std::max(maxx, c.path[i * 2]); + miny = std::min(miny, c.path[i * 2 + 1]); + maxy = std::max(maxy, c.path[i * 2 + 1]); + } + if (maxx < 0 || maxy < 0 || minx >= next_.width || miny >= next_.height) return; + c.x = static_cast((std::max(0, minx) + std::min(next_.width - 1, maxx)) / 2); + c.y = static_cast((std::max(0, miny) + std::min(next_.height - 1, maxy)) / 2); + } + else if (c.x < 0 || c.y < 0 || c.x >= next_.width || c.y >= next_.height) return; + c.retained = false; + if (snapshot_.compatibility_key == next_.compatibility_key) + for (std::size_t i = 0; i < previous_count_; ++i) + if (previous_[i].key == c.key) c.retained = true; + std::size_t same_cell = 0, same_kind = 0, worst_cell = count_, worst_kind = count_; + for (std::size_t i = 0; i < count_; ++i) + { + const auto& other = candidates_[i]; + if (other.key == c.key) return; + if (other.kind != c.kind) continue; + ++same_kind; + if (worst_kind == count_ || better(candidates_[worst_kind], other)) worst_kind = i; + if (cell(other) != cell(c)) continue; + ++same_cell; + if (worst_cell == count_ || better(candidates_[worst_cell], other)) worst_cell = i; + } + // A dense cluster cannot occupy the entire candidate pool before layout. + const std::size_t kind_limit = c.kind == ui::map::AnnotationKind::Road ? 72 : c.kind == ui::map::AnnotationKind::Place ? 40 + : 80; + std::size_t replace = count_; + if (same_cell >= 6) replace = worst_cell; + else if (same_kind >= kind_limit) replace = worst_kind; + else if (count_ == capacity_) + { + // Prefer replacing an over-represented class, while preserving a floor + // of candidates for roads and places even during asynchronous arrivals. + for (std::size_t i = 0; i < count_; ++i) + { + if (same_kind < 12 && candidates_[i].kind != c.kind) + { + replace = i; + break; + } + } + if (replace == count_) replace = worst_kind; + } + if (replace < count_) + { + if (candidates_[replace].kind != c.kind || better(c, candidates_[replace])) candidates_[replace] = c; + } + else if (count_ < capacity_) candidates_[count_++] = c; +} + +bool AnnotationFrame::finish(const AnnotationLayoutOptions& options, AnnotationMeasure measure, void* context) +{ + const auto places = std::min(capacity_, AnnotationLayoutOptions::kMaxPlacements); + const auto history = snapshot_.compatibility_key == next_.compatibility_key ? previous_count_ : 0; + result_ = layout_annotations(candidates_, count_, previous_, history, placements_, places, options, measure, context); + if (result_.metrics_pending && same_view(next_)) return false; + for (std::size_t i = 0; i < result_.count; ++i) + { + const auto& placement = placements_[i]; + const auto& candidate = candidates_[placement.candidate]; + auto& item = back_[i]; + item = {}; + std::snprintf(item.id.data, sizeof(item.id.data), "%016llX", static_cast(candidate.key)); + if (placement.text_bytes) + { + std::memcpy(item.label.data, candidate.name, placement.text_bytes); + std::size_t bytes = placement.text_bytes; + if (placement.ellipsis) + { + std::memcpy(item.label.data + bytes, "\xE2\x80\xA6", 3); + bytes += 3; + } + item.label.data[bytes] = '\0'; + } + ui::copyText(item.category, candidate.category); + item.kind = candidate.kind; + item.marker = placement.marker; + item.x = placement.x; + item.y = placement.y; + item.priority = candidate.priority; + item.text_x = placement.text_x; + item.text_y = placement.text_y; + item.text_width = placement.text_width; + item.text_height = placement.text_height; + item.cluster_count = placement.cluster_count; + } + std::swap(front_, back_); + std::swap(placements_, previous_); + previous_count_ = result_.count; + snapshot_ = next_; + snapshot_.items = front_; + snapshot_.capacity = places; + snapshot_.item_count = result_.count; + snapshot_.layout_ready = true; + return true; +} + +void AnnotationFrame::clear() +{ + snapshot_ = {}; + next_ = {}; + result_ = {}; + if (release_) + { + release_(candidates_, context_); + release_(placements_, context_); + release_(previous_, context_); + release_(front_, context_); + release_(back_, context_); + } + candidates_ = nullptr; + placements_ = previous_ = nullptr; + front_ = back_ = nullptr; + capacity_ = count_ = previous_count_ = 0; +} +} // namespace ui::map_poi diff --git a/modules/ui_map_runtime/src/map_poi/annotation_layout.cpp b/modules/ui_map_runtime/src/map_poi/annotation_layout.cpp new file mode 100644 index 00000000..91e65da7 --- /dev/null +++ b/modules/ui_map_runtime/src/map_poi/annotation_layout.cpp @@ -0,0 +1,271 @@ +#include "ui_map_runtime/map_poi/annotation_layout.h" +#include +#include +#include + +namespace ui::map_poi +{ +namespace +{ +struct Rect +{ + int x, y, w, h; +}; +bool overlaps(const Rect& a, const Rect& b, int padding) +{ + return a.x < b.x + b.w + padding && a.x + a.w + padding > b.x && + a.y < b.y + b.h + padding && a.y + a.h + padding > b.y; +} +bool inside(const Rect& a, const AnnotationLayoutOptions& o) +{ + return a.x >= 0 && a.y >= 0 && a.x + a.w <= o.width && a.y + a.h <= o.height; +} +bool named(const AnnotationCandidate& c) { return c.name && c.name[0]; } +Rect marker_rect(int x, int y, int size) { return {x - size / 2, y - size / 2, size, size}; } +Rect text_rect(const AnnotationPlacement& p) { return {p.text_x, p.text_y, p.text_width, p.text_height}; } + +bool fits(const Rect& text, const Rect* marker, const AnnotationPlacement* placed, std::size_t count, const AnnotationLayoutOptions& o) +{ + if (!inside(text, o) || (marker && !inside(*marker, o))) return false; + for (std::size_t i = 0; i < count; ++i) + { + const auto& p = placed[i]; + if (p.text_bytes && (overlaps(text, text_rect(p), o.padding) || (marker && overlaps(*marker, text_rect(p), o.padding)))) return false; + if (p.marker) + { + const auto existing = marker_rect(p.x, p.y, o.marker_size); + if (overlaps(text, existing, o.padding) || (marker && overlaps(*marker, existing, o.padding))) return false; + } + } + return true; +} + +std::size_t utf8_prefix(const char* text, std::size_t limit) +{ + auto size = std::min(std::strlen(text), limit); + while (size && (static_cast(text[size]) & 0xC0U) == 0x80U) --size; + return size; +} + +bool measure_name(const AnnotationCandidate& c, const AnnotationLayoutOptions& o, AnnotationMeasure measure, void* context, + uint8_t& bytes, bool& ellipsis, int16_t& width, int16_t& height, bool& pending) +{ + auto length = utf8_prefix(c.name, 76); // leave room for UTF-8 ellipsis + NUL in the 80-byte view text + ellipsis = c.name[length] != '\0'; + const int available = std::min(o.max_text_width, o.width - o.marker_size - 8); + while (length) + { + if (!measure(context, c.name, length, ellipsis, width, height)) + { + pending = true; + return false; + } + if (width > 0 && height > 0 && width <= available && height <= o.height) + { + bytes = static_cast(length); + return true; + } + length = utf8_prefix(c.name, length - 1); + ellipsis = true; + } + return false; +} + +bool clip_segment(float& x0, float& y0, float& x1, float& y1, const AnnotationLayoutOptions& o) +{ + // Liang–Barsky clipping of already projected coordinates. No map projection here. + const float dx = x1 - x0, dy = y1 - y0; + float begin = 0, end = 1; + const auto edge = [&](float p, float q) + { + if (p == 0) return q >= 0; + const float t = q / p; + if (p < 0) + { + if (t > end) return false; + begin = std::max(begin, t); + } + else + { + if (t < begin) return false; + end = std::min(end, t); + } + return true; + }; + if (!edge(-dx, x0) || !edge(dx, o.width - 1 - x0) || !edge(-dy, y0) || !edge(dy, o.height - 1 - y0)) return false; + x1 = x0 + end * dx; + y1 = y0 + end * dy; + x0 += begin * dx; + y0 += begin * dy; + return true; +} + +bool road_repeats(const AnnotationCandidate& c, int x, int y, const AnnotationCandidate* candidates, + const AnnotationPlacement* placed, std::size_t count) +{ + for (std::size_t i = 0; i < count; ++i) + { + const auto& p = placed[i]; + const auto& other = candidates[p.candidate]; + if (other.kind != ui::map::AnnotationKind::Road) continue; + if (c.feature_key != p.feature_key && std::strcmp(c.name, other.name) != 0) continue; + const int dx = x - p.x, dy = y - p.y; + if (dx * dx + dy * dy < 200 * 200) return true; + } + return false; +} + +bool place_named(std::size_t index, AnnotationCandidate* candidates, AnnotationPlacement* output, std::size_t count, + const AnnotationLayoutOptions& o, AnnotationMeasure measure, void* context, AnnotationLayoutResult& result) +{ + const auto& c = candidates[index]; + auto& p = output[count]; + p = {}; + p.key = c.key; + p.feature_key = c.feature_key; + p.candidate = static_cast(index); + if (!measure_name(c, o, measure, context, p.text_bytes, p.ellipsis, p.text_width, p.text_height, result.metrics_pending)) return false; + p.marker = c.kind == ui::map::AnnotationKind::Poi; + const auto attempt = [&](int x, int y) + { + if (c.kind == ui::map::AnnotationKind::Road && road_repeats(c, x, y, candidates, output, count)) return false; + const Rect marker = marker_rect(x, y, o.marker_size); + for (int side = 0; side < (p.marker ? 4 : 1); ++side) + { + Rect text{x - p.text_width / 2, y - p.text_height / 2, p.text_width, p.text_height}; + if (p.marker) + { + if (side == 0) text.x = x + o.marker_size / 2 + o.padding; + if (side == 1) text.x = x - o.marker_size / 2 - o.padding - text.w; + if (side == 2) text.y = y - o.marker_size / 2 - o.padding - text.h; + if (side == 3) text.y = y + o.marker_size / 2 + o.padding; + } + if (!fits(text, p.marker ? &marker : nullptr, output, count, o)) continue; + p.x = static_cast(x); + p.y = static_cast(y); + p.text_x = static_cast(text.x); + p.text_y = static_cast(text.y); + return true; + } + return false; + }; + if (c.kind != ui::map::AnnotationKind::Road) return attempt(c.x, c.y); + // Multiple visible segments can supply a label; the original whole-road + // midpoint is irrelevant when it is outside the viewport. + for (unsigned i = 1; i < std::min(c.path_points, 8); ++i) + { + float x0 = c.path[(i - 1) * 2], y0 = c.path[(i - 1) * 2 + 1]; + float x1 = c.path[i * 2], y1 = c.path[i * 2 + 1]; + if (!clip_segment(x0, y0, x1, y1, o)) continue; + for (const float fraction : {0.5f, 0.25f, 0.75f}) + if (attempt(static_cast(std::lround(x0 + fraction * (x1 - x0))), + static_cast(std::lround(y0 + fraction * (y1 - y0))))) return true; + } + return false; +} + +bool better(const AnnotationCandidate& a, const AnnotationCandidate& b, const AnnotationLayoutOptions& o) +{ + const int as = a.priority + (a.retained ? o.retention_bonus : 0); + const int bs = b.priority + (b.retained ? o.retention_bonus : 0); + if (as != bs) return as > bs; + // Quantisation reduces tie changes from sub-marker GPS jitter. + const int ax = (a.x - o.width / 2) / 8, ay = (a.y - o.height / 2) / 8; + const int bx = (b.x - o.width / 2) / 8, by = (b.y - o.height / 2) / 8; + const int ad = ax * ax + ay * ay, bd = bx * bx + by * by; + return ad != bd ? ad < bd : a.key < b.key; +} +} // namespace + +AnnotationLayoutResult layout_annotations(AnnotationCandidate* candidates, std::size_t candidate_count, + const AnnotationPlacement* previous, std::size_t previous_count, AnnotationPlacement* output, std::size_t capacity, + const AnnotationLayoutOptions& o, AnnotationMeasure measure, void* context) +{ + AnnotationLayoutResult result; + if (!candidates || !output || !measure || o.width <= 0 || o.height <= 0) return result; + candidate_count = std::min(candidate_count, AnnotationLayoutOptions::kMaxCandidates); + capacity = std::min(capacity, AnnotationLayoutOptions::kMaxPlacements); + previous_count = previous ? std::min(previous_count, AnnotationLayoutOptions::kMaxPlacements) : 0; + for (std::size_t i = 0; i < candidate_count; ++i) + { + auto& c = candidates[i]; + c.processed = false; + c.retained = false; + for (std::size_t j = 0; j < previous_count; ++j) + if (c.key == previous[j].key) c.retained = true; + } + const auto phase = [&](int kind, unsigned limit) + { + unsigned added = 0; + while (added < limit && result.count < capacity && result.labels < o.max_labels) + { + std::size_t best = candidate_count; + for (std::size_t i = 0; i < candidate_count; ++i) + { + const auto& c = candidates[i]; + if (c.processed || !named(c) || (kind >= 0 && static_cast(c.kind) != kind)) continue; + if (best == candidate_count || better(c, candidates[best], o)) best = i; + } + if (best == candidate_count) break; + auto& c = candidates[best]; + c.processed = true; + bool duplicate = false; + for (std::size_t i = 0; i < result.count; ++i) + if (output[i].key == c.key) duplicate = true; + if (duplicate) continue; + if (!place_named(best, candidates, output, result.count, o, measure, context, result)) + { + ++result.collisions; + continue; + } + if (output[result.count].ellipsis) ++result.truncated; + ++result.count; + ++result.labels; + ++added; + if (c.kind == ui::map::AnnotationKind::Road) ++result.road_labels; + else if (c.kind == ui::map::AnnotationKind::Place) ++result.place_labels; + else ++result.poi_labels; + } + }; + phase(static_cast(ui::map::AnnotationKind::Road), o.road_reservation); + phase(static_cast(ui::map::AnnotationKind::Place), o.place_reservation); + phase(static_cast(ui::map::AnnotationKind::Poi), o.poi_reservation); + phase(-1, o.max_labels); + + for (std::size_t i = 0; i < candidate_count && result.count < capacity; ++i) + { + const auto& c = candidates[i]; + if (named(c) || c.kind != ui::map::AnnotationKind::Poi) continue; + bool clustered = false; + for (std::size_t j = 0; j < result.count; ++j) + { + auto& p = output[j]; + const auto& other = candidates[p.candidate]; + if (p.text_bytes || !p.marker || !c.category || !other.category || std::strcmp(c.category, other.category)) continue; + if (std::abs(c.x - p.x) < 24 && std::abs(c.y - p.y) < 24) + { + if (p.cluster_count < UINT16_MAX) ++p.cluster_count; + clustered = true; + break; + } + } + if (clustered || result.unnamed_markers >= o.max_unnamed_markers) continue; + const auto rect = marker_rect(c.x, c.y, o.marker_size); + if (!fits(rect, nullptr, output, result.count, o)) + { + ++result.collisions; + continue; + } + auto& p = output[result.count++]; + p = {}; + p.key = c.key; + p.feature_key = c.feature_key; + p.candidate = static_cast(i); + p.x = c.x; + p.y = c.y; + p.marker = true; + ++result.unnamed_markers; + } + return result; +} +} // 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 index 919d874b..39efe383 100644 --- a/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp +++ b/modules/ui_map_runtime/src/map_poi/poi_tile_source.cpp @@ -81,7 +81,9 @@ map_tiles::MapTileReadResult PoiTileSource::read(const map_tiles::MapTileRef& re // 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 (!parser_.record(reinterpret_cast(raw + begin), length, *target) || + (policy_.schema_version == 3 && !target->explicit_kind) || + (policy_.schema_version < 3 && target->kind != ui::map::AnnotationKind::Poi)) { if (output->invalid_rows < UINT16_MAX) ++output->invalid_rows; continue; diff --git a/modules/ui_presentation/include/ui_presentation/map/map_annotation_kind.h b/modules/ui_presentation/include/ui_presentation/map/map_annotation_kind.h new file mode 100644 index 00000000..fef906a3 --- /dev/null +++ b/modules/ui_presentation/include/ui_presentation/map/map_annotation_kind.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace ui::map +{ +// Rendering role, distinct from the source's geographical category. +enum class AnnotationKind : uint8_t +{ + Poi, + Road, + Place, +}; +} // namespace ui::map 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 index 4ed02edd..38ba5340 100644 --- a/modules/ui_presentation/include/ui_presentation/map/map_poi_snapshot.h +++ b/modules/ui_presentation/include/ui_presentation/map/map_poi_snapshot.h @@ -2,6 +2,7 @@ #include "ui_presentation/common/fixed_text.h" #include "ui_presentation/common/snapshot_header.h" +#include "ui_presentation/map/map_annotation_kind.h" #include #include @@ -16,6 +17,10 @@ struct MapPoiItem int16_t x = 0; int16_t y = 0; uint8_t priority = 0; + AnnotationKind kind = AnnotationKind::Poi; + int16_t text_x = 0, text_y = 0, text_width = 0, text_height = 0; + uint16_t cluster_count = 1; + bool marker = true; }; // Caller-owned snapshot: never returned by value on a small ESP task stack. @@ -26,10 +31,15 @@ struct MapPoiSnapshot bool enabled = false; bool labels = true; bool truncated = false; + bool loading = false; + bool layout_ready = 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. + uint64_t view_key = 0; + uint64_t compatibility_key = 0; + int16_t width = 0, height = 0; }; 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 index b0a3a749..66b1063d 100644 --- a/modules/ui_shared/include/ui/widgets/map/poi_overlay.h +++ b/modules/ui_shared/include/ui/widgets/map/poi_overlay.h @@ -16,25 +16,16 @@ class PoiOverlay void update(const ui::map::MapPoiSnapshot& snapshot); void clear(); void translate(int dx, int dy); + void prepare_text(const char* text); + uint64_t font_signature() const; + static bool measure_text(void* view, const char* text, std::size_t bytes, bool ellipsis, int16_t& width, int16_t& height); 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 946b64ef..281ce0ae 100644 --- a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp +++ b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp @@ -13,6 +13,7 @@ #include "ui/widgets/map/map_tiles.h" #if defined(TRAIL_MATE_MAP_POI_AVAILABLE) #include "ui/widgets/map/poi_overlay.h" +#include "ui_map_runtime/map_poi/annotation_frame.h" #endif #include @@ -35,6 +36,11 @@ struct PoiSnapshotDeleter { void operator()(ui::map::MapPoiItem* data) const noexcept { heap_caps_free(data); } }; +void* allocate_annotation_memory(std::size_t bytes, void*) +{ + return heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +} +void release_annotation_memory(void* memory, void*) { heap_caps_free(memory); } #endif struct RuntimeImpl @@ -65,9 +71,9 @@ struct RuntimeImpl ui::map::MapOverlaySnapshot overlay{}; #if defined(TRAIL_MATE_MAP_POI_AVAILABLE) PoiOverlay poi_overlay; - ui::map::MapPoiSnapshot poi_snapshot{}; - std::unique_ptr poi_storage; + ui::map_poi::AnnotationFrame annotation_frame{allocate_annotation_memory, release_annotation_memory}; uint32_t poi_seen_revision = 0; + uint64_t poi_font_signature = 0; uint32_t poi_allocation_retry_ms = 0; #endif MapAnchor anchor{}; @@ -675,38 +681,49 @@ 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) + if (!force && impl.poi_seen_revision == impl.tile_ctx.poi_revision && + impl.poi_font_signature == impl.poi_overlay.font_signature()) return; + ui::map::MapPoiSnapshot metadata; + map_poi_snapshot(impl.tile_ctx, metadata); + if (!metadata.enabled || (metadata.candidate_count == 0 && !metadata.loading)) { impl.poi_overlay.clear(); - impl.poi_storage.reset(); - impl.poi_snapshot.items = nullptr; - impl.poi_snapshot.capacity = 0; + impl.annotation_frame.clear(); impl.poi_seen_revision = impl.tile_ctx.poi_revision; + impl.poi_font_signature = impl.poi_overlay.font_signature(); return; } - const auto needed = std::min(impl.poi_snapshot.candidate_count, ui::map::MapPoiSnapshot::kMaxItems); - if (impl.poi_snapshot.capacity < needed) + const bool same_view = impl.annotation_frame.same_view(metadata); + if (!same_view) impl.poi_overlay.clear(); + if (metadata.candidate_count == 0) return; + const uint32_t now = lv_tick_get(); + if (impl.poi_allocation_retry_ms && static_cast(impl.poi_allocation_retry_ms - now) > 0) return; + if (!impl.annotation_frame.begin(metadata.candidate_count, metadata)) { - 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_allocation_retry_ms = now + 1000U; + return; } - impl.poi_overlay.update(impl.poi_snapshot); + visit_map_annotations( + impl.tile_ctx, [](void* context, const ui::map_poi::AnnotationCandidate& candidate) + { static_cast(context)->add(candidate); }, + &impl.annotation_frame); + impl.poi_overlay.prepare_text(""); + for (std::size_t i = 0; i < impl.annotation_frame.candidate_count(); ++i) + impl.poi_overlay.prepare_text(impl.annotation_frame.candidates()[i].name); + ui::map_poi::AnnotationLayoutOptions options; + options.width = metadata.width; + options.height = metadata.height; + const int zoom = impl.anchor.z; + options.max_labels = zoom <= 6 ? 8 : zoom <= 11 ? 16 + : zoom <= 15 ? 24 + : 32; + options.road_reservation = zoom <= 6 ? 0 : zoom <= 11 ? 3 + : 8; + options.place_reservation = zoom <= 11 ? 6 : 2; + if (!impl.annotation_frame.finish(options, PoiOverlay::measure_text, &impl.poi_overlay)) return; + impl.poi_overlay.update(impl.annotation_frame.snapshot()); + impl.poi_font_signature = impl.poi_overlay.font_signature(); + impl.poi_allocation_retry_ms = 0; impl.poi_seen_revision = impl.tile_ctx.poi_revision; } #endif @@ -1026,9 +1043,7 @@ void clear(Runtime& runtime) 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; + impl->annotation_frame.clear(); #endif cleanup_tiles(impl->tile_ctx); impl->anchor.valid = 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 index b7bea9c2..69a12fb2 100644 --- a/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp +++ b/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp @@ -1,13 +1,13 @@ #include "ui/widgets/map/poi_overlay.h" #include "ui/assets/fonts/font_utils.h" #include +#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"; @@ -54,6 +54,28 @@ void draw_text(lv_layer_t* layer, const lv_area_t& area, const char* text, const dsc.text_static = marker; lv_draw_label(layer, &dsc, &area); } + +void draw_annotation_marker(lv_layer_t* layer, const lv_area_t& area, const ui::map::MapPoiItem& item) +{ + draw_background(layer, area, color(item.category.c_str()), true); + char count[4]; + const char* text = symbol(item.category.c_str()); + if (item.cluster_count > 1) + { + std::snprintf(count, sizeof(count), item.cluster_count > 9 ? "9+" : "%u", static_cast(item.cluster_count)); + text = count; + } + lv_draw_label_dsc_t dsc; + lv_draw_label_dsc_init(&dsc); + dsc.font = &lv_font_montserrat_10; + dsc.text = text; + dsc.text_local = 1; + dsc.align = LV_TEXT_ALIGN_CENTER; + dsc.color = lv_color_hex(0xFFFFFF); + lv_area_t centered = area; + centered.y1 += (12 - dsc.font->line_height) / 2; + lv_draw_label(layer, &dsc, ¢ered); +} } // namespace PoiOverlay::~PoiOverlay() @@ -78,78 +100,13 @@ void PoiOverlay::create(lv_obj_t* parent) 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; + if (!snapshot.enabled || !snapshot.layout_ready || !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_); } @@ -162,32 +119,76 @@ void PoiOverlay::on_draw(lv_event_t* event) 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); + if (snapshot.layout_ready) + { + if (item.marker) + { + lv_area_t marker_area{origin.x1 + item.x - 6, origin.y1 + item.y - 6, + origin.x1 + item.x + 5, origin.y1 + item.y + 5}; + draw_annotation_marker(layer, marker_area, item); + } + if (snapshot.labels && !item.label.empty() && self->font_) + { + lv_area_t text_area{origin.x1 + item.text_x, origin.y1 + item.text_y, + origin.x1 + item.text_x + item.text_width - 1, + origin.y1 + item.text_y + item.text_height - 1}; + draw_text(layer, text_area, item.label.c_str(), self->font_, false); + } + continue; + } } } 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); } + +void PoiOverlay::prepare_text(const char* text) +{ + font_ = ui::fonts::content_font(text, &lv_font_montserrat_14); +} + +uint64_t PoiOverlay::font_signature() const +{ + uint64_t hash = UINT64_C(14695981039346656037); + // The shared font manager updates fallback links when deferred resources + // become available. Inspect metadata only; never load files from this check. + const lv_font_t* font = font_; + for (unsigned depth = 0; font && depth < 16; ++depth, font = font->fallback) + { + hash ^= reinterpret_cast(font); + hash *= UINT64_C(1099511628211); + hash ^= reinterpret_cast(font->dsc); + hash *= UINT64_C(1099511628211); + hash ^= font->line_height; + } + return hash; +} + +bool PoiOverlay::measure_text(void* view, const char* text, std::size_t bytes, bool ellipsis, int16_t& width, int16_t& height) +{ + auto* self = static_cast(view); + if (!self || !self->font_ || !text || bytes > 76) return false; + char prefix[80]; + std::memcpy(prefix, text, bytes); + if (ellipsis) + { + std::memcpy(prefix + bytes, "\xE2\x80\xA6", 3); + bytes += 3; + } + prefix[bytes] = '\0'; + lv_point_t size{}; + lv_text_get_size(&size, prefix, self->font_, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + width = static_cast(size.x); + height = static_cast(size.y); + return true; +} } // namespace ui::widgets::map 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 0c1ef797..fd1a0350 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 @@ -22,6 +22,7 @@ #if defined(ARDUINO_ARCH_ESP32) #define TRAIL_MATE_MAP_POI_AVAILABLE 1 +#include "ui_map_runtime/map_poi/annotation_layout.h" #include "ui_map_runtime/map_poi/poi_types.h" #include "ui_presentation/map/map_poi_snapshot.h" #include @@ -117,6 +118,7 @@ struct TileContext #if defined(TRAIL_MATE_MAP_POI_AVAILABLE) void map_poi_snapshot(TileContext& ctx, ui::map::MapPoiSnapshot& out); +void visit_map_annotations(TileContext& ctx, ui::map_poi::AnnotationConsumer consume, void* user); #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 index 7326091d..95a6d334 100644 --- a/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp +++ b/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp @@ -73,6 +73,34 @@ void copy_utf8(char* destination, std::size_t capacity, const char* source) std::memcpy(destination, source, size); destination[size] = '\0'; } + +uint64_t legacy_key(const char* value) +{ + uint64_t key = UINT64_C(14695981039346656037); + while (*value) + { + key ^= static_cast(*value++); + key *= UINT64_C(1099511628211); + } + return key; +} + +bool hex_key(const char* text, uint64_t& key) +{ + if (!text || std::strlen(text) != 16) return false; + key = 0; + for (std::size_t i = 0; i < 16; ++i) + { + const unsigned char c = static_cast(text[i]); + unsigned digit; + if (c >= '0' && c <= '9') digit = c - '0'; + else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') digit = c - 'A' + 10; + else return false; + key = (key << 4) | digit; + } + return true; +} } // namespace bool CJsonPoiParser::manifest(const char* json, std::size_t size, ::ui::map_poi::Policy& out) const @@ -82,12 +110,21 @@ bool CJsonPoiParser::manifest(const char* json, std::size_t size, ::ui::map_poi: 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; + if (!integer(cJSON_GetObjectItemCaseSensitive(root.get(), "version"), version) || (version != 1 && version != 2 && version != 3)) return false; + out.schema_version = static_cast(version); 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; + if (version == 3) + { + const char* geometry = string(index, "geometry"); + int points = 0, margin = 0; + if (!geometry || std::strcmp(geometry, "tile-local-pixels") != 0 || + !integer(cJSON_GetObjectItemCaseSensitive(index, "max_path_points"), points) || points != 8 || + !integer(cJSON_GetObjectItemCaseSensitive(index, "tile_margin"), margin) || margin != 32) return false; + } auto* levels = cJSON_GetObjectItemCaseSensitive(index, "enabled_zoom_levels"); if (levels) { @@ -121,6 +158,31 @@ bool CJsonPoiParser::record(const char* json, std::size_t size, ::ui::map_poi::R 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; + const char* kind = string(root.get(), "kind"); + out.explicit_kind = kind != nullptr; + if (kind) + { + if (std::strcmp(kind, "road") == 0) out.kind = ui::map::AnnotationKind::Road; + else if (std::strcmp(kind, "place") == 0) out.kind = ui::map::AnnotationKind::Place; + else if (std::strcmp(kind, "poi") != 0) return false; + if (!hex_key(id, out.key) || !hex_key(string(root.get(), "feature_id"), out.feature_key)) return false; + } + else out.key = out.feature_key = legacy_key(id); + auto* path = cJSON_GetObjectItemCaseSensitive(root.get(), "path"); + if (out.kind == ui::map::AnnotationKind::Road) + { + const int count = cJSON_IsArray(path) ? cJSON_GetArraySize(path) : 0; + if (count < 4 || count > 16 || (count % 2) != 0) return false; + std::size_t i = 0; + for (auto* item = path->child; item; item = item->next) + { + int value = 0; + if (!integer(item, value) || value < -32 || value > 288) return false; + out.path[i++] = static_cast(value); + } + out.path_points = static_cast(count / 2); + } + else if (path && !cJSON_IsNull(path)) 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) || @@ -135,6 +197,7 @@ bool CJsonPoiParser::record(const char* json, std::size_t size, ::ui::map_poi::R std::strcpy(out.id, id); std::strcpy(out.category, category); copy_utf8(out.name, sizeof(out.name), string(root.get(), "name", "n")); + if (out.kind != ui::map::AnnotationKind::Poi && out.name[0] == '\0') return false; 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 f6e41b79..e3587170 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 @@ -22,7 +22,7 @@ #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/annotation_layout.h" #include "ui_map_runtime/map_poi/poi_tile_source.h" #endif @@ -2539,7 +2539,6 @@ static bool apply_poi_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEv 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 || @@ -2555,6 +2554,9 @@ static bool apply_poi_tile_event(TileContext& ctx, ui::map_tiles::MapTileAsyncEv return false; } const auto* header = reinterpret_cast(event.payload.data); + // Failed reads must not discard a previously valid tile payload. Replace + // only after a new typed payload has passed validation (including empty). + tile->poi.reset(); const uint16_t count = header->count; ctx.poi_policy_known = true; ctx.poi_policy = header->policy; @@ -3491,30 +3493,60 @@ void map_poi_snapshot(TileContext& ctx, ui::map::MapPoiSnapshot& out) out.truncated = false; out.candidate_count = 0; out.item_count = 0; + out.loading = false; + out.layout_ready = false; if (!out.enabled || !ctx.tiles || !ctx.map_container) return; + out.width = static_cast(lv_obj_get_width(ctx.map_container)); + out.height = static_cast(lv_obj_get_height(ctx.map_container)); + const int32_t origin_x = ctx.anchor->gps_tile_screen_x + ctx.anchor->gps_offset_x - ctx.anchor->gps_global_pixel_x; + const int32_t origin_y = ctx.anchor->gps_tile_screen_y + ctx.anchor->gps_offset_y - ctx.anchor->gps_global_pixel_y; + out.view_key = (uint64_t{static_cast(origin_x)} << 32) | static_cast(origin_y); + out.compatibility_key = (uint64_t{g_map_tile_runtime_generation} << 16) | + (uint64_t{static_cast(ctx.anchor->z)} << 8) | g_active_map_source; for (const auto& tile : *ctx.tiles) { - if (!tile.visible || tile.z != ctx.anchor->z || !tile.poi) continue; + if (!tile.visible || tile.z != ctx.anchor->z) continue; + out.loading = out.loading || tile.poi_pending || !tile.poi_checked; + if (!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); +} + +void visit_map_annotations(TileContext& ctx, ui::map_poi::AnnotationConsumer consume, void* user) +{ + if (!consume || !ctx.tiles || !ctx.anchor || !ctx.anchor->valid) 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()); const auto* records = ui::map_poi::payloadRecords(tile.poi.get()); + int tile_x = 0, tile_y = 0; + if (!tile_screen_pos_xyz(ctx, tile.x, tile.y, tile.z, tile_x, tile_y)) continue; 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); + if (!gps_screen_pos(ctx, record.lat, record.lon, x, y)) continue; + ui::map_poi::AnnotationCandidate candidate; + candidate.key = record.key; + candidate.feature_key = record.feature_key; + candidate.name = ctx.poi_policy.labels ? record.name : ""; + candidate.category = record.category; + candidate.kind = record.kind; + candidate.priority = record.priority; + candidate.x = static_cast(std::clamp(x, -32768, 32767)); + candidate.y = static_cast(std::clamp(y, -32768, 32767)); + candidate.path_points = record.path_points; + for (unsigned p = 0; p < record.path_points; ++p) + { + candidate.path[p * 2] = static_cast(std::clamp(tile_x + record.path[p * 2], -32768, 32767)); + candidate.path[p * 2 + 1] = static_cast(std::clamp(tile_y + record.path[p * 2 + 1], -32768, 32767)); + } + consume(user, candidate); } } - builder.finish(); } #endif diff --git a/tests/map_poi/CMakeLists.txt b/tests/map_poi/CMakeLists.txt index 03286504..54db3ecc 100644 --- a/tests/map_poi/CMakeLists.txt +++ b/tests/map_poi/CMakeLists.txt @@ -14,6 +14,30 @@ target_include_directories(map_poi_contract PRIVATE ${CJSON_DIR} ${REPO}/platform/esp/arduino_common/include) enable_testing() add_test(NAME map_poi_contract COMMAND map_poi_contract) +add_executable(map_annotation_parser_contract test_annotation_parser.cpp + ${CJSON_DIR}/cJSON.c ${REPO}/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp) +target_include_directories(map_annotation_parser_contract PRIVATE ${CJSON_DIR} + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/ui_presentation/include + ${REPO}/platform/esp/arduino_common/include) +add_test(NAME map_annotation_parser_contract COMMAND map_annotation_parser_contract) +add_executable(validate_annotation_package validate_annotation_package.cpp + ${CJSON_DIR}/cJSON.c ${REPO}/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp) +target_include_directories(validate_annotation_package PRIVATE ${CJSON_DIR} + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/ui_presentation/include ${REPO}/platform/esp/arduino_common/include) +if(MSVC) + target_compile_options(validate_annotation_package PRIVATE /utf-8 /EHsc) +endif() +add_executable(map_annotation_layout_contract test_annotation_layout.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/annotation_layout.cpp) +target_include_directories(map_annotation_layout_contract PRIVATE + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/ui_presentation/include) +add_test(NAME map_annotation_layout_contract COMMAND map_annotation_layout_contract) +add_executable(map_annotation_frame_contract test_annotation_frame.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/annotation_layout.cpp) +target_include_directories(map_annotation_frame_contract PRIVATE + ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/ui_presentation/include) +add_test(NAME map_annotation_frame_contract COMMAND map_annotation_frame_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 @@ -23,4 +47,7 @@ target_include_directories(map_tile_async_regression PRIVATE add_test(NAME map_tile_async_regression COMMAND map_tile_async_regression) if(MSVC) target_compile_options(map_poi_contract PRIVATE /utf-8 /EHsc) + target_compile_options(map_annotation_parser_contract PRIVATE /utf-8 /EHsc) + target_compile_options(map_annotation_layout_contract PRIVATE /utf-8 /EHsc) + target_compile_options(map_annotation_frame_contract PRIVATE /utf-8 /EHsc) endif() diff --git a/tests/map_poi/test_annotation_frame.cpp b/tests/map_poi/test_annotation_frame.cpp new file mode 100644 index 00000000..2e18f4ec --- /dev/null +++ b/tests/map_poi/test_annotation_frame.cpp @@ -0,0 +1,86 @@ +#include "ui_map_runtime/map_poi/annotation_frame.h" +#include +#include +#include +#include + +using namespace ui::map_poi; +struct Heap +{ + int calls = 0, fail = -1; + std::map live; + static void* allocate(std::size_t bytes, void* context) + { + auto& self = *static_cast(context); + if (self.calls++ == self.fail) return nullptr; + auto* value = std::malloc(bytes); + if (value) self.live[value] = bytes; + return value; + } + static void release(void* value, void* context) + { + if (!value) return; + auto& self = *static_cast(context); + assert(self.live.erase(value) == 1); + std::free(value); + } +}; +bool measure(void* context, const char*, std::size_t bytes, bool ellipsis, int16_t& width, int16_t& height) +{ + if (context) return false; + width = static_cast(bytes * 6 + (ellipsis ? 6 : 0)); + height = 14; + return true; +} +int main() +{ + for (int failure = 0; failure < 5; ++failure) + { + Heap heap; + { + AnnotationFrame frame(Heap::allocate, Heap::release, &heap); + ui::map::MapPoiSnapshot metadata; + metadata.enabled = true; + metadata.header.valid = true; + metadata.width = 240; + metadata.height = 120; + metadata.view_key = 42; + metadata.compatibility_key = 3; + AnnotationLayoutOptions options; + options.width = 240; + options.height = 120; + char source[] = "Water"; + AnnotationCandidate candidate; + candidate.key = candidate.feature_key = 1; + candidate.name = source; + candidate.category = "water"; + candidate.x = 30; + candidate.y = 30; + candidate.priority = 90; + assert(frame.begin(1, metadata)); + frame.add(candidate); + assert(frame.finish(options, measure, nullptr)); + assert(frame.snapshot().item_count == 1); + source[0] = 'X'; // Published strings cannot depend on the evictable source tile. + assert(std::strcmp(frame.snapshot().items[0].label.c_str(), "Water") == 0); + const auto live = heap.live.size(); + heap.fail = heap.calls + failure; + assert(!frame.begin(100, metadata)); + assert(heap.live.size() == live); + assert(frame.same_view(metadata)); + assert(std::strcmp(frame.snapshot().items[0].label.c_str(), "Water") == 0); + heap.fail = -1; + assert(frame.begin(100, metadata)); + frame.add(candidate); + assert(!frame.finish(options, measure, &heap)); // Deferred font metrics preserve the old frame. + assert(std::strcmp(frame.snapshot().items[0].label.c_str(), "Water") == 0); + assert(frame.begin(100, metadata)); + frame.add(candidate); + assert(frame.finish(options, measure, nullptr)); + assert(std::strcmp(frame.snapshot().items[0].label.c_str(), "Xater") == 0); + frame.clear(); + assert(heap.live.empty()); + } + assert(heap.live.empty()); + } +} diff --git a/tests/map_poi/test_annotation_layout.cpp b/tests/map_poi/test_annotation_layout.cpp new file mode 100644 index 00000000..f4d8a955 --- /dev/null +++ b/tests/map_poi/test_annotation_layout.cpp @@ -0,0 +1,146 @@ +#include "ui_map_runtime/map_poi/annotation_layout.h" +#include +#include +#include + +using namespace ui::map_poi; +using ui::map::AnnotationKind; + +bool measure(void*, const char* text, std::size_t bytes, bool ellipsis, int16_t& width, int16_t& height) +{ + assert((static_cast(text[bytes]) & 0xC0U) != 0x80U); + unsigned characters = 0; + for (std::size_t i = 0; i < bytes; ++i) + if ((static_cast(text[i]) & 0xC0U) != 0x80U) ++characters; + width = static_cast(characters * 7 + (ellipsis ? 7 : 0)); + height = 14; + return true; +} + +AnnotationCandidate point(uint64_t id, int x, int y, const char* name, uint8_t priority = 100) +{ + AnnotationCandidate c; + c.key = c.feature_key = id; + c.x = static_cast(x); + c.y = static_cast(y); + c.name = name; + c.category = "water"; + c.priority = priority; + return c; +} + +void named_points_own_their_marker_space() +{ + std::vector candidates{point(1, 10, 10, "Water"), point(2, 40, 10, "")}; + std::vector placed(48); + AnnotationLayoutOptions options; + options.width = 100; + options.height = 20; + auto result = layout_annotations(candidates.data(), candidates.size(), nullptr, 0, placed.data(), placed.size(), options, measure, nullptr); + assert(result.labels == 1 && result.unnamed_markers == 0); + assert(placed[0].key == 1 && placed[0].marker && placed[0].text_bytes == 5); +} + +void rejected_front_candidates_do_not_starve_later_labels() +{ + std::vector candidates; + for (int i = 0; i < 60; ++i) candidates.push_back(point(i + 1, 20, 20, "Dense", 200)); + for (int i = 0; i < 12; ++i) candidates.push_back(point(i + 100, 110 + i % 4 * 90, 30 + i / 4 * 60, "Named", 100)); + std::vector placed(48); + AnnotationLayoutOptions options; + options.width = 480; + options.height = 222; + auto result = layout_annotations(candidates.data(), candidates.size(), nullptr, 0, placed.data(), placed.size(), options, measure, nullptr); + assert(result.labels > 8 && result.collisions >= 59); +} + +void stable_labels_survive_minor_distance_changes() +{ + std::vector candidates{point(1, 20, 25, "Old"), point(2, 70, 25, "New")}; + std::vector placed(48); + placed[0].key = 1; + AnnotationLayoutOptions options; + options.width = 150; + options.height = 60; + options.max_labels = 1; + auto result = layout_annotations(candidates.data(), candidates.size(), placed.data(), 1, placed.data(), placed.size(), options, measure, nullptr); + assert(result.labels == 1 && placed[0].key == 1); + candidates[1].priority = 140; + result = layout_annotations(candidates.data(), candidates.size(), placed.data(), 1, placed.data(), placed.size(), options, measure, nullptr); + assert(result.labels == 1 && placed[0].key == 2); +} + +void roads_use_visible_segments_and_do_not_need_markers() +{ + auto road = point(1, -900, 40, "Road"); + road.kind = AnnotationKind::Road; + road.path_points = 2; + road.path[0] = -32; + road.path[1] = 40; + road.path[2] = 200; + road.path[3] = 40; + std::vector candidates{road}; + std::vector placed(48); + AnnotationLayoutOptions options; + options.width = 100; + options.height = 80; + auto result = layout_annotations(candidates.data(), candidates.size(), nullptr, 0, placed.data(), placed.size(), options, measure, nullptr); + assert(result.road_labels == 1 && !placed[0].marker && placed[0].x >= 0 && placed[0].x < 100); +} + +void long_utf8_names_are_shortened_instead_of_removed() +{ + std::vector candidates{point(1, 10, 30, u8"昆明主城区非常长的道路与设施名称")}; + std::vector placed(48); + AnnotationLayoutOptions options; + options.width = 120; + options.height = 60; + options.max_text_width = 56; + auto result = layout_annotations(candidates.data(), candidates.size(), nullptr, 0, placed.data(), placed.size(), options, measure, nullptr); + assert(result.labels == 1 && result.truncated == 1 && placed[0].ellipsis); + assert(placed[0].text_bytes > 0 && placed[0].text_bytes % 3 == 0); +} + +void reservations_prevent_facilities_from_removing_all_roads() +{ + std::vector candidates; + for (int i = 0; i < 10; ++i) candidates.push_back(point(i + 1, 20 + i % 5 * 90, 30 + i / 5 * 80, "POI", 255)); + auto road = point(100, 200, 110, "Street", 100); + road.kind = AnnotationKind::Road; + road.path_points = 2; + road.path[0] = 10; + road.path[1] = 110; + road.path[2] = 470; + road.path[3] = 110; + candidates.push_back(road); + std::vector placed(48); + AnnotationLayoutOptions options; + options.width = 480; + options.height = 222; + options.max_labels = 4; + auto result = layout_annotations(candidates.data(), candidates.size(), nullptr, 0, placed.data(), placed.size(), options, measure, nullptr); + assert(result.road_labels == 1 && result.labels == 4); +} + +void unnamed_clusters_only_join_the_same_category() +{ + std::vector candidates{point(1, 20, 20, ""), point(2, 24, 20, ""), point(3, 60, 20, "")}; + candidates[2].category = "parking"; + std::vector placed(48); + AnnotationLayoutOptions options; + options.width = 120; + options.height = 60; + auto result = layout_annotations(candidates.data(), candidates.size(), nullptr, 0, placed.data(), placed.size(), options, measure, nullptr); + assert(result.unnamed_markers == 2 && placed[0].cluster_count == 2 && placed[1].cluster_count == 1); +} + +int main() +{ + named_points_own_their_marker_space(); + rejected_front_candidates_do_not_starve_later_labels(); + stable_labels_survive_minor_distance_changes(); + roads_use_visible_segments_and_do_not_need_markers(); + long_utf8_names_are_shortened_instead_of_removed(); + reservations_prevent_facilities_from_removing_all_roads(); + unnamed_clusters_only_join_the_same_category(); +} diff --git a/tests/map_poi/test_annotation_parser.cpp b/tests/map_poi/test_annotation_parser.cpp new file mode 100644 index 00000000..5b3c398c --- /dev/null +++ b/tests/map_poi/test_annotation_parser.cpp @@ -0,0 +1,36 @@ +#include "platform/esp/arduino_common/map_poi/cjson_poi_parser.h" +#include +#include +#include + +using platform::esp::arduino_common::map_poi::CJsonPoiParser; +int main() +{ + CJsonPoiParser parser; + ui::map_poi::Policy policy; + const std::string manifest = R"({"version":3,"annotation_options":{"RoadZooms":{"Minimum":7,"Maximum":18}},"index":{"enabled_zoom_levels":[1,16,17,18],"scheme":"web-mercator-xyz","format":"jsonl","geometry":"tile-local-pixels","max_path_points":8,"tile_margin":32}})"; + assert(parser.manifest(manifest.data(), manifest.size(), policy)); + assert(policy.schema_version == 3 && policy.enabled(1) && policy.enabled(18) && !policy.enabled(15)); + auto incompatible = manifest; + incompatible.replace(incompatible.find("tile-local-pixels"), 17, "geographic-degrees"); + assert(!parser.manifest(incompatible.data(), incompatible.size(), policy)); + + ui::map_poi::Record record; + const std::string road = u8R"({"id":"ABCDEF0123456789","feature_id":"0123456789ABCDEF","kind":"road","type":"residential","name":"人民路","lat":25.04,"lon":102.71,"priority":175,"path":[-32,80,100,80,288,90]})"; + assert(parser.record(road.data(), road.size(), record)); + assert(record.explicit_kind && record.kind == ui::map::AnnotationKind::Road); + assert(record.feature_key == UINT64_C(0x0123456789ABCDEF)); + assert(record.path_points == 3 && record.path[0] == -32 && record.path[4] == 288); + assert(std::strcmp(record.name, u8"人民路") == 0); + auto wrong_path = road; + wrong_path.replace(wrong_path.find("288"), 3, "289"); + assert(!parser.record(wrong_path.data(), wrong_path.size(), record)); + const std::string place = u8R"({"id":"1111111111111111","feature_id":"2222222222222222","kind":"place","type":"regional_capital","name":"昆明市","lat":25.04,"lon":102.71,"priority":225})"; + assert(parser.record(place.data(), place.size(), record)); + assert(record.kind == ui::map::AnnotationKind::Place && record.path_points == 0); + const std::string old = R"({"id":"node/1","type":"water","name":"Water","lat":25,"lon":102,"priority":90})"; + assert(parser.record(old.data(), old.size(), record)); + assert(!record.explicit_kind && record.kind == ui::map::AnnotationKind::Poi && record.feature_key != 0); + const std::string unnamed_road = R"({"id":"a","feature_id":"2222222222222222","kind":"road","type":"service","lat":25,"lon":102,"path":[0,0,256,256]})"; + assert(!parser.record(unnamed_road.data(), unnamed_road.size(), record)); +} diff --git a/tests/map_poi/validate_annotation_package.cpp b/tests/map_poi/validate_annotation_package.cpp new file mode 100644 index 00000000..b0ca523f --- /dev/null +++ b/tests/map_poi/validate_annotation_package.cpp @@ -0,0 +1,47 @@ +#include "platform/esp/arduino_common/map_poi/cjson_poi_parser.h" +#include +#include +#include +#include +#include + +int main(int argc, char** argv) +{ + if (argc != 2) return 2; + const std::filesystem::path root(argv[1]); + platform::esp::arduino_common::map_poi::CJsonPoiParser parser; + ui::map_poi::Policy policy; + std::ifstream manifest(root / "manifest.json"); + std::stringstream source; + source << manifest.rdbuf(); + const auto json = source.str(); + if (!parser.manifest(json.data(), json.size(), policy) || policy.schema_version != 3) return 3; + for (int z = 1; z <= 18; ++z) + if (!policy.enabled(z)) return 4; + std::size_t files = 0, rows = 0, roads = 0, places = 0, pois = 0; + ui::map_poi::Record record; + for (const auto& entry : std::filesystem::recursive_directory_iterator(root / "index")) + { + if (!entry.is_regular_file() || entry.path().extension() != ".jsonl") continue; + std::ifstream input(entry.path()); + std::string line; + std::size_t count = 0; + while (std::getline(input, line)) + { + if (!parser.record(line.data(), line.size(), record) || !record.explicit_kind) + { + std::cerr << entry.path() << ':' << count + 1 << " invalid annotation\n"; + return 5; + } + ++count; + ++rows; + if (record.kind == ui::map::AnnotationKind::Road) ++roads; + else if (record.kind == ui::map::AnnotationKind::Place) ++places; + else ++pois; + } + if (count > ui::map_poi::TileHeader::kMaxRecords) return 6; + ++files; + } + std::cout << "files=" << files << " rows=" << rows << " roads=" << roads << " places=" << places << " pois=" << pois << '\n'; + return roads && places && pois ? 0 : 7; +} diff --git a/tests/map_poi_view/CMakeLists.txt b/tests/map_poi_view/CMakeLists.txt index 98609072..e30610c3 100644 --- a/tests/map_poi_view/CMakeLists.txt +++ b/tests/map_poi_view/CMakeLists.txt @@ -16,3 +16,32 @@ if(MSVC) endif() enable_testing() add_test(NAME map_poi_view_contract COMMAND map_poi_view_contract) +add_executable(map_annotation_view_contract test_prepared_frame.cpp + ${REPO}/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/annotation_layout.cpp) +target_include_directories(map_annotation_view_contract BEFORE PRIVATE stubs + ${REPO}/modules/ui_shared/include ${REPO}/modules/ui_presentation/include ${REPO}/modules/ui_map_runtime/include) +target_link_libraries(map_annotation_view_contract PRIVATE lvgl) +if(MSVC) + target_compile_options(map_annotation_view_contract PRIVATE /utf-8 /EHsc) +endif() +add_test(NAME map_annotation_view_contract COMMAND map_annotation_view_contract) +add_executable(map_marker_semantics_contract test_marker_semantics.cpp ${REPO}/modules/ui_shared/src/ui/widgets/map/poi_overlay.cpp) +target_include_directories(map_marker_semantics_contract BEFORE PRIVATE stubs ${REPO}/modules/ui_shared/include ${REPO}/modules/ui_presentation/include) +target_link_libraries(map_marker_semantics_contract PRIVATE lvgl) +add_test(NAME map_marker_semantics_contract COMMAND map_marker_semantics_contract) +set(CJSON_DIR "" CACHE PATH "cJSON source for package replay") +if(CJSON_DIR) + add_executable(replay_annotation_layout replay_annotation_layout.cpp + ${CJSON_DIR}/cJSON.c + ${REPO}/platform/esp/arduino_common/src/map_poi/cjson_poi_parser.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/annotation_frame.cpp + ${REPO}/modules/ui_map_runtime/src/map_poi/annotation_layout.cpp) + target_include_directories(replay_annotation_layout PRIVATE ${CJSON_DIR} + ${REPO}/platform/esp/arduino_common/include ${REPO}/modules/ui_map_runtime/include ${REPO}/modules/ui_presentation/include) + target_link_libraries(replay_annotation_layout PRIVATE lvgl) + if(MSVC) + target_compile_options(replay_annotation_layout PRIVATE /utf-8 /EHsc) + endif() +endif() diff --git a/tests/map_poi_view/lv_conf.h b/tests/map_poi_view/lv_conf.h index f5780853..7c8d6d5c 100644 --- a/tests/map_poi_view/lv_conf.h +++ b/tests/map_poi_view/lv_conf.h @@ -11,4 +11,5 @@ #define LV_USE_THORVG_INTERNAL 0 #define LV_USE_LOG 0 #define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_10 1 #endif diff --git a/tests/map_poi_view/replay_annotation_layout.cpp b/tests/map_poi_view/replay_annotation_layout.cpp new file mode 100644 index 00000000..d098a5a2 --- /dev/null +++ b/tests/map_poi_view/replay_annotation_layout.cpp @@ -0,0 +1,124 @@ +#include "lvgl.h" +#include "platform/esp/arduino_common/map_poi/cjson_poi_parser.h" +#include "ui_map_runtime/map_poi/annotation_frame.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +bool measure(void* context, const char* text, std::size_t bytes, bool ellipsis, int16_t& width, int16_t& height) +{ + char value[80]; + std::memcpy(value, text, bytes); + if (ellipsis) + { + std::memcpy(value + bytes, "\xE2\x80\xA6", 3); + bytes += 3; + } + value[bytes] = 0; + lv_point_t size; + lv_text_get_size(&size, value, static_cast(context), 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + width = size.x; + height = size.y; + return true; +} + +int main(int argc, char** argv) +{ + if (argc != 6) return 2; + const std::filesystem::path maps(argv[1]); + const int zoom = std::stoi(argv[3]); + const double lon = std::stod(argv[4]), lat = std::stod(argv[5]); + const double scale = 256.0 * (1 << zoom), pi = 3.14159265358979323846; + // Host fixture projects the exported XYZ data to a 480x222 north-up view. + // Device projection remains owned by the existing map backend. + const double left = (lon + 180) / 360 * scale - 240; + const double top = (1 - std::asinh(std::tan(lat * pi / 180)) / pi) / 2 * scale - 111; + struct Source + { + ui::map_poi::Record record; + int x, y; + }; + std::vector records; + platform::esp::arduino_common::map_poi::CJsonPoiParser parser; + for (int x = static_cast(std::floor(left / 256)); x <= static_cast(std::floor((left + 480) / 256)); ++x) + for (int y = static_cast(std::floor(top / 256)); y <= static_cast(std::floor((top + 222) / 256)); ++y) + { + auto path = maps / "poi/index" / std::to_string(zoom) / std::to_string(x) / (std::to_string(y) + ".jsonl"); + std::ifstream input(path); + std::string line; + while (std::getline(input, line)) + { + Source value{}; + value.x = x; + value.y = y; + if (!parser.record(line.data(), line.size(), value.record)) return 3; + records.push_back(value); + } + } + lv_init(); + std::ifstream binary(argv[2], std::ios::binary); + std::vector fontBytes((std::istreambuf_iterator(binary)), std::istreambuf_iterator()); + auto* subset = lv_binfont_create_from_buffer(fontBytes.data(), fontBytes.size()); + if (!subset) return 4; + lv_font_t font = lv_font_montserrat_14; + font.fallback = subset; + ui::map_poi::AnnotationFrame frame([](std::size_t size, void*) + { return std::malloc(size); }, + [](void* data, void*) + { std::free(data); }); + ui::map::MapPoiSnapshot metadata; + metadata.enabled = metadata.header.valid = true; + metadata.width = 480; + metadata.height = 222; + metadata.view_key = 1; + metadata.compatibility_key = zoom; + if (!frame.begin(records.size(), metadata)) return 5; + for (const auto& source : records) + { + const auto& r = source.record; + ui::map_poi::AnnotationCandidate c; + c.key = r.key; + c.feature_key = r.feature_key; + c.name = r.name; + c.category = r.category; + c.kind = r.kind; + c.priority = r.priority; + c.x = static_cast((r.lon + 180) / 360 * scale - left); + c.y = static_cast((1 - std::asinh(std::tan(r.lat * pi / 180)) / pi) / 2 * scale - top); + c.path_points = r.path_points; + for (int p = 0; p < r.path_points; ++p) + { + c.path[p * 2] = static_cast(source.x * 256.0 + r.path[p * 2] - left); + c.path[p * 2 + 1] = static_cast(source.y * 256.0 + r.path[p * 2 + 1] - top); + } + frame.add(c); + } + ui::map_poi::AnnotationLayoutOptions options; + options.width = 480; + options.height = 222; + options.max_labels = zoom <= 6 ? 8 : zoom <= 11 ? 16 + : zoom <= 15 ? 24 + : 32; + options.road_reservation = zoom <= 6 ? 0 : zoom <= 11 ? 3 + : 8; + options.place_reservation = zoom <= 11 ? 6 : 2; + if (!frame.finish(options, measure, &font)) return 6; + const auto& result = frame.result(); + std::cout << "z=" << zoom << " records=" << records.size() << " admitted=" << frame.candidate_count() + << " roads=" << result.road_labels << " places=" << result.place_labels << " pois=" << result.poi_labels + << " unnamed=" << result.unnamed_markers << " collisions=" << result.collisions << '\n'; + for (std::size_t i = 0; i < frame.snapshot().item_count; ++i) + { + const auto& item = frame.snapshot().items[i]; + std::cout << static_cast(item.kind) << '\t' << item.text_x << '\t' << item.text_y << '\t' << item.label.c_str() << '\n'; + } + frame.clear(); + lv_binfont_destroy(subset); + lv_deinit(); +} diff --git a/tests/map_poi_view/test_marker_semantics.cpp b/tests/map_poi_view/test_marker_semantics.cpp new file mode 100644 index 00000000..0f37c6b4 --- /dev/null +++ b/tests/map_poi_view/test_marker_semantics.cpp @@ -0,0 +1,46 @@ +#include "ui/widgets/map/poi_overlay.h" +#include +#include + +int main() +{ + lv_init(); + auto* display = lv_display_create(100, 60); + lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565); + std::vector pixels(6000); + lv_display_set_buffers(display, pixels.data(), nullptr, pixels.size() * 2, 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, 100, 60); + ui::widgets::map::PoiOverlay view; + view.create(root); + lv_obj_update_layout(root); + ui::map::MapPoiItem item; + item.marker = true; + item.x = 30; + item.y = 30; + ui::copyText(item.category, "water"); + ui::map::MapPoiSnapshot snapshot; + snapshot.enabled = snapshot.layout_ready = true; + snapshot.items = &item; + snapshot.item_count = snapshot.capacity = 1; + view.update(snapshot); + lv_refr_now(display); + auto single = pixels; + item.cluster_count = 3; + view.update(snapshot); + lv_refr_now(display); + assert(pixels != single); + auto three = pixels; + item.cluster_count = 5; + view.update(snapshot); + lv_refr_now(display); + assert(pixels != three); + assert(lv_obj_get_child_count(lv_obj_get_child(root, -1)) == 0); + view.clear(); + lv_obj_delete(root); + lv_display_delete(display); + lv_deinit(); +} diff --git a/tests/map_poi_view/test_prepared_frame.cpp b/tests/map_poi_view/test_prepared_frame.cpp new file mode 100644 index 00000000..0c44af58 --- /dev/null +++ b/tests/map_poi_view/test_prepared_frame.cpp @@ -0,0 +1,80 @@ +#include "ui/widgets/map/poi_overlay.h" +#include "ui_map_runtime/map_poi/annotation_frame.h" +#include +#include +#include +#include + +int main() +{ + lv_init(); + 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() * 2, 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(0x00FF00), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + ui::widgets::map::PoiOverlay view; + view.create(root); + lv_obj_update_layout(root); + ui::map_poi::AnnotationFrame frame( + [](std::size_t size, void*) + { return std::malloc(size); }, + [](void* data, void*) + { std::free(data); }); + ui::map::MapPoiSnapshot metadata; + metadata.enabled = metadata.header.valid = true; + metadata.width = 480; + metadata.height = 222; + metadata.view_key = 1; + metadata.compatibility_key = 1; + ui::map_poi::AnnotationCandidate road; + road.key = road.feature_key = 1; + road.kind = ui::map::AnnotationKind::Road; + road.name = "People Road"; + road.category = "residential"; + road.priority = 175; + road.x = -1000; + road.y = 80; + road.path_points = 2; + road.path[0] = -32; + road.path[1] = 80; + road.path[2] = 288; + road.path[3] = 80; + assert(frame.begin(1, metadata)); + frame.add(road); + view.prepare_text(road.name); + ui::map_poi::AnnotationLayoutOptions options; + options.width = 480; + options.height = 222; + assert(frame.finish(options, ui::widgets::map::PoiOverlay::measure_text, &view)); + assert(frame.result().road_labels == 1); + const auto& item = frame.snapshot().items[0]; + assert(!item.marker && item.text_width > 0); + view.update(frame.snapshot()); + lv_refr_now(display); + int ink = 0, transparent = 0; + for (int y = item.text_y; y < item.text_y + item.text_height; ++y) + for (int x = item.text_x; x < item.text_x + item.text_width; ++x) + { + const auto pixel = pixels[y * 480 + x]; + if (pixel == 0x07E0) ++transparent; + else ++ink; + assert(pixel != 0xFFFF && pixel != 0xFFBD); + } + assert(ink > 10 && transparent > 10); + assert(lv_obj_get_child_count(lv_obj_get_child(root, -1)) == 0); + view.clear(); + frame.clear(); + lv_refr_now(display); + assert(std::all_of(pixels.begin(), pixels.end(), [](uint16_t p) + { return p == 0x07E0; })); + lv_obj_delete(root); + lv_display_delete(display); + lv_deinit(); +} diff --git a/tests/map_poi_view/test_view.cpp b/tests/map_poi_view/test_view.cpp index f59c3685..c9a1f6ef 100644 --- a/tests/map_poi_view/test_view.cpp +++ b/tests/map_poi_view/test_view.cpp @@ -1,4 +1,5 @@ #include "ui/widgets/map/poi_overlay.h" +#include #include #include #include @@ -38,7 +39,7 @@ int main(int argc, char** argv) 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_color(root, lv_color_hex(0x00FF00), 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); @@ -48,6 +49,7 @@ int main(int argc, char** argv) std::vector items(48); ui::map::MapPoiSnapshot snapshot; snapshot.enabled = true; + snapshot.layout_ready = true; snapshot.labels = true; snapshot.items = items.data(); snapshot.capacity = items.size(); @@ -61,7 +63,8 @@ int main(int argc, char** argv) } overlay.update(snapshot); lv_refr_now(display); - assert(pixels[25 * 480 + 15] != 0xFFFF); // actual marker rasterisation + assert(pixels[25 * 480 + 15] != 0x07E0); // actual marker rasterisation + assert(std::count(pixels.begin(), pixels.end(), static_cast(0xFFBD)) == 0); // no cream label plate 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 @@ -74,7 +77,7 @@ int main(int argc, char** argv) } overlay.clear(); lv_refr_now(display); - assert(pixels[25 * 480 + 15] == 0xFFFF); + assert(pixels[25 * 480 + 15] == 0x07E0); snapshot.enabled = false; overlay.update(snapshot); lv_refr_now(display);