2962 lines
		
	
	
		
			97 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			2962 lines
		
	
	
		
			97 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import React, {
 | ||
|   useEffect,
 | ||
|   useRef,
 | ||
|   useState,
 | ||
|   useCallback,
 | ||
|   useMemo,
 | ||
| } from "react";
 | ||
| import { useNavigate } from "react-router-dom";
 | ||
| import { Map, View, Overlay, MapBrowserEvent } from "ol";
 | ||
| import TileLayer from "ol/layer/Tile";
 | ||
| import OSM from "ol/source/OSM";
 | ||
| import VectorLayer from "ol/layer/Vector";
 | ||
| import VectorSource, { VectorSourceEvent } from "ol/source/Vector";
 | ||
| import Cluster from "ol/source/Cluster";
 | ||
| 
 | ||
| import {
 | ||
|   Draw,
 | ||
|   Modify,
 | ||
|   Select,
 | ||
|   DragPan,
 | ||
|   MouseWheelZoom,
 | ||
|   KeyboardPan,
 | ||
|   KeyboardZoom,
 | ||
|   PinchZoom,
 | ||
|   PinchRotate,
 | ||
| } from "ol/interaction";
 | ||
| import { DrawEvent } from "ol/interaction/Draw";
 | ||
| import { SelectEvent } from "ol/interaction/Select";
 | ||
| import {
 | ||
|   Style,
 | ||
|   Fill,
 | ||
|   Stroke,
 | ||
|   Circle as CircleStyle,
 | ||
|   RegularShape,
 | ||
|   Text,
 | ||
| } from "ol/style";
 | ||
| import { Point, LineString, Geometry, Polygon } from "ol/geom";
 | ||
| import { transform, toLonLat } from "ol/proj";
 | ||
| import { GeoJSON } from "ol/format";
 | ||
| import {
 | ||
|   Bus,
 | ||
|   RouteIcon,
 | ||
|   MapPin,
 | ||
|   Trash2,
 | ||
|   ArrowRightLeft,
 | ||
|   Landmark,
 | ||
|   Pencil,
 | ||
|   InfoIcon,
 | ||
|   X,
 | ||
|   Loader2,
 | ||
| } from "lucide-react";
 | ||
| import { toast } from "react-toastify";
 | ||
| import { singleClick, doubleClick } from "ol/events/condition";
 | ||
| import { Feature } from "ol";
 | ||
| import Layer from "ol/layer/Layer";
 | ||
| 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;
 | ||
|     scrollbar-width: none;
 | ||
|   }
 | ||
|   .scrollbar-hide::-webkit-scrollbar {
 | ||
|     display: none;
 | ||
|   }
 | ||
|   
 | ||
|   .scrollbar-visible {
 | ||
|     scrollbar-width: thin;
 | ||
|     scrollbar-color: #cbd5e1 #f1f5f9;
 | ||
|   }
 | ||
|   
 | ||
|   .scrollbar-visible::-webkit-scrollbar {
 | ||
|     width: 8px;
 | ||
|   }
 | ||
|   
 | ||
|   .scrollbar-visible::-webkit-scrollbar-track {
 | ||
|     background: #f1f5f9;
 | ||
|     border-radius: 4px;
 | ||
|   }
 | ||
|   
 | ||
|   .scrollbar-visible::-webkit-scrollbar-thumb {
 | ||
|     background: #cbd5e1;
 | ||
|     border-radius: 4px;
 | ||
|   }
 | ||
|   
 | ||
|   .scrollbar-visible::-webkit-scrollbar-thumb:hover {
 | ||
|     background: #94a3b8;
 | ||
|   }
 | ||
