Compare commits
4
Commits
35b6819459
...
a9b25b94d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b25b94d8 | ||
|
|
69ad145e75 | ||
|
|
331ea3f094 | ||
|
|
08ddb4453d |
@@ -0,0 +1,63 @@
|
||||
name: Frontend E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
GIT_USERNAME: ${{ github.actor }}
|
||||
GIT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
case "$SERVER_URL" in
|
||||
http://*)
|
||||
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
|
||||
;;
|
||||
https://*)
|
||||
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
|
||||
;;
|
||||
*)
|
||||
AUTH_SERVER_URL="$SERVER_URL"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -d .git ]; then
|
||||
git init .
|
||||
fi
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
||||
else
|
||||
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
||||
fi
|
||||
|
||||
git fetch --depth=1 origin "$COMMIT_SHA"
|
||||
git checkout --force --detach FETCH_HEAD
|
||||
git clean -ffdx
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
CI: "true"
|
||||
run: npm run test:e2e
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
/e2e/.auth/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -44,6 +44,7 @@ npm run dev
|
||||
npm run lint
|
||||
npm test
|
||||
npm run test:coverage
|
||||
npm run test:e2e
|
||||
npm run build
|
||||
npm run start
|
||||
docker build -t tjwater-frontend:local .
|
||||
@@ -52,6 +53,7 @@ docker build -t tjwater-frontend:local .
|
||||
- `npm run lint`:运行 ESLint。
|
||||
- `npm test`:运行 Jest。
|
||||
- `npm run test:coverage`:生成测试覆盖率。
|
||||
- `npm run test:e2e`:启动本地 Next.js 与 Playwright Chromium 烟测。
|
||||
- `npm run build`:生成生产构建。
|
||||
- `npm run start`:启动生产模式服务。
|
||||
|
||||
@@ -86,6 +88,36 @@ npm run build
|
||||
|
||||
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
|
||||
|
||||
### Playwright E2E
|
||||
|
||||
首次运行先安装 Chromium:
|
||||
|
||||
```bash
|
||||
npm run e2e:install
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
本地测试会自动构建生产版本并启动隔离的 `http://127.0.0.1:3100`,生成仅用于测试的 NextAuth
|
||||
会话,并模拟后端 API,因此不要求启动 Keycloak、Server 或 Agent。失败时可通过
|
||||
`npm run test:e2e:report` 查看 HTML 报告,交互调试可使用
|
||||
`npm run test:e2e:ui` 或 `npm run test:e2e:debug`。
|
||||
|
||||
在不能访问 Playwright CDN 的内网环境,可设置
|
||||
`E2E_CHROMIUM_PATH=/absolute/path/to/chrome` 复用预装的 Chromium/Chrome。
|
||||
|
||||
若要对已部署环境运行测试,请传入环境地址和预先保存的管理员 Playwright 登录状态:
|
||||
|
||||
```bash
|
||||
E2E_BASE_URL=https://example.test \
|
||||
E2E_STORAGE_STATE=/absolute/path/to/storage-state.json \
|
||||
E2E_USE_REAL_SERVICES=true \
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
`E2E_BASE_URL` 会关闭本地开发服务器;`E2E_STORAGE_STATE` 避免在仓库中保存账号或
|
||||
会话;`E2E_USE_REAL_SERVICES=true` 会关闭 API 模拟。Gitea 的
|
||||
`.gitea/workflows/e2e.yml` 在分支推送和 PR 中运行同一组 Chromium 测试。
|
||||
|
||||
## 安全规则
|
||||
|
||||
不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
|
||||
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+591
-1513
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockBackend } from "./support/mockBackend";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockBackend(page);
|
||||
});
|
||||
|
||||
test("已登录管理员可以打开审计日志页面", async ({ page }) => {
|
||||
await page.goto("/audit-logs");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "审计日志", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "查询结果" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { encode } from "next-auth/jwt";
|
||||
|
||||
import {
|
||||
AUTH_STATE_PATH,
|
||||
E2E_AUTH_SECRET,
|
||||
E2E_BASE_URL,
|
||||
} from "./support/environment";
|
||||
|
||||
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
|
||||
|
||||
export default async function globalSetup() {
|
||||
if (process.env.E2E_STORAGE_STATE) return;
|
||||
|
||||
const now = Date.now();
|
||||
const baseUrl = new URL(E2E_BASE_URL);
|
||||
const secure = baseUrl.protocol === "https:";
|
||||
const token = await encode({
|
||||
secret: E2E_AUTH_SECRET,
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
token: {
|
||||
sub: "playwright-user",
|
||||
username: "playwright",
|
||||
name: "E2E 测试用户",
|
||||
email: "playwright@example.invalid",
|
||||
accessToken: "playwright-access-token",
|
||||
accessTokenIssuedAt: now,
|
||||
accessTokenExpires: now + SESSION_MAX_AGE_SECONDS * 1000,
|
||||
sessionExpiresAt: now + SESSION_MAX_AGE_SECONDS * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
await fs.mkdir(path.dirname(AUTH_STATE_PATH), { recursive: true });
|
||||
await fs.writeFile(
|
||||
AUTH_STATE_PATH,
|
||||
JSON.stringify(
|
||||
{
|
||||
cookies: [
|
||||
{
|
||||
name: secure
|
||||
? "__Secure-next-auth.session-token"
|
||||
: "next-auth.session-token",
|
||||
value: token,
|
||||
domain: baseUrl.hostname,
|
||||
path: "/",
|
||||
expires: Math.floor(now / 1000) + SESSION_MAX_AGE_SECONDS,
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
],
|
||||
origins: [
|
||||
{
|
||||
origin: baseUrl.origin,
|
||||
localStorage: [
|
||||
{ name: "active_project", value: "playwright-project" },
|
||||
{ name: "MAP_WORKSPACE", value: "tjwater_e2e" },
|
||||
{ name: "NETWORK_NAME", value: "tjwater_e2e" },
|
||||
{
|
||||
name: "MAP_EXTENT",
|
||||
value: "13508801.93,3608163.35,13555650.64,3633685.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
|
||||
export const E2E_BASE_URL =
|
||||
process.env.E2E_BASE_URL || "http://127.0.0.1:3100";
|
||||
|
||||
export const E2E_AUTH_SECRET =
|
||||
process.env.E2E_NEXTAUTH_SECRET ||
|
||||
"tjwater-playwright-local-secret-at-least-32-characters";
|
||||
|
||||
export const AUTH_STATE_PATH = path.join(
|
||||
process.cwd(),
|
||||
"e2e",
|
||||
".auth",
|
||||
"user.json",
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
const json = (route: Route, body: unknown) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const mockBackend = async (page: Page) => {
|
||||
if (process.env.E2E_USE_REAL_SERVICES === "true") return;
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
|
||||
if (url.pathname === "/api/v1/access-context") {
|
||||
return json(route, {
|
||||
user_id: "playwright-user",
|
||||
username: "playwright",
|
||||
system_role: "system_admin",
|
||||
is_system_admin: true,
|
||||
project_id: "playwright-project",
|
||||
project_role: "project_admin",
|
||||
permissions: [
|
||||
"webgis.view",
|
||||
"simulation.view",
|
||||
"simulation.run",
|
||||
"scada.clean",
|
||||
"risk.run",
|
||||
"optimization.run",
|
||||
"burst.run",
|
||||
"audit.view",
|
||||
"environment.manage",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/v1/projects/current") {
|
||||
return json(route, {
|
||||
project_id: "playwright-project",
|
||||
code: "tjwater_e2e",
|
||||
gs_workspace: "tjwater_e2e",
|
||||
map_extent: {
|
||||
bbox: [13508801.93, 3608163.35, 13555650.64, 3633685.14],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/v1/audit-logs/count") {
|
||||
return json(route, { count: 0 });
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === "/api/v1/audit-logs" ||
|
||||
url.pathname === "/api/v1/admin/users" ||
|
||||
url.pathname === "/api/v1/admin/projects"
|
||||
) {
|
||||
return json(route, []);
|
||||
}
|
||||
|
||||
return json(route, {});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockBackend } from "./support/mockBackend";
|
||||
|
||||
test.setTimeout(180_000);
|
||||
|
||||
type CameraProbe = {
|
||||
active: string | null;
|
||||
position: number[];
|
||||
target: number[];
|
||||
up: number[];
|
||||
};
|
||||
|
||||
type SceneProbe = {
|
||||
errors: string[];
|
||||
contextVisible?: boolean;
|
||||
camera?: CameraProbe;
|
||||
appearance?: { contextOpacity: number };
|
||||
networkStyle?: { style: { scale: number } };
|
||||
};
|
||||
|
||||
test("三维页面默认展开场景工具并可调建筑透明度", async ({ page }) => {
|
||||
await mockBackend(page);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("active_project", "playwright-project");
|
||||
localStorage.setItem("MAP_WORKSPACE", "zjb");
|
||||
localStorage.setItem("NETWORK_NAME", "zjb");
|
||||
});
|
||||
await page.goto("/three-dimensional-scene");
|
||||
|
||||
await expect(page.getByLabel("三维场景控制面板")).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "场景" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const opacitySlider = page.getByRole("slider", {
|
||||
name: /建筑背景透明度/,
|
||||
});
|
||||
await expect(opacitySlider).toBeEnabled({ timeout: 60_000 });
|
||||
await expect(opacitySlider).toHaveAccessibleName("建筑背景透明度 60%");
|
||||
|
||||
const backgroundToggle = page.getByRole("switch", { name: "建筑背景" });
|
||||
await backgroundToggle.evaluate((element) =>
|
||||
element.scrollIntoView({ block: "center" }),
|
||||
);
|
||||
const panel = page.getByLabel("三维场景控制面板");
|
||||
const panelBeforeToggle = await panel.boundingBox();
|
||||
const panelScrollBeforeToggle = await panel.locator(".overflow-y-auto").evaluate(
|
||||
(element) => element.scrollTop,
|
||||
);
|
||||
await backgroundToggle.click();
|
||||
await expect(backgroundToggle).toHaveAttribute("aria-checked", "false");
|
||||
const panelAfterToggle = await panel.boundingBox();
|
||||
const panelScrollAfterToggle = await panel.locator(".overflow-y-auto").evaluate(
|
||||
(element) => element.scrollTop,
|
||||
);
|
||||
expect(panelAfterToggle).toEqual(panelBeforeToggle);
|
||||
expect(panelScrollAfterToggle).toBe(panelScrollBeforeToggle);
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
const mobilePanelBeforeToggle = await panel.boundingBox();
|
||||
await backgroundToggle.click();
|
||||
await expect(backgroundToggle).toHaveAttribute("aria-checked", "true");
|
||||
const mobilePanelAfterToggle = await panel.boundingBox();
|
||||
expect(mobilePanelAfterToggle).toEqual(mobilePanelBeforeToggle);
|
||||
await expect(opacitySlider).toBeEnabled();
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("全部预设使用稳定且紧凑的场景视角", async ({ page }) => {
|
||||
await page.goto(
|
||||
"/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity-test",
|
||||
);
|
||||
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||
|
||||
const sceneProbe = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { webQA: SceneProbe }).webQA,
|
||||
);
|
||||
|
||||
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("overview");
|
||||
const initialProbe = await sceneProbe();
|
||||
const overview = initialProbe.camera;
|
||||
expect(overview?.up[1]).toBeGreaterThan(0.8);
|
||||
expect(initialProbe.appearance?.contextOpacity).toBe(0.4);
|
||||
expect(initialProbe.networkStyle?.style.scale).toBe(6);
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "set-appearance", patch: { contextOpacity: 0.35 } },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => (await sceneProbe()).appearance?.contextOpacity)
|
||||
.toBe(0.35);
|
||||
|
||||
const cameraBeforeContextToggle = (await sceneProbe()).camera;
|
||||
await page.evaluate(() =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "toggle-context" },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
);
|
||||
await expect.poll(async () => (await sceneProbe()).contextVisible).toBe(false);
|
||||
const cameraAfterContextToggle = (await sceneProbe()).camera;
|
||||
expect(cameraAfterContextToggle?.position).toEqual(cameraBeforeContextToggle?.position);
|
||||
expect(cameraAfterContextToggle?.target).toEqual(cameraBeforeContextToggle?.target);
|
||||
await page.evaluate(() =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "toggle-context" },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "visit-camera", viewId: "plan" },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
);
|
||||
|
||||
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("plan");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const camera = (await sceneProbe()).camera;
|
||||
return Math.max(
|
||||
Math.abs((camera?.position[0] ?? 0) - (camera?.target[0] ?? 1)),
|
||||
Math.abs((camera?.position[2] ?? 0) - (camera?.target[2] ?? 1)),
|
||||
);
|
||||
})
|
||||
.toBeLessThan(0.01);
|
||||
const planProbe = await sceneProbe();
|
||||
const plan = planProbe.camera;
|
||||
expect(planProbe.errors).toEqual([]);
|
||||
expect(Math.abs((plan?.position[0] ?? 0) - (plan?.target[0] ?? 1))).toBeLessThan(0.01);
|
||||
expect(Math.abs((plan?.position[2] ?? 0) - (plan?.target[2] ?? 1))).toBeLessThan(0.01);
|
||||
expect(plan?.up[0]).toBeCloseTo(0, 6);
|
||||
expect(plan?.up[1]).toBeCloseTo(0, 6);
|
||||
expect(plan?.up[2]).toBeCloseTo(-1, 6);
|
||||
|
||||
for (const viewId of [
|
||||
"pump",
|
||||
"hydraulic",
|
||||
"singlePump",
|
||||
"main",
|
||||
"meter",
|
||||
"crossing",
|
||||
"station",
|
||||
"riser",
|
||||
]) {
|
||||
await page.evaluate((nextViewId) =>
|
||||
window.postMessage(
|
||||
{
|
||||
channel: "tjwater:zjb-scene",
|
||||
version: 2,
|
||||
type: "command",
|
||||
projectCode: "zjb",
|
||||
modelId: "zjb-water-network-v23",
|
||||
command: { name: "visit-camera", viewId: nextViewId },
|
||||
},
|
||||
window.location.origin,
|
||||
),
|
||||
viewId,
|
||||
);
|
||||
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe(viewId);
|
||||
const presetProbe = await sceneProbe();
|
||||
expect(presetProbe.errors).toEqual([]);
|
||||
expect(presetProbe.camera?.position.every(Number.isFinite)).toBe(true);
|
||||
expect(presetProbe.camera?.target.every(Number.isFinite)).toBe(true);
|
||||
expect(presetProbe.camera?.up[1]).toBeGreaterThan(0.99);
|
||||
}
|
||||
|
||||
const occupancy = await page.evaluate(async () => {
|
||||
const threeUrl =
|
||||
"/three-dimensional/zjb/v29/vendor/three/three.module.js";
|
||||
const navigationUrl =
|
||||
"/three-dimensional/zjb/v29/camera-navigation.mjs?v=31-camera-presets-test";
|
||||
const THREE = await import(threeUrl);
|
||||
const { framePose } = await import(navigationUrl);
|
||||
const aspect = 16 / 9;
|
||||
const box = new THREE.Box3(
|
||||
new THREE.Vector3(-330, 0, -205),
|
||||
new THREE.Vector3(330, 15, 205),
|
||||
);
|
||||
const pose = framePose(box, aspect, {
|
||||
direction: [0, 1, 0],
|
||||
up: [0, 0, -1],
|
||||
padding: 1.04,
|
||||
});
|
||||
const camera = new THREE.PerspectiveCamera(42, aspect, 0.1, 10_000);
|
||||
camera.position.fromArray(pose.position);
|
||||
camera.up.fromArray(pose.up);
|
||||
camera.lookAt(new THREE.Vector3(...pose.target));
|
||||
camera.updateProjectionMatrix();
|
||||
camera.updateMatrixWorld();
|
||||
const points = [
|
||||
[-330, 0, -205],
|
||||
[-330, 0, 205],
|
||||
[-330, 15, -205],
|
||||
[-330, 15, 205],
|
||||
[330, 0, -205],
|
||||
[330, 0, 205],
|
||||
[330, 15, -205],
|
||||
[330, 15, 205],
|
||||
].map((point) => new THREE.Vector3(...point).project(camera));
|
||||
return {
|
||||
x: Math.max(...points.map((point) => Math.abs(point.x))),
|
||||
y: Math.max(...points.map((point) => Math.abs(point.y))),
|
||||
};
|
||||
});
|
||||
expect(occupancy.x).toBeLessThanOrEqual(1);
|
||||
expect(occupancy.y).toBeLessThanOrEqual(1);
|
||||
expect(Math.max(occupancy.x, occupancy.y)).toBeGreaterThan(0.9);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("未登录访问受保护页面时返回登录重定向", async ({ request }) => {
|
||||
const sessionResponse = await request.get("/api/auth/session");
|
||||
expect(await sessionResponse.json()).toEqual({});
|
||||
|
||||
const response = await request.get("/audit-logs", { maxRedirects: 0 });
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
expect(body).toContain("NEXT_REDIRECT;replace;/login;307;");
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
testPathIgnorePatterns: ['<rootDir>/e2e/'],
|
||||
moduleNameMapper: {
|
||||
'^@pages/(.*)$': '<rootDir>/pages/$1',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
|
||||
Generated
+46
@@ -48,6 +48,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -5710,6 +5711,22 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -19080,6 +19097,35 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"e2e:install": "playwright install chromium",
|
||||
"e2e:serve": "node scripts/startE2eServer.mjs",
|
||||
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
|
||||
"api:check": "node scripts/check-api-contracts.mjs",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||
@@ -64,6 +70,7 @@
|
||||
"sharp": "0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
import {
|
||||
AUTH_STATE_PATH,
|
||||
E2E_AUTH_SECRET,
|
||||
E2E_BASE_URL,
|
||||
} from "./e2e/support/environment";
|
||||
|
||||
const externalServer = Boolean(process.env.E2E_BASE_URL);
|
||||
const storageState = process.env.E2E_STORAGE_STATE || AUTH_STATE_PATH;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
expect: { timeout: 30_000 },
|
||||
reporter: process.env.CI
|
||||
? [["line"], ["html", { open: "never" }]]
|
||||
: [["list"], ["html", { open: "never" }]],
|
||||
outputDir: "test-results",
|
||||
globalSetup: "./e2e/globalSetup.ts",
|
||||
use: {
|
||||
baseURL: E2E_BASE_URL,
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
launchOptions: process.env.E2E_CHROMIUM_PATH
|
||||
? { executablePath: process.env.E2E_CHROMIUM_PATH }
|
||||
: undefined,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-public",
|
||||
testMatch: /unauthenticated\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "chromium-authenticated",
|
||||
testIgnore: /unauthenticated\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState,
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: externalServer
|
||||
? undefined
|
||||
: {
|
||||
command:
|
||||
"npm run runtime:config && npm run build && npm run e2e:serve",
|
||||
url: E2E_BASE_URL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NEXTAUTH_URL: E2E_BASE_URL,
|
||||
NEXTAUTH_SECRET: E2E_AUTH_SECRET,
|
||||
KEYCLOAK_CLIENT_ID: "tjwater-e2e",
|
||||
KEYCLOAK_CLIENT_SECRET: "tjwater-e2e",
|
||||
KEYCLOAK_ISSUER: "http://127.0.0.1:8180/realms/tjwater-e2e",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -85,14 +85,14 @@ export function enhanceMaterials(object){
|
||||
}
|
||||
|
||||
// Restore the authored appearance on every mode change. In network overview,
|
||||
// opaque buildings fade more than glass so the curtain wall remains legible.
|
||||
export function applyBuildingContext(material,fade){
|
||||
// all architectural context follows the same user-controlled opacity ceiling.
|
||||
export function applyBuildingContext(material,fade,contextOpacity=.4){
|
||||
const original=material.userData.original??{opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};
|
||||
material.userData.original=original;
|
||||
const facade=/v3_glass|coatedcurtainglass/i.test(material.name);
|
||||
material.opacity=fade?(facade?Math.min(original.opacity,.38):.12):original.opacity;
|
||||
material.transparent=fade||original.transparent;
|
||||
material.depthWrite=fade?false:original.depthWrite;material.needsUpdate=true;
|
||||
const opacity=THREE.MathUtils.clamp(contextOpacity,0,1),contextualOpacity=Math.min(original.opacity,opacity);
|
||||
material.opacity=fade?contextualOpacity:original.opacity;
|
||||
material.transparent=fade?(contextualOpacity<1||original.transparent):original.transparent;
|
||||
material.depthWrite=fade&&contextualOpacity<1?false:original.depthWrite;material.needsUpdate=true;
|
||||
}
|
||||
|
||||
const presets={
|
||||
|
||||
@@ -102,10 +102,10 @@ export function createAssetInspector({model,networkRoot,scene,style,onLocate,onS
|
||||
if(item.cad)pressureBasis='接入节点压力,非立管上端压力';
|
||||
const pressureSource=result?.source==='scada'?'SCADA '+(result.deviceId??'监测'):pressure!=null?'在线模拟':'暂无数据';
|
||||
sections.push({title:'运行结果',fields:fields([
|
||||
['运行状态',status(result?.status)],['压力',value(pressure,'mH₂O')],
|
||||
['运行状态',status(result?.status)],['压力',value(pressure,'m')],
|
||||
...(pressure!=null?[['压力依据',pressureBasis],['压力来源',pressureSource]]:[]),
|
||||
...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'mH₂O')]]:[]),
|
||||
...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'L/s')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]),
|
||||
...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'m')]]:[]),
|
||||
...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'m³/h')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]),
|
||||
])});
|
||||
sections.push({title:'来源说明',fields:fields([
|
||||
['拓扑来源','已核对 INP;本页不求解水力'],['位置与高度','展示调整,不代表实测埋深'],
|
||||
|
||||
@@ -1,15 +1,56 @@
|
||||
import * as THREE from 'three';
|
||||
export function framePose(box,aspect,{direction=[.6,.8,1],padding=1.2,fov=42}={}){
|
||||
const WORLD_UP=new THREE.Vector3(0,1,0);
|
||||
function boxCorners(box){const {min,max}=box;return [
|
||||
new THREE.Vector3(min.x,min.y,min.z),new THREE.Vector3(min.x,min.y,max.z),new THREE.Vector3(min.x,max.y,min.z),new THREE.Vector3(min.x,max.y,max.z),
|
||||
new THREE.Vector3(max.x,min.y,min.z),new THREE.Vector3(max.x,min.y,max.z),new THREE.Vector3(max.x,max.y,min.z),new THREE.Vector3(max.x,max.y,max.z),
|
||||
];}
|
||||
export function networkPrimaryAxis(model){
|
||||
const points=(model.nodes??[]).map(node=>node.coordinate).filter(point=>Array.isArray(point)&&point.length>=2&&point.slice(0,2).every(Number.isFinite));
|
||||
if(points.length<2)return new THREE.Vector3(1,0,0);
|
||||
const center=points.reduce((sum,point)=>[sum[0]+point[0]/points.length,sum[1]-point[1]/points.length],[0,0]);let xx=0,xz=0,zz=0;
|
||||
for(const point of points){const x=point[0]-center[0],z=-point[1]-center[1];xx+=x*x;xz+=x*z;zz+=z*z;}
|
||||
const angle=.5*Math.atan2(2*xz,xx-zz),axis=new THREE.Vector3(Math.cos(angle),0,Math.sin(angle));if(axis.x<0)axis.negate();return axis.normalize();
|
||||
}
|
||||
function linkAxis(link,fallback){
|
||||
const points=link?.coordinates;if(!Array.isArray(points)||points.length<2)return fallback.clone();
|
||||
const first=points[0],last=points.at(-1),axis=new THREE.Vector3(last[0]-first[0],0,-(last[1]-first[1]));
|
||||
if(axis.lengthSq()<1e-8)return fallback.clone();if(axis.x<0)axis.negate();return axis.normalize();
|
||||
}
|
||||
function viewingDirection(axis,reference,elevation){
|
||||
const horizontal=axis.clone().cross(WORLD_UP).normalize(),referenceHorizontal=reference.clone().cross(WORLD_UP).normalize();
|
||||
if(horizontal.dot(referenceHorizontal)<0)horizontal.negate();return horizontal.addScaledVector(WORLD_UP,elevation).normalize();
|
||||
}
|
||||
export function framePose(box,aspect,{direction=[.6,.8,1],up=[0,1,0],padding=1.12,fov=42}={}){
|
||||
if(box.isEmpty())throw Error('视角目标没有可见模型。');
|
||||
const sphere=box.getBoundingSphere(new THREE.Sphere()),angle=Math.min(fov*Math.PI/360,Math.atan(Math.tan(fov*Math.PI/360)*aspect));
|
||||
const distance=Math.max(2,sphere.radius)*padding/Math.sin(angle);return {target:sphere.center.toArray(),position:sphere.center.clone().add(new THREE.Vector3(...direction).normalize().multiplyScalar(distance)).toArray()};
|
||||
const target=box.getCenter(new THREE.Vector3()),viewDirection=new THREE.Vector3(...direction).normalize();let requestedUp=new THREE.Vector3(...up).normalize();
|
||||
if(Math.abs(requestedUp.dot(viewDirection))>.98)requestedUp=Math.abs(viewDirection.y)<.98?WORLD_UP.clone():new THREE.Vector3(0,0,-1);
|
||||
const right=requestedUp.clone().cross(viewDirection).normalize(),projectedUp=viewDirection.clone().cross(right).normalize();
|
||||
const forward=viewDirection.clone().negate(),verticalTan=Math.tan(fov*Math.PI/360),horizontalTan=verticalTan*Math.max(.1,aspect);let distance=2;
|
||||
for(const corner of boxCorners(box)){const offset=corner.sub(target),depthOffset=offset.dot(forward);distance=Math.max(distance,Math.abs(offset.dot(right))/horizontalTan-depthOffset,Math.abs(offset.dot(projectedUp))/verticalTan-depthOffset);}
|
||||
distance*=padding;return {target:target.toArray(),position:target.clone().add(viewDirection.multiplyScalar(distance)).toArray(),up:requestedUp.toArray()};
|
||||
}
|
||||
export function createCameraNavigation({camera,controls,model,networkRoot,root,show,getMode,appearance,onChange,getDisplayMode=()=> 'global',setDisplayMode=()=>{}}){
|
||||
const presets=[{id:'overview',label:'供水总览',mode:'network',note:'完整管网与站区背景'},{id:'plan',label:'管网俯视',mode:'network',note:'查看管网平面关系'},{id:'pump',label:'CAD 泵房参考',mode:'pump',note:'六泵与集管剖开检查 · 隐藏外墙和楼梯'},{id:'hydraulic',label:'泵组与管网',mode:'hydraulic',note:'六泵与下沉接入 · 竖向按 CAD,平面配准待核'},{id:'main',label:'主干管段',mode:'network',note:'定位模型中最长的供水管段'},{id:'meter',label:'BJ9 水表',mode:'network',note:'定位 CAD 水表及宿主管段'},{id:'crossing',label:'交叉下穿',mode:'network',note:'查看非连通交叉的示意下穿'},{id:'station',label:'站房外观',mode:'detail',note:'站房精细模型与周边关系'}];
|
||||
presets.splice(4,0,{id:'singlePump',label:'主泵近景',mode:'hydraulic',note:'PU_VFD_1 · 设备法兰与进出水连接'});
|
||||
if(model.cadAttachments?.length)presets.push({id:'riser',label:'站台上层立管',mode:'network',context:false,note:'CAD DN150 · 高差 10.85 m · 上层配水未推断'});
|
||||
let request=0,flight=null,active=null;const key='zjb-camera-bookmarks-v24';let saved=[];
|
||||
try{const v=JSON.parse(localStorage.getItem(key)||'[]');if(Array.isArray(v))saved=v.filter(p=>p&&typeof p.id==='string'&&typeof p.label==='string'&&p.label.length<=24&&['network','hydraulic','pump','detail','map','meters'].includes(p.mode)&&[p.position,p.target].every(a=>Array.isArray(a)&&a.length===3&&a.every(Number.isFinite))).slice(0,8);}catch{}
|
||||
try{const v=JSON.parse(localStorage.getItem(key)||'[]');if(Array.isArray(v))saved=v.filter(p=>p&&typeof p.id==='string'&&typeof p.label==='string'&&p.label.length<=24&&['network','hydraulic','pump','detail','map','meters'].includes(p.mode)&&[p.position,p.target].every(a=>Array.isArray(a)&&a.length===3&&a.every(Number.isFinite))&&(!p.up||(Array.isArray(p.up)&&p.up.length===3&&p.up.every(Number.isFinite)))).slice(0,8);}catch{}
|
||||
const primaryAxis=networkPrimaryAxis(model),planDirection=WORLD_UP.clone(),planUp=new THREE.Vector3(0,0,-1),overviewDirection=viewingDirection(primaryAxis,primaryAxis,.92);
|
||||
const links=new Map((model.links??[]).map(link=>[link.id,link]));
|
||||
const linkLength=link=>(link.coordinates??[]).slice(1).reduce((sum,point,index)=>sum+Math.hypot(point[0]-link.coordinates[index][0],point[1]-link.coordinates[index][1]),0);
|
||||
const mainLink=(model.links??[]).filter(link=>link.kind==='PIPES').sort((a,b)=>linkLength(b)-linkLength(a))[0];
|
||||
const mainAxis=linkAxis(mainLink,primaryAxis);
|
||||
const meterBinding=(model.cadEquipmentBindings??[]).find(binding=>binding.assetId==='zjb:meter:BJ9'),meterAxis=linkAxis(links.get(meterBinding?.inpLinkId),primaryAxis);
|
||||
const poseOptions={
|
||||
pump:{direction:viewingDirection(primaryAxis,primaryAxis,.72).toArray(),padding:1.06},
|
||||
hydraulic:{direction:viewingDirection(primaryAxis,primaryAxis,.58).toArray(),padding:1.08},
|
||||
singlePump:{direction:viewingDirection(primaryAxis,primaryAxis,.36).toArray(),padding:1.28},
|
||||
main:{direction:viewingDirection(mainAxis,primaryAxis,.72).toArray(),padding:1.1},
|
||||
meter:{direction:viewingDirection(meterAxis,primaryAxis,1.6).toArray(),padding:1.16},
|
||||
crossing:{direction:viewingDirection(primaryAxis,primaryAxis,2.3).toArray(),padding:1.16},
|
||||
station:{direction:viewingDirection(primaryAxis,primaryAxis,.58).toArray(),padding:1.06},
|
||||
riser:{direction:viewingDirection(primaryAxis,primaryAxis,.2).toArray(),padding:1.24},
|
||||
};
|
||||
function emit(note){onChange({active,views:[...presets,...saved],note});}
|
||||
function stop(){request++;flight=null;active=null;emit('自由浏览 · 可保存当前视角');}
|
||||
controls.addEventListener('start',stop);
|
||||
@@ -17,11 +58,11 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
|
||||
if(p.id==='overview'||p.id==='plan')box.setFromObject(networkRoot);
|
||||
else if(p.id==='pump'||p.id==='station'){for(const o of root.children)if(o.visible&&(p.id!=='pump'||/physical_|piping_reference/.test(o.userData.resourceId)))box.union(new THREE.Box3().setFromObject(o));if(p.id==='station'){const facade=root.children.find(o=>o.userData.resourceId==='detail_facade');if(facade)box.setFromObject(facade);}}
|
||||
else if(p.id==='hydraulic'){for(const l of model.links.filter(l=>l.kind==='PUMPS'))for(const a of l.coordinates)box.expandByPoint(new THREE.Vector3(a[0]-513800,model.verticalCoordination?.pump.pipeCenterY??.35,-(a[1]-2344450)));networkRoot.traverse(o=>{if(o.userData.kind==='PUMPS'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);}
|
||||
else if(p.id==='riser'){networkRoot.traverse(o=>{if(o.userData.assetId===model.cadAttachments[0].assetId)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);}
|
||||
else if(p.id==='singlePump'){networkRoot.traverse(o=>{if(o.userData.assetId==='inp:link:PU_VFD_1'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(.75);}
|
||||
else if(p.id==='meter'){networkRoot.traverse(o=>{if(o.userData.assetId==='zjb:meter:BJ9')box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(3);}
|
||||
else if(p.id==='main'){const distance=l=>l.coordinates.slice(1).reduce((s,p,i)=>s+Math.hypot(p[0]-l.coordinates[i][0],p[1]-l.coordinates[i][1]),0),l=model.links.filter(l=>l.kind==='PIPES').sort((a,b)=>distance(b)-distance(a))[0];for(const p of l.coordinates)box.expandByPoint(new THREE.Vector3(p[0]-513800,.35,-(p[1]-2344450)));box.expandByScalar(5);}
|
||||
else if(p.id==='crossing'){const a=model.layouts[8].pipes;let i=0;for(let j=0;j<a.length;j+=16)if(a[j+13]<a[i+13])i=j;const point=new THREE.Vector3(a[i+12],a[i+13],a[i+14]);box.setFromCenterAndSize(point,new THREE.Vector3(22,10,22));}
|
||||
else if(p.id==='riser'){networkRoot.traverse(o=>{if(o.userData.assetId===model.cadAttachments[0].assetId)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(4);}
|
||||
else if(p.id==='singlePump'){networkRoot.traverse(o=>{if(o.userData.assetId==='inp:link:PU_VFD_1'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(1.25);}
|
||||
else if(p.id==='meter'){networkRoot.traverse(o=>{if(o.userData.assetId==='zjb:meter:BJ9')box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(3.2);}
|
||||
else if(p.id==='main'){for(const point of mainLink.coordinates)box.expandByPoint(new THREE.Vector3(point[0]-513800,.35,-(point[1]-2344450)));box.expandByScalar(6);}
|
||||
else if(p.id==='crossing'){const a=model.layouts[8].pipes;let i=0;for(let j=0;j<a.length;j+=16)if(a[j+13]<a[i+13])i=j;const point=new THREE.Vector3(a[i+12],a[i+13],a[i+14]);box.setFromCenterAndSize(point,new THREE.Vector3(30,14,30));}
|
||||
return box;
|
||||
}
|
||||
async function visit(id){const p=[...presets,...saved].find(p=>p.id===id);if(!p)return;const token=++request;flight=null;emit('正在准备 '+p.label+'…');
|
||||
@@ -29,7 +70,8 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
|
||||
for(const o of root.children)if(/roof|ceiling/.test(o.userData.resourceId))o.visible=p.roof??true;
|
||||
if(p.id==='pump')for(const o of root.children)if(['detail_pump_walls','detail_pump_access'].includes(o.userData.resourceId))o.visible=false;
|
||||
if(p.layers)for(const o of root.children)if(typeof p.layers[o.userData.resourceId]==='boolean')o.visible=p.layers[o.userData.resourceId];
|
||||
let pose,box;if(p.position){pose=p;box=new THREE.Box3().setFromCenterAndSize(new THREE.Vector3(...p.target),new THREE.Vector3(20,20,20));}else{box=bounds(p);pose=framePose(box,camera.aspect,{direction:p.id==='plan'?[0,1,.0001]:p.id==='crossing'?[1,.8,1]:p.id==='pump'?[.5,.9,1]:[.6,.8,1]});}
|
||||
let pose,box;if(p.position){pose=p;box=new THREE.Box3().setFromCenterAndSize(new THREE.Vector3(...p.target),new THREE.Vector3(20,20,20));}else{box=bounds(p);pose=framePose(box,camera.aspect,p.id==='plan'?{direction:planDirection.toArray(),up:planUp.toArray(),padding:1.04}:p.id==='overview'?{direction:overviewDirection.toArray(),padding:1.08}:poseOptions[p.id]);}
|
||||
camera.up.fromArray(pose.up??[0,1,0]).normalize();
|
||||
appearance.frame(box,p.mode);camera.near=Math.max(.05,new THREE.Vector3(...pose.position).distanceTo(new THREE.Vector3(...pose.target))/2000);camera.far=20000;camera.updateProjectionMatrix();active=id;
|
||||
flight={start:performance.now(),from:camera.position.clone(),targetFrom:controls.target.clone(),to:new THREE.Vector3(...pose.position),targetTo:new THREE.Vector3(...pose.target)};
|
||||
if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();}
|
||||
@@ -37,7 +79,7 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
|
||||
}catch(e){if(token===request)emit('视角加载失败:'+e.message);}
|
||||
}
|
||||
function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;}
|
||||
function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');}
|
||||
function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');}
|
||||
function remove(id){saved=saved.filter(p=>p.id!==id);localStorage.setItem(key,JSON.stringify(saved));if(active===id)active=null;emit('已移除保存的视角');}
|
||||
emit('选择一个观察位置');return {visit,tick,save,remove,stop,presets};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
export const DEFAULT_STYLE=Object.freeze({scale:8,mode:'uniform',color:'#098ed0',missingColor:'#89949d',lowColor:'#2b83ba',highColor:'#e66c37',opacity:1,roughness:.3,metalness:.22,nodes:true,direction:'none',arrowColor:'#f2b447',autoRange:true,min:0,max:3});
|
||||
export const DEFAULT_STYLE=Object.freeze({scale:6,mode:'uniform',color:'#098ed0',missingColor:'#89949d',lowColor:'#2b83ba',highColor:'#e66c37',opacity:1,roughness:.3,metalness:.22,nodes:true,direction:'none',arrowColor:'#f2b447',autoRange:true,min:0,max:3});
|
||||
const finite=v=>typeof v==='number'&&Number.isFinite(v);
|
||||
export function validateResults(input,model){
|
||||
if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。');
|
||||
if(input.units?.velocity!=='m/s'||input.units?.pressure!=='mH2O'||input.units?.flow!=='L/s')throw Error('结果单位须明确为 m/s、mH2O、L/s。');
|
||||
if(input.units?.velocity!=='m/s'||input.units?.pressure!=='m'||input.units?.flow!=='m³/h')throw Error('结果单位须明确为 m/s、m、m³/h。');
|
||||
const linkIds=new Set(model.links.map(l=>l.id)),nodeIds=new Set(model.nodes.map(n=>n.id));
|
||||
for(const [table,ids,fields] of [[input.links??{},linkIds,['velocity','flow','pressure','direction']],[input.nodes??{},nodeIds,['pressure']]]){
|
||||
if(typeof table!=='object'||table===null||Array.isArray(table))throw Error('结果必须按编号提供对象。');
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
<body>
|
||||
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
|
||||
<output id="qa" hidden></output>
|
||||
<script type="module" src="./preview.mjs?v=29-platform2"></script>
|
||||
<script type="module" src="./preview.mjs?v=32-context-opacity"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {createRenderEffects} from './render-effects.mjs';
|
||||
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=28';
|
||||
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=31-camera-presets';
|
||||
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
|
||||
import * as THREE from 'three';
|
||||
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
||||
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
|
||||
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
|
||||
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=28b';
|
||||
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=32-default-scale';
|
||||
import {cadToGltf} from './integration.mjs';
|
||||
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=29';
|
||||
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity';
|
||||
|
||||
const HOST_CHANNEL='tjwater:zjb-scene',HOST_VERSION=2,PROJECT_CODE='zjb',MODEL_ID='zjb-water-network-v23';
|
||||
let net,networkRoot,networkStyle,navigation,inspector;
|
||||
let mode='network',generation=0,contextVisible=true,roofVisible=true;
|
||||
let sceneStatus='正在载入三维模型';
|
||||
let cameraState={active:null,note:'正在准备观察位置',views:[]};
|
||||
let appearanceState={preset:'day',exposure:.95,shadows:true,effects:true,quality:'standard'};
|
||||
let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'};
|
||||
|
||||
function postHost(type,detail={}){
|
||||
if(window.parent===window)return;
|
||||
@@ -37,7 +37,7 @@ function runtimeState(){
|
||||
function postState(){if(net&&networkStyle)postHost('scene-state',{state:runtimeState()});}
|
||||
function setStatus(value){sceneStatus=value;postState();}
|
||||
|
||||
window.webQA={errors:[],loaded:[],mode:'network'};
|
||||
window.webQA={errors:[],loaded:[],mode:'network',contextVisible};
|
||||
window.addEventListener('error',event=>{const message=String(event.message||'三维场景运行错误');window.webQA.errors.push(message);postHost('error',{message});});
|
||||
window.addEventListener('unhandledrejection',event=>{const message=String(event.reason||'三维场景异步任务失败');window.webQA.errors.push(message);postHost('error',{message});});
|
||||
|
||||
@@ -78,6 +78,7 @@ function fit(){
|
||||
if(box.isEmpty())return;
|
||||
appearance.frame(box,mode);
|
||||
const sphere=box.getBoundingSphere(new THREE.Sphere());controls.target.copy(sphere.center);
|
||||
camera.up.copy(new THREE.Vector3(0,1,0));
|
||||
camera.position.copy(sphere.center).add(new THREE.Vector3(.65,.72,1).normalize().multiplyScalar(sphere.radius*3.1/Math.min(1,camera.aspect)));
|
||||
camera.near=Math.max(.005,sphere.radius/5000);camera.far=sphere.radius*30+100;camera.updateProjectionMatrix();controls.update();
|
||||
}
|
||||
@@ -103,7 +104,7 @@ async function show(next,{frame=true}={}){
|
||||
const objects=await Promise.all(ids.map(load));if(token!==generation)return;
|
||||
root.clear();objects.forEach((object,index)=>{
|
||||
object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object);
|
||||
object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next));});
|
||||
object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next),appearanceState.contextOpacity);});
|
||||
});
|
||||
if(networkRoot)networkRoot.visible=['network','hydraulic'].includes(next);
|
||||
networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit();
|
||||
@@ -115,12 +116,14 @@ function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.st
|
||||
function applyAppearance(patch){
|
||||
if(patch.preset!==undefined){if(!['day','studio','evening'].includes(patch.preset))throw Error('不支持的光照场景');appearance.preset(patch.preset);appearanceState.preset=patch.preset;appearanceState.exposure=appearance.state.exposure;}
|
||||
if(patch.exposure!==undefined){if(!Number.isFinite(patch.exposure)||patch.exposure<.55||patch.exposure>1.6)throw Error('亮度超出有效范围');appearance.exposure(patch.exposure);appearanceState.exposure=patch.exposure;}
|
||||
if(patch.contextOpacity!==undefined){if(!Number.isFinite(patch.contextOpacity)||patch.contextOpacity<0||patch.contextOpacity>1)throw Error('建筑背景透明度超出有效范围');appearanceState.contextOpacity=patch.contextOpacity;for(const object of root.children)object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(mode),appearanceState.contextOpacity);});appearance.refresh();}
|
||||
if(patch.shadows!==undefined){appearance.shadows(Boolean(patch.shadows));appearanceState.shadows=Boolean(patch.shadows);}
|
||||
if(patch.effects!==undefined){effects.setEnabled(Boolean(patch.effects));appearanceState.effects=Boolean(patch.effects);}
|
||||
if(patch.quality!==undefined){if(!['standard','high'].includes(patch.quality))throw Error('不支持的画质');effects.setQuality(patch.quality);appearanceState.quality=patch.quality;localStorage.setItem('zjb-render-quality-v27',patch.quality);}
|
||||
window.webQA.appearance={...appearanceState};
|
||||
postState();
|
||||
}
|
||||
function toggleContext(){contextVisible=!contextVisible;applyVisibility();appearance.refresh();fit();postState();}
|
||||
function toggleContext(){contextVisible=!contextVisible;window.webQA.contextVisible=contextVisible;applyVisibility();appearance.refresh();postState();}
|
||||
function toggleRoof(){roofVisible=!roofVisible;applyVisibility();appearance.refresh();postState();}
|
||||
function resizeView(){const current=viewport();camera.aspect=current.width/current.height;camera.updateProjectionMatrix();renderer.setSize(current.width,current.height);effects.resize(current.width,current.height);}
|
||||
addEventListener('resize',resizeView);
|
||||
@@ -146,7 +149,7 @@ async function locateAsset(item){
|
||||
if(mode!==targetMode)await show(targetMode,{frame:false});if(request!==locateRequest||generation!==expected)return;
|
||||
setDisplayMode(targetMode==='hydraulic'?'coordinated':'global');contextVisible=targetMode!=='hydraulic';applyVisibility();
|
||||
const box=inspector.bounds(item);if(box.isEmpty())return;box.expandByScalar(item.link?.kind==='PUMPS'?.6:1);
|
||||
const pose=framePose(box,camera.aspect,{padding:1.3});controls.target.fromArray(pose.target);camera.position.fromArray(pose.position);camera.far=20000;controls.update();appearance.frame(box,targetMode);inspector.highlight();postState();
|
||||
const pose=framePose(box,camera.aspect,{padding:1.3});camera.up.fromArray(pose.up??[0,1,0]).normalize();controls.target.fromArray(pose.target);camera.position.fromArray(pose.position);camera.far=20000;controls.update();appearance.frame(box,targetMode);inspector.highlight();postState();
|
||||
}
|
||||
|
||||
let lastFrame=performance.now(),frameTimes=[];
|
||||
@@ -155,15 +158,16 @@ renderer.setAnimationLoop(()=>{
|
||||
if(frameTimes.length>=60)window.webQA.performance={samples:frameTimes.length,meanFps:1000/(frameTimes.reduce((sum,value)=>sum+value,0)/frameTimes.length),viewport:viewport(),quality:effects.state.quality};
|
||||
renderer.info.reset();navigation?.tick(performance.now());controls.update();const near=Math.max(.02,camera.position.distanceTo(controls.target)/150);
|
||||
if(Math.abs(camera.near-near)>.001){camera.near=near;camera.updateProjectionMatrix();}
|
||||
effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;document.getElementById('qa').textContent=JSON.stringify(window.webQA);
|
||||
effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;window.webQA.camera={active:cameraState.active,position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray()};document.getElementById('qa').textContent=JSON.stringify(window.webQA);
|
||||
});
|
||||
|
||||
net=await (await fetch(manifest.networkModel.metadata,{cache:'no-store'})).json();
|
||||
const built=await loader.loadAsync(manifest.networkModel.file+'?v='+manifest.networkModel.sha256);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot);
|
||||
networkStyle=attachNetworkStyle(networkRoot,net);grain(networkStyle.pipes.material,{frequency:140,amplitude:.00004,variation:.08});
|
||||
try{const saved=localStorage.getItem('zjb-network-style-v23');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{}
|
||||
try{const saved=localStorage.getItem('zjb-network-style-v24');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{}
|
||||
try{const quality=localStorage.getItem('zjb-render-quality-v27')??'standard';effects.setQuality(quality);appearanceState.quality=quality;}catch{effects.setQuality('standard');}
|
||||
window.webQA.network={...net.connectivity,meters:net.cadEquipmentBindings.length,modelId:net.modelId,runtimeInpRequired:false};
|
||||
window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};window.webQA.appearance={...appearanceState};
|
||||
navigation=createCameraNavigation({camera,controls,model:net,networkRoot,root,show,getMode:()=>mode,appearance,getDisplayMode:()=>networkStyle.displayMode,setDisplayMode,onChange(state){
|
||||
cameraState={active:state.active,note:state.note,views:state.views.map(view=>({id:view.id,label:view.label,mode:view.mode,note:view.note,saved:view.id.startsWith('saved-')}))};postState();
|
||||
}});
|
||||
@@ -185,8 +189,8 @@ window.addEventListener('message',async event=>{
|
||||
else if(command.name==='visit-camera')await navigation.visit(command.viewId);
|
||||
else if(command.name==='save-camera')navigation.save(command.label);
|
||||
else if(command.name==='remove-camera')navigation.remove(command.viewId);
|
||||
else if(command.name==='set-style'){networkStyle.setStyle(command.patch);localStorage.setItem('zjb-network-style-v23',JSON.stringify(networkStyle.style));updateStyle();}
|
||||
else if(command.name==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v23');updateStyle();}
|
||||
else if(command.name==='set-style'){networkStyle.setStyle(command.patch);localStorage.setItem('zjb-network-style-v24',JSON.stringify(networkStyle.style));updateStyle();}
|
||||
else if(command.name==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v24');updateStyle();}
|
||||
else if(command.name==='set-display-mode')setDisplayMode(command.mode);
|
||||
else if(command.name==='set-appearance')applyAppearance(command.patch);
|
||||
else if(command.name==='toggle-context')toggleContext();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const projectDir = process.cwd();
|
||||
const standaloneDir = path.join(projectDir, ".next", "standalone");
|
||||
const standalonePublicDir = path.join(standaloneDir, "public");
|
||||
const standaloneStaticDir = path.join(standaloneDir, ".next", "static");
|
||||
|
||||
fs.rmSync(standalonePublicDir, { recursive: true, force: true });
|
||||
fs.rmSync(standaloneStaticDir, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(projectDir, "public"), standalonePublicDir, {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(standaloneStaticDir), { recursive: true });
|
||||
fs.cpSync(path.join(projectDir, ".next", "static"), standaloneStaticDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const server = spawn(process.execPath, [path.join(standaloneDir, "server.js")], {
|
||||
cwd: standaloneDir,
|
||||
env: {
|
||||
...process.env,
|
||||
HOSTNAME: "127.0.0.1",
|
||||
PORT: "3100",
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const stopServer = (signal) => {
|
||||
if (!server.killed) server.kill(signal);
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => stopServer("SIGINT"));
|
||||
process.once("SIGTERM", () => stopServer("SIGTERM"));
|
||||
server.once("exit", (code, signal) => {
|
||||
process.exitCode = signal ? 1 : (code ?? 1);
|
||||
});
|
||||
@@ -41,6 +41,19 @@ import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
import {
|
||||
ElementHistoryResult,
|
||||
ElementHistorySeries,
|
||||
ElementHistoryTarget,
|
||||
fetchElementHistory,
|
||||
historySeriesKey,
|
||||
TimeSeriesPoint,
|
||||
toTimeSeriesPoints,
|
||||
} from "@/lib/elementHistory";
|
||||
import {
|
||||
FLOW_DISPLAY_UNIT,
|
||||
PRESSURE_DISPLAY_UNIT,
|
||||
} from "@/utils/units";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -50,13 +63,6 @@ type IUser = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
/** ISO8601 时间戳 */
|
||||
timestamp: string;
|
||||
/** 每个设备对应的值 */
|
||||
values: Record<string, number | null | undefined>;
|
||||
}
|
||||
|
||||
export interface SCADADataPanelProps {
|
||||
/** 选中的设备 ID 列表 */
|
||||
deviceIds: string[];
|
||||
@@ -90,164 +96,75 @@ const panelHeaderActionSx = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 从后端 API 获取 SCADA 数据
|
||||
*/
|
||||
interface ScadaDeviceMetadata {
|
||||
device_id: string;
|
||||
device_type: string;
|
||||
node_id: string | null;
|
||||
link_id: string | null;
|
||||
}
|
||||
|
||||
/** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */
|
||||
const fetchFromBackend = async (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date },
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
): Promise<ElementHistoryResult> => {
|
||||
if (deviceIds.length === 0) {
|
||||
return [];
|
||||
return { points: [], series: [] };
|
||||
}
|
||||
|
||||
const device_ids = deviceIds.join(",");
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
// 清洗数据接口
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 原始数据
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
try {
|
||||
// 优先查询清洗数据和模拟数据
|
||||
const [cleaningRes, simulationRes] = await Promise.all([
|
||||
apiFetch(cleaningDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(simulationDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const cleaningData = transformBackendData(cleaningRes, deviceIds);
|
||||
const simulationData = transformBackendData(simulationRes, deviceIds);
|
||||
|
||||
// 如果清洗数据有数据,返回清洗和模拟数据
|
||||
if (cleaningData.length > 0) {
|
||||
return mergeTimeSeriesData(
|
||||
cleaningData,
|
||||
simulationData,
|
||||
deviceIds,
|
||||
"clean",
|
||||
"sim",
|
||||
const metadataResponse = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/scada-devices`,
|
||||
);
|
||||
} else {
|
||||
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
|
||||
const rawRes = await apiFetch(rawDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null);
|
||||
const rawData = transformBackendData(rawRes, deviceIds);
|
||||
return mergeTimeSeriesData(
|
||||
simulationData,
|
||||
rawData,
|
||||
deviceIds,
|
||||
"sim",
|
||||
"raw",
|
||||
if (!metadataResponse.ok) {
|
||||
throw new Error(`SCADA 设备信息请求失败: HTTP ${metadataResponse.status}`);
|
||||
}
|
||||
const allDevices = (await metadataResponse.json()) as ScadaDeviceMetadata[];
|
||||
const requested = new Set(deviceIds);
|
||||
const devices = allDevices.filter((device) => requested.has(device.device_id));
|
||||
const missing = deviceIds.filter(
|
||||
(deviceId) => !devices.some((device) => device.device_id === deviceId),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`SCADA 设备不存在: ${missing.join(", ")}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
|
||||
throw error;
|
||||
|
||||
const targetsByElement = new Map<string, ElementHistoryTarget>();
|
||||
devices.forEach((device) => {
|
||||
const isFlow = ["pipe_flow", "flow"].includes(
|
||||
device.device_type.toLowerCase(),
|
||||
);
|
||||
const elementId = isFlow ? device.link_id : device.node_id;
|
||||
const elementType = isFlow ? "pipe" : "junction";
|
||||
if (!elementId) {
|
||||
throw new Error(`SCADA 设备 ${device.device_id} 未关联管网元素`);
|
||||
}
|
||||
const key = `${elementType}:${elementId}`;
|
||||
const target = targetsByElement.get(key) ?? {
|
||||
element_id: elementId,
|
||||
element_type: elementType,
|
||||
device_ids: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换后端数据格式
|
||||
* 根据实际后端返回的数据结构进行调整
|
||||
*/
|
||||
const transformBackendData = (
|
||||
backendData: any,
|
||||
deviceIds: string[],
|
||||
): TimeSeriesPoint[] => {
|
||||
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
|
||||
if (backendData && !Array.isArray(backendData)) {
|
||||
// 检查是否是设备ID为键的对象格式
|
||||
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
|
||||
|
||||
if (hasDeviceKeys) {
|
||||
// 获取所有时间点的集合
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = backendData[deviceId];
|
||||
if (Array.isArray(deviceData)) {
|
||||
deviceData.forEach((item: any) => {
|
||||
const timestamp = item.time || item.timestamp || item._time;
|
||||
if (timestamp) {
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(timestamp)!;
|
||||
values[deviceId] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
target.device_ids!.push(device.device_id);
|
||||
targetsByElement.set(key, target);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 转换为 TimeSeriesPoint 数组并按时间排序
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}),
|
||||
const targets = Array.from(targetsByElement.values());
|
||||
const result = await fetchElementHistory(
|
||||
targets,
|
||||
range,
|
||||
"realtime_comparison",
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
const expandedSeries = result.series.flatMap((series) => {
|
||||
if (series.device_id || series.source.startsWith("scada_")) return [series];
|
||||
const target = targets.find(
|
||||
(item) =>
|
||||
item.element_id === series.element_id &&
|
||||
item.element_type === series.element_type,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// 默认返回空数组
|
||||
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 合并两个时间序列数据,为每个设备添加后缀
|
||||
*/
|
||||
const mergeTimeSeriesData = (
|
||||
data1: TimeSeriesPoint[],
|
||||
data2: TimeSeriesPoint[],
|
||||
deviceIds: string[],
|
||||
suffix1: string,
|
||||
suffix2: string,
|
||||
): TimeSeriesPoint[] => {
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: TimeSeriesPoint[], suffix: string) => {
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
processData(data1, suffix1);
|
||||
processData(data2, suffix2);
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
return (target?.device_ids ?? []).map((deviceId) => ({
|
||||
...series,
|
||||
device_id: deviceId,
|
||||
}));
|
||||
|
||||
result.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
return { series: expandedSeries, points: toTimeSeriesPoints(expandedSeries) };
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) =>
|
||||
@@ -337,83 +254,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const { open } = useNotification();
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
|
||||
const customFetcher = useMemo(() => {
|
||||
if (!showCleaning) {
|
||||
return fetchFromBackend;
|
||||
}
|
||||
|
||||
return async (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date },
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
const device_ids = deviceIds.join(",");
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
|
||||
// 清洗数据接口
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 原始数据
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
try {
|
||||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||||
apiFetch(cleaningDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(rawDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(simulationDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: any, suffix: string) => {
|
||||
if (!data) return;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = data[deviceId];
|
||||
if (Array.isArray(deviceData)) {
|
||||
deviceData.forEach((item: any) => {
|
||||
const timestamp = item.time || item.timestamp || item._time;
|
||||
if (timestamp) {
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(timestamp)!;
|
||||
values[`${deviceId}_${suffix}`] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processData(cleanRes, "clean");
|
||||
processData(rawRes, "raw");
|
||||
processData(simRes, "sim");
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}),
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 获取三种数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}, [showCleaning]);
|
||||
const customFetcher = fetchFromBackend;
|
||||
|
||||
const [from, setFrom] = useState<Dayjs>(() => {
|
||||
if (start_time) {
|
||||
@@ -435,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
@@ -473,11 +315,30 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
||||
[timeSeries, deviceIds, fractionDigits, showCleaning],
|
||||
);
|
||||
const seriesByKey = useMemo(
|
||||
() =>
|
||||
new Map(historySeries.map((series) => [historySeriesKey(series), series])),
|
||||
[historySeries],
|
||||
);
|
||||
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
|
||||
const hasPressureSeries = historySeries.some(
|
||||
(item) => item.metric === "pressure",
|
||||
);
|
||||
const unitForKey = useCallback(
|
||||
(key: string) => seriesByKey.get(key)?.display_unit ?? "",
|
||||
[seriesByKey],
|
||||
);
|
||||
const axisForKey = useCallback(
|
||||
(key: string) =>
|
||||
seriesByKey.get(key)?.metric === "pressure" && hasFlowSeries ? 1 : 0,
|
||||
[hasFlowSeries, seriesByKey],
|
||||
);
|
||||
|
||||
const handleFetch = useCallback(
|
||||
async (reason: string) => {
|
||||
if (!hasDevices) {
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
setLoadingState("idle");
|
||||
setError(null);
|
||||
return;
|
||||
@@ -491,7 +352,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
});
|
||||
setTimeSeries(result);
|
||||
setTimeSeries(result.points);
|
||||
setHistorySeries(result.series);
|
||||
setLoadingState("success");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "未知错误");
|
||||
@@ -583,6 +445,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
handleFetch("device-change");
|
||||
} else {
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
}
|
||||
}, [deviceIdsKey, handleFetch, hasDevices]);
|
||||
|
||||
@@ -610,7 +473,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
return deviceIds.flatMap<GridColDef>((id) => [
|
||||
{
|
||||
field: `${id}_raw`,
|
||||
headerName: `${id} (原始)`,
|
||||
headerName: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -623,7 +486,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
},
|
||||
{
|
||||
field: `${id}_clean`,
|
||||
headerName: `${id} (清洗)`,
|
||||
headerName: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -636,7 +499,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
},
|
||||
{
|
||||
field: `${id}_sim`,
|
||||
headerName: `${id} (模拟)`,
|
||||
headerName: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -652,7 +515,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
// 单一数据源模式:只显示选中的数据源
|
||||
return deviceIds.map<GridColDef>((id) => ({
|
||||
field: `${id}_${selectedSource}`,
|
||||
headerName: id,
|
||||
headerName: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -688,7 +551,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
if (hasData) {
|
||||
cols.push({
|
||||
field: fieldKey,
|
||||
headerName: `${deviceName} (${name})`,
|
||||
headerName: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
@@ -708,7 +571,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
})();
|
||||
|
||||
return [...base, ...dynamic];
|
||||
}, [deviceIds, fractionDigits, showCleaning, selectedSource, dataset]);
|
||||
}, [
|
||||
deviceIds,
|
||||
fractionDigits,
|
||||
showCleaning,
|
||||
selectedSource,
|
||||
dataset,
|
||||
unitForKey,
|
||||
]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
@@ -766,8 +636,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
if (selectedSource === "all") {
|
||||
return deviceIds.flatMap((id, index) => [
|
||||
{
|
||||
name: `${id} (原始)`,
|
||||
name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_raw`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -775,8 +646,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
data: dataset.map((item) => item[`${id}_raw`]),
|
||||
},
|
||||
{
|
||||
name: `${id} (清洗)`,
|
||||
name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_clean`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -784,8 +656,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
data: dataset.map((item) => item[`${id}_clean`]),
|
||||
},
|
||||
{
|
||||
name: `${id} (模拟)`,
|
||||
name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_sim`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -795,8 +668,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
]);
|
||||
} else {
|
||||
return deviceIds.map((id, index) => ({
|
||||
name: id,
|
||||
name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(`${id}_${selectedSource}`),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -820,8 +694,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
: suffix === "clean"
|
||||
? "清洗"
|
||||
: "模拟"
|
||||
})`,
|
||||
}) [${unitForKey(key)}]`,
|
||||
type: "line",
|
||||
yAxisIndex: axisForKey(key),
|
||||
symbol: "none",
|
||||
connectNulls: true,
|
||||
sampling: "lttb",
|
||||
@@ -908,10 +783,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
boundaryGap: false,
|
||||
data: xData,
|
||||
},
|
||||
yAxis: {
|
||||
yAxis: [
|
||||
...(hasFlowSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasPressureSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: "inside",
|
||||
|
||||
@@ -24,14 +24,8 @@ const range = {
|
||||
describe("fetchHistoryData", () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it("queries SCADA readings once per selected network element", async () => {
|
||||
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
||||
const url = new URL(String(input));
|
||||
const elementId = url.searchParams.get("element_id") ?? "";
|
||||
return jsonResponse({
|
||||
[elementId]: [{ time: range.from.toISOString(), value: 1 }],
|
||||
});
|
||||
});
|
||||
it("queries all selected elements in one batch request", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
|
||||
await fetchHistoryData(
|
||||
[
|
||||
@@ -42,16 +36,20 @@ describe("fetchHistoryData", () => {
|
||||
"none",
|
||||
);
|
||||
|
||||
const elementIds = jest
|
||||
.mocked(apiFetch)
|
||||
.mock.calls.map(([input]) =>
|
||||
new URL(String(input)).searchParams.get("element_id"),
|
||||
);
|
||||
expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = jest.mocked(apiFetch).mock.calls[0];
|
||||
expect(String(url)).toContain("/element-history/query");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
mode: "observed",
|
||||
elements: [
|
||||
{ element_id: "J-1", element_type: "junction" },
|
||||
{ element_id: "J-2", element_type: "junction" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the analysis run ID for historical scheme simulation data", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ "P-1": [] }));
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
|
||||
await fetchHistoryData(
|
||||
[["P-1", "pipe"]],
|
||||
@@ -60,15 +58,12 @@ describe("fetchHistoryData", () => {
|
||||
"99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||
);
|
||||
|
||||
const simulationUrls = jest
|
||||
.mocked(apiFetch)
|
||||
.mock.calls.map(([input]) => new URL(String(input)))
|
||||
.filter((url) => url.pathname.endsWith("/element-simulations"));
|
||||
|
||||
expect(simulationUrls).toHaveLength(2);
|
||||
expect(
|
||||
simulationUrls.map((url) => url.searchParams.get("run_id")),
|
||||
).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
const [, init] = jest.mocked(apiFetch).mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
mode: "analysis_comparison",
|
||||
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects oversized element selections before issuing requests", async () => {
|
||||
@@ -84,17 +79,8 @@ describe("fetchHistoryData", () => {
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("limits concurrent SCADA requests for multi-element history", async () => {
|
||||
let activeRequests = 0;
|
||||
let peakRequests = 0;
|
||||
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
||||
activeRequests += 1;
|
||||
peakRequests = Math.max(peakRequests, activeRequests);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
activeRequests -= 1;
|
||||
const elementId = new URL(String(input)).searchParams.get("element_id") ?? "";
|
||||
return jsonResponse({ [elementId]: [] });
|
||||
});
|
||||
it("does not create N+1 requests for multi-element history", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
|
||||
await fetchHistoryData(
|
||||
Array.from(
|
||||
@@ -105,6 +91,6 @@ describe("fetchHistoryData", () => {
|
||||
"none",
|
||||
);
|
||||
|
||||
expect(peakRequests).toBeLessThanOrEqual(8);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,20 +34,23 @@ import timezone from "dayjs/plugin/timezone";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||
import config from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||
import {
|
||||
ElementHistoryResult,
|
||||
ElementHistorySeries,
|
||||
fetchElementHistory,
|
||||
historySeriesKey,
|
||||
historySeriesLabel,
|
||||
TimeSeriesPoint,
|
||||
} from "@/lib/elementHistory";
|
||||
import {
|
||||
FLOW_DISPLAY_UNIT,
|
||||
PRESSURE_DISPLAY_UNIT,
|
||||
} from "@/utils/units";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
/** ISO8601 时间戳 */
|
||||
timestamp: string;
|
||||
/** 每个设备对应的值 */
|
||||
values: Record<string, number | null | undefined>;
|
||||
}
|
||||
|
||||
export interface SCADADataPanelProps {
|
||||
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
|
||||
featureInfos: [string, string][];
|
||||
@@ -73,7 +76,6 @@ type LoadingState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
const MAX_HISTORY_ELEMENTS = 200;
|
||||
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
|
||||
const HISTORY_SCADA_CONCURRENCY = 4;
|
||||
|
||||
const panelHeaderActionSx = {
|
||||
color: "primary.contrastText",
|
||||
@@ -83,48 +85,6 @@ const panelHeaderActionSx = {
|
||||
},
|
||||
};
|
||||
|
||||
const buildApiUrl = (
|
||||
path: string,
|
||||
params: Record<string, string | boolean>,
|
||||
) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
return `${config.BACKEND_URL}${path}?${searchParams.toString()}`;
|
||||
};
|
||||
|
||||
const fetchOptionalJson = async (url: string, signal?: AbortSignal) => {
|
||||
const response = await apiFetch(url, { signal });
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`历史数据请求失败: HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const mapWithConcurrency = async <Input, Output>(
|
||||
items: Input[],
|
||||
limit: number,
|
||||
mapper: (item: Input, index: number) => Promise<Output>,
|
||||
): Promise<Output[]> => {
|
||||
const results = new Array<Output>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(limit, items.length);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */
|
||||
export const fetchHistoryData = async (
|
||||
featureInfos: [string, string][],
|
||||
@@ -132,9 +92,9 @@ export const fetchHistoryData = async (
|
||||
type: "realtime" | "scheme" | "none",
|
||||
schemeRunId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
): Promise<ElementHistoryResult> => {
|
||||
if (featureInfos.length === 0) {
|
||||
return [];
|
||||
return { points: [], series: [] };
|
||||
}
|
||||
if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
|
||||
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
|
||||
@@ -149,285 +109,36 @@ export const fetchHistoryData = async (
|
||||
}
|
||||
|
||||
const uniqueFeatureInfos = Array.from(
|
||||
new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(),
|
||||
);
|
||||
const featureIds = uniqueFeatureInfos.map(([id]) => id);
|
||||
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
|
||||
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
|
||||
const feature_infos = uniqueFeatureInfos
|
||||
.map(([id, type]) => `${id}:${type}`)
|
||||
.join(",");
|
||||
|
||||
const fetchElementScadaData = async (useCleaned: boolean) => {
|
||||
const results = await mapWithConcurrency(
|
||||
featureIds,
|
||||
HISTORY_SCADA_CONCURRENCY,
|
||||
(elementId) =>
|
||||
fetchOptionalJson(
|
||||
buildApiUrl("/api/v1/timeseries/views/element-scada-readings", {
|
||||
element_id: elementId,
|
||||
start_time,
|
||||
end_time,
|
||||
use_cleaned: useCleaned,
|
||||
}),
|
||||
signal,
|
||||
),
|
||||
);
|
||||
return Object.assign({}, ...results.filter(Boolean));
|
||||
};
|
||||
|
||||
const simulationDataUrl = buildApiUrl(
|
||||
"/api/v1/timeseries/views/element-simulations",
|
||||
{ feature_infos, start_time, end_time },
|
||||
new Map(
|
||||
featureInfos.map((featureInfo) => [featureInfo.join(":"), featureInfo]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
if (type === "scheme" && !schemeRunId) {
|
||||
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
|
||||
}
|
||||
|
||||
const schemeSimulationDataUrl = schemeRunId
|
||||
? buildApiUrl("/api/v1/timeseries/views/element-simulations", {
|
||||
feature_infos,
|
||||
start_time,
|
||||
end_time,
|
||||
run_id: schemeRunId,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
if (type === "none") {
|
||||
// 查询清洗值和监测值
|
||||
const [cleanedRes, rawRes] = await Promise.all([
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
// 如果清洗数据有值,则不显示原始监测值
|
||||
const rawData =
|
||||
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
||||
|
||||
return mergeTimeSeriesData(
|
||||
cleanedData,
|
||||
rawData,
|
||||
featureIds,
|
||||
"clean",
|
||||
"raw"
|
||||
return await fetchElementHistory(
|
||||
uniqueFeatureInfos.map(([id, elementType]) => ({
|
||||
element_id: id.trim(),
|
||||
element_type: elementType.toLowerCase() as "pipe" | "junction",
|
||||
})),
|
||||
range,
|
||||
type === "none"
|
||||
? "observed"
|
||||
: type === "scheme"
|
||||
? "analysis_comparison"
|
||||
: "realtime_comparison",
|
||||
schemeRunId,
|
||||
signal,
|
||||
);
|
||||
} else if (type === "scheme") {
|
||||
// 查询策略模拟值、实时模拟值、清洗值和监测值
|
||||
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
fetchOptionalJson(simulationDataUrl, signal),
|
||||
fetchOptionalJson(schemeSimulationDataUrl!, signal),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
// 如果清洗数据有值,则不显示原始监测值
|
||||
const rawData =
|
||||
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
||||
const simulationData = transformBackendData(simulationRes, featureIds);
|
||||
const schemeSimData = transformBackendData(schemeSimRes, featureIds);
|
||||
|
||||
return mergeMultipleTimeSeriesData(
|
||||
[
|
||||
{ data: cleanedData, suffix: "clean" },
|
||||
{ data: rawData, suffix: "raw" },
|
||||
{ data: simulationData, suffix: "sim" },
|
||||
{ data: schemeSimData, suffix: "scheme_sim" },
|
||||
],
|
||||
featureIds
|
||||
);
|
||||
} else {
|
||||
// realtime: 查询模拟值、清洗值和监测值
|
||||
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
fetchOptionalJson(simulationDataUrl, signal),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
// 如果清洗数据有值,则不显示原始监测值
|
||||
const rawData =
|
||||
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
||||
const simulationData = transformBackendData(simulationRes, featureIds);
|
||||
|
||||
// 合并三组数据
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
[cleanedData, rawData, simulationData].forEach((data, index) => {
|
||||
const suffix = ["clean", "raw", "sim"][index];
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
featureIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
})
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
|
||||
console.error("[HistoryDataPanel] 从后端获取数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换后端数据格式
|
||||
* 根据实际后端返回的数据结构进行调整
|
||||
*/
|
||||
const transformBackendData = (
|
||||
backendData: any,
|
||||
deviceIds: string[]
|
||||
): TimeSeriesPoint[] => {
|
||||
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
|
||||
if (backendData && !Array.isArray(backendData)) {
|
||||
// 检查是否是设备ID为键的对象格式
|
||||
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
|
||||
|
||||
if (hasDeviceKeys) {
|
||||
// 获取所有时间点的集合
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = backendData[deviceId];
|
||||
if (Array.isArray(deviceData)) {
|
||||
deviceData.forEach((item: any) => {
|
||||
const timestamp = item.time || item.timestamp || item._time;
|
||||
if (timestamp) {
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(timestamp)!;
|
||||
values[deviceId] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 转换为 TimeSeriesPoint 数组并按时间排序
|
||||
const result = Array.from(timeMap.entries()).map(
|
||||
([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
})
|
||||
);
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// 默认返回空数组
|
||||
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 合并两个时间序列数据,为每个设备添加后缀
|
||||
*/
|
||||
const mergeTimeSeriesData = (
|
||||
data1: TimeSeriesPoint[],
|
||||
data2: TimeSeriesPoint[],
|
||||
deviceIds: string[],
|
||||
suffix1: string,
|
||||
suffix2: string
|
||||
): TimeSeriesPoint[] => {
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: TimeSeriesPoint[], suffix: string) => {
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
processData(data1, suffix1);
|
||||
processData(data2, suffix2);
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}));
|
||||
|
||||
result.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const mergeMultipleTimeSeriesData = (
|
||||
datasets: Array<{
|
||||
data: TimeSeriesPoint[];
|
||||
suffix: string;
|
||||
}>,
|
||||
deviceIds: string[]
|
||||
): TimeSeriesPoint[] => {
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
datasets.forEach(({ data, suffix }) => {
|
||||
data.forEach((point) => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, {});
|
||||
}
|
||||
const values = timeMap.get(point.timestamp)!;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const value = point.values[deviceId];
|
||||
if (value !== undefined) {
|
||||
values[`${deviceId}_${suffix}`] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
|
||||
timestamp,
|
||||
values,
|
||||
}));
|
||||
|
||||
result.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) =>
|
||||
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
||||
|
||||
@@ -443,7 +154,7 @@ const ensureValidRange = (
|
||||
|
||||
const buildDataset = (
|
||||
points: TimeSeriesPoint[],
|
||||
deviceIds: string[],
|
||||
series: ElementHistorySeries[],
|
||||
fractionDigits: number
|
||||
) => {
|
||||
return points.map((point) => {
|
||||
@@ -452,9 +163,8 @@ const buildDataset = (
|
||||
label: formatTimestamp(point.timestamp),
|
||||
};
|
||||
|
||||
deviceIds.forEach((id) => {
|
||||
["clean", "raw", "sim", "scheme_sim"].forEach((suffix) => {
|
||||
const key = `${id}_${suffix}`;
|
||||
series.forEach((metadata) => {
|
||||
const key = historySeriesKey(metadata);
|
||||
const value = point.values[key];
|
||||
if (value !== undefined && value !== null) {
|
||||
entry[key] =
|
||||
@@ -465,7 +175,6 @@ const buildDataset = (
|
||||
: value ?? null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return entry;
|
||||
});
|
||||
@@ -521,11 +230,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<
|
||||
"raw" | "clean" | "sim" | "all"
|
||||
>(() => (featureInfos.length === 1 ? "all" : "clean"));
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -559,8 +266,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
);
|
||||
|
||||
const dataset = useMemo(
|
||||
() => buildDataset(timeSeries, deviceIds, fractionDigits),
|
||||
[timeSeries, deviceIds, fractionDigits]
|
||||
() => buildDataset(timeSeries, historySeries, fractionDigits),
|
||||
[timeSeries, historySeries, fractionDigits]
|
||||
);
|
||||
|
||||
const handleFetch = useCallback(
|
||||
@@ -568,6 +275,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
if (!hasDevices) {
|
||||
requestControllerRef.current?.abort();
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
setLoadingState("idle");
|
||||
setError(null);
|
||||
return;
|
||||
@@ -591,7 +299,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
requestController.signal,
|
||||
);
|
||||
if (requestControllerRef.current !== requestController) return;
|
||||
setTimeSeries(result);
|
||||
setTimeSeries(result.points);
|
||||
setHistorySeries(result.series);
|
||||
setLoadingState("success");
|
||||
} catch (err) {
|
||||
if (
|
||||
@@ -620,16 +329,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
handleFetch("device-change");
|
||||
} else {
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
}
|
||||
}, [featureInfosKey, handleFetch, hasDevices]);
|
||||
|
||||
// 当设备数量变化时,调整数据源选择
|
||||
useEffect(() => {
|
||||
if (featureInfos.length > 1 && selectedSource === "all") {
|
||||
setSelectedSource("clean");
|
||||
}
|
||||
}, [featureInfos.length, selectedSource]);
|
||||
|
||||
const columns: GridColDef[] = useMemo(() => {
|
||||
const base: GridColDef[] = [
|
||||
{
|
||||
@@ -643,27 +346,16 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const dynamic = (() => {
|
||||
const cols: GridColDef[] = [];
|
||||
|
||||
deviceIds.forEach((id) => {
|
||||
// 为每个设备的每种数据类型创建列
|
||||
const suffixes = [
|
||||
{ key: "clean", name: "清洗值" },
|
||||
{ key: "raw", name: "监测值" },
|
||||
{ key: "sim", name: "实时模拟值" },
|
||||
{ key: "scheme_sim", name: "方案模拟值" },
|
||||
];
|
||||
|
||||
suffixes.forEach(({ key, name }) => {
|
||||
const fieldKey = `${id}_${key}`;
|
||||
// 检查是否有该字段的数据
|
||||
historySeries.forEach((metadata) => {
|
||||
const fieldKey = historySeriesKey(metadata);
|
||||
const hasData = dataset.some(
|
||||
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined
|
||||
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined,
|
||||
);
|
||||
|
||||
if (hasData) {
|
||||
cols.push({
|
||||
field: fieldKey,
|
||||
headerName: `${id} (${name})`,
|
||||
minWidth: 140,
|
||||
headerName: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
|
||||
minWidth: 180,
|
||||
flex: 1,
|
||||
valueFormatter: (value: any) => {
|
||||
if (value === null || value === undefined) return "--";
|
||||
@@ -675,13 +367,12 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return cols;
|
||||
})();
|
||||
|
||||
return [...base, ...dynamic];
|
||||
}, [deviceIds, fractionDigits, dataset]);
|
||||
}, [historySeries, fractionDigits, dataset]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
@@ -733,76 +424,43 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
];
|
||||
|
||||
const xData = dataset.map((item) => item.label);
|
||||
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
|
||||
const hasPressureSeries = historySeries.some(
|
||||
(item) => item.metric === "pressure",
|
||||
);
|
||||
|
||||
const getSeries = () => {
|
||||
return deviceIds.flatMap((id, index) => {
|
||||
const series = [];
|
||||
["clean", "raw", "sim", "scheme_sim"].forEach((suffix, sIndex) => {
|
||||
const key = `${id}_${suffix}`;
|
||||
const hasData = dataset.some(
|
||||
(item) => item[key] !== null && item[key] !== undefined
|
||||
return historySeries.flatMap((metadata, index) => {
|
||||
const key = historySeriesKey(metadata);
|
||||
const hasSeriesData = dataset.some(
|
||||
(item) => item[key] !== null && item[key] !== undefined,
|
||||
);
|
||||
if (hasData) {
|
||||
const displayName =
|
||||
suffix === "clean"
|
||||
? "清洗值"
|
||||
: suffix === "raw"
|
||||
? "监测值"
|
||||
: suffix === "sim"
|
||||
? "实时模拟"
|
||||
: "方案模拟";
|
||||
|
||||
series.push({
|
||||
name: `${id} (${displayName})`,
|
||||
if (!hasSeriesData) return [];
|
||||
const isObserved = metadata.source.startsWith("scada_");
|
||||
return [
|
||||
{
|
||||
name: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
|
||||
type: "line",
|
||||
yAxisIndex:
|
||||
metadata.metric === "pressure" && hasFlowSeries ? 1 : 0,
|
||||
symbol:
|
||||
suffix === "clean"
|
||||
metadata.source === "scada_cleaned"
|
||||
? "circle"
|
||||
: suffix === "raw"
|
||||
: metadata.source === "scada_raw"
|
||||
? "diamond"
|
||||
: "none",
|
||||
symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0,
|
||||
showSymbol: suffix === "clean" || suffix === "raw",
|
||||
symbolSize: isObserved ? 7 : 0,
|
||||
showSymbol: isObserved,
|
||||
sampling: "lttb",
|
||||
connectNulls: suffix !== "clean" && suffix !== "raw",
|
||||
connectNulls: !isObserved,
|
||||
itemStyle: {
|
||||
color: colors[(index * 4 + sIndex) % colors.length],
|
||||
color: colors[index % colors.length],
|
||||
},
|
||||
data: dataset.map((item) => item[key]),
|
||||
lineStyle:
|
||||
suffix === "clean" || suffix === "raw"
|
||||
? { width: 0 }
|
||||
: undefined,
|
||||
areaStyle:
|
||||
suffix === "clean" || suffix === "raw"
|
||||
lineStyle: isObserved ? { width: 0 } : undefined,
|
||||
areaStyle: isObserved
|
||||
? undefined
|
||||
: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: colors[(index * 4 + sIndex) % colors.length],
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: "rgba(255, 255, 255, 0)",
|
||||
},
|
||||
]),
|
||||
opacity: 0.3,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// 如果没有任何数据,则使用fallback
|
||||
if (series.length === 0) {
|
||||
series.push({
|
||||
name: id,
|
||||
type: "line",
|
||||
symbol: "none",
|
||||
sampling: "lttb",
|
||||
connectNulls: true,
|
||||
itemStyle: { color: colors[index % colors.length] },
|
||||
data: dataset.map((item) => item[id]),
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
@@ -815,9 +473,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
]),
|
||||
opacity: 0.3,
|
||||
},
|
||||
});
|
||||
}
|
||||
return series;
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
const option = {
|
||||
@@ -853,10 +510,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
boundaryGap: false,
|
||||
data: xData,
|
||||
},
|
||||
yAxis: {
|
||||
yAxis: [
|
||||
...(hasFlowSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasPressureSeries
|
||||
? [
|
||||
{
|
||||
type: "value",
|
||||
scale: true,
|
||||
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: "inside",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type Feature from "ol/Feature";
|
||||
|
||||
import PropertyPanel from "./PropertyPanel";
|
||||
import { buildFeatureProperties } from "./toolbarFeatureHelpers";
|
||||
|
||||
jest.mock("react-draggable", () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
jest.mock("ol/Feature", () => ({
|
||||
__esModule: true,
|
||||
default: class Feature {},
|
||||
}));
|
||||
|
||||
describe("PropertyPanel junction demands", () => {
|
||||
it("shows a readable demand table instead of raw GeoServer JSON", () => {
|
||||
const feature = {
|
||||
getId: () => "junctions.n_001",
|
||||
getProperties: () => ({
|
||||
id: "n_001",
|
||||
elevation: 42,
|
||||
base_demand: 0,
|
||||
demands:
|
||||
'[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]',
|
||||
}),
|
||||
} as unknown as Feature;
|
||||
const panelData = buildFeatureProperties(feature, {});
|
||||
|
||||
render(<PropertyPanel {...panelData} onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByText("PAT_BASE")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("0.000 m³/h")).toHaveLength(2);
|
||||
expect(screen.getByText("未分类")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/\[{"category"/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -835,6 +835,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onSave: handleValveSettingSave,
|
||||
}
|
||||
: undefined,
|
||||
data?.resultUnits,
|
||||
),
|
||||
[
|
||||
selectedFeature,
|
||||
@@ -848,6 +849,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
isValveStatusLoading,
|
||||
isValveStatusSaving,
|
||||
isValvePropertiesLoading,
|
||||
data?.resultUnits,
|
||||
isValveSettingSaving,
|
||||
handleValveStatusSave,
|
||||
handleValveSettingSave,
|
||||
|
||||
@@ -173,6 +173,52 @@ describe("getSimulationElementType", () => {
|
||||
});
|
||||
|
||||
describe("buildFeatureProperties simulation values by hydraulic type", () => {
|
||||
it("renders GeoServer demand JSON as a readable unit-aware table", () => {
|
||||
const junction = createFeature("junctions", "n_001", {
|
||||
base_demand: 0,
|
||||
demands:
|
||||
'[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]',
|
||||
});
|
||||
|
||||
const result = buildFeatureProperties(
|
||||
junction,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
{ flow: "LPS", pressure: "METERS", velocity: "m/s" },
|
||||
);
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
type: "table",
|
||||
label: "需水配置",
|
||||
columns: ["序号", "基础需水量", "模式", "类别"],
|
||||
rows: [[1, "0.000 m³/h", "PAT_BASE", "未分类"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the selected project's MLD model unit for demand values", () => {
|
||||
const junction = createFeature("junctions", "J1", { base_demand: 1 });
|
||||
|
||||
const result = buildFeatureProperties(
|
||||
junction,
|
||||
{ actual_demand: 2, pressure: 18 },
|
||||
undefined,
|
||||
undefined,
|
||||
{ flow: "MLD", pressure: "METERS", velocity: "m/s" },
|
||||
);
|
||||
|
||||
expect(result.properties).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "基本需水量", value: "41.667" }),
|
||||
expect.objectContaining({ label: "实际需水量", value: "83.333" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows link simulation results for a point-rendered pump", () => {
|
||||
const pump = createFeature("pumps", "P1", {
|
||||
node1: "J1",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import Feature from "ol/Feature";
|
||||
|
||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||
import {
|
||||
DEFAULT_NETWORK_RESULT_UNITS,
|
||||
FLOW_DISPLAY_UNIT,
|
||||
PRESSURE_DISPLAY_UNIT,
|
||||
type NetworkResultUnits,
|
||||
VELOCITY_DISPLAY_UNIT,
|
||||
toModelDisplayValue,
|
||||
} from "@utils/units";
|
||||
import {
|
||||
getValveSettingHelperText,
|
||||
VALVE_STATUS_OPTIONS,
|
||||
@@ -163,11 +170,70 @@ export const inferHistoryFeatureInfos = (
|
||||
})
|
||||
.filter(Boolean) as [string, string][];
|
||||
|
||||
type DemandEntry = {
|
||||
sequence_no?: unknown;
|
||||
base_demand?: unknown;
|
||||
demand?: unknown;
|
||||
pattern_id?: unknown;
|
||||
pattern?: unknown;
|
||||
category?: unknown;
|
||||
};
|
||||
|
||||
const parseDemandEntries = (value: unknown): DemandEntry[] => {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(entry): entry is DemandEntry =>
|
||||
typeof entry === "object" && entry !== null,
|
||||
);
|
||||
};
|
||||
|
||||
const buildDemandProperty = (
|
||||
value: unknown,
|
||||
resultUnits: NetworkResultUnits,
|
||||
): ToolbarPropertyItem => {
|
||||
const entries = parseDemandEntries(value);
|
||||
if (entries.length === 0) {
|
||||
return { label: "需水配置", value: "未配置" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: "table",
|
||||
label: "需水配置",
|
||||
columns: ["序号", "基础需水量", "模式", "类别"],
|
||||
rows: entries.map((entry, index) => {
|
||||
const sourceDemand = Number(entry.base_demand ?? entry.demand);
|
||||
const demand = Number.isFinite(sourceDemand)
|
||||
? `${toModelDisplayValue(
|
||||
sourceDemand,
|
||||
"base_demand",
|
||||
resultUnits,
|
||||
).toFixed(3)} ${FLOW_DISPLAY_UNIT}`
|
||||
: "未设置";
|
||||
const sequence = Number(entry.sequence_no);
|
||||
return [
|
||||
Number.isInteger(sequence) ? sequence + 1 : index + 1,
|
||||
demand,
|
||||
String(entry.pattern_id ?? entry.pattern ?? "无"),
|
||||
String(entry.category ?? "未分类"),
|
||||
];
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildFeatureProperties = (
|
||||
highlightFeature: Feature | undefined,
|
||||
computedProperties: Record<string, any>,
|
||||
valveStatus?: ValveStatusPropertyOptions,
|
||||
valveSetting?: ValveSettingPropertyOptions,
|
||||
resultUnits: NetworkResultUnits = DEFAULT_NETWORK_RESULT_UNITS,
|
||||
): ToolbarPropertyPanelData => {
|
||||
if (!highlightFeature) return {};
|
||||
|
||||
@@ -182,12 +248,12 @@ export const buildFeatureProperties = (
|
||||
{ key: "reaction", label: "反应", unit: "1/d" },
|
||||
{ key: "setting", label: "设置", unit: "" },
|
||||
{ key: "status", label: "状态", unit: "" },
|
||||
{ key: "velocity", label: "流速", unit: "m/s" },
|
||||
{ key: "velocity", label: "流速", unit: VELOCITY_DISPLAY_UNIT },
|
||||
];
|
||||
const nodeComputedFields = [
|
||||
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
|
||||
{ key: "total_head", label: "水头", unit: "m" },
|
||||
{ key: "pressure", label: "压力", unit: "m" },
|
||||
{ key: "pressure", label: "压力", unit: PRESSURE_DISPLAY_UNIT },
|
||||
{ key: "quality", label: "水质", unit: "mg/L" },
|
||||
];
|
||||
|
||||
@@ -200,7 +266,10 @@ export const buildFeatureProperties = (
|
||||
|
||||
let value = computedProperties[key];
|
||||
if (key === "flow" && value !== undefined) {
|
||||
value = toM3h(value, "lps");
|
||||
value = toModelDisplayValue(value, key, resultUnits);
|
||||
}
|
||||
if (key === "velocity" && value !== undefined) {
|
||||
value = toModelDisplayValue(value, key, resultUnits);
|
||||
}
|
||||
if (
|
||||
key === "unit_headloss" &&
|
||||
@@ -226,7 +295,10 @@ export const buildFeatureProperties = (
|
||||
|
||||
let value = computedProperties[key];
|
||||
if (key === "actual_demand") {
|
||||
value = toM3h(value, "lps");
|
||||
value = toModelDisplayValue(value, key, resultUnits);
|
||||
}
|
||||
if (key === "pressure") {
|
||||
value = toModelDisplayValue(value, key, resultUnits);
|
||||
}
|
||||
result.properties?.push({
|
||||
label,
|
||||
@@ -273,14 +345,15 @@ export const buildFeatureProperties = (
|
||||
{
|
||||
label: "基本需水量",
|
||||
value: Number.isFinite(Number(properties.base_demand))
|
||||
? toM3h(Number(properties.base_demand), "lps").toFixed(3)
|
||||
? toModelDisplayValue(
|
||||
Number(properties.base_demand),
|
||||
"base_demand",
|
||||
resultUnits,
|
||||
).toFixed(3)
|
||||
: properties.base_demand,
|
||||
unit: "m³/h",
|
||||
},
|
||||
{
|
||||
label: "需水配置",
|
||||
value: properties.demands,
|
||||
unit: FLOW_DISPLAY_UNIT,
|
||||
},
|
||||
buildDemandProperty(properties.demands, resultUnits),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { FlatStyleLike } from "ol/style/flat";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { config } from "@/config/config";
|
||||
import { isLpsFlowProperty, toM3h } from "@utils/units";
|
||||
import {
|
||||
type NetworkResultUnits,
|
||||
toModelDisplayValue,
|
||||
} from "@utils/units";
|
||||
|
||||
import { LayerStyleController } from "../layerStyleController";
|
||||
import { useData, useMap } from "../MapComponent";
|
||||
@@ -56,11 +59,15 @@ const configsEqual = (left?: StyleConfig, right?: StyleConfig) =>
|
||||
const hasSameTemplate = (left: StyleConfig, right: StyleConfig) =>
|
||||
left.property === right.property && left.segments === right.segments;
|
||||
|
||||
const normalizeComputedStyleValue = (property: string, value: unknown) => {
|
||||
const normalizeComputedStyleValue = (
|
||||
property: string,
|
||||
value: unknown,
|
||||
resultUnits?: NetworkResultUnits,
|
||||
) => {
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue)) return Number.NaN;
|
||||
const displayValue = isLpsFlowProperty(property)
|
||||
? toM3h(numericValue, "lps")
|
||||
const displayValue = resultUnits
|
||||
? toModelDisplayValue(numericValue, property, resultUnits)
|
||||
: numericValue;
|
||||
return property === "flow" ? Math.abs(displayValue) : displayValue;
|
||||
};
|
||||
@@ -140,6 +147,7 @@ export const useStyleEditor = ({
|
||||
const elevationRange = data?.elevationRange;
|
||||
const diameterRange = data?.diameterRange;
|
||||
const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0;
|
||||
const resultUnits = data?.resultUnits;
|
||||
const setJunctionText = data?.setJunctionText;
|
||||
const setPipeText = data?.setPipeText;
|
||||
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
|
||||
@@ -311,10 +319,18 @@ export const useStyleEditor = ({
|
||||
}
|
||||
const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData;
|
||||
return (records || [])
|
||||
.map((item: any) => normalizeComputedStyleValue(property, item.value))
|
||||
.map((item: any) =>
|
||||
normalizeComputedStyleValue(property, item.value, resultUnits),
|
||||
)
|
||||
.filter(Number.isFinite);
|
||||
},
|
||||
[currentJunctionCalData, currentPipeCalData, diameterRange, elevationRange],
|
||||
[
|
||||
currentJunctionCalData,
|
||||
currentPipeCalData,
|
||||
diameterRange,
|
||||
elevationRange,
|
||||
resultUnits,
|
||||
],
|
||||
);
|
||||
|
||||
const syncAuxiliaryLayers = useCallback(
|
||||
@@ -425,7 +441,11 @@ export const useStyleEditor = ({
|
||||
records.forEach((record: any) => {
|
||||
const id = record.ID ?? record.id;
|
||||
if (id === undefined || id === null) return;
|
||||
const value = normalizeComputedStyleValue(nextConfig.property, record.value);
|
||||
const value = normalizeComputedStyleValue(
|
||||
nextConfig.property,
|
||||
record.value,
|
||||
resultUnits,
|
||||
);
|
||||
if (Number.isFinite(value)) stateById.set(String(id), value);
|
||||
});
|
||||
const committed = await controller.applyRuntime(options, stateById);
|
||||
@@ -465,6 +485,7 @@ export const useStyleEditor = ({
|
||||
getDataForMap,
|
||||
getMapKey,
|
||||
getRenderLayersById,
|
||||
resultUnits,
|
||||
syncContoursForStyle,
|
||||
upsertLayerStyleState,
|
||||
],
|
||||
|
||||
@@ -23,7 +23,11 @@ import { TextLayer } from "@deck.gl/layers";
|
||||
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
||||
import { ContourLayer } from "deck.gl";
|
||||
import { isLpsFlowProperty, toM3h } from "@utils/units";
|
||||
import {
|
||||
type NetworkResultUnits,
|
||||
toModelDisplayValue,
|
||||
} from "@utils/units";
|
||||
import { useNetworkResultUnits } from "@/hooks/useNetworkResultUnits";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
cleanupTransientMapResources,
|
||||
@@ -125,6 +129,7 @@ interface DataContextType {
|
||||
elevationRange?: [number, number];
|
||||
forceStyleAutoApplyVersion?: number;
|
||||
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
|
||||
resultUnits?: NetworkResultUnits;
|
||||
}
|
||||
|
||||
// 跨组件传递
|
||||
@@ -138,14 +143,13 @@ const mergeJunctionValues = (
|
||||
features: any[],
|
||||
records: any[],
|
||||
property: string,
|
||||
resultUnits: NetworkResultUnits,
|
||||
) => {
|
||||
const recordsById = indexCalculationRecords(records);
|
||||
return features.map((feature) => {
|
||||
const record = recordsById.get(String(feature.id));
|
||||
if (!record) return feature;
|
||||
const value = isLpsFlowProperty(property)
|
||||
? toM3h(record.value, "lps")
|
||||
: record.value;
|
||||
const value = toModelDisplayValue(record.value, property, resultUnits);
|
||||
return { ...feature, [property]: value };
|
||||
});
|
||||
};
|
||||
@@ -154,13 +158,14 @@ const mergePipeValues = (
|
||||
features: any[],
|
||||
records: any[],
|
||||
property: string,
|
||||
resultUnits: NetworkResultUnits,
|
||||
) => {
|
||||
const recordsById = indexCalculationRecords(records);
|
||||
const isFlow = property === "flow";
|
||||
return features.map((feature) => {
|
||||
const record = recordsById.get(String(feature.id));
|
||||
if (!record) return feature;
|
||||
const value = isFlow ? toM3h(record.value, "lps") : record.value;
|
||||
const value = toModelDisplayValue(record.value, property, resultUnits);
|
||||
const reverseFlow = isFlow && record.value < 0;
|
||||
return {
|
||||
...feature,
|
||||
@@ -202,6 +207,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
const MAP_URL = config.MAP_URL;
|
||||
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
||||
const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
||||
const resultUnits = useNetworkResultUnits(project?.networkName);
|
||||
|
||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -288,37 +294,52 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
// 实时合并计算结果到基础地理数据中
|
||||
const mergedJunctionData = useMemo(
|
||||
() =>
|
||||
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
|
||||
[junctionData, currentJunctionCalData, junctionText],
|
||||
mergeJunctionValues(
|
||||
junctionData,
|
||||
currentJunctionCalData,
|
||||
junctionText,
|
||||
resultUnits,
|
||||
),
|
||||
[junctionData, currentJunctionCalData, junctionText, resultUnits],
|
||||
);
|
||||
const mergedPipeData = useMemo(
|
||||
() => mergePipeValues(pipeData, currentPipeCalData, pipeText),
|
||||
[pipeData, currentPipeCalData, pipeText],
|
||||
() => mergePipeValues(pipeData, currentPipeCalData, pipeText, resultUnits),
|
||||
[pipeData, currentPipeCalData, pipeText, resultUnits],
|
||||
);
|
||||
const mergedPipeFragments = useMemo(
|
||||
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
|
||||
[pipeFragments, currentPipeCalData, pipeText],
|
||||
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText, resultUnits),
|
||||
[pipeFragments, currentPipeCalData, pipeText, resultUnits],
|
||||
);
|
||||
const mergedCompareJunctionData = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
|
||||
? mergeJunctionValues(
|
||||
junctionData,
|
||||
compareJunctionCalData,
|
||||
junctionText,
|
||||
resultUnits,
|
||||
)
|
||||
: [],
|
||||
[isCompareMode, junctionData, compareJunctionCalData, junctionText],
|
||||
[isCompareMode, junctionData, compareJunctionCalData, junctionText, resultUnits],
|
||||
);
|
||||
const mergedComparePipeData = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergePipeValues(pipeData, comparePipeCalData, pipeText)
|
||||
? mergePipeValues(pipeData, comparePipeCalData, pipeText, resultUnits)
|
||||
: [],
|
||||
[isCompareMode, pipeData, comparePipeCalData, pipeText],
|
||||
[isCompareMode, pipeData, comparePipeCalData, pipeText, resultUnits],
|
||||
);
|
||||
const mergedComparePipeFragments = useMemo(
|
||||
() =>
|
||||
isCompareMode
|
||||
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
|
||||
? mergePipeValues(
|
||||
pipeFragments,
|
||||
comparePipeCalData,
|
||||
pipeText,
|
||||
resultUnits,
|
||||
)
|
||||
: [],
|
||||
[isCompareMode, pipeFragments, comparePipeCalData, pipeText],
|
||||
[isCompareMode, pipeFragments, comparePipeCalData, pipeText, resultUnits],
|
||||
);
|
||||
|
||||
const [diameterRange, setDiameterRange] = useState<
|
||||
@@ -1163,6 +1184,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
||||
elevationRange,
|
||||
forceStyleAutoApplyVersion,
|
||||
setForceStyleAutoApplyVersion,
|
||||
resultUnits,
|
||||
}}
|
||||
>
|
||||
<MapContext.Provider value={map}>
|
||||
|
||||
@@ -10,7 +10,7 @@ const state: SceneRuntimeState = {
|
||||
roofVisible: true,
|
||||
displayMode: "global",
|
||||
style: {
|
||||
scale: 8,
|
||||
scale: 6,
|
||||
mode: "uniform",
|
||||
color: "#098ed0",
|
||||
missingColor: "#89949d",
|
||||
@@ -30,6 +30,7 @@ const state: SceneRuntimeState = {
|
||||
appearance: {
|
||||
preset: "day",
|
||||
exposure: 0.95,
|
||||
contextOpacity: 0.4,
|
||||
shadows: true,
|
||||
effects: true,
|
||||
quality: "standard",
|
||||
@@ -49,6 +50,48 @@ const state: SceneRuntimeState = {
|
||||
};
|
||||
|
||||
describe("ThreeDimensionalControls", () => {
|
||||
it("toggles the active toolbar panel and opens a different panel", () => {
|
||||
const onOpenChange = jest.fn();
|
||||
const onTabChange = jest.fn();
|
||||
const { rerender } = render(
|
||||
<ThreeDimensionalControls
|
||||
open
|
||||
activeTab="scene"
|
||||
ready
|
||||
state={state}
|
||||
selection={null}
|
||||
timelineOpen
|
||||
onOpenChange={onOpenChange}
|
||||
onTimelineOpenChange={jest.fn()}
|
||||
onTabChange={onTabChange}
|
||||
onCommand={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "场景与视角" }));
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false);
|
||||
expect(onTabChange).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<ThreeDimensionalControls
|
||||
open={false}
|
||||
activeTab="scene"
|
||||
ready
|
||||
state={state}
|
||||
selection={null}
|
||||
timelineOpen
|
||||
onOpenChange={onOpenChange}
|
||||
onTimelineOpenChange={jest.fn()}
|
||||
onTabChange={onTabChange}
|
||||
onCommand={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "管网样式" }));
|
||||
expect(onTabChange).toHaveBeenLastCalledWith("style");
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it("sends typed scene commands from platform controls", () => {
|
||||
const onCommand = jest.fn();
|
||||
const onTimelineOpenChange = jest.fn();
|
||||
@@ -69,10 +112,49 @@ describe("ThreeDimensionalControls", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "站房精细版" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" });
|
||||
fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), {
|
||||
target: { value: "0.35" },
|
||||
});
|
||||
expect(onCommand).toHaveBeenCalledWith({
|
||||
name: "set-appearance",
|
||||
patch: { contextOpacity: 0.65 },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("switch", { name: "建筑背景" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({ name: "toggle-context" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "时间轴" }));
|
||||
expect(onTimelineOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("uses the glass listbox instead of a native select", () => {
|
||||
const onCommand = jest.fn();
|
||||
render(
|
||||
<ThreeDimensionalControls
|
||||
open
|
||||
activeTab="scene"
|
||||
ready
|
||||
state={state}
|
||||
selection={null}
|
||||
timelineOpen
|
||||
onOpenChange={jest.fn()}
|
||||
onTimelineOpenChange={jest.fn()}
|
||||
onTabChange={jest.fn()}
|
||||
onCommand={onCommand}
|
||||
/>,
|
||||
);
|
||||
|
||||
const displayMode = screen.getByRole("combobox", {
|
||||
name: "管网展示比例",
|
||||
});
|
||||
expect(displayMode.tagName).not.toBe("SELECT");
|
||||
|
||||
fireEvent.mouseDown(displayMode);
|
||||
fireEvent.click(screen.getByRole("option", { name: "泵房协调比例" }));
|
||||
expect(onCommand).toHaveBeenCalledWith({
|
||||
name: "set-display-mode",
|
||||
mode: "coordinated",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows selected asset fields and linked assets in the property tab", () => {
|
||||
const onCommand = jest.fn();
|
||||
render(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import clsx from "clsx";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import {
|
||||
FiBox,
|
||||
FiCamera,
|
||||
FiCheck,
|
||||
FiChevronDown,
|
||||
FiChevronRight,
|
||||
FiClock,
|
||||
FiCrosshair,
|
||||
@@ -40,6 +44,90 @@ const glassSurface =
|
||||
"bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.28))] [backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [-webkit-backdrop-filter:blur(24px)_saturate(170%)_contrast(94%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-1px_0_rgba(112,145,168,0.18),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/55";
|
||||
const fieldClass =
|
||||
"h-10 w-full rounded-lg border border-slate-300/70 bg-white/55 px-3 text-sm text-slate-800 outline-none transition focus:border-blue-500 focus:bg-white/80 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
const selectSx = {
|
||||
height: 40,
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "rgba(255,255,255,0.55)",
|
||||
color: "#1e293b",
|
||||
fontSize: "0.875rem",
|
||||
transition: "background-color 150ms ease, box-shadow 150ms ease",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255,255,255,0.78)",
|
||||
},
|
||||
"& .MuiSelect-select": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
minHeight: "0 !important",
|
||||
padding: "8px 38px 8px 12px !important",
|
||||
},
|
||||
"& .MuiSelect-icon": {
|
||||
right: 12,
|
||||
color: "#64748b",
|
||||
fontSize: 16,
|
||||
transition: "transform 150ms ease",
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(148,163,184,0.7)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(59,130,246,0.65)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "rgba(255,255,255,0.82)",
|
||||
boxShadow: "0 0 0 3px rgba(59,130,246,0.16)",
|
||||
},
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "#3b82f6",
|
||||
borderWidth: 1,
|
||||
},
|
||||
};
|
||||
const selectMenuProps = {
|
||||
disableScrollLock: true,
|
||||
PaperProps: {
|
||||
elevation: 0,
|
||||
sx: {
|
||||
mt: 0.75,
|
||||
overflow: "hidden",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid rgba(255,255,255,0.72)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(244,249,252,0.94), rgba(225,239,248,0.88))",
|
||||
backdropFilter: "blur(24px) saturate(155%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(155%)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 rgba(255,255,255,0.92), 0 16px 42px rgba(15,43,69,0.22)",
|
||||
},
|
||||
},
|
||||
MenuListProps: {
|
||||
sx: {
|
||||
p: 0.75,
|
||||
},
|
||||
},
|
||||
};
|
||||
const selectMenuItemSx = {
|
||||
minHeight: 40,
|
||||
gap: 1,
|
||||
borderRadius: "0.5rem",
|
||||
px: 1.25,
|
||||
color: "#334155",
|
||||
fontSize: "0.875rem",
|
||||
transition: "background-color 120ms ease, color 120ms ease",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255,255,255,0.68)",
|
||||
color: "#1d4ed8",
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "#2563eb",
|
||||
color: "#fff",
|
||||
},
|
||||
"&.Mui-selected:hover": {
|
||||
backgroundColor: "#1d4ed8",
|
||||
},
|
||||
"&.Mui-focusVisible": {
|
||||
outline: "2px solid rgba(59,130,246,0.42)",
|
||||
outlineOffset: -2,
|
||||
},
|
||||
};
|
||||
|
||||
export type ControlTab = "scene" | "style" | "properties";
|
||||
|
||||
@@ -71,6 +159,10 @@ export function ThreeDimensionalControls({
|
||||
const [cameraLabel, setCameraLabel] = useState("");
|
||||
|
||||
const selectTab = (tab: ControlTab) => {
|
||||
if (open && activeTab === tab) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onTabChange(tab);
|
||||
onOpenChange(true);
|
||||
};
|
||||
@@ -150,7 +242,7 @@ export function ThreeDimensionalControls({
|
||||
aria-label="三维场景控制面板"
|
||||
className={clsx(
|
||||
glassSurface,
|
||||
"absolute inset-x-2 bottom-2 z-30 flex max-h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:h-auto md:max-h-[760px] md:w-96",
|
||||
"absolute inset-x-2 bottom-2 z-30 flex h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-auto md:right-4 md:top-4 md:h-[min(760px,calc(100%-2rem))] md:w-96",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-14 items-center gap-3 border-b border-white/45 bg-white/10 px-4">
|
||||
@@ -175,7 +267,7 @@ export function ThreeDimensionalControls({
|
||||
<div
|
||||
aria-disabled={!ready}
|
||||
className={clsx(
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]",
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [overflow-anchor:none] [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]",
|
||||
!ready && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -255,6 +347,15 @@ export function ThreeDimensionalControls({
|
||||
|
||||
<ControlSection title="场景显示">
|
||||
<SwitchRow label="建筑背景" checked={state.contextVisible} onChange={() => onCommand({ name: "toggle-context" })} />
|
||||
<LabeledSlider
|
||||
label={`建筑背景透明度 ${Math.round((1 - state.appearance.contextOpacity) * 100)}%`}
|
||||
value={1 - state.appearance.contextOpacity}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
disabled={!state.contextVisible}
|
||||
onChange={(value) => onCommand({ name: "set-appearance", patch: { contextOpacity: 1 - value } })}
|
||||
/>
|
||||
<SwitchRow label="屋盖与吊顶" checked={state.roofVisible} onChange={() => onCommand({ name: "toggle-roof" })} />
|
||||
<SelectField
|
||||
label="管网展示比例"
|
||||
@@ -386,31 +487,64 @@ function ControlSection({ title, description, children }: { title: string; descr
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) {
|
||||
const labelId = useId();
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-500">{label}</span>
|
||||
<select className={fieldClass} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="block">
|
||||
<span id={labelId} className="mb-1 block text-xs text-slate-500">{label}</span>
|
||||
<Select
|
||||
fullWidth
|
||||
labelId={labelId}
|
||||
value={value}
|
||||
renderValue={() => selected?.label ?? value}
|
||||
MenuProps={selectMenuProps}
|
||||
IconComponent={FiChevronDown}
|
||||
onChange={(event) => onChange(String(event.target.value))}
|
||||
sx={selectSx}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const optionSelected = option.value === value;
|
||||
return (
|
||||
<MenuItem key={option.value} value={option.value} sx={selectMenuItemSx}>
|
||||
<span>{option.label}</span>
|
||||
{optionSelected && <FiCheck aria-hidden="true" className="ml-auto text-base" />}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex min-h-10 cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className="flex min-h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<input type="checkbox" className="peer sr-only" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
||||
<span aria-hidden="true" className="relative h-6 w-11 shrink-0 rounded-full bg-slate-300/80 transition peer-checked:bg-blue-600 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500/40 peer-focus-visible:ring-offset-2 after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" />
|
||||
</label>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
"relative h-6 w-11 shrink-0 rounded-full transition-colors after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform",
|
||||
checked
|
||||
? "bg-blue-600 after:translate-x-5"
|
||||
: "bg-slate-300/80",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledSlider({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
|
||||
function LabeledSlider({ label, value, min, max, step, disabled = false, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs tabular-nums text-slate-500">{label}</span>
|
||||
<input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600" value={value} min={min} max={max} step={step} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
<label className={clsx("block", disabled && "opacity-45")}>
|
||||
<span className="mb-1.5 flex items-center justify-between text-xs tabular-nums text-slate-500">{label}</span>
|
||||
<input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600 disabled:cursor-not-allowed" value={value} min={min} max={max} step={step} disabled={disabled} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type SceneRuntimeState,
|
||||
} from "./sceneProtocol";
|
||||
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2";
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity";
|
||||
const SCENE_LOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
const initialRuntimeState: SceneRuntimeState = {
|
||||
@@ -48,7 +48,7 @@ const initialRuntimeState: SceneRuntimeState = {
|
||||
roofVisible: true,
|
||||
displayMode: "global",
|
||||
style: {
|
||||
scale: 8,
|
||||
scale: 6,
|
||||
mode: "uniform",
|
||||
color: "#098ed0",
|
||||
missingColor: "#89949d",
|
||||
@@ -68,6 +68,7 @@ const initialRuntimeState: SceneRuntimeState = {
|
||||
appearance: {
|
||||
preset: "day",
|
||||
exposure: 0.95,
|
||||
contextOpacity: 0.4,
|
||||
shadows: true,
|
||||
effects: true,
|
||||
quality: "standard",
|
||||
@@ -128,7 +129,7 @@ export default function ThreeDimensionalScene() {
|
||||
const [runtimeState, setRuntimeState] =
|
||||
useState<SceneRuntimeState>(initialRuntimeState);
|
||||
const [selection, setSelection] = useState<SceneAssetSelection | null>(null);
|
||||
const [controlsOpen, setControlsOpen] = useState(false);
|
||||
const [controlsOpen, setControlsOpen] = useState(true);
|
||||
const [controlTab, setControlTab] = useState<ControlTab>("scene");
|
||||
const [timelineOpen, setTimelineOpen] = useState(true);
|
||||
const [pressureDevices, setPressureDevices] = useState<PressureDevice[]>([]);
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
DraggableCoreProps,
|
||||
DraggableData,
|
||||
DraggableEvent,
|
||||
} from "react-draggable";
|
||||
|
||||
const mockDraggable = jest.fn(({ children }: { children: ReactNode }) => children);
|
||||
const mockDraggableCore = jest.fn(
|
||||
({ children }: Partial<DraggableCoreProps> & { children: ReactNode }) => children,
|
||||
);
|
||||
|
||||
jest.mock("react-draggable", () => ({
|
||||
__esModule: true,
|
||||
default: (props: { children: ReactNode }) => mockDraggable(props),
|
||||
DraggableCore: (props: { children: ReactNode }) => mockDraggableCore(props),
|
||||
}));
|
||||
|
||||
import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline";
|
||||
@@ -31,7 +38,7 @@ describe("ThreeDimensionalTimeline", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows the timeline to be dragged vertically away from the bottom", () => {
|
||||
it("tracks the absolute pointer displacement when drag events are skipped", () => {
|
||||
render(
|
||||
<ThreeDimensionalTimeline
|
||||
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
|
||||
@@ -44,9 +51,50 @@ describe("ThreeDimensionalTimeline", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockDraggable).toHaveBeenLastCalledWith(
|
||||
expect.not.objectContaining({ bounds: expect.anything() }),
|
||||
const timeline = screen.getByRole("region", { name: "三维场景时间轴" });
|
||||
const dragShield = timeline.previousElementSibling as HTMLElement;
|
||||
const dragProps = mockDraggableCore.mock.lastCall?.[0];
|
||||
|
||||
expect(dragProps).toEqual(
|
||||
expect.objectContaining({
|
||||
handle: ".timeline-drag-handle",
|
||||
cancel: "button, input, select, [role='dialog']",
|
||||
}),
|
||||
);
|
||||
if (!dragProps?.onStart || !dragProps.onDrag || !dragProps.onStop) {
|
||||
throw new Error("DraggableCore handlers were not configured");
|
||||
}
|
||||
const { onStart, onDrag, onStop } = dragProps;
|
||||
const dragEvent = {} as DraggableEvent;
|
||||
const dragData = (
|
||||
x: number,
|
||||
y: number,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
): DraggableData => ({
|
||||
node: timeline,
|
||||
x,
|
||||
y,
|
||||
deltaX,
|
||||
deltaY,
|
||||
lastX: x - deltaX,
|
||||
lastY: y - deltaY,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
onStart(dragEvent, dragData(100, 200, 0, 0));
|
||||
onDrag(dragEvent, dragData(360, 80, 20, -10));
|
||||
});
|
||||
|
||||
expect(dragShield.style.display).toBe("block");
|
||||
expect(timeline).toHaveStyle({
|
||||
transform: "translate3d(260px, -120px, 0)",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
onStop(dragEvent, dragData(360, 80, 0, 0));
|
||||
});
|
||||
expect(dragShield.style.display).toBe("");
|
||||
});
|
||||
|
||||
it("uses the themed calendar and returns the selected day", () => {
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Draggable from "react-draggable";
|
||||
import {
|
||||
DraggableCore,
|
||||
type DraggableData,
|
||||
type DraggableEvent,
|
||||
} from "react-draggable";
|
||||
import {
|
||||
FiCalendar,
|
||||
FiChevronDown,
|
||||
@@ -78,7 +82,15 @@ export function ThreeDimensionalTimeline({
|
||||
onSelectedDateChange,
|
||||
onCurrentTimeChange,
|
||||
}: ThreeDimensionalTimelineProps) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLElement>(null);
|
||||
const dragShieldRef = useRef<HTMLDivElement>(null);
|
||||
const dragPositionRef = useRef({ x: 0, y: 0 });
|
||||
const dragStartRef = useRef<{
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS);
|
||||
const [previewTime, setPreviewTime] = useState<number | null>(null);
|
||||
@@ -133,6 +145,58 @@ export function ThreeDimensionalTimeline({
|
||||
? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100))
|
||||
: 0;
|
||||
|
||||
const applyDragPosition = useCallback((x: number, y: number) => {
|
||||
dragPositionRef.current = { x, y };
|
||||
if (timelineRef.current) {
|
||||
timelineRef.current.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(_event: DraggableEvent, data: DraggableData) => {
|
||||
const { x: originX, y: originY } = dragPositionRef.current;
|
||||
dragStartRef.current = {
|
||||
pointerX: data.x,
|
||||
pointerY: data.y,
|
||||
originX,
|
||||
originY,
|
||||
};
|
||||
if (dragShieldRef.current) {
|
||||
dragShieldRef.current.style.display = "block";
|
||||
}
|
||||
if (timelineRef.current) {
|
||||
timelineRef.current.style.willChange = "transform";
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDrag = useCallback(
|
||||
(_event: DraggableEvent, data: DraggableData) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start) return;
|
||||
applyDragPosition(
|
||||
start.originX + data.x - start.pointerX,
|
||||
start.originY + data.y - start.pointerY,
|
||||
);
|
||||
},
|
||||
[applyDragPosition],
|
||||
);
|
||||
|
||||
const handleDragStop = useCallback(
|
||||
(event: DraggableEvent, data: DraggableData) => {
|
||||
handleDrag(event, data);
|
||||
dragStartRef.current = null;
|
||||
if (dragShieldRef.current) {
|
||||
dragShieldRef.current.style.display = "";
|
||||
}
|
||||
if (timelineRef.current) {
|
||||
timelineRef.current.style.willChange = "";
|
||||
}
|
||||
},
|
||||
[handleDrag],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -140,17 +204,25 @@ export function ThreeDimensionalTimeline({
|
||||
sidePanelOpen ? "md:right-[416px]" : "md:right-4",
|
||||
)}
|
||||
>
|
||||
<Draggable
|
||||
<div
|
||||
ref={dragShieldRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-auto fixed inset-0 z-0 hidden cursor-move"
|
||||
/>
|
||||
<DraggableCore
|
||||
nodeRef={timelineRef}
|
||||
handle=".timeline-drag-handle"
|
||||
cancel="button, input, select, [role='dialog']"
|
||||
onStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onStop={handleDragStop}
|
||||
>
|
||||
<section
|
||||
ref={timelineRef}
|
||||
aria-label="三维场景时间轴"
|
||||
className={clsx(
|
||||
glassClass,
|
||||
"pointer-events-auto relative w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||||
"pointer-events-auto relative z-10 w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
|
||||
@@ -248,7 +320,7 @@ export function ThreeDimensionalTimeline({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Draggable>
|
||||
</DraggableCore>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("buildSceneFrame", () => {
|
||||
});
|
||||
expect(frame.payload?.links["P-1"]).toEqual({
|
||||
velocity: 0.82,
|
||||
flow: -18.4,
|
||||
flow: -66.24,
|
||||
direction: -1,
|
||||
status: "open",
|
||||
});
|
||||
@@ -196,7 +196,12 @@ describe("buildSceneFrame", () => {
|
||||
const devices = await fetchPressureDevices(new AbortController().signal);
|
||||
|
||||
expect(devices).toEqual([
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
{
|
||||
device_id: "S-1",
|
||||
device_type: "pressure",
|
||||
node_id: "J-1",
|
||||
measurement_unit: "m",
|
||||
},
|
||||
]);
|
||||
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"),
|
||||
@@ -228,6 +233,10 @@ describe("buildSceneFrame", () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ FLOW_UNITS: "LPS", PRESSURE_UNITS: "METERS" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { fetchNetworkResultUnits } from "@/hooks/useNetworkResultUnits";
|
||||
import { config } from "@config/config";
|
||||
import {
|
||||
DEFAULT_NETWORK_RESULT_UNITS,
|
||||
type NetworkResultUnits,
|
||||
toDisplayValue,
|
||||
} from "@/utils/units";
|
||||
|
||||
export const ZJB_PROJECT_CODE = "zjb";
|
||||
export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23";
|
||||
@@ -32,8 +38,8 @@ export type SceneResultsPayload = {
|
||||
modelId: string;
|
||||
units: {
|
||||
velocity: "m/s";
|
||||
pressure: "mH2O";
|
||||
flow: "L/s";
|
||||
pressure: "m";
|
||||
flow: "m³/h";
|
||||
};
|
||||
timestamp: string;
|
||||
nodes: Record<string, SceneNodeResult>;
|
||||
@@ -61,6 +67,7 @@ export type PressureDevice = {
|
||||
device_id: string;
|
||||
device_type: string;
|
||||
node_id: string;
|
||||
measurement_unit?: string;
|
||||
};
|
||||
|
||||
type RawPressureDevice = Omit<PressureDevice, "node_id"> & {
|
||||
@@ -192,6 +199,7 @@ export const buildSceneFrame = ({
|
||||
linkRows,
|
||||
pressureDevices,
|
||||
scadaRows,
|
||||
simulationUnits = DEFAULT_NETWORK_RESULT_UNITS,
|
||||
}: {
|
||||
queryTime: Date;
|
||||
model: SceneModelIndex;
|
||||
@@ -199,6 +207,7 @@ export const buildSceneFrame = ({
|
||||
linkRows: RealtimeLinkRow[];
|
||||
pressureDevices: PressureDevice[];
|
||||
scadaRows: ScadaReadingRow[];
|
||||
simulationUnits?: NetworkResultUnits;
|
||||
}): SceneFrame => {
|
||||
const selectedTime = queryTime.toISOString();
|
||||
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
|
||||
@@ -223,7 +232,14 @@ export const buildSceneFrame = ({
|
||||
return;
|
||||
}
|
||||
if (isFiniteNumber(row.pressure)) {
|
||||
nodes[id] = { pressure: row.pressure, source: "simulation" };
|
||||
nodes[id] = {
|
||||
pressure: toDisplayValue(
|
||||
row.pressure,
|
||||
"pressure",
|
||||
simulationUnits.pressure,
|
||||
)!,
|
||||
source: "simulation",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -234,9 +250,15 @@ export const buildSceneFrame = ({
|
||||
return;
|
||||
}
|
||||
const result: SceneLinkResult = {};
|
||||
if (isFiniteNumber(row.velocity)) result.velocity = row.velocity;
|
||||
if (isFiniteNumber(row.velocity)) {
|
||||
result.velocity = toDisplayValue(
|
||||
row.velocity,
|
||||
"velocity",
|
||||
simulationUnits.velocity,
|
||||
)!;
|
||||
}
|
||||
if (isFiniteNumber(row.flow)) {
|
||||
result.flow = row.flow;
|
||||
result.flow = toDisplayValue(row.flow, "flow", simulationUnits.flow)!;
|
||||
result.direction = Math.sign(row.flow) as -1 | 0 | 1;
|
||||
}
|
||||
const status = normalizeLinkStatus(row.status);
|
||||
@@ -260,7 +282,11 @@ export const buildSceneFrame = ({
|
||||
if (!isFiniteNumber(value)) return;
|
||||
const simulationPressure = nodes[device.node_id]?.pressure;
|
||||
nodes[device.node_id] = {
|
||||
pressure: value,
|
||||
pressure: toDisplayValue(
|
||||
value,
|
||||
"pressure",
|
||||
device.measurement_unit || "m",
|
||||
)!,
|
||||
source: "scada",
|
||||
deviceId: device.device_id,
|
||||
...(simulationPressure === undefined ? {} : { simulationPressure }),
|
||||
@@ -274,7 +300,7 @@ export const buildSceneFrame = ({
|
||||
resultTime,
|
||||
payload: {
|
||||
modelId: model.modelId,
|
||||
units: { velocity: "m/s", pressure: "mH2O", flow: "L/s" },
|
||||
units: { velocity: "m/s", pressure: "m", flow: "m³/h" },
|
||||
timestamp: resultTime,
|
||||
nodes,
|
||||
links,
|
||||
@@ -301,18 +327,23 @@ const readJson = async <T>(url: string, signal: AbortSignal): Promise<T> => {
|
||||
};
|
||||
|
||||
export const fetchPressureDevices = async (signal: AbortSignal) => {
|
||||
const page = await readJson<Page<RawPressureDevice>>(
|
||||
const response = await readJson<Page<RawPressureDevice> | RawPressureDevice[]>(
|
||||
`${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`,
|
||||
signal,
|
||||
);
|
||||
return page.items
|
||||
const items = Array.isArray(response) ? response : response.items;
|
||||
return items
|
||||
.filter(
|
||||
(device): device is PressureDevice =>
|
||||
device.device_type?.trim().toLowerCase() === "pressure" &&
|
||||
typeof device.node_id === "string" &&
|
||||
device.node_id.trim().length > 0,
|
||||
)
|
||||
.map((device) => ({ ...device, node_id: device.node_id.trim() }));
|
||||
.map((device) => ({
|
||||
...device,
|
||||
node_id: device.node_id.trim(),
|
||||
measurement_unit: device.measurement_unit?.trim() || "m",
|
||||
}));
|
||||
};
|
||||
|
||||
export const fetchSceneFrame = async ({
|
||||
@@ -337,7 +368,7 @@ export const fetchSceneFrame = async ({
|
||||
pressureDevices.map((device) => device.device_id).join(","),
|
||||
);
|
||||
|
||||
const [nodeRows, linkRows] = await Promise.all([
|
||||
const [nodeRows, linkRows, networkOptions] = await Promise.all([
|
||||
readJson<RealtimeNodeRow[]>(
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`,
|
||||
signal,
|
||||
@@ -346,6 +377,7 @@ export const fetchSceneFrame = async ({
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`,
|
||||
signal,
|
||||
),
|
||||
fetchNetworkResultUnits(ZJB_PROJECT_CODE, signal),
|
||||
]);
|
||||
|
||||
let scadaRows: ScadaReadingRow[] = [];
|
||||
@@ -370,6 +402,7 @@ export const fetchSceneFrame = async ({
|
||||
linkRows: Array.isArray(linkRows) ? linkRows : [],
|
||||
pressureDevices,
|
||||
scadaRows,
|
||||
simulationUnits: networkOptions,
|
||||
});
|
||||
return { ...frame, warnings };
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ export type SceneNetworkStyle = {
|
||||
export type SceneAppearance = {
|
||||
preset: SceneLightingPreset;
|
||||
exposure: number;
|
||||
contextOpacity: number;
|
||||
shadows: boolean;
|
||||
effects: boolean;
|
||||
quality: SceneQuality;
|
||||
|
||||
+284
-1286
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import config from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import {
|
||||
DEFAULT_NETWORK_RESULT_UNITS,
|
||||
type NetworkResultUnits,
|
||||
networkResultUnitsFromOptions,
|
||||
} from "@/utils/units";
|
||||
|
||||
export const fetchNetworkResultUnits = async (
|
||||
networkName: string,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const query = new URLSearchParams({ network: networkName });
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/network-options?${query}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`模型单位请求失败: HTTP ${response.status}`);
|
||||
}
|
||||
return networkResultUnitsFromOptions(
|
||||
(await response.json()) as Record<string, string>,
|
||||
);
|
||||
};
|
||||
|
||||
export const useNetworkResultUnits = (networkName?: string | null) => {
|
||||
const [loaded, setLoaded] = useState<{
|
||||
networkName: string;
|
||||
units: NetworkResultUnits;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!networkName) return;
|
||||
const controller = new AbortController();
|
||||
void fetchNetworkResultUnits(networkName, controller.signal)
|
||||
.then((units) => setLoaded({ networkName, units }))
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error("[units] 获取项目模型单位失败:", error);
|
||||
setLoaded({ networkName, units: DEFAULT_NETWORK_RESULT_UNITS });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [networkName]);
|
||||
|
||||
return loaded && loaded.networkName === networkName
|
||||
? loaded.units
|
||||
: DEFAULT_NETWORK_RESULT_UNITS;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { toTimeSeriesPoints, type ElementHistorySeries } from "./elementHistory";
|
||||
|
||||
describe("element history display conversion", () => {
|
||||
it("converts every series through the shared unit utility", () => {
|
||||
const series: ElementHistorySeries[] = [
|
||||
{
|
||||
element_id: "P-1",
|
||||
element_type: "pipe",
|
||||
device_id: null,
|
||||
metric: "flow",
|
||||
source: "realtime_simulation",
|
||||
source_unit: "MLD",
|
||||
display_unit: "m³/h",
|
||||
unit_inferred: false,
|
||||
points: [{ time: "2026-09-01T00:00:00Z", value: 2 }],
|
||||
},
|
||||
{
|
||||
element_id: "J-1",
|
||||
element_type: "junction",
|
||||
device_id: "pressure-1",
|
||||
metric: "pressure",
|
||||
source: "scada_cleaned",
|
||||
source_unit: "KPA",
|
||||
display_unit: "m",
|
||||
unit_inferred: false,
|
||||
points: [{ time: "2026-09-01T00:00:00Z", value: 10 }],
|
||||
},
|
||||
];
|
||||
|
||||
const points = toTimeSeriesPoints(series);
|
||||
expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z");
|
||||
expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334);
|
||||
expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import config from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { toDisplayValue } from "@/utils/units";
|
||||
|
||||
export type HistoryElementType = "pipe" | "junction";
|
||||
export type HistoryMode =
|
||||
| "observed"
|
||||
| "realtime_comparison"
|
||||
| "analysis_comparison";
|
||||
export type HistoryMetric = "flow" | "pressure";
|
||||
export type HistorySource =
|
||||
| "scada_raw"
|
||||
| "scada_cleaned"
|
||||
| "realtime_simulation"
|
||||
| "analysis_simulation";
|
||||
|
||||
export interface ElementHistoryTarget {
|
||||
element_id: string;
|
||||
element_type: HistoryElementType;
|
||||
device_ids?: string[];
|
||||
}
|
||||
|
||||
export interface ElementHistorySeries {
|
||||
element_id: string;
|
||||
element_type: HistoryElementType;
|
||||
device_id: string | null;
|
||||
metric: HistoryMetric;
|
||||
source: HistorySource;
|
||||
source_unit: string;
|
||||
display_unit: string;
|
||||
unit_inferred: boolean;
|
||||
points: Array<{ time: string; value: number | null }>;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
timestamp: string;
|
||||
values: Record<string, number | null | undefined>;
|
||||
}
|
||||
|
||||
export interface ElementHistoryResult {
|
||||
points: TimeSeriesPoint[];
|
||||
series: ElementHistorySeries[];
|
||||
}
|
||||
|
||||
const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
||||
scada_raw: "raw",
|
||||
scada_cleaned: "clean",
|
||||
realtime_simulation: "sim",
|
||||
analysis_simulation: "scheme_sim",
|
||||
};
|
||||
|
||||
export const historySeriesKey = (series: ElementHistorySeries) =>
|
||||
`${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`;
|
||||
|
||||
export const historySourceLabel = (source: HistorySource) => {
|
||||
switch (source) {
|
||||
case "scada_raw":
|
||||
return "原始监测";
|
||||
case "scada_cleaned":
|
||||
return "清洗监测";
|
||||
case "realtime_simulation":
|
||||
return "实时模拟";
|
||||
case "analysis_simulation":
|
||||
return "方案模拟";
|
||||
}
|
||||
};
|
||||
|
||||
export const historySeriesLabel = (series: ElementHistorySeries) =>
|
||||
`${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`;
|
||||
|
||||
export const toTimeSeriesPoints = (
|
||||
seriesList: ElementHistorySeries[],
|
||||
): TimeSeriesPoint[] => {
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
seriesList.forEach((series) => {
|
||||
const key = historySeriesKey(series);
|
||||
series.points.forEach((point) => {
|
||||
const values = timeMap.get(point.time) ?? {};
|
||||
values[key] = toDisplayValue(
|
||||
point.value,
|
||||
series.metric,
|
||||
series.source_unit,
|
||||
);
|
||||
timeMap.set(point.time, values);
|
||||
});
|
||||
});
|
||||
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(
|
||||
(left, right) =>
|
||||
new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchElementHistory = async (
|
||||
elements: ElementHistoryTarget[],
|
||||
range: { from: Date; to: Date },
|
||||
mode: HistoryMode,
|
||||
runId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ElementHistoryResult> => {
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/views/element-history/query`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
start_time: range.from.toISOString(),
|
||||
end_time: range.to.toISOString(),
|
||||
mode,
|
||||
...(runId ? { run_id: runId } : {}),
|
||||
elements,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const problem = await response.json().catch(() => null);
|
||||
const detail =
|
||||
typeof problem?.detail === "string"
|
||||
? problem.detail
|
||||
: `HTTP ${response.status}`;
|
||||
throw new Error(`历史数据请求失败: ${detail}`);
|
||||
}
|
||||
const payload = (await response.json()) as { series?: ElementHistorySeries[] };
|
||||
const series = Array.isArray(payload.series) ? payload.series : [];
|
||||
return { series, points: toTimeSeriesPoints(series) };
|
||||
};
|
||||
+41
-6
@@ -1,4 +1,11 @@
|
||||
import { FLOW_DISPLAY_UNIT, isLpsFlowProperty, toM3h } from "./units";
|
||||
import {
|
||||
FLOW_DISPLAY_UNIT,
|
||||
metricForResultProperty,
|
||||
networkResultUnitsFromOptions,
|
||||
toDisplayValue,
|
||||
toM3h,
|
||||
toModelDisplayValue,
|
||||
} from "./units";
|
||||
|
||||
describe("flow display units", () => {
|
||||
it("uses cubic meters per hour as the flow display unit", () => {
|
||||
@@ -10,10 +17,38 @@ describe("flow display units", () => {
|
||||
expect(toM3h(10, "L/s")).toBe(36);
|
||||
});
|
||||
|
||||
it("recognizes computed properties that arrive from the backend in L/s", () => {
|
||||
expect(isLpsFlowProperty("flow")).toBe(true);
|
||||
expect(isLpsFlowProperty("actual_demand")).toBe(true);
|
||||
expect(isLpsFlowProperty("actualdemand")).toBe(true);
|
||||
expect(isLpsFlowProperty("pressure")).toBe(false);
|
||||
it("centralizes model and SCADA conversion for all UI consumers", () => {
|
||||
expect(toDisplayValue(2, "flow", "MLD")).toBeCloseTo(83.3333333334);
|
||||
expect(toDisplayValue(10, "pressure", "KPA")).toBeCloseTo(1.019716213);
|
||||
expect(toDisplayValue(2, "velocity", "ft/s")).toBeCloseTo(0.6096);
|
||||
expect(toDisplayValue(36, "flow", "m3/h")).toBe(36);
|
||||
});
|
||||
|
||||
it("maps result properties to their physical metrics", () => {
|
||||
expect(metricForResultProperty("flow")).toBe("flow");
|
||||
expect(metricForResultProperty("base_demand")).toBe("flow");
|
||||
expect(metricForResultProperty("actual_demand")).toBe("flow");
|
||||
expect(metricForResultProperty("pressure")).toBe("pressure");
|
||||
expect(metricForResultProperty("velocity")).toBe("velocity");
|
||||
expect(metricForResultProperty("headloss")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves legacy and v3 model options in one place", () => {
|
||||
expect(
|
||||
networkResultUnitsFromOptions({
|
||||
FLOW_UNITS: "MLD",
|
||||
PRESSURE_UNITS: "METERS",
|
||||
}),
|
||||
).toEqual({ flow: "MLD", pressure: "METERS", velocity: "m/s" });
|
||||
expect(networkResultUnitsFromOptions({ UNITS: "GPM", PRESSURE: "PSI" }))
|
||||
.toEqual({ flow: "GPM", pressure: "PSI", velocity: "ft/s" });
|
||||
});
|
||||
|
||||
it("converts model properties using project result units", () => {
|
||||
const units = { flow: "MLD", pressure: "KPA", velocity: "ft/s" };
|
||||
expect(toModelDisplayValue(2, "flow", units)).toBeCloseTo(83.3333333334);
|
||||
expect(toModelDisplayValue(10, "pressure", units)).toBeCloseTo(1.019716213);
|
||||
expect(toModelDisplayValue(2, "velocity", units)).toBeCloseTo(0.6096);
|
||||
expect(toModelDisplayValue(3, "headloss", units)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
+124
-9
@@ -1,17 +1,132 @@
|
||||
export const FLOW_DISPLAY_UNIT = "m³/h";
|
||||
const M3H_FACTOR = 3600;
|
||||
const LPS_FLOW_PROPERTIES = new Set(["flow", "actual_demand", "actualdemand"]);
|
||||
export const PRESSURE_DISPLAY_UNIT = "m";
|
||||
export const VELOCITY_DISPLAY_UNIT = "m/s";
|
||||
export type MeasurementMetric = "flow" | "pressure" | "velocity";
|
||||
export interface NetworkResultUnits {
|
||||
flow: string;
|
||||
pressure: string;
|
||||
velocity: string;
|
||||
}
|
||||
|
||||
export const isLpsFlowProperty = (property: string) =>
|
||||
LPS_FLOW_PROPERTIES.has(property);
|
||||
export const DEFAULT_NETWORK_RESULT_UNITS: NetworkResultUnits = {
|
||||
flow: "LPS",
|
||||
pressure: "MTR",
|
||||
velocity: "m/s",
|
||||
};
|
||||
|
||||
const M3H_FACTOR = 3600;
|
||||
const FLOW_PROPERTIES = new Set([
|
||||
"flow",
|
||||
"demand",
|
||||
"base_demand",
|
||||
"actual_demand",
|
||||
"actualdemand",
|
||||
]);
|
||||
const PRESSURE_PROPERTIES = new Set(["pressure"]);
|
||||
const VELOCITY_PROPERTIES = new Set(["velocity"]);
|
||||
const IMPERIAL_FLOW_UNITS = new Set(["CFS", "GPM", "MGD", "IMGD", "AFD"]);
|
||||
|
||||
export const metricForResultProperty = (
|
||||
property: string,
|
||||
): MeasurementMetric | null => {
|
||||
const normalizedProperty = property.trim().toLowerCase();
|
||||
if (FLOW_PROPERTIES.has(normalizedProperty)) return "flow";
|
||||
if (PRESSURE_PROPERTIES.has(normalizedProperty)) return "pressure";
|
||||
if (VELOCITY_PROPERTIES.has(normalizedProperty)) return "velocity";
|
||||
return null;
|
||||
};
|
||||
|
||||
export const networkResultUnitsFromOptions = (
|
||||
options: Record<string, string>,
|
||||
): NetworkResultUnits => {
|
||||
const flow = options.FLOW_UNITS || options.UNITS || "LPS";
|
||||
const pressure = options.PRESSURE_UNITS || options.PRESSURE || "MTR";
|
||||
return {
|
||||
flow,
|
||||
pressure,
|
||||
velocity: IMPERIAL_FLOW_UNITS.has(flow.trim().toUpperCase())
|
||||
? "ft/s"
|
||||
: "m/s",
|
||||
};
|
||||
};
|
||||
|
||||
export const toM3h = (value: number, sourceUnit: string = "m³/s") => {
|
||||
if (!Number.isFinite(value)) return Number.NaN;
|
||||
const normalizedUnit = sourceUnit.trim().toLowerCase();
|
||||
if (normalizedUnit === "m³/h") return value;
|
||||
if (normalizedUnit === "lps" || normalizedUnit === "l/s") return value * 3.6;
|
||||
if (normalizedUnit === "m³/s") return value * M3H_FACTOR;
|
||||
return value * M3H_FACTOR;
|
||||
const normalizedUnit = sourceUnit.trim().toUpperCase().replace("³", "3");
|
||||
const factors: Record<string, number> = {
|
||||
CFS: 101.9406477312,
|
||||
GPM: 0.22712470704,
|
||||
MGD: 157.725491,
|
||||
IMGD: 189.4204167,
|
||||
AFD: 51.39507656,
|
||||
LPS: 3.6,
|
||||
"L/S": 3.6,
|
||||
LPM: 0.06,
|
||||
MLD: 41.6666666667,
|
||||
CMH: 1,
|
||||
"M3/H": 1,
|
||||
CMD: 1 / 24,
|
||||
"M3/D": 1 / 24,
|
||||
"M3/S": M3H_FACTOR,
|
||||
};
|
||||
const factor = factors[normalizedUnit];
|
||||
if (factor === undefined) throw new Error(`不支持的流量单位: ${sourceUnit}`);
|
||||
return value * factor;
|
||||
};
|
||||
|
||||
export const toMeters = (value: number, sourceUnit: string = "m") => {
|
||||
if (!Number.isFinite(value)) return Number.NaN;
|
||||
const normalizedUnit = sourceUnit.trim().toUpperCase();
|
||||
const factors: Record<string, number> = {
|
||||
METERS: 1,
|
||||
METRES: 1,
|
||||
MTR: 1,
|
||||
M: 1,
|
||||
MH2O: 1,
|
||||
"M H2O": 1,
|
||||
KPA: 0.1019716213,
|
||||
PSI: 0.7032496149,
|
||||
};
|
||||
const factor = factors[normalizedUnit];
|
||||
if (factor === undefined) throw new Error(`不支持的压力单位: ${sourceUnit}`);
|
||||
return value * factor;
|
||||
};
|
||||
|
||||
export const toMetersPerSecond = (
|
||||
value: number,
|
||||
sourceUnit: string = "m/s",
|
||||
) => {
|
||||
if (!Number.isFinite(value)) return Number.NaN;
|
||||
const normalizedUnit = sourceUnit.trim().toUpperCase();
|
||||
if (["M/S", "MPS"].includes(normalizedUnit)) return value;
|
||||
if (["FT/S", "FPS"].includes(normalizedUnit)) return value * 0.3048;
|
||||
throw new Error(`不支持的流速单位: ${sourceUnit}`);
|
||||
};
|
||||
|
||||
export const toDisplayValue = (
|
||||
value: number | null | undefined,
|
||||
metric: MeasurementMetric,
|
||||
sourceUnit: string,
|
||||
) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
switch (metric) {
|
||||
case "flow":
|
||||
return toM3h(value, sourceUnit);
|
||||
case "pressure":
|
||||
return toMeters(value, sourceUnit);
|
||||
case "velocity":
|
||||
return toMetersPerSecond(value, sourceUnit);
|
||||
}
|
||||
};
|
||||
|
||||
export const toModelDisplayValue = (
|
||||
value: number,
|
||||
property: string,
|
||||
units: NetworkResultUnits,
|
||||
) => {
|
||||
const metric = metricForResultProperty(property);
|
||||
if (!metric) return value;
|
||||
return toDisplayValue(value, metric, units[metric])!;
|
||||
};
|
||||
|
||||
export const toM3s = (value: number, sourceUnit: string = "m³/h") => {
|
||||
|
||||
Reference in New Issue
Block a user