Files
MapLibreParticleLab/docs/30_设计/MapLibre粒子ReactNative使用说明_v1.md
OpenAI Codex bcd020fc55
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
docs: close STEP-063 release validation
2026-09-09 15:07:36 +08:00

13 KiB
Raw Blame History

MapLibre 粒子 React Native 使用说明 v1

本文面向 NavSea / React Native 调用侧,说明如何通过正式包 @navsea/maplibre-particle-rn 使用 MapLibre Particle Android 能力。本文描述的是当前已验证的 13.4.1-particle.1 契约,不是 Weather Provider、下载服务或天气业务层设计。

1. 使用模型

React Native App 只直接依赖正式包:

@navsea/maplibre-particle-rn@13.4.1-particle.1

Android Native 由该包的 Expo/Gradle 接线从正式 Maven 仓库解析:

cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1

正式 Maven 仓库:https://pbf.jpcn.ccwu.cc/maven。

包会复用唯一的 @maplibre/maplibre-react-native@11.3.6 peer dependency,并把其 OpenGL Native SDK 请求替换为 Particle AAR。App 不应再并行引入第二份 MapLibre Native AAR、Maven Local AAR、源码路径 bridge 或 MapLibre Native 源码树。

以后升级粒子版本的目标流程是:升级 @navsea/maplibre-particle-rn 版本 → npm 安装 → Gradle 取得对应正式 AAR → 重新编译 App。

2. Expo / React Native 配置

app.json / app config 中加入正式 plugin:

{
  "expo": {
    "plugins": ["@navsea/maplibre-particle-rn/app.plugin"]
  }
}

React 侧统一从正式包导入,不直接从官方 MLRN 或旧 bridge 导入:

import {
  Camera,
  Map,
  WeatherRuntime,
  createParticleWeatherStyle,
  particleWeatherIds,
} from "@navsea/maplibre-particle-rn";

WeatherColor 与 RasterParticle 当前是 MapLibre Core style layer,不是 React 组件。测试或独立页面可直接使用 createParticleWeatherStyle();NavSea 有自己的正式 style 时,应把同样的 raster-array source、weather-color layer 和 raster-particle layer 合入宿主 style,而不是在 MapView 外再画一层 Canvas/WebView。

3. Map 生命周期与 WeatherRuntime

必须在 Map style 已完成加载后 attach Native Runtime,并在页面销毁时 detach:

const [reactTag, setReactTag] = useState<number | null>(null);

const onStyleReady = useCallback(async (event: unknown) => {
  const tag = findNodeHandle((event as { target?: unknown }).target as never);
  if (tag == null) return;
  await WeatherRuntime.attach(tag);
  setReactTag(tag);
}, []);

useEffect(() => () => {
  if (reactTag != null) void WeatherRuntime.detach(reactTag);
}, [reactTag]);

典型 Map:

<Map mapStyle={createParticleWeatherStyle()} onDidFinishLoadingStyle={onStyleReady}>
  <Camera initialViewState={{ center: [135, 32], zoom: 3.9 }} />
</Map>

WeatherRuntime.getState(tag) 返回 Native/Core 权威状态。至少两帧兼容的合法 Field 建立 initial window 后,Runtime 才进入 READY;React 不自行维护第二套 T0/T1/MIX Runtime Window。

4. 显示数据的总体结构

这是本接口最重要的部分。每一个时间片不是图片,也不是粒子坐标列表,而是一张规则二维风矢量场。一个 Field 由两部分组成:

metadataJson
+
local file:// binary payload

其中 metadata 描述时间、范围、网格和编码;binary payload 只保存每个网格点的 U/V 风矢量。

当前正式 schema:

maplibre-particle-field/v1

固定语义:

二维规则 grid
origin       = north-west
row order    = north-to-south
column order = west-to-east
storage      = row-major
layout       = interleaved-uv
encoding     = float32 little-endian
U            = eastward-positive,单位 m/s
V            = northward-positive,单位 m/s
每 cell       = 2 × float32 = 8 bytes

MapLibre 接收的是这张向量场;WeatherColor 根据 U/V 得到色场,RasterParticle 从同一 U/V Field 采样并推进 GPU 粒子。React 不需要提前生成每个粒子的经纬度或轨迹。

5. metadataJson 结构

当前真实 WeatherV2 T0 metadata 的核心结构如下:

{
  "schema": "maplibre-particle-field/v1",
  "fieldId": "ecmwf-hres-wind-2026080700",
  "fieldType": "WIND",
  "displayDomain": "GLOBAL",
  "generation": 1786060800000,
  "validTime": 1786060800000,
  "dataVersion": 1786060800000,
  "bounds": { "west": 112.5, "south": 0.0, "east": 157.5, "north": 55.77657301866769 },
  "grid": { "width": 513, "height": 769 },
  "vector": {
    "layout": "interleaved-uv",
    "encoding": "float32-le",
    "noData": -9999.0,
    "unit": "m/s",
    "origin": "north-west",
    "rowOrder": "north-to-south",
    "columnOrder": "west-to-east",
    "uPositive": "east",
    "vPositive": "north"
  },
  "payload": { "byteLength": 3155976 }
}

