feat: add Kyushu S-52 PBF comparison web

This commit is contained in:
OpenAI Codex
2026-09-18 23:29:37 +08:00
parent 46beab7191
commit c557572d24
2 changed files with 338 additions and 0 deletions

227
web/kyushu_compare.html Normal file
View File

@@ -0,0 +1,227 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>九州 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:46px;display:flex;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% - 46px);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}
.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}
@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">左:Production v2 本地 PBF 右:extentfix 原始数据源</span>
<label><input id="sync-toggle" type="checkbox" checked />同步视图</label>
<button id="reset-btn" type="button">重置九州</button>
<span id="center-readout">--</span>
</div>
<div id="maps">
<section class="pane" id="left-pane">
<div id="left-map" class="map"></div>
<div class="pane-title"><b>左:九州 Production v2</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>
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 KYUSHU_BOUNDS = [[128.0,29.5],[132.5,34.8]];
const START = {center:[130.25,32.15],zoom:6.6,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");
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"}}]};
}
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:REFERENCE_STYLE,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;
function cloneReferenceForLocal(){
const style=JSON.parse(JSON.stringify(rightMap.getStyle()));
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=5;
source.maxzoom=12;
source.bounds=[128.0,29.5,132.5,34.8];
source.attribution="CM95 九州 S-52 Production Pipeline v2";
style.name=(style.name || "NavSea") + " / Kyushu Production v2";
return style;
}
rightMap.once("style.load",function(){
setState(rightState,"参考 style 已加载","ok");
try{
leftMap.setStyle(cloneReferenceForLocal());
}catch(err){
console.error(err);
setState(leftState,"左侧初始化失败:" + (err.message || err),"err");
}
});
leftMap.on("style.load",function(){
const sources=Object.keys((leftMap.getStyle() || {}).sources || {});
if(sources.includes("navsea_delivery"))setState(leftState,"Production v2 PBF 已接入","ok");
});
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(KYUSHU_BOUNDS,{padding:36,duration:0});
syncCamera(leftMap,rightMap);
});
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;
}
const point=map.project(lngLat);
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(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>

View File

@@ -0,0 +1,111 @@
#!/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-bbaaeafee1b4"
def parse_args():
parser = argparse.ArgumentParser(description="Kyushu S-52 PBF side-by-side comparison server")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=18888)
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"
manifest_path = release_dir / "release_manifest_v1.json"
html_path = web_root / "kyushu_compare.html"
assets_root = project_root / "build" / "fukuoka_s52_web" / "site" / "assets"
required = [pbf_root, manifest_path, html_path, assets_root / "maplibre-gl.js", assets_root / "maplibre-gl.css"]
missing = [str(path) for path in required if not path.exists()]
if missing:
raise SystemExit("Missing required path(s):\n" + "\n".join(missing))
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
class Handler(BaseHTTPRequestHandler):
def send_bytes(self, data: bytes, content_type: str, status: int = 200):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-cache")
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")
return
if path == "/health":
payload = {
"status": "ok",
"release_id": args.release_id,
"release_status": manifest.get("status"),
"pbf_file_count": manifest.get("pbf_file_count"),
"decoded_pbf_features": manifest.get("decoded_pbf_features"),
"s52_runtime_contract": (manifest.get("s52_runtime_contract") or {}).get("status"),
}
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_bytes(manifest_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():
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"Kyushu 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()