feat: add RN binary wind field transport
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:
@@ -2,6 +2,7 @@ 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 { writeCanonicalWindFixture } from "./src/canonicalWindFixture";
|
||||
import { weatherTestStyle } from "./src/weatherStyle";
|
||||
|
||||
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
|
||||
@@ -11,6 +12,11 @@ export default function App() {
|
||||
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot);
|
||||
const [result, setResult] = useState("Style is loading");
|
||||
const [opacity, setOpacity] = useState("0.62");
|
||||
const [binaryTransport, setBinaryTransport] = useState("PENDING: submit fixture to validate FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
|
||||
const [invalidProbe, setInvalidProbe] = useState("PENDING");
|
||||
const [t0Submit, setT0Submit] = useState("PENDING");
|
||||
const [t1Submit, setT1Submit] = useState("PENDING");
|
||||
const [fieldWindow, setFieldWindow] = useState("TIMELINE_NOT_READY");
|
||||
|
||||
const attach = useCallback(async (tag: number) => {
|
||||
try { setSnapshot(await WeatherRuntimeBridge.attach(tag)); setReactTag(tag); setResult("WeatherRuntime attached"); }
|
||||
@@ -34,6 +40,39 @@ export default function App() {
|
||||
catch (error) { setResult(`${name}: ${String(error)}`); }
|
||||
}, [reactTag]);
|
||||
|
||||
const submitBinaryFixture = useCallback(async () => {
|
||||
if (reactTag == null) return setResult("MAP_NOT_READY: wait for style ready");
|
||||
try {
|
||||
setBinaryTransport("PENDING: clearing fields and validating FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
|
||||
setInvalidProbe("PENDING");
|
||||
setT0Submit("PENDING");
|
||||
setT1Submit("PENDING");
|
||||
setFieldWindow("PENDING: awaiting Core initial window");
|
||||
const clear = await WeatherRuntimeBridge.clearWindFields(reactTag);
|
||||
if (clear !== "ACCEPTED") throw new Error(`clearWindFields: ${clear}`);
|
||||
const [t0, t1, invalid] = writeCanonicalWindFixture();
|
||||
const rejected = await WeatherRuntimeBridge.submitWindField(reactTag, invalid.metadataJson, invalid.payloadFileUri);
|
||||
setInvalidProbe(rejected);
|
||||
if (rejected !== "REJECTED_ADAPTER") throw new Error(`invalid probe expected REJECTED_ADAPTER, got ${rejected}`);
|
||||
const t0Result = await WeatherRuntimeBridge.submitWindField(reactTag, t0.metadataJson, t0.payloadFileUri);
|
||||
setT0Submit(t0Result);
|
||||
if (t0Result !== "ACCEPTED_PENDING_INITIAL_WINDOW") throw new Error(`T0 expected ACCEPTED_PENDING_INITIAL_WINDOW, got ${t0Result}`);
|
||||
const t1Result = await WeatherRuntimeBridge.submitWindField(reactTag, t1.metadataJson, t1.payloadFileUri);
|
||||
setT1Submit(t1Result);
|
||||
if (t1Result !== "INITIAL_WINDOW_READY") throw new Error(`T1 expected INITIAL_WINDOW_READY, got ${t1Result}`);
|
||||
const native = await WeatherRuntimeBridge.getState(reactTag);
|
||||
setSnapshot(native);
|
||||
if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`);
|
||||
setFieldWindow(`Core evidence: INITIAL_WINDOW_READY + runtime READY; producer identity: T0=${t0.generation}@${t0.validTime}, T1=${t1.generation}@${t1.validTime}; MIX: Core-owned / not exposed`);
|
||||
setBinaryTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
|
||||
setResult(`Binary field submit: invalid=${rejected}, T0=${t0Result}, T1=${t1Result}`);
|
||||
} catch (error) {
|
||||
setBinaryTransport(`ERROR: ${String(error)}`);
|
||||
setResult(`Binary field submit: ${String(error)}`);
|
||||
setFieldWindow("TIMELINE_NOT_READY");
|
||||
}
|
||||
}, [reactTag]);
|
||||
|
||||
const buttons = useMemo(() => [
|
||||
["getState", (tag: number) => WeatherRuntimeBridge.getState(tag)],
|
||||
["enable", (tag: number) => WeatherRuntimeBridge.setWeatherEnabled(tag, true)],
|
||||
@@ -49,7 +88,13 @@ export default function App() {
|
||||
<ScrollView style={styles.controls} contentContainerStyle={styles.controlContent}>
|
||||
<Text>reactTag: {reactTag ?? "not attached"}</Text>
|
||||
<Text>runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text>
|
||||
<Text selectable>Binary Transport: {binaryTransport}</Text>
|
||||
<Text selectable>Invalid Probe: {invalidProbe}</Text>
|
||||
<Text selectable>T0 Submit Result: {t0Submit}</Text>
|
||||
<Text selectable>T1 Submit Result: {t1Submit}</Text>
|
||||
<Text selectable>Runtime Field Window: {fieldWindow}</Text>
|
||||
<Text selectable>{result}</Text>
|
||||
<Button title="Submit binary WIND T0/T1" onPress={() => void submitBinaryFixture()} />
|
||||
{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)))} />
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
## 版本与首次 prebuild 边界
|
||||
|
||||
- package manager 为 npm(`packageManager: npm@10.9.4`),锁文件为 `package-lock.json`;当前环境的 `npm ci --dry-run --ignore-scripts` 无须 `--legacy-peer-deps` 即可解析。
|
||||
- package 明确固定 React Native `0.85.2`、React `19.2.3`、Expo `56.0.4`、TypeScript `5.9.3`、MLRN `11.3.6`;`app.json` 明确请求 New Architecture。Hermes 的最终启用状态必须在首次 Android prebuild 后检查生成工程。
|
||||
- package 明确固定 React Native `0.85.2`、React `19.2.3`、Expo `56.0.4`、`expo-file-system` `56.0.11`、TypeScript `5.9.3`、MLRN `11.3.6`;`app.json` 明确请求 New Architecture。Hermes 的最终启用状态必须在首次 Android prebuild 后检查生成工程。
|
||||
- AGP `8.5.0`、Gradle `8.14.3`、Kotlin `2.1.20`、NDK `27.1.12297006`、JDK 17、minSdk 24、compile/targetSdk 36 是本 Test Host 的**人工验收配置目标**,不是尚未生成 Android 工程的已验证结果。首次 prebuild 后必须以实际 Gradle wrapper、AGP/Kotlin plugin、SDK/NDK 与 Hermes/New Architecture 配置为准核对。
|
||||
- MLRN 的 root Gradle extras 固定 `org.maplibre.reactnative.nativeVersion=13.4.1-step060f` 与 `nativeVariant=opengl`;这是 STEP-060F WeatherColor opacity 修复的唯一临时版本,避免与先前 Maven Local artifact 缓存混淆。
|
||||
- MLRN 的 root Gradle extras 固定 `org.maplibre.reactnative.nativeVersion=13.4.1-step061` 与 `nativeVariant=opengl`。STEP-061 必须构建并 Maven Local publish 这个唯一的新坐标,禁止覆盖已经冻结的 `13.4.1-step060f`;本轮 RDC 修复仅推进坐标和文档,未构建或发布。
|
||||
|
||||
## Custom SDK 来源(手工执行)
|
||||
|
||||
@@ -17,7 +17,7 @@ Gate C 封存环境负责生成 custom AAR;Test Host 只负责 Maven Local han
|
||||
cd /mnt/sda1/MapLibreParticleLab/platform/android
|
||||
# 仅当 checkout 已变化,或下方已核对 AAR 的 SHA-256 不一致时才需要:
|
||||
RENDERER=opengl BUILDTYPE=Release make apackage
|
||||
# 当前 Gate C 已生成并核对的 AAR 可直接发布,不需要为 STEP-060C 重建:
|
||||
# STEP-061 必须先按当前 checkout 重建,且只能发布新的 `13.4.1-step061` 坐标:
|
||||
./gradlew :MapLibreAndroid:publishOpenglreleasePublicationToMavenLocal --console=plain
|
||||
cd /mnt/sda1/MapLibreParticleLab/integration/react-native/MapLibreParticleRNTestHost
|
||||
npm ci
|
||||
@@ -37,7 +37,13 @@ Style JSON 明确创建 background、host-field `RasterArraySource`、`WeatherCo
|
||||
|
||||
页面 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。
|
||||
## STEP-061 Binary U/V Field transport
|
||||
|
||||
正式 Binary ingress 为 `metadataJson + payloadFileUri/path`。Test Host 使用 Expo FileSystem `File.write(Uint8Array)` 将 raw bytes 写入 app cache;TurboModule 只接收 metadata 和本地 `file://` URI(也接受绝对本地路径),Kotlin 对可读性与 64 MiB 上限 fail-closed 后读取原始 `byte[]`,并且只调用既有 `WeatherRuntime.submitWindField(metadataJson, byte[])`。没有 `number[]`/`Array<number>`、payload JSON、base64、hex 或网络下载层。
|
||||
|
||||
`Submit binary WIND T0/T1` 固定生产 4×3 的两帧 `WIND/GLOBAL` canonical field:north-west origin、row-major、`interleaved-uv`、`float32-le`,并以 `DataView.setFloat32(..., true)` 显式写入 U=eastward、V=northward。初始状态与每轮开始均为 `PENDING`,绝不预先显示 READY。按钮先通过既有 `clearWindFields()` 清空 Source-owned Timeline,再依序提交一个声明字节数与文件实际字节数不一致的 invalid probe、T0、T1,并严格断言 Core 结果依次为 `REJECTED_ADAPTER`、`ACCEPTED_PENDING_INITIAL_WINDOW`、`INITIAL_WINDOW_READY`,最后 Native `WeatherRuntime.state` 必须为 `READY`;任意不符均显示 `ERROR`。
|
||||
|
||||
界面严格区分 `Core evidence: INITIAL_WINDOW_READY + runtime READY` 与 producer 已提交的 T0/T1 generation/time identity;后者不是 Native/Core runtime window。MIX 明确为 `Core-owned / not exposed`。Android 最终 T0/T1 真值由既有 logcat `HOST_FIELD_SNAPSHOT_READY` 与 `HOST_FIELD_INITIAL_WINDOW_READY` 验证,本 Step 不新增 Core getter。
|
||||
|
||||
启动入口是 `App.tsx`。`app.json` 已固定 `orientation: landscape`;下一次 Android prebuild/build 应生成 `MainActivity android:screenOrientation="landscape"`。Android build、安装及设备操作必须由用户手工执行,详见 `docs/60_测试/STEP-060_RNTestHost_Android人工编译与验收_v1.md`。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
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-step060f"
|
||||
implementation "org.maplibre.gl:android-sdk-opengl:13.4.1-step061"
|
||||
compileOnly project(":maplibre_maplibre-react-native")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.maplibre.particle.rntesthost.weather
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
@@ -15,6 +16,10 @@ import org.maplibre.android.maps.MapLibreMap
|
||||
import org.maplibre.android.maps.Style
|
||||
import org.maplibre.android.maps.WeatherRuntime
|
||||
import org.maplibre.reactnative.components.mapview.MLRNMapView
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A low-frequency, per-view bridge. It resolves the Fabric view for each call and never stores a
|
||||
@@ -58,6 +63,22 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
|
||||
}
|
||||
|
||||
@ReactMethod override fun getState(reactTag: Double, promise: Promise) = withRuntime(reactTag, promise) { runtime -> promise.resolve(snapshot(runtime)) }
|
||||
@ReactMethod override fun clearWindFields(reactTag: Double, promise: Promise) = command(reactTag, promise) { it.clearWindFields() }
|
||||
|
||||
/** Binary ingress is a local file reference, never a JS number array or encoded string. */
|
||||
@ReactMethod
|
||||
override fun submitWindField(reactTag: Double, metadataJson: String, payloadFileUri: String, promise: Promise) {
|
||||
if (metadataJson.isEmpty() || metadataJson.toByteArray(Charsets.UTF_8).size > MAX_METADATA_UTF8_BYTES) return reject(promise, "INVALID_ARGUMENT", "metadataJson UTF-8 payload must be non-empty and at most $MAX_METADATA_UTF8_BYTES bytes")
|
||||
val payload = try { readLocalPayload(payloadFileUri) }
|
||||
catch (error: IllegalArgumentException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", error.message ?: "payload file is unreadable") }
|
||||
catch (error: IOException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", "payload file could not be read: ${error.message ?: "I/O error"}") }
|
||||
catch (error: RuntimeException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", "payload file could not be read") }
|
||||
if (payload.isEmpty()) return reject(promise, "PAYLOAD_FILE_UNREADABLE", "payload file is empty")
|
||||
withRuntime(reactTag, promise) { runtime ->
|
||||
try { promise.resolve(runtime.submitWindField(metadataJson, payload).name) }
|
||||
catch (error: IllegalStateException) { reject(promise, "NATIVE_REJECTED", error.message ?: "WeatherRuntime rejected the field") }
|
||||
}
|
||||
}
|
||||
@ReactMethod override fun setWeatherEnabled(reactTag: Double, enabled: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherEnabled(enabled) }
|
||||
@ReactMethod override fun setWeatherColorVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherColorVisible(visible) }
|
||||
@ReactMethod override fun setParticleVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setParticleVisible(visible) }
|
||||
@@ -123,6 +144,27 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
|
||||
val value = values.firstOrNull { it.name == raw } ?: return reject(promise, "INVALID_ARGUMENT", "unsupported enum value: $raw")
|
||||
command(reactTag, promise) { fn(it, value) }
|
||||
}
|
||||
private fun readLocalPayload(reference: String): ByteArray {
|
||||
if (reference.isBlank()) throw IllegalArgumentException("payload file reference is empty")
|
||||
val uri = Uri.parse(reference)
|
||||
if (uri.scheme != null && uri.scheme != "file") throw IllegalArgumentException("payload file reference must use file:// or an absolute local path")
|
||||
val file = if (uri.scheme == "file") File(uri.path ?: throw IllegalArgumentException("payload file URI has no path")) else File(reference).also {
|
||||
if (!it.isAbsolute) throw IllegalArgumentException("payload path without a URI scheme must be absolute")
|
||||
}
|
||||
if (!file.isFile || !file.canRead()) throw IllegalArgumentException("payload file is not a readable local file")
|
||||
if (file.length() <= 0L || file.length() > MAX_PAYLOAD_BYTES) throw IllegalArgumentException("payload file size must be in 1..$MAX_PAYLOAD_BYTES bytes")
|
||||
return file.inputStream().use(::readBounded)
|
||||
}
|
||||
private fun readBounded(input: InputStream): ByteArray {
|
||||
val output = ByteArrayOutputStream(); val buffer = ByteArray(DEFAULT_READ_BUFFER_BYTES)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
if (output.size().toLong() + count > MAX_PAYLOAD_BYTES) throw IllegalArgumentException("payload file exceeds $MAX_PAYLOAD_BYTES bytes")
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
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)
|
||||
@@ -176,5 +218,8 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
|
||||
}
|
||||
companion object {
|
||||
const val NAME = "WeatherRuntimeBridge"
|
||||
private const val MAX_METADATA_UTF8_BYTES = 1_048_576
|
||||
private const val MAX_PAYLOAD_BYTES = 64L * 1024L * 1024L
|
||||
private const val DEFAULT_READ_BUFFER_BYTES = 16 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ type RuntimeState = "DISABLED" | "WAITING_FOR_FIELDS" | "READY" | "ERROR";
|
||||
type PlaybackState = "PLAY" | "PAUSE";
|
||||
type TargetFps = "AUTO" | "FPS_30" | "FPS_60";
|
||||
type CommandResult = "ACCEPTED" | "REJECTED";
|
||||
type FieldSubmitResult = "REJECTED_SOURCE_MODE" | "REJECTED_ADAPTER" | "REJECTED_INCOMPATIBLE" | "REJECTED_NON_MONOTONIC" | "REJECTED_CAPACITY" | "ACCEPTED_PENDING_INITIAL_WINDOW" | "INITIAL_WINDOW_READY" | "ACCEPTED_NEXT_SNAPSHOT" | "DUPLICATE" | "UNKNOWN";
|
||||
type ColorStop = { speedMps: number; red: number; green: number; blue: number; alpha: number };
|
||||
type RuntimeSnapshot = { state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean };
|
||||
|
||||
@@ -12,6 +13,8 @@ export interface Spec extends TurboModule {
|
||||
attach(reactTag: number): Promise<RuntimeSnapshot>;
|
||||
detach(reactTag: number): Promise<void>;
|
||||
getState(reactTag: number): Promise<RuntimeSnapshot>;
|
||||
clearWindFields(reactTag: number): Promise<CommandResult>;
|
||||
submitWindField(reactTag: number, metadataJson: string, payloadFileUri: string): Promise<FieldSubmitResult>;
|
||||
setWeatherEnabled(reactTag: number, enabled: boolean): Promise<CommandResult>;
|
||||
setWeatherColorVisible(reactTag: number, visible: boolean): Promise<CommandResult>;
|
||||
setParticleVisible(reactTag: number, visible: boolean): Promise<CommandResult>;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 FieldSubmitResult = "REJECTED_SOURCE_MODE" | "REJECTED_ADAPTER" | "REJECTED_INCOMPATIBLE" | "REJECTED_NON_MONOTONIC" | "REJECTED_CAPACITY" | "ACCEPTED_PENDING_INITIAL_WINDOW" | "INITIAL_WINDOW_READY" | "ACCEPTED_NEXT_SNAPSHOT" | "DUPLICATE" | "UNKNOWN";
|
||||
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 }>;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
|
||||
"@maplibre/maplibre-react-native": "11.3.6",
|
||||
"expo": "56.0.4",
|
||||
"expo-file-system": "56.0.11",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.2"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@maplibre-particle/weather-runtime-bridge": "file:./modules/weather-runtime-bridge",
|
||||
"@maplibre/maplibre-react-native": "11.3.6",
|
||||
"expo": "56.0.4",
|
||||
"expo-file-system": "56.0.11",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.2"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ const marker = "// MapLibreParticleRNTestHost custom OpenGL SDK";
|
||||
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.mavenLocal()\n p.repositories.withType(org.gradle.api.artifacts.repositories.MavenArtifactRepository).configureEach { repo ->\n def repoUrl = repo.url.toString()\n if (repoUrl.contains(\"repo.maven.apache.org/maven2\") || repoUrl.contains(\"repo1.maven.org/maven2\")) {\n repo.content { excludeModule(\"org.maplibre.gl\", \"android-sdk-opengl\") }\n }\n }\n }\n}\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVersion\", \"13.4.1-step060f\")\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVariant\", \"opengl\")\n`;
|
||||
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.mavenLocal()\n p.repositories.withType(org.gradle.api.artifacts.repositories.MavenArtifactRepository).configureEach { repo ->\n def repoUrl = repo.url.toString()\n if (repoUrl.contains(\"repo.maven.apache.org/maven2\") || repoUrl.contains(\"repo1.maven.org/maven2\")) {\n repo.content { excludeModule(\"org.maplibre.gl\", \"android-sdk-opengl\") }\n }\n }\n }\n}\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVersion\", \"13.4.1-step061\")\nrootProject.ext.set(\"org.maplibre.reactnative.nativeVariant\", \"opengl\")\n`;
|
||||
return project;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
|
||||
export type CanonicalWindFrame = Readonly<{
|
||||
label: "T0" | "T1" | "INVALID";
|
||||
metadataJson: string;
|
||||
payloadFileUri: string;
|
||||
generation: number;
|
||||
validTime: number;
|
||||
}>;
|
||||
|
||||
const width = 4;
|
||||
const height = 3;
|
||||
const bytesPerCell = 2 * Float32Array.BYTES_PER_ELEMENT;
|
||||
const validTimeT0 = 1767225600000;
|
||||
const validTimeT1 = validTimeT0 + 3_600_000;
|
||||
|
||||
function metadata(label: "T0" | "T1" | "INVALID", generation: number, validTime: number, byteLength: number) {
|
||||
return JSON.stringify({
|
||||
schema: "maplibre-particle-field/v1", fieldId: `rn-step061-wind-${label.toLowerCase()}`,
|
||||
fieldType: "WIND", displayDomain: "GLOBAL", generation, validTime, dataVersion: 1,
|
||||
bounds: { west: -180, south: -85, east: 180, north: 85 }, grid: { width, height },
|
||||
vector: { layout: "interleaved-uv", encoding: "float32-le", noData: -9999 }, payload: { byteLength },
|
||||
});
|
||||
}
|
||||
|
||||
function interleavedUvBytes(frameOffset: number) {
|
||||
const bytes = new ArrayBuffer(width * height * bytesPerCell);
|
||||
const view = new DataView(bytes);
|
||||
for (let cell = 0; cell < width * height; cell += 1) {
|
||||
const offset = cell * bytesPerCell;
|
||||
// Canonical order is U=eastward followed by V=northward. `true` freezes LE storage.
|
||||
view.setFloat32(offset, 4 + frameOffset + cell * 0.1, true);
|
||||
view.setFloat32(offset + Float32Array.BYTES_PER_ELEMENT, -1 + frameOffset * 0.25, true);
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function writeFrame(label: "T0" | "T1" | "INVALID", generation: number, validTime: number, bytes: Uint8Array, declaredByteLength = bytes.byteLength): CanonicalWindFrame {
|
||||
const file = new File(Paths.cache, `maplibre-step061-${label.toLowerCase()}.uvf`);
|
||||
file.write(bytes);
|
||||
return { label, metadataJson: metadata(label, generation, validTime, declaredByteLength), payloadFileUri: file.uri, generation, validTime };
|
||||
}
|
||||
|
||||
/** Produces independent canonical frames; the Core timeline alone selects T0/T1/MIX. */
|
||||
export function writeCanonicalWindFixture(): readonly [CanonicalWindFrame, CanonicalWindFrame, CanonicalWindFrame] {
|
||||
const t0Bytes = interleavedUvBytes(0);
|
||||
const t1Bytes = interleavedUvBytes(1);
|
||||
// Deliberately declare the normal byte length while writing one byte fewer: Core must reject it.
|
||||
const invalidBytes = t0Bytes.slice(0, t0Bytes.byteLength - 1);
|
||||
return [
|
||||
writeFrame("T0", 1, validTimeT0, t0Bytes),
|
||||
writeFrame("T1", 2, validTimeT1, t1Bytes),
|
||||
writeFrame("INVALID", 3, validTimeT1 + 3_600_000, invalidBytes, t0Bytes.byteLength),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user