| `;
 | ||
| 
 | ||
| if (typeof document !== "undefined") {
 | ||
|   const styleElement = document.createElement("style");
 | ||
|   styleElement.textContent = scrollbarStyles;
 | ||
|   document.head.appendChild(styleElement);
 | ||
| }
 | ||
| 
 | ||
| // --- MAP STORE ---
 | ||
| // @ts-ignore
 | ||
| import { languageInstance } from "@shared";
 | ||
| import { makeAutoObservable } from "mobx";
 | ||
| 
 | ||
| import {
 | ||
|   stationsStore,
 | ||
|   routeStore,
 | ||
|   sightsStore,
 | ||
|   menuStore,
 | ||
|   selectedCityStore,
 | ||
|   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();
 | ||
|   }
 | ||
| };
 | ||
| 
 | ||
| interface ApiRoute {
 | ||
|   id: number;
 | ||
|   route_number: string;
 | ||
|   path: [number, number][];
 | ||
|   center_latitude: number;
 | ||
|   center_longitude: number;
 | ||
|   carrier_id: number;
 | ||
| }
 | ||
| 
 | ||
| interface ApiStation {
 | ||
|   id: number;
 | ||
|   name: string;
 | ||
|   latitude: number;
 | ||
|   longitude: number;
 | ||
|   city_id: number;
 | ||
|   created_at?: string;
 | ||
|   updated_at?: string;
 | ||
| }
 | ||
| 
 | ||
| interface ApiSight {
 | ||
|   id: number;
 | ||
|   name: string;
 | ||
|   description: string;
 | ||
|   latitude: number;
 | ||
|   longitude: number;
 | ||
|   city_id: number;
 | ||
|   created_at?: string;
 | ||
|   updated_at?: string;
 | ||
| }
 | ||
| 
 | ||
| export type SortType =
 | ||
|   | "name_asc"
 | ||
|   | "name_desc"
 | ||
|   | "created_asc"
 | ||
|   | "created_desc"
 | ||
|   | "updated_asc"
 | ||
|   | "updated_desc";
 | ||
| 
 | ||
| class MapStore {
 | ||
|   constructor() {
 | ||
|     makeAutoObservable(this);
 | ||
|   }
 | ||
| 
 | ||
|   routes: ApiRoute[] = [];
 | ||
|   stations: ApiStation[] = [];
 | ||
|   sights: ApiSight[] = [];
 | ||
| 
 | ||
|   // НОВЫЕ ПОЛЯ ДЛЯ СОРТИРОВКИ
 | ||
|   stationSort: SortType = "name_asc";
 | ||
|   sightSort: SortType = "name_asc";
 | ||
| 
 | ||
|   // НОВЫЕ МЕТОДЫ-СЕТТЕРЫ
 | ||
|   setStationSort = (sortType: SortType) => {
 | ||
|     this.stationSort = sortType;
 | ||
|   };
 | ||
| 
 | ||
|   setSightSort = (sortType: SortType) => {
 | ||
|     this.sightSort = sortType;
 | ||
|   };
 | ||
| 
 | ||
|   // ПРИВАТНЫЙ МЕТОД ДЛЯ ОБЩЕЙ ЛОГИКИ СОРТИРОВКИ
 | ||
|   private sortFeatures<T extends ApiStation | ApiSight>(
 | ||
|     features: T[],
 | ||
|     sortType: SortType
 | ||
|   ): T[] {
 | ||
|     const sorted = [...features];
 | ||
|     switch (sortType) {
 | ||
|       case "name_asc":
 | ||
|         return sorted.sort((a, b) => a.name.localeCompare(b.name));
 | ||
|       case "name_desc":
 | ||
|         return sorted.sort((a, b) => b.name.localeCompare(a.name));
 | ||
|       case "created_asc":
 | ||
|         return sorted.sort((a, b) => {
 | ||
|           if (
 | ||
|             "created_at" in a &&
 | ||
|             "created_at" in b &&
 | ||
|             a.created_at &&
 | ||
|             b.created_at
 | ||
|           ) {
 | ||
|             return (
 | ||
|               new Date(a.created_at).getTime() -
 | ||
|               new Date(b.created_at).getTime()
 | ||
|             );
 | ||
|           }
 | ||
|           // Фоллбэк: сортировка по ID, если дата недоступна
 | ||
|           return a.id - b.id;
 | ||
|         });
 | ||
|       case "created_desc":
 | ||
|         return sorted.sort((a, b) => {
 | ||
|           if (
 | ||
|             "created_at" in a &&
 | ||
|             "created_at" in b &&
 | ||
|             a.created_at &&
 | ||
|             b.created_at
 | ||
|           ) {
 | ||
|             return (
 | ||
|               new Date(b.created_at).getTime() -
 | ||
|               new Date(a.created_at).getTime()
 | ||
|             );
 | ||
|           }
 | ||
|           // Фоллбэк: сортировка по ID, если дата недоступна
 | ||
|           return b.id - a.id;
 | ||
|         });
 | ||
|       case "updated_asc":
 | ||
|         return sorted.sort((a, b) => {
 | ||
|           const aUpdated =
 | ||
|             ("updated_at" in a && a.updated_at) ||
 | ||
|             ("created_at" in a && a.created_at);
 | ||
|           const bUpdated =
 | ||
|             ("updated_at" in b && b.updated_at) ||
 | ||
|             ("created_at" in b && b.created_at);
 | ||
|           if (typeof aUpdated === "string" && typeof bUpdated === "string") {
 | ||
|             return new Date(aUpdated).getTime() - new Date(bUpdated).getTime();
 | ||
|           }
 | ||
|           return a.id - b.id;
 | ||
|         });
 | ||
|       case "updated_desc":
 | ||
|         return sorted.sort((a, b) => {
 | ||
|           const aUpdated =
 | ||
|             ("updated_at" in a && a.updated_at) ||
 | ||
|             ("created_at" in a && a.created_at);
 | ||
|           const bUpdated =
 | ||
|             ("updated_at" in b && b.updated_at) ||
 | ||
|             ("created_at" in b && b.created_at);
 | ||
|           if (typeof aUpdated === "string" && typeof bUpdated === "string") {
 | ||
|             return new Date(bUpdated).getTime() - new Date(aUpdated).getTime();
 | ||
|           }
 | ||
|           return b.id - a.id;
 | ||
|         });
 | ||
|       default:
 | ||
|         return sorted;
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   // НОВЫЕ ГЕТТЕРЫ, ВОЗВРАЩАЮЩИЕ ОТСОРТИРОВАННЫЕ СПИСКИ
 | ||
|   get sortedStations(): ApiStation[] {
 | ||
|     return this.sortFeatures(this.stations, this.stationSort);
 | ||
|   }
 | ||
| 
 | ||
|   get sortedSights(): ApiSight[] {
 | ||
|     return this.sortFeatures(this.sights, this.sightSort);
 | ||
|   }
 | ||
| 
 | ||
|   // ГЕТТЕРЫ ДЛЯ ФИЛЬТРАЦИИ ПО ГОРОДУ
 | ||
|   get filteredStations(): ApiStation[] {
 | ||
|     const selectedCityId = selectedCityStore.selectedCityId;
 | ||
|     if (!selectedCityId) {
 | ||
|       return this.sortedStations;
 | ||
|     }
 | ||
|     return this.sortedStations.filter(
 | ||
|       (station) => station.city_id === selectedCityId
 | ||
|     );
 | ||
|   }
 | ||
| 
 | ||
|   get filteredRoutes(): ApiRoute[] {
 | ||
|     const selectedCityId = selectedCityStore.selectedCityId;
 | ||
|     if (!selectedCityId) {
 | ||
|       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;
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   get filteredSights(): ApiSight[] {
 | ||
|     const selectedCityId = selectedCityStore.selectedCityId;
 | ||
|     if (!selectedCityId) {
 | ||
|       return this.sortedSights;
 | ||
|     }
 | ||
|     return this.sortedSights.filter(
 | ||
|       (sight) => sight.city_id === selectedCityId
 | ||
|     );
 | ||
|   }
 | ||
| 
 | ||
|   getRoutes = async () => {
 | ||
|     const response = await languageInstance("ru").get("/route");
 | ||
|     const routesIds = response.data.map((route: any) => route.id);
 | ||
|     const routePromises = routesIds.map((id: number) =>
 | ||
|       languageInstance("ru").get(`/route/${id}`)
 | ||
|     );
 | ||
|     const routeResponses = await Promise.all(routePromises);
 | ||
|     this.routes = routeResponses.map((res) => ({
 | ||
|       id: res.data.id,
 | ||
|       route_number: res.data.route_number,
 | ||
|       path: res.data.path,
 | ||
|       center_latitude: res.data.center_latitude,
 | ||
|       center_longitude: res.data.center_longitude,
 | ||
|       carrier_id: res.data.carrier_id,
 | ||
|     }));
 | ||
| 
 | ||
|     this.routes = this.routes.sort((a, b) =>
 | ||
|       a.route_number.localeCompare(b.route_number)
 | ||
|     );
 | ||
|   };
 | ||
| 
 | ||
|   getStations = async () => {
 | ||
|     const stations = await languageInstance("ru").get("/station");
 | ||
|     this.stations = stations.data.map((station: any) => ({
 | ||
|       ...station,
 | ||
|     }));
 | ||
|   };
 | ||
| 
 | ||
|   getSights = async () => {
 | ||
|     const sights = await languageInstance("ru").get("/sight");
 | ||
|     this.sights = sights.data.map((sight: any) => ({
 | ||
|       ...sight,
 | ||
|     }));
 | ||
|   };
 | ||
| 
 | ||
|   deleteFeature = async (featureType: string, id: number) => {
 | ||
|     await languageInstance("ru").delete(`/${featureType}/${id}`);
 | ||
|     if (featureType === "route") {
 | ||
|       this.routes = this.routes.filter((route) => route.id !== id);
 | ||
|     } else if (featureType === "station") {
 | ||
|       this.stations = this.stations.filter((station) => station.id !== id);
 | ||
|     } else if (featureType === "sight") {
 | ||
|       this.sights = this.sights.filter((sight) => sight.id !== id);
 | ||
|     }
 | ||
|   };
 | ||
| 
 | ||
|   createFeature = async (featureType: string, geoJsonFeature: any) => {
 | ||
|     const { geometry, properties } = geoJsonFeature;
 | ||
|     let createdItem;
 | ||
| 
 | ||
|     if (featureType === "station") {
 | ||
|       const name = properties.name || "Остановка 1";
 | ||
|       const latitude = geometry.coordinates[1];
 | ||
|       const longitude = geometry.coordinates[0];
 | ||
| 
 | ||
|       stationsStore.setLanguageCreateStationData("ru", {
 | ||
|         name,
 | ||
|         address: "",
 | ||
|         system_name: name,
 | ||
|       });
 | ||
|       stationsStore.setLanguageCreateStationData("en", {
 | ||
|         name,
 | ||
|         address: "",
 | ||
|         system_name: name,
 | ||
|       });
 | ||
|       stationsStore.setLanguageCreateStationData("zh", {
 | ||
|         name,
 | ||
|         address: "",
 | ||
|         system_name: name,
 | ||
|       });
 | ||
|       const selectedCityId = selectedCityStore.selectedCityId || 1;
 | ||
|       const selectedCityName =
 | ||
|         selectedCityStore.selectedCityName || "Неизвестный город";
 | ||
|       stationsStore.setCreateCommonData({
 | ||
|         latitude,
 | ||
|         longitude,
 | ||
|         city_id: selectedCityId,
 | ||
|         city: selectedCityName,
 | ||
|       });
 | ||
| 
 | ||
|       await stationsStore.createStation();
 | ||
|       createdItem =
 | ||
|         stationsStore.stationLists["ru"].data[
 | ||
|           stationsStore.stationLists["ru"].data.length - 1
 | ||
|         ];
 | ||
|     } else if (featureType === "route") {
 | ||
|       const route_number = properties.name || "Маршрут 1";
 | ||
|       const path = geometry.coordinates.map((c: any) => [c[1], c[0]]);
 | ||
| 
 | ||
|       const lineGeom = new GeoJSON().readGeometry(geometry, {
 | ||
|         dataProjection: "EPSG:4326",
 | ||
|         featureProjection: "EPSG:3857",
 | ||
|       });
 | ||
|       const centerCoords = getCenter(lineGeom.getExtent());
 | ||
|       const [center_longitude, center_latitude] = toLonLat(
 | ||
|         centerCoords,
 | ||
|         "EPSG:3857"
 | ||
|       );
 | ||
| 
 | ||
|       // Автоматически назначаем перевозчика из выбранного города
 | ||
|       let carrier_id = 0;
 | ||
|       let carrier = "";
 | ||
| 
 | ||
|       if (selectedCityStore.selectedCityId) {
 | ||
|         const carriersInCity = carrierStore.carriers.ru.data.filter(
 | ||
|           (c: any) => c.city_id === selectedCityStore.selectedCityId
 | ||
|         );
 | ||
| 
 | ||
|         if (carriersInCity.length > 0) {
 | ||
|           carrier_id = carriersInCity[0].id;
 | ||
|           carrier = carriersInCity[0].full_name;
 | ||
|         }
 | ||
|       }
 | ||
| 
 | ||
|       const routeData = {
 | ||
|         route_number,
 | ||
|         path,
 | ||
|         center_latitude,
 | ||
|         center_longitude,
 | ||
|         carrier,
 | ||
|         carrier_id,
 | ||
|         governor_appeal: 0,
 | ||
|         rotate: 0,
 | ||
|         route_direction: false,
 | ||
|         route_sys_number: route_number,
 | ||
|         scale_max: 0,
 | ||
|         scale_min: 0,
 | ||
|       };
 | ||
| 
 | ||
|       await routeStore.createRoute(routeData);
 | ||
| 
 | ||
|       if (!carrier_id && selectedCityStore.selectedCityId) {
 | ||
|         toast.error(
 | ||
|           "В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке"
 | ||
|         );
 | ||
|       }
 | ||
|       createdItem = routeStore.routes.data[routeStore.routes.data.length - 1];
 | ||
|     } else if (featureType === "sight") {
 | ||
|       const name = properties.name || "Достопримечательность 1";
 | ||
|       const latitude = geometry.coordinates[1];
 | ||
|       const longitude = geometry.coordinates[0];
 | ||
| 
 | ||
|       sightsStore.updateCreateSight("ru", { name, address: "" });
 | ||
|       sightsStore.updateCreateSight("en", { name, address: "" });
 | ||
|       sightsStore.updateCreateSight("zh", { name, address: "" });
 | ||
| 
 | ||
|       const selectedCityId = selectedCityStore.selectedCityId || 1;
 | ||
|       await sightsStore.createSightAction(selectedCityId, {
 | ||
|         latitude,
 | ||
|         longitude,
 | ||
|       });
 | ||
|       createdItem = sightsStore.sights[sightsStore.sights.length - 1];
 | ||
|     } else {
 | ||
|       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;
 | ||
|   };
 | ||
| 
 | ||
|   updateFeature = async (featureType: string, geoJsonFeature: any) => {
 | ||
|     const { geometry, properties, id } = geoJsonFeature;
 | ||
|     const numericId = parseInt(String(id).split("-")[1], 10);
 | ||
|     if (isNaN(numericId)) {
 | ||
|       throw new Error(`Invalid feature ID for update: ${id}`);
 | ||
|     }
 | ||
| 
 | ||
|     let data;
 | ||
|     if (featureType === "station") {
 | ||
|       data = {
 | ||
|         name: properties.name,
 | ||
|         latitude: geometry.coordinates[1],
 | ||
|         longitude: geometry.coordinates[0],
 | ||
|       };
 | ||
|     } else if (featureType === "route") {
 | ||
|       const lineGeom = new GeoJSON().readGeometry(geometry, {
 | ||
|         dataProjection: "EPSG:4326",
 | ||
|         featureProjection: "EPSG:3857",
 | ||
|       });
 | ||
|       const centerCoords = getCenter(lineGeom.getExtent());
 | ||
|       const [center_longitude, center_latitude] = toLonLat(
 | ||
|         centerCoords,
 | ||
|         "EPSG:3857"
 | ||
|       );
 | ||
|       data = {
 | ||
|         route_number: properties.name,
 | ||
|         path: geometry.coordinates.map((coord: any) => [coord[1], coord[0]]),
 | ||
|         center_latitude,
 | ||
|         center_longitude,
 | ||
|       };
 | ||
|     } else if (featureType === "sight") {
 | ||
|       data = {
 | ||
|         name: properties.name,
 | ||
|         description: properties.description,
 | ||
|         latitude: geometry.coordinates[1],
 | ||
|         longitude: geometry.coordinates[0],
 | ||
|       };
 | ||
|     } else {
 | ||
|       throw new Error(`Unknown feature type for update: ${featureType}`);
 | ||
|     }
 | ||
| 
 | ||
|     const findOldData = (store: any[], id: number) =>
 | ||
|       store.find((f: any) => f.id === id);
 | ||
|     let oldData;
 | ||
|     if (featureType === "route") oldData = findOldData(this.routes, numericId);
 | ||
|     else if (featureType === "station")
 | ||
|       oldData = findOldData(this.stations, numericId);
 | ||
|     else if (featureType === "sight")
 | ||
|       oldData = findOldData(this.sights, numericId);
 | ||
| 
 | ||
|     if (!oldData) {
 | ||
|       if (properties.isProxy) {
 | ||
|         return;
 | ||
|       }
 | ||
|       throw new Error(
 | ||
|         `Could not find old data for ${featureType} with id ${numericId}`
 | ||
|       );
 | ||
|     }
 | ||
| 
 | ||
|     let requestBody: any;
 | ||
|     if (featureType === "route") {
 | ||
|       requestBody = {
 | ||
|         ...oldData,
 | ||
|         ...data,
 | ||
|       };
 | ||
|     } else {
 | ||
|       requestBody = { ...oldData, ...data };
 | ||
|     }
 | ||
| 
 | ||
|     if (properties.isProxy) {
 | ||
|       return requestBody;
 | ||
|     }
 | ||
| 
 | ||
|     const response = await languageInstance("ru").patch(
 | ||
|       `/${featureType}/${numericId}`,
 | ||
|       requestBody
 | ||
|     );
 | ||
| 
 | ||
|     const updateStore = (store: any[], updatedItem: any) => {
 | ||
|       const index = store.findIndex((f) => f.id === updatedItem.id);
 | ||
|       if (index !== -1) store[index] = updatedItem;
 | ||
|       else store.push(updatedItem);
 | ||
|     };
 | ||
| 
 | ||
|     if (featureType === "route") updateStore(this.routes, response.data);
 | ||
|     else if (featureType === "station")
 | ||
|       updateStore(this.stations, response.data);
 | ||
|     else if (featureType === "sight") updateStore(this.sights, response.data);
 | ||
| 
 | ||
|     return response.data;
 | ||
|   };
 | ||
| }
 | ||
| 
 | ||
| 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";
 | ||
| 
 | ||
| interface MapPosition {
 | ||
|   center: [number, number];
 | ||
|   zoom: number;
 | ||
| }
 | ||
| 
 | ||
| const getStoredMapPosition = (): MapPosition | null => {
 | ||
|   try {
 | ||
|     const stored = localStorage.getItem(MAP_POSITION_KEY);
 | ||
|     if (stored) {
 | ||
|       const position = JSON.parse(stored);
 | ||
|       if (
 | ||
|         position &&
 | ||
|         Array.isArray(position.center) &&
 | ||
|         position.center.length === 2 &&
 | ||
|         typeof position.zoom === "number" &&
 | ||
|         position.zoom >= 0 &&
 | ||
|         position.zoom <= 20
 | ||
|       ) {
 | ||
|         return position;
 | ||
|       }
 | ||
|     }
 | ||
|   } catch (error) {
 | ||
|     console.warn("Failed to parse stored map position:", error);
 | ||
|   }
 | ||
|   return null;
 | ||
| };
 | ||
| 
 | ||
| const saveMapPosition = (position: MapPosition): void => {
 | ||
|   try {
 | ||
|     localStorage.setItem(MAP_POSITION_KEY, JSON.stringify(position));
 | ||
|   } catch (error) {
 | ||
|     console.warn("Failed to save map position:", error);
 | ||
|   }
 | ||
| };
 | ||
| 
 | ||
| // --- ACTIVE SECTION STORAGE ---
 | ||
| const getStoredActiveSection = (): string | null => {
 | ||
|   try {
 | ||
|     const stored = localStorage.getItem(ACTIVE_SECTION_KEY);
 | ||
|     if (stored) {
 | ||
|       return stored;
 | ||
|     }
 | ||
|   } catch (error) {
 | ||
|     console.warn("Failed to get stored active section:", error);
 | ||
|   }
 | ||
|   return null;
 | ||
| };
 | ||
| 
 | ||
| const saveActiveSection = (section: string | null): void => {
 | ||
|   try {
 | ||
|     if (section) {
 | ||
|       localStorage.setItem(ACTIVE_SECTION_KEY, section);
 | ||
|     } else {
 | ||
|       localStorage.removeItem(ACTIVE_SECTION_KEY);
 | ||
|     }
 | ||
|   } catch (error) {
 | ||
|     console.warn("Failed to save active section:", error);
 | ||
|   }
 | ||
| };
 | ||
| 
 | ||
| // --- TYPE DEFINITIONS ---
 | ||
| interface MapServiceConfig {
 | ||
|   target: HTMLElement;
 | ||
|   center: [number, number];
 | ||
|   zoom: number;
 | ||
| }
 | ||
| 
 | ||
| type FeatureType = "station" | "route" | "sight";
 | ||
| 
 | ||
| class MapService {
 | ||
|   private map: Map | null;
 | ||
|   public pointSource: VectorSource<Feature<Point>>;
 | ||
|   public lineSource: VectorSource<Feature<LineString>>;
 | ||
|   public clusterLayer: VectorLayer<Cluster>; // Public for the deselect handler
 | ||
|   public routeLayer: VectorLayer<VectorSource<Feature<LineString>>>; // Public for deselect
 | ||
|   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;
 | ||
|   private modifyInteraction: Modify;
 | ||
|   private selectInteraction: Select;
 | ||
|   private hoveredFeatureId: string | number | null;
 | ||
|   private boundHandlePointerMove: (
 | ||
|     event: MapBrowserEvent<PointerEvent>
 | ||
|   ) => void;
 | ||
|   private boundHandlePointerLeave: () => void;
 | ||
|   private boundHandleContextMenu: (event: MouseEvent) => void;
 | ||
|   private boundHandleKeyDown: (event: KeyboardEvent) => void;
 | ||
|   private lassoInteraction: Draw | null = null;
 | ||
|   private selectedIds: Set<string | number> = new Set();
 | ||
|   private onSelectionChange: ((ids: Set<string | number>) => void) | null =
 | ||
|     null;
 | ||
|   private isCreating: boolean = false;
 | ||
| 
 | ||
|   // Styles
 | ||
|   private defaultStyle: Style;
 | ||
|   private selectedStyle: Style;
 | ||
|   private drawStyle: Style;
 | ||
|   private busIconStyle: Style;
 | ||
|   private selectedBusIconStyle: Style;
 | ||
|   private drawBusIconStyle: Style;
 | ||
|   private sightIconStyle: Style;
 | ||
|   private selectedSightIconStyle: Style;
 | ||
|   private drawSightIconStyle: Style;
 | ||
| 
 | ||
|   private universalHoverStylePoint: Style;
 | ||
|   private hoverSightIconStyle: Style;
 | ||
|   private universalHoverStyleLine: Style;
 | ||
| 
 | ||
|   // Callbacks
 | ||
|   private setLoading: (loading: boolean) => void;
 | ||
|   private setError: (error: string | null) => void;
 | ||
|   private onModeChangeCallback: (mode: string) => void;
 | ||
|   private onFeaturesChange: (features: Feature<Geometry>[]) => void;
 | ||
|   private onFeatureSelect: (feature: Feature<Geometry> | null) => void;
 | ||
| 
 | ||
|   constructor(
 | ||
|     config: MapServiceConfig,
 | ||
|     setLoading: (loading: boolean) => void,
 | ||
|     setError: (error: string | null) => void,
 | ||
|     onModeChangeCallback: (mode: string) => void,
 | ||
|     onFeaturesChange: (features: Feature<Geometry>[]) => void,
 | ||
|     onFeatureSelect: (feature: Feature<Geometry> | null) => void,
 | ||
|     tooltipElement: HTMLElement,
 | ||
|     onSelectionChange?: (ids: Set<string | number>) => void
 | ||
|   ) {
 | ||
|     this.map = null;
 | ||
|     this.tooltipElement = tooltipElement;
 | ||
|     this.tooltipOverlay = null;
 | ||
|     this.mode = null;
 | ||
|     this.currentDrawingType = null;
 | ||
|     this.currentDrawingFeatureType = null;
 | ||
|     this.currentInteraction = null;
 | ||
|     this.hoveredFeatureId = null;
 | ||
|     this.clusterStyleCache = {};
 | ||
| 
 | ||
|     this.setLoading = setLoading;
 | ||
|     this.setError = setError;
 | ||
|     this.onModeChangeCallback = onModeChangeCallback;
 | ||
|     this.onFeaturesChange = onFeaturesChange;
 | ||
|     this.onFeatureSelect = onFeatureSelect;
 | ||
| 
 | ||
|     this.defaultStyle = new Style({
 | ||
|       fill: new Fill({ color: "rgba(66, 153, 225, 0.2)" }),
 | ||
|       stroke: new Stroke({ color: "#3182ce", width: 8 }),
 | ||
|     });
 | ||
| 
 | ||
|     this.selectedStyle = new Style({
 | ||
|       fill: new Fill({ color: "rgba(221, 107, 32, 0.3)" }),
 | ||
|       stroke: new Stroke({ color: "#dd6b20", width: 8 }),
 | ||
|     });
 | ||
| 
 | ||
|     this.drawStyle = new Style({
 | ||
|       fill: new Fill({ color: "rgba(74, 222, 128, 0.3)" }),
 | ||
|       stroke: new Stroke({
 | ||
|         color: "rgba(34, 197, 94, 0.7)",
 | ||
|         width: 8,
 | ||
|         lineDash: [5, 5],
 | ||
|       }),
 | ||
|       image: new CircleStyle({
 | ||
|         radius: 5,
 | ||
|         fill: new Fill({ color: "rgba(34, 197, 94, 0.7)" }),
 | ||
|       }),
 | ||
|     });
 | ||
| 
 | ||
|     this.busIconStyle = new Style({
 | ||
|       image: new CircleStyle({
 | ||
|         radius: 8,
 | ||
|         fill: new Fill({ color: "rgba(0, 60, 255, 0.8)" }),
 | ||
|         stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
 | ||
|       }),
 | ||
|     });
 | ||
|     this.selectedBusIconStyle = new Style({
 | ||
|       image: new CircleStyle({
 | ||
|         radius: 10,
 | ||
|         fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
 | ||
|         stroke: new Stroke({ color: "#ffffff", width: 2 }),
 | ||
|       }),
 | ||
|     });
 | ||
|     this.drawBusIconStyle = new Style({
 | ||
|       image: new CircleStyle({
 | ||
|         radius: 8,
 | ||
|         fill: new Fill({ color: "rgba(100, 180, 100, 0.8)" }),
 | ||
|         stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
 | ||
|       }),
 | ||
|     });
 | ||
| 
 | ||
|     this.sightIconStyle = new Style({
 | ||
|       image: new RegularShape({
 | ||
|         fill: new Fill({ color: "rgba(139, 92, 246, 0.8)" }),
 | ||
|         stroke: new Stroke({ color: "#ffffff", width: 2 }),
 | ||
|         points: 5,
 | ||
|         radius: 12,
 | ||
|         radius2: 6,
 | ||
|         angle: 0,
 | ||
|       }),
 | ||
|     });
 | ||
|     this.selectedSightIconStyle = new Style({
 | ||
|       image: new RegularShape({
 | ||
|         fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
 | ||
|         stroke: new Stroke({ color: "#ffffff", width: 2 }),
 | ||
|         points: 5,
 | ||
|         radius: 12,
 | ||
|         radius2: 6,
 | ||
|         angle: 0,
 | ||
|       }),
 | ||
|     });
 | ||
|     this.drawSightIconStyle = new Style({
 | ||
|       image: new RegularShape({
 | ||
|         fill: new Fill({ color: "rgba(100, 180, 100, 0.8)" }),
 | ||
|         stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
 | ||
|         points: 5,
 | ||
|         radius: 12,
 | ||
|         radius2: 6,
 | ||
|         angle: 0,
 | ||
|       }),
 | ||
|     });
 | ||
| 
 | ||
|     this.universalHoverStylePoint = new Style({
 | ||
|       image: new CircleStyle({
 | ||
|         radius: 11,
 | ||
|         fill: new Fill({ color: "rgba(255, 165, 0, 0.7)" }),
 | ||
|         stroke: new Stroke({ color: "rgba(255,255,255,0.8)", width: 2 }),
 | ||
|       }),
 | ||
|       zIndex: Infinity,
 | ||
|     });
 | ||
|     this.hoverSightIconStyle = new Style({
 | ||
|       image: new RegularShape({
 | ||
|         fill: new Fill({ color: "rgba(255, 165, 0, 0.7)" }),
 | ||
|         stroke: new Stroke({ color: "rgba(255,255,255,0.8)", width: 2 }),
 | ||
|         points: 5,
 | ||
|         radius: 15,
 | ||
|         radius2: 7.5,
 | ||
|         angle: 0,
 | ||
|       }),
 | ||
|       zIndex: Infinity,
 | ||
|     });
 | ||
|     this.universalHoverStyleLine = new Style({
 | ||
|       stroke: new Stroke({ color: "rgba(255, 165, 0, 0.8)", width: 8 }),
 | ||
|       zIndex: Infinity,
 | ||
|     });
 | ||
| 
 | ||
|     this.pointSource = new VectorSource();
 | ||
|     this.lineSource = new VectorSource();
 | ||
|     this.clusterSource = new Cluster({
 | ||
|       distance: 45,
 | ||
|       source: this.pointSource,
 | ||
|     });
 | ||
| 
 | ||
|     this.routeLayer = new VectorLayer({
 | ||
|       source: this.lineSource,
 | ||
|       // @ts-ignore
 | ||
|       style: (featureLike: FeatureLike) => {
 | ||
|         const feature = featureLike as Feature<Geometry>;
 | ||
|         if (!feature) return this.defaultStyle;
 | ||
|         const fId = feature.getId();
 | ||
| 
 | ||
|         // Все маршруты всегда отображаются, так как они не кластеризуются
 | ||
|         const isSelected =
 | ||
|           this.selectInteraction?.getFeatures().getArray().includes(feature) ||
 | ||
|           (fId !== undefined && this.selectedIds.has(fId));
 | ||
|         const isHovered = this.hoveredFeatureId === fId;
 | ||
| 
 | ||
|         if (isHovered) return this.universalHoverStyleLine;
 | ||
|         if (isSelected) return this.selectedStyle;
 | ||
|         return this.defaultStyle;
 | ||
|       },
 | ||
|     });
 | ||
| 
 | ||
|     this.clusterLayer = new VectorLayer({
 | ||
|       source: this.clusterSource,
 | ||
|       style: (featureLike: FeatureLike) => {
 | ||
|         const clusterFeature = featureLike as Feature<Point>;
 | ||
|         const featuresInCluster = clusterFeature.get(
 | ||
|           "features"
 | ||
|         ) as Feature<Point>[];
 | ||
|         const size = featuresInCluster.length;
 | ||
| 
 | ||
|         if (size > 1) {
 | ||
|           let style = this.clusterStyleCache[size];
 | ||
|           if (!style) {
 | ||
|             style = new Style({
 | ||
|               image: new CircleStyle({
 | ||
|                 radius: 12 + Math.log(size) * 3,
 | ||
|                 fill: new Fill({ color: "rgba(56, 189, 248, 0.9)" }),
 | ||
|                 stroke: new Stroke({ color: "#fff", width: 2 }),
 | ||
|               }),
 | ||
|               text: new Text({
 | ||
|                 text: size.toString(),
 | ||
|                 fill: new Fill({ color: "#fff" }),
 | ||
|                 font: "bold 12px sans-serif",
 | ||
|               }),
 | ||
|             });
 | ||
|             this.clusterStyleCache[size] = style;
 | ||
|           }
 | ||
|           return style;
 | ||
|         } else {
 | ||
|           const originalFeature = featuresInCluster[0];
 | ||
|           const fId = originalFeature.getId();
 | ||
|           const featureType = originalFeature.get("featureType");
 | ||
| 
 | ||
|           const isSelected = fId !== undefined && this.selectedIds.has(fId);
 | ||
|           const isHovered = this.hoveredFeatureId === fId;
 | ||
| 
 | ||
|           if (isHovered) {
 | ||
|             return featureType === "sight"
 | ||
|               ? this.hoverSightIconStyle
 | ||
|               : this.universalHoverStylePoint;
 | ||
|           }
 | ||
| 
 | ||
|           if (isSelected) {
 | ||
|             if (featureType === "sight") return this.selectedSightIconStyle;
 | ||
|             return this.selectedBusIconStyle;
 | ||
|           }
 | ||
| 
 | ||
|           if (featureType === "sight") return this.sightIconStyle;
 | ||
|           return this.busIconStyle;
 | ||
|         }
 | ||
|       },
 | ||
|     });
 | ||
| 
 | ||
|     this.clusterSource.on("change", () => {
 | ||
|       // Поскольку маршруты больше не добавляются как точки,
 | ||
|       // нам не нужно отслеживать unclusteredRouteIds
 | ||
|       // Все маршруты всегда отображаются как линии
 | ||
|       this.routeLayer.changed();
 | ||
|     });
 | ||
| 
 | ||
|     this.boundHandlePointerMove = this.handlePointerMove.bind(this);
 | ||
|     this.boundHandlePointerLeave = this.handlePointerLeave.bind(this);
 | ||
|     this.boundHandleContextMenu = this.handleContextMenu.bind(this);
 | ||
|     this.boundHandleKeyDown = this.handleKeyDown.bind(this);
 | ||
| 
 | ||
|     this.pointSource.on(
 | ||
|       "addfeature",
 | ||
|       this.handleFeatureEvent.bind(this) as any
 | ||
|     );
 | ||
|     this.pointSource.on("removefeature", () => this.updateFeaturesInReact());
 | ||
|     this.pointSource.on(
 | ||
|       "changefeature",
 | ||
|       this.handleFeatureChange.bind(this) as any
 | ||
|     );
 | ||
|     this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any);
 | ||
|     this.lineSource.on("removefeature", () => this.updateFeaturesInReact());
 | ||
|     this.lineSource.on(
 | ||
|       "changefeature",
 | ||
|       this.handleFeatureChange.bind(this) as any
 | ||
|     );
 | ||
| 
 | ||
|     let renderCompleteHandled = false;
 | ||
|     const MAP_LOAD_TIMEOUT = 15000;
 | ||
|     try {
 | ||
|       const storedPosition = getStoredMapPosition();
 | ||
|       const initialCenter = storedPosition?.center || config.center;
 | ||
|       const initialZoom = storedPosition?.zoom || config.zoom;
 | ||
| 
 | ||
|       this.map = new Map({
 | ||
|         target: config.target,
 | ||
|         layers: [
 | ||
|           new TileLayer({ source: new OSM() }),
 | ||
|           this.routeLayer,
 | ||
|           this.clusterLayer,
 | ||
|         ],
 | ||
|         view: new View({
 | ||
|           center: transform(initialCenter, "EPSG:4326", "EPSG:3857"),
 | ||
|           zoom: initialZoom,
 | ||
|         }),
 | ||
|         interactions: [
 | ||
|           new MouseWheelZoom(),
 | ||
|           new KeyboardPan(),
 | ||
|           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 false;
 | ||
|             },
 | ||
|           }),
 | ||
|         ],
 | ||
|         controls: [],
 | ||
|       });
 | ||
| 
 | ||
|       this.map.getView().on("change:center", () => {
 | ||
|         const center = this.map?.getView().getCenter();
 | ||
|         const zoom = this.map?.getView().getZoom();
 | ||
|         if (center && zoom !== undefined && this.map) {
 | ||
|           const [lon, lat] = toLonLat(
 | ||
|             center,
 | ||
|             this.map.getView().getProjection()
 | ||
|           );
 | ||
|           saveMapPosition({ center: [lon, lat], zoom });
 | ||
|         }
 | ||
|       });
 | ||
| 
 | ||
|       this.map.getView().on("change:resolution", () => {
 | ||
|         const center = this.map?.getView().getCenter();
 | ||
|         const zoom = this.map?.getView().getZoom();
 | ||
|         if (center && zoom !== undefined && this.map) {
 | ||
|           const [lon, lat] = toLonLat(
 | ||
|             center,
 | ||
|             this.map.getView().getProjection()
 | ||
|           );
 | ||
|           saveMapPosition({ center: [lon, lat], zoom });
 | ||
|         }
 | ||
|       });
 | ||
| 
 | ||
|       if (this.tooltipElement && this.map) {
 | ||
|         this.tooltipOverlay = new Overlay({
 | ||
|           element: this.tooltipElement,
 | ||
|           offset: [15, 0],
 | ||
|           positioning: "center-left",
 | ||
|         });
 | ||
|         this.map.addOverlay(this.tooltipOverlay);
 | ||
|       }
 | ||
|       this.map.once("rendercomplete", () => {
 | ||
|         if (!renderCompleteHandled) {
 | ||
|           this.setLoading(false);
 | ||
|           renderCompleteHandled = true;
 | ||
|           setError(null);
 | ||
|         }
 | ||
|       });
 | ||
|       setTimeout(() => {
 | ||
|         if (!renderCompleteHandled && this.map) {
 | ||
|           this.setLoading(false);
 | ||
|           this.setError("Карта не загрузилась вовремя.");
 | ||
|           renderCompleteHandled = true;
 | ||
|         }
 | ||
|       }, MAP_LOAD_TIMEOUT);
 | ||
|     } catch (error) {
 | ||
|       this.setError("Критическая ошибка при инициализации карты.");
 | ||
|       this.setLoading(false);
 | ||
|       renderCompleteHandled = true;
 | ||
|     }
 | ||
| 
 | ||
|     this.selectInteraction = new Select({
 | ||
|       style: null,
 | ||
|       condition: singleClick,
 | ||
|       filter: (feature: FeatureLike, l: Layer<Source, any> | null) => {
 | ||
|         if (l !== this.clusterLayer && l !== this.routeLayer) return false;
 | ||
|         const originalFeatures = feature.get("features");
 | ||
|         if (
 | ||
|           originalFeatures &&
 | ||
|           originalFeatures.length === 1 &&
 | ||
|           originalFeatures[0].get("isProxy")
 | ||
|         )
 | ||
|           return false; // Ignore proxy points
 | ||
|         return true;
 | ||
|       },
 | ||
|       multi: true,
 | ||
|     });
 | ||
| 
 | ||
|     this.modifyInteraction = new Modify({
 | ||
|       features: this.selectInteraction.getFeatures(),
 | ||
|       style: new Style({
 | ||
|         image: new CircleStyle({
 | ||
|           radius: 6,
 | ||
|           fill: new Fill({
 | ||
|             color: "rgba(255, 255, 255, 0.8)",
 | ||
|           }),
 | ||
|           stroke: new Stroke({
 | ||
|             color: "#0099ff",
 | ||
|             width: 2.5,
 | ||
|           }),
 | ||
|         }),
 | ||
|       }),
 | ||
|       deleteCondition: (e: MapBrowserEvent<any>) => {
 | ||
|         if (!doubleClick(e)) {
 | ||
|           return false;
 | ||
|         }
 | ||
|         const selectedFeatures = this.selectInteraction.getFeatures();
 | ||
|         if (selectedFeatures.getLength() !== 1) {
 | ||
|           return true;
 | ||
|         }
 | ||
|         const feature = selectedFeatures.item(0) as Feature<Geometry>;
 | ||
|         const geometry = feature.getGeometry();
 | ||
|         if (!geometry || geometry.getType() !== "LineString") {
 | ||
|           return true;
 | ||
|         }
 | ||
|         const lineString = geometry as LineString;
 | ||
|         const coordinates = lineString.getCoordinates();
 | ||
|         if (coordinates.length <= 2) {
 | ||
|           toast.info("В маршруте должно быть не менее 2 точек.");
 | ||
|           return false;
 | ||
|         }
 | ||
|         const clickCoordinate = e.coordinate;
 | ||
|         let closestVertexIndex = -1;
 | ||
|         let minDistanceSq = Infinity;
 | ||
|         coordinates.forEach((vertex, index) => {
 | ||
|           const dx = vertex[0] - clickCoordinate[0];
 | ||
|           const dy = vertex[1] - clickCoordinate[1];
 | ||
|           const distanceSq = dx * dx + dy * dy;
 | ||
|           if (distanceSq < minDistanceSq) {
 | ||
|             minDistanceSq = distanceSq;
 | ||
|             closestVertexIndex = index;
 | ||
|           }
 | ||
|         });
 | ||
|         if (
 | ||
|           closestVertexIndex === 0 ||
 | ||
|           closestVertexIndex === coordinates.length - 1
 | ||
|         ) {
 | ||
|           return false;
 | ||
|         }
 | ||
|         return true;
 | ||
|       },
 | ||
|     });
 | ||
| 
 | ||
|     this.modifyInteraction.on("modifyend", (event) => {
 | ||
|       event.features.getArray().forEach((feature) => {
 | ||
|         this.saveModifiedFeature(feature as Feature<Geometry>);
 | ||
|       });
 | ||
|     });
 | ||
| 
 | ||
|     if (this.map) {
 | ||
|       this.map.on("dblclick", (event: MapBrowserEvent<any>) => {
 | ||
|         if (this.mode !== "edit") return;
 | ||
| 
 | ||
|         const layerFilter = (l: Layer<Source, any>) => l === this.routeLayer;
 | ||
| 
 | ||
|         const feature = this.map?.forEachFeatureAtPixel(
 | ||
|           event.pixel,
 | ||
|           (f: FeatureLike) => f as Feature<Geometry>,
 | ||
|           { layerFilter, hitTolerance: 5 }
 | ||
|         );
 | ||
| 
 | ||
|         if (!feature) return;
 | ||
| 
 | ||
|         const featureType = feature.get("featureType");
 | ||
|         if (featureType !== "route") return;
 | ||
| 
 | ||
|         const geometry = feature.getGeometry();
 | ||
|         if (!geometry || geometry.getType() !== "LineString") return;
 | ||
| 
 | ||
|         const lineString = geometry as LineString;
 | ||
|         const coordinates = lineString.getCoordinates();
 | ||
| 
 | ||
|         if (coordinates.length <= 2) {
 | ||
|           toast.info("В маршруте должно быть не менее 2 точек.");
 | ||
|           return;
 | ||
|         }
 | ||
| 
 | ||
|         const clickCoordinate = event.coordinate;
 | ||
|         let closestIndex = -1;
 | ||
|         let minDistanceSq = Infinity;
 | ||
| 
 | ||
|         coordinates.forEach((vertex, index) => {
 | ||
|           const dx = vertex[0] - clickCoordinate[0];
 | ||
|           const dy = vertex[1] - clickCoordinate[1];
 | ||
|           const distanceSq = dx * dx + dy * dy;
 | ||
|           if (distanceSq < minDistanceSq) {
 | ||
|             minDistanceSq = distanceSq;
 | ||
|             closestIndex = index;
 | ||
|           }
 | ||
|         });
 | ||
| 
 | ||
|         if (closestIndex === 0 || closestIndex === coordinates.length - 1) {
 | ||
|           return;
 | ||
|         }
 | ||
| 
 | ||
|         const newCoordinates = coordinates.filter(
 | ||
|           (_, index) => index !== closestIndex
 | ||
|         );
 | ||
|         lineString.setCoordinates(newCoordinates);
 | ||
|         this.saveModifiedFeature(feature);
 | ||
|       });
 | ||
|     }
 | ||
| 
 | ||
|     this.lassoInteraction = new Draw({
 | ||
|       type: "Polygon",
 | ||
|       style: new Style({
 | ||
|         stroke: new Stroke({ color: "#14b8a6", width: 2 }),
 | ||
|         fill: new Fill({ color: "rgba(20, 184, 166, 0.1)" }),
 | ||
|       }),
 | ||
|     });
 | ||
|     this.lassoInteraction.setActive(false);
 | ||
|     this.lassoInteraction.on("drawend", (event: DrawEvent) => {
 | ||
|       const geometry = event.feature.getGeometry() as Polygon;
 | ||
|       const extent = geometry.getExtent();
 | ||
|       const selected = new Set<string | number>();
 | ||
| 
 | ||
|       this.pointSource.forEachFeatureInExtent(extent, (f) => {
 | ||
|         if (f.get("isProxy")) return; // Ignore proxy in lasso
 | ||
|         const geom = f.getGeometry();
 | ||
|         if (geom && geom.getType() === "Point") {
 | ||
|           const pointCoords = (geom as Point).getCoordinates();
 | ||
|           if (geometry.intersectsCoordinate(pointCoords)) {
 | ||
|             if (f.getId() !== undefined) selected.add(f.getId()!);
 | ||
|           }
 | ||
|         }
 | ||
|       });
 | ||
| 
 | ||
|       this.lineSource.forEachFeatureInExtent(
 | ||
|         extent,
 | ||
|         (f: Feature<LineString>) => {
 | ||
|           const lineGeom = f.getGeometry();
 | ||
|           if (lineGeom) {
 | ||
|             const intersects = lineGeom
 | ||
|               .getCoordinates()
 | ||
|               .some((coord) => geometry.intersectsCoordinate(coord));
 | ||
|             if (intersects && f.getId() !== undefined) {
 | ||
|               selected.add(f.getId()!);
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
|       );
 | ||
| 
 | ||
|       this.setSelectedIds(selected);
 | ||
|       this.deactivateLasso();
 | ||
|     });
 | ||
| 
 | ||
|     if (this.map) {
 | ||
|       this.map.addInteraction(this.modifyInteraction);
 | ||
|       this.map.addInteraction(this.selectInteraction);
 | ||
|       this.map.addInteraction(this.lassoInteraction);
 | ||
|       this.modifyInteraction.setActive(false);
 | ||
|       this.selectInteraction.setActive(false);
 | ||
|       this.lassoInteraction.setActive(false);
 | ||
| 
 | ||
|       // --- ИСПРАВЛЕНИЕ: Главный обработчик выбора объектов и кластеров
 | ||
|       this.selectInteraction.on("select", (e: SelectEvent) => {
 | ||
|         if (this.mode !== "edit" || !this.map) return;
 | ||
| 
 | ||
|         const ctrlKey =
 | ||
|           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<Point>) => {
 | ||
|               const geom = feat.getGeometry();
 | ||
|               if (geom) extend(extent, geom.getExtent());
 | ||
|             });
 | ||
|             this.map.getView().fit(extent, {
 | ||
|               duration: 500,
 | ||
|               padding: [60, 60, 60, 60],
 | ||
|               maxZoom: 18,
 | ||
|             });
 | ||
|             // Сбрасываем выделение, так как мы не хотим "выделять" сам кластер
 | ||
|             this.selectInteraction.getFeatures().clear();
 | ||
|             this.setSelectedIds(new Set());
 | ||
|             return; // Завершаем обработку
 | ||
|           }
 | ||
|         }
 | ||
| 
 | ||
|         // Стандартная логика выделения для одиночных объектов (или с Ctrl)
 | ||
|         const newSelectedIds = ctrlKey
 | ||
|           ? new Set(this.selectedIds)
 | ||
|           : new Set<string | number>();
 | ||
| 
 | ||
|         e.selected.forEach((feature) => {
 | ||
|           const originalFeatures = feature.get("features");
 | ||
|           let targetId: string | number | undefined;
 | ||
| 
 | ||
|           if (originalFeatures && originalFeatures.length > 0) {
 | ||
|             // Это фича из кластера (может быть и одна)
 | ||
|             targetId = originalFeatures[0].getId();
 | ||
|           } else {
 | ||
|             // Это линия или что-то не из кластера
 | ||
|             targetId = feature.getId();
 | ||
|           }
 | ||
| 
 | ||
|           if (targetId !== undefined) {
 | ||
|             newSelectedIds.add(targetId);
 | ||
|           }
 | ||
|         });
 | ||
| 
 | ||
|         e.deselected.forEach((feature) => {
 | ||
|           const originalFeatures = feature.get("features");
 | ||
|           let targetId: string | number | undefined;
 | ||
| 
 | ||
|           if (originalFeatures && originalFeatures.length > 0) {
 | ||
|             targetId = originalFeatures[0].getId();
 | ||
|           } else {
 | ||
|             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(); // Предотвращаем скролл страницы
 | ||
|             targetEl.style.cursor = "grabbing";
 | ||
|           }
 | ||
|         });
 | ||
| 
 | ||
|         targetEl.addEventListener("pointerup", (e) => {
 | ||
|           if (e.button === 1) {
 | ||
|             // Средняя кнопка мыши отпущена
 | ||
|             e.preventDefault(); // Предотвращаем скролл страницы
 | ||
|             targetEl.style.cursor = "pointer";
 | ||
|           }
 | ||
|         });
 | ||
| 
 | ||
|         // Также добавляем обработчик для mousedown/mouseup для совместимости
 | ||
|         targetEl.addEventListener("mousedown", (e) => {
 | ||
|           if (e.button === 1) {
 | ||
|             // Средняя кнопка мыши
 | ||
|             e.preventDefault(); // Предотвращаем скролл страницы
 | ||
|             targetEl.style.cursor = "grabbing";
 | ||
|           }
 | ||
|         });
 | ||
| 
 | ||
|         targetEl.addEventListener("mouseup", (e) => {
 | ||
|           if (e.button === 1) {
 | ||
|             // Средняя кнопка мыши отпущена
 | ||
|             e.preventDefault(); // Предотвращаем скролл страницы
 | ||
|             targetEl.style.cursor = "pointer";
 | ||
|           }
 | ||
|         });
 | ||
| 
 | ||
|         // Дополнительная защита от нежелательного поведения средней кнопки мыши
 | ||
|         targetEl.addEventListener("auxclick", (e) => {
 | ||
|           if (e.button === 1) {
 | ||
|             // Средняя кнопка мыши
 | ||
|             e.preventDefault(); // Предотвращаем открытие ссылки в новой вкладке
 | ||
|           }
 | ||
|         });
 | ||
|       }
 | ||
|       document.addEventListener("keydown", this.boundHandleKeyDown);
 | ||
|       this.activateEditMode();
 | ||
|     }
 | ||
| 
 | ||
|     if (onSelectionChange) {
 | ||
|       this.onSelectionChange = onSelectionChange;
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   public unselect(): void {
 | ||
|     this.selectInteraction.getFeatures().clear();
 | ||
|     this.modifyInteraction.setActive(false);
 | ||
|     this.onFeatureSelect(null);
 | ||
|     this.setSelectedIds(new Set());
 | ||
|   }
 | ||
| 
 | ||
|   public loadFeaturesFromApi(
 | ||
|     _apiStations: typeof mapStore.stations,
 | ||
|     _apiRoutes: typeof mapStore.routes,
 | ||
|     _apiSights: typeof mapStore.sights
 | ||
|   ): void {
 | ||
|     if (!this.map) return;
 | ||
| 
 | ||
|     const projection = this.map.getView().getProjection();
 | ||
|     const pointFeatures: Feature<Point>[] = [];
 | ||
|     const lineFeatures: Feature<LineString>[] = [];
 | ||
| 
 | ||
|     // Используем фильтрованные данные из mapStore
 | ||
|     const filteredStations = mapStore.filteredStations;
 | ||
|     const filteredSights = mapStore.filteredSights;
 | ||
|     const filteredRoutes = mapStore.filteredRoutes;
 | ||
| 
 | ||
|     filteredStations.forEach((station) => {
 | ||
|       if (station.longitude == null || station.latitude == null) return;
 | ||
|       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");
 | ||
|       pointFeatures.push(feature);
 | ||
|     });
 | ||
| 
 | ||
|     filteredSights.forEach((sight) => {
 | ||
|       if (sight.longitude == null || sight.latitude == null) return;
 | ||
|       const point = new Point(
 | ||
|         transform([sight.longitude, sight.latitude], "EPSG:4326", projection)
 | ||
|       );
 | ||
|       const feature = new Feature({
 | ||
|         geometry: point,
 | ||
|         name: sight.name,
 | ||
|         description: sight.description,
 | ||
|       });
 | ||
|       feature.setId(`sight-${sight.id}`);
 | ||
|       feature.set("featureType", "sight");
 | ||
|       pointFeatures.push(feature);
 | ||
|     });
 | ||
| 
 | ||
|     filteredRoutes.forEach((route) => {
 | ||
|       if (!route.path || route.path.length === 0) 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) return;
 | ||
|       const routeId = `route-${route.id}`;
 | ||
|       const line = new LineString(coordinates);
 | ||
|       const lineFeature = new Feature({
 | ||
|         geometry: line,
 | ||
|         name: route.route_number,
 | ||
|       });
 | ||
|       lineFeature.setId(routeId);
 | ||
|       lineFeature.set("featureType", "route");
 | ||
|       lineFeatures.push(lineFeature);
 | ||
|     });
 | ||
| 
 | ||
|     this.pointSource.addFeatures(pointFeatures);
 | ||
|     this.lineSource.addFeatures(lineFeatures);
 | ||
| 
 | ||
|     this.updateFeaturesInReact();
 | ||
|   }
 | ||
| 
 | ||
|   private updateFeaturesInReact(): void {
 | ||
|     if (this.onFeaturesChange) {
 | ||
|       const allFeatures = [
 | ||
|         ...this.pointSource.getFeatures(),
 | ||
|         ...this.lineSource.getFeatures(),
 | ||
|       ];
 | ||
|       this.onFeaturesChange(allFeatures);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   private handlePointerLeave(): void {
 | ||
|     if (this.hoveredFeatureId) {
 | ||
|       this.hoveredFeatureId = null;
 | ||
|       this.clusterLayer.changed();
 | ||
|       this.routeLayer.changed();
 | ||
|     }
 | ||
|     if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
 | ||
| 
 | ||
|     // Сбрасываем курсор при покидании области карты
 | ||
|     if (this.map) {
 | ||
|       const targetEl = this.map.getTargetElement();
 | ||
|       if (targetEl instanceof HTMLElement) {
 | ||
|         targetEl.style.cursor = "pointer";
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   private handleKeyDown(event: KeyboardEvent): void {
 | ||
|     if (event.key === "Escape") {
 | ||
|       this.unselect();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   private setMode(newMode: string): void {
 | ||
|     if (!this.map) return;
 | ||
|     const oldMode = this.mode;
 | ||
|     this.mode = newMode;
 | ||
|     if (this.onModeChangeCallback) this.onModeChangeCallback(newMode);
 | ||
| 
 | ||
|     if (this.hoveredFeatureId && oldMode !== newMode) {
 | ||
|       this.hoveredFeatureId = null;
 | ||
|       this.clusterLayer.changed();
 | ||
|       this.routeLayer.changed();
 | ||
|       if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
 | ||
|     }
 | ||
| 
 | ||
|     if (this.currentInteraction instanceof Draw) {
 | ||
|       this.map.removeInteraction(this.currentInteraction);
 | ||
|       this.currentInteraction = null;
 | ||
|     }
 | ||
| 
 | ||
|     if (newMode === "edit") {
 | ||
|       this.selectInteraction.setActive(true);
 | ||
|     } else {
 | ||
|       this.unselect();
 | ||
|       this.selectInteraction.setActive(false);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   public activateEditMode(): void {
 | ||
|     this.currentDrawingType = null;
 | ||
|     this.setMode("edit");
 | ||
|   }
 | ||
| 
 | ||
|   public startDrawing(
 | ||
|     type: "Point" | "LineString",
 | ||
|     featureType: FeatureType
 | ||
|   ): void {
 | ||
|     if (!this.map) return;
 | ||
| 
 | ||
|     this.currentDrawingType = type;
 | ||
|     this.currentDrawingFeatureType = featureType;
 | ||
| 
 | ||
|     const drawingMode = `drawing-${featureType}`;
 | ||
|     this.setMode(drawingMode);
 | ||
| 
 | ||
|     if (this.currentInteraction instanceof Draw) {
 | ||
|       this.map.removeInteraction(this.currentInteraction);
 | ||
|     }
 | ||
| 
 | ||
|     let styleForDrawing: Style;
 | ||
|     if (featureType === "station") styleForDrawing = this.drawBusIconStyle;
 | ||
|     else if (featureType === "sight") styleForDrawing = this.drawSightIconStyle;
 | ||
|     else styleForDrawing = this.drawStyle;
 | ||
| 
 | ||
|     const sourceForDrawing =
 | ||
|       type === "Point" ? this.pointSource : this.lineSource;
 | ||
| 
 | ||
|     this.currentInteraction = new Draw({
 | ||
|       source: sourceForDrawing,
 | ||
|       type,
 | ||
|       style: styleForDrawing,
 | ||
|     });
 | ||
| 
 | ||
|     this.currentInteraction.on("drawend", async (event: DrawEvent) => {
 | ||
|       const feature = event.feature as Feature<Geometry>;
 | ||
|       const fType = this.currentDrawingFeatureType;
 | ||
|       if (!fType) return;
 | ||
| 
 | ||
|       // Проверяем, не идет ли уже процесс создания
 | ||
|       if (this.isCreating) {
 | ||
|         toast.warning("Дождитесь завершения создания предыдущего объекта.");
 | ||
|         // Удаляем созданный объект из источника
 | ||
|         const sourceForDrawing =
 | ||
|           type === "Point" ? this.pointSource : this.lineSource;
 | ||
|         setTimeout(() => {
 | ||
|           if (sourceForDrawing.hasFeature(feature as any)) {
 | ||
|             sourceForDrawing.removeFeature(feature as any);
 | ||
|           }
 | ||
|         }, 0);
 | ||
|         return;
 | ||
|       }
 | ||
| 
 | ||
|       feature.set("featureType", fType);
 | ||
| 
 | ||
|       let resourceName: string;
 | ||
| 
 | ||
|       switch (fType) {
 | ||
|         case "station":
 | ||
|           // Используем полный список из mapStore, а не отфильтрованный
 | ||
|           const stationNumbers = mapStore.stations
 | ||
|             .map((station) => {
 | ||
|               const match = station.name?.match(/^Остановка (\d+)$/);
 | ||
|               return match ? parseInt(match[1], 10) : 0;
 | ||
|             })
 | ||
|             .filter((num) => num > 0);
 | ||
|           const nextStationNumber =
 | ||
|             stationNumbers.length > 0 ? Math.max(...stationNumbers) + 1 : 1;
 | ||
|           resourceName = `Остановка ${nextStationNumber}`;
 | ||
|           break;
 | ||
|         case "sight":
 | ||
|           // Используем полный список из mapStore, а не отфильтрованный
 | ||
|           const sightNumbers = mapStore.sights
 | ||
|             .map((sight) => {
 | ||
|               const match = sight.name?.match(/^Достопримечательность (\d+)$/);
 | ||
|               return match ? parseInt(match[1], 10) : 0;
 | ||
|             })
 | ||
|             .filter((num) => num > 0);
 | ||
|           const nextSightNumber =
 | ||
|             sightNumbers.length > 0 ? Math.max(...sightNumbers) + 1 : 1;
 | ||
|           resourceName = `Достопримечательность ${nextSightNumber}`;
 | ||
|           break;
 | ||
|         case "route":
 | ||
|           // Используем полный список из mapStore, а не отфильтрованный
 | ||
|           const routeNumbers = mapStore.routes
 | ||
|             .map((route) => {
 | ||
|               const match = route.route_number?.match(/^Маршрут (\d+)$/);
 | ||
|               return match ? parseInt(match[1], 10) : 0;
 | ||
|             })
 | ||
|             .filter((num) => num > 0);
 | ||
|           const nextRouteNumber =
 | ||
|             routeNumbers.length > 0 ? Math.max(...routeNumbers) + 1 : 1;
 | ||
|           resourceName = `Маршрут ${nextRouteNumber}`;
 | ||
|           break;
 | ||
|         default:
 | ||
|           resourceName = "Объект";
 | ||
|       }
 | ||
|       feature.set("name", resourceName);
 | ||
| 
 | ||
|       if (fType === "route") {
 | ||
|         this.activateEditMode();
 | ||
|       }
 | ||
| 
 | ||
|       await this.saveNewFeature(feature);
 | ||
|     });
 | ||
| 
 | ||
|     this.map.addInteraction(this.currentInteraction);
 | ||
|   }
 | ||
| 
 | ||
|   private handleContextMenu(event: MouseEvent): void {
 | ||
|     event.preventDefault();
 | ||
|     if (
 | ||
|       this.mode?.startsWith("drawing-") &&
 | ||
|       this.currentInteraction instanceof Draw
 | ||
|     ) {
 | ||
|       this.finishDrawing();
 | ||
|       if (this.currentDrawingType === "LineString") {
 | ||
|         this.stopDrawing();
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   private stopDrawing() {
 | ||
|     if (this.map && this.currentInteraction) {
 | ||
|       try {
 | ||
|         // @ts-ignore
 | ||
|         this.currentInteraction.abortDrawing();
 | ||
|       } catch (e) {
 | ||
|         /* ignore */
 | ||
|       }
 | ||
|       this.map.removeInteraction(this.currentInteraction);
 | ||
|     }
 | ||
|     this.currentInteraction = null;
 | ||
|     this.currentDrawingType = null;
 | ||
|     this.currentDrawingFeatureType = null;
 | ||
|   }
 | ||
| 
 | ||
|   public finishDrawing(): void {
 | ||
|     if (!this.currentInteraction) return;
 | ||
| 
 | ||
|     // Блокируем завершение рисования, если идет процесс создания
 | ||
|     if (this.isCreating) {
 | ||
|       toast.warning("Дождитесь завершения создания предыдущего объекта.");
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     try {
 | ||
|       this.currentInteraction.finishDrawing();
 | ||
|     } catch (e) {
 | ||
|       this.stopDrawing();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   private handlePointerMove(event: MapBrowserEvent<PointerEvent>): void {
 | ||
|     if (!this.map || event.dragging) {
 | ||
|       if (this.hoveredFeatureId) {
 | ||
|         this.hoveredFeatureId = null;
 | ||
|         this.clusterLayer.changed();
 | ||
|         this.routeLayer.changed();
 | ||
|       }
 | ||
|       if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     const layerFilter = (l: Layer<Source, any>) =>
 | ||
|       l === this.clusterLayer || l === this.routeLayer;
 | ||
| 
 | ||
|     const hit = this.map.hasFeatureAtPixel(event.pixel, {
 | ||
|       layerFilter,
 | ||
|       hitTolerance: 5,
 | ||
|     });
 | ||
|     // Устанавливаем курсор pointer для всей карты, чтобы показать возможность перетаскивания колёсиком
 | ||
|     this.map.getTargetElement().style.cursor = hit ? "pointer" : "pointer";
 | ||
| 
 | ||
|     const featureAtPixel: Feature<Geometry> | undefined =
 | ||
|       this.map.forEachFeatureAtPixel(
 | ||
|         event.pixel,
 | ||
|         (f: FeatureLike) => f as Feature<Geometry>,
 | ||
|         { layerFilter, hitTolerance: 5 }
 | ||
|       );
 | ||
| 
 | ||
|     let finalFeature: Feature<Geometry> | null = null;
 | ||
|     if (featureAtPixel) {
 | ||
|       const originalFeatures = featureAtPixel.get("features");
 | ||
|       if (originalFeatures && originalFeatures.length > 0) {
 | ||
|         if (originalFeatures[0].get("isProxy")) return; // Ignore hover on proxy
 | ||
|         finalFeature = originalFeatures[0];
 | ||
|       } else {
 | ||
|         finalFeature = featureAtPixel;
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     const newHoveredFeatureId = finalFeature ? finalFeature.getId() : null;
 | ||
| 
 | ||
|     if (this.tooltipOverlay && this.tooltipElement) {
 | ||
|       if (this.mode === "edit" && finalFeature) {
 | ||
|         const name = finalFeature.get("name");
 | ||
|         if (name) {
 | ||
|           this.tooltipElement.innerHTML = name as string;
 | ||
|           this.tooltipOverlay.setPosition(event.coordinate);
 | ||
|         } else {
 | ||
|           this.tooltipOverlay.setPosition(undefined);
 | ||
|         }
 | ||
|       } else {
 | ||
|         this.tooltipOverlay.setPosition(undefined);
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     if (this.hoveredFeatureId !== newHoveredFeatureId) {
 | ||
|       this.hoveredFeatureId = newHoveredFeatureId as string | number | null;
 | ||
|       this.clusterLayer.changed();
 | ||
|       this.routeLayer.changed();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   public selectFeature(featureId: string | number | undefined): void {
 | ||
|     if (!this.map || featureId === undefined) {
 | ||
|       this.unselect();
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     this.setSelectedIds(new Set([featureId]));
 | ||
| 
 | ||
|     const feature =
 | ||
|       this.lineSource.getFeatureById(featureId) ||
 | ||
|       this.pointSource.getFeatureById(featureId);
 | ||
| 
 | ||
|     if (!feature) {
 | ||
|       this.unselect();
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     const view = this.map.getView();
 | ||
|     const geometry = feature.getGeometry();
 | ||
|     if (geometry) {
 | ||
|       if (geometry instanceof Point) {
 | ||
|         view.animate({
 | ||
|           center: geometry.getCoordinates(),
 | ||
|           duration: 500,
 | ||
|           zoom: Math.max(view.getZoom() || 14, 16),
 | ||
|         });
 | ||
|       } else {
 | ||
|         view.fit(geometry.getExtent(), {
 | ||
|           duration: 500,
 | ||
|           padding: [50, 50, 50, 50],
 | ||
|           maxZoom: 16,
 | ||
|         });
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   public deleteFeature(
 | ||
|     featureId: string | number | undefined,
 | ||
|     recourse: string
 | ||
|   ): void {
 | ||
|     if (featureId === undefined) return;
 | ||
| 
 | ||
|     const numericId = parseInt(String(featureId).split("-")[1], 10);
 | ||
|     if (!recourse || isNaN(numericId)) return;
 | ||
| 
 | ||
|     mapStore
 | ||
|       .deleteFeature(recourse, numericId)
 | ||
|       .then(() => {
 | ||
|         if (recourse === "route") {
 | ||
|           const lineFeature = this.lineSource.getFeatureById(featureId);
 | ||
|           if (lineFeature)
 | ||
|             this.lineSource.removeFeature(lineFeature as Feature<LineString>);
 | ||
| 
 | ||
|           const pointFeature = this.pointSource.getFeatureById(featureId);
 | ||
|           if (pointFeature)
 | ||
|             this.pointSource.removeFeature(pointFeature as Feature<Point>);
 | ||
|         } else {
 | ||
|           const feature = this.pointSource.getFeatureById(featureId);
 | ||
|           if (feature)
 | ||
|             this.pointSource.removeFeature(feature as Feature<Point>);
 | ||
|         }
 | ||
| 
 | ||
|         this.unselect();
 | ||
|       })
 | ||
|       .catch((err) => {
 | ||
|         console.error("Delete failed:", err);
 | ||
|       });
 | ||
|   }
 | ||
| 
 | ||
|   public deleteMultipleFeatures(featureIds: (string | number)[]): void {
 | ||
|     if (!featureIds || featureIds.length === 0) return;
 | ||
| 
 | ||
|     const deletePromises = Array.from(featureIds).map((id) => {
 | ||
|       const recourse = String(id).split("-")[0];
 | ||
|       const numericId = parseInt(String(id).split("-")[1], 10);
 | ||
|       if (recourse && !isNaN(numericId)) {
 | ||
|         return mapStore.deleteFeature(recourse, numericId).then(() => id);
 | ||
|       }
 | ||
|       return Promise.resolve(null);
 | ||
|     });
 | ||
| 
 | ||
|     Promise.all(deletePromises)
 | ||
|       .then((deletedIds) => {
 | ||
|         const successfulDeletes = deletedIds.filter((id) => id) as (
 | ||
|           | string
 | ||
|           | number
 | ||
|         )[];
 | ||
|         if (successfulDeletes.length > 0) {
 | ||
|           successfulDeletes.forEach((id) => {
 | ||
|             const recourse = String(id).split("-")[0];
 | ||
|             if (recourse === "route") {
 | ||
|               const lineFeature = this.lineSource.getFeatureById(id);
 | ||
|               if (lineFeature)
 | ||
|                 this.lineSource.removeFeature(
 | ||
|                   lineFeature as Feature<LineString>
 | ||
|                 );
 | ||
|               const pointFeature = this.pointSource.getFeatureById(id);
 | ||
|               if (pointFeature)
 | ||
|                 this.pointSource.removeFeature(pointFeature as Feature<Point>);
 | ||
|             } else {
 | ||
|               const feature = this.pointSource.getFeatureById(id);
 | ||
|               if (feature)
 | ||
|                 this.pointSource.removeFeature(feature as Feature<Point>);
 | ||
|             }
 | ||
|           });
 | ||
|           toast.success(`Удалено ${successfulDeletes.length} объект(ов).`);
 | ||
|           this.unselect();
 | ||
|         }
 | ||
|       })
 | ||
|       .catch((err) => {
 | ||
|         console.error("Bulk delete failed:", err);
 | ||
|       });
 | ||
|   }
 | ||
| 
 | ||
|   public destroy(): void {
 | ||
|     if (this.map) {
 | ||
|       document.removeEventListener("keydown", this.boundHandleKeyDown);
 | ||
|       const targetEl = this.map.getTargetElement();
 | ||
|       if (targetEl instanceof HTMLElement) {
 | ||
|         targetEl.removeEventListener(
 | ||
|           "contextmenu",
 | ||
|           this.boundHandleContextMenu
 | ||
|         );
 | ||
|         targetEl.removeEventListener(
 | ||
|           "pointerleave",
 | ||
|           this.boundHandlePointerLeave
 | ||
|         );
 | ||
|       }
 | ||
|       this.map.un("pointermove", this.boundHandlePointerMove as any);
 | ||
|       if (this.tooltipOverlay) this.map.removeOverlay(this.tooltipOverlay);
 | ||
|       this.pointSource.clear();
 | ||
|       this.lineSource.clear();
 | ||
|       this.map.setTarget(undefined);
 | ||
|       this.map = null;
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   private handleFeatureEvent(
 | ||
|     event: VectorSourceEvent<Feature<Geometry>>
 | ||
|   ): void {
 | ||
|     if (!event.feature) return;
 | ||
|     const feature = event.feature;
 | ||
|     if (!feature.getId()) {
 | ||
|       feature.setId(Date.now() + Math.random().toString(36).substr(2, 9));
 | ||
|     }
 | ||
|     this.updateFeaturesInReact();
 | ||
|   }
 | ||
| 
 | ||
|   private handleFeatureChange(
 | ||
|     event: VectorSourceEvent<Feature<Geometry>>
 | ||
|   ): void {
 | ||
|     if (!event.feature) return;
 | ||
|     this.updateFeaturesInReact();
 | ||
|   }
 | ||
| 
 | ||
|   public activateLasso() {
 | ||
|     if (this.lassoInteraction && this.map) {
 | ||
|       this.lassoInteraction.setActive(true);
 | ||
|       this.setMode("lasso");
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   public deactivateLasso() {
 | ||
|     if (this.lassoInteraction && this.map) {
 | ||
|       this.lassoInteraction.setActive(false);
 | ||
|       this.setMode("edit");
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   public setSelectedIds(ids: Set<string | number>) {
 | ||
|     this.selectedIds = new Set(ids);
 | ||
|     if (this.onSelectionChange) this.onSelectionChange(this.selectedIds);
 | ||
| 
 | ||
|     this.selectInteraction.getFeatures().clear();
 | ||
|     ids.forEach((id) => {
 | ||
|       const lineFeature = this.lineSource.getFeatureById(id);
 | ||
|       if (lineFeature) this.selectInteraction.getFeatures().push(lineFeature);
 | ||
| 
 | ||
|       const pointFeature = this.pointSource.getFeatureById(id);
 | ||
|       if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature);
 | ||
|     });
 | ||
| 
 | ||
|     this.modifyInteraction.setActive(
 | ||
|       this.selectInteraction.getFeatures().getLength() > 0
 | ||
|     );
 | ||
|     this.clusterLayer.changed();
 | ||
|     this.routeLayer.changed();
 | ||
| 
 | ||
|     if (ids.size === 1) {
 | ||
|       const featureId = Array.from(ids)[0];
 | ||
|       const feature =
 | ||
|         this.lineSource.getFeatureById(featureId) ||
 | ||
|         this.pointSource.getFeatureById(featureId);
 | ||
|       if (feature) {
 | ||
|         this.onFeatureSelect(feature);
 | ||
|       }
 | ||
|     } else {
 | ||
|       this.onFeatureSelect(null);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   public getSelectedIds() {
 | ||
|     return new Set(this.selectedIds);
 | ||
|   }
 | ||
|   public setOnSelectionChange(cb: (ids: Set<string | number>) => void) {
 | ||
|     this.onSelectionChange = cb;
 | ||
|   }
 | ||
|   public toggleLasso() {
 | ||
|     if (this.mode === "lasso") this.deactivateLasso();
 | ||
|     else this.activateLasso();
 | ||
|   }
 | ||
|   public getMap(): Map | null {
 | ||
|     return this.map;
 | ||
|   }
 | ||
| 
 | ||
|   // Метод для сброса кешей карты
 | ||
|   public clearCaches() {
 | ||
|     this.clusterStyleCache = {};
 | ||
|     this.hoveredFeatureId = null;
 | ||
|     this.selectedIds.clear();
 | ||
| 
 | ||
|     // Очищаем источники данных
 | ||
|     if (this.pointSource) {
 | ||
|       this.pointSource.clear();
 | ||
|     }
 | ||
|     if (this.lineSource) {
 | ||
|       this.lineSource.clear();
 | ||
|     }
 | ||
| 
 | ||
|     // Обновляем слои
 | ||
|     if (this.clusterLayer) {
 | ||
|       this.clusterLayer.changed();
 | ||
|     }
 | ||
|     if (this.routeLayer) {
 | ||
|       this.routeLayer.changed();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   public saveCurrentPosition(): void {
 | ||
|     if (!this.map) return;
 | ||
|     const center = this.map.getView().getCenter();
 | ||
|     const zoom = this.map.getView().getZoom();
 | ||
|     if (center && zoom !== undefined) {
 | ||
|       const [lon, lat] = toLonLat(center, this.map.getView().getProjection());
 | ||
|       saveMapPosition({ center: [lon, lat], zoom });
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   private async saveModifiedFeature(feature: Feature<Geometry>) {
 | ||
|     const featureType = feature.get("featureType") as FeatureType;
 | ||
|     const featureId = feature.getId();
 | ||
|     if (!featureType || featureId === undefined || !this.map) return;
 | ||
| 
 | ||
|     if (typeof featureId === "number" || !String(featureId).includes("-")) {
 | ||
|       console.warn(
 | ||
|         "Skipping save for feature with non-standard ID:",
 | ||
|         featureId
 | ||
|       );
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     const geoJSONFormat = new GeoJSON({
 | ||
|       dataProjection: "EPSG:4326",
 | ||
|       featureProjection: this.map.getView().getProjection().getCode(),
 | ||
|     });
 | ||
|     const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
 | ||
| 
 | ||
|     try {
 | ||
|       await mapStore.updateFeature(featureType, featureGeoJSON);
 | ||
|     } catch (error) {
 | ||
|       console.error("Failed to update feature:", error);
 | ||
|       toast.error(`Не удалось обновить: ${error}`);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   private async saveNewFeature(feature: Feature<Geometry>) {
 | ||
|     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<LineString>))
 | ||
|           this.lineSource.removeFeature(feature as Feature<LineString>);
 | ||
|       } else {
 | ||
|         if (this.pointSource.hasFeature(feature as Feature<Point>))
 | ||
|           this.pointSource.removeFeature(feature as Feature<Point>);
 | ||
|       }
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     this.isCreating = true;
 | ||
| 
 | ||
|     const geoJSONFormat = new GeoJSON({
 | ||
|       dataProjection: "EPSG:4326",
 | ||
|       featureProjection: this.map.getView().getProjection().getCode(),
 | ||
|     });
 | ||
| 
 | ||
|     const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
 | ||
| 
 | ||
|     try {
 | ||
|       const createdFeatureData = await mapStore.createFeature(
 | ||
|         featureType,
 | ||
|         featureGeoJSON
 | ||
|       );
 | ||
| 
 | ||
|       const newFeatureId = `${featureType}-${createdFeatureData.id}`;
 | ||
|       // @ts-ignore
 | ||
|       const displayName =
 | ||
|         featureType === "route"
 | ||
|           ? // @ts-ignore
 | ||
|             createdFeatureData.route_number
 | ||
|           : // @ts-ignore
 | ||
|             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();
 | ||
|       this.routeLayer.changed();
 | ||
|       this.clusterLayer.changed();
 | ||
|     } catch (error) {
 | ||
|       console.error("Failed to save new feature:", error);
 | ||
|       toast.error("Не удалось сохранить объект.");
 | ||
|       if (feature.getGeometry()?.getType() === "LineString") {
 | ||
|         if (this.lineSource.hasFeature(feature as Feature<LineString>))
 | ||
|           this.lineSource.removeFeature(feature as Feature<LineString>);
 | ||
|       } else {
 | ||
|         if (this.pointSource.hasFeature(feature as Feature<Point>))
 | ||
|           this.pointSource.removeFeature(feature as Feature<Point>);
 | ||
|       }
 | ||
|     } finally {
 | ||
|       this.isCreating = false;
 | ||
|     }
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| // --- MAP CONTROLS COMPONENT ---
 | ||
| interface MapControlsProps {
 | ||
|   mapService: MapService | null;
 | ||
|   activeMode: string;
 | ||
|   isLassoActive: boolean;
 | ||
|   isUnselectDisabled: boolean;
 | ||
| }
 | ||
| 
 | ||
| interface ControlItem {
 | ||
|   mode: string;
 | ||
|   title: string;
 | ||
|   longTitle: string;
 | ||
|   icon: React.ReactNode;
 | ||
|   action: () => void;
 | ||
|   isActive?: boolean;
 | ||
|   disabled?: boolean;
 | ||
| }
 | ||
| 
 | ||
| const MapControls: React.FC<MapControlsProps> = ({
 | ||
|   mapService,
 | ||
|   activeMode,
 | ||
|   // @ts-ignore
 | ||
|   isLassoActive,
 | ||
|   isUnselectDisabled,
 | ||
| }) => {
 | ||
|   if (!mapService) return null;
 | ||
| 
 | ||
|   const controls: ControlItem[] = [
 | ||
|     {
 | ||
|       mode: "edit",
 | ||
|       title: "Редактировать",
 | ||
|       longTitle: "Редактирование",
 | ||
|       icon: <Pencil size={16} className="mr-1 sm:mr-2" />,
 | ||
|       action: () => mapService.activateEditMode(),
 | ||
|     },
 | ||
|     {
 | ||
|       mode: "drawing-station",
 | ||
|       title: "Остановка",
 | ||
|       longTitle: "Добавить остановку",
 | ||
|       icon: <Bus size={16} className="mr-1 sm:mr-2" />,
 | ||
|       action: () => mapService.startDrawing("Point", "station"),
 | ||
|     },
 | ||
|     {
 | ||
|       mode: "drawing-sight",
 | ||
|       title: "Достопримечательность",
 | ||
|       longTitle: "Добавить достопримечательность",
 | ||
|       icon: <Landmark size={16} className="mr-1 sm:mr-2" />,
 | ||
|       action: () => mapService.startDrawing("Point", "sight"),
 | ||
|     },
 | ||
|     {
 | ||
|       mode: "drawing-route",
 | ||
|       title: "Маршрут",
 | ||
|       longTitle: "Добавить маршрут (Правый клик для завершения)",
 | ||
|       icon: <RouteIcon size={16} className="mr-1 sm:mr-2" />,
 | ||
|       action: () => mapService.startDrawing("LineString", "route"),
 | ||
|     },
 | ||
| 
 | ||
|     {
 | ||
|       mode: "unselect",
 | ||
|       title: "Сбросить",
 | ||
|       longTitle: "Сбросить выделение (Esc)",
 | ||
|       icon: <X size={16} className="mr-1 sm:mr-2" />,
 | ||
|       action: () => mapService.unselect(),
 | ||
|       disabled: isUnselectDisabled,
 | ||
|     },
 | ||
|   ];
 | ||
|   return (
 | ||
|     <div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex flex-nowrap justify-center p-2 bg-white/90 backdrop-blur-sm rounded-lg shadow-xl space-x-1 sm:space-x-2">
 | ||
|       {controls.map((c) => {
 | ||
|         const isActive =
 | ||
|           c.isActive !== undefined ? c.isActive : activeMode === c.mode;
 | ||
|         const isDisabled = c.disabled;
 | ||
|         const buttonClasses = `flex items-center px-3 py-2 rounded-md transition-all duration-200 text-xs sm:text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 focus:ring-opacity-75 ${
 | ||
|           isDisabled
 | ||
|             ? "bg-gray-200 text-gray-400 cursor-not-allowed"
 | ||
|             : isActive
 | ||
|             ? "bg-blue-600 text-white shadow-md hover:bg-blue-700"
 | ||
|             : "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700"
 | ||
|         }`;
 | ||
|         return (
 | ||
|           <button
 | ||
|             key={c.mode}
 | ||
|             className={buttonClasses}
 | ||
|             onClick={c.action}
 | ||
|             title={c.longTitle}
 | ||
|             disabled={isDisabled}
 | ||
|           >
 | ||
|             {c.icon}
 | ||
|             <span className="hidden sm:inline ml-0 sm:ml-1">{c.title}</span>
 | ||
|           </button>
 | ||
|         );
 | ||
|       })}
 | ||
|     </div>
 | ||
|   );
 | ||
| };
 | ||
| 
 | ||
| import { observer } from "mobx-react-lite";
 | ||
| 
 | ||
| // --- MAP SIGHTBAR COMPONENT ---
 | ||
| interface MapSightbarProps {
 | ||
|   mapService: MapService | null;
 | ||
|   mapFeatures: Feature<Geometry>[];
 | ||
|   selectedFeature: Feature<Geometry> | null;
 | ||
|   selectedIds: Set<string | number>;
 | ||
|   setSelectedIds: (ids: Set<string | number>) => void;
 | ||
|   activeSection: string | null;
 | ||
|   setActiveSection: (section: string | null) => void;
 | ||
| }
 | ||
| const MapSightbar: React.FC<MapSightbarProps> = observer(
 | ||
|   ({
 | ||
|     mapService,
 | ||
|     mapFeatures,
 | ||
|     selectedFeature,
 | ||
|     selectedIds,
 | ||
|     setSelectedIds,
 | ||
|     activeSection,
 | ||
|     setActiveSection,
 | ||
|   }) => {
 | ||
|     const navigate = useNavigate();
 | ||
|     const [searchQuery, setSearchQuery] = useState("");
 | ||
|     const [stationSort, setStationSort] = useState<SortType>("name_asc");
 | ||
|     const [sightSort, setSightSort] = useState<SortType>("name_asc");
 | ||
| 
 | ||
|     const { isOpen } = menuStore;
 | ||
|     const { selectedCityId } = selectedCityStore;
 | ||
| 
 | ||
|     const actualFeatures = useMemo(
 | ||
|       () => mapFeatures.filter((f) => !f.get("isProxy")),
 | ||
|       [mapFeatures]
 | ||
|     );
 | ||
| 
 | ||
|     // Создаем объединенный список всех объектов для поиска
 | ||
|     const allFeatures = useMemo(() => {
 | ||
|       const stations = mapStore.filteredStations.map((station) => {
 | ||
|         const feature = new Feature({
 | ||
|           geometry: new Point(
 | ||
|             transform(
 | ||
|               [station.longitude, station.latitude],
 | ||
|               "EPSG:4326",
 | ||
|               "EPSG:3857"
 | ||
|             )
 | ||
|           ),
 | ||
|           name: station.name,
 | ||
|         });
 | ||
|         feature.setId(`station-${station.id}`);
 | ||
|         feature.set("featureType", "station");
 | ||
|         feature.set("created_at", station.created_at);
 | ||
|         return feature;
 | ||
|       });
 | ||
| 
 | ||
|       const sights = mapStore.filteredSights.map((sight) => {
 | ||
|         const feature = new Feature({
 | ||
|           geometry: new Point(
 | ||
|             transform(
 | ||
|               [sight.longitude, sight.latitude],
 | ||
|               "EPSG:4326",
 | ||
|               "EPSG:3857"
 | ||
|             )
 | ||
|           ),
 | ||
|           name: sight.name,
 | ||
|           description: sight.description,
 | ||
|         });
 | ||
|         feature.setId(`sight-${sight.id}`);
 | ||
|         feature.set("featureType", "sight");
 | ||
|         feature.set("created_at", sight.created_at);
 | ||
|         return feature;
 | ||
|       });
 | ||
| 
 | ||
|       const lines = mapStore.filteredRoutes.map((route) => {
 | ||
|         const feature = new Feature({
 | ||
|           geometry: new LineString(route.path),
 | ||
|           name: route.route_number,
 | ||
|         });
 | ||
|         feature.setId(`route-${route.id}`);
 | ||
|         feature.set("featureType", "route");
 | ||
|         return feature;
 | ||
|       });
 | ||
| 
 | ||
|       return [...stations, ...sights, ...lines];
 | ||
|     }, [
 | ||
|       mapStore.filteredStations,
 | ||
|       mapStore.filteredSights,
 | ||
|       mapStore.filteredRoutes,
 | ||
|       actualFeatures,
 | ||
|       selectedCityId,
 | ||
|       mapStore,
 | ||
|     ]);
 | ||
| 
 | ||
|     const filteredFeatures = useMemo(() => {
 | ||
|       if (!searchQuery.trim()) return allFeatures;
 | ||
|       return allFeatures.filter((f) =>
 | ||
|         ((f.get("name") as string) || "")
 | ||
|           .toLowerCase()
 | ||
|           .includes(searchQuery.toLowerCase())
 | ||
|       );
 | ||
|     }, [allFeatures, searchQuery]);
 | ||
| 
 | ||
|     const handleFeatureClick = useCallback(
 | ||
|       (id: string | number) => {
 | ||
|         if (!mapService) return;
 | ||
|         mapService.selectFeature(id);
 | ||
|       },
 | ||
|       [mapService]
 | ||
|     );
 | ||
| 
 | ||
|     const handleDeleteFeature = useCallback(
 | ||
|       (id: string | number, resource: string) => {
 | ||
|         if (!mapService) return;
 | ||
|         if (window.confirm("Вы действительно хотите удалить этот объект?")) {
 | ||
|           mapService.deleteFeature(id, resource);
 | ||
|         }
 | ||
|       },
 | ||
|       [mapService]
 | ||
|     );
 | ||
| 
 | ||
|     const handleCheckboxChange = useCallback(
 | ||
|       (id: string | number) => {
 | ||
|         if (!mapService) return;
 | ||
|         const newSet = new Set(selectedIds);
 | ||
|         if (newSet.has(id)) newSet.delete(id);
 | ||
|         else newSet.add(id);
 | ||
|         setSelectedIds(newSet);
 | ||
|         mapService.setSelectedIds(newSet);
 | ||
|       },
 | ||
|       [mapService, selectedIds, setSelectedIds]
 | ||
|     );
 | ||
| 
 | ||
|     const handleBulkDelete = useCallback(() => {
 | ||
|       if (!mapService || selectedIds.size === 0) return;
 | ||
|       if (
 | ||
|         window.confirm(
 | ||
|           `Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`
 | ||
|         )
 | ||
|       ) {
 | ||
|         mapService.deleteMultipleFeatures(Array.from(selectedIds));
 | ||
|         setSelectedIds(new Set());
 | ||
|       }
 | ||
|     }, [mapService, selectedIds, setSelectedIds]);
 | ||
| 
 | ||
|     const handleEditFeature = useCallback(
 | ||
|       (featureType: string, fullId: string | number) => {
 | ||
|         const numericId = String(fullId).split("-")[1];
 | ||
|         if (!featureType || !numericId) return;
 | ||
|         navigate(`/${featureType}/${numericId}/edit`);
 | ||
|       },
 | ||
|       [navigate]
 | ||
|     );
 | ||
| 
 | ||
|     const sortFeaturesByType = <T extends Feature<Geometry>>(
 | ||
|       features: T[],
 | ||
|       sortType: SortType
 | ||
|     ): T[] => {
 | ||
|       const sorted = [...features];
 | ||
|       switch (sortType) {
 | ||
|         case "name_asc":
 | ||
|           return sorted.sort((a, b) =>
 | ||
|             ((a.get("name") as string) || "").localeCompare(
 | ||
|               (b.get("name") as string) || ""
 | ||
|             )
 | ||
|           );
 | ||
|         case "name_desc":
 | ||
|           return sorted.sort((a, b) =>
 | ||
|             ((b.get("name") as string) || "").localeCompare(
 | ||
|               (a.get("name") as string) || ""
 | ||
|             )
 | ||
|           );
 | ||
|         case "created_asc":
 | ||
|           return sorted.sort((a, b) => {
 | ||
|             const aDate = a.get("created_at")
 | ||
|               ? new Date(a.get("created_at"))
 | ||
|               : new Date(0);
 | ||
|             const bDate = b.get("created_at")
 | ||
|               ? new Date(b.get("created_at"))
 | ||
|               : new Date(0);
 | ||
|             return aDate.getTime() - bDate.getTime();
 | ||
|           });
 | ||
|         case "created_desc":
 | ||
|           return sorted.sort((a, b) => {
 | ||
|             const aDate = a.get("created_at")
 | ||
|               ? new Date(a.get("created_at"))
 | ||
|               : new Date(0);
 | ||
|             const bDate = b.get("created_at")
 | ||
|               ? new Date(b.get("created_at"))
 | ||
|               : new Date(0);
 | ||
|             return bDate.getTime() - aDate.getTime();
 | ||
|           });
 | ||
|         case "updated_asc":
 | ||
|           return sorted.sort((a, b) => {
 | ||
|             const aDate = a.get("updated_at")
 | ||
|               ? new Date(a.get("updated_at"))
 | ||
|               : a.get("created_at")
 | ||
|               ? new Date(a.get("created_at"))
 | ||
|               : new Date(0);
 | ||
|             const bDate = b.get("updated_at")
 | ||
|               ? new Date(b.get("updated_at"))
 | ||
|               : b.get("created_at")
 | ||
|               ? new Date(b.get("created_at"))
 | ||
|               : new Date(0);
 | ||
|             return aDate.getTime() - bDate.getTime();
 | ||
|           });
 | ||
|         case "updated_desc":
 | ||
|           return sorted.sort((a, b) => {
 | ||
|             const aDate = a.get("updated_at")
 | ||
|               ? new Date(a.get("updated_at"))
 | ||
|               : a.get("created_at")
 | ||
|               ? new Date(a.get("created_at"))
 | ||
|               : new Date(0);
 | ||
|             const bDate = b.get("updated_at")
 | ||
|               ? new Date(b.get("updated_at"))
 | ||
|               : b.get("created_at")
 | ||
|               ? new Date(b.get("created_at"))
 | ||
|               : new Date(0);
 | ||
|             return bDate.getTime() - aDate.getTime();
 | ||
|           });
 | ||
|         default:
 | ||
|           return sorted;
 | ||
|       }
 | ||
|     };
 | ||
| 
 | ||
|     const stations = filteredFeatures.filter(
 | ||
|       (f) => f.get("featureType") === "station"
 | ||
|     );
 | ||
|     const lines = filteredFeatures.filter(
 | ||
|       (f) => f.get("featureType") === "route"
 | ||
|     );
 | ||
|     const sights = filteredFeatures.filter(
 | ||
|       (f) => f.get("featureType") === "sight"
 | ||
|     );
 | ||
| 
 | ||
|     const sortedStations = sortFeaturesByType(stations, stationSort);
 | ||
|     const sortedSights = sortFeaturesByType(sights, sightSort);
 | ||
| 
 | ||
|     const renderFeatureList = (
 | ||
|       features: Feature<Geometry>[],
 | ||
|       featureType: "station" | "route" | "sight",
 | ||
|       IconComponent: React.ElementType
 | ||
|     ) => (
 | ||
|       <div className="space-y-1 pr-1">
 | ||
|         {features.length > 0 ? (
 | ||
|           features.map((feature) => {
 | ||
|             const fId = feature.getId();
 | ||
|             if (fId === undefined) return null; // TypeScript-safe
 | ||
|             const fName = (feature.get("name") as string) || "Без названия";
 | ||
|             const isSelected = selectedFeature?.getId() === fId;
 | ||
|             const isChecked = selectedIds.has(fId);
 | ||
| 
 | ||
|             return (
 | ||
|               <div
 | ||
|                 key={String(fId)}
 | ||
|                 data-feature-id={fId}
 | ||
|                 className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
 | ||
|                   isSelected
 | ||
|                     ? "bg-orange-100 border border-orange-300"
 | ||
|                     : "hover:bg-blue-50"
 | ||
|                 }`}
 | ||