字段说明:

字段 含义 / 当前约束
schema 必须为 maplibre-particle-field/v1
fieldId Producer 提供的非空 Field 标识
fieldType 当前实现必须为 WIND
displayDomain 当前实现必须为 GLOBAL
generation 非 0 uint64;Producer 负责赋值,Core 不猜版本
validTime 非 0 uint64;同一序列使用同一时间基准并严格递增;当前 WeatherV2 使用 Unix 毫秒
dataVersion 可选 uint64,用于数据版本 / identity;当前真实 fixture 提供
bounds west < east、south < north,描述规则网格覆盖范围
grid.width/height 大于 0;总 cell 数=width × height
vector.layout 只支持 interleaved-uv
vector.encoding 只支持 float32-le
vector.noData 必须为有限数值;当前 fixture 为 -9999.0
payload.byteLength 必须严格等于 width × height × 8
stride 可省略;若提供,当前 interleaved UV 必须为 8 bytes

真实 fixture 还带 unit/origin/rowOrder/columnOrder/uPositive/vPositive、payload.sha256、source 等审计字段。当前 Native parser 强制读取的是 schema、identity、时间、bounds、grid、layout、encoding、noData 与 byteLength;额外审计字段不会替代上述 canonical 语义。

注意:当前 payload.sha256 可用于文件生产/发布审计,但 LocalParticleFieldFeed 目前只读取 payload.byteLength,不要把 sha256 写成 Core 已强制校验的安全门槛。

6. binary payload 的精确排列

二维索引:

cell = y * width + x

其中 x=0 是最西列,y=0 是最北行。payload 按 row-major 连续存储:先完整保存最北一行,再向南逐行保存。

每个 cell 占 8 bytes:

byteOffset = cell * 8
[byteOffset + 0 .. +3] = U,float32 little-endian
[byteOffset + 4 .. +7] = V,float32 little-endian

等价的 float 序列是:

U0, V0, U1, V1, U2, V2, ...

也就是:

velocity[2 * cell]     = U
velocity[2 * cell + 1] = V

U>0 表示向东,U<0 表示向西;V>0 表示向北,V<0 表示向南。noData 只要出现在某 cell 的 U 或 V 任一分量,该 cell 就标记为无效。NaN/Infinity 不是 noData:当前 Adapter 会直接拒绝整张 Field。

真实 WeatherV2 fixture:

width  = 513
height = 769
cells  = 513 × 769 = 394,497
bytes  = 394,497 × 8 = 3,155,976

所以 payload.byteLength 必须正好是 3155976;少 1 byte、多 1 byte 或 metadata 声明与实际文件不一致都会 fail-closed。

一个 2×2 示例:

北行:  NW(U=5,V=1)   NE(U=6,V=1)
南行:  SW(U=4,V=0)   SE(U=5,V=0)

payload 中的 float 顺序必须是:

5,1, 6,1, 4,0, 5,0

总长度为 2 × 2 × 8 = 32 bytes。

React/Node 侧若自己生成 canonical payload,可用 DataView.setFloat32(offset, value, true);最后一个参数必须为 true,明确写成 little-endian。业务层也可以从服务器取得已经生成好的 .f32 文件,不要求 JS 重新编码。

7. React 侧如何提交一帧

当前正式 bridge 不把大块 U/V 数据变成 JS number[]。React 侧准备好 metadata JSON 和本地文件 URI 后直接提交:

const result = await WeatherRuntime.submitWindField(
  reactTag,
  JSON.stringify(metadata),
  payloadFileUri, // 必须是 local file:// URI
);

测试 Host 使用 expo-asset 把随 App 打包的 .f32 materialize 到本地 cache:

const asset = Asset.fromModule(require("./field.f32"));
await asset.downloadAsync();
const payloadFileUri = asset.localUri;

正式 NavSea 不要求数据必须来自 Expo Asset。可以由天气业务层下载、转换或合成 canonical U/V Field,然后保存到 App 可读的本地文件,再把 file:// URI 交给 WeatherRuntime。Native 读取 bytes 后进入唯一的 RasterArraySource::submitHostField() / Adapter / Snapshot / Timeline 路径。

不要使用以下正式 transport:

number[]
JSON float array
base64
hex string
每 cell 一个 {lat,lon,u,v} 对象
React 每帧传粒子位置

这些都会扩大 JS/Native 开销并破坏当前已冻结的 binary Field 边界。

8. 多时间片与 Timeline

至少提交两个彼此兼容、时间递增的 Field,Core 才能建立 initial window:

T0 → ACCEPTED_PENDING_INITIAL_WINDOW
T1 → INITIAL_WINDOW_READY
T2 → ACCEPTED_NEXT_SNAPSHOT
T3 → ACCEPTED_NEXT_SNAPSHOT

