feat: add WeatherV2 HTTP particle provider
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-09 20:09:28 +08:00
parent bcd020fc55
commit e15f583c65
11 changed files with 615 additions and 7 deletions

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
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 { Camera, Map, WeatherRuntime, type ColorStop, 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 };
@@ -18,6 +19,7 @@ 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 [weatherVisible, setWeatherVisible] = useState(true); 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);
@@ -57,6 +59,21 @@ export default function App() {
} 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");
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)}`);
}
}, [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([
@@ -76,6 +93,7 @@ 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="LOAD HTTP WEATHER T0-T3" 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={`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}>{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) => WeatherRuntime.setForecastTime(tag, frame.validTime))} />)}</View>

View File

@@ -2,8 +2,10 @@
这是正式发布包 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。 这是正式发布包 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。
禁止直接依赖 `@maplibre/maplibre-react-native`、旧 `weather-runtime-bridge`、MapLibre Native source tree、Maven Local、本地 AAR 或 workspace/source-path bridge。Map、Camera、WeatherRuntime 统一从 `@navsea/maplibre-particle-rn` 导入。 禁止直接依赖 `@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` 导入。
当前验收数据复用 STEP-062 已通过的真实 WeatherV2 fixture:ECMWF HRES 20260807,513×769,T0/T1/T2/T3 四个 canonical `float32-le / interleaved-uv` 风场。`.f32` 通过 Expo Asset materialize 为本地 `file://` URI 后提交给 WeatherRuntime,payload 不进入 JS 数组。 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。
验收目标是同时看到 GSI 底图、WeatherColor 与 Particle,并确认 T0~T3 顺序接受、Runtime 进入 READY。这样验证的是“正式 npm 包 → 正式 Maven 仓库 → 正式 AAR → RN/Native/Core”的完整升级链,而不是源码工程或开发期 AAR。 HTTP 回归固定使用 ECMWF HRES `referenceTime=2026080600` 与 2026080700/06/12/18 四帧,以便和既有 fixture 做同源对照。正式 NavSea 不应固定这些时次,而应通过 `getWindContext()` 获取服务器可用时次后按业务策略选择。
验收目标是同时看到 GSI 底图、WeatherColor 与 Particle,并确认 T0~T3 顺序接受、Runtime 进入 READY。这样验证的是“正式 npm 包 → WeatherV2 HTTP/转换 → 正式 Maven AAR → RN/Native/Core”的完整升级链。

View File

@@ -0,0 +1,40 @@
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

@@ -6,3 +6,6 @@
- 包含 WeatherColor、RasterParticle、HostField binary file ingress、Timeline / Playback 与 React Native WeatherRuntime。 - 包含 WeatherColor、RasterParticle、HostField binary file ingress、Timeline / Playback 与 React Native WeatherRuntime。
- Native/RN binary ingress 已在 STEP-061 验证;GSI + 真实 WeatherV2 + Particle Android 综合验证已在 STEP-062 完成。 - Native/RN binary ingress 已在 STEP-061 验证;GSI + 真实 WeatherV2 + Particle Android 综合验证已在 STEP-062 完成。
- 发布前 Consumer 验证修正 MLRN 依赖为 peer dependency,避免嵌套 native module 与 Windows codegen 长路径。 - 发布前 Consumer 验证修正 MLRN 依赖为 peer dependency,避免嵌套 native module 与 Windows codegen 长路径。
- 新增纯 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,9 +1,71 @@
# @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,使 npm 将唯一 MLRN 放在 App 根依赖层,并由其提供 `Map`、`Camera` 等 MapLibre RN API,并以 `WeatherRuntime` 提供既有 HostField/Timeline 低频控制。 `13.4.1-particle.1` 是 NavSea 维护的 MapLibre Particle React Native 入口。Consumer 只直接安装本包;本包将 `@maplibre/maplibre-react-native@11.3.6` 声明为 peer dependency,并由 `WeatherRuntime` 提供 HostField / Timeline 的低频控制。
Android 使用 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`。Expo Consumer 应在 app config 加入 `@navsea/maplibre-particle-rn/app.plugin`;plugin 将 MLRN 对 OpenGL SDK 的请求替换为该唯一 NavSea artifact,避免官方与 Particle AAR 同时进入 APK。正式 Android Maven 仓库为 `https://pbf.jpcn.ccwu.cc/maven`;plugin 会自动加入该仓库。npm registry 尚未决定时,可先安装本包的 `.tgz` 做发布包验证。 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。
WeatherColor 和 RasterParticle 是 Core-only style layer,不是 React 组件;请用 `createParticleWeatherStyle()` 或等价 style JSON 声明。binary submit 始终为 `metadataJson + local file:// payload`,通过 `WeatherRuntime.submitWindField()` 进入既有 Native/Core 链路;本包不创建第二套 Timeline 或 field ingress。 WeatherColor 和 RasterParticle 是 Core-only style layer,不是 React 组件。使用 `createParticleWeatherStyle()` 或等价 Style JSON 声明。binary submit 始终为 `metadataJson + local file:// payload`,通过 `WeatherRuntime.submitWindField()` 进入既有 Native/Core 链路。
本包不包含 Test Host、WeatherV2 fixture、AAR、APK、cache 或凭证。发布前必须先通过正式 AAR 手工构建、tarball Consumer APK 和实体机 smoke;本任务不创建 release tag。 ## 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 {
WeatherV2HttpClient,
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;
},
};
```
`fileStore` 是 Host 适配点,不属于 WeatherV2 协议。NavSea 可以换成自己的缓存目录、文件系统和生命周期策略,不需要修改 HTTP client 或 canonical converter。
```ts
await loadWeatherV2WindIntoRuntime({
reactTag,
client,
fileStore,
datasetId: "ecmwf-hres",
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 回归。
转换器读取服务端 `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。
下载/转换/提交按帧串行执行,避免同时在 JS 内存保留多份约 5 MiB 的 WVECBIN1 与约 3 MiB 的 canonical payload。没有 `number[]`、JSON 数值数组、base64 或 per-frame JNI Field 上传。
本包不包含 Test Host、WeatherV2 fixture、AAR、APK、cache 或凭证。发布前仍需通过 tarball Consumer APK 与实体机 smoke;Release Tag 由 Release Freeze 阶段单独创建。

View File

@@ -1,5 +1,6 @@
export * from "@maplibre/maplibre-react-native"; export * from "@maplibre/maplibre-react-native";
export { default as WeatherRuntime } from "./NativeWeatherRuntimeBridge"; export { default as WeatherRuntime } from "./NativeWeatherRuntimeBridge";
export * from "./weatherV2";
export type { export type {
ColorStop, ColorStop,
CommandResult, CommandResult,

View File

@@ -0,0 +1,138 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,95 @@
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.
*/
export async function loadWeatherV2WindIntoRuntime(
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 };
}

View File

@@ -0,0 +1,96 @@
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

@@ -0,0 +1,136 @@
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 };
}