diff --git a/package-lock.json b/package-lock.json
index 834c5a6..a04c6c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "white-nights",
- "version": "0.0.0",
+ "version": "1.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "white-nights",
- "version": "0.0.0",
+ "version": "1.0.6",
"license": "UNLICENSED",
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -23,10 +23,13 @@
"axios": "^1.9.0",
"easymde": "^2.20.0",
"i18n-iso-countries": "^7.14.0",
+ "lodash": "^4.18.1",
"lucide-react": "^0.511.0",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
"ol": "^10.5.0",
+ "overlayscrollbars": "^2.15.1",
+ "overlayscrollbars-react": "^0.5.6",
"path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0",
@@ -37,13 +40,16 @@
"react-router-dom": "^7.6.1",
"react-simplemde-editor": "^5.2.0",
"react-toastify": "^11.0.5",
+ "react-transition-group": "^4.4.5",
"rehype-raw": "^7.0.0",
"tailwindcss": "^4.1.8",
- "three": "^0.177.0"
+ "three": "^0.177.0",
+ "uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.16",
+ "@types/lodash": "^4.17.24",
"@types/node": "^22.15.24",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
@@ -89,7 +95,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -412,7 +417,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -456,7 +460,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1248,7 +1251,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.4",
@@ -1359,7 +1361,6 @@
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz",
"integrity": "sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.3",
@@ -1661,7 +1662,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@@ -2689,6 +2689,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/marked": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz",
@@ -2716,7 +2723,6 @@
"integrity": "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2750,7 +2756,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -2803,7 +2808,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -2878,7 +2882,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -3170,7 +3173,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3370,7 +3372,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -3887,7 +3888,6 @@
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz",
"integrity": "sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7",
@@ -4058,7 +4058,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5501,6 +5500,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6272,7 +6277,6 @@
"resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz",
"integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mobx"
@@ -6414,6 +6418,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/overlayscrollbars": {
+ "version": "2.15.1",
+ "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz",
+ "integrity": "sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA==",
+ "license": "MIT"
+ },
+ "node_modules/overlayscrollbars-react": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz",
+ "integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "overlayscrollbars": "^2.0.0",
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -6611,7 +6631,6 @@
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz",
"integrity": "sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
@@ -6815,7 +6834,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6825,7 +6843,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7039,8 +7056,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/rehype-raw": {
"version": "7.0.0",
@@ -7169,7 +7185,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -7516,8 +7531,7 @@
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -7536,8 +7550,7 @@
"version": "0.177.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz",
"integrity": "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -7624,7 +7637,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -7771,7 +7783,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7979,6 +7990,19 @@
"node": ">= 4"
}
},
+ "node_modules/uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -8026,7 +8050,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -8133,7 +8156,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/package.json b/package.json
index f520d6b..acf9d88 100644
--- a/package.json
+++ b/package.json
@@ -25,10 +25,13 @@
"axios": "^1.9.0",
"easymde": "^2.20.0",
"i18n-iso-countries": "^7.14.0",
+ "lodash": "^4.18.1",
"lucide-react": "^0.511.0",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
"ol": "^10.5.0",
+ "overlayscrollbars": "^2.15.1",
+ "overlayscrollbars-react": "^0.5.6",
"path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0",
@@ -39,6 +42,7 @@
"react-router-dom": "^7.6.1",
"react-simplemde-editor": "^5.2.0",
"react-toastify": "^11.0.5",
+ "react-transition-group": "^4.4.5",
"rehype-raw": "^7.0.0",
"tailwindcss": "^4.1.8",
"three": "^0.177.0",
@@ -47,6 +51,7 @@
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.16",
+ "@types/lodash": "^4.17.24",
"@types/node": "^22.15.24",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
diff --git a/public/Roboto.ttf b/public/Roboto.ttf
new file mode 100644
index 0000000..175c7b7
Binary files /dev/null and b/public/Roboto.ttf differ
diff --git a/public/loader.gif b/public/loader.gif
new file mode 100644
index 0000000..0d951ad
Binary files /dev/null and b/public/loader.gif differ
diff --git a/public/side-menu-photo.png b/public/side-menu-photo.png
new file mode 100644
index 0000000..a71d739
Binary files /dev/null and b/public/side-menu-photo.png differ
diff --git a/public/to_video.mp4 b/public/to_video.mp4
new file mode 100644
index 0000000..1ac287a
Binary files /dev/null and b/public/to_video.mp4 differ
diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx
index b7e1dc2..5b2a34c 100644
--- a/src/app/router/index.tsx
+++ b/src/app/router/index.tsx
@@ -33,11 +33,18 @@ import {
StationEditPage,
RouteCreatePage,
RoutePreview,
+ DemoPage,
RouteEditPage,
ArticlePreviewPage,
CountryAddPage,
} from "@pages";
-import { authStore, createSightStore, editSightStore, ROUTE_REQUIRED_RESOURCES } from "@shared";
+import {
+ authStore,
+ createSightStore,
+ editSightStore,
+ languageStore,
+ ROUTE_REQUIRED_RESOURCES,
+} from "@shared";
import { Layout } from "@widgets";
import { runInAction } from "mobx";
import React, { useEffect } from "react";
@@ -102,6 +109,14 @@ const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
runInAction(() => {
editSightStore.hasLoadedCommon = false;
});
+
+ if (
+ location.pathname.includes("create") ||
+ location.pathname.includes("edit") ||
+ location.pathname.includes("add")
+ ) {
+ languageStore.setLanguage("ru");
+ }
}, [location]);
return <>{children}>;
@@ -120,6 +135,10 @@ const router = createBrowserRouter([
path: "route-preview/:id",
element: ,
},
+ {
+ path: "demo/:id",
+ element: ,
+ },
{
path: "/",
element: (
diff --git a/src/client/src/App.css b/src/client/src/App.css
new file mode 100644
index 0000000..5fb5553
--- /dev/null
+++ b/src/client/src/App.css
@@ -0,0 +1,49 @@
+@font-face {
+ font-family: "Roboto";
+ src: url("/Roboto.ttf") format("truetype");
+ font-weight: 400 900;
+ font-style: normal;
+}
+
+.client-app-scoped * {
+ color: white;
+ margin: 0px;
+ padding: 0px;
+ box-sizing: border-box;
+}
+
+.client-app-scoped {
+ font-family: "Roboto", sans-serif;
+ background-color: black;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+ position: fixed;
+ inset: 0;
+}
+
+.app-root {
+ position: relative;
+}
+
+.widget-layer {
+ position: absolute;
+ display: inline-flex;
+ left: 0;
+ top: 0;
+ z-index: 10;
+ pointer-events: none;
+}
+
+.maintenance-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 2147483647;
+ background: #000;
+}
+
+.maintenance-video {
+ width: 100vw;
+ height: 100vh;
+ object-fit: cover;
+}
diff --git a/src/client/src/App.jsx b/src/client/src/App.jsx
new file mode 100644
index 0000000..113b06a
--- /dev/null
+++ b/src/client/src/App.jsx
@@ -0,0 +1,237 @@
+import "./App.css";
+import SideMenu from "./components/side-menu/SideMenu";
+import Loader from "./components/Loader";
+import { Map } from "./components/map/Map";
+import ListOfSights from "./components/ListOfSights";
+import VideoPreviewWidget from "./components/widgets/VideoPreviewWidget";
+import StoreDebugInfo from "./components/StoreDebugInfo";
+import { SimulationSettings } from "./components/SimulationSettings";
+import { observer } from "mobx-react-lite";
+
+import { apiStore } from "./api/ApiStore/store";
+import { useCallback, useEffect, useState } from "react";
+import { useGeolocationStore } from "./stores/hooks/useGeolocationStore";
+import { geolocationStore } from "./stores/GeolocationStore";
+
+const App = observer(() => {
+ const [isDebugVisible, setIsDebugVisible] = useState(false);
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const store = useGeolocationStore();
+ const { setSelectedSightId } = store;
+ const {
+ isLoading,
+ loadingStatus,
+ loadingProgress,
+ context,
+ setIsLoading,
+ setLoadingStatus,
+ setLoadingProgress,
+ getRouteSights,
+ getRouteStations,
+ getAllSightArticles,
+ getRoute,
+ getCarrier,
+ getCity,
+ preloadSightStationsAndStationSights,
+ startPositionSimulation,
+ stopPositionSimulation,
+ } = apiStore;
+
+ const handleGetAll = useCallback(async () => {
+ const totalSteps = 7;
+ let currentStep = 0;
+
+ try {
+ setIsLoading(true);
+
+ currentStep = 1;
+ setLoadingStatus("Загрузка маршрута...");
+ setLoadingProgress(currentStep, totalSteps);
+ await getRoute();
+
+ currentStep = 2;
+ setLoadingStatus("Загрузка перевозчика...");
+ setLoadingProgress(currentStep, totalSteps);
+ await getCarrier();
+
+ currentStep = 3;
+ setLoadingStatus("Загрузка города...");
+ setLoadingProgress(currentStep, totalSteps);
+ await getCity();
+
+ currentStep = 4;
+ setLoadingStatus("Загрузка достопримечательностей...");
+ setLoadingProgress(currentStep, totalSteps);
+ await getRouteSights();
+
+ currentStep = 5;
+ setLoadingStatus("Загрузка остановок маршрута...");
+ setLoadingProgress(currentStep, totalSteps);
+ await getRouteStations();
+
+ currentStep = 6;
+ setLoadingStatus("Загрузка связанных данных...");
+ setLoadingProgress(currentStep, totalSteps);
+ await preloadSightStationsAndStationSights();
+
+ currentStep = 7;
+ setLoadingStatus("Загрузка статей...");
+ setLoadingProgress(currentStep, totalSteps);
+ await getAllSightArticles();
+
+ startPositionSimulation();
+ } catch (error) {
+ console.error("Ошибка полной загрузки приложения:", error);
+ } finally {
+ setIsLoading(false);
+ setLoadingStatus(null);
+ setLoadingProgress(null);
+ }
+ }, []);
+
+ useEffect(() => {
+ const handleKeyDown = (event) => {
+ if (event.key === "F4") {
+ event.preventDefault();
+ setIsDebugVisible((prev) => !prev);
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ stopPositionSimulation();
+ };
+ }, []);
+
+ useEffect(() => {
+ handleGetAll();
+ }, []);
+
+ // Синхронизация currentStationId с симуляцией для корректной раскраски станций
+ useEffect(() => {
+ const nearestStationId = context?.nearestStationId;
+ if (nearestStationId) {
+ geolocationStore.setCurrentStationId(nearestStationId);
+ }
+ }, [context?.nearestStationId]);
+
+ useEffect(() => {
+ let idleSeconds = 0;
+
+ const checkIdle = () => {
+ idleSeconds++;
+
+ if (idleSeconds === 60) {
+ if (geolocationStore.isLeftWidgetOpen) {
+ geolocationStore.setIsLeftWidgetOpen(false);
+ }
+ if (geolocationStore.isTransferWidgetOpen) {
+ geolocationStore.setIsTransferWidgetOpen(false);
+ }
+ }
+
+ if (idleSeconds === 120) {
+ if (context?.nearestSightId) {
+ setSelectedSightId(context.nearestSightId);
+ }
+ }
+ };
+
+ const intervalId = setInterval(checkIdle, 1000);
+
+ const resetIdle = () => {
+ idleSeconds = 0;
+ };
+
+ const events = [
+ "mousedown",
+ "mousemove",
+ "keypress",
+ "scroll",
+ "touchstart",
+ "click",
+ ];
+
+ events.forEach((event) => {
+ window.addEventListener(event, resetIdle, { passive: true });
+ });
+
+ return () => {
+ clearInterval(intervalId);
+ events.forEach((event) => {
+ window.removeEventListener(event, resetIdle);
+ });
+ };
+ }, []);
+
+ return (
+
+ {isLoading ? (
+
setIsDebugVisible((prev) => !prev)}
+ />
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+});
+
+export default App;
diff --git a/src/client/src/api/ApiStore/api.ts b/src/client/src/api/ApiStore/api.ts
new file mode 100644
index 0000000..ad6944f
--- /dev/null
+++ b/src/client/src/api/ApiStore/api.ts
@@ -0,0 +1,96 @@
+import { apiInstance } from "../apiConfig";
+import {
+ GetRouteSightsResponse,
+ GetRouteStationsResponse,
+ GetMediaResponse,
+ GetRouteResponse,
+ GetCarrierResponse,
+ GetCityResponse,
+ GetSightArticleResponse,
+ GetArticleResponse,
+} from "./types";
+
+export const getRoute = async (routeId: string): Promise => {
+ const response = await apiInstance.get(`/route/${routeId}`);
+ return response.data;
+};
+
+export const getCarrier = async (
+ carrierId: number,
+): Promise => {
+ const response = await apiInstance.get(`/carrier/${carrierId}`);
+ return response.data;
+};
+
+export const getCity = async (cityId: number): Promise => {
+ const response = await apiInstance.get(`/city/${cityId}`);
+ return response.data;
+};
+
+export const getRouteSights = async (
+ routeId: string,
+ lang: string = "ru",
+): Promise => {
+ const response = await apiInstance.get(
+ `/route/${routeId}/sight?lang=${lang}`,
+ );
+ return response.data;
+};
+
+export const getRouteStations = async (
+ routeId: string,
+ lang: string = "ru",
+): Promise => {
+ const response = await apiInstance.get(
+ `/route/${routeId}/station?lang=${lang}`,
+ );
+ return response.data;
+};
+
+export const getArticleMedia = async (
+ articleId: number,
+): Promise => {
+ const response = await apiInstance.get(`/article/${articleId}/media`);
+ return response.data;
+};
+
+export const getMedia = async (): Promise => {
+ const response = await apiInstance.get("/media");
+ return response.data;
+};
+
+export const getArticle = async (
+ articleId: number,
+ lang: string = "ru",
+): Promise => {
+ const response = await apiInstance.get(`/article/${articleId}?lang=${lang}`);
+ return response.data;
+};
+
+export const getSightArticlesIds = async (
+ sightId: number,
+): Promise => {
+ const response = await apiInstance.get(`/sight/${sightId}/article`);
+ const articles = response.data;
+ return articles.map((article: GetSightArticleResponse) => article.id);
+};
+
+export const getSightStations = async (
+ sightId: number,
+ lang: string = "ru",
+): Promise => {
+ const response = await apiInstance.get(
+ `/sight/${sightId}/station?lang=${lang}`,
+ );
+ return response.data;
+};
+
+export const getStationSights = async (
+ stationId: number,
+ lang: string = "ru",
+): Promise => {
+ const response = await apiInstance.get(
+ `/station/${stationId}/sight?lang=${lang}`,
+ );
+ return response.data;
+};
diff --git a/src/client/src/api/ApiStore/index.ts b/src/client/src/api/ApiStore/index.ts
new file mode 100644
index 0000000..3c20093
--- /dev/null
+++ b/src/client/src/api/ApiStore/index.ts
@@ -0,0 +1,8 @@
+export { apiStore } from "./store";
+export type {
+ GetContextResponse,
+ GetWeatherResponse,
+ GetRouteSightsResponse,
+ GetRouteStationsResponse,
+ GetMediaResponse,
+} from "./types";
diff --git a/src/client/src/api/ApiStore/store.ts b/src/client/src/api/ApiStore/store.ts
new file mode 100644
index 0000000..6efae5c
--- /dev/null
+++ b/src/client/src/api/ApiStore/store.ts
@@ -0,0 +1,420 @@
+import { makeAutoObservable, runInAction } from "mobx";
+import {
+ getRouteSights,
+ getRouteStations,
+ getRoute,
+ getCarrier,
+ getCity,
+ getArticle,
+ getArticleMedia,
+ getSightStations,
+ getStationSights,
+ getSightArticlesIds,
+} from "./api";
+import {
+ GetContextResponse,
+ GetRouteSightsResponse,
+ GetRouteStationsResponse,
+ GetMediaResponse,
+ GetRouteResponse,
+ GetCarrierResponse,
+ GetCityResponse,
+ GetSightArticleResponse,
+} from "./types";
+import { orderStationsByRoute } from "../../utils/routeStationsUtils";
+
+class ApiStore {
+ isLoading = true;
+ loadingStatus: string | null = null;
+ loadingProgress: { current: number; total: number } | null = null;
+
+ routeId: string | null = null;
+
+ context: GetContextResponse | null = null;
+
+ routeSights: GetRouteSightsResponse | null = null;
+ routeSightsEn: GetRouteSightsResponse | null = null;
+ routeSightsZh: GetRouteSightsResponse | null = null;
+
+ routeStations: GetRouteStationsResponse | null = null;
+ routeStationsEn: GetRouteStationsResponse | null = null;
+ routeStationsZh: GetRouteStationsResponse | null = null;
+ orderedRouteStations: GetRouteStationsResponse | null = null;
+
+ sightArticles: Map = new Map();
+ sightArticlesEn: Map = new Map();
+ sightArticlesZh: Map = new Map();
+
+ sightArticlesIds: Map = new Map();
+
+ sightStationsCache: Map = new Map();
+ stationSightsCache: Map = new Map();
+
+ route: GetRouteResponse | null = null;
+ carrier: GetCarrierResponse | null = null;
+ city: GetCityResponse | null = null;
+
+ private positionIndex = 0;
+ private positionInterval: ReturnType | null = null;
+
+ simulationSpeed = 1;
+ simulationDirection: 1 | -1 = 1;
+ simulationPaused = false;
+ simulationInstantMove = false;
+
+ constructor() {
+ makeAutoObservable(this);
+ }
+
+ setRouteId = (id: string) => {
+ this.routeId = id;
+ };
+
+ setIsLoading = (isLoading: boolean) => {
+ this.isLoading = isLoading;
+ if (!isLoading) {
+ this.loadingStatus = null;
+ this.loadingProgress = null;
+ }
+ };
+
+ setLoadingStatus = (status: string | null) => {
+ this.loadingStatus = status;
+ };
+
+ setLoadingProgress = (current: number, total: number) => {
+ this.loadingProgress = { current, total };
+ };
+
+ getRoute = async () => {
+ this.route = await getRoute(this.routeId!);
+ this.updateOrderedRouteStations();
+ };
+
+ getCarrier = async () => {
+ this.carrier = await getCarrier(this.route!.carrier_id!);
+ };
+
+ getCity = async () => {
+ this.city = await getCity(this.carrier!.city_id!);
+ };
+
+ getRouteSights = async () => {
+ this.routeSights = await getRouteSights(this.routeId!);
+ this.routeSightsEn = await getRouteSights(this.routeId!, "en");
+ this.routeSightsZh = await getRouteSights(this.routeId!, "zh");
+ };
+
+ getRouteStations = async () => {
+ this.routeStations = await getRouteStations(this.routeId!);
+ this.routeStationsEn = await getRouteStations(this.routeId!, "en");
+ this.routeStationsZh = await getRouteStations(this.routeId!, "zh");
+ this.updateOrderedRouteStations();
+ };
+
+ updateOrderedRouteStations = () => {
+ if (
+ !this.routeStations ||
+ !this.route?.path ||
+ !this.context?.startStopId ||
+ !this.context?.endStopId
+ ) {
+ runInAction(() => {
+ this.orderedRouteStations = null;
+ });
+ return;
+ }
+
+ const ordered = orderStationsByRoute(
+ this.routeStations,
+ this.route.path,
+ this.context.startStopId,
+ this.context.endStopId
+ );
+
+ runInAction(() => {
+ this.orderedRouteStations = ordered;
+ });
+ };
+
+ setSimulationSpeed = (speed: number) => {
+ this.simulationSpeed = Math.max(0.25, Math.min(5, speed));
+ if (this.positionInterval) {
+ clearInterval(this.positionInterval);
+ const intervalMs = Math.round(3000 / this.simulationSpeed);
+ this.positionInterval = setInterval(() => {
+ if (!this.simulationPaused) {
+ this.updateSimulatedPosition();
+ }
+ }, intervalMs);
+ }
+ };
+
+ setSimulationDirection = (direction: 1 | -1) => {
+ this.simulationDirection = direction;
+ };
+
+ toggleSimulationDirection = () => {
+ this.simulationDirection = this.simulationDirection === 1 ? -1 : 1;
+ };
+
+ setSimulationPaused = (paused: boolean) => {
+ this.simulationPaused = paused;
+ };
+
+ toggleSimulationPaused = () => {
+ this.simulationPaused = !this.simulationPaused;
+ };
+
+ toggleSimulationInstantMove = () => {
+ this.simulationInstantMove = !this.simulationInstantMove;
+ };
+
+ startPositionSimulation = () => {
+ if (this.positionInterval) return;
+
+ this.updateSimulatedPosition();
+
+ const intervalMs = Math.round(3000 / this.simulationSpeed);
+ this.positionInterval = setInterval(() => {
+ if (!this.simulationPaused) {
+ this.updateSimulatedPosition();
+ }
+ }, intervalMs);
+ };
+
+ stopPositionSimulation = () => {
+ if (this.positionInterval) {
+ clearInterval(this.positionInterval);
+ this.positionInterval = null;
+ }
+ };
+
+ private updateSimulatedPosition = () => {
+ if (!this.route?.path?.length) return;
+
+ const path = this.route.path;
+ const step = Math.max(1, Math.floor(path.length / 120));
+ this.positionIndex = this.positionIndex + step * this.simulationDirection;
+ if (this.positionIndex >= path.length) this.positionIndex = 0;
+ if (this.positionIndex < 0) this.positionIndex = path.length - 1;
+
+ const [lat, lon] = path[this.positionIndex];
+
+ let nearestStationId = this.context?.nearestStationId ?? null;
+ if (this.routeStations?.length) {
+ let minDist = Infinity;
+ for (const station of this.routeStations) {
+ const d =
+ Math.pow(station.latitude - lat, 2) +
+ Math.pow(station.longitude - lon, 2);
+ if (d < minDist) {
+ minDist = d;
+ nearestStationId = String(station.id);
+ }
+ }
+ }
+
+ let nearestSightId = this.context?.nearestSightId ?? null;
+ if (this.routeSights?.length) {
+ let minDist = Infinity;
+ for (const sight of this.routeSights) {
+ const d =
+ Math.pow(sight.latitude - lat, 2) +
+ Math.pow(sight.longitude - lon, 2);
+ if (d < minDist) {
+ minDist = d;
+ nearestSightId = String(sight.id);
+ }
+ }
+ }
+
+ const percentageCompleted =
+ (this.positionIndex / path.length) * 100;
+
+ const startStopId =
+ this.routeStations?.[0]
+ ? String(this.routeStations[0].id)
+ : this.context?.startStopId ?? "";
+ const endStopId =
+ this.routeStations?.length
+ ? String(this.routeStations[this.routeStations.length - 1].id)
+ : this.context?.endStopId ?? "";
+
+ runInAction(() => {
+ this.context = {
+ routeId: this.routeId!,
+ routeNumber: this.route?.route_number ?? "",
+ currentCoordinates: { latitude: lat, longitude: lon },
+ rawCoordinates: { latitude: lat, longitude: lon },
+ startStopId,
+ endStopId,
+ nearestSightId: nearestSightId ?? "",
+ nearestStationId: nearestStationId ?? undefined,
+ routeProgress: {
+ startStopId,
+ endStopId: nearestStationId ?? endStopId,
+ percentageCompleted,
+ },
+ maintenanceModeOn: false,
+ } as GetContextResponse;
+
+ if (!this.orderedRouteStations) {
+ this.updateOrderedRouteStations();
+ }
+ });
+ };
+
+ private getArticle = async (articleId: number, lang: string) => {
+ const article = await getArticle(articleId, lang);
+
+ const isArticleExists = this.sightArticles.has(
+ articleId.toString() + "_" + "ru"
+ );
+
+ let media: GetMediaResponse;
+ if (isArticleExists) {
+ const ruArticle = this.sightArticles.get(
+ articleId.toString() + "_" + "ru"
+ );
+ media = ruArticle?.media!;
+ } else {
+ media = await getArticleMedia(articleId);
+ }
+
+ runInAction(() => {
+ if (lang === "ru") {
+ this.sightArticles.set(articleId.toString() + "_" + "ru", {
+ id: articleId,
+ heading: article.heading,
+ body: article.body,
+ media,
+ });
+ } else if (lang === "en") {
+ this.sightArticlesEn.set(articleId.toString() + "_" + "en", {
+ id: articleId,
+ heading: article.heading,
+ body: article.body,
+ media,
+ });
+ } else if (lang === "zh") {
+ this.sightArticlesZh.set(articleId.toString() + "_" + "zh", {
+ id: articleId,
+ heading: article.heading,
+ body: article.body,
+ media,
+ });
+ }
+ });
+ };
+
+ private getSightArticles = async (sightId: number) => {
+ const leftArticle = this.routeSights?.find(
+ (sight) => sight.id === sightId
+ )?.left_article;
+
+ const rightArticles = await getSightArticlesIds(sightId);
+
+ runInAction(() => {
+ this.sightArticlesIds.set(sightId, rightArticles);
+ });
+
+ if (leftArticle) {
+ this.setLoadingStatus(`Загрузка статьи ${leftArticle} (ru)...`);
+ await this.getArticle(leftArticle, "ru");
+ this.setLoadingStatus(`Загрузка статьи ${leftArticle} (en)...`);
+ await this.getArticle(leftArticle, "en");
+ this.setLoadingStatus(`Загрузка статьи ${leftArticle} (zh)...`);
+ await this.getArticle(leftArticle, "zh");
+ }
+
+ for (const article of rightArticles) {
+ this.setLoadingStatus(`Загрузка статьи ${article} (ru)...`);
+ await this.getArticle(article, "ru");
+ this.setLoadingStatus(`Загрузка статьи ${article} (en)...`);
+ await this.getArticle(article, "en");
+ this.setLoadingStatus(`Загрузка статьи ${article} (zh)...`);
+ await this.getArticle(article, "zh");
+ }
+ };
+
+ getAllSightArticles = async () => {
+ const totalSights = this.routeSights!.length;
+ let currentSight = 0;
+
+ for (const sight of this.routeSights!) {
+ currentSight++;
+ this.setLoadingStatus(
+ `Загрузка статей для достопримечательности ${currentSight}/${totalSights}...`
+ );
+ this.setLoadingProgress(currentSight, totalSights);
+ await this.getSightArticles(sight.id);
+ }
+ };
+
+ getSightStations = async (sightId: number, lang: string = "ru") => {
+ const cacheKey = `${sightId}_${lang}`;
+
+ if (this.sightStationsCache.has(cacheKey)) {
+ return this.sightStationsCache.get(cacheKey)!;
+ }
+
+ const stations = await getSightStations(sightId, lang);
+
+ runInAction(() => {
+ this.sightStationsCache.set(cacheKey, stations);
+ });
+
+ return stations;
+ };
+
+ getStationSights = async (stationId: number, lang: string = "ru") => {
+ const cacheKey = `${stationId}_${lang}`;
+
+ if (this.stationSightsCache.has(cacheKey)) {
+ return this.stationSightsCache.get(cacheKey)!;
+ }
+
+ const sights = await getStationSights(stationId, lang);
+
+ runInAction(() => {
+ this.stationSightsCache.set(cacheKey, sights);
+ });
+
+ return sights;
+ };
+
+ preloadSightStationsAndStationSights = async () => {
+ if (!this.routeSights || !this.routeStations) return;
+
+ const languages = ["ru", "en", "zh"];
+
+ for (const sight of this.routeSights) {
+ for (const lang of languages) {
+ try {
+ await this.getSightStations(sight.id, lang);
+ } catch (error) {
+ console.warn(
+ `Failed to preload stations for sight ${sight.id} (${lang}):`,
+ error
+ );
+ }
+ }
+ }
+
+ for (const station of this.routeStations) {
+ for (const lang of languages) {
+ try {
+ await this.getStationSights(station.id, lang);
+ } catch (error) {
+ console.warn(
+ `Failed to preload sights for station ${station.id} (${lang}):`,
+ error
+ );
+ }
+ }
+ }
+ };
+}
+
+export const apiStore = new ApiStore();
diff --git a/src/client/src/api/ApiStore/types.ts b/src/client/src/api/ApiStore/types.ts
new file mode 100644
index 0000000..c6c0372
--- /dev/null
+++ b/src/client/src/api/ApiStore/types.ts
@@ -0,0 +1,156 @@
+export type GetContextResponse = {
+ currentCoordinates: {
+ latitude: number;
+ longitude: number;
+ };
+ endStopId: string;
+ nearestSightId: string;
+ rawCoordinates: {
+ latitude: number;
+ longitude: number;
+ };
+ routeId: string;
+ route_id?: number | string;
+ routeNumber: string;
+ routeProgress: {
+ startStopId: string;
+ endStopId: string;
+ percentageCompleted: number;
+ };
+ startStopId: string;
+ maintenanceModeOn: boolean;
+ error?: string | boolean | { message?: string };
+ message?: string;
+ status?: string;
+};
+
+export type GetContextStationsResponse = {
+ stationId: string[];
+};
+
+export type GetWeatherResponse = {
+ currentWeather: {
+ temperatureCelsius: number;
+ description: string;
+ humidity?: number;
+ windSpeed: number;
+ windDegree: number;
+ };
+ forecast: {
+ date: string;
+ description: string;
+ humidity?: number;
+
+ minTemperatureCelsius: number;
+ maxTemperatureCelsius: number;
+ windDegree: number;
+ windSpeed: number;
+ }[];
+};
+
+export type GetRouteResponse = {
+ carrier: string;
+ carrier_id: number;
+ center_latitude: number;
+ center_longitude: number;
+ governor_appeal: number;
+ id: number;
+ path: [number, number][];
+ rotate: number;
+ route_direction: boolean;
+ route_number: string;
+ route_sys_number: string;
+ scale_max: number;
+ scale_min: number;
+ video_preview: string;
+ video_timer?: number;
+ icon?: string;
+ icon_size?: number;
+ font_size?: number;
+};
+
+export type GetCarrierResponse = {
+ city: string;
+ city_id: number;
+ full_name: string;
+ id: number;
+ logo: string;
+ short_name: string;
+ slogan: string;
+};
+
+export type GetCityResponse = {
+ arms: string;
+ country: string;
+ country_code: string;
+ id: number;
+ name: string;
+};
+
+export type GetRouteSightsResponse = {
+ id: number;
+ name: string;
+ description: string;
+ city_id: string;
+ address: string;
+ latitude: number;
+ longitude: number;
+ thumbnail: string;
+ watermark_lu: string;
+ watermark_rd: string;
+ left_article: number;
+ preview_media: string;
+ video_preview: string;
+ icon_size?: number;
+ icon?: string;
+ alt_icon?: string;
+ is_default_icon?: boolean;
+}[];
+
+export type GetRouteStationsResponse = {
+ id: number;
+ name: string;
+ system_name: string;
+ description: string;
+ address: string;
+ latitude: number;
+ longitude: number;
+ direction: boolean;
+ city_id: string;
+ offset_x: number;
+ offset_y: number;
+ align: number;
+ stick: number;
+ icon?: string;
+ transfers: {
+ tram: string;
+ trolleybus: string;
+ bus: string;
+ train: string;
+ metro_red: string;
+ metro_green: string;
+ metro_blue: string;
+ metro_purple: string;
+ metro_orange: string;
+ };
+}[];
+
+export type GetMediaResponse = {
+ id: string;
+ filename: string;
+ media_name: string;
+ media_type: number;
+};
+
+export type GetArticleResponse = {
+ id: number;
+ heading: string;
+ body: string;
+};
+
+export type GetSightArticleResponse = {
+ id: number;
+ heading: string;
+ body: string;
+ media: GetMediaResponse;
+};
diff --git a/src/client/src/api/apiConfig.js b/src/client/src/api/apiConfig.js
new file mode 100644
index 0000000..85f0b45
--- /dev/null
+++ b/src/client/src/api/apiConfig.js
@@ -0,0 +1,37 @@
+import axios from "axios";
+
+const envUrl = import.meta.env.VITE_API_URL || "localhost";
+const baseURL = envUrl.includes("://") ? envUrl : `http://${envUrl}`;
+const isFullUrl =
+ baseURL.includes("://") &&
+ !envUrl.includes("localhost") &&
+ !envUrl.includes("127.0.0.1");
+
+const addAuthInterceptor = (instance) => {
+ instance.interceptors.request.use((config) => {
+ const token = localStorage.getItem("token");
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ });
+};
+
+export const apiBaseURL = isFullUrl ? baseURL : `${baseURL}:8080`;
+export const geoBaseURL = isFullUrl ? baseURL : `${baseURL}:6001`;
+export const weatherBaseURL = isFullUrl ? baseURL : `${baseURL}:6002`;
+
+export const getMediaUrl = (id) => {
+ const token = localStorage.getItem("token");
+ const base = `${apiBaseURL}/media/${id}/download`;
+ return token ? `${base}?token=${token}` : base;
+};
+
+export const apiInstance = axios.create({ baseURL: apiBaseURL });
+addAuthInterceptor(apiInstance);
+
+export const geoInstance = axios.create({ baseURL: geoBaseURL });
+addAuthInterceptor(geoInstance);
+
+export const weatherInstance = axios.create({ baseURL: weatherBaseURL });
+addAuthInterceptor(weatherInstance);
diff --git a/src/client/src/api/content/content.api.js b/src/client/src/api/content/content.api.js
new file mode 100644
index 0000000..cd17c5a
--- /dev/null
+++ b/src/client/src/api/content/content.api.js
@@ -0,0 +1,224 @@
+import { apiInstance, getMediaUrl } from "../apiConfig";
+
+const ContentAPI = {
+ async getStartStation(startStopId, lang = "ru") {
+ if (!startStopId) {
+ console.warn("startStopId не указан.");
+ return null;
+ }
+
+ try {
+ const response = await apiInstance.get(
+ `/station/${startStopId}?lang=${lang}`
+ );
+ return response.data;
+ } catch (error) {
+ console.error("Ошибка при запросе:", error.message);
+ throw error;
+ }
+ },
+
+ async getEndStation(endStopId, lang = "ru") {
+ if (!endStopId) {
+ console.warn("endStopId не указан.");
+ return null;
+ }
+
+ try {
+ const response = await apiInstance.get(
+ `/station/${endStopId}?lang=${lang}`
+ );
+ return response.data;
+ } catch (error) {
+ console.error("Ошибка при запросе:", error.message);
+ throw error;
+ }
+ },
+
+ async getListOfStations(id, lang = "ru") {
+ try {
+ const responce = await apiInstance.get(
+ `/route/${id}/station?lang=${lang}`
+ );
+
+ return responce.data;
+ } catch (error) {
+ console.error("Ошибка при запросе:", error.message);
+ throw error;
+ }
+ },
+
+ async getRoutePath(id, lang = "ru") {
+ try {
+ const responce = await apiInstance.get(`/route/${id}?lang=${lang}`);
+ return responce.data;
+ } catch (error) {
+ console.error("Ошибка при запросе:", error.message);
+ throw error;
+ }
+ },
+
+ async getRouteSights(id, lang = "ru") {
+ try {
+ const responce = await apiInstance.get(
+ `/route/${id}/sight?lang=${lang}`
+ );
+ return responce.data;
+ } catch (error) {
+ console.error("Ошибка при запросе:", error.message);
+ throw error;
+ }
+ },
+
+ async getSightData(id, lang = "ru") {
+ try {
+ const responce = await apiInstance.get(`/sight/${id}?lang=${lang}`);
+ return responce.data;
+ } catch (error) {
+ console.error("Ошибка при запросе:", error.message);
+ throw error;
+ }
+ },
+
+ async getSightArticle(id, lang = "ru") {
+ try {
+ const responce = await apiInstance.get(
+ `/sight/${id}/article?lang=${lang}`
+ );
+ return responce.data;
+ } catch (error) {
+ console.error("Ошибка при запросе:", error.message);
+ throw error;
+ }
+ },
+
+ async getMedia(id, lang = "ru", signal) {
+ try {
+ const responce = await apiInstance.get(
+ `/article/${id}/media?lang=${lang}`,
+ { signal }
+ );
+ return {
+ path: getMediaUrl(responce.data[0].id),
+ type: responce.data[0].media_type,
+ };
+ } catch (error) {
+ console.error("Ошибка в получении медиа:", error.message);
+ throw error;
+ }
+ },
+
+ async getMediaPreview(id, lang = "ru", signal) {
+ try {
+ const responce = await apiInstance.get(
+ `/media/${id}?lang=${lang}`,
+ { signal }
+ );
+ return {
+ path: getMediaUrl(id),
+ type: responce.data.media_type,
+ };
+ } catch (error) {
+ console.error("Ошибка в получении медиа-превью:", error.message);
+ throw error;
+ }
+ },
+
+ async getCarrierAndCrestByRoute(id, lang = "ru") {
+ try {
+ const responce = await apiInstance.get(`/route/${id}?lang=${lang}`);
+ const carrier = await apiInstance.get(
+ `/carrier/${responce.data.carrier_id}?lang=${lang}`
+ );
+ const creast = await apiInstance.get(
+ `/city/${carrier.data.city_id}?lang=${lang}`
+ );
+ return {
+ carrierPath: getMediaUrl(carrier.data.logo),
+ creastPath: getMediaUrl(creast.data.arms),
+ };
+ } catch (error) {
+ console.error("Ошибка в получении герба:", error.message);
+ throw error;
+ }
+ },
+
+ async getLeftArticle(id, lang = "ru") {
+ try {
+ const responce = await apiInstance.get(`/sight/${id}?lang=${lang}`);
+ const leftArticle = await apiInstance.get(
+ `/article/${responce.data.left_article}?lang=${lang}`
+ );
+ const leftArticleMedia = await apiInstance.get(
+ `/article/${leftArticle.data.id}/media?lang=${lang}`
+ );
+ if (leftArticleMedia.data.length == 0) {
+ return {
+ mediaPath: null,
+ mediaType: null,
+ title: leftArticle.data.heading,
+ text: leftArticle.data.body,
+ address: responce.data.address,
+ };
+ }
+ return {
+ mediaPath: getMediaUrl(leftArticleMedia.data[0].id),
+ mediaType: leftArticleMedia.data[0].media_type,
+ title: leftArticle.data.heading,
+ text: leftArticle.data.body,
+ address: responce.data.address,
+ };
+ } catch (error) {
+ console.error(
+ "Ошибка в получении информации для левого виджета:",
+ error.message
+ );
+ throw error;
+ }
+ },
+
+ async getListOfTransfer(routeId, stationId, lang = "ru") {
+ try {
+ const response = await apiInstance.get(
+ `/route/${routeId}/station?lang=${lang}`
+ );
+ const stationData = response.data.find((s) => s.id == stationId);
+ if (!stationData || !stationData.transfers) {
+ return "no data";
+ }
+
+ const result = {
+ tram: [],
+ trolleybus: [],
+ bus: [],
+ train: [],
+ metro_red: [],
+ metro_green: [],
+ metro_blue: [],
+ metro_purple: [],
+ metro_orange: [],
+ };
+ for (const type in stationData.transfers) {
+ if (
+ Object.hasOwnProperty.call(stationData.transfers, type) &&
+ result[type] !== undefined
+ ) {
+ result[type] = stationData.transfers[type];
+ }
+ }
+ return result;
+ } catch (error) {
+ console.error(
+ "Ошибка в получении информации о пересадках:",
+ error.message
+ );
+ throw error;
+ }
+ },
+
+ getMediaPath(path) {
+ return getMediaUrl(path);
+ },
+};
+
+export default ContentAPI;
diff --git a/src/client/src/api/geo/geo.api.js b/src/client/src/api/geo/geo.api.js
new file mode 100644
index 0000000..150e2dc
--- /dev/null
+++ b/src/client/src/api/geo/geo.api.js
@@ -0,0 +1,13 @@
+import { geoInstance } from "../apiConfig";
+
+export default {
+ async GetContext() {
+ try {
+ const response = await geoInstance.get("/v1/geolocation/context");
+ return response.data;
+ } catch (error) {
+ console.error("Geoposition API error:", error);
+ throw new Error("Не удалось получить данные о маршруте");
+ }
+ },
+};
diff --git a/src/client/src/api/weather/weather.api.js b/src/client/src/api/weather/weather.api.js
new file mode 100644
index 0000000..a841d8c
--- /dev/null
+++ b/src/client/src/api/weather/weather.api.js
@@ -0,0 +1,139 @@
+import { weatherInstance } from "../apiConfig";
+
+const WEATHER_STATUS_MAP = {
+ Rain: "дождливо",
+ Clouds: "облачно",
+ Clear: "солнечно",
+ Thunderstorm: "гроза",
+ Snow: "снег",
+ Drizzle: "мелкий дождь",
+ Fog: "туман",
+};
+
+export default {
+ async getFormattedWeather(lat = 59.938784, lng = 30.314997) {
+ try {
+ const response = await weatherInstance.post("/v1/weather", {
+ coordinates: { latitude: lat, longitude: lng },
+ });
+
+ return this._formatWeatherData(response.data);
+ } catch (error) {
+ console.error("Weather API error:", error);
+ throw new Error("Не удалось получить данные о погоде");
+ }
+ },
+
+ _formatWeatherData(data) {
+ const today = new Date();
+ const tomorrow = new Date();
+ const dayAfterTomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ dayAfterTomorrow.setDate(today.getDate() + 2);
+
+ const formatDate = (date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ };
+
+ const tomorrowDateStr = formatDate(tomorrow);
+ const dayAfterTomorrowDateStr = formatDate(dayAfterTomorrow);
+
+ const todayForecast = data.currentWeather;
+
+ const tomorrowForecast = (data.forecast || []).find((item) => {
+ if (!item || !item.date) return false;
+
+ const itemDateStr = item.date;
+ return itemDateStr === tomorrowDateStr;
+ });
+
+ const dayAfterTomorrowForecast = (data.forecast || []).find((item) => {
+ if (!item || !item.date) return false;
+
+ const itemDateStr = item.date;
+ return itemDateStr === dayAfterTomorrowDateStr;
+ });
+
+ const getTemperature = (forecast) => {
+ if (!forecast) return "N/A";
+
+ if (
+ forecast.minTemperatureCelsius != null &&
+ forecast.maxTemperatureCelsius != null
+ ) {
+ const max = Math.round(forecast.maxTemperatureCelsius);
+ const min = Math.round(forecast.minTemperatureCelsius);
+
+ return `${max} / ${min}`;
+ }
+
+ if (forecast.minTemperatureCelsius != null) {
+ return Math.round(forecast.minTemperatureCelsius);
+ }
+
+ if (forecast.maxTemperatureCelsius != null) {
+ return Math.round(forecast.maxTemperatureCelsius);
+ }
+
+ if (forecast.temperatureCelsius != null) {
+ return Math.round(forecast.temperatureCelsius);
+ }
+
+ return "N/A";
+ };
+
+ return {
+ today: {
+ temperature: todayForecast ? getTemperature(todayForecast) : "N/A",
+ status:
+ todayForecast && todayForecast.description
+ ? WEATHER_STATUS_MAP[todayForecast.description] ||
+ todayForecast.description
+ : "данные отсутствуют",
+ precipitation:
+ todayForecast && todayForecast.humidity != null
+ ? todayForecast.humidity
+ : "N/A",
+ windSpeed:
+ todayForecast && todayForecast.windSpeed != null
+ ? todayForecast.windSpeed
+ : "N/A",
+ },
+ tomorrow: {
+ temperature: getTemperature(tomorrowForecast),
+ status:
+ tomorrowForecast && tomorrowForecast.description
+ ? WEATHER_STATUS_MAP[tomorrowForecast.description] ||
+ tomorrowForecast.description
+ : "данные отсутствуют",
+ precipitation:
+ tomorrowForecast && tomorrowForecast.humidity != null
+ ? tomorrowForecast.humidity
+ : "N/A",
+ windSpeed:
+ tomorrowForecast && tomorrowForecast.windSpeed != null
+ ? tomorrowForecast.windSpeed
+ : "N/A",
+ },
+ dayAfterTomorrow: {
+ temperature: getTemperature(dayAfterTomorrowForecast),
+ status:
+ dayAfterTomorrowForecast && dayAfterTomorrowForecast.description
+ ? WEATHER_STATUS_MAP[dayAfterTomorrowForecast.description] ||
+ dayAfterTomorrowForecast.description
+ : "данные отсутствуют",
+ precipitation:
+ dayAfterTomorrowForecast && dayAfterTomorrowForecast.humidity != null
+ ? dayAfterTomorrowForecast.humidity
+ : "N/A",
+ windSpeed:
+ dayAfterTomorrowForecast && dayAfterTomorrowForecast.windSpeed != null
+ ? dayAfterTomorrowForecast.windSpeed
+ : "N/A",
+ },
+ };
+ },
+};
diff --git a/src/client/src/assets/Constants.jsx b/src/client/src/assets/Constants.jsx
new file mode 100644
index 0000000..bac9df4
--- /dev/null
+++ b/src/client/src/assets/Constants.jsx
@@ -0,0 +1,9 @@
+export const UP_SCALE = 30000;
+export const PATH_WIDTH = 15;
+export const STATION_RADIUS = 20;
+export const STATION_OUTLINE_WIDTH = 10;
+export const SIGHT_SIZE = 30;
+export const SCALE_FACTOR = 50;
+
+export const BACKGROUND_COLOR = 0x111111;
+export const PATH_COLOR = 0xff4d4d;
diff --git a/src/client/src/assets/fullscreen-modal-actions/close.png b/src/client/src/assets/fullscreen-modal-actions/close.png
new file mode 100644
index 0000000..c6ae874
Binary files /dev/null and b/src/client/src/assets/fullscreen-modal-actions/close.png differ
diff --git a/src/client/src/assets/fullscreen-modal-actions/scale_minus.png b/src/client/src/assets/fullscreen-modal-actions/scale_minus.png
new file mode 100644
index 0000000..7687481
Binary files /dev/null and b/src/client/src/assets/fullscreen-modal-actions/scale_minus.png differ
diff --git a/src/client/src/assets/fullscreen-modal-actions/scale_plus.png b/src/client/src/assets/fullscreen-modal-actions/scale_plus.png
new file mode 100644
index 0000000..f3b61d7
Binary files /dev/null and b/src/client/src/assets/fullscreen-modal-actions/scale_plus.png differ
diff --git a/src/client/src/assets/icons/bus.svg b/src/client/src/assets/icons/bus.svg
new file mode 100644
index 0000000..2d98c63
--- /dev/null
+++ b/src/client/src/assets/icons/bus.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/client/src/assets/icons/clear-day.svg b/src/client/src/assets/icons/clear-day.svg
new file mode 100644
index 0000000..b7cac9d
--- /dev/null
+++ b/src/client/src/assets/icons/clear-day.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/client/src/assets/icons/cloudy.svg b/src/client/src/assets/icons/cloudy.svg
new file mode 100644
index 0000000..e45d371
--- /dev/null
+++ b/src/client/src/assets/icons/cloudy.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/client/src/assets/icons/default.svg b/src/client/src/assets/icons/default.svg
new file mode 100644
index 0000000..b7cac9d
--- /dev/null
+++ b/src/client/src/assets/icons/default.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/client/src/assets/icons/fog.svg b/src/client/src/assets/icons/fog.svg
new file mode 100644
index 0000000..12a65ed
--- /dev/null
+++ b/src/client/src/assets/icons/fog.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/metro_blue.svg b/src/client/src/assets/icons/metro_blue.svg
new file mode 100644
index 0000000..2e7a2a1
--- /dev/null
+++ b/src/client/src/assets/icons/metro_blue.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/metro_green.svg b/src/client/src/assets/icons/metro_green.svg
new file mode 100644
index 0000000..f50babf
--- /dev/null
+++ b/src/client/src/assets/icons/metro_green.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/metro_orange.svg b/src/client/src/assets/icons/metro_orange.svg
new file mode 100644
index 0000000..fe22746
--- /dev/null
+++ b/src/client/src/assets/icons/metro_orange.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/metro_purple.svg b/src/client/src/assets/icons/metro_purple.svg
new file mode 100644
index 0000000..31b1a17
--- /dev/null
+++ b/src/client/src/assets/icons/metro_purple.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/metro_red.svg b/src/client/src/assets/icons/metro_red.svg
new file mode 100644
index 0000000..59c2db0
--- /dev/null
+++ b/src/client/src/assets/icons/metro_red.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/rainy.svg b/src/client/src/assets/icons/rainy.svg
new file mode 100644
index 0000000..92b255d
--- /dev/null
+++ b/src/client/src/assets/icons/rainy.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/snowy.svg b/src/client/src/assets/icons/snowy.svg
new file mode 100644
index 0000000..46dc49f
--- /dev/null
+++ b/src/client/src/assets/icons/snowy.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/client/src/assets/icons/thunderstorms.svg b/src/client/src/assets/icons/thunderstorms.svg
new file mode 100644
index 0000000..f9388d4
--- /dev/null
+++ b/src/client/src/assets/icons/thunderstorms.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/train.svg b/src/client/src/assets/icons/train.svg
new file mode 100644
index 0000000..03c2e9f
--- /dev/null
+++ b/src/client/src/assets/icons/train.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/icons/tram-bottom-left.svg b/src/client/src/assets/icons/tram-bottom-left.svg
new file mode 100644
index 0000000..26147ff
--- /dev/null
+++ b/src/client/src/assets/icons/tram-bottom-left.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/icons/tram-bottom-right.svg b/src/client/src/assets/icons/tram-bottom-right.svg
new file mode 100644
index 0000000..b801670
--- /dev/null
+++ b/src/client/src/assets/icons/tram-bottom-right.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/icons/tram-left.svg b/src/client/src/assets/icons/tram-left.svg
new file mode 100644
index 0000000..993b9fa
--- /dev/null
+++ b/src/client/src/assets/icons/tram-left.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/icons/tram-right.svg b/src/client/src/assets/icons/tram-right.svg
new file mode 100644
index 0000000..9d93d79
--- /dev/null
+++ b/src/client/src/assets/icons/tram-right.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/icons/tram-top-left.svg b/src/client/src/assets/icons/tram-top-left.svg
new file mode 100644
index 0000000..30c2604
--- /dev/null
+++ b/src/client/src/assets/icons/tram-top-left.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/icons/tram-top-right.svg b/src/client/src/assets/icons/tram-top-right.svg
new file mode 100644
index 0000000..bdb0f74
--- /dev/null
+++ b/src/client/src/assets/icons/tram-top-right.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/icons/tram.svg b/src/client/src/assets/icons/tram.svg
new file mode 100644
index 0000000..1107938
--- /dev/null
+++ b/src/client/src/assets/icons/tram.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/client/src/assets/icons/trolleybus.svg b/src/client/src/assets/icons/trolleybus.svg
new file mode 100644
index 0000000..dc5c60d
--- /dev/null
+++ b/src/client/src/assets/icons/trolleybus.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/client/src/assets/images/GAT.png b/src/client/src/assets/images/GAT.png
new file mode 100644
index 0000000..737ba11
Binary files /dev/null and b/src/client/src/assets/images/GAT.png differ
diff --git a/src/client/src/assets/images/sight.png b/src/client/src/assets/images/sight.png
new file mode 100644
index 0000000..b235fd3
Binary files /dev/null and b/src/client/src/assets/images/sight.png differ
diff --git a/src/client/src/assets/images/sight.svg b/src/client/src/assets/images/sight.svg
new file mode 100644
index 0000000..5bdc018
--- /dev/null
+++ b/src/client/src/assets/images/sight.svg
@@ -0,0 +1,21 @@
+
diff --git a/src/client/src/assets/images/test-image.png b/src/client/src/assets/images/test-image.png
new file mode 100644
index 0000000..1a66fdc
Binary files /dev/null and b/src/client/src/assets/images/test-image.png differ
diff --git a/src/client/src/assets/images/transfer-button.png b/src/client/src/assets/images/transfer-button.png
new file mode 100644
index 0000000..d66c002
Binary files /dev/null and b/src/client/src/assets/images/transfer-button.png differ
diff --git a/src/client/src/assets/images/Герб.png b/src/client/src/assets/images/Герб.png
new file mode 100644
index 0000000..5a26407
Binary files /dev/null and b/src/client/src/assets/images/Герб.png differ
diff --git a/src/client/src/assets/tramPosition/Tram Base.svg b/src/client/src/assets/tramPosition/Tram Base.svg
new file mode 100644
index 0000000..5f7e478
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram Base.svg
@@ -0,0 +1,15 @@
+
diff --git a/src/client/src/assets/tramPosition/Tram Bottom Left.svg b/src/client/src/assets/tramPosition/Tram Bottom Left.svg
new file mode 100644
index 0000000..26147ff
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram Bottom Left.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/tramPosition/Tram Bottom Right.svg b/src/client/src/assets/tramPosition/Tram Bottom Right.svg
new file mode 100644
index 0000000..b801670
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram Bottom Right.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/tramPosition/Tram Left.svg b/src/client/src/assets/tramPosition/Tram Left.svg
new file mode 100644
index 0000000..993b9fa
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram Left.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/tramPosition/Tram Right.svg b/src/client/src/assets/tramPosition/Tram Right.svg
new file mode 100644
index 0000000..9d93d79
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram Right.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/tramPosition/Tram Top Left.svg b/src/client/src/assets/tramPosition/Tram Top Left.svg
new file mode 100644
index 0000000..30c2604
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram Top Left.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/tramPosition/Tram Top Right.svg b/src/client/src/assets/tramPosition/Tram Top Right.svg
new file mode 100644
index 0000000..bdb0f74
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram Top Right.svg
@@ -0,0 +1,35 @@
+
diff --git a/src/client/src/assets/tramPosition/Tram.svg b/src/client/src/assets/tramPosition/Tram.svg
new file mode 100644
index 0000000..f41a2ee
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram.svg
@@ -0,0 +1,22 @@
+
diff --git a/src/client/src/assets/tramPosition/Tram_Second.svg b/src/client/src/assets/tramPosition/Tram_Second.svg
new file mode 100644
index 0000000..c0f4388
--- /dev/null
+++ b/src/client/src/assets/tramPosition/Tram_Second.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/src/client/src/assets/transport-icons/bus.svg b/src/client/src/assets/transport-icons/bus.svg
new file mode 100644
index 0000000..00f52e2
--- /dev/null
+++ b/src/client/src/assets/transport-icons/bus.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/client/src/assets/transport-icons/metroBlue.svg b/src/client/src/assets/transport-icons/metroBlue.svg
new file mode 100644
index 0000000..884b565
--- /dev/null
+++ b/src/client/src/assets/transport-icons/metroBlue.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/transport-icons/metroGreen.svg b/src/client/src/assets/transport-icons/metroGreen.svg
new file mode 100644
index 0000000..039f95a
--- /dev/null
+++ b/src/client/src/assets/transport-icons/metroGreen.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/transport-icons/metroOrange.svg b/src/client/src/assets/transport-icons/metroOrange.svg
new file mode 100644
index 0000000..8b27624
--- /dev/null
+++ b/src/client/src/assets/transport-icons/metroOrange.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/transport-icons/metroPurple.svg b/src/client/src/assets/transport-icons/metroPurple.svg
new file mode 100644
index 0000000..6813ae1
--- /dev/null
+++ b/src/client/src/assets/transport-icons/metroPurple.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/transport-icons/metroRed.svg b/src/client/src/assets/transport-icons/metroRed.svg
new file mode 100644
index 0000000..9db2a0e
--- /dev/null
+++ b/src/client/src/assets/transport-icons/metroRed.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/transport-icons/station.svg b/src/client/src/assets/transport-icons/station.svg
new file mode 100644
index 0000000..0609994
--- /dev/null
+++ b/src/client/src/assets/transport-icons/station.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/src/client/src/assets/transport-icons/train.svg b/src/client/src/assets/transport-icons/train.svg
new file mode 100644
index 0000000..60bc427
--- /dev/null
+++ b/src/client/src/assets/transport-icons/train.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/client/src/assets/transport-icons/tram.svg b/src/client/src/assets/transport-icons/tram.svg
new file mode 100644
index 0000000..b01c9fc
--- /dev/null
+++ b/src/client/src/assets/transport-icons/tram.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/client/src/assets/transport-icons/trolley.svg b/src/client/src/assets/transport-icons/trolley.svg
new file mode 100644
index 0000000..eab02b6
--- /dev/null
+++ b/src/client/src/assets/transport-icons/trolley.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/client/src/assets/video/taganai.mp4 b/src/client/src/assets/video/taganai.mp4
new file mode 100644
index 0000000..515cecc
Binary files /dev/null and b/src/client/src/assets/video/taganai.mp4 differ
diff --git a/src/client/src/assets/weather-icons/humidity.png b/src/client/src/assets/weather-icons/humidity.png
new file mode 100644
index 0000000..19a0cf0
Binary files /dev/null and b/src/client/src/assets/weather-icons/humidity.png differ
diff --git a/src/client/src/assets/weather-icons/icon1.png b/src/client/src/assets/weather-icons/icon1.png
new file mode 100644
index 0000000..18a7817
Binary files /dev/null and b/src/client/src/assets/weather-icons/icon1.png differ
diff --git a/src/client/src/assets/weather-icons/icon2.png b/src/client/src/assets/weather-icons/icon2.png
new file mode 100644
index 0000000..03cbe14
Binary files /dev/null and b/src/client/src/assets/weather-icons/icon2.png differ
diff --git a/src/client/src/assets/weather-icons/icon3.png b/src/client/src/assets/weather-icons/icon3.png
new file mode 100644
index 0000000..18a7817
Binary files /dev/null and b/src/client/src/assets/weather-icons/icon3.png differ
diff --git a/src/client/src/assets/weather-icons/wind.png b/src/client/src/assets/weather-icons/wind.png
new file mode 100644
index 0000000..4589227
Binary files /dev/null and b/src/client/src/assets/weather-icons/wind.png differ
diff --git a/src/client/src/assets/weather-status/sunny.png b/src/client/src/assets/weather-status/sunny.png
new file mode 100644
index 0000000..a726ce7
Binary files /dev/null and b/src/client/src/assets/weather-status/sunny.png differ
diff --git a/src/client/src/components/ErrorBoundary.jsx b/src/client/src/components/ErrorBoundary.jsx
new file mode 100644
index 0000000..e69de29
diff --git a/src/client/src/components/ErrorOverlay.css b/src/client/src/components/ErrorOverlay.css
new file mode 100644
index 0000000..d07d520
--- /dev/null
+++ b/src/client/src/components/ErrorOverlay.css
@@ -0,0 +1,6 @@
+.error-gif {
+ position: fixed;
+ width: 100vw;
+ height: 100vh;
+ z-index: 999999;
+}
diff --git a/src/client/src/components/ErrorOverlay.jsx b/src/client/src/components/ErrorOverlay.jsx
new file mode 100644
index 0000000..7e8cbab
--- /dev/null
+++ b/src/client/src/components/ErrorOverlay.jsx
@@ -0,0 +1,24 @@
+import React from "react";
+import { observer } from "mobx-react-lite";
+import { useGeolocationStore } from "../stores/hooks/useGeolocationStore";
+import "./ErrorOverlay.css";
+
+const ErrorOverlay = observer(() => {
+ const { error, isLoading } = useGeolocationStore();
+
+ // Показываем GIF только если есть ошибка и загрузка завершена
+ if (!error || isLoading) {
+ return null;
+ }
+
+ return (
+
+
+

+
{error}
+
+
+ );
+});
+
+export default ErrorOverlay;
diff --git a/src/client/src/components/Fullscreen3DModal.jsx b/src/client/src/components/Fullscreen3DModal.jsx
new file mode 100644
index 0000000..b8d6dde
--- /dev/null
+++ b/src/client/src/components/Fullscreen3DModal.jsx
@@ -0,0 +1,56 @@
+import React, { useMemo, useState } from "react";
+import { createPortal } from "react-dom";
+import { ThreeView } from "./widgets/ThreeView";
+import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
+import scale_plus from "../assets/fullscreen-modal-actions/scale_plus.png";
+import scale_minus from "../assets/fullscreen-modal-actions/scale_minus.png";
+import closeIcon from "../assets/fullscreen-modal-actions/close.png";
+
+const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => {
+ if (!isOpen || !fileUrl) return null;
+
+ const [scale, setScale] = useState(0.1);
+ const [fullscreenResetKey, setFullscreenResetKey] = useState(0);
+
+ const handleResetScale = (e) => {
+ e.stopPropagation();
+ setScale(0.1);
+ };
+
+ return createPortal(
+
+
+
+ {
+ setFullscreenResetKey((prev) => prev + 1);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
,
+ document.body
+ );
+};
+
+export default Fullscreen3DModal;
diff --git a/src/client/src/components/ListOfSights.jsx b/src/client/src/components/ListOfSights.jsx
new file mode 100644
index 0000000..d5d17bb
--- /dev/null
+++ b/src/client/src/components/ListOfSights.jsx
@@ -0,0 +1,520 @@
+import React, {
+ useState,
+ useEffect,
+ useRef,
+ useCallback,
+ useMemo,
+} from "react";
+import { observer } from "mobx-react-lite";
+import "../styles/ListOfSights.css";
+import { useGeolocationStore, useColorStore } from "../stores";
+import ContentAPI from "../api/content/content.api";
+import { getMediaType } from "../utils/getMediaType";
+
+// Импортируем новые компоненты
+import SightFrame from "./ListOfSights/SightFrame";
+import ListHeader from "./ListOfSights/ListHeader";
+import SightsList from "./ListOfSights/SightsList";
+import AlphabetNavigator from "./ListOfSights/AlphabetNavigator";
+import LanguageSelector from "./ListOfSights/LanguageSelector";
+import TransferWidget from "./ListOfSights/TransferWidget";
+import { apiStore } from "../api/ApiStore/store";
+
+const LANGUAGES = {
+ EN: "en",
+ RU: "ru",
+ ZH: "zh",
+};
+
+const ListOfSights = observer(() => {
+ const [sightData, setSightData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const store = useGeolocationStore();
+ const colorStore = useColorStore();
+ const { currentColor, startColorAnimation } = colorStore;
+ const { routeSights, routeSightsEn, routeSightsZh } = apiStore;
+ const {
+ nearestSightId,
+ selectedSightId,
+ setSelectedSightId,
+ selectedLanguage,
+ selectedLanguageRight,
+ setSelectedLanguageRight,
+ nearestStationId,
+ isInteractingWith3D,
+ isManualSelection,
+ setIsManualSelection,
+ isRightWidgetSelectorOpen,
+ setIsRightWidgetSelectorOpen,
+ isLeftWidgetOpen,
+ isTransferWidgetOpen,
+ setIsTransferWidgetOpen,
+ } = store;
+ const { context: contextData, routeStations } = apiStore;
+
+ const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
+ const [selectedLetter, setSelectedLetter] = useState(null);
+ const [TransferWidgetData, setTransferWidgetData] = useState(null);
+ const [isTransferButtonVisible, setIsTransferButtonVisible] = useState(false);
+ const [showTransferButton, setShowTransferButton] = useState(false);
+ const [highlightedSights, setHighlightedSights] = useState(new Set());
+ const [isAlphabetDisabled, setIsAlphabetDisabled] = useState(false);
+
+ const sightRefs = useRef({});
+ const sightsListRef = useRef(null);
+ const alphabetRef = useRef(null);
+ const isInitialSelectionDone = useRef(false);
+
+ const handleIconClick = () => {
+ setIsRightWidgetSelectorOpen(!isRightWidgetSelectorOpen);
+ };
+
+ const handleSightClick = useCallback(
+ (id) => {
+ setSelectedSightId(String(id));
+ setIsManualSelection(true);
+ setIsRightWidgetSelectorOpen(false);
+ // Закрываем виджет губернатора при выборе достопримечательности
+ store.closeGovernorModal();
+ },
+ [setSelectedSightId, setIsRightWidgetSelectorOpen, store],
+ );
+
+ const handleLetterClick = (letter) => {
+ // Если алфавит заблокирован, игнорируем клик
+ if (isAlphabetDisabled) return;
+
+ setSelectedLetter((prevLetter) => {
+ const newLetter = prevLetter === letter ? null : letter;
+
+ if (newLetter && sightData) {
+ // Блокируем алфавит на время анимации
+ setIsAlphabetDisabled(true);
+
+ // Находим все достопримечательности, начинающиеся с выбранной буквы
+ const sightsForLetter = sightData.filter((sight) => {
+ if (!sight.name) return false;
+
+ let sightFirstChar = sight.name.trim().charAt(0);
+
+ // Для китайского языка используем пиньинь или иероглиф
+ if (selectedLanguageRight === "zh") {
+ if (sight.name_pinyin) {
+ sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
+ } else {
+ // Если пиньинь нет, используем первый иероглиф
+ sightFirstChar = sight.name.trim().charAt(0);
+ }
+ } else {
+ sightFirstChar = sightFirstChar.toUpperCase();
+ }
+
+ return sightFirstChar === newLetter;
+ });
+
+ const sightIds = new Set(sightsForLetter.map((sight) => sight.id));
+
+ // Скроллим к первой достопримечательности
+ const firstSightForLetter = sightsForLetter[0];
+ if (firstSightForLetter && sightRefs.current[firstSightForLetter.id]) {
+ const targetElement = sightRefs.current[firstSightForLetter.id];
+
+ if (targetElement) {
+ // Используем setTimeout для гарантии, что OverlayScrollbars инициализирован
+ setTimeout(() => {
+ try {
+ // Используем scrollIntoView, чтобы scroll-margin-top из CSS работал правильно
+ targetElement.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+
+ // Fallback mechanism if scrollIntoView fails or is intercepted
+ setTimeout(() => {
+ if (sightsListRef.current) {
+ const container = sightsListRef.current;
+ const rect = targetElement.getBoundingClientRect();
+ const containerRect = container.getBoundingClientRect();
+
+ // Check if element is visible in container
+ const isVisible =
+ rect.top >= containerRect.top &&
+ rect.bottom <= containerRect.bottom;
+
+ if (!isVisible && container.scrollTo) {
+ // Calculate relative position
+ const relativeTop = targetElement.offsetTop;
+ container.scrollTo({
+ top: relativeTop - 15,
+ behavior: "smooth",
+ });
+ }
+ }
+ }, 100);
+ } catch (error) {
+ console.error("Ошибка при скролле к элементу:", error);
+ }
+ }, 100);
+
+ // Запускаем анимацию после завершения скролла (примерно 500-600ms для smooth scroll)
+ setTimeout(() => {
+ setHighlightedSights(sightIds);
+
+ // Через 1.5 секунды убираем подсветку и разблокируем алфавит
+ setTimeout(() => {
+ setHighlightedSights(new Set());
+ setIsAlphabetDisabled(false);
+ }, 1500);
+ }, 600);
+ }
+ } else {
+ setIsAlphabetDisabled(false);
+ }
+ } else {
+ // Если буква отменена, убираем подсветку
+ setHighlightedSights(new Set());
+ }
+ return newLetter;
+ });
+ };
+
+ const handleLanguageChange = (val) => {
+ setIsLangMenuOpen(false);
+ setSelectedLanguageRight(val);
+ };
+
+ const handleTransferToggle = () => {
+ setIsTransferWidgetOpen(!isTransferWidgetOpen);
+ };
+
+ const handleBackToNearest = useCallback(() => {
+ setIsManualSelection(false);
+ const sightData =
+ selectedLanguageRight === "ru"
+ ? routeSights
+ : selectedLanguageRight === "en"
+ ? routeSightsEn
+ : routeSightsZh;
+
+ if (sightData && sightData.length > 0) {
+ const nearestSightInList = sightData.find((s) => s.id == nearestSightId);
+ if (nearestSightInList) {
+ setSelectedSightId(String(nearestSightInList.id));
+ } else {
+ setSelectedSightId(String(sightData[0].id));
+ }
+ } else {
+ setSelectedSightId(null);
+ }
+ setIsRightWidgetSelectorOpen(false);
+ // Закрываем виджет губернатора при возврате к ближайшей достопримечательности
+ store.closeGovernorModal();
+ }, [
+ setSelectedSightId,
+ routeSights,
+ routeSightsEn,
+ routeSightsZh,
+ nearestSightId,
+ selectedLanguageRight,
+ setIsRightWidgetSelectorOpen,
+ store,
+ ]);
+
+ // Запуск анимации цвета при монтировании компонента
+ useEffect(() => {
+ startColorAnimation();
+ }, [startColorAnimation]);
+
+ // Загрузка достопримечательностей
+ useEffect(() => {
+ setError(null);
+ setIsLoading(true);
+
+ const fetchSights = async () => {
+ try {
+ const sights =
+ selectedLanguageRight === "ru"
+ ? routeSights
+ : selectedLanguageRight === "en"
+ ? routeSightsEn
+ : routeSightsZh;
+
+ const processedSights = sights.map((sight) => ({
+ ...sight,
+ preview_media_type: getMediaType(sight.preview_media_type_raw),
+ thumbnail_type: getMediaType(sight.thumbnail_type_raw),
+ }));
+
+ const sortedSights = [...processedSights].sort((a, b) => {
+ if (a.name && b.name) {
+ return a.name.localeCompare(b.name, selectedLanguageRight, {
+ sensitivity: "base",
+ });
+ }
+ return 0;
+ });
+
+ setSightData(sortedSights);
+ } catch (err) {
+ console.error("Ошибка загрузки достопримечательностей:", err);
+ setError(
+ "Не удалось загрузить достопримечательности. Пожалуйста, попробуйте позже.",
+ );
+ setSightData([]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchSights();
+ }, [contextData?.routeId, selectedLanguageRight]);
+
+ // Анимация кнопки пересадок при открытии/закрытии левого меню
+ useEffect(() => {
+ if (isLeftWidgetOpen) {
+ // При открытии левого меню - сначала показываем кнопку пересадок
+ setIsTransferButtonVisible(true);
+ } else {
+ // При закрытии левого меню - сначала скрываем кнопку пересадок
+ setIsTransferButtonVisible(false);
+ }
+ }, [isLeftWidgetOpen]);
+
+ // Логика выбора достопримечательности
+ useEffect(() => {
+ if (!isLoading && sightData && sightData.length > 0) {
+ if (!isInitialSelectionDone.current && !isManualSelection) {
+ const nearestSightInList = sightData.find(
+ (s) => s.id == nearestSightId,
+ );
+ if (nearestSightInList) {
+ setSelectedSightId(String(nearestSightInList.id));
+ } else {
+ setSelectedSightId(String(sightData[0].id));
+ }
+ isInitialSelectionDone.current = true;
+ } else if (!isInitialSelectionDone.current && isManualSelection) {
+ const nearestSightInList = sightData.find(
+ (s) => s.id == nearestSightId,
+ );
+ if (
+ isManualSelection &&
+ nearestSightInList &&
+ nearestSightInList.id === selectedSightId
+ ) {
+ // Все ОК, продолжаем использовать выбранную вручную, если она совпадает с ближайшей
+ } else if (isManualSelection && selectedSightId) {
+ // Ничего не делаем здесь, выбранная вручную достопримечательность уже установлена.
+ } else {
+ if (nearestSightInList) {
+ setSelectedSightId(String(nearestSightInList.id));
+ } else {
+ setSelectedSightId(String(sightData[0].id));
+ }
+ }
+ isInitialSelectionDone.current = true;
+ setIsManualSelection(false);
+ }
+
+ if (
+ nearestSightId &&
+ selectedSightId !== nearestSightId &&
+ !isManualSelection &&
+ !isInteractingWith3D
+ ) {
+ const newNearestSight = sightData.find((s) => s.id == nearestSightId);
+ if (newNearestSight) {
+ setSelectedSightId(String(newNearestSight.id));
+ }
+ }
+ } else if (
+ !isLoading &&
+ (!sightData || sightData.length === 0) &&
+ isInitialSelectionDone.current
+ ) {
+ setSelectedSightId(null);
+ isInitialSelectionDone.current = false;
+ setIsManualSelection(false);
+ }
+ }, [
+ isLoading,
+ sightData,
+ nearestSightId,
+ setSelectedSightId,
+ selectedSightId,
+ isManualSelection,
+ isInteractingWith3D,
+ ]);
+
+ // Загрузка данных о пересадках для ближайшей станции
+ useEffect(() => {
+ if (!isLoading && nearestStationId != null) {
+ const fetchTransferWidgetData = async () => {
+ try {
+ const station = routeStations.find((s) => s.id == nearestStationId);
+ const response = station.transfers;
+
+ setTransferWidgetData(response);
+ } catch (err) {
+ console.error("Ошибка в получении пересадок:", err);
+ setTransferWidgetData(null);
+ }
+ };
+ fetchTransferWidgetData();
+ } else if (!isLoading && (!contextData?.routeId || !nearestStationId)) {
+ setTransferWidgetData(null);
+ }
+ }, [isLoading, contextData?.routeId, nearestStationId, routeStations]);
+
+ const currentSelectedSight = sightData
+ ? sightData.find((sight) => sight.id == selectedSightId)
+ : null;
+
+ const sightFrameMedia = useMemo(() => {
+ if (!currentSelectedSight) return null;
+
+ // Если preview_media это строка (URL), используем её как path
+ if (typeof currentSelectedSight.preview_media === "string") {
+ return {
+ id: currentSelectedSight.preview_media,
+ path: currentSelectedSight.preview_media,
+ media_type: currentSelectedSight.preview_media_type || null,
+ type: currentSelectedSight.preview_media_type || null,
+ };
+ }
+
+ if (
+ typeof currentSelectedSight.preview_media === "object" &&
+ currentSelectedSight.preview_media
+ ) {
+ return {
+ id:
+ currentSelectedSight.preview_media.id ||
+ currentSelectedSight.preview_media,
+ path:
+ currentSelectedSight.preview_media.path ||
+ currentSelectedSight.preview_media.url,
+ media_type:
+ currentSelectedSight.preview_media.type ||
+ currentSelectedSight.preview_media.media_type,
+ type:
+ currentSelectedSight.preview_media.type ||
+ currentSelectedSight.preview_media.media_type,
+ };
+ }
+
+ return {
+ id: currentSelectedSight.preview_media,
+ };
+ }, [currentSelectedSight]);
+
+ return (
+
+ {currentSelectedSight && (
+
+ )}
+
+
+
+
+
+
+
setIsLangMenuOpen(true)}
+ onBackToNearest={handleBackToNearest}
+ />
+
+
+
+
+
+
+
+
+ );
+});
+
+export default ListOfSights;
diff --git a/src/client/src/components/ListOfSights/AlphabetNavigator.jsx b/src/client/src/components/ListOfSights/AlphabetNavigator.jsx
new file mode 100644
index 0000000..0dd17b6
--- /dev/null
+++ b/src/client/src/components/ListOfSights/AlphabetNavigator.jsx
@@ -0,0 +1,99 @@
+import React, { useMemo, forwardRef } from "react";
+
+const AlphabetNavigator = forwardRef(function AlphabetNavigator(
+ { selectedLetter, onLetterClick, sightData, selectedLanguage, isDisabled },
+ ref
+) {
+ // Генерируем алфавит на основе доступных достопримечательностей
+ const availableLetters = useMemo(() => {
+ if (!sightData || sightData.length === 0) {
+ return [];
+ }
+
+ // Получаем уникальные первые символы из названий достопримечательностей
+ const letters = new Set();
+
+ sightData.forEach((sight) => {
+ if (sight.name && sight.name.trim()) {
+ let firstChar = sight.name.trim().charAt(0);
+
+ // Для китайского языка используем пиньинь (если доступен) или иероглиф
+ if (selectedLanguage === "zh") {
+ if (sight.name_pinyin) {
+ firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
+ } else {
+ // Если пиньинь нет, используем первый иероглиф
+ firstChar = sight.name.trim().charAt(0);
+ }
+ } else {
+ firstChar = firstChar.toUpperCase();
+ }
+
+ // Проверяем, что символ подходит для алфавита
+ if (
+ firstChar &&
+ (/[A-ZА-ЯЁ]/.test(firstChar) ||
+ (selectedLanguage === "zh" && /[\u4e00-\u9fff]/.test(firstChar)))
+ ) {
+ letters.add(firstChar);
+ }
+ }
+ });
+
+ // Сортируем символы в зависимости от языка
+ const sortedLetters = Array.from(letters).sort((a, b) => {
+ if (selectedLanguage === "zh") {
+ // Для китайского языка сортируем по пиньинь или по Unicode
+ if (/[A-Z]/.test(a) && /[A-Z]/.test(b)) {
+ // Если оба символа - латинские буквы (пиньинь)
+ return a.localeCompare(b, "en", { sensitivity: "base" });
+ } else if (/[\u4e00-\u9fff]/.test(a) && /[\u4e00-\u9fff]/.test(b)) {
+ // Если оба символа - иероглифы, сортируем по Unicode
+ return a.localeCompare(b, "zh", { sensitivity: "base" });
+ } else {
+ // Смешанные символы: сначала латинские, потом иероглифы
+ return /[A-Z]/.test(a) ? -1 : 1;
+ }
+ }
+ return a.localeCompare(b, selectedLanguage, { sensitivity: "base" });
+ });
+
+ return sortedLetters;
+ }, [sightData, selectedLanguage]);
+
+ // Если нет доступных букв, не показываем компонент
+ if (availableLetters.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {availableLetters.map((letter) => (
+ !isDisabled && onLetterClick(letter)}
+ role="button"
+ tabIndex="0"
+ data-lang={selectedLanguage === "zh" ? "zh" : undefined}
+ aria-label={
+ selectedLanguage === "en"
+ ? `Scroll to sights starting with letter ${letter}`
+ : selectedLanguage === "zh"
+ ? `滚动到以字母 ${letter} 开头的景点`
+ : `Прокрутить к достопримечательностям на букву ${letter}`
+ }
+ >
+ {letter}
+
+ ))}
+
+ );
+});
+
+export default AlphabetNavigator;
diff --git a/src/client/src/components/ListOfSights/LanguageSelector.jsx b/src/client/src/components/ListOfSights/LanguageSelector.jsx
new file mode 100644
index 0000000..d2d6a46
--- /dev/null
+++ b/src/client/src/components/ListOfSights/LanguageSelector.jsx
@@ -0,0 +1,172 @@
+import React from "react";
+import { useGeolocationStore } from "../../stores";
+import { observer } from "mobx-react-lite";
+const LANGUAGES = {
+ EN: "en",
+ RU: "ru",
+ ZH: "zh",
+};
+
+const LANGUAGE_NAMES = {
+ [LANGUAGES.EN]: "en",
+ [LANGUAGES.RU]: "ru",
+ [LANGUAGES.ZH]: "zh",
+};
+
+const LanguageSelector = observer(function LanguageSelector({
+ selectedLanguageRight,
+ onLanguageChange,
+ isOpen,
+ onToggle,
+ onBackToNearest,
+}) {
+ const store = useGeolocationStore();
+ const { isManualSelection, selectedSightId, nearestSightId } = store;
+
+ const SelectLangHandler = (val) => {
+ onLanguageChange(val);
+ };
+
+ const handleBackClick = () => {
+ if (onBackToNearest) {
+ onBackToNearest();
+ }
+ };
+
+ if (isOpen) {
+ return (
+
+
SelectLangHandler(LANGUAGES.EN)}
+ >
+
+
+
SelectLangHandler(LANGUAGES.RU)}
+ >
+
+
+
SelectLangHandler(LANGUAGES.ZH)}
+ >
+
+
+
+ );
+ }
+
+ return (
+
+ );
+});
+
+export default LanguageSelector;
diff --git a/src/client/src/components/ListOfSights/ListHeader.jsx b/src/client/src/components/ListOfSights/ListHeader.jsx
new file mode 100644
index 0000000..9b8a4bb
--- /dev/null
+++ b/src/client/src/components/ListOfSights/ListHeader.jsx
@@ -0,0 +1,89 @@
+import React from "react";
+
+const LANGUAGES = {
+ EN: "en",
+ RU: "ru",
+ ZH: "zh",
+};
+
+const ListHeader = function ListHeader({
+ isLangOpen,
+ isOpen,
+ onIconClick,
+ selectedLanguageRight,
+ isManualSelection,
+ selectedSightId,
+ nearestSightId,
+ onTransferToggle,
+ isTransferWidgetOpen,
+ onBackToNearest,
+}) {
+ const getTitle = () => {
+ return selectedLanguageRight === LANGUAGES.RU
+ ? "Достопримечательности"
+ : selectedLanguageRight === LANGUAGES.ZH
+ ? "景点"
+ : "Attractions";
+ };
+
+ return (
+
+ );
+};
+
+export default ListHeader;
diff --git a/src/client/src/components/ListOfSights/SightComponent.jsx b/src/client/src/components/ListOfSights/SightComponent.jsx
new file mode 100644
index 0000000..c2dceec
--- /dev/null
+++ b/src/client/src/components/ListOfSights/SightComponent.jsx
@@ -0,0 +1,46 @@
+import React from "react";
+import { getMediaUrl } from "../../api/apiConfig";
+import { useClickDetection } from "../../hooks/useClickDetection";
+
+const SightComponent = function SightComponent({
+ media,
+ title,
+ onPointerUp,
+ sightId,
+ componentRef,
+ isHighlighted,
+}) {
+ // Используем общий хук для различения клика и скролла
+ const { handlePointerDown, handlePointerUp, handleScroll } =
+ useClickDetection();
+
+ const renderThumbnail = () => {
+ if (!media || !media.id) {
+ return No Media
;
+ }
+
+ const mediaUrl = getMediaUrl(media.id);
+
+ return
;
+ };
+
+ return (
+ handlePointerDown(e, sightId)}
+ onPointerUp={(e) => handlePointerUp(e, sightId, onPointerUp)}
+ onScroll={handleScroll}
+ ref={componentRef}
+ role="button"
+ tabIndex="0"
+ aria-label={`Выбрать достопримечательность ${title}`}
+ >
+
{renderThumbnail()}
+
+ {title}
+
+
+ );
+};
+
+export default SightComponent;
diff --git a/src/client/src/components/ListOfSights/SightFrame.jsx b/src/client/src/components/ListOfSights/SightFrame.jsx
new file mode 100644
index 0000000..c65b0f5
--- /dev/null
+++ b/src/client/src/components/ListOfSights/SightFrame.jsx
@@ -0,0 +1,609 @@
+import React, { useState, useEffect, useRef, useMemo } from "react";
+import axios from "axios";
+import { observer } from "mobx-react-lite";
+import { useGeolocationStore } from "../../stores";
+import ContentAPI from "../../api/content/content.api";
+import { getMediaType } from "../../utils/getMediaType";
+import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
+import { ThreeView } from "../widgets/ThreeView";
+import {
+ MinusIcon,
+ PlusIcon,
+ SizeIcon,
+ SizeOpenIcon,
+} from "../widgets/ThreeViewIcons";
+import { ThreeViewErrorBoundary } from "../ThreeViewErrorBoundary";
+import { apiStore } from "../../api/ApiStore/store";
+import { ReactMarkdownComponent } from "../ReactMarkdown";
+import { TouchableLayout } from "../TouchableLayout";
+
+const Watermark = ({ path }) => {
+ if (!path) return null;
+ return
;
+};
+
+const SightFrame = observer(({ media, sight_id, sight_name }) => {
+ const store = useGeolocationStore();
+ const { selectedLanguageRight } = store;
+
+ const [articleSections, setArticleSections] = useState(null);
+ const [isLoadingContent, setIsLoadingContent] = useState(true);
+ const [contentError, setContentError] = useState(null);
+ const [selectedSection, setSelectedSection] = useState(0);
+ const [modelAspectRatio, setModelAspectRatio] = useState(null);
+ const [isVisible, setIsVisible] = useState(false);
+ const [sightData, setSightData] = useState(null);
+ const [mediaData, setMediaData] = useState({});
+ const [isFullscreen3D, setIsFullscreen3D] = useState(false);
+ const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
+ const [threeViewResetKey, setThreeViewResetKey] = useState(0);
+ const threeViewControlRef = useRef(null);
+ const mediaCache = useRef({});
+
+ const textWrapperRef = useRef(null);
+
+ const {
+ routeSights,
+ routeSightsEn,
+ routeSightsZh,
+ sightArticles,
+ sightArticlesEn,
+ sightArticlesZh,
+ sightArticlesIds,
+ } = apiStore;
+
+ const isStringUuid = (id) =>
+ typeof id === "string" &&
+ id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
+
+ const fetchMediaForSection = async (sectionId, signal) => {
+ if (!sectionId || sectionId === "intro-title") return null;
+
+ try {
+ const usePreviewApi = isStringUuid(sectionId);
+ const fetchedMediaObj = usePreviewApi
+ ? await ContentAPI.getMediaPreview(sectionId, "ru", signal)
+ : await ContentAPI.getMedia(sectionId, "ru", signal);
+
+ return {
+ path: fetchedMediaObj.path,
+ type: getMediaType(fetchedMediaObj.type),
+ };
+ } catch (err) {
+ if (axios.isCancel(err) || err.name === "AbortError") return null;
+ console.error(`Failed to fetch media for section ${sectionId}:`, err);
+ return null;
+ }
+ };
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ setIsLoadingContent(true);
+ setContentError(null);
+ setModelAspectRatio(null);
+ setIsVisible(false);
+ setMediaData({});
+
+ if (!sight_id) {
+ setContentError("Не указан ID статьи.");
+ setIsLoadingContent(false);
+ return () => controller.abort();
+ }
+
+ const fetchContent = async () => {
+ try {
+ const sight =
+ selectedLanguageRight === "ru"
+ ? routeSights.find((sight) => sight.id === sight_id)
+ : selectedLanguageRight === "en"
+ ? routeSightsEn.find((sight) => sight.id === sight_id)
+ : routeSightsZh.find((sight) => sight.id === sight_id);
+
+ if (!sight) {
+ setContentError("Достопримечательность не найдена.");
+ setIsLoadingContent(false);
+ return;
+ }
+
+ const rightArticleData = sightArticlesIds.get(sight_id);
+
+ if (!rightArticleData) {
+ setContentError("Данные о статьях не найдены.");
+ setIsLoadingContent(false);
+ return;
+ }
+
+ const rightArticles = rightArticleData.map((articleId) => {
+ const article =
+ selectedLanguageRight === "ru"
+ ? sightArticles.get(articleId.toString() + "_ru")
+ : selectedLanguageRight === "en"
+ ? sightArticlesEn.get(articleId.toString() + "_en")
+ : sightArticlesZh.get(articleId.toString() + "_zh");
+
+ if (!article) {
+ console.warn(
+ `Article not found for ID: ${articleId} in language: ${selectedLanguageRight}`,
+ );
+ return {
+ id: articleId,
+ heading: "Статья не найдена",
+ body: "Содержание статьи недоступно.",
+ };
+ }
+
+ const processBodyText = (text) => {
+ if (!text) return text;
+
+ const namePattern =
+ /[А-Яа-яA-Za-z0-9]\.[А-Яа-яA-Za-z0-9]\.[А-Яа-яA-Za-z0-9]+|[А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+/g;
+
+ const hasNamePattern = namePattern.test(text);
+
+ if (hasNamePattern) {
+ return text.replace(/\n/g, " ");
+ }
+
+ return text;
+ };
+
+ return {
+ id: articleId,
+ heading: article.heading,
+ body: processBodyText(article.body),
+ };
+ });
+
+ if (signal.aborted) return;
+
+ setSightData(sight);
+ const introSection = {
+ id: media?.id || "intro-title",
+ heading:
+ sight?.short_name || sight?.name || sight_name || "Название достопримечательности",
+ body: "",
+ };
+ const allSections = [introSection, ...rightArticles];
+ setArticleSections(allSections);
+
+ const cacheKey = `${sight_id}_${selectedLanguageRight}`;
+ if (mediaCache.current[cacheKey]) {
+ setMediaData(mediaCache.current[cacheKey]);
+ setIsLoadingContent(false);
+ setIsVisible(true);
+ return;
+ }
+
+ const mediaSections = allSections.filter(
+ (s) => s.id && s.id !== "intro-title",
+ );
+ const results = await Promise.all(
+ mediaSections.map((s) => fetchMediaForSection(s.id, signal)),
+ );
+
+ if (signal.aborted) return;
+
+ const newMediaData = {};
+ mediaSections.forEach((s, i) => {
+ if (results[i]) newMediaData[s.id] = results[i];
+ });
+
+ mediaCache.current[cacheKey] = newMediaData;
+ setMediaData(newMediaData);
+ setIsLoadingContent(false);
+ setIsVisible(true);
+ } catch (err) {
+ if (axios.isCancel(err) || signal.aborted) return;
+ console.error("Error fetching content:", err);
+ setContentError("Не удалось загрузить информацию.");
+ setIsLoadingContent(false);
+ }
+ };
+
+ fetchContent();
+ return () => controller.abort();
+ }, [sight_id, selectedLanguageRight, media]);
+
+ useEffect(() => {
+ setSelectedSection(0);
+ }, [sight_id]);
+
+ const currentSection = articleSections?.[selectedSection] ?? null;
+
+ const renderCurrentMedia = () => {
+ if (!articleSections || Object.keys(mediaData).length === 0) {
+ return (
+
+ {isLoadingContent ? "Загрузка медиа..." : "Нет медиа для отображения"}
+
+ );
+ }
+
+ const section = articleSections[selectedSection];
+ if (!section) return null;
+
+ const sectionId = section.id?.toString();
+ const currentMediaData = mediaData[sectionId];
+ if (!currentMediaData) return null;
+
+ const className = "sight-frame-media-item visible";
+
+ try {
+ switch (currentMediaData.type) {
+ case "image":
+ return (
+
{
+ console.warn(
+ `Failed to load image: ${currentMediaData.path}`,
+ );
+ e.target.style.display = "none";
+ }}
+ />
+ );
+ case "video":
+ return (
+
+ );
+ case "panorama":
+ return (
+
+
+
+ );
+ case "3d":
+ return (
+
+
{
+ setThreeViewResetKey((prev) => prev + 1);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ );
+ default:
+ return (
+
+
+ Неподдерживаемый тип медиа: {currentMediaData.type}
+
+
+ );
+ }
+ } catch (error) {
+ console.error(`Error rendering media ${sectionId}:`, error);
+ return (
+
+
+ Ошибка отображения медиа
+
+
+ );
+ }
+ };
+
+ const isCurrentMedia3D =
+ currentSection && mediaData[currentSection.id]?.type === "3d";
+
+ const getMediaStackStyle = () => {
+ if (isCurrentMedia3D && isFullscreen3D) {
+ const fullHeight = window.innerHeight * 0.8;
+
+ const width = modelAspectRatio
+ ? fullHeight * modelAspectRatio
+ : fullHeight * 1.3;
+
+ const finalWidth = Math.max(width, 550);
+ const containerWidth = 550;
+ const extraWidth = Math.max(0, finalWidth - containerWidth);
+
+ return {
+ height: `${fullHeight}px`,
+ width: `${finalWidth}px`,
+ maxWidth: "100vw",
+ overflow: "visible",
+ marginLeft: `-${extraWidth}px`,
+ marginTop: `-2px`,
+ };
+ }
+ if (isCurrentMedia3D) {
+ if (modelAspectRatio) {
+ const width = 400;
+ const height = Math.min(
+ width / modelAspectRatio,
+ window.innerHeight * 0.7,
+ );
+ return { height: `${height}px` };
+ }
+ return { height: "500px" };
+ }
+ return {};
+ };
+
+ const processedSightName = useMemo(() => {
+ if (!sight_name) return sight_name;
+
+ const namePattern =
+ /([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
+
+ const parts = sight_name.split(namePattern);
+
+ if (parts.length > 1) {
+ return parts.map((part, index) => {
+ const initialsPattern =
+ /^[А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+$/;
+ if (initialsPattern.test(part)) {
+ return (
+
+
+ {part}
+
+ );
+ }
+ return {part};
+ });
+ }
+
+ return sight_name;
+ }, [sight_name]);
+
+ const titleLineHeight = useMemo(() => {
+ if (!sight_name) return "120%";
+ const textLength = sight_name.length;
+ const calculatedLineHeight = Math.max(
+ 100,
+ Math.min(120, 120 - (textLength / 10) * 1),
+ );
+ return `${calculatedLineHeight}%`;
+ }, [sight_name]);
+
+ return (
+
+ {sightData?.watermark_lu && !isFullscreen3D && (
+
+ )}
+
+ {contentError ? (
+
+ {contentError}
+
+ ) : isLoadingContent || !articleSections ? (
+
+ Загрузка контента...
+
+ ) : (
+ renderCurrentMedia()
+ )}
+
+
+ {contentError ? (
+
{contentError}
+ ) : !currentSection ? (
+
Информация отсутствует.
+ ) : (
+ <>
+ {!isFullscreen3D && (
+
+
+ {selectedSection === 0 ? processedSightName : sight_name}
+
+
+ )}
+ {selectedSection !== 0 && (
+
+
+
+
+
+ )}
+ >
+ )}
+
+
+ {selectedSection !== 0 && (
+
setSelectedSection(0)}
+ >
+
+
+ )}
+ {contentError ? (
+
{contentError}
+ ) : (
+ articleSections &&
+ articleSections.length > 1 &&
+ articleSections.slice(1).map((section, index) => (
+
setSelectedSection(index + 1)}
+ key={section.id || section.heading || index}
+ className={`sight-frame-menu-point ${
+ index + 1 === selectedSection ? "active" : ""
+ }`}
+ role="button"
+ tabIndex="0"
+ >
+ {section.heading}
+
+ ))
+ )}
+
+
+ );
+});
+
+export default SightFrame;
diff --git a/src/client/src/components/ListOfSights/SightsList.jsx b/src/client/src/components/ListOfSights/SightsList.jsx
new file mode 100644
index 0000000..3c31462
--- /dev/null
+++ b/src/client/src/components/ListOfSights/SightsList.jsx
@@ -0,0 +1,50 @@
+import React from "react";
+import SightComponent from "./SightComponent";
+import { TouchableLayout } from "../TouchableLayout";
+
+const SightsList = function SightsList({
+ isOpen,
+ isLoading,
+ error,
+ sightData,
+ onSightClick,
+ sightRefs,
+ sightsListRef,
+ highlightedSights,
+}) {
+ return (
+
+
+ {isLoading ? (
+
Загрузка достопримечательностей...
+ ) : error ? (
+
{error}
+ ) : !sightData || sightData.length === 0 ? (
+
Достопримечательности не найдены.
+ ) : (
+ sightData.map((sight) => (
+
(sightRefs.current[sight.id] = el)}
+ isHighlighted={
+ highlightedSights && highlightedSights.has(sight.id)
+ }
+ />
+ ))
+ )}
+
+
+ );
+};
+
+export default SightsList;
diff --git a/src/client/src/components/ListOfSights/TransferWidget.jsx b/src/client/src/components/ListOfSights/TransferWidget.jsx
new file mode 100644
index 0000000..9ed0b2b
--- /dev/null
+++ b/src/client/src/components/ListOfSights/TransferWidget.jsx
@@ -0,0 +1,307 @@
+import React, { useState, useEffect } from "react";
+import "../../styles/TransferWidget.css";
+import { observer } from "mobx-react-lite";
+import { useGeolocationStore } from "../../stores";
+import { apiStore } from "../../api/ApiStore/store";
+import { getTargetStation } from "../../utils/routeStationsUtils";
+import busIcon from "../../assets/transport-icons/bus.svg";
+import metroBlueIcon from "../../assets/transport-icons/metroBlue.svg";
+import metroGreenIcon from "../../assets/transport-icons/metroGreen.svg";
+import metroOrangeIcon from "../../assets/transport-icons/metroOrange.svg";
+import metroPurpleIcon from "../../assets/transport-icons/metroPurple.svg";
+import metroRedIcon from "../../assets/transport-icons/metroRed.svg";
+import trainIcon from "../../assets/transport-icons/train.svg";
+import tramIcon from "../../assets/transport-icons/tram.svg";
+import trolleyIcon from "../../assets/transport-icons/trolley.svg";
+
+const TransferWidget = observer(function TransferWidget({
+ isOpen,
+ selectedLanguageRight,
+}) {
+ const { nearestStationId, currentStationId, setCurrentStationId } =
+ useGeolocationStore();
+ const {
+ routeStations,
+ routeStationsEn,
+ routeStationsZh,
+ orderedRouteStations,
+ context,
+ route,
+ } = apiStore;
+ const [currentTransferData, setCurrentTransferData] = useState(null);
+
+ useEffect(() => {
+ if (nearestStationId != null) {
+ setCurrentStationId(nearestStationId);
+ }
+ }, [nearestStationId, setCurrentStationId]);
+
+ useEffect(() => {
+ if (!routeStations || !context?.currentCoordinates) {
+ setCurrentTransferData(null);
+ return;
+ }
+
+ let targetStation = null;
+ let targetStationId = null;
+
+ if (nearestStationId != null) {
+ targetStation = routeStations.find((s) => s.id == nearestStationId);
+ targetStationId = nearestStationId;
+ } else if (
+ orderedRouteStations &&
+ orderedRouteStations.length > 0 &&
+ route?.path
+ ) {
+ targetStation = getTargetStation(
+ context.currentCoordinates,
+ orderedRouteStations,
+ route.path
+ );
+
+ if (targetStation) {
+ targetStationId = targetStation.id;
+ }
+ }
+
+ if (targetStation) {
+ setCurrentTransferData(targetStation.transfers);
+ setCurrentStationId(targetStationId);
+ } else {
+ setCurrentTransferData(null);
+ setCurrentStationId(null);
+ }
+ }, [
+ nearestStationId,
+ routeStations,
+ orderedRouteStations,
+ route?.path,
+ context?.currentCoordinates,
+ ]);
+
+ let stationName = null;
+ if (currentStationId != null) {
+ const stationsByLang =
+ selectedLanguageRight === "ru"
+ ? routeStations
+ : selectedLanguageRight === "en"
+ ? routeStationsEn
+ : routeStationsZh;
+
+ const station =
+ stationsByLang &&
+ stationsByLang.find((s) => String(s.id) === String(currentStationId));
+ stationName = station ? station.name : null;
+ }
+
+ const getTransferLabel = () => {
+ if (selectedLanguageRight === "ru") {
+ return stationName
+ ? `Пересадки остановки ${stationName}:`
+ : "Ближайшая остановка не обнаружена";
+ }
+
+ if (selectedLanguageRight === "en") {
+ return stationName
+ ? `Available transfers at station ${stationName}`
+ : "Nearest station not found";
+ }
+
+ return stationName
+ ? `在车站可用的换乘:${stationName}`
+ : "最近的站点未找到";
+ };
+
+ const getNoTransfersMessage = () => {
+ return selectedLanguageRight === "ru"
+ ? "Нет доступных пересадок"
+ : selectedLanguageRight === "en"
+ ? "No transfers available"
+ : "没有可用的中转";
+ };
+
+ return (
+
+
+ {getTransferLabel()}
+ {currentTransferData != null &&
+ !Object.values(currentTransferData).every((value) => value === "") ? (
+ <>
+
+ {currentTransferData?.metro_red?.length > 0 ? (
+
+

+ {currentTransferData.metro_red}
+
+ ) : (
+ ""
+ )}
+
+
+ {currentTransferData?.metro_green?.length > 0 ? (
+
+

+ {currentTransferData.metro_green}
+
+ ) : (
+ ""
+ )}
+
+
+ {currentTransferData?.metro_blue?.length > 0 ? (
+
+

+ {currentTransferData.metro_blue}
+
+ ) : (
+ ""
+ )}
+
+
+ {currentTransferData?.metro_orange?.length > 0 ? (
+
+

+ {currentTransferData.metro_orange}
+
+ ) : (
+ ""
+ )}
+
+
+ {currentTransferData?.metro_purple?.length > 0 ? (
+
+

+ {currentTransferData.metro_purple}
+
+ ) : (
+ ""
+ )}
+
+
+ {currentTransferData?.tram?.length > 0 ? (
+
+

+ {currentTransferData.tram}
+
+ ) : (
+ ""
+ )}
+
+
+ {currentTransferData?.trolleybus?.length > 0 ? (
+
+

+ {currentTransferData.trolleybus}
+
+ ) : (
+ ""
+ )}
+
+
+ {currentTransferData?.bus?.length > 0 ? (
+
+

+ {currentTransferData.bus}
+
+ ) : (
+ ""
+ )}
+
+
+ {currentTransferData?.train?.length > 0 ? (
+
+

+ {currentTransferData.train}
+
+ ) : (
+ ""
+ )}
+
+ >
+ ) : (
+
{getNoTransfersMessage()}
+ )}
+
+
+ );
+});
+
+export default TransferWidget;
diff --git a/src/client/src/components/ListOfSights/index.js b/src/client/src/components/ListOfSights/index.js
new file mode 100644
index 0000000..76a97bc
--- /dev/null
+++ b/src/client/src/components/ListOfSights/index.js
@@ -0,0 +1,7 @@
+export { default as SightFrame } from "./SightFrame";
+export { default as SightComponent } from "./SightComponent";
+export { default as LanguageSelector } from "./LanguageSelector";
+export { default as TransferWidget } from "./TransferWidget";
+export { default as AlphabetNavigator } from "./AlphabetNavigator";
+export { default as SightsList } from "./SightsList";
+export { default as ListHeader } from "./ListHeader";
diff --git a/src/client/src/components/Loader.jsx b/src/client/src/components/Loader.jsx
new file mode 100644
index 0000000..8f1ebb8
--- /dev/null
+++ b/src/client/src/components/Loader.jsx
@@ -0,0 +1,68 @@
+import React, { useState, useEffect } from "react";
+import { createPortal } from "react-dom";
+import "../styles/Loader.css";
+
+const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url)
+ .href;
+
+const Loader = ({
+ loadingStatus = null,
+ loadingProgress = null,
+ onToggleDebug,
+}) => {
+ const [showLoadingInfo, setShowLoadingInfo] = useState(false);
+
+ useEffect(() => {
+ const handleKeyDown = (event) => {
+ if (event.key === "F3") {
+ event.preventDefault();
+ setShowLoadingInfo((prev) => !prev);
+ }
+ if (event.key === "F4") {
+ event.preventDefault();
+ onToggleDebug?.();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [onToggleDebug]);
+
+ const getStatusText = () => {
+ if (!loadingStatus) return "Данные не загружены";
+
+ if (loadingProgress) {
+ const percentage = Math.round(
+ (loadingProgress.current / loadingProgress.total) * 100,
+ );
+ return `${loadingStatus} [${loadingProgress.current}/${loadingProgress.total}] ${percentage}%`;
+ }
+
+ return loadingStatus;
+ };
+
+ return createPortal(
+
+ {showLoadingInfo &&
{getStatusText()}
}
+
+
,
+ document.body,
+ );
+};
+
+export default Loader;
diff --git a/src/client/src/components/OverlayScrollbarsWrapper.jsx b/src/client/src/components/OverlayScrollbarsWrapper.jsx
new file mode 100644
index 0000000..8395c73
--- /dev/null
+++ b/src/client/src/components/OverlayScrollbarsWrapper.jsx
@@ -0,0 +1,326 @@
+import React, { useMemo, useEffect, useRef, useCallback } from "react";
+import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
+import "overlayscrollbars/overlayscrollbars.css";
+
+/**
+ * Обертка для OverlayScrollbars с правильной интеграцией с React
+ */
+export const OverlayScrollbarsWrapper = React.forwardRef(
+ (
+ {
+ children,
+ className,
+ onScroll,
+ overflowX = "hidden",
+ overflowY = "scroll",
+ scrollbarVisibility = "auto",
+ ...props
+ },
+ ref
+ ) => {
+ const internalRef = useRef(null);
+ const scrollHandlerRef = useRef(onScroll);
+
+ // Обновляем ref обработчика при изменении
+ useEffect(() => {
+ scrollHandlerRef.current = onScroll;
+ }, [onScroll]);
+
+ // Получаем DOM-элемент из OverlayScrollbars компонента
+ const getDOMElement = (element) => {
+ if (!element) return null;
+
+ // Если это уже DOM элемент
+ if (typeof element.querySelector === "function") {
+ return element;
+ }
+
+ // Если это экземпляр OverlayScrollbars, получаем host элемент
+ if (element.osInstance && typeof element.osInstance === "function") {
+ const instance = element.osInstance();
+ if (instance && instance.getElements) {
+ const elements = instance.getElements();
+ return elements?.host || null;
+ }
+ }
+
+ // Пытаемся получить DOM-элемент через различные методы
+ if (element?.getElement && typeof element.getElement === "function") {
+ return element.getElement();
+ }
+
+ if (element?.$el) {
+ return element.$el;
+ }
+
+ if (element?.current) {
+ return element.current;
+ }
+
+ // Если это объект с host свойством (может быть экземпляр)
+ if (element?.host && typeof element.host.querySelector === "function") {
+ return element.host;
+ }
+
+ return null;
+ };
+
+ // Получаем viewport элемент (scrollable контейнер)
+ const getViewportElement = (element) => {
+ const domElement = getDOMElement(element);
+ if (!domElement || typeof domElement.querySelector !== "function") {
+ return null;
+ }
+ return domElement.querySelector(".os-viewport");
+ };
+
+ // Объединяем внешний ref с viewport элементом
+ useEffect(() => {
+ if (!internalRef.current) return;
+
+ // Функция для обновления ref с viewport элементом
+ const updateRef = () => {
+ const viewport = getViewportElement(internalRef.current);
+ if (viewport) {
+ if (typeof ref === "function") {
+ ref(viewport);
+ } else if (ref) {
+ ref.current = viewport;
+ }
+ return true;
+ }
+ return false;
+ };
+
+ // Пытаемся получить viewport сразу
+ const found = updateRef();
+ if (found) return; // Если нашли сразу, выходим
+
+ // Если viewport еще не создан, ждем его появления
+ const domElement = getDOMElement(internalRef.current);
+ if (domElement && typeof domElement.observe === "function") {
+ const observer = new MutationObserver(() => {
+ if (updateRef()) {
+ observer.disconnect();
+ }
+ });
+
+ observer.observe(domElement, {
+ childList: true,
+ subtree: true,
+ });
+
+ // Также проверяем периодически
+ const checkInterval = setInterval(() => {
+ if (updateRef()) {
+ clearInterval(checkInterval);
+ observer.disconnect();
+ }
+ }, 100);
+
+ // Очищаем через 2 секунды
+ setTimeout(() => {
+ clearInterval(checkInterval);
+ observer.disconnect();
+ }, 2000);
+
+ return () => {
+ clearInterval(checkInterval);
+ observer.disconnect();
+ };
+ } else {
+ // Fallback: периодическая проверка
+ const checkInterval = setInterval(() => {
+ if (updateRef()) {
+ clearInterval(checkInterval);
+ }
+ }, 100);
+
+ setTimeout(() => {
+ clearInterval(checkInterval);
+ }, 2000);
+
+ return () => {
+ clearInterval(checkInterval);
+ };
+ }
+ }, [ref]);
+
+ // Подключаем обработчик события скролла напрямую на viewport элемент
+ useEffect(() => {
+ const element = internalRef.current;
+ if (!element || !scrollHandlerRef.current) return;
+
+ const handleScroll = (event) => {
+ if (scrollHandlerRef.current) {
+ scrollHandlerRef.current(event);
+ }
+ };
+
+ // Ждем, пока OverlayScrollbars создаст viewport элемент
+ const setupScrollHandler = () => {
+ const viewport = getViewportElement(element);
+ if (viewport) {
+ viewport.addEventListener("scroll", handleScroll);
+ return () => {
+ viewport.removeEventListener("scroll", handleScroll);
+ };
+ }
+ return null;
+ };
+
+ // Пытаемся подключить сразу
+ let cleanup = setupScrollHandler();
+
+ // Если viewport еще не создан, ждем его появления
+ if (!cleanup) {
+ const domElement = getDOMElement(element);
+ if (!domElement || typeof domElement.observe !== "function") {
+ // Если не можем наблюдать за изменениями, просто периодически проверяем
+ const checkInterval = setInterval(() => {
+ if (!cleanup) {
+ cleanup = setupScrollHandler();
+ if (cleanup) {
+ clearInterval(checkInterval);
+ }
+ }
+ }, 100);
+
+ setTimeout(() => {
+ clearInterval(checkInterval);
+ }, 2000);
+
+ return () => {
+ clearInterval(checkInterval);
+ if (cleanup) cleanup();
+ };
+ }
+
+ const observer = new MutationObserver(() => {
+ cleanup = setupScrollHandler();
+ if (cleanup) {
+ observer.disconnect();
+ }
+ });
+
+ observer.observe(domElement, {
+ childList: true,
+ subtree: true,
+ });
+
+ // Также проверяем периодически на случай, если MutationObserver не сработал
+ const checkInterval = setInterval(() => {
+ if (!cleanup) {
+ cleanup = setupScrollHandler();
+ if (cleanup) {
+ clearInterval(checkInterval);
+ observer.disconnect();
+ }
+ }
+ }, 100);
+
+ // Очищаем через 2 секунды, если viewport так и не появился
+ setTimeout(() => {
+ clearInterval(checkInterval);
+ observer.disconnect();
+ }, 2000);
+
+ return () => {
+ clearInterval(checkInterval);
+ observer.disconnect();
+ if (cleanup) cleanup();
+ };
+ }
+
+ return cleanup;
+ }, []);
+
+ const options = useMemo(
+ () => ({
+ scrollbars: {
+ theme: "os-theme-custom",
+ visibility: scrollbarVisibility,
+ autoHide: "never",
+ autoHideDelay: 0,
+ dragScrolling: true,
+ clickScrolling: false,
+ touchSupport: true,
+ snapHandle: false,
+ },
+ overflow: {
+ x: overflowX,
+ y: overflowY,
+ },
+ }),
+ [overflowX, overflowY, scrollbarVisibility]
+ );
+
+ // Обработчик инициализации OverlayScrollbars
+ const handleInitialized = useCallback(
+ (instance) => {
+ if (!instance) {
+ console.warn("OverlayScrollbars: instance is null");
+ return;
+ }
+
+ // Получаем DOM элементы из экземпляра
+ // Метод может называться elements() или getElements()
+ let elements;
+ if (typeof instance.elements === "function") {
+ elements = instance.elements();
+ } else if (typeof instance.getElements === "function") {
+ elements = instance.getElements();
+ } else {
+ console.warn("OverlayScrollbars: cannot get elements from instance");
+ return;
+ }
+
+ const viewport = elements?.viewport;
+
+ if (!viewport) {
+ console.warn("OverlayScrollbars: viewport element not found");
+ return;
+ }
+
+ if (ref) {
+ // Обновляем внешний ref с viewport элементом
+ if (typeof ref === "function") {
+ ref(viewport);
+ } else if (ref) {
+ ref.current = viewport;
+ }
+ }
+ },
+ [ref]
+ );
+
+ const events = useMemo(
+ () => ({
+ initialized: () => {
+ // После инициализации получаем instance через ref
+ if (!internalRef.current) return;
+
+ const instance = internalRef.current.osInstance?.();
+ if (instance) {
+ handleInitialized(instance);
+ }
+ },
+ }),
+ [handleInitialized]
+ );
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+OverlayScrollbarsWrapper.displayName = "OverlayScrollbarsWrapper";
diff --git a/src/client/src/components/ReactMarkdown/ReactMarkdown.css b/src/client/src/components/ReactMarkdown/ReactMarkdown.css
new file mode 100644
index 0000000..1647800
--- /dev/null
+++ b/src/client/src/components/ReactMarkdown/ReactMarkdown.css
@@ -0,0 +1,149 @@
+.react-markdown-container {
+ width: 100%;
+ max-width: 100%;
+ font-size: 0.875rem;
+ line-height: 150%;
+ color: white;
+}
+
+.react-markdown-container img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 4px;
+}
+
+.react-markdown-container h1,
+.react-markdown-container h2,
+.react-markdown-container h3,
+.react-markdown-container h4,
+.react-markdown-container h5,
+.react-markdown-container h6 {
+ color: white;
+ margin-top: 16px;
+ margin-bottom: 8px;
+ font-weight: 600;
+}
+
+.react-markdown-container h1 {
+ font-size: 36px;
+ line-height: 150%;
+}
+
+.react-markdown-container h2 {
+ font-size: 30px;
+ line-height: 150%;
+}
+
+.react-markdown-container h3 {
+ font-size: 24px;
+ line-height: 150%;
+}
+
+.react-markdown-container h4 {
+ font-size: 20px;
+ line-height: 150%;
+}
+
+.react-markdown-container h5 {
+ font-size: 18px;
+ line-height: 150%;
+}
+
+.react-markdown-container h6 {
+ line-height: 150%;
+ font-size: 16px;
+}
+
+.react-markdown-container p {
+ margin-bottom: 16px;
+ font-size: 18px;
+ line-height: 150%;
+ color: white;
+}
+
+.react-markdown-container a {
+ color: white;
+ text-decoration: none;
+}
+
+.react-markdown-container a:hover {
+ text-decoration: underline;
+}
+
+.react-markdown-container blockquote {
+ border-left: 4px solid #006F3A;
+ padding-left: 16px;
+ margin-top: 16px;
+ margin-bottom: 16px;
+ color: rgba(255, 255, 255, 0.7);
+ font-style: italic;
+}
+
+.react-markdown-container code {
+ background-color: rgba(0, 0, 0, 0.2);
+ padding: 4px;
+ border-radius: 4px;
+ color: #fcd500;
+ font-family: "Courier New", monospace;
+ font-size: 0.875em;
+}
+
+.react-markdown-container pre {
+ background-color: rgba(0, 0, 0, 0.2);
+ padding: 16px;
+ border-radius: 4px;
+ overflow-x: auto;
+ margin-bottom: 16px;
+}
+
+.react-markdown-container pre code {
+ background-color: transparent;
+ padding: 0;
+}
+
+.react-markdown-container ul,
+.react-markdown-container ol {
+ margin-bottom: 16px;
+ padding-left: 24px;
+ color: white;
+}
+
+.react-markdown-container li {
+ margin-bottom: 8px;
+ color: white;
+}
+
+.react-markdown-container strong {
+ font-weight: 600;
+ color: white;
+}
+
+.react-markdown-container em {
+ font-style: italic;
+ color: white;
+}
+
+.react-markdown-container hr {
+ border: none;
+ border-top: 1px solid rgba(255, 255, 255, 0.2);
+ margin: 24px 0;
+}
+
+.react-markdown-container table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 16px;
+}
+
+.react-markdown-container th,
+.react-markdown-container td {
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ padding: 8px;
+ text-align: left;
+ color: white;
+}
+
+.react-markdown-container th {
+ font-weight: 600;
+ background-color: rgba(0, 0, 0, 0.2);
+}
diff --git a/src/client/src/components/ReactMarkdown/index.tsx b/src/client/src/components/ReactMarkdown/index.tsx
new file mode 100644
index 0000000..b870722
--- /dev/null
+++ b/src/client/src/components/ReactMarkdown/index.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+import ReactMarkdown from "react-markdown";
+import rehypeRaw from "rehype-raw";
+import "./ReactMarkdown.css";
+
+export const ReactMarkdownComponent = ({ value }: { value: string }) => {
+ return (
+
+ {value}
+
+ );
+};
diff --git a/src/client/src/components/SimulationSettings.tsx b/src/client/src/components/SimulationSettings.tsx
new file mode 100644
index 0000000..524c399
--- /dev/null
+++ b/src/client/src/components/SimulationSettings.tsx
@@ -0,0 +1,118 @@
+import { useState } from "react";
+import { observer } from "mobx-react-lite";
+import { apiStore } from "../api/ApiStore/store";
+
+export const SimulationSettings = observer(() => {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+ {open && (
+
+ {/* Пауза */}
+
+ Пауза
+
+
+
+ {/* Направление */}
+
+ Направление
+
+
+
+ {/* Скорость */}
+
+ Скорость
+
+
+
+ {apiStore.simulationSpeed}x
+
+
+
+
+
+ {/* Без анимации */}
+
+ Без анимации
+
+
+
+ )}
+
+ );
+});
+
+function Row({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
+ return (
+
+ );
+}
+
+const btnStyle: React.CSSProperties = {
+ width: 28, height: 24, borderRadius: 4,
+ border: "1px solid rgba(255,255,255,0.2)",
+ background: "rgba(255,255,255,0.08)",
+ color: "white", cursor: "pointer", fontSize: 14,
+ display: "flex", alignItems: "center", justifyContent: "center",
+};
diff --git a/src/client/src/components/StoreDebugInfo.jsx b/src/client/src/components/StoreDebugInfo.jsx
new file mode 100644
index 0000000..9a67726
--- /dev/null
+++ b/src/client/src/components/StoreDebugInfo.jsx
@@ -0,0 +1,118 @@
+import React from "react";
+import { createPortal } from "react-dom";
+import { observer } from "mobx-react-lite";
+import { apiStore } from "../api/ApiStore/store";
+import { geolocationStore } from "../stores/GeolocationStore";
+import { apiBaseURL, geoBaseURL, weatherBaseURL } from "../api/apiConfig";
+
+const StoreDebugInfo = observer(({ isVisible }) => {
+ if (!isVisible) return null;
+
+ const serializeMap = (map) => {
+ if (!map || !(map instanceof Map)) return map;
+ const obj = {};
+ map.forEach((value, key) => {
+ obj[key] = value;
+ });
+ return obj;
+ };
+
+ const serializeSet = (set) => {
+ if (!set || !(set.forEach)) return set;
+ const arr = [];
+ set.forEach((value) => arr.push(value));
+ return arr;
+ };
+
+ const debugData = {
+ apiStore: {
+ isLoading: apiStore.isLoading,
+ loadingStatus: apiStore.loadingStatus,
+ loadingProgress: apiStore.loadingProgress,
+ context: apiStore.context,
+ weather: apiStore.weather,
+ route: apiStore.route,
+ carrier: apiStore.carrier,
+ city: apiStore.city,
+ routeId: apiStore.routeId,
+ routeSights: { count: apiStore.routeSights?.length ?? 0, items: apiStore.routeSights ?? [] },
+ routeSightsEn: { count: apiStore.routeSightsEn?.length ?? 0 },
+ routeSightsZh: { count: apiStore.routeSightsZh?.length ?? 0 },
+ routeStations: { count: apiStore.routeStations?.length ?? 0, items: apiStore.routeStations ?? [] },
+ routeStationsEn: { count: apiStore.routeStationsEn?.length ?? 0 },
+ routeStationsZh: { count: apiStore.routeStationsZh?.length ?? 0 },
+ orderedRouteStations: { count: apiStore.orderedRouteStations?.length ?? 0 },
+ sightArticlesIds: serializeMap(apiStore.sightArticlesIds),
+ sightArticles: serializeMap(apiStore.sightArticles),
+ media: { count: apiStore.media?.length ?? 0, items: apiStore.media ?? [] },
+ },
+ geolocationStore: {
+ contextData: geolocationStore.contextData,
+ nearestSightId: geolocationStore.nearestSightId,
+ selectedSightId: geolocationStore.selectedSightId,
+ selectedLanguage: geolocationStore.selectedLanguage,
+ selectedLanguageRight: geolocationStore.selectedLanguageRight,
+ nearestStationId: geolocationStore.nearestStationId,
+ currentStationId: geolocationStore.currentStationId,
+ isLeftWidgetOpen: geolocationStore.isLeftWidgetOpen,
+ isTransferWidgetOpen: geolocationStore.isTransferWidgetOpen,
+ isGovernorWidgetOpen: geolocationStore.isGovernorWidgetOpen,
+ isManualSelection: geolocationStore.isManualSelection,
+ isRightWidgetSelectorOpen: geolocationStore.isRightWidgetSelectorOpen,
+ activeClusterId: geolocationStore.activeClusterId,
+ isLoading: geolocationStore.isLoading,
+ error: geolocationStore.error,
+ },
+ };
+
+ return createPortal(
+
+
+
+ Store Debug (F4 — закрыть)
+
+
+
API: {apiBaseURL}
+
GEO: {geoBaseURL}
+
WEATHER: {weatherBaseURL}
+
+ {JSON.stringify(debugData, null, 2)}
+
+
,
+ document.body
+ );
+});
+
+export default StoreDebugInfo;
diff --git a/src/client/src/components/ThreeViewErrorBoundary.css b/src/client/src/components/ThreeViewErrorBoundary.css
new file mode 100644
index 0000000..717317a
--- /dev/null
+++ b/src/client/src/components/ThreeViewErrorBoundary.css
@@ -0,0 +1,147 @@
+.three-view-error-boundary {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+ background-color: rgba(0, 0, 0, 0.05);
+ min-height: 300px;
+}
+
+.error-container {
+ max-width: 500px;
+ width: 100%;
+ padding: 24px;
+ background: linear-gradient(
+ 114deg,
+ rgba(255, 255, 255, 0.1) 8.71%,
+ rgba(255, 255, 255, 0.05) 69.69%
+ ), #006F3A;
+ border-radius: 12px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ color: white;
+ position: relative;
+}
+
+.error-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.error-icon {
+ font-size: 32px;
+ margin-right: 12px;
+}
+
+.error-title {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 600;
+ color: white;
+}
+
+.error-message {
+ margin-bottom: 16px;
+ font-size: 16px;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.error-reasons {
+ margin-bottom: 16px;
+}
+
+.error-reasons p {
+ margin: 0 0 8px 0;
+ font-size: 14px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.error-reasons ul {
+ margin: 8px 0;
+ padding-left: 20px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.error-reasons li {
+ margin-bottom: 4px;
+ font-size: 14px;
+}
+
+.error-details {
+ margin-bottom: 16px;
+ background-color: rgba(0, 0, 0, 0.2);
+ padding: 12px;
+ border-radius: 6px;
+ border-left: 3px solid rgba(255, 255, 255, 0.3);
+}
+
+.error-details pre {
+ margin: 0;
+ font-family: 'Courier New', monospace;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.8);
+ word-break: break-word;
+ white-space: pre-wrap;
+ max-height: 100px;
+ overflow-y: auto;
+}
+
+.error-actions {
+ display: flex;
+ gap: 12px;
+}
+
+.retry-button {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.1));
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: white;
+ padding: 12px 24px;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.retry-button:hover {
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.2));
+ border-color: rgba(255, 255, 255, 0.5);
+ transform: translateY(-1px);
+}
+
+.retry-button:active {
+ transform: translateY(0);
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
+}
+
+/* Адаптивность */
+@media (max-width: 768px) {
+ .three-view-error-boundary {
+ padding: 16px;
+ }
+
+ .error-container {
+ padding: 20px;
+ max-width: 100%;
+ }
+
+ .error-title {
+ font-size: 18px;
+ }
+
+ .error-message {
+ font-size: 14px;
+ }
+
+ .retry-button {
+ padding: 10px 20px;
+ font-size: 13px;
+ }
+}
diff --git a/src/client/src/components/ThreeViewErrorBoundary.tsx b/src/client/src/components/ThreeViewErrorBoundary.tsx
new file mode 100644
index 0000000..075bd69
--- /dev/null
+++ b/src/client/src/components/ThreeViewErrorBoundary.tsx
@@ -0,0 +1,177 @@
+import React, { Component, ReactNode } from "react";
+import "./ThreeViewErrorBoundary.css";
+
+interface Props {
+ children: ReactNode;
+ onReset?: () => void;
+ resetKey?: number | string;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+ lastResetKey?: number | string;
+}
+
+export class ThreeViewErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ hasError: false,
+ error: null,
+ lastResetKey: props.resetKey,
+ };
+ }
+
+ static getDerivedStateFromError(error: Error): Partial {
+ return {
+ hasError: true,
+ error,
+ };
+ }
+
+ static getDerivedStateFromProps(
+ props: Props,
+ state: State
+ ): Partial | null {
+ // Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
+ if (
+ props.resetKey !== state.lastResetKey &&
+ state.lastResetKey !== undefined
+ ) {
+ const oldMediaId = String(state.lastResetKey).split("-")[0];
+ const newMediaId = String(props.resetKey).split("-")[0];
+
+ // Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
+ if (oldMediaId !== newMediaId) {
+ return {
+ hasError: false,
+ error: null,
+ lastResetKey: props.resetKey,
+ };
+ }
+
+ // Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
+ // но не сбрасываем ошибку автоматически - ждем результата загрузки
+
+ return {
+ lastResetKey: props.resetKey,
+ };
+ }
+
+ if (state.lastResetKey === undefined && props.resetKey !== undefined) {
+ return {
+ lastResetKey: props.resetKey,
+ };
+ }
+
+ return null;
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error("❌ ThreeViewErrorBoundary: Ошибка загрузки 3D модели", {
+ error: error.message,
+ stack: error.stack,
+ componentStack: errorInfo.componentStack,
+ });
+ }
+
+ getErrorMessage = () => {
+ const errorMessage = this.state.error?.message || "";
+
+ if (
+ errorMessage.includes("not valid JSON") ||
+ errorMessage.includes("Unexpected token")
+ ) {
+ return "Неверный формат файла. Убедитесь, что файл является корректной 3D моделью в формате GLB/GLTF.";
+ }
+
+ if (errorMessage.includes("Could not load")) {
+ return "Не удалось загрузить файл 3D модели. Проверьте, что файл существует и доступен.";
+ }
+
+ if (errorMessage.includes("404") || errorMessage.includes("Not Found")) {
+ return "Файл 3D модели не найден на сервере.";
+ }
+
+ if (errorMessage.includes("Network") || errorMessage.includes("fetch")) {
+ return "Ошибка сети при загрузке 3D модели. Проверьте интернет-соединение.";
+ }
+
+ return (
+ errorMessage || "Произошла неизвестная ошибка при загрузке 3D модели"
+ );
+ };
+
+ getErrorReasons = () => {
+ const errorMessage = this.state.error?.message || "";
+
+ if (
+ errorMessage.includes("not valid JSON") ||
+ errorMessage.includes("Unexpected token")
+ ) {
+ return [
+ "Файл не является 3D моделью",
+ "Загружен файл неподдерживаемого формата",
+ "Файл поврежден или не полностью загружен",
+ "Используйте только GLB или GLTF форматы",
+ ];
+ }
+
+ return [
+ "Поврежденный файл модели",
+ "Неподдерживаемый формат",
+ "Проблемы с загрузкой файла",
+ ];
+ };
+
+ handleReset = () => {
+ // Сначала сбрасываем состояние ошибки
+ this.setState(
+ {
+ hasError: false,
+ error: null,
+ },
+ () => {
+ // После того как состояние обновилось, вызываем callback для изменения resetKey
+ // Это приведет к пересозданию компонента и новой попытке загрузки
+ this.props.onReset?.();
+ }
+ );
+ };
+
+ handleClose = () => {
+ this.setState({
+ hasError: false,
+ error: null,
+ });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+
⚠️
+
Ошибка загрузки 3D модели
+
+
+
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/src/client/src/components/TouchableLayout/index.tsx b/src/client/src/components/TouchableLayout/index.tsx
new file mode 100644
index 0000000..0d936d7
--- /dev/null
+++ b/src/client/src/components/TouchableLayout/index.tsx
@@ -0,0 +1,269 @@
+import React, {
+ useEffect,
+ useRef,
+ useState,
+ useCallback,
+ ReactNode,
+ forwardRef,
+} from "react";
+import "../../styles/TouchableLayout.css";
+
+interface TouchableLayoutProps {
+ children?: ReactNode;
+ className?: string;
+ maxHeight?: string | number;
+}
+
+function useThumbSync(scrollableRef: React.RefObject) {
+ const [state, setState] = useState({
+ height: 60,
+ top: 0,
+ hasScroll: false,
+ });
+ const rafRef = useRef(null);
+
+ const update = useCallback(() => {
+ const el = scrollableRef.current;
+ if (!el) return;
+
+ const sh = el.scrollHeight;
+ const ch = el.clientHeight;
+ const st = el.scrollTop;
+ const th = ch;
+
+ if (sh <= ch) {
+ setState({ height: th, top: 0, hasScroll: false });
+ return;
+ }
+
+ const thumbHeight = Math.max(60, (ch / sh) * th);
+ const range = th - thumbHeight;
+ const scrollRange = sh - ch;
+ const top = range <= 0 ? 0 : (st / scrollRange) * range;
+
+ setState({ height: thumbHeight, top, hasScroll: true });
+ }, []);
+
+ useEffect(() => {
+ const el = scrollableRef.current;
+ if (!el) return;
+
+ const schedule = () => {
+ if (rafRef.current != null) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ update();
+ });
+ };
+
+ el.addEventListener("scroll", schedule, { passive: true });
+ const ro = new ResizeObserver(schedule);
+ ro.observe(el);
+ schedule();
+
+ return () => {
+ el.removeEventListener("scroll", schedule);
+ ro.disconnect();
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
+ };
+ }, [update]);
+
+ return state;
+}
+
+export const TouchableLayout = forwardRef(
+ ({ children, className, maxHeight }, ref) => {
+ const containerRef = useRef(null);
+ const scrollableRef = useRef(null);
+ const trackRef = useRef(null);
+ const thumbRef = useRef(null);
+
+ const thumb = useThumbSync(scrollableRef);
+
+ const setRefs = useCallback(
+ (node: HTMLDivElement | null) => {
+ (
+ containerRef as React.MutableRefObject
+ ).current = node;
+ if (typeof ref === "function") {
+ ref(node);
+ } else if (ref) {
+ (ref as React.MutableRefObject).current = node;
+ }
+ },
+ [ref],
+ );
+
+ useEffect(() => {
+ const scrollable = scrollableRef.current;
+ if (!scrollable) return;
+
+ let isDown = false;
+ let isDragging = false;
+ let startY = 0;
+ let startScrollTop = 0;
+ const DRAG_THRESHOLD = 5;
+
+ const handlePointerDown = (e: Event) => {
+ const pe = e as PointerEvent;
+
+ isDown = true;
+ isDragging = false;
+ startY = pe.clientY;
+ startScrollTop = scrollable.scrollTop;
+ };
+
+ const handlePointerMove = (e: Event) => {
+ if (!isDown) return;
+ const pe = e as PointerEvent;
+
+ const walk = pe.clientY - startY;
+
+ if (!isDragging && Math.abs(walk) < DRAG_THRESHOLD) {
+ return;
+ }
+
+ if (!isDragging) {
+ isDragging = true;
+ scrollable.setPointerCapture(pe.pointerId);
+ }
+
+ pe.preventDefault();
+
+ scrollable.scrollTop = Math.max(
+ 0,
+ Math.min(
+ scrollable.scrollHeight - scrollable.clientHeight,
+ startScrollTop - walk,
+ ),
+ );
+ };
+
+ const stopScrolling = (e: Event) => {
+ if (!isDown) return;
+ const pe = e as PointerEvent;
+
+ if (isDragging) {
+ pe.preventDefault();
+ isDragging = false;
+ }
+
+ isDown = false;
+ try {
+ scrollable.releasePointerCapture(pe.pointerId);
+ } catch {
+ }
+ };
+
+ scrollable.addEventListener("pointerdown", handlePointerDown);
+ scrollable.addEventListener("pointermove", handlePointerMove);
+ scrollable.addEventListener("pointerup", stopScrolling);
+ scrollable.addEventListener("pointercancel", stopScrolling);
+
+ return () => {
+ scrollable.removeEventListener("pointerdown", handlePointerDown);
+ scrollable.removeEventListener("pointermove", handlePointerMove);
+ scrollable.removeEventListener("pointerup", stopScrolling);
+ scrollable.removeEventListener("pointercancel", stopScrolling);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!thumb.hasScroll) return;
+
+ const scrollable = scrollableRef.current;
+ const track = trackRef.current;
+ const thumbEl = thumbRef.current;
+ if (!scrollable || !track || !thumbEl) return;
+
+ let isDragging = false;
+ let startY = 0;
+ let startScrollTop = 0;
+
+ const onPointerDown = (e: PointerEvent) => {
+ e.preventDefault();
+ isDragging = true;
+ thumbEl.setPointerCapture(e.pointerId);
+ startY = e.clientY;
+ startScrollTop = scrollable.scrollTop;
+ };
+
+ const onPointerMove = (e: PointerEvent) => {
+ if (!isDragging) return;
+ e.preventDefault();
+ const th = track.offsetHeight;
+ const thumbH = thumbEl.offsetHeight;
+ const range = th - thumbH;
+ const scrollRange = scrollable.scrollHeight - scrollable.clientHeight;
+ if (range <= 0 || scrollRange <= 0) return;
+
+ const dy = e.clientY - startY;
+ const dScroll = (dy / range) * scrollRange;
+ scrollable.scrollTop = Math.max(
+ 0,
+ Math.min(scrollRange, startScrollTop + dScroll),
+ );
+ };
+
+ const onPointerUp = (e: PointerEvent) => {
+ if (isDragging) {
+ isDragging = false;
+ thumbEl.releasePointerCapture(e.pointerId);
+ }
+ };
+
+ thumbEl.addEventListener("pointerdown", onPointerDown as EventListener);
+ thumbEl.addEventListener("pointermove", onPointerMove as EventListener);
+ thumbEl.addEventListener("pointerup", onPointerUp as EventListener);
+ thumbEl.addEventListener("pointercancel", onPointerUp as EventListener);
+
+ return () => {
+ thumbEl.removeEventListener(
+ "pointerdown",
+ onPointerDown as EventListener,
+ );
+ thumbEl.removeEventListener(
+ "pointermove",
+ onPointerMove as EventListener,
+ );
+ thumbEl.removeEventListener("pointerup", onPointerUp as EventListener);
+ thumbEl.removeEventListener(
+ "pointercancel",
+ onPointerUp as EventListener,
+ );
+ };
+ }, [thumb.hasScroll]);
+
+ const containerClassName = className
+ ? `scrollable-container ${className}`
+ : "scrollable-container";
+
+ const viewportStyle: React.CSSProperties = maxHeight
+ ? {
+ maxHeight:
+ typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight,
+ }
+ : {};
+
+ return (
+
+
+
+ {children}
+
+ {thumb.hasScroll && (
+
+ )}
+
+
+ );
+ },
+);
+
+TouchableLayout.displayName = "TouchableLayout";
diff --git a/src/client/src/components/WeatherWidget.jsx b/src/client/src/components/WeatherWidget.jsx
new file mode 100644
index 0000000..d1a125e
--- /dev/null
+++ b/src/client/src/components/WeatherWidget.jsx
@@ -0,0 +1,192 @@
+import "../styles/WeatherWidget.css";
+import { useState, useEffect, useRef } from "react";
+import { observer } from "mobx-react-lite";
+import weatherApi from "../api/weather/weather.api";
+
+import getWeatherIcon from "../utils/getWeatherIcon";
+import windIcon from "../assets/weather-icons/wind.png";
+import humidityIcon from "../assets/weather-icons/humidity.png";
+import { useGeolocationStore } from "../stores";
+import { translateWeatherStatus } from "../utils/translateWeatherStatus";
+
+function WeatherDataLine({ icon, value, unit }) {
+ return (
+
+

+
+ {unit ? (
+ <>
+ {value}
+ {unit}
+ >
+ ) : (
+ value
+ )}
+
+
+ );
+}
+
+const WeatherWidget = observer(() => {
+ const [currentTime, setCurrentTime] = useState(new Date());
+ const [weather, setWeather] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
+ const lastDateRef = useRef(() => {
+ const today = new Date();
+ return `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
+ });
+
+ const store = useGeolocationStore();
+ const { selectedLanguage, setError: setErrorStore } = store;
+
+ const fetchWeather = async () => {
+ try {
+ const data = await weatherApi.getFormattedWeather();
+ setWeather(data);
+ setError(null);
+ setHasLoadedOnce(true);
+ } catch (err) {
+ setError(err.message);
+ setErrorStore(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchWeather();
+ }, []);
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ const now = new Date();
+ setCurrentTime(now);
+
+ const newDate = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
+ if (newDate !== lastDateRef.current) {
+ lastDateRef.current = newDate;
+
+ fetchWeather();
+ }
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, []);
+
+ useEffect(() => {
+ const intervalId = setInterval(
+ () => {
+ fetchWeather();
+ },
+ 5 * 60 * 1000,
+ );
+
+ return () => clearInterval(intervalId);
+ }, [setErrorStore]);
+
+ const formattedTime = currentTime.toLocaleTimeString(
+ selectedLanguage === "ru"
+ ? "ru-RU"
+ : selectedLanguage === "zh"
+ ? "zh-ZH"
+ : "en-GB",
+ { hour: "2-digit", minute: "2-digit" },
+ );
+ const day = currentTime.getDate().toString().padStart(2, "0");
+ const month = (currentTime.getMonth() + 1).toString().padStart(2, "0");
+ const weekday = currentTime.toLocaleDateString(
+ selectedLanguage === "ru"
+ ? "ru-RU"
+ : selectedLanguage === "zh"
+ ? "zh-ZH"
+ : "en-GB",
+ { weekday: "long" },
+ );
+ const formattedDate = `${day}.${month}, ${weekday}`;
+
+ const formatDayOfWeek = (daysToAdd) => {
+ const date = new Date();
+ date.setDate(date.getDate() + daysToAdd);
+ const dayName = date.toLocaleDateString(
+ selectedLanguage === "ru"
+ ? "ru-RU"
+ : selectedLanguage === "zh"
+ ? "zh-ZH"
+ : "en-US",
+ { weekday: "short" },
+ );
+ return dayName.charAt(0).toUpperCase() + dayName.slice(1);
+ };
+
+ if (!hasLoadedOnce) {
+ return null;
+ }
+
+ if (!weather) {
+ return null;
+ }
+
+ return (
+
+
{formattedTime}
+
{formattedDate}
+
+
+
+
})
+
+ {weather.today.temperature}°
+
+
+ {translateWeatherStatus(weather.today.status, selectedLanguage)}
+
+
+
+ {weather.tomorrow.temperature !== "N/A" && (
+
+ )}
+ {weather.dayAfterTomorrow.temperature !== "N/A" && (
+
+ )}
+
+
+
+
+
+
+ );
+});
+
+export default WeatherWidget;
diff --git a/src/client/src/components/map/Constants.tsx b/src/client/src/components/map/Constants.tsx
new file mode 100644
index 0000000..d69c2ef
--- /dev/null
+++ b/src/client/src/components/map/Constants.tsx
@@ -0,0 +1,12 @@
+export const UP_SCALE = 10000;
+export const PATH_WIDTH = 5;
+export const STATION_RADIUS = 8;
+export const STATION_OUTLINE_WIDTH = 4;
+export const BACKGROUND_COLOR = 0x111111;
+export const PATH_COLOR = 0xed1c24;
+export const BUS_COLOR = 0xfcd500;
+export const UNPASSED_STATION_COLOR = 0xcccccc;
+export const BASE_ICON_SIZE = 30;
+export const CLUSTER_RADIUS_BASE = 10;
+export const CLUSTER_COLOR = 0x896f58;
+export const ACTIVE_STATION_COLOR = 0xffa500;
diff --git a/src/client/src/components/map/InfiniteCanvas.tsx b/src/client/src/components/map/InfiniteCanvas.tsx
new file mode 100644
index 0000000..314a2ec
--- /dev/null
+++ b/src/client/src/components/map/InfiniteCanvas.tsx
@@ -0,0 +1,356 @@
+import { FederatedPointerEvent, FederatedWheelEvent } from "pixi.js";
+import { ReactNode, useEffect, useState, useRef, useCallback } from "react";
+import { useTransform } from "./transformContext";
+import { BACKGROUND_COLOR, SCALE_FACTOR } from "../../assets/Constants";
+import { useApplication } from "@pixi/react";
+import { useGeolocation } from "../../context/GeolocationContext";
+import ContentAPI from "../../api/content/content.api";
+import React from "react";
+import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
+import { useCameraAnimationStore } from "../../stores";
+import { observer } from "mobx-react-lite";
+import debounce from "lodash/debounce";
+import { apiStore } from "../../api/ApiStore/store";
+
+export const InfiniteCanvas = observer(
+ ({ children }: Readonly<{ children?: ReactNode }>) => {
+ const { route } = apiStore;
+ const {
+ position,
+ setPosition,
+ scale,
+ setScale,
+ setScreenCenter,
+ isAutoMode,
+ setIsAutoMode,
+ userActivityTimestamp,
+ updateUserActivity,
+ autoModeStartTimestamp,
+ setAutoModeStartTimestamp,
+ } = useTransform();
+ const [loaded, setLoaded] = useState(false);
+
+ const applicationRef = useApplication();
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [startMousePosition, setStartMousePosition] = useState({
+ x: 0,
+ y: 0,
+ });
+ const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
+
+ const activePointers = useRef(new Map());
+ const [isPinching, setIsPinching] = useState(false);
+ // Add new useRef for storing initial pinch gesture data
+ const pinchStartData = useRef<{
+ distance: number;
+ midpoint: { x: number; y: number };
+ scale: number;
+ position: { x: number; y: number };
+ } | null>(null);
+ // Keep these for backward compatibility, but we'll use pinchStartData for calculations
+ const [initialPinchDistance, setInitialPinchDistance] = useState<
+ number | null
+ >(null);
+ const [initialPinchMidpoint, setInitialPinchMidpoint] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ const [scaleMin, setScaleMin] = useState(0.1); // Default min scale
+ const [scaleMax, setScaleMax] = useState(3); // Default max scale
+ const store = useGeolocationStore();
+ const cameraAnimationStore = useCameraAnimationStore();
+
+ // Add debounced version of syncState to reduce jittering
+ const syncStateDebounced = useRef(
+ debounce((pos, zoom) => {
+ cameraAnimationStore.syncState(pos, zoom);
+ }, 16) // ~60fps
+ ).current;
+
+ // Функция для плавного ограничения масштаба
+ const getSmoothScale = (targetScale: number, currentScale: number) => {
+ if (isAutoMode) return targetScale; // В авто режиме без ограничений
+
+ // Плавное ограничение с затуханием
+ const damping = 0.3; // Коэффициент затухания (0-1)
+
+ if (targetScale < scaleMin) {
+ // Плавное замедление при приближении к минимальному масштабу
+ const distance = scaleMin - targetScale;
+ const dampedDistance = distance * damping;
+ return Math.max(scaleMin, targetScale + dampedDistance);
+ }
+
+ if (targetScale > scaleMax) {
+ // Плавное замедление при приближении к максимальному масштабу
+ const distance = targetScale - scaleMax;
+ const dampedDistance = distance * damping;
+ return Math.min(scaleMax, targetScale - dampedDistance);
+ }
+
+ // Плавное возвращение к границам, если текущий масштаб за пределами
+ if (currentScale < scaleMin) {
+ // Плавно возвращаемся к минимальному масштабу
+ const distance = scaleMin - currentScale;
+ const dampedDistance = distance * damping;
+ return Math.min(targetScale, currentScale + dampedDistance);
+ }
+
+ if (currentScale > scaleMax) {
+ // Плавно возвращаемся к максимальному масштабу
+ const distance = currentScale - scaleMax;
+ const dampedDistance = distance * damping;
+ return Math.max(targetScale, currentScale - dampedDistance);
+ }
+
+ return targetScale;
+ };
+
+ const handleUserActivity = () => {
+ updateUserActivity();
+ if (isAutoMode) {
+ setIsAutoMode(false);
+ }
+ // При любом действии пользователя останавливаем анимацию
+ cameraAnimationStore.stopAnimation();
+ };
+
+ // Автоматический режим - таймер для включения и управление масштабом
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const timeSinceActivity = Date.now() - userActivityTimestamp;
+ if (timeSinceActivity >= 5000 && !isAutoMode) {
+ // 5 секунд бездействия - включаем авто режим
+ if (loaded) {
+ setIsAutoMode(true);
+ setAutoModeStartTimestamp(Date.now()); // Записываем время включения автопреследования
+ // Убираем мгновенную установку масштаба - теперь это делает CameraAnimationStore плавно
+ }
+ }
+ }, 1000); // Проверяем каждую секунду
+
+ return () => clearInterval(interval);
+ }, [
+ userActivityTimestamp,
+ isAutoMode,
+ setIsAutoMode,
+ setScale,
+ setAutoModeStartTimestamp,
+ ]);
+
+ useEffect(() => {
+ async function fetchRouteData() {
+ try {
+ const newScaleMin = route?.scale_min! / SCALE_FACTOR;
+ const newScaleMax = route?.scale_max! / SCALE_FACTOR;
+
+ setScaleMin(newScaleMin);
+ setScaleMax(newScaleMax);
+ setLoaded(true);
+ } catch (error) {
+ console.error(
+ "Ошибка загрузки данных маршрута для scaleMin/Max:",
+ error
+ );
+ }
+ }
+ fetchRouteData();
+ }, [route]);
+
+ // Убираем жесткое ограничение масштаба - теперь используется плавное ограничение
+
+ useEffect(() => {
+ const canvas = applicationRef?.app.canvas;
+ if (!canvas) return;
+ const canvasRect = canvas.getBoundingClientRect();
+ const canvasLeft = canvasRect.left;
+ const canvasTop = canvasRect.top;
+ const centerX = window.innerWidth / 2 - canvasLeft;
+ const centerY = window.innerHeight / 2 - canvasTop;
+ setScreenCenter({ x: centerX, y: centerY });
+ }, [applicationRef?.app.canvas, setScreenCenter]);
+
+ const handlePointerDown = (e: FederatedPointerEvent) => {
+ handleUserActivity();
+
+ activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
+
+ if (activePointers.current.size === 1) {
+ setIsPinching(false);
+ setInitialPinchDistance(null);
+ setInitialPinchMidpoint(null);
+ pinchStartData.current = null;
+ setIsDragging(true);
+ setStartPosition({ x: position.x, y: position.y });
+ setStartMousePosition({ x: e.globalX, y: e.globalY });
+ } else if (activePointers.current.size === 2) {
+ setIsDragging(false); // Останавливаем перетаскивание, начинаем пинч
+ const pointersArray = Array.from(activePointers.current.values());
+ const p1 = pointersArray[0];
+ const p2 = pointersArray[1];
+
+ // Calculate initial values
+ const initialDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
+ const initialMidpoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
+
+ // Save initial gesture data in pinchStartData
+ pinchStartData.current = {
+ distance: initialDistance,
+ midpoint: initialMidpoint,
+ scale: scale,
+ position: { ...position },
+ };
+
+ // Keep these for backward compatibility
+ setInitialPinchDistance(initialDistance);
+ setInitialPinchMidpoint(initialMidpoint);
+
+ setIsPinching(true);
+ }
+ e.stopPropagation();
+ };
+
+ const handlePointerMove = (e: FederatedPointerEvent) => {
+ if (!activePointers.current.has(e.pointerId)) return;
+
+ updateUserActivity();
+ activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
+
+ if (
+ isPinching &&
+ activePointers.current.size === 2 &&
+ pinchStartData.current
+ ) {
+ const pointersArray = Array.from(activePointers.current.values());
+ const p1 = pointersArray[0];
+ const p2 = pointersArray[1];
+ const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
+ const currentMidpoint = {
+ x: (p1.x + p2.x) / 2,
+ y: (p1.y + p2.y) / 2,
+ };
+
+ // 1. Calculate zoomFactor relative to the INITIAL distance
+ const zoomFactor = currentDistance / pinchStartData.current.distance;
+
+ // 2. Calculate new scale relative to the INITIAL scale
+ const targetScale = pinchStartData.current.scale * zoomFactor;
+ const newScale = getSmoothScale(targetScale, scale);
+
+ // 3. Calculate new position relative to the INITIAL position
+ // This is the standard formula for "zoom to point" (in our case, to the midpoint between fingers)
+ const newPosition = {
+ x:
+ pinchStartData.current.midpoint.x +
+ (pinchStartData.current.position.x -
+ pinchStartData.current.midpoint.x) *
+ (newScale / pinchStartData.current.scale),
+ y:
+ pinchStartData.current.midpoint.y +
+ (pinchStartData.current.position.y -
+ pinchStartData.current.midpoint.y) *
+ (newScale / pinchStartData.current.scale),
+ };
+
+ setPosition(newPosition);
+ setScale(newScale);
+ syncStateDebounced(newPosition, newScale);
+
+ // We do NOT update pinchStartData here - it remains fixed for the entire gesture
+
+ // Update these for backward compatibility, but they're not used for calculations
+ setInitialPinchDistance(currentDistance);
+ setInitialPinchMidpoint(currentMidpoint);
+ } else if (activePointers.current.size === 1) {
+ setIsPinching(false);
+ setInitialPinchDistance(null);
+ setInitialPinchMidpoint(null);
+ pinchStartData.current = null;
+
+ if (isDragging) {
+ const newPosition = {
+ x: startPosition.x - startMousePosition.x + e.globalX,
+ y: startPosition.y - startMousePosition.y + e.globalY,
+ };
+ setPosition(newPosition);
+ syncStateDebounced(newPosition, scale);
+ }
+ e.stopPropagation();
+ };
+
+ const handlePointerUp = (e: FederatedPointerEvent) => {
+ handleUserActivity();
+
+ activePointers.current.delete(e.pointerId);
+
+ if (activePointers.current.size < 2) {
+ setIsPinching(false);
+ setInitialPinchDistance(null);
+ setInitialPinchMidpoint(null);
+ pinchStartData.current = null; // Clear pinch gesture data
+ }
+
+ if (activePointers.current.size === 0) {
+ setIsDragging(false);
+ } else if (activePointers.current.size === 1) {
+ // If one finger remains after pinch, start dragging from the new position
+ const remainingPointer = Array.from(activePointers.current.values())[0];
+ setStartPosition({ x: position.x, y: position.y });
+ setStartMousePosition({ x: remainingPointer.x, y: remainingPointer.y });
+ setIsDragging(true);
+ }
+ e.stopPropagation();
+ };
+
+ const handleWheel = (e: FederatedWheelEvent) => {
+ e.stopPropagation();
+ handleUserActivity(); // Используем новую функцию
+
+ const mouseX = e.globalX - position.x;
+ const mouseY = e.globalY - position.y;
+ const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
+
+ // Используем плавное ограничение масштаба
+ const targetScale = scale * zoomFactor;
+ const newScale = getSmoothScale(targetScale, scale);
+ const actualZoomFactor = newScale / scale;
+
+ if (scale !== newScale && newScale >= scaleMin && newScale <= scaleMax) {
+ // Убираем из условия && newScale >= scaleMin && newScale <= scaleMax
+ const newPosition = {
+ x: position.x + mouseX * (1 - actualZoomFactor),
+ y: position.y + mouseY * (1 - actualZoomFactor),
+ };
+ setPosition(newPosition);
+ setScale(newScale);
+ // Используем дебаунсированную функцию для синхронизации стора после зума
+ syncStateDebounced(newPosition, newScale);
+ }
+ };
+
+ return (
+ <>
+ {
+ const canvas = applicationRef.app.canvas;
+ g.clear();
+ g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
+ g.fill(BACKGROUND_COLOR);
+ }}
+ eventMode="static"
+ interactive
+ onPointerDown={handlePointerDown}
+ onGlobalPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerLeave={handlePointerUp}
+ onWheel={handleWheel}
+ />
+
+ {children}
+
+ >
+ );
+ }
+);
diff --git a/src/client/src/components/map/Map.tsx b/src/client/src/components/map/Map.tsx
new file mode 100644
index 0000000..9fcaa24
--- /dev/null
+++ b/src/client/src/components/map/Map.tsx
@@ -0,0 +1,348 @@
+import React, {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useMemo,
+} from "react";
+import { observer } from "mobx-react-lite";
+import { Application, extend } from "@pixi/react";
+import { Container, Graphics, Sprite, Text } from "pixi.js";
+import { MapDataProvider, useMapData } from "./MapDataContext";
+import { TransformProvider, useTransform } from "./transformContext";
+import { InfiniteCanvas } from "./InfiniteCanvas";
+import { TravelPath } from "./TravelPath";
+import { Station } from "./Station";
+import { SightsLayer } from "./Sight";
+import Loader from "../Loader";
+import {
+ BACKGROUND_COLOR,
+ BUS_COLOR,
+ STATION_OUTLINE_WIDTH,
+ STATION_RADIUS,
+ UP_SCALE,
+} from "./Constants";
+import "../../styles/MapLayer.css";
+import { useGeolocationStore, useCameraAnimationStore } from "../../stores";
+import { coordinatesToLocal } from "./utils";
+import { TramIcon } from "./TramIcon";
+import { SCALE_FACTOR } from "../../assets/Constants";
+import { apiStore } from "../../api/ApiStore/store";
+import WebGLMap from "./WebGLMap";
+
+extend({ Container, Graphics, Text, Sprite });
+
+export function Map() {
+ return (
+
+
+
+
+
+ );
+}
+
+function rotatePoint(x, y, originX, originY, angle) {
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+ const dx = x - originX;
+ const dy = y - originY;
+ const rotatedX = dx * cos - dy * sin + originX;
+ const rotatedY = dx * sin + dy * cos + originY;
+ return { x: rotatedX, y: rotatedY };
+}
+
+const RouteMap = observer(() => {
+ const store = useGeolocationStore();
+ const { contextData } = store;
+ const {
+ routeData,
+ stationData,
+ stationDataEn,
+ stationDataZh,
+ sightData,
+ setRouteData,
+ setStationData,
+ setStationDataEn,
+ setStationDataZh,
+ setSightData,
+ } = useMapData();
+ const {
+ setPosition,
+ setScreenCenter,
+ position,
+ screenCenter,
+ isAutoMode,
+ setScale,
+ scale,
+ } = useTransform();
+ const cameraAnimationStore = useCameraAnimationStore();
+ const parentRef = useRef(null);
+
+ const [rotationAngle, setRotationAngle] = useState(0);
+
+ const {
+ routeStations,
+ context,
+ routeStationsEn,
+ routeStationsZh,
+ routeSights,
+ route,
+ isLoading,
+ } = apiStore;
+
+ useEffect(() => {
+ if (!isLoading) {
+ setStationData(routeStations!);
+ setStationDataEn(routeStationsEn!);
+ setStationDataZh(routeStationsZh!);
+ setSightData(routeSights!);
+ setRouteData(route!);
+ setRotationAngle(route?.rotate! * (Math.PI / 180));
+ cameraAnimationStore.setMaxZoom(route?.scale_max! / SCALE_FACTOR);
+ cameraAnimationStore.setMinZoom(route?.scale_min! / SCALE_FACTOR);
+ }
+ }, [isLoading]);
+
+ useEffect(() => {
+ cameraAnimationStore.setUpdateCallback((newPos, newZoom) => {
+ setPosition(newPos);
+ setScale(newZoom);
+ });
+
+ cameraAnimationStore.syncState(position, scale);
+
+ return () => {
+ cameraAnimationStore.setUpdateCallback(null);
+ };
+ }, [cameraAnimationStore, setPosition, setScale]);
+
+ useEffect(() => {
+ const canvasRect = {
+ left: 0,
+ top: 0,
+ width: window.innerWidth,
+ height: window.innerHeight,
+ };
+ const centerX = canvasRect.width / 2;
+ const centerY = canvasRect.height / 2;
+ setScreenCenter({ x: centerX, y: centerY });
+
+ if (routeData?.center_latitude && routeData?.center_longitude) {
+ const initialCameraPosition = { x: centerX, y: centerY };
+ const initialZoom = cameraAnimationStore.minZoom;
+ cameraAnimationStore.syncState(initialCameraPosition, initialZoom);
+ } else {
+ setPosition({ x: centerX, y: centerY });
+ }
+ }, [routeData, cameraAnimationStore]);
+
+ const centerLat = routeData?.center_latitude;
+ const centerLon = routeData?.center_longitude;
+ const rotationOriginX = 0;
+ const rotationOriginY = 0;
+
+ const transformGeoToMapLocal = useCallback(
+ (latitude, longitude) => {
+ if (centerLat === undefined || centerLon === undefined) {
+ return { x: 0, y: 0 };
+ }
+ const local = coordinatesToLocal(
+ latitude - centerLat,
+ longitude - centerLon
+ );
+ return { x: local.x * UP_SCALE, y: local.y * UP_SCALE };
+ },
+ [centerLat, centerLon]
+ );
+
+ const transformedCurrentCoordinates = useMemo(() => {
+ if (
+ !context?.currentCoordinates ||
+ centerLat === undefined ||
+ centerLon === undefined
+ ) {
+ return undefined;
+ }
+
+ return rotatePoint(
+ transformGeoToMapLocal(
+ context.currentCoordinates.latitude,
+ context.currentCoordinates.longitude
+ ).x,
+ transformGeoToMapLocal(
+ context.currentCoordinates.latitude,
+ context.currentCoordinates.longitude
+ ).y,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle
+ );
+ }, [
+ context?.currentCoordinates?.latitude,
+ context?.currentCoordinates?.longitude,
+ centerLat,
+ centerLon,
+ transformGeoToMapLocal,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle,
+ ]);
+
+ const transformedStations = useMemo(() => {
+ if (!stationData) return [];
+
+ return stationData.map((station) => {
+ const { x, y } = transformGeoToMapLocal(
+ station.latitude,
+ station.longitude
+ );
+ const rotatedCoords = rotatePoint(
+ x,
+ y,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle
+ );
+ return {
+ ...station,
+ longitude: rotatedCoords.x,
+ latitude: rotatedCoords.y,
+ };
+ });
+ }, [
+ stationData,
+ transformGeoToMapLocal,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle,
+ ]);
+
+ useEffect(() => {
+ if (isAutoMode && transformedCurrentCoordinates && screenCenter) {
+ cameraAnimationStore.followTram(
+ {
+ x: transformedCurrentCoordinates.x,
+ y: transformedCurrentCoordinates.y,
+ },
+ { x: screenCenter.x, y: screenCenter.y },
+ transformedStations
+ );
+ } else if (!isAutoMode) {
+ cameraAnimationStore.stopAnimation();
+ }
+ }, [
+ isAutoMode,
+ transformedCurrentCoordinates,
+ screenCenter,
+ cameraAnimationStore,
+ transformedStations,
+ ]);
+
+ const drawActualBusPos = useCallback(
+ (g: Graphics) => {
+ g.clear();
+ if (transformedCurrentCoordinates) {
+ g.circle(
+ transformedCurrentCoordinates.x,
+ transformedCurrentCoordinates.y,
+ STATION_RADIUS / scale < 10
+ ? 10
+ : STATION_RADIUS / scale > 20
+ ? 20
+ : STATION_RADIUS / scale
+ );
+ g.fill({ color: BUS_COLOR });
+ g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
+ }
+ },
+ [transformedCurrentCoordinates, scale]
+ );
+
+ const scaledPoints = useMemo(() => {
+ if (!routeData?.path) return [];
+
+ return routeData.path.map(([latitude, longitude]) => {
+ const { x, y } = transformGeoToMapLocal(latitude, longitude);
+ return rotatePoint(x, y, rotationOriginX, rotationOriginY, rotationAngle);
+ });
+ }, [
+ routeData?.path,
+ transformGeoToMapLocal,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle,
+ ]);
+
+ const transformedStationsEn = useMemo(() => {
+ if (!stationDataEn) return [];
+
+ return stationDataEn.map((station) => {
+ const { x, y } = transformGeoToMapLocal(
+ station.latitude,
+ station.longitude
+ );
+ const rotatedCoords = rotatePoint(
+ x,
+ y,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle
+ );
+ return {
+ ...station,
+ longitude: rotatedCoords.x,
+ latitude: rotatedCoords.y,
+ };
+ });
+ }, [
+ stationDataEn,
+ transformGeoToMapLocal,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle,
+ ]);
+
+ const transformedStationsZh = useMemo(() => {
+ if (!stationDataZh) return [];
+
+ return stationDataZh.map((station) => {
+ const { x, y } = transformGeoToMapLocal(
+ station.latitude,
+ station.longitude
+ );
+ const rotatedCoords = rotatePoint(
+ x,
+ y,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle
+ );
+ return {
+ ...station,
+ longitude: rotatedCoords.x,
+ latitude: rotatedCoords.y,
+ };
+ });
+ }, [
+ stationDataZh,
+ transformGeoToMapLocal,
+ rotationOriginX,
+ rotationOriginY,
+ rotationAngle,
+ ]);
+
+ if (
+ !routeData ||
+ !stationData ||
+ !stationDataEn ||
+ !stationDataZh ||
+ !sightData
+ ) {
+ return ;
+ }
+
+ if (true) {
+ return ;
+ }
+});
diff --git a/src/client/src/components/map/MapDataContext.tsx b/src/client/src/components/map/MapDataContext.tsx
new file mode 100644
index 0000000..d1196ba
--- /dev/null
+++ b/src/client/src/components/map/MapDataContext.tsx
@@ -0,0 +1,64 @@
+import { createContext, ReactNode, useContext, useState } from "react";
+import { RouteData, StationData, SightData } from "./types";
+
+const MapDataContext = createContext<{
+ routeData?: RouteData;
+ stationData?: StationData[];
+ stationDataEn?: StationData[];
+ stationDataZh?: StationData[];
+ sightData?: SightData[]; // <--- ИЗМЕНЕНО: теперь это массив SightData[]
+ setRouteData: React.Dispatch>;
+ setStationData: React.Dispatch<
+ React.SetStateAction
+ >;
+ setStationDataEn: React.Dispatch<
+ React.SetStateAction
+ >;
+ setStationDataZh: React.Dispatch<
+ React.SetStateAction
+ >;
+ setSightData: React.Dispatch>; // <--- ИЗМЕНЕНО: теперь это массив SightData[]
+}>({
+ routeData: undefined,
+ stationData: undefined,
+ stationDataEn: undefined,
+ sightData: undefined, // Инициализация sightData
+ setRouteData: () => {},
+ setStationData: () => {},
+ setStationDataEn: () => {},
+ setStationDataZh: () => {},
+ setSightData: () => {},
+});
+
+export const MapDataProvider = ({ children }: { children: ReactNode }) => {
+ const [routeData, setRouteData] = useState();
+ const [stationData, setStationData] = useState();
+ const [stationDataEn, setStationDataEn] = useState();
+ const [stationDataZh, setStationDataZh] = useState();
+ const [sightData, setSightData] = useState(); // <--- ИЗМЕНЕНО: теперь это массив SightData[]
+
+ const value = {
+ routeData,
+ stationData,
+ stationDataEn,
+ stationDataZh,
+ sightData,
+ setRouteData,
+ setStationData,
+ setStationDataEn,
+ setSightData,
+ setStationDataZh,
+ };
+
+ return (
+ {children}
+ );
+};
+
+export const useMapData = () => {
+ const context = useContext(MapDataContext);
+ if (!context) {
+ throw new Error("useMapData must be used within a MapDataProvider");
+ }
+ return context;
+};
diff --git a/src/client/src/components/map/Sight.tsx b/src/client/src/components/map/Sight.tsx
new file mode 100644
index 0000000..5db65c5
--- /dev/null
+++ b/src/client/src/components/map/Sight.tsx
@@ -0,0 +1,395 @@
+// SightsLayer.tsx
+import React from "react";
+import { Graphics, Assets, Texture, TextStyle } from "pixi.js";
+import { useCallback, useEffect, useState, useMemo } from "react";
+import { useTransform } from "./transformContext";
+import { SightData } from "./types";
+import sightIcon from "../../assets/images/sight.svg";
+import { useGeolocationStore } from "../../stores"; // Импортируем useGeolocationStore
+
+const BASE_ICON_SIZE = 30;
+const CLUSTER_RADIUS_BASE = 10;
+const CLUSTER_COLOR = 0x1a73e8;
+
+type Cluster = {
+ id: string;
+ longitude: number;
+ latitude: number;
+ count: number;
+ sights: SightData[];
+};
+
+type PointItem = { type: "point"; id: string; data: SightData };
+type ClusterItem = { type: "cluster"; id: string; data: Cluster };
+type ClusteredItem = PointItem | ClusterItem;
+
+const getDistance = (
+ p1: { longitude: number; latitude: number },
+ p2: { longitude: number; latitude: number }
+) => {
+ return Math.sqrt(
+ Math.pow(p1.longitude - p2.longitude, 2) +
+ Math.pow(p1.latitude - p2.latitude, 2)
+ );
+};
+
+const getDistanceFromPointToPath = (
+ point: { longitude: number; latitude: number },
+ pathPoints: { x: number; y: number }[]
+): number => {
+ if (!pathPoints || pathPoints.length < 2) {
+ return Infinity;
+ }
+
+ let minDistance = Infinity;
+
+ for (let i = 0; i < pathPoints.length - 1; i++) {
+ const p1 = pathPoints[i];
+ const p2 = pathPoints[i + 1];
+
+ const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
+ if (lineLengthSq === 0) continue;
+
+ const t =
+ ((point.longitude - p1.x) * (p2.x - p1.x) +
+ (point.latitude - p1.y) * (p2.y - p1.y)) /
+ lineLengthSq;
+ const projectionT = Math.max(0, Math.min(1, t));
+
+ const projectedX = p1.x + projectionT * (p2.x - p1.x);
+ const projectedY = p1.y + projectionT * (p2.y - p1.y);
+
+ const dist = Math.sqrt(
+ (point.longitude - projectedX) ** 2 + (point.latitude - projectedY) ** 2
+ );
+ minDistance = Math.min(minDistance, dist);
+ }
+ return minDistance;
+};
+
+const useSightClustering = (
+ sights: SightData[],
+ distanceThreshold: number,
+ pathPoints: { x: number; y: number }[]
+): ClusteredItem[] => {
+ return useMemo(() => {
+ const unclusteredSights: (SightData & { visited?: boolean })[] = sights.map(
+ (s) => ({ ...s })
+ );
+ const clusteredResult: ClusteredItem[] = [];
+
+ for (const sight of unclusteredSights) {
+ if (sight.visited) {
+ continue;
+ }
+
+ const clusterSights: SightData[] = [];
+ const queue = [sight];
+ sight.visited = true;
+
+ while (queue.length > 0 && clusterSights.length < 4) {
+ const current = queue.shift()!;
+ clusterSights.push(current);
+
+ for (const potentialNeighbor of unclusteredSights) {
+ if (
+ !potentialNeighbor.visited &&
+ clusterSights.length < 4 &&
+ getDistance(current, potentialNeighbor) < distanceThreshold
+ ) {
+ potentialNeighbor.visited = true;
+ queue.push(potentialNeighbor);
+ }
+ }
+ }
+
+ if (clusterSights.length > 1) {
+ let furthestSight: SightData | null = null;
+ let maxDistanceToPath = -1;
+
+ for (const s of clusterSights) {
+ const dist = getDistanceFromPointToPath(s, pathPoints);
+ if (dist > maxDistanceToPath) {
+ maxDistanceToPath = dist;
+ furthestSight = s;
+ }
+ }
+
+ const count = clusterSights.length;
+ const longitude = furthestSight
+ ? furthestSight.longitude
+ : clusterSights.reduce((sum, s) => sum + s.longitude, 0) / count;
+ const latitude = furthestSight
+ ? furthestSight.latitude
+ : clusterSights.reduce((sum, s) => sum + s.latitude, 0) / count;
+
+ const id = `cluster-${clusterSights[0].id}`;
+ clusteredResult.push({
+ type: "cluster",
+ id,
+ data: { id, count, longitude, latitude, sights: clusterSights },
+ });
+ } else {
+ const singleSight = clusterSights[0];
+ clusteredResult.push({
+ type: "point",
+ id: String(singleSight.id),
+ data: singleSight,
+ });
+ }
+ }
+ return clusteredResult;
+ }, [sights, distanceThreshold, pathPoints]);
+};
+
+// Изменяем пропсы для SingleSight, чтобы он мог передавать ID при тапе
+function SingleSight({
+ sight,
+ onSightClick,
+}: {
+ readonly sight: SightData;
+ onSightClick: (sightId: string) => void;
+}) {
+ const { scale } = useTransform();
+ const [texture, setTexture] = useState(Texture.EMPTY);
+ const store = useGeolocationStore();
+ const { setIsGovernorWidgetOpen } = store;
+ useEffect(() => {
+ Assets.load(sightIcon).then(setTexture).catch(console.error);
+ }, []);
+
+ const handlePointerTap = useCallback(() => {
+ setIsGovernorWidgetOpen(false);
+ onSightClick(String(sight.id)); // Передаем ID выбранной достопримечательности
+ }, [sight.id, onSightClick]);
+
+ if (texture === Texture.EMPTY) {
+ return null;
+ }
+
+ const dynamicSize = BASE_ICON_SIZE;
+
+ return (
+
+ );
+}
+
+// Добавляем onSightSelectedInCluster в пропсы
+function SightCluster({
+ cluster,
+ onClusterToggle,
+ isExpanded,
+ onSightSelectedInCluster,
+}: {
+ readonly cluster: Cluster;
+ onClusterToggle: (clusterId: string | null) => void;
+ isExpanded: boolean;
+ onSightSelectedInCluster: (sightId: string) => void;
+}) {
+ const store = useGeolocationStore();
+ const { setIsGovernorWidgetOpen } = store;
+ const { scale } = useTransform();
+ const radius = CLUSTER_RADIUS_BASE;
+ const [texture, setTexture] = useState(Texture.EMPTY);
+ const fontSize = 14;
+
+ useEffect(() => {
+ Assets.load(sightIcon).then(setTexture).catch(console.error);
+ }, []);
+
+ const clusterTextStyle = useMemo(
+ () =>
+ new TextStyle({
+ fill: "white",
+ fontSize: fontSize,
+ fontWeight: "bold",
+ }),
+ [fontSize]
+ );
+
+ const handleClusterTap = useCallback(() => {
+ onClusterToggle(isExpanded ? null : cluster.id);
+ }, [cluster.id, cluster.count, isExpanded, onClusterToggle]);
+
+ const handleSightSelect = useCallback(
+ (sightId: string) => {
+ setIsGovernorWidgetOpen(false);
+ onSightSelectedInCluster(sightId); // Передаем выбранный ID наверх
+ onClusterToggle(null); // Закрываем кластер после выбора достопримечательности
+ },
+ [onClusterToggle, onSightSelectedInCluster, setIsGovernorWidgetOpen]
+ ); // Добавляем onSightSelectedInCluster в зависимости
+
+ const drawClusterGraphics = useCallback(
+ (g: Graphics) => {
+ g.clear();
+ g.beginFill(0x896f58);
+ g.drawCircle(0, 0, radius);
+ g.endFill();
+ },
+ [radius]
+ );
+
+ const drawExpandedClusterBackground = useCallback((g: Graphics) => {
+ g.clear();
+ const expandedRadius = BASE_ICON_SIZE * 2;
+ g.beginFill(0x896f58, 0.3);
+ g.lineStyle(2, 0x896f58, 1);
+ g.drawCircle(0, 0, expandedRadius);
+ g.endFill();
+ }, []);
+
+ const handleBackgroundTap = useCallback(() => {
+ onClusterToggle(null); // Закрываем кластер при клике по фону
+ }, [cluster.id, onClusterToggle]);
+
+ if (texture === Texture.EMPTY) {
+ return null;
+ }
+
+ const dynamicSize = BASE_ICON_SIZE;
+ const offsetX = dynamicSize / 2;
+ const offsetY = -dynamicSize / 2;
+
+ const getPositionForSight = (index: number, total: number) => {
+ const angle = (index / total) * Math.PI * 2;
+ const distance = BASE_ICON_SIZE * 1.2;
+ return {
+ x: distance * Math.cos(angle),
+ y: distance * Math.sin(angle),
+ };
+ };
+
+ return (
+
+ {isExpanded && (
+
+ )}
+
+ {!isExpanded ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+ {cluster.sights.map((sight, index) => {
+ const pos = getPositionForSight(index, cluster.sights.length);
+ return (
+ handleSightSelect(String(sight.id))}
+ />
+ );
+ })}
+ >
+ )}
+
+ );
+}
+
+interface SightsLayerProps {
+ sights: SightData[];
+ pathPoints: { x: number; y: number }[];
+}
+
+export function SightsLayer({
+ sights,
+ pathPoints,
+}: Readonly) {
+ const { scale } = useTransform();
+ const distanceThreshold = BASE_ICON_SIZE * 3;
+
+ const store = useGeolocationStore(); // Получаем доступ к MobX хранилищу
+ const {
+ setSelectedSightId,
+ setIsManualSelection,
+ setIsRightWidgetSelectorOpen,
+ } = store; // Получаем нужные экшены
+
+ const items = useSightClustering(sights, distanceThreshold, pathPoints);
+
+ const [activeClusterId, setActiveClusterId] = useState(null);
+
+ const handleClusterToggle = useCallback((clusterId: string | null) => {
+ setActiveClusterId(clusterId);
+ }, []);
+
+ const handleSightSelected = useCallback(
+ (sightId: string) => {
+ setSelectedSightId(sightId);
+ setIsManualSelection(true);
+ setIsRightWidgetSelectorOpen(false); // Закрываем селектор правого виджета при клике по достопримечательности
+ // Закрываем виджет губернатора при выборе достопримечательности
+ store.closeGovernorModal();
+ },
+ [
+ setSelectedSightId,
+ setIsManualSelection,
+ setIsRightWidgetSelectorOpen,
+ store,
+ ]
+ );
+
+ return (
+ <>
+ {items.map((item) => {
+ if (item.type === "cluster") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/client/src/components/map/Station.tsx b/src/client/src/components/map/Station.tsx
new file mode 100644
index 0000000..cd5124a
--- /dev/null
+++ b/src/client/src/components/map/Station.tsx
@@ -0,0 +1,174 @@
+import { Graphics } from "pixi.js";
+import { useCallback, useMemo } from "react";
+import {
+ BACKGROUND_COLOR,
+ PATH_COLOR,
+ STATION_RADIUS,
+ STATION_OUTLINE_WIDTH,
+ UNPASSED_STATION_COLOR,
+} from "./Constants";
+import { StationData } from "./types";
+import { useTransform } from "./transformContext";
+import { observer } from "mobx-react-lite";
+import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
+import { apiStore } from "../../api/ApiStore/store";
+
+interface StationProps {
+ station: StationData;
+ stationEn?: StationData | null;
+ stationZh?: StationData | null;
+ isPassed: boolean;
+}
+
+const BASE_FONT_SIZE = 16;
+const POINT_LABEL_FONT_SIZE = 13;
+const DEFAULT_LABEL_OFFSET_X = 25;
+const DEFAULT_LABEL_OFFSET_Y = 0;
+
+const getAnchorFromOffset = (
+ offsetX: number,
+ offsetY: number
+): { x: number; y: number } => {
+ if (offsetX === 0 && offsetY === 0) {
+ return { x: 0, y: 0.5 };
+ }
+
+ const length = Math.hypot(offsetX, offsetY);
+ const nx = offsetX / length;
+ const ny = offsetY / length;
+
+ return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
+};
+
+export const Station = observer(
+ ({ station, stationEn, stationZh, isPassed }: Readonly) => {
+ const { scale } = useTransform();
+ const { context } = apiStore;
+
+ const { selectedLanguage } = useGeolocationStore();
+
+ const ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION = 2; // Порог масштаба (в 2 раза)
+ const HIGH_RESOLUTION_VALUE = 4;
+ const LOW_RESOLUTION_VALUE = 2;
+
+ const dynamicResolution =
+ scale > ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION
+ ? HIGH_RESOLUTION_VALUE
+ : LOW_RESOLUTION_VALUE;
+
+ const draw = useCallback(
+ (g: Graphics) => {
+ g.clear();
+
+ // Проверяем, является ли станция начальной или конечной
+ const isTerminalStation =
+ context &&
+ (station.id.toString() === (context as any)?.startStopId ||
+ station.id.toString() === (context as any)?.endStopId);
+
+ // Определяем радиус станции
+ const stationRadius = isTerminalStation
+ ? STATION_RADIUS * 1.5
+ : STATION_RADIUS;
+
+ // Рисуем основной круг станции
+ g.circle(station.longitude, station.latitude, stationRadius);
+ g.fill({ color: isPassed ? PATH_COLOR : UNPASSED_STATION_COLOR });
+ g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
+
+ // Если это терминальная станция, рисуем дополнительный круг по центру
+ if (isTerminalStation) {
+ const centerCircleRadius = stationRadius * 0.5; // 50% от радиуса станции
+ g.circle(station.longitude, station.latitude, centerCircleRadius);
+ g.fill({ color: BACKGROUND_COLOR });
+ }
+ },
+ [station.latitude, station.longitude, station.id, context, isPassed]
+ );
+
+ const dynamicFontSize = BASE_FONT_SIZE;
+ const dynamicPointLabelFontSize = POINT_LABEL_FONT_SIZE;
+
+ // Определяем фактические смещения. Если station.offset_x и station.offset_y оба равны 0,
+ // используем дефолтный отступ для обеспечения видимости.
+ const labelOffsetX =
+ station.offset_x === 0 && station.offset_y === 0
+ ? DEFAULT_LABEL_OFFSET_X
+ : station.offset_x / 3;
+ const labelOffsetY =
+ station.offset_x === 0 && station.offset_y === 0
+ ? DEFAULT_LABEL_OFFSET_Y
+ : station.offset_y / 3;
+
+ // Вычисляем позицию текстового блока. Это прямые координаты карты.
+ const textBlockPositionX = station.longitude + labelOffsetX / 3;
+ const textBlockPositionY = station.latitude + labelOffsetY;
+
+ // Используем useMemo для запоминания dynamicAnchor на основе *фактически используемого* отступа.
+ const dynamicAnchor = useMemo(
+ () => getAnchorFromOffset(labelOffsetX, labelOffsetY),
+ [labelOffsetX, labelOffsetY]
+ );
+
+ return (
+
+
+
+
+
+ {(selectedLanguage === "en" || selectedLanguage === "ru") && (
+
+ )}
+
+ {selectedLanguage === "zh" && (
+
+ )}
+
+ );
+ }
+);
diff --git a/src/client/src/components/map/TramIcon.tsx b/src/client/src/components/map/TramIcon.tsx
new file mode 100644
index 0000000..ecbc5de
--- /dev/null
+++ b/src/client/src/components/map/TramIcon.tsx
@@ -0,0 +1,378 @@
+import React from "react";
+import { Texture, Assets, Graphics } from "pixi.js";
+import { useEffect, useState, useMemo, useRef } from "react";
+import { useTransform } from "./transformContext";
+import { lerp, lerpAngle } from "../../utils/animationUtils";
+
+const basePath = new URL(
+ "../../assets/tramPosition/Tram Base.svg",
+ import.meta.url
+).href;
+const tramPath = new URL("../../assets/tramPosition/Tram.svg", import.meta.url)
+ .href;
+
+// Константы анимации (как в HTML файле)
+const ANIMATION_DURATION = 1200; // 1.2 секунды
+const LERP_SPEED = 0.1; // Скорость интерполяции (10% каждый кадр)
+
+// Функция для проверки расстояния до ближайшей точки маршрута
+const getDistanceToPath = (
+ point: { x: number; y: number },
+ pathPoints: { x: number; y: number }[]
+) => {
+ if (!pathPoints || pathPoints.length < 2) return Infinity;
+
+ let minDistance = Infinity;
+
+ for (let i = 0; i < pathPoints.length - 1; i++) {
+ const p1 = pathPoints[i];
+ const p2 = pathPoints[i + 1];
+
+ const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
+ if (lineLengthSq === 0) continue;
+
+ const t =
+ ((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
+ lineLengthSq;
+ const projectionT = Math.max(0, Math.min(1, t));
+
+ const projectedX = p1.x + projectionT * (p2.x - p1.x);
+ const projectedY = p1.y + projectionT * (p2.y - p1.y);
+
+ const dist = Math.sqrt(
+ (point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
+ );
+ if (dist < minDistance) {
+ minDistance = dist;
+ }
+ }
+
+ return minDistance;
+};
+
+// Функция для проверки расстояния до пройденной части маршрута
+const getDistanceToPassedPath = (
+ point: { x: number; y: number },
+ pathPoints: { x: number; y: number }[],
+ passedSegmentIndex: number
+) => {
+ if (!pathPoints || pathPoints.length < 2 || passedSegmentIndex < 0)
+ return Infinity;
+
+ let minDistance = Infinity;
+
+ // Проверяем только пройденную часть (до passedSegmentIndex включительно)
+ for (
+ let i = 0;
+ i <= Math.min(passedSegmentIndex, pathPoints.length - 2);
+ i++
+ ) {
+ const p1 = pathPoints[i];
+ const p2 = pathPoints[i + 1];
+
+ const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
+ if (lineLengthSq === 0) continue;
+
+ const t =
+ ((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
+ lineLengthSq;
+ const projectionT = Math.max(0, Math.min(1, t));
+
+ const projectedX = p1.x + projectionT * (p2.x - p1.x);
+ const projectedY = p1.y + projectionT * (p2.y - p1.y);
+
+ const dist = Math.sqrt(
+ (point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
+ );
+ if (dist < minDistance) {
+ minDistance = dist;
+ }
+ }
+
+ return minDistance;
+};
+
+// Функция для проверки расстояния до ближайшей станции
+const getDistanceToStations = (
+ point: { x: number; y: number },
+ stations: {
+ longitude: number;
+ latitude: number;
+ offset_x?: number;
+ offset_y?: number;
+ }[],
+ debug: boolean = false
+) => {
+ if (!stations || stations.length === 0) {
+ return Infinity;
+ }
+
+ let minDistance = Infinity;
+
+ for (const station of stations) {
+ // Рассчитываем позицию текста станции с учетом смещений (как в Station.tsx)
+ const DEFAULT_LABEL_OFFSET_X = 25;
+ const DEFAULT_LABEL_OFFSET_Y = 0;
+
+ const labelOffsetX =
+ station.offset_x === 0 && station.offset_y === 0
+ ? DEFAULT_LABEL_OFFSET_X
+ : (station.offset_x || 0) / 3;
+ const labelOffsetY =
+ station.offset_x === 0 && station.offset_y === 0
+ ? DEFAULT_LABEL_OFFSET_Y
+ : (station.offset_y || 0) / 3;
+
+ const textBlockPositionX = station.longitude + labelOffsetX;
+ const textBlockPositionY = station.latitude + labelOffsetY;
+
+ // Вычисляем расстояние до позиции текста станции (не до центра станции)
+ const dist = Math.sqrt(
+ (point.x - textBlockPositionX) ** 2 + (point.y - textBlockPositionY) ** 2
+ );
+
+ if (dist < minDistance) {
+ minDistance = dist;
+ }
+ }
+
+ return minDistance;
+};
+
+// Функция для поиска оптимального угла поворота метки относительно трамвая
+const findOptimalAngle = (
+ tramX: number,
+ tramY: number,
+ pathPoints: { x: number; y: number }[],
+ stations: {
+ longitude: number;
+ latitude: number;
+ offset_x?: number;
+ offset_y?: number;
+ }[],
+ passedSegmentIndex: number,
+ scale: number
+) => {
+ const testRadiusInMapCoords = 100 / scale; // Радиус в координатах карты
+ const minSafeDistanceToPath = 60; // Минимальное безопасное расстояние до пути в пикселях
+ const minSafeDistanceToPassedPath = 60; // Минимальное безопасное расстояние до пройденной части в пикселях
+ const minSafeDistanceToStation = 50; // Минимальное безопасное расстояние до станции в пикселях
+
+ let bestAngle = 0;
+ let bestScore = Infinity; // Ищем минимальный вес
+ const segmentWeights: { angle: number; weight: number; distances: any }[] =
+ [];
+
+ // Проверяем 12 углов (каждые 30 градусов)
+ for (let i = 0; i < 12; i++) {
+ const testAngle = (i * Math.PI * 2) / 12;
+ const testX = tramX + Math.cos(testAngle) * testRadiusInMapCoords;
+ const testY = tramY + Math.sin(testAngle) * testRadiusInMapCoords;
+
+ const distanceToPath =
+ getDistanceToPath({ x: testX, y: testY }, pathPoints) * scale; // В пикселях
+ const distanceToPassedPath =
+ getDistanceToPassedPath(
+ { x: testX, y: testY },
+ pathPoints,
+ passedSegmentIndex
+ ) * scale; // В пикселях
+ const distanceToStation =
+ getDistanceToStations({ x: testX, y: testY }, stations, false) * scale; // В пикселях с отладкой
+
+ // Вычисляем вес для этого угла (чем меньше расстояние, тем больше вес)
+ let weight = 0;
+
+ // Путь - вес 100
+ if (distanceToPath < minSafeDistanceToPath) {
+ weight += 100 * (1 - distanceToPath / minSafeDistanceToPath);
+ }
+
+ // Текст прошедшей станции - вес 10
+ if (distanceToPassedPath < minSafeDistanceToPassedPath) {
+ weight += 10 * (1 - distanceToPassedPath / minSafeDistanceToPassedPath);
+ }
+
+ // Текст следующей станции - вес 1000
+ if (distanceToStation < minSafeDistanceToStation) {
+ weight += 1000 * (1 - distanceToStation / minSafeDistanceToStation);
+ }
+
+ segmentWeights.push({
+ angle: testAngle,
+ weight: Math.round(weight * 10) / 10, // Округляем для читаемости
+ distances: {
+ path: Math.round(distanceToPath),
+ passedPath: Math.round(distanceToPassedPath),
+ station: Math.round(distanceToStation),
+ },
+ });
+
+ if (weight < bestScore) {
+ bestScore = weight;
+ bestAngle = testAngle;
+ }
+ }
+
+ // Если несколько сегментов имеют одинаковый минимальный вес,
+ // выбираем тот, который максимально удален от препятствий
+ const minWeightSegments = segmentWeights.filter(
+ (s) => s.weight === bestScore
+ );
+ if (minWeightSegments.length > 1) {
+ // Из сегментов с минимальным весом выбираем тот, который максимально удален от препятствий
+ let bestDistanceSum = -1;
+ for (const segment of minWeightSegments) {
+ const distanceSum =
+ segment.distances.path +
+ segment.distances.passedPath +
+ segment.distances.station;
+ if (distanceSum > bestDistanceSum) {
+ bestDistanceSum = distanceSum;
+ bestAngle = segment.angle;
+ }
+ }
+ }
+
+ return { bestAngle, segmentWeights };
+};
+
+export function TramIcon({
+ x,
+ y,
+ angle,
+ pathPoints = [],
+ stations = [],
+ passedSegmentIndex = -1,
+}: {
+ x: number;
+ y: number;
+ angle: number;
+ pathPoints?: { x: number; y: number }[];
+ stations?: {
+ longitude: number;
+ latitude: number;
+ offset_x?: number;
+ offset_y?: number;
+ }[];
+ passedSegmentIndex?: number;
+}) {
+ const { scale } = useTransform();
+
+ // Находим оптимальный угол поворота метки относительно трамвая
+ const optimalAngle = useMemo(
+ () =>
+ findOptimalAngle(x, y, pathPoints, stations, passedSegmentIndex, scale)
+ .bestAngle,
+ [x, y, pathPoints, stations, passedSegmentIndex, scale]
+ );
+
+ // Состояние для плавной анимации позиции и углов (как в HTML файле)
+ const [smoothPosition, setSmoothPosition] = useState({ x, y });
+ const [smoothOptimalAngle, setSmoothOptimalAngle] = useState(optimalAngle);
+ const [smoothTramAngle, setSmoothTramAngle] = useState(angle);
+ const animationRef = useRef(undefined);
+
+ // Плавная анимация позиции и углов (логика из HTML файла)
+ useEffect(() => {
+ const animate = () => {
+ // Анимируем позицию (как в HTML файле)
+ setSmoothPosition((prev) => {
+ const newX = lerp(prev.x, x, LERP_SPEED);
+ const newY = lerp(prev.y, y, LERP_SPEED);
+ return {
+ x: Math.abs(newX - x) < 0.1 ? x : newX,
+ y: Math.abs(newY - y) < 0.1 ? y : newY,
+ };
+ });
+
+ // Анимируем оптимальный угол
+ setSmoothOptimalAngle((prev) => {
+ const newAngle = lerpAngle(prev, optimalAngle, LERP_SPEED);
+ return Math.abs(newAngle - optimalAngle) < 0.01
+ ? optimalAngle
+ : newAngle;
+ });
+
+ // Анимируем угол трамвая
+ setSmoothTramAngle((prev) => {
+ const newAngle = lerpAngle(prev, angle, LERP_SPEED);
+ return Math.abs(newAngle - angle) < 0.01 ? angle : newAngle;
+ });
+
+ // Продолжаем анимацию, если что-то еще не достигло цели
+ if (
+ Math.abs(smoothPosition.x - x) > 0.1 ||
+ Math.abs(smoothPosition.y - y) > 0.1 ||
+ Math.abs(smoothOptimalAngle - optimalAngle) > 0.01 ||
+ Math.abs(smoothTramAngle - angle) > 0.01
+ ) {
+ animationRef.current = requestAnimationFrame(animate);
+ }
+ };
+
+ animationRef.current = requestAnimationFrame(animate);
+
+ return () => {
+ if (animationRef.current !== undefined) {
+ cancelAnimationFrame(animationRef.current);
+ }
+ };
+ }, [
+ x,
+ y,
+ optimalAngle,
+ angle,
+ smoothPosition,
+ smoothOptimalAngle,
+ smoothTramAngle,
+ ]);
+
+ // Обычные размеры без увеличения
+ const backgroundWidth = 111 / scale;
+ const backgroundHeight = 82 / scale;
+ const tramWidth = 31 / scale;
+ const tramHeight = 52 / scale;
+
+ const [baseTexture, setBaseTexture] = useState(null);
+ const [tramTexture, setTramTexture] = useState(null);
+
+ useEffect(() => {
+ Assets.load(basePath).then(setBaseTexture).catch(console.error);
+ Assets.load(tramPath).then(setTramTexture).catch(console.error);
+ }, []);
+
+ if (!baseTexture || !tramTexture) return null;
+
+ return (
+
+ {/* вращающийся контейнер с плавным оптимальным углом */}
+
+ 53.7 ? backgroundWidth : 53.7}
+ height={backgroundHeight > 39.68 ? backgroundHeight : 39.68}
+ />
+
+ {/* контейнер иконки трамвая с плавным поворотом направления движения */}
+ 53.7
+ ? -backgroundWidth / 1.42
+ : -backgroundWidth / 0.98
+ }
+ y={0}
+ >
+
+
+
+
+ );
+}
diff --git a/src/client/src/components/map/TramIconWebGL.tsx b/src/client/src/components/map/TramIconWebGL.tsx
new file mode 100644
index 0000000..dc2a5f9
--- /dev/null
+++ b/src/client/src/components/map/TramIconWebGL.tsx
@@ -0,0 +1,123 @@
+import React, { useEffect, useState, useRef } from "react";
+import { lerp, lerpAngle } from "../../utils/animationUtils";
+import tramBase from "../../assets/tramPosition/Tram Base.svg";
+import tramSvg from "../../assets/tramPosition/Tram_Second.svg";
+import { getMediaUrl } from "../../api/apiConfig";
+
+const LERP_SPEED = 0.1;
+
+interface TramIconWebGLProps {
+ x: number;
+ y: number;
+ optimalAngle: number;
+ scale: number;
+ routeIcon?: string | null;
+}
+
+export const TramIconWebGL: React.FC = ({
+ x,
+ y,
+ optimalAngle,
+ scale,
+ routeIcon,
+}) => {
+ const tramIconSrc = routeIcon
+ ? getMediaUrl(routeIcon)
+ : tramSvg;
+ const [smoothOptimalAngle, setSmoothOptimalAngle] = useState(optimalAngle);
+ const animationRef = useRef(undefined);
+
+ useEffect(() => {
+ const animate = () => {
+ setSmoothOptimalAngle((prev) => {
+ const newAngle = lerpAngle(prev, optimalAngle, LERP_SPEED);
+ return Math.abs(newAngle - optimalAngle) < 0.01
+ ? optimalAngle
+ : newAngle;
+ });
+
+ if (Math.abs(smoothOptimalAngle - optimalAngle) > 0.01) {
+ animationRef.current = requestAnimationFrame(animate);
+ }
+ };
+
+ animationRef.current = requestAnimationFrame(animate);
+
+ return () => {
+ if (animationRef.current !== undefined) {
+ cancelAnimationFrame(animationRef.current);
+ }
+ };
+ }, [optimalAngle, smoothOptimalAngle]);
+
+ const backgroundWidth = 111 / (scale * 12);
+ const backgroundHeight = 82 / (scale * 12);
+ const tramWidth = 31 / (scale * 12);
+ const tramHeight = 52 / (scale * 12);
+
+ const outerRotation = ((smoothOptimalAngle + Math.PI) * 180) / Math.PI;
+ const innerRotation = ((-smoothOptimalAngle - Math.PI) * 180) / Math.PI;
+
+ const bgWidth = Math.max(backgroundWidth, 100);
+ const bgHeight = Math.max(backgroundHeight, 100);
+
+ const minTramWidth = Math.max(tramWidth, 31);
+ const minTramHeight = Math.max(tramHeight, 52);
+
+ return (
+
+
+

+
+
53.7 ? -bgWidth / 1.42 : -bgWidth / 0.98,
+ top: 0,
+ transform: `translate(-50%, -50%) rotate(${innerRotation}deg)`,
+ width: minTramWidth,
+ height: minTramHeight,
+ }}
+ >
+

+
+
+
+ );
+};
diff --git a/src/client/src/components/map/TravelPath.tsx b/src/client/src/components/map/TravelPath.tsx
new file mode 100644
index 0000000..4637b14
--- /dev/null
+++ b/src/client/src/components/map/TravelPath.tsx
@@ -0,0 +1,92 @@
+import { Graphics } from "pixi.js";
+import { useCallback } from "react";
+import { PATH_COLOR, PATH_WIDTH, UNPASSED_STATION_COLOR } from "./Constants";
+
+interface TravelPathProps {
+ points: { x: number; y: number }[];
+ busCoordinates?: { x: number; y: number };
+}
+
+const PASSED_PATH_COLOR = PATH_COLOR;
+const UNPASSED_PATH_COLOR = UNPASSED_STATION_COLOR;
+
+export function TravelPath({
+ points,
+ busCoordinates,
+}: Readonly) {
+ const draw = useCallback(
+ (g: Graphics) => {
+ g.clear();
+
+ if (points.length < 2) {
+ return;
+ }
+
+ g.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length; i++) {
+ g.lineTo(points[i].x, points[i].y);
+ }
+ g.stroke({
+ color: UNPASSED_PATH_COLOR,
+ width: PATH_WIDTH,
+ });
+
+ if (!busCoordinates) {
+ return;
+ }
+
+ let minDistance = Infinity;
+ let closestSegmentIndex = -1;
+ let busSegmentStartPoint: { x: number; y: number } | null = null;
+
+ for (let i = 0; i < points.length - 1; i++) {
+ const p1 = points[i];
+ const p2 = points[i + 1];
+
+ const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
+ if (lineLengthSq === 0) continue;
+
+ const t =
+ ((busCoordinates.x - p1.x) * (p2.x - p1.x) +
+ (busCoordinates.y - p1.y) * (p2.y - p1.y)) /
+ lineLengthSq;
+ const projectionT = Math.max(0, Math.min(1, t));
+
+ const projectedX = p1.x + projectionT * (p2.x - p1.x);
+ const projectedY = p1.y + projectionT * (p2.y - p1.y);
+
+ const dist = Math.sqrt(
+ (busCoordinates.x - projectedX) ** 2 +
+ (busCoordinates.y - projectedY) ** 2
+ );
+
+ if (dist < minDistance) {
+ minDistance = dist;
+ closestSegmentIndex = i;
+ busSegmentStartPoint = { x: projectedX, y: projectedY };
+ }
+ }
+
+ if (closestSegmentIndex !== -1 && busSegmentStartPoint) {
+ g.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i <= closestSegmentIndex; i++) {
+ g.lineTo(points[i].x, points[i].y);
+ }
+ g.lineTo(busSegmentStartPoint.x, busSegmentStartPoint.y);
+
+ g.stroke({
+ color: PASSED_PATH_COLOR,
+ width: PATH_WIDTH,
+ });
+ }
+ },
+ [points, busCoordinates]
+ );
+
+ if (points.length === 0) {
+ console.error("points is empty");
+ return null;
+ }
+
+ return ;
+}
diff --git a/src/client/src/components/map/WebGLMap.tsx b/src/client/src/components/map/WebGLMap.tsx
new file mode 100644
index 0000000..9419098
--- /dev/null
+++ b/src/client/src/components/map/WebGLMap.tsx
@@ -0,0 +1,3137 @@
+import React, {
+ useEffect,
+ useMemo,
+ useRef,
+ useCallback,
+ useState,
+} from "react";
+import { observer } from "mobx-react-lite";
+import { useMapData } from "./MapDataContext";
+import { SightData } from "./types";
+import { useTransform } from "./transformContext";
+import { coordinatesToLocal } from "./utils";
+import {
+ UP_SCALE,
+ PATH_COLOR,
+ BACKGROUND_COLOR,
+ UNPASSED_STATION_COLOR,
+ BUS_COLOR,
+ BASE_ICON_SIZE,
+ CLUSTER_RADIUS_BASE,
+ CLUSTER_COLOR,
+ ACTIVE_STATION_COLOR,
+} from "./Constants";
+import { SCALE_FACTOR } from "../../assets/Constants";
+import { apiStore } from "../../api/ApiStore/store";
+import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
+import { useRouteFollowingPosition } from "../../hooks/useRouteFollowingPosition";
+import { useCameraAnimationStore } from "../../stores";
+import { TramIconWebGL } from "./TramIconWebGL";
+import { OverlayScrollbarsWrapper } from "../OverlayScrollbarsWrapper";
+import { apiBaseURL } from "../../api/apiConfig";
+import "../../styles/OverlayScrollbars.css";
+const SIGHT_ICON_URL = new URL("../../assets/images/sight.svg", import.meta.url)
+ .href;
+const SIGHT_ICON_BASE_SIZE = 30;
+const CUSTOM_SIGHT_ICON_BASE_SCALE = 0.1;
+const DEBUG_WEBGL_ROUTE_MAP = true;
+const YELLOW_ICON_FILTER =
+ "brightness(0) saturate(100%) invert(84%) sepia(99%) saturate(3457%) hue-rotate(358deg) brightness(104%) contrast(99%)";
+const clamp = (value: number, min: number, max: number) =>
+ Math.min(max, Math.max(min, value));
+
+const debugWebglLog = (...args: unknown[]) => {
+ if (!DEBUG_WEBGL_ROUTE_MAP) return;
+};
+
+const isMediaIdEmpty = (id: string | null) => {
+ if (id == null || id === "") return true;
+ const digits = id.replace(/-/g, "");
+ return digits === "" || /^0+$/.test(digits);
+};
+
+const appendTokenToUrl = (url: string, token: string): string => {
+ if (!token) return url;
+ if (/([?&])token=/.test(url)) return url;
+ const separator = url.includes("?") ? "&" : "?";
+ return `${url}${separator}token=${encodeURIComponent(token)}`;
+};
+
+const buildMediaDownloadUrl = (
+ baseUrl: string,
+ mediaId: string,
+ token: string,
+): string => {
+ if (/^https?:\/\//i.test(mediaId)) {
+ return appendTokenToUrl(mediaId, token);
+ }
+
+ const normalizedBase = baseUrl.replace(/\/+$/, "");
+ const normalizedMediaId = mediaId.replace(/^\/+/, "");
+
+ if (/^media\/.+\/download$/i.test(normalizedMediaId)) {
+ return appendTokenToUrl(`${normalizedBase}/${normalizedMediaId}`, token);
+ }
+
+ if (/^media$/i.test(normalizedBase.split("/").pop() ?? "")) {
+ return appendTokenToUrl(
+ `${normalizedBase}/${normalizedMediaId}/download`,
+ token,
+ );
+ }
+
+ return appendTokenToUrl(
+ `${normalizedBase}/media/${normalizedMediaId}/download`,
+ token,
+ );
+};
+
+type Cluster = {
+ id: string;
+ longitude: number;
+ latitude: number;
+ count: number;
+ sights: SightData[];
+};
+
+type PointItem = { type: "point"; id: string; data: SightData };
+type ClusterItem = { type: "cluster"; id: string; data: Cluster };
+type ClusteredItem = PointItem | ClusterItem;
+
+const getDistance = (
+ p1: { longitude: number; latitude: number },
+ p2: { longitude: number; latitude: number },
+) => {
+ return Math.sqrt(
+ Math.pow(p1.longitude - p2.longitude, 2) +
+ Math.pow(p1.latitude - p2.latitude, 2),
+ );
+};
+
+const getDistanceFromPointToPath = (
+ point: { longitude: number; latitude: number },
+ pathPoints: { x: number; y: number }[],
+): number => {
+ if (!pathPoints || pathPoints.length < 2) {
+ return Infinity;
+ }
+
+ let minDistance = Infinity;
+
+ for (let i = 0; i < pathPoints.length - 1; i++) {
+ const p1 = pathPoints[i];
+ const p2 = pathPoints[i + 1];
+
+ const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
+ if (lineLengthSq === 0) continue;
+
+ const t =
+ ((point.longitude - p1.x) * (p2.x - p1.x) +
+ (point.latitude - p1.y) * (p2.y - p1.y)) /
+ lineLengthSq;
+ const projectionT = Math.max(0, Math.min(1, t));
+
+ const projectedX = p1.x + projectionT * (p2.x - p1.x);
+ const projectedY = p1.y + projectionT * (p2.y - p1.y);
+
+ const dist = Math.sqrt(
+ (point.longitude - projectedX) ** 2 + (point.latitude - projectedY) ** 2,
+ );
+ minDistance = Math.min(minDistance, dist);
+ }
+ return minDistance;
+};
+
+const useSightClustering = (
+ sights: SightData[],
+ distanceThreshold: number,
+ pathPoints: { x: number; y: number }[],
+): ClusteredItem[] => {
+ return useMemo(() => {
+ const unclusteredSights: (SightData & { visited?: boolean })[] = sights.map(
+ (s) => ({ ...s }),
+ );
+ const clusteredResult: ClusteredItem[] = [];
+
+ for (const sight of unclusteredSights) {
+ if (sight.visited) {
+ continue;
+ }
+
+ const clusterSights: SightData[] = [];
+ const queue = [sight];
+ sight.visited = true;
+
+ while (queue.length > 0 && clusterSights.length < 4) {
+ const current = queue.shift()!;
+ clusterSights.push(current);
+
+ for (const potentialNeighbor of unclusteredSights) {
+ if (
+ !potentialNeighbor.visited &&
+ clusterSights.length < 4 &&
+ getDistance(current, potentialNeighbor) < distanceThreshold
+ ) {
+ potentialNeighbor.visited = true;
+ queue.push(potentialNeighbor);
+ }
+ }
+ }
+
+ if (clusterSights.length > 1) {
+ let furthestSight: SightData | null = null;
+ let maxDistanceToPath = -1;
+
+ for (const s of clusterSights) {
+ const dist = getDistanceFromPointToPath(s, pathPoints);
+ if (dist > maxDistanceToPath) {
+ maxDistanceToPath = dist;
+ furthestSight = s;
+ }
+ }
+
+ const count = clusterSights.length;
+ const longitude = furthestSight
+ ? furthestSight.longitude
+ : clusterSights.reduce((sum, s) => sum + s.longitude, 0) / count;
+ const latitude = furthestSight
+ ? furthestSight.latitude
+ : clusterSights.reduce((sum, s) => sum + s.latitude, 0) / count;
+
+ const id = `cluster-${clusterSights[0].id}`;
+ clusteredResult.push({
+ type: "cluster",
+ id,
+ data: { id, count, longitude, latitude, sights: clusterSights },
+ });
+ } else {
+ const singleSight = clusterSights[0];
+ clusteredResult.push({
+ type: "point",
+ id: String(singleSight.id),
+ data: singleSight,
+ });
+ }
+ }
+ return clusteredResult;
+ }, [sights, distanceThreshold, pathPoints]);
+};
+
+const getAnchorFromOffset = (align: number): { x: number; y: number } => {
+ let anchorX: number;
+ if (align === 1) {
+ anchorX = 0;
+ } else if (align === 3) {
+ anchorX = 1;
+ } else {
+ anchorX = 0.5;
+ }
+
+ const anchorY = 0.5;
+
+ return { x: anchorX, y: anchorY };
+};
+
+function initWebGLContext(
+ canvas: HTMLCanvasElement,
+): WebGLRenderingContext | null {
+ const gl =
+ (canvas.getContext("webgl") as WebGLRenderingContext | null) ||
+ (canvas.getContext("experimental-webgl") as WebGLRenderingContext | null);
+ return gl;
+}
+
+function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
+ const displayWidth = Math.floor(canvas.clientWidth * dpr);
+ const displayHeight = Math.floor(canvas.clientHeight * dpr);
+ if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
+ canvas.width = displayWidth;
+ canvas.height = displayHeight;
+ return true;
+ }
+ return false;
+}
+
+export const WebGLMap = observer(() => {
+ const canvasRef = useRef(null);
+ const glRef = useRef(null);
+ const programRef = useRef(null);
+ const bufferRef = useRef(null);
+ const pointProgramRef = useRef(null);
+ const pointBufferRef = useRef(null);
+ const screenLineProgramRef = useRef(null);
+ const screenLineBufferRef = useRef(null);
+ const attribsRef = useRef<{ a_pos: number } | null>(null);
+ const uniformsRef = useRef<{
+ u_cameraPos: WebGLUniformLocation | null;
+ u_scale: WebGLUniformLocation | null;
+ u_resolution: WebGLUniformLocation | null;
+ u_color: WebGLUniformLocation | null;
+ } | null>(null);
+ const customSightIconBaseScaleRef = useRef(null);
+ const liveSightIconSizesRef = useRef