|               >
 | ||
|                 <div className="flex-shrink-0 pr-2 pt-1">
 | ||
|                   <input
 | ||
|                     type="checkbox"
 | ||
|                     className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
 | ||
|                     checked={isChecked}
 | ||
|                     onChange={() => handleCheckboxChange(fId)}
 | ||
|                     onClick={(e) => e.stopPropagation()}
 | ||
|                     aria-label={`Выбрать ${fName}`}
 | ||
|                   />
 | ||
|                 </div>
 | ||
|                 <div
 | ||
|                   className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
 | ||
|                   onClick={() => handleFeatureClick(fId)}
 | ||
|                 >
 | ||
|                   <div className="flex items-center">
 | ||
|                     <IconComponent
 | ||
|                       {...({
 | ||
|                         className: [
 | ||
|                           "mr-1.5",
 | ||
|                           "flex-shrink-0",
 | ||
|                           isSelected ? "text-orange-500" : "text-blue-500",
 | ||
|                           !isSelected && "group-hover:text-blue-600",
 | ||
|                         ]
 | ||
|                           .filter(Boolean)
 | ||
|                           .join(" "),
 | ||
|                       } as React.HTMLAttributes<HTMLElement>)}
 | ||
|                       // @ts-ignore
 | ||
|                       size={16}
 | ||
