486 lines
16 KiB
TypeScript
486 lines
16 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,
|
|
RendererRuntime,
|
|
type RetainedUnderlayDiagnostics,
|
|
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 [retainedSnapshot, setRetainedSnapshot] =
|
|
useState<RetainedUnderlayDiagnostics | null>(null);
|
|
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 refreshRetained = useCallback(async (tag: number) => {
|
|
setRetainedSnapshot(await RendererRuntime.getRetainedUnderlayDiagnostics(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 Promise.all([refresh(tag), refreshRetained(tag)]);
|
|
setResult("FieldRuntime attached / particle.10");
|
|
} catch (error) {
|
|
setResult("attach error: " + String(error));
|
|
}
|
|
}, [refresh, refreshRetained]);
|
|
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 toggleRetained = useCallback(async () => {
|
|
if (reactTag == null) return;
|
|
const next = !(retainedSnapshot?.enabled ?? true);
|
|
const result = await RendererRuntime.setRetainedUnderlayEnabled(reactTag, next);
|
|
if (result === "ACCEPTED") await refreshRetained(reactTag);
|
|
}, [reactTag, refreshRetained, retainedSnapshot?.enabled]);
|
|
|
|
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 selectable>
|
|
Retained: {retainedSnapshot == null ? "not read" :
|
|
`${retainedSnapshot.enabled ? "ON" : "OFF"} build=${retainedSnapshot.build} reuse=${retainedSnapshot.reuse} fallback=${retainedSnapshot.fallback} invalidation=${retainedSnapshot.invalidation} reason=${retainedSnapshot.lastReason}`}
|
|
</Text>
|
|
<View style={styles.row}>
|
|
<Button
|
|
title={`Retained ${retainedSnapshot?.enabled === false ? "OFF" : "ON"}`}
|
|
disabled={reactTag == null}
|
|
onPress={() => void toggleRetained()}
|
|
/>
|
|
<Button
|
|
title="Refresh retained"
|
|
disabled={reactTag == null}
|
|
onPress={() => reactTag != null && void refreshRetained(reactTag)}
|
|
/>
|
|
</View>
|
|
|
|
<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 },
|
|
});
|