Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc7187de2 | ||
|
|
d013f34493 |
@@ -0,0 +1,19 @@
|
|||||||
|
# 三维场景静态资源下载与缓存
|
||||||
|
|
||||||
|
`public/three-dimensional/zjb/v29/resourceCache.mjs` 统一加载模型和管网静态元数据,不缓存实时仿真或 SCADA 数据。
|
||||||
|
|
||||||
|
- 每次请求最多下载 1 MiB,通过单区间 `Range` 请求获取资源。服务器返回 `206` 时,校验 `Content-Range` 的起止位置、总长度以及实际收到的字节数;不支持 Range 的服务器可在首次请求返回完整的 `200` 响应。
|
||||||
|
- 网络中断、连续 30 秒无数据以及临时 HTTP 错误可重试,每个分段最多尝试 3 次,等待间隔为 300、600 毫秒。永久 HTTP 错误和不合法的分段响应直接显示失败提示。
|
||||||
|
- 每个完整分段保存至 IndexedDB。刷新页面或重新载入后,从已保存的分段末尾继续;未完成的当前分段会重新下载。浏览器禁止存储或配额不足时,仍能在本次加载中下载和重试,但无法保证刷新后保留断点。
|
||||||
|
- 完整资源和部分下载均按 URL、manifest revision 匹配,版本改变后重新下载。完整资源经过解析验证后才进入正式缓存,后续打开场景直接复用。
|
||||||
|
- 进度界面显示下载、解析和断点重试状态。加载模块版本参数需要与 `preview.html`、`preview.mjs` 和 `ThreeDimensionalScene.tsx` 同步,防止浏览器沿用旧加载器。
|
||||||
|
|
||||||
|
反向代理应透传 `Range` 请求及 `206`、`Content-Range` 响应。若内网完整下载正常,而公网完整下载中断,应同时排查代理的大文件传输;客户端分段恢复不代表代理链路问题已修复。
|
||||||
|
|
||||||
|
聚焦回归:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test e2e/three-dimensional-loading.spec.ts --workers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令需要已运行的前端。测试覆盖完整缓存复用、分段重试、跨刷新续传、错误分段拒绝及浏览器存储不可用的降级行为。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
const root = "/three-dimensional/zjb/v29/";
|
const root = "/three-dimensional/zjb/v29/";
|
||||||
const preview = root + "preview.html?host=platform2&v=35-resource-cache";
|
const preview = root + "preview.html?host=platform2&v=36-resumable-cache";
|
||||||
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
|
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
@@ -28,7 +28,7 @@ test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试
|
|||||||
expect(cached).toBe(false);
|
expect(cached).toBe(false);
|
||||||
await page.getByRole("button", { name: "重新载入场景" }).click();
|
await page.getByRole("button", { name: "重新载入场景" }).click();
|
||||||
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||||
expect(requests).toBe(2);
|
expect(requests).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
|
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
|
||||||
@@ -46,7 +46,7 @@ test("模型和元数据跨页面复用持久缓存,即使服务器资源不
|
|||||||
await page.goto(preview);
|
await page.goto(preview);
|
||||||
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||||
const coldMs = Date.now() - coldStart;
|
const coldMs = Date.now() - coldStart;
|
||||||
expect(requests).toHaveLength(10);
|
expect(new Set(requests).size).toBe(10);
|
||||||
requests.length = 0;
|
requests.length = 0;
|
||||||
block = true;
|
block = true;
|
||||||
await page.close();
|
await page.close();
|
||||||
@@ -140,7 +140,7 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复",
|
|||||||
await page.route("**/cache-probe.json*", (route) => {
|
await page.route("**/cache-probe.json*", (route) => {
|
||||||
requests++;
|
requests++;
|
||||||
return requests === 1
|
return requests === 1
|
||||||
? route.fulfill({ status: 503, body: "unavailable" })
|
? route.fulfill({ status: 404, body: "not found" })
|
||||||
: requests === 2
|
: requests === 2
|
||||||
? route.fulfill({ body: "invalid json" })
|
? route.fulfill({ body: "invalid json" })
|
||||||
: route.fulfill({ json: { ok: true } });
|
: route.fulfill({ json: { ok: true } });
|
||||||
@@ -160,6 +160,145 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复",
|
|||||||
}, root);
|
}, root);
|
||||||
expect(requests).toBe(3);
|
expect(requests).toBe(3);
|
||||||
expect(result.errors).toHaveLength(2);
|
expect(result.errors).toHaveLength(2);
|
||||||
expect(result.errors[0]).toContain("HTTP 503");
|
expect(result.errors[0]).toContain("HTTP 404");
|
||||||
expect(result.recovered).toEqual({ ok: true });
|
expect(result.recovered).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("公网整文件中断时分段下载,重试失败分段并缓存完整模型", async ({ page }) => {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const { createHash } = await import("node:crypto");
|
||||||
|
const bytes = await readFile("public/three-dimensional/zjb/v29/models/detail_interior.glb");
|
||||||
|
const ranges: string[] = [];
|
||||||
|
let interrupted = false;
|
||||||
|
await page.route("**/models/detail_interior.glb*", async (route) => {
|
||||||
|
const range = route.request().headers().range;
|
||||||
|
ranges.push(range ?? "whole-file");
|
||||||
|
// The public proxy drops whole-file responses, and one partial request drops too.
|
||||||
|
if (!range) return route.abort("failed");
|
||||||
|
if (range.startsWith("bytes=1048576-") && !interrupted) {
|
||||||
|
interrupted = true;
|
||||||
|
return route.abort("failed");
|
||||||
|
}
|
||||||
|
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const end = Math.min(Number(match[2]), bytes.length - 1);
|
||||||
|
return route.fulfill({ status: 206, headers: {
|
||||||
|
"content-type": "model/gltf-binary",
|
||||||
|
"content-range": `bytes ${start}-${end}/${bytes.length}`,
|
||||||
|
}, body: bytes.subarray(start, end + 1) });
|
||||||
|
});
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const result = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
const load = () => createSceneResourceCache(location.origin + base).load("models/detail_interior.glb", "range-recovery");
|
||||||
|
const data = await load();
|
||||||
|
const cached = await load();
|
||||||
|
return { length: data.byteLength, cachedLength: cached.byteLength,
|
||||||
|
sha256: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), n => n.toString(16).padStart(2, "0")).join("") };
|
||||||
|
}, root);
|
||||||
|
expect(result).toEqual({ length: bytes.length, cachedLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") });
|
||||||
|
expect(ranges).toHaveLength(7);
|
||||||
|
expect(ranges[0]).toBe("bytes=0-1048575");
|
||||||
|
expect(ranges[1]).toBe(ranges[2]);
|
||||||
|
expect(interrupted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const fault of ["offset", "total", "truncated", "whole-response"] as const) {
|
||||||
|
test(`无效分段响应 ${fault} 不会进入缓存,恢复后可重新下载`, async ({ page }) => {
|
||||||
|
const bytes = Buffer.alloc(1048576 + 16, 7);
|
||||||
|
let broken = true;
|
||||||
|
let requests = 0;
|
||||||
|
await page.route("**/range-probe.bin*", (route) => {
|
||||||
|
requests++;
|
||||||
|
const range = route.request().headers().range;
|
||||||
|
if (!range) return route.abort("failed");
|
||||||
|
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const end = Math.min(Number(match[2]), bytes.length - 1);
|
||||||
|
if (broken && start > 0 && fault === "whole-response") return route.fulfill({ body: bytes });
|
||||||
|
return route.fulfill({ status: 206, headers: {
|
||||||
|
"content-range": `bytes ${broken && start > 0 && fault === "offset" ? 0 : start}-${end}/${bytes.length + (broken && start > 0 && fault === "total" ? 1 : 0)}`,
|
||||||
|
}, body: bytes.subarray(start, end + (broken && start > 0 && fault === "truncated" ? 0 : 1)) });
|
||||||
|
});
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const failed = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
try { await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first"); return false; }
|
||||||
|
catch { return true; }
|
||||||
|
}, root);
|
||||||
|
expect(failed).toBe(true);
|
||||||
|
const before = requests;
|
||||||
|
broken = false;
|
||||||
|
const length = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
return (await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first")).byteLength;
|
||||||
|
}, root);
|
||||||
|
expect(length).toBe(bytes.length);
|
||||||
|
// The valid first segment survives; only the rejected final segment is fetched again.
|
||||||
|
expect(requests - before).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("下载中断后刷新页面,从已保存的分段继续下载", async ({ page }) => {
|
||||||
|
const bytes = Buffer.alloc(2 * 1048576 + 16, 9);
|
||||||
|
let disconnected = true;
|
||||||
|
const ranges: string[] = [];
|
||||||
|
await page.route("**/resume-probe.bin*", (route) => {
|
||||||
|
const range = route.request().headers().range;
|
||||||
|
ranges.push(range ?? "whole-file");
|
||||||
|
if (!range) return route.abort("failed");
|
||||||
|
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const end = Math.min(Number(match[2]), bytes.length - 1);
|
||||||
|
if (disconnected && start > 0) return route.abort("failed");
|
||||||
|
return route.fulfill({ status: 206, headers: { "content-range": `bytes ${start}-${end}/${bytes.length}` }, body: bytes.subarray(start, end + 1) });
|
||||||
|
});
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const load = (base: string) => page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
try {
|
||||||
|
const data = await createSceneResourceCache(location.origin + base).load("resume-probe.bin", "first");
|
||||||
|
return { length: data.byteLength, failed: false };
|
||||||
|
} catch { return { failed: true }; }
|
||||||
|
}, base);
|
||||||
|
expect(await load(root)).toEqual({ failed: true });
|
||||||
|
expect(ranges.filter(range => range === "bytes=1048576-2097151")).toHaveLength(3);
|
||||||
|
disconnected = false;
|
||||||
|
ranges.length = 0;
|
||||||
|
await page.reload();
|
||||||
|
expect(await load(root)).toEqual({ length: bytes.length, failed: false });
|
||||||
|
expect(ranges).toEqual(["bytes=1048576-2097151", "bytes=2097152-2097167"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("临时 HTTP 503 自动重试,成功后才写入缓存", async ({ page }) => {
|
||||||
|
let requests = 0;
|
||||||
|
await page.route("**/retry-probe.json*", (route) => {
|
||||||
|
requests++;
|
||||||
|
return requests < 3 ? route.fulfill({ status: 503, body: "unavailable" }) : route.fulfill({ json: { ok: true } });
|
||||||
|
});
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const result = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
const stages: string[] = [];
|
||||||
|
const cache = createSceneResourceCache(location.origin + base, ({ stage }: { stage: string }) => stages.push(stage));
|
||||||
|
const data = await cache.load("retry-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
|
||||||
|
return { data, retries: stages.filter(stage => stage === "retry").length };
|
||||||
|
}, root);
|
||||||
|
expect(result).toEqual({ data: { ok: true }, retries: 2 });
|
||||||
|
expect(requests).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("服务器返回空的完整响应时只解析一次,不循环下载", async ({ page }) => {
|
||||||
|
let requests = 0;
|
||||||
|
await page.route("**/empty-probe.json*", (route) => { requests++; return route.fulfill({ body: "" }); });
|
||||||
|
await page.goto(root + "manifest.json");
|
||||||
|
const failed = await page.evaluate(async (base) => {
|
||||||
|
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
|
||||||
|
try {
|
||||||
|
await createSceneResourceCache(location.origin + base).load("empty-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
|
||||||
|
return false;
|
||||||
|
} catch { return true; }
|
||||||
|
}, root);
|
||||||
|
expect(failed).toBe(true);
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ export function reportProgress(update) {
|
|||||||
const done = values.filter(item => item.stage === 'ready').length;
|
const done = values.filter(item => item.stage === 'ready').length;
|
||||||
const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
|
const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
|
||||||
const cached = values.filter(item => item.stage === 'cache').length;
|
const cached = values.filter(item => item.stage === 'cache').length;
|
||||||
|
const retrying = values.some(item => item.stage === 'retry');
|
||||||
const parsing = values.some(item => item.stage === 'parse');
|
const parsing = values.some(item => item.stage === 'parse');
|
||||||
const downloading = values.some(item => item.stage === 'download');
|
const downloading = values.some(item => item.stage === 'download');
|
||||||
progress.max = Math.max(1, values.length);
|
progress.max = Math.max(1, values.length);
|
||||||
progress.value = done;
|
progress.value = done;
|
||||||
title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
|
title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
|
||||||
detail.textContent = `${parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
|
detail.textContent = `${retrying ? '连接中断,正在从断点重试' : parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finishLoading() {
|
export function finishLoading() {
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<output id="qa" hidden></output>
|
<output id="qa" hidden></output>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import {failLoading} from './loadingProgress.mjs?v=35-resource-cache';
|
import {failLoading} from './loadingProgress.mjs?v=36-resumable-cache';
|
||||||
import('./preview.mjs?v=35-resource-cache').catch(failLoading);
|
import('./preview.mjs?v=36-resumable-cache').catch(failLoading);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {createRenderEffects} from './render-effects.mjs';
|
import {createRenderEffects} from './render-effects.mjs';
|
||||||
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=35-resource-cache';
|
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=36-resumable-cache';
|
||||||
import {createSceneResourceCache} from './resourceCache.mjs?v=35-resource-cache';
|
import {createSceneResourceCache} from './resourceCache.mjs?v=36-resumable-cache';
|
||||||
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=35-resource-cache';
|
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=36-resumable-cache';
|
||||||
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
|
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
const DATABASE = 'tjwater-scene-assets-v1';
|
const DATABASE = 'tjwater-scene-assets-v1';
|
||||||
const STORE = 'resources';
|
const STORE = 'resources';
|
||||||
const REQUEST_TIMEOUT_MS = 30_000;
|
const REQUEST_TIMEOUT_MS = 30_000;
|
||||||
|
const CHUNK_BYTES = 1024 * 1024;
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
function openDatabase() {
|
function openDatabase() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
@@ -54,39 +56,111 @@ export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
|
|||||||
|
|
||||||
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
|
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
|
||||||
if (revision) url.searchParams.set('v', revision);
|
if (revision) url.searchParams.set('v', revision);
|
||||||
|
const partialKey = key + '#partial';
|
||||||
|
let chunks = [], loaded = 0, total = 0;
|
||||||
|
if (storage && revision) {
|
||||||
|
try {
|
||||||
|
const partial = await transact(storage, 'readonly', store => store.get(partialKey));
|
||||||
|
if (partial?.revision === revision && Number.isSafeInteger(partial.total) && partial.total > 0 &&
|
||||||
|
Array.isArray(partial.chunks) && partial.chunks.length &&
|
||||||
|
partial.chunks.every(chunk => chunk instanceof ArrayBuffer && chunk.byteLength > 0 && chunk.byteLength <= CHUNK_BYTES)) {
|
||||||
|
const size = partial.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||||
|
if (size <= partial.total) { chunks = partial.chunks; loaded = size; total = partial.total; }
|
||||||
|
}
|
||||||
|
} catch { /* A denied partial cache does not prevent downloading. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPart() {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timer;
|
let timer;
|
||||||
const resetTimeout = () => {
|
const resetTimeout = () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
};
|
};
|
||||||
report('download');
|
|
||||||
resetTimeout();
|
resetTimeout();
|
||||||
let data;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {signal:controller.signal, cache:'no-cache'});
|
const end = total ? Math.min(loaded + CHUNK_BYTES, total) - 1 : loaded + CHUNK_BYTES - 1;
|
||||||
if (!response.ok) throw Error('HTTP ' + response.status);
|
const response = await fetch(url, {signal:controller.signal, cache:'no-cache', headers:{Range:`bytes=${loaded}-${end}`}});
|
||||||
const total = Number(response.headers.get('content-length')) || 0;
|
if (!response.ok) {
|
||||||
|
const error = Error('HTTP ' + response.status);
|
||||||
|
error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
let partTotal = 0, expected = 0;
|
||||||
|
if (response.status === 206) {
|
||||||
|
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
|
||||||
|
const start = Number(range?.[1]), last = Number(range?.[2]);
|
||||||
|
partTotal = Number(range?.[3]);
|
||||||
|
if (!range || start !== loaded || last < start || last > end || last >= partTotal ||
|
||||||
|
!Number.isSafeInteger(partTotal) || (total && total !== partTotal)) {
|
||||||
|
throw Error('资源分段响应无效');
|
||||||
|
}
|
||||||
|
expected = last - start + 1;
|
||||||
|
} else {
|
||||||
|
if (loaded) throw Error('服务器未按断点返回资源');
|
||||||
|
// Servers without Range support can still return an ordinary complete response.
|
||||||
|
partTotal = Number(response.headers.get('content-length')) || 0;
|
||||||
|
}
|
||||||
|
const buffers = [];
|
||||||
|
let size = 0;
|
||||||
if (response.body) {
|
if (response.body) {
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const chunks = [];
|
try {
|
||||||
let loaded = 0;
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const {done, value} = await reader.read();
|
const {done, value} = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
chunks.push(value); loaded += value.byteLength;
|
buffers.push(value); size += value.byteLength;
|
||||||
report('download', loaded, total); resetTimeout();
|
if (expected && size > expected) throw Error('资源分段长度无效');
|
||||||
|
report('download', loaded + size, partTotal); resetTimeout();
|
||||||
|
}
|
||||||
|
} finally { await reader.cancel().catch(() => {}); }
|
||||||
|
} else {
|
||||||
|
const buffer = new Uint8Array(await response.arrayBuffer());
|
||||||
|
buffers.push(buffer); size = buffer.byteLength;
|
||||||
|
}
|
||||||
|
if (expected && size !== expected) throw Error('资源分段不完整');
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
|
let offset = 0;
|
||||||
|
for (const buffer of buffers) { bytes.set(buffer, offset); offset += buffer.byteLength; }
|
||||||
|
return {data:bytes.buffer, total:response.status === 206 ? partTotal : size, ranged:response.status === 206};
|
||||||
|
} catch (error) {
|
||||||
|
controller.abort();
|
||||||
|
throw error;
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
report('download', loaded, total);
|
||||||
|
try {
|
||||||
|
while (!total || loaded < total) {
|
||||||
|
let part;
|
||||||
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||||
|
try { part = await downloadPart(); break; }
|
||||||
|
catch (error) {
|
||||||
|
// Permanent HTTP/contract errors stop; temporary transport/server errors can recover.
|
||||||
|
if (!error.retryable && !(error instanceof TypeError) && error.name !== 'AbortError' && error.message !== '资源分段不完整') throw error;
|
||||||
|
if (attempt + 1 === MAX_ATTEMPTS) throw error;
|
||||||
|
report('retry', loaded, total);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunks.push(part.data); loaded += part.data.byteLength; total = part.total;
|
||||||
|
if (storage && revision && part.ranged) {
|
||||||
|
try { await transact(storage, 'readwrite', store => store.put({url:partialKey, revision, total, chunks})); }
|
||||||
|
catch { /* Storage denied/full: retain the in-memory download. */ }
|
||||||
|
}
|
||||||
|
if (!part.ranged) break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw Error(file + ':' + (error.name === 'AbortError' ? '下载超时,请重试' : error.message));
|
||||||
}
|
}
|
||||||
const bytes = new Uint8Array(loaded);
|
const bytes = new Uint8Array(loaded);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
for (const chunk of chunks) { bytes.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; }
|
||||||
data = bytes.buffer;
|
const data = bytes.buffer;
|
||||||
} else {
|
if (storage) {
|
||||||
data = await response.arrayBuffer();
|
try { await transact(storage, 'readwrite', store => store.delete(partialKey)); }
|
||||||
|
catch { /* Completed bytes can still be parsed when storage is unavailable. */ }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message));
|
|
||||||
} finally { clearTimeout(timer); }
|
|
||||||
|
|
||||||
report('parse', data.byteLength, data.byteLength);
|
report('parse', data.byteLength, data.byteLength);
|
||||||
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
|
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns";
|
||||||
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
|
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
|
||||||
|
|
||||||
jest.mock("@/lib/apiFetch", () => ({
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
apiFetch: jest.fn(),
|
apiFetch: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
jest.mock("@/lib/analysisRuns", () => ({
|
||||||
|
...jest.requireActual("@/lib/analysisRuns"),
|
||||||
|
getAnalysisScheme: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("@components/olmap/core/MapComponent", () => ({
|
jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||||
useData: () => null,
|
useData: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@mui/x-date-pickers", () => ({
|
jest.mock("@mui/x-date-pickers", () => ({
|
||||||
DateTimePicker: () => null,
|
DateTimePicker: ({ label, value, onChange }: {
|
||||||
|
label: string; value: dayjs.Dayjs; onChange: (value: dayjs.Dayjs) => void;
|
||||||
|
}) => React.createElement("input", {
|
||||||
|
"aria-label": label,
|
||||||
|
value: value.toISOString(),
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(dayjs(event.target.value)),
|
||||||
|
}),
|
||||||
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
|
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
}));
|
}));
|
||||||
jest.mock("echarts-for-react", () => () => null);
|
jest.mock("echarts-for-react", () => jest.fn(() => null));
|
||||||
|
|
||||||
const jsonResponse = (payload: unknown, status = 200) =>
|
const jsonResponse = (payload: unknown, status = 200) =>
|
||||||
({
|
({
|
||||||
@@ -66,14 +79,63 @@ describe("fetchHistoryData", () => {
|
|||||||
"99dd4142-368b-54cb-bfca-d59ee48f6298",
|
"99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||||
const [, init] = jest.mocked(apiFetch).mock.calls[0];
|
const bodies = jest.mocked(apiFetch).mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
|
||||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
expect(bodies[0]).toMatchObject({ mode: "realtime_comparison" });
|
||||||
|
expect(bodies[0]).not.toHaveProperty("run_id");
|
||||||
|
expect(bodies[1]).toMatchObject({
|
||||||
mode: "analysis_comparison",
|
mode: "analysis_comparison",
|
||||||
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
|
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("combines element simulations without SCADA and deduplicates monitoring", async () => {
|
||||||
|
const base = {
|
||||||
|
element_id: "P-1", element_type: "pipe", device_id: null,
|
||||||
|
metric: "flow", source_unit: "L/s", display_unit: "m³/h", unit_inferred: false,
|
||||||
|
};
|
||||||
|
const observed = {
|
||||||
|
...base, device_id: "F-1", source: "scada_raw",
|
||||||
|
points: [{ time: "2026-08-17T09:30:00Z", value: 5 }],
|
||||||
|
};
|
||||||
|
const targets: [string, string][] = [["P-1", "pipe"], ["J-1", "junction"]];
|
||||||
|
jest.mocked(apiFetch).mockImplementation(async (_url, init) => {
|
||||||
|
const { mode } = JSON.parse(String(init?.body));
|
||||||
|
return jsonResponse({ series: mode === "analysis_comparison" ? [
|
||||||
|
observed,
|
||||||
|
{ ...base, source: "analysis_simulation", points: [
|
||||||
|
{ time: "2026-08-17T09:30:00Z", value: 20 },
|
||||||
|
] },
|
||||||
|
] : [
|
||||||
|
observed,
|
||||||
|
{ ...base, source: "realtime_simulation", points: [
|
||||||
|
{ time: "2026-08-17T09:00:00Z", value: 10 },
|
||||||
|
{ time: "2026-08-17T10:00:00Z", value: 12 },
|
||||||
|
] },
|
||||||
|
{ ...base, element_id: "J-1", element_type: "junction", metric: "pressure",
|
||||||
|
source_unit: "m", source: "realtime_simulation", points: [
|
||||||
|
{ time: "2026-08-17T09:00:00Z", value: 30 },
|
||||||
|
] },
|
||||||
|
] });
|
||||||
|
});
|
||||||
|
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
|
||||||
|
expect(result.series).toHaveLength(4);
|
||||||
|
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 });
|
||||||
|
expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72);
|
||||||
|
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2);
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||||
|
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
|
||||||
|
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an aggregate request failure instead of showing an incomplete comparison", async () => {
|
||||||
|
jest.mocked(apiFetch).mockResolvedValueOnce(jsonResponse({ series: [] }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ detail: "analysis unavailable" }, 503));
|
||||||
|
await expect(fetchHistoryData([["P-1", "pipe"]], range, "scheme", "run-1"))
|
||||||
|
.rejects.toThrow("analysis unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects oversized element selections before issuing requests", async () => {
|
it("rejects oversized element selections before issuing requests", async () => {
|
||||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({}));
|
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({}));
|
||||||
const featureInfos = Array.from(
|
const featureInfos = Array.from(
|
||||||
@@ -103,10 +165,18 @@ describe("fetchHistoryData", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const eventRun = (runId = "run-1", startedAt = "2026-08-17T09:00:00Z") =>
|
||||||
|
toAnalysisSchemeRecord({
|
||||||
|
run_id: runId, name: "event", run_type: "burst_analysis", created_by: "alice",
|
||||||
|
created_at: "2026-11-03T00:00:00Z", started_at: startedAt,
|
||||||
|
status: "completed", parameters: { modify_total_duration: 3600 },
|
||||||
|
});
|
||||||
|
|
||||||
describe("HistoryDataPanel requests", () => {
|
describe("HistoryDataPanel requests", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||||
|
jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => jest.clearAllMocks());
|
afterEach(() => jest.clearAllMocks());
|
||||||
@@ -137,4 +207,138 @@ describe("HistoryDataPanel requests", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
featureInfos: [["P-1", "pipe"]] as [string, string][],
|
||||||
|
type: "scheme" as const, runId: "run-1", onClose: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("initializes a historical event with one hour of context on both sides", async () => {
|
||||||
|
render(React.createElement(HistoryDataPanel, props));
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
|
||||||
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||||
|
start_time: "2026-08-17T08:00:00.000Z", end_time: "2026-08-17T11:00:00.000Z",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(screen.getByText(/事件方案时段:2026-08-17 17:00/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("开始时间")).toHaveValue("2026-08-17T08:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit query dates and issues one pair of requests per date change", async () => {
|
||||||
|
render(React.createElement(HistoryDataPanel, {
|
||||||
|
...props, start_time: "2026-08-17T07:00:00Z", end_time: "2026-08-17T12:00:00Z",
|
||||||
|
}));
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
fireEvent.change(screen.getByLabelText("开始时间"), { target: { value: "2026-08-17T06:00:00Z" } });
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4));
|
||||||
|
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({
|
||||||
|
start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
|
||||||
|
expect(getAnalysisScheme).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a stale scheme response after switching events", async () => {
|
||||||
|
let resolveOld!: (value: ReturnType<typeof eventRun>) => void;
|
||||||
|
jest.mocked(getAnalysisScheme).mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; }))
|
||||||
|
.mockResolvedValueOnce(eventRun("run-2", "2026-08-18T09:00:00Z"));
|
||||||
|
const { rerender } = render(React.createElement(HistoryDataPanel, props));
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
|
rerender(React.createElement(HistoryDataPanel, { ...props, runId: "run-2" }));
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
await act(async () => { resolveOld(eventRun()); });
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toMatchObject({
|
||||||
|
run_id: "run-2", start_time: "2026-08-18T08:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the manually chosen end date when only the start date prop changes", async () => {
|
||||||
|
const partialProps = { ...props, type: "none" as const, start_time: "2026-08-17T07:00:00Z" };
|
||||||
|
const { rerender } = render(React.createElement(HistoryDataPanel, partialProps));
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
|
||||||
|
fireEvent.change(screen.getByLabelText("结束时间"), { target: { value: "2026-08-17T12:00:00Z" } });
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
rerender(React.createElement(HistoryDataPanel, { ...partialProps, start_time: "2026-08-17T06:00:00Z" }));
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(3));
|
||||||
|
expect(screen.getByLabelText("结束时间")).toHaveValue("2026-08-17T12:00:00.000Z");
|
||||||
|
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({
|
||||||
|
start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries online element readings for an analysis without hydraulic results", async () => {
|
||||||
|
jest.mocked(getAnalysisScheme).mockResolvedValue({ ...eventRun(), run_type: "burst_detection" });
|
||||||
|
render(React.createElement(HistoryDataPanel, props));
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
|
||||||
|
const body = JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body));
|
||||||
|
expect(body.mode).toBe("realtime_comparison");
|
||||||
|
expect(body).not.toHaveProperty("run_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore stale curves after the selected elements are cleared", async () => {
|
||||||
|
const pending: Array<(value: Response) => void> = [];
|
||||||
|
jest.mocked(apiFetch).mockImplementation(() => new Promise((resolve) => { pending.push(resolve); }));
|
||||||
|
const { rerender } = render(React.createElement(HistoryDataPanel, props));
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
rerender(React.createElement(HistoryDataPanel, { ...props, featureInfos: [] }));
|
||||||
|
expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
|
||||||
|
expect(jest.mocked(apiFetch).mock.calls[1][1]?.signal?.aborted).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
pending[0](jsonResponse({ series: [{
|
||||||
|
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
|
||||||
|
source: "realtime_simulation", source_unit: "m3/h", display_unit: "m³/h",
|
||||||
|
unit_inferred: false, points: [{ time: "2026-08-17T09:00:00Z", value: 10 }],
|
||||||
|
}] }));
|
||||||
|
pending[1](jsonResponse({ series: [] }));
|
||||||
|
});
|
||||||
|
expect(ReactECharts).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("尚未选择地图要素")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a failed event metadata query to be retried", async () => {
|
||||||
|
jest.mocked(getAnalysisScheme).mockRejectedValueOnce(new Error("unavailable"));
|
||||||
|
render(React.createElement(HistoryDataPanel, props));
|
||||||
|
await screen.findByText(/无法读取事件方案/);
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plots native timestamps and explicit gaps with the event interval marked", async () => {
|
||||||
|
const series = {
|
||||||
|
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
|
||||||
|
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
|
||||||
|
};
|
||||||
|
jest.mocked(apiFetch).mockImplementation(async (_url, init) => {
|
||||||
|
const { mode } = JSON.parse(String(init?.body));
|
||||||
|
return jsonResponse({ series: [{ ...series,
|
||||||
|
source: mode === "analysis_comparison" ? "analysis_simulation" : "realtime_simulation",
|
||||||
|
points: mode === "analysis_comparison" ? [
|
||||||
|
{ time: "2026-08-17T09:15:00Z", value: 8 },
|
||||||
|
] : [
|
||||||
|
{ time: "2026-08-17T08:00:00Z", value: 10 },
|
||||||
|
{ time: "2026-08-17T09:00:00Z", value: null },
|
||||||
|
{ time: "2026-08-17T11:00:00Z", value: 12 },
|
||||||
|
],
|
||||||
|
}] });
|
||||||
|
});
|
||||||
|
render(React.createElement(HistoryDataPanel, props));
|
||||||
|
await waitFor(() => expect(ReactECharts).toHaveBeenCalled());
|
||||||
|
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
|
||||||
|
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
|
||||||
|
expect(option.series[0].connectNulls).toBe(false);
|
||||||
|
expect(option.series[0].data).toEqual([
|
||||||
|
[Date.parse("2026-08-17T08:00:00Z"), 10],
|
||||||
|
[Date.parse("2026-08-17T09:00:00Z"), null],
|
||||||
|
[Date.parse("2026-08-17T11:00:00Z"), 12],
|
||||||
|
]);
|
||||||
|
expect(option.series[0].markArea.data[0]).toEqual([
|
||||||
|
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
|
||||||
|
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
|
||||||
|
]);
|
||||||
|
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,8 +41,14 @@ import {
|
|||||||
fetchElementHistory,
|
fetchElementHistory,
|
||||||
historySeriesKey,
|
historySeriesKey,
|
||||||
historySeriesLabel,
|
historySeriesLabel,
|
||||||
|
toTimeSeriesPoints,
|
||||||
TimeSeriesPoint,
|
TimeSeriesPoint,
|
||||||
} from "@/lib/elementHistory";
|
} from "@/lib/elementHistory";
|
||||||
|
import {
|
||||||
|
type AnalysisHistoryContext,
|
||||||
|
getAnalysisHistoryContext,
|
||||||
|
getAnalysisScheme,
|
||||||
|
} from "@/lib/analysisRuns";
|
||||||
import {
|
import {
|
||||||
FLOW_DISPLAY_UNIT,
|
FLOW_DISPLAY_UNIT,
|
||||||
PRESSURE_DISPLAY_UNIT,
|
PRESSURE_DISPLAY_UNIT,
|
||||||
@@ -54,7 +60,7 @@ dayjs.extend(timezone);
|
|||||||
export interface SCADADataPanelProps {
|
export interface SCADADataPanelProps {
|
||||||
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
||||||
featureInfos: [string, string][];
|
featureInfos: [string, string][];
|
||||||
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
|
/** 数据类型: realtime-在线模拟与监测, none-仅监测, scheme-方案、在线模拟与监测 */
|
||||||
type?: "realtime" | "scheme" | "none";
|
type?: "realtime" | "scheme" | "none";
|
||||||
/** 默认展示的选项卡 */
|
/** 默认展示的选项卡 */
|
||||||
defaultTab?: "chart" | "table";
|
defaultTab?: "chart" | "table";
|
||||||
@@ -107,6 +113,13 @@ export const fetchHistoryData = async (
|
|||||||
) {
|
) {
|
||||||
throw new Error("历史数据包含无效的管网元素 ID");
|
throw new Error("历史数据包含无效的管网元素 ID");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
featureInfos.some(
|
||||||
|
([, elementType]) => !["pipe", "junction"].includes(elementType.toLowerCase()),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("历史数据仅支持管道和节点");
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueFeatureInfos = Array.from(
|
const uniqueFeatureInfos = Array.from(
|
||||||
new Map(
|
new Map(
|
||||||
@@ -118,25 +131,33 @@ export const fetchHistoryData = async (
|
|||||||
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
|
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const elements = uniqueFeatureInfos.map(([id, elementType]) => ({
|
||||||
return await fetchElementHistory(
|
|
||||||
uniqueFeatureInfos.map(([id, elementType]) => ({
|
|
||||||
element_id: id.trim(),
|
element_id: id.trim(),
|
||||||
element_type: elementType.toLowerCase() as "pipe" | "junction",
|
element_type: elementType.toLowerCase() as "pipe" | "junction",
|
||||||
})),
|
}));
|
||||||
|
if (type === "scheme") {
|
||||||
|
const [online, analysis] = await Promise.all([
|
||||||
|
fetchElementHistory(
|
||||||
|
elements, range, "realtime_comparison", undefined, signal,
|
||||||
|
),
|
||||||
|
fetchElementHistory(
|
||||||
|
elements, range, "analysis_comparison", schemeRunId, signal,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
// 两次聚合查询均返回监测序列,只保留在线查询中的一份。
|
||||||
|
const series = [
|
||||||
|
...online.series,
|
||||||
|
...analysis.series.filter((item) => item.source === "analysis_simulation"),
|
||||||
|
];
|
||||||
|
return { series, points: toTimeSeriesPoints(series) };
|
||||||
|
}
|
||||||
|
return fetchElementHistory(
|
||||||
|
elements,
|
||||||
range,
|
range,
|
||||||
type === "none"
|
type === "none" ? "observed" : "realtime_comparison",
|
||||||
? "observed"
|
undefined,
|
||||||
: type === "scheme"
|
|
||||||
? "analysis_comparison"
|
|
||||||
: "realtime_comparison",
|
|
||||||
schemeRunId,
|
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
console.error("[HistoryDataPanel] 从后端获取数据失败:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) =>
|
const formatTimestamp = (timestamp: string) =>
|
||||||
@@ -186,11 +207,11 @@ const emptyStateMessages: Record<
|
|||||||
> = {
|
> = {
|
||||||
chart: {
|
chart: {
|
||||||
title: "暂无时序数据",
|
title: "暂无时序数据",
|
||||||
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
|
subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。",
|
||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
title: "暂无表格数据",
|
title: "暂无表格数据",
|
||||||
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
|
subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,30 +225,65 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
runId,
|
runId,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
// 从 featureInfos 中提取设备 ID 列表
|
const [schemeState, setSchemeState] = useState<{
|
||||||
const deviceIds = useMemo(
|
runId: string;
|
||||||
() => featureInfos.map(([id]) => id),
|
context?: AnalysisHistoryContext;
|
||||||
[featureInfos]
|
error?: string;
|
||||||
);
|
} | null>(null);
|
||||||
|
const [schemeReload, setSchemeReload] = useState(0);
|
||||||
|
const schemeReady = type !== "scheme" || !runId || schemeState?.runId === runId;
|
||||||
|
const schemeContext =
|
||||||
|
type === "scheme" && schemeState?.runId === runId
|
||||||
|
? schemeState?.context
|
||||||
|
: undefined;
|
||||||
|
const schemeError =
|
||||||
|
type === "scheme" && schemeState?.runId === runId
|
||||||
|
? schemeState?.error
|
||||||
|
: undefined;
|
||||||
|
const eventRange = schemeContext?.eventRange;
|
||||||
|
const rangeKey = JSON.stringify([type, runId]);
|
||||||
|
const defaultRange = useMemo(() => {
|
||||||
|
const now = dayjs();
|
||||||
|
const start = start_time ? dayjs(start_time) : null;
|
||||||
|
const end = end_time ? dayjs(end_time) : null;
|
||||||
|
return {
|
||||||
|
from: start?.isValid()
|
||||||
|
? start
|
||||||
|
: eventRange
|
||||||
|
? dayjs(eventRange.start).subtract(1, "hour")
|
||||||
|
: now.subtract(1, "day"),
|
||||||
|
to: end?.isValid()
|
||||||
|
? end
|
||||||
|
: eventRange
|
||||||
|
? dayjs(eventRange.end).add(1, "hour")
|
||||||
|
: now,
|
||||||
|
};
|
||||||
|
}, [start_time, end_time, eventRange]);
|
||||||
|
const [selectedRange, setSelectedRange] = useState<{
|
||||||
|
key: string;
|
||||||
|
from: Dayjs;
|
||||||
|
to: Dayjs;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const rangeForContext = selectedRange?.key === rangeKey ? selectedRange : null;
|
||||||
|
const from = rangeForContext && (
|
||||||
|
rangeForContext.startTime === start_time || !start_time || !dayjs(start_time).isValid()
|
||||||
|
) ? rangeForContext.from : defaultRange.from;
|
||||||
|
const to = rangeForContext && (
|
||||||
|
rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid()
|
||||||
|
) ? rangeForContext.to : defaultRange.to;
|
||||||
|
const setFrom = (value: Dayjs) =>
|
||||||
|
setSelectedRange({ key: rangeKey, from: value, to, startTime: start_time, endTime: end_time });
|
||||||
|
const setTo = (value: Dayjs) =>
|
||||||
|
setSelectedRange({ key: rangeKey, from, to: value, startTime: start_time, endTime: end_time });
|
||||||
|
|
||||||
const [from, setFrom] = useState<Dayjs>(() => {
|
useEffect(() => {
|
||||||
if (start_time) {
|
if (!rangeForContext || (
|
||||||
const parsedStart = dayjs(start_time);
|
rangeForContext.startTime === start_time && rangeForContext.endTime === end_time
|
||||||
if (parsedStart.isValid()) {
|
)) return;
|
||||||
return parsedStart;
|
setSelectedRange({ key: rangeKey, from, to, startTime: start_time, endTime: end_time });
|
||||||
}
|
}, [rangeForContext, rangeKey, from, to, start_time, end_time]);
|
||||||
}
|
|
||||||
return dayjs().subtract(1, "day");
|
|
||||||
});
|
|
||||||
const [to, setTo] = useState<Dayjs>(() => {
|
|
||||||
if (end_time) {
|
|
||||||
const parsedEnd = dayjs(end_time);
|
|
||||||
if (parsedEnd.isValid()) {
|
|
||||||
return parsedEnd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dayjs();
|
|
||||||
});
|
|
||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||||
@@ -241,20 +297,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
}, [defaultTab]);
|
}, [defaultTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!start_time && !end_time) return;
|
if (type !== "scheme" || !runId) return;
|
||||||
if (start_time) {
|
let cancelled = false;
|
||||||
const parsedStart = dayjs(start_time);
|
getAnalysisScheme(runId).then(
|
||||||
if (parsedStart.isValid()) {
|
(run) => {
|
||||||
setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart));
|
if (!cancelled) {
|
||||||
|
setSchemeState({ runId, context: getAnalysisHistoryContext(run) });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSchemeState({ runId, error: "无法读取事件方案,请刷新重试" });
|
||||||
}
|
}
|
||||||
if (end_time) {
|
},
|
||||||
const parsedEnd = dayjs(end_time);
|
);
|
||||||
if (parsedEnd.isValid()) {
|
return () => {
|
||||||
setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd));
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}
|
}, [type, runId, schemeReload]);
|
||||||
}, [start_time, end_time]);
|
|
||||||
|
|
||||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||||
|
|
||||||
@@ -272,9 +332,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFetch = useCallback(
|
const handleFetch = useCallback(
|
||||||
async (reason: string) => {
|
async () => {
|
||||||
if (!hasDevices) {
|
if (!hasDevices || !schemeReady || schemeError) {
|
||||||
requestControllerRef.current?.abort();
|
requestControllerRef.current?.abort();
|
||||||
|
requestControllerRef.current = null;
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setHistorySeries([]);
|
setHistorySeries([]);
|
||||||
setLoadingState("idle");
|
setLoadingState("idle");
|
||||||
@@ -295,26 +356,37 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
from: rangeFrom.toDate(),
|
from: rangeFrom.toDate(),
|
||||||
to: rangeTo.toDate(),
|
to: rangeTo.toDate(),
|
||||||
},
|
},
|
||||||
type,
|
type === "scheme" && schemeContext && !schemeContext.simulationRunId
|
||||||
runId,
|
? "realtime"
|
||||||
|
: type,
|
||||||
|
schemeContext?.simulationRunId,
|
||||||
requestController.signal,
|
requestController.signal,
|
||||||
);
|
);
|
||||||
if (requestControllerRef.current !== requestController) return;
|
if (
|
||||||
|
requestController.signal.aborted ||
|
||||||
|
requestControllerRef.current !== requestController
|
||||||
|
) return;
|
||||||
setTimeSeries(result.points);
|
setTimeSeries(result.points);
|
||||||
setHistorySeries(result.series);
|
setHistorySeries(result.series);
|
||||||
setLoadingState("success");
|
setLoadingState("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
requestControllerRef.current !== requestController ||
|
requestControllerRef.current !== requestController ||
|
||||||
|
requestController.signal.aborted ||
|
||||||
(err instanceof Error && err.name === "AbortError")
|
(err instanceof Error && err.name === "AbortError")
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setTimeSeries([]);
|
||||||
|
setHistorySeries([]);
|
||||||
setError(err instanceof Error ? err.message : "未知错误");
|
setError(err instanceof Error ? err.message : "未知错误");
|
||||||
setLoadingState("error");
|
setLoadingState("error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
|
[
|
||||||
|
requestedFeatureInfos, hasDevices, normalizedRange,
|
||||||
|
schemeReady, schemeError, schemeContext, type,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -324,15 +396,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 设备变化时自动查询
|
// 元素、方案或查询范围变化时自动查询。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasDevices) {
|
void handleFetch();
|
||||||
handleFetch("device-change");
|
}, [handleFetch]);
|
||||||
} else {
|
|
||||||
setTimeSeries([]);
|
|
||||||
setHistorySeries([]);
|
|
||||||
}
|
|
||||||
}, [handleFetch, hasDevices]);
|
|
||||||
|
|
||||||
const columns: GridColDef[] = useMemo(() => {
|
const columns: GridColDef[] = useMemo(() => {
|
||||||
const base: GridColDef[] = [
|
const base: GridColDef[] = [
|
||||||
@@ -424,7 +491,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
"#3f51b5", // 靛蓝色
|
"#3f51b5", // 靛蓝色
|
||||||
];
|
];
|
||||||
|
|
||||||
const xData = dataset.map((item) => item.label);
|
|
||||||
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
|
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
|
||||||
const hasPressureSeries = historySeries.some(
|
const hasPressureSeries = historySeries.some(
|
||||||
(item) => item.metric === "pressure",
|
(item) => item.metric === "pressure",
|
||||||
@@ -453,11 +519,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
symbolSize: isObserved ? 7 : 0,
|
symbolSize: isObserved ? 7 : 0,
|
||||||
showSymbol: isObserved,
|
showSymbol: isObserved,
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
connectNulls: !isObserved,
|
connectNulls: false,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: colors[index % colors.length],
|
color: colors[index % colors.length],
|
||||||
},
|
},
|
||||||
data: dataset.map((item) => item[key]),
|
data: timeSeries
|
||||||
|
.filter((point) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(point.values, key),
|
||||||
|
)
|
||||||
|
.map((point) => [
|
||||||
|
new Date(point.timestamp).getTime(), point.values[key] ?? null,
|
||||||
|
]),
|
||||||
lineStyle: isObserved ? { width: 0 } : undefined,
|
lineStyle: isObserved ? { width: 0 } : undefined,
|
||||||
areaStyle: isObserved
|
areaStyle: isObserved
|
||||||
? undefined
|
? undefined
|
||||||
@@ -507,9 +579,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: "category",
|
type: "time",
|
||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
data: xData,
|
min: normalizedRange.from.valueOf(),
|
||||||
|
max: normalizedRange.to.valueOf(),
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value: number) => formatTimestamp(new Date(value).toISOString()),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
yAxis: [
|
yAxis: [
|
||||||
...(hasFlowSeries
|
...(hasFlowSeries
|
||||||
@@ -542,7 +618,21 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
end: 100,
|
end: 100,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
series: getSeries(),
|
series: getSeries().map((series, index) => ({
|
||||||
|
...series,
|
||||||
|
...(index === 0 && eventRange
|
||||||
|
? {
|
||||||
|
markArea: {
|
||||||
|
silent: true,
|
||||||
|
itemStyle: { color: "rgba(255, 152, 0, 0.10)" },
|
||||||
|
data: [[
|
||||||
|
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
|
||||||
|
{ xAxis: Date.parse(eventRange.end) },
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -716,16 +806,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setFrom(value);
|
setFrom(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onAccept={(value) => {
|
|
||||||
if (
|
|
||||||
value &&
|
|
||||||
dayjs.isDayjs(value) &&
|
|
||||||
value.isValid() &&
|
|
||||||
hasDevices
|
|
||||||
) {
|
|
||||||
handleFetch("date-change");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maxDateTime={to}
|
maxDateTime={to}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
textField: { fullWidth: true, size: "small" },
|
textField: { fullWidth: true, size: "small" },
|
||||||
@@ -739,16 +819,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setTo(value);
|
setTo(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onAccept={(value) => {
|
|
||||||
if (
|
|
||||||
value &&
|
|
||||||
dayjs.isDayjs(value) &&
|
|
||||||
value.isValid() &&
|
|
||||||
hasDevices
|
|
||||||
) {
|
|
||||||
handleFetch("date-change");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
minDateTime={from}
|
minDateTime={from}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
textField: { fullWidth: true, size: "small" },
|
textField: { fullWidth: true, size: "small" },
|
||||||
@@ -787,8 +857,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={<Refresh fontSize="small" />}
|
startIcon={<Refresh fontSize="small" />}
|
||||||
disabled={!hasDevices || loadingState === "loading"}
|
disabled={
|
||||||
onClick={() => handleFetch("manual")}
|
!hasDevices || !schemeReady || loadingState === "loading"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (schemeError) {
|
||||||
|
setSchemeState(null);
|
||||||
|
setSchemeReload((value) => value + 1);
|
||||||
|
} else {
|
||||||
|
void handleFetch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@@ -799,13 +878,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
|
|
||||||
{error && (
|
{eventRange && (
|
||||||
|
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
|
||||||
|
事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{(schemeError || error) && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color="error"
|
color="error"
|
||||||
sx={{ mt: 1, display: "block" }}
|
sx={{ mt: 1, display: "block" }}
|
||||||
>
|
>
|
||||||
获取数据失败:{error}
|
获取数据失败:{schemeError || error}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -814,7 +898,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}>
|
<Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}>
|
||||||
{loadingState === "loading" && (
|
{(!schemeReady || loadingState === "loading") && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
type SceneRuntimeState,
|
type SceneRuntimeState,
|
||||||
} from "./sceneProtocol";
|
} from "./sceneProtocol";
|
||||||
|
|
||||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=35-resource-cache";
|
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=36-resumable-cache";
|
||||||
const SCENE_LOAD_TIMEOUT_MS = 120_000;
|
const SCENE_LOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
const initialRuntimeState: SceneRuntimeState = {
|
const initialRuntimeState: SceneRuntimeState = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api } from "@/lib/api";
|
|||||||
import {
|
import {
|
||||||
getAnalysisResults,
|
getAnalysisResults,
|
||||||
getAnalysisScheme,
|
getAnalysisScheme,
|
||||||
|
getAnalysisHistoryContext,
|
||||||
listAnalysisSchemes,
|
listAnalysisSchemes,
|
||||||
} from "./analysisRuns";
|
} from "./analysisRuns";
|
||||||
|
|
||||||
@@ -94,3 +95,49 @@ describe("analysis runs API adapter", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("analysis history context", () => {
|
||||||
|
const run = {
|
||||||
|
run_id: "run-1", name: "event", run_type: "burst_analysis",
|
||||||
|
created_by: "alice", created_at: "2026-11-03T05:00:00Z",
|
||||||
|
started_at: "2026-08-25T09:00:00+08:00", status: "completed", parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["burst_analysis", { modify_total_duration: 3600 }],
|
||||||
|
["flushing_analysis", { duration: 3600 }],
|
||||||
|
["contaminant_analysis", { duration: 3600 }],
|
||||||
|
])("uses the simulated interval of %s rather than its creation time", (run_type, parameters) => {
|
||||||
|
expect(getAnalysisHistoryContext({ ...run, run_type, parameters })).toEqual({
|
||||||
|
simulationRunId: "run-1",
|
||||||
|
eventRange: { start: "2026-08-25T01:00:00.000Z", end: "2026-08-25T02:00:00.000Z" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a detection run as a hydraulic simulation", () => {
|
||||||
|
expect(getAnalysisHistoryContext({
|
||||||
|
...run, run_type: "burst_detection", parameters: {
|
||||||
|
scada_window: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
eventRange: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the linked hydraulic run for a localization analysis", () => {
|
||||||
|
expect(getAnalysisHistoryContext({
|
||||||
|
...run, run_type: "burst_location", parameters: {
|
||||||
|
result_payload: {
|
||||||
|
simulation_run: { run_id: "simulation-1" },
|
||||||
|
scada_window: { burst_start: "2026-08-25T01:00:00Z", burst_end: "2026-08-25T02:00:00Z" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).simulationRunId).toBe("simulation-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid or missing event durations without inventing an interval", () => {
|
||||||
|
expect(getAnalysisHistoryContext(run).eventRange).toBeNull();
|
||||||
|
expect(getAnalysisHistoryContext({ ...run, parameters: { duration: -1 } }).eventRange).toBeNull();
|
||||||
|
expect(getAnalysisHistoryContext({ ...run, started_at: "invalid", parameters: { duration: 3600 } }).eventRange).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,66 @@ export type AnalysisRun = {
|
|||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AnalysisHistoryContext = {
|
||||||
|
simulationRunId?: string;
|
||||||
|
eventRange: { start: string; end: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIMULATION_RUN_TYPES = new Set([
|
||||||
|
"burst_analysis",
|
||||||
|
"flushing_analysis",
|
||||||
|
"contaminant_analysis",
|
||||||
|
"valve_close_analysis",
|
||||||
|
"pressure_regulation",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const getAnalysisHistoryContext = (
|
||||||
|
run: AnalysisRun,
|
||||||
|
): AnalysisHistoryContext => {
|
||||||
|
const parameters = normalizeParameters(run.parameters);
|
||||||
|
if (SIMULATION_RUN_TYPES.has(run.run_type)) {
|
||||||
|
const start = Date.parse(run.started_at);
|
||||||
|
const duration = parameters.modify_total_duration ?? parameters.duration;
|
||||||
|
const end = new Date(
|
||||||
|
typeof duration === "number" ? start + duration * 1000 : NaN,
|
||||||
|
).getTime();
|
||||||
|
return {
|
||||||
|
simulationRunId: run.run_id,
|
||||||
|
eventRange:
|
||||||
|
Number.isFinite(start) && Number.isFinite(end) && end > start
|
||||||
|
? {
|
||||||
|
start: new Date(start).toISOString(),
|
||||||
|
end: new Date(end).toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = normalizeParameters(parameters.result_payload);
|
||||||
|
const window = normalizeParameters(
|
||||||
|
parameters.scada_window ?? payload.scada_window,
|
||||||
|
);
|
||||||
|
const start = window.burst_start ?? window.start ?? parameters.scada_start;
|
||||||
|
const end = window.burst_end ?? window.end ?? parameters.scada_end;
|
||||||
|
const simulation = normalizeParameters(
|
||||||
|
payload.simulation_run ?? payload.simulation_scheme,
|
||||||
|
);
|
||||||
|
const simulationRunId = parameters.simulation_run_id ?? simulation.run_id;
|
||||||
|
return {
|
||||||
|
simulationRunId:
|
||||||
|
typeof simulationRunId === "string" && simulationRunId.trim()
|
||||||
|
? simulationRunId.trim()
|
||||||
|
: undefined,
|
||||||
|
eventRange:
|
||||||
|
typeof start === "string" &&
|
||||||
|
typeof end === "string" &&
|
||||||
|
Number.isFinite(Date.parse(start)) &&
|
||||||
|
Date.parse(end) > Date.parse(start)
|
||||||
|
? { start, end }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type AnalysisResult = {
|
export type AnalysisResult = {
|
||||||
result_id: string;
|
result_id: string;
|
||||||
run_id: string;
|
run_id: string;
|
||||||
|
|||||||
@@ -28,8 +28,46 @@ describe("element history display conversion", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const points = toTimeSeriesPoints(series);
|
const points = toTimeSeriesPoints(series);
|
||||||
expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z");
|
expect(points[0].timestamp).toBe("2026-09-01T00:00:00.000Z");
|
||||||
expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334);
|
expect(points[0].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(83.3333333334);
|
||||||
expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
|
expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aligns equivalent timestamps and keeps same-name pipes and junctions separate", () => {
|
||||||
|
const base: ElementHistorySeries = {
|
||||||
|
element_id: "shared-id", element_type: "pipe", device_id: null,
|
||||||
|
metric: "flow", source: "realtime_simulation", source_unit: "L/s",
|
||||||
|
display_unit: "m³/h", unit_inferred: false,
|
||||||
|
points: [{ time: "2026-09-01T08:00:00+08:00", value: 10 }],
|
||||||
|
};
|
||||||
|
const points = toTimeSeriesPoints([
|
||||||
|
base,
|
||||||
|
{ ...base, element_type: "junction", metric: "pressure", source_unit: "m",
|
||||||
|
points: [{ time: "2026-09-01T00:00:00Z", value: 20 }] },
|
||||||
|
{ ...base, source: "analysis_simulation", points: [
|
||||||
|
{ time: "2026-09-01T00:00:00.000Z", value: null },
|
||||||
|
] },
|
||||||
|
]);
|
||||||
|
expect(points).toHaveLength(1);
|
||||||
|
expect(points[0].values).toEqual({
|
||||||
|
'["pipe","shared-id","realtime_simulation"]': 36,
|
||||||
|
'["junction","shared-id","realtime_simulation"]': 20,
|
||||||
|
'["pipe","shared-id","analysis_simulation"]': null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps online IDs ending in _scheme distinct from another element's scheme series", () => {
|
||||||
|
const base: ElementHistorySeries = {
|
||||||
|
element_id: "P_scheme", element_type: "pipe", device_id: null,
|
||||||
|
metric: "flow", source: "realtime_simulation", source_unit: "m3/h",
|
||||||
|
display_unit: "m³/h", unit_inferred: false,
|
||||||
|
points: [{ time: "2026-09-01T00:00:00Z", value: 100 }],
|
||||||
|
};
|
||||||
|
const points = toTimeSeriesPoints([
|
||||||
|
base,
|
||||||
|
{ ...base, element_id: "P", source: "analysis_simulation",
|
||||||
|
points: [{ time: "2026-09-01T00:00:00Z", value: 5 }] },
|
||||||
|
]);
|
||||||
|
expect(Object.values(points[0].values).sort((a, b) => Number(a) - Number(b))).toEqual([5, 100]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
|||||||
analysis_simulation: "scheme_sim",
|
analysis_simulation: "scheme_sim",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const historySeriesKey = (series: ElementHistorySeries) =>
|
export const historySeriesKey = (series: ElementHistorySeries) => {
|
||||||
`${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`;
|
if (series.device_id) return `${series.device_id}_${SOURCE_SUFFIX[series.source]}`;
|
||||||
|
return JSON.stringify([series.element_type, series.element_id, series.source]);
|
||||||
|
};
|
||||||
|
|
||||||
export const historySourceLabel = (source: HistorySource) => {
|
export const historySourceLabel = (source: HistorySource) => {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
@@ -65,8 +67,11 @@ export const historySourceLabel = (source: HistorySource) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const historySeriesLabel = (series: ElementHistorySeries) =>
|
export const historySeriesLabel = (series: ElementHistorySeries) => {
|
||||||
`${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`;
|
const target = series.device_id ??
|
||||||
|
`${series.element_type === "pipe" ? "管道" : "节点"} ${series.element_id}`;
|
||||||
|
return `${target} (${historySourceLabel(series.source)})`;
|
||||||
|
};
|
||||||
|
|
||||||
export const toTimeSeriesPoints = (
|
export const toTimeSeriesPoints = (
|
||||||
seriesList: ElementHistorySeries[],
|
seriesList: ElementHistorySeries[],
|
||||||
@@ -75,13 +80,14 @@ export const toTimeSeriesPoints = (
|
|||||||
seriesList.forEach((series) => {
|
seriesList.forEach((series) => {
|
||||||
const key = historySeriesKey(series);
|
const key = historySeriesKey(series);
|
||||||
series.points.forEach((point) => {
|
series.points.forEach((point) => {
|
||||||
const values = timeMap.get(point.time) ?? {};
|
const timestamp = new Date(point.time).toISOString();
|
||||||
|
const values = timeMap.get(timestamp) ?? {};
|
||||||
values[key] = toDisplayValue(
|
values[key] = toDisplayValue(
|
||||||
point.value,
|
point.value,
|
||||||
series.metric,
|
series.metric,
|
||||||
series.source_unit,
|
series.source_unit,
|
||||||
);
|
);
|
||||||
timeMap.set(point.time, values);
|
timeMap.set(timestamp, values);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(
|
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(
|
||||||
|
|||||||
Reference in New Issue
Block a user