|                     />
 | ||
|                     <span
 | ||
|                       className={`font-medium whitespace-nowrap overflow-x-auto block
 | ||
|                       scrollbar-visible`}
 | ||
|                       title={fName}
 | ||
|                     >
 | ||
|                       {fName}
 | ||
|                     </span>
 | ||
|                   </div>
 | ||
|                 </div>
 | ||
|                 <div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
 | ||
|                   <button
 | ||
|                     onClick={(e) => {
 | ||
|                       e.stopPropagation();
 | ||
|                       const featureTypeVal = feature.get("featureType");
 | ||
|                       if (featureTypeVal)
 | ||
|                         handleEditFeature(featureTypeVal, fId);
 | ||
|                     }}
 | ||
|                     className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
 | ||
|                     title="Редактировать детали"
 | ||
|                   >
 | ||
|                     <Pencil size={16} />
 | ||
|                   </button>
 | ||
|                   <button
 | ||
|                     onClick={(e) => {
 | ||
|                       e.stopPropagation();
 | ||
|                       handleDeleteFeature(fId, featureType);
 | ||
|                     }}
 | ||
|                     className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors"
 | ||
|                     title="Удалить"
 | ||
|                   >
 | ||
|                     <Trash2 size={16} />
 | ||
|                   </button>
 | ||
