feat: add RN WeatherRuntime bridge test host

This commit is contained in:
OpenAI Codex
2026-09-07 16:09:50 +08:00
parent b535d98263
commit 4c906b21fb
19 changed files with 7225 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
import { Map } from "@maplibre/maplibre-react-native";
import { WeatherRuntimeBridge, type RuntimeSnapshot, type TargetFps } from "@maplibre-particle/weather-runtime-bridge";
import { weatherTestStyle } from "./src/weatherStyle";
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
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 [opacity, setOpacity] = useState("0.62");
const attach = useCallback(async (tag: number) => {
try { setSnapshot(await WeatherRuntimeBridge.attach(tag)); setReactTag(tag); setResult("WeatherRuntime attached"); }
catch (error) { setResult(String(error)); }
}, []);
// MLRN forwards the native MapView event. Its target is the concrete Fabric MLRNMapView tag;
// this is the same tag consumed by MLRN's own NativeMapViewModule.
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 WeatherRuntimeBridge.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 style ready");
try { const value = await operation(reactTag); setResult(`${name}: ${String(value)}`); setSnapshot(await WeatherRuntimeBridge.getState(reactTag)); }
catch (error) { setResult(`${name}: ${String(error)}`); }
}, [reactTag]);
const buttons = useMemo(() => [
["getState", (tag: number) => WeatherRuntimeBridge.getState(tag)],
["enable", (tag: number) => WeatherRuntimeBridge.setWeatherEnabled(tag, true)],
["weather visible", (tag: number) => WeatherRuntimeBridge.setWeatherColorVisible(tag, true)],
["particle visible", (tag: number) => WeatherRuntimeBridge.setParticleVisible(tag, true)],
["FPS 30", (tag: number) => WeatherRuntimeBridge.setTargetFps(tag, "FPS_30" as TargetFps)],
["PLAY", (tag: number) => WeatherRuntimeBridge.setPlaybackState(tag, "PLAY")],
["PAUSE", (tag: number) => WeatherRuntimeBridge.setPlaybackState(tag, "PAUSE")]
] as const, []);
return <SafeAreaView style={styles.root}>
<View style={styles.map}><Map mapStyle={weatherTestStyle} onDidFinishLoadingStyle={onStyleReady} /></View>
<ScrollView style={styles.controls} contentContainerStyle={styles.controlContent}>
<Text>reactTag: {reactTag ?? "not attached"}</Text>
<Text>runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text>
<Text selectable>{result}</Text>
{buttons.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}
<TextInput value={opacity} onChangeText={setOpacity} keyboardType="decimal-pad" style={styles.input} />
<Button title="weather opacity" onPress={() => void command("weather opacity", (tag) => WeatherRuntimeBridge.setWeatherColorOpacity(tag, Number(opacity)))} />
</ScrollView>
</SafeAreaView>;
}
const styles = StyleSheet.create({
root: { flex: 1 }, map: { flex: 1 }, controls: { maxHeight: 280 }, controlContent: { gap: 8, padding: 12 }, input: { borderWidth: 1, padding: 6 }
});

View File

