Compare commits
50 Commits
develop
...
94f512e0e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 94f512e0e4 | |||
| 60c6840db4 | |||
| 248eea6f85 | |||
| 7f8b90c15e | |||
| d67df0c2e1 | |||
| b42802aac0 | |||
| 938a7e6d1e | |||
| beb9e932ef | |||
| a3a4d2eb18 | |||
| d380b2570f | |||
| fbf8232ce3 | |||
| 8d1de769c5 | |||
| 4b02c6e9d3 | |||
| a58f438dce | |||
| 5e0b56c7dc | |||
| a182a52111 | |||
| dd5aee58e6 | |||
| 442160ba38 | |||
| b6a9cecba6 | |||
| 591ca8104d | |||
| c3127b8d47 | |||
| 73070fe233 | |||
| 7cf188a55c | |||
| 2a9449ba58 | |||
| 1c097a4ca2 | |||
| 048848faa0 | |||
| 8fe6505249 | |||
| 58abe15ec4 | |||
| 144e7cb00c | |||
| d557664b25 | |||
| bbab6fc46a | |||
| 25155a66bc | |||
| a3d574a79c | |||
| 39e11ad5ca | |||
| 7e068e49f5 | |||
| 79539d0583 | |||
| c5c5f835bc | |||
| 5481d264e0 | |||
| d6772b1e3a | |||
| 11133b6839 | |||
| aaeaed3fa5 | |||
| 95fe297aae | |||
| 04a9ac452e | |||
| 85c71563c1 | |||
| 6f32c6e671 | |||
| 0a6192c7da | |||
| b1ba3b4cd5 | |||
| 1917b2cf5a | |||
| 5298fb9f60 | |||
| c95a6517e9 |
7
.env
@@ -1,3 +1,8 @@
|
||||
# VITE_API_URL='https://wn.st.unprism.ru'
|
||||
# VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||
# VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||
# VITE_NEED_AUTH='true'
|
||||
VITE_API_URL='https://wn.krbl.ru'
|
||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
VITE_NEED_AUTH='true'
|
||||
|
||||
86
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
10
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.6",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
@@ -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,13 +42,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",
|
||||
|
||||
BIN
public/Roboto.ttf
Normal file
BIN
public/loader.gif
Normal file
|
After Width: | Height: | Size: 8.3 MiB |
BIN
public/side-menu-photo.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/to_video.mp4
Normal file
@@ -5,10 +5,12 @@ import { CustomTheme } from "@shared";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||
import { TestingModeBanner } from "@widgets";
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<GlobalErrorBoundary>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<TestingModeBanner />
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -16,33 +16,35 @@ import {
|
||||
SnapshotListPage,
|
||||
CarrierListPage,
|
||||
StationListPage,
|
||||
// VehicleListPage,
|
||||
ArticleListPage,
|
||||
|
||||
// CountryPreviewPage,
|
||||
// VehiclePreviewPage,
|
||||
// CarrierPreviewPage,
|
||||
SnapshotCreatePage,
|
||||
CountryCreatePage,
|
||||
CityCreatePage,
|
||||
CarrierCreatePage,
|
||||
VehicleCreatePage,
|
||||
VehicleEditPage,
|
||||
CountryEditPage,
|
||||
CityEditPage,
|
||||
UserCreatePage,
|
||||
UserEditPage,
|
||||
// VehicleEditPage,
|
||||
CarrierEditPage,
|
||||
StationCreatePage,
|
||||
StationPreviewPage,
|
||||
StationEditPage,
|
||||
RouteCreatePage,
|
||||
RoutePreview,
|
||||
DemoPage,
|
||||
RouteEditPage,
|
||||
ArticlePreviewPage,
|
||||
CountryAddPage,
|
||||
} from "@pages";
|
||||
import { authStore, createSightStore, editSightStore } 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";
|
||||
@@ -53,11 +55,14 @@ import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useMatches,
|
||||
} from "react-router-dom";
|
||||
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = authStore;
|
||||
if (isAuthenticated) {
|
||||
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||
|
||||
if (isAuthenticated || !need_auth) {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
@@ -65,17 +70,34 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = authStore;
|
||||
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated) {
|
||||
const matches = useMatches();
|
||||
|
||||
if (!isAuthenticated && need_auth) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (location.pathname === "/") {
|
||||
|
||||
if (location.pathname === "/" && authStore.canRead("map")) {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
|
||||
const lastMatch = matches[matches.length - 1] as
|
||||
| { handle?: { permissions?: string[] } }
|
||||
| undefined;
|
||||
const requiredPermissions = lastMatch?.handle?.permissions ?? [];
|
||||
|
||||
if (
|
||||
requiredPermissions.length > 0 &&
|
||||
!requiredPermissions.every((permission) => authStore.canAccess(permission))
|
||||
) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Чтобы очистка сторов происходила при смене локации
|
||||
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
@@ -87,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}</>;
|
||||
@@ -101,7 +131,14 @@ const router = createBrowserRouter([
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{ path: "route-preview/:id", element: <RoutePreview /> },
|
||||
{
|
||||
path: "route-preview/:id",
|
||||
element: <RoutePreview />,
|
||||
},
|
||||
{
|
||||
path: "demo/:id",
|
||||
element: <DemoPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
@@ -114,67 +151,258 @@ const router = createBrowserRouter([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <MainPage /> },
|
||||
{
|
||||
index: true,
|
||||
element: <MainPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/"],
|
||||
},
|
||||
},
|
||||
|
||||
// Sight
|
||||
{ path: "sight", element: <SightListPage /> },
|
||||
{ path: "sight/create", element: <CreateSightPage /> },
|
||||
{ path: "sight/:id/edit", element: <EditSightPage /> },
|
||||
{
|
||||
path: "sight",
|
||||
element: <SightListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/sight"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "sight/create",
|
||||
element: <CreateSightPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/sight/create"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "sight/:id/edit",
|
||||
element: <EditSightPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/sight/:id/edit"],
|
||||
},
|
||||
},
|
||||
|
||||
// Device
|
||||
{ path: "devices", element: <DevicesPage /> },
|
||||
{
|
||||
path: "devices",
|
||||
element: <DevicesPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/devices"],
|
||||
},
|
||||
},
|
||||
|
||||
// Map
|
||||
{ path: "map", element: <MapPage /> },
|
||||
{
|
||||
path: "map",
|
||||
element: <MapPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/map"],
|
||||
},
|
||||
},
|
||||
|
||||
// Media
|
||||
{ path: "media", element: <MediaListPage /> },
|
||||
{ path: "media/:id", element: <MediaPreviewPage /> },
|
||||
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
||||
{
|
||||
path: "media",
|
||||
element: <MediaListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/media"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "media/:id",
|
||||
element: <MediaPreviewPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/media/:id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "media/:id/edit",
|
||||
element: <MediaEditPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/media/:id/edit"],
|
||||
},
|
||||
},
|
||||
|
||||
// Country
|
||||
{ path: "country", element: <CountryListPage /> },
|
||||
{ path: "country/create", element: <CountryCreatePage /> },
|
||||
{ path: "country/add", element: <CountryAddPage /> },
|
||||
// { path: "country/:id", element: <CountryPreviewPage /> },
|
||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
||||
// City
|
||||
{ path: "city", element: <CityListPage /> },
|
||||
{ path: "city/create", element: <CityCreatePage /> },
|
||||
// { path: "city/:id", element: <CityPreviewPage /> },
|
||||
{ path: "city/:id/edit", element: <CityEditPage /> },
|
||||
// Route
|
||||
{ path: "route", element: <RouteListPage /> },
|
||||
{ path: "route/create", element: <RouteCreatePage /> },
|
||||
{ path: "route/:id/edit", element: <RouteEditPage /> },
|
||||
{
|
||||
path: "country",
|
||||
element: <CountryListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/country"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "country/create",
|
||||
element: <CountryCreatePage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/country/create"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "country/add",
|
||||
element: <CountryAddPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/country/add"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "country/:id/edit",
|
||||
element: <CountryEditPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/country/:id/edit"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "city",
|
||||
element: <CityListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/city"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "city/create",
|
||||
element: <CityCreatePage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/city/create"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "city/:id/edit",
|
||||
element: <CityEditPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/city/:id/edit"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "route",
|
||||
element: <RouteListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/route"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "route/create",
|
||||
element: <RouteCreatePage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/route/create"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "route/:id/edit",
|
||||
element: <RouteEditPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/route/:id/edit"],
|
||||
},
|
||||
},
|
||||
|
||||
// User
|
||||
{ path: "user", element: <UserListPage /> },
|
||||
{ path: "user/create", element: <UserCreatePage /> },
|
||||
{ path: "user/:id/edit", element: <UserEditPage /> },
|
||||
// Snapshot
|
||||
{ path: "snapshot", element: <SnapshotListPage /> },
|
||||
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
||||
{
|
||||
path: "user",
|
||||
element: <UserListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/user"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "user/create",
|
||||
element: <UserCreatePage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/user/create"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "user/:id/edit",
|
||||
element: <UserEditPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/user/:id/edit"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "snapshot",
|
||||
element: <SnapshotListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/snapshot"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "snapshot/create",
|
||||
element: <SnapshotCreatePage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/snapshot/create"],
|
||||
},
|
||||
},
|
||||
|
||||
// Carrier
|
||||
{ path: "carrier", element: <CarrierListPage /> },
|
||||
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
||||
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
|
||||
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
||||
// Station
|
||||
{ path: "station", element: <StationListPage /> },
|
||||
{ path: "station/create", element: <StationCreatePage /> },
|
||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
||||
// Vehicle
|
||||
// { path: "vehicle", element: <VehicleListPage /> },
|
||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
||||
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
||||
// Article
|
||||
{ path: "article", element: <ArticleListPage /> },
|
||||
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
||||
// { path: "media/create", element: <CreateMediaPage /> },
|
||||
{
|
||||
path: "carrier",
|
||||
element: <CarrierListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/carrier"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "carrier/create",
|
||||
element: <CarrierCreatePage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/carrier/create"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "carrier/:id/edit",
|
||||
element: <CarrierEditPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/carrier/:id/edit"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "station",
|
||||
element: <StationListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/station"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "station/create",
|
||||
element: <StationCreatePage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/station/create"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "station/:id",
|
||||
element: <StationPreviewPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/station/:id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "station/:id/edit",
|
||||
element: <StationEditPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/station/:id/edit"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "vehicle/create",
|
||||
element: <VehicleCreatePage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/create"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "vehicle/:id/edit",
|
||||
element: <VehicleEditPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/:id/edit"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "article",
|
||||
element: <ArticleListPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/article"],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "article/:id",
|
||||
element: <ArticlePreviewPage />,
|
||||
handle: {
|
||||
permissions: ROUTE_REQUIRED_RESOURCES["/article/:id"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
49
src/client/src/App.css
Normal file
@@ -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;
|
||||
}
|
||||
4
src/client/src/App.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import React from "react";
|
||||
|
||||
declare const App: React.FC;
|
||||
export default App;
|
||||
237
src/client/src/App.jsx
Normal file
@@ -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 (
|
||||
<div className="client-app-scoped">
|
||||
{isLoading ? (
|
||||
<Loader
|
||||
loadingStatus={loadingStatus}
|
||||
loadingProgress={loadingProgress}
|
||||
onToggleDebug={() => setIsDebugVisible((prev) => !prev)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<VideoPreviewWidget />
|
||||
<SimulationSettings />
|
||||
<svg
|
||||
className="zoom-icon"
|
||||
width="39"
|
||||
height="48"
|
||||
viewBox="0 0 39 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_658_93679)">
|
||||
<path
|
||||
opacity="0.75"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M20.0715 14.5703C20.0715 15.7403 20.5715 16.7903 21.3715 17.5203V18.1203C21.3715 18.5603 21.3715 19.0003 21.3715 19.4503C19.6515 18.5103 18.4915 16.6803 18.5015 14.5803C18.5015 11.5203 20.9915 9.03027 24.0515 9.03027C27.1015 9.03027 29.5715 11.5103 29.5615 14.5703C29.5615 16.6703 28.3815 18.5003 26.6615 19.4403C26.6615 19.3203 26.6615 19.2003 26.6615 19.0903C26.6615 18.9003 26.6615 18.7103 26.6615 18.5303C26.6615 18.4303 26.6615 18.3203 26.6615 18.2203C26.6615 17.9903 26.6615 17.7603 26.6615 17.5203C27.4715 16.7903 27.9815 15.7403 27.9815 14.5703C27.9815 12.3803 26.2215 10.6103 24.0415 10.6103C21.8615 10.6103 20.0815 12.3903 20.0815 14.5803L20.0715 14.5703ZM34.1915 47.9103H30.2915C28.4115 47.9103 26.5315 47.9103 24.6915 47.9103C24.6115 47.6303 24.5415 47.3503 24.4715 47.0803C24.3015 46.4303 24.1315 45.8003 23.9315 45.1803C23.8015 44.7803 23.5615 44.3803 23.2915 44.0603C21.7415 42.2703 20.7215 40.1703 19.7915 38.0203C19.6415 37.6703 19.4915 37.3203 19.3415 36.9703C18.6915 35.4303 18.0315 33.9003 17.3215 32.3903C16.5115 30.6903 15.3515 29.2403 13.8415 28.0803C13.5315 27.8403 13.2515 27.5103 13.0615 27.1703C12.6415 26.4203 13.0615 25.7303 13.9115 25.6803C14.9015 25.6303 15.7815 25.9803 16.5915 26.5003C18.1315 27.4703 19.2715 28.8503 20.3515 30.2903C20.6515 30.6903 20.9415 31.1103 21.2315 31.5203C21.4515 31.8403 21.7115 32.0203 22.1115 31.8903C22.5015 31.7603 22.6015 31.4503 22.6015 31.0703C22.6015 26.5003 22.6215 21.9403 22.6415 17.3703V14.9903C22.6415 14.9903 22.6415 14.9103 22.6415 14.8703C22.6415 14.7803 22.6415 14.6903 22.6415 14.6003V14.5403C22.6615 14.0803 22.6915 13.5903 23.1715 13.3603C23.7415 13.0803 24.3615 13.0803 24.9215 13.3903C25.1115 13.4903 25.2615 13.7503 25.3215 13.9703C25.4015 14.2503 25.4115 14.5703 25.4115 14.8703C25.4115 16.0903 25.4115 17.3003 25.4115 18.5203C25.4115 20.1403 25.4115 21.7703 25.4115 23.3903C25.4115 23.6103 25.4315 23.9103 25.5715 24.0303C25.7515 24.1803 26.0815 24.3003 26.2915 24.2403C26.4915 24.1803 26.7315 23.8903 26.7715 23.6703C26.9415 22.7703 27.3015 22.4903 28.3315 22.5603C29.1315 22.6103 29.5615 23.0703 29.5615 23.8703C29.5615 24.1803 29.5615 24.4803 29.5615 24.7903C29.5615 25.3003 29.5615 25.8003 29.5615 26.3103C29.5615 26.8003 29.8215 27.0803 30.2415 27.0803C30.6515 27.0803 30.9015 26.8403 30.9515 26.3703C31.0415 25.5803 31.5215 25.2003 32.4015 25.2403C33.2015 25.2803 33.7115 25.7503 33.7115 26.4903C33.7115 26.9703 33.7115 27.4503 33.7115 27.9303C33.7115 28.3003 33.7115 28.6803 33.7115 29.0503C33.7115 29.5503 33.9415 29.8303 34.3615 29.8503C34.7815 29.8703 35.0415 29.6103 35.1015 29.1203C35.1915 28.3503 35.7415 27.8803 36.5215 27.9003C37.3015 27.9203 37.8615 28.4303 37.8615 29.2003C37.8615 32.2103 37.8715 35.2203 37.8315 38.2403C37.8215 39.4203 37.2915 40.4703 36.7515 41.5003C36.6215 41.7503 36.4815 42.0103 36.3515 42.2603C35.9015 43.1203 35.4415 43.9803 35.0815 44.8803C34.8215 45.5303 34.6515 46.2103 34.4815 46.9203C34.4015 47.2403 34.3215 47.5703 34.2315 47.9103H34.1915ZM14.2815 31.0503C12.1015 31.0503 10.3315 29.2803 10.3415 27.0903C10.3415 24.9003 12.1215 23.1303 14.3015 23.1203C16.1215 23.1203 17.6615 24.3603 18.1115 26.0503C18.7315 26.5403 19.2915 27.0803 19.7915 27.6303C19.8115 27.4503 19.8215 27.2703 19.8215 27.0903C19.8215 24.0303 17.3615 21.5503 14.3115 21.5503C11.2615 21.5503 8.77148 24.0303 8.77148 27.1003C8.77148 30.1603 11.2315 32.6403 14.2815 32.6403C14.8415 32.6403 15.3815 32.5603 15.8815 32.4003C15.6115 31.9003 15.3015 31.4403 14.9615 31.0003C14.7415 31.0403 14.5115 31.0603 14.2815 31.0603V31.0503Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
opacity="0.75"
|
||||
d="M9.56147 32.6299C9.30147 32.3499 8.91146 32.3299 8.59146 32.5799C8.49146 32.6599 8.40147 32.7599 8.31147 32.8499C6.14147 35.0199 3.97146 37.1899 1.80146 39.3599C1.69146 39.4699 1.58146 39.5599 1.40146 39.7199C1.40146 39.4699 1.40146 39.3199 1.40146 39.1599C1.40146 37.6399 1.42146 36.1199 1.42146 34.5999C1.42146 34.0799 1.18146 33.7899 0.751465 33.7799C0.301465 33.7799 0.0414648 34.0699 0.0414648 34.6199C0.0214648 36.8699 0.00146484 39.1099 0.00146484 41.3499C0.00146484 41.8799 0.241465 42.1399 0.751465 42.1399C3.02146 42.1399 5.28147 42.1399 7.55147 42.1399C7.71146 42.1399 7.90146 42.0999 8.04146 42.0099C8.34146 41.8299 8.41146 41.5299 8.30147 41.2099C8.19147 40.8699 7.91146 40.7299 7.56146 40.7299C6.02146 40.7299 4.47147 40.7299 2.93146 40.7299C2.79146 40.7299 2.66146 40.7199 2.45146 40.7099C2.59146 40.5599 2.68146 40.4499 2.78146 40.3499C4.95146 38.1799 7.12146 36.0099 9.29146 33.8399C9.38146 33.7499 9.48147 33.6599 9.56147 33.5599C9.81147 33.2599 9.80147 32.8799 9.56147 32.6099V32.6299Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
opacity="0.75"
|
||||
d="M28.9114 9.53C29.1714 9.81 29.5614 9.83 29.8814 9.58C29.9814 9.5 30.0714 9.4 30.1614 9.31C32.3314 7.14 34.5014 4.97 36.6714 2.8C36.7814 2.69 36.8914 2.6 37.0714 2.44C37.0714 2.69 37.0714 2.84 37.0714 3C37.0714 4.52 37.0514 6.04 37.0514 7.56C37.0514 8.08 37.2914 8.37 37.7214 8.38C38.1714 8.38 38.4314 8.09 38.4314 7.54C38.4414 5.3 38.4514 3.05 38.4614 0.81C38.4614 0.28 38.2214 0.02 37.7114 0.02C35.4514 0 33.1814 0 30.9214 0C30.7614 0 30.5714 0.04 30.4314 0.13C30.1314 0.31 30.0614 0.61 30.1714 0.93C30.2814 1.27 30.5614 1.41 30.9114 1.41C32.4514 1.41 34.0014 1.41 35.5414 1.41C35.6814 1.41 35.8114 1.42 36.0214 1.43C35.8814 1.58 35.7914 1.69 35.6914 1.79C33.5214 3.96 31.3514 6.13 29.1814 8.3C29.0914 8.39 28.9914 8.48 28.9114 8.58C28.6614 8.88 28.6714 9.26 28.9114 9.53Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_658_93679">
|
||||
<rect
|
||||
width="38.46"
|
||||
height="47.92"
|
||||
fill="white"
|
||||
transform="translate(0.00146484)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="map-layer"
|
||||
style={{
|
||||
transform: isMenuOpen ? "translateX(150px)" : "translateX(0)",
|
||||
transition: "transform 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<Map />
|
||||
</div>
|
||||
<SideMenu onMenuToggle={setIsMenuOpen} />
|
||||
<ListOfSights />
|
||||
</div>
|
||||
)}
|
||||
<StoreDebugInfo isVisible={isDebugVisible} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default App;
|
||||
96
src/client/src/api/ApiStore/api.ts
Normal file
@@ -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<GetRouteResponse> => {
|
||||
const response = await apiInstance.get(`/route/${routeId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getCarrier = async (
|
||||
carrierId: number,
|
||||
): Promise<GetCarrierResponse> => {
|
||||
const response = await apiInstance.get(`/carrier/${carrierId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getCity = async (cityId: number): Promise<GetCityResponse> => {
|
||||
const response = await apiInstance.get(`/city/${cityId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getRouteSights = async (
|
||||
routeId: string,
|
||||
lang: string = "ru",
|
||||
): Promise<GetRouteSightsResponse> => {
|
||||
const response = await apiInstance.get(
|
||||
`/route/${routeId}/sight?lang=${lang}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getRouteStations = async (
|
||||
routeId: string,
|
||||
lang: string = "ru",
|
||||
): Promise<GetRouteStationsResponse> => {
|
||||
const response = await apiInstance.get(
|
||||
`/route/${routeId}/station?lang=${lang}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getArticleMedia = async (
|
||||
articleId: number,
|
||||
): Promise<GetMediaResponse> => {
|
||||
const response = await apiInstance.get(`/article/${articleId}/media`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getMedia = async (): Promise<GetMediaResponse[]> => {
|
||||
const response = await apiInstance.get("/media");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getArticle = async (
|
||||
articleId: number,
|
||||
lang: string = "ru",
|
||||
): Promise<GetArticleResponse> => {
|
||||
const response = await apiInstance.get(`/article/${articleId}?lang=${lang}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getSightArticlesIds = async (
|
||||
sightId: number,
|
||||
): Promise<number[]> => {
|
||||
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<GetRouteStationsResponse> => {
|
||||
const response = await apiInstance.get(
|
||||
`/sight/${sightId}/station?lang=${lang}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getStationSights = async (
|
||||
stationId: number,
|
||||
lang: string = "ru",
|
||||
): Promise<GetRouteSightsResponse> => {
|
||||
const response = await apiInstance.get(
|
||||
`/station/${stationId}/sight?lang=${lang}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
8
src/client/src/api/ApiStore/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { apiStore } from "./store";
|
||||
export type {
|
||||
GetContextResponse,
|
||||
GetWeatherResponse,
|
||||
GetRouteSightsResponse,
|
||||
GetRouteStationsResponse,
|
||||
GetMediaResponse,
|
||||
} from "./types";
|
||||
441
src/client/src/api/ApiStore/store.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
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";
|
||||
// @ts-ignore
|
||||
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
||||
import { resamplePath } from "../../utils/animationUtils";
|
||||
|
||||
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<string, GetSightArticleResponse> = new Map();
|
||||
sightArticlesEn: Map<string, GetSightArticleResponse> = new Map();
|
||||
sightArticlesZh: Map<string, GetSightArticleResponse> = new Map();
|
||||
|
||||
sightArticlesIds: Map<number, number[]> = new Map();
|
||||
|
||||
sightStationsCache: Map<string, GetRouteStationsResponse> = new Map();
|
||||
stationSightsCache: Map<string, GetRouteSightsResponse> = new Map();
|
||||
|
||||
route: GetRouteResponse | null = null;
|
||||
carrier: GetCarrierResponse | null = null;
|
||||
city: GetCityResponse | null = null;
|
||||
|
||||
positionIndex = 0;
|
||||
private positionInterval: ReturnType<typeof setInterval> | 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 () => {
|
||||
const route = await getRoute(this.routeId!);
|
||||
if (route.path && route.path.length > 1) {
|
||||
// Рассчитываем общую дистанцию для выбора адекватного шага ресемплинга
|
||||
let totalDist = 0;
|
||||
for (let i = 0; i < route.path.length - 1; i++) {
|
||||
const p1 = route.path[i];
|
||||
const p2 = route.path[i + 1];
|
||||
totalDist += Math.sqrt(
|
||||
Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)
|
||||
);
|
||||
}
|
||||
// Хотим иметь примерно 2000 точек для равномерности и плавности
|
||||
const segmentLength = totalDist / 2000;
|
||||
if (segmentLength > 0) {
|
||||
route.path = resamplePath(route.path as [number, number][], segmentLength);
|
||||
}
|
||||
}
|
||||
runInAction(() => {
|
||||
this.route = route;
|
||||
});
|
||||
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();
|
||||
158
src/client/src/api/ApiStore/types.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
export type GetContextResponse = {
|
||||
currentCoordinates: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
endStopId: string;
|
||||
nearestSightId: string;
|
||||
nearestStationId?: string | null;
|
||||
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;
|
||||
short_name?: string;
|
||||
}[];
|
||||
|
||||
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;
|
||||
};
|
||||
9
src/client/src/api/apiConfig.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
|
||||
export declare const apiBaseURL: string;
|
||||
export declare const geoBaseURL: string;
|
||||
export declare const weatherBaseURL: string;
|
||||
export declare const getMediaUrl: (id: string) => string;
|
||||
export declare const apiInstance: AxiosInstance;
|
||||
export declare const geoInstance: AxiosInstance;
|
||||
export declare const weatherInstance: AxiosInstance;
|
||||
37
src/client/src/api/apiConfig.js
Normal file
@@ -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);
|
||||
224
src/client/src/api/content/content.api.js
Normal file
@@ -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;
|
||||
13
src/client/src/api/geo/geo.api.js
Normal file
@@ -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("Не удалось получить данные о маршруте");
|
||||
}
|
||||
},
|
||||
};
|
||||
139
src/client/src/api/weather/weather.api.js
Normal file
@@ -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",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
8
src/client/src/assets/Constants.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export declare const UP_SCALE: number;
|
||||
export declare const PATH_WIDTH: number;
|
||||
export declare const STATION_RADIUS: number;
|
||||
export declare const STATION_OUTLINE_WIDTH: number;
|
||||
export declare const SIGHT_SIZE: number;
|
||||
export declare const SCALE_FACTOR: number;
|
||||
export declare const BACKGROUND_COLOR: number;
|
||||
export declare const PATH_COLOR: number;
|
||||
9
src/client/src/assets/Constants.jsx
Normal file
@@ -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;
|
||||
BIN
src/client/src/assets/fullscreen-modal-actions/close.png
Normal file
|
After Width: | Height: | Size: 559 B |
BIN
src/client/src/assets/fullscreen-modal-actions/scale_minus.png
Normal file
|
After Width: | Height: | Size: 388 B |
BIN
src/client/src/assets/fullscreen-modal-actions/scale_plus.png
Normal file
|
After Width: | Height: | Size: 412 B |
11
src/client/src/assets/icons/bus.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#fff" d="M63.03 32.175c-.097 17.144-14.06 30.952-31.204 30.855C14.68 62.933.873 48.97.97 31.806 1.086 14.681 15.05.873 32.175.97c17.144.097 30.972 14.08 30.855 31.205Z"/>
|
||||
<path fill="#816C5A" d="m41.658 12.276-19.064-.116a5.459 5.459 0 0 0-5.489 5.43l-.155 26.628a5.452 5.452 0 0 0 4.17 5.314v.194c0 1.105.892 1.998 1.998 2.017a2.02 2.02 0 0 0 2.017-1.978v-.02l13.42.078v.02c0 1.085.892 1.997 1.978 2.016 1.106 0 2.017-.892 2.017-1.978v-.136a5.453 5.453 0 0 0 4.403-5.333l.155-26.608c0-3.045-2.444-5.508-5.45-5.528ZM20.907 22.458c0-1.435 1.454-2.58 3.18-2.58l15.787.098c1.745 0 3.18 1.183 3.161 2.618l-.039 9.212c-.019 1.435-1.454 2.58-3.2 2.58l-15.786-.098c-1.746 0-3.162-1.182-3.162-2.618l.059-9.212Zm3.937 19.452c-.466.505-1.048.737-1.785.737-.717 0-1.318-.252-1.803-.756-.485-.504-.718-1.105-.718-1.823s.252-1.3.776-1.745a2.692 2.692 0 0 1 1.804-.66c.717 0 1.28.252 1.745.718.446.484.679 1.066.679 1.706a2.47 2.47 0 0 1-.698 1.823Zm17.61.097c-.486.505-1.067.737-1.785.737-.717 0-1.319-.252-1.784-.756-.485-.504-.737-1.105-.718-1.823 0-.718.252-1.3.776-1.746a2.692 2.692 0 0 1 1.804-.659c.717 0 1.28.233 1.745.718.446.504.679 1.047.66 1.706 0 .757-.233 1.358-.699 1.823Z"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h64v64H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
10
src/client/src/assets/icons/clear-day.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#FCD500" d="M19.4 21.64c-.57 0-1.14-.22-1.58-.65l-8.45-8.46a2.227 2.227 0 1 1 3.15-3.15l8.45 8.45c.87.87.87 2.28 0 3.15-.43.44-1 .66-1.57.66Zm42.37 12.6H49.81c-1.23 0-2.23-1-2.23-2.23s1-2.23 2.23-2.23h11.96c1.23 0 2.23 1 2.23 2.23s-1 2.23-2.23 2.23ZM44.6 21.64c-.57 0-1.14-.22-1.58-.65-.87-.87-.87-2.28 0-3.15l8.45-8.45a2.227 2.227 0 1 1 3.15 3.15l-8.45 8.45c-.43.43-1 .65-1.57.65ZM32 16.42c-1.23 0-2.23-1-2.23-2.23V2.23C29.77 1 30.77 0 32 0s2.23 1 2.23 2.23v11.96c0 1.23-1 2.23-2.23 2.23ZM14.19 34.24H2.24C1 34.24 0 33.24 0 32.01s1-2.23 2.23-2.23h11.95c1.23 0 2.23 1 2.23 2.23s-.99 2.23-2.22 2.23Zm-3.24 21.05c-.57 0-1.14-.22-1.58-.65-.87-.87-.87-2.28 0-3.15l8.45-8.45a2.227 2.227 0 1 1 3.15 3.15l-8.45 8.45c-.43.43-1 .65-1.57.65ZM32 64.01c-1.23 0-2.23-1-2.23-2.23V49.82c0-1.23 1-2.23 2.23-2.23s2.23 1 2.23 2.23v11.96c0 1.23-1 2.23-2.23 2.23Zm21.05-8.72c-.57 0-1.14-.22-1.58-.65l-8.45-8.45a2.227 2.227 0 1 1 3.15-3.15l8.45 8.45a2.227 2.227 0 0 1-1.57 3.8ZM32 43.93c6.625 0 12-5.375 12-12s-5.375-12-12-12-12 5.375-12 12 5.375 12 12 12Z"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h64v64H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
src/client/src/assets/icons/cloudy.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#fff" d="M47.15 14.23c-.31 0-.62.01-.92.03-1.42.08-2.81-.53-3.6-1.72C39.6 7.98 34.51 5 28.75 5c-7.17 0-13.3 4.63-15.73 11.15-.4 1.09-1.3 1.93-2.41 2.29C4.47 20.42 0 26.35 0 33.36 0 42 6.78 49 15.15 49h32C56.46 49 64 41.22 64 31.61c0-9.6-7.54-17.38-16.85-17.38Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
10
src/client/src/assets/icons/default.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#FCD500" d="M19.4 21.64c-.57 0-1.14-.22-1.58-.65l-8.45-8.46a2.227 2.227 0 1 1 3.15-3.15l8.45 8.45c.87.87.87 2.28 0 3.15-.43.44-1 .66-1.57.66Zm42.37 12.6H49.81c-1.23 0-2.23-1-2.23-2.23s1-2.23 2.23-2.23h11.96c1.23 0 2.23 1 2.23 2.23s-1 2.23-2.23 2.23ZM44.6 21.64c-.57 0-1.14-.22-1.58-.65-.87-.87-.87-2.28 0-3.15l8.45-8.45a2.227 2.227 0 1 1 3.15 3.15l-8.45 8.45c-.43.43-1 .65-1.57.65ZM32 16.42c-1.23 0-2.23-1-2.23-2.23V2.23C29.77 1 30.77 0 32 0s2.23 1 2.23 2.23v11.96c0 1.23-1 2.23-2.23 2.23ZM14.19 34.24H2.24C1 34.24 0 33.24 0 32.01s1-2.23 2.23-2.23h11.95c1.23 0 2.23 1 2.23 2.23s-.99 2.23-2.22 2.23Zm-3.24 21.05c-.57 0-1.14-.22-1.58-.65-.87-.87-.87-2.28 0-3.15l8.45-8.45a2.227 2.227 0 1 1 3.15 3.15l-8.45 8.45c-.43.43-1 .65-1.57.65ZM32 64.01c-1.23 0-2.23-1-2.23-2.23V49.82c0-1.23 1-2.23 2.23-2.23s2.23 1 2.23 2.23v11.96c0 1.23-1 2.23-2.23 2.23Zm21.05-8.72c-.57 0-1.14-.22-1.58-.65l-8.45-8.45a2.227 2.227 0 1 1 3.15-3.15l8.45 8.45a2.227 2.227 0 0 1-1.57 3.8ZM32 43.93c6.625 0 12-5.375 12-12s-5.375-12-12-12-12 5.375-12 12 5.375 12 12 12Z"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h64v64H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
src/client/src/assets/icons/fog.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#FCD500" d="M49.02 29.96c8.27 0 14.98-6.71 14.98-14.98S57.29 0 49.02 0 34.04 6.71 34.04 14.98c-.01 8.28 6.7 14.98 14.98 14.98Z"/>
|
||||
<path fill="#fff" d="M43.467 17.383c-.272 0-.554.01-.826.02a3.974 3.974 0 0 1-3.445-1.708C36.386 11.642 31.75 9 26.503 9c-6.538 0-12.128 4.112-14.425 9.933-.443 1.122-1.36 1.967-2.508 2.354C4.01 23.154 0 28.498 0 34.786 0 42.633 6.256 49 13.972 49h29.495C52.039 49 59 41.928 59 33.197c0-8.731-6.95-15.814-15.533-15.814Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 572 B |
4
src/client/src/assets/icons/metro_blue.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#2D3B8E" fill-rule="evenodd" d="M62.933 31.961C62.933 14.836 49.008.97 31.942.97 14.875.97.872 14.817.872 31.96c0 17.125 13.829 31.07 31.07 31.07s30.991-13.906 30.991-31.07Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M55.796 31.05c-.698-9.891-7.68-17.125-15.573-19.84-2.773 9.057-5.508 18.269-8.32 27.21-2.831-8.941-5.547-18.153-8.32-27.21C15.69 13.925 8.708 21.159 8.01 31.05a16.371 16.371 0 0 0 0 3.083c.349 6.478 3.18 12.277 6.807 15.845h9.561c.136-.446-.795-.892-1.28-1.183-3.685-2.676-7.389-6.09-9.173-10.802-2.463-6.09-1.125-13.712 2.23-18.017.892-1.028 3.86-3.801 5.74-2.366.64.504 1.087 2.734 1.494 3.86 2.87 9.25 8.436 27.46 8.436 27.46l.097.33.097-.33s5.566-18.21 8.437-27.46c.388-1.126.834-3.375 1.493-3.86 1.881-1.435 4.849 1.338 5.74 2.366 3.375 4.305 4.713 11.927 2.231 18.017-1.784 4.712-5.508 8.126-9.173 10.802-.485.291-1.416.737-1.28 1.183h9.56c3.627-3.568 6.44-9.348 6.808-15.845.078-.989.078-1.997-.039-3.083Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/client/src/assets/icons/metro_green.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#056939" fill-rule="evenodd" d="M62.933 31.961C62.933 14.836 49.008.97 31.942.97 14.875.97.872 14.817.872 31.96c0 17.125 13.829 31.07 31.07 31.07s30.991-13.906 30.991-31.07Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M55.796 31.05c-.698-9.891-7.68-17.125-15.573-19.84-2.773 9.057-5.508 18.269-8.32 27.21-2.831-8.941-5.547-18.153-8.32-27.21C15.69 13.925 8.708 21.159 8.01 31.05a16.371 16.371 0 0 0 0 3.083c.349 6.478 3.18 12.277 6.807 15.845h9.561c.136-.446-.795-.892-1.28-1.183-3.685-2.676-7.389-6.09-9.173-10.802-2.463-6.09-1.125-13.712 2.23-18.017.892-1.028 3.86-3.801 5.74-2.366.64.504 1.087 2.734 1.494 3.86 2.87 9.25 8.436 27.46 8.436 27.46l.097.33.097-.33s5.566-18.21 8.437-27.46c.388-1.126.834-3.375 1.493-3.86 1.881-1.435 4.849 1.338 5.74 2.366 3.375 4.305 4.713 11.927 2.231 18.017-1.784 4.712-5.508 8.126-9.173 10.802-.485.291-1.416.737-1.28 1.183h9.56c3.627-3.568 6.44-9.348 6.808-15.845.078-.989.078-1.997-.039-3.083Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/client/src/assets/icons/metro_orange.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#EB5C2C" fill-rule="evenodd" d="M62.933 31.961C62.933 14.836 49.008.97 31.942.97 14.875.97.872 14.817.872 31.96c0 17.125 13.829 31.07 31.07 31.07s30.991-13.906 30.991-31.07Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M55.796 31.05c-.698-9.891-7.68-17.125-15.573-19.84-2.773 9.057-5.508 18.269-8.32 27.21-2.831-8.941-5.547-18.153-8.32-27.21C15.69 13.925 8.708 21.159 8.01 31.05a16.371 16.371 0 0 0 0 3.083c.349 6.478 3.18 12.277 6.807 15.845h9.561c.136-.446-.795-.892-1.28-1.183-3.685-2.676-7.389-6.09-9.173-10.802-2.463-6.09-1.125-13.712 2.23-18.017.892-1.028 3.86-3.801 5.74-2.366.64.504 1.087 2.734 1.494 3.86 2.87 9.25 8.436 27.46 8.436 27.46l.097.33.097-.33s5.566-18.21 8.437-27.46c.388-1.126.834-3.375 1.493-3.86 1.881-1.435 4.849 1.338 5.74 2.366 3.375 4.305 4.713 11.927 2.231 18.017-1.784 4.712-5.508 8.126-9.173 10.802-.485.291-1.416.737-1.28 1.183h9.56c3.627-3.568 6.44-9.348 6.808-15.845.078-.989.078-1.997-.039-3.083Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/client/src/assets/icons/metro_purple.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#64328A" fill-rule="evenodd" d="M62.933 31.961C62.933 14.836 49.008.97 31.942.97 14.875.97.872 14.817.872 31.96c0 17.125 13.829 31.07 31.07 31.07s30.991-13.906 30.991-31.07Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M55.796 31.05c-.698-9.891-7.68-17.125-15.573-19.84-2.773 9.057-5.508 18.269-8.32 27.21-2.831-8.941-5.547-18.153-8.32-27.21C15.69 13.925 8.708 21.159 8.01 31.05a16.371 16.371 0 0 0 0 3.083c.349 6.478 3.18 12.277 6.807 15.845h9.561c.136-.446-.795-.892-1.28-1.183-3.685-2.676-7.389-6.09-9.173-10.802-2.463-6.09-1.125-13.712 2.23-18.017.892-1.028 3.86-3.801 5.74-2.366.64.504 1.087 2.734 1.494 3.86 2.87 9.25 8.436 27.46 8.436 27.46l.097.33.097-.33s5.566-18.21 8.437-27.46c.388-1.126.834-3.375 1.493-3.86 1.881-1.435 4.849 1.338 5.74 2.366 3.375 4.305 4.713 11.927 2.231 18.017-1.784 4.712-5.508 8.126-9.173 10.802-.485.291-1.416.737-1.28 1.183h9.56c3.627-3.568 6.44-9.348 6.808-15.845.078-.989.078-1.997-.039-3.083Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/client/src/assets/icons/metro_red.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#E52629" fill-rule="evenodd" d="M62.933 31.961C62.933 14.836 49.008.97 31.942.97 14.875.97.872 14.817.872 31.96c0 17.125 13.829 31.07 31.07 31.07s30.991-13.906 30.991-31.07Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M55.796 31.05c-.698-9.891-7.68-17.125-15.573-19.84-2.773 9.057-5.508 18.269-8.32 27.21-2.831-8.941-5.547-18.153-8.32-27.21C15.69 13.925 8.708 21.159 8.01 31.05a16.371 16.371 0 0 0 0 3.083c.349 6.478 3.18 12.277 6.807 15.845h9.561c.136-.446-.795-.892-1.28-1.183-3.685-2.676-7.389-6.09-9.173-10.802-2.463-6.09-1.125-13.712 2.23-18.017.892-1.028 3.86-3.801 5.74-2.366.64.504 1.087 2.734 1.494 3.86 2.87 9.25 8.436 27.46 8.436 27.46l.097.33.097-.33s5.566-18.21 8.437-27.46c.388-1.126.834-3.375 1.493-3.86 1.881-1.435 4.849 1.338 5.74 2.366 3.375 4.305 4.713 11.927 2.231 18.017-1.784 4.712-5.508 8.126-9.173 10.802-.485.291-1.416.737-1.28 1.183h9.56c3.627-3.568 6.44-9.348 6.808-15.845.078-.989.078-1.997-.039-3.083Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/client/src/assets/icons/rainy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#fff" d="M47.15 11.22c-.31 0-.62.01-.92.03-1.42.08-2.81-.53-3.6-1.72C39.59 4.98 34.51 2 28.75 2c-7.17 0-13.3 4.63-15.73 11.15-.4 1.09-1.3 1.93-2.41 2.29C4.47 17.41 0 23.35 0 30.35c0 8.64 6.78 15.64 15.15 15.64h32c9.3 0 16.85-7.78 16.85-17.39 0-9.6-7.54-17.38-16.85-17.38Z"/>
|
||||
<path fill="#00B1FF" fill-rule="evenodd" d="M17.69 48.502a1.5 1.5 0 0 1 .548 2.049l-3.77 6.52a1.5 1.5 0 1 1-2.597-1.502l3.77-6.52a1.5 1.5 0 0 1 2.05-.547Zm8.17 4.719a1.5 1.5 0 0 1 .548 2.05l-3.77 6.52a1.5 1.5 0 0 1-2.597-1.502l3.77-6.52a1.5 1.5 0 0 1 2.05-.547Zm13.339-4.251a1.5 1.5 0 0 1 .55 2.05l-3.76 6.52a1.5 1.5 0 0 1-2.599-1.5l3.76-6.52a1.5 1.5 0 0 1 2.049-.55Zm8.17 4.72a1.5 1.5 0 0 1 .55 2.05l-3.76 6.52a1.5 1.5 0 1 1-2.599-1.5l3.76-6.52a1.5 1.5 0 0 1 2.049-.55Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 888 B |
6
src/client/src/assets/icons/snowy.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#fff" fill-rule="evenodd" d="M31.95.19a2 2 0 0 1 2 2v59.68a2 2 0 1 1-4 0V2.19a2 2 0 0 1 2-2Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M20.446 4.926a2 2 0 0 1 2.828 0l8.737 8.736 8.615-8.607a2 2 0 1 1 2.828 2.83L32.008 19.318 20.446 7.754a2 2 0 0 1 0-2.828ZM31.89 45.042l11.564 11.564a2 2 0 0 1-2.828 2.828L31.89 50.7l-8.616 8.615a2 2 0 0 1-2.828-2.828L31.89 45.042ZM4.368 16.11a2 2 0 0 1 2.732-.732l51.69 29.84a2 2 0 0 1-2 3.464L5.1 18.842a2 2 0 0 1-.732-2.732Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M14.331 8.578a2 2 0 0 1 2.45 1.413l4.2 15.634-15.804 4.227a2 2 0 1 1-1.034-3.864l11.936-3.193-3.16-11.766a2 2 0 0 1 1.412-2.45Zm35.326-.06a2 2 0 0 1 1.415 2.45l-3.193 11.929 11.77 3.161a2 2 0 1 1-1.038 3.864l-15.63-4.199 4.227-15.79a2 2 0 0 1 2.45-1.415ZM2.568 35.703a2 2 0 0 1 2.45-1.415l15.632 4.188-4.238 15.802a2 2 0 0 1-3.864-1.036l3.202-11.938-11.768-3.152a2 2 0 0 1-1.414-2.45Zm58.864.059a2 2 0 0 1-1.414 2.45L48.08 41.414l3.153 11.768a2 2 0 1 1-3.864 1.036l-4.187-15.632 15.8-4.238a2 2 0 0 1 2.45 1.414Z" clip-rule="evenodd"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M59.522 16.11a2 2 0 0 1-.732 2.732L7.1 48.682a2 2 0 0 1-2-3.464l51.69-29.84a2 2 0 0 1 2.732.732Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
src/client/src/assets/icons/thunderstorms.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#fff" d="M47.15 12.22c-.31 0-.62.01-.92.03-1.42.08-2.81-.53-3.6-1.72C39.59 5.98 34.51 3 28.75 3c-7.17 0-13.3 4.63-15.73 11.15-.4 1.09-1.3 1.93-2.41 2.29C4.47 18.42 0 24.35 0 31.35c0 8.64 6.78 15.64 15.15 15.64h32c9.3 0 16.85-7.78 16.85-17.39 0-9.6-7.54-17.38-16.85-17.38Z"/>
|
||||
<path fill="#FCD500" d="M26.1 47.01 47.64 22.1l-3.11 14.79h12.45L33.37 61.8l3.69-14.79H26.1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 490 B |
4
src/client/src/assets/icons/train.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<path fill="#fff" fill-rule="evenodd" d="M63.03 31.593C63.03 14.448 49.028.582 31.922.582 14.817.582.97 14.429.97 31.592c0 17.145 13.789 31.05 30.952 31.05 17.164 0 31.108-13.886 31.108-31.05Z" clip-rule="evenodd"/>
|
||||
<path fill="#816C5A" d="M45.266 39.505V18.25c0-2.968-2.522-5.78-6.013-5.78H33.3l1.377-4.305v-.117c3.724.097 4.403.408 4.461.64 0 .156.252.194.252.194s.446-.038.446-.252c0-.077 0-.349-.136-.485-.678-.543-2.967-.892-7.873-.892-2.289 0-4.17.155-5.508.291-1.843.194-2.735.504-2.735 1.086 0 .194.194.252.194.252s.601-.038.601-.194c0-.077.64-.543 4.46-.64v.097l1.785 4.306h-6.05c-3.434 0-5.994 2.812-5.994 5.78v21.255c0 2.967 2.289 5.353 4.616 5.702h2.075l-5.663 9.736h2.619l.155-.194c0-.078.194-.136.194-.136h18.56s.252.039.388.136v.194h2.773l-5.663-9.736h2.036c2.308-.33 4.597-2.715 4.597-5.683ZM29.596 8.01h4.46l-1.552 4.46h-1.183l-1.726-4.46Zm-1.552 6.632c0-.504.446-.95 1.008-.95h5.74c.602 0 .99.446.99.95v1.726c0 .544-.407.95-.99.95h-5.74c-.64 0-1.008-.407-1.008-.95v-1.726ZM21.74 21.47c0-1.629 1.144-2.967 2.986-2.967h14.371c1.94 0 2.929 1.338 2.929 2.967v3.801c.155 1.9-1.28 2.987-2.929 2.987h-14.37c-1.688 0-2.987-1.086-2.987-2.987v-3.8Zm14.351 23.874c0 .097.99 1.784 1.144 2.056.117.136 0 .194 0 .194H26.92s-.31-.04-.252-.194c.116-.252 1.105-1.94 1.144-2.056.039-.078.155-.136.155-.136h7.835c-.02 0 .175.058.291.136Zm-11.617-3.22C23 42.124 21.74 41 21.74 39.468c0-1.455 1.26-2.58 2.734-2.58 1.338 0 2.58 1.145 2.58 2.58 0 1.532-1.242 2.657-2.58 2.657Zm14.294 7.894c.096.039.95 1.823 1.086 2.036.155.097 0 .194 0 .194h-15.71s-.252-.077-.155-.194c.097-.194 1.145-1.978 1.145-2.036.038-.097.155-.155.155-.155h13.246c-.02 0 .116.039.233.155Zm-2.037-10.55c0-1.455 1.144-2.58 2.676-2.58 1.339 0 2.522 1.145 2.522 2.58 0 1.532-1.183 2.657-2.522 2.657-1.532 0-2.676-1.125-2.676-2.657Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
35
src/client/src/assets/icons/tram-bottom-left.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/icons/tram-bottom-right.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/icons/tram-left.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/icons/tram-right.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/icons/tram-top-left.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/icons/tram-top-right.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
11
src/client/src/assets/icons/tram.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#fff" d="M64 31.787c-.097 17.144-14.08 30.972-31.205 30.875-17.144-.097-30.953-14.08-30.856-31.244C2.036 14.274 16 .446 33.144.543 50.29.64 64.097 14.623 64 31.787Z"/>
|
||||
<path fill="#816C5A" d="m42.59 17.959-19.065-.117a5.475 5.475 0 0 0-5.508 5.43l-.155 26.648c-.02 3.025 2.405 5.47 5.43 5.508l19.064.116c3.026.02 5.47-2.424 5.489-5.43L48 23.467c.02-3.026-2.405-5.489-5.41-5.508ZM21.837 28.14c0-1.436 1.435-2.6 3.18-2.58l15.787.097c1.765.02 3.18 1.183 3.161 2.618l-.058 9.251c0 1.416-1.435 2.58-3.2 2.58L24.92 40.01c-1.745-.02-3.18-1.183-3.16-2.618l.077-9.251Zm3.937 19.471c-.466.485-1.048.737-1.785.737-.717 0-1.318-.252-1.803-.756-.485-.505-.718-1.106-.718-1.823 0-.718.252-1.3.776-1.746a2.64 2.64 0 0 1 1.803-.679c.718 0 1.3.253 1.746.737.465.485.679 1.048.66 1.707a2.484 2.484 0 0 1-.68 1.823Zm17.61.097c-.486.504-1.067.756-1.785.737-.718-.02-1.319-.271-1.784-.756a2.504 2.504 0 0 1-.718-1.804c0-.737.272-1.3.776-1.765.524-.446 1.105-.679 1.804-.679.717 0 1.299.252 1.745.737.446.485.679 1.048.66 1.707.019.718-.233 1.319-.699 1.823ZM25.93 9.464l2.346 6.982c.233.698.99 1.067 1.688.853.698-.232 1.066-1.008.834-1.706l-1.746-5.198 7.777.039-1.823 5.178a1.359 1.359 0 0 0 .834 1.707 1.348 1.348 0 0 0 1.707-.815l2.424-6.962c.02-.039.02-.097.039-.136v-.039c0-.058.02-.116.02-.194v-.097c0-.097 0-.155-.02-.232a1.328 1.328 0 0 0-.854-1.009c-.194-.058-.368-.077-.562-.058-.058 0-.097-.02-.136-.02L27.442 7.7h-.116a1.43 1.43 0 0 0-.562.059 1.345 1.345 0 0 0-.873.95c-.039.097-.039.194-.039.33 0 .135.02.252.058.368 0 .02.02.039.02.058Z"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h64v64H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
11
src/client/src/assets/icons/trolleybus.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#fff" d="M63.03 32.175c-.097 17.163-14.08 30.972-31.205 30.894C14.681 62.953.873 48.97.97 31.806 1.067 14.662 15.05.834 32.175.931c17.144.116 30.952 14.08 30.855 31.244Z"/>
|
||||
<path fill="#816C5A" d="m41.62 16.718-19.065-.097c-3.006-.02-5.488 2.424-5.488 5.43l-.175 26.647c-.02 2.58 1.765 4.732 4.17 5.334v.174a2.02 2.02 0 0 0 1.978 2.017c1.105 0 2.017-.892 2.017-1.978v-.02l13.42.078c0 1.105.893 2.017 1.979 2.017a2 2 0 0 0 2.017-1.998v-.135a5.448 5.448 0 0 0 4.402-5.314l.155-26.648c.04-3.025-2.404-5.488-5.41-5.507ZM20.848 26.899c0-1.415 1.435-2.579 3.2-2.56l15.767.078c1.745 0 3.161 1.164 3.161 2.599l-.058 9.25c0 1.436-1.435 2.6-3.2 2.58l-15.787-.097c-1.765 0-3.18-1.183-3.161-2.618l.078-9.232Zm3.936 19.491c-.465.485-1.047.737-1.784.737-.717 0-1.319-.252-1.803-.756-.485-.504-.699-1.106-.699-1.823 0-.718.253-1.3.776-1.746a2.657 2.657 0 0 1 1.804-.659c.698 0 1.3.252 1.745.737.446.485.68 1.067.68 1.707a2.55 2.55 0 0 1-.719 1.803Zm17.61.117c-.465.485-1.067.737-1.784.717a2.36 2.36 0 0 1-1.784-.756c-.485-.504-.718-1.125-.718-1.823 0-.718.272-1.3.776-1.746.523-.446 1.125-.678 1.803-.659.718 0 1.3.252 1.746.737a2.42 2.42 0 0 1 .66 1.707c.019.698-.233 1.299-.699 1.823ZM28.51 16.058a1.464 1.464 0 0 0 1.9-.93l2.734-7.817a1.475 1.475 0 0 0-.911-1.92 1.515 1.515 0 0 0-1.92.931l-2.735 7.816c-.271.776.136 1.63.931 1.92Zm5.1.02c.775.29 1.648-.136 1.92-.912l2.734-7.835a1.5 1.5 0 0 0-.93-1.92 1.487 1.487 0 0 0-1.901.93l-2.715 7.817a1.495 1.495 0 0 0 .892 1.92Z"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h64v64H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/client/src/assets/images/GAT.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/client/src/assets/images/sight.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
21
src/client/src/assets/images/sight.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="53" height="49" viewBox="0 0 53 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 20.5712H52.7174V16.6384L26.3662 0.641602L0 16.6384V20.5712ZM26.3659 4.39873L4.75331 17.5032H47.9636L26.3659 4.39873Z" fill="#A6A6A6"/>
|
||||
<path d="M52.7174 46.0488H0V49.0003H52.7174V46.0488Z" fill="#A6A6A6"/>
|
||||
<path d="M50.0742 42.1172H2.64355V45.0686H50.0742V42.1172Z" fill="#A6A6A6"/>
|
||||
<path d="M9.46312 22.2451H5.49805V39.666H9.46312V22.2451Z" fill="#A6A6A6"/>
|
||||
<path d="M11.4453 40.0732H3.51514V41.2243H11.4453V40.0732Z" fill="#A6A6A6"/>
|
||||
<path d="M4.40104 21.3008C4.10066 21.3008 3.86035 21.5369 3.86035 21.832C3.86035 22.1272 4.10066 22.3633 4.40104 22.3633C4.70143 22.3633 4.94173 22.1272 4.94173 21.832H10.0182C10.0182 22.1272 10.2585 22.3633 10.5589 22.3633C10.8593 22.3633 11.0996 22.1272 11.0996 21.832C11.0996 21.5369 10.8593 21.3008 10.5589 21.3008H4.40104Z" fill="#A6A6A6"/>
|
||||
<path d="M22.0976 22.2451H18.1326V39.666H22.0976V22.2451Z" fill="#A6A6A6"/>
|
||||
<path d="M24.0815 40.0732H16.1514V41.2243H24.0815V40.0732Z" fill="#A6A6A6"/>
|
||||
<path d="M17.0356 21.3008C16.7352 21.3008 16.4949 21.5369 16.4949 21.832C16.4949 22.1272 16.7352 22.3633 17.0356 22.3633C17.3359 22.3633 17.5763 22.1272 17.5763 21.832H22.6527C22.6527 22.1272 22.8931 22.3633 23.1934 22.3633C23.4938 22.3633 23.7341 22.1272 23.7341 21.832C23.7341 21.5369 23.4938 21.3008 23.1934 21.3008H17.0356Z" fill="#A6A6A6"/>
|
||||
<path d="M34.7414 22.2451H30.7764V39.666H34.7414V22.2451Z" fill="#A6A6A6"/>
|
||||
<path d="M36.7236 40.0732H28.7935V41.2243H36.7236V40.0732Z" fill="#A6A6A6"/>
|
||||
<path d="M29.6796 21.3008C29.3792 21.3008 29.1389 21.5369 29.1389 21.832C29.1389 22.1272 29.3792 22.3633 29.6796 22.3633C29.98 22.3633 30.2203 22.1272 30.2203 21.832H35.2968C35.2968 22.1272 35.5371 22.3633 35.8375 22.3633C36.1379 22.3633 36.3782 22.1272 36.3782 21.832C36.3782 21.5369 36.1379 21.3008 35.8375 21.3008H29.6796Z" fill="#A6A6A6"/>
|
||||
<path d="M47.3755 22.2461H43.4104V39.667H47.3755V22.2461Z" fill="#A6A6A6"/>
|
||||
<path d="M49.3596 40.0732H41.4294V41.2243H49.3596V40.0732Z" fill="#A6A6A6"/>
|
||||
<path d="M42.3136 21.3008C42.0133 21.3008 41.7729 21.5369 41.7729 21.832C41.7729 22.1272 42.0133 22.3633 42.3136 22.3633C42.614 22.3633 42.8543 22.1272 42.8543 21.832H47.9308C47.9308 22.1272 48.1711 22.3633 48.4715 22.3633C48.7719 22.3633 49.0122 22.1272 49.0122 21.832C49.0122 21.5369 48.7719 21.3008 48.4715 21.3008H42.3136Z" fill="#A6A6A6"/>
|
||||
<path d="M26.8476 10.0647C26.8476 9.82856 26.6148 9.63672 26.3294 9.63672C26.0441 9.63672 25.8113 9.82856 25.8113 10.0647V10.4041C25.8113 10.6845 26.0441 10.9132 26.3294 10.9132C26.6148 10.9132 26.8476 10.6845 26.8476 10.4041V10.0647Z" fill="#A6A6A6"/>
|
||||
<path d="M19.51 12.293C19.2697 12.293 19.0745 12.5217 19.0745 12.8095C19.0745 13.0899 19.2697 13.326 19.51 13.326H20.4112C20.6965 13.326 20.9368 13.0972 20.9368 12.8095C20.9368 12.5291 20.704 12.293 20.4112 12.293H19.51Z" fill="#A6A6A6"/>
|
||||
<path d="M32.2017 12.293C31.9614 12.293 31.7661 12.5217 31.7661 12.8095C31.7661 13.0899 31.9614 13.326 32.2017 13.326H33.1028C33.3882 13.326 33.6285 13.0972 33.6285 12.8095C33.6285 12.5291 33.3957 12.293 33.1028 12.293H32.2017Z" fill="#A6A6A6"/>
|
||||
<path d="M27.6186 11.806L26.9727 11.4002C26.9727 11.4002 26.8976 11.3633 26.8526 11.3633H25.8012C25.7562 11.3633 25.7186 11.378 25.6811 11.4002L25.0353 11.806C24.9677 11.8503 24.9301 11.9167 24.9301 11.9905V12.4627C24.9301 12.507 24.9451 12.5512 24.9677 12.5881L25.5309 13.4072C25.5759 13.4736 25.5835 13.5474 25.5534 13.6211L24.9451 14.9788C24.9451 14.9788 24.9226 15.0378 24.9226 15.0673V16.4176C24.9226 16.5431 25.0202 16.639 25.1479 16.639H27.4909C27.6186 16.639 27.7162 16.5431 27.7162 16.4176V15.0673C27.7162 15.0673 27.7162 15.0083 27.6937 14.9788L27.0854 13.6211C27.0553 13.5547 27.0628 13.4736 27.1079 13.4072L27.6711 12.5881C27.6711 12.5881 27.7087 12.507 27.7087 12.4627V11.9905C27.7087 11.9167 27.6711 11.8429 27.6035 11.806H27.6186Z" fill="#A6A6A6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src/client/src/assets/images/test-image.png
Normal file
|
After Width: | Height: | Size: 692 KiB |
BIN
src/client/src/assets/images/transfer-button.png
Normal file
|
After Width: | Height: | Size: 880 B |
BIN
src/client/src/assets/images/Герб.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
15
src/client/src/assets/tramPosition/Tram Base.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="112" height="83" viewBox="0 0 112 83" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_308_78336)">
|
||||
<path d="M33.0692 75.6911C51.2832 75.6911 66.0485 60.9257 66.0485 42.7118C66.0485 24.4978 51.2832 9.73242 33.0692 9.73242C14.8552 9.73242 0.0898438 24.4978 0.0898438 42.7118C0.0898438 60.9257 14.8552 75.6911 33.0692 75.6911Z" fill="#E20613"/>
|
||||
</g>
|
||||
<path d="M52.0945 19.7037L51.8353 42.933L51.5761 66.1621L111.279 43.501L52.0945 19.7037Z" fill="url(#paint0_linear_308_78336)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_308_78336" x1="51.7446" y1="43.1963" x2="111.279" y2="43.7818" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E20613" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#E20613"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_308_78336">
|
||||
<rect width="65.9587" height="65.9587" fill="white" transform="translate(0.0898438 9.73242)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
35
src/client/src/assets/tramPosition/Tram Bottom Left.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/tramPosition/Tram Bottom Right.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/tramPosition/Tram Left.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/tramPosition/Tram Right.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/tramPosition/Tram Top Left.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
35
src/client/src/assets/tramPosition/Tram Top Right.svg
Normal file
|
After Width: | Height: | Size: 63 KiB |
22
src/client/src/assets/tramPosition/Tram.svg
Normal file
|
After Width: | Height: | Size: 62 KiB |
4
src/client/src/assets/tramPosition/Tram_Second.svg
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
11
src/client/src/assets/transport-icons/bus.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_27605)">
|
||||
<path d="M63.0304 32.1746C62.9334 49.3188 48.9697 63.1273 31.8255 63.0304C14.6813 62.9334 0.872776 48.9697 0.969746 31.8061C1.08611 14.6813 15.0497 0.872776 32.1746 0.969746C49.3188 1.06672 63.1467 15.0497 63.0304 32.1746Z" fill="white"/>
|
||||
<path d="M41.6582 12.2764L22.5939 12.16C19.5879 12.1406 17.1248 14.5649 17.1054 17.5903L16.9503 44.2182C16.9503 46.7976 18.7151 48.9503 21.12 49.5322V49.7261C21.12 50.8315 22.0121 51.7237 23.1176 51.7431C24.223 51.7431 25.1151 50.8509 25.1345 49.7649V49.7455L38.5551 49.8231V49.8425C38.5551 50.9285 39.4473 51.84 40.5333 51.8594C41.6388 51.8594 42.5503 50.9673 42.5503 49.8812V49.7455C45.0521 49.2606 46.9333 47.0691 46.9527 44.4121L47.1079 17.8037C47.1079 14.7588 44.6642 12.2958 41.6582 12.2764ZM20.9067 22.4582C20.9067 21.0231 22.3612 19.8788 24.0873 19.8788L39.8739 19.9758C41.6194 19.9758 43.0545 21.1588 43.0351 22.594L42.9963 31.8061C42.977 33.2412 41.5418 34.3855 39.7963 34.3855L24.0097 34.2885C22.2642 34.2885 20.8485 33.1055 20.8485 31.6703L20.9067 22.4582ZM24.8436 41.9103C24.3782 42.4146 23.7963 42.6473 23.0594 42.6473C22.3418 42.6473 21.7406 42.3952 21.2557 41.8909C20.7709 41.3867 20.5382 40.7855 20.5382 40.0679C20.5382 39.3503 20.7903 38.7685 21.3139 38.3225C21.8376 37.8764 22.4388 37.6631 23.1176 37.6631C23.8351 37.6631 24.3976 37.9152 24.863 38.3806C25.3091 38.8655 25.5418 39.4473 25.5418 40.0873C25.5612 40.8049 25.3091 41.4255 24.8436 41.9103ZM42.4533 42.0073C41.9685 42.5115 41.3866 42.7443 40.6691 42.7443C39.9515 42.7443 39.3503 42.4921 38.8848 41.9879C38.4 41.4837 38.1479 40.8825 38.1673 40.1649C38.1673 39.4473 38.4194 38.8655 38.943 38.4194C39.4667 37.9734 40.0679 37.76 40.7467 37.76C41.4642 37.76 42.0266 37.9928 42.4921 38.4776C42.9382 38.9818 43.1709 39.5249 43.1515 40.1843C43.1515 40.9406 42.9188 41.5418 42.4533 42.0073Z" fill="#816C5A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_27605">
|
||||
<rect width="64" height="64" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
4
src/client/src/assets/transport-icons/metroBlue.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#2D3B8E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
4
src/client/src/assets/transport-icons/metroGreen.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#056939"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
4
src/client/src/assets/transport-icons/metroOrange.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#EB5C2C"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
4
src/client/src/assets/transport-icons/metroPurple.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#64328A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
4
src/client/src/assets/transport-icons/metroRed.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#E52629"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
12
src/client/src/assets/transport-icons/station.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_549_95086)">
|
||||
<path d="M18 9.05328C17.9705 14.0221 13.9179 18.0293 8.94679 17.9998C3.97795 17.9704 -0.0293203 13.92 0.000161638 8.9467C0.0296435 3.98017 4.08 -0.0270761 9.05111 0.000137794C14.0222 0.0296195 18.0272 4.08222 17.9977 9.05328H18Z" fill="white" />
|
||||
<path d="M11.7886 5.04349L6.2596 5.01174C5.38195 5.0072 4.66985 5.7125 4.66531 6.58561L4.61996 14.3075C4.61542 15.1829 5.31845 15.895 6.19384 15.9018L11.7228 15.9358C12.5982 15.9404 13.3103 15.2351 13.3149 14.3597L13.3602 6.63777C13.367 5.76239 12.6617 5.05029 11.7863 5.04349H11.7886ZM5.76975 7.99846C5.76975 7.58345 6.18703 7.24781 6.69503 7.25008L11.2715 7.27729C11.7818 7.28183 12.1923 7.61974 12.19 8.03475L12.1741 10.713C12.1719 11.1258 11.7591 11.4614 11.2466 11.4592L6.67008 11.4319C6.16209 11.4274 5.74934 11.0895 5.75161 10.6722L5.76748 7.99619L5.76975 7.99846ZM6.91274 13.6385C6.77894 13.7814 6.60658 13.854 6.39794 13.8517C6.1893 13.8517 6.01468 13.7769 5.87634 13.6317C5.738 13.4866 5.66997 13.3097 5.66997 13.1056C5.66997 12.897 5.7448 12.7269 5.89675 12.5976C6.04416 12.4683 6.21878 12.4026 6.41835 12.4026C6.62473 12.4026 6.79481 12.4774 6.92181 12.6157C7.05561 12.7564 7.11911 12.9196 7.11458 13.1124C7.11458 13.3165 7.04654 13.4934 6.91047 13.6363L6.91274 13.6385ZM12.0222 13.668C11.8838 13.8132 11.7138 13.8857 11.5074 13.8835C11.2987 13.8789 11.1241 13.8064 10.988 13.6612C10.8497 13.5161 10.7794 13.3414 10.7817 13.1373C10.7817 12.9264 10.8588 12.7586 11.0062 12.6271C11.1559 12.5001 11.3282 12.4298 11.5301 12.4298C11.7364 12.4298 11.9065 12.5046 12.0335 12.6452C12.1651 12.7836 12.2286 12.9491 12.2263 13.1419C12.2263 13.3483 12.1583 13.5229 12.0222 13.6657V13.668Z" fill="#816C5A" />
|
||||
<path d="M6.95597 2.58543L7.63859 4.60833C7.70435 4.81243 7.92433 4.91902 8.12617 4.85325C8.33255 4.78522 8.43687 4.56297 8.3711 4.36113L7.8631 2.85303L10.1173 2.86664L9.58893 4.36794C9.52089 4.56977 9.62748 4.79202 9.82932 4.86232C10.0312 4.93262 10.2511 4.8283 10.3237 4.6242L11.029 2.60811C11.0358 2.59677 11.0358 2.58089 11.0403 2.56729C11.0403 2.56729 11.0403 2.56275 11.0403 2.55368C11.0426 2.5378 11.0471 2.51739 11.0471 2.49925C11.0471 2.49245 11.0494 2.48791 11.0494 2.48338C11.0494 2.47657 11.0471 2.4743 11.0471 2.4743C11.0471 2.44709 11.0449 2.42668 11.0403 2.404C11.0154 2.27474 10.9247 2.15908 10.7909 2.11372C10.7365 2.09558 10.682 2.08878 10.6253 2.09558C10.614 2.09558 10.6049 2.09104 10.5913 2.09104L7.39366 2.0729C7.38232 2.0729 7.37098 2.0729 7.36191 2.0729C7.30975 2.0661 7.25305 2.0729 7.19863 2.09104C7.07163 2.13413 6.98091 2.23845 6.9469 2.36545C6.93782 2.39493 6.93329 2.42441 6.93329 2.45843C6.93329 2.49698 6.94009 2.531 6.95143 2.56502C6.95143 2.57182 6.95597 2.57862 6.95597 2.58316V2.58543Z" fill="#816C5A" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_549_95086">
|
||||
<rect width="18" height="18" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
4
src/client/src/assets/transport-icons/train.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M63.0303 31.5927C63.0303 14.4485 49.0279 0.581787 31.9224 0.581787C14.817 0.581787 0.969696 14.4291 0.969696 31.5927C0.969696 48.7369 14.7588 62.6424 31.9224 62.6424C49.0861 62.6424 63.0303 48.7563 63.0303 31.5927Z" fill="white"/>
|
||||
<path d="M45.2655 39.5054V18.2497C45.2655 15.2824 42.7443 12.4703 39.2534 12.4703H33.2994L34.6764 8.16481V8.04845C38.4 8.14542 39.0788 8.45572 39.137 8.68845C39.137 8.8436 39.3891 8.88239 39.3891 8.88239C39.3891 8.88239 39.8352 8.8436 39.8352 8.63027C39.8352 8.55269 39.8352 8.28117 39.6994 8.14542C39.0206 7.60239 36.7322 7.2533 31.8255 7.2533C29.537 7.2533 27.6558 7.40845 26.3176 7.54421C24.4752 7.73814 23.5831 8.04845 23.5831 8.63027C23.5831 8.8242 23.777 8.88239 23.777 8.88239C23.777 8.88239 24.3782 8.8436 24.3782 8.68845C24.3782 8.61087 25.0182 8.14542 28.8388 8.04845V8.14542L30.6231 12.4509H24.5722C21.1394 12.4509 18.5794 15.263 18.5794 18.2303V39.486C18.5794 42.4533 20.8679 44.8387 23.1952 45.1878H25.2703L19.6073 54.9236H22.2255L22.3806 54.7297C22.3806 54.6521 22.5746 54.5939 22.5746 54.5939H41.1346C41.1346 54.5939 41.3867 54.6327 41.5225 54.7297V54.9236H44.2958L38.6328 45.1878H40.6691C42.977 44.8581 45.2655 42.4727 45.2655 39.5054ZM29.5952 8.00966H34.0558L32.5043 12.4703H31.3213L29.5952 8.00966ZM28.0437 14.6424C28.0437 14.1381 28.4897 13.6921 29.0522 13.6921H34.7928C35.394 13.6921 35.7819 14.1381 35.7819 14.6424V16.3684C35.7819 16.9115 35.3746 17.3188 34.7928 17.3188H29.0522C28.4122 17.3188 28.0437 16.9115 28.0437 16.3684V14.6424ZM21.7406 21.4691C21.7406 19.84 22.8849 18.5018 24.7273 18.5018H39.0982C41.0376 18.5018 42.0267 19.84 42.0267 21.4691V25.2703C42.1819 27.1709 40.7467 28.2569 39.0982 28.2569H24.7273C23.04 28.2569 21.7406 27.1709 21.7406 25.2703V21.4691ZM36.0922 45.343C36.0922 45.44 37.0813 47.1272 37.2364 47.3988C37.3528 47.5345 37.2364 47.5927 37.2364 47.5927H26.9188C26.9188 47.5927 26.6085 47.5539 26.6667 47.3988C26.7831 47.1466 27.7722 45.4594 27.811 45.343C27.8497 45.2654 27.9661 45.2072 27.9661 45.2072H35.8013C35.7819 45.2072 35.9758 45.2654 36.0922 45.343ZM24.4752 42.1236C23.0013 42.1236 21.7406 40.9988 21.7406 39.4666C21.7406 38.0121 23.0013 36.8872 24.4752 36.8872C25.8134 36.8872 27.0546 38.0315 27.0546 39.4666C27.0546 40.9988 25.8134 42.1236 24.4752 42.1236ZM38.7685 50.0169C38.8655 50.0557 39.7188 51.84 39.8546 52.0533C40.0097 52.1503 39.8546 52.2472 39.8546 52.2472H24.1455C24.1455 52.2472 23.8934 52.1697 23.9903 52.0533C24.0873 51.8594 25.1346 50.0751 25.1346 50.0169C25.1734 49.92 25.2897 49.8618 25.2897 49.8618H38.5358C38.5164 49.8618 38.6522 49.9006 38.7685 50.0169ZM36.7322 39.4666C36.7322 38.0121 37.8764 36.8872 39.4085 36.8872C40.7467 36.8872 41.9297 38.0315 41.9297 39.4666C41.9297 40.9988 40.7467 42.1236 39.4085 42.1236C37.8764 42.1236 36.7322 40.9988 36.7322 39.4666Z" fill="#816C5A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
12
src/client/src/assets/transport-icons/tram.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_27600)">
|
||||
<path d="M64 31.7866C63.903 48.9309 49.92 62.7587 32.7952 62.6618C15.6509 62.5648 1.84244 48.5818 1.93941 31.4181C2.03638 14.2739 16 0.446018 33.1443 0.542987C50.2885 0.639957 64.097 14.623 64 31.7866Z" fill="white"/>
|
||||
<path d="M42.5891 17.9588L23.5249 17.8424C20.4994 17.823 18.0364 20.2666 18.017 23.2727L17.8619 49.92C17.8425 52.9454 20.2667 55.3891 23.2922 55.4279L42.3564 55.5442C45.3819 55.5636 47.8255 53.12 47.8449 50.1139L48.0001 23.4666C48.0194 20.4412 45.5952 17.9782 42.5891 17.9588ZM21.8376 28.1406C21.8376 26.7054 23.2728 25.5418 25.0182 25.5612L40.8049 25.6582C42.5697 25.6776 43.9855 26.8412 43.9661 28.2763L43.9079 37.5273C43.9079 38.943 42.4728 40.1066 40.7079 40.1066L24.9213 40.0097C23.1758 39.9903 21.7407 38.8266 21.7601 37.3915L21.8376 28.1406ZM25.7746 47.6121C25.3091 48.0969 24.7273 48.3491 23.9904 48.3491C23.2728 48.3491 22.6716 48.0969 22.1867 47.5927C21.7019 47.0885 21.4691 46.4872 21.4691 45.7697C21.4691 45.0521 21.7213 44.4703 22.2449 44.0242C22.7491 43.5782 23.3504 43.3454 24.0485 43.3454C24.7661 43.3454 25.3479 43.5976 25.794 44.0824C26.2594 44.5672 26.4728 45.1297 26.4534 45.7891C26.4728 46.5066 26.2401 47.1079 25.7746 47.6121ZM43.3843 47.7091C42.8994 48.2133 42.3176 48.4654 41.6001 48.446C40.8825 48.4266 40.2813 48.1745 39.8158 47.6897C39.331 47.1854 39.0982 46.5842 39.0982 45.886C39.0982 45.1491 39.3697 44.5866 39.874 44.1212C40.3976 43.6751 40.9794 43.4424 41.6776 43.4424C42.3952 43.4424 42.977 43.6945 43.4231 44.1794C43.8691 44.6642 44.1019 45.2266 44.0825 45.886C44.1019 46.6036 43.8497 47.2048 43.3843 47.7091Z" fill="#816C5A"/>
|
||||
<path d="M25.9297 9.46426L28.2764 16.4461C28.5091 17.1443 29.2654 17.5127 29.9636 17.2994C30.6618 17.0667 31.0303 16.2909 30.7976 15.5927L29.0521 10.3952L36.8291 10.434L35.006 15.6121C34.7733 16.3103 35.1418 17.0667 35.84 17.3188C36.5382 17.5515 37.2945 17.2024 37.5467 16.5043L39.9709 9.54184C39.9903 9.50305 39.9903 9.44487 40.0097 9.40608C40.0097 9.40608 40.0097 9.38668 40.0097 9.36729C40.0097 9.30911 40.0291 9.25093 40.0291 9.17335C40.0291 9.15396 40.0291 9.13456 40.0291 9.11517C40.0291 9.09577 40.0291 9.07638 40.0291 9.07638C40.0291 8.97941 40.0291 8.92123 40.0097 8.84365C39.9321 8.39759 39.6024 7.99032 39.1564 7.83517C38.9624 7.77699 38.7879 7.75759 38.5939 7.77699C38.5357 7.77699 38.497 7.75759 38.4582 7.75759L27.4424 7.69941C27.4036 7.69941 27.3648 7.69941 27.3261 7.69941C27.1515 7.68002 26.9576 7.69941 26.7636 7.75759C26.3176 7.91274 26.0073 8.26184 25.8909 8.7079C25.8521 8.80487 25.8521 8.90184 25.8521 9.03759C25.8521 9.17335 25.8715 9.28971 25.9103 9.40608C25.9103 9.42547 25.9297 9.44487 25.9297 9.46426Z" fill="#816C5A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_27600">
|
||||
<rect width="64" height="64" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
13
src/client/src/assets/transport-icons/trolley.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_27609)">
|
||||
<path d="M63.0303 32.1746C62.9333 49.3382 48.9503 63.1467 31.8254 63.0691C14.6812 62.9527 0.872715 48.9697 0.969685 31.8061C1.06665 14.6618 15.0497 0.833957 32.1745 0.930927C49.3188 1.04729 63.1273 15.0109 63.0303 32.1746Z" fill="white"/>
|
||||
<path d="M41.6194 16.7176L22.5552 16.6206C19.5491 16.6012 17.0667 19.0448 17.0667 22.0509L16.8922 48.6982C16.8728 51.2776 18.657 53.4303 21.0619 54.0315V54.2061C21.0619 55.3115 21.954 56.2036 23.04 56.223C24.1455 56.223 25.057 55.3309 25.057 54.2448V54.2254L38.4776 54.303C38.4776 55.4085 39.3697 56.32 40.4558 56.32C41.5806 56.32 42.4728 55.4279 42.4728 54.3224V54.1867C44.9746 53.7018 46.8558 51.5103 46.8752 48.8727L47.0303 22.2254C47.0691 19.2 44.6255 16.737 41.6194 16.7176ZM20.8485 26.8994C20.8485 25.4836 22.2837 24.32 24.0485 24.3394L39.8158 24.417C41.5612 24.417 42.977 25.5806 42.977 27.0158L42.9188 36.2667C42.9188 37.7018 41.4837 38.8654 39.7188 38.8461L23.9322 38.7491C22.1673 38.7491 20.7515 37.5661 20.7709 36.1309L20.8485 26.8994ZM24.7855 46.3903C24.32 46.8751 23.7382 47.1273 23.0012 47.1273C22.2837 47.1273 21.6825 46.8751 21.1976 46.3709C20.7128 45.8667 20.4994 45.2654 20.4994 44.5479C20.4994 43.8303 20.7515 43.2485 21.2752 42.8024C21.7988 42.3564 22.3806 42.143 23.0788 42.143C23.777 42.143 24.3782 42.3951 24.8243 42.88C25.2703 43.3648 25.5031 43.9467 25.5031 44.5867C25.5031 45.2848 25.2509 45.9054 24.7855 46.3903ZM42.3952 46.5067C41.9297 46.9915 41.3285 47.2436 40.6109 47.2242C39.8934 47.2242 39.3116 46.9721 38.8267 46.4679C38.3419 45.9636 38.1091 45.343 38.1091 44.6448C38.1091 43.9273 38.3806 43.3454 38.8849 42.8994C39.4085 42.4533 40.0097 42.2206 40.6885 42.24C41.4061 42.24 41.9879 42.4921 42.434 42.977C42.88 43.4618 43.0934 44.0242 43.0934 44.6836C43.1128 45.3818 42.8606 45.983 42.3952 46.5067Z" fill="#816C5A"/>
|
||||
<path d="M28.5091 16.0581C29.2849 16.349 30.1576 15.9223 30.4097 15.1272L33.1443 7.31144C33.4352 6.51629 33.0279 5.66296 32.2328 5.39144C31.457 5.11993 30.6037 5.5272 30.3128 6.32235L27.5782 14.1381C27.3067 14.9139 27.714 15.7672 28.5091 16.0581Z" fill="#816C5A"/>
|
||||
<path d="M33.6097 16.0775C34.3854 16.3684 35.2581 15.9418 35.5296 15.166L38.2642 7.33085C38.5357 6.5357 38.1284 5.68237 37.3333 5.41085C36.5575 5.13933 35.7042 5.54661 35.4327 6.34176L32.7175 14.1575C32.4266 14.9527 32.8533 15.806 33.6097 16.0775Z" fill="#816C5A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_27609">
|
||||
<rect width="64" height="64" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/client/src/assets/video/taganai.mp4
Normal file
BIN
src/client/src/assets/weather-icons/humidity.png
Normal file
|
After Width: | Height: | Size: 715 B |
BIN
src/client/src/assets/weather-icons/icon1.png
Normal file
|
After Width: | Height: | Size: 638 B |
BIN
src/client/src/assets/weather-icons/icon2.png
Normal file
|
After Width: | Height: | Size: 675 B |
BIN
src/client/src/assets/weather-icons/icon3.png
Normal file
|
After Width: | Height: | Size: 638 B |
BIN
src/client/src/assets/weather-icons/wind.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
src/client/src/assets/weather-status/sunny.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
0
src/client/src/components/ErrorBoundary.jsx
Normal file
6
src/client/src/components/ErrorOverlay.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.error-gif {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 999999;
|
||||
}
|
||||
24
src/client/src/components/ErrorOverlay.jsx
Normal file
@@ -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 (
|
||||
<div className="error-overlay">
|
||||
<div className="error-content">
|
||||
<img src="/loader.gif" alt="Error" className="error-gif" />
|
||||
<p className="error-message">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ErrorOverlay;
|
||||
56
src/client/src/components/Fullscreen3DModal.jsx
Normal file
@@ -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(
|
||||
<div className="fullscreen-3d-modal">
|
||||
<div className="fullscreen-3d-content">
|
||||
<div className="fullscreen-3d-viewer">
|
||||
<ThreeViewErrorBoundary
|
||||
resetKey={`fullscreen-${fileUrl}-${fullscreenResetKey}`}
|
||||
onReset={() => {
|
||||
setFullscreenResetKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
<ThreeView
|
||||
key={`fullscreen-${fileUrl}-${fullscreenResetKey}`}
|
||||
fileUrl={fileUrl}
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
/>
|
||||
</ThreeViewErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fullscreen-3d-actions">
|
||||
<button title="Увеличить масштаб" disabled={scale >= 1}>
|
||||
<img src={scale_plus} alt="Увеличить" />
|
||||
</button>
|
||||
<button title="Уменьшить масштаб" disabled={scale <= 0.1}>
|
||||
<img src={scale_minus} alt="Уменьшить" />
|
||||
</button>
|
||||
|
||||
<button onPointerUp={onClose} title="Закрыть">
|
||||
<img src={closeIcon} alt="Закрыть" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default Fullscreen3DModal;
|
||||
520
src/client/src/components/ListOfSights.jsx
Normal file
@@ -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 (
|
||||
<div className="right-widget">
|
||||
{currentSelectedSight && (
|
||||
<SightFrame
|
||||
key={currentSelectedSight.id}
|
||||
media={sightFrameMedia}
|
||||
sight_id={currentSelectedSight.id}
|
||||
sight_name={currentSelectedSight.short_name || currentSelectedSight.name}
|
||||
selectedLanguageRight={selectedLanguageRight}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`list-of-sights ${
|
||||
isRightWidgetSelectorOpen ? "is-open" : ""
|
||||
}`}
|
||||
>
|
||||
<ListHeader
|
||||
isOpen={isRightWidgetSelectorOpen}
|
||||
onIconClick={handleIconClick}
|
||||
selectedLanguageRight={selectedLanguageRight}
|
||||
isManualSelection={isManualSelection}
|
||||
selectedSightId={selectedSightId}
|
||||
nearestSightId={nearestSightId}
|
||||
onTransferToggle={handleTransferToggle}
|
||||
isTransferWidgetOpen={isTransferWidgetOpen}
|
||||
onBackToNearest={handleBackToNearest}
|
||||
isLangOpen={isLangMenuOpen}
|
||||
/>
|
||||
|
||||
<TransferWidget
|
||||
isOpen={isTransferWidgetOpen}
|
||||
selectedLanguageRight={selectedLanguage}
|
||||
/>
|
||||
|
||||
<LanguageSelector
|
||||
selectedLanguageRight={selectedLanguageRight}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
isOpen={isLangMenuOpen}
|
||||
onToggle={() => setIsLangMenuOpen(true)}
|
||||
onBackToNearest={handleBackToNearest}
|
||||
/>
|
||||
|
||||
<div className="sights-line"></div>
|
||||
|
||||
<div className="alphabet-position">
|
||||
<SightsList
|
||||
isOpen={isRightWidgetSelectorOpen}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
sightData={sightData}
|
||||
onSightClick={handleSightClick}
|
||||
sightRefs={sightRefs}
|
||||
sightsListRef={sightsListRef}
|
||||
highlightedSights={highlightedSights}
|
||||
/>
|
||||
|
||||
<AlphabetNavigator
|
||||
ref={alphabetRef}
|
||||
selectedLetter={selectedLetter}
|
||||
onLetterClick={handleLetterClick}
|
||||
sightData={sightData}
|
||||
selectedLanguage={selectedLanguageRight}
|
||||
isDisabled={isAlphabetDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="transfer-button-container"
|
||||
style={{
|
||||
transition: "transform 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "black",
|
||||
width: "48px",
|
||||
height: "48px",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
className="transfer-button"
|
||||
>
|
||||
<svg
|
||||
onPointerUp={handleTransferToggle}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
className="icon-color-animated"
|
||||
fill="none"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
"--animated-color": currentColor,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M24.0013 0C23.3513 0 22.7013 0.03 22.0413 0.08C10.4213 1 1.01127 10.41 0.0812683 22.03C-0.428732 28.39 1.55127 34.28 5.14127 38.83C5.60127 39.42 5.76127 40.21 5.46127 40.89C4.76127 42.43 3.63127 43.64 3.05127 44.23C2.50127 44.78 1.97127 45.27 1.38127 45.7C0.791268 46.13 0.841268 47.03 1.45127 47.42C2.08127 47.82 3.01127 47.99 4.12127 47.99C6.84127 47.99 10.5813 46.99 13.3013 46.06C13.5013 45.99 13.7013 45.96 13.9113 45.96C14.1813 45.96 14.4613 46.02 14.7113 46.13C17.5713 47.33 20.7113 48 24.0013 48C24.6513 48 25.3213 47.97 25.9813 47.92C37.6313 46.98 47.0613 37.51 47.9313 25.85C48.9913 11.76 37.8713 0 24.0013 0ZM29.5113 37.71C29.4813 37.82 29.3413 37.94 29.2313 37.98C27.7413 38.48 26.2713 39.12 24.7313 39.42C22.9513 39.77 21.1413 39.68 19.5513 38.58C18.2213 37.66 17.7313 36.36 17.8113 34.8C17.9013 32.91 18.5113 31.13 19.0013 29.33C19.5213 27.42 20.1113 25.53 20.4613 23.59C20.9413 20.94 19.7813 20.48 17.3913 20.74C16.8013 20.8 16.2313 21.04 15.5813 21.22C15.7213 20.62 15.8313 20.08 15.9913 19.55C16.0213 19.45 19.6313 17.94 21.4413 17.78C23.3513 17.61 25.2013 17.8 26.6013 19.32C27.3913 20.17 27.6113 21.21 27.5913 22.33C27.5413 24.8 26.5813 27.07 25.9813 29.42C25.6113 30.86 25.2513 32.3 24.9313 33.75C24.8413 34.15 24.8413 34.59 24.8813 35C24.9613 35.97 25.4413 36.39 26.4313 36.57C27.6213 36.78 28.7213 36.45 29.9313 36.04C29.7813 36.66 29.6613 37.19 29.5213 37.7L29.5113 37.71ZM26.8513 15.21C26.6513 15.23 26.4613 15.23 26.2013 15.25C24.4013 15.27 22.7313 14.15 22.2013 12.52C21.5913 10.65 22.5613 8.71 24.5313 7.86C26.7913 6.88 29.5813 8.07 30.2713 10.33C31.0513 12.87 29.0613 14.95 26.8613 15.21H26.8513Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ListOfSights;
|
||||
99
src/client/src/components/ListOfSights/AlphabetNavigator.jsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`alphabet ${isDisabled ? "alphabet-disabled" : ""}`}
|
||||
ref={ref}
|
||||
>
|
||||
{availableLetters.map((letter) => (
|
||||
<span
|
||||
key={letter}
|
||||
className={`alphabet-letter ${
|
||||
selectedLetter === letter ? "active" : ""
|
||||
}`}
|
||||
onPointerUp={() => !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}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AlphabetNavigator;
|
||||
172
src/client/src/components/ListOfSights/LanguageSelector.jsx
Normal file
@@ -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 (
|
||||
<div className="list-of-sights-lang-open">
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "5px",
|
||||
margin: "-5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerUp={() => SelectLangHandler(LANGUAGES.EN)}
|
||||
>
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="button"
|
||||
aria-label={`Выбрать ${LANGUAGE_NAMES.EN} язык`}
|
||||
>
|
||||
<path
|
||||
className={selectedLanguageRight === LANGUAGES.EN ? `active` : ""}
|
||||
d="M14 0C6.27083 0 0 6.27083 0 14C0 21.7292 6.27083 28 14 28C21.7292 28 28 21.7292 28 14C28 6.27083 21.7292 0 14 0ZM12.5825 19.7108H4.90583V8.25417H12.5708V10.1675H7.2625V12.8975H11.795V14.7467H7.2625V17.815H12.5825V19.7108ZM23.065 19.7108H20.7025L16.1058 12.1742V19.7108H13.7433V8.25417H16.1058L20.7083 15.8083V8.25417H23.0592V19.7108H23.065Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "5px",
|
||||
margin: "-5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerUp={() => SelectLangHandler(LANGUAGES.RU)}
|
||||
>
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="button"
|
||||
aria-label={`Выбрать ${LANGUAGE_NAMES.RU} язык`}
|
||||
>
|
||||
<path
|
||||
className={selectedLanguageRight === LANGUAGES.RU ? `active` : ""}
|
||||
d="M14 0C6.27083 0 0 6.27083 0 14C0 21.7292 6.27083 28 14 28C21.7292 28 28 21.7292 28 14C28 6.27083 21.7292 0 14 0ZM14.1167 19.5708H11.62L9.5025 15.435H7.6475V19.5708H5.32V8.27167H9.52C10.8558 8.27167 11.8825 8.56917 12.6117 9.16417C13.3408 9.75917 13.6967 10.5992 13.6967 11.6842C13.6967 12.4542 13.5275 13.0958 13.195 13.615C12.8625 14.1283 12.355 14.5425 11.6783 14.8458L14.1225 19.4658V19.5767L14.1167 19.5708ZM23.5083 15.715C23.5083 16.9517 23.1233 17.9317 22.3475 18.6492C21.5717 19.3667 20.5158 19.7283 19.1742 19.7283C17.8325 19.7283 16.8058 19.3783 16.03 18.6783C15.2542 17.9783 14.8575 17.0217 14.84 15.7967V8.27167H17.1675V15.7325C17.1675 16.4733 17.3425 17.01 17.6983 17.3483C18.0542 17.6867 18.5442 17.8558 19.1683 17.8558C20.475 17.8558 21.14 17.1675 21.1633 15.7908V8.27167H23.4967V15.715H23.5083Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
<path
|
||||
className={selectedLanguageRight === LANGUAGES.RU ? `active` : ""}
|
||||
d="M9.51337 10.1558H7.64087V13.5449H9.5192C10.1025 13.5449 10.5575 13.3933 10.8784 13.0958C11.1992 12.7983 11.3567 12.3899 11.3567 11.8649C11.3567 11.3399 11.205 10.9141 10.9017 10.6049C10.5984 10.2958 10.1317 10.1441 9.50753 10.1441L9.51337 10.1558Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "5px",
|
||||
margin: "-5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerUp={() => SelectLangHandler(LANGUAGES.ZH)}
|
||||
>
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="button"
|
||||
aria-label={`Выбрать ${LANGUAGE_NAMES.ZH} язык`}
|
||||
>
|
||||
<path
|
||||
className={selectedLanguageRight === LANGUAGES.ZH ? `active` : ""}
|
||||
d="M6.00075 11.8895H3.66975V14.0858H6.00075V11.8895Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
<path
|
||||
className={selectedLanguageRight === LANGUAGES.ZH ? `active` : ""}
|
||||
d="M7.99399 14.0858H10.3372V11.8895H7.99399V14.0858Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
<path
|
||||
className={selectedLanguageRight === LANGUAGES.ZH ? `active` : ""}
|
||||
d="M21.0731 11.6935H17.4169C17.8853 12.7987 18.5026 13.7925 19.2799 14.6543C20.0145 13.8208 20.6101 12.8407 21.0731 11.6935Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
<path
|
||||
className={selectedLanguageRight === LANGUAGES.ZH ? `active` : ""}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M14 28C21.732 28 28 21.732 28 14C28 6.26802 21.732 0 14 0C6.26802 0 0 6.26802 0 14C0 21.732 6.26802 28 14 28ZM6.00075 7.875H7.99399V10.0065H12.3183V16.5935H10.3372V15.9688H7.99399V19.8152H6.00075V15.9688H3.66975V16.6548H1.75V10.0065H6.00075V7.875ZM18.2875 7.875H20.244V9.81053H25.1318V11.6935H23.2684C22.6357 13.3879 21.8018 14.807 20.7556 15.9871C21.9618 16.8722 23.4296 17.5316 25.1815 17.9253L25.5774 18.0143L25.2854 18.2962C24.9523 18.6178 24.487 19.295 24.26 19.7131L24.1782 19.8637L24.0124 19.8204C22.0725 19.3134 20.4895 18.5221 19.1902 17.4564C17.8673 18.489 16.2977 19.2695 14.4678 19.8421L14.2852 19.8992L14.2043 19.7257C14.0422 19.3785 13.5878 18.6836 13.303 18.3394L13.0846 18.0756L13.4158 17.9884C15.1293 17.5378 16.5681 16.8993 17.7551 16.0379C16.7286 14.8202 15.9361 13.3666 15.3073 11.6935H13.4733V9.81053H18.2875V7.875Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-of-sights-lang-closed">
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "5px",
|
||||
margin: "-5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerUp={onToggle}
|
||||
>
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="button"
|
||||
aria-label="Открыть меню выбора языка"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 14C0 6.27083 6.27083 0 14 0C21.7292 0 28 6.27083 28 14C28 21.7292 21.7292 28 14 28C6.27083 28 0 21.7292 0 14ZM21.0292 11.3225L22.3242 14.8575L23.6075 11.3108L22.8492 11.5325C22.8433 11.5208 22.8433 11.2875 22.8433 11.27C22.505 7.23917 19.2442 4.17083 15.1317 4.17083C14.8283 4.17083 14.5775 4.42167 14.5775 4.725C14.5775 5.02833 14.8283 5.27917 15.1317 5.27917C18.62 5.27917 21.3967 7.8575 21.735 11.2642C21.735 11.2729 21.7365 11.3371 21.7379 11.4012C21.7394 11.4654 21.7408 11.5296 21.7408 11.5383L21.0292 11.3225ZM5.19369 16.0632C6.62272 15.7248 7.95252 15.0756 9.08238 14.1655L9.08287 14.166C10.0404 15.0807 11.2288 15.7374 12.5306 16.0721C12.5925 16.0795 12.655 16.0795 12.7169 16.0721C13.0195 16.1043 13.3172 15.9781 13.4973 15.7411C13.6778 15.5042 13.7132 15.1924 13.5907 14.9232C13.4678 14.654 13.2059 14.4684 12.9033 14.4362C11.9467 14.1856 11.072 13.7047 10.3618 13.0372C11.8117 11.6743 12.8936 9.98932 13.5133 8.12953C13.5757 7.87948 13.5128 7.61633 13.3438 7.41768C13.1841 7.21156 12.9328 7.0905 12.6661 7.0905H9.70105V6.06783C9.70105 5.77571 9.53936 5.50602 9.27747 5.35972C9.01558 5.21343 8.69221 5.21343 8.43032 5.35972C8.16842 5.50555 8.00674 5.77571 8.00674 6.06783V7.1064H5.04169C4.73865 7.1064 4.45933 7.26251 4.30781 7.51537C4.15629 7.76823 4.15629 8.08046 4.30781 8.33332C4.45933 8.58618 4.73913 8.7423 5.04169 8.7423H11.4714C10.8968 9.915 10.1174 10.983 9.1671 11.8996C8.65493 11.3153 8.20667 10.6825 7.82859 10.0099C7.74339 9.79395 7.56767 9.62335 7.34450 9.53921C7.12134 9.45508 6.87203 9.46630 6.65806 9.56959C6.44409 9.67289 6.28531 9.85891 6.22093 10.0814C6.15655 10.3039 6.19285 10.5423 6.32065 10.7376C6.76989 11.5163 7.30045 12.2483 7.90459 12.9218C7.01193 13.6341 5.96775 14.1478 4.84660 14.4268C4.62150 14.4497 4.41527 14.5577 4.27344 14.7274C4.13111 14.8966 4.06431 15.1139 4.08803 15.3308C4.11175 15.5481 4.22357 15.7472 4.39930 15.8842C4.57454 16.0216 4.79964 16.0861 5.02426 16.0632C5.08041 16.0716 5.13754 16.0716 5.19369 16.0632ZM20.3583 22.6802C20.5631 22.7676 20.7949 22.7732 21.0041 22.6956C21.2224 22.6255 21.4015 22.4722 21.5007 22.2717C21.6 22.0711 21.6101 21.8402 21.5293 21.6323L17.7002 12.1197C17.6387 11.966 17.5307 11.8337 17.3899 11.7402C17.249 11.6467 17.0825 11.5962 16.9121 11.5958C16.7426 11.5958 16.5766 11.6448 16.4362 11.7365C16.2958 11.8285 16.1874 11.9589 16.1244 12.1113L12.3292 21.3051C12.2174 21.5766 12.2638 21.8856 12.4516 22.1151C12.6395 22.3446 12.9396 22.46 13.2393 22.418C13.5394 22.3754 13.7931 22.1824 13.9049 21.9104L14.8198 19.6285H18.8523L19.9115 22.2296C19.9928 22.4306 20.1535 22.5928 20.3583 22.6802ZM16.8612 14.6797L18.1915 17.9926H18.191H15.4971L16.8612 14.6797Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default LanguageSelector;
|
||||
89
src/client/src/components/ListOfSights/ListHeader.jsx
Normal file
@@ -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 (
|
||||
<div className="list-of-sights-control" onPointerUp={onIconClick}>
|
||||
<div
|
||||
className="svg-container"
|
||||
onPointerUp={onIconClick}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
aria-label={
|
||||
isOpen
|
||||
? "Свернуть список достопримечательностей"
|
||||
: "Развернуть список достопримечательностей"
|
||||
}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="12"
|
||||
viewBox="0 0 21 12"
|
||||
fill="none"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.15s ease-in-out",
|
||||
}}
|
||||
>
|
||||
<g clipPath="url(#clip0_658_91932)">
|
||||
<path
|
||||
d="M10.4257 3.53158C10.2908 3.71878 10.2229 3.84702 10.1248 3.9453C7.63081 6.43048 5.13303 8.91098 2.63903 11.3962C2.19286 11.8408 1.67878 12.0186 1.06565 11.8361C0.0337182 11.5281 -0.339817 10.2776 0.349713 9.45295C0.425175 9.36309 0.510069 9.28165 0.593077 9.19928C3.45496 6.35279 6.31778 3.50537 9.18060 0.659815C10.0654 -0.219125 10.854 -0.218188 11.7397 0.661687C14.6365 3.54 17.538 6.41457 20.4196 9.30786C20.6706 9.55966 20.88 9.91441 20.962 10.2561C21.1167 10.9019 20.7781 11.515 20.2131 11.8193C19.6462 12.1263 18.9387 12.0392 18.4482 11.5806C18.0709 11.2286 17.7125 10.8561 17.3474 10.491C15.1439 8.29601 12.9405 6.10193 10.7389 3.90599C10.6559 3.82362 10.5889 3.72721 10.4267 3.53158L10.4257 3.53158Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_658_91932">
|
||||
<rect
|
||||
width="21"
|
||||
height="12"
|
||||
fill="white"
|
||||
transform="translate(21 12) rotate(-180)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onPointerUp={onIconClick}
|
||||
className="list-of-sights-title"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
aria-label={
|
||||
isOpen
|
||||
? "Свернуть список достопримечательностей"
|
||||
: "Развернуть список достопримечательностей"
|
||||
}
|
||||
>
|
||||
{getTitle()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListHeader;
|
||||
46
src/client/src/components/ListOfSights/SightComponent.jsx
Normal file
@@ -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 <div className="no-image-placeholder">No Media</div>;
|
||||
}
|
||||
|
||||
const mediaUrl = getMediaUrl(media.id);
|
||||
|
||||
return <img className="sight-image-svg" src={mediaUrl} alt={title} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sight-component ${isHighlighted ? "sight-highlighted" : ""}`}
|
||||
onPointerDown={(e) => handlePointerDown(e, sightId)}
|
||||
onPointerUp={(e) => handlePointerUp(e, sightId, onPointerUp)}
|
||||
onScroll={handleScroll}
|
||||
ref={componentRef}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
aria-label={`Выбрать достопримечательность ${title}`}
|
||||
>
|
||||
<div className="sight-image">{renderThumbnail()}</div>
|
||||
<div className="sight-title" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SightComponent;
|
||||
619
src/client/src/components/ListOfSights/SightFrame.jsx
Normal file
@@ -0,0 +1,619 @@
|
||||
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 <img className="watermark" src={path} alt="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 (
|
||||
<div className="sight-frame-image-placeholder">
|
||||
{isLoadingContent ? "Загрузка медиа..." : "Нет медиа для отображения"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<img
|
||||
key={sectionId}
|
||||
src={currentMediaData.path}
|
||||
alt=""
|
||||
className={className}
|
||||
onError={(e) => {
|
||||
console.warn(
|
||||
`Failed to load image: ${currentMediaData.path}`,
|
||||
);
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<video
|
||||
key={sectionId}
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
className={className}
|
||||
onError={(e) => {
|
||||
console.warn(
|
||||
`Failed to load video: ${currentMediaData.path}`,
|
||||
);
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
>
|
||||
<source src={currentMediaData.path} type="video/mp4" />
|
||||
</video>
|
||||
);
|
||||
case "panorama":
|
||||
return (
|
||||
<div
|
||||
key={sectionId}
|
||||
className={`${className} panorama-container`}
|
||||
style={{ isolation: "isolate" }}
|
||||
>
|
||||
<ReactPhotoSphereViewer
|
||||
src={currentMediaData.path}
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
showFullscreenButton={false}
|
||||
showZoomButton={false}
|
||||
showPhotoDome={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "3d":
|
||||
return (
|
||||
<div
|
||||
key={sectionId}
|
||||
className={className}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<ThreeViewErrorBoundary
|
||||
resetKey={`${currentMediaData.id}-${threeViewResetKey}`}
|
||||
onReset={() => {
|
||||
setThreeViewResetKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
<ThreeView
|
||||
key={`widget-${sectionId}-${isFullscreen3D}-${threeViewResetKey}`}
|
||||
fileUrl={currentMediaData.path}
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
onAspectRatioCalculated={setModelAspectRatio}
|
||||
controlRef={threeViewControlRef}
|
||||
/>
|
||||
</ThreeViewErrorBoundary>
|
||||
<div className="three-d-controls-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title="Уменьшить"
|
||||
onPointerUp={() =>
|
||||
threeViewControlRef.current?.zoomOut?.()
|
||||
}
|
||||
>
|
||||
<MinusIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title="Увеличить"
|
||||
onPointerUp={() =>
|
||||
threeViewControlRef.current?.zoomIn?.()
|
||||
}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title={isFullscreen3D ? "Свернуть" : "Развернуть"}
|
||||
onPointerUp={() => {
|
||||
if (isFullscreen3D) {
|
||||
setIsFullscreen3D(false);
|
||||
} else {
|
||||
setFullscreenFileUrl(currentMediaData.path);
|
||||
setIsFullscreen3D(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFullscreen3D ? <SizeIcon /> : <SizeOpenIcon />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={"fullscreen-3d-button"}
|
||||
title={
|
||||
isFullscreen3D
|
||||
? "Закрыть полноэкранный режим"
|
||||
: "Открыть в полноэкранном режиме"
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
width="44"
|
||||
height="48"
|
||||
viewBox="0 0 44 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M42.69 11.3375L22.79 0.2475C22.2 -0.0825 21.48 -0.0825 20.89 0.2475L1 11.3475C0.38 11.6875 0 12.3475 0 13.0475V34.9875C0 35.6975 0.38 36.3475 1 36.6875L20.9 47.7475C21.49 48.0775 22.2 48.0775 22.79 47.7475L42.69 36.6775C43.31 36.3375 43.69 35.6775 43.69 34.9775V13.0375C43.69 12.3375 43.31 11.6775 42.69 11.3375ZM19.64 40.5975C19.64 41.2375 18.96 41.6375 18.4 41.3275L5.41 34.1075C4.78 33.7575 4.4 33.0975 4.4 32.3875V18.3675C4.4 17.7275 5.08 17.3275 5.64 17.6375L18.63 24.8575C19.26 25.2075 19.64 25.8675 19.64 26.5775V40.5975ZM20.88 21.0675L8.03 13.9275C7.46 13.6075 7.46 12.7875 8.03 12.4675L20.88 5.2975C21.47 4.9675 22.2 4.9675 22.8 5.2975L35.65 12.4575C36.22 12.7775 36.22 13.5975 35.65 13.9175L22.8 21.0675C22.21 21.3975 21.48 21.3975 20.89 21.0675H20.88ZM39.29 32.3875C39.29 33.1075 38.9 33.7575 38.28 34.1075L25.29 41.3275C24.73 41.6375 24.05 41.2375 24.05 40.5975V26.5775C24.05 25.8575 24.44 25.2075 25.06 24.8575L38.05 17.6375C38.61 17.3275 39.29 17.7275 39.29 18.3675V32.3875Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div key={sectionId} className={className}>
|
||||
<div className="sight-frame-image-placeholder">
|
||||
Неподдерживаемый тип медиа: {currentMediaData.type}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error rendering media ${sectionId}:`, error);
|
||||
return (
|
||||
<div key={sectionId} className={className}>
|
||||
<div className="sight-frame-image-placeholder error-message">
|
||||
Ошибка отображения медиа
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// Handle \n line breaks (только в правом виджете)
|
||||
if (sight_name.includes("\n")) {
|
||||
return sight_name.split("\n").map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
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 (
|
||||
<React.Fragment key={index}>
|
||||
<br />
|
||||
{part}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return <React.Fragment key={index}>{part}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
|
||||
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
|
||||
}`}
|
||||
>
|
||||
{sightData?.watermark_lu && !isFullscreen3D && (
|
||||
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
|
||||
)}
|
||||
<div
|
||||
className={`sight-frame-media-stack ${
|
||||
isCurrentMedia3D ? "three-d-view" : ""
|
||||
}`}
|
||||
style={getMediaStackStyle()}
|
||||
>
|
||||
{contentError ? (
|
||||
<div className="sight-frame-image-placeholder error-message">
|
||||
{contentError}
|
||||
</div>
|
||||
) : isLoadingContent || !articleSections ? (
|
||||
<div className="sight-frame-image-placeholder">
|
||||
Загрузка контента...
|
||||
</div>
|
||||
) : (
|
||||
renderCurrentMedia()
|
||||
)}
|
||||
</div>
|
||||
<div className="sight-frame-content">
|
||||
{contentError ? (
|
||||
<p className="error-message">{contentError}</p>
|
||||
) : !currentSection ? (
|
||||
<p>Информация отсутствует.</p>
|
||||
) : (
|
||||
<>
|
||||
{!isFullscreen3D && (
|
||||
<div
|
||||
className={`sight-frame-title ${
|
||||
selectedSection === 0 ? "intro-title" : ""
|
||||
}`}
|
||||
style={{ lineHeight: titleLineHeight }}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
}}
|
||||
>
|
||||
{selectedSection === 0 ? processedSightName : sight_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedSection !== 0 && (
|
||||
<TouchableLayout
|
||||
className="sight-frame-text-wrapper"
|
||||
ref={textWrapperRef}
|
||||
maxHeight="calc(80vh - 354px)"
|
||||
>
|
||||
<div className="sight-frame-text">
|
||||
<ReactMarkdownComponent value={currentSection.body} />
|
||||
</div>
|
||||
</TouchableLayout>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="sight-frame-menu">
|
||||
{selectedSection !== 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
marginTop: "-4.5px",
|
||||
zIndex: 1,
|
||||
paddingLeft: "15px",
|
||||
paddingRight: "7.5px",
|
||||
paddingTop: "4.5px",
|
||||
paddingBottom: "4.5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerUp={() => setSelectedSection(0)}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="25"
|
||||
viewBox="0 0 20 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sightFrameGradient3"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.2)" />
|
||||
<stop offset="100%" stopColor="rgba(255, 255, 255, 0)" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_662_97446">
|
||||
<rect
|
||||
width="20"
|
||||
height="25"
|
||||
fill="white"
|
||||
transform="translate(12.5 0.5) rotate(90)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#clip0_662_97446)">
|
||||
<path
|
||||
d="M4.03158 11.0738C4.21879 11.2087 4.34702 11.2766 4.44531 11.3747C6.93048 13.8687 9.41098 16.3665 11.8962 18.8605C12.3408 19.3067 12.5186 19.8207 12.3361 20.4339C12.0281 21.4658 10.7776 21.8393 9.95295 21.1498C9.86309 21.0743 9.78165 20.9894 9.69928 20.9064C6.85279 18.0446 4.00537 15.1817 1.15982 12.3189C0.280876 11.4341 0.281813 10.6456 1.16169 9.75982C4.04 6.86305 6.91457 3.96155 9.80786 1.07986C10.0597 0.828952 10.4144 0.619547 10.7561 0.537482C11.4019 0.382786 12.015 0.72142 12.3193 1.28644C12.6263 1.85334 12.5392 2.56079 12.0806 3.05129C11.7286 3.4286 11.3561 3.78704 10.991 4.15209C8.79601 6.35557 6.60194 8.55904 4.40599 10.7606C4.32362 10.8436 4.22721 10.9106 4.03158 11.0729L4.03158 11.0738Z"
|
||||
fill="url(#sightFrameGradient3)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{contentError ? (
|
||||
<p className="error-message">{contentError}</p>
|
||||
) : (
|
||||
articleSections &&
|
||||
articleSections.length > 1 &&
|
||||
articleSections.slice(1).map((section, index) => (
|
||||
<div
|
||||
onPointerUp={() => setSelectedSection(index + 1)}
|
||||
key={section.id || section.heading || index}
|
||||
className={`sight-frame-menu-point ${
|
||||
index + 1 === selectedSection ? "active" : ""
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
>
|
||||
{section.heading}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default SightFrame;
|
||||
50
src/client/src/components/ListOfSights/SightsList.jsx
Normal file
@@ -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 (
|
||||
<TouchableLayout
|
||||
className={`list-of-sights-content ${isOpen ? "is-open" : ""}`}
|
||||
ref={sightsListRef}
|
||||
>
|
||||
<div className="list-of-sights-grid">
|
||||
{isLoading ? (
|
||||
<p>Загрузка достопримечательностей...</p>
|
||||
) : error ? (
|
||||
<p className="error-message">{error}</p>
|
||||
) : !sightData || sightData.length === 0 ? (
|
||||
<p style={{ paddingLeft: "13px" }}>Достопримечательности не найдены.</p>
|
||||
) : (
|
||||
sightData.map((sight) => (
|
||||
<SightComponent
|
||||
key={sight.id || sight.name}
|
||||
title={sight.short_name || sight.name}
|
||||
media={{
|
||||
id: sight.thumbnail,
|
||||
type: sight.thumbnail_type,
|
||||
}}
|
||||
sightId={sight.id}
|
||||
onPointerUp={onSightClick}
|
||||
componentRef={(el) => (sightRefs.current[sight.id] = el)}
|
||||
isHighlighted={
|
||||
highlightedSights && highlightedSights.has(sight.id)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TouchableLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SightsList;
|
||||
307
src/client/src/components/ListOfSights/TransferWidget.jsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
transition: "0.5s ease-in-out",
|
||||
transform: isOpen ? "translateY(-100%)" : "translateY(100%)",
|
||||
}}
|
||||
className="transfer-widget"
|
||||
>
|
||||
<div className="transferLabel">
|
||||
{getTransferLabel()}
|
||||
{currentTransferData != null &&
|
||||
!Object.values(currentTransferData).every((value) => value === "") ? (
|
||||
<>
|
||||
<div>
|
||||
{currentTransferData?.metro_red?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={metroRedIcon}
|
||||
alt="Metro Red Icon"
|
||||
/>
|
||||
{currentTransferData.metro_red}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{currentTransferData?.metro_green?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={metroGreenIcon}
|
||||
alt="Metro Green Icon"
|
||||
/>
|
||||
{currentTransferData.metro_green}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{currentTransferData?.metro_blue?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={metroBlueIcon}
|
||||
alt="Metro Blue Icon"
|
||||
/>
|
||||
{currentTransferData.metro_blue}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{currentTransferData?.metro_orange?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={metroOrangeIcon}
|
||||
alt="Metro Orange Icon"
|
||||
/>
|
||||
{currentTransferData.metro_orange}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{currentTransferData?.metro_purple?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={metroPurpleIcon}
|
||||
alt="Metro Purple Icon"
|
||||
/>
|
||||
{currentTransferData.metro_purple}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{currentTransferData?.tram?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={tramIcon}
|
||||
alt="Tram Icon"
|
||||
/>
|
||||
{currentTransferData.tram}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{currentTransferData?.trolleybus?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={trolleyIcon}
|
||||
alt="Trolleybus Icon"
|
||||
/>
|
||||
{currentTransferData.trolleybus}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{currentTransferData?.bus?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={busIcon}
|
||||
alt="Bus Icon"
|
||||
/>
|
||||
{currentTransferData.bus}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{currentTransferData?.train?.length > 0 ? (
|
||||
<div className="transfer-line">
|
||||
<img
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
src={trainIcon}
|
||||
alt="Train Icon"
|
||||
/>
|
||||
{currentTransferData.train}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="transfers-body">{getNoTransfersMessage()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TransferWidget;
|
||||
7
src/client/src/components/ListOfSights/index.js
Normal file
@@ -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";
|
||||
68
src/client/src/components/Loader.jsx
Normal file
@@ -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(
|
||||
<div
|
||||
className="loading-gif"
|
||||
style={{ zIndex: 2147483647, backgroundColor: "black" }}
|
||||
>
|
||||
{showLoadingInfo && <div className="loading-info">{getStatusText()}</div>}
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
src={loaderVideoPath}
|
||||
alt="Loading"
|
||||
className="error-gif"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
13
src/client/src/components/OverlayScrollbarsWrapper.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
export declare const OverlayScrollbarsWrapper: React.ForwardRefExoticComponent<
|
||||
React.PropsWithChildren<{
|
||||
className?: string;
|
||||
onScroll?: (event: Event) => void;
|
||||
overflowX?: string;
|
||||
overflowY?: string;
|
||||
scrollbarVisibility?: string;
|
||||
[key: string]: any;
|
||||
}> &
|
||||
React.RefAttributes<HTMLElement>
|
||||
>;
|
||||
326
src/client/src/components/OverlayScrollbarsWrapper.jsx
Normal file
@@ -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 (
|
||||
<OverlayScrollbarsComponent
|
||||
ref={internalRef}
|
||||
options={options}
|
||||
className={className}
|
||||
events={events}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</OverlayScrollbarsComponent>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
OverlayScrollbarsWrapper.displayName = "OverlayScrollbarsWrapper";
|
||||
149
src/client/src/components/ReactMarkdown/ReactMarkdown.css
Normal file
@@ -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);
|
||||
}
|
||||
12
src/client/src/components/ReactMarkdown/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import "./ReactMarkdown.css";
|
||||
|
||||
export const ReactMarkdownComponent = ({ value }: { value: string }) => {
|
||||
return (
|
||||
<div className="react-markdown-container">
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{value}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
126
src/client/src/components/SimulationSettings.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
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 (
|
||||
<div style={{ position: "fixed", top: 12, right: 12, zIndex: 2147483646 }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 6,
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
background: open ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.5)",
|
||||
color: "white", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.34.07-.69.07-1.08s-.03-.73-.07-1.08l2.33-1.82c.21-.16.27-.46.13-.7l-2.21-3.83a.55.55 0 0 0-.68-.22l-2.75 1.1a8.1 8.1 0 0 0-1.86-1.08l-.42-2.93A.545.545 0 0 0 14 2h-4c-.27 0-.5.2-.54.46l-.42 2.93c-.68.28-1.3.65-1.86 1.08L4.43 5.37a.543.543 0 0 0-.68.22L1.54 9.42c-.14.24-.08.54.13.7l2.33 1.82c-.04.35-.07.7-.07 1.08s.03.73.07 1.08L1.67 15.92c-.21.16-.27.46-.13.7l2.21 3.83c.14.24.43.31.68.22l2.75-1.1c.56.43 1.18.8 1.86 1.08l.42 2.93c.04.26.27.46.54.46h4c.27 0 .5-.2.54-.46l.42-2.93c.68-.28 1.3-.65 1.86-1.08l2.75 1.1c.25.09.54.02.68-.22l2.21-3.83c.14-.24.08-.54-.13-.7l-2.33-1.82Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: "absolute", top: 42, right: 0,
|
||||
background: "rgba(20,20,20,0.9)",
|
||||
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
|
||||
padding: "10px 12px", minWidth: 200, fontSize: 13,
|
||||
}}>
|
||||
{/* Пауза */}
|
||||
<Row>
|
||||
<span>Пауза</span>
|
||||
<Toggle
|
||||
on={apiStore.simulationPaused}
|
||||
onClick={apiStore.toggleSimulationPaused}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{/* Направление */}
|
||||
<Row>
|
||||
<span>Направление</span>
|
||||
<button onClick={apiStore.toggleSimulationDirection} style={btnStyle}>
|
||||
{apiStore.simulationDirection === 1 ? "→" : "←"}
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
{/* Скорость */}
|
||||
<Row>
|
||||
<span>Скорость</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<button
|
||||
style={btnStyle}
|
||||
onClick={() => apiStore.setSimulationSpeed(apiStore.simulationSpeed - 0.25)}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span style={{ minWidth: 32, textAlign: "center" }}>
|
||||
{apiStore.simulationSpeed}x
|
||||
</span>
|
||||
<button
|
||||
style={btnStyle}
|
||||
onClick={() => apiStore.setSimulationSpeed(apiStore.simulationSpeed + 0.25)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Без анимации */}
|
||||
<Row>
|
||||
<span>Без анимации</span>
|
||||
<Toggle
|
||||
on={apiStore.simulationInstantMove}
|
||||
onClick={apiStore.toggleSimulationInstantMove}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Row({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between",
|
||||
alignItems: "center", padding: "5px 0",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: 36, height: 20, borderRadius: 10, border: "none",
|
||||
background: on ? "#6366f1" : "rgba(255,255,255,0.2)",
|
||||
cursor: "pointer", position: "relative",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 8,
|
||||
background: "white", position: "absolute", top: 2,
|
||||
left: on ? 18 : 2, transition: "left 0.15s",
|
||||
}} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
118
src/client/src/components/StoreDebugInfo.jsx
Normal file
@@ -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(
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.85)",
|
||||
zIndex: 2147483647,
|
||||
overflow: "auto",
|
||||
padding: "20px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#00ff00",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#ffffff",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Store Debug (F4 — закрыть)
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "#ffff00",
|
||||
fontSize: "12px",
|
||||
marginBottom: "16px",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
<div>API: {apiBaseURL}</div>
|
||||
<div>GEO: {geoBaseURL}</div>
|
||||
<div>WEATHER: {weatherBaseURL}</div>
|
||||
</div>
|
||||
{JSON.stringify(debugData, null, 2)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
});
|
||||
|
||||
export default StoreDebugInfo;
|
||||