refactor: make RN particle API field-renderer only
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-10 09:12:44 +08:00
parent 8592dae3ab
commit 59a48608fb
22 changed files with 343 additions and 712 deletions

View File

@@ -0,0 +1,79 @@
# MapLibre 粒子 React Native 使用说明 v3
本文对应 `@navsea/maplibre-particle-rn@13.4.1-particle.2`。v2 保留为 `.1` 历史接口说明;不要将其中的旧接口用于本版本。
## 安装与版本
Consumer 直接安装 `@navsea/maplibre-particle-rn@13.4.1-particle.2` 和 `@maplibre/maplibre-react-native@11.3.6`。RN 包 `.2` 继续使用 Android Native artifact `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;本版本没有新的 AAR。
```ts
import { Camera, Map } from "@maplibre/maplibre-react-native";
import { FieldRuntime } from "@navsea/maplibre-particle-rn";
```
Map 与 Camera 必须来自官方 MLRN。NavSea 包仅为 peer dependency 对接 MLRN,不转导出地图组件。
## Style 合入
宿主保有自己的底图、source 和 layer。将纯 renderer fragment 合入既有 style:
```ts
import { createParticleFieldStyleFragment } from "@navsea/maplibre-particle-rn";
const fragment = createParticleFieldStyleFragment();
const style = {
version: 8,
sources: { ...hostSources, ...fragment.sources },
layers: [...hostLayers, ...fragment.layers],
};
```
fragment 只有 field source、field layer 和 particle layer;不含 GSI 或任何业务底图。使用 `particleFieldIds` 引用 renderer ID,不要自行硬编码其历史内部字符串。
## Canonical Field 提交
React/宿主选择业务数据、下载/解码、校验、缓存和本地文件写入;SDK 不理解 provider、network、WeatherV2 或数据集。宿主将既有 Native 数据契约的 `metadataJson` 与本地 `file://` payload 提交一次:
```ts
await FieldRuntime.attach(reactTag);
await FieldRuntime.clearFields(reactTag);
await FieldRuntime.submitField(reactTag, metadataJson, localFileUri);
```
契约保持 `maplibre-particle-field/v1`、`float32-le`、`interleaved-uv`。同一 canonical Field 同时供 field renderer 和 particle renderer 使用,不需要重复提交。
## FieldRuntime 方法
所有方法均以 `reactTag: number` 为首参,异步返回现有 bridge 的结果;无 Native bridge 时调用会明确失败,import 本身不会失败。
| 方法 | 其余参数 | 返回值 |
| --- | --- | --- |
| `attach` | 无 | `Promise<RuntimeSnapshot>` |
| `detach` | 无 | `Promise<void>` |
| `getState` | 无 | `Promise<RuntimeSnapshot>` |
| `clearFields` | 无 | `Promise<CommandResult>` |
| `submitField` | `metadataJson: string`, `payloadFileUri: string` | `Promise<FieldSubmitResult>` |
| `setEnabled` | `enabled: boolean` | `Promise<CommandResult>` |
| `setFieldVisible` | `visible: boolean` | `Promise<CommandResult>` |
| `setParticleVisible` | `visible: boolean` | `Promise<CommandResult>` |
| `setTargetFps` | `target: "AUTO" | "FPS_30" | "FPS_60"` | `Promise<CommandResult>` |
| `setTargetTime` | `timestampMs: number` | `Promise<CommandResult>` |
| `setPlaybackState` | `state: "PLAY" | "PAUSE"` | `Promise<CommandResult>` |
| `setPlaybackSpeed` | `speed: "HALF" | "NORMAL" | "DOUBLE"` | `Promise<CommandResult>` |
| `setFieldColorStops` | `stops: readonly FieldColorStop[]` | `Promise<CommandResult>` |
| `setParticleColor` / `setTrailColor` | `red, green, blue, alpha: number` | `Promise<CommandResult>` |
| `setParticleCount` | `count: number` | `Promise<CommandResult>` |
| `setParticleLineWidth` / `setParticleLength` | `value: number` | `Promise<CommandResult>` |
| `setParticleOpacity` / `setTrailOpacity` / `setTrailFade` / `setFieldOpacity` | `value: number` | `Promise<CommandResult>` |
`FieldColorStop` 为 `{ value, red, green, blue, alpha }`。`RuntimeSnapshot` 包含 `state`、`playbackState`、`targetFps`、`disposed`;`CommandResult` 为 `"ACCEPTED" | "REJECTED"`。
`setFieldColorStops()` 继续遵循既有 Native 色阶约束:至少 2 个 stop,`value` 必须为 finite 且严格递增,RGBA 分量位于 `[0,1]`。
## 独立显示
`setFieldVisible()` 与 `setParticleVisible()` 可独立切换同一 Field 的两种 renderer。`setEnabled()` 控制 runtime 总开关。SDK 不根据业务类型自动选择显示组合;显示策略由宿主决定。
## 边界
本包不提供业务数据类型、HTTP、provider、重试、缓存、路线或 ETA 策略。Native/Core、JNI、shader 和 Android AAR 均保持 `.1` 已验证实现不变。Android 实机验证应在 Consumer 安装 `.2` tarball 后单独执行。

View File

@@ -0,0 +1,34 @@
# STEP-064 RN 纯 Field Renderer 接口 V2
状态:进行中(代码完成,待 Consumer 验证)
## 目标
将 `@navsea/maplibre-particle-rn` 升级为 `13.4.1-particle.2` 纯 Field Renderer 公共 API。React/宿主负责业务、WeatherV2、provider、网络、数据选择和文件管理;SDK 只向既有 Native ingress 提交 canonical Field 并控制图场/粒子渲染。
## 已完成
- 新增根入口 `FieldRuntime`,以薄映射保留既有 Native bridge ABI,未保留旧 API wrapper。
- 公共类型使用 `FieldColorStop.value`;保留无业务含义的 Runtime、Playback、FPS 与结果类型。
- 移除 package 内 `src/weatherV2/` 与根导出;不再转导出 MLRN。
- 新增不含底图的 `particleFieldIds` 和 `createParticleFieldStyleFragment()`。
- `TurboModuleRegistry.get()` 使无 Native module 的 import 安全;实际调用时给出明确错误。
- Consumer Host 直接从官方 MLRN 导入 Map/Camera,仅从 NavSea 包导入 Field Renderer API;本地真实 fixture 仍由宿主提交 canonical metadata 与 file URI。
- release manifest 明确 RN `.2` 对应冻结 Native `.1`。
## 验证
- package TypeScript typecheck:通过。
- Consumer TypeScript typecheck:通过(使用临时类型解析配置,仅用于检查,不进入 Consumer 产品依赖)。
- `npm pack`:通过,`.2` tarball 仅生成在 `/tmp/step064-pack/`,未触碰仓库既有未跟踪 `.1` tarball。
- tarball 内容审计:通过;不含 `src/weatherV2/`、WeatherV2 HTTP/decoder/loader、Test Host、node_modules 或构建缓存。
- 根导出与业务语义审计、`git diff --check`:通过;旧名称仅存在于内部 Native ABI/codegen 接缝,未由根入口公开。
- Android Gradle、AAR、APK、ADB、Jest:按边界未执行。
- RN 0.85.2 Codegen parser 已核对支持 `TurboModuleRegistry.get()` 与 `getEnforcing()`;当前 import-safe `get()` 方案保留。
- 收尾复审修正:`.2` Consumer validation 明确为 pending,`.1` PASS 仅作为 previous baseline;FieldColorStop 文档示例改为满足至少 2 个 stop 的既有 Native 契约。
## 待办与边界
- 待 Consumer 安装最终 `.2` tarball 并实际运行验证;此项完成前不得标记 RELEASED 或 CLOSED/PASS。
- 未修改 Native/Core/JNI/shader/Android AAR,Native artifact 继续 `13.4.1-particle.1`。
- 未进入 NavSea。

View File

