diff --git a/docs/报告/CM95_S52_ProductionPipelineV2与九州运行报告_v3.md b/docs/报告/CM95_S52_ProductionPipelineV2与九州运行报告_v3.md
new file mode 100644
index 0000000..a0b9397
--- /dev/null
+++ b/docs/报告/CM95_S52_ProductionPipelineV2与九州运行报告_v3.md
@@ -0,0 +1,171 @@
+# CM95 S-52 Production Pipeline v2 与九州运行报告 v3
+
+日期:2026-09-19
+
+## 1. 本阶段结论
+
+本阶段已完成九州工程 AOI 的 S-52 资源契约补全、MySQL 8 空间查询修复、全量 PBF 重新生产、Release finalize、release-scoped S-52 style 编译和 Web 对比页切换。
+
+最终 Release 状态为 **PASS**。本次 PASS 表示 Production Pipeline、AOI footprint、S-52 resource/runtime contract、PBF/report/decoded feature 对账和当前 release gate 均通过;不表示所有 ENC 语义待审项目已经清零。
+
+## 2. 权威输入与版本
+
+- OpenCPN S-52 参考 commit:`f7211b4e1874c453e37a7f190aa191c55a25c32e`
+- Builder contract:`cm95pbf-builder/0.4.10-s52-resource-contract`
+- Production pipeline:`cm95pbf-production/2.2-s52-resource-contract`
+- Lookup IR SHA-256:`f63cbc0d3c9317b1873c0b0c470a5e7371d1b6d8d58393da660f70b837a52eb7`
+- Resource contract SHA-256:`ebdce70cf8f67dff3ecdeecc8e8f161d9626153a860c69972d41612ed803ef1e`
+## 3. S-52 资源契约
+
+按当前 OpenCPN 定义统计,完整资源宇宙为:
+
+- SY:1018
+- AP:30
+- LC:59
+- Defined total:1107
+- Active reachable:SY 902 / AP 27 / LC 48 / total 977
+
+`build/s52_resources_v2/resource_contract.json` 最终为 **PASS**,defined missing=0,active missing=0。
+
+资源编译同时处理了 OpenCPN 重名 symbol 的 bitmap/vector 选择、真正 vector-only symbol、全部 vector AP、全部 LC,以及 OpenCPN 自身未定义资源引用的官方 fallback 语义;未人为伪造上游不存在的资源。
+
+## 4. AOI 与生产计划
+
+工程 AOI:`kyushu-main-nearby-v1`
+
+bbox:`128.0,29.5,132.5,34.8`
+
+Zoom 5–12 的完整计划数:
+
+- z5=2,z6=4,z7=12,z8=24
+- z9=70,z10=247,z11=962,z12=3744
+- total=5065
+
+Plan ID:`2b2e7d976a43df64da3d031c3863ab596469c7331fc990a4025238e6b9b0d0c9`
+
+Release ID:`cm95-d1-62a85bd40d3a-2b2e7d976a43`
+
+旧 incomplete release `cm95-d1-62a85bd40d3a-7cb5890160d2` 已在本次生产前整目录删除。新 release 从零建立,不做 migrate / hardlink / copy,也不复用旧 PBF。历史已 finalize 的 sparse release `cm95-d1-62a85bd40d3a-bbaaeafee1b4` 仅作为历史参考保留。
+
+## 5. MySQL 8 空间索引问题与修复
+
+实际生产数据库位于 NAS `192.168.200.230` 的 MySQL 8.0.45 Docker 实例,数据库为 `cm95_japan`;X220 本地 MySQL 不是本次生产数据源。
+
+原 geometry 列虽然数据实际为 SRID 4326,但列定义没有声明固定 SRID。MySQL 8 因此不把现有 SPATIAL index 作为查询候选,即使 SQL 使用 `FORCE INDEX(spx_*)`,执行计划仍为 `type=ALL / possible_keys=None`,每个 tile 会反复扫描几十万 geometry。
+
+隔离实验确认:将列声明改为 `... NOT NULL SRID 4326` 后,执行计划变为 `type=range` 并实际使用 `spx_*`。
+
+已修复:
+
+- `chart_area_geometry` + `spx_area_geom`
+- `chart_line_geometry` + `spx_line_geom`
+- `chart_point_geometry` + `spx_point_geom`
+- `chart_sounding_point` + `spx_sounding_geom`
+
+代表性查询修复后实测:AREA 约 0.31s、LINE 热缓存约 0.09s、POINT 约 0.02s、SOUNDING 约 0.03–0.07s。生产恢复后吞吐显著提升。
+
+仓库仍保留 MySQL 5.7 历史兼容 schema,本次没有把 `sql/001_schema_mysql57.sql` 强行改成 MySQL 8 专用列语法。后续若新建 MySQL 8 production 数据库,必须在投入 PBF 生产前同样确认四张空间表的列级 `SRID 4326` 与 `spx_*` 实际可用,避免回退到全表扫描。
+
+## 6. 九州 Production 最终结果
+
+生产任务最终为:
+
+- PASS:5065 / 5065
+- FAILED:0
+- Report:5065
+- 实体 PBF:5064
+- 合法显式空 tile:1
+- planned output features:476121
+- report output features:476121
+- decoded PBF features:476121
+
+唯一没有实体 PBF 的 tile 是 `12/3530/1648`。其 report 明确记录 `planned_output_feature_count=0`、`output_feature_count=0`,且无 hard gate、无 encoder drop,因此属于合法空瓦片,不应伪造空 PBF。
+
+Release gate 已调整为:AOI complete 要求每个计划 tile 都有 PASS report;非空 tile 必须有 PBF;经过明确验证的空 tile 可以不产生实体 PBF。
+
+最终 `tile_footprint_contract.status=PASS`,`materialized_or_empty_tile_count=5065`,missing non-empty PBF=[],unexpected PBF=[]。
+## 7. S-52 Runtime Contract
+
+本 release 使用:
+
+- point table:Paper
+- boundary table:Plain
+- mariner mode:runtime
+
+Final manifest:
+
+- `s52_resource_contract.status=PASS`
+- `s52_runtime_contract.status=PASS`
+- mariner-dependent features:286975
+- dynamic final leaks:`{}`
+- runtime failure count:0
+
+Runtime CSP 数量:
+
+- DEPARE01:47516
+- DEPCNT02:46189
+- OBSTRN04:45633
+- SOUNDG02:146728
+- WRECKS02:909
+
+因此 PBF 保存水深、危险物、sounding 等事实字段,而不会永久写死当前用户的 safety contour / safety depth / isolated danger 等动态最终结果。
+## 8. Release-scoped S-52 Style
+
+对新 release 的 5064 个实体 PBF 做 aggregate usage 编译后生成:
+
+`build/s52_style_compiler_release_2b2e7d976a43/style.s52.json`
+
+结果:
+
+- compiled layers:142
+- 使用范围:5064 PBF / 28 source layers / 476121 features
+- resource contract:PASS
+- hard gate failures:[]
+
+关键 FSHFAC、FSHHAV、SOUNDG 和 LOWACC/fallback 资源均可在生成 style 中找到。
+
+完整 sprite 使用 `build/s52_resources_v2/final/s52-sprite-full`,并补齐浏览器需要的 1x / 2x sprite JSON/PNG。
+
+## 9. Web 对比与实际 WebGL 验证
+
+比较服务已切换到新 Release,端口 `18888`。
+
+`/health` 当前返回新 release、release status PASS、PBF=5064、decoded features=476121、S-52 runtime contract PASS。
+浏览器实际加载验证已完成,而不是只检查 DOM:
+
+- release-scoped style:HTTP 200
+- sprite 1x/2x JSON/PNG:HTTP 200
+- 普通 PBF:HTTP 200
+- 合法空 tile:HTTP 204
+- 左右 MapLibre style 状态均为 OK
+- 左侧实际 `querySourceFeatures` / `queryRenderedFeatures` 有数据
+- 静态 LANDA fill 在截图中实际出现;其 0.92 opacity 混合色 `#CABD83` 有大量像素
+
+为验证 runtime PBF,本地比较页增加了可关闭的 runtime 默认阈值预览。默认值来自现有 `S52MarinerSettings`:shallow=2m、safety contour=3m、safety depth=3m、deep=6m。
+
+z11 近岸样本实测:
+
+- effective safety contour:5m
+- runtime DEPARE rendered:24
+- runtime DEPCNT rendered:35
+- runtime SOUNDG rendered:61
+- source SOUNDG facts:202
+
+实际截图已生成并检查。runtime preview 只读取 PBF facts,不修改 PBF;Hazard exact isolated-danger / symbol composition 仍明确留给正式 NavSea Runtime Adapter,不在 Web 调试页中伪造。
+## 10. 仍然保留的语义待审项
+
+Final manifest 的 `semantic_completeness` 仍为 **PENDING_REVIEW**:
+
+- observed gap variants:141
+- pending semantic review:112
+- pending semantic review feature rows:52828
+
+这部分是既有语义映射 backlog,与本次资源契约、PBF 技术生产和 runtime contract PASS 是不同维度。
+
+因此本阶段可以确认“技术生产链与 S-52 资源/runtime 契约闭环”,但不能描述为“全部 ENC/S-52 业务语义已经 100% 完成”。
+
+## 11. 最终结论
+
+九州完整 AOI 的新 S-52 runtime Release 已完成从零生产并 finalize PASS。MySQL 8 空间查询根因已确认并在当前 NAS 数据库修复,生产查询已真正使用 SPATIAL index。
+
+新 Release、完整 S-52 资源、release-scoped style、Web 对比服务和 runtime facts 预览已连通。后续工作可以基于该 Release 继续做语义 backlog 审查和正式 NavSea Runtime Adapter,而不需要重新设计本次 Production/Resource Contract 基线。
diff --git a/src/cm95pbf/release.py b/src/cm95pbf/release.py
index fb43f56..3aa436d 100644
--- a/src/cm95pbf/release.py
+++ b/src/cm95pbf/release.py
@@ -482,17 +482,54 @@ def finalize_release(
}
tile_enumeration_mode = str(plan.get("tile_enumeration_mode") or "coverage")
+ pbf_rel_paths = {
+ path.relative_to(pbf_root).as_posix()
+ for path in pbf_files
+ }
+ explicit_empty_tiles: list[str] = []
+ missing_nonempty_pbf_tiles: list[str] = []
+ report_tile_paths: set[str] = set()
+ for path in report_files:
+ obj = json.loads(path.read_text(encoding="utf-8"))
+ rel_pbf = path.relative_to(report_root).with_suffix(".pbf").as_posix()
+ report_tile_paths.add(rel_pbf)
+ if rel_pbf in pbf_rel_paths:
+ continue
+ planned_count = int(obj.get("planned_output_feature_count") or 0)
+ output_count = int(obj.get("output_feature_count") or 0)
+ hard_gate_count = len(obj.get("hard_gate_unmapped") or [])
+ encoder_drop_count = len(obj.get("encoder_dropped_features") or [])
+ if (
+ planned_count == 0
+ and output_count == 0
+ and hard_gate_count == 0
+ and encoder_drop_count == 0
+ ):
+ explicit_empty_tiles.append(rel_pbf)
+ else:
+ missing_nonempty_pbf_tiles.append(rel_pbf)
+
+ unexpected_pbf_tiles = sorted(pbf_rel_paths - report_tile_paths)
tile_footprint_contract = {
"status": "NOT_APPLICABLE",
"mode": tile_enumeration_mode,
"planned_tile_jobs": int(plan["job_count"]),
"pbf_file_count": len(pbf_files),
"report_file_count": len(report_files),
+ "explicit_empty_tile_count": len(explicit_empty_tiles),
+ "materialized_or_empty_tile_count": len(pbf_files) + len(explicit_empty_tiles),
+ "missing_nonempty_pbf_tiles": missing_nonempty_pbf_tiles,
+ "unexpected_pbf_tiles": unexpected_pbf_tiles,
}
if tile_enumeration_mode == "aoi-complete":
tile_footprint_contract["status"] = (
"PASS"
- if len(pbf_files) == len(report_files) == int(plan["job_count"])
+ if (
+ len(report_files) == int(plan["job_count"])
+ and len(pbf_files) + len(explicit_empty_tiles) == int(plan["job_count"])
+ and not missing_nonempty_pbf_tiles
+ and not unexpected_pbf_tiles
+ )
else "FAIL"
)
@@ -551,8 +588,8 @@ def finalize_release(
if tile_enumeration_mode == "aoi-complete":
if len(report_files) != int(plan["job_count"]):
failures.append("aoi_complete_report_count_mismatch")
- if len(pbf_files) != int(plan["job_count"]):
- failures.append("aoi_complete_pbf_count_mismatch")
+ if tile_footprint_contract["status"] != "PASS":
+ failures.append("aoi_complete_tile_footprint_mismatch")
if hard_gate:
failures.append("hard_gate_unmapped")
if encoder_dropped:
diff --git a/web/kyushu_compare.html b/web/kyushu_compare.html
index 9c4b740..e34e0ed 100644
--- a/web/kyushu_compare.html
+++ b/web/kyushu_compare.html
@@ -51,10 +51,12 @@
@@ -84,6 +86,8 @@ 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");
@@ -98,15 +102,170 @@ function emptyStyle(){
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"
+];
+let runtimeEffectiveSafetyContourM=null;
+
+function removeRuntimePreviewLayers(map){
+ for(const id of RUNTIME_LAYER_IDS){
+ if(map.getLayer(id))map.removeLayer(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 installRuntimePreviewLayers(map){
+ removeRuntimePreviewLayers(map);
+ if(!debugRuntimePreview.checked || !map.getSource("cm95"))return;
+ addRuntimeDepthAreaLayer(map);
+ addRuntimeContourLayer(map);
+ addRuntimeSoundingLayer(map);
+ setTimeout(updateRuntimeSafetyContour,0);
+}
+
function applyDebugStyles(){
setLayerVisibility(rightMap,"bathymetry-support",debugRefBathy.checked);
setLayerVisibility(rightMap,"bathymetry-depth-labels",debugRefBathy.checked);
- for(const map of [leftMap,rightMap]){
- if(map.getLayer("depth-contours")){
- map.setPaintProperty("depth-contours","line-color",
- debugHighlightDepcnt.checked ? "#ff00ff" : "rgba(136,152,139,1)");
- map.setPaintProperty("depth-contours","line-width",
- debugHighlightDepcnt.checked ? 3 : 1);
+ 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";
@@ -123,6 +282,10 @@ const rightMap = new maplibregl.Map({
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));
@@ -151,6 +314,47 @@ function cloneReferenceForLocal(referenceStyle){
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 Pipeline v2";
+ 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",{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…","");
@@ -161,8 +365,10 @@ async function loadComparisonStyles(){
const removedText=checked.removed.length
? ";忽略无效 layer: " + checked.removed.join(", ")
: "";
- rightMap.setStyle(checked.style);
- leftMap.setStyle(cloneReferenceForLocal(checked.style));
+ 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);
@@ -173,10 +379,20 @@ async function loadComparisonStyles(){
leftMap.on("style.load",function(){
const sources=Object.keys((leftMap.getStyle() || {}).sources || {});
- if(sources.includes("navsea_delivery"))setState(leftState,"Production v2 PBF 已接入","ok");
+ if(sources.includes("cm95")){
+ installRuntimePreviewLayers(leftMap);
+ setState(leftState,"Production v2 / S-52 style" + (debugRuntimePreview.checked ? " + runtime preview" : ""),"ok");
+ }else if(sources.includes("navsea_delivery")){
+ setState(leftState,"Production v2 / extentfix style","ok");
+ }
applyDebugStyles();
});
rightMap.on("style.load",applyDebugStyles);
+leftMap.on("sourcedata",function(e){
+ if(e && e.sourceId==="cm95" && debugRuntimePreview.checked){
+ updateRuntimeSafetyContour();
+ }
+});
loadComparisonStyles();
function cameraOf(map){
@@ -209,6 +425,19 @@ document.getElementById("debug-toggle-btn").addEventListener("click",function(){
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 v2 / S-52 style" + (debugRuntimePreview.checked ? " + runtime preview" : ""),"ok");
+ applyDebugStyles();
+ }
+});
debugRefBathy.addEventListener("change",applyDebugStyles);
debugHighlightDepcnt.addEventListener("change",applyDebugStyles);
debugInspector.addEventListener("change",function(){
diff --git a/web/kyushu_compare_server.py b/web/kyushu_compare_server.py
index 978c32f..0c75c33 100644
--- a/web/kyushu_compare_server.py
+++ b/web/kyushu_compare_server.py
@@ -7,7 +7,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
-RELEASE_ID = "cm95-d1-62a85bd40d3a-bbaaeafee1b4"
+RELEASE_ID = "cm95-d1-62a85bd40d3a-2b2e7d976a43"
def parse_args():
@@ -24,11 +24,25 @@ def main():
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"
manifest_path = release_dir / "release_manifest_v1.json"
html_path = web_root / "kyushu_compare.html"
assets_root = project_root / "build" / "fukuoka_s52_web" / "site" / "assets"
+ s52_style_path = project_root / "build" / "s52_style_compiler_release_2b2e7d976a43" / "style.s52.json"
+ s52_sprite_root = project_root / "build" / "s52_resources_v2" / "final"
- required = [pbf_root, manifest_path, html_path, assets_root / "maplibre-gl.js", assets_root / "maplibre-gl.css"]
+ required = [
+ pbf_root,
+ manifest_path,
+ html_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",
+ ]
missing = [str(path) for path in required if not path.exists()]
if missing:
raise SystemExit("Missing required path(s):\n" + "\n".join(missing))
@@ -66,6 +80,24 @@ def main():
if path == "/release-manifest.json":
self.send_bytes(manifest_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")
+ 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 in ("/assets/maplibre-gl.js", "/assets/maplibre-gl.css"):
filename = path.rsplit("/", 1)[-1]
file_path = assets_root / filename
@@ -85,6 +117,20 @@ def main():
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")