From f31c0ea1571aa3429ec170fbf38bdecf45c847ae Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Thu, 10 Sep 2026 07:37:24 +0800 Subject: [PATCH] docs: add React Native particle API guide v2 --- .../MapLibre粒子ReactNative使用说明_v2.md | 965 ++++++++++++++++++ 1 file changed, 965 insertions(+) create mode 100644 docs/30_设计/MapLibre粒子ReactNative使用说明_v2.md diff --git a/docs/30_设计/MapLibre粒子ReactNative使用说明_v2.md b/docs/30_设计/MapLibre粒子ReactNative使用说明_v2.md new file mode 100644 index 000000000..cd822ce79 --- /dev/null +++ b/docs/30_设计/MapLibre粒子ReactNative使用说明_v2.md @@ -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 +``` + +返回 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 + + + +``` + +`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(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 + + + +``` + +`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` | 为当前 MapView 建立 WeatherRuntime | +| `detach(reactTag)` | `reactTag:number` | `Promise` | dispose 当前 MapView Runtime | +| `getState(reactTag)` | `reactTag:number` | `Promise` | 读取 Native 权威状态 | +| `clearWindFields(reactTag)` | `reactTag:number` | `Promise` | 清空 Host Field/Timeline | +| `submitWindField(reactTag, metadataJson, payloadFileUri)` | tag + JSON + 本地文件 | `Promise` | 提交一张 canonical 风场 | +| `setWeatherEnabled(reactTag, enabled)` | `boolean` | `Promise` | 总开关 | +| `setWeatherColorVisible(reactTag, visible)` | `boolean` | `Promise` | 场图显示开关 | +| `setParticleVisible(reactTag, visible)` | `boolean` | `Promise` | 粒子显示开关 | +| `setTargetFps(reactTag, target)` | `AUTO/FPS_30/FPS_60` | `Promise` | Runtime 绘制 cadence | +| `setForecastTime(reactTag, timestampMs)` | epoch ms | `Promise` | seek 到目标预报时间 | +| `setPlaybackState(reactTag, playback)` | `PLAY/PAUSE` | `Promise` | 播放/暂停 Timeline | +| `setPlaybackSpeed(reactTag, speed)` | `HALF/NORMAL/DOUBLE` | `Promise` | 0.5x/1x/2x 播放速度 | +| `setWeatherColorStops(reactTag, stops)` | `ColorStop[]` | `Promise` | 风速→场图颜色 | +| `setParticleColor(...)` | RGBA | `Promise` | 粒子颜色 | +| `setTrailColor(...)` | RGBA | `Promise` | 尾迹颜色 | +| `setParticleCount(reactTag, count)` | 非负整数 | `Promise` | 粒子数量 | +| `setParticleLineWidth(reactTag, value)` | finite number | `Promise` | 粒子线宽 | +| `setParticleLength(reactTag, value)` | finite number | `Promise` | 粒子长度 | +| `setParticleOpacity(reactTag, value)` | finite number | `Promise` | 粒子透明度 | +| `setTrailOpacity(reactTag, value)` | finite number | `Promise` | 尾迹透明度 | +| `setTrailFade(reactTag, value)` | finite number | `Promise` | 尾迹衰减 | +| `setWeatherColorOpacity(reactTag, value)` | finite number | `Promise` | 场图整体透明度 | +### 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>; + 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 +``` + +返回: + +```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; + + removeTempFile?( + fileUri: string, + ): Promise; +}>; +``` + +`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; + + selectWindFrames( + context: WeatherV2WindContext, + options?: { + targetTimes?: readonly string[]; + count?: number; + }, + ): readonly WeatherV2WindFrameRef[]; + + fetchCanonicalWindFrame( + frame: WeatherV2WindFrameRef, + ): Promise; +}>; +``` + +因此以后 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(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 ( + + + + + + ); +} +``` + +实际产品中不要简单使用 `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; + arrayBuffer(): Promise; +}>; + +type WeatherV2Fetch = ( + url: string, + init?: Readonly<{ + headers?: Readonly>; + }>, +) => Promise; +``` + +例如可以用自定义 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 公共控制面。 \ No newline at end of file