release: prepare MapLibre Particle Android v1 package
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-09 10:47:18 +08:00
parent 3664db0770
commit 17774373d0
28 changed files with 691 additions and 15 deletions

View File

@@ -0,0 +1,27 @@
# MapLibre 粒子 Android 发布与 React Native 包设计 v1
## 版本与坐标
真实基础为 MapLibre Native Android `13.4.1`,共同 core commit 为 `18545ca10d296704b2cba675b182ff2c8da24588`,本发行只准备 Android OpenGL。正式规则为 `<上游版本>-particle.<修订号>`;首发为 `13.4.1-particle.1`。正式 npm 包为 `@navsea/maplibre-particle-rn@13.4.1-particle.1`,正式 Maven 坐标为 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`。
`step061` 是已经完成验证的开发阶段制品编号,不是正式发行版本。release manifest 是 `release/maplibre-particle/release-manifest.json`;registry URL 暂不决定,manifest 中 npm/Maven server 字段均为 null。
## Consumer 依赖关系
```text
Consumer
↓ 直接安装
@navsea/maplibre-particle-rn
↓ 唯一 MapLibre RN native module
@maplibre/maplibre-react-native 11.3.6
↓ Gradle dependency substitution
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1
```
正式包 re-export 已有 MLRN `Map`、`Camera` 等公共 API,并包含 WeatherRuntime TurboModule。它不是 MLRN 的完整 fork;`@maplibre/maplibre-react-native` 是正式包的内部依赖,而 Consumer 不应再直接安装第二份 MLRN。package plugin 把 MLRN 唯一的 `org.maplibre.gl:android-sdk-opengl` 请求替换为正式 NavSea AAR,避免官方 AAR 与 Particle AAR 同时进入 APK。
WeatherColorLayer 与 RasterParticleLayer 是 Core-only style layer,不是 React 组件;包公开 `createParticleWeatherStyle()` 和稳定 IDs 以声明样式。field 仍只经既有 `metadataJson + local file:// payload → WeatherRuntime → RasterArraySource::submitHostField()` 进入 Native/Core;不创建第二套 ingress、Timeline 或 Runtime Window。
## 发布与升级边界
Consumer 不编译 MapLibre Native 源码,也不使用 workspace、relative source path、CMake path 或软链接。registry 未决定时,`.tgz` 与 Maven Local/临时 staging 只用于预发布验证,不是最终部署决定。未来升级到新的上游版本时,按 `13.5.0-particle.1` 等新发行线重新完成 Native、tarball Consumer 和 APK 验证;通过后才单独创建 release tag。

View File

@@ -0,0 +1,27 @@
# STEP-063 MapLibre 粒子 Android v1 发布工程化 v1
状态:`IMPLEMENTED / WAITING_MANUAL_RELEASE_VALIDATION`
## 目标与基线
- 实际 MapLibre Native Android 基础版本:`13.4.1`;core:`18545ca10d296704b2cba675b182ff2c8da24588`;renderer:OpenGL。
- 正式版本:`13.4.1-particle.1`;npm:`@navsea/maplibre-particle-rn@13.4.1-particle.1`;Maven:`cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`。
- STEP-061 的 `13.4.1-step061` 是开发期已验收坐标,不作为正式 release/package/build config。
## 已完成
- [x] 建立机器可读 release manifest,registry 均明确为 `null`。
- [x] Android publication 配置改为正式 group/artifact/version,保留 OpenGL publication postfix。
- [x] 建立正式 RN facade package,含 JS/TS source、类型、Android bridge、autolinking、Expo plugin、README、CHANGELOG、LICENSE。
- [x] package plugin 将 MLRN 的单一 OpenGL SDK dependency substitution 到正式 NavSea AAR,避免双 Native AAR。
- [x] 建立只声明正式 npm 包的 Consumer Test Host;不引用源码 package、Native tree 或复制 bridge。
- [x] `npm pack`、tarball 内容审计、package JSON、plugin syntax、package TypeScript check、`git diff --check` 静态验证。
- [x] 设计、Step、完成报告和开发记录。
## 禁止项与未执行项
未改 Particle/Core/JNI/WeatherRuntime 业务功能,未接入 NavSea,未执行 Android Gradle、AAR build/publish、Maven Local publish、APK、ADB 或 Jest。未创建 release tag,未决定 npm/Maven server。
## 下一步
用户手工构建并在 Maven Local 或临时 staging 准备 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;以 npm tarball 安装 Consumer Host,手工编译/安装 APK 并验证 GSI、WeatherColor、Particle 与 Runtime READY。全部通过后才可进入 release freeze/tag 任务。