@@ -0,0 +1,31 @@
# STEP-064 RN 纯 Field Renderer 接口 V2 完成报告
状态:IMPLEMENTED / WAITING_CONSUMER_VALIDATION
## 完成内容
`@navsea/maplibre-particle-rn` 已升为 `13.4.1-particle.2`。根入口仅公开 `FieldRuntime`、纯渲染类型、`particleFieldIds` 与 `createParticleFieldStyleFragment()`;旧 Weather/Wind 公共名称、MLRN facade 导出与兼容 wrapper 均已删除。
`FieldRuntime.submitField()` / `clearFields()` 继续薄映射既有 Native ingress。图场与粒子消费同一 canonical Field,分别由 `setFieldVisible()` 与 `setParticleVisible()` 控制。`FieldColorStop` 使用 `value`,RN 内部才转换既有 Native ABI 所需字段。
package 内 WeatherV2 HTTP、manifest、time index、WVECBIN1 decoder 与高层 loader 已移除。Consumer Test Host 改为直接使用官方 MLRN 的 Map/Camera,并自行保有 GSI、真实 fixture 与本地文件物化;最终仍只向 SDK 提交 metadata 与 file URI。
## 冻结边界
- Native/Core/JNI/shader/Android AAR 未修改。
- Native artifact 为 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;`.2` 仅为 RN/TS 公共 API revision。
- 未构建/发布 AAR,未执行 Android Gradle、APK、ADB 或 Jest,未进入 NavSea。
## 静态验证
- package TypeScript typecheck:通过。
- Consumer TypeScript typecheck:通过;因 Consumer 尚未安装 `.2` 发布包,检查采用临时 TypeScript module-resolution 配置,不改变 Consumer 产品依赖。
- `npm pack`:通过,`.2` tarball 仅生成在 `/tmp/step064-pack/`,未触碰仓库既有未跟踪 `.1` tarball。
- tarball 内容审计:通过;不含 `src/weatherV2/`、WeatherV2 HTTP/decoder/loader、Test Host、node_modules 或构建缓存。
- package 根导出、业务语义残留审计和 `git diff --check`:通过。旧 Weather/Wind 名称仅留在内部 Native ABI/codegen 接缝及其薄映射实现,不构成公开 API。
- RN 0.85.2 Codegen parser 已核对支持 `TurboModuleRegistry.get()` 与 `getEnforcing()`;当前 import-safe `get()` 方案保留。
- 收尾复审修正:`.2` Consumer validation 明确为 pending,`.1` PASS 仅作为 previous baseline;FieldColorStop 文档示例改为满足至少 2 个 stop 的既有 Native 契约。
## 未完成与后续最小验证
Consumer 尚未安装最终 `.2` tarball 并在既有 `.1` Native artifact 的 APK 中运行。后续最小步骤是:安装 `.2` tarball,启动 Metro,确认 Map/Camera 来自官方 MLRN,加载宿主 canonical T0-T3 fixture,确认 `FieldRuntime` attach、提交、独立开关与控制命令正常。该人工 Consumer 验证完成前,本 Step 保持等待状态。

View File

