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:
@@ -6,3 +6,6 @@
|
||||
- 包含 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 长路径。
|
||||
- 新增纯 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
|
||||
|
||||
`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 { default as WeatherRuntime } from "./NativeWeatherRuntimeBridge";
|
||||
export * from "./weatherV2";
|
||||
export type {
|
||||
ColorStop,
|
||||
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