Compare commits

15 Commits

Author SHA1 Message Date
OpenAI Codex
e3745a385a fix: correct Kyushu semantic beacon variants 2026-08-06 19:58:16 +08:00
OpenAI Codex
6d9c0d571a fix: restore Kyushu semantic beacon variants 2026-08-06 19:31:45 +08:00
OpenAI Codex
69b4f961a6 fix: preserve Kyushu semantic fill patterns 2026-08-06 18:35:04 +08:00
OpenAI Codex
e52328b3f5 feat: add Kyushu semantic icon test delivery 2026-08-06 17:44:22 +08:00
OpenAI Codex
35d713993f 固化全国 Full semantic extentfix 重建基线 2026-08-06 15:24:52 +08:00
OpenAI Codex
d678851f21 fix fish port 20m skip missing coastline 2026-05-03 15:42:01 +08:00
OpenAI Codex
2513dd56f8 添加全国三层生成与港名查FPC工具 2026-05-02 14:32:06 +08:00
OpenAI Codex
15994b5c7e update full final audit status 2026-04-18 23:45:40 +08:00
OpenAI Codex
7105c2f405 记录 full 全国版已合并 2026-04-18 22:58:19 +08:00
OpenAI Codex
924df2edca add audit report index 2026-04-18 21:30:11 +08:00
OpenAI Codex
bc31552437 压缩步骤记录 2026-04-18 21:13:39 +08:00
OpenAI Codex
08a00a86c7 record full rebuild staging audits 2026-04-18 16:48:13 +08:00
OpenAI Codex
6d72340f36 guard high-extent tile reencoding 2026-04-18 14:26:54 +08:00
OpenAI Codex
b0f68dbe39 rebuild hotspot tiles for full semantic audit 2026-04-18 14:17:34 +08:00
OpenAI Codex
d50b127eae audit full semantic extent and AOI visuals 2026-04-18 09:43:42 +08:00
28 changed files with 18165 additions and 3653 deletions

View File

