Files
pbf/NavSea_Native_Pickup_ReactNative_MapLibre_实现说明.md

15 KiB
Raw Permalink Blame History

NavSea Native Pickup React Native + MapLibre 实现说明

最后更新:2026-04-09 适用项目:/root/sourceserver/pbf 目标前端:React Native + MapLibre Native

1. 文档目的

本文给前端团队一份可以直接实现的 pickup 方案。

目标不是再讨论抽象模型,而是回答下面几件事:

  1. 在 native MapLibre 中如何点击海图并拿到结果
  2. 当前 NavSea 20nm final 这条线应该怎么消费
  3. 哪些层应该能点,哪些层应屏蔽
  4. 点击空白水域时如何返回深度上下文
  5. 规则字典和解释器怎么接入

2. 当前正式口径

当前项目已经收口为以下原则:

  • 对象主键统一使用:
    • feature.id
  • feature.id 视为当前 fid 的 native 可消费形式
  • 当前 Karatsu 20nm final 不拆 query 数据源
  • pickup、渲染、CPA / 安全判断继续消费同一个 feature 的同一套属性
  • 只要用户看不到的东西,就不应该成为正式 pickup 的点击结果

3. 当前数据基线

当前固定视觉与 PBF 基线:

当前真实瓦片抽样结果已经确认:

  • feature.id 可直接作为主对象 id 使用
  • 当前 properties 是瘦载荷,但已经保留一批 pickup 必要字段

典型保留字段包括:

  • navigation_marks
    • display_code
    • canonical_object_type
    • chart_icon_image
    • chart_label_text
    • chart_label_subtext
    • light_color_code
    • light_sector_mode
    • name_ja
  • navigation_hazard_point
    • class_code
    • canonical_object_type
    • chart_icon_image
  • clearance_limit_point
    • class_code
    • clearance_height_m
    • chart_label_text
    • name_ja
  • depth_contour
    • class_code
    • least_depth_m
    • chart_label_text

4. Pickup 的结果类型

pickup 不只返回“对象”,而是返回三类结果之一。

4.1 object

点中了明确海图对象,例如:

  • 航标
  • 危险物
  • 引航站
  • 净空限制点
  • 锚地符号

返回:

  • 这个对象是什么
  • 它有哪些细节

4.2 info

点中了可见信息,例如:

  • 深度数字
  • 净空数字
  • 灯略记
  • 标签

返回:

  • 这条信息是什么
  • 它属于哪个对象

4.3 context

点的是空白水域,但这个位置本身有海图意义。

返回:

  • 当前深度上下文
  • 是否浅水
  • 最近等深线信息
  • 是否位于锚地/航路/危险区

说明:

  • 空白水域不应简单返回“未命中对象”
  • 应优先返回水深上下文

5. 当前推荐实现结构

前端实现建议直接采用:

对于 React Native 团队,推荐两种接法:

方案 A:直接复用解释器逻辑

  • 把现有 JS 解释器迁入 RN 工程
  • 把规则字典作为静态 JSON 资源加载

适合:

  • JS / TS 代码统一
  • Web compare 和 RN 共享判读逻辑

方案 B:规则字典保留,解释器改写为 TypeScript

  • 保留同一份 JSON 规则字典
  • RN 端直接使用仓库里的 TS 解释器,或以它为母版迁入工程

适合:

  • RN 工程更强调类型约束
  • 前端团队不想直接引用仓库脚本文件

建议优先使用方案 B。

6. MapLibre Native 查询方式

官方 MapLibre React Native 的 MapView 提供:

  • queryRenderedFeaturesAtPoint(point, [filter], [layerIDs])
  • queryRenderedFeaturesInRect(bbox, [filter], [layerIDs])

其中更推荐用于 pickup 的是:

  • queryRenderedFeaturesInRect

原因:

  • 海图符号小
  • 单点容易漏
  • 小范围矩形更稳

建议默认点击窗口:

  • 8px

