diff --git a/integration/react-native/MapLibreParticleRNTestHost/.gitignore b/integration/react-native/MapLibreParticleRNTestHost/.gitignore index 1a1c29a93..ce87fddee 100644 --- a/integration/react-native/MapLibreParticleRNTestHost/.gitignore +++ b/integration/react-native/MapLibreParticleRNTestHost/.gitignore @@ -1,2 +1,4 @@ # Expo/RN prebuild output is generated locally and must not enter this source host. /ios/ + +/.expo/ diff --git a/integration/react-native/MapLibreParticleRNTestHost/App.tsx b/integration/react-native/MapLibreParticleRNTestHost/App.tsx index 7da5613db..c04521451 100644 --- a/integration/react-native/MapLibreParticleRNTestHost/App.tsx +++ b/integration/react-native/MapLibreParticleRNTestHost/App.tsx @@ -1,192 +1,453 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native"; +import { + Button, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TextInput, + View, + findNodeHandle, +} from "react-native"; import { Camera, Map } from "@maplibre/maplibre-react-native"; -import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn"; -import { fieldDatasets, materializeV3Frame, type V3Dataset } from "./src/fieldDatasets"; -import { materializeV4Frame, qbinGoldenDatasets, type V4Dataset } from "./src/qbinGoldenFixtures"; +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 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: 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 }, -]; +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(null); const [snapshot, setSnapshot] = useState(initialSnapshot); - const [result, setResult] = useState("Style is loading"); - const [basemap, setBasemap] = useState("LOADING"); - const [frames, setFrames] = useState>({ T0: "PENDING", T1: "PENDING" }); - const [dataset, setDataset] = useState(fieldDatasets[0]); - const [transport, setTransport] = useState("PENDING: V2 FILE_REF / FLOAT32_LE"); - const [v4Dataset, setV4Dataset] = useState(qbinGoldenDatasets[0]); - const [v4Frames, setV4Frames] = useState>({ T0: "PENDING", T1: "PENDING" }); - const [v4Bindings, setV4Bindings] = useState("PENDING"); - const [v4Transport, setV4Transport] = useState("PENDING: raw QBIN bundled asset → local file:// URI"); - const [finalControlRunning, setFinalControlRunning] = useState(false); - const [finalControlSummary, setFinalControlSummary] = useState("FINAL CONTROL REGRESSION: not run"); - const finalControlRunRef = useRef(false); + 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("V3"); + const [datasetId, setDatasetId] = useState("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(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 refresh = useCallback(async (tag: number) => setSnapshot(await FieldRuntime.getState(tag)), []); const attach = useCallback(async (tag: number) => { - try { await FieldRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("FieldRuntime attached; style ready"); } - catch (error) { setResult(`style/attach error: ${String(error)}`); } + 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) void FieldRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]); - const command = useCallback(async (name: string, operation: (tag: number) => Promise) => { - 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, refresh]); + useEffect(() => { + if (reactTag == null || autoLoadTagRef.current === reactTag) return; + autoLoadTagRef.current = reactTag; + void loadBundled(reactTag, "V3", "wind"); + }, [loadBundled, reactTag]); - const loadDataset = useCallback(async () => { - if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready"); - const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY"]; + useEffect(() => () => { + if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined); + }, [reactTag]); + + const applyVisual = useCallback(async () => { + if (reactTag == null) return; try { - setTransport("LOADING: V2 metadata + validity/scalar/vector local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING" }); - if (await FieldRuntime.clearFields(reactTag) !== "ACCEPTED") throw new Error("clearFields was rejected"); - await FieldRuntime.setFieldValueSource(reactTag, dataset.fieldValueSource); await FieldRuntime.setParticleVisible(reactTag, dataset.particle); await FieldRuntime.setParticleAdvectionScale(reactTag, dataset.advectionScale); - const localFrames = await Promise.all(dataset.frames.map(materializeV3Frame)); - for (let index = 0; index < localFrames.length; index += 1) { - const frame = localFrames[index]; const submit = await FieldRuntime.submitField(reactTag, frame.metadataJson, frame.payloadRefs); - setFrames((current) => ({ ...current, [frame.label]: submit })); - if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`); - } - const native = await FieldRuntime.getState(reactTag); setSnapshot(native); - if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`); - setTransport("READY: V2 FILE_REF / FLOAT32_LE / shared validity"); setResult(`${dataset.title}: T0/T1 accepted; FieldRuntime READY`); - } catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD ${dataset.id}: ${String(error)}`); } - }, [reactTag, dataset]); - - const prepareV4 = useCallback(async (tag: number) => { - setV4Transport("LOADING: clear → binding → T0 → T1 → midpoint"); - setV4Frames({ T0: "PENDING", T1: "PENDING" }); - setV4Bindings("PENDING"); - const clear = await FieldRuntime.clearAllEncodedFields(tag); - if (clear !== "ACCEPTED") throw new Error(`clearAllEncodedFields expected ACCEPTED, got ${clear}`); - const color = await FieldRuntime.setEncodedColorBinding(tag, v4Dataset.colorBinding); - if (color !== "ACCEPTED") throw new Error(`ColorBinding expected ACCEPTED, got ${color}`); - const particle = v4Dataset.particleBinding == null ? "N/A" : await FieldRuntime.setEncodedParticleBinding(tag, v4Dataset.particleBinding); - if (particle !== "N/A" && particle !== "ACCEPTED") throw new Error(`ParticleBinding expected ACCEPTED, got ${particle}`); - setV4Bindings(`clearAll=${clear}; color=${color}; particle=${particle}`); - const localFrames = await Promise.all(v4Dataset.frames.map(materializeV4Frame)); - const submitted: string[] = []; - for (const frame of localFrames) { - const submit = await FieldRuntime.submitEncodedField(tag, v4Dataset.fieldKey, frame.metadata, v4Dataset.profileContract, frame.qbinFileUri); - submitted.push(`${frame.label}=${submit}`); - setV4Frames((current) => ({ ...current, [frame.label]: submit })); - if (submit !== "ACCEPTED_INSERTED") throw new Error(`${frame.label} expected ACCEPTED_INSERTED, got ${submit}`); - } - const midpoint = await FieldRuntime.setEncodedForecastTime(tag, v4Dataset.midpointMs); - if (midpoint !== "ACCEPTED") throw new Error(`midpoint seek expected ACCEPTED, got ${midpoint}`); - await refresh(tag); - setV4Transport("READY: T0/T1 ACCEPTED_INSERTED; midpoint command ACCEPTED (mix=0.5 is not exposed)"); - return `clearAll=${clear}; color=${color}; particle=${particle}; ${submitted.join(", ")}; midpoint=${midpoint}`; - }, [refresh, v4Dataset]); - - const loadV4Dataset = useCallback(async () => { - if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready"); - try { - await prepareV4(reactTag); - setResult(`${v4Dataset.title}: V4 raw QBIN load accepted`); + await applyVisualToTag(reactTag); + await refresh(reactTag); + setResult("Visual parameters applied"); } catch (error) { - setV4Transport(`ERROR: ${String(error)}`); - setResult(`LOAD V4 ${v4Dataset.id}: ${String(error)}`); + setResult("Visual error: " + String(error)); } - }, [prepareV4, reactTag, v4Dataset]); + }, [applyVisualToTag, reactTag, refresh]); - const runV4FinalControlRegression = useCallback(async () => { - if (reactTag == null || !Number.isInteger(reactTag) || reactTag <= 0) return setResult("MAP_NOT_READY: wait for a valid reactTag/style ready"); - if (finalControlRunRef.current) return; - const steps: string[] = []; - const accepted = (step: string, value: string) => { - if (value !== "ACCEPTED") throw new Error(`${step} expected ACCEPTED, got ${value}`); - steps.push(`${step}=${value}`); - }; - const prepare = async (attempt: number) => { - try { steps.push(`prepare#${attempt} PASS (${await prepareV4(reactTag)})`); } - catch (error) { throw new Error(`prepare#${attempt}: ${String(error)}`); } - }; - finalControlRunRef.current = true; - setFinalControlRunning(true); - setFinalControlSummary("FINAL CONTROL REGRESSION: RUNNING"); - try { - await prepare(1); - accepted("PLAY", await FieldRuntime.setEncodedForecastPlaybackState(reactTag, "PLAY")); - await new Promise((resolve) => setTimeout(resolve, 500)); - steps.push("PLAY wait≈500ms=PASS"); - accepted("PAUSE", await FieldRuntime.setEncodedForecastPlaybackState(reactTag, "PAUSE")); - accepted("clear selected", await FieldRuntime.clearEncodedField(reactTag, v4Dataset.fieldKey)); - await prepare(2); - accepted("clear all", await FieldRuntime.clearAllEncodedFields(reactTag)); - try { await FieldRuntime.detach(reactTag); } - catch (error) { throw new Error(`detach: ${String(error)}`); } - steps.push("detach=PASS"); - let attached: RuntimeSnapshot; - try { attached = await FieldRuntime.attach(reactTag); } - catch (error) { throw new Error(`attach: ${String(error)}`); } - setSnapshot(attached); - steps.push("attach=PASS"); - await prepare(3); - try { await refresh(reactTag); } - catch (error) { throw new Error(`refresh: ${String(error)}`); } - steps.push("refresh=PASS"); - const summary = steps.join(" | "); - setFinalControlSummary(`FINAL CONTROL REGRESSION PASS: ${summary}`); - setResult("FINAL CONTROL REGRESSION PASS"); - } catch (error) { - const failure = `FAIL: ${String(error)}`; - steps.push(failure); - setFinalControlSummary(`FINAL CONTROL REGRESSION ${steps.join(" | ")}`); - setResult(`FINAL CONTROL REGRESSION ${failure}`); - } finally { - finalControlRunRef.current = false; - setFinalControlRunning(false); + const toggleWeather = useCallback(async () => { + if (reactTag == null) return; + const next = !weatherVisible; + if (await FieldRuntime.setFieldVisible(reactTag, next) === "ACCEPTED") { + setWeatherVisible(next); } - }, [prepareV4, reactTag, refresh, v4Dataset.fieldKey]); + }, [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 })), []); - const applyVisual = useCallback(() => void command("particle visual", async (tag) => { - 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(", ")); return "ACCEPTED"; - }), [command, visual]); + const updateVisual = useCallback( + (key: keyof typeof initialVisual, value: string) => { + setVisual((current) => ({ ...current, [key]: value })); + }, + [], + ); - const controls = useMemo(() => [ - ["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 ( + + + setMapStatus("READY")} + onDidFailLoadingMap={() => { + setMapStatus("ERROR"); + setResult("map loading error"); + }} + > + + + + + + + Particle V10 Test Controls + +