View File

@@ -0,0 +1,42 @@
# STEP-063 MapLibre 粒子 Android v1 发布工程化 — Codex完成报告 v1
结论:`IMPLEMENTED / WAITING_MANUAL_RELEASE_VALIDATION`。
## 真实基线与发布契约
实际确认 upstream Android 基础为 `13.4.1`,共同 core 为 `18545ca10d296704b2cba675b182ff2c8da24588`,当前发行 renderer 为 OpenGL。正式版本采用 `13.4.1-particle.1`;release manifest 位于 `release/maplibre-particle/release-manifest.json`。正式 npm 包路径为 `packages/maplibre-particle-rn/`,名称为 `@navsea/maplibre-particle-rn`;正式 Maven 坐标为 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`。
原开发 AAR `org.maplibre.gl:android-sdk-opengl:13.4.1-step061` 只保留在历史 Step/验收说明中。正式 Android publication、RN package Android bridge 与 package plugin 均指向正式 NavSea coordinate;未继续把 step061 用作当前发行依赖。
## RN package 与 Consumer
正式 package 保留 Native WeatherRuntime bridge,re-export 当前 MLRN 公共 Map API。当前仓库不是完整 MLRN fork,故 package 将 `@maplibre/maplibre-react-native@11.3.6` 作为内部依赖;Consumer 只直接安装 NavSea package,不应额外直接安装官方 MLRN。package plugin 利用 Gradle dependency substitution,将 MLRN 的唯一 OpenGL SDK 请求替换为正式 NavSea AAR,避免两份 Native AAR。
Consumer Host 位于 `integration/react-native/MapLibreParticlePackageConsumerTestHost/`,只声明 `@navsea/maplibre-particle-rn@13.4.1-particle.1`,从其导入 Map、Camera、WeatherRuntime 和 style helper;没有 `file:`、workspace/source-path package dependency,也不复制 bridge 或引用 Native source tree。
## tarball 与静态验证
`npm pack` 成功生成 `navsea-maplibre-particle-rn-13.4.1-particle.1.tgz`(8,951 bytes,SHA-1 `2127d7b0e7ce71d0d9e429c92806cd93799c919e`)至 `/tmp/step063-package.*`。tarball 含 package JSON、TS source/types、Android bridge/Gradle、autolinking config、Expo plugin、README、CHANGELOG、LICENSE;不含 node_modules、.git、build cache、APK、Test Host、WeatherV2 assets、logs、无关 docs 或凭证。
package JSON 和两个 plugin Node syntax 检查 PASS;正式 package TypeScript check PASS(经 build-output-throttle);release manifest/package/Consumer version contract、无 `file:` consumer dependency、正式 build config 无 step061 与 `git diff --check` 均 PASS。Consumer 依赖的是尚未上传 registry 的正式版本,故本轮不执行其 `npm install`;这由后续 tarball 安装人工验证覆盖。没有运行 Jest。
## 未执行的人工验证与命令
Codex 未执行 Android Gradle、AAR build/publish、Maven Local publish、APK、ADB 或实体机测试。用户后续在具备 Android 环境时执行:
```bash
cd platform/android
# 按当前正式 publication 配置手工构建并发布到 Maven Local 或临时 staging。
./gradlew :MapLibreAndroid:publishOpenglreleasePublicationToMavenLocal --console=plain
cd packages/maplibre-particle-rn
npm pack --pack-destination /tmp/maplibre-particle-release
cd integration/react-native/MapLibreParticlePackageConsumerTestHost
npm install /tmp/maplibre-particle-release/navsea-maplibre-particle-rn-13.4.1-particle.1.tgz --save-exact
npm install --ignore-scripts
npx expo prebuild --platform android --clean
npx expo run:android
```
随后验证 GSI、WeatherColor、Particle、canonical fixture submit 与 Runtime READY。通过前不得创建正式 release tag,也不得写 `RELEASED` 或 `CLOSED / PASS`。

View File

@@ -0,0 +1,26 @@
import { useCallback, useState } from "react";
import { Button, SafeAreaView, StyleSheet, Text, View, findNodeHandle } from "react-native";
import { File, Paths } from "expo-file-system";
import { Camera, Map, WeatherRuntime, createParticleWeatherStyle } from "@navsea/maplibre-particle-rn";
const metadata = JSON.stringify({ schema: "maplibre-particle-field/v1", fieldId: "consumer-smoke", fieldType: "WIND", displayDomain: "GLOBAL", generation: 1, validTime: 1786060800000, dataVersion: 1, bounds: { west: -180, south: -85, east: 180, north: 85 }, grid: { width: 2, height: 2 }, vector: { layout: "interleaved-uv", encoding: "float32-le", noData: -9999 }, payload: { byteLength: 32 } });
function writeFixture() {
const bytes = new Uint8Array(32); const view = new DataView(bytes.buffer);
for (let offset = 0; offset < bytes.byteLength; offset += 8) { view.setFloat32(offset, 5, true); view.setFloat32(offset + 4, 1, true); }
const file = new File(Paths.cache, "navsea-particle-consumer-smoke.f32"); file.write(bytes); return file.uri;
}
export default function App() {
const [tag, setTag] = useState<number | null>(null); const [state, setState] = useState("STYLE_LOADING");
const onStyle = useCallback(async (event: unknown) => {
const value = findNodeHandle((event as { target?: unknown }).target as never); if (value == null) return;
try { await WeatherRuntime.attach(value); setTag(value); setState("WAITING_FOR_FIELDS"); } catch (error) { setState(String(error)); }
}, []);
const submit = useCallback(async () => {
if (tag == null) return;
try { await WeatherRuntime.clearWindFields(tag); const result = await WeatherRuntime.submitWindField(tag, metadata, writeFixture()); const runtime = await WeatherRuntime.getState(tag); setState(`${result} / ${runtime.state}`); } catch (error) { setState(String(error)); }
}, [tag]);
return <SafeAreaView style={styles.root}><View style={styles.map}><Map mapStyle={createParticleWeatherStyle()} onDidFinishLoadingStyle={onStyle}><Camera initialViewState={{ center: [135, 32], zoom: 3.9 }} /></Map></View><View style={styles.controls}><Text>Package smoke: {state}</Text><Button title="Submit canonical fixture" onPress={() => void submit()} /></View></SafeAreaView>;
}
const styles = StyleSheet.create({ root: { flex: 1 }, map: { flex: 1 }, controls: { padding: 12, backgroundColor: "white" } });

View File

@@ -0,0 +1,14 @@
# MapLibreParticlePackageConsumerTestHost
这是发布包 Consumer smoke host,不引用 `packages/`、Test Host bridge 源码、MapLibre Native source tree 或 workspace dependency。其 `package.json` 只直接依赖 `@navsea/maplibre-particle-rn@13.4.1-particle.1`;Map、Camera、WeatherRuntime 和 style helper 全部从该发布包导入。
最终 registry 未决定时,先在包目录生成 tarball,再在本目录手工执行:
```bash
npm install /absolute/path/navsea-maplibre-particle-rn-13.4.1-particle.1.tgz --save-exact
npm install --ignore-scripts
npx expo prebuild --platform android --clean
npx expo run:android
```
该 `.tgz` 是发布物而非源码路径。用户还需将 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1` 手工发布至 Maven Local 或临时 staging;package plugin 会将 MLRN 的唯一 OpenGL SDK 请求替换为该正式 artifact。Codex 不执行上述 Android 命令。

