@ -0,0 +1,6 @@
|
|||||||
|
.gitignore
|
||||||
|
.gitlab-ci.yml
|
||||||
|
README.md
|
||||||
|
.git
|
||||||
|
docker-compose.yml
|
||||||
|
.env
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
# dist
|
||||||
|
# dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
handle {
|
||||||
|
root * /srv
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 376 KiB |
@ -0,0 +1 @@
|
|||||||
|
function s(r,i){for(var o=0;o<i.length;o++){const e=i[o];if(typeof e!="string"&&!Array.isArray(e)){for(const t in e)if(t!=="default"&&!(t in r)){const n=Object.getOwnPropertyDescriptor(e,t);n&&Object.defineProperty(r,t,n.get?n:{enumerable:!0,get:()=>e[t]})}}}return Object.freeze(Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}))}var a={},c=a.printMsg=function(){console.log("This is a message from the demo package")};const f=s({__proto__:null,printMsg:c,default:a},[a]);export{f as i};
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="/vite.svg" rel="icon"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DZKH by Spatial</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index.8efd437a.js"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/index.767ffdb3.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="/vite.svg" rel="icon"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DZKH by Spatial</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# error_page 500 502 503 504 /50x.html;
|
||||||
|
# location = /50x.html {
|
||||||
|
# root /usr/share/nginx/html;
|
||||||
|
# }
|
||||||
|
|
||||||
|
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
|
||||||
|
#
|
||||||
|
#location ~ \.php$ {
|
||||||
|
# proxy_pass http://127.0.0.1;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
|
||||||
|
#
|
||||||
|
#location ~ \.php$ {
|
||||||
|
# root html;
|
||||||
|
# fastcgi_pass 127.0.0.1:9000;
|
||||||
|
# fastcgi_index index.php;
|
||||||
|
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
|
||||||
|
# include fastcgi_params;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# deny access to .htaccess files, if Apache's document root
|
||||||
|
# concurs with nginx's one
|
||||||
|
#
|
||||||
|
#location ~ /\.ht {
|
||||||
|
# deny all;
|
||||||
|
#}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8888;
|
||||||
|
server_name _;
|
||||||
|
location /nginx-health {
|
||||||
|
return 200 "healthy\n";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "dzkh-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.0.1",
|
||||||
|
"@nanostores/react": "^0.4.1",
|
||||||
|
"@react-keycloak/web": "^3.4.0",
|
||||||
|
"@tanstack/react-query": "^4.24.9",
|
||||||
|
"@turf/bbox": "^6.5.0",
|
||||||
|
"@turf/helpers": "^6.5.0",
|
||||||
|
"@watergis/maplibre-gl-export": "^1.3.7",
|
||||||
|
"antd": "^4.23.6",
|
||||||
|
"axios": "^1.1.3",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"immer": "^9.0.19",
|
||||||
|
"immutable": "^4.3.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
|
"mapbox-gl": "npm:empty-npm-package@1.0.0",
|
||||||
|
"maplibre-gl": "^2.4.0",
|
||||||
|
"nanostores": "^0.7.3",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^4.8.0",
|
||||||
|
"react-map-gl": "^7.0.19",
|
||||||
|
"react-router-dom": "^6.8.1",
|
||||||
|
"scroll-into-view-if-needed": "^3.0.6",
|
||||||
|
"tailwind-merge": "^1.7.0",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"vite-plugin-svgr": "^2.4.0",
|
||||||
|
"wellknown": "^0.5.0",
|
||||||
|
"zustand": "^4.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.0.28",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"@vitejs/plugin-react": "^2.2.0",
|
||||||
|
"autoprefixer": "^10.4.13",
|
||||||
|
"less": "^4.1.3",
|
||||||
|
"postcss": "^8.4.18",
|
||||||
|
"simple-zustand-devtools": "^1.1.0",
|
||||||
|
"tailwindcss": "^3.2.1",
|
||||||
|
"vite": "^3.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,14 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||||
|
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import "./App.css";
|
||||||
|
import {BrowserRouter, Route, Routes} from "react-router-dom";
|
||||||
|
import {MapPage} from "./pages/Map";
|
||||||
|
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||||
|
import {enableMapSet} from "immer";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
|
||||||
|
if (import.meta.env.MODE === "development") {
|
||||||
|
// for debugging purpose
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<MapPage />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { Layer, Source } from "react-map-gl";
|
||||||
|
|
||||||
|
const greyMap =
|
||||||
|
"https://api.mapbox.com/styles/v1/ghermant/cla2nwk5f00el14nxvtjlsi6z/tiles/256/{z}/{x}/{y}@2x?access_token=pk.eyJ1IjoiZ2hlcm1hbnQiLCJhIjoiY2xhMm5zZ3ZrMDF4MDN2bzc5Yjd0ZjZ1dCJ9.fqnvrEqKKBoguR7R6DR7Yw";
|
||||||
|
// const colorMap =
|
||||||
|
// "https://api.mapbox.com/styles/v1/ghermant/cl9vd1nji002n15s2xxj25f4m/tiles/256/{z}/{x}/{y}@2x?access_token=pk.eyJ1IjoiZ2hlcm1hbnQiLCJhIjoiY2pncDUwcnRmNDQ4ZjJ4czdjZXMzaHZpNyJ9.3rFyYRRtvLUngHm027HZ7A";
|
||||||
|
//
|
||||||
|
// const positron =
|
||||||
|
// "https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}@2x.png";
|
||||||
|
|
||||||
|
export const Basemap = () => {
|
||||||
|
return (
|
||||||
|
<Source type={"raster"} id={"basemap"} tiles={[greyMap]} tileSize={256}>
|
||||||
|
<Layer
|
||||||
|
type={"raster"}
|
||||||
|
source={"basemap"}
|
||||||
|
id={"basemap-layer"}
|
||||||
|
paint={{}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import {Layer, Source} from "react-map-gl";
|
||||||
|
import {selectedRegionLayer} from "../../dzkh-features/Layers/layers-config.js";
|
||||||
|
import {useDzkhFilters} from "../../stores/useDzkhFilters.js";
|
||||||
|
|
||||||
|
const SelectedRegionLayer = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<Source id="selected-region" type="geojson" data={data}>
|
||||||
|
<Layer {...selectedRegionLayer} />
|
||||||
|
</Source>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectedRegion = () => {
|
||||||
|
const {
|
||||||
|
filters: { region },
|
||||||
|
} = useDzkhFilters();
|
||||||
|
|
||||||
|
if (region?.geometry) {
|
||||||
|
return <SelectedRegionLayer data={region.geometry} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
};
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
const REGION_FIELD_MAPPER = {
|
||||||
|
ao: "ao_id",
|
||||||
|
rayon: "rayon_id",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRegionFilterExpression = (region) => {
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
region
|
||||||
|
? ["==", ["get", REGION_FIELD_MAPPER[region.type]], region.id]
|
||||||
|
: null,
|
||||||
|
[region]
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import Map, {MapProvider} from "react-map-gl";
|
||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {Sidebar} from "../modules/Sidebar/Sidebar";
|
||||||
|
import {Layers} from "../dzkh-features/Layers/Layers.jsx";
|
||||||
|
import {MapPopup} from "./Popup/Popup";
|
||||||
|
import {Basemap} from "./Basemap";
|
||||||
|
import debounce from "lodash.debounce";
|
||||||
|
import {usePopup} from "../stores/usePopup";
|
||||||
|
import {useClickedPointConfig} from "../stores/useClickedPointConfig";
|
||||||
|
import {Legend} from "../dzkh-features/Legend.jsx";
|
||||||
|
import {TableWrapper} from "../modules/Table/TableWrapper";
|
||||||
|
import {SidebarControl} from "./SidebarControl";
|
||||||
|
import {useTable} from "../stores/useTable";
|
||||||
|
import {twMerge} from "tailwind-merge";
|
||||||
|
import {ImportDataModal} from "../dzkh-features/ImportDataModal.jsx";
|
||||||
|
import {ImportDataButton} from "../dzkh-features/ImportDataButton.jsx";
|
||||||
|
import {WeatherSlider} from "../dzkh-features/WeatherSlider/WeatherSlider.jsx";
|
||||||
|
import {LAYER_IDS} from "../dzkh-features/constants.js";
|
||||||
|
import {AccidentSimulationResults} from "../dzkh-features/AccidentSimulationResults.jsx";
|
||||||
|
import { icons } from "../icons/icons-config.js";
|
||||||
|
|
||||||
|
export const MapComponent = () => {
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const mapContainerRef = useRef(null);
|
||||||
|
const sidebarRef = useRef(null);
|
||||||
|
const { popup, setPopup } = usePopup();
|
||||||
|
const { setClickedPointConfig } = useClickedPointConfig();
|
||||||
|
const { tableState, openTable } = useTable();
|
||||||
|
|
||||||
|
const handleClick = (event) => {
|
||||||
|
if (!event.features) {
|
||||||
|
setPopup(null);
|
||||||
|
setClickedPointConfig(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feature = event.features[0];
|
||||||
|
if (!feature) {
|
||||||
|
setPopup(null);
|
||||||
|
setClickedPointConfig(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lng: pointLng } = event.lngLat;
|
||||||
|
|
||||||
|
if (feature.geometry.type === "Point") {
|
||||||
|
const coordinates = feature.geometry.coordinates.slice();
|
||||||
|
while (Math.abs(pointLng - coordinates[0]) > 180) {
|
||||||
|
coordinates[0] += pointLng > coordinates[0] ? 360 : -360;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPopup({ features: event.features, coordinates });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = (event) => {
|
||||||
|
const feature = event.features[0];
|
||||||
|
if (!feature) return;
|
||||||
|
|
||||||
|
mapRef.current.getCanvas().style.cursor = "pointer";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = (event) => {
|
||||||
|
const feature = event.features[0];
|
||||||
|
if (!feature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mapRef.current.getCanvas().style.cursor = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(
|
||||||
|
debounce(() => {
|
||||||
|
mapRef?.current?.resize();
|
||||||
|
}, 16)
|
||||||
|
);
|
||||||
|
if (mapContainerRef.current) {
|
||||||
|
resizeObserver.observe(mapContainerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [mapContainerRef.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tableState.fullScreen && !tableState.isOpened) {
|
||||||
|
openTable();
|
||||||
|
}
|
||||||
|
}, [tableState.fullScreen]);
|
||||||
|
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const toggleCollapseSidebar = () =>
|
||||||
|
setIsSidebarCollapsed((prevState) => !prevState);
|
||||||
|
|
||||||
|
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
icons.map((icon) => {
|
||||||
|
const img = new Image(
|
||||||
|
icon.size?.width || 64,
|
||||||
|
icon.size?.height || 64
|
||||||
|
);
|
||||||
|
img.src = icon.url;
|
||||||
|
img.crossOrigin = "Anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
mapRef.current.addImage(icon.name, img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [icons]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapProvider>
|
||||||
|
<div className={"w-screen h-screen relative flex overflow-hidden"}>
|
||||||
|
<Sidebar isCollapsed={isSidebarCollapsed} ref={sidebarRef} />
|
||||||
|
<div className="flex-1 h-screen flex flex-col ">
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
className={twMerge(tableState.fullScreen ? "" : "flex-1")}
|
||||||
|
>
|
||||||
|
<Map
|
||||||
|
mapLib={maplibregl}
|
||||||
|
initialViewState={{
|
||||||
|
latitude: 55.7558,
|
||||||
|
longitude: 37.6173,
|
||||||
|
zoom: 12,
|
||||||
|
}}
|
||||||
|
dragRotate={false}
|
||||||
|
ref={mapRef}
|
||||||
|
interactiveLayerIds={[
|
||||||
|
LAYER_IDS.consumer,
|
||||||
|
LAYER_IDS.source,
|
||||||
|
LAYER_IDS.dispatcher,
|
||||||
|
]}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
id="map"
|
||||||
|
>
|
||||||
|
{popup && (
|
||||||
|
<MapPopup
|
||||||
|
lat={popup.coordinates[1]}
|
||||||
|
lng={popup.coordinates[0]}
|
||||||
|
features={popup.features}
|
||||||
|
onClose={() => {
|
||||||
|
setPopup(null);
|
||||||
|
setClickedPointConfig(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ImportDataButton onClick={() => setIsImportOpen(true)}/>
|
||||||
|
|
||||||
|
<SidebarControl toggleCollapse={toggleCollapseSidebar} />
|
||||||
|
<WeatherSlider />
|
||||||
|
|
||||||
|
<Basemap />
|
||||||
|
<Layers />
|
||||||
|
|
||||||
|
<Legend />
|
||||||
|
|
||||||
|
<AccidentSimulationResults />
|
||||||
|
|
||||||
|
{isImportOpen && <ImportDataModal onClose={() => setIsImportOpen(false)} />}
|
||||||
|
</Map>
|
||||||
|
</div>
|
||||||
|
<div className="w-full border-solid border-border border-0 border-t-[1px] z-20">
|
||||||
|
<TableWrapper fullWidth={isSidebarCollapsed} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MapProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import {commonPopupConfig,} from "./config";
|
||||||
|
import {Button, Col, Row} from "antd";
|
||||||
|
import {isNil} from "../../../utils.js";
|
||||||
|
import {useGetRegions} from "../../../components/RegionSelect.jsx";
|
||||||
|
import {useSimulateAccident} from "../../../stores/useSimulateAccident.js";
|
||||||
|
import {useWeatherFilter} from "../../../dzkh-features/WeatherSlider/useWeatherFilter.js";
|
||||||
|
import {tempMapper} from "../../../config.js";
|
||||||
|
|
||||||
|
const dynamicFields = [
|
||||||
|
{
|
||||||
|
field: 'prob1',
|
||||||
|
name: 'Отсутствие отопления в доме'
|
||||||
|
},{
|
||||||
|
field: 'prob2',
|
||||||
|
name: 'Протечка труб в подъезде'
|
||||||
|
},{
|
||||||
|
field: 'prob3',
|
||||||
|
name: 'Температура в квартире ниже нормативной'
|
||||||
|
},{
|
||||||
|
field: 'prob4',
|
||||||
|
name: 'Температура в помещении общего пользования ниже нормативной'
|
||||||
|
},{
|
||||||
|
field: 'prob5',
|
||||||
|
name: 'Течь в системе отопления'
|
||||||
|
},{
|
||||||
|
field: 'cooling_time',
|
||||||
|
name: 'Время остывания (часы)'
|
||||||
|
},{
|
||||||
|
field: 'priority',
|
||||||
|
name: 'Приоритет здания'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FeatureProperties = ({ feature }) => {
|
||||||
|
const { data } = useGetRegions();
|
||||||
|
const { setSelectedSourceConfig } = useSimulateAccident();
|
||||||
|
const { value: temperature } = useWeatherFilter();
|
||||||
|
|
||||||
|
const isSourcePoint = feature.properties.point_type === 'tp'
|
||||||
|
const isDispatcherPoint = feature.properties.point_type === 'ods'
|
||||||
|
|
||||||
|
const getConfig = () => {
|
||||||
|
if (isDispatcherPoint) return commonPopupConfig
|
||||||
|
|
||||||
|
if (isSourcePoint) {
|
||||||
|
return [
|
||||||
|
...commonPopupConfig, {field: `prob6_${tempMapper[temperature]}`, name: 'Авария на ТП'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalFields = dynamicFields.map((item) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
field: `${item.field}_${tempMapper[temperature]}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [...commonPopupConfig, ...additionalFields];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValue = ({ field, render, empty, type, fallbackField }) => {
|
||||||
|
let value = feature.properties[field];
|
||||||
|
|
||||||
|
if (type === "region") {
|
||||||
|
value = value ? value : feature[fallbackField];
|
||||||
|
value = render(value, data?.normalized);
|
||||||
|
} else {
|
||||||
|
value = render ? render(value) : value;
|
||||||
|
value = isNil(value) && empty ? empty : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulateAccident = () => setSelectedSourceConfig({
|
||||||
|
pointId: feature.properties.id,
|
||||||
|
sourceId: feature.properties.tp_number,
|
||||||
|
dispatcherNumber: feature.properties.ods_number
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{getConfig().map((row) => {
|
||||||
|
return (
|
||||||
|
<Row className={"p-1"} key={row.field}>
|
||||||
|
<Col className={"font-semibold"} span={12}>
|
||||||
|
{row.name}
|
||||||
|
</Col>
|
||||||
|
<Col span={12} className={'text-right'}>{getValue(row)}</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{isSourcePoint ?
|
||||||
|
<div className={'mt-3 text-center'}>
|
||||||
|
<Button type={'primary'} onClick={simulateAccident}>Смоделировать аварию</Button>
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
export const getRegionNameById = (id, normalizedRegions) =>
|
||||||
|
normalizedRegions?.[id]?.name ?? id;
|
||||||
|
|
||||||
|
export const commonPopupConfig = [
|
||||||
|
{
|
||||||
|
name: "Адрес",
|
||||||
|
field: "building_address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Номер ТП",
|
||||||
|
field: "tp_number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Номер ОДС",
|
||||||
|
field: "ods_number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Телефон ОДС",
|
||||||
|
field: "phone_number",
|
||||||
|
}
|
||||||
|
];
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import {Button} from "antd";
|
||||||
|
import {PopupWrapper} from "./PopupWrapper";
|
||||||
|
import {FeatureProperties} from "./FeatureProperties/FeatureProperties";
|
||||||
|
import {usePopup} from "../../stores/usePopup.js";
|
||||||
|
import { ChartModal } from "../../dzkh-features/Chart/ChartModal.jsx";
|
||||||
|
|
||||||
|
const SingleFeaturePopup = ({ feature }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex-col gap-2">
|
||||||
|
{feature.properties.point_type === "potreb" && <ChartModal point={feature.properties}/>}
|
||||||
|
<FeatureProperties feature={feature} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeMapper = {
|
||||||
|
potreb: 'Потребитель',
|
||||||
|
tp: 'Источник',
|
||||||
|
ods: 'Диспетчерская'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultipleFeaturesPopup = ({ features }) => {
|
||||||
|
const { setPopup } = usePopup();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-1">
|
||||||
|
{features.map((feature) => {
|
||||||
|
const featureId = feature.properties.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center gap-2 w-full" key={featureId}>
|
||||||
|
<Button
|
||||||
|
className="text-start flex-1 !w-0"
|
||||||
|
block
|
||||||
|
onClick={() => {
|
||||||
|
setPopup({
|
||||||
|
features: [feature],
|
||||||
|
coordinates: feature.geometry.coordinates,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-x-2 flex items-center w-full">
|
||||||
|
<span className="flex-1 truncate inline-block">
|
||||||
|
{typeMapper[feature.properties.point_type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MapPopup = ({features, lat, lng, onClose}) => {
|
||||||
|
const getContent = () => {
|
||||||
|
if (features.length === 1) {
|
||||||
|
return <SingleFeaturePopup feature={features[0]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MultipleFeaturesPopup features={features} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopupWrapper lat={lat} lng={lng} onClose={onClose}>
|
||||||
|
{getContent()}
|
||||||
|
</PopupWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { Popup } from "react-map-gl";
|
||||||
|
|
||||||
|
export const PopupWrapper = ({ lat, lng, onClose, children }) => {
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
longitude={lng}
|
||||||
|
latitude={lat}
|
||||||
|
onClose={onClose}
|
||||||
|
closeOnClick={false}
|
||||||
|
style={{ minWidth: "330px" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { Button } from "antd";
|
||||||
|
import { FiltersIcon } from "../icons/FiltersIcon";
|
||||||
|
|
||||||
|
export const SidebarControl = ({ toggleCollapse }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
icon={<FiltersIcon width={16} height={16} />}
|
||||||
|
onClick={toggleCollapse}
|
||||||
|
className="border-l-0 rounded-bl-none rounded-tl-none absolute top-[100px] flex items-center justify-center"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL:
|
||||||
|
import.meta.env.MODE === "development"
|
||||||
|
? "http://localhost:5173/"
|
||||||
|
: BASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichParamsWithRegionFilter = (params, region) => {
|
||||||
|
const resultParams = params ? params : new URLSearchParams();
|
||||||
|
|
||||||
|
if (region) {
|
||||||
|
if (region.type === "ao") {
|
||||||
|
resultParams.append("district[]", region.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (region.type === "rayon") {
|
||||||
|
resultParams.append("rayon", region.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPoints = async (params, region, signal) => {
|
||||||
|
const resultParams = enrichParamsWithRegionFilter(params, region);
|
||||||
|
|
||||||
|
const { data } = await api.get(
|
||||||
|
`/api/data/?${resultParams.toString()}`, {signal}
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportPoints = async (params, region) => {
|
||||||
|
const resultParams = enrichParamsWithRegionFilter(params, region);
|
||||||
|
|
||||||
|
const { data } = await api.get(
|
||||||
|
`/api/data/to_csv/?${resultParams.toString()}`,
|
||||||
|
{ responseType: "arraybuffer" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadImportTemplate = async (dataType) => {
|
||||||
|
const { data } = await api.get(
|
||||||
|
`/default_data/templates/${dataType}.xlsx`,
|
||||||
|
{ responseType: "arraybuffer" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadPointsFile = async (file, dataType, refill = false,) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("model", dataType);
|
||||||
|
formData.append("refill", refill);
|
||||||
|
const { data } = await api.post(
|
||||||
|
`/api/import_file/`,
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startAnalysis = async () => {
|
||||||
|
const { data } = await api.get(`/api/data/start_ds_miracle`);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 376 KiB |
@ -0,0 +1,9 @@
|
|||||||
|
import { Button } from "antd";
|
||||||
|
|
||||||
|
export const ClearFiltersButton = ({ onClick, disabled }) => {
|
||||||
|
return (
|
||||||
|
<Button block className={"mt-2"} onClick={onClick} disabled={disabled}>
|
||||||
|
Сбросить фильтры
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import { Title } from "./Title";
|
||||||
|
import { Slider } from "antd";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const Mark = ({ value }) => {
|
||||||
|
return (
|
||||||
|
<span className={"text-grey text-xs bg-white-background-light"}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitialMarks = (fullRange, value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const [min, max] = value;
|
||||||
|
return {
|
||||||
|
...fullRange,
|
||||||
|
[min]: <Mark value={min} />,
|
||||||
|
[max]: <Mark value={max} />,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...fullRange,
|
||||||
|
[value]: <Mark value={value} />,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SliderComponent = ({
|
||||||
|
title,
|
||||||
|
value: initialValue,
|
||||||
|
onChange,
|
||||||
|
onAfterChange,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
range = false,
|
||||||
|
step = 1,
|
||||||
|
disabled = false,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
showZeroMark = false,
|
||||||
|
}) => {
|
||||||
|
const fullRangeMarks = {
|
||||||
|
[min]: <Mark value={min} />,
|
||||||
|
[max]: <Mark value={max} />,
|
||||||
|
};
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [marks, setMarks] = useState(
|
||||||
|
getInitialMarks(fullRangeMarks, initialValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initialValue);
|
||||||
|
setMarks(getInitialMarks(fullRangeMarks, initialValue));
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleAfterChange = (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const [min, max] = value;
|
||||||
|
setMarks({
|
||||||
|
...fullRangeMarks,
|
||||||
|
[min]: <Mark value={min} />,
|
||||||
|
[max]: <Mark value={max} />,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setMarks({
|
||||||
|
...fullRangeMarks,
|
||||||
|
[value]: <Mark value={value} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onAfterChange?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (value) => {
|
||||||
|
setValue(value);
|
||||||
|
onChange?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalMarks = showZeroMark ? { ...marks, 0: <Mark value={0} /> } : marks;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||||
|
<Title text={title} />
|
||||||
|
<Slider
|
||||||
|
range={range}
|
||||||
|
value={value}
|
||||||
|
marks={finalMarks}
|
||||||
|
onChange={handleChange}
|
||||||
|
onAfterChange={handleAfterChange}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { Typography } from "antd";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export const Title = ({ text, className, classNameText, type = "secondary" }) => {
|
||||||
|
return (
|
||||||
|
<div className={twMerge("mb-1", className)}>
|
||||||
|
<Text
|
||||||
|
type={type}
|
||||||
|
className={classNameText}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
export const CONSUMER_OPTIONS = [
|
||||||
|
{
|
||||||
|
value: "category1",
|
||||||
|
title: "Прогнозирование",
|
||||||
|
selectable: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
value: "prob1",
|
||||||
|
title: "Отсутствие отопления в доме",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "prob2",
|
||||||
|
title: "Протечка труб в подъезде",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "prob3",
|
||||||
|
title: "Температура в квартире ниже нормативной",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "prob4",
|
||||||
|
title: "Температура в помещении общего пользования ниже нормативной",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "prob5",
|
||||||
|
title: "Течь в системе отопления",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "category2",
|
||||||
|
title: "Реагирование",
|
||||||
|
selectable: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
value: "cooling_time",
|
||||||
|
title: "Время остывания, ч",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "priority",
|
||||||
|
title: "Приоритет здания",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const getTempMapper = () => {
|
||||||
|
const result = {}
|
||||||
|
let index = 0
|
||||||
|
for (let temp = -25; temp < 25; temp++) {
|
||||||
|
result[temp] = index
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tempMapper = getTempMapper()
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { Button, Col, Divider, Modal, Popover, Row, Tooltip } from "antd";
|
||||||
|
import {useSimulateAccident} from "../stores/useSimulateAccident.js";
|
||||||
|
import {Title} from "../components/Title.jsx";
|
||||||
|
import { AccidentSimulationModal } from "./AccidentSimulation/AccidentSimulationModal.jsx";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BiInfoCircle } from "react-icons/all.js";
|
||||||
|
|
||||||
|
export const AccidentSimulationResults = () => {
|
||||||
|
const {selectedSourceConfig, setSelectedSourceConfig} = useSimulateAccident();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
if (!selectedSourceConfig.pointId) return "";
|
||||||
|
|
||||||
|
const {pointId} = selectedSourceConfig;
|
||||||
|
|
||||||
|
const stopSimulation = () => setSelectedSourceConfig({sourceId: null, dispatcherNumber: null})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='absolute bottom-[20px] left-[20px] text-grey z-10 bg-white-background rounded-xl p-3 text-center'>
|
||||||
|
<Title text={`Результаты моделирования аварии на ТП ${selectedSourceConfig?.sourceId}`}/>
|
||||||
|
<div className="flex justify-center flex-row gap-2">
|
||||||
|
<Button type="primary" onClick={stopSimulation}>Отменить</Button>
|
||||||
|
{!!pointId && (
|
||||||
|
<Popover content={<AccidentSimulationModal id={pointId} />}>
|
||||||
|
<Button className="flex justify-center items-center" type="primary" onClick={() => setModalOpen(true)}>
|
||||||
|
<BiInfoCircle className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{!!pointId && <AccidentSimulationModal id={pointId} modalOpen={modalOpen} setModalOpen={setModalOpen} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import {Button, Tooltip} from "antd";
|
||||||
|
import {UploadOutlined} from "@ant-design/icons";
|
||||||
|
|
||||||
|
export const ImportDataButton = ({onClick}) => {
|
||||||
|
return <div className='absolute top-[20px] left-[20px]'>
|
||||||
|
<Tooltip title='Импорт данных' placement='right'>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
import {Layer} from "react-map-gl";
|
||||||
|
import {useLayersVisibility} from "../../stores/useLayersVisibility.js";
|
||||||
|
import {useRegionFilterExpression} from "../../Map/Layers/useRegionFilterExpression.js";
|
||||||
|
import { CONSUMER_COLOR, CONSUMER_COLOR_COOLING, consumerPointLayer } from "./layers-config.js";
|
||||||
|
import {LAYER_IDS} from "../constants.js";
|
||||||
|
import {useDzkhFilters} from "../../stores/useDzkhFilters.js";
|
||||||
|
import { useConsumerLayerFilterExpression } from "./useConsumerLayerFilterExpression.js";
|
||||||
|
import {useSimulateAccident} from "../../stores/useSimulateAccident.js";
|
||||||
|
import {useWeatherFilter} from "../WeatherSlider/useWeatherFilter.js";
|
||||||
|
import {tempMapper} from "../../config.js";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useGetDzkhFiltersRange } from "../api/filters.js";
|
||||||
|
|
||||||
|
const typeExpression = ["==", ["get", "point_type"], LAYER_IDS.consumer];
|
||||||
|
|
||||||
|
export const ConsumerLayer = () => {
|
||||||
|
const { isVisible } = useLayersVisibility();
|
||||||
|
const {
|
||||||
|
filters: { region },
|
||||||
|
consumer_filter
|
||||||
|
} = useDzkhFilters();
|
||||||
|
const regionFilterExpression = useRegionFilterExpression(region);
|
||||||
|
const filterExpression = useConsumerLayerFilterExpression();
|
||||||
|
const { selectedSourceConfig } = useSimulateAccident();
|
||||||
|
const { value: temp } = useWeatherFilter();
|
||||||
|
|
||||||
|
const key = useMemo(() => {
|
||||||
|
return consumer_filter?.key;
|
||||||
|
}, [consumer_filter?.key]);
|
||||||
|
|
||||||
|
const filterKey = useMemo(() => {
|
||||||
|
if (!!key) return `${key}_${tempMapper[temp]}`;
|
||||||
|
return undefined;
|
||||||
|
}, [key, temp]);
|
||||||
|
|
||||||
|
const {data, isLoading: isRangeLoading} = useGetDzkhFiltersRange(filterKey);
|
||||||
|
|
||||||
|
const range = useMemo(() => {
|
||||||
|
return data?.range || [0, 0]
|
||||||
|
}, [data?.range]);
|
||||||
|
|
||||||
|
const getFilter = () => {
|
||||||
|
const result = ["all", typeExpression, ...filterExpression];
|
||||||
|
if (regionFilterExpression) {
|
||||||
|
result.push(regionFilterExpression)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSourceConfig.sourceId) {
|
||||||
|
result.push(["==", ["get", "tp_number"], selectedSourceConfig.sourceId])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
};
|
||||||
|
|
||||||
|
const propToColor = consumer_filter?.key ? `${consumer_filter.key}_${tempMapper[temp]}` : null;
|
||||||
|
|
||||||
|
const isCooling = useMemo(() => {
|
||||||
|
return consumer_filter && consumer_filter.key.includes('cooling')
|
||||||
|
}, [consumer_filter]);
|
||||||
|
|
||||||
|
const colorStops = useMemo(() => {
|
||||||
|
const min = range[0];
|
||||||
|
const max = range[1];
|
||||||
|
const step = (max - min) / 8;
|
||||||
|
if (isCooling) return [].concat(CONSUMER_COLOR.stops).reverse().map((c, i) => {
|
||||||
|
return [min + i * step, c[1]];
|
||||||
|
})
|
||||||
|
return CONSUMER_COLOR.stops.map((c, i) => {
|
||||||
|
return [min + i * step, c[1]];
|
||||||
|
})
|
||||||
|
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
const paintConfig = propToColor ? {
|
||||||
|
...consumerPointLayer.paint,
|
||||||
|
"circle-color": {
|
||||||
|
property: propToColor,
|
||||||
|
stops: colorStops
|
||||||
|
}
|
||||||
|
} : consumerPointLayer.paint
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layer
|
||||||
|
{...consumerPointLayer}
|
||||||
|
id={LAYER_IDS.consumer}
|
||||||
|
source={"points"}
|
||||||
|
source-layer={"public.data"}
|
||||||
|
layout={{
|
||||||
|
visibility: isVisible[LAYER_IDS.consumer] ? "visible" : "none",
|
||||||
|
}}
|
||||||
|
filter={getFilter()}
|
||||||
|
paint={paintConfig}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import {Layer} from "react-map-gl";
|
||||||
|
import {useLayersVisibility} from "../../stores/useLayersVisibility.js";
|
||||||
|
import {useRegionFilterExpression} from "../../Map/Layers/useRegionFilterExpression.js";
|
||||||
|
import { dispatcherPointLayer, getPointSymbolLayer } from "./layers-config.js";
|
||||||
|
import {LAYER_IDS} from "../constants.js";
|
||||||
|
import {useDzkhFilters} from "../../stores/useDzkhFilters.js";
|
||||||
|
import {useSimulateAccident} from "../../stores/useSimulateAccident.js";
|
||||||
|
|
||||||
|
const typeExpression = ["==", ["get", "point_type"], LAYER_IDS.dispatcher];
|
||||||
|
|
||||||
|
export const DispatcherLayer = () => {
|
||||||
|
const { isVisible } = useLayersVisibility();
|
||||||
|
const {
|
||||||
|
filters: { region },
|
||||||
|
} = useDzkhFilters();
|
||||||
|
const regionFilterExpression = useRegionFilterExpression(region);
|
||||||
|
const { selectedSourceConfig } = useSimulateAccident()
|
||||||
|
|
||||||
|
const getFilter = () => {
|
||||||
|
const result = ["all", typeExpression];
|
||||||
|
if (regionFilterExpression) {
|
||||||
|
result.push(regionFilterExpression)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSourceConfig.sourceId) {
|
||||||
|
result.push(["==", ["get", "ods_number"], selectedSourceConfig.dispatcherNumber])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layer
|
||||||
|
type={getPointSymbolLayer("dispatcherIcon").type}
|
||||||
|
id={LAYER_IDS.dispatcher}
|
||||||
|
source={"points"}
|
||||||
|
source-layer={"public.data"}
|
||||||
|
layout={{
|
||||||
|
...getPointSymbolLayer("dispatcherIcon").layout,
|
||||||
|
visibility: isVisible[LAYER_IDS.dispatcher] ? "visible" : "none",
|
||||||
|
}}
|
||||||
|
filter={getFilter()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import {Points} from "./Points.jsx";
|
||||||
|
import {Layer, Source} from "react-map-gl";
|
||||||
|
import {aoLayer, rayonLayer} from "./layers-config.js";
|
||||||
|
import {BASE_URL} from "../../api.js";
|
||||||
|
import {SelectedRegion} from "../../Map/Layers/SelectedRegion.jsx";
|
||||||
|
|
||||||
|
export const Layers = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Source
|
||||||
|
id="ao"
|
||||||
|
type="vector"
|
||||||
|
tiles={[`${BASE_URL}/martin/public.service_ao/{z}/{x}/{y}.pbf`]}
|
||||||
|
>
|
||||||
|
<Layer
|
||||||
|
{...aoLayer}
|
||||||
|
layout={{
|
||||||
|
...aoLayer.layout,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
|
||||||
|
<Source
|
||||||
|
id="rayon"
|
||||||
|
type="vector"
|
||||||
|
tiles={[`${BASE_URL}/martin/public.service_rayon/{z}/{x}/{y}.pbf`]}
|
||||||
|
>
|
||||||
|
<Layer
|
||||||
|
{...rayonLayer}
|
||||||
|
layout={{
|
||||||
|
...rayonLayer.layout,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
|
||||||
|
<SelectedRegion />
|
||||||
|
|
||||||
|
<Points />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { Source } from "react-map-gl";
|
||||||
|
import { BASE_URL } from "../../api.js";
|
||||||
|
import { useUpdateLayerCounter } from "../../stores/useUpdateLayerCounter.js";
|
||||||
|
import { ConsumerLayer } from "./ConsumerLayer.jsx";
|
||||||
|
import { DispatcherLayer } from "./DispatcherLayer.jsx";
|
||||||
|
import { SourceLayer } from "./SourceLayer.jsx";
|
||||||
|
|
||||||
|
export const Points = () => {
|
||||||
|
const { updateCounter } = useUpdateLayerCounter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Source
|
||||||
|
id="points"
|
||||||
|
type="vector"
|
||||||
|
key={`points-${updateCounter}`}
|
||||||
|
tiles={[
|
||||||
|
`${BASE_URL}/martin/public.data/{z}/{x}/{y}.pbf`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SourceLayer />
|
||||||
|
<ConsumerLayer />
|
||||||
|
<DispatcherLayer />
|
||||||
|
</Source>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import {Layer} from "react-map-gl";
|
||||||
|
import {useLayersVisibility} from "../../stores/useLayersVisibility.js";
|
||||||
|
import {useRegionFilterExpression} from "../../Map/Layers/useRegionFilterExpression.js";
|
||||||
|
import {SOURCE_COLOR, sourcePointLayer} from "./layers-config.js";
|
||||||
|
import {LAYER_IDS} from "../constants.js";
|
||||||
|
import {useDzkhFilters} from "../../stores/useDzkhFilters.js";
|
||||||
|
import {useSourceLayerFilterExpression} from "./useSourceLayerFilterExpression.js";
|
||||||
|
import {useSimulateAccident} from "../../stores/useSimulateAccident.js";
|
||||||
|
import {tempMapper} from "../../config.js";
|
||||||
|
import {useWeatherFilter} from "../WeatherSlider/useWeatherFilter.js";
|
||||||
|
|
||||||
|
const typeExpression = ["==", ["get", "point_type"], LAYER_IDS.source];
|
||||||
|
|
||||||
|
export const SourceLayer = () => {
|
||||||
|
const { isVisible } = useLayersVisibility();
|
||||||
|
const {
|
||||||
|
filters: { region },
|
||||||
|
source_filter
|
||||||
|
} = useDzkhFilters();
|
||||||
|
const regionFilterExpression = useRegionFilterExpression(region);
|
||||||
|
const filterExpression = useSourceLayerFilterExpression();
|
||||||
|
const { selectedSourceConfig } = useSimulateAccident()
|
||||||
|
const { value: temp } = useWeatherFilter()
|
||||||
|
|
||||||
|
const getFilter = () => {
|
||||||
|
const result = ["all", typeExpression, ...filterExpression];
|
||||||
|
if (regionFilterExpression) {
|
||||||
|
result.push(regionFilterExpression)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSourceConfig.sourceId) {
|
||||||
|
result.push(["==", ["get", "tp_number"], selectedSourceConfig.sourceId])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
};
|
||||||
|
|
||||||
|
const propToColor = source_filter?.key ? `${source_filter.key}_${tempMapper[temp]}` : null;
|
||||||
|
const paintConfig = propToColor ? {
|
||||||
|
...sourcePointLayer.paint,
|
||||||
|
"circle-color": {
|
||||||
|
property: propToColor,
|
||||||
|
stops: SOURCE_COLOR.stops
|
||||||
|
}
|
||||||
|
} : sourcePointLayer.paint
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layer
|
||||||
|
{...sourcePointLayer}
|
||||||
|
id={LAYER_IDS.source}
|
||||||
|
source={"points"}
|
||||||
|
source-layer={"public.data"}
|
||||||
|
layout={{
|
||||||
|
visibility: isVisible[LAYER_IDS.source] ? "visible" : "none",
|
||||||
|
}}
|
||||||
|
filter={getFilter()}
|
||||||
|
paint={paintConfig}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
const POINT_SIZE = 6;
|
||||||
|
|
||||||
|
export const CONSUMER_COLOR = {
|
||||||
|
stops: [
|
||||||
|
[0.05, "#fff700"],
|
||||||
|
[0.20, "#ffda00"],
|
||||||
|
[0.35, "#ffbc00"],
|
||||||
|
[0.5, "#ff9d00"],
|
||||||
|
[0.65, "#ff7a00"],
|
||||||
|
[0.8, "#ff5200"],
|
||||||
|
[0.95, "#ff0000"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONSUMER_COLOR_COOLING = {
|
||||||
|
stops: [
|
||||||
|
[2.8, "#ff0000"],
|
||||||
|
[3.7, "#ff5200"],
|
||||||
|
[4.6, "#ff7a00"],
|
||||||
|
[5.5, "#ff9d00"],
|
||||||
|
[6.4, "#ffbc00"],
|
||||||
|
[7.3, "#ffda00"],
|
||||||
|
[8.2, "#fff700"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const DISPATCHER_COLOR = "#078500";
|
||||||
|
export const SOURCE_COLOR = {
|
||||||
|
stops: [
|
||||||
|
[0.05, "#fd99ff"],
|
||||||
|
[0.20, "#da7ee1"],
|
||||||
|
[0.35, "#b964c3"],
|
||||||
|
[0.5, "#984ba6"],
|
||||||
|
[0.65, "#793389"],
|
||||||
|
[0.8, "#5a1a6e"],
|
||||||
|
[0.95, "#3d0053"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_POINT_CONFIG = {
|
||||||
|
type: "circle",
|
||||||
|
paint: {
|
||||||
|
"circle-stroke-width": 0.1,
|
||||||
|
"circle-stroke-color": "#262626",
|
||||||
|
"circle-opacity": 0.8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPointConfig = (color = 'grey', size = POINT_SIZE) => {
|
||||||
|
return {
|
||||||
|
...DEFAULT_POINT_CONFIG,
|
||||||
|
paint: {
|
||||||
|
...DEFAULT_POINT_CONFIG.paint,
|
||||||
|
"circle-color": color,
|
||||||
|
"circle-radius": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
1,
|
||||||
|
13,
|
||||||
|
size,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPointSymbolLayer = (image) => {
|
||||||
|
return {
|
||||||
|
type: "symbol",
|
||||||
|
layout: {
|
||||||
|
"icon-image": [
|
||||||
|
'coalesce',
|
||||||
|
['image', image],
|
||||||
|
['image', 'defaultIcon']
|
||||||
|
],
|
||||||
|
"icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0, 9, 0.1, 13, 0.5],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const consumerPointLayer = {
|
||||||
|
...getPointConfig(),
|
||||||
|
paint: {
|
||||||
|
...getPointConfig().paint,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dispatcherPointLayer = {
|
||||||
|
...getPointSymbolLayer("dispatcherIcon"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sourcePointLayer = {
|
||||||
|
...getPointConfig('grey', 10),
|
||||||
|
paint: {
|
||||||
|
...getPointConfig('grey', 10).paint,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const regionColor = "#676767";
|
||||||
|
|
||||||
|
export const aoLayer = {
|
||||||
|
id: "ao",
|
||||||
|
type: "line",
|
||||||
|
source: "ao",
|
||||||
|
"source-layer": "public.service_ao",
|
||||||
|
layout: {
|
||||||
|
"line-join": "round",
|
||||||
|
"line-cap": "round",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"line-color": regionColor,
|
||||||
|
"line-width": 1.5,
|
||||||
|
"line-opacity": 0.8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rayonLayer = {
|
||||||
|
id: "rayon",
|
||||||
|
type: "line",
|
||||||
|
source: "rayon",
|
||||||
|
"source-layer": "public.service_rayon",
|
||||||
|
layout: {
|
||||||
|
"line-join": "round",
|
||||||
|
"line-cap": "round",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"line-color": regionColor,
|
||||||
|
"line-width": 0.5,
|
||||||
|
"line-opacity": 0.8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectedRegionLayer = {
|
||||||
|
id: "selected-region",
|
||||||
|
type: "line",
|
||||||
|
source: "selected-region",
|
||||||
|
layout: {
|
||||||
|
"line-join": "round",
|
||||||
|
"line-cap": "round",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"line-color": "#CC2222",
|
||||||
|
"line-width": 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { useDzkhFilters } from "../../stores/useDzkhFilters.js";
|
||||||
|
import { useWeatherFilter } from "../WeatherSlider/useWeatherFilter.js";
|
||||||
|
import { tempMapper } from "../../config.js";
|
||||||
|
|
||||||
|
export const useConsumerLayerFilterExpression = () => {
|
||||||
|
const {consumer_filter, consumer_type, energy_class, wall_material} = useDzkhFilters();
|
||||||
|
const {value: temperature} = useWeatherFilter();
|
||||||
|
const t = tempMapper[temperature]
|
||||||
|
const consumerFilterExpression = () => {
|
||||||
|
const res = [];
|
||||||
|
if (consumer_filter) {
|
||||||
|
const key = `${consumer_filter.key}_${t}`;
|
||||||
|
|
||||||
|
res.push(
|
||||||
|
...[
|
||||||
|
[">=", ["get", key], consumer_filter.gt],
|
||||||
|
["<=", ["get", key], consumer_filter.lt],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (consumer_type) {
|
||||||
|
res.push(["==", ["get", "potreb_type"], consumer_type.value]);
|
||||||
|
}
|
||||||
|
if (energy_class) {
|
||||||
|
res.push(["==", ["get", "fi_energy_class"], energy_class.value]);
|
||||||
|
}
|
||||||
|
if (wall_material) {
|
||||||
|
res.push(["==", ["get", "wall_materials"], wall_material.value]);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...consumerFilterExpression()
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { useDzkhFilters } from "../../stores/useDzkhFilters.js";
|
||||||
|
import { useWeatherFilter } from "../WeatherSlider/useWeatherFilter.js";
|
||||||
|
import { tempMapper } from "../../config.js";
|
||||||
|
|
||||||
|
export const useSourceLayerFilterExpression = () => {
|
||||||
|
const {source_filter} = useDzkhFilters();
|
||||||
|
const {value: temperature} = useWeatherFilter();
|
||||||
|
const t = tempMapper[temperature]
|
||||||
|
const consumerFilterExpression = () => {
|
||||||
|
if (source_filter) {
|
||||||
|
const key = `${source_filter.key}_${t}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
[">=", ["get", key], source_filter.gt],
|
||||||
|
["<=", ["get", key], source_filter.lt],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...consumerFilterExpression()
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import Checkbox from "antd/es/checkbox/Checkbox";
|
||||||
|
import {LAYER_IDS} from "./constants.js";
|
||||||
|
import {useLayersVisibility} from "../stores/useLayersVisibility.js";
|
||||||
|
import { Image } from "antd";
|
||||||
|
import { CONSUMER_COLOR, CONSUMER_COLOR_COOLING, SOURCE_COLOR } from "./Layers/layers-config.js";
|
||||||
|
import {useDzkhFilters} from "../stores/useDzkhFilters.js";
|
||||||
|
import dispIcon from "../assets/circle.svg";
|
||||||
|
|
||||||
|
export const LegendPointItem = ({color, imageSrc, name, border}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{imageSrc && <Image src={imageSrc} width={18} height={18} className='flex items-center' preview={false}/>}
|
||||||
|
{color && !imageSrc && (
|
||||||
|
<span className="w-4 h-[100%] flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={`rounded-xl w-3 h-3 inline-block ${border && "border-black border-[1px] border-solid"}`}
|
||||||
|
style={{backgroundColor: color}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className='text-xs text-grey'>{name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LegendColorRampItem = ({colors, name, desc}) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className='text-xs text-grey'>{name}</span>
|
||||||
|
<div className="w-[200px]">
|
||||||
|
<div
|
||||||
|
className={"w-full h-[10px] rounded-xl"}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${colors.join(",")})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs text-grey italic">{desc} →</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const consumerColors = CONSUMER_COLOR.stops.map(stop => stop[1])
|
||||||
|
const sourceColors = SOURCE_COLOR.stops.map(stop => stop[1])
|
||||||
|
|
||||||
|
export function Legend() {
|
||||||
|
const { toggleVisibility, isVisible } = useLayersVisibility();
|
||||||
|
const { consumer_filter, source_filter } = useDzkhFilters();
|
||||||
|
const isCooling = useMemo(() => {
|
||||||
|
return consumer_filter && consumer_filter.key.includes('cooling')
|
||||||
|
}, [consumer_filter])
|
||||||
|
const consumerColors = useMemo(() => {
|
||||||
|
if (isCooling) return CONSUMER_COLOR_COOLING.stops.map(stop => stop[1]);
|
||||||
|
return CONSUMER_COLOR.stops.map(stop => stop[1])
|
||||||
|
}, [isCooling]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-[20px] right-[20px] text-xs text-grey z-10 bg-white-background rounded-xl p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="space-y-2 flex flex-col">
|
||||||
|
<Checkbox
|
||||||
|
className={"!ml-0"}
|
||||||
|
onChange={() => toggleVisibility(LAYER_IDS.consumer)}
|
||||||
|
checked={isVisible[LAYER_IDS.consumer]}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
consumer_filter ? <LegendColorRampItem
|
||||||
|
colors={consumerColors}
|
||||||
|
name="Потребитель"
|
||||||
|
desc={consumer_filter.key.includes("prob") ? "склонность к аварийности" : consumer_filter.key.includes("cooling_time") ? "время остывания, ч" : "приоритет здания"}
|
||||||
|
/> : <LegendPointItem name="Потребитель"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
className={"!ml-0"}
|
||||||
|
onChange={() => toggleVisibility(LAYER_IDS.source)}
|
||||||
|
checked={isVisible[LAYER_IDS.source]}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
source_filter ? <LegendColorRampItem
|
||||||
|
colors={sourceColors}
|
||||||
|
name="Источник"
|
||||||
|
desc={'склонность к аварийности'}
|
||||||
|
/> : <LegendPointItem name="Источник"/>
|
||||||
|
}
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
className={"!ml-0 flex items-center"}
|
||||||
|
onChange={() => toggleVisibility(LAYER_IDS.dispatcher)}
|
||||||
|
checked={isVisible[LAYER_IDS.dispatcher]}
|
||||||
|
>
|
||||||
|
<LegendPointItem name="Диспетчерская" imageSrc={dispIcon} />
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { TreeSelect } from "antd";
|
||||||
|
import { Title } from "../../components/Title.jsx";
|
||||||
|
import { CONSUMER_OPTIONS } from "../../config.js";
|
||||||
|
import { useDzkhFilters } from "../../stores/useDzkhFilters.js";
|
||||||
|
|
||||||
|
export const ConsumerParameterSelect = ({ disabled }) => {
|
||||||
|
const {
|
||||||
|
consumer_filter,
|
||||||
|
setConsumerFilter,
|
||||||
|
} = useDzkhFilters();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title text={"Показатель потребителя"} />
|
||||||
|
|
||||||
|
<TreeSelect
|
||||||
|
mode="tags"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
placeholder="Выберите показатель"
|
||||||
|
onChange={(key) => setConsumerFilter(key)}
|
||||||
|
treeData={CONSUMER_OPTIONS}
|
||||||
|
allowClear={true}
|
||||||
|
value={consumer_filter?.key}
|
||||||
|
disabled={disabled}
|
||||||
|
treeDefaultExpandAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import {SliderComponent as Slider} from "../../components/SliderComponent.jsx";
|
||||||
|
import {useMemo} from "react";
|
||||||
|
import {Spin} from "antd";
|
||||||
|
import {useDzkhFilters} from "../../stores/useDzkhFilters.js";
|
||||||
|
import {useGetDzkhFiltersRange} from "../api/filters.js";
|
||||||
|
import {useWeatherFilter} from "../WeatherSlider/useWeatherFilter.js";
|
||||||
|
import {tempMapper} from "../../config.js";
|
||||||
|
|
||||||
|
export const ConsumerParameterSlider = ({ disabled, isLoading }) => {
|
||||||
|
const {
|
||||||
|
consumer_filter,
|
||||||
|
setConsumerFilter,
|
||||||
|
} = useDzkhFilters();
|
||||||
|
|
||||||
|
const key = useMemo(() => {
|
||||||
|
return consumer_filter?.key;
|
||||||
|
}, [consumer_filter?.key]);
|
||||||
|
|
||||||
|
const values = useMemo(() => {
|
||||||
|
if (!key) return [0, 0];
|
||||||
|
const gt = consumer_filter.gt;
|
||||||
|
const lt = consumer_filter.lt;
|
||||||
|
|
||||||
|
return [gt, lt];
|
||||||
|
}, [consumer_filter, key]);
|
||||||
|
|
||||||
|
const {value: temperature} = useWeatherFilter();
|
||||||
|
|
||||||
|
const filterKey = useMemo(() => {
|
||||||
|
if (!!key) return `${key}_${tempMapper[temperature]}`;
|
||||||
|
return undefined;
|
||||||
|
}, [key, temperature]);
|
||||||
|
|
||||||
|
const {data, isLoading: isRangeLoading} = useGetDzkhFiltersRange(filterKey);
|
||||||
|
|
||||||
|
const range = useMemo(() => {
|
||||||
|
return data?.range || [0, 0]
|
||||||
|
}, [data?.range])
|
||||||
|
|
||||||
|
const handleAfterChange = (range) => setConsumerFilter(key, range);
|
||||||
|
|
||||||
|
if (isLoading || isRangeLoading) {
|
||||||
|
return (
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{consumer_filter && range && (
|
||||||
|
<Slider
|
||||||
|
title={key.includes("prob") ? "Склонность к аварийности" : key.includes("cooling_time") ? "Время остывания, ч" : "Приоритет здания"}
|
||||||
|
value={[values[0], values[1]]}
|
||||||
|
onAfterChange={handleAfterChange}
|
||||||
|
min={range[0]}
|
||||||
|
max={range[1]}
|
||||||
|
range
|
||||||
|
step={0.01}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { Select } from "antd";
|
||||||
|
import { Title } from "../../components/Title.jsx";
|
||||||
|
import { useDzkhFilters } from "../../stores/useDzkhFilters.js";
|
||||||
|
import { useGetDzkhFiltersValues } from "../api/filters.js";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
const key = "potreb_type";
|
||||||
|
const mapper = {
|
||||||
|
living_house: "Жилой дом",
|
||||||
|
soc_object: "Социальный объект"
|
||||||
|
}
|
||||||
|
export const ConsumerTypeSelect = ({ disabled }) => {
|
||||||
|
const {
|
||||||
|
consumer_type,
|
||||||
|
setConsumerType,
|
||||||
|
} = useDzkhFilters();
|
||||||
|
|
||||||
|
const {data, isLoading: isrLoading} = useGetDzkhFiltersValues(key);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return data?.filter(Boolean).map((item) => {
|
||||||
|
return {
|
||||||
|
value: item,
|
||||||
|
label: mapper[item]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [data])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title text={"Тип потребителя"} />
|
||||||
|
|
||||||
|
<Select
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
placeholder="Выберите тип"
|
||||||
|
onChange={(v, o) => setConsumerType(o)}
|
||||||
|
options={options}
|
||||||
|
allowClear={true}
|
||||||
|
value={consumer_type}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { Select } from "antd";
|
||||||
|
import { Title } from "../../components/Title.jsx";
|
||||||
|
import { useDzkhFilters } from "../../stores/useDzkhFilters.js";
|
||||||
|
import { useGetDzkhFiltersValues } from "../api/filters.js";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
const key = "fi_energy_class";
|
||||||
|
|
||||||
|
export const EnergyClassSelect = ({ disabled }) => {
|
||||||
|
const {
|
||||||
|
energy_class,
|
||||||
|
setEnergyClass,
|
||||||
|
} = useDzkhFilters();
|
||||||
|
|
||||||
|
const {data, isLoading: isrLoading} = useGetDzkhFiltersValues(key);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return data?.filter(Boolean).map((item) => {
|
||||||
|
return {
|
||||||
|
value: item,
|
||||||
|
label: item
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [data])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title text={"Класс энергоэффективности"} />
|
||||||
|
|
||||||
|
<Select
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
placeholder="Выберите тип"
|
||||||
|
onChange={(v, o) => setEnergyClass(o)}
|
||||||
|
options={options}
|
||||||
|
allowClear={true}
|
||||||
|
value={energy_class}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import {useDzkhFilters} from "../../stores/useDzkhFilters.js";
|
||||||
|
import {Button} from "antd";
|
||||||
|
import {ConsumerParameterSelect} from "./ConsumerParameterSelect.jsx";
|
||||||
|
import {ConsumerParameterSlider} from "./ConsumerParameterSlider.jsx";
|
||||||
|
import {SourceParameterSelect} from "./SourceParameterSelect.jsx";
|
||||||
|
import {SourceParameterSlider} from "./SourceParameterSlider.jsx";
|
||||||
|
import { ConsumerTypeSelect } from "./ConsumerTypeSelect.jsx";
|
||||||
|
import { EnergyClassSelect } from "./EnergyClassSelect.jsx";
|
||||||
|
import { WallMaterialSelect } from "./WallMaterialsSelect.jsx";
|
||||||
|
|
||||||
|
export const DzkhFilters = () => {
|
||||||
|
const { clear, consumer_filter, source_filter } = useDzkhFilters();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<ConsumerParameterSelect />
|
||||||
|
<ConsumerParameterSlider />
|
||||||
|
|
||||||
|
<SourceParameterSelect />
|
||||||
|
<SourceParameterSlider />
|
||||||
|
|
||||||
|
<ConsumerTypeSelect />
|
||||||
|
<EnergyClassSelect />
|
||||||
|
<WallMaterialSelect />
|
||||||
|
</div>
|
||||||
|
{(consumer_filter || source_filter) && <div className="flex items-center justify-end pt-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => clear()} type="secondary">
|
||||||
|
Сбросить фильтры
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { TreeSelect } from "antd";
|
||||||
|
import { Title } from "../../components/Title.jsx";
|
||||||
|
import { useDzkhFilters } from "../../stores/useDzkhFilters.js";
|
||||||
|
|
||||||
|
const options = [{
|
||||||
|
value: "prob6",
|
||||||
|
label: "Авария на ТП"
|
||||||
|
}];
|
||||||
|
|
||||||
|
export const SourceParameterSelect = ({ disabled }) => {
|
||||||
|
const {
|
||||||
|
source_filter,
|
||||||
|
setSourceFilter,
|
||||||
|
} = useDzkhFilters();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title text={"Показатель источника"} />
|
||||||
|
|
||||||
|
<TreeSelect
|
||||||
|
mode="tags"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
placeholder="Выберите показатель"
|
||||||
|
onChange={(key) => setSourceFilter(key)}
|
||||||
|
treeData={options}
|
||||||
|
allowClear={true}
|
||||||
|
value={source_filter?.key}
|
||||||
|
disabled={disabled}
|
||||||
|
treeDefaultExpandAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import {SliderComponent as Slider} from "../../components/SliderComponent.jsx";
|
||||||
|
import {useMemo} from "react";
|
||||||
|
import {Spin} from "antd";
|
||||||
|
import {useDzkhFilters} from "../../stores/useDzkhFilters.js";
|
||||||
|
import {useWeatherFilter} from "../WeatherSlider/useWeatherFilter.js";
|
||||||
|
import {tempMapper} from "../../config.js";
|
||||||
|
import {useGetDzkhFiltersRange} from "../api/filters.js";
|
||||||
|
|
||||||
|
export const SourceParameterSlider = ({ disabled, isLoading }) => {
|
||||||
|
const {
|
||||||
|
source_filter,
|
||||||
|
setSourceFilter,
|
||||||
|
} = useDzkhFilters();
|
||||||
|
|
||||||
|
const key = useMemo(() => {
|
||||||
|
return source_filter?.key;
|
||||||
|
}, [source_filter?.key]);
|
||||||
|
|
||||||
|
const values = useMemo(() => {
|
||||||
|
if (!key) return [0, 0];
|
||||||
|
const gt = source_filter.gt;
|
||||||
|
const lt = source_filter.lt;
|
||||||
|
|
||||||
|
return [gt, lt];
|
||||||
|
}, [source_filter, key]);
|
||||||
|
|
||||||
|
const {value: temperature} = useWeatherFilter();
|
||||||
|
|
||||||
|
const filterKey = useMemo(() => {
|
||||||
|
if (!!key) return `${key}_${tempMapper[temperature]}`;
|
||||||
|
return undefined;
|
||||||
|
}, [key, temperature]);
|
||||||
|
|
||||||
|
const {data, isLoading: isRangeLoading} = useGetDzkhFiltersRange(filterKey);
|
||||||
|
|
||||||
|
const range = useMemo(() => {
|
||||||
|
return data?.range || [0, 0]
|
||||||
|
}, [data?.range])
|
||||||
|
|
||||||
|
const handleAfterChange = (range) => setSourceFilter(key, range);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{source_filter && range && (
|
||||||
|
<Slider
|
||||||
|
title={"Фильтр по показателю"}
|
||||||
|
value={[values[0], values[1]]}
|
||||||
|
onAfterChange={handleAfterChange}
|
||||||
|
min={range[0]}
|
||||||
|
max={range[1]}
|
||||||
|
range
|
||||||
|
step={0.01}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { Select } from "antd";
|
||||||
|
import { Title } from "../../components/Title.jsx";
|
||||||
|
import { useDzkhFilters } from "../../stores/useDzkhFilters.js";
|
||||||
|
import { useGetDzkhFiltersValues } from "../api/filters.js";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
const key = "wall_materials";
|
||||||
|
|
||||||
|
export const WallMaterialSelect = ({ disabled }) => {
|
||||||
|
const {
|
||||||
|
wall_material,
|
||||||
|
setWallMaterial,
|
||||||
|
} = useDzkhFilters();
|
||||||
|
|
||||||
|
const {data, isLoading: isLoading} = useGetDzkhFiltersValues(key);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return data?.filter(Boolean).map((item) => {
|
||||||
|
return {
|
||||||
|
value: item,
|
||||||
|
label: item
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [data])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title text={"Материал стен"} />
|
||||||
|
|
||||||
|
<Select
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
placeholder="Выберите тип"
|
||||||
|
onChange={(v, o) => setWallMaterial(o)}
|
||||||
|
options={options}
|
||||||
|
allowClear={true}
|
||||||
|
value={wall_material}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { SliderComponent as Slider } from "../../components/SliderComponent.jsx";
|
||||||
|
import { Spin } from "antd";
|
||||||
|
import { useWeatherFilter } from "./useWeatherFilter.js";
|
||||||
|
|
||||||
|
export const WeatherSlider = ({ disabled, fullRange, isLoading }) => {
|
||||||
|
const { value, setValue, range } = useWeatherFilter();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={"flex justify-center items-center"}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute w-[200px] top-[20px] right-[20px] text-xs text-grey z-10 bg-white-background rounded-xl p-3 space-y-3">
|
||||||
|
<Slider
|
||||||
|
title={"Температура воздуха на улице"}
|
||||||
|
value={value}
|
||||||
|
onAfterChange={setValue}
|
||||||
|
min={range[0]}
|
||||||
|
max={range[1]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
const store = (set) => ({
|
||||||
|
value: 0,
|
||||||
|
range: [-25, 18],
|
||||||
|
|
||||||
|
setValue: (value) => {
|
||||||
|
set((state) => {
|
||||||
|
state.value = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () =>
|
||||||
|
set((state) => {
|
||||||
|
state.value = 0;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useWeatherFilter = create(
|
||||||
|
persist(immer(store), { name: "weather-filter" })
|
||||||
|
);
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {api} from "../../api.js";
|
||||||
|
import { useWeatherFilter } from "../WeatherSlider/useWeatherFilter.js";
|
||||||
|
|
||||||
|
export const useGetDzkhFiltersRange = (field) => {
|
||||||
|
return useQuery(
|
||||||
|
["dzkh-filter-range", field],
|
||||||
|
async () => {
|
||||||
|
const { data, isInitialLoading, isFetching } = await api.get(
|
||||||
|
`/api/data/filters_ranges/?field=${field}`
|
||||||
|
);
|
||||||
|
return {data, isLoading: isInitialLoading || isFetching};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
select: ({data, isLoading}) => {
|
||||||
|
let r;
|
||||||
|
if (!data || !data[field]) r = [0, 0];
|
||||||
|
else r = [data[field].min, data[field].max];
|
||||||
|
return {
|
||||||
|
range: r,
|
||||||
|
isLoading: isLoading
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetDzkhFiltersValues = (field) => {
|
||||||
|
return useQuery(
|
||||||
|
["dzkh-filter-values", field],
|
||||||
|
async () => {
|
||||||
|
const { data, isInitialLoading, isFetching } = await api.get(
|
||||||
|
`/api/data/filters_ranges/?field=${field}`
|
||||||
|
);
|
||||||
|
return {data, isLoading: isInitialLoading || isFetching};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
select: ({data, isLoading}) => {
|
||||||
|
if (!data) return [];
|
||||||
|
return [...data[field]];
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useShap = (id) => {
|
||||||
|
return useQuery(
|
||||||
|
["dzkh-shap", id],
|
||||||
|
async () => {
|
||||||
|
const { data, isInitialLoading, isFetching } = await api.get(
|
||||||
|
`/api/data/${id}/data_shap/`
|
||||||
|
);
|
||||||
|
return {data, isLoading: isInitialLoading || isFetching};
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
select: (raw) => {
|
||||||
|
return raw.data.shap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCrushSummary = (id) => {
|
||||||
|
const {value: temp} = useWeatherFilter();
|
||||||
|
if (!id) return {data: null};
|
||||||
|
return useQuery(
|
||||||
|
["dzkh-crush", id],
|
||||||
|
async () => {
|
||||||
|
const { data, isInitialLoading, isFetching } = await api.get(
|
||||||
|
`/api/data/${id}/crush_summary/?current_temp=${temp}`
|
||||||
|
);
|
||||||
|
return {data, isLoading: isInitialLoading || isFetching};
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
select: (raw) => {
|
||||||
|
return raw.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export const LAYER_IDS = {
|
||||||
|
consumer: 'potreb',
|
||||||
|
source: 'tp',
|
||||||
|
dispatcher: 'ods'
|
||||||
|
};
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const useLocalStorage = (key, defaultValue) => {
|
||||||
|
const [value, setValue] = useState(() => {
|
||||||
|
let currentValue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentValue = JSON.parse(
|
||||||
|
localStorage.getItem(key) || String(defaultValue)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
currentValue = defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}, [value, key]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useLocalStorage;
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
export const FiltersIcon = ({ width = 24, height = 24 }) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
>
|
||||||
|
<polygon points="23.7,4.1 23.7,6.1 0.1,6.2 0.1,4.1 " />
|
||||||
|
<g>
|
||||||
|
<circle cx="6.3" cy="5.2" r="3.1" />
|
||||||
|
<circle fill="#fff" cx="6.3" cy="5.2" r="1.4" />
|
||||||
|
</g>
|
||||||
|
<polygon points="23.6,11.1 23.7,13.1 0,13.1 0,11 " />
|
||||||
|
<g>
|
||||||
|
<circle cx="18.6" cy="12.1" r="3.1" />
|
||||||
|
<circle fill="#fff" cx="18.6" cy="12.1" r="1.4" />
|
||||||
|
</g>
|
||||||
|
<polygon points="23.8,18.2 23.9,20.1 0.2,20.2 0.2,18.2 " />
|
||||||
|
<g>
|
||||||
|
<circle cx="10.2" cy="19.1" r="3.1" />
|
||||||
|
<circle fill="#fff" cx="10.2" cy="19.1" r="1.4" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import dispatcherIcon from "../assets/circle.svg";
|
||||||
|
|
||||||
|
export const icons = [
|
||||||
|
{ name: "dispatcherIcon", url: dispatcherIcon },
|
||||||
|
];
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.mapboxgl-popup,
|
||||||
|
.maplibregl-popup {
|
||||||
|
@apply max-w-[400px] min-w-[250px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-popup-content,
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
@apply bg-grey-light shadow-lg rounded-md max-h-[500px] overflow-y-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip,
|
||||||
|
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
|
||||||
|
@apply border-t-grey-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-popup-anchor-top .mapboxgl-popup-tip,
|
||||||
|
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
|
||||||
|
@apply border-b-grey-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-popover-inner {
|
||||||
|
@apply bg-white-background rounded-xl max-h-[calc(100vh-100px)] overflow-y-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*.ant-modal-header {*/
|
||||||
|
/* border-bottom: none;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
.ant-select-multiple .ant-select-selection-item {
|
||||||
|
@apply !bg-rose;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-ctrl-group,
|
||||||
|
.maplibregl-ctrl-group {
|
||||||
|
@apply bg-white-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-ctrl-group button,
|
||||||
|
.maplibregl-ctrl-group button {
|
||||||
|
@apply w-fit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-export-control {
|
||||||
|
@apply !w-[30px] !h-[30px];
|
||||||
|
background: url('data:image/svg+xml;charset=UTF-8,<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m422.5 99v-24c0-41.355-33.645-75-75-75h-184c-41.355 0-75 33.645-75 75v24z"/><path d="m118.5 319v122 26 15c0 16.568 13.431 30 30 30h214c16.569 0 30-13.432 30-30v-15-26-122zm177 128h-80c-8.284 0-15-6.716-15-15s6.716-15 15-15h80c8.284 0 15 6.716 15 15s-6.716 15-15 15zm0-64h-80c-8.284 0-15-6.716-15-15s6.716-15 15-15h80c8.284 0 15 6.716 15 15s-6.716 15-15 15z"/><path d="m436.5 129h-361c-41.355 0-75 33.645-75 75v120c0 41.355 33.645 75 75 75h13v-80h-9c-8.284 0-15-6.716-15-15s6.716-15 15-15h24 304 24c8.284 0 15 6.716 15 15s-6.716 15-15 15h-9v80h14c41.355 0 75-33.645 75-75v-120c0-41.355-33.645-75-75-75zm-309 94h-48c-8.284 0-15-6.716-15-15s6.716-15 15-15h48c8.284 0 15 6.716 15 15s-6.716 15-15 15z"/></g></svg>');
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapboxgl-ctrl-top-left .mapboxgl-ctrl,
|
||||||
|
.maplibregl-ctrl-top-left .maplibregl-ctrl {
|
||||||
|
margin: 20px 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-export-list {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-item-option-content {
|
||||||
|
overflow: initial;
|
||||||
|
white-space: initial;
|
||||||
|
text-overflow: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #0052FF !important;
|
||||||
|
background: #0052FF !important;
|
||||||
|
text-shadow: 0 -1px 0 rgb(0 0 0 / 12%);
|
||||||
|
box-shadow: 0 2px 0 rgb(0 0 0 / 5%);
|
||||||
|
line-height: 1.5715;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
background-image: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
height: 32px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 8px auto 0;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 5px;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend_group .ant-collapse-header {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter_group .ant-collapse-header {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.filter_group .ant-collapse-arrow {
|
||||||
|
right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border: 4px solid rgba(0, 0, 0, 0);
|
||||||
|
background-clip: padding-box;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #e2e2e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Works on Firefox*/
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
import "antd/dist/antd.less";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<App />
|
||||||
|
);
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import {twMerge} from "tailwind-merge";
|
||||||
|
import {forwardRef} from "react";
|
||||||
|
import {RegionSelect} from "../../components/RegionSelect.jsx";
|
||||||
|
import {DzkhFilters} from "../../dzkh-features/Sidebar/Filters.jsx";
|
||||||
|
import {useDzkhFilters} from "../../stores/useDzkhFilters.js";
|
||||||
|
|
||||||
|
import logo from "../../assets/dzkh_logo.png";
|
||||||
|
import ditlogo from "../../assets/dit_logo.png";
|
||||||
|
|
||||||
|
export const Logo = ({ width = 40, height = 40 }) => {
|
||||||
|
return (
|
||||||
|
<img width={150} height={40} src={logo} alt={"logo"}/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Sidebar = forwardRef(({ isCollapsed }, ref) => {
|
||||||
|
const { filters, setRegion } = useDzkhFilters();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"h-screen p-3 overflow-y-auto shrink-0 border-solid border-border border-0 border-r-[1px] flex flex-col transition-all",
|
||||||
|
isCollapsed ? "basis-0 px-0 -translate-x-[320px]" : "basis-[320px]"
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-row gap-1 justify-between items-center">
|
||||||
|
<img width={"48%"} height={50} src={logo} alt={"logo"}/>
|
||||||
|
<img width={"48%"} height={50} src={ditlogo} alt={"logo"}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RegionSelect
|
||||||
|
value={filters.region?.id}
|
||||||
|
onChange={setRegion}
|
||||||
|
/>
|
||||||
|
<DzkhFilters />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import React, {useCallback, useState} from "react";
|
||||||
|
import {Table} from "../Table";
|
||||||
|
import {useClickedPointConfig} from "../../../stores/useClickedPointConfig";
|
||||||
|
import {useTableData} from "./useTableData.js";
|
||||||
|
import {HeaderWrapper} from "../HeaderWrapper";
|
||||||
|
import {useExportData} from "./useExportData.js";
|
||||||
|
import {useColumns} from "../useColumns.jsx";
|
||||||
|
import {PAGE_SIZE} from "../constants.js";
|
||||||
|
import {usePopup} from "../../../stores/usePopup.js";
|
||||||
|
|
||||||
|
const tableKey = 'dzkhTable';
|
||||||
|
export const DzkhTable = ({ fullWidth }) => {
|
||||||
|
const { setClickedPointConfig } = useClickedPointConfig();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||||
|
const { columns, orderColumns, sort, setSort } = useColumns(tableKey);
|
||||||
|
const { setPopup } = usePopup();
|
||||||
|
|
||||||
|
const onSort = (sortDirection, key) => {
|
||||||
|
if (sortDirection === `ascend`) setSort(key);
|
||||||
|
if (sortDirection === `descend`) setSort(`-${key}`);
|
||||||
|
if (!sortDirection) setSort(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isClickedPointLoading, isDataLoading } = useTableData(
|
||||||
|
page,
|
||||||
|
() => setPage(1),
|
||||||
|
pageSize,
|
||||||
|
setPageSize,
|
||||||
|
sort
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetPageSize = () => setPageSize(PAGE_SIZE);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback((page) => {
|
||||||
|
resetPageSize();
|
||||||
|
setClickedPointConfig(null);
|
||||||
|
setPopup(null);
|
||||||
|
setPage(page);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
data={data}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
isClickedPointLoading={isClickedPointLoading}
|
||||||
|
columns={columns}
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
onChange={(val, filter, sorter) => {
|
||||||
|
onSort(sorter.order, sorter.columnKey);
|
||||||
|
}}
|
||||||
|
header={<HeaderWrapper exportProvider={useExportData} orderColumns={orderColumns} />}
|
||||||
|
loading={isDataLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {exportPoints} from "../../../api";
|
||||||
|
import {handleExportSuccess} from "../ExportButton";
|
||||||
|
import {appendFiltersInUse} from "../../../utils.js";
|
||||||
|
import {useDzkhFilters} from "../../../stores/useDzkhFilters.js";
|
||||||
|
import { useWeatherFilter } from "../../../dzkh-features/WeatherSlider/useWeatherFilter.js";
|
||||||
|
import { useSimulateAccident } from "../../../stores/useSimulateAccident.js";
|
||||||
|
import { tempMapper } from "../../../config.js";
|
||||||
|
import { useUpdateLayerCounter } from "../../../stores/useUpdateLayerCounter.js";
|
||||||
|
|
||||||
|
export const useExportData = (enabled, onSettled) => {
|
||||||
|
const { filters, consumer_filter, source_filter, consumer_type, wall_material, energy_class } = useDzkhFilters();
|
||||||
|
const { value } = useWeatherFilter();
|
||||||
|
const { selectedSourceConfig } = useSimulateAccident()
|
||||||
|
const t = tempMapper[value];
|
||||||
|
const {
|
||||||
|
region,
|
||||||
|
} = filters;
|
||||||
|
|
||||||
|
const getParams = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const customFilters = [];
|
||||||
|
if (consumer_filter && source_filter) {
|
||||||
|
const cK = consumer_filter.key + "_" + t;
|
||||||
|
const sK = source_filter.key + "_" + t;
|
||||||
|
const cFilter = `${cK}__gte=${consumer_filter.gt}&${cK}__lte=${consumer_filter.lt}`;
|
||||||
|
const sFilter = `${sK}__gte=${source_filter.gt}&${sK}__lt=${source_filter.lt}`;
|
||||||
|
customFilters.push(`(${sFilter})|(${cFilter})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSourceConfig) {
|
||||||
|
if (selectedSourceConfig.sourceId) customFilters.push(`(tp_number=${selectedSourceConfig.sourceId})`);
|
||||||
|
if (selectedSourceConfig.dispatcherNumber) customFilters.push(`(ods_number=${selectedSourceConfig.dispatcherNumber})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customFilters.length !== 0) {
|
||||||
|
params.append("filters", customFilters.join("&"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consumer_filter && !source_filter) {
|
||||||
|
params.append(consumer_filter.key + "_" + t + "__gt" , consumer_filter.gt)
|
||||||
|
params.append(consumer_filter.key + "_" + t + "__lt", consumer_filter.lt)
|
||||||
|
}
|
||||||
|
if (source_filter && !consumer_filter) {
|
||||||
|
params.append(source_filter.key + "_" + t + "__gt" , source_filter.gt)
|
||||||
|
params.append(source_filter.key + "_" + t + "__lt", source_filter.lt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useQuery(
|
||||||
|
["export-initial", filters],
|
||||||
|
async () => {
|
||||||
|
|
||||||
|
return await exportPoints(getParams(), region);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled,
|
||||||
|
onSuccess: handleExportSuccess,
|
||||||
|
onSettled,
|
||||||
|
retry: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {getPoints} from "../../../api";
|
||||||
|
import {useMergeTableData} from "../useMergeTableData";
|
||||||
|
import {appendFiltersInUse} from "../../../utils.js";
|
||||||
|
import {useUpdateLayerCounter} from "../../../stores/useUpdateLayerCounter.js";
|
||||||
|
import {useDzkhFilters} from "../../../stores/useDzkhFilters.js";
|
||||||
|
import { useWeatherFilter } from "../../../dzkh-features/WeatherSlider/useWeatherFilter.js";
|
||||||
|
import { CONSUMER_OPTIONS, tempMapper } from "../../../config.js";
|
||||||
|
import { useSimulateAccident } from "../../../stores/useSimulateAccident.js";
|
||||||
|
|
||||||
|
export const useTableData = (page, resetPage, pageSize, setPageSize, sort) => {
|
||||||
|
const { filters, consumer_filter, source_filter, consumer_type, wall_material, energy_class } = useDzkhFilters();
|
||||||
|
const { value } = useWeatherFilter();
|
||||||
|
const { selectedSourceConfig } = useSimulateAccident()
|
||||||
|
const t = tempMapper[value];
|
||||||
|
const { updateCounter } = useUpdateLayerCounter();
|
||||||
|
const {
|
||||||
|
region,
|
||||||
|
} = filters;
|
||||||
|
|
||||||
|
const getParams = () => {
|
||||||
|
const order =
|
||||||
|
sort &&
|
||||||
|
(sort.includes("prob") ||
|
||||||
|
sort.includes("cooling_time") ||
|
||||||
|
sort.includes("priority")) ?
|
||||||
|
`${sort}_${t}` : sort;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (order) {
|
||||||
|
params.append("ordering", order);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFilters = [];
|
||||||
|
if (consumer_filter && source_filter) {
|
||||||
|
const cK = consumer_filter.key + "_" + t;
|
||||||
|
const sK = source_filter.key + "_" + t;
|
||||||
|
const cFilter = `${cK}__gte=${consumer_filter.gt}&${cK}__lte=${consumer_filter.lt}`;
|
||||||
|
const sFilter = `${sK}__gte=${source_filter.gt}&${sK}__lt=${source_filter.lt}`;
|
||||||
|
customFilters.push(`(${sFilter})|(${cFilter})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSourceConfig) {
|
||||||
|
if (selectedSourceConfig.sourceId) customFilters.push(`(tp_number=${selectedSourceConfig.sourceId})`);
|
||||||
|
if (selectedSourceConfig.dispatcherNumber) customFilters.push(`(ods_number=${selectedSourceConfig.dispatcherNumber})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customFilters.length !== 0) {
|
||||||
|
params.append("filters", customFilters.join("&"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consumer_filter && !source_filter) {
|
||||||
|
params.append(consumer_filter.key + "_" + t + "__gt" , consumer_filter.gt)
|
||||||
|
params.append(consumer_filter.key + "_" + t + "__lt", consumer_filter.lt)
|
||||||
|
}
|
||||||
|
if (source_filter && !consumer_filter) {
|
||||||
|
params.append(source_filter.key + "_" + t + "__gt" , source_filter.gt)
|
||||||
|
params.append(source_filter.key + "_" + t + "__lt", source_filter.lt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data, isInitialLoading, isFetching} = useQuery(
|
||||||
|
["table", page, filters, sort, updateCounter, consumer_filter, source_filter, selectedSourceConfig],
|
||||||
|
async ({signal}) => {
|
||||||
|
const params = getParams();
|
||||||
|
|
||||||
|
return await getPoints(params, region, signal);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
if (err.response.data.detail === "Неправильная страница") {
|
||||||
|
resetPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {data: mergedData, isClickedPointLoading} = useMergeTableData(
|
||||||
|
data,
|
||||||
|
setPageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: mergedData,
|
||||||
|
pageSize,
|
||||||
|
isClickedPointLoading,
|
||||||
|
isDataLoading: isInitialLoading || isFetching,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Tooltip } from "antd";
|
||||||
|
import { DownloadOutlined } from "@ant-design/icons";
|
||||||
|
import { download } from "../../utils";
|
||||||
|
|
||||||
|
export const handleExportSuccess = (data) => {
|
||||||
|
download("data.csv", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExportButton = ({ provider }) => {
|
||||||
|
const [startExport, setStartExport] = useState(false);
|
||||||
|
|
||||||
|
provider(startExport, () => setStartExport(false));
|
||||||
|
|
||||||
|
const handleExport = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setStartExport(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title="Скачать данные">
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
loading={startExport}
|
||||||
|
disabled={startExport}
|
||||||
|
>
|
||||||
|
<DownloadOutlined />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
import { ExportButton } from "./ExportButton";
|
||||||
|
import { Button, Tooltip } from "antd";
|
||||||
|
import { useTable } from "../../stores/useTable";
|
||||||
|
import { FullscreenExitOutlined, FullscreenOutlined } from "@ant-design/icons";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { TableSettings } from "./TableSettings";
|
||||||
|
|
||||||
|
const ToggleFullScreenButton = () => {
|
||||||
|
const {
|
||||||
|
tableState: { fullScreen },
|
||||||
|
toggleFullScreen,
|
||||||
|
} = useTable();
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setHover(false), 1500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [hover]);
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setHover(true);
|
||||||
|
};
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setHover(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFullScreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={fullScreen ? "Свернуть" : "Раскрыть на полный экран"}
|
||||||
|
placement={"topRight"}
|
||||||
|
open={hover}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{fullScreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HeaderWrapper = ({
|
||||||
|
leftColumn,
|
||||||
|
rightColumn,
|
||||||
|
exportProvider,
|
||||||
|
classes,
|
||||||
|
orderColumns
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={"flex items-center w-full justify-between"}>
|
||||||
|
<div className={classes?.leftColumn}>
|
||||||
|
<span className="py-[5px]">Таблица атрибутов</span>
|
||||||
|
{leftColumn}
|
||||||
|
</div>
|
||||||
|
<div className={classes?.rightColumn}>
|
||||||
|
{rightColumn}
|
||||||
|
<div className="flex items-center gap-x-1">
|
||||||
|
<TableSettings orderColumns={orderColumns} />
|
||||||
|
{exportProvider && <ExportButton provider={exportProvider} />}
|
||||||
|
<ToggleFullScreenButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
.ant-collapse-content-box {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__wrapper .ant-table {
|
||||||
|
@apply max-w-[calc(100vw-320px)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__wrapper .ant-table[data-fullwidth="true"] {
|
||||||
|
@apply max-w-[100vw];
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__wrapper .ant-table-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__wrapper__fullScreen .ant-table-container {
|
||||||
|
height: calc(100vh - 98px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__title {
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title__content {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
column-gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__badge {
|
||||||
|
background-color: rgba(51, 51, 51, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-pagination.ant-pagination {
|
||||||
|
@apply !my-2 !mx-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table.ant-table-small .ant-table-title,
|
||||||
|
.ant-table.ant-table-small .ant-table-footer,
|
||||||
|
.ant-table.ant-table-small .ant-table-thead > tr > th,
|
||||||
|
.ant-table.ant-table-small .ant-table-tbody > tr > td,
|
||||||
|
.ant-table.ant-table-small tfoot > tr > th,
|
||||||
|
.ant-table.ant-table-small tfoot > tr > td {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-collapse > .ant-collapse-item > .ant-collapse-header {
|
||||||
|
@apply !items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-collapse-header-text {
|
||||||
|
@apply flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr.ant-table-row-selected > td {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr:not(.ant-table-placeholder):hover > td {
|
||||||
|
@apply !bg-grey-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr.ant-table-row-selected.scroll-row > td {
|
||||||
|
@apply !bg-primary-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr.ant-table-row.scroll-row > td {
|
||||||
|
@apply !bg-primary-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr.ant-table-row-selected.scroll-row:hover > td {
|
||||||
|
@apply !bg-primary-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-tbody > tr.ant-table-row.scroll-row:hover > td {
|
||||||
|
@apply !bg-primary-light;
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Collapse, Empty, Table as AntdTable } from "antd";
|
||||||
|
import "./Table.css";
|
||||||
|
import parse from "wellknown";
|
||||||
|
import { useMap } from "react-map-gl";
|
||||||
|
import { useClickedPointConfig } from "../../stores/useClickedPointConfig";
|
||||||
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { HeaderWrapper } from "./HeaderWrapper";
|
||||||
|
import { useTable } from "../../stores/useTable";
|
||||||
|
import { usePopup } from "../../stores/usePopup.js";
|
||||||
|
|
||||||
|
export const Table = React.memo(
|
||||||
|
({
|
||||||
|
data,
|
||||||
|
pageSize,
|
||||||
|
isClickedPointLoading,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
|
columns,
|
||||||
|
header,
|
||||||
|
fullWidth,
|
||||||
|
loading,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
const { clickedPointConfig, setClickedPointConfig } =
|
||||||
|
useClickedPointConfig();
|
||||||
|
const { map } = useMap();
|
||||||
|
const { tableState, toggleOpened } = useTable();
|
||||||
|
const { setPopup } = usePopup();
|
||||||
|
|
||||||
|
const SCROLL = {
|
||||||
|
y: tableState.fullScreen ? "calc(100vh - 136px)" : "200px",
|
||||||
|
x: "max-content",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clickedPointConfig === null || isClickedPointLoading) return;
|
||||||
|
|
||||||
|
const row = document.querySelector(".scroll-row");
|
||||||
|
if (row) {
|
||||||
|
scrollIntoView(row, { behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [clickedPointConfig, data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapse
|
||||||
|
bordered={false}
|
||||||
|
onChange={toggleOpened}
|
||||||
|
activeKey={tableState.isOpened ? "opened" : null}
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
key={"opened"}
|
||||||
|
header={header ? header : <HeaderWrapper />}
|
||||||
|
collapsible={tableState.fullScreen ? "disabled" : undefined}
|
||||||
|
>
|
||||||
|
<AntdTable
|
||||||
|
size="small"
|
||||||
|
className={twMerge(
|
||||||
|
"table__wrapper",
|
||||||
|
tableState.fullScreen && "table__wrapper__fullScreen"
|
||||||
|
)}
|
||||||
|
locale={{ emptyText: <Empty description="Нет данных" /> }}
|
||||||
|
pagination={{
|
||||||
|
pageSize,
|
||||||
|
current: page,
|
||||||
|
onChange: onPageChange,
|
||||||
|
total: data?.count,
|
||||||
|
showSizeChanger: false,
|
||||||
|
position: "bottomCenter",
|
||||||
|
}}
|
||||||
|
showHeader={data?.results && data.results.length > 0}
|
||||||
|
dataSource={data?.results}
|
||||||
|
columns={columns}
|
||||||
|
onChange={onChange}
|
||||||
|
rowKey="id"
|
||||||
|
scroll={SCROLL}
|
||||||
|
sticky={true}
|
||||||
|
onRow={(record) => {
|
||||||
|
return {
|
||||||
|
onClick: () => {
|
||||||
|
const geometry = parse(record.geometry);
|
||||||
|
map.flyTo({
|
||||||
|
center: [geometry.coordinates[0], geometry.coordinates[1]],
|
||||||
|
zoom: 13,
|
||||||
|
essential: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feature = {
|
||||||
|
properties: record,
|
||||||
|
};
|
||||||
|
|
||||||
|
setPopup({
|
||||||
|
features: [feature],
|
||||||
|
coordinates: geometry.coordinates,
|
||||||
|
});
|
||||||
|
|
||||||
|
setClickedPointConfig(
|
||||||
|
record.id,
|
||||||
|
rowSelection?.selectedRowKeys.includes(record.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
rowClassName={(record) =>
|
||||||
|
twMerge(
|
||||||
|
"cursor-pointer",
|
||||||
|
record.id === clickedPointConfig?.id && "scroll-row"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-fullwidth={fullWidth}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {Button, Checkbox, Dropdown} from "antd";
|
||||||
|
import {SettingOutlined} from "@ant-design/icons";
|
||||||
|
import {DragDropContext, Draggable, Droppable} from "react-beautiful-dnd";
|
||||||
|
|
||||||
|
export const TableSettings = ({orderColumns}) => {
|
||||||
|
const [columnsList, setColumnsList] = useState(orderColumns.order);
|
||||||
|
useEffect(() => {
|
||||||
|
setColumnsList(orderColumns.order);
|
||||||
|
}, [orderColumns]);
|
||||||
|
|
||||||
|
const handleDrop = (droppedItem) => {
|
||||||
|
// Ignore drop outside droppable container
|
||||||
|
if (!droppedItem.destination) return;
|
||||||
|
var updatedList = [...columnsList];
|
||||||
|
// Remove dragged item
|
||||||
|
const [reorderedItem] = updatedList.splice(droppedItem.source.index, 1);
|
||||||
|
// Add dropped item
|
||||||
|
updatedList.splice(droppedItem.destination.index, 0, reorderedItem);
|
||||||
|
// Update State
|
||||||
|
setColumnsList(updatedList);
|
||||||
|
orderColumns.setOrder(updatedList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideColumn = (columnIndex) => {
|
||||||
|
const updatedList = columnsList.map((item, index) => {
|
||||||
|
if (columnIndex === index) return {...item, show: !item.show};
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
setColumnsList(updatedList);
|
||||||
|
orderColumns.setOrder(updatedList);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnsListRender = () => {
|
||||||
|
return (
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className='z-10 bg-white-background rounded-xl p-3 space-y-3'
|
||||||
|
style={{ maxHeight: "80vh", overflowY: "scroll", margin: "24px 0 24px" }}>
|
||||||
|
<DragDropContext onDragEnd={handleDrop}>
|
||||||
|
<Droppable droppableId="tableOrder">
|
||||||
|
{(provided) => (
|
||||||
|
<div className="flex flex-col" {...provided.droppableProps} ref={provided.innerRef}>
|
||||||
|
{columnsList.map((item, index) => {
|
||||||
|
const num = item.position;
|
||||||
|
if (!orderColumns.defaultColumns[num]) return;
|
||||||
|
return (
|
||||||
|
<Draggable key={`list-${num}`} draggableId={`list-${num}`} index={index}>
|
||||||
|
{(provided) => (
|
||||||
|
<div className="flex flex-row gap-2 p-1.5 hover:bg-gray-300 rounded-md" ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
|
||||||
|
<Checkbox onChange={() => hideColumn(index)} checked={item.show} />
|
||||||
|
<p className="m-0">
|
||||||
|
{ orderColumns.defaultColumns[num].name || orderColumns.defaultColumns[num].title }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
trigger="click"
|
||||||
|
dropdownRender={() => columnsListRender()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<SettingOutlined/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { DzkhTable } from "./DzkhTable/DzkhTable.jsx";
|
||||||
|
|
||||||
|
export const TableWrapper = ({ fullWidth }) => {
|
||||||
|
return <DzkhTable fullWidth={fullWidth} />;
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export const PAGE_SIZE = 30;
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
import {tempMapper} from "../../config";
|
||||||
|
import {useEffect, useMemo} from "react";
|
||||||
|
import {useGetRegions} from "../../components/RegionSelect.jsx";
|
||||||
|
import {useTable} from "../../stores/useTable.js";
|
||||||
|
import useLocalStorage from "../../hooks/useLocalStorage.js";
|
||||||
|
import {useWeatherFilter} from "../../dzkh-features/WeatherSlider/useWeatherFilter.js";
|
||||||
|
|
||||||
|
export const useColumns = (key) => {
|
||||||
|
const { data: regions } = useGetRegions();
|
||||||
|
const {
|
||||||
|
tableState: { fullScreen },
|
||||||
|
} = useTable();
|
||||||
|
|
||||||
|
const {value: temperature} = useWeatherFilter();
|
||||||
|
|
||||||
|
const [sort, setSort] = useLocalStorage(`${key}Sort`, null);
|
||||||
|
|
||||||
|
const defaultColumns = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Адрес",
|
||||||
|
dataIndex: "building_address",
|
||||||
|
key: "building_address",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Номер ТП",
|
||||||
|
dataIndex: "tp_number",
|
||||||
|
key: "tp_number",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Номер ОДС",
|
||||||
|
dataIndex: "ods_number",
|
||||||
|
key: "ods_number",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Телефон ОДС",
|
||||||
|
dataIndex: "phone_number",
|
||||||
|
key: "phone_number",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Отсутствие отопления в доме",
|
||||||
|
dataIndex: `prob1_${tempMapper[temperature]}`,
|
||||||
|
key: "prob1",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Протечка труб в подъезде",
|
||||||
|
dataIndex: `prob2_${tempMapper[temperature]}`,
|
||||||
|
key: "prob2",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Температура в квартире ниже нормативной",
|
||||||
|
dataIndex: `prob3_${tempMapper[temperature]}`,
|
||||||
|
key: "prob3",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Температура в помещении общего пользования ниже нормативной",
|
||||||
|
dataIndex: `prob4_${tempMapper[temperature]}`,
|
||||||
|
key: "prob4",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Течь в системе отопления",
|
||||||
|
dataIndex: `prob5_${tempMapper[temperature]}`,
|
||||||
|
key: "prob5",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Авария на ТП",
|
||||||
|
dataIndex: `prob6_${tempMapper[temperature]}`,
|
||||||
|
key: "prob6",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: "Время остывания, ч",
|
||||||
|
dataIndex: `cooling_time_${tempMapper[temperature]}`,
|
||||||
|
key: "cooling_time",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Приоритет здания",
|
||||||
|
dataIndex: `priority_${tempMapper[temperature]}`,
|
||||||
|
key: "priority",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Материал стен",
|
||||||
|
dataIndex: "wall_materials",
|
||||||
|
key: "wall_materials",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Класс энергоэффективности",
|
||||||
|
dataIndex: "fi_energy_class",
|
||||||
|
key: "fi_energy_class",
|
||||||
|
width: "120px",
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
showSorterTooltip: false,
|
||||||
|
},
|
||||||
|
].filter(Boolean);
|
||||||
|
}, [regions?.normalized, fullScreen, temperature]);
|
||||||
|
|
||||||
|
const [order, setOrder] = useLocalStorage(`${key}Order`, defaultColumns.map((column, index) => {
|
||||||
|
return {
|
||||||
|
key: column.key,
|
||||||
|
position: index,
|
||||||
|
show: true,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newColumns = defaultColumns.filter((column) => {
|
||||||
|
return !order.find(c => c.key === column.key);
|
||||||
|
});
|
||||||
|
const newOrderColumns = newColumns.map((column, index) => {
|
||||||
|
return {
|
||||||
|
key: column.key,
|
||||||
|
position: defaultColumns.length - index - 1,
|
||||||
|
show: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setOrder([
|
||||||
|
...order,
|
||||||
|
...newOrderColumns
|
||||||
|
]);
|
||||||
|
}, [defaultColumns]);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return order.flatMap((item) => !item.show ? [] : defaultColumns[item.position])
|
||||||
|
.map((column) => {
|
||||||
|
if (sort && sort.includes(column?.key)) return {
|
||||||
|
...column,
|
||||||
|
defaultSortOrder: sort.includes('-') ? 'descend' : 'ascend',
|
||||||
|
};
|
||||||
|
return column;
|
||||||
|
}).filter(Boolean);
|
||||||
|
}, [defaultColumns, order, sort]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns,
|
||||||
|
orderColumns: {
|
||||||
|
defaultColumns,
|
||||||
|
order,
|
||||||
|
setOrder,
|
||||||
|
},
|
||||||
|
sort,
|
||||||
|
setSort
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { useClickedPointConfig } from "../../stores/useClickedPointConfig";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../../api";
|
||||||
|
|
||||||
|
export const useGetClickedPoint = (enabled, onSuccess) => {
|
||||||
|
const { clickedPointConfig } = useClickedPointConfig();
|
||||||
|
|
||||||
|
const { data, isInitialLoading, isFetching } = useQuery(
|
||||||
|
["clicked-point", clickedPointConfig?.id],
|
||||||
|
async () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
"location_ids[]": [clickedPointConfig.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await api.get(
|
||||||
|
`/api/placement_points?${params.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled,
|
||||||
|
onSuccess,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data, isLoading: isInitialLoading || isFetching };
|
||||||
|
};
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { PAGE_SIZE } from "./constants";
|
||||||
|
import { useClickedPointConfig } from "../../stores/useClickedPointConfig";
|
||||||
|
import { useGetClickedPoint } from "./useGetClickedPoint";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const useMergeTableData = (fullData, onPageSizeChange) => {
|
||||||
|
const [mergedData, setMergedData] = useState();
|
||||||
|
const [shouldLoadClickedPoint, setShouldLoadClickedPoint] = useState(false);
|
||||||
|
const lastClickedPointId = useRef();
|
||||||
|
|
||||||
|
const { data: clickedPointData, isLoading: isClickedPointLoading } =
|
||||||
|
useGetClickedPoint(shouldLoadClickedPoint, () =>
|
||||||
|
setShouldLoadClickedPoint(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { clickedPointConfig } = useClickedPointConfig();
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullData) return;
|
||||||
|
|
||||||
|
setMergedData(fullData);
|
||||||
|
}, [fullData]);
|
||||||
|
|
||||||
|
// find clicked point among already loaded data - if no - fetch it
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullData || clickedPointConfig === null) return;
|
||||||
|
|
||||||
|
const clickedPoint = fullData.results.find(
|
||||||
|
(item) => item.id === clickedPointConfig.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clickedPoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShouldLoadClickedPoint(true);
|
||||||
|
}, [fullData, clickedPointConfig]);
|
||||||
|
|
||||||
|
// merge data with clicked point
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clickedPointData?.results?.[0]) return;
|
||||||
|
|
||||||
|
lastClickedPointId.current = clickedPointData.results[0].id;
|
||||||
|
|
||||||
|
onPageSizeChange(PAGE_SIZE + 1);
|
||||||
|
setMergedData({
|
||||||
|
count: fullData?.count + 1,
|
||||||
|
results: [clickedPointData.results[0], ...fullData.results],
|
||||||
|
});
|
||||||
|
}, [clickedPointData, fullData]);
|
||||||
|
|
||||||
|
// reset data after popup disappeared
|
||||||
|
useEffect(() => {
|
||||||
|
if (clickedPointConfig === null) {
|
||||||
|
const queryKey = ["clicked-point", lastClickedPointId.current];
|
||||||
|
queryClient.removeQueries({ queryKey });
|
||||||
|
onPageSizeChange(PAGE_SIZE);
|
||||||
|
setMergedData(fullData);
|
||||||
|
}
|
||||||
|
}, [clickedPointConfig, fullData]);
|
||||||
|
|
||||||
|
return { data: mergedData, isClickedPointLoading };
|
||||||
|
};
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { MapComponent } from "../Map/MapComponent";
|
||||||
|
|
||||||
|
export function MapPage() {
|
||||||
|
return (
|
||||||
|
<MapComponent />
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { action, atom } from "nanostores";
|
||||||
|
|
||||||
|
export const userInfoLoading$ = atom(true);
|
||||||
|
|
||||||
|
export const isAuthorized$ = atom(false);
|
||||||
|
|
||||||
|
export const setAuth = action(isAuthorized$, "setAuth", (store, newValue) => {
|
||||||
|
store.set(newValue);
|
||||||
|
});
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
export class DjangoValidationError extends Error {
|
||||||
|
errors;
|
||||||
|
|
||||||
|
constructor(errObject) {
|
||||||
|
super("");
|
||||||
|
this.name = "DjangoValidationError";
|
||||||
|
|
||||||
|
const fieldsErrors = [];
|
||||||
|
|
||||||
|
for (const field in errObject) {
|
||||||
|
fieldsErrors.push({
|
||||||
|
name: field,
|
||||||
|
errors: errObject[field],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.errors = fieldsErrors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
|
const store = (set) => ({
|
||||||
|
clickedPointConfig: null,
|
||||||
|
|
||||||
|
setClickedPointConfig: (id, shouldSelect = false) => {
|
||||||
|
set((state) => {
|
||||||
|
if (id === null) {
|
||||||
|
state.clickedPointConfig = null;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
state.clickedPointConfig = {
|
||||||
|
id,
|
||||||
|
shouldSelect,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useClickedPointConfig = create(immer(store));
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export const INITIAL = {
|
||||||
|
region: null,
|
||||||
|
prob1__gt: 0,
|
||||||
|
prob1__lt: 5000,
|
||||||
|
prob2__gt: 0,
|
||||||
|
prob2__lt: 5000,
|
||||||
|
prob3__gt: 0,
|
||||||
|
prob3__lt: 5000,
|
||||||
|
prob4__gt: 0,
|
||||||
|
prob4__lt: 5000,
|
||||||
|
prob5__gt: 0,
|
||||||
|
prob5__lt: 5000,
|
||||||
|
prob6__gt: 0,
|
||||||
|
prob6__lt: 5000,
|
||||||
|
cooling_time__gt: 0,
|
||||||
|
cooling_time__lt: 5000,
|
||||||
|
priority__gt: 0,
|
||||||
|
priority__lt: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_RANGES = {
|
||||||
|
prob1: [0, 5000],
|
||||||
|
prob2: [0, 5000],
|
||||||
|
prob3: [0, 5000],
|
||||||
|
prob4: [0, 5000],
|
||||||
|
prob5: [0, 5000],
|
||||||
|
prob6: [0, 5000],
|
||||||
|
cooling_time: [0, 5000],
|
||||||
|
priority: [0, 5000],
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = (set) => ({
|
||||||
|
filters: INITIAL,
|
||||||
|
consumer_filter: null,
|
||||||
|
consumer_ranges: null,
|
||||||
|
source_filter: null,
|
||||||
|
consumer_type: null,
|
||||||
|
energy_class: null,
|
||||||
|
wall_material: null,
|
||||||
|
ranges: INITIAL_RANGES,
|
||||||
|
|
||||||
|
setConsumerFilter: (key, value) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!key) {
|
||||||
|
state.consumer_filter = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arr = value ? value : state.ranges[key];
|
||||||
|
state.consumer_filter = {
|
||||||
|
key: key,
|
||||||
|
gt: arr[0],
|
||||||
|
lt: arr[1],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setSourceFilter: (key, value) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!key) {
|
||||||
|
state.source_filter = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const arr = value ? value : state.ranges[key];
|
||||||
|
state.source_filter = {
|
||||||
|
key: key,
|
||||||
|
gt: arr[0],
|
||||||
|
lt: arr[1],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setRegion: (value) =>
|
||||||
|
set((state) => {
|
||||||
|
state.filters.region = value;
|
||||||
|
}),
|
||||||
|
|
||||||
|
setConsumerType: (value) =>
|
||||||
|
set((state) => {
|
||||||
|
state.consumer_type = value;
|
||||||
|
}),
|
||||||
|
|
||||||
|
setEnergyClass: (value) =>
|
||||||
|
set((state) => {
|
||||||
|
state.energy_class = value;
|
||||||
|
}),
|
||||||
|
|
||||||
|
setWallMaterial: (value) =>
|
||||||
|
set((state) => {
|
||||||
|
state.wall_material = value;
|
||||||
|
}),
|
||||||
|
|
||||||
|
setFilterWithKey: (value, key) =>
|
||||||
|
set((state) => {
|
||||||
|
state.filters[`${key}__gt`] = value[0];
|
||||||
|
state.filters[`${key}__lt`] = value[1];
|
||||||
|
}),
|
||||||
|
|
||||||
|
setRanges: (value) =>
|
||||||
|
set((state) => {
|
||||||
|
state.ranges = value;
|
||||||
|
}),
|
||||||
|
|
||||||
|
clear: (fullRange) =>
|
||||||
|
set((state) => {
|
||||||
|
|
||||||
|
state.consumer_filter = null;
|
||||||
|
state.source_filter = null;
|
||||||
|
state.energy_class = null;
|
||||||
|
state.consumer_type = null;
|
||||||
|
state.wall_material = null;
|
||||||
|
if (!fullRange) {
|
||||||
|
state.filters = INITIAL;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
state.filters = {
|
||||||
|
...INITIAL,
|
||||||
|
prediction: fullRange.prediction,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDzkhFilters = create(
|
||||||
|
persist(immer(store), { name: "dzkh/filters" })
|
||||||
|
);
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import { LAYER_IDS } from "../dzkh-features/constants.js";
|
||||||
|
|
||||||
|
const INITIAL_STATE = {
|
||||||
|
[LAYER_IDS.consumer]: true,
|
||||||
|
[LAYER_IDS.source]: true,
|
||||||
|
[LAYER_IDS.dispatcher]: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = (set) => ({
|
||||||
|
isVisible: INITIAL_STATE,
|
||||||
|
|
||||||
|
toggleVisibility: (layerId) =>
|
||||||
|
set((state) => {
|
||||||
|
state.isVisible[layerId] = !state.isVisible[layerId];
|
||||||
|
}),
|
||||||
|
|
||||||
|
showLayers: (layerIds) =>
|
||||||
|
set((state) => {
|
||||||
|
layerIds.forEach((layerId) => {
|
||||||
|
state.isVisible[layerId] = true;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
setLayersVisibility: (visibleLayerIds) =>
|
||||||
|
set((state) => {
|
||||||
|
visibleLayerIds.forEach((layerId) => {
|
||||||
|
state.isVisible[layerId] = true;
|
||||||
|
});
|
||||||
|
const invisibleLayersIds = Object.keys(state.isVisible).filter(
|
||||||
|
(l) => !visibleLayerIds.includes(l)
|
||||||
|
);
|
||||||
|
invisibleLayersIds.forEach((layerId) => {
|
||||||
|
state.isVisible[layerId] = false;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useLayersVisibility = create(
|
||||||
|
persist(immer(store), { name: "dzkh/layers-visibility" })
|
||||||
|
);
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
|
const store = (set) => ({
|
||||||
|
popup: null,
|
||||||
|
|
||||||
|
setPopup: (popupState) => {
|
||||||
|
set((state) => {
|
||||||
|
if (!popupState) {
|
||||||
|
state.popup = null;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
state.popup = popupState;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePopup = create(immer(store));
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
|
const store = (set) => ({
|
||||||
|
selectedSourceConfig: {
|
||||||
|
sourceId: null,
|
||||||
|
dispatcherNumber: null
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedSourceConfig: ({pointId, sourceId, dispatcherNumber}) =>
|
||||||
|
set((state) => {
|
||||||
|
state.selectedSourceConfig = { pointId, sourceId, dispatcherNumber: dispatcherNumber || null }
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSimulateAccident = create(immer(store));
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
|
const INITIAL_STATE = {
|
||||||
|
isOpened: false,
|
||||||
|
fullScreen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = (set) => ({
|
||||||
|
tableState: INITIAL_STATE,
|
||||||
|
|
||||||
|
toggleOpened: (value) => {
|
||||||
|
set((state) => {
|
||||||
|
state.tableState.isOpened = value[0] === "opened";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleFullScreen: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.tableState.fullScreen = !state.tableState.fullScreen;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openTable: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.tableState.isOpened = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useTable = create(immer(store));
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
|
const store = (set) => ({
|
||||||
|
updateCounter: -1,
|
||||||
|
|
||||||
|
toggleUpdateCounter: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.updateCounter = state.updateCounter === -1 ? 1 : -1;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUpdateLayerCounter = create(immer(store));
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
export function download(filename, data) {
|
||||||
|
const downloadLink = window.document.createElement("a");
|
||||||
|
downloadLink.href = window.URL.createObjectURL(
|
||||||
|
new Blob([data], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
downloadLink.download = filename;
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
document.body.removeChild(downloadLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appendFiltersInUse = (params, filters, ranges) => {}
|
||||||
|
|
||||||
|
export const isNil = (value) =>
|
||||||
|
value === undefined || value === null || value === "";
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./src/**/*.{js,jsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: "#0052FF",
|
||||||
|
'primary-light': "#ffe4e4",
|
||||||
|
blue: "rgba(167,201,236,0.57)",
|
||||||
|
"white-background": "rgba(255, 255, 255, 0.9)",
|
||||||
|
"white-background-light": "rgba(255, 255, 255, 0.6)",
|
||||||
|
grey: "rgba(0,0, 0, 0.5)",
|
||||||
|
"grey-light": "rgba(239,239,239,0.9)",
|
||||||
|
border: '#d9d9d9',
|
||||||
|
rose: '#FAA7B4'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
corePlugins: {
|
||||||
|
preflight: false, // <== disable this!
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||