diff --git a/config/mappings/cm95_to_navsea_v1.yaml b/config/mappings/cm95_to_navsea_v1.yaml index f7f7319..890a1b6 100644 --- a/config/mappings/cm95_to_navsea_v1.yaml +++ b/config/mappings/cm95_to_navsea_v1.yaml @@ -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 diff --git a/docs/CM95_S52_NavSea运行时呈现契约_v1.md b/docs/CM95_S52_NavSea运行时呈现契约_v1.md index f897a39..261c670 100644 --- a/docs/CM95_S52_NavSea运行时呈现契约_v1.md +++ b/docs/CM95_S52_NavSea运行时呈现契约_v1.md @@ -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 数组。 diff --git a/docs/报告/CM95_S52_灯弧与多LIGHTS修复阶段报告_v1.md b/docs/报告/CM95_S52_灯弧与多LIGHTS修复阶段报告_v1.md new file mode 100644 index 0000000..4a68a7c --- /dev/null +++ b/docs/报告/CM95_S52_灯弧与多LIGHTS修复阶段报告_v1.md @@ -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 上做原地混合修补。 diff --git a/src/cm95pbf/__init__.py b/src/cm95pbf/__init__.py index 87b1952..65dc25f 100644 --- a/src/cm95pbf/__init__.py +++ b/src/cm95pbf/__init__.py @@ -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" diff --git a/src/cm95pbf/builder.py b/src/cm95pbf/builder.py index adbd7a4..72c8f15 100644 --- a/src/cm95pbf/builder.py +++ b/src/cm95pbf/builder.py @@ -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; diff --git a/src/cm95pbf/cli.py b/src/cm95pbf/cli.py index 7f0fc8e..c452947 100644 --- a/src/cm95pbf/cli.py +++ b/src/cm95pbf/cli.py @@ -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": { diff --git a/src/cm95pbf/mapping.py b/src/cm95pbf/mapping.py index 8799f8b..ceb3bca 100644 --- a/src/cm95pbf/mapping.py +++ b/src/cm95pbf/mapping.py @@ -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", diff --git a/src/cm95pbf/release.py b/src/cm95pbf/release.py index 3aa436d..3646e4c 100644 --- a/src/cm95pbf/release.py +++ b/src/cm95pbf/release.py @@ -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, diff --git a/tools/cm95_s52_style_compiler.py b/tools/cm95_s52_style_compiler.py index 46e4490..af08155 100755 --- a/tools/cm95_s52_style_compiler.py +++ b/tools/cm95_s52_style_compiler.py @@ -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: diff --git a/web/kyushu_compare.html b/web/kyushu_compare.html index e34e0ed..8b5a2b2 100644 --- a/web/kyushu_compare.html +++ b/web/kyushu_compare.html @@ -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 @@
+
左:九州 Production v2等待参考 style…
点击任一侧海图,对比同一位置的 Feature。
LOCAL
@@ -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){