diff --git a/package-lock.json b/package-lock.json index 8a3de04..e4996e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "@mui/icons-material": "^6.1.6", "@mui/lab": "^6.0.0-beta.14", "@mui/material": "^6.1.7", + "@mui/x-charts": "^7.29.1", "@mui/x-data-grid": "^7.22.2", + "@mui/x-date-pickers": "^8.12.0", "@refinedev/cli": "^2.16.49", "@refinedev/core": "^5.0.2", "@refinedev/devtools": "^2.0.2", @@ -25,6 +27,7 @@ "@tailwindcss/postcss": "^4.1.13", "@turf/turf": "^7.2.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "deck.gl": "^9.1.14", "js-cookie": "^3.0.5", "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": { "version": "7.29.9", "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": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", @@ -4553,6 +4759,78 @@ "integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==", "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": { "version": "2.16.49", "resolved": "https://registry.npmjs.org/@refinedev/cli/-/cli-2.16.49.tgz", @@ -7541,6 +7819,27 @@ "integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==", "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": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz", @@ -7550,6 +7849,15 @@ "@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": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz", @@ -9768,6 +10076,18 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -9804,6 +10124,15 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -9832,6 +10161,18 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", @@ -9935,6 +10276,16 @@ "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": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", @@ -10116,6 +10467,21 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", diff --git a/package.json b/package.json index 8fbeb2d..b106098 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "@mui/icons-material": "^6.1.6", "@mui/lab": "^6.0.0-beta.14", "@mui/material": "^6.1.7", + "@mui/x-charts": "^7.29.1", "@mui/x-data-grid": "^7.22.2", + "@mui/x-date-pickers": "^8.12.0", "@refinedev/cli": "^2.16.49", "@refinedev/core": "^5.0.2", "@refinedev/devtools": "^2.0.2", @@ -30,6 +32,7 @@ "@tailwindcss/postcss": "^4.1.13", "@turf/turf": "^7.2.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "deck.gl": "^9.1.14", "js-cookie": "^3.0.5", "next": "^15.2.4", @@ -55,4 +58,4 @@ "refine": { "projectId": "4LwOCL-BBaV29-qUYMAJ" } -} \ No newline at end of file +} diff --git a/src/app/(main)/network-simulation/page.tsx b/src/app/(main)/network-simulation/page.tsx index dee979d..281b54d 100644 --- a/src/app/(main)/network-simulation/page.tsx +++ b/src/app/(main)/network-simulation/page.tsx @@ -1,11 +1,88 @@ "use client"; +import { useCallback, useMemo, useState } from "react"; 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() { + const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); + const [panelVisible, setPanelVisible] = useState(false); + + const devices = useMemo(() => mockDevices, []); + + const deviceLabels = useMemo( + () => + devices.reduce>((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 (
+
+ +
+ +
); } diff --git a/src/app/OlMap/Controls/LayerControl.tsx b/src/app/OlMap/Controls/LayerControl.tsx new file mode 100644 index 0000000..73f4f4a --- /dev/null +++ b/src/app/OlMap/Controls/LayerControl.tsx @@ -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([]); + const [layerVisibilities, setLayerVisibilities] = useState< + Map + >(new Map()); + + useEffect(() => { + if (!map) return; + const mapLayers = map + .getLayers() + .getArray() + .filter((layer) => layer instanceof WebGLVectorTileLayer) as Layer[]; + setLayers(mapLayers); + const visible = new Map(); + 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 ( +
+
+ {layers.map((layer, index) => ( + + handleVisibilityChange(layer, e.target.checked) + } + size="small" + /> + } + label={layer.get("name") || `Layer ${index + 1}`} + sx={{ + fontSize: "0.7rem", + "& .MuiFormControlLabel-label": { fontSize: "0.7rem" }, + }} + /> + ))} +
+
+ ); +}; + +export default LayerControl; diff --git a/src/app/OlMap/Controls/StyleEditorPanel.tsx b/src/app/OlMap/Controls/StyleEditorPanel.tsx index adcbb0c..1ef5ce5 100644 --- a/src/app/OlMap/Controls/StyleEditorPanel.tsx +++ b/src/app/OlMap/Controls/StyleEditorPanel.tsx @@ -18,7 +18,6 @@ import { } from "@mui/material"; // 导入OpenLayers样式相关模块 -import VectorTileLayer from "ol/layer/VectorTile"; import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile"; import { useMap } from "../MapComponent"; diff --git a/src/app/OlMap/Controls/Timeline.tsx b/src/app/OlMap/Controls/Timeline.tsx index e69de29..7c22f82 100644 --- a/src/app/OlMap/Controls/Timeline.tsx +++ b/src/app/OlMap/Controls/Timeline.tsx @@ -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 = ({ + onTimeChange, + onDateChange, + onPlay, + onPause, + onStop, + onRefresh, + onFetch, +}) => { + const [currentTime, setCurrentTime] = useState(0); // 分钟数 (0-1439) + const [selectedDate, setSelectedDate] = useState(new Date()); + const [isPlaying, setIsPlaying] = useState(false); + const [playInterval, setPlayInterval] = useState(5000); // 毫秒 + const [calculatedInterval, setCalculatedInterval] = useState(1440); // 分钟 + const [sliderValue, setSliderValue] = useState(0); + + const intervalRef = useRef(null); + const timelineRef = useRef(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 ( + + + + {/* 控制按钮栏 */} + + {/* 日期选择器 */} + handleDateChange(newValue)} + enableAccessibleFieldDOMStructure={false} + format="yyyy-MM-dd" + sx={{ width: 180, "& .MuiInputBase-root": { height: 40 } }} + maxDate={new Date()} // 禁止选取未来的日期 + /> + + {/* 播放控制按钮 */} + + {/* 播放间隔选择 */} + + 播放间隔 + + + + + + + + + + + + {isPlaying ? : } + + + + + + + + + + + + + + + + + {/* 强制计算时间段 */} + + 计算时间段 + + + + {/* 功能按钮 */} + + + + + + {/* 当前时间显示 */} + + {formatTime(currentTime)} + + + + {/* 时间轴滑块 */} + + 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", + }, + }} + /> + + + + + ); +}; + +export default Timeline; diff --git a/src/app/OlMap/Controls/Zoom.tsx b/src/app/OlMap/Controls/Zoom.tsx index a2eb852..4d0bec5 100644 --- a/src/app/OlMap/Controls/Zoom.tsx +++ b/src/app/OlMap/Controls/Zoom.tsx @@ -1,16 +1,12 @@ -import React, { useState } from "react"; +import React from "react"; import { useMap } from "../MapComponent"; -import Geolocation from "ol/Geolocation"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded"; -import GpsFixedRoundedIcon from "@mui/icons-material/GpsFixedRounded"; -import clsx from "clsx"; - -const INITIAL_ZOOM = 14; // 默认缩放级别 +import FitScreenIcon from "@mui/icons-material/FitScreen"; +import { config } from "@config/config"; const Zoom: React.FC = () => { const map = useMap(); - const [locateDisabled, setLocateDisabled] = useState(false); // 放大函数 const handleZoomIn = () => { @@ -26,57 +22,22 @@ const Zoom: React.FC = () => { view.animate({ zoom: (view.getZoom() ?? 0) - 1, duration: 200 }); }; - // 定位功能 - const handleLocate = () => { + // 缩放到全局 Extent + const handleFitScreen = () => { if (!map) return; - - const geolocation = new Geolocation({ - 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("定位失败,将使用默认位置。"); - } - ); + const view = map.getView(); + view.fit(config.mapExtent, { duration: 500 }); }; return (
-
+
diff --git a/src/app/OlMap/MapComponent.tsx b/src/app/OlMap/MapComponent.tsx index 3e66ddc..6c3ad30 100644 --- a/src/app/OlMap/MapComponent.tsx +++ b/src/app/OlMap/MapComponent.tsx @@ -24,6 +24,7 @@ import { bearing } from "@turf/turf"; import { Deck } from "@deck.gl/core"; import { TextLayer } from "@deck.gl/layers"; import { TripsLayer } from "@deck.gl/geo-layers"; +import { tr } from "date-fns/locale"; // 创建自定义Layer类来包装deck.gl class DeckLayer extends Layer { @@ -89,8 +90,9 @@ const MapComponent: React.FC = () => { let showPipeText = true; // 控制管道文本显示 let junctionText = "pressure"; let pipeText = "flow"; + let animate = false; // 控制是否动画 const isAnimating = useRef(false); // 添加动画控制标志 - + const [currentZoom, setCurrentZoom] = useState(12); // 当前缩放级别 // 防抖更新函数 const debouncedUpdateData = useRef( debounce(() => { @@ -194,7 +196,7 @@ const MapComponent: React.FC = () => { }) ); // 属性为 flow 时启动动画 - if (pipeProperties === "flow") { + if (pipeProperties === "flow" && animate) { isAnimating.current = true; } else { isAnimating.current = false; @@ -397,6 +399,13 @@ const MapComponent: React.FC = () => { padding: [50, 50, 50, 50], // 添加一些内边距 duration: 1000, // 动画持续时间 }); + // 监听缩放变化 + map.getView().on("change", () => { + setTimeout(() => { + const zoom = map.getView().getZoom() || 0; + setCurrentZoom(zoom); + }, 0); + }); // 初始化 deck.gl const deck = new Deck({ initialViewState: { @@ -439,6 +448,7 @@ const MapComponent: React.FC = () => { getTextAnchor: "middle", getAlignmentBaseline: "center", getPixelOffset: [0, -10], + visible: currentZoom >= 15 && currentZoom <= 24, // --- 修改以下属性 --- // characterSet: "auto", // outlineWidth: 4, @@ -458,6 +468,7 @@ const MapComponent: React.FC = () => { getPixelOffset: [0, -8], getTextAnchor: "middle", getAlignmentBaseline: "bottom", + visible: currentZoom >= 15 && currentZoom <= 24, // --- 修改以下属性 --- // characterSet: "auto", // outlineWidth: 5, diff --git a/src/app/OlMap/MapTools.tsx b/src/app/OlMap/MapTools.tsx index 54ebde7..2842f27 100644 --- a/src/app/OlMap/MapTools.tsx +++ b/src/app/OlMap/MapTools.tsx @@ -3,6 +3,7 @@ import Zoom from "./Controls/Zoom"; import BaseLayers from "./Controls/BaseLayers"; import MapToolbar from "./Controls/Toolbar"; import ScaleLine from "./Controls/ScaleLine"; +import LayerControl from "./Controls/LayerControl"; const MapTools = () => { return ( @@ -11,6 +12,7 @@ const MapTools = () => { + {/* 继续添加其他自定义控件 */} ); diff --git a/src/app/icon.ico b/src/app/icon.ico deleted file mode 100644 index a3df394..0000000 Binary files a/src/app/icon.ico and /dev/null differ diff --git a/src/components/olmap/SCADADataPanel.tsx b/src/components/olmap/SCADADataPanel.tsx new file mode 100644 index 0000000..094067a --- /dev/null +++ b/src/components/olmap/SCADADataPanel.tsx @@ -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; +} + +export interface SCADADataPanelProps { + /** 选中的设备 ID 列表 */ + deviceIds: string[]; + /** 自定义数据获取器,默认使用本地模拟数据 */ + fetchTimeSeriesData?: ( + deviceIds: string[], + range: { from: Date; to: Date } + ) => Promise; + /** 可选:为设备提供友好的显示名称 */ + deviceLabels?: Record; + /** 可选:控制浮窗显示 */ + 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>( + (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 => { + 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 = { + 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 = ({ + deviceIds, + fetchTimeSeriesData = defaultFetcher, + deviceLabels, + visible = true, + onClose, + defaultTab = "chart", + fractionDigits = 2, +}) => { + const [from, setFrom] = useState(() => dayjs().subtract(1, "day")); + const [to, setTo] = useState(() => dayjs()); + const [activeTab, setActiveTab] = useState(defaultTab); + const [timeSeries, setTimeSeries] = useState([]); + const [loadingState, setLoadingState] = useState("idle"); + const [error, setError] = useState(null); + const [isExpanded, setIsExpanded] = useState(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((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 ( + + + {message.title} + + {message.subtitle} + + ); + }; + + const chartSection = hasData ? ( + + 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 ? ( + + ) : ( + renderEmpty() + ); + + return ( + + {/* Header */} + + + + + + SCADA 历史数据 + + + + + + setIsExpanded(!isExpanded)} + sx={{ color: "primary.contrastText" }} + > + {isExpanded ? : } + + + + + + + + + + + + + {/* Controls */} + + + + + + value && dayjs.isDayjs(value) && setFrom(value) + } + maxDateTime={to} + slotProps={{ textField: { fullWidth: true, size: "small" } }} + /> + + value && dayjs.isDayjs(value) && setTo(value) + } + minDateTime={from} + slotProps={{ textField: { fullWidth: true, size: "small" } }} + /> + + + setActiveTab(value)} + variant="fullWidth" + > + } + iconPosition="start" + label="曲线" + /> + } + iconPosition="start" + label="表格" + /> + + + + + + + + + + + {!hasDevices && ( + + 未选择任何设备,无法获取数据。 + + )} + {error && ( + + 获取数据失败:{error} + + )} + + + + + {/* Content */} + + {loadingState === "loading" && ( + + + + )} + + {activeTab === "chart" ? chartSection : tableSection} + + + + ); +}; + +export default SCADADataPanel; diff --git a/src/components/olmap/SCADADeviceList.tsx b/src/components/olmap/SCADADeviceList.tsx new file mode 100644 index 0000000..ff6b31f --- /dev/null +++ b/src/components/olmap/SCADADeviceList.tsx @@ -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; +} + +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 = ({ + devices = [], + onDeviceClick, + onZoomToDevice, + multiSelect = true, + selectedDeviceIds, + onSelectionChange, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedType, setSelectedType] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState("all"); + const [isExpanded, setIsExpanded] = useState(true); + const [internalSelection, setInternalSelection] = useState([]); + + 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 ( + + {/* 头部控制栏 */} + + + + + + SCADA 设备列表 + + + + + + setIsExpanded(!isExpanded)} + sx={{ color: "white" }} + > + {isExpanded ? : } + + + + + + + + {/* 搜索和筛选栏 */} + + + {/* 搜索框 */} + + setSearchQuery(e.target.value)} + inputProps={{ "aria-label": "search devices" }} + /> + + + + {searchQuery && ( + <> + + + + + + )} + + + {/* 筛选器 */} + + + 设备类型 + + + + + 状态 + + + + + + + + + + + {/* 筛选结果统计 */} + + 共找到 {filteredDevices.length} 个设备 + {devices.length !== filteredDevices.length && + ` (共 ${devices.length} 个设备)`} + + + + + + + {/* 设备列表 */} + + {filteredDevices.length === 0 ? ( + + + + {searchQuery || + selectedType !== "all" || + selectedStatus !== "all" + ? "未找到匹配的设备" + : "暂无 SCADA 设备"} + + + ) : ( + + {filteredDevices.map((device, index) => ( + + + handleDeviceClick(device, event)} + sx={{ + "&.Mui-selected": { + backgroundColor: "primary.50", + borderLeft: 3, + borderColor: "primary.main", + }, + "&:hover": { + backgroundColor: "grey.50", + }, + }} + > + + + {getStatusIcon(device.status)} + + + + + + {device.name} + + + + } + secondary={ + + + ID: {device.id} + + + 坐标: {device.coordinates[0].toFixed(6)},{" "} + {device.coordinates[1].toFixed(6)} + + + } + /> + + + handleZoomToDevice(device, e)} + sx={{ + ml: 1, + color: "primary.main", + "&:hover": { + backgroundColor: "primary.50", + }, + }} + > + + + + + + {index < filteredDevices.length - 1 && ( + + )} + + ))} + + )} + + + + ); +}; + +export default SCADADeviceList; diff --git a/src/utils/breaks_classification.js b/src/utils/breaks_classification.js new file mode 100644 index 0000000..649281c --- /dev/null +++ b/src/utils/breaks_classification.js @@ -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 }; \ No newline at end of file