Compare commits
60 Commits
main
...
d758dbffa6
| Author | SHA1 | Date | |
|---|---|---|---|
| d758dbffa6 | |||
| 6af95bb449 | |||
| 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 |
14
.env
14
.env
@@ -1,8 +1,8 @@
|
||||
VITE_API_URL='https://wn.st.unprism.ru'
|
||||
VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||
VITE_NEED_AUTH='true'
|
||||
# VITE_API_URL='https://wn.krbl.ru'
|
||||
# VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
# VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
# VITE_API_URL='https://wn.st.unprism.ru'
|
||||
# VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||
# VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||
# VITE_NEED_AUTH='true'
|
||||
VITE_API_URL='https://wn.krbl.ru'
|
||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
VITE_NEED_AUTH='true'
|
||||
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,7 +1,14 @@
|
||||
# Logs
|
||||
<<<<<<< HEAD
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
=======
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
@@ -10,10 +17,22 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
<<<<<<< HEAD
|
||||
\*.local
|
||||
|
||||
# Build artifacts
|
||||
*.tsbuildinfo
|
||||
# Editor directories and files
|
||||
|
||||
.vscode/_
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
_.suo
|
||||
_.ntvs_
|
||||
_.njsproj
|
||||
_.sln
|
||||
\*.sw?
|
||||
=======
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -25,3 +44,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "white-nights",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.6",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"private": true,
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.7",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"@mui/material": "^7.1.0",
|
||||
"@mui/x-data-grid": "^8.5.1",
|
||||
"@photo-sphere-viewer/core": "^5.13.2",
|
||||
"@pixi/react": "^8.0.2",
|
||||
"@react-three/drei": "^10.1.2",
|
||||
"@react-three/fiber": "^9.1.2",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -32,6 +33,7 @@
|
||||
"overlayscrollbars": "^2.15.1",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"path": "^0.12.7",
|
||||
"pixi.js": "^8.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Router } from "./router";
|
||||
import { CustomTheme, languageStore } from "@shared";
|
||||
import { CustomTheme } from "@shared";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||
import { TestingModeBanner } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const App: React.FC = observer(() => {
|
||||
React.useEffect(() => {
|
||||
document.documentElement.lang = languageStore.language;
|
||||
}, [languageStore.language]);
|
||||
|
||||
return (
|
||||
export const App: React.FC = () => (
|
||||
<GlobalErrorBoundary>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<TestingModeBanner />
|
||||
@@ -22,4 +16,3 @@ export const App: React.FC = observer(() => {
|
||||
</ThemeProvider>
|
||||
</GlobalErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ const App = observer(() => {
|
||||
loadingStatus,
|
||||
loadingProgress,
|
||||
context,
|
||||
carrier,
|
||||
setIsLoading,
|
||||
setLoadingStatus,
|
||||
setLoadingProgress,
|
||||
@@ -173,7 +172,6 @@ const App = observer(() => {
|
||||
loadingStatus={loadingStatus}
|
||||
loadingProgress={loadingProgress}
|
||||
onToggleDebug={() => setIsDebugVisible((prev) => !prev)}
|
||||
videoId={carrier?.video_preview}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
|
||||
@@ -47,16 +47,6 @@ function darkenHex(hex: string, amount: number): string {
|
||||
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";
|
||||
@@ -70,7 +60,6 @@ function applyCarrierColors(carrier: { main_color?: string; left_color?: string;
|
||||
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 {
|
||||
|
||||
@@ -55,7 +55,6 @@ export type GetRouteResponse = {
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
button_text?: string;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
@@ -77,8 +76,6 @@ export type GetCarrierResponse = {
|
||||
full_name: string;
|
||||
id: number;
|
||||
logo: string;
|
||||
preview_media?: string;
|
||||
video_preview?: string;
|
||||
short_name: string;
|
||||
slogan: string;
|
||||
main_color?: string;
|
||||
|
||||
@@ -37,14 +37,14 @@ const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="fullscreen-3d-actions">
|
||||
<button disabled={scale >= 1}>
|
||||
<button title="Увеличить масштаб" disabled={scale >= 1}>
|
||||
<img src={scale_plus} alt="Увеличить" />
|
||||
</button>
|
||||
<button disabled={scale <= 0.1}>
|
||||
<button title="Уменьшить масштаб" disabled={scale <= 0.1}>
|
||||
<img src={scale_minus} alt="Уменьшить" />
|
||||
</button>
|
||||
|
||||
<button onPointerUp={onClose}>
|
||||
<button onPointerUp={onClose} title="Закрыть">
|
||||
<img src={closeIcon} alt="Закрыть" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -95,10 +95,9 @@ const ListOfSights = observer(() => {
|
||||
|
||||
// Находим все достопримечательности, начинающиеся с выбранной буквы
|
||||
const sightsForLetter = sightData.filter((sight) => {
|
||||
const name = sight.name;
|
||||
if (!name) return false;
|
||||
if (!sight.name) return false;
|
||||
|
||||
let sightFirstChar = name.trim().charAt(0);
|
||||
let sightFirstChar = sight.name.trim().charAt(0);
|
||||
|
||||
// Для китайского языка используем пиньинь или иероглиф
|
||||
if (selectedLanguageRight === "zh") {
|
||||
@@ -106,7 +105,7 @@ const ListOfSights = observer(() => {
|
||||
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
||||
} else {
|
||||
// Если пиньинь нет, используем первый иероглиф
|
||||
sightFirstChar = name.trim().charAt(0);
|
||||
sightFirstChar = sight.name.trim().charAt(0);
|
||||
}
|
||||
} else {
|
||||
sightFirstChar = sightFirstChar.toUpperCase();
|
||||
@@ -412,7 +411,7 @@ const ListOfSights = observer(() => {
|
||||
}, [currentSelectedSight]);
|
||||
|
||||
return (
|
||||
<div className="right-widget" lang={selectedLanguageRight}>
|
||||
<div className="right-widget">
|
||||
{currentSelectedSight && (
|
||||
<SightFrame
|
||||
key={currentSelectedSight.id}
|
||||
@@ -441,6 +440,11 @@ const ListOfSights = observer(() => {
|
||||
isLangOpen={isLangMenuOpen}
|
||||
/>
|
||||
|
||||
<TransferWidget
|
||||
isOpen={isTransferWidgetOpen}
|
||||
selectedLanguageRight={selectedLanguage}
|
||||
/>
|
||||
|
||||
<LanguageSelector
|
||||
selectedLanguageRight={selectedLanguageRight}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
@@ -472,12 +476,6 @@ const ListOfSights = observer(() => {
|
||||
isDisabled={isAlphabetDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransferWidget
|
||||
isOpen={isTransferWidgetOpen}
|
||||
selectedLanguageRight={selectedLanguage}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="transfer-button-container"
|
||||
@@ -515,6 +513,7 @@ const ListOfSights = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
|
||||
const letters = new Set();
|
||||
|
||||
sightData.forEach((sight) => {
|
||||
const name = sight.short_name || sight.name;
|
||||
if (name && name.trim()) {
|
||||
let firstChar = name.trim().charAt(0);
|
||||
if (sight.name && sight.name.trim()) {
|
||||
let firstChar = sight.name.trim().charAt(0);
|
||||
|
||||
// Для китайского языка используем пиньинь (если доступен) или иероглиф
|
||||
if (selectedLanguage === "zh") {
|
||||
@@ -24,7 +23,7 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
|
||||
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
||||
} else {
|
||||
// Если пиньинь нет, используем первый иероглиф
|
||||
firstChar = name.trim().charAt(0);
|
||||
firstChar = sight.name.trim().charAt(0);
|
||||
}
|
||||
} else {
|
||||
firstChar = firstChar.toUpperCase();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React from "react";
|
||||
|
||||
const LANGUAGES = {
|
||||
EN: "en",
|
||||
@@ -18,26 +18,6 @@ const ListHeader = function ListHeader({
|
||||
isTransferWidgetOpen,
|
||||
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 = () => {
|
||||
return selectedLanguageRight === LANGUAGES.RU
|
||||
? "Достопримечательности"
|
||||
@@ -61,11 +41,14 @@ const ListHeader = function ListHeader({
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
width="21"
|
||||
height="12"
|
||||
viewBox="0 0 21 12"
|
||||
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)">
|
||||
<path
|
||||
|
||||
@@ -36,7 +36,7 @@ const SightComponent = function SightComponent({
|
||||
aria-label={`Выбрать достопримечательность ${title}`}
|
||||
>
|
||||
<div className="sight-image">{renderThumbnail()}</div>
|
||||
<div className="sight-title">
|
||||
<div className="sight-title" title={title}>
|
||||
{title}
|
||||
</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 { observer } from "mobx-react-lite";
|
||||
import { useGeolocationStore } from "../../stores";
|
||||
@@ -41,68 +41,59 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
|
||||
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
|
||||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
const threeViewControlRef = useRef(null);
|
||||
const mediaCache = useRef({});
|
||||
const prevSightIdRef = useRef(null);
|
||||
const idleTimerRef = 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;
|
||||
// Автозакрытие fullscreen 3D при бездействии (45 сек)
|
||||
useEffect(() => {
|
||||
if (!isFullscreen3D) {
|
||||
if (idleTimerRef.current) {
|
||||
clearInterval(idleTimerRef.current);
|
||||
idleTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSection = (target) => {
|
||||
let idleSeconds = 0;
|
||||
|
||||
const checkIdle = () => {
|
||||
idleSeconds += 1;
|
||||
if (idleSeconds >= 45) {
|
||||
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);
|
||||
}, []);
|
||||
idleTimerRef.current = setInterval(checkIdle, 1000);
|
||||
|
||||
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]);
|
||||
const resetIdle = () => {
|
||||
idleSeconds = 0;
|
||||
};
|
||||
|
||||
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 events = [
|
||||
"mousedown",
|
||||
"mousemove",
|
||||
"keypress",
|
||||
"scroll",
|
||||
"touchstart",
|
||||
"click",
|
||||
];
|
||||
events.forEach((event) => {
|
||||
window.addEventListener(event, resetIdle, { passive: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (idleTimerRef.current) {
|
||||
clearInterval(idleTimerRef.current);
|
||||
idleTimerRef.current = null;
|
||||
}
|
||||
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]);
|
||||
events.forEach((event) => {
|
||||
window.removeEventListener(event, resetIdle);
|
||||
});
|
||||
};
|
||||
}, [isFullscreen3D]);
|
||||
|
||||
const {
|
||||
routeSights,
|
||||
@@ -142,16 +133,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
const isSameSight = prevSightIdRef.current === sight_id;
|
||||
prevSightIdRef.current = sight_id;
|
||||
|
||||
if (!isSameSight) {
|
||||
setIsLoadingContent(true);
|
||||
setContentError(null);
|
||||
setModelAspectRatio(null);
|
||||
setIsVisible(false);
|
||||
setMediaData({});
|
||||
}
|
||||
|
||||
if (!sight_id) {
|
||||
setContentError("Не указан ID статьи.");
|
||||
@@ -229,18 +215,15 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
const introSection = {
|
||||
id: media?.id || "intro-title",
|
||||
heading:
|
||||
sight?.name || sight_name || "Название достопримечательности",
|
||||
sight?.short_name ||
|
||||
sight?.name ||
|
||||
sight_name ||
|
||||
"Название достопримечательности",
|
||||
body: "",
|
||||
};
|
||||
const allSections = [introSection, ...rightArticles];
|
||||
setArticleSections(allSections);
|
||||
|
||||
if (isSameSight) {
|
||||
setIsLoadingContent(false);
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
|
||||
if (mediaCache.current[cacheKey]) {
|
||||
setMediaData(mediaCache.current[cacheKey]);
|
||||
@@ -281,43 +264,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSection(0);
|
||||
setIsSwitching(false);
|
||||
if (switchTimerRef.current) {
|
||||
clearTimeout(switchTimerRef.current);
|
||||
switchTimerRef.current = null;
|
||||
}
|
||||
}, [sight_id]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
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 currentSection = articleSections?.[selectedSection] ?? null;
|
||||
|
||||
const renderCurrentMedia = () => {
|
||||
if (!articleSections || Object.keys(mediaData).length === 0) {
|
||||
@@ -412,6 +361,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title="Уменьшить"
|
||||
onPointerUp={() => threeViewControlRef.current?.zoomOut?.()}
|
||||
>
|
||||
<MinusIcon />
|
||||
@@ -419,6 +369,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title="Увеличить"
|
||||
onPointerUp={() => threeViewControlRef.current?.zoomIn?.()}
|
||||
>
|
||||
<PlusIcon />
|
||||
@@ -426,6 +377,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title={isFullscreen3D ? "Свернуть" : "Развернуть"}
|
||||
onPointerUp={() => {
|
||||
if (isFullscreen3D) {
|
||||
setIsFullscreen3D(false);
|
||||
@@ -440,6 +392,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
</div>
|
||||
<button
|
||||
className={"fullscreen-3d-button"}
|
||||
title={
|
||||
isFullscreen3D
|
||||
? "Закрыть полноэкранный режим"
|
||||
: "Открыть в полноэкранном режиме"
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
@@ -613,6 +570,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
const processedSightName = useMemo(() => {
|
||||
if (!sight_name) return sight_name;
|
||||
|
||||
// Handle \n line breaks (только в правом виджете)
|
||||
if (sight_name.includes("\n")) {
|
||||
return sight_name.split("\n").map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
@@ -657,7 +615,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
}, [sight_name]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
|
||||
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
|
||||
@@ -667,7 +624,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
|
||||
)}
|
||||
<div
|
||||
ref={mediaStackRef}
|
||||
className={`sight-frame-media-stack ${
|
||||
isCurrentMedia3D ? "three-d-view" : ""
|
||||
}`}
|
||||
@@ -685,22 +641,21 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
renderCurrentMedia()
|
||||
)}
|
||||
</div>
|
||||
<div className={`sight-frame-content ${isSwitching ? "is-switching" : ""}`}>
|
||||
<div className="sight-frame-content">
|
||||
{contentError ? (
|
||||
<p className="error-message">{contentError}</p>
|
||||
) : !currentSection ? (
|
||||
<p>Информация отсутствует.</p>
|
||||
) : (
|
||||
<>
|
||||
{!isFullscreen3D && (
|
||||
<div
|
||||
ref={titleRef}
|
||||
className={`sight-frame-title ${
|
||||
selectedSection === 0 ? "intro-title" : ""
|
||||
}`}
|
||||
style={{ lineHeight: titleLineHeight }}
|
||||
>
|
||||
<p
|
||||
key={selectedSection}
|
||||
className="fade-in-text"
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
@@ -708,45 +663,53 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
}}
|
||||
>
|
||||
{selectedSection === 0
|
||||
? processedSightName || sightData?.name
|
||||
: sightData?.short_name || sightData?.name}
|
||||
? processedSightName
|
||||
: sightData?.short_name || sight_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedSection !== 0 && (
|
||||
<TouchableLayout
|
||||
key={selectedSection}
|
||||
className={`sight-frame-text-wrapper ${selectedSection === 0 ? "is-intro" : ""}`}
|
||||
className="sight-frame-text-wrapper"
|
||||
ref={textWrapperRef}
|
||||
maxHeight="calc(80vh - 354px)"
|
||||
>
|
||||
<div className="sight-frame-text fade-in-text">
|
||||
{isLoadingContent || !currentSection ? (
|
||||
<div className="sight-frame-loading-placeholder" />
|
||||
) : (
|
||||
selectedSection !== 0 && (
|
||||
<div className="sight-frame-text">
|
||||
<ReactMarkdownComponent value={currentSection.body} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TouchableLayout>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sight-frame-menu">
|
||||
{selectedSection !== 0 && (
|
||||
<div
|
||||
className="sight-frame-menu-wrapper"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: "opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.08s",
|
||||
pointerEvents: isVisible ? "auto" : "none",
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
marginTop: "-4.5px",
|
||||
zIndex: 1,
|
||||
paddingLeft: "15px",
|
||||
paddingRight: "7.5px",
|
||||
paddingTop: "4.5px",
|
||||
paddingBottom: "4.5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
setSelectedSection(0);
|
||||
setIsFullscreen3D(false);
|
||||
}}
|
||||
>
|
||||
<div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
|
||||
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
|
||||
<div
|
||||
className="sight-frame-menu"
|
||||
ref={menuRef}
|
||||
style={menuNeedsScroll ? { justifyContent: 'flex-start' } : undefined}
|
||||
>
|
||||
<img
|
||||
src={subtractHomeIcon}
|
||||
alt=""
|
||||
width="24"
|
||||
height="21"
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{contentError ? (
|
||||
<p className="error-message">{contentError}</p>
|
||||
) : (
|
||||
@@ -754,9 +717,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
articleSections.length > 1 &&
|
||||
articleSections.slice(1).map((section, index) => (
|
||||
<div
|
||||
onPointerUp={() => requestSection(index + 1)}
|
||||
onPointerUp={() => {
|
||||
setSelectedSection(index + 1);
|
||||
setIsFullscreen3D(false);
|
||||
}}
|
||||
key={section.id || section.heading || index}
|
||||
data-label={section.heading}
|
||||
className={`sight-frame-menu-point ${
|
||||
index + 1 === selectedSection ? "active" : ""
|
||||
}`}
|
||||
@@ -769,33 +734,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -95,35 +95,35 @@ const TransferWidget = observer(function TransferWidget({
|
||||
}
|
||||
|
||||
const getTransferLabel = () => {
|
||||
if (selectedLanguageRight === "ru") {
|
||||
return stationName ? (
|
||||
<>
|
||||
<div>Пересадка на остановке</div>
|
||||
<div>«{stationName}»:</div>
|
||||
</>
|
||||
) : (
|
||||
"Ближайшая остановка не обнаружена"
|
||||
);
|
||||
if (!stationName) {
|
||||
if (selectedLanguageRight === "en") return "Nearest station not found";
|
||||
if (selectedLanguageRight === "zh") return "最近的站点未找到";
|
||||
return "Ближайшая остановка не обнаружена";
|
||||
}
|
||||
|
||||
if (selectedLanguageRight === "en") {
|
||||
return stationName ? (
|
||||
return (
|
||||
<>
|
||||
<div>Transfer at stop</div>
|
||||
<div>«{stationName}»:</div>
|
||||
Transfer at stop<br />
|
||||
«{stationName}»:
|
||||
</>
|
||||
) : (
|
||||
"Nearest station not found"
|
||||
);
|
||||
}
|
||||
|
||||
return stationName ? (
|
||||
if (selectedLanguageRight === "zh") {
|
||||
return (
|
||||
<>
|
||||
<div>在站点换乘</div>
|
||||
<div>«{stationName}»:</div>
|
||||
换乘站<br />
|
||||
«{stationName}»:
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Пересадка на остановке<br />
|
||||
«{stationName}»:
|
||||
</>
|
||||
) : (
|
||||
"最近的站点未找到"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "../styles/Loader.css";
|
||||
import { getMediaUrl } from "../api/apiConfig";
|
||||
|
||||
const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url)
|
||||
.href;
|
||||
@@ -10,14 +9,8 @@ const Loader = ({
|
||||
loadingStatus = null,
|
||||
loadingProgress = null,
|
||||
onToggleDebug,
|
||||
videoId = null,
|
||||
}) => {
|
||||
const [showLoadingInfo, setShowLoadingInfo] = useState(false);
|
||||
const [isCustomVideoError, setIsCustomVideoError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsCustomVideoError(false);
|
||||
}, [videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
@@ -62,12 +55,7 @@ const Loader = ({
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
src={
|
||||
videoId && !isCustomVideoError
|
||||
? getMediaUrl(videoId)
|
||||
: loaderVideoPath
|
||||
}
|
||||
onError={() => setIsCustomVideoError(true)}
|
||||
src={loaderVideoPath}
|
||||
alt="Loading"
|
||||
className="error-gif"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
|
||||
@@ -12,7 +12,6 @@ interface TouchableLayoutProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
maxHeight?: string | number;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||
@@ -20,30 +19,20 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||
height: 60,
|
||||
top: 0,
|
||||
hasScroll: false,
|
||||
isAtTop: true,
|
||||
isAtBottom: false,
|
||||
});
|
||||
const [visible, setVisible] = useState(false);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollableRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (el.closest('[data-height-animating="true"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sh = el.scrollHeight;
|
||||
const ch = el.clientHeight;
|
||||
const st = el.scrollTop;
|
||||
const th = ch;
|
||||
|
||||
const isAtTop = st <= 0;
|
||||
const isAtBottom = st + ch >= sh - 1;
|
||||
|
||||
if (sh <= ch + 2) {
|
||||
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
|
||||
if (sh <= ch) {
|
||||
setState({ height: th, top: 0, hasScroll: false });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -52,7 +41,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const scrollRange = sh - ch;
|
||||
const top = range <= 0 ? 0 : (st / scrollRange) * range;
|
||||
|
||||
setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom });
|
||||
setState({ height: thumbHeight, top, hasScroll: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,36 +57,22 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", schedule, { passive: true });
|
||||
el.addEventListener("layoutchange", schedule);
|
||||
const ro = new ResizeObserver(schedule);
|
||||
ro.observe(el);
|
||||
const mo = new MutationObserver(schedule);
|
||||
mo.observe(el, { childList: true, subtree: true, characterData: true });
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("scroll", schedule);
|
||||
el.removeEventListener("layoutchange", schedule);
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.hasScroll) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setVisible(true), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.hasScroll]);
|
||||
|
||||
return { ...state, visible };
|
||||
return state;
|
||||
}
|
||||
|
||||
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||
({ children, className, maxHeight, style }, ref) => {
|
||||
({ children, className, maxHeight }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
@@ -259,12 +234,9 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||
};
|
||||
}, [thumb.hasScroll]);
|
||||
|
||||
const containerClassName = [
|
||||
"scrollable-container",
|
||||
className,
|
||||
thumb.isAtTop ? "is-at-top" : "",
|
||||
thumb.isAtBottom ? "is-at-bottom" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const containerClassName = className
|
||||
? `scrollable-container ${className}`
|
||||
: "scrollable-container";
|
||||
|
||||
const viewportStyle: React.CSSProperties = maxHeight
|
||||
? {
|
||||
@@ -274,27 +246,20 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div ref={setRefs} className={containerClassName} style={style}>
|
||||
<div ref={setRefs} className={containerClassName}>
|
||||
<div className="scrollable-viewport" style={viewportStyle}>
|
||||
<div ref={scrollableRef} className="scrollable">
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="custom-scrollbar-track"
|
||||
style={{ opacity: thumb.visible ? 1 : 0 }}
|
||||
>
|
||||
{thumb.hasScroll && (
|
||||
<div ref={trackRef} className="custom-scrollbar-track">
|
||||
<div
|
||||
ref={thumbRef}
|
||||
className="custom-scrollbar-thumb"
|
||||
style={{
|
||||
height: thumb.height,
|
||||
top: thumb.top,
|
||||
opacity: thumb.visible ? 1 : 0,
|
||||
pointerEvents: thumb.visible ? "auto" : "none",
|
||||
}}
|
||||
style={{ height: thumb.height, top: thumb.top }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import windIcon from "../assets/weather-icons/wind.png";
|
||||
import humidityIcon from "../assets/weather-icons/humidity.png";
|
||||
import { useGeolocationStore } from "../stores";
|
||||
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
|
||||
import { apiStore } from "../api/ApiStore";
|
||||
|
||||
function WeatherDataLine({ icon, value, unit }) {
|
||||
return (
|
||||
@@ -44,11 +43,7 @@ const WeatherWidget = observer(() => {
|
||||
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
const data = await weatherApi.getFormattedWeather({
|
||||
lat: apiStore.context?.currentCoordinates?.latitude,
|
||||
lng: apiStore.context?.currentCoordinates?.longitude,
|
||||
cityCode: apiStore.city?.weather_city_code,
|
||||
});
|
||||
const data = await weatherApi.getFormattedWeather();
|
||||
setWeather(data);
|
||||
setError(null);
|
||||
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,
|
||||
} from "react";
|
||||
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 { TransformProvider, useTransform } from "./transformContext";
|
||||
// @ts-ignore
|
||||
@@ -17,6 +19,8 @@ import { SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import WebGLMap from "./WebGLMap";
|
||||
|
||||
extend({ Container, Graphics, Text, Sprite });
|
||||
|
||||
export function Map() {
|
||||
return (
|
||||
<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,
|
||||
BUS_COLOR,
|
||||
BASE_ICON_SIZE,
|
||||
CLUSTER_RADIUS_BASE,
|
||||
} from "./Constants";
|
||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
@@ -155,19 +156,6 @@ const useSightClustering = (
|
||||
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 queue = [sight];
|
||||
sight.visited = true;
|
||||
@@ -177,12 +165,8 @@ const useSightClustering = (
|
||||
clusterSights.push(current);
|
||||
|
||||
for (const potentialNeighbor of unclusteredSights) {
|
||||
const neighborHasCustomIcon =
|
||||
potentialNeighbor.is_default_icon === false &&
|
||||
!isMediaIdEmpty(potentialNeighbor.icon ?? null);
|
||||
if (
|
||||
!potentialNeighbor.visited &&
|
||||
!neighborHasCustomIcon &&
|
||||
clusterSights.length < 4 &&
|
||||
getDistance(current, potentialNeighbor) < distanceThreshold
|
||||
) {
|
||||
@@ -192,10 +176,6 @@ const useSightClustering = (
|
||||
}
|
||||
}
|
||||
|
||||
for (const leftover of queue) {
|
||||
leftover.visited = false;
|
||||
}
|
||||
|
||||
if (clusterSights.length > 1) {
|
||||
let furthestSight: SightData | null = null;
|
||||
let maxDistanceToPath = -1;
|
||||
@@ -402,15 +382,13 @@ export const WebGLMap = observer(() => {
|
||||
return livePercent;
|
||||
}
|
||||
|
||||
if (sight?.is_default_icon === false) {
|
||||
if (
|
||||
sight != null &&
|
||||
typeof sight.icon_size === "number" &&
|
||||
Number.isFinite(sight.icon_size)
|
||||
) {
|
||||
return sight.icon_size;
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof routeData?.icon_size === "number" &&
|
||||
@@ -2573,8 +2551,14 @@ export const WebGLMap = observer(() => {
|
||||
const rx = cluster.longitude;
|
||||
const ry = cluster.latitude;
|
||||
|
||||
const iconSizePercent = resolveSightIconSizePercent();
|
||||
const iconSize =
|
||||
SIGHT_ICON_BASE_SIZE * clamp(iconSizePercent / 100, 0.1, 10);
|
||||
|
||||
const screenX = (rx * scale + position.x) / dpr;
|
||||
const screenY = (ry * scale + position.y) / dpr;
|
||||
const iconLeft = screenX - iconSize / 2;
|
||||
const iconTop = screenY - iconSize / 2;
|
||||
|
||||
const isExpanded = activeClusterId === cluster.id;
|
||||
const selectedSightInCluster = cluster.sights.find(
|
||||
@@ -2586,20 +2570,6 @@ export const WebGLMap = observer(() => {
|
||||
const selectedIsCustomInCluster =
|
||||
selectedSightInCluster?.is_default_icon === false &&
|
||||
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 =
|
||||
selectedSightInCluster != null &&
|
||||
selectedIsCustomInCluster &&
|
||||
@@ -2673,10 +2643,13 @@ export const WebGLMap = observer(() => {
|
||||
onTouchEnd={handleClusterClick}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: iconLeft,
|
||||
top: iconTop,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
left: iconLeft - CLUSTER_RADIUS_BASE - 10,
|
||||
top: iconTop - CLUSTER_RADIUS_BASE - 10,
|
||||
width: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
|
||||
height: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
@@ -2689,22 +2662,15 @@ export const WebGLMap = observer(() => {
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<img
|
||||
src={clusterIconUrl}
|
||||
alt=""
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{
|
||||
display: "block",
|
||||
pointerEvents: "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
filter: hasSelectedSight
|
||||
? hasSelectedAltIconInCluster
|
||||
? "none"
|
||||
@@ -2717,7 +2683,6 @@ export const WebGLMap = observer(() => {
|
||||
position: "absolute",
|
||||
top: -6,
|
||||
right: -6,
|
||||
zIndex: 1,
|
||||
width: 15,
|
||||
height: 15,
|
||||
borderRadius: "10px",
|
||||
@@ -2743,8 +2708,8 @@ export const WebGLMap = observer(() => {
|
||||
onMouseMove={handleCircleInteraction}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: screenX - iconSize,
|
||||
top: screenY - iconSize,
|
||||
left: screenX - iconSize / 2,
|
||||
top: screenY - iconSize / 2,
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
pointerEvents: "auto",
|
||||
@@ -2955,6 +2920,7 @@ export const WebGLMap = observer(() => {
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
}}
|
||||
title={sightName}
|
||||
>
|
||||
{sightName}
|
||||
</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 { apiStore } from "../../api/ApiStore/store";
|
||||
import { apiBaseURL } from "../../api/apiConfig";
|
||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
||||
import { TouchableLayout } from "../TouchableLayout";
|
||||
|
||||
const LeftWidget = observer(
|
||||
({ selectedSightId, onClose, isVisible, sightTop }) => {
|
||||
@@ -17,7 +15,8 @@ const LeftWidget = observer(
|
||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||
const [widgetHeight, setWidgetHeight] = useState(0);
|
||||
|
||||
const layoutRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const activeTouch = useRef(null);
|
||||
const widgetRef = useRef(null);
|
||||
|
||||
const store = useGeolocationStore();
|
||||
@@ -38,10 +37,64 @@ const LeftWidget = observer(
|
||||
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
const scrollable = layoutRef.current.querySelector(".scrollable");
|
||||
if (scrollable) scrollable.scrollTop = 0;
|
||||
const scrollContainer = textRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.touches.length === 1) {
|
||||
activeTouch.current = {
|
||||
identifier: e.touches[0].identifier,
|
||||
lastY: e.touches[0].clientY,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
e.preventDefault();
|
||||
if (activeTouch.current) {
|
||||
for (const touch of e.changedTouches) {
|
||||
if (touch.identifier === activeTouch.current.identifier) {
|
||||
const deltaY = touch.clientY - activeTouch.current.lastY;
|
||||
scrollContainer.scrollTop -= deltaY;
|
||||
activeTouch.current.lastY = touch.clientY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
for (const touch of e.changedTouches) {
|
||||
if (
|
||||
activeTouch.current &&
|
||||
touch.identifier === activeTouch.current.identifier
|
||||
) {
|
||||
activeTouch.current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener("touchstart", handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
scrollContainer.addEventListener("touchmove", handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
scrollContainer.addEventListener("touchend", handleTouchEnd, {
|
||||
passive: true,
|
||||
});
|
||||
scrollContainer.addEventListener("touchcancel", handleTouchEnd, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("touchstart", handleTouchStart);
|
||||
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
}, [selectedSightData]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,8 +123,6 @@ const LeftWidget = observer(
|
||||
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
||||
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
||||
|
||||
if (!sight) return;
|
||||
|
||||
const leftArticle = sight.left_article;
|
||||
|
||||
const leftArticleData =
|
||||
@@ -81,8 +132,6 @@ const LeftWidget = observer(
|
||||
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
||||
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
||||
|
||||
if (!leftArticleData?.media?.length) return;
|
||||
|
||||
const media = await ContentAPI.getMediaPreview(
|
||||
leftArticleData.media[0].id,
|
||||
selectedLanguage,
|
||||
@@ -159,7 +208,7 @@ const LeftWidget = observer(
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget" lang={selectedLanguage}>
|
||||
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget">
|
||||
{isLoading ? (
|
||||
<div>Загрузка информации...</div>
|
||||
) : error ? (
|
||||
@@ -185,11 +234,9 @@ const LeftWidget = observer(
|
||||
<div className="left-widget-address">
|
||||
{selectedSightData.address}
|
||||
</div>
|
||||
<TouchableLayout ref={layoutRef} className="left-widget-text-scroll">
|
||||
<div className="left-widget-text">
|
||||
<ReactMarkdownComponent value={selectedSightData.text} />
|
||||
<div ref={textRef} className="left-widget-text">
|
||||
{selectedSightData.text}
|
||||
</div>
|
||||
</TouchableLayout>
|
||||
</div>
|
||||
</>
|
||||
) : (isVisible || selectedSightData) && !isLoading ? (
|
||||
|
||||
@@ -2,9 +2,9 @@ import "../../styles/SideMenu.css";
|
||||
import AppealWidget from "../widgets/AppealWidget";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import gouvermentImage from "../../assets/images/test-image.png";
|
||||
import sideMenuPhoto from "/side-menu-photo.png";
|
||||
import RouteWidget from "../widgets/RouteWidget";
|
||||
import WeatherWidget from "../WeatherWidget";
|
||||
import ContentAPI from "../../api/content/content.api";
|
||||
import { useGeolocationStore, useColorStore } from "../../stores";
|
||||
import "../../styles/LeftWidget.css";
|
||||
@@ -13,10 +13,12 @@ import StationsList from "./StationsList";
|
||||
import LeftWidget from "./LeftWidget";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import { getMediaUrl } from "../../api/apiConfig";
|
||||
import defaultCrest from "../../assets/images/Герб.png";
|
||||
|
||||
const SideMenu = observer(({ onMenuToggle }) => {
|
||||
const {
|
||||
carrier,
|
||||
city,
|
||||
route,
|
||||
getArticle,
|
||||
sightArticles,
|
||||
@@ -31,7 +33,6 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
const [isStationOpen, openStation] = useState(false);
|
||||
const [isWidgetOpen, openWidget] = useState(false);
|
||||
const [isWeatherVisible, setIsWeatherVisible] = useState(true);
|
||||
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
|
||||
const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false);
|
||||
const [localSelectedSightId, setLocalSelectedSightId] = useState(null);
|
||||
const [isSightsAnimating, setIsSightsAnimating] = useState(false);
|
||||
@@ -85,7 +86,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
let scrollTarget = null;
|
||||
const targetElement = document.elementFromPoint(
|
||||
touch.clientX,
|
||||
touch.clientY,
|
||||
touch.clientY
|
||||
);
|
||||
|
||||
// Определяем, над каким из скролл-контейнеров находится палец
|
||||
@@ -185,12 +186,27 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
}
|
||||
}, [isLeftWidgetVisible]);
|
||||
|
||||
const [designData, setDesignData] = useState(null);
|
||||
const [routeData, setRouteData] = useState(null);
|
||||
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsPreviewMediaError(false);
|
||||
}, [carrier?.preview_media]);
|
||||
async function fetchCarrierAndCrest() {
|
||||
try {
|
||||
const carrierPath = carrier.logo ? getMediaUrl(carrier.logo) : null;
|
||||
const cityPath = city.arms ? getMediaUrl(city.arms) : null;
|
||||
|
||||
const response = {
|
||||
carrierPath: carrierPath,
|
||||
creastPath: cityPath,
|
||||
};
|
||||
setDesignData(response);
|
||||
} catch (err) {
|
||||
console.error("Ошибка в получении данных герба и перевозчика:", err);
|
||||
}
|
||||
}
|
||||
fetchCarrierAndCrest();
|
||||
}, [contextData?.routeId, isGeolocationLoading, selectedLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGeolocationLoading || !contextData?.routeId) {
|
||||
@@ -248,19 +264,15 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuToggleRef = useRef(handleMenuToggle);
|
||||
handleMenuToggleRef.current = handleMenuToggle;
|
||||
|
||||
const isMenuOpenRef = useRef(isMenuOpen);
|
||||
isMenuOpenRef.current = isMenuOpen;
|
||||
|
||||
useEffect(() => {
|
||||
// Автоматическое закрытие сайд-меню после 45 секунд бездействия
|
||||
let idleSeconds = 0;
|
||||
|
||||
const checkIdle = () => {
|
||||
idleSeconds += 1;
|
||||
if (idleSeconds >= 60 && isMenuOpenRef.current) {
|
||||
handleMenuToggleRef.current(false);
|
||||
|
||||
if (idleSeconds >= 45 && isMenuOpen) {
|
||||
handleMenuToggle(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -289,48 +301,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
window.removeEventListener(event, resetIdle);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Автоматическое скрытие левого виджета через 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]);
|
||||
}, [isMenuOpen, handleMenuToggle]);
|
||||
|
||||
// Закрываем и открываем список достопримечательностей при изменении сортировки
|
||||
const prevSortingByRef = useRef(sortingBy);
|
||||
@@ -399,6 +370,14 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className="side-menu-crest"
|
||||
src={designData?.creastPath || defaultCrest}
|
||||
alt="Герб"
|
||||
/>
|
||||
{carrier?.slogan && (
|
||||
<div className="side-menu-label">{carrier.slogan}</div>
|
||||
)}
|
||||
{shouldRenderSights && (
|
||||
<SightsList
|
||||
ref={sightsListRef}
|
||||
@@ -464,16 +443,14 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
}}
|
||||
className="appeal-button"
|
||||
>
|
||||
{route?.button_text
|
||||
? route.button_text
|
||||
: selectedLanguage == "ru"
|
||||
{selectedLanguage == "ru"
|
||||
? "Обращение губернатора"
|
||||
: selectedLanguage == "zh"
|
||||
? "州长致辞"
|
||||
: "Governor's appeal"}
|
||||
</div>
|
||||
)}
|
||||
<div className="side-menu-buttons" style={{ marginTop: route?.governor_appeal > 0 ? '40px' : '260px' }}>
|
||||
<div className="side-menu-buttons">
|
||||
<div
|
||||
onPointerUp={() => {
|
||||
if (!isSightsOpen) {
|
||||
@@ -515,7 +492,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
}, 300);
|
||||
}
|
||||
}}
|
||||
className={`side-menu-button side-menu-button--sights ${
|
||||
className={`side-menu-button ${
|
||||
isSightsOpen ? "side-menu-button--active" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -582,48 +559,30 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
: "#EveryoneOnTheWay"} */}
|
||||
</div>
|
||||
<div className="side-menu-bottom-section">
|
||||
<div className="side-menu-carrier-block">
|
||||
{designData?.carrierPath && (
|
||||
<img
|
||||
className="side-menu-bottom-photo"
|
||||
src={
|
||||
carrier?.preview_media && !isPreviewMediaError
|
||||
? getMediaUrl(carrier.preview_media)
|
||||
: sideMenuPhoto
|
||||
}
|
||||
onError={() => setIsPreviewMediaError(true)}
|
||||
alt=""
|
||||
className="carrier-logo"
|
||||
src={designData?.carrierPath}
|
||||
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 className="side-menu-control-panel">
|
||||
<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
|
||||
widgetImgPath={(() => {
|
||||
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}
|
||||
widgetImgPath={gouvermentImage}
|
||||
style={{
|
||||
transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)",
|
||||
transition: "transform 0.5s ease",
|
||||
zIndex: -1,
|
||||
pointerEvents: isWidgetOpen ? "auto" : "none",
|
||||
}}
|
||||
widgetLabel={
|
||||
selectedLanguage == "ru"
|
||||
|
||||
@@ -64,6 +64,7 @@ const StationItem = ({
|
||||
className="side-menu-sight"
|
||||
onPointerDown={(e) => handlePointerDown(e, station.id)}
|
||||
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
|
||||
title={station.name}
|
||||
>
|
||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||
{station.name}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useGeolocationStore } from "../../stores";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||
import { TouchableLayout } from "../TouchableLayout";
|
||||
import Collapsible from "./Collapsible";
|
||||
import { getMediaUrl } from "../../api/apiConfig";
|
||||
import stationIcon from "../../assets/transport-icons/station.svg";
|
||||
|
||||
@@ -39,8 +38,25 @@ const SightItem = ({
|
||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Получаем название для отображения
|
||||
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();
|
||||
@@ -79,48 +95,28 @@ const SightItem = ({
|
||||
const cacheKey = `${sight.id}_${selectedLanguage}`;
|
||||
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 (
|
||||
<div
|
||||
className={`side-menu-sight-wrapper ${
|
||||
localSelectedSightId === sight.id ? "side-menu-sight-selected-wrapper" : ""
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
id={`sight-${sight.id}`}
|
||||
onPointerDown={(e) => handlePointerDown(e, sight.id)}
|
||||
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" : ""}>
|
||||
{sightName}
|
||||
</span>
|
||||
</div>
|
||||
<Collapsible className="side-menu-sight-transfer-list" open={isExpanded}>
|
||||
<div
|
||||
className={`side-menu-sight-transfer-list ${isExpanded ? "open" : ""}`}
|
||||
>
|
||||
{stations.length > 0 ? (
|
||||
stations.map((station, index) => {
|
||||
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
|
||||
const iconSrc = isMediaIdEmptyResult
|
||||
const iconSrc = isMediaIdEmpty(station.icon)
|
||||
? stationIcon
|
||||
: getMediaUrl(station.icon);
|
||||
|
||||
@@ -161,7 +157,7 @@ const SightItem = ({
|
||||
: "No stations"}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -237,8 +233,24 @@ const SightsList = observer(
|
||||
: selectedLanguage === "en"
|
||||
? routeSightsEn
|
||||
: routeSightsZh;
|
||||
// Функция для получения названия для сортировки
|
||||
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) => {
|
||||
@@ -337,18 +349,25 @@ const SightsList = observer(
|
||||
}, [localSelectedSightId, sightList, className]);
|
||||
|
||||
const handleSightClick = (sightId) => {
|
||||
// Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет
|
||||
if (isLeftWidgetOpen && localSelectedSightId === sightId) {
|
||||
setLocalSelectedSightId(null);
|
||||
setIsLeftWidgetOpen(false);
|
||||
return;
|
||||
}
|
||||
// Открываем левый виджет
|
||||
if (isLeftWidgetOpen && localSelectedSightId !== sightId) {
|
||||
setLocalSelectedSightId(sightId);
|
||||
setIsLeftWidgetOpen(true);
|
||||
if (onSightSelected) {
|
||||
onSightSelected(sightId);
|
||||
}
|
||||
} else if (!isLeftWidgetOpen) {
|
||||
setLocalSelectedSightId(sightId);
|
||||
setIsLeftWidgetOpen(true);
|
||||
if (onSightSelected) {
|
||||
onSightSelected(sightId);
|
||||
}
|
||||
} else {
|
||||
// Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок
|
||||
if (onSightSelected) {
|
||||
onSightSelected(sightId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
|
||||
@@ -28,10 +28,10 @@ const SightItem = ({
|
||||
const textRef = useRef(null);
|
||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||
|
||||
// Получаем название из left_article или используем short_name/name
|
||||
// Получаем название из left_article
|
||||
const getSightName = () => {
|
||||
if (!sight.left_article) {
|
||||
return sight.short_name || sight.name;
|
||||
return sight.name;
|
||||
}
|
||||
|
||||
const leftArticleData =
|
||||
@@ -41,7 +41,7 @@ const SightItem = ({
|
||||
? sightArticlesEn.get(sight.left_article + "_en")
|
||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||
|
||||
return leftArticleData?.heading || sight.short_name || sight.name;
|
||||
return leftArticleData?.heading || sight.name;
|
||||
};
|
||||
|
||||
const sightName = getSightName();
|
||||
@@ -70,6 +70,7 @@ const SightItem = ({
|
||||
className={`side-menu-sight pointer ${
|
||||
localSelectedSightId === sight.id ? "selected" : ""
|
||||
}`}
|
||||
title={sightName}
|
||||
>
|
||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||
{sightName}
|
||||
@@ -119,9 +120,10 @@ const StationSightsList = observer(
|
||||
selectedLanguage
|
||||
);
|
||||
|
||||
// Функция для получения названия из left_article
|
||||
const getSightNameForSort = (sight) => {
|
||||
if (!sight.left_article) {
|
||||
return (sight.short_name || sight.name).trim();
|
||||
return sight.name.trim();
|
||||
}
|
||||
|
||||
const leftArticleData =
|
||||
@@ -131,7 +133,7 @@ const StationSightsList = observer(
|
||||
? sightArticlesEn.get(sight.left_article + "_en")
|
||||
: 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) => {
|
||||
@@ -309,4 +311,3 @@ const StationSightsList = observer(
|
||||
);
|
||||
|
||||
export default StationSightsList;
|
||||
nSightsList;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useGeolocationStore } from "../../stores";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||
import { TouchableLayout } from "../TouchableLayout";
|
||||
import Collapsible from "./Collapsible";
|
||||
|
||||
const SightTransferItem = ({ name, style, onPointerUp }) => {
|
||||
const containerRef = useRef(null);
|
||||
@@ -93,15 +92,26 @@ const StationItem = ({
|
||||
const sights = stationSightsCache.get(cacheKey) || [];
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`side-menu-sight-wrapper ${
|
||||
selectedStationId === station.id ? "side-menu-sight-selected-wrapper" : ""
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="side-menu-sight"
|
||||
@@ -114,14 +124,16 @@ const StationItem = ({
|
||||
);
|
||||
}
|
||||
}}
|
||||
title={station.name}
|
||||
>
|
||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||
{station.name}
|
||||
</span>
|
||||
</div>
|
||||
<Collapsible
|
||||
className="side-menu-sight-transfer-list"
|
||||
open={selectedStationId === station.id}
|
||||
<div
|
||||
className={`side-menu-sight-transfer-list ${
|
||||
selectedStationId === station.id ? "open" : ""
|
||||
}`}
|
||||
>
|
||||
{sights.length > 0 ? (
|
||||
sights.map((sight, index) => (
|
||||
@@ -154,7 +166,7 @@ const StationItem = ({
|
||||
: "No sights"}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
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]);
|
||||
import '../../styles/AppealWidget.css'
|
||||
|
||||
function AppealWidget({widgetImgPath, widgetLabel, widgetText, style}) {
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className="dynamic-widget"
|
||||
onPointerDown={stopProp}
|
||||
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 style={style} className='dynamic-widget'>
|
||||
<img className='dynamic-widget-image' src={widgetImgPath} />
|
||||
<div className='dynamic-widget-label'>{widgetLabel}</div>
|
||||
<div className='dynamic-widget-text'>{widgetText}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppealWidget;
|
||||
export default AppealWidget
|
||||
|
||||
@@ -8,7 +8,7 @@ import { apiStore } from "../../api/ApiStore/store";
|
||||
const RouteWidget = observer(() => {
|
||||
const store = useGeolocationStore();
|
||||
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 [startStationEn, setStartStationEn] = useState(null);
|
||||
const [endStation, setEndStation] = useState(null);
|
||||
@@ -84,14 +84,15 @@ const RouteWidget = observer(() => {
|
||||
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
|
||||
return (
|
||||
<div className="route-widget">
|
||||
<div className={`route-widget-number ${getNumberSizeClass(context?.routeNumber)}`}>
|
||||
{context?.routeNumber || "No number"}
|
||||
<div className={`route-widget-number ${getNumberSizeClass(route?.route_sys_number || context?.routeNumber)}`}>
|
||||
{route?.route_sys_number || context?.routeNumber || ""}
|
||||
</div>
|
||||
<div className="route-widget-content">
|
||||
<div
|
||||
className={`route-widget-label ${
|
||||
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
|
||||
} ${getLabelSizeClass(startStation?.name)}`}
|
||||
title={startStation?.name}
|
||||
>
|
||||
{startStation?.name}
|
||||
</div>
|
||||
@@ -99,6 +100,7 @@ const RouteWidget = observer(() => {
|
||||
className={`route-widget-label ${
|
||||
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
|
||||
} ${getLabelSizeClass(endStation?.name)}`}
|
||||
title={endStation?.name}
|
||||
>
|
||||
{endStation?.name}
|
||||
</div>
|
||||
@@ -107,6 +109,7 @@ const RouteWidget = observer(() => {
|
||||
className={`route-widget-subtitle ${
|
||||
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
|
||||
}`}
|
||||
title={routeEnSubtitle}
|
||||
>
|
||||
{routeEnSubtitle}
|
||||
</div>
|
||||
@@ -116,6 +119,7 @@ const RouteWidget = observer(() => {
|
||||
className={`route-widget-subtitle ${
|
||||
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
|
||||
}`}
|
||||
title={routeZhSubtitle}
|
||||
>
|
||||
{routeZhSubtitle}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
||||
import { OrbitControls, Center, useGLTF } from "@react-three/drei";
|
||||
import React, { useEffect, useRef, Suspense, useCallback } from "react";
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||
import React, { useEffect, Suspense } from "react";
|
||||
import { BACKGROUND_COLOR } from "../../assets/Constants";
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||
@@ -23,7 +23,6 @@ interface ThreeViewProps {
|
||||
const ZOOM_FACTOR = 1.2;
|
||||
const MIN_DISTANCE = 1;
|
||||
const MAX_DISTANCE = 100;
|
||||
const CAMERA_FOV = 40;
|
||||
|
||||
const TouchController = () => {
|
||||
const { camera, controls, gl } = useThree();
|
||||
@@ -198,47 +197,6 @@ const AutoResize = () => {
|
||||
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 = ({
|
||||
fileUrl,
|
||||
onLoad,
|
||||
@@ -275,37 +233,21 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
onError,
|
||||
controlRef,
|
||||
}) => {
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const groupRef = useRef<THREE.Group>(null!);
|
||||
|
||||
const handleReady = useCallback(() => {
|
||||
setIsReady(true);
|
||||
onLoad?.();
|
||||
}, [onLoad]);
|
||||
|
||||
return (
|
||||
<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
|
||||
gl={{
|
||||
antialias: true,
|
||||
toneMappingExposure: 1.5,
|
||||
outputColorSpace: THREE.SRGBColorSpace,
|
||||
}}
|
||||
camera={{ position: [0, 0, 50], fov: CAMERA_FOV }}
|
||||
camera={{ position: [0, 0, 5], fov: 40 }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onError={(e: any) => onError?.(e.message)}
|
||||
>
|
||||
<AutoResize />
|
||||
<TouchController />
|
||||
{controlRef && <ZoomController controlRef={controlRef} />}
|
||||
<FitCamera groupRef={groupRef} onReady={handleReady} />
|
||||
<color attach="background" args={[BACKGROUND_COLOR]} />
|
||||
<ambientLight intensity={0.8} />
|
||||
<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} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Center precise>
|
||||
<group ref={groupRef}>
|
||||
<Model fileUrl={fileUrl} />
|
||||
</group>
|
||||
</Center>
|
||||
<Stage
|
||||
environment={null}
|
||||
intensity={1}
|
||||
castShadow={false}
|
||||
shadows={false}
|
||||
adjustCamera={true}
|
||||
center={{ precise: true }}
|
||||
>
|
||||
<Model fileUrl={fileUrl} onLoad={onLoad} />
|
||||
</Stage>
|
||||
</Suspense>
|
||||
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableZoom={true}
|
||||
enablePan={true}
|
||||
target={[0, 0, 0]}
|
||||
target={[50, 50, 50]}
|
||||
minDistance={1}
|
||||
maxDistance={100}
|
||||
enableDamping={true}
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
.side-menu-sights-block {
|
||||
height: calc(60%);
|
||||
overflow-y: scroll;
|
||||
margin-left: 0;
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
padding-right: 5px;
|
||||
margin-right: 5px;
|
||||
touch-action: none; /* Отключаем стандартные действия */
|
||||
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
|
||||
}
|
||||
|
||||
@@ -5,124 +5,37 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 420px;
|
||||
max-height: calc(100vh - 150px - 98px);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(
|
||||
background: linear-gradient(
|
||||
114deg,
|
||||
rgba(255, 255, 255, 0) 8.71%,
|
||||
rgba(255, 255, 255, 0.16) 69.69%
|
||||
),
|
||||
var(--carrier-left, #006f3a);
|
||||
var(--carrier-main, #006F3A);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dynamic-widget-image {
|
||||
padding-top: 2px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
width: 416px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
object-fit: cover;
|
||||
border-radius-top-left: 10px;
|
||||
border-radius-top-right: 10px;
|
||||
padding-top: 4px;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
width: 412px;
|
||||
}
|
||||
|
||||
.dynamic-widget-label {
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
box-sizing: border-box;
|
||||
width: 380px;
|
||||
margin-top: 29px;
|
||||
font-size: 20px;
|
||||
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 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 25px;
|
||||
width: 380px;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
line-height: 135%;
|
||||
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;
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
@@ -67,78 +67,17 @@
|
||||
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 {
|
||||
margin-top: 15px;
|
||||
color: #fff;
|
||||
font-family: "Roboto";
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
line-height: 135%;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container {
|
||||
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;
|
||||
max-height: 200px; /* Пример ограничения высоты */
|
||||
overflow-y: auto;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.left-widget-image {
|
||||
@@ -166,15 +105,24 @@
|
||||
animation: side-menu-marquee 14s linear infinite;
|
||||
}
|
||||
|
||||
.side-menu-sight-transfer-list {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
/* Анимация для списка пересадок */
|
||||
.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;
|
||||
transition:
|
||||
height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.side-menu-sight-transfer-list {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */
|
||||
}
|
||||
|
||||
/* Активное состояние - когда список открыт */
|
||||
.side-menu-sight-transfer-list.open {
|
||||
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
@@ -48,8 +6,6 @@
|
||||
}
|
||||
|
||||
.list-of-sights {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||
@@ -61,11 +17,11 @@
|
||||
rgba(255, 255, 255, 0) 8.71%,
|
||||
rgba(255, 255, 255, 0.16) 69.69%
|
||||
),
|
||||
var(--carrier-right, #806c59);
|
||||
var(--carrier-right, #006f3a);
|
||||
color: white;
|
||||
|
||||
max-height: 68px;
|
||||
transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: max-height 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -107,7 +63,11 @@
|
||||
border-radius: 10px;
|
||||
width: 128px;
|
||||
|
||||
background-color: var(--carrier-right-dark, #005a2f);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--carrier-right, #006f3a) 80%,
|
||||
black
|
||||
);
|
||||
}
|
||||
|
||||
.list-of-sights-title {
|
||||
@@ -125,7 +85,7 @@
|
||||
|
||||
opacity: 0;
|
||||
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;
|
||||
touch-action: pan-y;
|
||||
@@ -134,29 +94,6 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -170,12 +107,6 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.list-of-sights-content .custom-scrollbar-track {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sight-component {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -248,18 +179,18 @@
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sight-frame {
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
left: 0;
|
||||
bottom: 66px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-bottom: 32px;
|
||||
width: 550px;
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(
|
||||
@@ -267,14 +198,8 @@
|
||||
rgba(255, 255, 255, 0) 8.71%,
|
||||
rgba(255, 255, 255, 0.16) 69.69%
|
||||
),
|
||||
var(--carrier-right, #806c59);
|
||||
var(--carrier-right, #006f3a);
|
||||
max-height: calc(100vh - 128px);
|
||||
transform: translate3d(0, 0, 0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sight-frame.three-d-fullscreen {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sight-frame-image {
|
||||
@@ -291,11 +216,6 @@
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.sight-frame-content.is-switching {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sight-frame-get-back-wrapper {
|
||||
@@ -321,37 +241,15 @@
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0.04) 100%
|
||||
),
|
||||
rgba(var(--carrier-right-rgb, 128, 108, 89), 0.72);
|
||||
rgba(var(--carrier-right-rgb, 0, 111, 58), 0.72);
|
||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||
box-sizing: border-box;
|
||||
color: white;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
@@ -369,40 +267,18 @@
|
||||
}
|
||||
|
||||
.sight-frame-text-wrapper {
|
||||
flex-grow: 1;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
max-height: calc(80vh - 354px);
|
||||
min-height: 0;
|
||||
flex-shrink: 0;
|
||||
padding: 16px 16px 76px 16px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.sight-frame-text-wrapper.scrollable-container {
|
||||
padding: 16px 16px 76px 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;
|
||||
padding: 16px;
|
||||
}
|
||||
.sight-frame-text-wrapper .scrollable-viewport {
|
||||
flex: 1;
|
||||
@@ -413,12 +289,11 @@
|
||||
}
|
||||
|
||||
.sight-frame-text {
|
||||
display: flow-root; /* BFC prevents margin collapsing for correct scrollHeight measurement */
|
||||
padding-right: 10px;
|
||||
text-align: left;
|
||||
color: #fff;
|
||||
font-family: "Roboto";
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 150%;
|
||||
@@ -433,7 +308,7 @@
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 35%,
|
||||
#0e8953 50%,
|
||||
color-mix(in srgb, var(--carrier-right, #006f3a) 80%, black) 50%,
|
||||
transparent 65%
|
||||
);
|
||||
border-radius: 3px;
|
||||
@@ -455,57 +330,13 @@
|
||||
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 {
|
||||
z-index: 100000;
|
||||
position: relative;
|
||||
padding: 7px 60px;
|
||||
padding: 7px;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
justify-content: space-around;
|
||||
border-radius: 0px 0px 10px 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.8);
|
||||
background:
|
||||
@@ -514,22 +345,11 @@
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
||||
rgba(var(--carrier-right-rgb, 0, 111, 58), 0.4);
|
||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||
backdrop-filter: blur(10px);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
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 {
|
||||
@@ -540,17 +360,16 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
padding: 8px 12px;
|
||||
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition:
|
||||
background-color 0.1s ease,
|
||||
color 0.1s ease;
|
||||
}
|
||||
|
||||
.sight-frame-menu-point.active {
|
||||
text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff;
|
||||
border-bottom-color: #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sight-frame-text-wrapper::-webkit-scrollbar-track {
|
||||
@@ -575,20 +394,30 @@
|
||||
}
|
||||
.sight-frame {
|
||||
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 {
|
||||
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 {
|
||||
from {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,7 +469,6 @@
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 150%;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.svg-container {
|
||||
@@ -695,20 +523,17 @@
|
||||
}
|
||||
|
||||
.alphabet {
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
margin-right: 10px;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 10px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 676px;
|
||||
height: 650px;
|
||||
padding-bottom: 30px;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
@@ -758,9 +583,8 @@
|
||||
}
|
||||
|
||||
.alphabet-position {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transfer-button-container {
|
||||
@@ -927,7 +751,7 @@
|
||||
border-radius: 32px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
background: var(--carrier-main, #006f3a);
|
||||
background: var(--carrier-right, #006f3a);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -26,17 +26,15 @@
|
||||
position: fixed;
|
||||
display: inline-flex;
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
||||
background:
|
||||
linear-gradient(
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(179, 165, 152, 0.4);
|
||||
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
z-index: 10000001;
|
||||
@@ -52,7 +50,7 @@
|
||||
height: 96px;
|
||||
background-color: #fcd500;
|
||||
color: black;
|
||||
border-radius: 10px 0 0 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,6 +16,17 @@
|
||||
var(--carrier-left, #006f3a);
|
||||
}
|
||||
|
||||
.side-menu-label {
|
||||
margin-top: 10px;
|
||||
text-align: left;
|
||||
font-size: 15px;
|
||||
padding: 0 20px;
|
||||
align-self: flex-start;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
.appeal-button {
|
||||
color: black;
|
||||
padding: 8px 16px;
|
||||
@@ -24,12 +35,11 @@
|
||||
font-size: 16px;
|
||||
margin-top: 120px;
|
||||
font-weight: 500;
|
||||
width: 220px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.side-menu-buttons {
|
||||
width: 220px;
|
||||
margin-top: 260px;
|
||||
}
|
||||
|
||||
.side-menu-button {
|
||||
@@ -55,6 +65,24 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.side-menu-carrier-block {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.carrier-logo {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.side-menu-slogan {
|
||||
margin-top: 4px;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 150%;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.side-menu-bottom-photo {
|
||||
width: 100%;
|
||||
margin-top: 32px;
|
||||
@@ -168,8 +196,6 @@
|
||||
overflow: hidden;
|
||||
top: 250px;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 10px 10px 0px 0px;
|
||||
background:
|
||||
linear-gradient(
|
||||
@@ -185,6 +211,8 @@
|
||||
transition:
|
||||
transform 0.3s ease-out,
|
||||
opacity 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.side-menu-sights.slide-in {
|
||||
@@ -199,10 +227,12 @@
|
||||
.side-menu-sights-block {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
max-width: calc(100% - 20px);
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
@@ -210,6 +240,7 @@
|
||||
|
||||
.side-menu-sight {
|
||||
padding-bottom: 2px;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 6px;
|
||||
margin-top: 6px;
|
||||
border-bottom: 1px solid
|
||||
@@ -223,17 +254,6 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
@@ -255,6 +275,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.side-menu-crest {
|
||||
width: 170px;
|
||||
align-self: flex-start;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.side-menu-control-lang-open {
|
||||
gap: 10px;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -43,8 +43,6 @@
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar-thumb {
|
||||
@@ -57,7 +55,6 @@
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transition: opacity 0.5s ease, height 0.5s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar-thumb:active {
|
||||
@@ -65,12 +62,11 @@
|
||||
}
|
||||
|
||||
.side-menu-sights-block .scrollable-viewport {
|
||||
height: calc(98%);
|
||||
height: calc(92%);
|
||||
}
|
||||
|
||||
.side-menu-sights-block .scrollable {
|
||||
height: 100%;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.list-of-sights-content .scrollable-viewport {
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore, selectedCityStore } from "@shared";
|
||||
import { articlesStore } from "@shared";
|
||||
|
||||
const ArticleCreatePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const { articleData } = articlesStore;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore, languageStore, selectedCityStore } from "@shared";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const ArticleEditPage: React.FC = observer(() => {
|
||||
@@ -11,11 +11,6 @@ const ArticleEditPage: React.FC = observer(() => {
|
||||
|
||||
const { articleData, getArticle } = articlesStore;
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ArticleListPage = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchArticles();
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -55,12 +55,11 @@ export const ArticleListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<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" />
|
||||
</button>
|
||||
{canWriteArticles && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -108,9 +107,7 @@ export const ArticleListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<div className="w-full">
|
||||
<DataGrid
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
@@ -19,14 +20,12 @@ import {
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
useSelectedCity,
|
||||
selectedCityStore,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
editSightStore,
|
||||
} from "@shared";
|
||||
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 };
|
||||
|
||||
@@ -80,36 +79,20 @@ const ColorPickerField = ({
|
||||
|
||||
export const CarrierCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
createCarrierData,
|
||||
setCreateCarrierData,
|
||||
setCreateCarrierMedia,
|
||||
} = carrierStore;
|
||||
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMediaField, setActiveMediaField] = useState<
|
||||
"preview_media" | "video_preview"
|
||||
>("preview_media");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
| "thumbnail"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image"
|
||||
| "video_preview"
|
||||
| null
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCities = async () => {
|
||||
if (!authStore.me) {
|
||||
@@ -148,16 +131,12 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
selectedCityId,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
selectedCityId,
|
||||
createCarrierData,
|
||||
createCarrierData.city_id,
|
||||
language,
|
||||
setCreateCarrierData,
|
||||
]);
|
||||
}, [selectedCityId, createCarrierData.city_id]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
@@ -166,7 +145,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
|
||||
toast.success("Перевозчик успешно создан");
|
||||
navigate("/carrier");
|
||||
} catch {
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при создании перевозчика");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -179,22 +158,27 @@ export const CarrierCreatePage = observer(() => {
|
||||
media_name?: string;
|
||||
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(
|
||||
createCarrierData.preview_media,
|
||||
)
|
||||
const selectedMedia =
|
||||
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
|
||||
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||
: null;
|
||||
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
|
||||
? null
|
||||
: createCarrierData.preview_media;
|
||||
const effectiveVideoPreviewId = isMediaIdEmpty(
|
||||
createCarrierData.video_preview,
|
||||
)
|
||||
? null
|
||||
: createCarrierData.video_preview;
|
||||
: selectedMedia?.id ?? selectedMediaId ?? null;
|
||||
|
||||
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 />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
@@ -222,6 +206,8 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
e.target.value as number,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
)
|
||||
}
|
||||
@@ -244,6 +230,8 @@ export const CarrierCreatePage = observer(() => {
|
||||
e.target.value,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
)
|
||||
}
|
||||
@@ -259,6 +247,24 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData[language].full_name,
|
||||
e.target.value,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Слоган"
|
||||
value={createCarrierData[language].slogan}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
e.target.value,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
)
|
||||
}
|
||||
@@ -274,13 +280,15 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
{ ...colorFields(createCarrierData), main_color: val }
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
|
||||
Используется в: виджет маршрута, виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -292,6 +300,8 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
{ ...colorFields(createCarrierData), left_color: val }
|
||||
)
|
||||
@@ -310,6 +320,8 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
{ ...colorFields(createCarrierData), right_color: val }
|
||||
)
|
||||
@@ -321,58 +333,37 @@ export const CarrierCreatePage = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center items-start gap-10 flex-wrap">
|
||||
<div className="w-full max-w-[300px]">
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Изображение сайд-меню"
|
||||
imageKey="image"
|
||||
imageUrl={effectivePreviewMediaUrl}
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveLogoUrl}
|
||||
onImageClick={() => {
|
||||
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={() => {
|
||||
setActiveMediaField("preview_media");
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setActiveMediaField("preview_media");
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
setIsUploadMediaOpen(true);
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
variant="contained"
|
||||
@@ -398,7 +389,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={activeMediaField === "video_preview" ? 2 : 1}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
@@ -415,6 +406,6 @@ export const CarrierCreatePage = observer(() => {
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
@@ -20,26 +21,16 @@ import {
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
LoadingSpinner,
|
||||
selectedCityStore,
|
||||
editSightStore,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ImageUploadCard,
|
||||
LanguageSwitcher,
|
||||
VideoPreviewCard,
|
||||
} from "@widgets";
|
||||
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
type ColorFields = {
|
||||
main_color: string;
|
||||
left_color: string;
|
||||
right_color: string;
|
||||
};
|
||||
type ColorFields = { main_color: string; left_color: string; right_color: string };
|
||||
|
||||
const colorFields = (data: ColorFields) => ({
|
||||
main_color: data.main_color,
|
||||
@@ -92,12 +83,7 @@ const ColorPickerField = ({
|
||||
export const CarrierEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const {
|
||||
getCarrier,
|
||||
setEditCarrierData,
|
||||
setEditCarrierMedia,
|
||||
editCarrierData,
|
||||
} = carrierStore;
|
||||
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
const canReadCities = authStore.canRead("cities");
|
||||
|
||||
@@ -107,24 +93,12 @@ export const CarrierEditPage = observer(() => {
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
|
||||
const [initialCityName, setInitialCityName] = useState("");
|
||||
const [activeMediaField, setActiveMediaField] = useState<
|
||||
"preview_media" | "video_preview"
|
||||
>("preview_media");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
| "thumbnail"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image"
|
||||
| "video_preview"
|
||||
| null
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!id) {
|
||||
@@ -153,28 +127,26 @@ export const CarrierEditPage = observer(() => {
|
||||
carrierData.ru?.full_name || "",
|
||||
carrierData.ru?.short_name || "",
|
||||
carrierData.ru?.city_id || 0,
|
||||
carrierData.ru?.slogan || "",
|
||||
carrierData.ru?.logo || "",
|
||||
"ru",
|
||||
colors,
|
||||
colors
|
||||
);
|
||||
setEditCarrierData(
|
||||
carrierData.en?.full_name || "",
|
||||
carrierData.en?.short_name || "",
|
||||
carrierData.en?.city_id || 0,
|
||||
"en",
|
||||
carrierData.en?.slogan || "",
|
||||
carrierData.en?.logo || "",
|
||||
"en"
|
||||
);
|
||||
setEditCarrierData(
|
||||
carrierData.zh?.full_name || "",
|
||||
carrierData.zh?.short_name || "",
|
||||
carrierData.zh?.city_id || 0,
|
||||
"zh",
|
||||
);
|
||||
setEditCarrierMedia(
|
||||
"video_preview",
|
||||
carrierData.ru?.video_preview || "",
|
||||
);
|
||||
setEditCarrierMedia(
|
||||
"preview_media",
|
||||
carrierData.ru?.preview_media || "",
|
||||
carrierData.zh?.slogan || "",
|
||||
carrierData.zh?.logo || "",
|
||||
"zh"
|
||||
);
|
||||
setInitialCityName(carrierData.ru?.city || "");
|
||||
}
|
||||
@@ -186,7 +158,7 @@ export const CarrierEditPage = observer(() => {
|
||||
})();
|
||||
|
||||
languageStore.setLanguage("ru");
|
||||
}, [id, getCarrier, setEditCarrierData, setEditCarrierMedia]);
|
||||
}, [id]);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
@@ -194,7 +166,7 @@ export const CarrierEditPage = observer(() => {
|
||||
await carrierStore.editCarrier(Number(id));
|
||||
toast.success("Перевозчик успешно обновлен");
|
||||
navigate("/carrier");
|
||||
} catch {
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при обновлении перевозчика");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -207,19 +179,23 @@ export const CarrierEditPage = observer(() => {
|
||||
media_name?: string;
|
||||
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(
|
||||
editCarrierData.preview_media,
|
||||
)
|
||||
const selectedMedia =
|
||||
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
|
||||
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
|
||||
: null;
|
||||
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
|
||||
? null
|
||||
: editCarrierData.preview_media;
|
||||
const effectiveVideoPreviewId = isMediaIdEmpty(
|
||||
editCarrierData.video_preview,
|
||||
)
|
||||
? null
|
||||
: editCarrierData.video_preview;
|
||||
: (selectedMedia?.id ?? editCarrierData.logo);
|
||||
|
||||
const baseCities = canReadCities
|
||||
? cityStore.cities["ru"].data
|
||||
@@ -262,7 +238,7 @@ export const CarrierEditPage = observer(() => {
|
||||
}
|
||||
|
||||
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 />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
@@ -289,7 +265,9 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
Number(e.target.value),
|
||||
language,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -311,7 +289,9 @@ export const CarrierEditPage = observer(() => {
|
||||
e.target.value,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
language,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -326,7 +306,25 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData[language].full_name,
|
||||
e.target.value,
|
||||
editCarrierData.city_id,
|
||||
language,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Слоган"
|
||||
value={editCarrierData[language].slogan}
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
e.target.value,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -341,14 +339,15 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language,
|
||||
{ ...colorFields(editCarrierData), main_color: val },
|
||||
{ ...colorFields(editCarrierData), main_color: val }
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: значки на карте, скопление достопримечательностей
|
||||
на карте, информационный виджет
|
||||
Используется в: виджет маршрута, виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -360,14 +359,15 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language,
|
||||
{ ...colorFields(editCarrierData), left_color: val },
|
||||
{ ...colorFields(editCarrierData), left_color: val }
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: виджет обращений, боковое меню (фон, список
|
||||
остановок), левый виджет достопримечательности
|
||||
Используется в: боковое меню, левый виджет достопримечательности
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -379,70 +379,41 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language,
|
||||
{ ...colorFields(editCarrierData), right_color: val },
|
||||
{ ...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 max-w-[300px]">
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Изображение сайд-меню"
|
||||
imageKey="image"
|
||||
imageUrl={effectivePreviewMediaUrl}
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveLogoUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(effectivePreviewMediaUrl ?? "");
|
||||
setMediaId(effectiveLogoUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setIsDeleteLogoModalOpen(true);
|
||||
}}
|
||||
onDeleteImageClick={() =>
|
||||
setEditCarrierMedia("preview_media", "")
|
||||
}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMediaField("preview_media");
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setActiveMediaField("preview_media");
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
setIsUploadMediaOpen(true);
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
variant="contained"
|
||||
@@ -467,7 +438,7 @@ export const CarrierEditPage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={activeMediaField === "video_preview" ? 2 : 1}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
@@ -485,6 +456,22 @@ export const CarrierEditPage = observer(() => {
|
||||
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 { 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 { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
@@ -39,7 +39,7 @@ export const CarrierListPage = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -98,11 +98,10 @@ export const CarrierListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<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" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -162,9 +161,7 @@ export const CarrierListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -172,11 +169,6 @@ export const CarrierListPage = observer(() => {
|
||||
checkboxSelection={canWriteCarriers}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={(params) => {
|
||||
if (canWriteCarriers) {
|
||||
navigate(`/carrier/${params.id}/edit`);
|
||||
}
|
||||
}}
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
countryStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
snapshotStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
@@ -61,13 +60,7 @@ export const CityCreatePage = observer(() => {
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const ruCityName = createCityData.ru.name.trim();
|
||||
await cityStore.createCity();
|
||||
try {
|
||||
await snapshotStore.createEmptySnapshot(`${ruCityName}_Пустой_Экспорт`);
|
||||
} catch (e) {
|
||||
console.warn("Failed to create empty snapshot for city:", e);
|
||||
}
|
||||
toast.success("Город успешно создан");
|
||||
navigate("/city");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, languageStore, cityStore, countryStore, SearchInput } from "@shared";
|
||||
import { authStore, languageStore, cityStore, countryStore, selectedCityStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
@@ -59,16 +59,21 @@ export const CityListPage = observer(() => {
|
||||
}, [cities, countryStore.countries, language, isLoading]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) return [];
|
||||
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return rows;
|
||||
return rows.filter((row) => {
|
||||
const result = rows.filter((row) => row.id === selectedCityId);
|
||||
|
||||
if (!query) return result;
|
||||
return result.filter((row) => {
|
||||
const cityName = (row.name ?? "").toLowerCase();
|
||||
const countryName = (
|
||||
countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? ""
|
||||
).toLowerCase();
|
||||
return cityName.includes(query) || countryName.includes(query);
|
||||
});
|
||||
}, [rows, searchQuery, countryStore.countries, language]);
|
||||
}, [rows, searchQuery, countryStore.countries, language, selectedCityStore.selectedCityId]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -114,11 +119,10 @@ export const CityListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<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" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -143,6 +147,7 @@ export const CityListPage = observer(() => {
|
||||
<CreateButton
|
||||
label="Создать город"
|
||||
path="/city/create"
|
||||
disabled={!selectedCityStore.selectedCityId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -159,9 +164,7 @@ export const CityListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={filteredRows}
|
||||
@@ -169,11 +172,6 @@ export const CityListPage = observer(() => {
|
||||
checkboxSelection={canWriteCities}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={(params) => {
|
||||
if (canWriteCities) {
|
||||
navigate(`/city/${params.id}/edit`);
|
||||
}
|
||||
}}
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
@@ -208,6 +206,8 @@ export const CityListPage = observer(() => {
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityStore.selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет городов"
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, countryStore, languageStore, SearchInput } from "@shared";
|
||||
import { authStore, countryStore, languageStore, selectedCityStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Minus } from "lucide-react";
|
||||
@@ -74,15 +74,20 @@ export const CountryListPage = observer(() => {
|
||||
];
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const { selectedCity } = selectedCityStore;
|
||||
if (!selectedCity) {
|
||||
return [];
|
||||
}
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
return (countries[language]?.data ?? [])
|
||||
.filter((country) => country.code === selectedCity.country_code)
|
||||
.filter((country) => !query || (country.name ?? "").toLowerCase().includes(query))
|
||||
.map((country) => ({
|
||||
id: country.code,
|
||||
code: country.code,
|
||||
name: country.name,
|
||||
}));
|
||||
}, [countries[language]?.data, searchQuery]);
|
||||
}, [countries[language]?.data, searchQuery, selectedCityStore.selectedCity]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -95,6 +100,7 @@ export const CountryListPage = observer(() => {
|
||||
<CreateButton
|
||||
label="Добавить страну"
|
||||
path="/country/add"
|
||||
disabled={!selectedCityStore.selectedCityId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -111,9 +117,7 @@ export const CountryListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -155,6 +159,8 @@ export const CountryListPage = observer(() => {
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityStore.selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет стран"
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "@widgets";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
@@ -63,14 +62,6 @@ export const CreateSightPage = observer(() => {
|
||||
await authStore.fetchMeCities().catch(() => undefined);
|
||||
}
|
||||
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();
|
||||
}, []);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -20,11 +20,6 @@ export const MediaCreatePage = observer(() => {
|
||||
const [type, setType] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
MEDIA_TYPE_LABELS,
|
||||
languageStore,
|
||||
LoadingSpinner,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { MediaViewer } from "@widgets";
|
||||
|
||||
@@ -43,11 +42,6 @@ export const MediaEditPage = observer(() => {
|
||||
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
|
||||
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
mediaStore.getOneMedia(id);
|
||||
|
||||
@@ -78,12 +78,11 @@ export const MediaListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<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" />
|
||||
</button>
|
||||
{canWriteMedia && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -113,9 +112,7 @@ export const MediaListPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
{canWriteMedia && ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer, VideoPreviewCard, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
MediaViewer,
|
||||
VideoPreviewCard,
|
||||
ImageUploadCard,
|
||||
} from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
@@ -46,7 +50,6 @@ export const RouteCreatePage = observer(() => {
|
||||
const [routeCoords, setRouteCoords] = useState("");
|
||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||
const [buttonText, setButtonText] = useState("");
|
||||
const [direction, setDirection] = useState("backward");
|
||||
const [scaleMin, setScaleMin] = useState("10");
|
||||
const [scaleMax, setScaleMax] = useState("100");
|
||||
@@ -136,16 +139,6 @@ export const RouteCreatePage = observer(() => {
|
||||
articlesStore.getArticleList();
|
||||
};
|
||||
|
||||
const handleIconSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setIcon(media.id);
|
||||
setIsSelectIconDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
@@ -176,6 +169,20 @@ export const RouteCreatePage = observer(() => {
|
||||
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 =
|
||||
icon && !isMediaIdEmpty(icon)
|
||||
? mediaStore.media.find((m) => m.id === icon)
|
||||
@@ -285,10 +292,6 @@ export const RouteCreatePage = observer(() => {
|
||||
newRoute.governor_appeal = governor_appeal;
|
||||
}
|
||||
|
||||
if (buttonText.trim()) {
|
||||
newRoute.button_text = buttonText.trim();
|
||||
}
|
||||
|
||||
const newId = await routeStore.createRoute(newRoute);
|
||||
toast.success("Маршрут успешно создан");
|
||||
navigate(`/route/${newId}/edit`);
|
||||
@@ -404,18 +407,6 @@ export const RouteCreatePage = observer(() => {
|
||||
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>
|
||||
@@ -481,7 +472,7 @@ export const RouteCreatePage = observer(() => {
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={effectiveVideoId}
|
||||
onVideoClick={() => setIsVideoPreviewOpen(true)}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => setVideoPreview("")}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
@@ -570,7 +561,7 @@ export const RouteCreatePage = observer(() => {
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Таймер показа видео при бездействии (сек)"
|
||||
label="Таймер видео заставки (сек)"
|
||||
type="number"
|
||||
value={videoTimer}
|
||||
onChange={(e) => {
|
||||
@@ -643,10 +634,11 @@ export const RouteCreatePage = observer(() => {
|
||||
}}
|
||||
hardcodeType="video_preview"
|
||||
contextObjectName={routeName || "Маршрут"}
|
||||
contextType="route"
|
||||
contextType="sight"
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectIconDialogOpen}
|
||||
onClose={() => setIsSelectIconDialogOpen(false)}
|
||||
|
||||
@@ -244,7 +244,9 @@ export const RouteEditPage = observer(() => {
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
routeStore.setEditRouteData({ video_preview: media.id });
|
||||
routeStore.setEditRouteData({
|
||||
video_preview: media.id,
|
||||
});
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
@@ -263,11 +265,19 @@ export const RouteEditPage = observer(() => {
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
routeStore.setEditRouteData({ video_preview: media.id });
|
||||
routeStore.setEditRouteData({
|
||||
video_preview: media.id,
|
||||
});
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||
setIsVideoPreviewOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем название выбранной статьи для отображения
|
||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||
(article) => article.id === editRouteData.governor_appeal
|
||||
@@ -545,7 +555,7 @@ export const RouteEditPage = observer(() => {
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Таймер показа видео при бездействии (сек)"
|
||||
label="Таймер видео заставки (сек)"
|
||||
type="number"
|
||||
value={editRouteData.video_timer ?? 420}
|
||||
onChange={(e) => {
|
||||
@@ -556,22 +566,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>
|
||||
@@ -640,10 +634,10 @@ export const RouteEditPage = observer(() => {
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={effectiveVideoId}
|
||||
onVideoClick={() => setIsVideoPreviewOpen(true)}
|
||||
onDeleteVideoClick={() =>
|
||||
routeStore.setEditRouteData({ video_preview: "" })
|
||||
}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
}}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -731,10 +725,11 @@ export const RouteEditPage = observer(() => {
|
||||
}}
|
||||
hardcodeType="video_preview"
|
||||
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||||
contextType="route"
|
||||
contextType="sight"
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectIconDialogOpen}
|
||||
onClose={() => setIsSelectIconDialogOpen(false)}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const RouteListPage = observer(() => {
|
||||
loadCounts(routeIds);
|
||||
};
|
||||
fetchData();
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -178,23 +178,22 @@ export const RouteListPage = observer(() => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{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" />
|
||||
</button>
|
||||
)}
|
||||
{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" />
|
||||
</button>
|
||||
)}
|
||||
{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" />
|
||||
</button>
|
||||
)}
|
||||
{canWriteRoutes && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -254,6 +253,7 @@ export const RouteListPage = observer(() => {
|
||||
<CreateButton
|
||||
label="Создать маршрут"
|
||||
path="/route/create"
|
||||
disabled={!selectedCityStore.selectedCityId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -270,9 +270,7 @@ export const RouteListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
|
||||
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 { useNavigate, useNavigationType } from "react-router";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authInstance } from "@shared";
|
||||
import { authInstance, isMediaIdEmpty } from "@shared";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import LanguageSelector from "./web-gl/LanguageSelector";
|
||||
|
||||
@@ -16,17 +17,17 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
const navigate = useNavigate();
|
||||
const navigationType = useNavigationType();
|
||||
const { routeData } = useMapData();
|
||||
const [previewMedia, setPreviewMedia] = useState<string | null>(null);
|
||||
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
|
||||
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||
const [carrierSlogan, setCarrierSlogan] = useState<string | null>(null);
|
||||
const [carrierShortName, setCarrierShortName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCarrierData() {
|
||||
if (routeData?.carrier_id) {
|
||||
const carrier = (
|
||||
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
||||
).data;
|
||||
setPreviewMedia(carrier.preview_media ?? null);
|
||||
setIsPreviewMediaError(false);
|
||||
const carrier = (await authInstance.get(`/carrier/${routeData.carrier_id}`)).data;
|
||||
setCarrierLogo(carrier.logo);
|
||||
setCarrierSlogan(carrier.slogan ?? null);
|
||||
setCarrierShortName(carrier.short_name ?? null);
|
||||
}
|
||||
}
|
||||
fetchCarrierData();
|
||||
@@ -44,20 +45,12 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 288,
|
||||
height: "100%",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{/* Кнопка назад — вне основного меню */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 12px 0",
|
||||
opacity: open ? 1 : 0,
|
||||
pointerEvents: open ? "auto" : "none",
|
||||
transition: "opacity 0.25s ease",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "12px 12px 0" }}>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="contained"
|
||||
@@ -91,13 +84,45 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
pointerEvents: open ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Кнопки — .side-menu-buttons */}
|
||||
{/* Герб — .side-menu-crest */}
|
||||
<div
|
||||
style={{
|
||||
width: 220,
|
||||
marginTop: routeData?.governor_appeal || 0 > 0 ? 40 : 260,
|
||||
width: 170,
|
||||
height: 170,
|
||||
alignSelf: "flex-start",
|
||||
marginLeft: 20,
|
||||
backgroundColor: "rgba(255,255,255,0.15)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Герб
|
||||
</div>
|
||||
|
||||
{/* Слоган — .side-menu-label */}
|
||||
{carrierSlogan && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
textAlign: "left",
|
||||
fontSize: 15,
|
||||
padding: "0 20px",
|
||||
alignSelf: "flex-start",
|
||||
fontWeight: 400,
|
||||
lineHeight: "150%",
|
||||
}}
|
||||
>
|
||||
{carrierSlogan}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки — .side-menu-buttons */}
|
||||
<div style={{ width: 220, marginTop: 260 }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
@@ -135,32 +160,42 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* .side-menu-carrier-block */}
|
||||
<div style={{ padding: "0 20px" }}>
|
||||
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||
<div style={{ width: 170 }}>
|
||||
<MediaViewer
|
||||
media={{ id: carrierLogo, media_type: 1, filename: "carrier_logo" }}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{carrierShortName && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
textAlign: "left",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
lineHeight: "150%",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{carrierShortName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* .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)}
|
||||
src="/side-menu-photo.png"
|
||||
alt=""
|
||||
style={{
|
||||
width: "288px",
|
||||
marginTop: 32,
|
||||
display: "block",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
style={{ width: "100%", marginTop: 32, display: "block", pointerEvents: "none" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute bottom-[20px] z-10"
|
||||
style={{
|
||||
right: open ? -520 : -312,
|
||||
transition: "right 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
||||
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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 { 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 { MapDataProvider, useMapData } from "./MapDataContext";
|
||||
import { TransformProvider, useTransform } from "./TransformContext";
|
||||
@@ -13,6 +22,15 @@ import { UP_SCALE } from "./Constants";
|
||||
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
||||
import { CircularProgress } from "@mui/material";
|
||||
|
||||
extend({
|
||||
Container,
|
||||
Graphics,
|
||||
Sprite,
|
||||
Texture,
|
||||
TilingSprite,
|
||||
Text,
|
||||
});
|
||||
|
||||
const Loading = () => {
|
||||
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
||||
|
||||
@@ -36,8 +54,7 @@ export const RoutePreview = () => {
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
zIndex: 20,
|
||||
width: isLeftSidebarOpen ? 288 : 0,
|
||||
width: isLeftSidebarOpen ? 300 : 0,
|
||||
transition: "width 0.3s ease",
|
||||
overflow: "visible",
|
||||
height: "100%",
|
||||
@@ -76,10 +93,9 @@ export const RouteMap = observer(() => {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previousBodyOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -129,14 +145,14 @@ export const RouteMap = observer(() => {
|
||||
) {
|
||||
const coordinates = coordinatesToLocal(
|
||||
originalRouteData?.center_latitude,
|
||||
originalRouteData?.center_longitude,
|
||||
originalRouteData?.center_longitude
|
||||
);
|
||||
|
||||
setTransform(
|
||||
coordinates.x,
|
||||
coordinates.y,
|
||||
originalRouteData?.rotate,
|
||||
originalRouteData?.scale_min,
|
||||
originalRouteData?.scale_min
|
||||
);
|
||||
setIsSetup(true);
|
||||
}
|
||||
@@ -155,6 +171,25 @@ export const RouteMap = observer(() => {
|
||||
|
||||
return (
|
||||
<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 />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface RouteData {
|
||||
icon_size?: number;
|
||||
font_size: number;
|
||||
governor_appeal: number;
|
||||
button_text?: string;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
|
||||
@@ -26,17 +26,15 @@
|
||||
position: fixed;
|
||||
display: inline-flex;
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
||||
background:
|
||||
linear-gradient(
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(179, 165, 152, 0.4);
|
||||
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
z-index: 10000001;
|
||||
@@ -52,8 +50,7 @@
|
||||
height: 96px;
|
||||
background-color: #fcd500;
|
||||
color: black;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@@ -2327,6 +2327,9 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
const stationScreenY =
|
||||
rotatedY * camera.scale + camera.translation.y;
|
||||
|
||||
const labelX = stationScreenX + offsetX;
|
||||
const labelY = stationScreenY + offsetY;
|
||||
|
||||
const backendAlign = station.align;
|
||||
|
||||
const anchor = getAnchorFromOffset(backendAlign ?? 2);
|
||||
@@ -2336,6 +2339,8 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
|
||||
const cssX = labelX / dpr;
|
||||
const cssY = labelY / dpr;
|
||||
const rotationCss = `${rotationAngle}rad`;
|
||||
const counterRotationCss = `${-rotationAngle}rad`;
|
||||
|
||||
@@ -2354,13 +2359,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
|
||||
|
||||
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 secondaryMarginTop = 5 * fontScale * scaleFactor;
|
||||
@@ -2406,7 +2404,13 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
hoveredStationIconId === 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 (
|
||||
<div key={station.id}>
|
||||
@@ -2436,7 +2440,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
color: "#fff",
|
||||
fontFamily: "Roboto, sans-serif",
|
||||
textAlign: "left",
|
||||
pointerEvents: "none",
|
||||
pointerEvents: "auto",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
@@ -2444,16 +2448,14 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
pointerEvents: "none",
|
||||
pointerEvents: "auto",
|
||||
transformOrigin: "left center",
|
||||
transform: `rotate(${rotationCss})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
pointerEvents: "none",
|
||||
pointerEvents: "auto",
|
||||
transformOrigin: "left center",
|
||||
transform: `rotate(${counterRotationCss})`,
|
||||
}}
|
||||
@@ -2547,7 +2549,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
fontWeight: 700,
|
||||
fontSize: primaryFontSize,
|
||||
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
||||
@@ -2556,6 +2557,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
}}
|
||||
>
|
||||
{station.name}
|
||||
</div>
|
||||
{showSecondary ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -2578,7 +2580,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hoveredStationId === station.id && (
|
||||
<div
|
||||
style={{
|
||||
@@ -2586,9 +2587,9 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
top: "100%",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
paddingTop: "8px",
|
||||
paddingTop: menuPaddingTop,
|
||||
pointerEvents: "auto",
|
||||
zIndex: 1000000,
|
||||
zIndex: 10,
|
||||
cursor: "default",
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const SightListPage = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSights();
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -105,11 +105,10 @@ export const SightListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<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" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -165,6 +164,7 @@ export const SightListPage = observer(() => {
|
||||
<CreateButton
|
||||
label="Создать достопримечательность"
|
||||
path="/sight/create"
|
||||
disabled={!selectedCityStore.selectedCityId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -181,9 +181,7 @@ export const SightListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
|
||||
@@ -1,58 +1,46 @@
|
||||
import {
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared";
|
||||
import { Button, TextField } from "@mui/material";
|
||||
import { snapshotStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
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(() => {
|
||||
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
|
||||
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
|
||||
|
||||
const exportNameRegex = useMemo(() => {
|
||||
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
|
||||
return buildExportNameRegex(names);
|
||||
}, [cityStore.cities["ru"].data.length]);
|
||||
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}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityStore.cities["ru"].loaded) {
|
||||
cityStore.getCities("ru");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canReadRoutes = authStore.canRead("routes");
|
||||
|
||||
const startExport = async () => {
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const id = await createSnapshot(name);
|
||||
@@ -80,118 +68,8 @@ export const SnapshotCreatePage = observer(() => {
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<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())}
|
||||
disabled={isLoading || !name.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -209,45 +87,6 @@ export const SnapshotCreatePage = observer(() => {
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
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 { observer } from "mobx-react-lite";
|
||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||
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;
|
||||
|
||||
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 = [
|
||||
"#FF3B30",
|
||||
"#FF9500",
|
||||
@@ -43,14 +33,11 @@ export const SnapshotListPage = observer(() => {
|
||||
createEmptySnapshot,
|
||||
} = snapshotStore;
|
||||
const canWriteDevices = authStore.canWrite("devices");
|
||||
const canReadDevices = authStore.canRead("devices");
|
||||
const canCreateSnapshot =
|
||||
authStore.hasRole("snapshot_create") && canWriteDevices;
|
||||
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isSnapshotOnDeviceWarning, setIsSnapshotOnDeviceWarning] = useState(false);
|
||||
const [devicesWithSnapshot, setDevicesWithSnapshot] = useState<string[]>([]);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const { language } = languageStore;
|
||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
@@ -58,12 +45,6 @@ export const SnapshotListPage = observer(() => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
|
||||
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 [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
@@ -80,11 +61,7 @@ export const SnapshotListPage = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchSnapshots = async () => {
|
||||
setIsLoading(true);
|
||||
const promises: Promise<void>[] = [getSnapshots(), getStorageInfo()];
|
||||
if (canReadDevices && !vehicleStore.vehicles.loaded) {
|
||||
promises.push(vehicleStore.getVehicles());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
await Promise.all([getSnapshots(), getStorageInfo()]);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSnapshots();
|
||||
@@ -131,7 +108,7 @@ export const SnapshotListPage = observer(() => {
|
||||
},
|
||||
{
|
||||
field: "created_at",
|
||||
headerName: "Дата создания (UTC+03:00, MSK)",
|
||||
headerName: "Дата создания",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return <div>{params.value ? params.value : "-"}</div>;
|
||||
@@ -160,7 +137,6 @@ export const SnapshotListPage = observer(() => {
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button
|
||||
title="Восстановить"
|
||||
onClick={() => {
|
||||
setIsRestoreModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -169,22 +145,9 @@ export const SnapshotListPage = observer(() => {
|
||||
<DatabaseBackup size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
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);
|
||||
setRowId(snapshotId);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
@@ -236,7 +199,6 @@ export const SnapshotListPage = observer(() => {
|
||||
disabled={isLowStorage}
|
||||
onClick={() => {
|
||||
setEmptySnapshotName("");
|
||||
setEmptySnapshotNameError(null);
|
||||
setIsEmptySnapshotModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -335,9 +297,7 @@ export const SnapshotListPage = observer(() => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -389,19 +349,7 @@ export const SnapshotListPage = observer(() => {
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={emptySnapshotName}
|
||||
error={!!emptySnapshotNameError}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setEmptySnapshotName(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -411,7 +359,7 @@ export const SnapshotListPage = observer(() => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!exportNameRegex.test(emptySnapshotName.trim()) || isCreatingEmpty}
|
||||
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
|
||||
onClick={async () => {
|
||||
setIsCreatingEmpty(true);
|
||||
try {
|
||||
@@ -428,36 +376,6 @@ export const SnapshotListPage = observer(() => {
|
||||
</DialogActions>
|
||||
</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
|
||||
open={isRestoreModalOpen}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -49,7 +49,7 @@ export const StationListPage = observer(() => {
|
||||
loadSightCounts(stationIds);
|
||||
};
|
||||
fetchStations();
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -141,7 +141,7 @@ export const StationListPage = observer(() => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{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" />
|
||||
</button>
|
||||
)}
|
||||
@@ -151,14 +151,13 @@ export const StationListPage = observer(() => {
|
||||
setSelectedStationId(params.row.id);
|
||||
setIsTransfersModalOpen(true);
|
||||
}}
|
||||
title="Управление пересадками"
|
||||
title="Редактировать пересадки"
|
||||
>
|
||||
<Route size={20} className="text-purple-500" />
|
||||
</button>
|
||||
)}
|
||||
{canWriteStations && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -209,6 +208,7 @@ export const StationListPage = observer(() => {
|
||||
<CreateButton
|
||||
label="Создать остановку"
|
||||
path="/station/create"
|
||||
disabled={!selectedCityStore.selectedCityId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -225,9 +225,7 @@ export const StationListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
|
||||
@@ -2,8 +2,16 @@ import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
@@ -17,10 +25,43 @@ import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
|
||||
import { ImageUploadCard } 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 UserCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -38,11 +79,6 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mediaStore.getMedia();
|
||||
}, []);
|
||||
@@ -234,8 +270,133 @@ export const UserCreatePage = observer(() => {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
|
||||
<RolesHintTable />
|
||||
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||
<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,
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
|
||||
<Checkbox
|
||||
checked={localRoles.includes("devices_maintenance_rw")}
|
||||
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||
size="small"
|
||||
title="Техническое обслуживание (ТО)"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
-
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</section>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Paper,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
@@ -22,12 +30,45 @@ import {
|
||||
authStore,
|
||||
cityStore,
|
||||
MultiSelect,
|
||||
selectedCityStore,
|
||||
type User,
|
||||
type UserCity,
|
||||
} from "@shared";
|
||||
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(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -51,11 +92,6 @@ export const UserEditPage = observer(() => {
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
@@ -316,8 +352,133 @@ export const UserEditPage = observer(() => {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
|
||||
<RolesHintTable />
|
||||
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||
<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,
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
|
||||
<Checkbox
|
||||
checked={localRoles.includes("devices_maintenance_rw")}
|
||||
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||
size="small"
|
||||
title="Техническое обслуживание (ТО)"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
-
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</section>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -92,11 +92,10 @@ export const UserListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<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" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -147,9 +146,7 @@ export const UserListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -157,11 +154,6 @@ export const UserListPage = observer(() => {
|
||||
checkboxSelection={canWriteUsers}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={(params) => {
|
||||
if (canWriteUsers) {
|
||||
navigate(`/user/${params.row.id}/edit`);
|
||||
}
|
||||
}}
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
VEHICLE_TYPES,
|
||||
carrierStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
@@ -28,18 +26,11 @@ export const VehicleCreatePage = observer(() => {
|
||||
const [type, setType] = useState("");
|
||||
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||
const [model, setModel] = useState("");
|
||||
const [cityId, setCityId] = useState<number | null>(selectedCityStore.selectedCityId);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers(language);
|
||||
cityStore.getCities("ru");
|
||||
}, [language]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -52,7 +43,6 @@ export const VehicleCreatePage = observer(() => {
|
||||
?.full_name as string,
|
||||
carrierId!,
|
||||
model || undefined,
|
||||
cityId ?? undefined,
|
||||
);
|
||||
toast.success("Транспорт успешно создан");
|
||||
} catch (error) {
|
||||
@@ -83,11 +73,12 @@ export const VehicleCreatePage = observer(() => {
|
||||
onChange={(e) => setTailNumber(e.target.value)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select
|
||||
value={type}
|
||||
label="Тип"
|
||||
required
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
@@ -98,11 +89,12 @@ export const VehicleCreatePage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Перевозчик</InputLabel>
|
||||
<Select
|
||||
value={carrierId || ""}
|
||||
label="Перевозчик"
|
||||
required
|
||||
onChange={(e) => setCarrierId(e.target.value as number)}
|
||||
>
|
||||
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||
@@ -121,27 +113,12 @@ export const VehicleCreatePage = observer(() => {
|
||||
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
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !tailNumber || !type || !carrierId || !cityId}
|
||||
disabled={isLoading || !tailNumber || !type || !carrierId}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
VEHICLE_TYPES,
|
||||
vehicleStore,
|
||||
LoadingSpinner,
|
||||
cityStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -40,11 +38,6 @@ export const VehicleEditPage = observer(() => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
@@ -61,7 +54,6 @@ export const VehicleEditPage = observer(() => {
|
||||
try {
|
||||
await getVehicle(Number(id));
|
||||
await getCarriers(language);
|
||||
await cityStore.getCities("ru");
|
||||
|
||||
setEditVehicleData({
|
||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
|
||||
@@ -71,7 +63,6 @@ export const VehicleEditPage = observer(() => {
|
||||
model: vehicle[Number(id)]?.vehicle.model ?? "",
|
||||
snapshot_update_blocked:
|
||||
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
|
||||
city_id: vehicle[Number(id)]?.vehicle.city_id ?? selectedCityStore.selectedCityId ?? undefined,
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
@@ -134,11 +125,12 @@ export const VehicleEditPage = observer(() => {
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select
|
||||
value={editVehicleData.type}
|
||||
label="Тип"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({ ...editVehicleData, type: e.target.value })
|
||||
}
|
||||
@@ -151,11 +143,12 @@ export const VehicleEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Перевозчик</InputLabel>
|
||||
<Select
|
||||
value={editVehicleData.carrier_id}
|
||||
label="Перевозчик"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
@@ -184,26 +177,6 @@ export const VehicleEditPage = observer(() => {
|
||||
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
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -229,8 +202,7 @@ export const VehicleEditPage = observer(() => {
|
||||
isLoading ||
|
||||
!editVehicleData.tail_number ||
|
||||
!editVehicleData.type ||
|
||||
!editVehicleData.carrier_id ||
|
||||
!editVehicleData.city_id
|
||||
!editVehicleData.carrier_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
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 { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
@@ -11,7 +11,7 @@ import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const VehicleListPage = observer(() => {
|
||||
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
||||
const { getCarriers } = carrierStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
@@ -31,11 +31,10 @@ export const VehicleListPage = observer(() => {
|
||||
setIsLoading(true);
|
||||
await getVehicles();
|
||||
await getCarriers(language);
|
||||
await cityStore.getCities("ru");
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -115,16 +114,15 @@ export const VehicleListPage = observer(() => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{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" />
|
||||
</button>
|
||||
)}
|
||||
<button title="Просмотр" onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
||||
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -139,13 +137,9 @@ export const VehicleListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!selectedCityId) return [];
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
return (vehicles.data ?? [])
|
||||
.filter((vehicle) => vehicle.vehicle.city_id === selectedCityId)
|
||||
.filter(
|
||||
(vehicle) =>
|
||||
!query ||
|
||||
@@ -157,9 +151,11 @@ export const VehicleListPage = observer(() => {
|
||||
tail_number: vehicle.vehicle.tail_number,
|
||||
type: vehicle.vehicle.type,
|
||||
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 (
|
||||
<>
|
||||
@@ -173,9 +169,7 @@ export const VehicleListPage = observer(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
{canWriteVehicles && ids.length > 0 && (
|
||||
<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" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет транспортных средств"
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
mediaStore,
|
||||
MEDIA_TYPE_LABELS,
|
||||
PreviewBackgroundToggle,
|
||||
} from "@shared";
|
||||
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -33,8 +29,6 @@ export const PreviewMediaDialog = observer(
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
|
||||
useState(false);
|
||||
const media = mediaId
|
||||
? mediaStore.media.find((m) => m.id === mediaId)
|
||||
: null;
|
||||
@@ -46,7 +40,6 @@ export const PreviewMediaDialog = observer(
|
||||
if (media) {
|
||||
setMediaName(media.media_name);
|
||||
setMediaFilename(media.filename);
|
||||
setIsPreviewBackgroundDark(false);
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
@@ -148,19 +141,8 @@ export const PreviewMediaDialog = observer(
|
||||
display: "flex",
|
||||
alignItems: "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
|
||||
media={{
|
||||
id: mediaId,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
clearBlobAndGLTFCache,
|
||||
authStore,
|
||||
snapshotStore,
|
||||
PreviewBackgroundToggle,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
@@ -91,8 +90,6 @@ export const UploadMediaDialog = observer(
|
||||
[]
|
||||
);
|
||||
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
||||
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
|
||||
useState(false);
|
||||
const previousMediaUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,26 +103,8 @@ export const UploadMediaDialog = observer(
|
||||
|
||||
setMediaFile(initialFile);
|
||||
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]);
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
const newBlobUrl = URL.createObjectURL(initialFile);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl;
|
||||
@@ -350,7 +329,6 @@ export const UploadMediaDialog = observer(
|
||||
setMediaUrl(null);
|
||||
setMediaFile(null);
|
||||
setIsPreviewLoaded(false);
|
||||
setIsPreviewBackgroundDark(false);
|
||||
previousMediaUrlRef.current = null;
|
||||
onClose();
|
||||
};
|
||||
@@ -420,18 +398,8 @@ export const UploadMediaDialog = observer(
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
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 && (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -12,10 +12,10 @@ export type Carrier = {
|
||||
id: number;
|
||||
short_name: string;
|
||||
full_name: string;
|
||||
slogan: string;
|
||||
city: string;
|
||||
city_id: number;
|
||||
preview_media?: string;
|
||||
video_preview?: string;
|
||||
logo: string;
|
||||
main_color: string;
|
||||
left_color: string;
|
||||
right_color: string;
|
||||
@@ -111,40 +111,38 @@ class CarrierStore {
|
||||
|
||||
createCarrierData = {
|
||||
city_id: 0,
|
||||
preview_media: "",
|
||||
video_preview: "",
|
||||
logo: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
|
||||
setCreateCarrierMedia = (
|
||||
field: "preview_media" | "video_preview",
|
||||
mediaId: string
|
||||
) => {
|
||||
this.createCarrierData[field] = mediaId;
|
||||
};
|
||||
|
||||
setCreateCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
cityId: number,
|
||||
slogan: string,
|
||||
logoId: string,
|
||||
language: Language,
|
||||
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
||||
) => {
|
||||
this.createCarrierData.city_id = cityId;
|
||||
this.createCarrierData.logo = logoId;
|
||||
if (colors) {
|
||||
if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color;
|
||||
if (colors.left_color !== undefined) this.createCarrierData.left_color = colors.left_color;
|
||||
@@ -153,6 +151,7 @@ class CarrierStore {
|
||||
this.createCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
slogan: slogan,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -207,12 +206,8 @@ class CarrierStore {
|
||||
short_name: (this.createCarrierData[language].short_name || "").trim(),
|
||||
city: cityName,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
...(this.createCarrierData.preview_media
|
||||
? { preview_media: this.createCarrierData.preview_media }
|
||||
: {}),
|
||||
...(this.createCarrierData.video_preview
|
||||
? { video_preview: this.createCarrierData.video_preview }
|
||||
: {}),
|
||||
slogan: (this.createCarrierData[language].slogan || "").trim(),
|
||||
...(this.createCarrierData.logo ? { logo: this.createCarrierData.logo } : {}),
|
||||
...(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 } : {}),
|
||||
@@ -226,55 +221,55 @@ class CarrierStore {
|
||||
this.carriers[language].data.push(response.data);
|
||||
});
|
||||
|
||||
for (const lang of (["ru", "en", "zh"] as const).filter(
|
||||
(item) => item !== language
|
||||
)) {
|
||||
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
||||
const cityNameForLang = this.resolveCityName(
|
||||
this.createCarrierData.city_id,
|
||||
lang
|
||||
lang as Language
|
||||
);
|
||||
const localizedData = this.createCarrierData[lang];
|
||||
const patchPayload = {
|
||||
full_name: (localizedData.full_name || "").trim(),
|
||||
short_name: (localizedData.short_name || "").trim(),
|
||||
// @ts-ignore
|
||||
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_id: this.createCarrierData.city_id,
|
||||
...(this.createCarrierData.preview_media
|
||||
? { preview_media: this.createCarrierData.preview_media }
|
||||
: {}),
|
||||
...(this.createCarrierData.video_preview
|
||||
? { video_preview: this.createCarrierData.video_preview }
|
||||
// @ts-ignore
|
||||
slogan: ((this.createCarrierData[lang as any].slogan as string) || "").trim(),
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await languageInstance(lang).patch(
|
||||
const response = await languageInstance(lang as Language).patch(
|
||||
`/carrier/${carrierId}`,
|
||||
patchPayload
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers[lang].data.push(response.data);
|
||||
this.carriers[lang as keyof Carriers].data.push(response.data);
|
||||
});
|
||||
}
|
||||
|
||||
this.createCarrierData = {
|
||||
city_id: 0,
|
||||
preview_media: "",
|
||||
video_preview: "",
|
||||
logo: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -283,38 +278,36 @@ class CarrierStore {
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
city_id: 0,
|
||||
preview_media: "",
|
||||
video_preview: "",
|
||||
logo: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
};
|
||||
|
||||
setEditCarrierMedia = (
|
||||
field: "preview_media" | "video_preview",
|
||||
mediaId: string
|
||||
) => {
|
||||
this.editCarrierData[field] = mediaId;
|
||||
};
|
||||
|
||||
setEditCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
cityId: number,
|
||||
slogan: string,
|
||||
logoId: string,
|
||||
language: Language,
|
||||
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
||||
) => {
|
||||
this.editCarrierData.city_id = cityId;
|
||||
this.editCarrierData.logo = logoId;
|
||||
if (colors) {
|
||||
if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color;
|
||||
if (colors.left_color !== undefined) this.editCarrierData.left_color = colors.left_color;
|
||||
@@ -323,6 +316,7 @@ class CarrierStore {
|
||||
this.editCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
slogan: slogan,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -333,10 +327,10 @@ class CarrierStore {
|
||||
...this.editCarrierData[lang],
|
||||
full_name: (this.editCarrierData[lang].full_name || "").trim(),
|
||||
short_name: (this.editCarrierData[lang].short_name || "").trim(),
|
||||
slogan: (this.editCarrierData[lang].slogan || "").trim(),
|
||||
city: cityName,
|
||||
city_id: this.editCarrierData.city_id,
|
||||
preview_media: this.editCarrierData.preview_media || null,
|
||||
video_preview: this.editCarrierData.video_preview || null,
|
||||
...(this.editCarrierData.logo ? { 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 } : {}),
|
||||
|
||||
@@ -13,7 +13,6 @@ export type Route = {
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
button_text?: string;
|
||||
id: number;
|
||||
icon: string;
|
||||
path: number[][];
|
||||
@@ -144,7 +143,6 @@ class RouteStore {
|
||||
center_latitude: "",
|
||||
center_longitude: "",
|
||||
governor_appeal: 0,
|
||||
button_text: "" as string | undefined,
|
||||
id: 0,
|
||||
icon: "",
|
||||
path: [] as number[][],
|
||||
|
||||
@@ -4,7 +4,6 @@ import { City } from "../CityStore";
|
||||
class SelectedCityStore {
|
||||
selectedCity: City | null = null;
|
||||
isLocked: boolean = false;
|
||||
cityVersion: number = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
@@ -26,7 +25,6 @@ class SelectedCityStore {
|
||||
setSelectedCity = (city: City | null) => {
|
||||
runInAction(() => {
|
||||
this.selectedCity = city;
|
||||
this.cityVersion += 1;
|
||||
if (city) {
|
||||
localStorage.setItem("selectedCity", JSON.stringify(city));
|
||||
} else {
|
||||
|
||||
@@ -60,6 +60,12 @@ class SnapshotStore {
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
|
||||
countryStore.countries = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
carrierStore.carriers = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
|
||||
@@ -120,7 +120,6 @@ class VehicleStore {
|
||||
carrier: string,
|
||||
carrierId: number,
|
||||
model?: string,
|
||||
cityId?: number,
|
||||
) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: tailNumber.trim(),
|
||||
@@ -130,7 +129,6 @@ class VehicleStore {
|
||||
};
|
||||
// TODO: когда будет бекенд — добавить model в payload и в ответ
|
||||
if (model != null && model !== "") payload.model = model;
|
||||
if (cityId != null) payload.city_id = cityId;
|
||||
const response = await languageInstance("ru").post("/vehicle", payload);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
@@ -149,7 +147,6 @@ class VehicleStore {
|
||||
carrier_id: number;
|
||||
model: string;
|
||||
snapshot_update_blocked: boolean;
|
||||
city_id?: number;
|
||||
} = {
|
||||
tail_number: "",
|
||||
type: 0,
|
||||
@@ -157,7 +154,6 @@ class VehicleStore {
|
||||
carrier_id: 0,
|
||||
model: "",
|
||||
snapshot_update_blocked: false,
|
||||
city_id: undefined,
|
||||
};
|
||||
|
||||
setEditVehicleData = (data: {
|
||||
@@ -167,7 +163,6 @@ class VehicleStore {
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
city_id?: number;
|
||||
}) => {
|
||||
this.editVehicleData = {
|
||||
...this.editVehicleData,
|
||||
@@ -184,7 +179,6 @@ class VehicleStore {
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
city_id?: number;
|
||||
},
|
||||
) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -196,7 +190,6 @@ class VehicleStore {
|
||||
if (data.model != null && data.model !== "") payload.model = data.model;
|
||||
if (data.snapshot_update_blocked != null)
|
||||
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(
|
||||
`/vehicle/${id}`,
|
||||
payload,
|
||||
|
||||
@@ -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 "./MultiSelect";
|
||||
export * from "./SearchInput";
|
||||
export * from "./PreviewBackgroundToggle";
|
||||
|
||||
@@ -6,21 +6,11 @@ import {
|
||||
SelectChangeEvent,
|
||||
Typography,
|
||||
Box,
|
||||
keyframes,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { authStore, cityStore, selectedCityStore, snapshotStore, type City } from "@shared";
|
||||
import { authStore, cityStore, selectedCityStore, type City } from "@shared";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
const borderSpin = keyframes`
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CitySelector: React.FC = observer(() => {
|
||||
const { selectedCity, setSelectedCity, isLocked } = selectedCityStore;
|
||||
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
|
||||
@@ -53,24 +43,23 @@ export const CitySelector: React.FC = observer(() => {
|
||||
})()
|
||||
: baseCities;
|
||||
|
||||
const noCitySelected = !selectedCity?.id;
|
||||
|
||||
const handleCityChange = (event: SelectChangeEvent<string>) => {
|
||||
const cityId = event.target.value;
|
||||
if (cityId === "") {
|
||||
snapshotStore.clearStoreCache();
|
||||
setSelectedCity(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const city = currentCities.find((c) => c.id === Number(cityId));
|
||||
if (city) {
|
||||
snapshotStore.clearStoreCache();
|
||||
setSelectedCity(city);
|
||||
}
|
||||
};
|
||||
|
||||
const selectElement = (
|
||||
return (
|
||||
<Box className="flex items-center gap-2">
|
||||
<MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} />
|
||||
<FormControl size="medium" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={selectedCity?.id?.toString() || ""}
|
||||
onChange={handleCityChange}
|
||||
@@ -79,23 +68,15 @@ export const CitySelector: React.FC = observer(() => {
|
||||
sx={{
|
||||
height: "40px",
|
||||
color: "white",
|
||||
borderRadius: "4px",
|
||||
...(noCitySelected && !isLocked
|
||||
? {
|
||||
backgroundColor: "#48989f",
|
||||
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||
}
|
||||
: {
|
||||
"&.Mui-disabled": {
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: isLocked
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
}),
|
||||
"&.Mui-disabled": {
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: isLocked
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
@@ -118,28 +99,6 @@ export const CitySelector: React.FC = observer(() => {
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="flex items-center gap-2">
|
||||
<MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} />
|
||||
<FormControl size="medium" sx={{ minWidth: 120 }}>
|
||||
{noCitySelected && !isLocked ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
borderRadius: "4px",
|
||||
padding: "2px",
|
||||
background: "linear-gradient(90deg, rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1))",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: `${borderSpin} 2.5s linear infinite`,
|
||||
}}
|
||||
>
|
||||
{selectElement}
|
||||
</Box>
|
||||
) : (
|
||||
selectElement
|
||||
)}
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -24,51 +24,30 @@ const shiftYYYYMMDD = (value: string, days: number) => {
|
||||
|
||||
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
|
||||
|
||||
const LOG_LEVEL_STYLES: Record<
|
||||
LogLevel,
|
||||
{ badge: string; text: string; bg: string; color: string; borderColor: string }
|
||||
> = {
|
||||
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
|
||||
info: {
|
||||
badge: "bg-blue-100 text-blue-700",
|
||||
text: "text-[#000000BF]",
|
||||
bg: "#DBEAFE",
|
||||
color: "#1D4ED8",
|
||||
borderColor: "#93C5FD",
|
||||
},
|
||||
debug: {
|
||||
badge: "bg-gray-100 text-gray-600",
|
||||
text: "text-gray-600",
|
||||
bg: "#F3F4F6",
|
||||
color: "#4B5563",
|
||||
borderColor: "#D1D5DB",
|
||||
},
|
||||
warn: {
|
||||
badge: "bg-amber-100 text-amber-700",
|
||||
text: "text-amber-800",
|
||||
bg: "#FEF3C7",
|
||||
color: "#B45309",
|
||||
borderColor: "#FCD34D",
|
||||
},
|
||||
error: {
|
||||
badge: "bg-red-100 text-red-700",
|
||||
text: "text-red-700",
|
||||
bg: "#FEE2E2",
|
||||
color: "#B91C1C",
|
||||
borderColor: "#FCA5A5",
|
||||
},
|
||||
fatal: {
|
||||
badge: "bg-red-200 text-red-900",
|
||||
text: "text-red-900 font-semibold",
|
||||
bg: "#FECACA",
|
||||
color: "#7F1D1D",
|
||||
borderColor: "#F87171",
|
||||
},
|
||||
unknown: {
|
||||
badge: "bg-gray-100 text-gray-500",
|
||||
text: "text-[#000000BF]",
|
||||
bg: "#F3F4F6",
|
||||
color: "#6B7280",
|
||||
borderColor: "#D1D5DB",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -160,23 +139,6 @@ export const DeviceLogsModal = ({
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
|
||||
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
|
||||
|
||||
const ALL_LEVELS: LogLevel[] = ["debug", "info", "warn", "error", "fatal"];
|
||||
const [activeLevels, setActiveLevels] = useState<Set<LogLevel>>(
|
||||
new Set(ALL_LEVELS)
|
||||
);
|
||||
|
||||
const toggleLevel = (level: LogLevel) => {
|
||||
setActiveLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(level)) {
|
||||
next.delete(level);
|
||||
} else {
|
||||
next.add(level);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const dateToMin = shiftYYYYMMDD(dateFrom, 1);
|
||||
const dateFromMax = shiftYYYYMMDD(dateTo, -1);
|
||||
|
||||
@@ -243,21 +205,16 @@ export const DeviceLogsModal = ({
|
||||
return parsed;
|
||||
}, [chunks]);
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() => logs.filter((log) => activeLevels.has(log.level)),
|
||||
[logs, activeLevels]
|
||||
);
|
||||
|
||||
const logsText = useMemo(
|
||||
() =>
|
||||
filteredLogs
|
||||
logs
|
||||
.map((log) => {
|
||||
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
|
||||
const time = log.time ? `[${log.time}] ` : "";
|
||||
return `${time}${level}: ${log.text}`;
|
||||
})
|
||||
.join("\n"),
|
||||
[filteredLogs]
|
||||
[logs]
|
||||
);
|
||||
|
||||
const handleDownloadLogs = () => {
|
||||
@@ -296,28 +253,6 @@ export const DeviceLogsModal = ({
|
||||
<div className="flex flex-col gap-6 h-[85vh]">
|
||||
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
|
||||
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{ALL_LEVELS.map((level) => {
|
||||
const active = activeLevels.has(level);
|
||||
const s = LOG_LEVEL_STYLES[level];
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => toggleLevel(level)}
|
||||
className="cursor-pointer select-none rounded-md px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: active ? s.bg : "transparent",
|
||||
color: active ? s.color : "#9CA3AF",
|
||||
border: `1.5px solid ${active ? s.borderColor : "#E5E7EB"}`,
|
||||
opacity: active ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<TextField
|
||||
type="date"
|
||||
@@ -345,7 +280,7 @@ export const DeviceLogsModal = ({
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleDownloadLogs}
|
||||
disabled={isLoading || Boolean(error) || filteredLogs.length === 0}
|
||||
disabled={isLoading || Boolean(error) || logs.length === 0}
|
||||
>
|
||||
Скачать .txt
|
||||
</Button>
|
||||
@@ -368,8 +303,8 @@ export const DeviceLogsModal = ({
|
||||
{!isLoading && !error && (
|
||||
<div className="w-full h-full overflow-y-auto rounded-xl">
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log) => {
|
||||
{logs.length > 0 ? (
|
||||
logs.map((log) => {
|
||||
const style = LOG_LEVEL_STYLES[log.level];
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
vehicleStore,
|
||||
routeStore,
|
||||
Vehicle,
|
||||
carrierStore,
|
||||
selectedCityStore,
|
||||
menuStore,
|
||||
VEHICLE_TYPES,
|
||||
@@ -183,14 +184,31 @@ export const DevicesTable = observer(() => {
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
const filterVehiclesBySelectedCity = (vehiclesList: Vehicle[]): Vehicle[] => {
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
|
||||
const filteredVehicles = useMemo((): Vehicle[] => {
|
||||
if (!selectedCityId) return [];
|
||||
return (vehicles.data as Vehicle[]).filter(
|
||||
(vehicle) => vehicle.vehicle.city_id === selectedCityId,
|
||||
if (!selectedCityId) {
|
||||
return vehiclesList;
|
||||
}
|
||||
|
||||
const carriersInSelectedCityIds = new Set(
|
||||
carrierStore.carriers.ru.data
|
||||
.filter((carrier) => carrier.city_id === selectedCityId)
|
||||
.map((carrier) => carrier.id),
|
||||
);
|
||||
|
||||
if (carriersInSelectedCityIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return vehiclesList.filter((vehicle) =>
|
||||
carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id),
|
||||
);
|
||||
};
|
||||
|
||||
const filteredVehicles = filterVehiclesBySelectedCity(
|
||||
vehicles.data as Vehicle[],
|
||||
);
|
||||
}, [selectedCityId, vehicles.data]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformToRows(filteredVehicles),
|
||||
@@ -618,7 +636,7 @@ export const DevicesTable = observer(() => {
|
||||
e.stopPropagation();
|
||||
navigate(`/vehicle/${row.vehicle_id}/edit`);
|
||||
}}
|
||||
title="Редактировать"
|
||||
title="Редактировать транспорт"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
@@ -628,7 +646,7 @@ export const DevicesTable = observer(() => {
|
||||
e.stopPropagation();
|
||||
handleReloadStatus();
|
||||
}}
|
||||
title="Обновить статус"
|
||||
title="Перезапросить статус"
|
||||
disabled={
|
||||
!row.device_uuid || !devices.includes(row.device_uuid)
|
||||
}
|
||||
@@ -655,7 +673,7 @@ export const DevicesTable = observer(() => {
|
||||
setLogsModalOpen(true);
|
||||
}
|
||||
}}
|
||||
title="Логи"
|
||||
title="Логи устройства"
|
||||
>
|
||||
<ScrollText size={16} />
|
||||
</button>
|
||||
@@ -668,7 +686,7 @@ export const DevicesTable = observer(() => {
|
||||
setSessionsModalVehicleTailNumber(row.tail_number);
|
||||
setSessionsModalOpen(true);
|
||||
}}
|
||||
title="Сессии обслуживания"
|
||||
title="Сессии ТО"
|
||||
>
|
||||
<Wrench size={16} />
|
||||
</button>
|
||||
@@ -730,8 +748,13 @@ export const DevicesTable = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]);
|
||||
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMaintenanceOnly) {
|
||||
carrierStore.getCarriers("ru");
|
||||
}
|
||||
}, [isMaintenanceOnly]);
|
||||
|
||||
const handleOpenSendSnapshotModal = () => {
|
||||
if (!canWriteDevices) {
|
||||
@@ -865,8 +888,6 @@ export const DevicesTable = observer(() => {
|
||||
>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет устройств для отображения"
|
||||
)}
|
||||
@@ -912,12 +933,8 @@ export const DevicesTable = observer(() => {
|
||||
checkboxSelection={canWriteDevices}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={(params) => {
|
||||
if (canWriteDevices) {
|
||||
navigate(`/vehicle/${params.row.vehicle_id}/edit`);
|
||||
}
|
||||
}}
|
||||
loading={isLoading} paginationModel={paginationModel}
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
onRowSelectionModelChange={
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useState, DragEvent } from "react";
|
||||
import React, { useRef, DragEvent } from "react";
|
||||
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
|
||||
import { X, Info, Plus } from "lucide-react";
|
||||
import { editSightStore, PreviewBackgroundToggle } from "@shared";
|
||||
import { editSightStore } from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
interface ImageUploadCardProps {
|
||||
title: string;
|
||||
@@ -33,7 +33,6 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
tooltipText,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] = useState(false);
|
||||
const { setFileToUpload } = editSightStore;
|
||||
|
||||
const handleZoneClick = () => {
|
||||
@@ -125,21 +124,9 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: imageUrl ? "pointer" : "default",
|
||||
backgroundColor: imageUrl
|
||||
? isPreviewBackgroundDark
|
||||
? "#000"
|
||||
: "#fff"
|
||||
: "transparent",
|
||||
transition: "background-color 150ms ease",
|
||||
}}
|
||||
onClick={onImageClick}
|
||||
>
|
||||
{imageUrl && (
|
||||
<PreviewBackgroundToggle
|
||||
dark={isPreviewBackgroundDark}
|
||||
onToggle={() => setIsPreviewBackgroundDark((value) => !value)}
|
||||
/>
|
||||
)}
|
||||
{imageUrl && (
|
||||
<button
|
||||
className="absolute top-2 right-2"
|
||||
|
||||
@@ -42,14 +42,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
height: "100vh",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<AppBar position="fixed" open={open}>
|
||||
<Toolbar className="flex justify-between">
|
||||
<div className="flex items-center">
|
||||
@@ -156,14 +149,9 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
sx={{
|
||||
width: "100%",
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
height: "100vh",
|
||||
p: 3,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
overflow: "auto",
|
||||
maxWidth: "100vw",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div className="mt-16"></div>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import {
|
||||
Checkbox,
|
||||
Typography,
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from "@mui/material";
|
||||
import { ROLE_RESOURCES, getPermissionLevel, applyPermissionChange, type PermissionLevel } from "./constants";
|
||||
|
||||
interface PermissionsTableProps {
|
||||
localRoles: string[];
|
||||
setLocalRoles: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export function PermissionsTable({ localRoles, setLocalRoles }: PermissionsTableProps) {
|
||||
return (
|
||||
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||
<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 isDevicesResource = key === "devices";
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
setLocalRoles((prev) => {
|
||||
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
|
||||
if (key === "devices") {
|
||||
updated = applyPermissionChange(updated, "vehicles", val as PermissionLevel);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSnapshotCreateChange = (checked: boolean) => {
|
||||
setLocalRoles((prev) => {
|
||||
const without = prev.filter((role) => role !== "snapshot_create");
|
||||
return checked ? [...without, "snapshot_create"] : without;
|
||||
});
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
|
||||
<Checkbox
|
||||
checked={localRoles.includes("devices_maintenance_rw")}
|
||||
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||
size="small"
|
||||
title="Техническое обслуживание (ТО)"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">-</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@mui/material";
|
||||
|
||||
const ROLE_HINTS = [
|
||||
{ tab: "Экспорт", roles: "Экспорт (Ч/З)" },
|
||||
{ tab: "Создание экспорта", roles: "Экспорт (доп. права) + Устройства (Ч/З)" },
|
||||
{ tab: "Устройства", roles: "Устройства + Транспорт + Маршруты + Перевозчики + Экспорт (Ч/З)" },
|
||||
{ tab: "Карта", roles: "Маршруты (Ч/З) или Остановки (Ч/З) или Достопримечательности (Ч/З)" },
|
||||
{ tab: "Пользователи", roles: "Пользователи" },
|
||||
{ tab: "Достопримечательности", roles: "Достопримечательности" },
|
||||
{ tab: "Остановки", roles: "Остановки" },
|
||||
{ tab: "Маршруты", roles: "Маршруты + Перевозчики" },
|
||||
{ tab: "Страны", roles: "Страны" },
|
||||
{ tab: "Города", roles: "Города + Страны" },
|
||||
{ tab: "Перевозчики", roles: "Перевозчики" },
|
||||
];
|
||||
|
||||
export function RolesHintTable() {
|
||||
return (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: "grey.50", borderRadius: 1, border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Какие роли нужны для вкладок
|
||||
</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Вкладка</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Необходимые роли</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ROLE_HINTS.map(({ tab, roles }) => (
|
||||
<TableRow key={tab}>
|
||||
<TableCell sx={{ py: 0.5 }}>{tab}</TableCell>
|
||||
<TableCell sx={{ py: 0.5 }}>{roles}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export 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;
|
||||
|
||||
export type PermissionLevel = "none" | "ro" | "rw";
|
||||
|
||||
export function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
|
||||
if (roles.includes(`${resource}_rw`)) return "rw";
|
||||
if (roles.includes(`${resource}_ro`)) return "ro";
|
||||
return "none";
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { PermissionsTable } from "./PermissionsTable";
|
||||
export { RolesHintTable } from "./RolesHintTable";
|
||||
export { ROLE_RESOURCES, type PermissionLevel, getPermissionLevel, applyPermissionChange } from "./constants";
|
||||
@@ -164,7 +164,6 @@
|
||||
position: relative;
|
||||
padding: 7px;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
@@ -268,3 +267,4 @@
|
||||
.sfp-sight-frame-media-stack.three-d-view {
|
||||
background-color: #111 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,14 +87,12 @@ export const SightsTable = observer(() => {
|
||||
<TableCell align="center" className="py-3">
|
||||
<div className="flex justify-center items-center gap-3">
|
||||
<button
|
||||
title="Редактировать"
|
||||
className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105"
|
||||
onClick={() => navigate(`/sight/${row?.id}`)}
|
||||
>
|
||||
<Pencil size={18} className="text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
|
||||
@@ -21,4 +21,3 @@ export * from "./SaveWithoutCityAgree";
|
||||
export * from "./CitySelector";
|
||||
export * from "./modals";
|
||||
export * from "./TestingModeBanner";
|
||||
export * from "./PermissionsTable";
|
||||
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user