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, defaults as defaultInteractions, } 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 scrollbarHideStyles = ` .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; } .scrollbar-hide::-webkit-scrollbar { display: none; } `; if (typeof document !== "undefined") { const styleElement = document.createElement("style"); styleElement.textContent = scrollbarHideStyles; document.head.appendChild(styleElement); } // --- MAP STORE --- // @ts-ignore import { languageInstance } from "@shared"; import { makeAutoObservable } from "mobx"; import { stationsStore, routeStore, sightsStore } from "@shared"; interface ApiRoute { id: number; route_number: string; path: [number, number][]; center_latitude: number; center_longitude: number; } interface ApiStation { id: number; name: string; latitude: number; longitude: number; } interface ApiSight { id: number; name: string; description: string; latitude: number; longitude: number; } class MapStore { constructor() { makeAutoObservable(this); } routes: ApiRoute[] = []; stations: ApiStation[] = []; sights: ApiSight[] = []; 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) => res.data); 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, }); stationsStore.setCreateCommonData({ latitude, longitude, city_id: 1 }); 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" ); const routeData = { route_number, path, center_latitude, center_longitude, carrier: "", carrier_id: 0, governor_appeal: 0, rotate: 0, route_direction: false, route_sys_number: route_number, scale_max: 0, scale_min: 0, }; await routeStore.createRoute(routeData); 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: "" }); await sightsStore.createSightAction(1, { 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(); // --- 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); } }; // --- SVG ICONS --- const EditIcon = () => ( ); const LineIconSvg = () => ( ); // --- TYPE DEFINITIONS --- interface MapServiceConfig { target: HTMLElement; center: [number, number]; zoom: number; } interface HistoryState { state: string; } type FeatureType = "station" | "route" | "sight"; class MapService { private map: Map | null; public pointSource: VectorSource>; public lineSource: VectorSource>; public clusterLayer: VectorLayer; // Public for the deselect handler public routeLayer: VectorLayer>>; // Public for deselect private clusterSource: Cluster; private clusterStyleCache: { [key: number]: Style }; private unclusteredRouteIds: Set = new Set(); 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 history: HistoryState[]; private historyIndex: number; private beforeActionState: string | null = null; private boundHandlePointerMove: ( event: MapBrowserEvent ) => void; private boundHandlePointerLeave: () => void; private boundHandleContextMenu: (event: MouseEvent) => void; private boundHandleKeyDown: (event: KeyboardEvent) => void; private lassoInteraction: Draw | null = null; private selectedIds: Set = new Set(); private onSelectionChange: ((ids: Set) => void) | null = null; // 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 routeIconStyle: Style; private selectedRouteIconStyle: 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[]) => void; private onFeatureSelect: (feature: Feature | null) => void; constructor( config: MapServiceConfig, setLoading: (loading: boolean) => void, setError: (error: string | null) => void, onModeChangeCallback: (mode: string) => void, onFeaturesChange: (features: Feature[]) => void, onFeatureSelect: (feature: Feature | null) => void, tooltipElement: HTMLElement, onSelectionChange?: (ids: Set) => 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.history = []; this.historyIndex = -1; 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.routeIconStyle = new Style({ image: new CircleStyle({ radius: 8, fill: new Fill({ color: "rgba(34, 197, 94, 0.8)" }), // Green stroke: new Stroke({ color: "#ffffff", width: 1.5 }), }), }); this.selectedRouteIconStyle = new Style({ image: new CircleStyle({ radius: 10, fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }), // Orange on select stroke: new Stroke({ color: "#ffffff", width: 2 }), }), }); 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; if (!feature) return this.defaultStyle; const fId = feature.getId(); if (fId === undefined || !this.unclusteredRouteIds.has(fId)) { return null; } 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; const featuresInCluster = clusterFeature.get( "features" ) as Feature[]; 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 isProxy = originalFeature.get("isProxy"); if (isProxy) return new Style(); // Invisible empty style 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; if (featureType === "route") return this.selectedRouteIconStyle; return this.selectedBusIconStyle; } if (featureType === "sight") return this.sightIconStyle; if (featureType === "route") return this.routeIconStyle; return this.busIconStyle; } }, }); this.clusterSource.on("change", () => { const newUnclusteredRouteIds = new Set(); this.clusterSource .getFeatures() .forEach((clusterFeature: Feature) => { const originalFeatures = clusterFeature.get( "features" ) as Feature[]; if (originalFeatures && originalFeatures.length === 1) { const originalFeature = originalFeatures[0]; if (originalFeature.get("featureType") === "route") { const featureId = originalFeature.getId(); if (featureId !== undefined) { newUnclusteredRouteIds.add(featureId); } } } }); if ( newUnclusteredRouteIds.size !== this.unclusteredRouteIds.size || ![...newUnclusteredRouteIds].every((id) => this.unclusteredRouteIds.has(id) ) ) { this.unclusteredRouteIds = newUnclusteredRouteIds; 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: defaultInteractions({ doubleClickZoom: 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 | 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) => { if (!doubleClick(e)) { return false; } const selectedFeatures = this.selectInteraction.getFeatures(); if (selectedFeatures.getLength() !== 1) { return true; } const feature = selectedFeatures.item(0) as Feature; 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; }, }); // @ts-ignore this.modifyInteraction.on("modifystart", () => { if (!this.beforeActionState) { this.beforeActionState = this.getCurrentStateAsGeoJSON(); } }); this.modifyInteraction.on("modifyend", (event) => { if (this.beforeActionState) { this.addStateToHistory(this.beforeActionState); } event.features.getArray().forEach((feature) => { this.saveModifiedFeature(feature as Feature); }); this.beforeActionState = null; }); if (this.map) { this.map.on("dblclick", (event: MapBrowserEvent) => { if (this.mode !== "edit") return; const layerFilter = (l: Layer) => l === this.routeLayer; const feature = this.map?.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, { 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 beforeState = this.getCurrentStateAsGeoJSON(); if (beforeState) { this.addStateToHistory(beforeState); } 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(); 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) => { 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) => { 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(); 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) { targetEl.addEventListener("contextmenu", this.boundHandleContextMenu); targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave); } 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[] = []; const lineFeatures: Feature[] = []; apiStations.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); }); apiSights.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); }); apiRoutes.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); if (route.center_longitude != null && route.center_latitude != null) { const centerPoint = new Point( transform( [route.center_longitude, route.center_latitude], "EPSG:4326", projection ) ); const proxyPointFeature = new Feature({ geometry: centerPoint, name: route.route_number, isProxy: true, }); proxyPointFeature.setId(routeId); proxyPointFeature.set("featureType", "route"); pointFeatures.push(proxyPointFeature); } }); this.pointSource.addFeatures(pointFeatures); this.lineSource.addFeatures(lineFeatures); this.updateFeaturesInReact(); const initialState = this.getCurrentStateAsGeoJSON(); if (initialState) { this.addStateToHistory(initialState); } } private addStateToHistory(stateToSave: string): void { this.history = this.history.slice(0, this.historyIndex + 1); this.history.push({ state: stateToSave }); this.historyIndex = this.history.length - 1; } private getCurrentStateAsGeoJSON(): string | null { if (!this.map) return null; const geoJSONFormat = new GeoJSON(); const allFeatures = [ ...this.pointSource.getFeatures(), ...this.lineSource.getFeatures(), ]; return geoJSONFormat.writeFeatures(allFeatures, { dataProjection: "EPSG:4326", featureProjection: this.map.getView().getProjection().getCode(), }); } private applyHistoryState(geoJSONState: string) { if (!this.map) return; const projection = this.map.getView().getProjection(); const geoJSONFormat = new GeoJSON({ dataProjection: "EPSG:4326", featureProjection: projection.getCode(), }); const features = geoJSONFormat.readFeatures( geoJSONState ) as Feature[]; this.unselect(); this.pointSource.clear(); this.lineSource.clear(); const pointFeatures: Feature[] = []; const lineFeatures: Feature[] = []; features.forEach((feature) => { const featureType = feature.get("featureType"); const isProxy = feature.get("isProxy"); if (featureType === "route" && !isProxy) { lineFeatures.push(feature as Feature); } else { pointFeatures.push(feature as Feature); } }); this.pointSource.addFeatures(pointFeatures); this.lineSource.addFeatures(lineFeatures); this.updateFeaturesInReact(); const newStations: ApiStation[] = []; const newRoutes: ApiRoute[] = []; const newSights: ApiSight[] = []; features.forEach((feature) => { const id = feature.getId(); if (!id || feature.get("isProxy")) return; const [featureType, numericIdStr] = String(id).split("-"); const numericId = parseInt(numericIdStr, 10); if (isNaN(numericId)) return; const geometry = feature.getGeometry(); if (!geometry) return; const properties = feature.getProperties(); if (featureType === "station") { const coords = (geometry as Point).getCoordinates(); const [lon, lat] = toLonLat(coords, projection); newStations.push({ id: numericId, name: properties.name, latitude: lat, longitude: lon, }); } else if (featureType === "sight") { const coords = (geometry as Point).getCoordinates(); const [lon, lat] = toLonLat(coords, projection); newSights.push({ id: numericId, name: properties.name, description: properties.description, latitude: lat, longitude: lon, }); } else if (featureType === "route") { const coords = (geometry as LineString).getCoordinates(); const path = coords.map((c) => { const [lon, lat] = toLonLat(c, projection); return [lat, lon] as [number, number]; }); const centerCoords = getCenter(geometry.getExtent()); const [center_longitude, center_latitude] = toLonLat( centerCoords, projection ); newRoutes.push({ id: numericId, route_number: properties.name, path: path, center_latitude, center_longitude, }); } }); mapStore.stations = newStations; mapStore.routes = newRoutes.sort((a, b) => a.route_number.localeCompare(b.route_number) ); mapStore.sights = newSights; } public undo(): void { if (this.historyIndex > 0) { this.historyIndex--; const stateToRestore = this.history[this.historyIndex].state; this.applyHistoryState(stateToRestore); const features = [ ...this.pointSource.getFeatures().filter((f) => !f.get("isProxy")), ...this.lineSource.getFeatures(), ]; const updatePromises = features.map((feature) => { const featureType = feature.get("featureType"); const geoJSONFormat = new GeoJSON({ dataProjection: "EPSG:4326", featureProjection: this.map?.getView().getProjection().getCode(), }); const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature); return mapStore.updateFeature(featureType, featureGeoJSON); }); Promise.all(updatePromises) .then(() => {}) .catch((error) => { console.error("Failed to update backend after undo:", error); this.historyIndex++; const previousState = this.history[this.historyIndex].state; this.applyHistoryState(previousState); }); } else { toast.info("Больше отменять нечего"); } } public redo(): void { if (this.historyIndex < this.history.length - 1) { this.historyIndex++; const stateToRestore = this.history[this.historyIndex].state; this.applyHistoryState(stateToRestore); const features = [ ...this.pointSource.getFeatures().filter((f) => !f.get("isProxy")), ...this.lineSource.getFeatures(), ]; const updatePromises = features.map((feature) => { const featureType = feature.get("featureType"); const geoJSONFormat = new GeoJSON({ dataProjection: "EPSG:4326", featureProjection: this.map?.getView().getProjection().getCode(), }); const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature); return mapStore.updateFeature(featureType, featureGeoJSON); }); Promise.all(updatePromises) .then(() => { toast.info("Действие повторено"); }) .catch((error) => { console.error("Failed to update backend after redo:", error); toast.error("Не удалось обновить данные на сервере"); this.historyIndex--; const previousState = this.history[this.historyIndex].state; this.applyHistoryState(previousState); }); } else { toast.info("Больше повторять нечего"); } } 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); } private handleKeyDown(event: KeyboardEvent): void { if ((event.ctrlKey || event.metaKey) && event.key === "z") { event.preventDefault(); this.undo(); return; } if ((event.ctrlKey || event.metaKey) && event.key === "y") { event.preventDefault(); this.redo(); return; } 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("drawstart", () => { this.beforeActionState = this.getCurrentStateAsGeoJSON(); }); this.currentInteraction.on("drawend", async (event: DrawEvent) => { if (this.beforeActionState) { this.addStateToHistory(this.beforeActionState); } this.beforeActionState = null; const feature = event.feature as Feature; const fType = this.currentDrawingFeatureType; if (!fType) return; feature.set("featureType", fType); let resourceName: string; const allFeatures = [ ...this.pointSource.getFeatures(), ...this.lineSource.getFeatures(), ]; switch (fType) { case "station": const existingStations = allFeatures.filter( (f) => f.get("featureType") === "station" ); const stationNumbers = existingStations .map((f) => { const name = f.get("name") as string; const match = 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": const existingSights = allFeatures.filter( (f) => f.get("featureType") === "sight" ); const sightNumbers = existingSights .map((f) => { const name = f.get("name") as string; const match = 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": const existingRoutes = allFeatures.filter( (f) => f.get("featureType") === "route" && !f.get("isProxy") ); const routeNumbers = existingRoutes .map((f) => { const name = f.get("name") as string; const match = name?.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; try { this.currentInteraction.finishDrawing(); } catch (e) { this.stopDrawing(); } } private handlePointerMove(event: MapBrowserEvent): 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) => l === this.clusterLayer || l === this.routeLayer; const hit = this.map.hasFeatureAtPixel(event.pixel, { layerFilter, hitTolerance: 5, }); this.map.getTargetElement().style.cursor = hit ? "pointer" : ""; const featureAtPixel: Feature | undefined = this.map.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, { layerFilter, hitTolerance: 5 } ); let finalFeature: Feature | 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; this.beforeActionState = this.getCurrentStateAsGeoJSON(); const numericId = parseInt(String(featureId).split("-")[1], 10); if (!recourse || isNaN(numericId)) return; mapStore .deleteFeature(recourse, numericId) .then(() => { if (this.beforeActionState) this.addStateToHistory(this.beforeActionState); this.beforeActionState = null; if (recourse === "route") { const lineFeature = this.lineSource.getFeatureById(featureId); if (lineFeature) this.lineSource.removeFeature(lineFeature as Feature); const pointFeature = this.pointSource.getFeatureById(featureId); if (pointFeature) this.pointSource.removeFeature(pointFeature as Feature); } else { const feature = this.pointSource.getFeatureById(featureId); if (feature) this.pointSource.removeFeature(feature as Feature); } this.unselect(); }) .catch((err) => { console.error("Delete failed:", err); }); } public deleteMultipleFeatures(featureIds: (string | number)[]): void { if (!featureIds || featureIds.length === 0) return; this.beforeActionState = this.getCurrentStateAsGeoJSON(); 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) { if (this.beforeActionState) this.addStateToHistory(this.beforeActionState); this.beforeActionState = null; 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 ); const pointFeature = this.pointSource.getFeatureById(id); if (pointFeature) this.pointSource.removeFeature(pointFeature as Feature); } else { const feature = this.pointSource.getFeatureById(id); if (feature) this.pointSource.removeFeature(feature as Feature); } }); 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> ): 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> ): 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) { 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) => void) { this.onSelectionChange = cb; } public toggleLasso() { if (this.mode === "lasso") this.deactivateLasso(); else this.activateLasso(); } public getMap(): Map | null { return this.map; } 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) { const featureType = feature.get("featureType") as FeatureType; const featureId = feature.getId(); if (!featureType || featureId === undefined || !this.map) return; if ( featureType === "route" && feature.getGeometry()?.getType() === "LineString" ) { const proxyPoint = this.pointSource.getFeatureById( featureId ) as Feature; if (proxyPoint) { const lineGeom = feature.getGeometry() as LineString; const newCenter = getCenter(lineGeom.getExtent()); proxyPoint.getGeometry()?.setCoordinates(newCenter); } } 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}`); if (this.beforeActionState) { this.applyHistoryState(this.beforeActionState); } } } private async saveNewFeature(feature: Feature) { const featureType = feature.get("featureType") as FeatureType; if (!featureType || !this.map) return; 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); // Create and add proxy point const centerPointGeom = new Point( transform( [routeData.center_longitude, routeData.center_latitude], "EPSG:4326", projection ) ); const proxyPointFeature = new Feature({ geometry: centerPointGeom, name: displayName, isProxy: true, }); proxyPointFeature.setId(newFeatureId); proxyPointFeature.set("featureType", "route"); this.pointSource.addFeature(proxyPointFeature); } 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)) this.lineSource.removeFeature(feature as Feature); } else { if (this.pointSource.hasFeature(feature as Feature)) this.pointSource.removeFeature(feature as Feature); } if (this.beforeActionState) { this.applyHistoryState(this.beforeActionState); } this.beforeActionState = null; } } } // --- 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 = ({ mapService, activeMode, // @ts-ignore isLassoActive, isUnselectDisabled, }) => { if (!mapService) return null; const controls: ControlItem[] = [ { mode: "edit", title: "Редактировать", longTitle: "Редактирование", icon: , action: () => mapService.activateEditMode(), }, { mode: "drawing-station", title: "Остановка", longTitle: "Добавить остановку", icon: , action: () => mapService.startDrawing("Point", "station"), }, { mode: "drawing-sight", title: "Достопримечательность", longTitle: "Добавить достопримечательность", icon: , action: () => mapService.startDrawing("Point", "sight"), }, { mode: "drawing-route", title: "Маршрут", longTitle: "Добавить маршрут (Правый клик для завершения)", icon: , action: () => mapService.startDrawing("LineString", "route"), }, { mode: "unselect", title: "Сбросить", longTitle: "Сбросить выделение (Esc)", icon: , action: () => mapService.unselect(), disabled: isUnselectDisabled, }, ]; return (
{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 ( ); })}
); }; // --- MAP SIGHTBAR COMPONENT --- interface MapSightbarProps { mapService: MapService | null; mapFeatures: Feature[]; selectedFeature: Feature | null; selectedIds: Set; setSelectedIds: (ids: Set) => void; activeSection: string | null; setActiveSection: (section: string | null) => void; } const MapSightbar: React.FC = ({ mapService, mapFeatures, selectedFeature, selectedIds, setSelectedIds, activeSection, setActiveSection, }) => { const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(""); const actualFeatures = useMemo(() => { return mapFeatures.filter((feature) => !feature.get("isProxy")); }, [mapFeatures]); const filteredFeatures = useMemo(() => { if (!searchQuery.trim()) return actualFeatures; return actualFeatures.filter((feature) => ((feature.get("name") as string) || "") .toLowerCase() .includes(searchQuery.toLowerCase()) ); }, [actualFeatures, searchQuery]); const handleFeatureClick = useCallback( (id: string | number | undefined) => { if (!id || !mapService) return; mapService.selectFeature(id); }, [mapService] ); const handleDeleteFeature = useCallback( // @ts-ignore (id, recourse) => { if ( mapService && window.confirm("Вы действительно хотите удалить этот объект?") ) { mapService.deleteFeature(id, recourse); } }, [mapService] ); const handleCheckboxChange = useCallback( (id: string | number | undefined) => { if (!id || !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]); // @ts-ignore const handleEditFeature = useCallback( // @ts-ignore (featureType, fullId) => { if (!featureType || !fullId) return; const numericId = String(fullId).split("-")[1]; if (numericId) navigate(`/${featureType}/${numericId}/edit`); }, [navigate] ); const sortFeatures = ( features: Feature[], currentSelectedIds: Set, currentSelectedFeature: Feature | null ) => { const selectedId = currentSelectedFeature?.getId(); return [...features].sort((a, b) => { const aId = a.getId(); const bId = b.getId(); if (selectedId) { if (aId === selectedId) return -1; if (bId === selectedId) return 1; } const aIsChecked = aId !== undefined && currentSelectedIds.has(aId); const bIsChecked = bId !== undefined && currentSelectedIds.has(bId); if (aIsChecked && !bIsChecked) return -1; if (!aIsChecked && bIsChecked) return 1; const aNumericId = aId ? parseInt(String(aId).split("-")[1], 10) : 0; const bNumericId = bId ? parseInt(String(bId).split("-")[1], 10) : 0; if ( !isNaN(aNumericId) && !isNaN(bNumericId) && aNumericId !== bNumericId ) { return aNumericId - bNumericId; } const aName = (a.get("name") as string) || ""; const bName = (b.get("name") as string) || ""; return aName.localeCompare(bName, "ru"); }); }; const toggleSection = (id: string) => setActiveSection(activeSection === id ? null : id); 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 = sortFeatures(stations, selectedIds, selectedFeature); const sortedLines = sortFeatures(lines, selectedIds, selectedFeature); const sortedSights = sortFeatures(sights, selectedIds, selectedFeature); const renderFeatureList = ( features: Feature[], featureType: "station" | "route" | "sight", IconComponent: React.ElementType ) => (
{features.length > 0 ? ( features.map((feature) => { const fId = feature.getId(); const fName = (feature.get("name") as string) || "Без названия"; const isSelected = selectedFeature?.getId() === fId; const isChecked = fId !== undefined && selectedIds.has(fId); return (
handleCheckboxChange(fId)} onClick={(e) => e.stopPropagation()} aria-label={`Выбрать ${fName}`} />
handleFeatureClick(fId)} >
)} // @ts-ignore size={16} /> {fName}
); }) ) : (

Нет объектов этого типа.

)}
); const sections = [ { id: "layers", title: `Остановки (${sortedStations.length})`, icon: , count: sortedStations.length, content: renderFeatureList(sortedStations, "station", MapPin), }, { id: "lines", title: `Маршруты (${sortedLines.length})`, icon: , count: sortedLines.length, content: renderFeatureList(sortedLines, "route", ArrowRightLeft), }, { id: "sights", title: `Достопримечательности (${sortedSights.length})`, icon: , count: sortedSights.length, content: renderFeatureList(sortedSights, "sight", Landmark), }, ]; return (

Панель управления

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" />
{filteredFeatures.length === 0 && searchQuery ? (
Ничего не найдено.
) : ( sections.map( (s) => (s.count > 0 || !searchQuery) && (
{s.content}
) ) )}
{selectedIds.size > 0 && (
)}
); }; // --- MAP PAGE COMPONENT --- export const MapPage: React.FC = () => { const mapRef = useRef(null); const tooltipRef = useRef(null); const [mapServiceInstance, setMapServiceInstance] = useState(null); const [isMapLoading, setIsMapLoading] = useState(true); const [isDataLoading, setIsDataLoading] = useState(true); const [error, setError] = useState(null); const [currentMapMode, setCurrentMapMode] = useState("edit"); const [mapFeatures, setMapFeatures] = useState[]>([]); const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] = useState | null>(null); const [selectedIds, setSelectedIds] = useState>( new Set() ); const [isLassoActive, setIsLassoActive] = useState(false); const [showHelp, setShowHelp] = useState(false); const [activeSectionFromParent, setActiveSectionFromParent] = useState< string | null >(() => getStoredActiveSection() || "layers"); const handleFeaturesChange = useCallback( (feats: Feature[]) => setMapFeatures([...feats]), [] ); const handleFeatureSelectForSidebar = useCallback( (feat: Feature | 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(), ]); 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); loadInitialData(service); } catch (e: any) { setError( `Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.` ); setIsMapLoading(false); setIsDataLoading(false); } } return () => { service?.destroy(); setMapServiceInstance(null); }; // 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]); const showLoader = isMapLoading || isDataLoading; const showContent = mapServiceInstance && !showLoader && !error; const isAnythingSelected = selectedFeatureForSidebar !== null || selectedIds.size > 0; return (
{showLoader && (
{isMapLoading ? "Загрузка карты..." : "Загрузка данных..."}
)} {error && !showLoader && (

Произошла ошибка

{error}

)} {isLassoActive && (
Режим выделения области.
)}
{showContent && ( )} {showHelp && (

Горячие клавиши:

  • Shift {" "} - Режим выделения (лассо)
  • Ctrl + клик {" "} - Добавить/убрать из выделения
  • Esc{" "} - Отменить выделение
  • Ctrl+Z {" "} - Отменить действие
  • Ctrl+Y {" "} - Повторить действие
)}
{showContent && ( )}
); };