Initial commit

main
h 5 months ago
commit 40d67832d7

@ -0,0 +1,6 @@
.gitignore
.gitlab-ci.yml
README.md
.git
docker-compose.yml
.env

@ -0,0 +1 @@
VITE_API_URL=https://geoheat.spatialsystems.ru

24
.gitignore vendored

@ -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,10 @@
# DZKH by Spatial frontend part
### Инструкция по запуску
1. Склонировать проект `git clone ...`
2. Установить зависимости через `npm / yarn install`
3. Для запуска в режиме разработки выполнить команду `yarn dev`
4. Для продакшн-сборки выполнить команду `yarn build` - после этого вся статика будет в папке `dist`
римечания_
1. Для установки своего URL, где находится API, нужно переопределить переменную окружения `VITE_API_URL` в файле `.env` в корне проекта.

@ -0,0 +1,11 @@
{
auto_https off
}
:80 {
handle {
root * /srv
file_server
}
}

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<svg fill="#0052FF" version="1.1" id="Capa_1" width="2" height="2" viewBox="0 0 350.23 350.23" xmlns="http://www.w3.org/2000/svg">
<defs/>
<ellipse style="fill: rgb(255, 255, 255); stroke: rgb(0, 82, 255); stroke-width: 4px;" cx="175.115" cy="175.115" rx="175.115" ry="175.115"/>
<g style="" transform="matrix(0.77428, 0, 0, 0.77428, 39.525757, 39.527344)">
<g>
<g>
<path d="M233.695,248.694c-3.507-3.488-8.365-5.404-13.709-5.404c-5.627,0-11.049,2.192-14.88,6.023l-24.229,24.235l-6.548-3.633&#10;&#9;&#9;&#9;&#9;c-14.403-7.986-34.141-18.939-54.977-39.806c-20.921-20.896-31.882-40.677-39.896-55.14l-3.579-6.365l24.271-24.268&#10;&#9;&#9;&#9;&#9;c8.028-8.056,8.308-20.876,0.606-28.589L55.911,70.911c-3.485-3.486-8.35-5.41-13.685-5.41c-5.621,0-11.04,2.203-14.883,6.038&#10;&#9;&#9;&#9;&#9;L16.331,82.614l-1.021,1.702c-4.102,5.258-7.458,11.166-9.965,17.606c-2.333,6.134-3.783,11.964-4.458,17.813&#10;&#9;&#9;&#9;&#9;c-5.779,48.053,16.375,92.167,76.492,152.27c71.247,71.234,130.785,76.777,147.339,76.777c2.841,0,4.552-0.156,5.026-0.204&#10;&#9;&#9;&#9;&#9;c6.119-0.745,11.968-2.216,17.87-4.498c6.371-2.48,12.268-5.812,17.517-9.914l2.498-1.976l10.31-10.112&#10;&#9;&#9;&#9;&#9;c8.017-8.021,8.274-20.824,0.576-28.528L233.695,248.694z"/>
</g>
<g>
<g>
<path d="M186.228,165.766c-9.758-9.752-22.014-16.769-35.407-20.272l-6.161-1.63l-1.766,6.083&#10;&#9;&#9;&#9;&#9;&#9;c-0.663,2.255-1.444,4.39-2.33,6.308l-3.339,7.113l7.65,1.976c10.121,2.558,19.344,7.77,26.667,15.108&#10;&#9;&#9;&#9;&#9;&#9;c6.999,6.984,12.115,15.745,14.793,25.316l1.291,4.611l9.631,0.547c0.973,0.114,1.946,0.223,2.877,0.307l9.055,0.853&#10;&#9;&#9;&#9;&#9;&#9;l-2.102-8.852C203.713,188.995,196.508,176.04,186.228,165.766z"/>
</g>
<g>
<path d="M217.698,134.282c-18.116-18.119-41.268-29.942-66.92-34.182l-8.923-1.499l1.402,8.92&#10;&#9;&#9;&#9;&#9;&#9;c0.412,2.645,0.775,5.278,1.078,7.938l0.57,4.693l4.723,0.934c20.446,3.777,38.905,13.43,53.377,27.893&#10;&#9;&#9;&#9;&#9;&#9;c16.615,16.621,26.77,37.824,29.339,61.351l0.547,4.979l7.548,1.087c1.213,0.096,2.444,0.084,4.18,0.096l9.091,0.312&#10;&#9;&#9;&#9;&#9;&#9;l-0.721-7.422C250.16,180.505,237.946,154.543,217.698,134.282z"/>
</g>
<g>
<path d="M251.884,100.101c-29.724-29.718-69.316-47.42-111.485-49.831l-8.191-0.511l1.366,8.067&#10;&#9;&#9;&#9;&#9;&#9;c0.417,2.627,0.928,5.314,1.468,8.113l0.997,4.894l4.951,0.306c36.389,2.492,70.554,17.994,96.212,43.646&#10;&#9;&#9;&#9;&#9;&#9;c25.244,25.247,40.671,59.024,43.433,95.064l0.475,6.413l6.407-0.378c1.519-0.078,3.014-0.21,5.2-0.402l9.169-0.721&#10;&#9;&#9;&#9;&#9;&#9;l-0.505-6.497C298.247,167.297,280.671,128.884,251.884,100.101z"/>
</g>
<g>
<path d="M349.702,203.665c-4.191-52.005-26.775-100.943-63.603-137.777C246.804,26.59,194.496,3.805,138.85,1.712l-6.308-0.265&#10;&#9;&#9;&#9;&#9;&#9;l-0.48,6.341c-0.111,1.771-0.186,3.522-0.252,5.281l-0.387,9.206l6.542,0.234c50.461,1.868,97.851,22.497,133.436,58.082&#10;&#9;&#9;&#9;&#9;&#9;c33.333,33.333,53.785,77.654,57.604,124.779l0.534,6.617l6.659-0.648c2.258-0.24,4.546-0.414,6.852-0.594l7.182-0.553&#10;&#9;&#9;&#9;&#9;&#9;L349.702,203.665z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
dist/index.html vendored

@ -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>

1
dist/vite.svg vendored

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

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;
}
}

7825
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -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: {},
},
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

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;
};

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<svg fill="#0052FF" version="1.1" id="Capa_1" width="2" height="2" viewBox="0 0 350.23 350.23" xmlns="http://www.w3.org/2000/svg">
<defs/>
<ellipse style="fill: rgb(255, 255, 255); stroke: rgb(0, 82, 255); stroke-width: 4px;" cx="175.115" cy="175.115" rx="175.115" ry="175.115"/>
<g style="" transform="matrix(0.77428, 0, 0, 0.77428, 39.525757, 39.527344)">
<g>
<g>
<path d="M233.695,248.694c-3.507-3.488-8.365-5.404-13.709-5.404c-5.627,0-11.049,2.192-14.88,6.023l-24.229,24.235l-6.548-3.633&#10;&#9;&#9;&#9;&#9;c-14.403-7.986-34.141-18.939-54.977-39.806c-20.921-20.896-31.882-40.677-39.896-55.14l-3.579-6.365l24.271-24.268&#10;&#9;&#9;&#9;&#9;c8.028-8.056,8.308-20.876,0.606-28.589L55.911,70.911c-3.485-3.486-8.35-5.41-13.685-5.41c-5.621,0-11.04,2.203-14.883,6.038&#10;&#9;&#9;&#9;&#9;L16.331,82.614l-1.021,1.702c-4.102,5.258-7.458,11.166-9.965,17.606c-2.333,6.134-3.783,11.964-4.458,17.813&#10;&#9;&#9;&#9;&#9;c-5.779,48.053,16.375,92.167,76.492,152.27c71.247,71.234,130.785,76.777,147.339,76.777c2.841,0,4.552-0.156,5.026-0.204&#10;&#9;&#9;&#9;&#9;c6.119-0.745,11.968-2.216,17.87-4.498c6.371-2.48,12.268-5.812,17.517-9.914l2.498-1.976l10.31-10.112&#10;&#9;&#9;&#9;&#9;c8.017-8.021,8.274-20.824,0.576-28.528L233.695,248.694z"/>
</g>
<g>
<g>
<path d="M186.228,165.766c-9.758-9.752-22.014-16.769-35.407-20.272l-6.161-1.63l-1.766,6.083&#10;&#9;&#9;&#9;&#9;&#9;c-0.663,2.255-1.444,4.39-2.33,6.308l-3.339,7.113l7.65,1.976c10.121,2.558,19.344,7.77,26.667,15.108&#10;&#9;&#9;&#9;&#9;&#9;c6.999,6.984,12.115,15.745,14.793,25.316l1.291,4.611l9.631,0.547c0.973,0.114,1.946,0.223,2.877,0.307l9.055,0.853&#10;&#9;&#9;&#9;&#9;&#9;l-2.102-8.852C203.713,188.995,196.508,176.04,186.228,165.766z"/>
</g>
<g>
<path d="M217.698,134.282c-18.116-18.119-41.268-29.942-66.92-34.182l-8.923-1.499l1.402,8.92&#10;&#9;&#9;&#9;&#9;&#9;c0.412,2.645,0.775,5.278,1.078,7.938l0.57,4.693l4.723,0.934c20.446,3.777,38.905,13.43,53.377,27.893&#10;&#9;&#9;&#9;&#9;&#9;c16.615,16.621,26.77,37.824,29.339,61.351l0.547,4.979l7.548,1.087c1.213,0.096,2.444,0.084,4.18,0.096l9.091,0.312&#10;&#9;&#9;&#9;&#9;&#9;l-0.721-7.422C250.16,180.505,237.946,154.543,217.698,134.282z"/>
</g>
<g>
<path d="M251.884,100.101c-29.724-29.718-69.316-47.42-111.485-49.831l-8.191-0.511l1.366,8.067&#10;&#9;&#9;&#9;&#9;&#9;c0.417,2.627,0.928,5.314,1.468,8.113l0.997,4.894l4.951,0.306c36.389,2.492,70.554,17.994,96.212,43.646&#10;&#9;&#9;&#9;&#9;&#9;c25.244,25.247,40.671,59.024,43.433,95.064l0.475,6.413l6.407-0.378c1.519-0.078,3.014-0.21,5.2-0.402l9.169-0.721&#10;&#9;&#9;&#9;&#9;&#9;l-0.505-6.497C298.247,167.297,280.671,128.884,251.884,100.101z"/>
</g>
<g>
<path d="M349.702,203.665c-4.191-52.005-26.775-100.943-63.603-137.777C246.804,26.59,194.496,3.805,138.85,1.712l-6.308-0.265&#10;&#9;&#9;&#9;&#9;&#9;l-0.48,6.341c-0.111,1.771-0.186,3.522-0.252,5.281l-0.387,9.206l6.542,0.234c50.461,1.868,97.851,22.497,133.436,58.082&#10;&#9;&#9;&#9;&#9;&#9;c33.333,33.333,53.785,77.654,57.604,124.779l0.534,6.617l6.659-0.648c2.258-0.24,4.546-0.414,6.852-0.594l7.182-0.553&#10;&#9;&#9;&#9;&#9;&#9;L349.702,203.665z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

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,113 @@
import { Empty, TreeSelect } from "antd";
import { Title } from "./Title";
import { useMap } from "react-map-gl";
import getBbox from "@turf/bbox";
import { polygon as getPolygon } from "@turf/helpers";
import { api } from "../api";
import parse from "wellknown";
import { useQuery } from "@tanstack/react-query";
const { TreeNode } = TreeSelect;
const normalizeRegions = (rawRegions) => {
return rawRegions.reduce((acc, ao) => {
acc[ao.name] = ao;
acc[ao.name].type = "ao";
if (ao.rayons) {
ao.rayons.forEach((rayon) => {
acc[rayon.name] = rayon;
acc[rayon.name].type = "rayon";
});
}
return acc;
}, {});
};
export const useGetRegions = () => {
return useQuery(
["regions"],
async () => {
const { data } = await api.get("/api/ao_rayons/");
return data;
},
{
select: (rawRegions) => {
return {
raw: rawRegions,
normalized: normalizeRegions(rawRegions),
};
},
refetchOnWindowFocus: false,
refetchOnMount: false
}
);
};
export const RegionSelect = ({
disabled,
value: selectedRegionId,
onChange,
}) => {
const { map } = useMap();
const { data, isInitialLoading } = useGetRegions();
const handleChange = (value) => {
if (!value) return;
const selectedRegion = data.normalized[value];
const polygonWrapperGeom = parse(selectedRegion.polygon);
const polygon = getPolygon(polygonWrapperGeom.coordinates[0]);
const bbox = getBbox(polygon);
onChange({ id: selectedRegion.name, geometry: polygon, type: selectedRegion.type });
map.fitBounds(
[
[bbox[0], bbox[1]], // southwestern corner of the bounds
[bbox[2], bbox[3]], // northeastern corner of the bounds
],
{
padding: 20,
}
);
};
const handleClear = () => onChange(null);
return (
<div>
<Title text={"АО / район"} />
<TreeSelect
showSearch
style={{ width: "100%" }}
value={selectedRegionId}
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Выберите АО или район"
allowClear
treeDefaultExpandAll={false}
onChange={handleChange}
loading={isInitialLoading}
treeNodeFilterProp="title"
onClear={handleClear}
notFoundContent={
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={"Не найдено"}
/>
}
disabled={disabled}
>
{data?.raw.map((ao) => {
return (
<TreeNode key={ao.name} value={ao.name} title={ao.name}>
{ao.rayons?.map((rayon) => (
<TreeNode key={rayon.name} value={rayon.name} title={rayon.name} />
))}
</TreeNode>
);
})}
</TreeSelect>
</div>
);
};

@ -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,43 @@
import { Row } from "antd";
import { twMerge } from "tailwind-merge";
import { useCrushSummary } from "../api/filters.js";
import { isNil } from "../../utils.js";
export const AccidentSimulationModal = ({id, modalOpen, setModalOpen}) => {
const {data, isLoading} = useCrushSummary(id);
return (
<div className="flex flex-col gap-1">
<Row className={twMerge("font-bold")}>
В зоне аварии оказалось:
</Row>
{!isNil(data?.potreb_count) && !isNil(data?.potreb_soc_count) && (
<Row>
{data?.potreb_count + data?.potreb_soc_count} потребителей (из них {data?.potreb_soc_count} - социальные
объекты)
</Row>
)}
{!isNil(data?.total_area) && (
<Row>
Общая площадь потребителей - {Math.ceil(data?.total_area)} кв. метров
</Row>
)}
{!isNil(data?.number_of_apartments) && (
<Row>
{data?.number_of_apartments} квартир
</Row>
)}
{!isNil(data?.data_min_cooling_time) && !isNil(data?.min_cooling_time) && (
<Row>
Быстрее всего остынет здание по
адресу {data?.data_min_cooling_time.building_address} ({data?.min_cooling_time} часов)
</Row>
)}
</div>
);
};

@ -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,120 @@
import { Line } from "react-chartjs-2";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip as ChartTooltip,
Legend, PointElement, LineElement, BarController,
} from 'chart.js';
import { useShap } from "../api/filters.js";
import { useMemo } from "react";
import { useDzkhFilters } from "../../stores/useDzkhFilters.js";
ChartJS.register(
CategoryScale,
BarController,
PointElement,
LineElement,
LinearScale,
BarElement,
Title,
ChartTooltip,
Legend
);
const GRAPH_LABELS_MAP= {
t_shap: "Температура воздуха",
fi_remont1_shap: "Замена стояков ХВС",
fi_remont2_shap: "Ремонт разводящих внутридомовых магистралей ХВС",
fi_remont3_shap: "Замена стояков ГВС",
fi_remont4_shap: "Ремонт разводящих внутридомовых магистралей ГВС",
fi_remont5_shap: "Ремонт стояков канализации",
fi_remont6_shap: "Ремонт выпусков и сборных трубопроводов системы канализации",
fi_remont7_shap: "Ремонт внутридомовых систем теплоснабжения (с заменой стояков)",
fi_remont8_shap: "Ремонт разводящих магистралей системы теплоснабжения",
total_area_shap: "Общая площадь",
fi_is_block_shap: "Материал - блочный",
fi_is_brick_shap: "Материал - кирпичный",
fi_is_panel_shap: "Материал - панельный",
building_wear_shap: "Износ",
fi_is_monolit_shap: "Материал - монолитный",
fi_energy_class_shap: "Класс энергоэффективности",
fi_building_year_shap: "Год постройки",
gvs_avg_heat_load_shap: "Средняя тепловая нагрузка ГВС",
heating_heat_load_shap: "Тепловая нагрузка на отопление",
gvs_fact_heat_load_shap: "Фактическая тепловая нагрузка ГВС",
ventilation_heat_load_shap: "Тепловая нагрузка на вентиляцию"
};
export const PointChart = ({ point }) => {
const { data: shapData } = useShap(point.id);
const {consumer_filter} = useDzkhFilters();
const shap = useMemo(() => {
if (!shapData) return null;
if (!consumer_filter || !consumer_filter.key.includes("prob")) return shapData['prob1_35'];
return shapData[`${consumer_filter.key}_35`];
}, [shapData, consumer_filter]);
if (!shapData || !shap) return "";
const options = {
indexAxis: 'y',
elements: {
bar: {
borderWidth: 0,
borderRadius: 5,
pointStyle: 'circle'
},
},
plugins: {
legend: {
display: false
},
tooltip: {
displayColors: false,
yAlign: "top",
},
},
scales: {
y: {
stacked: true,
},
x: {
title: {
display: true,
text: 'Вклад в прогноз, %',
},
grid: {
color: function(context) {
if (context.tick.value === 0) {
return "#000000";
}
return "#E5E5E5";
},
},
}
}
};
const labels = Object.keys(GRAPH_LABELS_MAP).sort((a, b) => {
if (Math.abs(shap[a]) < Math.abs(shap[b])) return 1;
else return -1;
}).slice(0, 15);
const data = {
labels: labels.map((l) => GRAPH_LABELS_MAP[l]),
datasets: [
{
data: labels.map((l) => shap ? shap[l] : 0),
backgroundColor: labels.map((l) => shap[l]).map(v => v <= 0 ? '#278211' : '#CC2500'),
hoverBackgroundColor: labels.map((l) => shap[l]).map(v => v <= 0 ? '#2DB20C' : '#F22C00'),
type: 'bar',
showLine: false,
},
],
};
return <Line options={options} data={data} />
}