|                 </div>
 | ||
|               </div>
 | ||
|             );
 | ||
|           })
 | ||
|         ) : (
 | ||
|           <p className="text-gray-500 text-sm p-2">Нет объектов этого типа.</p>
 | ||
|         )}
 | ||
|       </div>
 | ||
|     );
 | ||
| 
 | ||
|     const toggleSection = (id: string) =>
 | ||
|       setActiveSection(activeSection === id ? null : id);
 | ||
| 
 | ||
|     const sections = [
 | ||
|       {
 | ||
|         id: "layers",
 | ||
|         title: `Остановки (${sortedStations.length})`,
 | ||
|         icon: <Bus size={20} />,
 | ||
|         count: sortedStations.length,
 | ||
|         sortControl: (
 | ||
|           <div className="flex items-center space-x-2 p-3 bg-white border-b border-gray-200">
 | ||
|             <label className="text-sm text-gray-700">Сортировка:</label>
 | ||
|             <select
 | ||
|               value={stationSort}
 | ||
|               onChange={(e) => setStationSort(e.target.value as SortType)}
 | ||
|               className="border rounded px-2 py-1 text-sm"
 | ||
|             >
 | ||
|               <option value="name_asc">Имя ↑</option>
 | ||
|               <option value="name_desc">Имя ↓</option>
 | ||
|               <option value="created_asc">Дата создания ↑</option>
 | ||
|               <option value="created_desc">Дата создания ↓</option>
 | ||
|               <option value="updated_asc">Дата обновления ↑</option>
 | ||
|               <option value="updated_desc">Дата обновления ↓</option>
 | ||
|             </select>
 | ||
|           </div>
 | ||
|         ),
 | ||
|         content: renderFeatureList(sortedStations, "station", MapPin),
 | ||
|       },
 | ||
|       {
 | ||
|         id: "lines",
 | ||
|         title: `Маршруты (${lines.length})`,
 | ||
|         icon: <RouteIcon size={20} />,
 | ||
|         count: lines.length,
 | ||
|         sortControl: null,
 | ||
|         content: renderFeatureList(lines, "route", ArrowRightLeft),
 | ||
|       },
 | ||
|       {
 | ||
|         id: "sights",
 | ||
|         title: `Достопримечательности (${sortedSights.length})`,
 | ||
|         icon: <Landmark size={20} />,
 | ||
|         count: sortedSights.length,
 | ||
|         sortControl: (
 | ||
|           <div className="flex items-center space-x-2 p-3 bg-white border-b border-gray-200">
 | ||
|             <label className="text-sm text-gray-700">Сортировка:</label>
 | ||
|             <select
 | ||
|               value={sightSort}
 | ||
|               onChange={(e) => setSightSort(e.target.value as SortType)}
 | ||
|               className="border rounded px-2 py-1 text-sm"
 | ||
|             >
 | ||
|               <option value="name_asc">Имя ↑</option>
 | ||
|               <option value="name_desc">Имя ↓</option>
 | ||
|               <option value="created_asc">Дата создания ↑</option>
 | ||
|               <option value="created_desc">Дата создания ↓</option>
 | ||
|               <option value="updated_asc">Дата обновления ↑</option>
 | ||
|               <option value="updated_desc">Дата обновления ↓</option>
 | ||
|             </select>
 | ||
|           </div>
 | ||
|         ),
 | ||
|         content: renderFeatureList(sortedSights, "sight", Landmark),
 | ||
|       },
 | ||
|     ];
 | ||
