Files
MapLibreParticleLab/integration/react-native/MapLibreParticleRNTestHost/App.tsx
tei f47469d2ab
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
test: integrate particle.10 online QBIN host
2026-09-21 20:21:16 +09:00

454 lines
15 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Button,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
findNodeHandle,
} from "react-native";
import { Camera, Map } from "@maplibre/maplibre-react-native";
import { FieldRuntime, type RuntimeSnapshot } from "@navsea/maplibre-particle-rn";
import {
materializeV4Frame as materializeV2Frame,
qbinGoldenDatasets,
} from "./src/qbinGoldenFixtures";
import {
materializeV4Frame as materializeV3Frame,
qbinV3GoldenDatasets,
type V4Dataset,
} from "./src/qbinV3GoldenFixtures";
import {
colorStopsFor,
legendFor,
type WeatherDatasetId,
} from "./src/weatherPalettes";
import {
downloadOnlineQbin,
type QbinFormat,
} from "./src/onlineQbin";
import { weatherTestStyle } from "./src/weatherStyle";
const initialSnapshot: RuntimeSnapshot = {
state: "WAITING_FOR_FIELDS",
playbackState: "PAUSE",
targetFps: "AUTO",
disposed: false,
};
const initialVisual = {
count: "4096",
width: "1.8",
length: "0.028",
opacity: "0.85",
trailOpacity: "0.72",
trailFade: "0.975",
weatherOpacity: "1.0",
};
function datasetFor(format: QbinFormat, id: WeatherDatasetId): V4Dataset {
const datasets = format === "V3" ? qbinV3GoldenDatasets : qbinGoldenDatasets;
const found = datasets.find((item) => item.id === id);
if (!found) throw new Error("Dataset missing: " + format + " / " + id);
return found as V4Dataset;
}
function valueSourceFor(id: WeatherDatasetId): "SCALAR" | "VECTOR_MAGNITUDE" {
return id === "wind" || id === "current" ? "VECTOR_MAGNITUDE" : "SCALAR";
}
export default function App() {
const [reactTag, setReactTag] = useState<number | null>(null);
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot);
const [mapStatus, setMapStatus] = useState("LOADING");
const [weatherStatus, setWeatherStatus] = useState("WAITING");
const [result, setResult] = useState("Waiting for map/style");
const [activeSource, setActiveSource] = useState("none");
const [format, setFormat] = useState<QbinFormat>("V3");
const [datasetId, setDatasetId] = useState<WeatherDatasetId>("wind");
const [weatherVisible, setWeatherVisible] = useState(true);
const [particleVisible, setParticleVisible] = useState(true);
const [controlsCollapsed, setControlsCollapsed] = useState(false);
const [loading, setLoading] = useState(false);
const [visual, setVisual] = useState(initialVisual);
const autoLoadTagRef = useRef<number | null>(null);
const selectedDataset = useMemo(
() => datasetFor(format, datasetId),
[format, datasetId],
);
const refresh = useCallback(async (tag: number) => {
setSnapshot(await FieldRuntime.getState(tag));
}, []);
const applyVisualToTag = useCallback(async (tag: number) => {
const results = await Promise.all([
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.weatherOpacity)),
]);
if (results.some((value) => value !== "ACCEPTED")) {
throw new Error(results.join(", "));
}
}, [visual]);
const prepareDataset = useCallback(async (
tag: number,
dataset: V4Dataset,
) => {
const clearEncoded = await FieldRuntime.clearAllEncodedFields(tag);
if (clearEncoded !== "ACCEPTED") throw new Error("clearAllEncodedFields rejected");
const clearCanonical = await FieldRuntime.clearFields(tag);
if (clearCanonical !== "ACCEPTED") throw new Error("clearFields rejected");
const valueSource = await FieldRuntime.setFieldValueSource(
tag,
valueSourceFor(dataset.id),
);
if (valueSource !== "ACCEPTED") throw new Error("setFieldValueSource rejected");
const stops = await FieldRuntime.setFieldColorStops(
tag,
colorStopsFor(dataset.id),
);
if (stops !== "ACCEPTED") throw new Error("setFieldColorStops rejected");
const color = await FieldRuntime.setEncodedColorBinding(tag, dataset.colorBinding);
if (color !== "ACCEPTED") throw new Error("setEncodedColorBinding rejected");
if (dataset.particleBinding != null) {
const particle = await FieldRuntime.setEncodedParticleBinding(
tag,
dataset.particleBinding,
);
if (particle !== "ACCEPTED") {
throw new Error("setEncodedParticleBinding rejected");
}
}
const hasParticle = dataset.particleBinding != null;
await FieldRuntime.setFieldVisible(tag, true);
await FieldRuntime.setParticleVisible(tag, hasParticle);
await applyVisualToTag(tag);
setWeatherVisible(true);
setParticleVisible(hasParticle);
}, [applyVisualToTag]);
const finishLoad = useCallback(async (
tag: number,
dataset: V4Dataset,
midpointMs: number,
sourceLabel: string,
) => {
const midpoint = await FieldRuntime.setEncodedForecastTime(tag, midpointMs);
if (midpoint !== "ACCEPTED") throw new Error("setEncodedForecastTime rejected");
await FieldRuntime.setFieldVisible(tag, true);
await FieldRuntime.setParticleVisible(tag, dataset.particleBinding != null);
await refresh(tag);
setWeatherStatus("READY");
setActiveSource(sourceLabel);
setResult(
dataset.id + " READY / " + valueSourceFor(dataset.id) +
" / " + legendFor(dataset.id),
);
}, [refresh]);
const loadBundled = useCallback(async (
tag: number,
loadFormat: QbinFormat,
id: WeatherDatasetId,
) => {
const dataset = datasetFor(loadFormat, id);
try {
setLoading(true);
setWeatherStatus("LOADING");
setResult("Loading bundled " + loadFormat + " " + id);
await prepareDataset(tag, dataset);
const materialize = loadFormat === "V3" ? materializeV3Frame : materializeV2Frame;
const frames = await Promise.all(
dataset.frames.map((frame) => materialize(frame as never)),
);
for (const frame of frames) {
const submit = await FieldRuntime.submitEncodedField(
tag,
dataset.fieldKey,
frame.metadata,
dataset.profileContract,
frame.qbinFileUri,
);
if (submit !== "ACCEPTED_INSERTED") {
throw new Error(frame.label + " submit=" + submit);
}
}
await finishLoad(
tag,
dataset,
dataset.midpointMs,
"BUNDLED " + loadFormat + " / " + id,
);
} catch (error) {
setWeatherStatus("ERROR");
setResult("Bundled load error: " + String(error));
} finally {
setLoading(false);
}
}, [finishLoad, prepareDataset]);
const loadOnline = useCallback(async (
tag: number,
loadFormat: QbinFormat,
id: WeatherDatasetId,
) => {
const dataset = datasetFor(loadFormat, id);
try {
setLoading(true);
setWeatherStatus("DOWNLOADING");
setResult("Discovering online " + loadFormat + " " + id);
await prepareDataset(tag, dataset);
const online = await downloadOnlineQbin(loadFormat, id);
setResult(
"Downloading " + online.datasetId + "/" + online.serverLayer +
" ref=" + online.refTime,
);
for (const frame of online.frames) {
const submit = await FieldRuntime.submitEncodedField(
tag,
dataset.fieldKey,
frame.metadata,
dataset.profileContract,
frame.qbinFileUri,
);
if (submit !== "ACCEPTED_INSERTED") {
throw new Error(frame.label + " submit=" + submit);
}
}
const times = online.frames
.map((frame) => frame.targetTime)
.join(" → ");
await finishLoad(
tag,
dataset,
online.midpointMs,
"ONLINE " + loadFormat + " / " + online.datasetId +
" / " + online.serverLayer + " / ref=" + online.refTime +
" / " + times,
);
} catch (error) {
setWeatherStatus("ERROR");
setResult("Online load error: " + String(error));
} finally {
setLoading(false);
}
}, [finishLoad, prepareDataset]);
const attach = useCallback(async (tag: number) => {
try {
await FieldRuntime.attach(tag);
setReactTag(tag);
await refresh(tag);
setResult("FieldRuntime attached / particle.10");
} catch (error) {
setResult("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 || autoLoadTagRef.current === reactTag) return;
autoLoadTagRef.current = reactTag;
void loadBundled(reactTag, "V3", "wind");
}, [loadBundled, reactTag]);
useEffect(() => () => {
if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined);
}, [reactTag]);
const applyVisual = useCallback(async () => {
if (reactTag == null) return;
try {
await applyVisualToTag(reactTag);
await refresh(reactTag);
setResult("Visual parameters applied");
} catch (error) {
setResult("Visual error: " + String(error));
}
}, [applyVisualToTag, reactTag, refresh]);
const toggleWeather = useCallback(async () => {
if (reactTag == null) return;
const next = !weatherVisible;
if (await FieldRuntime.setFieldVisible(reactTag, next) === "ACCEPTED") {
setWeatherVisible(next);
}
}, [reactTag, weatherVisible]);
const toggleParticle = useCallback(async () => {
if (reactTag == null) return;
const next = !particleVisible;
if (await FieldRuntime.setParticleVisible(reactTag, next) === "ACCEPTED") {
setParticleVisible(next);
}
}, [particleVisible, reactTag]);
const updateVisual = useCallback(
(key: keyof typeof initialVisual, value: string) => {
setVisual((current) => ({ ...current, [key]: value }));
},
[],
);
return (
<SafeAreaView style={styles.root}>
<View style={styles.map}>
<Map
mapStyle={weatherTestStyle}
onDidFinishLoadingStyle={onStyleReady}
onDidFinishLoadingMap={() => setMapStatus("READY")}
onDidFailLoadingMap={() => {
setMapStatus("ERROR");
setResult("map loading error");
}}
>
<Camera initialViewState={{ center: [137.5, 36.2], zoom: 4.8 }} />
</Map>
</View>
<View style={styles.controlPanel}>
<View style={styles.controlHeader}>
<Text style={styles.controlHeaderTitle}>
Particle V10 Test Controls
</Text>
<Button
title={controlsCollapsed ? "展开 ▼" : "收起 ▲"}
onPress={() => setControlsCollapsed((current) => !current)}
/>
</View>
{!controlsCollapsed && (
<ScrollView style={styles.controls} contentContainerStyle={styles.content}>
<Text>
Map: {mapStatus} / Weather: {weatherStatus} / Runtime: {snapshot.state}
</Text>
<Text>
Native: RN 13.4.1-particle.10 / iOS 6.28.0-particle.10
</Text>
<Text selectable>Source: {activeSource}</Text>
<Text selectable>{result}</Text>
<Text>QBIN Format</Text>
<View style={styles.row}>
{(["V2", "V3"] as const).map((item) => (
<Button
key={item}
title={(format === item ? "✓ " : "") + item}
onPress={() => setFormat(item)}
disabled={loading}
/>
))}
</View>
<Text>Dataset</Text>
<View style={styles.row}>
{(["wind", "wave", "current", "rain"] as const).map((item) => (
<Button
key={item}
title={(datasetId === item ? "✓ " : "") + item}
onPress={() => setDatasetId(item)}
disabled={loading}
/>
))}
</View>
<Text>
{valueSourceFor(datasetId)} / {legendFor(datasetId)}
</Text>
<View style={styles.row}>
<Button
title={"LOAD BUNDLED " + format}
disabled={reactTag == null || loading}
onPress={() => reactTag != null &&
void loadBundled(reactTag, format, datasetId)}
/>
<Button
title={"ONLINE GET " + format}
disabled={reactTag == null || loading}
onPress={() => reactTag != null &&
void loadOnline(reactTag, format, datasetId)}
/>
<Button
title={weatherVisible ? "Weather ON" : "Weather OFF"}
disabled={reactTag == null || loading}
onPress={() => void toggleWeather()}
/>
<Button
title={particleVisible ? "Particle ON" : "Particle OFF"}
disabled={
reactTag == null ||
loading ||
selectedDataset.particleBinding == null
}
onPress={() => void toggleParticle()}
/>
</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"
disabled={reactTag == null || loading}
onPress={() => void applyVisual()}
/>
</View>
</ScrollView>
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
map: { flex: 1 },
controlPanel: { backgroundColor: "#fff" },
controlHeader: {
minHeight: 44,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#c7c7c7",
},
controlHeaderTitle: { fontSize: 15, fontWeight: "600" },
controls: { maxHeight: 360, backgroundColor: "#fff" },
content: { gap: 7, padding: 10, paddingTop: 4 },
row: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
},
input: { borderWidth: 1, minWidth: 48, padding: 5 },
});