Files
CM95/web/japan_compare.html

995 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>