Files
MapLibreParticleLab/docs/30_设计/MapLibre粒子ReactNative使用说明_v2.md
OpenAI Codex f31c0ea157
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
docs: add React Native particle API guide v2
2026-09-10 07:37:24 +08:00

30 KiB
Raw Permalink Blame History

MapLibre 粒子 React Native 使用说明 v2

本文面向使用 @navsea/maplibre-particle-rn 的 React Native / Expo 调用方,描述当前 13.4.1-particle.1 的真实公共接口、参数结构、安装方式与调用顺序。

本文只说明已经存在的公开 API。@navsea/maplibre-particle-rn 会完整 re-export @maplibre/maplibre-react-native@11.3.6;上游 MapLibre React Native 的全部通用 Map API 不在本文重复展开,本文重点说明 NavSea Particle 新增能力。

1. 当前发布物

React Native 包名:

@navsea/maplibre-particle-rn

当前版本:

13.4.1-particle.1

当前采用静态 HTTPS tarball 发布,不使用 npm Registry,也不需要 .npmrc:

https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.1.tgz

Android Native AAR 独立位于 Maven 仓库:

cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1
https://pbf.jpcn.ccwu.cc/maven/

2. 安装

新 React Native / Expo 项目建议一次安装正式 tarball 和固定的 MapLibre React Native peer dependency:

npm install https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.1.tgz @maplibre/maplibre-react-native@11.3.6

安装后,package.json 中 @navsea/maplibre-particle-rn 可能记录为完整 HTTPS URL,这是当前预期行为。代码中仍然使用正式包名导入:

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

当前 peer dependency 契约:

@maplibre/maplibre-react-native = 11.3.6
react                            >= 19.1.0
react-native                     >= 0.80.0

不要同时再安装另一份自定义 MapLibre Native AAR、旧 STEP AAR、源码路径 bridge 或第二份 MLRN native module。

3. Expo 配置

Expo / Expo Prebuild 项目必须在 app.json 或 app config 中加入:

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

这个 plugin 会完成两件事:

  1. 向 Android Gradle 加入 https://pbf.jpcn.ccwu.cc/maven;
  2. 把官方 org.maplibre.gl:android-sdk-opengl 替换为 cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1,避免 APK 中同时进入官方 AAR 与 Particle AAR。

首次接入、plugin/AAR 版本变化或 Android Native 代码变化后,需要重新生成/编译 Android 原生工程。仅 JS/TS 逻辑变化时,已有包含正确 Native bridge/AAR 的 Debug APK 可以继续通过 Metro 验证新 JS。

Bare React Native 如果不使用 Expo config plugin,必须在宿主 Android Gradle 中等价完成 Maven repository 与 dependency substitution;不能只依赖 autolinking。

4. 公共导入入口

包根入口当前导出:

export * from "@maplibre/maplibre-react-native";
export { default as WeatherRuntime } from "./NativeWeatherRuntimeBridge";
export * from "./weatherV2";

因此调用方统一从一个入口导入即可:

import {
  Map,
  Camera,
  WeatherRuntime,
  WeatherV2HttpClient,
  loadWeatherV2WindIntoRuntime,
  decodeWeatherV2WindFrame,
  createParticleWeatherStyle,
  particleWeatherIds,
  type ColorStop,
  type RuntimeSnapshot,
  type WeatherV2WindContext,
  type LoadWeatherV2WindResult,
} from "@navsea/maplibre-particle-rn";

Map、Camera 以及其他通用 MapLibre React Native API 都来自上游 @maplibre/maplibre-react-native@11.3.6,只是由本包重新导出。本文不重新定义这些上游组件的全部 Props。

5. 固定 Weather 对象 ID

particleWeatherIds = {
  source: "rn-weather-fields",
  weatherColorLayer: "rn-weather-color",
  rasterParticleLayer: "rn-weather-particles",
};

