refactor: make RN particle API field-renderer only
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:
79
docs/30_设计/MapLibre粒子ReactNative使用说明_v3.md
Normal file
79
docs/30_设计/MapLibre粒子ReactNative使用说明_v3.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# MapLibre 粒子 React Native 使用说明 v3
|
||||||
|
|
||||||
|
本文对应 `@navsea/maplibre-particle-rn@13.4.1-particle.2`。v2 保留为 `.1` 历史接口说明;不要将其中的旧接口用于本版本。
|
||||||
|
|
||||||
|
## 安装与版本
|
||||||
|
|
||||||
|
Consumer 直接安装 `@navsea/maplibre-particle-rn@13.4.1-particle.2` 和 `@maplibre/maplibre-react-native@11.3.6`。RN 包 `.2` 继续使用 Android Native artifact `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;本版本没有新的 AAR。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Camera, Map } from "@maplibre/maplibre-react-native";
|
||||||
|
import { FieldRuntime } from "@navsea/maplibre-particle-rn";
|
||||||
|
```
|
||||||
|
|
||||||
|
Map 与 Camera 必须来自官方 MLRN。NavSea 包仅为 peer dependency 对接 MLRN,不转导出地图组件。
|
||||||
|
|
||||||
|
## Style 合入
|
||||||
|
|
||||||
|
宿主保有自己的底图、source 和 layer。将纯 renderer fragment 合入既有 style:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createParticleFieldStyleFragment } from "@navsea/maplibre-particle-rn";
|
||||||
|
|
||||||
|
const fragment = createParticleFieldStyleFragment();
|
||||||
|
const style = {
|
||||||
|
version: 8,
|
||||||
|
sources: { ...hostSources, ...fragment.sources },
|
||||||
|
layers: [...hostLayers, ...fragment.layers],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
fragment 只有 field source、field layer 和 particle layer;不含 GSI 或任何业务底图。使用 `particleFieldIds` 引用 renderer ID,不要自行硬编码其历史内部字符串。
|
||||||
|
|
||||||
|
## Canonical Field 提交
|
||||||
|
|
||||||
|
React/宿主选择业务数据、下载/解码、校验、缓存和本地文件写入;SDK 不理解 provider、network、WeatherV2 或数据集。宿主将既有 Native 数据契约的 `metadataJson` 与本地 `file://` payload 提交一次:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await FieldRuntime.attach(reactTag);
|
||||||
|
await FieldRuntime.clearFields(reactTag);
|
||||||
|
await FieldRuntime.submitField(reactTag, metadataJson, localFileUri);
|
||||||
|
```
|
||||||
|
|
||||||
|
契约保持 `maplibre-particle-field/v1`、`float32-le`、`interleaved-uv`。同一 canonical Field 同时供 field renderer 和 particle renderer 使用,不需要重复提交。
|
||||||
|
|
||||||
|
## FieldRuntime 方法
|
||||||
|
|
||||||
|
所有方法均以 `reactTag: number` 为首参,异步返回现有 bridge 的结果;无 Native bridge 时调用会明确失败,import 本身不会失败。
|
||||||
|
|
||||||
|
| 方法 | 其余参数 | 返回值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `attach` | 无 | `Promise<RuntimeSnapshot>` |
|
||||||
|
| `detach` | 无 | `Promise<void>` |
|
||||||
|
| `getState` | 无 | `Promise<RuntimeSnapshot>` |
|
||||||
|
| `clearFields` | 无 | `Promise<CommandResult>` |
|
||||||
|
| `submitField` | `metadataJson: string`, `payloadFileUri: string` | `Promise<FieldSubmitResult>` |
|
||||||
|
| `setEnabled` | `enabled: boolean` | `Promise<CommandResult>` |
|
||||||
|
| `setFieldVisible` | `visible: boolean` | `Promise<CommandResult>` |
|
||||||
|
| `setParticleVisible` | `visible: boolean` | `Promise<CommandResult>` |
|
||||||
|
| `setTargetFps` | `target: "AUTO" | "FPS_30" | "FPS_60"` | `Promise<CommandResult>` |
|
||||||
|
| `setTargetTime` | `timestampMs: number` | `Promise<CommandResult>` |
|
||||||
|
| `setPlaybackState` | `state: "PLAY" | "PAUSE"` | `Promise<CommandResult>` |
|
||||||
|
| `setPlaybackSpeed` | `speed: "HALF" | "NORMAL" | "DOUBLE"` | `Promise<CommandResult>` |
|
||||||
|
| `setFieldColorStops` | `stops: readonly FieldColorStop[]` | `Promise<CommandResult>` |
|
||||||
|
| `setParticleColor` / `setTrailColor` | `red, green, blue, alpha: number` | `Promise<CommandResult>` |
|
||||||
|
| `setParticleCount` | `count: number` | `Promise<CommandResult>` |
|
||||||
|
| `setParticleLineWidth` / `setParticleLength` | `value: number` | `Promise<CommandResult>` |
|
||||||
|
| `setParticleOpacity` / `setTrailOpacity` / `setTrailFade` / `setFieldOpacity` | `value: number` | `Promise<CommandResult>` |
|
||||||
|
|
||||||
|
`FieldColorStop` 为 `{ value, red, green, blue, alpha }`。`RuntimeSnapshot` 包含 `state`、`playbackState`、`targetFps`、`disposed`;`CommandResult` 为 `"ACCEPTED" | "REJECTED"`。
|
||||||
|
|
||||||
|
`setFieldColorStops()` 继续遵循既有 Native 色阶约束:至少 2 个 stop,`value` 必须为 finite 且严格递增,RGBA 分量位于 `[0,1]`。
|
||||||
|
|
||||||
|
## 独立显示
|
||||||
|
|
||||||
|
`setFieldVisible()` 与 `setParticleVisible()` 可独立切换同一 Field 的两种 renderer。`setEnabled()` 控制 runtime 总开关。SDK 不根据业务类型自动选择显示组合;显示策略由宿主决定。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
本包不提供业务数据类型、HTTP、provider、重试、缓存、路线或 ETA 策略。Native/Core、JNI、shader 和 Android AAR 均保持 `.1` 已验证实现不变。Android 实机验证应在 Consumer 安装 `.2` tarball 后单独执行。
|
||||||
34
docs/50_Step记录/STEP-064_RN纯FieldRenderer接口V2_v1.md
Normal file
34
docs/50_Step记录/STEP-064_RN纯FieldRenderer接口V2_v1.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# STEP-064 RN 纯 Field Renderer 接口 V2
|
||||||
|
|
||||||
|
状态:进行中(代码完成,待 Consumer 验证)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将 `@navsea/maplibre-particle-rn` 升级为 `13.4.1-particle.2` 纯 Field Renderer 公共 API。React/宿主负责业务、WeatherV2、provider、网络、数据选择和文件管理;SDK 只向既有 Native ingress 提交 canonical Field 并控制图场/粒子渲染。
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
- 新增根入口 `FieldRuntime`,以薄映射保留既有 Native bridge ABI,未保留旧 API wrapper。
|
||||||
|
- 公共类型使用 `FieldColorStop.value`;保留无业务含义的 Runtime、Playback、FPS 与结果类型。
|
||||||
|
- 移除 package 内 `src/weatherV2/` 与根导出;不再转导出 MLRN。
|
||||||
|
- 新增不含底图的 `particleFieldIds` 和 `createParticleFieldStyleFragment()`。
|
||||||
|
- `TurboModuleRegistry.get()` 使无 Native module 的 import 安全;实际调用时给出明确错误。
|
||||||
|
- Consumer Host 直接从官方 MLRN 导入 Map/Camera,仅从 NavSea 包导入 Field Renderer API;本地真实 fixture 仍由宿主提交 canonical metadata 与 file URI。
|
||||||
|
- release manifest 明确 RN `.2` 对应冻结 Native `.1`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- package TypeScript typecheck:通过。
|
||||||
|
- Consumer TypeScript typecheck:通过(使用临时类型解析配置,仅用于检查,不进入 Consumer 产品依赖)。
|
||||||
|
- `npm pack`:通过,`.2` tarball 仅生成在 `/tmp/step064-pack/`,未触碰仓库既有未跟踪 `.1` tarball。
|
||||||
|
- tarball 内容审计:通过;不含 `src/weatherV2/`、WeatherV2 HTTP/decoder/loader、Test Host、node_modules 或构建缓存。
|
||||||
|
- 根导出与业务语义审计、`git diff --check`:通过;旧名称仅存在于内部 Native ABI/codegen 接缝,未由根入口公开。
|
||||||
|
- Android Gradle、AAR、APK、ADB、Jest:按边界未执行。
|
||||||
|
- RN 0.85.2 Codegen parser 已核对支持 `TurboModuleRegistry.get()` 与 `getEnforcing()`;当前 import-safe `get()` 方案保留。
|
||||||
|
- 收尾复审修正:`.2` Consumer validation 明确为 pending,`.1` PASS 仅作为 previous baseline;FieldColorStop 文档示例改为满足至少 2 个 stop 的既有 Native 契约。
|
||||||
|
|
||||||
|
## 待办与边界
|
||||||
|
|
||||||
|
- 待 Consumer 安装最终 `.2` tarball 并实际运行验证;此项完成前不得标记 RELEASED 或 CLOSED/PASS。
|
||||||
|
- 未修改 Native/Core/JNI/shader/Android AAR,Native artifact 继续 `13.4.1-particle.1`。
|
||||||
|
- 未进入 NavSea。
|
||||||
31
docs/报告/STEP-064_RN纯FieldRenderer接口V2_Codex完成报告_v1.md
Normal file
31
docs/报告/STEP-064_RN纯FieldRenderer接口V2_Codex完成报告_v1.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# STEP-064 RN 纯 Field Renderer 接口 V2 完成报告
|
||||||
|
|
||||||
|
状态:IMPLEMENTED / WAITING_CONSUMER_VALIDATION
|
||||||
|
|
||||||
|
## 完成内容
|
||||||
|
|
||||||
|
`@navsea/maplibre-particle-rn` 已升为 `13.4.1-particle.2`。根入口仅公开 `FieldRuntime`、纯渲染类型、`particleFieldIds` 与 `createParticleFieldStyleFragment()`;旧 Weather/Wind 公共名称、MLRN facade 导出与兼容 wrapper 均已删除。
|
||||||
|
|
||||||
|
`FieldRuntime.submitField()` / `clearFields()` 继续薄映射既有 Native ingress。图场与粒子消费同一 canonical Field,分别由 `setFieldVisible()` 与 `setParticleVisible()` 控制。`FieldColorStop` 使用 `value`,RN 内部才转换既有 Native ABI 所需字段。
|
||||||
|
|
||||||
|
package 内 WeatherV2 HTTP、manifest、time index、WVECBIN1 decoder 与高层 loader 已移除。Consumer Test Host 改为直接使用官方 MLRN 的 Map/Camera,并自行保有 GSI、真实 fixture 与本地文件物化;最终仍只向 SDK 提交 metadata 与 file URI。
|
||||||
|
|
||||||
|
## 冻结边界
|
||||||
|
|
||||||
|
- Native/Core/JNI/shader/Android AAR 未修改。
|
||||||
|
- Native artifact 为 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;`.2` 仅为 RN/TS 公共 API revision。
|
||||||
|
- 未构建/发布 AAR,未执行 Android Gradle、APK、ADB 或 Jest,未进入 NavSea。
|
||||||
|
|
||||||
|
## 静态验证
|
||||||
|
|
||||||
|
- package TypeScript typecheck:通过。
|
||||||
|
- Consumer TypeScript typecheck:通过;因 Consumer 尚未安装 `.2` 发布包,检查采用临时 TypeScript module-resolution 配置,不改变 Consumer 产品依赖。
|
||||||
|
- `npm pack`:通过,`.2` tarball 仅生成在 `/tmp/step064-pack/`,未触碰仓库既有未跟踪 `.1` tarball。
|
||||||
|
- tarball 内容审计:通过;不含 `src/weatherV2/`、WeatherV2 HTTP/decoder/loader、Test Host、node_modules 或构建缓存。
|
||||||
|
- package 根导出、业务语义残留审计和 `git diff --check`:通过。旧 Weather/Wind 名称仅留在内部 Native ABI/codegen 接缝及其薄映射实现,不构成公开 API。
|
||||||
|
- RN 0.85.2 Codegen parser 已核对支持 `TurboModuleRegistry.get()` 与 `getEnforcing()`;当前 import-safe `get()` 方案保留。
|
||||||
|
- 收尾复审修正:`.2` Consumer validation 明确为 pending,`.1` PASS 仅作为 previous baseline;FieldColorStop 文档示例改为满足至少 2 个 stop 的既有 Native 契约。
|
||||||
|
|
||||||
|
## 未完成与后续最小验证
|
||||||
|
|
||||||
|
Consumer 尚未安装最终 `.2` tarball 并在既有 `.1` Native artifact 的 APK 中运行。后续最小步骤是:安装 `.2` tarball,启动 Metro,确认 Map/Camera 来自官方 MLRN,加载宿主 canonical T0-T3 fixture,确认 `FieldRuntime` attach、提交、独立开关与控制命令正常。该人工 Consumer 验证完成前,本 Step 保持等待状态。
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Camera, Map } from "@maplibre/maplibre-react-native";
|
||||||
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
|
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
|
||||||
import { Camera, Map, WeatherRuntime, type ColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn";
|
import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn";
|
||||||
import { materializeRealWeatherFrames, realWeatherFrames } from "./src/realWeatherDataset";
|
import { materializeRealWeatherFrames, realWeatherFrames } from "./src/realWeatherDataset";
|
||||||
import { loadHttpWeatherV2 } from "./src/httpWeatherV2Demo";
|
|
||||||
import { weatherTestStyle } from "./src/weatherStyle";
|
import { weatherTestStyle } from "./src/weatherStyle";
|
||||||
|
|
||||||
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
|
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
|
||||||
const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", weatherOpacity: "0.62" };
|
const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", fieldOpacity: "0.62" };
|
||||||
const frozenStops: ColorStop[] = [
|
const frozenStops: FieldColorStop[] = [
|
||||||
{ speedMps: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { speedMps: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 },
|
{ value: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { value: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 },
|
||||||
{ speedMps: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { speedMps: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
|
{ value: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { value: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -19,22 +19,20 @@ export default function App() {
|
|||||||
const [basemap, setBasemap] = useState("LOADING");
|
const [basemap, setBasemap] = useState("LOADING");
|
||||||
const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
|
const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
|
||||||
const [transport, setTransport] = useState("PENDING: FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
|
const [transport, setTransport] = useState("PENDING: FILE_REF / FLOAT32_LE / INTERLEAVED_UV");
|
||||||
const [httpTransport, setHttpTransport] = useState("PENDING: WeatherV2 HTTP → canonical U/V → WeatherRuntime");
|
const [fieldVisible, setFieldVisible] = useState(true);
|
||||||
const [httpLoading, setHttpLoading] = useState(false);
|
|
||||||
const [weatherVisible, setWeatherVisible] = useState(true);
|
|
||||||
const [particleVisible, setParticleVisible] = useState(true);
|
const [particleVisible, setParticleVisible] = useState(true);
|
||||||
const [visual, setVisual] = useState(initialVisual);
|
const [visual, setVisual] = useState(initialVisual);
|
||||||
|
|
||||||
const refresh = useCallback(async (tag: number) => setSnapshot(await WeatherRuntime.getState(tag)), []);
|
const refresh = useCallback(async (tag: number) => setSnapshot(await FieldRuntime.getState(tag)), []);
|
||||||
const attach = useCallback(async (tag: number) => {
|
const attach = useCallback(async (tag: number) => {
|
||||||
try { await WeatherRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("WeatherRuntime attached; style ready"); }
|
try { await FieldRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("FieldRuntime attached; style ready"); }
|
||||||
catch (error) { setResult(`style/attach error: ${String(error)}`); }
|
catch (error) { setResult(`style/attach error: ${String(error)}`); }
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
const onStyleReady = useCallback((event: unknown) => {
|
const onStyleReady = useCallback((event: unknown) => {
|
||||||
const tag = findNodeHandle((event as { target?: unknown }).target as never);
|
const tag = findNodeHandle((event as { target?: unknown }).target as never);
|
||||||
if (tag != null) void attach(tag);
|
if (tag != null) void attach(tag);
|
||||||
}, [attach]);
|
}, [attach]);
|
||||||
useEffect(() => () => { if (reactTag != null) void WeatherRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]);
|
useEffect(() => () => { if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]);
|
||||||
|
|
||||||
const command = useCallback(async (name: string, operation: (tag: number) => Promise<unknown>) => {
|
const command = useCallback(async (name: string, operation: (tag: number) => Promise<unknown>) => {
|
||||||
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
|
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
|
||||||
@@ -47,49 +45,30 @@ export default function App() {
|
|||||||
const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY", "ACCEPTED_NEXT_SNAPSHOT", "ACCEPTED_NEXT_SNAPSHOT"];
|
const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY", "ACCEPTED_NEXT_SNAPSHOT", "ACCEPTED_NEXT_SNAPSHOT"];
|
||||||
try {
|
try {
|
||||||
setTransport("LOADING: materializing Expo Asset local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
|
setTransport("LOADING: materializing Expo Asset local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING", T2: "PENDING", T3: "PENDING" });
|
||||||
if (await WeatherRuntime.clearWindFields(reactTag) !== "ACCEPTED") throw new Error("clearWindFields was rejected");
|
if (await FieldRuntime.clearFields(reactTag) !== "ACCEPTED") throw new Error("clearFields was rejected");
|
||||||
const localFrames = await materializeRealWeatherFrames();
|
const localFrames = await materializeRealWeatherFrames();
|
||||||
for (let index = 0; index < localFrames.length; index += 1) {
|
for (let index = 0; index < localFrames.length; index += 1) {
|
||||||
const frame = localFrames[index]; const submit = await WeatherRuntime.submitWindField(reactTag, frame.metadataJson, frame.payloadFileUri);
|
const frame = localFrames[index]; const submit = await FieldRuntime.submitField(reactTag, frame.metadataJson, frame.payloadFileUri);
|
||||||
setFrames((current) => ({ ...current, [frame.label]: submit }));
|
setFrames((current) => ({ ...current, [frame.label]: submit }));
|
||||||
if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`);
|
if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`);
|
||||||
}
|
}
|
||||||
const native = await WeatherRuntime.getState(reactTag); setSnapshot(native);
|
const native = await FieldRuntime.getState(reactTag); setSnapshot(native);
|
||||||
if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`);
|
if (native.state !== "READY") throw new Error(`Native FieldRuntime expected READY, got ${native.state}`);
|
||||||
setTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); setResult("T0-T3 accepted in strict producer timestamp order; Native runtime READY");
|
setTransport("READY: FILE_REF / FLOAT32_LE / INTERLEAVED_UV"); setResult("T0-T3 accepted in strict producer timestamp order; Native runtime READY");
|
||||||
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD REAL WEATHER T0-T3: ${String(error)}`); }
|
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD REAL WEATHER T0-T3: ${String(error)}`); }
|
||||||
}, [reactTag]);
|
}, [reactTag]);
|
||||||
|
|
||||||
const loadHttpWeather = useCallback(async () => {
|
|
||||||
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
|
|
||||||
if (httpLoading) return;
|
|
||||||
setHttpLoading(true);
|
|
||||||
try {
|
|
||||||
setHttpTransport("LOADING: manifest → time index → WVECBIN1 → canonical U/V");
|
|
||||||
const loaded = await loadHttpWeatherV2(reactTag);
|
|
||||||
setSnapshot(loaded.runtime);
|
|
||||||
setFrames(Object.fromEntries(loaded.frames.map((frame, index) => [`T${index}`, frame.submitResult])) as Record<string, string>);
|
|
||||||
setHttpTransport(`READY: ${loaded.frames.length} HTTP frames / ${loaded.context.referenceTime}`);
|
|
||||||
setResult(`HTTP WeatherV2 READY: ${loaded.frames.map((frame) => frame.fieldId).join(", ")}`);
|
|
||||||
} catch (error) {
|
|
||||||
setHttpTransport(`ERROR: ${String(error)}`);
|
|
||||||
setResult(`LOAD HTTP WEATHER: ${String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setHttpLoading(false);
|
|
||||||
}
|
|
||||||
}, [httpLoading, reactTag]);
|
|
||||||
|
|
||||||
const updateVisual = useCallback((key: keyof typeof initialVisual, value: string) => setVisual((current) => ({ ...current, [key]: value })), []);
|
const updateVisual = useCallback((key: keyof typeof initialVisual, value: string) => setVisual((current) => ({ ...current, [key]: value })), []);
|
||||||
const applyVisual = useCallback(() => void command("particle visual", async (tag) => {
|
const applyVisual = useCallback(() => void command("particle visual", async (tag) => {
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
WeatherRuntime.setParticleCount(tag, Number(visual.count)), WeatherRuntime.setParticleLineWidth(tag, Number(visual.width)), WeatherRuntime.setParticleLength(tag, Number(visual.length)), WeatherRuntime.setParticleOpacity(tag, Number(visual.opacity)), WeatherRuntime.setTrailOpacity(tag, Number(visual.trailOpacity)), WeatherRuntime.setTrailFade(tag, Number(visual.trailFade)), WeatherRuntime.setWeatherColorOpacity(tag, Number(visual.weatherOpacity)),
|
FieldRuntime.setParticleCount(tag, Number(visual.count)), FieldRuntime.setParticleLineWidth(tag, Number(visual.width)), FieldRuntime.setParticleLength(tag, Number(visual.length)), FieldRuntime.setParticleOpacity(tag, Number(visual.opacity)), FieldRuntime.setTrailOpacity(tag, Number(visual.trailOpacity)), FieldRuntime.setTrailFade(tag, Number(visual.trailFade)), FieldRuntime.setFieldOpacity(tag, Number(visual.fieldOpacity)),
|
||||||
]);
|
]);
|
||||||
if (results.some((value) => value !== "ACCEPTED")) throw new Error(results.join(", ")); return "ACCEPTED";
|
if (results.some((value) => value !== "ACCEPTED")) throw new Error(results.join(", ")); return "ACCEPTED";
|
||||||
}), [command, visual]);
|
}), [command, visual]);
|
||||||
|
|
||||||
const controls = useMemo(() => [
|
const controls = useMemo(() => [
|
||||||
["PLAY", (tag: number) => WeatherRuntime.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => WeatherRuntime.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => WeatherRuntime.setPlaybackSpeed(tag, "DOUBLE")],
|
["PLAY", (tag: number) => FieldRuntime.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => FieldRuntime.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "DOUBLE")],
|
||||||
["FPS AUTO", (tag: number) => WeatherRuntime.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => WeatherRuntime.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => WeatherRuntime.setTargetFps(tag, "FPS_60" as TargetFps)], ["Weather stops", (tag: number) => WeatherRuntime.setWeatherColorStops(tag, frozenStops)], ["Particle color", (tag: number) => WeatherRuntime.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => WeatherRuntime.setTrailColor(tag, 0.35, 0.85, 1, 0.8)],
|
["FPS AUTO", (tag: number) => FieldRuntime.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => FieldRuntime.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => FieldRuntime.setTargetFps(tag, "FPS_60" as TargetFps)], ["Field stops", (tag: number) => FieldRuntime.setFieldColorStops(tag, frozenStops)], ["Particle color", (tag: number) => FieldRuntime.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => FieldRuntime.setTrailColor(tag, 0.35, 0.85, 1, 0.8)],
|
||||||
] as const, []);
|
] as const, []);
|
||||||
|
|
||||||
return <SafeAreaView style={styles.root}>
|
return <SafeAreaView style={styles.root}>
|
||||||
@@ -98,11 +77,10 @@ export default function App() {
|
|||||||
<Text>Basemap: GSI {basemap}</Text><Text>Dataset: ECMWF HRES 20260807 · 513×769 · 3.01 MiB/frame</Text><Text>Runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text><Text selectable>REAL DATA TRANSPORT = {transport}</Text>
|
<Text>Basemap: GSI {basemap}</Text><Text>Dataset: ECMWF HRES 20260807 · 513×769 · 3.01 MiB/frame</Text><Text>Runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text><Text selectable>REAL DATA TRANSPORT = {transport}</Text>
|
||||||
{realWeatherFrames.map((frame) => <Text selectable key={frame.label}>{frame.label} {frame.cycle}: {frames[frame.label]}</Text>)}
|
{realWeatherFrames.map((frame) => <Text selectable key={frame.label}>{frame.label} {frame.cycle}: {frames[frame.label]}</Text>)}
|
||||||
<Text selectable>{result}</Text><Button title="LOAD REAL WEATHER T0-T3" onPress={() => void loadRealWeather()} />
|
<Text selectable>{result}</Text><Button title="LOAD REAL WEATHER T0-T3" onPress={() => void loadRealWeather()} />
|
||||||
<Text selectable>HTTP DATA TRANSPORT = {httpTransport}</Text><Button title={httpLoading ? "LOADING HTTP WEATHER..." : "LOAD HTTP WEATHER T0-T3"} disabled={httpLoading} onPress={() => void loadHttpWeather()} />
|
|
||||||
<View style={styles.row}>{controls.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}</View>
|
<View style={styles.row}>{controls.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}</View>
|
||||||
<View style={styles.row}><Button title={`Weather ${weatherVisible ? "visible" : "hidden"}`} onPress={() => { const next = !weatherVisible; setWeatherVisible(next); void command("weather visible", (tag) => WeatherRuntime.setWeatherColorVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => WeatherRuntime.setParticleVisible(tag, next)); }} /></View>
|
<View style={styles.row}><Button title={`Field ${fieldVisible ? "visible" : "hidden"}`} onPress={() => { const next = !fieldVisible; setFieldVisible(next); void command("field visible", (tag) => FieldRuntime.setFieldVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => FieldRuntime.setParticleVisible(tag, next)); }} /></View>
|
||||||
<View style={styles.row}>{realWeatherFrames.map((frame) => <Button key={`seek-${frame.label}`} title={`seek ${frame.label}`} onPress={() => void command(`seek ${frame.label}`, (tag) => WeatherRuntime.setForecastTime(tag, frame.validTime))} />)}</View>
|
<View style={styles.row}>{realWeatherFrames.map((frame) => <Button key={`seek-${frame.label}`} title={`seek ${frame.label}`} onPress={() => void command(`seek ${frame.label}`, (tag) => FieldRuntime.setTargetTime(tag, frame.validTime))} />)}</View>
|
||||||
<Text>Visual: count / width / length / particle opacity / trail opacity / trail fade / WeatherColor opacity</Text><View style={styles.row}>{(Object.keys(visual) as Array<keyof typeof initialVisual>).map((key) => <TextInput key={key} value={visual[key]} onChangeText={(value) => updateVisual(key, value)} keyboardType="decimal-pad" style={styles.input} />)}<Button title="Apply visual" onPress={applyVisual} /></View>
|
<Text>Visual: count / width / length / particle opacity / trail opacity / trail fade / field opacity</Text><View style={styles.row}>{(Object.keys(visual) as Array<keyof typeof initialVisual>).map((key) => <TextInput key={key} value={visual[key]} onChangeText={(value) => updateVisual(key, value)} keyboardType="decimal-pad" style={styles.input} />)}<Button title="Apply visual" onPress={applyVisual} /></View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>;
|
</SafeAreaView>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# MapLibreParticlePackageConsumerTestHost
|
# MapLibreParticlePackageConsumerTestHost
|
||||||
|
|
||||||
这是正式发布包 Consumer 验收 Host。它用于模拟 NavSea 的升级方式:Consumer 只直接安装 `@navsea/maplibre-particle-rn@13.4.1-particle.1`,由该 npm 包接入 React Native,并由 Gradle 从 `https://pbf.jpcn.ccwu.cc/maven` 解析正式 Particle AAR。
|
这是 `@navsea/maplibre-particle-rn@13.4.1-particle.2` 的 Consumer 验收 Host。它直接依赖 `@maplibre/maplibre-react-native@11.3.6` 提供 `Map` 与 `Camera`,只从 NavSea 包导入 `FieldRuntime`、纯类型与 field style fragment。
|
||||||
|
|
||||||
禁止直接依赖 `@maplibre/maplibre-react-native`、旧 `weather-runtime-bridge`、MapLibre Native source tree、Maven Local、本地 AAR 或 workspace/source-path bridge。Map、Camera、WeatherRuntime、WeatherV2 HTTP client 统一从 `@navsea/maplibre-particle-rn` 导入。
|
Host 自己拥有 GSI 底图、真实 WeatherV2 fixture、文件物化、数据选择与交互 UI。`LOAD REAL WEATHER T0-T3` 将本地 canonical `float32-le / interleaved-uv` 文件按时间顺序提交至 `FieldRuntime.submitField()`;这些业务能力不属于发布包。
|
||||||
|
|
||||||
Host 保留两条可对照数据链:`LOAD REAL WEATHER T0-T3` 使用 STEP-062 已通过的本地 canonical fixture;`LOAD HTTP WEATHER T0-T3` 从 `https://weather.jpcn.ccwu.cc` 获取 manifest/time-index/WVECBIN1,由包内 JS/TS converter 生成同一 canonical `float32-le / interleaved-uv` U/V 文件后提交 WeatherRuntime。
|
包的 Expo plugin 仍解析冻结 Native artifact:
|
||||||
|
|
||||||
HTTP 回归固定使用 ECMWF HRES `referenceTime=2026080600` 与 2026080700/06/12/18 四帧,以便和既有 fixture 做同源对照。正式 NavSea 不应固定这些时次,而应通过 `getWindContext()` 获取服务器可用时次后按业务策略选择。
|
```text
|
||||||
|
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1
|
||||||
|
```
|
||||||
|
|
||||||
验收目标是同时看到 GSI 底图、WeatherColor 与 Particle,并确认 T0~T3 顺序接受、Runtime 进入 READY。这样验证的是“正式 npm 包 → WeatherV2 HTTP/转换 → 正式 Maven AAR → RN/Native/Core”的完整升级链。
|
STEP-064 不构建或发布 AAR,也不执行 Android Gradle、APK、ADB 或 NavSea 集成。Consumer 验证待后续实际安装发布 `.2` tarball 后完成。
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"android": "expo run:android"
|
"android": "expo run:android"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@navsea/maplibre-particle-rn": "13.4.1-particle.1",
|
"@maplibre/maplibre-react-native": "11.3.6",
|
||||||
|
"@navsea/maplibre-particle-rn": "13.4.1-particle.2",
|
||||||
"expo": "56.0.4",
|
"expo": "56.0.4",
|
||||||
"expo-file-system": "56.0.11",
|
"expo-file-system": "56.0.11",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { File, Paths } from "expo-file-system";
|
|
||||||
import {
|
|
||||||
WeatherV2HttpClient,
|
|
||||||
loadWeatherV2WindIntoRuntime,
|
|
||||||
type LoadWeatherV2WindResult,
|
|
||||||
type WeatherV2BinaryFileStore,
|
|
||||||
} from "@navsea/maplibre-particle-rn";
|
|
||||||
|
|
||||||
const weatherClient = new WeatherV2HttpClient({
|
|
||||||
baseUrl: "https://weather.jpcn.ccwu.cc",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileStore: WeatherV2BinaryFileStore = {
|
|
||||||
async writeTempFile(fileName, bytes) {
|
|
||||||
const file = new File(Paths.cache, fileName);
|
|
||||||
file.write(bytes);
|
|
||||||
return file.uri;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const frozenTargetTimes = [
|
|
||||||
"2026080700",
|
|
||||||
"2026080706",
|
|
||||||
"2026080712",
|
|
||||||
"2026080718",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export async function loadHttpWeatherV2(reactTag: number): Promise<LoadWeatherV2WindResult> {
|
|
||||||
return loadWeatherV2WindIntoRuntime({
|
|
||||||
reactTag,
|
|
||||||
client: weatherClient,
|
|
||||||
fileStore,
|
|
||||||
datasetId: "ecmwf-hres",
|
|
||||||
referenceTime: "2026080600",
|
|
||||||
targetTimes: frozenTargetTimes,
|
|
||||||
weatherColorVisible: true,
|
|
||||||
particleVisible: true,
|
|
||||||
playbackState: "PAUSE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/** No field is embedded here: RasterArraySource is reserved for STEP-061 file-reference submission. */
|
import { createParticleFieldStyleFragment, particleFieldIds } from "@navsea/maplibre-particle-rn";
|
||||||
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 GSI_SOURCE_ID = "gsi-standard";
|
export const GSI_SOURCE_ID = "gsi-standard";
|
||||||
export const GSI_LAYER_ID = "gsi-standard";
|
export const GSI_LAYER_ID = "gsi-standard";
|
||||||
|
|
||||||
|
const fieldRenderer = createParticleFieldStyleFragment();
|
||||||
|
|
||||||
export const weatherTestStyle = JSON.stringify({
|
export const weatherTestStyle = JSON.stringify({
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -15,17 +15,26 @@ export const weatherTestStyle = JSON.stringify({
|
|||||||
maxzoom: 18,
|
maxzoom: 18,
|
||||||
attribution: "\u56fd\u571f\u5730\u7406\u9662 / GSI"
|
attribution: "\u56fd\u571f\u5730\u7406\u9662 / GSI"
|
||||||
},
|
},
|
||||||
[WEATHER_SOURCE_ID]: { type: "raster-array", "host-field-submission": true }
|
...fieldRenderer.sources
|
||||||
},
|
},
|
||||||
layers: [
|
layers: [
|
||||||
{ id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } },
|
{ id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } },
|
||||||
{ id: GSI_LAYER_ID, type: "raster", source: GSI_SOURCE_ID, paint: { "raster-opacity": 1 } },
|
{ id: GSI_LAYER_ID, type: "raster", source: GSI_SOURCE_ID, paint: { "raster-opacity": 1 } },
|
||||||
{ id: WEATHER_COLOR_LAYER_ID, type: "weather-color", source: WEATHER_SOURCE_ID,
|
...fieldRenderer.layers.map((layer) => {
|
||||||
paint: { "weather-color-opacity": 0.62 } },
|
if (layer.id !== particleFieldIds.particleLayer) return layer;
|
||||||
{ id: PARTICLE_LAYER_ID, type: "raster-particle", source: WEATHER_SOURCE_ID,
|
return {
|
||||||
paint: { "raster-particle-active": true, "raster-particle-particle-count": 4096,
|
...layer,
|
||||||
"raster-particle-line-width": 1.5, "raster-particle-particle-length": 0.012,
|
paint: {
|
||||||
"raster-particle-particle-opacity": 0.9, "raster-particle-trail-opacity": 0.8,
|
...(layer.paint as Record<string, unknown>),
|
||||||
"raster-particle-trail-fade": 0.94 } }
|
"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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# 变更记录
|
# 变更记录
|
||||||
|
|
||||||
|
## 13.4.1-particle.2
|
||||||
|
|
||||||
|
- 公共 RN/TS API 重整为纯 `FieldRuntime` Field Renderer 接口。
|
||||||
|
- 移除根入口对 `@maplibre/maplibre-react-native` 的转导出,以及旧 Weather/Wind 公共 API 与兼容 wrapper。
|
||||||
|
- 移除包内数据加载、HTTP、解码与业务辅助模块;数据获取、业务选择、缓存和本地文件写入由 Consumer/宿主负责。
|
||||||
|
- 新增 `FieldColorStop.value`、`particleFieldIds` 与不含底图的 `createParticleFieldStyleFragment()`。
|
||||||
|
- 非原生环境可安全导入;实际调用且 native bridge 缺失时返回稳定错误。
|
||||||
|
- RN 包版本升为 `.2`;Android Native artifact 保持 `13.4.1-particle.1`,未构建、未修改、未发布。
|
||||||
|
|
||||||
## 13.4.1-particle.1
|
## 13.4.1-particle.1
|
||||||
|
|
||||||
- 基于 MapLibre Native 13.4.1 的 Android OpenGL Particle 发行线。
|
- 首个 Android OpenGL Particle RN 发布线,保留为历史版本记录。
|
||||||
- 包含 WeatherColor、RasterParticle、HostField binary file ingress、Timeline / Playback 与 React Native WeatherRuntime。
|
|
||||||
- Native/RN binary ingress 已在 STEP-061 验证;GSI + 真实 WeatherV2 + Particle Android 综合验证已在 STEP-062 完成。
|
|
||||||
- 发布前 Consumer 验证修正 MLRN 依赖为 peer dependency,避免嵌套 native module 与 Windows codegen 长路径。
|
|
||||||
- WeatherV2 HTTP 全帧加载增加按 map `reactTag` 的 single-flight 保护,避免重复点击造成并发 clear/submit 与非单调快照拒绝。
|
|
||||||
- 新增纯 JS/TS WeatherV2 HTTP 客户端:manifest/time-index → WVECBIN1 full wind frame → canonical interleaved U/V。
|
|
||||||
- 新增 `loadWeatherV2WindIntoRuntime()`:顺序下载、转换、写临时 binary file 并通过现有 WeatherRuntime 提交,不建立第二套 Timeline/MIX。
|
|
||||||
- WeatherV2 文件存储通过 `WeatherV2BinaryFileStore` 注入,包本身不强依赖 Expo FileSystem,NavSea 可复用自己的缓存实现。
|
|
||||||
|
|||||||
@@ -1,94 +1,65 @@
|
|||||||
# @navsea/maplibre-particle-rn
|
# @navsea/maplibre-particle-rn
|
||||||
|
|
||||||
`13.4.1-particle.1` 是 NavSea 维护的 MapLibre Particle React Native 入口。Consumer 只直接安装本包;本包将 `@maplibre/maplibre-react-native@11.3.6` 声明为 peer dependency,并由 `WeatherRuntime` 提供 HostField / Timeline 的低频控制。
|
`13.4.1-particle.2` 提供 React Native 的纯 Field Renderer 接口。它只接收宿主已经准备好的 canonical field 元数据与本地二进制文件,并控制既有 Native/Core 渲染器;不提供地图组件、底图、数据集、provider、HTTP、缓存或业务策略。
|
||||||
|
|
||||||
Android 使用 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`。Expo Consumer 在 app config 加入 `@navsea/maplibre-particle-rn/app.plugin`;plugin 会加入 `https://pbf.jpcn.ccwu.cc/maven` 并替换 MLRN 的 OpenGL Native SDK 请求,避免官方与 Particle AAR 同时进入 APK。
|
Map 与 Camera 必须由 Consumer 直接从 `@maplibre/maplibre-react-native@11.3.6` 导入。本包将该库声明为 peer dependency,不再转导出 MapLibre React Native。
|
||||||
|
|
||||||
WeatherColor 和 RasterParticle 是 Core-only style layer,不是 React 组件。使用 `createParticleWeatherStyle()` 或等价 Style JSON 声明。binary submit 始终为 `metadataJson + local file:// payload`,通过 `WeatherRuntime.submitWindField()` 进入既有 Native/Core 链路。
|
## 版本关系
|
||||||
|
|
||||||
|
- RN/TS 包:`13.4.1-particle.2`
|
||||||
|
- Android Native artifact:`cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`
|
||||||
|
|
||||||
## HTTPS 静态包安装
|
本次仅整理 RN/TS 公共接口。Native artifact 未构建、未修改、未发布;Expo plugin 继续解析上述冻结 artifact。
|
||||||
|
|
||||||
本包当前采用静态 HTTPS tarball 发布,不部署 npm Registry。正式包由 `npm pack` 生成后上传到:
|
## 最小接入
|
||||||
|
|
||||||
```text
|
|
||||||
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-<version>.tgz
|
|
||||||
```
|
|
||||||
|
|
||||||
Consumer 直接安装完整 URL,例如:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.1.tgz
|
|
||||||
```
|
|
||||||
|
|
||||||
安装完成后仍按正式包名导入:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Map, Camera, WeatherRuntime } from "@navsea/maplibre-particle-rn";
|
import { Camera, Map } from "@maplibre/maplibre-react-native";
|
||||||
```
|
|
||||||
|
|
||||||
Consumer 不依赖 MapLibreParticleLab 源码路径。已发布 tarball 不覆盖;有修改时递增为新的 `13.4.1-particle.N`,重新 `npm pack` 并上传新的文件名。Android AAR 继续独立从 `https://pbf.jpcn.ccwu.cc/maven/` 获取。
|
|
||||||
|
|
||||||
## WeatherV2 HTTP 数据链
|
|
||||||
|
|
||||||
包内 `weatherV2` 子模块负责:
|
|
||||||
|
|
||||||
```text
|
|
||||||
WeatherV2 HTTP
|
|
||||||
→ weather_manifest.json
|
|
||||||
→ wind time_index.json
|
|
||||||
→ pt full-frame WVECBIN1
|
|
||||||
→ JS/TS canonical converter
|
|
||||||
→ maplibre-particle-field/v1
|
|
||||||
→ local binary file URI
|
|
||||||
→ WeatherRuntime.submitWindField()
|
|
||||||
→ WeatherColorLayer + RasterParticleLayer
|
|
||||||
```
|
|
||||||
|
|
||||||
Native 不理解 provider、HTTP、dataset、Route 或 ETA;Core 仍然独占 T0/T1/MIX、Runtime Field Window 和 GPU 插值。
|
|
||||||
### 最小调用
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { File, Paths } from "expo-file-system";
|
|
||||||
import {
|
import {
|
||||||
WeatherV2HttpClient,
|
FieldRuntime,
|
||||||
loadWeatherV2WindIntoRuntime,
|
createParticleFieldStyleFragment,
|
||||||
|
type FieldColorStop,
|
||||||
} from "@navsea/maplibre-particle-rn";
|
} from "@navsea/maplibre-particle-rn";
|
||||||
|
|
||||||
const client = new WeatherV2HttpClient({
|
const renderer = createParticleFieldStyleFragment();
|
||||||
baseUrl: "https://weather.jpcn.ccwu.cc",
|
const mapStyle = JSON.stringify({
|
||||||
|
version: 8,
|
||||||
|
sources: { ...renderer.sources },
|
||||||
|
layers: [...renderer.layers],
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileStore = {
|
|
||||||
async writeTempFile(fileName: string, bytes: Uint8Array) {
|
|
||||||
const file = new File(Paths.cache, fileName);
|
|
||||||
file.write(bytes);
|
|
||||||
return file.uri;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`fileStore` 是 Host 适配点,不属于 WeatherV2 协议。NavSea 可以换成自己的缓存目录、文件系统和生命周期策略,不需要修改 HTTP client 或 canonical converter。
|
宿主在样式加载并取得 `reactTag` 后调用 `FieldRuntime.attach(reactTag)`。准备 canonical payload 的方式由宿主决定,再按时间顺序调用:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
await loadWeatherV2WindIntoRuntime({
|
await FieldRuntime.clearFields(reactTag);
|
||||||
reactTag,
|
await FieldRuntime.submitField(reactTag, metadataJson, localFileUri);
|
||||||
client,
|
await FieldRuntime.setFieldColorStops(reactTag, [
|
||||||
fileStore,
|
{ value: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 },
|
||||||
datasetId: "ecmwf-hres",
|
{ value: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
|
||||||
referenceTime: "2026080600",
|
]);
|
||||||
targetTimes: ["2026080700", "2026080706", "2026080712", "2026080718"],
|
|
||||||
weatherColorVisible: true,
|
|
||||||
particleVisible: true,
|
|
||||||
playbackState: "PAUSE",
|
|
||||||
});
|
|
||||||
|
|
||||||
await WeatherRuntime.setPlaybackState(reactTag, "PLAY");
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`WeatherV2HttpClient.getWindContext()` 会从 manifest 选择 wind dataset 并读取 time index。NavSea 正式代码通常应先取得 context,再根据当前时间、路线/ETA 或业务策略选择 `targetTimes`;上面的固定四帧只用于当前 Test Host 回归。
|
`createParticleFieldStyleFragment()` 只返回 renderer source、field layer 与 particle layer;不含底图或业务数据。`particleFieldIds` 可供宿主引用相同的 source/layer ID。
|
||||||
|
|
||||||
转换器读取服务端 `WVECBIN1` 的 U plane、V plane、speed plane 和 valid mask;正式 canonical payload 只保留 interleaved U/V,invalid sample 写为 `-9999`。方向和单位固定为 U=eastward、V=northward、m/s、north-west origin、rows north-to-south。
|
## FieldRuntime
|
||||||
|
|
||||||
下载/转换/提交按帧串行执行,避免同时在 JS 内存保留多份约 5 MiB 的 WVECBIN1 与约 3 MiB 的 canonical payload。没有 `number[]`、JSON 数值数组、base64 或 per-frame JNI Field 上传。
|
全部方法均以 `reactTag` 为首参并返回既有桥接命令结果:
|
||||||
|
|
||||||
本包不包含 Test Host、WeatherV2 fixture、AAR、APK、cache 或凭证。发布前仍需通过 tarball Consumer APK 与实体机 smoke;Release Tag 由 Release Freeze 阶段单独创建。
|
- 生命周期:`attach`、`detach`、`getState`
|
||||||
|
- 数据:`clearFields`、`submitField`
|
||||||
|
- 显示与播放:`setEnabled`、`setFieldVisible`、`setParticleVisible`、`setTargetFps`、`setTargetTime`、`setPlaybackState`、`setPlaybackSpeed`
|
||||||
|
- 色彩:`setFieldColorStops`、`setParticleColor`、`setTrailColor`
|
||||||
|
- 粒子视觉参数:`setParticleCount`、`setParticleLineWidth`、`setParticleLength`、`setParticleOpacity`、`setTrailOpacity`、`setTrailFade`、`setFieldOpacity`
|
||||||
|
|
||||||
|
`FieldColorStop` 使用无业务含义的 `value` 字段;色阶继续遵循既有 Native 契约,至少提供 2 个且 `value` 严格递增。包在非原生环境可安全导入;实际调用 `FieldRuntime` 而 Native bridge 不存在时会给出稳定、明确的错误。
|
||||||
|
|
||||||
|
## 安装与发布
|
||||||
|
|
||||||
|
正式包仍以 HTTPS tarball 交付:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.2.tgz
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumer 只依赖发布 tarball,不依赖本仓库源码路径。发布前通过 `npm pack` 检查 tarball 内容;本包不包含 AAR、APK、fixture 或凭证。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@navsea/maplibre-particle-rn",
|
"name": "@navsea/maplibre-particle-rn",
|
||||||
"version": "13.4.1-particle.1",
|
"version": "13.4.1-particle.2",
|
||||||
"description": "NavSea MapLibre Particle React Native facade for the Android OpenGL particle release.",
|
"description": "NavSea MapLibre Particle React Native pure Field Renderer API.",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"react-native": "src/index.ts",
|
"react-native": "src/index.ts",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"maplibre",
|
"maplibre",
|
||||||
"particle",
|
"particle",
|
||||||
"react-native",
|
"react-native",
|
||||||
"weather"
|
"field-renderer"
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
34
packages/maplibre-particle-rn/src/FieldRuntime.ts
Normal file
34
packages/maplibre-particle-rn/src/FieldRuntime.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { getNativeFieldRuntimeBridge } from "./NativeWeatherRuntimeBridge";
|
||||||
|
import type { ColorChannel, CommandResult, FieldColorStop, FieldSubmitResult, PlaybackState, RuntimeSnapshot, TargetFps } from "./types";
|
||||||
|
|
||||||
|
function nativeBridge() {
|
||||||
|
const bridge = getNativeFieldRuntimeBridge();
|
||||||
|
if (bridge == null) throw new Error("@navsea/maplibre-particle-rn FieldRuntime requires the Android WeatherRuntimeBridge native module");
|
||||||
|
return bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure canonical-field renderer controls; it has no provider, dataset, or business policy. */
|
||||||
|
export const FieldRuntime = {
|
||||||
|
attach: (reactTag: number): Promise<RuntimeSnapshot> => nativeBridge().attach(reactTag),
|
||||||
|
detach: (reactTag: number): Promise<void> => nativeBridge().detach(reactTag),
|
||||||
|
getState: (reactTag: number): Promise<RuntimeSnapshot> => nativeBridge().getState(reactTag),
|
||||||
|
clearFields: (reactTag: number): Promise<CommandResult> => nativeBridge().clearWindFields(reactTag),
|
||||||
|
submitField: (reactTag: number, metadataJson: string, payloadFileUri: string): Promise<FieldSubmitResult> => nativeBridge().submitWindField(reactTag, metadataJson, payloadFileUri),
|
||||||
|
setEnabled: (reactTag: number, enabled: boolean): Promise<CommandResult> => nativeBridge().setWeatherEnabled(reactTag, enabled),
|
||||||
|
setFieldVisible: (reactTag: number, visible: boolean): Promise<CommandResult> => nativeBridge().setWeatherColorVisible(reactTag, visible),
|
||||||
|
setParticleVisible: (reactTag: number, visible: boolean): Promise<CommandResult> => nativeBridge().setParticleVisible(reactTag, visible),
|
||||||
|
setTargetFps: (reactTag: number, target: TargetFps): Promise<CommandResult> => nativeBridge().setTargetFps(reactTag, target),
|
||||||
|
setTargetTime: (reactTag: number, timestampMs: number): Promise<CommandResult> => nativeBridge().setForecastTime(reactTag, timestampMs),
|
||||||
|
setPlaybackState: (reactTag: number, state: PlaybackState): Promise<CommandResult> => nativeBridge().setPlaybackState(reactTag, state),
|
||||||
|
setPlaybackSpeed: (reactTag: number, speed: "HALF" | "NORMAL" | "DOUBLE"): Promise<CommandResult> => nativeBridge().setPlaybackSpeed(reactTag, speed),
|
||||||
|
setFieldColorStops: (reactTag: number, stops: readonly FieldColorStop[]): Promise<CommandResult> => nativeBridge().setWeatherColorStops(reactTag, stops.map(({ value, red, green, blue, alpha }) => ({ speedMps: value, red, green, blue, alpha }))),
|
||||||
|
setParticleColor: (reactTag: number, red: ColorChannel, green: ColorChannel, blue: ColorChannel, alpha: ColorChannel): Promise<CommandResult> => nativeBridge().setParticleColor(reactTag, red, green, blue, alpha),
|
||||||
|
setTrailColor: (reactTag: number, red: ColorChannel, green: ColorChannel, blue: ColorChannel, alpha: ColorChannel): Promise<CommandResult> => nativeBridge().setTrailColor(reactTag, red, green, blue, alpha),
|
||||||
|
setParticleCount: (reactTag: number, count: number): Promise<CommandResult> => nativeBridge().setParticleCount(reactTag, count),
|
||||||
|
setParticleLineWidth: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setParticleLineWidth(reactTag, value),
|
||||||
|
setParticleLength: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setParticleLength(reactTag, value),
|
||||||
|
setParticleOpacity: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setParticleOpacity(reactTag, value),
|
||||||
|
setTrailOpacity: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setTrailOpacity(reactTag, value),
|
||||||
|
setTrailFade: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setTrailFade(reactTag, value),
|
||||||
|
setFieldOpacity: (reactTag: number, value: number): Promise<CommandResult> => nativeBridge().setWeatherColorOpacity(reactTag, value),
|
||||||
|
} as const;
|
||||||
@@ -34,4 +34,7 @@ export interface Spec extends TurboModule {
|
|||||||
setWeatherColorOpacity(reactTag: number, value: number): Promise<CommandResult>;
|
setWeatherColorOpacity(reactTag: number, value: number): Promise<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TurboModuleRegistry.getEnforcing<Spec>("WeatherRuntimeBridge");
|
/** Native ABI name is retained internally for the existing Android bridge/codegen seam. */
|
||||||
|
export function getNativeFieldRuntimeBridge(): Spec | null {
|
||||||
|
return TurboModuleRegistry.get<Spec>("WeatherRuntimeBridge");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
export * from "@maplibre/maplibre-react-native";
|
export { FieldRuntime } from "./FieldRuntime";
|
||||||
export { default as WeatherRuntime } from "./NativeWeatherRuntimeBridge";
|
|
||||||
export * from "./weatherV2";
|
|
||||||
export type {
|
export type {
|
||||||
ColorStop,
|
ColorChannel,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
|
FieldColorStop,
|
||||||
FieldSubmitResult,
|
FieldSubmitResult,
|
||||||
PlaybackState,
|
PlaybackState,
|
||||||
RuntimeSnapshot,
|
RuntimeSnapshot,
|
||||||
@@ -11,30 +10,43 @@ export type {
|
|||||||
TargetFps,
|
TargetFps,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const particleWeatherIds = {
|
/** IDs for the renderer-owned source and layers. Hosts retain ownership of their base map. */
|
||||||
|
export const particleFieldIds = {
|
||||||
source: "rn-weather-fields",
|
source: "rn-weather-fields",
|
||||||
weatherColorLayer: "rn-weather-color",
|
fieldLayer: "rn-weather-color",
|
||||||
rasterParticleLayer: "rn-weather-particles",
|
particleLayer: "rn-weather-particles",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type ParticleFieldStyleFragment = Readonly<{
|
||||||
|
sources: Record<string, unknown>;
|
||||||
|
layers: readonly Record<string, unknown>[];
|
||||||
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core-only WeatherColor/RasterParticle layers are declared in a MapLibre style, rather than as
|
* Creates only the renderer source/layers. It deliberately contains no base-map,
|
||||||
* React components. This helper reserves their stable IDs without creating a second field ingress.
|
* provider, network, or field-data policy.
|
||||||
*/
|
*/
|
||||||
export function createParticleWeatherStyle(overrides: Record<string, unknown> = {}) {
|
export function createParticleFieldStyleFragment(): ParticleFieldStyleFragment {
|
||||||
return JSON.stringify({
|
return {
|
||||||
version: 8,
|
|
||||||
sources: {
|
sources: {
|
||||||
"gsi-standard": { type: "raster", tiles: ["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"], tileSize: 256, maxzoom: 18, attribution: "国土地理院 / GSI" },
|
[particleFieldIds.source]: {
|
||||||
[particleWeatherIds.source]: { type: "raster-array", "host-field-submission": true },
|
type: "raster-array",
|
||||||
|
"host-field-submission": true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
layers: [
|
layers: [
|
||||||
{ id: "particle-background", type: "background", paint: { "background-color": "#10243a" } },
|
{
|
||||||
{ id: "gsi-standard", type: "raster", source: "gsi-standard", paint: { "raster-opacity": 1 } },
|
id: particleFieldIds.fieldLayer,
|
||||||
{ id: particleWeatherIds.weatherColorLayer, type: "weather-color", source: particleWeatherIds.source, paint: { "weather-color-opacity": 0.62 } },
|
type: "weather-color",
|
||||||
{ id: particleWeatherIds.rasterParticleLayer, type: "raster-particle", source: particleWeatherIds.source,
|
source: particleFieldIds.source,
|
||||||
paint: { "raster-particle-active": true, "raster-particle-particle-count": 4096 } },
|
paint: { "weather-color-opacity": 0.62 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: particleFieldIds.particleLayer,
|
||||||
|
type: "raster-particle",
|
||||||
|
source: particleFieldIds.source,
|
||||||
|
paint: { "raster-particle-opacity": 0.9 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
...overrides,
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ export type PlaybackState = "PLAY" | "PAUSE";
|
|||||||
export type TargetFps = "AUTO" | "FPS_30" | "FPS_60";
|
export type TargetFps = "AUTO" | "FPS_30" | "FPS_60";
|
||||||
export type CommandResult = "ACCEPTED" | "REJECTED";
|
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 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 ColorChannel = number;
|
||||||
|
export type FieldColorStop = Readonly<{ value: number; red: ColorChannel; green: ColorChannel; blue: ColorChannel; alpha: ColorChannel }>;
|
||||||
export type RuntimeSnapshot = Readonly<{ state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }>;
|
export type RuntimeSnapshot = Readonly<{ state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }>;
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import { decodeWeatherV2WindFrame } from "./wvecbin1";
|
|
||||||
import type {
|
|
||||||
CanonicalWindField,
|
|
||||||
WeatherV2Bounds,
|
|
||||||
WeatherV2Fetch,
|
|
||||||
WeatherV2HttpClientOptions,
|
|
||||||
WeatherV2WindContext,
|
|
||||||
WeatherV2WindFrameRef,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
throw new Error(`WeatherV2 HTTP: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function objectValue(value: unknown, name: string): Record<string, unknown> {
|
|
||||||
if (value == null || typeof value !== "object" || Array.isArray(value)) fail(`${name} must be an object`);
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringValue(value: unknown, name: string): string {
|
|
||||||
if (typeof value !== "string" || value.length === 0) fail(`${name} must be a non-empty string`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinUrl(baseUrl: string, path: string): string {
|
|
||||||
return new URL(path.replace(/^\//, ""), `${baseUrl.replace(/\/+$/, "")}/`).toString();
|
|
||||||
}
|
|
||||||
function parseBounds(value: unknown): WeatherV2Bounds | undefined {
|
|
||||||
if (value == null) return undefined;
|
|
||||||
const bounds = objectValue(value, "timeIndex.b");
|
|
||||||
const names = ["west", "south", "east", "north"] as const;
|
|
||||||
const numbers = names.map((name) => bounds[name]);
|
|
||||||
if (numbers.some((number) => typeof number !== "number" || !Number.isFinite(number))) fail("timeIndex.b must contain finite bounds");
|
|
||||||
return { west: numbers[0] as number, south: numbers[1] as number, east: numbers[2] as number, north: numbers[3] as number };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTemplate(template: string, values: Readonly<Record<string, string>>): string {
|
|
||||||
let rendered = template;
|
|
||||||
Object.entries(values).forEach(([name, value]) => {
|
|
||||||
rendered = rendered.replaceAll(`{${name}}`, encodeURIComponent(value));
|
|
||||||
});
|
|
||||||
if (/\{[^}]+\}/.test(rendered)) fail(`unresolved template token in ${rendered}`);
|
|
||||||
return rendered;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultFetch: WeatherV2Fetch = (url, init) => globalThis.fetch(url, init as RequestInit) as Promise<WeatherV2FetchResponse>;
|
|
||||||
type WeatherV2FetchResponse = Awaited<ReturnType<WeatherV2Fetch>>;
|
|
||||||
|
|
||||||
export class WeatherV2HttpClient {
|
|
||||||
private readonly baseUrl: string;
|
|
||||||
private readonly headers: Readonly<Record<string, string>>;
|
|
||||||
private readonly fetchImpl: WeatherV2Fetch;
|
|
||||||
|
|
||||||
constructor(options: WeatherV2HttpClientOptions) {
|
|
||||||
if (!/^https?:\/\//.test(options.baseUrl)) fail("baseUrl must be absolute HTTP(S)");
|
|
||||||
this.baseUrl = options.baseUrl;
|
|
||||||
this.headers = { Accept: "application/json, application/octet-stream", ...options.headers };
|
|
||||||
this.fetchImpl = options.fetchImpl ?? defaultFetch;
|
|
||||||
}
|
|
||||||
private async fetchJson(path: string): Promise<unknown> {
|
|
||||||
const url = joinUrl(this.baseUrl, path);
|
|
||||||
const response = await this.fetchImpl(url, { headers: this.headers });
|
|
||||||
if (!response.ok) fail(`GET ${url} returned HTTP ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getWindContext(options: Readonly<{ datasetId?: string; referenceTime?: string }> = {}): Promise<WeatherV2WindContext> {
|
|
||||||
const manifest = objectValue(await this.fetchJson("weather_manifest.json"), "manifest");
|
|
||||||
if (manifest.f !== "weather-v2-manifest-v1") fail(`unsupported manifest ${String(manifest.f)}`);
|
|
||||||
const layers = objectValue(manifest.l, "manifest.l");
|
|
||||||
const wind = objectValue(layers.wind, "manifest.l.wind");
|
|
||||||
if (!Array.isArray(wind.ds) || wind.ds.length === 0) fail("manifest wind datasets are empty");
|
|
||||||
|
|
||||||
const datasets = wind.ds.map((value, index) => objectValue(value, `manifest.l.wind.ds[${index}]`));
|
|
||||||
const selected = datasets.find((dataset) =>
|
|
||||||
(options.datasetId == null || dataset.id === options.datasetId) &&
|
|
||||||
(options.referenceTime == null || dataset.r === options.referenceTime),
|
|
||||||
);
|
|
||||||
if (selected == null) fail(`no wind dataset matches datasetId=${options.datasetId ?? "default"} referenceTime=${options.referenceTime ?? "default"}`);
|
|
||||||
|
|
||||||
const datasetId = stringValue(selected.id, "dataset.id");
|
|
||||||
const model = stringValue(selected.m ?? selected.id, "dataset.model");
|
|
||||||
const referenceTime = stringValue(selected.r, "dataset.referenceTime");
|
|
||||||
const timeIndexPath = stringValue(selected.iu, "dataset.timeIndexUrl");
|
|
||||||
const timeIndex = objectValue(await this.fetchJson(timeIndexPath), "timeIndex");
|
|
||||||
if (timeIndex.f !== "weather-v2-time-index-v1" || timeIndex.l !== "wind") fail("time index is not a WeatherV2 wind index");
|
|
||||||
if (!Array.isArray(timeIndex.t) || timeIndex.t.length === 0 || timeIndex.t.some((value) => typeof value !== "string")) {
|
|
||||||
fail("timeIndex.t must be a non-empty string array");
|
|
||||||
}
|
|
||||||
const particleTemplate = stringValue(timeIndex.pt, "timeIndex.pt");
|
|
||||||
return {
|
|
||||||
datasetId,
|
|
||||||
model,
|
|
||||||
referenceTime,
|
|
||||||
targetTimes: timeIndex.t as string[],
|
|
||||||
particleTemplate,
|
|
||||||
bounds: parseBounds(timeIndex.b),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
selectWindFrames(
|
|
||||||
context: WeatherV2WindContext,
|
|
||||||
options: Readonly<{ targetTimes?: readonly string[]; count?: number }> = {},
|
|
||||||
): readonly WeatherV2WindFrameRef[] {
|
|
||||||
const count = options.count ?? 4;
|
|
||||||
if (!Number.isInteger(count) || count < 2) fail("count must be an integer >= 2");
|
|
||||||
const selected = options.targetTimes == null ? context.targetTimes.slice(0, count) : [...options.targetTimes];
|
|
||||||
if (selected.length < 2) fail("at least two target times are required for a runtime window");
|
|
||||||
if (selected.length > count && options.targetTimes == null) fail("selected target count exceeds count");
|
|
||||||
|
|
||||||
const available = new Set(context.targetTimes);
|
|
||||||
selected.forEach((targetTime, index) => {
|
|
||||||
if (!available.has(targetTime)) fail(`target time ${targetTime} is not available`);
|
|
||||||
if (index > 0 && selected[index - 1] >= targetTime) fail("target times must be strictly increasing");
|
|
||||||
});
|
|
||||||
return selected.map((targetTime) => {
|
|
||||||
const path = renderTemplate(context.particleTemplate, {
|
|
||||||
dataset_id: context.datasetId,
|
|
||||||
layer: "wind",
|
|
||||||
ref_time: context.referenceTime,
|
|
||||||
target_time: targetTime,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
datasetId: context.datasetId,
|
|
||||||
model: context.model,
|
|
||||||
referenceTime: context.referenceTime,
|
|
||||||
targetTime,
|
|
||||||
url: joinUrl(this.baseUrl, path),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchCanonicalWindFrame(frame: WeatherV2WindFrameRef): Promise<CanonicalWindField> {
|
|
||||||
const response = await this.fetchImpl(frame.url, { headers: this.headers });
|
|
||||||
if (!response.ok) fail(`GET ${frame.url} returned HTTP ${response.status}`);
|
|
||||||
return decodeWeatherV2WindFrame(await response.arrayBuffer(), frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export { WeatherV2HttpClient } from "./WeatherV2HttpClient";
|
|
||||||
export { decodeWeatherV2WindFrame } from "./wvecbin1";
|
|
||||||
export { loadWeatherV2WindIntoRuntime } from "./loadIntoWeatherRuntime";
|
|
||||||
export type {
|
|
||||||
CanonicalWindField,
|
|
||||||
LoadedWeatherV2WindFrame,
|
|
||||||
LoadWeatherV2WindOptions,
|
|
||||||
LoadWeatherV2WindResult,
|
|
||||||
WeatherV2BinaryFileStore,
|
|
||||||
WeatherV2Bounds,
|
|
||||||
WeatherV2Fetch,
|
|
||||||
WeatherV2FetchResponse,
|
|
||||||
WeatherV2HttpClientOptions,
|
|
||||||
WeatherV2WindClient,
|
|
||||||
WeatherV2WindContext,
|
|
||||||
WeatherV2WindFrameRef,
|
|
||||||
} from "./types";
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import WeatherRuntime from "../NativeWeatherRuntimeBridge";
|
|
||||||
import type { FieldSubmitResult } from "../types";
|
|
||||||
import type {
|
|
||||||
LoadWeatherV2WindOptions,
|
|
||||||
LoadWeatherV2WindResult,
|
|
||||||
LoadedWeatherV2WindFrame,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
function requireAccepted(value: string, operation: string): void {
|
|
||||||
if (value !== "ACCEPTED") throw new Error(`${operation} was rejected: ${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectedSubmit(index: number): FieldSubmitResult {
|
|
||||||
if (index === 0) return "ACCEPTED_PENDING_INITIAL_WINDOW";
|
|
||||||
if (index === 1) return "INITIAL_WINDOW_READY";
|
|
||||||
return "ACCEPTED_NEXT_SNAPSHOT";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches WeatherV2 full wind frames sequentially, converts each frame to the canonical
|
|
||||||
* U/V contract and submits it through the existing map-owned WeatherRuntime.
|
|
||||||
* Core remains the sole owner of T0/T1/MIX and playback interpolation.
|
|
||||||
*/
|
|
||||||
const inFlightLoads = new Map<number, Promise<LoadWeatherV2WindResult>>();
|
|
||||||
|
|
||||||
async function loadWeatherV2WindIntoRuntimeInternal(
|
|
||||||
options: LoadWeatherV2WindOptions,
|
|
||||||
): Promise<LoadWeatherV2WindResult> {
|
|
||||||
const { reactTag, client, fileStore } = options;
|
|
||||||
if (!Number.isInteger(reactTag) || reactTag <= 0) throw new Error("reactTag must be a positive integer");
|
|
||||||
requireAccepted(await WeatherRuntime.clearWindFields(reactTag), "clearWindFields");
|
|
||||||
requireAccepted(await WeatherRuntime.setWeatherEnabled(reactTag, true), "setWeatherEnabled");
|
|
||||||
requireAccepted(
|
|
||||||
await WeatherRuntime.setWeatherColorVisible(reactTag, options.weatherColorVisible ?? true),
|
|
||||||
"setWeatherColorVisible",
|
|
||||||
);
|
|
||||||
requireAccepted(
|
|
||||||
await WeatherRuntime.setParticleVisible(reactTag, options.particleVisible ?? true),
|
|
||||||
"setParticleVisible",
|
|
||||||
);
|
|
||||||
|
|
||||||
const context = await client.getWindContext({
|
|
||||||
datasetId: options.datasetId,
|
|
||||||
referenceTime: options.referenceTime,
|
|
||||||
});
|
|
||||||
const frameRefs = client.selectWindFrames(context, {
|
|
||||||
targetTimes: options.targetTimes,
|
|
||||||
count: options.count,
|
|
||||||
});
|
|
||||||
const loaded: LoadedWeatherV2WindFrame[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < frameRefs.length; index += 1) {
|
|
||||||
const frame = await client.fetchCanonicalWindFrame(frameRefs[index]);
|
|
||||||
const fileName = `navsea-weather-${frame.fieldId}.f32`;
|
|
||||||
const payloadFileUri = await fileStore.writeTempFile(fileName, frame.payload);
|
|
||||||
let submitResult: FieldSubmitResult;
|
|
||||||
try {
|
|
||||||
submitResult = await WeatherRuntime.submitWindField(reactTag, frame.metadataJson, payloadFileUri);
|
|
||||||
} finally {
|
|
||||||
if (fileStore.removeTempFile != null) {
|
|
||||||
try { await fileStore.removeTempFile(payloadFileUri); } catch { /* cache cleanup is best effort */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = expectedSubmit(index);
|
|
||||||
if (submitResult !== expected) {
|
|
||||||
throw new Error(`${frame.fieldId} expected ${expected}, got ${submitResult}`);
|
|
||||||
}
|
|
||||||
loaded.push({
|
|
||||||
fieldId: frame.fieldId,
|
|
||||||
validTime: frame.validTime,
|
|
||||||
generation: frame.generation,
|
|
||||||
bounds: frame.bounds,
|
|
||||||
width: frame.width,
|
|
||||||
height: frame.height,
|
|
||||||
sourceUrl: frame.sourceUrl,
|
|
||||||
submitResult,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
requireAccepted(
|
|
||||||
await WeatherRuntime.setForecastTime(reactTag, loaded[0].validTime),
|
|
||||||
"setForecastTime",
|
|
||||||
);
|
|
||||||
if (options.playbackState != null) {
|
|
||||||
requireAccepted(
|
|
||||||
await WeatherRuntime.setPlaybackState(reactTag, options.playbackState),
|
|
||||||
"setPlaybackState",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime = await WeatherRuntime.getState(reactTag);
|
|
||||||
if (runtime.state !== "READY") {
|
|
||||||
throw new Error(`WeatherRuntime expected READY after ${loaded.length} frames, got ${runtime.state}`);
|
|
||||||
}
|
|
||||||
return { context, frames: loaded, runtime };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes full WeatherV2 loads per map view. Repeated taps while a load is in flight
|
|
||||||
* reuse the same Promise instead of clearing/submitting a second overlapping timeline.
|
|
||||||
*/
|
|
||||||
export function loadWeatherV2WindIntoRuntime(
|
|
||||||
options: LoadWeatherV2WindOptions,
|
|
||||||
): Promise<LoadWeatherV2WindResult> {
|
|
||||||
const existing = inFlightLoads.get(options.reactTag);
|
|
||||||
if (existing != null) return existing;
|
|
||||||
|
|
||||||
const task = loadWeatherV2WindIntoRuntimeInternal(options).finally(() => {
|
|
||||||
if (inFlightLoads.get(options.reactTag) === task) inFlightLoads.delete(options.reactTag);
|
|
||||||
});
|
|
||||||
inFlightLoads.set(options.reactTag, task);
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import type { FieldSubmitResult, PlaybackState, RuntimeSnapshot } from "../types";
|
|
||||||
|
|
||||||
export type WeatherV2FetchResponse = Readonly<{
|
|
||||||
ok: boolean;
|
|
||||||
status: number;
|
|
||||||
json(): Promise<unknown>;
|
|
||||||
arrayBuffer(): Promise<ArrayBuffer>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type WeatherV2Fetch = (
|
|
||||||
url: string,
|
|
||||||
init?: Readonly<{ headers?: Readonly<Record<string, string>> }>,
|
|
||||||
) => Promise<WeatherV2FetchResponse>;
|
|
||||||
|
|
||||||
export type WeatherV2HttpClientOptions = Readonly<{
|
|
||||||
baseUrl: string;
|
|
||||||
headers?: Readonly<Record<string, string>>;
|
|
||||||
fetchImpl?: WeatherV2Fetch;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type WeatherV2Bounds = Readonly<{
|
|
||||||
west: number;
|
|
||||||
south: number;
|
|
||||||
east: number;
|
|
||||||
north: number;
|
|
||||||
}>;
|
|
||||||
export type WeatherV2WindContext = Readonly<{
|
|
||||||
datasetId: string;
|
|
||||||
model: string;
|
|
||||||
referenceTime: string;
|
|
||||||
targetTimes: readonly string[];
|
|
||||||
particleTemplate: string;
|
|
||||||
bounds?: WeatherV2Bounds;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type WeatherV2WindFrameRef = Readonly<{
|
|
||||||
datasetId: string;
|
|
||||||
model: string;
|
|
||||||
referenceTime: string;
|
|
||||||
targetTime: string;
|
|
||||||
url: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type CanonicalWindField = Readonly<{
|
|
||||||
metadataJson: string;
|
|
||||||
payload: Uint8Array;
|
|
||||||
fieldId: string;
|
|
||||||
validTime: number;
|
|
||||||
generation: number;
|
|
||||||
bounds: WeatherV2Bounds;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
sourceUrl: string;
|
|
||||||
}>;
|
|
||||||
export type WeatherV2WindClient = Readonly<{
|
|
||||||
getWindContext(options?: Readonly<{ datasetId?: string; referenceTime?: string }>): Promise<WeatherV2WindContext>;
|
|
||||||
selectWindFrames(
|
|
||||||
context: WeatherV2WindContext,
|
|
||||||
options?: Readonly<{ targetTimes?: readonly string[]; count?: number }>,
|
|
||||||
): readonly WeatherV2WindFrameRef[];
|
|
||||||
fetchCanonicalWindFrame(frame: WeatherV2WindFrameRef): Promise<CanonicalWindField>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type WeatherV2BinaryFileStore = Readonly<{
|
|
||||||
writeTempFile(fileName: string, bytes: Uint8Array): Promise<string>;
|
|
||||||
removeTempFile?(fileUri: string): Promise<void>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type LoadedWeatherV2WindFrame = Readonly<{
|
|
||||||
fieldId: string;
|
|
||||||
validTime: number;
|
|
||||||
generation: number;
|
|
||||||
bounds: WeatherV2Bounds;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
sourceUrl: string;
|
|
||||||
submitResult: FieldSubmitResult;
|
|
||||||
}>;
|
|
||||||
export type LoadWeatherV2WindOptions = Readonly<{
|
|
||||||
reactTag: number;
|
|
||||||
client: WeatherV2WindClient;
|
|
||||||
fileStore: WeatherV2BinaryFileStore;
|
|
||||||
datasetId?: string;
|
|
||||||
referenceTime?: string;
|
|
||||||
targetTimes?: readonly string[];
|
|
||||||
count?: number;
|
|
||||||
weatherColorVisible?: boolean;
|
|
||||||
particleVisible?: boolean;
|
|
||||||
playbackState?: PlaybackState;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type LoadWeatherV2WindResult = Readonly<{
|
|
||||||
context: WeatherV2WindContext;
|
|
||||||
frames: readonly LoadedWeatherV2WindFrame[];
|
|
||||||
runtime: RuntimeSnapshot;
|
|
||||||
}>;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import type { CanonicalWindField, WeatherV2Bounds, WeatherV2WindFrameRef } from "./types";
|
|
||||||
|
|
||||||
const MAGIC = "WVECBIN1";
|
|
||||||
const NO_DATA = -9999;
|
|
||||||
const FLOAT_BYTES = 4;
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
throw new Error(`WeatherV2 WVECBIN1: ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ascii(bytes: Uint8Array): string {
|
|
||||||
let value = "";
|
|
||||||
for (let index = 0; index < bytes.length; index += 1) {
|
|
||||||
const code = bytes[index];
|
|
||||||
if (code > 0x7f) fail("header must be ASCII JSON");
|
|
||||||
value += String.fromCharCode(code);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function objectValue(value: unknown, name: string): Record<string, unknown> {
|
|
||||||
if (value == null || typeof value !== "object" || Array.isArray(value)) fail(`${name} must be an object`);
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteNumber(value: unknown, name: string): number {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) fail(`${name} must be finite`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
function positiveInteger(value: unknown, name: string): number {
|
|
||||||
const number = finiteNumber(value, name);
|
|
||||||
if (!Number.isInteger(number) || number <= 0) fail(`${name} must be a positive integer`);
|
|
||||||
return number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBounds(value: unknown): WeatherV2Bounds {
|
|
||||||
const bounds = objectValue(value, "bounds");
|
|
||||||
const parsed = {
|
|
||||||
west: finiteNumber(bounds.west, "bounds.west"),
|
|
||||||
south: finiteNumber(bounds.south, "bounds.south"),
|
|
||||||
east: finiteNumber(bounds.east, "bounds.east"),
|
|
||||||
north: finiteNumber(bounds.north, "bounds.north"),
|
|
||||||
};
|
|
||||||
if (!(parsed.west < parsed.east) || !(parsed.south < parsed.north)) fail("bounds ordering is invalid");
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function epochMillis(targetTime: string): number {
|
|
||||||
if (!/^\d{10}$/.test(targetTime)) fail(`target_time must be YYYYMMDDHH: ${targetTime}`);
|
|
||||||
const year = Number(targetTime.slice(0, 4));
|
|
||||||
const month = Number(targetTime.slice(4, 6));
|
|
||||||
const day = Number(targetTime.slice(6, 8));
|
|
||||||
const hour = Number(targetTime.slice(8, 10));
|
|
||||||
const value = Date.UTC(year, month - 1, day, hour, 0, 0, 0);
|
|
||||||
if (!Number.isFinite(value)) fail(`invalid target_time: ${targetTime}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
function assertFields(value: unknown): void {
|
|
||||||
if (!Array.isArray(value) || value.length !== 4) fail("fields contract is invalid");
|
|
||||||
const expected = [["u", "float32"], ["v", "float32"], ["speed", "float32"], ["valid", "uint8"]] as const;
|
|
||||||
expected.forEach(([name, dtype], index) => {
|
|
||||||
const field = objectValue(value[index], `fields[${index}]`);
|
|
||||||
if (field.name !== name || field.dtype !== dtype) fail(`unsupported field ${index}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeWeatherV2WindFrame(
|
|
||||||
source: ArrayBuffer,
|
|
||||||
frame: WeatherV2WindFrameRef,
|
|
||||||
): CanonicalWindField {
|
|
||||||
const bytes = new Uint8Array(source);
|
|
||||||
if (bytes.byteLength < 12) fail("payload is shorter than the WVECBIN1 prefix");
|
|
||||||
if (ascii(bytes.subarray(0, 8)) !== MAGIC) fail("magic mismatch");
|
|
||||||
|
|
||||||
const view = new DataView(source);
|
|
||||||
const headerLength = view.getUint32(8, true);
|
|
||||||
const headerEnd = 12 + headerLength;
|
|
||||||
if (headerEnd > bytes.byteLength) fail("header length exceeds payload length");
|
|
||||||
|
|
||||||
const header = objectValue(JSON.parse(ascii(bytes.subarray(12, headerEnd))), "header");
|
|
||||||
if (header.format !== "weather-v2-particle-bin-v1" || header.layer !== "wind") fail("source is not a WeatherV2 wind frame");
|
|
||||||
if (header.target_time !== frame.targetTime) fail(`target_time ${String(header.target_time)} != ${frame.targetTime}`);
|
|
||||||
assertFields(header.fields);
|
|
||||||
const width = positiveInteger(header.width, "width");
|
|
||||||
const height = positiveInteger(header.height, "height");
|
|
||||||
const bounds = parseBounds(header.bounds);
|
|
||||||
const cellCount = width * height;
|
|
||||||
const uOffset = headerEnd;
|
|
||||||
const vOffset = uOffset + cellCount * FLOAT_BYTES;
|
|
||||||
const speedOffset = vOffset + cellCount * FLOAT_BYTES;
|
|
||||||
const validOffset = speedOffset + cellCount * FLOAT_BYTES;
|
|
||||||
const expectedLength = validOffset + cellCount;
|
|
||||||
if (bytes.byteLength !== expectedLength) fail(`byte length ${bytes.byteLength} != ${expectedLength}`);
|
|
||||||
|
|
||||||
const outputBuffer = new ArrayBuffer(cellCount * 2 * FLOAT_BYTES);
|
|
||||||
const output = new DataView(outputBuffer);
|
|
||||||
for (let cell = 0; cell < cellCount; cell += 1) {
|
|
||||||
const valid = view.getUint8(validOffset + cell) !== 0;
|
|
||||||
const u = view.getFloat32(uOffset + cell * FLOAT_BYTES, true);
|
|
||||||
const v = view.getFloat32(vOffset + cell * FLOAT_BYTES, true);
|
|
||||||
const outputOffset = cell * 2 * FLOAT_BYTES;
|
|
||||||
if (valid && (!Number.isFinite(u) || !Number.isFinite(v))) fail(`non-finite U/V at cell ${cell}`);
|
|
||||||
output.setFloat32(outputOffset, valid ? u : NO_DATA, true);
|
|
||||||
output.setFloat32(outputOffset + FLOAT_BYTES, valid ? v : NO_DATA, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validTime = epochMillis(frame.targetTime);
|
|
||||||
const fieldId = `${frame.datasetId}-wind-${frame.targetTime}`;
|
|
||||||
const payload = new Uint8Array(outputBuffer);
|
|
||||||
const metadataJson = JSON.stringify({
|
|
||||||
schema: "maplibre-particle-field/v1",
|
|
||||||
fieldId,
|
|
||||||
fieldType: "WIND",
|
|
||||||
displayDomain: "GLOBAL",
|
|
||||||
generation: validTime,
|
|
||||||
validTime,
|
|
||||||
dataVersion: validTime,
|
|
||||||
bounds,
|
|
||||||
grid: { width, height },
|
|
||||||
vector: {
|
|
||||||
layout: "interleaved-uv",
|
|
||||||
encoding: "float32-le",
|
|
||||||
noData: NO_DATA,
|
|
||||||
unit: "m/s",
|
|
||||||
origin: "north-west",
|
|
||||||
rowOrder: "north-to-south",
|
|
||||||
columnOrder: "west-to-east",
|
|
||||||
uPositive: "east",
|
|
||||||
vPositive: "north",
|
|
||||||
},
|
|
||||||
payload: { byteLength: payload.byteLength },
|
|
||||||
source: { kind: "WeatherV2 WVECBIN1", model: frame.model, referenceTime: frame.referenceTime, targetTime: frame.targetTime },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { metadataJson, payload, fieldId, validTime, generation: validTime, bounds, width, height, sourceUrl: frame.url };
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"release": "13.4.1-particle.1",
|
"release": "13.4.1-particle.2",
|
||||||
"releaseState": "validated-ready-for-release-tag",
|
"releaseState": "rn-api-prepared-waiting-consumer-validation",
|
||||||
"upstreamMapLibreNative": "13.4.1",
|
"upstreamMapLibreNative": "13.4.1",
|
||||||
"upstreamCoreCommit": "18545ca10d296704b2cba675b182ff2c8da24588",
|
"upstreamCoreCommit": "18545ca10d296704b2cba675b182ff2c8da24588",
|
||||||
"extensionLine": "particle",
|
"extensionLine": "particle",
|
||||||
"extensionRevision": 1,
|
"extensionRevision": 2,
|
||||||
"platform": {
|
"platform": {
|
||||||
"android": {
|
"android": {
|
||||||
"renderer": "opengl"
|
"renderer": "opengl"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"reactNativePackage": {
|
"reactNativePackage": {
|
||||||
"name": "@navsea/maplibre-particle-rn",
|
"name": "@navsea/maplibre-particle-rn",
|
||||||
"version": "13.4.1-particle.1",
|
"version": "13.4.1-particle.2",
|
||||||
"mapLibreReactNative": "11.3.6"
|
"mapLibreReactNative": "11.3.6"
|
||||||
},
|
},
|
||||||
"nativeArtifact": {
|
"nativeArtifact": {
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"artifact": "android-sdk-particle-opengl",
|
"artifact": "android-sdk-particle-opengl",
|
||||||
"version": "13.4.1-particle.1"
|
"version": "13.4.1-particle.1"
|
||||||
},
|
},
|
||||||
|
"releaseRelationship": "13.4.1-particle.2 仅变更 RN/TS Field Renderer 公共接口;Native artifact 保持 13.4.1-particle.1,未构建、修改或发布 AAR。",
|
||||||
"canonicalField": {
|
"canonicalField": {
|
||||||
"schema": "maplibre-particle-field/v1",
|
"schema": "maplibre-particle-field/v1",
|
||||||
"transport": "metadataJson + local file:// payload",
|
"transport": "metadataJson + local file:// payload",
|
||||||
@@ -28,6 +29,13 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"androidPackageConsumer": {
|
"androidPackageConsumer": {
|
||||||
|
"status": "pending",
|
||||||
|
"targetReactNativePackage": "13.4.1-particle.2",
|
||||||
|
"consumer": "integration/react-native/MapLibreParticlePackageConsumerTestHost",
|
||||||
|
"note": "13.4.1-particle.2 尚未完成发布 tarball 的 Consumer 运行验证。"
|
||||||
|
},
|
||||||
|
"previousBaseline": {
|
||||||
|
"reactNativePackage": "13.4.1-particle.1",
|
||||||
"status": "pass",
|
"status": "pass",
|
||||||
"validatedAt": "2026-09-09",
|
"validatedAt": "2026-09-09",
|
||||||
"consumer": "integration/react-native/MapLibreParticlePackageConsumerTestHost",
|
"consumer": "integration/react-native/MapLibreParticlePackageConsumerTestHost",
|
||||||
|
|||||||
@@ -600,3 +600,10 @@ WeatherColorLayer 的 `ParticleGpuValidationUBO` designated initializer 补齐 `
|
|||||||
- 实际 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` 正式契约。
|
- 实际 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。
|
- 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/实体机验证。
|
- `npm pack` 与 package TypeScript/static audit 完成;未执行 Android Gradle/AAR/APK/ADB/Jest,未决定 registry,未创建 release tag。下一步为用户手工 AAR publication 与 tarball Consumer APK/实体机验证。
|
||||||
|
|
||||||
|
# 2026-09-10 — STEP-064 RN 纯 Field Renderer 接口 V2(IMPLEMENTED / WAITING_CONSUMER_VALIDATION)
|
||||||
|
|
||||||
|
- `@navsea/maplibre-particle-rn` 升为 `13.4.1-particle.2`,根入口只提供 `FieldRuntime`、纯类型与 field style fragment;旧 Weather/Wind 公共 API、MLRN facade re-export 与兼容 wrapper 均删除。
|
||||||
|
- 包内 WeatherV2 HTTP、manifest、time index、decoder 与 loader 已移出,Consumer Test Host 保留自身的真实 fixture/file URI 业务链,并直接从官方 MLRN 导入 Map/Camera。
|
||||||
|
- `FieldColorStop.value` 在 RN 薄层转换为冻结 Native ABI;无 Native module 时 import 安全,实际调用明确失败。图场与粒子继续共享一次 canonical Field 提交,显示可独立控制。
|
||||||
|
- Native/Core/JNI/shader/Android AAR 未改,artifact 继续 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1`;Android Gradle/AAR/APK/ADB/Jest/NavSea 均未执行。package 与 Consumer TypeScript 检查已通过,发布 tarball 的实际 Consumer 验证待后续完成。
|
||||||
|
|||||||
Reference in New Issue
Block a user