@@ -0,0 +1,38 @@
# MapLibreParticleRNTestHost
这是 MapLibreParticleLab 内的独立 React Native 开发宿主,不是 NavSea 业务应用。它只有一个 `MLRNMapView` 与 WeatherRuntime 控制页,用于证明低频控制面:React 的 `reactTag` 经薄 Android 模块定位该 View,再定位该 View 的 `MapLibreMap` 与 map-owned `WeatherRuntime`。
## 冻结版本
- React Native `0.85.2`、React `19.2.5`、Expo `56.0.4`、TypeScript `5.9.3`;Hermes 与 New Architecture 由 Expo Android prebuild 保持开启。
- `@maplibre/maplibre-react-native` `11.3.6`;Android AGP `8.5.0`、Gradle `8.14.3`、Kotlin `2.1.20`、NDK `27.1.12297006`、JDK 17、minSdk 24、compile/targetSdk 36 是手工 Android 工程的冻结目标。
- MLRN 的 root Gradle extras 固定 `org.maplibre.reactnative.nativeVersion=13.4.1` 与 `nativeVariant=opengl`。
## Custom SDK 来源(手工执行)
Test Host 的 Expo plugin 会把 `org.maplibre.gl` 从 Maven Central 排除,只允许 Maven Local。因此未先发布本仓库 custom artifact 时,Android dependency resolution 必须失败,不能悄悄使用官方 SDK。
```bash
cd /mnt/sda1/MapLibreParticleLab/platform/android
./gradlew :MapLibreAndroid:publishToMavenLocal
cd /mnt/sda1/MapLibreParticleLab/integration/react-native/MapLibreParticleRNTestHost
yarn install
npx expo prebuild --platform android --clean
npx expo run:android
```
上述 Android 命令是用户手工验收步骤;本 Step 的 Codex 没有执行。发布前应检查 Maven Local 中 `org/maplibre/gl/android-sdk-opengl/13.4.1/` 的 AAR 来自当前 particle/main build,而非历史官方 artifact。
## Bridge 与生命周期
`WeatherRuntimeBridge` 每个命令通过 `UIManagerHelper.getUIManager(...FABRIC).resolveView(reactTag)` 解析 View,并只接受 `MLRNMapView`。它直接读取公开的 `MLRNMapView.mapLibreMap`,不复制 MLRN 的私有 `mViews`,不保存 current map,也没有第二套 timeline/playback state。`MapLibreMap` 持有每个 map 的唯一 `WeatherRuntime`:style replacement、View detach 和 React unmount 都会 dispose 旧 facade;style ready 后 `attach` 创建新的 facade。
Style JSON 明确创建 background、host-field `RasterArraySource`、`WeatherColorLayer` 与 `RasterParticleLayer`。Bridge 仅在 source/layers 都存在且 style 已加载时 attach;它不会创建私有 Style registry。错误为结构化 reject:`MAP_VIEW_NOT_FOUND`、`MAP_NOT_READY`、`STYLE_NOT_READY`、`WEATHER_OBJECTS_NOT_READY`、`RUNTIME_DISPOSED`、`INVALID_ARGUMENT` 或 Native `REJECTED`。
## Phase A / B 操作
页面 style ready 后显示 reactTag/runtime;Phase A 按钮为 `getState`、enable、WeatherColor visible、particle visible、FPS 30。Phase B 还通过 bridge 暴露 epoch-ms forecast seek、PLAY/PAUSE、速度、stops、独立 particle/trail RGBA、粒子视觉参数和 WeatherColor opacity。JS 只校验小参数,并不保存另一套 runtime 状态;snapshot 来自 Native。
当前**不包含 Binary Field**,不传 `number[]`、JSON 数组或 base64 field。3.01 MiB canonical field ingress 留给 STEP-061。
启动入口是 `App.tsx`。Android build、安装及设备操作必须由用户手工执行,详见 `docs/60_测试/STEP-060_RNTestHost_Android人工编译与验收_v1.md`。

View File

@@ -0,0 +1,11 @@
{
"expo": {
"name": "MapLibre Particle RN Test Host",
"slug": "maplibre-particle-rn-test-host",
"version": "0.1.0",
"orientation": "portrait",
"newArchEnabled": true,
"plugins": ["./plugins/withParticleNativeAndroid"],
"android": { "package": "org.maplibre.particle.rntesthost", "minSdkVersion": 24 }
}
}

View File

@@ -0,0 +1,19 @@
plugins {
id "com.android.library"
id "org.jetbrains.kotlin.android"
}
android {
namespace "org.maplibre.particle.rntesthost.weather"
compileSdk 36
defaultConfig { minSdk 24 }
}
dependencies {
implementation "com.facebook.react:react-android"
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
implementation "org.maplibre.gl:android-sdk-opengl:13.4.1"
// RN autolinking names the scoped MLRN Android project deterministically. The app owns the
// runtime dependency; this module only needs its public MLRNMapView type at compile time.
compileOnly project(":maplibre_maplibre-react-native")
}

View File

