feat: add RN WeatherRuntime bridge test host
This commit is contained in:
62
integration/react-native/MapLibreParticleRNTestHost/App.tsx
Normal file
62
integration/react-native/MapLibreParticleRNTestHost/App.tsx
Normal 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 }
|
||||
});
|
||||
@@ -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`。
|
||||
11
integration/react-native/MapLibreParticleRNTestHost/app.json
Normal file
11
integration/react-native/MapLibreParticleRNTestHost/app.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { dependency: { platforms: { android: { sourceDir: "./android" } } } };
|
||||
@@ -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");
|
||||
@@ -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";
|
||||
6780
integration/react-native/MapLibreParticleRNTestHost/package-lock.json
generated
Normal file
6780
integration/react-native/MapLibreParticleRNTestHost/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
16
integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeAndroid.js
vendored
Normal file
16
integration/react-native/MapLibreParticleRNTestHost/plugins/withParticleNativeAndroid.js
vendored
Normal 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;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
dependencies: {
|
||||
"@maplibre-particle/weather-runtime-bridge": {
|
||||
root: "./modules/weather-runtime-bridge"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 } }
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": { "strict": true, "noEmit": true },
|
||||
"include": ["App.tsx", "src", "modules/weather-runtime-bridge/src"]
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user