WeatherRuntime.attach() 会按这三个固定 ID 查找 source/layer。正式宿主 style 如果不用 createParticleWeatherStyle(),也必须提供同名对象。

6. createParticleWeatherStyle()

签名:

createParticleWeatherStyle(
  overrides?: Record<string, unknown>,
): string

返回 MapLibre Style JSON 字符串。默认包含:

source: rn-weather-fields / raster-array / host-field-submission=true
layer : rn-weather-color / weather-color
layer : rn-weather-particles / raster-particle
底图  : GSI standard raster

最小调用:

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

overrides 当前是顶层浅覆盖,不是深度 merge。若传入 sources 或 layers,会覆盖 helper 原来的整个顶层字段,因此正式 NavSea style 更推荐显式把 Particle source/layers 合入宿主 style,而不是依赖复杂 overrides。

7. Map 生命周期与 reactTag

WeatherRuntime 是 per-MapView Native facade。所有方法第一个参数都是当前 MapView 的正整数 reactTag。必须在 Map style 完成加载以后取得 tag 并 attach。

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;

  const state = await WeatherRuntime.attach(tag);
  setReactTag(tag);
  console.log(state);
}, []);

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

Map:

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

attach() 前必须满足 style、rn-weather-fields、rn-weather-color、rn-weather-particles 都已存在,否则 Promise 会 reject。页面销毁时应调用 detach();MapView 自身从 Window detach 时 Native 也有兜底 dispose。

8. WeatherRuntime 公共类型

type RuntimeState =
  | "DISABLED"
  | "WAITING_FOR_FIELDS"
  | "READY"
  | "ERROR";

type PlaybackState = "PLAY" | "PAUSE";
type TargetFps = "AUTO" | "FPS_30" | "FPS_60";
type CommandResult = "ACCEPTED" | "REJECTED";

type RuntimeSnapshot = Readonly<{
  state: RuntimeState;
  playbackState: PlaybackState;
  targetFps: TargetFps;
  disposed: boolean;
}>;

Field 提交结果:

type FieldSubmitResult =
  | "REJECTED_SOURCE_MODE"
  | "REJECTED_ADAPTER"
  | "REJECTED_INCOMPATIBLE"
  | "REJECTED_NON_MONOTONIC"
  | "REJECTED_CAPACITY"
  | "ACCEPTED_PENDING_INITIAL_WINDOW"
  | "INITIAL_WINDOW_READY"
  | "ACCEPTED_NEXT_SNAPSHOT"
  | "DUPLICATE"
  | "UNKNOWN";

注意:Native 日志内部可能打印更详细的 C++ reason 名称;React API 应以这里的 FieldSubmitResult 字符串为准。

9. WeatherRuntime 方法总表

除 detach() 外,方法返回值均通过 Promise 返回。Bridge 参数错误、Map/style/runtime 不存在等情况会 Promise reject;Core 正常处理但拒绝业务命令时通常返回 "REJECTED" 或对应 FieldSubmitResult。