@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@@ -0,0 +1,132 @@
package org.maplibre.particle.rntesthost.weather
import android.view.View
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.turbomodule.core.interfaces.TurboModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.uimanager.IllegalViewOperationException
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.common.UIManagerType
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.WeatherRuntime
import org.maplibre.reactnative.components.mapview.MLRNMapView
/**
* A low-frequency, per-view bridge. It resolves the Fabric view for each call and never stores a
* reactTag, current map, timeline, playback state, or a second Map registry.
*/
class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : NativeWeatherRuntimeBridgeSpec(context), TurboModule {
@ReactMethod
fun attach(reactTag: Double, promise: Promise) = withMapView(reactTag, promise) { view, map ->
val style = map.style ?: return@withMapView reject(promise, "STYLE_NOT_READY", "The MapLibre style is not loaded")
if (style.getSource(WeatherIds.SOURCE) == null || style.getLayer(WeatherIds.COLOR_LAYER) == null || style.getLayer(WeatherIds.PARTICLE_LAYER) == null) {
return@withMapView reject(promise, "WEATHER_OBJECTS_NOT_READY", "The Test Host weather source/layers are absent from the current style")
}
val runtime = map.replaceWeatherRuntime(WeatherRuntime.Config(WeatherIds.SOURCE, WeatherIds.COLOR_LAYER, WeatherIds.PARTICLE_LAYER))
// MLRN drops its MapView by detaching it. This listener captures only that concrete map,
// disposes its map-owned facade, and cannot target a replacement view.
view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = Unit
override fun onViewDetachedFromWindow(v: View) { map.disposeWeatherRuntime() }
})
promise.resolve(snapshot(runtime))
}
@ReactMethod
fun detach(reactTag: Double, promise: Promise) = withMap(reactTag, promise) { map ->
if (map.weatherRuntime == null) reject(promise, "RUNTIME_DISPOSED", "No attached WeatherRuntime exists for this map")
else { map.disposeWeatherRuntime(); promise.resolve(null) }
}
@ReactMethod fun getState(reactTag: Double, promise: Promise) = withRuntime(reactTag, promise) { runtime -> promise.resolve(snapshot(runtime)) }
@ReactMethod fun setWeatherEnabled(reactTag: Double, enabled: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherEnabled(enabled) }
@ReactMethod fun setWeatherColorVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherColorVisible(visible) }
@ReactMethod fun setParticleVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setParticleVisible(visible) }
@ReactMethod fun setTargetFps(reactTag: Double, target: String, promise: Promise) = enumCommand(reactTag, promise, target, WeatherRuntime.TargetFps.values()) { runtime, value -> runtime.setTargetFps(value) }
@ReactMethod fun setPlaybackState(reactTag: Double, playback: String, promise: Promise) = enumCommand(reactTag, promise, playback, WeatherRuntime.PlaybackState.values()) { runtime, value -> runtime.setPlaybackState(value) }
@ReactMethod fun setPlaybackSpeed(reactTag: Double, speed: String, promise: Promise) = enumCommand(reactTag, promise, speed, WeatherRuntime.PlaybackSpeed.values()) { runtime, value -> runtime.setPlaybackSpeed(value) }
@ReactMethod
fun setForecastTime(reactTag: Double, timestampMs: Double, promise: Promise) {
if (!timestampMs.isFinite() || timestampMs < 0 || timestampMs > Long.MAX_VALUE.toDouble() || timestampMs != kotlin.math.floor(timestampMs)) {
return reject(promise, "INVALID_ARGUMENT", "forecast timestamp must be a non-negative epoch millisecond integer")
}
command(reactTag, promise) { it.setForecastTime(timestampMs.toLong()) }
}
@ReactMethod fun setParticleCount(reactTag: Double, count: Double, promise: Promise) {
if (!count.isFinite() || count < 0 || count > Int.MAX_VALUE || count != kotlin.math.floor(count)) return reject(promise, "INVALID_ARGUMENT", "particle count must be a non-negative integer")
command(reactTag, promise) { it.setParticleCount(count.toInt()) }
}
@ReactMethod fun setParticleLineWidth(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setParticleLineWidth(valid) }
@ReactMethod fun setParticleLength(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setParticleLength(valid) }
@ReactMethod fun setParticleOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setParticleOpacity(valid) }
@ReactMethod fun setTrailOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setTrailOpacity(valid) }
@ReactMethod fun setTrailFade(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setTrailFade(valid) }
@ReactMethod fun setWeatherColorOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setWeatherColorOpacity(valid) }
@ReactMethod fun setParticleColor(reactTag: Double, red: Double, green: Double, blue: Double, alpha: Double, promise: Promise) = colorCommand(reactTag, red, green, blue, alpha, promise) { runtime, r, g, b, a -> runtime.setParticleColor(r, g, b, a) }
@ReactMethod fun setTrailColor(reactTag: Double, red: Double, green: Double, blue: Double, alpha: Double, promise: Promise) = colorCommand(reactTag, red, green, blue, alpha, promise) { runtime, r, g, b, a -> runtime.setTrailColor(r, g, b, a) }
@ReactMethod
fun setWeatherColorStops(reactTag: Double, stops: ReadableArray, promise: Promise) {
val parsed = ArrayList<WeatherRuntime.ColorStop>()
for (index in 0 until stops.size()) {
val stop: ReadableMap = try { stops.getMap(index) ?: throw IllegalArgumentException() } catch (_: RuntimeException) { return reject(promise, "INVALID_ARGUMENT", "color stop $index must be an object") }
try {
val speed = stop.getDouble("speedMps"); val red = stop.getDouble("red"); val green = stop.getDouble("green"); val blue = stop.getDouble("blue"); val alpha = stop.getDouble("alpha")
if (!speed.isFinite() || !red.isFinite() || !green.isFinite() || !blue.isFinite() || !alpha.isFinite() || red !in 0.0..1.0 || green !in 0.0..1.0 || blue !in 0.0..1.0 || alpha !in 0.0..1.0 || (parsed.isNotEmpty() && speed <= parsed.last().speedMps)) {
return reject(promise, "INVALID_ARGUMENT", "stops require finite, strictly increasing speedMps and finite RGBA in [0,1]")
}
parsed.add(WeatherRuntime.ColorStop(speed.toFloat(), red.toFloat(), green.toFloat(), blue.toFloat(), alpha.toFloat()))
} catch (_: RuntimeException) { return reject(promise, "INVALID_ARGUMENT", "color stop $index is incomplete") }
}
command(reactTag, promise) { it.setWeatherColorStops(parsed) }
}
private fun command(reactTag: Double, promise: Promise, fn: (WeatherRuntime) -> WeatherRuntime.CommandResult) = withRuntime(reactTag, promise) { runtime ->
try {
promise.resolve(fn(runtime).name)
} catch (error: IllegalStateException) {
// WeatherRuntime rejects disabled/disposed commands by contract. Do not let an
// exception escape the UI queue or turn a stale command into an implicit success.
reject(promise, "NATIVE_REJECTED", error.message ?: "WeatherRuntime rejected the command")
}
}
private fun floatCommand(reactTag: Double, value: Double, promise: Promise, fn: (WeatherRuntime, Float) -> WeatherRuntime.CommandResult) {
if (!value.isFinite()) return reject(promise, "INVALID_ARGUMENT", "value must be finite")
command(reactTag, promise) { fn(it, value.toFloat()) }
}
private fun colorCommand(reactTag: Double, red: Double, green: Double, blue: Double, alpha: Double, promise: Promise, fn: (WeatherRuntime, Float, Float, Float, Float) -> WeatherRuntime.CommandResult) {
if (listOf(red, green, blue, alpha).any { !it.isFinite() || it !in 0.0..1.0 }) return reject(promise, "INVALID_ARGUMENT", "RGBA must be finite values in [0,1]")
command(reactTag, promise) { fn(it, red.toFloat(), green.toFloat(), blue.toFloat(), alpha.toFloat()) }
}
private fun <T : Enum<T>> enumCommand(reactTag: Double, promise: Promise, raw: String, values: Array<T>, fn: (WeatherRuntime, T) -> WeatherRuntime.CommandResult) {
val value = values.firstOrNull { it.name == raw } ?: return reject(promise, "INVALID_ARGUMENT", "unsupported enum value: $raw")
command(reactTag, promise) { fn(it, value) }
}
private fun withRuntime(reactTag: Double, promise: Promise, fn: (WeatherRuntime) -> Unit) = withMap(reactTag, promise) { map ->
val runtime = map.weatherRuntime
if (runtime == null || runtime.isDisposed) reject(promise, "RUNTIME_DISPOSED", "WeatherRuntime is not attached to this map") else fn(runtime)
}
private fun withMap(reactTag: Double, promise: Promise, fn: (MapLibreMap) -> Unit) = withMapView(reactTag, promise) { _, map -> fn(map) }
private fun withMapView(reactTag: Double, promise: Promise, fn: (MLRNMapView, MapLibreMap) -> Unit) {
if (!reactTag.isFinite() || reactTag <= 0 || reactTag != kotlin.math.floor(reactTag)) return reject(promise, "INVALID_ARGUMENT", "reactTag must be a positive integer")
context.runOnUiQueueThread {
val view = try { UIManagerHelper.getUIManager(context, UIManagerType.FABRIC)?.resolveView(reactTag.toInt()) } catch (_: IllegalViewOperationException) { null }
if (view !is MLRNMapView) return@runOnUiQueueThread reject(promise, "MAP_VIEW_NOT_FOUND", "reactTag does not resolve to a current MLRNMapView")
val map = view.mapLibreMap ?: return@runOnUiQueueThread reject(promise, "MAP_NOT_READY", "MLRNMapView has no MapLibreMap yet")
fn(view, map)
}
}
private fun snapshot(runtime: WeatherRuntime) = Arguments.createMap().apply {
putString("state", runtime.state.name); putString("playbackState", runtime.playbackState.name); putString("targetFps", runtime.targetFps.name); putBoolean("disposed", runtime.isDisposed)
}
private fun reject(promise: Promise, code: String, message: String) = promise.reject(code, message)
private object WeatherIds { const val SOURCE = "rn-weather-fields"; const val COLOR_LAYER = "rn-weather-color"; const val PARTICLE_LAYER = "rn-weather-particles" }
companion object { const val NAME = "WeatherRuntimeBridge" }
}