| 
 | ||
|     return (
 | ||
|       <div
 | ||
|         className={`${
 | ||
|           isOpen ? "w-[360px]" : "w-[590px]"
 | ||
|         } transition-all duration-300 ease-in-out relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]`}
 | ||
|       >
 | ||
|         <div className="p-4 bg-gray-700 text-white">
 | ||
|           <h2 className="text-lg font-semibold">Панель управления</h2>
 | ||
|         </div>
 | ||
|         <div className="p-3 border-b border-gray-200 bg-white">
 | ||
|           <input
 | ||
|             type="text"
 | ||
|             placeholder="Поиск по названию..."
 | ||
|             value={searchQuery}
 | ||
|             onChange={(e) => setSearchQuery(e.target.value)}
 | ||
|             className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
 | ||
|           />
 | ||
|         </div>
 | ||
|         <div className="flex-1 flex flex-col min-h-0 overflow-y-auto scrollbar-visible">
 | ||
|           {filteredFeatures.length === 0 && searchQuery ? (
 | ||
|             <div className="p-4 text-center text-gray-500">
 | ||
|               Ничего не найдено.
 | ||
|             </div>
 | ||
|           ) : (
 | ||
|             sections.map(
 | ||
|               (s) =>
 | ||
|                 (s.count > 0 || !searchQuery) && (
 | ||
|                   <div
 | ||
|                     key={s.id}
 | ||
|                     className={`border-b border-gray-200 last:border-b-0 flex flex-col transition-all duration-300 ease-in-out ${
 | ||
|                       activeSection === s.id ? "flex-1 min-h-0" : "flex-none"
 | ||
|                     }`}
 | ||
|                   >
 | ||
|                     <button
 | ||
|                       onClick={() => toggleSection(s.id)}
 | ||
|                       className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 flex-shrink-0 ${
 | ||
|                         activeSection === s.id
 | ||
|                           ? "bg-gray-100 text-blue-600"
 | ||
|                           : "text-gray-700"
 | ||
|                       }`}
 | ||
|                     >
 | ||
|                       <div className="flex items-center space-x-3">
 | ||
|                         <span
 | ||
|                           className={
 | ||
|                             activeSection === s.id
 | ||
|                               ? "text-blue-600"
 | ||
|                               : "text-gray-600"
 | ||
|                           }
 | ||
|                         >
 | ||
|                           {s.icon}
 | ||
|                         </span>
 | ||
|                         <span className="font-medium text-sm">{s.title}</span>
 | ||
|                       </div>
 | ||
|                       <span
 | ||
|                         className={`transform transition-transform duration-200 text-gray-500 ${
 | ||
|                           activeSection === s.id ? "rotate-180" : ""
 | ||
|                         }`}
 | ||
|                       >
 | ||
|                         ▼
 | ||
|                       </span>
 | ||
|                     </button>
 | ||
|                     {activeSection === s.id && (
 | ||
|                       <>
 | ||
|                         {s.sortControl && (
 | ||
|                           <div className="flex-shrink-0">{s.sortControl}</div>
 | ||
|                         )}
 | ||
|                         <div className="overflow-y-auto scrollbar-visible bg-white flex-1 min-h-0">
 | ||
|                           <div className="p-3 text-sm text-gray-600">
 | ||
|                             {s.content}
 | ||
|                           </div>
 | ||
|                         </div>
 | ||
|                       </>
 | ||
|                     )}
 | ||
|                   </div>
 | ||
|                 )
 | ||
|             )
 | ||
|           )}
 | ||
|         </div>
 | ||
|         {selectedIds.size > 0 && (
 | ||
|           <div className="p-3 border-t border-gray-200 bg-white flex-shrink-0">
 | ||
|             <button
 | ||
|               onClick={handleBulkDelete}
 | ||
|               className="w-full flex items-center justify-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
 | ||
|             >
 | ||
|               <Trash2 size={16} className="mr-2" />
 | ||
|               Удалить выбранное ({selectedIds.size})
 | ||
|             </button>
 | ||
|           </div>
 | ||
|         )}
 | ||
|       </div>
 | ||
|     );
 | ||
|   }
 | ||
| );
 | ||
| // --- MAP PAGE COMPONENT ---
 | ||
| export const MapPage: React.FC = observer(() => {
 | ||
|   const mapRef = useRef<HTMLDivElement | null>(null);
 | ||
|   const tooltipRef = useRef<HTMLDivElement | null>(null);
 | ||
|   const [mapServiceInstance, setMapServiceInstance] =
 | ||
|     useState<MapService | null>(null);
 | ||
|   const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
 | ||
|   const [isDataLoading, setIsDataLoading] = useState<boolean>(true);
 | ||
|   const [error, setError] = useState<string | null>(null);
 | ||
|   const [currentMapMode, setCurrentMapMode] = useState<string>("edit");
 | ||
|   const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]);
 | ||
|   const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
 | ||
|     useState<Feature<Geometry> | null>(null);
 | ||
|   const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
 | ||
|     new Set()
 | ||
|   );
 | ||
|   const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
 | ||
|   const [showHelp, setShowHelp] = useState<boolean>(false);
 | ||
|   const [activeSectionFromParent, setActiveSectionFromParent] = useState<
 | ||
|     string | null
 | ||
|   >(() => getStoredActiveSection() || "layers");
 | ||
| 
 | ||
|   const { selectedCityId } = selectedCityStore;
 | ||
| 
 | ||
|   const handleFeaturesChange = useCallback(
 | ||
|     (feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
 | ||
|     []
 | ||
|   );
 | ||
| 
 | ||
|   const handleFeatureSelectForSidebar = useCallback(
 | ||
|     (feat: Feature<Geometry> | null) => {
 | ||
|       // Logic to sync sidebar selection with map
 | ||
|       setSelectedFeatureForSidebar(feat);
 | ||
|       if (feat) {
 | ||
|         const featureType = feat.get("featureType");
 | ||
|         const sectionId =
 | ||
|           featureType === "sight"
 | ||
|             ? "sights"
 | ||
|             : featureType === "route"
 | ||
|             ? "lines"
 | ||
|             : "layers";
 | ||
|         setActiveSectionFromParent(sectionId);
 | ||
|         setTimeout(() => {
 | ||
|           document
 | ||
|             .querySelector(`[data-feature-id="${feat.getId()}"]`)
 | ||
|             ?.scrollIntoView({ behavior: "smooth", block: "center" });
 | ||
|         }, 100);
 | ||
|       }
 | ||
|     },
 | ||
|     []
 | ||
|   );
 | ||
| 
 | ||
|   useEffect(() => {
 | ||
|     let service: MapService | null = null;
 | ||
|     if (mapRef.current && tooltipRef.current && !mapServiceInstance) {
 | ||
|       setIsMapLoading(true);
 | ||
|       setIsDataLoading(true);
 | ||
|       setError(null);
 | ||
| 
 | ||
|       const loadInitialData = async (mapService: MapService) => {
 | ||
|         try {
 | ||
|           await Promise.all([
 | ||
|             mapStore.getRoutes(),
 | ||
|             mapStore.getStations(),
 | ||
|             mapStore.getSights(),
 | ||
|             carrierStore.getCarriers("ru"),
 | ||
|           ]);
 | ||
|           mapService.loadFeaturesFromApi(
 | ||
|             mapStore.stations,
 | ||
|             mapStore.routes,
 | ||
|             mapStore.sights
 | ||
|           );
 | ||
|         } catch (e) {
 | ||
|           console.error("Failed to load initial map data:", e);
 | ||
|           setError("Не удалось загрузить данные для карты.");
 | ||
|         } finally {
 | ||
|           setIsDataLoading(false);
 | ||
|         }
 | ||
|       };
 | ||
| 
 | ||
|       try {
 | ||
|         service = new MapService(
 | ||
|           { ...mapConfig, target: mapRef.current },
 | ||
|           setIsMapLoading,
 | ||
|           setError,
 | ||
|           setCurrentMapMode,
 | ||
|           handleFeaturesChange,
 | ||
|           handleFeatureSelectForSidebar,
 | ||
|           tooltipRef.current,
 | ||
|           setSelectedIds
 | ||
|         );
 | ||
|         setMapServiceInstance(service);
 | ||
| 
 | ||
|         // Делаем mapServiceInstance доступным глобально для сброса кешей
 | ||
|         if (typeof window !== "undefined") {
 | ||
|           (window as any).mapServiceInstance = service;
 | ||
|         }
 | ||
| 
 | ||
|         loadInitialData(service);
 | ||
|       } catch (e: any) {
 | ||
|         setError(
 | ||
|           `Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`
 | ||
|         );
 | ||
|         setIsMapLoading(false);
 | ||
|         setIsDataLoading(false);
 | ||
|       }
 | ||
|     }
 | ||
|     return () => {
 | ||
|       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;
 | ||
| 
 | ||
|     const handleMapClickForDeselect = (event: any) => {
 | ||
|       if (!mapServiceInstance) return;
 | ||
| 
 | ||
|       const hit = olMap.hasFeatureAtPixel(event.pixel, {
 | ||
|         layerFilter: (layer) =>
 | ||
|           layer === mapServiceInstance.clusterLayer ||
 | ||
|           layer === mapServiceInstance.routeLayer,
 | ||
|         hitTolerance: 5,
 | ||
|       });
 | ||
| 
 | ||
|       // Если клик был НЕ по объекту, снимаем выделение
 | ||
|       if (!hit) {
 | ||
|         mapServiceInstance.unselect();
 | ||
|       }
 | ||
|       // Если клик был ПО объекту, НИЧЕГО не делаем. За это отвечает selectInteraction.
 | ||
|     };
 | ||
| 
 | ||
|     olMap.on("click", handleMapClickForDeselect);
 | ||
| 
 | ||
|     return () => {
 | ||
|       olMap.un("click", handleMapClickForDeselect);
 | ||
|     };
 | ||
|   }, [mapServiceInstance]);
 | ||
| 
 | ||
|   useEffect(() => {
 | ||
|     mapServiceInstance?.setOnSelectionChange(setSelectedIds);
 | ||
|   }, [mapServiceInstance]);
 | ||
| 
 | ||
|   useEffect(() => {
 | ||
|     const handleKeyDown = (e: KeyboardEvent) => {
 | ||
|       if (e.key === "Shift" && mapServiceInstance && !isLassoActive) {
 | ||
|         mapServiceInstance.activateLasso();
 | ||
|         setIsLassoActive(true);
 | ||
|       }
 | ||
|     };
 | ||
|     const handleKeyUp = (e: KeyboardEvent) => {
 | ||
|       if (e.key === "Shift" && mapServiceInstance) {
 | ||
|         mapServiceInstance.deactivateLasso();
 | ||
|         setIsLassoActive(false);
 | ||
|       }
 | ||
|     };
 | ||
|     window.addEventListener("keydown", handleKeyDown);
 | ||
|     window.addEventListener("keyup", handleKeyUp);
 | ||
|     return () => {
 | ||
|       window.removeEventListener("keydown", handleKeyDown);
 | ||
|       window.removeEventListener("keyup", handleKeyUp);
 | ||
|     };
 | ||
|   }, [mapServiceInstance, isLassoActive]);
 | ||
| 
 | ||
|   useEffect(() => {
 | ||
|     if (mapServiceInstance) {
 | ||
|       mapServiceInstance.toggleLasso = function () {
 | ||
|         if (currentMapMode === "lasso") {
 | ||
|           this.deactivateLasso();
 | ||
|           setIsLassoActive(false);
 | ||
|         } else {
 | ||
|           this.activateLasso();
 | ||
|           setIsLassoActive(true);
 | ||
|         }
 | ||
|       };
 | ||
|     }
 | ||
|   }, [mapServiceInstance, currentMapMode]);
 | ||
| 
 | ||
|   useEffect(() => {
 | ||
|     saveActiveSection(activeSectionFromParent);
 | ||
|   }, [activeSectionFromParent]);
 | ||
| 
 | ||
|   // Перезагружаем данные при изменении города
 | ||
|   useEffect(() => {
 | ||
|     if (mapServiceInstance && !isDataLoading) {
 | ||
|       // Очищаем текущие объекты на карте
 | ||
|       mapServiceInstance.pointSource.clear();
 | ||
|       mapServiceInstance.lineSource.clear();
 | ||
| 
 | ||
|       // Загружаем новые данные с фильтрацией по городу
 | ||
|       mapServiceInstance.loadFeaturesFromApi(
 | ||
|         mapStore.stations,
 | ||
|         mapStore.routes,
 | ||
|         mapStore.sights
 | ||
|       );
 | ||
|     }
 | ||
|   }, [selectedCityId, mapServiceInstance, isDataLoading]);
 | ||
| 
 | ||
|   const showLoader = isMapLoading || isDataLoading;
 | ||
|   const showContent = mapServiceInstance && !showLoader && !error;
 | ||
|   const isAnythingSelected =
 | ||
|     selectedFeatureForSidebar !== null || selectedIds.size > 0;
 | ||
| 
 | ||
|   return (
 | ||
|     <div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
 | ||
|       <div className="relative flex-grow flex">
 | ||
|         <div
 | ||
|           ref={mapRef}
 | ||
|           id="map"
 | ||
|           className="flex-1 h-full relative bg-gray-200"
 | ||
|         >
 | ||
|           <div
 | ||
|             ref={tooltipRef}
 | ||
|             className="tooltip ol-tooltip bg-white text-black p-2 rounded shadow-lg text-xs whitespace-nowrap"
 | ||
|             style={{ position: "absolute", pointerEvents: "none" }}
 | ||
|           ></div>
 | ||
|           {showLoader && (
 | ||
|             <div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-500 bg-opacity-50 z-[1001]">
 | ||
|               <Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-3" />
 | ||
|               <div className="text-md font-semibold text-white drop-shadow-md">
 | ||
|                 {isMapLoading ? "Загрузка карты..." : "Загрузка данных..."}
 | ||
|               </div>
 | ||
|             </div>
 | ||
|           )}
 | ||
|           {error && !showLoader && (
 | ||
|             <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-red-100 border border-red-400 text-red-700 p-6 rounded-lg shadow-lg z-[1002] text-center max-w-md">
 | ||
|               <h3 className="font-semibold text-lg mb-2">Произошла ошибка</h3>
 | ||
|               <p className="text-sm">{error}</p>
 | ||
|               <button
 | ||
|                 onClick={() => window.location.reload()}
 | ||
|                 className="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm"
 | ||
|               >
 | ||
|                 Перезагрузить
 | ||
|               </button>
 | ||
|             </div>
 | ||
|           )}
 | ||
|           {isLassoActive && (
 | ||
|             <div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white py-2 px-4 rounded-full shadow-lg text-sm font-medium z-20">
 | ||
|               Режим выделения области.
 | ||
|             </div>
 | ||
|           )}
 | ||
|         </div>
 | ||
|         {showContent && (
 | ||
|           <MapControls
 | ||
|             mapService={mapServiceInstance}
 | ||
|             activeMode={currentMapMode}
 | ||
|             isLassoActive={isLassoActive}
 | ||
|             isUnselectDisabled={!isAnythingSelected}
 | ||
|           />
 | ||
|         )}
 | ||
| 
 | ||
|         <button
 | ||
|           onClick={() => setShowHelp(!showHelp)}
 | ||
|           className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
 | ||
|           title="Помощь по клавишам"
 | ||
|         >
 | ||
|           <InfoIcon size={20} />
 | ||
|         </button>
 | ||
| 
 | ||
|         {showHelp && (
 | ||
|           <div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs">
 | ||
|             <h4 className="font-bold mb-2">Горячие клавиши:</h4>
 | ||
|             <ul className="text-sm space-y-2">
 | ||
|               <li>
 | ||
|                 <span className="font-mono bg-gray-100 px-1 rounded">
 | ||
|                   Shift
 | ||
|                 </span>{" "}
 | ||
|                 - Режим выделения (лассо)
 | ||
|               </li>
 | ||
|               <li>
 | ||
|                 <span className="font-mono bg-gray-100 px-1 rounded">
 | ||
|                   Ctrl + клик
 | ||
|                 </span>{" "}
 | ||
|                 - Добавить/убрать из выделения
 | ||
|               </li>
 | ||
|               <li>
 | ||
|                 <span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "}
 | ||
|                 - Отменить выделение
 | ||
|               </li>
 | ||
|             </ul>
 | ||
|             <button
 | ||
|               onClick={() => setShowHelp(false)}
 | ||
|               className="mt-3 text-xs text-blue-600 hover:text-blue-800"
 | ||
|             >
 | ||
|               Закрыть
 | ||
|             </button>
 | ||
|           </div>
 | ||
|         )}
 | ||
|       </div>
 | ||
|       {showContent && (
 | ||
|         <MapSightbar
 | ||
|           mapService={mapServiceInstance}
 | ||
|           mapFeatures={mapFeatures}
 | ||
|           selectedFeature={selectedFeatureForSidebar}
 | ||
|           selectedIds={selectedIds}
 | ||
|           setSelectedIds={setSelectedIds}
 | ||
|           activeSection={activeSectionFromParent}
 | ||
|           setActiveSection={setActiveSectionFromParent}
 | ||
|         />
 | ||
|       )}
 | ||
|     </div>
 | ||
|   );
 | ||
| });
 |