From 6a59f9af762db6a77ad90983dd24416610b3c2e1 Mon Sep 17 00:00:00 2001 From: Du Wenbo Date: Wed, 1 Apr 2026 22:43:48 +0800 Subject: [PATCH] feat: add 3D interactive dashboard and 2D BigScreen pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /bigscreen-3d route: React Three Fiber 3D campus with buildings, PV panels, heat pumps, meters, and sensors — all procedural geometry - Interactive: hover highlight, click to select, camera fly-in to device detail views (PV inverter, heat pump, meter, heat meter, sensor) - Real-time data: 15s polling for overview, 5s for selected device - Energy flow particles along PV→Building, Grid→Building, Building→HP paths - HUD overlay with date/clock, bottom metrics bar, device list panel - New /bigscreen route: 2D dashboard with energy flow diagram, charts - New /devices route: device management page - Vite config: optimizeDeps.force for R3F dep consistency - Data backfill script for testing Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/launch.json | 2 +- frontend/package-lock.json | 702 +++++++++++++++++- frontend/package.json | 7 +- frontend/src/App.tsx | 6 + frontend/src/layouts/MainLayout.tsx | 3 +- .../pages/BigScreen/components/AlarmCard.tsx | 91 +++ .../BigScreen/components/AnimatedNumber.tsx | 38 + .../pages/BigScreen/components/CarbonCard.tsx | 139 ++++ .../components/EnergyFlowDiagram.tsx | 190 +++++ .../components/EnergyOverviewCard.tsx | 101 +++ .../BigScreen/components/HeatPumpCard.tsx | 96 +++ .../BigScreen/components/LoadCurveCard.tsx | 83 +++ .../src/pages/BigScreen/components/PVCard.tsx | 110 +++ frontend/src/pages/BigScreen/index.tsx | 163 ++++ .../src/pages/BigScreen/styles.module.css | 426 +++++++++++ .../BigScreen3D/components/Buildings.tsx | 130 ++++ .../BigScreen3D/components/CampusScene.tsx | 182 +++++ .../components/DeviceDetailView.tsx | 490 ++++++++++++ .../components/DeviceInfoPanel.tsx | 172 +++++ .../components/DeviceListPanel.tsx | 82 ++ .../BigScreen3D/components/DeviceMarkers.tsx | 200 +++++ .../components/EnergyParticles.tsx | 164 ++++ .../pages/BigScreen3D/components/Ground.tsx | 22 + .../BigScreen3D/components/HUDOverlay.tsx | 93 +++ .../BigScreen3D/components/HeatPumps.tsx | 147 ++++ .../pages/BigScreen3D/components/PVPanels.tsx | 107 +++ .../components/SceneEnvironment.tsx | 24 + frontend/src/pages/BigScreen3D/constants.ts | 120 +++ .../BigScreen3D/hooks/useCameraAnimation.ts | 69 ++ .../pages/BigScreen3D/hooks/useDeviceData.ts | 129 ++++ .../pages/BigScreen3D/hooks/useEnergyFlow.ts | 33 + frontend/src/pages/BigScreen3D/index.tsx | 132 ++++ .../src/pages/BigScreen3D/styles.module.css | 329 ++++++++ frontend/src/pages/BigScreen3D/types.ts | 73 ++ frontend/src/pages/Devices/index.tsx | 304 ++++++++ frontend/vite.config.ts | 16 +- scripts/backfill_data.py | 213 ++++++ 37 files changed, 5351 insertions(+), 37 deletions(-) create mode 100644 frontend/src/pages/BigScreen/components/AlarmCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/AnimatedNumber.tsx create mode 100644 frontend/src/pages/BigScreen/components/CarbonCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx create mode 100644 frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/HeatPumpCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/LoadCurveCard.tsx create mode 100644 frontend/src/pages/BigScreen/components/PVCard.tsx create mode 100644 frontend/src/pages/BigScreen/index.tsx create mode 100644 frontend/src/pages/BigScreen/styles.module.css create mode 100644 frontend/src/pages/BigScreen3D/components/Buildings.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/CampusScene.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/DeviceListPanel.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/Ground.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/HeatPumps.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/PVPanels.tsx create mode 100644 frontend/src/pages/BigScreen3D/components/SceneEnvironment.tsx create mode 100644 frontend/src/pages/BigScreen3D/constants.ts create mode 100644 frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts create mode 100644 frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts create mode 100644 frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts create mode 100644 frontend/src/pages/BigScreen3D/index.tsx create mode 100644 frontend/src/pages/BigScreen3D/styles.module.css create mode 100644 frontend/src/pages/BigScreen3D/types.ts create mode 100644 frontend/src/pages/Devices/index.tsx create mode 100644 scripts/backfill_data.py diff --git a/.claude/launch.json b/.claude/launch.json index 116141a..c8e946d 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,7 +4,7 @@ { "name": "frontend", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], + "runtimeArgs": ["run", "dev", "--", "--force"], "port": 3000, "cwd": "frontend" }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d85a16a..f82a37b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,9 @@ "dependencies": { "@ant-design/icons": "^6.1.1", "@ant-design/pro-components": "^2.8.10", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "antd": "^5.29.3", "axios": "^1.14.0", "dayjs": "^1.11.20", @@ -17,13 +20,15 @@ "echarts-for-react": "^3.0.6", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^6.30.3" + "react-router-dom": "^6.30.3", + "three": "^0.183.2" }, "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", @@ -989,6 +994,12 @@ "node": ">=10" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1057,31 +1068,6 @@ "react": ">=16.8.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", @@ -1364,6 +1350,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", @@ -1566,6 +1570,121 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/postprocessing": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz", + "integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==", + "license": "MIT", + "dependencies": { + "maath": "^0.6.0", + "n8ao": "^1.9.4", + "postprocessing": "^6.36.6" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19.0", + "three": ">= 0.156.0" + } + }, + "node_modules/@react-three/postprocessing/node_modules/maath": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz", + "integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.144.0", + "three": ">=0.144.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1837,6 +1956,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1848,6 +1973,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1873,11 +2004,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1894,6 +2030,43 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -2205,6 +2378,24 @@ "react": "*" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2231,6 +2422,12 @@ } } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2435,6 +2632,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", @@ -2448,6 +2665,15 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -2494,6 +2720,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2517,6 +2767,19 @@ "node": ">=6" } }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001782", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", @@ -2631,11 +2894,28 @@ "toggle-selection": "^1.0.6" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2701,6 +2981,15 @@ "node": ">=6" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2711,6 +3000,12 @@ "node": ">=8" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3053,6 +3348,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3237,6 +3538,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3315,6 +3622,32 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3325,6 +3658,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3381,13 +3720,30 @@ "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", "license": "MIT" }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3487,6 +3843,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3805,6 +4170,16 @@ "yallist": "^3.0.2" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3814,6 +4189,21 @@ "node": ">= 0.4" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3855,6 +4245,16 @@ "dev": true, "license": "MIT" }, + "node_modules/n8ao": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz", + "integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3974,7 +4374,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4039,6 +4438,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postprocessing": { + "version": "6.39.0", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.0.tgz", + "integrity": "sha512-/G6JY8hs426lcto/pBZlnFSkyEo1fHsh4gy7FPJtq1SaSUOzJgDW6f6f1K/+aMOYzK/eQEefyOb3++jPPIUeDA==", + "license": "Zlib", + "peer": true, + "peerDependencies": { + "three": ">= 0.168.0 < 0.184.0" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4049,6 +4464,16 @@ "node": ">= 0.8.0" } }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4759,6 +5184,21 @@ "react-dom": ">=16.8" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", @@ -4768,6 +5208,15 @@ "lodash": "^4.0.1" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -4869,7 +5318,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4882,7 +5330,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4904,6 +5351,32 @@ "node": ">=0.10.0" } }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -4942,6 +5415,15 @@ "node": ">=8" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/swr": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", @@ -4955,6 +5437,45 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -4993,6 +5514,36 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5012,6 +5563,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5121,6 +5709,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/vite": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", @@ -5209,11 +5806,21 @@ "loose-envify": "^1.0.0" } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5293,6 +5900,35 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "license": "0BSD" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 6f3d514..b83f659 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,9 @@ "dependencies": { "@ant-design/icons": "^6.1.1", "@ant-design/pro-components": "^2.8.10", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "antd": "^5.29.3", "axios": "^1.14.0", "dayjs": "^1.11.20", @@ -19,13 +22,15 @@ "echarts-for-react": "^3.0.6", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^6.30.3" + "react-router-dom": "^6.30.3", + "three": "^0.183.2" }, "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae76c8d..2755d6a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,10 @@ import Analysis from './pages/Analysis'; import Alarms from './pages/Alarms'; import Carbon from './pages/Carbon'; import Reports from './pages/Reports'; +import Devices from './pages/Devices'; import SystemManagement from './pages/System'; +import BigScreen from './pages/BigScreen'; +import BigScreen3D from './pages/BigScreen3D'; import { isLoggedIn } from './utils/auth'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -25,9 +28,12 @@ export default function App() { } /> + } /> + } /> }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 4430ba6..5a3793e 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -4,7 +4,7 @@ import { DashboardOutlined, MonitorOutlined, BarChartOutlined, AlertOutlined, FileTextOutlined, CloudOutlined, SettingOutlined, UserOutlined, MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined, - ThunderboltOutlined, + ThunderboltOutlined, AppstoreOutlined, } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { getUser, removeToken } from '../utils/auth'; @@ -15,6 +15,7 @@ const { Text } = Typography; const menuItems = [ { key: '/', icon: , label: '能源总览' }, { key: '/monitoring', icon: , label: '实时监控' }, + { key: '/devices', icon: , label: '设备管理' }, { key: '/analysis', icon: , label: '能耗分析' }, { key: '/alarms', icon: , label: '告警管理' }, { key: '/carbon', icon: , label: '碳排放管理' }, diff --git a/frontend/src/pages/BigScreen/components/AlarmCard.tsx b/frontend/src/pages/BigScreen/components/AlarmCard.tsx new file mode 100644 index 0000000..53faf4a --- /dev/null +++ b/frontend/src/pages/BigScreen/components/AlarmCard.tsx @@ -0,0 +1,91 @@ +import ReactECharts from 'echarts-for-react'; +import styles from '../styles.module.css'; +import AnimatedNumber from './AnimatedNumber'; + +interface Props { + alarmEvents: any[]; + alarmStats: any; +} + +export default function AlarmCard({ alarmEvents, alarmStats }: Props) { + const activeCount = alarmStats?.active_count ?? 0; + const weeklyTrend = alarmStats?.weekly_trend ?? [3, 5, 2, 8, 4, 6, 1]; + const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + + const trendOption = { + grid: { left: 30, right: 8, top: 8, bottom: 20 }, + xAxis: { + type: 'category' as const, + data: weekDays, + axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } }, + axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value' as const, + splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } }, + axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' }, + }, + series: [{ + type: 'bar', + data: weeklyTrend, + itemStyle: { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: '#ff8c00' }, + { offset: 1, color: 'rgba(255, 140, 0, 0.2)' }, + ], + } as any, + borderRadius: [2, 2, 0, 0], + }, + barWidth: '50%', + }], + tooltip: { trigger: 'axis' as const, backgroundColor: 'rgba(6,30,62,0.9)', borderColor: 'rgba(0,212,255,0.3)', textStyle: { color: '#e0e8f0', fontSize: 12 } }, + }; + + const getSeverityClass = (severity: string) => { + if (severity === 'critical' || severity === 'high') return styles.alarmSeverityCritical; + if (severity === 'warning' || severity === 'medium') return styles.alarmSeverityWarning; + return styles.alarmSeverityInfo; + }; + + const formatTime = (ts: string) => { + if (!ts) return ''; + const d = new Date(ts); + return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; + }; + + return ( +
+
告警信息
+
+
+
+ 活跃告警 +
+ + +
+
+
+
+ {(alarmEvents ?? []).slice(0, 5).map((alarm: any, idx: number) => ( +
+ + {alarm.message ?? alarm.description ?? '未知告警'} + {formatTime(alarm.triggered_at ?? alarm.created_at)} +
+ ))} + {(!alarmEvents || alarmEvents.length === 0) && ( +
暂无告警
+ )} +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/AnimatedNumber.tsx b/frontend/src/pages/BigScreen/components/AnimatedNumber.tsx new file mode 100644 index 0000000..82605a0 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/AnimatedNumber.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from 'react'; + +interface Props { + value: number; + duration?: number; + decimals?: number; + className?: string; +} + +export default function AnimatedNumber({ value, duration = 1500, decimals = 0, className }: Props) { + const [display, setDisplay] = useState(0); + const rafRef = useRef(0); + const startRef = useRef(0); + const fromRef = useRef(0); + + useEffect(() => { + fromRef.current = display; + startRef.current = performance.now(); + + const animate = (now: number) => { + const elapsed = now - startRef.current; + const progress = Math.min(elapsed / duration, 1); + // ease-out cubic + const eased = 1 - Math.pow(1 - progress, 3); + const current = fromRef.current + (value - fromRef.current) * eased; + setDisplay(current); + if (progress < 1) { + rafRef.current = requestAnimationFrame(animate); + } + }; + + rafRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(rafRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, duration]); + + return {display.toFixed(decimals)}; +} diff --git a/frontend/src/pages/BigScreen/components/CarbonCard.tsx b/frontend/src/pages/BigScreen/components/CarbonCard.tsx new file mode 100644 index 0000000..e0ef6e9 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/CarbonCard.tsx @@ -0,0 +1,139 @@ +import ReactECharts from 'echarts-for-react'; +import styles from '../styles.module.css'; +import AnimatedNumber from './AnimatedNumber'; + +interface Props { + carbonOverview: any; + carbonTrend: any; +} + +export default function CarbonCard({ carbonOverview, carbonTrend }: Props) { + const annualEmission = carbonOverview?.annual_emission ?? 0; + const annualReduction = carbonOverview?.annual_reduction ?? 0; + const neutralityRate = annualEmission > 0 + ? Math.min((annualReduction / annualEmission) * 100, 100) + : 0; + + // Monthly trend + const months = carbonTrend?.months ?? ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; + const emissionData = carbonTrend?.emission ?? []; + const reductionData = carbonTrend?.reduction ?? []; + + const trendOption = { + grid: { left: 40, right: 12, top: 24, bottom: 24 }, + legend: { + data: ['碳排放', '碳减排'], + textStyle: { color: 'rgba(224,232,240,0.6)', fontSize: 10 }, + top: 0, + right: 8, + itemWidth: 12, + itemHeight: 8, + }, + tooltip: { + trigger: 'axis' as const, + backgroundColor: 'rgba(6,30,62,0.9)', + borderColor: 'rgba(0,212,255,0.3)', + textStyle: { color: '#e0e8f0', fontSize: 12 }, + }, + xAxis: { + type: 'category' as const, + data: months, + axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } }, + axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value' as const, + name: 'kgCO2', + nameTextStyle: { color: 'rgba(224,232,240,0.4)', fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } }, + axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' }, + }, + series: [ + { + name: '碳排放', + type: 'line', + data: emissionData, + smooth: true, + symbol: 'none', + lineStyle: { color: '#ff8c00', width: 2 }, + areaStyle: { color: 'rgba(255, 140, 0, 0.08)' }, + }, + { + name: '碳减排', + type: 'line', + data: reductionData, + smooth: true, + symbol: 'none', + lineStyle: { color: '#00ff88', width: 2 }, + areaStyle: { color: 'rgba(0, 255, 136, 0.08)' }, + }, + ], + }; + + // Neutrality gauge + const gaugeOption = { + series: [{ + type: 'gauge', + startAngle: 220, + endAngle: -40, + radius: '90%', + center: ['50%', '55%'], + min: 0, + max: 100, + progress: { + show: true, + width: 10, + itemStyle: { + color: neutralityRate >= 80 ? '#00ff88' : neutralityRate >= 50 ? '#ff8c00' : '#ff4757', + }, + }, + axisLine: { lineStyle: { width: 10, color: [[1, 'rgba(0, 255, 136, 0.1)']] } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + pointer: { show: false }, + title: { show: false }, + detail: { + fontSize: 18, + fontWeight: 700, + color: neutralityRate >= 80 ? '#00ff88' : neutralityRate >= 50 ? '#ff8c00' : '#ff4757', + offsetCenter: [0, '10%'], + formatter: '{value}%', + }, + data: [{ value: neutralityRate.toFixed(1) }], + }], + }; + + return ( +
+
碳排放
+
+
+
+ +
+
+
+ 年碳排放 + + + kg + +
+
+ 年碳减排 + + + kg + +
+
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx b/frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx new file mode 100644 index 0000000..f7416b5 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx @@ -0,0 +1,190 @@ +import { useEffect, useRef } from 'react'; +import styles from '../styles.module.css'; + +interface Props { + realtime: any; + overview: any; +} + +interface Particle { + x: number; + y: number; + progress: number; + speed: number; + pathIndex: number; +} + +export default function EnergyFlowDiagram({ realtime, overview }: Props) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const particlesRef = useRef([]); + const rafRef = useRef(0); + + const gridPower = realtime?.grid_power ?? 0; + const pvPower = realtime?.pv_power ?? 0; + const totalPower = realtime?.total_power ?? 0; + const hpPower = realtime?.heatpump_power ?? 0; + + useEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + if (!container || !canvas) return; + + const resizeObserver = new ResizeObserver(() => { + const rect = container.getBoundingClientRect(); + canvas.width = rect.width * window.devicePixelRatio; + canvas.height = rect.height * window.devicePixelRatio; + canvas.style.width = rect.width + 'px'; + canvas.style.height = rect.height + 'px'; + }); + resizeObserver.observe(container); + + // Initialize particles + particlesRef.current = []; + for (let i = 0; i < 60; i++) { + particlesRef.current.push({ + x: 0, y: 0, + progress: Math.random(), + speed: 0.002 + Math.random() * 0.003, + pathIndex: Math.floor(Math.random() * 4), + }); + } + + const animate = () => { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const dpr = window.devicePixelRatio; + const w = canvas.width / dpr; + const h = canvas.height / dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + // Node positions + const nodes = { + grid: { x: w * 0.12, y: h * 0.32, label: '电网', color: '#ff8c00' }, + pv: { x: w * 0.12, y: h * 0.68, label: '光伏', color: '#00ff88' }, + building: { x: w * 0.5, y: h * 0.5, label: '建筑负载', color: '#00d4ff' }, + heatpump: { x: w * 0.85, y: h * 0.38, label: '热泵', color: '#00d4ff' }, + heating: { x: w * 0.85, y: h * 0.68, label: '供暖', color: '#ff4757' }, + }; + + // Define paths: [from, to] + const paths = [ + { from: nodes.grid, to: nodes.building, color: '#ff8c00', value: gridPower }, + { from: nodes.pv, to: nodes.building, color: '#00ff88', value: pvPower }, + { from: nodes.building, to: nodes.heatpump, color: '#00d4ff', value: hpPower }, + { from: nodes.heatpump, to: nodes.heating, color: '#ff4757', value: hpPower * 3.5 }, + ]; + + // Draw paths + paths.forEach((path) => { + ctx.beginPath(); + ctx.moveTo(path.from.x, path.from.y); + // Bezier curve + const mx = (path.from.x + path.to.x) / 2; + ctx.bezierCurveTo(mx, path.from.y, mx, path.to.y, path.to.x, path.to.y); + ctx.strokeStyle = path.color + '30'; + ctx.lineWidth = 3; + ctx.stroke(); + }); + + // Animate particles + particlesRef.current.forEach((p) => { + p.progress += p.speed; + if (p.progress > 1) { + p.progress = 0; + p.pathIndex = Math.floor(Math.random() * paths.length); + } + + const path = paths[p.pathIndex]; + if (!path) return; + const t = p.progress; + const mx = (path.from.x + path.to.x) / 2; + // Cubic bezier interpolation + const u = 1 - t; + const x = u * u * u * path.from.x + 3 * u * u * t * mx + 3 * u * t * t * mx + t * t * t * path.to.x; + const y = u * u * u * path.from.y + 3 * u * u * t * path.from.y + 3 * u * t * t * path.to.y + t * t * t * path.to.y; + + const alpha = t < 0.1 ? t / 0.1 : t > 0.9 ? (1 - t) / 0.1 : 1; + ctx.beginPath(); + ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fillStyle = path.color; + ctx.globalAlpha = alpha * 0.9; + ctx.fill(); + + // Glow + ctx.beginPath(); + ctx.arc(x, y, 8, 0, Math.PI * 2); + ctx.fillStyle = path.color; + ctx.globalAlpha = alpha * 0.2; + ctx.fill(); + + ctx.globalAlpha = 1; + }); + + // Draw nodes + Object.values(nodes).forEach((node) => { + // Node bg + ctx.beginPath(); + const rw = 60, rh = 36, r = 8; + const nx = node.x - rw, ny = node.y - rh; + const nw = rw * 2, nh = rh * 2; + ctx.moveTo(nx + r, ny); + ctx.lineTo(nx + nw - r, ny); + ctx.quadraticCurveTo(nx + nw, ny, nx + nw, ny + r); + ctx.lineTo(nx + nw, ny + nh - r); + ctx.quadraticCurveTo(nx + nw, ny + nh, nx + nw - r, ny + nh); + ctx.lineTo(nx + r, ny + nh); + ctx.quadraticCurveTo(nx, ny + nh, nx, ny + nh - r); + ctx.lineTo(nx, ny + r); + ctx.quadraticCurveTo(nx, ny, nx + r, ny); + ctx.closePath(); + ctx.fillStyle = 'rgba(6, 30, 62, 0.95)'; + ctx.fill(); + ctx.strokeStyle = node.color + '66'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Shadow glow + ctx.shadowColor = node.color; + ctx.shadowBlur = 12; + ctx.strokeStyle = node.color + '33'; + ctx.stroke(); + ctx.shadowBlur = 0; + + // Label + ctx.fillStyle = 'rgba(224, 232, 240, 0.7)'; + ctx.font = '12px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(node.label, node.x, node.y - 8); + + // Value + let val = '0 kW'; + if (node.label === '电网') val = gridPower.toFixed(1) + ' kW'; + else if (node.label === '光伏') val = pvPower.toFixed(1) + ' kW'; + else if (node.label === '建筑负载') val = totalPower.toFixed(1) + ' kW'; + else if (node.label === '热泵') val = hpPower.toFixed(1) + ' kW'; + else if (node.label === '供暖') val = (hpPower * 3.5).toFixed(1) + ' kW'; + + ctx.fillStyle = node.color; + ctx.font = 'bold 15px system-ui, sans-serif'; + ctx.fillText(val, node.x, node.y + 12); + }); + + rafRef.current = requestAnimationFrame(animate); + }; + + rafRef.current = requestAnimationFrame(animate); + + return () => { + cancelAnimationFrame(rafRef.current); + resizeObserver.disconnect(); + }; + }, [gridPower, pvPower, totalPower, hpPower]); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx b/frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx new file mode 100644 index 0000000..c347514 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx @@ -0,0 +1,101 @@ +import ReactECharts from 'echarts-for-react'; +import styles from '../styles.module.css'; +import AnimatedNumber from './AnimatedNumber'; + +interface Props { + data: any; + realtime: any; +} + +export default function EnergyOverviewCard({ data, realtime }: Props) { + const selfUseRate = data?.self_consumption_rate ?? 0; + + const gaugeOption = { + series: [{ + type: 'gauge', + startAngle: 220, + endAngle: -40, + radius: '90%', + center: ['50%', '55%'], + min: 0, + max: 100, + splitNumber: 5, + progress: { show: true, width: 12, itemStyle: { color: '#00d4ff' } }, + axisLine: { lineStyle: { width: 12, color: [[1, 'rgba(0, 212, 255, 0.15)']] } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + pointer: { show: false }, + title: { show: false }, + detail: { + fontSize: 22, + fontWeight: 700, + color: '#00d4ff', + offsetCenter: [0, '10%'], + formatter: '{value}%', + }, + data: [{ value: selfUseRate.toFixed(1) }], + }], + }; + + return ( +
+
综合能源概览
+
+
+
+ 今日用电 + + + kWh + +
+
+ 光伏发电 + + + kWh + +
+
+
+
+ 电网购电 + + + kWh + +
+
+ 实时功率 + + + kW + +
+
+
+
+ +
+
+
+ 碳排放 + + + kgCO2 + +
+
+ 碳减排 + + + kgCO2 + +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/HeatPumpCard.tsx b/frontend/src/pages/BigScreen/components/HeatPumpCard.tsx new file mode 100644 index 0000000..80567a6 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/HeatPumpCard.tsx @@ -0,0 +1,96 @@ +import ReactECharts from 'echarts-for-react'; +import styles from '../styles.module.css'; +import AnimatedNumber from './AnimatedNumber'; + +interface Props { + realtime: any; + overview: any; +} + +export default function HeatPumpCard({ realtime, overview }: Props) { + const hpPower = realtime?.heatpump_power ?? 0; + const cop = overview?.heatpump_cop ?? 3.5; + const todayConsumption = overview?.heatpump_consumption_today ?? 0; + const monthlyConsumption = overview?.heatpump_monthly_consumption ?? 0; + const operatingHours = overview?.heatpump_operating_hours ?? 0; + + const copGaugeOption = { + series: [{ + type: 'gauge', + startAngle: 220, + endAngle: -40, + radius: '90%', + center: ['50%', '55%'], + min: 0, + max: 6, + splitNumber: 3, + progress: { + show: true, + width: 10, + itemStyle: { color: cop >= 3 ? '#00ff88' : '#ff8c00' }, + }, + axisLine: { lineStyle: { width: 10, color: [[1, 'rgba(0, 255, 136, 0.12)']] } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + pointer: { show: false }, + title: { show: false }, + detail: { + fontSize: 20, + fontWeight: 700, + color: '#00ff88', + offsetCenter: [0, '10%'], + formatter: '{value}', + }, + data: [{ value: cop.toFixed(2) }], + }], + }; + + return ( +
+
热泵系统
+
+
+
+
实时功率
+ + + kW + +
+
+ +
+
+ 平均COP +
+
+
+
+ 今日用电 + + + kWh + +
+
+ 本月用电 + + + kWh + +
+
+
+
+ 今日运行 + + + h + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/LoadCurveCard.tsx b/frontend/src/pages/BigScreen/components/LoadCurveCard.tsx new file mode 100644 index 0000000..d282d78 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/LoadCurveCard.tsx @@ -0,0 +1,83 @@ +import ReactECharts from 'echarts-for-react'; +import styles from '../styles.module.css'; + +interface Props { + loadData: any; +} + +export default function LoadCurveCard({ loadData }: Props) { + const hours = loadData?.hours ?? Array.from({ length: 24 }, (_, i) => `${i}:00`); + const values = loadData?.values ?? new Array(24).fill(0); + const peak = values.length ? Math.max(...values) : 0; + const valley = values.length ? Math.min(...values.filter((v: number) => v > 0)) || 0 : 0; + const avg = values.length ? values.reduce((a: number, b: number) => a + b, 0) / values.filter((v: number) => v > 0).length || 0 : 0; + + const option = { + grid: { left: 40, right: 12, top: 30, bottom: 24 }, + tooltip: { + trigger: 'axis' as const, + backgroundColor: 'rgba(6,30,62,0.9)', + borderColor: 'rgba(0,212,255,0.3)', + textStyle: { color: '#e0e8f0', fontSize: 12 }, + }, + xAxis: { + type: 'category' as const, + data: hours, + axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } }, + axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)', interval: 3 }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value' as const, + name: 'kW', + nameTextStyle: { color: 'rgba(224,232,240,0.4)', fontSize: 10 }, + splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } }, + axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' }, + }, + series: [{ + type: 'line', + data: values, + smooth: true, + symbol: 'none', + lineStyle: { color: '#00d4ff', width: 2 }, + areaStyle: { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(0, 212, 255, 0.3)' }, + { offset: 1, color: 'rgba(0, 212, 255, 0.02)' }, + ], + } as any, + }, + markLine: { + silent: true, + symbol: 'none', + lineStyle: { type: 'dashed' as const, width: 1 }, + data: [ + { yAxis: peak, label: { formatter: '峰值 {c}kW', color: '#ff4757', fontSize: 10 }, lineStyle: { color: '#ff475740' } }, + { yAxis: avg, label: { formatter: '均值 {c}kW', color: '#ff8c00', fontSize: 10 }, lineStyle: { color: '#ff8c0040' } }, + ], + }, + }], + }; + + return ( +
+
用电分析
+
+
+
+ 峰值 {peak.toFixed(1)} kW +
+
+ 谷值 {valley.toFixed(1)} kW +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/components/PVCard.tsx b/frontend/src/pages/BigScreen/components/PVCard.tsx new file mode 100644 index 0000000..05bc661 --- /dev/null +++ b/frontend/src/pages/BigScreen/components/PVCard.tsx @@ -0,0 +1,110 @@ +import ReactECharts from 'echarts-for-react'; +import styles from '../styles.module.css'; +import AnimatedNumber from './AnimatedNumber'; + +interface Props { + realtime: any; + overview: any; +} + +export default function PVCard({ realtime, overview }: Props) { + const pvPower = realtime?.pv_power ?? 0; + const todayGen = overview?.pv_generation_today ?? 0; + const monthlyGen = overview?.pv_monthly_generation ?? 0; + const selfUseRate = overview?.self_consumption_rate ?? 0; + + // Donut for self-use ratio + const donutOption = { + series: [{ + type: 'pie', + radius: ['60%', '80%'], + center: ['50%', '50%'], + silent: true, + label: { show: false }, + data: [ + { value: selfUseRate, itemStyle: { color: '#00ff88' } }, + { value: 100 - selfUseRate, itemStyle: { color: 'rgba(0, 255, 136, 0.1)' } }, + ], + }], + }; + + // Monthly PV bar chart (mock 12 months) + const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; + const monthlyData = overview?.monthly_pv_data ?? months.map(() => Math.round(Math.random() * 3000 + 1000)); + + const barOption = { + grid: { left: 35, right: 8, top: 8, bottom: 20 }, + xAxis: { + type: 'category' as const, + data: months, + axisLine: { lineStyle: { color: 'rgba(0,212,255,0.2)' } }, + axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.5)' }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value' as const, + splitLine: { lineStyle: { color: 'rgba(0,212,255,0.08)' } }, + axisLabel: { fontSize: 10, color: 'rgba(224,232,240,0.4)' }, + }, + series: [{ + type: 'bar', + data: monthlyData, + itemStyle: { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: '#00ff88' }, + { offset: 1, color: 'rgba(0, 255, 136, 0.2)' }, + ], + } as any, + borderRadius: [2, 2, 0, 0], + }, + barWidth: '50%', + }], + tooltip: { trigger: 'axis' as const, backgroundColor: 'rgba(6,30,62,0.9)', borderColor: 'rgba(0,212,255,0.3)', textStyle: { color: '#e0e8f0', fontSize: 12 } }, + }; + + return ( +
+
光伏发电
+
+
+
+
实时功率
+ + + kW + +
+
+ +
+
+ 自用率
+ {selfUseRate.toFixed(1)}% +
+
+
+
+ 今日发电 + + + kWh + +
+
+ 本月发电 + + + kWh + +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/index.tsx b/frontend/src/pages/BigScreen/index.tsx new file mode 100644 index 0000000..478e379 --- /dev/null +++ b/frontend/src/pages/BigScreen/index.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import styles from './styles.module.css'; +import EnergyOverviewCard from './components/EnergyOverviewCard'; +import PVCard from './components/PVCard'; +import HeatPumpCard from './components/HeatPumpCard'; +import EnergyFlowDiagram from './components/EnergyFlowDiagram'; +import LoadCurveCard from './components/LoadCurveCard'; +import AlarmCard from './components/AlarmCard'; +import CarbonCard from './components/CarbonCard'; +import AnimatedNumber from './components/AnimatedNumber'; +import { + getDashboardOverview, + getRealtimeData, + getLoadCurve, + getAlarmEvents, + getAlarmStats, + getCarbonOverview, + getCarbonTrend, + getDeviceStats, +} from '../../services/api'; + +export default function BigScreen() { + const [clock, setClock] = useState(new Date()); + const [overview, setOverview] = useState(null); + const [realtime, setRealtime] = useState(null); + const [loadData, setLoadData] = useState(null); + const [alarmEvents, setAlarmEvents] = useState([]); + const [alarmStats, setAlarmStats] = useState(null); + const [carbonOverview, setCarbonOverview] = useState(null); + const [carbonTrend, setCarbonTrend] = useState(null); + const [deviceStats, setDeviceStats] = useState(null); + const timerRef = useRef(null); + + // Clock update every second + useEffect(() => { + const t = setInterval(() => setClock(new Date()), 1000); + return () => clearInterval(t); + }, []); + + // Fetch all data + const fetchAll = useCallback(async () => { + try { + const results = await Promise.allSettled([ + getDashboardOverview(), + getRealtimeData(), + getLoadCurve(24), + getAlarmEvents({ limit: 5 }), + getAlarmStats(), + getCarbonOverview(), + getCarbonTrend(365), + getDeviceStats(), + ]); + + const get = (i: number) => { + const r = results[i]; + if (r.status === 'fulfilled') { + const val = r.value as any; + return val?.data ?? val; + } + return null; + }; + + if (get(0)) setOverview(get(0)); + if (get(1)) setRealtime(get(1)); + if (get(2)) setLoadData(get(2)); + if (get(3)) { + const alarms = get(3); + setAlarmEvents(Array.isArray(alarms) ? alarms : alarms?.items ?? alarms?.events ?? []); + } + if (get(4)) setAlarmStats(get(4)); + if (get(5)) setCarbonOverview(get(5)); + if (get(6)) setCarbonTrend(get(6)); + if (get(7)) setDeviceStats(get(7)); + } catch (e) { + console.error('BigScreen fetch error:', e); + } + }, []); + + // Initial fetch + polling every 15s + useEffect(() => { + fetchAll(); + timerRef.current = setInterval(fetchAll, 15000); + return () => clearInterval(timerRef.current); + }, [fetchAll]); + + const formatDate = (d: Date) => { + const y = d.getFullYear(); + const m = (d.getMonth() + 1).toString().padStart(2, '0'); + const day = d.getDate().toString().padStart(2, '0'); + const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; + return `${y}年${m}月${day}日 ${weekdays[d.getDay()]}`; + }; + + const formatTime = (d: Date) => { + return d.toLocaleTimeString('zh-CN', { hour12: false }); + }; + + const totalDevices = deviceStats?.total ?? 0; + const onlineDevices = deviceStats?.online ?? 0; + const offlineDevices = deviceStats?.offline ?? 0; + const alarmDevices = deviceStats?.alarm_count ?? alarmStats?.active_count ?? 0; + + return ( +
+ {/* Header */} +
+ {formatDate(clock)} +

天普零碳园区智慧能源管理平台

+ {formatTime(clock)} +
+ + {/* Main 3-column grid */} +
+ {/* Left Column */} +
+ + + +
+ + {/* Center Column */} +
+
+
能源流向
+ +
+
+
设备状态
+
+
+ + 总设备 + +
+
+ + 在线 + +
+
+ + 离线 + +
+
+ + 告警 + +
+
+
+
+ + {/* Right Column */} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen/styles.module.css b/frontend/src/pages/BigScreen/styles.module.css new file mode 100644 index 0000000..dbe47a5 --- /dev/null +++ b/frontend/src/pages/BigScreen/styles.module.css @@ -0,0 +1,426 @@ +/* Big Screen Visualization Dashboard - Dark Theme */ + +.container { + width: 100vw; + height: 100vh; + background: #0a1628; + background-image: + radial-gradient(circle at 1px 1px, rgba(0, 212, 255, 0.06) 1px, transparent 0); + background-size: 40px 40px; + color: #e0e8f0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Header */ +.header { + height: 72px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + background: linear-gradient(180deg, rgba(0, 212, 255, 0.12) 0%, transparent 100%); + border-bottom: 1px solid rgba(0, 212, 255, 0.2); + flex-shrink: 0; +} + +.headerTitle { + font-size: 28px; + font-weight: 700; + letter-spacing: 6px; + background: linear-gradient(90deg, #00d4ff, #00ff88, #00d4ff); + background-size: 200% 100%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: shimmer 4s ease-in-out infinite; +} + +@keyframes shimmer { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +.headerTime { + position: absolute; + right: 32px; + font-size: 16px; + color: rgba(0, 212, 255, 0.8); + letter-spacing: 2px; +} + +.headerDate { + position: absolute; + left: 32px; + font-size: 14px; + color: rgba(0, 212, 255, 0.6); + letter-spacing: 1px; +} + +/* Main Grid */ +.mainGrid { + flex: 1; + display: grid; + grid-template-columns: 1fr 2fr 1fr; + gap: 12px; + padding: 12px; + min-height: 0; +} + +.column { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +/* Cards */ +.card { + background: rgba(6, 30, 62, 0.85); + border: 1px solid rgba(0, 212, 255, 0.25); + border-radius: 8px; + padding: 14px 16px; + position: relative; + overflow: hidden; + box-shadow: + 0 0 12px rgba(0, 212, 255, 0.08), + inset 0 1px 0 rgba(0, 212, 255, 0.1); + display: flex; + flex-direction: column; +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent); +} + +.cardTitle { + font-size: 15px; + font-weight: 600; + color: #00d4ff; + margin-bottom: 12px; + padding-left: 10px; + border-left: 3px solid #00d4ff; + letter-spacing: 2px; + flex-shrink: 0; +} + +.cardBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* Big numbers */ +.bigNumber { + font-size: 32px; + font-weight: 700; + color: #00ff88; + line-height: 1; +} + +.bigNumberCyan { + font-size: 32px; + font-weight: 700; + color: #00d4ff; + line-height: 1; +} + +.bigNumberOrange { + font-size: 32px; + font-weight: 700; + color: #ff8c00; + line-height: 1; +} + +.bigNumberRed { + font-size: 24px; + font-weight: 700; + color: #ff4757; + line-height: 1; +} + +.unit { + font-size: 13px; + font-weight: 400; + color: rgba(224, 232, 240, 0.6); + margin-left: 4px; +} + +/* Stat grid rows */ +.statRow { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 8px; +} + +.statItem { + display: flex; + flex-direction: column; + gap: 2px; +} + +.statLabel { + font-size: 12px; + color: rgba(224, 232, 240, 0.5); +} + +.statValue { + font-size: 18px; + font-weight: 600; + color: #e0e8f0; +} + +.statValueCyan { + font-size: 18px; + font-weight: 600; + color: #00d4ff; +} + +.statValueGreen { + font-size: 18px; + font-weight: 600; + color: #00ff88; +} + +.statValueOrange { + font-size: 18px; + font-weight: 600; + color: #ff8c00; +} + +/* Center - Energy Flow */ +.centerCard { + background: rgba(6, 30, 62, 0.85); + border: 1px solid rgba(0, 212, 255, 0.25); + border-radius: 8px; + padding: 16px; + flex: 1; + position: relative; + overflow: hidden; + box-shadow: + 0 0 12px rgba(0, 212, 255, 0.08), + inset 0 1px 0 rgba(0, 212, 255, 0.1); + display: flex; + flex-direction: column; +} + +.centerCard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent); +} + +/* Device status bar */ +.deviceStatusBar { + display: flex; + gap: 24px; + justify-content: center; + padding: 8px 0; + flex-shrink: 0; +} + +.deviceStatusItem { + display: flex; + align-items: center; + gap: 8px; +} + +.statusDot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.statusDotGreen { + composes: statusDot; + background: #00ff88; + box-shadow: 0 0 8px rgba(0, 255, 136, 0.5); +} + +.statusDotRed { + composes: statusDot; + background: #ff4757; + box-shadow: 0 0 8px rgba(255, 71, 87, 0.5); +} + +.statusDotOrange { + composes: statusDot; + background: #ff8c00; + box-shadow: 0 0 8px rgba(255, 140, 0, 0.5); +} + +.statusDotCyan { + composes: statusDot; + background: #00d4ff; + box-shadow: 0 0 8px rgba(0, 212, 255, 0.5); +} + +.statusLabel { + font-size: 13px; + color: rgba(224, 232, 240, 0.6); +} + +.statusCount { + font-size: 18px; + font-weight: 700; + color: #e0e8f0; +} + +/* Alarm list */ +.alarmList { + flex: 1; + overflow: hidden; +} + +.alarmItem { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid rgba(0, 212, 255, 0.1); + font-size: 12px; +} + +.alarmSeverity { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.alarmSeverityCritical { + composes: alarmSeverity; + background: #ff4757; + box-shadow: 0 0 6px rgba(255, 71, 87, 0.6); +} + +.alarmSeverityWarning { + composes: alarmSeverity; + background: #ff8c00; + box-shadow: 0 0 6px rgba(255, 140, 0, 0.6); +} + +.alarmSeverityInfo { + composes: alarmSeverity; + background: #00d4ff; + box-shadow: 0 0 6px rgba(0, 212, 255, 0.6); +} + +.alarmMsg { + flex: 1; + color: rgba(224, 232, 240, 0.8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.alarmTime { + color: rgba(224, 232, 240, 0.4); + flex-shrink: 0; + font-size: 11px; +} + +/* Progress ring */ +.progressRing { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} + +/* Chart wrapper */ +.chartWrap { + flex: 1; + min-height: 0; +} + +/* Energy Flow SVG area */ +.energyFlowWrap { + flex: 1; + min-height: 0; + position: relative; +} + +/* Animated flow particles */ +@keyframes flowRight { + 0% { offset-distance: 0%; opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { offset-distance: 100%; opacity: 0; } +} + +@keyframes flowLeft { + 0% { offset-distance: 100%; opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { offset-distance: 0%; opacity: 0; } +} + +@keyframes pulse { + 0%, 100% { opacity: 0.6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.05); } +} + +.pulseAnim { + animation: pulse 2s ease-in-out infinite; +} + +/* Energy flow node */ +.flowNode { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 140px; + height: 100px; + background: rgba(6, 30, 62, 0.95); + border: 1.5px solid rgba(0, 212, 255, 0.4); + border-radius: 12px; + box-shadow: 0 0 20px rgba(0, 212, 255, 0.15); + z-index: 2; +} + +.flowNodeLabel { + font-size: 13px; + color: rgba(224, 232, 240, 0.7); + margin-bottom: 4px; +} + +.flowNodeValue { + font-size: 22px; + font-weight: 700; + color: #00d4ff; +} + +.flowNodeUnit { + font-size: 11px; + color: rgba(224, 232, 240, 0.5); +} + +/* Gauge display */ +.gaugeWrap { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.gaugeLabel { + font-size: 12px; + color: rgba(224, 232, 240, 0.5); +} diff --git a/frontend/src/pages/BigScreen3D/components/Buildings.tsx b/frontend/src/pages/BigScreen3D/components/Buildings.tsx new file mode 100644 index 0000000..1d8b3b0 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/Buildings.tsx @@ -0,0 +1,130 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; +import { Html } from '@react-three/drei'; +import { BUILDINGS, COLORS } from '../constants'; + +interface BuildingsProps { + detailMode?: boolean; + onBuildingClick?: (building: string) => void; +} + +function WindowGrid({ width, height, depth }: { width: number; height: number; depth: number }) { + const windows = useMemo(() => { + const cols = 4; + const rows = 3; + const winW = 1.5; + const winH = 0.8; + const winD = 0.05; + const gapX = (width - cols * winW) / (cols + 1); + const gapY = (height - rows * winH) / (rows + 1); + const items: { pos: [number, number, number] }[] = []; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const x = -width / 2 + gapX + winW / 2 + c * (winW + gapX); + const y = -height / 2 + gapY + winH / 2 + r * (winH + gapY); + items.push({ pos: [x, y, depth / 2 + winD / 2] }); + } + } + return items; + }, [width, height, depth]); + + return ( + + {windows.map((w, i) => ( + + + + + ))} + + ); +} + +function Building({ + label, + position, + size, + opacity, + onClick, +}: { + label: string; + position: [number, number, number]; + size: [number, number, number]; + opacity: number; + onClick?: () => void; +}) { + const [w, h, d] = size; + + const edgesGeo = useMemo(() => { + const box = new THREE.BoxGeometry(w, h, d); + return new THREE.EdgesGeometry(box); + }, [w, h, d]); + + const labelStyle: React.CSSProperties = { + fontSize: '13px', + color: COLORS.text, + background: 'rgba(6, 30, 62, 0.8)', + padding: '2px 8px', + borderRadius: '4px', + border: '1px solid rgba(0, 212, 255, 0.2)', + whiteSpace: 'nowrap', + pointerEvents: 'none', + }; + + return ( + { e.stopPropagation(); onClick(); } : undefined}> + {/* Main body */} + + + + + + {/* Edge highlight */} + + + + + {/* Windows on front face */} + + + {/* Label */} + +
{label}
+ +
+ ); +} + +export default function Buildings({ detailMode, onBuildingClick }: BuildingsProps) { + const opacity = detailMode ? 0.15 : 0.85; + + return ( + + onBuildingClick('east') : undefined} + /> + onBuildingClick('west') : undefined} + /> + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/CampusScene.tsx b/frontend/src/pages/BigScreen3D/components/CampusScene.tsx new file mode 100644 index 0000000..bd87228 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/CampusScene.tsx @@ -0,0 +1,182 @@ +import { useEffect, useRef, useMemo } from 'react'; +import { Canvas } from '@react-three/fiber'; +import { OrbitControls } from '@react-three/drei'; +import { EffectComposer, Bloom } from '@react-three/postprocessing'; +import { CAMERA, COLORS } from '../constants'; +import { useCameraAnimation } from '../hooks/useCameraAnimation'; +import SceneEnvironment from './SceneEnvironment'; +import Ground from './Ground'; +import Buildings from './Buildings'; +import PVPanels from './PVPanels'; +import HeatPumps from './HeatPumps'; +import DeviceMarkers from './DeviceMarkers'; +import EnergyParticles from './EnergyParticles'; +import DeviceDetailView from './DeviceDetailView'; + +interface CampusSceneProps { + devices: Array; + energyFlow: { nodes: any[]; links: any[] }; + realtimeData: any | null; + selectedDevice: any | null; + selectedDevicePosition: [number, number, number] | null; + detailRealtimeData: Record | null; + hoveredDeviceId: number | null; + viewMode: 'campus' | 'device-detail'; + onDeviceSelect: (device: any) => void; + onDeviceHover: (id: number | null) => void; +} + +// Inner component: handles camera animation from within Canvas context +function CameraController({ + selectedDevicePosition, + viewMode, +}: { + selectedDevicePosition: [number, number, number] | null; + viewMode: 'campus' | 'device-detail'; +}) { + const { animateTo, resetToOverview } = useCameraAnimation(); + const prevViewMode = useRef(viewMode); + + useEffect(() => { + if (viewMode === 'device-detail' && selectedDevicePosition) { + const [x, y, z] = selectedDevicePosition; + animateTo([x + 8, y + 6, z + 10], [x, y, z], 1.5); + } else if (viewMode === 'campus' && prevViewMode.current === 'device-detail') { + resetToOverview(); + } + prevViewMode.current = viewMode; + }, [viewMode, selectedDevicePosition, animateTo, resetToOverview]); + + return null; +} + +// Inner scene content (must be inside Canvas to use R3F hooks) +function SceneContent({ + devices, + energyFlow, + realtimeData, + selectedDevice, + selectedDevicePosition, + detailRealtimeData, + hoveredDeviceId, + viewMode, + onDeviceSelect, + onDeviceHover, +}: CampusSceneProps) { + const detailMode = viewMode === 'device-detail'; + + const { pvDevices, hpDevices, markerDevices } = useMemo(() => { + const pv: Array<{ id: number; code: string; status: string; power?: number; rated_power?: number }> = []; + const hp: Array<{ id: number; code: string; status: string; power?: number }> = []; + const markers: Array<{ + id: number; + code: string; + device_type: string; + name: string; + status: string; + primaryValue?: string; + }> = []; + + devices.forEach((d: any) => { + const type: string = d.device_type ?? ''; + const code: string = d.code ?? ''; + if (type === 'pv_inverter') { + pv.push({ id: d.id, code, status: d.status, power: d.power, rated_power: d.rated_power }); + } else if (type === 'heat_pump') { + hp.push({ id: d.id, code, status: d.status, power: d.power }); + } else if (['meter', 'sensor', 'heat_meter'].includes(type)) { + markers.push({ + id: d.id, + code, + device_type: type, + name: d.name ?? code, + status: d.status, + primaryValue: d.primaryValue, + }); + } + }); + + return { pvDevices: pv, hpDevices: hp, markerDevices: markers }; + }, [devices]); + + return ( + <> + + + + + + + + + + + + + + + + {viewMode === 'device-detail' && selectedDevice && selectedDevicePosition && ( + + )} + + + + + + + + ); +} + +export default function CampusScene(props: CampusSceneProps) { + return ( + + + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx b/frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx new file mode 100644 index 0000000..353d748 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx @@ -0,0 +1,490 @@ +import { useRef, useMemo } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Html } from '@react-three/drei'; +import * as THREE from 'three'; + +interface DeviceDetailViewProps { + device: { + id: number; + name: string; + code: string; + device_type: string; + status: string; + rated_power?: number; + }; + position: [number, number, number]; + realtimeData: Record | null; +} + +const overlayStyle: React.CSSProperties = { + background: 'rgba(6, 30, 62, 0.95)', + border: '1px solid rgba(0, 212, 255, 0.3)', + padding: 12, + borderRadius: 8, + color: '#e0e8f0', + fontSize: 12, + minWidth: 200, + pointerEvents: 'none' as const, + fontFamily: 'monospace', +}; + +// ─── PV Inverter Detail ───────────────────────────────────────────── +function PVDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const groupRef = useRef(null); + + useFrame((_, delta) => { + if (groupRef.current) { + groupRef.current.rotation.y += 0.1 * delta; + } + }); + + const panels = useMemo(() => { + const items: { pos: [number, number, number] }[] = []; + const cols = 5; + const rows = 3; + const pw = 2.5; + const ph = 1.2; + const depth = 0.08; + const gap = 0.3; + const totalW = cols * pw + (cols - 1) * gap; + const totalD = rows * ph + (rows - 1) * gap; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const x = -totalW / 2 + pw / 2 + c * (pw + gap); + const z = -totalD / 2 + ph / 2 + r * (ph + gap); + items.push({ pos: [x, 0, z] }); + } + } + return { items, totalW, depth, pw, ph }; + }, []); + + const tilt = Math.PI / 6; // 30 degrees + + return ( + + {/* Scale the whole panel array 1.5x */} + + {panels.items.map((p, i) => ( + + + + + ))} + {/* Mounting rails */} + + + + + + + + + + + {/* HTML overlay – block diagram + live data */} + +
+
+{`┌─────────┐ ┌──────┐ ┌─────────┐ +│ DC Input │ → │ MPPT │ → │ AC Output│ +└─────────┘ └──────┘ └─────────┘`} +
+
+
DC电压: {getValue('dc_voltage').toFixed(1)} V
+
AC电压: {getValue('ac_voltage').toFixed(1)} V
+
功率: {getValue('power').toFixed(1)} kW
+
温度: {getValue('temperature').toFixed(1)} ℃
+
+
+ +
+ ); +} + +// ─── Heat Pump Detail ─────────────────────────────────────────────── +function HeatPumpDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const particlesRef = useRef(null); + const fanRef = useRef(null); + + // Refrigerant circulation path + const { curve, particleCount } = useMemo(() => { + const points = [ + new THREE.Vector3(0, 0, 0), // compressor center + new THREE.Vector3(1, 0, 0), // condenser + new THREE.Vector3(1, -0.8, 0), // bottom right + new THREE.Vector3(0, -0.9, 0), // expansion valve + new THREE.Vector3(-1, -0.8, 0), // bottom left + new THREE.Vector3(-1, 0, 0), // evaporator + new THREE.Vector3(-0.5, 0.4, 0), // top left + new THREE.Vector3(0, 0.4, 0), // top center back to compressor + ]; + return { + curve: new THREE.CatmullRomCurve3(points, true), + particleCount: 12, + }; + }, []); + + useFrame((state, delta) => { + // Animate particles along path + if (particlesRef.current) { + const t = state.clock.elapsedTime; + particlesRef.current.children.forEach((child, i) => { + const offset = i / particleCount; + const pos = curve.getPointAt((t * 0.15 + offset) % 1); + child.position.copy(pos); + }); + } + // Spin fan + if (fanRef.current) { + fanRef.current.rotation.y += 3 * delta; + } + }); + + return ( + + {/* Main housing – transparent cutaway */} + + + + + + {/* Compressor */} + + + + + + {/* Evaporator (left) */} + + + + + + {/* Condenser (right) */} + + + + + + {/* Expansion valve */} + + + + + + {/* Refrigerant particles */} + + {Array.from({ length: particleCount }).map((_, i) => ( + + + + + ))} + + + {/* Fan on top */} + + {[0, 1, 2].map((i) => ( + + + + + ))} + {/* Fan hub */} + + + + + + + {/* HTML overlay */} + +
+
热泵详情
+
功率: {getValue('power').toFixed(1)} kW
+
COP: {getValue('cop').toFixed(2)}
+
进水温度: {getValue('inlet_temp').toFixed(1)} ℃
+
出水温度: {getValue('outlet_temp').toFixed(1)} ℃
+
流量: {getValue('flow_rate').toFixed(2)} m³/h
+
室外温度: {getValue('outdoor_temp').toFixed(1)} ℃
+
+ +
+ ); +} + +// ─── Meter Detail ─────────────────────────────────────────────────── +function MeterDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const needleRef = useRef(null); + + useFrame(() => { + if (needleRef.current) { + const power = getValue('power'); + const angle = (power / 150) * Math.PI - Math.PI / 2; + needleRef.current.rotation.z = angle; + } + }); + + return ( + + {/* Body */} + + + + + + {/* Screen */} + + + + + + {/* Dial face */} + + + + + + {/* Needle */} + + + + + + {/* HTML overlay */} + +
+
电表详情
+
功率: {getValue('power').toFixed(1)} kW
+
电压: {getValue('voltage').toFixed(1)} V
+
电流: {getValue('current').toFixed(2)} A
+
功率因数: {getValue('power_factor').toFixed(3)}
+
+ +
+ ); +} + +// ─── Heat Meter Detail ────────────────────────────────────────────── +function HeatMeterDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const needleRef = useRef(null); + + useFrame(() => { + if (needleRef.current) { + const power = getValue('heat_power'); + const angle = (power / 200) * Math.PI - Math.PI / 2; + needleRef.current.rotation.z = angle; + } + }); + + return ( + + {/* Body */} + + + + + + {/* Display screen */} + + + + + + {/* Dial */} + + + + + + {/* Needle */} + + + + + + {/* HTML overlay */} + +
+
热量表详情
+
热功率: {getValue('heat_power').toFixed(1)} kW
+
流量: {getValue('flow_rate').toFixed(2)} m³/h
+
供水温度: {getValue('supply_temp').toFixed(1)} ℃
+
回水温度: {getValue('return_temp').toFixed(1)} ℃
+
累计热量: {getValue('cumulative_heat').toFixed(3)} GJ
+
+ +
+ ); +} + +// ─── Sensor Detail ────────────────────────────────────────────────── +function SensorDetail({ + getValue, +}: { + getValue: (key: string) => number; +}) { + const sphereRef = useRef(null); + const ringRef = useRef(null); + + useFrame((state, delta) => { + // Pulsing glow + if (sphereRef.current) { + const mat = sphereRef.current.material as THREE.MeshStandardMaterial; + mat.emissiveIntensity = 0.3 + 0.3 * Math.sin(state.clock.elapsedTime * 2); + } + // Rotating ring + if (ringRef.current) { + ringRef.current.rotation.y += 0.8 * delta; + } + }); + + return ( + + {/* Sensor sphere */} + + + + + + {/* Antenna */} + + + + + + {/* Ring */} + + + + + + {/* HTML overlay */} + +
+
传感器详情
+
温度: {getValue('temperature').toFixed(1)} ℃
+
湿度: {getValue('humidity').toFixed(1)} %
+
+ +
+ ); +} + +// ─── Fallback ─────────────────────────────────────────────────────── +function DefaultDetail({ name }: { name: string }) { + const sphereRef = useRef(null); + + useFrame((state) => { + if (sphereRef.current) { + const mat = sphereRef.current.material as THREE.MeshStandardMaterial; + mat.emissiveIntensity = 0.3 + 0.2 * Math.sin(state.clock.elapsedTime * 2); + } + }); + + return ( + + + + + + +
+
{name}
+
+ +
+ ); +} + +// ─── Main Component ───────────────────────────────────────────────── +export default function DeviceDetailView({ + device, + position, + realtimeData, +}: DeviceDetailViewProps) { + const groupRef = useRef(null); + + useFrame((_, delta) => { + if (groupRef.current) { + groupRef.current.rotation.y += 0.05 * delta; + } + }); + + const getValue = (key: string) => realtimeData?.[key]?.value ?? 0; + + const renderDetail = () => { + switch (device.device_type) { + case 'pv_inverter': + return ; + case 'heat_pump': + return ; + case 'meter': + return ; + case 'heat_meter': + return ; + case 'sensor': + return ; + default: + return ; + } + }; + + return ( + + {renderDetail()} + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx b/frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx new file mode 100644 index 0000000..2d4d3e1 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx @@ -0,0 +1,172 @@ +import { useEffect, useRef, useState } from 'react'; +import styles from '../styles.module.css'; +import { getDeviceRealtime } from '../../../services/api'; + +interface Device { + id: number; + name: string; + code: string; + device_type: string; + status: string; + model?: string; + manufacturer?: string; + rated_power?: number; +} + +interface DeviceInfoPanelProps { + device: Device | null; + onClose: () => void; + onViewDetail: (device: Device) => void; +} + +interface ParamDef { + key: string; + label: string; + unit: string; +} + +const PARAMS_BY_TYPE: Record = { + pv_inverter: [ + { key: 'power', label: '功率', unit: 'kW' }, + { key: 'daily_energy', label: '日发电量', unit: 'kWh' }, + { key: 'total_energy', label: '累计发电', unit: 'kWh' }, + { key: 'dc_voltage', label: '直流电压', unit: 'V' }, + { key: 'ac_voltage', label: '交流电压', unit: 'V' }, + { key: 'temperature', label: '温度', unit: '℃' }, + ], + heat_pump: [ + { key: 'power', label: '功率', unit: 'kW' }, + { key: 'cop', label: 'COP', unit: '' }, + { key: 'inlet_temp', label: '进水温度', unit: '℃' }, + { key: 'outlet_temp', label: '出水温度', unit: '℃' }, + { key: 'flow_rate', label: '流量', unit: 'm³/h' }, + { key: 'outdoor_temp', label: '室外温度', unit: '℃' }, + ], + meter: [ + { key: 'power', label: '功率', unit: 'kW' }, + { key: 'voltage', label: '电压', unit: 'V' }, + { key: 'current', label: '电流', unit: 'A' }, + { key: 'power_factor', label: '功率因数', unit: '' }, + ], + sensor: [ + { key: 'temperature', label: '温度', unit: '℃' }, + { key: 'humidity', label: '湿度', unit: '%' }, + ], + heat_meter: [ + { key: 'heat_power', label: '热功率', unit: 'kW' }, + { key: 'flow_rate', label: '流量', unit: 'm³/h' }, + { key: 'supply_temp', label: '供水温度', unit: '℃' }, + { key: 'return_temp', label: '回水温度', unit: '℃' }, + { key: 'cumulative_heat', label: '累计热量', unit: 'GJ' }, + ], +}; + +const STATUS_COLORS: Record = { + online: '#00ff88', + offline: '#666666', + alarm: '#ff4757', + maintenance: '#ff8c00', +}; + +const STATUS_LABELS: Record = { + online: '在线', + offline: '离线', + alarm: '告警', + maintenance: '维护', +}; + +export default function DeviceInfoPanel({ device, onClose, onViewDetail }: DeviceInfoPanelProps) { + const [realtimeData, setRealtimeData] = useState>({}); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (!device) { + setRealtimeData({}); + return; + } + + const fetchData = async () => { + try { + const resp = await getDeviceRealtime(device.id) as any; + // API returns { device: {...}, data: { power: {...}, ... } } + const realtimeMap = resp?.data ?? resp; + setRealtimeData(realtimeMap as Record); + } catch { + // ignore fetch errors + } + }; + + fetchData(); + timerRef.current = setInterval(fetchData, 5000); + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [device?.id]); + + if (!device) return null; + + const params = PARAMS_BY_TYPE[device.device_type] || []; + + return ( +
+
+ {device.name} + +
+ +
+
+ 状态 + + {STATUS_LABELS[device.status] || device.status} + +
+ {device.model && ( +
+ 型号 + {device.model} +
+ )} + {device.manufacturer && ( +
+ 厂家 + {device.manufacturer} +
+ )} + {device.rated_power != null && ( +
+ 额定功率 + {device.rated_power} kW +
+ )} +
+ +
+ {params.map(param => { + const data = realtimeData[param.key]; + const valueStr = data != null ? `${data.value}${param.unit ? ' ' + param.unit : ''}` : '--'; + return ( +
+ {param.label} + {valueStr} +
+ ); + })} +
+ + +
+ ); +} diff --git a/frontend/src/pages/BigScreen3D/components/DeviceListPanel.tsx b/frontend/src/pages/BigScreen3D/components/DeviceListPanel.tsx new file mode 100644 index 0000000..bc797bc --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/DeviceListPanel.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import styles from '../styles.module.css'; + +interface Device { + id: number; + name: string; + code: string; + device_type: string; + status: string; + primaryValue?: string; +} + +interface DeviceListPanelProps { + devices: Device[]; + selectedDeviceId: number | null; + onDeviceSelect: (device: Device) => void; +} + +const TYPE_LABELS: Record = { + pv_inverter: '光伏逆变器', + heat_pump: '空气源热泵', + meter: '电表', + sensor: '温湿度传感器', + heat_meter: '热量表', +}; + +const STATUS_COLORS: Record = { + online: '#00ff88', + offline: '#666666', + alarm: '#ff4757', + maintenance: '#ff8c00', +}; + +export default function DeviceListPanel({ devices, selectedDeviceId, onDeviceSelect }: DeviceListPanelProps) { + const [collapsed, setCollapsed] = useState>({}); + + const groups: Record = {}; + for (const device of devices) { + const type = device.device_type; + if (!groups[type]) groups[type] = []; + groups[type].push(device); + } + + const toggleGroup = (type: string) => { + setCollapsed(prev => ({ ...prev, [type]: !prev[type] })); + }; + + return ( +
+ {Object.entries(TYPE_LABELS).map(([type, label]) => { + const group = groups[type]; + if (!group || group.length === 0) return null; + const isCollapsed = collapsed[type] ?? false; + + return ( +
+
toggleGroup(type)}> + {isCollapsed ? '▸' : '▾'} {label} +
+ {!isCollapsed && + group.map(device => ( +
onDeviceSelect(device)} + > + + {device.name} + {device.primaryValue && ( + {device.primaryValue} + )} +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx b/frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx new file mode 100644 index 0000000..2fb0bbb --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx @@ -0,0 +1,200 @@ +import { useMemo } from 'react'; +import { Html } from '@react-three/drei'; +import { DEVICE_POSITIONS, COLORS } from '../constants'; + +interface DeviceMarkersProps { + devices: Array<{ + id: number; + code: string; + device_type: string; + name: string; + status: string; + primaryValue?: string; + }>; + hoveredId: number | null; + onHover: (id: number | null) => void; + onClick: (device: { id: number; code: string; device_type: string; name: string; status: string; primaryValue?: string }) => void; + detailMode?: boolean; +} + +const labelStyle: React.CSSProperties = { + fontSize: '11px', + color: COLORS.text, + background: 'rgba(6, 30, 62, 0.85)', + padding: '2px 6px', + borderRadius: '3px', + border: '1px solid rgba(0, 212, 255, 0.2)', + whiteSpace: 'nowrap', + pointerEvents: 'none', + textAlign: 'center', +}; + +function MeterMarker({ + device, + position, + isHovered, + accentColor, + onHover, + onClick, +}: { + device: DeviceMarkersProps['devices'][number]; + position: [number, number, number]; + isHovered: boolean; + accentColor: string; + onHover: (id: number | null) => void; + onClick: (device: DeviceMarkersProps['devices'][number]) => void; +}) { + const scale: [number, number, number] = isHovered ? [1.2, 1.2, 1.2] : [1, 1, 1]; + + return ( + { e.stopPropagation(); onHover(device.id); }} + onPointerOut={(e) => { e.stopPropagation(); onHover(null); }} + onClick={(e) => { e.stopPropagation(); onClick(device); }} + > + {/* Body */} + + + + + {/* Front dial */} + + + + + {/* Label */} + +
+
{device.name}
+ {device.primaryValue &&
{device.primaryValue}
} +
+ +
+ ); +} + +function SensorMarker({ + device, + position, + isHovered, + onHover, + onClick, +}: { + device: DeviceMarkersProps['devices'][number]; + position: [number, number, number]; + isHovered: boolean; + onHover: (id: number | null) => void; + onClick: (device: DeviceMarkersProps['devices'][number]) => void; +}) { + const scale: [number, number, number] = isHovered ? [1.2, 1.2, 1.2] : [1, 1, 1]; + + return ( + { e.stopPropagation(); onHover(device.id); }} + onPointerOut={(e) => { e.stopPropagation(); onHover(null); }} + onClick={(e) => { e.stopPropagation(); onClick(device); }} + > + {/* Sphere */} + + + + + {/* Antenna */} + + + + + {/* Label */} + +
+
{device.name}
+ {device.primaryValue &&
{device.primaryValue}
} +
+ +
+ ); +} + +export default function DeviceMarkers({ devices, hoveredId, onHover, onClick }: DeviceMarkersProps) { + const categorized = useMemo(() => { + const meters: typeof devices = []; + const sensors: typeof devices = []; + const heatMeters: typeof devices = []; + + devices.forEach((d) => { + if (d.device_type === 'heat_meter' || d.code.startsWith('HM-')) { + heatMeters.push(d); + } else if (d.code.startsWith('MTR-')) { + meters.push(d); + } else if (d.code.startsWith('SENSOR-')) { + sensors.push(d); + } + }); + + return { meters, sensors, heatMeters }; + }, [devices]); + + return ( + + {/* Regular meters */} + {categorized.meters.map((d) => { + const posInfo = DEVICE_POSITIONS[d.code]; + if (!posInfo) return null; + return ( + + ); + })} + + {/* Heat meters */} + {categorized.heatMeters.map((d) => { + const posInfo = DEVICE_POSITIONS[d.code]; + if (!posInfo) return null; + return ( + + ); + })} + + {/* Sensors */} + {categorized.sensors.map((d) => { + const posInfo = DEVICE_POSITIONS[d.code]; + if (!posInfo) return null; + return ( + + ); + })} + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx b/frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx new file mode 100644 index 0000000..ccab272 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx @@ -0,0 +1,164 @@ +import { useRef, useMemo } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { ENERGY_FLOW_PATHS } from '../constants'; + +interface EnergyParticlesProps { + energyFlow: { nodes: any[]; links: any[] }; + realtimeData: { pv_power?: number; grid_power?: number; heatpump_power?: number } | null; +} + +interface ParticlePathConfig { + key: string; + curve: THREE.CatmullRomCurve3; + color: string; + power: number; +} + +const MAX_PARTICLES_PER_PATH = 40; +const MIN_PARTICLES = 3; +const PARTICLE_SIZE = 0.4; +const BASE_SPEED = 0.003; +const SPEED_VARIANCE = 0.002; + +export default function EnergyParticles({ energyFlow, realtimeData }: EnergyParticlesProps) { + const pvPower = realtimeData?.pv_power ?? 0; + const gridPower = realtimeData?.grid_power ?? 0; + const hpPower = realtimeData?.heatpump_power ?? 0; + + const paths = useMemo(() => { + const configs: ParticlePathConfig[] = []; + + // PV -> Building + if (pvPower > 0) { + configs.push({ + key: 'pv', + curve: new THREE.CatmullRomCurve3( + ENERGY_FLOW_PATHS.pvToBuilding.waypoints.map((p) => new THREE.Vector3(...p)), + ), + color: ENERGY_FLOW_PATHS.pvToBuilding.color, + power: pvPower, + }); + } + + // Grid -> Building (only when importing, i.e. positive) + if (gridPower > 0) { + configs.push({ + key: 'grid', + curve: new THREE.CatmullRomCurve3( + ENERGY_FLOW_PATHS.gridToBuilding.waypoints.map((p) => new THREE.Vector3(...p)), + ), + color: ENERGY_FLOW_PATHS.gridToBuilding.color, + power: gridPower, + }); + } + + // Building -> HeatPump + if (hpPower > 0) { + configs.push({ + key: 'hp', + curve: new THREE.CatmullRomCurve3( + ENERGY_FLOW_PATHS.buildingToHeatPump.waypoints.map((p) => new THREE.Vector3(...p)), + ), + color: ENERGY_FLOW_PATHS.buildingToHeatPump.color, + power: hpPower, + }); + } + + return configs; + }, [pvPower, gridPower, hpPower]); + + return ( + + {paths.map((path) => ( + + ))} + + ); +} + +function ParticlePath({ config }: { config: ParticlePathConfig }) { + const { curve, color, power } = config; + + const count = Math.max( + MIN_PARTICLES, + Math.min(Math.floor(power / 5), MAX_PARTICLES_PER_PATH), + ); + + // Stable random speeds per particle + const speeds = useMemo( + () => Array.from({ length: count }, () => BASE_SPEED + Math.random() * SPEED_VARIANCE), + [count], + ); + + // Progress values (0..1) for each particle along the curve + const progressRef = useRef(new Float32Array(0)); + if (progressRef.current.length !== count) { + progressRef.current = new Float32Array(count); + for (let i = 0; i < count; i++) { + progressRef.current[i] = Math.random(); + } + } + + const positionsRef = useRef(new Float32Array(count * 3)); + if (positionsRef.current.length !== count * 3) { + positionsRef.current = new Float32Array(count * 3); + } + + const geomRef = useRef(null); + + // Initialize positions + useMemo(() => { + const pos = positionsRef.current; + for (let i = 0; i < count; i++) { + const pt = curve.getPointAt(progressRef.current[i]); + pos[i * 3] = pt.x; + pos[i * 3 + 1] = pt.y; + pos[i * 3 + 2] = pt.z; + } + }, [curve, count]); + + useFrame(() => { + const prog = progressRef.current; + const pos = positionsRef.current; + + for (let i = 0; i < count; i++) { + prog[i] = (prog[i] + speeds[i]) % 1; + const pt = curve.getPointAt(prog[i]); + pos[i * 3] = pt.x; + pos[i * 3 + 1] = pt.y; + pos[i * 3 + 2] = pt.z; + } + + if (geomRef.current) { + const attr = geomRef.current.getAttribute('position') as THREE.BufferAttribute; + attr.needsUpdate = true; + } + }); + + const material = useMemo( + () => + new THREE.PointsMaterial({ + size: PARTICLE_SIZE, + color: new THREE.Color(color), + sizeAttenuation: true, + blending: THREE.AdditiveBlending, + transparent: true, + opacity: 0.8, + depthWrite: false, + }), + [color], + ); + + return ( + + + + + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/Ground.tsx b/frontend/src/pages/BigScreen3D/components/Ground.tsx new file mode 100644 index 0000000..89728d8 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/Ground.tsx @@ -0,0 +1,22 @@ +import { Grid } from '@react-three/drei'; + +export default function Ground() { + return ( + + + + + + + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx b/frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx new file mode 100644 index 0000000..862a575 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import styles from '../styles.module.css'; +import AnimatedNumber from '../../BigScreen/components/AnimatedNumber'; + +interface HUDOverlayProps { + overview: { + today_generation?: number; + today_consumption?: number; + carbon_reduction?: number; + active_alarms?: number; + } | null; + realtimeData: { + pv_power?: number; + grid_power?: number; + heat_pump_power?: number; + total_load?: number; + } | null; + deviceStats: { + online?: number; + total?: number; + } | null; +} + +const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; + +function formatDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const w = WEEKDAYS[date.getDay()]; + return `${y}年${m}月${d}日 星期${w}`; +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString('zh-CN', { hour12: false }); +} + +export default function HUDOverlay({ overview, realtimeData }: HUDOverlayProps) { + const [now, setNow] = useState(new Date()); + + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + return ( +
+
+ {formatDate(now)} + 天普零碳园区 3D智慧能源管理平台 + {formatTime(now)} +
+ +
+
+ 光伏发电 + + + kW + +
+
+ 电网功率 + + + kW + +
+
+ 总负荷 + + + kW + +
+
+ 今日发电 + + + kWh + +
+
+ 碳减排 + + + kg + +
+
+
+ ); +} diff --git a/frontend/src/pages/BigScreen3D/components/HeatPumps.tsx b/frontend/src/pages/BigScreen3D/components/HeatPumps.tsx new file mode 100644 index 0000000..8a1b786 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/HeatPumps.tsx @@ -0,0 +1,147 @@ +import { useRef, useMemo } from 'react'; +import * as THREE from 'three'; +import { useFrame } from '@react-three/fiber'; +import { DEVICE_POSITIONS } from '../constants'; + +interface HeatPumpsProps { + devices: Array<{ id: number; code: string; status: string; power?: number }>; + hoveredId: number | null; + onHover: (id: number | null) => void; + onClick: (device: { id: number; code: string; status: string; power?: number }) => void; + detailMode?: boolean; +} + +const HP_CODES = ['HP-01', 'HP-02', 'HP-03', 'HP-04'] as const; + +function FanBlades({ speed, status }: { speed: number; status: string }) { + const bladesRef = useRef(null); + + useFrame((_, delta) => { + if (!bladesRef.current) return; + if (status === 'offline') return; + bladesRef.current.rotation.y += speed * delta; + }); + + return ( + + {/* Fan housing */} + + + + + {/* Blades */} + + {[0, 1, 2].map((i) => ( + + + + + ))} + + + ); +} + +function HeatPumpUnit({ + position, + device, + isHovered, + onHover, + onClick, +}: { + position: [number, number, number]; + device: { id: number; code: string; status: string; power?: number } | undefined; + isHovered: boolean; + onHover: (id: number | null) => void; + onClick: (device: { id: number; code: string; status: string; power?: number }) => void; +}) { + const meshRef = useRef(null); + const status = device?.status ?? 'offline'; + const power = device?.power ?? 0; + const fanSpeed = status !== 'offline' ? (power / 35) * 2 : 0; + + useFrame((state) => { + if (!meshRef.current) return; + const mat = meshRef.current.material as THREE.MeshStandardMaterial; + if (status === 'alarm') { + mat.emissiveIntensity = Math.sin(state.clock.elapsedTime * 3) * 0.3 + 0.2; + } + }); + + const bodyColor = status === 'offline' ? '#444444' : '#2a4a6a'; + const emissiveColor = status === 'alarm' ? '#ff4757' : status === 'online' ? '#00d4ff' : '#000000'; + const emissiveIntensity = status === 'offline' ? 0 : isHovered ? 0.3 : 0.1; + const scale: [number, number, number] = isHovered ? [1.05, 1.05, 1.05] : [1, 1, 1]; + + return ( + { e.stopPropagation(); if (device) onHover(device.id); }} + onPointerOut={(e) => { e.stopPropagation(); onHover(null); }} + onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }} + > + {/* Main body */} + + + + + + {/* Fan on top */} + + + {/* Side pipes - left */} + + + + + + + + + + {/* Side pipes - right */} + + + + + + + + + + ); +} + +export default function HeatPumps({ devices, hoveredId, onHover, onClick }: HeatPumpsProps) { + const deviceMap = useMemo(() => { + const map = new Map(); + devices.forEach((d) => map.set(d.code, d)); + return map; + }, [devices]); + + return ( + + {HP_CODES.map((code) => { + const pos = DEVICE_POSITIONS[code].position; + const device = deviceMap.get(code); + return ( + + ); + })} + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/PVPanels.tsx b/frontend/src/pages/BigScreen3D/components/PVPanels.tsx new file mode 100644 index 0000000..00dffe0 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/PVPanels.tsx @@ -0,0 +1,107 @@ +import { useRef, useMemo } from 'react'; +import * as THREE from 'three'; +import { useFrame } from '@react-three/fiber'; +import { DEVICE_POSITIONS, PV_ARRAY, COLORS } from '../constants'; + +interface PVPanelsProps { + devices: Array<{ id: number; code: string; status: string; power?: number; rated_power?: number }>; + hoveredId: number | null; + onHover: (id: number | null) => void; + onClick: (device: { id: number; code: string; status: string; power?: number; rated_power?: number }) => void; + detailMode?: boolean; +} + +const PV_ZONES = [ + { code: 'PV-INV-01', center: DEVICE_POSITIONS['PV-INV-01'].position }, + { code: 'PV-INV-02', center: DEVICE_POSITIONS['PV-INV-02'].position }, + { code: 'PV-INV-03', center: DEVICE_POSITIONS['PV-INV-03'].position }, +] as const; + +function PVZone({ + center, + device, + isHovered, + onHover, + onClick, +}: { + center: readonly [number, number, number]; + device: { id: number; code: string; status: string; power?: number; rated_power?: number } | undefined; + isHovered: boolean; + onHover: (id: number | null) => void; + onClick: (device: { id: number; code: string; status: string; power?: number; rated_power?: number }) => void; +}) { + const groupRef = useRef(null); + + const panels = useMemo(() => { + const items: { pos: [number, number, number] }[] = []; + const { cols, rows, panelWidth, panelHeight, gap } = PV_ARRAY; + const totalW = cols * panelWidth + (cols - 1) * gap; + const totalD = rows * panelHeight + (rows - 1) * gap; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const x = -totalW / 2 + panelWidth / 2 + c * (panelWidth + gap); + const z = -totalD / 2 + panelHeight / 2 + r * (panelHeight + gap); + items.push({ pos: [x, 0, z] }); + } + } + return items; + }, []); + + const ratio = device && device.rated_power ? (device.power ?? 0) / device.rated_power : 0; + const emissiveIntensity = Math.min(ratio * 0.5, 0.5); + + return ( + { e.stopPropagation(); if (device) onHover(device.id); }} + onPointerOut={(e) => { e.stopPropagation(); onHover(null); }} + onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }} + > + {panels.map((p, i) => ( + + + + + ))} + + ); +} + +export default function PVPanels({ devices, hoveredId, onHover, onClick }: PVPanelsProps) { + const deviceMap = useMemo(() => { + const map = new Map(); + devices.forEach((d) => map.set(d.code, d)); + return map; + }, [devices]); + + return ( + + {PV_ZONES.map((zone) => { + const device = deviceMap.get(zone.code); + return ( + + ); + })} + + ); +} diff --git a/frontend/src/pages/BigScreen3D/components/SceneEnvironment.tsx b/frontend/src/pages/BigScreen3D/components/SceneEnvironment.tsx new file mode 100644 index 0000000..eebd480 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/components/SceneEnvironment.tsx @@ -0,0 +1,24 @@ +import { Stars } from '@react-three/drei'; + +export default function SceneEnvironment() { + return ( + <> + + + + + + ); +} diff --git a/frontend/src/pages/BigScreen3D/constants.ts b/frontend/src/pages/BigScreen3D/constants.ts new file mode 100644 index 0000000..b9fd874 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/constants.ts @@ -0,0 +1,120 @@ +// Campus layout constants for 3D scene +// Scale: 1 unit ≈ 2 meters + +// ============ Colors (matching existing BigScreen dark theme) ============ +export const COLORS = { + background: '#0a1628', + primary: '#00d4ff', + pvGreen: '#00ff88', + gridOrange: '#ff8c00', + alarmRed: '#ff4757', + sensorPurple: '#a78bfa', + heatPumpCyan: '#00d4ff', + buildingBase: '#1a3a5c', + buildingEdge: 'rgba(0, 212, 255, 0.3)', + windowGlow: '#ffcc66', + cardBg: 'rgba(6, 30, 62, 0.85)', + cardBorder: 'rgba(0, 212, 255, 0.25)', + text: '#e0e8f0', + textSecondary: '#8899aa', + groundGrid: '#0d2137', + groundLine: '#1a3a5c', +} as const; + +// ============ Buildings ============ +export const BUILDINGS = { + east: { + label: '东楼', + position: [12, 6, -5] as [number, number, number], + size: [20, 12, 15] as [number, number, number], + }, + west: { + label: '西楼', + position: [-12, 5, -5] as [number, number, number], + size: [16, 10, 12] as [number, number, number], + }, +} as const; + +// ============ Device Positions ============ +export const DEVICE_POSITIONS: Record = { + // PV Inverters (on rooftops) + 'PV-INV-01': { position: [6, 12.3, -8], type: 'pv_inverter' }, + 'PV-INV-02': { position: [18, 12.3, -8], type: 'pv_inverter' }, + 'PV-INV-03': { position: [-12, 10.3, -8], type: 'pv_inverter' }, + + // Heat Pumps (ground level, beside buildings) + 'HP-01': { position: [24, 0, 2], type: 'heat_pump' }, + 'HP-02': { position: [24, 0, 6], type: 'heat_pump' }, + 'HP-03': { position: [-24, 0, 2], type: 'heat_pump' }, + 'HP-04': { position: [-24, 0, 6], type: 'heat_pump' }, + + // Meters (ground, near entrances) + 'MTR-GRID': { position: [0, 0, 14], type: 'meter' }, + 'MTR-PV': { position: [3, 0, 14], type: 'meter' }, + 'MTR-HP': { position: [26, 0, -1], type: 'meter' }, + 'MTR-PUMP': { position: [-26, 0, -1], type: 'meter' }, + + // Sensors (elevated, on buildings) + 'SENSOR-01': { position: [8, 6, 2], type: 'sensor' }, + 'SENSOR-02': { position: [16, 6, 2], type: 'sensor' }, + 'SENSOR-03': { position: [-8, 5, 2], type: 'sensor' }, + 'SENSOR-04': { position: [-16, 5, 2], type: 'sensor' }, + 'SENSOR-05': { position: [0, 4, 5], type: 'sensor' }, + + // Heat Meter + 'HM-01': { position: [26, 0, 4], type: 'heat_meter' }, +}; + +// ============ Camera ============ +export const CAMERA = { + campusPosition: [0, 35, 45] as [number, number, number], + campusTarget: [0, 0, 0] as [number, number, number], + fov: 45, + near: 0.1, + far: 500, + detailDistance: 8, + animationDuration: 1.5, +} as const; + +// ============ Energy Flow Paths ============ +export const ENERGY_FLOW_PATHS = { + pvToBuilding: { + color: '#00ff88', + waypoints: [[12, 13, -8], [12, 10, 0], [0, 6, 5]] as [number, number, number][], + }, + gridToBuilding: { + color: '#ff8c00', + waypoints: [[0, 1, 14], [0, 4, 10], [0, 6, 5]] as [number, number, number][], + }, + buildingToHeatPump: { + color: '#00d4ff', + waypoints: [[0, 6, 5], [12, 3, 4], [24, 1, 4]] as [number, number, number][], + }, +} as const; + +// ============ PV Panel Array ============ +export const PV_ARRAY = { + cols: 5, + rows: 3, + panelWidth: 2, + panelHeight: 1, + panelDepth: 0.05, + gap: 0.3, + tiltAngle: Math.PI / 6, // 30 degrees +} as const; + +// ============ Polling ============ +export const POLL_INTERVAL = 15000; // 15 seconds +export const DETAIL_POLL_INTERVAL = 5000; // 5 seconds for selected device + +// ============ Status Colors ============ +export const STATUS_COLORS: Record = { + online: '#00ff88', + offline: '#666666', + alarm: '#ff4757', + maintenance: '#ff8c00', +}; diff --git a/frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts b/frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts new file mode 100644 index 0000000..6f246be --- /dev/null +++ b/frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts @@ -0,0 +1,69 @@ +import { useRef, useCallback } from 'react'; +import { useThree, useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; +import { CAMERA } from '../constants'; + +export function useCameraAnimation() { + const { camera } = useThree(); + + const isAnimating = useRef(false); + const startPos = useRef(new THREE.Vector3()); + const endPos = useRef(new THREE.Vector3()); + const startTarget = useRef(new THREE.Vector3()); + const endTarget = useRef(new THREE.Vector3()); + const progress = useRef(0); + const duration = useRef(CAMERA.animationDuration); + const currentTarget = useRef(new THREE.Vector3()); + + const animateTo = useCallback( + (position: [number, number, number], target: [number, number, number], dur?: number) => { + startPos.current.copy(camera.position); + endPos.current.set(...position); + + // Estimate current look-at target from camera direction + const dir = new THREE.Vector3(); + camera.getWorldDirection(dir); + startTarget.current.copy(camera.position).add(dir.multiplyScalar(10)); + + endTarget.current.set(...target); + duration.current = dur ?? CAMERA.animationDuration; + progress.current = 0; + isAnimating.current = true; + }, + [camera], + ); + + const resetToOverview = useCallback(() => { + animateTo( + CAMERA.campusPosition as [number, number, number], + CAMERA.campusTarget as [number, number, number], + ); + }, [animateTo]); + + useFrame((_, delta) => { + if (!isAnimating.current) return; + + progress.current = Math.min(progress.current + delta / duration.current, 1); + + // Smooth ease-in-out + const t = progress.current < 0.5 + ? 2 * progress.current * progress.current + : 1 - Math.pow(-2 * progress.current + 2, 2) / 2; + + camera.position.lerpVectors(startPos.current, endPos.current, t); + currentTarget.current.lerpVectors(startTarget.current, endTarget.current, t); + camera.lookAt(currentTarget.current); + + if (progress.current >= 1) { + isAnimating.current = false; + } + }); + + return { + animateTo, + resetToOverview, + get isAnimating() { + return isAnimating.current; + }, + }; +} diff --git a/frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts b/frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts new file mode 100644 index 0000000..5cfc06b --- /dev/null +++ b/frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts @@ -0,0 +1,129 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { getDevices, getDeviceStats, getDashboardOverview, getRealtimeData } from '../../../services/api'; +import type { DeviceInfo, DeviceWithPosition, OverviewData, RealtimePowerData } from '../types'; +import { DEVICE_POSITIONS, POLL_INTERVAL } from '../constants'; + +interface DeviceStats { + online: number; + offline: number; + alarm: number; + maintenance: number; + total: number; +} + +// Ordered position keys by device type for fuzzy matching +const POSITION_KEYS_BY_TYPE: Record = { + pv_inverter: ['PV-INV-01', 'PV-INV-02', 'PV-INV-03'], + heat_pump: ['HP-01', 'HP-02', 'HP-03', 'HP-04'], + meter: ['MTR-GRID', 'MTR-PV', 'MTR-HP', 'MTR-PUMP'], + sensor: ['SENSOR-01', 'SENSOR-02', 'SENSOR-03', 'SENSOR-04', 'SENSOR-05'], + heat_meter: ['HM-01'], +}; + +function matchDevicesToPositions(devices: DeviceInfo[]): DeviceWithPosition[] { + const usedPositions = new Set(); + const result: DeviceWithPosition[] = []; + + // Group devices by type + const byType: Record = {}; + for (const device of devices) { + const type = device.device_type || 'unknown'; + if (!byType[type]) byType[type] = []; + byType[type].push(device); + } + + for (const [type, typeDevices] of Object.entries(byType)) { + const positionKeys = POSITION_KEYS_BY_TYPE[type] || []; + + typeDevices.forEach((device, index) => { + // Try exact match by device code first + let matchedKey: string | undefined; + if (device.code && DEVICE_POSITIONS[device.code] && !usedPositions.has(device.code)) { + matchedKey = device.code; + } + + // Fall back to ordered assignment by type + if (!matchedKey && index < positionKeys.length) { + const key = positionKeys[index]; + if (!usedPositions.has(key)) { + matchedKey = key; + } + } + + const withPos: DeviceWithPosition = { ...device }; + if (matchedKey) { + usedPositions.add(matchedKey); + const posData = DEVICE_POSITIONS[matchedKey]; + withPos.position3D = posData.position; + withPos.rotation3D = posData.rotation; + // Override code with matched key so 3D components can look up positions by code + withPos.code = matchedKey; + } + + result.push(withPos); + }); + } + + return result; +} + +export function useDeviceData() { + const [devices, setDevices] = useState([]); + const [deviceStats, setDeviceStats] = useState(null); + const [overview, setOverview] = useState(null); + const [realtimeData, setRealtimeData] = useState(null); + const [devicesWithPositions, setDevicesWithPositions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const intervalRef = useRef | null>(null); + + const fetchAll = useCallback(async () => { + try { + const results = await Promise.allSettled([ + getDevices({ page_size: 100 }) as Promise, + getDeviceStats() as Promise, + getDashboardOverview() as Promise, + getRealtimeData() as Promise, + ]); + + // Devices + if (results[0].status === 'fulfilled') { + const devData = results[0].value; + const items: DeviceInfo[] = devData?.items || []; + setDevices(items); + setDevicesWithPositions(matchDevicesToPositions(items)); + } + + // Stats + if (results[1].status === 'fulfilled') { + setDeviceStats(results[1].value as DeviceStats); + } + + // Overview + if (results[2].status === 'fulfilled') { + setOverview(results[2].value as OverviewData); + } + + // Realtime + if (results[3].status === 'fulfilled') { + setRealtimeData(results[3].value as RealtimePowerData); + } + + setError(null); + } catch (err: any) { + setError(err?.message || 'Failed to fetch device data'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + intervalRef.current = setInterval(fetchAll, POLL_INTERVAL); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchAll]); + + return { devices, deviceStats, overview, realtimeData, devicesWithPositions, loading, error }; +} diff --git a/frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts b/frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts new file mode 100644 index 0000000..4ad8df9 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts @@ -0,0 +1,33 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { getEnergyFlow } from '../../../services/api'; +import type { EnergyFlowNode, EnergyFlowLink } from '../types'; +import { POLL_INTERVAL } from '../constants'; + +export function useEnergyFlow() { + const [nodes, setNodes] = useState([]); + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + const intervalRef = useRef | null>(null); + + const fetchFlow = useCallback(async () => { + try { + const data = (await getEnergyFlow()) as any; + setNodes(data?.nodes || []); + setLinks(data?.links || []); + } catch { + // Silently ignore — stale data is acceptable for flow visualization + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchFlow(); + intervalRef.current = setInterval(fetchFlow, POLL_INTERVAL); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchFlow]); + + return { nodes, links, loading }; +} diff --git a/frontend/src/pages/BigScreen3D/index.tsx b/frontend/src/pages/BigScreen3D/index.tsx new file mode 100644 index 0000000..b9aa1fe --- /dev/null +++ b/frontend/src/pages/BigScreen3D/index.tsx @@ -0,0 +1,132 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import styles from './styles.module.css'; +import type { DeviceInfo, ViewMode } from './types'; +import { useDeviceData } from './hooks/useDeviceData'; +import { useEnergyFlow } from './hooks/useEnergyFlow'; +import { getDeviceRealtime } from '../../services/api'; +import CampusScene from './components/CampusScene'; +import HUDOverlay from './components/HUDOverlay'; +import DeviceListPanel from './components/DeviceListPanel'; +import DeviceInfoPanel from './components/DeviceInfoPanel'; + +export default function BigScreen3D() { + const { devices, deviceStats, overview, realtimeData, devicesWithPositions, loading } = useDeviceData(); + const { nodes, links } = useEnergyFlow(); + + const [selectedDevice, setSelectedDevice] = useState(null); + const [hoveredDeviceId, setHoveredDeviceId] = useState(null); + const [viewMode, setViewMode] = useState('campus'); + const [detailRealtimeData, setDetailRealtimeData] = useState | null>(null); + const detailTimerRef = useRef | null>(null); + + // Poll per-device realtime data when in device-detail view + useEffect(() => { + if (!selectedDevice || viewMode !== 'device-detail') { + setDetailRealtimeData(null); + if (detailTimerRef.current) clearInterval(detailTimerRef.current); + return; + } + + const fetchDetail = async () => { + try { + const resp = await getDeviceRealtime(selectedDevice.id) as any; + const realtimeMap = resp?.data ?? resp; + setDetailRealtimeData(realtimeMap as Record); + } catch { + // ignore errors + } + }; + + fetchDetail(); + detailTimerRef.current = setInterval(fetchDetail, 5000); + + return () => { + if (detailTimerRef.current) clearInterval(detailTimerRef.current); + }; + }, [selectedDevice?.id, viewMode]); + + const handleDeviceSelect = useCallback((device: DeviceInfo) => { + setSelectedDevice(device); + }, []); + + const handleDeviceClose = useCallback(() => { + setSelectedDevice(null); + }, []); + + const handleEnterDetail = useCallback((device: DeviceInfo) => { + setSelectedDevice(device); + setViewMode('device-detail'); + }, []); + + const handleExitDetail = useCallback(() => { + setViewMode('campus'); + }, []); + + // Find 3D position of the selected device + const selectedDevicePosition = selectedDevice + ? (devicesWithPositions.find((d) => d.id === selectedDevice.id)?.position3D ?? null) + : null; + + if (loading && devicesWithPositions.length === 0) { + return ( +
+
+

天普零碳园区 3D智慧能源管理平台

+

正在加载设备数据...

+
+
+ ); + } + + return ( +
+ {/* 3D Canvas — fills entire screen */} +
+ +
+ + {/* HUD: header bar + bottom metrics — pointer-events: none */} + + + {/* Left device list panel (only in campus view) */} + {viewMode === 'campus' && ( + + )} + + {/* Right device info panel (when device selected in campus view) */} + {selectedDevice && viewMode === 'campus' && ( + + )} + + {/* Return button in detail view */} + {viewMode === 'device-detail' && ( + + )} +
+ ); +} diff --git a/frontend/src/pages/BigScreen3D/styles.module.css b/frontend/src/pages/BigScreen3D/styles.module.css new file mode 100644 index 0000000..b41fb15 --- /dev/null +++ b/frontend/src/pages/BigScreen3D/styles.module.css @@ -0,0 +1,329 @@ +/* BigScreen 3D - Dark monitoring theme */ +.container { + width: 100vw; + height: 100vh; + background: #0a1628; + position: relative; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: #e0e8f0; +} + +.placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #00d4ff; +} + +.placeholderTitle { + font-size: 2rem; + margin-bottom: 1rem; +} + +/* Canvas fills entire screen */ +.canvasWrapper { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; +} + +/* HUD overlay on top of canvas */ +.hudOverlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10; + pointer-events: none; +} + +/* Header bar */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 24px; + background: linear-gradient(180deg, rgba(6, 30, 62, 0.95) 0%, transparent 100%); + pointer-events: none; +} + +.headerDate { + font-size: 14px; + color: #8899aa; + min-width: 200px; +} + +.headerTitle { + font-size: 24px; + font-weight: 700; + background: linear-gradient(90deg, #00d4ff, #00ff88, #00d4ff); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 3s linear infinite; + text-align: center; +} + +@keyframes shimmer { + to { background-position: 200% center; } +} + +.headerClock { + font-size: 20px; + font-weight: 600; + color: #00d4ff; + font-variant-numeric: tabular-nums; + min-width: 200px; + text-align: right; +} + +/* Bottom metrics bar */ +.metricsBar { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + display: flex; + justify-content: center; + gap: 24px; + padding: 16px 24px; + background: linear-gradient(0deg, rgba(6, 30, 62, 0.95) 0%, transparent 100%); + pointer-events: none; +} + +.metricCard { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 20px; + background: rgba(0, 212, 255, 0.08); + border: 1px solid rgba(0, 212, 255, 0.2); + border-radius: 8px; + min-width: 140px; +} + +.metricLabel { + font-size: 12px; + color: #8899aa; + margin-bottom: 4px; +} + +.metricValue { + font-size: 22px; + font-weight: 700; + color: #00d4ff; + font-variant-numeric: tabular-nums; +} + +.metricUnit { + font-size: 12px; + color: #8899aa; + margin-left: 4px; +} + +/* Left device list panel */ +.deviceListPanel { + position: absolute; + top: 60px; + left: 16px; + width: 240px; + max-height: calc(100vh - 160px); + overflow-y: auto; + background: rgba(6, 30, 62, 0.85); + border: 1px solid rgba(0, 212, 255, 0.25); + border-radius: 8px; + padding: 12px; + z-index: 20; + pointer-events: auto; +} + +.deviceListPanel::-webkit-scrollbar { + width: 4px; +} +.deviceListPanel::-webkit-scrollbar-thumb { + background: rgba(0, 212, 255, 0.3); + border-radius: 2px; +} + +.deviceGroupTitle { + font-size: 13px; + font-weight: 600; + color: #00d4ff; + padding: 8px 0 4px; + border-bottom: 1px solid rgba(0, 212, 255, 0.15); + margin-bottom: 4px; + cursor: pointer; + user-select: none; +} + +.deviceItem { + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; + gap: 8px; +} + +.deviceItem:hover { + background: rgba(0, 212, 255, 0.1); +} + +.deviceItemActive { + background: rgba(0, 212, 255, 0.15); + border: 1px solid rgba(0, 212, 255, 0.3); +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.deviceName { + font-size: 12px; + color: #e0e8f0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deviceValue { + font-size: 11px; + color: #00d4ff; + font-variant-numeric: tabular-nums; +} + +/* Right device info panel */ +.deviceInfoPanel { + position: absolute; + top: 60px; + right: 16px; + width: 300px; + max-height: calc(100vh - 160px); + overflow-y: auto; + background: rgba(6, 30, 62, 0.9); + border: 1px solid rgba(0, 212, 255, 0.25); + border-radius: 8px; + padding: 16px; + z-index: 20; + pointer-events: auto; +} + +.infoPanelHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(0, 212, 255, 0.2); +} + +.infoPanelTitle { + font-size: 16px; + font-weight: 600; + color: #e0e8f0; +} + +.closeBtn { + background: none; + border: 1px solid rgba(0, 212, 255, 0.3); + color: #8899aa; + font-size: 14px; + cursor: pointer; + padding: 2px 8px; + border-radius: 4px; + transition: all 0.2s; +} + +.closeBtn:hover { + color: #00d4ff; + border-color: #00d4ff; +} + +.paramRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.paramLabel { + font-size: 13px; + color: #8899aa; +} + +.paramValue { + font-size: 15px; + font-weight: 600; + color: #00d4ff; + font-variant-numeric: tabular-nums; +} + +.detailBtn { + width: 100%; + margin-top: 12px; + padding: 8px; + background: rgba(0, 212, 255, 0.15); + border: 1px solid rgba(0, 212, 255, 0.4); + border-radius: 6px; + color: #00d4ff; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.detailBtn:hover { + background: rgba(0, 212, 255, 0.25); +} + +/* Return button (detail view) */ +.returnBtn { + position: absolute; + top: 70px; + left: 50%; + transform: translateX(-50%); + padding: 8px 24px; + background: rgba(6, 30, 62, 0.9); + border: 1px solid rgba(0, 212, 255, 0.4); + border-radius: 20px; + color: #00d4ff; + font-size: 14px; + cursor: pointer; + z-index: 25; + pointer-events: auto; + transition: all 0.2s; +} + +.returnBtn:hover { + background: rgba(0, 212, 255, 0.2); +} + +/* 3D label styles (used inside drei Html) */ +.label3d { + font-size: 11px; + color: #e0e8f0; + background: rgba(6, 30, 62, 0.8); + padding: 2px 8px; + border-radius: 4px; + border: 1px solid rgba(0, 212, 255, 0.2); + white-space: nowrap; + pointer-events: none; +} + +.label3dValue { + color: #00d4ff; + font-weight: 600; + margin-left: 4px; +} diff --git a/frontend/src/pages/BigScreen3D/types.ts b/frontend/src/pages/BigScreen3D/types.ts new file mode 100644 index 0000000..19b19bb --- /dev/null +++ b/frontend/src/pages/BigScreen3D/types.ts @@ -0,0 +1,73 @@ +// 3D BigScreen shared type definitions + +export interface DeviceInfo { + id: number; + name: string; + code: string; + device_type: string; + device_type_id?: number; + status: 'online' | 'offline' | 'alarm' | 'maintenance'; + model?: string; + manufacturer?: string; + rated_power?: number; + location?: string; + serial_number?: string; + collect_interval?: number; +} + +export interface DeviceRealtimeEntry { + value: number; + unit: string; + timestamp: string; +} + +export type DeviceRealtimeData = Record; + +export interface EnergyFlowNode { + id: string; + name: string; + power: number; + unit: string; +} + +export interface EnergyFlowLink { + source: string; + target: string; + value: number; +} + +export interface DevicePosition3D { + deviceCode: string; + position: [number, number, number]; + rotation?: [number, number, number]; + type: string; +} + +export type ViewMode = 'campus' | 'device-detail'; + +export interface SceneState { + viewMode: ViewMode; + selectedDevice: DeviceInfo | null; + hoveredDeviceId: number | null; +} + +export interface DeviceWithPosition extends DeviceInfo { + position3D?: [number, number, number]; + rotation3D?: [number, number, number]; +} + +export interface OverviewData { + total_devices?: number; + online_devices?: number; + today_consumption?: number; + today_generation?: number; + carbon_reduction?: number; + active_alarms?: number; +} + +export interface RealtimePowerData { + pv_power?: number; + heatpump_power?: number; + total_load?: number; + grid_power?: number; +} diff --git a/frontend/src/pages/Devices/index.tsx b/frontend/src/pages/Devices/index.tsx new file mode 100644 index 0000000..a02e959 --- /dev/null +++ b/frontend/src/pages/Devices/index.tsx @@ -0,0 +1,304 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, Row, Col, Statistic, Switch, message } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, AppstoreOutlined } from '@ant-design/icons'; +import { getDevices, getDeviceTypes, getDeviceGroups, getDeviceStats, createDevice, updateDevice } from '../../services/api'; + +const statusMap: Record = { + online: { color: 'green', text: '在线' }, + offline: { color: 'default', text: '离线' }, + alarm: { color: 'red', text: '告警' }, + maintenance: { color: 'orange', text: '维护' }, +}; + +const protocolOptions = [ + { label: 'Modbus TCP', value: 'modbus_tcp' }, + { label: 'Modbus RTU', value: 'modbus_rtu' }, + { label: 'OPC UA', value: 'opc_ua' }, + { label: 'MQTT', value: 'mqtt' }, + { label: 'HTTP API', value: 'http_api' }, + { label: 'DL/T 645', value: 'dlt645' }, + { label: '图像采集', value: 'image' }, +]; + +export default function Devices() { + const [data, setData] = useState({ total: 0, items: [] }); + const [stats, setStats] = useState({ online: 0, offline: 0, alarm: 0, total: 0 }); + const [deviceTypes, setDeviceTypes] = useState([]); + const [deviceGroups, setDeviceGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [editingDevice, setEditingDevice] = useState(null); + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ page: 1, page_size: 20 }); + + const loadDevices = useCallback(async (params?: Record) => { + setLoading(true); + try { + const query = params || filters; + // Remove empty values + const cleanQuery: Record = {}; + Object.entries(query).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v; + }); + const res = await getDevices(cleanQuery); + setData(res as any); + } catch (e) { console.error(e); } + finally { setLoading(false); } + }, [filters]); + + const loadMeta = async () => { + try { + const [types, groups, st] = await Promise.all([ + getDeviceTypes(), getDeviceGroups(), getDeviceStats(), + ]); + setDeviceTypes(types as any[]); + setDeviceGroups(groups as any[]); + setStats(st as any); + } catch (e) { console.error(e); } + }; + + useEffect(() => { loadMeta(); }, []); + useEffect(() => { loadDevices(); }, [filters, loadDevices]); + + const handleFilterChange = (key: string, value: any) => { + setFilters(prev => ({ ...prev, [key]: value, page: 1 })); + }; + + const handlePageChange = (page: number, pageSize: number) => { + setFilters(prev => ({ ...prev, page, page_size: pageSize })); + }; + + const openAddModal = () => { + setEditingDevice(null); + form.resetFields(); + form.setFieldsValue({ collect_interval: 15, is_active: true }); + setShowModal(true); + }; + + const openEditModal = (record: any) => { + setEditingDevice(record); + form.setFieldsValue({ + ...record, + device_type_id: record.device_type_id, + device_group_id: record.device_group_id, + connection_params: record.connection_params ? JSON.stringify(record.connection_params, null, 2) : '', + }); + setShowModal(true); + }; + + const handleSubmit = async (values: any) => { + try { + // Parse connection_params if provided as string + if (values.connection_params && typeof values.connection_params === 'string') { + try { + values.connection_params = JSON.parse(values.connection_params); + } catch { + message.error('连接参数JSON格式错误'); + return; + } + } + if (editingDevice) { + await updateDevice(editingDevice.id, values); + message.success('设备更新成功'); + } else { + await createDevice(values); + message.success('设备创建成功'); + } + setShowModal(false); + form.resetFields(); + loadDevices(); + loadMeta(); + } catch (e: any) { + message.error(e?.detail || '操作失败'); + } + }; + + const columns = [ + { title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true }, + { title: '设备编号', dataIndex: 'code', width: 130 }, + { title: '设备类型', dataIndex: 'device_type_name', width: 120, render: (v: string) => v ? } color="blue">{v} : '-' }, + { title: '设备分组', dataIndex: 'device_group_name', width: 120 }, + { title: '型号', dataIndex: 'model', width: 120, ellipsis: true }, + { title: '厂商', dataIndex: 'manufacturer', width: 120, ellipsis: true }, + { title: '额定功率(kW)', dataIndex: 'rated_power', width: 110, render: (v: number) => v != null ? v : '-' }, + { title: '位置', dataIndex: 'location', width: 120, ellipsis: true }, + { title: '协议', dataIndex: 'protocol', width: 100 }, + { title: '状态', dataIndex: 'status', width: 80, render: (s: string) => { + const st = statusMap[s] || { color: 'default', text: s || '-' }; + return {st.text}; + }}, + { title: '最近数据时间', dataIndex: 'last_data_time', width: 170 }, + { title: '操作', key: 'action', width: 120, fixed: 'right' as const, render: (_: any, record: any) => ( + + + + )}, + ]; + + return ( +
+ {/* Stats Cards */} + + + + } /> + + + + + } valueStyle={{ color: '#52c41a' }} /> + + + + + } valueStyle={{ color: '#999' }} /> + + + + + } valueStyle={{ color: '#ff4d4f' }} /> + + + + + {/* Device Table */} + } onClick={openAddModal}>添加设备 + }> + {/* Filters */} + + ({ label: g.name, value: g.id }))} + onChange={v => handleFilterChange('device_group', v)} + /> + + + + + + + + + + + + + ({ label: g.name, value: g.id }))} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +