完成管网在线模拟页面组件基本样式和布局
This commit is contained in:
366
package-lock.json
generated
366
package-lock.json
generated
@@ -13,7 +13,9 @@
|
|||||||
"@mui/icons-material": "^6.1.6",
|
"@mui/icons-material": "^6.1.6",
|
||||||
"@mui/lab": "^6.0.0-beta.14",
|
"@mui/lab": "^6.0.0-beta.14",
|
||||||
"@mui/material": "^6.1.7",
|
"@mui/material": "^6.1.7",
|
||||||
|
"@mui/x-charts": "^7.29.1",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
|
"@mui/x-date-pickers": "^8.12.0",
|
||||||
"@refinedev/cli": "^2.16.49",
|
"@refinedev/cli": "^2.16.49",
|
||||||
"@refinedev/core": "^5.0.2",
|
"@refinedev/core": "^5.0.2",
|
||||||
"@refinedev/devtools": "^2.0.2",
|
"@refinedev/devtools": "^2.0.2",
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@turf/turf": "^7.2.0",
|
"@turf/turf": "^7.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^15.2.4",
|
"next": "^15.2.4",
|
||||||
@@ -4075,6 +4078,91 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mui/x-charts": {
|
||||||
|
"version": "7.29.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.29.1.tgz",
|
||||||
|
"integrity": "sha512-5s9PX51HWhpMa+DCDa4RgjtODSaMe+PlTZUqoGIil2vaW/+4ouDLREXvyuVvIF93KfZwrPKAL2SJKSQS4YYB2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.25.7",
|
||||||
|
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@mui/x-charts-vendor": "7.20.0",
|
||||||
|
"@mui/x-internals": "7.29.0",
|
||||||
|
"@react-spring/rafz": "^9.7.5",
|
||||||
|
"@react-spring/web": "^9.7.5",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/react": "^11.9.0",
|
||||||
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@emotion/styled": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-charts-vendor": {
|
||||||
|
"version": "7.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-7.20.0.tgz",
|
||||||
|
"integrity": "sha512-pzlh7z/7KKs5o0Kk0oPcB+sY0+Dg7Q7RzqQowDQjpy5Slz6qqGsgOB5YUzn0L+2yRmvASc4Pe0914Ao3tMBogg==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.25.7",
|
||||||
|
"@types/d3-color": "^3.1.3",
|
||||||
|
"@types/d3-delaunay": "^6.0.4",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-scale": "^4.0.8",
|
||||||
|
"@types/d3-shape": "^3.1.6",
|
||||||
|
"@types/d3-time": "^3.0.3",
|
||||||
|
"d3-color": "^3.1.0",
|
||||||
|
"d3-delaunay": "^6.0.4",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.2.0",
|
||||||
|
"d3-time": "^3.1.0",
|
||||||
|
"delaunator": "^5.0.1",
|
||||||
|
"robust-predicates": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-charts-vendor/node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-charts-vendor/node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-charts-vendor/node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-charts-vendor/node_modules/robust-predicates": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/@mui/x-data-grid": {
|
"node_modules/@mui/x-data-grid": {
|
||||||
"version": "7.29.9",
|
"version": "7.29.9",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.29.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.29.9.tgz",
|
||||||
@@ -4113,6 +4201,124 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mui/x-date-pickers": {
|
||||||
|
"version": "8.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.12.0.tgz",
|
||||||
|
"integrity": "sha512-CDcjdBNwMcTy3flZTCKZqSUS6deBFGKLqy3Vl6bgr5KTo8Vky2v+S+zNi56fv23Qs+P47GwpILcm3QZt/0BP0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.2",
|
||||||
|
"@mui/utils": "^7.3.2",
|
||||||
|
"@mui/x-internals": "8.12.0",
|
||||||
|
"@types/react-transition-group": "^4.4.12",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-transition-group": "^4.4.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/react": "^11.9.0",
|
||||||
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
|
||||||
|
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
|
||||||
|
"dayjs": "^1.10.7",
|
||||||
|
"luxon": "^3.0.2",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"moment-hijri": "^2.1.2 || ^3.0.0",
|
||||||
|
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@emotion/styled": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"date-fns": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"date-fns-jalali": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"dayjs": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"luxon": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment-hijri": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment-jalaali": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-date-pickers/node_modules/@mui/utils": {
|
||||||
|
"version": "7.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.2.tgz",
|
||||||
|
"integrity": "sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.3",
|
||||||
|
"@mui/types": "^7.4.6",
|
||||||
|
"@types/prop-types": "^15.7.15",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-is": "^19.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-date-pickers/node_modules/@mui/x-internals": {
|
||||||
|
"version": "8.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.12.0.tgz",
|
||||||
|
"integrity": "sha512-KCZgFHwuPg0v8I2gpjeC6k3eDRXPPX8RIGSNDXe8zSZ8dAw+p6Q2pzT9kKvctqCXSFK8ct/5YQwqx8Quhs8Ndg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.2",
|
||||||
|
"@mui/utils": "^7.3.2",
|
||||||
|
"reselect": "^5.1.1",
|
||||||
|
"use-sync-external-store": "^1.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mui/x-internals": {
|
"node_modules/@mui/x-internals": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
|
||||||
@@ -4553,6 +4759,78 @@
|
|||||||
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==",
|
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-spring/animated": {
|
||||||
|
"version": "9.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
|
||||||
|
"integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-spring/shared": "~9.7.5",
|
||||||
|
"@react-spring/types": "~9.7.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-spring/core": {
|
||||||
|
"version": "9.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz",
|
||||||
|
"integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-spring/animated": "~9.7.5",
|
||||||
|
"@react-spring/shared": "~9.7.5",
|
||||||
|
"@react-spring/types": "~9.7.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-spring/donate"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-spring/rafz": {
|
||||||
|
"version": "9.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz",
|
||||||
|
"integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-spring/shared": {
|
||||||
|
"version": "9.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz",
|
||||||
|
"integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-spring/rafz": "~9.7.5",
|
||||||
|
"@react-spring/types": "~9.7.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-spring/types": {
|
||||||
|
"version": "9.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz",
|
||||||
|
"integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-spring/web": {
|
||||||
|
"version": "9.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz",
|
||||||
|
"integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-spring/animated": "~9.7.5",
|
||||||
|
"@react-spring/core": "~9.7.5",
|
||||||
|
"@react-spring/shared": "~9.7.5",
|
||||||
|
"@react-spring/types": "~9.7.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@refinedev/cli": {
|
"node_modules/@refinedev/cli": {
|
||||||
"version": "2.16.49",
|
"version": "2.16.49",
|
||||||
"resolved": "https://registry.npmjs.org/@refinedev/cli/-/cli-2.16.49.tgz",
|
"resolved": "https://registry.npmjs.org/@refinedev/cli/-/cli-2.16.49.tgz",
|
||||||
@@ -7541,6 +7819,27 @@
|
|||||||
"integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==",
|
"integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-delaunay": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-scale": {
|
"node_modules/@types/d3-scale": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz",
|
||||||
@@ -7550,6 +7849,15 @@
|
|||||||
"@types/d3-time": "^2"
|
"@types/d3-time": "^2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-time": {
|
"node_modules/@types/d3-time": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz",
|
||||||
@@ -9768,6 +10076,18 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-delaunay": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"delaunator": "5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-format": {
|
"node_modules/d3-format": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||||
@@ -9804,6 +10124,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-scale": {
|
"node_modules/d3-scale": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
@@ -9832,6 +10161,18 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-time": {
|
"node_modules/d3-time": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
@@ -9935,6 +10276,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.18",
|
"version": "1.11.18",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
||||||
@@ -10116,6 +10467,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delaunator": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"robust-predicates": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/delaunator/node_modules/robust-predicates": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
"@mui/icons-material": "^6.1.6",
|
"@mui/icons-material": "^6.1.6",
|
||||||
"@mui/lab": "^6.0.0-beta.14",
|
"@mui/lab": "^6.0.0-beta.14",
|
||||||
"@mui/material": "^6.1.7",
|
"@mui/material": "^6.1.7",
|
||||||
|
"@mui/x-charts": "^7.29.1",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
|
"@mui/x-date-pickers": "^8.12.0",
|
||||||
"@refinedev/cli": "^2.16.49",
|
"@refinedev/cli": "^2.16.49",
|
||||||
"@refinedev/core": "^5.0.2",
|
"@refinedev/core": "^5.0.2",
|
||||||
"@refinedev/devtools": "^2.0.2",
|
"@refinedev/devtools": "^2.0.2",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@turf/turf": "^7.2.0",
|
"@turf/turf": "^7.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^15.2.4",
|
"next": "^15.2.4",
|
||||||
|
|||||||
@@ -1,11 +1,88 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
|
import Timeline from "@app/OlMap/Controls/Timeline";
|
||||||
|
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
||||||
|
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
||||||
|
|
||||||
|
const mockDevices = [
|
||||||
|
{
|
||||||
|
id: "SCADA-001",
|
||||||
|
name: "进水口压力",
|
||||||
|
type: "pressure",
|
||||||
|
coordinates: [121.4737, 31.2304] as [number, number],
|
||||||
|
status: "online" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SCADA-002",
|
||||||
|
name: "二泵站流量",
|
||||||
|
type: "flow",
|
||||||
|
coordinates: [121.4807, 31.2204] as [number, number],
|
||||||
|
status: "warning" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SCADA-003",
|
||||||
|
name: "管网节点 A",
|
||||||
|
type: "pressure",
|
||||||
|
coordinates: [121.4607, 31.2354] as [number, number],
|
||||||
|
status: "offline" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SCADA-004",
|
||||||
|
name: "管网节点 B",
|
||||||
|
type: "demand",
|
||||||
|
coordinates: [121.4457, 31.2104] as [number, number],
|
||||||
|
status: "online" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const devices = useMemo(() => mockDevices, []);
|
||||||
|
|
||||||
|
const deviceLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
devices.reduce<Record<string, string>>((acc, device) => {
|
||||||
|
acc[device.id] = device.name;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
[devices]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||||
|
setSelectedDeviceIds(ids);
|
||||||
|
setPanelVisible(ids.length > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeviceClick = useCallback(() => {
|
||||||
|
setPanelVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClosePanel = useCallback(() => {
|
||||||
|
setPanelVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
<MapComponent />
|
<MapComponent />
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[800px] opacity-90 hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<Timeline />
|
||||||
|
</div>
|
||||||
|
<SCADADeviceList
|
||||||
|
devices={devices}
|
||||||
|
onDeviceClick={handleDeviceClick}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
selectedDeviceIds={selectedDeviceIds}
|
||||||
|
/>
|
||||||
|
<SCADADataPanel
|
||||||
|
deviceIds={selectedDeviceIds}
|
||||||
|
deviceLabels={deviceLabels}
|
||||||
|
visible={panelVisible}
|
||||||
|
onClose={handleClosePanel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/app/OlMap/Controls/LayerControl.tsx
Normal file
60
src/app/OlMap/Controls/LayerControl.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useMap } from "../MapComponent";
|
||||||
|
import { Layer } from "ol/layer";
|
||||||
|
import { Checkbox, FormControlLabel } from "@mui/material";
|
||||||
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
|
|
||||||
|
const LayerControl: React.FC = () => {
|
||||||
|
const map = useMap();
|
||||||
|
const [layers, setLayers] = useState<Layer[]>([]);
|
||||||
|
const [layerVisibilities, setLayerVisibilities] = useState<
|
||||||
|
Map<Layer, boolean>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const mapLayers = map
|
||||||
|
.getLayers()
|
||||||
|
.getArray()
|
||||||
|
.filter((layer) => layer instanceof WebGLVectorTileLayer) as Layer[];
|
||||||
|
setLayers(mapLayers);
|
||||||
|
const visible = new Map<Layer, boolean>();
|
||||||
|
mapLayers.forEach((layer) => {
|
||||||
|
visible.set(layer, layer.getVisible());
|
||||||
|
});
|
||||||
|
setLayerVisibilities(visible);
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
const handleVisibilityChange = (layer: Layer, visible: boolean) => {
|
||||||
|
layer.setVisible(visible);
|
||||||
|
setLayerVisibilities((prev) => new Map(prev).set(layer, visible));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute left-4 bottom-4 bg-white rounded-md drop-shadow-lg opacity-85 hover:opacity-100 transition-opacity max-w-xs">
|
||||||
|
<div className="ml-3 grid grid-cols-3">
|
||||||
|
{layers.map((layer, index) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={index}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={layerVisibilities.get(layer) ?? false}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleVisibilityChange(layer, e.target.checked)
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={layer.get("name") || `Layer ${index + 1}`}
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
"& .MuiFormControlLabel-label": { fontSize: "0.7rem" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayerControl;
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
// 导入OpenLayers样式相关模块
|
// 导入OpenLayers样式相关模块
|
||||||
import VectorTileLayer from "ol/layer/VectorTile";
|
|
||||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
import { useMap } from "../MapComponent";
|
import { useMap } from "../MapComponent";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Slider,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
|
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
||||||
|
import { zhCN } from "date-fns/locale";
|
||||||
|
import { PlayArrow, Pause, Stop, Refresh } from "@mui/icons-material";
|
||||||
|
import { TbRewindBackward5, TbRewindForward5 } from "react-icons/tb";
|
||||||
|
|
||||||
|
interface TimelineProps {
|
||||||
|
onTimeChange?: (time: string) => void;
|
||||||
|
onDateChange?: (date: Date) => void;
|
||||||
|
onPlay?: () => void;
|
||||||
|
onPause?: () => void;
|
||||||
|
onStop?: () => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onFetch?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Timeline: React.FC<TimelineProps> = ({
|
||||||
|
onTimeChange,
|
||||||
|
onDateChange,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onStop,
|
||||||
|
onRefresh,
|
||||||
|
onFetch,
|
||||||
|
}) => {
|
||||||
|
const [currentTime, setCurrentTime] = useState<number>(0); // 分钟数 (0-1439)
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [playInterval, setPlayInterval] = useState<number>(5000); // 毫秒
|
||||||
|
const [calculatedInterval, setCalculatedInterval] = useState<number>(1440); // 分钟
|
||||||
|
const [sliderValue, setSliderValue] = useState<number>(0);
|
||||||
|
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 时间刻度数组 (每5分钟一个刻度)
|
||||||
|
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
|
||||||
|
value: i * 5,
|
||||||
|
label: i % 24 === 0 ? formatTime(i * 5) : "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 格式化时间显示
|
||||||
|
function formatTime(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return `${hours.toString().padStart(2, "0")}:${mins
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放时间间隔选项
|
||||||
|
const intervalOptions = [
|
||||||
|
{ value: 1000, label: "1秒" },
|
||||||
|
{ value: 2000, label: "2秒" },
|
||||||
|
{ value: 5000, label: "5秒" },
|
||||||
|
{ value: 10000, label: "10秒" },
|
||||||
|
];
|
||||||
|
// 播放时间间隔选项
|
||||||
|
const calculatedIntervalOptions = [
|
||||||
|
{ value: 1440, label: "1 天" },
|
||||||
|
{ value: 60, label: "1 小时" },
|
||||||
|
{ value: 30, label: "30 分钟" },
|
||||||
|
{ value: 15, label: "15 分钟" },
|
||||||
|
{ value: 5, label: "5 分钟" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 处理时间轴滑动
|
||||||
|
const handleSliderChange = useCallback(
|
||||||
|
(event: Event, newValue: number | number[]) => {
|
||||||
|
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||||
|
setSliderValue(value);
|
||||||
|
setCurrentTime(value);
|
||||||
|
onTimeChange?.(formatTime(value));
|
||||||
|
},
|
||||||
|
[onTimeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 播放控制
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
onPlay?.();
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setCurrentTime((prev) => {
|
||||||
|
const next = prev >= 1435 ? 0 : prev + 5; // 到达23:55后回到00:00
|
||||||
|
setSliderValue(next);
|
||||||
|
onTimeChange?.(formatTime(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, playInterval);
|
||||||
|
}
|
||||||
|
}, [isPlaying, playInterval, onPlay, onTimeChange]);
|
||||||
|
|
||||||
|
const handlePause = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
onPause?.();
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, [onPause]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setSliderValue(0);
|
||||||
|
onStop?.();
|
||||||
|
onTimeChange?.(formatTime(0));
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, [onStop, onTimeChange]);
|
||||||
|
|
||||||
|
// 步进控制
|
||||||
|
const handleStepBackward = useCallback(() => {
|
||||||
|
setCurrentTime((prev) => {
|
||||||
|
const next = prev <= 0 ? 1435 : prev - 5;
|
||||||
|
setSliderValue(next);
|
||||||
|
onTimeChange?.(formatTime(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [onTimeChange]);
|
||||||
|
|
||||||
|
const handleStepForward = useCallback(() => {
|
||||||
|
setCurrentTime((prev) => {
|
||||||
|
const next = prev >= 1435 ? 0 : prev + 5;
|
||||||
|
setSliderValue(next);
|
||||||
|
onTimeChange?.(formatTime(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [onTimeChange]);
|
||||||
|
|
||||||
|
// 日期选择处理
|
||||||
|
const handleDateChange = useCallback(
|
||||||
|
(newDate: Date | null) => {
|
||||||
|
if (newDate) {
|
||||||
|
setSelectedDate(newDate);
|
||||||
|
onDateChange?.(newDate);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDateChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 播放间隔改变处理
|
||||||
|
const handleIntervalChange = useCallback(
|
||||||
|
(event: any) => {
|
||||||
|
const newInterval = event.target.value;
|
||||||
|
setPlayInterval(newInterval);
|
||||||
|
|
||||||
|
// 如果正在播放,重新启动定时器
|
||||||
|
if (isPlaying && intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setCurrentTime((prev) => {
|
||||||
|
const next = prev >= 1435 ? 0 : prev + 5;
|
||||||
|
setSliderValue(next);
|
||||||
|
onTimeChange?.(formatTime(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, newInterval);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPlaying, onTimeChange]
|
||||||
|
);
|
||||||
|
// 计算时间段改变处理
|
||||||
|
const handleCalculatedIntervalChange = useCallback((event: any) => {
|
||||||
|
const newInterval = event.target.value;
|
||||||
|
setCalculatedInterval(newInterval);
|
||||||
|
}, []);
|
||||||
|
// 组件卸载时清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={zhCN}>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
p: 2,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
{/* 控制按钮栏 */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
|
||||||
|
>
|
||||||
|
{/* 日期选择器 */}
|
||||||
|
<DatePicker
|
||||||
|
label="模拟数据日期选择"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(newValue) => handleDateChange(newValue)}
|
||||||
|
enableAccessibleFieldDOMStructure={false}
|
||||||
|
format="yyyy-MM-dd"
|
||||||
|
sx={{ width: 180, "& .MuiInputBase-root": { height: 40 } }}
|
||||||
|
maxDate={new Date()} // 禁止选取未来的日期
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 播放控制按钮 */}
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
{/* 播放间隔选择 */}
|
||||||
|
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||||
|
<InputLabel>播放间隔</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={playInterval}
|
||||||
|
label="播放间隔"
|
||||||
|
onChange={handleIntervalChange}
|
||||||
|
>
|
||||||
|
{intervalOptions.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Tooltip title="后退一步">
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
onClick={handleStepBackward}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<TbRewindBackward5 />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title={isPlaying ? "暂停" : "播放"}>
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
onClick={isPlaying ? handlePause : handlePlay}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="前进一步">
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
onClick={handleStepForward}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<TbRewindForward5 />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="停止">
|
||||||
|
<IconButton color="secondary" onClick={handleStop} size="small">
|
||||||
|
<Stop />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
{/* 强制计算时间段 */}
|
||||||
|
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||||
|
<InputLabel>计算时间段</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={calculatedInterval}
|
||||||
|
label="强制计算时间段"
|
||||||
|
onChange={handleCalculatedIntervalChange}
|
||||||
|
>
|
||||||
|
{calculatedIntervalOptions.map((option) => (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* 功能按钮 */}
|
||||||
|
<Tooltip title="强制计算">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Refresh />}
|
||||||
|
onClick={onRefresh}
|
||||||
|
>
|
||||||
|
强制计算
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 当前时间显示 */}
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
ml: "auto",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "primary.main",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 时间轴滑块 */}
|
||||||
|
<Box ref={timelineRef} sx={{ px: 2 }}>
|
||||||
|
<Slider
|
||||||
|
value={sliderValue}
|
||||||
|
min={0}
|
||||||
|
max={1435} // 23:55 = 1435分钟
|
||||||
|
step={5}
|
||||||
|
marks={timeMarks.filter((_, index) => index % 12 === 0)} // 每小时显示一个标记
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={formatTime}
|
||||||
|
sx={{
|
||||||
|
height: 8,
|
||||||
|
"& .MuiSlider-track": {
|
||||||
|
backgroundColor: "primary.main",
|
||||||
|
height: 6,
|
||||||
|
},
|
||||||
|
"& .MuiSlider-rail": {
|
||||||
|
backgroundColor: "grey.300",
|
||||||
|
height: 6,
|
||||||
|
},
|
||||||
|
"& .MuiSlider-thumb": {
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
backgroundColor: "primary.main",
|
||||||
|
border: "2px solid #fff",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
||||||
|
"&:hover": {
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"& .MuiSlider-mark": {
|
||||||
|
backgroundColor: "grey.400",
|
||||||
|
height: 4,
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
"& .MuiSlider-markActive": {
|
||||||
|
backgroundColor: "primary.main",
|
||||||
|
},
|
||||||
|
"& .MuiSlider-markLabel": {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "grey.600",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</LocalizationProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Timeline;
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { useMap } from "../MapComponent";
|
import { useMap } from "../MapComponent";
|
||||||
import Geolocation from "ol/Geolocation";
|
|
||||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||||
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
|
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
|
||||||
import GpsFixedRoundedIcon from "@mui/icons-material/GpsFixedRounded";
|
import FitScreenIcon from "@mui/icons-material/FitScreen";
|
||||||
import clsx from "clsx";
|
import { config } from "@config/config";
|
||||||
|
|
||||||
const INITIAL_ZOOM = 14; // 默认缩放级别
|
|
||||||
|
|
||||||
const Zoom: React.FC = () => {
|
const Zoom: React.FC = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [locateDisabled, setLocateDisabled] = useState(false);
|
|
||||||
|
|
||||||
// 放大函数
|
// 放大函数
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
@@ -26,57 +22,22 @@ const Zoom: React.FC = () => {
|
|||||||
view.animate({ zoom: (view.getZoom() ?? 0) - 1, duration: 200 });
|
view.animate({ zoom: (view.getZoom() ?? 0) - 1, duration: 200 });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 定位功能
|
// 缩放到全局 Extent
|
||||||
const handleLocate = () => {
|
const handleFitScreen = () => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
const view = map.getView();
|
||||||
const geolocation = new Geolocation({
|
view.fit(config.mapExtent, { duration: 500 });
|
||||||
trackingOptions: { enableHighAccuracy: true },
|
|
||||||
projection: map.getView().getProjection(),
|
|
||||||
});
|
|
||||||
|
|
||||||
geolocation.once("change:position", () => {
|
|
||||||
const coords = geolocation.getPosition();
|
|
||||||
if (coords) {
|
|
||||||
map
|
|
||||||
.getView()
|
|
||||||
.animate({ center: coords, zoom: INITIAL_ZOOM, duration: 500 });
|
|
||||||
}
|
|
||||||
geolocation.setTracking(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
geolocation.setTracking(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装 handleLocate,点击后禁用按钮一段时间
|
|
||||||
const onLocateClick = () => {
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
() => {
|
|
||||||
handleLocate();
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
console.log(error.message);
|
|
||||||
setLocateDisabled(true); // 定位失败后禁用按钮
|
|
||||||
// alert("定位失败,将使用默认位置。");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-4 bottom-8">
|
<div className="absolute right-4 bottom-8">
|
||||||
<div className="w-8 h-26 flex flex-col gap-2 items-center">
|
<div className="w-8 h-26 flex flex-col gap-2 items-center">
|
||||||
<div
|
<div className="w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black">
|
||||||
className={clsx(
|
|
||||||
"w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black",
|
|
||||||
locateDisabled && "text-gray-300"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
className="w-6 h-6 flex items-center justify-center rounded-xl hover:bg-white transition-all duration-100"
|
className="w-6 h-6 flex items-center justify-center rounded-xl hover:bg-white transition-all duration-100"
|
||||||
onClick={onLocateClick}
|
onClick={handleFitScreen}
|
||||||
disabled={locateDisabled}
|
|
||||||
>
|
>
|
||||||
<GpsFixedRoundedIcon fontSize="small" />
|
<FitScreenIcon fontSize="small" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-8 h-16 flex flex-col items-center justify-center bg-gray-50 rounded-xl drop-shadow-xl shadow-black">
|
<div className="w-8 h-16 flex flex-col items-center justify-center bg-gray-50 rounded-xl drop-shadow-xl shadow-black">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { bearing } from "@turf/turf";
|
|||||||
import { Deck } from "@deck.gl/core";
|
import { Deck } from "@deck.gl/core";
|
||||||
import { TextLayer } from "@deck.gl/layers";
|
import { TextLayer } from "@deck.gl/layers";
|
||||||
import { TripsLayer } from "@deck.gl/geo-layers";
|
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||||
|
import { tr } from "date-fns/locale";
|
||||||
|
|
||||||
// 创建自定义Layer类来包装deck.gl
|
// 创建自定义Layer类来包装deck.gl
|
||||||
class DeckLayer extends Layer {
|
class DeckLayer extends Layer {
|
||||||
@@ -89,8 +90,9 @@ const MapComponent: React.FC = () => {
|
|||||||
let showPipeText = true; // 控制管道文本显示
|
let showPipeText = true; // 控制管道文本显示
|
||||||
let junctionText = "pressure";
|
let junctionText = "pressure";
|
||||||
let pipeText = "flow";
|
let pipeText = "flow";
|
||||||
|
let animate = false; // 控制是否动画
|
||||||
const isAnimating = useRef(false); // 添加动画控制标志
|
const isAnimating = useRef(false); // 添加动画控制标志
|
||||||
|
const [currentZoom, setCurrentZoom] = useState(12); // 当前缩放级别
|
||||||
// 防抖更新函数
|
// 防抖更新函数
|
||||||
const debouncedUpdateData = useRef(
|
const debouncedUpdateData = useRef(
|
||||||
debounce(() => {
|
debounce(() => {
|
||||||
@@ -194,7 +196,7 @@ const MapComponent: React.FC = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
// 属性为 flow 时启动动画
|
// 属性为 flow 时启动动画
|
||||||
if (pipeProperties === "flow") {
|
if (pipeProperties === "flow" && animate) {
|
||||||
isAnimating.current = true;
|
isAnimating.current = true;
|
||||||
} else {
|
} else {
|
||||||
isAnimating.current = false;
|
isAnimating.current = false;
|
||||||
@@ -397,6 +399,13 @@ const MapComponent: React.FC = () => {
|
|||||||
padding: [50, 50, 50, 50], // 添加一些内边距
|
padding: [50, 50, 50, 50], // 添加一些内边距
|
||||||
duration: 1000, // 动画持续时间
|
duration: 1000, // 动画持续时间
|
||||||
});
|
});
|
||||||
|
// 监听缩放变化
|
||||||
|
map.getView().on("change", () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const zoom = map.getView().getZoom() || 0;
|
||||||
|
setCurrentZoom(zoom);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
// 初始化 deck.gl
|
// 初始化 deck.gl
|
||||||
const deck = new Deck({
|
const deck = new Deck({
|
||||||
initialViewState: {
|
initialViewState: {
|
||||||
@@ -439,6 +448,7 @@ const MapComponent: React.FC = () => {
|
|||||||
getTextAnchor: "middle",
|
getTextAnchor: "middle",
|
||||||
getAlignmentBaseline: "center",
|
getAlignmentBaseline: "center",
|
||||||
getPixelOffset: [0, -10],
|
getPixelOffset: [0, -10],
|
||||||
|
visible: currentZoom >= 15 && currentZoom <= 24,
|
||||||
// --- 修改以下属性 ---
|
// --- 修改以下属性 ---
|
||||||
// characterSet: "auto",
|
// characterSet: "auto",
|
||||||
// outlineWidth: 4,
|
// outlineWidth: 4,
|
||||||
@@ -458,6 +468,7 @@ const MapComponent: React.FC = () => {
|
|||||||
getPixelOffset: [0, -8],
|
getPixelOffset: [0, -8],
|
||||||
getTextAnchor: "middle",
|
getTextAnchor: "middle",
|
||||||
getAlignmentBaseline: "bottom",
|
getAlignmentBaseline: "bottom",
|
||||||
|
visible: currentZoom >= 15 && currentZoom <= 24,
|
||||||
// --- 修改以下属性 ---
|
// --- 修改以下属性 ---
|
||||||
// characterSet: "auto",
|
// characterSet: "auto",
|
||||||
// outlineWidth: 5,
|
// outlineWidth: 5,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Zoom from "./Controls/Zoom";
|
|||||||
import BaseLayers from "./Controls/BaseLayers";
|
import BaseLayers from "./Controls/BaseLayers";
|
||||||
import MapToolbar from "./Controls/Toolbar";
|
import MapToolbar from "./Controls/Toolbar";
|
||||||
import ScaleLine from "./Controls/ScaleLine";
|
import ScaleLine from "./Controls/ScaleLine";
|
||||||
|
import LayerControl from "./Controls/LayerControl";
|
||||||
|
|
||||||
const MapTools = () => {
|
const MapTools = () => {
|
||||||
return (
|
return (
|
||||||
@@ -11,6 +12,7 @@ const MapTools = () => {
|
|||||||
<ScaleLine />
|
<ScaleLine />
|
||||||
<BaseLayers />
|
<BaseLayers />
|
||||||
<MapToolbar />
|
<MapToolbar />
|
||||||
|
<LayerControl />
|
||||||
{/* 继续添加其他自定义控件 */}
|
{/* 继续添加其他自定义控件 */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
src/app/icon.ico
BIN
src/app/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 7.2 KiB |
528
src/components/olmap/SCADADataPanel.tsx
Normal file
528
src/components/olmap/SCADADataPanel.tsx
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Collapse,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
Close,
|
||||||
|
Refresh,
|
||||||
|
ShowChart,
|
||||||
|
TableChart,
|
||||||
|
ExpandLess,
|
||||||
|
ExpandMore,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { LineChart } from "@mui/x-charts";
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
|
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
export interface TimeSeriesPoint {
|
||||||
|
/** ISO8601 时间戳 */
|
||||||
|
timestamp: string;
|
||||||
|
/** 每个设备对应的值 */
|
||||||
|
values: Record<string, number | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SCADADataPanelProps {
|
||||||
|
/** 选中的设备 ID 列表 */
|
||||||
|
deviceIds: string[];
|
||||||
|
/** 自定义数据获取器,默认使用本地模拟数据 */
|
||||||
|
fetchTimeSeriesData?: (
|
||||||
|
deviceIds: string[],
|
||||||
|
range: { from: Date; to: Date }
|
||||||
|
) => Promise<TimeSeriesPoint[]>;
|
||||||
|
/** 可选:为设备提供友好的显示名称 */
|
||||||
|
deviceLabels?: Record<string, string>;
|
||||||
|
/** 可选:控制浮窗显示 */
|
||||||
|
visible?: boolean;
|
||||||
|
/** 可选:关闭浮窗的回调 */
|
||||||
|
onClose?: () => void;
|
||||||
|
/** 默认展示的选项卡 */
|
||||||
|
defaultTab?: "chart" | "table";
|
||||||
|
/** Y 轴数值的小数位数 */
|
||||||
|
fractionDigits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PanelTab = "chart" | "table";
|
||||||
|
|
||||||
|
type LoadingState = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
const generateMockTimeSeries = (
|
||||||
|
deviceIds: string[],
|
||||||
|
range: { from: Date; to: Date },
|
||||||
|
points = 96
|
||||||
|
): TimeSeriesPoint[] => {
|
||||||
|
if (deviceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = dayjs(range.from);
|
||||||
|
const end = dayjs(range.to);
|
||||||
|
const duration = end.diff(start, "minute");
|
||||||
|
const stepMinutes = Math.max(
|
||||||
|
Math.floor(duration / Math.max(points - 1, 1)),
|
||||||
|
15
|
||||||
|
);
|
||||||
|
|
||||||
|
const times: TimeSeriesPoint[] = [];
|
||||||
|
let current = start;
|
||||||
|
|
||||||
|
while (current.isBefore(end) || current.isSame(end)) {
|
||||||
|
const values = deviceIds.reduce<Record<string, number>>(
|
||||||
|
(acc, id, index) => {
|
||||||
|
const phase = (index + 1) * 0.6;
|
||||||
|
const base = 50 + index * 10;
|
||||||
|
const amplitude = 10 + index * 4;
|
||||||
|
const noise = Math.sin(current.unix() / 180 + phase) * amplitude;
|
||||||
|
const trend = (current.diff(start, "minute") / duration || 0) * 5;
|
||||||
|
acc[id] = parseFloat((base + noise + trend).toFixed(2));
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
times.push({
|
||||||
|
timestamp: current.toISOString(),
|
||||||
|
values,
|
||||||
|
});
|
||||||
|
|
||||||
|
current = current.add(stepMinutes, "minute");
|
||||||
|
}
|
||||||
|
|
||||||
|
return times;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultFetcher = async (
|
||||||
|
deviceIds: string[],
|
||||||
|
range: { from: Date; to: Date }
|
||||||
|
): Promise<TimeSeriesPoint[]> => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
return generateMockTimeSeries(deviceIds, range);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string) =>
|
||||||
|
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
||||||
|
|
||||||
|
const ensureValidRange = (
|
||||||
|
from: Dayjs,
|
||||||
|
to: Dayjs
|
||||||
|
): { from: Dayjs; to: Dayjs } => {
|
||||||
|
if (from.isAfter(to)) {
|
||||||
|
return { from: to, to: from };
|
||||||
|
}
|
||||||
|
return { from, to };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDataset = (
|
||||||
|
points: TimeSeriesPoint[],
|
||||||
|
deviceIds: string[],
|
||||||
|
fractionDigits: number
|
||||||
|
) => {
|
||||||
|
return points.map((point) => {
|
||||||
|
const entry: Record<string, any> = {
|
||||||
|
time: dayjs(point.timestamp).toDate(),
|
||||||
|
label: formatTimestamp(point.timestamp),
|
||||||
|
};
|
||||||
|
|
||||||
|
deviceIds.forEach((id) => {
|
||||||
|
const value = point.values[id];
|
||||||
|
entry[id] =
|
||||||
|
typeof value === "number"
|
||||||
|
? Number.isFinite(value)
|
||||||
|
? parseFloat(value.toFixed(fractionDigits))
|
||||||
|
: null
|
||||||
|
: value ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyStateMessages: Record<
|
||||||
|
PanelTab,
|
||||||
|
{ title: string; subtitle: string }
|
||||||
|
> = {
|
||||||
|
chart: {
|
||||||
|
title: "暂无时序数据",
|
||||||
|
subtitle: "请选择设备并点击刷新来获取曲线",
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
title: "暂无表格数据",
|
||||||
|
subtitle: "请选择设备并点击刷新来获取记录",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||||
|
deviceIds,
|
||||||
|
fetchTimeSeriesData = defaultFetcher,
|
||||||
|
deviceLabels,
|
||||||
|
visible = true,
|
||||||
|
onClose,
|
||||||
|
defaultTab = "chart",
|
||||||
|
fractionDigits = 2,
|
||||||
|
}) => {
|
||||||
|
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
||||||
|
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
||||||
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab(defaultTab);
|
||||||
|
}, [defaultTab]);
|
||||||
|
|
||||||
|
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||||
|
|
||||||
|
const hasDevices = deviceIds.length > 0;
|
||||||
|
const hasData = timeSeries.length > 0;
|
||||||
|
|
||||||
|
const dataset = useMemo(
|
||||||
|
() => buildDataset(timeSeries, deviceIds, fractionDigits),
|
||||||
|
[timeSeries, deviceIds, fractionDigits]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFetch = useCallback(
|
||||||
|
async (reason: string) => {
|
||||||
|
if (!hasDevices) {
|
||||||
|
setTimeSeries([]);
|
||||||
|
setLoadingState("idle");
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingState("loading");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||||
|
const result = await fetchTimeSeriesData(deviceIds, {
|
||||||
|
from: rangeFrom.toDate(),
|
||||||
|
to: rangeTo.toDate(),
|
||||||
|
});
|
||||||
|
setTimeSeries(result);
|
||||||
|
setLoadingState("success");
|
||||||
|
console.debug(
|
||||||
|
`[SCADADataPanel] 数据刷新成功 (${reason}),共 ${result.length} 条记录。`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[SCADADataPanel] 获取时序数据失败", err);
|
||||||
|
setError(err instanceof Error ? err.message : "未知错误");
|
||||||
|
setLoadingState("error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deviceIds, fetchTimeSeriesData, hasDevices, normalizedRange]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasDevices) {
|
||||||
|
handleFetch("device-change");
|
||||||
|
} else {
|
||||||
|
setTimeSeries([]);
|
||||||
|
}
|
||||||
|
}, [hasDevices, handleFetch]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = useMemo(() => {
|
||||||
|
const base: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "label",
|
||||||
|
headerName: "时间",
|
||||||
|
minWidth: 180,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const dynamic = deviceIds.map<GridColDef>((id) => ({
|
||||||
|
field: id,
|
||||||
|
headerName: deviceLabels?.[id] ?? id,
|
||||||
|
minWidth: 140,
|
||||||
|
flex: 1,
|
||||||
|
valueFormatter: (params) => {
|
||||||
|
const value = (params as any).value;
|
||||||
|
return value === null || value === undefined
|
||||||
|
? "--"
|
||||||
|
: Number.isFinite(Number(value))
|
||||||
|
? Number(value).toFixed(fractionDigits)
|
||||||
|
: String(value);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...base, ...dynamic];
|
||||||
|
}, [deviceIds, deviceLabels, fractionDigits]);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
dataset.map((item, index) => ({
|
||||||
|
id: `${
|
||||||
|
item.time instanceof Date ? item.time.getTime() : index
|
||||||
|
}-${index}`,
|
||||||
|
...item,
|
||||||
|
})),
|
||||||
|
[dataset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEmpty = () => {
|
||||||
|
const message = emptyStateMessages[activeTab];
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
py: 6,
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{message.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{message.subtitle}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartSection = hasData ? (
|
||||||
|
<LineChart
|
||||||
|
dataset={dataset}
|
||||||
|
height={360}
|
||||||
|
margin={{ left: 50, right: 50, top: 20, bottom: 80 }}
|
||||||
|
xAxis={[
|
||||||
|
{
|
||||||
|
dataKey: "time",
|
||||||
|
scaleType: "time",
|
||||||
|
valueFormatter: (value) =>
|
||||||
|
value instanceof Date
|
||||||
|
? dayjs(value).format("MM-DD HH:mm")
|
||||||
|
: String(value),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
yAxis={[{ label: "值" }]}
|
||||||
|
series={deviceIds.map((id) => ({
|
||||||
|
dataKey: id,
|
||||||
|
label: deviceLabels?.[id] ?? id,
|
||||||
|
showMark: false,
|
||||||
|
curve: "linear",
|
||||||
|
}))}
|
||||||
|
slotProps={{
|
||||||
|
legend: {
|
||||||
|
direction: "row",
|
||||||
|
position: { horizontal: "middle", vertical: "bottom" },
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
style: { backgroundColor: "transparent" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderEmpty()
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableSection = hasData ? (
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
columnBufferPx={100}
|
||||||
|
sx={{ border: "none", height: "360px" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderEmpty()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
className={clsx(
|
||||||
|
"absolute right-4 top-20 w-4xl h-2xl bg-white/95 backdrop-blur-[10px] rounded-xl shadow-lg overflow-hidden flex flex-col transition-opacity duration-300",
|
||||||
|
visible ? "opacity-95" : "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
backgroundColor: "primary.main",
|
||||||
|
color: "primary.contrastText",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<ShowChart fontSize="small" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
|
||||||
|
SCADA 历史数据
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`${deviceIds.length}`}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
|
color: "primary.contrastText",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Tooltip title="展开/收起">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
sx={{ color: "primary.contrastText" }}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="关闭">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={onClose}
|
||||||
|
sx={{ color: "primary.contrastText" }}
|
||||||
|
>
|
||||||
|
<Close fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Collapse in={isExpanded}>
|
||||||
|
{/* Controls */}
|
||||||
|
<Box sx={{ p: 2, backgroundColor: "grey.50" }}>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<DateTimePicker
|
||||||
|
label="开始时间"
|
||||||
|
value={from}
|
||||||
|
onChange={(value) =>
|
||||||
|
value && dayjs.isDayjs(value) && setFrom(value)
|
||||||
|
}
|
||||||
|
maxDateTime={to}
|
||||||
|
slotProps={{ textField: { fullWidth: true, size: "small" } }}
|
||||||
|
/>
|
||||||
|
<DateTimePicker
|
||||||
|
label="结束时间"
|
||||||
|
value={to}
|
||||||
|
onChange={(value) =>
|
||||||
|
value && dayjs.isDayjs(value) && setTo(value)
|
||||||
|
}
|
||||||
|
minDateTime={from}
|
||||||
|
slotProps={{ textField: { fullWidth: true, size: "small" } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_, value: PanelTab) => setActiveTab(value)}
|
||||||
|
variant="fullWidth"
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="chart"
|
||||||
|
icon={<ShowChart fontSize="small" />}
|
||||||
|
iconPosition="start"
|
||||||
|
label="曲线"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="table"
|
||||||
|
icon={<TableChart fontSize="small" />}
|
||||||
|
iconPosition="start"
|
||||||
|
label="表格"
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
<Tooltip title="刷新数据">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<Refresh fontSize="small" />}
|
||||||
|
disabled={!hasDevices || loadingState === "loading"}
|
||||||
|
onClick={() => handleFetch("manual")}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</LocalizationProvider>
|
||||||
|
|
||||||
|
{!hasDevices && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="warning.main"
|
||||||
|
sx={{ mt: 1, display: "block" }}
|
||||||
|
>
|
||||||
|
未选择任何设备,无法获取数据。
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="error"
|
||||||
|
sx={{ mt: 1, display: "block" }}
|
||||||
|
>
|
||||||
|
获取数据失败:{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}>
|
||||||
|
{loadingState === "loading" && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.6)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={48} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "chart" ? chartSection : tableSection}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SCADADataPanel;
|
||||||
430
src/components/olmap/SCADADeviceList.tsx
Normal file
430
src/components/olmap/SCADADeviceList.tsx
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Collapse,
|
||||||
|
InputAdornment,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Tooltip,
|
||||||
|
Stack,
|
||||||
|
Divider,
|
||||||
|
InputBase,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
MyLocation,
|
||||||
|
ExpandMore,
|
||||||
|
ExpandLess,
|
||||||
|
FilterList,
|
||||||
|
Clear,
|
||||||
|
Visibility,
|
||||||
|
VisibilityOff,
|
||||||
|
DeviceHub,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
|
interface SCADADevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
coordinates: [number, number];
|
||||||
|
status: "online" | "offline" | "warning" | "error";
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SCADADeviceListProps {
|
||||||
|
devices?: SCADADevice[];
|
||||||
|
onDeviceClick?: (device: SCADADevice) => void;
|
||||||
|
onZoomToDevice?: (coordinates: [number, number]) => void;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
selectedDeviceIds?: string[];
|
||||||
|
onSelectionChange?: (ids: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
||||||
|
devices = [],
|
||||||
|
onDeviceClick,
|
||||||
|
onZoomToDevice,
|
||||||
|
multiSelect = true,
|
||||||
|
selectedDeviceIds,
|
||||||
|
onSelectionChange,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
const [selectedType, setSelectedType] = useState<string>("all");
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
||||||
|
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||||
|
const [internalSelection, setInternalSelection] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const activeSelection = selectedDeviceIds ?? internalSelection;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDeviceIds) {
|
||||||
|
setInternalSelection(selectedDeviceIds);
|
||||||
|
}
|
||||||
|
}, [selectedDeviceIds]);
|
||||||
|
|
||||||
|
// 获取设备类型列表
|
||||||
|
const deviceTypes = useMemo(() => {
|
||||||
|
const types = Array.from(new Set(devices.map((device) => device.type)));
|
||||||
|
return types.sort();
|
||||||
|
}, [devices]);
|
||||||
|
|
||||||
|
// 获取设备状态列表
|
||||||
|
const deviceStatuses = useMemo(() => {
|
||||||
|
const statuses = Array.from(
|
||||||
|
new Set(devices.map((device) => device.status))
|
||||||
|
);
|
||||||
|
return statuses.sort();
|
||||||
|
}, [devices]);
|
||||||
|
|
||||||
|
// 过滤设备列表
|
||||||
|
const filteredDevices = useMemo(() => {
|
||||||
|
return devices.filter((device) => {
|
||||||
|
const matchesSearch =
|
||||||
|
searchQuery === "" ||
|
||||||
|
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
device.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
device.type.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const matchesType =
|
||||||
|
selectedType === "all" || device.type === selectedType;
|
||||||
|
const matchesStatus =
|
||||||
|
selectedStatus === "all" || device.status === selectedStatus;
|
||||||
|
|
||||||
|
return matchesSearch && matchesType && matchesStatus;
|
||||||
|
});
|
||||||
|
}, [devices, searchQuery, selectedType, selectedStatus]);
|
||||||
|
|
||||||
|
// 状态颜色映射
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "online":
|
||||||
|
return "success";
|
||||||
|
case "offline":
|
||||||
|
return "default";
|
||||||
|
case "warning":
|
||||||
|
return "warning";
|
||||||
|
case "error":
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态图标映射
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "online":
|
||||||
|
return "●";
|
||||||
|
case "offline":
|
||||||
|
return "○";
|
||||||
|
case "warning":
|
||||||
|
return "▲";
|
||||||
|
case "error":
|
||||||
|
return "✕";
|
||||||
|
default:
|
||||||
|
return "●";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理设备点击
|
||||||
|
const handleDeviceClick = (device: SCADADevice, event?: React.MouseEvent) => {
|
||||||
|
onDeviceClick?.(device);
|
||||||
|
|
||||||
|
setInternalSelection((prev) => {
|
||||||
|
const exists = prev.includes(device.id);
|
||||||
|
const nextSelection = multiSelect
|
||||||
|
? exists
|
||||||
|
? prev.filter((id) => id !== device.id)
|
||||||
|
: [...prev, device.id]
|
||||||
|
: exists
|
||||||
|
? []
|
||||||
|
: [device.id];
|
||||||
|
|
||||||
|
onSelectionChange?.(nextSelection);
|
||||||
|
return nextSelection;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理缩放到设备
|
||||||
|
const handleZoomToDevice = (device: SCADADevice, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onZoomToDevice?.(device.coordinates);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除搜索
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearchQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置所有筛选条件
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedType("all");
|
||||||
|
setSelectedStatus("all");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="absolute left-4 top-20 w-90 max-h-[calc(100vh-100px)] bg-white/95 backdrop-blur-[10px] rounded-xl shadow-lg overflow-hidden flex flex-col opacity-95 transition-opacity duration-200 ease-in-out hover:opacity-100">
|
||||||
|
{/* 头部控制栏 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
backgroundColor: "primary.main",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<DeviceHub fontSize="small" />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
|
||||||
|
SCADA 设备列表
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={filteredDevices.length}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||||
|
color: "white",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Tooltip title="展开/收起">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
sx={{ color: "white" }}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Collapse in={isExpanded}>
|
||||||
|
{/* 搜索和筛选栏 */}
|
||||||
|
<Box sx={{ p: 2, backgroundColor: "grey.50" }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<Box className="h-10 flex items-center border border-gray-300 rounded-lg p-0.5">
|
||||||
|
<InputBase
|
||||||
|
sx={{ ml: 1, flex: 1 }}
|
||||||
|
placeholder="搜索设备名称、ID 或类型..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
inputProps={{ "aria-label": "search devices" }}
|
||||||
|
/>
|
||||||
|
<IconButton type="button" sx={{ p: "6px" }} aria-label="search">
|
||||||
|
<Search fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
{searchQuery && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ height: 28, m: 0.5 }} orientation="vertical" />
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
sx={{ p: "6px" }}
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
aria-label="clear"
|
||||||
|
>
|
||||||
|
<Clear fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 筛选器 */}
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||||
|
<InputLabel>设备类型</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedType}
|
||||||
|
label="设备类型"
|
||||||
|
onChange={(e) => setSelectedType(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">全部类型</MenuItem>
|
||||||
|
{deviceTypes.map((type) => (
|
||||||
|
<MenuItem key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||||
|
<InputLabel>状态</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedStatus}
|
||||||
|
label="状态"
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">全部状态</MenuItem>
|
||||||
|
{deviceStatuses.map((status) => (
|
||||||
|
<MenuItem key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Tooltip title="重置筛选条件">
|
||||||
|
<IconButton onClick={handleResetFilters}>
|
||||||
|
<FilterList />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* 筛选结果统计 */}
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
共找到 {filteredDevices.length} 个设备
|
||||||
|
{devices.length !== filteredDevices.length &&
|
||||||
|
` (共 ${devices.length} 个设备)`}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 设备列表 */}
|
||||||
|
<Box sx={{ flex: 1, overflow: "auto", maxHeight: 400 }}>
|
||||||
|
{filteredDevices.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeviceHub sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
|
||||||
|
<Typography variant="body2">
|
||||||
|
{searchQuery ||
|
||||||
|
selectedType !== "all" ||
|
||||||
|
selectedStatus !== "all"
|
||||||
|
? "未找到匹配的设备"
|
||||||
|
: "暂无 SCADA 设备"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<List dense sx={{ p: 0 }}>
|
||||||
|
{filteredDevices.map((device, index) => (
|
||||||
|
<React.Fragment key={device.id}>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
selected={activeSelection.includes(device.id)}
|
||||||
|
onClick={(event) => handleDeviceClick(device, event)}
|
||||||
|
sx={{
|
||||||
|
"&.Mui-selected": {
|
||||||
|
backgroundColor: "primary.50",
|
||||||
|
borderLeft: 3,
|
||||||
|
borderColor: "primary.main",
|
||||||
|
},
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "grey.50",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: `${getStatusColor(device.status)}.main`,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getStatusIcon(device.status)}
|
||||||
|
</Typography>
|
||||||
|
</ListItemIcon>
|
||||||
|
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: "medium" }}
|
||||||
|
>
|
||||||
|
{device.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={device.type}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: "0.7rem", height: 20 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
|
ID: {device.id}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
|
坐标: {device.coordinates[0].toFixed(6)},{" "}
|
||||||
|
{device.coordinates[1].toFixed(6)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip title="缩放到设备位置">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => handleZoomToDevice(device, e)}
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
color: "primary.main",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "primary.50",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyLocation fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
{index < filteredDevices.length - 1 && (
|
||||||
|
<Divider variant="inset" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SCADADeviceList;
|
||||||
154
src/utils/breaks_classification.js
Normal file
154
src/utils/breaks_classification.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* 优雅分段分类 - 类似QGIS的Pretty Breaks
|
||||||
|
* 生成"好看"、易读的断点数值
|
||||||
|
* @param {number[]} data - 数据数组
|
||||||
|
* @param {number} n_classes - 分类数量
|
||||||
|
* @returns {number[]} 断点数组
|
||||||
|
*/
|
||||||
|
function prettyBreaksClassification(data, n_classes) {
|
||||||
|
if (data.length === 0) return [];
|
||||||
|
|
||||||
|
const min_val = Math.min(...data);
|
||||||
|
const max_val = Math.max(...data);
|
||||||
|
const data_range = max_val - min_val;
|
||||||
|
|
||||||
|
// 计算基础间隔
|
||||||
|
const raw_interval = data_range / n_classes;
|
||||||
|
|
||||||
|
// 寻找"优雅"的间隔
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(raw_interval));
|
||||||
|
const normalized = raw_interval / magnitude;
|
||||||
|
|
||||||
|
// 选择最接近的优雅数字
|
||||||
|
let nice_interval;
|
||||||
|
if (normalized <= 1) {
|
||||||
|
nice_interval = magnitude;
|
||||||
|
} else if (normalized <= 2) {
|
||||||
|
nice_interval = 2 * magnitude;
|
||||||
|
} else if (normalized <= 5) {
|
||||||
|
nice_interval = 5 * magnitude;
|
||||||
|
} else {
|
||||||
|
nice_interval = 10 * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算优雅的起始点
|
||||||
|
const nice_min = Math.floor(min_val / nice_interval) * nice_interval;
|
||||||
|
const nice_max = Math.ceil(max_val / nice_interval) * nice_interval;
|
||||||
|
|
||||||
|
// 生成断点
|
||||||
|
const breaks = [];
|
||||||
|
let current = nice_min;
|
||||||
|
while (current <= nice_max && breaks.length < n_classes + 1) {
|
||||||
|
breaks.push(current);
|
||||||
|
current += nice_interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保包含最大值
|
||||||
|
if (breaks.length === 0 || breaks[breaks.length - 1] < max_val) {
|
||||||
|
breaks.push(nice_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整为n_classes个区间
|
||||||
|
if (breaks.length > n_classes + 1) {
|
||||||
|
breaks.splice(n_classes + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return breaks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算类内方差
|
||||||
|
* @param {number[]} data - 排序后的数据数组
|
||||||
|
* @param {number} start - 起始索引
|
||||||
|
* @param {number} end - 结束索引
|
||||||
|
* @returns {number} 类内方差
|
||||||
|
*/
|
||||||
|
function variance(data, start, end) {
|
||||||
|
if (start >= end) return 0;
|
||||||
|
const mean = data.slice(start, end + 1).reduce((a, b) => a + b, 0) / (end - start + 1);
|
||||||
|
return data.slice(start, end + 1).reduce((sum, val) => sum + (val - mean) ** 2, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jenks自然断点分类算法
|
||||||
|
* @param {number[]} data - 数据数组
|
||||||
|
* @param {number} n_classes - 分类数量
|
||||||
|
* @returns {number[]} 断点数组
|
||||||
|
*/
|
||||||
|
function jenks_breaks_jenkspy(data, n_classes) {
|
||||||
|
if (data.length === 0) return [];
|
||||||
|
if (n_classes >= data.length) return data.slice().sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const sortedData = data.slice().sort((a, b) => a - b);
|
||||||
|
const n = sortedData.length;
|
||||||
|
const k = n_classes;
|
||||||
|
|
||||||
|
// 初始化矩阵
|
||||||
|
const lowerClassLimits = Array.from({ length: n + 1 }, () => Array(k + 1).fill(0));
|
||||||
|
const varianceCombinations = Array.from({ length: n + 1 }, () => Array(k + 1).fill(0));
|
||||||
|
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
lowerClassLimits[i][1] = 1;
|
||||||
|
varianceCombinations[i][1] = variance(sortedData, 0, i - 1);
|
||||||
|
for (let j = 2; j <= k; j++) {
|
||||||
|
varianceCombinations[i][j] = Infinity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态规划
|
||||||
|
for (let l = 2; l <= k; l++) {
|
||||||
|
for (let m = l; m <= n; m++) {
|
||||||
|
for (let i = l - 1; i < m; i++) {
|
||||||
|
const v = varianceCombinations[i][l - 1] + variance(sortedData, i, m - 1);
|
||||||
|
if (v < varianceCombinations[m][l]) {
|
||||||
|
varianceCombinations[m][l] = v;
|
||||||
|
lowerClassLimits[m][l] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回溯找到断点
|
||||||
|
const breaks = [];
|
||||||
|
let current = n;
|
||||||
|
for (let j = k; j >= 1; j--) {
|
||||||
|
breaks.unshift(sortedData[lowerClassLimits[current][j] - 1] || sortedData[0]);
|
||||||
|
current = lowerClassLimits[current][j];
|
||||||
|
}
|
||||||
|
breaks.push(sortedData[n - 1]);
|
||||||
|
|
||||||
|
return breaks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用分层采样优化的Jenks算法
|
||||||
|
* 确保采样数据能代表原数据的分布
|
||||||
|
* @param {number[]} data - 数据数组
|
||||||
|
* @param {number} n_classes - 分类数量
|
||||||
|
* @param {number} sample_size - 采样大小,默认10000
|
||||||
|
* @returns {number[]} 断点数组
|
||||||
|
*/
|
||||||
|
function jenks_with_stratified_sampling(data, n_classes, sample_size = 10000) {
|
||||||
|
if (data.length <= sample_size) {
|
||||||
|
return jenks_breaks_jenkspy(data, n_classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对数据排序
|
||||||
|
const sorted_data = data.slice().sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// 计算采样间隔
|
||||||
|
const interval = sorted_data.length / sample_size;
|
||||||
|
|
||||||
|
// 分层采样
|
||||||
|
const sampled_data = [];
|
||||||
|
for (let i = 0; i < sample_size; i++) {
|
||||||
|
const index = Math.floor(i * interval);
|
||||||
|
if (index < sorted_data.length) {
|
||||||
|
sampled_data.push(sorted_data[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jenks_breaks_jenkspy(sampled_data, n_classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { prettyBreaksClassification, jenks_breaks_jenkspy, jenks_with_stratified_sampling };
|
||||||
Reference in New Issue
Block a user