View File

@@ -0,0 +1,11 @@
{
"expo": {
"name": "MapLibre Particle Package Consumer",
"slug": "maplibre-particle-package-consumer",
"version": "0.1.0",
"orientation": "landscape",
"newArchEnabled": true,
"plugins": ["@navsea/maplibre-particle-rn/app.plugin"],
"android": { "package": "cc.navsea.maplibre.particle.consumer", "minSdkVersion": 24 }
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "maplibre-particle-package-consumer-test-host",
"private": true,
"version": "0.1.0",
"packageManager": "npm@10.9.4",
"scripts": {
"typecheck": "tsc --noEmit",
"start": "expo start --port 8092",
"android": "expo run:android"
},
"dependencies": {
"@navsea/maplibre-particle-rn": "13.4.1-particle.1",
"expo": "56.0.4",
"expo-file-system": "56.0.11",
"react": "19.2.3",
"react-native": "0.85.2"
},
"devDependencies": {
"@types/react": "19.2.5",
"typescript": "5.9.3"
}
}

View File

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

View File

@@ -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-step061"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1"
compileOnly project(":maplibre_maplibre-react-native")
}

View File

@@ -1,16 +1,12 @@
const { withProjectBuildGradle } = require("@expo/config-plugins");
const marker = "// MapLibreParticleRNTestHost custom OpenGL SDK";
const marker = "// MapLibreParticleRNTestHost NavSea Particle release SDK";
/**
* Pins the one custom MapLibre Native SDK coordinate to Maven Local without deleting
* Expo/RN local repositories. Maven Central is filtered only for the custom OpenGL SDK,
* so a missing local custom AAR fails instead of silently using the official Central AAR.
*/
/** Resolves the one MLRN SDK request to the formal NavSea artifact without a second Native 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.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`;
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.mavenLocal()\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1")\n }\n }\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 @@
# 变更记录
## 13.4.1-particle.1
- 基于 MapLibre Native 13.4.1 的 Android OpenGL Particle 发行线。
- 包含 WeatherColor、RasterParticle、HostField binary file ingress、Timeline / Playback 与 React Native WeatherRuntime。
- Native/RN binary ingress 已在 STEP-061 验证;GSI + 真实 WeatherV2 + Particle Android 综合验证已在 STEP-062 完成。

View File

@@ -0,0 +1,30 @@
BSD 2-Clause License
Copyright (c) 2021 MapLibre contributors
Copyright (c) 2018-2021 MapTiler.com
Copyright (c) 2014-2020 Mapbox
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,9 @@
# @navsea/maplibre-particle-rn
`13.4.1-particle.1` 是 NavSea 维护的 MapLibre Particle React Native 入口。Consumer 只直接安装本包;它以唯一 `@maplibre/maplibre-react-native` native module 提供 `Map`、`Camera` 等 MapLibre RN API,并以 `WeatherRuntime` 提供既有 HostField/Timeline 低频控制。
Android 使用 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`。Expo Consumer 应在 app config 加入 `@navsea/maplibre-particle-rn/app.plugin`;plugin 将 MLRN 对 OpenGL SDK 的请求替换为该唯一 NavSea artifact,避免官方与 Particle AAR 同时进入 APK。最终 registry 未决定时,可先安装本包的 `.tgz`,并由用户将正式 AAR 放入 Maven Local 或临时 staging。
WeatherColor 和 RasterParticle 是 Core-only style layer,不是 React 组件;请用 `createParticleWeatherStyle()` 或等价 style JSON 声明。binary submit 始终为 `metadataJson + local file:// payload`,通过 `WeatherRuntime.submitWindField()` 进入既有 Native/Core 链路;本包不创建第二套 Timeline 或 field ingress。
本包不包含 Test Host、WeatherV2 fixture、AAR、APK、cache 或凭证。发布前必须先通过正式 AAR 手工构建、tarball Consumer APK 和实体机 smoke;本任务不创建 release tag。

View File

@@ -0,0 +1,25 @@
plugins {
id "com.android.library"
id "org.jetbrains.kotlin.android"
}
apply plugin: "com.facebook.react"
android {
namespace "org.navsea.maplibre.particle.weather"
compileSdk 36
defaultConfig { minSdk 24 }
}
dependencies {
implementation "com.facebook.react:react-android"
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1"
compileOnly project(":maplibre_maplibre-react-native")
}
react {
jsRootDir = file("../src/")
libraryName = "NavSeaMapLibreParticle"
codegenJavaPackageName = "org.navsea.maplibre.particle.weather"
}

View File

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

View File

@@ -0,0 +1,225 @@
package org.navsea.maplibre.particle.weather
import android.net.Uri
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.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
* reactTag, current map, timeline, playback state, or a second Map registry.
*/
class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : NativeWeatherRuntimeBridgeSpec(context), TurboModule {
@ReactMethod
override 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")
val sourceExists = map.hasStyleSource(WeatherIds.SOURCE)
val weatherColorLayerExists = map.hasStyleLayer(WeatherIds.COLOR_LAYER)
val particleLayerExists = map.hasStyleLayer(WeatherIds.PARTICLE_LAYER)
if (!sourceExists || !weatherColorLayerExists || !particleLayerExists) {
return@withMapView reject(
promise,
"WEATHER_OBJECTS_NOT_READY",
weatherObjectsNotReadyDiagnostic(
reactTag.toInt(),
style,
sourceExists,
weatherColorLayerExists,
particleLayerExists,
),
)
}
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
override 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 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) }
@ReactMethod override fun setTargetFps(reactTag: Double, target: String, promise: Promise) = enumCommand(reactTag, promise, target, WeatherRuntime.TargetFps.values()) { runtime, value -> runtime.setTargetFps(value) }
@ReactMethod override fun setPlaybackState(reactTag: Double, playback: String, promise: Promise) = enumCommand(reactTag, promise, playback, WeatherRuntime.PlaybackState.values()) { runtime, value -> runtime.setPlaybackState(value) }
@ReactMethod override fun setPlaybackSpeed(reactTag: Double, speed: String, promise: Promise) = enumCommand(reactTag, promise, speed, WeatherRuntime.PlaybackSpeed.values()) { runtime, value -> runtime.setPlaybackSpeed(value) }
@ReactMethod
override 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 override 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 override fun setParticleLineWidth(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setParticleLineWidth(valid) }
@ReactMethod override fun setParticleLength(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setParticleLength(valid) }
@ReactMethod override fun setParticleOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setParticleOpacity(valid) }
@ReactMethod override fun setTrailOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setTrailOpacity(valid) }
@ReactMethod override fun setTrailFade(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setTrailFade(valid) }
@ReactMethod override fun setWeatherColorOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setWeatherColorOpacity(valid) }
@ReactMethod override 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 override 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
override 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 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)
}
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)
}
/**
* Core-only layer types do not have a Java Layer peer. Presence is checked directly in Core
* before this diagnostic is built; the style URI distinguishes a JSON style (empty URI) from
* a URI-backed style without materialising all sources or layers.
*/
private fun weatherObjectsNotReadyDiagnostic(
reactTag: Int,
style: Style,
sourceExists: Boolean,
weatherColorLayerExists: Boolean,
particleLayerExists: Boolean,
): String {
return buildString {
append("WEATHER_OBJECTS_NOT_READY reactTag=").append(reactTag)
append(" mapView=present mapLibreMap=present style=present")
append(" styleUri=").append(style.getUri().ifEmpty { "<empty>" })
append(" source[").append(WeatherIds.SOURCE).append("]=")
.append(presence(sourceExists))
append(" layer[").append(WeatherIds.COLOR_LAYER).append("]=")
.append(presence(weatherColorLayerExists))
append(" layer[").append(WeatherIds.PARTICLE_LAYER).append("]=")
.append(presence(particleLayerExists))
}
}
private fun presence(exists: Boolean) = if (exists) "present" else "missing"
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"
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
}
}

