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

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

View File

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

View File

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

View File

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

View File

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

View File

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