fix: preserve S-52 multi-light arcs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 数组。
|
||||
|
||||
121
docs/报告/CM95_S52_灯弧与多LIGHTS修复阶段报告_v1.md
Normal file
121
docs/报告/CM95_S52_灯弧与多LIGHTS修复阶段报告_v1.md
Normal 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 上做原地混合修补。
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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){
|
||||
|
||||
Reference in New Issue
Block a user