添加全国三层生成与港名查FPC工具
This commit is contained in:
290
NavSea_全国三层数据生成与查看说明.md
Normal file
290
NavSea_全国三层数据生成与查看说明.md
Normal 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`
|
||||
1501
STEP_RECORD.md
1501
STEP_RECORD.md
File diff suppressed because it is too large
Load Diff
1823
coastline/build_fish_port_20m_full_mysql_resume.py
Normal file
1823
coastline/build_fish_port_20m_full_mysql_resume.py
Normal file
File diff suppressed because it is too large
Load Diff
565
coastline/build_japan_coast_grid_mysql.py
Normal file
565
coastline/build_japan_coast_grid_mysql.py
Normal 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()
|
||||
516
coastline/build_japan_hazard_50m_mysql.py
Normal file
516
coastline/build_japan_hazard_50m_mysql.py
Normal 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()
|
||||
485
coastline/export_navgrid_mysql_assets.py
Normal file
485
coastline/export_navgrid_mysql_assets.py
Normal 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
282
find_fpc_by_port_name.py
Normal 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()
|
||||
459
src/pbf/navsea-coastline-fukuoka-saga-200m.html
Normal file
459
src/pbf/navsea-coastline-fukuoka-saga-200m.html
Normal 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: "© 昭文社"
|
||||
}
|
||||
},
|
||||
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>
|
||||
Reference in New Issue
Block a user