例如:

  • 点击点 (x, y)
  • 查询框:
    • [x - 8, y - 8, x + 8, y + 8]

7. 交互原则

7.1 只点“看得见”的东西

用户通常不是在做数据检索,而是在解释当前画面。

因此:

  • 可见对象可以点
  • 可见信息可以点
  • 不可见辅助层不应可点

7.2 看到的表达可以是入口,返回结果必须归到主对象

例如:

  • 点到航标标签
  • 点到净空数字
  • 点到灯略记

可以作为 pickup 入口,但最终结果应尽量回到同一个主对象 fid。

7.3 空白水域优先返回海图上下文

当没有主对象命中时,不要立刻返回空。

应继续尝试:

  • 深度上下文
  • 水域分带
  • 最近等深线

8. 哪些层应参与 pickup

当前不建议在 RN 工程里手写 layer 名单。

统一做法是直接读取规则字典中的:

  • render_layer_groups.primary
  • render_layer_groups.area
  • render_layer_groups.info
  • render_layer_groups.ignore

这样当 style / pickup 规则更新时,前端无需同步改动硬编码数组。

8.1 主对象层

主对象层从规则字典读取:

  • pickupRules.render_layer_groups.primary

这组层是正式 pickup 主入口。

8.2 信息层

信息层从规则字典读取:

  • pickupRules.render_layer_groups.info

这些层允许点击,但应尽量回归到主对象解释。

8.3 可见区域层

可见区域层从规则字典读取:

  • pickupRules.render_layer_groups.area

这些层本身不是小图标,但它们是用户肉眼明确能看到的主表达。

典型包括:

  • 渔业/定置渔具面
  • 危险区面
  • 锚地区
  • 设施区
  • 桥区

对这类层,原则是:

  • 如果用户精确点在可见区域填充上,应优先返回该区域对象
  • 不应让附近的小点符号把它抢走

8.4 默认屏蔽层

默认屏蔽层从规则字典读取:

  • pickupRules.render_layer_groups.ignore

说明:

  • 这些层多为辅助表达
  • 用户通常不是想点它们本身
  • 其中包括 flare、arc、outline、support 与 background/container 类层

9. 推荐查询流程

推荐前端固定按 4 步做。

第一步:先做精确点查

使用:

  • queryRenderedFeaturesAtPoint(point, undefined, exactLayers)

其中:

  • exactLayers = [...renderGroups.area, ...renderGroups.primary, ...renderGroups.info]

目的:

  • 优先解释用户当前像素下真正看见的表达
  • 特别是:
    • 渔网斜线面
    • 危险区面
    • 锚地区
    • 设施区

如果精确点查命中:

  • 先保留渲染顺序最靠上的候选
  • 再按 feature.id 聚合并解释

第二步:精确点查未命中,再查主对象层小范围补捞

使用:

  • queryRenderedFeaturesInRect(bbox, undefined, pickupRules.render_layer_groups.primary)

如果命中:

  • 按 feature.id 聚合
  • 用规则字典解释
  • 返回主对象结果

说明:

  • 这一步主要用于补捞:
    • 很小的灯标
    • 浮标
    • 危险物点
    • 净空点

第三步:主对象层未命中,再查信息层

使用:

  • queryRenderedFeaturesInRect(bbox, undefined, pickupRules.render_layer_groups.info)

如果命中:

  • 按 feature.id 聚合
  • 若能回到同一主对象,则返回主对象结果
  • 同时标记:
    • result_type = info

第四步:仍未命中,则返回上下文

执行:

  • 位置深度上下文解释
  • 水域/浅水/危险区上下文解释

返回:

  • result_type = context

10. 推荐返回结构

10.1 object

