From 8e8eb625dd1f72272c06ae51472e4e5a73d8456c Mon Sep 17 00:00:00 2001 From: itoshi Date: Sat, 8 Aug 2026 09:20:24 +0300 Subject: [PATCH] sync demo layout with client 1.0.8 --- .gitignore | 26 +- package.json | 2 - src/client/src/components/ListOfSights.jsx | 31 +- .../ListOfSights/AlphabetNavigator.jsx | 7 +- .../components/ListOfSights/SightFrame.jsx | 366 ++++++------ .../ListOfSights/TransferWidget.jsx | 38 +- .../src/components/TouchableLayout/index.tsx | 54 +- src/client/src/components/WeatherWidget.jsx | 7 +- .../src/components/map/InfiniteCanvas.tsx | 349 ----------- src/client/src/components/map/Map.tsx | 4 - src/client/src/components/map/Sight.tsx | 393 ------------ src/client/src/components/map/Station.tsx | 174 ------ src/client/src/components/map/TramIcon.tsx | 375 ------------ src/client/src/components/map/TravelPath.tsx | 92 --- .../src/components/side-menu/Collapsible.jsx | 39 ++ .../src/components/side-menu/SideMenu.jsx | 92 ++- .../src/components/side-menu/SightsList.jsx | 108 ++-- .../side-menu/StationSightsList.jsx | 12 +- .../src/components/side-menu/StationsList.jsx | 35 +- .../src/components/widgets/RouteWidget.jsx | 14 +- src/client/src/styles/LeftWidget.css | 17 +- src/client/src/styles/ListOfSights.css | 130 ++-- src/client/src/styles/RouteWidget.css | 3 +- src/client/src/styles/SideMenu.css | 14 +- src/client/src/styles/TouchableLayout.css | 4 +- .../Route/route-preview/InfiniteCanvas.tsx | 232 -------- src/pages/Route/route-preview/Sight.tsx | 137 ----- src/pages/Route/route-preview/Station.tsx | 557 ------------------ src/pages/Route/route-preview/TravelPath.tsx | 34 -- src/pages/Route/route-preview/index.tsx | 37 -- tsconfig.tsbuildinfo | 1 - 31 files changed, 551 insertions(+), 2833 deletions(-) delete mode 100644 src/client/src/components/map/InfiniteCanvas.tsx delete mode 100644 src/client/src/components/map/Sight.tsx delete mode 100644 src/client/src/components/map/Station.tsx delete mode 100644 src/client/src/components/map/TramIcon.tsx delete mode 100644 src/client/src/components/map/TravelPath.tsx create mode 100644 src/client/src/components/side-menu/Collapsible.jsx delete mode 100644 src/pages/Route/route-preview/InfiniteCanvas.tsx delete mode 100644 src/pages/Route/route-preview/Sight.tsx delete mode 100644 src/pages/Route/route-preview/Station.tsx delete mode 100644 src/pages/Route/route-preview/TravelPath.tsx delete mode 100644 tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore index 0a64c45..8e86fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,7 @@ # 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* @@ -17,23 +10,11 @@ lerna-debug.log* node_modules dist dist-ssr -<<<<<<< HEAD -\*.local - -# Editor directories and files - -.vscode/_ -!.vscode/extensions.json -.idea -.DS_Store -_.suo -_.ntvs_ -_.njsproj -_.sln -\*.sw? -======= *.local +# Build artifacts +*.tsbuildinfo + # Editor directories and files .vscode/* !.vscode/extensions.json @@ -44,4 +25,3 @@ _.sln *.njsproj *.sln *.sw? ->>>>>>> 6b9aa78 (init: Init React Application) diff --git a/package.json b/package.json index ca4feb4..bf6a1b5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@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", @@ -33,7 +32,6 @@ "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", diff --git a/src/client/src/components/ListOfSights.jsx b/src/client/src/components/ListOfSights.jsx index 982ada3..06e2957 100644 --- a/src/client/src/components/ListOfSights.jsx +++ b/src/client/src/components/ListOfSights.jsx @@ -95,9 +95,10 @@ const ListOfSights = observer(() => { // Находим все достопримечательности, начинающиеся с выбранной буквы const sightsForLetter = sightData.filter((sight) => { - if (!sight.name) return false; + const name = sight.name; + if (!name) return false; - let sightFirstChar = sight.name.trim().charAt(0); + let sightFirstChar = name.trim().charAt(0); // Для китайского языка используем пиньинь или иероглиф if (selectedLanguageRight === "zh") { @@ -105,7 +106,7 @@ const ListOfSights = observer(() => { sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0); } else { // Если пиньинь нет, используем первый иероглиф - sightFirstChar = sight.name.trim().charAt(0); + sightFirstChar = name.trim().charAt(0); } } else { sightFirstChar = sightFirstChar.toUpperCase(); @@ -440,11 +441,6 @@ const ListOfSights = observer(() => { isLangOpen={isLangMenuOpen} /> - - { isDisabled={isAlphabetDisabled} /> + -
+ + +
{
-
); }); diff --git a/src/client/src/components/ListOfSights/AlphabetNavigator.jsx b/src/client/src/components/ListOfSights/AlphabetNavigator.jsx index 0dd17b6..cdd79d7 100644 --- a/src/client/src/components/ListOfSights/AlphabetNavigator.jsx +++ b/src/client/src/components/ListOfSights/AlphabetNavigator.jsx @@ -14,8 +14,9 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator( const letters = new Set(); sightData.forEach((sight) => { - if (sight.name && sight.name.trim()) { - let firstChar = sight.name.trim().charAt(0); + const name = sight.short_name || sight.name; + if (name && name.trim()) { + let firstChar = name.trim().charAt(0); // Для китайского языка используем пиньинь (если доступен) или иероглиф if (selectedLanguage === "zh") { @@ -23,7 +24,7 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator( firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0); } else { // Если пиньинь нет, используем первый иероглиф - firstChar = sight.name.trim().charAt(0); + firstChar = name.trim().charAt(0); } } else { firstChar = firstChar.toUpperCase(); diff --git a/src/client/src/components/ListOfSights/SightFrame.jsx b/src/client/src/components/ListOfSights/SightFrame.jsx index 223f5cb..4d20d42 100644 --- a/src/client/src/components/ListOfSights/SightFrame.jsx +++ b/src/client/src/components/ListOfSights/SightFrame.jsx @@ -41,16 +41,35 @@ 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 idleTimerRef = useRef(null); + const prevSightIdRef = useRef(null); const textWrapperRef = useRef(null); + const mediaStackRef = useRef(null); + const titleRef = useRef(null); + const switchTimerRef = useRef(null); + const homeButtonRef = useRef(null); const menuRef = useRef(null); const [menuNeedsScroll, setMenuNeedsScroll] = useState(false); const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); + const currentSection = articleSections?.[selectedSection] ?? null; + + const requestSection = (target) => { + setIsFullscreen3D(false); + if (target === selectedSection) return; + if (switchTimerRef.current) clearTimeout(switchTimerRef.current); + setIsSwitching(true); + switchTimerRef.current = setTimeout(() => { + setSelectedSection(target); + setIsSwitching(false); + switchTimerRef.current = null; + }, 180); + }; + const updateScrollState = useCallback(() => { const menu = menuRef.current; if (!menu) return; @@ -83,55 +102,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0); const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1); setMenuNeedsScroll(evenGap < 10); - }, [articleSections, selectedSection]); - - // Автозакрытие fullscreen 3D при бездействии (60 сек) - useEffect(() => { - if (!isFullscreen3D) { - if (idleTimerRef.current) { - clearInterval(idleTimerRef.current); - idleTimerRef.current = null; - } - return; - } - - let idleSeconds = 0; - - const checkIdle = () => { - idleSeconds += 1; - if (idleSeconds >= 60) { - setIsFullscreen3D(false); - } - }; - - idleTimerRef.current = 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 () => { - if (idleTimerRef.current) { - clearInterval(idleTimerRef.current); - idleTimerRef.current = null; - } - events.forEach((event) => { - window.removeEventListener(event, resetIdle); - }); - }; - }, [isFullscreen3D]); + }, [articleSections]); const { routeSights, @@ -171,11 +142,16 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { const controller = new AbortController(); const { signal } = controller; - setIsLoadingContent(true); - setContentError(null); - setModelAspectRatio(null); - setIsVisible(false); - setMediaData({}); + 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 статьи."); @@ -253,15 +229,18 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { const introSection = { id: media?.id || "intro-title", heading: - sight?.short_name || - sight?.name || - sight_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]); @@ -302,9 +281,43 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { useEffect(() => { setSelectedSection(0); + setIsSwitching(false); + if (switchTimerRef.current) { + clearTimeout(switchTimerRef.current); + switchTimerRef.current = null; + } }, [sight_id]); - const currentSection = articleSections?.[selectedSection] ?? null; + 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 renderCurrentMedia = () => { if (!articleSections || Object.keys(mediaData).length === 0) { @@ -600,7 +613,6 @@ 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) => ( @@ -645,135 +657,145 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { }, [sight_name]); return ( -
- {sightData?.watermark_lu && !isFullscreen3D && ( - - )} + <>
- {contentError ? ( -
- {contentError} -
- ) : isLoadingContent || !articleSections ? ( -
- Загрузка контента... -
- ) : ( - renderCurrentMedia() + {sightData?.watermark_lu && !isFullscreen3D && ( + )} -
-
- {contentError ? ( -

{contentError}

- ) : !currentSection ? ( -

Информация отсутствует.

- ) : ( - <> - {!isFullscreen3D && ( -
-

+ {contentError ? ( +

+ {contentError} +
+ ) : isLoadingContent || !articleSections ? ( +
+ Загрузка контента... +
+ ) : ( + renderCurrentMedia() + )} +
+
+ {contentError ? ( +

{contentError}

+ ) : ( + <> + {!isFullscreen3D && ( +
- {selectedSection === 0 - ? processedSightName - : sightData?.short_name || sight_name} -

-
- )} - {selectedSection !== 0 && ( +

+ {selectedSection === 0 + ? processedSightName || sightData?.name + : sightData?.short_name || sightData?.name} +

+
+ )} -
- +
+ {isLoadingContent || !currentSection ? ( +
+ ) : ( + selectedSection !== 0 && ( + + ) + )}
- )} - - )} + + )} +
-
+
-
{ - setSelectedSection(0); - setIsFullscreen3D(false); - }} + className="sight-frame-menu" + ref={menuRef} + style={menuNeedsScroll ? { justifyContent: 'flex-start' } : undefined} > - + {contentError ? ( +

{contentError}

+ ) : ( + articleSections && + articleSections.length > 1 && + articleSections.slice(1).map((section, index) => ( +
requestSection(index + 1)} + key={section.id || section.heading || index} + data-label={section.heading} + className={`sight-frame-menu-point ${ + index + 1 === selectedSection ? "active" : "" + }`} + role="button" + tabIndex="0" + > + {section.heading} +
+ )) + )}
- {contentError ? ( -

{contentError}

- ) : ( - articleSections && - articleSections.length > 1 && - articleSections.slice(1).map((section, index) => ( -
{ - setSelectedSection(index + 1); - setIsFullscreen3D(false); - }} - key={section.id || section.heading || index} - className={`sight-frame-menu-point ${ - index + 1 === selectedSection ? "active" : "" - }`} - role="button" - tabIndex="0" - > - {section.heading} -
- )) - )}
+
requestSection(0)} + > +
-
+ ); }); diff --git a/src/client/src/components/ListOfSights/TransferWidget.jsx b/src/client/src/components/ListOfSights/TransferWidget.jsx index 1c0c355..e53d20c 100644 --- a/src/client/src/components/ListOfSights/TransferWidget.jsx +++ b/src/client/src/components/ListOfSights/TransferWidget.jsx @@ -95,35 +95,35 @@ const TransferWidget = observer(function TransferWidget({ } const getTransferLabel = () => { - if (!stationName) { - if (selectedLanguageRight === "en") return "Nearest station not found"; - if (selectedLanguageRight === "zh") return "最近的站点未找到"; - return "Ближайшая остановка не обнаружена"; + if (selectedLanguageRight === "ru") { + return stationName ? ( + <> +
Пересадка на остановке
+
«{stationName}»:
+ + ) : ( + "Ближайшая остановка не обнаружена" + ); } if (selectedLanguageRight === "en") { - return ( + return stationName ? ( <> - Transfer at stop
- «{stationName}»: +
Transfer at stop
+
«{stationName}»:
+ ) : ( + "Nearest station not found" ); } - if (selectedLanguageRight === "zh") { - return ( - <> - 换乘站
- «{stationName}»: - - ); - } - - return ( + return stationName ? ( <> - Пересадка на остановке
- «{stationName}»: +
在站点换乘
+
«{stationName}»:
+ ) : ( + "最近的站点未找到" ); }; diff --git a/src/client/src/components/TouchableLayout/index.tsx b/src/client/src/components/TouchableLayout/index.tsx index 282aeba..dc15894 100644 --- a/src/client/src/components/TouchableLayout/index.tsx +++ b/src/client/src/components/TouchableLayout/index.tsx @@ -12,6 +12,7 @@ interface TouchableLayoutProps { children?: ReactNode; className?: string; maxHeight?: string | number; + style?: React.CSSProperties; } function useThumbSync(scrollableRef: React.RefObject) { @@ -23,13 +24,16 @@ function useThumbSync(scrollableRef: React.RefObject) { isAtBottom: false, }); const [visible, setVisible] = useState(false); - const hideTimerRef = useRef | null>(null); const rafRef = useRef(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; @@ -38,7 +42,7 @@ function useThumbSync(scrollableRef: React.RefObject) { const isAtTop = st <= 0; const isAtBottom = st + ch >= sh - 1; - if (sh <= ch) { + if (sh <= ch + 2) { setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true })); return; } @@ -64,39 +68,36 @@ function useThumbSync(scrollableRef: React.RefObject) { }; 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) { - if (hideTimerRef.current) { - clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - setVisible(true); - } else { - hideTimerRef.current = setTimeout(() => { - setVisible(false); - }, 200); + if (!state.hasScroll) { + setVisible(false); + return; } - return () => { - if (hideTimerRef.current) clearTimeout(hideTimerRef.current); - }; + const t = setTimeout(() => setVisible(true), 250); + return () => clearTimeout(t); }, [state.hasScroll]); return { ...state, visible }; } export const TouchableLayout = forwardRef( - ({ children, className, maxHeight }, ref) => { + ({ children, className, maxHeight, style }, ref) => { const containerRef = useRef(null); const scrollableRef = useRef(null); const trackRef = useRef(null); @@ -273,7 +274,7 @@ export const TouchableLayout = forwardRef( : {}; return ( -
+
{children} @@ -281,15 +282,18 @@ export const TouchableLayout = forwardRef(
- {thumb.visible && ( -
- )} +
diff --git a/src/client/src/components/WeatherWidget.jsx b/src/client/src/components/WeatherWidget.jsx index d1a125e..30a0632 100644 --- a/src/client/src/components/WeatherWidget.jsx +++ b/src/client/src/components/WeatherWidget.jsx @@ -8,6 +8,7 @@ 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 ( @@ -43,7 +44,11 @@ const WeatherWidget = observer(() => { const fetchWeather = async () => { try { - const data = await weatherApi.getFormattedWeather(); + const data = await weatherApi.getFormattedWeather({ + lat: apiStore.context?.currentCoordinates?.latitude, + lng: apiStore.context?.currentCoordinates?.longitude, + cityCode: apiStore.city?.weather_city_code, + }); setWeather(data); setError(null); setHasLoadedOnce(true); diff --git a/src/client/src/components/map/InfiniteCanvas.tsx b/src/client/src/components/map/InfiniteCanvas.tsx deleted file mode 100644 index 1661cec..0000000 --- a/src/client/src/components/map/InfiniteCanvas.tsx +++ /dev/null @@ -1,349 +0,0 @@ -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()); - 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(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 ( - <> - { - const canvas = applicationRef.app.canvas; - g.clear(); - g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0); - g.fill(BACKGROUND_COLOR); - }} - eventMode="static" - interactive - onPointerDown={handlePointerDown} - onGlobalPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerLeave={handlePointerUp} - onWheel={handleWheel} - /> - - {children} - - - ); - } -); diff --git a/src/client/src/components/map/Map.tsx b/src/client/src/components/map/Map.tsx index 87cf3af..ecf745e 100644 --- a/src/client/src/components/map/Map.tsx +++ b/src/client/src/components/map/Map.tsx @@ -5,8 +5,6 @@ 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 @@ -19,8 +17,6 @@ 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 ( diff --git a/src/client/src/components/map/Sight.tsx b/src/client/src/components/map/Sight.tsx deleted file mode 100644 index 2058b3d..0000000 --- a/src/client/src/components/map/Sight.tsx +++ /dev/null @@ -1,393 +0,0 @@ -// 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.EMPTY); - const store = useGeolocationStore(); - const { setIsGovernorWidgetOpen } = store; - useEffect(() => { - Assets.load(sightIcon).then(setTexture).catch(console.error); - }, []); - - const handlePointerTap = useCallback(() => { - setIsGovernorWidgetOpen(false); - onSightClick(String(sight.id)); // Передаем ID выбранной достопримечательности - }, [sight.id, onSightClick]); - - if (texture === Texture.EMPTY) { - return null; - } - - const dynamicSize = BASE_ICON_SIZE; - - return ( - - ); -} - -// Добавляем onSightSelectedInCluster в пропсы -function SightCluster({ - cluster, - onClusterToggle, - isExpanded, - onSightSelectedInCluster, -}: { - readonly cluster: Cluster; - onClusterToggle: (clusterId: string | null) => void; - isExpanded: boolean; - onSightSelectedInCluster: (sightId: string) => void; -}) { - const store = useGeolocationStore(); - const { setIsGovernorWidgetOpen } = store; - useTransform(); - const radius = CLUSTER_RADIUS_BASE; - const [texture, setTexture] = useState(Texture.EMPTY); - const fontSize = 14; - - useEffect(() => { - Assets.load(sightIcon).then(setTexture).catch(console.error); - }, []); - - const clusterTextStyle = useMemo( - () => - new TextStyle({ - fill: "white", - fontSize: fontSize, - fontWeight: "bold", - }), - [fontSize] - ); - - const handleClusterTap = useCallback(() => { - onClusterToggle(isExpanded ? null : cluster.id); - }, [cluster.id, cluster.count, isExpanded, onClusterToggle]); - - const handleSightSelect = useCallback( - (sightId: string) => { - setIsGovernorWidgetOpen(false); - onSightSelectedInCluster(sightId); // Передаем выбранный ID наверх - onClusterToggle(null); // Закрываем кластер после выбора достопримечательности - }, - [onClusterToggle, onSightSelectedInCluster, setIsGovernorWidgetOpen] - ); // Добавляем onSightSelectedInCluster в зависимости - - const drawClusterGraphics = useCallback( - (g: Graphics) => { - g.clear(); - g.beginFill(0x896f58); - g.drawCircle(0, 0, radius); - g.endFill(); - }, - [radius] - ); - - const drawExpandedClusterBackground = useCallback((g: Graphics) => { - g.clear(); - const expandedRadius = BASE_ICON_SIZE * 2; - g.beginFill(0x896f58, 0.3); - g.lineStyle(2, 0x896f58, 1); - g.drawCircle(0, 0, expandedRadius); - g.endFill(); - }, []); - - const handleBackgroundTap = useCallback(() => { - onClusterToggle(null); // Закрываем кластер при клике по фону - }, [cluster.id, onClusterToggle]); - - if (texture === Texture.EMPTY) { - return null; - } - - const dynamicSize = BASE_ICON_SIZE; - const offsetX = dynamicSize / 2; - const offsetY = -dynamicSize / 2; - - const getPositionForSight = (index: number, total: number) => { - const angle = (index / total) * Math.PI * 2; - const distance = BASE_ICON_SIZE * 1.2; - return { - x: distance * Math.cos(angle), - y: distance * Math.sin(angle), - }; - }; - - return ( - - {isExpanded && ( - - )} - - {!isExpanded ? ( - <> - - - - - ) : ( - <> - {cluster.sights.map((sight, index) => { - const pos = getPositionForSight(index, cluster.sights.length); - return ( - handleSightSelect(String(sight.id))} - /> - ); - })} - - )} - - ); -} - -interface SightsLayerProps { - sights: SightData[]; - pathPoints: { x: number; y: number }[]; -} - -export function SightsLayer({ - sights, - pathPoints, -}: Readonly) { - useTransform(); - const distanceThreshold = BASE_ICON_SIZE * 3; - - const store = useGeolocationStore(); // Получаем доступ к MobX хранилищу - const { - setSelectedSightId, - setIsManualSelection, - setIsRightWidgetSelectorOpen, - } = store; // Получаем нужные экшены - - const items = useSightClustering(sights, distanceThreshold, pathPoints); - - const [activeClusterId, setActiveClusterId] = useState(null); - - const handleClusterToggle = useCallback((clusterId: string | null) => { - setActiveClusterId(clusterId); - }, []); - - const handleSightSelected = useCallback( - (sightId: string) => { - setSelectedSightId(sightId); - setIsManualSelection(true); - setIsRightWidgetSelectorOpen(false); // Закрываем селектор правого виджета при клике по достопримечательности - // Закрываем виджет губернатора при выборе достопримечательности - store.closeGovernorModal(); - }, - [ - setSelectedSightId, - setIsManualSelection, - setIsRightWidgetSelectorOpen, - store, - ] - ); - - return ( - <> - {items.map((item) => { - if (item.type === "cluster") { - return ( - - ); - } - return ( - - ); - })} - - ); -} diff --git a/src/client/src/components/map/Station.tsx b/src/client/src/components/map/Station.tsx deleted file mode 100644 index cd5124a..0000000 --- a/src/client/src/components/map/Station.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { Graphics } from "pixi.js"; -import { useCallback, useMemo } from "react"; -import { - BACKGROUND_COLOR, - PATH_COLOR, - STATION_RADIUS, - STATION_OUTLINE_WIDTH, - UNPASSED_STATION_COLOR, -} from "./Constants"; -import { StationData } from "./types"; -import { useTransform } from "./transformContext"; -import { observer } from "mobx-react-lite"; -import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore"; -import { apiStore } from "../../api/ApiStore/store"; - -interface StationProps { - station: StationData; - stationEn?: StationData | null; - stationZh?: StationData | null; - isPassed: boolean; -} - -const BASE_FONT_SIZE = 16; -const POINT_LABEL_FONT_SIZE = 13; -const DEFAULT_LABEL_OFFSET_X = 25; -const DEFAULT_LABEL_OFFSET_Y = 0; - -const getAnchorFromOffset = ( - offsetX: number, - offsetY: number -): { x: number; y: number } => { - if (offsetX === 0 && offsetY === 0) { - return { x: 0, y: 0.5 }; - } - - const length = Math.hypot(offsetX, offsetY); - const nx = offsetX / length; - const ny = offsetY / length; - - return { x: (1 - nx) / 2, y: (1 - ny) / 2 }; -}; - -export const Station = observer( - ({ station, stationEn, stationZh, isPassed }: Readonly) => { - const { scale } = useTransform(); - const { context } = apiStore; - - const { selectedLanguage } = useGeolocationStore(); - - const ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION = 2; // Порог масштаба (в 2 раза) - const HIGH_RESOLUTION_VALUE = 4; - const LOW_RESOLUTION_VALUE = 2; - - const dynamicResolution = - scale > ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION - ? HIGH_RESOLUTION_VALUE - : LOW_RESOLUTION_VALUE; - - const draw = useCallback( - (g: Graphics) => { - g.clear(); - - // Проверяем, является ли станция начальной или конечной - const isTerminalStation = - context && - (station.id.toString() === (context as any)?.startStopId || - station.id.toString() === (context as any)?.endStopId); - - // Определяем радиус станции - const stationRadius = isTerminalStation - ? STATION_RADIUS * 1.5 - : STATION_RADIUS; - - // Рисуем основной круг станции - g.circle(station.longitude, station.latitude, stationRadius); - g.fill({ color: isPassed ? PATH_COLOR : UNPASSED_STATION_COLOR }); - g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH }); - - // Если это терминальная станция, рисуем дополнительный круг по центру - if (isTerminalStation) { - const centerCircleRadius = stationRadius * 0.5; // 50% от радиуса станции - g.circle(station.longitude, station.latitude, centerCircleRadius); - g.fill({ color: BACKGROUND_COLOR }); - } - }, - [station.latitude, station.longitude, station.id, context, isPassed] - ); - - const dynamicFontSize = BASE_FONT_SIZE; - const dynamicPointLabelFontSize = POINT_LABEL_FONT_SIZE; - - // Определяем фактические смещения. Если station.offset_x и station.offset_y оба равны 0, - // используем дефолтный отступ для обеспечения видимости. - const labelOffsetX = - station.offset_x === 0 && station.offset_y === 0 - ? DEFAULT_LABEL_OFFSET_X - : station.offset_x / 3; - const labelOffsetY = - station.offset_x === 0 && station.offset_y === 0 - ? DEFAULT_LABEL_OFFSET_Y - : station.offset_y / 3; - - // Вычисляем позицию текстового блока. Это прямые координаты карты. - const textBlockPositionX = station.longitude + labelOffsetX / 3; - const textBlockPositionY = station.latitude + labelOffsetY; - - // Используем useMemo для запоминания dynamicAnchor на основе *фактически используемого* отступа. - const dynamicAnchor = useMemo( - () => getAnchorFromOffset(labelOffsetX, labelOffsetY), - [labelOffsetX, labelOffsetY] - ); - - return ( - - - - - - {(selectedLanguage === "en" || selectedLanguage === "ru") && ( - - )} - - {selectedLanguage === "zh" && ( - - )} - - ); - } -); diff --git a/src/client/src/components/map/TramIcon.tsx b/src/client/src/components/map/TramIcon.tsx deleted file mode 100644 index d83bfbc..0000000 --- a/src/client/src/components/map/TramIcon.tsx +++ /dev/null @@ -1,375 +0,0 @@ -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(undefined); - - // Плавная анимация позиции и углов (логика из HTML файла) - useEffect(() => { - const animate = () => { - // Анимируем позицию (как в HTML файле) - setSmoothPosition((prev) => { - const newX = lerp(prev.x, x, LERP_SPEED); - const newY = lerp(prev.y, y, LERP_SPEED); - return { - x: Math.abs(newX - x) < 0.1 ? x : newX, - y: Math.abs(newY - y) < 0.1 ? y : newY, - }; - }); - - // Анимируем оптимальный угол - setSmoothOptimalAngle((prev) => { - const newAngle = lerpAngle(prev, optimalAngle, LERP_SPEED); - return Math.abs(newAngle - optimalAngle) < 0.01 - ? optimalAngle - : newAngle; - }); - - // Анимируем угол трамвая - setSmoothTramAngle((prev) => { - const newAngle = lerpAngle(prev, angle, LERP_SPEED); - return Math.abs(newAngle - angle) < 0.01 ? angle : newAngle; - }); - - // Продолжаем анимацию, если что-то еще не достигло цели - if ( - Math.abs(smoothPosition.x - x) > 0.1 || - Math.abs(smoothPosition.y - y) > 0.1 || - Math.abs(smoothOptimalAngle - optimalAngle) > 0.01 || - Math.abs(smoothTramAngle - angle) > 0.01 - ) { - animationRef.current = requestAnimationFrame(animate); - } - }; - - animationRef.current = requestAnimationFrame(animate); - - return () => { - if (animationRef.current !== undefined) { - cancelAnimationFrame(animationRef.current); - } - }; - }, [ - x, - y, - optimalAngle, - angle, - smoothPosition, - smoothOptimalAngle, - smoothTramAngle, - ]); - - // Обычные размеры без увеличения - const backgroundWidth = 111 / scale; - const backgroundHeight = 82 / scale; - const tramWidth = 31 / scale; - const tramHeight = 52 / scale; - - const [baseTexture, setBaseTexture] = useState(null); - const [tramTexture, setTramTexture] = useState(null); - - useEffect(() => { - Assets.load(basePath).then(setBaseTexture).catch(console.error); - Assets.load(tramPath).then(setTramTexture).catch(console.error); - }, []); - - if (!baseTexture || !tramTexture) return null; - - return ( - - {/* вращающийся контейнер с плавным оптимальным углом */} - - 53.7 ? backgroundWidth : 53.7} - height={backgroundHeight > 39.68 ? backgroundHeight : 39.68} - /> - - {/* контейнер иконки трамвая с плавным поворотом направления движения */} - 53.7 - ? -backgroundWidth / 1.42 - : -backgroundWidth / 0.98 - } - y={0} - > - - - - - ); -} diff --git a/src/client/src/components/map/TravelPath.tsx b/src/client/src/components/map/TravelPath.tsx deleted file mode 100644 index 4637b14..0000000 --- a/src/client/src/components/map/TravelPath.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Graphics } from "pixi.js"; -import { useCallback } from "react"; -import { PATH_COLOR, PATH_WIDTH, UNPASSED_STATION_COLOR } from "./Constants"; - -interface TravelPathProps { - points: { x: number; y: number }[]; - busCoordinates?: { x: number; y: number }; -} - -const PASSED_PATH_COLOR = PATH_COLOR; -const UNPASSED_PATH_COLOR = UNPASSED_STATION_COLOR; - -export function TravelPath({ - points, - busCoordinates, -}: Readonly) { - const draw = useCallback( - (g: Graphics) => { - g.clear(); - - if (points.length < 2) { - return; - } - - g.moveTo(points[0].x, points[0].y); - for (let i = 1; i < points.length; i++) { - g.lineTo(points[i].x, points[i].y); - } - g.stroke({ - color: UNPASSED_PATH_COLOR, - width: PATH_WIDTH, - }); - - if (!busCoordinates) { - return; - } - - let minDistance = Infinity; - let closestSegmentIndex = -1; - let busSegmentStartPoint: { x: number; y: number } | null = null; - - for (let i = 0; i < points.length - 1; i++) { - const p1 = points[i]; - const p2 = points[i + 1]; - - const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; - if (lineLengthSq === 0) continue; - - const t = - ((busCoordinates.x - p1.x) * (p2.x - p1.x) + - (busCoordinates.y - p1.y) * (p2.y - p1.y)) / - lineLengthSq; - const projectionT = Math.max(0, Math.min(1, t)); - - const projectedX = p1.x + projectionT * (p2.x - p1.x); - const projectedY = p1.y + projectionT * (p2.y - p1.y); - - const dist = Math.sqrt( - (busCoordinates.x - projectedX) ** 2 + - (busCoordinates.y - projectedY) ** 2 - ); - - if (dist < minDistance) { - minDistance = dist; - closestSegmentIndex = i; - busSegmentStartPoint = { x: projectedX, y: projectedY }; - } - } - - if (closestSegmentIndex !== -1 && busSegmentStartPoint) { - g.moveTo(points[0].x, points[0].y); - for (let i = 1; i <= closestSegmentIndex; i++) { - g.lineTo(points[i].x, points[i].y); - } - g.lineTo(busSegmentStartPoint.x, busSegmentStartPoint.y); - - g.stroke({ - color: PASSED_PATH_COLOR, - width: PATH_WIDTH, - }); - } - }, - [points, busCoordinates] - ); - - if (points.length === 0) { - console.error("points is empty"); - return null; - } - - return ; -} diff --git a/src/client/src/components/side-menu/Collapsible.jsx b/src/client/src/components/side-menu/Collapsible.jsx new file mode 100644 index 0000000..6e31650 --- /dev/null +++ b/src/client/src/components/side-menu/Collapsible.jsx @@ -0,0 +1,39 @@ +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 ( +
+ {children} +
+ ); +}; + +export default Collapsible; diff --git a/src/client/src/components/side-menu/SideMenu.jsx b/src/client/src/components/side-menu/SideMenu.jsx index ee73562..e9e7af2 100644 --- a/src/client/src/components/side-menu/SideMenu.jsx +++ b/src/client/src/components/side-menu/SideMenu.jsx @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { observer } from "mobx-react-lite"; 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"; @@ -85,7 +86,7 @@ const SideMenu = observer(({ onMenuToggle }) => { let scrollTarget = null; const targetElement = document.elementFromPoint( touch.clientX, - touch.clientY + touch.clientY, ); // Определяем, над каким из скролл-контейнеров находится палец @@ -263,18 +264,17 @@ const SideMenu = observer(({ onMenuToggle }) => { } }; - const isMenuOpenRef = useRef(isMenuOpen); const handleMenuToggleRef = useRef(handleMenuToggle); - useEffect(() => { isMenuOpenRef.current = isMenuOpen; }, [isMenuOpen]); - useEffect(() => { handleMenuToggleRef.current = handleMenuToggle; }); + handleMenuToggleRef.current = handleMenuToggle; + + const isMenuOpenRef = useRef(isMenuOpen); + isMenuOpenRef.current = isMenuOpen; useEffect(() => { - // Автоматическое закрытие сайд-меню после 60 секунд бездействия let idleSeconds = 0; const checkIdle = () => { idleSeconds += 1; - if (idleSeconds >= 60 && isMenuOpenRef.current) { handleMenuToggleRef.current(false); } @@ -307,6 +307,47 @@ const SideMenu = observer(({ onMenuToggle }) => { }; }, []); + // Автоматическое скрытие левого виджета через 60 секунд бездействия + useEffect(() => { + if (!isLeftWidgetVisible) return; + + let idleSeconds = 0; + + const checkIdle = () => { + idleSeconds += 1; + if (idleSeconds >= 60) { + setIsLeftWidgetVisible(false); + setTimeout(() => { + setIsLeftWidgetOpen(false); + }, 1000); + } + }; + + const intervalId = setInterval(checkIdle, 1000); + + const resetIdle = () => { + idleSeconds = 0; + }; + + const events = [ + "mousedown", + "mousemove", + "keypress", + "scroll", + "touchstart", + "click", + ]; + + events.forEach((event) => + window.addEventListener(event, resetIdle, { passive: true }), + ); + + return () => { + clearInterval(intervalId); + events.forEach((event) => window.removeEventListener(event, resetIdle)); + }; + }, [isLeftWidgetVisible, setIsLeftWidgetOpen]); + // Закрываем и открываем список достопримечательностей при изменении сортировки const prevSortingByRef = useRef(sortingBy); const isFirstRenderRef = useRef(true); @@ -498,16 +539,16 @@ const SideMenu = observer(({ onMenuToggle }) => { }, 300); } }} - className={`side-menu-button ${ + className={`side-menu-button side-menu-button--sights ${ isSightsOpen ? "side-menu-button--active" : "" }`} > {selectedLanguage == "ru" ? "Достопримечательности" : selectedLanguage == "zh" - ? "景点" - : "Attractions"} -
+ ? "景点" + : "Attractions"} +
{ if (!isStationOpen) { @@ -553,9 +594,9 @@ const SideMenu = observer(({ onMenuToggle }) => { {selectedLanguage == "ru" ? "Остановки" : selectedLanguage == "zh" - ? "车站" - : "Stations"} -
+ ? "车站" + : "Stations"} +
{/* {selectedLanguage == "ru" @@ -583,11 +624,26 @@ const SideMenu = observer(({ onMenuToggle }) => {
+
+ +
+ { const m = sightArticles.get(route?.governor_appeal + "_ru")?.media; const mediaId = Array.isArray(m) ? m[0]?.id : m?.id; - return mediaId ? getMediaUrl(mediaId) : undefined; + return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined; })()} isOpen={isWidgetOpen} style={{ @@ -600,15 +656,15 @@ const SideMenu = observer(({ onMenuToggle }) => { selectedLanguage == "ru" ? sightArticles.get(route?.governor_appeal + "_ru")?.heading : selectedLanguage == "zh" - ? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading - : sightArticlesEn.get(route?.governor_appeal + "_en")?.heading + ? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading + : sightArticlesEn.get(route?.governor_appeal + "_en")?.heading } widgetText={ selectedLanguage == "ru" ? sightArticles.get(route?.governor_appeal + "_ru")?.body : selectedLanguage == "zh" - ? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body - : sightArticlesEn.get(route?.governor_appeal + "_en")?.body + ? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body + : sightArticlesEn.get(route?.governor_appeal + "_en")?.body } /> diff --git a/src/client/src/components/side-menu/SightsList.jsx b/src/client/src/components/side-menu/SightsList.jsx index f70c63b..a31ec8e 100644 --- a/src/client/src/components/side-menu/SightsList.jsx +++ b/src/client/src/components/side-menu/SightsList.jsx @@ -10,6 +10,7 @@ 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"; @@ -38,25 +39,8 @@ const SightItem = ({ const [shouldAnimate, setShouldAnimate] = useState(false); const [isExpanded, setIsExpanded] = useState(false); - // Получаем название для отображения const getSightName = () => { - // Если есть короткое название, используем его - if (sight.short_name) { - return sight.short_name; - } - - if (!sight.left_article) { - return sight.name; - } - - const leftArticleData = - selectedLanguage === "ru" - ? sightArticles.get(sight.left_article + "_ru") - : selectedLanguage === "en" - ? sightArticlesEn.get(sight.left_article + "_en") - : sightArticlesZh.get(sight.left_article + "_zh"); - - return leftArticleData?.heading || sight.name; + return sight.short_name || sight.name; }; const sightName = getSightName(); @@ -84,12 +68,6 @@ const SightItem = ({ return () => window.removeEventListener("resize", checkWidth); }, [sightName]); - useEffect(() => { - if (localSelectedSightId !== sight.id) { - setIsExpanded(false); - } - }, [localSelectedSightId, sight.id]); - const handleClick = (e) => { const newExpanded = !isExpanded; setIsExpanded(newExpanded); @@ -101,13 +79,31 @@ 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 (
-
+ {stations.length > 0 ? ( stations.map((station, index) => { - const iconSrc = isMediaIdEmpty(station.icon) + let isMediaIdEmptyResult = isMediaIdEmpty(station.icon); + const iconSrc = isMediaIdEmptyResult ? stationIcon : getMediaUrl(station.icon); @@ -166,7 +161,7 @@ const SightItem = ({ : "No stations"}
)} -
+
); }; @@ -242,24 +237,8 @@ const SightsList = observer( : selectedLanguage === "en" ? routeSightsEn : routeSightsZh; - // Функция для получения названия для сортировки const getSightNameForSort = (sight) => { - if (sight.short_name) { - return sight.short_name.trim(); - } - - if (!sight.left_article) { - return sight.name.trim(); - } - - const leftArticleData = - selectedLanguage === "ru" - ? sightArticles.get(sight.left_article + "_ru") - : selectedLanguage === "en" - ? sightArticlesEn.get(sight.left_article + "_en") - : sightArticlesZh.get(sight.left_article + "_zh"); - - return (leftArticleData?.heading || sight.name).trim(); + return (sight.short_name || sight.name).trim(); }; const sortedSights = [...(sights || [])].sort((a, b) => { @@ -358,24 +337,17 @@ 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); - } + setLocalSelectedSightId(sightId); + setIsLeftWidgetOpen(true); + if (onSightSelected) { + onSightSelected(sightId); } }; diff --git a/src/client/src/components/side-menu/StationSightsList.jsx b/src/client/src/components/side-menu/StationSightsList.jsx index d36bfbd..0298231 100644 --- a/src/client/src/components/side-menu/StationSightsList.jsx +++ b/src/client/src/components/side-menu/StationSightsList.jsx @@ -28,10 +28,10 @@ const SightItem = ({ const textRef = useRef(null); const [shouldAnimate, setShouldAnimate] = useState(false); - // Получаем название из left_article + // Получаем название из left_article или используем short_name/name const getSightName = () => { if (!sight.left_article) { - return sight.name; + return sight.short_name || 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.name; + return leftArticleData?.heading || sight.short_name || sight.name; }; const sightName = getSightName(); @@ -119,10 +119,9 @@ const StationSightsList = observer( selectedLanguage ); - // Функция для получения названия из left_article const getSightNameForSort = (sight) => { if (!sight.left_article) { - return sight.name.trim(); + return (sight.short_name || sight.name).trim(); } const leftArticleData = @@ -132,7 +131,7 @@ const StationSightsList = observer( ? sightArticlesEn.get(sight.left_article + "_en") : sightArticlesZh.get(sight.left_article + "_zh"); - return (leftArticleData?.heading || sight.name).trim(); + return (leftArticleData?.heading || sight.short_name || sight.name).trim(); }; const sortedSights = [...(response || [])].sort((a, b) => { @@ -310,3 +309,4 @@ const StationSightsList = observer( ); export default StationSightsList; +nSightsList; diff --git a/src/client/src/components/side-menu/StationsList.jsx b/src/client/src/components/side-menu/StationsList.jsx index 2cf8158..9d9cf4b 100644 --- a/src/client/src/components/side-menu/StationsList.jsx +++ b/src/client/src/components/side-menu/StationsList.jsx @@ -10,6 +10,7 @@ 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); @@ -92,31 +93,14 @@ const StationItem = ({ const sights = stationSightsCache.get(cacheKey) || []; const getSightName = (sight) => { - if (sight.short_name) { - return sight.short_name; - } - - if (!sight.left_article) { - return sight.name; - } - - const leftArticleData = - selectedLanguage === "ru" - ? sightArticles.get(sight.left_article + "_ru") - : selectedLanguage === "en" - ? sightArticlesEn.get(sight.left_article + "_en") - : sightArticlesZh.get(sight.left_article + "_zh"); - - return leftArticleData?.heading || sight.name; + return sight.short_name || sight.name; }; return (
-
{sights.length > 0 ? ( sights.map((sight, index) => ( @@ -171,7 +154,7 @@ const StationItem = ({ : "No sights"}
)} -
+
); }; diff --git a/src/client/src/components/widgets/RouteWidget.jsx b/src/client/src/components/widgets/RouteWidget.jsx index 2734e62..1b8a872 100644 --- a/src/client/src/components/widgets/RouteWidget.jsx +++ b/src/client/src/components/widgets/RouteWidget.jsx @@ -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, route } = apiStore; + const { routeStations, routeStationsEn, routeStationsZh, context } = apiStore; const [startStation, setStartStation] = useState(null); const [startStationEn, setStartStationEn] = useState(null); const [endStation, setEndStation] = useState(null); @@ -84,22 +84,22 @@ const RouteWidget = observer(() => { const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`; return (
-
- {route?.route_sys_number || context?.routeNumber || ""} +
+ {context?.routeNumber || "No number"}
+ > {startStation?.name}
+ > {endStation?.name}
{(selectedLanguage === "en" || selectedLanguage === "ru") && ( @@ -107,7 +107,7 @@ const RouteWidget = observer(() => { className={`route-widget-subtitle ${ shouldAnimate(routeEnSubtitle, 50) ? "marquee" : "" }`} -> + > {routeEnSubtitle}
)} @@ -116,7 +116,7 @@ const RouteWidget = observer(() => { className={`route-widget-subtitle ${ shouldAnimate(routeZhSubtitle, 50) ? "marquee" : "" }`} -> + > {routeZhSubtitle}
)} diff --git a/src/client/src/styles/LeftWidget.css b/src/client/src/styles/LeftWidget.css index b0cc157..340a19e 100644 --- a/src/client/src/styles/LeftWidget.css +++ b/src/client/src/styles/LeftWidget.css @@ -166,24 +166,15 @@ animation: side-menu-marquee 14s linear infinite; } -/* Анимация для списка пересадок */ -.side-menu-sight-transfer-list.entering, -.side-menu-sight-transfer-list.entered { - max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */ - opacity: 1; - transition: max-height 0.3s ease-out, opacity 0.3s ease-out; - overflow: hidden; -} - .side-menu-sight-transfer-list { - max-height: 0; + height: 0; opacity: 0; overflow: hidden; - transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */ + transition: + height 0.3s cubic-bezier(0.16, 1, 0.3, 1), + opacity 0.3s ease; } -/* Активное состояние - когда список открыт */ .side-menu-sight-transfer-list.open { - max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */ opacity: 1; } diff --git a/src/client/src/styles/ListOfSights.css b/src/client/src/styles/ListOfSights.css index 80e439b..d9b00c6 100644 --- a/src/client/src/styles/ListOfSights.css +++ b/src/client/src/styles/ListOfSights.css @@ -48,6 +48,8 @@ } .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); @@ -63,7 +65,7 @@ color: white; max-height: 68px; - transition: max-height 0.15s ease; + transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1); overflow: hidden; } @@ -105,11 +107,7 @@ border-radius: 10px; width: 128px; - background-color: color-mix( - in srgb, - var(--carrier-right, #806c59) 80%, - black - ); + background-color: var(--carrier-right-dark, #005a2f); } .list-of-sights-title { @@ -127,7 +125,7 @@ opacity: 0; pointer-events: none; - transition: opacity 0.15s ease-in-out 0.15s; + transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1) 0.1s; overscroll-behavior: contain; touch-action: pan-y; @@ -146,7 +144,9 @@ black calc(100% - var(--fade-bottom)), transparent 100% ); - transition: --fade-top 0.5s ease, --fade-bottom 0.5s ease; + transition: + --fade-top 0.5s ease, + --fade-bottom 0.5s ease; } .list-of-sights-content:not(.is-at-top) .scrollable { @@ -171,6 +171,7 @@ } .list-of-sights-content .custom-scrollbar-track { + margin-top: 14px; margin-bottom: 10px; overflow: hidden; } @@ -247,18 +248,18 @@ height: 1px; width: 100%; background-color: rgba(255, 255, 255, 0.3); - margin-bottom: 14px; + margin-bottom: 0; } .sight-frame { z-index: -1; position: absolute; - bottom: 66px; + bottom: 100px; + left: 0; display: flex; flex-direction: column; align-items: center; - margin-bottom: 32px; - width: 550px; + width: 100%; border-radius: 10px; background: linear-gradient( @@ -268,6 +269,12 @@ ), var(--carrier-right, #806c59); max-height: calc(100vh - 128px); + transform: translate3d(0, 0, 0); + overflow: hidden; +} + +.sight-frame.three-d-fullscreen { + overflow: visible; } .sight-frame-image { @@ -284,6 +291,11 @@ 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 { @@ -314,10 +326,22 @@ 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( @@ -345,18 +369,40 @@ } .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; + 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; } .sight-frame-text-wrapper .scrollable-viewport { flex: 1; @@ -367,11 +413,12 @@ } .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: 18px; + font-size: 20px; font-style: normal; font-weight: 400; line-height: 150%; @@ -386,7 +433,7 @@ background: linear-gradient( to right, transparent 35%, - color-mix(in srgb, var(--carrier-right, #806c59) 80%, black) 50%, + #0e8953 50%, transparent 65% ); border-radius: 3px; @@ -409,8 +456,14 @@ } .sight-frame-menu-wrapper { - position: relative; + position: absolute; + bottom: 100px; + left: 0; width: 100%; + height: 60px; + z-index: 2; + border-radius: 0 0 10px 10px; + overflow: hidden; flex-shrink: 0; } @@ -419,7 +472,7 @@ top: 0; bottom: 0; width: 120px; - z-index: 3; + z-index: 100001; pointer-events: none; transition: opacity 0.4s ease; } @@ -445,7 +498,7 @@ } .sight-frame-menu { - z-index: 10000; + z-index: 100000; position: relative; padding: 7px 60px; width: 100%; @@ -496,7 +549,7 @@ } .sight-frame-menu-point.active { - font-weight: 600; + text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff; border-bottom-color: #fff; } @@ -522,30 +575,20 @@ } .sight-frame { opacity: 0; - transform: translateY(20px); - transition: - opacity 0.2s ease-out, - transform 0.2s ease-out; + transition: opacity 0.9s ease-out; } .sight-frame.is-visible { opacity: 1; - transform: translateY(0); - animation: fadeInScale 0.6s ease-out forwards; + animation: fadeInScale 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards; } @keyframes fadeInScale { - 0% { + from { opacity: 0; - transform: translateY(20px) scale(0.95); } - 50% { - opacity: 0.7; - transform: translateY(-5px) scale(1.02); - } - 100% { + to { opacity: 1; - transform: translateY(0) scale(1); } } @@ -597,6 +640,7 @@ font-size: 40px; font-weight: 600; line-height: 150%; + padding-bottom: 60px; } .svg-container { @@ -654,15 +698,17 @@ width: 40px; flex-shrink: 0; margin-right: 10px; - padding-top: 24px; + margin-top: 14px; + margin-bottom: 10px; + padding-top: 10px; + padding-bottom: 10px; display: flex; align-items: center; flex-direction: column; - gap: 16px; + gap: 20px; overflow-y: auto; overflow-x: hidden; - height: 650px; - padding-bottom: 30px; + height: 676px; touch-action: pan-y; overscroll-behavior: contain; scrollbar-width: none; @@ -881,7 +927,7 @@ border-radius: 32px; right: 20px; bottom: 20px; - background: var(--carrier-right, #806c59); + background: var(--carrier-main, #006f3a); z-index: 9999; display: flex; } diff --git a/src/client/src/styles/RouteWidget.css b/src/client/src/styles/RouteWidget.css index b1b0992..8ae2ef2 100644 --- a/src/client/src/styles/RouteWidget.css +++ b/src/client/src/styles/RouteWidget.css @@ -52,8 +52,7 @@ height: 96px; background-color: #fcd500; color: black; - border-top-left-radius: 10px; - border-bottom-left-radius: 10px; + border-radius: 10px 0 0 10px; display: flex; justify-content: center; align-items: center; diff --git a/src/client/src/styles/SideMenu.css b/src/client/src/styles/SideMenu.css index 7c97e18..9322f20 100644 --- a/src/client/src/styles/SideMenu.css +++ b/src/client/src/styles/SideMenu.css @@ -41,7 +41,6 @@ .side-menu-buttons { width: 220px; - margin-top: 40px; } .side-menu-button { @@ -198,6 +197,8 @@ overflow: hidden; top: 250px; bottom: 0; + display: flex; + flex-direction: column; border-radius: 10px 10px 0px 0px; background: linear-gradient( @@ -213,8 +214,6 @@ transition: transform 0.3s ease-out, opacity 0.3s ease-out; - display: flex; - flex-direction: column; } .side-menu-sights.slide-in { @@ -253,10 +252,15 @@ position: relative; } -.side-menu-sight-selected-wrapper { - background: rgba(0, 0, 0, 0.2); +.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 { diff --git a/src/client/src/styles/TouchableLayout.css b/src/client/src/styles/TouchableLayout.css index 595edff..dc79d6b 100644 --- a/src/client/src/styles/TouchableLayout.css +++ b/src/client/src/styles/TouchableLayout.css @@ -43,7 +43,8 @@ position: relative; background: rgba(255, 255, 255, 0.2); border-radius: 3px; - transition: opacity 0.2s ease; + overflow: hidden; + transition: opacity 0.5s ease; } .custom-scrollbar-thumb { @@ -56,6 +57,7 @@ pointer-events: auto; cursor: grab; touch-action: none; + transition: opacity 0.5s ease, height 0.5s ease; } .custom-scrollbar-thumb:active { diff --git a/src/pages/Route/route-preview/InfiniteCanvas.tsx b/src/pages/Route/route-preview/InfiniteCanvas.tsx deleted file mode 100644 index a31317c..0000000 --- a/src/pages/Route/route-preview/InfiniteCanvas.tsx +++ /dev/null @@ -1,232 +0,0 @@ -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 ?

Whoopsie Daisy!

: 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(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 ( - - {applicationRef?.app && ( - { - 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} - /> - )} - - {children} - - {/* Show center of the screen. - { - g.clear(); - const center = screenCenter ?? {x: 0, y: 0}; - g.circle(center.x, center.y, 1); - g.fill("#fff"); - }} - /> */} - - ); -} diff --git a/src/pages/Route/route-preview/Sight.tsx b/src/pages/Route/route-preview/Sight.tsx deleted file mode 100644 index acecb48..0000000 --- a/src/pages/Route/route-preview/Sight.tsx +++ /dev/null @@ -1,137 +0,0 @@ -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) => { - 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 ( - - - { - g.clear(); - g.circle(0, 0, 20 / scale); - g.fill({ color: "#000" }); - }} - x={compensatedSize} - y={0} - /> - - - ); -}; diff --git a/src/pages/Route/route-preview/Station.tsx b/src/pages/Route/route-preview/Station.tsx deleted file mode 100644 index 9944e85..0000000 --- a/src/pages/Route/route-preview/Station.tsx +++ /dev/null @@ -1,557 +0,0 @@ -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 {} - -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 = ({ - 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 ( - { - e.stopPropagation(); - onControlPointerEnter(); - }} - onPointerOut={(e: FederatedMouseEvent) => { - e.stopPropagation(); - onControlPointerLeave(); - }} - onPointerDown={(e: FederatedMouseEvent) => { - e.stopPropagation(); - }} - > - {/* Основной фон */} - - - {/* Кнопки с подсветкой */} - {alignOptions.map((option, index) => ( - - {/* Подсветка активной кнопки */} - - drawButtonHighlight(g, index, option.key === currentAlign) - } - /> - - {/* Текст кнопки */} - { - e.stopPropagation(); - onAlignChange(option.key); - }} - onPointerDown={(e: FederatedMouseEvent) => { - e.stopPropagation(); - onAlignChange(option.key); - }} - onPointerOver={(e: FederatedMouseEvent) => { - e.stopPropagation(); - onControlPointerEnter(); - }} - /> - - ))} - - ); -}; - -const StationLabel = observer( - ({ - station, - ruLabel, - - labelAlign: labelAlignProp = "center", - onLabelAlignChange, - onTextHover, - }: Readonly) => { - 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(null); - const ruLabelRef = useRef(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 ( - - - {ruLabelWidth > 0 && ( - { - g.clear(); - const hasSecondLabel = !!(station.name && language !== "ru" && ruLabel); - const pad = 10 / scale; - const w = ruLabelWidth + pad * 2; - const top = -compensatedRuFontSize / 2 - pad; - const bottom = hasSecondLabel - ? compensatedRuFontSize * 1.1 + compensatedNameFontSize / 2 + pad - : compensatedRuFontSize / 2 + pad; - g.rect(-w / 2, top, w, bottom - top); - g.fill({ color: 0x000000, alpha: 0.001 }); - }} - /> - )} - {ruLabel && ( - - )} - {station.name && language !== "ru" && ruLabel && ( - - )} - {(isHovered || isControlHovered) && !isDragging && ( - - )} - - - ); - } -); - -export const Station = ({ - station, - ruLabel, - - labelAlign, - onLabelAlignChange, -}: Readonly) => { - 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 ( - - - - - ); -}; diff --git a/src/pages/Route/route-preview/TravelPath.tsx b/src/pages/Route/route-preview/TravelPath.tsx deleted file mode 100644 index 5102bcb..0000000 --- a/src/pages/Route/route-preview/TravelPath.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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) { - 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 ; -} diff --git a/src/pages/Route/route-preview/index.tsx b/src/pages/Route/route-preview/index.tsx index b84c89d..0aaa7c1 100644 --- a/src/pages/Route/route-preview/index.tsx +++ b/src/pages/Route/route-preview/index.tsx @@ -1,14 +1,5 @@ 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"; @@ -22,15 +13,6 @@ 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(); @@ -172,25 +154,6 @@ export const RouteMap = observer(() => { return (
- {/* - - - {stationData[language].map((obj, index) => ( - - ))} - {originalSightData?.map((sight: SightData, index: number) => { - return ; - })} - - */}
); diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo deleted file mode 100644 index e795ac1..0000000 --- a/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/globalerrorboundary.tsx","./src/app/index.tsx","./src/app/router/index.tsx","./src/client/src/app.d.ts","./src/client/src/api/apiconfig.d.ts","./src/client/src/api/apistore/api.ts","./src/client/src/api/apistore/index.ts","./src/client/src/api/apistore/store.ts","./src/client/src/api/apistore/types.ts","./src/client/src/assets/constants.d.ts","./src/client/src/components/overlayscrollbarswrapper.d.ts","./src/client/src/components/simulationsettings.tsx","./src/client/src/components/threeviewerrorboundary.tsx","./src/client/src/components/reactmarkdown/index.tsx","./src/client/src/components/touchablelayout/index.tsx","./src/client/src/components/map/constants.tsx","./src/client/src/components/map/infinitecanvas.tsx","./src/client/src/components/map/map.tsx","./src/client/src/components/map/mapdatacontext.tsx","./src/client/src/components/map/sight.tsx","./src/client/src/components/map/station.tsx","./src/client/src/components/map/tramicon.tsx","./src/client/src/components/map/tramiconwebgl.tsx","./src/client/src/components/map/travelpath.tsx","./src/client/src/components/map/webglmap.tsx","./src/client/src/components/map/custom.d.ts","./src/client/src/components/map/transformcontext.tsx","./src/client/src/components/map/types.tsx","./src/client/src/components/map/utils.tsx","./src/client/src/components/widgets/panoramview.tsx","./src/client/src/components/widgets/threeview.tsx","./src/client/src/components/widgets/threeviewicons.tsx","./src/client/src/context/geolocationcontext.tsx","./src/client/src/hooks/useanimatedposition.ts","./src/client/src/hooks/useroutefollowingposition.ts","./src/client/src/stores/cameraanimationstore.ts","./src/client/src/stores/colorstore.ts","./src/client/src/stores/geolocationstore.ts","./src/client/src/stores/index.ts","./src/client/src/stores/hooks/usecameraanimationstore.ts","./src/client/src/stores/hooks/usecolorstore.ts","./src/client/src/stores/hooks/usegeolocationstore.ts","./src/client/src/utils/routepathanimator.ts","./src/client/src/utils/animationutils.ts","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/article/index.ts","./src/pages/article/articlecreatepage/index.tsx","./src/pages/article/articleeditpage/index.tsx","./src/pages/article/articlelistpage/index.tsx","./src/pages/article/articlepreviewpage/previewleftwidget.tsx","./src/pages/article/articlepreviewpage/previewrightwidget.tsx","./src/pages/article/articlepreviewpage/index.tsx","./src/pages/carrier/index.ts","./src/pages/carrier/carriercreatepage/index.tsx","./src/pages/carrier/carriereditpage/index.tsx","./src/pages/carrier/carrierlistpage/index.tsx","./src/pages/city/index.ts","./src/pages/city/citycreatepage/index.tsx","./src/pages/city/cityeditpage/index.tsx","./src/pages/city/citylistpage/index.tsx","./src/pages/city/citypreviewpage/index.tsx","./src/pages/country/index.ts","./src/pages/country/countryaddpage/index.tsx","./src/pages/country/countrycreatepage/index.tsx","./src/pages/country/countryeditpage/index.tsx","./src/pages/country/countrylistpage/index.tsx","./src/pages/country/countrypreviewpage/index.tsx","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/mappage/index.tsx","./src/pages/mappage/mapstore.ts","./src/pages/media/index.ts","./src/pages/media/mediacreatepage/index.tsx","./src/pages/media/mediaeditpage/index.tsx","./src/pages/media/medialistpage/index.tsx","./src/pages/media/mediapreviewpage/index.tsx","./src/pages/route/linekedstations.tsx","./src/pages/route/index.ts","./src/pages/route/demopage/index.tsx","./src/pages/route/routecreatepage/index.tsx","./src/pages/route/routeeditpage/index.tsx","./src/pages/route/routelistpage/index.tsx","./src/pages/route/route-preview/constants.ts","./src/pages/route/route-preview/infinitecanvas.tsx","./src/pages/route/route-preview/leftsidebar.tsx","./src/pages/route/route-preview/mapdatacontext.tsx","./src/pages/route/route-preview/rightsidebar.tsx","./src/pages/route/route-preview/sight.tsx","./src/pages/route/route-preview/sightinfowidget.tsx","./src/pages/route/route-preview/station.tsx","./src/pages/route/route-preview/transformcontext.tsx","./src/pages/route/route-preview/travelpath.tsx","./src/pages/route/route-preview/widgets.tsx","./src/pages/route/route-preview/index.tsx","./src/pages/route/route-preview/types.ts","./src/pages/route/route-preview/utils.ts","./src/pages/route/route-preview/web-gl/languageselector.tsx","./src/pages/route/route-preview/webgl-prototype/routewidget.tsx","./src/pages/route/route-preview/webgl-prototype/webglroutemapprototype.tsx","./src/pages/sight/linkedstations.tsx","./src/pages/sight/index.ts","./src/pages/sight/sightlistpage/index.tsx","./src/pages/sightpage/index.tsx","./src/pages/snapshot/index.ts","./src/pages/snapshot/snapshotcreatepage/index.tsx","./src/pages/snapshot/snapshotlistpage/index.tsx","./src/pages/station/linkedsights.tsx","./src/pages/station/index.ts","./src/pages/station/stationcreatepage/index.tsx","./src/pages/station/stationeditpage/index.tsx","./src/pages/station/stationlistpage/index.tsx","./src/pages/station/stationpreviewpage/index.tsx","./src/pages/user/index.ts","./src/pages/user/usercreatepage/index.tsx","./src/pages/user/usereditpage/index.tsx","./src/pages/user/userlistpage/index.tsx","./src/pages/vehicle/index.ts","./src/pages/vehicle/vehiclecreatepage/index.tsx","./src/pages/vehicle/vehicleeditpage/index.tsx","./src/pages/vehicle/vehiclelistpage/index.tsx","./src/pages/vehicle/vehiclepreviewpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/api/mobxfetch/index.ts","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/const/mediatypes.ts","./src/shared/hooks/index.ts","./src/shared/hooks/useselectedcity.ts","./src/shared/lib/gltfcachemanager.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/lib/permissions/index.ts","./src/shared/modals/index.ts","./src/shared/modals/articleselectorcreatedialog/index.tsx","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/api.ts","./src/shared/store/authstore/index.tsx","./src/shared/store/carrierstore/index.tsx","./src/shared/store/citystore/index.ts","./src/shared/store/countrystore/index.ts","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/menustore/index.ts","./src/shared/store/modelloadingstore/index.ts","./src/shared/store/routestore/index.ts","./src/shared/store/selectedcitystore/index.ts","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/stationsstore/index.ts","./src/shared/store/testingmodestore/api.ts","./src/shared/store/testingmodestore/index.ts","./src/shared/store/userstore/api.ts","./src/shared/store/userstore/index.ts","./src/shared/store/vehiclestore/api.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/store/vehiclestore/types.ts","./src/shared/ui/animatedcirclebutton.tsx","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/loadingspinner/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/modelloadingindicator/index.tsx","./src/shared/ui/multiselect/index.tsx","./src/shared/ui/searchinput/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/cityselector/index.tsx","./src/widgets/createbutton/index.tsx","./src/widgets/deletemodal/index.tsx","./src/widgets/devicestable/devicelogsmodal.tsx","./src/widgets/devicestable/vehiclesessionsmodal.tsx","./src/widgets/devicestable/index.tsx","./src/widgets/imageuploadcard/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/leaveagree/index.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaareaforsight/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/threeviewerrorboundary.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/permissionstable/permissionstable.tsx","./src/widgets/permissionstable/roleshinttable.tsx","./src/widgets/permissionstable/constants.ts","./src/widgets/permissionstable/index.ts","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/savewithoutcityagree/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/sightframepreview.tsx","./src/widgets/sighttabs/rightwidgettab/sightframethreeview.tsx","./src/widgets/sighttabs/rightwidgettab/sightframethreeviewicons.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/snapshotrestore/index.tsx","./src/widgets/testingmodebanner/index.tsx","./src/widgets/videopreviewcard/index.tsx","./src/widgets/modals/editstationmodal.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/editstationtransfersmodal/index.tsx","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"} \ No newline at end of file