View File

@@ -0,0 +1,20 @@
package org.navsea.maplibre.particle.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,12 @@
const { withProjectBuildGradle } = require("@expo/config-plugins");
const marker = "// @navsea/maplibre-particle-rn Android OpenGL release";
/** Resolves MLRN's single Native SDK request to the NavSea release artifact, never a second AAR. */
module.exports = function withNavSeaMapLibreParticle(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.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1")\n }\n }\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,36 @@
{
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.1",
"description": "NavSea MapLibre Particle React Native facade for the Android OpenGL particle release.",
"license": "BSD-2-Clause",
"main": "src/index.ts",
"react-native": "src/index.ts",
"types": "src/index.ts",
"files": [
"src",
"android",
"app.plugin.js",
"react-native.config.js",
"README.md",
"CHANGELOG.md",
"LICENSE.md"
],
"keywords": ["navsea", "maplibre", "particle", "react-native", "weather"],
"repository": { "type": "git", "url": "https://git.eyaku.jp/tei/MapLibreParticleLab" },
"dependencies": {
"@maplibre/maplibre-react-native": "11.3.6"
},
"peerDependencies": {
"react": ">=19.1.0",
"react-native": ">=0.80.0"
},
"peerDependenciesMeta": {
"expo": { "optional": true }
},
"codegenConfig": {
"name": "WeatherRuntimeBridgeSpec",
"type": "modules",
"jsSrcsDir": "src",
"android": { "javaPackageName": "org.navsea.maplibre.particle.weather" }
}
}

View File

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

View File

@@ -0,0 +1,37 @@
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";
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 };
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>;
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: 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,39 @@
export * from "@maplibre/maplibre-react-native";
export { default as WeatherRuntime } from "./NativeWeatherRuntimeBridge";
export type {
ColorStop,
CommandResult,
FieldSubmitResult,
PlaybackState,
RuntimeSnapshot,
RuntimeState,
TargetFps,
} from "./types";
export const particleWeatherIds = {
source: "rn-weather-fields",
weatherColorLayer: "rn-weather-color",
rasterParticleLayer: "rn-weather-particles",
} as const;
/**
* Core-only WeatherColor/RasterParticle layers are declared in a MapLibre style, rather than as
* React components. This helper reserves their stable IDs without creating a second field ingress.
*/
export function createParticleWeatherStyle(overrides: Record<string, unknown> = {}) {
return JSON.stringify({
version: 8,
sources: {
"gsi-standard": { type: "raster", tiles: ["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"], tileSize: 256, maxzoom: 18, attribution: "国土地理院 / GSI" },
[particleWeatherIds.source]: { type: "raster-array", "host-field-submission": true },
},
layers: [
{ id: "particle-background", type: "background", paint: { "background-color": "#10243a" } },
{ id: "gsi-standard", type: "raster", source: "gsi-standard", paint: { "raster-opacity": 1 } },
{ id: particleWeatherIds.weatherColorLayer, type: "weather-color", source: particleWeatherIds.source, paint: { "weather-color-opacity": 0.62 } },
{ id: particleWeatherIds.rasterParticleLayer, type: "raster-particle", source: particleWeatherIds.source,
paint: { "raster-particle-active": true, "raster-particle-particle-count": 4096 } },
],
...overrides,
});
}