方法 参数 返回 用途
attach(reactTag) reactTag:number Promise<RuntimeSnapshot> 为当前 MapView 建立 WeatherRuntime
detach(reactTag) reactTag:number Promise<void> dispose 当前 MapView Runtime
getState(reactTag) reactTag:number Promise<RuntimeSnapshot> 读取 Native 权威状态
clearWindFields(reactTag) reactTag:number Promise<CommandResult> 清空 Host Field/Timeline
submitWindField(reactTag, metadataJson, payloadFileUri) tag + JSON + 本地文件 Promise<FieldSubmitResult> 提交一张 canonical 风场
setWeatherEnabled(reactTag, enabled) boolean Promise<CommandResult> 总开关
setWeatherColorVisible(reactTag, visible) boolean Promise<CommandResult> 场图显示开关
setParticleVisible(reactTag, visible) boolean Promise<CommandResult> 粒子显示开关
setTargetFps(reactTag, target) AUTO/FPS_30/FPS_60 Promise<CommandResult> Runtime 绘制 cadence
setForecastTime(reactTag, timestampMs) epoch ms Promise<CommandResult> seek 到目标预报时间
setPlaybackState(reactTag, playback) PLAY/PAUSE Promise<CommandResult> 播放/暂停 Timeline
setPlaybackSpeed(reactTag, speed) HALF/NORMAL/DOUBLE Promise<CommandResult> 0.5x/1x/2x 播放速度
setWeatherColorStops(reactTag, stops) ColorStop[] Promise<CommandResult> 风速→场图颜色
setParticleColor(...) RGBA Promise<CommandResult> 粒子颜色
setTrailColor(...) RGBA Promise<CommandResult> 尾迹颜色
setParticleCount(reactTag, count) 非负整数 Promise<CommandResult> 粒子数量
setParticleLineWidth(reactTag, value) finite number Promise<CommandResult> 粒子线宽
setParticleLength(reactTag, value) finite number Promise<CommandResult> 粒子长度
setParticleOpacity(reactTag, value) finite number Promise<CommandResult> 粒子透明度
setTrailOpacity(reactTag, value) finite number Promise<CommandResult> 尾迹透明度
setTrailFade(reactTag, value) finite number Promise<CommandResult> 尾迹衰减
setWeatherColorOpacity(reactTag, value) finite number Promise<CommandResult> 场图整体透明度

9.1 attach / detach / getState / clearWindFields

await WeatherRuntime.attach(reactTag);
await WeatherRuntime.getState(reactTag);
await WeatherRuntime.clearWindFields(reactTag);
await WeatherRuntime.detach(reactTag);

reactTag 必须是正整数并解析到当前 Fabric MLRNMapView。clearWindFields() 成功后 Runtime 回到 WAITING_FOR_FIELDS;已有 Timeline 和当前 Field 会被清空。

setWeatherEnabled(false) 会隐藏 WeatherColor/Particle 并清空 Field,Runtime 进入 DISABLED。重新 setWeatherEnabled(true) 后进入 WAITING_FOR_FIELDS,需要重新提交至少 T0/T1。

9.2 submitWindField()

const result = await WeatherRuntime.submitWindField(
  reactTag,
  metadataJson,
  payloadFileUri,
);

参数:

  • reactTag:正整数 MapView tag;
  • metadataJson:非空 UTF-8 JSON,Bridge 上限 1 MiB;
  • payloadFileUri:file:// URI,或 Android 可读的绝对本地路径;不能使用 HTTP URL;
  • payload 文件必须可读、非空,Bridge 上限 64 MiB。

正式数据路径不使用 JS number[]、base64 或逐 cell 对象。 严格时间序列典型返回:

T0 -> ACCEPTED_PENDING_INITIAL_WINDOW
T1 -> INITIAL_WINDOW_READY
T2 -> ACCEPTED_NEXT_SNAPSHOT
T3 -> ACCEPTED_NEXT_SNAPSHOT

至少 T0/T1 两张兼容 Field 建立 initial window 后,Runtime 才进入 READY。

9.3 显示开关

await WeatherRuntime.setWeatherEnabled(reactTag, true);
await WeatherRuntime.setWeatherColorVisible(reactTag, true);
await WeatherRuntime.setParticleVisible(reactTag, true);

语义区别:

  • setWeatherEnabled:整个 Weather Runtime 的总开关;关闭时同时清空 Field;
  • setWeatherColorVisible:只控制背景 WeatherColor 场图;
  • setParticleVisible:只控制运动粒子层。

9.4 时间与播放

await WeatherRuntime.setForecastTime(reactTag, timestampMs);
await WeatherRuntime.setPlaybackState(reactTag, "PLAY");
await WeatherRuntime.setPlaybackSpeed(reactTag, "NORMAL");
await WeatherRuntime.setTargetFps(reactTag, "AUTO");

