From 59a48608fb140d04f9008cb8273119999a87b6ef Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Thu, 10 Sep 2026 09:12:44 +0800 Subject: [PATCH] refactor: make RN particle API field-renderer only --- .../MapLibre粒子ReactNative使用说明_v3.md | 79 ++++++++++ .../STEP-064_RN纯FieldRenderer接口V2_v1.md | 34 +++++ ..._RN纯FieldRenderer接口V2_Codex完成报告_v1.md | 31 ++++ .../App.tsx | 62 +++----- .../README.md | 12 +- .../package.json | 3 +- .../src/httpWeatherV2Demo.ts | 40 ----- .../src/weatherStyle.ts | 33 +++-- packages/maplibre-particle-rn/CHANGELOG.md | 18 ++- packages/maplibre-particle-rn/README.md | 117 ++++++--------- packages/maplibre-particle-rn/package.json | 6 +- .../maplibre-particle-rn/src/FieldRuntime.ts | 34 +++++ .../src/NativeWeatherRuntimeBridge.ts | 5 +- packages/maplibre-particle-rn/src/index.ts | 54 ++++--- packages/maplibre-particle-rn/src/types.ts | 3 +- .../src/weatherV2/WeatherV2HttpClient.ts | 138 ------------------ .../src/weatherV2/index.ts | 17 --- .../src/weatherV2/loadIntoWeatherRuntime.ts | 114 --------------- .../src/weatherV2/types.ts | 96 ------------ .../src/weatherV2/wvecbin1.ts | 136 ----------------- .../maplibre-particle/release-manifest.json | 16 +- 粒子开发记录.md | 7 + 22 files changed, 343 insertions(+), 712 deletions(-) create mode 100644 docs/30_设计/MapLibre粒子ReactNative使用说明_v3.md create mode 100644 docs/50_Step记录/STEP-064_RN纯FieldRenderer接口V2_v1.md create mode 100644 docs/报告/STEP-064_RN纯FieldRenderer接口V2_Codex完成报告_v1.md delete mode 100644 integration/react-native/MapLibreParticlePackageConsumerTestHost/src/httpWeatherV2Demo.ts create mode 100644 packages/maplibre-particle-rn/src/FieldRuntime.ts delete mode 100644 packages/maplibre-particle-rn/src/weatherV2/WeatherV2HttpClient.ts delete mode 100644 packages/maplibre-particle-rn/src/weatherV2/index.ts delete mode 100644 packages/maplibre-particle-rn/src/weatherV2/loadIntoWeatherRuntime.ts delete mode 100644 packages/maplibre-particle-rn/src/weatherV2/types.ts delete mode 100644 packages/maplibre-particle-rn/src/weatherV2/wvecbin1.ts diff --git a/docs/30_设计/MapLibre粒子ReactNative使用说明_v3.md b/docs/30_设计/MapLibre粒子ReactNative使用说明_v3.md new file mode 100644 index 000000000..b734fab3b --- /dev/null +++ b/docs/30_设计/MapLibre粒子ReactNative使用说明_v3.md @@ -0,0 +1,79 @@ +# MapLibre 粒子 React Native 使用说明 v3 + +本文对应 `@navsea/maplibre-particle-rn@13.4.1-particle.2`。v2 保留为 `.1` 历史接口说明;不要将其中的旧接口用于本版本。 + +## 安装与版本 + +Consumer 直接安装 `@navsea/maplibre-particle-rn@13.4.1-particle.2` 和 `@maplibre/maplibre-react-native@11.3.6`。RN 包 `.2` 继续使用 Android Native artifact `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;本版本没有新的 AAR。 + +```ts +import { Camera, Map } from "@maplibre/maplibre-react-native"; +import { FieldRuntime } from "@navsea/maplibre-particle-rn"; +``` + +Map 与 Camera 必须来自官方 MLRN。NavSea 包仅为 peer dependency 对接 MLRN,不转导出地图组件。 + +## Style 合入 + +宿主保有自己的底图、source 和 layer。将纯 renderer fragment 合入既有 style: + +```ts +import { createParticleFieldStyleFragment } from "@navsea/maplibre-particle-rn"; + +const fragment = createParticleFieldStyleFragment(); +const style = { + version: 8, + sources: { ...hostSources, ...fragment.sources }, + layers: [...hostLayers, ...fragment.layers], +}; +``` + +fragment 只有 field source、field layer 和 particle layer;不含 GSI 或任何业务底图。使用 `particleFieldIds` 引用 renderer ID,不要自行硬编码其历史内部字符串。 + +## Canonical Field 提交 + +React/宿主选择业务数据、下载/解码、校验、缓存和本地文件写入;SDK 不理解 provider、network、WeatherV2 或数据集。宿主将既有 Native 数据契约的 `metadataJson` 与本地 `file://` payload 提交一次: + +```ts +await FieldRuntime.attach(reactTag); +await FieldRuntime.clearFields(reactTag); +await FieldRuntime.submitField(reactTag, metadataJson, localFileUri); +``` + +契约保持 `maplibre-particle-field/v1`、`float32-le`、`interleaved-uv`。同一 canonical Field 同时供 field renderer 和 particle renderer 使用,不需要重复提交。 + +## FieldRuntime 方法 + +所有方法均以 `reactTag: number` 为首参,异步返回现有 bridge 的结果;无 Native bridge 时调用会明确失败,import 本身不会失败。 + +| 方法 | 其余参数 | 返回值 | +| --- | --- | --- | +| `attach` | 无 | `Promise` | +| `detach` | 无 | `Promise` | +| `getState` | 无 | `Promise` | +| `clearFields` | 无 | `Promise` | +| `submitField` | `metadataJson: string`, `payloadFileUri: string` | `Promise` | +| `setEnabled` | `enabled: boolean` | `Promise` | +| `setFieldVisible` | `visible: boolean` | `Promise` | +| `setParticleVisible` | `visible: boolean` | `Promise` | +| `setTargetFps` | `target: "AUTO" | "FPS_30" | "FPS_60"` | `Promise` | +| `setTargetTime` | `timestampMs: number` | `Promise` | +| `setPlaybackState` | `state: "PLAY" | "PAUSE"` | `Promise` | +| `setPlaybackSpeed` | `speed: "HALF" | "NORMAL" | "DOUBLE"` | `Promise` | +| `setFieldColorStops` | `stops: readonly FieldColorStop[]` | `Promise` | +| `setParticleColor` / `setTrailColor` | `red, green, blue, alpha: number` | `Promise` | +| `setParticleCount` | `count: number` | `Promise` | +| `setParticleLineWidth` / `setParticleLength` | `value: number` | `Promise` | +| `setParticleOpacity` / `setTrailOpacity` / `setTrailFade` / `setFieldOpacity` | `value: number` | `Promise` | + +`FieldColorStop` 为 `{ value, red, green, blue, alpha }`。`RuntimeSnapshot` 包含 `state`、`playbackState`、`targetFps`、`disposed`;`CommandResult` 为 `"ACCEPTED" | "REJECTED"`。 + +`setFieldColorStops()` 继续遵循既有 Native 色阶约束:至少 2 个 stop,`value` 必须为 finite 且严格递增,RGBA 分量位于 `[0,1]`。 + +## 独立显示 + +`setFieldVisible()` 与 `setParticleVisible()` 可独立切换同一 Field 的两种 renderer。`setEnabled()` 控制 runtime 总开关。SDK 不根据业务类型自动选择显示组合;显示策略由宿主决定。 + +## 边界 + +本包不提供业务数据类型、HTTP、provider、重试、缓存、路线或 ETA 策略。Native/Core、JNI、shader 和 Android AAR 均保持 `.1` 已验证实现不变。Android 实机验证应在 Consumer 安装 `.2` tarball 后单独执行。 diff --git a/docs/50_Step记录/STEP-064_RN纯FieldRenderer接口V2_v1.md b/docs/50_Step记录/STEP-064_RN纯FieldRenderer接口V2_v1.md new file mode 100644 index 000000000..00b67212f --- /dev/null +++ b/docs/50_Step记录/STEP-064_RN纯FieldRenderer接口V2_v1.md @@ -0,0 +1,34 @@ +# STEP-064 RN 纯 Field Renderer 接口 V2 + +状态:进行中(代码完成,待 Consumer 验证) + +## 目标 + +将 `@navsea/maplibre-particle-rn` 升级为 `13.4.1-particle.2` 纯 Field Renderer 公共 API。React/宿主负责业务、WeatherV2、provider、网络、数据选择和文件管理;SDK 只向既有 Native ingress 提交 canonical Field 并控制图场/粒子渲染。 + +## 已完成 + +- 新增根入口 `FieldRuntime`,以薄映射保留既有 Native bridge ABI,未保留旧 API wrapper。 +- 公共类型使用 `FieldColorStop.value`;保留无业务含义的 Runtime、Playback、FPS 与结果类型。 +- 移除 package 内 `src/weatherV2/` 与根导出;不再转导出 MLRN。 +- 新增不含底图的 `particleFieldIds` 和 `createParticleFieldStyleFragment()`。 +- `TurboModuleRegistry.get()` 使无 Native module 的 import 安全;实际调用时给出明确错误。 +- Consumer Host 直接从官方 MLRN 导入 Map/Camera,仅从 NavSea 包导入 Field Renderer API;本地真实 fixture 仍由宿主提交 canonical metadata 与 file URI。 +- release manifest 明确 RN `.2` 对应冻结 Native `.1`。 + +## 验证 + +- package TypeScript typecheck:通过。 +- Consumer TypeScript typecheck:通过(使用临时类型解析配置,仅用于检查,不进入 Consumer 产品依赖)。 +- `npm pack`:通过,`.2` tarball 仅生成在 `/tmp/step064-pack/`,未触碰仓库既有未跟踪 `.1` tarball。 +- tarball 内容审计:通过;不含 `src/weatherV2/`、WeatherV2 HTTP/decoder/loader、Test Host、node_modules 或构建缓存。 +- 根导出与业务语义审计、`git diff --check`:通过;旧名称仅存在于内部 Native ABI/codegen 接缝,未由根入口公开。 +- Android Gradle、AAR、APK、ADB、Jest:按边界未执行。 +- RN 0.85.2 Codegen parser 已核对支持 `TurboModuleRegistry.get()` 与 `getEnforcing()`;当前 import-safe `get()` 方案保留。 +- 收尾复审修正:`.2` Consumer validation 明确为 pending,`.1` PASS 仅作为 previous baseline;FieldColorStop 文档示例改为满足至少 2 个 stop 的既有 Native 契约。 + +## 待办与边界 + +- 待 Consumer 安装最终 `.2` tarball 并实际运行验证;此项完成前不得标记 RELEASED 或 CLOSED/PASS。 +- 未修改 Native/Core/JNI/shader/Android AAR,Native artifact 继续 `13.4.1-particle.1`。 +- 未进入 NavSea。 diff --git a/docs/报告/STEP-064_RN纯FieldRenderer接口V2_Codex完成报告_v1.md b/docs/报告/STEP-064_RN纯FieldRenderer接口V2_Codex完成报告_v1.md new file mode 100644 index 000000000..418d31e1f --- /dev/null +++ b/docs/报告/STEP-064_RN纯FieldRenderer接口V2_Codex完成报告_v1.md @@ -0,0 +1,31 @@ +# STEP-064 RN 纯 Field Renderer 接口 V2 完成报告 + +状态:IMPLEMENTED / WAITING_CONSUMER_VALIDATION + +## 完成内容 + +`@navsea/maplibre-particle-rn` 已升为 `13.4.1-particle.2`。根入口仅公开 `FieldRuntime`、纯渲染类型、`particleFieldIds` 与 `createParticleFieldStyleFragment()`;旧 Weather/Wind 公共名称、MLRN facade 导出与兼容 wrapper 均已删除。 + +`FieldRuntime.submitField()` / `clearFields()` 继续薄映射既有 Native ingress。图场与粒子消费同一 canonical Field,分别由 `setFieldVisible()` 与 `setParticleVisible()` 控制。`FieldColorStop` 使用 `value`,RN 内部才转换既有 Native ABI 所需字段。 + +package 内 WeatherV2 HTTP、manifest、time index、WVECBIN1 decoder 与高层 loader 已移除。Consumer Test Host 改为直接使用官方 MLRN 的 Map/Camera,并自行保有 GSI、真实 fixture 与本地文件物化;最终仍只向 SDK 提交 metadata 与 file URI。 + +## 冻结边界 + +- Native/Core/JNI/shader/Android AAR 未修改。 +- Native artifact 为 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;`.2` 仅为 RN/TS 公共 API revision。 +- 未构建/发布 AAR,未执行 Android Gradle、APK、ADB 或 Jest,未进入 NavSea。 + +## 静态验证 + +- package TypeScript typecheck:通过。 +- Consumer TypeScript typecheck:通过;因 Consumer 尚未安装 `.2` 发布包,检查采用临时 TypeScript module-resolution 配置,不改变 Consumer 产品依赖。 +- `npm pack`:通过,`.2` tarball 仅生成在 `/tmp/step064-pack/`,未触碰仓库既有未跟踪 `.1` tarball。 +- tarball 内容审计:通过;不含 `src/weatherV2/`、WeatherV2 HTTP/decoder/loader、Test Host、node_modules 或构建缓存。 +- package 根导出、业务语义残留审计和 `git diff --check`:通过。旧 Weather/Wind 名称仅留在内部 Native ABI/codegen 接缝及其薄映射实现,不构成公开 API。 +- RN 0.85.2 Codegen parser 已核对支持 `TurboModuleRegistry.get()` 与 `getEnforcing()`;当前 import-safe `get()` 方案保留。 +- 收尾复审修正:`.2` Consumer validation 明确为 pending,`.1` PASS 仅作为 previous baseline;FieldColorStop 文档示例改为满足至少 2 个 stop 的既有 Native 契约。 + +## 未完成与后续最小验证 + +Consumer 尚未安装最终 `.2` tarball 并在既有 `.1` Native artifact 的 APK 中运行。后续最小步骤是:安装 `.2` tarball,启动 Metro,确认 Map/Camera 来自官方 MLRN,加载宿主 canonical T0-T3 fixture,确认 `FieldRuntime` attach、提交、独立开关与控制命令正常。该人工 Consumer 验证完成前,本 Step 保持等待状态。 diff --git a/integration/react-native/MapLibreParticlePackageConsumerTestHost/App.tsx b/integration/react-native/MapLibreParticlePackageConsumerTestHost/App.tsx index d2953ffb5..4bb78c401 100644 --- a/integration/react-native/MapLibreParticlePackageConsumerTestHost/App.tsx +++ b/integration/react-native/MapLibreParticlePackageConsumerTestHost/App.tsx @@ -1,15 +1,15 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { Camera, Map } from "@maplibre/maplibre-react-native"; import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native"; -import { Camera, Map, WeatherRuntime, type ColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn"; +import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn"; import { materializeRealWeatherFrames, realWeatherFrames } from "./src/realWeatherDataset"; -import { loadHttpWeatherV2 } from "./src/httpWeatherV2Demo"; import { weatherTestStyle } from "./src/weatherStyle"; 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: ColorStop[] = [ - { speedMps: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { speedMps: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 }, - { speedMps: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { speedMps: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 }, +const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", fieldOpacity: "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 }, ]; export default function App() { @@ -19,22 +19,20 @@ export default function App() { const [basemap, setBasemap] = useState("LOADING"); const [frames, setFrames] = useState>({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" }); const [transport, setTransport] = useState("PENDING: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); - const [httpTransport, setHttpTransport] = useState("PENDING: WeatherV2 HTTP → canonical U/V → WeatherRuntime"); - const [httpLoading, setHttpLoading] = useState(false); - const [weatherVisible, setWeatherVisible] = useState(true); + const [fieldVisible, setFieldVisible] = useState(true); const [particleVisible, setParticleVisible] = useState(true); const [visual, setVisual] = useState(initialVisual); - const refresh = useCallback(async (tag: number) => setSnapshot(await WeatherRuntime.getState(tag)), []); + const refresh = useCallback(async (tag: number) => setSnapshot(await FieldRuntime.getState(tag)), []); const attach = useCallback(async (tag: number) => { - try { await WeatherRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("WeatherRuntime attached; style ready"); } + try { await FieldRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("FieldRuntime attached; style ready"); } catch (error) { setResult(`style/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 WeatherRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]); + useEffect(() => () => { if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]); const command = useCallback(async (name: string, operation: (tag: number) => Promise) => { if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready"); @@ -47,49 +45,30 @@ export default function App() { const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY", "ACCEPTED_NEXT_SNAPSHOT", "ACCEPTED_NEXT_SNAPSHOT"]; try { setTransport("LOADING: materializing Expo Asset local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" }); - if (await WeatherRuntime.clearWindFields(reactTag) !== "ACCEPTED") throw new Error("clearWindFields was rejected"); + if (await FieldRuntime.clearFields(reactTag) !== "ACCEPTED") throw new Error("clearFields was rejected"); const localFrames = await materializeRealWeatherFrames(); for (let index = 0; index < localFrames.length; index += 1) { - const frame = localFrames[index]; const submit = await WeatherRuntime.submitWindField(reactTag, frame.metadataJson, frame.payloadFileUri); + const frame = localFrames[index]; const submit = await FieldRuntime.submitField(reactTag, frame.metadataJson, frame.payloadFileUri); setFrames((current) => ({ ...current, [frame.label]: submit })); if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`); } - const native = await WeatherRuntime.getState(reactTag); setSnapshot(native); - if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`); + const native = await FieldRuntime.getState(reactTag); setSnapshot(native); + if (native.state !== "READY") throw new Error(`Native FieldRuntime expected READY, got ${native.state}`); setTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); setResult("T0-T3 accepted in strict producer timestamp order; Native runtime READY"); } catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD REAL WEATHER T0-T3: ${String(error)}`); } }, [reactTag]); - const loadHttpWeather = useCallback(async () => { - if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready"); - if (httpLoading) return; - setHttpLoading(true); - try { - setHttpTransport("LOADING: manifest → time index → WVECBIN1 → canonical U/V"); - const loaded = await loadHttpWeatherV2(reactTag); - setSnapshot(loaded.runtime); - setFrames(Object.fromEntries(loaded.frames.map((frame, index) => [`T${index}`, frame.submitResult])) as Record); - setHttpTransport(`READY: ${loaded.frames.length} HTTP frames / ${loaded.context.referenceTime}`); - setResult(`HTTP WeatherV2 READY: ${loaded.frames.map((frame) => frame.fieldId).join(", ")}`); - } catch (error) { - setHttpTransport(`ERROR: ${String(error)}`); - setResult(`LOAD HTTP WEATHER: ${String(error)}`); - } finally { - setHttpLoading(false); - } - }, [httpLoading, 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([ - WeatherRuntime.setParticleCount(tag, Number(visual.count)), WeatherRuntime.setParticleLineWidth(tag, Number(visual.width)), WeatherRuntime.setParticleLength(tag, Number(visual.length)), WeatherRuntime.setParticleOpacity(tag, Number(visual.opacity)), WeatherRuntime.setTrailOpacity(tag, Number(visual.trailOpacity)), WeatherRuntime.setTrailFade(tag, Number(visual.trailFade)), WeatherRuntime.setWeatherColorOpacity(tag, Number(visual.weatherOpacity)), + 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.fieldOpacity)), ]); if (results.some((value) => value !== "ACCEPTED")) throw new Error(results.join(", ")); return "ACCEPTED"; }), [command, visual]); const controls = useMemo(() => [ - ["PLAY", (tag: number) => WeatherRuntime.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => WeatherRuntime.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "DOUBLE")], - ["FPS AUTO", (tag: number) => WeatherRuntime.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => WeatherRuntime.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => WeatherRuntime.setTargetFps(tag, "FPS_60" as TargetFps)], ["Weather stops", (tag: number) => WeatherRuntime.setWeatherColorStops(tag, frozenStops)], ["Particle color", (tag: number) => WeatherRuntime.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => WeatherRuntime.setTrailColor(tag, 0.35, 0.85, 1, 0.8)], + ["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 @@ -98,11 +77,10 @@ export default function App() { Basemap: GSI {basemap}Dataset: ECMWF HRES 20260807 · 513×769 · 3.01 MiB/frameRuntime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}REAL DATA TRANSPORT = {transport} {realWeatherFrames.map((frame) => {frame.label} {frame.cycle}: {frames[frame.label]})} {result}