@ -0,0 +1,70 @@
import { Button, Col, Divider, Modal, Popover, Row, Tooltip } from "antd";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { PointChart } from "./Chart.jsx";
const ChartHelp = () => {
return (
<div className="w-[200px]">
График показывает топ-15 факторов, которые оказывают наибольшее влияние на прогноз аварийности объекта.<br/><br/>
Факторы могут оказывать положительное или отрицательное влияние.<br/><br/>
Чем больше влияния оказывает фактор на аварийность, тем ближе его значение к 100% (-100%).
</div>
)
}
export const ChartModal = ({point}) => {
const [isOpened, setIsOpened] = useState(false);
const getFooter = () => {
return [
<Button
key="close-button"
type="primary"
onClick={() => setIsOpened(false)}
>
Закрыть
</Button>,
]
}
return (
<div className="flex items-center">
<Tooltip title="Влияние факторов на прогноз">
<Button className="flex justify-center items-center h-6 ml-1 mb-1 p-2" type="primary" onClick={() => setIsOpened(true)}>
Влияние факторов на прогноз
</Button>
</Tooltip>
<Modal
open={isOpened}
title="Вклад факторов в прогноз аварийности"
onCancel={() => setIsOpened(false)}
width={800}
footer={getFooter()}
style={{ top: "15px" }}
>
<div>
<div className="flex flex-col gap-2">
<Row className={twMerge("p-1")}>
<Col className={"font-semibold"} span={12}>
Адрес точки:
</Col>
<Col span={12}>{point.building_address}</Col>
</Row>
</div>
<Divider />
<PointChart point={point} />
<Popover
content={<ChartHelp autoFocus={true}/>}
trigger="click"
placement="leftBottom"
color="#ffffff"
>
<Button type="text" className="text-[#1890FF] p-0">Как читать график?</Button>
</Popover>
</div>
</Modal>
</div>
);
}

