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

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