View File

@@ -0,0 +1,20 @@
package org.maplibre.particle.rntesthost.weather
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.facebook.react.TurboReactPackage
class WeatherRuntimeBridgePackage : TurboReactPackage() {
override fun getModule(name: String, context: ReactApplicationContext): NativeModule? =
if (name == WeatherRuntimeBridgeModule.NAME) WeatherRuntimeBridgeModule(context) else null
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
mapOf(WeatherRuntimeBridgeModule.NAME to ReactModuleInfo(
WeatherRuntimeBridgeModule.NAME,
WeatherRuntimeBridgeModule::class.java.name,
false, false, false, false, true
))
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@maplibre-particle/weather-runtime-bridge",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"react-native": "src/index.ts",
"peerDependencies": { "react-native": "0.85.2" },
"codegenConfig": {
"name": "WeatherRuntimeBridgeSpec",
"type": "modules",
"jsSrcsDir": "src",
"android": { "javaPackageName": "org.maplibre.particle.rntesthost.weather" }
}
}

View File

@@ -0,0 +1 @@
module.exports = { dependency: { platforms: { android: { sourceDir: "./android" } } } };

View File

@@ -0,0 +1,28 @@
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";
import type { ColorStop, CommandResult, PlaybackState, RuntimeSnapshot, TargetFps } from "./index";
export interface Spec extends TurboModule {
attach(reactTag: number): Promise<RuntimeSnapshot>;
detach(reactTag: number): Promise<void>;
getState(reactTag: number): Promise<RuntimeSnapshot>;
setWeatherEnabled(reactTag: number, enabled: boolean): Promise<CommandResult>;
setWeatherColorVisible(reactTag: number, visible: boolean): Promise<CommandResult>;
setParticleVisible(reactTag: number, visible: boolean): Promise<CommandResult>;
setTargetFps(reactTag: number, target: TargetFps): Promise<CommandResult>;
setForecastTime(reactTag: number, timestampMs: number): Promise<CommandResult>;
setPlaybackState(reactTag: number, playback: PlaybackState): Promise<CommandResult>;
setPlaybackSpeed(reactTag: number, speed: "HALF" | "NORMAL" | "DOUBLE"): Promise<CommandResult>;
setWeatherColorStops(reactTag: number, stops: ReadonlyArray<ColorStop>): Promise<CommandResult>;
setParticleColor(reactTag: number, red: number, green: number, blue: number, alpha: number): Promise<CommandResult>;
setTrailColor(reactTag: number, red: number, green: number, blue: number, alpha: number): Promise<CommandResult>;
setParticleCount(reactTag: number, count: number): Promise<CommandResult>;
setParticleLineWidth(reactTag: number, value: number): Promise<CommandResult>;
setParticleLength(reactTag: number, value: number): Promise<CommandResult>;
setParticleOpacity(reactTag: number, value: number): Promise<CommandResult>;
setTrailOpacity(reactTag: number, value: number): Promise<CommandResult>;
setTrailFade(reactTag: number, value: number): Promise<CommandResult>;
setWeatherColorOpacity(reactTag: number, value: number): Promise<CommandResult>;
}
export default TurboModuleRegistry.getEnforcing<Spec>("WeatherRuntimeBridge");

