919 lines
36 KiB
HTML
919 lines
36 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>九州 S-52 PBF 左右对比</title>
|
||
<link rel="stylesheet" href="/assets/maplibre-gl.css" />
|
||
<style>
|
||
*{box-sizing:border-box}
|
||
html,body{height:100%;margin:0;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#111;color:#eee}
|
||
body{overflow:hidden}
|
||
#toolbar{height:46px;display:flex;align-items:center;gap:14px;padding:0 12px;background:#17191d;border-bottom:1px solid #3a3d43;font-size:13px}
|
||
#toolbar strong{font-size:14px;white-space:nowrap}
|
||
#toolbar .meta{color:#abb2bd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#toolbar button{border:1px solid #555;background:#272b31;color:#fff;border-radius:5px;padding:5px 9px;cursor:pointer}
|
||
#toolbar label{display:flex;gap:5px;align-items:center;white-space:nowrap}
|
||
#maps{height:calc(100% - 46px);display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#555}
|
||
.pane{position:relative;min-width:0;overflow:hidden;background:#d8edf5}
|
||
.map{position:absolute;inset:0}
|
||
.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">左:当前 Production 本地 PBF 右:extentfix 原始数据源</span>
|
||
<label><input id="sync-toggle" type="checkbox" checked />同步视图</label>
|
||
<button id="reset-btn" type="button">重置九州</button>
|
||
<button id="debug-toggle-btn" type="button">调试</button>
|
||
<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>左:九州当前 Production</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=attached-lights-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 KYUSHU_BOUNDS = [[128.0,29.5],[132.5,34.8]];
|
||
const START = {center:[130.25,32.15],zoom:6.6,bearing:0,pitch:0};
|
||
|
||
const leftState = document.getElementById("left-state");
|
||
const rightState = document.getElementById("right-state");
|
||
const centerReadout = document.getElementById("center-readout");
|
||
const syncToggle = document.getElementById("sync-toggle");
|
||
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;
|
||
|
||
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=5;
|
||
source.maxzoom=12;
|
||
source.bounds=[128.0,29.5,132.5,34.8];
|
||
source.attribution="CM95 九州当前 S-52 Production";
|
||
style.name=(style.name || "NavSea") + " / Kyushu current Production";
|
||
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=5;
|
||
source.maxzoom=12;
|
||
source.bounds=[128.0,29.5,132.5,34.8];
|
||
source.attribution="CM95 九州当前 S-52 Production";
|
||
style.sprite=location.origin + "/s52/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…","");
|
||
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",function(){
|
||
const sources=Object.keys((leftMap.getStyle() || {}).sources || {});
|
||
if(sources.includes("cm95")){
|
||
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();
|
||
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(KYUSHU_BOUNDS,{padding:36,duration: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>
|