fix: preserve S-52 multi-light arcs

This commit is contained in:
OpenAI Codex
2026-09-19 15:56:37 +08:00
parent 472acd6938
commit 8ac774dff1
10 changed files with 471 additions and 12 deletions

View File

@@ -35,7 +35,7 @@ global_contract:
- TOWERS
- _extgn
rule: one semantic navigation_marks feature per same-cell relation20 group when LIGHTS + base component exist; all source
components remain traceable in engineering output
components remain traceable in engineering output; multi-LIGHTS groups preserve every resolved S-52 light presentation in s52_light_presentations
light_colour_observed_mapping:
1: white
3: red
@@ -575,7 +575,7 @@ rules:
derive: nav_icon_from_bcnlat_attributes
chart_symbol_code:
const: beacon
notes: 若同 cell + _cm93_relation20_raw 存在 LIGHTS,则合成为一个 navigation_marks Feature;不重复输出 LIGHTS。
notes: 若同 cell + _cm93_relation20_raw 存在 LIGHTS,则实体仍合成为一个 navigation_marks Feature;多 LIGHTS 的全部 S-52 呈现保存在 s52_light_presentations,不重复制造实体航标。
- rule_id: CM95-028
mapping_version: v1
cm95_object_class_id: 39
@@ -660,7 +660,7 @@ rules:
derive: nav_icon_from_bcnspp_attributes
chart_symbol_code:
const: beacon
notes: 若同 cell + _cm93_relation20_raw 存在 LIGHTS,则合成为一个 navigation_marks Feature;不重复输出 LIGHTS。
notes: 若同 cell + _cm93_relation20_raw 存在 LIGHTS,则实体仍合成为一个 navigation_marks Feature;多 LIGHTS 的全部 S-52 呈现保存在 s52_light_presentations,不重复制造实体航标。
- rule_id: CM95-033
mapping_version: v1
cm95_object_class_id: 13
@@ -781,7 +781,7 @@ rules:
derive: nav_icon_from_boyspp_attributes
chart_symbol_code:
const: buoy
notes: 若同 cell + _cm93_relation20_raw 存在 LIGHTS,则合成为一个 navigation_marks Feature;不重复输出 LIGHTS。
notes: 若同 cell + _cm93_relation20_raw 存在 LIGHTS,则实体仍合成为一个 navigation_marks Feature;多 LIGHTS 的全部 S-52 呈现保存在 s52_light_presentations,不重复制造实体航标。
- rule_id: CM95-040
mapping_version: v1
cm95_object_class_id: 96
@@ -1415,7 +1415,7 @@ rules:
derive: nav_icon_from_rtpbcn_attributes
chart_symbol_code:
const: beacon
notes: 若同 cell + _cm93_relation20_raw 存在 LIGHTS,则合成为一个 navigation_marks Feature;不重复输出 LIGHTS。
notes: 若同 cell + _cm93_relation20_raw 存在 LIGHTS,则实体仍合成为一个 navigation_marks Feature;多 LIGHTS 的全部 S-52 呈现保存在 s52_light_presentations,不重复制造实体航标。
- rule_id: CM95-078
mapping_version: v1
cm95_object_class_id: 142

View File