View File

@@ -0,0 +1,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 }>;

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@maplibre/maplibre-react-native": ["../../integration/react-native/MapLibreParticleRNTestHost/node_modules/@maplibre/maplibre-react-native/lib/typescript/module/index.d.ts"],
"react-native": ["../../integration/react-native/MapLibreParticleRNTestHost/node_modules/react-native/types/index.d.ts"]
},
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"target": "esnext",
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -1 +1 @@
13.4.1-step061
13.4.1-particle.1

View File

@@ -1,9 +1,9 @@
extra["mapLibreArtifactGroupId"] = "org.maplibre.gl"
extra["mapLibreArtifactId"] = "android-sdk"
extra["mapLibreArtifactTitle"] = "MapLibre Android"
extra["mapLibreArtifactDescription"] = "MapLibre Android"
extra["mapLibreDeveloperName"] = "MapLibre"
extra["mapLibreDeveloperId"] = "maplibre"
extra["mapLibreArtifactGroupId"] = "cc.navsea.maplibre"
extra["mapLibreArtifactId"] = "android-sdk-particle"
extra["mapLibreArtifactTitle"] = "NavSea MapLibre Particle Android"
extra["mapLibreArtifactDescription"] = "NavSea MapLibre Particle Android"
extra["mapLibreDeveloperName"] = "NavSea"
extra["mapLibreDeveloperId"] = "navsea"
extra["mapLibreArtifactUrl"] = "https://github.com/maplibre/maplibre-native"
extra["mapLibreArtifactScmUrl"] = "scm:git@github.com:maplibre/maplibre-native.git"
extra["mapLibreArtifactLicenseName"] = "BSD"

