Skip to content

Latest commit

 

History

History
833 lines (598 loc) · 48.8 KB

implementation.md

File metadata and controls

833 lines (598 loc) · 48.8 KB

PyMapWidget 实现文档

中文 | English

本文档描述架构、模块职责,以及地图加载、瓦片下载、UI 重绘的完整运行时逻辑。Agent 修改实现时请同步更新本文(见 AGENTS.md)。


1. 概述

PyMapWidget 是可扩展的滑块地图(Slippy Map)组件库,采用 Core + Backend 分层:

层 职责
Core 坐标变换、视口计算、瓦片调度、磁盘缓存、异步 HTTP 下载;无 GUI 依赖
Backend Tkinter / PyQt5 / PyQt6 / PySide6:离屏合成、输入事件、主线程图像解码与绘制

设计原则:

  • UI 只与 MapController 交互,不直接调用 TileDownloader。
  • 网络与磁盘 I/O 在后台线程;回到 UI 的回调必须通过 main_thread_invoker。
  • 缩放、改窗口大小等会换一版「当前该显示什么」;晚到的旧下载结果若已不属于这一版,就直接丢掉,避免画错图(代码里用整数 generation 区分,见 §2.6)。

2. 模块说明

2.1 core/config.py — MapConfig

字段 类型 默认值 说明
cache_dir Path 必填 缓存根目录
tile_url_templates list[str] 见 MapConfig 默认 至少 1 个;支持 {x},{y},{z},{quadkey};多 URL 时见 §2.5
proxy str | None None HTTP 代理
default_zoom int 15 初始缩放级别
default_center (lat, lon) 北京 初始中心
max_concurrency int 8 并发下载数(Semaphore)
min_zoom / max_zoom int 3 / 18 缩放范围
tile_size int 256 瓦片像素边长
tile_path_cache_size int 1000 _tile_paths LRU 上限;0 不限制
bucket_size int 256 缓存目录分桶宽度
user_agent str Chrome 浏览器 UA HTTP User-Agent;OSM 官方瓦片须用可识别应用 UA(见 §4.1)
request_timeout float 10.0 请求超时(秒)
max_retries int 2 单瓦片失败时,在已选起始 URL 之后继续依次尝试的额外次数(见 §2.5)
tile_coord_ref str gcj02 底图影像 CRS:wgs84 / gcj02;矢量叠加投影前对齐到此值
reject_blocked_tiles bool False 是否启用拦截图 MD5 黑名单(按图源)
blocked_tile_md5 list[str] [] 已知错误页瓦片的整图 MD5(32 位 hex);仅在上一开关为真时使用

2.2 core/mercator.py — 坐标变换

本模块实现 Web Mercator 换算(与 Google / OSM 等在线瓦片一致)。概念说明见 api.md §1.1;经纬度有 Canonical(显示,lon ∈ [-180, 180))与 Continuous(内部平移,lon 可超出)两套语义,见 api.md §1.2。

函数 作用
lat_lon_to_tile(lat, lon, zoom) 经纬度 → 瓦片索引 (x, y)(Web Mercator;输入坐标语义须与底图一致;经度先 wrap)
tile_to_lat_lon(tile_x, tile_y, zoom) 瓦片西北角经纬度(Canonical)
lat_lon_to_pixel / pixel_to_lat_lon 全局像素 ↔ Continuous 经纬度
tile_pixel_to_lat_lon(...) 瓦片索引 + 瓦片内像素 [0,tile_size] → 经纬度
normalize_lat_lon(lat, lon) → Canonical(显示用)
wrap_tile_x(x, zoom) 经度方向 x 对 2^zoom 取模(可供调用方使用;视口放置不再使用)

地图控件对外封装见 api.md §3–§4。

2.3 core/viewport.py — 视口瓦片计算

输入 ViewportState(中心经纬度、zoom、offset_x/y)和窗口 (width, height),输出 TilePlacement 列表。

每个 TilePlacement 包含:

  • key: TileKey — (z, x, y)
  • screen_x, screen_y — 瓦片左上角在屏幕上的像素位置

缓冲圈:在可见矩形外再多算 1 圈瓦片(buffer=1),减少拖拽时边缘露白。

世界边界约束:constrain_viewport(...) 按轴调整 offset_x/y: 世界小于视口则居中;否则将视口左上角夹在 [0, world - view]。 MapController._sync_viewport 在计算 placement 前始终调用。

放置范围:tx/ty 仅取 [0, 2^z);不再对 tx 做 wrap_tile_x 后放置另一侧拷贝。世界边缘处缓冲圈自然变窄。

2.4 core/tile_cache.py — 分层磁盘缓存

目录结构