{
  "result_type": "object",
  "fid": "4101640307",
  "source_layer_std": "navigation_marks",
  "geometry_type": "Point",
  "title": "若宮灯台",
  "object_domain": "navigation_mark",
  "object_class": "lighthouse",
  "object_subclass": "coastal_lighthouse_over_15m",
  "canonical_object_type": "coastal_lighthouse_over_15m",
  "details": [
    { "field": "display_code", "label": "表示用编号", "value": "30000004" },
    { "field": "light_color_code", "label": "灯色", "value": "white" },
    { "field": "light_sector_mode", "label": "灯弧模式", "value": "sector" }
  ],
  "render": {
    "source_layer_std": "navigation_marks",
    "render_hit_layers": ["nav-marks-harbor-lighthouses", "nav-mark-labels"],
    "chart_icon_image": "symbol-daytime-301",
    "chart_symbol_code": "lighthouse"
  }
}

10.2 info

{
  "result_type": "info",
  "fid": "2689086947",
  "source_layer_std": "depth_contour",
  "title": "2",
  "object_domain": "depth",
  "object_class": "depth_contour",
  "object_subclass": "depth_contour_line",
  "details": [
    { "field": "least_depth_m", "label": "最浅/深度(m)", "value": 2.0 }
  ]
}

10.3 context

{
  "result_type": "context",
  "context": {
    "depth_band": "2-5m",
    "nearest_contour_m": 5,
    "shallow_water": true,
    "in_hazard_area": false,
    "in_route_area": false
  }
}

11. 前端解释器接法

11.1 输入

输入给解释器的是:

  • queryRenderedFeatures... 返回的候选 feature 数组
  • 规则字典 JSON

11.2 当前解释器职责

解释器只做以下事:

  1. 从 feature 中读取:
    • feature.id
    • feature.sourceLayer
    • feature.layer.id
    • feature.properties
  2. 按 feature.id 聚合
  3. 使用规则字典匹配:
    • source_layer_std
  4. 结合规则字典里的:
    • interaction_role
    • pickup_priority
  5. 若前端传入点击经纬度:
    • click_lnglat 解释器会在同分候选之间优先选择距离点击点最近的对象
  6. 当同一点击同时命中区域对象与附近点对象时:
    • 优先尊重精确点查命中的可见区域表达
  7. 输出统一 pickup 结构

11.3 当前解释器不负责

解释器不负责:

  • 再造对象 identity
  • 替代 style 做渲染决策
  • 做最终放行审计

12. React Native 伪代码

import pickupRules from "./navsea-pickup-rules.v1.json";
import {
  getRenderLayerGroups,
  interpretRenderedFeatures,
} from "./navsea-pickup-interpreter";

type PickupResult =
  | { result_type: "object"; primary: any; candidates: any[] }
  | { result_type: "info"; primary: any; candidates: any[] }
  | { result_type: "context"; context: any };

async function runPickup(
  mapRef: any,
  screenPoint: [number, number],
  clickLngLat: { lng: number; lat: number },
  pickupRules: any
): Promise<PickupResult> {
  const radius = 8;
  const bbox: [number, number, number, number] = [
    screenPoint[0] - radius,
    screenPoint[1] - radius,
    screenPoint[0] + radius,
    screenPoint[1] + radius,
  ];
  const renderGroups = getRenderLayerGroups(pickupRules);
  const exactLayers = [
    ...renderGroups.area,
    ...renderGroups.primary,
    ...renderGroups.info,
  ];

  const exactFeatures =
    (await mapRef.current?.queryRenderedFeaturesAtPoint(
      screenPoint,
      undefined,
      exactLayers
    )) || [];

  if (exactFeatures.length > 0) {
    const interpreted = interpretRenderedFeatures(exactFeatures, pickupRules, {
      click_lnglat: clickLngLat,
    });
    const exactPrimary = interpreted.primary;
    if (exactPrimary) {
      return {
        result_type: exactPrimary.interaction_role === "info" ? "info" : "object",
        primary: exactPrimary,
        candidates: interpreted.candidates,
      };
    }
  }

  const primaryFeatures =
    (await mapRef.current?.queryRenderedFeaturesInRect(
      bbox,
      undefined,
      renderGroups.primary
    )) || [];

  if (primaryFeatures.length > 0) {
    const interpreted = interpretRenderedFeatures(primaryFeatures, pickupRules, {
      click_lnglat: clickLngLat,
    });
    return {
      result_type: "object",
      primary: interpreted.primary,
      candidates: interpreted.candidates,
    };
  }

  const infoFeatures =
    (await mapRef.current?.queryRenderedFeaturesInRect(
      bbox,
      undefined,
      renderGroups.info
    )) || [];

  if (infoFeatures.length > 0) {
    const interpreted = interpretRenderedFeatures(infoFeatures, pickupRules, {
      click_lnglat: clickLngLat,
    });
    return {
      result_type: "info",
      primary: interpreted.primary,
      candidates: interpreted.candidates,
    };
  }
  return {
    result_type: "context",
    context: await buildDepthContext(mapRef, screenPoint),
  };
}