timestampMs 必须是非负 Unix epoch 毫秒整数。播放速度允许 HALF、NORMAL、DOUBLE;Target FPS 允许 AUTO、FPS_30、FPS_60。React 只设置目标时间/播放状态,T0/T1 选择、MIX 和 GPU 时间插值由 Native/Core 管理。

9.5 WeatherColor 色阶

类型:

type ColorStop = Readonly<{
  speedMps: number;
  red: number;
  green: number;
  blue: number;
  alpha: number;
}>;

调用:

await WeatherRuntime.setWeatherColorStops(reactTag, [
  { speedMps: 0,  red: 0.08, green: 0.20, blue: 0.80, alpha: 0.72 },
  { speedMps: 8,  red: 0.00, green: 0.85, blue: 0.90, alpha: 0.72 },
  { speedMps: 16, red: 1.00, green: 0.85, blue: 0.05, alpha: 0.72 },
  { speedMps: 30, red: 0.90, green: 0.08, blue: 0.08, alpha: 0.72 },
]);

约束:

  • 当前 Core capacity 为最多 8 个 stop;至少 2 个;
  • speedMps 必须 finite 且严格递增,单位 m/s;
  • RGBA 必须 finite 且全部在 [0,1];
  • 两个 stop 之间由 Shader 做线性颜色插值;
  • 小于第一个 stop 使用第一个颜色,大于最后一个 stop 使用最后一个颜色。

例如若 0 m/s 与 2 m/s 两个 stop 使用相同 RGBA,则 0~2 m/s 会保持同色。

9.6 粒子与尾迹视觉参数

await WeatherRuntime.setParticleColor(reactTag, 1, 1, 1, 0.95);
await WeatherRuntime.setTrailColor(reactTag, 0.35, 0.85, 1, 0.8);
await WeatherRuntime.setParticleCount(reactTag, 4096);
await WeatherRuntime.setParticleLineWidth(reactTag, 1.5);
await WeatherRuntime.setParticleLength(reactTag, 0.012);
await WeatherRuntime.setParticleOpacity(reactTag, 0.9);
await WeatherRuntime.setTrailOpacity(reactTag, 0.8);
await WeatherRuntime.setTrailFade(reactTag, 0.94);
await WeatherRuntime.setWeatherColorOpacity(reactTag, 0.62);

硬性 Bridge 参数约束:

  • setParticleColor / setTrailColor:RGBA 各分量 [0,1];
  • setParticleCount:0 到 Android Int.MAX_VALUE 的整数;
  • line width / length / opacity / fade / WeatherColor opacity:Bridge 要求 finite number,最终是否接受仍由 Native style property 决定。

上面的 4096 / 1.5 / 0.012 / 0.9 / 0.8 / 0.94 / 0.62 是当前 Consumer Test Host 的验证值,不是所有参数的固定默认契约。

WeatherColor 色阶、粒子颜色、尾迹颜色是三套独立视觉设置:

setWeatherColorStops -> 背景风速场颜色
setParticleColor     -> 粒子本体颜色
setTrailColor        -> 粒子尾迹颜色

10. canonical Wind Field 数据结构

MapLibre Particle 接收规则二维 U/V 向量场,而不是图片或粒子坐标数组。每个时间片由:

metadataJson + local binary payload file

组成。 当前 canonical schema:

maplibre-particle-field/v1

固定向量语义:

origin       = north-west
rowOrder     = north-to-south
columnOrder  = west-to-east
storage      = row-major
layout       = interleaved-uv
encoding     = float32-le
U            = eastward-positive, m/s
V            = northward-positive, m/s
noData       = -9999(当前 WeatherV2 converter)
每 cell       = U float32 + V float32 = 8 bytes

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, "east": 157.5, "north": 55.77657301866769 },
  "grid": { "width": 513, "height": 769 },
  "vector": {
    "layout": "interleaved-uv",
    "encoding": "float32-le",
    "noData": -9999,
    "unit": "m/s",
    "origin": "north-west",
    "rowOrder": "north-to-south",
    "columnOrder": "west-to-east",
    "uPositive": "east",
    "vPositive": "north"
  },
  "payload": { "byteLength": 3155976 }
}

