feat: enable Japan nationwide CM95 S-52 comparison page
This commit is contained in:
283
config/s52/cm95_s52_style_compiler_japan_compare_v1.json
Normal file
283
config/s52/cm95_s52_style_compiler_japan_compare_v1.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"schema": "navsea.cm95.s52-style-compiler-config/v1",
|
||||
"name": "CM95 S-52 Japan nationwide comparison style v1",
|
||||
"palette": "DAY_BRIGHT",
|
||||
"sprite": "http://192.168.200.184:18889/s52/sprite",
|
||||
"background_color": "DEPDW",
|
||||
"fallback_color": "NODTA",
|
||||
"area_fill_opacity": 0.92,
|
||||
"area_pattern_opacity": 0.85,
|
||||
"line_width_fallback": 1.0,
|
||||
"complex_line_width_px": 5.0,
|
||||
"display_categories": [
|
||||
"Displaybase",
|
||||
"Standard",
|
||||
"Other"
|
||||
],
|
||||
"source": {
|
||||
"id": "cm95",
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"http://192.168.200.184:18889/pbf/{z}/{x}/{y}.pbf"
|
||||
],
|
||||
"bounds": [
|
||||
122.0,
|
||||
20.0,
|
||||
154.5,
|
||||
46.0
|
||||
],
|
||||
"minzoom": 0,
|
||||
"maxzoom": 12
|
||||
},
|
||||
"resources": {
|
||||
"colors": "build/s52_resources_v2/base/s52_colors.json",
|
||||
"symbol_metadata": "build/s52_resources_v2/base/s52_symbol_metadata.json",
|
||||
"bitmap_pattern_metadata": "build/s52_resources_v2/base/s52_pattern_metadata.json",
|
||||
"vector_pattern_metadata": "build/s52_resources_v2/ap/s52-sprite-ap-pattern-metadata.json",
|
||||
"line_metadata": "build/s52_resources_v2/final/s52-sprite-full-line-metadata.json",
|
||||
"sprite_index": "build/s52_resources_v2/final/s52-sprite-full.json",
|
||||
"lookup_ir": "build/s52_presentation_ir_v2/lookup_rule_ir.json",
|
||||
"resource_contract": "build/s52_resources_v2/resource_contract.json"
|
||||
},
|
||||
"source_layers": [
|
||||
{
|
||||
"name": "anchor_caution_hazard_area",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "anchor_caution_hazard_point",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "anchorage_area",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "anchorage_outline",
|
||||
"capabilities": [
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "anchorage_point",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "baseline_area",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "baseline_line",
|
||||
"capabilities": [
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bridge_structure",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "clearance_limit_line",
|
||||
"capabilities": [
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "clearance_limit_point",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "cm95_unmapped",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "depth_contour",
|
||||
"capabilities": [
|
||||
"line",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "facility_boundary_area",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "facility_boundary_outline",
|
||||
"capabilities": [
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "facility_boundary_point",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "fixed_fishing_gear_area",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "land_area",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "navigation_hazard_area",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "navigation_hazard_outline",
|
||||
"capabilities": [
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "navigation_hazard_point",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "navigation_marks",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onshore_structure_area",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "onshore_structure_point",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pilot_station_point",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "place_label_land",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "s52_areas",
|
||||
"capabilities": [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "s52_lines",
|
||||
"capabilities": [
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "s52_points",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "seabed_line",
|
||||
"capabilities": [
|
||||
"line",
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "seabed_text_point",
|
||||
"capabilities": [
|
||||
"symbol",
|
||||
"text"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
31
docs/CM95_S52_日本全国HTML对比使用说明_v1.md
Normal file
31
docs/CM95_S52_日本全国HTML对比使用说明_v1.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# CM95 S-52 日本全国 HTML 对比使用说明 v1
|
||||
|
||||
日期:2026-09-27。项目:Linux X220 的 `/root/sourceserver/cm95`。
|
||||
|
||||
## 访问入口
|
||||
|
||||
- 全国对比:`http://192.168.200.184:18889/`,局域网访问。
|
||||
- 旧九州对比:`http://192.168.200.184:18888/`,保持不变。
|
||||
- 全国健康检查:`http://192.168.200.184:18889/health`。
|
||||
- 全国生产摘要:`http://192.168.200.184:18889/release-summary.json`。
|
||||
- 全国 PBF:`/pbf/{z}/{x}/{y}.pbf`,对应现有 Release 的 `pbf/delivery` 原始产物。
|
||||
- 左侧:全国 CM95 现有 S-52 Release;右侧:原 NavSea extentfix 旧版参考(不是同一版,也非官方基准)。
|
||||
- 保留旧页面的同步视图、完整 S-52/参考 Style 开关、runtime 预览、灯弧、等深线高亮、左右点选对象检查;新增全国总览与地区快捷定位。
|
||||
|
||||
## 版本和限制
|
||||
|
||||
- Release:`cm95-d1-62a85bd40d3a-ae61b8866e80`,Dataset 1,z0~z12。
|
||||
- 生产 177389 Job 中 177384 PASS,5 个低 Zoom FAILED;用户已接受遗留。z0~z3 总览可能缺图,尤其 z0/0/0。
|
||||
- 全国 PBF 原样全量提供;浏览器 S-52 Style 使用分层确定性抽样扫描实际 PBF 的使用情况以控制图层数量。此测试 Style **不代表全国全部符号已经逐条覆盖/验收**。
|
||||
- 右侧是不同数据/不同 Style 版本的视觉参考,差异需要再区分数据、Style、缩放及运行时原因。
|
||||
- 不存在已通过的全国 Finalize manifest;健康检查以原始生产摘要报告真实状态 `TEST_READY_WITH_KNOWN_ISSUES`。
|
||||
- 此对比入口用于辅助导航工程阶段 B 的调查与视觉验证,不是 B 阶段 PASS、官方 ENC 或 ECDIS 认证。
|
||||
|
||||
## 文件与运行
|
||||
|
||||
- 页面:`web/japan_compare.html`;服务:`web/japan_compare_server.py`;原九州页面/服务不改动。
|
||||
- 样式配置:`config/s52/cm95_s52_style_compiler_japan_compare_v1.json`。
|
||||
- 样式生成器:`tools/cm95_s52_japan_compare_build_style.py`;产物:`build/s52_style_compiler_japan_compare_v1/`。
|
||||
- 样式需要重建时,在项目目录执行 `./.venv/bin/python tools/cm95_s52_japan_compare_build_style.py`。
|
||||
- 服务重启命令:`./.venv/bin/python -u web/japan_compare_server.py --host 0.0.0.0 --port 18889`。
|
||||
- 不触碰 MySQL、Builder、生产 SQLite、原 PBF 或旧九州端口。
|
||||
34
docs/报告/CM95_S52_日本全国HTML对比启用报告_v1.md
Normal file
34
docs/报告/CM95_S52_日本全国HTML对比启用报告_v1.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# CM95 S-52 日本全国 HTML 对比启用报告 v1
|
||||
|
||||
日期:2026-09-27;设备:Linux X220;项目:`/root/sourceserver/cm95`。
|
||||
|
||||
## 1. 入口和实际数据
|
||||
|
||||
- 全国入口:`http://192.168.200.184:18889/`;旧九州入口:`http://192.168.200.184:18888/`(未修改)。
|
||||
- 全国 Release:`cm95-d1-62a85bd40d3a-ae61b8866e80`;左侧直接读取现有 `pbf/delivery`。
|
||||
- 右侧沿用旧 `style.navsea-delivery-full-semantic-extentfix.json`,与全国 S-52 不是同版本对等数据集,供视觉排查参考。
|
||||
- 全国生产状态仍是 177389 Job、177384 PASS、5 FAILED、177383 PBF;不伪造 Finalize manifest。
|
||||
- 页面使用独立 `web/japan_compare.html` 和 `web/japan_compare_server.py`,不覆盖九州页面及服务。
|
||||
|
||||
## 2. 浏览器样式与功能
|
||||
|
||||
- 样式按真实全国 PBF 的确定性分层/港口抽样编译:2418 / 177383 文件;产出 170 图层、1735989 字节。
|
||||
- 样式资源契约 PASS、hard_gate_failures=[];抽样仅控制 Style 用到的规则/符号集合,所有原 PBF 按请求直接服务。
|
||||
- 继续支持左右同步、S-52/extentfix 样式开关、runtime/灯弧预览、等深线高亮、点选对象检查。
|
||||
- 新增全国总览和 9 个地区快捷入口;z0~z3 的 5 个失败 Tile 有 UI 警告。
|
||||
- 全国 B 阶段尚未 PASS;本页面提供调查入口,不替代原始语义审计或实机航行验证。
|
||||
|
||||
## 3. 联通和烟雾检查
|
||||
|
||||
- 从另一台 Windows 设备访问全国根路径和健康检查:HTTP 200,Release ID 正确。
|
||||
- 全国 12 地区 × z10/z11/z12:36/36 PBF HTTP 200,MVT MIME 正确。
|
||||
- Style、Sprite、MapLibre JS、本地 Fukuoka z12 PBF:HTTP 200;旧九州根路径继续 HTTP 200。
|
||||
- headless Chromium 加载全国双地图:左右状态为已加载;本地 source loaded 且可查询已渲染 Feature;东京湾快捷定位后左右中心一致,可检索两侧 Feature。
|
||||
- 当前页面含 runtime 动态覆盖;headless 的 `leftMap.isStyleLoaded()` 未稳定为 true,但 `isSourceLoaded('cm95')` 和 `areTilesLoaded()` 为 true,显示/交互测试正常。B 运行时验收仍需人工复核。
|
||||
- JS 语法、Python 编译、样式 JSON/资源契约均通过。
|
||||
|
||||
## 4. 保留问题
|
||||
|
||||
- 5 个失败低 Zoom Tile 原样保留,特别是全球 z0/0/0 缺图。
|
||||
- Style 编译采用 2418 样本,不应被解释为全量符号或完整 S-52 呈现通过。
|
||||
- 浏览器比对仅为不同版本视觉参考;差异需进一步定位数据、Style、运行时及缩放来源。
|
||||
108
tools/cm95_s52_japan_compare_build_style.py
Normal file
108
tools/cm95_s52_japan_compare_build_style.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""只读汇总全国实际 PBF 使用情况,复用原 S-52 compiler 编译轻量对比样式。"""
|
||||
from __future__ import annotations
|
||||
import importlib.util
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE = "cm95-d1-62a85bd40d3a-ae61b8866e80"
|
||||
PBF_DIR = ROOT / "build" / "releases-s52-v2" / RELEASE / "pbf" / "delivery"
|
||||
OUT = ROOT / "build" / "s52_style_compiler_japan_compare_v1"
|
||||
SPEC = importlib.util.spec_from_file_location("cm95_s52_style_compiler", ROOT / "tools" / "cm95_s52_style_compiler.py")
|
||||
assert SPEC and SPEC.loader
|
||||
compiler = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(compiler)
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="全国 Style 按真实 PBF 的代表性样本编译")
|
||||
parser.add_argument("--all", action="store_true", help="扫描全部 PBF(资源密集,默认采用分层样本)")
|
||||
args = parser.parse_args()
|
||||
# 用 scandir 逐级枚举,避免为 177383 个文件创建和排序 Path 对象。
|
||||
strides = {9: 4, 10: 20, 11: 100, 12: 400}
|
||||
files = []
|
||||
by_zoom = {}
|
||||
all_count = 0
|
||||
with os.scandir(PBF_DIR) as zoom_items:
|
||||
zoom_dirs = sorted((d for d in zoom_items if d.is_dir() and d.name.isdigit()), key=lambda d: int(d.name))
|
||||
for zoom_dir in zoom_dirs:
|
||||
z = int(zoom_dir.name)
|
||||
with os.scandir(zoom_dir.path) as x_items:
|
||||
x_dirs = sorted((d for d in x_items if d.is_dir() and d.name.isdigit()), key=lambda d: int(d.name))
|
||||
for x_dir in x_dirs:
|
||||
with os.scandir(x_dir.path) as y_items:
|
||||
entries = sorted((d for d in y_items if d.is_file() and d.name.endswith('.pbf')), key=lambda d: int(d.name[:-4]))
|
||||
for entry in entries:
|
||||
i = by_zoom.get(z, 0)
|
||||
by_zoom[z] = i + 1
|
||||
all_count += 1
|
||||
if args.all or z <= 8 or i % strides.get(z, 1) == 0:
|
||||
files.append(Path(entry.path))
|
||||
if not all_count:
|
||||
raise SystemExit('全国 PBF 目录为空')
|
||||
ports = [(140.7288,41.7688),(140.8719,38.2682),(139.77,35.58),
|
||||
(136.75,34.73),(135.32,34.63),(132.44,34.30),(130.38,33.59),
|
||||
(129.97,33.44),(129.87,32.74),(130.56,31.56),(127.68,26.21),
|
||||
(124.16,24.34)]
|
||||
seen = set(files)
|
||||
for lon,lat in ports:
|
||||
for z in (10,11,12):
|
||||
n = 1 << z
|
||||
x = int((lon+180)/360*n)
|
||||
y = int((1-math.asinh(math.tan(math.radians(lat)))/math.pi)/2*n)
|
||||
path = PBF_DIR / str(z) / str(x) / (str(y)+".pbf")
|
||||
if path.is_file() and path not in seen:
|
||||
files.append(path)
|
||||
seen.add(path)
|
||||
files.sort()
|
||||
print("SAMPLE", len(files), "of", all_count, "PBF; all production tiles remain served", flush=True)
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
total = 0
|
||||
agg = None
|
||||
chunk = []
|
||||
begun = time.monotonic()
|
||||
for path in files:
|
||||
chunk.append(compiler._scan_pbf_usage(path))
|
||||
total += 1
|
||||
if len(chunk) == 200 or total == len(files):
|
||||
one = compiler._merge_pbf_usages(chunk, "national chunk")
|
||||
agg = one if agg is None else compiler._merge_pbf_usages([agg, one], "national aggregate")
|
||||
chunk.clear()
|
||||
if total % 10000 == 0 or total == len(files):
|
||||
print(f"USAGE {total}/{len(files)} elapsed={int(time.monotonic()-begun)}s", flush=True)
|
||||
assert agg is not None
|
||||
agg["pbf_count"] = total
|
||||
agg["pbf"] = str(PBF_DIR)
|
||||
(OUT / "usage_scope.json").write_text(json.dumps({
|
||||
"release_id": RELEASE, "mode": "all" if args.all else "deterministic_stratified_preview",
|
||||
"all_pbf_count": all_count, "scanned_pbf_count": total, "strides": strides,
|
||||
"ports": ports, "note": "仅控制浏览器 Style 的显示规则集合,不改变或删减全国 PBF 数据"
|
||||
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
usage_path = OUT / "national_pbf_usage.json"
|
||||
usage_path.write_text(json.dumps(agg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
# 不修改原编译器;将完整全国聚合 usage 交给原有编译入口。
|
||||
compiler._scan_pbf_directory = lambda path: agg
|
||||
sys.argv = [
|
||||
str(ROOT / "tools" / "cm95_s52_style_compiler.py"),
|
||||
"--config", str(ROOT / "config" / "s52" / "cm95_s52_style_compiler_japan_compare_v1.json"),
|
||||
"--usage-pbf-dir", str(PBF_DIR),
|
||||
"--output", str(OUT / "style.s52.json"),
|
||||
"--audit-output", str(OUT / "style.s52.audit.json"),
|
||||
]
|
||||
result = int(compiler.main())
|
||||
style = json.loads((OUT / "style.s52.json").read_text(encoding="utf-8"))
|
||||
audit = json.loads((OUT / "style.s52.audit.json").read_text(encoding="utf-8"))
|
||||
print("COMPLETE", json.dumps({"pbf_count": total, "layer_count": len(style["layers"]),
|
||||
"style_bytes": (OUT / "style.s52.json").stat().st_size,
|
||||
"hard_gate_failures": audit.get("hard_gate_failures"),
|
||||
"resource_contract_status": audit.get("resource_contract_status")}, ensure_ascii=False), flush=True)
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
994
web/japan_compare.html
Normal file
994
web/japan_compare.html
Normal file
@@ -0,0 +1,994 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>日本全国 CM95 S-52 PBF 对比</title>
|
||||
<link rel="stylesheet" href="/assets/maplibre-gl.css" />
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%;margin:0;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#111;color:#eee}
|
||||
body{overflow:hidden}
|
||||
#toolbar{height:66px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;padding:0 12px;background:#17191d;border-bottom:1px solid #3a3d43;font-size:13px}
|
||||
#toolbar strong{font-size:14px;white-space:nowrap}
|
||||
#toolbar .meta{color:#abb2bd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
#toolbar button{border:1px solid #555;background:#272b31;color:#fff;border-radius:5px;padding:5px 9px;cursor:pointer}
|
||||
#toolbar label{display:flex;gap:5px;align-items:center;white-space:nowrap}
|
||||
#maps{height:calc(100% - 66px);display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#555}
|
||||
.pane{position:relative;min-width:0;overflow:hidden;background:#d8edf5}
|
||||
.map{position:absolute;inset:0}
|
||||
.ca-overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2}
|
||||
.pane-title{position:absolute;z-index:5;left:10px;top:10px;max-width:calc(100% - 62px);padding:7px 9px;border-radius:6px;background:rgba(18,20,24,.88);box-shadow:0 1px 6px rgba(0,0,0,.3);font-size:12px;line-height:1.4}
|
||||
.pane-title b{display:block;font-size:13px}
|
||||
.state{font-size:11px;color:#cbd2dc}
|
||||
.state.ok{color:#91e39b}.state.err{color:#ff8c8c}
|
||||
.inspector{position:absolute;z-index:5;left:10px;right:10px;bottom:10px;max-height:26%;overflow:auto;padding:7px 9px;border-radius:6px;background:rgba(18,20,24,.88);font:11px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;pointer-events:auto}
|
||||
.inspector.empty{color:#9ca5b1}
|
||||
#center-readout{margin-left:auto;color:#cbd2dc;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.divider-label{position:absolute;z-index:6;top:50%;transform:translateY(-50%);background:rgba(0,0,0,.6);padding:4px 5px;font-size:10px;color:#ddd;pointer-events:none}
|
||||
#left-pane .divider-label{right:3px;border-radius:4px 0 0 4px}
|
||||
#right-pane .divider-label{left:3px;border-radius:0 4px 4px 0}
|
||||
#debug-panel{position:absolute;z-index:20;top:56px;right:10px;width:270px;padding:10px 11px;border:1px solid #555;border-radius:7px;background:rgba(20,22,26,.94);box-shadow:0 3px 12px rgba(0,0,0,.35);font-size:12px}
|
||||
#debug-panel.collapsed{display:none}
|
||||
#debug-panel .debug-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;font-weight:600}
|
||||
#debug-panel label{display:flex;align-items:center;gap:7px;margin:7px 0;line-height:1.3}
|
||||
#debug-panel .debug-note{margin-top:8px;color:#aeb7c3;font-size:11px;line-height:1.4}
|
||||
#debug-panel button{border:1px solid #555;background:#272b31;color:#fff;border-radius:4px;padding:3px 7px;cursor:pointer}
|
||||
@media(max-width:760px){
|
||||
#toolbar .meta{display:none}
|
||||
#maps{grid-template-columns:1fr;grid-template-rows:1fr 1fr}
|
||||
.inspector{max-height:32%}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<strong>日本全国 S-52 PBF 对比</strong>
|
||||
<span class="meta">左:X220 全国 S-52 Release 右:旧版 extentfix 参考(不同版本)</span>
|
||||
<label><input id="sync-toggle" type="checkbox" checked />同步视图</label>
|
||||
<button id="reset-btn" type="button">全国总览</button>
|
||||
<label>快速定位 <select id="region-preset" aria-label="日本区域快速定位"><option value="">选择地区</option><option value="hokkaido">北海道</option><option value="tohoku">东北</option><option value="tokyo">东京湾</option><option value="ise">伊势湾</option><option value="osaka">大阪湾</option><option value="setouchi">濑户内海</option><option value="fukuoka">福冈/唐津</option><option value="nagasaki">长崎</option><option value="okinawa">冲绳</option></select></label>
|
||||
<button id="debug-toggle-btn" type="button">调试</button>
|
||||
<span class="meta" title="z0~z3 有 5 个已知 FAILED Tile;全国总览可能出现缺图">测试版 · 样式分层抽样 · z0~z3 缺图 5 Tile</span>
|
||||
<span id="center-readout">--</span>
|
||||
</div>
|
||||
<div id="debug-panel" class="collapsed">
|
||||
<div class="debug-title"><span>图层调试</span><button id="debug-collapse-btn" type="button">收起</button></div>
|
||||
<label><input id="debug-left-s52" type="checkbox" checked />左侧使用 S-52 style</label>
|
||||
<label><input id="debug-runtime-preview" type="checkbox" checked />左侧 runtime 默认阈值预览</label>
|
||||
<label><input id="debug-ref-bathy" type="checkbox" checked />右侧显示 bathymetry_line</label>
|
||||
<label><input id="debug-highlight-depcnt" type="checkbox" />左右突出 DEPCNT</label>
|
||||
<label><input id="debug-inspector" type="checkbox" checked />点击 Feature 检查</label>
|
||||
<div class="debug-note">runtime 预览:shallow=2m / safety contour=3m / safety depth=3m / deep=6m;只消费 PBF facts,不改 PBF。DEPCNT safety contour 从当前已加载等深值中选择下一条实际线。Hazard exact CSP 仍由正式 Runtime Adapter 负责。bathymetry_line 是参考 PBF 的额外高密度海底地形线。</div>
|
||||
</div>
|
||||
<div id="maps">
|
||||
<section class="pane" id="left-pane">
|
||||
<div id="left-map" class="map"></div>
|
||||
<canvas id="left-ca-overlay" class="ca-overlay" aria-hidden="true"></canvas>
|
||||
<div class="pane-title"><b>左:日本全国 S-52(样式分层抽样)</b><span id="left-state" class="state">等待参考 style…</span></div>
|
||||
<div id="left-inspector" class="inspector empty">点击任一侧海图,对比同一位置的 Feature。</div>
|
||||
<div class="divider-label">LOCAL</div>
|
||||
</section>
|
||||
<section class="pane" id="right-pane">
|
||||
<div id="right-map" class="map"></div>
|
||||
<div class="pane-title"><b>右:NavSea extentfix 旧版参考</b><span id="right-state" class="state">加载远端 style…</span></div>
|
||||
<div id="right-inspector" class="inspector empty">点击任一侧海图,对比同一位置的 Feature。</div>
|
||||
<div class="divider-label">REFERENCE</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/assets/maplibre-gl.js"></script>
|
||||
<script src="/s52_runtime_executor.js?v=lights-symbol-runtime-v1"></script>
|
||||
<script>
|
||||
const REFERENCE_STYLE =
|
||||
"https://pbf.jpcn.ccwu.cc/style/style.navsea-delivery-full-semantic-extentfix.json";
|
||||
const LOCAL_TILES = location.origin + "/pbf/{z}/{x}/{y}.pbf";
|
||||
const JAPAN_BOUNDS = [[122.0,20.0],[154.5,46.0]];
|
||||
const START = {center:[137.0,35.0],zoom:4.2,bearing:0,pitch:0};
|
||||
|
||||
const leftState = document.getElementById("left-state");
|
||||
const rightState = document.getElementById("right-state");
|
||||
const centerReadout = document.getElementById("center-readout");
|
||||
const syncToggle = document.getElementById("sync-toggle");
|
||||
const debugPanel = document.getElementById("debug-panel");
|
||||
const debugLeftS52 = document.getElementById("debug-left-s52");
|
||||
const debugRuntimePreview = document.getElementById("debug-runtime-preview");
|
||||
const debugRefBathy = document.getElementById("debug-ref-bathy");
|
||||
const debugHighlightDepcnt = document.getElementById("debug-highlight-depcnt");
|
||||
const debugInspector = document.getElementById("debug-inspector");
|
||||
const leftCaOverlay = document.getElementById("left-ca-overlay");
|
||||
|
||||
function setState(el,text,kind){
|
||||
el.textContent=text;
|
||||
el.className="state" + (kind ? " " + kind : "");
|
||||
}
|
||||
function emptyStyle(){
|
||||
return {version:8,sources:{},layers:[{id:"background",type:"background",paint:{"background-color":"#d8edf5"}}]};
|
||||
}
|
||||
function setLayerVisibility(map,id,visible){
|
||||
if(map.getLayer(id))map.setLayoutProperty(id,"visibility",visible ? "visible" : "none");
|
||||
}
|
||||
function templateLayer(template,id){
|
||||
return template && (template.layers || []).find(function(layer){return layer.id===id;});
|
||||
}
|
||||
|
||||
const RUNTIME_DEFAULTS=Object.freeze({
|
||||
shallowContourM:2.0,
|
||||
safetyContourM:3.0,
|
||||
safetyDepthM:3.0,
|
||||
deepContourM:6.0,
|
||||
twoShades:false
|
||||
});
|
||||
const RUNTIME_LAYER_IDS=[
|
||||
"runtime-preview-depare",
|
||||
"runtime-preview-depcnt",
|
||||
"runtime-preview-soundg",
|
||||
"runtime-preview-hazard-symbols"
|
||||
];
|
||||
const RUNTIME_HAZARD_SOURCE_ID="runtime-preview-hazards";
|
||||
const RUNTIME_HAZARD_SOURCE_LAYERS=[
|
||||
"navigation_hazard_point",
|
||||
"anchor_caution_hazard_point",
|
||||
"s52_points"
|
||||
];
|
||||
let runtimeEffectiveSafetyContourM=null;
|
||||
let runtimeRuleExecutor=null;
|
||||
|
||||
function removeRuntimePreviewLayers(map){
|
||||
if(runtimeRuleExecutor){runtimeRuleExecutor.remove();runtimeRuleExecutor=null;}
|
||||
for(const id of RUNTIME_LAYER_IDS){
|
||||
if(map.getLayer(id))map.removeLayer(id);
|
||||
}
|
||||
if(map.getSource(RUNTIME_HAZARD_SOURCE_ID))map.removeSource(RUNTIME_HAZARD_SOURCE_ID);
|
||||
runtimeEffectiveSafetyContourM=null;
|
||||
}
|
||||
|
||||
function runtimeDepthColorExpression(){
|
||||
const dmin=["to-number",["get","depth_min_m"],-1];
|
||||
const dmax=["to-number",["get","depth_max_m"],["+",dmin,0.01]];
|
||||
return ["case",
|
||||
["!",["has","depth_min_m"]],"#bad5e1",
|
||||
["all",[">=",dmin,RUNTIME_DEFAULTS.deepContourM],[">",dmax,RUNTIME_DEFAULTS.deepContourM]],"#d4eaee",
|
||||
["all",[">=",dmin,RUNTIME_DEFAULTS.safetyContourM],[">",dmax,RUNTIME_DEFAULTS.safetyContourM]],"#bad5e1",
|
||||
["all",[">=",dmin,RUNTIME_DEFAULTS.shallowContourM],[">",dmax,RUNTIME_DEFAULTS.shallowContourM]],"#98c5f2",
|
||||
["all",[">=",dmin,0],[">",dmax,0]],"#73b6ef",
|
||||
"#83b295"
|
||||
];
|
||||
}
|
||||
|
||||
function runtimeDepcntColorExpression(){
|
||||
if(runtimeEffectiveSafetyContourM===null)return "#7d898c";
|
||||
return ["case",
|
||||
["==",["to-number",["get","contour_depth_m"],-9999],runtimeEffectiveSafetyContourM],"#525a5c",
|
||||
"#7d898c"
|
||||
];
|
||||
}
|
||||
function runtimeDepcntWidthExpression(){
|
||||
if(runtimeEffectiveSafetyContourM===null)return 1;
|
||||
return ["case",
|
||||
["==",["to-number",["get","contour_depth_m"],-9999],runtimeEffectiveSafetyContourM],2,
|
||||
1
|
||||
];
|
||||
}
|
||||
function updateRuntimeSafetyContour(){
|
||||
if(!debugRuntimePreview.checked || !leftMap.getSource("cm95") || !leftMap.isSourceLoaded("cm95"))return;
|
||||
const values=[];
|
||||
for(const feature of leftMap.querySourceFeatures("cm95",{sourceLayer:"depth_contour"})){
|
||||
const value=Number(feature.properties && feature.properties.contour_depth_m);
|
||||
if(Number.isFinite(value) && value>=RUNTIME_DEFAULTS.safetyContourM)values.push(value);
|
||||
}
|
||||
const next=values.length ? Math.min.apply(null,values) : null;
|
||||
if(next===runtimeEffectiveSafetyContourM)return;
|
||||
runtimeEffectiveSafetyContourM=next;
|
||||
if(leftMap.getLayer("runtime-preview-depcnt")){
|
||||
leftMap.setPaintProperty("runtime-preview-depcnt","line-color",runtimeDepcntColorExpression());
|
||||
leftMap.setPaintProperty("runtime-preview-depcnt","line-width",runtimeDepcntWidthExpression());
|
||||
}
|
||||
}
|
||||
function addRuntimeDepthAreaLayer(map){
|
||||
const beforeId=map.getLayer("s52-p01-ac-land-area") ? "s52-p01-ac-land-area" : undefined;
|
||||
map.addLayer({
|
||||
id:"runtime-preview-depare",
|
||||
type:"fill",
|
||||
source:"cm95",
|
||||
"source-layer":"baseline_area",
|
||||
filter:["all",
|
||||
["==",["get","s52_mariner_dependent"],1],
|
||||
["match",["get","s52_runtime_csp"],["DEPARE01","DEPARE02"],true,false]
|
||||
],
|
||||
paint:{
|
||||
"fill-color":runtimeDepthColorExpression(),
|
||||
"fill-opacity":0.92
|
||||
}
|
||||
},beforeId);
|
||||
}
|
||||
|
||||
function addRuntimeContourLayer(map){
|
||||
map.addLayer({
|
||||
id:"runtime-preview-depcnt",
|
||||
type:"line",
|
||||
source:"cm95",
|
||||
"source-layer":"depth_contour",
|
||||
filter:["all",
|
||||
["==",["get","s52_mariner_dependent"],1],
|
||||
["==",["get","s52_runtime_csp"],"DEPCNT02"]
|
||||
],
|
||||
paint:{
|
||||
"line-color":runtimeDepcntColorExpression(),
|
||||
"line-width":runtimeDepcntWidthExpression()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addRuntimeSoundingLayer(map){
|
||||
map.addLayer({
|
||||
id:"runtime-preview-soundg",
|
||||
type:"symbol",
|
||||
source:"cm95",
|
||||
"source-layer":"seabed_text_point",
|
||||
filter:["all",
|
||||
["==",["get","s52_mariner_dependent"],1],
|
||||
["==",["get","s52_runtime_csp"],"SOUNDG02"],
|
||||
["has","s52_sounding_depth_m"]
|
||||
],
|
||||
layout:{
|
||||
"text-field":["coalesce",["get","chart_label_text"],["to-string",["get","s52_sounding_depth_m"]]],
|
||||
"text-size":10,
|
||||
"text-allow-overlap":false
|
||||
},
|
||||
paint:{
|
||||
"text-color":["case",
|
||||
["<=",["to-number",["get","s52_sounding_depth_m"],99999],RUNTIME_DEFAULTS.safetyDepthM],"#070707",
|
||||
"#7d898c"
|
||||
],
|
||||
"text-halo-color":"#d4eaee",
|
||||
"text-halo-width":0.5
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeNumber(props,key,fallback=null){
|
||||
const value=Number(props && props[key]);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
function runtimeHazardIsolated(props){
|
||||
const depth=runtimeNumber(props,"s52_hazard_depth_m",null);
|
||||
const expsou=runtimeNumber(props,"s52_expsou_code",0);
|
||||
if(depth===null && expsou!==1)return true;
|
||||
if(expsou===1 || (depth!==null && depth<=RUNTIME_DEFAULTS.safetyContourM)){
|
||||
const surrounding=runtimeNumber(props,"s52_surrounding_max_drval1_m",null);
|
||||
if(surrounding!==null && surrounding>=RUNTIME_DEFAULTS.safetyContourM && expsou!==1){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function runtimeHazardSymbol(props){
|
||||
const csp=String((props && props.s52_runtime_csp) || "");
|
||||
const canonical=String((props && props.canonical_object_type) || "");
|
||||
const watlev=runtimeNumber(props,"s52_watlev_code",-9);
|
||||
const hasVal=props && props.least_depth_m!==undefined && props.least_depth_m!==null && props.least_depth_m!=="";
|
||||
const valsou=hasVal ? runtimeNumber(props,"least_depth_m",null) : null;
|
||||
const isolated=runtimeHazardIsolated(props);
|
||||
|
||||
if(isolated && ![1,2].includes(watlev))return "ISODGR51";
|
||||
|
||||
if(csp==="WRECKS02"){
|
||||
if(valsou!==null)return valsou<RUNTIME_DEFAULTS.safetyContourM ? "DANGER51" : "DANGER52";
|
||||
const catwrk=runtimeNumber(props,"s52_catwrk_code",-9);
|
||||
if(catwrk===1 && watlev===3)return "WRECKS04";
|
||||
if(catwrk===2 && watlev===3)return "WRECKS05";
|
||||
if([4,5].includes(catwrk) || [1,2,4,5].includes(watlev))return "WRECKS01";
|
||||
return "WRECKS05";
|
||||
}
|
||||
if(csp!=="OBSTRN04")return null;
|
||||
|
||||
if(canonical==="underwater_rock"){
|
||||
if(valsou!==null && valsou<=20){
|
||||
if([4,5].includes(watlev))return "UWTROC04";
|
||||
return "DANGER51";
|
||||
}
|
||||
if(valsou!==null && valsou>20)return "DANGER52";
|
||||
if(watlev===2)return "LNDARE01";
|
||||
if(watlev===3)return "UWTROC03";
|
||||
return "UWTROC04";
|
||||
}
|
||||
|
||||
if(valsou!==null){
|
||||
if(valsou>20)return "DANGER52";
|
||||
if(watlev===-9)return "DANGER01";
|
||||
if([1,2].includes(watlev))return "LNDARE01";
|
||||
if(watlev===3)return "DANGER52";
|
||||
if([4,5].includes(watlev))return null; // OpenCPN references undefined DANGER53; fail closed.
|
||||
return "DANGER51";
|
||||
}
|
||||
if([1,2].includes(watlev))return "OBSTRN11";
|
||||
if([4,5].includes(watlev))return "OBSTRN03";
|
||||
return "OBSTRN01";
|
||||
}
|
||||
function runtimeHazardDepthText(props){
|
||||
const csp=String((props && props.s52_runtime_csp) || "");
|
||||
const canonical=String((props && props.canonical_object_type) || "");
|
||||
const watlev=runtimeNumber(props,"s52_watlev_code",-9);
|
||||
const hasVal=props && props.least_depth_m!==undefined && props.least_depth_m!==null && props.least_depth_m!=="";
|
||||
if(!hasVal)return "";
|
||||
const valsou=runtimeNumber(props,"least_depth_m",null);
|
||||
if(valsou===null || runtimeHazardIsolated(props))return "";
|
||||
|
||||
let sounding=false;
|
||||
if(csp==="WRECKS02"){
|
||||
sounding=true;
|
||||
}else if(csp==="OBSTRN04"){
|
||||
if(canonical==="underwater_rock"){
|
||||
sounding=!(valsou<=20 && [4,5].includes(watlev));
|
||||
}else{
|
||||
sounding=![1,2].includes(watlev);
|
||||
}
|
||||
}
|
||||
if(!sounding)return "";
|
||||
|
||||
const rounded=Math.round(valsou*10)/10;
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
}
|
||||
|
||||
function refreshRuntimeHazards(){
|
||||
if(!debugRuntimePreview.checked || !leftMap.getSource("cm95") || !leftMap.isSourceLoaded("cm95"))return;
|
||||
const source=leftMap.getSource(RUNTIME_HAZARD_SOURCE_ID);
|
||||
if(!source || typeof source.setData!=="function")return;
|
||||
const seen=new Set();
|
||||
const features=[];
|
||||
for(const layer of RUNTIME_HAZARD_SOURCE_LAYERS){
|
||||
let rows=[];
|
||||
try{rows=leftMap.querySourceFeatures("cm95",{sourceLayer:layer}) || [];}catch(_err){continue;}
|
||||
for(const feature of rows){
|
||||
const props=feature.properties || {};
|
||||
const csp=String(props.s52_runtime_csp || "");
|
||||
if(csp!=="OBSTRN04" && csp!=="WRECKS02")continue;
|
||||
if(!feature.geometry || feature.geometry.type!=="Point")continue;
|
||||
const symbol=runtimeHazardSymbol(props);
|
||||
if(!symbol)continue;
|
||||
const key=[feature.id==null?"":feature.id,feature.geometry.coordinates[0],feature.geometry.coordinates[1],symbol].join("|");
|
||||
if(seen.has(key))continue;
|
||||
seen.add(key);
|
||||
features.push({
|
||||
type:"Feature",
|
||||
id:String(feature.id==null?key:feature.id),
|
||||
geometry:feature.geometry,
|
||||
properties:{
|
||||
runtime_symbol:symbol,
|
||||
runtime_csp:csp,
|
||||
runtime_depth_text:runtimeHazardDepthText(props),
|
||||
runtime_depth_m:props.least_depth_m==null?"":String(props.least_depth_m),
|
||||
source_feature_id:feature.id==null?"":String(feature.id)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
source.setData({type:"FeatureCollection",features:features});
|
||||
}
|
||||
function addRuntimeHazardLayer(map){
|
||||
map.addSource(RUNTIME_HAZARD_SOURCE_ID,{
|
||||
type:"geojson",
|
||||
data:{type:"FeatureCollection",features:[]}
|
||||
});
|
||||
map.addLayer({
|
||||
id:"runtime-preview-hazard-symbols",
|
||||
type:"symbol",
|
||||
source:RUNTIME_HAZARD_SOURCE_ID,
|
||||
layout:{
|
||||
"icon-image":["get","runtime_symbol"],
|
||||
"icon-size":1,
|
||||
"icon-allow-overlap":true,
|
||||
"icon-ignore-placement":true,
|
||||
"text-field":["get","runtime_depth_text"],
|
||||
"text-size":11,
|
||||
"text-anchor":"center",
|
||||
"text-allow-overlap":true,
|
||||
"text-ignore-placement":true
|
||||
},
|
||||
paint:{
|
||||
"text-color":"#30383b",
|
||||
"text-halo-color":"rgba(216,237,245,0.7)",
|
||||
"text-halo-width":0.35
|
||||
}
|
||||
});
|
||||
setTimeout(refreshRuntimeHazards,0);
|
||||
}
|
||||
|
||||
function installRuntimePreviewLayers(map){
|
||||
removeRuntimePreviewLayers(map);
|
||||
if(!debugRuntimePreview.checked || !map.getSource("cm95"))return;
|
||||
runtimeRuleExecutor=new CM95S52Runtime.RuntimeExecutor(map,{settings:RUNTIME_DEFAULTS});
|
||||
runtimeRuleExecutor.install();
|
||||
}
|
||||
|
||||
function refreshRuntimeRuleExecutor(){
|
||||
if(runtimeRuleExecutor)runtimeRuleExecutor.refresh();
|
||||
}
|
||||
|
||||
const CA_COLOR = Object.freeze({
|
||||
OUTLW:"#070707",
|
||||
CHBLK:"#070707",
|
||||
CHBRN:"#b19139",
|
||||
CHMGD:"#c545c3",
|
||||
LITRD:"#f15469",
|
||||
LITGN:"#68e456",
|
||||
LITYW:"#f4da48"
|
||||
});
|
||||
const CSS_PX_PER_MM = 96 / 25.4;
|
||||
let lightArcFeatures=[];
|
||||
let lightArcDrawPending=false;
|
||||
|
||||
function numericProp(props,key,fallback=0){
|
||||
const value=Number(props && props[key]);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
function lightArcPoint(feature){
|
||||
const geom=feature && feature.geometry;
|
||||
if(!geom)return null;
|
||||
let coord=null;
|
||||
if(geom.type==="Point")coord=geom.coordinates;
|
||||
else if(geom.type==="MultiPoint" && geom.coordinates && geom.coordinates.length)coord=geom.coordinates[0];
|
||||
if(!Array.isArray(coord) || coord.length<2)return null;
|
||||
const lng=Number(coord[0]),lat=Number(coord[1]);
|
||||
if(!Number.isFinite(lng) || !Number.isFinite(lat))return null;
|
||||
return [lng,lat];
|
||||
}
|
||||
|
||||
function refreshLightArcFeatures(){
|
||||
if(runtimeRuleExecutor && runtimeRuleExecutor.caFeatures.length){
|
||||
lightArcFeatures=runtimeRuleExecutor.caFeatures.slice();
|
||||
scheduleLightArcDraw();
|
||||
return;
|
||||
}
|
||||
if(!leftMap || !leftMap.getSource("cm95") || !leftMap.isSourceLoaded("cm95")){
|
||||
lightArcFeatures=[];
|
||||
scheduleLightArcDraw();
|
||||
return;
|
||||
}
|
||||
const seen=new Set();
|
||||
const out=[];
|
||||
for(const feature of leftMap.querySourceFeatures("cm95",{sourceLayer:"navigation_marks"})){
|
||||
const p=feature.properties || {};
|
||||
const point=lightArcPoint(feature);
|
||||
if(!point)continue;
|
||||
let presentations=[];
|
||||
if(p.s52_light_presentations){
|
||||
try{
|
||||
const parsed=typeof p.s52_light_presentations==="string"
|
||||
? JSON.parse(p.s52_light_presentations)
|
||||
: p.s52_light_presentations;
|
||||
if(Array.isArray(parsed))presentations=parsed.filter(item=>item && typeof item==="object");
|
||||
}catch(err){
|
||||
console.warn("invalid s52_light_presentations",feature.id,err);
|
||||
}
|
||||
}
|
||||
if(presentations.length){
|
||||
presentations.forEach(function(presentation,index){
|
||||
if(!presentation.s52_arc_color)return;
|
||||
const key=[
|
||||
feature.id == null ? "" : feature.id,
|
||||
presentation.source_record_index == null ? index : presentation.source_record_index,
|
||||
point[0],point[1],
|
||||
presentation.s52_arc_color,presentation.s52_arc_start_deg,presentation.s52_arc_end_deg,
|
||||
presentation.s52_arc_radius_mm,presentation.s52_arc_sector_radius_mm
|
||||
].join("|");
|
||||
if(seen.has(key))return;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
id:String(feature.id == null ? "" : feature.id)+":light:"+index,
|
||||
geometry:feature.geometry,
|
||||
properties:presentation
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if(!p.s52_arc_color || p.s52_csp_resolved!=="LIGHTS05")continue;
|
||||
const key=[
|
||||
feature.id == null ? "" : feature.id,
|
||||
point[0],point[1],
|
||||
p.s52_arc_color,p.s52_arc_start_deg,p.s52_arc_end_deg,
|
||||
p.s52_arc_radius_mm,p.s52_arc_sector_radius_mm
|
||||
].join("|");
|
||||
if(seen.has(key))continue;
|
||||
seen.add(key);
|
||||
out.push(feature);
|
||||
}
|
||||
lightArcFeatures=out;
|
||||
scheduleLightArcDraw();
|
||||
}
|
||||
|
||||
function prepareLightArcCanvas(){
|
||||
const rect=leftCaOverlay.getBoundingClientRect();
|
||||
const dpr=window.devicePixelRatio || 1;
|
||||
const width=Math.max(1,Math.round(rect.width*dpr));
|
||||
const height=Math.max(1,Math.round(rect.height*dpr));
|
||||
if(leftCaOverlay.width!==width || leftCaOverlay.height!==height){
|
||||
leftCaOverlay.width=width;
|
||||
leftCaOverlay.height=height;
|
||||
}
|
||||
const ctx=leftCaOverlay.getContext("2d");
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
ctx.clearRect(0,0,rect.width,rect.height);
|
||||
return {ctx:ctx,width:rect.width,height:rect.height};
|
||||
}
|
||||
function canvasArcAngle(degrees,bearing){
|
||||
return (degrees + bearing - 90) * Math.PI / 180;
|
||||
}
|
||||
function drawCaArc(ctx,x,y,radius,startDeg,endDeg,bearing,color,width,alpha){
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
x,y,radius,
|
||||
canvasArcAngle(startDeg,bearing),
|
||||
canvasArcAngle(endDeg,bearing),
|
||||
false
|
||||
);
|
||||
ctx.strokeStyle=color;
|
||||
ctx.globalAlpha=alpha==null?1:alpha;
|
||||
ctx.lineWidth=width;
|
||||
ctx.lineCap="butt";
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawCaLeg(ctx,x,y,length,degrees,bearing){
|
||||
const angle=canvasArcAngle(degrees,bearing);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([4.5,2.3]);
|
||||
ctx.moveTo(x,y);
|
||||
ctx.lineTo(x+length*Math.cos(angle),y+length*Math.sin(angle));
|
||||
ctx.strokeStyle=CA_COLOR.CHBLK;
|
||||
ctx.globalAlpha=0.82;
|
||||
ctx.lineWidth=1.35;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
function caScaleMetrics(props,coord,canvasWidth,canvasHeight){
|
||||
let args=CM95S52Runtime.jsonValue(props.runtime_args_json,[]);
|
||||
if(args.length<8)args=[
|
||||
props.s52_arc_outline_color,numericProp(props,"s52_arc_outline_width",4),
|
||||
props.s52_arc_color,numericProp(props,"s52_arc_width",1),
|
||||
numericProp(props,"s52_arc_start_deg",0),numericProp(props,"s52_arc_end_deg",360),
|
||||
numericProp(props,"s52_arc_radius_mm",0),numericProp(props,"s52_arc_sector_radius_mm",0)
|
||||
];
|
||||
return CM95S52Runtime.caScaleMetrics(args,coord,{
|
||||
width:canvasWidth,height:canvasHeight,zoom:leftMap.getZoom(),pixelsPerMm:CSS_PX_PER_MM
|
||||
});
|
||||
}
|
||||
function drawLightArcFeature(ctx,feature,bearing,width,height){
|
||||
const props=feature.properties || {};
|
||||
const coord=lightArcPoint(feature);
|
||||
if(!coord)return;
|
||||
let point=null;
|
||||
try{point=leftMap.project(coord);}catch(err){console.warn("light arc project failed",feature.id,coord,err);return;}
|
||||
if(!point || !Number.isFinite(point.x) || !Number.isFinite(point.y))return;
|
||||
const args=CM95S52Runtime.jsonValue(props.runtime_args_json,[]);
|
||||
const metrics=caScaleMetrics(props,coord,width,height);
|
||||
const radius=metrics.radius;
|
||||
if(radius<=0)return;
|
||||
const sectorRadius=metrics.sectorRadius;
|
||||
const margin=Math.max(radius,sectorRadius)+12;
|
||||
if(point.x<-margin || point.x>width+margin || point.y<-margin || point.y>height+margin)return;
|
||||
const start=args.length>=8 ? Number(args[4]) : numericProp(props,"s52_arc_start_deg",0);
|
||||
let end=args.length>=8 ? Number(args[5]) : numericProp(props,"s52_arc_end_deg",360);
|
||||
if(end<=start)end+=360;
|
||||
const arcColor=CA_COLOR[args.length>=8?args[2]:props.s52_arc_color] || CA_COLOR.CHMGD;
|
||||
const outlineColor=CA_COLOR[args.length>=8?args[0]:props.s52_arc_outline_color] || CA_COLOR.OUTLW;
|
||||
// Keep the S-52 CA radius/angles intact, but map millimetre pen widths to a
|
||||
// lighter screen preview. Raw OUTLW=4 can otherwise dominate the chart.
|
||||
const visualArcWidth=Math.max(1.6,Math.min(3.0,metrics.arcWidth*0.90));
|
||||
const visualOutlineExtra=Math.max(2.6,Math.min(4.2,metrics.outlineWidth*0.38));
|
||||
drawCaArc(ctx,point.x,point.y,radius,start,end,bearing,outlineColor,visualArcWidth+visualOutlineExtra,0.88);
|
||||
drawCaArc(ctx,point.x,point.y,radius,start,end,bearing,arcColor,visualArcWidth,0.94);
|
||||
if(sectorRadius>0){
|
||||
drawCaLeg(ctx,point.x,point.y,sectorRadius,start,bearing);
|
||||
drawCaLeg(ctx,point.x,point.y,sectorRadius,end,bearing);
|
||||
}
|
||||
}
|
||||
|
||||
function drawLightArcs(){
|
||||
lightArcDrawPending=false;
|
||||
const prepared=prepareLightArcCanvas();
|
||||
if(!leftMap || !leftMap.getSource("cm95") || !leftMap.isSourceLoaded("cm95"))return;
|
||||
const bearing=leftMap.getBearing();
|
||||
for(const feature of lightArcFeatures){
|
||||
try{
|
||||
drawLightArcFeature(prepared.ctx,feature,bearing,prepared.width,prepared.height);
|
||||
}catch(err){
|
||||
console.warn("light arc draw skipped",feature && feature.id,err);
|
||||
}
|
||||
}
|
||||
}
|
||||
function scheduleLightArcDraw(){
|
||||
if(lightArcDrawPending)return;
|
||||
lightArcDrawPending=true;
|
||||
requestAnimationFrame(drawLightArcs);
|
||||
}
|
||||
|
||||
function applyDebugStyles(){
|
||||
setLayerVisibility(rightMap,"bathymetry-support",debugRefBathy.checked);
|
||||
setLayerVisibility(rightMap,"bathymetry-depth-labels",debugRefBathy.checked);
|
||||
for(const pair of [[leftMap,activeLeftStyleTemplate],[rightMap,activeRightStyleTemplate]]){
|
||||
const map=pair[0], template=pair[1];
|
||||
const depthLayers=((map.getStyle() || {}).layers || []).filter(function(layer){
|
||||
return layer.type==="line" && layer["source-layer"]==="depth_contour";
|
||||
});
|
||||
for(const layer of depthLayers){
|
||||
if(debugHighlightDepcnt.checked){
|
||||
map.setPaintProperty(layer.id,"line-color","#ff00ff");
|
||||
map.setPaintProperty(layer.id,"line-width",3);
|
||||
}else{
|
||||
if(layer.id==="runtime-preview-depcnt"){
|
||||
map.setPaintProperty(layer.id,"line-color",runtimeDepcntColorExpression());
|
||||
map.setPaintProperty(layer.id,"line-width",runtimeDepcntWidthExpression());
|
||||
continue;
|
||||
}
|
||||
const original=templateLayer(template,layer.id);
|
||||
if(original && original.paint && original.paint["line-color"]!==undefined){
|
||||
map.setPaintProperty(layer.id,"line-color",original.paint["line-color"]);
|
||||
}
|
||||
if(original && original.paint && original.paint["line-width"]!==undefined){
|
||||
map.setPaintProperty(layer.id,"line-width",original.paint["line-width"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const display=debugInspector.checked ? "" : "none";
|
||||
document.getElementById("left-inspector").style.display=display;
|
||||
document.getElementById("right-inspector").style.display=display;
|
||||
}
|
||||
|
||||
const leftMap = new maplibregl.Map({
|
||||
container:"left-map",style:emptyStyle(),center:START.center,zoom:START.zoom,bearing:0,pitch:0,attributionControl:false
|
||||
});
|
||||
const rightMap = new maplibregl.Map({
|
||||
container:"right-map",style:emptyStyle(),center:START.center,zoom:START.zoom,bearing:0,pitch:0,attributionControl:false
|
||||
});
|
||||
leftMap.addControl(new maplibregl.NavigationControl(),"top-right");
|
||||
rightMap.addControl(new maplibregl.NavigationControl(),"top-right");
|
||||
let syncing=false;
|
||||
let referenceStyleTemplate=null;
|
||||
let s52StyleTemplate=null;
|
||||
let activeLeftStyleTemplate=null;
|
||||
let activeRightStyleTemplate=null;
|
||||
let s52SpriteAtlasPromise=null;
|
||||
|
||||
async function loadS52SpriteAtlas(){
|
||||
if(s52SpriteAtlasPromise)return s52SpriteAtlasPromise;
|
||||
s52SpriteAtlasPromise=(async function(){
|
||||
const metaResponse=await fetch('/s52/sprite.json?v=current-release',{cache:'no-store'});
|
||||
if(!metaResponse.ok)throw new Error('S-52 sprite JSON HTTP '+metaResponse.status);
|
||||
const metadata=await metaResponse.json();
|
||||
const image=await new Promise(function(resolve,reject){
|
||||
const img=new Image();
|
||||
img.onload=function(){resolve(img);};
|
||||
img.onerror=function(){reject(new Error('S-52 sprite PNG load failed'));};
|
||||
img.src='/s52/sprite.png?v=current-release';
|
||||
});
|
||||
return {metadata:metadata,image:image};
|
||||
})();
|
||||
try{
|
||||
return await s52SpriteAtlasPromise;
|
||||
}catch(err){
|
||||
// Do not permanently cache a transient fetch/image failure.
|
||||
s52SpriteAtlasPromise=null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureS52SpriteImages(map){
|
||||
const atlas=await loadS52SpriteAtlas();
|
||||
const canvas=document.createElement('canvas');
|
||||
const ctx=canvas.getContext('2d',{willReadFrequently:true});
|
||||
let added=0;
|
||||
for(const [name,meta] of Object.entries(atlas.metadata)){
|
||||
if(map.hasImage(name))continue;
|
||||
const width=Number(meta.width)||0, height=Number(meta.height)||0;
|
||||
if(width<=0||height<=0)continue;
|
||||
canvas.width=width; canvas.height=height;
|
||||
ctx.clearRect(0,0,width,height);
|
||||
ctx.drawImage(atlas.image,Number(meta.x)||0,Number(meta.y)||0,width,height,0,0,width,height);
|
||||
const pixels=ctx.getImageData(0,0,width,height);
|
||||
map.addImage(name,pixels,{pixelRatio:Number(meta.pixelRatio)||1});
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function sanitizeReferenceStyle(raw){
|
||||
const style=JSON.parse(JSON.stringify(raw));
|
||||
const sources=style.sources || {};
|
||||
const removed=[];
|
||||
style.layers=(style.layers || []).filter(function(layer){
|
||||
if(!layer.source || sources[layer.source])return true;
|
||||
removed.push(layer.id + " -> " + layer.source);
|
||||
return false;
|
||||
});
|
||||
return {style:style,removed:removed};
|
||||
}
|
||||
|
||||
function cloneReferenceForLocal(referenceStyle){
|
||||
const style=JSON.parse(JSON.stringify(referenceStyle));
|
||||
const source=style.sources && style.sources.navsea_delivery;
|
||||
if(!source)throw new Error("参考 style 中找不到 sources.navsea_delivery");
|
||||
delete source.url;
|
||||
source.type="vector";
|
||||
source.tiles=[LOCAL_TILES];
|
||||
source.minzoom=0;
|
||||
source.maxzoom=12;
|
||||
source.bounds=[122.0,20.0,154.5,46.0];
|
||||
source.attribution="CM95 日本全国 S-52 B 阶段测试";
|
||||
style.name=(style.name || "NavSea") + " / Japan nationwide B test";
|
||||
return style;
|
||||
}
|
||||
|
||||
function prepareS52Style(raw){
|
||||
const style=JSON.parse(JSON.stringify(raw));
|
||||
const source=style.sources && style.sources.cm95;
|
||||
if(!source)throw new Error("完整 S-52 style 中找不到 sources.cm95");
|
||||
source.type="vector";
|
||||
source.tiles=[LOCAL_TILES];
|
||||
source.minzoom=0;
|
||||
source.maxzoom=12;
|
||||
source.bounds=[122.0,20.0,154.5,46.0];
|
||||
source.attribution="CM95 日本全国 S-52 B 阶段测试";
|
||||
// Dynamic setStyle() and MapLibre's automatic sprite loader are unreliable
|
||||
// in this comparison page. Register S-52 images explicitly after style.load.
|
||||
delete style.sprite;
|
||||
if(referenceStyleTemplate && referenceStyleTemplate.glyphs){
|
||||
style.glyphs=referenceStyleTemplate.glyphs;
|
||||
}
|
||||
style.name=(style.name || "CM95 S-52") + " / Full production debug";
|
||||
return style;
|
||||
}
|
||||
|
||||
async function ensureS52StyleTemplate(){
|
||||
if(s52StyleTemplate)return s52StyleTemplate;
|
||||
const response=await fetch("/s52/full-style.json?v=current-release",{cache:"no-store"});
|
||||
if(!response.ok)throw new Error("S-52 full style HTTP " + response.status);
|
||||
s52StyleTemplate=await response.json();
|
||||
return s52StyleTemplate;
|
||||
}
|
||||
|
||||
async function applyLeftStyleMode(){
|
||||
if(debugLeftS52.checked){
|
||||
setState(leftState,"加载完整 S-52 style…","");
|
||||
// Prefetch the atlas before setStyle so style.load only has to register
|
||||
// already-local pixels; this avoids transient fetch failures mid-style swap.
|
||||
await loadS52SpriteAtlas();
|
||||
const raw=await ensureS52StyleTemplate();
|
||||
const style=prepareS52Style(raw);
|
||||
activeLeftStyleTemplate=style;
|
||||
leftMap.setStyle(JSON.parse(JSON.stringify(style)));
|
||||
}else{
|
||||
if(!referenceStyleTemplate)return;
|
||||
const style=cloneReferenceForLocal(referenceStyleTemplate);
|
||||
activeLeftStyleTemplate=style;
|
||||
leftMap.setStyle(JSON.parse(JSON.stringify(style)));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComparisonStyles(){
|
||||
try{
|
||||
setState(rightState,"下载并检查参考 style…","");
|
||||
const response=await fetch(REFERENCE_STYLE,{cache:"no-store"});
|
||||
if(!response.ok)throw new Error("HTTP " + response.status);
|
||||
const raw=await response.json();
|
||||
const checked=sanitizeReferenceStyle(raw);
|
||||
const removedText=checked.removed.length
|
||||
? ";忽略无效 layer: " + checked.removed.join(", ")
|
||||
: "";
|
||||
referenceStyleTemplate=checked.style;
|
||||
activeRightStyleTemplate=checked.style;
|
||||
rightMap.setStyle(JSON.parse(JSON.stringify(checked.style)));
|
||||
await applyLeftStyleMode();
|
||||
setState(rightState,"参考 style 已加载" + removedText,"ok");
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
setState(rightState,"参考 style 加载失败:" + (err.message || err),"err");
|
||||
setState(leftState,"等待有效参考 style","err");
|
||||
}
|
||||
}
|
||||
|
||||
leftMap.on("style.load",async function(){
|
||||
const sources=Object.keys((leftMap.getStyle() || {}).sources || {});
|
||||
if(sources.includes("cm95")){
|
||||
try{
|
||||
await ensureS52SpriteImages(leftMap);
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
setState(leftState,"S-52 sprite 注册失败:"+(err.message||err),"err");
|
||||
}
|
||||
installRuntimePreviewLayers(leftMap);
|
||||
setState(leftState,"当前 Production / S-52 style" + (debugRuntimePreview.checked ? " + runtime preview" : ""),"ok");
|
||||
}else if(sources.includes("navsea_delivery")){
|
||||
setState(leftState,"当前 Production / extentfix style","ok");
|
||||
}
|
||||
applyDebugStyles();
|
||||
refreshRuntimeRuleExecutor();
|
||||
refreshLightArcFeatures();
|
||||
});
|
||||
rightMap.on("style.load",applyDebugStyles);
|
||||
leftMap.on("sourcedata",function(e){
|
||||
if(!e || e.sourceId!=="cm95")return;
|
||||
if(debugRuntimePreview.checked)updateRuntimeSafetyContour();
|
||||
if(e.isSourceLoaded){
|
||||
refreshRuntimeRuleExecutor();
|
||||
refreshLightArcFeatures();
|
||||
}
|
||||
});
|
||||
leftMap.on("idle",function(){
|
||||
refreshRuntimeRuleExecutor();
|
||||
refreshLightArcFeatures();
|
||||
});
|
||||
leftMap.on("render",scheduleLightArcDraw);
|
||||
leftMap.on("resize",scheduleLightArcDraw);
|
||||
loadComparisonStyles();
|
||||
|
||||
function cameraOf(map){
|
||||
const c=map.getCenter();
|
||||
return {center:[c.lng,c.lat],zoom:map.getZoom(),bearing:map.getBearing(),pitch:map.getPitch()};
|
||||
}
|
||||
function syncCamera(from,to){
|
||||
if(syncing || !syncToggle.checked)return;
|
||||
syncing=true;
|
||||
try{to.jumpTo(cameraOf(from));}finally{syncing=false;}
|
||||
}
|
||||
leftMap.on("move",function(){syncCamera(leftMap,rightMap);});
|
||||
rightMap.on("move",function(){syncCamera(rightMap,leftMap);});
|
||||
function updateReadout(map){
|
||||
const c=map.getCenter();
|
||||
centerReadout.textContent="z=" + map.getZoom().toFixed(2) + " " +
|
||||
c.lng.toFixed(5) + ", " + c.lat.toFixed(5) + " b=" + map.getBearing().toFixed(1) + "°";
|
||||
}
|
||||
leftMap.on("move",function(){updateReadout(leftMap);});
|
||||
rightMap.on("move",function(){updateReadout(rightMap);});
|
||||
updateReadout(leftMap);
|
||||
|
||||
document.getElementById("reset-btn").addEventListener("click",function(){
|
||||
leftMap.fitBounds(JAPAN_BOUNDS,{padding:36,duration:0});
|
||||
syncCamera(leftMap,rightMap);
|
||||
});
|
||||
|
||||
const REGION_PRESETS={
|
||||
hokkaido:{center:[141.10,43.05],zoom:7.0},
|
||||
tohoku:{center:[140.62,39.10],zoom:7.0},
|
||||
tokyo:{center:[139.78,35.43],zoom:9.0},
|
||||
ise:{center:[136.75,34.73],zoom:8.6},
|
||||
osaka:{center:[135.25,34.52],zoom:8.6},
|
||||
setouchi:{center:[133.68,34.25],zoom:7.5},
|
||||
fukuoka:{center:[130.18,33.50],zoom:8.8},
|
||||
nagasaki:{center:[129.84,32.77],zoom:9.0},
|
||||
okinawa:{center:[127.77,26.38],zoom:8.0}
|
||||
};
|
||||
document.getElementById('region-preset').addEventListener('change',function(e){
|
||||
const preset=REGION_PRESETS[e.target.value];
|
||||
if(!preset)return;
|
||||
leftMap.jumpTo({center:preset.center,zoom:preset.zoom,bearing:0,pitch:0});
|
||||
syncCamera(leftMap,rightMap);
|
||||
});
|
||||
|
||||
document.getElementById("debug-toggle-btn").addEventListener("click",function(){
|
||||
debugPanel.classList.toggle("collapsed");
|
||||
});
|
||||
document.getElementById("debug-collapse-btn").addEventListener("click",function(){
|
||||
debugPanel.classList.add("collapsed");
|
||||
});
|
||||
debugLeftS52.addEventListener("change",function(){
|
||||
applyLeftStyleMode().catch(function(err){
|
||||
console.error(err);
|
||||
setState(leftState,"左侧 S-52 style 切换失败:" + (err.message || err),"err");
|
||||
});
|
||||
});
|
||||
debugRuntimePreview.addEventListener("change",function(){
|
||||
if(leftMap.getSource("cm95")){
|
||||
installRuntimePreviewLayers(leftMap);
|
||||
setState(leftState,"当前 Production / S-52 style" + (debugRuntimePreview.checked ? " + runtime preview" : ""),"ok");
|
||||
applyDebugStyles();
|
||||
}
|
||||
});
|
||||
debugRefBathy.addEventListener("change",applyDebugStyles);
|
||||
debugHighlightDepcnt.addEventListener("change",applyDebugStyles);
|
||||
debugInspector.addEventListener("change",function(){
|
||||
applyDebugStyles();
|
||||
if(!debugInspector.checked){
|
||||
if(leftMarker){leftMarker.remove();leftMarker=null;}
|
||||
if(rightMarker){rightMarker.remove();rightMarker=null;}
|
||||
}
|
||||
});
|
||||
|
||||
function featureKey(f){
|
||||
const p=f.properties || {};
|
||||
return [
|
||||
f.layer && f.layer.id,
|
||||
f.sourceLayer || "",
|
||||
f.id == null ? "" : f.id,
|
||||
p.canonical_object_type || "",
|
||||
p.source_object_class || ""
|
||||
].join("|");
|
||||
}
|
||||
function summarize(f){
|
||||
const p=f.properties || {};
|
||||
const bits=[
|
||||
"layer=" + ((f.layer && f.layer.id) || "-"),
|
||||
"source-layer=" + (f.sourceLayer || "-"),
|
||||
"id=" + (f.id == null ? "-" : f.id),
|
||||
p.canonical_object_type ? "type=" + p.canonical_object_type : "",
|
||||
p.source_object_class ? "class=" + p.source_object_class : "",
|
||||
p.chart_label_text ? "label=" + p.chart_label_text : "",
|
||||
p.s52_runtime_csp ? "csp=" + p.s52_runtime_csp : "",
|
||||
p.s52_csp ? "s52=" + p.s52_csp : "",
|
||||
p.depth_min_m !== undefined ? "dmin=" + p.depth_min_m : "",
|
||||
p.depth_max_m !== undefined ? "dmax=" + p.depth_max_m : "",
|
||||
p.contour_depth_m !== undefined ? "contour=" + p.contour_depth_m : "",
|
||||
p.s52_hazard_depth_m !== undefined ? "hazard=" + p.s52_hazard_depth_m : ""
|
||||
].filter(Boolean);
|
||||
return bits.join(" ");
|
||||
}
|
||||
function inspect(map,lngLat,element){
|
||||
if(!map.loaded()){
|
||||
element.textContent="地图仍在加载。";
|
||||
return;
|
||||
}
|
||||
let point=null;
|
||||
try{point=map.project(lngLat);}catch(err){
|
||||
console.warn("inspector project failed",lngLat,err);
|
||||
element.textContent="投影坐标不可用。";
|
||||
return;
|
||||
}
|
||||
if(!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)){
|
||||
element.textContent="投影坐标不可用。";
|
||||
return;
|
||||
}
|
||||
const box=[[point.x-5,point.y-5],[point.x+5,point.y+5]];
|
||||
let features=[];
|
||||
try{features=map.queryRenderedFeatures(box);}catch(err){console.warn(err);}
|
||||
const unique=new Map();
|
||||
for(const f of features){
|
||||
const key=featureKey(f);
|
||||
if(!unique.has(key))unique.set(key,f);
|
||||
}
|
||||
const rows=Array.from(unique.values()).slice(0,24);
|
||||
element.classList.toggle("empty",rows.length===0);
|
||||
if(rows.length){
|
||||
element.textContent=lngLat.lng.toFixed(6) + ", " + lngLat.lat.toFixed(6) +
|
||||
"\n命中 " + unique.size + " 项(显示前 " + rows.length + " 项)\n" +
|
||||
rows.map(function(f,i){return String(i+1).padStart(2,"0") + ". " + summarize(f);}).join("\n");
|
||||
}else{
|
||||
element.textContent=lngLat.lng.toFixed(6) + ", " + lngLat.lat.toFixed(6) +
|
||||
"\n此位置没有命中已渲染 Feature。";
|
||||
}
|
||||
}
|
||||
|
||||
let leftMarker=null;
|
||||
let rightMarker=null;
|
||||
function compareAt(lngLat){
|
||||
if(!debugInspector.checked)return;
|
||||
if(leftMarker)leftMarker.remove();
|
||||
if(rightMarker)rightMarker.remove();
|
||||
leftMarker=new maplibregl.Marker().setLngLat(lngLat).addTo(leftMap);
|
||||
rightMarker=new maplibregl.Marker().setLngLat(lngLat).addTo(rightMap);
|
||||
inspect(leftMap,lngLat,document.getElementById("left-inspector"));
|
||||
inspect(rightMap,lngLat,document.getElementById("right-inspector"));
|
||||
}
|
||||
leftMap.on("click",function(e){compareAt(e.lngLat);});
|
||||
rightMap.on("click",function(e){compareAt(e.lngLat);});
|
||||
|
||||
function mapError(side,el,event){
|
||||
const err=event && event.error ? event.error : event;
|
||||
const msg=err && err.message ? err.message : String(err || "unknown");
|
||||
console.error(side,msg);
|
||||
if(msg.indexOf("404")<0)setState(el,side + "错误:" + msg,"err");
|
||||
}
|
||||
leftMap.on("error",function(e){mapError("左侧",leftState,e);});
|
||||
rightMap.on("error",function(e){mapError("右侧",rightState,e);});
|
||||
|
||||
window.addEventListener("resize",function(){
|
||||
leftMap.resize();
|
||||
rightMap.resize();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
199
web/japan_compare_server.py
Normal file
199
web/japan_compare_server.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
RELEASE_ID = "cm95-d1-62a85bd40d3a-ae61b8866e80"
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Japan nationwide S-52 PBF side-by-side comparison test server")
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=18889)
|
||||
parser.add_argument("--release-id", default=RELEASE_ID)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
web_root = Path(__file__).resolve().parent
|
||||
project_root = web_root.parent
|
||||
release_dir = project_root / "build" / "releases-s52-v2" / args.release_id
|
||||
pbf_root = release_dir / "pbf" / "delivery"
|
||||
report_root = release_dir / "reports" / "delivery"
|
||||
summary_path = release_dir / "production_run_summary_v1.json"
|
||||
html_path = web_root / "japan_compare.html"
|
||||
runtime_executor_path = web_root / "s52_runtime_executor.js"
|
||||
assets_root = project_root / "build" / "fukuoka_s52_web" / "site" / "assets"
|
||||
# Comparison style is compiled from the same S-52 resources for nationwide coverage.
|
||||
s52_style_path = (
|
||||
project_root / "build" / "s52_style_compiler_japan_compare_v1" / "style.s52.json"
|
||||
)
|
||||
s52_sprite_root = project_root / "build" / "s52_resources_v2" / "final"
|
||||
s52_symbol_metadata_path = (
|
||||
project_root / "build" / "s52_resources_v2" / "base" / "s52_symbol_metadata.json"
|
||||
)
|
||||
|
||||
required = [
|
||||
pbf_root,
|
||||
summary_path,
|
||||
html_path,
|
||||
runtime_executor_path,
|
||||
assets_root / "maplibre-gl.js",
|
||||
assets_root / "maplibre-gl.css",
|
||||
s52_style_path,
|
||||
s52_sprite_root / "s52-sprite-full.json",
|
||||
s52_sprite_root / "s52-sprite-full.png",
|
||||
s52_sprite_root / "s52-sprite-full@2x.json",
|
||||
s52_sprite_root / "s52-sprite-full@2x.png",
|
||||
s52_symbol_metadata_path,
|
||||
]
|
||||
missing = [str(path) for path in required if not path.exists()]
|
||||
if missing:
|
||||
raise SystemExit("Missing required path(s):\n" + "\n".join(missing))
|
||||
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
if summary.get("release_id") != args.release_id:
|
||||
raise SystemExit("Production summary release_id mismatch")
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def send_bytes(
|
||||
self, data: bytes, content_type: str, status: int = 200, *, cache_control: str = "no-cache"
|
||||
):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", cache_control)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
|
||||
if path in ("/", "/index.html", "/compare"):
|
||||
self.send_bytes(
|
||||
html_path.read_bytes(), "text/html; charset=utf-8", cache_control="no-store"
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/s52_runtime_executor.js":
|
||||
self.send_bytes(
|
||||
runtime_executor_path.read_bytes(),
|
||||
"application/javascript; charset=utf-8",
|
||||
cache_control="no-store",
|
||||
)
|
||||
return
|
||||
|
||||
if path == "/health":
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"release_id": args.release_id,
|
||||
"release_status": "TEST_READY_WITH_KNOWN_ISSUES",
|
||||
"source": "production_run_summary_v1.json (not a finalized manifest)",
|
||||
"jobs": summary.get("jobs"),
|
||||
"states": summary.get("states"),
|
||||
"pbf_file_count": summary.get("pbf_files"),
|
||||
"output_features": summary.get("output_features"),
|
||||
"known_failed_tiles": ["0/0/0", "1/1/0", "2/3/1", "3/7/2", "3/7/3"],
|
||||
"s52_runtime_contract": "NOT_VALIDATED_FOR_NATIONAL_RELEASE",
|
||||
}
|
||||
self.send_bytes(json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
|
||||
return
|
||||
|
||||
if path == "/release-manifest.json":
|
||||
self.send_error(404, "Nationwide release has no finalized manifest")
|
||||
return
|
||||
|
||||
if path == "/release-summary.json":
|
||||
self.send_bytes(summary_path.read_bytes(), "application/json; charset=utf-8")
|
||||
return
|
||||
|
||||
if path == "/s52/full-style.json":
|
||||
self.send_bytes(
|
||||
s52_style_path.read_bytes(),
|
||||
"application/json; charset=utf-8",
|
||||
cache_control="no-store",
|
||||
)
|
||||
return
|
||||
|
||||
if path in (
|
||||
"/s52/sprite.json",
|
||||
"/s52/sprite.png",
|
||||
"/s52/sprite@2x.json",
|
||||
"/s52/sprite@2x.png",
|
||||
):
|
||||
suffix = ".json" if path.endswith(".json") else ".png"
|
||||
scale = "@2x" if "@2x" in path else ""
|
||||
sprite_path = s52_sprite_root / f"s52-sprite-full{scale}{suffix}"
|
||||
content_type = "application/json; charset=utf-8" if suffix == ".json" else "image/png"
|
||||
self.send_bytes(sprite_path.read_bytes(), content_type)
|
||||
return
|
||||
|
||||
if path == "/s52/symbol-metadata.json":
|
||||
self.send_bytes(
|
||||
s52_symbol_metadata_path.read_bytes(),
|
||||
"application/json; charset=utf-8",
|
||||
)
|
||||
return
|
||||
|
||||
if path in ("/assets/maplibre-gl.js", "/assets/maplibre-gl.css"):
|
||||
filename = path.rsplit("/", 1)[-1]
|
||||
file_path = assets_root / filename
|
||||
content_type = "application/javascript; charset=utf-8" if filename.endswith(".js") else "text/css; charset=utf-8"
|
||||
self.send_bytes(file_path.read_bytes(), content_type)
|
||||
return
|
||||
|
||||
if path.startswith("/pbf/") and path.endswith(".pbf"):
|
||||
parts = path[len("/pbf/"):].split("/")
|
||||
if len(parts) != 3:
|
||||
self.send_error(404)
|
||||
return
|
||||
z, x, yfile = parts
|
||||
y = yfile[:-4]
|
||||
if not (z.isdigit() and x.isdigit() and y.isdigit()):
|
||||
self.send_error(404)
|
||||
return
|
||||
tile = pbf_root / z / x / yfile
|
||||
if not tile.is_file():
|
||||
report = report_root / z / x / f"{y}.json"
|
||||
if report.is_file():
|
||||
try:
|
||||
report_obj = json.loads(report.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
report_obj = {}
|
||||
if (
|
||||
int(report_obj.get("planned_output_feature_count") or 0) == 0
|
||||
and int(report_obj.get("output_feature_count") or 0) == 0
|
||||
and not (report_obj.get("hard_gate_unmapped") or [])
|
||||
and not (report_obj.get("encoder_dropped_features") or [])
|
||||
):
|
||||
self.send_bytes(b"", "application/vnd.mapbox-vector-tile", status=204)
|
||||
return
|
||||
self.send_error(404, "PBF tile not found")
|
||||
return
|
||||
self.send_bytes(tile.read_bytes(), "application/vnd.mapbox-vector-tile")
|
||||
return
|
||||
|
||||
self.send_error(404)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"{self.address_string()} - {fmt % args}")
|
||||
|
||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
print(f"Japan nationwide compare: http://{args.host}:{args.port}/")
|
||||
print(f"Release: {release_dir}")
|
||||
print(f"PBF: {pbf_root}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user