中文 | English
本文档描述架构、模块职责,以及地图加载、瓦片下载、UI 重绘的完整运行时逻辑。Agent 修改实现时请同步更新本文(见 AGENTS.md)。
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)。
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
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);仅在上一开关为真时使用 |
本模块实现 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。
输入 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 后放置另一侧拷贝。世界边缘处缓冲圈自然变窄。
{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 混用不同地图源时,后一来源会直接复用前一来源相同坐标的瓦片,可能出现底图混用或切换来源后仍显示旧图。库不会自动迁移、清空或划分来源命名空间;应用必须为不同来源配置不同缓存目录,或在切换前自行清空缓存。
表 tiles 主键 (z, x, y),存相对路径 rel_path(禁止存绝对路径)。
- lookup:先查 SQLite → 校验文件存在、size 匹配且前 32 字节包含受支持的图片魔数 → 返回
Path;非图片文件会失效并删除。若配置了非空blocked_tile_md5,lookup 才会再读全文并按 MD5 剔除已知拦截图;命中时删除缓存文件并抛BlockedTileError(不当作普通缺失)。黑名单为空时不读整文件。 - 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。
- 独立 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。
- 至少 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。
代码里的字段名叫 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:
- 把缓存 lookup 投递到
_cache_executor;缓存命中 → 回调on_tile_ready(key, path, generation, retry=True)。 - 已经在下载中 → 只更新
_pending[key] = generation(不重复发起网络请求)。 - 已在
_blocked→ 清掉 pending,不再下载。 - 否则 → 记下
_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)时:
- 有数据则写入 cache;
put/lookup抛BlockedTileError时记入_blocked,回调retry=False。 - 从
_pending取出该瓦片请求时的版本号。 - 若与当前
generation不一致 → 当作过期结果丢弃(日志里会写tile ready dropped)。 - 一致 → 调用
on_tile_ready(key, path, generation, retry),进入 UI 更新链。
已不在当前 placement 里的 key,会从 _pending 里清掉。
| 变量 | 含义 |
|---|---|
center_lat, center_lon, zoom |
逻辑中心与缩放级别 |
offset_x, offset_y |
相对屏幕中心的像素偏移(拖拽时先改 offset) |
_generation |
与 TileManager.generation 同步的视图版本号 |
_placements |
当前需要的瓦片及其屏幕位置 |
_tile_paths |
已就绪瓦片的本地文件路径 |
_defer_tile_patches |
拖拽 pan_by 期间为 True:缓存命中只记路径,不立刻通知 UI 单瓦片补图 |
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 等路径触发。
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 → 合成占位图帧"]
- Backend 构造
MapController,注册on_map_changed、on_tile_updated、main_thread_invoker。 - 首次
set_size,或窗口Configure/resizeEvent,会调用refresh_tiles()。 _sync_viewport(bump_generation=True):- 视图版本号加一;
- 按当前中心、offset、窗口大小算出需要的
_placements(含视口外一圈缓冲瓦片); - 丢掉已不在 placement 里的
_tile_paths; - 对全部 placement key 查缓存或开始下载。
on_map_changed→ Backend_compose_back_buffer():已有图的瓦片贴图,还没有的先画灰色占位图。
拖拽时 Backend 的 _pan_drag:先让 Core 改状态,再看瓦片集合有没有变(详见 §5.4)。
拖着鼠标移动时(每一小步 B1-Motion / mouseMoveEvent):
- 记下拖拽前的瓦片 key 集合
old_keys。 - 调用
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 的单瓦片补丁(避免一帧里刷几十次)。
- 比较
new_keys与old_keys:- 没变:离屏大缓冲里的像素不动;屏幕显示时用当前 offset 相对「上次合成时的 offset」去裁剪取样(§5.4),看起来地图跟着鼠标走。
- 变了(换列/换行):整页
buffer compose重画缓冲圈上的全部瓦片,并记下新的buffer_sync_offset;日志打tile set change。
- 无论 key 变不变,都做一次
view blit,把缓冲里对应视口的部分画到控件上。 - 之后若有异步下载完成:在缓冲坐标上
buffer patch单块瓦片;若这块落在视口内,再合并刷新屏幕(§5.5)。
松开鼠标(end_pan):
_normalize_offset():若|offset|已达到或超过一块瓦片边长(通常 256px),把整瓦片部分折进center_lat/lon,offset 收回到不足一块的范围。- 若折叠后中心/offset 有变化:再
_sync_viewport,并通知 UI 全量buffer compose+view blit。 - 若折叠什么也没改:直接返回。拖拽过程中屏幕已经用裁剪取样对齐了,不必再画一遍。
zoom_at(screen_x, screen_y, delta):- 先算出鼠标下那一点的经纬度;
- 改
zoom,把该点设为新的逻辑中心,并设好 offset,使缩放后鼠标仍钉在同一地理位置。
refresh_tiles()→ 视图版本号加一 → 旧缩放级别上尚未返回的下载全部作废。- 按新级别重新计算 placement,并请求整屏(加缓冲圈)瓦片。
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
要点:
- 下载在 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。
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。
所有 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] |
对 controller.get_placements()(含缓冲环,用于定尺寸与预取):
- 用
export_bounds(placements, tile_size)计算含缓冲环的边界(placement 坐标可含负值); - 创建 大于视口 的离屏图,尺寸 =
bounds.width × bounds.height,先填底色; - 只绘制与视口相交的瓦片(约 5×7,不含最外一圈);圈外留给预取,不立即画进缓冲;
- 查
get_tile_path(key)或内存缓存;有文件 → 解码;无文件 → 占位图; - 记录
_compose_placements、_painted_keys与buffer_sync_offset,供后续 lazy patch / viewport 取样; - 可选:勾选 demo「显示瓦片分界线」时,对已绘制瓦片按网格 extent 画 1px 长线。
拖拽使圈外瓦片进入视口时:_ensure_visible_painted 用 unpainted_visible_buffer_targets 批量 buffer patch(日志 lazy +N),再 view blit。
| 场景 | 缓冲层 | 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。
启用 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)— 拖拽每一步
本节描述最常见的「对角拖过一格瓦片边界」场景:placement key 集合发生变化,需要全量重合成离屏缓冲,并统计新增 key 的缓存/下载情况。
视口在可见矩形外保留 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(取决于窗口宽高与缓冲圈尺寸)。
离屏缓冲在上次 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 是更大的整圈缓冲。
为什么要做成大缓冲? 若缓冲只有视口那么大,再把整张图平移,边缘会露出从来没画过的灰边。当前做法是:缓冲先含一圈余量,显示时只裁剪取样。
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
Core 侧(key 变化时) 对 新增 key 调用 request_tiles;此外会加入失败退避已到期的当前 key。
Backend 侧(key 变化):buffer compose 仍按缓冲环定尺寸,但只绘制可见瓦片(日志 painted=M/N),更新 buffer_sync_offset / _painted_keys。
全量 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
| 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)。
缓冲圈(约 63 瓦片)大于可见 viewport(约 35 瓦片)。瓦片 ready 时:
- 解码进内存瓦片缓存(供日后 lazy 绘制)。
- 仅当当前可见时才
buffer patch并调度view blit;圈外 ready 不写离屏缓冲。 - 之后 pan 使该瓦片可见时,由
_ensure_visible_painted批量补画(此时内存里往往已有图)。 - 合并绘制:可见瓦片的 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。
ImageTk.PhotoImage必须在主线程创建,并保持引用防 GC。main_thread_invoker→_main_queue+_pump_main_queue。- 提升叠加控件时显式调用窗口级
tk.Misc.lift;Canvas.lift实际是图元tag_raise,不能用于提升 Canvas 控件本身。 - 即使缓存文件在 lookup 后被外部损坏,
Image.open失败也会安全回退到占位图。
QPixmap在主线程从文件加载。main_thread_invoker→_dispatch_to_main.emit(fn)。- 除
closeEvent外还单次绑定destroyed信号;可更新 holder 始终指向当前 controller,因此反复apply_config()不会堆积槽或旧 controller 引用。
- 功能与公开方法对齐
PyQtMapWidget,分别使用 PyQt6 的pyqtSignal与 PySide6 的Signal。 - 矢量叠加与 PyQt5 共用
qt_vector_overlay.py(各绑定一份薄封装)。 - 使用 Qt 6 scoped enum(如
Qt.MouseButton.LeftButton)和QMouseEvent.position()。 - 两个 binding 为独立可选依赖;应用应只创建与自身 Qt binding 对应的控件。
- 几何:
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);请求/响应写日志,不弹窗。
- 创建
MapController,实现main_thread_invoker(线程安全投递到 GUI 线程)。 on_map_changed→ 离屏合成 + 刷新控件。on_tile_updated→ 加载该瓦片图像后重新合成。- 鼠标/滚轮/resize 转发给
pan_by/end_pan/zoom_at/set_size。 - 对外 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等。
| 文件 | 命令 |
|---|---|
| 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 |
- 路径:项目根目录
demo_settings.json(不存在则生成默认 Google + OSM + Bing 分组)。 - 某后端没有窗口尺寸配置时,默认使用
1200 × 800;已有明确保存的尺寸保持不变。 - 不迁移、不兼容旧路径
~/.pymapwidget/demo_settings.json。 - 已有配置文件不会自动追加新默认分组;删除文件后首次运行会按当前默认重新生成。
- 布局:左侧配置面板,右侧地图。
- 地图状态:首行合并
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。
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 文件规则 |
本节说明行政区高亮与 POI 的分层模型、数据形态与视口行为,并辅以南京 Demo 中的具体坐标。公共 API 见 api.md §3.5;坐标系约定见 §10。
底图仍由 PNG 瓦片构成(Google / 高德 / OSM 等)。 行政区高亮与 POI 不修改瓦片像素,也不对单块瓦片做「是否属于南京」之类的归属判断。
类比:底图相当于已印刷的底图;行政区是其上的矢量描边/填色层,POI 是按经纬度锚定的点标记。 平移、缩放时,叠加几何随经纬度重投影,边界保持为折线矢量,而不会因「烤进某一级瓦片」而在放大后出现像素块。
┌──────────────────────────────────────┐
│ 叠加层:区界折线 + 半透明填色 + POI │ ← 本库绘制
├──────────────────────────────────────┤
│ 底图:瓦片 PNG(道路、图商地名标注等) │ ← 图源提供
└──────────────────────────────────────┘
以「南京博物院」为例:
| 字段 | 值 | 含义 |
|---|---|---|
| lat / lon | 32.040802 / 118.825064 |
纬度 / 经度(GCJ-02,与高德地图网页 POI 一致) |
| coord_ref | gcj02 |
数据自身的坐标语义为 GCJ-02 |
绘制步骤:
- 若
tile_coord_ref同为gcj02(高德、国内 Google)→ 坐标直接使用;若为 OSM(wgs84)→ 先转换为 WGS84 再投影。 - 按当前整数缩放级别(如 z=15)将
(lat, lon)投影为屏幕点,绘制固定像素大小的圆点。 - 悬停时在标记正上方显示气泡 tip(底部三角指向标记;贴近视口上沿时改到标记下方)。
底图瓦片上的「南京博物院」文字由图源制图决定。叠加 POI 与之对齐,依赖正确坐标 + 与底图一致的 CRS,而非解析瓦片图像内容。
数据来自 DataV 南京市辖区边界(nanjing_districts.json),按区县拆分:玄武区、秦淮区、雨花台区、江宁区等各对应一个或多个 MapPolygon(MultiPolygon 会拆成多段)。
Demo 不维护「南京市外轮廓」的单一合并面。 界面上呈现的整片浅色高亮,是各区多边形各自填色后的视觉叠加;相邻两区在共享边界处各有一条边,看起来像一条分界线。
示例(牛首山附近,约 31.903309, 118.744150):
- 放大后视口内常同时包含 江宁区 与 雨花台区(雨花台北侧有一块向南伸入江宁的凸出部)。
- 叠加层分别为两区构建折线路径并填色。
- 不会对瓦片索引(如
(z=15, x=27192, y=13316))做「是否落在南京」判定:瓦片仅是底图分块;高亮只依赖多边形顶点。
| 误解 | 实际行为 |
|---|---|
| 对每个瓦片判断是否在南京市内再染色 | 不对瓦片做行政区归属 |
| 先将全市合并为单一多边形再计算 | Demo 按区县分条加载,无全市合并面 |
高亮写入 tiles.db |
高亮仅存在于运行时叠加层,不进入瓦片缓存 |
缩放(整数 zoom 变化) 同一经纬度在 z=11 与 z=15 下对应不同的世界像素坐标,因此需重建叠加层折线与 POI。地理顶点不变;缩放改变的是投影后的像素密度,边界仍为矢量折线。
平移(pan)
多数情况下只需更新视口对齐,不必重算全部几何。
实现上仅将与「当前视口 + 约 35% 边距缓冲」相交的区 / POI 加入叠加层:视口仍在缓冲内时只调整 sceneRect;移出缓冲后重建,以纳入新进入视野的要素。
过小比例尺 全部区县包围盒在屏幕上的跨度小于约 28 像素时,隐藏区界与 POI,避免轮廓挤成一团。 POI 另要求 zoom ≥ 9 才显示。
「行政区域高亮」开关
开启:半透明填色 + 描边。
关闭:仅描边(分界线)。
各区使用各自的 fill_color;不按「视口是否几乎完全落在某一区内」动态降低该区透明度,以免区界附近出现相邻区填色不一致。
坐标系区别、错位原因和转换原理见 WGS84、GCJ-02 与坐标转换入门。本节只记录叠加层约定:
约定:
- 叠加数据声明自身
coord_ref(Demo 行政区与多数 POI 为gcj02)。 - 底图声明
tile_coord_ref(高德 / 国内 Google 多为gcj02,OSM 为wgs84)。 - 绘制前将数据转换到与底图相同的坐标语义,再投影。
项目转换位置与 Demo 行为见 §10;Demo POI 坐标来源见 demos/data/README.md。
| 项目 | 位置 / 职责 |
|---|---|
| 数据模型 | 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。
坐标系概念、偏移原因和转换算法见 WGS84、GCJ-02 与坐标转换入门;公共调用契约见 api.md §6。本节只记录项目内部的转换位置与 Demo 行为。
本库不修改 Web Mercator 公式。叠加层在 lat_lon_to_pixel 之前,用 tile_coord_ref 把数据 CRS 对齐到底图语义:map_overlay.convert_* → gcj02.convert_lat_lon → Web Mercator 世界像素。
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。
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。
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 |
矢量控件不导入、不创建 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。
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 已发送的数据恢复叠加层、填充显隐和瓦片边界。
- 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。
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 # 交互输入- 缓存 LRU / 容量上限清理
- ETag / 304 条件请求
- 轨迹 overlay / 更丰富的编辑交互
- GPS 定位
- 从 SQLite 扫描重建索引
downloaded_at 字段已为按时间清理预留。
| 包 | 用途 |
|---|---|
| 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)。