@@ -226,3 +226,32 @@ NavSea 正式消费 runtime PBF 前至少验证:
- runtime PBF 中不存在旧固定 `s52_is_safety_contour` / `s52_isolated_danger` 等结果;
- reference PBF 仍可用于 OpenCPN 对照;
- 同一设置、同一 Feature 输入必须得到确定性相同输出。
## 15. LIGHTS05 多呈现补充
同一个物理航标可能通过同一 `cell_id + _cm93_relation20_raw` 关联多条 `LIGHTS` 记录,例如不同颜色、不同扇区或不同灯质。
`navigation_marks` 仍保持“一座实体航标 = 一个主 Feature”,不得为了多个灯光记录复制实体 beacon / buoy / tower。
当同一 composite group 包含两条或以上 `LIGHTS` 时,Delivery PBF 必须增加:
- `s52_light_presentations`
该字段在 MVT 中保存为 JSON 字符串,解析后是数组。数组中每一项对应一条源 `LIGHTS`,保存该记录已经由同一 S-52 Lookup/CSP 解析出的 presentation facts,例如:
- `s52_csp_resolved=LIGHTS05`
- `s52_display_priority / s52_display_category`
- `s52_light_mode`
- `s52_light_symbol_code`
- `s52_arc_outline_color / width`
- `s52_arc_color / width`
- `s52_arc_start_deg / end_deg`
- `s52_arc_radius_mm / s52_arc_sector_radius_mm`
- `s52_light_label_text / s52_text_literal`
- `source_record_index`,仅用于同一 composite 内稳定区分各 LIGHTS presentation。
主 Feature 上原有的标量 LIGHTS 字段继续保留,用于向后兼容;它们代表当前确定性选择的主 LIGHTS presentation。Runtime 若发现 `s52_light_presentations`,必须以数组为完整集合,不得只消费主 Feature 上的单组标量字段。
`CA(...)` 的弧半径与 sector leg 半径是屏幕毫米单位,不是地理距离。Runtime renderer 负责按当前屏幕 DPI / pixel ratio 转换成屏幕像素,并按 S-52/OpenCPN 语义绘制 outline、彩色弧和 sector legs。不得把 CA 圆弧预烘焙成 PBF 地理几何。
Release finalize 在 Builder Contract `cm95pbf-builder/0.4.11-s52-multi-light-presentations` 起必须核对 build report 与 PBF 中 multi-light group / light presentation record 数量一致,并拒绝损坏、缺项或非 `LIGHTS05` 的 presentation 数组。

View File

@@ -0,0 +1,121 @@
# CM95 S-52 灯弧与多 LIGHTS 修复阶段报告 v1
日期:2026-09-19
## 1. 问题
九州左右对比中,OpenCPN 能显示灯弧/扇区,而 CM95 PBF 左侧只显示灯标主体和部分文字,灯弧缺失。
调查确认问题分为两个独立层次:
1. PBF 已保存单条 LIGHTS05 的 `CA(...)` 标量参数,但 Web/MapLibre 显示端没有实现 screen-space CA renderer。
2. 同一个 relation20 composite 若关联多条 LIGHTS,旧 builder 只选择“属性最丰富的一条”写入主 `navigation_marks`,其余 LIGHTS 被 consumed,导致部分灯弧/灯质在 Delivery PBF 中结构性丢失。
## 2. 数据调查
九州 AOI 原始数据库只读审计:
- navigation aid rows:8425
- composite groups:3049
- multi-LIGHTS groups:193
- multi-LIGHTS groups 内 LIGHTS records:403
- 每组最大 LIGHTS 数:4
- 分布:180 组 ×2、9 组 ×3、4 组 ×4
因此旧 composite 结构至少会丢失 210 条附加 LIGHTS presentation。
福冈附近实际例子:同一 TOWERS / relation group 同时存在两条 22Nm sector:
- source record 3077:SECTR1=246°,SECTR2=286°
- source record 3078:SECTR1=296°,SECTR2=140°
旧 PBF 只能保留其中一条。
## 3. CA renderer
现有 PBF 已有:
- `s52_arc_outline_color`
- `s52_arc_outline_width`
- `s52_arc_color`
- `s52_arc_width`
- `s52_arc_start_deg / end_deg`
- `s52_arc_radius_mm`
- `s52_arc_sector_radius_mm`
OpenCPN 当前源码确认 CA 半径使用 screen mm;sector leg 同样是 screen-space 半径,角度绘制按屏幕方向转换。
Web 对比页新增独立 Canvas overlay:
- 不改变 PBF 几何;
- 按 CSS px/mm 与 devicePixelRatio 绘制;
- outline + 彩色弧双层;
- sector 绘制虚线 legs;
- 随 MapLibre move/rotate/resize 重绘。
真实浏览器运行验证:测试视口读取到 7 个去重灯对象,其中 sector=4、all_round=3;overlay canvas 有 5698 个非透明绘制像素,确认实际进行了 screen-space 绘制。
## 4. 多 LIGHTS PBF Contract
Builder Contract 提升为:
`cm95pbf-builder/0.4.11-s52-multi-light-presentations`
实体语义保持不变:
- 一座物理航标仍只有一个 `navigation_marks` 主 Feature;
- 不重复制造 beacon / buoy / tower 实体。
当 composite group 中存在两条或以上 LIGHTS 时,主 Feature 新增:
`s52_light_presentations`
MVT 中为 JSON 字符串;解析后数组中的每一项对应一条源 LIGHTS,并保存该记录由同一 S-52 Lookup/CSP 已解析的 presentation facts,以及 `source_record_index` 用于组内稳定区分。
旧标量 LIGHTS presentation 继续保留用于向后兼容;Runtime 发现数组时以数组作为完整 presentation 集合。
## 5. 定向回归
目标位置约:130.78248E, 34.09885N。
新 builder 生成 z10/884/408:
- navigation_marks:1 个实体 Feature
- report multi_light_groups:1
- report light_presentation_records:2
该实体内部数组实际包含:
1. record 3077:显示角 66°→106°,原始 sector 246°→286°,radius=15mm,sector radius=25mm
2. record 3078:显示角 116°→320°,原始 sector 296°→140°,radius=15mm,sector radius=25mm
即原来被旧 composite 吃掉的第二条 sector 已恢复。
## 6. Style audit 与 Release gate
Style compiler 已支持解析 `s52_light_presentations`,multi-light Feature 存在数组时不再把主标量 CA 重复计数,而是逐 presentation 计入 `RUNTIME_CA`。
单 tile audit:
- resource contract:PASS
- hard_gate_failures:[]
- RUNTIME_CA.feature_count:2
另建立 2-tile production regression release:
- build:2 / 2 PASS
- finalize:PASS
- report multi-light groups:2
- PBF multi-light groups:2
- report light presentation records:4
- PBF light presentation records:4
- light presentation failure count:0
新 release gate 会拒绝损坏 JSON、少于两个 presentation、非 LIGHTS05 presentation、缺少 source_record_index,以及 report/PBF 数量不一致。
## 7. 当前状态与下一步
代码层已验证:
- CA runtime renderer:实际绘制
- multi-LIGHTS 数据保存:PASS
- style runtime CA audit:PASS
- production build/finalize regression:PASS
当前正式九州 release `cm95-d1-62a85bd40d3a-2b2e7d976a43` 仍是旧 Builder Contract 0.4.10,因此它本身仍缺少 multi-LIGHTS 数组。
下一步应基于 0.4.11 生成新的九州 production plan,从零生成新的完整 5065-tile release;新 release finalize 和 release-scoped style audit 全部 PASS 后,再把 18888 比较服务切换过去。旧已 finalize release 保留为历史基线,不在其 PBF 上做原地混合修补。