@@ -1,15 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Camera, Map } from "@maplibre/maplibre-react-native";
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native"; import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
import { Camera, Map, WeatherRuntime, type ColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn"; import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn";
import { materializeRealWeatherFrames, realWeatherFrames } from "./src/realWeatherDataset"; import { materializeRealWeatherFrames, realWeatherFrames } from "./src/realWeatherDataset";
import { loadHttpWeatherV2 } from "./src/httpWeatherV2Demo";
import { weatherTestStyle } from "./src/weatherStyle"; import { weatherTestStyle } from "./src/weatherStyle";
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false }; const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", weatherOpacity: "0.62" }; const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", fieldOpacity: "0.62" };
const frozenStops: ColorStop[] = [ const frozenStops: FieldColorStop[] = [
{ speedMps: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { speedMps: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 }, { value: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { value: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 },
{ speedMps: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { speedMps: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 }, { value: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { value: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
]; ];
export default function App() { export default function App() {
@@ -19,22 +19,20 @@ export default function App() {
const [basemap, setBasemap] = useState("LOADING"); const [basemap, setBasemap] = useState("LOADING");
const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" }); const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
const [transport, setTransport] = useState("PENDING: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); const [transport, setTransport] = useState("PENDING: FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
const [httpTransport, setHttpTransport] = useState("PENDING: WeatherV2 HTTP → canonical U/V → WeatherRuntime"); const [fieldVisible, setFieldVisible] = useState(true);
const [httpLoading, setHttpLoading] = useState(false);
const [weatherVisible, setWeatherVisible] = useState(true);
const [particleVisible, setParticleVisible] = useState(true); const [particleVisible, setParticleVisible] = useState(true);
const [visual, setVisual] = useState(initialVisual); const [visual, setVisual] = useState(initialVisual);
const refresh = useCallback(async (tag: number) => setSnapshot(await WeatherRuntime.getState(tag)), []); const refresh = useCallback(async (tag: number) => setSnapshot(await FieldRuntime.getState(tag)), []);
const attach = useCallback(async (tag: number) => { const attach = useCallback(async (tag: number) => {
try { await WeatherRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("WeatherRuntime attached; style ready"); } try { await FieldRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("FieldRuntime attached; style ready"); }
catch (error) { setResult(`style/attach error: ${String(error)}`); } catch (error) { setResult(`style/attach error: ${String(error)}`); }
}, [refresh]); }, [refresh]);
const onStyleReady = useCallback((event: unknown) => { const onStyleReady = useCallback((event: unknown) => {
const tag = findNodeHandle((event as { target?: unknown }).target as never); const tag = findNodeHandle((event as { target?: unknown }).target as never);
if (tag != null) void attach(tag); if (tag != null) void attach(tag);
}, [attach]); }, [attach]);
useEffect(() => () => { if (reactTag != null) void WeatherRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]); useEffect(() => () => { if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]);
const command = useCallback(async (name: string, operation: (tag: number) => Promise<unknown>) => { const command = useCallback(async (name: string, operation: (tag: number) => Promise<unknown>) => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready"); if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
@@ -47,49 +45,30 @@ export default function App() {
const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY", "ACCEPTED_NEXT_SNAPSHOT", "ACCEPTED_NEXT_SNAPSHOT"]; const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY", "ACCEPTED_NEXT_SNAPSHOT", "ACCEPTED_NEXT_SNAPSHOT"];
try { try {
setTransport("LOADING: materializing Expo Asset local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" }); setTransport("LOADING: materializing Expo Asset local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
if (await WeatherRuntime.clearWindFields(reactTag) !== "ACCEPTED") throw new Error("clearWindFields was rejected"); if (await FieldRuntime.clearFields(reactTag) !== "ACCEPTED") throw new Error("clearFields was rejected");
const localFrames = await materializeRealWeatherFrames(); const localFrames = await materializeRealWeatherFrames();
for (let index = 0; index < localFrames.length; index += 1) { for (let index = 0; index < localFrames.length; index += 1) {
const frame = localFrames[index]; const submit = await WeatherRuntime.submitWindField(reactTag, frame.metadataJson, frame.payloadFileUri); const frame = localFrames[index]; const submit = await FieldRuntime.submitField(reactTag, frame.metadataJson, frame.payloadFileUri);
setFrames((current) => ({ ...current, [frame.label]: submit })); setFrames((current) => ({ ...current, [frame.label]: submit }));
if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`); if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`);
} }
const native = await WeatherRuntime.getState(reactTag); setSnapshot(native); const native = await FieldRuntime.getState(reactTag); setSnapshot(native);
if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`); if (native.state !== "READY") throw new Error(`Native FieldRuntime expected READY, got ${native.state}`);
setTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); setResult("T0-T3 accepted in strict producer timestamp order; Native runtime READY"); setTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); setResult("T0-T3 accepted in strict producer timestamp order; Native runtime READY");
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD REAL WEATHER T0-T3: ${String(error)}`); } } catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD REAL WEATHER T0-T3: ${String(error)}`); }
}, [reactTag]); }, [reactTag]);
const loadHttpWeather = useCallback(async () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
if (httpLoading) return;
setHttpLoading(true);
try {
setHttpTransport("LOADING: manifest → time index → WVECBIN1 → canonical U/V");
const loaded = await loadHttpWeatherV2(reactTag);
setSnapshot(loaded.runtime);
setFrames(Object.fromEntries(loaded.frames.map((frame, index) => [`T${index}`, frame.submitResult])) as Record<string, string>);
setHttpTransport(`READY: ${loaded.frames.length} HTTP frames / ${loaded.context.referenceTime}`);
setResult(`HTTP WeatherV2 READY: ${loaded.frames.map((frame) => frame.fieldId).join(", ")}`);
} catch (error) {
setHttpTransport(`ERROR: ${String(error)}`);
setResult(`LOAD HTTP WEATHER: ${String(error)}`);
} finally {
setHttpLoading(false);
}
}, [httpLoading, reactTag]);
const updateVisual = useCallback((key: keyof typeof initialVisual, value: string) => setVisual((current) => ({ ...current, [key]: value })), []); const updateVisual = useCallback((key: keyof typeof initialVisual, value: string) => setVisual((current) => ({ ...current, [key]: value })), []);
const applyVisual = useCallback(() => void command("particle visual", async (tag) => { const applyVisual = useCallback(() => void command("particle visual", async (tag) => {
const results = await Promise.all([ const results = await Promise.all([
WeatherRuntime.setParticleCount(tag, Number(visual.count)), WeatherRuntime.setParticleLineWidth(tag, Number(visual.width)), WeatherRuntime.setParticleLength(tag, Number(visual.length)), WeatherRuntime.setParticleOpacity(tag, Number(visual.opacity)), WeatherRuntime.setTrailOpacity(tag, Number(visual.trailOpacity)), WeatherRuntime.setTrailFade(tag, Number(visual.trailFade)), WeatherRuntime.setWeatherColorOpacity(tag, Number(visual.weatherOpacity)), FieldRuntime.setParticleCount(tag, Number(visual.count)), FieldRuntime.setParticleLineWidth(tag, Number(visual.width)), FieldRuntime.setParticleLength(tag, Number(visual.length)), FieldRuntime.setParticleOpacity(tag, Number(visual.opacity)), FieldRuntime.setTrailOpacity(tag, Number(visual.trailOpacity)), FieldRuntime.setTrailFade(tag, Number(visual.trailFade)), FieldRuntime.setFieldOpacity(tag, Number(visual.fieldOpacity)),
]); ]);
if (results.some((value) => value !== "ACCEPTED")) throw new Error(results.join(", ")); return "ACCEPTED"; if (results.some((value) => value !== "ACCEPTED")) throw new Error(results.join(", ")); return "ACCEPTED";
}), [command, visual]); }), [command, visual]);
const controls = useMemo(() => [ const controls = useMemo(() => [
["PLAY", (tag: number) => WeatherRuntime.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => WeatherRuntime.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "DOUBLE")], ["PLAY", (tag: number) => FieldRuntime.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => FieldRuntime.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "DOUBLE")],
["FPS AUTO", (tag: number) => WeatherRuntime.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => WeatherRuntime.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => WeatherRuntime.setTargetFps(tag, "FPS_60" as TargetFps)], ["Weather stops", (tag: number) => WeatherRuntime.setWeatherColorStops(tag, frozenStops)], ["Particle color", (tag: number) => WeatherRuntime.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => WeatherRuntime.setTrailColor(tag, 0.35, 0.85, 1, 0.8)], ["FPS AUTO", (tag: number) => FieldRuntime.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => FieldRuntime.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => FieldRuntime.setTargetFps(tag, "FPS_60" as TargetFps)], ["Field stops", (tag: number) => FieldRuntime.setFieldColorStops(tag, frozenStops)], ["Particle color", (tag: number) => FieldRuntime.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => FieldRuntime.setTrailColor(tag, 0.35, 0.85, 1, 0.8)],
] as const, []); ] as const, []);
return <SafeAreaView style={styles.root}> return <SafeAreaView style={styles.root}>
@@ -98,11 +77,10 @@ export default function App() {
<Text>Basemap: GSI {basemap}</Text><Text>Dataset: ECMWF HRES 20260807 · 513×769 · 3.01 MiB/frame</Text><Text>Runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text><Text selectable>REAL DATA TRANSPORT = {transport}</Text> <Text>Basemap: GSI {basemap}</Text><Text>Dataset: ECMWF HRES 20260807 · 513×769 · 3.01 MiB/frame</Text><Text>Runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text><Text selectable>REAL DATA TRANSPORT = {transport}</Text>
{realWeatherFrames.map((frame) => <Text selectable key={frame.label}>{frame.label} {frame.cycle}: {frames[frame.label]}</Text>)} {realWeatherFrames.map((frame) => <Text selectable key={frame.label}>{frame.label} {frame.cycle}: {frames[frame.label]}</Text>)}
<Text selectable>{result}</Text><Button title="LOAD REAL WEATHER T0-T3" onPress={() => void loadRealWeather()} /> <Text selectable>{result}</Text><Button title="LOAD REAL WEATHER T0-T3" onPress={() => void loadRealWeather()} />
<Text selectable>HTTP DATA TRANSPORT = {httpTransport}</Text><Button title={httpLoading ? "LOADING HTTP WEATHER..." : "LOAD HTTP WEATHER T0-T3"} disabled={httpLoading} onPress={() => void loadHttpWeather()} />
<View style={styles.row}>{controls.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}</View> <View style={styles.row}>{controls.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}</View>
<View style={styles.row}><Button title={`Weather ${weatherVisible ? "visible" : "hidden"}`} onPress={() => { const next = !weatherVisible; setWeatherVisible(next); void command("weather visible", (tag) => WeatherRuntime.setWeatherColorVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => WeatherRuntime.setParticleVisible(tag, next)); }} /></View> <View style={styles.row}><Button title={`Field ${fieldVisible ? "visible" : "hidden"}`} onPress={() => { const next = !fieldVisible; setFieldVisible(next); void command("field visible", (tag) => FieldRuntime.setFieldVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => FieldRuntime.setParticleVisible(tag, next)); }} /></View>
<View style={styles.row}>{realWeatherFrames.map((frame) => <Button key={`seek-${frame.label}`} title={`seek ${frame.label}`} onPress={() => void command(`seek ${frame.label}`, (tag) => WeatherRuntime.setForecastTime(tag, frame.validTime))} />)}</View> <View style={styles.row}>{realWeatherFrames.map((frame) => <Button key={`seek-${frame.label}`} title={`seek ${frame.label}`} onPress={() => void command(`seek ${frame.label}`, (tag) => FieldRuntime.setTargetTime(tag, frame.validTime))} />)}</View>
<Text>Visual: count / width / length / particle opacity / trail opacity / trail fade / WeatherColor opacity</Text><View style={styles.row}>{(Object.keys(visual) as Array<keyof typeof initialVisual>).map((key) => <TextInput key={key} value={visual[key]} onChangeText={(value) => updateVisual(key, value)} keyboardType="decimal-pad" style={styles.input} />)}<Button title="Apply visual" onPress={applyVisual} /></View> <Text>Visual: count / width / length / particle opacity / trail opacity / trail fade / field opacity</Text><View style={styles.row}>{(Object.keys(visual) as Array<keyof typeof initialVisual>).map((key) => <TextInput key={key} value={visual[key]} onChangeText={(value) => updateVisual(key, value)} keyboardType="decimal-pad" style={styles.input} />)}<Button title="Apply visual" onPress={applyVisual} /></View>
</ScrollView> </ScrollView>
</SafeAreaView>; </SafeAreaView>;
} }

View File

@@ -1,11 +1,13 @@
# MapLibreParticlePackageConsumerTestHost # MapLibreParticlePackageConsumerTestHost
这是正式发布包 Consumer 验收 Host。它用于模拟 NavSea 的升级方式:Consumer 只直接安装 `@navsea/maplibre-particle-rn@13.4.1-particle.1`,由该 npm 包接入 React Native,并由 Gradle 从 `https://pbf.jpcn.ccwu.cc/maven` 解析正式 Particle AAR。 这是 `@navsea/maplibre-particle-rn@13.4.1-particle.2` 的 Consumer 验收 Host。它直接依赖 `@maplibre/maplibre-react-native@11.3.6` 提供 `Map` 与 `Camera`,只从 NavSea 包导入 `FieldRuntime`、纯类型与 field style fragment。
禁止直接依赖 `@maplibre/maplibre-react-native`、旧 `weather-runtime-bridge`、MapLibre Native source tree、Maven Local、本地 AAR 或 workspace/source-path bridge。Map、Camera、WeatherRuntime、WeatherV2 HTTP client 统一从 `@navsea/maplibre-particle-rn` 导入。 Host 自己拥有 GSI 底图、真实 WeatherV2 fixture、文件物化、数据选择与交互 UI。`LOAD REAL WEATHER T0-T3` 将本地 canonical `float32-le / interleaved-uv` 文件按时间顺序提交至 `FieldRuntime.submitField()`;这些业务能力不属于发布包。
Host 保留两条可对照数据链:`LOAD REAL WEATHER T0-T3` 使用 STEP-062 已通过的本地 canonical fixture;`LOAD HTTP WEATHER T0-T3` 从 `https://weather.jpcn.ccwu.cc` 获取 manifest/time-index/WVECBIN1,由包内 JS/TS converter 生成同一 canonical `float32-le / interleaved-uv` U/V 文件后提交 WeatherRuntime。 包的 Expo plugin 仍解析冻结 Native artifact:
HTTP 回归固定使用 ECMWF HRES `referenceTime=2026080600` 与 2026080700/06/12/18 四帧,以便和既有 fixture 做同源对照。正式 NavSea 不应固定这些时次,而应通过 `getWindContext()` 获取服务器可用时次后按业务策略选择。 ```text
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1
```
验收目标是同时看到 GSI 底图、WeatherColor 与 Particle,并确认 T0~T3 顺序接受、Runtime 进入 READY。这样验证的是“正式 npm 包 → WeatherV2 HTTP/转换 → 正式 Maven AAR → RN/Native/Core”的完整升级链。 STEP-064 不构建或发布 AAR,也不执行 Android Gradle、APK、ADB 或 NavSea 集成。Consumer 验证待后续实际安装发布 `.2` tarball 后完成。

View File

@@ -9,7 +9,8 @@
"android": "expo run:android" "android": "expo run:android"
}, },
"dependencies": { "dependencies": {
"@navsea/maplibre-particle-rn": "13.4.1-particle.1", "@maplibre/maplibre-react-native": "11.3.6",
"@navsea/maplibre-particle-rn": "13.4.1-particle.2",
"expo": "56.0.4", "expo": "56.0.4",
"expo-file-system": "56.0.11", "expo-file-system": "56.0.11",
"react": "19.2.3", "react": "19.2.3",

View File

@@ -1,40 +0,0 @@
import { File, Paths } from "expo-file-system";
import {
WeatherV2HttpClient,
loadWeatherV2WindIntoRuntime,
type LoadWeatherV2WindResult,
type WeatherV2BinaryFileStore,
} from "@navsea/maplibre-particle-rn";
const weatherClient = new WeatherV2HttpClient({
baseUrl: "https://weather.jpcn.ccwu.cc",
});
const fileStore: WeatherV2BinaryFileStore = {
async writeTempFile(fileName, bytes) {
const file = new File(Paths.cache, fileName);
file.write(bytes);
return file.uri;
},
};
const frozenTargetTimes = [
"2026080700",
"2026080706",
"2026080712",
"2026080718",
] as const;
export async function loadHttpWeatherV2(reactTag: number): Promise<LoadWeatherV2WindResult> {
return loadWeatherV2WindIntoRuntime({
reactTag,
client: weatherClient,
fileStore,
datasetId: "ecmwf-hres",
referenceTime: "2026080600",
targetTimes: frozenTargetTimes,
weatherColorVisible: true,
particleVisible: true,
playbackState: "PAUSE",
});
}

View File

@@ -1,10 +1,10 @@
/** No field is embedded here: RasterArraySource is reserved for STEP-061 file-reference submission. */ import { createParticleFieldStyleFragment, particleFieldIds } from "@navsea/maplibre-particle-rn";
export const WEATHER_SOURCE_ID = "rn-weather-fields";
export const WEATHER_COLOR_LAYER_ID = "rn-weather-color";
export const PARTICLE_LAYER_ID = "rn-weather-particles";
export const GSI_SOURCE_ID = "gsi-standard"; export const GSI_SOURCE_ID = "gsi-standard";
export const GSI_LAYER_ID = "gsi-standard"; export const GSI_LAYER_ID = "gsi-standard";
const fieldRenderer = createParticleFieldStyleFragment();
export const weatherTestStyle = JSON.stringify({ export const weatherTestStyle = JSON.stringify({
version: 8, version: 8,
sources: { sources: {
@@ -15,17 +15,26 @@ export const weatherTestStyle = JSON.stringify({
maxzoom: 18, maxzoom: 18,
attribution: "\u56fd\u571f\u5730\u7406\u9662 / GSI" attribution: "\u56fd\u571f\u5730\u7406\u9662 / GSI"
}, },
[WEATHER_SOURCE_ID]: { type: "raster-array", "host-field-submission": true } ...fieldRenderer.sources
}, },
layers: [ layers: [
{ id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } }, { id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } },
{ id: GSI_LAYER_ID, type: "raster", source: GSI_SOURCE_ID, paint: { "raster-opacity": 1 } }, { id: GSI_LAYER_ID, type: "raster", source: GSI_SOURCE_ID, paint: { "raster-opacity": 1 } },
{ id: WEATHER_COLOR_LAYER_ID, type: "weather-color", source: WEATHER_SOURCE_ID, ...fieldRenderer.layers.map((layer) => {
paint: { "weather-color-opacity": 0.62 } }, if (layer.id !== particleFieldIds.particleLayer) return layer;
{ id: PARTICLE_LAYER_ID, type: "raster-particle", source: WEATHER_SOURCE_ID, return {
paint: { "raster-particle-active": true, "raster-particle-particle-count": 4096, ...layer,
"raster-particle-line-width": 1.5, "raster-particle-particle-length": 0.012, paint: {
"raster-particle-particle-opacity": 0.9, "raster-particle-trail-opacity": 0.8, ...(layer.paint as Record<string, unknown>),
"raster-particle-trail-fade": 0.94 } } "raster-particle-active": true,
"raster-particle-particle-count": 4096,
"raster-particle-line-width": 1.5,
"raster-particle-particle-length": 0.012,
"raster-particle-particle-opacity": 0.9,
"raster-particle-trail-opacity": 0.8,
"raster-particle-trail-fade": 0.94,
},
};
})
] ]
}); });

View File

@@ -1,12 +1,14 @@
# 变更记录 # 变更记录
## 13.4.1-particle.2
- 公共 RN/TS API 重整为纯 `FieldRuntime` Field Renderer 接口。
- 移除根入口对 `@maplibre/maplibre-react-native` 的转导出,以及旧 Weather/Wind 公共 API 与兼容 wrapper。
- 移除包内数据加载、HTTP、解码与业务辅助模块;数据获取、业务选择、缓存和本地文件写入由 Consumer/宿主负责。
- 新增 `FieldColorStop.value`、`particleFieldIds` 与不含底图的 `createParticleFieldStyleFragment()`。
- 非原生环境可安全导入;实际调用且 native bridge 缺失时返回稳定错误。
- RN 包版本升为 `.2`;Android Native artifact 保持 `13.4.1-particle.1`,未构建、未修改、未发布。
## 13.4.1-particle.1 ## 13.4.1-particle.1
- 基于 MapLibre Native 13.4.1 的 Android OpenGL Particle 发行线。 - 首个 Android OpenGL Particle RN 发布线,保留为历史版本记录。
- 包含 WeatherColor、RasterParticle、HostField binary file ingress、Timeline / Playback 与 React Native WeatherRuntime。
- Native/RN binary ingress 已在 STEP-061 验证;GSI + 真实 WeatherV2 + Particle Android 综合验证已在 STEP-062 完成。
- 发布前 Consumer 验证修正 MLRN 依赖为 peer dependency,避免嵌套 native module 与 Windows codegen 长路径。
- WeatherV2 HTTP 全帧加载增加按 map `reactTag` 的 single-flight 保护,避免重复点击造成并发 clear/submit 与非单调快照拒绝。
- 新增纯 JS/TS WeatherV2 HTTP 客户端:manifest/time-index → WVECBIN1 full wind frame → canonical interleaved U/V。
- 新增 `loadWeatherV2WindIntoRuntime()`:顺序下载、转换、写临时 binary file 并通过现有 WeatherRuntime 提交,不建立第二套 Timeline/MIX。
- WeatherV2 文件存储通过 `WeatherV2BinaryFileStore` 注入,包本身不强依赖 Expo FileSystem,NavSea 可复用自己的缓存实现。

View File

@@ -1,94 +1,65 @@
# @navsea/maplibre-particle-rn # @navsea/maplibre-particle-rn
`13.4.1-particle.1` 是 NavSea 维护的 MapLibre Particle React Native 入口。Consumer 只直接安装本包;本包将 `@maplibre/maplibre-react-native@11.3.6` 声明为 peer dependency,并由 `WeatherRuntime` 提供 HostField / Timeline 的低频控制。 `13.4.1-particle.2` 提供 React Native 的纯 Field Renderer 接口。它只接收宿主已经准备好的 canonical field 元数据与本地二进制文件,并控制既有 Native/Core 渲染器;不提供地图组件、底图、数据集、provider、HTTP、缓存或业务策略。
Android 使用 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`。Expo Consumer 在 app config 加入 `@navsea/maplibre-particle-rn/app.plugin`;plugin 会加入 `https://pbf.jpcn.ccwu.cc/maven` 并替换 MLRN 的 OpenGL Native SDK 请求,避免官方与 Particle AAR 同时进入 APK。 Map 与 Camera 必须由 Consumer 直接从 `@maplibre/maplibre-react-native@11.3.6` 导入。本包将该库声明为 peer dependency,不再转导出 MapLibre React Native。
WeatherColor 和 RasterParticle 是 Core-only style layer,不是 React 组件。使用 `createParticleWeatherStyle()` 或等价 Style JSON 声明。binary submit 始终为 `metadataJson + local file:// payload`,通过 `WeatherRuntime.submitWindField()` 进入既有 Native/Core 链路。 ## 版本关系
- RN/TS 包:`13.4.1-particle.2`
- Android Native artifact:`cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`
## HTTPS 静态包安装 本次仅整理 RN/TS 公共接口。Native artifact 未构建、未修改、未发布;Expo plugin 继续解析上述冻结 artifact。
本包当前采用静态 HTTPS tarball 发布,不部署 npm Registry。正式包由 `npm pack` 生成后上传到: ## 最小接入
```text
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-<version>.tgz
```
Consumer 直接安装完整 URL,例如:
```bash
npm install https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.1.tgz
```
安装完成后仍按正式包名导入:
```ts ```ts
import { Map, Camera, WeatherRuntime } from "@navsea/maplibre-particle-rn"; import { Camera, Map } from "@maplibre/maplibre-react-native";
```
Consumer 不依赖 MapLibreParticleLab 源码路径。已发布 tarball 不覆盖;有修改时递增为新的 `13.4.1-particle.N`,重新 `npm pack` 并上传新的文件名。Android AAR 继续独立从 `https://pbf.jpcn.ccwu.cc/maven/` 获取。
## WeatherV2 HTTP 数据链
包内 `weatherV2` 子模块负责:
```text
WeatherV2 HTTP
→ weather_manifest.json
→ wind time_index.json
→ pt full-frame WVECBIN1
→ JS/TS canonical converter
→ maplibre-particle-field/v1
→ local binary file URI
→ WeatherRuntime.submitWindField()
→ WeatherColorLayer + RasterParticleLayer
```
Native 不理解 provider、HTTP、dataset、Route 或 ETA;Core 仍然独占 T0/T1/MIX、Runtime Field Window 和 GPU 插值。
### 最小调用
```ts
import { File, Paths } from "expo-file-system";
import { import {
WeatherV2HttpClient, FieldRuntime,
loadWeatherV2WindIntoRuntime, createParticleFieldStyleFragment,
type FieldColorStop,
} from "@navsea/maplibre-particle-rn"; } from "@navsea/maplibre-particle-rn";
const client = new WeatherV2HttpClient({ const renderer = createParticleFieldStyleFragment();
baseUrl: "https://weather.jpcn.ccwu.cc", const mapStyle = JSON.stringify({
version: 8,
sources: { ...renderer.sources },
layers: [...renderer.layers],
}); });
const fileStore = {
async writeTempFile(fileName: string, bytes: Uint8Array) {
const file = new File(Paths.cache, fileName);
file.write(bytes);
return file.uri;
},
};
``` ```
`fileStore` 是 Host 适配点,不属于 WeatherV2 协议。NavSea 可以换成自己的缓存目录、文件系统和生命周期策略,不需要修改 HTTP client 或 canonical converter。 宿主在样式加载并取得 `reactTag` 后调用 `FieldRuntime.attach(reactTag)`。准备 canonical payload 的方式由宿主决定,再按时间顺序调用:
```ts ```ts
await loadWeatherV2WindIntoRuntime({ await FieldRuntime.clearFields(reactTag);
reactTag, await FieldRuntime.submitField(reactTag, metadataJson, localFileUri);
client, await FieldRuntime.setFieldColorStops(reactTag, [
fileStore, { value: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 },
datasetId: "ecmwf-hres", { value: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
referenceTime: "2026080600", ]);
targetTimes: ["2026080700", "2026080706", "2026080712", "2026080718"],
weatherColorVisible: true,
particleVisible: true,
playbackState: "PAUSE",
});
await WeatherRuntime.setPlaybackState(reactTag, "PLAY");
``` ```
`WeatherV2HttpClient.getWindContext()` 会从 manifest 选择 wind dataset 并读取 time index。NavSea 正式代码通常应先取得 context,再根据当前时间、路线/ETA 或业务策略选择 `targetTimes`;上面的固定四帧只用于当前 Test Host 回归。 `createParticleFieldStyleFragment()` 只返回 renderer source、field layer 与 particle layer;不含底图或业务数据。`particleFieldIds` 可供宿主引用相同的 source/layer ID。
转换器读取服务端 `WVECBIN1` 的 U plane、V plane、speed plane 和 valid mask;正式 canonical payload 只保留 interleaved U/V,invalid sample 写为 `-9999`。方向和单位固定为 U=eastward、V=northward、m/s、north-west origin、rows north-to-south。 ## FieldRuntime
下载/转换/提交按帧串行执行,避免同时在 JS 内存保留多份约 5 MiB 的 WVECBIN1 与约 3 MiB 的 canonical payload。没有 `number[]`、JSON 数值数组、base64 或 per-frame JNI Field 上传。 全部方法均以 `reactTag` 为首参并返回既有桥接命令结果:
本包不包含 Test Host、WeatherV2 fixture、AAR、APK、cache 或凭证。发布前仍需通过 tarball Consumer APK 与实体机 smoke;Release Tag 由 Release Freeze 阶段单独创建。 - 生命周期:`attach`、`detach`、`getState`
- 数据:`clearFields`、`submitField`
- 显示与播放:`setEnabled`、`setFieldVisible`、`setParticleVisible`、`setTargetFps`、`setTargetTime`、`setPlaybackState`、`setPlaybackSpeed`
- 色彩:`setFieldColorStops`、`setParticleColor`、`setTrailColor`
- 粒子视觉参数:`setParticleCount`、`setParticleLineWidth`、`setParticleLength`、`setParticleOpacity`、`setTrailOpacity`、`setTrailFade`、`setFieldOpacity`
`FieldColorStop` 使用无业务含义的 `value` 字段;色阶继续遵循既有 Native 契约,至少提供 2 个且 `value` 严格递增。包在非原生环境可安全导入;实际调用 `FieldRuntime` 而 Native bridge 不存在时会给出稳定、明确的错误。
## 安装与发布
正式包仍以 HTTPS tarball 交付:
```text
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.2.tgz
```
Consumer 只依赖发布 tarball,不依赖本仓库源码路径。发布前通过 `npm pack` 检查 tarball 内容;本包不包含 AAR、APK、fixture 或凭证。

View File

@@ -1,7 +1,7 @@
{ {
"name": "@navsea/maplibre-particle-rn", "name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.1", "version": "13.4.1-particle.2",
"description": "NavSea MapLibre Particle React Native facade for the Android OpenGL particle release.", "description": "NavSea MapLibre Particle React Native pure Field Renderer API.",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"main": "src/index.ts", "main": "src/index.ts",
"react-native": "src/index.ts", "react-native": "src/index.ts",
@@ -20,7 +20,7 @@
"maplibre", "maplibre",
"particle", "particle",
"react-native", "react-native",
"weather" "field-renderer"
], ],
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -0,0 +1,34 @@
import { getNativeFieldRuntimeBridge } from "./NativeWeatherRuntimeBridge";
import type { ColorChannel, CommandResult, FieldColorStop, FieldSubmitResult, PlaybackState, RuntimeSnapshot, TargetFps } from "./types";
function nativeBridge() {
const bridge = getNativeFieldRuntimeBridge();
if (bridge == null) throw new Error("@navsea/maplibre-particle-rn FieldRuntime requires the Android WeatherRuntimeBridge native module");
return bridge;
}
/** Pure canonical-field renderer controls; it has no provider, dataset, or business policy. */
export const FieldRuntime = {
attach: (reactTag: number): Promise<RuntimeSnapshot> => nativeBridge().attach(reactTag),
detach: (reactTag: number): Promise<void> => nativeBridge().detach(reactTag),
getState: (reactTag: number): Promise<RuntimeSnapshot> => nativeBridge().getState(reactTag),
clearFields: (reactTag: number): Promise<CommandResult> => nativeBridge().clearWindFields(reactTag),
submitField: (reactTag: number, metadataJson: string, payloadFileUri: string): Promise<FieldSubmitResult> => nativeBridge().submitWindField(reactTag, metadataJson, payloadFileUri),
setEnabled: (reactTag: number, enabled: boolean): Promise<CommandResult> => nativeBridge().setWeatherEnabled(reactTag, enabled),
setFieldVisible: (reactTag: number, visible: boolean): Promise<CommandResult> => nativeBridge().setWeatherColorVisible(reactTag, visible),
setParticleVisible: (reactTag: number, visible: boolean): Promise<CommandResult> => nativeBridge().setParticleVisible(reactTag, visible),
setTargetFps: (reactTag: number, target: TargetFps): Promise<CommandResult> => nativeBridge().setTargetFps(reactTag, target),
setTargetTime: (reactTag: number, timestampMs: number): Promise<CommandResult> => nativeBridge().setForecastTime(reactTag, timestampMs),
setPlaybackState: (reactTag: number, state: PlaybackState): Promise<CommandResult> => nativeBridge().setPlaybackState(reactTag, state),
setPlaybackSpeed: (reactTag: number, speed: "HALF" | "NORMAL" | "DOUBLE"): Promise<CommandResult> => nativeBridge().setPlaybackSpeed(reactTag, speed),
setFieldColorStops: (reactTag: number, stops: readonly FieldColorStop[]): Promise<CommandResult> => nativeBridge().setWeatherColorStops(reactTag, stops.map(({ value, red, green, blue, alpha }) => ({ speedMps: value, red, green, blue, alpha }))),
setParticleColor: (reactTag: number, red: ColorChannel, green: ColorChannel, blue: ColorChannel, alpha: ColorChannel): Promise<CommandResult> => nativeBridge().setParticleColor(reactTag, red, green, blue, alpha),
setTrailColor: (reactTag: number, red: ColorChannel, green: ColorChannel, blue: ColorChannel, alpha: ColorChannel): Promise<CommandResult> => nativeBridge().setTrailColor(reactTag, red, green, blue, alpha),
setParticleCount: (reactTag: number, count: number): Promise<CommandResult> => nativeBridge().setParticleCount(reactTag, count),
setParticleLineWidth: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setParticleLineWidth(reactTag, value),
setParticleLength: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setParticleLength(reactTag, value),
setParticleOpacity: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setParticleOpacity(reactTag, value),
setTrailOpacity: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setTrailOpacity(reactTag, value),
setTrailFade: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setTrailFade(reactTag, value),
setFieldOpacity: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setWeatherColorOpacity(reactTag, value),
} as const;

View File

@@ -34,4 +34,7 @@ export interface Spec extends TurboModule {
setWeatherColorOpacity(reactTag: number, value: number): Promise<CommandResult>; setWeatherColorOpacity(reactTag: number, value: number): Promise<CommandResult>;
} }
export default TurboModuleRegistry.getEnforcing<Spec>("WeatherRuntimeBridge"); /** Native ABI name is retained internally for the existing Android bridge/codegen seam. */
export function getNativeFieldRuntimeBridge(): Spec | null {
return TurboModuleRegistry.get<Spec>("WeatherRuntimeBridge");
}

View File

@@ -1,9 +1,8 @@
export * from "@maplibre/maplibre-react-native"; export { FieldRuntime } from "./FieldRuntime";
export { default as WeatherRuntime } from "./NativeWeatherRuntimeBridge";
export * from "./weatherV2";
export type { export type {
ColorStop, ColorChannel,
CommandResult, CommandResult,
FieldColorStop,
FieldSubmitResult, FieldSubmitResult,
PlaybackState, PlaybackState,
RuntimeSnapshot, RuntimeSnapshot,
@@ -11,30 +10,43 @@ export type {
TargetFps, TargetFps,
} from "./types"; } from "./types";
export const particleWeatherIds = { /** IDs for the renderer-owned source and layers. Hosts retain ownership of their base map. */
export const particleFieldIds = {
source: "rn-weather-fields", source: "rn-weather-fields",
weatherColorLayer: "rn-weather-color", fieldLayer: "rn-weather-color",
rasterParticleLayer: "rn-weather-particles", particleLayer: "rn-weather-particles",
} as const; } as const;
export type ParticleFieldStyleFragment = Readonly<{
sources: Record<string, unknown>;
layers: readonly Record<string, unknown>[];
}>;
/** /**
* Core-only WeatherColor/RasterParticle layers are declared in a MapLibre style, rather than as * Creates only the renderer source/layers. It deliberately contains no base-map,
* React components. This helper reserves their stable IDs without creating a second field ingress. * provider, network, or field-data policy.
*/ */
export function createParticleWeatherStyle(overrides: Record<string, unknown> = {}) { export function createParticleFieldStyleFragment(): ParticleFieldStyleFragment {
return JSON.stringify({ return {
version: 8,
sources: { sources: {
"gsi-standard": { type: "raster", tiles: ["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"], tileSize: 256, maxzoom: 18, attribution: "国土地理院 / GSI" }, [particleFieldIds.source]: {
[particleWeatherIds.source]: { type: "raster-array", "host-field-submission": true }, type: "raster-array",
"host-field-submission": true,
},
}, },
layers: [ layers: [
{ id: "particle-background", type: "background", paint: { "background-color": "#10243a" } }, {
{ id: "gsi-standard", type: "raster", source: "gsi-standard", paint: { "raster-opacity": 1 } }, id: particleFieldIds.fieldLayer,
{ id: particleWeatherIds.weatherColorLayer, type: "weather-color", source: particleWeatherIds.source, paint: { "weather-color-opacity": 0.62 } }, type: "weather-color",
{ id: particleWeatherIds.rasterParticleLayer, type: "raster-particle", source: particleWeatherIds.source, source: particleFieldIds.source,
paint: { "raster-particle-active": true, "raster-particle-particle-count": 4096 } }, paint: { "weather-color-opacity": 0.62 },
},
{
id: particleFieldIds.particleLayer,
type: "raster-particle",
source: particleFieldIds.source,
paint: { "raster-particle-opacity": 0.9 },
},
], ],
...overrides, };
});
} }

View File

@@ -3,5 +3,6 @@ export type PlaybackState = "PLAY" | "PAUSE";
export type TargetFps = "AUTO" | "FPS_30" | "FPS_60"; export type TargetFps = "AUTO" | "FPS_30" | "FPS_60";
export type CommandResult = "ACCEPTED" | "REJECTED"; export type CommandResult = "ACCEPTED" | "REJECTED";
export 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"; export 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";
export type ColorStop = Readonly<{ speedMps: number; red: number; green: number; blue: number; alpha: number }>; export type ColorChannel = number;
export type FieldColorStop = Readonly<{ value: number; red: ColorChannel; green: ColorChannel; blue: ColorChannel; alpha: ColorChannel }>;
export type RuntimeSnapshot = Readonly<{ state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }>; export type RuntimeSnapshot = Readonly<{ state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }>;

View File

@@ -1,138 +0,0 @@
import { decodeWeatherV2WindFrame } from "./wvecbin1";
import type {
CanonicalWindField,
WeatherV2Bounds,
WeatherV2Fetch,
WeatherV2HttpClientOptions,
WeatherV2WindContext,
WeatherV2WindFrameRef,
} from "./types";
function fail(message: string): never {
throw new Error(`WeatherV2 HTTP: ${message}`);
}
function objectValue(value: unknown, name: string): Record<string, unknown> {
if (value == null || typeof value !== "object" || Array.isArray(value)) fail(`${name} must be an object`);
return value as Record<string, unknown>;
}
function stringValue(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0) fail(`${name} must be a non-empty string`);
return value;
}
function joinUrl(baseUrl: string, path: string): string {
return new URL(path.replace(/^\//, ""), `${baseUrl.replace(/\/+$/, "")}/`).toString();
}
function parseBounds(value: unknown): WeatherV2Bounds | undefined {
if (value == null) return undefined;
const bounds = objectValue(value, "timeIndex.b");
const names = ["west", "south", "east", "north"] as const;
const numbers = names.map((name) => bounds[name]);
if (numbers.some((number) => typeof number !== "number" || !Number.isFinite(number))) fail("timeIndex.b must contain finite bounds");
return { west: numbers[0] as number, south: numbers[1] as number, east: numbers[2] as number, north: numbers[3] as number };
}
function renderTemplate(template: string, values: Readonly<Record<string, string>>): string {
let rendered = template;
Object.entries(values).forEach(([name, value]) => {
rendered = rendered.replaceAll(`{${name}}`, encodeURIComponent(value));
});
if (/\{[^}]+\}/.test(rendered)) fail(`unresolved template token in ${rendered}`);
return rendered;
}
const defaultFetch: WeatherV2Fetch = (url, init) => globalThis.fetch(url, init as RequestInit) as Promise<WeatherV2FetchResponse>;
type WeatherV2FetchResponse = Awaited<ReturnType<WeatherV2Fetch>>;
export class WeatherV2HttpClient {
private readonly baseUrl: string;
private readonly headers: Readonly<Record<string, string>>;
private readonly fetchImpl: WeatherV2Fetch;
constructor(options: WeatherV2HttpClientOptions) {
if (!/^https?:\/\//.test(options.baseUrl)) fail("baseUrl must be absolute HTTP(S)");
this.baseUrl = options.baseUrl;
this.headers = { Accept: "application/json, application/octet-stream", ...options.headers };
this.fetchImpl = options.fetchImpl ?? defaultFetch;
}
private async fetchJson(path: string): Promise<unknown> {
const url = joinUrl(this.baseUrl, path);
const response = await this.fetchImpl(url, { headers: this.headers });
if (!response.ok) fail(`GET ${url} returned HTTP ${response.status}`);
return response.json();
}
async getWindContext(options: Readonly<{ datasetId?: string; referenceTime?: string }> = {}): Promise<WeatherV2WindContext> {
const manifest = objectValue(await this.fetchJson("weather_manifest.json"), "manifest");
if (manifest.f !== "weather-v2-manifest-v1") fail(`unsupported manifest ${String(manifest.f)}`);
const layers = objectValue(manifest.l, "manifest.l");
const wind = objectValue(layers.wind, "manifest.l.wind");
if (!Array.isArray(wind.ds) || wind.ds.length === 0) fail("manifest wind datasets are empty");
const datasets = wind.ds.map((value, index) => objectValue(value, `manifest.l.wind.ds[${index}]`));
const selected = datasets.find((dataset) =>
(options.datasetId == null || dataset.id === options.datasetId) &&
(options.referenceTime == null || dataset.r === options.referenceTime),
);
if (selected == null) fail(`no wind dataset matches datasetId=${options.datasetId ?? "default"} referenceTime=${options.referenceTime ?? "default"}`);
const datasetId = stringValue(selected.id, "dataset.id");
const model = stringValue(selected.m ?? selected.id, "dataset.model");
const referenceTime = stringValue(selected.r, "dataset.referenceTime");
const timeIndexPath = stringValue(selected.iu, "dataset.timeIndexUrl");
const timeIndex = objectValue(await this.fetchJson(timeIndexPath), "timeIndex");
if (timeIndex.f !== "weather-v2-time-index-v1" || timeIndex.l !== "wind") fail("time index is not a WeatherV2 wind index");
if (!Array.isArray(timeIndex.t) || timeIndex.t.length === 0 || timeIndex.t.some((value) => typeof value !== "string")) {
fail("timeIndex.t must be a non-empty string array");
}
const particleTemplate = stringValue(timeIndex.pt, "timeIndex.pt");
return {
datasetId,
model,
referenceTime,
targetTimes: timeIndex.t as string[],
particleTemplate,
bounds: parseBounds(timeIndex.b),
};
}
selectWindFrames(
context: WeatherV2WindContext,
options: Readonly<{ targetTimes?: readonly string[]; count?: number }> = {},
): readonly WeatherV2WindFrameRef[] {
const count = options.count ?? 4;
if (!Number.isInteger(count) || count < 2) fail("count must be an integer >= 2");
const selected = options.targetTimes == null ? context.targetTimes.slice(0, count) : [...options.targetTimes];
if (selected.length < 2) fail("at least two target times are required for a runtime window");
if (selected.length > count && options.targetTimes == null) fail("selected target count exceeds count");
const available = new Set(context.targetTimes);
selected.forEach((targetTime, index) => {
if (!available.has(targetTime)) fail(`target time ${targetTime} is not available`);
if (index > 0 && selected[index - 1] >= targetTime) fail("target times must be strictly increasing");
});
return selected.map((targetTime) => {
const path = renderTemplate(context.particleTemplate, {
dataset_id: context.datasetId,
layer: "wind",
ref_time: context.referenceTime,
target_time: targetTime,
});
return {
datasetId: context.datasetId,
model: context.model,
referenceTime: context.referenceTime,
targetTime,
url: joinUrl(this.baseUrl, path),
};
});
}
async fetchCanonicalWindFrame(frame: WeatherV2WindFrameRef): Promise<CanonicalWindField> {
const response = await this.fetchImpl(frame.url, { headers: this.headers });
if (!response.ok) fail(`GET ${frame.url} returned HTTP ${response.status}`);
return decodeWeatherV2WindFrame(await response.arrayBuffer(), frame);
}
}

View File

@@ -1,17 +0,0 @@
export { WeatherV2HttpClient } from "./WeatherV2HttpClient";
export { decodeWeatherV2WindFrame } from "./wvecbin1";
export { loadWeatherV2WindIntoRuntime } from "./loadIntoWeatherRuntime";
export type {
CanonicalWindField,
LoadedWeatherV2WindFrame,
LoadWeatherV2WindOptions,
LoadWeatherV2WindResult,
WeatherV2BinaryFileStore,
WeatherV2Bounds,
WeatherV2Fetch,
WeatherV2FetchResponse,
WeatherV2HttpClientOptions,
WeatherV2WindClient,
WeatherV2WindContext,
WeatherV2WindFrameRef,
} from "./types";

View File

@@ -1,114 +0,0 @@
import WeatherRuntime from "../NativeWeatherRuntimeBridge";
import type { FieldSubmitResult } from "../types";
import type {
LoadWeatherV2WindOptions,
LoadWeatherV2WindResult,
LoadedWeatherV2WindFrame,
} from "./types";
function requireAccepted(value: string, operation: string): void {
if (value !== "ACCEPTED") throw new Error(`${operation} was rejected: ${value}`);
}
function expectedSubmit(index: number): FieldSubmitResult {
if (index === 0) return "ACCEPTED_PENDING_INITIAL_WINDOW";
if (index === 1) return "INITIAL_WINDOW_READY";
return "ACCEPTED_NEXT_SNAPSHOT";
}
/**
* Fetches WeatherV2 full wind frames sequentially, converts each frame to the canonical
* U/V contract and submits it through the existing map-owned WeatherRuntime.
* Core remains the sole owner of T0/T1/MIX and playback interpolation.
*/
const inFlightLoads = new Map<number, Promise<LoadWeatherV2WindResult>>();
async function loadWeatherV2WindIntoRuntimeInternal(
options: LoadWeatherV2WindOptions,
): Promise<LoadWeatherV2WindResult> {
const { reactTag, client, fileStore } = options;
if (!Number.isInteger(reactTag) || reactTag <= 0) throw new Error("reactTag must be a positive integer");
requireAccepted(await WeatherRuntime.clearWindFields(reactTag), "clearWindFields");
requireAccepted(await WeatherRuntime.setWeatherEnabled(reactTag, true), "setWeatherEnabled");
requireAccepted(
await WeatherRuntime.setWeatherColorVisible(reactTag, options.weatherColorVisible ?? true),
"setWeatherColorVisible",
);
requireAccepted(
await WeatherRuntime.setParticleVisible(reactTag, options.particleVisible ?? true),
"setParticleVisible",
);
const context = await client.getWindContext({
datasetId: options.datasetId,
referenceTime: options.referenceTime,
});
const frameRefs = client.selectWindFrames(context, {
targetTimes: options.targetTimes,
count: options.count,
});
const loaded: LoadedWeatherV2WindFrame[] = [];
for (let index = 0; index < frameRefs.length; index += 1) {
const frame = await client.fetchCanonicalWindFrame(frameRefs[index]);
const fileName = `navsea-weather-${frame.fieldId}.f32`;
const payloadFileUri = await fileStore.writeTempFile(fileName, frame.payload);
let submitResult: FieldSubmitResult;
try {
submitResult = await WeatherRuntime.submitWindField(reactTag, frame.metadataJson, payloadFileUri);
} finally {
if (fileStore.removeTempFile != null) {
try { await fileStore.removeTempFile(payloadFileUri); } catch { /* cache cleanup is best effort */ }
}
}
const expected = expectedSubmit(index);
if (submitResult !== expected) {
throw new Error(`${frame.fieldId} expected ${expected}, got ${submitResult}`);
}
loaded.push({
fieldId: frame.fieldId,
validTime: frame.validTime,
generation: frame.generation,
bounds: frame.bounds,
width: frame.width,
height: frame.height,
sourceUrl: frame.sourceUrl,
submitResult,
});
}
requireAccepted(
await WeatherRuntime.setForecastTime(reactTag, loaded[0].validTime),
"setForecastTime",
);
if (options.playbackState != null) {
requireAccepted(
await WeatherRuntime.setPlaybackState(reactTag, options.playbackState),
"setPlaybackState",
);
}
const runtime = await WeatherRuntime.getState(reactTag);
if (runtime.state !== "READY") {
throw new Error(`WeatherRuntime expected READY after ${loaded.length} frames, got ${runtime.state}`);
}
return { context, frames: loaded, runtime };
}
/**
* Serializes full WeatherV2 loads per map view. Repeated taps while a load is in flight
* reuse the same Promise instead of clearing/submitting a second overlapping timeline.
*/
export function loadWeatherV2WindIntoRuntime(
options: LoadWeatherV2WindOptions,
): Promise<LoadWeatherV2WindResult> {
const existing = inFlightLoads.get(options.reactTag);
if (existing != null) return existing;
const task = loadWeatherV2WindIntoRuntimeInternal(options).finally(() => {
if (inFlightLoads.get(options.reactTag) === task) inFlightLoads.delete(options.reactTag);
});
inFlightLoads.set(options.reactTag, task);
return task;
}

View File

@@ -1,96 +0,0 @@
import type { FieldSubmitResult, PlaybackState, RuntimeSnapshot } from "../types";
export type WeatherV2FetchResponse = Readonly<{
ok: boolean;
status: number;
json(): Promise<unknown>;
arrayBuffer(): Promise<ArrayBuffer>;
}>;
export type WeatherV2Fetch = (
url: string,
init?: Readonly<{ headers?: Readonly<Record<string, string>> }>,
) => Promise<WeatherV2FetchResponse>;
export type WeatherV2HttpClientOptions = Readonly<{
baseUrl: string;
headers?: Readonly<Record<string, string>>;
fetchImpl?: WeatherV2Fetch;
}>;
export type WeatherV2Bounds = Readonly<{
west: number;
south: number;
east: number;
north: number;
}>;
export type WeatherV2WindContext = Readonly<{
datasetId: string;
model: string;
referenceTime: string;
targetTimes: readonly string[];
particleTemplate: string;
bounds?: WeatherV2Bounds;
}>;
export type WeatherV2WindFrameRef = Readonly<{
datasetId: string;
model: string;
referenceTime: string;
targetTime: string;
url: string;
}>;
export type CanonicalWindField = Readonly<{
metadataJson: string;
payload: Uint8Array;
fieldId: string;
validTime: number;
generation: number;
bounds: WeatherV2Bounds;
width: number;
height: number;
sourceUrl: string;
}>;
export type WeatherV2WindClient = Readonly<{
getWindContext(options?: Readonly<{ datasetId?: string; referenceTime?: string }>): Promise<WeatherV2WindContext>;
selectWindFrames(
context: WeatherV2WindContext,
options?: Readonly<{ targetTimes?: readonly string[]; count?: number }>,
): readonly WeatherV2WindFrameRef[];
fetchCanonicalWindFrame(frame: WeatherV2WindFrameRef): Promise<CanonicalWindField>;
}>;
export type WeatherV2BinaryFileStore = Readonly<{
writeTempFile(fileName: string, bytes: Uint8Array): Promise<string>;
removeTempFile?(fileUri: string): Promise<void>;
}>;
export type LoadedWeatherV2WindFrame = Readonly<{
fieldId: string;
validTime: number;
generation: number;
bounds: WeatherV2Bounds;
width: number;
height: number;
sourceUrl: string;
submitResult: FieldSubmitResult;
}>;
export type LoadWeatherV2WindOptions = Readonly<{
reactTag: number;
client: WeatherV2WindClient;
fileStore: WeatherV2BinaryFileStore;
datasetId?: string;
referenceTime?: string;
targetTimes?: readonly string[];
count?: number;
weatherColorVisible?: boolean;
particleVisible?: boolean;
playbackState?: PlaybackState;
}>;
export type LoadWeatherV2WindResult = Readonly<{
context: WeatherV2WindContext;
frames: readonly LoadedWeatherV2WindFrame[];
runtime: RuntimeSnapshot;
}>;

View File

@@ -1,136 +0,0 @@
import type { CanonicalWindField, WeatherV2Bounds, WeatherV2WindFrameRef } from "./types";
const MAGIC = "WVECBIN1";
const NO_DATA = -9999;
const FLOAT_BYTES = 4;
function fail(message: string): never {
throw new Error(`WeatherV2 WVECBIN1: ${message}`);
}
function ascii(bytes: Uint8Array): string {
let value = "";
for (let index = 0; index < bytes.length; index += 1) {
const code = bytes[index];
if (code > 0x7f) fail("header must be ASCII JSON");
value += String.fromCharCode(code);
}
return value;
}
function objectValue(value: unknown, name: string): Record<string, unknown> {
if (value == null || typeof value !== "object" || Array.isArray(value)) fail(`${name} must be an object`);
return value as Record<string, unknown>;
}
function finiteNumber(value: unknown, name: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) fail(`${name} must be finite`);
return value;
}
function positiveInteger(value: unknown, name: string): number {
const number = finiteNumber(value, name);
if (!Number.isInteger(number) || number <= 0) fail(`${name} must be a positive integer`);
return number;
}
function parseBounds(value: unknown): WeatherV2Bounds {
const bounds = objectValue(value, "bounds");
const parsed = {
west: finiteNumber(bounds.west, "bounds.west"),
south: finiteNumber(bounds.south, "bounds.south"),
east: finiteNumber(bounds.east, "bounds.east"),
north: finiteNumber(bounds.north, "bounds.north"),
};
if (!(parsed.west < parsed.east) || !(parsed.south < parsed.north)) fail("bounds ordering is invalid");
return parsed;
}
function epochMillis(targetTime: string): number {
if (!/^\d{10}$/.test(targetTime)) fail(`target_time must be YYYYMMDDHH: ${targetTime}`);
const year = Number(targetTime.slice(0, 4));
const month = Number(targetTime.slice(4, 6));
const day = Number(targetTime.slice(6, 8));
const hour = Number(targetTime.slice(8, 10));
const value = Date.UTC(year, month - 1, day, hour, 0, 0, 0);
if (!Number.isFinite(value)) fail(`invalid target_time: ${targetTime}`);
return value;
}
function assertFields(value: unknown): void {
if (!Array.isArray(value) || value.length !== 4) fail("fields contract is invalid");
const expected = [["u", "float32"], ["v", "float32"], ["speed", "float32"], ["valid", "uint8"]] as const;
expected.forEach(([name, dtype], index) => {
const field = objectValue(value[index], `fields[${index}]`);
if (field.name !== name || field.dtype !== dtype) fail(`unsupported field ${index}`);
});
}
export function decodeWeatherV2WindFrame(
source: ArrayBuffer,
frame: WeatherV2WindFrameRef,
): CanonicalWindField {
const bytes = new Uint8Array(source);
if (bytes.byteLength < 12) fail("payload is shorter than the WVECBIN1 prefix");
if (ascii(bytes.subarray(0, 8)) !== MAGIC) fail("magic mismatch");
const view = new DataView(source);
const headerLength = view.getUint32(8, true);
const headerEnd = 12 + headerLength;
if (headerEnd > bytes.byteLength) fail("header length exceeds payload length");
const header = objectValue(JSON.parse(ascii(bytes.subarray(12, headerEnd))), "header");
if (header.format !== "weather-v2-particle-bin-v1" || header.layer !== "wind") fail("source is not a WeatherV2 wind frame");
if (header.target_time !== frame.targetTime) fail(`target_time ${String(header.target_time)} != ${frame.targetTime}`);
assertFields(header.fields);
const width = positiveInteger(header.width, "width");
const height = positiveInteger(header.height, "height");
const bounds = parseBounds(header.bounds);
const cellCount = width * height;
const uOffset = headerEnd;
const vOffset = uOffset + cellCount * FLOAT_BYTES;
const speedOffset = vOffset + cellCount * FLOAT_BYTES;
const validOffset = speedOffset + cellCount * FLOAT_BYTES;
const expectedLength = validOffset + cellCount;
if (bytes.byteLength !== expectedLength) fail(`byte length ${bytes.byteLength} != ${expectedLength}`);
const outputBuffer = new ArrayBuffer(cellCount * 2 * FLOAT_BYTES);
const output = new DataView(outputBuffer);
for (let cell = 0; cell < cellCount; cell += 1) {
const valid = view.getUint8(validOffset + cell) !== 0;
const u = view.getFloat32(uOffset + cell * FLOAT_BYTES, true);
const v = view.getFloat32(vOffset + cell * FLOAT_BYTES, true);
const outputOffset = cell * 2 * FLOAT_BYTES;
if (valid && (!Number.isFinite(u) || !Number.isFinite(v))) fail(`non-finite U/V at cell ${cell}`);
output.setFloat32(outputOffset, valid ? u : NO_DATA, true);
output.setFloat32(outputOffset + FLOAT_BYTES, valid ? v : NO_DATA, true);
}
const validTime = epochMillis(frame.targetTime);
const fieldId = `${frame.datasetId}-wind-${frame.targetTime}`;
const payload = new Uint8Array(outputBuffer);
const metadataJson = JSON.stringify({
schema: "maplibre-particle-field/v1",
fieldId,
fieldType: "WIND",
displayDomain: "GLOBAL",
generation: validTime,
validTime,
dataVersion: validTime,
bounds,
grid: { width, height },
vector: {
layout: "interleaved-uv",
encoding: "float32-le",
noData: NO_DATA,
unit: "m/s",
origin: "north-west",
rowOrder: "north-to-south",
columnOrder: "west-to-east",
uPositive: "east",
vPositive: "north",
},
payload: { byteLength: payload.byteLength },
source: { kind: "WeatherV2 WVECBIN1", model: frame.model, referenceTime: frame.referenceTime, targetTime: frame.targetTime },
});
return { metadataJson, payload, fieldId, validTime, generation: validTime, bounds, width, height, sourceUrl: frame.url };
}

View File

@@ -1,10 +1,10 @@
{ {
"release": "13.4.1-particle.1", "release": "13.4.1-particle.2",
"releaseState": "validated-ready-for-release-tag", "releaseState": "rn-api-prepared-waiting-consumer-validation",
"upstreamMapLibreNative": "13.4.1", "upstreamMapLibreNative": "13.4.1",
"upstreamCoreCommit": "18545ca10d296704b2cba675b182ff2c8da24588", "upstreamCoreCommit": "18545ca10d296704b2cba675b182ff2c8da24588",
"extensionLine": "particle", "extensionLine": "particle",
"extensionRevision": 1, "extensionRevision": 2,
"platform": { "platform": {
"android": { "android": {
"renderer": "opengl" "renderer": "opengl"
@@ -12,7 +12,7 @@
}, },
"reactNativePackage": { "reactNativePackage": {
"name": "@navsea/maplibre-particle-rn", "name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.1", "version": "13.4.1-particle.2",
"mapLibreReactNative": "11.3.6" "mapLibreReactNative": "11.3.6"
}, },
"nativeArtifact": { "nativeArtifact": {
@@ -20,6 +20,7 @@
"artifact": "android-sdk-particle-opengl", "artifact": "android-sdk-particle-opengl",
"version": "13.4.1-particle.1" "version": "13.4.1-particle.1"
}, },
"releaseRelationship": "13.4.1-particle.2 仅变更 RN/TS Field Renderer 公共接口;Native artifact 保持 13.4.1-particle.1,未构建、修改或发布 AAR。",
"canonicalField": { "canonicalField": {
"schema": "maplibre-particle-field/v1", "schema": "maplibre-particle-field/v1",
"transport": "metadataJson + local file:// payload", "transport": "metadataJson + local file:// payload",
@@ -28,6 +29,13 @@
}, },
"validation": { "validation": {
"androidPackageConsumer": { "androidPackageConsumer": {
"status": "pending",
"targetReactNativePackage": "13.4.1-particle.2",
"consumer": "integration/react-native/MapLibreParticlePackageConsumerTestHost",
"note": "13.4.1-particle.2 尚未完成发布 tarball 的 Consumer 运行验证。"
},
"previousBaseline": {
"reactNativePackage": "13.4.1-particle.1",
"status": "pass", "status": "pass",
"validatedAt": "2026-09-09", "validatedAt": "2026-09-09",
"consumer": "integration/react-native/MapLibreParticlePackageConsumerTestHost", "consumer": "integration/react-native/MapLibreParticlePackageConsumerTestHost",

View File

@@ -600,3 +600,10 @@ WeatherColorLayer 的 `ParticleGpuValidationUBO` designated initializer 补齐 `
- 实际 upstream Android 基线确认是 MapLibre Native `13.4.1` / core `18545ca` / OpenGL;建立 `13.4.1-particle.1` release manifest、`@navsea/maplibre-particle-rn` 与 `cc.navsea.maplibre:android-sdk-particle-opengl` 正式契约。 - 实际 upstream Android 基线确认是 MapLibre Native `13.4.1` / core `18545ca` / OpenGL;建立 `13.4.1-particle.1` release manifest、`@navsea/maplibre-particle-rn` 与 `cc.navsea.maplibre:android-sdk-particle-opengl` 正式契约。
- Android publication 配置、正式 bridge/package 和 Gradle substitution 统一到 NavSea AAR,MLRN 保持唯一 native module;Consumer Host 只依赖正式 npm package,不引用源码路径、Native tree 或 bridge source。 - Android publication 配置、正式 bridge/package 和 Gradle substitution 统一到 NavSea AAR,MLRN 保持唯一 native module;Consumer Host 只依赖正式 npm package,不引用源码路径、Native tree 或 bridge source。
- `npm pack` 与 package TypeScript/static audit 完成;未执行 Android Gradle/AAR/APK/ADB/Jest,未决定 registry,未创建 release tag。下一步为用户手工 AAR publication 与 tarball Consumer APK/实体机验证。 - `npm pack` 与 package TypeScript/static audit 完成;未执行 Android Gradle/AAR/APK/ADB/Jest,未决定 registry,未创建 release tag。下一步为用户手工 AAR publication 与 tarball Consumer APK/实体机验证。
# 2026-09-10 — STEP-064 RN 纯 Field Renderer 接口 V2(IMPLEMENTED / WAITING_CONSUMER_VALIDATION)
- `@navsea/maplibre-particle-rn` 升为 `13.4.1-particle.2`,根入口只提供 `FieldRuntime`、纯类型与 field style fragment;旧 Weather/Wind 公共 API、MLRN facade re-export 与兼容 wrapper 均删除。
- 包内 WeatherV2 HTTP、manifest、time index、decoder 与 loader 已移出,Consumer Test Host 保留自身的真实 fixture/file URI 业务链,并直接从官方 MLRN 导入 Map/Camera。
- `FieldColorStop.value` 在 RN 薄层转换为冻结 Native ABI;无 Native module 时 import 安全,实际调用明确失败。图场与粒子继续共享一次 canonical Field 提交,显示可独立控制。
- Native/Core/JNI/shader/Android AAR 未改,artifact 继续 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;Android Gradle/AAR/APK/ADB/Jest/NavSea 均未执行。package 与 Consumer TypeScript 检查已通过,发布 tarball 的实际 Consumer 验证待后续完成。