13. 深度上下文实现建议

补充说明:

  • 对点状对象,如果不把 clickLngLat 传给解释器
  • 当同一小框里命中多个同类型、同分对象时
  • 解释器只能按原始返回顺序选一个
  • 这会导致:
    • popup 标题是对的类型
    • 但返回的对象坐标可能偏到附近另一根灯标/浮标

13.1 优先级

当对象/信息层都未命中时,按下面顺序尝试:

  1. 查深度数字或等深相关 label
  2. 查深度分带 / 水域层
  3. 查最近等深线
  4. 最后返回“无可解释上下文”

13.2 当前建议先做的最小版

最小版只需要先返回:

  • 最近 depth_contour
  • 当前是否落在浅水相关面层
  • 当前是否落在危险区 / 航路 / 锚地区

后续再升级成更细的:

  • depth_band
  • nearest_contour_m
  • shallow_water

14. 规则字典的更新规则

规则字典不是每次纯视觉微调都要更新。

必须同步更新的情况:

  • PBF 标准层变化
  • 某层关键分类字段变化
  • style 主渲染字段依赖变化
  • 新增正式 pickup 层
  • render layer 分组变化

也就是说:

  • 纯颜色、字号、间距微调,通常不需要改
  • 但只要改了“这一层怎么解释、怎么归类、怎么点”,就必须改
  • 前端若直接读取 render_layer_groups,通常不需要再手改 layer 白名单
  • 前端若已实现:
    • 精确点查 area + primary + info
    • 小范围补查 primary + info 则能明显减少“看起来像渔网,却返回附近灯标”的误判

15. 当前不建议做的事

当前前端不建议:

  • 直接查询所有 style 层
  • 让 flare / arc / outline 抢 primary
  • 点空白水域直接返回 null
  • 再造一套独立 query API 替代当前统一对象载荷
  • 在前端代码里硬写大量分支而不保留规则字典

16. 当前建议的落地顺序

建议前端团队按下面顺序实现:

  1. 接入规则字典 JSON
  2. 接入仓库内的 TS 解释器,或以它为母版迁入 RN 工程
  3. 先实现:
    • 精确点查 pickup
    • 主对象层补查 pickup
    • 信息层 pickup
  4. 再实现:
    • 空白水域深度上下文
  5. 最后把 compare 页和 RN 端解释结果做对齐抽查

17. 与后续 PBF 演进的关系

当前方案不要求增加 PBF 容量。

后续如果 builder 增补:

  • object_domain
  • object_class
  • object_subclass

前端仍可继续使用同一套结构,只需把规则字典从“主判读层”逐步降为“兼容补充层”。

18. 结论

当前最稳的 native pickup 方案是:

  • 继续使用当前 Karatsu 20nm final PBF/style
  • 使用可见层作为点击入口
  • 使用 feature.id 作为对象主键
  • 使用规则字典和解释器做本地判读
  • 对空白水域返回深度上下文

这样既不增加 PBF 容量,也能满足:

  • 用户点图标看细节
  • 用户点水域看水深上下文
  • 后续继续做 CPA / 安全判断

19. 参考