二进制排列:

cell = y * width + x
byteOffset = cell * 8
[offset + 0..3] = U float32 little-endian
[offset + 4..7] = V float32 little-endian

float sequence = U0,V0,U1,V1,U2,V2,...

payload.byteLength 必须严格等于 width × height × 8。有效 U/V 不能是 NaN/Infinity;当前 WeatherV2 converter 对 invalid cell 把 U/V 写为 -9999。

同一 Timeline 内,Field 必须保持兼容 grid/bounds/语义,并且 validTime、generation 按 Native monotonicity 契约严格向前。React 不直接构造 T0/T1/MIX window。

11. WeatherV2HttpClient

WeatherV2 HTTP 层负责读取服务器 manifest/time index、选择预报帧、下载 WVECBIN1 并转换成 canonical U/V。Native/Core 不理解 WeatherV2/provider/HTTP。

11.1 构造函数

type WeatherV2HttpClientOptions = Readonly<{
  baseUrl: string;
  headers?: Readonly<Record<string, string>>;
  fetchImpl?: WeatherV2Fetch;
}>;

const client = new WeatherV2HttpClient({
  baseUrl: "https://weather.jpcn.ccwu.cc",
});

baseUrl 必须是绝对 http:// 或 https:// URL。headers 可增加/覆盖请求 header;默认会带 Accept: application/json, application/octet-stream。fetchImpl 用于宿主注入兼容 fetch,实现测试、代理或特殊网络层。

11.2 getWindContext()

签名:

client.getWindContext(options?: {
  datasetId?: string;
  referenceTime?: string;
}): Promise<WeatherV2WindContext>

返回:

type WeatherV2WindContext = Readonly<{
  datasetId: string;
  model: string;
  referenceTime: string;
  targetTimes: readonly string[];
  particleTemplate: string;
  bounds?: {
    west: number;
    south: number;
    east: number;
    north: number;
  };
}>;

调用示例:

const context = await client.getWindContext({
  datasetId: "ecmwf-hres",
  referenceTime: "2026080600",
});

两个参数都省略时,从 manifest 的 wind dataset 列表选择第一个匹配项。referenceTime/targetTimes 当前服务协议使用 YYYYMMDDHH 字符串。

11.3 selectWindFrames()

签名:

client.selectWindFrames(
  context,
  options?: {
    targetTimes?: readonly string[];
    count?: number;
  },
): readonly WeatherV2WindFrameRef[]

返回元素:

type WeatherV2WindFrameRef = Readonly<{
  datasetId: string;
  model: string;
  referenceTime: string;
  targetTime: string;
  url: string;
}>;

若不传 targetTimes,默认从 context.targetTimes 开头取 count ?? 4 帧;count 必须是 >=2 的整数。若显式传 targetTimes,这些时间必须都存在于 context,并严格递增,且至少两帧。

const refs = client.selectWindFrames(context, {
  targetTimes: ["2026080700", "2026080706", "2026080712", "2026080718"],
});

正式 NavSea 应根据当前时间、Route/ETA 或业务策略选择 targetTimes;固定四帧只是当前回归样例。

11.4 fetchCanonicalWindFrame()

const field = await client.fetchCanonicalWindFrame(ref);

返回:

type CanonicalWindField = Readonly<{
  metadataJson: string;
  payload: Uint8Array;
  fieldId: string;
  validTime: number;
  generation: number;
  bounds: WeatherV2Bounds;
  width: number;
  height: number;
  sourceUrl: string;
}>;

该方法会:

GET frame.url
-> 校验 WVECBIN1 magic/header/fields/length
-> 读取 U/V/speed/valid planes
-> 只保留 canonical U/V
-> invalid cell 写 noData=-9999
-> 生成 maplibre-particle-field/v1 metadata

