docs: add React Native particle API guide v2
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-10 07:37:24 +08:00
parent fa74df3fb6
commit f31c0ea157

View File

@@ -0,0 +1,965 @@
# 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 包名:
```text
@navsea/maplibre-particle-rn
```
当前版本:
```text
13.4.1-particle.1
```
当前采用静态 HTTPS tarball 发布,不使用 npm Registry,也不需要 `.npmrc`:
```text
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.1.tgz
```
Android Native AAR 独立位于 Maven 仓库:
```text
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:
```bash
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,这是当前预期行为。代码中仍然使用正式包名导入:
```ts
import { Map, Camera, WeatherRuntime } from "@navsea/maplibre-particle-rn";
```
当前 peer dependency 契约:
```text
@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 中加入:
```json
{
"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. 公共导入入口
包根入口当前导出:
```ts
export * from "@maplibre/maplibre-react-native";
export { default as WeatherRuntime } from "./NativeWeatherRuntimeBridge";
export * from "./weatherV2";
```
因此调用方统一从一个入口导入即可:
```ts
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
```ts
particleWeatherIds = {
source: "rn-weather-fields",
weatherColorLayer: "rn-weather-color",
rasterParticleLayer: "rn-weather-particles",
};
```
`WeatherRuntime.attach()` 会按这三个固定 ID 查找 source/layer。正式宿主 style 如果不用 `createParticleWeatherStyle()`,也必须提供同名对象。
## 6. `createParticleWeatherStyle()`
签名:
```ts
createParticleWeatherStyle(
overrides?: Record<string, unknown>,
): string
```
返回 MapLibre Style JSON 字符串。默认包含:
```text
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
```
最小调用:
```tsx
<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。
```tsx
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:
```tsx
<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 公共类型
```ts
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 提交结果:
```ts
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`
```ts
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()`
```ts
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 对象。
严格时间序列典型返回:
```text
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 显示开关
```ts
await WeatherRuntime.setWeatherEnabled(reactTag, true);
await WeatherRuntime.setWeatherColorVisible(reactTag, true);
await WeatherRuntime.setParticleVisible(reactTag, true);
```
语义区别:
- `setWeatherEnabled`:整个 Weather Runtime 的总开关;关闭时同时清空 Field;
- `setWeatherColorVisible`:只控制背景 WeatherColor 场图;
- `setParticleVisible`:只控制运动粒子层。
### 9.4 时间与播放
```ts
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 色阶
类型:
```ts
type ColorStop = Readonly<{
speedMps: number;
red: number;
green: number;
blue: number;
alpha: number;
}>;
```
调用:
```ts
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 粒子与尾迹视觉参数
```ts
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 色阶、粒子颜色、尾迹颜色是三套独立视觉设置:
```text
setWeatherColorStops -> 背景风速场颜色
setParticleColor -> 粒子本体颜色
setTrailColor -> 粒子尾迹颜色
```
## 10. canonical Wind Field 数据结构
MapLibre Particle 接收规则二维 U/V 向量场,而不是图片或粒子坐标数组。每个时间片由:
```text
metadataJson + local binary payload file
```
组成。
当前 canonical schema:
```text
maplibre-particle-field/v1
```
固定向量语义:
```text
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 示例:
```json
{
"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 }
}
```
二进制排列:
```text
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 构造函数
```ts
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()`
签名:
```ts
client.getWindContext(options?: {
datasetId?: string;
referenceTime?: string;
}): Promise<WeatherV2WindContext>
```
返回:
```ts
type WeatherV2WindContext = Readonly<{
datasetId: string;
model: string;
referenceTime: string;
targetTimes: readonly string[];
particleTemplate: string;
bounds?: {
west: number;
south: number;
east: number;
north: number;
};
}>;
```
调用示例:
```ts
const context = await client.getWindContext({
datasetId: "ecmwf-hres",
referenceTime: "2026080600",
});
```
两个参数都省略时,从 manifest 的 wind dataset 列表选择第一个匹配项。`referenceTime`/`targetTimes` 当前服务协议使用 `YYYYMMDDHH` 字符串。
### 11.3 `selectWindFrames()`
签名:
```ts
client.selectWindFrames(
context,
options?: {
targetTimes?: readonly string[];
count?: number;
},
): readonly WeatherV2WindFrameRef[]
```
返回元素:
```ts
type WeatherV2WindFrameRef = Readonly<{
datasetId: string;
model: string;
referenceTime: string;
targetTime: string;
url: string;
}>;
```
若不传 `targetTimes`,默认从 `context.targetTimes` 开头取 `count ?? 4` 帧;`count` 必须是 `>=2` 的整数。若显式传 `targetTimes`,这些时间必须都存在于 context,并严格递增,且至少两帧。
```ts
const refs = client.selectWindFrames(context, {
targetTimes: ["2026080700", "2026080706", "2026080712", "2026080718"],
});
```
正式 NavSea 应根据当前时间、Route/ETA 或业务策略选择 targetTimes;固定四帧只是当前回归样例。
### 11.4 `fetchCanonicalWindFrame()`
```ts
const field = await client.fetchCanonicalWindFrame(ref);
```
返回:
```ts
type CanonicalWindField = Readonly<{
metadataJson: string;
payload: Uint8Array;
fieldId: string;
validTime: number;
generation: number;
bounds: WeatherV2Bounds;
width: number;
height: number;
sourceUrl: string;
}>;
```
该方法会:
```text
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` 的调用方:
```ts
const canonical = decodeWeatherV2WindFrame(arrayBuffer, frameRef);
```
签名等价于:
```ts
decodeWeatherV2WindFrame(
source: ArrayBuffer,
frame: WeatherV2WindFrameRef,
): CanonicalWindField
```
一般业务代码优先使用 `WeatherV2HttpClient.fetchCanonicalWindFrame()`,不需要直接调用 decoder。
## 13. `WeatherV2BinaryFileStore`
WeatherV2 helper 不绑定 Expo FileSystem。宿主只需提供:
```ts
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 示例:
```ts
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 串成一条流程。
参数类型:
```ts
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";
}>;
```
调用:
```ts
const loaded = await loadWeatherV2WindIntoRuntime({
reactTag,
client,
fileStore,
datasetId: "ecmwf-hres",
referenceTime: "2026080600",
targetTimes: ["2026080700", "2026080706", "2026080712", "2026080718"],
weatherColorVisible: true,
particleVisible: true,
playbackState: "PAUSE",
});
```
内部顺序固定为:
```text
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)`,应先:
```ts
await WeatherRuntime.setWeatherEnabled(reactTag, true);
```
再调用完整 WeatherV2 loader。
返回类型:
```ts
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`,也可以注入自定义实现,只要满足:
```ts
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。
```tsx
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 都检查结果:
```ts
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 的责任边界
```text
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 逻辑为:
```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:
```text
.../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` 时才需要这些类型:
```ts
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 调用层级
普通业务优先级:
```text
第一选择: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 公共控制面。