View File

@@ -0,0 +1,9 @@
export type RuntimeState = "DISABLED" | "WAITING_FOR_FIELDS" | "READY" | "ERROR";
export type PlaybackState = "PLAY" | "PAUSE";
export type TargetFps = "AUTO" | "FPS_30" | "FPS_60";
export type CommandResult = "ACCEPTED" | "REJECTED";
export type ColorStop = Readonly<{ speedMps: number; red: number; green: number; blue: number; alpha: number }>;
export type RuntimeSnapshot = Readonly<{ state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }>;
export { default as WeatherRuntimeBridge } from "./NativeWeatherRuntimeBridge";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "maplibre-particle-rn-test-host",
"private": true,
"version": "0.1.0",
"packageManager": "yarn@4.10.3",
"scripts": {
"typecheck": "tsc --noEmit",
"start": "expo start --dev-client",
"android": "expo run:android"
},
"dependencies": {
"@maplibre/maplibre-react-native": "11.3.6",
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
"expo": "56.0.4",
"expo-dev-client": "~6.0.0",
"react": "19.2.5",
"react-native": "0.85.2"
},
"devDependencies": {
"@types/react": "19.2.5",
"typescript": "5.9.3"
}
}

View File

@@ -0,0 +1,16 @@
const { withProjectBuildGradle } = require("@expo/config-plugins");
const marker = "// MapLibreParticleRNTestHost custom OpenGL SDK";
/**
* Pins MLRN's configurable native dependency and makes org.maplibre.gl resolve from Maven Local
* only. A missing locally-published custom SDK is therefore a build error, never a fallback to
* Maven Central's official AAR.
*/
module.exports = function withParticleNativeAndroid(config) {
return withProjectBuildGradle(config, (project) => {
if (project.modResults.contents.includes(marker)) return project;
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.clear()\n p.repositories.mavenLocal()\n p.repositories.google()\n p.repositories.mavenCentral { content { excludeGroup(\"org.maplibre.gl\") } }\n }\n}\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVersion\", \"13.4.1\")\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVariant\", \"opengl\")\n`;
return project;
});
};

View File

@@ -0,0 +1,7 @@
module.exports = {
dependencies: {
"@maplibre-particle/weather-runtime-bridge": {
root: "./modules/weather-runtime-bridge"
}
}
};

View File

@@ -0,0 +1,21 @@
/** No field is embedded here: RasterArraySource is reserved for STEP-061 binary submission. */
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 weatherTestStyle = JSON.stringify({
version: 8,
sources: {
[WEATHER_SOURCE_ID]: { type: "raster-array", "host-field-submission": true }
},
layers: [
{ id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } },
{ 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 } }
]
});

View File

@@ -0,0 +1,5 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": { "strict": true, "noEmit": true },
"include": ["App.tsx", "src", "modules/weather-runtime-bridge/src"]
}