payload 此时仍在 JS 内存中,是 Uint8Array;提交 Native 前应通过 WeatherV2BinaryFileStore 写成临时本地文件。

12. decodeWeatherV2WindFrame()

这是公开的底层 converter,适合已经自行取得 WVECBIN1 ArrayBuffer 的调用方:

const canonical = decodeWeatherV2WindFrame(arrayBuffer, frameRef);

签名等价于:

decodeWeatherV2WindFrame(
  source: ArrayBuffer,
  frame: WeatherV2WindFrameRef,
): CanonicalWindField

一般业务代码优先使用 WeatherV2HttpClient.fetchCanonicalWindFrame(),不需要直接调用 decoder。

13. WeatherV2BinaryFileStore

WeatherV2 helper 不绑定 Expo FileSystem。宿主只需提供:

type WeatherV2BinaryFileStore = Readonly<{
  writeTempFile(
    fileName: string,
    bytes: Uint8Array,
  ): Promise<string>;

  removeTempFile?(
    fileUri: string,
  ): Promise<void>;
}>;

writeTempFile() 必须返回 Native 可读的 file:// URI或绝对本地路径。removeTempFile() 可选;helper 在每帧提交完成后 best-effort 清理。

Expo FileSystem 示例:

import { File, Paths } from "expo-file-system";

const fileStore = {
  async writeTempFile(fileName: string, bytes: Uint8Array) {
    const file = new File(Paths.cache, fileName);
    file.write(bytes);
    return file.uri;
  },
};

14. loadWeatherV2WindIntoRuntime()

这是 WeatherV2 最常用的高层入口。它把 HTTP 取数、转换、临时文件和 Native submission 串成一条流程。

参数类型:

type LoadWeatherV2WindOptions = Readonly<{
  reactTag: number;
  client: WeatherV2WindClient;
  fileStore: WeatherV2BinaryFileStore;
  datasetId?: string;
  referenceTime?: string;
  targetTimes?: readonly string[];
  count?: number;
  weatherColorVisible?: boolean;
  particleVisible?: boolean;
  playbackState?: "PLAY" | "PAUSE";
}>;

调用:

const loaded = await loadWeatherV2WindIntoRuntime({
  reactTag,
  client,
  fileStore,
  datasetId: "ecmwf-hres",
  referenceTime: "2026080600",
  targetTimes: ["2026080700", "2026080706", "2026080712", "2026080718"],
  weatherColorVisible: true,
  particleVisible: true,
  playbackState: "PAUSE",
});

内部顺序固定为:

clearWindFields
-> setWeatherEnabled(true)
-> 设置 WeatherColor / Particle visibility
-> getWindContext
-> selectWindFrames
-> 逐帧 fetch + WVECBIN1 decode
-> writeTempFile
-> submitWindField
-> best-effort removeTempFile
-> setForecastTime(第一帧 validTime)
-> 可选 setPlaybackState
-> getState,要求 READY

当前实现按帧串行下载/转换/提交,避免同时在 JS 内存保留多张大 Field。

同一个 reactTag 上,该函数带 single-flight:前一次完整加载尚未结束时再次调用,会直接返回同一个 in-flight Promise,不会再次 clearWindFields() 或交叉提交另一套 T0/T1/T2/T3。调用方仍建议在 UI 上把加载按钮置为 disabled/loading。

注意当前调用前提:Runtime 已 attach(),且不要处于 DISABLED。如果此前调用过 setWeatherEnabled(false),应先:

await WeatherRuntime.setWeatherEnabled(reactTag, true);

再调用完整 WeatherV2 loader。

返回类型:

type LoadedWeatherV2WindFrame = Readonly<{
  fieldId: string;
  validTime: number;
  generation: number;
  bounds: WeatherV2Bounds;
  width: number;
  height: number;
  sourceUrl: string;
  submitResult: FieldSubmitResult;
}>;

