test: integrate particle.10 online QBIN host
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
# Expo/RN prebuild output is generated locally and must not enter this source host.
|
||||
/ios/
|
||||
|
||||
/.expo/
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot);
|
||||
const [result, setResult] = useState("Style is loading");
|
||||
const [basemap, setBasemap] = useState("LOADING");
|
||||
const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING" });
|
||||
const [dataset, setDataset] = useState<V3Dataset>(fieldDatasets[0]);
|
||||
const [transport, setTransport] = useState("PENDING: V2 FILE_REF / FLOAT32_LE");
|
||||
const [v4Dataset, setV4Dataset] = useState<V4Dataset>(qbinGoldenDatasets[0]);
|
||||
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 [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 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<unknown>) => {
|
||||
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<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);
|
||||
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 (
|
||||
<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>
|
||||
|
||||
return <SafeAreaView style={styles.root}>
|
||||
<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>
|
||||
{!controlsCollapsed && (
|
||||
<ScrollView style={styles.controls} contentContainerStyle={styles.content}>
|
||||
<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>
|
||||
{dataset.frames.map((frame) => <Text selectable key={frame.label}>{frame.label}: {frames[frame.label]}</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()} />
|
||||
<View style={styles.row}>{controls.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}</View>
|
||||
<View style={styles.row}><Button title={`Weather ${weatherVisible ? "visible" : "hidden"}`} onPress={() => { const next = !weatherVisible; setWeatherVisible(next); void command("weather visible", (tag) => FieldRuntime.setFieldVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => FieldRuntime.setParticleVisible(tag, next)); }} /></View>
|
||||
<View style={styles.row}>{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>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>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>
|
||||
<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()} />
|
||||
<Button title={finalControlRunning ? "RUN V4 FINAL CONTROL REGRESSION (RUNNING)" : "RUN V4 FINAL CONTROL REGRESSION"} disabled={finalControlRunning} onPress={() => void runV4FinalControlRegression()} />
|
||||
<Text selectable>{finalControlSummary}</Text>
|
||||
<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>
|
||||
<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>
|
||||
</ScrollView>
|
||||
</SafeAreaView>;
|
||||
}
|
||||
<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>
|
||||
|
||||
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 } });
|
||||
<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 },
|
||||
});
|
||||
|
||||
@@ -5,7 +5,18 @@
|
||||
"version": "0.1.0",
|
||||
"orientation": "landscape",
|
||||
"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 },
|
||||
"ios": { "bundleIdentifier": "org.maplibre.particle.rntesthost", "supportsTablet": true }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@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-asset": "~56.0.14",
|
||||
"expo-file-system": "56.0.11",
|
||||
@@ -21,24 +21,6 @@
|
||||
"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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -1780,8 +1762,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@navsea/maplibre-particle-rn": {
|
||||
"resolved": "../../../packages/maplibre-particle-rn",
|
||||
"link": true
|
||||
"version": "13.4.1-particle.10",
|
||||
"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": {
|
||||
"version": "0.85.2",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"android": "expo run:android"
|
||||
},
|
||||
"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",
|
||||
"expo": "56.0.4",
|
||||
"expo-asset": "~56.0.14",
|
||||
|
||||
21
integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeIos.js
vendored
Normal file
21
integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeIos.js
vendored
Normal 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;
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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_COLOR_LAYER_ID = "rn-weather-color";
|
||||
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({
|
||||
version: 8,
|
||||
sources: {
|
||||
[GSI_SOURCE_ID]: {
|
||||
[DARKMAP_SOURCE_ID]: {
|
||||
type: "raster",
|
||||
tiles: ["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: "\u56fd\u571f\u5730\u7406\u9662 / GSI"
|
||||
tiles: [DARKMAP_TILE_URL],
|
||||
minzoom: 4,
|
||||
maxzoom: 9,
|
||||
tileSize: 256
|
||||
},
|
||||
[WEATHER_SOURCE_ID]: { type: "raster-array", "host-field-submission": true }
|
||||
[WEATHER_SOURCE_ID]: {
|
||||
type: "raster-array",
|
||||
"host-field-submission": true
|
||||
}
|
||||
}, layers: [
|
||||
{
|
||||
id: WEATHER_COLOR_LAYER_ID,
|
||||
type: "weather-color",
|
||||
source: WEATHER_SOURCE_ID,
|
||||
paint: {
|
||||
"weather-color-opacity": 1.0
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{ id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } },
|
||||
{ id: GSI_LAYER_ID, type: "raster", source: GSI_SOURCE_ID, paint: { "raster-opacity": 1 } },
|
||||
{ id: WEATHER_COLOR_LAYER_ID, type: "weather-color", source: WEATHER_SOURCE_ID,
|
||||
paint: { "weather-color-opacity": 0.62 } },
|
||||
{ id: PARTICLE_LAYER_ID, type: "raster-particle", source: WEATHER_SOURCE_ID,
|
||||
paint: { "raster-particle-active": true, "raster-particle-particle-count": 4096,
|
||||
"raster-particle-line-width": 1.5, "raster-particle-particle-length": 0.012,
|
||||
"raster-particle-particle-opacity": 0.9, "raster-particle-trail-opacity": 0.8,
|
||||
"raster-particle-trail-fade": 0.94 } }
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user