test: keep MySQL PostGIS visual comparison page

This commit is contained in:
OpenAI Codex
2026-10-02 18:04:53 +08:00
parent a6dcd872bc
commit c62326c2dd
2 changed files with 166 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CM95 MySQL vs PostgreSQL PBF 对比</title>
<link rel="stylesheet" href="/assets/maplibre-gl.css">
<style>
*{box-sizing:border-box}html,body{height:100%;margin:0;background:#111;color:#eee;font-family:system-ui,-apple-system,"Segoe UI",sans-serif}
body{overflow:hidden}#bar{height:52px;display:flex;align-items:center;gap:12px;padding:0 12px;background:#17191d;border-bottom:1px solid #3b3f46;font-size:12px}
#bar strong{font-size:14px;white-space:nowrap}#bar button{border:1px solid #555;background:#272b31;color:#fff;border-radius:5px;padding:5px 9px;cursor:pointer}
#bar label{display:flex;gap:5px;align-items:center;white-space:nowrap}.muted{color:#aeb7c3}#readout{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap}
#maps{height:calc(100% - 52px);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}
.title{position:absolute;z-index:5;left:10px;top:10px;padding:7px 9px;border-radius:6px;background:rgba(18,20,24,.88);font-size:12px;line-height:1.4}
.title b{display:block;font-size:14px}.state{font-size:11px;color:#cbd2dc}.ok{color:#91e39b}.err{color:#ff8c8c}
.inspect{position:absolute;z-index:5;left:10px;right:10px;bottom:10px;max-height:28%;overflow:auto;padding:7px 9px;border-radius:6px;background:rgba(18,20,24,.9);font:11px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
#stats{position:absolute;z-index:10;top:62px;left:50%;transform:translateX(-50%);max-width:72%;padding:7px 10px;border-radius:6px;background:rgba(18,20,24,.9);font:11px/1.35 ui-monospace,SFMono-Regular,Consolas,monospace;pointer-events:none}
@media(max-width:820px){#maps{grid-template-columns:1fr;grid-template-rows:1fr 1fr}#bar .muted{display:none}#stats{max-width:94%}}
</style>
</head>
<body>
<div id="bar"><strong>CM95 PBF:MySQL vs PostgreSQL(北九州人工对比)</strong><span class="muted">同源数据 / 同一 S-52 style / 同步视图</span>
<label><input id="sync" type="checkbox" checked>同步</label><button id="reset">差异 Tile</button><button id="info">统计</button><span id="readout">--</span></div>
<div id="stats" hidden></div>
<div id="maps">
<section class="pane"><div id="mysql-map" class="map"></div><div class="title"><b>左:MySQL</b><span id="mysql-state" class="state">加载中…</span></div><div id="mysql-inspect" class="inspect">点击任一侧,同点检查 Feature。</div></section>
<section class="pane"><div id="pg-map" class="map"></div><div class="title"><b>右:PostgreSQL / PostGIS</b><span id="pg-state" class="state">加载中…</span></div><div id="pg-inspect" class="inspect">点击任一侧,同点检查 Feature。</div></section>
</div>
<script src="/assets/maplibre-gl.js"></script>
<script>
const BOUNDS=[[130.25390625,33.57801474614399],[130.4296875,33.7243396617476]], START={center:[130.341796875,33.65117720394579],zoom:11.25,bearing:0,pitch:0};
const sync=document.getElementById("sync"), readout=document.getElementById("readout"), stats=document.getElementById("stats");
let styleTemplate=null,syncing=false,m1=null,m2=null;
function state(id,text,ok=true){const e=document.getElementById(id);e.textContent=text;e.className="state "+(ok?"ok":"err")}
function blank(){return {version:8,sources:{},layers:[{id:"bg",type:"background",paint:{"background-color":"#d8edf5"}}]}}
function styleFor(kind){const s=JSON.parse(JSON.stringify(styleTemplate));const src=s.sources&&s.sources.cm95;if(!src)throw new Error("style 缺少 sources.cm95");
src.type="vector";src.tiles=[location.origin+"/"+kind+"/{z}/{x}/{y}.pbf"];src.minzoom=5;src.maxzoom=12;src.bounds=[128,29.5,132.5,34.8];delete src.url;s.sprite=location.origin+"/sprite";return s}
const mysqlMap=new maplibregl.Map({container:"mysql-map",style:blank(),...START,attributionControl:false});
const pgMap=new maplibregl.Map({container:"pg-map",style:blank(),...START,attributionControl:false});
mysqlMap.addControl(new maplibregl.NavigationControl(),"top-right");pgMap.addControl(new maplibregl.NavigationControl(),"top-right");
function camera(m){const c=m.getCenter();return {center:[c.lng,c.lat],zoom:m.getZoom(),bearing:m.getBearing(),pitch:m.getPitch()}}
function link(a,b){if(syncing||!sync.checked)return;syncing=true;try{b.jumpTo(camera(a))}finally{syncing=false}}
mysqlMap.on("move",()=>{link(mysqlMap,pgMap);showReadout(mysqlMap)});pgMap.on("move",()=>{link(pgMap,mysqlMap);showReadout(pgMap)});
function showReadout(m){const c=m.getCenter();readout.textContent="z="+m.getZoom().toFixed(2)+" "+c.lng.toFixed(5)+", "+c.lat.toFixed(5)}
document.getElementById("reset").onclick=()=>{mysqlMap.fitBounds(BOUNDS,{padding:36,duration:0});link(mysqlMap,pgMap)};
document.getElementById("info").onclick=()=>{stats.hidden=!stats.hidden};
async function load(){try{const [sr,ir]=await Promise.all([fetch("/style.json",{cache:"no-store"}),fetch("/api/info",{cache:"no-store"})]);styleTemplate=await sr.json();const info=await ir.json();
stats.textContent="MySQL: "+info.mysql.release+" | "+info.mysql.files+" tiles\nPostgreSQL: "+info.postgresql.release+" | "+info.postgresql.files+" tiles\nCommon tile hash: "+info.compare.identical+"/"+info.compare.common+" identical";
mysqlMap.setStyle(styleFor("mysql"));pgMap.setStyle(styleFor("postgres"));}catch(e){state("mysql-state",String(e),false);state("pg-state",String(e),false)}}
mysqlMap.on("style.load",()=>state("mysql-state","S-52 / MySQL PBF",true));pgMap.on("style.load",()=>state("pg-state","S-52 / PostgreSQL PBF",true));
function key(f){const p=f.properties||{};return [f.sourceLayer||"",f.id??"",p.canonical_object_type||"",p.source_object_class||""].join("|")}
function summary(f){const p=f.properties||{};return [f.sourceLayer||"-","id="+(f.id??"-"),p.canonical_object_type||"",p.source_object_class||"",p.chart_label_text||"",p.contour_depth_m!==undefined?"depth="+p.contour_depth_m:""].filter(Boolean).join(" ")}
function inspect(map,ll,id){const el=document.getElementById(id);if(!map.loaded()){el.textContent="地图加载中";return}const p=map.project(ll),box=[[p.x-5,p.y-5],[p.x+5,p.y+5]],u=new Map();
for(const f of map.queryRenderedFeatures(box)){if(!u.has(key(f)))u.set(key(f),f)}const a=[...u.values()].slice(0,24);el.textContent=ll.lng.toFixed(6)+", "+ll.lat.toFixed(6)+"\n命中 "+u.size+" 项\n"+a.map((f,i)=>String(i+1).padStart(2,"0")+". "+summary(f)).join("\n")}
function compare(ll){if(m1)m1.remove();if(m2)m2.remove();m1=new maplibregl.Marker().setLngLat(ll).addTo(mysqlMap);m2=new maplibregl.Marker().setLngLat(ll).addTo(pgMap);inspect(mysqlMap,ll,"mysql-inspect");inspect(pgMap,ll,"pg-inspect")}
mysqlMap.on("click",e=>compare(e.lngLat));pgMap.on("click",e=>compare(e.lngLat));
mysqlMap.on("error",e=>{const m=e?.error?.message||"";if(!m.includes("404"))state("mysql-state",m,false)});pgMap.on("error",e=>{const m=e?.error?.message||"";if(!m.includes("404"))state("pg-state",m,false)});
window.addEventListener("resize",()=>{mysqlMap.resize();pgMap.resize()});showReadout(mysqlMap);load();
</script>
</body></html>

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
def parse_args():
p=argparse.ArgumentParser(description="CM95 MySQL vs PostgreSQL PBF comparison server")
p.add_argument("--host",default="0.0.0.0")
p.add_argument("--port",type=int,default=18889)
p.add_argument("--mysql-pbf-root",required=True)
p.add_argument("--postgres-pbf-root",required=True)
p.add_argument("--mysql-release",default="mysql-current")
p.add_argument("--postgres-release",default="postgres-current")
return p.parse_args()
def sha256(path: Path) -> str:
h=hashlib.sha256()
with path.open("rb") as f:
for b in iter(lambda:f.read(1024*1024),b""):
h.update(b)
return h.hexdigest()
def pbf_inventory(root: Path):
return {str(p.relative_to(root)):p for p in root.rglob("*.pbf") if p.is_file()}
def main():
args=parse_args()
web=Path(__file__).resolve().parent
mysql_root=Path(args.mysql_pbf_root).resolve()
pg_root=Path(args.postgres_pbf_root).resolve()
html=web/"mysql_postgresql_compare_north_kyushu.html"
style=web/"resources/style.s52.json"
assets=web/"assets"
resources=web/"resources"
for p in [mysql_root,pg_root,html,style,assets/"maplibre-gl.js",assets/"maplibre-gl.css",resources/"sprite.json",resources/"sprite.png"]:
if not p.exists():
raise SystemExit(f"missing: {p}")
mi=pbf_inventory(mysql_root)
pi=pbf_inventory(pg_root)
common=sorted(set(mi)&set(pi))
identical=0
different=[]
for rel in common:
if mi[rel].stat().st_size==pi[rel].stat().st_size and sha256(mi[rel])==sha256(pi[rel]):
identical+=1
elif len(different)<50:
different.append(rel)
info={
"mysql":{"release":args.mysql_release,"files":len(mi),"root":str(mysql_root)},
"postgresql":{"release":args.postgres_release,"files":len(pi),"root":str(pg_root)},
"compare":{"common":len(common),"identical":identical,"different":len(common)-identical,"different_examples":different},
}
class Handler(BaseHTTPRequestHandler):
def send(self,data:bytes,ctype:str,status=200):
self.send_response(status)
self.send_header("Content-Type",ctype)
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 serve_file(self,path:Path,ctype:str):
if not path.is_file():
self.send_error(404)
return
self.send(path.read_bytes(),ctype)
def do_GET(self):
path=urlparse(self.path).path
if path in ("/","/index.html","/compare"):
return self.serve_file(html,"text/html; charset=utf-8")
if path=="/api/info":
return self.send(json.dumps(info,ensure_ascii=False,indent=2).encode(),"application/json; charset=utf-8")
if path=="/style.json":
return self.serve_file(style,"application/json; charset=utf-8")
if path in ("/assets/maplibre-gl.js","/assets/maplibre-gl.css"):
fn=path.rsplit("/",1)[-1]
ct="application/javascript; charset=utf-8" if fn.endswith(".js") else "text/css; charset=utf-8"
return self.serve_file(assets/fn,ct)
if path in ("/sprite.json","/sprite.png","/sprite@2x.json","/sprite@2x.png"):
fn=path[1:]
ct="application/json; charset=utf-8" if fn.endswith(".json") else "image/png"
return self.serve_file(resources/fn,ct)
for prefix,root in (("/mysql/",mysql_root),("/postgres/",pg_root)):
if path.startswith(prefix) and path.endswith(".pbf"):
rel=path[len(prefix):]
parts=rel.split("/")
if len(parts)!=3 or not all(x.isdigit() for x in [parts[0],parts[1],parts[2][:-4]]):
self.send_error(404); return
return self.serve_file(root/rel,"application/vnd.mapbox-vector-tile")
self.send_error(404)
def log_message(self,fmt,*args):
pass
print(json.dumps(info,ensure_ascii=False,indent=2))
print(f"Compare: http://{args.host}:{args.port}/")
ThreadingHTTPServer((args.host,args.port),Handler).serve_forever()
if __name__=="__main__":
main()