feat: add STEP-062 real weather test map
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-09 09:13:03 +08:00
parent 364596574a
commit a91e3601f3
16 changed files with 302 additions and 77 deletions

View File

@@ -1,107 +1,88 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
import { Map } from "@maplibre/maplibre-react-native";
import { WeatherRuntimeBridge, type RuntimeSnapshot, type TargetFps } from "@maplibre-particle/weather-runtime-bridge";
import { writeCanonicalWindFixture } from "./src/canonicalWindFixture";
import { Camera, Map } from "@maplibre/maplibre-react-native";
import { WeatherRuntimeBridge, type ColorStop, type RuntimeSnapshot, type TargetFps } from "@maplibre-particle/weather-runtime-bridge";
import { materializeRealWeatherFrames, realWeatherFrames } from "./src/realWeatherDataset";
import { weatherTestStyle } from "./src/weatherStyle";
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", weatherOpacity: "0.62" };
const frozenStops: ColorStop[] = [
{ speedMps: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { speedMps: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 },
{ speedMps: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { speedMps: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
];
export default function App() {
const [reactTag, setReactTag] = useState<number | null>(null);
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot);
const [result, setResult] = useState("Style is loading");
const [opacity, setOpacity] = useState("0.62");
const [binaryTransport, setBinaryTransport] = useState("PENDING: submit fixture to validate FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
const [invalidProbe, setInvalidProbe] = useState("PENDING");
const [t0Submit, setT0Submit] = useState("PENDING");
const [t1Submit, setT1Submit] = useState("PENDING");
const [fieldWindow, setFieldWindow] = useState("TIMELINE_NOT_READY");
const [basemap, setBasemap] = useState("LOADING");
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 [weatherVisible, setWeatherVisible] = useState(true);
const [particleVisible, setParticleVisible] = useState(true);
const [visual, setVisual] = useState(initialVisual);
const refresh = useCallback(async (tag: number) => setSnapshot(await WeatherRuntimeBridge.getState(tag)), []);
const attach = useCallback(async (tag: number) => {
try { setSnapshot(await WeatherRuntimeBridge.attach(tag)); setReactTag(tag); setResult("WeatherRuntime attached"); }
catch (error) { setResult(String(error)); }
}, []);
// MLRN forwards the native MapView event. Its target is the concrete Fabric MLRNMapView tag;
// this is the same tag consumed by MLRN's own NativeMapViewModule.
try { await WeatherRuntimeBridge.attach(tag); await refresh(tag); setReactTag(tag); setResult("WeatherRuntime attached; style ready"); }
catch (error) { setResult(`style/attach error: ${String(error)}`); }
}, [refresh]);
const onStyleReady = useCallback((event: unknown) => {
const tag = findNodeHandle((event as { target?: unknown }).target as never);
if (tag != null) void attach(tag);
}, [attach]);
useEffect(() => () => {
if (reactTag != null) void WeatherRuntimeBridge.detach(reactTag).catch(() => undefined);
}, [reactTag]);
useEffect(() => () => { if (reactTag != null) void WeatherRuntimeBridge.detach(reactTag).catch(() => undefined); }, [reactTag]);
const command = useCallback(async (name: string, operation: (tag: number) => Promise<unknown>) => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for style ready");
try { const value = await operation(reactTag); setResult(`${name}: ${String(value)}`); setSnapshot(await WeatherRuntimeBridge.getState(reactTag)); }
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
try { const value = await operation(reactTag); await refresh(reactTag); setResult(`${name}: ${String(value)}`); }
catch (error) { setResult(`${name}: ${String(error)}`); }
}, [reactTag]);
}, [reactTag, refresh]);
const submitBinaryFixture = useCallback(async () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for style ready");
const loadRealWeather = useCallback(async () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY", "ACCEPTED_NEXT_SNAPSHOT", "ACCEPTED_NEXT_SNAPSHOT"];
try {
setBinaryTransport("PENDING: clearing fields and validating FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
setInvalidProbe("PENDING");
setT0Submit("PENDING");
setT1Submit("PENDING");
setFieldWindow("PENDING: awaiting Core initial window");
const clear = await WeatherRuntimeBridge.clearWindFields(reactTag);
if (clear !== "ACCEPTED") throw new Error(`clearWindFields: ${clear}`);
const [t0, t1, invalid] = writeCanonicalWindFixture();
const rejected = await WeatherRuntimeBridge.submitWindField(reactTag, invalid.metadataJson, invalid.payloadFileUri);
setInvalidProbe(rejected);
if (rejected !== "REJECTED_ADAPTER") throw new Error(`invalid probe expected REJECTED_ADAPTER, got ${rejected}`);
const t0Result = await WeatherRuntimeBridge.submitWindField(reactTag, t0.metadataJson, t0.payloadFileUri);
setT0Submit(t0Result);
if (t0Result !== "ACCEPTED_PENDING_INITIAL_WINDOW") throw new Error(`T0 expected ACCEPTED_PENDING_INITIAL_WINDOW, got ${t0Result}`);
const t1Result = await WeatherRuntimeBridge.submitWindField(reactTag, t1.metadataJson, t1.payloadFileUri);
setT1Submit(t1Result);
if (t1Result !== "INITIAL_WINDOW_READY") throw new Error(`T1 expected INITIAL_WINDOW_READY, got ${t1Result}`);
const native = await WeatherRuntimeBridge.getState(reactTag);
setSnapshot(native);
setTransport("LOADING: materializing Expo Asset local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
if (await WeatherRuntimeBridge.clearWindFields(reactTag) !== "ACCEPTED") throw new Error("clearWindFields was rejected");
const localFrames = await materializeRealWeatherFrames();
for (let index = 0; index < localFrames.length; index += 1) {
const frame = localFrames[index]; const submit = await WeatherRuntimeBridge.submitWindField(reactTag, frame.metadataJson, frame.payloadFileUri);
setFrames((current) => ({ ...current, [frame.label]: submit }));
if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`);
}
const native = await WeatherRuntimeBridge.getState(reactTag); setSnapshot(native);
if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`);
setFieldWindow(`Core evidence: INITIAL_WINDOW_READY + runtime READY; producer identity: T0=${t0.generation}@${t0.validTime}, T1=${t1.generation}@${t1.validTime}; MIX: Core-owned / not exposed`);
setBinaryTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
setResult(`Binary field submit: invalid=${rejected}, T0=${t0Result}, T1=${t1Result}`);
} catch (error) {
setBinaryTransport(`ERROR: ${String(error)}`);
setResult(`Binary field submit: ${String(error)}`);
setFieldWindow("TIMELINE_NOT_READY");
}
setTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); setResult("T0-T3 accepted in strict producer timestamp order; Native runtime READY");
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD REAL WEATHER T0-T3: ${String(error)}`); }
}, [reactTag]);
const buttons = useMemo(() => [
["getState", (tag: number) => WeatherRuntimeBridge.getState(tag)],
["enable", (tag: number) => WeatherRuntimeBridge.setWeatherEnabled(tag, true)],
["weather visible", (tag: number) => WeatherRuntimeBridge.setWeatherColorVisible(tag, true)],
["particle visible", (tag: number) => WeatherRuntimeBridge.setParticleVisible(tag, true)],
["FPS 30", (tag: number) => WeatherRuntimeBridge.setTargetFps(tag, "FPS_30" as TargetFps)],
["PLAY", (tag: number) => WeatherRuntimeBridge.setPlaybackState(tag, "PLAY")],
["PAUSE", (tag: number) => WeatherRuntimeBridge.setPlaybackState(tag, "PAUSE")]
const updateVisual = useCallback((key: keyof typeof initialVisual, value: string) => setVisual((current) => ({ ...current, [key]: value })), []);
const applyVisual = useCallback(() => void command("particle visual", async (tag) => {
const results = await Promise.all([
WeatherRuntimeBridge.setParticleCount(tag, Number(visual.count)), WeatherRuntimeBridge.setParticleLineWidth(tag, Number(visual.width)), WeatherRuntimeBridge.setParticleLength(tag, Number(visual.length)), WeatherRuntimeBridge.setParticleOpacity(tag, Number(visual.opacity)), WeatherRuntimeBridge.setTrailOpacity(tag, Number(visual.trailOpacity)), WeatherRuntimeBridge.setTrailFade(tag, Number(visual.trailFade)), WeatherRuntimeBridge.setWeatherColorOpacity(tag, Number(visual.weatherOpacity)),
]);
if (results.some((value) => value !== "ACCEPTED")) throw new Error(results.join(", ")); return "ACCEPTED";
}), [command, visual]);
const controls = useMemo(() => [
["PLAY", (tag: number) => WeatherRuntimeBridge.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => WeatherRuntimeBridge.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => WeatherRuntimeBridge.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => WeatherRuntimeBridge.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => WeatherRuntimeBridge.setPlaybackSpeed(tag, "DOUBLE")],
["FPS AUTO", (tag: number) => WeatherRuntimeBridge.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => WeatherRuntimeBridge.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => WeatherRuntimeBridge.setTargetFps(tag, "FPS_60" as TargetFps)], ["Weather stops", (tag: number) => WeatherRuntimeBridge.setWeatherColorStops(tag, frozenStops)], ["Particle color", (tag: number) => WeatherRuntimeBridge.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => WeatherRuntimeBridge.setTrailColor(tag, 0.35, 0.85, 1, 0.8)],
] as const, []);
return <SafeAreaView style={styles.root}>
<View style={styles.map}><Map mapStyle={weatherTestStyle} onDidFinishLoadingStyle={onStyleReady} /></View>
<ScrollView style={styles.controls} contentContainerStyle={styles.controlContent}>
<Text>reactTag: {reactTag ?? "not attached"}</Text>
<Text>runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text>
<Text selectable>Binary Transport: {binaryTransport}</Text>
<Text selectable>Invalid Probe: {invalidProbe}</Text>
<Text selectable>T0 Submit Result: {t0Submit}</Text>
<Text selectable>T1 Submit Result: {t1Submit}</Text>
<Text selectable>Runtime Field Window: {fieldWindow}</Text>
<Text selectable>{result}</Text>
<Button title="Submit binary WIND T0/T1" onPress={() => void submitBinaryFixture()} />
{buttons.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}
<TextInput value={opacity} onChangeText={setOpacity} keyboardType="decimal-pad" style={styles.input} />
<Button title="weather opacity" onPress={() => void command("weather opacity", (tag) => WeatherRuntimeBridge.setWeatherColorOpacity(tag, Number(opacity)))} />
<View style={styles.map}><Map mapStyle={weatherTestStyle} onDidFinishLoadingStyle={onStyleReady} onDidFinishLoadingMap={() => setBasemap("READY")} onDidFailLoadingMap={() => { setBasemap("ERROR"); setResult("map loading error"); }}><Camera initialViewState={{ center: [135, 32], zoom: 3.9 }} /></Map></View>
<ScrollView style={styles.controls} contentContainerStyle={styles.content}>
<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>)}
<Text selectable>{result}</Text><Button title="LOAD REAL WEATHER T0-T3" onPress={() => void loadRealWeather()} />
<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) => WeatherRuntimeBridge.setWeatherColorVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => WeatherRuntimeBridge.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) => WeatherRuntimeBridge.setForecastTime(tag, frame.validTime))} />)}</View>
<Text>Visual: count / width / length / particle opacity / trail opacity / trail fade / WeatherColor opacity</Text><View style={styles.row}>{(Object.keys(visual) as Array<keyof typeof initialVisual>).map((key) => <TextInput key={key} value={visual[key]} onChangeText={(value) => updateVisual(key, value)} keyboardType="decimal-pad" style={styles.input} />)}<Button title="Apply visual" onPress={applyVisual} /></View>
</ScrollView>
</SafeAreaView>;
}
const styles = StyleSheet.create({
root: { flex: 1 }, map: { flex: 1 }, controls: { maxHeight: 280 }, controlContent: { gap: 8, padding: 12 }, input: { borderWidth: 1, padding: 6 }
});
const styles = StyleSheet.create({ root: { flex: 1 }, map: { flex: 1 }, controls: { maxHeight: 310, backgroundColor: "#fff" }, content: { gap: 7, padding: 10 }, row: { flexDirection: "row", flexWrap: "wrap", gap: 6, alignItems: "center" }, input: { borderWidth: 1, minWidth: 48, padding: 5 } });

View File

@@ -47,6 +47,14 @@ Style JSON 明确创建 background、host-field `RasterArraySource`、`WeatherCo
启动入口是 `App.tsx`。`app.json` 已固定 `orientation: landscape`;下一次 Android prebuild/build 应生成 `MainActivity android:screenOrientation="landscape"`。Android build、安装及设备操作必须由用户手工执行,详见 `docs/60_测试/STEP-060_RNTestHost_Android人工编译与验收_v1.md`。
## STEP-062 真实 WeatherV2 综合地图
Test Host 固定消费已验收的 `org.maplibre.gl:android-sdk-opengl:13.4.1-step061`,本阶段不构建或发布 AAR。页面 style 顺序是 `background → GSI raster → WeatherColorLayer → RasterParticleLayer`;GSI 使用国土地理院标准地图 `https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png`(256、maxzoom 18、含 attribution),相机初始聚焦日本及周边 WeatherV2 bounds。
受控 assets `assets/weather/ecmwf-hres-wind-20260807/` 原样复制 Android Validation 的 `dataset.json` 与四个 `metadata.json`/`field.f32`。`expo-asset` 是明确直接依赖,`metro.config.js` 仅增加 `.f32` asset extension。点击 `LOAD REAL WEATHER T0-T3` 会先 clear,然后通过 `Asset.fromModule(...).downloadAsync()` 取得四个 Expo cache `file://` URI,按 producer validTime 提交;JS 只传小型 metadata JSON 和文件引用,不 materialize 3.01 MiB payload 为数组、JSON、base64 或 hex。
硬断言依次为 T0=`ACCEPTED_PENDING_INITIAL_WINDOW`、T1=`INITIAL_WINDOW_READY`、T2/T3=`ACCEPTED_NEXT_SNAPSHOT`,并在最后要求 Native state=`READY`;任何偏差均显示 `ERROR`。界面提供 PLAY/PAUSE、HALF/NORMAL/DOUBLE、四个 epoch-ms forecast seek、AUTO/30/60 FPS、两层可见性、冻结 WeatherColor stops、独立 particle/trail 色彩,以及粒子/轨迹视觉参数。Expo/系统管理 asset cache;本页不自建 `.f32` 临时文件。
## Windows 安装与 Metro reverse
Test Host 的 Metro 固定在 8091,但 Debug APK 的 RN dev-server 探测仍从设备 `localhost:8081` 发起。手工安装前以受控脚本 `scripts/05-install-and-run.ps1` 保留两条 reverse:`tcp:8091 -> tcp:8091` 与 `tcp:8081 -> tcp:8091`。脚本固定设备 `192.168.1.146`、package `org.maplibre.particle.rntesthost`、activity `.MainActivity`,并在安装前完成 reverse。

View File

@@ -0,0 +1,41 @@
{
"schema": "maplibre-particle-field/v1",
"fieldId": "ecmwf-hres-wind-2026080700",
"fieldType": "WIND",
"displayDomain": "GLOBAL",
"generation": 1786060800000,
"validTime": 1786060800000,
"dataVersion": 1786060800000,
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"grid": {
"width": 513,
"height": 769
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"noData": -9999.0,
"unit": "m/s",
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east",
"uPositive": "east",
"vPositive": "north"
},
"payload": {
"byteLength": 3155976,
"sha256": "363e2cd2e77501f6bceecbb4aaa68c4f9ef46998a60319ae5b7aba3548995283"
},
"source": {
"kind": "WeatherV2 ECMWF HRES WVECBIN1",
"referenceTime": "2026080600",
"targetTime": "2026080700",
"sourceBytes": 5128989,
"resampled": false
}
}

View File

@@ -0,0 +1,41 @@
{
"schema": "maplibre-particle-field/v1",
"fieldId": "ecmwf-hres-wind-2026080706",
"fieldType": "WIND",
"displayDomain": "GLOBAL",
"generation": 1786082400000,
"validTime": 1786082400000,
"dataVersion": 1786082400000,
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"grid": {
"width": 513,
"height": 769
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"noData": -9999.0,
"unit": "m/s",
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east",
"uPositive": "east",
"vPositive": "north"
},
"payload": {
"byteLength": 3155976,
"sha256": "ef7a8e4b1c47f652f8f7012de16a87c16e3b25556a8f4686629f80514426c451"
},
"source": {
"kind": "WeatherV2 ECMWF HRES WVECBIN1",
"referenceTime": "2026080600",
"targetTime": "2026080706",
"sourceBytes": 5128989,
"resampled": false
}
}

View File

@@ -0,0 +1,41 @@
{
"schema": "maplibre-particle-field/v1",
"fieldId": "ecmwf-hres-wind-2026080712",
"fieldType": "WIND",
"displayDomain": "GLOBAL",
"generation": 1786104000000,
"validTime": 1786104000000,
"dataVersion": 1786104000000,
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"grid": {
"width": 513,
"height": 769
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"noData": -9999.0,
"unit": "m/s",
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east",
"uPositive": "east",
"vPositive": "north"
},
"payload": {
"byteLength": 3155976,
"sha256": "d86a6a51fd5216cf9368756962a5366fd32a627afd40cfde235ba7358c02c5a0"
},
"source": {
"kind": "WeatherV2 ECMWF HRES WVECBIN1",
"referenceTime": "2026080600",
"targetTime": "2026080712",
"sourceBytes": 5128989,
"resampled": false
}
}

View File

@@ -0,0 +1,41 @@
{
"schema": "maplibre-particle-field/v1",
"fieldId": "ecmwf-hres-wind-2026080718",
"fieldType": "WIND",
"displayDomain": "GLOBAL",
"generation": 1786125600000,
"validTime": 1786125600000,
"dataVersion": 1786125600000,
"bounds": {
"west": 112.5,
"south": 0.0,
"east": 157.5,
"north": 55.77657301866769
},
"grid": {
"width": 513,
"height": 769
},
"vector": {
"layout": "interleaved-uv",
"encoding": "float32-le",
"noData": -9999.0,
"unit": "m/s",
"origin": "north-west",
"rowOrder": "north-to-south",
"columnOrder": "west-to-east",
"uPositive": "east",
"vPositive": "north"
},
"payload": {
"byteLength": 3155976,
"sha256": "970785b0334a9d1511b8897874a81f681814a32b1e0b90e37cf64b77ca9fa39b"
},
"source": {
"kind": "WeatherV2 ECMWF HRES WVECBIN1",
"referenceTime": "2026080600",
"targetTime": "2026080718",
"sourceBytes": 5128989,
"resampled": false
}
}

View File

@@ -0,0 +1,14 @@
{
"schema": "maplibre-particle-test-dataset/v1",
"datasetId": "ecmwf-hres-wind-20260807",
"source": "WeatherV2 ECMWF HRES WVECBIN1",
"referenceTime": "2026080600",
"validTimes": [
"2026080700",
"2026080706",
"2026080712",
"2026080718"
],
"snapshotLayout": "<validTime>/metadata.json + <validTime>/field.f32",
"notes": "开发期离线 canonical 测试索引;不是 Weather Manifest,也不进入 ParticleFieldSnapshot。"
}

View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
config.resolver.assetExts = [...config.resolver.assetExts, "f32"];
module.exports = config;

View File

@@ -11,6 +11,7 @@
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
"@maplibre/maplibre-react-native": "11.3.6",
"expo": "56.0.4",
"expo-asset": "~56.0.14",
"expo-file-system": "56.0.11",
"react": "19.2.3",
"react-native": "0.85.2"

View File

@@ -12,6 +12,7 @@
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
"@maplibre/maplibre-react-native": "11.3.6",
"expo": "56.0.4",
"expo-asset": "~56.0.14",
"expo-file-system": "56.0.11",
"react": "19.2.3",
"react-native": "0.85.2"

View File

@@ -0,0 +1,40 @@
import { Asset } from "expo-asset";
export type RealWeatherFrame = Readonly<{
label: "T0" | "T1" | "T2" | "T3";
cycle: "2026080700" | "2026080706" | "2026080712" | "2026080718";
metadataJson: string;
validTime: number;
generation: number;
payloadModule: number;
}>;
type CanonicalMetadata = Readonly<{ validTime: number; generation: number }>;
// Static requires register all four raw .f32 assets with Metro/Expo; payload bytes never enter JS memory.
const metadataT0 = require("../assets/weather/ecmwf-hres-wind-20260807/2026080700/metadata.json") as CanonicalMetadata;
const metadataT1 = require("../assets/weather/ecmwf-hres-wind-20260807/2026080706/metadata.json") as CanonicalMetadata;
const metadataT2 = require("../assets/weather/ecmwf-hres-wind-20260807/2026080712/metadata.json") as CanonicalMetadata;
const metadataT3 = require("../assets/weather/ecmwf-hres-wind-20260807/2026080718/metadata.json") as CanonicalMetadata;
function frame(label: RealWeatherFrame["label"], cycle: RealWeatherFrame["cycle"], metadata: CanonicalMetadata, payloadModule: number): RealWeatherFrame {
return { label, cycle, metadataJson: JSON.stringify(metadata), validTime: metadata.validTime, generation: metadata.generation, payloadModule };
}
export const realWeatherFrames = [
frame("T0", "2026080700", metadataT0, require("../assets/weather/ecmwf-hres-wind-20260807/2026080700/field.f32")),
frame("T1", "2026080706", metadataT1, require("../assets/weather/ecmwf-hres-wind-20260807/2026080706/field.f32")),
frame("T2", "2026080712", metadataT2, require("../assets/weather/ecmwf-hres-wind-20260807/2026080712/field.f32")),
frame("T3", "2026080718", metadataT3, require("../assets/weather/ecmwf-hres-wind-20260807/2026080718/field.f32")),
] as const;
export async function materializeRealWeatherFrames(): Promise<ReadonlyArray<RealWeatherFrame & { payloadFileUri: string }>> {
return Promise.all(realWeatherFrames.map(async (weatherFrame) => {
const asset = Asset.fromModule(weatherFrame.payloadModule);
await asset.downloadAsync();
if (asset.localUri == null || !asset.localUri.startsWith("file://")) {
throw new Error(`${weatherFrame.label} Asset.downloadAsync did not provide a local file:// URI`);
}
return { ...weatherFrame, payloadFileUri: asset.localUri };
}));
}

View File

@@ -1,15 +1,25 @@
/** No field is embedded here: RasterArraySource is reserved for STEP-061 binary submission. */
/** No field is embedded here: RasterArraySource is reserved for STEP-061 file-reference submission. */
export const WEATHER_SOURCE_ID = "rn-weather-fields";
export const WEATHER_COLOR_LAYER_ID = "rn-weather-color";
export const PARTICLE_LAYER_ID = "rn-weather-particles";
export const GSI_SOURCE_ID = "gsi-standard";
export const GSI_LAYER_ID = "gsi-standard";
export const weatherTestStyle = JSON.stringify({
version: 8,
sources: {
[GSI_SOURCE_ID]: {
type: "raster",
tiles: ["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"],
tileSize: 256,
maxzoom: 18,
attribution: "\u56fd\u571f\u5730\u7406\u9662 / GSI"
},
[WEATHER_SOURCE_ID]: { type: "raster-array", "host-field-submission": true }
},
layers: [
{ id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } },
{ id: GSI_LAYER_ID, type: "raster", source: GSI_SOURCE_ID, paint: { "raster-opacity": 1 } },
{ id: WEATHER_COLOR_LAYER_ID, type: "weather-color", source: WEATHER_SOURCE_ID,
paint: { "weather-color-opacity": 0.62 } },
{ id: PARTICLE_LAYER_ID, type: "raster-particle", source: WEATHER_SOURCE_ID,