feat(ui): converge shared map viewport and localization contracts

This commit is contained in:
liu weikai
2026-04-22 22:10:45 +08:00
parent c4b763d2ea
commit c4e300a57f
41 changed files with 5776 additions and 1340 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -92,6 +92,7 @@ set(trail_mate_idf_ui_shared_sources
${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/widgets/top_bar.cpp
${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/widgets/busy_overlay.cpp
${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/widgets/system_notification.cpp
${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp
${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/widgets/toast/toast_widget.cpp
${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp
${CMAKE_SOURCE_DIR}/modules/ui_shared/src/ui/widgets/ime/pinyin_ime.cpp
+62
View File
@@ -0,0 +1,62 @@
# ESP Shared SPI Bus Rule
## Scope
本文档约束 ESP 平台上“多个外设共享同一条 SPI 总线”的运行时访问规则。
当前直接受此规则约束的典型设备包括:
- `T-Deck`
- `T-LoRa Pager`
在这些设备上,显示、SD、LoRa、NFC 等外设并不是各自拥有独立 SPI 控制器,而是在板级上共享同一条 SPI 总线。
---
## Rule
`shared_spi_lock` 表达的是 **共享 SPI 总线所有权**,不是“显示锁”。
它的职责是:
- 串行化共享 SPI 总线上的访问
- 防止显示刷新与 SD / LoRa / NFC 等访问并发打总线
- 作为运行时层统一的总线仲裁入口
推荐用法:
- 直接调用 `shared_spi_lock` / `shared_spi_unlock`
- 优先使用 `SharedSpiLockGuard`
---
## Naming Contract
以下命名语义已经固定:
- `shared_spi_lock`
- 含义是“申请共享 SPI 总线所有权”
- `SharedSpiLockGuard`
- 含义是“一个有作用域的共享 SPI 总线占用会话”
`display_spi_lock` 只允许作为 **历史兼容别名** 存在,不能再作为新代码的主命名。
原因是:
- 真实被保护的对象不是 display
- 而是 board-level shared SPI bus
如果后续新代码继续使用“display lock”语义命名,等同于重新把总线仲裁误导回显示私有概念。
---
## Boundary
本规则约束的是:
- 平台运行时
- UI 运行时
- 地图瓦片 / 轨迹 / USB MSC / SSTV 等共享 SPI 访问路径
本规则不强制板级显示驱动内部必须如何组织其私有 mutex 实现;
但只要代码已经站在“共享 SPI 访问者”位置,而不是“显示驱动私有实现”位置,就应通过共享 SPI 语义入口表达自己。
+211 -213
View File
@@ -1,40 +1,46 @@
# Locale, Font, and IME Packs
# Locale、Font 与 IME Pack
## Goals
本文档解释 pack 机制与打包细节。
整个本地化系统的规范性规格现在位于
[`docs/LOCALIZATION_SPEC.md`](./LOCALIZATION_SPEC.md)。
如果两份文档之间存在冲突,以 `LOCALIZATION_SPEC.md` 为准。
The pack system exists to solve four problems at the same time:
## 目标
1. Keep the firmware image minimal. English stays built in, large script assets move to SD.
2. Keep `installed` separate from `loaded`. A pack being present on SD must not mean its font is already in RAM.
3. Let mixed-language content render correctly. A Spanish UI still needs to show a Chinese contact name if that font pack is installed.
4. Bound RAM usage across very different devices, including no-PSRAM targets.
pack 系统同时为了解决四个问题而存在:
This is now a first-class runtime architecture, not a collection of text-based bypasses.
1. 让固件镜像保持尽可能小。English 保持内建,大体积脚本资源移出固件镜像,进入外部 pack 存储。
2. 明确区分 `installed` 与 `loaded`。一个 pack 出现在 Flash 或 SD 上,并不意味着它的字体已经加载进 RAM。
3. 让混合语言内容正确渲染。即使 UI 是西班牙语,只要对应字体 pack 已安装,系统仍应能显示中文联系人名。
4. 给 RAM 使用量设定边界,适配差异很大的设备,包括无 PSRAM 目标。
## Core Model
这已经是一套一等公民的运行时架构,而不是若干基于文本的旁路拼接。
Trail Mate treats localization as three explicit pack types:
## 核心模型
Trail Mate 把本地化明确建模为三类 pack:
- `Locale Pack`
Owns translated UI strings and declares which UI font pack, content font pack, and optional IME pack it needs.
拥有翻译后的 UI 字符串,并声明它依赖的 UI font pack、content font pack,以及可选的 IME pack。
- `Font Pack`
Owns glyph coverage metadata plus the external `font.bin` asset.
拥有字形覆盖元数据以及外部 `font.bin` 资源。
- `IME Pack`
Declares script-specific input behavior such as Pinyin.
声明脚本特定的输入行为,例如拼音。
The Settings page selects a `Locale Pack`. It does not directly choose a font or IME.
Settings 页选择的是 `Locale Pack`,而不是直接选择字体或 IME。
## Built-In vs External
## 内建与外部
The firmware intentionally ships with the smallest built-in baseline:
固件有意只携带最小内建基线:
- built-in locale packs: `en`
- built-in font packs: `builtin-latin-ui`
- built-in IME packs: none
- 内建 locale pack:`en`
- 内建 font pack:`builtin-latin-ui`
- 内建 IME pack:无
English is therefore always available, even when the SD card has no language packs at all.
因此,即使没有任何外部语言包存在,English 也始终可用。
External packs are discovered from the SD card under:
运行时 payload 可以从外部 pack 根目录中被发现,例如 SD,或者设备上的 Flash 安装存储。
具体运行时根目录由当前固件实现定义;已安装布局保持为:
```text
/trailmate/packs/fonts/<font-pack-id>/manifest.ini
@@ -42,30 +48,29 @@ External packs are discovered from the SD card under:
/trailmate/packs/ime/<ime-pack-id>/manifest.ini
```
At boot the registry is built in two phases:
启动时,registry 分两阶段建立:
1. Catalog every built-in pack and every external pack manifest.
2. Resolve dependencies and decide which locales are allowed on the current memory profile.
1. 编目所有内建 pack 以及所有外部 pack manifest。
2. 解析依赖,并根据当前 memory profile 决定哪些 locale 被允许使用。
Important: manifest discovery is cheap. External fonts are not loaded into RAM during cataloging.
重要说明:发现 manifest 的成本很低。编目阶段不会把外部字体加载进 RAM。
## Three Layouts
## 三种布局
The same localization asset now exists in three different representations, and keeping them
separate is intentional:
同一份本地化资源现在同时存在三种表示形式,刻意把它们分开是设计要求的一部分:
### Repository Source Bundle
### 仓库源码 Bundle
What lives under `packs/<bundle-id>/` in Git.
Git 中 `packs/<bundle-id>/` 下的内容。
- contains runtime manifests
- contains human-facing package metadata
- contains build-only files such as `charset.txt` and `build.ini`
- may omit tracked `font.bin`, because the Pages build can regenerate it
- 包含运行时 manifest
- 包含面向人的 package 元数据
- 包含仅构建期使用的文件,例如 `charset.txt` 与 `build.ini`
- 可能不跟踪 `font.bin`,因为 Pages 构建可以重新生成它
### Installed Runtime Layout
### 已安装运行时布局
What the firmware actually scans on SD:
固件真正扫描的已安装运行时布局:
```text
/trailmate/packs/fonts/<font-pack-id>/...
@@ -73,55 +78,53 @@ What the firmware actually scans on SD:
/trailmate/packs/ime/<ime-pack-id>/...
```
This is the only layout the runtime registry understands.
这是运行时 registry 唯一理解的布局。
### Distribution Package
### 分发包
What the website and future Extensions page should traffic in:
网站以及未来 Extensions 页面应该分发和消费的对象:
- one zip per installable bundle
- one package manifest with version and compatibility metadata
- one description file for UI presentation
- one remote catalog entry for discovery and update checks
- 每个可安装 bundle 一个 zip
- 一个带版本与兼容性元数据的 package manifest
- 一个用于 UI 展示的描述文件
- 一个用于发现与更新检查的远程 catalog 条目
The runtime does not scan zip files directly. The package manager layer downloads a zip,
unpacks its payload into the installed runtime layout, then asks the runtime registry to
refresh.
运行时不会直接扫描 zip。package manager 层负责下载 zip,把其中 payload 解开到已安装运行时布局,然后要求运行时 registry 刷新。
## Installed Is Not Loaded
## Installed 不等于 Loaded
This distinction is the center of the design.
这是整个设计的中心区分。
- `Installed`
The manifest exists on SD and the registry knows the pack exists.
manifest 存在于 SD 上,registry 知道这个 pack 存在。
- `Loaded`
The external `font.bin` has actually been passed to `lv_binfont_create()` and now consumes runtime RAM.
外部 `font.bin` 已经真正传给 `lv_binfont_create()`,现在开始消耗运行时 RAM。
The runtime behaves like this:
运行时的行为如下:
1. The active locale is resolved from `settings/display_locale`.
2. The active UI font pack is loaded immediately when that locale is activated.
3. The active content font pack is loaded lazily, only when content-scope text needs it.
4. Additional content supplement packs are loaded lazily if the current text contains codepoints not covered by the active content chain.
5. Changing locale unloads all runtime-loaded external fonts and rebuilds the chains from scratch.
1. 从 `settings/display_locale` 解析当前活动 locale。
2. 当该 locale 被激活时,立即加载活动 UI font pack。
3. 活动 content font pack 采用惰性加载,只在 content-scope 文本真正需要时才加载。
4. 如果当前文本包含活动 content chain 尚未覆盖的 codepoint,则惰性加载额外的 content supplement pack。
5. 切换 locale 时,会卸载所有运行时已加载的外部字体,并从头重建整条链。
That means a device can have many packs installed while only one or two are resident in RAM.
这意味着,一个设备可以安装很多 pack,但任意时刻真正驻留在 RAM 中的只会有一到两个。
## UI Scope vs Content Scope
## UI Scope 与 Content Scope
The runtime keeps two different fallback chains on purpose.
运行时有意维持两条不同的 fallback chain。
### UI Chain
Used for static application chrome:
用于静态应用 chrome:
- menu labels
- settings labels
- headings
- buttons
- other translated interface text
- 菜单标签
- 设置页标签
- 标题
- 按钮
- 其他翻译后的界面文本
The chain is:
链路如下:
```text
screen-selected Latin base font -> active UI font pack
@@ -129,16 +132,16 @@ screen-selected Latin base font -> active UI font pack
### Content Chain
Used for user-generated or externally received text:
用于用户生成或外部接收的文本:
- chat sender lines
- chat message previews and bodies
- contact names
- team member names
- node names and descriptions
- locale display names shown in the selector
- 聊天发送者行
- 聊天预览与正文
- 联系人名
- 队伍成员名
- 节点名与描述
- 选择器中显示的 locale 名称
The chain is:
链路如下:
```text
screen-selected Latin base font
@@ -147,47 +150,47 @@ screen-selected Latin base font
-> lazily loaded content supplement packs
```
This is what lets a non-Chinese UI still render Chinese content when the corresponding pack is installed.
这就是为什么在非中文 UI 下,只要安装了相应 pack,系统仍然能正确显示中文内容。
## Memory Profiles
## Memory Profile
Pack availability is constrained by a board-specific memory profile in shared runtime code.
pack 的可用性由共享运行时代码中的 board-specific memory profile 约束。
Current profiles:
当前 profile 如下:
- `constrained`
Locale font budget `128 KiB`, content supplements disabled, decoded map cache `2` tiles, cache not retained on page exit.
locale 字体预算 `128 KiB`,禁用 content supplement,解码后的地图 cache 为 `2` 张 tile,页面退出后不保留 cache。
- `standard`
Locale font budget `768 KiB`, content supplement budget `640 KiB`, at most `1` supplement pack, decoded map cache `4` tiles, cache not retained.
locale 字体预算 `768 KiB`,content supplement 预算 `640 KiB`,最多 `1` 个 supplement pack,解码后的地图 cache 为 `4` 张 tile,页面退出后不保留 cache。
- `extended`
Locale font budget `2 MiB`, content supplement budget `2 MiB`, at most `3` supplement packs, decoded map cache `12` tiles, cache retained on page exit.
locale 字体预算 `2 MiB`,content supplement 预算 `2 MiB`,最多 `3` 个 supplement pack,解码后的地图 cache 为 `12` 张 tile,页面退出后保留 cache。
Current board mapping:
当前板级映射:
- `extended`: `Tab5`, `T-Display P4`
- `standard`: `T-Deck`, `T-Deck Pro`
- `constrained`: everything else, including no-PSRAM and pager-class targets
- `extended`:`Tab5`、`T-Display P4`
- `standard`:`T-Deck`、`T-Deck Pro`
- `constrained`:其余所有设备,包括无 PSRAM 和 pager 级目标
The locale budget is checked against the actual active locale cost:
locale 预算会基于当前活动 locale 的真实成本进行检查:
```text
unique(UI font pack, content font pack)
```
The supplement budget is separate and only applies to extra content packs that are pulled in later for mixed-script content.
supplement 预算与之分离,只作用于后续为混合脚本内容额外拉入的 content pack。
## Persistence
## 持久化
The active locale is stored as:
活动 locale 存储为:
- namespace: `settings`
- key: `display_locale`
- namespace:`settings`
- key:`display_locale`
Legacy ESP installs using the old integer `display_language` key are migrated once to the new string key. After migration, the legacy key is removed.
旧版 ESP 安装使用的整型 `display_language` key,会被一次性迁移到新的字符串 key。迁移完成后,旧 key 会被移除。
## Manifest Schema
Manifests are plain `key=value` files.
manifest 使用普通的 `key=value` 文件格式。
### Font Pack Manifest
@@ -202,24 +205,24 @@ file=font.bin
ranges=ranges.txt
```
Fields:
字段说明:
- `id`
Stable pack identifier.
稳定的 pack 标识符。
- `display_name`
Human-readable name used for diagnostics.
用于诊断的人类可读名称。
- `usage`
One of `ui`, `content`, or `both`.
取值为 `ui`、`content` 或 `both`。
- `estimated_ram_bytes`
Expected runtime RAM cost after loading with LVGL binfont loader. This is used for profile decisions and supplement planning.
使用 LVGL binfont loader 加载后的预期运行时 RAM 成本。该值用于 profile 决策与 supplement 规划。
- `source`
Currently `binfont` for external files, or `builtin` for an alias to a compiled font pack.
当前外部文件使用 `binfont`,编译进固件的字体别名使用 `builtin`。
- `file`
Path to `font.bin`, relative to the pack directory.
`font.bin` 的相对路径,相对于该 pack 目录。
- `ranges`
Coverage metadata file, relative to the pack directory. This is used for codepoint planning, not for rendering.
覆盖元数据文件的相对路径,相对于该 pack 目录。它用于 codepoint 规划,而不是直接用于渲染。
If `estimated_ram_bytes` is missing or `0`, the runtime treats the pack as having unknown cost and cannot budget it accurately. Repository packs should always provide it.
如果 `estimated_ram_bytes` 缺失或为 `0`,运行时会把该 pack 视为“成本未知”,因而无法准确做预算。仓库中的 pack 应始终提供此字段。
### IME Pack Manifest
@@ -230,11 +233,11 @@ display_name=Pinyin
backend=builtin-pinyin
```
Today the shipped backend implementation is:
目前已出货的 backend 实现是:
- `builtin-pinyin`
The backend lives in firmware code. The IME pack manifest is the runtime registration layer that exposes it.
backend 的真实实现位于固件代码中。IME pack manifest 是把它注册进运行时并暴露出来的那一层。
### Locale Pack Manifest
@@ -249,7 +252,7 @@ ime_pack=zh-hans-pinyin
strings=strings.tsv
```
Example with tiered Chinese coverage:
一个带分层中文覆盖的示例:
```ini
kind=locale
@@ -263,68 +266,68 @@ ime_pack=zh-hans-pinyin
strings=strings.tsv
```
Fields:
字段说明:
- `id`
Locale identifier shown in persistence and logs.
用于持久化和日志的 locale 标识符。
- `display_name`
English-facing name.
面向英文语境的名称。
- `native_name`
Native self-name shown in the selector.
在选择器中显示的本语言自称。
- `ui_font_pack`
Font pack used for interface chrome in this locale.
该 locale 用于界面 chrome 的 font pack。
- `content_font_pack`
Font pack preferred for content surfaces in this locale.
该 locale 在内容表面优先使用的 font pack。
- `preferred_content_supplement_packs`
Optional comma-separated list of content supplement font packs to try first when this locale encounters missing glyphs.
可选的逗号分隔 content supplement font pack 列表;当这个 locale 遇到缺字时,优先尝试它们。
- `ime_pack`
Optional IME dependency.
可选 IME 依赖。
- `strings`
Path to the locale TSV file, relative to the pack directory.
locale TSV 文件的相对路径,相对于该 pack 目录。
If `content_font_pack` is omitted, it defaults to `ui_font_pack`.
If `ui_font_pack` is omitted, it defaults to `builtin-latin-ui`.
如果省略 `content_font_pack`,默认回退为 `ui_font_pack`。
如果省略 `ui_font_pack`,默认回退为 `builtin-latin-ui`。
## String Table Format
## String Table 格式
Locale strings are stored as TSV:
locale 字符串以 TSV 存储:
```text
English source string<TAB>Localized string
```
Supported escapes:
支持的转义包括:
- `\\n`
- `\\t`
- `\\r`
- `\\\\`
Example:
示例:
```text
Settings Paramètres
Send this code and compare:\\n Envoyez ce code et comparez:\\n
```
The English source string remains the stable lookup key in code.
代码中的稳定查找 key 始终是 English 源字符串。
## Failure Behavior
## 失败行为
If a locale pack cannot be used:
如果某个 locale pack 无法被使用:
- missing font pack dependency: locale is skipped
- missing IME dependency: locale is skipped
- active locale font cost exceeds the current memory profile: locale is skipped
- persisted locale id no longer resolves: runtime falls back to `en`
- 缺少依赖的 font pack:跳过该 locale
- 缺少依赖的 IME pack:跳过该 locale
- 活动 locale 的字体成本超出当前 memory profile:跳过该 locale
- 持久化的 locale id 不再能解析:运行时回退到 `en`
The persisted `display_locale` value is not erased just because a removable pack is absent. If the pack returns later, the locale becomes selectable again.
不会仅仅因为可移除 pack 当前缺席,就把持久化的 `display_locale` 值擦掉。如果该 pack 之后重新出现,这个 locale 会再次变得可选。
If a content supplement cannot be loaded, the UI still stays alive. That specific text may render as missing glyphs, but the active locale remains unchanged.
如果某个 content supplement 无法加载,UI 仍然要继续存活。只有那段具体文本可能显示为缺字,但活动 locale 不会因此改变。
## Repository Bundles
## 仓库 Bundle
The repository ships source bundles under:
仓库当前提供的源码 bundle 位于:
- `packs/europe-latin-ext`
- `packs/zh-Hans`
@@ -332,24 +335,22 @@ The repository ships source bundles under:
- `packs/ja`
- `packs/ko`
Each source bundle contains:
每个源码 bundle 包含:
- `package.ini`
- `DESCRIPTION.txt`
- generation notes in `README.md`
- runtime payload trees under `fonts/`, `locales/`, and optional `ime/`
- `build.ini` plus `charset.txt` in each external font-pack directory
- `ranges.txt` for runtime coverage planning
- `README.md` 中的生成说明
- 位于 `fonts/`、`locales/` 与可选 `ime/` 下的运行时 payload 树
- 每个外部 font-pack 目录中的 `build.ini` 与 `charset.txt`
- 供运行时覆盖规划使用的 `ranges.txt`
`font.bin` is intentionally not part of the source-of-truth layout. If it already exists
locally, it will be used. If it is absent, the Pages pack build regenerates it from the
bundled `charset.txt` and `build.ini` metadata before producing the zip archive.
`font.bin` 有意不作为 source-of-truth 布局的一部分。如果本地已经存在,就直接使用;如果缺失,Pages pack 构建会在生成 zip 之前,基于 bundle 内的 `charset.txt` 与 `build.ini` 元数据重新生成它。
## Package Manifest
Every source bundle now carries a bundle-level `package.ini`.
每个源码 bundle 现在都带有 bundle-level 的 `package.ini`。
Example:
示例:
```ini
kind=package
@@ -367,38 +368,38 @@ supported_memory_profiles=standard,extended
tags=language,cjk,chinese,ime
```
Fields:
字段说明:
- `id`
Stable package identifier used by the remote catalog and future installed-index file.
稳定 package 标识符,供远程 catalog 与未来的 installed-index 文件使用。
- `package_type`
High-level package role. Today repository bundles use `locale-bundle`.
高层级 package 角色。当前仓库 bundle 使用 `locale-bundle`。
- `version`
Package version, independent from the firmware tag.
package 版本号,独立于固件 tag。
- `display_name`
Human-facing package name for the Extensions UI.
Extensions UI 中给用户看的 package 名称。
- `summary`
Short one-line description for list views.
用于列表视图的一行简述。
- `description`
Plain-text description file relative to the bundle root.
相对于 bundle 根目录的纯文本描述文件。
- `readme`
Developer-facing documentation file relative to the bundle root.
相对于 bundle 根目录的面向开发者文档文件。
- `author`
Package publisher string.
package 发布者字符串。
- `homepage`
Project or package home page.
项目或 package 首页。
- `min_firmware_version`
Minimum firmware version expected to understand the bundle contract correctly.
正确理解该 bundle 契约所要求的最小固件版本。
- `supported_memory_profiles`
Declared compatibility hint for `constrained`, `standard`, and/or `extended` devices.
声明式兼容性提示,可取 `constrained`、`standard` 与/或 `extended`。
- `tags`
Search and grouping metadata for a future Extensions browser.
供未来 Extensions 浏览器搜索与分组的元数据。
## Font Build Metadata
## Font 构建元数据
Each external font-pack directory may also declare a `build.ini` used only by tooling.
每个外部 font-pack 目录还可以声明一个仅供工具链使用的 `build.ini`。
Example:
示例:
```ini
font=tools/fonts/NotoSansCJKsc-Regular.otf
@@ -407,28 +408,28 @@ bpp=2
no_compress=true
```
Fields:
字段说明:
- `font`
Source font file in the repository.
仓库中的源字体文件。
- `size`
Pixel size passed to the binfont generator.
传给 binfont generator 的像素尺寸。
- `bpp`
Bits-per-pixel for the generated font.
生成字体的 bits-per-pixel。
- `no_compress`
Whether the generated `font.bin` should disable lv_font_conv RLE compression.
是否让生成的 `font.bin` 禁用 lv_font_conv 的 RLE 压缩。
This file is build metadata only. The runtime never reads it.
这个文件只属于构建期元数据。运行时永远不会读取它。
## Distribution Archive Layout
## 分发 Archive 布局
The GitHub Pages build now produces one zip per bundle under:
GitHub Pages 构建现在会在以下路径下为每个 bundle 生成一个 zip:
```text
site/assets/packs/<package-id>-<version>.zip
```
Each archive contains:
每个 archive 包含:
```text
package.ini
@@ -439,81 +440,78 @@ payload/locales/<locale-id>/...
payload/ime/<ime-pack-id>/...
```
Inside `payload/`, only installable runtime files are included. Build-only files such as
`charset.txt`, `build.ini`, and `.gitignore` are excluded from the archive.
`payload/` 内只包含可安装的运行时文件。像 `charset.txt`、`build.ini` 和 `.gitignore` 这类仅构建期文件都会被排除在 archive 之外。
## Remote Catalog
## 远程 Catalog
The Pages build also produces:
Pages 构建还会生成:
```text
site/data/packs.json
```
This catalog is the discovery surface for a future Extensions UI. Each entry contains:
这个 catalog 是未来 Extensions UI 的发现面。每个条目包含:
- package id, version, display name, summary, and long description
- compatibility hints such as `min_firmware_version` and supported memory profiles
- the list of locale/font/IME records provided by the bundle
- archive path, size, and SHA-256 for download and update checks
- estimated runtime font RAM totals for planning before install
- package id、version、display name、summary 和长描述
- `min_firmware_version`、supported memory profiles 之类的兼容性提示
- bundle 提供的 locale/font/IME 记录列表
- 用于下载与更新检查的 archive 路径、大小和 SHA-256
- 用于安装前规划的预计运行时字体 RAM 总量
The catalog intentionally sits above the runtime registry. It describes downloadable bundles,
not currently loaded fonts.
这个 catalog 被有意设计在 runtime registry 之上。它描述的是“可下载 bundle”,而不是“当前已经加载的字体”。
## Installed Package Index
## 已安装 Package 索引
The runtime registry still catalogs installed unpacked resources directly from `/trailmate/packs`.
For package management, the corresponding installer layer should additionally maintain:
runtime registry 仍然直接从 `/trailmate/packs` 对已解包资源进行编目。
对于 package 管理,对应的 installer 层还应额外维护:
```text
/trailmate/packs/.index/installed.json
```
That file is expected to record:
该文件应记录:
- installed package id
- installed package version
- install time
- source archive SHA-256
- 已安装的 package id
- 已安装的 package version
- 安装时间
- 来源 archive 的 SHA-256
Future update prompts should compare this installed index against `site/data/packs.json`
instead of guessing based on directory names.
未来的更新提示应把这个 installed index 与 `site/data/packs.json` 对比,而不是靠目录名猜测。
## Current IME Coverage
## 当前 IME 覆盖
Today only Simplified Chinese declares an IME pack in the repository bundle:
目前仓库 bundle 中,只有简体中文声明了 IME pack:
- `zh-Hans` -> `zh-hans-pinyin`
Traditional Chinese, Japanese, and Korean bundles are display-only for now. They intentionally keep input on the existing `EN` / `123` path until dedicated IME packs exist.
繁体中文、日文和韩文 bundle 目前都是 display-only。它们有意继续沿用现有 `EN` / `123` 输入路径,直到对应脚本的专用 IME pack 出现。
## Adding A New Language
## 新增一种语言
1. Decide whether the locale can reuse an existing font pack or needs a new one.
2. Add a source bundle `packs/<bundle-id>/` with `package.ini`, `DESCRIPTION.txt`, and `README.md`.
3. Generate `charset.txt` and `ranges.txt` for each external font pack in that bundle.
4. Write `build.ini` for each generated font pack.
5. Write the runtime font manifest with `usage`, `estimated_ram_bytes`, and `ranges`.
6. Write the locale pack manifest with `ui_font_pack` and `content_font_pack`.
7. Add `ime_pack` only if the script truly needs extra input behavior.
8. Run `python scripts/build_pack_repository.py --pack-root packs --site-root site`.
9. Either install manually by copying the runtime payload to SD, or ship the generated zip through Pages for the future Extensions installer.
10. Reboot and select the locale from Settings.
1. 先决定这个 locale 能否复用已有 font pack,还是需要新建一个。
2. 新增源码 bundle:`packs/<bundle-id>/`,并写好 `package.ini`、`DESCRIPTION.txt` 与 `README.md`。
3. 为该 bundle 中的每个外部 font pack 生成 `charset.txt` 与 `ranges.txt`。
4. 为每个生成型 font pack 写好 `build.ini`。
5. 写运行时 font manifest,包括 `usage`、`estimated_ram_bytes` 和 `ranges`。
6. 写 locale pack manifest,包括 `ui_font_pack` 与 `content_font_pack`。
7. 只有在该脚本确实需要额外输入行为时,才添加 `ime_pack`。
8. 运行 `python scripts/build_pack_repository.py --pack-root packs --site-root site`。
9. 可以手工把 runtime payload 拷贝到 SD 安装,也可以通过 Pages 产出的 zip 提供给未来的 Extensions installer。
10. 重启后,在 Settings 中选择该 locale。
## Design Rules
## 设计规则
This architecture intentionally avoids:
这套架构有意避免以下做法:
- integer language enums
- eager loading of every installed font at boot
- a single global "non-ASCII => swap font" shortcut
- conflating UI chrome text with user content text
- per-platform localization implementations drifting apart
- mixing runtime manifests with package-distribution metadata
- forcing the runtime registry to understand remote catalogs or zip archives
- 使用整数语言枚举
- 开机时急切加载所有已安装字体
- 用一个全局的“只要非 ASCII 就切换字体”快捷规则
- 把 UI chrome 文本与用户内容文本混为一谈
- 让不同平台上的本地化实现逐渐漂离
- 混淆运行时 manifest 与 package 分发元数据
- 让 runtime registry 去理解远程 catalog 或 zip archive
The dependency graph stays explicit:
依赖图保持显式:
```text
Locale Pack -> UI Font Pack
@@ -522,4 +520,4 @@ Locale Pack -> IME Pack
Content text -> Optional supplement font packs
```
That makes the code easier to reason about and gives Trail Mate a path to broader language support without forcing every device to pay the same firmware and RAM cost.
这样既让代码更容易推理,也让 Trail Mate 在不强迫所有设备承担同样固件体积与 RAM 成本的前提下,拥有扩展到更广泛语言支持的路径。
+497
View File
@@ -0,0 +1,497 @@
# Localization Specification
## 1. Why This Document Exists
本文件不是翻译指南,也不是打包教程。
本文件的目标是把 Trail Mate 的“本地化系统”定义成一个有边界的对象,防止后续开发再次把以下东西混在一起:
- 固件里的 i18n 运行时
- SD / Flash 上的 runtime pack 载荷
- 仓库中的 `packs/<bundle-id>/` 源包
- GitHub Pages 发布出来的 zip 分发包
- 远程 catalog 元数据
- 已安装索引 `installed.json`
- “换语言”这个用户动作
如果这些对象不被区分,系统就会退化成“改了几行英文字符串,再补几条 tsv”的低解释力状态。
---
## 2. Current Distinctions
### 2.1 当前必须切开的对象
Trail Mate 本地化系统至少由六类对象组成:
1. 固件本地化运行时
2. Runtime pack 载荷
3. 仓库源包
4. 分发包
5. 远程 catalog
6. 已安装索引
它们彼此相关,但不是同一个东西。
### 2.2 非法混淆
以下切法在本规格下视为非法:
1. 把 `strings.tsv` 直接当成“本地化系统本体”。
2. 把 zip 分发包当成运行时直接消费的对象。
3. 把“安装了某语言包”误认为“该字体已经加载进 RAM”。
4. 把固件版本和语言包版本绑成同一个版本号。
5. 把英文源串当成“只是文案”,而不是翻译查找 key。
6. 把页面私有字符串补丁当成合法的本地化实现。
7. 把 `docs/ui_localization_plan.md` 这种历史方案继续当成现行契约。
---
## 3. Object Model
### 3.1 固件本地化运行时
固件运行时负责:
1. 持有内建英文基线。
2. 扫描、编目并解析可用的 locale / font / IME pack manifest。
3. 根据 `display_locale` 选择活动 locale。
4. 维护 UI font chain 与 content font chain。
5. 在需要时惰性加载外部 `font.bin`。
6. 提供 `tr()`、`format()`、`set_label_text()` 等统一入口。
7. 在外部 pack 缺失、不可用或超出设备能力时安全回退。
它不负责:
1. 读取 zip 分发包。
2. 直接理解网站 catalog。
3. 把“语言包安装”与“当前 locale 激活”混成同一动作。
### 3.2 Runtime Pack 载荷
Runtime pack 载荷是运行时真正扫描和消费的已解包目录树。
当前形态包括:
1. Flash 安装根
- ` /fs/trailmate/packs/... `,用于 Arduino/ESP32 上的安装器写入
2. SD 安装根
- ` /trailmate/packs/... `,用于手工拷贝或可移动介质安装
运行时理解的是“解包后的 manifest + strings/ranges/font.bin”,而不是 zip。
### 3.3 仓库源包
仓库中的 `packs/<bundle-id>/` 是源码形态,不是运行时形态。
它可以包含:
1. `package.ini`
2. `README.md`
3. `DESCRIPTION.txt`
4. runtime manifests
5. `strings.tsv`
6. `build.ini`
7. `charset.txt`
8. `ranges.txt`
9. 可选的本地 `font.bin`
其中 `build.ini`、`charset.txt` 这类文件属于构建期元数据,运行时不读取。
### 3.4 分发包
分发包是面向安装器与网站分发的 zip 产物,不是运行时直接扫描的对象。
它当前由 `scripts/build_pack_repository.py` 生成,并包含:
1. 顶层包元数据
2. `payload/fonts/...`
3. `payload/locales/...`
4. `payload/ime/...`
安装器的职责是下载 zip、校验 SHA-256、解出 `payload/`,再写入 runtime pack 根目录。
### 3.5 远程 Catalog
远程 catalog 是“可下载 bundle 的索引”,不是运行时 registry。
它负责提供:
1. package id
2. package version
3. `min_firmware_version`
4. `supported_memory_profiles`
5. 提供了哪些 locale/font/IME
6. archive 路径、大小、SHA-256
7. Extensions 页面所需的展示元数据
它不负责:
1. 字体加载
2. locale 激活
3. zip 内文件解析
### 3.6 已安装索引
已安装索引记录“安装过哪些分发包”,而不是“当前有哪些 locale 被激活”。
当前安装索引的职责是记录:
1. package id
2. package version
3. archive SHA-256
4. storage
5. install time
在当前 Arduino/ESP32 实现中,主安装索引默认写入 Flash 安装根下的 `installed.json`。
历史 SD 路径可以作为迁移兼容来源存在,但不应再被当成新的规范落点。
它不能替代 runtime registry,也不能替代 locale persistence。
---
## 4. Core Contracts
### 4.1 English Source String Is API
英文源串不是随手写的文案,它是翻译查找 key。
这意味着:
1. `ui::i18n::tr(const char* english)` 的输入是稳定 key,而不是“仅供显示的默认英文”。
2. 修改英文源串,会直接改变 key。
3. 一旦 key 改变,所有 locale pack 中对应条目都会失配并回退到英文。
4. `strings.tsv` 中同一 English key 不允许承载两套不同语义。
5. `strings.tsv` 的行顺序不构成语义;运行时按 English key 查找,而不是按文件顺序查找。
因此,下列操作都属于本地化契约变化:
1. 修改现有英文源串文本
2. 拆分一条英文源串为多条
3. 把一条英文源串合并为另一条
4. 改变 format string 结构,例如 `%s` / `%u` / 换行符位置
它们都不能被当成“只改 UI 文案”的低风险改动。
### 4.2 Locale / Font / IME Dependency Graph
依赖图固定为:
1. `Locale Pack -> UI Font Pack`
2. `Locale Pack -> Content Font Pack`
3. `Locale Pack -> Optional IME Pack`
4. `Content Text -> Optional Supplement Font Packs`
Settings 选择的是 locale,不是 font,不是 IME。
页面或设置页不允许绕过 locale 直接定义另一套“选字体/选输入法”主流程。
### 4.3 Built-In Baseline
固件必须始终保留最小内建基线:
1. 内建 locale:`en`
2. 内建字体:`builtin-latin-ui`
3. 内建 IME:可为空
English 必须在没有任何外部 pack 的情况下仍可工作。
这条基线不能被 removable pack 破坏。
### 4.4 Installed Is Not Loaded
`Installed` 与 `Loaded` 是两个不同状态:
1. Installed
- pack manifest 存在于 Flash/SD
- runtime registry 能发现它
2. Loaded
- 外部 `font.bin` 已经实际加载进 RAM
- 会产生即时内存占用
禁止把“安装成功”实现成“开机即把所有外部字体全部加载”。
### 4.5 UI Scope vs Content Scope
本地化运行时必须继续维持两条字体链:
1. UI chain
- 页面 chrome
- 菜单、按钮、标题、设置项等
2. Content chain
- 联系人名
- 节点名
- 聊天内容
- 其他外部文本
这两条链不能被简化成“只要非 ASCII 就统一切换某个 CJK 字体”的旁路实现。
### 4.6 Persistence Contract
当前 locale 的持久化 key 是:
1. namespace:`settings`
2. key:`display_locale`
3. value:locale id string
`display_language` 只是历史迁移键,只允许用于一次性迁移,不得再作为现行设计继续扩展。
### 4.7 Discovery Contract
运行时 registry 只扫描“已解包 runtime pack”。
它不扫描:
1. zip
2. 远程 JSON catalog
3. 仓库里的 `build.ini`
4. 构建期 charset 生成元数据
如果一个 pack 只存在于网站 zip 中、还没解到运行时目录,那它对运行时来说就是不存在。
### 4.8 Installation Contract
安装器层与运行时层必须分离。
安装器负责:
1. 拉取 catalog
2. 下载 zip
3. 校验 SHA-256
4. 解压 `payload/`
5. 更新 installed index
6. 触发 `reload_language()`
运行时负责:
1. 重新扫描可用 pack
2. 解析 manifest / strings / ranges
3. 重新选择 active locale
4. 按需加载字体
当前 Arduino/ESP32 安装器默认把下载得到的 payload 解到 Flash pack 根。
与此同时,手工把 runtime payload 拷贝到 SD 仍然是合法安装方式,因为运行时会同时编目当前支持的 Flash/SD 根。
禁止让运行时 registry 直接承担网络下载或 zip 解释职责。
### 4.9 Failure And Fallback Contract
本地化失败时必须退回到可解释状态,而不是把系统带入半失效状态。
当前允许的失败行为包括:
1. locale 缺少依赖 font pack
- 跳过该 locale
2. locale 缺少依赖 IME pack
- 跳过该 locale
3. 当前设备 memory profile 无法承受 locale 成本
- 跳过该 locale
4. 持久化的 locale id 不再可解析
- 回退到 `en`
5. 某个 content supplement 无法加载
- 页面继续存活
- 个别文字可出现缺字
禁止因为一个外部包损坏而让整个 UI 失去最小英语可用能力。
---
## 5. Versioning And Compatibility Contract
### 5.1 Firmware Version 与 Package Version 必须分离
固件版本与语言包版本不是同一个版本号体系。
它们的职责不同:
1. 固件版本
- 描述运行时代码能力
2. package version
- 描述 bundle 载荷与元数据版本
不能因为固件升级一次,就机械把所有语言包 version 同步改掉。
也不能因为语言包换了翻译文本,就伪造一次固件版本升级。
### 5.2 `min_firmware_version` 的语义
`min_firmware_version` 是下界,不是“必须完全相等”的绑定版本。
它表示:
1. 低于这个固件版本,当前 bundle 语义可能无法被正确理解
2. 高于或等于这个版本,可以认为运行时至少理解这份 bundle 契约
它不表示:
1. 这个 bundle 只能给某一个固件版本使用
2. 每次小翻译更新都必须改 `min_firmware_version`
### 5.3 `supported_memory_profiles` 的语义
`supported_memory_profiles` 描述的是设备能力兼容边界,不是语言偏好。
它影响:
1. Extensions 页展示是否兼容
2. 某设备是否应被允许安装/更新该 bundle
它不能被拿来表达:
1. 用户喜欢哪种语言
2. 当前 locale 是否被激活
### 5.4 更新判定
当前“是否有更新”以 package record 为单位判定,至少比较:
1. version
2. archive SHA-256
因此,哪怕 version 不变,只要 archive 内容变了,也可能被视为不同包。
这意味着偷偷改 zip 内容而不更新包版本,是危险的发布行为。
### 5.5 兼容性 gating 的归属
兼容性 gating 当前属于“安装/发现层”的职责,主要体现在 catalog 解析和 Extensions UI。
但运行时仍必须对手工拷贝到 Flash/SD 的 payload 保持鲁棒:
1. 不兼容 pack 可以被发现
2. 依赖不满足时可以跳过
3. 系统仍需回退到 English 基线
禁止把系统安全性完全建立在“用户一定只会通过 Extensions 正规安装”这个假设上。
---
## 6. Change Classification
### 6.1 固件改动但通常不要求语言包跟随变化
以下改动通常不要求语言包更新:
1. 纯逻辑修复,不引入新的用户可见文本
2. 不改变 manifest schema 的运行时重构
3. 不改变翻译 key 的内部实现优化
4. 不改变字体需求的布局调整
### 6.2 固件改动后通常要求更新语言包字符串
以下改动通常要求至少更新一个或多个 locale pack:
1. 新增用户可见英文源串
2. 删除旧源串并引入新源串
3. 修改旧英文源串文本
4. 修改 format string 参数结构
5. 修改带转义字符的字符串结构,例如 `\n`
### 6.3 固件改动后可能要求更新字体包
以下改动不仅可能改 `strings.tsv`,还可能要求重建 font pack:
1. 新翻译文本引入当前字体未覆盖的新字形
2. `native_name` 改动引入新字形
3. 新 IME 字典或候选字集引入新字形
4. 新增 locale/self-name/设置项文本导致 charset 扩展
一旦字形覆盖变化,至少要同步处理:
1. `charset.txt`
2. `ranges.txt`
3. `font.bin`
4. `estimated_ram_bytes`
### 6.4 固件改动后必须提高 `min_firmware_version`
以下改动属于 bundle 契约变化,必须考虑提高 `min_firmware_version`:
1. manifest schema 新增运行时必需字段
2. 现有 manifest 字段语义改变
3. runtime 对 bundle 布局的期望发生变化
4. catalog / archive / installed index 语义发生不兼容变化
5. locale/font/IME 依赖规则发生不兼容变化
### 6.5 只改语言包也必须 bump package version
以下情况即使不改固件,也应 bump package version:
1. `strings.tsv` 变化
2. `manifest.ini` 变化
3. `ranges.txt` 变化
4. `font.bin` 变化
5. `package.ini` 中影响安装或兼容性的字段变化
否则 installed index 与 update check 将失去解释力。
---
## 7. Release Obligations
### 7.1 固件改动合入前必须回答的问题
1. 这次是否新增了用户可见英文源串?
2. 这次是否改动了现有英文 key?
3. 这次是否改变了 format string 结构?
4. 这次是否引入了新字形需求?
5. 这次是否改变了 manifest / package / catalog 契约?
只要上述任一答案为“是”,就不能只提交固件代码而不审视语言包。
### 7.2 语言包发布前必须回答的问题
1. `package version` 是否已反映 payload 变化?
2. 如有契约变化,`min_firmware_version` 是否已提高?
3. `font.bin` 是否与 `charset.txt` / `ranges.txt` / manifest 保持一致?
4. `estimated_ram_bytes` 是否仍真实反映生成结果?
5. `site/data/packs.json` 与 zip 产物是否已重建?
### 7.3 新 locale 合入前必须满足的最小条件
1. English 基线不被破坏。
2. locale manifest 依赖完整。
3. 对应 font pack 的 RAM 成本可被当前目标 memory profile 解释。
4. 运行时目录布局、分发包布局、catalog 元数据三者一致。
5. 手工安装与 Extensions 安装都不会把系统带离 English 回退能力。
---
## 8. Prohibited Implementations
以下实现方式在本规格下明确禁止:
1. 继续引入新的整数语言枚举作为主持久化键。
2. 页面直接绕过 `ui::i18n` 写死另一套翻译逻辑。
3. 通过“检测到非 ASCII 就随便切某个字体”来替代 locale/font chain。
4. 把 zip 当运行时 pack 根目录。
5. 改了英文源串却不把它当成 key 变化处理。
6. 改了翻译文本所需字形,却不重建相关字体包。
7. 改了 bundle payload,却不 bump package version。
8. 改了 bundle 契约,却不调整 `min_firmware_version`。
9. 让不兼容或损坏的 pack 破坏 English 最小基线。
---
## 9. Relationship To Other Documents
本文件是规范性文档。
如果与其他文档冲突,优先级如下:
1. `docs/LOCALIZATION_SPEC.md`
2. `docs/LOCALE_PACKS.md`
3. 各 `packs/<bundle-id>/README.md`
4. `docs/ui_localization_plan.md`
其中:
1. `docs/LOCALE_PACKS.md`
- 负责解释 pack 机制、布局和字段
2. `docs/ui_localization_plan.md`
- 仅保留历史演进价值,不再作为当前设计依据
---
## 10. One-Sentence Baseline
Trail Mate 的本地化不是“固件里几条翻译表”,而是“以 English key 为稳定接口、以 locale/font/IME pack 为显式依赖、以安装层与运行时层分离为前提、并允许固件与语言包独立演进但受兼容契约约束”的完整系统。
+71 -64
View File
@@ -1,95 +1,102 @@
# UI Localization Plan
# UI 本地化计划
## Goal
历史说明:
本文件记录的是一份较早期的实现计划,已经不再是当前的规范性设计来源。
当前本地化契约见 [`docs/LOCALIZATION_SPEC.md`](./LOCALIZATION_SPEC.md),
pack/runtime 机制说明见 [`docs/LOCALE_PACKS.md`](./LOCALE_PACKS.md)。
尤其需要注意的是,本计划中仍保留了 `display_language` 这类历史概念,
它们已经被当前的 locale-pack 架构取代。
Add runtime-selectable UI localization for English and Chinese across the LVGL-based Trail Mate interface.
## 目标
- Default language: English
- Supported languages: English / Chinese
- Language switch entry point: `Settings > System > Display Language`
- Switch behavior: apply immediately without reboot
为基于 LVGL 的 Trail Mate 界面增加可在运行时切换的 UI 本地化能力,支持英文与中文。
## Scope
- 默认语言:英文
- 支持语言:英文 / 中文
- 语言切换入口:`Settings > System > Display Language`
- 切换行为:立即生效,无需重启
This task covers user-facing UI text produced by the device firmware itself, including:
## 范围
- Main menu app names
- Shared LVGL screens under `modules/ui_shared`
- ESP-specific LVGL screens under `platform/esp/arduino_common/src/ui`
- Shared notifications / prompts / modal labels
- Settings categories, items, enum labels, action labels, and validation messages
- Menu dashboard widgets
- Screen saver prompts and device-side status notices
本任务覆盖设备固件自身产生的、面向用户的 UI 文本,包括:
This task does not translate:
- 主菜单应用名称
- `modules/ui_shared` 下的共享 LVGL 页面
- `platform/esp/arduino_common/src/ui` 下的 ESP 特定 LVGL 页面
- 共享通知、提示框、模态框标签
- 设置页的分类名、项目名、枚举标签、动作标签和校验消息
- 菜单仪表盘组件
- 屏保提示与设备侧状态通知
- Incoming user messages
- Contact names, node names, callsigns, channel names, or other user-generated content
- Protocol brand names such as `Meshtastic`, `MeshCore`, `LXMF`, and `RNode`
- README / docs / release notes
本任务不翻译以下内容:
## Design
- 收到的用户消息
- 联系人名、节点名、呼号、频道名等用户生成内容
- `Meshtastic`、`MeshCore`、`LXMF`、`RNode` 这类协议品牌名
- README / 文档 / 发布说明
### 1. Translation Dictionary
## 设计
Use a shared localization module in `modules/ui_shared` with:
### 1. 翻译字典
在 `modules/ui_shared` 中使用共享本地化模块,包含:
- `ui::i18n::Language`
- persistent current language state
- `ui::i18n::tr(const char* english)` as the canonical dictionary lookup
- 持久化的当前语言状态
- 以 `ui::i18n::tr(const char* english)` 作为规范的字典查找入口
The dictionary uses the existing English source text as the canonical lookup key and returns:
字典以现有英文源文本作为规范查找 key,并返回:
- the original English string when language is English
- the translated Chinese string when language is Chinese and a translation exists
- the original English string as a fallback when no translation entry exists
- 当语言为英文时,返回原始英文字符串
- 当语言为中文且存在翻译时,返回对应中文翻译
- 当没有找到翻译条目时,回退到原始英文字符串
This approach minimizes risk while retrofitting a large existing UI codebase with many hardcoded literals.
这种方式在面对大量已有硬编码文本的 UI 代码库时,能够以较低风险完成本地化接入。
### 2. Persistence
### 2. 持久化
Persist the current UI language under the `settings` namespace:
将当前 UI 语言持久化到 `settings` namespace:
- key: `display_language`
- value: `0 = English`, `1 = Chinese`
- key:`display_language`
- value:`0 = English`,`1 = Chinese`
### 3. Runtime Refresh
### 3. 运行时刷新
Changing the language should:
切换语言时应当:
- persist the new language
- refresh main menu labels
- rebuild the currently active app screen asynchronously
- 持久化新的语言值
- 刷新主菜单标签
- 以异步方式重建当前激活的应用页面
This avoids requiring every widget to subscribe to a language-change event individually.
这样可以避免要求每一个控件都分别订阅语言变更事件。
### 4. Font Handling
### 4. 字体处理
Localized labels must automatically switch to the CJK font when the translated text contains non-ASCII characters.
当翻译后的文本包含非 ASCII 字符时,本地化标签应自动切换到 CJK 字体。
Use the shared font helpers so that:
应使用共享字体辅助工具,以保证:
- ASCII labels keep their existing UI font
- Chinese labels switch to the Noto CJK font when available
- boards compiled without CJK glyph support still fall back safely to the existing font configuration
- ASCII 标签继续使用原有 UI 字体
- 中文标签在可用时切换到 Noto CJK 字体
- 未编译 CJK glyph 支持的板卡仍能安全回退到现有字体配置
## Implementation Steps
## 实施步骤
1. Add the shared localization module and persistence helpers.
2. Add menu/app refresh support so language changes take effect immediately.
3. Add `Display Language` to `Settings > System`.
4. Route shared settings labels, options, prompts, and validation messages through localization.
5. Localize menu titles, dashboard labels, shared widgets, modal buttons, and notifications.
6. Localize page-level fixed strings across Contacts / Chat / GPS / Tracker / PC Link / USB / SSTV / GNSS / Walkie Talkie / placeholder pages.
7. Localize ESP-specific prompts such as screen-saver text and battery / event notifications.
8. Run formatting and CI-equivalent builds, then fix any regressions.
1. 增加共享本地化模块与持久化辅助函数。
2. 增加菜单/应用刷新支持,使语言切换后立即生效。
3. 在 `Settings > System` 中加入 `Display Language`。
4. 将共享设置项标签、选项、提示和校验消息接入本地化。
5. 本地化菜单标题、仪表盘标签、共享组件、模态按钮和通知。
6. 本地化 Contacts / Chat / GPS / Tracker / PC Link / USB / SSTV / GNSS / Walkie Talkie / placeholder 页面中的固定文本。
7. 本地化屏保文本、电池通知、事件通知等 ESP 特定提示。
8. 运行格式化与 CI 等价构建,并修复回归问题。
## Acceptance Criteria
## 验收标准
- English is the default UI language on clean startup.
- The language can be changed in `Settings > System > Display Language`.
- Changing the language updates the current screen immediately.
- Returning to the main menu shows localized app names.
- Shared prompts such as `Back`, `Save`, `Cancel`, `Loading...`, system toasts, and screen titles are localized.
- Chinese text renders legibly with CJK glyph coverage on supported targets.
- Existing PlatformIO CI builds still pass.
- 全新启动时英文是默认 UI 语言。
- 语言可以在 `Settings > System > Display Language` 中切换。
- 切换语言后当前页面立即更新。
- 返回主菜单后可看到已本地化的应用名称。
- `Back`、`Save`、`Cancel`、`Loading...`、系统 toast、页面标题等共享提示已完成本地化。
- 在支持目标上,中文文本能以 CJK glyph 覆盖正常显示。
- 现有 PlatformIO CI 构建仍能通过。
+30
View File
@@ -0,0 +1,30 @@
# UI/UX Specification Index
`docs/uiux` 按“对象类型”而不是按文件历史堆叠组织。
当前目录约束如下:
- `foundation/`
- 放全局风格、视觉语言、跨页面共享的设计基线。
- 这里的文档不能偷带某个具体页面的布局细节。
- `pages/`
- 放页面级规格。
- 每个文件只解释一个页面对象,不解释可复用组件本体。
- `components/`
- 放组件级规格与组件实现规格。
- 这里只定义组件边界、职责、状态与实现约束,不反向定义页面。
当前文件归属:
- `foundation/firmware_visual_style.md`
- `pages/node_info_page.md`
- `pages/node_info_page_layer_popup_addendum.md`
- `components/shared_map_viewport.md`
- `components/shared_map_viewport_impl.md`
- `components/shared_map_viewport_layer_popup_addendum.md`
如果后续新增文档,必须先判断它属于哪一类对象,再落目录:
1. 它描述的是全局视觉语言,还是某个页面,还是某个组件。
2. 如果它同时想定义页面和组件,说明文档边界还没切开,应先拆分。
3. 不允许继续把页面规格、组件规格、全局风格规格平铺在同一层目录。
+465
View File
@@ -0,0 +1,465 @@
# Shared Map Viewport Component Specification
## 1. Scope
本文档定义 Trail Mate 中“共享地图视口组件”的规格。
它约束的不是某一个页面,而是所有“以地图作为主背景或主内容承载层”的页面都应共同遵守的地图组件边界。
当前直接受此文档约束的页面包括:
- `GPS / 地图` 页面
- `Node Info / 节点详情` 页面
后续任何新页面只要需要:
- 地图底图渲染
- 地图拖动
- 地图缩放
- 地图图层切换
- 地图语义覆盖层投影
都应优先复用本组件,而不是在页面内再次实现一套地图逻辑。
本文档是“组件职责与边界规格”,不是具体代码设计稿;但后续实现必须能被本文档解释。
---
## 2. Current Confusions
在进入重构前,必须先承认当前系统中存在以下混淆:
1. `GPS` 页面已经拥有一套相对完整的地图能力,但它和页面业务状态耦合过深,不能直接当成通用组件复用。
2. `Node Info` 页面当前又实现了一套独立的地图逻辑,这不是复用,而是平行实现。
3. “地图页面”和“地图组件”不是同一个对象。
4. “瓦片引擎”也不是“地图组件”本身,它只是底层能力的一部分。
5. 图层切换、拖动、缩放、投影这些语义应属于共享地图视口,而不是某个页面私有行为。
6. `Layer` 按钮出现在不同页面的不同位置,不等于图层切换语义可以按页面各自定义。
如果不先把这些混淆切开,后续任何“先支持功能再说”的实现,都会把系统重新带回双轨地图逻辑。
---
## 3. Distinctions
### 3.1 地图页面 != 地图组件
`GPS` 页面是一个完整页面对象,除了地图之外还包含:
- GPS fix 状态
- follow self 策略
- 队友标记
- 路线/轨迹覆盖层
- 页面标题与状态信息
- 页面快捷操作
这些都不是共享地图视口本体。
共享地图视口组件只负责“地图如何被显示、平移、缩放、切图层、投影叠加层”,不负责页面的业务含义。
### 3.2 瓦片引擎 != 地图视口
现有 `map_tiles.*` 是底层瓦片与投影能力,负责:
- tile 计算
- tile 对象管理
- tile 加载与缓存
- 地图源目录与文件路径
- 等高线叠加层
- 屏幕投影
它是共享地图视口的后端基础,不应被页面直接当成页面组件来使用。
### 3.3 页面覆盖信息 != 地图语义覆盖层
以下元素属于页面覆盖信息:
- 顶部栏
- 节点 ID
- 左下角经纬度文本
- 右侧信息列
- 独立于地图移动的页面按钮
以下元素属于地图语义覆盖层:
- 节点标记
- 自身标记
- 队友标记
- 连线
- 距离标签
- 路径/轨迹
地图语义覆盖层必须和地图视口同步移动;页面覆盖信息必须稳定悬浮,不得跟随拖动漂移。
### 3.4 视口状态 != 页面业务状态
地图视口状态是:
- 当前缩放级别
- 当前平移偏移
- 当前基础底图
- 当前叠加图层开关
- 当前是否允许交互
- 当前视口是否有可用底图
页面业务状态是:
- GPS 页是否 follow self
- Node Info 页当前查看的是哪个节点
- 页面上显示哪些右侧信息项
- 节点详情页的缩放锚点是谁
页面业务状态可以驱动地图视口,但不应与地图视口内部状态混在一起。
### 3.5 基础底图 != 叠加图层
基础底图是“互斥的一选一”:
- OSM
- Terrain
- Satellite
叠加图层是“附着在基础底图上的可选层”:
- Contour Overlay
切换基础底图与开关叠加图层,语义不同,不能混成一个随意的“layer mode”。
---
## 4. Component Goals
### 4.1 核心目标
共享地图视口组件必须提供以下稳定能力:
1. 在统一组件模型下承载地图底图。
2. 在统一状态语义下支持拖动与缩放。
3. 在统一规则下支持图层切换。
4. 为页面提供稳定的地理点到屏幕坐标投影能力。
5. 允许页面在地图之上叠加页面私有的语义元素。
6. 让多个页面共享同一套地图主流程,而不是共享几段 helper。
### 4.2 非目标
本组件当前不是:
- 页面导航容器
- 联系人或节点业务模型
- GPS 页面专属状态机
- 团队页面专属状态机
- 离线地图下载器
- 地图数据准备工具
---
## 5. Responsibilities
共享地图视口组件负责:
1. 创建并维护地图底图承载区域。
2. 维护统一的 camera / viewport 状态。
3. 协调基础底图与叠加图层渲染选项。
4. 驱动底层瓦片后端进行 tile 计算、加载与布局。
5. 向页面提供投影查询能力。
6. 管理地图语义覆盖层宿主容器。
7. 管理拖动、缩放、图层切换这三类交互的共同语义。
8. 暴露“当前视口状态”和“当前地图可用性状态”。
共享地图视口组件不负责:
1. 决定某个页面应该显示哪些业务字段。
2. 决定页面右侧信息列内容。
3. 直接持有联系人、节点、GPS 页面、团队页面的业务模型。
4. 在页面里自行定义“某个标记代表什么”。
5. 让页面直接操作 tile cache、tile record、文件路径拼接等底层细节。
---
## 6. Module Ownership
### 6.1 `modules/ui_shared` 的职责
`modules/ui_shared` 负责共享地图视口的页面无关接口与组件壳层,至少包括:
- 组件公开 API
- 视口状态模型
- 页面接入约束
- 交互语义约束
- 地图语义覆盖层宿主抽象
换句话说,页面应该依赖 `ui_shared` 中的共享地图视口组件,而不是自己直接拼装底层瓦片逻辑。
### 6.2 `platform/esp/*` 的职责
平台层负责地图视口所依赖的具体后端能力,至少包括:
- LVGL 对象级实现
- 瓦片加载与缓存
- 文件系统路径与资源查找
- 等高线叠加渲染
- 坐标系转换实现
- 平台相关的内存/加载预算控制
平台层提供的是“后端适配”,不是页面语义。
### 6.3 页面层的职责
页面层只负责自己的业务使用方式,例如:
- `GPS` 页决定 self/队友/轨迹这些覆盖层语义
- `Node Info` 页决定目标节点/自身节点/连线/距离这些覆盖层语义
- 页面决定自己需要哪些固定信息栏与按钮
页面层不再自建地图底图逻辑。
---
## 7. State Boundaries
### 7.1 持久配置状态
以下状态属于应用配置,组件读取但不私自定义:
- `map_source`
- `map_contour_enabled`
- `map_coord_system`
这些状态的持久化归应用配置系统,组件只消费其当前值或接收页面显式下发。
### 7.2 视口运行时状态
以下状态属于共享地图视口组件本体:
- 当前 zoom
- 当前 pan_x / pan_y
- 当前基础底图
- 当前 contour 是否开启
- 当前视口是否允许拖动
- 当前视口是否允许缩放
- 当前视口是否具备可用地图数据
- 当前 anchor / projection cache
- 当前渲染中的 tile state 摘要
### 7.3 页面驱动状态
以下状态由页面拥有,再作为输入喂给视口:
- 视口聚焦对象
- 页面是否允许 follow
- 页面希望缩放围绕谁发生
- 页面要画哪些语义标记与线段
- 页面是否要在视口之上显示固定 UI chrome
### 7.4 后端缓存状态
以下状态属于后端,不应越过组件边界暴露给页面自由操作:
- tile records
- decoded image cache
- missing tile notice once flags
- contour overlay cache
- tile object eviction state
页面可以读到摘要,不可以改写内部细节。
---
## 8. Layer Switching Semantics
### 8.1 基础规则
共享地图视口必须支持“页内图层切换”,且切换不应要求页面重建。
### 8.2 基础底图规则
基础底图始终恰有一个 active source:
- `0 = OSM`
- `1 = Terrain`
- `2 = Satellite`
切换基础底图时:
1. 页面不重建。
2. 地图视口对象不重建。
3. 视口 camera 状态尽量保持。
4. 地图语义覆盖层仍由页面持有,不因切底图而丢失。
5. 底层 tile backend 应刷新底图渲染状态。
### 8.3 叠加图层规则
Contour Overlay 是叠加层,不是基础底图的一种。
切换 contour 时:
1. 不改变 active base source。
2. 不改变页面语义覆盖层。
3. 不改变页面的业务聚焦对象。
4. 只改变地图底图之上的 contour 可见性与加载策略。
### 8.4 缺图语义
当切换到某图层而当前视口无图时:
1. 不允许页面崩塌成黑屏。
2. 不允许页面 silently fail。
3. 组件应维持稳定的地图容器结构。
4. 组件应给出“当前图层缺图”的可观察状态或一次性通知。
页面可决定如何展示该通知,但不应自己再实现一套缺图判断。
### 8.5 图层切换入口语义
共享地图视口约束的是“图层切换语义”,不是“按钮必须长在同一个坐标”。
允许:
- `GPS / 地图` 页将 `Layer` 按钮放在自己的控制区
- `Node Info` 页将 `Layer` 按钮放在底部中间
不允许:
- 不同页面拥有不同的基础底图枚举
- 不同页面对 `Contour` 有不同含义
- 不同页面各自实现不同的缺图判断和图层归一化逻辑
因此,页面可以拥有自己的触发 chrome,但图层切换后的状态变化、合法值集合、缺图语义与持久化后果必须完全一致。
---
## 9. Camera and Interaction Semantics
### 9.1 拖动
当页面允许拖动时:
- 拖动作用于地图视口
- 地图语义覆盖层随之移动
- 页面固定 chrome 不移动
### 9.2 缩放
共享地图视口必须支持“页面指定缩放锚点语义”。
原因是不同页面的缩放锚点不同:
- `GPS` 页可能围绕 self / screen center / follow target
- `Node Info` 页必须围绕目标节点
因此缩放行为不能硬编码为某一页面私有规则。
共享缩放等级契约固定为:
- 默认缩放:`12`
- 最小缩放:`0`
- 最大缩放:`18`
页面可以决定“用户看到的首帧是否因为缺图而降级到其它最近可用级别”,但**不能**在页面内部再次私有化一套不同的最小/最大缩放范围。
补充约束:
- “用户请求改变 zoom” 与 “当前 zoom 是否具备中心瓦片” 必须是两个分离判断
- 首帧或自动选级可以参考瓦片可用性
- 但交互缩放不得因为缺少中心瓦片而被静默拦截成 no-op
### 9.3 Follow
`follow` 不是共享地图视口的通用默认行为,而是页面策略。
共享地图视口只提供:
- camera 移动能力
- anchor 计算能力
- 拖动后 camera 偏移保持能力
是否“自动跟随某个对象”,由页面自己声明。
### 9.4 无地理目标时
当页面没有可用地理目标时,组件必须允许进入“无地图语义能力”的降级态。
在该状态下:
- 地图底图可为空或仅为背景
- 拖动可被禁用
- 缩放可被禁用
- 页面固定信息仍可正常显示
---
## 10. Page Integration Contracts
### 10.1 `Node Info` 页面接入要求
`Node Info` 页面通过共享地图视口组件获得:
- 地图底图
- 拖动能力
- 缩放能力
- 图层切换能力
- 坐标投影能力
`Node Info` 页面自己提供:
- 目标节点标记
- 自身标记
- 两点连线
- 距离标签
- 左上 ID、左下经纬度、右侧信息列、右下缩放按钮、底部中间 `Layer` 按钮等固定 chrome
补充约束:
- `Node Info` 页允许在地图上方叠加固定 chrome,但不得再覆盖持续存在的半透明雾化层或右侧遮罩来“压暗”底图
- `Node Info` 页的拖动只改变 camera 偏移;其缩放锚点始终仍是目标节点
### 10.2 `GPS` 页面接入要求
`GPS` 页面通过共享地图视口组件获得:
- 地图底图
- 拖动/缩放
- 图层切换
- 坐标投影
`GPS` 页面自己提供:
- self marker
- team markers
- 轨迹/路线覆盖层
- follow 策略
- 页面状态信息
---
## 11. Illegal Implementations
以下实现方式在本规格下视为非法:
1. 在页面文件中再次实现 `map_source` 归一化。
2. 在页面文件中再次实现基础 tile 路径拼接规则。
3. 在页面文件中再次实现世界像素投影主流程。
4. 在页面文件中再次维护独立的 tile image 阵列与 tile 生命周期。
5. 在页面中直接操纵底层 tile cache 细节。
6. 把 `GPS` 页面整体当成组件硬复用到别的页面。
7. 把页面固定 chrome 混入地图语义覆盖层一起拖动。
8. 在页面内再次实现一套页面私有的图层归一化、等高线切换语义或缺图判定。
---
## 12. Consequences
一旦接受本规格,后续重构就会被明确约束为:
1. `Node Info` 当前那套平行地图实现必须被移除,而不是继续扩展。
2. `GPS` 页面当前的地图主流程必须被剥离出页面专属状态。
3. 共享地图视口组件会成为多个页面共同依赖的唯一地图主入口。
4. 后续任何地图能力增强,例如更多图层、更多标记、更多交互,都优先加在共享组件,而不是加在页面私有分支上。
---
## 13. Summary Baseline
一句话总结:
共享地图视口组件不是“另一个地图页面”,也不是“几段共用 helper”,而是 Trail Mate 中所有地图型页面共享的唯一地图主流程承载层。
@@ -0,0 +1,430 @@
# Shared Map Viewport Implementation Specification
## 1. Scope
本文档定义“共享地图视口组件”的实现规格。
它不是为了锁死某个具体类名,而是为了把后续重构时最容易漂移的实现边界固定下来,使代码结构能够持续解释:
- 谁拥有视口状态
- 谁拥有业务状态
- 谁拥有瓦片后端
- 谁负责叠加层
- 图层切换时哪些状态保留,哪些状态刷新
本文件与 [shared_map_viewport.md](./shared_map_viewport.md) 配套使用:
- 前者定义“组件是什么”
- 本文件定义“组件应如何落地”
---
## 2. Implementation Goal
目标不是把现有 `GPS` 页代码整体搬出来,而是把当前系统中已经存在、但被页面私有代码包住的地图主流程抽成一个共享实现层。
换句话说,重构目标是:
- 页面继续表达自己的业务语义
- 地图主流程只保留一份
- 底层瓦片后端继续复用已有能力
---
## 3. Target Decomposition
共享地图能力在实现上应至少被拆成三层。
### 3.1 Layer A: Page-Neutral Viewport Facade
这一层属于共享组件的公开入口,负责:
- 创建/销毁地图视口对象
- 接收页面传入的视口模型
- 管理交互与生命周期
- 提供投影与状态查询
- 提供共享图层状态读写核心
- 宿主页内语义覆盖层
这一层不应直接持有联系人、节点详情、GPS 页面私有状态。
### 3.2 Layer B: Map Runtime / Camera State
这一层负责:
- zoom / pan
- active base layer
- contour toggle
- focus anchor
- interaction enabled flags
- viewport availability flags
- render dirty / refresh scheduling
这一层应是页面无关的运行时状态机。
### 3.3 Layer C: Platform Tile Backend
这一层负责:
- tile calculation
- tile cache
- tile object creation
- contour overlay loading
- file path resolution
- coordinate transform helpers
- LVGL object level rendering
当前 `map_tiles.*` 已经承担了大量 Layer C 职责,后续应被保留为共享地图视口的后端,而不是页面直接消费的公共页面 API。
---
## 4. Proposed Module Ownership
### 4.1 组件主入口位置
共享地图视口组件的主入口应归属于页面共享层,目标归属建议为:
- `modules/ui_shared/include/ui/widgets/map/...`
- `modules/ui_shared/src/ui/widgets/map/...`
原因是页面代码应依赖“共享组件接口”,而不是直接依赖某个具体页面或某个具体板级页面实现。
### 4.2 后端适配位置
具体瓦片/LVGL/文件系统后端继续放在平台层,建议归属于:
- `platform/esp/.../ui/widgets/map/...`
这层负责 ESP + LVGL + 本地文件系统相关实现。
### 4.3 页面使用位置
页面层只引用共享地图视口组件,不直接引用后端私有细节。
如果页面仍然直接包含并操纵 `TileContext`、`MapTile`、tile path helper 等对象,说明组件边界仍未收敛完成。
---
## 5. Public Contract
共享地图视口组件在实现上应对页面暴露以下能力类型。
### 5.1 输入模型
页面向组件输入的应是“页面意图”,而不是后端细节,至少包括:
- 地图容器尺寸或挂载父对象
- 当前地理聚焦对象
- 初始或当前 zoom
- 当前 layer selection
- contour enabled
- 是否允许拖动
- 是否允许缩放
- 缩放锚点策略
- 页面私有覆盖层模型
### 5.2 输出能力
组件向页面输出的应是“受控能力”,至少包括:
- 请求重渲染
- 坐标投影查询
- 当前视口状态快照
- 当前共享图层状态快照
- 共享图层状态修改入口
- 当前地图是否可用
- 缺图事件/一次性通知
- 页面手势回调或状态变更回调
### 5.3 不应暴露的内容
以下内容不应作为页面公开 API:
- tile record vector
- decoded image cache entry
- contour object 指针
- tile placeholder 对象
- 文件路径拼接细节
这些都属于后端内部实现。
---
## 6. UI Object Tree
共享地图视口组件内部应至少维持如下对象层次:
```text
MapViewportRoot
├─ TileLayer
├─ SemanticOverlayLayer
└─ GestureSurface
```
说明如下:
- `TileLayer` 承载基础底图与 contour 这类地图底层图像对象
- `SemanticOverlayLayer` 承载会随地图一起移动的语义对象
- `GestureSurface` 用于接收地图手势,不承载页面固定 chrome
页面固定 chrome,例如:
- Top bar
- Node ID
- 经纬度文本
- 右侧信息列
- 页面按钮
不应内置在共享地图视口内部,而应由页面放在组件外侧或上层。
---
## 7. Camera Model
### 7.1 必须存在的状态
组件运行时应至少显式持有:
- `zoom`
- `pan_x`
- `pan_y`
- `active_base_layer`
- `contour_enabled`
- `interaction_enabled`
- `drag_enabled`
- `zoom_enabled`
- `viewport_has_map_data`
- `viewport_has_visible_map_data`
### 7.1.1 Zoom Contract
共享地图视口实现必须只保留一套缩放等级契约:
- `default_zoom = 12`
- `min_zoom = 0`
- `max_zoom = 18`
如果某个页面因为缺图、弱网格、离线瓦片覆盖不足而需要选择不同首帧 zoom,它可以在这套契约内寻找“最近可用级别”,但不得私自改写最小值、最大值或默认值。
### 7.2 焦点与锚点
实现中必须区分两个概念:
- `focus object`
- `zoom anchor`
二者通常重合,但不是同义词。
例如:
- `Node Info` 页里,focus object 和 zoom anchor 都是目标节点
- `GPS` 页里,focus object 可能是当前位置,但拖动后 camera center 可以偏离 focus object
这也意味着:
- 拖动后 `camera center` 可以临时偏离 `focus object`
- 但页面如果声明“缩放锚点始终是 focus object”,那么下一次 zoom commit 时必须按该锚点重新求解 camera
### 7.3 Follow 不属于底层默认逻辑
共享地图视口不应默认内置 “follow self”。
正确实现是:
- 页面声明自己是否 follow
- 组件只执行页面给出的 camera policy
---
## 8. Render Pipeline
组件的主渲染流程应可被解释为以下顺序:
1. 页面传入当前模型。
2. 组件归一化 layer selection。
3. 组件计算地理焦点与坐标转换。
4. 组件更新 anchor / camera state。
5. 组件驱动后端计算 required tiles。
6. 后端布局可见 tiles。
7. 组件刷新地图语义覆盖层。
8. 页面固定 chrome 保持不动。
需要注意:
- 第 7 步中的语义覆盖层更新应基于统一投影能力,而不是页面自己再做一套经纬度到屏幕坐标的推导。
- 图层切换应重走 2 到 7,但不应要求页面重建。
---
## 9. Layer Switching Implementation Rules
### 9.0 共享核心与页面入口分离
实现上必须显式区分两层:
1. 图层切换共享核心
2. 页面触发入口 chrome
图层切换共享核心负责:
- `map_source` 合法值归一化
- `Contour` 开关语义
- 配置持久化
- 缺图 / 缺 SD / 缺等高线数据的一次性通知生成
页面触发入口 chrome 负责:
- 按钮放在哪里
- 如何打开弹层
- 焦点如何落到弹层按钮上
页面入口可以不同,但共享核心必须唯一。
### 9.1 基础底图切换
实现上应遵守:
1. 修改 active base layer。
2. 通知后端刷新 render options。
3. 保留当前 camera 语义状态。
4. 保留页面覆盖层模型。
5. 让语义覆盖层按新底图投影重新定位。
禁止做法:
- 切图层时直接销毁整个页面
- 切图层时把页面业务状态重置为初始值
- 切图层时丢掉 overlay host 再让页面自己重建一切
### 9.2 Contour 开关
Contour 开关应只是底图渲染选项变化。
它不应:
- 改变 focus object
- 改变 zoom
- 改变 pan
- 改变页面 overlay 数据
### 9.3 缺图处理
组件实现必须将“缺图”建模为显式状态,而不是隐藏失败。
页面消费的是:
- 当前图层是否可用
- 是否触发一次性缺图通知
而不是自己去碰文件系统判断。
### 9.4 `Node Info` 的实现约束
`Node Info` 页可以拥有自己的 `Layer` 按钮位置和弹层承载外壳,但它不得自行重新定义:
- `OSM / Terrain / Satellite` 的枚举语义
- `Contour` 的开关语义
- 图层配置写回逻辑
- 缺图提示判定
换句话说:
- `Node Info` 页允许拥有自己的入口 chrome
- `Node Info` 页不允许拥有自己的图层状态核心
---
## 10. Overlay Contract
页面语义覆盖层应通过共享视口组件提供的宿主进行渲染。
页面只负责:
- 描述要画哪些对象
- 描述它们的样式与标签
- 响应交互后是否更新模型
组件负责:
- 提供地理点到屏幕坐标投影
- 提供覆盖层挂载容器
- 在 camera 变化时触发重新定位
这意味着 `Node Info` 页中的:
- 节点点位
- 自身点位
- 连线
- 距离
都应是共享地图视口之上的页面 overlay,而不是页面自己维护的一套“伪 tile overlay”。
---
## 11. Logging Contract
为避免后续再出现“界面黑了但不知道发生了什么”的情况,共享地图视口组件必须具备统一日志前缀,建议为:
- `[MapViewport]`
至少应在以下节点打日志:
1. create / destroy
2. attach / detach parent
3. model apply
4. layer switch
5. contour toggle
6. drag begin / drag update / drag end
7. zoom request / zoom commit
8. anchor update
9. required tile summary
10. missing tile notice
11. overlay projection refresh
12. gesture enable / disable
页面日志仍可保留,但页面日志不应取代组件日志。
---
## 12. Refactor Obligations
接受本实现规格后,后续代码重构至少必须完成以下收敛:
1. `Node Info` 页面中的地图源归一化、tile 路径拼接、世界像素转换、独立 tile image 数组等逻辑必须删除。
2. `GPS` 页面中只属于共享地图主流程的能力必须从页面私有逻辑中剥离出来。
3. 坐标系转换这类地图通用能力不得继续挂在 `gps_page_map.cpp` 这种页面文件里充当事实上的共享库。
4. 页面应改为通过共享地图视口组件 API 获取投影与交互能力。
---
## 13. File Layout Baseline
后续实现落地时,推荐至少形成以下结构:
```text
modules/ui_shared/include/ui/widgets/map/
map_viewport.h
map_viewport_types.h
map_viewport_overlay.h
modules/ui_shared/src/ui/widgets/map/
map_viewport.cpp
platform/esp/.../include/ui/widgets/map/
map_viewport_backend.h
map_tiles.h
platform/esp/.../src/ui/widgets/map/
map_viewport_backend.cpp
map_tiles.cpp
```
此处是实现布局基线,不是必须逐字符照搬的文件名;但“共享入口在 `ui_shared`、平台后端在 platform 层”这一结构含义应保持稳定。
---
## 14. Summary Baseline
一句话总结:
共享地图视口组件的正确实现,不是把某个页面抽成公共代码,而是把“地图主流程”从页面业务中分离出来,让页面只保留自己的语义与覆盖层。
@@ -0,0 +1,46 @@
# Shared Map Viewport Layer Popup Addendum
本文件是 [shared_map_viewport.md](C:/Users/VicLi/Documents/Projects/trail-mate/docs/uiux/components/shared_map_viewport.md) 与 [shared_map_viewport_impl.md](C:/Users/VicLi/Documents/Projects/trail-mate/docs/uiux/components/shared_map_viewport_impl.md) 的最小增补,用于把图层弹窗里的“文案所有权”与“页面壳层职责”彻底切开。
## 1. Distinction
1. 图层弹窗不是某个页面私有的业务对象,而是共享地图图层语义的一个可视化入口。
2. 页面拥有的是“弹窗壳层”。
3. 共享地图组件拥有的是“图层语义与图层专有文案”。
## 2. Component Ownership
共享地图组件必须统一拥有并输出以下地图专有可见语义:
1. 图层弹窗标题键,例如 `Map Layer`。
2. 基础底图名称键,例如 `OSM / Terrain / Satellite`。
3. 状态摘要格式,例如 `Base: <Source>`。
4. 等高线状态文本,例如 `Contour: ON / OFF`。
5. 地图相关缺失提示,例如图层缺失或等高线数据缺失。
页面不允许在自己的文件中重新发明这些字符串,也不允许给同一图层状态起第二套名字。
## 3. Page-Shell Ownership
页面壳层可以独立决定:
1. 弹窗挂载到哪个父容器。
2. 弹窗相对触发按钮或安全区如何定位。
3. 背景遮罩、关闭按钮接线、焦点组切换与退场动画。
但页面壳层不得独立决定:
1. 图层名称。
2. 状态摘要格式。
3. 缺图提示措辞。
4. 图层合法值集合。
## 4. Localization Rule
1. 共享地图组件输出的地图专有文案必须以可本地化 key 或已格式化后的本地化文本形式提供给页面。
2. 页面如果需要显示这些文案,只能消费共享组件给出的 key 或结果,不得把 `Terrain`、`Satellite`、`Contour` 这类词重新写死在页面实现里。
3. 通用动作词例如 `Close` 可以继续走公共 i18n 键,但也不得绕过本地化。
## 5. Consequence
这条增补的目的不是增加抽象层,而是禁止以后再次漂回“页面壳层顺手兼任图层语义拥有者”的影子实现。
@@ -0,0 +1,242 @@
# Firmware Visual Style Specification
## 1. Scope
本文档定义 Trail Mate 固件界面的整体视觉语言。
它约束的是:
- 色彩系统
- 顶部 chrome 风格
- 面板/按钮/弹窗的基础样式
- 文本层级与对齐原则
- 页面应该呈现出的整体气质
它**不**定义:
- 某个页面的像素级几何布局
- 某个设备 profile 的固定尺寸
- 某个组件的内部实现细节
这意味着:
- `480x222`
- `pager`
- `tdeck`
- 任何具体 `x / y / w / h`
都只能出现在“页面规格”或“设备/profile 示例”里,不能被提升为全固件通用布局法则。
`docs/skyplot.md`、`docs/sstv/SSTV.md`、`docs/EnergySweep/uiux.md` 这类文档里的像素表,只能解释它们各自页面在各自目标 profile 上如何落地;它们共享的是视觉语言,不是同一套几何。
---
## 2. Distinctions
### 2.1 视觉风格 != 页面布局
视觉风格回答的是“这个固件看起来像什么”。
页面布局回答的是“某个页面在某个 profile 上具体怎么摆”。
前者是全局约束,后者是页面级约束。
### 2.2 页面布局 != 设备 profile
同一个页面在不同设备上可以保持同一视觉语言,但采用不同尺寸、边距、字号和控件密度。
因此:
- 视觉风格应跨设备稳定
- 像素布局应跟随 `page_profile`、屏幕尺寸和交互模式调节
### 2.3 页面 chrome != 页面内容
以下元素属于共享 chrome:
- TopBar
- 返回入口
- 电量/状态位
- 固定悬浮控制按钮
- 弹窗/底部弹层的基础样式
以下元素属于页面内容:
- 地图
- 列表
- 遥测信息
- 图像区域
- 图表/状态面板
chrome 要保持全局一致;内容可以随页面语义变化。
### 2.4 语义色 != 装饰色
颜色首先用于表达层级和语义,不是为了制造“花哨”。
可接受的颜色分工是:
- 主强调:Amber
- 主文本:Text
- 次文本:TextDim
- 信息:Info
- 成功:Ok
- 警告:Warn
不可接受的是为每一行、每一个标签随机发明一组风格无关的颜色。
---
## 3. Canonical Tokens
固件界面整体风格的基线 token 如下:
- `Amber` = `#EBA341`
- `AmberDark` = `#C98118`
- `WarmBG` = `#F6E6C6`
- `PanelBG` = `#FAF0D8`
- `Line` = `#E7C98F`
- `Text` = `#6B4A1E`
- `TextDim` = `#8A6A3A`
- `Warn` = `#B94A2C`
- `Ok` = `#3E7D3E`
- `Info` = `#2D6FB6`
新页面和重构页面应优先围绕这组 token 建立视觉。
如果实现层已经存在共享 theme/helper,则应让 helper 朝这组 token 收敛,而不是在页面里继续发散出新的暗色私有体系。
---
## 4. Global Style Direction
Trail Mate 的界面应统一成“暖色工程仪表风格”,而不是暗色 cyber / HUD 风格。
它的气质应当是:
- 温暖
- 克制
- 可读
- 工程化
- 轻量仪表感
它不应当是:
- 黑蓝霓虹 HUD
- 调试面板堆叠
- 高饱和赛博风
- 到处阴影和浮雕的重装饰 UI
---
## 5. Layout Principles
### 5.1 根背景
页面根背景优先使用 `WarmBG`。
地图、图片、列表或图表所在的主要承载区,可在此基础上用 `PanelBG` 或内容专属底色分层,但不得背离整体暖色基调。
### 5.2 TopBar
所有标准页面优先复用共享 `top_bar` 组件。
TopBar 的语义是“共享应用 chrome”,不是页面自己发明的新标题条。
要求:
- 颜色与全局 theme 保持一致
- 不允许页面局部自定义成另一套标题栏风格
- 标题居中
- 右侧状态信息维持弱层级
### 5.3 面板与边框
如页面确实需要容器,应遵守:
- 背景优先 `PanelBG`
- 边框优先 `Line` 或 `AmberDark`
- 圆角统一 8~10px
- 边框厚度优先 2px
不允许把所有信息都塞进多层嵌套卡片里。
### 5.4 按钮
按钮应沿用暖色工程风格:
- 默认态:`PanelBG` + `AmberDark/Line` 边框
- 聚焦态:`Amber` 外轮廓或高亮
- 禁用态:弱化背景与边框,但仍保持可识别
不允许在单个页面中引入一套深色、金属蓝、玻璃态按钮体系。
### 5.5 弹窗与弹层
弹窗应继承全局风格,而不是成为一块风格孤岛。
要求:
- 背景使用暖色 panel
- 边框使用 `AmberDark` 或 `Line`
- 在小屏上优先用紧凑弹层/底部弹层,而不是巨大居中黑色模态块
- 交互入口不能因为弹层遮挡而变得难以操作
### 5.6 文本层级
文本层级应稳定:
- 主对象名/主读数:`Text`
- 次级说明/状态:`TextDim`
- 强调信息:`AmberDark` 或 `Amber`
- 状态语义:`Info / Ok / Warn`
默认不要把页面做成“所有文字都用不同颜色”的彩条板。
### 5.7 右侧遥测列
当页面右侧承担“遥测/链路/readout”职责时,这一列应按“右缘读数列”处理:
- 整体右对齐
- 一行一项
- 字段语义直接、短、可扫读
- 不要做成“把左对齐段落搬到右边”
---
## 6. Geometry Rules
全局视觉规格只规定几何原则,不规定统一像素。
允许的全局原则:
- 顶部保留共享 TopBar 区
- 内容区按页面语义自由分配
- 边距、字号、按钮尺寸可随 `page_profile` 调整
不允许的误读:
- 把某一页在 `pager` 上的 `480x222` 布局,当成所有页面或所有设备都必须照抄的布局
- 把某一页的双栏布局,当成所有页面都必须双栏
- 把某一页的按钮坐标,当成共享组件的唯一合法位置
---
## 7. Guardrails
后续页面设计与改造必须遵守以下约束:
1. 不允许把暗色 HUD 风格引入到标准内容页面。
2. 不允许把 `pager` 或任何单设备布局示例提升为全局几何规范。
3. 不允许在共享 TopBar 之外私造一套风格冲突的标题栏。
4. 不允许在右侧遥测列中使用“左对齐段落式文本”冒充读数列。
5. 不允许弹窗做成风格割裂、遮挡严重且难以操作的深色模态块。
6. 不允许为了“好看”而突破语义色边界,把颜色变成随机装饰。
---
## 8. Summary Baseline
一句话总结这份规格:
Trail Mate 的固件界面应统一成“暖色工程仪表风格”,而具体像素布局始终是页面级、profile 级决策,不能从某个设备示例反向立法为全局规则。
+668
View File
@@ -0,0 +1,668 @@
# Node Info Page UI/UX Specification
## 1. Scope
本文档定义 Trail Mate `Node Info` / 节点详情页面的 UI/UX 规格。
本文档当前只约束“联系人中打开某个节点后看到的详情页”,不覆盖联系人列表页本身,也不覆盖地图主页面。
本页面中的地图背景、拖动、缩放、图层切换与投影能力,不在本文档中单独重新定义底层组件职责,而是继承共享地图视口组件规格:
- [Firmware Visual Style Specification](../foundation/firmware_visual_style.md)
- [Shared Map Viewport Component Specification](../components/shared_map_viewport.md)
- [Shared Map Viewport Implementation Specification](../components/shared_map_viewport_impl.md)
这是一份实现约束文档,不是视觉灵感草图。后续 `node_info_page_layout.*` 与 `node_info_page_components.*` 的改造应回到本文档核对,而不是继续靠局部补丁演化。
本文档中的未定义项,默认视为“禁止实现”,而不是“留给实现者自由发挥”。
换句话说:
- 只有文档明确列出的字段可以显示
- 只有文档明确允许的降级行为可以出现
- 没有被文档点名的补充信息,默认不应出现在标准 `Node Info` 页面
如果后续需要扩展字段或增加新的视觉元素,应先改规格,再改代码。
---
## 2. Requirement Restatement
节点详情页的目标不是做成一个由多个卡片和边框堆起来的信息面板,而是做成一个以“节点空间位置”为主视觉、以“节点链路信息”为右侧辅信息的沉浸式页面。
当前已经确认的需求如下:
1. 整个节点详情页需要重构,不再沿用旧的卡片式/框式信息布局。
2. 页面主体不要再出现内容框、链路框、信息卡等视觉容器,内容尽量直接落在页面上。
3. 如果节点存在经纬度信息,地图应作为页面背景。
4. 节点 ID 显示在左上角。
5. 经纬度显示在左下角,并且“上面经度,下面纬度”。
6. 原来链路框里显示的信息,不再使用单独框体,而是全部放到页面右侧,一行一项。
7. 如果节点存在经纬度,需要在地图上标出该节点位置。
8. 如果同时存在“我的经纬度”和“该节点经纬度”,需要在地图上同时标记两个点,并连线显示,还要渲染两点之间的距离。
9. 页面右下角增加 `+` 和 `-` 两个地图缩放按钮,缩放中心始终是该节点。
10. 只有在节点存在经纬度时,缩放按钮才可操作;如果没有经纬度,两个缩放按钮不可操作。
11. 当节点存在经纬度时,地图需要支持滑动,允许用户拖动查看节点周边区域。
12. “角色”字段如果只能显示 `-`,则不要显示。当前规格中默认移除该字段。
13. 地图上的文字描述需要使用一组较鲜明、彼此区分的彩色配色。ID、经纬度、链路信息项、距离等可以使用不同颜色,整体要尽量好看。
14. 页面底部中间增加一个 `Layer` 按钮,用于图层切换。
15. `Layer` 按钮的功能必须与 `GPS / 地图` 页中的 `Layer` 按钮完全一致,至少包括:
- 街道图 / `OSM`
- 地形图 / `Terrain`
- 卫星图 / `Satellite`
- 等高线开关 / `Contour`
16. `Node Info` 页不允许定义自己的图层切换语义;它只能复用共享地图视口所定义的图层切换语义。
---
## 3. Distinctions
### 3.1 页面主对象
节点详情页的主对象是“一个远端节点”。
地图不是主对象,地图只是这个节点的空间上下文。
### 3.2 页面主视觉
页面主视觉不是“表格”也不是“卡片组”,而是:
- 有坐标时:以地图为背景的空间视图
- 无坐标时:以纯背景承载文本信息的降级视图
### 3.3 右侧信息区的职责
右侧区域负责承载节点的链路和附加信息,它是“信息投影区”,不是第二个主页面,也不是独立卡片容器。
### 3.4 不再成立的旧表达
以下表达在新规格中不再成立:
- “角色”字段恒常显示,即使值没有意义
- 使用链路框来承载链路信息
- 使用多个内容框把一个节点拆成多个视觉岛
- 让地图退化为小插图或次要组件
---
## 4. Page Goals
### 4.1 核心目标
- 让用户一眼先看到“这个节点在哪里”
- 再快速看到“它和我之间是什么关系”
- 最后看到“它的链路/状态细节”
### 4.2 非目标
本页面当前不是:
- 节点配置编辑器
- 完整诊断页
- 多标签页容器
- 多卡片信息看板
---
## 5. Layout Specification
## 5.1 Overall Structure
页面保留应用级公共顶部栏能力,例如返回、标题、电量等。
除顶部栏外,内容区域采用全屏单画布表达,不再引入内容卡片、信息框、链路框、外轮廓盒子。
本节给出的结构图只表达“区域关系”,不表达固定像素。
`Node Info` 的具体几何必须由当前设备尺寸和 `page_profile` 决定,不能把其它文档中面向 `pager` 的 `480x222` 示例反向理解成 `Node Info` 或全固件的统一布局法。
```text
+--------------------------------------------------+
| Top Bar |
+--------------------------------------------------+
| |
| ID right-side |
| map / background info lines |
| |
| |
| lon |
| lat [Layer] + |
| - |
+--------------------------------------------------+
```
### 5.1.1 Compact Portrait Baseline
以下基线适用于当前 `320x240` 竖屏设备族,是 `Node Info` 页面评审、截图比对和回归检查时的**规范性参考布局**:
- 根页面:`320x240`
- TopBar:`320x30`
- 内容区:`320x210`
- 内容区内边距:`10px`
- 右侧链路列宽:`122px`
- 右侧链路列起始 Y:`12px`
- 右侧链路列行高:`12px`
- 右侧链路列行间距:`1px`
- 右下角缩放按钮:`28x28`
- 底部中间 `Layer` 按钮:`68x24`
这个基线只约束当前紧凑竖屏 profile 下的 `Node Info` 页面,不构成全固件通用布局法。
## 5.2 Background
### 有节点坐标
- 地图铺满内容区域,作为主背景
- 默认首帧以节点为中心
- 地图需要支持用户滑动查看周边区域
- 地图缩放围绕节点进行
- 地图底图必须保持清晰可读;标准态下不允许再叠加任何持续存在的半透明蒙版、雾化层、scrim 或右侧渐隐遮罩
- 唯一允许盖在地图之上的半透明层,只能是临时模态弹层自己的背景遮罩,且它必须在弹层关闭后完全消失
### 无节点坐标
- 不显示地图底图
- 使用纯色或轻量纹理背景承载文本信息
- 缩放按钮显示为禁用态
## 5.3 Left-Top: Node Identity
左上角显示节点 ID。
要求:
- 位置稳定,优先级高
- 不被右侧信息区挤压
- 颜色应明显区别于背景
- 可以使用强调色
如果后续需要同时显示短名/昵称,也应从属于 ID,不应取代 ID 的第一视觉位。
## 5.4 Left-Bottom: Coordinates
左下角显示坐标文本,顺序固定为:
1. 经度
2. 纬度
要求:
- 上面经度,下面纬度
- 文字颜色与 ID 和右侧信息项区分开
- 坐标缺失时不显示伪值、不显示占位破折号
## 5.5 Right Side: Info Lines
页面右侧为信息列,一行一项。
要求:
- 不再使用链路框
- 每一行只承载一个信息项
- 信息项自上而下排列
- 视觉上比左侧主信息弱,但仍应清晰可读
- 行之间需要保持稳定节奏和可扫描性
- 整列按右缘读数列处理,必须真正右对齐
- 当底图为瓦片地图或卫星图时,右侧读数列必须优先保证对比度和瞬时可读性;它允许比全局正文使用更亮的叠加读数色,但不得退化成随机多彩文本
### 5.5.1 Standard Field Set
标准 `Node Info` 页面右侧信息列只允许显示以下字段,顺序固定:
1. `Protocol`
2. `RSSI`
3. `SNR`
4. `Seen`
这 4 项组成标准节点详情页的完整链路信息合同。
如果某一项缺失,则直接省略,后续项上移;**不允许用任何其它字段补位**。
### 5.5.2 Field Text Templates
右侧信息列文本模板固定如下:
1. `Protocol`
- 允许值:`Meshtastic` / `MeshCore` / `RNode` / `LXMF`
- 不允许缩写成 `MT` / `MC` / `RN` / `LX`
2. `RSSI`
- 模板:`RSSI -49 dBm`
- 数值取整到 1 dBm
3. `SNR`
- 模板:`SNR +6.2 dB`
- 保留 1 位小数,正数必须带 `+`
4. `Seen`
- 模板:`Seen 12s` / `Seen 3m` / `Seen 2h` / `Seen 1d`
- 使用相对时长短格式
### 5.5.3 Readout Color Contract
右侧读数列在地图背景上使用固定的高可读颜色合同,当前版本定义如下:
1. `Protocol`
- 使用明亮暖 amber
- 目标是成为第一眼可扫读项
2. `RSSI`
- 使用明亮 info blue / cyan
- 必须明显亮于普通正文棕色
3. `SNR`
- 使用明亮 ok green
4. `Seen`
- 使用浅暖亮色,而不是暗淡次文本棕色
5. `Zoom`
- 使用与 `Seen` 同级或略强一级的浅亮色
禁止做法:
- 继续沿用全局普通正文深棕色直接覆盖到地图瓦片之上
- 为了“统一”而把 `Seen` / `Zoom` 压回低亮度灰褐色
- 对每一行随意指定未收敛的新颜色
### 5.5.4 Forbidden Fields
以下字段在标准 `Node Info` 页面中**明确禁止显示**:
- `LoRa`
- `MQTT`
- `FREQ`
- `SF`
- `BW`
- `CH`
- `HOPS`
- `NEXT`
- `Role`
- 任何调试字段
- 任何为了“补满右侧区域”而临时加入的派生字段
这些信息如果未来确有价值,应进入单独的诊断页或高级详情模式,而不是重新塞回标准节点详情页。
### 5.5.5 Line Count Contract
标准 `Node Info` 页面右侧信息列最大行数为 `4`。
这不是“当前实现巧合”,而是规格本身的一部分。
任何实现如果在标准节点详情页中显示第 5 行及以上的链路信息,都应视为违反规格。
### 5.5.6 Viewport Status Line
标准 `Node Info` 页面允许在 `Seen` 下方额外显示 1 行**视口状态行**,当前版本固定只用于显示当前缩放等级。
它不是链路信息的一部分,因此**不计入** 5.5.4 中“链路信息列最大行数为 `4`”的限制。
要求:
- 固定显示在 `Seen` 的下方
- 仍然属于右侧读数列,必须右对齐
- 只在节点存在有效经纬度时显示
- 当前版本文本模板固定为 `Zoom 12`
- 不允许在这行里追加其它调试字段、tile 状态、坐标系、底图源简称等信息
### 5.5.7 Readout Backdrop
右侧链路读数列允许增加一个 *内容包裹式半透明底板*,用途只有一个:在瓦片地图或卫星图背景上提升 `Protocol / RSSI / SNR / Seen / Zoom` 这组读数的瞬时可读性。
要求:
- 底板只能包裹右侧当前可见的读数项,尺寸必须由“可见读数文本的实际包围盒 + 少量内边距”决定
- 当前版本透明度固定为 `60%`
- 当前版本底板颜色固定继承固件暖色面板底色语义,即 `PanelBG`
- 底板必须跟随可见读数项数量与文本宽度变化,不能退化成固定半列、固定整列或半屏遮罩
- 底板只服务于右侧读数簇,不得扩展到地图主体区域,也不得影响左上 ID、左下坐标、地图标记或距离标签的视觉层级
- 底板是读数簇的承托层,不是新的“链路框”或“信息卡”;禁止重新引入标题栏、分组边框、分隔线或二级卡片语义
- 当右侧没有任何可见读数项时,底板必须完全隐藏
禁止做法:
- 用整块右半屏蒙版、渐隐遮罩、scrim、雾化层来替代这个底板
- 为了实现可读性而重新把地图做灰、做雾或整体降对比
- 把底板做成与右侧列宽永久绑定的固定矩形,而不是内容驱动矩形
## 5.6 Bottom-Right: Zoom Controls
右下角放置两个按钮:
- `+`
- `-`
要求:
- 始终固定在右下角区域
- 只控制地图缩放
- 缩放中心始终是当前节点
- 缩放等级契约固定继承共享地图视口 / 地图页契约:最小 `0`,最大 `18`,默认 `12`
- 首次进入页面时,视口默认使用 `12` 作为首选缩放级别;若该级别中心瓦片不可用,才允许在 `0..18` 范围内寻找最近可用级别
- 用户点击 `+` 或 `-` 后,缩放动作必须重新回到“以当前节点为锚点”的相机语义,不能继续沿用拖动后偏离节点的屏幕中心
- 用户触发的缩放请求只受缩放范围约束,不得再额外被“当前更高/更低一级中心瓦片是否存在”拦截为 no-op;瓦片缺失属于地图数据可用性问题,不是缩放语义问题
- 当已经到达最小或最大缩放级别时,对应按钮必须显示为禁用态
当节点没有经纬度时:
- 两个按钮保持可见但不可操作
- 视觉上必须表现为禁用态
- 点击后不执行任何地图行为
## 5.7 Bottom-Center: Layer Button
页面底部中间必须放置一个 `Layer` 按钮。
要求:
- 位置固定在底部中间区域
- 它属于页面固定 chrome,不跟随地图拖动
- 它打开的图层切换能力必须与 `GPS / 地图` 页的 `Layer` 按钮功能完全一致
- 它不允许引入一套 `Node Info` 私有的图层定义、图层命名或图层切换后果
`Layer` 按钮控制的是共享地图配置,而不是当前页面私有状态。
这意味着:
- 切换 `OSM / Terrain / Satellite` 时,改变的是共享基础底图选择
- 切换 `Contour` 时,改变的是共享等高线叠加开关
- 这些变化的语义应与地图页保持一致
### 无坐标状态下的 `Layer` 按钮
即使当前节点没有坐标,`Layer` 按钮仍然保持可见且可操作。
原因是:
- 它切换的是共享地图图层偏好
- 它不依赖当前节点是否可投影
- 无坐标只意味着当前页不显示地图内容,不意味着图层切换能力失效
但在无坐标状态下:
- 页面仍然不得伪装出可浏览地图
- 切换图层后也不会强行显示地图背景
- 页面主降级态保持不变
---
## 6. Map Semantics
## 6.1 Node Marker
当节点存在经纬度时:
- 必须在地图上标记节点位置
- 节点标记应清楚、醒目、易定位
- 标记色应与“我的位置”标记色区分
## 6.2 Self Marker
只有在本机也存在有效经纬度时,才显示“我的位置”标记。
## 6.3 Connection Line
只有在“节点位置”和“我的位置”都存在时,才显示连线。
要求:
- 连线应清楚但不要喧宾夺主
- 颜色应与底图和两个标记都能区分
## 6.4 Distance Label
只有在“节点位置”和“我的位置”都存在时,才显示距离。
要求:
- 距离标签必须与连线语义一致
- 距离标签优先服务于空间理解,不应遮挡主文本
## 6.5 Panning Semantics
当节点存在经纬度时,地图内容层必须支持滑动。
要求:
- 用户可以通过拖动地图查看节点周边区域
- 滑动的是地图视口,不是整个页面布局
- ID、经纬度文本、右侧信息列、缩放按钮等 UI 覆盖层位置必须保持稳定,不能跟着地图一起漂移
- 节点标记、自身标记、连线、距离等地图语义层必须与底图一起同步移动
- 如果节点没有经纬度,则不提供地图滑动能力
- 首帧默认视图必须让当前节点落在可视区几何中心,而不是左偏或右偏构图
- 拖动只代表“临时浏览周边区域”;它不改变本页的主对象,也不改变缩放锚点语义
- 因此,当用户在拖动后再次执行缩放时,视口必须重新收敛到“节点居中”的锚点状态
滑动能力的职责是“浏览节点周边”,不是改写页面主对象。即使用户把视口拖离节点,节点仍然是本页的主参考对象。
---
## 7. Data Presence States
## 7.1 State A: Node Has No Coordinates
表现:
- 不显示地图底图
- 不显示节点地图标记
- 不显示自我标记
- 不显示连线
- 不显示距离
- 缩放按钮禁用
- 仍显示左上 ID 和右侧信息列
## 7.2 State B: Node Has Coordinates, Self Has No Coordinates
表现:
- 显示地图背景
- 显示节点标记
- 不显示自我标记
- 不显示连线
- 不显示距离
- 支持地图滑动
- 缩放按钮可用
## 7.3 State C: Node Has Coordinates, Self Also Has Coordinates
表现:
- 显示地图背景
- 显示节点标记
- 显示自我标记
- 显示两点连线
- 显示距离
- 支持地图滑动
- 缩放按钮可用
---
## 8. Visual Style
## 8.1 General Style Direction
`Node Info` 页面必须继承 [Firmware Visual Style Specification](../foundation/firmware_visual_style.md)。
这意味着:
- 页面整体必须保持暖色工程仪表风格
- TopBar 必须与其它标准页面使用同一套共享 chrome
- 不允许在该页私自引入深色 HUD / cyber 配色体系
- 地图语义层可以使用有限语义色,但页面 chrome 仍以暖色 token 为主
页面应摆脱“调试面板感”和“表单感”,整体更接近一张有空间感的暖色工程地图详情页。
要求:
- 不堆框
- 不堆边线
- 不让页面看起来像报表
- 颜色鲜明但不杂乱
## 8.2 Color Strategy
以下元素允许使用有限的语义区分色:
- ID
- 经度
- 纬度
- 距离
- 节点标记
- 自身标记
- 连线
右侧信息列默认应以 `Text / TextDim` 为主,只在必要时用 `Info / Ok / Warn / AmberDark` 做语义强调。
颜色要表达层次和角色,不是随机上色。
对于右侧信息列,色彩分配也应固定收敛:
- `Protocol`:`Text`
- `RSSI`:`Text`
- `SNR`:`Ok`
- `Seen`:`TextDim`
标准节点详情页不允许让不同链路项各自随机发明一套颜色。
## 8.3 Typography
要求:
- ID 为页面最高优先级文本之一
- 经纬度次一级,但仍要明显
- 右侧信息项按可扫描性优先,而不是挤成密集小字
- 不要因为要容纳更多字段而把字体压得过小
---
## 9. Interaction Specification
## 9.1 Enter Page
进入节点详情页后:
- 页面应直接展示节点当前信息
- 如果有坐标,应直接进入地图背景态
- 如果有坐标,首帧默认以节点为中心
- 不需要额外点击才能展开地图
## 9.2 Drag / Pan
当节点存在经纬度时,地图必须支持拖动。
要求:
- 拖动手势只作用于地图视口
- 拖动后,用户可以查看节点附近区域
- 拖动不改变节点作为页面主对象的语义地位
- 拖动后不应导致左上 ID、左下坐标、右侧信息列和右下缩放按钮错位
## 9.3 Zoom In / Out
- `+` 放大地图
- `-` 缩小地图
- 缩放中心固定为该节点,不是屏幕中心自由漫游
- 如果用户此前已经把地图拖离节点,触发缩放后视图应回到以节点为缩放锚点的状态
## 9.4 Disabled Interaction
当节点没有坐标时:
- 缩放按钮不可操作
- 地图不可滑动
- 页面不能假装存在地图能力
- 不应出现点击后“看起来点了但什么都没发生”的模糊反馈
## 9.5 Layer Switching
`Layer` 按钮触发的图层切换交互遵守以下规则:
1. 不重建 `Node Info` 页面。
2. 不改变当前查看对象。
3. 不改变页面右侧信息语义。
4. 如果当前节点有坐标,则底图按新图层配置刷新。
5. 如果当前节点无坐标,则只更新共享地图图层偏好,不强行制造地图显示。
6. 缺图提示、图层归一化、等高线开关语义必须与共享地图视口一致。
7. 图层弹窗/弹层必须继承全局暖色弹层风格,并在小屏上保持可操作,不允许做成遮挡严重、难以操作的深色模态块。
8. `320x240` 基线下,图层弹窗的垂直定位必须优先贴近顶部安全区或贴近触发按钮的上方;如果按钮上方空间不足,不允许退化成“尽量压在屏幕下沿”的低位弹窗。
9. 图层弹窗的状态摘要必须压缩为单行展示,固定为同一行中的 `Base: <Source>` 与 `Contour: <ON/OFF>`;不允许再次拆成两行。
10. 图层弹窗在 `320x240` 基线下必须保证 `OSM / Terrain / Satellite / Contour / Close` 五个动作项一次完整可见;不允许依赖裁切、滚动或超高按钮来挤占可操作空间。
---
## 10. Content Rules
## 10.1 Role Field
“角色”字段默认不显示。
只有当后续产品上真正定义了有意义、稳定、对用户有价值的角色语义,并且该值不是占位符时,才允许重新引入。
## 10.2 Empty Values
不要用 `-`、`N/A`、空标签去填充页面。
规则是:
- 有值才显示
- 无值就省略
## 10.3 Information Priority
页面的信息优先级如下:
1. 节点身份
2. 节点空间位置
3. 我与节点的空间关系
4. 节点链路与附加信息
右侧信息项不得反过来压制地图和身份信息。
### 10.4 Standard Page Contract
标准 `Node Info` 页面不是诊断页。
因此它的信息合同固定为:
- 左上:节点 ID
- 左下:经度、纬度
- 地图层:节点位置、自身位置、连线、距离
- 右侧:`Protocol / RSSI / SNR / Seen`
除此之外,不再显示其它链路细节。
这条合同的目的就是压缩实现自由度,避免不同开发者或 AI 在“也许这些字段有用”的判断下继续发散。
---
## 11. Implementation Guardrails
为避免实现再次漂移,后续改造时必须遵守以下约束:
1. 不允许重新引入“链路框”。
2. 不允许为了补字段而恢复卡片式布局。
3. 不允许保留只会显示 `-` 的角色字段。
4. 不允许把地图降为页面中的一个小组件,只要节点有坐标,地图就是背景主画布。
5. 不允许让缩放围绕任意中心点漂移,缩放中心必须是节点。
6. 不允许在无坐标状态下伪装成可缩放地图页。
7. 不允许把“地图支持滑动”实现成整个详情页跟着滚动,固定信息层必须保持稳定。
8. 不允许把颜色策略退化成“全部同色文本”。
9. 不允许把右侧信息列做成“放在右侧的左对齐段落”。
10. 不允许把协议对象名重新压缩成 `MT/MC/RN/LX` 这类缩写主表达。
11. 不允许在标准 `Node Info` 页面中重新加入 `LoRa / MQTT / FREQ / SF / BW / CH / HOPS / NEXT`。
12. 不允许把右侧信息列扩展到 4 行以上。
13. 不允许在 `Node Info` 页面内再次实现一套独立地图主流程;地图底图、投影、拖动、缩放、图层切换必须接入共享地图视口组件。
14. 不允许在 `Node Info` 页面内定义与 `GPS / 地图` 页不同的图层切换语义。
15. 不允许把 `Layer` 按钮实现成只改视觉、不改共享图层配置的伪入口。
---
## 12. Open Items
以下问题当前可以留给实现阶段,但不能违反前述结构约束:
- 地图标记的具体图形样式
- 距离标签放置在连线中点、靠近节点还是靠近右侧信息列
- 无坐标状态下采用纯色背景还是轻量纹理背景
- 不同设备尺寸上的字号与边距微调
这些都属于实现细节微调,不应推翻本文档的页面结构。
---
## 13. Summary Baseline
一句话总结本页规格:
节点详情页应当是一个“以节点位置为中心的全屏地图详情页”,而不是一个“套着多个框的节点信息面板”。
@@ -0,0 +1,32 @@
# Node Info Layer Popup Addendum
本文件是 [node_info_page.md](C:/Users/VicLi/Documents/Projects/trail-mate/docs/uiux/pages/node_info_page.md) 的增补约束,只收敛 `Node Info` 页中的图层弹窗,不重新定义整页布局。
## 1. Scope
1. 本增补只约束 `Node Info` 页底部 `Layer` 按钮打开的图层弹窗。
2. 页面壳层可以决定弹窗出现位置、承载容器、关闭触发方式与焦点切换。
3. 页面壳层不拥有图层语义本身,不得重新定义图层名称、状态摘要或缺图提示。
## 2. Copy Contract
1. `Node Info` 页图层弹窗中的地图专有文案必须复用共享地图组件语义,不得在页面内直接写死另一套英文或页面私有叫法。
2. 至少以下文本必须与共享地图组件保持同义且可本地化:
- `Map Layer`
- `Base: <Source>`
- `OSM / Terrain / Satellite`
- `Contour: ON / OFF`
- 图层缺失提示
- 等高线数据缺失提示
3. 通用关闭动作允许继续复用全局公共文案键,例如 `Close`,但仍必须经过 i18n 处理。
## 3. Small-Screen Operability
1. 在 `320x240` 基线下,图层弹窗首次打开时必须保证 `OSM / Terrain / Satellite / Contour / Close` 五个动作项一次完整可见。
2. `Close` 动作项必须完整可见、完整可点,不允许被屏幕边缘、遮罩、父容器或超高按钮裁切。
3. 如果小屏空间不足,优先收紧弹窗内部间距、按钮高度与定位策略,不允许把最后一个动作项挤成半截露出。
## 4. Consequence
1. 以后如果 `GPS / 地图` 页调整图层名称、摘要格式或缺图提示,`Node Info` 页必须跟随共享语义一起调整。
2. 以后如果 `Node Info` 页只改了弹窗外壳而没有改共享语义层,则视为合法页面实现。
@@ -5,8 +5,14 @@
#include "chat/infra/meshcore/meshcore_protocol_helpers.h"
#if defined(ESP_PLATFORM)
#include "mbedtls/aes.h"
#include "mbedtls/md.h"
#include "mbedtls/sha256.h"
#else
#include <AES.h>
#include <SHA256.h>
#endif
#include <algorithm>
#include <cmath>
@@ -43,6 +49,109 @@ T clampValue(T value, T min_value, T max_value)
return value;
}
#if defined(ESP_PLATFORM)
class Sha256Accumulator
{
public:
Sha256Accumulator()
{
mbedtls_sha256_init(&ctx_);
mbedtls_sha256_starts(&ctx_, 0);
valid_ = true;
}
~Sha256Accumulator()
{
mbedtls_sha256_free(&ctx_);
}
void update(const void* data, size_t len)
{
if (!valid_ || !data || len == 0)
{
return;
}
mbedtls_sha256_update(&ctx_,
static_cast<const unsigned char*>(data),
len);
}
bool finalize(uint8_t out_hash[32])
{
if (!valid_ || !out_hash)
{
return false;
}
mbedtls_sha256_finish(&ctx_, out_hash);
return true;
}
private:
mbedtls_sha256_context ctx_{};
bool valid_ = false;
};
class Aes128EcbCipher
{
public:
Aes128EcbCipher()
{
mbedtls_aes_init(&encrypt_);
mbedtls_aes_init(&decrypt_);
}
~Aes128EcbCipher()
{
mbedtls_aes_free(&encrypt_);
mbedtls_aes_free(&decrypt_);
}
bool setKey(const uint8_t* key, size_t len)
{
if (!key || len != kCipherKeySize)
{
return false;
}
return mbedtls_aes_setkey_enc(&encrypt_, key, static_cast<unsigned>(len * 8U)) == 0 &&
mbedtls_aes_setkey_dec(&decrypt_, key, static_cast<unsigned>(len * 8U)) == 0;
}
bool encryptBlock(uint8_t* out, const uint8_t* in)
{
return out && in &&
mbedtls_aes_crypt_ecb(&encrypt_, MBEDTLS_AES_ENCRYPT, in, out) == 0;
}
bool decryptBlock(uint8_t* out, const uint8_t* in)
{
return out && in &&
mbedtls_aes_crypt_ecb(&decrypt_, MBEDTLS_AES_DECRYPT, in, out) == 0;
}
private:
mbedtls_aes_context encrypt_{};
mbedtls_aes_context decrypt_{};
};
bool hmacSha256(const uint8_t* key,
size_t key_len,
const uint8_t* data,
size_t data_len,
uint8_t out_hash[32])
{
if (!key || key_len == 0 || !out_hash)
{
return false;
}
static constexpr uint8_t kEmpty = 0;
const uint8_t* input = (data && data_len != 0) ? data : &kEmpty;
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
return info &&
mbedtls_md_hmac(info, key, key_len, input, data_len, out_hash) == 0;
}
#endif
} // namespace
std::string toHex(const uint8_t* data, size_t len, size_t max_len)
@@ -138,6 +247,25 @@ uint32_t packetSignature(uint8_t payload_type, size_t path_len,
const uint8_t* payload, size_t payload_len)
{
uint8_t sig_bytes[sizeof(uint32_t)] = {};
#if defined(ESP_PLATFORM)
uint8_t hash_bytes[32] = {};
Sha256Accumulator sha;
sha.update(&payload_type, sizeof(payload_type));
if (payload_type == kPayloadTypeTrace)
{
uint8_t p = static_cast<uint8_t>(path_len & 0xFFU);
sha.update(&p, sizeof(p));
}
if (payload && payload_len > 0)
{
sha.update(payload, payload_len);
}
if (!sha.finalize(hash_bytes))
{
return 0;
}
memcpy(sig_bytes, hash_bytes, sizeof(sig_bytes));
#else
SHA256 sha;
sha.update(&payload_type, sizeof(payload_type));
if (payload_type == kPayloadTypeTrace)
@@ -150,6 +278,7 @@ uint32_t packetSignature(uint8_t payload_type, size_t path_len,
sha.update(payload, payload_len);
}
sha.finalize(sig_bytes, sizeof(sig_bytes));
#endif
uint32_t sig = 0;
memcpy(&sig, sig_bytes, sizeof(sig));
@@ -285,9 +414,24 @@ void sha256Trunc(uint8_t* out_hash, size_t out_len, const uint8_t* msg, size_t m
{
return;
}
#if defined(ESP_PLATFORM)
uint8_t full_hash[32] = {};
Sha256Accumulator sha;
if (msg && msg_len != 0)
{
sha.update(msg, msg_len);
}
if (!sha.finalize(full_hash))
{
memset(out_hash, 0, out_len);
return;
}
memcpy(out_hash, full_hash, std::min(out_len, sizeof(full_hash)));
#else
SHA256 sha;
sha.update(msg, static_cast<size_t>(msg_len));
sha.finalize(out_hash, out_len);
#endif
}
uint8_t computeChannelHash(const uint8_t* key16)
@@ -307,15 +451,30 @@ size_t aesEncrypt(const uint8_t* key16, uint8_t* dest, const uint8_t* src, size_
{
return 0;
}
#if defined(ESP_PLATFORM)
Aes128EcbCipher aes;
if (!aes.setKey(key16, kCipherKeySize))
{
return 0;
}
#else
AES128 aes;
aes.setKey(key16, kCipherKeySize);
#endif
uint8_t* dest_ptr = dest;
const uint8_t* src_ptr = src;
size_t remaining = src_len;
while (remaining >= kCipherBlockSize)
{
#if defined(ESP_PLATFORM)
if (!aes.encryptBlock(dest_ptr, src_ptr))
{
return 0;
}
#else
aes.encryptBlock(dest_ptr, src_ptr);
#endif
dest_ptr += kCipherBlockSize;
src_ptr += kCipherBlockSize;
remaining -= kCipherBlockSize;
@@ -325,7 +484,14 @@ size_t aesEncrypt(const uint8_t* key16, uint8_t* dest, const uint8_t* src, size_
uint8_t tail[kCipherBlockSize];
memset(tail, 0, sizeof(tail));
memcpy(tail, src_ptr, remaining);
#if defined(ESP_PLATFORM)
if (!aes.encryptBlock(dest_ptr, tail))
{
return 0;
}
#else
aes.encryptBlock(dest_ptr, tail);
#endif
dest_ptr += kCipherBlockSize;
}
return static_cast<size_t>(dest_ptr - dest);
@@ -337,15 +503,30 @@ size_t aesDecrypt(const uint8_t* key16, uint8_t* dest, const uint8_t* src, size_
{
return 0;
}
#if defined(ESP_PLATFORM)
Aes128EcbCipher aes;
if (!aes.setKey(key16, kCipherKeySize))
{
return 0;
}
#else
AES128 aes;
aes.setKey(key16, kCipherKeySize);
#endif
uint8_t* dest_ptr = dest;
const uint8_t* src_ptr = src;
size_t remaining = src_len;
while (remaining >= kCipherBlockSize)
{
#if defined(ESP_PLATFORM)
if (!aes.decryptBlock(dest_ptr, src_ptr))
{
return 0;
}
#else
aes.decryptBlock(dest_ptr, src_ptr);
#endif
dest_ptr += kCipherBlockSize;
src_ptr += kCipherBlockSize;
remaining -= kCipherBlockSize;
@@ -375,10 +556,19 @@ size_t encryptThenMac(const uint8_t* key16, const uint8_t* key32,
return 0;
}
#if defined(ESP_PLATFORM)
uint8_t full_hash[32] = {};
if (!hmacSha256(key32, kCipherHmacKeySize, out + kCipherMacSize, enc_len, full_hash))
{
return 0;
}
memcpy(out, full_hash, kCipherMacSize);
#else
SHA256 sha;
sha.resetHMAC(key32, kCipherHmacKeySize);
sha.update(out + kCipherMacSize, enc_len);
sha.finalizeHMAC(key32, kCipherHmacKeySize, out, kCipherMacSize);
#endif
return kCipherMacSize + enc_len;
}
@@ -396,10 +586,19 @@ bool macThenDecrypt(const uint8_t* key16, const uint8_t* key32,
}
uint8_t expected[kCipherMacSize];
#if defined(ESP_PLATFORM)
uint8_t full_hash[32] = {};
if (!hmacSha256(key32, kCipherHmacKeySize, src + kCipherMacSize, cipher_len, full_hash))
{
return false;
}
memcpy(expected, full_hash, sizeof(expected));
#else
SHA256 sha;
sha.resetHMAC(key32, kCipherHmacKeySize);
sha.update(src + kCipherMacSize, cipher_len);
sha.finalizeHMAC(key32, kCipherHmacKeySize, expected, kCipherMacSize);
#endif
if (memcmp(expected, src, kCipherMacSize) != 0)
{
return false;
@@ -5,9 +5,15 @@
#include "chat/infra/reticulum/reticulum_wire.h"
#if defined(ESP_PLATFORM)
#include "mbedtls/aes.h"
#include "mbedtls/md.h"
#include "mbedtls/sha256.h"
#else
#include <AES.h>
#include <Crypto.h>
#include <SHA256.h>
#endif
#include <algorithm>
#include <array>
@@ -28,12 +34,34 @@ constexpr uint8_t kHeaderType2 = 0x01;
class Aes256CbcCipher
{
public:
Aes256CbcCipher()
{
#if defined(ESP_PLATFORM)
mbedtls_aes_init(&encrypt_);
mbedtls_aes_init(&decrypt_);
#endif
}
~Aes256CbcCipher()
{
#if defined(ESP_PLATFORM)
mbedtls_aes_free(&encrypt_);
mbedtls_aes_free(&decrypt_);
#endif
}
void setKey(const uint8_t* key, size_t len)
{
valid_ = (key != nullptr && len == 32);
if (valid_)
{
#if defined(ESP_PLATFORM)
valid_ =
mbedtls_aes_setkey_enc(&encrypt_, key, static_cast<unsigned>(len * 8U)) == 0 &&
mbedtls_aes_setkey_dec(&decrypt_, key, static_cast<unsigned>(len * 8U)) == 0;
#else
aes_.setKey(key, len);
#endif
}
}
@@ -48,7 +76,14 @@ class Aes256CbcCipher
{
return;
}
#if defined(ESP_PLATFORM)
if (valid_)
{
(void)mbedtls_aes_crypt_ecb(&encrypt_, MBEDTLS_AES_ENCRYPT, in, out);
}
#else
aes_.encryptBlock(out, in);
#endif
}
void decryptBlock(uint8_t* out, const uint8_t* in)
@@ -57,11 +92,23 @@ class Aes256CbcCipher
{
return;
}
#if defined(ESP_PLATFORM)
if (valid_)
{
(void)mbedtls_aes_crypt_ecb(&decrypt_, MBEDTLS_AES_DECRYPT, in, out);
}
#else
aes_.decryptBlock(out, in);
#endif
}
private:
#if defined(ESP_PLATFORM)
mbedtls_aes_context encrypt_{};
mbedtls_aes_context decrypt_{};
#else
AESSmall256 aes_;
#endif
bool valid_ = false;
};
@@ -74,6 +121,16 @@ void hmacSha256(const uint8_t* key, size_t key_len,
return;
}
#if defined(ESP_PLATFORM)
static constexpr uint8_t kEmpty = 0;
const uint8_t* input = (data && data_len != 0) ? data : &kEmpty;
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (!info ||
mbedtls_md_hmac(info, key, key_len, input, data_len, out_hash) != 0)
{
memset(out_hash, 0, kFullHashSize);
}
#else
SHA256 sha;
sha.resetHMAC(key, key_len);
if (data && data_len != 0)
@@ -81,6 +138,7 @@ void hmacSha256(const uint8_t* key, size_t key_len,
sha.update(data, data_len);
}
sha.finalizeHMAC(key, key_len, out_hash, kFullHashSize);
#endif
}
void xorBlock(uint8_t* dst, const uint8_t* src)
@@ -251,12 +309,23 @@ void fullHash(const uint8_t* data, size_t len, uint8_t out_hash[kFullHashSize])
return;
}
#if defined(ESP_PLATFORM)
static constexpr uint8_t kEmpty = 0;
const uint8_t* input = (data && len != 0) ? data : &kEmpty;
mbedtls_sha256_context sha;
mbedtls_sha256_init(&sha);
mbedtls_sha256_starts(&sha, 0);
mbedtls_sha256_update(&sha, input, len);
mbedtls_sha256_finish(&sha, out_hash);
mbedtls_sha256_free(&sha);
#else
SHA256 sha;
if (data && len != 0)
{
sha.update(data, len);
}
sha.finalize(out_hash, kFullHashSize);
#endif
}
void truncatedHash(const uint8_t* data, size_t len, uint8_t out_hash[kTruncatedHashSize])
@@ -59,4 +59,26 @@ void set_content_label_text(lv_obj_t* label, const char* english);
void set_content_label_text_raw(lv_obj_t* label, const char* text);
void set_label_text_fmt(lv_obj_t* label, const char* english_fmt, ...);
#if defined(__cpp_char8_t)
inline void set_label_text(lv_obj_t* label, const char8_t* english)
{
set_label_text(label, reinterpret_cast<const char*>(english));
}
inline void set_label_text_raw(lv_obj_t* label, const char8_t* text)
{
set_label_text_raw(label, reinterpret_cast<const char*>(text));
}
inline void set_content_label_text(lv_obj_t* label, const char8_t* english)
{
set_content_label_text(label, reinterpret_cast<const char*>(english));
}
inline void set_content_label_text_raw(lv_obj_t* label, const char8_t* text)
{
set_content_label_text_raw(label, reinterpret_cast<const char*>(text));
}
#endif
} // namespace ui::i18n
@@ -9,14 +9,14 @@
#include "chat/domain/contact_types.h"
#include "lvgl.h"
#include "ui/widgets/map/map_viewport.h"
namespace node_info
{
namespace ui
{
static constexpr std::size_t kNodeInfoTileCount = 9;
static constexpr std::size_t kNodeInfoInfoLineCount = 8;
static constexpr std::size_t kNodeInfoInfoLineCount = 4;
struct NodeInfoWidgets
{
@@ -31,8 +31,8 @@ struct NodeInfoWidgets
lv_obj_t* map_stage = nullptr;
lv_obj_t* tile_layer = nullptr;
lv_obj_t* left_scrim = nullptr;
lv_obj_t* right_scrim = nullptr;
lv_obj_t* map_overlay_layer = nullptr;
lv_obj_t* map_gesture_surface = nullptr;
lv_obj_t* id_label = nullptr;
lv_obj_t* lon_label = nullptr;
@@ -50,9 +50,13 @@ struct NodeInfoWidgets
lv_obj_t* zoom_out_btn = nullptr;
lv_obj_t* zoom_in_label = nullptr;
lv_obj_t* zoom_out_label = nullptr;
lv_obj_t* info_panel = nullptr;
lv_obj_t* zoom_status_label = nullptr;
lv_obj_t* layer_btn = nullptr;
lv_obj_t* layer_label = nullptr;
lv_obj_t* tile_images[kNodeInfoTileCount]{};
lv_obj_t* info_labels[kNodeInfoInfoLineCount]{};
::ui::widgets::map::Widgets map_viewport{};
};
/**
@@ -0,0 +1,148 @@
/**
* @file map_viewport.h
* @brief Shared map viewport facade for map-based pages.
*/
#pragma once
#include "lvgl.h"
#include <cstdint>
#include <string>
namespace ui::widgets::map
{
constexpr int kDefaultZoom = 12;
constexpr int kMinZoom = 0;
constexpr int kMaxZoom = 18;
struct RuntimeImpl;
struct GeoPoint
{
constexpr GeoPoint() = default;
constexpr GeoPoint(bool valid_value, double lat_value, double lon_value)
: valid(valid_value), lat(lat_value), lon(lon_value)
{
}
bool valid = false;
double lat = 0.0;
double lon = 0.0;
};
struct Model
{
GeoPoint focus_point{};
int zoom = kDefaultZoom;
int pan_x = 0;
int pan_y = 0;
uint8_t map_source = 0;
bool contour_enabled = false;
uint8_t coord_system = 0;
};
struct Widgets
{
lv_obj_t* root = nullptr;
lv_obj_t* tile_layer = nullptr;
lv_obj_t* overlay_layer = nullptr;
lv_obj_t* gesture_surface = nullptr;
};
struct Status
{
bool alive = false;
bool has_focus = false;
bool anchor_valid = false;
bool has_map_data = false;
bool has_visible_map_data = false;
int zoom = 0;
int pan_x = 0;
int pan_y = 0;
};
struct LayerState
{
uint8_t map_source = 0;
bool contour_enabled = false;
};
struct LayerNotice
{
bool has_message = false;
char message[64]{};
uint32_t duration_ms = 0;
};
enum class GesturePhase : uint8_t
{
Pressed = 0,
DragBegin,
DragUpdate,
DragEnd,
Cancel,
};
struct GestureEvent
{
GesturePhase phase = GesturePhase::Pressed;
lv_point_t point{};
int total_dx = 0;
int total_dy = 0;
bool dragging = false;
};
using GestureCallback = void (*)(const GestureEvent& event, void* user_data);
class Runtime
{
public:
Runtime() = default;
~Runtime();
Runtime(const Runtime&) = delete;
Runtime& operator=(const Runtime&) = delete;
Runtime(Runtime&& other) noexcept;
Runtime& operator=(Runtime&& other) noexcept;
private:
RuntimeImpl* impl_ = nullptr;
friend Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t loader_interval_ms);
friend void destroy(Runtime& runtime);
friend const Widgets& widgets(const Runtime& runtime);
friend void set_size(Runtime& runtime, lv_coord_t width, lv_coord_t height);
friend void apply_model(Runtime& runtime, const Model& model);
friend void clear(Runtime& runtime);
friend bool project_point(const Runtime& runtime, const GeoPoint& point, lv_point_t& out_screen_point);
friend Status status(const Runtime& runtime);
friend bool take_missing_tile_notice(Runtime& runtime, uint8_t* out_map_source);
friend void set_gesture_enabled(Runtime& runtime, bool enabled);
friend void set_gesture_callback(Runtime& runtime, GestureCallback callback, void* user_data);
};
Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t loader_interval_ms = 200);
void destroy(Runtime& runtime);
const Widgets& widgets(const Runtime& runtime);
void set_size(Runtime& runtime, lv_coord_t width, lv_coord_t height);
void apply_model(Runtime& runtime, const Model& model);
void clear(Runtime& runtime);
bool project_point(const Runtime& runtime, const GeoPoint& point, lv_point_t& out_screen_point);
bool preview_project_point(lv_obj_t* viewport_root, const Model& model, const GeoPoint& point, lv_point_t& out_screen_point);
bool focus_tile_available(const Model& model);
Status status(const Runtime& runtime);
bool take_missing_tile_notice(Runtime& runtime, uint8_t* out_map_source);
bool transform_geo_point(const GeoPoint& point, uint8_t coord_system, GeoPoint& out_point);
void set_gesture_enabled(Runtime& runtime, bool enabled);
void set_gesture_callback(Runtime& runtime, GestureCallback callback, void* user_data);
LayerState current_layer_state();
bool set_layer_map_source(uint8_t map_source, LayerNotice* out_notice = nullptr);
bool toggle_layer_contour(LayerNotice* out_notice = nullptr);
const char* layer_map_source_label_key(uint8_t map_source);
const char* layer_contour_status_key(bool contour_enabled);
std::string layer_base_summary_text(uint8_t map_source);
} // namespace ui::widgets::map
@@ -1,10 +1,18 @@
#include "ui/components/two_pane_styles.h"
#if defined(ESP_PLATFORM)
#include "esp_log.h"
#endif
namespace ui::components::two_pane_styles
{
namespace
{
#if defined(ESP_PLATFORM)
constexpr const char* kLogTag = "two-pane-style";
#endif
bool s_inited = false;
lv_style_t s_panel_side;
@@ -29,6 +37,10 @@ void init_once()
if (s_inited) return;
s_inited = true;
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "init_once");
#endif
lv_style_init(&s_panel_side);
lv_style_set_bg_opa(&s_panel_side, LV_OPA_COVER);
lv_style_set_bg_color(&s_panel_side, lv_color_hex(kSidePanelBg));
@@ -28,7 +28,7 @@
#ifdef INADDR_NONE
#undef INADDR_NONE
#endif
#include "rom/miniz.h"
#include <rom/miniz.h>
#define UI_PACKS_HAVE_CRT_BUNDLE 1
@@ -1271,9 +1271,9 @@ std::string sha256_hex_of_bytes(const std::uint8_t* data, std::size_t len)
unsigned char hash[32];
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts_ret(&ctx, 0);
mbedtls_sha256_update_ret(&ctx, data, len);
mbedtls_sha256_finish_ret(&ctx, hash);
mbedtls_sha256_starts(&ctx, 0);
mbedtls_sha256_update(&ctx, data, len);
mbedtls_sha256_finish(&ctx, hash);
mbedtls_sha256_free(&ctx);
char hex[65];
@@ -48,6 +48,8 @@
#define CONTACTS_LOG(...)
#endif
#define CONTACTS_NODE_INFO_LOG(...) std::printf("[Contacts][NodeInfo] " __VA_ARGS__)
using namespace contacts::ui;
namespace chat_support = chat::ui::support;
@@ -1136,8 +1138,14 @@ static void open_delete_confirm_modal()
static void open_node_info_screen_for_node(uint32_t node_id)
{
CONTACTS_NODE_INFO_LOG("open request node=%08lX existing_root=%p contacts_root=%p active=%p\n",
static_cast<unsigned long>(node_id),
g_contacts_state.node_info_root,
g_contacts_state.root,
lv_screen_active());
if (g_contacts_state.node_info_root)
{
CONTACTS_NODE_INFO_LOG("open ignored: node_info_root already exists\n");
return;
}
@@ -1148,19 +1156,39 @@ static void open_node_info_screen_for_node(uint32_t node_id)
}
if (!node)
{
CONTACTS_NODE_INFO_LOG("open aborted: node not found\n");
return;
}
CONTACTS_NODE_INFO_LOG("resolved node=%08lX display='%s' long='%s' short='%s' pos_valid=%d\n",
static_cast<unsigned long>(node->node_id),
node->display_name.c_str(),
node->long_name,
node->short_name,
node->position.valid ? 1 : 0);
lv_obj_t* parent = g_contacts_state.root
? lv_obj_get_parent(g_contacts_state.root)
: lv_screen_active();
if (!parent)
{
CONTACTS_NODE_INFO_LOG("open aborted: parent missing\n");
return;
}
lv_obj_update_layout(parent);
CONTACTS_NODE_INFO_LOG("parent=%p size=%dx%d hidden=%d\n",
parent,
static_cast<int>(lv_obj_get_width(parent)),
static_cast<int>(lv_obj_get_height(parent)),
lv_obj_has_flag(parent, LV_OBJ_FLAG_HIDDEN) ? 1 : 0);
node_info::ui::NodeInfoWidgets widgets = node_info::ui::create(parent);
g_contacts_state.node_info_root = widgets.root;
CONTACTS_NODE_INFO_LOG("node_info created root=%p header=%p content=%p back_btn=%p\n",
widgets.root,
widgets.header,
widgets.content,
widgets.back_btn);
const chat::contacts::NodeInfo* info = node;
if (g_contacts_state.contact_service)
@@ -1169,68 +1197,106 @@ static void open_node_info_screen_for_node(uint32_t node_id)
if (latest)
{
info = latest;
CONTACTS_NODE_INFO_LOG("using latest contact_service snapshot node=%08lX pos_valid=%d\n",
static_cast<unsigned long>(latest->node_id),
latest->position.valid ? 1 : 0);
}
}
node_info::ui::set_node_info(*info);
CONTACTS_NODE_INFO_LOG("set_node_info done root=%p hidden=%d size=%dx%d\n",
widgets.root,
widgets.root ? (lv_obj_has_flag(widgets.root, LV_OBJ_FLAG_HIDDEN) ? 1 : 0) : -1,
widgets.root ? static_cast<int>(lv_obj_get_width(widgets.root)) : -1,
widgets.root ? static_cast<int>(lv_obj_get_height(widgets.root)) : -1);
if (!g_contacts_state.node_info_group)
{
g_contacts_state.node_info_group = lv_group_create();
CONTACTS_NODE_INFO_LOG("created node_info_group=%p\n", g_contacts_state.node_info_group);
}
lv_group_remove_all_objs(g_contacts_state.node_info_group);
g_contacts_state.node_info_prev_group = lv_group_get_default();
set_default_group(g_contacts_state.node_info_group);
CONTACTS_NODE_INFO_LOG("focus group switched prev=%p current=%p\n",
g_contacts_state.node_info_prev_group,
g_contacts_state.node_info_group);
if (widgets.back_btn)
{
lv_group_add_obj(g_contacts_state.node_info_group, widgets.back_btn);
lv_group_focus_obj(widgets.back_btn);
lv_obj_add_event_cb(widgets.back_btn, on_node_info_back_clicked, LV_EVENT_CLICKED, nullptr);
CONTACTS_NODE_INFO_LOG("back button wired and focused back_btn=%p\n", widgets.back_btn);
}
if (widgets.layer_btn)
{
lv_group_add_obj(g_contacts_state.node_info_group, widgets.layer_btn);
CONTACTS_NODE_INFO_LOG("layer button added layer_btn=%p\n", widgets.layer_btn);
}
if (g_contacts_state.root)
{
lv_obj_add_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN);
CONTACTS_NODE_INFO_LOG("contacts root hidden root=%p hidden=%d\n",
g_contacts_state.root,
lv_obj_has_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN) ? 1 : 0);
}
if (g_contacts_state.refresh_timer)
{
lv_timer_pause(g_contacts_state.refresh_timer);
CONTACTS_NODE_INFO_LOG("refresh timer paused timer=%p\n", g_contacts_state.refresh_timer);
}
CONTACTS_NODE_INFO_LOG("open complete\n");
}
static void close_node_info_screen()
{
CONTACTS_NODE_INFO_LOG("close request root=%p group=%p prev_group=%p\n",
g_contacts_state.node_info_root,
g_contacts_state.node_info_group,
g_contacts_state.node_info_prev_group);
if (!g_contacts_state.node_info_root)
{
CONTACTS_NODE_INFO_LOG("close ignored: no node_info_root\n");
return;
}
node_info::ui::destroy();
g_contacts_state.node_info_root = nullptr;
CONTACTS_NODE_INFO_LOG("node_info destroyed\n");
if (g_contacts_state.node_info_group)
{
lv_group_remove_all_objs(g_contacts_state.node_info_group);
CONTACTS_NODE_INFO_LOG("node_info_group cleared group=%p\n", g_contacts_state.node_info_group);
}
lv_group_t* restore = contacts_input_get_group();
if (restore)
{
set_default_group(restore);
CONTACTS_NODE_INFO_LOG("restored default group=%p\n", restore);
}
g_contacts_state.node_info_prev_group = nullptr;
if (g_contacts_state.root)
{
lv_obj_clear_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN);
CONTACTS_NODE_INFO_LOG("contacts root shown root=%p hidden=%d\n",
g_contacts_state.root,
lv_obj_has_flag(g_contacts_state.root, LV_OBJ_FLAG_HIDDEN) ? 1 : 0);
}
if (g_contacts_state.refresh_timer)
{
lv_timer_resume(g_contacts_state.refresh_timer);
CONTACTS_NODE_INFO_LOG("refresh timer resumed timer=%p\n", g_contacts_state.refresh_timer);
}
CONTACTS_NODE_INFO_LOG("close refresh_ui\n");
refresh_ui();
CONTACTS_NODE_INFO_LOG("close contacts_focus_to_list\n");
contacts_focus_to_list();
CONTACTS_NODE_INFO_LOG("close complete\n");
}
static void open_chat_compose()
@@ -1993,6 +2059,7 @@ static void on_del_cancel_clicked(lv_event_t* /*e*/)
static void on_node_info_back_clicked(lv_event_t* /*e*/)
{
CONTACTS_NODE_INFO_LOG("back button clicked\n");
close_node_info_screen();
}
@@ -320,8 +320,8 @@ static void gps_initial_tiles_async(void* /*user_data*/)
g_gps_state.has_fix,
sanitize_map_source(app::configFacade().getConfig().map_source),
app::configFacade().getConfig().map_contour_enabled,
lv_obj_get_width(g_gps_state.map),
lv_obj_get_height(g_gps_state.map),
static_cast<int>(lv_obj_get_width(g_gps_state.map)),
static_cast<int>(lv_obj_get_height(g_gps_state.map)),
g_gps_state.lat,
g_gps_state.lng);
update_map_tiles(false);
File diff suppressed because it is too large Load Diff
@@ -77,8 +77,9 @@ lv_obj_t* create_root(lv_obj_t* parent)
lv_obj_t* create_header(lv_obj_t* root)
{
const lv_coord_t root_w = resolve_parent_width(root);
lv_obj_t* header = lv_obj_create(root);
lv_obj_set_size(header, lv_obj_get_width(root), top_bar_height());
lv_obj_set_size(header, root_w, top_bar_height());
lv_obj_set_pos(header, 0, 0);
make_plain(header);
return header;
@@ -86,9 +87,11 @@ lv_obj_t* create_header(lv_obj_t* root)
lv_obj_t* create_content(lv_obj_t* root)
{
const lv_coord_t root_w = resolve_parent_width(root);
const lv_coord_t root_h = resolve_parent_height(root);
const lv_coord_t header_h = top_bar_height();
lv_obj_t* content = lv_obj_create(root);
lv_obj_set_size(content, lv_obj_get_width(root), lv_obj_get_height(root) - header_h);
lv_obj_set_size(content, root_w, root_h - header_h);
lv_obj_set_pos(content, 0, header_h);
make_plain(content);
return content;
@@ -42,12 +42,20 @@
#include "ui/widgets/system_notification.h"
#include "ui/widgets/top_bar.h"
#if defined(ESP_PLATFORM)
#include "esp_log.h"
#endif
namespace settings::ui::components
{
namespace
{
#if defined(ESP_PLATFORM)
constexpr const char* kLogTag = "settings-page";
#endif
namespace device_runtime = ::platform::ui::device;
namespace firmware_update_runtime = ::platform::ui::firmware_update;
namespace gps_runtime = ::platform::ui::gps;
@@ -794,6 +802,9 @@ static void perform_factory_reset()
static void settings_load()
{
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "settings_load begin");
#endif
app::IAppFacade& app_ctx = app::appFacade();
g_settings.chat_protocol = static_cast<int>(app_ctx.getConfig().mesh_protocol);
@@ -1036,6 +1047,14 @@ static void settings_load()
snprintf(g_settings.gauge_full_mah, sizeof(g_settings.gauge_full_mah), "%lu",
static_cast<unsigned long>(f));
}
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag,
"settings_load complete locales=%u tx_power_options=%u wifi_supported=%d wifi_networks=%u",
static_cast<unsigned>(kLocaleOptionCount),
static_cast<unsigned>(kTxPowerOptionCount),
wifi_runtime::is_supported() ? 1 : 0,
static_cast<unsigned>(kWifiNetworkOptionCount));
#endif
}
static void format_value(const settings::ui::SettingItem& item, char* out, size_t out_len)
@@ -2863,12 +2882,23 @@ static void build_item_list()
return;
}
s_building_list = true;
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag,
"build_item_list begin category=%d",
g_state.current_category);
#endif
g_state.list_back_btn = nullptr;
lv_obj_clean(g_state.list_panel);
g_state.item_count = 0;
lv_obj_clear_flag(g_state.list_panel, LV_OBJ_FLAG_SCROLLABLE);
const CategoryDef& cat = kCategories[g_state.current_category];
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag,
"build_item_list category_label=%s item_count=%u",
cat.label ? cat.label : "<null>",
static_cast<unsigned>(cat.item_count));
#endif
for (size_t i = 0; i < cat.item_count && g_state.item_count < kMaxItems; ++i)
{
settings::ui::ItemWidget& widget = g_state.item_widgets[g_state.item_count];
@@ -2898,9 +2928,22 @@ static void build_item_list()
}
if (!should_show_item(*widget.def))
{
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag,
"build_item_list skip index=%u key=%s",
static_cast<unsigned>(i),
widget.def->pref_key ? widget.def->pref_key : "<none>");
#endif
continue;
}
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag,
"build_item_list item index=%u key=%s type=%d",
static_cast<unsigned>(i),
widget.def->pref_key ? widget.def->pref_key : "<none>",
static_cast<int>(widget.def->type));
#endif
lv_obj_t* btn = lv_btn_create(g_state.list_panel);
configure_list_item_button(btn);
style::apply_list_item(btn);
@@ -2937,6 +2980,12 @@ static void build_item_list()
lv_obj_add_flag(g_state.list_panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(g_state.list_panel, LV_SCROLLBAR_MODE_AUTO);
s_building_list = false;
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag,
"build_item_list complete visible_items=%u has_back=%d",
static_cast<unsigned>(g_state.item_count),
g_state.list_back_btn ? 1 : 0);
#endif
}
static bool activate_item_widget(settings::ui::ItemWidget& widget)
@@ -3242,6 +3291,9 @@ static void settings_back_cb(void* /*user_data*/)
void create(lv_obj_t* parent)
{
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create begin");
#endif
settings_load();
// Avoid auto-adding widgets to the current default group during creation.
@@ -3249,16 +3301,42 @@ void create(lv_obj_t* parent)
set_default_group(nullptr);
g_state.parent = parent;
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create root");
#endif
g_state.root = layout::create_root(parent);
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create header");
#endif
layout::create_header(g_state.root, settings_back_cb, nullptr);
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create content");
#endif
g_state.content = layout::create_content(g_state.root);
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create filter panel");
#endif
layout::create_filter_panel(g_state.content);
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create list panel");
#endif
layout::create_list_panel(g_state.content);
g_state.filter_count = sizeof(kCategories) / sizeof(kCategories[0]);
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag,
"create filter buttons count=%u",
static_cast<unsigned>(g_state.filter_count));
#endif
for (size_t i = 0; i < g_state.filter_count; ++i)
{
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag,
"create filter button index=%u label=%s",
static_cast<unsigned>(i),
kCategories[i].label ? kCategories[i].label : "<null>");
#endif
lv_obj_t* btn = lv_btn_create(g_state.filter_panel);
lv_obj_set_size(btn, LV_PCT(100), ::ui::page_profile::current().filter_button_height);
style::apply_btn_filter(btn);
@@ -3274,8 +3352,17 @@ void create(lv_obj_t* parent)
g_state.filter_buttons[i] = btn;
}
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create update_filter_styles");
#endif
update_filter_styles();
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create build_item_list");
#endif
build_item_list();
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create sync_firmware_update_ui");
#endif
sync_firmware_update_ui(false);
if (s_firmware_update_timer)
{
@@ -3295,7 +3382,13 @@ void create(lv_obj_t* parent)
// Restore previous default group before initializing input.
set_default_group(prev_group);
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create input init");
#endif
settings::ui::input::init();
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "create complete");
#endif
}
void destroy()
@@ -7,11 +7,19 @@
#include "ui/components/info_card.h"
#include "ui/components/two_pane_styles.h"
#if defined(ESP_PLATFORM)
#include "esp_log.h"
#endif
namespace settings::ui::style
{
namespace
{
#if defined(ESP_PLATFORM)
constexpr const char* kLogTag = "settings-style";
#endif
bool s_inited = false;
lv_style_t s_modal_bg;
lv_style_t s_modal_panel;
@@ -26,6 +34,10 @@ void init_once()
if (s_inited) return;
s_inited = true;
#if defined(ESP_PLATFORM)
ESP_LOGI(kLogTag, "init_once");
#endif
lv_style_init(&s_modal_bg);
lv_style_set_bg_opa(&s_modal_bg, LV_OPA_COVER);
lv_style_set_bg_color(&s_modal_bg,
@@ -0,0 +1,911 @@
/**
* @file map_viewport.cpp
* @brief Shared map viewport facade backed by the platform tile runtime.
*/
#include "ui/widgets/map/map_viewport.h"
#include "app/app_config.h"
#include "app/app_facade_access.h"
#include "platform/ui/device_runtime.h"
#include "ui/localization.h"
#include "ui/widgets/map/map_tiles.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace ui::widgets::map
{
struct RuntimeImpl
{
Widgets widgets{};
Model model{};
MapAnchor anchor{};
std::vector<MapTile> tiles{};
TileContext tile_ctx{};
lv_timer_t* loader_timer = nullptr;
uint32_t loader_interval_ms = 200;
bool alive = false;
bool has_map_data = false;
bool has_visible_map_data = false;
GestureCallback gesture_callback = nullptr;
void* gesture_user_data = nullptr;
bool gesture_enabled = false;
bool gesture_pressed = false;
bool gesture_dragging = false;
lv_point_t gesture_start{};
lv_point_t gesture_last{};
};
namespace
{
#define MAP_VIEWPORT_LOG(...) std::printf("[MapViewport] " __VA_ARGS__)
constexpr double kCoordPi = 3.14159265358979323846;
constexpr double kCoordA = 6378245.0;
constexpr double kCoordEe = 0.00669342162296594323;
constexpr int kGestureDragStartPx = 6;
bool coord_out_of_china(double lat, double lon)
{
return (lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271);
}
double coord_transform_lat(double x, double y)
{
double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y +
0.2 * std::sqrt(std::fabs(x));
ret += (20.0 * std::sin(6.0 * x * kCoordPi) + 20.0 * std::sin(2.0 * x * kCoordPi)) * 2.0 / 3.0;
ret += (20.0 * std::sin(y * kCoordPi) + 40.0 * std::sin(y / 3.0 * kCoordPi)) * 2.0 / 3.0;
ret += (160.0 * std::sin(y / 12.0 * kCoordPi) + 320 * std::sin(y * kCoordPi / 30.0)) * 2.0 / 3.0;
return ret;
}
double coord_transform_lon(double x, double y)
{
double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y +
0.1 * std::sqrt(std::fabs(x));
ret += (20.0 * std::sin(6.0 * x * kCoordPi) + 20.0 * std::sin(2.0 * x * kCoordPi)) * 2.0 / 3.0;
ret += (20.0 * std::sin(x * kCoordPi) + 40.0 * std::sin(x / 3.0 * kCoordPi)) * 2.0 / 3.0;
ret += (150.0 * std::sin(x / 12.0 * kCoordPi) + 300.0 * std::sin(x / 30.0 * kCoordPi)) * 2.0 / 3.0;
return ret;
}
void wgs84_to_gcj02(double lat, double lon, double& out_lat, double& out_lon)
{
if (coord_out_of_china(lat, lon))
{
out_lat = lat;
out_lon = lon;
return;
}
double dlat = coord_transform_lat(lon - 105.0, lat - 35.0);
double dlon = coord_transform_lon(lon - 105.0, lat - 35.0);
double radlat = lat / 180.0 * kCoordPi;
double magic = std::sin(radlat);
magic = 1 - kCoordEe * magic * magic;
double sqrt_magic = std::sqrt(magic);
dlat = (dlat * 180.0) / ((kCoordA * (1 - kCoordEe)) / (magic * sqrt_magic) * kCoordPi);
dlon = (dlon * 180.0) / (kCoordA / sqrt_magic * std::cos(radlat) * kCoordPi);
out_lat = lat + dlat;
out_lon = lon + dlon;
}
void gcj02_to_bd09(double lat, double lon, double& out_lat, double& out_lon)
{
double z = std::sqrt(lon * lon + lat * lat) + 0.00002 * std::sin(lat * kCoordPi);
double theta = std::atan2(lat, lon) + 0.000003 * std::cos(lon * kCoordPi);
out_lon = z * std::cos(theta) + 0.0065;
out_lat = z * std::sin(theta) + 0.006;
}
void make_plain(lv_obj_t* obj)
{
if (!obj)
{
return;
}
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_style_pad_all(obj, 0, 0);
lv_obj_set_style_border_width(obj, 0, 0);
lv_obj_set_style_radius(obj, 0, 0);
lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0);
}
bool is_runtime_alive(const RuntimeImpl& impl)
{
return impl.alive &&
impl.widgets.root &&
lv_obj_is_valid(impl.widgets.root) &&
impl.widgets.tile_layer &&
lv_obj_is_valid(impl.widgets.tile_layer);
}
void reset_gesture_state(RuntimeImpl& impl)
{
impl.gesture_pressed = false;
impl.gesture_dragging = false;
impl.gesture_start = lv_point_t{};
impl.gesture_last = lv_point_t{};
}
void emit_gesture_event(RuntimeImpl& impl, GesturePhase phase, const lv_point_t& point)
{
if (!impl.gesture_callback)
{
return;
}
GestureEvent event{};
event.phase = phase;
event.point = point;
event.total_dx = static_cast<int>(point.x - impl.gesture_start.x);
event.total_dy = static_cast<int>(point.y - impl.gesture_start.y);
event.dragging = impl.gesture_dragging;
impl.gesture_callback(event, impl.gesture_user_data);
}
void update_gesture_surface_visibility(RuntimeImpl& impl)
{
if (!impl.widgets.gesture_surface || !lv_obj_is_valid(impl.widgets.gesture_surface))
{
return;
}
const bool visible = impl.gesture_enabled && impl.gesture_callback != nullptr;
if (visible)
{
lv_obj_clear_flag(impl.widgets.gesture_surface, LV_OBJ_FLAG_HIDDEN);
}
else
{
lv_obj_add_flag(impl.widgets.gesture_surface, LV_OBJ_FLAG_HIDDEN);
reset_gesture_state(impl);
}
MAP_VIEWPORT_LOG("gesture_surface enabled=%d callback=%d visible=%d obj=%p\n",
impl.gesture_enabled ? 1 : 0,
impl.gesture_callback ? 1 : 0,
visible ? 1 : 0,
impl.widgets.gesture_surface);
}
lv_indev_t* resolve_event_indev(lv_event_t* e)
{
if (e)
{
if (lv_indev_t* indev = lv_event_get_indev(e))
{
return indev;
}
}
return lv_indev_active();
}
bool resolve_event_point(lv_event_t* e, lv_point_t& out_point)
{
lv_indev_t* indev = resolve_event_indev(e);
if (!indev)
{
out_point = {};
return false;
}
lv_indev_get_point(indev, &out_point);
return true;
}
void gesture_surface_event_cb(lv_event_t* e)
{
auto* impl = static_cast<RuntimeImpl*>(lv_event_get_user_data(e));
if (!impl || !impl->gesture_enabled || !impl->gesture_callback || !is_runtime_alive(*impl))
{
return;
}
lv_point_t point{};
const bool has_point = resolve_event_point(e, point);
const lv_event_code_t code = lv_event_get_code(e);
switch (code)
{
case LV_EVENT_PRESSED:
if (!has_point)
{
return;
}
impl->gesture_pressed = true;
impl->gesture_dragging = false;
impl->gesture_start = point;
impl->gesture_last = point;
emit_gesture_event(*impl, GesturePhase::Pressed, point);
break;
case LV_EVENT_PRESSING:
if (!impl->gesture_pressed || !has_point)
{
return;
}
impl->gesture_last = point;
if (!impl->gesture_dragging)
{
const int dx = static_cast<int>(point.x - impl->gesture_start.x);
const int dy = static_cast<int>(point.y - impl->gesture_start.y);
if (std::abs(dx) < kGestureDragStartPx && std::abs(dy) < kGestureDragStartPx)
{
return;
}
impl->gesture_dragging = true;
MAP_VIEWPORT_LOG("drag_begin root=%p dx=%d dy=%d\n",
impl->widgets.root,
dx,
dy);
emit_gesture_event(*impl, GesturePhase::DragBegin, point);
}
if (impl->gesture_dragging)
{
if (lv_indev_t* indev = resolve_event_indev(e))
{
lv_indev_stop_processing(indev);
}
MAP_VIEWPORT_LOG("drag_update root=%p dx=%d dy=%d\n",
impl->widgets.root,
static_cast<int>(point.x - impl->gesture_start.x),
static_cast<int>(point.y - impl->gesture_start.y));
emit_gesture_event(*impl, GesturePhase::DragUpdate, point);
}
break;
case LV_EVENT_RELEASED:
case LV_EVENT_PRESS_LOST:
if (!impl->gesture_pressed)
{
return;
}
if (!has_point)
{
point = impl->gesture_last;
}
if (impl->gesture_dragging)
{
if (lv_indev_t* indev = resolve_event_indev(e))
{
lv_indev_stop_processing(indev);
}
MAP_VIEWPORT_LOG("drag_end root=%p dx=%d dy=%d code=%d\n",
impl->widgets.root,
static_cast<int>(point.x - impl->gesture_start.x),
static_cast<int>(point.y - impl->gesture_start.y),
static_cast<int>(code));
emit_gesture_event(*impl,
code == LV_EVENT_PRESS_LOST ? GesturePhase::Cancel : GesturePhase::DragEnd,
point);
}
reset_gesture_state(*impl);
break;
default:
break;
}
}
void prime_visible_tiles(RuntimeImpl& impl, const char* reason)
{
if (!is_runtime_alive(impl) || !impl.model.focus_point.valid)
{
MAP_VIEWPORT_LOG("prime_visible_tiles skipped reason=%s alive=%d focus=%d\n",
reason ? reason : "<none>",
impl.alive ? 1 : 0,
impl.model.focus_point.valid ? 1 : 0);
return;
}
MAP_VIEWPORT_LOG("prime_visible_tiles begin reason=%s map_data=%d visible_map=%d\n",
reason ? reason : "<none>",
impl.has_map_data ? 1 : 0,
impl.has_visible_map_data ? 1 : 0);
for (int attempt = 0; attempt < 2 && !impl.has_visible_map_data; ++attempt)
{
tile_loader_step(impl.tile_ctx);
MAP_VIEWPORT_LOG("prime_visible_tiles step=%d visible_map=%d map_data=%d\n",
attempt + 1,
impl.has_visible_map_data ? 1 : 0,
impl.has_map_data ? 1 : 0);
}
MAP_VIEWPORT_LOG("prime_visible_tiles end reason=%s map_data=%d visible_map=%d\n",
reason ? reason : "<none>",
impl.has_map_data ? 1 : 0,
impl.has_visible_map_data ? 1 : 0);
}
GeoPoint transformed_focus(const Model& model)
{
GeoPoint out{};
transform_geo_point(model.focus_point, model.coord_system, out);
return out;
}
void refresh_tiles(RuntimeImpl& impl, const char* reason)
{
if (!is_runtime_alive(impl))
{
MAP_VIEWPORT_LOG("refresh_tiles skipped reason=%s alive=%d root=%p tile=%p\n",
reason ? reason : "<none>",
impl.alive ? 1 : 0,
impl.widgets.root,
impl.widgets.tile_layer);
return;
}
if (!impl.model.focus_point.valid)
{
MAP_VIEWPORT_LOG("refresh_tiles skipped reason=%s focus=invalid\n",
reason ? reason : "<none>");
cleanup_tiles(impl.tile_ctx);
impl.anchor.valid = false;
return;
}
GeoPoint focus = transformed_focus(impl.model);
if (!focus.valid)
{
MAP_VIEWPORT_LOG("refresh_tiles skipped reason=%s transformed_focus=invalid\n",
reason ? reason : "<none>");
cleanup_tiles(impl.tile_ctx);
impl.anchor.valid = false;
return;
}
lv_obj_update_layout(impl.widgets.root);
set_map_render_options(impl.model.map_source, impl.model.contour_enabled);
calculate_required_tiles(impl.tile_ctx,
focus.lat,
focus.lon,
impl.model.zoom,
impl.model.pan_x,
impl.model.pan_y,
true);
prime_visible_tiles(impl, reason);
MAP_VIEWPORT_LOG("refresh_tiles reason=%s zoom=%d pan=%d,%d src=%u contour=%d anchor=%d size=%dx%d map_data=%d visible_map=%d\n",
reason ? reason : "<none>",
impl.model.zoom,
impl.model.pan_x,
impl.model.pan_y,
sanitize_map_source(impl.model.map_source),
impl.model.contour_enabled ? 1 : 0,
impl.anchor.valid ? 1 : 0,
static_cast<int>(lv_obj_get_width(impl.widgets.tile_layer)),
static_cast<int>(lv_obj_get_height(impl.widgets.tile_layer)),
impl.has_map_data ? 1 : 0,
impl.has_visible_map_data ? 1 : 0);
}
void loader_timer_cb(lv_timer_t* timer)
{
auto* impl = static_cast<RuntimeImpl*>(lv_timer_get_user_data(timer));
if (!impl || !is_runtime_alive(*impl) || !impl->model.focus_point.valid)
{
return;
}
tile_loader_step(impl->tile_ctx);
uint8_t missing_source = 0;
if (::take_missing_tile_notice(&missing_source))
{
MAP_VIEWPORT_LOG("missing_tiles source=%u label=%s\n",
static_cast<unsigned>(missing_source),
map_source_label(missing_source));
}
}
const Widgets& empty_widgets()
{
static Widgets kEmpty{};
return kEmpty;
}
void clear_layer_notice(LayerNotice* notice)
{
if (!notice)
{
return;
}
notice->has_message = false;
notice->message[0] = '\0';
notice->duration_ms = 0;
}
void set_layer_notice(LayerNotice* notice, const char* message, uint32_t duration_ms)
{
clear_layer_notice(notice);
if (!notice || !message || message[0] == '\0')
{
return;
}
notice->has_message = true;
std::snprintf(notice->message, sizeof(notice->message), "%s", message);
notice->duration_ms = duration_ms;
}
} // namespace
Runtime::~Runtime()
{
destroy(*this);
}
Runtime::Runtime(Runtime&& other) noexcept
: impl_(other.impl_)
{
other.impl_ = nullptr;
}
Runtime& Runtime::operator=(Runtime&& other) noexcept
{
if (this != &other)
{
destroy(*this);
impl_ = other.impl_;
other.impl_ = nullptr;
}
return *this;
}
Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t loader_interval_ms)
{
destroy(runtime);
if (!runtime.impl_)
{
runtime.impl_ = new RuntimeImpl();
runtime.impl_->tiles.reserve(TILE_RECORD_LIMIT);
}
RuntimeImpl* impl = runtime.impl_;
impl->loader_interval_ms = loader_interval_ms;
impl->widgets.root = lv_obj_create(parent);
lv_obj_set_size(impl->widgets.root, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(impl->widgets.root, 0, 0);
make_plain(impl->widgets.root);
#ifdef LV_OBJ_FLAG_CLIP_CHILDREN
lv_obj_add_flag(impl->widgets.root, LV_OBJ_FLAG_CLIP_CHILDREN);
#endif
impl->widgets.tile_layer = lv_obj_create(impl->widgets.root);
lv_obj_set_size(impl->widgets.tile_layer, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(impl->widgets.tile_layer, 0, 0);
make_plain(impl->widgets.tile_layer);
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);
make_plain(impl->widgets.overlay_layer);
impl->widgets.gesture_surface = lv_obj_create(impl->widgets.root);
lv_obj_set_size(impl->widgets.gesture_surface, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(impl->widgets.gesture_surface, 0, 0);
make_plain(impl->widgets.gesture_surface);
lv_obj_add_flag(impl->widgets.gesture_surface, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(impl->widgets.gesture_surface, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_event_cb(impl->widgets.gesture_surface, gesture_surface_event_cb, LV_EVENT_ALL, impl);
init_tile_context(impl->tile_ctx,
impl->widgets.tile_layer,
&impl->anchor,
&impl->tiles,
&impl->has_map_data,
&impl->has_visible_map_data);
impl->loader_timer = lv_timer_create(loader_timer_cb, loader_interval_ms, impl);
impl->alive = true;
MAP_VIEWPORT_LOG("create root=%p tile=%p overlay=%p loader_timer=%p interval=%u\n",
impl->widgets.root,
impl->widgets.tile_layer,
impl->widgets.overlay_layer,
impl->loader_timer,
static_cast<unsigned>(loader_interval_ms));
return impl->widgets;
}
void destroy(Runtime& runtime)
{
RuntimeImpl* impl = runtime.impl_;
if (!impl)
{
return;
}
MAP_VIEWPORT_LOG("destroy begin root=%p timer=%p alive=%d\n",
impl->widgets.root,
impl->loader_timer,
impl->alive ? 1 : 0);
impl->alive = false;
if (impl->loader_timer)
{
lv_timer_del(impl->loader_timer);
impl->loader_timer = nullptr;
}
cleanup_tiles(impl->tile_ctx);
if (impl->widgets.root && lv_obj_is_valid(impl->widgets.root))
{
lv_obj_del(impl->widgets.root);
}
delete impl;
runtime.impl_ = nullptr;
MAP_VIEWPORT_LOG("destroy end\n");
}
const Widgets& widgets(const Runtime& runtime)
{
const RuntimeImpl* impl = runtime.impl_;
return impl ? impl->widgets : empty_widgets();
}
void set_size(Runtime& runtime, lv_coord_t width, lv_coord_t height)
{
if (!runtime.impl_)
{
runtime.impl_ = new RuntimeImpl();
runtime.impl_->tiles.reserve(TILE_RECORD_LIMIT);
}
RuntimeImpl* impl = runtime.impl_;
if (!impl->widgets.root)
{
return;
}
lv_obj_set_size(impl->widgets.root, width, height);
lv_obj_set_size(impl->widgets.tile_layer, width, height);
lv_obj_set_size(impl->widgets.overlay_layer, width, height);
lv_obj_set_size(impl->widgets.gesture_surface, width, height);
MAP_VIEWPORT_LOG("set_size root=%p size=%dx%d\n",
impl->widgets.root,
static_cast<int>(width),
static_cast<int>(height));
}
void apply_model(Runtime& runtime, const Model& model)
{
if (!runtime.impl_)
{
runtime.impl_ = new RuntimeImpl();
runtime.impl_->tiles.reserve(TILE_RECORD_LIMIT);
}
RuntimeImpl* impl = runtime.impl_;
impl->model = model;
impl->model.map_source = sanitize_map_source(impl->model.map_source);
MAP_VIEWPORT_LOG("apply_model focus_valid=%d lat=%.7f lon=%.7f zoom=%d pan=%d,%d src=%u contour=%d coord=%u\n",
impl->model.focus_point.valid ? 1 : 0,
impl->model.focus_point.lat,
impl->model.focus_point.lon,
impl->model.zoom,
impl->model.pan_x,
impl->model.pan_y,
static_cast<unsigned>(impl->model.map_source),
impl->model.contour_enabled ? 1 : 0,
static_cast<unsigned>(impl->model.coord_system));
refresh_tiles(*impl, "apply_model");
}
void clear(Runtime& runtime)
{
RuntimeImpl* impl = runtime.impl_;
if (!impl)
{
return;
}
impl->model.focus_point = GeoPoint{};
cleanup_tiles(impl->tile_ctx);
impl->anchor.valid = false;
reset_gesture_state(*impl);
MAP_VIEWPORT_LOG("clear root=%p\n", impl->widgets.root);
}
bool project_point(const Runtime& runtime, const GeoPoint& point, lv_point_t& out_screen_point)
{
const RuntimeImpl* impl = runtime.impl_;
if (!impl || !is_runtime_alive(*impl) || !impl->anchor.valid || !point.valid)
{
return false;
}
GeoPoint transformed{};
if (!transform_geo_point(point, impl->model.coord_system, transformed) || !transformed.valid)
{
return false;
}
int screen_x = 0;
int screen_y = 0;
if (!gps_screen_pos(impl->tile_ctx, transformed.lat, transformed.lon, screen_x, screen_y))
{
return false;
}
out_screen_point.x = static_cast<lv_coord_t>(screen_x);
out_screen_point.y = static_cast<lv_coord_t>(screen_y);
return true;
}
bool preview_project_point(lv_obj_t* viewport_root, const Model& model, const GeoPoint& point, lv_point_t& out_screen_point)
{
if (!viewport_root || !lv_obj_is_valid(viewport_root) || !model.focus_point.valid || !point.valid)
{
return false;
}
GeoPoint transformed_focus{};
GeoPoint transformed_point{};
if (!transform_geo_point(model.focus_point, model.coord_system, transformed_focus) || !transformed_focus.valid)
{
return false;
}
if (!transform_geo_point(point, model.coord_system, transformed_point) || !transformed_point.valid)
{
return false;
}
MapAnchor anchor{};
TileContext ctx{};
ctx.map_container = viewport_root;
ctx.anchor = &anchor;
update_map_anchor(ctx,
transformed_focus.lat,
transformed_focus.lon,
model.zoom,
model.pan_x,
model.pan_y,
true);
if (!anchor.valid)
{
return false;
}
int screen_x = 0;
int screen_y = 0;
if (!gps_screen_pos(ctx, transformed_point.lat, transformed_point.lon, screen_x, screen_y))
{
return false;
}
out_screen_point.x = static_cast<lv_coord_t>(screen_x);
out_screen_point.y = static_cast<lv_coord_t>(screen_y);
return true;
}
bool focus_tile_available(const Model& model)
{
if (!model.focus_point.valid)
{
return false;
}
GeoPoint transformed{};
if (!transform_geo_point(model.focus_point, model.coord_system, transformed) || !transformed.valid)
{
return false;
}
int tile_x = 0;
int tile_y = 0;
latLngToTile(transformed.lat, transformed.lon, model.zoom, tile_x, tile_y);
normalize_tile(model.zoom, tile_x, tile_y);
char path[96];
if (!build_base_tile_path(model.zoom,
tile_x,
tile_y,
sanitize_map_source(model.map_source),
path,
sizeof(path)))
{
return false;
}
lv_fs_file_t file;
const lv_fs_res_t res = lv_fs_open(&file, path, LV_FS_MODE_RD);
if (res != LV_FS_RES_OK)
{
return false;
}
lv_fs_close(&file);
return true;
}
Status status(const Runtime& runtime)
{
Status out{};
const RuntimeImpl* impl = runtime.impl_;
if (!impl)
{
return out;
}
out.alive = impl->alive;
out.has_focus = impl->model.focus_point.valid;
out.anchor_valid = impl->anchor.valid;
out.has_map_data = impl->has_map_data;
out.has_visible_map_data = impl->has_visible_map_data;
out.zoom = impl->model.zoom;
out.pan_x = impl->model.pan_x;
out.pan_y = impl->model.pan_y;
return out;
}
bool take_missing_tile_notice(Runtime& runtime, uint8_t* out_map_source)
{
(void)runtime;
return ::take_missing_tile_notice(out_map_source);
}
void set_gesture_enabled(Runtime& runtime, bool enabled)
{
RuntimeImpl* impl = runtime.impl_;
if (!impl)
{
return;
}
impl->gesture_enabled = enabled;
update_gesture_surface_visibility(*impl);
}
void set_gesture_callback(Runtime& runtime, GestureCallback callback, void* user_data)
{
RuntimeImpl* impl = runtime.impl_;
if (!impl)
{
return;
}
impl->gesture_callback = callback;
impl->gesture_user_data = user_data;
update_gesture_surface_visibility(*impl);
}
bool transform_geo_point(const GeoPoint& point, uint8_t coord_system, GeoPoint& out_point)
{
out_point = {};
if (!point.valid)
{
return false;
}
out_point.valid = true;
if (coord_system == 1)
{
wgs84_to_gcj02(point.lat, point.lon, out_point.lat, out_point.lon);
return true;
}
if (coord_system == 2)
{
double gcj_lat = 0.0;
double gcj_lon = 0.0;
wgs84_to_gcj02(point.lat, point.lon, gcj_lat, gcj_lon);
gcj02_to_bd09(gcj_lat, gcj_lon, out_point.lat, out_point.lon);
return true;
}
out_point.lat = point.lat;
out_point.lon = point.lon;
return true;
}
LayerState current_layer_state()
{
const auto& cfg = app::configFacade().getConfig();
LayerState state{};
state.map_source = sanitize_map_source(cfg.map_source);
state.contour_enabled = cfg.map_contour_enabled;
return state;
}
const char* layer_map_source_label_key(uint8_t map_source)
{
return map_source_label(sanitize_map_source(map_source));
}
const char* layer_contour_status_key(bool contour_enabled)
{
return contour_enabled ? "Contour: ON" : "Contour: OFF";
}
std::string layer_base_summary_text(uint8_t map_source)
{
return ::ui::i18n::format("Base: %s",
::ui::i18n::tr(layer_map_source_label_key(map_source)));
}
bool set_layer_map_source(uint8_t map_source, LayerNotice* out_notice)
{
clear_layer_notice(out_notice);
app::IAppConfigFacade& config_api = app::configFacade();
const uint8_t previous = sanitize_map_source(config_api.getConfig().map_source);
const uint8_t normalized = sanitize_map_source(map_source);
const bool changed = previous != normalized;
MAP_VIEWPORT_LOG("set_layer_map_source from=%u to=%u contour=%d changed=%d\n",
static_cast<unsigned>(previous),
static_cast<unsigned>(normalized),
config_api.getConfig().map_contour_enabled ? 1 : 0,
changed ? 1 : 0);
if (changed)
{
config_api.getConfig().map_source = normalized;
config_api.saveConfig();
}
if (!platform::ui::device::sd_ready())
{
set_layer_notice(out_notice, ::ui::i18n::tr("No SD Card"), 1200);
}
else if (!map_source_directory_available(normalized))
{
const std::string message = ::ui::i18n::format(
"%s layer missing",
::ui::i18n::tr(layer_map_source_label_key(normalized)));
set_layer_notice(out_notice, message.c_str(), 1600);
}
return changed;
}
bool toggle_layer_contour(LayerNotice* out_notice)
{
clear_layer_notice(out_notice);
app::IAppConfigFacade& config_api = app::configFacade();
const bool previous = config_api.getConfig().map_contour_enabled;
const bool enabled = !previous;
MAP_VIEWPORT_LOG("toggle_layer_contour from=%d to=%d src=%u\n",
previous ? 1 : 0,
enabled ? 1 : 0,
static_cast<unsigned>(sanitize_map_source(config_api.getConfig().map_source)));
config_api.getConfig().map_contour_enabled = enabled;
config_api.saveConfig();
if (enabled)
{
if (!platform::ui::device::sd_ready())
{
set_layer_notice(out_notice, ::ui::i18n::tr("No SD Card"), 1200);
}
else if (!contour_directory_available())
{
set_layer_notice(out_notice, ::ui::i18n::tr("Contour data missing"), 1600);
}
}
return true;
}
} // namespace ui::widgets::map
@@ -546,3 +546,7 @@ queue full 队列已满
unsupported protocol 协议不支持
Factory Reset 恢复出厂设置
Clear all settings and restart? 清空全部设置并重启?
Terrain 地形图
Satellite 卫星图
Contour: ON 等高线:开
Contour data missing 缺少等高线数据
1 Action 操作
546 unsupported protocol 协议不支持
547 Factory Reset 恢复出厂设置
548 Clear all settings and restart? 清空全部设置并重启?
549 Terrain 地形图
550 Satellite 卫星图
551 Contour: ON 等高线:开
552 Contour data missing 缺少等高线数据
@@ -1,12 +1,14 @@
#pragma once
#include "ui/widgets/map/map_viewport.h"
namespace gps_ui
{
constexpr int kMapPanStep = 32;
constexpr int kDefaultZoom = 12;
constexpr int kMinZoom = 0;
constexpr int kMaxZoom = 18;
constexpr int kDefaultZoom = ::ui::widgets::map::kDefaultZoom;
constexpr int kMinZoom = ::ui::widgets::map::kMinZoom;
constexpr int kMaxZoom = ::ui::widgets::map::kMaxZoom;
constexpr double kDefaultLat = 51.5074;
constexpr double kDefaultLng = -0.1278;
@@ -1,5 +1,5 @@
#include "platform/esp/arduino_common/gps/track_recorder.h"
#include "display/DisplayInterface.h"
#include "platform/esp/common/shared_spi_lock.h"
#include <cmath>
#include <esp_system.h>
@@ -48,25 +48,6 @@ double haversine_m(double lat1, double lon1, double lat2, double lon2)
return R * c;
}
class DisplaySpiGuard
{
public:
explicit DisplaySpiGuard(TickType_t wait_ticks = pdMS_TO_TICKS(20))
: locked_(display_spi_lock(wait_ticks))
{
}
~DisplaySpiGuard()
{
if (locked_)
{
display_spi_unlock();
}
}
bool locked() const { return locked_; }
private:
bool locked_ = false;
};
} // namespace
TrackRecorder& TrackRecorder::getInstance()
@@ -373,8 +354,10 @@ void TrackRecorder::appendPoint(const TrackPoint& pt)
return;
}
// SD and display share SPI on T-Deck/Pager. Use the display SPI lock as bus arbiter.
DisplaySpiGuard spi_guard(pdMS_TO_TICKS(20));
// SD and display share one board-level SPI bus on T-Deck/Pager. Acquire
// shared bus ownership before touching SD so runtime call sites describe
// the actual resource being arbitrated.
::platform::esp::common::SharedSpiLockGuard spi_guard(pdMS_TO_TICKS(20));
if (!spi_guard.locked())
{
if (mutex_)
@@ -723,7 +723,7 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error)
mbedtls_sha256_context sha_ctx;
unsigned char hash[32];
mbedtls_sha256_init(&sha_ctx);
mbedtls_sha256_starts_ret(&sha_ctx, 0);
mbedtls_sha256_starts(&sha_ctx, 0);
if (esp_http_client_open(client, 0) != ESP_OK)
{
@@ -787,7 +787,7 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error)
goto cleanup;
}
mbedtls_sha256_update_ret(&sha_ctx, buffer, static_cast<std::size_t>(read));
mbedtls_sha256_update(&sha_ctx, buffer, static_cast<std::size_t>(read));
bytes_written += static_cast<std::size_t>(read);
std::size_t progress_total = 0;
@@ -831,7 +831,7 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error)
goto cleanup;
}
mbedtls_sha256_finish_ret(&sha_ctx, hash);
mbedtls_sha256_finish(&sha_ctx, hash);
{
char actual_sha256[65];
for (int i = 0; i < 32; ++i)
@@ -4,7 +4,9 @@
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "platform/esp/arduino_common/app_tasks.h"
#include "platform/esp/arduino_common/gps/gps_service_api.h"
#include "platform/esp/common/shared_spi_lock.h"
#include "platform/ui/device_runtime.h"
#include "screen_sleep.h"
#include "team/usecase/team_pairing_service.h"
@@ -28,6 +30,9 @@ namespace
Status s_status{};
char s_message[96] = "";
bool s_prepared = false;
bool s_radio_tasks_paused_by_usb = false;
#define USB_MSC_LOG(...) std::printf("[USBMSC] " __VA_ARGS__)
void set_status_message(const char* message)
{
@@ -51,6 +56,15 @@ bool s_backend_started = false;
int32_t usbReadCallback(uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize)
{
(void)offset;
::platform::esp::common::SharedSpiLockGuard spi_guard{};
if (!spi_guard.locked())
{
USB_MSC_LOG("read lock failed lba=%lu size=%lu\n",
static_cast<unsigned long>(lba),
static_cast<unsigned long>(bufsize));
return -1;
}
const uint32_t sec_size = SD.sectorSize();
if (sec_size == 0)
{
@@ -71,6 +85,15 @@ int32_t usbReadCallback(uint32_t lba, uint32_t offset, void* buffer, uint32_t bu
int32_t usbWriteCallback(uint32_t lba, uint32_t offset, uint8_t* buffer, uint32_t bufsize)
{
(void)offset;
::platform::esp::common::SharedSpiLockGuard spi_guard{};
if (!spi_guard.locked())
{
USB_MSC_LOG("write lock failed lba=%lu size=%lu\n",
static_cast<unsigned long>(lba),
static_cast<unsigned long>(bufsize));
return -1;
}
uint64_t free_space = SD.totalBytes() - SD.usedBytes();
if (bufsize > free_space)
{
@@ -212,6 +235,13 @@ void prepare_mass_storage_mode()
esp_wifi_stop();
disableScreenSleep();
if (!app::AppTasks::areRadioTasksPaused())
{
app::AppTasks::pauseRadioTasks();
s_radio_tasks_paused_by_usb = true;
USB_MSC_LOG("radio tasks paused for USB mass storage\n");
}
TaskHandle_t gps_task_handle = gps::gps_get_task_handle();
if (gps_task_handle != nullptr)
{
@@ -228,6 +258,13 @@ void restore_mass_storage_mode()
{
vTaskResume(gps_task_handle);
}
if (s_radio_tasks_paused_by_usb)
{
app::AppTasks::resumeRadioTasks();
s_radio_tasks_paused_by_usb = false;
USB_MSC_LOG("radio tasks resumed after USB mass storage\n");
}
}
bool start()
@@ -13,15 +13,16 @@
#include <sys/stat.h>
#include <sys/types.h>
#include "display/DisplayInterface.h"
#include "esp_heap_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "platform/esp/common/shared_spi_lock.h"
#include "platform/esp/idf_common/bsp_runtime.h"
#include "platform/esp/idf_common/tab5_codec_compat.h"
#include "sys/clock.h"
#else
#include "boards/tlora_pager/tlora_pager_board.h"
#include "platform/esp/common/shared_spi_lock.h"
#include <SD.h>
#endif
@@ -312,25 +313,6 @@ int64_t s_no_pixel_samples = 0;
int64_t s_log_samples = 0;
bool s_last_in_progress = false;
struct SdSpiGuard
{
bool locked = false;
explicit SdSpiGuard(bool enable = true, TickType_t wait = portMAX_DELAY)
{
if (enable)
{
locked = display_spi_lock(wait);
}
}
~SdSpiGuard()
{
if (locked)
{
display_spi_unlock();
}
}
};
void set_error(const char* msg)
{
if (!msg)
@@ -479,7 +461,7 @@ bool save_frame_to_sd()
set_error("No frame");
return false;
}
SdSpiGuard guard;
::platform::esp::common::SharedSpiLockGuard guard;
if (SD.cardType() == CARD_NONE)
{
set_error("SD not ready");
@@ -15,6 +15,7 @@
#include "ui/screens/gps/gps_state.h"
#include "ui/ui_common.h"
#include "ui/widgets/map/map_tiles.h"
#include "ui/widgets/map/map_viewport.h"
#include <cstdint>
#include <cstdio>
@@ -715,14 +716,13 @@ void refresh_layer_popup_labels()
if (s_layer_source_label != nullptr)
{
const std::string text =
::ui::i18n::format("Base: %s", map_source_label(map_source));
lv_label_set_text(s_layer_source_label, text.c_str());
const std::string text = ::ui::widgets::map::layer_base_summary_text(map_source);
::ui::i18n::set_label_text_raw(s_layer_source_label, text.c_str());
}
if (s_layer_contour_label != nullptr)
{
::ui::i18n::set_label_text(s_layer_contour_label,
contour ? "Contour: ON" : "Contour: OFF");
::ui::widgets::map::layer_contour_status_key(contour));
}
for (uint8_t i = 0; i < 3; ++i)
{
@@ -734,65 +734,50 @@ void refresh_layer_popup_labels()
lv_obj_t* label = lv_obj_get_child(s_layer_contour_btn, 0);
if (label != nullptr)
{
::ui::i18n::set_label_text(label, contour ? "Contour: ON" : "Contour: OFF");
::ui::i18n::set_label_text(label,
::ui::widgets::map::layer_contour_status_key(contour));
}
}
}
void layer_set_map_source(uint8_t map_source)
{
app::IAppConfigFacade& config_api = app::configFacade();
uint8_t previous = sanitize_map_source(config_api.getConfig().map_source);
uint8_t normalized = sanitize_map_source(map_source);
if (config_api.getConfig().map_source != normalized)
const uint8_t previous = sanitize_map_source(app::configFacade().getConfig().map_source);
const uint8_t normalized = sanitize_map_source(map_source);
::ui::widgets::map::LayerNotice notice{};
if (::ui::widgets::map::set_layer_map_source(normalized, &notice))
{
GPS_FLOW_LOG("[GPS][MAP][flow] layer_source change from=%u to=%u contour=%d\n",
previous,
normalized,
config_api.getConfig().map_contour_enabled);
config_api.getConfig().map_source = normalized;
config_api.saveConfig();
app::configFacade().getConfig().map_contour_enabled ? 1 : 0);
update_map_tiles(false);
log_map_tile_state("layer_source");
}
if (!platform::ui::device::sd_ready())
if (notice.has_message)
{
show_toast("No SD Card", 1200);
}
else if (!map_source_directory_available(normalized))
{
const std::string message =
::ui::i18n::format("%s layer missing", map_source_label(normalized));
show_toast(message.c_str(), 1600);
show_toast(notice.message, notice.duration_ms);
}
refresh_layer_popup_labels();
}
void layer_toggle_contour()
{
app::IAppConfigFacade& config_api = app::configFacade();
bool previous = config_api.getConfig().map_contour_enabled;
bool enabled = !config_api.getConfig().map_contour_enabled;
const bool previous = app::configFacade().getConfig().map_contour_enabled;
::ui::widgets::map::LayerNotice notice{};
::ui::widgets::map::toggle_layer_contour(&notice);
const bool enabled = app::configFacade().getConfig().map_contour_enabled;
GPS_FLOW_LOG("[GPS][MAP][flow] contour toggle from=%d to=%d src=%u\n",
previous,
enabled,
sanitize_map_source(config_api.getConfig().map_source));
config_api.getConfig().map_contour_enabled = enabled;
config_api.saveConfig();
sanitize_map_source(app::configFacade().getConfig().map_source));
update_map_tiles(false);
log_map_tile_state("contour_toggle");
if (enabled)
if (notice.has_message)
{
if (!platform::ui::device::sd_ready())
{
show_toast("No SD Card", 1200);
}
else if (!contour_directory_available())
{
show_toast("Contour data missing", 1600);
}
show_toast(notice.message, notice.duration_ms);
}
refresh_layer_popup_labels();
@@ -955,10 +940,22 @@ void show_layer_popup()
lv_obj_set_scroll_dir(action_list, LV_DIR_VER);
lv_obj_set_scrollbar_mode(action_list, LV_SCROLLBAR_MODE_AUTO);
osm_btn = create_action_btn(action_list, "OSM", on_layer_source_clicked, static_cast<uintptr_t>(0));
terrain_btn = create_action_btn(action_list, "Terrain", on_layer_source_clicked, static_cast<uintptr_t>(1));
satellite_btn = create_action_btn(action_list, "Satellite", on_layer_source_clicked, static_cast<uintptr_t>(2));
contour_btn = create_action_btn(action_list, "Contour: OFF", on_layer_contour_clicked, static_cast<uintptr_t>(0));
osm_btn = create_action_btn(action_list,
::ui::widgets::map::layer_map_source_label_key(0),
on_layer_source_clicked,
static_cast<uintptr_t>(0));
terrain_btn = create_action_btn(action_list,
::ui::widgets::map::layer_map_source_label_key(1),
on_layer_source_clicked,
static_cast<uintptr_t>(1));
satellite_btn = create_action_btn(action_list,
::ui::widgets::map::layer_map_source_label_key(2),
on_layer_source_clicked,
static_cast<uintptr_t>(2));
contour_btn = create_action_btn(action_list,
::ui::widgets::map::layer_contour_status_key(false),
on_layer_contour_clicked,
static_cast<uintptr_t>(0));
close_btn = create_action_btn(action_list, "Close", on_layer_close_clicked, static_cast<uintptr_t>(0));
}
else
@@ -998,11 +995,23 @@ void show_layer_popup()
lv_obj_set_style_border_width(list, 0, LV_PART_MAIN);
lv_obj_clear_flag(list, LV_OBJ_FLAG_SCROLLABLE);
osm_btn = create_action_btn(list, "OSM", on_layer_source_clicked, static_cast<uintptr_t>(0));
terrain_btn = create_action_btn(list, "Terrain", on_layer_source_clicked, static_cast<uintptr_t>(1));
satellite_btn = create_action_btn(list, "Satellite", on_layer_source_clicked, static_cast<uintptr_t>(2));
contour_btn = create_action_btn(list, "Contour: OFF", on_layer_contour_clicked, static_cast<uintptr_t>(0));
close_btn = create_action_btn(list, "Cancel", on_layer_close_clicked, static_cast<uintptr_t>(0));
osm_btn = create_action_btn(list,
::ui::widgets::map::layer_map_source_label_key(0),
on_layer_source_clicked,
static_cast<uintptr_t>(0));
terrain_btn = create_action_btn(list,
::ui::widgets::map::layer_map_source_label_key(1),
on_layer_source_clicked,
static_cast<uintptr_t>(1));
satellite_btn = create_action_btn(list,
::ui::widgets::map::layer_map_source_label_key(2),
on_layer_source_clicked,
static_cast<uintptr_t>(2));
contour_btn = create_action_btn(list,
::ui::widgets::map::layer_contour_status_key(false),
on_layer_contour_clicked,
static_cast<uintptr_t>(0));
close_btn = create_action_btn(list, "Close", on_layer_close_clicked, static_cast<uintptr_t>(0));
}
s_layer_source_btns[0] = osm_btn;
@@ -42,6 +42,11 @@ static bool show_pan_axis_controls()
return !::ui::page_profile::current().large_touch_hitbox;
}
static int fmt_coord_i32(int32_t value)
{
return static_cast<int>(value);
}
static bool is_pan_h_editing()
{
return g_gps_state.edit_mode == GpsEditMode::PanH;
@@ -226,8 +231,8 @@ static void handle_map_touch_press(const lv_point_t& point)
{
reset_map_touch_pan_state();
GPS_FLOW_LOG("[GPS][MAP][touch] press_ignored x=%d y=%d hit=%d in_map=%d blocked=%d\n",
point.x,
point.y,
fmt_coord_i32(point.x),
fmt_coord_i32(point.y),
static_cast<int>(hit_id),
in_map ? 1 : 0,
blocked ? 1 : 0);
@@ -242,8 +247,8 @@ static void handle_map_touch_press(const lv_point_t& point)
g_gps_state.touch_pan.start_pan_x = g_gps_state.pan_x;
g_gps_state.touch_pan.start_pan_y = g_gps_state.pan_y;
GPS_FLOW_LOG("[GPS][MAP][touch] press x=%d y=%d pan=%d,%d target=%d\n",
point.x,
point.y,
fmt_coord_i32(point.x),
fmt_coord_i32(point.y),
g_gps_state.pan_x,
g_gps_state.pan_y,
static_cast<int>(hit_id));
@@ -321,8 +326,8 @@ static void handle_map_touch_release(lv_indev_t* indev, const lv_point_t& point)
lv_indev_stop_processing(indev);
}
GPS_FLOW_LOG("[GPS][MAP][touch] release x=%d y=%d dragging=%d pan=%d,%d\n",
point.x,
point.y,
fmt_coord_i32(point.x),
fmt_coord_i32(point.y),
g_gps_state.touch_pan.dragging ? 1 : 0,
g_gps_state.pan_x,
g_gps_state.pan_y);
@@ -497,9 +502,9 @@ static bool try_handle_pan_edit_key(lv_obj_t* target, lv_key_t key, lv_event_t*
input_model,
target_id,
key,
step,
before_x,
before_y,
fmt_coord_i32(step),
fmt_coord_i32(before_x),
fmt_coord_i32(before_y),
g_gps_state.pan_x,
g_gps_state.pan_y);
return true;
@@ -19,6 +19,7 @@
#include "ui/screens/team/team_ui_store.h"
#include "ui/ui_common.h"
#include "ui/widgets/map/map_tiles.h"
#include "ui/widgets/map/map_viewport.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
@@ -87,82 +88,19 @@ static double approx_distance_m(double lat1, double lng1, double lat2, double ln
return sqrt(x * x + y * y) * kEarthRadiusM;
}
namespace
{
constexpr double kCoordPi = 3.14159265358979323846;
constexpr double kCoordA = 6378245.0;
constexpr double kCoordEe = 0.00669342162296594323;
bool coord_out_of_china(double lat, double lon)
{
return (lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271);
}
double coord_transform_lat(double x, double y)
{
double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y +
0.2 * std::sqrt(std::fabs(x));
ret += (20.0 * std::sin(6.0 * x * kCoordPi) + 20.0 * std::sin(2.0 * x * kCoordPi)) * 2.0 / 3.0;
ret += (20.0 * std::sin(y * kCoordPi) + 40.0 * std::sin(y / 3.0 * kCoordPi)) * 2.0 / 3.0;
ret += (160.0 * std::sin(y / 12.0 * kCoordPi) + 320 * std::sin(y * kCoordPi / 30.0)) * 2.0 / 3.0;
return ret;
}
double coord_transform_lon(double x, double y)
{
double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y +
0.1 * std::sqrt(std::fabs(x));
ret += (20.0 * std::sin(6.0 * x * kCoordPi) + 20.0 * std::sin(2.0 * x * kCoordPi)) * 2.0 / 3.0;
ret += (20.0 * std::sin(x * kCoordPi) + 40.0 * std::sin(x / 3.0 * kCoordPi)) * 2.0 / 3.0;
ret += (150.0 * std::sin(x / 12.0 * kCoordPi) + 300.0 * std::sin(x / 30.0 * kCoordPi)) * 2.0 / 3.0;
return ret;
}
void wgs84_to_gcj02(double lat, double lon, double& out_lat, double& out_lon)
{
if (coord_out_of_china(lat, lon))
{
out_lat = lat;
out_lon = lon;
return;
}
double dlat = coord_transform_lat(lon - 105.0, lat - 35.0);
double dlon = coord_transform_lon(lon - 105.0, lat - 35.0);
double radlat = lat / 180.0 * kCoordPi;
double magic = std::sin(radlat);
magic = 1 - kCoordEe * magic * magic;
double sqrt_magic = std::sqrt(magic);
dlat = (dlat * 180.0) / ((kCoordA * (1 - kCoordEe)) / (magic * sqrt_magic) * kCoordPi);
dlon = (dlon * 180.0) / (kCoordA / sqrt_magic * std::cos(radlat) * kCoordPi);
out_lat = lat + dlat;
out_lon = lon + dlon;
}
void gcj02_to_bd09(double lat, double lon, double& out_lat, double& out_lon)
{
double z = std::sqrt(lon * lon + lat * lat) + 0.00002 * std::sin(lat * kCoordPi);
double theta = std::atan2(lat, lon) + 0.000003 * std::cos(lon * kCoordPi);
out_lon = z * std::cos(theta) + 0.0065;
out_lat = z * std::sin(theta) + 0.006;
}
} // namespace
void gps_map_transform(double lat, double lon, double& out_lat, double& out_lon)
{
uint8_t coord_system = app::configFacade().getConfig().map_coord_system;
if (coord_system == 1)
::ui::widgets::map::GeoPoint input{true, lat, lon};
::ui::widgets::map::GeoPoint output{};
if (::ui::widgets::map::transform_geo_point(
input, app::configFacade().getConfig().map_coord_system, output) &&
output.valid)
{
wgs84_to_gcj02(lat, lon, out_lat, out_lon);
return;
}
if (coord_system == 2)
{
double gcj_lat = 0.0;
double gcj_lon = 0.0;
wgs84_to_gcj02(lat, lon, gcj_lat, gcj_lon);
gcj02_to_bd09(gcj_lat, gcj_lon, out_lat, out_lon);
out_lat = output.lat;
out_lon = output.lon;
return;
}
out_lat = lat;
out_lon = lon;
}
@@ -4,9 +4,9 @@
*/
#include "ui/widgets/map/map_tiles.h"
#include "display/DisplayInterface.h"
#include "freertos/FreeRTOS.h"
#include "lvgl.h"
#include "platform/esp/common/shared_spi_lock.h"
#include "src/draw/lv_image_decoder_private.h"
#include "src/misc/cache/instance/lv_image_cache.h"
#include "sys/clock.h"
@@ -14,6 +14,7 @@
#include "ui/runtime/memory_profile.h"
#include "ui/screens/gps/gps_constants.h"
#include "ui/support/lvgl_fs_utils.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
@@ -56,6 +57,13 @@ static bool use_non_touch_placeholder_cards()
return !::ui::page_profile::current().large_touch_hitbox;
}
static int fmt_tile_coord(int32_t value)
{
// Tile coordinates are bounded by the zoom-domain we support, so narrowing
// them for debug/UI formatting is intentional and safe.
return static_cast<int>(value);
}
static void create_placeholder_tile_card(lv_obj_t* parent, MapTile& tile, int screen_x, int screen_y)
{
tile.img_obj = lv_obj_create(parent);
@@ -66,7 +74,12 @@ static void create_placeholder_tile_card(lv_obj_t* parent, MapTile& tile, int sc
lv_obj_t* placeholder_label = lv_label_create(tile.img_obj);
char placeholder_text[48];
snprintf(placeholder_text, sizeof(placeholder_text), "z=%d\nx=%d\ny=%d", tile.z, tile.x, tile.y);
snprintf(placeholder_text,
sizeof(placeholder_text),
"z=%d\nx=%d\ny=%d",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
lv_label_set_text(placeholder_label, placeholder_text);
style_placeholder_text(placeholder_label);
lv_obj_center(placeholder_label);
@@ -223,27 +236,6 @@ const char* major_contour_profile_for_zoom(int z)
return "major-25";
}
class SpiLockGuard
{
public:
explicit SpiLockGuard(TickType_t wait_ticks)
: locked_(display_spi_lock(wait_ticks))
{
}
~SpiLockGuard()
{
if (locked_)
{
display_spi_unlock();
}
}
bool locked() const { return locked_; }
private:
bool locked_;
};
} // namespace
uint8_t sanitize_map_source(uint8_t map_source)
@@ -481,9 +473,9 @@ static DecodedTileCache* get_lru_cache_slot(size_t active_limit)
if (g_tile_decode_cache[lru_idx].img_dsc != NULL)
{
GPS_LOG("[GPS] Evicting cached tile %d/%d/%d from decode cache\n",
g_tile_decode_cache[lru_idx].z,
g_tile_decode_cache[lru_idx].x,
g_tile_decode_cache[lru_idx].y);
fmt_tile_coord(g_tile_decode_cache[lru_idx].z),
fmt_tile_coord(g_tile_decode_cache[lru_idx].x),
fmt_tile_coord(g_tile_decode_cache[lru_idx].y));
// Free the image descriptor (data was allocated by us)
if (g_tile_decode_cache[lru_idx].img_dsc->data != NULL)
{
@@ -910,15 +902,20 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
if (tile.img_obj != NULL && tile.has_png_file)
{
tile.last_used_ms = sys::millis_now();
GPS_LOG("[GPS] load_tile_image: Tile %d/%d/%d already loaded\n", tile.z, tile.x, tile.y);
GPS_LOG("[GPS] load_tile_image: Tile %d/%d/%d already loaded\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
return;
}
SpiLockGuard spi_lock(pdMS_TO_TICKS(20));
::platform::esp::common::SharedSpiLockGuard spi_lock(pdMS_TO_TICKS(20));
if (!spi_lock.locked())
{
GPS_LOG("[GPS] load_tile_image: SPI lock busy, deferring tile %d/%d/%d\n",
tile.z, tile.x, tile.y);
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
tile.last_used_ms = sys::millis_now();
return;
}
@@ -928,7 +925,11 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
bool file_exists = false;
GPS_LOG("[GPS] load_tile_image: Loading tile %d/%d/%d, path=%s\n", tile.z, tile.x, tile.y, path);
GPS_LOG("[GPS] load_tile_image: Loading tile %d/%d/%d, path=%s\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y),
path);
// Always recalculate screen position (don't use old placeholder position)
// This ensures correct position after panning/zooming
@@ -971,7 +972,9 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
if (cache_slot == NULL)
{
GPS_LOG("[GPS] Cache full (all slots in use), deferring tile %d/%d/%d\n",
tile.z, tile.x, tile.y);
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
tile.last_used_ms = sys::millis_now();
return;
}
@@ -1000,7 +1003,10 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
if (cached && cached->img_dsc != NULL)
{
// Use cached decoded image (no PNG decode needed)
GPS_LOG("[GPS] Using cached decoded image for tile %d/%d/%d\n", tile.z, tile.x, tile.y);
GPS_LOG("[GPS] Using cached decoded image for tile %d/%d/%d\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
lv_image_set_src(tile.img_obj, cached->img_dsc);
cached->in_use = true;
cached->last_used_ms = sys::millis_now();
@@ -1009,7 +1015,10 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
else
{
// Decode PNG and cache it in RAM
GPS_LOG("[GPS] Decoding and caching tile %d/%d/%d\n", tile.z, tile.x, tile.y);
GPS_LOG("[GPS] Decoding and caching tile %d/%d/%d\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
// Use LVGL's decoder to decode PNG
lv_image_decoder_dsc_t decoder_dsc;
@@ -1029,7 +1038,14 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
uint32_t data_size = decoded_buf->data_size;
GPS_LOG("[GPS] Decoded tile %d/%d/%d: %dx%d, cf=%d, stride=%d, size=%d\n",
tile.z, tile.x, tile.y, width, height, cf, stride, data_size);
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y),
width,
height,
cf,
stride,
data_size);
// Allocate image descriptor (cache_slot->img_dsc should be NULL after eviction)
cache_slot->img_dsc = (lv_image_dsc_t*)lv_malloc(sizeof(lv_image_dsc_t));
@@ -1084,7 +1100,10 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
// Use cached decoded image
lv_image_set_src(tile.img_obj, cache_slot->img_dsc);
GPS_LOG("[GPS] Tile %d/%d/%d decoded and cached successfully\n", tile.z, tile.x, tile.y);
GPS_LOG("[GPS] Tile %d/%d/%d decoded and cached successfully\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
}
}
}
@@ -1093,10 +1112,13 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
// Decode failed - fall back to file path
GPS_FLOW_LOG("[GPS][MAP][fallback] decode_path_fallback z=%d x=%d y=%d src=%u\n",
tile.z,
tile.x,
tile.y,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y),
g_active_map_source);
GPS_LOG("[GPS] WARNING: Failed to decode tile %d/%d/%d, using file path\n", tile.z, tile.x, tile.y);
GPS_LOG("[GPS] WARNING: Failed to decode tile %d/%d/%d, using file path\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
lv_image_decoder_close(&decoder_dsc);
// Create cache entry placeholder (will be decoded later if possible)
@@ -1117,12 +1139,18 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
tile.contour_checked = false;
tile.contour_loaded = false;
*ctx.has_map_data = true; // Global flag - any tile ever loaded
GPS_LOG("[GPS] Tile %d/%d/%d image loaded successfully\n", tile.z, tile.x, tile.y);
GPS_LOG("[GPS] Tile %d/%d/%d image loaded successfully\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
}
else
{
// File missing: use unified placeholder card
GPS_LOG("[GPS] Creating placeholder card for missing tile %d/%d/%d\n", tile.z, tile.x, tile.y);
GPS_LOG("[GPS] Creating placeholder card for missing tile %d/%d/%d\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
if (tile.img_obj != NULL)
{
lv_obj_del(tile.img_obj);
@@ -1132,8 +1160,8 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
tile.base_missing = true;
GPS_FLOW_LOG("[GPS][MAP][fallback] base_missing_confirmed z=%d x=%d y=%d src=%u res=%d path=%s\n",
tile.z,
tile.x,
tile.y,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y),
g_active_map_source,
res,
path);
@@ -1144,13 +1172,20 @@ static void load_tile_image(TileContext& ctx, MapTile& tile)
g_missing_tile_notice_pending = true;
g_missing_tile_notice_source = g_active_map_source;
}
GPS_LOG("[GPS] Created placeholder card for tile %d/%d/%d\n", tile.z, tile.x, tile.y);
GPS_LOG("[GPS] Created placeholder card for tile %d/%d/%d\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y));
}
tile.last_used_ms = sys::millis_now();
tile.obj_evicted_ms = 0;
tile.record_evicted = false;
GPS_LOG("[GPS] Tile %d/%d/%d loaded, visible=%d\n", tile.z, tile.x, tile.y, tile.visible);
GPS_LOG("[GPS] Tile %d/%d/%d loaded, visible=%d\n",
tile.z,
fmt_tile_coord(tile.x),
fmt_tile_coord(tile.y),
tile.visible);
}
static void load_contour_overlay(MapTile& tile)
@@ -1824,8 +1859,8 @@ void tile_loader_step(TileContext& ctx)
before_visible_unloaded);
GPS_FLOW_LOG("[GPS][MAP][loader] pick z=%d x=%d y=%d prio=%d vis=%d loaded=%d placeholder=%d unloaded=%d\n",
best->z,
best->x,
best->y,
fmt_tile_coord(best->x),
fmt_tile_coord(best->y),
best->priority,
before_visible_total,
before_visible_loaded,
@@ -1854,8 +1889,8 @@ void tile_loader_step(TileContext& ctx)
after_visible_unloaded);
GPS_FLOW_LOG("[GPS][MAP][loader] done z=%d x=%d y=%d file=%d obj=%d vis=%d loaded=%d placeholder=%d unloaded=%d\n",
best->z,
best->x,
best->y,
fmt_tile_coord(best->x),
fmt_tile_coord(best->y),
best->has_png_file,
best->img_obj != NULL,
after_visible_total,
@@ -2,6 +2,7 @@
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "platform/esp/common/shared_spi_lock.h"
#include <stddef.h>
#include <stdint.h>
@@ -162,6 +163,3 @@ class LilyGoDispArduinoSPI
void unlock();
};
#endif
bool display_spi_lock(TickType_t xTicksToWait = portMAX_DELAY);
void display_spi_unlock();
@@ -11,12 +11,15 @@
static LilyGoDispArduinoSPI* g_display_spi = nullptr;
bool display_spi_lock(TickType_t xTicksToWait)
namespace platform::esp::common
{
bool shared_spi_lock(TickType_t xTicksToWait)
{
return g_display_spi && g_display_spi->lock(xTicksToWait);
}
void display_spi_unlock()
void shared_spi_unlock()
{
if (g_display_spi)
{
@@ -24,6 +27,8 @@ void display_spi_unlock()
}
}
} // namespace platform::esp::common
bool LilyGoDispArduinoSPI::lock(TickType_t xTicksToWait)
{
return xSemaphoreTake(_lock, xTicksToWait) == pdTRUE;
@@ -0,0 +1,49 @@
#pragma once
#include "freertos/FreeRTOS.h"
namespace platform::esp::common
{
// The shared SPI lock arbitrates ownership of the board-level SPI bus on
// devices where display, SD, radio, NFC, or other peripherals physically
// share one controller. It is not display-specific.
bool shared_spi_lock(TickType_t xTicksToWait = portMAX_DELAY);
void shared_spi_unlock();
class SharedSpiLockGuard
{
public:
explicit SharedSpiLockGuard(TickType_t wait_ticks = portMAX_DELAY)
: locked_(shared_spi_lock(wait_ticks))
{
}
~SharedSpiLockGuard()
{
if (locked_)
{
shared_spi_unlock();
}
}
bool locked() const { return locked_; }
private:
bool locked_ = false;
};
} // namespace platform::esp::common
// Legacy compatibility alias. New code should use shared_spi_lock /
// shared_spi_unlock or SharedSpiLockGuard so call sites describe the bus they
// are arbitrating rather than implying the lock belongs only to display code.
inline bool display_spi_lock(TickType_t xTicksToWait = portMAX_DELAY)
{
return ::platform::esp::common::shared_spi_lock(xTicksToWait);
}
inline void display_spi_unlock()
{
::platform::esp::common::shared_spi_unlock();
}
@@ -1,9 +1,14 @@
#include "display/DisplayInterface.h"
#include "platform/esp/common/shared_spi_lock.h"
bool display_spi_lock(TickType_t xTicksToWait)
namespace platform::esp::common
{
bool shared_spi_lock(TickType_t xTicksToWait)
{
(void)xTicksToWait;
return true;
}
void display_spi_unlock() {}
void shared_spi_unlock() {}
} // namespace platform::esp::common