diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 6082418..e82522d 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -16,12 +16,7 @@ import { SnapshotListPage, CarrierListPage, StationListPage, - // VehicleListPage, ArticleListPage, - - // CountryPreviewPage, - // VehiclePreviewPage, - // CarrierPreviewPage, SnapshotCreatePage, CountryCreatePage, CityCreatePage, @@ -31,7 +26,6 @@ import { CityEditPage, UserCreatePage, UserEditPage, - // VehicleEditPage, CarrierEditPage, StationCreatePage, StationPreviewPage, @@ -75,7 +69,6 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { return <>{children}; }; -// Чтобы очистка сторов происходила при смене локации const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({ children, }) => { @@ -116,65 +109,45 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, - // Sight { path: "sight", element: }, { path: "sight/create", element: }, { path: "sight/:id/edit", element: }, - // Device { path: "devices", element: }, - // Map { path: "map", element: }, - // Media { path: "media", element: }, { path: "media/:id", element: }, { path: "media/:id/edit", element: }, - // Country { path: "country", element: }, { path: "country/create", element: }, { path: "country/add", element: }, - // { path: "country/:id", element: }, { path: "country/:id/edit", element: }, - // City { path: "city", element: }, { path: "city/create", element: }, - // { path: "city/:id", element: }, { path: "city/:id/edit", element: }, - // Route { path: "route", element: }, { path: "route/create", element: }, { path: "route/:id/edit", element: }, - // User { path: "user", element: }, { path: "user/create", element: }, { path: "user/:id/edit", element: }, - // Snapshot { path: "snapshot", element: }, { path: "snapshot/create", element: }, - // Carrier { path: "carrier", element: }, { path: "carrier/create", element: }, - // { path: "carrier/:id", element: }, { path: "carrier/:id/edit", element: }, - // Station { path: "station", element: }, { path: "station/create", element: }, { path: "station/:id", element: }, { path: "station/:id/edit", element: }, - // Vehicle - // { path: "vehicle", element: }, { path: "vehicle/create", element: }, - // { path: "vehicle/:id", element: }, - // { path: "vehicle/:id/edit", element: }, - // Article { path: "article", element: }, { path: "article/:id", element: }, - // { path: "media/create", element: }, ], }, ]); diff --git a/src/pages/Carrier/CarrierCreatePage/index.tsx b/src/pages/Carrier/CarrierCreatePage/index.tsx index 98e795a..a61daae 100644 --- a/src/pages/Carrier/CarrierCreatePage/index.tsx +++ b/src/pages/Carrier/CarrierCreatePage/index.tsx @@ -48,7 +48,6 @@ export const CarrierCreatePage = observer(() => { languageStore.setLanguage("ru"); }, []); - // Автоматически устанавливаем выбранный город при загрузке страницы useEffect(() => { if (selectedCityId && !createCarrierData.city_id) { setCreateCarrierData( diff --git a/src/pages/Carrier/CarrierEditPage/index.tsx b/src/pages/Carrier/CarrierEditPage/index.tsx index e914ba2..ff6ed55 100644 --- a/src/pages/Carrier/CarrierEditPage/index.tsx +++ b/src/pages/Carrier/CarrierEditPage/index.tsx @@ -74,7 +74,6 @@ export const CarrierEditPage = observer(() => { mediaStore.getMedia(); })(); - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); }, [id]); diff --git a/src/pages/City/CityEditPage/index.tsx b/src/pages/City/CityEditPage/index.tsx index 4d7b0b4..dcccc3f 100644 --- a/src/pages/City/CityEditPage/index.tsx +++ b/src/pages/City/CityEditPage/index.tsx @@ -44,7 +44,6 @@ export const CityEditPage = observer(() => { const { getMedia, getOneMedia } = mediaStore; useEffect(() => { - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); }, []); @@ -64,12 +63,11 @@ export const CityEditPage = observer(() => { (async () => { if (id) { await getCountries("ru"); - // Fetch data for all languages + const ruData = await getCity(id as string, "ru"); const enData = await getCity(id as string, "en"); const zhData = await getCity(id as string, "zh"); - // Set data for each language setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); setEditCityData(enData.name, enData.country_code, enData.arms, "en"); setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); @@ -207,7 +205,7 @@ export const CityEditPage = observer(() => { open={isSelectMediaOpen} onClose={() => setIsSelectMediaOpen(false)} onSelectMedia={handleMediaSelect} - mediaType={1} // Тип медиа для иконок + mediaType={1} /> { countryStore; useEffect(() => { - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); }, []); @@ -36,12 +35,10 @@ export const CountryEditPage = observer(() => { useEffect(() => { (async () => { if (id) { - // Fetch data for all languages const ruData = await getCountry(id as string, "ru"); const enData = await getCountry(id as string, "en"); const zhData = await getCountry(id as string, "zh"); - // Set data for each language setEditCountryData(ruData.name, "ru"); setEditCountryData(enData.name, "en"); setEditCountryData(zhData.name, "zh"); diff --git a/src/pages/LoginPage/index.tsx b/src/pages/LoginPage/index.tsx index ef14c28..d69157a 100644 --- a/src/pages/LoginPage/index.tsx +++ b/src/pages/LoginPage/index.tsx @@ -24,7 +24,6 @@ export const LoginPage = () => { const { login } = authStore; const { getUsers } = userStore; useEffect(() => { - // Load saved credentials if they exist const savedEmail = localStorage.getItem("rememberedEmail"); const savedPassword = localStorage.getItem("rememberedPassword"); if (savedEmail && savedPassword) { @@ -42,7 +41,6 @@ export const LoginPage = () => { try { await login(email, password); - // Save or clear credentials based on remember me checkbox if (rememberMe) { localStorage.setItem("rememberedEmail", email); localStorage.setItem("rememberedPassword", password); diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx index 7f0f1bc..33fdf5d 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"; @@ -57,7 +60,6 @@ import Source from "ol/source/Source"; import { FeatureLike } from "ol/Feature"; import { createEmpty, extend, getCenter } from "ol/extent"; -// --- CUSTOM SCROLLBAR STYLES --- const scrollbarStyles = ` .scrollbar-hide { -ms-overflow-style: none; @@ -97,8 +99,6 @@ if (typeof document !== "undefined") { document.head.appendChild(styleElement); } -// --- MAP STORE --- -// @ts-ignore import { languageInstance } from "@shared"; import { makeAutoObservable } from "mobx"; @@ -111,14 +111,11 @@ import { carrierStore, } from "@shared"; -// Функция для сброса кешей карты export const clearMapCaches = () => { - // Сброс кешей маршрутов mapStore.routes = []; mapStore.stations = []; mapStore.sights = []; - // Сброс кешей MapService если он доступен if (typeof window !== "undefined" && (window as any).mapServiceInstance) { (window as any).mapServiceInstance.clearCaches(); } @@ -136,6 +133,7 @@ interface ApiRoute { interface ApiStation { id: number; name: string; + description?: string; latitude: number; longitude: number; city_id: number; @@ -162,20 +160,74 @@ export type SortType = | "updated_asc" | "updated_desc"; +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); + + 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"; sightSort: SortType = "name_asc"; - // НОВЫЕ МЕТОДЫ-СЕТТЕРЫ setStationSort = (sortType: SortType) => { this.stationSort = sortType; }; @@ -184,7 +236,6 @@ class MapStore { this.sightSort = sortType; }; - // ПРИВАТНЫЙ МЕТОД ДЛЯ ОБЩЕЙ ЛОГИКИ СОРТИРОВКИ private sortFeatures( features: T[], sortType: SortType @@ -208,7 +259,7 @@ class MapStore { new Date(b.created_at).getTime() ); } - // Фоллбэк: сортировка по ID, если дата недоступна + return a.id - b.id; }); case "created_desc": @@ -224,7 +275,7 @@ class MapStore { new Date(a.created_at).getTime() ); } - // Фоллбэк: сортировка по ID, если дата недоступна + return b.id - a.id; }); case "updated_asc": @@ -258,7 +309,6 @@ class MapStore { } } - // НОВЫЕ ГЕТТЕРЫ, ВОЗВРАЩАЮЩИЕ ОТСОРТИРОВАННЫЕ СПИСКИ get sortedStations(): ApiStation[] { return this.sortFeatures(this.stations, this.stationSort); } @@ -267,7 +317,6 @@ class MapStore { return this.sortFeatures(this.sights, this.sightSort); } - // ГЕТТЕРЫ ДЛЯ ФИЛЬТРАЦИИ ПО ГОРОДУ get filteredStations(): ApiStation[] { const selectedCityId = selectedCityStore.selectedCityId; if (!selectedCityId) { @@ -284,12 +333,9 @@ class MapStore { return this.routes; } - // Получаем carriers для текущего языка const carriers = carrierStore.carriers.ru.data; - // Фильтруем маршруты по городу через carriers return this.routes.filter((route: ApiRoute) => { - // Находим carrier для маршрута const carrier = carriers.find((c: any) => c.id === route.carrier_id); return carrier && carrier.city_id === selectedCityId; }); @@ -297,12 +343,21 @@ 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 +379,43 @@ 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[]) => { + 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); + }; + + preloadRouteSights = async (routesIds: number[]) => { + 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); }; getStations = async () => { @@ -353,7 +445,7 @@ class MapStore { createFeature = async (featureType: string, geoJsonFeature: any) => { const { geometry, properties } = geoJsonFeature; - let createdItem; + let createdItem: any; if (featureType === "station") { const name = properties.name || "Остановка 1"; @@ -404,7 +496,6 @@ class MapStore { "EPSG:3857" ); - // Автоматически назначаем перевозчика из выбранного города let carrier_id = 0; let carrier = ""; @@ -430,8 +521,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); @@ -461,11 +552,8 @@ class MapStore { throw new Error(`Unknown feature type for creation: ${featureType}`); } - // @ts-ignore if (featureType === "route") this.routes.push(createdItem); - // @ts-ignore else if (featureType === "station") this.stations.push(createdItem); - // @ts-ignore else if (featureType === "sight") this.sights.push(createdItem); return createdItem; @@ -566,18 +654,15 @@ class MapStore { const mapStore = new MapStore(); -// Делаем mapStore доступным глобально для сброса кешей if (typeof window !== "undefined") { (window as any).mapStore = mapStore; } -// --- CONFIGURATION --- export const mapConfig = { center: [30.311, 59.94] as [number, number], zoom: 13, }; -// --- MAP POSITION STORAGE --- const MAP_POSITION_KEY = "mapPosition"; const ACTIVE_SECTION_KEY = "mapActiveSection"; @@ -616,7 +701,6 @@ const saveMapPosition = (position: MapPosition): void => { } }; -// --- ACTIVE SECTION STORAGE --- const getStoredActiveSection = (): string | null => { try { const stored = localStorage.getItem(ACTIVE_SECTION_KEY); @@ -641,7 +725,6 @@ const saveActiveSection = (section: string | null): void => { } }; -// --- TYPE DEFINITIONS --- interface MapServiceConfig { target: HTMLElement; center: [number, number]; @@ -651,18 +734,18 @@ 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 - public routeLayer: VectorLayer>>; // Public for deselect + public clusterLayer: VectorLayer; + public routeLayer: VectorLayer>>; private clusterSource: Cluster; private clusterStyleCache: { [key: number]: Style }; private tooltipElement: HTMLElement; private tooltipOverlay: Overlay | null; private mode: string | null; - // @ts-ignore + private currentDrawingType: "Point" | "LineString" | null; private currentDrawingFeatureType: FeatureType | null; private currentInteraction: Draw | null; @@ -681,7 +764,6 @@ class MapService { null; private isCreating: boolean = false; - // Styles private defaultStyle: Style; private selectedStyle: Style; private drawStyle: Style; @@ -696,7 +778,6 @@ class MapService { private hoverSightIconStyle: Style; private universalHoverStyleLine: Style; - // Callbacks private setLoading: (loading: boolean) => void; private setError: (error: string | null) => void; private onModeChangeCallback: (mode: string) => void; @@ -838,13 +919,12 @@ class MapService { this.routeLayer = new VectorLayer({ source: this.lineSource, - // @ts-ignore + style: (featureLike: FeatureLike) => { const feature = featureLike as Feature; if (!feature) return this.defaultStyle; const fId = feature.getId(); - // Все маршруты всегда отображаются, так как они не кластеризуются const isSelected = this.selectInteraction?.getFeatures().getArray().includes(feature) || (fId !== undefined && this.selectedIds.has(fId)); @@ -909,9 +989,6 @@ class MapService { }); this.clusterSource.on("change", () => { - // Поскольку маршруты больше не добавляются как точки, - // нам не нужно отслеживать unclusteredRouteIds - // Все маршруты всегда отображаются как линии this.routeLayer.changed(); }); @@ -943,7 +1020,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() }), @@ -960,21 +1037,18 @@ class MapService { new KeyboardZoom(), new PinchZoom(), new PinchRotate(), - // Отключаем DoubleClickZoom как было изначально - // new DoubleClickZoom(), + new DragPan({ condition: (event) => { - // Разрешаем перетаскивание только при нажатии средней кнопки мыши (колёсико) const originalEvent = event.originalEvent; if (!originalEvent) return false; - // Проверяем, что это событие мыши и нажата средняя кнопка if ( originalEvent.type === "pointerdown" || originalEvent.type === "pointermove" ) { const pointerEvent = originalEvent as PointerEvent; - return pointerEvent.buttons === 4; // 4 = средняя кнопка мыши + return pointerEvent.buttons === 4; } return false; @@ -1047,7 +1121,7 @@ class MapService { originalFeatures.length === 1 && originalFeatures[0].get("isProxy") ) - return false; // Ignore proxy points + return false; return true; }, multi: true, @@ -1182,7 +1256,7 @@ class MapService { const selected = new Set(); this.pointSource.forEachFeatureInExtent(extent, (f) => { - if (f.get("isProxy")) return; // Ignore proxy in lasso + if (f.get("isProxy")) return; const geom = f.getGeometry(); if (geom && geom.getType() === "Point") { const pointCoords = (geom as Point).getCoordinates(); @@ -1219,7 +1293,6 @@ class MapService { this.selectInteraction.setActive(false); this.lassoInteraction.setActive(false); - // --- ИСПРАВЛЕНИЕ: Главный обработчик выбора объектов и кластеров this.selectInteraction.on("select", (e: SelectEvent) => { if (this.mode !== "edit" || !this.map) return; @@ -1227,13 +1300,11 @@ class MapService { e.mapBrowserEvent.originalEvent.ctrlKey || e.mapBrowserEvent.originalEvent.metaKey; - // Проверяем, был ли клик по кластеру (группе) if (e.selected.length === 1 && !ctrlKey) { const clickedFeature = e.selected[0]; const originalFeatures = clickedFeature.get("features"); if (originalFeatures && originalFeatures.length > 1) { - // Если да, то приближаем карту const extent = createEmpty(); originalFeatures.forEach((feat: Feature) => { const geom = feat.getGeometry(); @@ -1244,14 +1315,13 @@ class MapService { padding: [60, 60, 60, 60], maxZoom: 18, }); - // Сбрасываем выделение, так как мы не хотим "выделять" сам кластер + this.selectInteraction.getFeatures().clear(); this.setSelectedIds(new Set()); - return; // Завершаем обработку + return; } } - // Стандартная логика выделения для одиночных объектов (или с Ctrl) const newSelectedIds = ctrlKey ? new Set(this.selectedIds) : new Set(); @@ -1260,23 +1330,6 @@ class MapService { 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,60 +1337,72 @@ class MapService { } if (targetId !== undefined) { - newSelectedIds.delete(targetId); + if (ctrlKey && newSelectedIds.has(targetId)) { + newSelectedIds.delete(targetId); + } else { + newSelectedIds.add(targetId); + } } }); + 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); }); this.map.on("pointermove", this.boundHandlePointerMove as any); const targetEl = this.map.getTargetElement(); if (targetEl instanceof HTMLElement) { - // Устанавливаем курсор pointer по умолчанию для всей карты targetEl.style.cursor = "pointer"; targetEl.addEventListener("contextmenu", this.boundHandleContextMenu); targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave); - // Добавляем обработчики для изменения курсора при нажатии средней кнопки мыши targetEl.addEventListener("pointerdown", (e) => { if (e.buttons === 4) { - // Средняя кнопка мыши - e.preventDefault(); // Предотвращаем скролл страницы + e.preventDefault(); targetEl.style.cursor = "grabbing"; } }); targetEl.addEventListener("pointerup", (e) => { if (e.button === 1) { - // Средняя кнопка мыши отпущена - e.preventDefault(); // Предотвращаем скролл страницы + e.preventDefault(); targetEl.style.cursor = "pointer"; } }); - // Также добавляем обработчик для mousedown/mouseup для совместимости targetEl.addEventListener("mousedown", (e) => { if (e.button === 1) { - // Средняя кнопка мыши - e.preventDefault(); // Предотвращаем скролл страницы + e.preventDefault(); targetEl.style.cursor = "grabbing"; } }); targetEl.addEventListener("mouseup", (e) => { if (e.button === 1) { - // Средняя кнопка мыши отпущена - e.preventDefault(); // Предотвращаем скролл страницы + e.preventDefault(); targetEl.style.cursor = "pointer"; } }); - // Дополнительная защита от нежелательного поведения средней кнопки мыши targetEl.addEventListener("auxclick", (e) => { if (e.button === 1) { - // Средняя кнопка мыши - e.preventDefault(); // Предотвращаем открытие ссылки в новой вкладке + e.preventDefault(); } }); } @@ -1368,13 +1433,27 @@ class MapService { const pointFeatures: Feature[] = []; const lineFeatures: Feature[] = []; - // Используем фильтрованные данные из mapStore const filteredStations = mapStore.filteredStations; const filteredSights = mapStore.filteredSights; const filteredRoutes = mapStore.filteredRoutes; + 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)); + }); + + 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 +1484,9 @@ 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]) => @@ -1447,7 +1529,6 @@ class MapService { } if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); - // Сбрасываем курсор при покидании области карты if (this.map) { const targetEl = this.map.getTargetElement(); if (targetEl instanceof HTMLElement) { @@ -1528,10 +1609,9 @@ class MapService { const fType = this.currentDrawingFeatureType; if (!fType) return; - // Проверяем, не идет ли уже процесс создания if (this.isCreating) { toast.warning("Дождитесь завершения создания предыдущего объекта."); - // Удаляем созданный объект из источника + const sourceForDrawing = type === "Point" ? this.pointSource : this.lineSource; setTimeout(() => { @@ -1548,7 +1628,6 @@ class MapService { switch (fType) { case "station": - // Используем полный список из mapStore, а не отфильтрованный const stationNumbers = mapStore.stations .map((station) => { const match = station.name?.match(/^Остановка (\d+)$/); @@ -1560,7 +1639,6 @@ class MapService { resourceName = `Остановка ${nextStationNumber}`; break; case "sight": - // Используем полный список из mapStore, а не отфильтрованный const sightNumbers = mapStore.sights .map((sight) => { const match = sight.name?.match(/^Достопримечательность (\d+)$/); @@ -1572,7 +1650,6 @@ class MapService { resourceName = `Достопримечательность ${nextSightNumber}`; break; case "route": - // Используем полный список из mapStore, а не отфильтрованный const routeNumbers = mapStore.routes .map((route) => { const match = route.route_number?.match(/^Маршрут (\d+)$/); @@ -1614,11 +1691,8 @@ class MapService { private stopDrawing() { if (this.map && this.currentInteraction) { try { - // @ts-ignore this.currentInteraction.abortDrawing(); - } catch (e) { - /* ignore */ - } + } catch (e) {} this.map.removeInteraction(this.currentInteraction); } this.currentInteraction = null; @@ -1629,7 +1703,6 @@ class MapService { public finishDrawing(): void { if (!this.currentInteraction) return; - // Блокируем завершение рисования, если идет процесс создания if (this.isCreating) { toast.warning("Дождитесь завершения создания предыдущего объекта."); return; @@ -1660,7 +1733,7 @@ class MapService { layerFilter, hitTolerance: 5, }); - // Устанавливаем курсор pointer для всей карты, чтобы показать возможность перетаскивания колёсиком + this.map.getTargetElement().style.cursor = hit ? "pointer" : "pointer"; const featureAtPixel: Feature | undefined = @@ -1674,7 +1747,7 @@ class MapService { if (featureAtPixel) { const originalFeatures = featureAtPixel.get("features"); if (originalFeatures && originalFeatures.length > 0) { - if (originalFeatures[0].get("isProxy")) return; // Ignore hover on proxy + if (originalFeatures[0].get("isProxy")) return; finalFeature = originalFeatures[0]; } else { finalFeature = featureAtPixel; @@ -1880,10 +1953,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,17 +1992,15 @@ class MapService { if (this.mode === "lasso") this.deactivateLasso(); else this.activateLasso(); } - public getMap(): Map | null { + public getMap(): OLMap | null { return this.map; } - // Метод для сброса кешей карты public clearCaches() { this.clusterStyleCache = {}; this.hoveredFeatureId = null; this.selectedIds.clear(); - // Очищаем источники данных if (this.pointSource) { this.pointSource.clear(); } @@ -1933,7 +2008,6 @@ class MapService { this.lineSource.clear(); } - // Обновляем слои if (this.clusterLayer) { this.clusterLayer.changed(); } @@ -1983,10 +2057,9 @@ class MapService { const featureType = feature.get("featureType") as FeatureType; if (!featureType || !this.map) return; - // Проверяем, не идет ли уже процесс создания if (this.isCreating) { toast.warning("Дождитесь завершения создания предыдущего объекта."); - // Удаляем незавершенный объект с карты + if (feature.getGeometry()?.getType() === "LineString") { if (this.lineSource.hasFeature(feature as Feature)) this.lineSource.removeFeature(feature as Feature); @@ -2013,37 +2086,28 @@ class MapService { ); const newFeatureId = `${featureType}-${createdFeatureData.id}`; - // @ts-ignore + const displayName = featureType === "route" - ? // @ts-ignore - createdFeatureData.route_number - : // @ts-ignore - createdFeatureData.name; + ? createdFeatureData.route_number + : createdFeatureData.name; if (featureType === "route") { - // @ts-ignore const routeData = createdFeatureData as ApiRoute; const projection = this.map.getView().getProjection(); - // Update existing line feature feature.setId(newFeatureId); feature.set("name", displayName); - // Optionally update geometry if server modified it const lineGeom = new LineString( routeData.path.map((c) => transform([c[1], c[0]], "EPSG:4326", projection) ) ); feature.setGeometry(lineGeom); - - // Не создаем прокси-точку для маршрута - только линия } else { - // For points: update existing feature.setId(newFeatureId); feature.set("name", displayName); - // No need to remove and re-add since it's already in the source } this.updateFeaturesInReact(); @@ -2065,7 +2129,6 @@ class MapService { } } -// --- MAP CONTROLS COMPONENT --- interface MapControlsProps { mapService: MapService | null; activeMode: string; @@ -2163,7 +2226,6 @@ const MapControls: React.FC = ({ import { observer } from "mobx-react-lite"; -// --- MAP SIGHTBAR COMPONENT --- interface MapSightbarProps { mapService: MapService | null; mapFeatures: Feature[]; @@ -2196,7 +2258,6 @@ const MapSightbar: React.FC = observer( [mapFeatures] ); - // Создаем объединенный список всех объектов для поиска const allFeatures = useMemo(() => { const stations = mapStore.filteredStations.map((station) => { const feature = new Feature({ @@ -2208,6 +2269,7 @@ const MapSightbar: React.FC = observer( ) ), name: station.name, + description: station.description || "", }); feature.setId(`station-${station.id}`); feature.set("featureType", "station"); @@ -2263,11 +2325,24 @@ 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 +2388,154 @@ 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); + + try { + if (isHidden) { + const route = mapStore.routes.find((r) => r.id === numericRouteId); + if (!route) { + return; + } + + const projection = mapService.getMap()?.getView().getProjection(); + if (!projection) { + return; + } + + 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); + } else { + } + + const routeStationIds = + mapStore.routeStationsCache.get(numericRouteId) || []; + + const allRouteIds = mapStore.routes.map((r) => r.id); + + const visibleRouteIds = allRouteIds.filter( + (id: number) => + id !== numericRouteId && !mapStore.hiddenRoutes.has(id) + ); + + const stationsInVisibleRoutes = new Set(); + visibleRouteIds.forEach((otherRouteId) => { + const stationIds = + mapStore.routeStationsCache.get(otherRouteId) || []; + stationIds.forEach((id: number) => + stationsInVisibleRoutes.add(id) + ); + }); + + 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); + } else { + const routeStationIds = + mapStore.routeStationsCache.get(numericRouteId) || []; + + const allRouteIds = mapStore.routes.map((r) => r.id); + + const visibleRouteIds = allRouteIds.filter( + (id: number) => + id !== numericRouteId && !mapStore.hiddenRoutes.has(id) + ); + + const stationsInVisibleRoutes = new Set(); + visibleRouteIds.forEach((otherRouteId) => { + const stationIds = + mapStore.routeStationsCache.get(otherRouteId) || []; + stationIds.forEach((id: number) => + stationsInVisibleRoutes.add(id) + ); + }); + + 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); + } + + mapService.unselect(); + } catch (error) { + console.error( + "[handleHideRoute] Error toggling route visibility:", + error + ); + toast.error("Ошибка при изменении видимости маршрута"); + } + }, + [mapService] + ); + const sortFeaturesByType = >( features: T[], sortType: SortType @@ -2406,16 +2629,32 @@ const MapSightbar: React.FC = observer( {features.length > 0 ? ( features.map((feature) => { const fId = feature.getId(); - if (fId === undefined) return null; // TypeScript-safe + if (fId === undefined) return null; const fName = (feature.get("name") as string) || "Без названия"; 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); + + const description = feature.get("description") as + | string + | undefined; + const showDescription = + featureType === "station" && + description && + description.trim() !== ""; + return (
= observer(
handleFeatureClick(fId)} + onClick={(e) => handleFeatureClick(fId, e)} >
= observer( {fName}
+ {showDescription && ( +
+ {description} +
+ )}
+ {featureType === "route" && ( + + )} + {featureType === "route" && ( + + )} + {showSightsOptions && ( +
+
+ Будут скрыты все достопримечательности, привязанные к + скрытым маршрутам. +
+
+ )} +
), content: renderFeatureList(sortedSights, "sight", Landmark), @@ -2647,7 +2973,7 @@ const MapSightbar: React.FC = observer( ); } ); -// --- MAP PAGE COMPONENT --- + export const MapPage: React.FC = observer(() => { const mapRef = useRef(null); const tooltipRef = useRef(null); @@ -2678,7 +3004,6 @@ export const MapPage: React.FC = observer(() => { const handleFeatureSelectForSidebar = useCallback( (feat: Feature | null) => { - // Logic to sync sidebar selection with map setSelectedFeatureForSidebar(feat); if (feat) { const featureType = feat.get("featureType"); @@ -2740,7 +3065,6 @@ export const MapPage: React.FC = observer(() => { ); setMapServiceInstance(service); - // Делаем mapServiceInstance доступным глобально для сброса кешей if (typeof window !== "undefined") { (window as any).mapServiceInstance = service; } @@ -2758,15 +3082,12 @@ export const MapPage: React.FC = observer(() => { service?.destroy(); setMapServiceInstance(null); - // Удаляем глобальную ссылку if (typeof window !== "undefined") { delete (window as any).mapServiceInstance; } }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // --- ИСПРАВЛЕНИЕ: Этот хук отвечает ТОЛЬКО за клики по ПУСТОМУ месту useEffect(() => { const olMap = mapServiceInstance?.getMap(); if (!olMap || !mapServiceInstance) return; @@ -2781,11 +3102,9 @@ export const MapPage: React.FC = observer(() => { hitTolerance: 5, }); - // Если клик был НЕ по объекту, снимаем выделение if (!hit) { mapServiceInstance.unselect(); } - // Если клик был ПО объекту, НИЧЕГО не делаем. За это отвечает selectInteraction. }; olMap.on("click", handleMapClickForDeselect); @@ -2838,14 +3157,11 @@ export const MapPage: React.FC = observer(() => { saveActiveSection(activeSectionFromParent); }, [activeSectionFromParent]); - // Перезагружаем данные при изменении города useEffect(() => { if (mapServiceInstance && !isDataLoading) { - // Очищаем текущие объекты на карте mapServiceInstance.pointSource.clear(); mapServiceInstance.lineSource.clear(); - // Загружаем новые данные с фильтрацией по городу mapServiceInstance.loadFeaturesFromApi( mapStore.stations, mapStore.routes, @@ -2854,6 +3170,24 @@ 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/MapPage/mapStore.ts b/src/pages/MapPage/mapStore.ts index 91ad7eb..273925f 100644 --- a/src/pages/MapPage/mapStore.ts +++ b/src/pages/MapPage/mapStore.ts @@ -22,10 +22,8 @@ interface ApiSight { longitude: number; } -// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел. const COORDINATE_PRECISION_TOLERANCE = 1e-9; -// Вспомогательная функция, обновленная для сравнения с допуском. const arePathsEqual = ( path1: [number, number][], path2: [number, number][] @@ -136,7 +134,6 @@ class MapStore { longitude: geometry.coordinates[0], }; - // ИЗМЕНЕНИЕ: Сравнение координат с допуском if ( originalStation.name !== currentStation.name || Math.abs(originalStation.latitude - currentStation.latitude) > @@ -155,7 +152,6 @@ class MapStore { path: geometry.coordinates, }; - // ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском if ( originalRoute.route_number !== currentRoute.route_number || !arePathsEqual(originalRoute.path, currentRoute.path) @@ -173,7 +169,6 @@ class MapStore { longitude: geometry.coordinates[0], }; - // ИЗМЕНЕНИЕ: Сравнение координат с допуском if ( originalSight.name !== currentSight.name || originalSight.description !== currentSight.description || diff --git a/src/pages/Media/MediaEditPage/index.tsx b/src/pages/Media/MediaEditPage/index.tsx index 1f4b6e7..26237f3 100644 --- a/src/pages/Media/MediaEditPage/index.tsx +++ b/src/pages/Media/MediaEditPage/index.tsx @@ -33,7 +33,7 @@ export const MediaEditPage = observer(() => { const fileInputRef = useRef(null); const [newFile, setNewFile] = useState(null); - const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const media = id ? mediaStore.media.find((m) => m.id === id) : null; const [mediaName, setMediaName] = useState(media?.media_name ?? ""); @@ -55,51 +55,27 @@ export const MediaEditPage = observer(() => { setMediaFilename(media.filename); setMediaType(media.media_type); - // Set available media types based on current file extension const extension = media.filename.split(".").pop()?.toLowerCase(); if (extension) { if (["glb", "gltf"].includes(extension)) { - setAvailableMediaTypes([6]); // 3D model + setAvailableMediaTypes([6]); } else if ( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( extension ) ) { - setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama + setAvailableMediaTypes([1, 3, 4, 5]); } else if (["mp4", "webm", "mov"].includes(extension)) { - setAvailableMediaTypes([2]); // Video + setAvailableMediaTypes([2]); } } } }, [media]); useEffect(() => { - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); }, []); - // const handleDrop = (e: DragEvent) => { - // e.preventDefault(); - // e.stopPropagation(); - // setIsDragging(false); - - // const files = Array.from(e.dataTransfer.files); - // if (files.length > 0) { - // setNewFile(files[0]); - // setMediaFilename(files[0].name); - // setUploadDialogOpen(true); // Open dialog on file drop - // } - // }; - - // const handleDragOver = (e: DragEvent) => { - // e.preventDefault(); - // setIsDragging(true); - // }; - - // const handleDragLeave = () => { - // setIsDragging(false); - // }; - const handleFileSelect = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { @@ -107,26 +83,25 @@ export const MediaEditPage = observer(() => { setNewFile(file); setMediaFilename(file.name); - // Determine media type based on file extension const extension = file.name.split(".").pop()?.toLowerCase(); if (extension) { if (["glb", "gltf"].includes(extension)) { - setAvailableMediaTypes([6]); // 3D model + setAvailableMediaTypes([6]); setMediaType(6); } else if ( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( extension ) ) { - setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama - setMediaType(1); // Default to Photo + setAvailableMediaTypes([1, 3, 4, 5]); + setMediaType(1); } else if (["mp4", "webm", "mov"].includes(extension)) { - setAvailableMediaTypes([2]); // Video + setAvailableMediaTypes([2]); setMediaType(2); } } - setUploadDialogOpen(true); // Open dialog on file selection + setUploadDialogOpen(true); } }; @@ -143,11 +118,6 @@ export const MediaEditPage = observer(() => { type: mediaType, }); - // If a new file was selected, the actual file upload will happen - // via the UploadMediaDialog. We just need to make sure the metadata - // is updated correctly before or after. - // Since the dialog handles the actual upload, we don't call updateMediaFile here. - setSuccess(true); handleUploadSuccess(); } catch (err) { @@ -158,17 +128,15 @@ export const MediaEditPage = observer(() => { }; const handleUploadSuccess = () => { - // After successful upload in the dialog, refresh media data if needed if (id) { mediaStore.getOneMedia(id); } - setNewFile(null); // Clear the new file state after successful upload + setNewFile(null); setUploadDialogOpen(false); setSuccess(true); }; if (!media && id) { - // Only show loading if an ID is present and media is not yet loaded return ( diff --git a/src/pages/Route/LinekedStations.tsx b/src/pages/Route/LinekedStations.tsx index edb0926..1ff13b1 100644 --- a/src/pages/Route/LinekedStations.tsx +++ b/src/pages/Route/LinekedStations.tsx @@ -42,7 +42,6 @@ import { } from "@shared"; import { EditStationModal } from "../../widgets/modals/EditStationModal"; -// Helper function to insert an item at a specific position (1-based index) function insertAtPosition(arr: T[], pos: number, value: T): T[] { const index = pos - 1; const result = [...arr]; @@ -54,7 +53,6 @@ function insertAtPosition(arr: T[], pos: number, value: T): T[] { return result; } -// Helper function to reorder items after drag and drop const reorder = (list: T[], startIndex: number, endIndex: number): T[] => { const result = Array.from(list); const [removed] = result.splice(startIndex, 1); @@ -152,13 +150,11 @@ const LinkedItemsContentsInner = < const availableItems = allItems .filter((item) => !linkedItems.some((linked) => linked.id === item.id)) .filter((item) => { - // Если направление маршрута не указано, показываем все станции if (routeDirection === undefined) return true; - // Фильтруем станции по направлению маршрута + return item.direction === routeDirection; }) .filter((item) => { - // Фильтруем по городу из навбара const selectedCityId = selectedCityStore.selectedCityId; if (selectedCityId && "city_id" in item) { return item.city_id === selectedCityId; @@ -167,7 +163,6 @@ const LinkedItemsContentsInner = < }) .sort((a, b) => a.name.localeCompare(b.name)); - // Фильтрация по поиску для массового режима const filteredAvailableItems = availableItems.filter((item) => { if (!searchQuery.trim()) return true; return String(item.name).toLowerCase().includes(searchQuery.toLowerCase()); @@ -562,7 +557,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..f8317cc 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(""); @@ -113,7 +113,6 @@ export const RouteCreatePage = observer(() => { const handleArticleSelect = (articleId: number) => { setGovernorAppeal(articleId.toString()); setIsSelectArticleDialogOpen(false); - // Обновляем список статей после создания новой articlesStore.getArticleList(); }; @@ -154,23 +153,72 @@ export const RouteCreatePage = observer(() => { const handleCreateRoute = async () => { try { setIsLoading(true); - // Преобразуем значения в нужные типы + + 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; + + 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 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"; - const validationResult = validateCoordinates(routeCoords); - if (validationResult !== true) { - toast.error(validationResult); - return; - } - - // Координаты маршрута как массив массивов чисел const path = routeCoords .trim() .split("\n") @@ -182,7 +230,6 @@ export const RouteCreatePage = observer(() => { return [lat, lon]; }); - // Собираем объект маршрута const newRoute: Partial = { carrier: carrierStore.carriers[ @@ -194,8 +241,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, @@ -215,7 +262,6 @@ export const RouteCreatePage = observer(() => { } }; - // Получаем название выбранной статьи для отображения const selectedArticle = articlesStore.articleList.ru.data.find( (article) => article.id === Number(governorAppeal) ); @@ -371,14 +417,39 @@ 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 + ? "Максимальный масштаб не может быть меньше минимального" + : "" + } /> (undefined); useEffect(() => { @@ -68,7 +66,7 @@ export function InfiniteCanvas({ const handlePointerDown = (e: FederatedMouseEvent) => { setIsPointerDown(true); setIsDragging(false); - setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя + setIsUserInteracting(true); setStartPosition({ x: position.x, y: position.y, @@ -81,13 +79,9 @@ export function InfiniteCanvas({ e.stopPropagation(); }; - // Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя useEffect(() => { const newRotation = originalRouteData?.rotate ?? 0; - // Обновляем rotation только если: - // 1. Пользователь не взаимодействует с канвасом - // 2. Значение действительно изменилось if (!isUserInteracting && lastOriginalRotation.current !== newRotation) { setRotation((newRotation * Math.PI) / 180); lastOriginalRotation.current = newRotation; @@ -97,7 +91,6 @@ export function InfiniteCanvas({ const handlePointerMove = (e: FederatedMouseEvent) => { if (!isPointerDown) return; - // Проверяем, началось ли перетаскивание if (!isDragging) { const dx = e.globalX - startMousePosition.x; const dy = e.globalY - startMousePosition.y; @@ -119,10 +112,8 @@ export function InfiniteCanvas({ e.globalX - center.x ); - // Calculate rotation difference in radians const rotationDiff = currentAngle - startAngle; - // Update rotation setRotation(startRotation + rotationDiff); const cosDelta = Math.cos(rotationDiff); @@ -149,15 +140,13 @@ export function InfiniteCanvas({ }; const handlePointerUp = (e: FederatedMouseEvent) => { - // Если не было перетаскивания, то это простой клик - закрываем виджет if (!isDragging) { setSelectedSight(undefined); } setIsPointerDown(false); setIsDragging(false); - // Сбрасываем флаг взаимодействия через небольшую задержку - // чтобы избежать немедленного срабатывания useEffect + setTimeout(() => { setIsUserInteracting(false); }, 100); @@ -166,29 +155,25 @@ export function InfiniteCanvas({ const handleWheel = (e: FederatedWheelEvent) => { e.stopPropagation(); - setIsUserInteracting(true); // Устанавливаем флаг при зуме + setIsUserInteracting(true); - // Get mouse position relative to canvas const mouseX = e.globalX - position.x; const mouseY = e.globalY - position.y; - // Calculate new scale const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR; const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR; - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor)); const actualZoomFactor = newScale / scale; if (scale === newScale) { - // Сбрасываем флаг, если зум не изменился setTimeout(() => { setIsUserInteracting(false); }, 100); return; } - // Update position to zoom towards mouse cursor setPosition({ x: position.x + mouseX * (1 - actualZoomFactor), y: position.y + mouseY * (1 - actualZoomFactor), @@ -196,7 +181,6 @@ export function InfiniteCanvas({ setScale(newScale); - // Сбрасываем флаг взаимодействия через задержку setTimeout(() => { setIsUserInteracting(false); }, 100); diff --git a/src/pages/Route/route-preview/MapDataContext.tsx b/src/pages/Route/route-preview/MapDataContext.tsx index 1e1f7b1..dfe7de7 100644 --- a/src/pages/Route/route-preview/MapDataContext.tsx +++ b/src/pages/Route/route-preview/MapDataContext.tsx @@ -141,7 +141,6 @@ export const MapDataProvider = observer( }, [routeId]); useEffect(() => { - // combine changes with original data if (originalRouteData) setRouteData({ ...originalRouteData, ...routeChanges }); if (originalSightData) setSightData(originalSightData); diff --git a/src/pages/Route/route-preview/RightSidebar.tsx b/src/pages/Route/route-preview/RightSidebar.tsx index 54162b1..dd09d29 100644 --- a/src/pages/Route/route-preview/RightSidebar.tsx +++ b/src/pages/Route/route-preview/RightSidebar.tsx @@ -37,11 +37,9 @@ export function RightSidebar() { useEffect(() => { if (originalRouteData) { - // Проверяем и сбрасываем минимальный масштаб если нужно const originalMinScale = originalRouteData.scale_min ?? 1; const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale; - // Проверяем и сбрасываем максимальный масштаб если нужно const originalMaxScale = originalRouteData.scale_max ?? 5; const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale; @@ -118,7 +116,7 @@ export function RightSidebar() { borderRadius={2} > - Детали о достопримечательностях + Настройка маршрута @@ -130,7 +128,6 @@ export function RightSidebar() { onChange={(e) => { let newMinScale = Number(e.target.value); - // Сбрасываем к 1 если меньше if (newMinScale < 1) { newMinScale = 1; } @@ -139,10 +136,10 @@ export function RightSidebar() { if (maxScale - newMinScale < 2) { let newMaxScale = newMinScale + 2; - // Сбрасываем максимальный к 3 если меньше минимального + if (newMaxScale < 3) { newMaxScale = 3; - setMinScale(1); // Сбрасываем минимальный к 1 + setMinScale(1); } setMaxScale(newMaxScale); } @@ -175,7 +172,6 @@ export function RightSidebar() { onChange={(e) => { let newMaxScale = Number(e.target.value); - // Сбрасываем к 3 если меньше минимального if (newMaxScale < 3) { newMaxScale = 3; } @@ -184,10 +180,10 @@ export function RightSidebar() { if (newMaxScale - minScale < 2) { let newMinScale = newMaxScale - 2; - // Сбрасываем минимальный к 1 если меньше + if (newMinScale < 1) { newMinScale = 1; - setMaxScale(3); // Сбрасываем максимальный к минимальному значению + setMaxScale(3); } setMinScale(newMinScale); } diff --git a/src/pages/Route/route-preview/Station.tsx b/src/pages/Route/route-preview/Station.tsx index 1e51b67..41fd367 100644 --- a/src/pages/Route/route-preview/Station.tsx +++ b/src/pages/Route/route-preview/Station.tsx @@ -2,7 +2,6 @@ import { FederatedMouseEvent, Graphics } from "pixi.js"; import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; -// --- Заглушки для зависимостей (замените на ваши реальные импорты) --- import { BACKGROUND_COLOR, PATH_COLOR, @@ -15,22 +14,16 @@ import { StationData } from "./types"; import { useMapData } from "./MapDataContext"; import { coordinatesToLocal } from "./utils"; import { languageStore } from "@shared"; -// --- Конец заглушек --- -// --- Декларации для react-pixi --- -// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi) declare const pixiContainer: any; declare const pixiGraphics: any; declare const pixiText: any; -// --- Типы --- type HorizontalAlign = "left" | "center" | "right"; type VerticalAlign = "top" | "center" | "bottom"; type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`; type LabelAlign = "left" | "center" | "right"; -// --- Утилиты --- - /** * Преобразует текстовое позиционирование в anchor координаты. */ @@ -39,8 +32,6 @@ type LabelAlign = "left" | "center" | "right"; * Получает координату anchor.x из типа выравнивания. */ -// --- Интерфейсы пропсов --- - interface StationProps { station: StationData; ruLabel: string | null; @@ -83,10 +74,6 @@ const getAnchorFromOffset = ( return { x: (1 - nx) / 2, y: (1 - ny) / 2 }; }; -// ========================================================================= -// Компонент: Панель управления выравниванием в стиле УрФУ -// ========================================================================= - const LabelAlignmentControl: FC = ({ scale, currentAlign, @@ -107,7 +94,6 @@ const LabelAlignmentControl: FC = ({ (g: Graphics) => { g.clear(); - // Основной фон с градиентом g.roundRect( -controlWidth / 2, 0, @@ -115,9 +101,8 @@ const LabelAlignmentControl: FC = ({ controlHeight, borderRadius ); - g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ + g.fill({ color: "#1a1a1a" }); - // Тонкая рамка g.roundRect( -controlWidth / 2, 0, @@ -127,7 +112,6 @@ const LabelAlignmentControl: FC = ({ ); g.stroke({ color: "#333333", width: strokeWidth }); - // Разделители между кнопками for (let i = 1; i < 3; i++) { const x = -controlWidth / 2 + buttonWidth * i; g.moveTo(x, strokeWidth); @@ -151,7 +135,7 @@ const LabelAlignmentControl: FC = ({ controlHeight - strokeWidth * 2, borderRadius / 2 ); - g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ + g.fill({ color: "#0066cc", alpha: 0.8 }); } }, [controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius] @@ -230,10 +214,6 @@ const LabelAlignmentControl: FC = ({ ); }; -// ========================================================================= -// Компонент: Метка Станции (с логикой) -// ========================================================================= - const StationLabel = observer( ({ station, @@ -274,48 +254,45 @@ const StationLabel = observer( hideTimer.current = null; } setIsHovered(true); - onTextHover?.(true); // Call the callback to indicate text is hovered + onTextHover?.(true); }; const handleControlPointerEnter = () => { - // Дополнительная обработка для панели управления if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; } setIsControlHovered(true); setIsHovered(true); - onTextHover?.(true); // Call the callback to indicate text/control is hovered + onTextHover?.(true); }; const handleControlPointerLeave = () => { setIsControlHovered(false); - // Если курсор не над основным контейнером, скрываем панель через некоторое время + if (!isHovered) { hideTimer.current = setTimeout(() => { setIsHovered(false); - onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered + onTextHover?.(false); }, 0); } }; const handlePointerLeave = () => { - // Увеличиваем время до скрытия панели и добавляем проверку hideTimer.current = setTimeout(() => { setIsHovered(false); - // Если курсор не над панелью управления, скрываем и её + if (!isControlHovered) { setIsControlHovered(false); } - onTextHover?.(false); // Call the callback to indicate text is no longer hovered - }, 100); // Увеличиваем время до скрытия панели + onTextHover?.(false); + }, 100); }; useEffect(() => { setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 }); }, [station.offset_x, station.offset_y, station.id]); - // Функция для конвертации числового align в строковый const convertNumericAlign = (align: number): LabelAlign => { switch (align) { case 0: @@ -329,7 +306,6 @@ const StationLabel = observer( } }; - // Функция для конвертации строкового align в числовой const convertStringAlign = (align: LabelAlign): number => { switch (align) { case "left": @@ -353,7 +329,6 @@ const StationLabel = observer( const compensatedRuFontSize = (26 * 0.75) / scale; const compensatedNameFontSize = (16 * 0.75) / scale; - // Измеряем ширину верхнего лейбла useEffect(() => { if (ruLabelRef.current && ruLabel) { setRuLabelWidth(ruLabelRef.current.width); @@ -386,7 +361,6 @@ const StationLabel = observer( y: dragStartPos.current.y + dy_screen, }; - // Проверяем, изменилась ли позиция if ( Math.abs(newPosition.x - position.x) > 0.01 || Math.abs(newPosition.y - position.y) > 0.01 @@ -406,7 +380,7 @@ const StationLabel = observer( const handleAlignChange = async (align: LabelAlign) => { setCurrentLabelAlign(align); onLabelAlignChange?.(align); - // Сохраняем в стор + const numericAlign = convertStringAlign(align); setStationAlign(station.id, numericAlign); }; @@ -416,34 +390,29 @@ const StationLabel = observer( [position.x, position.y] ); - // Функция для расчета позиции нижнего лейбла относительно ширины верхнего const getSecondLabelPosition = (): number => { if (!ruLabelWidth) return 0; switch (currentLabelAlign) { case "left": - // Позиционируем относительно левого края верхнего текста return -ruLabelWidth / 2; case "center": - // Центрируем относительно центра верхнего текста return 0; case "right": - // Позиционируем относительно правого края верхнего текста return ruLabelWidth / 2; default: return 0; } }; - // Функция для расчета anchor нижнего лейбла const getSecondLabelAnchor = (): number => { switch (currentLabelAlign) { case "left": - return 0; // anchor.x = 0 (левый край) + return 0; case "center": - return 0.5; // anchor.x = 0.5 (центр) + return 0.5; case "right": - return 1; // anchor.x = 1 (правый край) + return 1; default: return 0.5; } @@ -522,10 +491,6 @@ const StationLabel = observer( } ); -// ========================================================================= -// Главный экспортируемый компонент: Станция -// ========================================================================= - export const Station = ({ station, ruLabel, @@ -548,10 +513,9 @@ export const Station = ({ g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius); - // Change fill color when text is hovered if (isTextHovered) { - g.fill({ color: 0x00aaff }); // Highlight color when hovered - g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered + g.fill({ color: 0x00aaff }); + g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); } else { g.fill({ color: PATH_COLOR }); g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth }); diff --git a/src/pages/Route/route-preview/TransformContext.tsx b/src/pages/Route/route-preview/TransformContext.tsx index 8655b18..7ee2ac5 100644 --- a/src/pages/Route/route-preview/TransformContext.tsx +++ b/src/pages/Route/route-preview/TransformContext.tsx @@ -50,7 +50,6 @@ const TransformContext = createContext<{ setScaleAtCenter: () => {}, }); -// Provider component export const TransformProvider = ({ children }: { children: ReactNode }) => { const [position, setPosition] = useState({ x: 0, y: 0 }); const [scale, setScale] = useState(1); @@ -59,12 +58,10 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { const screenToLocal = useCallback( (screenX: number, screenY: number) => { - // Translate point relative to current pan position const translatedX = (screenX - position.x) / scale; const translatedY = (screenY - position.y) / scale; - // Rotate point around center - const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform + const cosRotation = Math.cos(-rotation); const sinRotation = Math.sin(-rotation); const rotatedX = translatedX * cosRotation - translatedY * sinRotation; const rotatedY = translatedX * sinRotation + translatedY * cosRotation; @@ -77,7 +74,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { [position.x, position.y, scale, rotation] ); - // Inverse of screenToLocal const localToScreen = useCallback( (localX: number, localY: number) => { const upscaledX = localX * UP_SCALE; @@ -120,7 +116,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { (currentFromPosition.x - center.x) * sinDelta, }; - // Update both rotation and position in a single batch to avoid stale closure setRotation(to); setPosition(newPosition); }, @@ -150,13 +145,11 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { const cosRot = Math.cos(selectedRotation); const sinRot = Math.sin(selectedRotation); - // Translate point relative to center, rotate, then translate back const dx = newPosition.x; const dy = newPosition.y; newPosition.x = dx * cosRot - dy * sinRot + center.x; newPosition.y = dx * sinRot + dy * cosRot + center.y; - // Batch state updates to avoid intermediate renders setPosition(newPosition); setRotation(selectedRotation); setScale(selectedScale); @@ -184,7 +177,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { ); const setScaleOnly = useCallback((newScale: number) => { - // Изменяем только масштаб, не трогая позицию и поворот setScale(newScale); }, []); @@ -237,7 +229,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { ); }; -// Custom hook for easy access to transform values export const useTransform = () => { const context = useContext(TransformContext); if (!context) { 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..8c4190b --- /dev/null +++ b/src/pages/Route/route-preview/web-gl/web-gl-version.tsx @@ -0,0 +1,1725 @@ +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(); + + const scaleLimitsRef = useRef({ + min: null as number | null, + max: null as number | null, + }); + + 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); + + 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); + + 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, + ]); + + 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]); + + useEffect(() => { + const interval = setInterval(() => { + const timeSinceActivity = Date.now() - userActivityTimestamp; + if (timeSinceActivity >= 5000 && !isAutoMode) { + setIsAutoMode(true); + } + }, 1000); + + return () => clearInterval(interval); + }, [userActivityTimestamp, isAutoMode, setIsAutoMode]); + + useEffect(() => { + if (cameraAnimationStore.isActivelyAnimating) { + return; + } + + if (isAutoMode && transformedTramCoords && screenCenter) { + 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, + ]); + + 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, + ]); + + 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, + ]); + + 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; + 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) { + const generateThickLine = (points: Float32Array, width: number) => { + const vertices: number[] = []; + const halfWidth = width / 2; + + if (points.length < 4) return new Float32Array(); + + 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]; + + 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; + + vertices.push(x1 + perpX, y1 + perpY); + vertices.push(x1 - perpX, y1 - perpY); + vertices.push(x2 + perpX, y2 + perpY); + + vertices.push(x1 - perpX, y1 - perpY); + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 + perpX, y2 + perpY); + + 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; + + 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); + } + } + } + + 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); + + 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); + + gl.uniform1f(u_pointSize, 8.0 * scale * 1.5); + + if (tramSegIndex >= 0) { + const passedStations = []; + for (let i = 0; i < stationData.length; i++) { + if (i <= tramSegIndex) { + 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); + } + } + + if (tramSegIndex >= 0) { + const unpassedStations = []; + for (let i = 0; i < stationData.length; i++) { + if (i > tramSegIndex) { + unpassedStations.push( + 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 { + 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); + + 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); + + 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[] = []; + + 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); + } + + 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) { + const terminalStationData: any[] = []; + if (startStationData) terminalStationData.push(startStationData); + if (endStationData) terminalStationData.push(endStationData); + + 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; + + 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; + } + + 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); + + gl.uniform1f(u_pointSize, 18.0 * scale); + if (startStationData && endStationData) { + 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); + + 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); + } else { + 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); + } + + 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); + gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); + } + } + } + + 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 }, + }; + } + + 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) => { + 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(); + 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/pages/Station/StationCreatePage/index.tsx b/src/pages/Station/StationCreatePage/index.tsx index 94f1864..09d85d5 100644 --- a/src/pages/Station/StationCreatePage/index.tsx +++ b/src/pages/Station/StationCreatePage/index.tsx @@ -35,7 +35,7 @@ export const StationCreatePage = observer(() => { const { cities, getCities } = cityStore; const { selectedCityId, selectedCity } = useSelectedCity(); const [coordinates, setCoordinates] = useState(""); - // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА + const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); useEffect(() => { @@ -49,7 +49,6 @@ export const StationCreatePage = observer(() => { } }, [createStationData.common.latitude, createStationData.common.longitude]); - // НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения) const executeCreate = async () => { try { setIsLoading(true); @@ -64,11 +63,13 @@ export const StationCreatePage = observer(() => { } }; - // ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания const handleCreate = async () => { const isCityMissing = !createStationData.common.city_id; - // Проверяем названия на всех языках - const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name; + + const isNameMissing = + !createStationData.ru.name || + !createStationData.en.name || + !createStationData.zh.name; if (isCityMissing || isNameMissing) { setIsSaveWarningOpen(true); @@ -78,13 +79,11 @@ export const StationCreatePage = observer(() => { await executeCreate(); }; - // Обработчик "Да" в предупреждающем окне const handleConfirmCreate = async () => { setIsSaveWarningOpen(false); await executeCreate(); }; - // Обработчик "Нет" в предупреждающем окне const handleCancelCreate = () => { setIsSaveWarningOpen(false); }; @@ -99,7 +98,6 @@ export const StationCreatePage = observer(() => { fetchCities(); }, []); - // Автоматически устанавливаем выбранный город при загрузке страницы useEffect(() => { if (selectedCityId && selectedCity && !createStationData.common.city_id) { setCreateCommonData({ @@ -237,7 +235,7 @@ export const StationCreatePage = observer(() => { className="w-min flex gap-2 items-center" startIcon={} onClick={handleCreate} - disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate + disabled={isLoading} > {isLoading ? ( diff --git a/src/pages/Station/StationEditPage/index.tsx b/src/pages/Station/StationEditPage/index.tsx index 03ba50e..69d3275 100644 --- a/src/pages/Station/StationEditPage/index.tsx +++ b/src/pages/Station/StationEditPage/index.tsx @@ -32,7 +32,7 @@ export const StationEditPage = observer(() => { } = stationsStore; const { cities, getCities } = cityStore; const [coordinates, setCoordinates] = useState(""); - // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА + const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); useEffect(() => { @@ -50,7 +50,6 @@ export const StationEditPage = observer(() => { } }, [editStationData.common.latitude, editStationData.common.longitude]); - // НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения) const executeEdit = async () => { try { setIsLoading(true); @@ -64,10 +63,9 @@ export const StationEditPage = observer(() => { } }; - // ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования const handleEdit = async () => { const isCityMissing = !editStationData.common.city_id; - // Проверяем названия на всех языках + const isNameMissing = !editStationData.ru.name || !editStationData.en.name || @@ -81,13 +79,11 @@ export const StationEditPage = observer(() => { await executeEdit(); }; - // Обработчик "Да" в предупреждающем окне const handleConfirmEdit = async () => { setIsSaveWarningOpen(false); await executeEdit(); }; - // Обработчик "Нет" в предупреждающем окне const handleCancelEdit = () => { setIsSaveWarningOpen(false); }; @@ -243,7 +239,7 @@ export const StationEditPage = observer(() => { className="w-min flex gap-2 items-center" startIcon={} onClick={handleEdit} - disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit + disabled={isLoading} > {isLoading ? ( diff --git a/src/shared/config/constants.tsx b/src/shared/config/constants.tsx index 4c9a8fb..9edef16 100644 --- a/src/shared/config/constants.tsx +++ b/src/shared/config/constants.tsx @@ -8,13 +8,10 @@ import { Earth, Landmark, GitBranch, - // Car, Table, Split, - // Newspaper, PersonStanding, Cpu, - // BookImage, } from "lucide-react"; import carrierIcon from "./carrier.svg"; @@ -57,12 +54,6 @@ export const NAVIGATION_ITEMS: { path: "/devices", for_admin: true, }, - // { - // id: "vehicles", - // label: "Транспорт", - // icon: Car, - // path: "/vehicle", - // }, { id: "users", label: "Пользователи", @@ -75,18 +66,6 @@ export const NAVIGATION_ITEMS: { label: "Справочник", icon: Table, nestedItems: [ - // { - // id: "media", - // label: "Медиа", - // icon: BookImage, - // path: "/media", - // }, - // { - // id: "articles", - // label: "Статьи", - // icon: Newspaper, - // path: "/article", - // }, { id: "attractions", label: "Достопримечательности", @@ -124,7 +103,7 @@ export const NAVIGATION_ITEMS: { id: "carriers", label: "Перевозчики", // @ts-ignore - icon: () => Перевозчики, + icon: () => Перевозчики, path: "/carrier", for_admin: true, }, diff --git a/src/shared/lib/gltfCacheManager.ts b/src/shared/lib/gltfCacheManager.ts index 6550252..f9d0b66 100644 --- a/src/shared/lib/gltfCacheManager.ts +++ b/src/shared/lib/gltfCacheManager.ts @@ -1,8 +1,3 @@ -/** - * Утилита для управления кешем GLTF и blob URL - */ - -// Динамический импорт useGLTF для избежания проблем с SSR let useGLTF: any = null; const initializeUseGLTF = async () => { @@ -20,9 +15,6 @@ const initializeUseGLTF = async () => { return useGLTF; }; -/** - * Очищает кеш GLTF для конкретного URL - */ export const clearGLTFCacheForUrl = async (url: string) => { try { const gltf = await initializeUseGLTF(); @@ -32,9 +24,6 @@ export const clearGLTFCacheForUrl = async (url: string) => { } catch (error) {} }; -/** - * Очищает весь кеш GLTF - */ export const clearAllGLTFCache = async () => { try { const gltf = await initializeUseGLTF(); @@ -44,9 +33,6 @@ export const clearAllGLTFCache = async () => { } catch (error) {} }; -/** - * Очищает blob URL из памяти браузера - */ export const revokeBlobURL = (url: string) => { if (url && url.startsWith("blob:")) { try { @@ -55,25 +41,16 @@ export const revokeBlobURL = (url: string) => { } }; -/** - * Комплексная очистка: blob URL + кеш GLTF - */ export const clearBlobAndGLTFCache = async (url: string) => { - // Сначала отзываем blob URL revokeBlobURL(url); - // Затем очищаем кеш GLTF await clearGLTFCacheForUrl(url); }; -/** - * Очистка при смене медиа (для предотвращения конфликтов) - */ export const clearMediaTransitionCache = async ( previousMediaId: string | number | null, newMediaType?: number ) => { - // Если переключаемся с/на 3D модель, очищаем весь кеш if (newMediaType === 6 || previousMediaId) { await clearAllGLTFCache(); } diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index e21c768..a58d0fc 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -2,34 +2,17 @@ export * from "./mui/theme"; export * from "./DecodeJWT"; export * from "./gltfCacheManager"; -/** - * Генерирует название медиа по умолчанию в разных форматах - * - * Примеры использования: - * - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото" - * - Для достопримечательности без названия: "Название_mikhail-zamok_Фото" - * - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи) - * - * @param objectName - Название объекта (достопримечательности, города и т.д.) - * @param fileName - Название файла - * @param mediaType - Тип медиа (число) или название статьи - * @param isArticle - Флаг, указывающий что медиа добавляется к статье - * @returns Строка в нужном формате - */ export const generateDefaultMediaName = ( objectName: string, fileName: string, mediaType: number | string, isArticle: boolean = false ): string => { - // Убираем расширение из названия файла const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); if (isArticle && typeof mediaType === "string") { - // Для статей: "Название достопримечательности_название файла_название статьи" return `${objectName}_${fileNameWithoutExtension}_${mediaType}`; } else if (typeof mediaType === "number") { - // Получаем название типа медиа const mediaTypeLabels: Record = { 1: "Фото", 2: "Видео", @@ -42,14 +25,11 @@ export const generateDefaultMediaName = ( const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа"; if (objectName && objectName.trim() !== "") { - // Если есть название объекта: "Название объекта_название файла_тип медиа" return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`; } else { - // Если нет названия объекта: "Название_название файла_тип медиа" return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`; } } - // Fallback return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`; }; diff --git a/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx b/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx index b100909..e1eee1f 100644 --- a/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx +++ b/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx @@ -523,7 +523,6 @@ export const ArticleSelectOrCreateDialog = observer( article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase()) ); - // Preview-by-click logic with request serialization to avoid concurrent requests const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [queuedPreviewId, setQueuedPreviewId] = useState(null); const clickTimerRef = (typeof window !== "undefined" @@ -551,7 +550,7 @@ export const ArticleSelectOrCreateDialog = observer( if (queuedPreviewId && queuedPreviewId !== articleId) { const nextId = queuedPreviewId; setQueuedPreviewId(null); - // Run the next queued preview + runPreviewFetch(nextId); } else { setQueuedPreviewId(null); @@ -560,7 +559,6 @@ export const ArticleSelectOrCreateDialog = observer( }; const handleListItemClick = (articleId: number) => { - // Delay to allow double-click to cancel preview if (clickTimerRef.current) clearTimeout(clickTimerRef.current); clickTimerRef.current = setTimeout(() => { if (tabValue === 0 && !selectedArticleId && !tempArticleId) { @@ -570,7 +568,6 @@ export const ArticleSelectOrCreateDialog = observer( }; const handleListItemDoubleClick = (articleId: number) => { - // Cancel pending single-click preview and proceed to select if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); (clickTimerRef as any).current = null; diff --git a/src/shared/modals/UploadMediaDialog/index.tsx b/src/shared/modals/UploadMediaDialog/index.tsx index fa9efd4..62e1cdc 100644 --- a/src/shared/modals/UploadMediaDialog/index.tsx +++ b/src/shared/modals/UploadMediaDialog/index.tsx @@ -54,7 +54,7 @@ interface UploadMediaDialogProps { | "station"; isArticle?: boolean; articleName?: string; - initialFile?: File; // <--- добавлено + initialFile?: File; } export const UploadMediaDialog = observer( @@ -68,7 +68,7 @@ export const UploadMediaDialog = observer( isArticle, articleName, - initialFile, // <--- добавлено + initialFile, }: UploadMediaDialogProps) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -87,7 +87,6 @@ export const UploadMediaDialog = observer( useEffect(() => { if (initialFile) { - // Очищаем предыдущий blob URL если он существует if ( previousMediaUrlRef.current && previousMediaUrlRef.current.startsWith("blob:") @@ -106,7 +105,6 @@ export const UploadMediaDialog = observer( } }, [initialFile]); - // Очистка blob URL при размонтировании компонента useEffect(() => { return () => { if ( @@ -116,13 +114,13 @@ export const UploadMediaDialog = observer( clearBlobAndGLTFCache(previousMediaUrlRef.current); } }; - }, []); // Пустой массив зависимостей - выполняется только при размонтировании + }, []); useEffect(() => { if (fileToUpload) { setMediaFile(fileToUpload); setMediaFilename(fileToUpload.name); - // Try to determine media type from file extension + const extension = fileToUpload.name.split(".").pop()?.toLowerCase(); if (extension) { if (["glb", "gltf"].includes(extension)) { @@ -134,22 +132,18 @@ export const UploadMediaDialog = observer( extension ) ) { - // Для изображений доступны все типы кроме видео - setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель - setMediaType(1); // По умолчанию Фото + setAvailableMediaTypes([1, 3, 4, 5]); + setMediaType(1); } else if (["mp4", "webm", "mov"].includes(extension)) { - // Для видео только тип Видео setAvailableMediaTypes([2]); setMediaType(2); } } - // Генерируем название по умолчанию если есть контекст if (fileToUpload.name) { let defaultName = ""; if (isArticle && articleName && contextObjectName) { - // Для статей: "Название достопримечательности_название файла_название статьи" defaultName = generateDefaultMediaName( contextObjectName, fileToUpload.name, @@ -157,10 +151,9 @@ export const UploadMediaDialog = observer( true ); } else if (contextObjectName && contextObjectName.trim() !== "") { - // Для обычных медиа с названием объекта const currentMediaType = hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] - : 1; // По умолчанию фото + : 1; defaultName = generateDefaultMediaName( contextObjectName, fileToUpload.name, @@ -168,10 +161,9 @@ export const UploadMediaDialog = observer( false ); } else { - // Для медиа без названия объекта const currentMediaType = hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] - : 1; // По умолчанию фото + : 1; defaultName = generateDefaultMediaName( "", fileToUpload.name, @@ -185,13 +177,11 @@ export const UploadMediaDialog = observer( } }, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]); - // Обновляем название при изменении типа медиа useEffect(() => { if (mediaFilename && mediaType > 0) { let defaultName = ""; if (isArticle && articleName && contextObjectName) { - // Для статей: "Название достопримечательности_название файла_название статьи" defaultName = generateDefaultMediaName( contextObjectName, mediaFilename, @@ -199,7 +189,6 @@ export const UploadMediaDialog = observer( true ); } else if (contextObjectName && contextObjectName.trim() !== "") { - // Для обычных медиа с названием объекта const currentMediaType = hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] : mediaType; @@ -210,7 +199,6 @@ export const UploadMediaDialog = observer( false ); } else { - // Для медиа без названия объекта const currentMediaType = hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] : mediaType; @@ -235,7 +223,6 @@ export const UploadMediaDialog = observer( useEffect(() => { if (mediaFile) { - // Очищаем предыдущий blob URL и кеш GLTF если он существует if ( previousMediaUrlRef.current && previousMediaUrlRef.current.startsWith("blob:") @@ -245,22 +232,10 @@ export const UploadMediaDialog = observer( const newBlobUrl = URL.createObjectURL(mediaFile as Blob); setMediaUrl(newBlobUrl); - previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref - setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла + previousMediaUrlRef.current = newBlobUrl; + setIsPreviewLoaded(false); } - }, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания - - // const fileFormat = useEffect(() => { - // const handleKeyPress = (event: KeyboardEvent) => { - // if (event.key.toLowerCase() === "enter" && !event.ctrlKey) { - // event.preventDefault(); - // onClose(); - // } - // }; - - // window.addEventListener("keydown", handleKeyPress); - // return () => window.removeEventListener("keydown", handleKeyPress); - // }, [onClose]); + }, [mediaFile]); const handleSave = async () => { if (!mediaFile) return; @@ -285,10 +260,10 @@ export const UploadMediaDialog = observer( } } setSuccess(true); - // Закрываем модальное окно после успешного сохранения + setTimeout(() => { handleClose(); - }, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе + }, 1000); } catch (err) { setError(err instanceof Error ? err.message : "Failed to save media"); } finally { @@ -297,7 +272,6 @@ export const UploadMediaDialog = observer( }; const handleClose = () => { - // Очищаем blob URL и кеш GLTF при закрытии диалога if ( previousMediaUrlRef.current && previousMediaUrlRef.current.startsWith("blob:") @@ -310,7 +284,7 @@ export const UploadMediaDialog = observer( setMediaUrl(null); setMediaFile(null); setIsPreviewLoaded(false); - previousMediaUrlRef.current = null; // Очищаем ref + previousMediaUrlRef.current = null; onClose(); }; diff --git a/src/shared/store/CarrierStore/index.tsx b/src/shared/store/CarrierStore/index.tsx index 273a3c4..f0886ba 100644 --- a/src/shared/store/CarrierStore/index.tsx +++ b/src/shared/store/CarrierStore/index.tsx @@ -171,7 +171,6 @@ class CarrierStore { this.carriers[language].data.push(response.data); }); - // Create translations for other languages for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) { const patchPayload = { // @ts-ignore diff --git a/src/shared/store/CreateSightStore/index.tsx b/src/shared/store/CreateSightStore/index.tsx index 9b74aad..762cbe4 100644 --- a/src/shared/store/CreateSightStore/index.tsx +++ b/src/shared/store/CreateSightStore/index.tsx @@ -1,4 +1,3 @@ -// @shared/stores/createSightStore.ts import { articlesStore, Language, @@ -27,7 +26,6 @@ type SightLanguageInfo = { }; type SightCommonInfo = { - // id: number; // ID is 0 until created city_id: number; city: string; latitude: number; @@ -35,13 +33,11 @@ type SightCommonInfo = { thumbnail: string | null; watermark_lu: string | null; watermark_rd: string | null; - left_article: number; // Can be 0 or a real ID, or placeholder like 10000000 + left_article: number; preview_media: string | null; video_preview: string | null; }; -// SightBaseInfo combines common info with language-specific info -// The 'id' for the sight itself will be assigned upon creation by the backend. type SightBaseInfo = SightCommonInfo & { [key in Language]: SightLanguageInfo; }; @@ -78,7 +74,7 @@ const initialSightState: SightBaseInfo = { }; class CreateSightStore { - sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset + sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); uploadMediaOpen = false; setUploadMediaOpen = (open: boolean) => { @@ -93,9 +89,7 @@ class CreateSightStore { makeAutoObservable(this); } - // --- Right Article Management --- createNewRightArticle = async () => { - // Create article in DB for all languages const articleRuData = { heading: "Новый заголовок (RU)", body: "Новый текст (RU)", @@ -125,7 +119,7 @@ class CreateSightStore { }, }, }); - const { id } = articleRes.data; // New article's ID + const { id } = articleRes.data; runInAction(() => { const newArticleEntry = { id, media: [] }; @@ -133,7 +127,7 @@ class CreateSightStore { this.sight.en.right.push({ ...newArticleEntry, ...articleEnData }); this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData }); }); - return id; // Return ID for potential immediate use + return id; } catch (error) { console.error("Error creating new right article:", error); throw error; @@ -169,7 +163,7 @@ class CreateSightStore { }); }); - return articleId; // Return the linked article ID + return articleId; } catch (error) { console.error("Error linking existing right article:", error); throw error; @@ -188,9 +182,7 @@ class CreateSightStore { } }; - // "Unlink" in create mode means just removing from the list to be created with the sight unlinkRightAritcle = (articleId: number) => { - // Changed from 'unlinkRightAritcle' spelling runInAction(() => { this.sight.ru.right = this.sight.ru.right.filter( (article) => article.id !== articleId @@ -202,16 +194,12 @@ class CreateSightStore { (article) => article.id !== articleId ); }); - // Note: If this article was created via createNewRightArticle, it still exists in the DB. - // Consider if an orphaned article should be deleted here or managed separately. - // For now, it just removes it from the list associated with *this specific sight creation process*. }; deleteRightArticle = async (articleId: number) => { try { - await authInstance.delete(`/article/${articleId}`); // Delete from backend + await authInstance.delete(`/article/${articleId}`); runInAction(() => { - // Remove from local store for all languages this.sight.ru.right = this.sight.ru.right.filter( (article) => article.id !== articleId ); @@ -228,12 +216,11 @@ class CreateSightStore { } }; - // --- Right Article Media Management --- createLinkWithRightArticle = async (media: MediaItem, articleId: number) => { try { await authInstance.post(`/article/${articleId}/media`, { media_id: media.id, - media_order: 1, // Or calculate based on existing media.length + 1 + media_order: 1, }); runInAction(() => { (["ru", "en", "zh"] as Language[]).forEach((lang) => { @@ -242,7 +229,7 @@ class CreateSightStore { ); if (article) { if (!article.media) article.media = []; - article.media.unshift(media); // Add to the beginning + article.media.unshift(media); } }); }); @@ -273,7 +260,6 @@ class CreateSightStore { } }; - // --- Left Article Management (largely unchanged from your provided store) --- updateLeftInfo = (language: Language, heading: string, body: string) => { this.sight[language].left.heading = heading; this.sight[language].left.body = body; @@ -323,7 +309,7 @@ class CreateSightStore { deleteLeftArticle = async (articleId: number) => { /* ... your existing logic ... */ await authInstance.delete(`/article/${articleId}`); - // articlesStore.getArticles(languageStore.language); // If still neede + runInAction(() => { articlesStore.articles.ru = articlesStore.articles.ru.filter( (article) => article.id !== articleId @@ -344,7 +330,6 @@ class CreateSightStore { const enName = (this.sight.en.name || "").trim(); const zhName = (this.sight.zh.name || "").trim(); - // If all names are empty, skip defaulting and use empty headings const hasAnyName = !!(ruName || enName || zhName); const response = await languageInstance("ru").post("/article", { @@ -363,7 +348,7 @@ class CreateSightStore { }); runInAction(() => { - this.sight.left_article = newLeftArticleId; // Store the actual ID + this.sight.left_article = newLeftArticleId; this.sight.ru.left = { heading: hasAnyName ? ruName : "", body: "", @@ -402,9 +387,8 @@ class CreateSightStore { return newLeftArticleId; }; - // Placeholder for a "new" unsaved left article setNewLeftArticlePlaceholder = () => { - this.sight.left_article = 10000000; // Special placeholder ID + this.sight.left_article = 10000000; this.sight.ru.left = { heading: "Новая левая статья", body: "Заполните контентом", @@ -422,7 +406,6 @@ class CreateSightStore { }; }; - // --- Sight Preview Media --- linkPreviewMedia = (mediaId: string) => { this.sight.preview_media = mediaId; }; @@ -431,32 +414,27 @@ class CreateSightStore { this.sight.preview_media = null; }; - // --- General Store Methods --- clearCreateSight = () => { this.needLeaveAgree = false; - this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial + this.sight = JSON.parse(JSON.stringify(initialSightState)); }; updateSightInfo = ( - content: Partial, // Corrected types + content: Partial, language?: Language ) => { this.needLeaveAgree = true; if (language) { this.sight[language] = { ...this.sight[language], ...content }; } else { - // Assuming content here is for SightCommonInfo this.sight = { ...this.sight, ...(content as Partial) }; } }; - // --- Main Sight Creation Logic --- createSight = async (primaryLanguage: Language) => { let finalLeftArticleId = this.sight.left_article; - // 1. Handle Left Article (Create if new, or use existing ID) if (this.sight.left_article === 10000000) { - // Placeholder for new const res = await languageInstance("ru").post("/article", { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body, @@ -474,7 +452,6 @@ class CreateSightStore { this.sight.left_article !== 0 && this.sight.left_article !== null ) { - // Existing, ensure it's up-to-date await languageInstance("ru").patch( `/article/${this.sight.left_article}`, { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body } @@ -488,10 +465,7 @@ class CreateSightStore { { heading: this.sight.zh.left.heading, body: this.sight.zh.left.body } ); } - // else: left_article is 0, so no left article - // 2. Right articles are already created in DB and their IDs are in this.sight[lang].right. - // We just need to update their content if changed before saving the sight. for (const lang of ["ru", "en", "zh"] as Language[]) { for (const article of this.sight[lang].right) { if (article.id == 0 || article.id == null) { @@ -501,14 +475,12 @@ class CreateSightStore { heading: article.heading, body: article.body, }); - // Media for these articles are already linked via createLinkWithRightArticle } } const rightArticleIdsForLink = this.sight[primaryLanguage].right.map( (a) => a.id ); - // 3. Create Sight object in DB const sightPayload = { city_id: this.sight.city_id, city: this.sight.city, @@ -528,9 +500,8 @@ class CreateSightStore { "/sight", sightPayload ); - const newSightId = response.data.id; // ID of the newly created sight + const newSightId = response.data.id; - // 4. Update other languages for the sight const otherLanguages = (["ru", "en", "zh"] as Language[]).filter( (l) => l !== primaryLanguage ); @@ -551,20 +522,17 @@ class CreateSightStore { }); } - // 5. Link Right Articles to the new Sight for (let i = 0; i < rightArticleIdsForLink.length; i++) { await authInstance.post(`/sight/${newSightId}/article`, { article_id: rightArticleIdsForLink[i], - page_num: i + 1, // Or other logic for page_num + page_num: i + 1, }); } - // Optionally: this.clearCreateSight(); // To reset form after successful creation this.needLeaveAgree = false; return newSightId; }; - // --- Media Upload (Generic, used by dialogs) --- uploadMedia = async ( filename: string, type: number, @@ -583,12 +551,12 @@ class CreateSightStore { this.fileToUpload = null; this.uploadMediaOpen = false; }); - mediaStore.getMedia(); // Refresh global media list + mediaStore.getMedia(); return { id: response.data.id, - filename: filename, // Or response.data.filename if backend returns it - media_name: media_name, // Or response.data.media_name - media_type: type, // Or response.data.type + filename: filename, + media_name: media_name, + media_type: type, }; } catch (error) { console.error("Error uploading media:", error); @@ -596,15 +564,12 @@ class CreateSightStore { } }; - // For Left Article Media createLinkWithLeftArticle = async (media: MediaItem) => { if (!this.sight.left_article || this.sight.left_article === 10000000) { console.warn( "Left article not selected or is a placeholder. Cannot link media yet." ); - // If it's a placeholder, we could store the media temporarily and link it after the article is created. - // For simplicity, we'll assume the article must exist. - // A more robust solution might involve creating the article first if it's a placeholder. + return; } try { @@ -663,7 +628,7 @@ class CreateSightStore { this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.en.right = sortArticles(this.sight.en.right); - this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково + this.sight.zh.right = sortArticles(this.sight.zh.right); this.needLeaveAgree = true; }; diff --git a/src/shared/store/EditSightStore/index.tsx b/src/shared/store/EditSightStore/index.tsx index 6a1a6eb..5c6ab82 100644 --- a/src/shared/store/EditSightStore/index.tsx +++ b/src/shared/store/EditSightStore/index.tsx @@ -1,4 +1,3 @@ -// @shared/stores/editSightStore.ts import { articlesStore, authInstance, @@ -96,13 +95,11 @@ class EditSightStore { } runInAction(() => { - // Обновляем языковую часть this.sight[language] = { ...this.sight[language], ...data, }; - // Только при первом запросе обновляем общую часть if (!this.hasLoadedCommon) { this.sight.common = { ...this.sight.common, @@ -123,7 +120,6 @@ class EditSightStore { let responseEn = await languageInstance("en").get(`/sight/${id}/article`); let responseZh = await languageInstance("zh").get(`/sight/${id}/article`); - // Create a map of article IDs to their media const mediaMap = new Map(); for (const article of responseRu.data) { const responseMedia = await authInstance.get( @@ -132,7 +128,6 @@ class EditSightStore { mediaMap.set(article.id, responseMedia.data); } - // Function to add media to articles const addMediaToArticles = (articles: any[]) => { return articles.map((article) => ({ ...article, @@ -327,28 +322,6 @@ class EditSightStore { articles: articleIdsInObject, }); - // await languageInstance("ru").patch( - // `/sight/${this.sight.common.left_article}/article`, - // { - // heading: this.sight.ru.left.heading, - // body: this.sight.ru.left.body, - // } - // ); - // await languageInstance("en").patch( - // `/sight/${this.sight.common.left_article}/article`, - // { - // heading: this.sight.en.left.heading, - // body: this.sight.en.left.body, - // } - // ); - // await languageInstance("zh").patch( - // `/sight/${this.sight.common.left_article}/article`, - // { - // heading: this.sight.zh.left.heading, - // body: this.sight.zh.left.body, - // } - // ); - this.needLeaveAgree = false; }; @@ -589,7 +562,7 @@ class EditSightStore { }); }); - return article_id; // Return the linked article ID + return article_id; }; deleteRightArticleMedia = async (article_id: number, media_id: string) => { @@ -695,7 +668,7 @@ class EditSightStore { }); }); - return id; // Return the ID of the newly created article + return id; }; createLinkWithRightArticle = async ( @@ -770,7 +743,7 @@ class EditSightStore { this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.en.right = sortArticles(this.sight.en.right); - this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково + this.sight.zh.right = sortArticles(this.sight.zh.right); this.needLeaveAgree = true; }; diff --git a/src/shared/store/MediaStore/index.tsx b/src/shared/store/MediaStore/index.tsx index 7daeb26..bbbb3bb 100644 --- a/src/shared/store/MediaStore/index.tsx +++ b/src/shared/store/MediaStore/index.tsx @@ -39,12 +39,11 @@ class MediaStore { updateMedia = async (id: string, data: Partial) => { const response = await authInstance.patch(`/media/${id}`, data); runInAction(() => { - // Update in media array const index = this.media.findIndex((m) => m.id === id); if (index !== -1) { this.media[index] = { ...this.media[index], ...response.data }; } - // Update oneMedia if it's the current media being viewed + if (this.oneMedia?.id === id) { this.oneMedia = { ...this.oneMedia, ...response.data }; } @@ -64,12 +63,11 @@ class MediaStore { }); runInAction(() => { - // Update in media array const index = this.media.findIndex((m) => m.id === id); if (index !== -1) { this.media[index] = { ...this.media[index], ...response.data }; } - // Update oneMedia if it's the current media being viewed + if (this.oneMedia?.id === id) { this.oneMedia = { ...this.oneMedia, ...response.data }; } diff --git a/src/shared/store/ModelLoadingStore/index.ts b/src/shared/store/ModelLoadingStore/index.ts index a55906b..81c0e27 100644 --- a/src/shared/store/ModelLoadingStore/index.ts +++ b/src/shared/store/ModelLoadingStore/index.ts @@ -15,7 +15,6 @@ class ModelLoadingStore { makeAutoObservable(this); } - // Начать отслеживание загрузки модели startLoading(modelId: string) { this.loadingStates.set(modelId, { isLoading: true, @@ -25,7 +24,6 @@ class ModelLoadingStore { }); } - // Обновить прогресс загрузки updateProgress(modelId: string, progress: number) { const state = this.loadingStates.get(modelId); if (state) { @@ -33,7 +31,6 @@ class ModelLoadingStore { } } - // Завершить загрузку модели finishLoading(modelId: string) { const state = this.loadingStates.get(modelId); if (state) { @@ -42,12 +39,10 @@ class ModelLoadingStore { } } - // Остановить загрузку (в случае ошибки) stopLoading(modelId: string) { this.loadingStates.delete(modelId); } - // Обработать ошибку загрузки handleError(modelId: string, error?: string) { const state = this.loadingStates.get(modelId); if (state) { @@ -56,26 +51,22 @@ class ModelLoadingStore { } } - // Получить состояние загрузки для конкретной модели getLoadingState(modelId: string): ModelLoadingState | undefined { return this.loadingStates.get(modelId); } - // Проверить, загружается ли какая-либо модель get isAnyModelLoading(): boolean { return Array.from(this.loadingStates.values()).some( (state) => state.isLoading ); } - // Получить все загружающиеся модели get loadingModels(): ModelLoadingState[] { return Array.from(this.loadingStates.values()).filter( (state) => state.isLoading ); } - // Получить общий прогресс всех загружающихся моделей get overallProgress(): number { const loadingModels = this.loadingModels; if (loadingModels.length === 0) return 100; @@ -87,12 +78,10 @@ class ModelLoadingStore { return Math.round(totalProgress / loadingModels.length); } - // Проверить, заблокировано ли сохранение (есть ли загружающиеся модели) get isSaveBlocked(): boolean { return this.isAnyModelLoading; } - // Очистить все состояния загрузки clearAll() { this.loadingStates.clear(); } diff --git a/src/shared/store/SightsStore/index.tsx b/src/shared/store/SightsStore/index.tsx index 8515ae5..d4dc4f0 100644 --- a/src/shared/store/SightsStore/index.tsx +++ b/src/shared/store/SightsStore/index.tsx @@ -58,41 +58,6 @@ class SightsStore { }); }; - // getSight = async (id: number) => { - // const response = await authInstance.get(`/sight/${id}`); - - // runInAction(() => { - // this.sight = response.data; - // editSightStore.sightInfo = { - // ...editSightStore.sightInfo, - // id: response.data.id, - // city_id: response.data.city_id, - // city: response.data.city, - // latitude: response.data.latitude, - // longitude: response.data.longitude, - // thumbnail: response.data.thumbnail, - // watermark_lu: response.data.watermark_lu, - // watermark_rd: response.data.watermark_rd, - // left_article: response.data.left_article, - // preview_media: response.data.preview_media, - // video_preview: response.data.video_preview, - - // [languageStore.language]: { - // info: { - // name: response.data.name, - // address: response.data.address, - // }, - // left: { - // heading: articlesStore.articles[languageStore.language].find( - // (article) => article.id === response.data.left_article - // )?.heading, - // body: articlesStore.articles[languageStore.language].find( - // }, - // }, - // }; - // }); - // }; - createSightAction = async ( city: number, coordinates: { latitude: number; longitude: number } @@ -167,16 +132,12 @@ class SightsStore { common: boolean ) => { if (common) { - // @ts-ignore this.sight!.common = { - // @ts-ignore ...this.sight!.common, ...content, }; } else { - // @ts-ignore this.sight![language] = { - // @ts-ignore ...this.sight![language], ...content, }; diff --git a/src/shared/store/SnapshotStore/index.ts b/src/shared/store/SnapshotStore/index.ts index 41cc382..ca70e34 100644 --- a/src/shared/store/SnapshotStore/index.ts +++ b/src/shared/store/SnapshotStore/index.ts @@ -1,8 +1,6 @@ import { authInstance } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; -// Импорт функции сброса кешей карты -// import { clearMapCaches } from "../../pages/MapPage"; import { articlesStore, cityStore, @@ -35,9 +33,7 @@ class SnapshotStore { makeAutoObservable(this); } - // Функция для сброса всех кешей в приложении private clearAllCaches = () => { - // Сброс кешей статей articlesStore.articleList = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, @@ -47,7 +43,6 @@ class SnapshotStore { articlesStore.articleData = null; articlesStore.articleMedia = null; - // Сброс кешей городов cityStore.cities = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, @@ -56,21 +51,18 @@ class SnapshotStore { cityStore.ruCities = { data: [], loaded: false }; cityStore.city = {}; - // Сброс кешей стран countryStore.countries = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, zh: { data: [], loaded: false }, }; - // Сброс кешей перевозчиков carrierStore.carriers = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, zh: { data: [], loaded: false }, }; - // Сброс кешей станций stationsStore.stationLists = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, @@ -78,24 +70,18 @@ class SnapshotStore { }; stationsStore.stationPreview = {}; - // Сброс кешей достопримечательностей sightsStore.sights = []; sightsStore.sight = null; - // Сброс кешей маршрутов routeStore.routes = { data: [], loaded: false }; - // Сброс кешей транспорта vehicleStore.vehicles = { data: [], loaded: false }; - // Сброс кешей пользователей userStore.users = { data: [], loaded: false }; - // Сброс кешей медиа mediaStore.media = []; mediaStore.oneMedia = null; - // Сброс кешей создания и редактирования достопримечательностей createSightStore.sight = JSON.parse( JSON.stringify({ city_id: 0, @@ -173,26 +159,21 @@ class SnapshotStore { editSightStore.fileToUpload = null; editSightStore.needLeaveAgree = false; - // Сброс кешей устройств devicesStore.devices = []; devicesStore.uuid = null; devicesStore.sendSnapshotModalOpen = false; - // Сброс кешей авторизации (кроме токена) authStore.payload = null; authStore.error = null; authStore.isLoading = false; - // Сброс кешей карты (если они загружены) try { - // Сбрасываем кеши mapStore если он доступен if (typeof window !== "undefined" && (window as any).mapStore) { (window as any).mapStore.routes = []; (window as any).mapStore.stations = []; (window as any).mapStore.sights = []; } - // Сбрасываем кеши MapService если он доступен if (typeof window !== "undefined" && (window as any).mapServiceInstance) { (window as any).mapServiceInstance.clearCaches(); } @@ -200,7 +181,6 @@ class SnapshotStore { console.warn("Не удалось сбросить кеши карты:", error); } - // Сброс localStorage кешей (кроме токена авторизации) const token = localStorage.getItem("token"); const rememberedEmail = localStorage.getItem("rememberedEmail"); const rememberedPassword = localStorage.getItem("rememberedPassword"); @@ -208,14 +188,12 @@ class SnapshotStore { localStorage.clear(); sessionStorage.clear(); - // Восстанавливаем важные данные if (token) localStorage.setItem("token", token); if (rememberedEmail) localStorage.setItem("rememberedEmail", rememberedEmail); if (rememberedPassword) localStorage.setItem("rememberedPassword", rememberedPassword); - // Сброс кешей карты (если они есть) const mapPositionKey = "mapPosition"; const activeSectionKey = "mapActiveSection"; if (localStorage.getItem(mapPositionKey)) { @@ -225,7 +203,6 @@ class SnapshotStore { localStorage.removeItem(activeSectionKey); } - // Попытка очистить кеш браузера (если поддерживается) if ("caches" in window) { try { caches.keys().then((cacheNames) => { @@ -240,7 +217,6 @@ class SnapshotStore { } } - // Попытка очистить IndexedDB (если поддерживается) if ("indexedDB" in window) { try { indexedDB.databases().then((databases) => { @@ -284,10 +260,8 @@ class SnapshotStore { }; restoreSnapshot = async (id: string) => { - // Сначала сбрасываем все кеши this.clearAllCaches(); - // Затем восстанавливаем снапшот await authInstance.post(`/snapshots/${id}/restore`); }; diff --git a/src/shared/store/StationsStore/index.ts b/src/shared/store/StationsStore/index.ts index 8ad2778..ea7444b 100644 --- a/src/shared/store/StationsStore/index.ts +++ b/src/shared/store/StationsStore/index.ts @@ -7,7 +7,7 @@ type StationLanguageData = { name: string; system_name: string; address: string; - loaded: boolean; // Indicates if this language's data has been loaded/modified + loaded: boolean; }; type StationCommonData = { @@ -92,7 +92,6 @@ class StationsStore { }, }; - // This will store the full station data, keyed by ID and then by language stationPreview: Record< string, Record @@ -264,7 +263,6 @@ class StationsStore { }; }; - // Sets language-specific station data setLanguageEditStationData = ( language: Language, data: Partial @@ -295,7 +293,7 @@ class StationsStore { `/station/${id}`, { name: name || "", - system_name: name || "", // system_name is often derived from name + system_name: name || "", description: description || "", address: address || "", ...commonDataPayload, @@ -303,7 +301,6 @@ class StationsStore { ); runInAction(() => { - // Update the cached preview data and station lists after successful patch if (this.stationPreview[id]) { this.stationPreview[id][language] = { loaded: true, @@ -343,11 +340,11 @@ class StationsStore { runInAction(() => { this.stations = this.stations.filter((station) => station.id !== id); - // Also clear from stationPreview cache + if (this.stationPreview[id]) { delete this.stationPreview[id]; } - // Clear from stationLists as well for all languages + for (const lang of ["ru", "en", "zh"] as const) { if (this.stationLists[lang].data) { this.stationLists[lang].data = this.stationLists[lang].data.filter( @@ -421,12 +418,11 @@ class StationsStore { delete commonDataPayload.icon; } - // First create station in Russian const { name, address } = this.createStationData[language]; const description = this.createStationData.common.description; const response = await languageInstance(language).post("/station", { name: name || "", - system_name: name || "", // system_name is often derived from name + system_name: name || "", description: description || "", address: address || "", ...commonDataPayload, @@ -438,7 +434,6 @@ class StationsStore { const stationId = response.data.id; - // Then update for other languages for (const lang of ["ru", "en", "zh"].filter( (lang) => lang !== language ) as Language[]) { @@ -448,7 +443,7 @@ class StationsStore { `/station/${stationId}`, { name: name || "", - system_name: name || "", // system_name is often derived from name + system_name: name || "", description: description || "", address: address || "", ...commonDataPayload, @@ -507,7 +502,6 @@ class StationsStore { return response.data; }; - // Reset editStationData when navigating away or after saving resetEditStationData = () => { this.editStationData = { ru: { diff --git a/src/widgets/DevicesTable/index.tsx b/src/widgets/DevicesTable/index.tsx index 5cfbcc9..0fb67b3 100644 --- a/src/widgets/DevicesTable/index.tsx +++ b/src/widgets/DevicesTable/index.tsx @@ -11,8 +11,8 @@ import { devicesStore, Modal, snapshotStore, - vehicleStore, // Not directly used in this component's rendering logic anymore -} from "@shared"; // Assuming @shared exports these + vehicleStore, +} from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Button, Checkbox, Typography } from "@mui/material"; @@ -23,12 +23,10 @@ import { useNavigate } from "react-router-dom"; export type ConnectedDevice = string; interface Snapshot { - ID: string; // Assuming ID is string based on usage + ID: string; Name: string; - // Add other snapshot properties if needed } -// --- HELPER FUNCTIONS --- const formatDate = (dateString: string | null) => { if (!dateString) return "Нет данных"; try { @@ -76,12 +74,7 @@ function createData( }; } -// This function transforms the raw device data (which includes vehicle and device_status) -// into the format expected by the table. It now filters for devices that have a UUID. -const transformDevicesToRows = ( - vehicles: Vehicle[] - // devices: ConnectedDevice[] -): TableRowData[] => { +const transformDevicesToRows = (vehicles: Vehicle[]): TableRowData[] => { return vehicles.map((vehicle) => { const uuid = vehicle.vehicle.uuid; if (!uuid) @@ -115,26 +108,21 @@ export const DevicesTable = observer(() => { } = devicesStore; const { snapshots, getSnapshots } = snapshotStore; - const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth + const { getVehicles, vehicles } = vehicleStore; const { devices } = devicesStore; const navigate = useNavigate(); const [selectedDeviceUuids, setSelectedDeviceUuids] = useState([]); - // Transform the raw devices data into rows suitable for the table - // This will also filter out devices without a UUID, as those cannot be acted upon. - const currentTableRows = transformDevicesToRows( - vehicles.data as Vehicle[] - // devices as ConnectedDevice[] - ); + const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]); useEffect(() => { const fetchData = async () => { - await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices - await getDevices(); // This should fetch the combined vehicle/device_status data + await getVehicles(); + await getDevices(); await getSnapshots(); }; fetchData(); - }, [getDevices, getSnapshots]); // Added dependencies + }, [getDevices, getSnapshots]); const isAllSelected = currentTableRows.length > 0 && @@ -144,7 +132,6 @@ export const DevicesTable = observer(() => { if (isAllSelected) { setSelectedDeviceUuids([]); } else { - // Select all device UUIDs from the *currently visible and selectable* rows setSelectedDeviceUuids( currentTableRows.map((row) => row.device_uuid ?? "") ); @@ -171,14 +158,13 @@ export const DevicesTable = observer(() => { }; const handleReloadStatus = async (uuid: string) => { - setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere + setSelectedDevice(uuid); try { await authInstance.post(`/devices/${uuid}/request-status`); await getVehicles(); - await getDevices(); // Refresh devices to show updated status + await getDevices(); } catch (error) { console.error(`Error requesting status for device ${uuid}:`, error); - // Optionally: show a user-facing error message } }; @@ -200,22 +186,16 @@ export const DevicesTable = observer(() => { } }; try { - // Create an array of promises for all snapshot requests const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { return send(deviceUuid); }); - // Wait for all promises to settle (either resolve or reject) await Promise.allSettled(snapshotPromises); - // After all requests are attempted - await getDevices(); // Refresh the device list - setSelectedDeviceUuids([]); // Clear the selection - toggleSendSnapshotModal(); // Close the modal + await getDevices(); + setSelectedDeviceUuids([]); + toggleSendSnapshotModal(); } catch (error) { - // This catch block might not be hit if Promise.allSettled is used, - // as it doesn't reject on individual promise failures. - // Individual errors should be handled if needed within the .map or by checking results. console.error("Error in snapshot sending process:", error); } }; @@ -235,7 +215,7 @@ export const DevicesTable = observer(() => {
    )} diff --git a/src/widgets/MediaViewer/ThreeView.tsx b/src/widgets/MediaViewer/ThreeView.tsx index 79296b0..699e770 100644 --- a/src/widgets/MediaViewer/ThreeView.tsx +++ b/src/widgets/MediaViewer/ThreeView.tsx @@ -3,11 +3,9 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; import { useEffect, Suspense } from "react"; import { Box, CircularProgress, Typography } from "@mui/material"; -// Утилита для очистки кеша GLTF const clearGLTFCache = (url?: string) => { try { if (url) { - // Если это blob URL, очищаем его из кеша if (url.startsWith("blob:")) { useGLTF.clear(url); } else { @@ -19,29 +17,23 @@ const clearGLTFCache = (url?: string) => { } }; -// Утилита для проверки типа файла const isValid3DFile = (url: string): boolean => { try { const urlObj = new URL(url); const pathname = urlObj.pathname.toLowerCase(); const searchParams = urlObj.searchParams; - // Проверяем расширение файла в пути const validExtensions = [".glb", ".gltf"]; const hasValidExtension = validExtensions.some((ext) => pathname.endsWith(ext) ); - // Проверяем параметры запроса на наличие типа файла const fileType = searchParams.get("type") || searchParams.get("format"); const hasValidType = fileType && ["glb", "gltf"].includes(fileType.toLowerCase()); - // Если это blob URL, считаем его валидным (пользователь выбрал файл) const isBlobUrl = url.startsWith("blob:"); - // Если это URL с токеном и нет явного расширения, считаем валидным - // (предполагаем что сервер вернет правильный файл) const hasToken = searchParams.has("token"); const isServerUrl = hasToken && !hasValidExtension; @@ -51,7 +43,7 @@ const isValid3DFile = (url: string): boolean => { return isValid; } catch (error) { console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error); - // В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется) + return true; } }; @@ -63,13 +55,10 @@ type ModelViewerProps = { }; const Model = ({ fileUrl }: { fileUrl: string }) => { - // Очищаем кеш перед загрузкой новой модели useEffect(() => { - // Очищаем кеш для текущего URL clearGLTFCache(fileUrl); }, [fileUrl]); - // Проверяем валидность файла перед загрузкой (только для blob URL) if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) { console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl }); throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`); @@ -114,16 +103,13 @@ export const ThreeView = ({ height = "100%", width = "100%", }: ModelViewerProps) => { - // Проверяем валидность файла (только для blob URL) useEffect(() => { if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) { console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl }); } }, [fileUrl]); - // Очищаем кеш при размонтировании и при смене URL useEffect(() => { - // Очищаем кеш сразу при монтировании компонента clearGLTFCache(fileUrl); return () => { diff --git a/src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx b/src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx index e7885e1..8114f1d 100644 --- a/src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx +++ b/src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx @@ -35,7 +35,6 @@ export class ThreeViewErrorBoundary extends Component { props: Props, state: State ): Partial | null { - // Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey) if ( props.resetKey !== state.lastResetKey && state.lastResetKey !== undefined @@ -43,7 +42,6 @@ export class ThreeViewErrorBoundary extends Component { const oldMediaId = String(state.lastResetKey).split("-")[0]; const newMediaId = String(props.resetKey).split("-")[0]; - // Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель) if (oldMediaId !== newMediaId) { return { hasError: false, @@ -52,9 +50,6 @@ export class ThreeViewErrorBoundary extends Component { }; } - // Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey - // но не сбрасываем ошибку автоматически - ждем результата загрузки - return { lastResetKey: props.resetKey, }; @@ -127,15 +122,12 @@ export class ThreeViewErrorBoundary extends Component { }; handleReset = () => { - // Сначала сбрасываем состояние ошибки this.setState( { hasError: false, error: null, }, () => { - // После того как состояние обновилось, вызываем callback для изменения resetKey - // Это приведет к пересозданию компонента и новой попытке загрузки this.props.onReset?.(); } ); diff --git a/src/widgets/SightTabs/CreateInformationTab/MediaUploadBox.tsx b/src/widgets/SightTabs/CreateInformationTab/MediaUploadBox.tsx index a148144..e69de29 100644 --- a/src/widgets/SightTabs/CreateInformationTab/MediaUploadBox.tsx +++ b/src/widgets/SightTabs/CreateInformationTab/MediaUploadBox.tsx @@ -1,158 +0,0 @@ -// import { Box, Button, Paper, Typography } from "@mui/material"; -// import { X, Upload } from "lucide-react"; -// import { useCallback, useState } from "react"; -// import { useDropzone } from "react-dropzone"; -// import { UploadMediaDialog } from "@shared"; -// import { createSightStore } from "@shared"; - -// interface MediaUploadBoxProps { -// title: string; -// tooltip?: string; -// mediaId: string | null; -// onMediaSelect: (mediaId: string) => void; -// onMediaRemove: () => void; -// onPreviewClick: (mediaId: string) => void; -// token: string; -// type: "thumbnail" | "watermark_lu" | "watermark_rd"; -// } - -// export const MediaUploadBox = ({ -// title, -// tooltip, -// mediaId, -// onMediaSelect, -// onMediaRemove, -// onPreviewClick, -// token, -// type, -// }: MediaUploadBoxProps) => { -// const [uploadMediaOpen, setUploadMediaOpen] = useState(false); -// const [fileToUpload, setFileToUpload] = useState(null); - -// const onDrop = useCallback((acceptedFiles: File[]) => { -// if (acceptedFiles.length > 0) { -// setFileToUpload(acceptedFiles[0]); -// setUploadMediaOpen(true); -// } -// }, []); - -// const { getRootProps, getInputProps, isDragActive } = useDropzone({ -// onDrop, -// accept: { -// "image/*": [".png", ".jpg", ".jpeg", ".gif"], -// }, -// multiple: false, -// }); - -// const handleUploadComplete = async (media: { -// id: string; -// filename: string; -// media_name?: string; -// media_type: number; -// }) => { -// onMediaSelect(media.id); -// }; - -// return ( -// <> -// -// -// -// {title} -// -// -// -// -// {mediaId && ( -// -// )} -// {mediaId ? ( -// {title} { -// e.stopPropagation(); -// onPreviewClick(mediaId); -// }} -// /> -// ) : ( -//
    -//
    -// -//

    -// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"} -//

    -//
    -//

    или

    -// -//
    -// )} -//
    -//
    - -// { -// setUploadMediaOpen(false); -// setFileToUpload(null); -// }} -// afterUpload={handleUploadComplete} -// /> -// -// ); -// }; diff --git a/src/widgets/SightTabs/CreateLeftTab/index.tsx b/src/widgets/SightTabs/CreateLeftTab/index.tsx index f18d1b6..1a1f1bc 100644 --- a/src/widgets/SightTabs/CreateLeftTab/index.tsx +++ b/src/widgets/SightTabs/CreateLeftTab/index.tsx @@ -1,4 +1,3 @@ -// @widgets/LeftWidgetTab.tsx import { Box, Button, TextField, Paper, Typography } from "@mui/material"; import { BackButton, @@ -50,17 +49,6 @@ export const CreateLeftTab = observer( const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - // const handleMediaSelected = useCallback(() => { - // // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА - // // сохраняя текущие heading и body. - // updateSightInfo(language, { - // left: { - // heading: data.left.heading, - // body: data.left.body, - // }, - // }); - // setIsSelectMediaDialogOpen(false); - // }, [language, data.left.heading, data.left.body]); const handleCloseArticleDialog = useCallback(() => { setIsSelectArticleDialogOpen(false); diff --git a/src/widgets/SightTabs/CreateRightTab/index.tsx b/src/widgets/SightTabs/CreateRightTab/index.tsx index bf60797..d76d2a3 100644 --- a/src/widgets/SightTabs/CreateRightTab/index.tsx +++ b/src/widgets/SightTabs/CreateRightTab/index.tsx @@ -13,28 +13,27 @@ import { languageStore, SelectArticleModal, TabPanel, - SelectMediaDialog, // Import + SelectMediaDialog, UploadMediaDialog, - Media, // Import + Media, } from "@shared"; import { LanguageSwitcher, - MediaArea, // Import - MediaAreaForSight, // Import + MediaArea, + MediaAreaForSight, ReactMarkdownComponent, ReactMarkdownEditor, DeleteModal, } from "@widgets"; -import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; // Import X +import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; import { observer } from "mobx-react-lite"; -import { useState, useEffect } from "react"; // Added useEffect +import { useState, useEffect } from "react"; import { MediaViewer } from "../../MediaViewer/index"; import { toast } from "react-toastify"; import { authInstance } from "@shared"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; type MediaItemShared = { - // Define if not already available from @shared id: string; filename: string; media_name?: string; @@ -52,14 +51,14 @@ export const CreateRightTab = observer( unlinkPreviewMedia, createLinkWithRightArticle, deleteRightArticleMedia, - setFileToUpload, // From store - setUploadMediaOpen, // From store - uploadMediaOpen, // From store - unlinkRightAritcle, // Corrected spelling + setFileToUpload, + setUploadMediaOpen, + uploadMediaOpen, + unlinkRightAritcle, deleteRightArticle, linkExistingRightArticle, createSight, - clearCreateSight, // For resetting form + clearCreateSight, updateRightArticles, } = createSightStore; const { language } = languageStore; @@ -78,7 +77,7 @@ export const CreateRightTab = observer( >(null); const [previewMedia, setPreviewMedia] = useState(null); - // Reset activeArticleIndex if language changes and index is out of bounds + useEffect(() => { if (sight.preview_media) { const fetchMedia = async () => { @@ -97,7 +96,7 @@ export const CreateRightTab = observer( activeArticleIndex >= sight[language].right.length ) { setActiveArticleIndex(null); - setType("media"); // Default back to media preview if selected article disappears + setType("media"); } }, [language, sight[language].right, activeArticleIndex]); @@ -113,10 +112,9 @@ export const CreateRightTab = observer( try { await createSight(language); toast.success("Достопримечательность успешно создана!"); - clearCreateSight(); // Reset form + clearCreateSight(); setActiveArticleIndex(null); setType("media"); - // Potentially navigate away: history.push('/sights-list'); } catch (error) { console.error("Failed to save sight:", error); toast.error("Ошибка при создании достопримечательности."); @@ -132,7 +130,7 @@ export const CreateRightTab = observer( handleCloseMenu(); try { const newArticleId = await createNewRightArticle(); - // Automatically select the new article if ID is returned + const newIndex = sight[language].right.findIndex( (a) => a.id === newArticleId ); @@ -140,7 +138,6 @@ export const CreateRightTab = observer( setActiveArticleIndex(newIndex); setType("article"); } else { - // Fallback if findIndex fails (should not happen if store updates correctly) setActiveArticleIndex(sight[language].right.length - 1); setType("article"); } @@ -156,7 +153,7 @@ export const CreateRightTab = observer( const linkedArticleId = await linkExistingRightArticle( selectedArticleId ); - setSelectArticleDialogOpen(false); // Close dialog + setSelectArticleDialogOpen(false); const newIndex = sight[language].right.findIndex( (a) => a.id === linkedArticleId ); @@ -174,7 +171,6 @@ export const CreateRightTab = observer( ? sight[language].right[activeArticleIndex] : null; - // Media Handling for Dialogs const handleOpenUploadMedia = () => { setUploadMediaOpen(true); }; @@ -203,7 +199,6 @@ export const CreateRightTab = observer( }; const handleMediaUploaded = async (media: MediaItemShared) => { - // After UploadMediaDialog finishes setUploadMediaOpen(false); setFileToUpload(null); if (mediaTarget === "sightPreview") { @@ -211,36 +206,25 @@ export const CreateRightTab = observer( } else if (mediaTarget === "rightArticle" && currentRightArticle) { await createLinkWithRightArticle(media, currentRightArticle.id); } - setMediaTarget(null); // Reset target + setMediaTarget(null); }; const handleDragEnd = (result: any) => { const { source, destination } = result; - // 1. Guard clause: If dropped outside any droppable area, do nothing. if (!destination) return; - // Extract source and destination indices const sourceIndex = source.index; const destinationIndex = destination.index; - // 2. Guard clause: If dropped in the same position, do nothing. if (sourceIndex === destinationIndex) return; - // 3. Create a new array with reordered articles: - // - Create a shallow copy of the current articles array. - // This is important for immutability and triggering re-renders. const newRightArticles = [...sight[language].right]; - // - Remove the dragged article from its original position. - // `splice` returns an array of removed items, so we destructure the first (and only) one. const [movedArticle] = newRightArticles.splice(sourceIndex, 1); - // - Insert the moved article into its new position. newRightArticles.splice(destinationIndex, 0, movedArticle); - // 4. Update the store with the new order: - // This will typically trigger a re-render of the component with the updated list. updateRightArticles(newRightArticles); }; @@ -254,7 +238,7 @@ export const CreateRightTab = observer( height: "100%", minHeight: "calc(100vh - 200px)", gap: 2, - paddingBottom: "70px", // Space for the save button + paddingBottom: "70px", position: "relative", }} > @@ -264,7 +248,6 @@ export const CreateRightTab = observer( - {/* Left Column: Navigation & Article List */} @@ -272,7 +255,6 @@ export const CreateRightTab = observer( { setType("media"); - // setActiveArticleIndex(null); // Optional: deselect article when switching to general media view }} className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${ type === "media" @@ -364,7 +346,6 @@ export const CreateRightTab = observer( - {/* Main content area: Article Editor or Sight Media Preview */} {type === "article" && currentRightArticle ? ( @@ -375,7 +356,7 @@ export const CreateRightTab = observer( startIcon={} onClick={() => { if (currentRightArticle) { - unlinkRightAritcle(currentRightArticle.id); // Corrected function name + unlinkRightAritcle(currentRightArticle.id); setActiveArticleIndex(null); setType("media"); } @@ -435,7 +416,7 @@ export const CreateRightTab = observer( /> { if (files.length > 0) { @@ -507,7 +488,6 @@ export const CreateRightTab = observer( - {/* Right Column: Live Preview */} {type === "article" && activeArticleIndex !== null && ( - {/* Sticky Save Button Footer */} - {/* Modals */} setSelectArticleDialogOpen(false)} onSelectArticle={handleSelectExistingArticleAndLink} - // Pass IDs of already linked/added right articles to exclude them from selection linkedArticleIds={sight[language].right.map((article) => article.id)} /> { setUploadMediaOpen(false); - setFileToUpload(null); // Clear file if dialog is closed without upload + setFileToUpload(null); setMediaTarget(null); }} contextObjectName={sight[language].name} @@ -712,7 +689,7 @@ export const CreateRightTab = observer( ? sight[language].right[activeArticleIndex].heading : undefined } - afterUpload={handleMediaUploaded} // This will use the mediaTarget + afterUpload={handleMediaUploaded} /> { @@ -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/src/widgets/SightTabs/RightWidgetTab/index.tsx b/src/widgets/SightTabs/RightWidgetTab/index.tsx index 95265cb..02432d7 100644 --- a/src/widgets/SightTabs/RightWidgetTab/index.tsx +++ b/src/widgets/SightTabs/RightWidgetTab/index.tsx @@ -118,7 +118,7 @@ export const RightWidgetTab = observer( try { const newArticleId = await createNewRightArticle(); handleClose(); - // Automatically select the newly created article + const newIndex = sight[language].right.findIndex( (article) => article.id === newArticleId ); @@ -144,7 +144,7 @@ export const RightWidgetTab = observer( try { const linkedArticleId = await linkArticle(id); handleCloseSelectModal(); - // Automatically select the newly linked article + const newIndex = sight[language].right.findIndex( (article) => article.id === linkedArticleId ); @@ -177,30 +177,19 @@ export const RightWidgetTab = observer( const handleDragEnd = (result: DropResult) => { const { source, destination } = result; - // 1. Guard clause: If dropped outside any droppable area, do nothing. if (!destination) return; - // Extract source and destination indices const sourceIndex = source.index; const destinationIndex = destination.index; - // 2. Guard clause: If dropped in the same position, do nothing. if (sourceIndex === destinationIndex) return; - // 3. Create a new array with reordered articles: - // - Create a shallow copy of the current articles array. - // This is important for immutability and triggering re-renders. const newRightArticles = [...sight[language].right]; - // - Remove the dragged article from its original position. - // `splice` returns an array of removed items, so we destructure the first (and only) one. const [movedArticle] = newRightArticles.splice(sourceIndex, 1); - // - Insert the moved article into its new position. newRightArticles.splice(destinationIndex, 0, movedArticle); - // 4. Update the store with the new order: - // This will typically trigger a re-render of the component with the updated list. updateRightArticles(newRightArticles); }; diff --git a/src/widgets/VideoPreviewCard/index.tsx b/src/widgets/VideoPreviewCard/index.tsx index a103a7d..8ace054 100644 --- a/src/widgets/VideoPreviewCard/index.tsx +++ b/src/widgets/VideoPreviewCard/index.tsx @@ -28,9 +28,8 @@ export const VideoPreviewCard: React.FC = ({ const token = localStorage.getItem("token"); useEffect(() => {}, [isDragOver]); - // --- Click to select file --- + const handleZoneClick = () => { - // Trigger the hidden file input click fileInputRef.current?.click(); }; @@ -40,19 +39,17 @@ export const VideoPreviewCard: React.FC = ({ const file = event.target.files?.[0]; if (file) { if (file.type.startsWith("video/")) { - // Открываем диалог загрузки медиа с файлом видео onSelectVideoClick(file); } else { toast.error("Пожалуйста, выберите видео файл"); } } - // Reset the input value so selecting the same file again triggers change + event.target.value = ""; }; - // --- Drag and Drop Handlers --- const handleDragOver = (event: DragEvent) => { - event.preventDefault(); // Crucial to allow a drop + event.preventDefault(); event.stopPropagation(); setIsDragOver(true); }; @@ -64,7 +61,7 @@ export const VideoPreviewCard: React.FC = ({ }; const handleDrop = async (event: DragEvent) => { - event.preventDefault(); // Crucial to allow a drop + event.preventDefault(); event.stopPropagation(); setIsDragOver(false); @@ -72,7 +69,6 @@ export const VideoPreviewCard: React.FC = ({ if (files && files.length > 0) { const file = files[0]; if (file.type.startsWith("video/")) { - // Открываем диалог загрузки медиа с файлом видео onSelectVideoClick(file); } else { toast.error("Пожалуйста, выберите видео файл"); @@ -175,7 +171,7 @@ export const VideoPreviewCard: React.FC = ({ borderRadius: 1, cursor: "pointer", }} - onClick={handleZoneClick} // Click handler for the zone + onClick={handleZoneClick} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} @@ -189,8 +185,8 @@ export const VideoPreviewCard: React.FC = ({ color="primary" startIcon={} onClick={(e) => { - e.stopPropagation(); // Prevent `handleZoneClick` from firing - onSelectVideoClick(); // This button triggers the media selection dialog + e.stopPropagation(); + onSelectVideoClick(); }} > Выбрать файл @@ -201,7 +197,7 @@ export const VideoPreviewCard: React.FC = ({ ref={fileInputRef} onChange={handleFileInputChange} style={{ display: "none" }} - accept="video/*" // Accept only video files + accept="video/*" /> )} 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