Compare commits
58 Commits
develop
...
e3469763ce
| Author | SHA1 | Date | |
|---|---|---|---|
| e3469763ce | |||
| 7f8a327329 | |||
| 53b8ce7095 | |||
| 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 | |||
| 79f523e9cb | |||
| 90f3d66b22 | |||
| 2b48ade2f1 | |||
| b0fdf03cc6 | |||
|
|
349c7009c6 |
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,7 +1,14 @@
|
|||||||
# Logs
|
# Logs
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
=======
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
@@ -10,10 +17,22 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
<<<<<<< HEAD
|
||||||
|
\*.local
|
||||||
|
|
||||||
# Build artifacts
|
# Editor directories and files
|
||||||
*.tsbuildinfo
|
|
||||||
|
.vscode/_
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
_.suo
|
||||||
|
_.ntvs_
|
||||||
|
_.njsproj
|
||||||
|
_.sln
|
||||||
|
\*.sw?
|
||||||
|
=======
|
||||||
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -25,3 +44,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"version": "1.0.8",
|
"version": "1.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"version": "1.0.8",
|
"version": "1.0.6",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.8",
|
"version": "1.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@mui/material": "^7.1.0",
|
"@mui/material": "^7.1.0",
|
||||||
"@mui/x-data-grid": "^8.5.1",
|
"@mui/x-data-grid": "^8.5.1",
|
||||||
"@photo-sphere-viewer/core": "^5.13.2",
|
"@photo-sphere-viewer/core": "^5.13.2",
|
||||||
|
"@pixi/react": "^8.0.2",
|
||||||
"@react-three/drei": "^10.1.2",
|
"@react-three/drei": "^10.1.2",
|
||||||
"@react-three/fiber": "^9.1.2",
|
"@react-three/fiber": "^9.1.2",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"overlayscrollbars": "^2.15.1",
|
"overlayscrollbars": "^2.15.1",
|
||||||
"overlayscrollbars-react": "^0.5.6",
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
|
"pixi.js": "^8.10.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Router } from "./router";
|
import { Router } from "./router";
|
||||||
import { CustomTheme, languageStore } from "@shared";
|
import { CustomTheme } from "@shared";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||||
import { TestingModeBanner } from "@widgets";
|
import { TestingModeBanner } from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
|
|
||||||
export const App: React.FC = observer(() => {
|
export const App: React.FC = () => (
|
||||||
React.useEffect(() => {
|
|
||||||
document.documentElement.lang = languageStore.language;
|
|
||||||
}, [languageStore.language]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GlobalErrorBoundary>
|
<GlobalErrorBoundary>
|
||||||
<ThemeProvider theme={CustomTheme.Light}>
|
<ThemeProvider theme={CustomTheme.Light}>
|
||||||
<TestingModeBanner />
|
<TestingModeBanner />
|
||||||
@@ -22,4 +16,3 @@ export const App: React.FC = observer(() => {
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</GlobalErrorBoundary>
|
</GlobalErrorBoundary>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|||||||
@@ -92,9 +92,6 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
requiredPermissions.length > 0 &&
|
requiredPermissions.length > 0 &&
|
||||||
!requiredPermissions.every((permission) => authStore.canAccess(permission))
|
!requiredPermissions.every((permission) => authStore.canAccess(permission))
|
||||||
) {
|
) {
|
||||||
if (location.pathname === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const App = observer(() => {
|
|||||||
loadingStatus,
|
loadingStatus,
|
||||||
loadingProgress,
|
loadingProgress,
|
||||||
context,
|
context,
|
||||||
carrier,
|
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setLoadingStatus,
|
setLoadingStatus,
|
||||||
setLoadingProgress,
|
setLoadingProgress,
|
||||||
@@ -173,7 +172,6 @@ const App = observer(() => {
|
|||||||
loadingStatus={loadingStatus}
|
loadingStatus={loadingStatus}
|
||||||
loadingProgress={loadingProgress}
|
loadingProgress={loadingProgress}
|
||||||
onToggleDebug={() => setIsDebugVisible((prev) => !prev)}
|
onToggleDebug={() => setIsDebugVisible((prev) => !prev)}
|
||||||
videoId={carrier?.video_preview}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -24,54 +24,6 @@ import {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
||||||
import { resamplePath } from "../../utils/animationUtils";
|
import { resamplePath } from "../../utils/animationUtils";
|
||||||
import { colorStore } from "../../stores/ColorStore";
|
|
||||||
|
|
||||||
function hexToRgbString(hex: string): string | null {
|
|
||||||
const clean = hex.trim().replace(/^#/, "");
|
|
||||||
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
||||||
if (full.length !== 6) return null;
|
|
||||||
const r = parseInt(full.slice(0, 2), 16);
|
|
||||||
const g = parseInt(full.slice(2, 4), 16);
|
|
||||||
const b = parseInt(full.slice(4, 6), 16);
|
|
||||||
return `${r}, ${g}, ${b}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function darkenHex(hex: string, amount: number): string {
|
|
||||||
const rgb = hexToRgbString(hex);
|
|
||||||
if (!rgb) return hex;
|
|
||||||
const [r, g, b] = rgb.split(",").map(Number);
|
|
||||||
const factor = 1 - amount;
|
|
||||||
const dr = Math.round(r * factor);
|
|
||||||
const dg = Math.round(g * factor);
|
|
||||||
const db = Math.round(b * factor);
|
|
||||||
return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lightenRgbString(hex: string, amount: number): string | null {
|
|
||||||
const rgb = hexToRgbString(hex);
|
|
||||||
if (!rgb) return null;
|
|
||||||
const [r, g, b] = rgb.split(",").map(Number);
|
|
||||||
const lr = Math.round(r + (255 - r) * amount);
|
|
||||||
const lg = Math.round(g + (255 - g) * amount);
|
|
||||||
const lb = Math.round(b + (255 - b) * amount);
|
|
||||||
return `${lr}, ${lg}, ${lb}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCarrierColors(carrier: { main_color?: string; left_color?: string; right_color?: string }) {
|
|
||||||
const mainColor = carrier.main_color || "#006f3a";
|
|
||||||
const leftColor = carrier.left_color || "#006f3a";
|
|
||||||
const rightColor = carrier.right_color || "#006f3a";
|
|
||||||
const mainDark = darkenHex(mainColor, 0.3);
|
|
||||||
|
|
||||||
document.documentElement.style.setProperty("--carrier-main", mainColor);
|
|
||||||
document.documentElement.style.setProperty("--carrier-main-rgb", hexToRgbString(mainColor) ?? "0, 111, 58");
|
|
||||||
document.documentElement.style.setProperty("--carrier-main-dark", mainDark);
|
|
||||||
document.documentElement.style.setProperty("--carrier-left", leftColor);
|
|
||||||
document.documentElement.style.setProperty("--carrier-left-rgb", hexToRgbString(leftColor) ?? "0, 111, 58");
|
|
||||||
document.documentElement.style.setProperty("--carrier-right", rightColor);
|
|
||||||
document.documentElement.style.setProperty("--carrier-right-rgb", hexToRgbString(rightColor) ?? "0, 111, 58");
|
|
||||||
document.documentElement.style.setProperty("--carrier-right-menu-rgb", lightenRgbString(rightColor, 0.38) ?? "179, 165, 152");
|
|
||||||
}
|
|
||||||
|
|
||||||
class ApiStore {
|
class ApiStore {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -163,10 +115,6 @@ class ApiStore {
|
|||||||
|
|
||||||
getCarrier = async () => {
|
getCarrier = async () => {
|
||||||
this.carrier = await getCarrier(this.route!.carrier_id!);
|
this.carrier = await getCarrier(this.route!.carrier_id!);
|
||||||
applyCarrierColors(this.carrier);
|
|
||||||
if (this.carrier.main_color) {
|
|
||||||
colorStore.setMainColor(this.carrier.main_color);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getCity = async () => {
|
getCity = async () => {
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export type GetRouteResponse = {
|
|||||||
center_latitude: number;
|
center_latitude: number;
|
||||||
center_longitude: number;
|
center_longitude: number;
|
||||||
governor_appeal: number;
|
governor_appeal: number;
|
||||||
button_text?: string;
|
|
||||||
id: number;
|
id: number;
|
||||||
path: [number, number][];
|
path: [number, number][];
|
||||||
rotate: number;
|
rotate: number;
|
||||||
@@ -77,13 +76,8 @@ export type GetCarrierResponse = {
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
id: number;
|
id: number;
|
||||||
logo: string;
|
logo: string;
|
||||||
preview_media?: string;
|
|
||||||
video_preview?: string;
|
|
||||||
short_name: string;
|
short_name: string;
|
||||||
slogan: string;
|
slogan: string;
|
||||||
main_color?: string;
|
|
||||||
left_color?: string;
|
|
||||||
right_color?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCityResponse = {
|
export type GetCityResponse = {
|
||||||
|
|||||||
@@ -37,14 +37,14 @@ const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fullscreen-3d-actions">
|
<div className="fullscreen-3d-actions">
|
||||||
<button disabled={scale >= 1}>
|
<button title="Увеличить масштаб" disabled={scale >= 1}>
|
||||||
<img src={scale_plus} alt="Увеличить" />
|
<img src={scale_plus} alt="Увеличить" />
|
||||||
</button>
|
</button>
|
||||||
<button disabled={scale <= 0.1}>
|
<button title="Уменьшить масштаб" disabled={scale <= 0.1}>
|
||||||
<img src={scale_minus} alt="Уменьшить" />
|
<img src={scale_minus} alt="Уменьшить" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onPointerUp={onClose}>
|
<button onPointerUp={onClose} title="Закрыть">
|
||||||
<img src={closeIcon} alt="Закрыть" />
|
<img src={closeIcon} alt="Закрыть" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,10 +95,9 @@ const ListOfSights = observer(() => {
|
|||||||
|
|
||||||
// Находим все достопримечательности, начинающиеся с выбранной буквы
|
// Находим все достопримечательности, начинающиеся с выбранной буквы
|
||||||
const sightsForLetter = sightData.filter((sight) => {
|
const sightsForLetter = sightData.filter((sight) => {
|
||||||
const name = sight.name;
|
if (!sight.name) return false;
|
||||||
if (!name) return false;
|
|
||||||
|
|
||||||
let sightFirstChar = name.trim().charAt(0);
|
let sightFirstChar = sight.name.trim().charAt(0);
|
||||||
|
|
||||||
// Для китайского языка используем пиньинь или иероглиф
|
// Для китайского языка используем пиньинь или иероглиф
|
||||||
if (selectedLanguageRight === "zh") {
|
if (selectedLanguageRight === "zh") {
|
||||||
@@ -106,7 +105,7 @@ const ListOfSights = observer(() => {
|
|||||||
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
||||||
} else {
|
} else {
|
||||||
// Если пиньинь нет, используем первый иероглиф
|
// Если пиньинь нет, используем первый иероглиф
|
||||||
sightFirstChar = name.trim().charAt(0);
|
sightFirstChar = sight.name.trim().charAt(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sightFirstChar = sightFirstChar.toUpperCase();
|
sightFirstChar = sightFirstChar.toUpperCase();
|
||||||
@@ -412,13 +411,13 @@ const ListOfSights = observer(() => {
|
|||||||
}, [currentSelectedSight]);
|
}, [currentSelectedSight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="right-widget" lang={selectedLanguageRight}>
|
<div className="right-widget">
|
||||||
{currentSelectedSight && (
|
{currentSelectedSight && (
|
||||||
<SightFrame
|
<SightFrame
|
||||||
key={currentSelectedSight.id}
|
key={currentSelectedSight.id}
|
||||||
media={sightFrameMedia}
|
media={sightFrameMedia}
|
||||||
sight_id={currentSelectedSight.id}
|
sight_id={currentSelectedSight.id}
|
||||||
sight_name={currentSelectedSight.name}
|
sight_name={currentSelectedSight.short_name || currentSelectedSight.name}
|
||||||
selectedLanguageRight={selectedLanguageRight}
|
selectedLanguageRight={selectedLanguageRight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -441,6 +440,11 @@ const ListOfSights = observer(() => {
|
|||||||
isLangOpen={isLangMenuOpen}
|
isLangOpen={isLangMenuOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TransferWidget
|
||||||
|
isOpen={isTransferWidgetOpen}
|
||||||
|
selectedLanguageRight={selectedLanguage}
|
||||||
|
/>
|
||||||
|
|
||||||
<LanguageSelector
|
<LanguageSelector
|
||||||
selectedLanguageRight={selectedLanguageRight}
|
selectedLanguageRight={selectedLanguageRight}
|
||||||
onLanguageChange={handleLanguageChange}
|
onLanguageChange={handleLanguageChange}
|
||||||
@@ -472,12 +476,6 @@ const ListOfSights = observer(() => {
|
|||||||
isDisabled={isAlphabetDisabled}
|
isDisabled={isAlphabetDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<TransferWidget
|
|
||||||
isOpen={isTransferWidgetOpen}
|
|
||||||
selectedLanguageRight={selectedLanguage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="transfer-button-container"
|
className="transfer-button-container"
|
||||||
@@ -515,6 +513,7 @@ const ListOfSights = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
|
|||||||
const letters = new Set();
|
const letters = new Set();
|
||||||
|
|
||||||
sightData.forEach((sight) => {
|
sightData.forEach((sight) => {
|
||||||
const name = sight.short_name || sight.name;
|
if (sight.name && sight.name.trim()) {
|
||||||
if (name && name.trim()) {
|
let firstChar = sight.name.trim().charAt(0);
|
||||||
let firstChar = name.trim().charAt(0);
|
|
||||||
|
|
||||||
// Для китайского языка используем пиньинь (если доступен) или иероглиф
|
// Для китайского языка используем пиньинь (если доступен) или иероглиф
|
||||||
if (selectedLanguage === "zh") {
|
if (selectedLanguage === "zh") {
|
||||||
@@ -24,7 +23,7 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
|
|||||||
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
||||||
} else {
|
} else {
|
||||||
// Если пиньинь нет, используем первый иероглиф
|
// Если пиньинь нет, используем первый иероглиф
|
||||||
firstChar = name.trim().charAt(0);
|
firstChar = sight.name.trim().charAt(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
firstChar = firstChar.toUpperCase();
|
firstChar = firstChar.toUpperCase();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React from "react";
|
||||||
|
|
||||||
const LANGUAGES = {
|
const LANGUAGES = {
|
||||||
EN: "en",
|
EN: "en",
|
||||||
@@ -18,26 +18,6 @@ const ListHeader = function ListHeader({
|
|||||||
isTransferWidgetOpen,
|
isTransferWidgetOpen,
|
||||||
onBackToNearest,
|
onBackToNearest,
|
||||||
}) {
|
}) {
|
||||||
const [isIdle, setIsIdle] = useState(false);
|
|
||||||
const timerRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const resetTimer = () => {
|
|
||||||
setIsIdle(false);
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => setIsIdle(true), 15000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = ["pointermove", "pointerdown", "touchstart", "keydown"];
|
|
||||||
events.forEach((e) => window.addEventListener(e, resetTimer));
|
|
||||||
resetTimer();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
events.forEach((e) => window.removeEventListener(e, resetTimer));
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
return selectedLanguageRight === LANGUAGES.RU
|
return selectedLanguageRight === LANGUAGES.RU
|
||||||
? "Достопримечательности"
|
? "Достопримечательности"
|
||||||
@@ -61,11 +41,14 @@ const ListHeader = function ListHeader({
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="20"
|
width="21"
|
||||||
height="20"
|
height="12"
|
||||||
viewBox="0 0 21 12"
|
viewBox="0 0 21 12"
|
||||||
fill="none"
|
fill="none"
|
||||||
className={`chevron-svg${isOpen ? " is-open" : ""}${isIdle ? " is-idle" : ""}`}
|
style={{
|
||||||
|
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 0.15s ease-in-out",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<g clipPath="url(#clip0_658_91932)">
|
<g clipPath="url(#clip0_658_91932)">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const SightComponent = function SightComponent({
|
|||||||
aria-label={`Выбрать достопримечательность ${title}`}
|
aria-label={`Выбрать достопримечательность ${title}`}
|
||||||
>
|
>
|
||||||
<div className="sight-image">{renderThumbnail()}</div>
|
<div className="sight-image">{renderThumbnail()}</div>
|
||||||
<div className="sight-title">
|
<div className="sight-title" title={title}>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useGeolocationStore } from "../../stores";
|
import { useGeolocationStore } from "../../stores";
|
||||||
@@ -41,68 +41,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
|
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
|
||||||
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
|
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
|
||||||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
|
||||||
const threeViewControlRef = useRef(null);
|
const threeViewControlRef = useRef(null);
|
||||||
const mediaCache = useRef({});
|
const mediaCache = useRef({});
|
||||||
const prevSightIdRef = useRef(null);
|
|
||||||
|
|
||||||
const textWrapperRef = useRef(null);
|
const textWrapperRef = useRef(null);
|
||||||
const mediaStackRef = useRef(null);
|
|
||||||
const titleRef = useRef(null);
|
|
||||||
const switchTimerRef = useRef(null);
|
|
||||||
const homeButtonRef = useRef(null);
|
|
||||||
const menuRef = useRef(null);
|
|
||||||
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
|
|
||||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
||||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
|
||||||
|
|
||||||
const currentSection = articleSections?.[selectedSection] ?? null;
|
|
||||||
|
|
||||||
const requestSection = (target) => {
|
|
||||||
setIsFullscreen3D(false);
|
|
||||||
if (target === selectedSection) return;
|
|
||||||
if (switchTimerRef.current) clearTimeout(switchTimerRef.current);
|
|
||||||
setIsSwitching(true);
|
|
||||||
switchTimerRef.current = setTimeout(() => {
|
|
||||||
setSelectedSection(target);
|
|
||||||
setIsSwitching(false);
|
|
||||||
switchTimerRef.current = null;
|
|
||||||
}, 180);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateScrollState = useCallback(() => {
|
|
||||||
const menu = menuRef.current;
|
|
||||||
if (!menu) return;
|
|
||||||
setCanScrollLeft(menu.scrollLeft > 2);
|
|
||||||
setCanScrollRight(menu.scrollLeft + menu.clientWidth < menu.scrollWidth - 2);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const menu = menuRef.current;
|
|
||||||
if (!menu || !menuNeedsScroll) {
|
|
||||||
setCanScrollLeft(false);
|
|
||||||
setCanScrollRight(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateScrollState();
|
|
||||||
menu.addEventListener('scroll', updateScrollState);
|
|
||||||
return () => menu.removeEventListener('scroll', updateScrollState);
|
|
||||||
}, [menuNeedsScroll, updateScrollState]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const menu = menuRef.current;
|
|
||||||
if (!menu) return;
|
|
||||||
const children = Array.from(menu.querySelectorAll('.sight-frame-menu-point'));
|
|
||||||
if (children.length < 2) {
|
|
||||||
setMenuNeedsScroll(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const style = getComputedStyle(menu);
|
|
||||||
const availableWidth = menu.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
||||||
const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
|
|
||||||
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
|
|
||||||
setMenuNeedsScroll(evenGap < 10);
|
|
||||||
}, [articleSections]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
routeSights,
|
routeSights,
|
||||||
@@ -142,16 +84,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const { signal } = controller;
|
const { signal } = controller;
|
||||||
|
|
||||||
const isSameSight = prevSightIdRef.current === sight_id;
|
|
||||||
prevSightIdRef.current = sight_id;
|
|
||||||
|
|
||||||
if (!isSameSight) {
|
|
||||||
setIsLoadingContent(true);
|
setIsLoadingContent(true);
|
||||||
setContentError(null);
|
setContentError(null);
|
||||||
setModelAspectRatio(null);
|
setModelAspectRatio(null);
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
setMediaData({});
|
setMediaData({});
|
||||||
}
|
|
||||||
|
|
||||||
if (!sight_id) {
|
if (!sight_id) {
|
||||||
setContentError("Не указан ID статьи.");
|
setContentError("Не указан ID статьи.");
|
||||||
@@ -229,18 +166,12 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const introSection = {
|
const introSection = {
|
||||||
id: media?.id || "intro-title",
|
id: media?.id || "intro-title",
|
||||||
heading:
|
heading:
|
||||||
sight?.name || sight_name || "Название достопримечательности",
|
sight?.short_name || sight?.name || sight_name || "Название достопримечательности",
|
||||||
body: "",
|
body: "",
|
||||||
};
|
};
|
||||||
const allSections = [introSection, ...rightArticles];
|
const allSections = [introSection, ...rightArticles];
|
||||||
setArticleSections(allSections);
|
setArticleSections(allSections);
|
||||||
|
|
||||||
if (isSameSight) {
|
|
||||||
setIsLoadingContent(false);
|
|
||||||
setIsVisible(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
|
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
|
||||||
if (mediaCache.current[cacheKey]) {
|
if (mediaCache.current[cacheKey]) {
|
||||||
setMediaData(mediaCache.current[cacheKey]);
|
setMediaData(mediaCache.current[cacheKey]);
|
||||||
@@ -281,43 +212,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedSection(0);
|
setSelectedSection(0);
|
||||||
setIsSwitching(false);
|
|
||||||
if (switchTimerRef.current) {
|
|
||||||
clearTimeout(switchTimerRef.current);
|
|
||||||
switchTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, [sight_id]);
|
}, [sight_id]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const currentSection = articleSections?.[selectedSection] ?? null;
|
||||||
const scrollable = textWrapperRef.current?.querySelector(".scrollable");
|
|
||||||
if (scrollable) scrollable.scrollTop = 0;
|
|
||||||
}, [selectedSection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFullscreen3D) return;
|
|
||||||
|
|
||||||
let idleSeconds = 0;
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
idleSeconds += 1;
|
|
||||||
|
|
||||||
if (idleSeconds >= 60) {
|
|
||||||
setIsFullscreen3D(false);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
const resetIdle = () => {
|
|
||||||
idleSeconds = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = ["pointerdown", "pointermove", "touchstart", "keydown"];
|
|
||||||
events.forEach((e) => window.addEventListener(e, resetIdle, { passive: true }));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
events.forEach((e) => window.removeEventListener(e, resetIdle));
|
|
||||||
};
|
|
||||||
}, [isFullscreen3D]);
|
|
||||||
|
|
||||||
|
|
||||||
const renderCurrentMedia = () => {
|
const renderCurrentMedia = () => {
|
||||||
if (!articleSections || Object.keys(mediaData).length === 0) {
|
if (!articleSections || Object.keys(mediaData).length === 0) {
|
||||||
@@ -347,7 +244,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
alt=""
|
alt=""
|
||||||
className={className}
|
className={className}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.warn(`Failed to load image: ${currentMediaData.path}`);
|
console.warn(
|
||||||
|
`Failed to load image: ${currentMediaData.path}`,
|
||||||
|
);
|
||||||
e.target.style.display = "none";
|
e.target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -362,7 +261,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
playsInline
|
playsInline
|
||||||
className={className}
|
className={className}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.warn(`Failed to load video: ${currentMediaData.path}`);
|
console.warn(
|
||||||
|
`Failed to load video: ${currentMediaData.path}`,
|
||||||
|
);
|
||||||
e.target.style.display = "none";
|
e.target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -412,20 +313,27 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="three-d-control-btn"
|
className="three-d-control-btn"
|
||||||
onPointerUp={() => threeViewControlRef.current?.zoomOut?.()}
|
title="Уменьшить"
|
||||||
|
onPointerUp={() =>
|
||||||
|
threeViewControlRef.current?.zoomOut?.()
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MinusIcon />
|
<MinusIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="three-d-control-btn"
|
className="three-d-control-btn"
|
||||||
onPointerUp={() => threeViewControlRef.current?.zoomIn?.()}
|
title="Увеличить"
|
||||||
|
onPointerUp={() =>
|
||||||
|
threeViewControlRef.current?.zoomIn?.()
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="three-d-control-btn"
|
className="three-d-control-btn"
|
||||||
|
title={isFullscreen3D ? "Свернуть" : "Развернуть"}
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
if (isFullscreen3D) {
|
if (isFullscreen3D) {
|
||||||
setIsFullscreen3D(false);
|
setIsFullscreen3D(false);
|
||||||
@@ -440,6 +348,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={"fullscreen-3d-button"}
|
className={"fullscreen-3d-button"}
|
||||||
|
title={
|
||||||
|
isFullscreen3D
|
||||||
|
? "Закрыть полноэкранный режим"
|
||||||
|
: "Открыть в полноэкранном режиме"
|
||||||
|
}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -455,8 +368,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{isFullscreen3D && (
|
{isFullscreen3D && <div
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 94,
|
top: 94,
|
||||||
@@ -468,7 +380,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
<div
|
<div
|
||||||
className="cluster-sights-list"
|
className="cluster-sights-list"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-right-rgb, 0, 111, 58), 0.4)`,
|
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(0, 111, 58, 0.4)`,
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -482,26 +394,15 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
label: "Вращать",
|
label: "Вращать",
|
||||||
icon: (
|
icon: <img src={rotate3DIcon} alt="" width="14" height="14" />,
|
||||||
<img
|
|
||||||
src={rotate3DIcon}
|
|
||||||
alt=""
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Приблизить / Отдалить",
|
label: "Приблизить / Отдалить",
|
||||||
icon: (
|
icon: <img src={zoom3DIcon} alt="" width="14" height="14" />,
|
||||||
<img src={zoom3DIcon} alt="" width="14" height="14" />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Переместить",
|
label: "Переместить",
|
||||||
icon: (
|
icon: <img src={pan3DIcon} alt="" width="14" height="14" />,
|
||||||
<img src={pan3DIcon} alt="" width="14" height="14" />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
].map((item, index, arr) => (
|
].map((item, index, arr) => (
|
||||||
<div
|
<div
|
||||||
@@ -547,8 +448,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -613,6 +513,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const processedSightName = useMemo(() => {
|
const processedSightName = useMemo(() => {
|
||||||
if (!sight_name) return sight_name;
|
if (!sight_name) return sight_name;
|
||||||
|
|
||||||
|
// Handle \n line breaks (только в правом виджете)
|
||||||
if (sight_name.includes("\n")) {
|
if (sight_name.includes("\n")) {
|
||||||
return sight_name.split("\n").map((line, i) => (
|
return sight_name.split("\n").map((line, i) => (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
@@ -657,7 +558,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
}, [sight_name]);
|
}, [sight_name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div
|
<div
|
||||||
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
|
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
|
||||||
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
|
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
|
||||||
@@ -667,7 +567,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
|
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={mediaStackRef}
|
|
||||||
className={`sight-frame-media-stack ${
|
className={`sight-frame-media-stack ${
|
||||||
isCurrentMedia3D ? "three-d-view" : ""
|
isCurrentMedia3D ? "three-d-view" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -685,68 +584,64 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
renderCurrentMedia()
|
renderCurrentMedia()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`sight-frame-content ${isSwitching ? "is-switching" : ""}`}>
|
<div className="sight-frame-content">
|
||||||
{contentError ? (
|
{contentError ? (
|
||||||
<p className="error-message">{contentError}</p>
|
<p className="error-message">{contentError}</p>
|
||||||
|
) : !currentSection ? (
|
||||||
|
<p>Информация отсутствует.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!isFullscreen3D && (
|
{!isFullscreen3D && (
|
||||||
<div
|
<div
|
||||||
ref={titleRef}
|
|
||||||
className={`sight-frame-title ${
|
className={`sight-frame-title ${
|
||||||
selectedSection === 0 ? "intro-title" : ""
|
selectedSection === 0 ? "intro-title" : ""
|
||||||
}`}
|
}`}
|
||||||
style={{ lineHeight: titleLineHeight }}
|
style={{ lineHeight: titleLineHeight }}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
key={selectedSection}
|
|
||||||
className="fade-in-text"
|
|
||||||
style={{
|
style={{
|
||||||
whiteSpace: "normal",
|
whiteSpace: "normal",
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
overflowWrap: "break-word",
|
overflowWrap: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedSection === 0
|
{selectedSection === 0 ? processedSightName : (sightData?.short_name || sight_name)}
|
||||||
? processedSightName || sightData?.name
|
|
||||||
: sightData?.short_name || sightData?.name}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedSection !== 0 && (
|
||||||
<TouchableLayout
|
<TouchableLayout
|
||||||
key={selectedSection}
|
className="sight-frame-text-wrapper"
|
||||||
className={`sight-frame-text-wrapper ${selectedSection === 0 ? "is-intro" : ""}`}
|
|
||||||
ref={textWrapperRef}
|
ref={textWrapperRef}
|
||||||
|
maxHeight="calc(80vh - 354px)"
|
||||||
>
|
>
|
||||||
<div className="sight-frame-text fade-in-text">
|
<div className="sight-frame-text">
|
||||||
{isLoadingContent || !currentSection ? (
|
|
||||||
<div className="sight-frame-loading-placeholder" />
|
|
||||||
) : (
|
|
||||||
selectedSection !== 0 && (
|
|
||||||
<ReactMarkdownComponent value={currentSection.body} />
|
<ReactMarkdownComponent value={currentSection.body} />
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TouchableLayout>
|
</TouchableLayout>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="sight-frame-menu">
|
||||||
|
{selectedSection !== 0 && (
|
||||||
<div
|
<div
|
||||||
className="sight-frame-menu-wrapper"
|
|
||||||
style={{
|
style={{
|
||||||
opacity: isVisible ? 1 : 0,
|
position: "absolute",
|
||||||
transition: "opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.08s",
|
left: "10px",
|
||||||
pointerEvents: isVisible ? "auto" : "none",
|
marginTop: "-4.5px",
|
||||||
|
zIndex: 1,
|
||||||
|
paddingLeft: "15px",
|
||||||
|
paddingRight: "7.5px",
|
||||||
|
paddingTop: "4.5px",
|
||||||
|
paddingBottom: "4.5px",
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
|
onPointerUp={() => { setSelectedSection(0); setIsFullscreen3D(false); }}
|
||||||
>
|
>
|
||||||
<div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
|
<img src={subtractHomeIcon} alt="" width="24" height="21" style={{ display: "block" }} />
|
||||||
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
|
</div>
|
||||||
<div
|
)}
|
||||||
className="sight-frame-menu"
|
|
||||||
ref={menuRef}
|
|
||||||
style={menuNeedsScroll ? { justifyContent: 'flex-start' } : undefined}
|
|
||||||
>
|
|
||||||
{contentError ? (
|
{contentError ? (
|
||||||
<p className="error-message">{contentError}</p>
|
<p className="error-message">{contentError}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -754,9 +649,8 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
articleSections.length > 1 &&
|
articleSections.length > 1 &&
|
||||||
articleSections.slice(1).map((section, index) => (
|
articleSections.slice(1).map((section, index) => (
|
||||||
<div
|
<div
|
||||||
onPointerUp={() => requestSection(index + 1)}
|
onPointerUp={() => { setSelectedSection(index + 1); setIsFullscreen3D(false); }}
|
||||||
key={section.id || section.heading || index}
|
key={section.id || section.heading || index}
|
||||||
data-label={section.heading}
|
|
||||||
className={`sight-frame-menu-point ${
|
className={`sight-frame-menu-point ${
|
||||||
index + 1 === selectedSection ? "active" : ""
|
index + 1 === selectedSection ? "active" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -769,33 +663,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
ref={homeButtonRef}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "10px",
|
|
||||||
bottom: "115px",
|
|
||||||
zIndex: 3,
|
|
||||||
paddingLeft: "15px",
|
|
||||||
paddingRight: "7.5px",
|
|
||||||
paddingTop: "4.5px",
|
|
||||||
paddingBottom: "4.5px",
|
|
||||||
cursor: "pointer",
|
|
||||||
opacity: selectedSection !== 0 ? 1 : 0,
|
|
||||||
transition: "opacity 0.3s ease",
|
|
||||||
pointerEvents: selectedSection !== 0 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
onPointerUp={() => requestSection(0)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={subtractHomeIcon}
|
|
||||||
alt=""
|
|
||||||
width="24"
|
|
||||||
height="21"
|
|
||||||
style={{ display: "block" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,35 +96,20 @@ const TransferWidget = observer(function TransferWidget({
|
|||||||
|
|
||||||
const getTransferLabel = () => {
|
const getTransferLabel = () => {
|
||||||
if (selectedLanguageRight === "ru") {
|
if (selectedLanguageRight === "ru") {
|
||||||
return stationName ? (
|
return stationName
|
||||||
<>
|
? `Пересадки остановки ${stationName}:`
|
||||||
<div>Пересадка на остановке</div>
|
: "Ближайшая остановка не обнаружена";
|
||||||
<div>«{stationName}»:</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Ближайшая остановка не обнаружена"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLanguageRight === "en") {
|
if (selectedLanguageRight === "en") {
|
||||||
return stationName ? (
|
return stationName
|
||||||
<>
|
? `Available transfers at station ${stationName}`
|
||||||
<div>Transfer at stop</div>
|
: "Nearest station not found";
|
||||||
<div>«{stationName}»:</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Nearest station not found"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return stationName ? (
|
return stationName
|
||||||
<>
|
? `在车站可用的换乘:${stationName}`
|
||||||
<div>在站点换乘</div>
|
: "最近的站点未找到";
|
||||||
<div>«{stationName}»:</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"最近的站点未找到"
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNoTransfersMessage = () => {
|
const getNoTransfersMessage = () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import "../styles/Loader.css";
|
import "../styles/Loader.css";
|
||||||
import { getMediaUrl } from "../api/apiConfig";
|
|
||||||
|
|
||||||
const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url)
|
const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url)
|
||||||
.href;
|
.href;
|
||||||
@@ -10,14 +9,8 @@ const Loader = ({
|
|||||||
loadingStatus = null,
|
loadingStatus = null,
|
||||||
loadingProgress = null,
|
loadingProgress = null,
|
||||||
onToggleDebug,
|
onToggleDebug,
|
||||||
videoId = null,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [showLoadingInfo, setShowLoadingInfo] = useState(false);
|
const [showLoadingInfo, setShowLoadingInfo] = useState(false);
|
||||||
const [isCustomVideoError, setIsCustomVideoError] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsCustomVideoError(false);
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
@@ -62,12 +55,7 @@ const Loader = ({
|
|||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
src={
|
src={loaderVideoPath}
|
||||||
videoId && !isCustomVideoError
|
|
||||||
? getMediaUrl(videoId)
|
|
||||||
: loaderVideoPath
|
|
||||||
}
|
|
||||||
onError={() => setIsCustomVideoError(true)}
|
|
||||||
alt="Loading"
|
alt="Loading"
|
||||||
className="error-gif"
|
className="error-gif"
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.react-markdown-container blockquote {
|
.react-markdown-container blockquote {
|
||||||
border-left: 4px solid var(--carrier-main, #006F3A);
|
border-left: 4px solid #006F3A;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
114deg,
|
114deg,
|
||||||
rgba(255, 255, 255, 0.1) 8.71%,
|
rgba(255, 255, 255, 0.1) 8.71%,
|
||||||
rgba(255, 255, 255, 0.05) 69.69%
|
rgba(255, 255, 255, 0.05) 69.69%
|
||||||
), var(--carrier-main, #006F3A);
|
), #006F3A;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface TouchableLayoutProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxHeight?: string | number;
|
maxHeight?: string | number;
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||||
@@ -20,30 +19,20 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
height: 60,
|
height: 60,
|
||||||
top: 0,
|
top: 0,
|
||||||
hasScroll: false,
|
hasScroll: false,
|
||||||
isAtTop: true,
|
|
||||||
isAtBottom: false,
|
|
||||||
});
|
});
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
const el = scrollableRef.current;
|
const el = scrollableRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
if (el.closest('[data-height-animating="true"]')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sh = el.scrollHeight;
|
const sh = el.scrollHeight;
|
||||||
const ch = el.clientHeight;
|
const ch = el.clientHeight;
|
||||||
const st = el.scrollTop;
|
const st = el.scrollTop;
|
||||||
const th = ch;
|
const th = ch;
|
||||||
|
|
||||||
const isAtTop = st <= 0;
|
if (sh <= ch) {
|
||||||
const isAtBottom = st + ch >= sh - 1;
|
setState({ height: th, top: 0, hasScroll: false });
|
||||||
|
|
||||||
if (sh <= ch + 2) {
|
|
||||||
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +41,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
const scrollRange = sh - ch;
|
const scrollRange = sh - ch;
|
||||||
const top = range <= 0 ? 0 : (st / scrollRange) * range;
|
const top = range <= 0 ? 0 : (st / scrollRange) * range;
|
||||||
|
|
||||||
setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom });
|
setState({ height: thumbHeight, top, hasScroll: true });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,36 +57,22 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("scroll", schedule, { passive: true });
|
el.addEventListener("scroll", schedule, { passive: true });
|
||||||
el.addEventListener("layoutchange", schedule);
|
|
||||||
const ro = new ResizeObserver(schedule);
|
const ro = new ResizeObserver(schedule);
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
const mo = new MutationObserver(schedule);
|
|
||||||
mo.observe(el, { childList: true, subtree: true, characterData: true });
|
|
||||||
schedule();
|
schedule();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("scroll", schedule);
|
el.removeEventListener("scroll", schedule);
|
||||||
el.removeEventListener("layoutchange", schedule);
|
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
mo.disconnect();
|
|
||||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||||
};
|
};
|
||||||
}, [update]);
|
}, [update]);
|
||||||
|
|
||||||
useEffect(() => {
|
return state;
|
||||||
if (!state.hasScroll) {
|
|
||||||
setVisible(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const t = setTimeout(() => setVisible(true), 250);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [state.hasScroll]);
|
|
||||||
|
|
||||||
return { ...state, visible };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||||
({ children, className, maxHeight, style }, ref) => {
|
({ children, className, maxHeight }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -259,12 +234,9 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
|||||||
};
|
};
|
||||||
}, [thumb.hasScroll]);
|
}, [thumb.hasScroll]);
|
||||||
|
|
||||||
const containerClassName = [
|
const containerClassName = className
|
||||||
"scrollable-container",
|
? `scrollable-container ${className}`
|
||||||
className,
|
: "scrollable-container";
|
||||||
thumb.isAtTop ? "is-at-top" : "",
|
|
||||||
thumb.isAtBottom ? "is-at-bottom" : "",
|
|
||||||
].filter(Boolean).join(" ");
|
|
||||||
|
|
||||||
const viewportStyle: React.CSSProperties = maxHeight
|
const viewportStyle: React.CSSProperties = maxHeight
|
||||||
? {
|
? {
|
||||||
@@ -274,27 +246,20 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
|||||||
: {};
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setRefs} className={containerClassName} style={style}>
|
<div ref={setRefs} className={containerClassName}>
|
||||||
<div className="scrollable-viewport" style={viewportStyle}>
|
<div className="scrollable-viewport" style={viewportStyle}>
|
||||||
<div ref={scrollableRef} className="scrollable">
|
<div ref={scrollableRef} className="scrollable">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{thumb.hasScroll && (
|
||||||
ref={trackRef}
|
<div ref={trackRef} className="custom-scrollbar-track">
|
||||||
className="custom-scrollbar-track"
|
|
||||||
style={{ opacity: thumb.visible ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={thumbRef}
|
ref={thumbRef}
|
||||||
className="custom-scrollbar-thumb"
|
className="custom-scrollbar-thumb"
|
||||||
style={{
|
style={{ height: thumb.height, top: thumb.top }}
|
||||||
height: thumb.height,
|
|
||||||
top: thumb.top,
|
|
||||||
opacity: thumb.visible ? 1 : 0,
|
|
||||||
pointerEvents: thumb.visible ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import windIcon from "../assets/weather-icons/wind.png";
|
|||||||
import humidityIcon from "../assets/weather-icons/humidity.png";
|
import humidityIcon from "../assets/weather-icons/humidity.png";
|
||||||
import { useGeolocationStore } from "../stores";
|
import { useGeolocationStore } from "../stores";
|
||||||
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
|
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
|
||||||
import { apiStore } from "../api/ApiStore";
|
|
||||||
|
|
||||||
function WeatherDataLine({ icon, value, unit }) {
|
function WeatherDataLine({ icon, value, unit }) {
|
||||||
return (
|
return (
|
||||||
@@ -44,11 +43,7 @@ const WeatherWidget = observer(() => {
|
|||||||
|
|
||||||
const fetchWeather = async () => {
|
const fetchWeather = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await weatherApi.getFormattedWeather({
|
const data = await weatherApi.getFormattedWeather();
|
||||||
lat: apiStore.context?.currentCoordinates?.latitude,
|
|
||||||
lng: apiStore.context?.currentCoordinates?.longitude,
|
|
||||||
cityCode: apiStore.city?.weather_city_code,
|
|
||||||
});
|
|
||||||
setWeather(data);
|
setWeather(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
setHasLoadedOnce(true);
|
setHasLoadedOnce(true);
|
||||||
|
|||||||
349
src/client/src/components/map/InfiniteCanvas.tsx
Normal file
349
src/client/src/components/map/InfiniteCanvas.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { FederatedPointerEvent, FederatedWheelEvent } from "pixi.js";
|
||||||
|
import { ReactNode, useEffect, useState, useRef } from "react";
|
||||||
|
import { useTransform } from "./transformContext";
|
||||||
|
import { BACKGROUND_COLOR, SCALE_FACTOR } from "../../assets/Constants";
|
||||||
|
import { useApplication } from "@pixi/react";
|
||||||
|
import { useCameraAnimationStore } from "../../stores";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
|
|
||||||
|
export const InfiniteCanvas = observer(
|
||||||
|
({ children }: Readonly<{ children?: ReactNode }>) => {
|
||||||
|
const { route } = apiStore;
|
||||||
|
const {
|
||||||
|
position,
|
||||||
|
setPosition,
|
||||||
|
scale,
|
||||||
|
setScale,
|
||||||
|
setScreenCenter,
|
||||||
|
isAutoMode,
|
||||||
|
setIsAutoMode,
|
||||||
|
userActivityTimestamp,
|
||||||
|
updateUserActivity,
|
||||||
|
setAutoModeStartTimestamp,
|
||||||
|
} = useTransform();
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const applicationRef = useApplication();
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [startMousePosition, setStartMousePosition] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const activePointers = useRef(new Map<number, { x: number; y: number }>());
|
||||||
|
const [isPinching, setIsPinching] = useState(false);
|
||||||
|
// Add new useRef for storing initial pinch gesture data
|
||||||
|
const pinchStartData = useRef<{
|
||||||
|
distance: number;
|
||||||
|
midpoint: { x: number; y: number };
|
||||||
|
scale: number;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
} | null>(null);
|
||||||
|
// Keep these for backward compatibility, but we'll use pinchStartData for calculations
|
||||||
|
const [, setInitialPinchDistance] = useState<number | null>(null);
|
||||||
|
const [, setInitialPinchMidpoint] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [scaleMin, setScaleMin] = useState(0.1); // Default min scale
|
||||||
|
const [scaleMax, setScaleMax] = useState(3); // Default max scale
|
||||||
|
const cameraAnimationStore = useCameraAnimationStore();
|
||||||
|
|
||||||
|
// Add debounced version of syncState to reduce jittering
|
||||||
|
const syncStateDebounced = useRef(
|
||||||
|
debounce((pos, zoom) => {
|
||||||
|
cameraAnimationStore.syncState(pos, zoom);
|
||||||
|
}, 16) // ~60fps
|
||||||
|
).current;
|
||||||
|
|
||||||
|
// Функция для плавного ограничения масштаба
|
||||||
|
const getSmoothScale = (targetScale: number, currentScale: number) => {
|
||||||
|
if (isAutoMode) return targetScale; // В авто режиме без ограничений
|
||||||
|
|
||||||
|
// Плавное ограничение с затуханием
|
||||||
|
const damping = 0.3; // Коэффициент затухания (0-1)
|
||||||
|
|
||||||
|
if (targetScale < scaleMin) {
|
||||||
|
// Плавное замедление при приближении к минимальному масштабу
|
||||||
|
const distance = scaleMin - targetScale;
|
||||||
|
const dampedDistance = distance * damping;
|
||||||
|
return Math.max(scaleMin, targetScale + dampedDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetScale > scaleMax) {
|
||||||
|
// Плавное замедление при приближении к максимальному масштабу
|
||||||
|
const distance = targetScale - scaleMax;
|
||||||
|
const dampedDistance = distance * damping;
|
||||||
|
return Math.min(scaleMax, targetScale - dampedDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Плавное возвращение к границам, если текущий масштаб за пределами
|
||||||
|
if (currentScale < scaleMin) {
|
||||||
|
// Плавно возвращаемся к минимальному масштабу
|
||||||
|
const distance = scaleMin - currentScale;
|
||||||
|
const dampedDistance = distance * damping;
|
||||||
|
return Math.min(targetScale, currentScale + dampedDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentScale > scaleMax) {
|
||||||
|
// Плавно возвращаемся к максимальному масштабу
|
||||||
|
const distance = currentScale - scaleMax;
|
||||||
|
const dampedDistance = distance * damping;
|
||||||
|
return Math.max(targetScale, currentScale - dampedDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetScale;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserActivity = () => {
|
||||||
|
updateUserActivity();
|
||||||
|
if (isAutoMode) {
|
||||||
|
setIsAutoMode(false);
|
||||||
|
}
|
||||||
|
// При любом действии пользователя останавливаем анимацию
|
||||||
|
cameraAnimationStore.stopAnimation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Автоматический режим - таймер для включения и управление масштабом
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const timeSinceActivity = Date.now() - userActivityTimestamp;
|
||||||
|
if (timeSinceActivity >= 5000 && !isAutoMode) {
|
||||||
|
// 5 секунд бездействия - включаем авто режим
|
||||||
|
if (loaded) {
|
||||||
|
setIsAutoMode(true);
|
||||||
|
setAutoModeStartTimestamp(Date.now()); // Записываем время включения автопреследования
|
||||||
|
// Убираем мгновенную установку масштаба - теперь это делает CameraAnimationStore плавно
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000); // Проверяем каждую секунду
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [
|
||||||
|
userActivityTimestamp,
|
||||||
|
isAutoMode,
|
||||||
|
setIsAutoMode,
|
||||||
|
setScale,
|
||||||
|
setAutoModeStartTimestamp,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchRouteData() {
|
||||||
|
try {
|
||||||
|
const newScaleMin = route?.scale_min! / SCALE_FACTOR;
|
||||||
|
const newScaleMax = route?.scale_max! / SCALE_FACTOR;
|
||||||
|
|
||||||
|
setScaleMin(newScaleMin);
|
||||||
|
setScaleMax(newScaleMax);
|
||||||
|
setLoaded(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка загрузки данных маршрута для scaleMin/Max:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchRouteData();
|
||||||
|
}, [route]);
|
||||||
|
|
||||||
|
// Убираем жесткое ограничение масштаба - теперь используется плавное ограничение
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = applicationRef?.app.canvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
const canvasLeft = canvasRect.left;
|
||||||
|
const canvasTop = canvasRect.top;
|
||||||
|
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||||
|
const centerY = window.innerHeight / 2 - canvasTop;
|
||||||
|
setScreenCenter({ x: centerX, y: centerY });
|
||||||
|
}, [applicationRef?.app.canvas, setScreenCenter]);
|
||||||
|
|
||||||
|
const handlePointerDown = (e: FederatedPointerEvent) => {
|
||||||
|
handleUserActivity();
|
||||||
|
|
||||||
|
activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
|
||||||
|
|
||||||
|
if (activePointers.current.size === 1) {
|
||||||
|
setIsPinching(false);
|
||||||
|
setInitialPinchDistance(null);
|
||||||
|
setInitialPinchMidpoint(null);
|
||||||
|
pinchStartData.current = null;
|
||||||
|
setIsDragging(true);
|
||||||
|
setStartPosition({ x: position.x, y: position.y });
|
||||||
|
setStartMousePosition({ x: e.globalX, y: e.globalY });
|
||||||
|
} else if (activePointers.current.size === 2) {
|
||||||
|
setIsDragging(false); // Останавливаем перетаскивание, начинаем пинч
|
||||||
|
const pointersArray = Array.from(activePointers.current.values());
|
||||||
|
const p1 = pointersArray[0];
|
||||||
|
const p2 = pointersArray[1];
|
||||||
|
|
||||||
|
// Calculate initial values
|
||||||
|
const initialDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||||
|
const initialMidpoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
||||||
|
|
||||||
|
// Save initial gesture data in pinchStartData
|
||||||
|
pinchStartData.current = {
|
||||||
|
distance: initialDistance,
|
||||||
|
midpoint: initialMidpoint,
|
||||||
|
scale: scale,
|
||||||
|
position: { ...position },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep these for backward compatibility
|
||||||
|
setInitialPinchDistance(initialDistance);
|
||||||
|
setInitialPinchMidpoint(initialMidpoint);
|
||||||
|
|
||||||
|
setIsPinching(true);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: FederatedPointerEvent) => {
|
||||||
|
if (!activePointers.current.has(e.pointerId)) return;
|
||||||
|
|
||||||
|
updateUserActivity();
|
||||||
|
activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
|
||||||
|
|
||||||
|
if (
|
||||||
|
isPinching &&
|
||||||
|
activePointers.current.size === 2 &&
|
||||||
|
pinchStartData.current
|
||||||
|
) {
|
||||||
|
const pointersArray = Array.from(activePointers.current.values());
|
||||||
|
const p1 = pointersArray[0];
|
||||||
|
const p2 = pointersArray[1];
|
||||||
|
const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||||
|
const currentMidpoint = {
|
||||||
|
x: (p1.x + p2.x) / 2,
|
||||||
|
y: (p1.y + p2.y) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Calculate zoomFactor relative to the INITIAL distance
|
||||||
|
const zoomFactor = currentDistance / pinchStartData.current.distance;
|
||||||
|
|
||||||
|
// 2. Calculate new scale relative to the INITIAL scale
|
||||||
|
const targetScale = pinchStartData.current.scale * zoomFactor;
|
||||||
|
const newScale = getSmoothScale(targetScale, scale);
|
||||||
|
|
||||||
|
// 3. Calculate new position relative to the INITIAL position
|
||||||
|
// This is the standard formula for "zoom to point" (in our case, to the midpoint between fingers)
|
||||||
|
const newPosition = {
|
||||||
|
x:
|
||||||
|
pinchStartData.current.midpoint.x +
|
||||||
|
(pinchStartData.current.position.x -
|
||||||
|
pinchStartData.current.midpoint.x) *
|
||||||
|
(newScale / pinchStartData.current.scale),
|
||||||
|
y:
|
||||||
|
pinchStartData.current.midpoint.y +
|
||||||
|
(pinchStartData.current.position.y -
|
||||||
|
pinchStartData.current.midpoint.y) *
|
||||||
|
(newScale / pinchStartData.current.scale),
|
||||||
|
};
|
||||||
|
|
||||||
|
setPosition(newPosition);
|
||||||
|
setScale(newScale);
|
||||||
|
syncStateDebounced(newPosition, newScale);
|
||||||
|
|
||||||
|
// We do NOT update pinchStartData here - it remains fixed for the entire gesture
|
||||||
|
|
||||||
|
// Update these for backward compatibility, but they're not used for calculations
|
||||||
|
setInitialPinchDistance(currentDistance);
|
||||||
|
setInitialPinchMidpoint(currentMidpoint);
|
||||||
|
} else if (activePointers.current.size === 1) {
|
||||||
|
setIsPinching(false);
|
||||||
|
setInitialPinchDistance(null);
|
||||||
|
setInitialPinchMidpoint(null);
|
||||||
|
pinchStartData.current = null;
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
const newPosition = {
|
||||||
|
x: startPosition.x - startMousePosition.x + e.globalX,
|
||||||
|
y: startPosition.y - startMousePosition.y + e.globalY,
|
||||||
|
};
|
||||||
|
setPosition(newPosition);
|
||||||
|
syncStateDebounced(newPosition, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: FederatedPointerEvent) => {
|
||||||
|
handleUserActivity();
|
||||||
|
|
||||||
|
activePointers.current.delete(e.pointerId);
|
||||||
|
|
||||||
|
if (activePointers.current.size < 2) {
|
||||||
|
setIsPinching(false);
|
||||||
|
setInitialPinchDistance(null);
|
||||||
|
setInitialPinchMidpoint(null);
|
||||||
|
pinchStartData.current = null; // Clear pinch gesture data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePointers.current.size === 0) {
|
||||||
|
setIsDragging(false);
|
||||||
|
} else if (activePointers.current.size === 1) {
|
||||||
|
// If one finger remains after pinch, start dragging from the new position
|
||||||
|
const remainingPointer = Array.from(activePointers.current.values())[0];
|
||||||
|
setStartPosition({ x: position.x, y: position.y });
|
||||||
|
setStartMousePosition({ x: remainingPointer.x, y: remainingPointer.y });
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: FederatedWheelEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleUserActivity(); // Используем новую функцию
|
||||||
|
|
||||||
|
const mouseX = e.globalX - position.x;
|
||||||
|
const mouseY = e.globalY - position.y;
|
||||||
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
|
||||||
|
// Используем плавное ограничение масштаба
|
||||||
|
const targetScale = scale * zoomFactor;
|
||||||
|
const newScale = getSmoothScale(targetScale, scale);
|
||||||
|
const actualZoomFactor = newScale / scale;
|
||||||
|
|
||||||
|
if (scale !== newScale && newScale >= scaleMin && newScale <= scaleMax) {
|
||||||
|
// Убираем из условия && newScale >= scaleMin && newScale <= scaleMax
|
||||||
|
const newPosition = {
|
||||||
|
x: position.x + mouseX * (1 - actualZoomFactor),
|
||||||
|
y: position.y + mouseY * (1 - actualZoomFactor),
|
||||||
|
};
|
||||||
|
setPosition(newPosition);
|
||||||
|
setScale(newScale);
|
||||||
|
// Используем дебаунсированную функцию для синхронизации стора после зума
|
||||||
|
syncStateDebounced(newPosition, newScale);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<pixiGraphics
|
||||||
|
draw={(g) => {
|
||||||
|
const canvas = applicationRef.app.canvas;
|
||||||
|
g.clear();
|
||||||
|
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
|
||||||
|
g.fill(BACKGROUND_COLOR);
|
||||||
|
}}
|
||||||
|
eventMode="static"
|
||||||
|
interactive
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
/>
|
||||||
|
<pixiContainer position={position} scale={scale}>
|
||||||
|
{children}
|
||||||
|
</pixiContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { extend } from "@pixi/react";
|
||||||
|
import { Container, Graphics, Sprite, Text } from "pixi.js";
|
||||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||||
import { TransformProvider, useTransform } from "./transformContext";
|
import { TransformProvider, useTransform } from "./transformContext";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -17,6 +19,8 @@ import { SCALE_FACTOR } from "../../assets/Constants";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import WebGLMap from "./WebGLMap";
|
import WebGLMap from "./WebGLMap";
|
||||||
|
|
||||||
|
extend({ Container, Graphics, Text, Sprite });
|
||||||
|
|
||||||
export function Map() {
|
export function Map() {
|
||||||
return (
|
return (
|
||||||
<MapDataProvider>
|
<MapDataProvider>
|
||||||
|
|||||||
393
src/client/src/components/map/Sight.tsx
Normal file
393
src/client/src/components/map/Sight.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
// SightsLayer.tsx
|
||||||
|
import { Graphics, Assets, Texture, TextStyle } from "pixi.js";
|
||||||
|
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||||
|
import { useTransform } from "./transformContext";
|
||||||
|
import { SightData } from "./types";
|
||||||
|
import sightIcon from "../../assets/images/sight.svg";
|
||||||
|
import { useGeolocationStore } from "../../stores"; // Импортируем useGeolocationStore
|
||||||
|
|
||||||
|
const BASE_ICON_SIZE = 30;
|
||||||
|
const CLUSTER_RADIUS_BASE = 10;
|
||||||
|
|
||||||
|
type Cluster = {
|
||||||
|
id: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
count: number;
|
||||||
|
sights: SightData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PointItem = { type: "point"; id: string; data: SightData };
|
||||||
|
type ClusterItem = { type: "cluster"; id: string; data: Cluster };
|
||||||
|
type ClusteredItem = PointItem | ClusterItem;
|
||||||
|
|
||||||
|
const getDistance = (
|
||||||
|
p1: { longitude: number; latitude: number },
|
||||||
|
p2: { longitude: number; latitude: number }
|
||||||
|
) => {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.pow(p1.longitude - p2.longitude, 2) +
|
||||||
|
Math.pow(p1.latitude - p2.latitude, 2)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDistanceFromPointToPath = (
|
||||||
|
point: { longitude: number; latitude: number },
|
||||||
|
pathPoints: { x: number; y: number }[]
|
||||||
|
): number => {
|
||||||
|
if (!pathPoints || pathPoints.length < 2) {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < pathPoints.length - 1; i++) {
|
||||||
|
const p1 = pathPoints[i];
|
||||||
|
const p2 = pathPoints[i + 1];
|
||||||
|
|
||||||
|
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||||
|
if (lineLengthSq === 0) continue;
|
||||||
|
|
||||||
|
const t =
|
||||||
|
((point.longitude - p1.x) * (p2.x - p1.x) +
|
||||||
|
(point.latitude - p1.y) * (p2.y - p1.y)) /
|
||||||
|
lineLengthSq;
|
||||||
|
const projectionT = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projectedX = p1.x + projectionT * (p2.x - p1.x);
|
||||||
|
const projectedY = p1.y + projectionT * (p2.y - p1.y);
|
||||||
|
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(point.longitude - projectedX) ** 2 + (point.latitude - projectedY) ** 2
|
||||||
|
);
|
||||||
|
minDistance = Math.min(minDistance, dist);
|
||||||
|
}
|
||||||
|
return minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSightClustering = (
|
||||||
|
sights: SightData[],
|
||||||
|
distanceThreshold: number,
|
||||||
|
pathPoints: { x: number; y: number }[]
|
||||||
|
): ClusteredItem[] => {
|
||||||
|
return useMemo(() => {
|
||||||
|
const unclusteredSights: (SightData & { visited?: boolean })[] = sights.map(
|
||||||
|
(s) => ({ ...s })
|
||||||
|
);
|
||||||
|
const clusteredResult: ClusteredItem[] = [];
|
||||||
|
|
||||||
|
for (const sight of unclusteredSights) {
|
||||||
|
if (sight.visited) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusterSights: SightData[] = [];
|
||||||
|
const queue = [sight];
|
||||||
|
sight.visited = true;
|
||||||
|
|
||||||
|
while (queue.length > 0 && clusterSights.length < 4) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
clusterSights.push(current);
|
||||||
|
|
||||||
|
for (const potentialNeighbor of unclusteredSights) {
|
||||||
|
if (
|
||||||
|
!potentialNeighbor.visited &&
|
||||||
|
clusterSights.length < 4 &&
|
||||||
|
getDistance(current, potentialNeighbor) < distanceThreshold
|
||||||
|
) {
|
||||||
|
potentialNeighbor.visited = true;
|
||||||
|
queue.push(potentialNeighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clusterSights.length > 1) {
|
||||||
|
let furthestSight: SightData | null = null;
|
||||||
|
let maxDistanceToPath = -1;
|
||||||
|
|
||||||
|
for (const s of clusterSights) {
|
||||||
|
const dist = getDistanceFromPointToPath(s, pathPoints);
|
||||||
|
if (dist > maxDistanceToPath) {
|
||||||
|
maxDistanceToPath = dist;
|
||||||
|
furthestSight = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = clusterSights.length;
|
||||||
|
const longitude = furthestSight
|
||||||
|
? furthestSight.longitude
|
||||||
|
: clusterSights.reduce((sum, s) => sum + s.longitude, 0) / count;
|
||||||
|
const latitude = furthestSight
|
||||||
|
? furthestSight.latitude
|
||||||
|
: clusterSights.reduce((sum, s) => sum + s.latitude, 0) / count;
|
||||||
|
|
||||||
|
const id = `cluster-${clusterSights[0].id}`;
|
||||||
|
clusteredResult.push({
|
||||||
|
type: "cluster",
|
||||||
|
id,
|
||||||
|
data: { id, count, longitude, latitude, sights: clusterSights },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const singleSight = clusterSights[0];
|
||||||
|
clusteredResult.push({
|
||||||
|
type: "point",
|
||||||
|
id: String(singleSight.id),
|
||||||
|
data: singleSight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clusteredResult;
|
||||||
|
}, [sights, distanceThreshold, pathPoints]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Изменяем пропсы для SingleSight, чтобы он мог передавать ID при тапе
|
||||||
|
function SingleSight({
|
||||||
|
sight,
|
||||||
|
onSightClick,
|
||||||
|
}: {
|
||||||
|
readonly sight: SightData;
|
||||||
|
onSightClick: (sightId: string) => void;
|
||||||
|
}) {
|
||||||
|
useTransform();
|
||||||
|
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
|
||||||
|
const store = useGeolocationStore();
|
||||||
|
const { setIsGovernorWidgetOpen } = store;
|
||||||
|
useEffect(() => {
|
||||||
|
Assets.load(sightIcon).then(setTexture).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerTap = useCallback(() => {
|
||||||
|
setIsGovernorWidgetOpen(false);
|
||||||
|
onSightClick(String(sight.id)); // Передаем ID выбранной достопримечательности
|
||||||
|
}, [sight.id, onSightClick]);
|
||||||
|
|
||||||
|
if (texture === Texture.EMPTY) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicSize = BASE_ICON_SIZE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiSprite
|
||||||
|
texture={texture}
|
||||||
|
x={sight.longitude}
|
||||||
|
y={sight.latitude}
|
||||||
|
width={dynamicSize}
|
||||||
|
height={dynamicSize}
|
||||||
|
anchor={0.5}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerTap={handlePointerTap}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем onSightSelectedInCluster в пропсы
|
||||||
|
function SightCluster({
|
||||||
|
cluster,
|
||||||
|
onClusterToggle,
|
||||||
|
isExpanded,
|
||||||
|
onSightSelectedInCluster,
|
||||||
|
}: {
|
||||||
|
readonly cluster: Cluster;
|
||||||
|
onClusterToggle: (clusterId: string | null) => void;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onSightSelectedInCluster: (sightId: string) => void;
|
||||||
|
}) {
|
||||||
|
const store = useGeolocationStore();
|
||||||
|
const { setIsGovernorWidgetOpen } = store;
|
||||||
|
useTransform();
|
||||||
|
const radius = CLUSTER_RADIUS_BASE;
|
||||||
|
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
|
||||||
|
const fontSize = 14;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Assets.load(sightIcon).then(setTexture).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clusterTextStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
new TextStyle({
|
||||||
|
fill: "white",
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: "bold",
|
||||||
|
}),
|
||||||
|
[fontSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClusterTap = useCallback(() => {
|
||||||
|
onClusterToggle(isExpanded ? null : cluster.id);
|
||||||
|
}, [cluster.id, cluster.count, isExpanded, onClusterToggle]);
|
||||||
|
|
||||||
|
const handleSightSelect = useCallback(
|
||||||
|
(sightId: string) => {
|
||||||
|
setIsGovernorWidgetOpen(false);
|
||||||
|
onSightSelectedInCluster(sightId); // Передаем выбранный ID наверх
|
||||||
|
onClusterToggle(null); // Закрываем кластер после выбора достопримечательности
|
||||||
|
},
|
||||||
|
[onClusterToggle, onSightSelectedInCluster, setIsGovernorWidgetOpen]
|
||||||
|
); // Добавляем onSightSelectedInCluster в зависимости
|
||||||
|
|
||||||
|
const drawClusterGraphics = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
g.beginFill(0x896f58);
|
||||||
|
g.drawCircle(0, 0, radius);
|
||||||
|
g.endFill();
|
||||||
|
},
|
||||||
|
[radius]
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawExpandedClusterBackground = useCallback((g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
const expandedRadius = BASE_ICON_SIZE * 2;
|
||||||
|
g.beginFill(0x896f58, 0.3);
|
||||||
|
g.lineStyle(2, 0x896f58, 1);
|
||||||
|
g.drawCircle(0, 0, expandedRadius);
|
||||||
|
g.endFill();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBackgroundTap = useCallback(() => {
|
||||||
|
onClusterToggle(null); // Закрываем кластер при клике по фону
|
||||||
|
}, [cluster.id, onClusterToggle]);
|
||||||
|
|
||||||
|
if (texture === Texture.EMPTY) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicSize = BASE_ICON_SIZE;
|
||||||
|
const offsetX = dynamicSize / 2;
|
||||||
|
const offsetY = -dynamicSize / 2;
|
||||||
|
|
||||||
|
const getPositionForSight = (index: number, total: number) => {
|
||||||
|
const angle = (index / total) * Math.PI * 2;
|
||||||
|
const distance = BASE_ICON_SIZE * 1.2;
|
||||||
|
return {
|
||||||
|
x: distance * Math.cos(angle),
|
||||||
|
y: distance * Math.sin(angle),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer x={cluster.longitude} y={cluster.latitude}>
|
||||||
|
{isExpanded && (
|
||||||
|
<pixiGraphics
|
||||||
|
draw={drawExpandedClusterBackground}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerTap={handleBackgroundTap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isExpanded ? (
|
||||||
|
<>
|
||||||
|
<pixiSprite
|
||||||
|
texture={texture}
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={dynamicSize}
|
||||||
|
height={dynamicSize}
|
||||||
|
anchor={0.5}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerTap={handleClusterTap}
|
||||||
|
/>
|
||||||
|
<pixiGraphics draw={drawClusterGraphics} x={offsetX} y={offsetY} />
|
||||||
|
<pixiText
|
||||||
|
text={String(cluster.count)}
|
||||||
|
x={offsetX}
|
||||||
|
y={offsetY}
|
||||||
|
anchor={0.5}
|
||||||
|
style={clusterTextStyle}
|
||||||
|
resolution={4}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{cluster.sights.map((sight, index) => {
|
||||||
|
const pos = getPositionForSight(index, cluster.sights.length);
|
||||||
|
return (
|
||||||
|
<pixiSprite
|
||||||
|
key={sight.id}
|
||||||
|
texture={texture}
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y}
|
||||||
|
width={dynamicSize}
|
||||||
|
height={dynamicSize}
|
||||||
|
anchor={0.5}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerTap={() => handleSightSelect(String(sight.id))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SightsLayerProps {
|
||||||
|
sights: SightData[];
|
||||||
|
pathPoints: { x: number; y: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SightsLayer({
|
||||||
|
sights,
|
||||||
|
pathPoints,
|
||||||
|
}: Readonly<SightsLayerProps>) {
|
||||||
|
useTransform();
|
||||||
|
const distanceThreshold = BASE_ICON_SIZE * 3;
|
||||||
|
|
||||||
|
const store = useGeolocationStore(); // Получаем доступ к MobX хранилищу
|
||||||
|
const {
|
||||||
|
setSelectedSightId,
|
||||||
|
setIsManualSelection,
|
||||||
|
setIsRightWidgetSelectorOpen,
|
||||||
|
} = store; // Получаем нужные экшены
|
||||||
|
|
||||||
|
const items = useSightClustering(sights, distanceThreshold, pathPoints);
|
||||||
|
|
||||||
|
const [activeClusterId, setActiveClusterId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleClusterToggle = useCallback((clusterId: string | null) => {
|
||||||
|
setActiveClusterId(clusterId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSightSelected = useCallback(
|
||||||
|
(sightId: string) => {
|
||||||
|
setSelectedSightId(sightId);
|
||||||
|
setIsManualSelection(true);
|
||||||
|
setIsRightWidgetSelectorOpen(false); // Закрываем селектор правого виджета при клике по достопримечательности
|
||||||
|
// Закрываем виджет губернатора при выборе достопримечательности
|
||||||
|
store.closeGovernorModal();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setSelectedSightId,
|
||||||
|
setIsManualSelection,
|
||||||
|
setIsRightWidgetSelectorOpen,
|
||||||
|
store,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map((item) => {
|
||||||
|
if (item.type === "cluster") {
|
||||||
|
return (
|
||||||
|
<SightCluster
|
||||||
|
key={item.id}
|
||||||
|
cluster={item.data}
|
||||||
|
onClusterToggle={handleClusterToggle}
|
||||||
|
isExpanded={activeClusterId === item.id}
|
||||||
|
onSightSelectedInCluster={handleSightSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SingleSight
|
||||||
|
key={item.id}
|
||||||
|
sight={item.data}
|
||||||
|
onSightClick={handleSightSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/client/src/components/map/Station.tsx
Normal file
174
src/client/src/components/map/Station.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Graphics } from "pixi.js";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
BACKGROUND_COLOR,
|
||||||
|
PATH_COLOR,
|
||||||
|
STATION_RADIUS,
|
||||||
|
STATION_OUTLINE_WIDTH,
|
||||||
|
UNPASSED_STATION_COLOR,
|
||||||
|
} from "./Constants";
|
||||||
|
import { StationData } from "./types";
|
||||||
|
import { useTransform } from "./transformContext";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
|
||||||
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
|
|
||||||
|
interface StationProps {
|
||||||
|
station: StationData;
|
||||||
|
stationEn?: StationData | null;
|
||||||
|
stationZh?: StationData | null;
|
||||||
|
isPassed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_FONT_SIZE = 16;
|
||||||
|
const POINT_LABEL_FONT_SIZE = 13;
|
||||||
|
const DEFAULT_LABEL_OFFSET_X = 25;
|
||||||
|
const DEFAULT_LABEL_OFFSET_Y = 0;
|
||||||
|
|
||||||
|
const getAnchorFromOffset = (
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
if (offsetX === 0 && offsetY === 0) {
|
||||||
|
return { x: 0, y: 0.5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = Math.hypot(offsetX, offsetY);
|
||||||
|
const nx = offsetX / length;
|
||||||
|
const ny = offsetY / length;
|
||||||
|
|
||||||
|
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Station = observer(
|
||||||
|
({ station, stationEn, stationZh, isPassed }: Readonly<StationProps>) => {
|
||||||
|
const { scale } = useTransform();
|
||||||
|
const { context } = apiStore;
|
||||||
|
|
||||||
|
const { selectedLanguage } = useGeolocationStore();
|
||||||
|
|
||||||
|
const ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION = 2; // Порог масштаба (в 2 раза)
|
||||||
|
const HIGH_RESOLUTION_VALUE = 4;
|
||||||
|
const LOW_RESOLUTION_VALUE = 2;
|
||||||
|
|
||||||
|
const dynamicResolution =
|
||||||
|
scale > ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION
|
||||||
|
? HIGH_RESOLUTION_VALUE
|
||||||
|
: LOW_RESOLUTION_VALUE;
|
||||||
|
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
// Проверяем, является ли станция начальной или конечной
|
||||||
|
const isTerminalStation =
|
||||||
|
context &&
|
||||||
|
(station.id.toString() === (context as any)?.startStopId ||
|
||||||
|
station.id.toString() === (context as any)?.endStopId);
|
||||||
|
|
||||||
|
// Определяем радиус станции
|
||||||
|
const stationRadius = isTerminalStation
|
||||||
|
? STATION_RADIUS * 1.5
|
||||||
|
: STATION_RADIUS;
|
||||||
|
|
||||||
|
// Рисуем основной круг станции
|
||||||
|
g.circle(station.longitude, station.latitude, stationRadius);
|
||||||
|
g.fill({ color: isPassed ? PATH_COLOR : UNPASSED_STATION_COLOR });
|
||||||
|
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
|
||||||
|
|
||||||
|
// Если это терминальная станция, рисуем дополнительный круг по центру
|
||||||
|
if (isTerminalStation) {
|
||||||
|
const centerCircleRadius = stationRadius * 0.5; // 50% от радиуса станции
|
||||||
|
g.circle(station.longitude, station.latitude, centerCircleRadius);
|
||||||
|
g.fill({ color: BACKGROUND_COLOR });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[station.latitude, station.longitude, station.id, context, isPassed]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dynamicFontSize = BASE_FONT_SIZE;
|
||||||
|
const dynamicPointLabelFontSize = POINT_LABEL_FONT_SIZE;
|
||||||
|
|
||||||
|
// Определяем фактические смещения. Если station.offset_x и station.offset_y оба равны 0,
|
||||||
|
// используем дефолтный отступ для обеспечения видимости.
|
||||||
|
const labelOffsetX =
|
||||||
|
station.offset_x === 0 && station.offset_y === 0
|
||||||
|
? DEFAULT_LABEL_OFFSET_X
|
||||||
|
: station.offset_x / 3;
|
||||||
|
const labelOffsetY =
|
||||||
|
station.offset_x === 0 && station.offset_y === 0
|
||||||
|
? DEFAULT_LABEL_OFFSET_Y
|
||||||
|
: station.offset_y / 3;
|
||||||
|
|
||||||
|
// Вычисляем позицию текстового блока. Это прямые координаты карты.
|
||||||
|
const textBlockPositionX = station.longitude + labelOffsetX / 3;
|
||||||
|
const textBlockPositionY = station.latitude + labelOffsetY;
|
||||||
|
|
||||||
|
// Используем useMemo для запоминания dynamicAnchor на основе *фактически используемого* отступа.
|
||||||
|
const dynamicAnchor = useMemo(
|
||||||
|
() => getAnchorFromOffset(labelOffsetX, labelOffsetY),
|
||||||
|
[labelOffsetX, labelOffsetY]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer>
|
||||||
|
<pixiGraphics draw={draw} zIndex={0} />
|
||||||
|
|
||||||
|
<pixiText
|
||||||
|
anchor={dynamicAnchor} // Используем динамический якорь
|
||||||
|
text={station.name}
|
||||||
|
position={{
|
||||||
|
x: textBlockPositionX,
|
||||||
|
y: textBlockPositionY,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: dynamicFontSize,
|
||||||
|
fontFamily: "Roboto",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fill: "#fff",
|
||||||
|
}}
|
||||||
|
resolution={dynamicResolution}
|
||||||
|
zIndex={100}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(selectedLanguage === "en" || selectedLanguage === "ru") && (
|
||||||
|
<pixiText
|
||||||
|
anchor={dynamicAnchor} // Используем динамический якорь
|
||||||
|
text={stationEn?.name}
|
||||||
|
position={{
|
||||||
|
x: textBlockPositionX,
|
||||||
|
y: textBlockPositionY + 14, // Это смещение второй надписи относительно первой
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: dynamicPointLabelFontSize,
|
||||||
|
fontFamily: "Roboto",
|
||||||
|
fontWeight: "normal",
|
||||||
|
fill: "#CBCBCB",
|
||||||
|
}}
|
||||||
|
resolution={dynamicResolution}
|
||||||
|
zIndex={100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedLanguage === "zh" && (
|
||||||
|
<pixiText
|
||||||
|
anchor={dynamicAnchor} // Используем динамический якорь
|
||||||
|
text={stationZh?.name}
|
||||||
|
position={{
|
||||||
|
x: textBlockPositionX,
|
||||||
|
y: textBlockPositionY + 14, // Это смещение второй надписи относительно первой
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: dynamicPointLabelFontSize,
|
||||||
|
fontFamily: "Roboto",
|
||||||
|
fontWeight: "normal",
|
||||||
|
fill: "#CBCBCB",
|
||||||
|
}}
|
||||||
|
resolution={dynamicResolution}
|
||||||
|
zIndex={100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
375
src/client/src/components/map/TramIcon.tsx
Normal file
375
src/client/src/components/map/TramIcon.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { Texture, Assets } from "pixi.js";
|
||||||
|
import { useEffect, useState, useMemo, useRef } from "react";
|
||||||
|
import { useTransform } from "./transformContext";
|
||||||
|
import { lerp, lerpAngle } from "../../utils/animationUtils";
|
||||||
|
|
||||||
|
const basePath = new URL(
|
||||||
|
"../../assets/tramPosition/Tram Base.svg",
|
||||||
|
import.meta.url
|
||||||
|
).href;
|
||||||
|
const tramPath = new URL("../../assets/tramPosition/Tram.svg", import.meta.url)
|
||||||
|
.href;
|
||||||
|
|
||||||
|
const LERP_SPEED = 0.1; // Скорость интерполяции (10% каждый кадр)
|
||||||
|
|
||||||
|
// Функция для проверки расстояния до ближайшей точки маршрута
|
||||||
|
const getDistanceToPath = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
pathPoints: { x: number; y: number }[]
|
||||||
|
) => {
|
||||||
|
if (!pathPoints || pathPoints.length < 2) return Infinity;
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < pathPoints.length - 1; i++) {
|
||||||
|
const p1 = pathPoints[i];
|
||||||
|
const p2 = pathPoints[i + 1];
|
||||||
|
|
||||||
|
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||||
|
if (lineLengthSq === 0) continue;
|
||||||
|
|
||||||
|
const t =
|
||||||
|
((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
|
||||||
|
lineLengthSq;
|
||||||
|
const projectionT = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projectedX = p1.x + projectionT * (p2.x - p1.x);
|
||||||
|
const projectedY = p1.y + projectionT * (p2.y - p1.y);
|
||||||
|
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
|
||||||
|
);
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для проверки расстояния до пройденной части маршрута
|
||||||
|
const getDistanceToPassedPath = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
pathPoints: { x: number; y: number }[],
|
||||||
|
passedSegmentIndex: number
|
||||||
|
) => {
|
||||||
|
if (!pathPoints || pathPoints.length < 2 || passedSegmentIndex < 0)
|
||||||
|
return Infinity;
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
// Проверяем только пройденную часть (до passedSegmentIndex включительно)
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i <= Math.min(passedSegmentIndex, pathPoints.length - 2);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
const p1 = pathPoints[i];
|
||||||
|
const p2 = pathPoints[i + 1];
|
||||||
|
|
||||||
|
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||||
|
if (lineLengthSq === 0) continue;
|
||||||
|
|
||||||
|
const t =
|
||||||
|
((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
|
||||||
|
lineLengthSq;
|
||||||
|
const projectionT = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projectedX = p1.x + projectionT * (p2.x - p1.x);
|
||||||
|
const projectedY = p1.y + projectionT * (p2.y - p1.y);
|
||||||
|
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
|
||||||
|
);
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для проверки расстояния до ближайшей станции
|
||||||
|
const getDistanceToStations = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
stations: {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
offset_x?: number;
|
||||||
|
offset_y?: number;
|
||||||
|
}[],
|
||||||
|
_debug: boolean = false
|
||||||
|
) => {
|
||||||
|
if (!stations || stations.length === 0) {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (const station of stations) {
|
||||||
|
// Рассчитываем позицию текста станции с учетом смещений (как в Station.tsx)
|
||||||
|
const DEFAULT_LABEL_OFFSET_X = 25;
|
||||||
|
const DEFAULT_LABEL_OFFSET_Y = 0;
|
||||||
|
|
||||||
|
const labelOffsetX =
|
||||||
|
station.offset_x === 0 && station.offset_y === 0
|
||||||
|
? DEFAULT_LABEL_OFFSET_X
|
||||||
|
: (station.offset_x || 0) / 3;
|
||||||
|
const labelOffsetY =
|
||||||
|
station.offset_x === 0 && station.offset_y === 0
|
||||||
|
? DEFAULT_LABEL_OFFSET_Y
|
||||||
|
: (station.offset_y || 0) / 3;
|
||||||
|
|
||||||
|
const textBlockPositionX = station.longitude + labelOffsetX;
|
||||||
|
const textBlockPositionY = station.latitude + labelOffsetY;
|
||||||
|
|
||||||
|
// Вычисляем расстояние до позиции текста станции (не до центра станции)
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(point.x - textBlockPositionX) ** 2 + (point.y - textBlockPositionY) ** 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для поиска оптимального угла поворота метки относительно трамвая
|
||||||
|
const findOptimalAngle = (
|
||||||
|
tramX: number,
|
||||||
|
tramY: number,
|
||||||
|
pathPoints: { x: number; y: number }[],
|
||||||
|
stations: {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
offset_x?: number;
|
||||||
|
offset_y?: number;
|
||||||
|
}[],
|
||||||
|
passedSegmentIndex: number,
|
||||||
|
scale: number
|
||||||
|
) => {
|
||||||
|
const testRadiusInMapCoords = 100 / scale; // Радиус в координатах карты
|
||||||
|
const minSafeDistanceToPath = 60; // Минимальное безопасное расстояние до пути в пикселях
|
||||||
|
const minSafeDistanceToPassedPath = 60; // Минимальное безопасное расстояние до пройденной части в пикселях
|
||||||
|
const minSafeDistanceToStation = 50; // Минимальное безопасное расстояние до станции в пикселях
|
||||||
|
|
||||||
|
let bestAngle = 0;
|
||||||
|
let bestScore = Infinity; // Ищем минимальный вес
|
||||||
|
const segmentWeights: { angle: number; weight: number; distances: any }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
// Проверяем 12 углов (каждые 30 градусов)
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const testAngle = (i * Math.PI * 2) / 12;
|
||||||
|
const testX = tramX + Math.cos(testAngle) * testRadiusInMapCoords;
|
||||||
|
const testY = tramY + Math.sin(testAngle) * testRadiusInMapCoords;
|
||||||
|
|
||||||
|
const distanceToPath =
|
||||||
|
getDistanceToPath({ x: testX, y: testY }, pathPoints) * scale; // В пикселях
|
||||||
|
const distanceToPassedPath =
|
||||||
|
getDistanceToPassedPath(
|
||||||
|
{ x: testX, y: testY },
|
||||||
|
pathPoints,
|
||||||
|
passedSegmentIndex
|
||||||
|
) * scale; // В пикселях
|
||||||
|
const distanceToStation =
|
||||||
|
getDistanceToStations({ x: testX, y: testY }, stations, false) * scale; // В пикселях с отладкой
|
||||||
|
|
||||||
|
// Вычисляем вес для этого угла (чем меньше расстояние, тем больше вес)
|
||||||
|
let weight = 0;
|
||||||
|
|
||||||
|
// Путь - вес 100
|
||||||
|
if (distanceToPath < minSafeDistanceToPath) {
|
||||||
|
weight += 100 * (1 - distanceToPath / minSafeDistanceToPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текст прошедшей станции - вес 10
|
||||||
|
if (distanceToPassedPath < minSafeDistanceToPassedPath) {
|
||||||
|
weight += 10 * (1 - distanceToPassedPath / minSafeDistanceToPassedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текст следующей станции - вес 1000
|
||||||
|
if (distanceToStation < minSafeDistanceToStation) {
|
||||||
|
weight += 1000 * (1 - distanceToStation / minSafeDistanceToStation);
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentWeights.push({
|
||||||
|
angle: testAngle,
|
||||||
|
weight: Math.round(weight * 10) / 10, // Округляем для читаемости
|
||||||
|
distances: {
|
||||||
|
path: Math.round(distanceToPath),
|
||||||
|
passedPath: Math.round(distanceToPassedPath),
|
||||||
|
station: Math.round(distanceToStation),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (weight < bestScore) {
|
||||||
|
bestScore = weight;
|
||||||
|
bestAngle = testAngle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если несколько сегментов имеют одинаковый минимальный вес,
|
||||||
|
// выбираем тот, который максимально удален от препятствий
|
||||||
|
const minWeightSegments = segmentWeights.filter(
|
||||||
|
(s) => s.weight === bestScore
|
||||||
|
);
|
||||||
|
if (minWeightSegments.length > 1) {
|
||||||
|
// Из сегментов с минимальным весом выбираем тот, который максимально удален от препятствий
|
||||||
|
let bestDistanceSum = -1;
|
||||||
|
for (const segment of minWeightSegments) {
|
||||||
|
const distanceSum =
|
||||||
|
segment.distances.path +
|
||||||
|
segment.distances.passedPath +
|
||||||
|
segment.distances.station;
|
||||||
|
if (distanceSum > bestDistanceSum) {
|
||||||
|
bestDistanceSum = distanceSum;
|
||||||
|
bestAngle = segment.angle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bestAngle, segmentWeights };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TramIcon({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
angle,
|
||||||
|
pathPoints = [],
|
||||||
|
stations = [],
|
||||||
|
passedSegmentIndex = -1,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
angle: number;
|
||||||
|
pathPoints?: { x: number; y: number }[];
|
||||||
|
stations?: {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
offset_x?: number;
|
||||||
|
offset_y?: number;
|
||||||
|
}[];
|
||||||
|
passedSegmentIndex?: number;
|
||||||
|
}) {
|
||||||
|
const { scale } = useTransform();
|
||||||
|
|
||||||
|
// Находим оптимальный угол поворота метки относительно трамвая
|
||||||
|
const optimalAngle = useMemo(
|
||||||
|
() =>
|
||||||
|
findOptimalAngle(x, y, pathPoints, stations, passedSegmentIndex, scale)
|
||||||
|
.bestAngle,
|
||||||
|
[x, y, pathPoints, stations, passedSegmentIndex, scale]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Состояние для плавной анимации позиции и углов (как в HTML файле)
|
||||||
|
const [smoothPosition, setSmoothPosition] = useState({ x, y });
|
||||||
|
const [smoothOptimalAngle, setSmoothOptimalAngle] = useState(optimalAngle);
|
||||||
|
const [smoothTramAngle, setSmoothTramAngle] = useState(angle);
|
||||||
|
const animationRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// Плавная анимация позиции и углов (логика из HTML файла)
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = () => {
|
||||||
|
// Анимируем позицию (как в HTML файле)
|
||||||
|
setSmoothPosition((prev) => {
|
||||||
|
const newX = lerp(prev.x, x, LERP_SPEED);
|
||||||
|
const newY = lerp(prev.y, y, LERP_SPEED);
|
||||||
|
return {
|
||||||
|
x: Math.abs(newX - x) < 0.1 ? x : newX,
|
||||||
|
y: Math.abs(newY - y) < 0.1 ? y : newY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Анимируем оптимальный угол
|
||||||
|
setSmoothOptimalAngle((prev) => {
|
||||||
|
const newAngle = lerpAngle(prev, optimalAngle, LERP_SPEED);
|
||||||
|
return Math.abs(newAngle - optimalAngle) < 0.01
|
||||||
|
? optimalAngle
|
||||||
|
: newAngle;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Анимируем угол трамвая
|
||||||
|
setSmoothTramAngle((prev) => {
|
||||||
|
const newAngle = lerpAngle(prev, angle, LERP_SPEED);
|
||||||
|
return Math.abs(newAngle - angle) < 0.01 ? angle : newAngle;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Продолжаем анимацию, если что-то еще не достигло цели
|
||||||
|
if (
|
||||||
|
Math.abs(smoothPosition.x - x) > 0.1 ||
|
||||||
|
Math.abs(smoothPosition.y - y) > 0.1 ||
|
||||||
|
Math.abs(smoothOptimalAngle - optimalAngle) > 0.01 ||
|
||||||
|
Math.abs(smoothTramAngle - angle) > 0.01
|
||||||
|
) {
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current !== undefined) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
optimalAngle,
|
||||||
|
angle,
|
||||||
|
smoothPosition,
|
||||||
|
smoothOptimalAngle,
|
||||||
|
smoothTramAngle,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Обычные размеры без увеличения
|
||||||
|
const backgroundWidth = 111 / scale;
|
||||||
|
const backgroundHeight = 82 / scale;
|
||||||
|
const tramWidth = 31 / scale;
|
||||||
|
const tramHeight = 52 / scale;
|
||||||
|
|
||||||
|
const [baseTexture, setBaseTexture] = useState<Texture | null>(null);
|
||||||
|
const [tramTexture, setTramTexture] = useState<Texture | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Assets.load(basePath).then(setBaseTexture).catch(console.error);
|
||||||
|
Assets.load(tramPath).then(setTramTexture).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!baseTexture || !tramTexture) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer x={smoothPosition.x} y={smoothPosition.y}>
|
||||||
|
{/* вращающийся контейнер с плавным оптимальным углом */}
|
||||||
|
<pixiContainer rotation={smoothOptimalAngle + Math.PI}>
|
||||||
|
<pixiSprite
|
||||||
|
texture={baseTexture}
|
||||||
|
anchor={{ x: 1, y: 0.5 }}
|
||||||
|
width={backgroundWidth > 53.7 ? backgroundWidth : 53.7}
|
||||||
|
height={backgroundHeight > 39.68 ? backgroundHeight : 39.68}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* контейнер иконки трамвая с плавным поворотом направления движения */}
|
||||||
|
<pixiContainer
|
||||||
|
x={
|
||||||
|
backgroundWidth > 53.7
|
||||||
|
? -backgroundWidth / 1.42
|
||||||
|
: -backgroundWidth / 0.98
|
||||||
|
}
|
||||||
|
y={0}
|
||||||
|
>
|
||||||
|
<pixiSprite
|
||||||
|
texture={tramTexture}
|
||||||
|
anchor={0.5}
|
||||||
|
rotation={-smoothOptimalAngle - Math.PI}
|
||||||
|
width={tramWidth}
|
||||||
|
height={tramHeight}
|
||||||
|
/>
|
||||||
|
</pixiContainer>
|
||||||
|
</pixiContainer>
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/client/src/components/map/TravelPath.tsx
Normal file
92
src/client/src/components/map/TravelPath.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Graphics } from "pixi.js";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { PATH_COLOR, PATH_WIDTH, UNPASSED_STATION_COLOR } from "./Constants";
|
||||||
|
|
||||||
|
interface TravelPathProps {
|
||||||
|
points: { x: number; y: number }[];
|
||||||
|
busCoordinates?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PASSED_PATH_COLOR = PATH_COLOR;
|
||||||
|
const UNPASSED_PATH_COLOR = UNPASSED_STATION_COLOR;
|
||||||
|
|
||||||
|
export function TravelPath({
|
||||||
|
points,
|
||||||
|
busCoordinates,
|
||||||
|
}: Readonly<TravelPathProps>) {
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
if (points.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
g.lineTo(points[i].x, points[i].y);
|
||||||
|
}
|
||||||
|
g.stroke({
|
||||||
|
color: UNPASSED_PATH_COLOR,
|
||||||
|
width: PATH_WIDTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!busCoordinates) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
let closestSegmentIndex = -1;
|
||||||
|
let busSegmentStartPoint: { x: number; y: number } | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[i + 1];
|
||||||
|
|
||||||
|
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||||
|
if (lineLengthSq === 0) continue;
|
||||||
|
|
||||||
|
const t =
|
||||||
|
((busCoordinates.x - p1.x) * (p2.x - p1.x) +
|
||||||
|
(busCoordinates.y - p1.y) * (p2.y - p1.y)) /
|
||||||
|
lineLengthSq;
|
||||||
|
const projectionT = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projectedX = p1.x + projectionT * (p2.x - p1.x);
|
||||||
|
const projectedY = p1.y + projectionT * (p2.y - p1.y);
|
||||||
|
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(busCoordinates.x - projectedX) ** 2 +
|
||||||
|
(busCoordinates.y - projectedY) ** 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
closestSegmentIndex = i;
|
||||||
|
busSegmentStartPoint = { x: projectedX, y: projectedY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closestSegmentIndex !== -1 && busSegmentStartPoint) {
|
||||||
|
g.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i <= closestSegmentIndex; i++) {
|
||||||
|
g.lineTo(points[i].x, points[i].y);
|
||||||
|
}
|
||||||
|
g.lineTo(busSegmentStartPoint.x, busSegmentStartPoint.y);
|
||||||
|
|
||||||
|
g.stroke({
|
||||||
|
color: PASSED_PATH_COLOR,
|
||||||
|
width: PATH_WIDTH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[points, busCoordinates]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
console.error("points is empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <pixiGraphics draw={draw} />;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
UNPASSED_STATION_COLOR,
|
UNPASSED_STATION_COLOR,
|
||||||
BUS_COLOR,
|
BUS_COLOR,
|
||||||
BASE_ICON_SIZE,
|
BASE_ICON_SIZE,
|
||||||
|
CLUSTER_RADIUS_BASE,
|
||||||
} from "./Constants";
|
} from "./Constants";
|
||||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
@@ -155,19 +156,6 @@ const useSightClustering = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCustomIcon =
|
|
||||||
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null);
|
|
||||||
|
|
||||||
if (hasCustomIcon) {
|
|
||||||
sight.visited = true;
|
|
||||||
clusteredResult.push({
|
|
||||||
type: "point",
|
|
||||||
id: String(sight.id),
|
|
||||||
data: sight,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clusterSights: SightData[] = [];
|
const clusterSights: SightData[] = [];
|
||||||
const queue = [sight];
|
const queue = [sight];
|
||||||
sight.visited = true;
|
sight.visited = true;
|
||||||
@@ -177,12 +165,8 @@ const useSightClustering = (
|
|||||||
clusterSights.push(current);
|
clusterSights.push(current);
|
||||||
|
|
||||||
for (const potentialNeighbor of unclusteredSights) {
|
for (const potentialNeighbor of unclusteredSights) {
|
||||||
const neighborHasCustomIcon =
|
|
||||||
potentialNeighbor.is_default_icon === false &&
|
|
||||||
!isMediaIdEmpty(potentialNeighbor.icon ?? null);
|
|
||||||
if (
|
if (
|
||||||
!potentialNeighbor.visited &&
|
!potentialNeighbor.visited &&
|
||||||
!neighborHasCustomIcon &&
|
|
||||||
clusterSights.length < 4 &&
|
clusterSights.length < 4 &&
|
||||||
getDistance(current, potentialNeighbor) < distanceThreshold
|
getDistance(current, potentialNeighbor) < distanceThreshold
|
||||||
) {
|
) {
|
||||||
@@ -192,10 +176,6 @@ const useSightClustering = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const leftover of queue) {
|
|
||||||
leftover.visited = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clusterSights.length > 1) {
|
if (clusterSights.length > 1) {
|
||||||
let furthestSight: SightData | null = null;
|
let furthestSight: SightData | null = null;
|
||||||
let maxDistanceToPath = -1;
|
let maxDistanceToPath = -1;
|
||||||
@@ -402,15 +382,13 @@ export const WebGLMap = observer(() => {
|
|||||||
return livePercent;
|
return livePercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sight?.is_default_icon === false) {
|
|
||||||
if (
|
if (
|
||||||
|
sight != null &&
|
||||||
typeof sight.icon_size === "number" &&
|
typeof sight.icon_size === "number" &&
|
||||||
Number.isFinite(sight.icon_size)
|
Number.isFinite(sight.icon_size)
|
||||||
) {
|
) {
|
||||||
return sight.icon_size;
|
return sight.icon_size;
|
||||||
}
|
}
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof routeData?.icon_size === "number" &&
|
typeof routeData?.icon_size === "number" &&
|
||||||
@@ -903,36 +881,25 @@ export const WebGLMap = observer(() => {
|
|||||||
|
|
||||||
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
|
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
|
||||||
const orderedStationSegs = useMemo(() => {
|
const orderedStationSegs = useMemo(() => {
|
||||||
if (!orderedRouteStations || !stationData || routePath.length < 4)
|
if (!orderedRouteStations || !stationData || routePath.length < 4) return [] as number[];
|
||||||
return [] as number[];
|
|
||||||
return (orderedRouteStations as any[]).map((ordStation) => {
|
return (orderedRouteStations as any[]).map((ordStation) => {
|
||||||
const stIdx = stationData.findIndex(
|
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(ordStation.id));
|
||||||
(s: any) => String(s.id) === String(ordStation.id),
|
|
||||||
);
|
|
||||||
if (stIdx < 0) return -1;
|
if (stIdx < 0) return -1;
|
||||||
const sx = stationPoints[stIdx * 2];
|
const sx = stationPoints[stIdx * 2];
|
||||||
const sy = stationPoints[stIdx * 2 + 1];
|
const sy = stationPoints[stIdx * 2 + 1];
|
||||||
if (sx === undefined || sy === undefined) return -1;
|
if (sx === undefined || sy === undefined) return -1;
|
||||||
let best = -1,
|
let best = -1, bestD = Infinity;
|
||||||
bestD = Infinity;
|
|
||||||
for (let i = 0; i < routePath.length - 2; i += 2) {
|
for (let i = 0; i < routePath.length - 2; i += 2) {
|
||||||
const p1x = routePath[i],
|
const p1x = routePath[i], p1y = routePath[i + 1];
|
||||||
p1y = routePath[i + 1];
|
const p2x = routePath[i + 2], p2y = routePath[i + 3];
|
||||||
const p2x = routePath[i + 2],
|
const dx = p2x - p1x, dy = p2y - p1y;
|
||||||
p2y = routePath[i + 3];
|
|
||||||
const dx = p2x - p1x,
|
|
||||||
dy = p2y - p1y;
|
|
||||||
const len2 = dx * dx + dy * dy;
|
const len2 = dx * dx + dy * dy;
|
||||||
if (!len2) continue;
|
if (!len2) continue;
|
||||||
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
|
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
|
||||||
const cl = Math.max(0, Math.min(1, t));
|
const cl = Math.max(0, Math.min(1, t));
|
||||||
const px = p1x + cl * dx,
|
const px = p1x + cl * dx, py = p1y + cl * dy;
|
||||||
py = p1y + cl * dy;
|
|
||||||
const d = Math.hypot(sx - px, sy - py);
|
const d = Math.hypot(sx - px, sy - py);
|
||||||
if (d < bestD) {
|
if (d < bestD) { bestD = d; best = i / 2; }
|
||||||
bestD = d;
|
|
||||||
best = i / 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
});
|
});
|
||||||
@@ -1177,9 +1144,7 @@ export const WebGLMap = observer(() => {
|
|||||||
const curIdx = apiStore.positionIndex;
|
const curIdx = apiStore.positionIndex;
|
||||||
const prevIdx = prevPositionIndexRef.current;
|
const prevIdx = prevPositionIndexRef.current;
|
||||||
const pathLen = apiStore.route?.path?.length ?? 0;
|
const pathLen = apiStore.route?.path?.length ?? 0;
|
||||||
const isWrap =
|
const isWrap = prevIdx >= 0 && pathLen > 0 &&
|
||||||
prevIdx >= 0 &&
|
|
||||||
pathLen > 0 &&
|
|
||||||
Math.abs(curIdx - prevIdx) > pathLen / 4;
|
Math.abs(curIdx - prevIdx) > pathLen / 4;
|
||||||
prevPositionIndexRef.current = curIdx;
|
prevPositionIndexRef.current = curIdx;
|
||||||
|
|
||||||
@@ -1449,72 +1414,32 @@ export const WebGLMap = observer(() => {
|
|||||||
|
|
||||||
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
||||||
|
|
||||||
if (
|
if (tramSegIndex >= 0 && orderedRouteStations && stationData && orderedStationSegs.length > 0) {
|
||||||
tramSegIndex >= 0 &&
|
|
||||||
orderedRouteStations &&
|
|
||||||
stationData &&
|
|
||||||
orderedStationSegs.length > 0
|
|
||||||
) {
|
|
||||||
const passedPts1: number[] = [];
|
const passedPts1: number[] = [];
|
||||||
const unpassedPts1: number[] = [];
|
const unpassedPts1: number[] = [];
|
||||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
for (let i = 0; i < orderedRouteStations.length; i++) {
|
||||||
const orderedStation = (orderedRouteStations as any[])[i];
|
const orderedStation = (orderedRouteStations as any[])[i];
|
||||||
const stationSeg = orderedStationSegs[i] ?? -1;
|
const stationSeg = orderedStationSegs[i] ?? -1;
|
||||||
if (!orderedStation || stationSeg < 0) continue;
|
if (!orderedStation || stationSeg < 0) continue;
|
||||||
const isPassed =
|
const isPassed = simulationDirection === 1 ? stationSeg < tramSegIndex : stationSeg > tramSegIndex;
|
||||||
simulationDirection === 1
|
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(orderedStation.id));
|
||||||
? stationSeg < tramSegIndex
|
|
||||||
: stationSeg > tramSegIndex;
|
|
||||||
const stIdx = stationData.findIndex(
|
|
||||||
(s: any) => String(s.id) === String(orderedStation.id),
|
|
||||||
);
|
|
||||||
if (stIdx < 0) continue;
|
if (stIdx < 0) continue;
|
||||||
const sx = stationPoints[stIdx * 2] as number;
|
const sx = stationPoints[stIdx * 2] as number;
|
||||||
const sy = stationPoints[stIdx * 2 + 1] as number;
|
const sy = stationPoints[stIdx * 2 + 1] as number;
|
||||||
if (isPassed) {
|
if (isPassed) { passedPts1.push(sx, sy); } else { unpassedPts1.push(sx, sy); }
|
||||||
passedPts1.push(sx, sy);
|
|
||||||
} else {
|
|
||||||
unpassedPts1.push(sx, sy);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (passedPts1.length > 0) {
|
if (passedPts1.length > 0) {
|
||||||
gl.uniform4f(
|
gl.uniform4f(u_color_pts, ((PATH_COLOR >> 16) & 0xff) / 255, ((PATH_COLOR >> 8) & 0xff) / 255, (PATH_COLOR & 0xff) / 255, 1);
|
||||||
u_color_pts,
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(passedPts1), gl.STATIC_DRAW);
|
||||||
((PATH_COLOR >> 16) & 0xff) / 255,
|
|
||||||
((PATH_COLOR >> 8) & 0xff) / 255,
|
|
||||||
(PATH_COLOR & 0xff) / 255,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
gl.bufferData(
|
|
||||||
gl.ARRAY_BUFFER,
|
|
||||||
new Float32Array(passedPts1),
|
|
||||||
gl.STATIC_DRAW,
|
|
||||||
);
|
|
||||||
gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
|
gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
|
||||||
}
|
}
|
||||||
if (unpassedPts1.length > 0) {
|
if (unpassedPts1.length > 0) {
|
||||||
gl.uniform4f(
|
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
|
||||||
u_color_pts,
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unpassedPts1), gl.STATIC_DRAW);
|
||||||
((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255,
|
|
||||||
((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255,
|
|
||||||
(UNPASSED_STATION_COLOR & 0xff) / 255,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
gl.bufferData(
|
|
||||||
gl.ARRAY_BUFFER,
|
|
||||||
new Float32Array(unpassedPts1),
|
|
||||||
gl.STATIC_DRAW,
|
|
||||||
);
|
|
||||||
gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
|
gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
gl.uniform4f(
|
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
|
||||||
u_color_pts,
|
|
||||||
((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255,
|
|
||||||
((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255,
|
|
||||||
(UNPASSED_STATION_COLOR & 0xff) / 255,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
|
||||||
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
|
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
|
||||||
}
|
}
|
||||||
@@ -1588,17 +1513,12 @@ export const WebGLMap = observer(() => {
|
|||||||
const passedStationIds = new Set<string>();
|
const passedStationIds = new Set<string>();
|
||||||
const unpassedStationIds = new Set<string>();
|
const unpassedStationIds = new Set<string>();
|
||||||
|
|
||||||
if (
|
if (tramSegIndex >= 0 && orderedRouteStations && orderedStationSegs.length === orderedRouteStations.length) {
|
||||||
tramSegIndex >= 0 &&
|
|
||||||
orderedRouteStations &&
|
|
||||||
orderedStationSegs.length === orderedRouteStations.length
|
|
||||||
) {
|
|
||||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
for (let i = 0; i < orderedRouteStations.length; i++) {
|
||||||
const station = (orderedRouteStations as any[])[i];
|
const station = (orderedRouteStations as any[])[i];
|
||||||
const seg = orderedStationSegs[i] ?? -1;
|
const seg = orderedStationSegs[i] ?? -1;
|
||||||
if (!station || seg < 0) continue;
|
if (!station || seg < 0) continue;
|
||||||
const isPassed =
|
const isPassed = simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
|
||||||
simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
|
|
||||||
if (isPassed) passedStationIds.add(String(station.id));
|
if (isPassed) passedStationIds.add(String(station.id));
|
||||||
else unpassedStationIds.add(String(station.id));
|
else unpassedStationIds.add(String(station.id));
|
||||||
}
|
}
|
||||||
@@ -1742,26 +1662,11 @@ export const WebGLMap = observer(() => {
|
|||||||
const sin = Math.sin(rotationAngle);
|
const sin = Math.sin(rotationAngle);
|
||||||
|
|
||||||
const startStationData = orderedRouteStations?.[0]
|
const startStationData = orderedRouteStations?.[0]
|
||||||
? stationData.find(
|
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[0].id))
|
||||||
(station: any) =>
|
: stationData.find((station: any) => station.id.toString() === apiStore.context?.startStopId);
|
||||||
station.id.toString() === String(orderedRouteStations[0].id),
|
|
||||||
)
|
|
||||||
: stationData.find(
|
|
||||||
(station: any) =>
|
|
||||||
station.id.toString() === apiStore.context?.startStopId,
|
|
||||||
);
|
|
||||||
const endStationData = orderedRouteStations?.length
|
const endStationData = orderedRouteStations?.length
|
||||||
? stationData.find(
|
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[orderedRouteStations.length - 1].id))
|
||||||
(station: any) =>
|
: stationData.find((station: any) => station.id.toString() === apiStore.context?.endStopId);
|
||||||
station.id.toString() ===
|
|
||||||
String(
|
|
||||||
orderedRouteStations[orderedRouteStations.length - 1].id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: stationData.find(
|
|
||||||
(station: any) =>
|
|
||||||
station.id.toString() === apiStore.context?.endStopId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const terminalStations: number[] = [];
|
const terminalStations: number[] = [];
|
||||||
|
|
||||||
@@ -1861,13 +1766,7 @@ export const WebGLMap = observer(() => {
|
|||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
})();
|
})();
|
||||||
return (
|
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
||||||
tramSegIndex !== -1 &&
|
|
||||||
seg !== -1 &&
|
|
||||||
(simulationDirection === 1
|
|
||||||
? seg < tramSegIndex
|
|
||||||
: seg > tramSegIndex)
|
|
||||||
);
|
|
||||||
})()
|
})()
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
@@ -1900,13 +1799,7 @@ export const WebGLMap = observer(() => {
|
|||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
})();
|
})();
|
||||||
return (
|
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
||||||
tramSegIndex !== -1 &&
|
|
||||||
seg !== -1 &&
|
|
||||||
(simulationDirection === 1
|
|
||||||
? seg < tramSegIndex
|
|
||||||
: seg > tramSegIndex)
|
|
||||||
);
|
|
||||||
})()
|
})()
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
@@ -1932,24 +1825,11 @@ export const WebGLMap = observer(() => {
|
|||||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||||
|
|
||||||
if (startStationData && endStationData) {
|
if (startStationData && endStationData) {
|
||||||
const startIsPassed =
|
const startIsPassed = simulationDirection === 1 ? true : isStartPassed;
|
||||||
simulationDirection === 1 ? true : isStartPassed;
|
|
||||||
const endIsPassed = simulationDirection === -1 ? true : isEndPassed;
|
const endIsPassed = simulationDirection === -1 ? true : isEndPassed;
|
||||||
gl.uniform4f(
|
gl.uniform4f(u_color_pts, startIsPassed ? r_passed : r_unpassed, startIsPassed ? g_passed : g_unpassed, startIsPassed ? b_passed : b_unpassed, 1.0);
|
||||||
u_color_pts,
|
|
||||||
startIsPassed ? r_passed : r_unpassed,
|
|
||||||
startIsPassed ? g_passed : g_unpassed,
|
|
||||||
startIsPassed ? b_passed : b_unpassed,
|
|
||||||
1.0,
|
|
||||||
);
|
|
||||||
gl.drawArrays(gl.POINTS, 0, 1);
|
gl.drawArrays(gl.POINTS, 0, 1);
|
||||||
gl.uniform4f(
|
gl.uniform4f(u_color_pts, endIsPassed ? r_passed : r_unpassed, endIsPassed ? g_passed : g_unpassed, endIsPassed ? b_passed : b_unpassed, 1.0);
|
||||||
u_color_pts,
|
|
||||||
endIsPassed ? r_passed : r_unpassed,
|
|
||||||
endIsPassed ? g_passed : g_unpassed,
|
|
||||||
endIsPassed ? b_passed : b_unpassed,
|
|
||||||
1.0,
|
|
||||||
);
|
|
||||||
gl.drawArrays(gl.POINTS, 1, 1);
|
gl.drawArrays(gl.POINTS, 1, 1);
|
||||||
} else {
|
} else {
|
||||||
const isStartStation = startStationData !== undefined;
|
const isStartStation = startStationData !== undefined;
|
||||||
@@ -2573,8 +2453,14 @@ export const WebGLMap = observer(() => {
|
|||||||
const rx = cluster.longitude;
|
const rx = cluster.longitude;
|
||||||
const ry = cluster.latitude;
|
const ry = cluster.latitude;
|
||||||
|
|
||||||
|
const iconSizePercent = resolveSightIconSizePercent();
|
||||||
|
const iconSize =
|
||||||
|
SIGHT_ICON_BASE_SIZE * clamp(iconSizePercent / 100, 0.1, 10);
|
||||||
|
|
||||||
const screenX = (rx * scale + position.x) / dpr;
|
const screenX = (rx * scale + position.x) / dpr;
|
||||||
const screenY = (ry * scale + position.y) / dpr;
|
const screenY = (ry * scale + position.y) / dpr;
|
||||||
|
const iconLeft = screenX - iconSize / 2;
|
||||||
|
const iconTop = screenY - iconSize / 2;
|
||||||
|
|
||||||
const isExpanded = activeClusterId === cluster.id;
|
const isExpanded = activeClusterId === cluster.id;
|
||||||
const selectedSightInCluster = cluster.sights.find(
|
const selectedSightInCluster = cluster.sights.find(
|
||||||
@@ -2586,20 +2472,6 @@ export const WebGLMap = observer(() => {
|
|||||||
const selectedIsCustomInCluster =
|
const selectedIsCustomInCluster =
|
||||||
selectedSightInCluster?.is_default_icon === false &&
|
selectedSightInCluster?.is_default_icon === false &&
|
||||||
selectedHasIconInCluster;
|
selectedHasIconInCluster;
|
||||||
|
|
||||||
const iconSizePercent = resolveSightIconSizePercent(
|
|
||||||
cluster.sights[0],
|
|
||||||
);
|
|
||||||
const clusterCustomScaleFactor = selectedIsCustomInCluster
|
|
||||||
? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
|
|
||||||
: 1;
|
|
||||||
const iconSize =
|
|
||||||
SIGHT_ICON_BASE_SIZE *
|
|
||||||
clamp(iconSizePercent / 100, 0.1, 10) *
|
|
||||||
clusterCustomScaleFactor;
|
|
||||||
|
|
||||||
const iconLeft = screenX - iconSize;
|
|
||||||
const iconTop = screenY - iconSize;
|
|
||||||
const hasSelectedAltIconInCluster =
|
const hasSelectedAltIconInCluster =
|
||||||
selectedSightInCluster != null &&
|
selectedSightInCluster != null &&
|
||||||
selectedIsCustomInCluster &&
|
selectedIsCustomInCluster &&
|
||||||
@@ -2657,7 +2529,7 @@ export const WebGLMap = observer(() => {
|
|||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const badgeColor = "var(--carrier-main, #006F3A)";
|
const badgeColor = "#006F3A";
|
||||||
const listPanelWidth = 200;
|
const listPanelWidth = 200;
|
||||||
const listItemHeight = 30;
|
const listItemHeight = 30;
|
||||||
const listMaxHeight = 250;
|
const listMaxHeight = 250;
|
||||||
@@ -2673,10 +2545,13 @@ export const WebGLMap = observer(() => {
|
|||||||
onTouchEnd={handleClusterClick}
|
onTouchEnd={handleClusterClick}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: iconLeft,
|
left: iconLeft - CLUSTER_RADIUS_BASE - 10,
|
||||||
top: iconTop,
|
top: iconTop - CLUSTER_RADIUS_BASE - 10,
|
||||||
width: iconSize,
|
width: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
|
||||||
height: iconSize,
|
height: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
@@ -2689,22 +2564,15 @@ export const WebGLMap = observer(() => {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ position: "relative" }}>
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: iconSize,
|
|
||||||
height: iconSize,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={clusterIconUrl}
|
src={clusterIconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
filter: hasSelectedSight
|
filter: hasSelectedSight
|
||||||
? hasSelectedAltIconInCluster
|
? hasSelectedAltIconInCluster
|
||||||
? "none"
|
? "none"
|
||||||
@@ -2717,7 +2585,6 @@ export const WebGLMap = observer(() => {
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: -6,
|
top: -6,
|
||||||
right: -6,
|
right: -6,
|
||||||
zIndex: 1,
|
|
||||||
width: 15,
|
width: 15,
|
||||||
height: 15,
|
height: 15,
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
@@ -2740,11 +2607,12 @@ export const WebGLMap = observer(() => {
|
|||||||
<div
|
<div
|
||||||
data-expanded-cluster={cluster.id}
|
data-expanded-cluster={cluster.id}
|
||||||
onTouchStart={handleCircleInteraction}
|
onTouchStart={handleCircleInteraction}
|
||||||
|
onTouchMove={handleCircleInteraction}
|
||||||
onMouseMove={handleCircleInteraction}
|
onMouseMove={handleCircleInteraction}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: screenX - iconSize,
|
left: screenX - iconSize / 2,
|
||||||
top: screenY - iconSize,
|
top: screenY - iconSize / 2,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
@@ -2805,14 +2673,15 @@ export const WebGLMap = observer(() => {
|
|||||||
<div
|
<div
|
||||||
className="cluster-sights-list"
|
className="cluster-sights-list"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4)`,
|
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(0, 111, 58, 0.4)`,
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
width: listPanelWidth,
|
width: listPanelWidth,
|
||||||
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
|
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"inset 0 0 0 1px rgba(255, 255, 255, 0.3), inset 4px 4px 12px 0 rgba(255, 255, 255, 0.12)",
|
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -2955,6 +2824,7 @@ export const WebGLMap = observer(() => {
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
|
title={sightName}
|
||||||
>
|
>
|
||||||
{sightName}
|
{sightName}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { useRef, useEffect } from "react";
|
|
||||||
|
|
||||||
const Collapsible = ({ open, className = "", children }) => {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isFirst = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
if (isFirst.current) {
|
|
||||||
isFirst.current = false;
|
|
||||||
el.style.height = open ? "auto" : "0px";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
el.style.height = `${el.scrollHeight}px`;
|
|
||||||
const onEnd = (e) => {
|
|
||||||
if (e.target !== el || e.propertyName !== "height") return;
|
|
||||||
el.style.height = "auto";
|
|
||||||
el.removeEventListener("transitionend", onEnd);
|
|
||||||
};
|
|
||||||
el.addEventListener("transitionend", onEnd);
|
|
||||||
} else {
|
|
||||||
el.style.height = `${el.scrollHeight}px`;
|
|
||||||
void el.offsetHeight;
|
|
||||||
el.style.height = "0px";
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={`${className} ${open ? "open" : ""}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Collapsible;
|
|
||||||
@@ -5,8 +5,6 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import "../../styles/LeftWidget.css";
|
import "../../styles/LeftWidget.css";
|
||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { apiBaseURL } from "../../api/apiConfig";
|
import { apiBaseURL } from "../../api/apiConfig";
|
||||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
|
||||||
|
|
||||||
const LeftWidget = observer(
|
const LeftWidget = observer(
|
||||||
({ selectedSightId, onClose, isVisible, sightTop }) => {
|
({ selectedSightId, onClose, isVisible, sightTop }) => {
|
||||||
@@ -17,7 +15,8 @@ const LeftWidget = observer(
|
|||||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||||
const [widgetHeight, setWidgetHeight] = useState(0);
|
const [widgetHeight, setWidgetHeight] = useState(0);
|
||||||
|
|
||||||
const layoutRef = useRef(null);
|
const textRef = useRef(null);
|
||||||
|
const activeTouch = useRef(null);
|
||||||
const widgetRef = useRef(null);
|
const widgetRef = useRef(null);
|
||||||
|
|
||||||
const store = useGeolocationStore();
|
const store = useGeolocationStore();
|
||||||
@@ -38,10 +37,64 @@ const LeftWidget = observer(
|
|||||||
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
|
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
const scrollContainer = textRef.current;
|
||||||
const scrollable = layoutRef.current.querySelector(".scrollable");
|
if (!scrollContainer) return;
|
||||||
if (scrollable) scrollable.scrollTop = 0;
|
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
activeTouch.current = {
|
||||||
|
identifier: e.touches[0].identifier,
|
||||||
|
lastY: e.touches[0].clientY,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeTouch.current) {
|
||||||
|
for (const touch of e.changedTouches) {
|
||||||
|
if (touch.identifier === activeTouch.current.identifier) {
|
||||||
|
const deltaY = touch.clientY - activeTouch.current.lastY;
|
||||||
|
scrollContainer.scrollTop -= deltaY;
|
||||||
|
activeTouch.current.lastY = touch.clientY;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e) => {
|
||||||
|
for (const touch of e.changedTouches) {
|
||||||
|
if (
|
||||||
|
activeTouch.current &&
|
||||||
|
touch.identifier === activeTouch.current.identifier
|
||||||
|
) {
|
||||||
|
activeTouch.current = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollContainer.addEventListener("touchstart", handleTouchStart, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
scrollContainer.addEventListener("touchmove", handleTouchMove, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
scrollContainer.addEventListener("touchend", handleTouchEnd, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
scrollContainer.addEventListener("touchcancel", handleTouchEnd, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer.removeEventListener("touchstart", handleTouchStart);
|
||||||
|
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||||
|
};
|
||||||
}, [selectedSightData]);
|
}, [selectedSightData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,8 +123,6 @@ const LeftWidget = observer(
|
|||||||
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
||||||
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
||||||
|
|
||||||
if (!sight) return;
|
|
||||||
|
|
||||||
const leftArticle = sight.left_article;
|
const leftArticle = sight.left_article;
|
||||||
|
|
||||||
const leftArticleData =
|
const leftArticleData =
|
||||||
@@ -81,17 +132,15 @@ const LeftWidget = observer(
|
|||||||
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
||||||
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
||||||
|
|
||||||
if (!leftArticleData?.media?.length) return;
|
|
||||||
|
|
||||||
const media = await ContentAPI.getMediaPreview(
|
const media = await ContentAPI.getMediaPreview(
|
||||||
leftArticleData.media[0].id,
|
leftArticleData.media[0].id,
|
||||||
selectedLanguage,
|
selectedLanguage
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
mediaPath: media.path,
|
mediaPath: media.path,
|
||||||
mediaType: media.type,
|
mediaType: media.type,
|
||||||
title: leftArticleData.heading,
|
title: sight.short_name || sight.name || leftArticleData.heading,
|
||||||
text: leftArticleData.body,
|
text: leftArticleData.body,
|
||||||
address: sight.address,
|
address: sight.address,
|
||||||
};
|
};
|
||||||
@@ -129,7 +178,7 @@ const LeftWidget = observer(
|
|||||||
setIsImageLoaded(false);
|
setIsImageLoaded(false);
|
||||||
console.error(
|
console.error(
|
||||||
"Ошибка загрузки изображения для достопримечательности:",
|
"Ошибка загрузки изображения для достопримечательности:",
|
||||||
selectedSightId,
|
selectedSightId
|
||||||
);
|
);
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -159,7 +208,7 @@ const LeftWidget = observer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget" lang={selectedLanguage}>
|
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div>Загрузка информации...</div>
|
<div>Загрузка информации...</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -185,11 +234,9 @@ const LeftWidget = observer(
|
|||||||
<div className="left-widget-address">
|
<div className="left-widget-address">
|
||||||
{selectedSightData.address}
|
{selectedSightData.address}
|
||||||
</div>
|
</div>
|
||||||
<TouchableLayout ref={layoutRef} className="left-widget-text-scroll">
|
<div ref={textRef} className="left-widget-text">
|
||||||
<div className="left-widget-text">
|
{selectedSightData.text}
|
||||||
<ReactMarkdownComponent value={selectedSightData.text} />
|
|
||||||
</div>
|
</div>
|
||||||
</TouchableLayout>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (isVisible || selectedSightData) && !isLoading ? (
|
) : (isVisible || selectedSightData) && !isLoading ? (
|
||||||
@@ -203,7 +250,7 @@ const LeftWidget = observer(
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default LeftWidget;
|
export default LeftWidget;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import "../../styles/SideMenu.css";
|
|||||||
import AppealWidget from "../widgets/AppealWidget";
|
import AppealWidget from "../widgets/AppealWidget";
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import gouvermentImage from "../../assets/images/test-image.png";
|
||||||
import sideMenuPhoto from "/side-menu-photo.png";
|
import sideMenuPhoto from "/side-menu-photo.png";
|
||||||
import RouteWidget from "../widgets/RouteWidget";
|
import RouteWidget from "../widgets/RouteWidget";
|
||||||
import WeatherWidget from "../WeatherWidget";
|
|
||||||
import ContentAPI from "../../api/content/content.api";
|
import ContentAPI from "../../api/content/content.api";
|
||||||
import { useGeolocationStore, useColorStore } from "../../stores";
|
import { useGeolocationStore, useColorStore } from "../../stores";
|
||||||
import "../../styles/LeftWidget.css";
|
import "../../styles/LeftWidget.css";
|
||||||
@@ -17,6 +17,7 @@ import { getMediaUrl } from "../../api/apiConfig";
|
|||||||
const SideMenu = observer(({ onMenuToggle }) => {
|
const SideMenu = observer(({ onMenuToggle }) => {
|
||||||
const {
|
const {
|
||||||
carrier,
|
carrier,
|
||||||
|
city,
|
||||||
route,
|
route,
|
||||||
getArticle,
|
getArticle,
|
||||||
sightArticles,
|
sightArticles,
|
||||||
@@ -31,7 +32,6 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
const [isStationOpen, openStation] = useState(false);
|
const [isStationOpen, openStation] = useState(false);
|
||||||
const [isWidgetOpen, openWidget] = useState(false);
|
const [isWidgetOpen, openWidget] = useState(false);
|
||||||
const [isWeatherVisible, setIsWeatherVisible] = useState(true);
|
const [isWeatherVisible, setIsWeatherVisible] = useState(true);
|
||||||
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
|
|
||||||
const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false);
|
const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false);
|
||||||
const [localSelectedSightId, setLocalSelectedSightId] = useState(null);
|
const [localSelectedSightId, setLocalSelectedSightId] = useState(null);
|
||||||
const [isSightsAnimating, setIsSightsAnimating] = useState(false);
|
const [isSightsAnimating, setIsSightsAnimating] = useState(false);
|
||||||
@@ -85,7 +85,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
let scrollTarget = null;
|
let scrollTarget = null;
|
||||||
const targetElement = document.elementFromPoint(
|
const targetElement = document.elementFromPoint(
|
||||||
touch.clientX,
|
touch.clientX,
|
||||||
touch.clientY,
|
touch.clientY
|
||||||
);
|
);
|
||||||
|
|
||||||
// Определяем, над каким из скролл-контейнеров находится палец
|
// Определяем, над каким из скролл-контейнеров находится палец
|
||||||
@@ -185,12 +185,27 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}
|
}
|
||||||
}, [isLeftWidgetVisible]);
|
}, [isLeftWidgetVisible]);
|
||||||
|
|
||||||
|
const [designData, setDesignData] = useState(null);
|
||||||
const [routeData, setRouteData] = useState(null);
|
const [routeData, setRouteData] = useState(null);
|
||||||
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
|
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsPreviewMediaError(false);
|
async function fetchCarrierAndCrest() {
|
||||||
}, [carrier?.preview_media]);
|
try {
|
||||||
|
const carrierPath = carrier.logo ? getMediaUrl(carrier.logo) : null;
|
||||||
|
const cityPath = city.arms ? getMediaUrl(city.arms) : null;
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
carrierPath: carrierPath,
|
||||||
|
creastPath: cityPath,
|
||||||
|
};
|
||||||
|
setDesignData(response);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Ошибка в получении данных герба и перевозчика:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCarrierAndCrest();
|
||||||
|
}, [contextData?.routeId, isGeolocationLoading, selectedLanguage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isGeolocationLoading || !contextData?.routeId) {
|
if (isGeolocationLoading || !contextData?.routeId) {
|
||||||
@@ -248,19 +263,15 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMenuToggleRef = useRef(handleMenuToggle);
|
|
||||||
handleMenuToggleRef.current = handleMenuToggle;
|
|
||||||
|
|
||||||
const isMenuOpenRef = useRef(isMenuOpen);
|
|
||||||
isMenuOpenRef.current = isMenuOpen;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Автоматическое закрытие сайд-меню после 45 секунд бездействия
|
||||||
let idleSeconds = 0;
|
let idleSeconds = 0;
|
||||||
|
|
||||||
const checkIdle = () => {
|
const checkIdle = () => {
|
||||||
idleSeconds += 1;
|
idleSeconds += 1;
|
||||||
if (idleSeconds >= 60 && isMenuOpenRef.current) {
|
|
||||||
handleMenuToggleRef.current(false);
|
if (idleSeconds >= 45 && isMenuOpen) {
|
||||||
|
handleMenuToggle(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,48 +300,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
window.removeEventListener(event, resetIdle);
|
window.removeEventListener(event, resetIdle);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isMenuOpen, handleMenuToggle]);
|
||||||
|
|
||||||
// Автоматическое скрытие левого виджета через 60 секунд бездействия
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLeftWidgetVisible) return;
|
|
||||||
|
|
||||||
let idleSeconds = 0;
|
|
||||||
|
|
||||||
const checkIdle = () => {
|
|
||||||
idleSeconds += 1;
|
|
||||||
if (idleSeconds >= 60) {
|
|
||||||
setIsLeftWidgetVisible(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLeftWidgetOpen(false);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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));
|
|
||||||
};
|
|
||||||
}, [isLeftWidgetVisible, setIsLeftWidgetOpen]);
|
|
||||||
|
|
||||||
// Закрываем и открываем список достопримечательностей при изменении сортировки
|
// Закрываем и открываем список достопримечательностей при изменении сортировки
|
||||||
const prevSortingByRef = useRef(sortingBy);
|
const prevSortingByRef = useRef(sortingBy);
|
||||||
@@ -387,7 +357,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
background:
|
background:
|
||||||
isSightsOpen || isStationOpen
|
isSightsOpen || isStationOpen
|
||||||
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-left-rgb, 76, 175, 75), 0.4)`
|
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(76, 175, 75, 0.4)`
|
||||||
: undefined,
|
: undefined,
|
||||||
backdropFilter:
|
backdropFilter:
|
||||||
isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
|
isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
|
||||||
@@ -399,6 +369,16 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
|
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{designData?.creastPath && (
|
||||||
|
<img
|
||||||
|
className="side-menu-crest"
|
||||||
|
src={designData?.creastPath}
|
||||||
|
alt="Герб"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{carrier?.slogan && (
|
||||||
|
<div className="side-menu-label">{carrier.slogan}</div>
|
||||||
|
)}
|
||||||
{shouldRenderSights && (
|
{shouldRenderSights && (
|
||||||
<SightsList
|
<SightsList
|
||||||
ref={sightsListRef}
|
ref={sightsListRef}
|
||||||
@@ -464,16 +444,14 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}}
|
}}
|
||||||
className="appeal-button"
|
className="appeal-button"
|
||||||
>
|
>
|
||||||
{route?.button_text
|
{selectedLanguage == "ru"
|
||||||
? route.button_text
|
|
||||||
: selectedLanguage == "ru"
|
|
||||||
? "Обращение губернатора"
|
? "Обращение губернатора"
|
||||||
: selectedLanguage == "zh"
|
: selectedLanguage == "zh"
|
||||||
? "州长致辞"
|
? "州长致辞"
|
||||||
: "Governor's appeal"}
|
: "Governor's appeal"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="side-menu-buttons" style={{ marginTop: route?.governor_appeal > 0 ? '40px' : '260px' }}>
|
<div className="side-menu-buttons">
|
||||||
<div
|
<div
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
if (!isSightsOpen) {
|
if (!isSightsOpen) {
|
||||||
@@ -582,48 +560,30 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
: "#EveryoneOnTheWay"} */}
|
: "#EveryoneOnTheWay"} */}
|
||||||
</div>
|
</div>
|
||||||
<div className="side-menu-bottom-section">
|
<div className="side-menu-bottom-section">
|
||||||
|
<div className="side-menu-carrier-block">
|
||||||
|
{designData?.carrierPath && (
|
||||||
<img
|
<img
|
||||||
className="side-menu-bottom-photo"
|
className="carrier-logo"
|
||||||
src={
|
src={designData?.carrierPath}
|
||||||
carrier?.preview_media && !isPreviewMediaError
|
alt="ГЭТ - Электротранспорт Санкт-Петербурга"
|
||||||
? getMediaUrl(carrier.preview_media)
|
|
||||||
: sideMenuPhoto
|
|
||||||
}
|
|
||||||
onError={() => setIsPreviewMediaError(true)}
|
|
||||||
alt=""
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{carrier?.full_name && (
|
||||||
|
<div className="side-menu-slogan">{carrier.short_name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<img className="side-menu-bottom-photo" src={sideMenuPhoto} alt="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="side-menu-control-panel">
|
<div className="side-menu-control-panel">
|
||||||
<RouteWidget />
|
<RouteWidget />
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform:
|
|
||||||
isWeatherVisible && !isGovernorWidgetOpen
|
|
||||||
? "translateX(0)"
|
|
||||||
: "translateX(-200%)",
|
|
||||||
transition: "transform 1s ease",
|
|
||||||
opacity: isWeatherVisible && !isGovernorWidgetOpen ? 1 : 0,
|
|
||||||
pointerEvents:
|
|
||||||
isWeatherVisible && !isGovernorWidgetOpen ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WeatherWidget />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppealWidget
|
<AppealWidget
|
||||||
widgetImgPath={(() => {
|
widgetImgPath={gouvermentImage}
|
||||||
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
|
|
||||||
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
|
|
||||||
return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined;
|
|
||||||
})()}
|
|
||||||
isOpen={isWidgetOpen}
|
|
||||||
style={{
|
style={{
|
||||||
transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)",
|
transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)",
|
||||||
transition: "transform 0.5s ease",
|
transition: "transform 0.5s ease",
|
||||||
zIndex: -1,
|
zIndex: -1,
|
||||||
pointerEvents: isWidgetOpen ? "auto" : "none",
|
|
||||||
}}
|
}}
|
||||||
widgetLabel={
|
widgetLabel={
|
||||||
selectedLanguage == "ru"
|
selectedLanguage == "ru"
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const StationItem = ({
|
|||||||
className="side-menu-sight"
|
className="side-menu-sight"
|
||||||
onPointerDown={(e) => handlePointerDown(e, station.id)}
|
onPointerDown={(e) => handlePointerDown(e, station.id)}
|
||||||
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
|
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
|
||||||
|
title={station.name}
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{station.name}
|
{station.name}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
import Collapsible from "./Collapsible";
|
|
||||||
import { getMediaUrl } from "../../api/apiConfig";
|
import { getMediaUrl } from "../../api/apiConfig";
|
||||||
import stationIcon from "../../assets/transport-icons/station.svg";
|
import stationIcon from "../../assets/transport-icons/station.svg";
|
||||||
|
|
||||||
@@ -39,8 +38,25 @@ const SightItem = ({
|
|||||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Получаем название для отображения
|
||||||
const getSightName = () => {
|
const getSightName = () => {
|
||||||
return sight.short_name || sight.name;
|
// Если есть короткое название, используем его
|
||||||
|
if (sight.short_name) {
|
||||||
|
return sight.short_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sight.left_article) {
|
||||||
|
return sight.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftArticleData =
|
||||||
|
selectedLanguage === "ru"
|
||||||
|
? sightArticles.get(sight.left_article + "_ru")
|
||||||
|
: selectedLanguage === "en"
|
||||||
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
|
return leftArticleData?.heading || sight.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sightName = getSightName();
|
const sightName = getSightName();
|
||||||
@@ -79,48 +95,28 @@ const SightItem = ({
|
|||||||
const cacheKey = `${sight.id}_${selectedLanguage}`;
|
const cacheKey = `${sight.id}_${selectedLanguage}`;
|
||||||
const stations = sightStationsCache.get(cacheKey) || [];
|
const stations = sightStationsCache.get(cacheKey) || [];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (localSelectedSightId !== sight.id) {
|
|
||||||
setIsExpanded(false);
|
|
||||||
}
|
|
||||||
}, [localSelectedSightId, sight.id]);
|
|
||||||
|
|
||||||
// Логирование при открытии списка станций для достопримечательности (только один раз)
|
|
||||||
const wasExpandedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
// Выводим логи только когда список открывается (переход из закрытого в открытое состояние)
|
|
||||||
if (isExpanded && !wasExpandedRef.current && stations.length > 0) {
|
|
||||||
wasExpandedRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сбрасываем флаг когда список закрывается
|
|
||||||
if (!isExpanded) {
|
|
||||||
wasExpandedRef.current = false;
|
|
||||||
}
|
|
||||||
}, [isExpanded, sight.id, selectedLanguage, stations.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className={`side-menu-sight-wrapper ${
|
|
||||||
localSelectedSightId === sight.id ? "side-menu-sight-selected-wrapper" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
id={`sight-${sight.id}`}
|
id={`sight-${sight.id}`}
|
||||||
onPointerDown={(e) => handlePointerDown(e, sight.id)}
|
onPointerDown={(e) => handlePointerDown(e, sight.id)}
|
||||||
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
|
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
|
||||||
className="side-menu-sight pointer"
|
className={`side-menu-sight pointer ${
|
||||||
|
localSelectedSightId === sight.id ? "selected" : ""
|
||||||
|
}`}
|
||||||
|
title={sightName}
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{sightName}
|
{sightName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Collapsible className="side-menu-sight-transfer-list" open={isExpanded}>
|
<div
|
||||||
|
className={`side-menu-sight-transfer-list ${isExpanded ? "open" : ""}`}
|
||||||
|
>
|
||||||
{stations.length > 0 ? (
|
{stations.length > 0 ? (
|
||||||
stations.map((station, index) => {
|
stations.map((station, index) => {
|
||||||
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
|
const iconSrc = isMediaIdEmpty(station.icon)
|
||||||
const iconSrc = isMediaIdEmptyResult
|
|
||||||
? stationIcon
|
? stationIcon
|
||||||
: getMediaUrl(station.icon);
|
: getMediaUrl(station.icon);
|
||||||
|
|
||||||
@@ -161,7 +157,7 @@ const SightItem = ({
|
|||||||
: "No stations"}
|
: "No stations"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Collapsible>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -237,8 +233,24 @@ const SightsList = observer(
|
|||||||
: selectedLanguage === "en"
|
: selectedLanguage === "en"
|
||||||
? routeSightsEn
|
? routeSightsEn
|
||||||
: routeSightsZh;
|
: routeSightsZh;
|
||||||
|
// Функция для получения названия для сортировки
|
||||||
const getSightNameForSort = (sight) => {
|
const getSightNameForSort = (sight) => {
|
||||||
return (sight.short_name || sight.name).trim();
|
if (sight.short_name) {
|
||||||
|
return sight.short_name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sight.left_article) {
|
||||||
|
return sight.name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftArticleData =
|
||||||
|
selectedLanguage === "ru"
|
||||||
|
? sightArticles.get(sight.left_article + "_ru")
|
||||||
|
: selectedLanguage === "en"
|
||||||
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
|
return (leftArticleData?.heading || sight.name).trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedSights = [...(sights || [])].sort((a, b) => {
|
const sortedSights = [...(sights || [])].sort((a, b) => {
|
||||||
@@ -337,18 +349,25 @@ const SightsList = observer(
|
|||||||
}, [localSelectedSightId, sightList, className]);
|
}, [localSelectedSightId, sightList, className]);
|
||||||
|
|
||||||
const handleSightClick = (sightId) => {
|
const handleSightClick = (sightId) => {
|
||||||
// Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет
|
|
||||||
if (isLeftWidgetOpen && localSelectedSightId === sightId) {
|
|
||||||
setLocalSelectedSightId(null);
|
|
||||||
setIsLeftWidgetOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Открываем левый виджет
|
// Открываем левый виджет
|
||||||
|
if (isLeftWidgetOpen && localSelectedSightId !== sightId) {
|
||||||
setLocalSelectedSightId(sightId);
|
setLocalSelectedSightId(sightId);
|
||||||
setIsLeftWidgetOpen(true);
|
setIsLeftWidgetOpen(true);
|
||||||
if (onSightSelected) {
|
if (onSightSelected) {
|
||||||
onSightSelected(sightId);
|
onSightSelected(sightId);
|
||||||
}
|
}
|
||||||
|
} else if (!isLeftWidgetOpen) {
|
||||||
|
setLocalSelectedSightId(sightId);
|
||||||
|
setIsLeftWidgetOpen(true);
|
||||||
|
if (onSightSelected) {
|
||||||
|
onSightSelected(sightId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок
|
||||||
|
if (onSightSelected) {
|
||||||
|
onSightSelected(sightId);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ const SightItem = ({
|
|||||||
const textRef = useRef(null);
|
const textRef = useRef(null);
|
||||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||||
|
|
||||||
// Получаем название из left_article или используем short_name/name
|
// Получаем название из left_article
|
||||||
const getSightName = () => {
|
const getSightName = () => {
|
||||||
if (!sight.left_article) {
|
if (!sight.left_article) {
|
||||||
return sight.short_name || sight.name;
|
return sight.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftArticleData =
|
const leftArticleData =
|
||||||
@@ -41,7 +41,7 @@ const SightItem = ({
|
|||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
return leftArticleData?.heading || sight.short_name || sight.name;
|
return leftArticleData?.heading || sight.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sightName = getSightName();
|
const sightName = getSightName();
|
||||||
@@ -70,6 +70,7 @@ const SightItem = ({
|
|||||||
className={`side-menu-sight pointer ${
|
className={`side-menu-sight pointer ${
|
||||||
localSelectedSightId === sight.id ? "selected" : ""
|
localSelectedSightId === sight.id ? "selected" : ""
|
||||||
}`}
|
}`}
|
||||||
|
title={sightName}
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{sightName}
|
{sightName}
|
||||||
@@ -119,9 +120,10 @@ const StationSightsList = observer(
|
|||||||
selectedLanguage
|
selectedLanguage
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Функция для получения названия из left_article
|
||||||
const getSightNameForSort = (sight) => {
|
const getSightNameForSort = (sight) => {
|
||||||
if (!sight.left_article) {
|
if (!sight.left_article) {
|
||||||
return (sight.short_name || sight.name).trim();
|
return sight.name.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftArticleData =
|
const leftArticleData =
|
||||||
@@ -131,7 +133,7 @@ const StationSightsList = observer(
|
|||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
return (leftArticleData?.heading || sight.short_name || sight.name).trim();
|
return (leftArticleData?.heading || sight.name).trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedSights = [...(response || [])].sort((a, b) => {
|
const sortedSights = [...(response || [])].sort((a, b) => {
|
||||||
@@ -309,4 +311,3 @@ const StationSightsList = observer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default StationSightsList;
|
export default StationSightsList;
|
||||||
nSightsList;
|
|
||||||
|
|||||||
@@ -10,43 +10,6 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
import Collapsible from "./Collapsible";
|
|
||||||
|
|
||||||
const SightTransferItem = ({ name, style, onPointerUp }) => {
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const textRef = useRef(null);
|
|
||||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const checkWidth = () => {
|
|
||||||
if (containerRef.current && textRef.current) {
|
|
||||||
const containerWidth = containerRef.current.offsetWidth;
|
|
||||||
const textWidth = textRef.current.scrollWidth;
|
|
||||||
const shouldAnimateValue = textWidth > containerWidth;
|
|
||||||
setShouldAnimate(shouldAnimateValue);
|
|
||||||
if (shouldAnimateValue) {
|
|
||||||
containerRef.current.style.setProperty("--container-width", `${containerWidth}px`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkWidth();
|
|
||||||
window.addEventListener("resize", checkWidth);
|
|
||||||
return () => window.removeEventListener("resize", checkWidth);
|
|
||||||
}, [name]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="side-menu-sight-transfer pointer"
|
|
||||||
style={style}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
>
|
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StationItem = ({
|
const StationItem = ({
|
||||||
station,
|
station,
|
||||||
@@ -93,15 +56,26 @@ const StationItem = ({
|
|||||||
const sights = stationSightsCache.get(cacheKey) || [];
|
const sights = stationSightsCache.get(cacheKey) || [];
|
||||||
|
|
||||||
const getSightName = (sight) => {
|
const getSightName = (sight) => {
|
||||||
return sight.short_name || sight.name;
|
if (sight.short_name) {
|
||||||
|
return sight.short_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sight.left_article) {
|
||||||
|
return sight.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftArticleData =
|
||||||
|
selectedLanguage === "ru"
|
||||||
|
? sightArticles.get(sight.left_article + "_ru")
|
||||||
|
: selectedLanguage === "en"
|
||||||
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
|
return leftArticleData?.heading || sight.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className={`side-menu-sight-wrapper ${
|
|
||||||
selectedStationId === station.id ? "side-menu-sight-selected-wrapper" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="side-menu-sight"
|
className="side-menu-sight"
|
||||||
@@ -114,20 +88,22 @@ const StationItem = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
title={station.name}
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{station.name}
|
{station.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Collapsible
|
<div
|
||||||
className="side-menu-sight-transfer-list"
|
className={`side-menu-sight-transfer-list ${
|
||||||
open={selectedStationId === station.id}
|
selectedStationId === station.id ? "open" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{sights.length > 0 ? (
|
{sights.length > 0 ? (
|
||||||
sights.map((sight, index) => (
|
sights.map((sight, index) => (
|
||||||
<SightTransferItem
|
<div
|
||||||
key={sight.id}
|
key={sight.id}
|
||||||
name={getSightName(sight)}
|
className="side-menu-sight-transfer pointer"
|
||||||
style={{
|
style={{
|
||||||
borderBottom:
|
borderBottom:
|
||||||
index < sights.length - 1
|
index < sights.length - 1
|
||||||
@@ -139,11 +115,19 @@ const StationItem = ({
|
|||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onSightClick) {
|
if (onSightClick) {
|
||||||
const elementRect = e.currentTarget.getBoundingClientRect();
|
// Вычисляем позицию элемента для правильного позиционирования левого виджета
|
||||||
onSightClick(sight.id, elementRect.top);
|
const element = e.currentTarget;
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Используем позицию элемента относительно viewport (elementRect.top)
|
||||||
|
// чтобы верхняя граница виджета совпадала с верхней границей элемента
|
||||||
|
const elementTop = elementRect.top;
|
||||||
|
onSightClick(sight.id, elementTop);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{getSightName(sight)}
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="side-menu-sight-transfer-empty">
|
<div className="side-menu-sight-transfer-empty">
|
||||||
@@ -154,7 +138,7 @@ const StationItem = ({
|
|||||||
: "No sights"}
|
: "No sights"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Collapsible>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,50 +1,13 @@
|
|||||||
import { useRef, useEffect } from "react";
|
import '../../styles/AppealWidget.css'
|
||||||
import "../../styles/AppealWidget.css";
|
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
|
||||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
|
||||||
|
|
||||||
function AppealWidget({
|
|
||||||
widgetImgPath,
|
|
||||||
widgetLabel,
|
|
||||||
widgetText,
|
|
||||||
style,
|
|
||||||
isOpen,
|
|
||||||
}) {
|
|
||||||
const stopProp = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
const layoutRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && layoutRef.current) {
|
|
||||||
const scrollable = layoutRef.current.querySelector(".scrollable");
|
|
||||||
if (scrollable) scrollable.scrollTop = 0;
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
|
function AppealWidget({widgetImgPath, widgetLabel, widgetText, style}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={style} className='dynamic-widget'>
|
||||||
style={style}
|
<img className='dynamic-widget-image' src={widgetImgPath} />
|
||||||
className="dynamic-widget"
|
<div className='dynamic-widget-label'>{widgetLabel}</div>
|
||||||
onPointerDown={stopProp}
|
<div className='dynamic-widget-text'>{widgetText}</div>
|
||||||
onPointerMove={stopProp}
|
|
||||||
onPointerUp={stopProp}
|
|
||||||
>
|
|
||||||
{widgetImgPath && (
|
|
||||||
<img className="dynamic-widget-image" src={widgetImgPath} />
|
|
||||||
)}
|
|
||||||
<div className="dynamic-widget-label">{widgetLabel}</div>
|
|
||||||
<TouchableLayout
|
|
||||||
ref={layoutRef}
|
|
||||||
className="dynamic-widget-text-scroll"
|
|
||||||
>
|
|
||||||
<div className="dynamic-widget-text">
|
|
||||||
<ReactMarkdownComponent value={widgetText} />
|
|
||||||
</div>
|
|
||||||
</TouchableLayout>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppealWidget;
|
export default AppealWidget
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { apiStore } from "../../api/ApiStore/store";
|
|||||||
const RouteWidget = observer(() => {
|
const RouteWidget = observer(() => {
|
||||||
const store = useGeolocationStore();
|
const store = useGeolocationStore();
|
||||||
const { contextData, isLoading, error, selectedLanguage } = store;
|
const { contextData, isLoading, error, selectedLanguage } = store;
|
||||||
const { routeStations, routeStationsEn, routeStationsZh, context } = apiStore;
|
const { routeStations, routeStationsEn, routeStationsZh, context, route } = apiStore;
|
||||||
const [startStation, setStartStation] = useState(null);
|
const [startStation, setStartStation] = useState(null);
|
||||||
const [startStationEn, setStartStationEn] = useState(null);
|
const [startStationEn, setStartStationEn] = useState(null);
|
||||||
const [endStation, setEndStation] = useState(null);
|
const [endStation, setEndStation] = useState(null);
|
||||||
@@ -84,14 +84,15 @@ const RouteWidget = observer(() => {
|
|||||||
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
|
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
|
||||||
return (
|
return (
|
||||||
<div className="route-widget">
|
<div className="route-widget">
|
||||||
<div className={`route-widget-number ${getNumberSizeClass(context?.routeNumber)}`}>
|
<div className={`route-widget-number ${getNumberSizeClass(route?.route_sys_number || context?.routeNumber)}`}>
|
||||||
{context?.routeNumber || "No number"}
|
{route?.route_sys_number || context?.routeNumber || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="route-widget-content">
|
<div className="route-widget-content">
|
||||||
<div
|
<div
|
||||||
className={`route-widget-label ${
|
className={`route-widget-label ${
|
||||||
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
|
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
|
||||||
} ${getLabelSizeClass(startStation?.name)}`}
|
} ${getLabelSizeClass(startStation?.name)}`}
|
||||||
|
title={startStation?.name}
|
||||||
>
|
>
|
||||||
{startStation?.name}
|
{startStation?.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -99,6 +100,7 @@ const RouteWidget = observer(() => {
|
|||||||
className={`route-widget-label ${
|
className={`route-widget-label ${
|
||||||
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
|
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
|
||||||
} ${getLabelSizeClass(endStation?.name)}`}
|
} ${getLabelSizeClass(endStation?.name)}`}
|
||||||
|
title={endStation?.name}
|
||||||
>
|
>
|
||||||
{endStation?.name}
|
{endStation?.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,6 +109,7 @@ const RouteWidget = observer(() => {
|
|||||||
className={`route-widget-subtitle ${
|
className={`route-widget-subtitle ${
|
||||||
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
|
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
|
||||||
}`}
|
}`}
|
||||||
|
title={routeEnSubtitle}
|
||||||
>
|
>
|
||||||
{routeEnSubtitle}
|
{routeEnSubtitle}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +119,7 @@ const RouteWidget = observer(() => {
|
|||||||
className={`route-widget-subtitle ${
|
className={`route-widget-subtitle ${
|
||||||
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
|
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
|
||||||
}`}
|
}`}
|
||||||
|
title={routeZhSubtitle}
|
||||||
>
|
>
|
||||||
{routeZhSubtitle}
|
{routeZhSubtitle}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
import { OrbitControls, Center, useGLTF } from "@react-three/drei";
|
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||||
import React, { useEffect, useRef, Suspense, useCallback } from "react";
|
import React, { useEffect, Suspense } from "react";
|
||||||
import { BACKGROUND_COLOR } from "../../assets/Constants";
|
import { BACKGROUND_COLOR } from "../../assets/Constants";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||||
@@ -23,7 +23,6 @@ interface ThreeViewProps {
|
|||||||
const ZOOM_FACTOR = 1.2;
|
const ZOOM_FACTOR = 1.2;
|
||||||
const MIN_DISTANCE = 1;
|
const MIN_DISTANCE = 1;
|
||||||
const MAX_DISTANCE = 100;
|
const MAX_DISTANCE = 100;
|
||||||
const CAMERA_FOV = 40;
|
|
||||||
|
|
||||||
const TouchController = () => {
|
const TouchController = () => {
|
||||||
const { camera, controls, gl } = useThree();
|
const { camera, controls, gl } = useThree();
|
||||||
@@ -198,47 +197,6 @@ const AutoResize = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FitCamera = ({
|
|
||||||
groupRef,
|
|
||||||
onReady,
|
|
||||||
}: {
|
|
||||||
groupRef: React.RefObject<THREE.Group>;
|
|
||||||
onReady: () => void;
|
|
||||||
}) => {
|
|
||||||
const { camera, controls } = useThree();
|
|
||||||
const fitted = useRef(false);
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
if (fitted.current) return;
|
|
||||||
const group = groupRef.current;
|
|
||||||
if (!group || group.children.length === 0) return;
|
|
||||||
|
|
||||||
const box = new THREE.Box3().setFromObject(group);
|
|
||||||
const sphere = new THREE.Sphere();
|
|
||||||
box.getBoundingSphere(sphere);
|
|
||||||
|
|
||||||
if (sphere.radius === 0) return;
|
|
||||||
|
|
||||||
const fov = THREE.MathUtils.degToRad(CAMERA_FOV);
|
|
||||||
const dist = sphere.radius / Math.sin(fov / 2);
|
|
||||||
|
|
||||||
camera.position.set(0, 0, dist);
|
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
|
|
||||||
if (controls) {
|
|
||||||
const orbit = controls as unknown as OrbitControlsImpl;
|
|
||||||
orbit.target.set(0, 0, 0);
|
|
||||||
orbit.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
fitted.current = true;
|
|
||||||
onReady();
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Model = ({
|
const Model = ({
|
||||||
fileUrl,
|
fileUrl,
|
||||||
onLoad,
|
onLoad,
|
||||||
@@ -275,37 +233,21 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
|||||||
onError,
|
onError,
|
||||||
controlRef,
|
controlRef,
|
||||||
}) => {
|
}) => {
|
||||||
const [isReady, setIsReady] = React.useState(false);
|
|
||||||
const groupRef = useRef<THREE.Group>(null!);
|
|
||||||
|
|
||||||
const handleReady = useCallback(() => {
|
|
||||||
setIsReady(true);
|
|
||||||
onLoad?.();
|
|
||||||
}, [onLoad]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width, height, position: "relative", overflow: "hidden" }}>
|
<div style={{ width, height, position: "relative", overflow: "hidden" }}>
|
||||||
{!isReady && (
|
|
||||||
<div style={{
|
|
||||||
position: "absolute", inset: 0,
|
|
||||||
backgroundColor: `#${BACKGROUND_COLOR.toString(16).padStart(6, "0")}`,
|
|
||||||
zIndex: 1,
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
<Canvas
|
<Canvas
|
||||||
gl={{
|
gl={{
|
||||||
antialias: true,
|
antialias: true,
|
||||||
toneMappingExposure: 1.5,
|
toneMappingExposure: 1.5,
|
||||||
outputColorSpace: THREE.SRGBColorSpace,
|
outputColorSpace: THREE.SRGBColorSpace,
|
||||||
}}
|
}}
|
||||||
camera={{ position: [0, 0, 50], fov: CAMERA_FOV }}
|
camera={{ position: [0, 0, 5], fov: 40 }}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onError={(e: any) => onError?.(e.message)}
|
onError={(e: any) => onError?.(e.message)}
|
||||||
>
|
>
|
||||||
<AutoResize />
|
<AutoResize />
|
||||||
<TouchController />
|
<TouchController />
|
||||||
{controlRef && <ZoomController controlRef={controlRef} />}
|
{controlRef && <ZoomController controlRef={controlRef} />}
|
||||||
<FitCamera groupRef={groupRef} onReady={handleReady} />
|
|
||||||
<color attach="background" args={[BACKGROUND_COLOR]} />
|
<color attach="background" args={[BACKGROUND_COLOR]} />
|
||||||
<ambientLight intensity={0.8} />
|
<ambientLight intensity={0.8} />
|
||||||
<directionalLight position={[30, 30, 30]} intensity={1.2} />
|
<directionalLight position={[30, 30, 30]} intensity={1.2} />
|
||||||
@@ -323,18 +265,23 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
|||||||
<pointLight position={[0, 30, 0]} intensity={0.6} />
|
<pointLight position={[0, 30, 0]} intensity={0.6} />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Center precise>
|
<Stage
|
||||||
<group ref={groupRef}>
|
environment={null}
|
||||||
<Model fileUrl={fileUrl} />
|
intensity={1}
|
||||||
</group>
|
castShadow={false}
|
||||||
</Center>
|
shadows={false}
|
||||||
|
adjustCamera={true}
|
||||||
|
center={{ precise: true }}
|
||||||
|
>
|
||||||
|
<Model fileUrl={fileUrl} onLoad={onLoad} />
|
||||||
|
</Stage>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
makeDefault
|
makeDefault
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
enablePan={true}
|
enablePan={true}
|
||||||
target={[0, 0, 0]}
|
target={[50, 50, 50]}
|
||||||
minDistance={1}
|
minDistance={1}
|
||||||
maxDistance={100}
|
maxDistance={100}
|
||||||
enableDamping={true}
|
enableDamping={true}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
.side-menu-sights-block {
|
.side-menu-sights-block {
|
||||||
height: calc(60%);
|
height: calc(60%);
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
margin-left: 0;
|
margin-left: 20px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-right: 5px;
|
margin-right: 5px;
|
||||||
touch-action: none; /* Отключаем стандартные действия */
|
touch-action: none; /* Отключаем стандартные действия */
|
||||||
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
|
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
import { makeAutoObservable, runInAction } from "mobx";
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
|
|
||||||
|
const COLOR_WHITE = { h: 151, s: 0, l: 100 };
|
||||||
|
const COLOR_GREEN = { h: 151, s: 100, l: 22 };
|
||||||
|
|
||||||
const TRANSITION_DURATION = 60000;
|
const TRANSITION_DURATION = 60000;
|
||||||
const TICK_INTERVAL = 100;
|
const TICK_INTERVAL = 100;
|
||||||
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION;
|
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION;
|
||||||
|
|
||||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
||||||
const clean = hex.trim().replace(/^#/, "");
|
|
||||||
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
||||||
if (full.length !== 6) return null;
|
|
||||||
return {
|
|
||||||
r: parseInt(full.slice(0, 2), 16),
|
|
||||||
g: parseInt(full.slice(2, 4), 16),
|
|
||||||
b: parseInt(full.slice(4, 6), 16),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolateRgb(
|
|
||||||
from: { r: number; g: number; b: number },
|
|
||||||
to: { r: number; g: number; b: number },
|
|
||||||
t: number
|
|
||||||
): string {
|
|
||||||
const r = Math.round(from.r + (to.r - from.r) * t);
|
|
||||||
const g = Math.round(from.g + (to.g - from.g) * t);
|
|
||||||
const b = Math.round(from.b + (to.b - from.b) * t);
|
|
||||||
return `rgb(${r}, ${g}, ${b})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WHITE = { r: 255, g: 255, b: 255 };
|
|
||||||
const DEFAULT_MAIN = { r: 0, g: 111, b: 58 };
|
|
||||||
|
|
||||||
interface ColorStore {
|
interface ColorStore {
|
||||||
currentColor: string;
|
currentColor: string;
|
||||||
setCurrentColor: (color: string) => void;
|
setCurrentColor: (color: string) => void;
|
||||||
setMainColor: (hex: string) => void;
|
|
||||||
startColorAnimation: () => void;
|
startColorAnimation: () => void;
|
||||||
stopColorAnimation: () => void;
|
stopColorAnimation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ColorStore implements ColorStore {
|
class ColorStore implements ColorStore {
|
||||||
currentColor: string = "#fff";
|
currentColor: string = "#fff";
|
||||||
private mainColor: { r: number; g: number; b: number } = DEFAULT_MAIN;
|
|
||||||
private progress: number = 0;
|
private progress: number = 0;
|
||||||
private direction: number = 1;
|
private direction: number = 1;
|
||||||
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -52,12 +28,12 @@ class ColorStore implements ColorStore {
|
|||||||
this.currentColor = color;
|
this.currentColor = color;
|
||||||
};
|
};
|
||||||
|
|
||||||
setMainColor = (hex: string) => {
|
private interpolateColor(progress: number): string {
|
||||||
const parsed = hexToRgb(hex);
|
const h = Math.round(COLOR_WHITE.h + (COLOR_GREEN.h - COLOR_WHITE.h) * progress);
|
||||||
if (parsed) {
|
const s = Math.round(COLOR_WHITE.s + (COLOR_GREEN.s - COLOR_WHITE.s) * progress);
|
||||||
this.mainColor = parsed;
|
const l = Math.round(COLOR_WHITE.l + (COLOR_GREEN.l - COLOR_WHITE.l) * progress);
|
||||||
|
return `hsl(${h}, ${s}%, ${l}%)`;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
startColorAnimation = () => {
|
startColorAnimation = () => {
|
||||||
if (this.tickInterval) return;
|
if (this.tickInterval) return;
|
||||||
@@ -74,7 +50,7 @@ class ColorStore implements ColorStore {
|
|||||||
this.direction = 1;
|
this.direction = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentColor = interpolateRgb(WHITE, this.mainColor, this.progress);
|
this.currentColor = this.interpolateColor(this.progress);
|
||||||
});
|
});
|
||||||
}, TICK_INTERVAL);
|
}, TICK_INTERVAL);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,124 +5,37 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 420px;
|
width: 420px;
|
||||||
max-height: calc(100vh - 150px - 98px);
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background:
|
background: linear-gradient(
|
||||||
linear-gradient(
|
|
||||||
114deg,
|
114deg,
|
||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-left, #006f3a);
|
#006F3A;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-widget-image {
|
.dynamic-widget-image {
|
||||||
padding-top: 2px;
|
border-radius-top-left: 10px;
|
||||||
margin-left: 2px;
|
border-radius-top-right: 10px;
|
||||||
margin-right: 2px;
|
padding-top: 4px;
|
||||||
width: 416px;
|
margin-left: 4px;
|
||||||
border-radius: 10px 10px 0 0;
|
margin-right: 4px;
|
||||||
object-fit: cover;
|
width: 412px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-widget-label {
|
.dynamic-widget-label {
|
||||||
width: 100%;
|
width: 380px;
|
||||||
padding: 10px 20px;
|
margin-top: 29px;
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #fff;
|
|
||||||
border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8));
|
|
||||||
background:
|
|
||||||
linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
|
||||||
rgba(255, 255, 255, 0) 100%
|
|
||||||
),
|
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-widget-text-scroll.scrollable-container {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
align-self: stretch;
|
|
||||||
margin: 15px 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text-scroll .scrollable-viewport {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text-scroll .scrollable {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.dynamic-widget-text {
|
.dynamic-widget-text {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
width: 380px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 135%;
|
line-height: 150%;
|
||||||
padding-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 135%;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container p {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 135%;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container h1,
|
|
||||||
.dynamic-widget-text .react-markdown-container h2,
|
|
||||||
.dynamic-widget-text .react-markdown-container h3,
|
|
||||||
.dynamic-widget-text .react-markdown-container h4,
|
|
||||||
.dynamic-widget-text .react-markdown-container h5,
|
|
||||||
.dynamic-widget-text .react-markdown-container h6 {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container ul,
|
|
||||||
.dynamic-widget-text .react-markdown-container ol {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container li {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container blockquote {
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 12px;
|
|
||||||
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container img {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container a {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-left, #006F3A);
|
#006F3A;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
@@ -67,78 +67,17 @@
|
|||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-widget-text-scroll.scrollable-container {
|
|
||||||
margin-top: 15px;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text-scroll .scrollable-viewport {
|
|
||||||
max-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text {
|
.left-widget-text {
|
||||||
|
margin-top: 15px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 135%;
|
line-height: 135%;
|
||||||
padding-right: 3px;
|
max-height: 200px; /* Пример ограничения высоты */
|
||||||
}
|
overflow-y: auto;
|
||||||
|
touch-action: none;
|
||||||
.left-widget-text .react-markdown-container {
|
overscroll-behavior: contain;
|
||||||
font-size: 16px;
|
|
||||||
line-height: 135%;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container p {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 135%;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container h1,
|
|
||||||
.left-widget-text .react-markdown-container h2,
|
|
||||||
.left-widget-text .react-markdown-container h3,
|
|
||||||
.left-widget-text .react-markdown-container h4,
|
|
||||||
.left-widget-text .react-markdown-container h5,
|
|
||||||
.left-widget-text .react-markdown-container h6 {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container ul,
|
|
||||||
.left-widget-text .react-markdown-container ol {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container li {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container blockquote {
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 12px;
|
|
||||||
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container img {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container a {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-widget-image {
|
.left-widget-image {
|
||||||
@@ -154,27 +93,26 @@
|
|||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимация для списка пересадок */
|
||||||
|
.side-menu-sight-transfer-list.entering,
|
||||||
|
.side-menu-sight-transfer-list.entered {
|
||||||
|
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
|
||||||
|
opacity: 1;
|
||||||
|
transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sight-transfer span {
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-sight-transfer span.marquee-text {
|
|
||||||
animation: side-menu-marquee 14s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-sight-transfer-list {
|
.side-menu-sight-transfer-list {
|
||||||
height: 0;
|
max-height: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition:
|
transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */
|
||||||
height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
||||||
opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Активное состояние - когда список открыт */
|
||||||
.side-menu-sight-transfer-list.open {
|
.side-menu-sight-transfer-list.open {
|
||||||
|
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,3 @@
|
|||||||
@property --fade-top {
|
|
||||||
syntax: "<length>";
|
|
||||||
inherits: false;
|
|
||||||
initial-value: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property --fade-bottom {
|
|
||||||
syntax: "<length>";
|
|
||||||
inherits: false;
|
|
||||||
initial-value: 45px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-chevron {
|
|
||||||
0% {
|
|
||||||
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron-svg {
|
|
||||||
font-size: 20px;
|
|
||||||
animation: pulse-chevron 1.2s ease-in-out infinite;
|
|
||||||
animation-play-state: paused;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron-svg.is-idle {
|
|
||||||
animation-play-state: running;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron-svg.is-open {
|
|
||||||
--r: 180deg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-widget {
|
.right-widget {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 32px;
|
right: 32px;
|
||||||
@@ -48,8 +6,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights {
|
.list-of-sights {
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
@@ -61,11 +17,11 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-right, #806c59);
|
#006f3a;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
max-height: 68px;
|
max-height: 68px;
|
||||||
transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: max-height 0.15s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +63,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
width: 128px;
|
width: 128px;
|
||||||
|
|
||||||
background-color: var(--carrier-right-dark, #005a2f);
|
background-color: #0e8953;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-title {
|
.list-of-sights-title {
|
||||||
@@ -125,7 +81,7 @@
|
|||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1) 0.1s;
|
transition: opacity 0.15s ease-in-out 0.15s;
|
||||||
|
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
@@ -134,29 +90,6 @@
|
|||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-content .scrollable {
|
|
||||||
--fade-top: 0px;
|
|
||||||
--fade-bottom: 45px;
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0px,
|
|
||||||
black var(--fade-top),
|
|
||||||
black calc(100% - var(--fade-bottom)),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
transition:
|
|
||||||
--fade-top 0.5s ease,
|
|
||||||
--fade-bottom 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-of-sights-content:not(.is-at-top) .scrollable {
|
|
||||||
--fade-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-of-sights-content.is-at-bottom .scrollable {
|
|
||||||
--fade-bottom: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-of-sights-grid {
|
.list-of-sights-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -170,12 +103,6 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-content .custom-scrollbar-track {
|
|
||||||
margin-top: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-component {
|
.sight-component {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -248,18 +175,18 @@
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
margin-bottom: 0;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame {
|
.sight-frame {
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100px;
|
bottom: 66px;
|
||||||
left: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
margin-bottom: 32px;
|
||||||
|
width: 550px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -267,14 +194,8 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-right, #806c59);
|
#006f3a;
|
||||||
max-height: calc(100vh - 128px);
|
max-height: calc(100vh - 128px);
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame.three-d-fullscreen {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-image {
|
.sight-frame-image {
|
||||||
@@ -291,11 +212,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: opacity 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-content.is-switching {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-get-back-wrapper {
|
.sight-frame-get-back-wrapper {
|
||||||
@@ -321,37 +237,15 @@
|
|||||||
rgba(255, 255, 255, 0.22) 0%,
|
rgba(255, 255, 255, 0.22) 0%,
|
||||||
rgba(255, 255, 255, 0.04) 100%
|
rgba(255, 255, 255, 0.04) 100%
|
||||||
),
|
),
|
||||||
rgba(var(--carrier-right-rgb, 128, 108, 89), 0.72);
|
rgba(0, 111, 58, 0.72);
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: white;
|
color: white;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in-text {
|
|
||||||
animation: fadeInText 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInText {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-title:not(.intro-title) {
|
|
||||||
background:
|
|
||||||
linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
|
||||||
rgba(255, 255, 255, 0) 100%
|
|
||||||
),
|
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-title p {
|
.sight-frame-title p {
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
@@ -369,40 +263,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper {
|
.sight-frame-text-wrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
max-height: calc(80vh - 354px);
|
max-height: calc(80vh - 354px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 16px 16px 76px 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper.scrollable-container {
|
.sight-frame-text-wrapper.scrollable-container {
|
||||||
padding: 16px 16px 76px 16px;
|
padding: 16px;
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-text-wrapper.is-intro {
|
|
||||||
height: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
border: none;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-loading-placeholder {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-track,
|
|
||||||
.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-thumb {
|
|
||||||
transition: opacity 0.3s ease !important;
|
|
||||||
}
|
}
|
||||||
.sight-frame-text-wrapper .scrollable-viewport {
|
.sight-frame-text-wrapper .scrollable-viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -413,12 +285,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text {
|
.sight-frame-text {
|
||||||
display: flow-root; /* BFC prevents margin collapsing for correct scrollHeight measurement */
|
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
@@ -455,57 +326,13 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 100px;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
z-index: 2;
|
|
||||||
border-radius: 0 0 10px 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu-fade {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 120px;
|
|
||||||
z-index: 100001;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu-fade.left {
|
|
||||||
left: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
border-radius: 0 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu-fade.right {
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to left,
|
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
border-radius: 0 0 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu {
|
.sight-frame-menu {
|
||||||
z-index: 100000;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 7px 60px;
|
padding: 7px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-evenly;
|
justify-content: space-around;
|
||||||
border-radius: 0px 0px 10px 10px;
|
border-radius: 0px 0px 10px 10px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.8);
|
border-top: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
background:
|
background:
|
||||||
@@ -514,22 +341,11 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
rgba(0, 111, 58, 0.4);
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-point {
|
.sight-frame-menu-point {
|
||||||
@@ -540,17 +356,16 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition:
|
transition:
|
||||||
background-color 0.1s ease,
|
background-color 0.1s ease,
|
||||||
color 0.1s ease;
|
color 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-point.active {
|
.sight-frame-menu-point.active {
|
||||||
text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff;
|
border-bottom: 2px solid #fff;
|
||||||
border-bottom-color: #fff;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper::-webkit-scrollbar-track {
|
.sight-frame-text-wrapper::-webkit-scrollbar-track {
|
||||||
@@ -575,20 +390,30 @@
|
|||||||
}
|
}
|
||||||
.sight-frame {
|
.sight-frame {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.9s ease-out;
|
transform: translateY(20px);
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease-out,
|
||||||
|
transform 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame.is-visible {
|
.sight-frame.is-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
animation: fadeInScale 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
transform: translateY(0);
|
||||||
|
animation: fadeInScale 0.6s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInScale {
|
@keyframes fadeInScale {
|
||||||
from {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
}
|
}
|
||||||
to {
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translateY(-5px) scale(1.02);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +465,6 @@
|
|||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
padding-bottom: 60px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-container {
|
.svg-container {
|
||||||
@@ -695,20 +519,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alphabet {
|
.alphabet {
|
||||||
width: 40px;
|
width: 100px;
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
margin-top: 14px;
|
padding-top: 24px;
|
||||||
margin-bottom: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
height: 676px;
|
height: 650px;
|
||||||
|
padding-bottom: 30px;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -758,9 +579,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alphabet-position {
|
.alphabet-position {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.transfer-button-container {
|
.transfer-button-container {
|
||||||
@@ -787,14 +607,14 @@
|
|||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--carrier-main, #006f3a);
|
border: 1px solid #006f3a;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
|
rgba(0, 111, 58, 0.4);
|
||||||
|
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -927,7 +747,7 @@
|
|||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
background: var(--carrier-main, #006f3a);
|
background: #006f3a;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,17 +26,15 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow:
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
|
||||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
||||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
||||||
background:
|
background: linear-gradient(
|
||||||
linear-gradient(
|
|
||||||
to bottom right,
|
to bottom right,
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(179, 165, 152, 0.4);
|
rgba(0, 111, 58, 0.4);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
z-index: 10000001;
|
z-index: 10000001;
|
||||||
@@ -52,7 +50,7 @@
|
|||||||
height: 96px;
|
height: 96px;
|
||||||
background-color: #fcd500;
|
background-color: #fcd500;
|
||||||
color: black;
|
color: black;
|
||||||
border-radius: 10px 0 0 10px;
|
border-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -13,7 +13,18 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
var(--carrier-left, #006f3a);
|
#006f3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu-label {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 20px;
|
||||||
|
align-self: flex-start;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 150%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appeal-button {
|
.appeal-button {
|
||||||
@@ -24,12 +35,11 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-top: 120px;
|
margin-top: 120px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
width: 220px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-buttons {
|
.side-menu-buttons {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
|
margin-top: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-button {
|
.side-menu-button {
|
||||||
@@ -41,6 +51,10 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-menu-button--sights {
|
||||||
|
background-color: #fcd500;
|
||||||
|
}
|
||||||
|
|
||||||
.side-menu-button--active {
|
.side-menu-button--active {
|
||||||
background-color: #fcd500;
|
background-color: #fcd500;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -55,6 +69,24 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-menu-carrier-block {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carrier-logo {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu-slogan {
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 150%;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.side-menu-bottom-photo {
|
.side-menu-bottom-photo {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
@@ -106,10 +138,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
3.33% {
|
3.33% {
|
||||||
fill: var(--carrier-left, rgb(76, 175, 75));
|
fill: rgb(76, 175, 75);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
fill: var(--carrier-left, rgb(76, 175, 75));
|
fill: rgb(76, 175, 75);
|
||||||
}
|
}
|
||||||
53.33% {
|
53.33% {
|
||||||
fill: #ffffff;
|
fill: #ffffff;
|
||||||
@@ -159,7 +191,7 @@
|
|||||||
top: -2px;
|
top: -2px;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
background-color: color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
|
background-color: #0e8953;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +200,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
top: 250px;
|
top: 250px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: 10px 10px 0px 0px;
|
border-radius: 10px 10px 0px 0px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -177,7 +207,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-left, #006f3a);
|
#006f3a;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 288px;
|
width: 288px;
|
||||||
transform: translateY(100%);
|
transform: translateY(100%);
|
||||||
@@ -185,6 +215,8 @@
|
|||||||
transition:
|
transition:
|
||||||
transform 0.3s ease-out,
|
transform 0.3s ease-out,
|
||||||
opacity 0.3s ease-out;
|
opacity 0.3s ease-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sights.slide-in {
|
.side-menu-sights.slide-in {
|
||||||
@@ -199,10 +231,12 @@
|
|||||||
.side-menu-sights-block {
|
.side-menu-sights-block {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
margin-left: 20px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
width: 100%;
|
width: auto;
|
||||||
|
max-width: calc(100% - 20px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -210,10 +244,10 @@
|
|||||||
|
|
||||||
.side-menu-sight {
|
.side-menu-sight {
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
|
margin-right: 20px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
border-bottom: 1px solid
|
border-bottom: 1px solid #0e8953;
|
||||||
color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
|
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
@@ -223,17 +257,6 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sight-wrapper {
|
|
||||||
margin-left: -20px;
|
|
||||||
padding-left: 20px;
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-sight-selected-wrapper {
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-sight > span {
|
.side-menu-sight > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -255,6 +278,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-menu-crest {
|
||||||
|
width: 170px;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.side-menu-control-lang-open {
|
.side-menu-control-lang-open {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -43,8 +43,6 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar-thumb {
|
.custom-scrollbar-thumb {
|
||||||
@@ -57,7 +55,6 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
transition: opacity 0.5s ease, height 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar-thumb:active {
|
.custom-scrollbar-thumb:active {
|
||||||
@@ -65,12 +62,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sights-block .scrollable-viewport {
|
.side-menu-sights-block .scrollable-viewport {
|
||||||
height: calc(98%);
|
height: calc(92%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sights-block .scrollable {
|
.side-menu-sights-block .scrollable {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-left: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-content .scrollable-viewport {
|
.list-of-sights-content .scrollable-viewport {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(179, 165, 152, 0.4);
|
rgba(0, 111, 58, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-widget-time {
|
.weather-widget-time {
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пользователь с ролью ТО всегда видит раздел устройств
|
|
||||||
if (item.path === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
|
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
|
||||||
const canAccessRoute = routePermissions.every((permission) =>
|
const canAccessRoute = routePermissions.every((permission) =>
|
||||||
authStore.canAccess(permission),
|
authStore.canAccess(permission),
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
margin: 0;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
import { articlesStore, selectedCityStore } from "@shared";
|
import { articlesStore } from "@shared";
|
||||||
|
|
||||||
const ArticleCreatePage: React.FC = () => {
|
const ArticleCreatePage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { articleData } = articlesStore;
|
const { articleData } = articlesStore;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
import { articlesStore, languageStore, selectedCityStore } from "@shared";
|
import { articlesStore, languageStore } from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
const ArticleEditPage: React.FC = observer(() => {
|
const ArticleEditPage: React.FC = observer(() => {
|
||||||
@@ -11,11 +11,6 @@ const ArticleEditPage: React.FC = observer(() => {
|
|||||||
|
|
||||||
const { articleData, getArticle } = articlesStore;
|
const { articleData, getArticle } = articlesStore;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Устанавливаем русский язык при загрузке страницы
|
// Устанавливаем русский язык при загрузке страницы
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const ArticleListPage = observer(() => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchArticles();
|
fetchArticles();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -55,12 +55,11 @@ export const ArticleListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Просмотр" onClick={() => navigate(`/article/${params.row.id}`)}>
|
<button onClick={() => navigate(`/article/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
{canWriteArticles && (
|
{canWriteArticles && (
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -108,9 +107,7 @@ export const ArticleListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@@ -19,97 +20,29 @@ import {
|
|||||||
languageStore,
|
languageStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
useSelectedCity,
|
useSelectedCity,
|
||||||
selectedCityStore,
|
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
editSightStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ImageUploadCard, LanguageSwitcher, VideoPreviewCard } from "@widgets";
|
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
type ColorFields = { main_color: string; left_color: string; right_color: string };
|
|
||||||
|
|
||||||
const colorFields = (data: ColorFields) => ({
|
|
||||||
main_color: data.main_color,
|
|
||||||
left_color: data.left_color,
|
|
||||||
right_color: data.right_color,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ColorPickerField = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (val: string) => void;
|
|
||||||
}) => (
|
|
||||||
<div className="flex items-center gap-3 w-full">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
|
|
||||||
style={{ backgroundColor: value || "#ffffff" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={value || "#ffffff"}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
placeholder="#000000"
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: value ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange("")}
|
|
||||||
className="text-gray-400 hover:text-gray-600 text-xs px-1"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CarrierCreatePage = observer(() => {
|
export const CarrierCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||||
createCarrierData,
|
|
||||||
setCreateCarrierData,
|
|
||||||
setCreateCarrierMedia,
|
|
||||||
} = carrierStore;
|
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const canReadCities = authStore.canRead("cities");
|
const canReadCities = authStore.canRead("cities");
|
||||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||||
|
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
const [mediaId, setMediaId] = useState("");
|
const [mediaId, setMediaId] = useState("");
|
||||||
const [activeMediaField, setActiveMediaField] = useState<
|
|
||||||
"preview_media" | "video_preview"
|
|
||||||
>("preview_media");
|
|
||||||
const [activeMenuType, setActiveMenuType] = useState<
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
| "thumbnail"
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
| "watermark_lu"
|
|
||||||
| "watermark_rd"
|
|
||||||
| "image"
|
|
||||||
| "video_preview"
|
|
||||||
| null
|
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCities = async () => {
|
const fetchCities = async () => {
|
||||||
if (!authStore.me) {
|
if (!authStore.me) {
|
||||||
@@ -148,16 +81,12 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
createCarrierData[language].full_name,
|
createCarrierData[language].full_name,
|
||||||
createCarrierData[language].short_name,
|
createCarrierData[language].short_name,
|
||||||
selectedCityId,
|
selectedCityId,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [selectedCityId, createCarrierData.city_id]);
|
||||||
selectedCityId,
|
|
||||||
createCarrierData,
|
|
||||||
createCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
setCreateCarrierData,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -166,7 +95,7 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
|
|
||||||
toast.success("Перевозчик успешно создан");
|
toast.success("Перевозчик успешно создан");
|
||||||
navigate("/carrier");
|
navigate("/carrier");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Ошибка при создании перевозчика");
|
toast.error("Ошибка при создании перевозчика");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -179,22 +108,27 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
media_name?: string;
|
media_name?: string;
|
||||||
media_type: number;
|
media_type: number;
|
||||||
}) => {
|
}) => {
|
||||||
setCreateCarrierMedia(activeMediaField, media.id);
|
setSelectedMediaId(media.id);
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
media.id,
|
||||||
|
language,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectivePreviewMediaUrl = isMediaIdEmpty(
|
const selectedMedia =
|
||||||
createCarrierData.preview_media,
|
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
|
||||||
)
|
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||||
|
: null;
|
||||||
|
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
|
||||||
? null
|
? null
|
||||||
: createCarrierData.preview_media;
|
: selectedMedia?.id ?? selectedMediaId ?? null;
|
||||||
const effectiveVideoPreviewId = isMediaIdEmpty(
|
|
||||||
createCarrierData.video_preview,
|
|
||||||
)
|
|
||||||
? null
|
|
||||||
: createCarrierData.video_preview;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -222,6 +156,8 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
createCarrierData[language].full_name,
|
createCarrierData[language].full_name,
|
||||||
createCarrierData[language].short_name,
|
createCarrierData[language].short_name,
|
||||||
e.target.value as number,
|
e.target.value as number,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -244,6 +180,8 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
e.target.value,
|
e.target.value,
|
||||||
createCarrierData[language].short_name,
|
createCarrierData[language].short_name,
|
||||||
createCarrierData.city_id,
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -259,120 +197,60 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
createCarrierData[language].full_name,
|
createCarrierData[language].full_name,
|
||||||
e.target.value,
|
e.target.value,
|
||||||
createCarrierData.city_id,
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-6">
|
<TextField
|
||||||
<div className="flex flex-col gap-1">
|
fullWidth
|
||||||
<ColorPickerField
|
label="Слоган"
|
||||||
label="Основной цвет"
|
value={createCarrierData[language].slogan}
|
||||||
value={createCarrierData.main_color}
|
onChange={(e) =>
|
||||||
onChange={(val) =>
|
|
||||||
setCreateCarrierData(
|
setCreateCarrierData(
|
||||||
createCarrierData[language].full_name,
|
createCarrierData[language].full_name,
|
||||||
createCarrierData[language].short_name,
|
createCarrierData[language].short_name,
|
||||||
createCarrierData.city_id,
|
createCarrierData.city_id,
|
||||||
|
e.target.value,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
{ ...colorFields(createCarrierData), main_color: val }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="Левый цвет"
|
|
||||||
value={createCarrierData.left_color}
|
|
||||||
onChange={(val) =>
|
|
||||||
setCreateCarrierData(
|
|
||||||
createCarrierData[language].full_name,
|
|
||||||
createCarrierData[language].short_name,
|
|
||||||
createCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
{ ...colorFields(createCarrierData), left_color: val }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: боковое меню, левый виджет достопримечательности
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="Правый цвет"
|
|
||||||
value={createCarrierData.right_color}
|
|
||||||
onChange={(val) =>
|
|
||||||
setCreateCarrierData(
|
|
||||||
createCarrierData[language].full_name,
|
|
||||||
createCarrierData[language].short_name,
|
|
||||||
createCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
{ ...colorFields(createCarrierData), right_color: val }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: список достопримечательностей, страница достопримечательности
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex justify-center items-start gap-10 flex-wrap">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<div className="w-full max-w-[300px]">
|
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Изображение сайд-меню"
|
title="Логотип перевозчика"
|
||||||
imageKey="image"
|
imageKey="thumbnail"
|
||||||
imageUrl={effectivePreviewMediaUrl}
|
imageUrl={effectiveLogoUrl}
|
||||||
onImageClick={() => {
|
onImageClick={() => {
|
||||||
setIsPreviewMediaOpen(true);
|
setIsPreviewMediaOpen(true);
|
||||||
setMediaId(effectivePreviewMediaUrl ?? "");
|
setMediaId(effectiveLogoUrl ?? "");
|
||||||
|
}}
|
||||||
|
onDeleteImageClick={() => {
|
||||||
|
setSelectedMediaId(null);
|
||||||
|
setActiveMenuType(null);
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
"",
|
||||||
|
language,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onDeleteImageClick={() =>
|
|
||||||
setCreateCarrierMedia("preview_media", "")
|
|
||||||
}
|
|
||||||
onSelectFileClick={() => {
|
onSelectFileClick={() => {
|
||||||
setActiveMediaField("preview_media");
|
|
||||||
setActiveMenuType("image");
|
setActiveMenuType("image");
|
||||||
setIsSelectMediaOpen(true);
|
setIsSelectMediaOpen(true);
|
||||||
}}
|
}}
|
||||||
setUploadMediaOpen={() => {
|
setUploadMediaOpen={() => {
|
||||||
setActiveMediaField("preview_media");
|
setIsUploadMediaOpen(true);
|
||||||
setActiveMenuType("image");
|
setActiveMenuType("image");
|
||||||
setIsUploadMediaOpen(true);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full max-w-[300px]">
|
|
||||||
<VideoPreviewCard
|
|
||||||
title="Видео загрузки"
|
|
||||||
videoId={effectiveVideoPreviewId}
|
|
||||||
onVideoClick={() => {
|
|
||||||
if (effectiveVideoPreviewId) {
|
|
||||||
setMediaId(effectiveVideoPreviewId);
|
|
||||||
setIsPreviewMediaOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDeleteVideoClick={() =>
|
|
||||||
setCreateCarrierMedia("video_preview", "")
|
|
||||||
}
|
|
||||||
onSelectVideoClick={(file) => {
|
|
||||||
setActiveMediaField("video_preview");
|
|
||||||
setActiveMenuType("video_preview");
|
|
||||||
if (file) {
|
|
||||||
editSightStore.setFileToUpload(file);
|
|
||||||
setIsUploadMediaOpen(true);
|
|
||||||
} else {
|
|
||||||
setIsSelectMediaOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -398,7 +276,7 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
onClose={() => setIsSelectMediaOpen(false)}
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
onSelectMedia={handleMediaSelect}
|
onSelectMedia={handleMediaSelect}
|
||||||
mediaType={activeMediaField === "video_preview" ? 2 : 1}
|
mediaType={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadMediaDialog
|
<UploadMediaDialog
|
||||||
@@ -415,6 +293,6 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
onClose={() => setIsPreviewMediaOpen(false)}
|
onClose={() => setIsPreviewMediaOpen(false)}
|
||||||
mediaId={mediaId}
|
mediaId={mediaId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@@ -20,84 +21,19 @@ import {
|
|||||||
languageStore,
|
languageStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
editSightStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||||
ImageUploadCard,
|
|
||||||
LanguageSwitcher,
|
|
||||||
VideoPreviewCard,
|
|
||||||
} from "@widgets";
|
|
||||||
import {
|
import {
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
|
|
||||||
type ColorFields = {
|
|
||||||
main_color: string;
|
|
||||||
left_color: string;
|
|
||||||
right_color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const colorFields = (data: ColorFields) => ({
|
|
||||||
main_color: data.main_color,
|
|
||||||
left_color: data.left_color,
|
|
||||||
right_color: data.right_color,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ColorPickerField = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (val: string) => void;
|
|
||||||
}) => (
|
|
||||||
<div className="flex items-center gap-3 w-full">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
|
|
||||||
style={{ backgroundColor: value || "#ffffff" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={value || "#ffffff"}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
placeholder="#000000"
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: value ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange("")}
|
|
||||||
className="text-gray-400 hover:text-gray-600 text-xs px-1"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CarrierEditPage = observer(() => {
|
export const CarrierEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const {
|
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
|
||||||
getCarrier,
|
|
||||||
setEditCarrierData,
|
|
||||||
setEditCarrierMedia,
|
|
||||||
editCarrierData,
|
|
||||||
} = carrierStore;
|
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const canReadCities = authStore.canRead("cities");
|
const canReadCities = authStore.canRead("cities");
|
||||||
|
|
||||||
@@ -107,24 +43,12 @@ export const CarrierEditPage = observer(() => {
|
|||||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
const [mediaId, setMediaId] = useState("");
|
const [mediaId, setMediaId] = useState("");
|
||||||
|
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
|
||||||
const [initialCityName, setInitialCityName] = useState("");
|
const [initialCityName, setInitialCityName] = useState("");
|
||||||
const [activeMediaField, setActiveMediaField] = useState<
|
|
||||||
"preview_media" | "video_preview"
|
|
||||||
>("preview_media");
|
|
||||||
const [activeMenuType, setActiveMenuType] = useState<
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
| "thumbnail"
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
| "watermark_lu"
|
|
||||||
| "watermark_rd"
|
|
||||||
| "image"
|
|
||||||
| "video_preview"
|
|
||||||
| null
|
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -144,37 +68,29 @@ export const CarrierEditPage = observer(() => {
|
|||||||
const carrierData = await getCarrier(Number(id));
|
const carrierData = await getCarrier(Number(id));
|
||||||
|
|
||||||
if (carrierData) {
|
if (carrierData) {
|
||||||
const colors = {
|
|
||||||
main_color: carrierData.ru?.main_color || "",
|
|
||||||
left_color: carrierData.ru?.left_color || "",
|
|
||||||
right_color: carrierData.ru?.right_color || "",
|
|
||||||
};
|
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
carrierData.ru?.full_name || "",
|
carrierData.ru?.full_name || "",
|
||||||
carrierData.ru?.short_name || "",
|
carrierData.ru?.short_name || "",
|
||||||
carrierData.ru?.city_id || 0,
|
carrierData.ru?.city_id || 0,
|
||||||
"ru",
|
carrierData.ru?.slogan || "",
|
||||||
colors,
|
carrierData.ru?.logo || "",
|
||||||
|
"ru"
|
||||||
);
|
);
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
carrierData.en?.full_name || "",
|
carrierData.en?.full_name || "",
|
||||||
carrierData.en?.short_name || "",
|
carrierData.en?.short_name || "",
|
||||||
carrierData.en?.city_id || 0,
|
carrierData.en?.city_id || 0,
|
||||||
"en",
|
carrierData.en?.slogan || "",
|
||||||
|
carrierData.en?.logo || "",
|
||||||
|
"en"
|
||||||
);
|
);
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
carrierData.zh?.full_name || "",
|
carrierData.zh?.full_name || "",
|
||||||
carrierData.zh?.short_name || "",
|
carrierData.zh?.short_name || "",
|
||||||
carrierData.zh?.city_id || 0,
|
carrierData.zh?.city_id || 0,
|
||||||
"zh",
|
carrierData.zh?.slogan || "",
|
||||||
);
|
carrierData.zh?.logo || "",
|
||||||
setEditCarrierMedia(
|
"zh"
|
||||||
"video_preview",
|
|
||||||
carrierData.ru?.video_preview || "",
|
|
||||||
);
|
|
||||||
setEditCarrierMedia(
|
|
||||||
"preview_media",
|
|
||||||
carrierData.ru?.preview_media || "",
|
|
||||||
);
|
);
|
||||||
setInitialCityName(carrierData.ru?.city || "");
|
setInitialCityName(carrierData.ru?.city || "");
|
||||||
}
|
}
|
||||||
@@ -186,7 +102,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
}, [id, getCarrier, setEditCarrierData, setEditCarrierMedia]);
|
}, [id]);
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -194,7 +110,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
await carrierStore.editCarrier(Number(id));
|
await carrierStore.editCarrier(Number(id));
|
||||||
toast.success("Перевозчик успешно обновлен");
|
toast.success("Перевозчик успешно обновлен");
|
||||||
navigate("/carrier");
|
navigate("/carrier");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Ошибка при обновлении перевозчика");
|
toast.error("Ошибка при обновлении перевозчика");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -207,19 +123,23 @@ export const CarrierEditPage = observer(() => {
|
|||||||
media_name?: string;
|
media_name?: string;
|
||||||
media_type: number;
|
media_type: number;
|
||||||
}) => {
|
}) => {
|
||||||
setEditCarrierMedia(activeMediaField, media.id);
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
media.id,
|
||||||
|
language
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectivePreviewMediaUrl = isMediaIdEmpty(
|
const selectedMedia =
|
||||||
editCarrierData.preview_media,
|
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
|
||||||
)
|
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
|
||||||
|
: null;
|
||||||
|
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
|
||||||
? null
|
? null
|
||||||
: editCarrierData.preview_media;
|
: (selectedMedia?.id ?? editCarrierData.logo);
|
||||||
const effectiveVideoPreviewId = isMediaIdEmpty(
|
|
||||||
editCarrierData.video_preview,
|
|
||||||
)
|
|
||||||
? null
|
|
||||||
: editCarrierData.video_preview;
|
|
||||||
|
|
||||||
const baseCities = canReadCities
|
const baseCities = canReadCities
|
||||||
? cityStore.cities["ru"].data
|
? cityStore.cities["ru"].data
|
||||||
@@ -262,7 +182,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -289,7 +209,9 @@ export const CarrierEditPage = observer(() => {
|
|||||||
editCarrierData[language].full_name,
|
editCarrierData[language].full_name,
|
||||||
editCarrierData[language].short_name,
|
editCarrierData[language].short_name,
|
||||||
Number(e.target.value),
|
Number(e.target.value),
|
||||||
language,
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -311,7 +233,9 @@ export const CarrierEditPage = observer(() => {
|
|||||||
e.target.value,
|
e.target.value,
|
||||||
editCarrierData[language].short_name,
|
editCarrierData[language].short_name,
|
||||||
editCarrierData.city_id,
|
editCarrierData.city_id,
|
||||||
language,
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -326,123 +250,51 @@ export const CarrierEditPage = observer(() => {
|
|||||||
editCarrierData[language].full_name,
|
editCarrierData[language].full_name,
|
||||||
e.target.value,
|
e.target.value,
|
||||||
editCarrierData.city_id,
|
editCarrierData.city_id,
|
||||||
language,
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-6">
|
<TextField
|
||||||
<div className="flex flex-col gap-1">
|
fullWidth
|
||||||
<ColorPickerField
|
label="Слоган"
|
||||||
label="Основной цвет"
|
value={editCarrierData[language].slogan}
|
||||||
value={editCarrierData.main_color}
|
onChange={(e) =>
|
||||||
onChange={(val) =>
|
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
editCarrierData[language].full_name,
|
editCarrierData[language].full_name,
|
||||||
editCarrierData[language].short_name,
|
editCarrierData[language].short_name,
|
||||||
editCarrierData.city_id,
|
editCarrierData.city_id,
|
||||||
language,
|
e.target.value,
|
||||||
{ ...colorFields(editCarrierData), main_color: val },
|
editCarrierData.logo,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: значки на карте, скопление достопримечательностей
|
|
||||||
на карте, информационный виджет
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="Левый цвет"
|
|
||||||
value={editCarrierData.left_color}
|
|
||||||
onChange={(val) =>
|
|
||||||
setEditCarrierData(
|
|
||||||
editCarrierData[language].full_name,
|
|
||||||
editCarrierData[language].short_name,
|
|
||||||
editCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
{ ...colorFields(editCarrierData), left_color: val },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: виджет обращений, боковое меню (фон, список
|
|
||||||
остановок), левый виджет достопримечательности
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="Правый цвет"
|
|
||||||
value={editCarrierData.right_color}
|
|
||||||
onChange={(val) =>
|
|
||||||
setEditCarrierData(
|
|
||||||
editCarrierData[language].full_name,
|
|
||||||
editCarrierData[language].short_name,
|
|
||||||
editCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
{ ...colorFields(editCarrierData), right_color: val },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: список достопримечательностей (фон, карточки),
|
|
||||||
правый виджет достопримечательности
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex justify-center items-start gap-10 flex-wrap">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<div className="w-full max-w-[300px]">
|
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Изображение сайд-меню"
|
title="Логотип перевозчика"
|
||||||
imageKey="image"
|
imageKey="thumbnail"
|
||||||
imageUrl={effectivePreviewMediaUrl}
|
imageUrl={effectiveLogoUrl}
|
||||||
onImageClick={() => {
|
onImageClick={() => {
|
||||||
setIsPreviewMediaOpen(true);
|
setIsPreviewMediaOpen(true);
|
||||||
setMediaId(effectivePreviewMediaUrl ?? "");
|
setMediaId(effectiveLogoUrl ?? "");
|
||||||
|
}}
|
||||||
|
onDeleteImageClick={() => {
|
||||||
|
setIsDeleteLogoModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onDeleteImageClick={() =>
|
|
||||||
setEditCarrierMedia("preview_media", "")
|
|
||||||
}
|
|
||||||
onSelectFileClick={() => {
|
onSelectFileClick={() => {
|
||||||
setActiveMediaField("preview_media");
|
|
||||||
setActiveMenuType("image");
|
setActiveMenuType("image");
|
||||||
setIsSelectMediaOpen(true);
|
setIsSelectMediaOpen(true);
|
||||||
}}
|
}}
|
||||||
setUploadMediaOpen={() => {
|
setUploadMediaOpen={() => {
|
||||||
setActiveMediaField("preview_media");
|
setIsUploadMediaOpen(true);
|
||||||
setActiveMenuType("image");
|
setActiveMenuType("image");
|
||||||
setIsUploadMediaOpen(true);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full max-w-[300px]">
|
|
||||||
<VideoPreviewCard
|
|
||||||
title="Видео загрузки"
|
|
||||||
videoId={effectiveVideoPreviewId}
|
|
||||||
onVideoClick={() => {
|
|
||||||
if (effectiveVideoPreviewId) {
|
|
||||||
setMediaId(effectiveVideoPreviewId);
|
|
||||||
setIsPreviewMediaOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDeleteVideoClick={() =>
|
|
||||||
setEditCarrierMedia("video_preview", "")
|
|
||||||
}
|
|
||||||
onSelectVideoClick={(file) => {
|
|
||||||
setActiveMediaField("video_preview");
|
|
||||||
setActiveMenuType("video_preview");
|
|
||||||
if (file) {
|
|
||||||
editSightStore.setFileToUpload(file);
|
|
||||||
setIsUploadMediaOpen(true);
|
|
||||||
} else {
|
|
||||||
setIsSelectMediaOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -467,7 +319,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
onClose={() => setIsSelectMediaOpen(false)}
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
onSelectMedia={handleMediaSelect}
|
onSelectMedia={handleMediaSelect}
|
||||||
mediaType={activeMediaField === "video_preview" ? 2 : 1}
|
mediaType={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadMediaDialog
|
<UploadMediaDialog
|
||||||
@@ -485,6 +337,22 @@ export const CarrierEditPage = observer(() => {
|
|||||||
mediaId={mediaId}
|
mediaId={mediaId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
<DeleteModal
|
||||||
|
open={isDeleteLogoModalOpen}
|
||||||
|
onDelete={() => {
|
||||||
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
"",
|
||||||
|
language
|
||||||
|
);
|
||||||
|
setIsDeleteLogoModalOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setIsDeleteLogoModalOpen(false)}
|
||||||
|
edit
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, carrierStore, cityStore, languageStore, selectedCityStore, SearchInput } from "@shared";
|
import { authStore, carrierStore, cityStore, languageStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -39,7 +39,7 @@ export const CarrierListPage = observer(() => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -98,11 +98,10 @@ export const CarrierListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Редактировать" onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -162,9 +161,7 @@ export const CarrierListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -172,11 +169,6 @@ export const CarrierListPage = observer(() => {
|
|||||||
checkboxSelection={canWriteCarriers}
|
checkboxSelection={canWriteCarriers}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onRowDoubleClick={(params) => {
|
|
||||||
if (canWriteCarriers) {
|
|
||||||
navigate(`/carrier/${params.id}/edit`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
|||||||
@@ -17,24 +17,16 @@ import {
|
|||||||
countryStore,
|
countryStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
mediaStore,
|
mediaStore,
|
||||||
snapshotStore,
|
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||||
|
|
||||||
export const CityCreatePage = observer(() => {
|
export const CityCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
|
const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
|
||||||
cityStore;
|
cityStore;
|
||||||
@@ -61,13 +53,7 @@ export const CityCreatePage = observer(() => {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const ruCityName = createCityData.ru.name.trim();
|
|
||||||
await cityStore.createCity();
|
await cityStore.createCity();
|
||||||
try {
|
|
||||||
await snapshotStore.createEmptySnapshot(`${ruCityName}_Пустой_Экспорт`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to create empty snapshot for city:", e);
|
|
||||||
}
|
|
||||||
toast.success("Город успешно создан");
|
toast.success("Город успешно создан");
|
||||||
navigate("/city");
|
navigate("/city");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -23,19 +23,12 @@ import {
|
|||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||||
|
|
||||||
export const CityEditPage = observer(() => {
|
export const CityEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
|
|||||||
@@ -114,11 +114,10 @@ export const CityListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Редактировать" onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -140,10 +139,7 @@ export const CityListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Города</h1>
|
<h1 className="text-2xl">Города</h1>
|
||||||
{canWriteCities && (
|
{canWriteCities && (
|
||||||
<CreateButton
|
<CreateButton label="Создать город" path="/city/create" />
|
||||||
label="Создать город"
|
|
||||||
path="/city/create"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,9 +155,7 @@ export const CityListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={filteredRows}
|
rows={filteredRows}
|
||||||
@@ -169,11 +163,6 @@ export const CityListPage = observer(() => {
|
|||||||
checkboxSelection={canWriteCities}
|
checkboxSelection={canWriteCities}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onRowDoubleClick={(params) => {
|
|
||||||
if (canWriteCities) {
|
|
||||||
navigate(`/city/${params.id}/edit`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
@@ -206,11 +195,7 @@ export const CityListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : (
|
|
||||||
"Нет городов"
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,18 +15,11 @@ import {
|
|||||||
RU_COUNTRIES,
|
RU_COUNTRIES,
|
||||||
EN_COUNTRIES,
|
EN_COUNTRIES,
|
||||||
ZH_COUNTRIES,
|
ZH_COUNTRIES,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const CountryAddPage = observer(() => {
|
export const CountryAddPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,12 @@ import { ArrowLeft, Save } from "lucide-react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { countryStore, languageStore, selectedCityStore } from "@shared";
|
import { countryStore, languageStore } from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
export const CountryCreatePage = observer(() => {
|
export const CountryCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||||
|
|||||||
@@ -4,18 +4,12 @@ import { ArrowLeft, Save } from "lucide-react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { countryStore, languageStore, LoadingSpinner, selectedCityStore } from "@shared";
|
import { countryStore, languageStore, LoadingSpinner } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
export const CountryEditPage = observer(() => {
|
export const CountryEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|||||||
@@ -92,10 +92,7 @@ export const CountryListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Страны</h1>
|
<h1 className="text-2xl">Страны</h1>
|
||||||
{canWriteCountries && (
|
{canWriteCountries && (
|
||||||
<CreateButton
|
<CreateButton label="Добавить страну" path="/country/add" />
|
||||||
label="Добавить страну"
|
|
||||||
path="/country/add"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,9 +108,7 @@ export const CountryListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -153,11 +148,7 @@ export const CountryListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : (
|
|
||||||
"Нет стран"
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
cityStore,
|
cityStore,
|
||||||
createSightStore,
|
createSightStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import {
|
import {
|
||||||
CreateInformationTab,
|
CreateInformationTab,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
} from "@widgets";
|
} from "@widgets";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { runInAction } from "mobx";
|
|
||||||
|
|
||||||
function a11yProps(index: number) {
|
function a11yProps(index: number) {
|
||||||
return {
|
return {
|
||||||
@@ -32,11 +30,6 @@ export const CreateSightPage = observer(() => {
|
|||||||
const { getArticles } = articlesStore;
|
const { getArticles } = articlesStore;
|
||||||
const needLeave = createSightStore.needLeaveAgree;
|
const needLeave = createSightStore.needLeaveAgree;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
};
|
};
|
||||||
@@ -63,14 +56,6 @@ export const CreateSightPage = observer(() => {
|
|||||||
await authStore.fetchMeCities().catch(() => undefined);
|
await authStore.fetchMeCities().catch(() => undefined);
|
||||||
}
|
}
|
||||||
await getArticles(languageStore.language);
|
await getArticles(languageStore.language);
|
||||||
|
|
||||||
const { selectedCityId, selectedCity } = selectedCityStore;
|
|
||||||
if (selectedCityId && selectedCity && !createSightStore.sight.city_id) {
|
|
||||||
runInAction(() => {
|
|
||||||
createSightStore.sight.city_id = selectedCityId;
|
|
||||||
createSightStore.sight.city = selectedCity.name;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
cityStore,
|
cityStore,
|
||||||
editSightStore,
|
editSightStore,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useBlocker, useParams } from "react-router-dom";
|
import { useBlocker, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -26,11 +25,6 @@ export const EditSightPage = observer(() => {
|
|||||||
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
||||||
const { getArticles } = articlesStore;
|
const { getArticles } = articlesStore;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { getCities } = cityStore;
|
const { getCities } = cityStore;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared";
|
import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
@@ -20,11 +20,6 @@ export const MediaCreatePage = observer(() => {
|
|||||||
const [type, setType] = useState("");
|
const [type, setType] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
MEDIA_TYPE_LABELS,
|
MEDIA_TYPE_LABELS,
|
||||||
languageStore,
|
languageStore,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { MediaViewer } from "@widgets";
|
import { MediaViewer } from "@widgets";
|
||||||
|
|
||||||
@@ -43,11 +42,6 @@ export const MediaEditPage = observer(() => {
|
|||||||
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
|
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
|
||||||
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
|
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
mediaStore.getOneMedia(id);
|
mediaStore.getOneMedia(id);
|
||||||
|
|||||||
@@ -78,12 +78,11 @@ export const MediaListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Просмотр" onClick={() => navigate(`/media/${params.row.id}`)}>
|
<button onClick={() => navigate(`/media/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
{canWriteMedia && (
|
{canWriteMedia && (
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -113,9 +112,7 @@ export const MediaListPage = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
{canWriteMedia && ids.length > 0 && (
|
{canWriteMedia && ids.length > 0 && (
|
||||||
<div className="flex justify-end mb-5 duration-300">
|
<div className="flex justify-end mb-5 duration-300">
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { MediaViewer, VideoPreviewCard, ImageUploadCard } from "@widgets";
|
import {
|
||||||
|
MediaViewer,
|
||||||
|
VideoPreviewCard,
|
||||||
|
ImageUploadCard,
|
||||||
|
} from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
|
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
@@ -35,18 +39,11 @@ import type { Route } from "@shared";
|
|||||||
|
|
||||||
export const RouteCreatePage = observer(() => {
|
export const RouteCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [carrier, setCarrier] = useState<string>("");
|
const [carrier, setCarrier] = useState<string>("");
|
||||||
const [routeNumber, setRouteNumber] = useState("");
|
const [routeNumber, setRouteNumber] = useState("");
|
||||||
const [routeCoords, setRouteCoords] = useState("");
|
const [routeCoords, setRouteCoords] = useState("");
|
||||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||||
const [buttonText, setButtonText] = useState("");
|
|
||||||
const [direction, setDirection] = useState("backward");
|
const [direction, setDirection] = useState("backward");
|
||||||
const [scaleMin, setScaleMin] = useState("10");
|
const [scaleMin, setScaleMin] = useState("10");
|
||||||
const [scaleMax, setScaleMax] = useState("100");
|
const [scaleMax, setScaleMax] = useState("100");
|
||||||
@@ -54,7 +51,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const [turn, setTurn] = useState("");
|
const [turn, setTurn] = useState("");
|
||||||
const [centerLat, setCenterLat] = useState("");
|
const [centerLat, setCenterLat] = useState("");
|
||||||
const [centerLng, setCenterLng] = useState("");
|
const [centerLng, setCenterLng] = useState("");
|
||||||
const [videoTimer, setVideoTimer] = useState(420);
|
const [videoTimer, setVideoTimer] = useState(60);
|
||||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||||
const [icon, setIcon] = useState<string>("");
|
const [icon, setIcon] = useState<string>("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -136,16 +133,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
articlesStore.getArticleList();
|
articlesStore.getArticleList();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIconSelect = (media: {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
media_name?: string;
|
|
||||||
media_type: number;
|
|
||||||
}) => {
|
|
||||||
setIcon(media.id);
|
|
||||||
setIsSelectIconDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVideoSelect = (media: {
|
const handleVideoSelect = (media: {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -176,6 +163,20 @@ export const RouteCreatePage = observer(() => {
|
|||||||
setFileToUpload(null);
|
setFileToUpload(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVideoPreviewClick = () => {
|
||||||
|
setIsVideoPreviewOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconSelect = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
setIcon(media.id);
|
||||||
|
setIsSelectIconDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const selectedIconMedia =
|
const selectedIconMedia =
|
||||||
icon && !isMediaIdEmpty(icon)
|
icon && !isMediaIdEmpty(icon)
|
||||||
? mediaStore.media.find((m) => m.id === icon)
|
? mediaStore.media.find((m) => m.id === icon)
|
||||||
@@ -285,10 +286,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
newRoute.governor_appeal = governor_appeal;
|
newRoute.governor_appeal = governor_appeal;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (buttonText.trim()) {
|
|
||||||
newRoute.button_text = buttonText.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const newId = await routeStore.createRoute(newRoute);
|
const newId = await routeStore.createRoute(newRoute);
|
||||||
toast.success("Маршрут успешно создан");
|
toast.success("Маршрут успешно создан");
|
||||||
navigate(`/route/${newId}/edit`);
|
navigate(`/route/${newId}/edit`);
|
||||||
@@ -404,18 +401,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
|
||||||
Текст кнопки обращения
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
value={buttonText}
|
|
||||||
onChange={(e) => setButtonText(e.target.value)}
|
|
||||||
placeholder="Обращение губернатора"
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
helperText="Если пусто, будет использован текст по умолчанию"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
Обращение к пассажирам
|
Обращение к пассажирам
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -481,7 +466,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
<VideoPreviewCard
|
<VideoPreviewCard
|
||||||
title="Видеозаставка"
|
title="Видеозаставка"
|
||||||
videoId={effectiveVideoId}
|
videoId={effectiveVideoId}
|
||||||
onVideoClick={() => setIsVideoPreviewOpen(true)}
|
onVideoClick={handleVideoPreviewClick}
|
||||||
onDeleteVideoClick={() => setVideoPreview("")}
|
onDeleteVideoClick={() => setVideoPreview("")}
|
||||||
onSelectVideoClick={handleVideoFileSelect}
|
onSelectVideoClick={handleVideoFileSelect}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -570,7 +555,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Таймер показа видео при бездействии (сек)"
|
label="Таймер видео (сек)"
|
||||||
type="number"
|
type="number"
|
||||||
value={videoTimer}
|
value={videoTimer}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -643,10 +628,11 @@ export const RouteCreatePage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
hardcodeType="video_preview"
|
hardcodeType="video_preview"
|
||||||
contextObjectName={routeName || "Маршрут"}
|
contextObjectName={routeName || "Маршрут"}
|
||||||
contextType="route"
|
contextType="sight"
|
||||||
initialFile={fileToUpload || undefined}
|
initialFile={fileToUpload || undefined}
|
||||||
afterUpload={handleVideoUpload}
|
afterUpload={handleVideoUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectIconDialogOpen}
|
open={isSelectIconDialogOpen}
|
||||||
onClose={() => setIsSelectIconDialogOpen(false)}
|
onClose={() => setIsSelectIconDialogOpen(false)}
|
||||||
|
|||||||
@@ -36,18 +36,11 @@ import {
|
|||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { LinkedItems } from "../LinekedStations";
|
import { LinkedItems } from "../LinekedStations";
|
||||||
|
|
||||||
export const RouteEditPage = observer(() => {
|
export const RouteEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { editRouteData, copyRouteAction } = routeStore;
|
const { editRouteData, copyRouteAction } = routeStore;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -244,7 +237,9 @@ export const RouteEditPage = observer(() => {
|
|||||||
media_name?: string;
|
media_name?: string;
|
||||||
media_type: number;
|
media_type: number;
|
||||||
}) => {
|
}) => {
|
||||||
routeStore.setEditRouteData({ video_preview: media.id });
|
routeStore.setEditRouteData({
|
||||||
|
video_preview: media.id,
|
||||||
|
});
|
||||||
setIsSelectVideoDialogOpen(false);
|
setIsSelectVideoDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,11 +258,19 @@ export const RouteEditPage = observer(() => {
|
|||||||
media_name?: string;
|
media_name?: string;
|
||||||
media_type: number;
|
media_type: number;
|
||||||
}) => {
|
}) => {
|
||||||
routeStore.setEditRouteData({ video_preview: media.id });
|
routeStore.setEditRouteData({
|
||||||
|
video_preview: media.id,
|
||||||
|
});
|
||||||
setIsUploadVideoDialogOpen(false);
|
setIsUploadVideoDialogOpen(false);
|
||||||
setFileToUpload(null);
|
setFileToUpload(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVideoPreviewClick = () => {
|
||||||
|
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||||
|
setIsVideoPreviewOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Получаем название выбранной статьи для отображения
|
// Получаем название выбранной статьи для отображения
|
||||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||||
(article) => article.id === editRouteData.governor_appeal
|
(article) => article.id === editRouteData.governor_appeal
|
||||||
@@ -545,9 +548,9 @@ export const RouteEditPage = observer(() => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Таймер показа видео при бездействии (сек)"
|
label="Таймер видео (сек)"
|
||||||
type="number"
|
type="number"
|
||||||
value={editRouteData.video_timer ?? 420}
|
value={editRouteData.video_timer ?? 60}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = Math.max(1, Math.round(Number(e.target.value)));
|
const val = Math.max(1, Math.round(Number(e.target.value)));
|
||||||
if (Number.isFinite(val)) {
|
if (Number.isFinite(val)) {
|
||||||
@@ -556,22 +559,6 @@ export const RouteEditPage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
|
||||||
Текст кнопки обращения
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
value={editRouteData.button_text || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
button_text: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Обращение губернатора"
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
helperText="Если пусто, будет использован текст по умолчанию"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
Обращение к пассажирам
|
Обращение к пассажирам
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -640,10 +627,10 @@ export const RouteEditPage = observer(() => {
|
|||||||
<VideoPreviewCard
|
<VideoPreviewCard
|
||||||
title="Видеозаставка"
|
title="Видеозаставка"
|
||||||
videoId={effectiveVideoId}
|
videoId={effectiveVideoId}
|
||||||
onVideoClick={() => setIsVideoPreviewOpen(true)}
|
onVideoClick={handleVideoPreviewClick}
|
||||||
onDeleteVideoClick={() =>
|
onDeleteVideoClick={() => {
|
||||||
routeStore.setEditRouteData({ video_preview: "" })
|
routeStore.setEditRouteData({ video_preview: "" });
|
||||||
}
|
}}
|
||||||
onSelectVideoClick={handleVideoFileSelect}
|
onSelectVideoClick={handleVideoFileSelect}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -731,10 +718,11 @@ export const RouteEditPage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
hardcodeType="video_preview"
|
hardcodeType="video_preview"
|
||||||
contextObjectName={editRouteData.route_name || "Маршрут"}
|
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||||||
contextType="route"
|
contextType="sight"
|
||||||
initialFile={fileToUpload || undefined}
|
initialFile={fileToUpload || undefined}
|
||||||
afterUpload={handleVideoUpload}
|
afterUpload={handleVideoUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectIconDialogOpen}
|
open={isSelectIconDialogOpen}
|
||||||
onClose={() => setIsSelectIconDialogOpen(false)}
|
onClose={() => setIsSelectIconDialogOpen(false)}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const RouteListPage = observer(() => {
|
|||||||
loadCounts(routeIds);
|
loadCounts(routeIds);
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -139,7 +139,7 @@ export const RouteListPage = observer(() => {
|
|||||||
headerAlign: "center" as const,
|
headerAlign: "center" as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
renderHeader: (params: any) => (
|
renderHeader: (params: any) => (
|
||||||
<Tooltip title="Отображает количество привязанных достопримечательностей">
|
<Tooltip title="Количество привязанных достопримечательностей">
|
||||||
<span>{params.colDef.headerName}</span>
|
<span>{params.colDef.headerName}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -157,7 +157,7 @@ export const RouteListPage = observer(() => {
|
|||||||
headerAlign: "center" as const,
|
headerAlign: "center" as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
renderHeader: (params: any) => (
|
renderHeader: (params: any) => (
|
||||||
<Tooltip title="Отображает количество привязанных остановок">
|
<Tooltip title="Количество привязанных остановок">
|
||||||
<span>{params.colDef.headerName}</span>
|
<span>{params.colDef.headerName}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -178,23 +178,22 @@ export const RouteListPage = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
{canWriteRoutes && (
|
{canWriteRoutes && (
|
||||||
<button title="Редактировать" onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canShowRoutePreview && (
|
{canShowRoutePreview && (
|
||||||
<button title="Предпросмотр на карте" onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||||
<Map size={20} className="text-purple-500" />
|
<Map size={20} className="text-purple-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canShowRoutePreview && (
|
{canShowRoutePreview && (
|
||||||
<button title="Демо" onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
|
<button onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
|
||||||
<Monitor size={20} className="text-green-500" />
|
<Monitor size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canWriteRoutes && (
|
{canWriteRoutes && (
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -211,9 +210,6 @@ export const RouteListPage = observer(() => {
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const { selectedCityId } = selectedCityStore;
|
const { selectedCityId } = selectedCityStore;
|
||||||
if (!selectedCityId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
let filtered = routes.data;
|
let filtered = routes.data;
|
||||||
if (selectedCityId) {
|
if (selectedCityId) {
|
||||||
@@ -251,10 +247,7 @@ export const RouteListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Маршруты</h1>
|
<h1 className="text-2xl">Маршруты</h1>
|
||||||
{canWriteRoutes && (
|
{canWriteRoutes && (
|
||||||
<CreateButton
|
<CreateButton label="Создать маршрут" path="/route/create" />
|
||||||
label="Создать маршрут"
|
|
||||||
path="/route/create"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -270,9 +263,7 @@ export const RouteListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -313,13 +304,7 @@ export const RouteListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : !selectedCityStore.selectedCityId ? (
|
|
||||||
"Выберите город"
|
|
||||||
) : (
|
|
||||||
"Нет маршрутов"
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
232
src/pages/Route/route-preview/InfiniteCanvas.tsx
Normal file
232
src/pages/Route/route-preview/InfiniteCanvas.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
|
||||||
|
import { Component, ReactNode, useEffect, useState, useRef } from "react";
|
||||||
|
import { useTransform } from "./TransformContext";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
import { SCALE_FACTOR } from "./Constants";
|
||||||
|
import { useApplication } from "@pixi/react";
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<
|
||||||
|
{ children: ReactNode },
|
||||||
|
{ hasError: boolean }
|
||||||
|
> {
|
||||||
|
state = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
console.error("Error caught:", error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfiniteCanvas({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children?: ReactNode }>) {
|
||||||
|
const {
|
||||||
|
position,
|
||||||
|
setPosition,
|
||||||
|
scale,
|
||||||
|
setScale,
|
||||||
|
rotation,
|
||||||
|
setRotation,
|
||||||
|
setScreenCenter,
|
||||||
|
screenCenter,
|
||||||
|
} = useTransform();
|
||||||
|
const { routeData, originalRouteData, setSelectedSight } = useMapData();
|
||||||
|
|
||||||
|
const applicationRef = useApplication();
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [startRotation, setStartRotation] = useState(0);
|
||||||
|
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||||
|
|
||||||
|
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||||
|
|
||||||
|
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!applicationRef?.app?.canvas) return;
|
||||||
|
|
||||||
|
const canvas = applicationRef.app.canvas;
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
const canvasLeft = canvasRect.left;
|
||||||
|
const canvasTop = canvasRect.top;
|
||||||
|
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||||
|
const centerY = window.innerHeight / 2 - canvasTop;
|
||||||
|
setScreenCenter({ x: centerX, y: centerY });
|
||||||
|
}, [applicationRef?.app, setScreenCenter]);
|
||||||
|
|
||||||
|
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(true);
|
||||||
|
setIsDragging(false);
|
||||||
|
setIsUserInteracting(true);
|
||||||
|
setStartPosition({
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
});
|
||||||
|
setStartMousePosition({
|
||||||
|
x: e.globalX,
|
||||||
|
y: e.globalY,
|
||||||
|
});
|
||||||
|
setStartRotation(rotation);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newRotation = originalRouteData?.rotate ?? 0;
|
||||||
|
|
||||||
|
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
|
||||||
|
setRotation((newRotation * Math.PI) / 180);
|
||||||
|
lastOriginalRotation.current = newRotation;
|
||||||
|
}
|
||||||
|
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
|
||||||
|
|
||||||
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
|
if (!isPointerDown) return;
|
||||||
|
|
||||||
|
if (!isDragging) {
|
||||||
|
const dx = e.globalX - startMousePosition.x;
|
||||||
|
const dy = e.globalY - startMousePosition.y;
|
||||||
|
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||||
|
setIsDragging(true);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
const center = screenCenter ?? { x: 0, y: 0 };
|
||||||
|
const startAngle = Math.atan2(
|
||||||
|
startMousePosition.y - center.y,
|
||||||
|
startMousePosition.x - center.x
|
||||||
|
);
|
||||||
|
const currentAngle = Math.atan2(
|
||||||
|
e.globalY - center.y,
|
||||||
|
e.globalX - center.x
|
||||||
|
);
|
||||||
|
|
||||||
|
const rotationDiff = currentAngle - startAngle;
|
||||||
|
|
||||||
|
setRotation(startRotation + rotationDiff);
|
||||||
|
|
||||||
|
const cosDelta = Math.cos(rotationDiff);
|
||||||
|
const sinDelta = Math.sin(rotationDiff);
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x:
|
||||||
|
center.x * (1 - cosDelta) +
|
||||||
|
startPosition.x * cosDelta +
|
||||||
|
(center.y - startPosition.y) * sinDelta,
|
||||||
|
y:
|
||||||
|
center.y * (1 - cosDelta) +
|
||||||
|
startPosition.y * cosDelta +
|
||||||
|
(startPosition.x - center.x) * sinDelta,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setRotation(startRotation);
|
||||||
|
setPosition({
|
||||||
|
x: startPosition.x - startMousePosition.x + e.globalX,
|
||||||
|
y: startPosition.y - startMousePosition.y + e.globalY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
|
if (!isDragging) {
|
||||||
|
setSelectedSight(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPointerDown(false);
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsUserInteracting(false);
|
||||||
|
}, 100);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: FederatedWheelEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsUserInteracting(true);
|
||||||
|
|
||||||
|
const mouseX = e.globalX - position.x;
|
||||||
|
const mouseY = e.globalY - position.y;
|
||||||
|
|
||||||
|
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
|
||||||
|
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
|
||||||
|
|
||||||
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
|
||||||
|
const actualZoomFactor = newScale / scale;
|
||||||
|
|
||||||
|
if (scale === newScale) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsUserInteracting(false);
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: position.x + mouseX * (1 - actualZoomFactor),
|
||||||
|
y: position.y + mouseY * (1 - actualZoomFactor),
|
||||||
|
});
|
||||||
|
|
||||||
|
setScale(newScale);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsUserInteracting(false);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applicationRef?.app.render();
|
||||||
|
}, [position, scale, rotation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
{applicationRef?.app && (
|
||||||
|
<pixiGraphics
|
||||||
|
draw={(g) => {
|
||||||
|
const canvas = applicationRef.app.canvas;
|
||||||
|
g.clear();
|
||||||
|
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
|
||||||
|
g.fill("#111");
|
||||||
|
}}
|
||||||
|
eventMode={"static"}
|
||||||
|
interactive
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerUpOutside={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<pixiContainer
|
||||||
|
x={position.x}
|
||||||
|
y={position.y}
|
||||||
|
scale={scale}
|
||||||
|
rotation={rotation}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</pixiContainer>
|
||||||
|
{/* Show center of the screen.
|
||||||
|
<pixiGraphics
|
||||||
|
eventMode="none"
|
||||||
|
draw={(g) => {
|
||||||
|
g.clear();
|
||||||
|
const center = screenCenter ?? {x: 0, y: 0};
|
||||||
|
g.circle(center.x, center.y, 1);
|
||||||
|
g.fill("#fff");
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Button } from "@mui/material";
|
import { Box, Stack, Typography, Button } from "@mui/material";
|
||||||
import { useNavigate, useNavigationType } from "react-router";
|
import { useNavigate, useNavigationType } from "react-router";
|
||||||
|
import { MediaViewer } from "@widgets";
|
||||||
import { useMapData } from "./MapDataContext";
|
import { useMapData } from "./MapDataContext";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { authInstance } from "@shared";
|
import { authInstance, isMediaIdEmpty } from "@shared";
|
||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import LanguageSelector from "./web-gl/LanguageSelector";
|
import LanguageSelector from "./web-gl/LanguageSelector";
|
||||||
|
|
||||||
@@ -14,22 +15,22 @@ type LeftSidebarProps = {
|
|||||||
|
|
||||||
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigationType = useNavigationType();
|
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
||||||
const { routeData } = useMapData();
|
const { routeData } = useMapData();
|
||||||
const [previewMedia, setPreviewMedia] = useState<string | null>(null);
|
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
|
||||||
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
|
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchCarrierData() {
|
async function fetchCarrierThumbnail() {
|
||||||
if (routeData?.carrier_id) {
|
if (routeData?.carrier_id) {
|
||||||
const carrier = (
|
const { city_id, logo } = (
|
||||||
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
||||||
).data;
|
).data;
|
||||||
setPreviewMedia(carrier.preview_media ?? null);
|
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
|
||||||
setIsPreviewMediaError(false);
|
setCarrierThumbnail(arms);
|
||||||
|
setCarrierLogo(logo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchCarrierData();
|
fetchCarrierThumbnail();
|
||||||
}, [routeData?.carrier_id]);
|
}, [routeData?.carrier_id]);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@@ -41,128 +42,131 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: 288,
|
|
||||||
height: "100%",
|
height: "100%",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
|
transition: "padding 0.3s ease",
|
||||||
|
p: open ? 2 : 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
justifyContent: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Кнопка назад — вне основного меню */}
|
<Stack
|
||||||
<div
|
direction="column"
|
||||||
style={{
|
height="100%"
|
||||||
padding: "12px 12px 0",
|
width="100%"
|
||||||
|
spacing={4}
|
||||||
|
alignItems="stretch"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{
|
||||||
opacity: open ? 1 : 0,
|
opacity: open ? 1 : 0,
|
||||||
pointerEvents: open ? "auto" : "none",
|
|
||||||
transition: "opacity 0.25s ease",
|
transition: "opacity 0.25s ease",
|
||||||
|
pointerEvents: open ? "auto" : "none",
|
||||||
|
display: open ? "flex" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "#222",
|
backgroundColor: "#222",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 1,
|
py: 1,
|
||||||
"&:hover": { backgroundColor: "#2d2d2d" },
|
marginBottom: 10,
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "#2d2d2d",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
startIcon={<ArrowBackIcon />}
|
startIcon={<ArrowBackIcon />}
|
||||||
>
|
>
|
||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Основное меню — повторяет .side-menu */}
|
<Stack
|
||||||
|
direction="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
spacing={3}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
boxSizing: "border-box",
|
maxWidth: 150,
|
||||||
paddingTop: 46,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
height: "calc(100% - 56px)",
|
gap: 10,
|
||||||
position: "relative",
|
|
||||||
opacity: open ? 1 : 0,
|
|
||||||
transition: "opacity 0.25s ease",
|
|
||||||
pointerEvents: open ? "auto" : "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Кнопки — .side-menu-buttons */}
|
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
|
||||||
<div
|
<MediaViewer
|
||||||
style={{
|
media={{
|
||||||
width: 220,
|
id: carrierThumbnail,
|
||||||
marginTop: routeData?.governor_appeal || 0 > 0 ? 40 : 260,
|
media_type: 1, // Тип "Фото" для логотипа
|
||||||
}}
|
filename: "route_thumbnail",
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
color: "#000",
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "8px 16px",
|
|
||||||
marginBottom: 16,
|
|
||||||
borderRadius: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Достопримечательности
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
color: "#000",
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "8px 16px",
|
|
||||||
marginBottom: 16,
|
|
||||||
borderRadius: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Остановки
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Нижняя секция — .side-menu-bottom-section */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* .side-menu-bottom-photo */}
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
previewMedia && !isPreviewMediaError
|
|
||||||
? `${import.meta.env.VITE_KRBL_MEDIA}${previewMedia}/download?token=${localStorage.getItem("token") ?? ""}`
|
|
||||||
: "/side-menu-photo.png"
|
|
||||||
}
|
|
||||||
onError={() => setIsPreviewMediaError(true)}
|
|
||||||
alt=""
|
|
||||||
style={{
|
|
||||||
width: "288px",
|
|
||||||
marginTop: 32,
|
|
||||||
display: "block",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
}}
|
||||||
|
fullWidth
|
||||||
|
fullHeight
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<Typography sx={{ color: "#fff" }} textAlign="center">
|
||||||
|
При поддержке Правительства
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 mt-10">
|
||||||
|
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
|
||||||
|
Обращение губернатора
|
||||||
|
</button>
|
||||||
|
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||||
|
Достопримечательности
|
||||||
|
</button>
|
||||||
|
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||||
|
Остановки
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<Stack
|
||||||
className="absolute bottom-[20px] z-10"
|
direction="column"
|
||||||
style={{
|
alignItems="center"
|
||||||
right: open ? -520 : -312,
|
maxHeight={150}
|
||||||
transition: "right 0.3s ease",
|
justifyContent="center"
|
||||||
}}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
|
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: carrierLogo,
|
||||||
|
media_type: 1, // Тип "Фото" для логотипа
|
||||||
|
filename: "route_thumbnail_logo",
|
||||||
|
}}
|
||||||
|
fullHeight
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
textAlign="center"
|
||||||
|
sx={{ color: "#fff", marginTop: "auto" }}
|
||||||
|
>
|
||||||
|
#ВсемПоПути
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
||||||
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
137
src/pages/Route/route-preview/Sight.tsx
Normal file
137
src/pages/Route/route-preview/Sight.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTransform } from "./TransformContext";
|
||||||
|
import { SightData } from "./types";
|
||||||
|
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
|
||||||
|
|
||||||
|
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
|
||||||
|
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
|
||||||
|
interface SightProps {
|
||||||
|
sight: SightData;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
|
||||||
|
const { rotation, scale } = useTransform();
|
||||||
|
const { setSightCoordinates, setSelectedSight } = useMapData();
|
||||||
|
|
||||||
|
const [position, setPosition] = useState(
|
||||||
|
coordinatesToLocal(sight.latitude, sight.longitude)
|
||||||
|
);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||||
|
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(true);
|
||||||
|
setIsDragging(false);
|
||||||
|
setStartPosition({
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
});
|
||||||
|
setStartMousePosition({
|
||||||
|
x: e.globalX,
|
||||||
|
y: e.globalY,
|
||||||
|
});
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
|
if (!isPointerDown) return;
|
||||||
|
|
||||||
|
if (!isDragging) {
|
||||||
|
const dx = e.globalX - startMousePosition.x;
|
||||||
|
const dy = e.globalY - startMousePosition.y;
|
||||||
|
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
||||||
|
setIsDragging(true);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
|
||||||
|
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
|
||||||
|
const cos = Math.cos(rotation);
|
||||||
|
const sin = Math.sin(rotation);
|
||||||
|
const newPosition = {
|
||||||
|
x: startPosition.x + dx * cos + dy * sin,
|
||||||
|
y: startPosition.y - dx * sin + dy * cos,
|
||||||
|
};
|
||||||
|
setPosition(newPosition);
|
||||||
|
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
|
||||||
|
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(false);
|
||||||
|
|
||||||
|
// Если не было перетаскивания, то это клик
|
||||||
|
if (!isDragging) {
|
||||||
|
setSelectedSight(sight);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [texture, setTexture] = useState(Texture.EMPTY);
|
||||||
|
useEffect(() => {
|
||||||
|
Assets.load("/sight_icon.svg").then(setTexture);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
|
||||||
|
|
||||||
|
if (!sight) {
|
||||||
|
console.error("sight is null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компенсируем масштаб, но при зуме текст немного увеличивается
|
||||||
|
const clampedScale = Math.min(Math.max(scale, 1), 3);
|
||||||
|
const textScaleFactor = 1 + (clampedScale - 1) * 0.4;
|
||||||
|
const compensatedSize = SIGHT_SIZE / scale;
|
||||||
|
const compensatedFontSize = (24 / scale) * textScaleFactor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer
|
||||||
|
rotation={-rotation}
|
||||||
|
eventMode="static"
|
||||||
|
interactive
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerUpOutside={handlePointerUp}
|
||||||
|
x={position.x * UP_SCALE - SIGHT_SIZE / 2}
|
||||||
|
y={position.y * UP_SCALE - SIGHT_SIZE / 2}
|
||||||
|
>
|
||||||
|
<pixiSprite
|
||||||
|
texture={texture}
|
||||||
|
width={compensatedSize}
|
||||||
|
height={compensatedSize}
|
||||||
|
/>
|
||||||
|
<pixiGraphics
|
||||||
|
draw={(g) => {
|
||||||
|
g.clear();
|
||||||
|
g.circle(0, 0, 20 / scale);
|
||||||
|
g.fill({ color: "#000" });
|
||||||
|
}}
|
||||||
|
x={compensatedSize}
|
||||||
|
y={0}
|
||||||
|
/>
|
||||||
|
<pixiText
|
||||||
|
text={`${id + 1}`}
|
||||||
|
x={compensatedSize + 1 / scale}
|
||||||
|
y={0}
|
||||||
|
anchor={0.5}
|
||||||
|
style={{
|
||||||
|
fontSize: compensatedFontSize,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fill: "#ffffff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
541
src/pages/Route/route-preview/Station.tsx
Normal file
541
src/pages/Route/route-preview/Station.tsx
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import { FederatedMouseEvent, Graphics } from "pixi.js";
|
||||||
|
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BACKGROUND_COLOR,
|
||||||
|
PATH_COLOR,
|
||||||
|
STATION_RADIUS,
|
||||||
|
STATION_OUTLINE_WIDTH,
|
||||||
|
UP_SCALE,
|
||||||
|
} from "./Constants";
|
||||||
|
import { useTransform } from "./TransformContext";
|
||||||
|
import { StationData } from "./types";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
import { coordinatesToLocal } from "./utils";
|
||||||
|
import { languageStore } from "@shared";
|
||||||
|
|
||||||
|
declare const pixiContainer: any;
|
||||||
|
declare const pixiGraphics: any;
|
||||||
|
declare const pixiText: any;
|
||||||
|
|
||||||
|
type HorizontalAlign = "left" | "center" | "right";
|
||||||
|
type VerticalAlign = "top" | "center" | "bottom";
|
||||||
|
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
|
||||||
|
type LabelAlign = "left" | "center" | "right";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует текстовое позиционирование в anchor координаты.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает координату anchor.x из типа выравнивания.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface StationProps {
|
||||||
|
station: StationData;
|
||||||
|
ruLabel: string | null;
|
||||||
|
anchorPoint?: { x: number; y: number };
|
||||||
|
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
|
||||||
|
labelBlockAnchor?: TextAlign | { x: number; y: number };
|
||||||
|
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
|
||||||
|
labelAlign?: LabelAlign;
|
||||||
|
/** Callback для изменения внутреннего выравнивания */
|
||||||
|
onLabelAlignChange?: (align: LabelAlign) => void;
|
||||||
|
/** Callback для отслеживания наведения на текст */
|
||||||
|
onTextHover?: (isHovered: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LabelAlignmentControlProps {
|
||||||
|
scale: number;
|
||||||
|
currentAlign: LabelAlign;
|
||||||
|
onAlignChange: (align: LabelAlign) => void;
|
||||||
|
onPointerOver: () => void;
|
||||||
|
onPointerOut: () => void;
|
||||||
|
onControlPointerEnter: () => void;
|
||||||
|
onControlPointerLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StationLabelProps
|
||||||
|
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
|
||||||
|
|
||||||
|
const getAnchorFromOffset = (
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
if (offsetX === 0 && offsetY === 0) {
|
||||||
|
return { x: 0.5, y: 0.5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = Math.hypot(offsetX, offsetY);
|
||||||
|
const nx = offsetX / length;
|
||||||
|
const ny = offsetY / length;
|
||||||
|
|
||||||
|
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||||
|
scale,
|
||||||
|
currentAlign,
|
||||||
|
onAlignChange,
|
||||||
|
|
||||||
|
onControlPointerEnter,
|
||||||
|
onControlPointerLeave,
|
||||||
|
}) => {
|
||||||
|
const controlHeight = 50 / scale;
|
||||||
|
const controlWidth = 200 / scale;
|
||||||
|
const fontSize = 18 / scale;
|
||||||
|
const borderRadius = 8 / scale;
|
||||||
|
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||||
|
const buttonWidth = controlWidth / 3;
|
||||||
|
const strokeWidth = 2 / scale;
|
||||||
|
|
||||||
|
const drawBg = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
g.roundRect(
|
||||||
|
-controlWidth / 2,
|
||||||
|
0,
|
||||||
|
controlWidth,
|
||||||
|
controlHeight,
|
||||||
|
borderRadius
|
||||||
|
);
|
||||||
|
g.fill({ color: "#1a1a1a" });
|
||||||
|
|
||||||
|
g.roundRect(
|
||||||
|
-controlWidth / 2,
|
||||||
|
0,
|
||||||
|
controlWidth,
|
||||||
|
controlHeight,
|
||||||
|
borderRadius
|
||||||
|
);
|
||||||
|
g.stroke({ color: "#333333", width: strokeWidth });
|
||||||
|
|
||||||
|
for (let i = 1; i < 3; i++) {
|
||||||
|
const x = -controlWidth / 2 + buttonWidth * i;
|
||||||
|
g.moveTo(x, strokeWidth);
|
||||||
|
g.lineTo(x, controlHeight - strokeWidth);
|
||||||
|
g.stroke({ color: "#333333", width: strokeWidth });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawButtonHighlight = useCallback(
|
||||||
|
(g: Graphics, index: number, isActive: boolean) => {
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
const x = -controlWidth / 2 + buttonWidth * index;
|
||||||
|
g.roundRect(
|
||||||
|
x + strokeWidth,
|
||||||
|
strokeWidth,
|
||||||
|
buttonWidth - strokeWidth * 2,
|
||||||
|
controlHeight - strokeWidth * 2,
|
||||||
|
borderRadius / 2
|
||||||
|
);
|
||||||
|
g.fill({ color: "#0066cc", alpha: 0.8 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTextStyle = (isActive: boolean) => ({
|
||||||
|
fontSize,
|
||||||
|
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
|
||||||
|
fill: isActive ? "#ffffff" : "#cccccc",
|
||||||
|
fontFamily: "Arial, sans-serif",
|
||||||
|
});
|
||||||
|
|
||||||
|
const alignOptions = [
|
||||||
|
{ key: "left" as const, label: "Left" },
|
||||||
|
{ key: "center" as const, label: "Center" },
|
||||||
|
{ key: "right" as const, label: "Right" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer
|
||||||
|
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
|
||||||
|
zIndex={999999999999999999}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerOver={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onControlPointerEnter();
|
||||||
|
}}
|
||||||
|
onPointerOut={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onControlPointerLeave();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Основной фон */}
|
||||||
|
<pixiGraphics draw={drawBg} />
|
||||||
|
|
||||||
|
{/* Кнопки с подсветкой */}
|
||||||
|
{alignOptions.map((option, index) => (
|
||||||
|
<pixiContainer key={option.key}>
|
||||||
|
{/* Подсветка активной кнопки */}
|
||||||
|
<pixiGraphics
|
||||||
|
draw={(g: Graphics) =>
|
||||||
|
drawButtonHighlight(g, index, option.key === currentAlign)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Текст кнопки */}
|
||||||
|
<pixiText
|
||||||
|
text={option.label}
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
position={{
|
||||||
|
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
|
||||||
|
y: controlHeight / 2,
|
||||||
|
}}
|
||||||
|
style={getTextStyle(option.key === currentAlign)}
|
||||||
|
eventMode="static"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAlignChange(option.key);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAlignChange(option.key);
|
||||||
|
}}
|
||||||
|
onPointerOver={(e: FederatedMouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onControlPointerEnter();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</pixiContainer>
|
||||||
|
))}
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StationLabel = observer(
|
||||||
|
({
|
||||||
|
station,
|
||||||
|
ruLabel,
|
||||||
|
|
||||||
|
labelAlign: labelAlignProp = "center",
|
||||||
|
onLabelAlignChange,
|
||||||
|
onTextHover,
|
||||||
|
}: Readonly<StationLabelProps>) => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
const { rotation, scale } = useTransform();
|
||||||
|
const { setStationOffset, setStationAlign } = useMapData();
|
||||||
|
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [isControlHovered, setIsControlHovered] = useState(false);
|
||||||
|
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
|
||||||
|
const [ruLabelWidth, setRuLabelWidth] = useState(0);
|
||||||
|
|
||||||
|
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||||
|
const mouseStartPos = useRef({ x: 0, y: 0 });
|
||||||
|
const hideTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const ruLabelRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerEnter = () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
setIsHovered(true);
|
||||||
|
onTextHover?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleControlPointerEnter = () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
setIsControlHovered(true);
|
||||||
|
setIsHovered(true);
|
||||||
|
onTextHover?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleControlPointerLeave = () => {
|
||||||
|
setIsControlHovered(false);
|
||||||
|
|
||||||
|
if (!isHovered) {
|
||||||
|
hideTimer.current = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
onTextHover?.(false);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerLeave = () => {
|
||||||
|
hideTimer.current = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
|
||||||
|
if (!isControlHovered) {
|
||||||
|
setIsControlHovered(false);
|
||||||
|
}
|
||||||
|
onTextHover?.(false);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
|
||||||
|
}, [station.offset_x, station.offset_y, station.id]);
|
||||||
|
|
||||||
|
const convertNumericAlign = (align: number): LabelAlign => {
|
||||||
|
switch (align) {
|
||||||
|
case 0:
|
||||||
|
return "left";
|
||||||
|
case 1:
|
||||||
|
return "center";
|
||||||
|
case 2:
|
||||||
|
return "right";
|
||||||
|
default:
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertStringAlign = (align: LabelAlign): number => {
|
||||||
|
switch (align) {
|
||||||
|
case "left":
|
||||||
|
return 0;
|
||||||
|
case "center":
|
||||||
|
return 1;
|
||||||
|
case "right":
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
|
||||||
|
}, [station.align]);
|
||||||
|
|
||||||
|
if (!station) return null;
|
||||||
|
|
||||||
|
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
|
||||||
|
const clampedScale = Math.min(Math.max(scale, 1), 3);
|
||||||
|
const textScaleFactor = 1 + (clampedScale - 1) * 0.4;
|
||||||
|
const compensatedRuFontSize = ((26 * 0.75) / scale) * textScaleFactor;
|
||||||
|
const compensatedNameFontSize = ((16 * 0.75) / scale) * textScaleFactor;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ruLabelRef.current && ruLabel) {
|
||||||
|
setRuLabelWidth(ruLabelRef.current.width);
|
||||||
|
}
|
||||||
|
}, [ruLabel, compensatedRuFontSize]);
|
||||||
|
|
||||||
|
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(true);
|
||||||
|
setIsDragging(false);
|
||||||
|
dragStartPos.current = { ...position };
|
||||||
|
mouseStartPos.current = { x: e.global.x, y: e.global.y };
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
|
if (!isPointerDown) return;
|
||||||
|
|
||||||
|
if (!isDragging) {
|
||||||
|
const dx = e.global.x - mouseStartPos.current.x;
|
||||||
|
const dy = e.global.y - mouseStartPos.current.y;
|
||||||
|
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx_screen = e.global.x - mouseStartPos.current.x;
|
||||||
|
const dy_screen = e.global.y - mouseStartPos.current.y;
|
||||||
|
|
||||||
|
const newPosition = {
|
||||||
|
x: dragStartPos.current.x + dx_screen,
|
||||||
|
y: dragStartPos.current.y + dy_screen,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(newPosition.x - position.x) > 0.01 ||
|
||||||
|
Math.abs(newPosition.y - position.y) > 0.01
|
||||||
|
) {
|
||||||
|
setPosition(newPosition);
|
||||||
|
setStationOffset(station.id, newPosition.x, newPosition.y);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(false);
|
||||||
|
setTimeout(() => setIsDragging(false), 50);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlignChange = async (align: LabelAlign) => {
|
||||||
|
setCurrentLabelAlign(align);
|
||||||
|
onLabelAlignChange?.(align);
|
||||||
|
|
||||||
|
const numericAlign = convertStringAlign(align);
|
||||||
|
setStationAlign(station.id, numericAlign);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dynamicAnchor = useMemo(
|
||||||
|
() => getAnchorFromOffset(position.x, position.y),
|
||||||
|
[position.x, position.y]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSecondLabelPosition = (): number => {
|
||||||
|
if (!ruLabelWidth) return 0;
|
||||||
|
|
||||||
|
switch (currentLabelAlign) {
|
||||||
|
case "left":
|
||||||
|
return -ruLabelWidth / 2;
|
||||||
|
case "center":
|
||||||
|
return 0;
|
||||||
|
case "right":
|
||||||
|
return ruLabelWidth / 2;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecondLabelAnchor = (): number => {
|
||||||
|
switch (currentLabelAlign) {
|
||||||
|
case "left":
|
||||||
|
return 0;
|
||||||
|
case "center":
|
||||||
|
return 0.5;
|
||||||
|
case "right":
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer
|
||||||
|
x={coordinates.x * UP_SCALE}
|
||||||
|
y={coordinates.y * UP_SCALE}
|
||||||
|
rotation={-rotation}
|
||||||
|
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||||
|
eventMode="static"
|
||||||
|
interactive
|
||||||
|
cursor={isDragging ? "grabbing" : "grab"}
|
||||||
|
onPointerOver={handlePointerEnter}
|
||||||
|
onPointerOut={handlePointerLeave}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerUpOutside={handlePointerUp}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
>
|
||||||
|
<pixiContainer
|
||||||
|
position={{
|
||||||
|
x:
|
||||||
|
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
|
||||||
|
scale,
|
||||||
|
y:
|
||||||
|
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
|
||||||
|
scale,
|
||||||
|
}}
|
||||||
|
anchor={dynamicAnchor}
|
||||||
|
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||||
|
>
|
||||||
|
{ruLabel && (
|
||||||
|
<pixiText
|
||||||
|
ref={ruLabelRef}
|
||||||
|
text={ruLabel}
|
||||||
|
position={{ x: 0, y: 0 }}
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
style={{
|
||||||
|
fontSize: compensatedRuFontSize,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fill: "#ffffff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{station.name && language !== "ru" && ruLabel && (
|
||||||
|
<pixiText
|
||||||
|
text={station.name}
|
||||||
|
position={{
|
||||||
|
x: getSecondLabelPosition(),
|
||||||
|
y: compensatedRuFontSize * 1.1,
|
||||||
|
}}
|
||||||
|
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
|
||||||
|
style={{
|
||||||
|
fontSize: compensatedNameFontSize,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fill: "#CCCCCC",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(isHovered || isControlHovered) && !isDragging && (
|
||||||
|
<LabelAlignmentControl
|
||||||
|
scale={scale}
|
||||||
|
currentAlign={currentLabelAlign}
|
||||||
|
onAlignChange={handleAlignChange}
|
||||||
|
onPointerOver={handlePointerEnter}
|
||||||
|
onPointerOut={handlePointerLeave}
|
||||||
|
onControlPointerEnter={handleControlPointerEnter}
|
||||||
|
onControlPointerLeave={handleControlPointerLeave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</pixiContainer>
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Station = ({
|
||||||
|
station,
|
||||||
|
ruLabel,
|
||||||
|
|
||||||
|
labelAlign,
|
||||||
|
onLabelAlignChange,
|
||||||
|
}: Readonly<StationProps>) => {
|
||||||
|
const [isTextHovered, setIsTextHovered] = useState(false);
|
||||||
|
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
const coordinates = coordinatesToLocal(
|
||||||
|
station.latitude,
|
||||||
|
station.longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
const radius = STATION_RADIUS;
|
||||||
|
const strokeWidth = STATION_OUTLINE_WIDTH;
|
||||||
|
|
||||||
|
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
||||||
|
|
||||||
|
if (isTextHovered) {
|
||||||
|
g.fill({ color: 0x00aaff });
|
||||||
|
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
|
||||||
|
} else {
|
||||||
|
g.fill({ color: PATH_COLOR });
|
||||||
|
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[station.latitude, station.longitude, isTextHovered]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
|
||||||
|
<pixiGraphics draw={draw} />
|
||||||
|
<StationLabel
|
||||||
|
station={station}
|
||||||
|
ruLabel={ruLabel}
|
||||||
|
labelAlign={labelAlign}
|
||||||
|
onLabelAlignChange={onLabelAlignChange}
|
||||||
|
onTextHover={setIsTextHovered}
|
||||||
|
/>
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/pages/Route/route-preview/TravelPath.tsx
Normal file
34
src/pages/Route/route-preview/TravelPath.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Graphics } from "pixi.js";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { PATH_COLOR, PATH_WIDTH } from "./Constants";
|
||||||
|
import { coordinatesToLocal } from "./utils";
|
||||||
|
|
||||||
|
interface TravelPathProps {
|
||||||
|
points: { x: number; y: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TravelPath({ points }: Readonly<TravelPathProps>) {
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
const coordStart = coordinatesToLocal(points[0].x, points[0].y);
|
||||||
|
g.moveTo(coordStart.x, coordStart.y);
|
||||||
|
for (let i = 1; i <= points.length - 1; i++) {
|
||||||
|
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
|
||||||
|
g.lineTo(coordinates.x, coordinates.y);
|
||||||
|
}
|
||||||
|
g.stroke({
|
||||||
|
color: PATH_COLOR,
|
||||||
|
width: PATH_WIDTH,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[points]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
console.error("points is empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <pixiGraphics draw={draw} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { Widgets } from "./Widgets";
|
import { Widgets } from "./Widgets";
|
||||||
|
import { extend } from "@pixi/react";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Graphics,
|
||||||
|
Sprite,
|
||||||
|
Texture,
|
||||||
|
TilingSprite,
|
||||||
|
Text,
|
||||||
|
} from "pixi.js";
|
||||||
import { Box, Stack } from "@mui/material";
|
import { Box, Stack } from "@mui/material";
|
||||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||||
import { TransformProvider, useTransform } from "./TransformContext";
|
import { TransformProvider, useTransform } from "./TransformContext";
|
||||||
@@ -13,6 +22,15 @@ import { UP_SCALE } from "./Constants";
|
|||||||
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
||||||
import { CircularProgress } from "@mui/material";
|
import { CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
|
extend({
|
||||||
|
Container,
|
||||||
|
Graphics,
|
||||||
|
Sprite,
|
||||||
|
Texture,
|
||||||
|
TilingSprite,
|
||||||
|
Text,
|
||||||
|
});
|
||||||
|
|
||||||
const Loading = () => {
|
const Loading = () => {
|
||||||
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
||||||
|
|
||||||
@@ -36,8 +54,7 @@ export const RoutePreview = () => {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
zIndex: 20,
|
width: isLeftSidebarOpen ? 300 : 0,
|
||||||
width: isLeftSidebarOpen ? 288 : 0,
|
|
||||||
transition: "width 0.3s ease",
|
transition: "width 0.3s ease",
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@@ -76,10 +93,9 @@ export const RouteMap = observer(() => {
|
|||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousBodyOverflow = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousBodyOverflow;
|
document.body.style.overflow = "auto";
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -129,14 +145,14 @@ export const RouteMap = observer(() => {
|
|||||||
) {
|
) {
|
||||||
const coordinates = coordinatesToLocal(
|
const coordinates = coordinatesToLocal(
|
||||||
originalRouteData?.center_latitude,
|
originalRouteData?.center_latitude,
|
||||||
originalRouteData?.center_longitude,
|
originalRouteData?.center_longitude
|
||||||
);
|
);
|
||||||
|
|
||||||
setTransform(
|
setTransform(
|
||||||
coordinates.x,
|
coordinates.x,
|
||||||
coordinates.y,
|
coordinates.y,
|
||||||
originalRouteData?.rotate,
|
originalRouteData?.rotate,
|
||||||
originalRouteData?.scale_min,
|
originalRouteData?.scale_min
|
||||||
);
|
);
|
||||||
setIsSetup(true);
|
setIsSetup(true);
|
||||||
}
|
}
|
||||||
@@ -155,6 +171,25 @@ export const RouteMap = observer(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||||
|
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
|
||||||
|
<InfiniteCanvas>
|
||||||
|
<TravelPath points={points} />
|
||||||
|
{stationData[language].map((obj, index) => (
|
||||||
|
<Station
|
||||||
|
station={obj}
|
||||||
|
key={obj.id}
|
||||||
|
ruLabel={
|
||||||
|
language === "ru"
|
||||||
|
? stationData.ru[index].name
|
||||||
|
: stationData.ru[index].name
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{originalSightData?.map((sight: SightData, index: number) => {
|
||||||
|
return <Sight sight={sight} id={index} key={sight.id} />;
|
||||||
|
})}
|
||||||
|
</InfiniteCanvas>
|
||||||
|
</Application> */}
|
||||||
<WebGLRouteMapPrototype />
|
<WebGLRouteMapPrototype />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export interface RouteData {
|
|||||||
icon_size?: number;
|
icon_size?: number;
|
||||||
font_size: number;
|
font_size: number;
|
||||||
governor_appeal: number;
|
governor_appeal: number;
|
||||||
button_text?: string;
|
|
||||||
id: number;
|
id: number;
|
||||||
path: [number, number][];
|
path: [number, number][];
|
||||||
rotate: number;
|
rotate: number;
|
||||||
|
|||||||
@@ -26,12 +26,10 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow:
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
|
||||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
||||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
||||||
background:
|
background: linear-gradient(
|
||||||
linear-gradient(
|
|
||||||
to bottom right,
|
to bottom right,
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
@@ -52,8 +50,7 @@
|
|||||||
height: 96px;
|
height: 96px;
|
||||||
background-color: #fcd500;
|
background-color: #fcd500;
|
||||||
color: black;
|
color: black;
|
||||||
border-top-left-radius: 10px;
|
border-radius: 10px;
|
||||||
border-bottom-left-radius: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2327,6 +2327,9 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
const stationScreenY =
|
const stationScreenY =
|
||||||
rotatedY * camera.scale + camera.translation.y;
|
rotatedY * camera.scale + camera.translation.y;
|
||||||
|
|
||||||
|
const labelX = stationScreenX + offsetX;
|
||||||
|
const labelY = stationScreenY + offsetY;
|
||||||
|
|
||||||
const backendAlign = station.align;
|
const backendAlign = station.align;
|
||||||
|
|
||||||
const anchor = getAnchorFromOffset(backendAlign ?? 2);
|
const anchor = getAnchorFromOffset(backendAlign ?? 2);
|
||||||
@@ -2336,6 +2339,8 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
|
|
||||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||||
|
|
||||||
|
const cssX = labelX / dpr;
|
||||||
|
const cssY = labelY / dpr;
|
||||||
const rotationCss = `${rotationAngle}rad`;
|
const rotationCss = `${rotationAngle}rad`;
|
||||||
const counterRotationCss = `${-rotationAngle}rad`;
|
const counterRotationCss = `${-rotationAngle}rad`;
|
||||||
|
|
||||||
@@ -2354,13 +2359,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
|
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
|
||||||
|
|
||||||
const primaryFontSize = 16 * fontScale * scaleFactor;
|
const primaryFontSize = 16 * fontScale * scaleFactor;
|
||||||
|
|
||||||
const mainLabelHeight = primaryFontSize * 1.2;
|
|
||||||
const labelX = stationScreenX + offsetX;
|
|
||||||
const labelY = stationScreenY + offsetY + mainLabelHeight / 2;
|
|
||||||
|
|
||||||
const cssX = labelX / dpr;
|
|
||||||
const cssY = labelY / dpr;
|
|
||||||
const secondaryFontSize = 13 * fontScale * scaleFactor;
|
const secondaryFontSize = 13 * fontScale * scaleFactor;
|
||||||
|
|
||||||
const secondaryMarginTop = 5 * fontScale * scaleFactor;
|
const secondaryMarginTop = 5 * fontScale * scaleFactor;
|
||||||
@@ -2406,7 +2404,13 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
hoveredStationIconId === station.id ||
|
hoveredStationIconId === station.id ||
|
||||||
resizingStationIconId === station.id;
|
resizingStationIconId === station.id;
|
||||||
|
|
||||||
const secondaryLineHeight = 1.2 * scaleFactor;
|
const secondaryLineHeight = 1.2;
|
||||||
|
const secondaryHeight = showSecondary
|
||||||
|
? secondaryFontSize * secondaryLineHeight
|
||||||
|
: 0;
|
||||||
|
const menuPaddingTop = showSecondary
|
||||||
|
? Math.max(0, secondaryHeight - secondaryMarginTop) + 3
|
||||||
|
: 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={station.id}>
|
<div key={station.id}>
|
||||||
@@ -2436,7 +2440,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontFamily: "Roboto, sans-serif",
|
fontFamily: "Roboto, sans-serif",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
pointerEvents: "none",
|
pointerEvents: "auto",
|
||||||
cursor: "grab",
|
cursor: "grab",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
@@ -2444,16 +2448,14 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
pointerEvents: "auto",
|
||||||
pointerEvents: "none",
|
|
||||||
transformOrigin: "left center",
|
transformOrigin: "left center",
|
||||||
transform: `rotate(${rotationCss})`,
|
transform: `rotate(${rotationCss})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
pointerEvents: "auto",
|
||||||
pointerEvents: "none",
|
|
||||||
transformOrigin: "left center",
|
transformOrigin: "left center",
|
||||||
transform: `rotate(${counterRotationCss})`,
|
transform: `rotate(${counterRotationCss})`,
|
||||||
}}
|
}}
|
||||||
@@ -2547,7 +2549,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: primaryFontSize,
|
fontSize: primaryFontSize,
|
||||||
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
||||||
@@ -2556,6 +2557,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{station.name}
|
{station.name}
|
||||||
|
</div>
|
||||||
{showSecondary ? (
|
{showSecondary ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -2578,7 +2580,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{hoveredStationId === station.id && (
|
{hoveredStationId === station.id && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -2586,9 +2587,9 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
top: "100%",
|
top: "100%",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
paddingTop: "8px",
|
paddingTop: menuPaddingTop,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
zIndex: 1000000,
|
zIndex: 10,
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const SightListPage = observer(() => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchSights();
|
fetchSights();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -105,11 +105,10 @@ export const SightListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Редактировать" onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -122,11 +121,8 @@ export const SightListPage = observer(() => {
|
|||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const { selectedCityId } = selectedCityStore;
|
|
||||||
const filteredSights = useMemo(() => {
|
const filteredSights = useMemo(() => {
|
||||||
if (!selectedCityId) {
|
const { selectedCityId } = selectedCityStore;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const allowedCityIds = canReadCities
|
const allowedCityIds = canReadCities
|
||||||
? null
|
? null
|
||||||
: authStore.meCities["ru"].map((c) => c.city_id);
|
: authStore.meCities["ru"].map((c) => c.city_id);
|
||||||
@@ -135,12 +131,12 @@ export const SightListPage = observer(() => {
|
|||||||
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
|
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (sight.city_id !== selectedCityId) {
|
if (selectedCityId && sight.city_id !== selectedCityId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [sights, selectedCityId, canReadCities, authStore.meCities]);
|
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
|
||||||
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
const rows = filteredSights
|
const rows = filteredSights
|
||||||
@@ -181,9 +177,7 @@ export const SightListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -222,8 +216,6 @@ export const SightListPage = observer(() => {
|
|||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CircularProgress size={20} />
|
<CircularProgress size={20} />
|
||||||
) : !selectedCityId ? (
|
|
||||||
"Выберите город"
|
|
||||||
) : (
|
) : (
|
||||||
"Нет достопримечательностей"
|
"Нет достопримечательностей"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,58 +1,46 @@
|
|||||||
import {
|
import { Button, TextField } from "@mui/material";
|
||||||
Button,
|
import { snapshotStore } from "@shared";
|
||||||
TextField,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { runInAction } from "mobx";
|
import { runInAction } from "mobx";
|
||||||
|
|
||||||
function escapeRegex(s: string) {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExportNameRegex(cityNames: string[]): RegExp {
|
|
||||||
if (!cityNames.length) return /.+/;
|
|
||||||
const pattern = cityNames.map(escapeRegex).join("|");
|
|
||||||
return new RegExp(`^(${pattern})_.+$`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SnapshotCreatePage = observer(() => {
|
export const SnapshotCreatePage = observer(() => {
|
||||||
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [nameError, setNameError] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
|
|
||||||
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const exportNameRegex = useMemo(() => {
|
return (
|
||||||
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
|
<div className="w-full h-[400px] flex justify-center items-center">
|
||||||
return buildExportNameRegex(names);
|
<div className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
}, [cityStore.cities["ru"].data.length]);
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Создание экспорта медиа</h1>
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Название"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
useEffect(() => {
|
<Button
|
||||||
if (!cityStore.cities["ru"].loaded) {
|
variant="contained"
|
||||||
cityStore.getCities("ru");
|
color="primary"
|
||||||
}
|
className="w-min flex gap-2 items-center"
|
||||||
}, []);
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={async () => {
|
||||||
const canReadRoutes = authStore.canRead("routes");
|
|
||||||
|
|
||||||
const startExport = async () => {
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const id = await createSnapshot(name);
|
const id = await createSnapshot(name);
|
||||||
@@ -80,118 +68,8 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!canReadRoutes) {
|
|
||||||
await startExport();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
runInAction(() => {
|
|
||||||
routeStore.routes.loaded = false;
|
|
||||||
});
|
|
||||||
await routeStore.getRoutes();
|
|
||||||
await carrierStore.getCarriers("ru");
|
|
||||||
|
|
||||||
const routes = routeStore.routes.data;
|
|
||||||
const carriers = carrierStore.carriers.ru.data;
|
|
||||||
const carrierCityMap = new Map<number, number>();
|
|
||||||
for (const c of carriers) {
|
|
||||||
carrierCityMap.set(c.id, c.city_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const duplicateMessages: string[] = [];
|
|
||||||
|
|
||||||
const directionKey = new Map<string, number>();
|
|
||||||
for (const route of routes) {
|
|
||||||
const num = (route.route_sys_number ?? "").trim();
|
|
||||||
if (!num) continue;
|
|
||||||
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
|
|
||||||
const key = `${num}|${route.route_direction}|${cityId}`;
|
|
||||||
directionKey.set(key, (directionKey.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
for (const [key, count] of directionKey) {
|
|
||||||
if (count > 1) {
|
|
||||||
const [num, dir] = key.split("|");
|
|
||||||
const dirLabel = dir === "true" ? "прямой" : "обратный";
|
|
||||||
duplicateMessages.push(
|
|
||||||
`Дублируется маршрут №${num} (${dirLabel})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cityPerNumber = new Map<string, Set<number>>();
|
|
||||||
for (const route of routes) {
|
|
||||||
const num = (route.route_sys_number ?? "").trim();
|
|
||||||
if (!num) continue;
|
|
||||||
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
|
|
||||||
if (!cityPerNumber.has(num)) {
|
|
||||||
cityPerNumber.set(num, new Set());
|
|
||||||
}
|
|
||||||
cityPerNumber.get(num)!.add(cityId);
|
|
||||||
}
|
|
||||||
for (const [num, cities] of cityPerNumber) {
|
|
||||||
if (cities.size > 1) {
|
|
||||||
duplicateMessages.push(
|
|
||||||
`Маршрут №${num} присутствует в нескольких городах`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duplicateMessages.length > 0) {
|
|
||||||
setDuplicateRouteNumbers(duplicateMessages);
|
|
||||||
setDuplicateWarningOpen(true);
|
|
||||||
} else {
|
|
||||||
await startExport();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await startExport();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-[400px] flex justify-center items-center">
|
|
||||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
|
||||||
Назад
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold">Создание экспорта медиа</h1>
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
|
||||||
<TextField
|
|
||||||
className="w-full"
|
|
||||||
label="Название"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
error={!!nameError}
|
|
||||||
helperText={nameError ?? " "}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setName(val);
|
|
||||||
const trimmed = val.trim();
|
|
||||||
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
|
|
||||||
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
|
|
||||||
setNameError("Название должно начинаться с названия существующего города");
|
|
||||||
} else {
|
|
||||||
setNameError(null);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
disabled={isLoading || !name.trim()}
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
className="w-min flex gap-2 items-center"
|
|
||||||
startIcon={<Save size={20} />}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isLoading || !exportNameRegex.test(name.trim())}
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -209,45 +87,6 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={duplicateWarningOpen}
|
|
||||||
onClose={() => !isLoading && setDuplicateWarningOpen(false)}
|
|
||||||
maxWidth="sm"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<p className="mb-3">
|
|
||||||
Обнаружены маршруты с одинаковыми номерами трассы. Это может привести к
|
|
||||||
некорректным данным в экспорте.
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-5">
|
|
||||||
{duplicateRouteNumbers.map((msg, i) => (
|
|
||||||
<li key={i}>{msg}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
onClick={() => setDuplicateWarningOpen(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
sx={{ backgroundColor: "#795548", "&:hover": { backgroundColor: "#5D4037" } }}
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={async () => {
|
|
||||||
setDuplicateWarningOpen(false);
|
|
||||||
await startExport();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Продолжить экспорт
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, languageStore, snapshotStore, cityStore, vehicleStore, SearchInput } from "@shared";
|
import { authStore, languageStore, snapshotStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||||
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
||||||
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
|
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
||||||
|
|
||||||
const LOW_STORAGE_THRESHOLD_GB = 10;
|
const LOW_STORAGE_THRESHOLD_GB = 10;
|
||||||
|
|
||||||
function escapeRegex(s: string) {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExportNameRegex(cityNames: string[]): RegExp {
|
|
||||||
if (!cityNames.length) return /.+/;
|
|
||||||
const pattern = cityNames.map(escapeRegex).join("|");
|
|
||||||
return new RegExp(`^(${pattern})_.+$`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEGMENT_COLORS = [
|
const SEGMENT_COLORS = [
|
||||||
"#FF3B30",
|
"#FF3B30",
|
||||||
"#FF9500",
|
"#FF9500",
|
||||||
@@ -43,14 +33,11 @@ export const SnapshotListPage = observer(() => {
|
|||||||
createEmptySnapshot,
|
createEmptySnapshot,
|
||||||
} = snapshotStore;
|
} = snapshotStore;
|
||||||
const canWriteDevices = authStore.canWrite("devices");
|
const canWriteDevices = authStore.canWrite("devices");
|
||||||
const canReadDevices = authStore.canRead("devices");
|
|
||||||
const canCreateSnapshot =
|
const canCreateSnapshot =
|
||||||
authStore.hasRole("snapshot_create") && canWriteDevices;
|
authStore.hasRole("snapshot_create") && canWriteDevices;
|
||||||
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
|
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
|
||||||
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [isSnapshotOnDeviceWarning, setIsSnapshotOnDeviceWarning] = useState(false);
|
|
||||||
const [devicesWithSnapshot, setDevicesWithSnapshot] = useState<string[]>([]);
|
|
||||||
const [rowId, setRowId] = useState<string | null>(null);
|
const [rowId, setRowId] = useState<string | null>(null);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||||
@@ -58,12 +45,6 @@ export const SnapshotListPage = observer(() => {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
|
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
|
||||||
const [emptySnapshotName, setEmptySnapshotName] = useState("");
|
const [emptySnapshotName, setEmptySnapshotName] = useState("");
|
||||||
const [emptySnapshotNameError, setEmptySnapshotNameError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const exportNameRegex = useMemo(() => {
|
|
||||||
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
|
|
||||||
return buildExportNameRegex(names);
|
|
||||||
}, [cityStore.cities["ru"].data.length]);
|
|
||||||
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
|
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
|
||||||
const [paginationModel, setPaginationModel] = useState({
|
const [paginationModel, setPaginationModel] = useState({
|
||||||
page: 0,
|
page: 0,
|
||||||
@@ -80,11 +61,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSnapshots = async () => {
|
const fetchSnapshots = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const promises: Promise<void>[] = [getSnapshots(), getStorageInfo()];
|
await Promise.all([getSnapshots(), getStorageInfo()]);
|
||||||
if (canReadDevices && !vehicleStore.vehicles.loaded) {
|
|
||||||
promises.push(vehicleStore.getVehicles());
|
|
||||||
}
|
|
||||||
await Promise.all(promises);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchSnapshots();
|
fetchSnapshots();
|
||||||
@@ -99,26 +76,6 @@ export const SnapshotListPage = observer(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
|
||||||
field: "color",
|
|
||||||
headerName: "",
|
|
||||||
width: 28,
|
|
||||||
sortable: false,
|
|
||||||
disableColumnMenu: true,
|
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
|
||||||
<div className="flex items-center justify-center h-full w-full">
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
backgroundColor: params.value,
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
field: "name",
|
field: "name",
|
||||||
headerName: "Название",
|
headerName: "Название",
|
||||||
@@ -131,7 +88,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "created_at",
|
field: "created_at",
|
||||||
headerName: "Дата создания (UTC+03:00, MSK)",
|
headerName: "Дата создания",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
return <div>{params.value ? params.value : "-"}</div>;
|
return <div>{params.value ? params.value : "-"}</div>;
|
||||||
@@ -160,7 +117,6 @@ export const SnapshotListPage = observer(() => {
|
|||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button
|
<button
|
||||||
title="Восстановить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsRestoreModalOpen(true);
|
setIsRestoreModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -169,22 +125,9 @@ export const SnapshotListPage = observer(() => {
|
|||||||
<DatabaseBackup size={20} className="text-blue-500" />
|
<DatabaseBackup size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const snapshotId = params.row.id;
|
|
||||||
if (canReadDevices) {
|
|
||||||
const devicesUsing = vehicleStore.vehicles.data
|
|
||||||
.filter(v => v.vehicle.current_snapshot_uuid === snapshotId)
|
|
||||||
.map(v => v.vehicle.tail_number || v.vehicle.uuid || `ID ${v.vehicle.id}`);
|
|
||||||
if (devicesUsing.length > 0) {
|
|
||||||
setDevicesWithSnapshot(devicesUsing);
|
|
||||||
setIsSnapshotOnDeviceWarning(true);
|
|
||||||
setRowId(snapshotId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(snapshotId);
|
setRowId(params.row.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={20} className="text-red-500" />
|
<Trash2 size={20} className="text-red-500" />
|
||||||
@@ -207,13 +150,12 @@ export const SnapshotListPage = observer(() => {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query),
|
.includes(query),
|
||||||
)
|
)
|
||||||
.map((snapshot, index) => ({
|
.map((snapshot) => ({
|
||||||
id: snapshot.ID,
|
id: snapshot.ID,
|
||||||
name: snapshot.Name,
|
name: snapshot.Name,
|
||||||
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
||||||
created_at: formatCreationTime(snapshot.CreationTime),
|
created_at: formatCreationTime(snapshot.CreationTime),
|
||||||
occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
|
occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
|
||||||
color: SEGMENT_COLORS[index % SEGMENT_COLORS.length],
|
|
||||||
}));
|
}));
|
||||||
}, [snapshots, searchQuery]);
|
}, [snapshots, searchQuery]);
|
||||||
|
|
||||||
@@ -236,11 +178,10 @@ export const SnapshotListPage = observer(() => {
|
|||||||
disabled={isLowStorage}
|
disabled={isLowStorage}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEmptySnapshotName("");
|
setEmptySnapshotName("");
|
||||||
setEmptySnapshotNameError(null);
|
|
||||||
setIsEmptySnapshotModalOpen(true);
|
setIsEmptySnapshotModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Создать пустой экспорт
|
Создать пустой снапшот
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canCreateSnapshot && (
|
{canCreateSnapshot && (
|
||||||
@@ -262,7 +203,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
|
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
|
||||||
{rows.map((row) => {
|
{rows.map((row, i) => {
|
||||||
const pct =
|
const pct =
|
||||||
row.occupied_disk_space_gb != null && totalGB > 0
|
row.occupied_disk_space_gb != null && totalGB > 0
|
||||||
? (row.occupied_disk_space_gb / totalGB) * 100
|
? (row.occupied_disk_space_gb / totalGB) * 100
|
||||||
@@ -273,7 +214,8 @@ export const SnapshotListPage = observer(() => {
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
style={{
|
style={{
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
backgroundColor: row.color,
|
backgroundColor:
|
||||||
|
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||||
}}
|
}}
|
||||||
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
|
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
|
||||||
/>
|
/>
|
||||||
@@ -291,7 +233,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
|
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
|
||||||
{rows.map((row) => {
|
{rows.map((row, i) => {
|
||||||
if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
|
if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
|
||||||
return null;
|
return null;
|
||||||
return (
|
return (
|
||||||
@@ -301,7 +243,10 @@ export const SnapshotListPage = observer(() => {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2.5 h-2.5 rounded-full"
|
className="inline-block w-2.5 h-2.5 rounded-full"
|
||||||
style={{ backgroundColor: row.color }}
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{row.name}
|
{row.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -335,9 +280,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -382,26 +325,14 @@ export const SnapshotListPage = observer(() => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
>
|
>
|
||||||
<DialogTitle>Создать пустой экспорт</DialogTitle>
|
<DialogTitle>Создать пустой снапшот</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Название"
|
label="Название"
|
||||||
value={emptySnapshotName}
|
value={emptySnapshotName}
|
||||||
error={!!emptySnapshotNameError}
|
onChange={(e) => setEmptySnapshotName(e.target.value)}
|
||||||
helperText={emptySnapshotNameError ?? " "}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setEmptySnapshotName(val);
|
|
||||||
const trimmed = val.trim();
|
|
||||||
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
|
|
||||||
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
|
|
||||||
setEmptySnapshotNameError("Название должно начинаться с названия существующего города");
|
|
||||||
} else {
|
|
||||||
setEmptySnapshotNameError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
margin="normal"
|
margin="normal"
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -411,7 +342,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!exportNameRegex.test(emptySnapshotName.trim()) || isCreatingEmpty}
|
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setIsCreatingEmpty(true);
|
setIsCreatingEmpty(true);
|
||||||
try {
|
try {
|
||||||
@@ -428,36 +359,6 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={isSnapshotOnDeviceWarning}
|
|
||||||
onClose={() => setIsSnapshotOnDeviceWarning(false)}
|
|
||||||
fullWidth
|
|
||||||
maxWidth="xs"
|
|
||||||
>
|
|
||||||
<DialogTitle>Удаление невозможно</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
|
||||||
Этот экспорт загружен на устройства. Удалите или замените экспорт на
|
|
||||||
устройствах перед удалением.
|
|
||||||
</Alert>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Typography variant="body2" fontWeight={600} gutterBottom>
|
|
||||||
Устройства:
|
|
||||||
</Typography>
|
|
||||||
{devicesWithSnapshot.map((name, i) => (
|
|
||||||
<Typography key={i} variant="body2">
|
|
||||||
• {name}
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setIsSnapshotOnDeviceWarning(false)}>
|
|
||||||
Закрыть
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<SnapshotRestore
|
<SnapshotRestore
|
||||||
open={isRestoreModalOpen}
|
open={isRestoreModalOpen}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
mediaStore,
|
mediaStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
useSelectedCity,
|
useSelectedCity,
|
||||||
selectedCityStore,
|
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
@@ -33,12 +32,6 @@ import {
|
|||||||
|
|
||||||
export const StationCreatePage = observer(() => {
|
export const StationCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
mediaStore,
|
mediaStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
@@ -35,12 +34,6 @@ import { LinkedSights } from "../LinkedSights";
|
|||||||
|
|
||||||
export const StationEditPage = observer(() => {
|
export const StationEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const StationListPage = observer(() => {
|
|||||||
loadSightCounts(stationIds);
|
loadSightCounts(stationIds);
|
||||||
};
|
};
|
||||||
fetchStations();
|
fetchStations();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -86,13 +86,13 @@ export const StationListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "sightCount",
|
field: "sightCount",
|
||||||
headerName: "Привязки",
|
headerName: "Достопримечательности",
|
||||||
width: 180,
|
width: 180,
|
||||||
align: "center" as const,
|
align: "center" as const,
|
||||||
headerAlign: "center" as const,
|
headerAlign: "center" as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
renderHeader: (params) => (
|
renderHeader: (params) => (
|
||||||
<Tooltip title="Отображает количество привязок">
|
<Tooltip title="Количество привязанных достопримечательностей">
|
||||||
<span>{params.colDef.headerName}</span>
|
<span>{params.colDef.headerName}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -114,7 +114,7 @@ export const StationListPage = observer(() => {
|
|||||||
headerAlign: "center" as const,
|
headerAlign: "center" as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
renderHeader: (params) => (
|
renderHeader: (params) => (
|
||||||
<Tooltip title="Отображает подтверждение добавленных пересадок">
|
<Tooltip title="Подтверждение добавленных пересадок">
|
||||||
<span>{params.colDef.headerName}</span>
|
<span>{params.colDef.headerName}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -141,7 +141,7 @@ export const StationListPage = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
{canWriteStations && (
|
{canWriteStations && (
|
||||||
<button title="Редактировать" onClick={() => navigate(`/station/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -151,14 +151,13 @@ export const StationListPage = observer(() => {
|
|||||||
setSelectedStationId(params.row.id);
|
setSelectedStationId(params.row.id);
|
||||||
setIsTransfersModalOpen(true);
|
setIsTransfersModalOpen(true);
|
||||||
}}
|
}}
|
||||||
title="Управление пересадками"
|
title="Редактировать пересадки"
|
||||||
>
|
>
|
||||||
<Route size={20} className="text-purple-500" />
|
<Route size={20} className="text-purple-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canWriteStations && (
|
{canWriteStations && (
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -175,12 +174,9 @@ export const StationListPage = observer(() => {
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const { selectedCityId } = selectedCityStore;
|
const { selectedCityId } = selectedCityStore;
|
||||||
if (!selectedCityId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
return stationLists[language].data
|
return stationLists[language].data
|
||||||
.filter((station: any) => station.city_id === selectedCityId)
|
.filter((station: any) => !selectedCityId || station.city_id === selectedCityId)
|
||||||
.filter(
|
.filter(
|
||||||
(station: any) =>
|
(station: any) =>
|
||||||
!query ||
|
!query ||
|
||||||
@@ -206,10 +202,7 @@ export const StationListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Остановки</h1>
|
<h1 className="text-2xl">Остановки</h1>
|
||||||
{canWriteStations && (
|
{canWriteStations && (
|
||||||
<CreateButton
|
<CreateButton label="Создать остановку" path="/station/create" />
|
||||||
label="Создать остановку"
|
|
||||||
path="/station/create"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,9 +218,7 @@ export const StationListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -286,13 +277,7 @@ export const StationListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? <CircularProgress size={20} /> : "Нет остановок"}
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : !selectedCityStore.selectedCityId ? (
|
|
||||||
"Выберите город"
|
|
||||||
) : (
|
|
||||||
"Нет остановок"
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Button, Paper, TextField } from "@mui/material";
|
||||||
Button,
|
|
||||||
Paper,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Divider,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -17,10 +10,9 @@ import {
|
|||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ImageUploadCard, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
|
import { ImageUploadCard } from "@widgets";
|
||||||
|
|
||||||
export const UserCreatePage = observer(() => {
|
export const UserCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -34,38 +26,13 @@ export const UserCreatePage = observer(() => {
|
|||||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const [localRoles, setLocalRoles] = useState<string[]>(
|
|
||||||
createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mediaStore.getMedia();
|
mediaStore.getMedia();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
|
|
||||||
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
|
|
||||||
if (isAdmin !== createUserData.is_admin) {
|
|
||||||
setCreateUserData(
|
|
||||||
createUserData.name || "",
|
|
||||||
createUserData.email || "",
|
|
||||||
createUserData.password || "",
|
|
||||||
isAdmin,
|
|
||||||
createUserData.icon
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [localRoles]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// Убеждаемся, что роли в сторе обновлены перед созданием
|
|
||||||
userStore.createUserData.roles = localRoles;
|
|
||||||
await createUser();
|
await createUser();
|
||||||
toast.success("Пользователь успешно создан");
|
toast.success("Пользователь успешно создан");
|
||||||
navigate("/user");
|
navigate("/user");
|
||||||
@@ -100,15 +67,18 @@ export const UserCreatePage = observer(() => {
|
|||||||
: selectedMedia?.id ?? createUserData.icon ?? null;
|
: selectedMedia?.id ?? createUserData.icon ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full p-6 flex flex-col gap-8">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}>
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
Назад
|
Назад
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="flex flex-col gap-6">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
<Typography variant="h6">Основные данные</Typography>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Имя"
|
label="Имя"
|
||||||
@@ -146,7 +116,6 @@ export const UserCreatePage = observer(() => {
|
|||||||
label="Пароль"
|
label="Пароль"
|
||||||
value={createUserData.password || ""}
|
value={createUserData.password || ""}
|
||||||
required
|
required
|
||||||
type="password"
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCreateUserData(
|
setCreateUserData(
|
||||||
createUserData.name || "",
|
createUserData.name || "",
|
||||||
@@ -158,7 +127,7 @@ export const UserCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 max-w-[300px]">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Аватар"
|
title="Аватар"
|
||||||
imageKey="thumbnail"
|
imageKey="thumbnail"
|
||||||
@@ -187,64 +156,14 @@ export const UserCreatePage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<Typography variant="h6">Права доступа</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setCreateUserData(
|
|
||||||
createUserData.name || "",
|
|
||||||
createUserData.email || "",
|
|
||||||
createUserData.password || "",
|
|
||||||
true,
|
|
||||||
createUserData.icon
|
|
||||||
);
|
|
||||||
const next: string[] = [];
|
|
||||||
for (const { key } of ROLE_RESOURCES) {
|
|
||||||
next.push(`${key}_rw`);
|
|
||||||
}
|
|
||||||
next.push("snapshot_create");
|
|
||||||
setLocalRoles(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Полный доступ (admin)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setCreateUserData(
|
|
||||||
createUserData.name || "",
|
|
||||||
createUserData.email || "",
|
|
||||||
createUserData.password || "",
|
|
||||||
false,
|
|
||||||
createUserData.icon
|
|
||||||
);
|
|
||||||
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Администратор ТО
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
|
|
||||||
<RolesHintTable />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
className="self-end w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={
|
disabled={
|
||||||
isLoading || !createUserData.name || !createUserData.password || !createUserData.email
|
isLoading || !createUserData.name || !createUserData.password
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -253,6 +172,7 @@ export const UserCreatePage = observer(() => {
|
|||||||
"Создать"
|
"Создать"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
Paper,
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
Divider,
|
Divider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
@@ -22,12 +31,45 @@ import {
|
|||||||
authStore,
|
authStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
selectedCityStore,
|
|
||||||
type User,
|
type User,
|
||||||
type UserCity,
|
type UserCity,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ImageUploadCard, DeleteModal, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
|
import { ImageUploadCard, DeleteModal } from "@widgets";
|
||||||
|
|
||||||
|
const ROLE_RESOURCES = [
|
||||||
|
{ key: "snapshot", label: "Экспорт" },
|
||||||
|
{ key: "devices", label: "Устройства" },
|
||||||
|
{ key: "vehicles", label: "Транспорт" },
|
||||||
|
{ key: "users", label: "Пользователи" },
|
||||||
|
{ key: "sights", label: "Достопримечательности" },
|
||||||
|
{ key: "stations", label: "Остановки" },
|
||||||
|
{ key: "routes", label: "Маршруты" },
|
||||||
|
{ key: "countries", label: "Страны" },
|
||||||
|
{ key: "cities", label: "Города" },
|
||||||
|
{ key: "carriers", label: "Перевозчики" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PermissionLevel = "none" | "ro" | "rw";
|
||||||
|
|
||||||
|
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
|
||||||
|
if (roles.includes(`${resource}_rw`)) return "rw";
|
||||||
|
if (roles.includes(`${resource}_ro`)) return "ro";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPermissionChange(
|
||||||
|
roles: string[],
|
||||||
|
resource: string,
|
||||||
|
level: PermissionLevel,
|
||||||
|
): string[] {
|
||||||
|
const filtered = roles.filter(
|
||||||
|
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
|
||||||
|
);
|
||||||
|
if (level === "ro") return [...filtered, `${resource}_ro`];
|
||||||
|
if (level === "rw") return [...filtered, `${resource}_rw`];
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
export const UserEditPage = observer(() => {
|
export const UserEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -51,29 +93,10 @@ export const UserEditPage = observer(() => {
|
|||||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
|
|
||||||
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
|
|
||||||
if (isAdmin !== editUserData.is_admin) {
|
|
||||||
setEditUserData(
|
|
||||||
editUserData.name || "",
|
|
||||||
editUserData.email || "",
|
|
||||||
editUserData.password || "",
|
|
||||||
isAdmin,
|
|
||||||
editUserData.icon || ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [localRoles]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -288,36 +311,175 @@ export const UserEditPage = observer(() => {
|
|||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<Typography variant="h6">Права доступа</Typography>
|
<Typography variant="h6">Права доступа</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 1 }}>
|
<FormControlLabel
|
||||||
<Button
|
control={
|
||||||
variant="outlined"
|
<Checkbox
|
||||||
size="small"
|
checked={localRoles.includes("admin")}
|
||||||
onClick={() => {
|
onChange={(e) => {
|
||||||
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", true, editUserData.icon || "");
|
if (e.target.checked) {
|
||||||
const next: string[] = [];
|
setLocalRoles((prev) => {
|
||||||
|
let next = prev.filter((r) => r !== "admin");
|
||||||
for (const { key } of ROLE_RESOURCES) {
|
for (const { key } of ROLE_RESOURCES) {
|
||||||
|
next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`);
|
||||||
next.push(`${key}_rw`);
|
next.push(`${key}_rw`);
|
||||||
}
|
}
|
||||||
|
if (!next.includes("snapshot_create")) {
|
||||||
next.push("snapshot_create");
|
next.push("snapshot_create");
|
||||||
setLocalRoles(next);
|
}
|
||||||
|
if (!next.includes("devices_maintenance_rw")) {
|
||||||
|
next.push("devices_maintenance_rw");
|
||||||
|
}
|
||||||
|
next.push("admin");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
Полный доступ (admin)
|
}
|
||||||
</Button>
|
label="Полный доступ (admin)"
|
||||||
<Button
|
/>
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", false, editUserData.icon || "");
|
|
||||||
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Администратор ТО
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
|
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||||
<RolesHintTable />
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: "action.hover" }}>
|
||||||
|
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
|
||||||
|
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
|
||||||
|
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
|
||||||
|
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
|
||||||
|
<TableCell align="center" sx={{ fontWeight: 600 }}>
|
||||||
|
Доп. права
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{ROLE_RESOURCES.map(({ key, label }) => {
|
||||||
|
const level = getPermissionLevel(localRoles, key);
|
||||||
|
const isSnapshotResource = key === "snapshot";
|
||||||
|
|
||||||
|
const handleChange = (val: string) => {
|
||||||
|
setLocalRoles((prev) => {
|
||||||
|
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
|
||||||
|
|
||||||
|
if (key === "devices") {
|
||||||
|
updated = applyPermissionChange(
|
||||||
|
updated,
|
||||||
|
"vehicles",
|
||||||
|
val as PermissionLevel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRw = ROLE_RESOURCES.every(({ key: k }) =>
|
||||||
|
updated.includes(`${k}_rw`),
|
||||||
|
);
|
||||||
|
if (allRw && !updated.includes("admin")) {
|
||||||
|
const next = [...updated];
|
||||||
|
if (!next.includes("snapshot_create")) {
|
||||||
|
next.push("snapshot_create");
|
||||||
|
}
|
||||||
|
next.push("admin");
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
if (!allRw) {
|
||||||
|
return updated.filter((r) => r !== "admin");
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDevicesResource = key === "devices";
|
||||||
|
|
||||||
|
const handleSnapshotCreateChange = (checked: boolean) => {
|
||||||
|
if (!isSnapshotResource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalRoles((prev) => {
|
||||||
|
const withoutSnapshotCreate = prev.filter(
|
||||||
|
(role) => role !== "snapshot_create"
|
||||||
|
);
|
||||||
|
return checked
|
||||||
|
? [...withoutSnapshotCreate, "snapshot_create"]
|
||||||
|
: withoutSnapshotCreate;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaintenanceChange = (checked: boolean) => {
|
||||||
|
setLocalRoles((prev) => {
|
||||||
|
const without = prev.filter((r) => r !== "devices_maintenance_rw");
|
||||||
|
return checked ? [...without, "devices_maintenance_rw"] : without;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={key} hover>
|
||||||
|
<TableCell>{label}</TableCell>
|
||||||
|
<TableCell align="center" padding="checkbox">
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||||
|
>
|
||||||
|
<Radio value="none" size="small" />
|
||||||
|
</RadioGroup>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" padding="checkbox">
|
||||||
|
{isSnapshotResource ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
-
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||||
|
>
|
||||||
|
<Radio value="ro" size="small" />
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" padding="checkbox">
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||||
|
>
|
||||||
|
<Radio value="rw" size="small" />
|
||||||
|
</RadioGroup>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" padding="checkbox">
|
||||||
|
{isSnapshotResource ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={localRoles.includes("snapshot_create")}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSnapshotCreateChange(e.target.checked)
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
title="Разрешает создавать новые снапшоты"
|
||||||
|
/>
|
||||||
|
) : isDevicesResource ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={localRoles.includes("devices_maintenance_rw")}
|
||||||
|
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
title="Разрешает переводить устройства в режим технического обслуживания"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
-
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -92,11 +92,10 @@ export const UserListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Редактировать" onClick={() => navigate(`/user/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/user/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -147,9 +146,7 @@ export const UserListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -157,11 +154,6 @@ export const UserListPage = observer(() => {
|
|||||||
checkboxSelection={canWriteUsers}
|
checkboxSelection={canWriteUsers}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onRowDoubleClick={(params) => {
|
|
||||||
if (canWriteUsers) {
|
|
||||||
navigate(`/user/${params.row.id}/edit`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import {
|
|||||||
VEHICLE_TYPES,
|
VEHICLE_TYPES,
|
||||||
carrierStore,
|
carrierStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
cityStore,
|
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
@@ -28,18 +26,11 @@ export const VehicleCreatePage = observer(() => {
|
|||||||
const [type, setType] = useState("");
|
const [type, setType] = useState("");
|
||||||
const [carrierId, setCarrierId] = useState<number | null>(null);
|
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [cityId, setCityId] = useState<number | null>(selectedCityStore.selectedCityId);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
carrierStore.getCarriers(language);
|
carrierStore.getCarriers(language);
|
||||||
cityStore.getCities("ru");
|
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
@@ -52,7 +43,6 @@ export const VehicleCreatePage = observer(() => {
|
|||||||
?.full_name as string,
|
?.full_name as string,
|
||||||
carrierId!,
|
carrierId!,
|
||||||
model || undefined,
|
model || undefined,
|
||||||
cityId ?? undefined,
|
|
||||||
);
|
);
|
||||||
toast.success("Транспорт успешно создан");
|
toast.success("Транспорт успешно создан");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -83,11 +73,12 @@ export const VehicleCreatePage = observer(() => {
|
|||||||
onChange={(e) => setTailNumber(e.target.value)}
|
onChange={(e) => setTailNumber(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl fullWidth required>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Тип</InputLabel>
|
<InputLabel>Тип</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={type}
|
value={type}
|
||||||
label="Тип"
|
label="Тип"
|
||||||
|
required
|
||||||
onChange={(e) => setType(e.target.value)}
|
onChange={(e) => setType(e.target.value)}
|
||||||
>
|
>
|
||||||
{VEHICLE_TYPES.map((type) => (
|
{VEHICLE_TYPES.map((type) => (
|
||||||
@@ -98,11 +89,12 @@ export const VehicleCreatePage = observer(() => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl fullWidth required>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Перевозчик</InputLabel>
|
<InputLabel>Перевозчик</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={carrierId || ""}
|
value={carrierId || ""}
|
||||||
label="Перевозчик"
|
label="Перевозчик"
|
||||||
|
required
|
||||||
onChange={(e) => setCarrierId(e.target.value as number)}
|
onChange={(e) => setCarrierId(e.target.value as number)}
|
||||||
>
|
>
|
||||||
{carrierStore.carriers[language].data?.map((carrier) => (
|
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||||
@@ -121,27 +113,12 @@ export const VehicleCreatePage = observer(() => {
|
|||||||
placeholder="Произвольное название модели"
|
placeholder="Произвольное название модели"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl fullWidth required>
|
|
||||||
<InputLabel>Город</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={cityId ?? ""}
|
|
||||||
label="Город"
|
|
||||||
onChange={(e) => setCityId(e.target.value ? Number(e.target.value) : null)}
|
|
||||||
>
|
|
||||||
{cityStore.cities.ru.data.map((city) => (
|
|
||||||
<MenuItem key={city.id} value={city.id}>
|
|
||||||
{city.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
className="w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={isLoading || !tailNumber || !type || !carrierId || !cityId}
|
disabled={isLoading || !tailNumber || !type || !carrierId}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import {
|
|||||||
VEHICLE_TYPES,
|
VEHICLE_TYPES,
|
||||||
vehicleStore,
|
vehicleStore,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
cityStore,
|
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
@@ -40,11 +38,6 @@ export const VehicleEditPage = observer(() => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Устанавливаем русский язык при загрузке страницы
|
// Устанавливаем русский язык при загрузке страницы
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
@@ -61,7 +54,6 @@ export const VehicleEditPage = observer(() => {
|
|||||||
try {
|
try {
|
||||||
await getVehicle(Number(id));
|
await getVehicle(Number(id));
|
||||||
await getCarriers(language);
|
await getCarriers(language);
|
||||||
await cityStore.getCities("ru");
|
|
||||||
|
|
||||||
setEditVehicleData({
|
setEditVehicleData({
|
||||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
|
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
|
||||||
@@ -71,7 +63,6 @@ export const VehicleEditPage = observer(() => {
|
|||||||
model: vehicle[Number(id)]?.vehicle.model ?? "",
|
model: vehicle[Number(id)]?.vehicle.model ?? "",
|
||||||
snapshot_update_blocked:
|
snapshot_update_blocked:
|
||||||
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
|
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
|
||||||
city_id: vehicle[Number(id)]?.vehicle.city_id ?? selectedCityStore.selectedCityId ?? undefined,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingData(false);
|
setIsLoadingData(false);
|
||||||
@@ -134,11 +125,12 @@ export const VehicleEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl fullWidth required>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Тип</InputLabel>
|
<InputLabel>Тип</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={editVehicleData.type}
|
value={editVehicleData.type}
|
||||||
label="Тип"
|
label="Тип"
|
||||||
|
required
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditVehicleData({ ...editVehicleData, type: e.target.value })
|
setEditVehicleData({ ...editVehicleData, type: e.target.value })
|
||||||
}
|
}
|
||||||
@@ -151,11 +143,12 @@ export const VehicleEditPage = observer(() => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl fullWidth required>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Перевозчик</InputLabel>
|
<InputLabel>Перевозчик</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={editVehicleData.carrier_id}
|
value={editVehicleData.carrier_id}
|
||||||
label="Перевозчик"
|
label="Перевозчик"
|
||||||
|
required
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditVehicleData({
|
setEditVehicleData({
|
||||||
...editVehicleData,
|
...editVehicleData,
|
||||||
@@ -184,26 +177,6 @@ export const VehicleEditPage = observer(() => {
|
|||||||
placeholder="Произвольное название модели"
|
placeholder="Произвольное название модели"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl fullWidth required>
|
|
||||||
<InputLabel>Город</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={editVehicleData.city_id ?? ""}
|
|
||||||
label="Город"
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditVehicleData({
|
|
||||||
...editVehicleData,
|
|
||||||
city_id: e.target.value ? Number(e.target.value) : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{cityStore.cities.ru.data.map((city) => (
|
|
||||||
<MenuItem key={city.id} value={city.id}>
|
|
||||||
{city.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -229,8 +202,7 @@ export const VehicleEditPage = observer(() => {
|
|||||||
isLoading ||
|
isLoading ||
|
||||||
!editVehicleData.tail_number ||
|
!editVehicleData.tail_number ||
|
||||||
!editVehicleData.type ||
|
!editVehicleData.type ||
|
||||||
!editVehicleData.carrier_id ||
|
!editVehicleData.carrier_id
|
||||||
!editVehicleData.city_id
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput, selectedCityStore, cityStore } from "@shared";
|
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -11,7 +11,7 @@ import { Box, CircularProgress } from "@mui/material";
|
|||||||
|
|
||||||
export const VehicleListPage = observer(() => {
|
export const VehicleListPage = observer(() => {
|
||||||
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
||||||
const { getCarriers } = carrierStore;
|
const { carriers, getCarriers } = carrierStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||||
@@ -31,11 +31,10 @@ export const VehicleListPage = observer(() => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await getVehicles();
|
await getVehicles();
|
||||||
await getCarriers(language);
|
await getCarriers(language);
|
||||||
await cityStore.getCities("ru");
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -115,16 +114,15 @@ export const VehicleListPage = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<button title="Редактировать" onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button title="Просмотр" onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -139,13 +137,9 @@ export const VehicleListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { selectedCityId } = selectedCityStore;
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!selectedCityId) return [];
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
return (vehicles.data ?? [])
|
return (vehicles.data ?? [])
|
||||||
.filter((vehicle) => vehicle.vehicle.city_id === selectedCityId)
|
|
||||||
.filter(
|
.filter(
|
||||||
(vehicle) =>
|
(vehicle) =>
|
||||||
!query ||
|
!query ||
|
||||||
@@ -157,9 +151,11 @@ export const VehicleListPage = observer(() => {
|
|||||||
tail_number: vehicle.vehicle.tail_number,
|
tail_number: vehicle.vehicle.tail_number,
|
||||||
type: vehicle.vehicle.type,
|
type: vehicle.vehicle.type,
|
||||||
carrier: vehicle.vehicle.carrier,
|
carrier: vehicle.vehicle.carrier,
|
||||||
city: cityStore.cities.ru.data.find((c) => c.id === vehicle.vehicle.city_id)?.name,
|
city: carriers[language].data?.find(
|
||||||
|
(carrier) => carrier.id === vehicle.vehicle.carrier_id
|
||||||
|
)?.city,
|
||||||
}));
|
}));
|
||||||
}, [vehicles.data, selectedCityId, searchQuery]);
|
}, [vehicles.data, carriers[language].data, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -173,9 +169,7 @@ export const VehicleListPage = observer(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
)}
|
|
||||||
|
|
||||||
{canWriteVehicles && ids.length > 0 && (
|
{canWriteVehicles && ids.length > 0 && (
|
||||||
<div className="flex justify-end mb-5 duration-300">
|
<div className="flex justify-end mb-5 duration-300">
|
||||||
@@ -229,8 +223,6 @@ export const VehicleListPage = observer(() => {
|
|||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CircularProgress size={20} />
|
<CircularProgress size={20} />
|
||||||
) : !selectedCityId ? (
|
|
||||||
"Выберите город"
|
|
||||||
) : (
|
) : (
|
||||||
"Нет транспортных средств"
|
"Нет транспортных средств"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||||
mediaStore,
|
|
||||||
MEDIA_TYPE_LABELS,
|
|
||||||
PreviewBackgroundToggle,
|
|
||||||
} from "@shared";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -33,8 +29,6 @@ export const PreviewMediaDialog = observer(
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
|
|
||||||
useState(false);
|
|
||||||
const media = mediaId
|
const media = mediaId
|
||||||
? mediaStore.media.find((m) => m.id === mediaId)
|
? mediaStore.media.find((m) => m.id === mediaId)
|
||||||
: null;
|
: null;
|
||||||
@@ -46,7 +40,6 @@ export const PreviewMediaDialog = observer(
|
|||||||
if (media) {
|
if (media) {
|
||||||
setMediaName(media.media_name);
|
setMediaName(media.media_name);
|
||||||
setMediaFilename(media.filename);
|
setMediaFilename(media.filename);
|
||||||
setIsPreviewBackgroundDark(false);
|
|
||||||
}
|
}
|
||||||
}, [media]);
|
}, [media]);
|
||||||
|
|
||||||
@@ -148,19 +141,8 @@ export const PreviewMediaDialog = observer(
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
position: "relative",
|
|
||||||
backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff",
|
|
||||||
transition: "background-color 150ms ease",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[1, 3, 4, 5].includes(media.media_type) && (
|
|
||||||
<PreviewBackgroundToggle
|
|
||||||
dark={isPreviewBackgroundDark}
|
|
||||||
onToggle={() =>
|
|
||||||
setIsPreviewBackgroundDark((value) => !value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MediaViewer
|
<MediaViewer
|
||||||
media={{
|
media={{
|
||||||
id: mediaId,
|
id: mediaId,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
clearBlobAndGLTFCache,
|
clearBlobAndGLTFCache,
|
||||||
authStore,
|
authStore,
|
||||||
snapshotStore,
|
snapshotStore,
|
||||||
PreviewBackgroundToggle,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
@@ -91,8 +90,6 @@ export const UploadMediaDialog = observer(
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
||||||
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
|
|
||||||
useState(false);
|
|
||||||
const previousMediaUrlRef = useRef<string | null>(null);
|
const previousMediaUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,26 +103,8 @@ export const UploadMediaDialog = observer(
|
|||||||
|
|
||||||
setMediaFile(initialFile);
|
setMediaFile(initialFile);
|
||||||
setMediaFilename(initialFile.name);
|
setMediaFilename(initialFile.name);
|
||||||
|
|
||||||
const extension = initialFile.name.split(".").pop()?.toLowerCase();
|
|
||||||
if (extension) {
|
|
||||||
if (["glb", "gltf"].includes(extension)) {
|
|
||||||
setAvailableMediaTypes([6]);
|
|
||||||
setMediaType(6);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
|
||||||
extension
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setAvailableMediaTypes([1, 3, 4, 5]);
|
|
||||||
setMediaType(1);
|
|
||||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
|
||||||
setAvailableMediaTypes([2]);
|
setAvailableMediaTypes([2]);
|
||||||
setMediaType(2);
|
setMediaType(2);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newBlobUrl = URL.createObjectURL(initialFile);
|
const newBlobUrl = URL.createObjectURL(initialFile);
|
||||||
setMediaUrl(newBlobUrl);
|
setMediaUrl(newBlobUrl);
|
||||||
previousMediaUrlRef.current = newBlobUrl;
|
previousMediaUrlRef.current = newBlobUrl;
|
||||||
@@ -350,7 +329,6 @@ export const UploadMediaDialog = observer(
|
|||||||
setMediaUrl(null);
|
setMediaUrl(null);
|
||||||
setMediaFile(null);
|
setMediaFile(null);
|
||||||
setIsPreviewLoaded(false);
|
setIsPreviewLoaded(false);
|
||||||
setIsPreviewBackgroundDark(false);
|
|
||||||
previousMediaUrlRef.current = null;
|
previousMediaUrlRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -420,18 +398,8 @@ export const UploadMediaDialog = observer(
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff",
|
|
||||||
transition: "background-color 150ms ease",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mediaUrl && mediaType !== 2 && mediaType !== 6 && (
|
|
||||||
<PreviewBackgroundToggle
|
|
||||||
dark={isPreviewBackgroundDark}
|
|
||||||
onToggle={() =>
|
|
||||||
setIsPreviewBackgroundDark((value) => !value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isPreviewLoaded && mediaUrl && (
|
{!isPreviewLoaded && mediaUrl && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export type Carrier = {
|
|||||||
id: number;
|
id: number;
|
||||||
short_name: string;
|
short_name: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
|
slogan: string;
|
||||||
city: string;
|
city: string;
|
||||||
city_id: number;
|
city_id: number;
|
||||||
preview_media?: string;
|
logo: string;
|
||||||
video_preview?: string;
|
// main_color: string;
|
||||||
main_color: string;
|
// left_color: string;
|
||||||
left_color: string;
|
// right_color: string;
|
||||||
right_color: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type CarrierData = {
|
type CarrierData = {
|
||||||
@@ -111,48 +111,38 @@ class CarrierStore {
|
|||||||
|
|
||||||
createCarrierData = {
|
createCarrierData = {
|
||||||
city_id: 0,
|
city_id: 0,
|
||||||
preview_media: "",
|
logo: "",
|
||||||
video_preview: "",
|
|
||||||
main_color: "",
|
|
||||||
left_color: "",
|
|
||||||
right_color: "",
|
|
||||||
ru: {
|
ru: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
slogan: "",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
slogan: "",
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
slogan: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
setCreateCarrierMedia = (
|
|
||||||
field: "preview_media" | "video_preview",
|
|
||||||
mediaId: string
|
|
||||||
) => {
|
|
||||||
this.createCarrierData[field] = mediaId;
|
|
||||||
};
|
|
||||||
|
|
||||||
setCreateCarrierData = (
|
setCreateCarrierData = (
|
||||||
fullName: string,
|
fullName: string,
|
||||||
shortName: string,
|
shortName: string,
|
||||||
cityId: number,
|
cityId: number,
|
||||||
language: Language,
|
slogan: string,
|
||||||
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
logoId: string,
|
||||||
|
language: Language
|
||||||
) => {
|
) => {
|
||||||
this.createCarrierData.city_id = cityId;
|
this.createCarrierData.city_id = cityId;
|
||||||
if (colors) {
|
this.createCarrierData.logo = logoId;
|
||||||
if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color;
|
|
||||||
if (colors.left_color !== undefined) this.createCarrierData.left_color = colors.left_color;
|
|
||||||
if (colors.right_color !== undefined) this.createCarrierData.right_color = colors.right_color;
|
|
||||||
}
|
|
||||||
this.createCarrierData[language] = {
|
this.createCarrierData[language] = {
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
short_name: shortName,
|
short_name: shortName,
|
||||||
|
slogan: slogan,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,15 +197,10 @@ class CarrierStore {
|
|||||||
short_name: (this.createCarrierData[language].short_name || "").trim(),
|
short_name: (this.createCarrierData[language].short_name || "").trim(),
|
||||||
city: cityName,
|
city: cityName,
|
||||||
city_id: this.createCarrierData.city_id,
|
city_id: this.createCarrierData.city_id,
|
||||||
...(this.createCarrierData.preview_media
|
slogan: (this.createCarrierData[language].slogan || "").trim(),
|
||||||
? { preview_media: this.createCarrierData.preview_media }
|
...(this.createCarrierData.logo
|
||||||
|
? { logo: this.createCarrierData.logo }
|
||||||
: {}),
|
: {}),
|
||||||
...(this.createCarrierData.video_preview
|
|
||||||
? { video_preview: this.createCarrierData.video_preview }
|
|
||||||
: {}),
|
|
||||||
...(this.createCarrierData.main_color ? { main_color: this.createCarrierData.main_color } : {}),
|
|
||||||
...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}),
|
|
||||||
...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await languageInstance(language).post("/carrier", payload);
|
const response = await languageInstance(language).post("/carrier", payload);
|
||||||
@@ -226,55 +211,52 @@ class CarrierStore {
|
|||||||
this.carriers[language].data.push(response.data);
|
this.carriers[language].data.push(response.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const lang of (["ru", "en", "zh"] as const).filter(
|
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
||||||
(item) => item !== language
|
|
||||||
)) {
|
|
||||||
const cityNameForLang = this.resolveCityName(
|
const cityNameForLang = this.resolveCityName(
|
||||||
this.createCarrierData.city_id,
|
this.createCarrierData.city_id,
|
||||||
lang
|
lang as Language
|
||||||
);
|
);
|
||||||
const localizedData = this.createCarrierData[lang];
|
|
||||||
const patchPayload = {
|
const patchPayload = {
|
||||||
full_name: (localizedData.full_name || "").trim(),
|
// @ts-ignore
|
||||||
short_name: (localizedData.short_name || "").trim(),
|
full_name: ((this.createCarrierData[lang as any].full_name as string) || "").trim(),
|
||||||
|
// @ts-ignore
|
||||||
|
short_name: ((this.createCarrierData[lang as any].short_name as string) || "").trim(),
|
||||||
city: cityNameForLang || cityName,
|
city: cityNameForLang || cityName,
|
||||||
city_id: this.createCarrierData.city_id,
|
city_id: this.createCarrierData.city_id,
|
||||||
...(this.createCarrierData.preview_media
|
// @ts-ignore
|
||||||
? { preview_media: this.createCarrierData.preview_media }
|
slogan: ((this.createCarrierData[lang as any].slogan as string) || "").trim(),
|
||||||
: {}),
|
...(this.createCarrierData.logo
|
||||||
...(this.createCarrierData.video_preview
|
? { logo: this.createCarrierData.logo }
|
||||||
? { video_preview: this.createCarrierData.video_preview }
|
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await languageInstance(lang).patch(
|
const response = await languageInstance(lang as Language).patch(
|
||||||
`/carrier/${carrierId}`,
|
`/carrier/${carrierId}`,
|
||||||
patchPayload
|
patchPayload
|
||||||
);
|
);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.carriers[lang].data.push(response.data);
|
this.carriers[lang as keyof Carriers].data.push(response.data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.createCarrierData = {
|
this.createCarrierData = {
|
||||||
city_id: 0,
|
city_id: 0,
|
||||||
preview_media: "",
|
logo: "",
|
||||||
video_preview: "",
|
|
||||||
main_color: "",
|
|
||||||
left_color: "",
|
|
||||||
right_color: "",
|
|
||||||
ru: {
|
ru: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
slogan: "",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
slogan: "",
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
slogan: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -283,46 +265,54 @@ class CarrierStore {
|
|||||||
ru: {
|
ru: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
|
||||||
|
// main_color: "",
|
||||||
|
// left_color: "",
|
||||||
|
// right_color: "",
|
||||||
|
slogan: "",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
|
||||||
|
// main_color: "",
|
||||||
|
// left_color: "",
|
||||||
|
// right_color: "",
|
||||||
|
slogan: "",
|
||||||
},
|
},
|
||||||
|
city_id: 0,
|
||||||
|
logo: "",
|
||||||
zh: {
|
zh: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
},
|
|
||||||
city_id: 0,
|
|
||||||
preview_media: "",
|
|
||||||
video_preview: "",
|
|
||||||
main_color: "",
|
|
||||||
left_color: "",
|
|
||||||
right_color: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
setEditCarrierMedia = (
|
// main_color: "",
|
||||||
field: "preview_media" | "video_preview",
|
// left_color: "",
|
||||||
mediaId: string
|
// right_color: "",
|
||||||
) => {
|
slogan: "",
|
||||||
this.editCarrierData[field] = mediaId;
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
setEditCarrierData = (
|
setEditCarrierData = (
|
||||||
fullName: string,
|
fullName: string,
|
||||||
shortName: string,
|
shortName: string,
|
||||||
cityId: number,
|
cityId: number,
|
||||||
language: Language,
|
// main_color: string,
|
||||||
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
// left_color: string,
|
||||||
|
// right_color: string,
|
||||||
|
slogan: string,
|
||||||
|
logoId: string,
|
||||||
|
language: Language
|
||||||
) => {
|
) => {
|
||||||
this.editCarrierData.city_id = cityId;
|
this.editCarrierData.city_id = cityId;
|
||||||
if (colors) {
|
this.editCarrierData.logo = logoId;
|
||||||
if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color;
|
|
||||||
if (colors.left_color !== undefined) this.editCarrierData.left_color = colors.left_color;
|
|
||||||
if (colors.right_color !== undefined) this.editCarrierData.right_color = colors.right_color;
|
|
||||||
}
|
|
||||||
this.editCarrierData[language] = {
|
this.editCarrierData[language] = {
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
short_name: shortName,
|
short_name: shortName,
|
||||||
|
// main_color: main_color,
|
||||||
|
// left_color: left_color,
|
||||||
|
// right_color: right_color,
|
||||||
|
slogan: slogan,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -333,13 +323,12 @@ class CarrierStore {
|
|||||||
...this.editCarrierData[lang],
|
...this.editCarrierData[lang],
|
||||||
full_name: (this.editCarrierData[lang].full_name || "").trim(),
|
full_name: (this.editCarrierData[lang].full_name || "").trim(),
|
||||||
short_name: (this.editCarrierData[lang].short_name || "").trim(),
|
short_name: (this.editCarrierData[lang].short_name || "").trim(),
|
||||||
|
slogan: (this.editCarrierData[lang].slogan || "").trim(),
|
||||||
city: cityName,
|
city: cityName,
|
||||||
city_id: this.editCarrierData.city_id,
|
city_id: this.editCarrierData.city_id,
|
||||||
preview_media: this.editCarrierData.preview_media || null,
|
...(this.editCarrierData.logo
|
||||||
video_preview: this.editCarrierData.video_preview || null,
|
? { logo: this.editCarrierData.logo }
|
||||||
...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}),
|
: {}),
|
||||||
...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}),
|
|
||||||
...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export type Route = {
|
|||||||
center_latitude: number;
|
center_latitude: number;
|
||||||
center_longitude: number;
|
center_longitude: number;
|
||||||
governor_appeal: number;
|
governor_appeal: number;
|
||||||
button_text?: string;
|
|
||||||
id: number;
|
id: number;
|
||||||
icon: string;
|
icon: string;
|
||||||
path: number[][];
|
path: number[][];
|
||||||
@@ -144,7 +143,6 @@ class RouteStore {
|
|||||||
center_latitude: "",
|
center_latitude: "",
|
||||||
center_longitude: "",
|
center_longitude: "",
|
||||||
governor_appeal: 0,
|
governor_appeal: 0,
|
||||||
button_text: "" as string | undefined,
|
|
||||||
id: 0,
|
id: 0,
|
||||||
icon: "",
|
icon: "",
|
||||||
path: [] as number[][],
|
path: [] as number[][],
|
||||||
@@ -155,7 +153,7 @@ class RouteStore {
|
|||||||
scale_max: 0,
|
scale_max: 0,
|
||||||
scale_min: 0,
|
scale_min: 0,
|
||||||
video_preview: "" as string | undefined,
|
video_preview: "" as string | undefined,
|
||||||
video_timer: 420,
|
video_timer: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
setEditRouteData = (data: any) => {
|
setEditRouteData = (data: any) => {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { City } from "../CityStore";
|
|||||||
|
|
||||||
class SelectedCityStore {
|
class SelectedCityStore {
|
||||||
selectedCity: City | null = null;
|
selectedCity: City | null = null;
|
||||||
isLocked: boolean = false;
|
|
||||||
cityVersion: number = 0;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
@@ -26,7 +24,6 @@ class SelectedCityStore {
|
|||||||
setSelectedCity = (city: City | null) => {
|
setSelectedCity = (city: City | null) => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.selectedCity = city;
|
this.selectedCity = city;
|
||||||
this.cityVersion += 1;
|
|
||||||
if (city) {
|
if (city) {
|
||||||
localStorage.setItem("selectedCity", JSON.stringify(city));
|
localStorage.setItem("selectedCity", JSON.stringify(city));
|
||||||
} else {
|
} else {
|
||||||
@@ -35,12 +32,6 @@ class SelectedCityStore {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsLocked = (locked: boolean) => {
|
|
||||||
runInAction(() => {
|
|
||||||
this.isLocked = locked;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
clearSelectedCity = () => {
|
clearSelectedCity = () => {
|
||||||
this.setSelectedCity(null);
|
this.setSelectedCity(null);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ class SnapshotStore {
|
|||||||
articlesStore.articleData = null;
|
articlesStore.articleData = null;
|
||||||
articlesStore.articleMedia = null;
|
articlesStore.articleMedia = null;
|
||||||
|
|
||||||
|
countryStore.countries = {
|
||||||
|
ru: { data: [], loaded: false },
|
||||||
|
en: { data: [], loaded: false },
|
||||||
|
zh: { data: [], loaded: false },
|
||||||
|
};
|
||||||
|
|
||||||
carrierStore.carriers = {
|
carrierStore.carriers = {
|
||||||
ru: { data: [], loaded: false },
|
ru: { data: [], loaded: false },
|
||||||
en: { data: [], loaded: false },
|
en: { data: [], loaded: false },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { authInstance, languageInstance, mobxFetch } from "@shared";
|
import { authInstance, languageInstance, mobxFetch } from "@shared";
|
||||||
import { makeAutoObservable, runInAction } from "mobx";
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
import { getVehicleSessionsApi } from "./api";
|
import { getVehicleSessionsApi } from "./api";
|
||||||
import { Vehicle, VehicleMaintenanceSession, SetMaintenanceModeResponse } from "./types";
|
import { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||||
|
|
||||||
export type { Vehicle, VehicleMaintenanceSession } from "./types";
|
export type { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||||
|
|
||||||
@@ -120,7 +120,6 @@ class VehicleStore {
|
|||||||
carrier: string,
|
carrier: string,
|
||||||
carrierId: number,
|
carrierId: number,
|
||||||
model?: string,
|
model?: string,
|
||||||
cityId?: number,
|
|
||||||
) => {
|
) => {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
tail_number: tailNumber.trim(),
|
tail_number: tailNumber.trim(),
|
||||||
@@ -130,7 +129,6 @@ class VehicleStore {
|
|||||||
};
|
};
|
||||||
// TODO: когда будет бекенд — добавить model в payload и в ответ
|
// TODO: когда будет бекенд — добавить model в payload и в ответ
|
||||||
if (model != null && model !== "") payload.model = model;
|
if (model != null && model !== "") payload.model = model;
|
||||||
if (cityId != null) payload.city_id = cityId;
|
|
||||||
const response = await languageInstance("ru").post("/vehicle", payload);
|
const response = await languageInstance("ru").post("/vehicle", payload);
|
||||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
|
|
||||||
@@ -149,7 +147,6 @@ class VehicleStore {
|
|||||||
carrier_id: number;
|
carrier_id: number;
|
||||||
model: string;
|
model: string;
|
||||||
snapshot_update_blocked: boolean;
|
snapshot_update_blocked: boolean;
|
||||||
city_id?: number;
|
|
||||||
} = {
|
} = {
|
||||||
tail_number: "",
|
tail_number: "",
|
||||||
type: 0,
|
type: 0,
|
||||||
@@ -157,7 +154,6 @@ class VehicleStore {
|
|||||||
carrier_id: 0,
|
carrier_id: 0,
|
||||||
model: "",
|
model: "",
|
||||||
snapshot_update_blocked: false,
|
snapshot_update_blocked: false,
|
||||||
city_id: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setEditVehicleData = (data: {
|
setEditVehicleData = (data: {
|
||||||
@@ -167,7 +163,6 @@ class VehicleStore {
|
|||||||
carrier_id: number;
|
carrier_id: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
snapshot_update_blocked?: boolean;
|
snapshot_update_blocked?: boolean;
|
||||||
city_id?: number;
|
|
||||||
}) => {
|
}) => {
|
||||||
this.editVehicleData = {
|
this.editVehicleData = {
|
||||||
...this.editVehicleData,
|
...this.editVehicleData,
|
||||||
@@ -184,7 +179,6 @@ class VehicleStore {
|
|||||||
carrier_id: number;
|
carrier_id: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
snapshot_update_blocked?: boolean;
|
snapshot_update_blocked?: boolean;
|
||||||
city_id?: number;
|
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
@@ -196,7 +190,6 @@ class VehicleStore {
|
|||||||
if (data.model != null && data.model !== "") payload.model = data.model;
|
if (data.model != null && data.model !== "") payload.model = data.model;
|
||||||
if (data.snapshot_update_blocked != null)
|
if (data.snapshot_update_blocked != null)
|
||||||
payload.snapshot_update_blocked = data.snapshot_update_blocked;
|
payload.snapshot_update_blocked = data.snapshot_update_blocked;
|
||||||
if (data.city_id != null) payload.city_id = data.city_id;
|
|
||||||
const response = await languageInstance("ru").patch(
|
const response = await languageInstance("ru").patch(
|
||||||
`/vehicle/${id}`,
|
`/vehicle/${id}`,
|
||||||
payload,
|
payload,
|
||||||
@@ -218,32 +211,23 @@ class VehicleStore {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
setMaintenanceMode = async (
|
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
|
||||||
uuid: string,
|
const response = await authInstance.post(
|
||||||
enabled: boolean,
|
|
||||||
durationSeconds?: number,
|
|
||||||
): Promise<SetMaintenanceModeResponse> => {
|
|
||||||
const response = await authInstance.post<SetMaintenanceModeResponse>(
|
|
||||||
`/devices/${uuid}/maintenance-mode`,
|
`/devices/${uuid}/maintenance-mode`,
|
||||||
{
|
{
|
||||||
enabled,
|
enabled,
|
||||||
...(enabled && durationSeconds !== undefined
|
|
||||||
? { duration_seconds: durationSeconds }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.data.saved) {
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
throw new Error("Maintenance mode was not saved");
|
|
||||||
}
|
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.mergeVehicleInCaches({
|
this.mergeVehicleInCaches({
|
||||||
|
...normalizedVehicle.vehicle,
|
||||||
uuid,
|
uuid,
|
||||||
maintenance_mode_on: response.data.enabled,
|
maintenance_mode_on:
|
||||||
maintenance_mode_duration_seconds: response.data.duration_seconds,
|
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return response.data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setDemoMode = async (uuid: string, enabled: boolean) => {
|
setDemoMode = async (uuid: string, enabled: boolean) => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export type Vehicle = {
|
|||||||
snapshot_update_blocked?: boolean;
|
snapshot_update_blocked?: boolean;
|
||||||
demo_mode_enabled?: boolean;
|
demo_mode_enabled?: boolean;
|
||||||
maintenance_mode_on?: boolean;
|
maintenance_mode_on?: boolean;
|
||||||
maintenance_mode_duration_seconds?: number;
|
|
||||||
city_id?: number;
|
city_id?: number;
|
||||||
};
|
};
|
||||||
device_status?: {
|
device_status?: {
|
||||||
@@ -22,24 +21,13 @@ export type Vehicle = {
|
|||||||
last_update: string;
|
last_update: string;
|
||||||
is_connected: boolean;
|
is_connected: boolean;
|
||||||
current_route_id?: number;
|
current_route_id?: number;
|
||||||
maintenance_mode_on?: boolean;
|
|
||||||
maintenance_mode_duration_seconds?: number;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VehicleMaintenanceSession = {
|
export type VehicleMaintenanceSession = {
|
||||||
duration_seconds?: number | null;
|
duration_seconds: number;
|
||||||
ended_at?: string | null;
|
ended_at: string;
|
||||||
id: number;
|
id: number;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
vehicle_id: number;
|
vehicle_id: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetMaintenanceModeResponse = {
|
|
||||||
message: string;
|
|
||||||
enabled: boolean;
|
|
||||||
duration_seconds: number;
|
|
||||||
saved: boolean;
|
|
||||||
sent: boolean;
|
|
||||||
warning?: string;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { IconButton, Tooltip } from "@mui/material";
|
|
||||||
import { Contrast } from "lucide-react";
|
|
||||||
|
|
||||||
interface PreviewBackgroundToggleProps {
|
|
||||||
dark: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PreviewBackgroundToggle = ({
|
|
||||||
dark,
|
|
||||||
onToggle,
|
|
||||||
}: PreviewBackgroundToggleProps) => (
|
|
||||||
<Tooltip title={dark ? "Сделать фон белым" : "Сделать фон чёрным"}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={dark ? "Сделать фон белым" : "Сделать фон чёрным"}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onToggle();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 8,
|
|
||||||
left: 8,
|
|
||||||
zIndex: 2,
|
|
||||||
color: dark ? "#fff" : "#000",
|
|
||||||
backgroundColor: dark
|
|
||||||
? "rgba(255, 255, 255, 0.16)"
|
|
||||||
: "rgba(255, 255, 255, 0.8)",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: dark
|
|
||||||
? "rgba(255, 255, 255, 0.28)"
|
|
||||||
: "rgba(255, 255, 255, 1)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Contrast size={18} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
@@ -6,4 +6,3 @@ export * from "./AnimatedCircleButton";
|
|||||||
export * from "./LoadingSpinner";
|
export * from "./LoadingSpinner";
|
||||||
export * from "./MultiSelect";
|
export * from "./MultiSelect";
|
||||||
export * from "./SearchInput";
|
export * from "./SearchInput";
|
||||||
export * from "./PreviewBackgroundToggle";
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user