{cache_dir}/
├── tiles.db
└── tiles/
    └── z{zoom:02d}/
        └── xb{x//256:05d}/
            └── yb{y//256:05d}/
                └── {x}_{y}.png

桶编号 xb00105 表示 x // 256 == 105(x ∈ [26880, 27135]),与文件名 {x}_{y}.png 中的精确坐标是两层含义。

来源隔离约束: SQLite 主键和文件路径只包含 (z, x, y),不保存、不比较 tile_url_templates。因此同一 cache_dir 混用不同地图源时,后一来源会直接复用前一来源相同坐标的瓦片,可能出现底图混用或切换来源后仍显示旧图。库不会自动迁移、清空或划分来源命名空间;应用必须为不同来源配置不同缓存目录,或在切换前自行清空缓存。

SQLite

表 tiles 主键 (z, x, y),存相对路径 rel_path(禁止存绝对路径)。

读写

  1. lookup:先查 SQLite → 校验文件存在、size 匹配且前 32 字节包含受支持的图片魔数 → 返回 Path;非图片文件会失效并删除。若配置了非空 blocked_tile_md5,lookup 才会再读全文并按 MD5 剔除已知拦截图;命中时删除缓存文件并抛 BlockedTileError(不当作普通缺失)。黑名单为空时不读整文件。
  2. put:下载阶段先通过 Content-Type 预检,再检查响应前 32 字节是否为 PNG/JPEG/WebP/GIF/BMP/TIFF/ICO/AVIF/HEIF 等常见图片头;若黑名单非空则再对全文做 MD5。命中黑名单抛 BlockedTileError。最后写 {name}.png.tmp → os.replace 原子替换 → UPSERT SQLite;规范化后的 media type 同步保存到 content_type 字段。

文件头检查只用于轻量排除 HTML/JSON 等明显错误响应,不做全文件完整性验证。极少数“文件头正确但主体损坏”的内容会在后端实际解码时回退到占位图。

TileManager 使用单线程 TileCache executor 串行执行 lookup/put,因此 SQLite 和文件检查不会阻塞 UI 线程,也不会在多个线程上并发操作同一 connection。

2.5 core/tile_downloader.py — 异步下载

  • 独立 daemon 线程运行 asyncio 事件循环。
  • 单例 aiohttp.ClientSession,请求头含 User-Agent;可选 proxy。
  • asyncio.Semaphore(max_concurrency) 限制同时进行的 HTTP 请求数。
  • 收到 HTTP 200 后先规范化 Content-Type(忽略 ; charset=... 等参数):image/*、缺失类型、application/octet-stream 和其他未知二进制类型继续读取;明确的 text/*、JSON、XML、JavaScript 响应直接视为本次 URL 失败并进入重试。读取后的内容仍须通过文件头检查。
  • 正文命中拦截图黑名单时抛 BlockedTileError:立即停止,不换下一个 URL,也不走网络失败重试。
  • 同一 TileKey 在 _inflight 中时不重复发起请求。
  • 下载完成在下载线程调用 TileManager 传入的 callback(非 UI 线程)。
  • close() 先取消并等待在途协程,再关闭 session 和事件循环,避免遗留 pending task。

多 URL 模板(tile_url_templates)

  • 至少 1 个 URL;占位符 {x}, {y}, {z},以及 Bing 用的 {quadkey}(由 core/tile_url.py 从 XYZ 换算)。
  • 并发下载多个瓦片时:每发起一个新瓦片下载,按配置列表顺序轮询选取起始 URL(第 1 个瓦片用 templates[0],第 2 个用 templates[1],…,到头后回到 templates[0])。实现见 _allocate_start_url_index()。
  • 同一瓦片请求失败重试时:从该瓦片已分配的起始 URL 起,依次尝试后续 URL((start + 0) % n、(start + 1) % n、…),最多 min(max_retries + 1, len(templates)) 次。拦截图命中不属于此类失败。

示例:配置 3 个 URL,同时缺 5 块瓦片 → 起始 URL 索引分别为 0、1、2、0、1;若索引 1 的瓦片第一次失败,则依次试 1 → 2 → 0。

2.6 core/tile_manager.py — 调度与「视图版本号」

代码里的字段名叫 generation(整数,从 0 递增)。可以把它理解成:当前地图内容的版本号。

  • 什么时候加一? 缩放、改窗口大小、refresh_tiles() 等会调用 bump_generation(),表示「现在要显示的是另一套瓦片了」。
  • 什么时候不加? 单纯拖拽平移时版本号不变。正在下载的瓦片下完后,只要版本号还对得上,就可以继续画到当前地图上。
  • 为什么需要? 用户可能很快滚轮缩放:旧 zoom 的下载稍后才返回。若版本号已经变了,这次结果作废,避免把旧级别瓦片贴到新视图上。
成员 作用
generation 当前视图版本号
_pending: TileKey → generation 每个瓦片最近一次被请求时对应的版本号
cache / downloader 磁盘缓存与异步下载
_cache_executor 串行执行缓存 lookup/put 的单线程 executor
_blocked 已确认命中拦截图的瓦片;后续不再下载、不按网络失败重试

request_tiles(keys, generation, fetch_keys=...) 对每个要拉取的 key:

  1. 把缓存 lookup 投递到 _cache_executor;缓存命中 → 回调 on_tile_ready(key, path, generation, retry=True)。
  2. 已经在下载中 → 只更新 _pending[key] = generation(不重复发起网络请求)。
  3. 已在 _blocked → 清掉 pending,不再下载。
  4. 否则 → 记下 _pending[key] = generation,并调用 downloader.download。

fetch_keys 控制「这次要不要查缓存/开下载」:

  • 平移 / 松手(bump_generation=False):通常只对新进入缓冲圈的 key 拉取(fetch_keys = 新集合 − 旧集合)。若已有 key 网络加载失败,会记录 1 秒退避;退避到期且该 key 仍无路径、不在 pending 时,后续平移会重新拉取。命中拦截图时 retry=False,不写退避、不按失败重试。没有到期失败项且集合不变时仍跳过缓存查询。
  • 缩放 / resize / refresh:对当前全部 placement key 重新拉取;_blocked 中的 key 仍跳过网络。

下载完成(_on_download_bytes)时:

  1. 有数据则写入 cache;put/lookup 抛 BlockedTileError 时记入 _blocked,回调 retry=False。
  2. 从 _pending 取出该瓦片请求时的版本号。
  3. 若与当前 generation 不一致 → 当作过期结果丢弃(日志里会写 tile ready dropped)。
  4. 一致 → 调用 on_tile_ready(key, path, generation, retry),进入 UI 更新链。

已不在当前 placement 里的 key,会从 _pending 里清掉。

2.7 core/map_controller.py — 地图控制器

状态变量

变量 含义
center_lat, center_lon, zoom 逻辑中心与缩放级别
offset_x, offset_y 相对屏幕中心的像素偏移(拖拽时先改 offset)
_generation 与 TileManager.generation 同步的视图版本号
_placements 当前需要的瓦片及其屏幕位置
_tile_paths 已就绪瓦片的本地文件路径
_defer_tile_patches 拖拽 pan_by 期间为 True:缓存命中只记路径,不立刻通知 UI 单瓦片补图

视口中心 vs 逻辑中心

  • get_view_center() / get_view_corners():屏幕中心/四角经纬度,包含当前拖拽 offset;返回前会 normalize_lat_lon(常规经纬度范围,见 api.md)。
  • center_lat/lon:内部存储的逻辑中心。松手 end_pan() 时,若 offset 已超过一整块瓦片宽高,会把这部分折进中心(_normalize_offset),避免 offset 无限变大。

交互入口

操作 方法 是否提升视图版本号 说明
拖拽中 pan_by(dx, dy) 否 改 offset;与定位相同:只请求 _tile_paths 中尚无路径的瓦片
松手 end_pan() 否 必要时折叠 offset;有变化才再 sync 并通知 UI 全量合成
设中心 set_center(lat, lon) 是 中心+offset 未变则 no-op;否则只请求尚未记录路径的瓦片
滚轮缩放 zoom_at(...) 是(经 refresh_tiles) 清空路径缓存后以鼠标下地理点为锚点改 zoom
改窗口大小 set_size(w, h) 是 重算可见瓦片(全量请求)
强制刷新 refresh_tiles() 是 版本号 +1,旧下载结果不再上屏;全量请求

_tile_paths 为 LRU(MapConfig.tile_path_cache_size,默认 1000):优先淘汰非当前缓冲圈内的条目;当前视野内的 key 不会因上限被踢掉。

close() 会先标记控制器关闭;尚在 GUI 事件队列中的旧 tile ready 闭包执行时再次检查关闭态,因此 apply_config() 和窗口销毁后不会把旧来源瓦片写入新视图。

瓦片就绪后的回调链

异步下载或缓存命中后:

TileManager.on_tile_ready
  → MapController._handle_tile_ready
      → 经 main_thread_invoker 回到 UI 线程
          → 写入 _tile_paths
          → 若正在 pan_by(_defer_tile_patches)则到此为止,不通知 UI
          → 否则调用 on_tile_updated(key, path, placement)
          → Backend:解码图像 → 补丁绘制到离屏缓冲 →(若瓦片在视口内)合并刷新屏幕

注意:单瓦片就绪不会再走 on_map_changed 全量重绘;全量合成由缩放、resize、换列 compose 等路径触发。


3. 运行时:地图加载全流程

3.1 启动与 resize

flowchart TD
    A["Backend 创建 MapController"] --> B["set_size(width, height)"]
    B --> C["refresh_tiles()"]
    C --> D["_sync_viewport:版本号+1"]
    D --> E["compute_tile_placements"]
    E --> F["TileManager.request_tiles"]
    F --> G{"缓存?"}
    G -->|hit| H["tile_ready → UI"]
    G -->|miss| I["download → cache.put → tile_ready → UI"]
    D --> J["on_map_changed → 合成占位图帧"]
Loading
  1. Backend 构造 MapController,注册 on_map_changed、on_tile_updated、main_thread_invoker。
  2. 首次 set_size,或窗口 Configure / resizeEvent,会调用 refresh_tiles()。
  3. _sync_viewport(bump_generation=True):
    • 视图版本号加一;
    • 按当前中心、offset、窗口大小算出需要的 _placements(含视口外一圈缓冲瓦片);
    • 丢掉已不在 placement 里的 _tile_paths;
    • 对全部 placement key 查缓存或开始下载。
  4. on_map_changed → Backend _compose_back_buffer():已有图的瓦片贴图,还没有的先画灰色占位图。

3.2 平移(pan)

拖拽时 Backend 的 _pan_drag:先让 Core 改状态,再看瓦片集合有没有变(详见 §5.4)。

拖着鼠标移动时(每一小步 B1-Motion / mouseMoveEvent):

  1. 记下拖拽前的瓦片 key 集合 old_keys。
  2. 调用 controller.pan_by(dx, dy),内部走 _sync_viewport(bump_generation=False):
    • _sync_viewport 开头调用 constrain_viewport:越界 offset 被夹回;小世界时该轴 offset 保持居中。
    • 不提升视图版本号。因此缩放之前已经发出去的下载,只要版本号还对,下完仍可上屏。
    • 按新的 offset 重算 _placements。
    • 对新走进缓冲圈的 key 查缓存/下载;对仍在圈内且失败退避已到期的 key 也会重试。其余集合不变场景不查缓存。
    • pan_by 期间 _defer_tile_patches=True:就算缓存立刻命中,也只更新 _tile_paths,不马上触发 UI 的单瓦片补丁(避免一帧里刷几十次)。
  3. 比较 new_keys 与 old_keys:
    • 没变:离屏大缓冲里的像素不动;屏幕显示时用当前 offset 相对「上次合成时的 offset」去裁剪取样(§5.4),看起来地图跟着鼠标走。
    • 变了(换列/换行):整页 buffer compose 重画缓冲圈上的全部瓦片,并记下新的 buffer_sync_offset;日志打 tile set change。
  4. 无论 key 变不变,都做一次 view blit,把缓冲里对应视口的部分画到控件上。
  5. 之后若有异步下载完成:在缓冲坐标上 buffer patch 单块瓦片;若这块落在视口内,再合并刷新屏幕(§5.5)。

松开鼠标(end_pan):

  1. _normalize_offset():若 |offset| 已达到或超过一块瓦片边长(通常 256px),把整瓦片部分折进 center_lat/lon,offset 收回到不足一块的范围。
  2. 若折叠后中心/offset 有变化:再 _sync_viewport,并通知 UI 全量 buffer compose + view blit。
  3. 若折叠什么也没改:直接返回。拖拽过程中屏幕已经用裁剪取样对齐了,不必再画一遍。

3.3 缩放(zoom)

  1. zoom_at(screen_x, screen_y, delta):
    • 先算出鼠标下那一点的经纬度;
    • 改 zoom,把该点设为新的逻辑中心,并设好 offset,使缩放后鼠标仍钉在同一地理位置。
  2. refresh_tiles() → 视图版本号加一 → 旧缩放级别上尚未返回的下载全部作废。
  3. 按新级别重新计算 placement,并请求整屏(加缓冲圈)瓦片。

4. 运行时:瓦片下载

sequenceDiagram
    participant TM as TileManager
    participant DL as TileDownloader
    participant Cache as TileCache
    participant MC as MapController
    participant UI as Backend

    TM->>TM: request_tiles(key, gen)
    TM->>Cache: executor lookup
    alt cache hit
        TM->>MC: on_tile_ready (cache thread)
    else not downloading
        TM->>DL: download(key)
        DL->>DL: aiohttp GET (retry URLs)
        DL->>TM: _on_download_bytes
        TM->>Cache: put(bytes)
        TM->>TM: 版本号仍是当前?
        TM->>MC: on_tile_ready
    else already downloading
        TM->>TM: _pending[key]=gen
    end
    MC->>UI: main_thread_invoker
    UI->>UI: load pixmap / PhotoImage
    UI->>UI: compose + repaint
Loading

要点:

  • 下载在 TileDownloader 线程完成;缓存 lookup/put 在单独的 TileCache 线程串行执行。
  • MapController._handle_tile_ready 必须经 main_thread_invoker 才能在 UI 线程改 _tile_paths 并重绘。
  • PyQt5:pyqtSignal(object) + Qt.QueuedConnection(跨线程可靠)。
  • PyQt6:pyqtSignal(object) + Qt.ConnectionType.QueuedConnection。
  • PySide6:Signal(object) + Qt.ConnectionType.QueuedConnection。
  • Tkinter:queue.Queue + after(16ms) 轮询 _pump_main_queue。

4.1 OpenStreetMap 官方瓦片与 “Access blocked”

tile.openstreetmap.org 的 Tile Usage Policy 要求可识别 User-Agent、控制并发、本地缓存;禁止伪装浏览器 UA。

违反时服务器常仍返回 HTTP 200 + 合法 PNG(画面 “Access blocked”)。本库用整图 MD5 黑名单识别,且按图源配置(默认关闭)。命中后删除/拒绝该图,不再当作网络失败去换 URL 或 1 秒后重拉(同一字节会得到同一 MD5,重试只会空转)。

"reject_blocked_tiles": true,
"blocked_tile_md5": ["d41d8cd98f00b204e9800998ecf8427e"]

对应 MapConfig.reject_blocked_tiles / blocked_tile_md5。Demo 仅 OSM profile 默认打开该开关;指纹用 tile_md5_hex(png_bytes)(hashlib.md5)从拦截 PNG 算出后写入。同时用 DEFAULT_OSM_USER_AGENT、max_concurrency=2。拦截图更换后需更新该 profile 黑名单;仍被拦时清空该图源 cache_dir 并检查 UA。


5. 运行时:UI 重绘

所有 Backend 均采用 离屏双缓冲;三个 Qt 后端使用相同的缓冲策略:

步骤 Qt(PyQt5 / PyQt6 / PySide6) Tkinter
离屏缓冲 QPixmap _back_buffer(含 1 圈缓冲环,大于视口) PIL.Image _back_image(同上)
合成 _compose_back_buffer():按 export_bounds 尺寸绘制全部 placement 同上
拖拽(key 不变) 不修改缓冲;paintEvent / Canvas 用 viewport_blit_rect 从缓冲裁剪视口 同上
显示 _paint_viewport():从 _back_buffer 按 pan delta 取样 _viewport_frame() + create_image(0,0)
图像缓存 _pixmaps[TileKey] _tile_images[TileKey]

5.1 合成一帧(_compose_back_buffer)

对 controller.get_placements()(含缓冲环,用于定尺寸与预取):

  1. 用 export_bounds(placements, tile_size) 计算含缓冲环的边界(placement 坐标可含负值);
  2. 创建 大于视口 的离屏图,尺寸 = bounds.width × bounds.height,先填底色;
  3. 只绘制与视口相交的瓦片(约 5×7,不含最外一圈);圈外留给预取,不立即画进缓冲;
  4. 查 get_tile_path(key) 或内存缓存;有文件 → 解码;无文件 → 占位图;
  5. 记录 _compose_placements、_painted_keys 与 buffer_sync_offset,供后续 lazy patch / viewport 取样;
  6. 可选:勾选 demo「显示瓦片分界线」时,对已绘制瓦片按网格 extent 画 1px 长线。

拖拽使圈外瓦片进入视口时:_ensure_visible_painted 用 unpainted_visible_buffer_targets 批量 buffer patch(日志 lazy +N),再 view blit。

5.2 触发重绘的时机

场景 缓冲层 View 层
拖动、key 集合不变 若新露出未绘制瓦片 → 批量 lazy patch;否则不改缓冲 view blit:viewport_blit_rect 裁剪视口(1 次)
拖动、key 集合变化(换列/换行,§5.4) buffer compose(只画可见瓦片) view blit(offset=0 相对新缓冲)
下载完成 / 异步 tile ready 仅可见时 buffer patch;圈外只进内存缓存 可见时合并 view blit(§5.5)
zoom / resize / normalize 后 end_pan buffer compose(只画可见) view blit

pan_by 期间 _defer_tile_patches=True:缓存命中只更新 _tile_paths,不触发 patch;换列时的全量 compose 已绘制全部瓦片(含占位图),避免同一帧内 63 次 patch。

5.3 诊断日志

启用 configure_tile_logging() 后可见:

  • sync viewport / download start|done|pending / tile ready / tile ready dropped(版本号已变,结果作废)
  • buffer compose — 全量重绘离屏缓冲(换列、zoom、normalize 等)
  • buffer patch tiles=63 patched=1 x=… y=… z=… — 单瓦片异步下载完成(写入缓冲坐标)
  • tile set change tiles=63 added=15 removed=15 cached=3 download=12 — 拖动换列时新增/移除 key 及缓存命中 vs 待下载(§5.4)
  • view blit — 将离屏缓冲贴到控件
  • mouse move dx=… dy=… pos=(x,y) — 拖拽每一步

5.4 拖动换列场景与流程

本节描述最常见的「对角拖过一格瓦片边界」场景:placement key 集合发生变化,需要全量重合成离屏缓冲,并统计新增 key 的缓存/下载情况。

5.4.1 场景说明

视口在可见矩形外保留 1 圈缓冲(compute_tile_placements(..., buffer=1))。典型 800×600 窗口约需 7×9 = 63 块 placement 瓦片填满缓冲环。

当用户沿对角方向拖动,使 offset_x 与 offset_y 同时越过一整块瓦片宽(256px)时,缓冲圈在 X、Y 两个方向各换一列/一行:

方向 离开(removed) 进入(added)
X 最左一列(约 7 块) 最右一列(约 7 块)
Y 最上一行(约 9 块) 最下一行(约 9 块)

角上的那块瓦片同时属于「列变化」与「行变化」,在集合差集中只计一次,因此:

|added| = |removed| ≈ 7 + 9 − 1 = 15

这与日志中 tile set change … added=15 removed=15 一致。仅沿单轴拖动时,通常 added = removed = 7 或 9(取决于窗口宽高与缓冲圈尺寸)。

5.4.2 从大缓冲裁剪视口(key 不变时)

离屏缓冲在上次 compose 时已经画好整圈瓦片(含视口外一圈),并记下当时的 buffer_sync_offset(合成那一刻的 offset_x/y)。

之后只挪了几个像素、瓦片集合没变时:

  • 不再改缓冲里的像素(那几十块瓦片还在);
  • 屏幕刷新时算「相对合成时挪了多少」:delta = 当前 offset − buffer_sync_offset;
  • 用 viewport_blit_rect 从大缓冲里裁出当前视口对应的那一块贴到控件上(方向与 Win32 在 WM_PAINT 里按滚动偏移取样一致)。

所以小幅拖动时日志通常只有 mouse move → sync viewport → view blit,没有 buffer compose。

典型 800×600 窗口,大缓冲大约 1312×1112。调试导出时:map.png 应与屏幕一致;buffer.png 是更大的整圈缓冲。

为什么要做成大缓冲? 若缓冲只有视口那么大,再把整张图平移,边缘会露出从来没画过的灰边。当前做法是:缓冲先含一圈余量,显示时只裁剪取样。

5.4.3 单帧决策流程

flowchart TD
    A["mouse move → _pan_drag(dx, dy)"] --> B["old_keys = placement_keys()"]
    B --> C["controller.pan_by(dx, dy)"]
    C --> D["_sync_viewport: 重算 placements + request_tiles"]
    D --> E["new_keys = placement_keys()"]
    E --> F{"new_keys == old_keys?"}
    F -->|是| G["view blit(viewport 裁剪,不改缓冲)"]
    F -->|否| H["log tile set change + buffer compose"]
    H --> G
Loading

Core 侧(key 变化时) 对 新增 key 调用 request_tiles;此外会加入失败退避已到期的当前 key。

Backend 侧(key 变化):buffer compose 仍按缓冲环定尺寸,但只绘制可见瓦片(日志 painted=M/N),更新 buffer_sync_offset / _painted_keys。

5.4.4 换列后的异步补图

全量 compose 之后,仍在下载中的瓦片会在完成后走常规 ready 链:

TileManager.on_tile_ready
  → MapController._handle_tile_ready  (此时 _defer_tile_patches 已为 False)
  → main_thread_invoker
  → Backend.on_tile_updated
  → buffer patch(单瓦片)+ view blit

因此换列一帧的典型日志序列为:

mouse move dx=… dy=… pos=(x,y)
sync viewport …
tile set change tiles=63 added=15 removed=15 cached=3 download=12
buffer compose tiles=63 detail=keys +15 -15
view blit

5.4.5 路径对比

key 不变 key 变化
离屏缓冲 必要时 lazy 批量 patch buffer compose(只画可见)
View offset 平移 blit blit(相对新缓冲 offset=0)
request_tiles 通常 fetch_keys=[];失败退避到期时含重试 key fetch 新增 key + 到期重试 key
典型日志 mouse move + view blit + tile set change + buffer compose

实现位置:backends/buffer_view.py(BufferViewState)、backends/repaint.py、backends/pyqt5.py / tkinter.py(_pan_drag)。

5.5 下载完成:缓冲 patch 与 view blit 分离

缓冲圈(约 63 瓦片)大于可见 viewport(约 35 瓦片)。瓦片 ready 时:

  1. 解码进内存瓦片缓存(供日后 lazy 绘制)。
  2. 仅当当前可见时才 buffer patch 并调度 view blit;圈外 ready 不写离屏缓冲。
  3. 之后 pan 使该瓦片可见时,由 _ensure_visible_painted 批量补画(此时内存里往往已有图)。
  4. 合并绘制:可见瓦片的 view blit 经 ViewBlitCoalescer 合并,默认间隔 25ms(约 40fps)。构造时可传 view_blit_coalesce_ms,运行时可调 set_view_blit_coalesce_ms(ms) / view_blit_coalesce_ms()。同一窗口内多块下载完成 → 一次 blit;最后一块完成后仍会触发定时器 → 保证最终帧更新。pan/compose 路径调用 blit_immediate() 取消合并、立即 blit。

极限场景(63 瓦片全 miss):缓冲层约 35 次可见 patch(compose/lazy),圈外下载完成不再刷缓冲;view 层合并后约数帧~数十次 blit。


6. UI 后端要点

6.1 Tkinter — backends/tkinter.py

  • ImageTk.PhotoImage 必须在主线程创建,并保持引用防 GC。
  • main_thread_invoker → _main_queue + _pump_main_queue。
  • 提升叠加控件时显式调用窗口级 tk.Misc.lift;Canvas.lift 实际是图元 tag_raise,不能用于提升 Canvas 控件本身。
  • 即使缓存文件在 lookup 后被外部损坏,Image.open 失败也会安全回退到占位图。

6.2 PyQt5 — backends/pyqt5.py

  • QPixmap 在主线程从文件加载。
  • main_thread_invoker → _dispatch_to_main.emit(fn)。
  • 除 closeEvent 外还单次绑定 destroyed 信号;可更新 holder 始终指向当前 controller,因此反复 apply_config() 不会堆积槽或旧 controller 引用。

6.3 Qt 6 — backends/pyqt6.py / backends/pyside6.py

  • 功能与公开方法对齐 PyQtMapWidget,分别使用 PyQt6 的 pyqtSignal 与 PySide6 的 Signal。
  • 矢量叠加与 PyQt5 共用 qt_vector_overlay.py(各绑定一份薄封装)。
  • 使用 Qt 6 scoped enum(如 Qt.MouseButton.LeftButton)和 QMouseEvent.position()。
  • 两个 binding 为独立可选依赖;应用应只创建与自身 Qt binding 对应的控件。

6.4 叠加控件(缩放 / 比例尺)

  • 几何:core/map_controls.py(ControlCorner、corner_rect)。
  • 比例尺:core/scale_bar.py(m/px、nice 距离、可选 zoom→米 覆盖)。
  • 状态:backends/map_overlays.py 的 OverlaySettings。
  • UI:各 backend 原生控件叠在地图上;默认右下为定位 ◎ + +/-,左下比例尺(文字居中);点击定位仅触发 set_on_locate 回调。
  • Demo:demos/ip_locate.py 按出口 IP 定位:优先 ip9.com.cn(国内库更准),失败再回退 ip-api.com / ipwho.is;回调里 set_center + set_zoom(12);请求/响应写日志,不弹窗。

6.5 扩展新后端

  1. 创建 MapController,实现 main_thread_invoker(线程安全投递到 GUI 线程)。
  2. on_map_changed → 离屏合成 + 刷新控件。
  3. on_tile_updated → 加载该瓦片图像后重新合成。
  4. 鼠标/滚轮/resize 转发给 pan_by / end_pan / zoom_at / set_size。
  5. 对外 API 对齐:get_center、get_zoom、set_zoom、set_zoom_range、缩放/比例尺显隐与位置、get_view_stats、lat_lon_to_tile_xy、lat_lon_at_tile_pixel、set_show_tile_borders、view_blit_coalesce_ms / set_view_blit_coalesce_ms 等。

7. Demo 与配置

文件 命令
Tkinter python demos/demo_tkinter.py
PyQt5 python demos/demo_pyqt5.py
PyQt6 python demos/demo_pyqt6.py
PySide6 python demos/demo_pyside6.py
PySide6 + MapLibre python demos/demo_maplibre_pyside6.py

7.1 配置持久化

  • 路径:项目根目录 demo_settings.json(不存在则生成默认 Google + OSM + Bing 分组)。
  • 某后端没有窗口尺寸配置时,默认使用 1200 × 800;已有明确保存的尺寸保持不变。
  • 不迁移、不兼容旧路径 ~/.pymapwidget/demo_settings.json。
  • 已有配置文件不会自动追加新默认分组;删除文件后首次运行会按当前默认重新生成。

7.2 配置面板

  • 布局:左侧配置面板,右侧地图。
  • 地图状态:首行合并 z/中心与显示/已加载瓦片数,下接四角经纬度;鼠标经纬度与两行瓦片 URL(「复制瓦片URL」按钮写入剪贴板;仅鼠标在地图上且未按 Ctrl 时更新);分界线开关与导出按钮同行。
  • 地图配置:分组、缓存目录、默认 zoom、默认纬度/经度同行、代理、User-Agent、URL 列表。
  • 中英文切换:所有 GUI Demo 顶部提供 English / 中文 按钮。demos/i18n.py 保存小型共享翻译表,点击后递归批量替换 Qt/Tk 控件的标签、按钮、分组标题和占位文字;地图状态及弹窗按面板当前语言即时格式化。语言只影响界面文本,不写入 demo_settings.json,也不翻译用户填写的分组名称。
  • 切换分组、应用配置、设为默认加载位置、关闭窗口时自动保存。
  • 地图右下角定位按钮:后台请求 IP 粗定位并移动地图。

共享模块:demos/settings_store.py、demos/config_actions.py、demos/map_state_display.py、demos/i18n.py、demos/ip_locate.py。


8. 测试

pip install -e ".[dev]"
python -m pytest
测试文件 覆盖
test_mercator.py 坐标变换、自定义 tile_size、tile_pixel_to_lat_lon
test_cache_paths.py 缓存路径、图片头验证与桶编号
test_viewport.py 视口瓦片数量
test_map_controller.py pan 保视图、get_view_stats、坐标 API
test_scale_bar.py m/px、nice 距离、覆盖与格式化
test_map_controls.py 四角定位、配置校验、set_zoom_range
test_tile_url.py XYZ→quadkey、URL 模板占位符
test_tile_downloader.py 多 URL 轮询、Content-Type 预检、安全取消与关闭
test_core_exports.py pymapwidget.core 公共导出
test_tile_manager.py 视图版本号、pending 队列、content type 入库、过期下载丢弃
test_settings_store.py JSON 配置读写
test_config_actions.py 保存与地图状态同步
test_map_state_display.py 状态面板格式化
test_ip_locate.py IP 定位图源优先级、回退与全部失败
test_repaint.py placement key、拖拽 defer patch、关闭后旧回调丢弃、换列统计
test_buffer_view.py offset blit 与缓冲坐标变换
test_tile_grid.py 瓦片网格长线边界
test_view_blit.py viewport 可见性、view blit 合并
test_gcj02.py GCJ ↔ WGS 转换与境外短路
test_map_overlay.py overlay 模型、fit_bounds、视口原点
test_qt5_vector_overlay.py Qt5 叠加层裁剪、重建、填充与 POI tip
test_qt6_backends.py Qt6 后端 API 对齐、信号/枚举、叠加层与 Demo 接线
test_tkinter_backend.py Tkinter 叠加控件的层级提升
test_nanjing_overlay_demo.py 南京区县 GeoJSON / 示例 POI 加载
test_maplibre_page.py MapLibre host 配置、经纬度顺序、脚本/属性转义、事件值对象
test_maplibre_pyside6_source.py 可选控件语法、公开类、栅格流水线隔离与 demo 状态面板接线
test_maplibre_overlay.py 共享 overlay 模型到 WGS84 GeoJSON 的坐标、闭环与样式转换
test_pbf_to_image_demo.py PBF 诊断 SVG/PNG 的点线面输出、尺寸、交互输入、耗时步骤与本地/URL 文件规则

9. 矢量叠加(行政区 / POI)

本节说明行政区高亮与 POI 的分层模型、数据形态与视口行为,并辅以南京 Demo 中的具体坐标。公共 API 见 api.md §3.5;坐标系约定见 §10。

9.1 分层模型:叠加层 ≠ 瓦片染色

底图仍由 PNG 瓦片构成(Google / 高德 / OSM 等)。 行政区高亮与 POI 不修改瓦片像素,也不对单块瓦片做「是否属于南京」之类的归属判断。

类比:底图相当于已印刷的底图;行政区是其上的矢量描边/填色层,POI 是按经纬度锚定的点标记。 平移、缩放时,叠加几何随经纬度重投影,边界保持为折线矢量,而不会因「烤进某一级瓦片」而在放大后出现像素块。

┌──────────────────────────────────────┐
│  叠加层:区界折线 + 半透明填色 + POI     │  ← 本库绘制
├──────────────────────────────────────┤
│  底图:瓦片 PNG(道路、图商地名标注等)   │  ← 图源提供
└──────────────────────────────────────┘

9.2 南京 Demo 示例(含坐标)

POI

以「南京博物院」为例:

字段 值 含义
lat / lon 32.040802 / 118.825064 纬度 / 经度(GCJ-02,与高德地图网页 POI 一致)
coord_ref gcj02 数据自身的坐标语义为 GCJ-02

绘制步骤:

  1. 若 tile_coord_ref 同为 gcj02(高德、国内 Google)→ 坐标直接使用;若为 OSM(wgs84)→ 先转换为 WGS84 再投影。
  2. 按当前整数缩放级别(如 z=15)将 (lat, lon) 投影为屏幕点,绘制固定像素大小的圆点。
  3. 悬停时在标记正上方显示气泡 tip(底部三角指向标记;贴近视口上沿时改到标记下方)。

底图瓦片上的「南京博物院」文字由图源制图决定。叠加 POI 与之对齐,依赖正确坐标 + 与底图一致的 CRS,而非解析瓦片图像内容。

行政区多边形

数据来自 DataV 南京市辖区边界(nanjing_districts.json),按区县拆分:玄武区、秦淮区、雨花台区、江宁区等各对应一个或多个 MapPolygon(MultiPolygon 会拆成多段)。

Demo 不维护「南京市外轮廓」的单一合并面。 界面上呈现的整片浅色高亮,是各区多边形各自填色后的视觉叠加;相邻两区在共享边界处各有一条边,看起来像一条分界线。

示例(牛首山附近,约 31.903309, 118.744150):

  • 放大后视口内常同时包含 江宁区 与 雨花台区(雨花台北侧有一块向南伸入江宁的凸出部)。
  • 叠加层分别为两区构建折线路径并填色。
  • 不会对瓦片索引(如 (z=15, x=27192, y=13316))做「是否落在南京」判定:瓦片仅是底图分块;高亮只依赖多边形顶点。

常见误解

误解 实际行为
对每个瓦片判断是否在南京市内再染色 不对瓦片做行政区归属
先将全市合并为单一多边形再计算 Demo 按区县分条加载,无全市合并面
高亮写入 tiles.db 高亮仅存在于运行时叠加层,不进入瓦片缓存

9.3 缩放与平移

缩放(整数 zoom 变化) 同一经纬度在 z=11 与 z=15 下对应不同的世界像素坐标,因此需重建叠加层折线与 POI。地理顶点不变;缩放改变的是投影后的像素密度,边界仍为矢量折线。

平移(pan) 多数情况下只需更新视口对齐,不必重算全部几何。 实现上仅将与「当前视口 + 约 35% 边距缓冲」相交的区 / POI 加入叠加层:视口仍在缓冲内时只调整 sceneRect;移出缓冲后重建,以纳入新进入视野的要素。

过小比例尺 全部区县包围盒在屏幕上的跨度小于约 28 像素时,隐藏区界与 POI,避免轮廓挤成一团。 POI 另要求 zoom ≥ 9 才显示。

「行政区域高亮」开关 开启:半透明填色 + 描边。 关闭:仅描边(分界线)。 各区使用各自的 fill_color;不按「视口是否几乎完全落在某一区内」动态降低该区透明度,以免区界附近出现相邻区填色不一致。

9.4 与底图 CRS 对齐

坐标系区别、错位原因和转换原理见 WGS84、GCJ-02 与坐标转换入门。本节只记录叠加层约定:

约定:

  • 叠加数据声明自身 coord_ref(Demo 行政区与多数 POI 为 gcj02)。
  • 底图声明 tile_coord_ref(高德 / 国内 Google 多为 gcj02,OSM 为 wgs84)。
  • 绘制前将数据转换到与底图相同的坐标语义,再投影。

项目转换位置与 Demo 行为见 §10;Demo POI 坐标来源见 demos/data/README.md。

9.5 相关模块

项目 位置 / 职责
数据模型 core/map_overlay.py:MapPolygon / MapPoi / CoordRef
GCJ 互转 core/gcj02.py
矢量叠加层 backends/qt_vector_overlay.py(按绑定生成透明 QGraphicsView);qt5_vector_overlay.py / qt6_vector_overlay.py / pyside6_vector_overlay.py 为薄封装
Demo 数据 demos/data/nanjing_districts.json、demos/nanjing_overlay_demo.py
Demo 入口 PyQt5 / PyQt6 / PySide6:勾选「显示南京行政区 + POI」「行政区域高亮」

数据流:

GeoJSON / POI (lat,lon + CoordRef)
  → 必要时转换到 tile_coord_ref
  → 按当前 zoom 投影为世界像素
  → 保留与视口缓冲相交的区/点 → 绘制折线或圆点
  → 叠加层视口与地图视口对齐

PyQt6 / PySide6 与 PyQt5 共用同一套叠加层实现。Tkinter 不提供矢量叠加 API。


10. GCJ-02 与图源 CRS

坐标系概念、偏移原因和转换算法见 WGS84、GCJ-02 与坐标转换入门;公共调用契约见 api.md §6。本节只记录项目内部的转换位置与 Demo 行为。

10.1 项目内的转换与投影顺序

本库不修改 Web Mercator 公式。叠加层在 lat_lon_to_pixel 之前,用 tile_coord_ref 把数据 CRS 对齐到底图语义:map_overlay.convert_* → gcj02.convert_lat_lon → Web Mercator 世界像素。

10.2 转换模块与公开函数(core/gcj02.py)

core/gcj02.py 提供 wgs84_to_gcj02、gcj02_to_wgs84、convert_lat_lon 和 out_of_china;core/map_overlay.py 再用 convert_point / convert_polygon_rings 封装叠加数据转换。公式直觉、椭球参数、范围判断与逆向迭代过程见 coordinate_systems.md §5–§8。

10.3 Demo:共用视口 vs 图源 CRS

demo_settings.json 顶层 default_zoom / default_center 全局共用(不属于 profile)。切换 Google / 高德 / OSM / Bing 时相机用同一组数字,便于对比厂商偏移。

对象 切换图源时 说明
视口中心 / zoom 不做 GCJ↔WGS 数字原样进 MapController
矢量叠加 会按 tile_coord_ref 转换 数据 coord_ref → 底图 CRS 再投影
图源(典型 URL) tile_coord_ref(推断) 相对共用中心
OpenStreetMap wgs84 与国内 GCJ 底图对比时中心相对地面可偏数十~数百米
Google(gl=cn)、高德、Bing(demo 默认) gcj02 彼此通常更接近
显式 "tile_coord_ref" 按字段 覆盖 URL 推断

南京示例数据为 GCJ-02:在 gcj02 底图上恒等对齐;切到 OSM 时叠加层做 GCJ→WGS,视口数字不变。库不会在 apply_config 时改写视口;要「同一地面点」由应用自行 convert_lat_lon。


11. MapLibre PBF 矢量控件

pymapwidget/maplibre/ 是与现有栅格链路隔离的可选模块,第一版只实现 PySide6:

文件 职责
types.py MapLibreViewState、MapLibrePointerInfo、MapLibreClickEvent 公共值对象
overlay.py 将共享 MapPolygon / MapPoi 转换成 WGS84 GeoJSON
page.py 生成 Qt WebEngine host HTML;加载 MapLibre GL JS 与 qwebchannel.js
pyside6.py PySide6MapLibreWidget、WebChannel bridge、Python API 与 Qt signals

11.1 隔离边界

矢量控件不导入、不创建 MapController、TileManager、TileDownloader、TileCache、栅格 back buffer、MapConfig 或 tiles.db。

MapLibre GL JS 在 Chromium 进程内负责解析 Style JSON、选择/下载 MVT、overzoom、样式表达式、glyph/sprite、标签碰撞和 WebGL 合成。Qt WebEngine 使用自己的 Chromium 网络栈与 HTTP cache,因此栅格缓存设置、aiohttp proxy 和 MD5 拦截图配置均不适用于该控件。

pymapwidget.maplibre.__init__ 延迟导入控件:读取 MapLibreViewState 等纯 Python 类型不会强制安装 PySide6;只有访问 PySide6MapLibreWidget 时才加载 Qt WebEngine。

11.2 JavaScript 与 Python 事件流

Python build_maplibre_html(style/camera)
  → QWebEngineView.setHtml
  → qwebchannel.js 建立 pymapwidgetBridge
  → new maplibregl.Map(...)
  → load / moveend / mousemove / click / error
  → bridge.dispatch(name, JSON)
  → Python 值对象 + Qt signal + 可选 callback

Python 在 load 之前发出的 set_center、set_zoom、fit_bounds、set_style 会进入脚本队列,首次 ready 后按调用顺序执行。Python 同时先更新本地 camera 快照,使控件尚未 ready 时的 get_center() / get_zoom() 仍返回最近设置值;MapLibre 后续事件会用实际状态覆盖快照。

点击事件调用 queryRenderedFeatures,最多回传前 100 个 feature。为控制跨进程 JSON 体积,只保留来源、图层、geometry type、ID 和属性,不返回完整 geometry。

地图状态通过 moveend / resize 推送,并使用 unproject 计算真实屏幕四角。鼠标状态通过节流到最多约 10 Hz 的 mousemove 推送,不由 Python 定时轮询;页面根据首个可用 vector source 的 TileJSON 模板及 source zoom 范围计算光标所在的 z/x/y 和请求 URL,mouseleave 时推送空状态。Demo 直接绑定这些 callback,并支持按住 Ctrl 暂停鼠标字段更新。

瓦片分界线直接设置 MapLibre 的 showTileBoundaries,不在 Qt 表面重复绘制。行政区和 POI 复用 core 的 UI 无关数据模型:Python 将每个对象按其 coord_ref 转为 WGS84 GeoJSON,网页端维护一个 GeoJSON source,并分别用 fill、line、circle layer 渲染。POI layer 从 zoom 9 起显示并使用安全 DOM 文本构建悬停提示。set_style() 会清空 Style 内的自定义 source/layer,因此 style.load 事件会按 Python 已发送的数据恢复叠加层、填充显隐和瓦片边界。

11.3 坐标与前端资源

  • Python 公共 API:(lat, lon);MapLibre JS:[lon, lat],只在页面边界转换。
  • 坐标为 WGS84;该控件不做 GCJ-02 自动转换。
  • zoom 为浮点数,区别于栅格 MapController 的整数瓦片 zoom。
  • 默认前端资源为 unpkg 上的 MapLibre GL JS 5.x 路径;构造参数允许应用换成本地/固定版本 URL。
  • 字典 Style 中的相对 URL 按 base_url 解析;Style URL 自身的相对资源由 MapLibre 按该 Style URL 解析。

Demo:python demos/demo_maplibre_pyside6.py,默认加载 OpenFreeMap Liberty Style。

11.4 单瓦片 PBF → SVG / PNG 诊断 Demo

demos/pbf_to_image.py 用 mapbox-vector-tile 解码单个 HTTP(S) 或本地 MVT/PBF。解码时保留规范中的瓦片左上角原点、Y 轴向下坐标,再按各 source layer 的 extent 缩放到输出尺寸。由于 PBF 只有几何和属性、不包含完整 Style JSON,脚本按 layer 名称提供语义/确定性诊断配色:面先填充,随后画边和线,最后画点。

面向初学者的格式、坐标、绘制、标注和性能优化说明见 PBF / MVT 矢量瓦片入门。

  • SVG 直接输出 path / circle,保留矢量几何和 layer <title>。
  • PNG 使用 Pillow 以 2 倍尺寸绘制后 Lanczos 下采样;Polygon 洞通过每 feature mask 保留。
  • HTTP(S) 输入会在图片旁同时写入同名原始瓦片:URL 路径扩展名为 .mvt 时沿用 .mvt,否则使用 .pbf;网络数据只下载一次,gzip 响应保存解压后的 protobuf。本地 PBF/MVT 输入只生成图片,不复制源文件。
  • CLI 分别打印下载/本地读取、PBF 解析、SVG/PNG 几何生成、PNG 编码、图片/瓦片写入与总耗时。PNG 的面填充只为单个面的像素包围框分配局部 mask,避免为每个 feature 反复创建整张 2x 抗锯齿画布。
  • 地名优先读取 name:zh / name_zh,否则回退到 name。候选来自当前瓦片所有命名要素:已知 OpenMapTiles 图层按地点类型、rank、POI 类别和道路等级换算优先级,未知/自定义图层作为低优先级候选,因此低 zoom 与高 zoom 会自然使用不同内容。按图片尺寸限制最多约 40 个名称,并用估算文字框做碰撞过滤;SVG 使用系统字体族,PNG 优先自动加载微软雅黑、黑体、Noto CJK 或苹方,并给文字加白色描边。
  • 缺少参数时在终端分别询问 PBF URL/路径和输出路径。
  • 输出扩展名只接受 .svg / .png;--size 默认 1024。
pip install -e ".[pbf-image]"
python demos/pbf_to_image.py --pbf "<PBF_URL>" --output-image tile.svg
python demos/pbf_to_image.py  # 交互输入

12. 预留扩展

  • 缓存 LRU / 容量上限清理
  • ETag / 304 条件请求
  • 轨迹 overlay / 更丰富的编辑交互
  • GPS 定位
  • 从 SQLite 扫描重建索引

downloaded_at 字段已为按时间清理预留。


13. 依赖

包 用途
aiohttp 异步 HTTP 下载
Pillow 占位图、Tkinter 图像
PyQt5 可选 PyQt5 后端(含矢量叠加)
PyQt6 可选 PyQt6 后端(含矢量叠加)
PySide6 可选 PySide6 栅格后端;Qt WebEngine/WebChannel 也用于独立 MapLibre 控件
mapbox-vector-tile 可选;单瓦片 PBF → SVG/PNG demo 的 MVT 解码
pytest 可选测试

Python ≥ 3.10;tkinter / sqlite3 为标准库。类型注解优先使用 3.10+ 写法(X | None、list[str]、collections.abc.Callable、typing.TypeAlias / Protocol)。