feat: add 3D interactive dashboard and 2D BigScreen pages
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"runtimeArgs": ["run", "dev", "--", "--force"],
|
||||
"port": 3000,
|
||||
"cwd": "frontend"
|
||||
},
|
||||
|
||||
702
frontend/package-lock.json
generated
702
frontend/package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/bigscreen" element={<BigScreen />} />
|
||||
<Route path="/bigscreen-3d" element={<BigScreen3D />} />
|
||||
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="monitoring" element={<Monitoring />} />
|
||||
<Route path="devices" element={<Devices />} />
|
||||
<Route path="analysis" element={<Analysis />} />
|
||||
<Route path="alarms" element={<Alarms />} />
|
||||
<Route path="carbon" element={<Carbon />} />
|
||||
|
||||
@@ -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: <DashboardOutlined />, label: '能源总览' },
|
||||
{ key: '/monitoring', icon: <MonitorOutlined />, label: '实时监控' },
|
||||
{ key: '/devices', icon: <AppstoreOutlined />, label: '设备管理' },
|
||||
{ key: '/analysis', icon: <BarChartOutlined />, label: '能耗分析' },
|
||||
{ key: '/alarms', icon: <AlertOutlined />, label: '告警管理' },
|
||||
{ key: '/carbon', icon: <CloudOutlined />, label: '碳排放管理' },
|
||||
|
||||
91
frontend/src/pages/BigScreen/components/AlarmCard.tsx
Normal file
91
frontend/src/pages/BigScreen/components/AlarmCard.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className={styles.cardTitle}>告警信息</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<div>
|
||||
<span className={styles.statLabel}>活跃告警</span>
|
||||
<div>
|
||||
<AnimatedNumber value={activeCount} className={styles.bigNumberRed} />
|
||||
<span className={styles.unit}> 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.alarmList}>
|
||||
{(alarmEvents ?? []).slice(0, 5).map((alarm: any, idx: number) => (
|
||||
<div key={alarm.id ?? idx} className={styles.alarmItem}>
|
||||
<span className={getSeverityClass(alarm.severity ?? 'info')} />
|
||||
<span className={styles.alarmMsg}>{alarm.message ?? alarm.description ?? '未知告警'}</span>
|
||||
<span className={styles.alarmTime}>{formatTime(alarm.triggered_at ?? alarm.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!alarmEvents || alarmEvents.length === 0) && (
|
||||
<div style={{ color: 'rgba(224,232,240,0.3)', fontSize: 12, padding: '8px 0' }}>暂无告警</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 80 }}>
|
||||
<ReactECharts option={trendOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/pages/BigScreen/components/AnimatedNumber.tsx
Normal file
38
frontend/src/pages/BigScreen/components/AnimatedNumber.tsx
Normal file
@@ -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<number>(0);
|
||||
const startRef = useRef<number>(0);
|
||||
const fromRef = useRef<number>(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 <span className={className}>{display.toFixed(decimals)}</span>;
|
||||
}
|
||||
139
frontend/src/pages/BigScreen/components/CarbonCard.tsx
Normal file
139
frontend/src/pages/BigScreen/components/CarbonCard.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.card} style={{ flex: '0 0 auto' }}>
|
||||
<div className={styles.cardTitle}>碳排放</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
|
||||
<div style={{ width: 90, height: 80, flexShrink: 0 }}>
|
||||
<ReactECharts option={gaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>年碳排放</span>
|
||||
<span className={styles.statValueOrange}>
|
||||
<AnimatedNumber value={annualEmission} decimals={0} />
|
||||
<span className={styles.unit}> kg</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem} style={{ marginTop: 4 }}>
|
||||
<span className={styles.statLabel}>年碳减排</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={annualReduction} decimals={0} />
|
||||
<span className={styles.unit}> kg</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 100 }}>
|
||||
<ReactECharts option={trendOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx
Normal file
190
frontend/src/pages/BigScreen/components/EnergyFlowDiagram.tsx
Normal file
@@ -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<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const rafRef = useRef<number>(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 (
|
||||
<div ref={containerRef} className={styles.energyFlowWrap}>
|
||||
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx
Normal file
101
frontend/src/pages/BigScreen/components/EnergyOverviewCard.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.card} style={{ flex: '0 0 auto' }}>
|
||||
<div className={styles.cardTitle}>综合能源概览</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日用电</span>
|
||||
<span className={styles.statValueCyan}>
|
||||
<AnimatedNumber value={data?.energy_today ?? 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>光伏发电</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={data?.pv_generation_today ?? 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>电网购电</span>
|
||||
<span className={styles.statValueOrange}>
|
||||
<AnimatedNumber value={data?.grid_import_today ?? 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>实时功率</span>
|
||||
<span className={styles.statValue}>
|
||||
<AnimatedNumber value={realtime?.total_power ?? 0} decimals={1} />
|
||||
<span className={styles.unit}> kW</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 100, height: 100 }}>
|
||||
<ReactECharts option={gaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>碳排放</span>
|
||||
<span className={styles.statValue}>
|
||||
<AnimatedNumber value={data?.carbon_emission ?? 0} decimals={0} />
|
||||
<span className={styles.unit}> kgCO2</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem} style={{ marginTop: 6 }}>
|
||||
<span className={styles.statLabel}>碳减排</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={data?.carbon_reduction ?? 0} decimals={0} />
|
||||
<span className={styles.unit}> kgCO2</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
frontend/src/pages/BigScreen/components/HeatPumpCard.tsx
Normal file
96
frontend/src/pages/BigScreen/components/HeatPumpCard.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.card} style={{ flex: '0 0 auto' }}>
|
||||
<div className={styles.cardTitle}>热泵系统</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<div>
|
||||
<div className={styles.statLabel}>实时功率</div>
|
||||
<span>
|
||||
<AnimatedNumber value={hpPower} decimals={1} className={styles.bigNumberCyan} />
|
||||
<span className={styles.unit}> kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ width: 90, height: 80, flexShrink: 0 }}>
|
||||
<ReactECharts option={copGaugeOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(224,232,240,0.5)' }}>
|
||||
平均COP
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日用电</span>
|
||||
<span className={styles.statValueCyan}>
|
||||
<AnimatedNumber value={todayConsumption} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>本月用电</span>
|
||||
<span className={styles.statValueCyan}>
|
||||
<AnimatedNumber value={monthlyConsumption} decimals={0} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日运行</span>
|
||||
<span className={styles.statValue}>
|
||||
<AnimatedNumber value={operatingHours} decimals={1} />
|
||||
<span className={styles.unit}> h</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/pages/BigScreen/components/LoadCurveCard.tsx
Normal file
83
frontend/src/pages/BigScreen/components/LoadCurveCard.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className={styles.cardTitle}>用电分析</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.statRow} style={{ marginBottom: 4 }}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>峰值 <span style={{ color: '#ff4757' }}>{peak.toFixed(1)} kW</span></span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>谷值 <span style={{ color: '#00ff88' }}>{valley.toFixed(1)} kW</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 0 }}>
|
||||
<ReactECharts option={option} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/BigScreen/components/PVCard.tsx
Normal file
110
frontend/src/pages/BigScreen/components/PVCard.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.card} style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className={styles.cardTitle}>光伏发电</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
|
||||
<div>
|
||||
<div className={styles.statLabel}>实时功率</div>
|
||||
<span className={styles.bigNumberGreen || styles.bigNumber}>
|
||||
<AnimatedNumber value={pvPower} decimals={1} className={styles.bigNumber} />
|
||||
<span className={styles.unit}> kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ width: 64, height: 64, flexShrink: 0 }}>
|
||||
<ReactECharts option={donutOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(224,232,240,0.5)' }}>
|
||||
自用率<br/>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: '#00ff88' }}>{selfUseRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statRow}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日发电</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={todayGen} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>本月发电</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={monthlyGen} decimals={0} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.chartWrap} style={{ flex: 1, minHeight: 0 }}>
|
||||
<ReactECharts option={barOption} style={{ width: '100%', height: '100%' }} opts={{ renderer: 'svg' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
frontend/src/pages/BigScreen/index.tsx
Normal file
163
frontend/src/pages/BigScreen/index.tsx
Normal file
@@ -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<any>(null);
|
||||
const [realtime, setRealtime] = useState<any>(null);
|
||||
const [loadData, setLoadData] = useState<any>(null);
|
||||
const [alarmEvents, setAlarmEvents] = useState<any[]>([]);
|
||||
const [alarmStats, setAlarmStats] = useState<any>(null);
|
||||
const [carbonOverview, setCarbonOverview] = useState<any>(null);
|
||||
const [carbonTrend, setCarbonTrend] = useState<any>(null);
|
||||
const [deviceStats, setDeviceStats] = useState<any>(null);
|
||||
const timerRef = useRef<any>(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 (
|
||||
<div className={styles.container}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerDate}>{formatDate(clock)}</span>
|
||||
<h1 className={styles.headerTitle}>天普零碳园区智慧能源管理平台</h1>
|
||||
<span className={styles.headerTime}>{formatTime(clock)}</span>
|
||||
</div>
|
||||
|
||||
{/* Main 3-column grid */}
|
||||
<div className={styles.mainGrid}>
|
||||
{/* Left Column */}
|
||||
<div className={styles.column}>
|
||||
<EnergyOverviewCard data={overview} realtime={realtime} />
|
||||
<PVCard realtime={realtime} overview={overview} />
|
||||
<HeatPumpCard realtime={realtime} overview={overview} />
|
||||
</div>
|
||||
|
||||
{/* Center Column */}
|
||||
<div className={styles.column}>
|
||||
<div className={styles.centerCard} style={{ flex: 1 }}>
|
||||
<div className={styles.cardTitle}>能源流向</div>
|
||||
<EnergyFlowDiagram realtime={realtime} overview={overview} />
|
||||
</div>
|
||||
<div className={styles.card} style={{ flex: '0 0 auto', padding: '10px 16px' }}>
|
||||
<div className={styles.cardTitle}>设备状态</div>
|
||||
<div className={styles.deviceStatusBar}>
|
||||
<div className={styles.deviceStatusItem}>
|
||||
<span className={styles.statusDotCyan} />
|
||||
<span className={styles.statusLabel}>总设备</span>
|
||||
<AnimatedNumber value={totalDevices} className={styles.statusCount} />
|
||||
</div>
|
||||
<div className={styles.deviceStatusItem}>
|
||||
<span className={styles.statusDotGreen} />
|
||||
<span className={styles.statusLabel}>在线</span>
|
||||
<AnimatedNumber value={onlineDevices} className={styles.statusCount} />
|
||||
</div>
|
||||
<div className={styles.deviceStatusItem}>
|
||||
<span className={styles.statusDotRed} />
|
||||
<span className={styles.statusLabel}>离线</span>
|
||||
<AnimatedNumber value={offlineDevices} className={styles.statusCount} />
|
||||
</div>
|
||||
<div className={styles.deviceStatusItem}>
|
||||
<span className={styles.statusDotOrange} />
|
||||
<span className={styles.statusLabel}>告警</span>
|
||||
<AnimatedNumber value={alarmDevices} className={styles.statusCount} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className={styles.column}>
|
||||
<LoadCurveCard loadData={loadData} />
|
||||
<AlarmCard alarmEvents={alarmEvents} alarmStats={alarmStats} />
|
||||
<CarbonCard carbonOverview={carbonOverview} carbonTrend={carbonTrend} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
frontend/src/pages/BigScreen/styles.module.css
Normal file
426
frontend/src/pages/BigScreen/styles.module.css
Normal file
@@ -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);
|
||||
}
|
||||
130
frontend/src/pages/BigScreen3D/components/Buildings.tsx
Normal file
130
frontend/src/pages/BigScreen3D/components/Buildings.tsx
Normal file
@@ -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 (
|
||||
<group>
|
||||
{windows.map((w, i) => (
|
||||
<mesh key={i} position={w.pos}>
|
||||
<boxGeometry args={[1.5, 0.8, 0.05]} />
|
||||
<meshStandardMaterial
|
||||
color="#ffcc66"
|
||||
emissive="#ffcc66"
|
||||
emissiveIntensity={0.3}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<group position={position} onClick={onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined}>
|
||||
{/* Main body */}
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[w, h, d]} />
|
||||
<meshStandardMaterial
|
||||
color={COLORS.buildingBase}
|
||||
transparent
|
||||
opacity={opacity}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Edge highlight */}
|
||||
<lineSegments geometry={edgesGeo}>
|
||||
<lineBasicMaterial color="#00d4ff" transparent opacity={0.3} />
|
||||
</lineSegments>
|
||||
|
||||
{/* Windows on front face */}
|
||||
<WindowGrid width={w} height={h} depth={d} />
|
||||
|
||||
{/* Label */}
|
||||
<Html position={[0, h / 2 + 1.5, 0]} center>
|
||||
<div style={labelStyle}>{label}</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Buildings({ detailMode, onBuildingClick }: BuildingsProps) {
|
||||
const opacity = detailMode ? 0.15 : 0.85;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<Building
|
||||
label={BUILDINGS.east.label}
|
||||
position={[...BUILDINGS.east.position]}
|
||||
size={[...BUILDINGS.east.size]}
|
||||
opacity={opacity}
|
||||
onClick={onBuildingClick ? () => onBuildingClick('east') : undefined}
|
||||
/>
|
||||
<Building
|
||||
label={BUILDINGS.west.label}
|
||||
position={[...BUILDINGS.west.position]}
|
||||
size={[...BUILDINGS.west.size]}
|
||||
opacity={opacity}
|
||||
onClick={onBuildingClick ? () => onBuildingClick('west') : undefined}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
182
frontend/src/pages/BigScreen3D/components/CampusScene.tsx
Normal file
182
frontend/src/pages/BigScreen3D/components/CampusScene.tsx
Normal file
@@ -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<any>;
|
||||
energyFlow: { nodes: any[]; links: any[] };
|
||||
realtimeData: any | null;
|
||||
selectedDevice: any | null;
|
||||
selectedDevicePosition: [number, number, number] | null;
|
||||
detailRealtimeData: Record<string, { value: number; unit: string }> | 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 (
|
||||
<>
|
||||
<CameraController
|
||||
selectedDevicePosition={selectedDevicePosition}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
|
||||
<SceneEnvironment />
|
||||
<Ground />
|
||||
|
||||
<Buildings detailMode={detailMode} />
|
||||
|
||||
<PVPanels
|
||||
devices={pvDevices}
|
||||
hoveredId={hoveredDeviceId}
|
||||
onHover={onDeviceHover}
|
||||
onClick={onDeviceSelect}
|
||||
detailMode={detailMode}
|
||||
/>
|
||||
|
||||
<HeatPumps
|
||||
devices={hpDevices}
|
||||
hoveredId={hoveredDeviceId}
|
||||
onHover={onDeviceHover}
|
||||
onClick={onDeviceSelect}
|
||||
detailMode={detailMode}
|
||||
/>
|
||||
|
||||
<DeviceMarkers
|
||||
devices={markerDevices}
|
||||
hoveredId={hoveredDeviceId}
|
||||
onHover={onDeviceHover}
|
||||
onClick={onDeviceSelect}
|
||||
detailMode={detailMode}
|
||||
/>
|
||||
|
||||
<EnergyParticles
|
||||
energyFlow={energyFlow}
|
||||
realtimeData={realtimeData}
|
||||
/>
|
||||
|
||||
{viewMode === 'device-detail' && selectedDevice && selectedDevicePosition && (
|
||||
<DeviceDetailView
|
||||
device={selectedDevice}
|
||||
position={selectedDevicePosition}
|
||||
realtimeData={detailRealtimeData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OrbitControls
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
maxPolarAngle={Math.PI / 2.2}
|
||||
minDistance={5}
|
||||
maxDistance={80}
|
||||
/>
|
||||
|
||||
<EffectComposer>
|
||||
<Bloom luminanceThreshold={0.5} intensity={0.6} />
|
||||
</EffectComposer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CampusScene(props: CampusSceneProps) {
|
||||
return (
|
||||
<Canvas
|
||||
camera={{
|
||||
position: CAMERA.campusPosition as unknown as [number, number, number],
|
||||
fov: CAMERA.fov,
|
||||
near: CAMERA.near,
|
||||
far: CAMERA.far,
|
||||
}}
|
||||
shadows="soft"
|
||||
style={{ background: COLORS.background }}
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
>
|
||||
<SceneContent {...props} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
490
frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx
Normal file
490
frontend/src/pages/BigScreen3D/components/DeviceDetailView.tsx
Normal file
@@ -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<string, { value: number; unit: string }> | 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<THREE.Group>(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 (
|
||||
<group ref={groupRef} position={[0, 2, 0]}>
|
||||
{/* Scale the whole panel array 1.5x */}
|
||||
<group scale={1.5}>
|
||||
{panels.items.map((p, i) => (
|
||||
<mesh key={i} position={p.pos} rotation={[-tilt, 0, 0]} castShadow>
|
||||
<boxGeometry args={[panels.pw, panels.depth, panels.ph]} />
|
||||
<meshStandardMaterial
|
||||
color="#1e3a5f"
|
||||
metalness={0.85}
|
||||
roughness={0.2}
|
||||
emissive="#004488"
|
||||
emissiveIntensity={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
{/* Mounting rails */}
|
||||
<mesh position={[0, -0.15, -1]} rotation={[-tilt, 0, 0]}>
|
||||
<boxGeometry args={[panels.totalW, 0.05, 0.05]} />
|
||||
<meshStandardMaterial color="#555555" metalness={0.6} roughness={0.4} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.15, 1]} rotation={[-tilt, 0, 0]}>
|
||||
<boxGeometry args={[panels.totalW, 0.05, 0.05]} />
|
||||
<meshStandardMaterial color="#555555" metalness={0.6} roughness={0.4} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* HTML overlay – block diagram + live data */}
|
||||
<Html position={[10, 1, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ marginBottom: 8, whiteSpace: 'pre', lineHeight: 1.4, fontSize: 11 }}>
|
||||
{`┌─────────┐ ┌──────┐ ┌─────────┐
|
||||
│ DC Input │ → │ MPPT │ → │ AC Output│
|
||||
└─────────┘ └──────┘ └─────────┘`}
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid rgba(0,212,255,0.2)', paddingTop: 6 }}>
|
||||
<div>DC电压: {getValue('dc_voltage').toFixed(1)} V</div>
|
||||
<div>AC电压: {getValue('ac_voltage').toFixed(1)} V</div>
|
||||
<div>功率: {getValue('power').toFixed(1)} kW</div>
|
||||
<div>温度: {getValue('temperature').toFixed(1)} ℃</div>
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Heat Pump Detail ───────────────────────────────────────────────
|
||||
function HeatPumpDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const particlesRef = useRef<THREE.Group>(null);
|
||||
const fanRef = useRef<THREE.Group>(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 (
|
||||
<group>
|
||||
{/* Main housing – transparent cutaway */}
|
||||
<mesh>
|
||||
<boxGeometry args={[3, 2.2, 2.2]} />
|
||||
<meshStandardMaterial
|
||||
color="#2a4a6a"
|
||||
transparent
|
||||
opacity={0.3}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Compressor */}
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<cylinderGeometry args={[0.4, 0.4, 0.8, 16]} />
|
||||
<meshStandardMaterial color="#4488cc" metalness={0.5} roughness={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* Evaporator (left) */}
|
||||
<mesh position={[-1, 0, 0]}>
|
||||
<boxGeometry args={[0.1, 1.5, 1.5]} />
|
||||
<meshStandardMaterial color="#00aaff" metalness={0.3} roughness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Condenser (right) */}
|
||||
<mesh position={[1, 0, 0]}>
|
||||
<boxGeometry args={[0.1, 1.5, 1.5]} />
|
||||
<meshStandardMaterial color="#ff6644" metalness={0.3} roughness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Expansion valve */}
|
||||
<mesh position={[0, -0.9, 0]}>
|
||||
<sphereGeometry args={[0.15, 16, 16]} />
|
||||
<meshStandardMaterial color="#ffaa00" metalness={0.4} roughness={0.4} />
|
||||
</mesh>
|
||||
|
||||
{/* Refrigerant particles */}
|
||||
<group ref={particlesRef}>
|
||||
{Array.from({ length: particleCount }).map((_, i) => (
|
||||
<mesh key={i}>
|
||||
<sphereGeometry args={[0.08, 8, 8]} />
|
||||
<meshStandardMaterial
|
||||
color="#00d4ff"
|
||||
emissive="#00d4ff"
|
||||
emissiveIntensity={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* Fan on top */}
|
||||
<group ref={fanRef} position={[0, 1.3, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<mesh key={i} rotation={[0, 0, (i * Math.PI * 2) / 3]}>
|
||||
<boxGeometry args={[0.08, 0.6, 0.02]} />
|
||||
<meshStandardMaterial color="#aaaaaa" metalness={0.5} />
|
||||
</mesh>
|
||||
))}
|
||||
{/* Fan hub */}
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.08, 0.08, 0.05, 12]} />
|
||||
<meshStandardMaterial color="#666666" metalness={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* HTML overlay */}
|
||||
<Html position={[3, 0, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#00d4ff' }}>热泵详情</div>
|
||||
<div>功率: {getValue('power').toFixed(1)} kW</div>
|
||||
<div>COP: {getValue('cop').toFixed(2)}</div>
|
||||
<div>进水温度: {getValue('inlet_temp').toFixed(1)} ℃</div>
|
||||
<div>出水温度: {getValue('outlet_temp').toFixed(1)} ℃</div>
|
||||
<div>流量: {getValue('flow_rate').toFixed(2)} m³/h</div>
|
||||
<div>室外温度: {getValue('outdoor_temp').toFixed(1)} ℃</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Meter Detail ───────────────────────────────────────────────────
|
||||
function MeterDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const needleRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (needleRef.current) {
|
||||
const power = getValue('power');
|
||||
const angle = (power / 150) * Math.PI - Math.PI / 2;
|
||||
needleRef.current.rotation.z = angle;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Body */}
|
||||
<mesh>
|
||||
<boxGeometry args={[1.5, 2, 0.5]} />
|
||||
<meshStandardMaterial color="#ff8c00" metalness={0.4} roughness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Screen */}
|
||||
<mesh position={[0, 0.4, 0.27]}>
|
||||
<boxGeometry args={[1, 0.6, 0.02]} />
|
||||
<meshStandardMaterial
|
||||
color="#1a1a1a"
|
||||
emissive="#003300"
|
||||
emissiveIntensity={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Dial face */}
|
||||
<mesh position={[0, -0.3, 0.27]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.4, 0.4, 0.05, 32]} />
|
||||
<meshStandardMaterial color="#111111" metalness={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* Needle */}
|
||||
<mesh ref={needleRef} position={[0, -0.3, 0.32]}>
|
||||
<boxGeometry args={[0.02, 0.35, 0.02]} />
|
||||
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* HTML overlay */}
|
||||
<Html position={[2, 0, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#ff8c00' }}>电表详情</div>
|
||||
<div>功率: {getValue('power').toFixed(1)} kW</div>
|
||||
<div>电压: {getValue('voltage').toFixed(1)} V</div>
|
||||
<div>电流: {getValue('current').toFixed(2)} A</div>
|
||||
<div>功率因数: {getValue('power_factor').toFixed(3)}</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Heat Meter Detail ──────────────────────────────────────────────
|
||||
function HeatMeterDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const needleRef = useRef<THREE.Mesh>(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 (
|
||||
<group>
|
||||
{/* Body */}
|
||||
<mesh>
|
||||
<boxGeometry args={[1.5, 2, 0.5]} />
|
||||
<meshStandardMaterial color="#cc3366" metalness={0.4} roughness={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* Display screen */}
|
||||
<mesh position={[0, 0.4, 0.27]}>
|
||||
<boxGeometry args={[1, 0.6, 0.02]} />
|
||||
<meshStandardMaterial color="#1a1a1a" emissive="#220011" emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* Dial */}
|
||||
<mesh position={[0, -0.3, 0.27]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.4, 0.4, 0.05, 32]} />
|
||||
<meshStandardMaterial color="#111111" metalness={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* Needle */}
|
||||
<mesh ref={needleRef} position={[0, -0.3, 0.32]}>
|
||||
<boxGeometry args={[0.02, 0.35, 0.02]} />
|
||||
<meshStandardMaterial color="#ff3366" emissive="#ff3366" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
|
||||
{/* HTML overlay */}
|
||||
<Html position={[2, 0, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#cc3366' }}>热量表详情</div>
|
||||
<div>热功率: {getValue('heat_power').toFixed(1)} kW</div>
|
||||
<div>流量: {getValue('flow_rate').toFixed(2)} m³/h</div>
|
||||
<div>供水温度: {getValue('supply_temp').toFixed(1)} ℃</div>
|
||||
<div>回水温度: {getValue('return_temp').toFixed(1)} ℃</div>
|
||||
<div>累计热量: {getValue('cumulative_heat').toFixed(3)} GJ</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sensor Detail ──────────────────────────────────────────────────
|
||||
function SensorDetail({
|
||||
getValue,
|
||||
}: {
|
||||
getValue: (key: string) => number;
|
||||
}) {
|
||||
const sphereRef = useRef<THREE.Mesh>(null);
|
||||
const ringRef = useRef<THREE.Mesh>(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 (
|
||||
<group>
|
||||
{/* Sensor sphere */}
|
||||
<mesh ref={sphereRef}>
|
||||
<sphereGeometry args={[0.5, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#a78bfa"
|
||||
metalness={0.5}
|
||||
roughness={0.3}
|
||||
emissive="#a78bfa"
|
||||
emissiveIntensity={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Antenna */}
|
||||
<mesh position={[0, 0.9, 0]}>
|
||||
<cylinderGeometry args={[0.03, 0.03, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#cccccc" metalness={0.6} />
|
||||
</mesh>
|
||||
|
||||
{/* Ring */}
|
||||
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.7, 0.02, 8, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#00d4ff"
|
||||
emissive="#00d4ff"
|
||||
emissiveIntensity={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* HTML overlay */}
|
||||
<Html position={[2, 0, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 6, color: '#a78bfa' }}>传感器详情</div>
|
||||
<div>温度: {getValue('temperature').toFixed(1)} ℃</div>
|
||||
<div>湿度: {getValue('humidity').toFixed(1)} %</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fallback ───────────────────────────────────────────────────────
|
||||
function DefaultDetail({ name }: { name: string }) {
|
||||
const sphereRef = useRef<THREE.Mesh>(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 (
|
||||
<group>
|
||||
<mesh ref={sphereRef}>
|
||||
<sphereGeometry args={[0.6, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#00d4ff"
|
||||
emissive="#00d4ff"
|
||||
emissiveIntensity={0.3}
|
||||
metalness={0.4}
|
||||
roughness={0.4}
|
||||
/>
|
||||
</mesh>
|
||||
<Html position={[0, 1.2, 0]} distanceFactor={10}>
|
||||
<div style={overlayStyle}>
|
||||
<div style={{ textAlign: 'center', color: '#00d4ff' }}>{name}</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ─────────────────────────────────────────────────
|
||||
export default function DeviceDetailView({
|
||||
device,
|
||||
position,
|
||||
realtimeData,
|
||||
}: DeviceDetailViewProps) {
|
||||
const groupRef = useRef<THREE.Group>(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 <PVDetail getValue={getValue} />;
|
||||
case 'heat_pump':
|
||||
return <HeatPumpDetail getValue={getValue} />;
|
||||
case 'meter':
|
||||
return <MeterDetail getValue={getValue} />;
|
||||
case 'heat_meter':
|
||||
return <HeatMeterDetail getValue={getValue} />;
|
||||
case 'sensor':
|
||||
return <SensorDetail getValue={getValue} />;
|
||||
default:
|
||||
return <DefaultDetail name={device.name} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={position}>
|
||||
{renderDetail()}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
172
frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx
Normal file
172
frontend/src/pages/BigScreen3D/components/DeviceInfoPanel.tsx
Normal file
@@ -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<string, ParamDef[]> = {
|
||||
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<string, string> = {
|
||||
online: '#00ff88',
|
||||
offline: '#666666',
|
||||
alarm: '#ff4757',
|
||||
maintenance: '#ff8c00',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
alarm: '告警',
|
||||
maintenance: '维护',
|
||||
};
|
||||
|
||||
export default function DeviceInfoPanel({ device, onClose, onViewDetail }: DeviceInfoPanelProps) {
|
||||
const [realtimeData, setRealtimeData] = useState<Record<string, { value: number; unit: string; timestamp: string }>>({});
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | 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<string, { value: number; unit: string; timestamp: string }>);
|
||||
} 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 (
|
||||
<div className={styles.deviceInfoPanel}>
|
||||
<div className={styles.infoPanelHeader}>
|
||||
<span className={styles.infoPanelTitle}>{device.name}</span>
|
||||
<button className={styles.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>状态</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
backgroundColor: STATUS_COLORS[device.status] || '#666',
|
||||
}}
|
||||
>
|
||||
{STATUS_LABELS[device.status] || device.status}
|
||||
</span>
|
||||
</div>
|
||||
{device.model && (
|
||||
<div className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>型号</span>
|
||||
<span className={styles.paramValue}>{device.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.manufacturer && (
|
||||
<div className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>厂家</span>
|
||||
<span className={styles.paramValue}>{device.manufacturer}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.rated_power != null && (
|
||||
<div className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>额定功率</span>
|
||||
<span className={styles.paramValue}>{device.rated_power} kW</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{params.map(param => {
|
||||
const data = realtimeData[param.key];
|
||||
const valueStr = data != null ? `${data.value}${param.unit ? ' ' + param.unit : ''}` : '--';
|
||||
return (
|
||||
<div key={param.key} className={styles.paramRow}>
|
||||
<span className={styles.paramLabel}>{param.label}</span>
|
||||
<span className={styles.paramValue}>{valueStr}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button className={styles.detailBtn} onClick={() => onViewDetail(device)}>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
pv_inverter: '光伏逆变器',
|
||||
heat_pump: '空气源热泵',
|
||||
meter: '电表',
|
||||
sensor: '温湿度传感器',
|
||||
heat_meter: '热量表',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: '#00ff88',
|
||||
offline: '#666666',
|
||||
alarm: '#ff4757',
|
||||
maintenance: '#ff8c00',
|
||||
};
|
||||
|
||||
export default function DeviceListPanel({ devices, selectedDeviceId, onDeviceSelect }: DeviceListPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
|
||||
const groups: Record<string, Device[]> = {};
|
||||
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 (
|
||||
<div className={styles.deviceListPanel}>
|
||||
{Object.entries(TYPE_LABELS).map(([type, label]) => {
|
||||
const group = groups[type];
|
||||
if (!group || group.length === 0) return null;
|
||||
const isCollapsed = collapsed[type] ?? false;
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className={styles.deviceGroupTitle} onClick={() => toggleGroup(type)}>
|
||||
{isCollapsed ? '▸' : '▾'} {label}
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
group.map(device => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={`${styles.deviceItem} ${selectedDeviceId === device.id ? styles.deviceItemActive : ''}`}
|
||||
onClick={() => onDeviceSelect(device)}
|
||||
>
|
||||
<span
|
||||
className={styles.statusDot}
|
||||
style={{ backgroundColor: STATUS_COLORS[device.status] || '#666666' }}
|
||||
/>
|
||||
<span className={styles.deviceName}>{device.name}</span>
|
||||
{device.primaryValue && (
|
||||
<span className={styles.deviceValue}>{device.primaryValue}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx
Normal file
200
frontend/src/pages/BigScreen3D/components/DeviceMarkers.tsx
Normal file
@@ -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 (
|
||||
<group
|
||||
position={[position[0], position[1] + 0.6, position[2]]}
|
||||
scale={scale}
|
||||
onPointerOver={(e) => { e.stopPropagation(); onHover(device.id); }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
|
||||
onClick={(e) => { e.stopPropagation(); onClick(device); }}
|
||||
>
|
||||
{/* Body */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.8, 1.2, 0.3]} />
|
||||
<meshStandardMaterial color={accentColor} metalness={0.3} roughness={0.6} />
|
||||
</mesh>
|
||||
{/* Front dial */}
|
||||
<mesh position={[0, 0.1, 0.16]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.25, 0.25, 0.05, 16]} />
|
||||
<meshStandardMaterial color="#1a1a1a" metalness={0.5} />
|
||||
</mesh>
|
||||
{/* Label */}
|
||||
<Html position={[0, 1, 0]} center>
|
||||
<div style={labelStyle}>
|
||||
<div>{device.name}</div>
|
||||
{device.primaryValue && <div style={{ color: accentColor }}>{device.primaryValue}</div>}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<group
|
||||
position={position}
|
||||
scale={scale}
|
||||
onPointerOver={(e) => { e.stopPropagation(); onHover(device.id); }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
|
||||
onClick={(e) => { e.stopPropagation(); onClick(device); }}
|
||||
>
|
||||
{/* Sphere */}
|
||||
<mesh castShadow>
|
||||
<sphereGeometry args={[0.25, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={COLORS.sensorPurple}
|
||||
metalness={0.5}
|
||||
roughness={0.4}
|
||||
emissive={COLORS.sensorPurple}
|
||||
emissiveIntensity={isHovered ? 0.3 : 0.1}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Antenna */}
|
||||
<mesh position={[0, 0.45, 0]}>
|
||||
<cylinderGeometry args={[0.02, 0.02, 0.4, 6]} />
|
||||
<meshStandardMaterial color="#b0b0b0" metalness={0.8} />
|
||||
</mesh>
|
||||
{/* Label */}
|
||||
<Html position={[0, 0.9, 0]} center>
|
||||
<div style={labelStyle}>
|
||||
<div>{device.name}</div>
|
||||
{device.primaryValue && <div style={{ color: COLORS.sensorPurple }}>{device.primaryValue}</div>}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<group>
|
||||
{/* Regular meters */}
|
||||
{categorized.meters.map((d) => {
|
||||
const posInfo = DEVICE_POSITIONS[d.code];
|
||||
if (!posInfo) return null;
|
||||
return (
|
||||
<MeterMarker
|
||||
key={d.id}
|
||||
device={d}
|
||||
position={posInfo.position}
|
||||
isHovered={hoveredId === d.id}
|
||||
accentColor={COLORS.gridOrange}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Heat meters */}
|
||||
{categorized.heatMeters.map((d) => {
|
||||
const posInfo = DEVICE_POSITIONS[d.code];
|
||||
if (!posInfo) return null;
|
||||
return (
|
||||
<MeterMarker
|
||||
key={d.id}
|
||||
device={d}
|
||||
position={posInfo.position}
|
||||
isHovered={hoveredId === d.id}
|
||||
accentColor={COLORS.alarmRed}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sensors */}
|
||||
{categorized.sensors.map((d) => {
|
||||
const posInfo = DEVICE_POSITIONS[d.code];
|
||||
if (!posInfo) return null;
|
||||
return (
|
||||
<SensorMarker
|
||||
key={d.id}
|
||||
device={d}
|
||||
position={posInfo.position}
|
||||
isHovered={hoveredId === d.id}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
164
frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx
Normal file
164
frontend/src/pages/BigScreen3D/components/EnergyParticles.tsx
Normal file
@@ -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<ParticlePathConfig[]>(() => {
|
||||
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 (
|
||||
<group>
|
||||
{paths.map((path) => (
|
||||
<ParticlePath key={path.key} config={path} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
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<Float32Array>(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<Float32Array>(new Float32Array(count * 3));
|
||||
if (positionsRef.current.length !== count * 3) {
|
||||
positionsRef.current = new Float32Array(count * 3);
|
||||
}
|
||||
|
||||
const geomRef = useRef<THREE.BufferGeometry>(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 (
|
||||
<points material={material}>
|
||||
<bufferGeometry ref={geomRef}>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positionsRef.current, 3]}
|
||||
count={count}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
22
frontend/src/pages/BigScreen3D/components/Ground.tsx
Normal file
22
frontend/src/pages/BigScreen3D/components/Ground.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Grid } from '@react-three/drei';
|
||||
|
||||
export default function Ground() {
|
||||
return (
|
||||
<group>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow position={[0, -0.01, 0]}>
|
||||
<planeGeometry args={[100, 100]} />
|
||||
<meshStandardMaterial color="#0d2137" />
|
||||
</mesh>
|
||||
<Grid
|
||||
args={[100, 100]}
|
||||
cellSize={2}
|
||||
cellColor="#1a3a5c"
|
||||
sectionSize={10}
|
||||
sectionColor="#2a5a8c"
|
||||
fadeDistance={80}
|
||||
position={[0, 0, 0]}
|
||||
infiniteGrid={false}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx
Normal file
93
frontend/src/pages/BigScreen3D/components/HUDOverlay.tsx
Normal file
@@ -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 (
|
||||
<div className={styles.hudOverlay}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerDate}>{formatDate(now)}</span>
|
||||
<span className={styles.headerTitle}>天普零碳园区 3D智慧能源管理平台</span>
|
||||
<span className={styles.headerClock}>{formatTime(now)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.metricsBar}>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>光伏发电</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={realtimeData?.pv_power ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>电网功率</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={realtimeData?.grid_power ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>总负荷</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={realtimeData?.total_load ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kW</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>今日发电</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={overview?.today_generation ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>碳减排</span>
|
||||
<span className={styles.metricValue}>
|
||||
<AnimatedNumber value={overview?.carbon_reduction ?? 0} decimals={1} />
|
||||
<span className={styles.metricUnit}>kg</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
frontend/src/pages/BigScreen3D/components/HeatPumps.tsx
Normal file
147
frontend/src/pages/BigScreen3D/components/HeatPumps.tsx
Normal file
@@ -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<THREE.Group>(null);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!bladesRef.current) return;
|
||||
if (status === 'offline') return;
|
||||
bladesRef.current.rotation.y += speed * delta;
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={[0, 0.8, 0]}>
|
||||
{/* Fan housing */}
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.6, 0.6, 0.1, 24]} />
|
||||
<meshStandardMaterial color="#3a5a7a" metalness={0.7} roughness={0.3} />
|
||||
</mesh>
|
||||
{/* Blades */}
|
||||
<group ref={bladesRef} position={[0, 0.06, 0]}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<mesh key={i} rotation={[0, (i * Math.PI * 2) / 3, 0]}>
|
||||
<boxGeometry args={[0.5, 0.02, 0.1]} />
|
||||
<meshStandardMaterial color="#b0c4de" metalness={0.8} roughness={0.2} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
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<THREE.Mesh>(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 (
|
||||
<group
|
||||
position={position}
|
||||
scale={scale}
|
||||
onPointerOver={(e) => { e.stopPropagation(); if (device) onHover(device.id); }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); onHover(null); }}
|
||||
onClick={(e) => { e.stopPropagation(); if (device) onClick(device); }}
|
||||
>
|
||||
{/* Main body */}
|
||||
<mesh ref={meshRef} castShadow>
|
||||
<boxGeometry args={[2, 1.5, 1.5]} />
|
||||
<meshStandardMaterial
|
||||
color={bodyColor}
|
||||
metalness={0.6}
|
||||
roughness={0.4}
|
||||
emissive={emissiveColor}
|
||||
emissiveIntensity={emissiveIntensity}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Fan on top */}
|
||||
<FanBlades speed={fanSpeed} status={status} />
|
||||
|
||||
{/* Side pipes - left */}
|
||||
<mesh position={[-1.2, 0, 0.3]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#556677" metalness={0.8} />
|
||||
</mesh>
|
||||
<mesh position={[-1.2, 0, -0.3]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#556677" metalness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* Side pipes - right */}
|
||||
<mesh position={[1.2, 0, 0.3]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#556677" metalness={0.8} />
|
||||
</mesh>
|
||||
<mesh position={[1.2, 0, -0.3]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.05, 0.05, 0.8, 8]} />
|
||||
<meshStandardMaterial color="#556677" metalness={0.8} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HeatPumps({ devices, hoveredId, onHover, onClick }: HeatPumpsProps) {
|
||||
const deviceMap = useMemo(() => {
|
||||
const map = new Map<string, (typeof devices)[number]>();
|
||||
devices.forEach((d) => map.set(d.code, d));
|
||||
return map;
|
||||
}, [devices]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{HP_CODES.map((code) => {
|
||||
const pos = DEVICE_POSITIONS[code].position;
|
||||
const device = deviceMap.get(code);
|
||||
return (
|
||||
<HeatPumpUnit
|
||||
key={code}
|
||||
position={[pos[0], pos[1] + 0.75, pos[2]]}
|
||||
device={device}
|
||||
isHovered={device ? hoveredId === device.id : false}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
107
frontend/src/pages/BigScreen3D/components/PVPanels.tsx
Normal file
107
frontend/src/pages/BigScreen3D/components/PVPanels.tsx
Normal file
@@ -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<THREE.Group>(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 (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={[center[0], center[1], center[2]]}
|
||||
onPointerOver={(e) => { 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) => (
|
||||
<mesh
|
||||
key={i}
|
||||
position={p.pos}
|
||||
rotation={[-PV_ARRAY.tiltAngle, 0, 0]}
|
||||
castShadow
|
||||
>
|
||||
<boxGeometry args={[PV_ARRAY.panelWidth, PV_ARRAY.panelDepth, PV_ARRAY.panelHeight]} />
|
||||
<meshStandardMaterial
|
||||
color={isHovered ? '#2a347e' : '#1a237e'}
|
||||
metalness={0.8}
|
||||
roughness={0.3}
|
||||
emissive={COLORS.pvGreen}
|
||||
emissiveIntensity={isHovered ? 0.5 : emissiveIntensity}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PVPanels({ devices, hoveredId, onHover, onClick }: PVPanelsProps) {
|
||||
const deviceMap = useMemo(() => {
|
||||
const map = new Map<string, (typeof devices)[number]>();
|
||||
devices.forEach((d) => map.set(d.code, d));
|
||||
return map;
|
||||
}, [devices]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{PV_ZONES.map((zone) => {
|
||||
const device = deviceMap.get(zone.code);
|
||||
return (
|
||||
<PVZone
|
||||
key={zone.code}
|
||||
center={zone.center}
|
||||
device={device}
|
||||
isHovered={device ? hoveredId === device.id : false}
|
||||
onHover={onHover}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Stars } from '@react-three/drei';
|
||||
|
||||
export default function SceneEnvironment() {
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.15} color="#4488cc" />
|
||||
<directionalLight
|
||||
position={[10, 20, 10]}
|
||||
intensity={0.4}
|
||||
color="#88bbff"
|
||||
castShadow
|
||||
shadow-mapSize-width={2048}
|
||||
shadow-mapSize-height={2048}
|
||||
shadow-camera-far={80}
|
||||
shadow-camera-left={-40}
|
||||
shadow-camera-right={40}
|
||||
shadow-camera-top={40}
|
||||
shadow-camera-bottom={-40}
|
||||
/>
|
||||
<Stars count={2000} factor={4} saturation={0} fade speed={1} />
|
||||
<fog attach="fog" args={['#0a1628', 50, 150]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
frontend/src/pages/BigScreen3D/constants.ts
Normal file
120
frontend/src/pages/BigScreen3D/constants.ts
Normal file
@@ -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<string, {
|
||||
position: [number, number, number];
|
||||
rotation?: [number, number, number];
|
||||
type: string;
|
||||
}> = {
|
||||
// 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<string, string> = {
|
||||
online: '#00ff88',
|
||||
offline: '#666666',
|
||||
alarm: '#ff4757',
|
||||
maintenance: '#ff8c00',
|
||||
};
|
||||
69
frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts
Normal file
69
frontend/src/pages/BigScreen3D/hooks/useCameraAnimation.ts
Normal file
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
129
frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts
Normal file
129
frontend/src/pages/BigScreen3D/hooks/useDeviceData.ts
Normal file
@@ -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<string, string[]> = {
|
||||
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<string>();
|
||||
const result: DeviceWithPosition[] = [];
|
||||
|
||||
// Group devices by type
|
||||
const byType: Record<string, DeviceInfo[]> = {};
|
||||
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<DeviceInfo[]>([]);
|
||||
const [deviceStats, setDeviceStats] = useState<DeviceStats | null>(null);
|
||||
const [overview, setOverview] = useState<OverviewData | null>(null);
|
||||
const [realtimeData, setRealtimeData] = useState<RealtimePowerData | null>(null);
|
||||
const [devicesWithPositions, setDevicesWithPositions] = useState<DeviceWithPosition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
getDevices({ page_size: 100 }) as Promise<any>,
|
||||
getDeviceStats() as Promise<any>,
|
||||
getDashboardOverview() as Promise<any>,
|
||||
getRealtimeData() as Promise<any>,
|
||||
]);
|
||||
|
||||
// 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 };
|
||||
}
|
||||
33
frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts
Normal file
33
frontend/src/pages/BigScreen3D/hooks/useEnergyFlow.ts
Normal file
@@ -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<EnergyFlowNode[]>([]);
|
||||
const [links, setLinks] = useState<EnergyFlowLink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 };
|
||||
}
|
||||
132
frontend/src/pages/BigScreen3D/index.tsx
Normal file
132
frontend/src/pages/BigScreen3D/index.tsx
Normal file
@@ -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<DeviceInfo | null>(null);
|
||||
const [hoveredDeviceId, setHoveredDeviceId] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('campus');
|
||||
const [detailRealtimeData, setDetailRealtimeData] = useState<Record<string, { value: number; unit: string }> | null>(null);
|
||||
const detailTimerRef = useRef<ReturnType<typeof setInterval> | 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<string, { value: number; unit: string }>);
|
||||
} 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 (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.placeholder}>
|
||||
<h2 className={styles.placeholderTitle}>天普零碳园区 3D智慧能源管理平台</h2>
|
||||
<p style={{ color: '#8899aa' }}>正在加载设备数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 3D Canvas — fills entire screen */}
|
||||
<div className={styles.canvasWrapper}>
|
||||
<CampusScene
|
||||
devices={devicesWithPositions}
|
||||
energyFlow={{ nodes, links }}
|
||||
realtimeData={realtimeData}
|
||||
selectedDevice={selectedDevice}
|
||||
selectedDevicePosition={selectedDevicePosition}
|
||||
detailRealtimeData={detailRealtimeData}
|
||||
hoveredDeviceId={hoveredDeviceId}
|
||||
viewMode={viewMode}
|
||||
onDeviceSelect={handleDeviceSelect}
|
||||
onDeviceHover={setHoveredDeviceId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HUD: header bar + bottom metrics — pointer-events: none */}
|
||||
<HUDOverlay
|
||||
overview={overview}
|
||||
realtimeData={realtimeData}
|
||||
deviceStats={deviceStats}
|
||||
/>
|
||||
|
||||
{/* Left device list panel (only in campus view) */}
|
||||
{viewMode === 'campus' && (
|
||||
<DeviceListPanel
|
||||
devices={devicesWithPositions}
|
||||
selectedDeviceId={selectedDevice?.id ?? null}
|
||||
onDeviceSelect={handleDeviceSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right device info panel (when device selected in campus view) */}
|
||||
{selectedDevice && viewMode === 'campus' && (
|
||||
<DeviceInfoPanel
|
||||
device={selectedDevice}
|
||||
onClose={handleDeviceClose}
|
||||
onViewDetail={handleEnterDetail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Return button in detail view */}
|
||||
{viewMode === 'device-detail' && (
|
||||
<button className={styles.returnBtn} onClick={handleExitDetail}>
|
||||
← 返回全景
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
329
frontend/src/pages/BigScreen3D/styles.module.css
Normal file
329
frontend/src/pages/BigScreen3D/styles.module.css
Normal file
@@ -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;
|
||||
}
|
||||
73
frontend/src/pages/BigScreen3D/types.ts
Normal file
73
frontend/src/pages/BigScreen3D/types.ts
Normal file
@@ -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<string, DeviceRealtimeEntry>;
|
||||
|
||||
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;
|
||||
}
|
||||
304
frontend/src/pages/Devices/index.tsx
Normal file
304
frontend/src/pages/Devices/index.tsx
Normal file
@@ -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<string, { color: string; text: string }> = {
|
||||
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<any>({ total: 0, items: [] });
|
||||
const [stats, setStats] = useState<any>({ online: 0, offline: 0, alarm: 0, total: 0 });
|
||||
const [deviceTypes, setDeviceTypes] = useState<any[]>([]);
|
||||
const [deviceGroups, setDeviceGroups] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDevice, setEditingDevice] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadDevices = useCallback(async (params?: Record<string, any>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = params || filters;
|
||||
// Remove empty values
|
||||
const cleanQuery: Record<string, any> = {};
|
||||
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 ? <Tag icon={<AppstoreOutlined />} color="blue">{v}</Tag> : '-' },
|
||||
{ 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 <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '最近数据时间', dataIndex: 'last_data_time', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>编辑</Button>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="设备总数" value={stats.total} prefix={<AppstoreOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="在线" value={stats.online} prefix={<CheckCircleOutlined />} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="离线" value={stats.offline} prefix={<CloseCircleOutlined />} valueStyle={{ color: '#999' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="告警" value={stats.alarm} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Device Table */}
|
||||
<Card size="small" title="设备列表" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>添加设备</Button>
|
||||
}>
|
||||
{/* Filters */}
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear placeholder="设备类型" style={{ width: 150 }}
|
||||
options={deviceTypes.map((t: any) => ({ label: t.name, value: t.id }))}
|
||||
onChange={v => handleFilterChange('device_type', v)}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="设备分组" style={{ width: 150 }}
|
||||
options={deviceGroups.map((g: any) => ({ label: g.name, value: g.id }))}
|
||||
onChange={v => handleFilterChange('device_group', v)}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="状态" style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '在线', value: 'online' },
|
||||
{ label: '离线', value: 'offline' },
|
||||
{ label: '告警', value: 'alarm' },
|
||||
{ label: '维护', value: 'maintenance' },
|
||||
]}
|
||||
onChange={v => handleFilterChange('status', v)}
|
||||
/>
|
||||
<Input.Search
|
||||
placeholder="搜索设备名称/编号" style={{ width: 220 }}
|
||||
allowClear
|
||||
onSearch={v => handleFilterChange('search', v)}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns} dataSource={data.items} rowKey="id"
|
||||
loading={loading} size="small" scroll={{ x: 1500 }}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 台设备`,
|
||||
onChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
title={editingDevice ? '编辑设备' : '添加设备'}
|
||||
open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editingDevice ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
|
||||
<Input placeholder="请输入设备名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="code" label="设备编号" rules={[{ required: true, message: '请输入设备编号' }]}>
|
||||
<Input placeholder="请输入唯一设备编号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="device_type_id" label="设备类型">
|
||||
<Select allowClear placeholder="选择设备类型"
|
||||
options={deviceTypes.map((t: any) => ({ label: t.name, value: t.id }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="device_group_id" label="设备分组">
|
||||
<Select allowClear placeholder="选择设备分组"
|
||||
options={deviceGroups.map((g: any) => ({ label: g.name, value: g.id }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="model" label="型号">
|
||||
<Input placeholder="设备型号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="manufacturer" label="厂商">
|
||||
<Input placeholder="制造商" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="serial_number" label="序列号">
|
||||
<Input placeholder="设备序列号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="rated_power" label="额定功率(kW)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.1} placeholder="额定功率" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="location" label="位置">
|
||||
<Input placeholder="安装位置" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="protocol" label="通信协议">
|
||||
<Select allowClear placeholder="选择协议" options={protocolOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="collect_interval" label="采集间隔(秒)" initialValue={15}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={3600} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="connection_params" label="连接参数(JSON)">
|
||||
<Input.TextArea rows={3} placeholder='{"host": "192.168.1.100", "port": 502}' />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,23 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8001',
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Force consistent pre-bundling of deps that have CJS/ESM interop issues
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'@react-three/fiber',
|
||||
'@react-three/drei',
|
||||
'@react-three/postprocessing',
|
||||
'three',
|
||||
],
|
||||
// Ensure all deps are processed in a single pass
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
|
||||
213
scripts/backfill_data.py
Normal file
213
scripts/backfill_data.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""回填历史模拟能耗数据 - 过去30天逐小时数据"""
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, "../backend")
|
||||
|
||||
# Allow override via env var (same pattern as other scripts)
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://tianpu:tianpu2026@localhost:5432/tianpu_ems",
|
||||
)
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
PV_IDS = [1, 2, 3] # 110 kW rated each
|
||||
HP_IDS = [4, 5, 6, 7] # 35 kW rated each
|
||||
METER_IDS = [8, 9, 10, 11]
|
||||
|
||||
DAYS = 30
|
||||
HOURS_PER_DAY = 24
|
||||
TOTAL_HOURS = DAYS * HOURS_PER_DAY # 720
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curve generators (return kW for a given hour-of-day 0-23)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pv_power(hour: int, rated: float = 110.0) -> float:
|
||||
"""Solar bell curve: 0 at night, peak ~80-100 kW around noon."""
|
||||
if hour < 6 or hour >= 18:
|
||||
return 0.0
|
||||
# sine curve from 6-18 with peak at 12
|
||||
x = (hour - 6) / 12.0 * math.pi
|
||||
base = math.sin(x) * rated * random.uniform(0.72, 0.92)
|
||||
noise = base * random.uniform(-0.15, 0.15)
|
||||
return max(0.0, base + noise)
|
||||
|
||||
|
||||
def heat_pump_power(hour: int, rated: float = 35.0) -> float:
|
||||
"""Heat pump load: base 15-25 kW, peaks morning 7-9 and evening 17-20.
|
||||
Spring season so moderate."""
|
||||
base = random.uniform(15, 25)
|
||||
if 7 <= hour <= 9:
|
||||
base += random.uniform(5, 10)
|
||||
elif 17 <= hour <= 20:
|
||||
base += random.uniform(5, 10)
|
||||
elif 0 <= hour <= 5:
|
||||
base -= random.uniform(3, 8)
|
||||
base = min(base, rated)
|
||||
noise = base * random.uniform(-0.20, 0.20)
|
||||
return max(0.0, base + noise)
|
||||
|
||||
|
||||
def meter_power(hour: int) -> float:
|
||||
"""Building load: ~60 kW at night, ~100 kW during business hours."""
|
||||
if 8 <= hour <= 18:
|
||||
base = random.uniform(85, 115)
|
||||
elif 6 <= hour <= 7 or 19 <= hour <= 21:
|
||||
base = random.uniform(65, 85)
|
||||
else:
|
||||
base = random.uniform(45, 70)
|
||||
noise = base * random.uniform(-0.15, 0.15)
|
||||
return max(0.0, base + noise)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main backfill
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def backfill():
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=5)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
||||
start = now - timedelta(days=DAYS)
|
||||
|
||||
print(f"Backfill range: {start.isoformat()} -> {now.isoformat()}")
|
||||
print(f"Total hours: {TOTAL_HOURS}")
|
||||
|
||||
# ---- Collect hourly energy_data rows and per-device daily buckets ----
|
||||
energy_rows = [] # list of dicts for bulk insert
|
||||
# daily_buckets[device_id][date_str] = list of hourly values
|
||||
daily_buckets: dict[int, dict[str, list[float]]] = {}
|
||||
|
||||
all_device_ids = PV_IDS + HP_IDS + METER_IDS
|
||||
for did in all_device_ids:
|
||||
daily_buckets[did] = {}
|
||||
|
||||
print("Generating hourly energy_data rows ...")
|
||||
for h_offset in range(TOTAL_HOURS):
|
||||
ts = start + timedelta(hours=h_offset)
|
||||
hour = ts.hour
|
||||
date_str = ts.strftime("%Y-%m-%d")
|
||||
|
||||
for did in PV_IDS:
|
||||
val = round(pv_power(hour), 2)
|
||||
energy_rows.append({
|
||||
"device_id": did,
|
||||
"timestamp": ts,
|
||||
"data_type": "power",
|
||||
"value": val,
|
||||
"unit": "kW",
|
||||
"quality": 100,
|
||||
})
|
||||
daily_buckets[did].setdefault(date_str, []).append(val)
|
||||
|
||||
for did in HP_IDS:
|
||||
val = round(heat_pump_power(hour), 2)
|
||||
energy_rows.append({
|
||||
"device_id": did,
|
||||
"timestamp": ts,
|
||||
"data_type": "power",
|
||||
"value": val,
|
||||
"unit": "kW",
|
||||
"quality": 100,
|
||||
})
|
||||
daily_buckets[did].setdefault(date_str, []).append(val)
|
||||
|
||||
for did in METER_IDS:
|
||||
val = round(meter_power(hour), 2)
|
||||
energy_rows.append({
|
||||
"device_id": did,
|
||||
"timestamp": ts,
|
||||
"data_type": "power",
|
||||
"value": val,
|
||||
"unit": "kW",
|
||||
"quality": 100,
|
||||
})
|
||||
daily_buckets[did].setdefault(date_str, []).append(val)
|
||||
|
||||
print(f" Generated {len(energy_rows)} energy_data rows")
|
||||
|
||||
# ---- Build daily summary rows ----
|
||||
print("Computing daily summaries ...")
|
||||
summary_rows = []
|
||||
for did, dates in daily_buckets.items():
|
||||
is_pv = did in PV_IDS
|
||||
for date_str, values in dates.items():
|
||||
total = round(sum(values), 2) # kWh (hourly power * 1h)
|
||||
peak = round(max(values), 2)
|
||||
min_p = round(min(values), 2)
|
||||
avg_p = round(sum(values) / len(values), 2)
|
||||
op_hours = sum(1 for v in values if v > 0)
|
||||
cost = round(total * 0.85, 2)
|
||||
carbon = round(total * 0.8843, 2)
|
||||
|
||||
summary_rows.append({
|
||||
"device_id": did,
|
||||
"date": datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc),
|
||||
"energy_type": "electricity",
|
||||
"total_consumption": 0.0 if is_pv else total,
|
||||
"total_generation": total if is_pv else 0.0,
|
||||
"peak_power": peak,
|
||||
"min_power": min_p,
|
||||
"avg_power": avg_p,
|
||||
"operating_hours": float(op_hours),
|
||||
"cost": cost,
|
||||
"carbon_emission": carbon,
|
||||
})
|
||||
|
||||
print(f" Generated {len(summary_rows)} daily summary rows")
|
||||
|
||||
# ---- Bulk insert ----
|
||||
BATCH = 2000
|
||||
|
||||
async with session_factory() as session:
|
||||
# Insert energy_data
|
||||
print("Inserting energy_data ...")
|
||||
insert_energy = text("""
|
||||
INSERT INTO energy_data (device_id, timestamp, data_type, value, unit, quality)
|
||||
VALUES (:device_id, :timestamp, :data_type, :value, :unit, :quality)
|
||||
""")
|
||||
for i in range(0, len(energy_rows), BATCH):
|
||||
batch = energy_rows[i : i + BATCH]
|
||||
await session.execute(insert_energy, batch)
|
||||
done = min(i + BATCH, len(energy_rows))
|
||||
print(f" energy_data: {done}/{len(energy_rows)}")
|
||||
await session.commit()
|
||||
print(" energy_data done.")
|
||||
|
||||
# Insert daily summaries
|
||||
print("Inserting energy_daily_summary ...")
|
||||
insert_summary = text("""
|
||||
INSERT INTO energy_daily_summary
|
||||
(device_id, date, energy_type, total_consumption, total_generation,
|
||||
peak_power, min_power, avg_power, operating_hours, cost, carbon_emission)
|
||||
VALUES
|
||||
(:device_id, :date, :energy_type, :total_consumption, :total_generation,
|
||||
:peak_power, :min_power, :avg_power, :operating_hours, :cost, :carbon_emission)
|
||||
""")
|
||||
for i in range(0, len(summary_rows), BATCH):
|
||||
batch = summary_rows[i : i + BATCH]
|
||||
await session.execute(insert_summary, batch)
|
||||
done = min(i + BATCH, len(summary_rows))
|
||||
print(f" daily_summary: {done}/{len(summary_rows)}")
|
||||
await session.commit()
|
||||
print(" daily_summary done.")
|
||||
|
||||
await engine.dispose()
|
||||
print("Backfill complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(backfill())
|
||||
Reference in New Issue
Block a user