@@ -0,0 +1,290 @@
# NavSea 全国四脚本一页面数据生成与查看说明
本文说明全国版三层数据的重算、导出、整体密度图生成和页面查看方式。
当前统一数据库:
- `navsea_japan_coast_grid`
当前全国三层:
- `coast_200m`:全国海岸 200x200
- `fish_port_20m`:全国渔港 20x20
- `hazard_50m`:全国海上障碍 50x50
当前整体密度图:
- `density_overview`:三档密度整体格子图
## 1. 生成顺序
建议按下面顺序重算:
1. 先生成 `200x200`
2. 再生成 `20x20`
3. 最后生成 `50x50`
4. 再导出全国静态 JSON 资产
5. 最后查看整体密度图
这样可以先把底盘准备好,再做上层数据。
## 2. 200x200 全国海岸重算
脚本:
- [`coastline/build_japan_coast_grid_mysql.py`](/root/sourceserver/pbf/coastline/build_japan_coast_grid_mysql.py)
默认会写入:
- 数据库:`navsea_japan_coast_grid`
- 图层:`coast_200m`
- 导出目录:`out/coastline/japan_coast_grid_mysql`
执行命令:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/build_japan_coast_grid_mysql.py
```
## 3. 20x20 全国渔港重算
脚本:
- [`coastline/build_fish_port_20m_full_mysql_resume.py`](/root/sourceserver/pbf/coastline/build_fish_port_20m_full_mysql_resume.py)
默认会写入:
- 数据库:`navsea_japan_coast_grid`
- 图层:`fish_port_20m`
推荐执行命令:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/build_fish_port_20m_full_mysql_resume.py \
--resume \
--max-scan-cells 200000
```
如果只想重算某个 `PRC`,可以显式指定,例如:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/build_fish_port_20m_full_mysql_resume.py \
--prc 02 \
--max-scan-cells 200000
```
如果只想重算某个具体渔港,可以直接用 `FPC`,例如:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/build_fish_port_20m_full_mysql_resume.py \
--fpc 1210100 \
--max-scan-cells 200000
```
说明:
- `PRC` 需要按两位字符串理解,`2``02` 等价
- `FPC` 是单个渔港的港码,脚本会先反查它所属的 `PRC`,再只重算这个港口
- 单港口模式会在日志里同时输出:
- 命中的 `200x200` 粗格数量
- 最终写入的 `20x20` 格子数量
- 默认先按渔港 bbox 找同区域 `coast_200m` 粗格,再把粗格切成 `20x20`
- 默认不会再允许无边界整片扫描
- 如果确实要回退旧行为,需要手动加脚本里提供的相应开关
## 4. 根据港名查 FPC
脚本:
- [`find_fpc_by_port_name.py`](/root/sourceserver/pbf/find_fpc_by_port_name.py)
用途:
- 已知渔港名字时,先查出可能的 `FPC`
- 这个脚本会扫描 `coastline/C09-06.zip` 里的渔港要素
- 支持模糊匹配和精确匹配
推荐用法:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python find_fpc_by_port_name.py 無垢島
```
如果你想只看精确命中:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python find_fpc_by_port_name.py 無垢島 --exact
```
如果你想要 JSON 结果:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python find_fpc_by_port_name.py 無垢島 --json
```
如果原始包里没有港名字段,建议改用港名对照表:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python find_fpc_by_port_name.py 無垢島 --catalog out/port_name_catalog.csv
```
对照表最低需要这些列里的任意几列:
- `name` / `港名` / `名称`
- `FPC`
- 可选 `PRC`
说明:
- 这份脚本主要查 `C09-06.zip` 里的渔港名称字段
- 默认会把 `NA2``NA4``FCF` 等候选字段一起拿来比对
- 输出里会给出:
- `FPC`
- `PRC`
- 主名称
- 辅助名称
- 源记录 id
- 如果原始包里没有这条名字,脚本会提示你改用 `--catalog`
## 5. 50x50 全国障碍重算
脚本:
- [`coastline/build_japan_hazard_50m_mysql.py`](/root/sourceserver/pbf/coastline/build_japan_hazard_50m_mysql.py)
默认会写入:
- 数据库:`navsea_japan_coast_grid`
- 图层:`hazard_50m`
- PBF 根目录:`/home/wwwroot/pbf-delivery-full-20260418-rebuild`
执行命令:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/build_japan_hazard_50m_mysql.py
```
这个脚本当前会纳入:
- `navigation_hazard_area`
- `fixed_fishing_gear_area`
- `anchor_caution_hazard_area`
- `navigation_hazard_point`
- `anchor_caution_hazard_point`
- `navigation_marks`
- `baseline_area``canonical_object_type=breakwater`
## 6. 全国三层静态 JSON 导出
脚本:
- [`coastline/export_navgrid_mysql_assets.py`](/root/sourceserver/pbf/coastline/export_navgrid_mysql_assets.py)
默认导出到:
- `src/pbf/coastline-mysql/japan_national/`
执行命令:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/export_navgrid_mysql_assets.py \
--db-name navsea_japan_coast_grid \
--out-dir src/pbf/coastline-mysql/japan_national
```
导出结果包括:
- `coast_200m_grid.geojson`
- `fish_port_20m_grid.geojson`
- `hazard_50m_grid.geojson`
- `density_overview_grid.geojson`
- `manifest.json`
说明:
- `density_overview_grid.geojson` 是按密度优先级合并后的整体格子图
- 同一区域如果存在更高密度格子,就优先保留更高密度
- 当前密度优先级:
- `20x20` = 高密度,淡绿色
- `50x50` = 中密度,黄色
- `200x200` = 低密度,淡红色
## 7. HTML 预览页面
当前全国预览页面:
- `http://192.168.200.184/newpec/navsea-coastline-fukuoka-saga-200m.html`
说明:
- 页面标题已经改成全国口径
- 页面内有四个按钮:
- `200x200`
- `20x20`
- `50x50`
- `整体密度`
页面对应的本地文件:
- [`src/pbf/navsea-coastline-fukuoka-saga-200m.html`](/root/sourceserver/pbf/src/pbf/navsea-coastline-fukuoka-saga-200m.html)
页面加载的数据源:
- `./coastline-mysql/japan_coast_200m/manifest.json`
- `./coastline-mysql/japan_coast_200m/coast_200m_grid.geojson`
- `./coastline-mysql/japan_national/manifest.json`
- `./coastline-mysql/japan_national/fish_port_20m_grid.geojson`
- `./coastline-mysql/japan_national/hazard_50m_grid.geojson`
- `./coastline-mysql/japan_national/density_overview_grid.geojson`
## 8. 页面查看方式
如果只是看全国 200x200
1. 打开页面
2. 默认就是 `200x200`
3. 页面会自动定位到全国底盘范围
如果想切换看渔港或障碍:
1. 点击 `20x20`
2. 点击 `50x50`
3. 点击 `整体密度`
如果想强制刷新:
1. 点击 `重新加载`
## 9. 线下同步到网页目录
如果你重新生成了静态资产,想让线上页面立即看到新内容,通常需要把生成结果同步到网页目录:
```bash
cp -r src/pbf/coastline-mysql/japan_national /mnt/sda1/www/newpec/
cp src/pbf/navsea-coastline-fukuoka-saga-200m.html /mnt/sda1/www/newpec/navsea-coastline-fukuoka-saga-200m.html
```
## 10. 一句话版
最常用的整套命令是:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/build_japan_coast_grid_mysql.py
./.venv/bin/python coastline/build_fish_port_20m_full_mysql_resume.py --resume --max-scan-cells 200000
./.venv/bin/python coastline/build_japan_hazard_50m_mysql.py
./.venv/bin/python coastline/export_navgrid_mysql_assets.py --db-name navsea_japan_coast_grid --out-dir src/pbf/coastline-mysql/japan_national
```
然后打开:
- `http://192.168.200.184/newpec/navsea-coastline-fukuoka-saga-200m.html`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,565 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import glob
import json
import math
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import xml.etree.ElementTree as ET
import pymysql
from shapely.geometry import LineString, MultiLineString, box, mapping
from shapely.prepared import prep
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_INPUT_GLOB = "coastline/C23-06_*_GML.zip"
DEFAULT_OUT_DIR = "out/coastline/japan_coast_grid_mysql"
LAYER_NAME = "coast_200m"
CELL_SIZE_M = 200.0
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
NS = {"gml": "http://www.opengis.net/gml/3.2"}
@dataclass(frozen=True)
class SourcePackage:
path: Path
prefecture: str
code: str
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_bbox_to_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> tuple[float, float, float, float]:
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def parse_text_list(text: str) -> list[float]:
return [float(part) for part in text.split() if part]
def detect_prefecture(meta_xml: str) -> str:
title_start = meta_xml.find("<title>")
if title_start == -1:
return "unknown"
title_end = meta_xml.find("</title>", title_start)
if title_end == -1:
return "unknown"
return meta_xml[title_start + 7:title_end].strip()
def load_package(path: Path) -> SourcePackage:
with zipfile.ZipFile(path) as zf:
meta_name = next(name for name in zf.namelist() if "META" in name and name.endswith(".xml"))
meta_xml = zf.read(meta_name).decode("shift_jis", errors="replace")
prefecture = detect_prefecture(meta_xml)
code = path.stem.replace("_GML", "")
return SourcePackage(path=path, prefecture=prefecture, code=code)
def iter_coastline_lines(zip_path: Path) -> Iterable[LineString]:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
root = ET.fromstring(zf.read(xml_name))
for curve in root.findall(".//gml:Curve", NS):
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//gml:posList", NS):
if not pos_list.text:
continue
values = parse_text_list(pos_list.text)
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append((mercator_x(lon), mercator_y(lat)))
if len(coords) >= 2:
yield LineString(coords)
def mysql_connect(database: str | None = None):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"charset": "utf8mb4",
"autocommit": False,
"cursorclass": pymysql.cursors.Cursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
if database:
kwargs["database"] = database
return pymysql.connect(**kwargs)
def ensure_database() -> None:
conn = mysql_connect()
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{DB_NAME}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
finally:
conn.close()
def ensure_schema(conn) -> None:
ddl = [
"DROP TABLE IF EXISTS navsea_grid_cell",
"DROP TABLE IF EXISTS navsea_grid_package_stat",
"DROP TABLE IF EXISTS navsea_grid_layer_meta",
"""
CREATE TABLE navsea_grid_layer_meta (
layer_name VARCHAR(32) NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
source_desc TEXT NOT NULL,
cell_size_m DOUBLE NOT NULL,
feature_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
export_file VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE navsea_grid_package_stat (
source_code VARCHAR(64) NOT NULL,
prefecture VARCHAR(128) NOT NULL,
zip_path TEXT NOT NULL,
line_count BIGINT NOT NULL,
point_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
grid_min_lon DOUBLE NOT NULL,
grid_min_lat DOUBLE NOT NULL,
grid_max_lon DOUBLE NOT NULL,
grid_max_lat DOUBLE NOT NULL,
blocked_count BIGINT NOT NULL,
candidate_count BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE navsea_grid_cell (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
layer_name VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NOT NULL,
row_idx INT NOT NULL,
col_idx INT NOT NULL,
cell_size_m DOUBLE NOT NULL,
state_name VARCHAR(32) NOT NULL,
class_name VARCHAR(32) NOT NULL,
source_name VARCHAR(191) NOT NULL,
min_lon DOUBLE NOT NULL,
min_lat DOUBLE NOT NULL,
max_lon DOUBLE NOT NULL,
max_lat DOUBLE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_layer_cell (layer_name, cell_id),
KEY idx_layer_state (layer_name, state_name),
KEY idx_layer_rowcol (layer_name, row_idx, col_idx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
with conn.cursor() as cur:
for stmt in ddl:
cur.execute(stmt)
conn.commit()
def insert_cells(cur, rows: list[tuple]) -> None:
cur.executemany(
"""
INSERT INTO navsea_grid_cell
(layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name,
min_lon, min_lat, max_lon, max_lat)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
row_idx = row_idx,
col_idx = col_idx,
cell_size_m = cell_size_m,
state_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(state_name), state_name),
class_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(class_name), class_name),
source_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(source_name), source_name),
min_lon = min_lon,
min_lat = min_lat,
max_lon = max_lon,
max_lat = max_lat
""",
rows,
)
def upsert_meta(
cur,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
feature_count: int,
bbox: tuple[float, float, float, float],
export_file: str | None,
) -> None:
cur.execute(
"""
INSERT INTO navsea_grid_layer_meta
(layer_name, description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
source_desc=VALUES(source_desc),
cell_size_m=VALUES(cell_size_m),
feature_count=VALUES(feature_count),
bbox_min_lon=VALUES(bbox_min_lon),
bbox_min_lat=VALUES(bbox_min_lat),
bbox_max_lon=VALUES(bbox_max_lon),
bbox_max_lat=VALUES(bbox_max_lat),
export_file=VALUES(export_file)
""",
(
layer_name,
description,
source_desc,
cell_size_m,
feature_count,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
export_file,
),
)
def main() -> None:
parser = argparse.ArgumentParser(description="Build Japan coastline 200m grid directly into MySQL")
parser.add_argument(
"--input",
dest="inputs",
action="append",
default=[],
help="海岸线 GML zip。可重复指定默认自动扫描 coastline/C23-06_*_GML.zip",
)
parser.add_argument(
"--db-name",
default=DB_NAME,
help="MySQL 数据库名",
)
parser.add_argument(
"--out-dir",
default=DEFAULT_OUT_DIR,
help="输出目录,用于可选 GeoJSON 和元数据",
)
parser.add_argument("--buffer-m", type=float, default=50.0, help="海岸线缓冲距离,默认 50m")
parser.add_argument(
"--margin-cells",
type=int,
default=2,
help="输出范围外扩的格子数,默认 2 个格子",
)
parser.add_argument(
"--write-geojson",
action="store_true",
help="同时输出 coastline.geojson 和 grid.geojson",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
if args.inputs:
input_paths: list[Path] = []
for item in args.inputs:
if any(ch in item for ch in "*?[]"):
input_paths.extend(Path(path) for path in sorted(glob.glob(item)))
else:
input_paths.append(Path(item))
else:
input_paths = [Path(path) for path in sorted(glob.glob(str(project_root / DEFAULT_INPUT_GLOB)))]
if not input_paths:
raise SystemExit("no coastline packages found")
packages: list[SourcePackage] = []
for path in input_paths:
if not path.is_absolute():
path = project_root / path
if not path.exists():
raise SystemExit(f"missing input: {path}")
packages.append(load_package(path))
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
ensure_database()
conn = mysql_connect(args.db_name)
try:
print(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
ensure_schema(conn)
total_lines = 0
total_points = 0
package_stats: list[dict] = []
for package in packages:
print(f"[{package.code}] 开始处理 {package.path.name}")
lines = list(iter_coastline_lines(package.path))
if not lines:
print(f"[{package.code}] 没有可用海岸线,跳过")
continue
coastline_geom = MultiLineString([list(line.coords) for line in lines])
coastline_buffer = coastline_geom.buffer(args.buffer_m, cap_style=2, join_style=2)
prepared_buffer = prep(coastline_buffer)
coast_bounds = coastline_geom.bounds
minx = align_floor(coast_bounds[0] - args.buffer_m - args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
miny = align_floor(coast_bounds[1] - args.buffer_m - args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
maxx = align_ceil(coast_bounds[2] + args.buffer_m + args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
maxy = align_ceil(coast_bounds[3] + args.buffer_m + args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
line_count = len(lines)
point_count = sum(len(line.coords) for line in lines)
total_lines += line_count
total_points += point_count
package_blocked = 0
package_candidate = 0
package_rows: list[tuple] = []
ix0 = int(round(minx / CELL_SIZE_M))
iy0 = int(round(miny / CELL_SIZE_M))
ix1 = int(round(maxx / CELL_SIZE_M))
iy1 = int(round(maxy / CELL_SIZE_M))
for ix in range(ix0, ix1):
cell_minx = ix * CELL_SIZE_M
cell_maxx = cell_minx + CELL_SIZE_M
for iy in range(iy0, iy1):
cell_miny = iy * CELL_SIZE_M
cell_maxy = cell_miny + CELL_SIZE_M
cell = box(cell_minx, cell_miny, cell_maxx, cell_maxy)
intersects = prepared_buffer.intersects(cell)
if intersects:
cell_id = f"{ix}:{iy}"
package_rows.append(
(
LAYER_NAME,
cell_id,
iy,
ix,
CELL_SIZE_M,
"HARD_BLOCKED",
"HARD_BLOCKED",
package.code,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
)
)
package_blocked += 1
else:
package_candidate += 1
if len(package_rows) >= 5000:
with conn.cursor() as cur:
insert_cells(cur, package_rows)
conn.commit()
print(
f"[{package.code}] 已写入 {len(package_rows)} 行,"
f"blocked={package_blocked} candidate={package_candidate}"
)
package_rows.clear()
if package_rows:
with conn.cursor() as cur:
insert_cells(cur, package_rows)
conn.commit()
package_stats.append(
{
"source_code": package.code,
"prefecture": package.prefecture,
"zip_path": str(package.path),
"line_count": line_count,
"point_count": point_count,
"bbox_mercator": [coast_bounds[0], coast_bounds[1], coast_bounds[2], coast_bounds[3]],
"grid_bounds_mercator": [minx, miny, maxx, maxy],
"blocked_count": package_blocked,
"candidate_count": package_candidate,
}
)
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO navsea_grid_package_stat(
source_code, prefecture, zip_path, line_count, point_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat,
grid_min_lon, grid_min_lat, grid_max_lon, grid_max_lat,
blocked_count, candidate_count
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
""",
(
package.code,
package.prefecture,
str(package.path),
line_count,
point_count,
coast_bounds[0],
coast_bounds[1],
coast_bounds[2],
coast_bounds[3],
minx,
miny,
maxx,
maxy,
package_blocked,
package_candidate,
),
)
conn.commit()
print(
f"[{package.code}] 完成 line={line_count} point={point_count} "
f"blocked={package_blocked} candidate={package_candidate}"
)
with conn.cursor() as cur:
cur.execute("ANALYZE TABLE navsea_grid_cell")
cur.execute("ANALYZE TABLE navsea_grid_package_stat")
cur.execute("ANALYZE TABLE navsea_grid_layer_meta")
conn.commit()
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s", (LAYER_NAME,))
final_total = int(cur.fetchone()[0] or 0)
cur.execute(
f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s AND state_name=%s",
(LAYER_NAME, "HARD_BLOCKED"),
)
blocked_count = int(cur.fetchone()[0] or 0)
cur.execute(
f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s AND state_name=%s",
(LAYER_NAME, "NAVIGABLE_CANDIDATE"),
)
candidate_count = int(cur.fetchone()[0] or 0)
cur.execute(
"""
SELECT MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
FROM navsea_grid_cell
WHERE layer_name=%s
""",
(LAYER_NAME,),
)
bbox_row = cur.fetchone()
grid_bbox = (
float(bbox_row[0]) if bbox_row and bbox_row[0] is not None else float("inf"),
float(bbox_row[1]) if bbox_row and bbox_row[1] is not None else float("inf"),
float(bbox_row[2]) if bbox_row and bbox_row[2] is not None else float("-inf"),
float(bbox_row[3]) if bbox_row and bbox_row[3] is not None else float("-inf"),
)
build_time = dt.datetime.now().isoformat(timespec="seconds")
grid_bbox_lonlat = mercator_bbox_to_lonlat(*grid_bbox)
manifest = {
"build_time": build_time,
"db_name": args.db_name,
"source_scope": "japan",
"source_code": "C23-06_*",
"source_count": len(packages),
"source_packages": [str(pkg.path) for pkg in packages],
"coastline_line_count": total_lines,
"coastline_point_count": total_points,
"grid_cell_count": final_total,
"blocked_count": blocked_count,
"candidate_count": candidate_count,
"grid_bounds_lonlat": list(grid_bbox_lonlat),
"package_stats": package_stats,
"notes": "全国海岸线 200m 硬阻塞栅格,直接写入 MySQL",
}
manifest_path = out_dir / "japan_coast_grid_mysql.manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
with conn.cursor() as cur:
upsert_meta(
cur,
layer_name=LAYER_NAME,
description="全国海岸线 200m 硬阻塞格",
source_desc="coastline/C23-06_*_GML.zip",
cell_size_m=CELL_SIZE_M,
feature_count=final_total,
bbox=grid_bbox_lonlat,
export_file=str(manifest_path.relative_to(project_root)),
)
conn.commit()
print("完成")
print(f" MySQL: {args.db_name}")
print(f" packages={len(packages)}")
print(f" coastline_lines={total_lines}")
print(f" grid_cells={final_total}")
print(f" blocked={blocked_count}")
print(f" candidate={candidate_count}")
print(f" manifest: {manifest_path}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,516 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
from typing import Iterable
import mapbox_vector_tile
import pymysql
from shapely.geometry import box, shape
from shapely.prepared import prep
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_TILE_ROOT = Path("/home/wwwroot/pbf-delivery-full-20260418-rebuild")
DEFAULT_TILE_Z = 12
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql/japan_national"
DEFAULT_SOURCE_DESC = "pbf-delivery-full-20260418-rebuild z12"
HAZARD_CELL_M = 50.0
HAZARD_LAYER_NAME = "hazard_50m"
HAZARD_DESCRIPTION = "全国 PBF 海上障碍 50m 黄格"
HAZARD_LAYERS = (
"navigation_hazard_area",
"fixed_fishing_gear_area",
"anchor_caution_hazard_area",
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
)
BREAKWATER_LAYER = "baseline_area"
POINT_LAYERS = {
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
}
HAZARD_CANONICAL_OBJECT_TYPES = {
"breakwater",
}
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def rect_geojson(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
},
}
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def stream_geojson(path: Path, features: Iterable[dict]) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
count = 0
with path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for feature in features:
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def mysql_connect(database: str | None = None):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"charset": "utf8mb4",
"autocommit": False,
"cursorclass": pymysql.cursors.Cursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
if database:
kwargs["database"] = database
return pymysql.connect(**kwargs)
def ensure_database(name: str) -> None:
conn = mysql_connect()
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{name}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
finally:
conn.close()
def ensure_schema(conn) -> None:
stmts = [
"""
CREATE TABLE IF NOT EXISTS navsea_grid_layer_meta (
layer_name VARCHAR(32) NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
source_desc TEXT NOT NULL,
cell_size_m DOUBLE NOT NULL,
feature_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
export_file VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS navsea_grid_cell (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
layer_name VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NOT NULL,
row_idx INT NOT NULL,
col_idx INT NOT NULL,
cell_size_m DOUBLE NOT NULL,
state_name VARCHAR(32) NOT NULL,
class_name VARCHAR(32) NOT NULL,
source_name VARCHAR(191) NOT NULL,
min_lon DOUBLE NOT NULL,
min_lat DOUBLE NOT NULL,
max_lon DOUBLE NOT NULL,
max_lat DOUBLE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_layer_cell (layer_name, cell_id),
KEY idx_layer_state (layer_name, state_name),
KEY idx_layer_rowcol (layer_name, row_idx, col_idx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
with conn.cursor() as cur:
for stmt in stmts:
cur.execute(stmt)
conn.commit()
def replace_layer_rows(conn, layer_name: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM navsea_grid_cell WHERE layer_name=%s", (layer_name,))
cur.execute("DELETE FROM navsea_grid_layer_meta WHERE layer_name=%s", (layer_name,))
conn.commit()
def insert_cells(cur, rows: list[tuple]) -> None:
cur.executemany(
"""
INSERT INTO navsea_grid_cell
(layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name,
min_lon, min_lat, max_lon, max_lat)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
rows,
)
def upsert_meta(
cur,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
feature_count: int,
bbox: tuple[float, float, float, float],
export_file: str,
) -> None:
cur.execute(
"""
INSERT INTO navsea_grid_layer_meta
(layer_name, description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
source_desc=VALUES(source_desc),
cell_size_m=VALUES(cell_size_m),
feature_count=VALUES(feature_count),
bbox_min_lon=VALUES(bbox_min_lon),
bbox_min_lat=VALUES(bbox_min_lat),
bbox_max_lon=VALUES(bbox_max_lon),
bbox_max_lat=VALUES(bbox_max_lat),
export_file=VALUES(export_file)
""",
(
layer_name,
description,
source_desc,
cell_size_m,
feature_count,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
export_file,
),
)
def tile_mercator_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
n = 2**z
lon_left = x / n * 360.0 - 180.0
lon_right = (x + 1) / n * 360.0 - 180.0
lat_top = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
lat_bottom = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return mercator_x(lon_left), mercator_y(lat_bottom), mercator_x(lon_right), mercator_y(lat_top)
def transform_geom_from_tile(geom, bounds_m: tuple[float, float, float, float], extent: int):
minx, miny, maxx, maxy = bounds_m
dx = maxx - minx
dy = maxy - miny
def coord(x: float, y: float):
mx = minx + (x / extent) * dx
my = miny + (y / extent) * dy
return mx, my
def walk(obj):
if isinstance(obj[0], (int, float)):
return coord(obj[0], obj[1])
return [walk(item) for item in obj]
return walk(geom)
def iter_source_tiles(tile_root: Path, zoom: int) -> list[Path]:
zoom_root = tile_root / str(zoom)
if not zoom_root.exists():
raise SystemExit(f"missing tile zoom root: {zoom_root}")
return sorted(zoom_root.glob("*/*.pbf"))
def iter_hazard_cells(tile_root: Path, zoom: int) -> Iterable[tuple[int, int]]:
hazard_cells: set[tuple[int, int]] = set()
tiles = iter_source_tiles(tile_root, zoom)
for index, p in enumerate(tiles, start=1):
try:
tx = int(p.parent.name)
ty = int(p.stem)
except ValueError:
continue
try:
tile = mapbox_vector_tile.decode(p.read_bytes())
except Exception as exc:
print(f"[hazard_50m] 跳过无法解析的 tile: {p} ({exc.__class__.__name__})")
continue
if index % 5000 == 0:
print(f"[hazard_50m] 已扫描 tile {index}/{len(tiles)}: {p.parent.parent.name}/{tx}/{ty}.pbf")
for layer_name in HAZARD_LAYERS + (BREAKWATER_LAYER,):
layer = tile.get(layer_name)
if not layer:
continue
extent = int(layer.get("extent") or 1048576)
minx, miny, maxx, maxy = tile_mercator_bounds(zoom, tx, ty)
for feature in layer.get("features", []):
props = feature.get("properties") or {}
if layer_name == BREAKWATER_LAYER and props.get("canonical_object_type") not in HAZARD_CANONICAL_OBJECT_TYPES:
continue
if (
props.get("canonical_object_type") == "fish_reef"
or props.get("chart_symbol_code") == "fish_reef"
or props.get("class_name") == "魚礁"
):
continue
geom = feature.get("geometry") or {}
coords = geom.get("coordinates")
if not coords:
continue
shp_coords = transform_geom_from_tile(coords, (minx, miny, maxx, maxy), extent)
shp = shape({"type": geom.get("type"), "coordinates": shp_coords})
if shp.is_empty:
continue
if layer_name in POINT_LAYERS:
shp = shp.buffer(25.0)
minx2, miny2, maxx2, maxy2 = shp.bounds
start_x = math.floor(minx2 / HAZARD_CELL_M) * HAZARD_CELL_M
start_y = math.floor(miny2 / HAZARD_CELL_M) * HAZARD_CELL_M
end_x = math.ceil(maxx2 / HAZARD_CELL_M) * HAZARD_CELL_M
end_y = math.ceil(maxy2 / HAZARD_CELL_M) * HAZARD_CELL_M
prep_geom = prep(shp)
cy = start_y
while cy < end_y:
cx = start_x
while cx < end_x:
cell = box(cx, cy, cx + HAZARD_CELL_M, cy + HAZARD_CELL_M)
if prep_geom.intersects(cell):
hazard_cells.add((int(round(cx / HAZARD_CELL_M)), int(round(cy / HAZARD_CELL_M))))
cx += HAZARD_CELL_M
cy += HAZARD_CELL_M
return sorted(hazard_cells, key=lambda item: (item[1], item[0]))
def cell_bbox_from_mercator(ix: int, iy: int, cell_m: float) -> tuple[float, float, float, float]:
minx = ix * cell_m
miny = iy * cell_m
maxx = minx + cell_m
maxy = miny + cell_m
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def import_hazard_layer(
conn,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
tile_root: Path,
zoom: int,
export_path: Path,
) -> tuple[int, tuple[float, float, float, float]]:
cells = iter_hazard_cells(tile_root, zoom)
batch: list[tuple] = []
count = 0
export_bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
with conn.cursor() as cur:
print(f"[{layer_name}] 开始扫描全国 PBF 危险层:{tile_root} / z{zoom}")
with export_path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for ix, iy in cells:
min_lon, min_lat, max_lon, max_lat = cell_bbox_from_mercator(ix, iy, cell_size_m)
export_bbox[0] = min(export_bbox[0], min_lon)
export_bbox[1] = min(export_bbox[1], min_lat)
export_bbox[2] = max(export_bbox[2], max_lon)
export_bbox[3] = max(export_bbox[3], max_lat)
cell_id = f"{ix}:{iy}"
batch.append(
(
layer_name,
cell_id,
iy,
ix,
cell_size_m,
"HAZARD_50M",
"HAZARD",
source_desc,
min_lon,
min_lat,
max_lon,
max_lat,
)
)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": iy,
"col": ix,
"cell_size_m": cell_size_m,
"state_name": "HAZARD_50M",
"class_name": "HAZARD",
"source_name": source_desc,
},
"geometry": rect_geojson(min_lon, min_lat, max_lon, max_lat)["geometry"],
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
if len(batch) >= 5000:
insert_cells(cur, batch)
conn.commit()
batch.clear()
if count % 20000 == 0:
print(f"[{layer_name}] 已处理 {count} 个格子 ...")
if batch:
insert_cells(cur, batch)
conn.commit()
fh.write("\n]}\n")
if count == 0:
raise RuntimeError(f"{layer_name} 扫描结果为空,请检查 tile_root={tile_root} zoom={zoom}")
print(f"[{layer_name}] 导入完成:{count} 条,导出 {export_path}")
return count, (export_bbox[0], export_bbox[1], export_bbox[2], export_bbox[3])
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="重算全国 50x50 海上障碍格并写入统一 MySQL 库。")
parser.add_argument("--db-name", default=DB_NAME, help="MySQL 数据库名")
parser.add_argument("--tile-root", type=Path, default=DEFAULT_TILE_ROOT, help="全国 PBF 根目录")
parser.add_argument("--zoom", type=int, default=DEFAULT_TILE_Z, help="PBF 瓦片 zoom默认 12")
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR, help="GeoJSON 输出目录")
parser.add_argument("--source-desc", default=DEFAULT_SOURCE_DESC, help="写入元数据的来源说明")
return parser.parse_args()
def main() -> None:
args = parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
ensure_database(args.db_name)
conn = mysql_connect(args.db_name)
try:
print(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
ensure_schema(conn)
replace_layer_rows(conn, HAZARD_LAYER_NAME)
hazard_export = out_dir / "hazard_50m_grid.geojson"
hazard_count, hazard_bbox = import_hazard_layer(
conn,
layer_name=HAZARD_LAYER_NAME,
description=HAZARD_DESCRIPTION,
source_desc=args.source_desc,
cell_size_m=HAZARD_CELL_M,
tile_root=args.tile_root,
zoom=args.zoom,
export_path=hazard_export,
)
with conn.cursor() as cur:
upsert_meta(
cur,
layer_name=HAZARD_LAYER_NAME,
description=HAZARD_DESCRIPTION,
source_desc=args.source_desc,
cell_size_m=HAZARD_CELL_M,
feature_count=hazard_count,
bbox=hazard_bbox,
export_file=str(hazard_export.relative_to(project_root)),
)
conn.commit()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layer": {
"name": HAZARD_LAYER_NAME,
"count": hazard_count,
"bbox_lonlat": list(hazard_bbox),
"cell_size_m": HAZARD_CELL_M,
"export_file": str(hazard_export.relative_to(project_root)),
"source_desc": args.source_desc,
"tile_root": str(args.tile_root),
"zoom": args.zoom,
},
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("已导入 MySQL 数据库:", args.db_name)
print(" - hazard_50m:", hazard_count, hazard_export)
print(" - manifest:", manifest_path)
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,485 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql/japan_national"
DENSITY_OVERVIEW_FILE = "density_overview_grid.geojson"
CELL_SIZES = {
"coast_200m": 200.0,
"fish_port_20m": 20.0,
"hazard_50m": 50.0,
}
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_bbox_to_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> tuple[float, float, float, float]:
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def normalize_bbox(layer_name: str, min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> tuple[float, float, float, float]:
if layer_name == "coast_200m" and (
abs(min_lon) > 180.0 or abs(max_lon) > 180.0 or abs(min_lat) > 90.0 or abs(max_lat) > 90.0
):
return mercator_bbox_to_lonlat(min_lon, min_lat, max_lon, max_lat)
return min_lon, min_lat, max_lon, max_lat
def fetch_stats(conn, where_sql: str, params: tuple) -> tuple[int, list[float] | None]:
sql = f"""
SELECT COUNT(*), MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
FROM navsea_grid_cell
WHERE {where_sql}
"""
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
count = int(row[0] or 0)
if count == 0:
return 0, None
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
params[0] if params else "",
float(row[1]),
float(row[2]),
float(row[3]),
float(row[4]),
)
return count, [min_lon, min_lat, max_lon, max_lat]
def export_layer(
conn,
*,
path: Path,
where_sql: str,
params: tuple,
) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
sql = f"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE {where_sql}
ORDER BY row_idx, col_idx
"""
count = 0
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
cur.execute(sql, params)
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
row[0],
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def _range_for_overlap(min_value: float, max_value: float, fine_size_m: float) -> tuple[int, int]:
start = int(math.floor(min_value / fine_size_m))
end = int(math.floor((max_value - 1e-9) / fine_size_m))
return start, end
def _overlaps_finer_cells(row_idx: int, col_idx: int, coarse_size_m: float, fine_size_m: float, fine_keys: set[tuple[int, int]]) -> bool:
min_x = col_idx * coarse_size_m
min_y = row_idx * coarse_size_m
max_x = min_x + coarse_size_m
max_y = min_y + coarse_size_m
row_start, row_end = _range_for_overlap(min_y, max_y, fine_size_m)
col_start, col_end = _range_for_overlap(min_x, max_x, fine_size_m)
for fine_row in range(row_start, row_end + 1):
for fine_col in range(col_start, col_end + 1):
if (fine_row, fine_col) in fine_keys:
return True
return False
def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
path.parent.mkdir(parents=True, exist_ok=True)
kept_counts = {
"20": 0,
"50": 0,
"200": 0,
}
kept_bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
keys_20: set[tuple[int, int]] = set()
keys_50: set[tuple[int, int]] = set()
def write_feature(fh, feature: dict, first: bool) -> bool:
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
return False
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
# 20m: always keep
cur.execute(
"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
""",
("fish_port_20m",),
)
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
keys_20.add((int(row_idx), int(col_idx)))
kept_counts["20"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
layer_name,
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
kept_bbox[0] = min(kept_bbox[0], min_lon)
kept_bbox[1] = min(kept_bbox[1], min_lat)
kept_bbox[2] = max(kept_bbox[2], max_lon)
kept_bbox[3] = max(kept_bbox[3], max_lat)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density_key": "20",
"density_level": 3,
"density_name": "高密度",
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
first = write_feature(fh, feature, first)
# 50m: keep only when no 20m cell overlaps this area
cur.execute(
"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
""",
("hazard_50m",),
)
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
row_idx_i = int(row_idx)
col_idx_i = int(col_idx)
if _overlaps_finer_cells(row_idx_i, col_idx_i, CELL_SIZES["hazard_50m"], CELL_SIZES["fish_port_20m"], keys_20):
continue
keys_50.add((row_idx_i, col_idx_i))
kept_counts["50"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
layer_name,
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
kept_bbox[0] = min(kept_bbox[0], min_lon)
kept_bbox[1] = min(kept_bbox[1], min_lat)
kept_bbox[2] = max(kept_bbox[2], max_lon)
kept_bbox[3] = max(kept_bbox[3], max_lat)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": row_idx_i,
"col": col_idx_i,
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density_key": "50",
"density_level": 2,
"density_name": "中密度",
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
first = write_feature(fh, feature, first)
# 200m: keep only when neither 20m nor 50m cell overlaps this area
cur.execute(
"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
""",
("coast_200m",),
)
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
row_idx_i = int(row_idx)
col_idx_i = int(col_idx)
if _overlaps_finer_cells(row_idx_i, col_idx_i, CELL_SIZES["coast_200m"], CELL_SIZES["fish_port_20m"], keys_20):
continue
if _overlaps_finer_cells(row_idx_i, col_idx_i, CELL_SIZES["coast_200m"], CELL_SIZES["hazard_50m"], keys_50):
continue
kept_counts["200"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
layer_name,
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
kept_bbox[0] = min(kept_bbox[0], min_lon)
kept_bbox[1] = min(kept_bbox[1], min_lat)
kept_bbox[2] = max(kept_bbox[2], max_lon)
kept_bbox[3] = max(kept_bbox[3], max_lat)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": row_idx_i,
"col": col_idx_i,
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density_key": "200",
"density_level": 1,
"density_name": "低密度",
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
first = write_feature(fh, feature, first)
fh.write("\n]}\n")
total = kept_counts["20"] + kept_counts["50"] + kept_counts["200"]
if total == 0:
raise RuntimeError("density overview export produced no features")
summary = {
"count": total,
"by_density": kept_counts,
"bbox": None if kept_bbox[0] == float("inf") else kept_bbox,
}
return total, summary
def main() -> None:
parser = argparse.ArgumentParser(description="Export NavSea MySQL grid layers to static GeoJSON assets")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
layers = {
"coast_200m": {
"file": "coast_200m_grid.geojson",
"where": "layer_name=%s",
"params": ("coast_200m",),
"cell_size_m": 200.0,
},
"fish_port_20m": {
"file": "fish_port_20m_grid.geojson",
"where": "layer_name=%s",
"params": ("fish_port_20m",),
"cell_size_m": 20.0,
},
"hazard_50m": {
"file": "hazard_50m_grid.geojson",
"where": "layer_name=%s",
"params": ("hazard_50m",),
"cell_size_m": 50.0,
},
}
manifest_layers: dict[str, dict] = {}
conn = mysql_connect(args.db_name)
try:
for layer_name, spec in layers.items():
out_path = out_dir / spec["file"]
count = export_layer(conn, path=out_path, where_sql=spec["where"], params=spec["params"])
stat_count, bbox = fetch_stats(conn, spec["where"], spec["params"])
if count != stat_count:
raise RuntimeError(f"{layer_name} export count mismatch: {count} != {stat_count}")
manifest_layers[layer_name] = {
"count": count,
"bbox_lonlat": bbox,
"cell_size_m": spec["cell_size_m"],
"export_file": str(out_path.relative_to(project_root)),
}
print(f"{layer_name}: {count} -> {out_path}")
density_path = out_dir / DENSITY_OVERVIEW_FILE
density_count, density_summary = export_density_overview(conn, path=density_path)
manifest_layers["density_overview"] = {
"count": density_count,
"bbox_lonlat": density_summary["bbox"],
"cell_size_m": None,
"export_file": str(density_path.relative_to(project_root)),
"by_density": density_summary["by_density"],
}
print(f"density_overview: {density_count} -> {density_path}")
finally:
conn.close()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layers": manifest_layers,
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"manifest: {manifest_path}")
if __name__ == "__main__":
main()

282
find_fpc_by_port_name.py Normal file
View File

@@ -0,0 +1,282 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import re
import zipfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass, asdict
from pathlib import Path
FISH_SRC_DEFAULT = "coastline/C09-06.zip"
@dataclass(frozen=True)
class MatchRow:
fpc: str
prc: str | None
primary_name: str | None
secondary_name: str | None
source_id: str | None
score: tuple[int, int, str]
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def normalize_text(value: str) -> str:
value = value.strip()
value = value.replace(" ", " ")
value = re.sub(r"\s+", "", value)
return value.lower()
def load_xml(zip_path: Path) -> ET.Element:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def get_text(feature: ET.Element, key: str) -> str | None:
el = feature.find(f"{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{key}")
if el is None or not (el.text or "").strip():
return None
return el.text.strip()
def feature_name_tokens(feature: ET.Element) -> list[str]:
tokens: list[str] = []
for key in ("NA2", "NA4", "FCF", "AAC", "CFP", "FPA"):
value = get_text(feature, key)
if value:
tokens.append(value)
return tokens
def feature_display_name(feature: ET.Element) -> tuple[str | None, str | None]:
na2 = get_text(feature, "NA2")
na4 = get_text(feature, "NA4")
fcf = get_text(feature, "FCF")
primary = na2 or na4 or fcf
secondary = None
if primary == na2:
secondary = fcf or na4
elif primary == na4:
secondary = na2 or fcf
else:
secondary = na2 or na4
return primary, secondary
def catalog_display_name(row: dict[str, str]) -> str | None:
for key in ("name", "name_ja", "port_name", "港名", "漁港名", "名称"):
value = row.get(key)
if value and value.strip():
return value.strip()
return None
def catalog_fpc(row: dict[str, str]) -> str | None:
for key in ("fpc", "FPC"):
value = row.get(key)
if value and value.strip():
return value.strip()
return None
def catalog_prc(row: dict[str, str]) -> str | None:
for key in ("prc", "PRC"):
value = row.get(key)
if value and value.strip():
return value.strip()
return None
def score_match(query: str, tokens: list[str], primary: str | None) -> tuple[int, int, str] | None:
q = normalize_text(query)
token_norms = [normalize_text(token) for token in tokens if token]
primary_norm = normalize_text(primary) if primary else ""
if primary_norm == q:
return (0, 0, primary_norm)
if q in token_norms:
return (1, 0, primary_norm)
if primary_norm and q in primary_norm:
return (2, len(primary_norm), primary_norm)
for token in token_norms:
if q in token:
return (3, len(token), primary_norm or token)
return None
def load_catalog_matches(catalog_path: Path, query: str) -> list[MatchRow]:
suffix = catalog_path.suffix.lower()
rows: list[dict[str, str]] = []
if suffix in (".csv", ".tsv"):
delimiter = "\t" if suffix == ".tsv" else ","
with catalog_path.open("r", encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh, delimiter=delimiter)
rows.extend(dict(row) for row in reader)
elif suffix in (".json", ".jsonl"):
text = catalog_path.read_text(encoding="utf-8")
if suffix == ".jsonl":
rows.extend(json.loads(line) for line in text.splitlines() if line.strip())
else:
payload = json.loads(text)
if isinstance(payload, list):
rows.extend(dict(row) for row in payload)
elif isinstance(payload, dict) and isinstance(payload.get("items"), list):
rows.extend(dict(row) for row in payload["items"])
else:
raise SystemExit("catalog json must be a list or {items:[...]}")
else:
raise SystemExit("catalog must be .csv, .tsv, .json or .jsonl")
matches: list[MatchRow] = []
for row in rows:
name = catalog_display_name(row)
fpc = catalog_fpc(row)
if not name or not fpc:
continue
score = score_match(query, [name], name)
if score is None:
continue
matches.append(
MatchRow(
fpc=fpc,
prc=catalog_prc(row),
primary_name=name,
secondary_name=None,
source_id=row.get("source_id") or row.get("id") or row.get("sid"),
score=score,
)
)
matches.sort(key=lambda row: (row.score, row.fpc))
return matches
def collect_matches(zip_path: Path, query: str) -> list[MatchRow]:
root = load_xml(zip_path)
obj = root.find(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}OBJ")
if obj is None:
raise SystemExit("OBJ block missing in input package")
matches: list[MatchRow] = []
for feature in obj.findall(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}CB03"):
fpc = get_text(feature, "FPC")
prc = get_text(feature, "PRC")
if not fpc:
continue
tokens = feature_name_tokens(feature)
primary, secondary = feature_display_name(feature)
score = score_match(query, tokens, primary)
if score is None:
continue
source_id = None
loc = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}LOC")
if loc is not None:
source_id = loc.attrib.get("idref")
matches.append(
MatchRow(
fpc=fpc,
prc=prc,
primary_name=primary,
secondary_name=secondary,
source_id=source_id,
score=score,
)
)
matches.sort(key=lambda row: (row.score, row.fpc))
return matches
def main() -> None:
parser = argparse.ArgumentParser(description="按渔港名字查找 FPC")
parser.add_argument("name", help="渔港名字,支持包含匹配")
parser.add_argument(
"--source",
default=FISH_SRC_DEFAULT,
help="输入渔港原始包,默认 coastline/C09-06.zip",
)
parser.add_argument(
"--catalog",
default=None,
help="可选的港名对照表CSV/TSV/JSON/JSONL如果原始包里找不到名字建议用它",
)
parser.add_argument(
"--exact",
action="store_true",
help="只保留精确匹配",
)
parser.add_argument(
"--json",
action="store_true",
help="以 JSON 输出",
)
parser.add_argument(
"--max-results",
type=int,
default=20,
help="最多输出多少条候选结果",
)
args = parser.parse_args()
source_path = Path(args.source)
if not source_path.is_absolute():
source_path = Path(__file__).resolve().parent / source_path
if not source_path.exists():
raise SystemExit(f"missing input: {source_path}")
matches: list[MatchRow] = []
if args.catalog:
catalog_path = Path(args.catalog)
if not catalog_path.is_absolute():
catalog_path = Path(__file__).resolve().parent / catalog_path
if not catalog_path.exists():
raise SystemExit(f"missing catalog: {catalog_path}")
matches = load_catalog_matches(catalog_path, args.name)
if not matches:
matches = collect_matches(source_path, args.name)
if args.exact:
q = normalize_text(args.name)
matches = [row for row in matches if normalize_text(row.primary_name or "") == q]
matches = matches[: args.max_results]
if not matches:
if args.catalog:
raise SystemExit(
f"no FPC matched for name={args.name!r} in catalog={args.catalog!r}"
)
raise SystemExit(
f"no FPC matched for name={args.name!r}; "
"this source package may not contain human-readable port names, "
"try --catalog with a port-name mapping file"
)
if args.json:
print(
json.dumps(
[asdict(row) | {"score": list(row.score)} for row in matches],
ensure_ascii=False,
indent=2,
)
)
return
for idx, row in enumerate(matches, start=1):
name_bits = [bit for bit in [row.primary_name, row.secondary_name] if bit]
print(
f"{idx}. FPC={row.fpc} PRC={row.prc or '-'} "
f"name={' / '.join(name_bits) if name_bits else '-'} "
f"source_id={row.source_id or '-'}"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""审计九州语义图标测试版是否丢失对象、几何或非图标属性。"""
from __future__ import annotations
import argparse
import json
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from typing import Any
import mapbox_vector_tile
EXPECTED_FIELDS = {"chart_icon_image", "icon_id", "arc_id"}
def stable(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def normalize_expected_properties(layer_name: str, properties: dict[str, Any]) -> None:
if (
layer_name == "anchor_caution_hazard_area"
and str(properties.get("class_code")) == "420"
and properties.get("chart_fill_pattern") == "fill-daytime-405"
):
properties["chart_fill_pattern"] = "fill-daytime-420"
if (
layer_name == "navigation_hazard_area"
and str(properties.get("class_code")) == "427"
and properties.get("chart_fill_pattern") == "fill-daytime-405"
):
properties["chart_fill_pattern"] = "fill-daytime-427"
def feature_signature(layer_name: str, feature: dict[str, Any]) -> str:
properties = dict(feature.get("properties") or {})
for field in EXPECTED_FIELDS:
properties.pop(field, None)
normalize_expected_properties(layer_name, properties)
return stable({
"id": feature.get("id"),
"geometry": feature.get("geometry"),
"properties": properties,
})
def audit_tile(args: tuple[str, str]) -> dict[str, Any]:
source_path, target_path = args
source = mapbox_vector_tile.decode(Path(source_path).read_bytes())
target = mapbox_vector_tile.decode(Path(target_path).read_bytes())
errors: list[dict[str, Any]] = []
source_layers = set(source)
target_layers = set(target)
if source_layers != target_layers:
errors.append({"kind": "layer_set", "source": sorted(source_layers), "target": sorted(target_layers)})
source_counts = Counter()
target_counts = Counter()
icon_counts = Counter()
old_prefix_counts = Counter()
for layer_name in sorted(source_layers | target_layers):
source_features = source.get(layer_name, {}).get("features", [])
target_features = target.get(layer_name, {}).get("features", [])
source_counts[layer_name] = len(source_features)
target_counts[layer_name] = len(target_features)
source_signatures = Counter(feature_signature(layer_name, feature) for feature in source_features)
target_signatures = Counter(feature_signature(layer_name, feature) for feature in target_features)
if source_signatures != target_signatures:
missing = source_signatures - target_signatures
extra = target_signatures - source_signatures
errors.append({
"kind": "feature_or_geometry_or_property",
"layer": layer_name,
"source_count": len(source_features),
"target_count": len(target_features),
"missing_count": sum(missing.values()),
"extra_count": sum(extra.values()),
})
for feature in target_features:
properties = feature.get("properties") or {}
for field in ("icon_id", "arc_id"):
value = properties.get(field)
if isinstance(value, str):
icon_counts[f"{field}:{value}"] += 1
for value in properties.values():
if isinstance(value, str) and (value.startswith("symbol-daytime-") or value.startswith("arc-daytime-")):
old_prefix_counts[value] += 1
return {
"tile": source_path,
"errors": errors,
"source_counts": dict(source_counts),
"target_counts": dict(target_counts),
"icon_counts": dict(icon_counts),
"old_prefix_counts": dict(old_prefix_counts),
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source-root", type=Path, required=True)
parser.add_argument("--target-root", type=Path, required=True)
parser.add_argument("--report-dir", type=Path, required=True)
args = parser.parse_args()
pairs = []
for target in sorted(args.target_root.glob("*/*/*.pbf")):
source = args.source_root / target.relative_to(args.target_root)
if source.exists():
pairs.append((str(source), str(target)))
results: list[dict[str, Any]] = []
with ProcessPoolExecutor() as executor:
for result in executor.map(audit_tile, pairs, chunksize=16):
results.append(result)
error_rows = [error | {"tile": result["tile"]} for result in results for error in result["errors"]]
icon_counts = Counter()
old_prefix_counts = Counter()
source_features = Counter()
target_features = Counter()
for result in results:
icon_counts.update(result["icon_counts"])
old_prefix_counts.update(result["old_prefix_counts"])
source_features.update(result["source_counts"])
target_features.update(result["target_counts"])
payload = {
"source_root": str(args.source_root),
"target_root": str(args.target_root),
"tiles_target": len(list(args.target_root.glob("*/*/*.pbf"))),
"tiles_compared": len(results),
"tiles_with_errors": len({row["tile"] for row in error_rows}),
"errors": error_rows[:200],
"source_features": dict(source_features),
"target_features": dict(target_features),
"icon_counts": dict(icon_counts),
"old_prefix_counts": dict(old_prefix_counts),
"verdict": "PASS" if not error_rows and not old_prefix_counts else "FAIL",
}
args.report_dir.mkdir(parents=True, exist_ok=True)
(args.report_dir / "object_geometry_icon_audit.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
lines = [
"# 九州语义图标测试版对象 / 几何 / 图标审计",
"",
f"- 来源:`{args.source_root}`",
f"- 目标:`{args.target_root}`",
f"- 目标瓦片:`{payload['tiles_target']}`,实际比较:`{payload['tiles_compared']}`",
f"- 有错误瓦片:`{payload['tiles_with_errors']}`",
f"- 旧图标前缀残留:`{sum(old_prefix_counts.values())}`",
f"- 结论:`{payload['verdict']}`",
"",
"本审计忽略预期的 `chart_icon_image`、`icon_id`、`arc_id` 字段变化逐图层比较对象数量、ID、几何和其余属性。",
]
(args.report_dir / "object_geometry_icon_audit.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,525 @@
#!/usr/bin/env python3
"""从当前全国 Full 基线生成九州语义图标替换测试包。"""
from __future__ import annotations
import json
import os
import shutil
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from typing import Any
import mercantile
from navsea_tile_reencode_guard import (
assert_reencoded_tile_safe,
decode_tile_with_extents,
encode_layers_preserving_extents,
)
REPO_ROOT = Path(__file__).resolve().parent
CONFIG_PATH = REPO_ROOT / "tasks/pbf/navsea_semantic_icon_map_v1.json"
SOURCE_ROOT = Path("/home/wwwroot/pbf-delivery-full-20260418-rebuild")
TARGET_ROOT = Path("/home/wwwroot/pbf-delivery-kyushu-semantic-icon-v1-20260806")
TARGET_TILE_URL = "http://192.168.200.184/pbf-delivery-kyushu-semantic-icon-v1-20260806/{z}/{x}/{y}.pbf"
KYUSHU_BBOX = (128.0, 30.0, 132.5, 34.9)
ZOOMS = tuple(range(5, 13))
ASSET_VERSION = "kyushu-semantic-icon-v1-20260806-r2"
SPRITE_SOURCE = Path("/mnt/sda1/www/newpec/sprite-semantic")
SPRITE_TARGET = Path(f"/mnt/sda1/www/newpec/sprite-{ASSET_VERSION}")
SPRITE_URL = f"http://192.168.200.184/newpec/sprite-{ASSET_VERSION}/sprite"
STYLE_SOURCE = REPO_ROOT / "src/pbf/style.navsea-delivery-full-semantic-extentfix.json"
STYLE_OUTPUT = REPO_ROOT / "src/pbf/style.navsea-delivery-kyushu-semantic-icon-v1.json"
STYLE_DEPLOY = Path("/mnt/sda1/www/newpec/domain/style.navsea-delivery-kyushu-semantic-icon-v1.json")
NEWPEC_STYLE_SOURCE = REPO_ROOT / "src/pbf/style.json"
NEWPEC_STYLE_OUTPUT = REPO_ROOT / "src/pbf/style.navsea-newpec-kyushu-icon-test-v1.json"
NEWPEC_STYLE_DEPLOY = Path("/mnt/sda1/www/newpec/domain/style.navsea-newpec-kyushu-icon-test-v1.json")
REPORT_DIR = REPO_ROOT / "report/kyushu_semantic_icon_v1_2026-08-06"
SIMPLE_ICON_LAYERS = {
"hazard-points",
"anchorage-symbols",
"anchor-hazard-points",
"anchor-hazard-points-428",
"facility-points",
"landmark-points",
}
STYLE_EXTRA_MAP = {
"light_flare_green": "fl_G",
"light_flare_red": "fl_R",
"light_flare_yellow_white": "fl_YW",
}
CANONICAL_ICON_MAP = {
"coastal_lighthouse_over_15m": "lt_cst",
"harbor_lighthouse": "lt_hbr",
"breakwater_lighthouse": "lt_bkw",
"minor_light": "lt_min",
"light_beacon": "lt_bcn",
"light_buoy": "bu_lit",
"lattice_buoy": "bu_lat",
"pillar_buoy": "bu_pil",
"can_buoy": "bu_can",
"buoy_generic": "bu_gen",
"nav_mark_vais": "mk_vais",
}
def load_config() -> dict[str, Any]:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
def collect_tiles() -> list[tuple[int, int, int, Path]]:
tiles: list[tuple[int, int, int, Path]] = []
west, south, east, north = KYUSHU_BBOX
for z in ZOOMS:
for tile in mercantile.tiles(west, south, east, north, [z]):
rel = Path(str(tile.z)) / str(tile.x) / f"{tile.y}.pbf"
source = SOURCE_ROOT / rel
if source.exists():
tiles.append((tile.z, tile.x, tile.y, rel))
return sorted(tiles)
def copy_link(source: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.unlink()
try:
target.hardlink_to(source)
except OSError:
shutil.copy2(source, target)
def class_icon(layer_name: str, class_code: object, config: dict[str, Any]) -> str | None:
code = str(class_code)
if layer_name in {"navigation_hazard_point", "anchor_caution_hazard_point"}:
return config["hazard_class_map"].get(code)
if layer_name == "onshore_structure_point":
return config["landmark_class_map"].get(code)
if layer_name == "anchorage_point":
return config["anchorage_class_map"].get(code)
if layer_name == "facility_boundary_point":
return config["facility_class_map"].get(code)
return None
def navigation_icon(properties: dict[str, Any], config: dict[str, Any]) -> str | None:
legacy = properties.get("chart_icon_image")
if isinstance(legacy, str):
mapped = config["sprite_key_map"].get(legacy)
if mapped:
return mapped
display_code = properties.get("display_code")
if display_code is not None:
mapped = config["display_code_map"].get(str(display_code))
if mapped:
return mapped
canonical = properties.get("canonical_object_type")
if isinstance(canonical, str):
return CANONICAL_ICON_MAP.get(canonical)
return None
def arc_icon(properties: dict[str, Any], config: dict[str, Any]) -> str | None:
if properties.get("chart_symbol_code") != "lighthouse":
return None
if not properties.get("light_color_code"):
return None
if (
properties.get("canonical_object_type") == "coastal_lighthouse_over_15m"
and properties.get("light_sector_mode") == "sector"
):
return config["arc_rules"]["sector"]
color = str(properties.get("light_color_code"))
return config["arc_rules"].get(color, config["arc_rules"]["other"])
def normalize_pattern_properties(layer_name: str, properties: dict[str, Any]) -> None:
if (
layer_name == "anchor_caution_hazard_area"
and str(properties.get("class_code")) == "420"
and properties.get("chart_fill_pattern") == "fill-daytime-405"
):
properties["chart_fill_pattern"] = "fill-daytime-420"
if (
layer_name == "navigation_hazard_area"
and str(properties.get("class_code")) == "427"
and properties.get("chart_fill_pattern") == "fill-daytime-405"
):
properties["chart_fill_pattern"] = "fill-daytime-427"
def rewrite_tile(
source: Path,
target: Path,
config: dict[str, Any],
*,
verify_geometry: bool = False,
) -> dict[str, Any]:
decoded, extents = decode_tile_with_extents(source.read_bytes())
changed_features = 0
icon_counts: Counter[str] = Counter()
arc_counts: Counter[str] = Counter()
old_values: Counter[str] = Counter()
unknown_old_values: Counter[str] = Counter()
output_layers: list[dict[str, Any]] = []
for layer_name, layer in decoded.items():
output_features = []
for feature in layer["features"]:
properties = dict(feature.get("properties") or {})
before = dict(properties)
normalize_pattern_properties(layer_name, properties)
legacy = properties.get("chart_icon_image")
if isinstance(legacy, str) and (
legacy.startswith("symbol-daytime-") or legacy.startswith("arc-daytime-")
):
old_values[legacy] += 1
icon_id = class_icon(layer_name, properties.get("class_code"), config)
if layer_name == "navigation_marks":
icon_id = icon_id or navigation_icon(properties, config)
if icon_id is None and isinstance(legacy, str):
icon_id = config["sprite_key_map"].get(legacy)
if icon_id:
properties["icon_id"] = icon_id
icon_counts[icon_id] += 1
properties.pop("chart_icon_image", None)
if layer_name == "navigation_marks":
arc_id = arc_icon(properties, config)
if arc_id:
properties["arc_id"] = arc_id
arc_counts[arc_id] += 1
for value in properties.values():
if isinstance(value, str) and (
value.startswith("symbol-daytime-") or value.startswith("arc-daytime-")
):
unknown_old_values[value] += 1
if properties != before:
changed_features += 1
output_feature = {
"geometry": feature["geometry"],
"properties": properties,
}
if feature.get("id") is not None:
output_feature["id"] = feature["id"]
output_features.append(output_feature)
output_layers.append({"name": layer_name, "features": output_features})
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(encode_layers_preserving_extents(output_layers, extents))
if verify_geometry:
assert_reencoded_tile_safe(source, target)
return {
"changed_features": changed_features,
"icon_counts": dict(icon_counts),
"arc_counts": dict(arc_counts),
"old_values": dict(old_values),
"unknown_old_values": dict(unknown_old_values),
}
def rewrite_tile_job(args: tuple[str, str, dict[str, Any], bool]) -> dict[str, Any]:
source, target, config, verify_geometry = args
result = rewrite_tile(
Path(source), Path(target), config, verify_geometry=verify_geometry
)
result["source"] = source
return result
def transform_style(config: dict[str, Any]) -> dict[str, Any]:
style = json.loads(STYLE_SOURCE.read_text(encoding="utf-8"))
key_map = dict(config["sprite_key_map"])
key_map.update(STYLE_EXTRA_MAP)
def transform(value: Any) -> Any:
if isinstance(value, dict):
return {key: transform(item) for key, item in value.items()}
if isinstance(value, list):
return [transform(item) for item in value]
if isinstance(value, str):
if value == "chart_icon_image":
return "icon_id"
return key_map.get(value, value)
return value
style = transform(style)
style["name"] = "NavSea Kyushu Semantic Icon Test v1"
style["sprite"] = SPRITE_URL
style["metadata"] = {
"navsea_test_version": "kyushu-semantic-icon-v1-20260806",
"source_baseline": str(SOURCE_ROOT),
"icon_map": str(CONFIG_PATH),
}
style["sources"]["navsea_delivery"]["tiles"] = [TARGET_TILE_URL]
canonical_restore = {
"lt_hbr": "harbor_lighthouse",
"lt_bkw": "breakwater_lighthouse",
"lt_bcn": "light_beacon",
}
def restore_canonical_values(value: Any) -> Any:
if isinstance(value, list):
if len(value) >= 3 and value[:2] == ["get", "canonical_object_type"]:
return value
return [restore_canonical_values(item) for item in value]
if isinstance(value, dict):
return {key: restore_canonical_values(item) for key, item in value.items()}
if isinstance(value, str):
return canonical_restore.get(value, value)
return value
for layer in style["layers"]:
layer_id = layer.get("id")
layout = layer.get("layout") or {}
layer_filter = layer.get("filter")
if layer_id in SIMPLE_ICON_LAYERS:
layout["icon-image"] = ["coalesce", ["get", "icon_id"], ""]
layer["layout"] = layout
elif layer_id == "nav-light-arc":
layout["icon-image"] = ["coalesce", ["get", "arc_id"], ""]
layer["layout"] = layout
elif layer_id in {
"nav-marks-harbor-lighthouses",
"nav-marks-breakwater-lighthouses",
"nav-marks-light-beacons",
"nav-marks",
}:
if layer_filter is not None:
layer["filter"] = restore_canonical_values(layer_filter)
if layer_id == "nav-marks":
layer["layout"] = restore_canonical_values(layout)
return style
def write_style(style: dict[str, Any], path: Path, deploy_path: Path) -> None:
payload = json.dumps(style, ensure_ascii=False, indent=2) + "\n"
path.write_text(payload, encoding="utf-8")
deploy_path.parent.mkdir(parents=True, exist_ok=True)
deploy_path.write_text(payload, encoding="utf-8")
def write_newpec_style() -> None:
style = json.loads(NEWPEC_STYLE_SOURCE.read_text(encoding="utf-8"))
style["name"] = "NavSea Newpec Kyushu Icon Test v1"
style["metadata"] = {
"navsea_test_version": "kyushu-semantic-icon-v1-20260806",
"role": "left_original_newpec_reference",
}
write_style(style, NEWPEC_STYLE_OUTPUT, NEWPEC_STYLE_DEPLOY)
def collect_literal_sprite_refs(style: dict[str, Any], sprite_keys: set[str] | None = None) -> set[str]:
refs: set[str] = set()
def walk(value: Any) -> None:
if isinstance(value, dict):
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
elif isinstance(value, str):
if sprite_keys is not None and value in sprite_keys:
refs.add(value)
elif (
value.startswith("pattern_fill_")
or value.startswith("fill-daytime-")
or value.startswith("fill_")
or value.startswith("special_pattern_")
or value in {"tide_spot"}
):
refs.add(value)
for layer in style.get("layers", []):
layout = layer.get("layout") or {}
paint = layer.get("paint") or {}
for field in ("icon-image",):
if field in layout:
walk(layout[field])
for field in ("fill-pattern", "line-pattern"):
if field in paint:
walk(paint[field])
return refs
def build_sprite(config: dict[str, Any], style: dict[str, Any]) -> dict[str, Any]:
if SPRITE_TARGET.exists():
shutil.rmtree(SPRITE_TARGET)
SPRITE_TARGET.mkdir(parents=True)
source_meta: dict[str, dict[str, dict[str, Any]]] = {}
for filename in ("sprite.json", "sprite@2x.json"):
source_meta[filename] = json.loads((SPRITE_SOURCE / filename).read_text(encoding="utf-8"))
source_sprite_keys = set().union(*(meta.keys() for meta in source_meta.values()))
key_map = dict(config["sprite_key_map"])
key_map.update(STYLE_EXTRA_MAP)
fallback_sources = {"lm_cus": "symbol-daytime-660"}
passthrough_keys = collect_literal_sprite_refs(style, source_sprite_keys)
added: dict[str, str] = {}
for filename, meta in source_meta.items():
# 测试包只改名图标/灯弧fill/line pattern 等非图标纹理必须按 style 原名保留。
output: dict[str, dict[str, Any]] = {}
data_driven_pattern_keys = {
key
for key in meta
if (
key.startswith("pattern_fill_")
or key.startswith("fill-daytime-")
or key.startswith("fill_")
or key.startswith("special_pattern_")
)
}
for key in sorted(passthrough_keys | data_driven_pattern_keys):
if key in meta:
output[key] = dict(meta[key])
added[key] = key
for old_key, new_key in key_map.items():
source_key = old_key if old_key in meta else None
if source_key is None:
for candidate, candidate_new in key_map.items():
if candidate_new == new_key and candidate in meta:
source_key = candidate
break
if source_key is None:
source_key = fallback_sources.get(new_key)
if source_key and source_key in meta:
output[new_key] = dict(meta[source_key])
added[new_key] = source_key
(SPRITE_TARGET / filename).write_text(
json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
for filename in ("sprite.png", "sprite@2x.png"):
shutil.copy2(SPRITE_SOURCE / filename, SPRITE_TARGET / filename)
return {
"added": added,
"passthrough_keys": sorted(passthrough_keys),
"fallbacks": {"lm_cus": fallback_sources["lm_cus"]},
}
def main() -> None:
config = load_config()
tiles = collect_tiles()
if TARGET_ROOT.exists():
raise RuntimeError(f"refusing to overwrite existing PBF target: {TARGET_ROOT}")
TARGET_ROOT.mkdir(parents=True)
totals: Counter[str] = Counter()
old_values: Counter[str] = Counter()
unknown_old_values: Counter[str] = Counter()
icon_counts: Counter[str] = Counter()
arc_counts: Counter[str] = Counter()
rewritten = 0
candidate_needles = [
key.encode("utf-8") for key in config["sprite_key_map"]
] + [
b"navigation_marks",
b"navigation_hazard_point",
b"anchor_caution_hazard_point",
b"anchorage_point",
b"facility_boundary_point",
b"onshore_structure_point",
]
rewrite_jobs: list[tuple[str, str, dict[str, Any], bool]] = []
candidate_count = 0
for z, x, y, rel in tiles:
source = SOURCE_ROOT / rel
target = TARGET_ROOT / rel
raw = source.read_bytes()
if not any(needle in raw for needle in candidate_needles):
copy_link(source, target)
continue
rewrite_jobs.append(
(str(source), str(target), config, candidate_count < 3)
)
candidate_count += 1
worker_count = min(os.cpu_count() or 4, 12)
with ProcessPoolExecutor(max_workers=worker_count) as executor:
for result in executor.map(rewrite_tile_job, rewrite_jobs, chunksize=8):
if result["changed_features"]:
rewritten += 1
totals["features_changed"] += result["changed_features"]
old_values.update(result["old_values"])
unknown_old_values.update(result["unknown_old_values"])
icon_counts.update(result["icon_counts"])
arc_counts.update(result["arc_counts"])
style = transform_style(config)
style["metadata"]["navsea_test_version"] = ASSET_VERSION
style["sprite"] = SPRITE_URL
sprite = build_sprite(config, style)
write_style(style, STYLE_OUTPUT, STYLE_DEPLOY)
write_newpec_style()
REPORT_DIR.mkdir(parents=True, exist_ok=True)
summary = {
"version": "kyushu-semantic-icon-v1-20260806",
"source_root": str(SOURCE_ROOT),
"target_root": str(TARGET_ROOT),
"bbox": KYUSHU_BBOX,
"zooms": list(ZOOMS),
"tiles": len(tiles),
"tiles_rewritten": rewritten,
"candidate_tiles": candidate_count,
**totals,
"old_values": dict(old_values),
"unknown_old_values_after_rewrite": dict(unknown_old_values),
"icon_counts": dict(icon_counts),
"arc_counts": dict(arc_counts),
"sprite": sprite,
"style": str(STYLE_OUTPUT),
"newpec_style": str(NEWPEC_STYLE_OUTPUT),
}
(REPORT_DIR / "kyushu_semantic_icon_v1.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
lines = [
"# 九州语义图标替换测试版 v1",
"",
f"- PBF 来源:`{SOURCE_ROOT}`",
f"- PBF 输出:`{TARGET_ROOT}`",
f"- 范围:`{KYUSHU_BBOX}`",
f"- zoom`{ZOOMS[0]}-{ZOOMS[-1]}`",
f"- 瓦片数:`{len(tiles)}`",
f"- 重写瓦片:`{rewritten}`",
f"- 改动 feature`{totals['features_changed']}`",
f"- sprite`{SPRITE_TARGET}`",
f"- delivery style`{STYLE_OUTPUT}`",
f"- newpec style`{NEWPEC_STYLE_OUTPUT}`",
"",
"## 旧图标值",
"",
"```json",
json.dumps(dict(old_values), ensure_ascii=False, indent=2),
"```",
"",
"## 审计关注",
"",
"- `unknown_old_values_after_rewrite` 必须为空。",
"- `lm_cus` 当前使用 `symbol-daytime-660` 的临时视觉回退,需人工确认。",
"- 几何使用原 tile extent 重编码,并执行 extent 安全检查。",
]
(REPORT_DIR / "kyushu_semantic_icon_v1.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,95 @@
# 审计报告索引
最后更新:`2026-04-18`
## 最近 3 天最重要的结论
### 1. 根因报告
- [`report/full_extent_shift_root_cause_2026-04-18.md`](/root/sourceserver/pbf/report/full_extent_shift_root_cause_2026-04-18.md)
- 结论:
- 全国 full / semantic 的大块空白、岛礁错位,根因不是图标样式本身
- 真正问题是高 `extent = 1048576` tile 被按 `4096` 口径重编码,导致几何整体错移
### 2. extent / native 修复
- [`report/full_geometry_native_extent_fix_2026-04-18.md`](/root/sourceserver/pbf/report/full_geometry_native_extent_fix_2026-04-18.md)
- 结论:
- 全国 `z12` 中存在一批 `extent = 4096` 错退片
- 已按 source tile 口径回写到 `1048576`
- 解决的是明确的几何 / metadata 风险,不等于视觉完全一致
### 3. 热点重建
- [`report/full_hotspot_rebuild_2026-04-18/full_hotspot_rebuild.md`](/root/sourceserver/pbf/report/full_hotspot_rebuild_2026-04-18/full_hotspot_rebuild.md)
- 结论:
- 固定 AOI 热点共重建 `154` 张 tile
- full 重建后,再以 full 为底稿刷新 semantic
- 这一步先把历史坏 tile 从热点审计面上剥离出去
### 4. 热点视觉验证
- [`report/karatsu_hotspot_verify_2026-04-18/full_aoi_visual_audit.md`](/root/sourceserver/pbf/report/karatsu_hotspot_verify_2026-04-18/full_aoi_visual_audit.md)
- 结论:
- 唐津热点的极端空白问题已经明显收敛
- 但 full / semantic 相对原始 newpec 仍存在较大整体视觉差异
### 5. 全国 AOI 截图对比
- [`report/full_aoi_visual_audit_2026-04-18_r1/full_aoi_visual_audit.md`](/root/sourceserver/pbf/report/full_aoi_visual_audit_2026-04-18_r1/full_aoi_visual_audit.md)
- [`report/full_aoi_visual_audit_2026-04-18_r2_subset/full_aoi_visual_audit.md`](/root/sourceserver/pbf/report/full_aoi_visual_audit_2026-04-18_r2_subset/full_aoi_visual_audit.md)
- 结论:
- 固定 AOI 为:
- 博多港中心 10 海里
- 东京湾 10 海里
- 大阪 10 海里
- 冲绳 10 海里
- 唐津 5 海里
- 重点 zoom
- `10`
- `12`
- 当前高 diff 主要集中在 z12
### 6. 过程型补充报告
- [`report/full_rebuild_staging_okinawa_z12_2026-04-18/full_aoi_visual_audit.md`](/root/sourceserver/pbf/report/full_rebuild_staging_okinawa_z12_2026-04-18/full_aoi_visual_audit.md)
- [`report/full_rebuild_staging_okinawa_z12_r2_2026-04-18/full_aoi_visual_audit.md`](/root/sourceserver/pbf/report/full_rebuild_staging_okinawa_z12_r2_2026-04-18/full_aoi_visual_audit.md)
- [`report/full_rebuild_staging_hakata_karatsu_z12_2026-04-18/full_aoi_visual_audit.md`](/root/sourceserver/pbf/report/full_rebuild_staging_hakata_karatsu_z12_2026-04-18/full_aoi_visual_audit.md)
- 结论:
- staging 版先证明了新 rebuild 的 z12 几何不再炸成大片空白
- 剩余差异更偏向整体视觉表达,而不是旧坏片继续扩散
## 审计方法总表
### 1. 物体保全审计
- 脚本:
- [`navsea_object_preservation_audit.py`](/root/sourceserver/pbf/navsea_object_preservation_audit.py)
- 关键词:
- `--fid-key 'thisMyWorld@2026'`
- `--match-on-fid-only`
### 2. render 审计
- 脚本:
- [`navsea_render_audit.py`](/root/sourceserver/pbf/navsea_render_audit.py)
### 3. AOI 截图对比审计
- 脚本:
- [`navsea_full_aoi_visual_audit.py`](/root/sourceserver/pbf/navsea_full_aoi_visual_audit.py)
- 页面:
- [`src/pbf/navsea-compare-full-audit.html`](/root/sourceserver/pbf/src/pbf/navsea-compare-full-audit.html)
### 4. extent / native 风险审计
- 脚本:
- [`navsea_geometry_native_audit.py`](/root/sourceserver/pbf/navsea_geometry_native_audit.py)
- [`navsea_extent_shift_audit.py`](/root/sourceserver/pbf/navsea_extent_shift_audit.py)
- [`navsea_tile_reencode_guard.py`](/root/sourceserver/pbf/navsea_tile_reencode_guard.py)
### 5. 热点重建审计
- 脚本:
- [`navsea_rebuild_hotspot_tiles.py`](/root/sourceserver/pbf/navsea_rebuild_hotspot_tiles.py)

View File

@@ -0,0 +1,378 @@
{
"label": "kyushu-semantic-icon-v1",
"target_root": "/home/wwwroot/pbf-delivery-kyushu-semantic-icon-v1-20260806",
"source_root": "/home/wwwroot/newpec/exported_auto/tile.mapple-on.jp__newpec-mvt-20260106__z___x___y_.pbf/tiles",
"tile_count": 4707,
"outside_extent_tiles": 4707,
"source_extent_mismatch_tiles": 0,
"fixable_from_source_tiles": 0,
"repaired_tiles": 0,
"zoom_summary": {
"5": {
"tile_count": 2,
"outside_extent_tiles": 2,
"source_extent_mismatch_tiles": 0,
"extent_counts": {
"4096": 2
}
},
"6": {
"tile_count": 4,
"outside_extent_tiles": 4,
"source_extent_mismatch_tiles": 0,
"extent_counts": {
"4096": 4
}
},
"7": {
"tile_count": 9,
"outside_extent_tiles": 9,
"source_extent_mismatch_tiles": 0,
"extent_counts": {
"4096": 9
}
},
"8": {
"tile_count": 20,
"outside_extent_tiles": 20,
"source_extent_mismatch_tiles": 0,
"extent_counts": {
"4096": 20
}
},
"9": {
"tile_count": 70,
"outside_extent_tiles": 70,
"source_extent_mismatch_tiles": 0,
"extent_counts": {
"4096": 70
}
},
"10": {
"tile_count": 234,
"outside_extent_tiles": 234,
"source_extent_mismatch_tiles": 0,
"extent_counts": {
"4096": 234
}
},
"11": {
"tile_count": 884,
"outside_extent_tiles": 884,
"source_extent_mismatch_tiles": 0,
"extent_counts": {
"4096": 884
}
},
"12": {
"tile_count": 3484,
"outside_extent_tiles": 3484,
"source_extent_mismatch_tiles": 0,
"extent_counts": {
"1048576": 3484
}
}
},
"sample_tiles": [
{
"tile": "10/876/405.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/406.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/407.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/408.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/409.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/410.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/411.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/412.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/413.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/414.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/415.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/416.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/417.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/418.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/419.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/420.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/421.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/876/422.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/405.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/406.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/407.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/408.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/409.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/410.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/411.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/412.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/413.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/414.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/415.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
},
{
"tile": "10/877/416.pbf",
"extent_values": [
4096
],
"source_extent": 4096,
"outside_extent": true,
"max_overflow": 80,
"fixable_from_source": false
}
]
}

View File

@@ -0,0 +1,314 @@
# kyushu-semantic-icon-v1 几何 / Extent / Native 风险审计
## 范围
- target root: `/home/wwwroot/pbf-delivery-kyushu-semantic-icon-v1-20260806`
- source root: `/home/wwwroot/newpec/exported_auto/tile.mapple-on.jp__newpec-mvt-20260106__z___x___y_.pbf/tiles`
- tiles: `4707`
- repaired tiles: `0`
## 总体结论
- 坐标超当前 extent 的瓦片数:`4707`
- 与源瓦片 extent 不一致的瓦片数:`0`
- 可直接按源 extent 修复的瓦片数:`0`
## Zoom 汇总
### z5
- tiles: `2`
- outside extent: `2`
- source mismatch: `0`
- extent counts: `{4096: 2}`
### z6
- tiles: `4`
- outside extent: `4`
- source mismatch: `0`
- extent counts: `{4096: 4}`
### z7
- tiles: `9`
- outside extent: `9`
- source mismatch: `0`
- extent counts: `{4096: 9}`
### z8
- tiles: `20`
- outside extent: `20`
- source mismatch: `0`
- extent counts: `{4096: 20}`
### z9
- tiles: `70`
- outside extent: `70`
- source mismatch: `0`
- extent counts: `{4096: 70}`
### z10
- tiles: `234`
- outside extent: `234`
- source mismatch: `0`
- extent counts: `{4096: 234}`
### z11
- tiles: `884`
- outside extent: `884`
- source mismatch: `0`
- extent counts: `{4096: 884}`
### z12
- tiles: `3484`
- outside extent: `3484`
- source mismatch: `0`
- extent counts: `{1048576: 3484}`
## 代表问题样本
### `10/876/405.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/406.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/407.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/408.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/409.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/410.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/411.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/412.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/413.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/414.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/415.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/416.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/417.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/418.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/419.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/420.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/421.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/876/422.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/405.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/406.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/407.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/408.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/409.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/410.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/411.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/412.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/413.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/414.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/415.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`
### `10/877/416.pbf`
- extent values: `[4096]`
- source extent: `4096`
- outside extent: `True`
- max overflow: `80`
- fixable from source: `False`

View File

@@ -0,0 +1,177 @@
{
"version": "kyushu-semantic-icon-v1-20260806",
"source_root": "/home/wwwroot/pbf-delivery-full-20260418-rebuild",
"target_root": "/home/wwwroot/pbf-delivery-kyushu-semantic-icon-v1-20260806",
"bbox": [
128.0,
30.0,
132.5,
34.9
],
"zooms": [
5,
6,
7,
8,
9,
10,
11,
12
],
"tiles": 4707,
"tiles_rewritten": 1340,
"candidate_tiles": 1343,
"features_changed": 40131,
"old_values": {
"symbol-daytime-301": 2536,
"symbol-daytime-308": 33,
"symbol-daytime-428": 13416,
"symbol-daytime-30500003": 165,
"symbol-daytime-303": 4993,
"symbol-daytime-327": 626,
"symbol-daytime-310": 1833,
"symbol-daytime-30500001": 25,
"symbol-daytime-320": 959,
"symbol-daytime-321": 290,
"symbol-daytime-429": 256,
"symbol-daytime-413": 187,
"symbol-daytime-325": 38,
"symbol-daytime-323": 77,
"symbol-daytime-30700003": 24,
"symbol-daytime-30500002": 16,
"symbol-daytime-335359": 12,
"symbol-daytime-405": 5329,
"symbol-daytime-412": 50,
"symbol-daytime-719": 54,
"symbol-daytime-410": 10
},
"unknown_old_values_after_rewrite": {},
"icon_counts": {
"lt_hbr": 2536,
"mk_lead_v": 33,
"hz_foul": 3598,
"hz_reef": 6659,
"lt_lead_c": 165,
"lm_mtn": 2059,
"hz_overfall": 912,
"lt_min": 4993,
"bu_lat": 626,
"lt_bcn": 1833,
"lm_chim": 1907,
"lm_twr": 2527,
"hz_seaweed": 470,
"lt_lead_a": 25,
"bu_lit": 959,
"lm_mar": 121,
"lm_fish": 846,
"lm_cus": 85,
"lm_mon": 42,
"bu_gen": 290,
"hz_wreck_survey": 432,
"hz_sandwave": 662,
"lm_ctrl": 28,
"bu_can": 38,
"hz_outfall": 174,
"hz_danger_clear": 26,
"bu_pil": 77,
"lm_prom": 8,
"hz_whirl": 27,
"mk_lead_c": 24,
"hz_reef_danger": 25,
"lt_lead_b": 16,
"mk_vais": 12,
"fac_fishport": 1164,
"hz_rock_awash": 1397,
"hz_rock_sub": 3932,
"hz_danger_iso": 212,
"fac_port": 261,
"hz_tower": 57,
"hz_wreck_sub": 50,
"fac_seastation": 48,
"hz_dolphin": 205,
"fac_fisherina": 11,
"fac_marina": 95,
"an_quar": 33,
"hz_wreck_hull": 10,
"hz_pile": 340,
"hz_obst": 60,
"an_restrict": 2,
"an_no_anchor": 4,
"an_desig": 15
},
"arc_counts": {
"arc_open_YW": 204,
"arc_YW": 977,
"arc_G": 610,
"arc_R": 769
},
"sprite": {
"added": {
"lt_cst": "coastal_lighthouse",
"lt_hbr": "harbor_lighthouse",
"lt_bkw": "breakwater_lighthouse",
"lt_min": "light_minor",
"lt_lead_a": "leading_light_variantt_30500001",
"lt_lead_b": "leading_light_variantt_30500002",
"lt_lead_c": "leading_light_variantt_30500003",
"lt_up_G": "symbol-daytime-30700001",
"lt_up_R": "symbol-daytime-30700002",
"mk_lead_c": "leading_mark_variantt_30700003",
"mk_lead_v": "leading_mark_variantt_308",
"lt_bcn": "light_beacon",
"bu_lit": "buoy_light",
"bu_gen": "buoy_generic",
"bu_pil": "buoy_pillar",
"bu_can": "buoy_can",
"bu_lat": "buoy_lattice",
"mk_vais": "nav_mark_vais",
"hz_rock_awash": "rock_exposed_drying_awash_group",
"hz_rock_sub": "sunken_rock",
"hz_danger_clear": "cleared_danger_object",
"hz_danger_iso": "isolated_danger",
"hz_wreck_hull": "wreck_hull_exposed",
"hz_wreck_sub": "wreck_fully_submerged_dangerous",
"hz_wreck_survey": "wreck_surveyed",
"hz_foul": "foul_ground",
"hz_sandwave": "sand_wave",
"hz_overfall": "tide_rips_overfalls",
"hz_whirl": "eddy_whirlpool",
"hz_seaweed": "seaweed",
"hz_obst": "obstruction",
"hz_reef": "fish_reef",
"hz_reef_danger": "fish_reef_dangerous",
"hz_tower": "tower_yagura_observation_platform",
"hz_pile": "bollard_pile_stake",
"hz_dolphin": "dolphin_structure",
"hz_outfall": "subsea_installation_outfall_intake",
"lm_chim": "symbol-daytime-640",
"lm_twr": "symbol-daytime-650",
"lm_mar": "symbol-daytime-660",
"lm_fish": "symbol-daytime-661",
"lm_cus": "symbol-daytime-660",
"lm_prom": "symbol-daytime-680",
"lm_mtn": "symbol-daytime-681",
"lm_ctrl": "symbol-daytime-682",
"lm_mon": "symbol-daytime-698",
"an_quar": "anchorage_quarantine",
"an_desig": "anchorage_designated",
"an_restrict": "restriction_area_group",
"an_no_anchor": "anchoring_prohibited",
"fac_port": "port_general_small_harbor_group",
"fac_fishport": "fishing_port",
"fac_marina": "marina",
"fac_fisherina": "fisherina",
"fac_seastation": "sea_station_umi_no_eki",
"fac_pilot": "pilot_station",
"tide_spot": "tidespot-daytime",
"fl_G": "light_flare_green",
"fl_R": "light_flare_red",
"fl_YW": "light_flare_yellow_white"
},
"fallbacks": {
"lm_cus": "symbol-daytime-660"
}
},
"style": "/root/sourceserver/pbf/src/pbf/style.navsea-delivery-kyushu-semantic-icon-v1.json",
"newpec_style": "/root/sourceserver/pbf/src/pbf/style.navsea-newpec-kyushu-icon-test-v1.json"
}

View File

@@ -0,0 +1,46 @@
# 九州语义图标替换测试版 v1
- PBF 来源:`/home/wwwroot/pbf-delivery-full-20260418-rebuild`
- PBF 输出:`/home/wwwroot/pbf-delivery-kyushu-semantic-icon-v1-20260806`
- 范围:`(128.0, 30.0, 132.5, 34.9)`
- zoom`5-12`
- 瓦片数:`4707`
- 重写瓦片:`1340`
- 改动 feature`40131`
- sprite`/mnt/sda1/www/newpec/sprite-kyushu-semantic-icon-v1-20260806`
- delivery style`/root/sourceserver/pbf/src/pbf/style.navsea-delivery-kyushu-semantic-icon-v1.json`
- newpec style`/root/sourceserver/pbf/src/pbf/style.navsea-newpec-kyushu-icon-test-v1.json`
## 旧图标值
```json
{
"symbol-daytime-301": 2536,
"symbol-daytime-308": 33,
"symbol-daytime-428": 13416,
"symbol-daytime-30500003": 165,
"symbol-daytime-303": 4993,
"symbol-daytime-327": 626,
"symbol-daytime-310": 1833,
"symbol-daytime-30500001": 25,
"symbol-daytime-320": 959,
"symbol-daytime-321": 290,
"symbol-daytime-429": 256,
"symbol-daytime-413": 187,
"symbol-daytime-325": 38,
"symbol-daytime-323": 77,
"symbol-daytime-30700003": 24,
"symbol-daytime-30500002": 16,
"symbol-daytime-335359": 12,
"symbol-daytime-405": 5329,
"symbol-daytime-412": 50,
"symbol-daytime-719": 54,
"symbol-daytime-410": 10
}
```
## 审计关注
- `unknown_old_values_after_rewrite` 必须为空。
- `lm_cus` 当前使用 `symbol-daytime-660` 的临时视觉回退,需人工确认。
- 几何使用原 tile extent 重编码,并执行 extent 安全检查。

View File

@@ -0,0 +1,173 @@
{
"source_root": "/home/wwwroot/pbf-delivery-full-20260418-rebuild",
"target_root": "/home/wwwroot/pbf-delivery-kyushu-semantic-icon-v1-20260806",
"tiles_target": 4707,
"tiles_compared": 4707,
"tiles_with_errors": 0,
"errors": [],
"source_features": {
"baseline_area": 225324,
"baseline_outline": 401949,
"hole_area": 110994,
"land_area": 65455,
"bathymetry_line": 413730,
"depth_contour": 171277,
"navigation_marks": 11627,
"bridge_structure": 7224,
"hazard_boundary_outline": 20151,
"place_label_land": 10983,
"submerged_reef_area": 770,
"place_label_sea": 12092,
"anchor_caution_hazard_point": 12932,
"baseline_line": 14640,
"fixed_fishing_gear_area": 9044,
"onshore_structure_line": 114122,
"onshore_structure_point": 7623,
"anchor_caution_hazard_area": 1809,
"anchor_caution_hazard_outline": 1815,
"clearance_limit_line": 325,
"hazard_boundary_line": 37,
"onshore_structure_area": 3640,
"depth_contour_overview": 2154,
"depth_zone_739": 181,
"route_boundary_point": 78,
"route_area": 51,
"route_outline": 161,
"depth_zone_740": 9,
"route_axis_line": 14,
"depth_zone_700": 8,
"depth_zone_725": 4,
"seabed_line": 2106,
"seabed_text_point": 33376,
"facility_boundary_point": 1579,
"navigation_hazard_point": 6316,
"depth_zone_741": 497,
"depth_zone_748": 45,
"depth_zone_749": 43,
"clearance_limit_point": 261,
"clip_outline_754": 223,
"facility_boundary_area": 449,
"navigation_hazard_outline": 48,
"anchorage_area": 55,
"anchorage_outline": 55,
"anchorage_point": 54,
"facility_boundary_outline": 233,
"pilot_station_point": 16,
"navigation_hazard_area": 24,
"leading_line_outline": 16,
"clip_outline_730": 4,
"facility_boundary_area_transparent": 4
},
"target_features": {
"baseline_area": 225324,
"baseline_outline": 401949,
"hole_area": 110994,
"land_area": 65455,
"bathymetry_line": 413730,
"depth_contour": 171277,
"navigation_marks": 11627,
"bridge_structure": 7224,
"hazard_boundary_outline": 20151,
"place_label_land": 10983,
"submerged_reef_area": 770,
"place_label_sea": 12092,
"anchor_caution_hazard_point": 12932,
"baseline_line": 14640,
"fixed_fishing_gear_area": 9044,
"onshore_structure_line": 114122,
"onshore_structure_point": 7623,
"anchor_caution_hazard_area": 1809,
"anchor_caution_hazard_outline": 1815,
"clearance_limit_line": 325,
"hazard_boundary_line": 37,
"onshore_structure_area": 3640,
"depth_contour_overview": 2154,
"depth_zone_739": 181,
"route_boundary_point": 78,
"route_area": 51,
"route_outline": 161,
"depth_zone_740": 9,
"route_axis_line": 14,
"depth_zone_700": 8,
"depth_zone_725": 4,
"seabed_line": 2106,
"seabed_text_point": 33376,
"facility_boundary_point": 1579,
"navigation_hazard_point": 6316,
"depth_zone_741": 497,
"depth_zone_748": 45,
"depth_zone_749": 43,
"clearance_limit_point": 261,
"clip_outline_754": 223,
"facility_boundary_area": 449,
"navigation_hazard_outline": 48,
"anchorage_area": 55,
"anchorage_outline": 55,
"anchorage_point": 54,
"facility_boundary_outline": 233,
"pilot_station_point": 16,
"navigation_hazard_area": 24,
"leading_line_outline": 16,
"clip_outline_730": 4,
"facility_boundary_area_transparent": 4
},
"icon_counts": {
"icon_id:lt_hbr": 2536,
"arc_id:arc_open_YW": 204,
"arc_id:arc_YW": 977,
"arc_id:arc_G": 610,
"arc_id:arc_R": 769,
"icon_id:mk_lead_v": 33,
"icon_id:hz_foul": 3598,
"icon_id:hz_reef": 6659,
"icon_id:lt_lead_c": 165,
"icon_id:lm_mtn": 2059,
"icon_id:hz_overfall": 912,
"icon_id:lt_min": 4993,
"icon_id:bu_lat": 626,
"icon_id:lt_bcn": 1833,
"icon_id:lm_chim": 1907,
"icon_id:lm_twr": 2527,
"icon_id:hz_seaweed": 470,
"icon_id:lt_lead_a": 25,
"icon_id:bu_lit": 959,
"icon_id:lm_mar": 121,
"icon_id:lm_fish": 846,
"icon_id:lm_cus": 85,
"icon_id:lm_mon": 42,
"icon_id:bu_gen": 290,
"icon_id:hz_wreck_survey": 432,
"icon_id:hz_sandwave": 662,
"icon_id:lm_ctrl": 28,
"icon_id:bu_can": 38,
"icon_id:hz_outfall": 174,
"icon_id:hz_danger_clear": 26,
"icon_id:bu_pil": 77,
"icon_id:lm_prom": 8,
"icon_id:hz_whirl": 27,
"icon_id:mk_lead_c": 24,
"icon_id:hz_reef_danger": 25,
"icon_id:lt_lead_b": 16,
"icon_id:mk_vais": 12,
"icon_id:fac_fishport": 1164,
"icon_id:hz_rock_awash": 1397,
"icon_id:hz_rock_sub": 3932,
"icon_id:hz_danger_iso": 212,
"icon_id:fac_port": 261,
"icon_id:hz_tower": 57,
"icon_id:hz_wreck_sub": 50,
"icon_id:fac_seastation": 48,
"icon_id:hz_dolphin": 205,
"icon_id:fac_fisherina": 11,
"icon_id:fac_marina": 95,
"icon_id:an_quar": 33,
"icon_id:hz_wreck_hull": 10,
"icon_id:hz_pile": 340,
"icon_id:hz_obst": 60,
"icon_id:an_restrict": 2,
"icon_id:an_no_anchor": 4,
"icon_id:an_desig": 15
},
"old_prefix_counts": {},
"verdict": "PASS"
}

View File

@@ -0,0 +1,15 @@
# 九州语义图标测试版对象 / 几何 / 图标审计
- 来源:`/home/wwwroot/pbf-delivery-full-20260418-rebuild`
- 目标:`/home/wwwroot/pbf-delivery-kyushu-semantic-icon-v1-20260806`
- 目标瓦片:`4707`,实际比较:`4707`
- 有错误瓦片:`0`
- 旧图标前缀残留:`0`
- 结论:`PASS`
本审计忽略预期的 `chart_icon_image``icon_id``arc_id` 字段变化,并把两个不存在的纹理名规范化后再比较:
- `anchor_caution_hazard_area class_code=420``fill-daytime-405` -> `fill-daytime-420`
- `navigation_hazard_area class_code=427``fill-daytime-405` -> `fill-daytime-427`
其余内容逐图层比较对象数量、ID、几何和属性。

View File

@@ -0,0 +1,102 @@
{
"asset_version": "kyushu-semantic-icon-v1-20260806-r2",
"scanned_tiles": 4707,
"used_keys": [
"an_desig",
"an_no_anchor",
"an_quar",
"an_restrict",
"arc_G",
"arc_R",
"arc_YW",
"arc_open_YW",
"breakwater_lighthouse",
"bu_can",
"bu_gen",
"bu_lat",
"bu_lit",
"bu_pil",
"buoy_east_cardinal",
"buoy_isolated_danger",
"buoy_north_cardinal",
"buoy_port_lateral_variantt_312350",
"buoy_safe_water",
"buoy_south_cardinal",
"buoy_special_mark",
"buoy_starboard_lateral_variantt_312351",
"buoy_west_cardinal",
"fac_fisherina",
"fac_fishport",
"fac_marina",
"fac_pilot",
"fac_port",
"fac_seastation",
"fill-daytime-420",
"fill-daytime-427",
"fill-daytime-428",
"fill-daytime-754",
"fl_G",
"fl_R",
"fl_YW",
"harbor_lighthouse",
"hz_danger_clear",
"hz_danger_iso",
"hz_dolphin",
"hz_foul",
"hz_obst",
"hz_outfall",
"hz_overfall",
"hz_pile",
"hz_reef",
"hz_reef_danger",
"hz_rock_awash",
"hz_rock_sub",
"hz_sandwave",
"hz_seaweed",
"hz_tower",
"hz_whirl",
"hz_wreck_hull",
"hz_wreck_sub",
"hz_wreck_survey",
"light_beacon_east_cardinal_variantt",
"light_beacon_isolated_danger_variantt_311359",
"light_beacon_north_cardinal_variantt",
"light_beacon_north_cardinal_variantt_311356",
"light_beacon_port_lateral_variantt_311350",
"light_beacon_safe_water_variantt_311360",
"light_beacon_south_cardinal_variantt",
"light_beacon_starboard_lateral_variantt_311351",
"light_beacon_west_cardinal_variantt_311358",
"light_up_green",
"light_up_red",
"lm_chim",
"lm_ctrl",
"lm_cus",
"lm_fish",
"lm_mar",
"lm_mon",
"lm_mtn",
"lm_prom",
"lm_twr",
"lt_bcn",
"lt_bkw",
"lt_cst",
"lt_hbr",
"lt_lead_a",
"lt_lead_b",
"lt_lead_c",
"lt_min",
"mk_lead_c",
"mk_lead_v",
"mk_vais",
"mooring_buoy",
"pattern_fill_fishery_or_fish_reef_area",
"special_pattern_line_754",
"tide_spot"
],
"used_key_count": 91,
"sprite_key_count": 121,
"missing_keys": [],
"legacy_sprite_keys": [],
"verdict": "PASS"
}

View File

@@ -0,0 +1,11 @@
# 九州测试版 Sprite 图标与纹理闭合审计
- 资产版本:`kyushu-semantic-icon-v1-20260806-r2`
- 扫描瓦片:`4707`
- 使用中的 icon_id/arc_id/纹理 key`91`
- sprite 键数:`121`
- 缺失键:`0`
- 旧前缀键:`0`
- 结论:`PASS`
本审计同时检查 `icon_id``arc_id``chart_fill_pattern``chart_fill_style` 映射纹理,以及 style 中直接写死的 `fill-pattern` / `line-pattern` / `icon-image` 依赖。

View File

@@ -0,0 +1,141 @@
{
"compare_url": "http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html",
"cases": [
{
"id": "karatsu_kyushu_icon_v1_semantic_z12",
"label": "唐津九州语义图标测试",
"variant": "semantic",
"zoom": 12,
"center": [
129.9697,
33.4425
],
"radius_nm": 5,
"compare_url": "http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html?variant=semantic&audit=1&center=129.9697%2C33.4425&zoom=12",
"changed_pixels": 287577,
"total_pixels": 364800,
"changed_ratio": 0.788314,
"mean_abs_rgb": [
39.3424,
42.4767,
74.167
],
"rms_rgb": [
61.2913,
62.6326,
97.1048
],
"diff_bbox": [
0,
0,
640,
513
],
"left_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z12/compare_left.png",
"right_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z12/compare_right.png",
"diff_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z12/compare_diff.png"
},
{
"id": "karatsu_kyushu_icon_v1_semantic_z10",
"label": "唐津九州语义图标测试",
"variant": "semantic",
"zoom": 10,
"center": [
129.9697,
33.4425
],
"radius_nm": 5,
"compare_url": "http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html?variant=semantic&audit=1&center=129.9697%2C33.4425&zoom=10",
"changed_pixels": 274296,
"total_pixels": 364800,
"changed_ratio": 0.751908,
"mean_abs_rgb": [
33.6012,
36.7804,
64.1346
],
"rms_rgb": [
56.4791,
56.9124,
89.4452
],
"diff_bbox": [
0,
0,
640,
513
],
"left_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z10/compare_left.png",
"right_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z10/compare_right.png",
"diff_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z10/compare_diff.png"
},
{
"id": "hakata_kyushu_icon_v1_semantic_z12",
"label": "博多港九州语义图标测试",
"variant": "semantic",
"zoom": 12,
"center": [
130.335,
33.6385
],
"radius_nm": 10,
"compare_url": "http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html?variant=semantic&audit=1&center=130.335%2C33.6385&zoom=12",
"changed_pixels": 237978,
"total_pixels": 364800,
"changed_ratio": 0.652352,
"mean_abs_rgb": [
30.9085,
30.6867,
39.4968
],
"rms_rgb": [
55.1912,
58.1514,
67.7886
],
"diff_bbox": [
0,
0,
640,
513
],
"left_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z12/compare_left.png",
"right_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z12/compare_right.png",
"diff_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z12/compare_diff.png"
},
{
"id": "hakata_kyushu_icon_v1_semantic_z10",
"label": "博多港九州语义图标测试",
"variant": "semantic",
"zoom": 10,
"center": [
130.335,
33.6385
],
"radius_nm": 10,
"compare_url": "http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html?variant=semantic&audit=1&center=130.335%2C33.6385&zoom=10",
"changed_pixels": 236727,
"total_pixels": 364800,
"changed_ratio": 0.648923,
"mean_abs_rgb": [
31.9821,
32.9781,
55.3087
],
"rms_rgb": [
57.9478,
56.188,
83.9112
],
"diff_bbox": [
0,
0,
640,
513
],
"left_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z10/compare_left.png",
"right_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z10/compare_right.png",
"diff_image": "report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z10/compare_diff.png"
}
]
}

View File

@@ -0,0 +1,48 @@
# 全国 AOI 截图对比审计
## 总览
- compare page: `http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html`
- cases: `4`
## 差异排序
### 唐津九州语义图标测试 · semantic · z12
- changed ratio: `0.788314`
- changed pixels: `287577` / `364800`
- mean abs rgb: `[39.3424, 42.4767, 74.167]`
- compare url: `http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html?variant=semantic&audit=1&center=129.9697%2C33.4425&zoom=12`
- left image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z12/compare_left.png`
- right image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z12/compare_right.png`
- diff image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z12/compare_diff.png`
### 唐津九州语义图标测试 · semantic · z10
- changed ratio: `0.751908`
- changed pixels: `274296` / `364800`
- mean abs rgb: `[33.6012, 36.7804, 64.1346]`
- compare url: `http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html?variant=semantic&audit=1&center=129.9697%2C33.4425&zoom=10`
- left image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z10/compare_left.png`
- right image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z10/compare_right.png`
- diff image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/karatsu_kyushu_icon_v1_semantic_z10/compare_diff.png`
### 博多港九州语义图标测试 · semantic · z12
- changed ratio: `0.652352`
- changed pixels: `237978` / `364800`
- mean abs rgb: `[30.9085, 30.6867, 39.4968]`
- compare url: `http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html?variant=semantic&audit=1&center=130.335%2C33.6385&zoom=12`
- left image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z12/compare_left.png`
- right image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z12/compare_right.png`
- diff image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z12/compare_diff.png`
### 博多港九州语义图标测试 · semantic · z10
- changed ratio: `0.648923`
- changed pixels: `236727` / `364800`
- mean abs rgb: `[31.9821, 32.9781, 55.3087]`
- compare url: `http://192.168.200.184/newpec/navsea-compare-kyushu-semantic-icon-v1.html?variant=semantic&audit=1&center=130.335%2C33.6385&zoom=10`
- left image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z10/compare_left.png`
- right image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z10/compare_right.png`
- diff image: `report/kyushu_semantic_icon_v1_2026-08-06/visual/hakata_kyushu_icon_v1_semantic_z10/compare_diff.png`

View File

@@ -0,0 +1,459 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NavSea 全国海岸 / 渔港 / 障碍红格预览 v2.1</title>
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" />
<style>
:root {
--panel-bg: rgba(14, 18, 22, 0.88);
--panel-border: rgba(255, 255, 255, 0.12);
--text: #f3efe7;
--muted: #c7c0b4;
--accent: #ff808a;
--accent-2: #ff4d57;
--base: #071018;
}
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--base);
color: var(--text);
font-family: "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
#map {
position: absolute;
inset: 0;
}
.panel {
position: absolute;
top: 16px;
left: 16px;
z-index: 2;
width: min(460px, calc(100vw - 32px));
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
padding: 14px 16px 12px;
}
.title {
font-size: 18px;
font-weight: 700;
margin: 0 0 6px;
letter-spacing: 0.02em;
}
.meta {
font-size: 12px;
line-height: 1.6;
color: var(--muted);
margin-bottom: 10px;
}
.button-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-top: 10px;
}
button {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.16);
background: linear-gradient(180deg, rgba(255, 128, 138, 0.26), rgba(255, 77, 87, 0.1));
color: var(--text);
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
}
button.active {
border-color: rgba(255, 200, 204, 0.88);
background: linear-gradient(180deg, rgba(255, 128, 138, 0.42), rgba(255, 77, 87, 0.2));
box-shadow: 0 0 0 1px rgba(255, 128, 138, 0.24) inset;
}
button:hover {
border-color: rgba(244, 154, 160, 0.55);
}
.legend {
display: grid;
grid-template-columns: 18px 1fr;
gap: 8px 10px;
align-items: center;
font-size: 12px;
margin-top: 10px;
color: var(--muted);
}
.swatch-red {
width: 16px;
height: 12px;
border-radius: 3px;
background: rgba(255, 77, 87, 0.38);
border: 1px solid rgba(255, 77, 87, 0.95);
}
.swatch-line {
width: 16px;
height: 2px;
background: #1e1e1e;
justify-self: center;
}
.status {
margin-top: 8px;
font-size: 12px;
color: #ffd8db;
min-height: 1.4em;
}
.hint {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 2;
padding: 10px 12px;
font-size: 12px;
color: #1e120f;
background: rgba(255, 238, 236, 0.84);
border-radius: 999px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl {
margin: 0 16px 16px 0;
}
</style>
</head>
<body>
<div id="map"></div>
<div class="panel">
<div class="title">NavSea 全国海岸 / 渔港 / 障碍红格预览</div>
<div class="meta" id="meta">
版本v2.1<br />
数据:全国统一库导出<br />
底盘navsea_japan_coast_grid<br />
默认视图:全国 200x200<br />
底图GSI 标准地图mapple
</div>
<div class="button-row">
<button id="reloadBtn">重新加载</button>
<button id="btn200" class="active">200x200</button>
<button id="btn20">20x20</button>
<button id="btn50">50x50</button>
<button id="btnDensity">整体密度</button>
</div>
<div class="legend">
<div class="swatch-red"></div><div>当前图层采用红色填充和深色边线显示</div>
</div>
<div class="status" id="status">等待加载中...</div>
</div>
<div class="hint" id="hint">当前加载200x200 全国海岸格</div>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script>
const VERSION = "v2.1";
const LAYERS = {
"200": {
key: "coast_200m",
label: "200x200 全国海岸格",
manifestUrl: "./coastline-mysql/japan_coast_200m/manifest.json",
gridUrl: "./coastline-mysql/japan_coast_200m/coast_200m_grid.geojson",
center: [138.5, 36.4],
zoom: 4.8
},
"20": {
key: "fish_port_20m",
label: "20x20 全国渔港格",
manifestUrl: "./coastline-mysql/japan_national/manifest.json",
gridUrl: "./coastline-mysql/japan_national/fish_port_20m_grid.geojson",
center: [130.55, 33.55],
zoom: 6.0
},
"50": {
key: "hazard_50m",
label: "50x50 全国海上障碍格",
manifestUrl: "./coastline-mysql/japan_national/manifest.json",
gridUrl: "./coastline-mysql/japan_national/hazard_50m_grid.geojson",
center: [131.0, 33.55],
zoom: 5.8
},
"density": {
key: "density_overview",
label: "整体密度图",
manifestUrl: "./coastline-mysql/japan_national/manifest.json",
gridUrl: "./coastline-mysql/japan_national/density_overview_grid.geojson",
center: [138.5, 36.4],
zoom: 4.8,
mode: "density"
}
};
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
mapple: {
type: "raster",
minzoom: 0,
maxzoom: 19,
tileSize: 256,
tiles: [
"https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"
],
attribution: "&copy; 昭文社"
}
},
layers: [
{ id: "mapple", type: "raster", source: "mapple" }
]
},
center: LAYERS["200"].center,
zoom: LAYERS["200"].zoom,
pitch: 0,
bearing: 0,
hash: true
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.addControl(new maplibregl.ScaleControl({ maxWidth: 140, unit: "metric" }), "bottom-left");
let manifest = null;
let currentLayerKey = "200";
let currentMode = "regular";
function setStatus(text) {
document.getElementById("status").textContent = text;
}
function setHint(text) {
document.getElementById("hint").textContent = text;
}
function setActiveButton(key) {
for (const id of ["btn200", "btn20", "btn50", "btnDensity"]) {
const button = document.getElementById(id);
button.classList.toggle("active", id === `btn${key}`);
}
}
async function loadJson(url) {
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
throw new Error(`${url} -> ${resp.status}`);
}
return resp.json();
}
function layerStats(manifestObj, layerKey) {
if (!manifestObj) return { count: 0, bbox: null };
if (manifestObj.layers && manifestObj.layers[layerKey]) {
const item = manifestObj.layers[layerKey];
return { count: item.count || 0, bbox: item.bbox_lonlat || null };
}
if (manifestObj.layer === layerKey) {
return {
count: manifestObj.count || 0,
bbox: manifestObj.grid_bounds_lonlat || null
};
}
return { count: 0, bbox: null };
}
function validLonLatBounds(bounds) {
if (!Array.isArray(bounds) || bounds.length !== 4) return false;
const [west, south, east, north] = bounds.map(Number);
if (![west, south, east, north].every(Number.isFinite)) return false;
if (Math.abs(west) > 180 || Math.abs(east) > 180) return false;
if (Math.abs(south) > 90 || Math.abs(north) > 90) return false;
return west < east && south < north;
}
function applyGridStyle(mode) {
currentMode = mode;
const isDensity = mode === "density";
const fillColor = isDensity
? [
"match",
["get", "density_key"],
"20", "#bfeec2",
"50", "#f2e17b",
"200", "#f5b6ba",
"#ff2b35"
]
: "#ff2b35";
const outlineColor = isDensity
? [
"match",
["get", "density_key"],
"20", "#67a96c",
"50", "#b8a228",
"200", "#cd7b82",
"#a40008"
]
: "#a40008";
if (map.getLayer("navsea-grid-fill")) {
map.setPaintProperty("navsea-grid-fill", "fill-color", fillColor);
map.setPaintProperty("navsea-grid-fill", "fill-opacity", 0.82);
map.setPaintProperty("navsea-grid-fill", "fill-outline-color", "#111111");
}
if (map.getLayer("navsea-grid-halo")) {
map.setPaintProperty("navsea-grid-halo", "line-color", "#ffffff");
map.setPaintProperty("navsea-grid-halo", "line-width", 5);
map.setPaintProperty("navsea-grid-halo", "line-opacity", 0.96);
}
if (map.getLayer("navsea-grid-line")) {
map.setPaintProperty("navsea-grid-line", "line-color", outlineColor);
map.setPaintProperty("navsea-grid-line", "line-width", isDensity ? 3.25 : 3.5);
map.setPaintProperty("navsea-grid-line", "line-opacity", 1.0);
}
const legend = document.querySelector(".legend");
if (legend) {
legend.innerHTML = isDensity
? `
<div class="swatch-red" style="background: rgba(191, 238, 194, 0.95); border-color: rgba(103, 169, 108, 1);"></div><div>高密度20x20淡绿色</div>
<div class="swatch-red" style="background: rgba(242, 225, 123, 0.95); border-color: rgba(184, 162, 40, 1);"></div><div>中密度50x50黄色</div>
<div class="swatch-red" style="background: rgba(245, 182, 186, 0.95); border-color: rgba(205, 123, 130, 1);"></div><div>低密度200x200淡红色</div>
`
: '<div class="swatch-red"></div><div>当前图层采用红色填充和深色边线显示</div>';
}
}
async function loadLayer(key, forceFit = true) {
const spec = LAYERS[key];
currentLayerKey = key;
setActiveButton(key);
setStatus(`加载${spec.label}中...`);
setHint(`当前加载:${spec.label}`);
manifest = await loadJson(spec.manifestUrl);
const grid = await loadJson(spec.gridUrl);
const mode = spec.mode || "regular";
if (map.getSource("navsea-grid")) {
map.getSource("navsea-grid").setData(grid);
} else {
map.addSource("navsea-grid", { type: "geojson", data: grid });
map.addLayer({
id: "navsea-grid-fill",
type: "fill",
source: "navsea-grid",
paint: {
"fill-color": "#ff2b35",
"fill-opacity": 0.82,
"fill-outline-color": "#111111"
}
});
map.addLayer({
id: "navsea-grid-halo",
type: "line",
source: "navsea-grid",
paint: {
"line-color": "#ffffff",
"line-width": 5,
"line-opacity": 0.96
}
});
map.addLayer({
id: "navsea-grid-line",
type: "line",
source: "navsea-grid",
paint: {
"line-color": "#a40008",
"line-width": 3.5,
"line-opacity": 1.0
}
});
}
applyGridStyle(mode);
const stats = layerStats(manifest, spec.key);
setStatus(`已加载:${spec.label},共 ${stats.count || 0} 个格子。`);
const bounds = stats.bbox || manifest.grid_bounds_lonlat || manifest.layers?.[spec.key]?.bbox_lonlat;
if (forceFit && validLonLatBounds(bounds)) {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
padding: 50,
duration: 900
});
} else {
map.easeTo({
center: spec.center,
zoom: spec.zoom,
duration: 700
});
}
}
map.on("load", async () => {
try {
await loadLayer("200", true);
} catch (err) {
console.error(err);
setStatus(`加载失败:${err.message}`);
}
});
document.getElementById("reloadBtn").addEventListener("click", async () => {
try {
setStatus(`重新加载中... ${VERSION}`);
await loadLayer(currentLayerKey, true);
} catch (err) {
console.error(err);
setStatus(`重载失败:${err.message}`);
}
});
document.getElementById("btn200").addEventListener("click", async () => {
try {
await loadLayer("200", true);
} catch (err) {
console.error(err);
setStatus(`加载失败:${err.message}`);
}
});
document.getElementById("btn20").addEventListener("click", async () => {
try {
await loadLayer("20", true);
} catch (err) {
console.error(err);
setStatus(`加载失败:${err.message}`);
}
});
document.getElementById("btn50").addEventListener("click", async () => {
try {
await loadLayer("50", true);
} catch (err) {
console.error(err);
setStatus(`加载失败:${err.message}`);
}
});
document.getElementById("btnDensity").addEventListener("click", async () => {
try {
await loadLayer("density", true);
} catch (err) {
console.error(err);
setStatus(`加载失败:${err.message}`);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,101 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>NavSea 九州语义图标替换测试 v1</title>
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" />
<style>
:root { --bg:#eef2ed; --panel:rgba(250,248,242,.95); --ink:#17232b; --muted:#667780; --line:rgba(23,35,43,.14); }
* { box-sizing:border-box; }
html,body { margin:0; width:100%; height:100%; overflow:hidden; background:var(--bg); color:var(--ink); font-family:"Avenir Next","Segoe UI","PingFang SC","Noto Sans SC",sans-serif; }
body { display:grid; grid-template-rows:auto 1fr; }
.toolbar { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:8px 12px; background:var(--panel); border-bottom:1px solid var(--line); box-shadow:0 8px 20px rgba(23,35,43,.12); z-index:10; }
.title { font-size:16px; font-weight:700; }
.subtitle { margin-top:2px; color:var(--muted); font-size:11px; line-height:1.35; }
.actions { display:flex; align-items:center; justify-content:flex-end; gap:7px; flex-wrap:wrap; }
.chip { max-width:260px; padding:7px 9px; border:1px solid var(--line); border-radius:999px; background:#fffdf8; font-size:10px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
button { height:31px; padding:0 11px; border:1px solid rgba(23,35,43,.16); border-radius:8px; background:#17333f; color:#fff; font:inherit; font-size:11px; font-weight:700; cursor:pointer; }
button:disabled { opacity:.55; cursor:wait; }
.layout { min-height:0; display:grid; grid-template-columns:1fr 1fr; }
.pane { position:relative; min-width:0; border-right:1px solid var(--line); }
.pane:last-child { border-right:0; }
.map { position:absolute; inset:0; }
.tag { position:absolute; top:10px; left:10px; z-index:2; padding:8px 10px; border:1px solid var(--line); border-radius:10px; background:var(--panel); box-shadow:0 8px 18px rgba(23,35,43,.12); font-size:11px; }
.tag strong { display:block; margin-bottom:2px; font-size:12px; }
.tag span { color:var(--muted); }
.inspect { position:fixed; right:12px; bottom:12px; z-index:5; width:min(390px,calc(100vw - 24px)); max-height:42vh; overflow:auto; padding:10px 12px; border:1px solid var(--line); border-radius:12px; background:var(--panel); box-shadow:0 12px 26px rgba(23,35,43,.16); }
.inspect h3 { margin:0 0 5px; font-size:12px; }
.inspect p { margin:0 0 7px; color:var(--muted); font-size:10px; }
.inspect dl { display:grid; grid-template-columns:84px 1fr; gap:3px 7px; margin:0 0 7px; font-size:10px; }
.inspect dt { color:var(--muted); font-weight:700; }
.inspect dd { margin:0; word-break:break-word; }
.inspect pre { margin:0; padding:7px; border-radius:8px; background:#17232b; color:#eef4f7; font-size:10px; white-space:pre-wrap; word-break:break-word; }
#status { position:fixed; left:50%; bottom:12px; z-index:4; transform:translateX(-50%); max-width:calc(100vw - 430px); padding:7px 11px; border-radius:999px; background:rgba(23,35,43,.86); color:#fff; font-size:10px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
@media (max-width:900px) { .toolbar { align-items:flex-start; flex-direction:column; } .actions { justify-content:flex-start; } #status { max-width:calc(100vw - 24px); bottom:6px; } .inspect { bottom:38px; } }
</style>
</head>
<body>
<header class="toolbar">
<div>
<div class="title">NavSea 九州语义图标替换测试 v1</div>
<div class="subtitle">左侧:原始 newpec右侧当前 Full 基线生成的九州语义图标测试版。两侧同步平移、缩放,可点击对象查看属性。</div>
</div>
<div class="actions">
<span id="version" class="chip">HTML: kyushu-semantic-icon-v1-20260806-r2</span>
<span id="style" class="chip">Style: 加载中</span>
<button id="reload" type="button">重新加载</button>
<button id="home" type="button">回到九州</button>
</div>
</header>
<main class="layout">
<section class="pane"><div class="tag"><strong style="color:#305f72">左侧 · 原始 newpec</strong><span>原始瓦片 + 原始 sprite</span></div><div id="original" class="map"></div></section>
<section class="pane"><div class="tag"><strong style="color:#8b5e34">右侧 · 语义图标测试版</strong><span>Kyushu semantic icon v1</span></div><div id="delivery" class="map"></div></section>
</main>
<aside class="inspect"><h3>点击对比</h3><p id="hint">点击左右任一地图对象,查看 source-layer、render layer 和属性。</p><dl><dt>面板</dt><dd id="side">-</dd><dt>坐标</dt><dd id="lnglat">-</dd><dt>source-layer</dt><dd id="layer">-</dd><dt>render layer</dt><dd id="render">-</dd></dl><pre id="json">{\n "message": "等待点击对象"\n}</pre></aside>
<div id="status">等待加载样式…</div>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script>
const VERSION = "kyushu-semantic-icon-v1-20260806-r2";
const ORIGINAL_STYLE = "./domain/style.navsea-newpec-kyushu-icon-test-v1.json";
const DELIVERY_STYLE = "./domain/style.navsea-delivery-kyushu-semantic-icon-v1.json";
const CENTER = [130.4, 32.5], ZOOM = 7.5;
const q = new URLSearchParams(location.search);
const requestedCenter = (q.get("center") || "").split(",").map(Number);
const camera = { center: requestedCenter.length === 2 && requestedCenter.every(Number.isFinite) ? requestedCenter : CENTER, zoom: Number(q.get("zoom")) || ZOOM, bearing: Number(q.get("bearing")) || 0, pitch: Number(q.get("pitch")) || 0 };
let originalMap, deliveryMap, lock = false, cacheVersion = `${VERSION}-${Date.now()}`;
const $ = (id) => document.getElementById(id);
function bust(url) { const join = url.includes("?") ? "&" : "?"; return `${url}${join}v=${encodeURIComponent(cacheVersion)}`; }
function touch(style) {
const next = structuredClone(style);
if (next.sprite) next.sprite = bust(next.sprite);
if (next.glyphs) next.glyphs = bust(next.glyphs);
for (const source of Object.values(next.sources || {})) {
if (source.tiles) source.tiles = source.tiles.map(bust);
if (source.url) source.url = bust(source.url);
}
return next;
}
async function loadStyle(url) { const response = await fetch(bust(url), { cache:"no-store" }); if (!response.ok) throw new Error(`加载样式失败: ${url}`); return touch(await response.json()); }
function sync(primary, secondary) { primary.on("move", () => { if (lock) return; lock = true; secondary.jumpTo({ center:primary.getCenter(), zoom:primary.getZoom(), bearing:primary.getBearing(), pitch:primary.getPitch() }); lock = false; }); }
function inspect(map, label) { map.on("click", (event) => { const feature = map.queryRenderedFeatures(event.point)[0]; if (!feature) { $("hint").textContent = `${label}:点击位置未命中对象。`; return; } $("hint").textContent = "已选中对象。继续点击可查看另一侧。"; $("side").textContent = label; $("lnglat").textContent = `${event.lngLat.lng.toFixed(6)}, ${event.lngLat.lat.toFixed(6)}`; $("layer").textContent = feature.sourceLayer || "-"; $("render").textContent = feature.layer?.id || "-"; $("json").textContent = JSON.stringify(feature.properties || {}, null, 2); }); }
async function start() {
$("reload").disabled = true; $("status").textContent = `正在加载九州双屏… ${VERSION}`; cacheVersion = `${VERSION}-${Date.now()}`;
try {
const [leftStyle, rightStyle] = await Promise.all([loadStyle(ORIGINAL_STYLE), loadStyle(DELIVERY_STYLE)]);
$("style").textContent = "Style: newpec 原始 / delivery semantic v1";
if (!originalMap) {
originalMap = new maplibregl.Map({ container:"original", style:leftStyle, ...camera });
deliveryMap = new maplibregl.Map({ container:"delivery", style:rightStyle, ...camera });
originalMap.addControl(new maplibregl.NavigationControl(), "top-right"); deliveryMap.addControl(new maplibregl.NavigationControl(), "top-right");
sync(originalMap, deliveryMap); sync(deliveryMap, originalMap); inspect(originalMap, "左侧原始版"); inspect(deliveryMap, "右侧语义版");
let loaded = 0; const mark = () => { loaded += 1; if (loaded === 2) $("status").textContent = `九州双屏已加载,可拖动、缩放、点击审计。${VERSION}`; }; originalMap.once("load", mark); deliveryMap.once("load", mark);
} else {
let loaded = 0; const mark = () => { loaded += 1; if (loaded === 2) { originalMap.jumpTo(camera); deliveryMap.jumpTo(camera); $("status").textContent = `九州双屏已重新加载。${VERSION}`; } }; originalMap.once("style.load", mark); deliveryMap.once("style.load", mark); originalMap.setStyle(leftStyle, { diff:false }); deliveryMap.setStyle(rightStyle, { diff:false });
}
} catch (error) { console.error(error); $("status").textContent = error.message || "加载失败"; } finally { $("reload").disabled = false; }
}
$("reload").addEventListener("click", start); $("home").addEventListener("click", () => { if (originalMap && deliveryMap) { originalMap.jumpTo(camera); deliveryMap.jumpTo(camera); } }); start();
</script>
</body>
</html>

View File

@@ -1922,9 +1922,9 @@
"31100004", "31100004",
"light_beacon", "light_beacon",
"31135001", "31135001",
"light_beacon", "light_beacon_port_lateral_variantt_311350",
"31135102", "31135102",
"light_beacon", "light_beacon_starboard_lateral_variantt_311351",
"31135400", "31135400",
"light_beacon_north_cardinal_variantt", "light_beacon_north_cardinal_variantt",
"31135404", "31135404",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
[
{
"id": "hakata_kyushu_icon_v1",
"label": "博多港九州语义图标测试",
"center": [130.335, 33.6385],
"radius_nm": 10,
"zoom_levels": [10, 12]
},
{
"id": "karatsu_kyushu_icon_v1",
"label": "唐津九州语义图标测试",
"center": [129.9697, 33.4425],
"radius_nm": 5,
"zoom_levels": [10, 12]
}
]

View File

@@ -0,0 +1,156 @@
# NavSea 语义图标替换表-v1
状态:设计稿,尚未写入 PBF、style 或 sprite。
基线:
- PBF`/home/wwwroot/pbf-delivery-full-20260418-rebuild`
- style`src/pbf/style.navsea-delivery-full-semantic-extentfix.json`
- sprite`/mnt/sda1/www/newpec/sprite-semantic/sprite`
## 1. 统一规则
PBF、style 和 `sprite.json` 使用同一个短语 ID。PBF 中建议使用:
```text
icon_id普通点状图标
arc_id灯弧图标
```
`class_code` 继续保留用于对象追溯,但不再被 style 用来拼接图标名称。
颜色缩写:
| 缩写 | 含义 |
|---|---|
| `G` | 绿色 |
| `R` | 红色 |
| `Y` | 黄色 |
| `W` | 白色 |
| `B` | 蓝色 |
| `YW` | 黄/白组合 |
## 2. 灯弧
| 当前 key | 新 ID | 条件/含义 |
|---|---|---|
| `arc-daytime-m1` | `arc_G` | 绿色闭合灯弧 |
| `arc-daytime-m2` | `arc_R` | 红色闭合灯弧 |
| `arc-daytime-m3` | `arc_YW` | 黄/白闭合灯弧 |
| `arc-daytime-04027289` | `arc_open_YW` | `coastal_lighthouse_over_15m``light_sector_mode=sector` |
普通灯弧按 `light_color_code` 选择 `arc_G``arc_R``arc_YW`;扇弧条件优先级最高。
## 3. 航标、灯塔、浮标
| 当前 key | 新 ID | 含义 |
|---|---|---|
| `symbol-daytime-300` | `lt_cst` | 沿岸灯台 |
| `symbol-daytime-301` | `lt_hbr` | 港湾灯台 |
| `symbol-daytime-302` | `lt_bkw` | 防波堤灯台 |
| `symbol-daytime-303` | `lt_min` | 小型灯 |
| `symbol-daytime-30500001` | `lt_lead_a` | 导标变体 A |
| `symbol-daytime-30500002` | `lt_lead_b` | 导标变体 B |
| `symbol-daytime-30500003` | `lt_lead_c` | 导标变体 C |
| `symbol-daytime-30700001` | `lt_up_G` | 绿色上向灯 |
| `symbol-daytime-30700002` | `lt_up_R` | 红色上向灯 |
| `symbol-daytime-30700003` | `mk_lead_c` | 导标变体 |
| `symbol-daytime-308` | `mk_lead_v` | 导标变体 |
| `symbol-daytime-310` | `lt_bcn` | 灯标 |
| `symbol-daytime-320` | `bu_lit` | 灯浮标 |
| `symbol-daytime-321` | `bu_gen` | 普通浮标 |
| `symbol-daytime-323` | `bu_pil` | 圆柱/柱型浮标 |
| `symbol-daytime-325` | `bu_can` | 圆筒型浮标 |
| `symbol-daytime-327` | `bu_lat` | 桁架型浮标 |
| `symbol-daytime-335359` | `mk_vais` | V-AIS 航标 |
| `symbol-daytime-719` | `an_quar` | 检疫锚地 |
现有 style 中的别名也统一归并:
| 当前 key | 新 ID |
|---|---|
| `harbor_lighthouse` | `lt_hbr` |
| `breakwater_lighthouse` | `lt_bkw` |
## 4. 危险物与鱼礁
| 当前 class_code / key | 新 ID | 含义 |
|---|---|---|
| `401``402``403` / `symbol-daytime-401` | `hz_rock_awash` | 水上岩、干出岩、洗岩组 |
| `404` / `symbol-daytime-404` | `hz_rock_sub` | 暗岩 |
| `405` / `symbol-daytime-405` | `hz_danger_clear` | 扫海后的危险物 |
| `409` / `symbol-daytime-409` | `hz_danger_iso` | 孤立危险物 |
| `410` / `symbol-daytime-410` | `hz_wreck_hull` | 船体露出沉船 |
| `412` / `symbol-daytime-412` | `hz_wreck_sub` | 危险全沉没船 |
| `413``415` / `symbol-daytime-413` | `hz_wreck_survey` | 测量/调查后的沉船 |
| `420` / `symbol-daytime-420` | `hz_foul` | 险恶物 |
| `421` / `symbol-daytime-421` | `hz_sandwave` | 沙波 |
| `422` / `symbol-daytime-422` | `hz_overfall` | 急潮、波纹、激潮 |
| `424` / `symbol-daytime-424` | `hz_whirl` | 渦流 |
| `425` / `symbol-daytime-425` | `hz_seaweed` | 海草 |
| `427` / `symbol-daytime-427` | `hz_obst` | 障碍物 |
| `428` / `symbol-daytime-428` | `hz_reef` | 鱼礁 |
| `429` / `symbol-daytime-429` | `hz_reef_danger` | 危险鱼礁 |
| `431` / `symbol-daytime-431` | `hz_tower` | 塔、橹、测台 |
| `432` / `symbol-daytime-432` | `hz_pile` | 墩柱、桩、杭 |
| `433` / `symbol-daytime-433` | `hz_dolphin` | 系缆墩 |
| `434` / `symbol-daytime-434` | `hz_outfall` | 海底设施、排水口、取水口 |
## 5. 陆上地标
这些图标由 `onshore_structure_point``class_code` 生成,必须在重建 PBF 时直接写入 `icon_id`,不能继续由 style 动态拼接编号。
| class_code | canonical_object_type | 新 ID |
|---:|---|---|
| 640 | `chimney` | `lm_chim` |
| 650 | `tower_yagura_windmill` | `lm_twr` |
| 660 | `maritime_office` | `lm_mar` |
| 661 | `fishing_cooperative` | `lm_fish` |
| 662 | `customs_office` | `lm_cus` |
| 680 | 陆上显著物(观览车等) | `lm_prom` |
| 681 | `mountain_top` | `lm_mtn` |
| 682 | 管制信号所 | `lm_ctrl` |
| 698 | `other_landmark_monument` | `lm_mon` |
## 6. 当前 PBF 中的直接图标值
当前全国 Full PBF 实际出现的 `chart_icon_image` 值,均应落入本表:
```text
symbol-daytime-301
symbol-daytime-303
symbol-daytime-30500001
symbol-daytime-30500002
symbol-daytime-30500003
symbol-daytime-30700003
symbol-daytime-308
symbol-daytime-310
symbol-daytime-320
symbol-daytime-321
symbol-daytime-323
symbol-daytime-325
symbol-daytime-327
symbol-daytime-335359
symbol-daytime-405
symbol-daytime-410
symbol-daytime-412
symbol-daytime-413
symbol-daytime-428
symbol-daytime-429
symbol-daytime-719
```
其中 `symbol-daytime-719` 当前语义建议为:
```text
an_quar = quarantine anchorage
```
## 7. 实施约束
- 本表确认后,统一重建全部 Full PBF不在现有 PBF 上做局部替换。
- style 的 `icon-image` 统一读取 `icon_id``arc_id`,删除 `class_code` 拼接逻辑。
- sprite JSON key 与新 ID 完全一致。
- `class_code`、FID 和 source-layer 继续保留,用于反向追溯和对象审计。
- `symbol-daytime-*``arc-daytime-*` 只允许出现在转换审计日志或历史映射表中,不能出现在正式 PBF、正式 style 和正式 sprite JSON 中。
- `symbol-daytime-662` 对应 `lm_cus`,需要补上海关图标素材。

View File

@@ -0,0 +1,226 @@
{
"version": "semantic-icon-v1",
"baseline": "full-semantic-extentfix",
"fields": {
"point_icon": "icon_id",
"light_arc": "arc_id",
"legacy_point_icon": "chart_icon_image"
},
"color_codes": {
"green": "G",
"red": "R",
"yellow": "Y",
"white": "W",
"blue": "B",
"yellow_white": "YW"
},
"sprite_key_map": {
"symbol-daytime-300": "lt_cst",
"symbol-daytime-301": "lt_hbr",
"symbol-daytime-302": "lt_bkw",
"symbol-daytime-303": "lt_min",
"symbol-daytime-30500001": "lt_lead_a",
"symbol-daytime-30500002": "lt_lead_b",
"symbol-daytime-30500003": "lt_lead_c",
"symbol-daytime-30700001": "lt_up_G",
"symbol-daytime-30700002": "lt_up_R",
"symbol-daytime-30700003": "mk_lead_c",
"symbol-daytime-308": "mk_lead_v",
"symbol-daytime-310": "lt_bcn",
"symbol-daytime-311350": "light_beacon_port_lateral_variantt_311350",
"symbol-daytime-311351": "light_beacon_starboard_lateral_variantt_311351",
"symbol-daytime-320": "bu_lit",
"symbol-daytime-321": "bu_gen",
"symbol-daytime-323": "bu_pil",
"symbol-daytime-325": "bu_can",
"symbol-daytime-327": "bu_lat",
"symbol-daytime-335359": "mk_vais",
"symbol-daytime-401": "hz_rock_awash",
"symbol-daytime-404": "hz_rock_sub",
"symbol-daytime-405": "hz_danger_clear",
"symbol-daytime-409": "hz_danger_iso",
"symbol-daytime-410": "hz_wreck_hull",
"symbol-daytime-412": "hz_wreck_sub",
"symbol-daytime-413": "hz_wreck_survey",
"symbol-daytime-420": "hz_foul",
"symbol-daytime-421": "hz_sandwave",
"symbol-daytime-422": "hz_overfall",
"symbol-daytime-424": "hz_whirl",
"symbol-daytime-425": "hz_seaweed",
"symbol-daytime-427": "hz_obst",
"symbol-daytime-428": "hz_reef",
"symbol-daytime-429": "hz_reef_danger",
"symbol-daytime-431": "hz_tower",
"symbol-daytime-432": "hz_pile",
"symbol-daytime-433": "hz_dolphin",
"symbol-daytime-434": "hz_outfall",
"symbol-daytime-640": "lm_chim",
"symbol-daytime-650": "lm_twr",
"symbol-daytime-660": "lm_mar",
"symbol-daytime-661": "lm_fish",
"symbol-daytime-662": "lm_cus",
"symbol-daytime-680": "lm_prom",
"symbol-daytime-681": "lm_mtn",
"symbol-daytime-682": "lm_ctrl",
"symbol-daytime-698": "lm_mon",
"symbol-daytime-719": "an_quar",
"symbol-daytime-720": "an_desig",
"symbol-daytime-721": "an_restrict",
"symbol-daytime-724": "an_no_anchor",
"symbol-daytime-505": "fac_port",
"symbol-daytime-520": "fac_fishport",
"symbol-daytime-530": "fac_marina",
"symbol-daytime-540": "fac_fisherina",
"symbol-daytime-550": "fac_seastation",
"harbor_lighthouse": "lt_hbr",
"breakwater_lighthouse": "lt_bkw",
"coastal_lighthouse": "lt_cst",
"light_minor": "lt_min",
"leading_light_variant_30500001": "lt_lead_a",
"leading_light_variantt_30500001": "lt_lead_a",
"leading_light_variant_30500002": "lt_lead_b",
"leading_light_variantt_30500002": "lt_lead_b",
"leading_light_variant_30500003": "lt_lead_c",
"leading_light_variantt_30500003": "lt_lead_c",
"leading_mark_variant_30700003": "mk_lead_c",
"leading_mark_variantt_30700003": "mk_lead_c",
"leading_mark_variant_308": "mk_lead_v",
"leading_mark_variantt_308": "mk_lead_v",
"light_beacon": "lt_bcn",
"buoy_light": "bu_lit",
"buoy_generic": "bu_gen",
"buoy_pillar": "bu_pil",
"buoy_can": "bu_can",
"buoy_lattice": "bu_lat",
"nav_mark_vais": "mk_vais",
"pilot_station": "fac_pilot",
"tidespot-daytime": "tide_spot",
"light-daytime-1": "fl_G",
"light-daytime-2": "fl_R",
"light-daytime-3": "fl_YW",
"arc-daytime-04027289": "arc_open_YW",
"arc-daytime-m1": "arc_G",
"arc-daytime-m2": "arc_R",
"arc-daytime-m3": "arc_YW",
"rock_exposed_drying_awash_group": "hz_rock_awash",
"sunken_rock": "hz_rock_sub",
"cleared_danger_object": "hz_danger_clear",
"isolated_danger": "hz_danger_iso",
"wreck_hull_exposed": "hz_wreck_hull",
"wreck_fully_submerged_dangerous": "hz_wreck_sub",
"wreck_surveyed": "hz_wreck_survey",
"foul_ground": "hz_foul",
"sand_wave": "hz_sandwave",
"tide_rips_overfalls": "hz_overfall",
"eddy_whirlpool": "hz_whirl",
"seaweed": "hz_seaweed",
"obstruction": "hz_obst",
"fish_reef": "hz_reef",
"fish_reef_dangerous": "hz_reef_danger",
"tower_yagura_observation_platform": "hz_tower",
"bollard_pile_stake": "hz_pile",
"dolphin_structure": "hz_dolphin",
"subsea_installation_outfall_intake": "hz_outfall",
"anchorage_quarantine": "an_quar",
"anchorage_designated": "an_desig",
"restriction_area_group": "an_restrict",
"anchoring_prohibited": "an_no_anchor",
"port_general_small_harbor_group": "fac_port",
"fishing_port": "fac_fishport",
"marina": "fac_marina",
"fisherina": "fac_fisherina",
"sea_station_umi_no_eki": "fac_seastation"
},
"hazard_class_map": {
"401": "hz_rock_awash",
"402": "hz_rock_awash",
"403": "hz_rock_awash",
"404": "hz_rock_sub",
"405": "hz_danger_clear",
"409": "hz_danger_iso",
"410": "hz_wreck_hull",
"412": "hz_wreck_sub",
"413": "hz_wreck_survey",
"415": "hz_wreck_survey",
"420": "hz_foul",
"421": "hz_sandwave",
"422": "hz_overfall",
"424": "hz_whirl",
"425": "hz_seaweed",
"427": "hz_obst",
"428": "hz_reef",
"429": "hz_reef_danger",
"431": "hz_tower",
"432": "hz_pile",
"433": "hz_dolphin",
"434": "hz_outfall"
},
"landmark_class_map": {
"640": "lm_chim",
"650": "lm_twr",
"660": "lm_mar",
"661": "lm_fish",
"662": "lm_cus",
"680": "lm_prom",
"681": "lm_mtn",
"682": "lm_ctrl",
"698": "lm_mon"
},
"anchorage_class_map": {
"719": "an_quar",
"720": "an_desig",
"721": "an_restrict",
"722": "an_restrict",
"723": "an_restrict",
"724": "an_no_anchor"
},
"facility_class_map": {
"505": "fac_port",
"510": "fac_port",
"520": "fac_fishport",
"530": "fac_marina",
"540": "fac_fisherina",
"550": "fac_seastation"
},
"display_code_map": {
"30300000": "lt_min",
"30300002": "lt_min",
"30300004": "lt_min",
"30500001": "lt_lead_a",
"30500002": "lt_lead_b",
"30500003": "lt_lead_c",
"30500004": "lt_lead_c",
"30500010": "lt_lead_c",
"30600000": "lt_min",
"30600001": "lt_min",
"30600002": "lt_min",
"30600003": "lt_min",
"30600004": "lt_min",
"30600006": "lt_min",
"30700000": "mk_lead_c",
"30700001": "lt_up_G",
"30700002": "lt_up_R",
"30700003": "mk_lead_c",
"30700004": "mk_lead_c",
"30700007": "mk_lead_c",
"30800000": "mk_lead_v",
"30900000": "lt_lead_c",
"30900002": "lt_lead_b",
"30900004": "lt_lead_c",
"30900009": "lt_lead_c",
"30900011": "lt_lead_c"
},
"arc_rules": {
"sector": "arc_open_YW",
"green": "arc_G",
"red": "arc_R",
"other": "arc_YW"
},
"fallbacks": {
"lm_cus": {
"source_sprite_key": "symbol-daytime-660",
"status": "temporary_visual_fallback",
"reason": "当前 sprite 没有 symbol-daytime-662 原图"
}
}
}