Files
MapStudio/server.py
dearsky a36b14dc45 上传文件至「/」
将所有图层处理为瓦片,并新增瓦片管理能力
2026-11-09 00:01:21 +08:00

6675 lines
270 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Local map service with disk caching for Map Studio.
The browser only talks to this server. The server serves static project files,
proxies map/DEM tiles and Overpass railway/water queries, and caches upstream data.
It uses only the Python standard library.
"""
from __future__ import annotations
import argparse
import base64
import gzip
import hashlib
import hmac
import json
import math
import mimetypes
import os
import queue
import re
import secrets
import shutil
import struct
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zlib
from dataclasses import dataclass
from http import HTTPStatus
from http import cookies
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent
DEFAULT_CACHE_DIR = ROOT / "cache"
# Tunable worker counts. Manual download workers fetch tiles concurrently; HTTP
# response threads and short-lived upstream timeout workers are separate.
CACHE_REFRESH_WORKERS = 16
VECTOR_BACKGROUND_WORKERS = 16
VECTOR_TILE_REQUEST_WORKERS = 24
RAIL_AGGREGATE_WORKERS = 4
RAIL_AGGREGATE_FETCH_WORKERS = 16
TIANDITU_DOWNLOAD_WORKERS = 8
OPENMAP_DOWNLOAD_WORKERS = 16
DOWNLOAD_STAGING_DIR_NAME = ".download-tmp"
STALE_DOWNLOAD_STAGING_SECONDS = 24 * 60 * 60
DEFAULT_TTL_SECONDS = 180 * 24 * 60 * 60
DEFAULT_RAIL_TTL_SECONDS = 180 * 24 * 60 * 60
DEFAULT_REFRESH_SECONDS = 180 * 24 * 60 * 60
USER_AGENT = "MapStudioLocalMapService/1.0"
LONG_BROWSER_CACHE = "public, max-age=15552000, immutable"
MAX_VECTOR_TILES = 1200
MAX_REFRESH_TILES = 512
OPENMAP_TILE_LOCK_SECONDS = 110
OPENMAP_VECTOR_FETCH_SECONDS = 52
OPENMAP_RASTER_FETCH_SECONDS = 24
OPENMAP_LONG_RASTER_FETCH_SECONDS = 40
OPENMAP_UPSTREAM_ATTEMPT_SECONDS = 8
OPENMAP_JOB_STALL_SECONDS = 150
CACHE_ONLY_SETTING_NAME = "cache-only.setting"
UPSTREAM_PROXY_SETTING_NAME = "upstream-proxy.setting"
CACHE_COVERAGE_INDEX_NAME = ".cache-admin-coverage.json"
CACHE_COVERAGE_INDEX_VERSION = 3
CACHE_COVERAGE_TILE_SUFFIXES = {
"raster": {".png"},
"tianditu": {".png"},
"vector": {".pbf"},
"rail-derived": {".json"},
"dynamic-vector": {".json"},
}
CACHE_COVERAGE_RESPONSE_LAYERS: dict[str, list[dict[str, str]]] = {
"rail": [
{
"id": "rail-national",
"key": "national",
"label": "国铁线路瓦片",
"description": "按 OpenFreeMap 来源 Z/X/Y 瓦片生成;空白只表示当前没有已索引瓦片。",
},
{
"id": "rail-metro",
"key": "metro",
"label": "地铁 / 轻轨瓦片",
"description": "按 OpenFreeMap 来源 Z/X/Y 瓦片生成;空白只表示当前没有已索引瓦片。",
},
],
"environment": [
{
"id": "environment-water",
"key": "water",
"label": "水域瓦片",
"description": "按 OpenFreeMap 来源 Z/X/Y 瓦片生成;空白只表示当前没有已索引瓦片。",
},
{
"id": "environment-vegetation",
"key": "vegetation",
"label": "植被瓦片",
"description": "按 OpenFreeMap 来源 Z/X/Y 瓦片生成;空白只表示当前没有已索引瓦片。",
},
{
"id": "environment-ports",
"key": "ports",
"label": "港口瓦片",
"description": "按 OpenFreeMap 来源 Z/X/Y 瓦片生成;空白只表示当前没有已索引瓦片。",
},
],
}
JSON_LATITUDE_RE = re.compile(rb'"lat"\s*:\s*(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)')
JSON_LONGITUDE_RE = re.compile(rb'"lon"\s*:\s*(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)')
class CacheOnlyMode(RuntimeError):
pass
def normalize_upstream_proxy(value: str) -> str:
value = str(value or "").strip()
if not value:
return ""
if any(ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError("代理地址包含无效控制字符")
parsed = urllib.parse.urlsplit(value)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError("代理地址必须是 http:// 或 https:// 地址")
if parsed.path not in ("", "/") or parsed.query or parsed.fragment:
raise ValueError("代理地址不能包含路径、查询参数或片段")
try:
parsed.port
except ValueError as error:
raise ValueError("代理地址端口无效") from error
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/")
def display_upstream_proxy(value: str) -> str:
if not value:
return ""
parsed = urllib.parse.urlsplit(value)
host = parsed.hostname or ""
if ":" in host and not host.startswith("["):
host = f"[{host}]"
port = f":{parsed.port}" if parsed.port else ""
return f"{parsed.scheme}://{host}{port}"
def build_placeholder_tile(size: int = 256) -> bytes:
rows = bytearray()
for y in range(size):
rows.append(0)
for x in range(size):
checker = ((x // 32) + (y // 32)) % 2
color = (232, 236, 232) if checker else (219, 226, 220)
if x < 3 or y < 3 or x >= size - 3 or y >= size - 3:
color = (174, 188, 178)
in_cross = (112 <= x <= 143 and 72 <= y <= 183) or (72 <= y <= 103 and 52 <= x <= 203)
if in_cross:
color = (111, 132, 119)
rows.extend(color)
def chunk(tag: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(bytes(rows), level=6))
+ chunk(b"IEND", b"")
)
PLACEHOLDER_TILE_PNG = build_placeholder_tile()
def resolve_gis_dependencies() -> Path:
candidates: list[Path] = []
configured = os.environ.get("MAP_STUDIO_GIS_DEPS", "").strip()
if configured:
candidate = Path(configured).expanduser()
if not candidate.is_absolute():
candidate = ROOT / candidate
candidates.append(candidate)
candidates.extend((ROOT / "_gisread", ROOT.parent / "_gisread"))
for candidate in candidates:
resolved = candidate.resolve()
if resolved.is_dir():
return resolved
return candidates[0].resolve()
DYNAMIC_VECTOR_DEPENDENCIES = resolve_gis_dependencies()
_dynamic_geometry_lock = threading.Lock()
_dynamic_geometry_backend: dict[str, Any] | None = None
PUBLIC_STATIC_FILES = {
"/index.html",
"/styles.css",
"/app.js",
"/coordinate-systems.js",
"/compliance-gate.js",
"/api-comparison.html",
"/readme.md",
"/project_documentation.md",
"/print-block.js",
"/sw.js",
"/tianditu-admin.html",
"/openmap-admin.html",
"/cache-admin.html",
"/regulations.html",
}
PUBLIC_STATIC_DIRS = {"vendor"}
TILE_PROVIDERS: dict[str, dict[str, Any]] = {
"clean": {
"urls": [
"https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}",
],
"content_type": "image/png",
},
"clean-labels": {
"urls": [
"https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}",
],
"content_type": "image/png",
},
"osm": {
"urls": [
"https://tile.openstreetmap.de/{z}/{x}/{y}.png",
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
],
"content_type": "image/png",
},
"opentopo": {
"urls": [
"https://a.tile.opentopomap.org/{z}/{x}/{y}.png",
"https://b.tile.opentopomap.org/{z}/{x}/{y}.png",
"https://c.tile.opentopomap.org/{z}/{x}/{y}.png",
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
],
"content_type": "image/png",
},
"topo-preview": {
"urls": [
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
],
"content_type": "image/jpeg",
},
"satellite": {
"urls": [
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
"https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
],
"content_type": "image/jpeg",
},
"railway": {
"urls": [
"https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png",
],
"content_type": "image/png",
},
"dem": {
"urls": [
"https://elevation-tiles-prod.s3.amazonaws.com/terrarium/{z}/{x}/{y}.png",
],
"content_type": "image/png",
},
}
for provider_name, layer_name, label in (
("tdt-vec", "vec", "天地图矢量底图"),
("tdt-vec-label", "cva", "天地图矢量注记"),
("tdt-img", "img", "天地图影像底图"),
("tdt-img-label", "cia", "天地图影像注记"),
("tdt-ter", "ter", "天地图地形底图"),
("tdt-ter-label", "cta", "天地图地形注记"),
("tdt-boundary", "ibo", "天地图行政区划"),
):
TILE_PROVIDERS[provider_name] = {
"urls": [
"https://t0.tianditu.gov.cn/"
+ layer_name
+ "_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER="
+ layer_name
+ "&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}"
],
"content_type": "image/png",
"requires_key": True,
"manual_only": True,
"label": label,
"tianditu_layer": layer_name,
}
OVERPASS_ENDPOINTS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
"https://overpass.monicz.dev/api/interpreter",
]
OPENFREEMAP_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
OPENFREEMAP_FALLBACK_TILE_URL = (
"https://tiles.openfreemap.org/planet/20261101_080013_pt/{z}/{x}/{y}.pbf"
)
OPENFREEMAP_SOURCE_MIN_ZOOM = 3
OPENFREEMAP_SOURCE_MAX_ZOOM = 14
ENVIRONMENT_MIN_ZOOM = 3
ENVIRONMENT_MAX_ZOOM = 16
OPEN_MAP_PROVIDERS: dict[str, dict[str, Any]] = {
"osm": {
"label": "OpenStreetMap 标准地图",
"kind": "raster",
"provider": "osm",
"minZoom": 1,
"maxZoom": 19,
"source": "OpenStreetMap",
"license": "ODbL",
"description": "OpenStreetMap 标准栅格底图",
},
"opentopo": {
"label": "OpenTopoMap 地形图",
"kind": "raster",
"provider": "opentopo",
"minZoom": 1,
"maxZoom": 17,
"source": "OpenTopoMap / OpenStreetMap",
"license": "CC-BY-SA / ODbL",
"description": "开放地形、等高线与道路渲染",
},
"railway": {
"label": "OpenRailwayMap 铁路基础设施",
"kind": "raster",
"provider": "railway",
"minZoom": 1,
"maxZoom": 19,
"source": "OpenRailwayMap / OpenStreetMap",
"license": "ODbL",
"description": "铁路、站场、信号等基础设施叠加瓦片",
},
"dem": {
"label": "AWS Terrain Tiles 开放地形数据",
"kind": "raster",
"provider": "dem",
"minZoom": 3,
"maxZoom": 15,
"source": "AWS Open Data / Terrarium",
"license": "开放地形数据",
"description": "供等高线、地形晕渲和视线分析使用",
},
"openfreemap": {
"label": "OpenFreeMap 全球矢量专题",
"kind": "vector",
"provider": None,
"minZoom": OPENFREEMAP_SOURCE_MIN_ZOOM,
"maxZoom": OPENFREEMAP_SOURCE_MAX_ZOOM,
"source": "OpenFreeMap / OpenStreetMap",
"license": "ODbL",
"description": "水域、植被、港口、铁路和行政边界原始 MVT",
},
"clean": {
"label": "简洁底图(Esri)",
"kind": "raster",
"provider": "clean",
"minZoom": 1,
"maxZoom": 19,
"source": "Esri Light Gray Canvas",
"license": "Esri Terms of Use",
"description": "主地图简洁底图;离线使用时建议同时选择简洁底图注记",
},
"clean-labels": {
"label": "简洁底图注记(Esri)",
"kind": "raster",
"provider": "clean-labels",
"minZoom": 1,
"maxZoom": 19,
"source": "Esri Light Gray Reference",
"license": "Esri Terms of Use",
"description": "简洁底图上的道路和地名注记",
},
"topo-preview": {
"label": "地形图(Esri)",
"kind": "raster",
"provider": "topo-preview",
"minZoom": 1,
"maxZoom": 13,
"source": "Esri World Topographic Map",
"license": "Esri Terms of Use",
"description": "主地图地形底图;原生最高 Z13",
},
"satellite": {
"label": "卫星影像(Esri)",
"kind": "raster",
"provider": "satellite",
"minZoom": 1,
"maxZoom": 19,
"source": "Esri World Imagery",
"license": "Esri Terms of Use",
"description": "主地图卫星影像底图",
},
}
CACHE_ADMIN_CATEGORIES: dict[str, dict[str, Any]] = {
"raster": {
"label": "底图、注记与地形瓦片",
"description": "主地图和开放地图后台使用的非天地图栅格、DEM 与预览瓦片。",
"refreshable": True,
},
"tianditu": {
"label": "天地图手动离线瓦片",
"description": "由天地图下载后台手动获取的离线瓦片;更新请使用天地图下载后台。",
"refreshable": False,
},
"vector": {
"label": "OpenFreeMap 全球原始矢量瓦片",
"description": "水域、植被、港口、铁路和行政边界使用的全球 MVT 源瓦片;实际显示范围取决于已缓存瓦片。",
"refreshable": True,
},
"rail": {
"label": "铁路查询响应集合",
"description": "按视窗保存的铁路专题响应集合;管理页将国铁和地铁拆分为两个可单独管理的入口。",
"refreshable": True,
},
"environment": {
"label": "环境查询响应集合",
"description": "按视窗保存的环境专题响应集合;管理页将水域、植被和港口拆分为三个可单独管理的入口。",
"refreshable": True,
},
"boundaries": {
"label": "行政边界响应",
"description": "由本服务从 OpenFreeMap 行政边界矢量瓦片生成并缓存的视窗响应,不依赖外部本地 API。",
"refreshable": False,
},
"rail-derived": {
"label": "派生铁路瓦片",
"description": "由矢量源生成的国铁和地铁本地瓦片;删除后按地图请求自动重建。",
"refreshable": False,
},
"dynamic-vector": {
"label": "按需裁切矢量瓦片",
"description": "1:100 万和 1:25 万高缩放按需裁切缓存;删除后自动重建。",
"refreshable": False,
},
"pending": {
"label": "待重试任务",
"description": "铁路和环境查询失败后保存的待重试队列。",
"refreshable": False,
},
"staging": {
"label": "下载暂存残留",
"description": "下载中的 .part 临时文件;仅在没有运行中的下载任务时删除。",
"refreshable": False,
},
}
CACHE_ADMIN_QUERY_LAYERS: dict[str, dict[str, Any]] = {
"rail-national": {
"parent": "rail",
"key": "national",
"label": "国铁线路瓦片",
"description": "按 OpenFreeMap 全球铁路来源 Z/X/Y 瓦片索引;可查看、更新和下载这些来源瓦片。删除专题只移除本专题索引,共享原始瓦片仍由 OpenFreeMap 原始瓦片统一管理。",
"refreshable": True,
},
"rail-metro": {
"parent": "rail",
"key": "metro",
"label": "地铁 / 轻轨瓦片",
"description": "按 OpenFreeMap 全球交通来源 Z/X/Y 瓦片索引地铁、轻轨、单轨和有轨电车;可查看、更新和下载来源瓦片。",
"refreshable": True,
},
"environment-water": {
"parent": "environment",
"key": "water",
"label": "水域瓦片",
"description": "按 OpenFreeMap 全球水域来源 Z/X/Y 瓦片建立索引;可查看、更新和下载这些来源瓦片。",
"refreshable": True,
},
"environment-vegetation": {
"parent": "environment",
"key": "vegetation",
"label": "植被瓦片",
"description": "按 OpenFreeMap 全球地表覆盖来源 Z/X/Y 瓦片建立索引;可查看、更新和下载这些来源瓦片。",
"refreshable": True,
},
"environment-ports": {
"parent": "environment",
"key": "ports",
"label": "港口瓦片",
"description": "按 OpenFreeMap 港口来源 Z/X/Y 瓦片建立索引;可查看、更新和下载这些来源瓦片。",
"refreshable": True,
},
"boundaries": {
"parent": "boundaries",
"key": "boundaries",
"label": "行政边界瓦片",
"description": "按 OpenFreeMap 行政边界来源 Z/X/Y 瓦片建立索引,不依赖外部本地 API;可查看、更新和下载这些来源瓦片。",
"refreshable": True,
},
}
CACHE_ADMIN_MANAGEMENT_ORDER = [
"raster",
"tianditu",
"vector",
"rail-national",
"rail-metro",
"environment-water",
"environment-vegetation",
"environment-ports",
"boundaries",
"rail-derived",
"dynamic-vector",
"pending",
"staging",
]
TILE_PATH_RE = re.compile(r"^/tiles/([a-z0-9-]+)/(\d+)/(\d+)/(\d+)\.png$")
LOCAL_VECTOR_PATH_RE = re.compile(r"^/api/local-vector/(\d+)/(\d+)/(\d+)\.json$")
LOCAL_VECTOR_25_PATH_RE = re.compile(r"^/api/local-vector-25/(\d+)/(\d+)/(\d+)\.json$")
RAIL_TILE_PATH_RE = re.compile(r"^/api/rail-tiles/(\d+)/(\d+)/(\d+)\.json$")
KNOWN_TOP_LEVEL = {
"index.html",
"styles.css",
"app.js",
"compliance-gate.js",
"api-comparison.html",
"README.md",
"PROJECT_DOCUMENTATION.md",
"print-block.js",
"sw.js",
"tianditu-admin.html",
"openmap-admin.html",
"cache-admin.html",
"regulations.html",
"tianditu-key.example.txt",
"admin-password.example.txt",
"admin-password.example.txt",
"start-map-studio.bat",
"vendor",
}
class RailPending(Exception):
def __init__(self, cache_key: str) -> None:
super().__init__("Rail request queued for background retry")
self.cache_key = cache_key
class WaterPending(Exception):
def __init__(self, cache_key: str) -> None:
super().__init__("Water request queued for background retry")
self.cache_key = cache_key
class TileNotCached(Exception):
pass
class TiandituKeyMissing(Exception):
pass
ATOMIC_STAGING_DIR: Path | None = None
def configure_atomic_staging(cache_dir: Path) -> Path:
"""Place incomplete files outside the cache tree until they are complete."""
global ATOMIC_STAGING_DIR
staging_dir = cache_dir.resolve() / DOWNLOAD_STAGING_DIR_NAME
staging_dir.mkdir(parents=True, exist_ok=True)
cutoff = time.time() - STALE_DOWNLOAD_STAGING_SECONDS
for leftover in staging_dir.glob("*.part"):
try:
if leftover.stat().st_mtime < cutoff:
leftover.unlink()
except OSError:
pass
ATOMIC_STAGING_DIR = staging_dir
return staging_dir
def atomic_write(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
staging_dir = ATOMIC_STAGING_DIR
temporary = (staging_dir or path.parent) / (
f"{path.name}.{os.getpid()}-{threading.get_ident()}-{uuid.uuid4().hex}.part"
)
try:
with temporary.open("wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
try:
temporary.unlink(missing_ok=True)
except OSError:
pass
def detect_image_content_type(data: bytes, fallback: str) -> str:
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
return "image/gif"
if len(data) > 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return fallback
def _decode_varint(data: bytes, offset: int) -> tuple[int, int]:
value = 0
shift = 0
while offset < len(data) and shift <= 70:
byte = data[offset]
offset += 1
value |= (byte & 0x7F) << shift
if not byte & 0x80:
return value, offset
shift += 7
raise ValueError("Invalid protobuf varint")
def _decode_zigzag(value: int) -> int:
return (value >> 1) ^ -(value & 1)
def _protobuf_fields(data: bytes):
offset = 0
while offset < len(data):
key, offset = _decode_varint(data, offset)
field_number = key >> 3
wire_type = key & 7
if wire_type == 0:
value, offset = _decode_varint(data, offset)
elif wire_type == 1:
value = data[offset : offset + 8]
offset += 8
elif wire_type == 2:
length, offset = _decode_varint(data, offset)
value = data[offset : offset + length]
offset += length
elif wire_type == 5:
value = data[offset : offset + 4]
offset += 4
else:
raise ValueError(f"Unsupported protobuf wire type: {wire_type}")
yield field_number, wire_type, value
def _decode_mvt_value(data: bytes):
for field_number, wire_type, value in _protobuf_fields(data):
if field_number == 1 and wire_type == 2:
return bytes(value).decode("utf-8", errors="replace")
if field_number == 2 and wire_type == 5:
return struct.unpack("<f", value)[0]
if field_number == 3 and wire_type == 1:
return struct.unpack("<d", value)[0]
if field_number in (4, 5) and wire_type == 0:
return value
if field_number == 6 and wire_type == 0:
return _decode_zigzag(value)
if field_number == 7 and wire_type == 0:
return bool(value)
return None
def _decode_mvt_geometry(values: list[int], geometry_type: int) -> list[list[tuple[int, int]]]:
paths: list[list[tuple[int, int]]] = []
current: list[tuple[int, int]] | None = None
x = 0
y = 0
index = 0
while index < len(values):
command_integer = values[index]
index += 1
command = command_integer & 7
count = command_integer >> 3
if command in (1, 2):
if command == 1:
current = []
paths.append(current)
elif current is None:
current = []
paths.append(current)
for _ in range(count):
if index + 1 >= len(values):
break
x += _decode_zigzag(values[index])
y += _decode_zigzag(values[index + 1])
index += 2
current.append((x, y))
elif command == 7 and current and geometry_type == 3:
if current[0] != current[-1]:
current.append(current[0])
else:
break
return [path for path in paths if path]
def _decode_mvt_feature(
data: bytes,
keys: list[str],
values: list[Any],
extent: int,
) -> dict[str, Any] | None:
tags: list[int] = []
geometry: list[int] = []
geometry_type = 0
feature_id: int | None = None
for field_number, wire_type, value in _protobuf_fields(data):
if field_number == 1 and wire_type == 0:
feature_id = value
elif field_number == 2:
if wire_type == 2:
offset = 0
while offset < len(value):
item, offset = _decode_varint(value, offset)
tags.append(item)
elif wire_type == 0:
tags.append(value)
elif field_number == 3 and wire_type == 0:
geometry_type = value
elif field_number == 4:
if wire_type == 2:
offset = 0
while offset < len(value):
item, offset = _decode_varint(value, offset)
geometry.append(item)
elif wire_type == 0:
geometry.append(value)
properties: dict[str, Any] = {}
for index in range(0, len(tags) - 1, 2):
key_index = tags[index]
value_index = tags[index + 1]
if key_index < len(keys) and value_index < len(values):
properties[keys[key_index]] = values[value_index]
paths = _decode_mvt_geometry(geometry, geometry_type)
if not paths:
return None
return {
"id": feature_id,
"type": geometry_type,
"properties": properties,
"paths": paths,
"extent": extent,
}
def decode_vector_tile(
data: bytes,
required_layers: set[str] | None = None,
) -> dict[str, list[dict[str, Any]]]:
layers: dict[str, list[dict[str, Any]]] = {}
for field_number, wire_type, layer_data in _protobuf_fields(data):
if field_number != 3 or wire_type != 2:
continue
layer_name = ""
keys: list[str] = []
values: list[Any] = []
feature_messages: list[bytes] = []
extent = 4096
for layer_field, layer_wire, value in _protobuf_fields(layer_data):
if layer_field == 1 and layer_wire == 2:
layer_name = bytes(value).decode("utf-8", errors="replace")
elif layer_field == 2 and layer_wire == 2:
feature_messages.append(bytes(value))
elif layer_field == 3 and layer_wire == 2:
keys.append(bytes(value).decode("utf-8", errors="replace"))
elif layer_field == 4 and layer_wire == 2:
values.append(_decode_mvt_value(bytes(value)))
elif layer_field == 5 and layer_wire == 0:
extent = max(1, int(value))
features = []
if layer_name and (required_layers is None or layer_name in required_layers):
for feature_data in feature_messages:
feature = _decode_mvt_feature(feature_data, keys, values, extent)
if feature:
features.append(feature)
if layer_name and (required_layers is None or layer_name in required_layers):
layers[layer_name] = features
return layers
def clip_segment_to_extent(
start: tuple[int, int],
end: tuple[int, int],
extent: int,
) -> tuple[tuple[float, float], tuple[float, float]] | None:
x0, y0 = float(start[0]), float(start[1])
x1, y1 = float(end[0]), float(end[1])
dx = x1 - x0
dy = y1 - y0
lower = 0.0
upper = 1.0
for p, q in ((-dx, x0), (dx, extent - x0), (-dy, y0), (dy, extent - y0)):
if abs(p) < 1e-12:
if q < 0:
return None
continue
ratio = q / p
if p < 0:
lower = max(lower, ratio)
else:
upper = min(upper, ratio)
if lower > upper:
return None
def clamp(point: tuple[float, float]) -> tuple[float, float]:
return (
max(0.0, min(float(extent), point[0])),
max(0.0, min(float(extent), point[1])),
)
return clamp((x0 + lower * dx, y0 + lower * dy)), clamp(
(x0 + upper * dx, y0 + upper * dy)
)
def clip_line_path(path: list[tuple[int, int]], extent: int) -> list[list[tuple[float, float]]]:
result: list[list[tuple[float, float]]] = []
current: list[tuple[float, float]] = []
for start, end in zip(path, path[1:]):
clipped = clip_segment_to_extent(start, end, extent)
if clipped is None:
if len(current) >= 2:
result.append(current)
current = []
continue
segment_start, segment_end = clipped
if current and abs(current[-1][0] - segment_start[0]) < 1e-7 and abs(
current[-1][1] - segment_start[1]
) < 1e-7:
current.append(segment_end)
else:
if len(current) >= 2:
result.append(current)
current = [segment_start, segment_end]
if len(current) >= 2:
result.append(current)
return result
def clip_polygon_path(path: list[tuple[int, int]], extent: int) -> list[tuple[float, float]]:
points = [
(float(point[0]), float(point[1]))
for point in path
if -extent <= point[0] <= extent * 2 and -extent <= point[1] <= extent * 2
]
if len(points) > 1 and points[0] == points[-1]:
points.pop()
if len(points) < 3:
return []
def clip_axis(
source: list[tuple[float, float]],
axis: int,
boundary: float,
keep_greater: bool,
) -> list[tuple[float, float]]:
if not source:
return []
output: list[tuple[float, float]] = []
previous = source[-1]
previous_inside = previous[axis] >= boundary if keep_greater else previous[axis] <= boundary
for current in source:
current_inside = current[axis] >= boundary if keep_greater else current[axis] <= boundary
if current_inside != previous_inside:
delta = current[axis] - previous[axis]
ratio = 0.0 if abs(delta) < 1e-12 else (boundary - previous[axis]) / delta
intersection = (
previous[0] + ratio * (current[0] - previous[0]),
previous[1] + ratio * (current[1] - previous[1]),
)
intersection = (
max(0.0, min(float(extent), intersection[0])),
max(0.0, min(float(extent), intersection[1])),
)
output.append(intersection)
if current_inside:
output.append(
(
max(0.0, min(float(extent), current[0])),
max(0.0, min(float(extent), current[1])),
)
)
previous = current
previous_inside = current_inside
return output
for axis, boundary, keep_greater in (
(0, 0.0, True),
(0, float(extent), False),
(1, 0.0, True),
(1, float(extent), False),
):
points = clip_axis(points, axis, boundary, keep_greater)
if len(points) < 3:
return []
if points[0] != points[-1]:
points.append(points[0])
return points
def simplify_path(
points: list[tuple[float, float]],
tolerance: float,
) -> list[tuple[float, float]]:
if len(points) <= 3 or tolerance <= 0:
return points
keep = {0, len(points) - 1}
stack = [(0, len(points) - 1)]
tolerance_squared = tolerance * tolerance
while stack:
start_index, end_index = stack.pop()
start = points[start_index]
end = points[end_index]
dx = end[0] - start[0]
dy = end[1] - start[1]
length_squared = dx * dx + dy * dy
farthest_index = -1
farthest_distance = tolerance_squared
for index in range(start_index + 1, end_index):
point = points[index]
if length_squared == 0:
distance_squared = (
(point[0] - start[0]) ** 2 + (point[1] - start[1]) ** 2
)
else:
ratio = max(
0.0,
min(
1.0,
((point[0] - start[0]) * dx + (point[1] - start[1]) * dy)
/ length_squared,
),
)
projected_x = start[0] + ratio * dx
projected_y = start[1] + ratio * dy
distance_squared = (
(point[0] - projected_x) ** 2 + (point[1] - projected_y) ** 2
)
if distance_squared > farthest_distance:
farthest_distance = distance_squared
farthest_index = index
if farthest_index >= 0:
keep.add(farthest_index)
stack.append((start_index, farthest_index))
stack.append((farthest_index, end_index))
return [points[index] for index in sorted(keep)]
def clipped_feature_paths(feature: dict[str, Any]) -> list[list[tuple[float, float]]]:
extent = int(feature["extent"])
geometry_type = int(feature["type"])
polygon_tolerance = max(1.0, extent / 2048.0)
line_tolerance = max(0.75, extent / 4096.0)
result: list[list[tuple[float, float]]] = []
for path in feature["paths"]:
if geometry_type == 1:
if path and 0 <= path[0][0] <= extent and 0 <= path[0][1] <= extent:
result.append([(float(path[0][0]), float(path[0][1]))])
elif geometry_type == 3:
clipped = clip_polygon_path(path, extent)
if clipped:
simplified = simplify_path(clipped, polygon_tolerance)
if len(simplified) >= 4:
if simplified[0] != simplified[-1]:
simplified.append(simplified[0])
result.append(simplified)
else:
result.extend(
simplified
for clipped in clip_line_path(path, extent)
for simplified in [simplify_path(clipped, line_tolerance)]
if len(simplified) >= 2
)
return result
def normalize_request_path(path: str, accept_header: str = "") -> str:
decoded = urllib.parse.unquote(path)
if decoded in ("", "/"):
return "/"
parts = [part for part in decoded.split("/") if part]
for index in range(len(parts)):
candidate = "/" + "/".join(parts[index:])
if candidate.startswith(("/tiles/", "/api/")):
return candidate
first = parts[index]
if first in KNOWN_TOP_LEVEL:
return candidate
if "text/html" in accept_header.lower():
return "/"
return decoded
def vector_tile_bounds(x: int, y: int, z: int):
backend = dynamic_geometry_backend()
count = 1 << z
west = x / count * 360.0 - 180.0
east = (x + 1) / count * 360.0 - 180.0
north = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * y / count))))
south = math.degrees(
math.atan(math.sinh(math.pi * (1.0 - 2.0 * (y + 1) / count)))
)
return backend["box"](west, south, east, north)
def vector_tile_bbox(z: int, x: int, y: int) -> tuple[float, float, float, float]:
count = 1 << z
west = x / count * 360.0 - 180.0
east = (x + 1) / count * 360.0 - 180.0
north = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * y / count))))
south = math.degrees(
math.atan(math.sinh(math.pi * (1.0 - 2.0 * (y + 1) / count)))
)
return south, west, north, east
def rounded_coordinates(value: Any, precision: int = 6) -> Any:
if isinstance(value, (int, float)):
return round(float(value), precision)
if isinstance(value, (list, tuple)):
return [rounded_coordinates(item, precision) for item in value]
return value
def rounded_geometry_json(geometry: dict[str, Any]) -> dict[str, Any]:
if geometry.get("type") == "GeometryCollection":
return {
"type": "GeometryCollection",
"geometries": [
rounded_geometry_json(item)
for item in geometry.get("geometries", [])
if item
],
}
result = dict(geometry)
if "coordinates" in result:
result["coordinates"] = rounded_coordinates(result["coordinates"])
return result
def dynamic_geometry_backend() -> dict[str, Any]:
global _dynamic_geometry_backend
if _dynamic_geometry_backend is not None:
return _dynamic_geometry_backend
with _dynamic_geometry_lock:
if _dynamic_geometry_backend is not None:
return _dynamic_geometry_backend
dependency = str(DYNAMIC_VECTOR_DEPENDENCIES)
if DYNAMIC_VECTOR_DEPENDENCIES.is_dir() and dependency not in sys.path:
sys.path.append(dependency)
try:
from shapely.geometry import box, mapping, shape
except Exception as error:
raise RuntimeError(
"High-zoom vector generation requires shapely and the local GIS dependencies"
) from error
_dynamic_geometry_backend = {
"box": box,
"mapping": mapping,
"shape": shape,
}
return _dynamic_geometry_backend
def clip_local_vector_payload(
payload: dict[str, Any],
z: int,
x: int,
y: int,
manifest_generated_at: str = "",
) -> dict[str, Any]:
backend = dynamic_geometry_backend()
clip_box = vector_tile_bounds(x, y, z)
features: list[dict[str, Any]] = []
for feature in payload.get("features", []):
raw_geometry = feature.get("geometry")
if not raw_geometry:
continue
try:
geometry = backend["shape"](raw_geometry)
clipped = geometry.intersection(clip_box)
except Exception:
continue
if clipped.is_empty:
continue
mapped = rounded_geometry_json(backend["mapping"](clipped))
features.append(
{
"type": "Feature",
"id": feature.get("id"),
"properties": feature.get("properties") or {},
"geometry": mapped,
}
)
return {
"type": "FeatureCollection",
"format": "local-geojson-vector-v1",
"generatedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"z": z,
"x": x,
"y": y,
"dynamic": True,
"manifestGeneratedAt": manifest_generated_at,
"features": features,
}
@dataclass
class ServiceConfig:
host: str
port: int
cache_dir: Path
tile_ttl_seconds: int
rail_ttl_seconds: int
refresh_seconds: int
refresh_batch: int
tianditu_key: str = ""
admin_password: str = ""
cache_only: bool = False
upstream_proxy: str = ""
class DaemonTaskPool:
def __init__(self, max_workers: int, name: str) -> None:
self._queue: queue.Queue[Any] = queue.Queue()
self._stopped = threading.Event()
self._workers = [
threading.Thread(
target=self._worker,
name=f"{name}-{index}",
daemon=True,
)
for index in range(max(1, max_workers))
]
for worker in self._workers:
worker.start()
def _worker(self) -> None:
while not self._stopped.is_set():
item = self._queue.get()
if item is None:
return
function, args, kwargs = item
if self._stopped.is_set():
continue
try:
function(*args, **kwargs)
except Exception:
pass
def submit(self, function: Any, *args: Any, **kwargs: Any) -> None:
if not self._stopped.is_set():
self._queue.put((function, args, kwargs))
def shutdown(self, wait: bool = False, cancel_futures: bool = False) -> None:
self._stopped.set()
while True:
try:
self._queue.get_nowait()
except queue.Empty:
break
for _ in self._workers:
self._queue.put(None)
def run_with_timeout(
function: Any,
timeout_seconds: float,
label: str,
*args: Any,
**kwargs: Any,
) -> Any:
"""Bound an outbound request even when DNS or a remote socket stalls."""
result_queue: queue.Queue[tuple[bool, Any]] = queue.Queue(maxsize=1)
def run() -> None:
try:
result_queue.put((True, function(*args, **kwargs)), block=False)
except Exception as error:
try:
result_queue.put((False, error), block=False)
except queue.Full:
pass
threading.Thread(
target=run,
name="openmap-outbound-request",
daemon=True,
).start()
try:
succeeded, value = result_queue.get(timeout=max(0.1, timeout_seconds))
except queue.Empty as error:
raise TimeoutError(f"{label} did not finish within {timeout_seconds:g}s") from error
if not succeeded:
raise value
return value
class MapService:
def __init__(self, config: ServiceConfig) -> None:
self.config = config
self.cache_dir = config.cache_dir.resolve()
self.tile_cache_dir = self.cache_dir / "tiles"
self.rail_cache_dir = self.cache_dir / "rail"
self.pending_rail_dir = self.cache_dir / "rail-pending"
self.water_cache_dir = self.cache_dir / "water"
self.pending_water_dir = self.cache_dir / "water-pending"
self.boundary_cache_dir = self.cache_dir / "boundaries"
self.vector_cache_dir = self.cache_dir / "vector"
self.local_vector_dir = self.cache_dir / "local-vector"
self.local_vector_25_dir = self.cache_dir / "local-vector-25"
self.local_vector_dynamic_dir = self.cache_dir / "local-vector-dynamic"
self.local_vector_25_dynamic_dir = self.cache_dir / "local-vector-25-dynamic"
self.rail_tile_dir = self.cache_dir / "rail-tiles"
self.rail_metro_dir = self.cache_dir / "rail-metro-tiles"
self.tile_cache_dir.mkdir(parents=True, exist_ok=True)
self.rail_cache_dir.mkdir(parents=True, exist_ok=True)
self.pending_rail_dir.mkdir(parents=True, exist_ok=True)
self.water_cache_dir.mkdir(parents=True, exist_ok=True)
self.pending_water_dir.mkdir(parents=True, exist_ok=True)
self.boundary_cache_dir.mkdir(parents=True, exist_ok=True)
self.vector_cache_dir.mkdir(parents=True, exist_ok=True)
self.local_vector_dir.mkdir(parents=True, exist_ok=True)
self.local_vector_25_dir.mkdir(parents=True, exist_ok=True)
self.local_vector_dynamic_dir.mkdir(parents=True, exist_ok=True)
self.local_vector_25_dynamic_dir.mkdir(parents=True, exist_ok=True)
self.rail_tile_dir.mkdir(parents=True, exist_ok=True)
self.rail_metro_dir.mkdir(parents=True, exist_ok=True)
self.started_at = time.time()
self.stats_lock = threading.Lock()
self.stats = {
"tile_hits": 0,
"tile_stale": 0,
"tile_misses": 0,
"tile_errors": 0,
"rail_hits": 0,
"rail_stale": 0,
"rail_misses": 0,
"rail_errors": 0,
"rail_pending": 0,
"water_hits": 0,
"water_misses": 0,
"water_errors": 0,
"water_pending": 0,
"boundary_hits": 0,
"boundary_misses": 0,
"boundary_errors": 0,
"vector_hits": 0,
"vector_misses": 0,
"vector_errors": 0,
"local_vector_dynamic_hits": 0,
"local_vector_dynamic_generated": 0,
"local_vector_dynamic_errors": 0,
"rail_tile_hits": 0,
"rail_tiles_generated": 0,
"rail_tile_errors": 0,
"rail_metro_downloads": 0,
"rail_metro_errors": 0,
"manual_refreshes": 0,
}
self._lock_guard = threading.Lock()
self._tile_locks: dict[str, threading.Lock] = {}
self._refreshing: set[str] = set()
self._rail_aggregate_inflight: set[str] = set()
self._vector_template: str | None = None
self._vector_template_loaded_at = 0.0
self._vector_template_lock = threading.Lock()
self._upstream_proxy_lock = threading.Lock()
self._upstream_proxy_opener: Any = None
self._default_upstream_proxy = self.config.upstream_proxy
configure_atomic_staging(self.cache_dir)
self.set_upstream_proxy(self.config.upstream_proxy, persist=False)
self._cache_totals_lock = threading.Lock()
self._cache_totals_cached_at = 0.0
self._cache_totals_result = (0, 0)
self._cache_admin_stats_lock = threading.Lock()
self._cache_admin_stats_cached_at = 0.0
self._cache_admin_stats_result: dict[str, Any] | None = None
self._cache_coverage_lock = threading.Lock()
self._cache_coverage_result: dict[str, Any] | None = None
self._cache_coverage_cached_at = 0.0
self._refresh_executor = DaemonTaskPool(CACHE_REFRESH_WORKERS, "map-cache-refresh")
self._download_executor = DaemonTaskPool(VECTOR_BACKGROUND_WORKERS, "map-vector-download")
self._vector_tile_executor = DaemonTaskPool(VECTOR_TILE_REQUEST_WORKERS, "openfreemap-tile-fetch")
self._rail_aggregate_executor = DaemonTaskPool(RAIL_AGGREGATE_WORKERS, "rail-lowzoom-aggregate")
self._rail_aggregate_fetch_executor = DaemonTaskPool(RAIL_AGGREGATE_FETCH_WORKERS, "rail-lowzoom-tile-fetch")
self._tianditu_executor = DaemonTaskPool(TIANDITU_DOWNLOAD_WORKERS, "tianditu-manual-download")
self._openmap_executor = DaemonTaskPool(OPENMAP_DOWNLOAD_WORKERS, "openmap-manual-download")
self._tianditu_jobs: dict[str, dict[str, Any]] = {}
self._openmap_jobs: dict[str, dict[str, Any]] = {}
self._tianditu_cancel_events: dict[str, threading.Event] = {}
self._openmap_cancel_events: dict[str, threading.Event] = {}
self._admin_sessions: dict[str, float] = {}
self._rate_events: dict[str, list[float]] = {}
self._stop = threading.Event()
self._maintenance = threading.Thread(
target=self._maintenance_loop,
name="map-cache-maintenance",
daemon=True,
)
self._maintenance.start()
@staticmethod
def build_upstream_proxy_opener(proxy_url: str) -> Any:
if not proxy_url:
return None
return urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
)
def set_upstream_proxy(self, value: str, persist: bool = True) -> str:
normalized = normalize_upstream_proxy(value)
opener = self.build_upstream_proxy_opener(normalized)
self.config.upstream_proxy = normalized
with self._upstream_proxy_lock:
self._upstream_proxy_opener = opener
if persist:
self._default_upstream_proxy = normalized
atomic_write(
self.cache_dir / UPSTREAM_PROXY_SETTING_NAME,
(normalized + "\n").encode("utf-8"),
)
return normalized
def reset_upstream_proxy(self) -> str:
return self.set_upstream_proxy(self._default_upstream_proxy, persist=False)
def open_upstream_url(self, request: urllib.request.Request, timeout: float) -> Any:
with self._upstream_proxy_lock:
opener = self._upstream_proxy_opener
if opener is not None:
return opener.open(request, timeout=timeout)
return urllib.request.urlopen(request, timeout=timeout)
def upstream_proxy_status(self) -> dict[str, Any]:
current = display_upstream_proxy(self.config.upstream_proxy)
default = display_upstream_proxy(self._default_upstream_proxy)
return {
"configured": bool(self.config.upstream_proxy),
"proxy": current,
"defaultConfigured": bool(self._default_upstream_proxy),
"defaultProxy": default,
"temporary": self.config.upstream_proxy != self._default_upstream_proxy,
}
def set_cache_only(self, enabled: bool) -> bool:
enabled = bool(enabled)
self.config.cache_only = enabled
setting_path = self.cache_dir / CACHE_ONLY_SETTING_NAME
atomic_write(setting_path, b"1\n" if enabled else b"0\n")
if enabled:
now = time.time()
with self._lock_guard:
for events in (self._tianditu_cancel_events, self._openmap_cancel_events):
for event in events.values():
event.set()
events.clear()
for jobs in (self._tianditu_jobs, self._openmap_jobs):
for job in jobs.values():
if job.get("status") not in ("queued", "running"):
continue
remaining = max(
0,
int(job.get("total", 0)) - int(job.get("completed", 0)),
)
job["completed"] = int(job.get("total", 0))
job["failed"] = int(job.get("failed", 0)) + remaining
job["status"] = "partial"
job["finishedAt"] = now
job["updatedAt"] = now
if len(job.get("errors", [])) < 20:
job.setdefault("errors", []).append(
"服务器已切换为仅缓存模式,任务停止请求云端。"
)
return enabled
def increment(self, key: str, amount: int = 1) -> None:
with self.stats_lock:
self.stats[key] = self.stats.get(key, 0) + amount
def consume_rate_limit(
self,
bucket: str,
identity: str,
limit: int,
window_seconds: float,
) -> bool:
now = time.time()
key = f"{bucket}:{identity}"
with self._lock_guard:
timestamps = [
timestamp
for timestamp in self._rate_events.get(key, [])
if now - timestamp < window_seconds
]
if len(timestamps) >= limit:
self._rate_events[key] = timestamps
return False
timestamps.append(now)
self._rate_events[key] = timestamps
if len(self._rate_events) > 2048:
expired = [
event_key
for event_key, events in self._rate_events.items()
if not events or now - events[-1] >= 24 * 60 * 60
]
for event_key in expired:
self._rate_events.pop(event_key, None)
return True
def redact_secrets(self, value: Any) -> str:
text = str(value)
proxy = self.config.upstream_proxy
proxy_secrets: list[str] = []
if proxy:
parsed_proxy = urllib.parse.urlsplit(proxy)
proxy_secrets.extend(
secret for secret in (parsed_proxy.username, parsed_proxy.password) if secret
)
for secret in (self.config.tianditu_key, self.config.admin_password, proxy, *proxy_secrets):
if secret:
text = text.replace(secret, "***")
text = text.replace(urllib.parse.quote(secret, safe=""), "***")
return text
def local_vector_status(self, directory: Path | None = None) -> dict[str, Any]:
manifest_path = (directory or self.local_vector_dir) / "manifest.json"
if not manifest_path.exists():
return {
"ok": True,
"available": False,
"message": "Local vector tiles have not been built",
}
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception as error:
return {"ok": False, "available": False, "error": str(error)}
manifest.pop("sources", None)
return {"ok": True, "available": True, **manifest}
def local_vector_tile(
self,
z: int,
x: int,
y: int,
directory: Path | None = None,
) -> dict[str, Any] | None:
if z < 0 or z > 22 or x < 0 or y < 0 or x >= 1 << z or y >= 1 << z:
return None
static_dir = (directory or self.local_vector_dir).resolve()
manifest_path = static_dir / "manifest.json"
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
browse_max_zoom = int(manifest.get("browseMaxZoom") or manifest.get("maxZoom") or 0)
static_max_zoom = int(manifest.get("maxZoom") or 0)
static_zooms = sorted(
int(level)
for level in (manifest.get("tiles") or {})
if str(level).isdigit()
)
except Exception:
return None
if not static_zooms or z < min(static_zooms) or z > browse_max_zoom:
return None
static_path = static_dir / str(z) / str(x) / f"{y}.json"
if z in static_zooms:
if not static_path.exists():
return None
try:
return json.loads(static_path.read_text(encoding="utf-8"))
except Exception:
return None
if z <= static_max_zoom:
return None
manifest_generated_at = str(manifest.get("generatedAt") or "")
dynamic_dir = (
self.local_vector_25_dynamic_dir
if static_dir == self.local_vector_25_dir.resolve()
else self.local_vector_dynamic_dir
)
dynamic_path = dynamic_dir / str(z) / str(x) / f"{y}.json"
if dynamic_path.exists():
try:
cached = json.loads(dynamic_path.read_text(encoding="utf-8"))
if cached.get("manifestGeneratedAt") == manifest_generated_at:
self.increment("local_vector_dynamic_hits")
return cached
except Exception:
pass
parent_zoom = max(level for level in static_zooms if level < z)
shift = z - parent_zoom
parent_x = x >> shift
parent_y = y >> shift
parent_path = static_dir / str(parent_zoom) / str(parent_x) / f"{parent_y}.json"
if not parent_path.exists():
return None
lock_key = f"local-vector-dynamic:{dynamic_dir}:{z}:{x}:{y}"
with self.tile_lock(lock_key):
if dynamic_path.exists():
try:
cached = json.loads(dynamic_path.read_text(encoding="utf-8"))
if cached.get("manifestGeneratedAt") == manifest_generated_at:
self.increment("local_vector_dynamic_hits")
return cached
except Exception:
pass
try:
parent_payload = json.loads(parent_path.read_text(encoding="utf-8"))
payload = clip_local_vector_payload(
parent_payload,
z,
x,
y,
manifest_generated_at,
)
dynamic_path.parent.mkdir(parents=True, exist_ok=True)
temporary = dynamic_path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
os.replace(temporary, dynamic_path)
self.increment("local_vector_dynamic_generated")
return payload
except Exception:
self.increment("local_vector_dynamic_errors")
return None
def tile_lock(self, key: str) -> threading.Lock:
with self._lock_guard:
return self._tile_locks.setdefault(key, threading.Lock())
def tile_cache_path(self, provider: str, z: int, x: int, y: int) -> Path:
return self.tile_cache_dir / provider / str(z) / str(x) / f"{y}.png"
def tile_upstream_url(self, provider: str, z: int, x: int, y: int, url: str) -> str:
return url.format(
z=z,
x=x,
y=y,
key=urllib.parse.quote(self.config.tianditu_key, safe=""),
)
def fetch_tile_upstream(
self,
provider: str,
z: int,
x: int,
y: int,
upstream_timeout_seconds: float | None = None,
) -> tuple[bytes, str]:
provider_config = TILE_PROVIDERS[provider]
if self.config.cache_only:
raise CacheOnlyMode("Cloud tile requests are disabled by cache-only mode")
if provider_config.get("requires_key") and not self.config.tianditu_key:
raise TiandituKeyMissing("Tianditu API key is not configured")
errors: list[str] = []
for template in provider_config["urls"]:
url = self.tile_upstream_url(provider, z, x, y, template)
safe_url = self.redact_secrets(url)
request = urllib.request.Request(
url,
headers={
"User-Agent": USER_AGENT,
"Accept": "image/png,image/jpeg,image/webp,image/*;q=0.8",
"Referer": f"http://{self.config.host}:{self.config.port}/",
},
)
try:
request_timeout = max(
1.0,
float(
upstream_timeout_seconds
if upstream_timeout_seconds is not None
else provider_config.get("request_timeout_seconds", 20)
),
)
with self.open_upstream_url(request, request_timeout) as response:
data = response.read()
content_type = detect_image_content_type(
data, response.headers.get_content_type() or provider_config["content_type"]
)
if data and content_type.startswith("image/"):
return data, content_type
errors.append(f"{safe_url}: non-image response")
except Exception as error:
errors.append(f"{safe_url}: {self.redact_secrets(error)}")
raise RuntimeError("; ".join(errors) or "No tile upstream configured")
def get_tile(self, provider: str, z: int, x: int, y: int) -> tuple[bytes, str, str]:
if provider not in TILE_PROVIDERS:
raise KeyError(provider)
path = self.tile_cache_path(provider, z, x, y)
key = str(path)
lock = self.tile_lock(key)
with lock:
if path.exists():
age = time.time() - path.stat().st_mtime
data = path.read_bytes()
content_type = detect_image_content_type(
data, TILE_PROVIDERS[provider]["content_type"]
)
if age <= self.config.tile_ttl_seconds:
self.increment("tile_hits")
return data, content_type, "HIT"
self.increment("tile_stale")
if self.config.cache_only:
return data, content_type, "STALE"
if TILE_PROVIDERS[provider].get("manual_only"):
return data, content_type, "STALE"
self.schedule_tile_refresh(path)
return data, content_type, "STALE"
if self.config.cache_only:
self.increment("tile_offline_placeholders")
return PLACEHOLDER_TILE_PNG, "image/png", "PLACEHOLDER"
if TILE_PROVIDERS[provider].get("manual_only"):
raise TileNotCached(
f"Tile {provider}/{z}/{x}/{y} has not been downloaded"
)
try:
data, content_type = self.fetch_tile_upstream(provider, z, x, y)
atomic_write(path, data)
self.increment("tile_misses")
return data, content_type, "MISS"
except Exception:
self.increment("tile_errors")
raise
def refresh_tile_path(self, path: Path) -> bool:
if self.config.cache_only:
return False
try:
relative = path.relative_to(self.tile_cache_dir).parts
if len(relative) != 4:
return False
provider, z_text, x_text, y_text = relative
z = int(z_text)
x = int(x_text)
y = int(y_text[:-4])
data, _ = self.fetch_tile_upstream(provider, z, x, y)
atomic_write(path, data)
return True
except Exception:
return False
finally:
with self._lock_guard:
self._refreshing.discard(str(path))
def schedule_tile_refresh(self, path: Path) -> None:
key = str(path)
with self._lock_guard:
if key in self._refreshing:
return
self._refreshing.add(key)
self._download_executor.submit(self.refresh_tile_path, path)
def tianditu_provider_names(self) -> list[str]:
return [
name for name, config in TILE_PROVIDERS.items()
if config.get("manual_only")
]
def tianditu_status(self) -> dict[str, Any]:
providers = []
for name in self.tianditu_provider_names():
config = TILE_PROVIDERS[name]
files = len(list((self.tile_cache_dir / name).rglob("*.png")))
providers.append({
"id": name,
"label": config.get("label", name),
"layer": config.get("tianditu_layer", ""),
"cachedTiles": files,
})
proxy_status = self.upstream_proxy_status()
return {
"ok": True,
"configured": bool(self.config.tianditu_key),
"cacheOnly": self.config.cache_only,
"upstreamProxy": proxy_status["proxy"],
"upstreamProxyConfigured": proxy_status["configured"],
"upstreamProxyDefault": proxy_status["defaultProxy"],
"upstreamProxyDefaultConfigured": proxy_status["defaultConfigured"],
"upstreamProxyTemporary": proxy_status["temporary"],
"providers": providers,
"downloadWorkers": TIANDITU_DOWNLOAD_WORKERS,
"activeJobs": sum(
1 for job in self._tianditu_jobs.values()
if job.get("status") in ("queued", "running")
),
}
def create_admin_session(self) -> str:
token = secrets.token_urlsafe(32)
now = time.time()
with self._lock_guard:
self._admin_sessions = {
key: expires for key, expires in self._admin_sessions.items()
if expires > now
}
self._admin_sessions[token] = now + 8 * 60 * 60
return token
def admin_session_valid(self, token: str) -> bool:
if not token:
return False
now = time.time()
with self._lock_guard:
expires = self._admin_sessions.get(token, 0)
if expires <= now:
self._admin_sessions.pop(token, None)
return False
return True
def destroy_admin_session(self, token: str) -> None:
if not token:
return
with self._lock_guard:
self._admin_sessions.pop(token, None)
def destroy_all_admin_sessions(self) -> None:
with self._lock_guard:
self._admin_sessions.clear()
def start_tianditu_download(
self,
providers: list[str],
force: bool = False,
*,
bounds: tuple[float, float, float, float] | None = None,
zoom_min: int | None = None,
zoom_max: int | None = None,
z: int | None = None,
x0: int | None = None,
x1: int | None = None,
y0: int | None = None,
y1: int | None = None,
) -> dict[str, Any]:
if self.config.cache_only:
raise ValueError("服务器处于仅缓存模式,已禁止提交云端下载任务")
if not self.config.tianditu_key:
raise TiandituKeyMissing("Tianditu API key is not configured")
allowed = set(self.tianditu_provider_names())
selected = list(dict.fromkeys(provider for provider in providers if provider in allowed))
if not selected:
raise ValueError("No Tianditu providers selected")
tile_ranges: list[tuple[int, int, int, int, int]] = []
if bounds is not None:
south, west, north, east = bounds
if not (-90 <= south < north <= 90 and -180 <= west < east <= 180):
raise ValueError("Invalid geographic bounds")
if zoom_min is None or zoom_max is None:
raise ValueError("Zoom range is required")
if not (1 <= zoom_min <= zoom_max <= 18):
raise ValueError("Zoom range must be between 1 and 18")
for tile_z in range(zoom_min, zoom_max + 1):
tile_x0 = self.lon_to_tile_x(west, tile_z)
tile_x1 = self.lon_to_tile_x(east, tile_z)
tile_y0 = self.lat_to_tile_y(north, tile_z)
tile_y1 = self.lat_to_tile_y(south, tile_z)
tile_ranges.append((tile_z, tile_x0, tile_x1, tile_y0, tile_y1))
else:
if z is None or x0 is None or x1 is None or y0 is None or y1 is None:
raise ValueError("Tile range is required")
if not (1 <= z <= 18):
raise ValueError("Zoom must be between 1 and 18")
count = 1 << z
if not (0 <= x0 <= x1 < count and 0 <= y0 <= y1 < count):
raise ValueError("Invalid tile range")
tile_ranges = [(z, x0, x1, y0, y1)]
total_per_provider = sum(
(tile_x1 - tile_x0 + 1) * (tile_y1 - tile_y0 + 1)
for _, tile_x0, tile_x1, tile_y0, tile_y1 in tile_ranges
)
total = len(selected) * total_per_provider
job_id = uuid.uuid4().hex
cancel_event = threading.Event()
job = {
"id": job_id,
"status": "queued",
"providers": selected,
"bounds": bounds,
"zoomMin": zoom_min if bounds is not None else z,
"zoomMax": zoom_max if bounds is not None else z,
"force": force,
"total": total,
"completed": 0,
"succeeded": 0,
"skipped": 0,
"failed": 0,
"downloadWorkers": TIANDITU_DOWNLOAD_WORKERS,
"startedAt": time.time(),
"updatedAt": time.time(),
"errors": [],
}
with self._lock_guard:
self._tianditu_jobs[job_id] = job
self._tianditu_cancel_events[job_id] = cancel_event
def download_tile(provider: str, tile_z: int, x: int, y: int) -> None:
if cancel_event.is_set():
return
path = self.tile_cache_path(provider, tile_z, x, y)
lock = self.tile_lock(f"tianditu:{provider}:{tile_z}:{x}:{y}")
with lock:
if cancel_event.is_set():
return
try:
if path.exists() and not force:
with self._lock_guard:
job["completed"] += 1
job["skipped"] += 1
job["updatedAt"] = time.time()
return
data, _ = self.fetch_tile_upstream(provider, tile_z, x, y)
if cancel_event.is_set():
return
atomic_write(path, data)
with self._lock_guard:
job["completed"] += 1
job["succeeded"] += 1
job["updatedAt"] = time.time()
except Exception as error:
if cancel_event.is_set():
return
with self._lock_guard:
job["completed"] += 1
job["failed"] += 1
job["updatedAt"] = time.time()
if len(job["errors"]) < 20:
job["errors"].append(
self.redact_secrets(
f"{provider}/{tile_z}/{x}/{y}: {error}"
)
)
finally:
with self._lock_guard:
if job["status"] == "running" and job["completed"] >= job["total"]:
job["status"] = "completed" if not job["failed"] else "partial"
job["finishedAt"] = time.time()
with self._lock_guard:
job["status"] = "running"
tile_iterator = (
(provider, tile_z, tile_x, tile_y)
for provider in selected
for tile_z, tile_x0, tile_x1, tile_y0, tile_y1 in tile_ranges
for tile_y in range(tile_y0, tile_y1 + 1)
for tile_x in range(tile_x0, tile_x1 + 1)
)
tile_dispatch_lock = threading.Lock()
def download_worker() -> None:
while not cancel_event.is_set():
with tile_dispatch_lock:
try:
provider, tile_z, tile_x, tile_y = next(tile_iterator)
except StopIteration:
return
download_tile(provider, tile_z, tile_x, tile_y)
for _ in range(TIANDITU_DOWNLOAD_WORKERS):
self._tianditu_executor.submit(download_worker)
return dict(job)
def tianditu_job_status(self, job_id: str) -> dict[str, Any] | None:
with self._lock_guard:
job = self._tianditu_jobs.get(job_id)
return dict(job) if job else None
def cancel_tianditu_job(self, job_id: str) -> dict[str, Any] | None:
with self._lock_guard:
job = self._tianditu_jobs.get(job_id)
if not job:
return None
if job.get("status") in ("queued", "running"):
event = self._tianditu_cancel_events.pop(job_id, None)
if event:
event.set()
job["status"] = "cancelled"
job["cancelledAt"] = time.time()
job["finishedAt"] = job["cancelledAt"]
job["updatedAt"] = job["cancelledAt"]
if len(job["errors"]) < 20:
job["errors"].append("下载任务已由管理员中断;已完成的完整瓦片会保留。")
return dict(job)
def openmap_status(self) -> dict[str, Any]:
providers = []
vector_count: int | None = None
for name, config in OPEN_MAP_PROVIDERS.items():
if config["kind"] == "vector":
if vector_count is None:
vector_count = sum(1 for _ in self.vector_cache_dir.rglob("*.pbf"))
cached_tiles = vector_count
else:
cached_tiles = sum(
1 for _ in (self.tile_cache_dir / str(config["provider"])).rglob("*.png")
)
providers.append({
"id": name,
"label": config["label"],
"kind": config["kind"],
"minZoom": config["minZoom"],
"maxZoom": config["maxZoom"],
"source": config["source"],
"license": config["license"],
"description": config["description"],
"cachedTiles": cached_tiles,
})
with self._lock_guard:
active_jobs = sorted(
(
dict(job)
for job in self._openmap_jobs.values()
if job.get("status") in ("queued", "running")
),
key=lambda job: float(job.get("startedAt", 0)),
reverse=True,
)
proxy_status = self.upstream_proxy_status()
return {
"ok": True,
"cacheOnly": self.config.cache_only,
"upstreamProxy": proxy_status["proxy"],
"upstreamProxyConfigured": proxy_status["configured"],
"upstreamProxyDefault": proxy_status["defaultProxy"],
"upstreamProxyDefaultConfigured": proxy_status["defaultConfigured"],
"upstreamProxyTemporary": proxy_status["temporary"],
"providers": providers,
"downloadWorkers": OPENMAP_DOWNLOAD_WORKERS,
"activeJobs": len(active_jobs),
"activeJob": active_jobs[0] if active_jobs else None,
}
def start_openmap_download(
self,
providers: list[str],
force: bool = False,
*,
bounds: tuple[float, float, float, float] | None = None,
zoom_min: int | None = None,
zoom_max: int | None = None,
z: int | None = None,
x0: int | None = None,
x1: int | None = None,
y0: int | None = None,
y1: int | None = None,
tiles: list[tuple[int, int, int]] | None = None,
) -> dict[str, Any]:
if self.config.cache_only:
raise ValueError("服务器处于仅缓存模式,已禁止提交云端下载任务")
selected = list(dict.fromkeys(providers))
unknown = [provider for provider in selected if provider not in OPEN_MAP_PROVIDERS]
if unknown:
raise ValueError("Unknown open map provider: " + ", ".join(unknown))
if not selected:
raise ValueError("No open map providers selected")
exact_tiles: list[tuple[int, int, int]] | None = None
tile_ranges: list[tuple[int, int, int, int, int]] = []
if tiles is not None:
if len(selected) != 1 or OPEN_MAP_PROVIDERS[selected[0]]["kind"] != "vector":
raise ValueError("Exact tile downloads require one vector provider")
config = OPEN_MAP_PROVIDERS[selected[0]]
exact_tiles = sorted(set(
(int(tile_z), int(tile_x), int(tile_y))
for tile_z, tile_x, tile_y in tiles
))
if not exact_tiles:
raise ValueError("No source tiles selected")
for tile_z, tile_x, tile_y in exact_tiles:
count = 1 << tile_z
if not (config["minZoom"] <= tile_z <= config["maxZoom"]):
raise ValueError(
f"{config['label']} supports Z{config['minZoom']}-Z{config['maxZoom']}"
)
if not (0 <= tile_x < count and 0 <= tile_y < count):
raise ValueError("Invalid source tile coordinate")
elif bounds is not None:
south, west, north, east = bounds
if not (-90 <= south < north <= 90 and -180 <= west < east <= 180):
raise ValueError("Invalid geographic bounds")
if zoom_min is None or zoom_max is None:
raise ValueError("Zoom range is required")
if zoom_min > zoom_max:
raise ValueError("Invalid zoom range")
for provider in selected:
config = OPEN_MAP_PROVIDERS[provider]
if zoom_min < config["minZoom"] or zoom_max > config["maxZoom"]:
raise ValueError(
f"{config['label']} supports Z{config['minZoom']}-Z{config['maxZoom']}"
)
for tile_z in range(zoom_min, zoom_max + 1):
tile_x0 = self.lon_to_tile_x(west, tile_z)
tile_x1 = self.lon_to_tile_x(east, tile_z)
tile_y0 = self.lat_to_tile_y(north, tile_z)
tile_y1 = self.lat_to_tile_y(south, tile_z)
tile_ranges.append((tile_z, tile_x0, tile_x1, tile_y0, tile_y1))
else:
if z is None or x0 is None or x1 is None or y0 is None or y1 is None:
raise ValueError("Tile range is required")
for provider in selected:
config = OPEN_MAP_PROVIDERS[provider]
if not (config["minZoom"] <= z <= config["maxZoom"]):
raise ValueError(
f"{config['label']} supports Z{config['minZoom']}-Z{config['maxZoom']}"
)
count = 1 << z
if not (0 <= x0 <= x1 < count and 0 <= y0 <= y1 < count):
raise ValueError("Invalid tile range")
tile_ranges = [(z, x0, x1, y0, y1)]
total_per_provider = sum(
(tile_x1 - tile_x0 + 1) * (tile_y1 - tile_y0 + 1)
for _, tile_x0, tile_x1, tile_y0, tile_y1 in tile_ranges
)
total = len(exact_tiles) if exact_tiles is not None else len(selected) * total_per_provider
job_id = uuid.uuid4().hex
cancel_event = threading.Event()
job = {
"id": job_id,
"status": "queued",
"providers": selected,
"bounds": bounds,
"zoomMin": min(tile_z for tile_z, _, _ in exact_tiles) if exact_tiles is not None else (zoom_min if bounds is not None else z),
"zoomMax": max(tile_z for tile_z, _, _ in exact_tiles) if exact_tiles is not None else (zoom_max if bounds is not None else z),
"force": force,
"total": total,
"completed": 0,
"succeeded": 0,
"skipped": 0,
"failed": 0,
"downloadWorkers": OPENMAP_DOWNLOAD_WORKERS,
"activeRequests": 0,
"activeTiles": [],
"startedAt": time.time(),
"updatedAt": time.time(),
"errors": [],
}
with self._lock_guard:
self._openmap_jobs[job_id] = job
self._openmap_cancel_events[job_id] = cancel_event
def record_job_result(kind: str, message: str = "") -> None:
with self._lock_guard:
if job["status"] not in ("queued", "running"):
return
if job["completed"] >= job["total"]:
return
job["completed"] += 1
job[kind] += 1
job["updatedAt"] = time.time()
if message and len(job["errors"]) < 20:
job["errors"].append(self.redact_secrets(message))
if job["completed"] >= job["total"]:
job["status"] = "completed" if not job["failed"] else "partial"
job["finishedAt"] = time.time()
def download_tile(provider: str, tile_z: int, x: int, y: int) -> None:
if cancel_event.is_set():
return
config = OPEN_MAP_PROVIDERS[provider]
tile_lock = self.tile_lock(f"openmap:{provider}:{tile_z}:{x}:{y}")
if not tile_lock.acquire(timeout=OPENMAP_TILE_LOCK_SECONDS):
if cancel_event.is_set():
return
record_job_result(
"failed",
f"{provider}/{tile_z}/{x}/{y}: waiting for tile lock timed out",
)
return
try:
if cancel_event.is_set():
return
with self._lock_guard:
if job["status"] not in ("queued", "running"):
return
if config["kind"] == "vector":
path = self.vector_tile_cache_path(tile_z, x, y)
else:
path = self.tile_cache_path(config["provider"], tile_z, x, y)
if path.exists() and not force:
record_job_result("skipped")
return
active_label = f"{provider}/{tile_z}/{x}/{y}"
with self._lock_guard:
if job["status"] not in ("queued", "running"):
return
job["activeRequests"] += 1
job["activeTiles"].append(active_label)
try:
if config["kind"] == "vector":
run_with_timeout(
self.fetch_vector_tile,
OPENMAP_VECTOR_FETCH_SECONDS,
f"OpenFreeMap tile {tile_z}/{x}/{y}",
tile_z,
x,
y,
force=force,
cancel_event=cancel_event,
)
else:
timeout_seconds = (
OPENMAP_LONG_RASTER_FETCH_SECONDS
if config["provider"] == "opentopo"
else OPENMAP_RASTER_FETCH_SECONDS
)
data, _ = run_with_timeout(
self.fetch_tile_upstream,
timeout_seconds,
f"{provider} tile {tile_z}/{x}/{y}",
config["provider"],
tile_z,
x,
y,
upstream_timeout_seconds=OPENMAP_UPSTREAM_ATTEMPT_SECONDS,
)
if cancel_event.is_set():
return
atomic_write(path, data)
finally:
with self._lock_guard:
job["activeRequests"] = max(0, job["activeRequests"] - 1)
if active_label in job["activeTiles"]:
job["activeTiles"].remove(active_label)
if not cancel_event.is_set():
record_job_result("succeeded")
except Exception as error:
if cancel_event.is_set():
return
record_job_result(
"failed",
f"{provider}/{tile_z}/{x}/{y}: {error}",
)
finally:
tile_lock.release()
with self._lock_guard:
job["status"] = "running"
if exact_tiles is not None:
tile_iterator = (
(selected[0], tile_z, tile_x, tile_y)
for tile_z, tile_x, tile_y in exact_tiles
)
else:
tile_iterator = (
(provider, tile_z, tile_x, tile_y)
for provider in selected
for tile_z, tile_x0, tile_x1, tile_y0, tile_y1 in tile_ranges
for tile_y in range(tile_y0, tile_y1 + 1)
for tile_x in range(tile_x0, tile_x1 + 1)
)
tile_dispatch_lock = threading.Lock()
def download_worker() -> None:
while not cancel_event.is_set():
with tile_dispatch_lock:
try:
provider, tile_z, tile_x, tile_y = next(tile_iterator)
except StopIteration:
return
download_tile(provider, tile_z, tile_x, tile_y)
for _ in range(OPENMAP_DOWNLOAD_WORKERS):
self._openmap_executor.submit(download_worker)
return dict(job)
def start_openmap_topic_download(
self,
category_id: str,
force: bool = False,
) -> dict[str, Any]:
if category_id not in CACHE_ADMIN_QUERY_LAYERS:
raise ValueError("Unknown OpenFreeMap source topic")
tiles = self.coverage_tiles_for_category(category_id)
if not tiles:
raise ValueError("当前专题没有可下载的来源瓦片,请先浏览或查询对应区域")
job = self.start_openmap_download(
providers=["openfreemap"],
force=force,
tiles=tiles,
)
job["sourceCategory"] = category_id
job["sourceLabel"] = CACHE_ADMIN_QUERY_LAYERS[category_id]["label"]
with self._lock_guard:
stored = self._openmap_jobs.get(job["id"])
if stored is not None:
stored["sourceCategory"] = job["sourceCategory"]
stored["sourceLabel"] = job["sourceLabel"]
return job
def openmap_job_status(self, job_id: str) -> dict[str, Any] | None:
with self._lock_guard:
job = self._openmap_jobs.get(job_id)
return dict(job) if job else None
def cancel_openmap_job(self, job_id: str) -> dict[str, Any] | None:
with self._lock_guard:
job = self._openmap_jobs.get(job_id)
if not job:
return None
if job.get("status") in ("queued", "running"):
event = self._openmap_cancel_events.pop(job_id, None)
if event:
event.set()
job["status"] = "cancelled"
job["cancelledAt"] = time.time()
job["finishedAt"] = job["cancelledAt"]
job["updatedAt"] = job["cancelledAt"]
if len(job["errors"]) < 20:
job["errors"].append("下载任务已由管理员中断;已完成的完整瓦片会保留。")
return dict(job)
def expire_stalled_openmap_jobs(
self,
stall_seconds: float = OPENMAP_JOB_STALL_SECONDS,
) -> None:
now = time.time()
with self._lock_guard:
for job in self._openmap_jobs.values():
if job.get("status") not in ("queued", "running"):
continue
idle_seconds = now - float(job.get("updatedAt", now))
if idle_seconds < stall_seconds:
continue
remaining = max(0, int(job.get("total", 0)) - int(job.get("completed", 0)))
job["completed"] = int(job.get("total", 0))
job["failed"] = int(job.get("failed", 0)) + remaining
job["status"] = "partial"
job["timedOut"] = True
job["finishedAt"] = now
job["updatedAt"] = now
if len(job["errors"]) < 20:
job["errors"].append(
f"下载任务连续 {int(idle_seconds)} 秒没有进展,已结束等待;"
f"{remaining} 个瓦片记为失败,可稍后重试。"
)
def vector_tile_cache_path(self, z: int, x: int, y: int) -> Path:
return self.vector_cache_dir / str(z) / str(x) / f"{y}.pbf"
def openfreemap_tile_template(self) -> str:
with self._vector_template_lock:
if self._vector_template and time.time() - self._vector_template_loaded_at < 6 * 60 * 60:
return self._vector_template
if self.config.cache_only:
return OPENFREEMAP_FALLBACK_TILE_URL
try:
request = urllib.request.Request(
OPENFREEMAP_TILEJSON_URL,
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
)
with self.open_upstream_url(request, 15) as response:
payload = json.load(response)
tiles = payload.get("tiles") or []
if isinstance(tiles, list) and tiles and isinstance(tiles[0], str):
self._vector_template = tiles[0]
self._vector_template_loaded_at = time.time()
return self._vector_template
except Exception:
pass
self._vector_template = self._vector_template or OPENFREEMAP_FALLBACK_TILE_URL
return self._vector_template
def fetch_vector_tile(
self,
z: int,
x: int,
y: int,
force: bool = False,
cancel_event: threading.Event | None = None,
) -> tuple[bytes, bool]:
path = self.vector_tile_cache_path(z, x, y)
if path.exists() and not force:
self.increment("vector_hits")
return path.read_bytes(), True
if self.config.cache_only:
raise CacheOnlyMode(f"Vector tile {z}/{x}/{y} is not cached")
url = self.openfreemap_tile_template().format(z=z, x=x, y=y)
last_error: Exception | None = None
for attempt in range(3):
if cancel_event and cancel_event.is_set():
raise RuntimeError("Vector tile download cancelled")
request = urllib.request.Request(
url,
headers={
"User-Agent": USER_AGENT,
"Accept": "application/x-protobuf,application/octet-stream,*/*",
},
)
try:
with self.open_upstream_url(request, 10) as response:
data = response.read()
if not data:
raise RuntimeError("Empty vector tile")
if cancel_event and cancel_event.is_set():
raise RuntimeError("Vector tile download cancelled")
atomic_write(path, data)
self.increment("vector_misses")
return data, False
except Exception as error:
last_error = error
if attempt < 2:
time.sleep(0.25 * (attempt + 1))
if path.exists():
return path.read_bytes(), True
self.increment("vector_errors")
raise last_error or RuntimeError("Vector tile unavailable")
@staticmethod
def lon_to_tile_x(lon: float, z: int) -> int:
count = 1 << z
return max(0, min(count - 1, int(math.floor((lon + 180.0) / 360.0 * count))))
@staticmethod
def lat_to_tile_y(lat: float, z: int) -> int:
count = 1 << z
clipped = max(-85.05112878, min(85.05112878, lat))
radians = math.radians(clipped)
return max(
0,
min(
count - 1,
int(math.floor((1.0 - math.asinh(math.tan(radians)) / math.pi) / 2.0 * count)),
),
)
def vector_tile_coordinates(
self,
bbox: tuple[float, float, float, float],
z: int,
limit: int = MAX_VECTOR_TILES,
) -> list[tuple[int, int]]:
south, west, north, east = bbox
x0 = self.lon_to_tile_x(west, z)
x1 = self.lon_to_tile_x(east, z)
y0 = self.lat_to_tile_y(north, z)
y1 = self.lat_to_tile_y(south, z)
count = (x1 - x0 + 1) * (y1 - y0 + 1)
if count > limit:
raise ValueError(f"Vector tile request too large: {count} tiles")
return [(x, y) for x in range(x0, x1 + 1) for y in range(y0, y1 + 1)]
def choose_vector_zoom(
self,
bbox: tuple[float, float, float, float],
preferred: int = 14,
max_tiles: int = MAX_VECTOR_TILES,
) -> int:
for z in range(max(4, min(14, preferred)), 3, -1):
south, west, north, east = bbox
x0 = self.lon_to_tile_x(west, z)
x1 = self.lon_to_tile_x(east, z)
y0 = self.lat_to_tile_y(north, z)
y1 = self.lat_to_tile_y(south, z)
if (x1 - x0 + 1) * (y1 - y0 + 1) <= max_tiles:
return z
return 4
def load_vector_layers(
self,
bbox: tuple[float, float, float, float],
tile_zoom: int | None,
force: bool = False,
required_layers: set[str] | None = None,
minimum_source_zoom: int = OPENFREEMAP_SOURCE_MIN_ZOOM,
tile_executor: DaemonTaskPool | None = None,
) -> dict[str, Any]:
requested_zoom = max(
OPENFREEMAP_SOURCE_MIN_ZOOM,
min(ENVIRONMENT_MAX_ZOOM, int(tile_zoom or 12)),
)
source_zoom = min(
OPENFREEMAP_SOURCE_MAX_ZOOM,
max(minimum_source_zoom, requested_zoom),
)
coordinates = self.vector_tile_coordinates(bbox, source_zoom)
if len(coordinates) > MAX_VECTOR_TILES:
raise RuntimeError(f"Vector tile request too large: {len(coordinates)} tiles")
results: queue.Queue[tuple[int, int, bytes | None, str]] = queue.Queue()
def fetch_one(x: int, y: int) -> None:
try:
data, _ = self.fetch_vector_tile(source_zoom, x, y, force=force)
results.put((x, y, data, ""))
except Exception as error:
results.put((x, y, None, str(error)))
executor = tile_executor or self._vector_tile_executor
for x, y in coordinates:
executor.submit(fetch_one, x, y)
layers: dict[str, list[dict[str, Any]]] = {}
succeeded = 0
errors = 0
deadline_seconds = max(60, min(300, (len(coordinates) + 2) // 3 + 20))
deadline = time.time() + deadline_seconds
for _ in coordinates:
remaining = deadline - time.time()
if remaining <= 0:
break
try:
x, y, data, error = results.get(timeout=remaining)
except queue.Empty:
break
if data is None:
errors += 1
continue
succeeded += 1
try:
tile_layers = decode_vector_tile(data, required_layers)
except Exception:
errors += 1
continue
for layer_name, features in tile_layers.items():
for feature in features:
feature["tile"] = (source_zoom, x, y)
layers.setdefault(layer_name, []).append(feature)
if not succeeded:
raise RuntimeError("OpenFreeMap vector tiles unavailable")
failed_tiles = len(coordinates) - succeeded
return {
"tileZoom": requested_zoom,
"sourceTileZoom": source_zoom,
"tileCount": len(coordinates),
"loadedTiles": succeeded,
"failedTiles": max(errors, failed_tiles),
"layers": layers,
}
@staticmethod
def vector_point_to_lonlat(
z: int,
x: int,
y: int,
point: tuple[int, int],
extent: int,
) -> tuple[float, float]:
count = 1 << z
world_x = (x + point[0] / extent) / count
world_y = (y + point[1] / extent) / count
lon = world_x * 360.0 - 180.0
lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * world_y))))
return lon, lat
def vector_rail_response(
self,
bbox: tuple[float, float, float, float],
types: list[str],
tile_zoom: int | None,
force: bool = False,
) -> dict[str, Any]:
result = self.load_vector_layers(
bbox,
tile_zoom,
force=force,
required_layers={"transportation"},
)
elements: list[dict[str, Any]] = []
seen: set[str] = set()
for feature in result["layers"].get("transportation", []):
properties = feature["properties"]
feature_class = properties.get("class")
subclass = properties.get("subclass") or feature_class
service = properties.get("service")
if feature_class not in ("rail", "transit") or feature["type"] != 2:
continue
if service and service not in ("main", None):
continue
is_metro = feature_class == "transit" and subclass in (
"subway", "light_rail", "monorail", "tram", "funicular"
)
if is_metro and "subway" not in types:
continue
if not is_metro and "rail" not in types:
continue
z, x, y = feature["tile"]
extent = feature["extent"]
for path in feature["paths"]:
geometry = []
for point in path:
lon, lat = self.vector_point_to_lonlat(z, x, y, point, extent)
geometry.append({"lat": round(lat, 7), "lon": round(lon, 7)})
if len(geometry) < 2:
continue
fingerprint = hashlib.sha1(
json.dumps(geometry, separators=(",", ":")).encode("utf-8")
).hexdigest()
if fingerprint in seen:
continue
seen.add(fingerprint)
elements.append(
{
"type": "way",
"id": "ofm:" + fingerprint[:16],
"tags": {"railway": "subway" if is_metro else "rail"},
"geometry": geometry,
}
)
return {
"version": 0.6,
"generator": "OpenFreeMap vector tiles",
"source": "openfreemap",
"tileZoom": result["tileZoom"],
"sourceTileZoom": result["sourceTileZoom"],
"tileCount": result["tileCount"],
"loadedTiles": result["loadedTiles"],
"failedTiles": result["failedTiles"],
"elements": elements,
}
def low_zoom_rail_cache_path(
self,
z: int,
x: int,
y: int,
selected: set[str],
) -> Path:
suffix = "-".join(sorted(selected)) or "none"
return self.rail_metro_dir / str(z) / str(x) / f"{y}-{suffix}-v2.json"
def read_low_zoom_rail_cache(
self,
z: int,
x: int,
y: int,
selected: set[str],
) -> list[dict[str, Any]] | None:
path = self.low_zoom_rail_cache_path(z, x, y, selected)
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
elements = payload.get("elements")
return elements if isinstance(elements, list) else None
except Exception:
return None
def schedule_low_zoom_rail_aggregation(
self,
z: int,
x: int,
y: int,
selected: set[str],
force: bool = False,
) -> None:
key = f"{z}:{x}:{y}:{','.join(sorted(selected))}"
with self._lock_guard:
if key in self._rail_aggregate_inflight:
return
self._rail_aggregate_inflight.add(key)
self._rail_aggregate_executor.submit(
self._build_low_zoom_rail_aggregation,
key,
z,
x,
y,
set(selected),
force,
)
def _build_low_zoom_rail_aggregation(
self,
key: str,
z: int,
x: int,
y: int,
selected: set[str],
force: bool,
) -> None:
try:
self.low_zoom_rail_elements(z, x, y, selected, force=force)
finally:
with self._lock_guard:
self._rail_aggregate_inflight.discard(key)
def low_zoom_rail_elements(
self,
z: int,
x: int,
y: int,
selected: set[str],
force: bool = False,
) -> tuple[list[dict[str, Any]], bool]:
if z < 9 or z >= OPENFREEMAP_SOURCE_MAX_ZOOM:
return [], False
path = self.low_zoom_rail_cache_path(z, x, y, selected)
if path.exists() and not force:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload.get("elements", []), True
except Exception:
pass
bbox = vector_tile_bbox(z, x, y)
try:
result = self.load_vector_layers(
bbox,
OPENFREEMAP_SOURCE_MAX_ZOOM,
force=force,
required_layers={"transportation"},
minimum_source_zoom=OPENFREEMAP_SOURCE_MAX_ZOOM,
tile_executor=self._rail_aggregate_fetch_executor,
)
elements: list[dict[str, Any]] = []
seen: set[str] = set()
for index, feature in enumerate(result["layers"].get("transportation", [])):
properties = feature.get("properties") or {}
feature_class = properties.get("class")
subclass = properties.get("subclass") or properties.get("class")
if feature.get("type") != 2:
continue
if feature_class == "transit" and subclass in (
"subway",
"light_rail",
"monorail",
):
if "metro" not in selected:
continue
railway = str(subclass)
elif feature_class == "rail":
if "rail" not in selected:
continue
railway = "rail"
else:
continue
tags: dict[str, Any] = {"railway": railway}
for source_key in (
"name",
"ref",
"operator",
"electrified",
"service",
"usage",
"railway:traffic_mode",
"highspeed",
):
if properties.get(source_key):
tags[source_key] = properties[source_key]
z_source, x_source, y_source = feature["tile"]
extent = feature["extent"]
for path_index, line_path in enumerate(clipped_feature_paths(feature)):
geometry = []
for point in line_path:
lon, lat = self.vector_point_to_lonlat(
z_source, x_source, y_source, point, extent
)
geometry.append({"lat": round(lat, 7), "lon": round(lon, 7)})
if len(geometry) < 2:
continue
fingerprint = hashlib.sha1(
json.dumps(
[geometry, tags],
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
).hexdigest()
if fingerprint in seen:
continue
seen.add(fingerprint)
elements.append(
{
"type": "way",
"id": f"ofm-metro:{fingerprint[:20]}",
"tags": tags,
"geometry": geometry,
}
)
path.parent.mkdir(parents=True, exist_ok=True)
complete = result.get("failedTiles", 0) == 0
if complete:
atomic_write(
path,
json.dumps(
{
"createdAt": time.time(),
"sourceTileZoom": OPENFREEMAP_SOURCE_MAX_ZOOM,
"elements": elements,
},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8"),
)
self.increment("rail_metro_downloads")
return elements, result.get("failedTiles", 0) == 0
except Exception as error:
print(
f"Low-zoom metro aggregation {z}/{x}/{y} failed: "
f"{self.redact_secrets(error)}",
flush=True,
)
self.increment("rail_metro_errors")
return [], False
def rail_tile(
self,
z: int,
x: int,
y: int,
force: bool = False,
include: set[str] | None = None,
) -> dict[str, Any] | None:
if (
z < ENVIRONMENT_MIN_ZOOM
or z > ENVIRONMENT_MAX_ZOOM
or x < 0
or y < 0
or x >= 1 << z
or y >= 1 << z
):
return None
selected = set(include or ("rail", "metro"))
low_zoom_tile = bool(selected and 9 <= z < OPENFREEMAP_SOURCE_MAX_ZOOM)
cache_suffix = ("-".join(sorted(selected)) or "none") + "-v7"
tile_path = (
self.rail_tile_dir / str(z) / str(x) / f"{y}-{cache_suffix}.json"
)
if tile_path.exists() and not force:
try:
cached_payload = json.loads(tile_path.read_text(encoding="utf-8"))
if not low_zoom_tile or int(cached_payload.get("sourceTileZoom", 0)) >= OPENFREEMAP_SOURCE_MAX_ZOOM:
self.increment("rail_tile_hits")
return cached_payload
except Exception:
pass
source_zoom = min(z, OPENFREEMAP_SOURCE_MAX_ZOOM)
source_x = x >> (z - source_zoom)
source_y = y >> (z - source_zoom)
lock_key = f"rail-tile:{z}:{x}:{y}"
with self.tile_lock(lock_key):
if tile_path.exists() and not force:
try:
cached_payload = json.loads(tile_path.read_text(encoding="utf-8"))
if not low_zoom_tile or int(cached_payload.get("sourceTileZoom", 0)) >= OPENFREEMAP_SOURCE_MAX_ZOOM:
self.increment("rail_tile_hits")
return cached_payload
except Exception:
pass
try:
data, _ = self.fetch_vector_tile(source_zoom, source_x, source_y, force=force)
layers = decode_vector_tile(data, {"transportation"})
source_features: list[dict[str, Any]] = []
for index, feature in enumerate(layers.get("transportation", [])):
if feature.get("type") != 2:
continue
properties = feature.get("properties") or {}
feature_class = properties.get("class")
subclass = properties.get("subclass") or feature_class
if feature_class == "transit" and subclass in (
"subway",
"light_rail",
"monorail",
):
if "metro" not in selected:
continue
railway = str(subclass)
elif feature_class == "rail":
if "rail" not in selected:
continue
railway = "rail"
else:
continue
tags: dict[str, Any] = {"railway": railway}
for source_key in (
"name",
"ref",
"operator",
"electrified",
"service",
"usage",
):
if properties.get(source_key):
tags[source_key] = properties[source_key]
z_source, x_source, y_source = source_zoom, source_x, source_y
extent = feature["extent"]
for path_index, path in enumerate(clipped_feature_paths(feature)):
coordinates = [
self.vector_point_to_lonlat(
z_source,
x_source,
y_source,
point,
extent,
)
for point in path
]
if len(coordinates) >= 2:
source_features.append(
{
"type": "Feature",
"id": f"ofm-rail:{source_zoom}:{source_x}:{source_y}:{index}:{path_index}",
"properties": tags,
"geometry": {
"type": "LineString",
"coordinates": [[lon, lat] for lon, lat in coordinates],
},
}
)
clipped = (
{"features": source_features}
if source_zoom == z
else clip_local_vector_payload(
{"features": source_features},
z,
x,
y,
"openfreemap-rail-tile-v1",
)
)
elements: list[dict[str, Any]] = []
seen: set[str] = set()
for feature in clipped.get("features", []):
geometry = feature.get("geometry") or {}
geometry_type = geometry.get("type")
if geometry_type == "LineString":
paths = [geometry.get("coordinates", [])]
elif geometry_type == "MultiLineString":
paths = geometry.get("coordinates", [])
else:
continue
tags = feature.get("properties") or {}
for path in paths:
points = [
{"lat": round(float(point[1]), 7), "lon": round(float(point[0]), 7)}
for point in path
if len(point) >= 2
]
if len(points) < 2:
continue
fingerprint = hashlib.sha1(
json.dumps(
[points, tags],
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
).hexdigest()
if fingerprint in seen:
continue
seen.add(fingerprint)
elements.append(
{
"type": "way",
"id": "ofm-rail:" + fingerprint[:16],
"tags": tags,
"geometry": points,
}
)
aggregating = False
if low_zoom_tile:
detailed_elements = None if force else self.read_low_zoom_rail_cache(
z,
x,
y,
selected,
)
if detailed_elements is None or force:
self.schedule_low_zoom_rail_aggregation(
z,
x,
y,
selected,
force=force,
)
aggregating = detailed_elements is None
if detailed_elements is not None:
elements = detailed_elements
source_zoom = OPENFREEMAP_SOURCE_MAX_ZOOM
payload = {
"format": "rail-tile-v1",
"source": "openfreemap",
"tileZoom": z,
"sourceTileZoom": source_zoom,
"include": sorted(selected),
"x": x,
"y": y,
"aggregating": aggregating,
"elements": elements,
}
if not aggregating:
tile_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write(
tile_path,
json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
self.increment("rail_tiles_generated")
return payload
except Exception as error:
print(
f"Rail tile {z}/{x}/{y} generation failed: "
f"{self.redact_secrets(error)}",
flush=True,
)
self.increment("rail_tile_errors")
return None
def vector_water_response(
self,
bbox: tuple[float, float, float, float],
tile_zoom: int | None,
force: bool = False,
include: list[str] | None = None,
) -> dict[str, Any]:
selected = set(include or ("water", "ports", "vegetation"))
required_layers: set[str] = set()
if "water" in selected:
required_layers.update(("water", "waterway"))
if "ports" in selected:
required_layers.update(("poi", "landuse"))
if "vegetation" in selected:
required_layers.update(("landcover", "landuse"))
minimum_source_zoom = 7 if "vegetation" in selected else OPENFREEMAP_SOURCE_MIN_ZOOM
result = self.load_vector_layers(
bbox,
tile_zoom,
force=force,
required_layers=required_layers,
minimum_source_zoom=minimum_source_zoom,
)
elements: list[dict[str, Any]] = []
seen: set[str] = set()
for layer_name in (("water", "waterway") if "water" in selected else ()):
for feature in result["layers"].get(layer_name, []):
properties = feature["properties"]
water_class = properties.get("class") or "water"
if layer_name == "water" and water_class == "ocean":
continue
z, x, y = feature["tile"]
extent = feature["extent"]
for path in clipped_feature_paths(feature):
geometry = []
for point in path:
lon, lat = self.vector_point_to_lonlat(z, x, y, point, extent)
geometry.append({"lat": round(lat, 7), "lon": round(lon, 7)})
if len(geometry) < 2:
continue
tags = (
{"waterway": water_class, "name": properties.get("name")}
if layer_name == "waterway"
else {"natural": "water", "water": water_class, "name": properties.get("name")}
)
fingerprint = hashlib.sha1(
json.dumps([geometry, tags], separators=(",", ":"), sort_keys=True).encode("utf-8")
).hexdigest()
if fingerprint in seen:
continue
seen.add(fingerprint)
elements.append(
{
"type": "way",
"id": "ofm-water:" + fingerprint[:16],
"tags": tags,
"geometry": geometry,
}
)
def convert_features(layer_names, allowed_classes, prefix, tag_builder):
converted = []
seen_features: set[str] = set()
for layer_name in layer_names:
for feature in result["layers"].get(layer_name, []):
properties = feature["properties"]
feature_class = properties.get("class") or ""
subclass = properties.get("subclass") or feature_class
if feature_class not in allowed_classes and subclass not in allowed_classes:
continue
z, x, y = feature["tile"]
extent = feature["extent"]
for path in clipped_feature_paths(feature):
geometry = []
for point in path:
lon, lat = self.vector_point_to_lonlat(z, x, y, point, extent)
geometry.append({"lat": round(lat, 7), "lon": round(lon, 7)})
if not geometry:
continue
tags = tag_builder(properties, feature_class, subclass)
fingerprint = hashlib.sha1(
json.dumps([geometry, tags], separators=(",", ":"), sort_keys=True).encode("utf-8")
).hexdigest()
if fingerprint in seen_features:
continue
seen_features.add(fingerprint)
converted.append(
{
"type": "point" if feature["type"] == 1 else "way",
"id": prefix + fingerprint[:16],
"tags": tags,
"geometry": geometry,
}
)
return converted
ports = convert_features(
("poi", "landuse"),
{"harbor", "ferry_terminal", "harbour", "port", "dock", "marina"},
"ofm-port:",
lambda props, cls, sub: {
"port": sub if cls == "poi" else cls,
"name": props.get("name"),
},
) if "ports" in selected else []
vegetation = convert_features(
("landcover", "landuse"),
{
"wood", "forest", "grass", "scrub", "heath", "wetland",
"meadow", "orchard", "vineyard", "farmland", "allotments",
"plant_nursery", "recreation_ground",
},
"ofm-vegetation:",
lambda props, cls, sub: {
"vegetation": sub,
"class": cls,
"name": props.get("name"),
},
) if "vegetation" in selected else []
return {
"version": 0.6,
"generator": "OpenFreeMap vector tiles",
"source": "openfreemap",
"tileZoom": result["tileZoom"],
"sourceTileZoom": result["sourceTileZoom"],
"tileCount": result["tileCount"],
"loadedTiles": result["loadedTiles"],
"failedTiles": result["failedTiles"],
"elements": elements,
"ports": ports,
"vegetation": vegetation,
}
def vector_boundary_response(
self,
bbox: tuple[float, float, float, float],
tile_zoom: int | None,
force: bool = False,
) -> dict[str, Any]:
result = self.load_vector_layers(
bbox,
tile_zoom,
force=force,
required_layers={"boundary"},
)
elements: list[dict[str, Any]] = []
seen: set[str] = set()
for feature in result["layers"].get("boundary", []):
if feature["type"] != 2:
continue
properties = feature["properties"]
if properties.get("maritime"):
continue
try:
admin_level = int(properties.get("admin_level"))
except (TypeError, ValueError):
admin_level = 6
if not 2 <= admin_level <= 10:
continue
tags: dict[str, Any] = {
"boundary": "administrative",
"admin_level": admin_level,
}
name = properties.get("name") or properties.get("name:zh")
if name:
tags["name"] = name
if properties.get("disputed"):
tags["disputed"] = True
z, x, y = feature["tile"]
extent = feature["extent"]
for path in feature["paths"]:
geometry = []
for point in path:
lon, lat = self.vector_point_to_lonlat(z, x, y, point, extent)
geometry.append({"lat": round(lat, 7), "lon": round(lon, 7)})
if len(geometry) < 2:
continue
fingerprint = hashlib.sha1(
json.dumps(geometry, separators=(",", ":")).encode("utf-8")
).hexdigest()
if fingerprint in seen:
continue
seen.add(fingerprint)
elements.append(
{
"type": "way",
"id": "ofm-boundary:" + fingerprint[:16],
"tags": tags,
"geometry": geometry,
}
)
return {
"version": 0.6,
"generator": "OpenFreeMap vector tiles",
"source": "openfreemap",
"tileZoom": result["tileZoom"],
"tileCount": result["tileCount"],
"loadedTiles": result["loadedTiles"],
"failedTiles": result["failedTiles"],
"elements": elements,
}
def boundary_cache_path(self, cache_key: str) -> Path:
return self.boundary_cache_dir / f"{cache_key}.json"
@staticmethod
def cache_only_vector_response(
kind: str,
tile_zoom: int,
include: list[str] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"ok": True,
"version": 0.6,
"generator": "Map Studio cache-only placeholder",
"source": "cache-only",
"cacheOnly": True,
"placeholder": True,
"tileZoom": tile_zoom,
"tileCount": 0,
"loadedTiles": 0,
"failedTiles": 0,
"elements": [],
}
if kind == "water":
selected = set(include or ("water", "ports", "vegetation"))
payload["ports"] = []
payload["vegetation"] = []
payload["include"] = sorted(selected)
return payload
def get_boundaries(
self,
bbox: tuple[float, float, float, float],
force: bool,
tile_zoom: int | None = None,
) -> tuple[dict[str, Any], str]:
vector_zoom = max(4, min(14, int(tile_zoom or 12)))
cache_key = self.vector_bbox_cache_key(bbox, vector_zoom, ["admin-boundaries-v1"])
path = self.boundary_cache_path(cache_key)
cached_response: dict[str, Any] | None = None
if path.exists() and not force:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
cached_response = payload["response"]
if time.time() - path.stat().st_mtime <= self.config.rail_ttl_seconds:
self.increment("boundary_hits")
return cached_response, "HIT"
except Exception:
cached_response = None
if self.config.cache_only:
if cached_response is None and path.exists():
try:
cached_response = json.loads(
path.read_text(encoding="utf-8")
)["response"]
except Exception:
cached_response = None
if cached_response is not None:
return cached_response, "STALE"
missing_tiles = self.vector_cache_missing(
bbox,
vector_zoom,
force=False,
)
if missing_tiles == 0:
try:
response = self.vector_boundary_response(
bbox,
vector_zoom,
force=False,
)
if (
response.get("failedTiles") == 0
and response.get("loadedTiles") == response.get("tileCount")
):
return response, "HIT"
except Exception:
pass
return self.cache_only_vector_response("boundary", vector_zoom), "PLACEHOLDER"
try:
response = self.vector_boundary_response(bbox, vector_zoom, force=force)
complete = (
response.get("failedTiles") == 0
and response.get("loadedTiles") == response.get("tileCount")
)
if complete:
atomic_write(
path,
json.dumps(
{
"createdAt": time.time(),
"query": "OpenFreeMap administrative boundary vector tiles",
"response": response,
},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8"),
)
if cached_response is not None and not complete:
return cached_response, "STALE"
self.increment("boundary_misses")
return response, "MISS"
except Exception:
self.increment("boundary_errors")
if cached_response is not None:
return cached_response, "STALE"
raise
@staticmethod
def compact_vector_response(response: dict[str, Any], kind: str) -> dict[str, Any]:
if response.get("source") != "openfreemap":
return response
def compact_group(source_elements: list[dict[str, Any]], group_kind: str) -> list[dict[str, Any]]:
compact_elements: list[dict[str, Any]] = []
for element in source_elements:
geometry = element.get("geometry") or []
if not geometry:
continue
first = geometry[0]
previous_lat = int(round(float(first["lat"]) * 10_000_000))
previous_lon = int(round(float(first["lon"]) * 10_000_000))
values = [previous_lat, previous_lon]
for point in geometry[1:]:
current_lat = int(round(float(point["lat"]) * 10_000_000))
current_lon = int(round(float(point["lon"]) * 10_000_000))
values.extend((current_lat - previous_lat, current_lon - previous_lon))
previous_lat = current_lat
previous_lon = current_lon
tags = element.get("tags") or {}
item: dict[str, Any] = {"i": element.get("id"), "g": values}
if group_kind == "rail":
item["r"] = tags.get("railway") or tags.get("route") or "rail"
for source_key, target_key in (
("name", "n"),
("ref", "f"),
("operator", "o"),
("electrified", "e"),
):
if tags.get(source_key):
item[target_key] = tags[source_key]
elif group_kind == "boundary":
item["a"] = int(tags.get("admin_level") or 6)
if tags.get("name"):
item["n"] = tags["name"]
if tags.get("disputed"):
item["d"] = 1
elif group_kind == "port":
item["p"] = tags.get("port") or "port"
if tags.get("name"):
item["n"] = tags["name"]
elif group_kind == "vegetation":
item["v"] = tags.get("vegetation") or tags.get("class") or "vegetation"
if tags.get("name"):
item["n"] = tags["name"]
else:
item["k"] = "L" if tags.get("waterway") else "P"
item["w"] = tags.get("waterway") or tags.get("water") or "water"
if tags.get("name"):
item["n"] = tags["name"]
compact_elements.append(item)
return compact_elements
compact = dict(response)
compact["format"] = "delta-v1"
compact["elements"] = compact_group(response.get("elements", []), kind)
if kind == "water":
compact["ports"] = compact_group(response.get("ports", []), "port")
compact["vegetation"] = compact_group(response.get("vegetation", []), "vegetation")
return compact
def rail_cache_path(self, cache_key: str) -> Path:
return self.rail_cache_dir / f"{cache_key}.json"
def pending_rail_path(self, cache_key: str) -> Path:
return self.pending_rail_dir / f"{cache_key}.json"
def rail_cache_key(self, bbox: tuple[float, float, float, float], types: list[str]) -> str:
key_source = json.dumps(
{
"schema": 2,
"bbox": [round(value, 5) for value in bbox],
"types": sorted(types),
},
separators=(",", ":"),
)
return hashlib.sha1(key_source.encode("utf-8")).hexdigest()
def vector_bbox_cache_key(
self,
bbox: tuple[float, float, float, float],
tile_zoom: int,
types: list[str] | None = None,
minimum_source_zoom: int = OPENFREEMAP_SOURCE_MIN_ZOOM,
) -> str:
source_zoom = min(
OPENFREEMAP_SOURCE_MAX_ZOOM,
max(minimum_source_zoom, OPENFREEMAP_SOURCE_MIN_ZOOM, tile_zoom),
)
coordinates = self.vector_tile_coordinates(bbox, source_zoom)
xs = [item[0] for item in coordinates]
ys = [item[1] for item in coordinates]
key_source = json.dumps(
{
"schema": 3,
"z": tile_zoom,
"sourceZ": source_zoom,
"x": [min(xs), max(xs)],
"y": [min(ys), max(ys)],
"types": sorted(types or []),
},
separators=(",", ":"),
)
return hashlib.sha1(key_source.encode("utf-8")).hexdigest()
def queue_pending_rail(
self,
cache_key: str,
bbox: tuple[float, float, float, float],
types: list[str],
query: str,
error: str,
job_kind: str = "overpass",
tile_zoom: int | None = None,
force_tiles: bool = False,
) -> None:
path = self.pending_rail_path(cache_key)
existing: dict[str, Any] = {}
if path.exists():
try:
existing = json.loads(path.read_text(encoding="utf-8"))
except Exception:
existing = {}
payload = {
"cacheKey": cache_key,
"bbox": bbox,
"types": types,
"query": query,
"jobKind": job_kind,
"tileZoom": tile_zoom,
"forceTiles": force_tiles,
"createdAt": existing.get("createdAt", time.time()),
"updatedAt": time.time(),
"nextAttempt": time.time() + 30,
"attempts": int(existing.get("attempts", 0)) + 1,
"lastError": error,
}
atomic_write(path, json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
self.increment("rail_pending")
def vector_cache_missing(
self,
bbox: tuple[float, float, float, float],
tile_zoom: int,
force: bool = False,
minimum_source_zoom: int = OPENFREEMAP_SOURCE_MIN_ZOOM,
) -> int:
source_zoom = min(
OPENFREEMAP_SOURCE_MAX_ZOOM,
max(minimum_source_zoom, OPENFREEMAP_SOURCE_MIN_ZOOM, tile_zoom),
)
coordinates = self.vector_tile_coordinates(bbox, source_zoom)
if force:
return len(coordinates)
return sum(
1 for x, y in coordinates
if not self.vector_tile_cache_path(source_zoom, x, y).exists()
)
def complete_vector_rail_pending(self, path: Path) -> bool:
if self.config.cache_only:
return False
try:
pending = json.loads(path.read_text(encoding="utf-8"))
cache_key = str(pending["cacheKey"])
bbox = tuple(float(value) for value in pending["bbox"])
types = [str(value) for value in pending.get("types", ["rail", "subway"])]
tile_zoom = int(pending.get("tileZoom") or 12)
response = self.vector_rail_response(
bbox, # type: ignore[arg-type]
types,
tile_zoom,
force=bool(pending.get("forceTiles", False)),
)
if response.get("failedTiles") or response.get("loadedTiles") != response.get("tileCount"):
raise RuntimeError("OpenFreeMap rail tiles incomplete")
atomic_write(
self.rail_cache_path(cache_key),
json.dumps(
{"createdAt": time.time(), "query": "vector-or-overpass", "response": response},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8"),
)
path.unlink(missing_ok=True)
return True
except Exception as error:
try:
pending = json.loads(path.read_text(encoding="utf-8"))
pending["updatedAt"] = time.time()
pending["nextAttempt"] = time.time() + 10
pending["attempts"] = int(pending.get("attempts", 0)) + 1
pending["lastError"] = str(error)
atomic_write(
path,
json.dumps(pending, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
except Exception:
pass
return False
def pending_rail_status(self, cache_key: str) -> dict[str, Any]:
cache_path = self.rail_cache_path(cache_key)
if cache_path.exists():
return {
"ok": True,
"ready": True,
"cacheKey": cache_key,
"createdAt": cache_path.stat().st_mtime,
}
pending_path = self.pending_rail_path(cache_key)
if pending_path.exists():
try:
pending = json.loads(pending_path.read_text(encoding="utf-8"))
return {
"ok": True,
"ready": False,
"cacheKey": cache_key,
"attempts": pending.get("attempts", 0),
"lastError": pending.get("lastError", ""),
"updatedAt": pending.get("updatedAt"),
}
except Exception:
pass
return {"ok": False, "ready": False, "cacheKey": cache_key, "error": "Unknown rail cache key"}
def retry_pending_rail_jobs(self, limit: int | None = None) -> int:
if self.config.cache_only:
return 0
processed = 0
max_items = limit or self.config.refresh_batch
now = time.time()
for path in self.pending_rail_dir.glob("*.json"):
if processed >= max_items:
break
try:
pending = json.loads(path.read_text(encoding="utf-8"))
if now < float(pending.get("nextAttempt", 0)):
continue
if pending.get("jobKind") != "vector":
bbox = tuple(float(value) for value in pending["bbox"])
pending["jobKind"] = "vector"
pending["tileZoom"] = self.choose_vector_zoom(bbox) # type: ignore[arg-type]
pending["forceTiles"] = False
pending["nextAttempt"] = now
atomic_write(
path,
json.dumps(pending, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
self.complete_vector_rail_pending(path)
processed += 1
continue
cache_key = str(pending["cacheKey"])
query = str(pending["query"])
response = self.fetch_overpass(query)
if not response.get("elements"):
raise RuntimeError("Overpass returned no elements")
cache_payload = {
"createdAt": time.time(),
"query": query,
"response": response,
}
atomic_write(
self.rail_cache_path(cache_key),
json.dumps(cache_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
path.unlink(missing_ok=True)
except Exception as error:
try:
pending = json.loads(path.read_text(encoding="utf-8"))
pending["updatedAt"] = time.time()
pending["nextAttempt"] = time.time() + 120
pending["attempts"] = int(pending.get("attempts", 0)) + 1
pending["lastError"] = str(error)
atomic_write(
path,
json.dumps(pending, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
except Exception:
pass
processed += 1
return processed
def water_cache_path(self, cache_key: str) -> Path:
return self.water_cache_dir / f"{cache_key}.json"
def build_water_query(
self,
south: float,
west: float,
north: float,
east: float,
) -> str:
bbox = f"{south:.6f},{west:.6f},{north:.6f},{east:.6f}"
return (
"[out:json][timeout:60];\n(\n"
f' way["natural"="water"]({bbox});\n'
f' way["water"="reservoir"]({bbox});\n'
f' way["waterway"="riverbank"]({bbox});\n'
f' way["waterway"~"^(river|canal|stream|drain)$"]({bbox});\n'
f' relation["natural"="water"]({bbox});\n'
f' relation["water"="reservoir"]({bbox});\n'
f' relation["waterway"="riverbank"]({bbox});\n'
");\nout geom;"
)
def refresh_water_path(self, path: Path) -> bool:
if self.config.cache_only:
return False
try:
payload = json.loads(path.read_text(encoding="utf-8"))
query = payload["query"]
response = self.fetch_overpass(query)
atomic_write(
path,
json.dumps(
{
"createdAt": time.time(),
"query": query,
"response": response,
},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8"),
)
return True
except Exception:
return False
finally:
with self._lock_guard:
self._refreshing.discard(str(path))
def queue_pending_water(
self,
cache_key: str,
bbox: tuple[float, float, float, float],
query: str,
error: str,
job_kind: str = "overpass",
tile_zoom: int | None = None,
force_tiles: bool = False,
include: list[str] | None = None,
) -> None:
path = self.pending_water_dir / f"{cache_key}.json"
existing: dict[str, Any] = {}
if path.exists():
try:
existing = json.loads(path.read_text(encoding="utf-8"))
except Exception:
existing = {}
pending = {
"cacheKey": cache_key,
"bbox": bbox,
"query": query,
"jobKind": job_kind,
"tileZoom": tile_zoom,
"forceTiles": force_tiles,
"include": include or ["water", "ports", "vegetation"],
"createdAt": existing.get("createdAt", time.time()),
"updatedAt": time.time(),
"nextAttempt": time.time() + 30,
"attempts": int(existing.get("attempts", 0)) + 1,
"lastError": error,
}
atomic_write(
path,
json.dumps(pending, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
self.increment("water_pending")
def complete_vector_water_pending(self, path: Path) -> bool:
if self.config.cache_only:
return False
try:
pending = json.loads(path.read_text(encoding="utf-8"))
cache_key = str(pending["cacheKey"])
bbox = tuple(float(value) for value in pending["bbox"])
tile_zoom = int(pending.get("tileZoom") or 12)
response = self.vector_water_response(
bbox, # type: ignore[arg-type]
tile_zoom,
force=bool(pending.get("forceTiles", False)),
include=[str(value) for value in pending.get("include", ["water", "ports", "vegetation"])],
)
if response.get("failedTiles") or response.get("loadedTiles") != response.get("tileCount"):
raise RuntimeError("OpenFreeMap water tiles incomplete")
atomic_write(
self.water_cache_path(cache_key),
json.dumps(
{"createdAt": time.time(), "query": "vector-or-overpass", "response": response},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8"),
)
path.unlink(missing_ok=True)
return True
except Exception as error:
try:
pending = json.loads(path.read_text(encoding="utf-8"))
pending["updatedAt"] = time.time()
pending["nextAttempt"] = time.time() + 10
pending["attempts"] = int(pending.get("attempts", 0)) + 1
pending["lastError"] = str(error)
atomic_write(
path,
json.dumps(pending, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
except Exception:
pass
return False
def get_water(
self,
bbox: tuple[float, float, float, float],
force: bool,
tile_zoom: int | None = None,
include: list[str] | None = None,
) -> tuple[dict[str, Any], str]:
selected_include = [
value for value in (include or ["water", "ports", "vegetation"])
if value in ("water", "ports", "vegetation")
]
if not selected_include:
raise ValueError("No environment layers selected")
vector_zoom = max(
ENVIRONMENT_MIN_ZOOM,
min(ENVIRONMENT_MAX_ZOOM, int(tile_zoom or 12)),
)
minimum_source_zoom = 7 if "vegetation" in selected_include else OPENFREEMAP_SOURCE_MIN_ZOOM
cache_key = self.vector_bbox_cache_key(
bbox,
vector_zoom,
["environment-v6", *sorted(selected_include)],
minimum_source_zoom=minimum_source_zoom,
)
pending_path = self.pending_water_dir / f"{cache_key}.json"
path = self.water_cache_path(cache_key)
has_vector_result = False
result_is_stale = False
if path.exists() and not force:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
response = payload["response"]
if response.get("source") == "openfreemap":
result_is_stale = time.time() - path.stat().st_mtime > self.config.rail_ttl_seconds
if not result_is_stale:
has_vector_result = True
self.increment("water_hits")
return response, "HIT"
except Exception:
pass
if self.config.cache_only:
if path.exists():
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload["response"], "STALE"
except Exception:
pass
missing_tiles = self.vector_cache_missing(
bbox,
vector_zoom,
force=False,
minimum_source_zoom=minimum_source_zoom,
)
if missing_tiles == 0:
try:
response = self.vector_water_response(
bbox,
vector_zoom,
force=False,
include=selected_include,
)
if (
response.get("failedTiles") == 0
and response.get("loadedTiles") == response.get("tileCount")
):
return response, "HIT"
except Exception:
pass
return self.cache_only_vector_response(
"water",
vector_zoom,
include=selected_include,
), "PLACEHOLDER"
missing_tiles = self.vector_cache_missing(
bbox,
vector_zoom,
force=force,
minimum_source_zoom=minimum_source_zoom,
)
needs_vector_result = force or result_is_stale or not has_vector_result
if missing_tiles or needs_vector_result:
with self.tile_lock("water-pending:" + cache_key):
existing_kind = ""
if pending_path.exists():
try:
existing_kind = str(
json.loads(pending_path.read_text(encoding="utf-8")).get("jobKind", "")
)
except Exception:
existing_kind = ""
if existing_kind != "vector":
self.queue_pending_water(
cache_key,
bbox,
"",
f"Queued {missing_tiles} OpenFreeMap vector tiles",
job_kind="vector",
tile_zoom=vector_zoom,
force_tiles=force or result_is_stale,
include=selected_include,
)
self._download_executor.submit(self.complete_vector_water_pending, pending_path)
raise WaterPending(cache_key)
south, west, north, east = bbox
if path.exists() and not force:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
age = time.time() - path.stat().st_mtime
if age <= self.config.rail_ttl_seconds:
self.increment("water_hits")
return payload["response"], "HIT"
key = str(path)
with self._lock_guard:
needs_refresh = key not in self._refreshing
if needs_refresh:
self._refreshing.add(key)
if needs_refresh:
self._refresh_executor.submit(self.refresh_water_path, path)
return payload["response"], "STALE"
except Exception:
pass
if pending_path.exists() and not force:
raise WaterPending(cache_key)
query = self.build_water_query(south, west, north, east)
try:
response = self.fetch_overpass(query)
atomic_write(
path,
json.dumps(
{
"createdAt": time.time(),
"query": query,
"response": response,
},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8"),
)
self.increment("water_misses")
return response, "MISS"
except Exception as error:
self.increment("water_errors")
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
return payload["response"], "STALE"
existing: dict[str, Any] = {}
if pending_path.exists():
try:
existing = json.loads(pending_path.read_text(encoding="utf-8"))
except Exception:
existing = {}
pending = {
"cacheKey": cache_key,
"bbox": bbox,
"query": query,
"createdAt": existing.get("createdAt", time.time()),
"updatedAt": time.time(),
"nextAttempt": time.time() + 30,
"attempts": int(existing.get("attempts", 0)) + 1,
"lastError": str(error),
}
atomic_write(
pending_path,
json.dumps(pending, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
self.increment("water_pending")
raise WaterPending(cache_key)
def water_pending_status(self, cache_key: str) -> dict[str, Any]:
cache_path = self.water_cache_path(cache_key)
if cache_path.exists():
return {
"ok": True,
"ready": True,
"cacheKey": cache_key,
"createdAt": cache_path.stat().st_mtime,
}
pending_path = self.pending_water_dir / f"{cache_key}.json"
if pending_path.exists():
try:
pending = json.loads(pending_path.read_text(encoding="utf-8"))
return {
"ok": True,
"ready": False,
"cacheKey": cache_key,
"attempts": pending.get("attempts", 0),
"lastError": pending.get("lastError", ""),
"updatedAt": pending.get("updatedAt"),
}
except Exception:
pass
return {"ok": False, "ready": False, "cacheKey": cache_key}
def retry_pending_water_jobs(self, limit: int | None = None) -> int:
if self.config.cache_only:
return 0
processed = 0
max_items = limit or self.config.refresh_batch
now = time.time()
for path in self.pending_water_dir.glob("*.json"):
if processed >= max_items:
break
try:
pending = json.loads(path.read_text(encoding="utf-8"))
if now < float(pending.get("nextAttempt", 0)):
continue
if pending.get("jobKind") != "vector":
bbox = tuple(float(value) for value in pending["bbox"])
pending["jobKind"] = "vector"
pending["tileZoom"] = self.choose_vector_zoom(bbox) # type: ignore[arg-type]
pending["forceTiles"] = False
pending["nextAttempt"] = now
atomic_write(
path,
json.dumps(pending, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
self.complete_vector_water_pending(path)
processed += 1
continue
response = self.fetch_overpass(str(pending["query"]))
if not response.get("elements"):
raise RuntimeError("Overpass returned no water elements")
atomic_write(
self.water_cache_path(str(pending["cacheKey"])),
json.dumps(
{
"createdAt": time.time(),
"query": pending["query"],
"response": response,
},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8"),
)
path.unlink(missing_ok=True)
except Exception as error:
try:
pending = json.loads(path.read_text(encoding="utf-8"))
pending["updatedAt"] = time.time()
pending["nextAttempt"] = time.time() + 120
pending["attempts"] = int(pending.get("attempts", 0)) + 1
pending["lastError"] = str(error)
atomic_write(
path,
json.dumps(pending, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
except Exception:
pass
processed += 1
return processed
def build_overpass_query(self, south: float, west: float, north: float, east: float, types: list[str]) -> str:
bbox = f"{south:.6f},{west:.6f},{north:.6f},{east:.6f}"
requests = []
if "rail" in types:
requests.append(f'way["railway"="rail"][!"service"]({bbox});')
if "subway" in types:
# Beijing uses subway for metro tracks, while other Chinese cities also
# use light_rail/monorail for parts of their urban rail network.
requests.append(
f'way["railway"~"^(subway|light_rail|monorail)$"][!"service"]({bbox});'
)
# Route relations reconnect OSM ways that are split at stations and
# junctions, so the frontend can draw continuous lines.
requests.append(
f'relation["route"~"^(subway|light_rail|monorail)$"]({bbox});'
)
return "[out:json][timeout:90];\n(\n " + "\n ".join(requests) + "\n);\nout geom;"
def fetch_overpass(self, query: str) -> dict[str, Any]:
if self.config.cache_only:
raise CacheOnlyMode("Overpass requests are disabled by cache-only mode")
body = urllib.parse.urlencode({"data": query}).encode("utf-8")
task_pool = DaemonTaskPool(len(OVERPASS_ENDPOINTS), "overpass-query")
results: queue.Queue[tuple[bool, Any]] = queue.Queue()
def fetch_endpoint(endpoint: str) -> None:
request = urllib.request.Request(
endpoint,
data=body,
method="POST",
headers={
"User-Agent": USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
)
try:
results.put((True, self._read_json(request, 35)))
except Exception as error:
results.put((False, error))
for endpoint in OVERPASS_ENDPOINTS:
task_pool.submit(fetch_endpoint, endpoint)
empty_result: dict[str, Any] | None = None
deadline = time.time() + 40
try:
for _ in OVERPASS_ENDPOINTS:
remaining = deadline - time.time()
if remaining <= 0:
break
try:
succeeded, value = results.get(timeout=remaining)
if not succeeded:
continue
payload = value
elements = payload.get("elements", [])
if elements:
return payload
if empty_result is None:
empty_result = payload
except queue.Empty:
continue
if empty_result is not None:
return empty_result
raise RuntimeError("All Overpass endpoints failed")
finally:
task_pool.shutdown()
def _read_json(self, request: urllib.request.Request, timeout: int) -> dict[str, Any]:
with self.open_upstream_url(request, timeout) as response:
return json.load(response)
def refresh_rail_path(self, path: Path) -> bool:
if self.config.cache_only:
return False
try:
payload = json.loads(path.read_text(encoding="utf-8"))
query = payload.get("query")
if not isinstance(query, str):
return False
response = self.fetch_overpass(query)
updated = {
"createdAt": time.time(),
"query": query,
"response": response,
}
atomic_write(
path,
json.dumps(updated, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
return True
except Exception:
return False
finally:
with self._lock_guard:
self._refreshing.discard(str(path))
def get_rail(
self,
bbox: tuple[float, float, float, float],
types: list[str],
force: bool,
tile_zoom: int | None = None,
) -> tuple[dict[str, Any], str]:
vector_zoom = max(4, min(14, int(tile_zoom or 12)))
cache_key = self.vector_bbox_cache_key(bbox, vector_zoom, types)
pending_path = self.pending_rail_path(cache_key)
path = self.rail_cache_path(cache_key)
has_vector_result = False
result_is_stale = False
if path.exists() and not force:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
response = payload["response"]
if response.get("source") == "openfreemap":
result_is_stale = time.time() - path.stat().st_mtime > self.config.rail_ttl_seconds
if not result_is_stale:
has_vector_result = True
self.increment("rail_hits")
return response, "HIT"
except Exception:
pass
if self.config.cache_only:
if path.exists():
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload["response"], "STALE"
except Exception:
pass
missing_tiles = self.vector_cache_missing(
bbox,
vector_zoom,
force=False,
)
if missing_tiles == 0:
try:
response = self.vector_rail_response(
bbox,
types,
vector_zoom,
force=False,
)
if (
response.get("failedTiles") == 0
and response.get("loadedTiles") == response.get("tileCount")
):
return response, "HIT"
except Exception:
pass
return self.cache_only_vector_response("rail", vector_zoom), "PLACEHOLDER"
missing_tiles = self.vector_cache_missing(bbox, vector_zoom, force=force)
needs_vector_result = force or result_is_stale or not has_vector_result
if missing_tiles or needs_vector_result:
with self.tile_lock("rail-pending:" + cache_key):
existing_kind = ""
if pending_path.exists():
try:
existing_kind = str(
json.loads(pending_path.read_text(encoding="utf-8")).get("jobKind", "")
)
except Exception:
existing_kind = ""
if existing_kind != "vector":
self.queue_pending_rail(
cache_key,
bbox,
types,
"",
f"Queued {missing_tiles} OpenFreeMap vector tiles",
job_kind="vector",
tile_zoom=vector_zoom,
force_tiles=force or result_is_stale,
)
self._download_executor.submit(self.complete_vector_rail_pending, pending_path)
raise RailPending(cache_key)
south, west, north, east = bbox
query = self.build_overpass_query(south, west, north, east, types)
if path.exists() and not force:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
age = time.time() - path.stat().st_mtime
response = payload["response"]
if age <= self.config.rail_ttl_seconds:
self.increment("rail_hits")
return response, "HIT"
self.increment("rail_stale")
refresh_key = str(path)
with self._lock_guard:
needs_refresh = refresh_key not in self._refreshing
if needs_refresh:
self._refreshing.add(refresh_key)
if needs_refresh:
self._refresh_executor.submit(self.refresh_rail_path, path)
return response, "STALE"
except Exception:
pass
if pending_path.exists() and not force:
raise RailPending(cache_key)
try:
response = self.fetch_overpass(query)
payload = {
"createdAt": time.time(),
"query": query,
"response": response,
}
atomic_write(
path,
json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
self.increment("rail_misses")
return response, "MISS"
except Exception as error:
self.increment("rail_errors")
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
return payload["response"], "STALE"
self.queue_pending_rail(cache_key, bbox, types, query, str(error))
raise RailPending(cache_key)
def cache_category_paths(self) -> dict[str, list[Path]]:
raster_providers = [
name for name, provider in TILE_PROVIDERS.items()
if not provider.get("manual_only")
]
tianditu_providers = [
name for name, provider in TILE_PROVIDERS.items()
if provider.get("manual_only")
]
return {
"raster": [self.tile_cache_dir / name for name in raster_providers],
"tianditu": [self.tile_cache_dir / name for name in tianditu_providers],
"vector": [self.vector_cache_dir],
"rail": [self.rail_cache_dir],
"environment": [self.water_cache_dir],
"boundaries": [self.boundary_cache_dir],
"rail-derived": [self.rail_tile_dir, self.rail_metro_dir],
"dynamic-vector": [self.local_vector_dynamic_dir, self.local_vector_25_dynamic_dir],
"pending": [self.pending_rail_dir, self.pending_water_dir],
"staging": [self.cache_dir / DOWNLOAD_STAGING_DIR_NAME],
}
@staticmethod
def cache_admin_category_info(category_id: str) -> dict[str, Any] | None:
query_layer = CACHE_ADMIN_QUERY_LAYERS.get(category_id)
if query_layer is not None:
return query_layer
return CACHE_ADMIN_CATEGORIES.get(category_id)
def response_layer_has_features(self, category_id: str, layer_key: str, path: Path) -> bool:
return layer_key in self.response_layer_coverage(category_id, path)
def delete_response_layer_file(
self,
category_id: str,
layer_key: str,
path: Path,
) -> tuple[int, int]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
response = payload.get("response") if isinstance(payload, dict) else None
if not isinstance(response, dict):
return 0, 0
grouped = self.grouped_response_features(category_id, response)
target_features = grouped.get(layer_key) or []
if not target_features:
return 0, 0
old_size = path.stat().st_size
target_ids = {id(feature) for feature in target_features}
if category_id == "rail":
response["elements"] = [
feature for feature in response.get("elements", [])
if id(feature) not in target_ids
]
elif category_id == "environment":
field = "elements" if layer_key == "water" else layer_key
response[field] = [
feature for feature in response.get(field, [])
if id(feature) not in target_ids
]
elif category_id == "boundaries":
response["elements"] = []
remaining = any(
response.get(field)
for field in ("elements", "vegetation", "ports")
)
if not remaining:
path.unlink()
return 1, old_size
atomic_write(
path,
json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
return 1, max(0, old_size - path.stat().st_size)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return 0, 0
@staticmethod
def parse_cache_bounds(value: Any) -> tuple[float, float, float, float] | None:
if value is None:
return None
if not isinstance(value, (list, tuple)) or len(value) != 4:
raise ValueError("Cache bounds must contain south, west, north and east")
try:
south, west, north, east = (float(item) for item in value)
except (TypeError, ValueError) as error:
raise ValueError("Cache bounds must be numeric") from error
if not all(math.isfinite(item) for item in (south, west, north, east)):
raise ValueError("Cache bounds must be finite")
if south < -85.05112878 or north > 85.05112878 or south >= north:
raise ValueError("Cache bounds have an invalid latitude range")
if west < -180.0 or east > 180.0 or west >= east:
raise ValueError("Cache bounds have an invalid longitude range")
return south, west, north, east
@staticmethod
def cache_bounds_intersect(
first: tuple[float, float, float, float] | list[float],
second: tuple[float, float, float, float] | list[float],
) -> bool:
return (
first[0] <= second[2]
and first[2] >= second[0]
and first[1] <= second[3]
and first[3] >= second[1]
)
@staticmethod
def cache_tile_coordinate(
category_id: str,
path: Path,
root: Path,
) -> tuple[int, int, int] | None:
suffixes = CACHE_COVERAGE_TILE_SUFFIXES.get(category_id)
if not suffixes or path.suffix.lower() not in suffixes:
return None
try:
relative = path.relative_to(root).parts
if len(relative) != 3:
return None
zoom = int(relative[0])
x = int(relative[1])
y_match = re.match(r"^(\d+)", path.stem)
if y_match is None:
return None
y = int(y_match.group(1))
world_tiles = 1 << zoom
if not 0 <= zoom <= 22 or not (0 <= x < world_tiles and 0 <= y < world_tiles):
return None
return zoom, x, y
except (OSError, ValueError, TypeError):
return None
def cache_files_in_bounds(
self,
category_ids: list[str],
bounds: tuple[float, float, float, float],
) -> tuple[dict[str, list[Path]], list[str]]:
path_groups = self.cache_category_paths()
response_categories = {"rail", "environment", "boundaries", "pending"}
matches: dict[str, list[Path]] = {}
skipped: list[str] = []
for category_id in category_ids:
category_files: list[Path] = []
seen: set[str] = set()
for root in path_groups[category_id]:
if not root.exists():
continue
if category_id in CACHE_COVERAGE_TILE_SUFFIXES:
try:
zoom_dirs = [item for item in root.iterdir() if item.is_dir() and item.name.isdigit()]
except OSError:
continue
for zoom_dir in zoom_dirs:
try:
zoom = int(zoom_dir.name)
if not 0 <= zoom <= 22:
continue
world_tiles = 1 << zoom
x_min = self.lon_to_tile_x(bounds[1], zoom)
x_max = self.lon_to_tile_x(bounds[3], zoom)
y_min = self.lat_to_tile_y(bounds[2], zoom)
y_max = self.lat_to_tile_y(bounds[0], zoom)
x_dirs = list(zoom_dir.iterdir())
except (OSError, ValueError):
continue
for x_dir in x_dirs:
if not x_dir.is_dir() or not x_dir.name.isdigit():
continue
x = int(x_dir.name)
if x < x_min or x > x_max or not 0 <= x < world_tiles:
continue
try:
candidates = list(x_dir.iterdir())
except OSError:
continue
for path in candidates:
coordinate = self.cache_tile_coordinate(category_id, path, root)
if coordinate is None:
continue
z, tile_x, tile_y = coordinate
if tile_y < y_min or tile_y > y_max:
continue
tile_bbox = vector_tile_bbox(z, tile_x, tile_y)
if not self.cache_bounds_intersect(tile_bbox, bounds):
continue
key = str(path)
if key not in seen:
seen.add(key)
category_files.append(path)
elif category_id in response_categories:
for path in root.rglob("*.json"):
response_bbox = self.response_cache_bbox(category_id, path)
if response_bbox is None or not self.cache_bounds_intersect(response_bbox, bounds):
continue
key = str(path)
if key not in seen:
seen.add(key)
category_files.append(path)
else:
if category_id not in skipped:
skipped.append(category_id)
matches[category_id] = category_files
return matches, skipped
@staticmethod
def summarize_cache_paths(paths: list[Path]) -> dict[str, Any]:
files = 0
total_bytes = 0
updated_at = 0.0
for root in paths:
if not root.exists():
continue
candidates = (root,) if root.is_file() else root.rglob("*")
for path in candidates:
try:
if not path.is_file():
continue
stat = path.stat()
except OSError:
continue
files += 1
total_bytes += stat.st_size
updated_at = max(updated_at, stat.st_mtime)
return {"files": files, "bytes": total_bytes, "updatedAt": updated_at or None}
def active_manual_download_count(self) -> int:
with self._lock_guard:
return sum(
1
for jobs in (self._tianditu_jobs, self._openmap_jobs)
for job in jobs.values()
if job.get("status") in ("queued", "running")
)
@staticmethod
def tile_runs(tiles: set[tuple[int, int]]) -> list[list[int]]:
rows: dict[int, list[int]] = {}
for x, y in tiles:
rows.setdefault(y, []).append(x)
runs: list[list[int]] = []
for y, values in sorted(rows.items()):
values.sort()
start = previous = values[0]
for value in values[1:]:
if value != previous + 1:
runs.append([y, start, previous])
start = value
previous = value
runs.append([y, start, previous])
return runs
@staticmethod
def coverage_bounds_for_tiles(z: int, tiles: set[tuple[int, int]]) -> list[float] | None:
if not tiles:
return None
min_x = min(x for x, _ in tiles)
max_x = max(x for x, _ in tiles)
min_y = min(y for _, y in tiles)
max_y = max(y for _, y in tiles)
north = vector_tile_bbox(z, min_x, min_y)[2]
south = vector_tile_bbox(z, max_x, max_y)[0]
west = min_x / (1 << z) * 360.0 - 180.0
east = (max_x + 1) / (1 << z) * 360.0 - 180.0
return [south, west, north, east]
@staticmethod
def response_cache_bbox(category_id: str, path: Path) -> list[float] | None:
try:
if category_id == "pending":
payload = json.loads(path.read_text(encoding="utf-8"))
bbox = payload.get("bbox")
if not isinstance(bbox, list) or len(bbox) != 4:
return None
south, west, north, east = (float(value) for value in bbox)
else:
raw = path.read_bytes()
south, west, north, east = 90.0, 180.0, -90.0, -180.0
found = False
for match in JSON_LATITUDE_RE.finditer(raw):
value = float(match.group(1))
if -90.0 <= value <= 90.0:
south = min(south, value)
north = max(north, value)
found = True
for match in JSON_LONGITUDE_RE.finditer(raw):
value = float(match.group(1))
if -180.0 <= value <= 180.0:
west = min(west, value)
east = max(east, value)
found = True
if not found:
return None
if not all(math.isfinite(value) for value in (south, west, north, east)):
return None
if south > north or west > east or south < -90 or north > 90:
return None
return [south, west, north, east]
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return None
def response_features_coverage(
self,
features: Any,
source_zoom: int | None,
) -> tuple[list[float] | None, set[tuple[int, int]]]:
lats: list[float] = []
lons: list[float] = []
tiles: set[tuple[int, int]] = set()
def collect(node: Any) -> None:
if isinstance(node, dict):
lat = node.get("lat")
lon = node.get("lon", node.get("lng"))
if lat is not None and lon is not None:
try:
lat_value = float(lat)
lon_value = float(lon)
except (TypeError, ValueError):
pass
else:
if -90.0 <= lat_value <= 90.0 and -180.0 <= lon_value <= 180.0:
lats.append(lat_value)
lons.append(lon_value)
if source_zoom is not None:
tiles.add(
(
self.lon_to_tile_x(lon_value, source_zoom),
self.lat_to_tile_y(lat_value, source_zoom),
)
)
return
for key in ("geometry", "geometries", "coordinates"):
if key in node:
collect(node[key])
elif isinstance(node, list):
for value in node:
collect(value)
collect(features)
if not lats or not lons:
return None, tiles
return [min(lats), min(lons), max(lats), max(lons)], tiles
@staticmethod
def grouped_response_features(category_id: str, response: dict[str, Any]) -> dict[str, list[Any]]:
grouped: dict[str, list[Any]] = {}
if category_id == "rail":
national: list[Any] = []
metro: list[Any] = []
metro_modes = {"subway", "light_rail", "monorail", "tram"}
for element in response.get("elements") or []:
if not isinstance(element, dict):
continue
tags = element.get("tags") if isinstance(element.get("tags"), dict) else {}
mode = str(tags.get("railway") or tags.get("route") or "").lower()
if mode in metro_modes:
metro.append(element)
else:
national.append(element)
grouped = {"national": national, "metro": metro}
elif category_id == "environment":
grouped = {
"water": response.get("elements") or [],
"vegetation": response.get("vegetation") or [],
"ports": response.get("ports") or [],
}
elif category_id == "boundaries":
grouped = {"boundaries": response.get("elements") or []}
return grouped
def response_layer_coverage(
self,
category_id: str,
path: Path,
) -> dict[str, dict[str, Any]]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return {}
response = payload.get("response") if isinstance(payload, dict) else None
if not isinstance(response, dict):
return {}
try:
source_zoom = int(response.get("sourceTileZoom") or response.get("tileZoom"))
except (TypeError, ValueError):
source_zoom = 0
if not OPENFREEMAP_SOURCE_MIN_ZOOM <= source_zoom <= 22:
source_zoom = None
grouped = self.grouped_response_features(category_id, response)
result: dict[str, dict[str, Any]] = {}
for key, features in grouped.items():
bbox, tiles = self.response_features_coverage(features, source_zoom)
if bbox is not None:
result[key] = {
"bbox": [round(value, 6) for value in bbox],
"sourceZoom": source_zoom,
"tiles": tiles,
}
return result
def build_cache_coverage(self) -> dict[str, Any]:
started = time.time()
path_groups = self.cache_category_paths()
categories: list[dict[str, Any]] = []
for category_id, info in CACHE_ADMIN_CATEGORIES.items():
category: dict[str, Any] = {
"id": category_id,
"label": info["label"],
"description": info["description"],
"spatial": False,
"mode": "none",
"bounds": None,
"zooms": [],
"areas": [],
"mappedFiles": 0,
}
suffixes = CACHE_COVERAGE_TILE_SUFFIXES.get(category_id)
if suffixes:
by_zoom: dict[int, set[tuple[int, int]]] = {}
for root in path_groups[category_id]:
if not root.exists():
continue
for path in root.rglob("*"):
if not path.is_file() or path.suffix.lower() not in suffixes:
continue
try:
relative = path.relative_to(root).parts
if len(relative) != 3:
continue
z = int(relative[0])
x = int(relative[1])
y_match = re.match(r"^(\d+)", Path(relative[2]).stem)
if y_match is None:
continue
y = int(y_match.group(1))
world_tiles = 1 << z
if not 0 <= z <= 22 or not (0 <= x < world_tiles and 0 <= y < world_tiles):
continue
except (OSError, ValueError):
continue
by_zoom.setdefault(z, set()).add((x, y))
category["mappedFiles"] += 1
zoom_details: list[dict[str, Any]] = []
for zoom in sorted(by_zoom):
zoom_tiles = by_zoom[zoom]
zoom_details.append(
{
"zoom": zoom,
"tiles": len(zoom_tiles),
"runs": self.tile_runs(zoom_tiles),
"bounds": self.coverage_bounds_for_tiles(zoom, zoom_tiles),
}
)
zoom_bounds = [item["bounds"] for item in zoom_details if item["bounds"]]
category.update(
{
"spatial": bool(zoom_details),
"mode": "tiles",
"bounds": [
min(item[0] for item in zoom_bounds),
min(item[1] for item in zoom_bounds),
max(item[2] for item in zoom_bounds),
max(item[3] for item in zoom_bounds),
] if zoom_bounds else None,
"zooms": zoom_details,
"totalTiles": sum(item["tiles"] for item in zoom_details),
}
)
elif category_id in CACHE_COVERAGE_RESPONSE_LAYERS:
layer_specs = CACHE_COVERAGE_RESPONSE_LAYERS[category_id]
layer_areas: dict[str, list[list[float]]] = {spec["id"]: [] for spec in layer_specs}
layer_tiles: dict[str, dict[int, set[tuple[int, int]]]] = {
spec["id"]: {} for spec in layer_specs
}
for root in path_groups[category_id]:
if not root.exists():
continue
for path in root.rglob("*.json"):
found = self.response_layer_coverage(category_id, path)
for spec in layer_specs:
layer = found.get(spec["key"])
if not isinstance(layer, dict):
continue
bbox = layer.get("bbox")
if isinstance(bbox, list) and len(bbox) == 4:
layer_areas[spec["id"]].append(bbox)
source_zoom = layer.get("sourceZoom")
source_tiles = layer.get("tiles")
if isinstance(source_zoom, int) and isinstance(source_tiles, set) and source_tiles:
layer_tiles[spec["id"]].setdefault(source_zoom, set()).update(source_tiles)
for spec in layer_specs:
areas = layer_areas[spec["id"]]
if not areas:
continue
zoom_details: list[dict[str, Any]] = []
for zoom, zoom_tiles in sorted(layer_tiles[spec["id"]].items()):
zoom_details.append(
{
"zoom": zoom,
"tiles": len(zoom_tiles),
"runs": self.tile_runs(zoom_tiles),
"bounds": self.coverage_bounds_for_tiles(zoom, zoom_tiles),
}
)
categories.append(
{
"id": spec["id"],
"parentId": category_id,
"label": spec["label"],
"description": spec["description"],
"spatial": True,
"mode": "tiles" if zoom_details else "areas",
"bounds": [
min(area[0] for area in areas),
min(area[1] for area in areas),
max(area[2] for area in areas),
max(area[3] for area in areas),
],
"zooms": zoom_details,
"areas": areas,
"mappedFiles": len(areas),
"totalTiles": sum(item["tiles"] for item in zoom_details),
"responseOnly": True,
}
)
continue
elif category_id in ("boundaries", "pending"):
areas: list[list[float]] = []
by_zoom: dict[int, set[tuple[int, int]]] = {}
for root in path_groups[category_id]:
if not root.exists():
continue
for path in root.rglob("*.json"):
found = self.response_layer_coverage(category_id, path)
layer = found.get("boundaries") if category_id == "boundaries" else None
if isinstance(layer, dict):
bbox = layer.get("bbox")
if isinstance(bbox, list) and len(bbox) == 4:
areas.append(bbox)
source_zoom = layer.get("sourceZoom")
source_tiles = layer.get("tiles")
if isinstance(source_zoom, int) and isinstance(source_tiles, set) and source_tiles:
by_zoom.setdefault(source_zoom, set()).update(source_tiles)
else:
bbox = self.response_cache_bbox(category_id, path)
if bbox is not None:
areas.append([round(value, 6) for value in bbox])
if areas:
zoom_details = [
{
"zoom": zoom,
"tiles": len(zoom_tiles),
"runs": self.tile_runs(zoom_tiles),
"bounds": self.coverage_bounds_for_tiles(zoom, zoom_tiles),
}
for zoom, zoom_tiles in sorted(by_zoom.items())
]
category.update(
{
"spatial": True,
"mode": "tiles" if zoom_details else "areas",
"bounds": [
min(area[0] for area in areas),
min(area[1] for area in areas),
max(area[2] for area in areas),
max(area[3] for area in areas),
],
"zooms": zoom_details,
"areas": areas,
"mappedFiles": len(areas),
"totalTiles": sum(item["tiles"] for item in zoom_details),
"responseOnly": category_id == "boundaries",
}
)
categories.append(category)
return {
"ok": True,
"version": CACHE_COVERAGE_INDEX_VERSION,
"rangeOperations": True,
"generatedAt": time.time(),
"elapsedMs": round((time.time() - started) * 1000),
"categories": categories,
}
def cache_coverage(self, refresh: bool = False) -> dict[str, Any]:
index_path = self.cache_dir / CACHE_COVERAGE_INDEX_NAME
with self._cache_coverage_lock:
now = time.time()
if (
not refresh
and self._cache_coverage_result is not None
and now - self._cache_coverage_cached_at < 60
):
return self._cache_coverage_result
if not refresh:
try:
stored = json.loads(index_path.read_text(encoding="utf-8"))
if (
stored.get("version") == CACHE_COVERAGE_INDEX_VERSION
and isinstance(stored.get("categories"), list)
):
stored["fromIndex"] = True
stored["rangeOperations"] = True
self._cache_coverage_result = stored
self._cache_coverage_cached_at = now
return stored
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
result = self.build_cache_coverage()
result["fromIndex"] = False
atomic_write(
index_path,
json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
)
self._cache_coverage_result = result
self._cache_coverage_cached_at = now
return result
def coverage_tiles_for_category(
self,
category_id: str,
bounds: tuple[float, float, float, float] | None = None,
) -> list[tuple[int, int, int]]:
coverage = self.cache_coverage(refresh=False)
category = next(
(
item for item in coverage.get("categories", [])
if isinstance(item, dict) and item.get("id") == category_id
),
None,
)
if not isinstance(category, dict):
return []
tiles: set[tuple[int, int, int]] = set()
for zoom_entry in category.get("zooms") or []:
if not isinstance(zoom_entry, dict):
continue
try:
zoom = int(zoom_entry.get("zoom"))
except (TypeError, ValueError):
continue
if not OPENFREEMAP_SOURCE_MIN_ZOOM <= zoom <= OPENFREEMAP_SOURCE_MAX_ZOOM:
continue
world_tiles = 1 << zoom
for run in zoom_entry.get("runs") or []:
if not isinstance(run, list) or len(run) != 3:
continue
try:
y, x0, x1 = (int(value) for value in run)
except (TypeError, ValueError):
continue
if not (0 <= y < world_tiles and 0 <= x0 <= x1 < world_tiles):
continue
for x in range(x0, x1 + 1):
if bounds is not None and not self.cache_bounds_intersect(
vector_tile_bbox(zoom, x, y), bounds
):
continue
tiles.add((zoom, x, y))
return sorted(tiles)
def invalidate_query_layer_responses(
self,
category_id: str,
bounds: tuple[float, float, float, float] | None = None,
) -> int:
info = CACHE_ADMIN_QUERY_LAYERS.get(category_id)
if not info:
return 0
parent_id = str(info["parent"])
layer_key = str(info["key"])
path_groups = self.cache_category_paths()
changed_files = 0
for root in path_groups.get(parent_id, []):
if not root.exists():
continue
for path in root.glob("*.json"):
if bounds is not None:
bbox = self.response_cache_bbox(parent_id, path)
if bbox is None or not self.cache_bounds_intersect(bbox, bounds):
continue
if not self.response_layer_has_features(parent_id, layer_key, path):
continue
changed, _ = self.delete_response_layer_file(parent_id, layer_key, path)
changed_files += changed
if changed_files:
self.invalidate_cache_admin_stats()
return changed_files
def invalidate_cache_admin_stats(self) -> None:
with self._cache_admin_stats_lock:
self._cache_admin_stats_cached_at = 0.0
self._cache_admin_stats_result = None
with self._cache_totals_lock:
self._cache_totals_cached_at = 0.0
self._cache_totals_result = (0, 0)
with self._cache_coverage_lock:
self._cache_coverage_cached_at = 0.0
self._cache_coverage_result = None
try:
(self.cache_dir / CACHE_COVERAGE_INDEX_NAME).unlink(missing_ok=True)
except OSError:
pass
def cache_admin_status(self, refresh: bool = False) -> dict[str, Any]:
now = time.time()
with self._cache_admin_stats_lock:
if (
not refresh
and self._cache_admin_stats_result is not None
and now - self._cache_admin_stats_cached_at < 30
):
result = self._cache_admin_stats_result
else:
path_groups = self.cache_category_paths()
canonical_summaries = {
category_id: self.summarize_cache_paths(paths)
for category_id, paths in path_groups.items()
}
categories: list[dict[str, Any]] = []
total_files = sum(summary["files"] for summary in canonical_summaries.values())
total_bytes = sum(summary["bytes"] for summary in canonical_summaries.values())
total_tiles = 0
physical_tile_categories = {
"raster", "tianditu", "vector", "rail-derived", "dynamic-vector",
}
coverage_by_id: dict[str, dict[str, Any]] = {}
try:
stored_coverage = json.loads(
(self.cache_dir / CACHE_COVERAGE_INDEX_NAME).read_text(encoding="utf-8")
)
if stored_coverage.get("version") == CACHE_COVERAGE_INDEX_VERSION:
coverage_by_id = {
str(category.get("id")): category
for category in stored_coverage.get("categories", [])
if isinstance(category, dict) and category.get("id")
}
except (OSError, ValueError, TypeError, json.JSONDecodeError):
coverage_by_id = {}
if refresh or not coverage_by_id:
coverage_data = self.cache_coverage(refresh=refresh)
coverage_by_id = {
str(category.get("id")): category
for category in coverage_data.get("categories", [])
if isinstance(category, dict) and category.get("id")
}
for category_id in CACHE_ADMIN_MANAGEMENT_ORDER:
query_layer = CACHE_ADMIN_QUERY_LAYERS.get(category_id)
if query_layer:
coverage = coverage_by_id.get(category_id, {})
parent_id = str(query_layer["parent"])
parent_summary = canonical_summaries[parent_id]
windows = int(coverage.get("mappedFiles") or 0)
categories.append(
{
"id": category_id,
"parentId": parent_id,
"layerKey": query_layer["key"],
"label": query_layer["label"],
"description": query_layer["description"],
"refreshable": query_layer["refreshable"],
"queryResponse": True,
"files": windows,
"bytes": 0,
"updatedAt": parent_summary["updatedAt"],
"windows": windows,
"sourceTiles": int(coverage.get("totalTiles") or 0),
"tileCount": int(coverage.get("totalTiles") or 0),
"tileKind": "source",
"downloadProvider": "openfreemap",
}
)
continue
info = CACHE_ADMIN_CATEGORIES.get(category_id)
if info is None:
continue
canonical_summary = canonical_summaries[category_id]
tile_count = 0
if category_id in physical_tile_categories:
tile_count = int(
coverage_by_id.get(category_id, {}).get("totalTiles")
or canonical_summary["files"]
)
total_tiles += tile_count
categories.append(
{
"id": category_id,
"label": info["label"],
"description": info["description"],
"refreshable": info["refreshable"],
**canonical_summary,
"tileCount": tile_count,
"tileKind": "cache",
}
)
result = {
"ok": True,
"cacheOnly": self.config.cache_only,
"tileTtlSeconds": self.config.tile_ttl_seconds,
"railTtlSeconds": self.config.rail_ttl_seconds,
"totalFiles": total_files,
"totalBytes": total_bytes,
"totalTiles": total_tiles,
"categories": categories,
"excluded": [
"cache/local-vector",
"cache/local-vector-25",
"cache/cache-only.setting",
f"cache/{CACHE_COVERAGE_INDEX_NAME}",
"data/map-library",
"data/map-library-25",
],
"generatedAt": now,
}
self._cache_admin_stats_result = result
self._cache_admin_stats_cached_at = now
current = dict(result)
current["activeJobs"] = self.active_manual_download_count()
return current
def refresh_stale(self, force: bool = False, limit: int | None = None) -> int:
return self.refresh_cache_categories(
["raster", "vector", "rail", "environment"],
force=force,
limit=limit,
)
def refresh_cache_categories(
self,
category_ids: list[str],
force: bool = False,
limit: int | None = None,
) -> int:
if self.config.cache_only:
return 0
categories = list(dict.fromkeys(category_ids))
category_info = {
category_id: self.cache_admin_category_info(category_id)
for category_id in categories
}
invalid = [item for item, info in category_info.items() if info is None]
if invalid:
raise ValueError("Unknown cache categories: " + ", ".join(invalid))
blocked = [
item for item in categories
if not category_info[item]["refreshable"]
]
if blocked:
raise ValueError("Cache categories cannot be refreshed in place: " + ", ".join(blocked))
max_items = limit or self.config.refresh_batch
scheduled = 0
now = time.time()
path_groups = self.cache_category_paths()
for category_id in categories:
info = category_info[category_id]
if info is None:
continue
if category_id in CACHE_ADMIN_QUERY_LAYERS:
category_scheduled = 0
for zoom, x, y in self.coverage_tiles_for_category(category_id):
if scheduled >= max_items:
break
path = self.vector_tile_cache_path(zoom, x, y)
try:
stale = force or not path.exists() or now - path.stat().st_mtime > self.config.tile_ttl_seconds
except OSError:
stale = force
if not stale:
continue
self._download_executor.submit(self.refresh_vector_tile, zoom, x, y)
scheduled += 1
category_scheduled += 1
if category_scheduled:
self.invalidate_query_layer_responses(category_id)
continue
parent_id = str(info.get("parent", category_id))
layer_key = info.get("key")
if scheduled >= max_items:
break
for root in path_groups[parent_id]:
if scheduled >= max_items:
break
if not root.exists():
continue
if parent_id == "raster":
for path in root.rglob("*.png"):
if scheduled >= max_items:
break
try:
stale = force or now - path.stat().st_mtime > self.config.tile_ttl_seconds
except OSError:
continue
if stale:
self.schedule_tile_refresh(path)
scheduled += 1
elif parent_id == "vector":
for path in root.rglob("*.pbf"):
if scheduled >= max_items:
break
try:
stale = force or now - path.stat().st_mtime > self.config.tile_ttl_seconds
relative = path.relative_to(self.vector_cache_dir).parts
if len(relative) != 3:
continue
z = int(relative[0])
x = int(relative[1])
y = int(relative[2][:-4])
except (OSError, ValueError):
continue
if stale:
self._download_executor.submit(self.refresh_vector_tile, z, x, y)
scheduled += 1
elif parent_id in ("rail", "environment"):
refresh_path = self.refresh_rail_path if parent_id == "rail" else self.refresh_water_path
ttl = self.config.rail_ttl_seconds
for path in root.glob("*.json"):
if scheduled >= max_items:
break
if layer_key and not self.response_layer_has_features(parent_id, str(layer_key), path):
continue
try:
stale = force or now - path.stat().st_mtime > ttl
except OSError:
continue
if not stale:
continue
key = str(path)
with self._lock_guard:
if key in self._refreshing:
continue
self._refreshing.add(key)
self._refresh_executor.submit(refresh_path, path)
scheduled += 1
return scheduled
def delete_cache_categories(self, category_ids: list[str]) -> dict[str, Any]:
categories = list(dict.fromkeys(category_ids))
category_info = {
category_id: self.cache_admin_category_info(category_id)
for category_id in categories
}
invalid = [item for item, info in category_info.items() if info is None]
if invalid:
raise ValueError("Unknown cache categories: " + ", ".join(invalid))
if not categories:
raise ValueError("Select at least one cache category")
if self.active_manual_download_count():
raise RuntimeError("存在正在运行的下载任务,请先完成或中断任务后再删除缓存")
path_groups = self.cache_category_paths()
targets: list[Path] = []
response_layers: list[tuple[str, str]] = []
seen: set[str] = set()
for category_id in categories:
info = category_info[category_id]
if info is None:
continue
parent_id = str(info.get("parent", category_id))
layer_key = info.get("key")
if parent_id in ("rail", "environment") and parent_id != category_id and layer_key:
response_layers.append((parent_id, str(layer_key)))
continue
for path in path_groups[parent_id]:
resolved = path.resolve()
if resolved == self.cache_dir or self.cache_dir not in resolved.parents:
raise ValueError("Refusing to delete a path outside the managed cache")
key = str(resolved)
if key not in seen:
seen.add(key)
targets.append(resolved)
before = self.summarize_cache_paths(targets)
response_targets = {
path
for parent_id, layer_key in response_layers
for root in path_groups[parent_id]
if root.exists()
for path in root.glob("*.json")
if self.response_layer_has_features(parent_id, layer_key, path)
}
response_before = self.summarize_cache_paths(list(response_targets))
deleted_layer_files = 0
deleted_layer_bytes = 0
for parent_id, layer_key in response_layers:
for path in response_targets:
changed, deleted_bytes = self.delete_response_layer_file(parent_id, layer_key, path)
deleted_layer_files += changed
deleted_layer_bytes += deleted_bytes
for target in targets:
if target.exists():
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
target.mkdir(parents=True, exist_ok=True)
self.invalidate_cache_admin_stats()
return {
"ok": True,
"categories": categories,
"deletedFiles": before["files"] + deleted_layer_files,
"deletedBytes": before["bytes"] + deleted_layer_bytes,
"targets": [str(path.relative_to(self.cache_dir)) for path in targets],
"responseFilesTouched": response_before["files"],
}
def refresh_cache_scope(
self,
category_ids: list[str],
bounds: tuple[float, float, float, float],
force: bool = False,
) -> dict[str, Any]:
if self.config.cache_only:
return {
"scheduled": 0,
"matchedFiles": 0,
"skippedCategories": [],
"bounds": list(bounds),
}
categories = list(dict.fromkeys(category_ids))
category_info = {
category_id: self.cache_admin_category_info(category_id)
for category_id in categories
}
invalid = [item for item, info in category_info.items() if info is None]
if invalid:
raise ValueError("Unknown cache categories: " + ", ".join(invalid))
if not categories:
raise ValueError("Select at least one cache category")
blocked = [
item for item in categories
if not category_info[item]["refreshable"]
]
if blocked:
raise ValueError("Cache categories cannot be refreshed in place: " + ", ".join(blocked))
parent_categories = list(dict.fromkeys(
str(category_info[category_id].get("parent", category_id))
for category_id in categories
))
matches, skipped = self.cache_files_in_bounds(parent_categories, bounds)
scheduled = 0
matched_files = 0
matched_tiles = 0
now = time.time()
for category_id in categories:
info = category_info[category_id]
if info is None:
continue
if category_id in CACHE_ADMIN_QUERY_LAYERS:
source_tiles = self.coverage_tiles_for_category(category_id, bounds)
matched_tiles += len(source_tiles)
category_scheduled = 0
for zoom, x, y in source_tiles:
path = self.vector_tile_cache_path(zoom, x, y)
try:
stale = force or not path.exists() or now - path.stat().st_mtime > self.config.tile_ttl_seconds
except OSError:
stale = force
if not stale:
continue
self._download_executor.submit(self.refresh_vector_tile, zoom, x, y)
scheduled += 1
category_scheduled += 1
if category_scheduled:
self.invalidate_query_layer_responses(category_id, bounds)
continue
parent_id = str(info.get("parent", category_id))
layer_key = info.get("key")
paths = matches.get(parent_id, [])
for path in paths:
if layer_key and parent_id in ("rail", "environment") and not self.response_layer_has_features(parent_id, str(layer_key), path):
continue
matched_files += 1
try:
ttl = (
self.config.rail_ttl_seconds
if parent_id in ("rail", "environment")
else self.config.tile_ttl_seconds
)
stale = force or now - path.stat().st_mtime > ttl
except OSError:
continue
if not stale:
continue
if parent_id == "raster":
self.schedule_tile_refresh(path)
scheduled += 1
elif parent_id == "vector":
coordinate = self.cache_tile_coordinate(parent_id, path, self.vector_cache_dir)
if coordinate is None:
continue
zoom, x, y = coordinate
self._download_executor.submit(self.refresh_vector_tile, zoom, x, y)
scheduled += 1
elif parent_id in ("rail", "environment"):
refresh_path = self.refresh_rail_path if parent_id == "rail" else self.refresh_water_path
key = str(path)
with self._lock_guard:
if key in self._refreshing:
continue
self._refreshing.add(key)
self._refresh_executor.submit(refresh_path, path)
scheduled += 1
return {
"scheduled": scheduled,
"matchedFiles": matched_files,
"matchedTiles": matched_tiles + matched_files,
"skippedCategories": skipped,
"bounds": list(bounds),
}
def delete_cache_scope(
self,
category_ids: list[str],
bounds: tuple[float, float, float, float],
) -> dict[str, Any]:
categories = list(dict.fromkeys(category_ids))
category_info = {
category_id: self.cache_admin_category_info(category_id)
for category_id in categories
}
invalid = [item for item, info in category_info.items() if info is None]
if invalid:
raise ValueError("Unknown cache categories: " + ", ".join(invalid))
if not categories:
raise ValueError("Select at least one cache category")
if self.active_manual_download_count():
raise RuntimeError("存在正在运行的下载任务,请先完成或中断任务后再删除缓存")
parent_categories = list(dict.fromkeys(
str(category_info[category_id].get("parent", category_id))
for category_id in categories
))
matches, skipped = self.cache_files_in_bounds(parent_categories, bounds)
path_groups = self.cache_category_paths()
deleted_files = 0
deleted_bytes = 0
for category_id in categories:
info = category_info[category_id]
if info is None:
continue
parent_id = str(info.get("parent", category_id))
layer_key = info.get("key")
paths = matches.get(parent_id, [])
for path in paths:
resolved = path.resolve()
if self.cache_dir not in resolved.parents:
raise ValueError("Refusing to delete a path outside the managed cache")
if layer_key and parent_id in ("rail", "environment"):
if not self.response_layer_has_features(parent_id, str(layer_key), resolved):
continue
changed, released_bytes = self.delete_response_layer_file(
parent_id, str(layer_key), resolved
)
deleted_files += changed
deleted_bytes += released_bytes
continue
try:
size = resolved.stat().st_size
resolved.unlink()
except FileNotFoundError:
continue
except OSError:
continue
deleted_files += 1
deleted_bytes += size
for root in path_groups[parent_id]:
if not root.is_dir():
continue
for path in paths:
parent = path.parent
while parent != root and root in parent.parents:
try:
parent.rmdir()
except OSError:
break
parent = parent.parent
self.invalidate_cache_admin_stats()
return {
"ok": True,
"categories": categories,
"deletedFiles": deleted_files,
"deletedBytes": deleted_bytes,
"skippedCategories": skipped,
"bounded": True,
"bounds": list(bounds),
}
def raster_tile_coordinates(
self,
bbox: tuple[float, float, float, float],
z: int,
limit: int = MAX_REFRESH_TILES,
) -> list[tuple[int, int]]:
south, west, north, east = bbox
x0 = self.lon_to_tile_x(west, z)
x1 = self.lon_to_tile_x(east, z)
y0 = self.lat_to_tile_y(north, z)
y1 = self.lat_to_tile_y(south, z)
count = (x1 - x0 + 1) * (y1 - y0 + 1)
if count > limit:
raise ValueError(f"Tile request too large: {count} tiles")
return [(x, y) for x in range(x0, x1 + 1) for y in range(y0, y1 + 1)]
def refresh_vector_tile(self, z: int, x: int, y: int) -> bool:
if self.config.cache_only:
return False
key = f"vector:{z}:{x}:{y}"
with self._lock_guard:
if key in self._refreshing:
return False
self._refreshing.add(key)
try:
self.fetch_vector_tile(z, x, y, force=True)
return True
except Exception:
return False
finally:
with self._lock_guard:
self._refreshing.discard(key)
def refresh_current_view(
self,
bbox: tuple[float, float, float, float],
map_zoom: int,
force: bool = True,
) -> int:
if self.config.cache_only:
return 0
scheduled = 0
zoom = max(0, min(18, int(map_zoom)))
active_providers = [
(provider, provider_config)
for provider, provider_config in TILE_PROVIDERS.items()
if not provider_config.get("manual_only")
]
raster_coordinates = self.raster_tile_coordinates(bbox, zoom)
if len(raster_coordinates) * len(active_providers) > MAX_REFRESH_TILES:
raise ValueError("Current view is too large to refresh; zoom in")
for provider, provider_config in active_providers:
if provider_config.get("manual_only"):
continue
for x, y in raster_coordinates:
path = self.tile_cache_path(provider, zoom, x, y)
if path.exists():
self.schedule_tile_refresh(path)
scheduled += 1
elif force:
self._download_executor.submit(
self.refresh_tile_path,
self.tile_cache_path(provider, zoom, x, y),
)
scheduled += 1
vector_zoom = self.choose_vector_zoom(
bbox,
preferred=min(14, max(7, zoom + 2)),
max_tiles=MAX_REFRESH_TILES,
)
vector_coordinates = self.vector_tile_coordinates(bbox, vector_zoom)
if len(vector_coordinates) > MAX_REFRESH_TILES:
raise ValueError("Current view has too many vector tiles to refresh")
for x, y in vector_coordinates:
self._download_executor.submit(self.refresh_vector_tile, vector_zoom, x, y)
scheduled += 1
return scheduled
def cache_totals(self) -> tuple[int, int]:
now = time.time()
with self._cache_totals_lock:
if now - self._cache_totals_cached_at < 15:
return self._cache_totals_result
files = 0
total_bytes = 0
for path in self.cache_dir.rglob("*"):
if not path.is_file():
continue
files += 1
try:
total_bytes += path.stat().st_size
except OSError:
pass
self._cache_totals_result = (files, total_bytes)
self._cache_totals_cached_at = now
return self._cache_totals_result
def status(self) -> dict[str, Any]:
files, total_bytes = self.cache_totals()
with self.stats_lock:
stats = dict(self.stats)
pending_files = len(list(self.pending_rail_dir.glob("*.json")))
pending_water_files = len(list(self.pending_water_dir.glob("*.json")))
return {
"ok": True,
"uptimeSeconds": round(time.time() - self.started_at, 1),
"cacheOnly": self.config.cache_only,
"cacheDir": "cache",
"cacheFiles": files,
"cacheBytes": total_bytes,
"tileTtlSeconds": self.config.tile_ttl_seconds,
"railTtlSeconds": self.config.rail_ttl_seconds,
"refreshSeconds": self.config.refresh_seconds,
"pendingRailRequests": pending_files,
"pendingWaterRequests": pending_water_files,
"stats": stats,
"providers": sorted(TILE_PROVIDERS),
"environment": {
"minZoom": ENVIRONMENT_MIN_ZOOM,
"maxZoom": ENVIRONMENT_MAX_ZOOM,
"sourceMaxZoom": OPENFREEMAP_SOURCE_MAX_ZOOM,
"vegetationSourceMinZoom": 7,
"layers": ["water", "waterway", "landcover", "landuse", "poi"],
},
"tiandituConfigured": bool(self.config.tianditu_key),
"manualOnlyProviders": sorted(self.tianditu_provider_names()),
"downloadStagingDir": DOWNLOAD_STAGING_DIR_NAME,
"workers": {
"cacheRefresh": CACHE_REFRESH_WORKERS,
"vectorBackground": VECTOR_BACKGROUND_WORKERS,
"vectorTileRequest": VECTOR_TILE_REQUEST_WORKERS,
"railAggregate": RAIL_AGGREGATE_WORKERS,
"railAggregateFetch": RAIL_AGGREGATE_FETCH_WORKERS,
"tiandituManual": TIANDITU_DOWNLOAD_WORKERS,
"openmapManual": OPENMAP_DOWNLOAD_WORKERS,
},
}
def _maintenance_loop(self) -> None:
last_refresh = time.time()
while not self._stop.wait(10):
self.retry_pending_rail_jobs(limit=min(8, self.config.refresh_batch))
self.retry_pending_water_jobs(limit=min(8, self.config.refresh_batch))
self.expire_stalled_openmap_jobs()
now = time.time()
if now - last_refresh >= self.config.refresh_seconds:
self.refresh_stale(force=False, limit=self.config.refresh_batch)
last_refresh = now
def close(self) -> None:
self._stop.set()
self._refresh_executor.shutdown(wait=False, cancel_futures=True)
self._download_executor.shutdown(wait=False, cancel_futures=True)
self._vector_tile_executor.shutdown(wait=False, cancel_futures=True)
self._rail_aggregate_executor.shutdown(wait=False, cancel_futures=True)
self._rail_aggregate_fetch_executor.shutdown(wait=False, cancel_futures=True)
self._tianditu_executor.shutdown(wait=False, cancel_futures=True)
self._openmap_executor.shutdown(wait=False, cancel_futures=True)
SERVICE: MapService | None = None
class QuietThreadingHTTPServer(ThreadingHTTPServer):
daemon_threads = True
block_on_close = False
request_queue_size = 128
def handle_error(self, request: Any, client_address: Any) -> None:
error = sys.exc_info()[1]
if isinstance(error, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)):
return
super().handle_error(request, client_address)
class MapStudioRequestHandler(BaseHTTPRequestHandler):
server_version = "MapStudioMapService/1.0"
def _cors_headers(self) -> None:
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept")
def _security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("X-Frame-Options", "SAMEORIGIN")
self.send_header("X-Robots-Tag", "noindex, nofollow")
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=(self)")
def _client_identity(self) -> str:
return str(self.client_address[0])
def _allow_request(
self,
bucket: str,
limit: int,
window_seconds: float,
) -> bool:
if SERVICE.consume_rate_limit(
bucket,
self._client_identity(),
limit,
window_seconds,
):
return True
self._send_json(
HTTPStatus.TOO_MANY_REQUESTS,
{"ok": False, "error": "Too many requests; retry later"},
extra_headers={"Retry-After": str(max(1, int(window_seconds)))},
)
return False
def _request_prefix(self) -> str:
raw_path = urllib.parse.urlsplit(self.path).path
marker = "/api/"
if raw_path.startswith(marker):
return "/"
index = raw_path.find(marker)
if index >= 0:
prefix = raw_path[:index]
return prefix.rstrip("/") + "/" if prefix else "/"
return "/"
def _send_json(
self,
status: int,
payload: dict[str, Any],
cache_state: str | None = None,
extra_headers: dict[str, str] | list[tuple[str, str]] | None = None,
) -> None:
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
use_gzip = len(data) > 1024 and "gzip" in self.headers.get("Accept-Encoding", "").lower()
body = gzip.compress(data, compresslevel=6, mtime=0) if use_gzip else data
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
if use_gzip:
self.send_header("Content-Encoding", "gzip")
self.send_header("Vary", "Accept-Encoding")
self.send_header(
"Cache-Control",
LONG_BROWSER_CACHE
if status == HTTPStatus.OK and cache_state in ("HIT", "MISS")
else "public, max-age=86400" if cache_state == "STALE" else "no-store",
)
self._cors_headers()
self._security_headers()
if cache_state:
self.send_header("X-Cache", cache_state)
header_items = (
extra_headers.items()
if isinstance(extra_headers, dict)
else (extra_headers or [])
)
for name, value in header_items:
self.send_header(name, value)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _require_admin(self) -> bool:
password = SERVICE.config.admin_password
if not password:
self._send_json(
HTTPStatus.SERVICE_UNAVAILABLE,
{
"ok": False,
"code": "ADMIN_PASSWORD_NOT_CONFIGURED",
"error": "地图下载后台密码尚未配置",
},
)
return False
try:
cookie_jar = cookies.SimpleCookie(self.headers.get("Cookie", ""))
session_token = cookie_jar.get("mapstudio_admin")
if session_token and SERVICE.admin_session_valid(session_token.value):
return True
except Exception:
pass
authorization = self.headers.get("Authorization", "")
valid = False
if authorization.startswith("Basic "):
if not self._allow_request("admin-basic-auth", 20, 600):
return False
try:
decoded = base64.b64decode(authorization[6:], validate=True).decode("utf-8")
username, supplied = decoded.split(":", 1)
valid = username == "admin" and hmac.compare_digest(supplied, password)
except Exception:
valid = False
if valid:
return True
self._send_json(
HTTPStatus.UNAUTHORIZED,
{"ok": False, "code": "ADMIN_AUTH_REQUIRED", "error": "需要管理员密码"},
)
return False
def _send_bytes(self, status: int, data: bytes, content_type: str, cache_state: str | None = None) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header(
"Cache-Control",
LONG_BROWSER_CACHE
if cache_state in ("HIT", "MISS")
else "public, max-age=86400" if cache_state == "STALE" else "no-store",
)
self._cors_headers()
self._security_headers()
if cache_state:
self.send_header("X-Cache", cache_state)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(data)
def do_OPTIONS(self) -> None:
self.send_response(HTTPStatus.NO_CONTENT)
self._cors_headers()
self._security_headers()
self.send_header("Content-Length", "0")
self.end_headers()
def do_HEAD(self) -> None:
self.do_GET()
def do_GET(self) -> None:
parsed = urllib.parse.urlsplit(self.path)
path = normalize_request_path(parsed.path, self.headers.get("Accept", ""))
if (
path in ("/api/cache-policy", "/api/upstream-proxy")
or path.startswith(("/api/tianditu/", "/api/openmap/", "/api/cache-admin/"))
):
if not self._require_admin():
return
if path == "/api/status":
if not self._allow_request("status", 60, 60):
return
self._send_json(HTTPStatus.OK, SERVICE.status())
return
if path == "/api/cache-admin/status":
if not self._allow_request("cache-admin-status", 20, 60):
return
refresh = urllib.parse.parse_qs(parsed.query).get("refresh", ["0"])[0] == "1"
self._send_json(HTTPStatus.OK, SERVICE.cache_admin_status(refresh=refresh))
return
if path == "/api/cache-admin/coverage":
if not self._allow_request("cache-admin-coverage", 10, 60):
return
refresh = urllib.parse.parse_qs(parsed.query).get("refresh", ["0"])[0] == "1"
self._send_json(HTTPStatus.OK, SERVICE.cache_coverage(refresh=refresh))
return
if path == "/api/cache-policy":
if not self._allow_request("cache-policy", 60, 60):
return
self._send_json(
HTTPStatus.OK,
{
"ok": True,
"cacheOnly": SERVICE.config.cache_only,
"placeholderTiles": True,
},
)
return
if path == "/api/upstream-proxy":
if not self._allow_request("upstream-proxy", 60, 60):
return
proxy = SERVICE.upstream_proxy_status()
self._send_json(
HTTPStatus.OK,
{
"ok": True,
"upstreamProxyConfigured": proxy["configured"],
"upstreamProxy": proxy["proxy"],
"upstreamProxyDefaultConfigured": proxy["defaultConfigured"],
"upstreamProxyDefault": proxy["defaultProxy"],
"upstreamProxyTemporary": proxy["temporary"],
},
)
return
if path == "/api/local-vector/status":
self._send_json(HTTPStatus.OK, SERVICE.local_vector_status())
return
if path == "/api/local-vector-25/status":
self._send_json(
HTTPStatus.OK,
SERVICE.local_vector_status(SERVICE.local_vector_25_dir),
)
return
local_vector_match = LOCAL_VECTOR_PATH_RE.match(path)
if local_vector_match:
if not self._allow_request("local-vector", 300, 60):
return
z, x, y = (int(value) for value in local_vector_match.groups())
tile = SERVICE.local_vector_tile(z, x, y)
if tile is None:
self._send_json(
HTTPStatus.NOT_FOUND,
{"ok": False, "error": "Local vector tile not found"},
)
else:
self._send_json(HTTPStatus.OK, tile, "HIT")
return
local_vector_25_match = LOCAL_VECTOR_25_PATH_RE.match(path)
if local_vector_25_match:
if not self._allow_request("local-vector-25", 300, 60):
return
z, x, y = (int(value) for value in local_vector_25_match.groups())
tile = SERVICE.local_vector_tile(
z,
x,
y,
SERVICE.local_vector_25_dir,
)
if tile is None:
self._send_json(
HTTPStatus.NOT_FOUND,
{"ok": False, "error": "Local 1:250,000 vector tile not found"},
)
else:
self._send_json(HTTPStatus.OK, tile, "HIT")
return
rail_tile_match = RAIL_TILE_PATH_RE.match(path)
if rail_tile_match:
if not self._allow_request("rail-tiles", 300, 60):
return
z, x, y = (int(value) for value in rail_tile_match.groups())
force = urllib.parse.parse_qs(parsed.query).get("refresh", ["0"])[0] == "1"
include = {
value
for value in urllib.parse.parse_qs(parsed.query).get(
"include", ["rail,metro"]
)[0].split(",")
if value in ("rail", "metro")
}
tile = SERVICE.rail_tile(z, x, y, force=force, include=include)
if tile is None:
if SERVICE.config.cache_only:
self._send_json(
HTTPStatus.OK,
{
"ok": True,
"format": "rail-tile-v1",
"source": "cache-only",
"cacheOnly": True,
"placeholder": True,
"tileZoom": z,
"x": x,
"y": y,
"include": sorted(include),
"elements": [],
},
"PLACEHOLDER",
)
else:
self._send_json(
HTTPStatus.BAD_GATEWAY,
{"ok": False, "error": "Rail tile unavailable"},
)
else:
self._send_json(HTTPStatus.OK, tile, "HIT" if not force else "MISS")
return
if path == "/api/tianditu/status":
if not self._allow_request("tianditu-status", 180, 60):
return
self._send_json(HTTPStatus.OK, SERVICE.tianditu_status())
return
if path == "/api/tianditu/job":
if not self._allow_request("tianditu-status", 180, 60):
return
job_id = urllib.parse.parse_qs(parsed.query).get("id", [""])[0]
job = SERVICE.tianditu_job_status(job_id)
if not job:
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Job not found"})
else:
self._send_json(HTTPStatus.OK, {"ok": True, "job": job})
return
if path == "/api/openmap/status":
if not self._allow_request("openmap-status", 180, 60):
return
self._send_json(HTTPStatus.OK, SERVICE.openmap_status())
return
if path == "/api/openmap/job":
if not self._allow_request("openmap-status", 180, 60):
return
job_id = urllib.parse.parse_qs(parsed.query).get("id", [""])[0]
job = SERVICE.openmap_job_status(job_id)
if not job:
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Job not found"})
else:
self._send_json(HTTPStatus.OK, {"ok": True, "job": job})
return
if path == "/api/rail/status":
key = urllib.parse.parse_qs(parsed.query).get("key", [""])[0]
if not re.fullmatch(r"[a-f0-9]{40}", key):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid cache key"})
return
status = SERVICE.pending_rail_status(key)
self._send_json(
HTTPStatus.OK if status.get("ok") else HTTPStatus.NOT_FOUND,
status,
)
return
if path == "/api/water/status":
key = urllib.parse.parse_qs(parsed.query).get("key", [""])[0]
if not re.fullmatch(r"[a-f0-9]{40}", key):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid cache key"})
return
status = SERVICE.water_pending_status(key)
self._send_json(
HTTPStatus.OK if status.get("ok") else HTTPStatus.NOT_FOUND,
status,
)
return
if path == "/api/rail":
if not self._allow_request("rail-api", 40, 60):
return
self._handle_rail(parsed.query)
return
if path == "/api/water":
if not self._allow_request("water-api", 60, 60):
return
self._handle_water(parsed.query)
return
if path == "/api/boundaries":
if not self._allow_request("boundary-api", 60, 60):
return
self._handle_boundaries(parsed.query)
return
tile_match = TILE_PATH_RE.match(path)
if tile_match:
provider, z_text, x_text, y_text = tile_match.groups()
z, x, y = int(z_text), int(x_text), int(y_text)
if z < 0 or z > 22 or x < 0 or y < 0 or x >= 2**z or y >= 2**z:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid tile coordinates"})
return
try:
data, content_type, cache_state = SERVICE.get_tile(provider, z, x, y)
self._send_bytes(HTTPStatus.OK, data, content_type, cache_state)
except TileNotCached as error:
self._send_json(
HTTPStatus.NOT_FOUND,
{
"ok": False,
"code": "TILE_NOT_DOWNLOADED",
"error": "Tile is not available in the local manual cache",
"detail": str(error),
},
)
except TiandituKeyMissing as error:
self._send_json(
HTTPStatus.CONFLICT,
{
"ok": False,
"code": "TIANDITU_KEY_MISSING",
"error": "Tianditu API key is not configured",
"detail": str(error),
},
)
except KeyError:
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Unknown tile provider"})
except Exception as error:
self._send_json(
HTTPStatus.BAD_GATEWAY,
{"ok": False, "error": "Tile unavailable", "detail": str(error)},
)
return
self._serve_static(path)
def do_POST(self) -> None:
parsed = urllib.parse.urlsplit(self.path)
path = normalize_request_path(parsed.path, self.headers.get("Accept", ""))
if path == "/api/admin/login":
if not self._allow_request("admin-login", 8, 600):
return
password = SERVICE.config.admin_password
if not password:
self._send_json(
HTTPStatus.SERVICE_UNAVAILABLE,
{
"ok": False,
"code": "ADMIN_PASSWORD_NOT_CONFIGURED",
"error": "地图下载后台密码尚未配置",
},
)
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 16 * 1024:
raise ValueError("Invalid request body")
payload = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Invalid login request")
supplied = str(payload.get("password", ""))
except (ValueError, TypeError, json.JSONDecodeError):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid login request"})
return
if not hmac.compare_digest(supplied, password):
self._send_json(
HTTPStatus.UNAUTHORIZED,
{"ok": False, "code": "ADMIN_AUTH_REQUIRED", "error": "管理员密码错误"},
)
return
token = SERVICE.create_admin_session()
cookie_path = self._request_prefix()
secure_cookie = (
"; Secure"
if self.headers.get("X-Forwarded-Proto", "").lower() == "https"
or self.headers.get("X-Forwarded-Ssl", "").lower() == "on"
else ""
)
self._send_json(
HTTPStatus.OK,
{"ok": True},
extra_headers={
"Set-Cookie": (
f"mapstudio_admin={token}; Path={cookie_path}; HttpOnly; "
f"SameSite=Strict; Max-Age=28800{secure_cookie}"
)
},
)
return
if path == "/api/admin/logout":
if not self._require_admin():
return
requested_cookie_path = ""
try:
length = int(self.headers.get("Content-Length", "0"))
if 0 < length <= 4096:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
requested_cookie_path = str(payload.get("cookiePath", ""))
cookie_jar = cookies.SimpleCookie(self.headers.get("Cookie", ""))
session_token = cookie_jar.get("mapstudio_admin")
if session_token:
SERVICE.destroy_admin_session(session_token.value)
except Exception:
pass
SERVICE.destroy_all_admin_sessions()
raw_path = urllib.parse.urlsplit(self.path).path
suffix = "/api/admin/logout"
proxy_prefix = raw_path[:-len(suffix)] + "/" if raw_path.endswith(suffix) else "/"
deletion = (
"mapstudio_admin=; Max-Age=0; "
"Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict; Path="
)
cookie_paths = ["/", "/api/admin/"]
if proxy_prefix not in cookie_paths:
cookie_paths.append(proxy_prefix)
if (
requested_cookie_path.startswith("/")
and not any(char in requested_cookie_path for char in "\r\n;")
):
normalized_cookie_path = requested_cookie_path
if not normalized_cookie_path.endswith("/"):
normalized_cookie_path += "/"
if normalized_cookie_path not in cookie_paths:
cookie_paths.append(normalized_cookie_path)
self._send_json(
HTTPStatus.OK,
{"ok": True},
extra_headers=[("Set-Cookie", deletion + cookie_path) for cookie_path in cookie_paths],
)
return
if (
path in ("/api/cache-policy", "/api/upstream-proxy")
or path.startswith(("/api/tianditu/", "/api/openmap/", "/api/cache-admin/"))
) and not self._require_admin():
return
if path in ("/api/cache-admin/refresh", "/api/cache-admin/delete"):
limit_key = "cache-admin-refresh" if path.endswith("/refresh") else "cache-admin-delete"
if not self._allow_request(limit_key, 3, 60):
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 16 * 1024:
raise ValueError("Invalid request body")
payload = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Invalid cache management request")
categories = payload.get("categories")
if not isinstance(categories, list) or not categories or len(categories) > 20:
raise ValueError("Select at least one valid cache category")
category_ids = [str(item) for item in categories]
bounds = SERVICE.parse_cache_bounds(payload.get("bounds"))
if path.endswith("/refresh"):
if SERVICE.config.cache_only:
self._send_json(
HTTPStatus.CONFLICT,
{
"ok": False,
"code": "CACHE_ONLY_MODE",
"error": "服务器处于仅缓存模式,已禁止更新云端缓存",
},
)
return
force = bool(payload.get("force", False))
if bounds is None:
scheduled = SERVICE.refresh_cache_categories(category_ids, force=force)
scope = {
"scheduled": scheduled,
"matchedFiles": scheduled,
"skippedCategories": [],
}
else:
scope = SERVICE.refresh_cache_scope(category_ids, bounds, force=force)
self._send_json(
HTTPStatus.ACCEPTED,
{
"ok": True,
"categories": category_ids,
"scheduled": scope["scheduled"],
"scheduledTiles": scope["scheduled"],
"matchedFiles": scope.get("matchedFiles", 0),
"matchedTiles": scope.get("matchedTiles", scope.get("matchedFiles", 0)),
"skippedCategories": scope.get("skippedCategories", []),
"bounded": bounds is not None,
"bounds": list(bounds) if bounds is not None else None,
"force": force,
},
)
return
if payload.get("confirmation") != "DELETE_CACHE":
raise ValueError("Deletion confirmation is required")
result = (
SERVICE.delete_cache_scope(category_ids, bounds)
if bounds is not None
else SERVICE.delete_cache_categories(category_ids)
)
self._send_json(HTTPStatus.OK, result)
except RuntimeError as error:
self._send_json(
HTTPStatus.CONFLICT,
{"ok": False, "code": "CACHE_BUSY", "error": str(error)},
)
except (ValueError, TypeError, json.JSONDecodeError) as error:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(error)})
except OSError as error:
self._send_json(
HTTPStatus.INTERNAL_SERVER_ERROR,
{"ok": False, "error": "删除缓存失败", "detail": str(error)},
)
return
if path in ("/api/tianditu/job/cancel", "/api/openmap/job/cancel"):
job_id = urllib.parse.parse_qs(parsed.query).get("id", [""])[0]
if not job_id:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Job id is required"})
return
cancel = (
SERVICE.cancel_tianditu_job
if path.startswith("/api/tianditu/")
else SERVICE.cancel_openmap_job
)
job = cancel(job_id)
if not job:
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Job not found"})
else:
self._send_json(HTTPStatus.OK, {"ok": True, "job": job})
return
if path == "/api/cache-policy":
if not self._allow_request("cache-policy", 60, 60):
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 4096:
raise ValueError("Invalid request body")
payload = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(payload, dict) or not isinstance(payload.get("cacheOnly"), bool):
raise ValueError("cacheOnly must be a boolean")
enabled = SERVICE.set_cache_only(payload["cacheOnly"])
self._send_json(
HTTPStatus.OK,
{
"ok": True,
"cacheOnly": enabled,
"placeholderTiles": True,
},
)
except (ValueError, TypeError, json.JSONDecodeError) as error:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(error)})
return
if path == "/api/upstream-proxy":
if not self._allow_request("upstream-proxy", 60, 60):
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 4096:
raise ValueError("Invalid request body")
payload = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Invalid proxy request")
if payload.get("reset") is True:
proxy_url = SERVICE.reset_upstream_proxy()
else:
proxy_url = SERVICE.set_upstream_proxy(
str(payload.get("proxy", "")),
persist=bool(payload.get("persist", False)),
)
proxy = SERVICE.upstream_proxy_status()
self._send_json(
HTTPStatus.OK,
{
"ok": True,
"upstreamProxyConfigured": proxy["configured"],
"upstreamProxy": proxy["proxy"],
"upstreamProxyDefaultConfigured": proxy["defaultConfigured"],
"upstreamProxyDefault": proxy["defaultProxy"],
"upstreamProxyTemporary": proxy["temporary"],
},
)
except (ValueError, TypeError, json.JSONDecodeError) as error:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(error)})
return
if path == "/api/openmap/topic-download":
if not self._allow_request("openmap-topic-download", 12, 60):
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 16 * 1024:
raise ValueError("Invalid request body")
payload = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Invalid topic download request")
category_id = str(payload.get("category", ""))
job = SERVICE.start_openmap_topic_download(
category_id,
force=bool(payload.get("force", False)),
)
self._send_json(HTTPStatus.ACCEPTED, {"ok": True, "job": job})
except (ValueError, TypeError, json.JSONDecodeError) as error:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(error)})
return
if path == "/api/openmap/download":
if not self._allow_request("openmap-download", 12, 60):
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 1024 * 1024:
raise ValueError("Invalid request body")
payload = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Invalid download request")
providers = [str(value) for value in payload.get("providers", [])]
force = bool(payload.get("force", False))
if isinstance(payload.get("bounds"), dict):
raw_bounds = payload["bounds"]
bounds = (
float(raw_bounds["south"]),
float(raw_bounds["west"]),
float(raw_bounds["north"]),
float(raw_bounds["east"]),
)
job = SERVICE.start_openmap_download(
providers=providers,
bounds=bounds,
zoom_min=int(payload.get("zoomMin")),
zoom_max=int(payload.get("zoomMax")),
force=force,
)
else:
job = SERVICE.start_openmap_download(
providers=providers,
z=int(payload.get("z")),
x0=int(payload.get("x0")),
x1=int(payload.get("x1")),
y0=int(payload.get("y0")),
y1=int(payload.get("y1")),
force=force,
)
self._send_json(HTTPStatus.ACCEPTED, {"ok": True, "job": job})
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(error)})
return
if path == "/api/tianditu/download":
if not self._allow_request("tianditu-download", 12, 60):
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 1024 * 1024:
raise ValueError("Invalid request body")
payload = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Invalid download request")
providers = [str(value) for value in payload.get("providers", [])]
force = bool(payload.get("force", False))
if isinstance(payload.get("bounds"), dict):
raw_bounds = payload["bounds"]
bounds = (
float(raw_bounds["south"]),
float(raw_bounds["west"]),
float(raw_bounds["north"]),
float(raw_bounds["east"]),
)
job = SERVICE.start_tianditu_download(
providers=providers,
bounds=bounds,
zoom_min=int(payload.get("zoomMin")),
zoom_max=int(payload.get("zoomMax")),
force=force,
)
else:
job = SERVICE.start_tianditu_download(
providers=providers,
z=int(payload.get("z")),
x0=int(payload.get("x0")),
x1=int(payload.get("x1")),
y0=int(payload.get("y0")),
y1=int(payload.get("y1")),
force=force,
)
self._send_json(HTTPStatus.ACCEPTED, {"ok": True, "job": job})
except TiandituKeyMissing as error:
self._send_json(
HTTPStatus.CONFLICT,
{
"ok": False,
"code": "TIANDITU_KEY_MISSING",
"error": "请先配置天地图 API KEY",
"detail": str(error),
},
)
except (ValueError, TypeError, json.JSONDecodeError) as error:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(error)})
return
if path not in ("/api/cache/refresh", "/api/cache/refresh-all"):
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Not found"})
return
if not self._require_admin():
return
query = urllib.parse.parse_qs(parsed.query)
force = query.get("force", ["0"])[0] == "1"
if SERVICE.config.cache_only:
self._send_json(
HTTPStatus.CONFLICT,
{
"ok": False,
"code": "CACHE_ONLY_MODE",
"error": "服务器处于仅缓存模式,已禁止刷新云端缓存",
},
)
return
if path == "/api/cache/refresh-all":
if not self._allow_request("cache-refresh-all", 2, 60):
return
scheduled = SERVICE.refresh_stale(force=force)
scope = "all"
else:
if not self._allow_request("cache-refresh-view", 4, 60):
return
try:
south = float(query["south"][0])
west = float(query["west"][0])
north = float(query["north"][0])
east = float(query["east"][0])
zoom = int(float(query.get("zoom", ["12"])[0]))
except (KeyError, ValueError, IndexError):
self._send_json(
HTTPStatus.BAD_REQUEST,
{"ok": False, "error": "Current view bbox and zoom are required"},
)
return
if not (-90 <= south < north <= 90 and -180 <= west < east <= 180):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid bbox order"})
return
if not 0 <= zoom <= 18:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid zoom"})
return
try:
scheduled = SERVICE.refresh_current_view(
(south, west, north, east),
zoom,
force=True,
)
except ValueError as error:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(error)})
return
scope = "view"
force = True
pending = len(list(SERVICE.pending_rail_dir.glob("*.json")))
if pending:
SERVICE._refresh_executor.submit(SERVICE.retry_pending_rail_jobs, min(8, pending))
pending_water = len(list(SERVICE.pending_water_dir.glob("*.json")))
if pending_water:
SERVICE._refresh_executor.submit(SERVICE.retry_pending_water_jobs, min(8, pending_water))
SERVICE.increment("manual_refreshes")
self._send_json(
HTTPStatus.ACCEPTED,
{
"ok": True,
"scheduled": scheduled,
"pendingRailRequests": pending,
"pendingWaterRequests": pending_water,
"force": force,
"scope": scope,
"message": "Current-view cache refresh scheduled in background"
if scope == "view" else "Bulk cache refresh scheduled in background",
},
)
def _handle_rail(self, raw_query: str) -> None:
query = urllib.parse.parse_qs(raw_query)
try:
south = float(query["south"][0])
west = float(query["west"][0])
north = float(query["north"][0])
east = float(query["east"][0])
except (KeyError, ValueError, IndexError):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Missing or invalid bbox"})
return
if not (-90 <= south < north <= 90 and -180 <= west < east <= 180):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid bbox order"})
return
area = (north - south) * (east - west)
if area > 64800:
self._send_json(
HTTPStatus.BAD_REQUEST,
{"ok": False, "error": "Rail query bbox is too large; zoom in"},
)
return
types = [
value
for value in query.get("types", ["rail", "subway"])[0].split(",")
if value in ("rail", "subway")
]
if not types:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "No valid rail types"})
return
force = query.get("refresh", ["0"])[0] == "1"
try:
tile_zoom = int(query.get("zoom", ["12"])[0])
except ValueError:
tile_zoom = 12
if "subway" in types:
tile_zoom = SERVICE.choose_vector_zoom(
(south, west, north, east),
preferred=14,
max_tiles=MAX_VECTOR_TILES,
)
else:
tile_zoom = min(14, max(7, tile_zoom + 2))
try:
response, cache_state = SERVICE.get_rail(
(south, west, north, east), types, force, tile_zoom=tile_zoom
)
if query.get("format", [""])[0] == "delta-v1":
response = SERVICE.compact_vector_response(response, "rail")
self._send_json(HTTPStatus.OK, response, cache_state)
except RailPending as pending:
self._send_json(
HTTPStatus.ACCEPTED,
{
"ok": False,
"pending": True,
"cacheKey": pending.cache_key,
"message": "Rail query queued for background retrieval",
},
)
except Exception as error:
self._send_json(
HTTPStatus.BAD_GATEWAY,
{"ok": False, "error": "Rail upstream unavailable", "detail": str(error)},
)
def _handle_water(self, raw_query: str) -> None:
query = urllib.parse.parse_qs(raw_query)
try:
south = float(query["south"][0])
west = float(query["west"][0])
north = float(query["north"][0])
east = float(query["east"][0])
except (KeyError, ValueError, IndexError):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Missing or invalid bbox"})
return
if not (-90 <= south < north <= 90 and -180 <= west < east <= 180):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid bbox order"})
return
area = (north - south) * (east - west)
if area > 64800:
self._send_json(
HTTPStatus.BAD_REQUEST,
{"ok": False, "error": "Water query bbox is too large; zoom in"},
)
return
force = query.get("refresh", ["0"])[0] == "1"
include = [
value
for value in query.get("include", ["water,ports,vegetation"])[0].split(",")
if value in ("water", "ports", "vegetation")
]
if not include:
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "No environment layers selected"})
return
try:
tile_zoom = int(query.get("zoom", ["12"])[0])
except ValueError:
tile_zoom = 12
tile_zoom = min(ENVIRONMENT_MAX_ZOOM, max(ENVIRONMENT_MIN_ZOOM, tile_zoom))
try:
response, cache_state = SERVICE.get_water(
(south, west, north, east), force, tile_zoom=tile_zoom, include=include
)
if query.get("format", [""])[0] == "delta-v1":
response = SERVICE.compact_vector_response(response, "water")
self._send_json(HTTPStatus.OK, response, cache_state)
except WaterPending as pending:
self._send_json(
HTTPStatus.ACCEPTED,
{
"ok": False,
"pending": True,
"cacheKey": pending.cache_key,
"message": "Water query queued for background retrieval",
},
)
except Exception as error:
self._send_json(
HTTPStatus.BAD_GATEWAY,
{"ok": False, "error": "Water upstream unavailable", "detail": str(error)},
)
def _handle_boundaries(self, raw_query: str) -> None:
query = urllib.parse.parse_qs(raw_query)
try:
south = float(query["south"][0])
west = float(query["west"][0])
north = float(query["north"][0])
east = float(query["east"][0])
except (KeyError, ValueError, IndexError):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Missing or invalid bbox"})
return
if not (-90 <= south < north <= 90 and -180 <= west < east <= 180):
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Invalid bbox order"})
return
try:
zoom = int(query.get("zoom", ["12"])[0])
except ValueError:
zoom = 12
preferred_zoom = min(14, max(4, zoom + 1))
tile_zoom = SERVICE.choose_vector_zoom(
(south, west, north, east),
preferred=preferred_zoom,
max_tiles=MAX_VECTOR_TILES,
)
force = query.get("refresh", ["0"])[0] == "1"
try:
response, cache_state = SERVICE.get_boundaries(
(south, west, north, east),
force,
tile_zoom=tile_zoom,
)
if query.get("format", [""])[0] == "delta-v1":
response = SERVICE.compact_vector_response(response, "boundary")
self._send_json(HTTPStatus.OK, response, cache_state)
except Exception as error:
self._send_json(
HTTPStatus.BAD_GATEWAY,
{"ok": False, "error": "Administrative boundary data unavailable", "detail": str(error)},
)
def _serve_static(self, request_path: str) -> None:
decoded = urllib.parse.unquote(request_path)
if decoded in ("", "/"):
decoded = "/index.html"
relative = decoded.lstrip("/")
candidate = (ROOT / relative).resolve()
try:
resolved_relative = candidate.relative_to(ROOT)
except ValueError:
self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Forbidden"})
return
public_path = "/" + resolved_relative.as_posix()
public_parts = public_path.split("/")
public_allowed = public_path.lower() in PUBLIC_STATIC_FILES or (
len(public_parts) > 2 and public_parts[1] in PUBLIC_STATIC_DIRS
)
if not public_allowed:
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "File not found"})
return
if not candidate.is_file():
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "File not found"})
return
content_type = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type in (
"application/javascript",
"application/json",
):
content_type += "; charset=utf-8"
try:
data = candidate.read_bytes()
except OSError:
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": "Read failed"})
return
compressible = (
content_type.startswith("text/")
or content_type in ("application/javascript", "application/json", "image/svg+xml")
)
use_gzip = (
compressible
and len(data) > 1024
and "gzip" in self.headers.get("Accept-Encoding", "").lower()
)
body = gzip.compress(data, compresslevel=6, mtime=0) if use_gzip else data
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
if use_gzip:
self.send_header("Content-Encoding", "gzip")
self.send_header("Vary", "Accept-Encoding")
entry_file = candidate.name in (
"index.html",
"sw.js",
"tianditu-admin.html",
"openmap-admin.html",
"cache-admin.html",
"regulations.html",
)
versioned_asset = "?v=" in self.path or "/vendor/" in decoded
long_lived_asset = candidate.name in (
"app.js",
"styles.css",
"print-block.js",
"compliance-gate.js",
)
self.send_header(
"Cache-Control",
"no-cache"
if entry_file
else LONG_BROWSER_CACHE if versioned_asset or long_lived_asset else "public, max-age=86400",
)
self._cors_headers()
self._security_headers()
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def log_message(self, fmt: str, *args: Any) -> None:
print(
"%s - - [%s] %s"
% (self.client_address[0], self.log_date_time_string(), fmt % args),
flush=True,
)
def parse_args() -> ServiceConfig:
parser = argparse.ArgumentParser(description="Map Studio local map cache service")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8081)
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR)
parser.add_argument("--ttl-hours", type=float, default=DEFAULT_TTL_SECONDS / 3600)
parser.add_argument("--rail-ttl-hours", type=float, default=DEFAULT_RAIL_TTL_SECONDS / 3600)
parser.add_argument("--refresh-hours", type=float, default=DEFAULT_REFRESH_SECONDS / 3600)
parser.add_argument("--refresh-batch", type=int, default=128)
parser.add_argument("--tianditu-key", default="")
parser.add_argument("--admin-password", default="")
parser.add_argument(
"--upstream-proxy",
default=None,
help="HTTP/HTTPS proxy for all outbound map, DEM, and Overpass requests",
)
parser.add_argument(
"--disable-upstream-proxy",
dest="upstream_proxy",
action="store_const",
const="",
help="Disable a persisted or environment upstream proxy",
)
parser.add_argument(
"--cache-only",
"--offline-only",
dest="cache_only",
action="store_true",
default=None,
help="Disable all upstream requests and serve placeholder tiles for cache misses",
)
parser.add_argument(
"--allow-online",
dest="cache_only",
action="store_false",
help="Override a persisted or environment cache-only setting",
)
args = parser.parse_args()
tianditu_key = (args.tianditu_key or os.environ.get("TIANDITU_KEY", "")).strip()
if not tianditu_key:
key_file = ROOT / "tianditu-key.txt"
if key_file.is_file():
tianditu_key = key_file.read_text(encoding="utf-8").strip()
admin_password = (
args.admin_password or os.environ.get("MAP_STUDIO_ADMIN_PASSWORD", "")
).strip()
if not admin_password:
password_file = ROOT / "admin-password.txt"
if password_file.is_file():
admin_password = password_file.read_text(encoding="utf-8").strip()
cache_only = args.cache_only
if cache_only is None:
env_cache_only = os.environ.get("MAP_STUDIO_CACHE_ONLY", "").strip().lower()
if env_cache_only in ("1", "true", "yes", "on"):
cache_only = True
elif env_cache_only in ("0", "false", "no", "off"):
cache_only = False
else:
setting_file = Path(args.cache_dir) / CACHE_ONLY_SETTING_NAME
try:
cache_only = setting_file.read_text(encoding="utf-8").strip() == "1"
except OSError:
cache_only = False
upstream_proxy = args.upstream_proxy
if upstream_proxy is None:
upstream_proxy = os.environ.get("MAP_STUDIO_UPSTREAM_PROXY")
if upstream_proxy is None:
proxy_file = Path(args.cache_dir) / UPSTREAM_PROXY_SETTING_NAME
try:
upstream_proxy = proxy_file.read_text(encoding="utf-8").strip()
except OSError:
upstream_proxy = ""
upstream_proxy = normalize_upstream_proxy(upstream_proxy)
return ServiceConfig(
host=args.host,
port=args.port,
cache_dir=args.cache_dir,
tile_ttl_seconds=max(60, int(args.ttl_hours * 3600)),
rail_ttl_seconds=max(60, int(args.rail_ttl_hours * 3600)),
refresh_seconds=max(60, int(args.refresh_hours * 3600)),
refresh_batch=max(1, args.refresh_batch),
tianditu_key=tianditu_key,
admin_password=admin_password,
cache_only=bool(cache_only),
upstream_proxy=upstream_proxy,
)
def main() -> None:
global SERVICE
config = parse_args()
SERVICE = MapService(config)
server = QuietThreadingHTTPServer((config.host, config.port), MapStudioRequestHandler)
print(f"Map Studio server: http://{config.host}:{config.port}/", flush=True)
print(f"Cache directory: {config.cache_dir.resolve()}", flush=True)
print(f"Cache-only mode: {'ON' if config.cache_only else 'OFF'}", flush=True)
print(
"Manual download workers: "
f"Tianditu={TIANDITU_DOWNLOAD_WORKERS}, OpenMap={OPENMAP_DOWNLOAD_WORKERS}; "
f"staging={DOWNLOAD_STAGING_DIR_NAME}",
flush=True,
)
proxy_label = display_upstream_proxy(config.upstream_proxy)
print(f"Upstream proxy: {proxy_label or 'direct'}", flush=True)
if config.admin_password and len(config.admin_password) < 12:
print(
"WARNING: admin password is shorter than 12 characters; use a longer "
"password, especially when exposing the service through a proxy.",
flush=True,
)
print(
f"Tile TTL: {config.tile_ttl_seconds}s, rail TTL: {config.rail_ttl_seconds}s, "
f"refresh interval: {config.refresh_seconds}s",
flush=True,
)
try:
server.serve_forever(poll_interval=0.5)
except KeyboardInterrupt:
print("\nStopping Map Studio server...", flush=True)
finally:
server.server_close()
SERVICE.close()
print("Map Studio server stopped.", flush=True)
if __name__ == "__main__":
main()