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 +

{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 ( +
+
+ + + + + + + + + + +
+ +
+ {getTitle()} +
+
+ ); +}; + +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 {title}; + }; + + 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 watermark; +}; + +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 ? ( +
+ Metro Red Icon + {currentTransferData.metro_red} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.metro_green?.length > 0 ? ( +
+ Metro Green Icon + {currentTransferData.metro_green} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.metro_blue?.length > 0 ? ( +
+ Metro Blue Icon + {currentTransferData.metro_blue} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.metro_orange?.length > 0 ? ( +
+ Metro Orange Icon + {currentTransferData.metro_orange} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.metro_purple?.length > 0 ? ( +
+ Metro Purple Icon + {currentTransferData.metro_purple} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.tram?.length > 0 ? ( +
+ Tram Icon + {currentTransferData.tram} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.trolleybus?.length > 0 ? ( +
+ Trolleybus Icon + {currentTransferData.trolleybus} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.bus?.length > 0 ? ( +
+ Bus Icon + {currentTransferData.bus} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.train?.length > 0 ? ( +
+ Train Icon + {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 ( +
+ Weather icon +
+ {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 condition +
+ {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 ( +
+
+ Tram Base + +
53.7 ? -bgWidth / 1.42 : -bgWidth / 0.98, + top: 0, + transform: `translate(-50%, -50%) rotate(${innerRotation}deg)`, + width: minTramWidth, + height: minTramHeight, + }} + > + Tram +
+
+
+ ); +}; 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>(new Map()); + const lastCameraLogScaleRef = useRef(null); + const lastDrawLogSignatureRef = useRef(null); + const [, forceSightIconScaleRender] = useState(0); + + const { routeData, stationData, stationDataEn, stationDataZh, sightData } = + useMapData() as any; + const { + position, + scale, + setPosition, + setScale, + isAutoMode, + setIsAutoMode, + screenCenter, + setScreenCenter, + userActivityTimestamp, + updateUserActivity, + } = useTransform(); + + const cameraAnimationStore = useCameraAnimationStore(); + + const scaleLimitsRef = useRef({ + min: null as number | null, + max: null as number | null, + }); + + const routeBoundsRef = useRef<{ + minX: number; + maxX: number; + minY: number; + maxY: number; + } | null>(null); + + const store = useGeolocationStore(); + const { + selectedSightId, + setSelectedSightId, + setIsManualSelection, + setIsRightWidgetSelectorOpen, + setIsGovernorWidgetOpen, + setIsLeftWidgetOpen, + activeClusterId, + setActiveClusterId, + nearestStationId, + setNearestStationId, + setIsTransferWidgetOpen, + selectedLanguage, + currentStationId, + } = store; + + const { + routeSights, + routeSightsEn, + routeSightsZh, + sightArticles, + sightArticlesEn, + sightArticlesZh, + orderedRouteStations, + route: originalRouteData, + } = apiStore; + + const mediaBaseUrl = apiBaseURL; + const mediaToken = localStorage.getItem("token") || ""; + + const clusterAutoCloseTimerRef = useRef | null>( + null, + ); + const hasInitializedCameraRef = useRef(false); + + useEffect(() => { + if ( + routeData?.scale_min !== undefined && + routeData?.scale_max !== undefined + ) { + scaleLimitsRef.current = { + min: routeData.scale_min / SCALE_FACTOR, + max: routeData.scale_max / SCALE_FACTOR, + }; + } + }, [routeData?.scale_min, routeData?.scale_max]); + + useEffect(() => { + customSightIconBaseScaleRef.current = null; + liveSightIconSizesRef.current.clear(); + }, [routeData?.id]); + + useEffect(() => { + if (!hasInitializedCameraRef.current) { + return; + } + + if ( + customSightIconBaseScaleRef.current == null && + Number.isFinite(scale) && + scale > 0 + ) { + customSightIconBaseScaleRef.current = CUSTOM_SIGHT_ICON_BASE_SCALE; + debugWebglLog("custom sight base scale initialized", { + baseScale: CUSTOM_SIGHT_ICON_BASE_SCALE, + currentScale: scale, + }); + forceSightIconScaleRender((version) => version + 1); + } + }, [scale, routeData?.id, routeData?.scale_min, forceSightIconScaleRender]); + + const resolveSightIconSizePercent = useCallback( + (sight?: SightData) => { + const livePercent = + sight != null ? liveSightIconSizesRef.current.get(sight.id) : undefined; + if (typeof livePercent === "number" && Number.isFinite(livePercent)) { + return livePercent; + } + + if ( + sight != null && + typeof sight.icon_size === "number" && + Number.isFinite(sight.icon_size) + ) { + return sight.icon_size; + } + + if ( + typeof routeData?.icon_size === "number" && + Number.isFinite(routeData.icon_size) + ) { + return routeData.icon_size; + } + + if ( + typeof originalRouteData?.icon_size === "number" && + Number.isFinite(originalRouteData.icon_size) + ) { + return originalRouteData.icon_size; + } + + return 100; + }, + [routeData?.icon_size, originalRouteData?.icon_size], + ); + + const clampScale = useCallback((value: number) => { + const { min, max } = scaleLimitsRef.current; + + if (min === null || max === null) { + return value; + } + + const clampedValue = Math.max(min, Math.min(max, value)); + + return clampedValue; + }, []); + + const clampPosition = useCallback( + (pos: { x: number; y: number }, currentScale: number) => { + return pos; + }, + [], + ); + const positionRef = useRef(position); + const scaleRef = useRef(scale); + const setPositionRef = useRef(setPosition); + const setScaleRef = useRef(setScale); + + useEffect(() => { + setPositionRef.current = setPosition; + }, [setPosition]); + + useEffect(() => { + setScaleRef.current = setScale; + }, [setScale]); + + useEffect(() => { + if (routeData) { + } + }, [routeData]); + + useEffect(() => { + positionRef.current = position; + }, [position]); + + useEffect(() => { + scaleRef.current = scale; + }, [scale]); + + useEffect(() => { + if ( + lastCameraLogScaleRef.current != null && + Math.abs(lastCameraLogScaleRef.current - scale) < 1e-4 + ) { + return; + } + + lastCameraLogScaleRef.current = scale; + debugWebglLog("camera state", { + scale, + position, + scaleLimits: scaleLimitsRef.current, + }); + }, [scale, position.x, position.y]); + + const rotationAngle = useMemo(() => { + const deg = (routeData as any)?.rotate ?? 0; + return (deg * Math.PI) / 180; + }, [routeData]); + + const { + position: animatedYellowDotPosition, + animateTo: animateYellowDotTo, + setPositionImmediate: setYellowDotImmediate, + setRoutePath: setAnimatorRoutePath, + getCurrentSegIndex, + } = useRouteFollowingPosition(800); + + const routePath = useMemo(() => { + if (!routeData?.path || routeData?.path.length === 0) + return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + + const verts: number[] = []; + for (const [lat, lon] of routeData.path) { + const local = coordinatesToLocal(lat - centerLat, lon - centerLon); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + routeData?.path, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + const transformedTramCoords = useMemo(() => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat === undefined || centerLon === undefined) return null; + + const coords: any = apiStore?.context?.currentCoordinates; + if (!coords) return null; + + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + return { x: rx, y: ry }; + }, [ + routeData?.center_latitude, + routeData?.center_longitude, + apiStore?.context?.currentCoordinates, + rotationAngle, + ]); + + const transformedTramCoordsRef = useRef<{ + x: number; + y: number; + } | null>(null); + transformedTramCoordsRef.current = transformedTramCoords; + + useEffect(() => { + if (routePath.length >= 4) { + const t = transformedTramCoordsRef.current; + setAnimatorRoutePath(routePath, t ? { x: t.x, y: t.y } : undefined); + } + }, [routePath, setAnimatorRoutePath]); + + useEffect(() => { + const callback = (newPos: { x: number; y: number }, newZoom: number) => { + setPosition(newPos); + setScale(newZoom); + }; + + cameraAnimationStore.setUpdateCallback(callback); + + return () => { + cameraAnimationStore.setUpdateCallback(null); + }; + }, []); + + useEffect(() => { + if ( + routeData?.scale_min !== undefined && + routeData?.scale_max !== undefined + ) { + cameraAnimationStore.setMaxZoom(routeData.scale_max / SCALE_FACTOR); + cameraAnimationStore.setMinZoom(routeData.scale_min / SCALE_FACTOR); + } + }, [routeData?.scale_min, routeData?.scale_max, cameraAnimationStore]); + + useEffect(() => { + const interval = setInterval(() => { + const timeSinceActivity = Date.now() - userActivityTimestamp; + if (timeSinceActivity >= 120000 && !isAutoMode) { + setIsAutoMode(true); + + setIsLeftWidgetOpen(false); + setIsRightWidgetSelectorOpen(false); + setIsTransferWidgetOpen(false); + } + }, 1000); + + return () => clearInterval(interval); + }, [ + userActivityTimestamp, + isAutoMode, + setIsAutoMode, + setIsLeftWidgetOpen, + setIsRightWidgetSelectorOpen, + setIsTransferWidgetOpen, + ]); + + useEffect(() => { + if (activeClusterId === null) { + if (clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + clusterAutoCloseTimerRef.current = null; + } + return; + } + + if (clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + } + clusterAutoCloseTimerRef.current = setTimeout(() => { + setActiveClusterId(null); + }, 20000); + + return () => { + if (clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + clusterAutoCloseTimerRef.current = null; + } + }; + }, [activeClusterId, setActiveClusterId]); + + useEffect(() => { + if (activeClusterId === null) { + return; + } + + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + const target = e.target as HTMLElement; + + const clusterElements = document.querySelectorAll("[data-cluster-id]"); + let clickedOnCluster = false; + + clusterElements.forEach((element) => { + if (element.contains(target) || element === target) { + clickedOnCluster = true; + } + }); + + const expandedCircle = target.closest("[data-expanded-cluster]"); + if (expandedCircle) { + clickedOnCluster = true; + } + + if (!clickedOnCluster) { + setActiveClusterId(null); + if (clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + clusterAutoCloseTimerRef.current = null; + } + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [activeClusterId, setActiveClusterId]); + + useEffect(() => { + if (!hasInitializedCameraRef.current) { + return; + } + + if (cameraAnimationStore.isActivelyAnimating) { + return; + } + + if (isAutoMode && transformedTramCoords && screenCenter) { + const transformedStations = stationData + ? stationData + .map((station: any) => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return null; + + const local = coordinatesToLocal( + station.latitude - centerLat, + station.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + return { + longitude: rx, + latitude: ry, + id: station.id, + }; + }) + .filter(Boolean) + : []; + + if ( + transformedTramCoords && + screenCenter && + transformedStations && + scaleLimitsRef.current !== null && + scaleLimitsRef.current.max !== null && + scaleLimitsRef.current.min && + scaleLimitsRef.current.min !== null + ) { + cameraAnimationStore.setMaxZoom(scaleLimitsRef.current!.max); + cameraAnimationStore.setMinZoom(scaleLimitsRef.current!.min); + + cameraAnimationStore.syncState(positionRef.current, scaleRef.current); + + cameraAnimationStore.followTram( + transformedTramCoords, + screenCenter, + transformedStations, + ); + } + } else if (!isAutoMode) { + cameraAnimationStore.stopAnimation(); + } + }, [ + isAutoMode, + transformedTramCoords, + screenCenter, + cameraAnimationStore, + stationData, + routeData, + rotationAngle, + ]); + + const stationLabels = useMemo(() => { + if (!stationData || !routeData) + return [] as Array<{ + x: number; + y: number; + name: string; + sub?: string; + anchorX: number; + anchorY: number; + distance: number; + }>; + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) return []; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const result: Array<{ + x: number; + y: number; + name: string; + sub?: string; + anchorX: number; + anchorY: number; + distance: number; + }> = []; + for (let i = 0; i < stationData.length; i++) { + const st = stationData[i]; + const local = coordinatesToLocal( + st.latitude - centerLat, + st.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + const dpr = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1, + ); + + const DEFAULT_LABEL_OFFSET_X = 25; + const DEFAULT_LABEL_OFFSET_Y = 0; + const offsetXPixels = + st.offset_x === 0 && st.offset_y === 0 + ? DEFAULT_LABEL_OFFSET_X + : st.offset_x; + const offsetYPixels = + st.offset_x === 0 && st.offset_y === 0 + ? DEFAULT_LABEL_OFFSET_Y + : st.offset_y; + + const backendAlign = st?.align ?? 3; + + const fontSizePercent = routeData?.font_size ?? 100; + const fontScale = fontSizePercent / 100; + const fontSize = 16 * fontScale; + + const lineHeight = fontSize * 1.2; + const mainLabelHeight = lineHeight; + + const labelOffsetX = offsetXPixels / scale; + const labelOffsetY = (offsetYPixels + mainLabelHeight / 2) / scale; + + const textBlockPositionX = rx + labelOffsetX; + const textBlockPositionY = ry + labelOffsetY; + + const approximateTextWidth = st.name.length * fontSize * 0.6; + const textWidthInMapCoords = approximateTextWidth / scale; + + let anchorXOffset = 0; + if (backendAlign === 1) { + anchorXOffset = 0; + } else if (backendAlign === 3) { + anchorXOffset = textWidthInMapCoords; + } else { + anchorXOffset = textWidthInMapCoords / 2; + } + + const anchorX = textBlockPositionX + anchorXOffset; + const anchorY = textBlockPositionY; + + const distanceInPixels = Math.hypot(offsetXPixels, offsetYPixels); + + const sx = (textBlockPositionX * scale + position.x) / dpr; + const sy = (textBlockPositionY * scale + position.y) / dpr; + let sub: string | undefined; + if ((selectedLanguage as any) === "zh") + sub = (stationDataZh as any)?.[i]?.name; + else if ( + (selectedLanguage as any) === "en" || + (selectedLanguage as any) === "ru" + ) + sub = (stationDataEn as any)?.[i]?.name; + result.push({ + x: sx, + y: sy, + name: st.name, + sub, + anchorX: anchorX, + anchorY: anchorY, + distance: distanceInPixels, + }); + } + return result; + }, [ + stationData, + stationDataEn as any, + stationDataZh as any, + position.x, + position.y, + scale, + routeData?.center_latitude, + routeData?.center_longitude, + routeData?.font_size, + rotationAngle, + selectedLanguage as any, + ]); + + const stationPoints = useMemo(() => { + if (!stationData || !routeData) return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const verts: number[] = []; + for (const s of stationData) { + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + stationData, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + const sightPoints = useMemo(() => { + if (!sightData || !routeData) return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const verts: number[] = []; + for (const s of sightData) { + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + sightData, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + const transformedSightData = useMemo(() => { + if (!sightData || !routeData) return []; + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) return []; + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + + return sightData.map((s: any) => { + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + return { + ...s, + longitude: rx, + latitude: ry, + }; + }); + }, [ + sightData, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + const pathPointsForClustering = useMemo(() => { + const points: { x: number; y: number }[] = []; + for (let i = 0; i < routePath.length; i += 2) { + points.push({ x: routePath[i], y: routePath[i + 1] }); + } + return points; + }, [routePath]); + + const distanceThreshold = BASE_ICON_SIZE * 3; + const clusteredSights = useSightClustering( + transformedSightData, + distanceThreshold, + pathPointsForClustering, + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const gl = initWebGLContext(canvas); + glRef.current = gl; + if (!gl) return; + + const vertSrc = ` + attribute vec2 a_pos; + uniform vec2 u_cameraPos; + uniform float u_scale; + uniform vec2 u_resolution; + void main() { + vec2 screen = a_pos * u_scale + u_cameraPos; + vec2 zeroToOne = screen / u_resolution; + vec2 zeroToTwo = zeroToOne * 2.0; + vec2 clip = zeroToTwo - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + } + `; + const fragSrc = ` + precision mediump float; + uniform vec4 u_color; + void main() { + gl_FragColor = u_color; + } + `; + + const compile = (type: number, src: string) => { + const s = gl.createShader(type)!; + gl.shaderSource(s, src); + gl.compileShader(s); + return s; + }; + const vs = compile(gl.VERTEX_SHADER, vertSrc); + const fs = compile(gl.FRAGMENT_SHADER, fragSrc); + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + programRef.current = prog; + gl.useProgram(prog); + + const a_pos = gl.getAttribLocation(prog, "a_pos"); + const u_cameraPos = gl.getUniformLocation(prog, "u_cameraPos"); + const u_scale = gl.getUniformLocation(prog, "u_scale"); + const u_resolution = gl.getUniformLocation(prog, "u_resolution"); + const u_color = gl.getUniformLocation(prog, "u_color"); + attribsRef.current = { a_pos }; + uniformsRef.current = { u_cameraPos, u_scale, u_resolution, u_color }; + + const buffer = gl.createBuffer(); + bufferRef.current = buffer; + + const pointVert = ` + attribute vec2 a_pos; + uniform vec2 u_cameraPos; + uniform float u_scale; + uniform vec2 u_resolution; + uniform float u_pointSize; + void main() { + vec2 screen = a_pos * u_scale + u_cameraPos; + vec2 zeroToOne = screen / u_resolution; + vec2 zeroToTwo = zeroToOne * 2.0; + vec2 clip = zeroToTwo - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + gl_PointSize = u_pointSize; + } + `; + const pointFrag = ` + precision mediump float; + uniform vec4 u_color; + void main() { + vec2 c = gl_PointCoord * 2.0 - 1.0; + float d = dot(c, c); + if (d > 1.0) discard; + gl_FragColor = u_color; + } + `; + const vs2 = compile(gl.VERTEX_SHADER, pointVert); + const fs2 = compile(gl.FRAGMENT_SHADER, pointFrag); + const pprog = gl.createProgram()!; + gl.attachShader(pprog, vs2); + gl.attachShader(pprog, fs2); + gl.linkProgram(pprog); + pointProgramRef.current = pprog; + pointBufferRef.current = gl.createBuffer(); + + const lineVert = ` + attribute vec2 a_screen; + uniform vec2 u_resolution; + void main(){ + vec2 zeroToOne = a_screen / u_resolution; + vec2 clip = zeroToOne * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + } + `; + const lineFrag = ` + precision mediump float; + uniform vec4 u_color; + void main(){ gl_FragColor = u_color; } + `; + const lv = compile(gl.VERTEX_SHADER, lineVert); + const lf = compile(gl.FRAGMENT_SHADER, lineFrag); + const lprog = gl.createProgram()!; + gl.attachShader(lprog, lv); + gl.attachShader(lprog, lf); + gl.linkProgram(lprog); + screenLineProgramRef.current = lprog; + screenLineBufferRef.current = gl.createBuffer(); + + const handleResize = () => { + const changed = resizeCanvasToDisplaySize(canvas); + if (!gl) return; + const dpr = Math.max(1, window.devicePixelRatio || 1); + debugWebglLog("container resize", { + changed, + cssWidth: canvas.clientWidth, + cssHeight: canvas.clientHeight, + dpr, + displayWidth: canvas.width, + displayHeight: canvas.height, + }); + setScreenCenter({ + x: canvas.width / 2, + y: canvas.height / 2, + }); + if (changed) { + gl.viewport(0, 0, canvas.width, canvas.height); + } + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + }; + + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + useEffect(() => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat !== undefined && centerLon !== undefined) { + const coords: any = apiStore?.context?.currentCoordinates; + if (coords) { + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle), + sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + if (apiStore.simulationInstantMove) { + setYellowDotImmediate(rx, ry); + } else { + animateYellowDotTo(rx, ry); + } + } + } + }, [ + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + apiStore?.context?.currentCoordinates?.latitude, + apiStore?.context?.currentCoordinates?.longitude, + animateYellowDotTo, + setYellowDotImmediate, + ]); + + useEffect(() => { + const gl = glRef.current; + const canvas = canvasRef.current; + const prog = programRef.current; + const buffer = bufferRef.current; + const attribs = attribsRef.current; + const uniforms = uniformsRef.current; + const pprog = pointProgramRef.current; + const pbuffer = pointBufferRef.current; + if ( + !gl || + !canvas || + !prog || + !buffer || + !attribs || + !uniforms || + !pprog || + !pbuffer + ) + return; + + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.useProgram(prog); + gl.uniform2f(uniforms.u_cameraPos, position.x, position.y); + gl.uniform1f(uniforms.u_scale, scale); + gl.uniform2f(uniforms.u_resolution, canvas.width, canvas.height); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, routePath, gl.STATIC_DRAW); + gl.enableVertexAttribArray(attribs.a_pos); + gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0); + + const vcount = routePath.length / 2; + let tramSegIndex = getCurrentSegIndex(); + + const dpr = Math.max(1, window.devicePixelRatio || 1); + const desiredRouteWidthCss = 7; + const desiredStationDiameterCss = 12; + const pointOuterSizePx = desiredStationDiameterCss * dpr; + const pointInnerSizePx = pointOuterSizePx * 0.8; + const lineWidthPreview = + (desiredRouteWidthCss * dpr) / Math.max(scale, 1e-6); + const drawSignature = [ + canvas.width, + canvas.height, + routePath.length, + stationPoints.length, + sightPoints.length, + Math.round(scale * 1e4) / 1e4, + Math.round(lineWidthPreview * 1e4) / 1e4, + ].join("|"); + const shouldLogDraw = lastDrawLogSignatureRef.current !== drawSignature; + if (shouldLogDraw) { + lastDrawLogSignatureRef.current = drawSignature; + debugWebglLog("drawScene:start", { + dpr, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + routeVerticesCount: routePath.length / 2, + stationVerticesCount: stationPoints.length / 2, + sightVerticesCount: sightPoints.length / 2, + scale, + position, + }); + } + + const vertexCount = routePath.length / 2; + if (vertexCount > 1) { + const generateThickLine = (points: Float32Array, width: number) => { + const vertices: number[] = []; + const halfWidth = width / 2; + + if (points.length < 4) return new Float32Array(); + + for (let i = 0; i < points.length - 2; i += 2) { + const x1 = points[i]; + const y1 = points[i + 1]; + const x2 = points[i + 2]; + const y2 = points[i + 3]; + + const dx = x2 - x1; + const dy = y2 - y1; + const length = Math.sqrt(dx * dx + dy * dy); + if (length === 0) continue; + + const perpX = (-dy / length) * halfWidth; + const perpY = (dx / length) * halfWidth; + + vertices.push(x1 + perpX, y1 + perpY); + vertices.push(x1 - perpX, y1 - perpY); + vertices.push(x2 + perpX, y2 + perpY); + + vertices.push(x1 - perpX, y1 - perpY); + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 + perpX, y2 + perpY); + + if (i < points.length - 4) { + const x3 = points[i + 4]; + const y3 = points[i + 5]; + + const dx2 = x3 - x2; + const dy2 = y3 - y2; + const length2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + if (length2 > 0) { + const perpX2 = (-dy2 / length2) * halfWidth; + const perpY2 = (dx2 / length2) * halfWidth; + + vertices.push(x2 + perpX, y2 + perpY); + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 + perpX2, y2 + perpY2); + + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 - perpX2, y2 - perpY2); + vertices.push(x2 + perpX2, y2 + perpY2); + } + } + } + + return new Float32Array(vertices); + }; + + const lineWidth = (desiredRouteWidthCss * dpr) / scale; + if (shouldLogDraw) { + debugWebglLog("drawScene:route", { + desiredRouteWidthCss, + lineWidthWorldUnits: lineWidth, + }); + } + const r1 = ((PATH_COLOR >> 16) & 0xff) / 255; + const g1 = ((PATH_COLOR >> 8) & 0xff) / 255; + const b1 = (PATH_COLOR & 0xff) / 255; + gl.uniform4f(uniforms.u_color, r1, g1, b1, 1); + + if (tramSegIndex >= 0) { + const animatedPos = animatedYellowDotPosition; + if ( + animatedPos && + animatedPos.x !== undefined && + animatedPos.y !== undefined + ) { + const passedPoints: number[] = []; + + for (let i = 0; i <= tramSegIndex; i++) { + passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + + passedPoints.push(animatedPos.x, animatedPos.y); + + if (passedPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(passedPoints), + lineWidth, + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + } + } + + const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255; + gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); + + const animatedPos = animatedYellowDotPosition; + if ( + animatedPos && + animatedPos.x !== undefined && + animatedPos.y !== undefined + ) { + const unpassedPoints: number[] = []; + + unpassedPoints.push(animatedPos.x, animatedPos.y); + + for (let i = tramSegIndex + 1; i < vertexCount; i++) { + unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + + if (unpassedPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(unpassedPoints), + lineWidth, + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + } + } + + if (stationPoints.length > 0) { + gl.useProgram(pprog); + const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); + const u_cameraPos_pts = gl.getUniformLocation(pprog, "u_cameraPos"); + const u_scale_pts = gl.getUniformLocation(pprog, "u_scale"); + const u_resolution_pts = gl.getUniformLocation(pprog, "u_resolution"); + const u_pointSize = gl.getUniformLocation(pprog, "u_pointSize"); + const u_color_pts = gl.getUniformLocation(pprog, "u_color"); + + gl.uniform2f(u_cameraPos_pts, position.x, position.y); + gl.uniform1f(u_scale_pts, scale); + gl.uniform2f(u_resolution_pts, canvas.width, canvas.height); + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + + gl.uniform1f(u_pointSize, pointOuterSizePx); + const r_outline = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; + const g_outline = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; + const b_outline = (BACKGROUND_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_outline, g_outline, b_outline, 1); + gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); + + gl.uniform1f(u_pointSize, pointInnerSizePx); + + let currentStationIndexInOrdered = -1; + if (currentStationId && orderedRouteStations) { + currentStationIndexInOrdered = orderedRouteStations.findIndex( + (station: any) => String(station.id) === String(currentStationId), + ); + } + + if ( + currentStationIndexInOrdered >= 0 && + orderedRouteStations && + stationData + ) { + const passedStations: number[] = []; + + for (let i = 0; i < currentStationIndexInOrdered; i++) { + const orderedStation = orderedRouteStations[i]; + if (orderedStation) { + const stationIndexInData = stationData.findIndex( + (station: any) => + String(station.id) === String(orderedStation.id), + ); + if (stationIndexInData >= 0) { + passedStations.push( + stationPoints[stationIndexInData * 2] as number, + stationPoints[stationIndexInData * 2 + 1] as number, + ); + } + } + } + if (passedStations.length > 0) { + const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255; + const g_passed = ((PATH_COLOR >> 8) & 0xff) / 255; + const b_passed = (PATH_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(passedStations), + gl.STATIC_DRAW, + ); + gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); + } + } + + if ( + currentStationIndexInOrdered >= 0 && + orderedRouteStations && + stationData + ) { + const unpassedStations: number[] = []; + + for ( + let i = currentStationIndexInOrdered + 1; + i < orderedRouteStations.length; + i++ + ) { + const orderedStation = orderedRouteStations[i]; + if (orderedStation) { + const stationIndexInData = stationData.findIndex( + (station: any) => + String(station.id) === String(orderedStation.id), + ); + if (stationIndexInData >= 0) { + unpassedStations.push( + stationPoints[stationIndexInData * 2] as number, + stationPoints[stationIndexInData * 2 + 1] as number, + ); + } + } + } + if (unpassedStations.length > 0) { + const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(unpassedStations), + gl.STATIC_DRAW, + ); + gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); + } + } else { + const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1); + gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); + gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); + } + } + + const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); + const u_cameraPos_pts = gl.getUniformLocation(pprog, "u_cameraPos"); + const u_scale_pts = gl.getUniformLocation(pprog, "u_scale"); + const u_resolution_pts = gl.getUniformLocation(pprog, "u_resolution"); + const u_pointSize = gl.getUniformLocation(pprog, "u_pointSize"); + const u_color_pts = gl.getUniformLocation(pprog, "u_color"); + + gl.uniform2f(u_cameraPos_pts, position.x, position.y); + gl.uniform1f(u_scale_pts, scale); + gl.uniform2f(u_resolution_pts, canvas.width, canvas.height); + + const toPointsArray = (arr: number[]) => new Float32Array(arr); + + const pathPts: { x: number; y: number }[] = []; + for (let i = 0; i < routePath.length; i += 2) + pathPts.push({ x: routePath[i], y: routePath[i + 1] }); + const getSeg = (px: number, py: number) => { + if (pathPts.length < 2) return -1; + let best = -1, + bestD = Infinity; + for (let i = 0; i < pathPts.length - 1; i++) { + const p1 = pathPts[i], + p2 = pathPts[i + 1]; + const dx = p2.x - p1.x, + dy = p2.y - p1.y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((px - p1.x) * dx + (py - p1.y) * dy) / len2; + const tt = Math.max(0, Math.min(1, t)); + const cx = p1.x + tt * dx, + cy = p1.y + tt * dy; + const d = Math.hypot(px - cx, py - cy); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; + }; + + let tramSegForStations = -1; + { + const cLat = routeData?.center_latitude, + cLon = routeData?.center_longitude; + const tram = apiStore?.context?.currentCoordinates as any; + if (tram && cLat !== undefined && cLon !== undefined) { + const loc = coordinatesToLocal( + tram.latitude - cLat, + tram.longitude - cLon, + ); + const wx = loc.x * UP_SCALE, + wy = loc.y * UP_SCALE; + const cosR = Math.cos(rotationAngle), + sinR = Math.sin(rotationAngle); + const tx = wx * cosR - wy * sinR, + ty = wx * sinR + wy * cosR; + tramSegForStations = getSeg(tx, ty); + } + } + + let activeStationIndex = -1; + const tramCoords = apiStore?.context?.currentCoordinates; + if ( + tramCoords && + stationData && + stationData.length > 0 && + Number.isFinite(tramCoords.latitude) && + Number.isFinite(tramCoords.longitude) + ) { + let bestD = Infinity; + + const DISTANCE_THRESHOLD = 0.00015; + + for (let i = 0; i < stationData.length; i++) { + const station = stationData[i]; + if ( + Number.isFinite(station.latitude) && + Number.isFinite(station.longitude) + ) { + const d = getDistance( + { + latitude: tramCoords.latitude, + longitude: tramCoords.longitude, + }, + { + latitude: station.latitude, + longitude: station.longitude, + }, + ); + if (d < bestD) { + bestD = d; + activeStationIndex = i; + } + } + } + + if (bestD > DISTANCE_THRESHOLD) { + activeStationIndex = -1; + } + } + + if (stationData && stationData.length > 0) { + if (activeStationIndex >= 0 && stationData[activeStationIndex]) { + const activeId = String(stationData[activeStationIndex].id); + if (activeId !== nearestStationId) { + setNearestStationId(activeId); + } + } else if (nearestStationId !== null) { + setNearestStationId(null); + } + } + + let currentStationIndexInOrdered = -1; + if (currentStationId && orderedRouteStations) { + currentStationIndexInOrdered = orderedRouteStations.findIndex( + (station: any) => String(station.id) === String(currentStationId), + ); + } + + const passedStationIds = new Set(); + const unpassedStationIds = new Set(); + + if (currentStationIndexInOrdered >= 0 && orderedRouteStations) { + for (let i = 0; i < currentStationIndexInOrdered; i++) { + const station = orderedRouteStations[i]; + if (station) { + passedStationIds.add(String(station.id)); + } + } + + for ( + let i = currentStationIndexInOrdered; + i < orderedRouteStations.length; + i++ + ) { + const station = orderedRouteStations[i]; + if (station) { + unpassedStationIds.add(String(station.id)); + } + } + } else { + if (orderedRouteStations) { + orderedRouteStations.forEach((station: any) => { + unpassedStationIds.add(String(station.id)); + }); + } + } + + const passedStations: number[] = []; + const unpassedStations: number[] = []; + const activeStations: number[] = []; + for ( + let i = 0, idx = 0; + i < stationData.length && idx < stationPoints.length; + i++, idx += 2 + ) { + const sx = stationPoints[idx]; + const sy = stationPoints[idx + 1]; + const stationId = String(stationData[i].id); + + if (i === activeStationIndex) { + activeStations.push(sx, sy); + } else if (passedStationIds.has(stationId)) { + passedStations.push(sx, sy); + } else { + unpassedStations.push(sx, sy); + } + } + + const outlineSize = pointOuterSizePx; + const coreSize = pointInnerSizePx; + if (shouldLogDraw) { + debugWebglLog("drawScene:stations", { + pointOuterSizePx: outlineSize, + pointInnerSizePx: coreSize, + }); + } + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + toPointsArray(unpassedStations), + gl.STREAM_DRAW, + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + gl.uniform1f(u_pointSize, outlineSize); + gl.uniform4f( + u_color_pts, + ((BACKGROUND_COLOR >> 16) & 255) / 255, + ((BACKGROUND_COLOR >> 8) & 255) / 255, + (BACKGROUND_COLOR & 255) / 255, + 1, + ); + if (unpassedStations.length) + gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); + gl.uniform1f(u_pointSize, coreSize); + gl.uniform4f( + u_color_pts, + ((UNPASSED_STATION_COLOR >> 16) & 255) / 255, + ((UNPASSED_STATION_COLOR >> 8) & 255) / 255, + (UNPASSED_STATION_COLOR & 255) / 255, + 1, + ); + if (unpassedStations.length) + gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + toPointsArray(passedStations), + gl.STREAM_DRAW, + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + gl.uniform1f(u_pointSize, outlineSize); + gl.uniform4f( + u_color_pts, + ((BACKGROUND_COLOR >> 16) & 255) / 255, + ((BACKGROUND_COLOR >> 8) & 255) / 255, + (BACKGROUND_COLOR & 255) / 255, + 1, + ); + if (passedStations.length) + gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); + gl.uniform1f(u_pointSize, coreSize); + gl.uniform4f( + u_color_pts, + ((PATH_COLOR >> 16) & 255) / 255, + ((PATH_COLOR >> 8) & 255) / 255, + (PATH_COLOR & 255) / 255, + 1, + ); + if (passedStations.length) + gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); + + if (activeStations.length) { + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + toPointsArray(activeStations), + gl.STREAM_DRAW, + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + + gl.uniform1f(u_pointSize, outlineSize + 1); + gl.uniform4f( + u_color_pts, + ((BACKGROUND_COLOR >> 16) & 255) / 255, + ((BACKGROUND_COLOR >> 8) & 255) / 255, + (BACKGROUND_COLOR & 255) / 255, + 1, + ); + gl.drawArrays(gl.POINTS, 0, activeStations.length / 2); + + gl.uniform1f(u_pointSize, coreSize + 1); + gl.uniform4f( + u_color_pts, + ((BUS_COLOR >> 16) & 255) / 255, + ((BUS_COLOR >> 8) & 255) / 255, + (BUS_COLOR & 255) / 255, + 1, + ); + gl.drawArrays(gl.POINTS, 0, activeStations.length / 2); + } + + if ( + stationData && + stationData.length > 0 && + routeData && + apiStore?.context + ) { + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat !== undefined && centerLon !== undefined) { + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + + const startStationData = stationData.find( + (station) => station.id.toString() === apiStore.context?.startStopId, + ); + const endStationData = stationData.find( + (station) => station.id.toString() === apiStore.context?.endStopId, + ); + + const terminalStations: number[] = []; + + if (startStationData) { + const startLocal = coordinatesToLocal( + startStationData.latitude - centerLat, + startStationData.longitude - centerLon, + ); + const startX = startLocal.x * UP_SCALE; + const startY = startLocal.y * UP_SCALE; + const startRx = startX * cos - startY * sin; + const startRy = startX * sin + startY * cos; + terminalStations.push(startRx, startRy); + } + + if (endStationData) { + const endLocal = coordinatesToLocal( + endStationData.latitude - centerLat, + endStationData.longitude - centerLon, + ); + const endX = endLocal.x * UP_SCALE; + const endY = endLocal.y * UP_SCALE; + const endRx = endX * cos - endY * sin; + const endRy = endX * sin + endY * cos; + terminalStations.push(endRx, endRy); + } + + if (terminalStations.length > 0) { + const terminalStationData: any[] = []; + if (startStationData) terminalStationData.push(startStationData); + if (endStationData) terminalStationData.push(endStationData); + + let tramSegIndex = -1; + const coords: any = apiStore?.context?.currentCoordinates; + if (coords && centerLat !== undefined && centerLon !== undefined) { + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon, + ); + const wx = local.x * UP_SCALE; + const wy = local.y * UP_SCALE; + const cosR = Math.cos(rotationAngle); + const sinR = Math.sin(rotationAngle); + const tx = wx * cosR - wy * sinR; + const ty = wx * sinR + wy * cosR; + + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((tx - p1x) * dx + (ty - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(tx - px, ty - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + tramSegIndex = best; + } + + const isStartPassed = startStationData + ? (() => { + const sx = terminalStations[0]; + const sy = terminalStations[1]; + const seg = (() => { + if (routePath.length < 4) return -1; + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(sx - px, sy - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + return best; + })(); + return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; + })() + : false; + + const isEndPassed = endStationData + ? (() => { + const ex = terminalStations[terminalStations.length - 2]; + const ey = terminalStations[terminalStations.length - 1]; + const seg = (() => { + if (routePath.length < 4) return -1; + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((ex - p1x) * dx + (ey - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(ex - px, ey - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + return best; + })(); + return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; + })() + : false; + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(terminalStations), + gl.STREAM_DRAW, + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + + const terminalOuterSizePx = pointOuterSizePx * 0.9; + const terminalInnerSizePx = pointOuterSizePx * 0.55; + gl.uniform1f(u_pointSize, terminalOuterSizePx); + + const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255; + const g_passed = ((PATH_COLOR >> 8) & 0xff) / 255; + const b_passed = (PATH_COLOR & 0xff) / 255; + + const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; + + if (startStationData && endStationData) { + gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0); + gl.drawArrays(gl.POINTS, 0, 1); + + if (isEndPassed) { + gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0); + } else { + gl.uniform4f( + u_color_pts, + r_unpassed, + g_unpassed, + b_unpassed, + 1.0, + ); + } + gl.drawArrays(gl.POINTS, 1, 1); + } else { + const isStartStation = startStationData !== undefined; + const isPassed = startStationData ? isStartPassed : isEndPassed; + if (isStartStation || isPassed) { + gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0); + } else { + gl.uniform4f( + u_color_pts, + r_unpassed, + g_unpassed, + b_unpassed, + 1.0, + ); + } + gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); + } + + gl.uniform1f(u_pointSize, terminalInnerSizePx); + const r_center = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; + const g_center = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; + const b_center = (BACKGROUND_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_center, g_center, b_center, 1.0); + gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); + } + } + } + }, [ + routePath, + stationPoints, + sightPoints, + position.x, + position.y, + scale, + animatedYellowDotPosition?.x, + animatedYellowDotPosition?.y, + nearestStationId, + currentStationId, + orderedRouteStations, + ]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || hasInitializedCameraRef.current) return; + if (!routePath || routePath.length < 4) return; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (let i = 0; i < routePath.length; i += 2) { + const x = routePath[i]; + const y = routePath[i + 1]; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + if ( + !isFinite(minX) || + !isFinite(minY) || + !isFinite(maxX) || + !isFinite(maxY) + ) + return; + + routeBoundsRef.current = { minX, maxX, minY, maxY }; + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const worldWidth = Math.max(1, maxX - minX); + const worldHeight = Math.max(1, maxY - minY); + const fitPadding = 0.1; + const fitScale = Math.min( + (canvas.width * (1 - fitPadding)) / worldWidth, + (canvas.height * (1 - fitPadding)) / worldHeight, + ); + + const maxRouteScale = scaleLimitsRef.current.max; + if ( + !( + typeof maxRouteScale === "number" && + Number.isFinite(maxRouteScale) && + maxRouteScale > 0 + ) + ) { + return; + } + const initialScale = maxRouteScale; + const clampedScale = clampScale(initialScale); + + const initialPosition = { + x: canvas.width / 2 - centerX * clampedScale, + y: canvas.height / 2 - centerY * clampedScale, + }; + setScaleRef.current(clampedScale); + setPositionRef.current(initialPosition); + hasInitializedCameraRef.current = true; + if ( + customSightIconBaseScaleRef.current == null && + Number.isFinite(fitScale) && + fitScale > 0 + ) { + customSightIconBaseScaleRef.current = CUSTOM_SIGHT_ICON_BASE_SCALE; + debugWebglLog("custom sight base scale initialized", { + baseScale: CUSTOM_SIGHT_ICON_BASE_SCALE, + currentScale: clampedScale, + }); + forceSightIconScaleRender((version) => version + 1); + } + cameraAnimationStore.syncState(initialPosition, clampedScale); + }, [routePath, cameraAnimationStore]); + + useEffect(() => { + if (!stationData?.length) return; + + const fontSizePercent = + routeData?.font_size ?? originalRouteData?.font_size ?? 100; + const fontScale = fontSizePercent / 100; + const baseStationIconSizePx = 16 * fontScale * 1.2; + + for (const station of stationData) { + const hasCustomStationIcon = !isMediaIdEmpty(station?.icon ?? null); + if (!hasCustomStationIcon) continue; + + const iconSizePx = Math.round(baseStationIconSizePx); + debugWebglLog("custom station icon size", { + stationId: station?.id, + fontSizePercent, + baseStationIconSizePx, + iconSizePx, + }); + } + }, [stationData, routeData?.font_size, originalRouteData?.font_size]); + + useEffect(() => { + if (!transformedSightData?.length) return; + + for (const sight of transformedSightData) { + const shouldUseCustomSightIcon = + sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null); + if (!shouldUseCustomSightIcon) continue; + + const customSightIconScaleFactor = + scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6); + const sightIconSizePercent = resolveSightIconSizePercent(sight); + const iconSizePx = + SIGHT_ICON_BASE_SIZE * + clamp(sightIconSizePercent / 100, 0.1, 10) * + customSightIconScaleFactor; + + debugWebglLog("custom sight icon size", { + sightId: sight.id, + cameraScale: scale, + baseScale: customSightIconBaseScaleRef.current, + scaleFactor: customSightIconScaleFactor, + iconPercent: sightIconSizePercent, + iconSizePx, + }); + } + }, [transformedSightData, scale, resolveSightIconSizePercent]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + let isDragging = false; + let startMouse = { x: 0, y: 0 }; + let startPos = { x: 0, y: 0 }; + + const activePointers = new Map(); + let isPinching = false; + let pinchStart: { + distance: number; + midpoint: { x: number; y: number }; + scale: number; + position: { x: number; y: number }; + } | null = null; + + const getDistance = ( + p1: { x: number; y: number }, + p2: { x: number; y: number }, + ) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + + const getMidpoint = ( + p1: { x: number; y: number }, + p2: { x: number; y: number }, + ) => ({ + x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2, + }); + + const onPointerDown = (e: PointerEvent) => { + updateUserActivity(); + if (isAutoMode) { + setIsAutoMode(false); + } + cameraAnimationStore.stopAnimation(); + + canvas.setPointerCapture(e.pointerId); + const rect = canvas.getBoundingClientRect(); + activePointers.set(e.pointerId, { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + if (activePointers.size === 1) { + isPinching = false; + pinchStart = null; + isDragging = true; + startMouse = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + startPos = { x: positionRef.current.x, y: positionRef.current.y }; + } else if (activePointers.size === 2) { + isDragging = false; + const [p1, p2] = Array.from(activePointers.values()); + pinchStart = { + distance: getDistance(p1, p2), + midpoint: getMidpoint(p1, p2), + scale: scaleRef.current, + position: { x: positionRef.current.x, y: positionRef.current.y }, + }; + isPinching = true; + } + }; + + const onPointerMove = (e: PointerEvent) => { + if (!activePointers.has(e.pointerId)) return; + + updateUserActivity(); + + const rect = canvas.getBoundingClientRect(); + activePointers.set(e.pointerId, { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + + if (activePointers.size === 2) { + isDragging = false; + + const pointersArray = Array.from(activePointers.values()); + if (pointersArray.length === 2) { + const [p1, p2] = pointersArray; + + if (!isPinching || pinchStart === null) { + isPinching = true; + pinchStart = { + distance: getDistance(p1, p2), + midpoint: getMidpoint(p1, p2), + scale: scaleRef.current, + position: { x: positionRef.current.x, y: positionRef.current.y }, + }; + } + + if (pinchStart) { + const currentDistance = getDistance(p1, p2); + const zoomFactor = currentDistance / pinchStart.distance; + const unclampedScale = pinchStart.scale * zoomFactor; + const newScale = clampScale(Math.max(0.1, unclampedScale)); + + const k = newScale / pinchStart.scale; + const newPosition = { + x: pinchStart.position.x * k + pinchStart.midpoint.x * (1 - k), + y: pinchStart.position.y * k + pinchStart.midpoint.y * (1 - k), + }; + const clampedPos = clampPosition(newPosition, newScale); + setPositionRef.current(clampedPos); + setScaleRef.current(newScale); + positionRef.current = clampedPos; + scaleRef.current = newScale; + } + } + } else if (activePointers.size === 1) { + isPinching = false; + pinchStart = null; + + if (isDragging) { + const p = Array.from(activePointers.values())[0]; + + if ( + !startMouse || + !startPos || + typeof startMouse.x !== "number" || + typeof startMouse.y !== "number" || + typeof startPos.x !== "number" || + typeof startPos.y !== "number" + ) { + return; + } + + const dx = p.x - startMouse.x; + const dy = p.y - startMouse.y; + + const newPos = { x: startPos.x + dx, y: startPos.y + dy }; + const clampedPos = clampPosition(newPos, scaleRef.current); + setPositionRef.current(clampedPos); + positionRef.current = clampedPos; + } + } + }; + + const onPointerUp = (e: PointerEvent) => { + updateUserActivity(); + + canvas.releasePointerCapture(e.pointerId); + activePointers.delete(e.pointerId); + if (activePointers.size < 2) { + isPinching = false; + pinchStart = null; + } + if (activePointers.size === 0) { + isDragging = false; + } else if (activePointers.size === 1) { + const p = Array.from(activePointers.values())[0]; + startPos = { x: positionRef.current.x, y: positionRef.current.y }; + startMouse = { x: p.x, y: p.y }; + isDragging = true; + } + }; + + const onPointerCancel = (e: PointerEvent) => { + updateUserActivity(); + canvas.releasePointerCapture(e.pointerId); + activePointers.delete(e.pointerId); + isPinching = false; + pinchStart = null; + if (activePointers.size === 0) { + isDragging = false; + } + }; + + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + + updateUserActivity(); + if (isAutoMode) { + setIsAutoMode(false); + } + cameraAnimationStore.stopAnimation(); + + const rect = canvas.getBoundingClientRect(); + + const mouseX = + (e.clientX - rect.left) * (canvas.width / canvas.clientWidth); + const mouseY = + (e.clientY - rect.top) * (canvas.height / canvas.clientHeight); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const unclampedScale = scaleRef.current * delta; + const newScale = clampScale(Math.max(0.1, unclampedScale)); + + const k = newScale / scaleRef.current; + const newPosition = { + x: positionRef.current.x * k + mouseX * (1 - k), + y: positionRef.current.y * k + mouseY * (1 - k), + }; + const clampedPos = clampPosition(newPosition, newScale); + setScaleRef.current(newScale); + setPositionRef.current(clampedPos); + scaleRef.current = newScale; + positionRef.current = clampedPos; + }; + + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", onPointerUp); + canvas.addEventListener("pointercancel", onPointerCancel); + canvas.addEventListener("pointerleave", onPointerUp); + canvas.addEventListener("wheel", onWheel, { passive: false }); + + return () => { + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", onPointerUp); + canvas.removeEventListener("pointercancel", onPointerCancel); + canvas.removeEventListener("pointerleave", onPointerUp); + canvas.removeEventListener("wheel", onWheel as any); + }; + }, [ + updateUserActivity, + setIsAutoMode, + cameraAnimationStore, + isAutoMode, + clampScale, + clampPosition, + ]); + + return ( +
+ +
+ {stationLabels.map((l, idx) => { + let station = stationData?.[idx]; + + const backendAlign = station?.align ?? 3; + const anchor = getAnchorFromOffset(backendAlign); + const transformCss = `translate(${-anchor.x * 100}%, ${ + -anchor.y * 100 + }%)`; + + const fontSizePercent = routeData?.font_size ?? 100; + const fontScale = fontSizePercent / 100; + + const clampedScale = Math.min(Math.max(scale, 1), 3); + const scaleFactor = 1 + (clampedScale - 1) * 0.4; + + const primaryFontSize = 16 * fontScale * scaleFactor; + const secondaryFontSize = 13 * fontScale * scaleFactor; + const secondaryMarginTop = 5 * fontScale * scaleFactor; + const secondaryLineHeight = 1.2 * scaleFactor; + + const alignment = + backendAlign === 1 + ? "left" + : backendAlign === 3 + ? "right" + : "center"; + + const secondaryPositionStyle = + alignment === "left" + ? { left: 0, transform: "none" } + : alignment === "right" + ? { right: 0, transform: "none" } + : { left: "50%", transform: "translateX(-50%)" }; + + const apiBaseUrl = apiBaseURL; + const isMediaIdEmptyResult = isMediaIdEmpty(station?.icon); + const iconSrc = isMediaIdEmptyResult + ? null + : `${apiBaseUrl}/media/${station?.icon}/download`; + const iconSizePx = Math.round(primaryFontSize * 1.2); + + return ( +
+
+ {iconSrc ? ( + + ) : null} +
+ {l.name} +
+ {l.sub ? ( +
+ {l.sub} +
+ ) : null} +
+
+ ); + })} + {clusteredSights.map((item) => { + const dpr = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1, + ); + + if (item.type === "point") { + const s = item.data; + const rx = s.longitude; + const ry = s.latitude; + const isSelected = selectedSightId === String(s.id); + const hasIcon = !isMediaIdEmpty(s.icon ?? null); + const isCustomIcon = s.is_default_icon === false && hasIcon; + const hasSelectedAltIcon = + isSelected && isCustomIcon && !isMediaIdEmpty(s.alt_icon ?? null); + const defaultCustomIcon = isCustomIcon; + const shouldUseCustomSightIcon = + hasSelectedAltIcon || defaultCustomIcon; + const customSightIconScaleFactor = shouldUseCustomSightIcon + ? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6) + : 1; + const sightIconSizePercent = resolveSightIconSizePercent(s); + const sightIconUrl = hasSelectedAltIcon + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(s.alt_icon), + mediaToken, + ) + : defaultCustomIcon + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(s.icon), + mediaToken, + ) + : SIGHT_ICON_URL; + const iconSize = + SIGHT_ICON_BASE_SIZE * + clamp(sightIconSizePercent / 100, 0.1, 10) * + customSightIconScaleFactor; + + const screenX = (rx * scale + position.x) / dpr; + const screenY = (ry * scale + position.y) / dpr; + const iconLeft = screenX - iconSize; + const iconTop = screenY - iconSize; + + const handleSightClick = () => { + setIsGovernorWidgetOpen(false); + setSelectedSightId(String(s.id)); + setIsManualSelection(true); + setIsRightWidgetSelectorOpen(false); + store.closeGovernorModal(); + }; + + const handleSightTouch = (e: React.TouchEvent) => { + e.stopPropagation(); + handleSightClick(); + }; + + return ( +
+
+ +
+
+ ); + } else { + const cluster = item.data; + const rx = cluster.longitude; + const ry = cluster.latitude; + + const iconSizePercent = resolveSightIconSizePercent(); + const iconSize = + SIGHT_ICON_BASE_SIZE * clamp(iconSizePercent / 100, 0.1, 10); + + const screenX = (rx * scale + position.x) / dpr; + const screenY = (ry * scale + position.y) / dpr; + const iconLeft = screenX - iconSize / 2; + const iconTop = screenY - iconSize / 2; + + const isExpanded = activeClusterId === cluster.id; + const selectedSightInCluster = cluster.sights.find( + (sight) => selectedSightId === String(sight.id), + ); + const selectedHasIconInCluster = + selectedSightInCluster != null && + !isMediaIdEmpty(selectedSightInCluster.icon ?? null); + const selectedIsCustomInCluster = + selectedSightInCluster?.is_default_icon === false && + selectedHasIconInCluster; + const hasSelectedAltIconInCluster = + selectedSightInCluster != null && + selectedIsCustomInCluster && + !isMediaIdEmpty(selectedSightInCluster.alt_icon ?? null); + const clusterIconUrl = hasSelectedAltIconInCluster + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(selectedSightInCluster?.alt_icon), + mediaToken, + ) + : selectedIsCustomInCluster + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(selectedSightInCluster?.icon), + mediaToken, + ) + : SIGHT_ICON_URL; + + const handleClusterClick = ( + e: React.MouseEvent | React.TouchEvent, + ) => { + e.stopPropagation(); + if (isExpanded) { + setActiveClusterId(null); + } else { + setActiveClusterId(cluster.id); + } + }; + + const handleCircleInteraction = () => { + if (isExpanded && clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + clusterAutoCloseTimerRef.current = setTimeout(() => { + setActiveClusterId(null); + }, 20000); + } + }; + + const handleSightInClusterClick = ( + sightId: string, + e: React.MouseEvent | React.TouchEvent, + ) => { + e.stopPropagation(); + setIsGovernorWidgetOpen(false); + setSelectedSightId(sightId); + setIsManualSelection(true); + setIsRightWidgetSelectorOpen(false); + store.closeGovernorModal(); + setActiveClusterId(null); + }; + + const hasSelectedSight = selectedSightId + ? cluster.sights.some( + (sight) => String(sight.id) === selectedSightId, + ) + : false; + + const badgeColor = "#006F3A"; + const listPanelWidth = 200; + const listItemHeight = 30; + const listMaxHeight = 250; + const hasMoreThanTwo = cluster.sights.length > 2; + const shouldShowScrollbar = hasMoreThanTwo; + + return ( + + {!isExpanded ? ( +
+
+ +
+ {cluster.count} +
+
+
+ ) : ( +
+
+ +
+ {cluster.count} +
+
+ +
e.stopPropagation()} + > + + {cluster.sights.map((sight, index) => { + const isSelected = + selectedSightId === String(sight.id); + const getSightName = () => { + const originalSight = + selectedLanguage === "ru" + ? routeSights?.find((s) => s.id === sight.id) + : selectedLanguage === "en" + ? routeSightsEn?.find( + (s) => s.id === sight.id, + ) + : routeSightsZh?.find( + (s) => s.id === sight.id, + ); + + if (originalSight?.short_name) { + return originalSight.short_name; + } + + if (!originalSight?.left_article) { + return ( + sight.name || + `Достопримечательность ${index + 1}` + ); + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get( + originalSight.left_article + "_ru", + ) + : selectedLanguage === "en" + ? sightArticlesEn.get( + originalSight.left_article + "_en", + ) + : sightArticlesZh.get( + originalSight.left_article + "_zh", + ); + + return ( + leftArticleData?.heading || + sight.name || + `Достопримечательность ${index + 1}` + ); + }; + + const sightName = getSightName(); + + return ( +
+ handleSightInClusterClick(String(sight.id), e) + } + onTouchEnd={(e) => + handleSightInClusterClick(String(sight.id), e) + } + style={{ + display: "flex", + alignItems: "center", + height: `${listItemHeight}px`, + cursor: "pointer", + userSelect: "none", + touchAction: "none", + padding: "0 4px", + zIndex: 1000000000000, + borderBottom: + "1px solid rgba(255, 255, 255, 0.1)", + transition: "background-color 0.2s", + }} + > + {(() => { + const hasRowIcon = !isMediaIdEmpty( + sight.icon ?? null, + ); + const isRowCustomIcon = + sight.is_default_icon === false && hasRowIcon; + const hasRowAltIcon = + isSelected && + isRowCustomIcon && + !isMediaIdEmpty(sight.alt_icon ?? null); + const rowIconUrl = hasRowAltIcon + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(sight.alt_icon), + mediaToken, + ) + : isRowCustomIcon + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(sight.icon), + mediaToken, + ) + : SIGHT_ICON_URL; + return ( + + ); + })()} + + {sightName} + +
+ ); + })} +
+
+
+ )} +
+ ); + } + })} + + {(() => { + if (!routeData) return null; + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) return null; + + if (!apiStore?.context?.currentCoordinates) return null; + + const rx = animatedYellowDotPosition.x; + const ry = animatedYellowDotPosition.y; + if (rx === 0 && ry === 0) return null; + + const dpr2 = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1, + ); + const cosR = Math.cos(rotationAngle); + const sinR = Math.sin(rotationAngle); + const pathPts: { x: number; y: number }[] = []; + for (let i = 0; i < routePath.length; i += 2) + pathPts.push({ x: routePath[i], y: routePath[i + 1] }); + const stationsForAngle = (stationData || []).map((st: any) => { + const loc = coordinatesToLocal( + st.latitude - centerLat, + st.longitude - centerLon, + ); + const x = loc.x * UP_SCALE, + y = loc.y * UP_SCALE; + const rx2 = x * cosR - y * sinR, + ry2 = x * sinR + y * cosR; + return { + longitude: rx2, + latitude: ry2, + offset_x: st.offset_x, + offset_y: st.offset_y, + align: st.align, + }; + }); + let tramSegIndex = getCurrentSegIndex(); + if (tramSegIndex < 0 && routePath.length >= 4) { + let best = -1, + bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i], + p1y = routePath[i + 1]; + const p2x = routePath[i + 2], + p2y = routePath[i + 3]; + const dx = p2x - p1x, + dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((rx - p1x) * dx + (ry - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx, + py = p1y + cl * dy; + const d = Math.hypot(rx - px, ry - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + tramSegIndex = best; + } + const optimalAngle = (() => { + const testRadiusInMap = 100 / scale; + const minPath = 60, + minPassed = 60, + minStation = 100; + let bestAng = 0, + bestScore = Infinity; + for (let i = 0; i < 12; i++) { + const ang = (i * Math.PI * 2) / 12; + const tx = rx + Math.cos(ang) * testRadiusInMap; + const ty = ry + Math.sin(ang) * testRadiusInMap; + const distPath = (function () { + if (pathPts.length < 2) return Infinity; + let md = Infinity; + for (let k = 0; k < pathPts.length - 1; k++) { + const p1 = pathPts[k], + p2 = pathPts[k + 1]; + const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + if (!L2) continue; + const tt = + ((tx - p1.x) * (p2.x - p1.x) + + (ty - p1.y) * (p2.y - p1.y)) / + L2; + const cl = Math.max(0, Math.min(1, tt)); + const px = p1.x + cl * (p2.x - p1.x), + py = p1.y + cl * (p2.y - p1.y); + const d = Math.hypot(tx - px, ty - py); + if (d < md) md = d; + } + return md * scale; + })(); + const distPassed = (function () { + if (pathPts.length < 2 || tramSegIndex < 0) return Infinity; + let md = Infinity; + for ( + let k = 0; + k <= Math.min(tramSegIndex, pathPts.length - 2); + k++ + ) { + const p1 = pathPts[k], + p2 = pathPts[k + 1]; + const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + if (!L2) continue; + const tt = + ((tx - p1.x) * (p2.x - p1.x) + + (ty - p1.y) * (p2.y - p1.y)) / + L2; + const cl = Math.max(0, Math.min(1, tt)); + const px = p1.x + cl * (p2.x - p1.x), + py = p1.y + cl * (p2.y - p1.y); + const d = Math.hypot(tx - px, ty - py); + if (d < md) md = d; + } + return md * scale; + })(); + const distStation = (function () { + if (!stationsForAngle.length) return Infinity; + let md = Infinity; + + const LABEL_WIDTH_PX = 120; + + const labelWidthMap = LABEL_WIDTH_PX / scale; + + for (const st of stationsForAngle) { + const align = st.align ?? 3; + + let lx = st.longitude; + let ly = st.latitude; + + if (align === 1 || align === 0) { + lx += labelWidthMap / 2; + } else if (align === 3) { + lx -= labelWidthMap / 2; + } + + const d = Math.hypot(tx - lx, ty - ly); + if (d < md) md = d; + } + return md * scale; + })(); + let weight = 0; + if (distPath < minPath) weight += 100 * (1 - distPath / minPath); + if (distPassed < minPassed) + weight += 10 * (1 - distPassed / minPassed); + if (distStation < minStation) + weight += 1000 * (1 - distStation / minStation); + if (weight < bestScore) { + bestScore = weight; + bestAng = ang; + } + } + return bestAng; + })(); + + const offsetBack = 5 / scale; + const adjustedRx = rx - Math.cos(optimalAngle) * offsetBack; + const adjustedRy = ry - Math.sin(optimalAngle) * offsetBack; + const screenX = (adjustedRx * scale + position.x) / dpr2; + const screenY = (adjustedRy * scale + position.y) / dpr2; + + const screenXDot = (rx * scale + position.x) / dpr2; + const screenYDot = (ry * scale + position.y) / dpr2; + + let activeStationIndex = -1; + const tramCoords = apiStore?.context?.currentCoordinates; + + if ( + tramCoords && + stationData && + stationData.length > 0 && + Number.isFinite(tramCoords.latitude) && + Number.isFinite(tramCoords.longitude) + ) { + let bestD = Infinity; + const DISTANCE_THRESHOLD = 0.00015; + + for (let i = 0; i < stationData.length; i++) { + const station = stationData[i]; + if ( + Number.isFinite(station.latitude) && + Number.isFinite(station.longitude) + ) { + const d = getDistance( + { + latitude: tramCoords.latitude, + longitude: tramCoords.longitude, + }, + { + latitude: station.latitude, + longitude: station.longitude, + }, + ); + if (d < bestD) { + bestD = d; + activeStationIndex = i; + } + } + } + + if (bestD > DISTANCE_THRESHOLD) { + activeStationIndex = -1; + } + } + + const isAtStation = activeStationIndex !== -1; + + const dotColor = isAtStation ? "#fcd500" : "white"; + + return ( + <> +
+ + + ); + })()} +
+
+ ); +}); + +export default WebGLMap; diff --git a/src/client/src/components/map/custom.d.ts b/src/client/src/components/map/custom.d.ts new file mode 100644 index 0000000..6dabad2 --- /dev/null +++ b/src/client/src/components/map/custom.d.ts @@ -0,0 +1,26 @@ +declare module "*.svg" { + import React = require("react"); + export const ReactComponent: React.FC>; + const src: string; + export default src; +} + +declare module "*.png" { + const value: string; + export default value; +} + +declare module "*.jpg" { + const value: string; + export default value; +} + +declare module "*.jpeg" { + const value: string; + export default value; +} + +declare module "*.gif" { + const value: string; + export default value; +} diff --git a/src/client/src/components/map/transformContext.tsx b/src/client/src/components/map/transformContext.tsx new file mode 100644 index 0000000..33e129f --- /dev/null +++ b/src/client/src/components/map/transformContext.tsx @@ -0,0 +1,89 @@ +import React, { + createContext, + ReactNode, + useContext, + useState, + useCallback, + useRef, +} from "react"; +import { UP_SCALE } from "./Constants"; + +const TransformContext = createContext<{ + position: { x: number; y: number }; + scale: number; + setPosition: React.Dispatch>; + setScale: React.Dispatch>; + screenCenter?: { x: number; y: number }; + setScreenCenter: React.Dispatch< + React.SetStateAction<{ x: number; y: number } | undefined> + >; + isAutoMode: boolean; + setIsAutoMode: React.Dispatch>; + userActivityTimestamp: number; + updateUserActivity: () => void; + autoModeStartTimestamp: number | null; + setAutoModeStartTimestamp: React.Dispatch< + React.SetStateAction + >; +}>({ + position: { x: 0, y: 0 }, + scale: 1, + setPosition: () => {}, + setScale: () => {}, + screenCenter: undefined, + setScreenCenter: () => {}, + isAutoMode: false, + setIsAutoMode: () => {}, + userActivityTimestamp: Date.now(), + updateUserActivity: () => {}, + autoModeStartTimestamp: null, + setAutoModeStartTimestamp: () => {}, +}); + +export const TransformProvider = ({ children }: { children: ReactNode }) => { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [scale, setScale] = useState(1); + const [screenCenter, setScreenCenter] = useState<{ x: number; y: number }>(); + const [isAutoMode, setIsAutoMode] = useState(false); + const [userActivityTimestamp, setUserActivityTimestamp] = useState( + Date.now() + ); + + const [autoModeStartTimestamp, setAutoModeStartTimestamp] = useState< + number | null + >(null); + + const updateUserActivity = useCallback(() => { + setUserActivityTimestamp(Date.now()); + // Убираем автоматическое отключение авто режима - это будет делаться в InfiniteCanvas + }, []); + + const value = { + position, + scale, + setPosition, + setScale, + screenCenter, + setScreenCenter, + isAutoMode, + setIsAutoMode, + userActivityTimestamp, + updateUserActivity, + autoModeStartTimestamp, + setAutoModeStartTimestamp, + }; + + return ( + + {children} + + ); +}; + +export const useTransform = () => { + const context = useContext(TransformContext); + if (!context) { + throw new Error("useTransform must be used within a TransformProvider"); + } + return context; +}; diff --git a/src/client/src/components/map/types.tsx b/src/client/src/components/map/types.tsx new file mode 100644 index 0000000..6613f70 --- /dev/null +++ b/src/client/src/components/map/types.tsx @@ -0,0 +1,41 @@ +export interface RouteData { + center_longitude: any; + center_latitude: any; + path: [number, number][]; + scale_min: number; + scale_max: number; + rotate?: number; +} + +export interface StationData { + id: number; + latitude: number; + longitude: number; + offset_x: number; + offset_y: number; + name: string; +} + +export interface StationDataEn extends StationData { + name: string; // Имя на английском +} + +export interface SightData { + id: number; + latitude: number; + longitude: number; + name: string; + description?: string; + city_id?: string; + address?: string; + 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; +} diff --git a/src/client/src/components/map/utils.tsx b/src/client/src/components/map/utils.tsx new file mode 100644 index 0000000..9aab309 --- /dev/null +++ b/src/client/src/components/map/utils.tsx @@ -0,0 +1,13 @@ +export function coordinatesToLocal(latitude: number, longitude: number) { + return { + x: longitude, + y: -latitude*2, + } +} + +export function localToCoordinates(x: number, y: number) { + return { + longitude: x, + latitude: -y/2, + } +} diff --git a/src/client/src/components/side-menu/BackButtonSVG.jsx b/src/client/src/components/side-menu/BackButtonSVG.jsx new file mode 100644 index 0000000..6fb5a06 --- /dev/null +++ b/src/client/src/components/side-menu/BackButtonSVG.jsx @@ -0,0 +1,21 @@ +function BackButtonSVG({ onPointerUp }) { + return ( + + + + ); +} + +export default BackButtonSVG; diff --git a/src/client/src/components/side-menu/LeftWidget.jsx b/src/client/src/components/side-menu/LeftWidget.jsx new file mode 100644 index 0000000..454924a --- /dev/null +++ b/src/client/src/components/side-menu/LeftWidget.jsx @@ -0,0 +1,256 @@ +import { useEffect, useState, useRef, useLayoutEffect } from "react"; +import { observer } from "mobx-react-lite"; +import ContentAPI from "../../api/content/content.api"; +import { useGeolocationStore } from "../../stores"; +import "../../styles/LeftWidget.css"; +import { apiStore } from "../../api/ApiStore/store"; +import { apiBaseURL } from "../../api/apiConfig"; + +const LeftWidget = observer( + ({ selectedSightId, onClose, isVisible, sightTop }) => { + const [selectedSightData, setSelectedSightData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [shouldAnimateIn, setShouldAnimateIn] = useState(false); + const [isImageLoaded, setIsImageLoaded] = useState(false); + const [widgetHeight, setWidgetHeight] = useState(0); + + const textRef = useRef(null); + const activeTouch = useRef(null); + const widgetRef = useRef(null); + + const store = useGeolocationStore(); + const { selectedLanguage, isLeftWidgetOpen } = store; + const { + sightArticles, + sightArticlesEn, + sightArticlesZh, + routeSights, + routeSightsEn, + routeSightsZh, + } = apiStore; + + useLayoutEffect(() => { + if (widgetRef.current) { + setWidgetHeight(widgetRef.current.getBoundingClientRect().height); + } + }, [selectedSightData, isImageLoaded, isVisible, isLoading, error]); + + useEffect(() => { + const scrollContainer = textRef.current; + if (!scrollContainer) return; + + const handleTouchStart = (e) => { + e.stopPropagation(); + if (e.touches.length === 1) { + activeTouch.current = { + identifier: e.touches[0].identifier, + lastY: e.touches[0].clientY, + }; + } + }; + + const handleTouchMove = (e) => { + e.preventDefault(); + if (activeTouch.current) { + for (const touch of e.changedTouches) { + if (touch.identifier === activeTouch.current.identifier) { + const deltaY = touch.clientY - activeTouch.current.lastY; + scrollContainer.scrollTop -= deltaY; + activeTouch.current.lastY = touch.clientY; + break; + } + } + } + }; + + const handleTouchEnd = (e) => { + for (const touch of e.changedTouches) { + if ( + activeTouch.current && + touch.identifier === activeTouch.current.identifier + ) { + activeTouch.current = null; + break; + } + } + }; + + scrollContainer.addEventListener("touchstart", handleTouchStart, { + passive: true, + }); + scrollContainer.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + scrollContainer.addEventListener("touchend", handleTouchEnd, { + passive: true, + }); + scrollContainer.addEventListener("touchcancel", handleTouchEnd, { + passive: true, + }); + + return () => { + scrollContainer.removeEventListener("touchstart", handleTouchStart); + scrollContainer.removeEventListener("touchmove", handleTouchMove); + scrollContainer.removeEventListener("touchend", handleTouchEnd); + scrollContainer.removeEventListener("touchcancel", handleTouchEnd); + }; + }, [selectedSightData]); + + useEffect(() => { + if (!selectedSightId) { + setSelectedSightData(null); + setError(null); + setIsLoading(false); + setShouldAnimateIn(false); + setIsImageLoaded(false); + return; + } + + async function fetchData() { + if (!isLeftWidgetOpen) { + setIsLoading(true); + setError(null); + setIsImageLoaded(false); + setShouldAnimateIn(false); + } + + try { + const sight = + selectedLanguage === "ru" + ? routeSights.find((sight) => sight.id === selectedSightId) + : selectedLanguage === "en" + ? routeSightsEn.find((sight) => sight.id === selectedSightId) + : routeSightsZh.find((sight) => sight.id === selectedSightId); + + const leftArticle = sight.left_article; + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(leftArticle + "_" + selectedLanguage) + : selectedLanguage === "en" + ? sightArticlesEn.get(leftArticle + "_" + selectedLanguage) + : sightArticlesZh.get(leftArticle + "_" + selectedLanguage); + + const media = await ContentAPI.getMediaPreview( + leftArticleData.media[0].id, + selectedLanguage + ); + + const response = { + mediaPath: media.path, + mediaType: media.type, + title: sight.short_name || sight.name || leftArticleData.heading, + text: leftArticleData.body, + address: sight.address, + }; + + setSelectedSightData(response); + } catch (err) { + console.error("Ошибка отображения левого виджета:", err); + setError("Не удалось загрузить информацию о достопримечательности."); + setSelectedSightData(null); + setShouldAnimateIn(false); + } finally { + if (!isLeftWidgetOpen) { + setIsLoading(false); + } + } + } + + if (isLeftWidgetOpen && isVisible) { + fetchData(); + } else if (!isLeftWidgetOpen) { + setShouldAnimateIn(false); + } + }, [selectedSightId, isLeftWidgetOpen, selectedLanguage, isVisible]); + + const handleImageLoad = () => { + setIsImageLoaded(true); + if (isVisible) { + setTimeout(() => { + setShouldAnimateIn(true); + }, 50); + } + }; + + const handleImageError = () => { + setIsImageLoaded(false); + console.error( + "Ошибка загрузки изображения для достопримечательности:", + selectedSightId + ); + if (isVisible) { + setTimeout(() => { + setShouldAnimateIn(true); + }, 50); + } + }; + + const getTopPosition = () => { + if (!sightTop) return "500px"; + if (!widgetHeight) return `${sightTop}px`; + + const windowHeight = window.innerHeight; + const margin = 90; + const maxBottom = windowHeight - margin; + + const projectedBottom = sightTop + widgetHeight; + + if (projectedBottom > maxBottom) { + return `${maxBottom - widgetHeight}px`; + } + return `${sightTop}px`; + }; + + const widgetTransformStyle = { + top: getTopPosition(), + }; + + return ( +
+ {isLoading ? ( +
Загрузка информации...
+ ) : error ? ( +
{error}
+ ) : selectedSightData ? ( + <> + {selectedSightData.mediaType == 1 || + selectedSightData.mediaType == 3 ? ( + Sight image + ) : ( + <> + )} +
+
{selectedSightData.title}
+
+ {selectedSightData.address} +
+
+ {selectedSightData.text} +
+
+ + ) : (isVisible || selectedSightData) && !isLoading ? ( +
+ {selectedLanguage === "ru" + ? "Выберите достопримечательность для просмотра деталей." + : selectedLanguage === "zh" + ? "选择一个地标来查看详细信息。" + : "Select a landmark to view details."} +
+ ) : null} +
+ ); + } +); + +export default LeftWidget; diff --git a/src/client/src/components/side-menu/SideMenu.jsx b/src/client/src/components/side-menu/SideMenu.jsx new file mode 100644 index 0000000..0d38baf --- /dev/null +++ b/src/client/src/components/side-menu/SideMenu.jsx @@ -0,0 +1,794 @@ +import "../../styles/SideMenu.css"; +import AppealWidget from "../widgets/AppealWidget"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { observer } from "mobx-react-lite"; +import gouvermentImage from "../../assets/images/test-image.png"; +import sideMenuPhoto from "/side-menu-photo.png"; +import RouteWidget from "../widgets/RouteWidget"; +import ContentAPI from "../../api/content/content.api"; +import { useGeolocationStore, useColorStore } from "../../stores"; +import "../../styles/LeftWidget.css"; +import SightsList from "./SightsList"; +import StationsList from "./StationsList"; +import LeftWidget from "./LeftWidget"; +import { apiStore } from "../../api/ApiStore/store"; +import { getMediaUrl } from "../../api/apiConfig"; + +const SideMenu = observer(({ onMenuToggle }) => { + const { + carrier, + city, + route, + getArticle, + sightArticles, + sightArticlesEn, + sightArticlesZh, + routeSights, + routeSightsEn, + routeSightsZh, + } = apiStore; + const [isMenuOpen, openMenu] = useState(false); + const [isSightsOpen, openSights] = useState(false); + const [isStationOpen, openStation] = useState(false); + const [isWidgetOpen, openWidget] = useState(false); + const [isWeatherVisible, setIsWeatherVisible] = useState(true); + const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false); + const [localSelectedSightId, setLocalSelectedSightId] = useState(null); + const [isSightsAnimating, setIsSightsAnimating] = useState(false); + const [isStationAnimating, setIsStationAnimating] = useState(false); + const [shouldRenderSights, setShouldRenderSights] = useState(false); + const [shouldRenderStation, setShouldRenderStation] = useState(false); + const [sightTop, setSightTop] = useState(null); + const isSortingChangingRef = useRef(false); + + const store = useGeolocationStore(); + const { setIsGovernorWidgetOpen, sortingBy, setSortingBy } = store; + const colorStore = useColorStore(); + const { currentColor, startColorAnimation } = colorStore; + const { + contextData, + isGovernorWidgetOpen, + isLoading: isGeolocationLoading, + selectedLanguage, + setSelectedLanguage, + setIsLeftWidgetOpen, + nearestSightId, + selectedSightId, + isManualSelection, + setSelectedSightId, + setIsManualSelection, + setIsRightWidgetSelectorOpen, + isLeftWidgetOpen, + } = store; + + const sideMenuRef = useRef(null); + const sightsListRef = useRef(null); + const stationsListRef = useRef(null); + const activeTouches = useRef(new Map()); + + // Запуск анимации цвета при монтировании компонента + useEffect(() => { + startColorAnimation(); + }, [startColorAnimation]); + + useEffect(() => { + const menuContainer = sideMenuRef.current; + if (!menuContainer) return; + + const SCROLL_THRESHOLD = 10; + + const handleTouchStart = (e) => { + const sightsContainer = sightsListRef.current; + const stationsContainer = stationsListRef.current; + + for (const touch of e.changedTouches) { + let scrollTarget = null; + const targetElement = document.elementFromPoint( + touch.clientX, + touch.clientY + ); + + // Определяем, над каким из скролл-контейнеров находится палец + if (sightsContainer && sightsContainer.contains(targetElement)) { + scrollTarget = sightsContainer; + } else if ( + stationsContainer && + stationsContainer.contains(targetElement) + ) { + scrollTarget = stationsContainer; + } + + if (scrollTarget) { + activeTouches.current.set(touch.identifier, { + target: scrollTarget, + startY: touch.clientY, + lastY: touch.clientY, + isScrolling: false, // Флаг, что скролл еще не начался + }); + } + } + }; + + const handleTouchMove = (e) => { + for (const touch of e.changedTouches) { + const activeTouch = activeTouches.current.get(touch.identifier); + if (activeTouch) { + const deltaY = touch.clientY - activeTouch.startY; + + if (!activeTouch.isScrolling && Math.abs(deltaY) > SCROLL_THRESHOLD) { + activeTouch.isScrolling = true; + } + + if (activeTouch.isScrolling) { + e.preventDefault(); // Предотвращаем стандартное поведение только при скролле + const moveDelta = touch.clientY - activeTouch.lastY; + activeTouch.target.scrollTop -= moveDelta; + activeTouch.lastY = touch.clientY; + } + } + } + }; + + const handleTouchEnd = (e) => { + for (const touch of e.changedTouches) { + activeTouches.current.delete(touch.identifier); + } + }; + + menuContainer.addEventListener("touchstart", handleTouchStart, { + passive: true, + }); + menuContainer.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + menuContainer.addEventListener("touchend", handleTouchEnd, { + passive: true, + }); + menuContainer.addEventListener("touchcancel", handleTouchEnd, { + passive: true, + }); + + return () => { + menuContainer.removeEventListener("touchstart", handleTouchStart); + menuContainer.removeEventListener("touchmove", handleTouchMove); + menuContainer.removeEventListener("touchend", handleTouchEnd); + menuContainer.removeEventListener("touchcancel", handleTouchEnd); + }; + }, [isSightsOpen, isStationOpen]); // Перезапускаем эффект, когда списки появляются/исчезают + // --- КОНЕЦ: Улучшенная логика --- + + useEffect(() => { + if (isGovernorWidgetOpen) { + openWidget(true); + } else { + openWidget(false); + } + }, [isGovernorWidgetOpen]); + + useEffect(() => { + setIsLeftWidgetVisible(isMenuOpen && isLeftWidgetOpen); + }, [isMenuOpen, isLeftWidgetOpen]); + + useEffect(() => {}, [ + localSelectedSightId, + sightTop, + isLeftWidgetVisible, + isLeftWidgetOpen, + isMenuOpen, + ]); + + useEffect(() => { + if (isLeftWidgetVisible) { + setIsWeatherVisible(false); + } else { + setIsWeatherVisible(true); + } + }, [isLeftWidgetVisible]); + + const [designData, setDesignData] = useState(null); + const [routeData, setRouteData] = useState(null); + const [isLangMenuOpen, setIsLangMenuOpen] = useState(false); + + useEffect(() => { + async function fetchCarrierAndCrest() { + try { + const carrierPath = carrier.logo ? getMediaUrl(carrier.logo) : null; + const cityPath = city.arms ? getMediaUrl(city.arms) : null; + + const response = { + carrierPath: carrierPath, + creastPath: cityPath, + }; + setDesignData(response); + } catch (err) { + console.error("Ошибка в получении данных герба и перевозчика:", err); + } + } + fetchCarrierAndCrest(); + }, [contextData?.routeId, isGeolocationLoading, selectedLanguage]); + + useEffect(() => { + if (isGeolocationLoading || !contextData?.routeId) { + return; + } + async function fetchRoutePathData() { + try { + setRouteData(route); + } catch (err) { + console.error("Ошибка в получении данных маршрута:", err); + } + } + fetchRoutePathData(); + }, [contextData?.routeId, isGeolocationLoading, selectedLanguage]); + + useEffect(() => { + const fetchArticles = async () => { + if (route && route?.governor_appeal > 0) { + await getArticle(route?.governor_appeal, "ru"); + await getArticle(route?.governor_appeal, "en"); + await getArticle(route?.governor_appeal, "zh"); + } + }; + fetchArticles(); + }, [route, route?.governor_appeal]); + + const handleLanguageChange = (langCode) => { + setSelectedLanguage(langCode); + setIsLangMenuOpen(false); + }; + + const handleMenuToggle = (newMenuState) => { + openWidget(false); + store.closeGovernorModal(); + if (onMenuToggle) onMenuToggle(newMenuState); + if (newMenuState) { + openMenu(newMenuState); + } else { + setIsLeftWidgetVisible(false); + // Закрываем списки мгновенно + if (isSightsOpen) { + openSights(false); + setLocalSelectedSightId(null); + setSightTop(null); + setIsSightsAnimating(false); + setShouldRenderSights(false); + } + if (isStationOpen) { + openStation(false); + setIsStationAnimating(false); + setShouldRenderStation(false); + } + openMenu(newMenuState); + setIsLeftWidgetOpen(false); + } + }; + + useEffect(() => { + // Автоматическое закрытие сайд-меню после 45 секунд бездействия + let idleSeconds = 0; + + const checkIdle = () => { + idleSeconds += 1; + + if (idleSeconds >= 45 && isMenuOpen) { + handleMenuToggle(false); + } + }; + + 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); + }); + }; + }, [isMenuOpen, handleMenuToggle]); + + // Закрываем и открываем список достопримечательностей при изменении сортировки + const prevSortingByRef = useRef(sortingBy); + const isFirstRenderRef = useRef(true); + useEffect(() => { + // Пропускаем первый рендер + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + prevSortingByRef.current = sortingBy; + return; + } + + // Если список достопримечательностей открыт и сортировка изменилась, закрываем и открываем заново + if (isSightsOpen && prevSortingByRef.current !== sortingBy) { + // Блокируем обновление позиции виджета во время процесса + isSortingChangingRef.current = true; + + setIsSightsAnimating(true); + openSights(false); + + setTimeout(() => { + setIsSightsAnimating(false); + setShouldRenderSights(false); + + // Открываем список заново + setShouldRenderSights(true); + setTimeout(() => { + openSights(true); + // Разблокируем обновление позиции после полного открытия + setTimeout(() => { + isSortingChangingRef.current = false; + }, 350); + }, 10); + }, 300); + } + + prevSortingByRef.current = sortingBy; + }, [sortingBy, isSightsOpen]); + + return ( +
+
+ {designData?.creastPath && ( + Герб + )} + {carrier?.slogan && ( +
{carrier.slogan}
+ )} + {shouldRenderSights && ( + { + setIsSightsAnimating(true); + openSights(false); + setIsLeftWidgetVisible(false); + setTimeout(() => { + setIsLeftWidgetOpen(false); + setIsSightsAnimating(false); + setShouldRenderSights(false); + }, 300); + }} + setWeatherVisible={setIsWeatherVisible} + setLocalSelectedSightId={setLocalSelectedSightId} + localSelectedSightId={localSelectedSightId} + onSightTopChange={(top) => { + // Не обновляем позицию виджета во время смены сортировки + if (isSortingChangingRef.current) { + return; + } + setSightTop(top); + }} + onSightSelected={(sightId) => { + // Обработка выбора достопримечательности уже в компоненте + }} + /> + )} + {shouldRenderStation && ( + { + setIsStationAnimating(true); + openStation(false); + setIsLeftWidgetVisible(false); + setTimeout(() => { + setIsLeftWidgetOpen(false); + setIsStationAnimating(false); + setShouldRenderStation(false); + setIsWeatherVisible(true); + }, 300); + }} + onStationSelected={(stationId) => { + // Обработка выбора остановки уже в компоненте + }} + onSightClick={(sightId, top) => { + setLocalSelectedSightId(sightId); + if (top !== undefined) { + setSightTop(top); + } + setIsLeftWidgetOpen(true); + }} + /> + )} + {route?.governor_appeal > 0 && ( +
{ + openWidget(!isWidgetOpen); + store.setIsGovernorWidgetOpen(!isWidgetOpen); + }} + className="appeal-button" + > + {selectedLanguage == "ru" + ? "Обращение губернатора" + : selectedLanguage == "zh" + ? "州长致辞" + : "Governor's appeal"} +
+ )} +
+
{ + if (!isSightsOpen) { + if (isStationOpen) { + setIsStationAnimating(true); + openStation(false); + setTimeout(() => { + setIsStationAnimating(false); + setShouldRenderStation(false); + + setLocalSelectedSightId(null); + setSightTop(null); + setShouldRenderSights(true); + setTimeout(() => { + openSights(true); + }, 10); + }, 300); + } else { + setLocalSelectedSightId(null); + setSightTop(null); + setShouldRenderSights(true); + setTimeout(() => { + openSights(true); + }, 10); + } + openWidget(false); + store.closeGovernorModal(); + setIsLeftWidgetOpen(false); + } else { + setIsSightsAnimating(true); + openSights(false); + setIsLeftWidgetVisible(false); + setLocalSelectedSightId(null); + setSightTop(null); + setTimeout(() => { + setIsLeftWidgetOpen(false); + setIsSightsAnimating(false); + setShouldRenderSights(false); + }, 300); + } + }} + className={`side-menu-button side-menu-button--sights ${ + isSightsOpen ? "side-menu-button--active" : "" + }`} + > + {selectedLanguage == "ru" + ? "Достопримечательности" + : selectedLanguage == "zh" + ? "景点" + : "Attractions"} +
+
{ + if (!isStationOpen) { + if (isSightsOpen) { + setIsSightsAnimating(true); + openSights(false); + setIsLeftWidgetVisible(false); + setLocalSelectedSightId(null); + setSightTop(null); + setTimeout(() => { + setIsLeftWidgetOpen(false); + setIsSightsAnimating(false); + setShouldRenderSights(false); + setShouldRenderStation(true); + setTimeout(() => { + openStation(true); + }, 10); + }, 300); + } else { + setLocalSelectedSightId(null); + setSightTop(null); + setShouldRenderStation(true); + setTimeout(() => { + openStation(true); + }, 10); + } + openWidget(false); + store.closeGovernorModal(); + setIsLeftWidgetOpen(false); + } else { + setIsStationAnimating(true); + openStation(false); + setTimeout(() => { + setIsStationAnimating(false); + setShouldRenderStation(false); + }, 300); + } + }} + className={`side-menu-button ${ + isStationOpen ? "side-menu-button--active" : "" + }`} + > + {selectedLanguage == "ru" + ? "Остановки" + : selectedLanguage == "zh" + ? "车站" + : "Stations"} +
+
+
+ {/* {selectedLanguage == "ru" + ? "#ВсемПоПути" + : selectedLanguage == "zh" + ? "#每个人都在路上" + : "#EveryoneOnTheWay"} */} +
+
+
+ {designData?.carrierPath && ( + ГЭТ - Электротранспорт Санкт-Петербурга + )} + {carrier?.full_name && ( +
{carrier.short_name}
+ )} +
+ +
+
+
+ + + + +
+ { + handleMenuToggle(!isMenuOpen); + }} + width="48" + height="48" + viewBox="0 0 48 48" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + + + {isLangMenuOpen ? ( +
+ handleLanguageChange("ru")} + className={`side-menu-control-lang icon-color-animated ${ + selectedLanguage === "ru" ? "active" : "" + }`} + width="48" + height="48" + viewBox="0 0 48 48" + fill="none" + xmlns="http://www.w3.org/2000/svg" + style={{ "--animated-color": currentColor }} + > + + + + + handleLanguageChange("zh")} + className={`side-menu-control-lang icon-color-animated ${ + selectedLanguage === "zh" ? "active" : "" + }`} + width="48" + height="48" + viewBox="0 0 48 48" + style={{ "--animated-color": currentColor }} + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + + + + + handleLanguageChange("en")} + className={`side-menu-control-lang icon-color-animated ${ + selectedLanguage === "en" ? "active" : "" + }`} + width="48" + height="48" + viewBox="0 0 48 48" + style={{ "--animated-color": currentColor }} + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + +
+ ) : ( + { + setIsLangMenuOpen(true); + }} + width="48" + height="48" + style={{ "--animated-color": currentColor }} + viewBox="0 0 48 48" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + + )} + + {isSightsOpen && ( + { + setSortingBy(sortingBy === "asc" ? "desc" : "asc"); + }} + > + + + + + + + + + + )} +
+
+
+ { + setIsLeftWidgetVisible(false); + setTimeout(() => { + setIsLeftWidgetOpen(false); + }, 1000); + }} + isVisible={isLeftWidgetVisible} + sightTop={sightTop} + /> +
+
+ ); +}); + +export default SideMenu; diff --git a/src/client/src/components/side-menu/SightStationsList.jsx b/src/client/src/components/side-menu/SightStationsList.jsx new file mode 100644 index 0000000..d1a1eb5 --- /dev/null +++ b/src/client/src/components/side-menu/SightStationsList.jsx @@ -0,0 +1,287 @@ +import { + useEffect, + useState, + forwardRef, + useRef, + useLayoutEffect, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { useClickDetection } from "../../hooks/useClickDetection"; +import { OverlayScrollbarsWrapper } from "../OverlayScrollbarsWrapper"; +import "../../styles/OverlayScrollbars.css"; +import { getSightStations } from "../../api/ApiStore/api"; +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 StationItem = ({ + station, + handlePointerDown, + handlePointerUp, + handleStationClick, + selectedStationId, + transferIcons, + getNoTransfersMessage, + isLast, +}) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const threshold = containerWidth * 1.1; + setShouldAnimate(textWidth > threshold); + } + }; + + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [station.name]); + + return ( +
+
handlePointerDown(e, station.id)} + onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)} + title={station.name} + > + + {station.name} + +
+
+ {station.transfers != null && + !Object.values(station.transfers).every((value) => value === "") ? ( + Object.entries(station.transfers).map( + ([transferType, transferValue]) => { + const IconComponent = transferIcons[transferType]; + + if (transferValue && transferValue.length > 0) { + return ( +
+ {IconComponent && ( + {`${transferType} + )} + {transferValue} +
+ ); + } + return null; + } + ) + ) : ( +
+ {getNoTransfersMessage()} +
+ )} +
+
+ ); +}; + +const SightStationsList = observer( + forwardRef( + ({ style, className, onClose, sightId, selectedLanguage }, ref) => { + const [stationList, setStationList] = useState([]); + const [error, setError] = useState(null); + const [isStationListLoading, setIsStationListLoading] = useState(true); + const [selectedStationId, setSelectedStationId] = useState(null); + + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + const getNoTransfersMessage = () => { + if (selectedLanguage === "ru") return "Нет пересадок"; + if (selectedLanguage === "zh") return "没有转移"; + return "No transfers"; + }; + + const transferIcons = { + metro_red: metroRedIcon, + metro_green: metroGreenIcon, + metro_blue: metroBlueIcon, + metro_orange: metroOrangeIcon, + metro_purple: metroPurpleIcon, + tram: tramIcon, + trolleybus: trolleyIcon, + bus: busIcon, + train: trainIcon, + }; + + useEffect(() => { + async function fetchStationList() { + if (!sightId) { + setIsStationListLoading(false); + return; + } + + setIsStationListLoading(true); + setError(null); + try { + const response = await getSightStations(sightId, selectedLanguage); + + const sortedStations = [...(response || [])].sort((a, b) => { + const nameA = a.name.trim(); + const nameB = b.name.trim(); + + return nameA.localeCompare( + nameB, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + } + ); + }); + + setStationList(sortedStations); + } catch (err) { + setError( + "Не удалось получить остановки. Пожалуйста, попробуйте позже." + ); + setStationList([]); + } finally { + setIsStationListLoading(false); + } + } + + fetchStationList(); + }, [sightId, selectedLanguage]); + + const handleStationClick = (stationId) => { + setSelectedStationId((prevId) => + prevId === stationId ? null : stationId + ); + }; + + return ( +
+
{ + if (onClose) { + onClose(); + } + }} + > +

+ {selectedLanguage === "ru" + ? "Остановки" + : selectedLanguage === "zh" + ? "车站" + : "Stations"} +

+ + + + + + + + + + +
+ + {isStationListLoading ? ( +
+ {selectedLanguage === "ru" + ? "Загрузка остановок..." + : selectedLanguage === "zh" + ? "加载停止..." + : "Loading stops..."} +
+ ) : error ? ( +
{error}
+ ) : stationList.length === 0 ? ( +
+ {selectedLanguage === "ru" + ? "Остановки не найдены" + : selectedLanguage === "zh" + ? "没有找到停靠站" + : "No stops found"} +
+ ) : ( + stationList.map((station, index) => ( + + )) + )} +
+
+ ); + } + ) +); + +export default SightStationsList; diff --git a/src/client/src/components/side-menu/SightsList.jsx b/src/client/src/components/side-menu/SightsList.jsx new file mode 100644 index 0000000..41932af --- /dev/null +++ b/src/client/src/components/side-menu/SightsList.jsx @@ -0,0 +1,477 @@ +import { + useEffect, + useState, + forwardRef, + useRef, + useLayoutEffect, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { useClickDetection } from "../../hooks/useClickDetection"; +import { TouchableLayout } from "../TouchableLayout"; +import { getMediaUrl } from "../../api/apiConfig"; +import stationIcon from "../../assets/transport-icons/station.svg"; + +export const isMediaIdEmpty = (id) => { + if (id == null || id === "") return true; + const digits = id.replace(/-/g, ""); + return digits === "" || /^0+$/.test(digits); +}; + +const SightItem = ({ + sight, + handlePointerDown, + handlePointerUp, + handleSightClick, + localSelectedSightId, + selectedLanguage, + sightArticles, + sightArticlesEn, + sightArticlesZh, + selectedSightId, + onSightSelected, + sightStationsCache, +}) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + // Получаем название для отображения + const getSightName = () => { + // Если есть короткое название, используем его + if (sight.short_name) { + return sight.short_name; + } + + if (!sight.left_article) { + return sight.name; + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(sight.left_article + "_ru") + : selectedLanguage === "en" + ? sightArticlesEn.get(sight.left_article + "_en") + : sightArticlesZh.get(sight.left_article + "_zh"); + + return leftArticleData?.heading || sight.name; + }; + + const sightName = getSightName(); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const shouldAnimateValue = textWidth > containerWidth; + setShouldAnimate(shouldAnimateValue); + + // Устанавливаем CSS переменную для анимации + if (shouldAnimateValue && containerRef.current) { + containerRef.current.style.setProperty( + "--container-width", + `${containerWidth}px`, + ); + } + } + }; + + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [sightName]); + + const handleClick = (e) => { + const newExpanded = !isExpanded; + setIsExpanded(newExpanded); + + // Всегда открываем виджет при клике на достопримечательность + handleSightClick(sight.id); + }; + + const cacheKey = `${sight.id}_${selectedLanguage}`; + const stations = sightStationsCache.get(cacheKey) || []; + + return ( +
+
handlePointerDown(e, sight.id)} + onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)} + className={`side-menu-sight pointer ${ + localSelectedSightId === sight.id ? "selected" : "" + }`} + title={sightName} + > + + {sightName} + +
+
+ {stations.length > 0 ? ( + stations.map((station, index) => { + const iconSrc = isMediaIdEmpty(station.icon) + ? stationIcon + : getMediaUrl(station.icon); + + return ( +
+ + {station.name} +
+ ); + }) + ) : ( +
+ {selectedLanguage === "ru" + ? "Нет остановок" + : selectedLanguage === "zh" + ? "没有站" + : "No stations"} +
+ )} +
+
+ ); +}; + +const SightsList = observer( + forwardRef( + ( + { + style, + className, + onCloseSights, + setWeatherVisible, + setLocalSelectedSightId, + localSelectedSightId, + onSightTopChange, + onSightSelected, + }, + ref, + ) => { + const containerRef = useRef(null); + const store = useGeolocationStore(); + const { + sortingBy, + contextData, + isLoading: isGeolocationLoading, + selectedLanguage, + isLeftWidgetOpen, + setIsLeftWidgetOpen, + } = store; + + useLayoutEffect(() => { + if (containerRef.current) { + if (className === "slide-in") { + // Применяем стили для slide-in + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.style.transform = "translateY(0)"; + containerRef.current.style.opacity = "1"; + } + }); + } else if (className === "slide-out") { + // Очищаем inline стили, чтобы CSS transition работал + containerRef.current.style.transform = ""; + // Принудительно применяем reflow для запуска transition + void containerRef.current.offsetHeight; + } + } + }, [className]); + const { + routeSights, + routeSightsEn, + routeSightsZh, + sightArticles, + sightArticlesEn, + sightArticlesZh, + sightStationsCache, + } = apiStore; + const [sightList, setSightList] = useState([]); + const [error, setError] = useState(null); + const [isSightListLoading, setIsSightListLoading] = useState(true); + + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + useEffect(() => { + async function fetchSightList() { + setIsSightListLoading(true); + setError(null); + try { + const sights = + selectedLanguage === "ru" + ? routeSights + : selectedLanguage === "en" + ? routeSightsEn + : routeSightsZh; + // Функция для получения названия для сортировки + const getSightNameForSort = (sight) => { + if (sight.short_name) { + return sight.short_name.trim(); + } + + if (!sight.left_article) { + return sight.name.trim(); + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(sight.left_article + "_ru") + : selectedLanguage === "en" + ? sightArticlesEn.get(sight.left_article + "_en") + : sightArticlesZh.get(sight.left_article + "_zh"); + + return (leftArticleData?.heading || sight.name).trim(); + }; + + const sortedSights = [...(sights || [])].sort((a, b) => { + const nameA = getSightNameForSort(a); + const nameB = getSightNameForSort(b); + + if (sortingBy === "asc") { + return nameA.localeCompare( + nameB, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + }, + ); + } else { + return nameB.localeCompare( + nameA, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + }, + ); + } + }); + setSightList(sortedSights); + } catch (err) { + setError( + "Не удалось получить достопримечательности. Пожалуйста, попробуйте позже.", + ); + setSightList([]); + setIsLeftWidgetOpen(false); + } finally { + setIsSightListLoading(false); + } + } + + fetchSightList(); + }, [ + contextData?.routeId, + isGeolocationLoading, + selectedLanguage, + sortingBy, + setIsLeftWidgetOpen, + setLocalSelectedSightId, + sightArticles, + sightArticlesEn, + sightArticlesZh, + routeSights, + routeSightsEn, + routeSightsZh, + ]); + + const measureSelectedSight = () => { + if (localSelectedSightId && onSightTopChange) { + const element = document.getElementById( + `sight-${localSelectedSightId}`, + ); + if (element && containerRef.current) { + const elementRect = element.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + + // Используем позицию элемента относительно viewport (elementRect.top) + // чтобы верхняя граница виджета совпадала с верхней границей элемента + const elementTop = elementRect.top; + onSightTopChange(elementTop); + } else { + console.warn( + "[SightsList] Element or container not found:", + `sight-${localSelectedSightId}`, + { element: !!element, container: !!containerRef.current }, + ); + } + } + }; + + useEffect(() => { + if (!localSelectedSightId) return; + + // Run measurement after render/update + // Используем requestAnimationFrame для точности измерения после рендеринга + const timer = setTimeout(() => { + requestAnimationFrame(() => { + requestAnimationFrame(measureSelectedSight); + }); + }, 0); + + return () => clearTimeout(timer); + }, [localSelectedSightId, sightList, className]); + + const handleSightClick = (sightId) => { + // Открываем левый виджет + if (isLeftWidgetOpen && localSelectedSightId !== sightId) { + setLocalSelectedSightId(sightId); + setIsLeftWidgetOpen(true); + if (onSightSelected) { + onSightSelected(sightId); + } + } else if (!isLeftWidgetOpen) { + setLocalSelectedSightId(sightId); + setIsLeftWidgetOpen(true); + if (onSightSelected) { + onSightSelected(sightId); + } + } else { + // Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок + if (onSightSelected) { + onSightSelected(sightId); + } + } + }; + + const handleClose = () => { + setIsLeftWidgetOpen(false); + setWeatherVisible(true); + onCloseSights(); + }; + + return ( +
+
{ + handleClose(); + }} + > +

+ {" "} + {selectedLanguage === "ru" + ? "Достопримечательности" + : selectedLanguage === "zh" + ? "景点" + : "Attractions"} +

+ + + + + + + + + + + +
+ + {isSightListLoading ? ( +
+ {selectedLanguage === "ru" + ? "Загрузка..." + : selectedLanguage === "zh" + ? "正在加载..." + : "Loading..."} +
+ ) : error ? ( +
{error}
+ ) : sightList.length === 0 ? ( +
+ {selectedLanguage === "ru" + ? "Ничего не найдено" + : selectedLanguage === "zh" + ? "没有找到" + : "Nothing found"} +
+ ) : ( + sightList.map((sight) => ( + + )) + )} +
+
+ ); + }, + ), +); + +export default SightsList; diff --git a/src/client/src/components/side-menu/StationSightsList.jsx b/src/client/src/components/side-menu/StationSightsList.jsx new file mode 100644 index 0000000..58887d0 --- /dev/null +++ b/src/client/src/components/side-menu/StationSightsList.jsx @@ -0,0 +1,313 @@ +import { + useEffect, + useState, + forwardRef, + useRef, + useLayoutEffect, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { useClickDetection } from "../../hooks/useClickDetection"; +import { OverlayScrollbarsWrapper } from "../OverlayScrollbarsWrapper"; +import "../../styles/OverlayScrollbars.css"; +import { getStationSights } from "../../api/ApiStore/api"; + +const SightItem = ({ + sight, + handlePointerDown, + handlePointerUp, + handleSightClick, + localSelectedSightId, + selectedLanguage, + sightArticles, + sightArticlesEn, + sightArticlesZh, +}) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + + // Получаем название из left_article + const getSightName = () => { + if (!sight.left_article) { + return sight.name; + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(sight.left_article + "_ru") + : selectedLanguage === "en" + ? sightArticlesEn.get(sight.left_article + "_en") + : sightArticlesZh.get(sight.left_article + "_zh"); + + return leftArticleData?.heading || sight.name; + }; + + const sightName = getSightName(); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const threshold = containerWidth; + setShouldAnimate(textWidth > threshold); + } + }; + + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [sightName]); + + return ( +
handlePointerDown(e, sight.id)} + onPointerUp={(e) => handlePointerUp(e, sight.id, handleSightClick)} + className={`side-menu-sight pointer ${ + localSelectedSightId === sight.id ? "selected" : "" + }`} + title={sightName} + > + + {sightName} + +
+ ); +}; + +const StationSightsList = observer( + forwardRef( + ( + { + style, + className, + onClose, + stationId, + selectedLanguage, + setLocalSelectedSightId, + setIsLeftWidgetOpen, + isLeftWidgetOpen, + localSelectedSightId, + onSightTopChange, + onSightSelected, + }, + ref + ) => { + const { sightArticles, sightArticlesEn, sightArticlesZh } = apiStore; + const [sightList, setSightList] = useState([]); + const [error, setError] = useState(null); + const [isSightListLoading, setIsSightListLoading] = useState(true); + + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + useEffect(() => { + async function fetchSightList() { + if (!stationId) { + setIsSightListLoading(false); + return; + } + + setIsSightListLoading(true); + setError(null); + try { + const response = await getStationSights( + stationId, + selectedLanguage + ); + + // Функция для получения названия из left_article + const getSightNameForSort = (sight) => { + if (!sight.left_article) { + return sight.name.trim(); + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(sight.left_article + "_ru") + : selectedLanguage === "en" + ? sightArticlesEn.get(sight.left_article + "_en") + : sightArticlesZh.get(sight.left_article + "_zh"); + + return (leftArticleData?.heading || sight.name).trim(); + }; + + const sortedSights = [...(response || [])].sort((a, b) => { + const nameA = getSightNameForSort(a); + const nameB = getSightNameForSort(b); + + return nameA.localeCompare( + nameB, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + } + ); + }); + setSightList(sortedSights); + } catch (err) { + setError( + "Не удалось получить достопримечательности. Пожалуйста, попробуйте позже." + ); + setSightList([]); + } finally { + setIsSightListLoading(false); + } + } + + fetchSightList(); + }, [ + stationId, + selectedLanguage, + sightArticles, + sightArticlesEn, + sightArticlesZh, + ]); + + const measureSelectedSight = () => { + if (localSelectedSightId && onSightTopChange) { + const element = document.getElementById( + `sight-${localSelectedSightId}` + ); + if (element) { + const rect = element.getBoundingClientRect(); + onSightTopChange(rect.top); + } + } + }; + + useEffect(() => { + const timer = setTimeout(measureSelectedSight, 0); + return () => clearTimeout(timer); + }, [localSelectedSightId, sightList]); + + const handleSightClick = (sightId) => { + // Открываем левый виджет + if (isLeftWidgetOpen && localSelectedSightId !== sightId) { + setTimeout(() => { + setLocalSelectedSightId(sightId); + setIsLeftWidgetOpen(true); + if (onSightSelected) { + onSightSelected(sightId); + } + }, 0); + } else if (!isLeftWidgetOpen) { + setLocalSelectedSightId(sightId); + setIsLeftWidgetOpen(true); + if (onSightSelected) { + onSightSelected(sightId); + } + } else { + if (onSightSelected) { + onSightSelected(sightId); + } + } + }; + + return ( +
+
{ + if (onClose) { + onClose(); + } + }} + > +

+ {selectedLanguage === "ru" + ? "Достопримечательности" + : selectedLanguage === "zh" + ? "景点" + : "Attractions"} +

+ + + + + + + + + + +
+ { + handleScroll(e); + measureSelectedSight(); + }} + > + {isSightListLoading ? ( +
+ {selectedLanguage === "ru" + ? "Загрузка..." + : selectedLanguage === "zh" + ? "正在加载..." + : "Loading..."} +
+ ) : error ? ( +
{error}
+ ) : sightList.length === 0 ? ( +
+ {selectedLanguage === "ru" + ? "Ничего не найдено" + : selectedLanguage === "zh" + ? "没有找到" + : "Nothing found"} +
+ ) : ( + sightList.map((sight) => ( + + )) + )} +
+
+ ); + } + ) +); + +export default StationSightsList; diff --git a/src/client/src/components/side-menu/StationsList.jsx b/src/client/src/components/side-menu/StationsList.jsx new file mode 100644 index 0000000..4d4e709 --- /dev/null +++ b/src/client/src/components/side-menu/StationsList.jsx @@ -0,0 +1,368 @@ +import { + useEffect, + useState, + forwardRef, + useRef, + useLayoutEffect, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { useClickDetection } from "../../hooks/useClickDetection"; +import { TouchableLayout } from "../TouchableLayout"; + +const StationItem = ({ + station, + handlePointerDown, + handlePointerUp, + handleStationClick, + selectedStationId, + selectedLanguage, + stationSightsCache, + sightArticles, + sightArticlesEn, + sightArticlesZh, + onSightClick, +}) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const threshold = containerWidth * 1.1; // 110% от ширины контейнера + const shouldAnimateValue = textWidth > threshold; + setShouldAnimate(shouldAnimateValue); + + // Устанавливаем CSS переменную для анимации + if (shouldAnimateValue && containerRef.current) { + containerRef.current.style.setProperty( + "--container-width", + `${containerWidth}px`, + ); + } + } + }; + + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [station.name]); + + const cacheKey = `${station.id}_${selectedLanguage}`; + const sights = stationSightsCache.get(cacheKey) || []; + + const getSightName = (sight) => { + if (sight.short_name) { + return sight.short_name; + } + + if (!sight.left_article) { + return sight.name; + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(sight.left_article + "_ru") + : selectedLanguage === "en" + ? sightArticlesEn.get(sight.left_article + "_en") + : sightArticlesZh.get(sight.left_article + "_zh"); + + return leftArticleData?.heading || sight.name; + }; + + return ( +
+
handlePointerDown(e, station.id)} + onPointerUp={(e) => { + // Если у станции нет достопримечательностей, не открываем список + if (sights.length > 0) { + handlePointerUp(e, station.id, () => + handleStationClick(station.id), + ); + } + }} + title={station.name} + > + + {station.name} + +
+
+ {sights.length > 0 ? ( + sights.map((sight, index) => ( +
{ + e.stopPropagation(); + if (onSightClick) { + // Вычисляем позицию элемента для правильного позиционирования левого виджета + const element = e.currentTarget; + const elementRect = element.getBoundingClientRect(); + + // Используем позицию элемента относительно viewport (elementRect.top) + // чтобы верхняя граница виджета совпадала с верхней границей элемента + const elementTop = elementRect.top; + onSightClick(sight.id, elementTop); + } + }} + > + {getSightName(sight)} +
+ )) + ) : ( +
+ {selectedLanguage === "ru" + ? "Нет достопримечательностей" + : selectedLanguage === "zh" + ? "没有景点" + : "No sights"} +
+ )} +
+
+ ); +}; + +const StationsList = observer( + forwardRef( + ( + { + style, + className, + onCloseStation, + isOpen, + onStationSelected, + onSightClick, + setWeatherVisible, + }, + ref, + ) => { + const containerRef = useRef(null); + const store = useGeolocationStore(); + const { + contextData, + isLoading: isGeolocationLoading, + selectedLanguage, + isLeftWidgetOpen, + setIsLeftWidgetOpen, + } = store; + const [stationList, setStationList] = useState([]); + const [error, setError] = useState(null); + const [isStationListLoading, setIsStationListLoading] = useState(true); + const [selectedStationId, setSelectedStationId] = useState(null); + + useLayoutEffect(() => { + if (containerRef.current) { + if (className === "slide-in") { + // Применяем стили для slide-in + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.style.transform = "translateY(0)"; + containerRef.current.style.opacity = "1"; + } + }); + } else if (className === "slide-out") { + // Очищаем inline стили, чтобы CSS transition работал + containerRef.current.style.transform = ""; + // Принудительно применяем reflow для запуска transition + void containerRef.current.offsetHeight; + } + } + }, [className]); + const { + routeStations, + routeStationsEn, + routeStationsZh, + stationSightsCache, + sightArticles, + sightArticlesEn, + sightArticlesZh, + } = apiStore; + + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + useEffect(() => { + async function fetchStationList() { + setIsStationListLoading(true); + setError(null); + try { + const response = + selectedLanguage === "ru" + ? routeStations + : selectedLanguage === "en" + ? routeStationsEn + : routeStationsZh; + + // Сортировка по умолчанию по возрастанию (не зависит от sortingBy) + const sortedStations = [...(response || [])].sort((a, b) => { + const nameA = a.name.trim(); + const nameB = b.name.trim(); + + return nameA.localeCompare( + nameB, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + }, + ); + }); + + setStationList(sortedStations); + } catch (err) { + setError( + "Не удалось получить остановки. Пожалуйста, попробуйте позже.", + ); + setStationList([]); + } finally { + setIsStationListLoading(false); + } + } + + fetchStationList(); + }, [ + contextData?.routeId, + isGeolocationLoading, + selectedLanguage, + routeStations, + routeStationsEn, + routeStationsZh, + ]); + + const handleStationClick = (stationId) => { + const newSelectedId = + selectedStationId === stationId ? null : stationId; + setSelectedStationId(newSelectedId); + + // Если остановка выбрана, вызываем callback для открытия списка достопримечательностей + if (newSelectedId !== null && onStationSelected) { + onStationSelected(newSelectedId); + } + }; + + const handleClose = () => { + setIsLeftWidgetOpen(false); + setWeatherVisible(true); + onCloseStation(); + }; + + return ( +
+
{ + handleClose(); + }} + > +

+ {selectedLanguage === "ru" + ? "Остановки" + : selectedLanguage === "zh" + ? "车站" + : "Stations"} +

+ + + + + + + + + + +
+ + {isStationListLoading ? ( +
+ {selectedLanguage === "ru" + ? "Загрузка остановок..." + : selectedLanguage === "zh" + ? "加载停止..." + : "Loading stops..."} +
+ ) : error ? ( +
{error}
+ ) : stationList.length === 0 ? ( +
+ {selectedLanguage === "ru" + ? "Остановки не найдены" + : selectedLanguage === "zh" + ? "没有找到停靠站" + : "No stops found"} +
+ ) : ( + stationList.map((station) => ( + + )) + )} +
+
+ ); + }, + ), +); + +export default StationsList; diff --git a/src/client/src/components/widgets/AppealWidget.jsx b/src/client/src/components/widgets/AppealWidget.jsx new file mode 100644 index 0000000..9b46505 --- /dev/null +++ b/src/client/src/components/widgets/AppealWidget.jsx @@ -0,0 +1,13 @@ +import '../../styles/AppealWidget.css' + +function AppealWidget({widgetImgPath, widgetLabel, widgetText, style}) { + return ( +
+ +
{widgetLabel}
+
{widgetText}
+
+ ); +} + +export default AppealWidget diff --git a/src/client/src/components/widgets/PanoramView.tsx b/src/client/src/components/widgets/PanoramView.tsx new file mode 100644 index 0000000..5116112 --- /dev/null +++ b/src/client/src/components/widgets/PanoramView.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { Viewer, ViewerConfig, AnimateOptions, CssSize, ExtendedPosition, UpdatableViewerConfig, events, PluginConstructor, NavbarCustomButton, TooltipConfig, Tooltip, Position, Size, PanoramaOptions, utils, AbstractPlugin } from "@photo-sphere-viewer/core"; +import "./styles.css"; +import "@photo-sphere-viewer/core/index.css"; + +type MakeOptional = Omit & Partial>; +export interface CubeMapSrc { + left: string; + front: string; + right: string; + back: string; + top: string; + bottom: string; +} +export interface TilesAdapterSrc { + width: number; + cols: number; + rows: number; + baseUrl: string; + tileUrl: (col: number, row: number) => string; +} +type PluginEntry = PluginConstructor | [PluginConstructor, Record]; +export type PluginConfig = PluginEntry; +/** + * Props interface for the Viewer component. + * + * @interface + * @property {string} src - The source of the image to be viewed. + * @property {boolean | string | Array} [navbar] - Configuration for the navbar. Can be a boolean, string, or an array of strings or NavbarCustomButton. + * @property {string} height - The height of the viewer. + * @property {string} [width] - The width of the viewer. + * @property {string} [containerClass] - The CSS class for the viewer container. + * @property {boolean} [littlePlanet] - Enable or disable the little planet effect. + * @property {boolean | number} [fishEye] - Enable or disable the fisheye effect, or set the fisheye level. + * @property {boolean} [hideNavbarButton] - Show/hide the button that hides the navbar. + * @property {Object} [lang] - Language configuration for the viewer. Each property is a string that represents the text for a specific action. + * @property {Function} [onPositionChange] - Event handler for when the position changes. Receives the latitude, longitude, and the Viewer instance. + * @property {Function} [onZoomChange] - Event handler for when the zoom level changes. Receives the ZoomUpdatedEvent and the Viewer instance. + * @property {Function} [onClick] - Event handler for when the viewer is clicked. Receives the ClickEvent and the Viewer instance. + * @property {Function} [onDblclick] - Event handler for when the viewer is double clicked. Receives the ClickEvent and the Viewer instance. + * @property {Function} [onReady] - Event handler for when the viewer is ready. Receives the Viewer instance. + */ +export interface Props extends MakeOptional { + src: string | CubeMapSrc | TilesAdapterSrc; + navbar?: boolean | string | Array; + height: string; + width?: string; + containerClass?: string; + littlePlanet?: boolean; + fishEye?: boolean | number; + hideNavbarButton?: boolean; + lang?: Record; + plugins?: PluginEntry[]; + onPositionChange?(lat: number, lng: number, instance: Viewer): void; + onZoomChange?(data: events.ZoomUpdatedEvent & { + type: "zoom-updated"; + }, instance: Viewer): void; + onClick?(data: events.ClickEvent & { + type: "click"; + }, instance: Viewer): void; + onDblclick?(data: events.ClickEvent & { + type: "dblclick"; + }, instance: Viewer): void; + onReady?(instance: Viewer): void; +} +/** + * Interface for the Viewer API. + * + * @interface + * @property {Function} animate - Starts an animation. Receives an object of AnimateOptions. + * @property {Function} destroy - Destroys the viewer. + * @property {Function} createTooltip - Creates a tooltip. Receives a TooltipConfig object. + * @property {Function} needsContinuousUpdate - Enables or disables continuous updates. Receives a boolean. + * @property {Function} observeObjects - Starts observing objects. Receives a string key. + * @property {Function} unobserveObjects - Stops observing objects. Receives a string key. + * @property {Function} setCursor - Sets the cursor. Receives a string. + * @property {Function} stopAnimation - Stops the current animation. Returns a Promise. + * @property {Function} rotate - Rotates the viewer. Receives an ExtendedPosition object. + * @property {Function} setOption - Sets a single option. Receives an option key and a value. + * @property {Function} setOptions - Sets multiple options. Receives an object of options. + * @property {Function} getCurrentNavbar - Returns the current navbar. + * @property {Function} zoom - Sets the zoom level. Receives a number. + * @property {Function} zoomIn - Increases the zoom level. Receives a number. + * @property {Function} zoomOut - Decreases the zoom level. Receives a number. + * @property {Function} resize - Resizes the viewer. Receives a CssSize object. + * @property {Function} enterFullscreen - Enters fullscreen mode. + * @property {Function} exitFullscreen - Exits fullscreen mode. + * @property {Function} toggleFullscreen - Toggles fullscreen mode. + * @property {Function} isFullscreenEnabled - Returns whether fullscreen is enabled. + * @property {Function} getPlugin - Returns a plugin. Receives a plugin ID or a PluginConstructor. + * @property {Function} getPosition - Returns the current position. + * @property {Function} getZoomLevel - Returns the current zoom level. + * @property {Function} getSize - Returns the current size. + * @property {Function} needsUpdate - Updates the viewer. + * @property {Function} autoSize - Sets the size to auto. + * @property {Function} setPanorama - Sets the panorama. Receives a path and an optional PanoramaOptions object. Returns a Promise. + * @property {Function} showError - Shows an error message. Receives a string. + * @property {Function} hideError - Hides the error message. + * @property {Function} startKeyboardControl - Starts keyboard control. + * @property {Function} stopKeyboardControl - Stops keyboard control. + */ +export interface ViewerAPI { + animate(options: AnimateOptions): utils.Animation; + destroy(): void; + createTooltip(config: TooltipConfig): Tooltip; + needsContinuousUpdate(enabled: boolean): void; + observeObjects(userDataKey: string): void; + unobserveObjects(userDataKey: string): void; + setCursor(cursor: string): void; + stopAnimation(): PromiseLike; + rotate(position: ExtendedPosition): void; + setOption(option: T, value: UpdatableViewerConfig[T]): void; + setOptions(options: Partial): void; + getCurrentNavbar(): (string | object)[] | void; + zoom(value: number): void; + zoomIn(step: number): void; + zoomOut(step: number): void; + resize(size: CssSize): void; + enterFullscreen(): void; + exitFullscreen(): void; + toggleFullscreen(): void; + isFullscreenEnabled(): boolean | void; + /** + * Returns the instance of a plugin if it exists + * @example By plugin identifier + * ```js + * viewer.getPlugin('markers') + * ``` + * @example By plugin class with TypeScript support + * ```ts + * viewer.getPlugin(MarkersPlugin) + * ``` + */ + getPlugin>(pluginId: string | PluginConstructor): T; + getPosition(): Position; + getZoomLevel(): number; + getSize(): Size; + needsUpdate(): void; + autoSize(): void; + setPanorama(path: any, options?: PanoramaOptions): Promise; + showError(message: string): void; + hideError(): void; + startKeyboardControl(): void; + stopKeyboardControl(): void; +} +declare const PanoramView: React.ForwardRefExoticComponent & React.RefAttributes>; +export { PanoramView }; diff --git a/src/client/src/components/widgets/RouteWidget.jsx b/src/client/src/components/widgets/RouteWidget.jsx new file mode 100644 index 0000000..97ef721 --- /dev/null +++ b/src/client/src/components/widgets/RouteWidget.jsx @@ -0,0 +1,125 @@ +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; +import "../../styles/RouteWidget.css"; +import { useGeolocationStore } from "../../stores"; +import ContentAPI from "../../api/content/content.api"; +import { apiStore } from "../../api/ApiStore/store"; + +const RouteWidget = observer(() => { + const store = useGeolocationStore(); + const { contextData, isLoading, error, selectedLanguage } = store; + const { routeStations, routeStationsEn, routeStationsZh, context, route } = apiStore; + const [startStation, setStartStation] = useState(null); + const [startStationEn, setStartStationEn] = useState(null); + const [endStation, setEndStation] = useState(null); + const [endStationEn, setEndStationEn] = useState(null); + const [startStationZh, setStartStationZh] = useState(null); + const [endStationZh, setEndStationZh] = useState(null); + + useEffect(() => { + async function fetchStartStation() { + try { + const data = routeStations.find( + (station) => station.id == context?.startStopId, + ); + + const dataEn = routeStationsEn.find( + (station) => station.id == context?.startStopId, + ); + const dataZh = routeStationsZh.find( + (station) => station.id == context?.startStopId, + ); + setStartStation(data); + setStartStationEn(dataEn); + setStartStationZh(dataZh); + } catch (err) { + console.error("Ошибка при загрузке начальной станции", err); + } + } + + fetchStartStation(); + }, [context?.startStopId, isLoading, selectedLanguage]); + + useEffect(() => { + async function fetchEndStation() { + try { + const data = routeStations.find( + (station) => station.id == context?.endStopId, + ); + const dataEn = routeStationsEn.find( + (station) => station.id == context?.endStopId, + ); + const dataZh = routeStationsZh.find( + (station) => station.id == context?.endStopId, + ); + setEndStation(data); + setEndStationEn(dataEn); + setEndStationZh(dataZh); + } catch (err) { + console.error("Ошибка при загрузке конечной станции", err); + } + } + + fetchEndStation(); + }, [context?.endStopId, isLoading, selectedLanguage]); + + const shouldAnimate = (text, maxLength) => text?.length > maxLength; + const getLabelSizeClass = (text) => { + const length = text?.length || 0; + + if (length <= 40) return ""; + if (length <= 60) return "route-widget-label--medium"; + if (length <= 80) return "route-widget-label--small"; + + return "route-widget-label--xsmall"; + }; + const routeEnSubtitle = `${startStationEn?.name} - ${endStationEn?.name}`; + const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`; + return ( +
+
+ {route?.route_sys_number || context?.routeNumber || ""} +
+
+
+ {startStation?.name} +
+
+ {endStation?.name} +
+ {(selectedLanguage === "en" || selectedLanguage === "ru") && ( +
+ {routeEnSubtitle} +
+ )} + {selectedLanguage === "zh" && ( +
+ {routeZhSubtitle} +
+ )} +
+
+ ); +}); + +export default RouteWidget; diff --git a/src/client/src/components/widgets/ThreeView.tsx b/src/client/src/components/widgets/ThreeView.tsx new file mode 100644 index 0000000..79abd24 --- /dev/null +++ b/src/client/src/components/widgets/ThreeView.tsx @@ -0,0 +1,293 @@ +import { Canvas, useThree } from "@react-three/fiber"; +import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; +import React, { useEffect, Suspense } from "react"; +import { BACKGROUND_COLOR } from "../../assets/Constants"; +import * as THREE from "three"; +import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; + +export interface ThreeViewHandle { + zoomIn: () => void; + zoomOut: () => void; +} + +interface ThreeViewProps { + fileUrl: string; + width?: string; + height?: string; + onLoad?: () => void; + onError?: (error: string) => void; + onAspectRatioCalculated?: (ratio: number) => void; + controlRef?: React.MutableRefObject; +} + +const ZOOM_FACTOR = 1.2; +const MIN_DISTANCE = 1; +const MAX_DISTANCE = 100; + +const TouchController = () => { + const { camera, controls, gl } = useThree(); + + useEffect(() => { + if (!controls) return; + + const orbit = controls as unknown as OrbitControlsImpl; + + // Отключаем встроенную обработку 2-пальцевых жестов — делаем вручную + orbit.touches.TWO = undefined as unknown as number; + + const domElement = gl.domElement; + let panStart: { x: number; y: number } | null = null; + let pinchStartDist: number | null = null; + let pinchStartCamDist: number | null = null; + + const getPinchDist = (touches: TouchList) => { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + }; + + const getCenter3 = (touches: TouchList) => ({ + x: (touches[0].clientX + touches[1].clientX + touches[2].clientX) / 3, + y: (touches[0].clientY + touches[1].clientY + touches[2].clientY) / 3, + }); + + const handleTouchStart = (e: TouchEvent) => { + if (e.touches.length === 2) { + e.preventDefault(); + orbit.enabled = false; + pinchStartDist = getPinchDist(e.touches); + pinchStartCamDist = camera.position.distanceTo(orbit.target); + } else if (e.touches.length === 3) { + e.preventDefault(); + orbit.enabled = false; + panStart = getCenter3(e.touches); + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (e.touches.length === 2 && pinchStartDist !== null && pinchStartCamDist !== null) { + e.preventDefault(); + const currentDist = getPinchDist(e.touches); + const scale = pinchStartDist / currentDist; + const newDist = Math.min(MAX_DISTANCE, Math.max(MIN_DISTANCE, pinchStartCamDist * scale)); + const dir = new THREE.Vector3() + .subVectors(camera.position, orbit.target) + .normalize(); + camera.position.copy(orbit.target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + orbit.update(); + } else if (e.touches.length === 3 && panStart) { + e.preventDefault(); + const center = getCenter3(e.touches); + const deltaX = center.x - panStart.x; + const deltaY = center.y - panStart.y; + panStart = center; + + const perspCamera = camera as THREE.PerspectiveCamera; + const targetDistance = camera.position.distanceTo(orbit.target); + const height = + 2 * + Math.tan(THREE.MathUtils.degToRad(perspCamera.fov / 2)) * + targetDistance; + const worldPerPixel = height / domElement.clientHeight; + + const panOffset = new THREE.Vector3( + -deltaX * worldPerPixel, + deltaY * worldPerPixel, + 0 + ); + panOffset.applyQuaternion(camera.quaternion); + + camera.position.add(panOffset); + orbit.target.add(panOffset); + orbit.update(); + } + }; + + const handleTouchEnd = (e: TouchEvent) => { + if (e.touches.length < 2) { + pinchStartDist = null; + pinchStartCamDist = null; + } + if (e.touches.length < 3) { + panStart = null; + } + if (e.touches.length <= 1) { + orbit.enabled = true; + } + }; + + domElement.addEventListener("touchstart", handleTouchStart, { + passive: false, + }); + domElement.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + domElement.addEventListener("touchend", handleTouchEnd); + + return () => { + domElement.removeEventListener("touchstart", handleTouchStart); + domElement.removeEventListener("touchmove", handleTouchMove); + domElement.removeEventListener("touchend", handleTouchEnd); + }; + }, [camera, controls, gl]); + + return null; +}; + +const ZoomController = ({ + controlRef, +}: { + controlRef?: React.MutableRefObject; +}) => { + const { camera, controls } = useThree(); + + useEffect(() => { + if (!controlRef || !controls) return; + const orbit = controls as unknown as { + target: THREE.Vector3; + getDistance: () => number; + setDistance?: (d: number) => void; + }; + const zoomIn = () => { + const target = orbit.target; + const dir = new THREE.Vector3() + .subVectors(camera.position, target) + .normalize(); + const dist = camera.position.distanceTo(target); + const newDist = Math.max(MIN_DISTANCE, dist / ZOOM_FACTOR); + camera.position.copy(target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + }; + const zoomOut = () => { + const target = orbit.target; + const dir = new THREE.Vector3() + .subVectors(camera.position, target) + .normalize(); + const dist = camera.position.distanceTo(target); + const newDist = Math.min(MAX_DISTANCE, dist * ZOOM_FACTOR); + camera.position.copy(target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + }; + controlRef.current = { zoomIn, zoomOut }; + return () => { + controlRef.current = null; + }; + }, [camera, controls, controlRef]); + + return null; +}; + +const AutoResize = () => { + const { gl, camera } = useThree(); + useEffect(() => { + const handleResize = () => { + const parent = gl.domElement.parentElement; + if (!parent) return; + gl.setSize(parent.clientWidth, parent.clientHeight); + if (camera instanceof THREE.PerspectiveCamera) { + camera.aspect = parent.clientWidth / parent.clientHeight; + camera.updateProjectionMatrix(); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [gl, camera]); + return null; +}; + +const Model = ({ + fileUrl, + onLoad, +}: { + fileUrl: string; + onLoad?: () => void; +}) => { + const { scene } = useGLTF(fileUrl); + + useEffect(() => { + if (scene) { + scene.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const m = (child as THREE.Mesh) + .material as THREE.MeshStandardMaterial; + if (m) { + m.envMapIntensity = 1.5; + m.needsUpdate = true; + } + } + }); + onLoad?.(); + } + }, [scene, onLoad]); + + return ; +}; + +export const ThreeView: React.FC = ({ + fileUrl, + width = "100%", + height = "100%", + onLoad, + onError, + onAspectRatioCalculated, + controlRef, +}) => { + return ( +
+ onError?.(e.message)} + > + + + {controlRef && } + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/src/client/src/components/widgets/ThreeViewIcons.tsx b/src/client/src/components/widgets/ThreeViewIcons.tsx new file mode 100644 index 0000000..d81ab04 --- /dev/null +++ b/src/client/src/components/widgets/ThreeViewIcons.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +export const MinusIcon: React.FC> = (props) => ( + + + + + + + + + + + +); + +export const PlusIcon: React.FC> = (props) => ( + + + + + + + + + + + +); + +export const SizeIcon: React.FC> = (props) => ( + + + +); + +export const SizeOpenIcon: React.FC> = (props) => ( + + + +); diff --git a/src/client/src/components/widgets/VideoPreviewWidget.jsx b/src/client/src/components/widgets/VideoPreviewWidget.jsx new file mode 100644 index 0000000..474a980 --- /dev/null +++ b/src/client/src/components/widgets/VideoPreviewWidget.jsx @@ -0,0 +1,188 @@ +import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; +import { observer } from "mobx-react-lite"; +import "../../styles/VideoPreview.css"; +import { apiStore } from "../../api/ApiStore/store"; +import { apiBaseURL } from "../../api/apiConfig"; +import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore"; + +const defaultVideoPath = new URL( + "../../assets/video/taganai.mp4", + import.meta.url, +).href; + +const VideoPreviewWidget = observer(() => { + const { context, isLoading, routeSights, route } = apiStore; + const { selectedSightId } = useGeolocationStore(); + const [showVideo, setShowVideo] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const [videoError, setVideoError] = useState(false); + const inactivityTimer = useRef(null); + const videoRef = useRef(null); + + const videoTimer = route?.video_timer + ? route.video_timer * 1000 + : 60000 * 60 * 7; + + const apiBaseUrl = apiBaseURL; + const mediaPath = "/media"; + + // Используем selectedSightId если есть, иначе nearestSightId + const nearestSightId = context?.nearestSightId; + const activeSightId = selectedSightId || nearestSightId; + + const videoToShow = useMemo(() => { + if (!activeSightId) return null; + return routeSights?.find((sight) => sight.id === Number(activeSightId)) + ?.video_preview; + }, [routeSights, activeSightId]); + + const videoSrc = useMemo(() => { + // Если есть видео из API и не было ошибки - используем его + if (videoToShow && !videoError) { + return `${apiBaseUrl}${mediaPath}/${videoToShow}/download`; + } + // Иначе используем видео по умолчанию + return defaultVideoPath; + }, [videoToShow, videoError, apiBaseUrl, mediaPath]); + + const [currentVideoId, setCurrentVideoId] = useState(null); + const [currentSightId, setCurrentSightId] = useState(null); + const [isVideoFinished, setIsVideoFinished] = useState(false); + + const handleHideVideo = useCallback(() => { + setIsExiting(true); + setTimeout(() => { + setShowVideo(false); + setIsExiting(false); + setIsVideoFinished(true); + }, 500); + }, []); + + const handleVideoEnded = useCallback(() => { + setIsVideoFinished(true); + setShowVideo(false); + }, []); + + const resetInactivityTimer = useCallback(() => { + if (inactivityTimer.current) { + clearTimeout(inactivityTimer.current); + } + + inactivityTimer.current = setTimeout(() => { + if (!showVideo && activeSightId) { + // Показываем видео после 10 секунд бездействия + setShowVideo(true); + setVideoError(false); + setIsVideoFinished(false); + } + }, videoTimer); + }, [videoTimer, showVideo, activeSightId]); + + const handleUserActivity = useCallback(() => { + resetInactivityTimer(); + if (showVideo) { + handleHideVideo(); + } + }, [showVideo, resetInactivityTimer, handleHideVideo]); + + // Сброс состояния при изменении достопримечательности или видео + useEffect(() => { + if (!activeSightId) return; + + // Проверяем изменение достопримечательности (selectedSightId или nearestSightId) + if (activeSightId !== currentSightId) { + setCurrentSightId(activeSightId); + setCurrentVideoId(videoToShow || null); + setIsVideoFinished(false); + setVideoError(false); + setIsExiting(false); + // Скрываем видео при смене достопримечательности - оно появится после 10 секунд бездействия + if (showVideo) { + setShowVideo(false); + } + // Перезапускаем таймер при смене достопримечательности + resetInactivityTimer(); + return; + } + + // Проверяем изменение видео для той же достопримечательности + if (videoToShow !== currentVideoId) { + setCurrentVideoId(videoToShow || null); + setIsVideoFinished(false); + setVideoError(false); + setIsExiting(false); + // Видео обновится автоматически через key при следующем показе + } + }, [ + activeSightId, + videoToShow, + currentSightId, + currentVideoId, + showVideo, + resetInactivityTimer, + ]); + + useEffect(() => { + const events = ["mousemove", "keydown", "scroll", "touchstart"]; + + events.forEach((event) => + window.addEventListener(event, handleUserActivity), + ); + resetInactivityTimer(); + + return () => { + events.forEach((event) => + window.removeEventListener(event, handleUserActivity), + ); + if (inactivityTimer.current) { + clearTimeout(inactivityTimer.current); + } + }; + }, [handleUserActivity, resetInactivityTimer]); + + return createPortal( +
+ {showVideo && activeSightId && ( +
+
+ )} +
, + document.body, + ); +}); + +export default VideoPreviewWidget; diff --git a/src/client/src/context/GeolocationContext.tsx b/src/client/src/context/GeolocationContext.tsx new file mode 100644 index 0000000..ec63836 --- /dev/null +++ b/src/client/src/context/GeolocationContext.tsx @@ -0,0 +1,101 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; + +import { observer } from "mobx-react-lite"; +import { apiStore } from "../api/ApiStore/store"; + +// Обновленный тип контекста +type GeolocationContextType = { + contextData: any; // Сохраняем для других данных (например, routeId) + setContextData: React.Dispatch>; + nearestSightId: string | null; // Отдельное состояние для nearestSightId + setNearestSightId: React.Dispatch>; // Сеттер для nearestSightId + isLoading: boolean; + error: string | null; + selectedSightId: string | null; + setSelectedSightId: React.Dispatch>; + selectedLanguage: string; + setSelectedLanguage: React.Dispatch>; + selectedLanguageRight: string; + setSelectedLanguageRight: React.Dispatch>; + nearestStationId: string | null; + setNearestStationId: React.Dispatch>; +}; + +const GeolocationContext = createContext({ + contextData: null, + setContextData: () => {}, + nearestSightId: null, + setNearestSightId: () => {}, + isLoading: false, + error: null, + selectedSightId: null, + setSelectedSightId: () => {}, + selectedLanguage: "ru", + setSelectedLanguage: () => {}, + selectedLanguageRight: "ru", + setSelectedLanguageRight: () => {}, + nearestStationId: null, + setNearestStationId: () => {}, +}); + +export const GeolocationProvider = observer( + ({ children }: { children: React.ReactNode }) => { + const { context } = apiStore; + const [contextData, setContextData] = useState(null); // Общее состояние для контекстных данных + const [nearestSightId, setNearestSightId] = useState(null); // Отдельное состояние для nearestSightId + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedSightId, setSelectedSightId] = useState(null); + const [selectedLanguage, setSelectedLanguage] = useState("ru"); + const [selectedLanguageRight, setSelectedLanguageRight] = + useState("ru"); + const [nearestStationId, setNearestStationId] = useState( + null + ); + + // Эффект для первоначальной загрузки contextData (выполняется один раз) + useEffect(() => { + const fetchInitialContext = async () => { + setIsLoading(true); + try { + setContextData(context); + setNearestSightId(context?.nearestSightId!); // Инициализируем nearestSightId также + setNearestStationId(context?.routeProgress?.endStopId || null); + setError(null); + } catch (err) { + setError("Не удалось загрузить геоданные при инициализации"); + console.error("Ошибка при инициализации контекста:", err); + } finally { + setIsLoading(false); + } + }; + + fetchInitialContext(); + }, [context]); // Пустой массив зависимостей: эффект запускается только один раз при монтировании + + return ( + + {children} + + ); + } +); + +export const useGeolocation = () => useContext(GeolocationContext); diff --git a/src/client/src/hooks/useAnimatedPosition.ts b/src/client/src/hooks/useAnimatedPosition.ts new file mode 100644 index 0000000..5da3e08 --- /dev/null +++ b/src/client/src/hooks/useAnimatedPosition.ts @@ -0,0 +1,221 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { PositionAnimator } from "../utils/animationUtils"; + +interface AnimatedPositionState { + x: number; + y: number; + isAnimating: boolean; +} + +/** + * Хук для управления анимированной позицией элемента + * Основано на логике анимации трамвая + */ +export const useAnimatedPosition = ( + initialX: number = 0, + initialY: number = 0, + animationDuration: number = 1000 +) => { + const [position, setPosition] = useState({ + x: initialX, + y: initialY, + isAnimating: false, + }); + + const animatorRef = useRef(null); + + // Создаем аниматор при первом рендере + useEffect(() => { + if (!animatorRef.current) { + animatorRef.current = new PositionAnimator( + (newPosition) => { + setPosition((prev) => ({ + ...prev, + x: newPosition.x, + y: newPosition.y, + })); + }, + () => { + setPosition((prev) => ({ + ...prev, + isAnimating: false, + })); + }, + animationDuration + ); + } + + return () => { + if (animatorRef.current) { + animatorRef.current.stop(); + } + }; + }, [animationDuration]); + + // Функция для анимации к новой позиции + const animateTo = useCallback( + (targetX: number, targetY: number, duration?: number) => { + if (animatorRef.current) { + setPosition((prev) => ({ ...prev, isAnimating: true })); + animatorRef.current.animateTo(targetX, targetY, duration); + } + }, + [] + ); + + // Функция для мгновенной установки позиции + const setPositionImmediate = useCallback((x: number, y: number) => { + if (animatorRef.current) { + animatorRef.current.stop(); + animatorRef.current.setPosition(x, y); + setPosition({ + x, + y, + isAnimating: false, + }); + } + }, []); + + // Функция для остановки анимации + const stopAnimation = useCallback(() => { + if (animatorRef.current) { + animatorRef.current.stop(); + setPosition((prev) => ({ ...prev, isAnimating: false })); + } + }, []); + + return { + position: { x: position.x, y: position.y }, + isAnimating: position.isAnimating, + animateTo, + setPositionImmediate, + stopAnimation, + }; +}; + +/** + * Хук для управления анимированной позицией с поддержкой полярных координат + */ +export const useAnimatedPolarPosition = ( + centerX: number = 0, + centerY: number = 0, + initialAngle: number = 0, + initialDistance: number = 0, + animationDuration: number = 1000 +) => { + const [position, setPosition] = useState({ + x: centerX + initialDistance * Math.cos(initialAngle), + y: centerY - initialDistance * Math.sin(initialAngle), + isAnimating: false, + }); + + const [polarPosition, setPolarPosition] = useState({ + angle: initialAngle, + distance: initialDistance, + }); + + const animatorRef = useRef(null); + + useEffect(() => { + if (!animatorRef.current) { + animatorRef.current = new PositionAnimator( + (newPosition) => { + setPosition((prev) => ({ + ...prev, + x: newPosition.x, + y: newPosition.y, + })); + }, + () => { + setPosition((prev) => ({ + ...prev, + isAnimating: false, + })); + }, + animationDuration + ); + } + + return () => { + if (animatorRef.current) { + animatorRef.current.stop(); + } + }; + }, [animationDuration]); + + // Функция для анимации к новой позиции по полярным координатам + const animateToPolar = useCallback( + (targetAngle: number, targetDistance: number, duration?: number) => { + if (animatorRef.current) { + const targetX = centerX + targetDistance * Math.cos(targetAngle); + const targetY = centerY - targetDistance * Math.sin(targetAngle); + + setPosition((prev) => ({ ...prev, isAnimating: true })); + setPolarPosition({ angle: targetAngle, distance: targetDistance }); + animatorRef.current.animateTo(targetX, targetY, duration); + } + }, + [centerX, centerY] + ); + + // Функция для анимации к новой позиции по декартовым координатам + const animateTo = useCallback( + (targetX: number, targetY: number, duration?: number) => { + if (animatorRef.current) { + const dx = targetX - centerX; + const dy = centerY - targetY; + const targetDistance = Math.sqrt(dx * dx + dy * dy); + const targetAngle = Math.atan2(dy, dx); + + setPosition((prev) => ({ ...prev, isAnimating: true })); + setPolarPosition({ angle: targetAngle, distance: targetDistance }); + animatorRef.current.animateTo(targetX, targetY, duration); + } + }, + [centerX, centerY] + ); + + // Функция для мгновенной установки позиции + const setPositionImmediate = useCallback( + (x: number, y: number) => { + if (animatorRef.current) { + animatorRef.current.stop(); + animatorRef.current.setPosition(x, y); + + const dx = x - centerX; + const dy = centerY - y; + const distance = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + + setPosition({ + x, + y, + isAnimating: false, + }); + setPolarPosition({ angle, distance }); + } + }, + [centerX, centerY] + ); + + // Функция для остановки анимации + const stopAnimation = useCallback(() => { + if (animatorRef.current) { + animatorRef.current.stop(); + setPosition((prev) => ({ ...prev, isAnimating: false })); + } + }, []); + + return { + position: { x: position.x, y: position.y }, + polarPosition: { + angle: polarPosition.angle, + distance: polarPosition.distance, + }, + isAnimating: position.isAnimating, + animateTo, + animateToPolar, + setPositionImmediate, + stopAnimation, + }; +}; diff --git a/src/client/src/hooks/useClickDetection.js b/src/client/src/hooks/useClickDetection.js new file mode 100644 index 0000000..27d7ad4 --- /dev/null +++ b/src/client/src/hooks/useClickDetection.js @@ -0,0 +1,51 @@ +import { useRef } from "react"; + +export const useClickDetection = () => { + const pointerDownRef = useRef(null); + const isScrollingRef = useRef(false); + + const handlePointerDown = (e, id) => { + pointerDownRef.current = { + x: e.clientX, + y: e.clientY, + time: Date.now(), + id, + }; + isScrollingRef.current = false; + }; + + const handlePointerUp = (e, id, callback) => { + if (!pointerDownRef.current || isScrollingRef.current) { + return; + } + + const { x: startX, y: startY, time: startTime } = pointerDownRef.current; + const endX = e.clientX; + const endY = e.clientY; + const endTime = Date.now(); + + // Проверяем расстояние и время + const distance = Math.sqrt( + Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2) + ); + const duration = endTime - startTime; + + // Если расстояние маленькое и время короткое - это клик + if (distance < 10 && duration < 300) { + callback(id); + } + + pointerDownRef.current = null; + }; + + const handleScroll = () => { + isScrollingRef.current = true; + pointerDownRef.current = null; + }; + + return { + handlePointerDown, + handlePointerUp, + handleScroll, + }; +}; diff --git a/src/client/src/hooks/useNetworkStatus.js b/src/client/src/hooks/useNetworkStatus.js new file mode 100644 index 0000000..3f0b04b --- /dev/null +++ b/src/client/src/hooks/useNetworkStatus.js @@ -0,0 +1,53 @@ +import { useState, useEffect } from "react"; + +const useNetworkStatus = () => { + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [wasOffline, setWasOffline] = useState(false); + + useEffect(() => { + const handleOnline = () => { + setIsOnline(true); + // Если пользователь был офлайн и теперь онлайн, сбрасываем флаг + if (wasOffline) { + setWasOffline(false); + } + }; + + const handleOffline = () => { + setIsOnline(false); + setWasOffline(true); + }; + + // Добавляем слушатели событий + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // Очистка слушателей при размонтировании + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, [wasOffline]); + + // Дополнительная проверка через fetch для более надежного определения + const checkNetworkConnection = async () => { + try { + const response = await fetch("/favicon.ico", { + method: "HEAD", + mode: "no-cors", + cache: "no-cache", + }); + return true; + } catch (error) { + return false; + } + }; + + return { + isOnline, + wasOffline, + checkNetworkConnection, + }; +}; + +export default useNetworkStatus; diff --git a/src/client/src/hooks/useOverlayScrollbars.js b/src/client/src/hooks/useOverlayScrollbars.js new file mode 100644 index 0000000..b08feda --- /dev/null +++ b/src/client/src/hooks/useOverlayScrollbars.js @@ -0,0 +1,86 @@ +import { useEffect, useRef, useCallback } from "react"; +import { OverlayScrollbars } from "overlayscrollbars"; +import "overlayscrollbars/overlayscrollbars.css"; + +/** + * Хук для инициализации OverlayScrollbars на элементе + * @param {Object} scrollbarOptions - Опции стилизации скроллбара + * @returns {Function} callback ref для элемента + */ +export const useOverlayScrollbars = (scrollbarOptions = {}) => { + const instanceRef = useRef(null); + const elementRef = useRef(null); + + // Callback ref для правильной обработки жизненного цикла + const setRef = useCallback((element) => { + // Если элемент был удален, уничтожаем экземпляр + if (!element && instanceRef.current) { + try { + instanceRef.current.destroy(); + } catch (e) { + // Игнорируем ошибки при уничтожении + } + instanceRef.current = null; + elementRef.current = null; + return; + } + + // Если элемент изменился, уничтожаем старый экземпляр + if (elementRef.current && elementRef.current !== element && instanceRef.current) { + try { + instanceRef.current.destroy(); + } catch (e) { + // Игнорируем ошибки при уничтожении + } + instanceRef.current = null; + } + + // Если элемент существует и еще не инициализирован + if (element && !instanceRef.current) { + elementRef.current = element; + + // Дефолтные опции для скроллбара + const defaultOptions = { + scrollbars: { + theme: "os-theme-custom", + visibility: "auto", + autoHide: "never", + autoHideDelay: 0, + dragScrolling: true, + clickScrolling: false, + touchSupport: true, + snapHandle: false, + }, + overflow: { + x: scrollbarOptions.overflowX || "hidden", + y: scrollbarOptions.overflowY || "scroll", + }, + }; + + // Инициализируем OverlayScrollbars + try { + instanceRef.current = OverlayScrollbars(element, defaultOptions); + } catch (e) { + console.error("Failed to initialize OverlayScrollbars:", e); + } + } + }, [scrollbarOptions.overflowX, scrollbarOptions.overflowY]); + + // Cleanup при размонтировании компонента + useEffect(() => { + return () => { + if (instanceRef.current) { + try { + instanceRef.current.destroy(); + } catch (e) { + // Игнорируем ошибки при уничтожении + } + instanceRef.current = null; + elementRef.current = null; + } + }; + }, []); + + return setRef; +}; + diff --git a/src/client/src/hooks/useRouteFollowingPosition.ts b/src/client/src/hooks/useRouteFollowingPosition.ts new file mode 100644 index 0000000..5ba010f --- /dev/null +++ b/src/client/src/hooks/useRouteFollowingPosition.ts @@ -0,0 +1,76 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { RoutePathAnimator } from "../utils/RoutePathAnimator"; + +interface RouteFollowingPositionState { + x: number; + y: number; + isAnimating: boolean; +} + +export const useRouteFollowingPosition = (defaultDuration: number = 800) => { + const [position, setPosition] = useState({ + x: 0, + y: 0, + isAnimating: false, + }); + + const animatorRef = useRef(null); + + useEffect(() => { + animatorRef.current = new RoutePathAnimator( + (newPos) => { + setPosition((prev) => ({ ...prev, x: newPos.x, y: newPos.y })); + }, + () => { + setPosition((prev) => ({ ...prev, isAnimating: false })); + }, + defaultDuration, + ); + + return () => { + animatorRef.current?.stop(); + }; + }, [defaultDuration]); + + const setRoutePath = useCallback( + (path: Float32Array, syncMapXY?: { x: number; y: number }) => { + animatorRef.current?.setRoutePath(path, syncMapXY); + }, + [], + ); + + const animateTo = useCallback( + (targetX: number, targetY: number, duration?: number) => { + if (animatorRef.current) { + setPosition((prev) => ({ ...prev, isAnimating: true })); + animatorRef.current.animateTo(targetX, targetY, duration); + } + }, + [], + ); + + const setPositionImmediate = useCallback((x: number, y: number) => { + animatorRef.current?.stop(); + animatorRef.current?.setPosition(x, y); + setPosition({ x, y, isAnimating: false }); + }, []); + + const stopAnimation = useCallback(() => { + animatorRef.current?.stop(); + setPosition((prev) => ({ ...prev, isAnimating: false })); + }, []); + + const getCurrentSegIndex = useCallback((): number => { + return animatorRef.current?.getCurrentSegIndex() ?? -1; + }, []); + + return { + position: { x: position.x, y: position.y }, + isAnimating: position.isAnimating, + setRoutePath, + animateTo, + setPositionImmediate, + stopAnimation, + getCurrentSegIndex, + }; +}; diff --git a/src/client/src/index.css b/src/client/src/index.css new file mode 100644 index 0000000..eeaf307 --- /dev/null +++ b/src/client/src/index.css @@ -0,0 +1,25 @@ +.client-app-scoped { + overflow: hidden; + -webkit-user-select: none; + user-select: none; +} + +.zoom-icon { + position: absolute; + z-index: 99; + top: 32px; + right: 600px; +} + +.side-menu-sights-block { + height: calc(60%); + overflow-y: scroll; + margin-left: 20px; + margin-top: 8px; + margin-right: 5px; + touch-action: none; /* Отключаем стандартные действия */ + overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */ +} +.side-menu-sight-transfer-empty { + padding-left: 20px; +} diff --git a/src/client/src/stores/CameraAnimationStore.ts b/src/client/src/stores/CameraAnimationStore.ts new file mode 100644 index 0000000..2b7d6a3 --- /dev/null +++ b/src/client/src/stores/CameraAnimationStore.ts @@ -0,0 +1,212 @@ +import { makeAutoObservable, runInAction } from "mobx"; + +interface CameraPosition { + x: number; + y: number; +} + +interface AnimationConfig { + duration: number; + easing: (t: number) => number; +} + +interface Station { + longitude: number; + latitude: number; + id?: number; +} + +class CameraAnimationStore { + public maxZoom: number = 8; + public minZoom: number = 1; + + // Актуальные, анимируемые значения, которые используются для рендеринга + public animatedPosition: CameraPosition = { x: 0, y: 0 }; + public animatedZoom: number = 1; + + // Цель, к которой мы стремимся + private targetPosition: CameraPosition | null = null; + private targetZoom: number | null = null; + + // Начальные значения для текущей интерполяции + private startPosition: CameraPosition = { x: 0, y: 0 }; + private startZoom: number = 1; + + private isAnimating: boolean = false; + private animationStartTime: number = 0; + private animationFrameId: number | null = null; + + public animationConfig: AnimationConfig = { + duration: 3000, // 3 секунды для максимально плавной анимации + easing: this.easeInOutCubic, + }; + + // Единый колбэк для обновления камеры в Pixi + private onUpdate: ((pos: CameraPosition, zoom: number) => void) | null = null; + + constructor() { + makeAutoObservable(this); + } + + private easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + } + + private calculateDistance(p1: CameraPosition, p2: CameraPosition): number { + return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)); + } + + private isNearStation( + tramPos: CameraPosition, + stations: Station[] + ): { isNear: boolean; distance: number } { + if (!stations || stations.length === 0) + return { isNear: false, distance: Infinity }; + const threshold = 300; // Порог в координатах карты + let minDistance = Infinity; + + for (const station of stations) { + const distance = this.calculateDistance(tramPos, { + x: station.longitude, + y: station.latitude, + }); + minDistance = Math.min(minDistance, distance); + } + + return { + isNear: minDistance < threshold, + distance: minDistance, + }; + } + + public setUpdateCallback( + callback: ((pos: CameraPosition, zoom: number) => void) | null + ) { + this.onUpdate = callback; + } + + public setMaxZoom(maxZoom: number) { + this.maxZoom = maxZoom; + } + public setMinZoom(minZoom: number) { + this.minZoom = minZoom; + } + + // Метод для ручной синхронизации (когда пользователь управляет камерой) + public syncState(pos: CameraPosition, zoom: number) { + this.stopAnimation(); + runInAction(() => { + // Сохраняем точные значения без округления для плавной анимации + this.animatedPosition = { + x: pos.x, + y: pos.y, + }; + this.animatedZoom = zoom; + }); + } + + // Метод для обновления целевой позиции без перезапуска анимации + public updateTarget(targetPos: CameraPosition, targetZoom: number) { + if (this.isAnimating) { + // Если анимация уже идет, обновляем стартовую позицию на текущую и целевую на новую + // Это даст плавную корректировку курса без рывков + runInAction(() => { + this.startPosition = { ...this.animatedPosition }; + this.startZoom = this.animatedZoom; + this.targetPosition = targetPos; + this.targetZoom = targetZoom; + this.animationStartTime = performance.now(); + }); + } else { + // Если анимация не идет, запускаем новую + this.animateTo(targetPos, targetZoom); + } + } + + // Главный метод для запуска анимации + public animateTo(targetPos: CameraPosition, targetZoom: number) { + this.stopAnimation(); // Прерываем текущую анимацию + runInAction(() => { + this.startPosition = { ...this.animatedPosition }; + this.startZoom = this.animatedZoom; + this.targetPosition = targetPos; + this.targetZoom = targetZoom; + this.animationStartTime = performance.now(); + this.isAnimating = true; + this.animationFrameId = requestAnimationFrame(this.animationLoop); + }); + } + + public followTram( + tramMapPos: CameraPosition, + screenCenter: CameraPosition, + stations: Station[] = [] + ) { + // Анимация начинается с текущего зума и плавно переходит к максимальному зуму + // для плавного приближения к желтой точке при слежении + const targetZoom = this.maxZoom; + + // Правильная формула для позиционирования камеры: центр экрана минус позиция точки умноженная на зум + // Это гарантирует, что точка tramMapPos будет в центре экрана + const targetCameraPosition = { + x: screenCenter.x - tramMapPos.x * targetZoom, + y: screenCenter.y - tramMapPos.y * targetZoom, + }; + + // Используем updateTarget чтобы не останавливать анимацию при обновлении координат + this.updateTarget(targetCameraPosition, targetZoom); + } + + private animationLoop = (timestamp: number) => { + if (!this.isAnimating || !this.targetPosition || this.targetZoom === null) + return; + + const elapsed = timestamp - this.animationStartTime; + const progress = Math.min(elapsed / this.animationConfig.duration, 1); + const easedProgress = this.animationConfig.easing(progress); + + runInAction(() => { + // Плавная интерполяция без округления для максимально плавной анимации + const newPos = { + x: + this.startPosition.x + + (this.targetPosition!.x - this.startPosition.x) * easedProgress, + y: + this.startPosition.y + + (this.targetPosition!.y - this.startPosition.y) * easedProgress, + }; + const newZoom = + this.startZoom + (this.targetZoom! - this.startZoom) * easedProgress; + + this.animatedPosition = newPos; + this.animatedZoom = newZoom; + + this.onUpdate?.(this.animatedPosition, this.animatedZoom); + }); + + if (progress < 1) { + this.animationFrameId = requestAnimationFrame(this.animationLoop); + } else { + this.stopAnimation(); + } + }; + + public stopAnimation = () => { + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + } + runInAction(() => { + this.isAnimating = false; + this.animationFrameId = null; + this.targetPosition = null; + this.targetZoom = null; + }); + }; + + public get isActivelyAnimating(): boolean { + return this.isAnimating; + } +} + +export const cameraAnimationStore = new CameraAnimationStore(); +export default CameraAnimationStore; diff --git a/src/client/src/stores/ColorStore.ts b/src/client/src/stores/ColorStore.ts new file mode 100644 index 0000000..75ce2a0 --- /dev/null +++ b/src/client/src/stores/ColorStore.ts @@ -0,0 +1,71 @@ +import { makeAutoObservable, runInAction } from "mobx"; + +const COLOR_WHITE = { h: 151, s: 0, l: 100 }; +const COLOR_GREEN = { h: 151, s: 100, l: 22 }; + +const TRANSITION_DURATION = 60000; +const TICK_INTERVAL = 100; +const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION; + +interface ColorStore { + currentColor: string; + setCurrentColor: (color: string) => void; + startColorAnimation: () => void; + stopColorAnimation: () => void; +} + +class ColorStore implements ColorStore { + currentColor: string = "#fff"; + private progress: number = 0; + private direction: number = 1; + private tickInterval: ReturnType | null = null; + + constructor() { + makeAutoObservable(this); + } + + setCurrentColor = (color: string) => { + this.currentColor = color; + }; + + private interpolateColor(progress: number): string { + const h = Math.round(COLOR_WHITE.h + (COLOR_GREEN.h - COLOR_WHITE.h) * progress); + const s = Math.round(COLOR_WHITE.s + (COLOR_GREEN.s - COLOR_WHITE.s) * progress); + const l = Math.round(COLOR_WHITE.l + (COLOR_GREEN.l - COLOR_WHITE.l) * progress); + return `hsl(${h}, ${s}%, ${l}%)`; + } + + startColorAnimation = () => { + if (this.tickInterval) return; + + this.tickInterval = globalThis.setInterval(() => { + runInAction(() => { + this.progress += TICK_STEP * this.direction; + + if (this.progress >= 1) { + this.progress = 1; + this.direction = -1; + } else if (this.progress <= 0) { + this.progress = 0; + this.direction = 1; + } + + this.currentColor = this.interpolateColor(this.progress); + }); + }, TICK_INTERVAL); + }; + + stopColorAnimation = () => { + if (this.tickInterval) { + clearInterval(this.tickInterval); + this.tickInterval = null; + } + }; + + dispose = () => { + this.stopColorAnimation(); + }; +} + +export const colorStore = new ColorStore(); +export { ColorStore }; \ No newline at end of file diff --git a/src/client/src/stores/GeolocationStore.ts b/src/client/src/stores/GeolocationStore.ts new file mode 100644 index 0000000..05618a1 --- /dev/null +++ b/src/client/src/stores/GeolocationStore.ts @@ -0,0 +1,146 @@ +import { makeAutoObservable } from "mobx"; + +interface ContextData { + routeId?: string; + routeNumber?: string; + startStopId?: string; + endStopId?: string; + nearestSightId?: string; + nearestStationId?: string; + maintenanceModeOn?: boolean; + routeProgress?: { + startStopId: string; + endStopId: string; + percentageCompleted: number; + }; + rawCoordinates?: { + latitude: number; + longitude: number; + }; + currentCoordinates?: { + latitude: number; + longitude: number; + }; + error?: string | boolean | { message?: string }; + message?: string; + status?: string; +} + +interface GeolocationStore { + contextData: ContextData | null; + nearestSightId: string | null; + selectedSightId: string | null; + selectedLanguage: string; + selectedLanguageRight: string; + nearestStationId: string | null; + currentStationId: string | null; + isLoading: boolean; + error: string | null; + isLeftWidgetOpen: boolean; + isTransferWidgetOpen: boolean; + isManualSelection: boolean; + isRightWidgetSelectorOpen: boolean; + isGovernorWidgetOpen: boolean; + activeClusterId: string | null; + setIsManualSelection: (val: boolean) => void; + setIsRightWidgetSelectorOpen: (isOpen: boolean) => void; + setIsGovernorWidgetOpen: (isOpen: boolean) => void; + setActiveClusterId: (id: string | null) => void; + setIsTransferWidgetOpen: (isOpen: boolean) => void; + setCurrentStationId: (id: string | null) => void; +} + +class GeolocationStore implements GeolocationStore { + contextData: ContextData | null = null; + nearestSightId: string | null = null; + selectedSightId: string | null = null; + selectedLanguage: string = "ru"; + selectedLanguageRight: string = "ru"; + nearestStationId: string | null = null; + currentStationId: string | null = null; + isLoading: boolean = true; + error: string | null = null; + isLeftWidgetOpen: boolean = false; + isTransferWidgetOpen: boolean = false; + isManualSelection: boolean = false; + isRightWidgetSelectorOpen: boolean = false; + isGovernorWidgetOpen: boolean = false; + activeClusterId: string | null = null; + + constructor() { + makeAutoObservable(this); + } + + setContextData = (data: ContextData) => { + this.contextData = data; + }; + + setNearestSightId = (id: string | null) => { + this.nearestSightId = id; + }; + + setSelectedSightId = (id: string | null) => { + this.selectedSightId = id; + }; + + setSelectedLanguage = (language: string) => { + this.selectedLanguage = language; + }; + + setSelectedLanguageRight = (language: string) => { + this.selectedLanguageRight = language; + }; + + setNearestStationId = (id: string | null) => { + this.nearestStationId = id; + }; + + setCurrentStationId = (id: string | null) => { + this.currentStationId = id; + }; + + setIsLoading = (loading: boolean) => { + this.isLoading = loading; + }; + + setError = (error: string | null) => { + this.error = error; + }; + + setIsLeftWidgetOpen = (isOpen: boolean) => { + this.isLeftWidgetOpen = isOpen; + }; + + setIsTransferWidgetOpen = (isOpen: boolean) => { + this.isTransferWidgetOpen = isOpen; + }; + + setIsManualSelection = (val: boolean) => { + this.isManualSelection = val; + }; + + setIsRightWidgetSelectorOpen = (isOpen: boolean) => { + this.isRightWidgetSelectorOpen = isOpen; + }; + + setIsGovernorWidgetOpen = (isOpen: boolean) => { + this.isGovernorWidgetOpen = isOpen; + }; + + closeGovernorModal = () => { + this.isGovernorWidgetOpen = false; + }; + + setActiveClusterId = (id: string | null) => { + this.activeClusterId = id; + }; + + sortingBy: string = "asc"; + + setSortingBy = (sorting: string) => { + this.sortingBy = sorting; + }; +} + +export const geolocationStore = new GeolocationStore(); +export { GeolocationStore }; diff --git a/src/client/src/stores/hooks/useCameraAnimationStore.ts b/src/client/src/stores/hooks/useCameraAnimationStore.ts new file mode 100644 index 0000000..6585f61 --- /dev/null +++ b/src/client/src/stores/hooks/useCameraAnimationStore.ts @@ -0,0 +1,6 @@ +import { cameraAnimationStore } from "../CameraAnimationStore"; +import CameraAnimationStore from "../CameraAnimationStore"; + +export const useCameraAnimationStore = (): CameraAnimationStore => { + return cameraAnimationStore; +}; diff --git a/src/client/src/stores/hooks/useColorStore.ts b/src/client/src/stores/hooks/useColorStore.ts new file mode 100644 index 0000000..3125256 --- /dev/null +++ b/src/client/src/stores/hooks/useColorStore.ts @@ -0,0 +1,6 @@ +import { colorStore } from "../ColorStore"; + +// Хук для использования стора цвета +export const useColorStore = () => { + return colorStore; +}; \ No newline at end of file diff --git a/src/client/src/stores/hooks/useGeolocationStore.ts b/src/client/src/stores/hooks/useGeolocationStore.ts new file mode 100644 index 0000000..a1c6e13 --- /dev/null +++ b/src/client/src/stores/hooks/useGeolocationStore.ts @@ -0,0 +1,6 @@ +import { geolocationStore } from "../GeolocationStore"; + +// Хук для использования стора +export const useGeolocationStore = () => { + return geolocationStore; +}; diff --git a/src/client/src/stores/index.ts b/src/client/src/stores/index.ts new file mode 100644 index 0000000..9004239 --- /dev/null +++ b/src/client/src/stores/index.ts @@ -0,0 +1,9 @@ +export { geolocationStore, GeolocationStore } from "./GeolocationStore"; +export { useGeolocationStore } from "./hooks/useGeolocationStore"; +export { + cameraAnimationStore, + default as CameraAnimationStore, +} from "./CameraAnimationStore"; +export { useCameraAnimationStore } from "./hooks/useCameraAnimationStore"; +export { colorStore, ColorStore } from "./ColorStore"; +export { useColorStore } from "./hooks/useColorStore"; diff --git a/src/client/src/styles/AppealWidget.css b/src/client/src/styles/AppealWidget.css new file mode 100644 index 0000000..4daf683 --- /dev/null +++ b/src/client/src/styles/AppealWidget.css @@ -0,0 +1,41 @@ +.dynamic-widget { + position: fixed; + top: 150px; + display: flex; + flex-direction: column; + align-items: center; + width: 420px; + border-radius: 10px; + background: linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #006F3A; + box-sizing: border-box; +} + +.dynamic-widget-image { + border-radius-top-left: 10px; + border-radius-top-right: 10px; + padding-top: 4px; + margin-left: 4px; + margin-right: 4px; + width: 412px; +} + +.dynamic-widget-label { + width: 380px; + margin-top: 29px; + font-size: 20px; + font-weight: 500; +} + +.dynamic-widget-text { + margin-top: 16px; + margin-bottom: 25px; + width: 380px; + font-size: 16px; + font-weight: 300; + line-height: 150%; +} diff --git a/src/client/src/styles/LeftWidget.css b/src/client/src/styles/LeftWidget.css new file mode 100644 index 0000000..8b8d725 --- /dev/null +++ b/src/client/src/styles/LeftWidget.css @@ -0,0 +1,118 @@ +.left-widget { + display: flex; + flex-direction: column; + align-items: center; + position: fixed; + left: 316px; + top: 500px; + z-index: 10; + width: 316px; + min-height: 350px; + border-radius: 10px; + background: linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #006F3A; + will-change: transform, opacity; + backface-visibility: hidden; +} + +.left-widget-header { + display: flex; + justify-content: flex-end; + width: 100%; + padding: 8px 8px 0 0; +} + +.left-widget-close-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + color: white; + font-size: 14px; + font-weight: bold; + + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.1s ease; +} + +.left-widget-close-btn:active { + background: rgba(255, 255, 255, 0.4); +} + +.left-widget-content { + padding: 0px 10px 20px 10px; +} + +.left-widget-title { + color: #fff; + font-family: "Roboto"; + font-size: 20px; + font-weight: 600; + line-height: 150%; +} + +.left-widget-address { + margin-top: 2px; + color: #fff; + font-family: "Roboto"; + font-size: 16px; + font-weight: 400; + line-height: 150%; +} + +.left-widget-text { + margin-top: 15px; + color: #fff; + font-family: "Roboto"; + font-size: 16px; + font-weight: 300; + line-height: 135%; + max-height: 200px; /* Пример ограничения высоты */ + overflow-y: auto; + touch-action: none; + overscroll-behavior: contain; +} + +.left-widget-image { + border-radius: 10px 10px 0 0; + margin: 2px 0px 2px 0px; + width: 312px; + height: 175px; +} + +.side-menu-sight-transfer { + display: flex; + align-items: center; + padding-left: 10px; + padding-bottom: 6px; + width: 100%; +} + +/* Анимация для списка пересадок */ +.side-menu-sight-transfer-list.entering, +.side-menu-sight-transfer-list.entered { + max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */ + opacity: 1; + transition: max-height 0.3s ease-out, opacity 0.3s ease-out; + overflow: hidden; +} + +.side-menu-sight-transfer-list { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */ +} + +/* Активное состояние - когда список открыт */ +.side-menu-sight-transfer-list.open { + max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */ + opacity: 1; +} diff --git a/src/client/src/styles/ListOfSights.css b/src/client/src/styles/ListOfSights.css new file mode 100644 index 0000000..a36cfa3 --- /dev/null +++ b/src/client/src/styles/ListOfSights.css @@ -0,0 +1,852 @@ +.right-widget { + position: fixed; + right: 32px; + bottom: 0px; + z-index: 300000; +} + +.list-of-sights { + -webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); + -moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); + box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); + width: 550px; + border-radius: 10px 10px 0px 0px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #006f3a; + color: white; + + max-height: 68px; + transition: max-height 0.15s ease; + overflow: hidden; +} + +.list-of-sights.is-open { + max-height: 890px; +} + +.list-of-sights:not(.is-open) .sights-line { + height: 0; + margin: 0; + padding: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; +} + +.list-of-sights-control { + position: relative; + width: 100%; + display: inline-flex; + justify-content: space-around; + align-items: center; + padding-top: 18px; + padding-bottom: 24px; + height: 68px; + flex-shrink: 0; + + user-select: none; +} + +.list-of-sights-control::after { + content: ""; + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + + top: 0; + height: 10px; + border-radius: 10px; + width: 128px; + + background-color: #0e8953; +} + +.list-of-sights-title { + font-family: "Roboto"; + font-size: 18px; + font-weight: 600; + line-height: normal; + color: white; +} + +.list-of-sights-content { + margin-right: 12px; + overflow-x: visible; + height: 700px; + + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease-in-out 0.15s; + + overscroll-behavior: contain; + touch-action: pan-y; + will-change: transform; + transform: translateZ(0); + backface-visibility: hidden; +} + +.list-of-sights-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 13px; + padding: 20px 13px 30px 13px; + align-content: start; +} + +.list-of-sights-content.is-open { + opacity: 1; + pointer-events: auto; +} + +.sight-component { + display: flex; + flex-direction: column; + align-items: center; + justify-content: start; + text-align: center; + touch-action: pan-y; + position: relative; + z-index: 1; + scroll-margin-top: 15px; +} + +.sight-component.sight-highlighted { + z-index: 10; +} + +.sight-image { + display: flex; + justify-content: center; + align-items: center; + width: 96px; + height: 96px; + border-radius: 100%; + background-color: #fff; + flex-shrink: 0; + position: relative; + overflow: visible; +} + +.sight-title { + margin-top: 8px; + text-align: center; + font-family: "Roboto"; + font-size: 14px; + font-weight: 500; + line-height: 1.4; + letter-spacing: 0.32px; + word-break: break-word; + overflow-wrap: break-word; + color: white; + overflow: hidden; + max-height: 2.8em; + position: relative; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +} + +.sight-image-svg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.no-image-placeholder { + width: 96px; + height: 96px; + border-radius: 100%; + background-color: #ccc; + display: flex; + justify-content: center; + align-items: center; + color: #666; + font-size: 12px; + text-align: center; +} + +.sights-line { + height: 1px; + width: 100%; + background-color: rgba(255, 255, 255, 0.3); + margin-bottom: 14px; +} + +.sight-frame { + z-index: -1; + position: absolute; + bottom: 66px; + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 32px; + width: 550px; + border-radius: 10px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #006f3a; + max-height: calc(100vh - 128px); +} + +.sight-frame-image { + margin-top: 2px; + width: 546px; + height: 342px; + border-radius: 8px 8px 0px 0px; + background: #d9d9d9; + object-fit: contain; +} + +.sight-frame-content { + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; +} + +.sight-frame-get-back-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.sight-frame-title { + display: flex; + align-items: center; + padding: 10px 20px; + width: 100%; + text-align: left; + font-family: "Roboto"; + font-size: 24px; + font-weight: 600; + line-height: 120%; + border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8)); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.22) 0%, + rgba(255, 255, 255, 0.04) 100% + ), + rgba(0, 111, 58, 0.72); + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + box-sizing: border-box; + color: white; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; +} + +.sight-frame-title p { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal !important; + margin: 0; + width: 100%; + flex: 1; + min-width: 0; + max-width: 100%; +} + +.sight-frame-get-back { + margin-right: 12px; +} + +.sight-frame-text-wrapper { + flex-grow: 1; + padding: 16px; + box-sizing: border-box; + max-height: calc(80vh - 354px); + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.sight-frame-text-wrapper.scrollable-container { + padding: 16px; +} +.sight-frame-text-wrapper .scrollable-viewport { + flex: 1; + min-height: 0; +} +.sight-frame-text-wrapper .scrollable { + padding-right: 10px; +} + +.sight-frame-text { + padding-right: 10px; + text-align: left; + color: #fff; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: 150%; + margin-bottom: 0; +} + +.sight-frame-text::-webkit-scrollbar { + width: 5px; +} + +.sight-frame-text::-webkit-scrollbar-track { + background: linear-gradient( + to right, + transparent 35%, + #0e8953 50%, + transparent 65% + ); + border-radius: 3px; +} + +.sight-frame-text::-webkit-scrollbar-thumb { + background: #fff; + border-radius: 3px; +} + +.sight-frame-text { + text-align: left; + color: #fff; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: 150%; + margin-bottom: 0; +} + +.sight-frame-menu { + position: relative; + padding: 7px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-around; + border-radius: 0px 0px 10px 10px; + border-top: 1px solid rgba(255, 255, 255, 0.8); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(0, 111, 58, 0.4); + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + backdrop-filter: blur(10px); + box-sizing: border-box; + flex-shrink: 0; +} + +.sight-frame-menu-point { + color: #fff; + text-align: center; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + padding: 8px 12px; + + white-space: nowrap; + transition: + background-color 0.1s ease, + color 0.1s ease; +} + +.sight-frame-menu-point.active { + border-bottom: 2px solid #fff; + font-weight: 600; +} + +.sight-frame-text-wrapper::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + width: 4px; +} + +.sight-frame-text-wrapper::-webkit-scrollbar-thumb { + background: #fff; + border-radius: 4px; + width: 2px; +} + +.sight-frame-text-wrapper::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.4); + border-radius: 1px; +} + +.sight-frame-text-wrapper::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); +} +.sight-frame { + opacity: 0; + transform: translateY(20px); + transition: + opacity 0.2s ease-out, + transform 0.2s ease-out; +} + +.sight-frame.is-visible { + opacity: 1; + transform: translateY(0); + animation: fadeInScale 0.6s ease-out forwards; +} + +@keyframes fadeInScale { + 0% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + 50% { + opacity: 0.7; + transform: translateY(-5px) scale(1.02); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes slideInFromRight { + 0% { + opacity: 0; + transform: translateX(50px); + } + 100% { + opacity: 1; + transform: translateX(0); + } +} + +.sight-component { + transition: transform 0.1s ease-in-out; +} + +.sight-component.sight-highlighted .sight-image { + animation: pulseHighlight 1.5s ease-in-out; + + animation-fill-mode: forwards; + will-change: transform, box-shadow; +} + +@keyframes pulseHighlight { + 0% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); + transform: scale(1); + } + + 50% { + box-shadow: 0 0 15px 10px rgba(255, 255, 255, 0.3); + transform: scale(1.05); + } + + 100% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + transform: scale(1); + } +} + +.intro-title { + height: 300px; + justify-content: center; + align-items: center; + border-bottom: none; + text-align: center; + font-size: 40px; + font-weight: 600; + line-height: 150%; +} + +.svg-container { + position: absolute; + right: 20px; + width: 60px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; +} + +.list-of-sights-lang-open { + position: absolute; + left: 20px; + display: flex; + align-items: center; + justify-content: center; + top: 18px; + gap: 4px; + flex-shrink: 0; +} + +.list-of-sights-lang-open .active { + fill: #fff; +} + +.list-of-sights-lang-open svg { + cursor: pointer; + transition: opacity 0.2s ease; + flex-shrink: 0; +} + +.list-of-sights-lang-open svg:hover { + opacity: 0.8; +} + +.list-of-sights-lang-closed { + position: absolute; + left: 20px; + top: 18px; +} + +.back-to-nearest-button { + cursor: pointer; + transition: opacity 0.2s ease; + flex-shrink: 0; +} + +.back-to-nearest-button:hover { + opacity: 0.8; +} + +.alphabet { + width: 100px; + margin-right: 10px; + padding-top: 24px; + display: flex; + align-items: center; + flex-direction: column; + gap: 16px; + overflow-y: auto; + overflow-x: hidden; + height: 650px; + padding-bottom: 30px; + touch-action: pan-y; + overscroll-behavior: contain; + scrollbar-width: none; + + mask-image: linear-gradient( + to bottom, + transparent 0%, + black 20px, + black calc(100% - 30px), + transparent 100% + ); +} + +.alphabet::-webkit-scrollbar { + display: none; +} + +.alphabet-letter { + font-size: 16px; + color: #cccccc; + padding: 4px 10px; + transition: + color 0.1s ease, + font-weight 0.1s ease, + opacity 0.2s ease; + cursor: pointer; + user-select: none; + min-width: 20px; + text-align: center; + border-radius: 4px; + font-family: "Roboto", "Microsoft YaHei", "PingFang SC", sans-serif; +} + +.alphabet-letter[data-lang="zh"] { + font-size: 14px; + padding: 6px 8px; + min-width: 24px; +} + +.alphabet-disabled { + pointer-events: none; +} + +.alphabet-disabled .alphabet-letter { + opacity: 0.4; + cursor: default; +} + +.alphabet-position { + display: inline-flex; + justify-content: space-between; +} + +.transfer-button-container { + position: absolute; + left: -80px; + bottom: 32px; + z-index: 10000000; + pointer-events: auto; +} + +.transfer-button { + width: 48px; + height: 48px; + border-radius: 50%; +} + +.transfer-widget { + left: -495px; + bottom: -168px; + padding: 16px; + box-sizing: border-box; + width: 382px; + min-height: 186px; + + position: absolute; + border-radius: 10px; + border: 1px solid #006f3a; + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(0, 111, 58, 0.4); + + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + backdrop-filter: blur(10px); +} + +.transferLabel { + font-family: "Roboto"; + font-size: 18px; + font-weight: 600; + line-height: 150%; +} + +.transferText { + font-family: "Roboto"; + font-size: 18px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.36px; +} + +.transfer-line { + display: inline-flex; + gap: 12px; +} + +.sight-frame-media-stack { + margin-top: 2px; + border-radius: 10px 10px 0 0; + position: relative; + width: calc(100% - 4px); + height: 300px; + overflow: hidden; +} + +.sight-frame-media-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: fill; + + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; + z-index: 0; +} + +.sight-frame-media-item.visible { + opacity: 1; + z-index: 1; + pointer-events: auto; +} + +.watermark { + position: absolute; + width: 51px; + height: auto; + left: 8px; + top: 8px; + z-index: 100000; +} + +.transfers-body { + font-family: "Roboto"; + font-size: 16px; + font-weight: 400; + line-height: 150%; + color: #fff; + overflow-y: auto; +} + +.panorama-container { + isolation: isolate; + contain: layout style paint; +} + +.panorama-container * { + box-sizing: border-box; +} + +.panorama-container .psv-container, +.panorama-container .psv-viewer, +.panorama-container .psv-canvas-container { + isolation: isolate; +} + +@supports (overscroll-behavior: contain) { + .list-of-sights-content { + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } +} + +.fullscreen-3d-button { + position: absolute; + top: 10px; + right: 10px; + background: transparent; + border: none; + border-radius: 6px; + color: white; + padding: 8px; + + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.fullscreen-3d-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.fullscreen-3d-actions { + position: fixed; + display: flex; + gap: 20px; + padding: 22px; + border-radius: 32px; + right: 20px; + bottom: 20px; + background: #006f3a; + z-index: 9999; + display: flex; +} + +.fullscreen-3d-actions button { + background: none; + border: none; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.fullscreen-3d-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.fullscreen-3d-actions button:hover:not(:disabled) { + opacity: 0.8; +} + +.fullscreen-3d-content { + position: relative; + width: 80vw; + height: 80vh; + border-radius: 50px; + background: rgba(255, 255, 255, 0.1); + overflow: hidden; + backdrop-filter: blur(10px); +} + +.fullscreen-3d-viewer { + width: 100%; + height: 100%; + border-radius: 8px; + overflow: hidden; +} + +.fullscreen-3d-close { + position: absolute; + top: 10px; + right: 10px; + background: red; + border: none; + border-radius: 6px; + color: white; + padding: 8px; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + cursor: pointer; +} + +.fullscreen-3d-close:hover { + background: rgba(0, 0, 0, 0.9); +} + +.sight-frame-media-stack.three-d-view { + background-color: #111 !important; +} + +.three-d-controls-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + gap: 15px; + padding: 0 30px; + background: rgba(64, 64, 64, 0.6); + z-index: 10; + pointer-events: none; +} + +.three-d-controls-bar .three-d-control-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + width: min-content; + + pointer-events: auto; + color: white; + transition: opacity 0.2s ease; +} + +.three-d-controls-bar .three-d-control-btn:hover { + opacity: 0.85; +} + +.three-d-controls-bar .three-d-control-btn svg { + width: 28px; + height: 28px; + flex-shrink: 0; +} diff --git a/src/client/src/styles/Loader.css b/src/client/src/styles/Loader.css new file mode 100644 index 0000000..a95eccc --- /dev/null +++ b/src/client/src/styles/Loader.css @@ -0,0 +1,29 @@ +.loading-gif { + position: fixed; + width: 100vw; + height: 100vh; + z-index: 999999; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.8); +} + +.loading-info { + position: absolute; + top: 20px; + left: 20px; + color: white; + font-size: 13px; + font-weight: 500; + padding: 10px 14px; + background-color: rgba(0, 0, 0, 0.75); + border-radius: 6px; + z-index: 1000000; + font-family: monospace; + line-height: 1.4; + max-width: 500px; + word-wrap: break-word; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} diff --git a/src/client/src/styles/MapLayer.css b/src/client/src/styles/MapLayer.css new file mode 100644 index 0000000..55b0724 --- /dev/null +++ b/src/client/src/styles/MapLayer.css @@ -0,0 +1,5 @@ +.map-layer { + position: absolute; + width: 100vw; + height: 100vh; +} diff --git a/src/client/src/styles/OverlayScrollbars.css b/src/client/src/styles/OverlayScrollbars.css new file mode 100644 index 0000000..a67ce46 --- /dev/null +++ b/src/client/src/styles/OverlayScrollbars.css @@ -0,0 +1,124 @@ +/* Стили для OverlayScrollbars, соответствующие текущим webkit scrollbar стилям */ + +/* Общие стили для всех скроллбаров */ +.os-scrollbar { + --os-size: 5px; +} + +.os-scrollbar-vertical { + width: 5px; +} + +.os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* Специфичные стили для разных контейнеров */ + +/* .side-menu-sights-block */ +.side-menu-sights-block .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.side-menu-sights-block .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .list-of-sights-content */ +.list-of-sights-content .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.list-of-sights-content .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .sight-frame-text-wrapper */ +.sight-frame-text-wrapper .os-scrollbar-vertical { + padding: 7px 0px; + right: 7px !important; +} + +.sight-frame-text-wrapper .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.sight-frame-text-wrapper .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .sight-frame-text (если используется отдельно) */ +.sight-frame-text .os-scrollbar-track { + background: linear-gradient( + to right, + transparent 35%, + #a6a6a6 50%, + transparent 65% + ) !important; + border-radius: 3px !important; +} + +.sight-frame-text .os-scrollbar-handle { + background: #fff !important; + border-radius: 3px !important; +} + +/* .alphabet - скрываем скроллбар */ +.alphabet .os-scrollbar { + display: none !important; +} + +/* .transfers-body */ +.transfers-body .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.transfers-body .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .left-widget-text */ +.left-widget-text .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.left-widget-text .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .cluster-sights-list */ +.cluster-sights-list .os-scrollbar-vertical { + width: 4px; + padding: 4px 0; + right: 4px !important; +} + +.cluster-sights-list .os-scrollbar-track { + background: transparent !important; + border-radius: 2px !important; +} + +.cluster-sights-list .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.5) !important; + border-radius: 2px !important; +} + +.cluster-sights-list .os-scrollbar-handle:hover { + background: rgba(255, 255, 255, 0.7) !important; +} diff --git a/src/client/src/styles/RouteWidget.css b/src/client/src/styles/RouteWidget.css new file mode 100644 index 0000000..394cce7 --- /dev/null +++ b/src/client/src/styles/RouteWidget.css @@ -0,0 +1,104 @@ +.route-widget-label.marquee { + display: inline-block; + animation: marquee 14s linear infinite; +} + +.route-widget-subtitle.marquee { + display: inline-block; + animation: marquee 14s linear infinite; +} + +@keyframes marquee { + 0% { + transform: translateX(0); + } + 50% { + transform: translateX(-50%); + } + 100% { + transform: translateX(0); + } +} + +.route-widget { + width: 361px; + height: 96px; + position: fixed; + display: inline-flex; + border-radius: 10px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset, + /* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */ + padding: 1px; /* Чтобы контент не прилипал к рамке */ + background: linear-gradient( + to bottom right, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(179, 165, 152, 0.4); + backdrop-filter: blur(10px); + pointer-events: auto; + z-index: 10000001; +} + +.route-widget-number { + position: absolute; + width: fit-content; + left: 0px; + top: 0px; + min-width: 94px; + max-width: 100px; + height: 96px; + background-color: #fcd500; + color: black; + border-radius: 10px; + display: flex; + justify-content: center; + align-items: center; + font-size: 70px; + padding: 14px; + font-weight: 900; +} + +.route-widget-content { + overflow: hidden; + width: 257px; + display: flex; + flex-direction: column; + margin-top: 13px; + margin-left: 109px; + margin-right: 9px; +} + +.route-widget-label { + white-space: nowrap; + font-size: 24px; + margin: 1px 0; + font-style: normal; + font-weight: 700; + line-height: 24px; +} + +.route-widget-label--medium { + font-size: 22px; + line-height: 22px; +} + +.route-widget-label--small { + font-size: 20px; + line-height: 20px; +} + +.route-widget-label--xsmall { + font-size: 18px; + line-height: 18px; +} + +.route-widget-subtitle { + white-space: nowrap; + color: #cbcbcb; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + margin-top: 4px; +} diff --git a/src/client/src/styles/SideMenu.css b/src/client/src/styles/SideMenu.css new file mode 100644 index 0000000..5e09e49 --- /dev/null +++ b/src/client/src/styles/SideMenu.css @@ -0,0 +1,312 @@ +.side-menu { + box-sizing: border-box; + padding-top: 46px; + display: flex; + flex-direction: column; + align-items: center; + height: 100vh; + width: 288px; + position: relative; + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + #006f3a; +} + +.side-menu-label { + margin-top: 10px; + text-align: left; + font-size: 15px; + padding: 0 20px; + align-self: flex-start; + font-style: normal; + font-weight: 400; + line-height: 150%; +} + +.appeal-button { + color: black; + padding: 8px 16px; + border-radius: 10px; + background: #fcd500; + font-size: 16px; + margin-top: 120px; + font-weight: 500; +} + +.side-menu-buttons { + width: 220px; + margin-top: 260px; +} + +.side-menu-button { + background-color: #fff; + color: #000; + text-align: center; + padding: 8px 16px; + margin-bottom: 16px; + border-radius: 10px; +} + +.side-menu-button--sights { + background-color: #fcd500; +} + +.side-menu-button--active { + background-color: #fcd500; + color: #000; +} + +.side-menu-bottom-section { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +.side-menu-carrier-block { + padding: 0 20px; +} + +.carrier-logo { + width: 170px; +} + +.side-menu-slogan { + margin-top: 4px; + text-align: left; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 150%; + color: #fff; +} + +.side-menu-bottom-photo { + width: 100%; + margin-top: 32px; + pointer-events: none; + display: block; +} + +.side-menu-description { + margin-top: 36px; + text-align: center; + font-size: 16px; + font-style: italic; + font-weight: 300; + line-height: 150%; +} + +.side-menu-tag { + position: absolute; + z-index: 1; + bottom: 32px; + font-size: 16px; + font-style: normal; + font-weight: 300; + line-height: 130%; +} + +.side-menu-control-panel { + margin-left: 30px; + display: inline-flex; + flex-direction: column; + justify-content: space-between; + margin: 32px; + pointer-events: none; +} + +.side-menu-open { + margin-right: 10px; +} + +.side-menu-control-buttons { + position: fixed; + display: flex; + bottom: 32px; +} + +@keyframes icon-color-change { + 0% { + fill: #ffffff; + } + + 3.33% { + fill: rgb(76, 175, 75); + } + 50% { + fill: rgb(76, 175, 75); + } + 53.33% { + fill: #ffffff; + } + 100% { + fill: #ffffff; + } +} + +.icon-color-animated path { + fill: var(--animated-color, #ffffff); +} + +.side-menu-control-buttons { + position: fixed; + display: flex; + bottom: 32px; +} + +.side-menu-sights-title { + overflow: hidden; + z-index: 10; + display: flex; + position: relative; + align-items: center; + justify-content: center; + padding-bottom: 13px; + padding-top: 13px; + border-bottom: 2px solid #fff; + text-align: center; + font-family: "Roboto"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 150%; +} + +.side-menu-sights-title svg { + width: 15px; + height: 10px; +} +.side-menu-sights-title:after { + content: ""; + position: absolute; + left: 50%; + transform: translateX(-50%); + top: -2px; + width: 100px; + height: 7px; + background-color: #0e8953; + border-radius: 10px; +} + +.side-menu-sights { + z-index: 10; + overflow: hidden; + top: 250px; + bottom: 0; + border-radius: 10px 10px 0px 0px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #006f3a; + position: absolute; + width: 288px; + transform: translateY(100%); + opacity: 0; + transition: + transform 0.3s ease-out, + opacity 0.3s ease-out; +} + +.side-menu-sights.slide-in { + transform: translateY(0); + opacity: 1; +} + +.side-menu-sights.slide-out { + transform: translateY(100%); +} + +.side-menu-sights-block { + height: calc(100% - 20px); + margin-left: 20px; + margin-top: 8px; + touch-action: none; + overscroll-behavior: contain; + width: auto; + max-width: calc(100% - 20px); + box-sizing: border-box; + overflow-x: hidden; +} + +.side-menu-sight { + padding-bottom: 2px; + margin-right: 20px; + margin-bottom: 6px; + margin-top: 6px; + border-bottom: 1px solid #0e8953; + font-family: "Roboto"; + font-size: 16px; + font-weight: 300; + line-height: 150%; + overflow-x: hidden; + scrollbar-width: none; + position: relative; +} + +.side-menu-sight > span { + display: inline-block; + white-space: nowrap; +} + +.side-menu-sight > span.marquee-text { + animation: side-menu-marquee 14s linear infinite; +} + +@keyframes side-menu-marquee { + 0% { + transform: translateX(0); + } + 50% { + transform: translateX(calc(-100% + var(--container-width, 248px))); + } + 100% { + transform: translateX(0); + } +} + +.side-menu-crest { + width: 170px; + align-self: flex-start; + margin-left: 20px; +} + +.side-menu-control-lang-open { + gap: 10px; + display: inline-flex; +} + +.side-menu-control-lang-open .active { + fill: var(--animated-color, #fff); +} + +.back-to-nearest-button { + width: 30px; + height: 30px; + position: absolute; + left: 70px; + bottom: 20px; + + transition: transform 0.1s ease; +} + +.side-menu-sorted { + width: 48px; + margin-left: 10px; + height: 48px; + background-color: #fff; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + cursor: pointer; +} diff --git a/src/client/src/styles/TouchableLayout.css b/src/client/src/styles/TouchableLayout.css new file mode 100644 index 0000000..64c88d8 --- /dev/null +++ b/src/client/src/styles/TouchableLayout.css @@ -0,0 +1,78 @@ +.scrollable-container { + display: flex; + flex-direction: column; + align-items: flex-start; + width: auto; + max-width: 100%; + box-sizing: border-box; +} + +.scrollable-viewport { + display: flex; + align-items: stretch; + width: 100%; + position: relative; +} + +.scrollable { + flex: 1; + min-width: 0; + min-height: 0; /* позволяет flex-элементу сжиматься и создавать overflow для скролла */ + overflow-y: auto; + overflow-x: hidden; + touch-action: none; + cursor: grab; + /* Скрыть нативный скроллбар */ + scrollbar-width: none; + -ms-overflow-style: none; +} + +.scrollable::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.scrollable:active { + cursor: grabbing; +} + +.custom-scrollbar-track { + width: 5px; + flex-shrink: 0; + position: relative; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.custom-scrollbar-thumb { + position: absolute; + right: 0; + width: 5px; + min-height: 60px; + background: #fff; + border-radius: 3px; + pointer-events: auto; + cursor: grab; + touch-action: none; +} + +.custom-scrollbar-thumb:active { + cursor: grabbing; +} + +.side-menu-sights-block .scrollable-viewport { + height: calc(92%); +} + +.side-menu-sights-block .scrollable { + height: 100%; +} + +.list-of-sights-content .scrollable-viewport { + height: 700px; +} + +.list-of-sights-content .scrollable { + height: 100%; +} diff --git a/src/client/src/styles/TransferWidget.css b/src/client/src/styles/TransferWidget.css new file mode 100644 index 0000000..25e16c2 --- /dev/null +++ b/src/client/src/styles/TransferWidget.css @@ -0,0 +1,13 @@ +.transferLabel { + color: #fff; + font-size: 18px; + font-weight: 600; + line-height: 150%; +} + +.transfer-line { + gap: 0px; + display: flex; + align-items: center; + margin-top: 8px; +} diff --git a/src/client/src/styles/VideoPreview.css b/src/client/src/styles/VideoPreview.css new file mode 100644 index 0000000..0b7c96a --- /dev/null +++ b/src/client/src/styles/VideoPreview.css @@ -0,0 +1,72 @@ +/* Основные стили */ +.video-preview-wrapper { +} + +.loading-message, +.error-message { + padding: 20px; + text-align: center; + color: #666; +} + +/* Контейнер видео */ +.video-container { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 2147483646; + background-color: #000; + display: flex; + justify-content: center; + align-items: center; + animation-duration: 0.5s; + animation-fill-mode: forwards; +} + +/* Видео на весь экран */ +.video-fullscreen { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Анимации */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.video-enter { + animation-name: fadeIn; + pointer-events: auto; +} + +.video-exit { + animation-name: fadeOut; + pointer-events: none; +} + +/* Сообщение об ошибке */ +.video-error-message { + position: absolute; + color: white; + background-color: rgba(0, 0, 0, 0.7); + padding: 15px 25px; + border-radius: 5px; + font-size: 1.2rem; +} diff --git a/src/client/src/styles/WeatherWidget.css b/src/client/src/styles/WeatherWidget.css new file mode 100644 index 0000000..8e6536f --- /dev/null +++ b/src/client/src/styles/WeatherWidget.css @@ -0,0 +1,108 @@ +.weather-widget { + padding: 12px; + display: inline-flex; + flex-direction: column; + align-items: center; + position: fixed; + margin-top: 130px; + z-index: -11; + border-radius: 10px; + width: 230px; + backdrop-filter: blur(10px); + + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.3) inset, + 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; + background: + linear-gradient( + to bottom right, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(179, 165, 152, 0.4); +} + +.weather-widget-time { + font-size: 48px; + font-weight: 600; + line-height: normal; +} + +.weather-widget-date { + font-size: 14px; + font-style: normal; + font-weight: 300; + line-height: normal; +} + +.weather-widget-line { + margin: 8px 0; + width: 100%; + height: 2px; + background-color: #999; +} + +.weather-widget-content { + width: 100%; + display: flex; + gap: 10px; + justify-content: space-between; + align-items: center; +} + +.weather-widget-visual { + gap: 4px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.weather-icon { + width: 72px; + height: 72px; +} + +.weather-widget-temp { + text-align: center; + font-size: 52px; + font-weight: 600; + line-height: normal; + letter-spacing: -3.9px; +} + +.weather-widget-temp-state { + text-align: center; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.weather-widget-data-line { + display: flex; + align-items: center; +} + +.weather-small-icon { + width: 16px; + height: 16px; +} + +.weather-widget-data { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-widget-data-value { + margin-left: 9px; +} + +.weather-bold-text { + font-weight: 900; +} + +.weather-widget-data-value { + font-weight: 400; + white-space: nowrap; +} diff --git a/src/client/src/utils/RoutePathAnimator.ts b/src/client/src/utils/RoutePathAnimator.ts new file mode 100644 index 0000000..30e44d1 --- /dev/null +++ b/src/client/src/utils/RoutePathAnimator.ts @@ -0,0 +1,308 @@ +export class RoutePathAnimator { + private routePath: Float32Array = new Float32Array(); + private routeDistances: Float64Array = new Float64Array(); + private totalRouteLength: number = 0; + + private targetDistance: number = 0; + private currentDistance: number = 0; + private currentSegIndex: number = -1; + + private isAnimating: boolean = false; + private animationId: number | null = null; + private lastFrameTime: number | null = null; + private duration: number; + + private fallbackCurX: number = 0; + private fallbackCurY: number = 0; + private fallbackTgtX: number = 0; + private fallbackTgtY: number = 0; + private fallbackInitialized: boolean = false; + + private onUpdate: (pos: { x: number; y: number }) => void; + private onComplete: (() => void) | null; + + private static readonly BASE_DURATION = 800; + private static readonly MAX_DURATION = 2500; + private static readonly MIN_SMOOTHING_MS = 400; + private static readonly NORMAL_DISTANCE = 30; + private static readonly SETTLE_EPS = 0.02; + private static readonly MAX_DT_MS = 100; + private static readonly MAX_SPEED_UNITS_PER_SEC = 140; + + constructor( + onUpdate: (pos: { x: number; y: number }) => void, + onComplete?: () => void, + duration: number = 800, + ) { + this.onUpdate = onUpdate; + this.onComplete = onComplete ?? null; + this.duration = duration; + } + + setRoutePath(routePath: Float32Array, syncMapXY?: { x: number; y: number }): void { + this.routePath = routePath; + const n = routePath.length / 2; + if (n < 2) { + this.routeDistances = new Float64Array(); + this.totalRouteLength = 0; + return; + } + this.routeDistances = new Float64Array(n); + this.routeDistances[0] = 0; + for (let i = 1; i < n; i++) { + const dx = routePath[i * 2] - routePath[(i - 1) * 2]; + const dy = routePath[i * 2 + 1] - routePath[(i - 1) * 2 + 1]; + this.routeDistances[i] = this.routeDistances[i - 1] + Math.hypot(dx, dy); + } + this.totalRouteLength = this.routeDistances[n - 1]; + + if (syncMapXY) { + const proj = this.projectToRoute(syncMapXY.x, syncMapXY.y); + this.currentDistance = proj.distance; + this.targetDistance = proj.distance; + const pt = this.distanceToPoint(this.currentDistance); + this.currentSegIndex = pt.segIndex; + this.onUpdate({ x: pt.x, y: pt.y }); + } + } + + projectToRoute( + x: number, + y: number, + ): { segIndex: number; t: number; distance: number } { + const path = this.routePath; + const n = path.length / 2; + if (n < 2) return { segIndex: 0, t: 0, distance: 0 }; + + let bestIndex = 0; + let bestT = 0; + let bestDist = Infinity; + + for (let i = 0; i < n - 1; i++) { + const p1x = path[i * 2], + p1y = path[i * 2 + 1]; + const p2x = path[(i + 1) * 2], + p2y = path[(i + 1) * 2 + 1]; + const dx = p2x - p1x, + dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (len2 === 0) continue; + + const t = Math.max(0, Math.min(1, ((x - p1x) * dx + (y - p1y) * dy) / len2)); + const px = p1x + t * dx, + py = p1y + t * dy; + const d = Math.hypot(x - px, y - py); + + if (d < bestDist) { + bestDist = d; + bestIndex = i; + bestT = t; + } + } + + const segLength = + this.routeDistances[bestIndex + 1] - this.routeDistances[bestIndex]; + const distance = this.routeDistances[bestIndex] + bestT * segLength; + + return { segIndex: bestIndex, t: bestT, distance }; + } + + distanceToPoint(distance: number): { x: number; y: number; segIndex: number } { + const dists = this.routeDistances; + const path = this.routePath; + const n = dists.length; + + if (n < 2) return { x: 0, y: 0, segIndex: -1 }; + + distance = Math.max(0, Math.min(distance, this.totalRouteLength)); + + let lo = 0, + hi = n - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (dists[mid] <= distance) lo = mid; + else hi = mid; + } + + const segStart = dists[lo]; + const segEnd = dists[hi]; + const segLen = segEnd - segStart; + const t = segLen > 0 ? (distance - segStart) / segLen : 0; + + const x = path[lo * 2] + t * (path[hi * 2] - path[lo * 2]); + const y = path[lo * 2 + 1] + t * (path[hi * 2 + 1] - path[lo * 2 + 1]); + + return { x, y, segIndex: lo }; + } + + private computeSmoothingTau(routeDistDelta: number, durationOverride?: number): number { + if (durationOverride !== undefined) { + return Math.max(durationOverride, RoutePathAnimator.MIN_SMOOTHING_MS); + } + return Math.min( + Math.max( + RoutePathAnimator.BASE_DURATION * + Math.max(1, routeDistDelta / RoutePathAnimator.NORMAL_DISTANCE), + RoutePathAnimator.MIN_SMOOTHING_MS, + ), + RoutePathAnimator.MAX_DURATION, + ); + } + + animateTo(x: number, y: number, duration?: number): void { + if (this.routeDistances.length < 2) { + this.fallbackTgtX = x; + this.fallbackTgtY = y; + if (!this.fallbackInitialized) { + this.fallbackCurX = x; + this.fallbackCurY = y; + this.fallbackInitialized = true; + this.onUpdate({ x, y }); + } + this.duration = this.computeSmoothingTau( + Math.hypot(this.fallbackTgtX - this.fallbackCurX, this.fallbackTgtY - this.fallbackCurY), + duration, + ); + this.isAnimating = true; + this.ensureTickLoop(); + return; + } + + const target = this.projectToRoute(x, y); + this.targetDistance = target.distance; + + const routeDistDelta = Math.abs(this.targetDistance - this.currentDistance); + this.duration = this.computeSmoothingTau(routeDistDelta, duration); + + this.isAnimating = true; + this.ensureTickLoop(); + } + + private ensureTickLoop(): void { + if (!this.animationId) { + this.lastFrameTime = null; + this.animationId = requestAnimationFrame(this.tick); + } + } + + setPosition(x: number, y: number): void { + this.stop(); + if (this.routeDistances.length >= 2) { + const proj = this.projectToRoute(x, y); + this.currentDistance = proj.distance; + this.targetDistance = proj.distance; + this.currentSegIndex = proj.segIndex; + const pt = this.distanceToPoint(proj.distance); + this.onUpdate({ x: pt.x, y: pt.y }); + } else { + this.fallbackCurX = x; + this.fallbackCurY = y; + this.fallbackTgtX = x; + this.fallbackTgtY = y; + this.fallbackInitialized = true; + this.onUpdate({ x, y }); + } + } + + stop(): void { + this.isAnimating = false; + this.lastFrameTime = null; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + getCurrentPosition(): { x: number; y: number } { + if (this.routeDistances.length < 2) { + return { x: this.fallbackCurX, y: this.fallbackCurY }; + } + const pt = this.distanceToPoint(this.currentDistance); + return { x: pt.x, y: pt.y }; + } + + getCurrentSegIndex(): number { + return this.currentSegIndex; + } + + private stepRoute(alpha: number, dt: number): boolean { + const delta = this.targetDistance - this.currentDistance; + let step = Math.abs(delta) < RoutePathAnimator.SETTLE_EPS ? 0 : delta * alpha; + const maxStep = + RoutePathAnimator.MAX_SPEED_UNITS_PER_SEC * Math.max(dt / 1000, 1e-6); + if (Math.abs(step) > maxStep) { + step = Math.sign(step) * maxStep; + } + if (Math.abs(delta) < RoutePathAnimator.SETTLE_EPS) { + this.currentDistance = this.targetDistance; + } else { + this.currentDistance += step; + } + const pt = this.distanceToPoint(this.currentDistance); + this.currentSegIndex = pt.segIndex; + this.onUpdate({ x: pt.x, y: pt.y }); + return Math.abs(this.targetDistance - this.currentDistance) < RoutePathAnimator.SETTLE_EPS; + } + + private stepFallback(alpha: number, dt: number): boolean { + const dx = this.fallbackTgtX - this.fallbackCurX; + const dy = this.fallbackTgtY - this.fallbackCurY; + const dist = Math.hypot(dx, dy); + if (dist < RoutePathAnimator.SETTLE_EPS) { + this.fallbackCurX = this.fallbackTgtX; + this.fallbackCurY = this.fallbackTgtY; + } else { + let sx = dx * alpha; + let sy = dy * alpha; + const maxStep = + RoutePathAnimator.MAX_SPEED_UNITS_PER_SEC * Math.max(dt / 1000, 1e-6); + const stepLen = Math.hypot(sx, sy); + if (stepLen > maxStep && stepLen > 0) { + const k = maxStep / stepLen; + sx *= k; + sy *= k; + } + this.fallbackCurX += sx; + this.fallbackCurY += sy; + } + this.onUpdate({ x: this.fallbackCurX, y: this.fallbackCurY }); + return ( + Math.hypot(this.fallbackTgtX - this.fallbackCurX, this.fallbackTgtY - this.fallbackCurY) < + RoutePathAnimator.SETTLE_EPS + ); + } + + private tick = (timestamp: number): void => { + if (!this.isAnimating) return; + + let dt: number; + if (this.lastFrameTime === null) { + dt = 16.67; + this.lastFrameTime = timestamp; + } else { + dt = Math.min(Math.max(timestamp - this.lastFrameTime, 0), RoutePathAnimator.MAX_DT_MS); + this.lastFrameTime = timestamp; + } + + const tau = Math.max(this.duration, 50); + const alpha = 1 - Math.exp(-dt / tau); + + const settled = + this.routeDistances.length >= 2 + ? this.stepRoute(alpha, dt) + : this.stepFallback(alpha, dt); + + if (settled) { + this.finishTickLoop(); + } else { + this.animationId = requestAnimationFrame(this.tick); + } + }; + + private finishTickLoop(): void { + this.isAnimating = false; + this.animationId = null; + this.lastFrameTime = null; + this.onComplete?.(); + } +} diff --git a/src/client/src/utils/animationUtils.ts b/src/client/src/utils/animationUtils.ts new file mode 100644 index 0000000..366a079 --- /dev/null +++ b/src/client/src/utils/animationUtils.ts @@ -0,0 +1,250 @@ +/** + * Утилиты для анимации + * Основано на логике анимации из HTML файла (a.html) + */ + +/** + * Линейная интерполяция между двумя значениями + * @param from - начальное значение + * @param to - конечное значение + * @param t - коэффициент интерполяции (0-1) + * @returns интерполированное значение + */ +export const lerp = (from: number, to: number, t: number): number => { + return from + (to - from) * t; +}; + +/** + * Интерполяция угла с учетом кратчайшего пути + * @param from - начальный угол в радианах + * @param to - конечный угол в радианах + * @param t - коэффициент интерполяции (0-1) + * @returns интерполированный угол в радианах + */ +export const lerpAngle = (from: number, to: number, t: number): number => { + // Нормализуем углы к диапазону 0-2π + from = ((from % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + to = ((to % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + + // Найдем кратчайший путь между углами + let diff = to - from; + if (diff > Math.PI) { + diff -= Math.PI * 2; + } else if (diff < -Math.PI) { + diff += Math.PI * 2; + } + + return from + diff * t; +}; + +/** + * Класс для анимации позиции + * Основано на логике анимации из HTML файла (a.html) + */ +export class PositionAnimator { + private currentX: number; + private currentY: number; + private targetX: number; + private targetY: number; + private isAnimating: boolean = false; + private animationId: number | null = null; + private onUpdate: (position: { x: number; y: number }) => void; + private onComplete: () => void; + private duration: number; + private startTime: number | null = null; + + constructor( + onUpdate: (position: { x: number; y: number }) => void, + onComplete: () => void = () => {}, + duration: number = 500 + ) { + this.currentX = 0; + this.currentY = 0; + this.targetX = 0; + this.targetY = 0; + this.onUpdate = onUpdate; + this.onComplete = onComplete; + this.duration = duration; + } + + /** + * Анимировать к новой позиции + */ + animateTo(x: number, y: number, duration?: number): void { + this.targetX = x; + this.targetY = y; + this.duration = duration || this.duration; + this.startTime = null; + this.isAnimating = true; + + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + this.animationId = requestAnimationFrame(this.animate); + } + + /** + * Установить позицию мгновенно + */ + setPosition(x: number, y: number): void { + this.currentX = x; + this.currentY = y; + this.targetX = x; + this.targetY = y; + this.isAnimating = false; + this.onUpdate({ x, y }); + } + + /** + * Остановить анимацию + */ + stop(): void { + this.isAnimating = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + private animate = (timestamp: number): void => { + if (!this.isAnimating) return; + + if (!this.startTime) { + this.startTime = timestamp; + } + + const elapsed = timestamp - this.startTime; + const progress = Math.min(elapsed / this.duration, 1); + + // Используем easeOutCubic для плавной анимации + const easedProgress = 1 - Math.pow(1 - progress, 3); + + this.currentX = lerp(this.currentX, this.targetX, easedProgress); + this.currentY = lerp(this.currentY, this.targetY, easedProgress); + + this.onUpdate({ x: this.currentX, y: this.currentY }); + + if (progress < 1) { + this.animationId = requestAnimationFrame(this.animate); + } else { + this.isAnimating = false; + this.onComplete(); + } + }; +} + +/** + * Класс для анимации по полярным координатам + * Основано на логике анимации из HTML файла (a.html) + */ +export class PolarAnimator { + private currentAngle: number; + private currentDistance: number; + private targetAngle: number; + private targetDistance: number; + private centerX: number; + private centerY: number; + private isAnimating: boolean = false; + private animationId: number | null = null; + private onUpdate: (position: { x: number; y: number }) => void; + private onComplete: () => void; + private duration: number; + private startTime: number | null = null; + + constructor( + onUpdate: (position: { x: number; y: number }) => void, + onComplete: () => void = () => {}, + duration: number = 500 + ) { + this.currentAngle = 0; + this.currentDistance = 0; + this.targetAngle = 0; + this.targetDistance = 0; + this.centerX = 0; + this.centerY = 0; + this.onUpdate = onUpdate; + this.onComplete = onComplete; + this.duration = duration; + } + + /** + * Анимировать к новой позиции по полярным координатам + */ + animateToPolar( + centerX: number, + centerY: number, + targetAngle: number, + targetDistance: number, + duration?: number + ): void { + this.centerX = centerX; + this.centerY = centerY; + this.targetAngle = targetAngle; + this.targetDistance = targetDistance; + this.duration = duration || this.duration; + this.startTime = null; + this.isAnimating = true; + + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + this.animationId = requestAnimationFrame(this.animate); + } + + /** + * Установить позицию мгновенно + */ + setPosition(angle: number, distance: number): void { + this.currentAngle = angle; + this.currentDistance = distance; + this.targetAngle = angle; + this.targetDistance = distance; + this.isAnimating = false; + + const x = this.centerX + distance * Math.cos(angle); + const y = this.centerY - distance * Math.sin(angle); + this.onUpdate({ x, y }); + } + + /** + * Остановить анимацию + */ + stop(): void { + this.isAnimating = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + private animate = (timestamp: number): void => { + if (!this.isAnimating) return; + + if (!this.startTime) { + this.startTime = timestamp; + } + + const elapsed = timestamp - this.startTime; + const progress = Math.min(elapsed / this.duration, 1); + + // Используем easeOutCubic для плавной анимации + const easedProgress = 1 - Math.pow(1 - progress, 3); + + this.currentAngle = lerpAngle(this.currentAngle, this.targetAngle, easedProgress); + this.currentDistance = lerp(this.currentDistance, this.targetDistance, easedProgress); + + const x = this.centerX + this.currentDistance * Math.cos(this.currentAngle); + const y = this.centerY - this.currentDistance * Math.sin(this.currentAngle); + + this.onUpdate({ x, y }); + + if (progress < 1) { + this.animationId = requestAnimationFrame(this.animate); + } else { + this.isAnimating = false; + this.onComplete(); + } + }; +} diff --git a/src/client/src/utils/getMediaType.js b/src/client/src/utils/getMediaType.js new file mode 100644 index 0000000..0c4612b --- /dev/null +++ b/src/client/src/utils/getMediaType.js @@ -0,0 +1,16 @@ +export const getMediaType = (typeNumber) => { + switch (typeNumber) { + case 2: + return "video"; + case 1: + case 3: + return "image"; + + case 5: + return "panorama"; + case 6: + return "3d"; + default: + return "unknown"; + } +}; diff --git a/src/client/src/utils/getWeatherIcon.js b/src/client/src/utils/getWeatherIcon.js new file mode 100644 index 0000000..a78174a --- /dev/null +++ b/src/client/src/utils/getWeatherIcon.js @@ -0,0 +1,45 @@ + +import sunnyIcon from '../assets/icons/clear-day.svg' +import cloudyIcon from '../assets/icons/cloudy.svg' +import rainyIcon from '../assets/icons/rainy.svg' +import snowyIcon from '../assets/icons/snowy.svg' +import thunderIcon from '../assets/icons/thunderstorms.svg' +import fogIcon from '../assets/icons/fog.svg' + +const getWeatherIcon = (status) => { + const statusMap = { + 'солнечно': 'clear-day', + 'облачно': 'cloudy', + 'мелкий дождь': 'rainy', + 'дождливо': 'rainy', + 'снег': 'snowy', + 'гроза': 'thunderstorms', + 'туман': 'fog', + 'clear-day': 'clear-day', + 'cloudy': 'cloudy', + 'rainy': 'rainy', + 'snowy': 'snowy', + 'thunderstorms': 'thunderstorms', + 'fog': 'fog', + }; + const normalizedStatus = statusMap[status] || status; + + switch (normalizedStatus) { + case 'clear-day': + return sunnyIcon; + case 'cloudy': + return cloudyIcon; + case 'rainy': + return rainyIcon; + case 'snowy': + return snowyIcon; + case 'thunderstorms': + return thunderIcon; + case 'fog': + return fogIcon; + default: + return sunnyIcon; + } +} + +export default getWeatherIcon; diff --git a/src/client/src/utils/routeStationsUtils.js b/src/client/src/utils/routeStationsUtils.js new file mode 100644 index 0000000..875afd6 --- /dev/null +++ b/src/client/src/utils/routeStationsUtils.js @@ -0,0 +1,197 @@ +const getDistance = (p1, p2) => { + return Math.sqrt( + Math.pow(p1.longitude - p2.longitude, 2) + + Math.pow(p1.latitude - p2.latitude, 2) + ); +}; + +const findNearestPointOnPath = (point, path) => { + if (!path || path.length < 2) { + return { index: -1, distance: Infinity, progress: 0 }; + } + + let minDistance = Infinity; + let bestIndex = -1; + let bestProgress = 0; + + for (let i = 0; i < path.length - 1; i++) { + const p1 = { latitude: path[i][0], longitude: path[i][1] }; + const p2 = { latitude: path[i + 1][0], longitude: path[i + 1][1] }; + + const dx = p2.longitude - p1.longitude; + const dy = p2.latitude - p1.latitude; + const lineLengthSq = dx * dx + dy * dy; + + if (lineLengthSq === 0) continue; + + const t = + ((point.longitude - p1.longitude) * dx + + (point.latitude - p1.latitude) * dy) / + lineLengthSq; + const projectionT = Math.max(0, Math.min(1, t)); + + const projectedX = p1.longitude + projectionT * dx; + const projectedY = p1.latitude + projectionT * dy; + + const dist = getDistance( + point, + { longitude: projectedX, latitude: projectedY } + ); + + if (dist < minDistance) { + minDistance = dist; + bestIndex = i; + bestProgress = projectionT; + } + } + + return { index: bestIndex, distance: minDistance, progress: bestProgress }; +}; + +const getStationPositionOnPath = (station, path) => { + const result = findNearestPointOnPath( + { latitude: station.latitude, longitude: station.longitude }, + path + ); + return result.index + result.progress; +}; + +export const orderStationsByRoute = (stations, routePath, startStopId, endStopId) => { + if (!stations || !routePath || stations.length === 0 || routePath.length === 0) { + return stations || []; + } + + const startStation = stations.find( + (s) => String(s.id) === String(startStopId) + ); + const endStation = stations.find( + (s) => String(s.id) === String(endStopId) + ); + + if (!startStation || !endStation) { + console.warn("Не найдены начальная или конечная остановки"); + return stations; + } + + const stationsWithPosition = stations.map((station) => ({ + station, + position: getStationPositionOnPath(station, routePath), + })); + + stationsWithPosition.sort((a, b) => a.position - b.position); + + const startPosition = stationsWithPosition.find( + (s) => s.station.id === startStation.id + )?.position; + const endPosition = stationsWithPosition.find( + (s) => s.station.id === endStation.id + )?.position; + + if (startPosition > endPosition) { + stationsWithPosition.reverse(); + } + + return stationsWithPosition.map((item) => item.station); +}; + +export const getNextStation = (currentPosition, orderedStations, currentStationId = null) => { + if (!orderedStations || orderedStations.length === 0 || !currentPosition) { + return null; + } + + let currentIndex = -1; + if (currentStationId) { + currentIndex = orderedStations.findIndex( + (s) => String(s.id) === String(currentStationId) + ); + } + + if (currentIndex >= 0 && currentIndex < orderedStations.length - 1) { + return orderedStations[currentIndex + 1]; + } + + if (currentIndex < 0) { + let nearestIndex = -1; + let minDistance = Infinity; + + for (let i = 0; i < orderedStations.length; i++) { + const distance = getDistance(currentPosition, { + latitude: orderedStations[i].latitude, + longitude: orderedStations[i].longitude, + }); + if (distance < minDistance) { + minDistance = distance; + nearestIndex = i; + } + } + + if (nearestIndex >= 0) { + const nearestStation = orderedStations[nearestIndex]; + const nextStation = orderedStations[nearestIndex + 1]; + + if (nextStation) { + const distanceToNearest = getDistance(currentPosition, { + latitude: nearestStation.latitude, + longitude: nearestStation.longitude, + }); + const distanceToNext = getDistance(currentPosition, { + latitude: nextStation.latitude, + longitude: nextStation.longitude, + }); + + if (distanceToNext < distanceToNearest) { + return nextStation; + } + } + + if (nearestIndex < orderedStations.length - 1) { + return orderedStations[nearestIndex + 1]; + } + } + } + + return null; +}; + +export const getTargetStation = (currentPosition, orderedStations, routePath) => { + if (!orderedStations || orderedStations.length === 0 || !currentPosition || !routePath || routePath.length < 2) { + return null; + } + + let nearestIndex = -1; + let minDistance = Infinity; + + for (let i = 0; i < orderedStations.length; i++) { + const distance = getDistance(currentPosition, { + latitude: orderedStations[i].latitude, + longitude: orderedStations[i].longitude, + }); + if (distance < minDistance) { + minDistance = distance; + nearestIndex = i; + } + } + + if (nearestIndex < 0) { + return null; + } + + const nearestStation = orderedStations[nearestIndex]; + const nextStation = orderedStations[nearestIndex + 1]; + + const tramPosition = findNearestPointOnPath(currentPosition, routePath); + const tramPosOnRoute = tramPosition.index + tramPosition.progress; + + const stationPosition = findNearestPointOnPath( + { latitude: nearestStation.latitude, longitude: nearestStation.longitude }, + routePath + ); + const stationPosOnRoute = stationPosition.index + stationPosition.progress; + + if (tramPosOnRoute > stationPosOnRoute && nextStation) { + return nextStation; + } + + return nearestStation; +}; + diff --git a/src/client/src/utils/translateWeatherStatus.js b/src/client/src/utils/translateWeatherStatus.js new file mode 100644 index 0000000..f4d9fbc --- /dev/null +++ b/src/client/src/utils/translateWeatherStatus.js @@ -0,0 +1,40 @@ +export const translateWeatherStatus = (status, selectedLanguage) => { + const translations = { + 'солнечно': { + 'ru': 'солнечно', + 'en': 'Sunny', + 'zh': '晴朗' + }, + 'облачно': { + 'ru': 'облачно', + 'en': 'Cloudy', + 'zh': '多云' + }, + 'мелкий дождь': { + 'ru': 'мелкий дождь', + 'en': 'Light Rain', + 'zh': '小雨' + }, + 'дождливо': { + 'ru': 'дождливо', + 'en': 'Rainy', + 'zh': '有雨' + }, + 'снег': { + 'ru': 'снег', + 'en': 'Snowy', + 'zh': '下雪' + }, + 'гроза': { + 'ru': 'гроза', + 'en': 'Thunderstorm', + 'zh': '雷暴' + }, + 'туман': { + 'ru': 'туман', + 'en': 'Foggy', + 'zh': '有雾' + }, + }; + return translations[status]?.[selectedLanguage] || status; + }; diff --git a/src/client/src/utils/truncateTitle.js b/src/client/src/utils/truncateTitle.js new file mode 100644 index 0000000..29a6031 --- /dev/null +++ b/src/client/src/utils/truncateTitle.js @@ -0,0 +1,7 @@ +export const truncateTitle = (text, maxLength) => { + if (!text) return ''; + if (text.length > maxLength) { + return text.substring(0, maxLength) + '...'; + } + return text; +}; diff --git a/src/pages/Route/DemoPage/index.tsx b/src/pages/Route/DemoPage/index.tsx new file mode 100644 index 0000000..3f27850 --- /dev/null +++ b/src/pages/Route/DemoPage/index.tsx @@ -0,0 +1,26 @@ +import React, { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { apiStore } from "../../../client/src/api/ApiStore/store"; +import App from "../../../client/src/App"; +import "../../../client/src/index.css"; +import "../../../client/src/App.css"; + +export const DemoPage = () => { + const { id } = useParams<{ id: string }>(); + + // Устанавливаем routeId синхронно при рендере, + // чтобы App уже имел его при монтировании + if (id && apiStore.routeId !== id) { + apiStore.setRouteId(id); + } + + useEffect(() => { + return () => { + apiStore.stopPositionSimulation(); + }; + }, []); + + if (!id) return null; + + return ; +}; diff --git a/src/pages/Route/RouteListPage/index.tsx b/src/pages/Route/RouteListPage/index.tsx index 9c94c0b..3adb476 100644 --- a/src/pages/Route/RouteListPage/index.tsx +++ b/src/pages/Route/RouteListPage/index.tsx @@ -3,7 +3,7 @@ import { ruRU } from "@mui/x-data-grid/locales"; import { authStore, carrierStore, languageStore, routeStore, selectedCityStore, SearchInput } from "@shared"; import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; -import { Map, Pencil, Trash2, Minus } from "lucide-react"; +import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { Box, CircularProgress } from "@mui/material"; @@ -148,6 +148,11 @@ export const RouteListPage = observer(() => { )} + {canShowRoutePreview && ( + + )} {canWriteRoutes && (