feat: enable Japan nationwide CM95 S-52 comparison page

This commit is contained in:
OpenAI Codex
2026-09-27 10:20:23 +08:00
parent c98929b72f
commit ed53864598
6 changed files with 1649 additions and 0 deletions

View 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"
]
}
]
}

View 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 或旧九州端口。

View 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、运行时及缩放来源。

View 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
View 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
View 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()