View File

@@ -1,2 +1,2 @@
__version__ = "0.4.10-s52-resource-contract"
BUILDER_CONTRACT_VERSION = "cm95pbf-builder/0.4.10-s52-resource-contract"
__version__ = "0.4.11-s52-multi-light-presentations"
BUILDER_CONTRACT_VERSION = "cm95pbf-builder/0.4.11-s52-multi-light-presentations"

View File

@@ -375,6 +375,8 @@ class Cm95TileBuilder:
"groups": 0,
"component_rows": 0,
"standalone_lights": 0,
"multi_light_groups": 0,
"light_presentation_records": 0,
"named_from_direct_source": 0,
"name_conflict_groups": 0,
},
@@ -462,6 +464,14 @@ class Cm95TileBuilder:
report["navigation_composites"]["standalone_lights"] += sum(
1 for o in nav_outputs if o.get("standalone_light")
)
report["navigation_composites"]["multi_light_groups"] += sum(
1 for o in nav_outputs
if o.get("composite") and int(o.get("light_record_count") or 0) > 1
)
report["navigation_composites"]["light_presentation_records"] += sum(
int(o.get("light_presentation_count") or 0)
for o in nav_outputs if o.get("composite")
)
report["navigation_composites"]["named_from_direct_source"] += sum(
1 for o in nav_outputs if o.get("name_source") == "direct_source"
)
@@ -1441,6 +1451,12 @@ class Cm95TileBuilder:
"feature": feature,
"composite": True,
"component_feature_ids": [m.feature_id for m in members],
"light_record_count": len(lights),
"light_presentation_count": (
len(lights)
if self.s52 is not None and len(lights) > 1
else 0
),
"name_source": name_meta["source"],
"name_conflict": name_meta["conflict"],
}
@@ -1571,6 +1587,27 @@ class Cm95TileBuilder:
),
}
# A physical navigation mark remains one delivery Feature, but one mark may
# own multiple LIGHTS records (for example separate white/red/green sectors).
# Preserve every resolved LIGHTS presentation so runtime CA/SY/TX rendering
# does not silently lose all but the richest record selected below.
if self.s52 is not None and len(lights) > 1:
presentations: list[dict[str, Any]] = []
for item in sorted(lights, key=lambda row: row.source_record_index):
light_props = self.s52.properties(
item.object_class,
item.geometry_type,
item.attrs,
)
presentation = {
key: value
for key, value in light_props.items()
if str(key).startswith("s52_")
}
presentation["source_record_index"] = item.source_record_index
presentations.append(presentation)
props["s52_light_presentations"] = presentations
# Name contract v1:
# - only source-owned OBJNAM/NOBJNM are eligible;
# - _texto proximity is never used;

