diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx index 7f0f1bc..6ada42b 100644 --- a/src/pages/MapPage/index.tsx +++ b/src/pages/MapPage/index.tsx @@ -6,7 +6,7 @@ import React, { useMemo, } from "react"; import { useNavigate } from "react-router-dom"; -import { Map, View, Overlay, MapBrowserEvent } from "ol"; +import { Map as OLMap, View, Overlay, MapBrowserEvent } from "ol"; import TileLayer from "ol/layer/Tile"; import OSM from "ol/source/OSM"; import VectorLayer from "ol/layer/Vector"; @@ -48,6 +48,9 @@ import { InfoIcon, X, Loader2, + EyeOff, + Eye, + Map as MapIcon, } from "lucide-react"; import { toast } from "react-toastify"; import { singleClick, doubleClick } from "ol/events/condition"; @@ -162,14 +165,71 @@ export type SortType = | "updated_asc" | "updated_desc"; +// --- HIDDEN ROUTES STORAGE --- +const HIDDEN_ROUTES_KEY = "mapHiddenRoutes"; +const HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY = "mapHideSightsByHiddenRoutes"; + +const getStoredHiddenRoutes = (): Set => { + try { + const stored = localStorage.getItem(HIDDEN_ROUTES_KEY); + if (stored) { + const routes = JSON.parse(stored); + if ( + Array.isArray(routes) && + routes.every((id) => typeof id === "number") + ) { + return new Set(routes); + } + } + } catch (error) { + console.warn("Failed to parse stored hidden routes:", error); + } + return new Set(); +}; + +const saveHiddenRoutes = (hiddenRoutes: Set): void => { + try { + localStorage.setItem( + HIDDEN_ROUTES_KEY, + JSON.stringify(Array.from(hiddenRoutes)) + ); + } catch (error) { + console.warn("Failed to save hidden routes:", error); + } +}; + class MapStore { constructor() { makeAutoObservable(this); + // Загружаем скрытые маршруты из localStorage при инициализации + this.hiddenRoutes = getStoredHiddenRoutes(); + // Загружаем настройку скрытия достопримечательностей + try { + const stored = localStorage.getItem(HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY); + this.hideSightsByHiddenRoutes = stored + ? JSON.parse(stored) === true + : false; + } catch (e) { + this.hideSightsByHiddenRoutes = false; + } } routes: ApiRoute[] = []; stations: ApiStation[] = []; sights: ApiSight[] = []; + hiddenRoutes: Set; + hideSightsByHiddenRoutes: boolean = false; + routeStationsCache: Map = new Map(); // Кэш станций для маршрутов + routeSightsCache: Map = new Map(); // Кэш достопримечательностей для маршрутов + setHideSightsByHiddenRoutes(val: boolean) { + this.hideSightsByHiddenRoutes = val; + try { + localStorage.setItem( + HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY, + JSON.stringify(!!val) + ); + } catch (e) {} + } // НОВЫЕ ПОЛЯ ДЛЯ СОРТИРОВКИ stationSort: SortType = "name_asc"; @@ -297,12 +357,23 @@ class MapStore { get filteredSights(): ApiSight[] { const selectedCityId = selectedCityStore.selectedCityId; - if (!selectedCityId) { - return this.sortedSights; + const cityFiltered = !selectedCityId + ? this.sortedSights + : this.sortedSights.filter((sight) => sight.city_id === selectedCityId); + + if (!this.hideSightsByHiddenRoutes || this.hiddenRoutes.size === 0) { + return cityFiltered; } - return this.sortedSights.filter( - (sight) => sight.city_id === selectedCityId - ); + + // Собираем все достопримечательности, связанные со скрытыми маршрутами + const hiddenSightIds = new Set(); + this.hiddenRoutes.forEach((routeId) => { + const sightIds = this.routeSightsCache.get(routeId) || []; + sightIds.forEach((id) => hiddenSightIds.add(id)); + }); + + // Фильтруем достопримечательности, исключая привязанные к скрытым маршрутам + return cityFiltered.filter((s) => !hiddenSightIds.has(s.id)); } getRoutes = async () => { @@ -324,6 +395,54 @@ class MapStore { this.routes = this.routes.sort((a, b) => a.route_number.localeCompare(b.route_number) ); + + // Предзагружаем станции для всех маршрутов и кэшируем их + await this.preloadRouteStations(routesIds); + // Предзагружаем достопримечательности для всех маршрутов + await this.preloadRouteSights(routesIds); + }; + + preloadRouteStations = async (routesIds: number[]) => { + console.log( + `[MapStore] Preloading stations for ${routesIds.length} routes` + ); + const stationPromises = routesIds.map(async (routeId) => { + try { + const stationsResponse = await languageInstance("ru").get( + `/route/${routeId}/station` + ); + const stationIds = stationsResponse.data.map((s: any) => s.id); + this.routeStationsCache.set(routeId, stationIds); + } catch (error) { + console.error( + `Failed to preload stations for route ${routeId}:`, + error + ); + } + }); + await Promise.all(stationPromises); + console.log( + `[MapStore] Preloaded stations for ${this.routeStationsCache.size} routes` + ); + }; + + preloadRouteSights = async (routesIds: number[]) => { + console.log(`[MapStore] Preloading sights for ${routesIds.length} routes`); + const sightPromises = routesIds.map(async (routeId) => { + try { + const sightsResponse = await languageInstance("ru").get( + `/route/${routeId}/sight` + ); + const sightIds = sightsResponse.data.map((s: any) => s.id); + this.routeSightsCache.set(routeId, sightIds); + } catch (error) { + console.error(`Failed to preload sights for route ${routeId}:`, error); + } + }); + await Promise.all(sightPromises); + console.log( + `[MapStore] Preloaded sights for ${this.routeSightsCache.size} routes` + ); }; getStations = async () => { @@ -430,8 +549,8 @@ class MapStore { rotate: 0, route_direction: false, route_sys_number: route_number, - scale_max: 0, - scale_min: 0, + scale_max: 100, + scale_min: 10, }; await routeStore.createRoute(routeData); @@ -651,7 +770,7 @@ interface MapServiceConfig { type FeatureType = "station" | "route" | "sight"; class MapService { - private map: Map | null; + private map: OLMap | null; public pointSource: VectorSource>; public lineSource: VectorSource>; public clusterLayer: VectorLayer; // Public for the deselect handler @@ -943,7 +1062,7 @@ class MapService { const initialCenter = storedPosition?.center || config.center; const initialZoom = storedPosition?.zoom || config.zoom; - this.map = new Map({ + this.map = new OLMap({ target: config.target, layers: [ new TileLayer({ source: new OSM() }), @@ -1251,32 +1370,17 @@ class MapService { } } - // Стандартная логика выделения для одиночных объектов (или с Ctrl) + // При Ctrl+клик сохраняем предыдущие выделения и добавляем/удаляем только изменённые + // При обычном клике создаём новый набор const newSelectedIds = ctrlKey ? new Set(this.selectedIds) : new Set(); + // Добавляем новые выбранные элементы e.selected.forEach((feature) => { const originalFeatures = feature.get("features"); let targetId: string | number | undefined; - if (originalFeatures && originalFeatures.length > 0) { - // Это фича из кластера (может быть и одна) - targetId = originalFeatures[0].getId(); - } else { - // Это линия или что-то не из кластера - targetId = feature.getId(); - } - - if (targetId !== undefined) { - newSelectedIds.add(targetId); - } - }); - - e.deselected.forEach((feature) => { - const originalFeatures = feature.get("features"); - let targetId: string | number | undefined; - if (originalFeatures && originalFeatures.length > 0) { targetId = originalFeatures[0].getId(); } else { @@ -1284,10 +1388,36 @@ class MapService { } if (targetId !== undefined) { - newSelectedIds.delete(targetId); + // При Ctrl+клик: если элемент уже был выбран, снимаем его (toggle) + // Если не был выбран, добавляем + if (ctrlKey && newSelectedIds.has(targetId)) { + newSelectedIds.delete(targetId); + } else { + newSelectedIds.add(targetId); + } } }); + // При Ctrl+клик игнорируем deselected, так как Select interaction может снимать + // предыдущие выделения, но мы хотим их сохранить + // При обычном клике удаляем deselected элементы + if (!ctrlKey) { + e.deselected.forEach((feature) => { + const originalFeatures = feature.get("features"); + let targetId: string | number | undefined; + + if (originalFeatures && originalFeatures.length > 0) { + targetId = originalFeatures[0].getId(); + } else { + targetId = feature.getId(); + } + + if (targetId !== undefined) { + newSelectedIds.delete(targetId); + } + }); + } + this.setSelectedIds(newSelectedIds); }); @@ -1373,8 +1503,33 @@ class MapService { const filteredSights = mapStore.filteredSights; const filteredRoutes = mapStore.filteredRoutes; + console.log( + `[loadFeaturesFromApi] Loading with ${mapStore.hiddenRoutes.size} hidden routes` + ); + + // Собираем все станции видимых маршрутов из кэша + const stationsInVisibleRoutes = new Set(); + filteredRoutes + .filter((route) => !mapStore.hiddenRoutes.has(route.id)) + .forEach((route) => { + const stationIds = mapStore.routeStationsCache.get(route.id) || []; + stationIds.forEach((id) => stationsInVisibleRoutes.add(id)); + }); + + console.log( + `[loadFeaturesFromApi] Found ${stationsInVisibleRoutes.size} stations in visible routes, total stations: ${filteredStations.length}` + ); + + let skippedStations = 0; filteredStations.forEach((station) => { if (station.longitude == null || station.latitude == null) return; + + // Пропускаем станции, которые принадлежат только скрытым маршрутам + if (!stationsInVisibleRoutes.has(station.id)) { + skippedStations++; + return; + } + const point = new Point( transform( [station.longitude, station.latitude], @@ -1405,6 +1560,10 @@ class MapService { filteredRoutes.forEach((route) => { if (!route.path || route.path.length === 0) return; + + // Пропускаем скрытые маршруты + if (mapStore.hiddenRoutes.has(route.id)) return; + const coordinates = route.path .filter((c) => c && c[0] != null && c[1] != null) .map((c: [number, number]) => @@ -1423,6 +1582,10 @@ class MapService { lineFeatures.push(lineFeature); }); + console.log( + `[loadFeaturesFromApi] Skipped ${skippedStations} stations (belonging only to hidden routes)` + ); + this.pointSource.addFeatures(pointFeatures); this.lineSource.addFeatures(lineFeatures); @@ -1880,10 +2043,14 @@ class MapService { this.selectInteraction.getFeatures().clear(); ids.forEach((id) => { const lineFeature = this.lineSource.getFeatureById(id); - if (lineFeature) this.selectInteraction.getFeatures().push(lineFeature); + if (lineFeature) { + this.selectInteraction.getFeatures().push(lineFeature); + } const pointFeature = this.pointSource.getFeatureById(id); - if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature); + if (pointFeature) { + this.selectInteraction.getFeatures().push(pointFeature); + } }); this.modifyInteraction.setActive( @@ -1915,7 +2082,7 @@ class MapService { if (this.mode === "lasso") this.deactivateLasso(); else this.activateLasso(); } - public getMap(): Map | null { + public getMap(): OLMap | null { return this.map; } @@ -2263,11 +2430,26 @@ const MapSightbar: React.FC = observer( }, [allFeatures, searchQuery]); const handleFeatureClick = useCallback( - (id: string | number) => { + (id: string | number, event?: React.MouseEvent) => { if (!mapService) return; - mapService.selectFeature(id); + const ctrlKey = event?.ctrlKey || event?.metaKey; + + if (ctrlKey) { + // Множественный выбор: добавляем к существующему + const newSet = new Set(selectedIds); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + setSelectedIds(newSet); + mapService.setSelectedIds(newSet); + } else { + // Одиночный выбор: используем стандартный метод + mapService.selectFeature(id); + } }, - [mapService] + [mapService, selectedIds, setSelectedIds] ); const handleDeleteFeature = useCallback( @@ -2313,6 +2495,217 @@ const MapSightbar: React.FC = observer( [navigate] ); + const handleHideRoute = useCallback( + async (routeId: string | number) => { + if (!mapService) return; + + const numericRouteId = parseInt(String(routeId).split("-")[1], 10); + if (isNaN(numericRouteId)) return; + + const isHidden = mapStore.hiddenRoutes.has(numericRouteId); + console.log( + `[handleHideRoute] Route ${numericRouteId}, isHidden: ${isHidden}` + ); + + try { + if (isHidden) { + console.log(`[handleHideRoute] Showing route ${numericRouteId}`); + // Показываем маршрут обратно + const route = mapStore.routes.find((r) => r.id === numericRouteId); + if (!route) { + console.warn( + `[handleHideRoute] Route ${numericRouteId} not found in mapStore` + ); + return; + } + + const projection = mapService.getMap()?.getView().getProjection(); + if (!projection) { + console.error(`[handleHideRoute] Failed to get map projection`); + return; + } + console.log( + `[handleHideRoute] Route ${numericRouteId} (${route.route_number}) found, showing` + ); + + // Показываем сам маршрут + const coordinates = route.path + .filter((c) => c && c[0] != null && c[1] != null) + .map((c: [number, number]) => + transform([c[1], c[0]], "EPSG:4326", projection) + ); + + if (coordinates.length > 0) { + const line = new LineString(coordinates); + const lineFeature = new Feature({ + geometry: line, + name: route.route_number, + }); + lineFeature.setId(routeId); + lineFeature.set("featureType", "route"); + mapService.lineSource.addFeature(lineFeature); + console.log(`[handleHideRoute] Added route line to map`); + } else { + console.warn( + `[handleHideRoute] No valid coordinates for route ${numericRouteId}` + ); + } + + // Получаем станции текущего маршрута из кэша + const routeStationIds = + mapStore.routeStationsCache.get(numericRouteId) || []; + console.log( + `[handleHideRoute] Route ${numericRouteId} has ${routeStationIds.length} stations` + ); + + // Получаем все маршруты для проверки + const allRouteIds = mapStore.routes.map((r) => r.id); + + // Исключаем скрытые маршруты из проверки + const visibleRouteIds = allRouteIds.filter( + (id: number) => + id !== numericRouteId && !mapStore.hiddenRoutes.has(id) + ); + console.log( + `[handleHideRoute] Checking against ${visibleRouteIds.length} visible routes (excluding hidden)` + ); + + // Собираем все станции видимых маршрутов из кэша + const stationsInVisibleRoutes = new Set(); + visibleRouteIds.forEach((otherRouteId) => { + const stationIds = + mapStore.routeStationsCache.get(otherRouteId) || []; + stationIds.forEach((id: number) => + stationsInVisibleRoutes.add(id) + ); + }); + + console.log( + `[handleHideRoute] Found ${stationsInVisibleRoutes.size} unique stations in visible routes` + ); + + // Показываем станции, которые не используются в других ВИДИМЫХ маршрутах + const stationsToShow = routeStationIds.filter( + (id: number) => !stationsInVisibleRoutes.has(id) + ); + + // Показываем станции на карте + for (const stationId of stationsToShow) { + const station = mapStore.stations.find((s) => s.id === stationId); + if (!station) continue; + + const point = new Point( + transform( + [station.longitude, station.latitude], + "EPSG:4326", + projection + ) + ); + const feature = new Feature({ + geometry: point, + name: station.name, + }); + feature.setId(`station-${station.id}`); + feature.set("featureType", "station"); + + // Добавляем станцию только если её еще нет на карте + const existingFeature = mapService.pointSource.getFeatureById( + `station-${station.id}` + ); + if (!existingFeature) { + mapService.pointSource.addFeature(feature); + } + } + + // Удаляем из скрытых + mapStore.hiddenRoutes.delete(numericRouteId); + saveHiddenRoutes(mapStore.hiddenRoutes); // Сохраняем в localStorage + console.log( + `[handleHideRoute] Removed ${numericRouteId} from hiddenRoutes, stations to show: ${stationsToShow.length}` + ); + } else { + // Скрываем маршрут + console.log(`[handleHideRoute] Hiding route ${numericRouteId}`); + + // Получаем станции текущего маршрута из кэша + const routeStationIds = + mapStore.routeStationsCache.get(numericRouteId) || []; + console.log( + `[handleHideRoute] Route ${numericRouteId} has ${routeStationIds.length} stations` + ); + + // Получаем все маршруты для проверки + const allRouteIds = mapStore.routes.map((r) => r.id); + + // Исключаем скрытые маршруты из проверки + const visibleRouteIds = allRouteIds.filter( + (id: number) => + id !== numericRouteId && !mapStore.hiddenRoutes.has(id) + ); + console.log( + `[handleHideRoute] Checking against ${visibleRouteIds.length} visible routes (excluding hidden)` + ); + + // Собираем все станции видимых маршрутов из кэша + const stationsInVisibleRoutes = new Set(); + visibleRouteIds.forEach((otherRouteId) => { + const stationIds = + mapStore.routeStationsCache.get(otherRouteId) || []; + stationIds.forEach((id: number) => + stationsInVisibleRoutes.add(id) + ); + }); + + console.log( + `[handleHideRoute] Found ${stationsInVisibleRoutes.size} unique stations in visible routes` + ); + + // Скрываем станции, которые не используются в других ВИДИМЫХ маршрутах + const stationsToHide = routeStationIds.filter( + (id: number) => !stationsInVisibleRoutes.has(id) + ); + + // Скрываем станции с карты + stationsToHide.forEach((stationId: number) => { + const pointFeature = mapService.pointSource.getFeatureById( + `station-${stationId}` + ); + if (pointFeature) { + mapService.pointSource.removeFeature( + pointFeature as Feature + ); + } + }); + + // Скрываем сам маршрут с карты + const lineFeature = mapService.lineSource.getFeatureById(routeId); + if (lineFeature) { + mapService.lineSource.removeFeature( + lineFeature as Feature + ); + } + + // Добавляем в скрытые + mapStore.hiddenRoutes.add(numericRouteId); + saveHiddenRoutes(mapStore.hiddenRoutes); // Сохраняем в localStorage + console.log( + `[handleHideRoute] Added ${numericRouteId} to hiddenRoutes, stations to hide: ${stationsToHide.length}` + ); + } + + // Снимаем выделение + mapService.unselect(); + } catch (error) { + console.error( + "[handleHideRoute] Error toggling route visibility:", + error + ); + toast.error("Ошибка при изменении видимости маршрута"); + } + }, + [mapService] + ); + const sortFeaturesByType = >( features: T[], sortType: SortType @@ -2411,6 +2804,15 @@ const MapSightbar: React.FC = observer( const isSelected = selectedFeature?.getId() === fId; const isChecked = selectedIds.has(fId); + // Проверяем, скрыт ли маршрут + const numericRouteId = + featureType === "route" + ? parseInt(String(fId).split("-")[1], 10) + : null; + const isRouteHidden = + numericRouteId !== null && + mapStore.hiddenRoutes.has(numericRouteId); + return (
= observer(
handleFeatureClick(fId)} + onClick={(e) => handleFeatureClick(fId, e)} >
= observer( > + {featureType === "route" && ( + + )} + {featureType === "route" && ( + + )} + {showSightsOptions && ( +
+
+ Будут скрыты все достопримечательности, привязанные к + скрытым маршрутам. +
+
+ )} +
), content: renderFeatureList(sortedSights, "sight", Landmark), @@ -2854,6 +3338,27 @@ export const MapPage: React.FC = observer(() => { } }, [selectedCityId, mapServiceInstance, isDataLoading]); + // Перезагружаем данные при изменении настройки скрытия достопримечательностей + useEffect(() => { + if (mapServiceInstance && !isDataLoading) { + // Очищаем текущие объекты на карте + mapServiceInstance.pointSource.clear(); + mapServiceInstance.lineSource.clear(); + + // Загружаем новые данные с учетом фильтрации достопримечательностей + mapServiceInstance.loadFeaturesFromApi( + mapStore.stations, + mapStore.routes, + mapStore.sights + ); + } + }, [ + mapStore.hideSightsByHiddenRoutes, + mapStore.hiddenRoutes.size, + mapServiceInstance, + isDataLoading, + ]); + const showLoader = isMapLoading || isDataLoading; const showContent = mapServiceInstance && !showLoader && !error; const isAnythingSelected = diff --git a/src/pages/Route/LinekedStations.tsx b/src/pages/Route/LinekedStations.tsx index edb0926..40fa24b 100644 --- a/src/pages/Route/LinekedStations.tsx +++ b/src/pages/Route/LinekedStations.tsx @@ -562,7 +562,14 @@ const LinkedItemsContentsInner = < size="small" /> } - label={String(item.name)} + label={ +
+

