feat: add WeatherV2 HTTP particle provider
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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”的完整升级链。
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 可复用自己的缓存实现。
|
||||||
|
|||||||
@@ -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 阶段单独创建。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/maplibre-particle-rn/src/weatherV2/index.ts
Normal file
17
packages/maplibre-particle-rn/src/weatherV2/index.ts
Normal 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";
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
96
packages/maplibre-particle-rn/src/weatherV2/types.ts
Normal file
96
packages/maplibre-particle-rn/src/weatherV2/types.ts
Normal 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;
|
||||||
|
}>;
|
||||||
136
packages/maplibre-particle-rn/src/weatherV2/wvecbin1.ts
Normal file
136
packages/maplibre-particle-rn/src/weatherV2/wvecbin1.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user