View File

@@ -318,6 +318,7 @@ def main() -> None:
},
"s52_resource_contract": result.get("s52_resource_contract"),
"s52_runtime_contract": result.get("s52_runtime_contract"),
"s52_light_presentation_contract": result.get("s52_light_presentation_contract"),
"quantization_fallbacks": result.get("quantization_fallbacks"),
"quantization_omissions": result.get("quantization_omissions"),
"nav_name_conflict_audit": {

View File

@@ -80,6 +80,7 @@ DELIVERY_PROPERTY_ALLOWLIST = frozenset(
"s52_light_nominal_range_nm",
"s52_light_obscured",
"s52_light_label_text",
"s52_light_presentations",
"s52_light_rotation_deg",
"s52_light_sector_radius_mm",
"s52_light_symbol_code",

View File

@@ -295,6 +295,8 @@ def finalize_release(
quantization_omission_layers = Counter()
encoder_reconciled_candidates = 0
encoder_reconciled_tiles = 0
report_multi_light_groups = 0
report_light_presentation_records = 0
planned = 0
output = 0
report_layer_counts = Counter()
@@ -302,7 +304,10 @@ def finalize_release(
obj = json.loads(path.read_text(encoding="utf-8"))
hard_gate += [{"report": str(path.relative_to(release_dir)), **x} for x in obj.get("hard_gate_unmapped") or []]
encoder_dropped += [{"report": str(path.relative_to(release_dir)), **x} for x in obj.get("encoder_dropped_features") or []]
tile_name_conflicts = int((obj.get("navigation_composites") or {}).get("name_conflict_groups") or 0)
nav_summary = obj.get("navigation_composites") or {}
tile_name_conflicts = int(nav_summary.get("name_conflict_groups") or 0)
report_multi_light_groups += int(nav_summary.get("multi_light_groups") or 0)
report_light_presentation_records += int(nav_summary.get("light_presentation_records") or 0)
name_conflicts += tile_name_conflicts
if tile_name_conflicts:
nav_name_conflict_reports.append((path, tile_name_conflicts))
@@ -346,6 +351,12 @@ def finalize_release(
s52_runtime_leaks = Counter()
s52_runtime_fact_counts = Counter()
s52_runtime_unknown_fact_exceptions = Counter()
light_presentation_required = str(plan.get("builder_contract_version") or "").startswith(
"cm95pbf-builder/0.4.11-s52-multi-light-presentations"
)
pbf_multi_light_groups = 0
pbf_light_presentation_records = 0
light_presentation_failures: list[str] = []
s52_dynamic_final_fields = {
"s52_is_safety_contour",
"s52_sounding_prefix",
@@ -366,6 +377,40 @@ def finalize_release(
for feat in feats:
fid = feat.get("id")
props = feat.get("properties") or {}
raw_light_presentations = props.get("s52_light_presentations")
if raw_light_presentations not in (None, ""):
try:
light_presentations = (
json.loads(raw_light_presentations)
if isinstance(raw_light_presentations, str)
else raw_light_presentations
)
except Exception as exc:
light_presentation_failures.append(
f"{pbf_rel}:{layer}:{fid} invalid s52_light_presentations JSON: {type(exc).__name__}"
)
light_presentations = []
if not isinstance(light_presentations, list) or len(light_presentations) < 2:
light_presentation_failures.append(
f"{pbf_rel}:{layer}:{fid} multi-light presentation array must contain at least 2 items"
)
else:
pbf_multi_light_groups += 1
pbf_light_presentation_records += len(light_presentations)
for index, presentation in enumerate(light_presentations):
if not isinstance(presentation, dict):
light_presentation_failures.append(
f"{pbf_rel}:{layer}:{fid} light presentation {index} is not an object"
)
continue
if presentation.get("s52_csp_resolved") != "LIGHTS05":
light_presentation_failures.append(
f"{pbf_rel}:{layer}:{fid} light presentation {index} is not resolved LIGHTS05"
)
if "source_record_index" not in presentation:
light_presentation_failures.append(
f"{pbf_rel}:{layer}:{fid} light presentation {index} missing source_record_index"
)
if s52_runtime_enabled:
if props.get("s52_mariner_dependent") == 1:
s52_runtime_dependent_count += 1
@@ -468,6 +513,30 @@ def finalize_release(
if s52_runtime_leaks:
s52_runtime_failures.append(f"dynamic final state leaked into runtime PBF: {dict(s52_runtime_leaks)}")
if light_presentation_required:
if report_multi_light_groups != pbf_multi_light_groups:
light_presentation_failures.append(
"report/PBF multi-light group count mismatch: "
f"report={report_multi_light_groups} pbf={pbf_multi_light_groups}"
)
if report_light_presentation_records != pbf_light_presentation_records:
light_presentation_failures.append(
"report/PBF light presentation record count mismatch: "
f"report={report_light_presentation_records} pbf={pbf_light_presentation_records}"
)
s52_light_presentation_contract = {
"status": (
"PASS" if not light_presentation_failures else "FAIL"
) if light_presentation_required else "NOT_APPLICABLE",
"required": light_presentation_required,
"report_multi_light_groups": report_multi_light_groups,
"pbf_multi_light_groups": pbf_multi_light_groups,
"report_light_presentation_records": report_light_presentation_records,
"pbf_light_presentation_records": pbf_light_presentation_records,
"failure_count": len(light_presentation_failures),
"failure_examples": light_presentation_failures[:100],
}
s52_runtime_contract = {
"status": ("PASS" if not s52_runtime_failures else "FAIL") if s52_runtime_enabled else "NOT_APPLICABLE",
"enabled": s52_runtime_enabled,
@@ -606,6 +675,8 @@ def finalize_release(
failures.append("style_or_sprite_contract")
if s52_runtime_enabled and s52_runtime_contract.get("status") != "PASS":
failures.append("s52_runtime_contract")
if light_presentation_required and s52_light_presentation_contract.get("status") != "PASS":
failures.append("s52_light_presentation_contract")
result = {
"schema": RELEASE_SCHEMA,
@@ -627,6 +698,7 @@ def finalize_release(
"aoi": plan.get("aoi"),
"s52_resource_contract": s52_resource_contract,
"s52_runtime_contract": s52_runtime_contract,
"s52_light_presentation_contract": s52_light_presentation_contract,
"style_sha256": style_sha,
"sprite_json_sha256": sprite_sha,
"gap_disposition_sha256": gap_disposition_sha,

View File

@@ -179,6 +179,7 @@ USAGE_PRESENCE_FIELDS = (
*USAGE_VALUE_FIELDS,
"s52_text_literal",
"s52_light_label_text",
"s52_light_presentations",
"s52_light_rotation_deg",
"s52_text1_value",
"s52_text2_value",
@@ -186,6 +187,20 @@ USAGE_PRESENCE_FIELDS = (
)
def _parse_light_presentations(props: dict[str, Any]) -> list[dict[str, Any]]:
raw = props.get("s52_light_presentations")
if raw in (None, ""):
return []
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
return []
if not isinstance(raw, list):
return []
return [dict(item) for item in raw if isinstance(item, dict)]
def _scan_pbf_usage(path: Path) -> dict[str, Any]:
try:
import mapbox_vector_tile
@@ -251,7 +266,17 @@ def _scan_pbf_usage(path: Path) -> dict[str, Any]:
render_kind_counts["TXTE"][placement] += 1
if props.get("s52_csp_resolved") == "LIGHTS05" and "s52_text_literal" in props:
render_kind_counts["LIGHT_TX"][placement] += 1
if "s52_arc_color" in props:
light_presentations = _parse_light_presentations(props)
if light_presentations:
for presentation in light_presentations:
nested_placement = (
layer_name,
str(presentation.get("s52_display_priority") or priority),
str(presentation.get("s52_display_category") or category),
)
if presentation.get("s52_arc_color"):
render_kind_counts["RUNTIME_CA"][nested_placement] += 1
elif "s52_arc_color" in props:
render_kind_counts["RUNTIME_CA"][placement] += 1
for field in USAGE_PRESENCE_FIELDS:
if field not in props:

View File

@@ -17,6 +17,7 @@
#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}
@@ -61,6 +62,7 @@
<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 v2</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>
@@ -91,6 +93,7 @@ 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;
@@ -240,6 +243,172 @@ function installRuntimePreviewLayers(map){
setTimeout(updateRuntimeSafetyContour,0);
}
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;
if(geom.type==="Point")return geom.coordinates;
if(geom.type==="MultiPoint" && geom.coordinates && geom.coordinates.length)return geom.coordinates[0];
return null;
}
function refreshLightArcFeatures(){
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){
ctx.beginPath();
ctx.arc(
x,y,radius,
canvasArcAngle(startDeg,bearing),
canvasArcAngle(endDeg,bearing),
false
);
ctx.strokeStyle=color;
ctx.lineWidth=width;
ctx.lineCap="butt";
ctx.stroke();
}
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.lineWidth=2;
ctx.stroke();
ctx.restore();
}
function drawLightArcFeature(ctx,feature,bearing,width,height){
const props=feature.properties || {};
const coord=lightArcPoint(feature);
if(!coord)return;
const point=leftMap.project(coord);
const radius=numericProp(props,"s52_arc_radius_mm",0)*CSS_PX_PER_MM;
if(radius<=0)return;
const sectorRadius=numericProp(props,"s52_arc_sector_radius_mm",0)*CSS_PX_PER_MM;
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=numericProp(props,"s52_arc_start_deg",0);
let end=numericProp(props,"s52_arc_end_deg",360);
if(end<=start)end+=360;
const arcColor=CA_COLOR[props.s52_arc_color] || CA_COLOR.CHMGD;
const outlineColor=CA_COLOR[props.s52_arc_outline_color] || CA_COLOR.OUTLW;
const outlineWidth=numericProp(props,"s52_arc_outline_width",4);
const colorWidth=CSS_PX_PER_MM;
drawCaArc(ctx,point.x,point.y,radius,start,end,bearing,outlineColor,colorWidth+outlineWidth);
drawCaArc(ctx,point.x,point.y,radius,start,end,bearing,arcColor,colorWidth);
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.isStyleLoaded())return;
const bearing=leftMap.getBearing();
for(const feature of lightArcFeatures){
drawLightArcFeature(prepared.ctx,feature,bearing,prepared.width,prepared.height);
}
}
function scheduleLightArcDraw(){
if(lightArcDrawPending)return;
lightArcDrawPending=true;
requestAnimationFrame(drawLightArcs);
}
function applyDebugStyles(){
setLayerVisibility(rightMap,"bathymetry-support",debugRefBathy.checked);
setLayerVisibility(rightMap,"bathymetry-depth-labels",debugRefBathy.checked);
@@ -386,13 +555,17 @@ leftMap.on("style.load",function(){
setState(leftState,"Production v2 / extentfix style","ok");
}
applyDebugStyles();
refreshLightArcFeatures();
});
rightMap.on("style.load",applyDebugStyles);
leftMap.on("sourcedata",function(e){
if(e && e.sourceId==="cm95" && debugRuntimePreview.checked){
updateRuntimeSafetyContour();
}
if(!e || e.sourceId!=="cm95")return;
if(debugRuntimePreview.checked)updateRuntimeSafetyContour();
if(e.isSourceLoaded)refreshLightArcFeatures();
});
leftMap.on("idle",refreshLightArcFeatures);
leftMap.on("render",scheduleLightArcDraw);
leftMap.on("resize",scheduleLightArcDraw);
loadComparisonStyles();
function cameraOf(map){