feat: add Kyushu S-52 PBF comparison web
This commit is contained in:
227
web/kyushu_compare.html
Normal file
227
web/kyushu_compare.html
Normal 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>
|
||||||
111
web/kyushu_compare_server.py
Normal file
111
web/kyushu_compare_server.py
Normal 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()
|
||||||
Reference in New Issue
Block a user