{String(item.name)}

+

+ {String(item.description)} +

+
+ } sx={{ margin: 0, "& .MuiFormControlLabel-label": { diff --git a/src/pages/Route/RouteCreatePage/index.tsx b/src/pages/Route/RouteCreatePage/index.tsx index 4522405..1f21c9f 100644 --- a/src/pages/Route/RouteCreatePage/index.tsx +++ b/src/pages/Route/RouteCreatePage/index.tsx @@ -38,8 +38,8 @@ export const RouteCreatePage = observer(() => { const [govRouteNumber, setGovRouteNumber] = useState(""); const [governorAppeal, setGovernorAppeal] = useState(""); const [direction, setDirection] = useState("backward"); - const [scaleMin, setScaleMin] = useState(""); - const [scaleMax, setScaleMax] = useState(""); + const [scaleMin, setScaleMin] = useState("10"); + const [scaleMax, setScaleMax] = useState("100"); const [routeName, setRouteName] = useState(""); const [turn, setTurn] = useState(""); const [centerLat, setCenterLat] = useState(""); @@ -154,22 +154,75 @@ export const RouteCreatePage = observer(() => { const handleCreateRoute = async () => { try { setIsLoading(true); - // Преобразуем значения в нужные типы - const carrier_id = Number(carrier); - const governor_appeal = Number(governorAppeal); - const scale_min = scaleMin ? Number(scaleMin) : undefined; - const scale_max = scaleMax ? Number(scaleMax) : undefined; - const rotate = turn ? Number(turn) : undefined; - const center_latitude = centerLat ? Number(centerLat) : undefined; - const center_longitude = centerLng ? Number(centerLng) : undefined; - const route_direction = direction === "forward"; + + // Валидация обязательных полей + if (!routeName.trim()) { + toast.error("Заполните название маршрута"); + setIsLoading(false); + return; + } + if (!carrier) { + toast.error("Выберите перевозчика"); + setIsLoading(false); + return; + } + if (!routeNumber.trim()) { + toast.error("Заполните номер маршрута"); + setIsLoading(false); + return; + } + if (!govRouteNumber.trim()) { + toast.error("Заполните номер маршрута в Говорящем Городе"); + setIsLoading(false); + return; + } + if (!governorAppeal) { + toast.error("Выберите статью для обращения к пассажирам"); + setIsLoading(false); + return; + } const validationResult = validateCoordinates(routeCoords); if (validationResult !== true) { toast.error(validationResult); + setIsLoading(false); return; } + // Валидация масштабов + const scale_min = scaleMin ? Number(scaleMin) : null; + const scale_max = scaleMax ? Number(scaleMax) : null; + console.log(scale_min, scale_max); + if ( + scale_min === 0 || + scale_max === 0 || + scale_min === null || + scale_max === null + ) { + toast.error("Масштабы не могут быть равны 0"); + setIsLoading(false); + return; + } + + if ( + scale_min !== null && + scale_max !== null && + scale_max !== undefined && + scale_min > scale_max + ) { + toast.error("Максимальный масштаб не может быть меньше минимального"); + setIsLoading(false); + return; + } + + // Преобразуем значения в нужные типы + const carrier_id = Number(carrier); + const governor_appeal = Number(governorAppeal); + const rotate = turn ? Number(turn) : undefined; + const center_latitude = centerLat ? Number(centerLat) : undefined; + const center_longitude = centerLng ? Number(centerLng) : undefined; + const route_direction = direction === "forward"; + // Координаты маршрута как массив массивов чисел const path = routeCoords .trim() @@ -194,8 +247,8 @@ export const RouteCreatePage = observer(() => { governor_appeal, route_name: routeName, route_direction, - scale_min, - scale_max, + scale_min: scale_min !== null ? scale_min : 0, + scale_max: scale_max !== null ? scale_max : 0, rotate, center_latitude, center_longitude, @@ -371,14 +424,40 @@ export const RouteCreatePage = observer(() => { setScaleMin(e.target.value)} + onChange={(e) => { + const value = e.target.value; + setScaleMin(value); + // Если максимальный масштаб стал меньше минимального, обновляем его + if (value && scaleMax && Number(value) > Number(scaleMax)) { + setScaleMax(value); + } + }} + error={ + scaleMin !== "" && + scaleMax !== "" && + Number(scaleMin) > Number(scaleMax) + } + required + helperText={ + scaleMin !== "" && + scaleMax !== "" && + Number(scaleMin) > Number(scaleMax) + ? "Минимальный масштаб не может быть больше максимального" + : "" + } /> setScaleMax(e.target.value)} + required + onChange={(e) => { + const value = e.target.value; + setScaleMax(value); + }} /> { }, [editRouteData.path]); const handleSave = async () => { + // Валидация обязательных полей + if (!editRouteData.route_name?.trim()) { + toast.error("Заполните название маршрута"); + return; + } + if (!editRouteData.carrier_id) { + toast.error("Выберите перевозчика"); + return; + } + if (!editRouteData.route_number?.trim()) { + toast.error("Заполните номер маршрута"); + return; + } + if (!editRouteData.route_sys_number?.trim()) { + toast.error("Заполните номер маршрута в Говорящем Городе"); + return; + } + if (!editRouteData.governor_appeal) { + toast.error("Выберите статью для обращения к пассажирам"); + return; + } + + const validationResult = validateCoordinates(coordinates); + if (validationResult !== true) { + toast.error(validationResult); + return; + } + + // Валидация масштабов + if ( + editRouteData.scale_min !== null && + editRouteData.scale_min !== undefined && + editRouteData.scale_max !== null && + editRouteData.scale_max !== undefined && + editRouteData.scale_min > editRouteData.scale_max + ) { + toast.error("Максимальный масштаб не может быть меньше минимального"); + return; + } + + if ( + editRouteData.scale_min === 0 || + editRouteData.scale_max === 0 || + editRouteData.scale_min === null || + editRouteData.scale_max === null + ) { + toast.error("Масштабы не могут быть равны 0"); + setIsLoading(false); + return; + } + setIsLoading(true); - await routeStore.editRoute(Number(id)); - toast.success("Маршрут успешно сохранен"); - setIsLoading(false); + try { + await routeStore.editRoute(Number(id)); + toast.success("Маршрут успешно сохранен"); + } catch (error) { + console.error(error); + toast.error("Произошла ошибка при сохранении маршрута"); + } finally { + setIsLoading(false); + } }; const validateCoordinates = (value: string) => { @@ -333,17 +390,33 @@ export const RouteEditPage = observer(() => { + onChange={(e) => { + const value = + e.target.value === "" ? null : parseFloat(e.target.value); routeStore.setEditRouteData({ - scale_min: - e.target.value === "" ? null : parseFloat(e.target.value), - }) - } + scale_min: value, + }); + // Если максимальный масштаб стал меньше минимального, обновляем его + if ( + value !== null && + editRouteData.scale_max !== null && + editRouteData.scale_max !== undefined && + value > editRouteData.scale_max + ) { + routeStore.setEditRouteData({ + scale_max: value, + }); + } + }} + required /> routeStore.setEditRouteData({ @@ -351,6 +424,22 @@ export const RouteEditPage = observer(() => { e.target.value === "" ? null : parseFloat(e.target.value), }) } + error={ + editRouteData.scale_min !== null && + editRouteData.scale_min !== undefined && + editRouteData.scale_max !== null && + editRouteData.scale_max !== undefined && + editRouteData.scale_max < editRouteData.scale_min + } + helperText={ + editRouteData.scale_min !== null && + editRouteData.scale_min !== undefined && + editRouteData.scale_max !== null && + editRouteData.scale_max !== undefined && + editRouteData.scale_max < editRouteData.scale_min + ? "Максимальный масштаб не может быть меньше минимального" + : "" + } /> - Детали о достопримечательностях + Настройка маршрута diff --git a/src/pages/Route/route-preview/web-gl/web-gl-version.tsx b/src/pages/Route/route-preview/web-gl/web-gl-version.tsx new file mode 100644 index 0000000..2597ced --- /dev/null +++ b/src/pages/Route/route-preview/web-gl/web-gl-version.tsx @@ -0,0 +1,1792 @@ +function initWebGLContext( + canvas: HTMLCanvasElement +): WebGLRenderingContext | null { + const gl = + (canvas.getContext("webgl") as WebGLRenderingContext | null) || + (canvas.getContext("experimental-webgl") as WebGLRenderingContext | null); + return gl; +} + +function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean { + const dpr = Math.max(1, window.devicePixelRatio || 1); + const displayWidth = Math.floor(canvas.clientWidth * dpr); + const displayHeight = Math.floor(canvas.clientHeight * dpr); + if (canvas.width !== displayWidth || canvas.height !== displayHeight) { + canvas.width = displayWidth; + canvas.height = displayHeight; + return true; + } + return false; +} + +export const WebGLMap = observer(() => { + const canvasRef = useRef(null); + const glRef = useRef(null); + const programRef = useRef(null); + const bufferRef = useRef(null); + const pointProgramRef = useRef(null); + const pointBufferRef = useRef(null); + const screenLineProgramRef = useRef(null); + const screenLineBufferRef = useRef(null); + const attribsRef = useRef<{ a_pos: number } | null>(null); + const uniformsRef = useRef<{ + u_cameraPos: WebGLUniformLocation | null; + u_scale: WebGLUniformLocation | null; + u_resolution: WebGLUniformLocation | null; + u_color: WebGLUniformLocation | null; + } | null>(null); + + const { routeData, stationData, stationDataEn, stationDataZh, sightData } = + useMapData() as any; + const { + position, + scale, + setPosition, + setScale, + isAutoMode, + setIsAutoMode, + screenCenter, + setScreenCenter, + userActivityTimestamp, + updateUserActivity, + } = useTransform(); + + const cameraAnimationStore = useCameraAnimationStore(); + + // Ref для хранения ограничений масштаба + const scaleLimitsRef = useRef({ + min: null as number | null, + max: null as number | null, + }); + + // Обновляем ограничения масштаба при изменении routeData + useEffect(() => { + if ( + routeData?.scale_min !== undefined && + routeData?.scale_max !== undefined + ) { + scaleLimitsRef.current = { + min: routeData.scale_min / 10, + max: routeData.scale_max / 10, + }; + } + }, [routeData?.scale_min, routeData?.scale_max]); + + // Функция для ограничения масштаба значениями с бекенда + const clampScale = useCallback((value: number) => { + const { min, max } = scaleLimitsRef.current; + + if (min === null || max === null) { + return value; + } + + const clampedValue = Math.max(min, Math.min(max, value)); + + return clampedValue; + }, []); + const { selectedLanguage } = useGeolocationStore(); + const positionRef = useRef(position); + const scaleRef = useRef(scale); + const setPositionRef = useRef(setPosition); + const setScaleRef = useRef(setScale); + + // Обновляем refs при изменении функций + useEffect(() => { + setPositionRef.current = setPosition; + }, [setPosition]); + + useEffect(() => { + setScaleRef.current = setScale; + }, [setScale]); + + // Логирование данных маршрута для отладки + useEffect(() => { + if (routeData) { + } + }, [routeData]); + + useEffect(() => { + positionRef.current = position; + }, [position]); + + useEffect(() => { + scaleRef.current = scale; + }, [scale]); + + const rotationAngle = useMemo(() => { + const deg = (routeData as any)?.rotate ?? 0; + return (deg * Math.PI) / 180; + }, [routeData]); + + const { + position: animatedYellowDotPosition, + animateTo: animateYellowDotTo, + setPositionImmediate: setYellowDotPositionImmediate, + } = useAnimatedPolarPosition(0, 0, 800); + + // Build transformed route path (map coords) + const routePath = useMemo(() => { + if (!routeData?.path || routeData?.path.length === 0) + return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + + const verts: number[] = []; + for (const [lat, lon] of routeData.path) { + const local = coordinatesToLocal(lat - centerLat, lon - centerLon); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + routeData?.path, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + const transformedTramCoords = useMemo(() => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat === undefined || centerLon === undefined) return null; + + const coords: any = apiStore?.context?.currentCoordinates; + if (!coords) return null; + + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + return { x: rx, y: ry }; + }, [ + routeData?.center_latitude, + routeData?.center_longitude, + apiStore?.context?.currentCoordinates, + rotationAngle, + ]); + + // Настройка CameraAnimationStore callback - только один раз при монтировании + useEffect(() => { + const callback = (newPos: { x: number; y: number }, newZoom: number) => { + setPosition(newPos); + setScale(newZoom); + }; + + cameraAnimationStore.setUpdateCallback(callback); + + // Синхронизируем начальное состояние только один раз + cameraAnimationStore.syncState(position, scale); + + return () => { + cameraAnimationStore.setUpdateCallback(null); + }; + }, []); // Пустой массив - выполняется только при монтировании + + // Установка границ зума + useEffect(() => { + if ( + routeData?.scale_min !== undefined && + routeData?.scale_max !== undefined + ) { + cameraAnimationStore.setMaxZoom(routeData.scale_max / SCALE_FACTOR); + cameraAnimationStore.setMinZoom(routeData.scale_min / SCALE_FACTOR); + } + }, [routeData?.scale_min, routeData?.scale_max, cameraAnimationStore]); + + // Автоматический режим - таймер для включения через 5 секунд бездействия + useEffect(() => { + const interval = setInterval(() => { + const timeSinceActivity = Date.now() - userActivityTimestamp; + if (timeSinceActivity >= 5000 && !isAutoMode) { + // 5 секунд бездействия - включаем авто режим + setIsAutoMode(true); + } + }, 1000); // Проверяем каждую секунду + + return () => clearInterval(interval); + }, [userActivityTimestamp, isAutoMode, setIsAutoMode]); + + // Следование за желтой точкой с зумом при включенном авто режиме + useEffect(() => { + // Пропускаем обновление если анимация уже идет + if (cameraAnimationStore.isActivelyAnimating) { + return; + } + + if (isAutoMode && transformedTramCoords && screenCenter) { + // Преобразуем станции в формат для CameraAnimationStore + const transformedStations = stationData + ? stationData + .map((station: any) => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return null; + + const local = coordinatesToLocal( + station.latitude - centerLat, + station.longitude - centerLon + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + return { + longitude: rx, + latitude: ry, + id: station.id, + }; + }) + .filter(Boolean) + : []; + + if ( + transformedTramCoords && + screenCenter && + transformedStations && + scaleLimitsRef.current !== null && + scaleLimitsRef.current.max !== null && + scaleLimitsRef.current.min && + scaleLimitsRef.current.min !== null + ) { + cameraAnimationStore.setMaxZoom(scaleLimitsRef.current!.max); + cameraAnimationStore.setMinZoom(scaleLimitsRef.current!.min); + + // Синхронизируем текущее состояние камеры перед запуском анимации + cameraAnimationStore.syncState(positionRef.current, scaleRef.current); + + // Запускаем анимацию к желтой точке + cameraAnimationStore.followTram( + transformedTramCoords, + screenCenter, + transformedStations + ); + } + } else if (!isAutoMode) { + cameraAnimationStore.stopAnimation(); + } + }, [ + isAutoMode, + transformedTramCoords, + screenCenter, + cameraAnimationStore, + stationData, + routeData, + rotationAngle, + ]); + + // Station label overlay positions (DOM overlay) + const stationLabels = useMemo(() => { + if (!stationData || !routeData) + return [] as Array<{ x: number; y: number; name: string; sub?: string }>; + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) return []; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const result: Array<{ x: number; y: number; name: string; sub?: string }> = + []; + for (let i = 0; i < stationData.length; i++) { + const st = stationData[i]; + const local = coordinatesToLocal( + st.latitude - centerLat, + st.longitude - centerLon + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + const DEFAULT_LABEL_OFFSET_X = 25; + const DEFAULT_LABEL_OFFSET_Y = 0; + const labelOffsetX = + st.offset_x === 0 && st.offset_y === 0 + ? DEFAULT_LABEL_OFFSET_X + : st.offset_x; + const labelOffsetY = + st.offset_x === 0 && st.offset_y === 0 + ? DEFAULT_LABEL_OFFSET_Y + : st.offset_y; + const textBlockPositionX = rx + labelOffsetX; + const textBlockPositionY = ry + labelOffsetY; + const dpr = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1 + ); + const sx = (textBlockPositionX * scale + position.x) / dpr; + const sy = (textBlockPositionY * scale + position.y) / dpr; + let sub: string | undefined; + if ((selectedLanguage as any) === "zh") + sub = (stationDataZh as any)?.[i]?.name; + else if ( + (selectedLanguage as any) === "en" || + (selectedLanguage as any) === "ru" + ) + sub = (stationDataEn as any)?.[i]?.name; + result.push({ x: sx, y: sy, name: st.name, sub }); + } + return result; + }, [ + stationData, + stationDataEn as any, + stationDataZh as any, + position.x, + position.y, + scale, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + selectedLanguage as any, + ]); + + // Build transformed stations (map coords) + const stationPoints = useMemo(() => { + if (!stationData || !routeData) return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const verts: number[] = []; + for (const s of stationData) { + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + stationData, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + // Build transformed sights (map coords) + const sightPoints = useMemo(() => { + if (!sightData || !routeData) return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const verts: number[] = []; + for (const s of sightData) { + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + sightData, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const gl = initWebGLContext(canvas); + glRef.current = gl; + if (!gl) return; + + const vertSrc = ` + attribute vec2 a_pos; + uniform vec2 u_cameraPos; + uniform float u_scale; + uniform vec2 u_resolution; + void main() { + vec2 screen = a_pos * u_scale + u_cameraPos; + vec2 zeroToOne = screen / u_resolution; + vec2 zeroToTwo = zeroToOne * 2.0; + vec2 clip = zeroToTwo - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + } + `; + const fragSrc = ` + precision mediump float; + uniform vec4 u_color; + void main() { + gl_FragColor = u_color; + } + `; + + const compile = (type: number, src: string) => { + const s = gl.createShader(type)!; + gl.shaderSource(s, src); + gl.compileShader(s); + return s; + }; + const vs = compile(gl.VERTEX_SHADER, vertSrc); + const fs = compile(gl.FRAGMENT_SHADER, fragSrc); + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + programRef.current = prog; + gl.useProgram(prog); + + const a_pos = gl.getAttribLocation(prog, "a_pos"); + const u_cameraPos = gl.getUniformLocation(prog, "u_cameraPos"); + const u_scale = gl.getUniformLocation(prog, "u_scale"); + const u_resolution = gl.getUniformLocation(prog, "u_resolution"); + const u_color = gl.getUniformLocation(prog, "u_color"); + attribsRef.current = { a_pos }; + uniformsRef.current = { u_cameraPos, u_scale, u_resolution, u_color }; + + const buffer = gl.createBuffer(); + bufferRef.current = buffer; + + const pointVert = ` + attribute vec2 a_pos; + uniform vec2 u_cameraPos; + uniform float u_scale; + uniform vec2 u_resolution; + uniform float u_pointSize; + void main() { + vec2 screen = a_pos * u_scale + u_cameraPos; + vec2 zeroToOne = screen / u_resolution; + vec2 zeroToTwo = zeroToOne * 2.0; + vec2 clip = zeroToTwo - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + gl_PointSize = u_pointSize; + } + `; + const pointFrag = ` + precision mediump float; + uniform vec4 u_color; + void main() { + vec2 c = gl_PointCoord * 2.0 - 1.0; + float d = dot(c, c); + if (d > 1.0) discard; + gl_FragColor = u_color; + } + `; + const vs2 = compile(gl.VERTEX_SHADER, pointVert); + const fs2 = compile(gl.FRAGMENT_SHADER, pointFrag); + const pprog = gl.createProgram()!; + gl.attachShader(pprog, vs2); + gl.attachShader(pprog, fs2); + gl.linkProgram(pprog); + pointProgramRef.current = pprog; + pointBufferRef.current = gl.createBuffer(); + + const lineVert = ` + attribute vec2 a_screen; + uniform vec2 u_resolution; + void main(){ + vec2 zeroToOne = a_screen / u_resolution; + vec2 clip = zeroToOne * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + } + `; + const lineFrag = ` + precision mediump float; + uniform vec4 u_color; + void main(){ gl_FragColor = u_color; } + `; + const lv = compile(gl.VERTEX_SHADER, lineVert); + const lf = compile(gl.FRAGMENT_SHADER, lineFrag); + const lprog = gl.createProgram()!; + gl.attachShader(lprog, lv); + gl.attachShader(lprog, lf); + gl.linkProgram(lprog); + screenLineProgramRef.current = lprog; + screenLineBufferRef.current = gl.createBuffer(); + + const handleResize = () => { + const changed = resizeCanvasToDisplaySize(canvas); + if (!gl) return; + // Update screen center when canvas size changes + // Use physical pixels (canvas.width) instead of CSS pixels + setScreenCenter({ + x: canvas.width / 2, + y: canvas.height / 2, + }); + if (changed) { + gl.viewport(0, 0, canvas.width, canvas.height); + } + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + }; + + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + useEffect(() => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat !== undefined && centerLon !== undefined) { + const coords: any = apiStore?.context?.currentCoordinates; + if (coords) { + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle), + sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + // В авторежиме используем анимацию, иначе мгновенное обновление + if (isAutoMode) { + animateYellowDotTo(rx, ry); + } else { + setYellowDotPositionImmediate(rx, ry); + } + } + } + }, [ + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + apiStore?.context?.currentCoordinates?.latitude, + apiStore?.context?.currentCoordinates?.longitude, + isAutoMode, + animateYellowDotTo, + setYellowDotPositionImmediate, + ]); + + useEffect(() => { + const gl = glRef.current; + const canvas = canvasRef.current; + const prog = programRef.current; + const buffer = bufferRef.current; + const attribs = attribsRef.current; + const uniforms = uniformsRef.current; + const pprog = pointProgramRef.current; + const pbuffer = pointBufferRef.current; + if ( + !gl || + !canvas || + !prog || + !buffer || + !attribs || + !uniforms || + !pprog || + !pbuffer + ) + return; + + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.useProgram(prog); + gl.uniform2f(uniforms.u_cameraPos, position.x, position.y); + gl.uniform1f(uniforms.u_scale, scale); + gl.uniform2f(uniforms.u_resolution, canvas.width, canvas.height); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, routePath, gl.STATIC_DRAW); + gl.enableVertexAttribArray(attribs.a_pos); + gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0); + + const vcount = routePath.length / 2; + let tramSegIndex = -1; + { + const centerLatTmp = routeData?.center_latitude; + const centerLonTmp = routeData?.center_longitude; + if (centerLatTmp !== undefined && centerLonTmp !== undefined) { + const coordsAny: any = apiStore?.context?.currentCoordinates; + if (coordsAny) { + const loc = coordinatesToLocal( + coordsAny.latitude - centerLatTmp, + coordsAny.longitude - centerLonTmp + ); + const wx = loc.x * UP_SCALE; + const wy = loc.y * UP_SCALE; + const cosR = Math.cos(rotationAngle), + sinR = Math.sin(rotationAngle); + const tX = wx * cosR - wy * sinR; + const tY = wx * sinR + wy * cosR; + let best = -1, + bestD = Infinity; + for (let i = 0; i < vcount - 1; i++) { + const p1x = routePath[i * 2], + p1y = routePath[i * 2 + 1]; + const p2x = routePath[(i + 1) * 2], + p2y = routePath[(i + 1) * 2 + 1]; + const dx = p2x - p1x, + dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((tX - p1x) * dx + (tY - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx, + py = p1y + cl * dy; + const d = Math.hypot(tX - px, tY - py); + if (d < bestD) { + bestD = d; + best = i; + } + } + tramSegIndex = best; + } + } + } + + const vertexCount = routePath.length / 2; + if (vertexCount > 1) { + // Generate thick line geometry using triangles with proper joins + const generateThickLine = (points: Float32Array, width: number) => { + const vertices: number[] = []; + const halfWidth = width / 2; + + if (points.length < 4) return new Float32Array(); + + // Process each segment + for (let i = 0; i < points.length - 2; i += 2) { + const x1 = points[i]; + const y1 = points[i + 1]; + const x2 = points[i + 2]; + const y2 = points[i + 3]; + + // Calculate perpendicular vector + const dx = x2 - x1; + const dy = y2 - y1; + const length = Math.sqrt(dx * dx + dy * dy); + if (length === 0) continue; + + const perpX = (-dy / length) * halfWidth; + const perpY = (dx / length) * halfWidth; + + // Create quad (two triangles) for this line segment + // Triangle 1 + vertices.push(x1 + perpX, y1 + perpY); + vertices.push(x1 - perpX, y1 - perpY); + vertices.push(x2 + perpX, y2 + perpY); + + // Triangle 2 + vertices.push(x1 - perpX, y1 - perpY); + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 + perpX, y2 + perpY); + + // Add simple join triangles to fill gaps + if (i < points.length - 4) { + const x3 = points[i + 4]; + const y3 = points[i + 5]; + + const dx2 = x3 - x2; + const dy2 = y3 - y2; + const length2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + if (length2 > 0) { + const perpX2 = (-dy2 / length2) * halfWidth; + const perpY2 = (dx2 / length2) * halfWidth; + + // Simple join - just connect the endpoints + vertices.push(x2 + perpX, y2 + perpY); + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 + perpX2, y2 + perpY2); + + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 - perpX2, y2 - perpY2); + vertices.push(x2 + perpX2, y2 + perpY2); + } + } + } + + return new Float32Array(vertices); + }; + + const lineWidth = Math.min(6); + const r1 = ((PATH_COLOR >> 16) & 0xff) / 255; + const g1 = ((PATH_COLOR >> 8) & 0xff) / 255; + const b1 = (PATH_COLOR & 0xff) / 255; + gl.uniform4f(uniforms.u_color, r1, g1, b1, 1); + + if (tramSegIndex >= 0) { + // Используем точную позицию желтой точки для определения конца красной линии + const animatedPos = animatedYellowDotPosition; + if ( + animatedPos && + animatedPos.x !== undefined && + animatedPos.y !== undefined + ) { + // Создаем массив точек от начала маршрута до позиции желтой точки + const passedPoints: number[] = []; + + // Добавляем все точки до текущего сегмента + for (let i = 0; i <= tramSegIndex; i++) { + passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + + // Добавляем точную позицию желтой точки как конечную точку + passedPoints.push(animatedPos.x, animatedPos.y); + + if (passedPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(passedPoints), + lineWidth + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + } + } + + const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255; + gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); + + // Серая линия начинается точно от позиции желтой точки + const animatedPos = animatedYellowDotPosition; + if ( + animatedPos && + animatedPos.x !== undefined && + animatedPos.y !== undefined + ) { + const unpassedPoints: number[] = []; + + // Добавляем позицию желтой точки как начальную точку серой линии + unpassedPoints.push(animatedPos.x, animatedPos.y); + + // Добавляем все точки после текущего сегмента + for (let i = tramSegIndex + 1; i < vertexCount; i++) { + unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + + if (unpassedPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(unpassedPoints), + lineWidth + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + } + } + + // Draw stations + if (stationPoints.length > 0) { + gl.useProgram(pprog); + const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); + const u_cameraPos_pts = gl.getUniformLocation(pprog, "u_cameraPos"); + const u_scale_pts = gl.getUniformLocation(pprog, "u_scale"); + const u_resolution_pts = gl.getUniformLocation(pprog, "u_resolution"); + const u_pointSize = gl.getUniformLocation(pprog, "u_pointSize"); + const u_color_pts = gl.getUniformLocation(pprog, "u_color"); + + gl.uniform2f(u_cameraPos_pts, position.x, position.y); + gl.uniform1f(u_scale_pts, scale); + gl.uniform2f(u_resolution_pts, canvas.width, canvas.height); + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + + // Draw station outlines (black background) + gl.uniform1f(u_pointSize, 10 * scale * 1.5); + const r_outline = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; + const g_outline = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; + const b_outline = (BACKGROUND_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_outline, g_outline, b_outline, 1); + gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); + + // Draw station cores (colored based on passed/unpassed) + gl.uniform1f(u_pointSize, 8.0 * scale * 1.5); + + // Draw passed stations (red) + if (tramSegIndex >= 0) { + const passedStations = []; + for (let i = 0; i < stationData.length; i++) { + if (i <= tramSegIndex) { + // @ts-ignore + passedStations.push(stationPoints[i * 2], stationPoints[i * 2 + 1]); + } + } + if (passedStations.length > 0) { + const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255; + const g_passed = ((PATH_COLOR >> 8) & 0xff) / 255; + const b_passed = (PATH_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(passedStations), + gl.STATIC_DRAW + ); + gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); + } + } + + // Draw unpassed stations (gray) + if (tramSegIndex >= 0) { + const unpassedStations = []; + for (let i = 0; i < stationData.length; i++) { + if (i > tramSegIndex) { + unpassedStations.push( + // @ts-ignore + stationPoints[i * 2], + stationPoints[i * 2 + 1] + ); + } + } + if (unpassedStations.length > 0) { + const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(unpassedStations), + gl.STATIC_DRAW + ); + gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); + } + } else { + // If no tram position, draw all stations as unpassed + const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1); + gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); + gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); + } + } + + const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); + const u_cameraPos_pts = gl.getUniformLocation(pprog, "u_cameraPos"); + const u_scale_pts = gl.getUniformLocation(pprog, "u_scale"); + const u_resolution_pts = gl.getUniformLocation(pprog, "u_resolution"); + const u_pointSize = gl.getUniformLocation(pprog, "u_pointSize"); + const u_color_pts = gl.getUniformLocation(pprog, "u_color"); + + gl.uniform2f(u_cameraPos_pts, position.x, position.y); + gl.uniform1f(u_scale_pts, scale); + gl.uniform2f(u_resolution_pts, canvas.width, canvas.height); + + const toPointsArray = (arr: number[]) => new Float32Array(arr); + + const pathPts: { x: number; y: number }[] = []; + for (let i = 0; i < routePath.length; i += 2) + pathPts.push({ x: routePath[i], y: routePath[i + 1] }); + const getSeg = (px: number, py: number) => { + if (pathPts.length < 2) return -1; + let best = -1, + bestD = Infinity; + for (let i = 0; i < pathPts.length - 1; i++) { + const p1 = pathPts[i], + p2 = pathPts[i + 1]; + const dx = p2.x - p1.x, + dy = p2.y - p1.y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((px - p1.x) * dx + (py - p1.y) * dy) / len2; + const tt = Math.max(0, Math.min(1, t)); + const cx = p1.x + tt * dx, + cy = p1.y + tt * dy; + const d = Math.hypot(px - cx, py - cy); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; + }; + + let tramSegForStations = -1; + { + const cLat = routeData?.center_latitude, + cLon = routeData?.center_longitude; + const tram = apiStore?.context?.currentCoordinates as any; + if (tram && cLat !== undefined && cLon !== undefined) { + const loc = coordinatesToLocal( + tram.latitude - cLat, + tram.longitude - cLon + ); + const wx = loc.x * UP_SCALE, + wy = loc.y * UP_SCALE; + const cosR = Math.cos(rotationAngle), + sinR = Math.sin(rotationAngle); + const tx = wx * cosR - wy * sinR, + ty = wx * sinR + wy * cosR; + tramSegForStations = getSeg(tx, ty); + } + } + + const passedStations: number[] = []; + const unpassedStations: number[] = []; + for (let i = 0; i < stationPoints.length; i += 2) { + const sx = stationPoints[i], + sy = stationPoints[i + 1]; + const seg = getSeg(sx, sy); + if (tramSegForStations !== -1 && seg !== -1 && seg < tramSegForStations) + passedStations.push(sx, sy); + else unpassedStations.push(sx, sy); + } + + const outlineSize = 10.0 * scale * 2, + coreSize = 8.0 * scale * 2; + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + toPointsArray(unpassedStations), + gl.STREAM_DRAW + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + gl.uniform1f(u_pointSize, outlineSize); + gl.uniform4f( + u_color_pts, + ((BACKGROUND_COLOR >> 16) & 255) / 255, + ((BACKGROUND_COLOR >> 8) & 255) / 255, + (BACKGROUND_COLOR & 255) / 255, + 1 + ); + if (unpassedStations.length) + gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); + gl.uniform1f(u_pointSize, coreSize); + gl.uniform4f( + u_color_pts, + ((UNPASSED_STATION_COLOR >> 16) & 255) / 255, + ((UNPASSED_STATION_COLOR >> 8) & 255) / 255, + (UNPASSED_STATION_COLOR & 255) / 255, + 1 + ); + if (unpassedStations.length) + gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + toPointsArray(passedStations), + gl.STREAM_DRAW + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + gl.uniform1f(u_pointSize, outlineSize); + gl.uniform4f( + u_color_pts, + ((BACKGROUND_COLOR >> 16) & 255) / 255, + ((BACKGROUND_COLOR >> 8) & 255) / 255, + (BACKGROUND_COLOR & 255) / 255, + 1 + ); + if (passedStations.length) + gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); + gl.uniform1f(u_pointSize, coreSize); + gl.uniform4f( + u_color_pts, + ((PATH_COLOR >> 16) & 255) / 255, + ((PATH_COLOR >> 8) & 255) / 255, + (PATH_COLOR & 255) / 255, + 1 + ); + if (passedStations.length) + gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); + + // Draw black dots with white outline for terminal stations (startStopId and endStopId) - 5x larger + if ( + stationData && + stationData.length > 0 && + routeData && + apiStore?.context + ) { + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat !== undefined && centerLon !== undefined) { + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + + // Find terminal stations using startStopId and endStopId from context + const startStationData = stationData.find( + (station) => station.id.toString() === apiStore.context?.startStopId + ); + const endStationData = stationData.find( + (station) => station.id.toString() === apiStore.context?.endStopId + ); + + const terminalStations: number[] = []; + + // Transform start station coordinates if found + if (startStationData) { + const startLocal = coordinatesToLocal( + startStationData.latitude - centerLat, + startStationData.longitude - centerLon + ); + const startX = startLocal.x * UP_SCALE; + const startY = startLocal.y * UP_SCALE; + const startRx = startX * cos - startY * sin; + const startRy = startX * sin + startY * cos; + terminalStations.push(startRx, startRy); + } + + // Transform end station coordinates if found + if (endStationData) { + const endLocal = coordinatesToLocal( + endStationData.latitude - centerLat, + endStationData.longitude - centerLon + ); + const endX = endLocal.x * UP_SCALE; + const endY = endLocal.y * UP_SCALE; + const endRx = endX * cos - endY * sin; + const endRy = endX * sin + endY * cos; + terminalStations.push(endRx, endRy); + } + + if (terminalStations.length > 0) { + // Determine if each terminal station is passed + const terminalStationData: any[] = []; + if (startStationData) terminalStationData.push(startStationData); + if (endStationData) terminalStationData.push(endStationData); + + // Get tram segment index for comparison + let tramSegIndex = -1; + const coords: any = apiStore?.context?.currentCoordinates; + if (coords && centerLat !== undefined && centerLon !== undefined) { + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon + ); + const wx = local.x * UP_SCALE; + const wy = local.y * UP_SCALE; + const cosR = Math.cos(rotationAngle); + const sinR = Math.sin(rotationAngle); + const tx = wx * cosR - wy * sinR; + const ty = wx * sinR + wy * cosR; + + // Find closest segment to tram position + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((tx - p1x) * dx + (ty - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(tx - px, ty - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + tramSegIndex = best; + } + + // Check if each terminal station is passed + const isStartPassed = startStationData + ? (() => { + const sx = terminalStations[0]; + const sy = terminalStations[1]; + const seg = (() => { + if (routePath.length < 4) return -1; + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(sx - px, sy - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + return best; + })(); + return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; + })() + : false; + + const isEndPassed = endStationData + ? (() => { + const ex = terminalStations[terminalStations.length - 2]; + const ey = terminalStations[terminalStations.length - 1]; + const seg = (() => { + if (routePath.length < 4) return -1; + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((ex - p1x) * dx + (ey - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(ex - px, ey - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + return best; + })(); + return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; + })() + : false; + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(terminalStations), + gl.STREAM_DRAW + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + + // Draw colored outline based on passed status - 24 pixels (x2) + gl.uniform1f(u_pointSize, 18.0 * scale); + if (startStationData && endStationData) { + // Both stations - draw each with its own color + if (isStartPassed) { + gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных + } else { + gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных + } + gl.drawArrays(gl.POINTS, 0, 1); // Draw start station + + if (isEndPassed) { + gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных + } else { + gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных + } + gl.drawArrays(gl.POINTS, 1, 1); // Draw end station + } else { + // Single station - use appropriate color + const isPassed = startStationData ? isStartPassed : isEndPassed; + if (isPassed) { + gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных + } else { + gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных + } + gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); + } + + // Draw dark center - 12 pixels (x2) + gl.uniform1f(u_pointSize, 11.0 * scale); + const r_center = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; + const g_center = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; + const b_center = (BACKGROUND_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_center, g_center, b_center, 1.0); // Dark color + gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); + } + } + } + + // Draw yellow dot for tram position + if (animatedYellowDotPosition) { + const rx = animatedYellowDotPosition.x; + const ry = animatedYellowDotPosition.y; + + gl.uniform1f(u_pointSize, 13.3333 * scale); + gl.uniform4f(u_color_pts, 1.0, 1.0, 0.0, 1.0); + const tmp = new Float32Array([rx, ry]); + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData(gl.ARRAY_BUFFER, tmp, gl.STREAM_DRAW); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + gl.drawArrays(gl.POINTS, 0, 1); + } + }, [ + routePath, + stationPoints, + sightPoints, + position.x, + position.y, + scale, + animatedYellowDotPosition?.x, + animatedYellowDotPosition?.y, + ]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + if (!routePath || routePath.length < 4) return; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (let i = 0; i < routePath.length; i += 2) { + const x = routePath[i]; + const y = routePath[i + 1]; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + if ( + !isFinite(minX) || + !isFinite(minY) || + !isFinite(maxX) || + !isFinite(maxY) + ) + return; + + const worldWidth = Math.max(1, maxX - minX); + const worldHeight = Math.max(1, maxY - minY); + + const margin = 0.1; + const targetScale = Math.min( + (canvas.width * (1 - margin)) / worldWidth, + (canvas.height * (1 - margin)) / worldHeight + ); + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const clampedScale = clampScale(targetScale); + + setScaleRef.current(clampedScale); + setPositionRef.current({ + x: canvas.width / 2 - centerX * clampedScale, + y: canvas.height / 2 - centerY * clampedScale, + }); + }, [routePath]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + let isDragging = false; + let startMouse = { x: 0, y: 0 }; + let startPos = { x: 0, y: 0 }; + + const activePointers = new Map(); + let isPinching = false; + let pinchStart: { + distance: number; + midpoint: { x: number; y: number }; + scale: number; + position: { x: number; y: number }; + } | null = null; + + const getDistance = ( + p1: { x: number; y: number }, + p2: { x: number; y: number } + ) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + + const getMidpoint = ( + p1: { x: number; y: number }, + p2: { x: number; y: number } + ) => ({ + x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2, + }); + + const onPointerDown = (e: PointerEvent) => { + // Отслеживаем активность пользователя + updateUserActivity(); + if (isAutoMode) { + setIsAutoMode(false); + } + cameraAnimationStore.stopAnimation(); + + canvas.setPointerCapture(e.pointerId); + const rect = canvas.getBoundingClientRect(); + activePointers.set(e.pointerId, { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + if (activePointers.size === 1) { + isDragging = true; + startMouse = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + startPos = { x: positionRef.current.x, y: positionRef.current.y }; + } else if (activePointers.size === 2) { + isDragging = false; + const [p1, p2] = Array.from(activePointers.values()); + pinchStart = { + distance: getDistance(p1, p2), + midpoint: getMidpoint(p1, p2), + scale: scaleRef.current, + position: { x: positionRef.current.x, y: positionRef.current.y }, + }; + isPinching = true; + } + }; + + const onPointerMove = (e: PointerEvent) => { + if (!activePointers.has(e.pointerId)) return; + + // Отслеживаем активность пользователя + updateUserActivity(); + + const rect = canvas.getBoundingClientRect(); + activePointers.set(e.pointerId, { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + + if (activePointers.size === 2) { + isDragging = false; + + const pointersArray = Array.from(activePointers.values()); + if (pointersArray.length === 2) { + const [p1, p2] = pointersArray; + + if (!isPinching || pinchStart === null) { + isPinching = true; + pinchStart = { + distance: getDistance(p1, p2), + midpoint: getMidpoint(p1, p2), + scale: scaleRef.current, + position: { x: positionRef.current.x, y: positionRef.current.y }, + }; + } + + // Process the pinch gesture + if (pinchStart) { + const currentDistance = getDistance(p1, p2); + const zoomFactor = currentDistance / pinchStart.distance; + const unclampedScale = pinchStart.scale * zoomFactor; + const newScale = clampScale(Math.max(0.1, unclampedScale)); + + const k = newScale / pinchStart.scale; + const newPosition = { + x: pinchStart.position.x * k + pinchStart.midpoint.x * (1 - k), + y: pinchStart.position.y * k + pinchStart.midpoint.y * (1 - k), + }; + setPositionRef.current(newPosition); + setScaleRef.current(newScale); + } + } + } else if (isDragging && activePointers.size === 1) { + const p = Array.from(activePointers.values())[0]; + + // Проверяем валидность значений + if ( + !startMouse || + !startPos || + typeof startMouse.x !== "number" || + typeof startMouse.y !== "number" || + typeof startPos.x !== "number" || + typeof startPos.y !== "number" + ) { + console.warn( + "WebGLMap: Некорректные значения startMouse или startPos:", + { + startMouse, + startPos, + p, + } + ); + return; + } + + const dx = p.x - startMouse.x; + const dy = p.y - startMouse.y; + + setPositionRef.current({ x: startPos.x + dx, y: startPos.y + dy }); + } + }; + + const onPointerUp = (e: PointerEvent) => { + // Отслеживаем активность пользователя + updateUserActivity(); + + canvas.releasePointerCapture(e.pointerId); + activePointers.delete(e.pointerId); + if (activePointers.size < 2) { + isPinching = false; + pinchStart = null; + } + if (activePointers.size === 0) { + isDragging = false; + } else if (activePointers.size === 1) { + const p = Array.from(activePointers.values())[0]; + startPos = { x: positionRef.current.x, y: positionRef.current.y }; + startMouse = { x: p.x, y: p.y }; + isDragging = true; + } + }; + + const onPointerCancel = (e: PointerEvent) => { + // Handle pointer cancellation (e.g., when touch is interrupted) + updateUserActivity(); + canvas.releasePointerCapture(e.pointerId); + activePointers.delete(e.pointerId); + isPinching = false; + pinchStart = null; + if (activePointers.size === 0) { + isDragging = false; + } + }; + + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + + // Отслеживаем активность пользователя + updateUserActivity(); + if (isAutoMode) { + setIsAutoMode(false); + } + cameraAnimationStore.stopAnimation(); + + const rect = canvas.getBoundingClientRect(); + // Convert mouse coordinates from CSS pixels to physical canvas pixels + const mouseX = + (e.clientX - rect.left) * (canvas.width / canvas.clientWidth); + const mouseY = + (e.clientY - rect.top) * (canvas.height / canvas.clientHeight); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const unclampedScale = scaleRef.current * delta; + const newScale = clampScale(Math.max(0.1, unclampedScale)); + + const k = newScale / scaleRef.current; + const newPosition = { + x: positionRef.current.x * k + mouseX * (1 - k), + y: positionRef.current.y * k + mouseY * (1 - k), + }; + setScaleRef.current(newScale); + setPositionRef.current(newPosition); + }; + + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", onPointerUp); + canvas.addEventListener("pointercancel", onPointerCancel); + canvas.addEventListener("pointerleave", onPointerUp); + canvas.addEventListener("wheel", onWheel, { passive: false }); + + return () => { + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", onPointerUp); + canvas.removeEventListener("pointercancel", onPointerCancel); + canvas.removeEventListener("pointerleave", onPointerUp); + canvas.removeEventListener("wheel", onWheel as any); + }; + }, [ + updateUserActivity, + setIsAutoMode, + cameraAnimationStore, + isAutoMode, + clampScale, + ]); + + return ( +
+ +
+ {stationLabels.map((l, idx) => ( +
+
{l.name}
+ {l.sub ? ( +
+ {l.sub} +
+ ) : null} +
+ ))} + {sightData?.map((s: any, i: number) => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat === undefined || centerLon === undefined) return null; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + const dpr = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1 + ); + const sx = (rx * scale + position.x) / dpr; + const sy = (ry * scale + position.y) / dpr; + const size = 30; + + // Обработчик клика для выбора достопримечательности + const handleSightClick = () => { + const { + setSelectedSightId, + setIsManualSelection, + setIsRightWidgetSelectorOpen, + closeGovernorModal, + } = useGeolocationStore(); + setSelectedSightId(String(s.id)); + setIsManualSelection(true); + setIsRightWidgetSelectorOpen(false); + closeGovernorModal(); + }; + + return ( + + ); + })} + + {(() => { + if (!routeData) return null; + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) return null; + + const coords: any = apiStore?.context?.currentCoordinates; + if (!coords) return null; + + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon + ); + const wx = local.x * UP_SCALE; + const wy = local.y * UP_SCALE; + const cosR = Math.cos(rotationAngle); + const sinR = Math.sin(rotationAngle); + const rx = wx * cosR - wy * sinR; + const ry = wx * sinR + wy * cosR; + const dpr2 = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1 + ); + const screenX = (rx * scale + position.x) / dpr2; + const screenY = (ry * scale + position.y) / dpr2; + + const pathPts: { x: number; y: number }[] = []; + for (let i = 0; i < routePath.length; i += 2) + pathPts.push({ x: routePath[i], y: routePath[i + 1] }); + const stationsForAngle = (stationData || []).map((st: any) => { + const loc = coordinatesToLocal( + st.latitude - centerLat, + st.longitude - centerLon + ); + const x = loc.x * UP_SCALE, + y = loc.y * UP_SCALE; + const rx2 = x * cosR - y * sinR, + ry2 = x * sinR + y * cosR; + return { + longitude: rx2, + latitude: ry2, + offset_x: st.offset_x, + offset_y: st.offset_y, + }; + }); + let tramSegIndex = -1; + if (routePath.length >= 4) { + let best = -1, + bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i], + p1y = routePath[i + 1]; + const p2x = routePath[i + 2], + p2y = routePath[i + 3]; + const dx = p2x - p1x, + dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((rx - p1x) * dx + (ry - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx, + py = p1y + cl * dy; + const d = Math.hypot(rx - px, ry - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + tramSegIndex = best; + } + const optimalAngle = (() => { + const testRadiusInMap = 100 / scale; + const minPath = 60, + minPassed = 60, + minStation = 50; + let bestAng = 0, + bestScore = Infinity; + for (let i = 0; i < 12; i++) { + const ang = (i * Math.PI * 2) / 12; + const tx = rx + Math.cos(ang) * testRadiusInMap; + const ty = ry + Math.sin(ang) * testRadiusInMap; + const distPath = (function () { + if (pathPts.length < 2) return Infinity; + let md = Infinity; + for (let k = 0; k < pathPts.length - 1; k++) { + const p1 = pathPts[k], + p2 = pathPts[k + 1]; + const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + if (!L2) continue; + const tt = + ((tx - p1.x) * (p2.x - p1.x) + + (ty - p1.y) * (p2.y - p1.y)) / + L2; + const cl = Math.max(0, Math.min(1, tt)); + const px = p1.x + cl * (p2.x - p1.x), + py = p1.y + cl * (p2.y - p1.y); + const d = Math.hypot(tx - px, ty - py); + if (d < md) md = d; + } + return md * scale; + })(); + const distPassed = (function () { + if (pathPts.length < 2 || tramSegIndex < 0) return Infinity; + let md = Infinity; + for ( + let k = 0; + k <= Math.min(tramSegIndex, pathPts.length - 2); + k++ + ) { + const p1 = pathPts[k], + p2 = pathPts[k + 1]; + const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + if (!L2) continue; + const tt = + ((tx - p1.x) * (p2.x - p1.x) + + (ty - p1.y) * (p2.y - p1.y)) / + L2; + const cl = Math.max(0, Math.min(1, tt)); + const px = p1.x + cl * (p2.x - p1.x), + py = p1.y + cl * (p2.y - p1.y); + const d = Math.hypot(tx - px, ty - py); + if (d < md) md = d; + } + return md * scale; + })(); + const distStation = (function () { + if (!stationsForAngle.length) return Infinity; + const DEFAULT_LABEL_OFFSET_X = 25, + DEFAULT_LABEL_OFFSET_Y = 0; + let md = Infinity; + for (const st of stationsForAngle) { + const offsetX = + st.offset_x === 0 && st.offset_y === 0 + ? DEFAULT_LABEL_OFFSET_X + : st.offset_x || 0 * 3; + const offsetY = + st.offset_x === 0 && st.offset_y === 0 + ? DEFAULT_LABEL_OFFSET_Y + : st.offset_y || 0 * 3; + const lx = st.longitude + offsetX, + ly = st.latitude + offsetY; + const d = Math.hypot(tx - lx, ty - ly); + if (d < md) md = d; + } + return md * scale; + })(); + let weight = 0; + if (distPath < minPath) weight += 100 * (1 - distPath / minPath); + if (distPassed < minPassed) + weight += 10 * (1 - distPassed / minPassed); + if (distStation < minStation) + weight += 1000 * (1 - distStation / minStation); + if (weight < bestScore) { + bestScore = weight; + bestAng = ang; + } + } + return bestAng; + })(); + + return ( + + ); + })()} +
+
+ ); +}); + +export default WebGLMap; diff --git a/src/pages/Sight/LinkedStations.tsx b/src/pages/Sight/LinkedStations.tsx new file mode 100644 index 0000000..f26d6b6 --- /dev/null +++ b/src/pages/Sight/LinkedStations.tsx @@ -0,0 +1,321 @@ +import { useState, useEffect } from "react"; +import { + Stack, + Typography, + Button, + Accordion, + AccordionSummary, + AccordionDetails, + useTheme, + TextField, + Autocomplete, + TableCell, + TableContainer, + Table, + TableHead, + TableRow, + Paper, + TableBody, +} from "@mui/material"; +import { observer } from "mobx-react-lite"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; + +import { authInstance, languageStore, selectedCityStore } from "@shared"; + +type Field = { + label: string; + data: keyof T; + render?: (value: any) => React.ReactNode; +}; + +type LinkedStationsProps = { + parentId: string | number; + fields: Field[]; + setItemsParent?: (items: T[]) => void; + type: "show" | "edit"; + onUpdate?: () => void; + disableCreation?: boolean; + updatedLinkedItems?: T[]; + refresh?: number; +}; + +export const LinkedStations = < + T extends { id: number; name: string; [key: string]: any } +>( + props: LinkedStationsProps +) => { + const theme = useTheme(); + + return ( + <> + + } + sx={{ + background: theme.palette.background.paper, + borderBottom: `1px solid ${theme.palette.divider}`, + width: "100%", + }} + > + + Привязанные остановки + + + + + + + + + + + ); +}; + +const LinkedStationsContentsInner = < + T extends { id: number; name: string; [key: string]: any } +>({ + parentId, + setItemsParent, + fields, + type, + onUpdate, + disableCreation = false, + updatedLinkedItems, + refresh, +}: LinkedStationsProps) => { + const { language } = languageStore; + + const [allItems, setAllItems] = useState([]); + const [linkedItems, setLinkedItems] = useState([]); + const [selectedItemId, setSelectedItemId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => {}, [error]); + + const parentResource = "sight"; + const childResource = "station"; + + const availableItems = allItems + .filter((item) => !linkedItems.some((linked) => linked.id === item.id)) + .filter((item) => { + const selectedCityId = selectedCityStore.selectedCityId; + if (selectedCityId && "city_id" in item) { + return item.city_id === selectedCityId; + } + return true; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + useEffect(() => { + if (updatedLinkedItems) { + setLinkedItems(updatedLinkedItems); + } + }, [updatedLinkedItems]); + + useEffect(() => { + setItemsParent?.(linkedItems); + }, [linkedItems, setItemsParent]); + + const linkItem = () => { + if (selectedItemId !== null) { + setError(null); + const requestData = { + station_id: selectedItemId, + }; + + authInstance + .post(`/${parentResource}/${parentId}/${childResource}`, requestData) + .then(() => { + const newItem = allItems.find((item) => item.id === selectedItemId); + if (newItem) { + setLinkedItems([...linkedItems, newItem]); + } + setSelectedItemId(null); + onUpdate?.(); + }) + .catch((error) => { + console.error("Error linking station:", error); + setError("Failed to link station"); + }); + } + }; + + const deleteItem = (itemId: number) => { + setError(null); + authInstance + .delete(`/${parentResource}/${parentId}/${childResource}`, { + data: { [`${childResource}_id`]: itemId }, + }) + .then(() => { + setLinkedItems(linkedItems.filter((item) => item.id !== itemId)); + onUpdate?.(); + }) + .catch((error) => { + console.error("Error deleting station:", error); + setError("Failed to delete station"); + }); + }; + + useEffect(() => { + if (parentId) { + setIsLoading(true); + setError(null); + authInstance + .get(`/${parentResource}/${parentId}/${childResource}`) + .then((response) => { + setLinkedItems(response?.data || []); + }) + .catch((error) => { + console.error("Error fetching linked stations:", error); + setError("Failed to load linked stations"); + setLinkedItems([]); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, [parentId, language, refresh]); + + useEffect(() => { + if (type === "edit") { + setError(null); + authInstance + .get(`/${childResource}`) + .then((response) => { + setAllItems(response?.data || []); + }) + .catch((error) => { + console.error("Error fetching all stations:", error); + setError("Failed to load available stations"); + setAllItems([]); + }); + } + }, [type]); + + return ( + <> + {linkedItems?.length > 0 && ( + + + + + + № + + {fields.map((field) => ( + {field.label} + ))} + {type === "edit" && ( + Действие + )} + + + + + {linkedItems.map((item, index) => ( + + {index + 1} + {fields.map((field, idx) => ( + + {field.render + ? field.render(item[field.data]) + : item[field.data]} + + ))} + {type === "edit" && ( + + + + )} + + ))} + +
+
+ )} + + {linkedItems.length === 0 && !isLoading && ( + + Остановки не найдены + + )} + + {type === "edit" && !disableCreation && ( + + Добавить остановку + item.id === selectedItemId) || null + } + onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} + options={availableItems} + getOptionLabel={(item) => String(item.name)} + renderInput={(params) => ( + + )} + isOptionEqualToValue={(option, value) => option.id === value?.id} + filterOptions={(options, { inputValue }) => { + const searchWords = inputValue + .toLowerCase() + .split(" ") + .filter(Boolean); + return options.filter((option) => { + const optionWords = String(option.name) + .toLowerCase() + .split(" "); + return searchWords.every((searchWord) => + optionWords.some((word) => word.startsWith(searchWord)) + ); + }); + }} + renderOption={(props, option) => ( +
  • + {String(option.name)} +
  • + )} + /> + + +
    + )} + + {isLoading && ( + + Загрузка... + + )} + + {error && ( + + {error} + + )} + + ); +}; + +export const LinkedStationsContents = observer( + LinkedStationsContentsInner +) as typeof LinkedStationsContentsInner; diff --git a/src/pages/Sight/index.ts b/src/pages/Sight/index.ts index 4eed577..96c2d33 100644 --- a/src/pages/Sight/index.ts +++ b/src/pages/Sight/index.ts @@ -1 +1,2 @@ export * from "./SightListPage"; +export { LinkedStations } from "./LinkedStations"; diff --git a/src/widgets/SightTabs/InformationTab/index.tsx b/src/widgets/SightTabs/InformationTab/index.tsx index af41555..93b0fbc 100644 --- a/src/widgets/SightTabs/InformationTab/index.tsx +++ b/src/widgets/SightTabs/InformationTab/index.tsx @@ -37,8 +37,8 @@ import { useEffect, useState } from "react"; import { toast } from "react-toastify"; -// Компонент предупреждающего окна (перенесен сюда) import { SaveWithoutCityAgree } from "@widgets"; +import { LinkedStations } from "@pages"; export const InformationTab = observer( ({ value, index }: { value: number; index: number }) => { @@ -62,7 +62,7 @@ export const InformationTab = observer( "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null >(null); const { cities } = cityStore; - // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА + const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); useEffect(() => {}, [hardcodeType]); @@ -119,16 +119,14 @@ export const InformationTab = observer( updateSightInfo(language, content, common); }; - // НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения) const executeSave = async () => { await updateSight(); toast.success("Достопримечательность сохранена"); }; - // ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения const handleSave = async () => { const isCityMissing = !sight.common.city_id; - // Проверяем названия на всех языках + const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name; if (isCityMissing || isNameMissing) { @@ -139,13 +137,11 @@ export const InformationTab = observer( await executeSave(); }; - // Обработчик "Да" в предупреждающем окне const handleConfirmSave = async () => { setIsSaveWarningOpen(false); await executeSave(); }; - // Обработчик "Нет" в предупреждающем окне const handleCancelSave = () => { setIsSaveWarningOpen(false); }; @@ -275,6 +271,16 @@ export const InformationTab = observer( /> + + {sight.common.id !== 0 && ( + + )} + + } - onClick={handleSave} // Используем новую функцию-обработчик + onClick={handleSave} > Сохранить @@ -538,7 +544,6 @@ export const InformationTab = observer( )} - {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {isSaveWarningOpen && ( ); } -); \ No newline at end of file +); diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 6b1027c..38a9018 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/globalerrorboundary.tsx","./src/app/index.tsx","./src/app/router/index.tsx","./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/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/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/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/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/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/userstore/index.ts","./src/shared/store/vehiclestore/index.ts","./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/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/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/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/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/snapshotrestore/index.tsx","./src/widgets/videopreviewcard/index.tsx","./src/widgets/modals/editstationmodal.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/globalerrorboundary.tsx","./src/app/index.tsx","./src/app/router/index.tsx","./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/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/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/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/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/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/userstore/index.ts","./src/shared/store/vehiclestore/index.ts","./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/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/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/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/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/snapshotrestore/index.tsx","./src/widgets/videopreviewcard/index.tsx","./src/widgets/modals/editstationmodal.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"errors":true,"version":"5.8.3"} \ No newline at end of file