type LoadWeatherV2WindResult = Readonly<{
  context: WeatherV2WindContext;
  frames: readonly LoadedWeatherV2WindFrame[];
  runtime: RuntimeSnapshot;
}>;

成功返回时 runtime.state 必须已经是 READY,否则 helper 自己 throw。

15. WeatherV2WindClient 接口

loadWeatherV2WindIntoRuntime() 不强制 client 必须是内置 WeatherV2HttpClient,也可以注入自定义实现,只要满足:

type WeatherV2WindClient = Readonly<{
  getWindContext(options?: {
    datasetId?: string;
    referenceTime?: string;
  }): Promise<WeatherV2WindContext>;

  selectWindFrames(
    context: WeatherV2WindContext,
    options?: {
      targetTimes?: readonly string[];
      count?: number;
    },
  ): readonly WeatherV2WindFrameRef[];

  fetchCanonicalWindFrame(
    frame: WeatherV2WindFrameRef,
  ): Promise<CanonicalWindField>;
}>;

因此以后 NavSea 可以保留统一的 Runtime loader,同时替换 HTTP/cache/provider 层。

16. 最小完整 WeatherV2 React Native 示例

以下示例演示:Map style ready → attach → 从 HTTP 获取 WeatherV2 → canonical U/V → Native Runtime → WeatherColor + Particle。

import { useCallback, useEffect, useState } from "react";
import { View, findNodeHandle } from "react-native";
import { File, Paths } from "expo-file-system";
import {
  Map,
  Camera,
  WeatherRuntime,
  WeatherV2HttpClient,
  createParticleWeatherStyle,
  loadWeatherV2WindIntoRuntime,
} from "@navsea/maplibre-particle-rn";

const client = new WeatherV2HttpClient({
  baseUrl: "https://weather.jpcn.ccwu.cc",
});

const fileStore = {
  async writeTempFile(fileName: string, bytes: Uint8Array) {
    const file = new File(Paths.cache, fileName);
    file.write(bytes);
    return file.uri;
  },
};

export default function WeatherMap() {
  const [reactTag, setReactTag] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  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);
  }, []);

  const loadWeather = useCallback(async () => {
    if (reactTag == null || loading) return;
    setLoading(true);
    try {
      const context = await client.getWindContext({
        datasetId: "ecmwf-hres",
      });

      const targetTimes = context.targetTimes.slice(0, 4);

      const result = await loadWeatherV2WindIntoRuntime({
        reactTag,
        client,
        fileStore,
        datasetId: context.datasetId,
        referenceTime: context.referenceTime,
        targetTimes,
        weatherColorVisible: true,
        particleVisible: true,
        playbackState: "PAUSE",
      });

      console.log(result.runtime);
      console.log(result.frames);
    } finally {
      setLoading(false);
    }
  }, [reactTag, loading]);
  useEffect(() => {
    if (reactTag == null) return;
    void loadWeather();

    return () => {
      void WeatherRuntime.detach(reactTag).catch(() => undefined);
    };
  }, [reactTag, loadWeather]);

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

实际产品中不要简单使用 context.targetTimes.slice(0, 4) 作为长期业务策略。应由 React/NavSea 天气业务层根据当前时间、船位、Route/ETA、缓存和预测有效期决定要提交哪些 canonical Field。

17. 错误处理

需要区分两类失败:

  1. Promise reject:参数格式错误、MapView 找不到、style 未 ready、Runtime 已 dispose、本地文件不可读等 Bridge/生命周期错误;
  2. Promise resolve 但返回 REJECTED / REJECTED_*:请求到达 Native/Core,但被 Runtime、Adapter、Timeline 或 capacity 契约拒绝。 建议所有 command 都检查结果:
const commandResult = await WeatherRuntime.setParticleVisible(reactTag, true);
if (commandResult !== "ACCEPTED") {
  throw new Error(`setParticleVisible failed: ${commandResult}`);
}