View File

@@ -81,6 +81,14 @@ public final class MapLibreMap {
@Nullable
private Style style;
/**
* Optional weather facade attached to this concrete map instance. It is deliberately owned by
* the map rather than by an application or bridge registry, so a style replacement and a map
* teardown cannot accidentally leave a facade pointing at another map.
*/
@Nullable
private WeatherRuntime weatherRuntime;
private boolean debugActive;
private boolean started;
@@ -171,6 +179,31 @@ public final class MapLibreMap {
}
}
/**
* Replaces this map's style-scoped weather facade. The caller must have already verified the
* configured source and layers in the currently loaded style.
*/
@NonNull
public WeatherRuntime replaceWeatherRuntime(@NonNull WeatherRuntime.Config config) {
disposeWeatherRuntime();
weatherRuntime = new WeatherRuntime(this, config);
return weatherRuntime;
}
/** Returns this map's attached weather facade, or null when no style-ready facade exists. */
@Nullable
public WeatherRuntime getWeatherRuntime() {
return weatherRuntime;
}
/** Disposes and detaches the style-scoped weather facade. Safe to call repeatedly. */
public void disposeWeatherRuntime() {
if (weatherRuntime != null) {
weatherRuntime.dispose();
weatherRuntime = null;
}
}
/**
* Called when the hosting Activity/Fragment onStart() method is called.
*/
@@ -1223,6 +1256,9 @@ public final class MapLibreMap {
* @see Style
*/
public void setStyle(Style.Builder builder, final Style.OnStyleLoaded callback) {
// A WeatherRuntime only controls objects from one loaded style. Never let it survive a
// replacement where identical IDs could refer to newly-created style objects.
disposeWeatherRuntime();
styleLoadedCallback = callback;
locationComponent.onStartLoadingMap();
if (style != null) {

View File

@@ -132,6 +132,8 @@ public final class WeatherRuntime {
scheduleCadenceWake();
return CommandResult.ACCEPTED;
}
/** Native-authoritative playback mode for a low-frequency Host snapshot. */
public PlaybackState getPlaybackState() { return playbackState; }
public TargetFps getTargetFps() { return targetFps; }
public CommandResult setWeatherColorStops(@NonNull List<ColorStop> stops) {
if (disposed) return CommandResult.REJECTED;