test: integrate particle.10 online QBIN host
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
tei
2026-09-21 20:21:16 +09:00
parent 822663cd5d
commit f47469d2ab
10 changed files with 837 additions and 206 deletions

View File

@@ -1,2 +1,4 @@
# Expo/RN prebuild output is generated locally and must not enter this source host. # Expo/RN prebuild output is generated locally and must not enter this source host.
/ios/ /ios/
/.expo/

View File

@@ -1,192 +1,453 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { Camera, Map } from "@maplibre/maplibre-react-native";
import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn"; import { FieldRuntime, type RuntimeSnapshot } from "@navsea/maplibre-particle-rn";
import { fieldDatasets, materializeV3Frame, type V3Dataset } from "./src/fieldDatasets"; import {
import { materializeV4Frame, qbinGoldenDatasets, type V4Dataset } from "./src/qbinGoldenFixtures"; 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"; 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 = {
const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", weatherOpacity: "0.62" }; count: "4096",
const frozenStops: FieldColorStop[] = [ width: "1.8",
{ 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 }, length: "0.028",
{ 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 }, 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() { export default function App() {
const [reactTag, setReactTag] = useState<number | null>(null); const [reactTag, setReactTag] = useState<number | null>(null);
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot); const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot);
const [result, setResult] = useState("Style is loading"); const [mapStatus, setMapStatus] = useState("LOADING");
const [basemap, setBasemap] = useState("LOADING"); const [weatherStatus, setWeatherStatus] = useState("WAITING");
const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING" }); const [result, setResult] = useState("Waiting for map/style");
const [dataset, setDataset] = useState<V3Dataset>(fieldDatasets[0]); const [activeSource, setActiveSource] = useState("none");
const [transport, setTransport] = useState("PENDING: V2 FILE_REF / FLOAT32_LE"); const [format, setFormat] = useState<QbinFormat>("V3");
const [v4Dataset, setV4Dataset] = useState<V4Dataset>(qbinGoldenDatasets[0]); const [datasetId, setDatasetId] = useState<WeatherDatasetId>("wind");
const [v4Frames, setV4Frames] = useState<Record<"T0" | "T1", string>>({ 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 [weatherVisible, setWeatherVisible] = useState(true); const [weatherVisible, setWeatherVisible] = useState(true);
const [particleVisible, setParticleVisible] = useState(true); const [particleVisible, setParticleVisible] = useState(true);
const [controlsCollapsed, setControlsCollapsed] = useState(false);
const [loading, setLoading] = useState(false);
const [visual, setVisual] = useState(initialVisual); 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 refresh = useCallback(async (tag: number) => setSnapshot(await FieldRuntime.getState(tag)), []);
const attach = useCallback(async (tag: number) => { const attach = useCallback(async (tag: number) => {
try { await FieldRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("FieldRuntime attached; style ready"); } try {
catch (error) { setResult(`style/attach error: ${String(error)}`); } await FieldRuntime.attach(tag);
setReactTag(tag);
await refresh(tag);
setResult("FieldRuntime attached / particle.10");
} catch (error) {
setResult("attach error: " + String(error));
}
}, [refresh]); }, [refresh]);
const onStyleReady = useCallback((event: unknown) => { const onStyleReady = useCallback((event: unknown) => {
const tag = findNodeHandle((event as { target?: unknown }).target as never); const tag = findNodeHandle((event as { target?: unknown }).target as never);
if (tag != null) void attach(tag); if (tag != null) void attach(tag);
}, [attach]); }, [attach]);
useEffect(() => () => { if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]);
const command = useCallback(async (name: string, operation: (tag: number) => Promise<unknown>) => { useEffect(() => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready"); if (reactTag == null || autoLoadTagRef.current === reactTag) return;
try { const value = await operation(reactTag); await refresh(reactTag); setResult(`${name}: ${String(value)}`); } autoLoadTagRef.current = reactTag;
catch (error) { setResult(`${name}: ${String(error)}`); } void loadBundled(reactTag, "V3", "wind");
}, [reactTag, refresh]); }, [loadBundled, reactTag]);
const loadDataset = useCallback(async () => { useEffect(() => () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready"); if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined);
const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY"]; }, [reactTag]);
const applyVisual = useCallback(async () => {
if (reactTag == null) return;
try { try {
setTransport("LOADING: V2 metadata + validity/scalar/vector local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING" }); await applyVisualToTag(reactTag);
if (await FieldRuntime.clearFields(reactTag) !== "ACCEPTED") throw new Error("clearFields was rejected"); await refresh(reactTag);
await FieldRuntime.setFieldValueSource(reactTag, dataset.fieldValueSource); await FieldRuntime.setParticleVisible(reactTag, dataset.particle); await FieldRuntime.setParticleAdvectionScale(reactTag, dataset.advectionScale); setResult("Visual parameters applied");
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`);
} catch (error) { } catch (error) {
setV4Transport(`ERROR: ${String(error)}`); setResult("Visual error: " + String(error));
setResult(`LOAD V4 ${v4Dataset.id}: ${String(error)}`);
} }
}, [prepareV4, reactTag, v4Dataset]); }, [applyVisualToTag, reactTag, refresh]);
const runV4FinalControlRegression = useCallback(async () => { const toggleWeather = useCallback(async () => {
if (reactTag == null || !Number.isInteger(reactTag) || reactTag <= 0) return setResult("MAP_NOT_READY: wait for a valid reactTag/style ready"); if (reactTag == null) return;
if (finalControlRunRef.current) return; const next = !weatherVisible;
const steps: string[] = []; if (await FieldRuntime.setFieldVisible(reactTag, next) === "ACCEPTED") {
const accepted = (step: string, value: string) => { setWeatherVisible(next);
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<void>((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);
} }
}, [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 updateVisual = useCallback(
const applyVisual = useCallback(() => void command("particle visual", async (tag) => { (key: keyof typeof initialVisual, value: string) => {
const results = await Promise.all([ setVisual((current) => ({ ...current, [key]: value }));
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 controls = useMemo(() => [ return (
["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")], <SafeAreaView style={styles.root}>
["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)], <View style={styles.map}>
] as const, []); <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>
return <SafeAreaView style={styles.root}> {!controlsCollapsed && (
<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}>
<ScrollView style={styles.controls} contentContainerStyle={styles.content}> <Text>
<Text>Basemap: GSI {basemap}</Text><Text>Dataset: {dataset.title}</Text><Text>Runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text><Text selectable>V2 FILE TRANSPORT = {transport}</Text> Map: {mapStatus} / Weather: {weatherStatus} / Runtime: {snapshot.state}
{dataset.frames.map((frame) => <Text selectable key={frame.label}>{frame.label}: {frames[frame.label]}</Text>)} </Text>
<Text selectable>{result}</Text><View style={styles.row}>{fieldDatasets.map((item) => <Button key={item.id} title={item.id} onPress={() => setDataset(item)} />)}</View><Button title="LOAD SELECTED V3 DATASET" onPress={() => void loadDataset()} /> <Text>
<View style={styles.row}>{controls.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}</View> Native: RN 13.4.1-particle.10 / iOS 6.28.0-particle.10
<View style={styles.row}><Button title={`Weather ${weatherVisible ? "visible" : "hidden"}`} onPress={() => { const next = !weatherVisible; setWeatherVisible(next); void command("weather 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> </Text>
<View style={styles.row}>{dataset.frames.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 selectable>Source: {activeSource}</Text>
<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> <Text selectable>{result}</Text>
<Text>V4 QBIN Golden: {v4Dataset.title}</Text><Text selectable>V4 raw QBIN transport = {v4Transport}</Text><Text selectable>V4 bindings: {v4Bindings}</Text><Text selectable>{v4Dataset.particleNote}</Text>
<Text selectable>V4 T0: {v4Frames.T0}</Text><Text selectable>V4 T1: {v4Frames.T1}</Text> <Text>QBIN Format</Text>
<View style={styles.row}>{qbinGoldenDatasets.map((item) => <Button key={item.id} title={`V4 ${item.id}`} onPress={() => setV4Dataset(item)} />)}</View><Button title="LOAD SELECTED V4 DATASET" onPress={() => void loadV4Dataset()} /> <View style={styles.row}>
<Button title={finalControlRunning ? "RUN V4 FINAL CONTROL REGRESSION (RUNNING)" : "RUN V4 FINAL CONTROL REGRESSION"} disabled={finalControlRunning} onPress={() => void runV4FinalControlRegression()} /> {(["V2", "V3"] as const).map((item) => (
<Text selectable>{finalControlSummary}</Text> <Button
<View style={styles.row}><Button title="V4 seek T0" onPress={() => void command("V4 seek T0", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.frames[0].validTimeMs))} /><Button title="V4 seek midpoint" onPress={() => void command("V4 seek midpoint (mix not exposed)", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.midpointMs))} /><Button title="V4 seek T1" onPress={() => void command("V4 seek T1", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.frames[1].validTimeMs))} /></View> key={item}
<View style={styles.row}><Button title="V4 PLAY" onPress={() => void command("V4 PLAY", (tag) => FieldRuntime.setEncodedForecastPlaybackState(tag, "PLAY"))} /><Button title="V4 PAUSE" onPress={() => void command("V4 PAUSE", (tag) => FieldRuntime.setEncodedForecastPlaybackState(tag, "PAUSE"))} /><Button title="V4 speed 1.0" onPress={() => void command("V4 speed 1.0", (tag) => FieldRuntime.setEncodedForecastPlaybackSpeed(tag, 1.0))} /><Button title="V4 clear selected" onPress={() => void command("V4 clear selected", (tag) => FieldRuntime.clearEncodedField(tag, v4Dataset.fieldKey))} /><Button title="V4 clear all" onPress={() => void command("V4 clear all", (tag) => FieldRuntime.clearAllEncodedFields(tag))} /></View> title={(format === item ? "✓ " : "") + item}
</ScrollView> onPress={() => setFormat(item)}
</SafeAreaView>; 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({
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 } }); 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 },
});

View File

@@ -5,7 +5,18 @@
"version": "0.1.0", "version": "0.1.0",
"orientation": "landscape", "orientation": "landscape",
"newArchEnabled": true, "newArchEnabled": true,
"plugins": ["./plugins/withParticleNativeAndroid"], "plugins": [
[
"@maplibre/maplibre-react-native",
{
"ios": {
"spmSpec": "{ url: \"https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git\", requirement: { kind: \"exactVersion\", version: \"6.28.0-particle.10\" }, product_name: \"MapLibre\" }"
}
}
],
"./plugins/withParticleNativeIos",
"./plugins/withParticleNativeAndroid"
],
"android": { "package": "org.maplibre.particle.rntesthost", "minSdkVersion": 24 }, "android": { "package": "org.maplibre.particle.rntesthost", "minSdkVersion": 24 },
"ios": { "bundleIdentifier": "org.maplibre.particle.rntesthost", "supportsTablet": true } "ios": { "bundleIdentifier": "org.maplibre.particle.rntesthost", "supportsTablet": true }
} }

View File

@@ -9,7 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@maplibre/maplibre-react-native": "11.3.6", "@maplibre/maplibre-react-native": "11.3.6",
"@navsea/maplibre-particle-rn": "file:../../../packages/maplibre-particle-rn", "@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"expo": "56.0.4", "expo": "56.0.4",
"expo-asset": "~56.0.14", "expo-asset": "~56.0.14",
"expo-file-system": "56.0.11", "expo-file-system": "56.0.11",
@@ -21,24 +21,6 @@
"typescript": "5.9.3" "typescript": "5.9.3"
} }
}, },
"../../../packages/maplibre-particle-rn": {
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.3",
"license": "BSD-2-Clause",
"devDependencies": {
"@maplibre/maplibre-react-native": "11.3.6"
},
"peerDependencies": {
"@maplibre/maplibre-react-native": "11.3.6",
"react": ">=19.1.0",
"react-native": ">=0.80.0"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1780,8 +1762,21 @@
} }
}, },
"node_modules/@navsea/maplibre-particle-rn": { "node_modules/@navsea/maplibre-particle-rn": {
"resolved": "../../../packages/maplibre-particle-rn", "version": "13.4.1-particle.10",
"link": true "resolved": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"integrity": "sha512-SNXvZ71A+vhrWzTxYCLGLGNkg6evtl8hydgdMUzB2Ft78sf5o+SCLQFtJ7ieLJl9r4kVEYdmprG8uwRdJDlhYg==",
"license": "BSD-2-Clause",
"peerDependencies": {
"@maplibre/maplibre-react-native": "11.3.6",
"expo": ">=54.0.0",
"react": ">=19.1.0",
"react-native": ">=0.80.0"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
}, },
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.85.2", "version": "0.85.2",

View File

@@ -9,7 +9,7 @@
"android": "expo run:android" "android": "expo run:android"
}, },
"dependencies": { "dependencies": {
"@navsea/maplibre-particle-rn": "file:../../../packages/maplibre-particle-rn", "@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"@maplibre/maplibre-react-native": "11.3.6", "@maplibre/maplibre-react-native": "11.3.6",
"expo": "56.0.4", "expo": "56.0.4",
"expo-asset": "~56.0.14", "expo-asset": "~56.0.14",

View File

@@ -0,0 +1,21 @@
const { withPodfile } = require("@expo/config-plugins");
const marker = "# MapLibreParticleRNTestHost Particle iOS SPM hook";
module.exports = function withParticleNativeIos(config) {
return withPodfile(config, (project) => {
const contents = project.modResults.contents;
if (contents.includes(marker)) return project;
const needle = " post_install do |installer|";
if (!contents.includes(needle)) {
throw new Error("Expo post_install block was not generated.");
}
project.modResults.contents = contents.replace(
needle,
needle + "\n " + marker + "\n $MLRN_PARTICLE.post_install(installer)",
);
return project;
});
};

View File

@@ -0,0 +1,136 @@
import { File, Paths } from "expo-file-system";
import type { WeatherDatasetId } from "./weatherPalettes";
export type QbinFormat = "V2" | "V3";
type WeatherManifest = Readonly<{
l: Record<string, Readonly<{
d: string;
ds: readonly Readonly<{ id: string; r: string; iu: string }>[];
}>>;
}>;
type WeatherTimeIndex = Readonly<{
d: string;
l: string;
r: string;
t: readonly string[];
x: Readonly<{
q2f?: string;
q2v?: number;
q3f?: string;
q3v?: number;
q3bw?: number;
q3bh?: number;
}>;
}>;
export type OnlineQbinFrame = Readonly<{
label: "T0" | "T1";
targetTime: string;
validTimeMs: number;
metadata: Readonly<{ fieldID: string; generation: number; dataVersion: number }>;
qbinFileUri: string;
sourceUrl: string;
}>;
export type OnlineQbinSelection = Readonly<{
datasetId: string;
serverLayer: string;
refTime: string;
frames: readonly OnlineQbinFrame[];
midpointMs: number;
}>;
const BASE = "https://weather.jpcn.ccwu.cc/";
const HEADERS = { "User-Agent": "Mozilla/5.0" };
function layerFor(id: WeatherDatasetId): string {
if (id === "wave") return "waves";
if (id === "current") return "tide";
return id;
}
function timeMs(value: string): number {
if (!/^\d{10}$/.test(value)) throw new Error("Invalid forecast time: " + value);
return Date.UTC(
Number(value.slice(0, 4)),
Number(value.slice(4, 6)) - 1,
Number(value.slice(6, 8)),
Number(value.slice(8, 10)),
);
}
function chooseTimes(times: readonly string[]): readonly [string, string] {
if (times.length < 2) throw new Error("time_index has fewer than two target times");
const now = Date.now();
let index = times.findIndex((value) => timeMs(value) >= now);
if (index < 0) index = times.length - 1;
if (index === 0) return [times[0], times[1]];
return [times[index - 1], times[index]];
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, { headers: HEADERS });
if (!response.ok) throw new Error("HTTP " + response.status + ": " + url);
return await response.json() as T;
}
export async function downloadOnlineQbin(
format: QbinFormat,
id: WeatherDatasetId,
): Promise<OnlineQbinSelection> {
const manifest = await fetchJson<WeatherManifest>(BASE + "weather_manifest.json");
const serverLayer = layerFor(id);
const layer = manifest.l[serverLayer];
if (!layer) throw new Error("manifest layer missing: " + serverLayer);
const dataset = layer.ds.find((item) => item.id === layer.d) ?? layer.ds[0];
if (!dataset) throw new Error("manifest dataset missing: " + serverLayer);
const indexUrl = BASE + dataset.iu.replace(/^\/+/, "");
const timeIndex = await fetchJson<WeatherTimeIndex>(indexUrl);
const template = format === "V3" ? timeIndex.x.q3f : timeIndex.x.q2f;
const version = format === "V3" ? timeIndex.x.q3v : timeIndex.x.q2v;
if (!template) throw new Error(format + " frame template missing");
if (version !== (format === "V3" ? 3 : 2)) {
throw new Error(format + " version mismatch: " + String(version));
}
const [t0, t1] = chooseTimes(timeIndex.t);
const baseUrl = indexUrl.slice(0, indexUrl.lastIndexOf("/") + 1);
const targets = [t0, t1] as const;
const frames: OnlineQbinFrame[] = [];
for (let index = 0; index < targets.length; index += 1) {
const targetTime = targets[index];
const relative = template.replace("{target_time}", targetTime);
const sourceUrl = baseUrl + relative;
const destination = new File(
Paths.cache,
"online-" + serverLayer + "-" + format.toLowerCase() + "-" + targetTime + ".qbin",
);
const downloaded = await File.downloadFileAsync(
sourceUrl,
destination,
{ headers: HEADERS, idempotent: true },
);
frames.push({
label: index === 0 ? "T0" : "T1",
targetTime,
validTimeMs: timeMs(targetTime),
metadata: {
fieldID: "online-" + timeIndex.d + "-" + serverLayer,
generation: index + 1,
dataVersion: index + 1,
},
qbinFileUri: downloaded.uri,
sourceUrl,
});
}
return {
datasetId: timeIndex.d,
serverLayer,
refTime: timeIndex.r,
frames,
midpointMs: Math.round((frames[0].validTimeMs + frames[1].validTimeMs) / 2),
};
}

View File

@@ -0,0 +1,126 @@
import { Asset } from "expo-asset";
export type V4CommandResult = "ACCEPTED" | "REJECTED";
type RequiredPlane = Readonly<{
semanticID: number;
valueClass: "SCALAR" | "VELOCITY_COMPONENT" | "DIRECTION_ANGLE" | "VALIDITY";
unitID: number;
groupID: number;
component: "NONE" | "U_EAST" | "V_NORTH" | "W_UP";
requiredFlag: boolean;
}>;
export type V4Dataset = Readonly<{
id: "wind" | "wave" | "current" | "rain";
title: string;
fieldKey: string;
profileContract: Readonly<{
contractSchemaVersion: 1;
profileID: string;
profileVersion: 1;
allowAdditionalPlanes: boolean;
sharedValidityRequired: boolean;
timeRelation: "VALID_GTE_REFERENCE";
requiredPlanes: readonly RequiredPlane[];
}>;
frames: readonly V4Frame[];
midpointMs: number;
colorBinding: Readonly<{
fieldKey: string;
semanticID: number;
groupID: number;
expectedValueClass: "SCALAR" | "VELOCITY_COMPONENT" | "DIRECTION_ANGLE";
expectedUnitID: number;
}>;
particleBinding?: Readonly<{
fieldKey: string;
mode: "VECTOR_COMPONENTS" | "DIRECTION_ANGLE";
uPlane?: Readonly<{ semanticID: number; groupID: number }>;
vPlane?: Readonly<{ semanticID: number; groupID: number }>;
directionPlane?: Readonly<{ semanticID: number; groupID: number }>;
expectedUnitID: number;
}>;
particleNote: string;
}>;
export type V4Frame = Readonly<{
label: "T0" | "T1";
validTimeMs: number;
metadata: Readonly<{ fieldID: string; generation: number; dataVersion: number }>;
assetModule: number;
sourceRelativePath: string;
bytes: number;
sha256: string;
}>;
const sharedValidity: RequiredPlane = {
semanticID: 3, valueClass: "VALIDITY", unitID: 0, groupID: 0, component: "NONE", requiredFlag: true,
};
const vectorPlanes: readonly RequiredPlane[] = [
{ semanticID: 1, valueClass: "VELOCITY_COMPONENT", unitID: 1, groupID: 1, component: "U_EAST", requiredFlag: true },
{ semanticID: 2, valueClass: "VELOCITY_COMPONENT", unitID: 1, groupID: 1, component: "V_NORTH", requiredFlag: true },
sharedValidity,
];
const wavePlanes: readonly RequiredPlane[] = [
{ semanticID: 10, valueClass: "DIRECTION_ANGLE", unitID: 4, groupID: 0, component: "NONE", requiredFlag: true },
{ semanticID: 20, valueClass: "SCALAR", unitID: 2, groupID: 0, component: "NONE", requiredFlag: true },
{ semanticID: 21, valueClass: "SCALAR", unitID: 3, groupID: 0, component: "NONE", requiredFlag: true },
sharedValidity,
];
const rainPlanes: readonly RequiredPlane[] = [
{ semanticID: 30, valueClass: "SCALAR", unitID: 5, groupID: 0, component: "NONE", requiredFlag: true },
sharedValidity,
];
function contract(profileID: string, requiredPlanes: readonly RequiredPlane[]) {
return {
contractSchemaVersion: 1 as const,
profileID,
profileVersion: 1 as const,
allowAdditionalPlanes: true,
sharedValidityRequired: true,
timeRelation: "VALID_GTE_REFERENCE" as const,
requiredPlanes,
};
}
function frame(label: "T0" | "T1", fieldID: string, validTimeMs: number, assetModule: number, sourceRelativePath: string, bytes: number, sha256: string): V4Frame {
const revision = label === "T0" ? 1 : 2;
return { label, validTimeMs, metadata: { fieldID, generation: revision, dataVersion: revision }, assetModule, sourceRelativePath, bytes, sha256 };
}
const windFrames = [
frame("T0", "fixture-wind", 1789239600000, require("../assets/qbin-v3-golden/wind/T0.qbin"), "wind/frames/2026091219.qbin", 621120, "e815d94c18711c70e27c570cfd89acda3a27d64646d04e0b29846404ca325979"),
frame("T1", "fixture-wind", 1789243200000, require("../assets/qbin-v3-golden/wind/T1.qbin"), "wind/frames/2026091220.qbin", 622832, "bb2dbde0a8984d2fda4140cb6393744ede14fc9327663091e7afd4d2e37606da"),
] as const;
const waveFrames = [
frame("T0", "fixture-wave", 1789239600000, require("../assets/qbin-v3-golden/wave/T0.qbin"), "wave/frames/2026091219.qbin", 520688, "e4409ba0b09003e663dacdce8fc2ef35a1b929b5ccba8dc82e1ad0fc8c2f1202"),
frame("T1", "fixture-wave", 1789243200000, require("../assets/qbin-v3-golden/wave/T1.qbin"), "wave/frames/2026091220.qbin", 520016, "4827e4c9a7917da944cdf65212cc6371b0b7276fb9ae3487625f1c539b27f178"),
] as const;
const currentFrames = [
frame("T0", "fixture-current", 1789174800000, require("../assets/qbin-v3-golden/current/T0.qbin"), "tide/frames/2026091201.qbin", 104592, "b1b6d4a1dbb98971c3daca6b781587464e3528dd929da25196409b4b3b2bc052"),
frame("T1", "fixture-current", 1789178400000, require("../assets/qbin-v3-golden/current/T1.qbin"), "tide/frames/2026091202.qbin", 104112, "0588b3b1ed9e2d491adefa308f7d0b0707a38d773cc8d0802cded77a24c2aa24"),
] as const;
const rainFrames = [
frame("T0", "fixture-rain", 1789239600000, require("../assets/qbin-v3-golden/rain/T0.qbin"), "rain/frames/2026091219.qbin", 153904, "567d99c6309d185184193f203f6eb7b2891572730ec9299371e6a0db0a067d20"),
frame("T1", "fixture-rain", 1789243200000, require("../assets/qbin-v3-golden/rain/T1.qbin"), "rain/frames/2026091220.qbin", 153904, "8688d0373534d1c67ab4eed836e1314f14eba639594666683e6c22ff091e14fc"),
] as const;
const vectorColor = (fieldKey: string) => ({ fieldKey, semanticID: 1, groupID: 1, expectedValueClass: "VELOCITY_COMPONENT" as const, expectedUnitID: 1 });
const vectorParticle = (fieldKey: string) => ({ fieldKey, mode: "VECTOR_COMPONENTS" as const, uPlane: { semanticID: 1, groupID: 1 }, vPlane: { semanticID: 2, groupID: 1 }, expectedUnitID: 1 });
/** QBIN V3 Golden fixtures for the encoded-field public contract. Production block geometry is fixed at 16x16. */
export const qbinV3GoldenDatasets: readonly V4Dataset[] = [
{ id: "wind", title: "Wind QBIN V3", fieldKey: "fixture.wind", profileContract: contract("wind", vectorPlanes), frames: windFrames, midpointMs: 1789241400000, colorBinding: vectorColor("fixture.wind"), particleBinding: vectorParticle("fixture.wind"), particleNote: "U ColorBinding (VELOCITY_COMPONENT); U/V particle vectors." },
{ id: "wave", title: "Wave QBIN V3", fieldKey: "fixture.wave", profileContract: contract("wave", wavePlanes), frames: waveFrames, midpointMs: 1789241400000, colorBinding: { fieldKey: "fixture.wave", semanticID: 20, groupID: 0, expectedValueClass: "SCALAR", expectedUnitID: 2 }, particleBinding: { fieldKey: "fixture.wave", mode: "DIRECTION_ANGLE", directionPlane: { semanticID: 10, groupID: 0 }, expectedUnitID: 4 }, particleNote: "WAVE_HEIGHT color; DIRECTION angle particle (no direction conversion)." },
{ id: "current", title: "Current QBIN V3", fieldKey: "fixture.current", profileContract: contract("current", vectorPlanes), frames: currentFrames, midpointMs: 1789176600000, colorBinding: vectorColor("fixture.current"), particleBinding: vectorParticle("fixture.current"), particleNote: "U ColorBinding (VELOCITY_COMPONENT); U/V particle vectors." },
{ id: "rain", title: "Rain QBIN V3", fieldKey: "fixture.rain", profileContract: contract("rain", rainPlanes), frames: rainFrames, midpointMs: 1789241400000, colorBinding: { fieldKey: "fixture.rain", semanticID: 30, groupID: 0, expectedValueClass: "SCALAR", expectedUnitID: 5 }, particleNote: "Particle N/A: fixture has no particle-compatible plane." },
] as const;
export async function materializeV4Frame(input: V4Frame): Promise<V4Frame & { qbinFileUri: string }> {
const asset = Asset.fromModule(input.assetModule);
await asset.downloadAsync();
if (!asset.localUri?.startsWith("file://")) throw new Error(`${input.label} QBIN asset did not materialize a local file:// URI`);
return { ...input, qbinFileUri: asset.localUri };
}

View File

@@ -0,0 +1,54 @@
import type { FieldColorStop } from "@navsea/maplibre-particle-rn";
export type WeatherDatasetId = "wind" | "wave" | "current" | "rain";
const wind: readonly FieldColorStop[] = [
{ value: 0.00000, red: 0.16, green: 0.35, blue: 0.78, alpha: 1.00 },
{ value: 2.57222, red: 0.10, green: 0.58, blue: 0.78, alpha: 1.00 },
{ value: 5.14444, red: 0.10, green: 0.68, blue: 0.34, alpha: 1.00 },
{ value: 10.28888, red: 0.70, green: 0.68, blue: 0.08, alpha: 1.00 },
{ value: 15.43332, red: 0.88, green: 0.38, blue: 0.12, alpha: 1.00 },
{ value: 20.57776, red: 0.78, green: 0.15, blue: 0.38, alpha: 1.00 },
{ value: 30.86664, red: 0.48, green: 0.18, blue: 0.70, alpha: 1.00 },
];
const wave: readonly FieldColorStop[] = [
{ value: 0.5, red: 0.18, green: 0.58, blue: 0.68, alpha: 1.00 },
{ value: 1.0, red: 0.18, green: 0.38, blue: 0.55, alpha: 1.00 },
{ value: 1.5, red: 0.21, green: 0.38, blue: 0.71, alpha: 1.00 },
{ value: 2.0, red: 0.22, green: 0.23, blue: 0.53, alpha: 1.00 },
{ value: 6.0, red: 0.74, green: 0.29, blue: 0.35, alpha: 1.00 },
{ value: 9.0, red: 0.73, green: 0.62, blue: 0.60, alpha: 1.00 },
];
const current: readonly FieldColorStop[] = [
{ value: 0.000000, red: 0.20, green: 0.25, blue: 0.46, alpha: 1.00 },
{ value: 0.205778, red: 0.19, green: 0.50, blue: 0.18, alpha: 1.00 },
{ value: 0.411555, red: 0.50, green: 0.40, blue: 0.18, alpha: 1.00 },
{ value: 0.823110, red: 0.37, green: 0.24, blue: 0.47, alpha: 1.00 },
{ value: 1.028889, red: 0.24, green: 0.39, blue: 0.42, alpha: 1.00 },
{ value: 1.646221, red: 0.53, green: 0.53, blue: 0.53, alpha: 1.00 },
];
const rain: readonly FieldColorStop[] = [
{ value: 1.5, red: 0.21, green: 0.44, blue: 0.57, alpha: 1.00 },
{ value: 2.0, red: 0.19, green: 0.42, blue: 0.53, alpha: 1.00 },
{ value: 3.0, red: 0.20, green: 0.47, blue: 0.56, alpha: 1.00 },
{ value: 7.0, red: 0.18, green: 0.58, blue: 0.38, alpha: 1.00 },
{ value: 10.0, red: 0.47, green: 0.59, blue: 0.22, alpha: 1.00 },
{ value: 20.0, red: 0.58, green: 0.22, blue: 0.22, alpha: 1.00 },
{ value: 30.0, red: 0.58, green: 0.21, blue: 0.55, alpha: 1.00 },
];
export function colorStopsFor(id: WeatherDatasetId): readonly FieldColorStop[] {
if (id === "wind") return wind;
if (id === "wave") return wave;
if (id === "current") return current;
return rain;
}
export function legendFor(id: WeatherDatasetId): string {
if (id === "wind") return "0 / 5 / 10 / 20 / 30 / 40 / 60 kt";
if (id === "wave") return "0.5 / 1 / 1.5 / 2 / 6 / 9 m";
if (id === "current") return "0 / 0.4 / 0.8 / 1.6 / 2 / 3.2 kt";
return "1.5 / 2 / 3 / 7 / 10 / 20 / 30 mm";
}

View File

@@ -1,31 +1,56 @@
/** No field is embedded here: RasterArraySource is reserved for STEP-061 file-reference submission. */ /** Fixed visual baseline: WeatherColor -> Particle -> local darkmap PNG. */
export const DARKMAP_SOURCE_ID = "navsea_darkmap_png";
export const DARKMAP_TILE_URL =
"http://192.168.200.133:8000/windy_tiles_japan/{z}/{x}/{y}.png";
export const WEATHER_SOURCE_ID = "rn-weather-fields"; export const WEATHER_SOURCE_ID = "rn-weather-fields";
export const WEATHER_COLOR_LAYER_ID = "rn-weather-color"; export const WEATHER_COLOR_LAYER_ID = "rn-weather-color";
export const PARTICLE_LAYER_ID = "rn-weather-particles"; 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({ export const weatherTestStyle = JSON.stringify({
version: 8, version: 8,
sources: { sources: {
[GSI_SOURCE_ID]: { [DARKMAP_SOURCE_ID]: {
type: "raster", type: "raster",
tiles: ["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"], tiles: [DARKMAP_TILE_URL],
tileSize: 256, minzoom: 4,
maxzoom: 18, maxzoom: 9,
attribution: "\u56fd\u571f\u5730\u7406\u9662 / GSI" tileSize: 256
}, },
[WEATHER_SOURCE_ID]: { type: "raster-array", "host-field-submission": true } [WEATHER_SOURCE_ID]: {
}, type: "raster-array",
layers: [ "host-field-submission": true
{ id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } }, }
{ id: GSI_LAYER_ID, type: "raster", source: GSI_SOURCE_ID, paint: { "raster-opacity": 1 } }, }, layers: [
{ id: WEATHER_COLOR_LAYER_ID, type: "weather-color", source: WEATHER_SOURCE_ID, {
paint: { "weather-color-opacity": 0.62 } }, id: WEATHER_COLOR_LAYER_ID,
{ id: PARTICLE_LAYER_ID, type: "raster-particle", source: WEATHER_SOURCE_ID, type: "weather-color",
paint: { "raster-particle-active": true, "raster-particle-particle-count": 4096, source: WEATHER_SOURCE_ID,
"raster-particle-line-width": 1.5, "raster-particle-particle-length": 0.012, paint: {
"raster-particle-particle-opacity": 0.9, "raster-particle-trail-opacity": 0.8, "weather-color-opacity": 1.0
"raster-particle-trail-fade": 0.94 } } }
},
{
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.8,
"raster-particle-particle-length": 0.028,
"raster-particle-particle-opacity": 0.85,
"raster-particle-trail-opacity": 0.72,
"raster-particle-trail-fade": 0.975
}
}, {
id: "darkmap-basemap",
type: "raster",
source: DARKMAP_SOURCE_ID,
paint: {
"raster-opacity": 1,
"raster-fade-duration": 0
}
}
] ]
}); });