直接提交 Field 时同样不要忽略返回值。loadWeatherV2WindIntoRuntime() 已经内置严格的 T0/T1/T2... expected result 检查,因此高层 WeatherV2 路径优先使用该 helper。

常见 Field 拒绝原因包括:schema/metadata 不合法、payload 长度不匹配、NaN/Infinity、时间不递增、Field 不兼容、Timeline capacity 已满或 source 不是 Host submission 模式。

18. React 与 Native 的责任边界

React Native / NavSea JS/TS
  provider / HTTP
  cache / retry / prefetch
  当前时间与 forecast time 选择
  Route / ETA
  WeatherV2 解析或其他 provider -> canonical U/V
  临时文件管理
  低频 Runtime 控制
        ↓
metadataJson + local file
        ↓
@navsea/maplibre-particle-rn
        ↓
MapLibre Native / C++
  Field 校验
  Snapshot / Timeline
  T0/T1 Runtime Field Window
  GPU 空间采样 + 时间插值
  WeatherColor
  Particle / Trail
  GPU rendering

React 不向 Native 传 provider 业务对象,也不每帧驱动粒子位置;Native 不负责 HTTP、Route、ETA 或业务缓存。

19. Bare React Native Android 手工配置

如果宿主不使用 Expo config plugin,需要在 Android 根工程中完成与 app.plugin.js 等价的配置。核心要求不是“再加一份 AAR”,而是让 MLRN 的 OpenGL Native SDK 请求统一解析到 NavSea Particle AAR。

当前等价 Groovy 逻辑为:

allprojects { p ->
  p.repositories.maven {
    url = p.uri("https://pbf.jpcn.ccwu.cc/maven")
  }

  p.configurations.configureEach { configuration ->
    configuration.resolutionStrategy.dependencySubstitution {
      substitute module("org.maplibre.gl:android-sdk-opengl") using module(
        "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1"
      )
    }
  }
}

rootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")
rootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")

包自身 react-native.config.js 已声明 Android sourceDir=./android,正常 React Native autolinking 应自动接入 WeatherRuntimeBridgePackage。

20. 版本与升级

当前静态包 URL 包含完整版本号,因此项目升级必须显式修改 URL:

.../navsea-maplibre-particle-rn-13.4.1-particle.1.tgz
.../navsea-maplibre-particle-rn-13.4.1-particle.2.tgz

已经发布的 tarball 不覆盖。版本有修改时创建新的 particle.N,重新 npm pack、上传、Consumer 验证后再给 NavSea 使用。

21. WeatherV2 低层网络类型

只有需要替换默认 globalThis.fetch 时才需要这些类型:

type WeatherV2FetchResponse = Readonly<{
  ok: boolean;
  status: number;
  json(): Promise<unknown>;
  arrayBuffer(): Promise<ArrayBuffer>;
}>;

type WeatherV2Fetch = (
  url: string,
  init?: Readonly<{
    headers?: Readonly<Record<string, string>>;
  }>,
) => Promise<WeatherV2FetchResponse>;

例如可以用自定义 fetch 包装日志、认证 header 或测试 mock,但返回对象必须至少实现上述四项契约。

22. 当前推荐的 React 调用层级

普通业务优先级:

第一选择:loadWeatherV2WindIntoRuntime()
  -> 完整 WeatherV2 HTTP + canonical + submit 流程

需要自定义选帧:WeatherV2HttpClient.getWindContext()
                 + selectWindFrames()
                 + loadWeatherV2WindIntoRuntime()

需要自定义缓存/Provider:实现 WeatherV2WindClient
                       + WeatherV2BinaryFileStore
                       + loadWeatherV2WindIntoRuntime()

已有 canonical 本地文件:WeatherRuntime.submitWindField()

已有 WVECBIN1 ArrayBuffer:decodeWeatherV2WindFrame()

不要为了普通显示直接绕过高层 helper 操作 Core T0/T1/MIX;这些不是 React 公共控制面。