随后:

const state = await WeatherRuntime.getState(reactTag);
// 预期 state.state === "READY"

同一时间序列必须保持相同的 grid 尺寸、bounds、noData、fieldType 和 displayDomain,validTime 必须使用同一时间基准并严格递增。当前真实 WeatherV2 验收使用 2026-08-07 00/06/12/18 四帧,每帧相隔 6 小时。

React 可以用:

await WeatherRuntime.setForecastTime(reactTag, targetTimestampMs);
await WeatherRuntime.setPlaybackState(reactTag, "PLAY");
await WeatherRuntime.setPlaybackSpeed(reactTag, "NORMAL");

但 Timeline cursor、T0/T1 选择、时间插值、Runtime Field Window 和 GPU Field 切换仍由 Core 管理;React 只发低频控制命令,不在 JS 中复制一套播放状态机。

9. 显示与视觉控制

当前公开控制包括:

setWeatherEnabled
setWeatherColorVisible
setParticleVisible
setTargetFps
setWeatherColorStops
setParticleColor
setTrailColor
setParticleCount
setParticleLineWidth
setParticleLength
setParticleOpacity
setTrailOpacity
setTrailFade
setWeatherColorOpacity

例如:

await WeatherRuntime.setWeatherColorVisible(reactTag, true);
await WeatherRuntime.setParticleVisible(reactTag, true);
await WeatherRuntime.setParticleCount(reactTag, 4096);
await WeatherRuntime.setParticleLineWidth(reactTag, 1.5);
await WeatherRuntime.setParticleOpacity(reactTag, 0.9);
await WeatherRuntime.setTrailOpacity(reactTag, 0.8);
await WeatherRuntime.setTrailFade(reactTag, 0.94);
await WeatherRuntime.setWeatherColorOpacity(reactTag, 0.62);

这些参数只控制显示,不改变 U/V canonical 数据结构。天气业务数据和渲染视觉参数必须分开管理。

10. 当前 Native 会拒绝哪些数据

当前 Adapter 采用 fail-closed。以下情况会拒绝 Field,而不是尽量猜测:

  • schema 不是 maplibre-particle-field/v1;
  • fieldId/fieldType/displayDomain 缺失或类型错误;
  • generation 或 validTime 为 0;
  • width/height 为 0;
  • west >= east 或 south >= north;
  • noData 不是有限数值;
  • layout 不是 interleaved-uv;
  • encoding 不是 float32-le;
  • stride 提供但不是 8;
  • metadata byteLength 不等于 width × height × 8;
  • 实际文件长度与 expected byte length 不一致;
  • payload 中包含 NaN/Infinity;
  • 当前 fieldType 不是 WIND;
  • 当前 displayDomain 不是 GLOBAL;
  • 时间序列不满足既有 compatibility / monotonicity 规则。

因此 React 侧不要通过容错转换掩盖 producer 错误。Producer/天气业务层应在提交前完成 canonical 化并保持 Field identity、时间和二进制长度稳定。

11. 当前已验证的真实数据基线

Package Consumer 已使用 ECMWF HRES WeatherV2 真实数据完成 Android 实机验证:4 帧 2026080700 / 06 / 12 / 18,每帧 513×769、3,155,976 bytes,bounds=112.5,0.0 → 157.5,55.77657301866769,transport=FILE_REF / FLOAT32_LE / INTERLEAVED_UV。用户确认正式 package Consumer APK 正常。

12. NavSea 侧责任边界

推荐保持以下分工:

React Native / NavSea 天气业务层
  负责:取数、Route/ETA、多地点/多时间合成、canonical U/V 生成、文件缓存、提交时机
        ↓ metadataJson + local file:// field
@navsea/maplibre-particle-rn
        ↓
MapLibre Native / Core
  负责:校验、Snapshot、Timeline、时间插值、空间采样、WeatherColor、Particle、GPU 资源与渲染

MapLibre Native 不理解 provider、HTTP、WeatherV2、Route、ETA 或业务缓存;React 不接管 GPU 粒子模拟和 Core Timeline。

13. 版本升级检查

每次升级 @navsea/maplibre-particle-rn 后,至少确认:

  1. npm 根层只有一份 @maplibre/maplibre-react-native,没有嵌套 native module;
  2. Gradle dependencyInsight 最终解析到对应 cc.navsea.maplibre:android-sdk-particle-opengl:<version>;
  3. 不出现旧 step061、Maven Local 或源码路径;
  4. Package Consumer 用真实 T0–T3 Field 达到 Runtime READY;
  5. WeatherColor 与 Particle 均正常显示,地图交互无异常;
  6. Consumer 通过后再升级 NavSea 的包版本。

正式参考实现:integration/react-native/MapLibreParticlePackageConsumerTestHost/。它的意义不是演示 UI,而是验证“正式 npm package → 正式 Maven AAR → 真实 canonical WeatherV2 → Core/Renderer”的完整消费链。