@ -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,223 @@
import {Alert, Button, Checkbox, Modal, Progress, Spin, Tooltip, Upload} from "antd";
import {UploadOutlined, LoadingOutlined} from "@ant-design/icons";
import {api, downloadImportTemplate, startAnalysis, uploadPointsFile} from "../api.js";
import {download} from "../utils.js";
import {useState} from "react";
import {useQuery} from "@tanstack/react-query";
import {Title} from "../components/Title.jsx";
const antIcon = <LoadingOutlined style={{ fontSize: 14 }} spin />
const Loader = (props) => <Spin indicator={antIcon} size={'small'} {...props}/>
const useStatusInfo = ({taskId, onSuccess}) => {
return useQuery(['tasks', taskId], async () => {
const {data} = await api.get(`/api/task/${taskId}/`)
return data;
}, {enabled: taskId !== null, refetchInterval: 1000, onSuccess });
}
const Uploader = ({text, dataType, onSuccess, onError}) => {
const [shouldRefill, setShouldRefill] = useState(false);
const [taskId, setTaskId] = useState(null);
const [isFinished, setIsFinished] = useState(false)
const [isInitialUploading, setIsInitialUploading] = useState(false);
const {data: uploadData, isInitialLoading, isFetching} = useStatusInfo({
taskId,
onSuccess: (data) => {
if (data.description === 'Импорт данных завершен') {
setTaskId(null)
setIsFinished(true)
onSuccess(dataType)
}
}})
const isLoading = isInitialLoading || isFetching
const onTemplateDownload = async () => {
const data = await downloadImportTemplate(dataType);
await download(`${dataType}_template.xlsx`, data);
}
const handleFileUpload = async (options) => {
const { file } = options;
try {
setIsInitialUploading(true)
const response = await uploadPointsFile(file, dataType, shouldRefill);
if (response.task_id) {
setTaskId(response.task_id)
}
} catch (err) {
onError(dataType)
console.error(err)
} finally {
setIsInitialUploading(false)
}
};
const getStatusInfo = () => {
const isIdle = !isInitialUploading && !uploadData && !isFinished
if (isIdle) return null
const getPercent = () => {
if (isInitialUploading) return 0
if (isFinished) return 100
return uploadData.progress
}
const getMessage = () => {
if (isInitialUploading) return 'Импорт данных'
if (isFinished) return 'Импорт данных завершен'
return uploadData.description
}
return <div>
{!isFinished && <Loader className={'mr-2'}/>}
<Progress
percent={getPercent()}
status={isLoading ? 'active' : null}
size={'small'}
className={'w-[300px]'}
/>
<Title text={getMessage()} className='text-xs'/>
</div>
}
return <div>
<div className='space-x-3'>
<Tooltip title={'Перезаписать данные'}>
<Checkbox checked={shouldRefill} onChange={e => setShouldRefill(e.target.checked)}/>
</Tooltip>
<Upload
name='file'
accept='application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml'
customRequest={handleFileUpload}
itemRender={() => null}
>
<Button icon={<UploadOutlined />}>{text}</Button>
</Upload>
<Button
className="p-0 text-xs text-grey underline h-auto"
type="text"
onClick={onTemplateDownload}
>
Скачать шаблон
</Button>
</div>
{getStatusInfo()}
</div>
}
export const ImportDataModal = ({onClose}) => {
const [isInitialAnalysisStarting, setIsInitialAnalysisStarting] = useState(false);
const [isError, setIsError] = useState(false);
const [uploadResult, setUploadResult] = useState({
odpu: null,
asupr: null,
moek_scheme: null,
events: null,
houses: null
});
const [taskId, setTaskId] = useState(null);
const [isFinished, setIsFinished] = useState(false)
const {data: uploadData, isInitialLoading, isFetching} = useStatusInfo({
taskId,
onSuccess: (data) => {
if (data.description === 'ПРОЦЕССИНГ данных завершен') {
setTaskId(null)
setIsFinished(true)
onClose()
}
}})
const isLoading = isInitialLoading || isFetching
const handleUploadSuccess = (dataType) => setUploadResult((prev) => ({...prev, [dataType]: true }) )
const handleUploadError = (dataType) => setUploadResult((prev) => ({...prev, [dataType]: false }) )
const hasUploadError = Object.values(uploadResult).some(result => result === null || result === false)
const handleButtonClick = async () => {
setIsInitialAnalysisStarting(true);
try {
const response = await startAnalysis()
if (response.task_id) {
setTaskId(response.task_id)
}
} catch (err) {
console.error(err)
setIsError(true);
} finally {
setIsInitialAnalysisStarting(false);
}
}
const getStatusInfo = () => {
const isIdle = !isInitialAnalysisStarting && !uploadData && !isFinished
if (isIdle) return null
const getPercent = () => {
if (isInitialAnalysisStarting) return 0
if (isFinished) return 100
return uploadData.progress
}
const getMessage = () => {
if (isInitialAnalysisStarting) return 'Начало анализа'
if (isFinished) return 'Анализ данных завершен'
return uploadData.description
}
return <div>
{!isFinished && <Loader className={'mr-2'}/>}
<Progress
percent={getPercent()}
status={isLoading ? 'active' : null}
size={'small'}
className={'w-[300px]'}
/>
<Title text={getMessage()} className='text-xs'/>
</div>
}
return (
<Modal
open={true}
title="Импорт данных"
width={400}
footer={null}
onCancel={onClose}
>
<div className='text-center space-y-3'>
<Alert
message="Обращаем ваше внимание, что импорт в зависимости от размера входных данных может занимать значительное время (до 10 мин)"
type="warning"
className={'mb-5'}
showIcon
closable
/>
<Uploader dataType={'odpu'} text={'Выбрать файл с ОДПУ'} onSuccess={handleUploadSuccess} onError={handleUploadError}/>
<Uploader dataType={'asupr'} text={'Выбрать файл с АСУПР'} onSuccess={handleUploadSuccess} onError={handleUploadError} />
<Uploader dataType={'moek_scheme'} text={'Выбрать файл с МОЭК'} onSuccess={handleUploadSuccess} onError={handleUploadError} />
<Uploader dataType={'events'} text={'Выбрать файл с событиями'} onSuccess={handleUploadSuccess} onError={handleUploadError} />
<Uploader dataType={'houses'} text={'Выбрать файл с домами'} onSuccess={handleUploadSuccess} onError={handleUploadError} />
<Button
key="start-upload"
className='mt-5'
type="primary"
onClick={handleButtonClick}
loading={isInitialAnalysisStarting}
disabled={hasUploadError || isLoading}
>
Начать анализ данных
</Button>
{getStatusInfo()}
{isError && <p className='text-red-600'>Произошла ошибка</p>}
</div>
</Modal>
);
}

@ -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"]
}

1
vite-env.d.ts vendored

@ -0,0 +1 @@
/// <reference types="vite/client" />

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save