View File

@@ -0,0 +1,27 @@
{
"release": "13.4.1-particle.1",
"releaseState": "prepared-waiting-manual-release-validation",
"upstreamMapLibreNative": "13.4.1",
"upstreamCoreCommit": "18545ca10d296704b2cba675b182ff2c8da24588",
"extensionLine": "particle",
"extensionRevision": 1,
"platform": { "android": { "renderer": "opengl" } },
"reactNativePackage": {
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.1",
"mapLibreReactNative": "11.3.6"
},
"nativeArtifact": {
"group": "cc.navsea.maplibre",
"artifact": "android-sdk-particle-opengl",
"version": "13.4.1-particle.1"
},
"canonicalField": {
"schema": "maplibre-particle-field/v1",
"transport": "metadataJson + local file:// payload",
"encoding": "float32-le",
"layout": "interleaved-uv"
},
"publication": { "npmRegistry": null, "mavenRepository": null },
"releaseTag": null
}

View File

@@ -587,3 +587,16 @@ WeatherColorLayer 的 `ParticleGpuValidationUBO` designated initializer 补齐 `
- Android Validation 的 ECMWF HRES 20260807 canonical T0–T3 assets 已原样放入 Host assets,四帧各 3,155,976 bytes 且逐文件 SHA-256 一致。
- `expo-asset` 以静态 `.f32` require + `downloadAsync()` 输出 cache `file://` URI;只把 metadata string 与 local file reference 交给既有 STEP-061 bridge,绝不 materialize payload 为 array/JSON/base64/hex。
- `LOAD REAL WEATHER T0-T3` clear 后严格断言 Pending、InitialWindowReady、NextSnapshot、NextSnapshot 和 Native READY;控制页提供 timestamp seek、playback/speed/FPS、独立 visibility/stops/colors 与视觉参数。静态 typecheck/Metro/asset hash 通过;RDC 追加 Android Expo export 也已确认四个 `.f32` 正常打包,功能提交 `a91e3601f37f3adfe5d8b7b6a6c3f7f04fcadb8f` 已 push;Android APK/实机待 CARBON7i 一次综合验收。
# 2026-09-09 — STEP-062 Android APK-only 最终关闭
- 用户已在 Win11 Test Host 手工完成依赖安装、typecheck、APK 编译/安装和实体机综合验收;Metro 确认 `.f32=true`,Test Host 始终使用冻结 `org.maplibre.gl:android-sdk-opengl:13.4.1-step061`。
- GSI 日本标准底图、真实 WeatherV2 T0–T3、binary file ingress、既有 Native/Core 正式入口、WeatherColor、GPU 粒子、地图拖动/缩放/旋转联动、双层独立显示、视觉参数、四时次切换及其他可操作交互均经用户确认 OK;LOAD 后整体评价为“非常完美”。
- Timeline/Playback 可操作且未见明确异常;因四帧间隔 6 小时,短时自动播放视觉变化不明显。用户接受当前表现,不追加专用 Timeline 可观测性 UI,不构成关闭阻塞。
- 最终用户验收未额外执行完整 PID logcat 硬门槛扫描,未声称 logcat 0 error。未改功能代码/Native/Core/JNI/WeatherRuntime,未 build/publish AAR,未接入 NavSea,未执行 Gradle/APK/ADB/Jest;STEP-062 正式 `CLOSED / PASS`。
# 2026-09-09 — STEP-063 Android v1 发布工程化(IMPLEMENTED / WAITING_MANUAL_RELEASE_VALIDATION)
- 实际 upstream Android 基线确认是 MapLibre Native `13.4.1` / core `18545ca` / OpenGL;建立 `13.4.1-particle.1` release manifest、`@navsea/maplibre-particle-rn` 与 `cc.navsea.maplibre:android-sdk-particle-opengl` 正式契约。
- Android publication 配置、正式 bridge/package 和 Gradle substitution 统一到 NavSea AAR,MLRN 保持唯一 native module;Consumer Host 只依赖正式 npm package,不引用源码路径、Native tree 或 bridge source。
- `npm pack` 与 package TypeScript/static audit 完成;未执行 Android Gradle/AAR/APK/ADB/Jest,未决定 registry,未创建 release tag。下一步为用户手工 AAR publication 与 tarball Consumer APK/实体机验证。