import React, { useEffect, useRef, useState, useCallback, ReactNode, 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 { Draw, Modify, Select } from "ol/interaction"; import { DrawEvent } from "ol/interaction/Draw"; import { SelectEvent } from "ol/interaction/Select"; import { Style, Fill, Stroke, Circle as CircleStyle, RegularShape, } from "ol/style"; import { Point, LineString, Geometry, Polygon } from "ol/geom"; import { transform } from "ol/proj"; import { GeoJSON } from "ol/format"; import { Bus, RouteIcon, MapPin, Trash2, ArrowRightLeft, Landmark, Pencil, Lasso, 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"; // --- MAP STORE --- import { languageInstance } from "@shared"; // Убедитесь, что этот импорт правильный import { makeAutoObservable } from "mobx"; interface ApiRoute { id: number; route_number: string; path: [number, 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"); console.log(response.data); const routesIds = response.data.map((route: any) => route.id); for (const id of routesIds) { const route = await languageInstance("ru").get(`/route/${id}`); this.routes.push({ id: route.data.id, route_number: route.data.route_number, path: route.data.path, }); } 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) => ({ id: station.id, name: station.name, latitude: station.latitude, longitude: station.longitude, })); }; getSights = async () => { const sights = await languageInstance("ru").get("/sight"); this.sights = sights.data.map((sight: any) => ({ id: sight.id, name: sight.name, description: sight.description, latitude: sight.latitude, longitude: sight.longitude, })); }; 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 data; if (featureType === "station") { data = { name: properties.name || "Новая станция", latitude: geometry.coordinates[1], longitude: geometry.coordinates[0], }; } else if (featureType === "route") { data = { route_number: properties.name || "Новый маршрут", path: geometry.coordinates, }; } 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 creation: ${featureType}`); } const response = await languageInstance("ru").post(`/${featureType}`, data); if (featureType === "route") this.routes.push(response.data); else if (featureType === "station") this.stations.push(response.data); else if (featureType === "sight") this.sights.push(response.data); return response.data; }; 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") { data = { route_number: properties.name, path: geometry.coordinates, }; } 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 response = await languageInstance("ru").patch( `/${featureType}/${numericId}`, data ); if (featureType === "route") { const index = this.routes.findIndex((f) => f.id === numericId); if (index !== -1) this.routes[index] = response.data; } else if (featureType === "station") { const index = this.stations.findIndex((f) => f.id === numericId); if (index !== -1) this.stations[index] = response.data; } else if (featureType === "sight") { const index = this.sights.findIndex((f) => f.id === numericId); if (index !== -1) this.sights[index] = response.data; } return response.data; }; } const mapStore = new MapStore(); // --- CONFIGURATION --- export const mapConfig = { center: [30.311, 59.94] as [number, number], zoom: 13, }; // --- SVG ICONS --- const EditIcon = () => ( ); const LineIconSvg = () => ( ); // --- TYPE DEFINITIONS --- interface MapServiceConfig { target: HTMLElement; center: [number, number]; zoom: number; } interface HistoryState { action: string; state: string; } type FeatureType = "station" | "route" | "sight"; class MapService { private map: Map | null; private vectorSource: VectorSource>; private vectorLayer: VectorLayer>>; private tooltipElement: HTMLElement; private tooltipOverlay: Overlay | null; private mode: string | null; 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 beforeModifyState: string | 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 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.beforeModifyState = null; 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: 3 }), }); // ИСПРАВЛЕНИЕ: Удалено свойство image из этого стиля. // Оно предназначалось для линий, но применялось и к точкам, // создавая ненужный центральный круг. this.selectedStyle = new Style({ fill: new Fill({ color: "rgba(221, 107, 32, 0.3)" }), stroke: new Stroke({ color: "#dd6b20", width: 4 }), }); 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: 2, 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: 5 }), zIndex: Infinity, }); this.vectorSource = new VectorSource>(); this.vectorLayer = new VectorLayer({ source: this.vectorSource, style: (featureLike: FeatureLike) => { const feature = featureLike as Feature; if (!feature) return this.defaultStyle; const geometryType = feature.getGeometry()?.getType(); const fId = feature.getId(); const featureType = feature.get("featureType"); const isEditSelected = this.selectInteraction ?.getFeatures() .getArray() .includes(feature); const isHovered = this.hoveredFeatureId === fId; const isLassoSelected = fId !== undefined && this.selectedIds.has(fId); if (geometryType === "Point") { const defaultPointStyle = featureType === "sight" ? this.sightIconStyle : this.busIconStyle; const selectedPointStyle = featureType === "sight" ? this.selectedSightIconStyle : this.selectedBusIconStyle; if (isEditSelected) { return selectedPointStyle; } if (isHovered) { // Only apply hover styles if not in edit mode if (this.mode !== "edit") { return featureType === "sight" ? this.hoverSightIconStyle : this.universalHoverStylePoint; } return defaultPointStyle; } if (isLassoSelected) { let imageStyle; if (featureType === "sight") { imageStyle = new RegularShape({ fill: new Fill({ color: "#14b8a6" }), stroke: new Stroke({ color: "#fff", width: 2 }), points: 5, radius: 12, radius2: 6, angle: 0, }); } else { imageStyle = new CircleStyle({ radius: 10, fill: new Fill({ color: "#14b8a6" }), stroke: new Stroke({ color: "#fff", width: 2 }), }); } return new Style({ image: imageStyle, zIndex: Infinity }); } return defaultPointStyle; } else if (geometryType === "LineString") { if (isEditSelected) { return this.selectedStyle; } if (isHovered) { return this.universalHoverStyleLine; } if (isLassoSelected) { return new Style({ stroke: new Stroke({ color: "#14b8a6", width: 6 }), zIndex: Infinity, }); } return this.defaultStyle; } return this.defaultStyle; }, }); 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.vectorSource.on( "addfeature", this.handleFeatureEvent.bind(this) as any ); this.vectorSource.on("removefeature", () => this.updateFeaturesInReact()); this.vectorSource.on( "changefeature", this.handleFeatureChange.bind(this) as any ); let renderCompleteHandled = false; const MAP_LOAD_TIMEOUT = 15000; try { this.map = new Map({ target: config.target, layers: [new TileLayer({ source: new OSM() }), this.vectorLayer], view: new View({ center: transform(config.center, "EPSG:4326", "EPSG:3857"), zoom: config.zoom, }), controls: [], }); 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.modifyInteraction = new Modify({ source: this.vectorSource, 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) => doubleClick(e), }); this.selectInteraction = new Select({ style: (featureLike: FeatureLike) => { if (!featureLike || !featureLike.getGeometry) return this.defaultStyle; const feature = featureLike as Feature; const featureType = feature.get("featureType"); const geometryType = feature.getGeometry()?.getType(); if (geometryType === "Point") { return featureType === "sight" ? this.selectedSightIconStyle : this.selectedBusIconStyle; } return this.selectedStyle; }, condition: singleClick, filter: (_: FeatureLike, l: Layer | null) => l === this.vectorLayer, }); this.modifyInteraction.on("modifystart", (event) => { const geoJSONFormat = new GeoJSON(); if (!this.map) return; this.beforeModifyState = geoJSONFormat.writeFeatures( this.vectorSource.getFeatures(), // Сохраняем все фичи для отката { dataProjection: "EPSG:4326", featureProjection: this.map.getView().getProjection().getCode(), } ); }); this.modifyInteraction.on("modifyend", (event) => { if (this.beforeModifyState) { this.addStateToHistory("modify", this.beforeModifyState); this.beforeModifyState = null; } this.updateFeaturesInReact(); event.features.getArray().forEach((feature) => { this.saveModifiedFeature(feature as 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.vectorSource.forEachFeatureInExtent(extent, (f) => { 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()!); } } else if (geom && geom.intersectsExtent(extent)) { if (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") { const selFs = e.selected as Feature[]; this.modifyInteraction.setActive(selFs.length > 0); if (selFs.length > 0) { this.onFeatureSelect(selFs[0]); } else { this.onFeatureSelect(null); } } }); 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 featuresToAdd: 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"); featuresToAdd.push(feature); }); apiRoutes.forEach((route) => { if (!route.path || route.path.length === 0) return; const coordinates = route.path .filter((c) => c[0] != null && c[1] != null) .map((c) => transform(c, "EPSG:4326", projection)); if (coordinates.length === 0) return; const line = new LineString(coordinates); const feature = new Feature({ geometry: line, name: route.route_number }); feature.setId(`route-${route.id}`); feature.set("featureType", "route"); featuresToAdd.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"); featuresToAdd.push(feature); }); this.vectorSource.addFeatures(featuresToAdd); this.updateFeaturesInReact(); } private addStateToHistory( actionDescription: string, stateToSave: string ): void { this.history = this.history.slice(0, this.historyIndex + 1); this.history.push({ action: actionDescription, state: stateToSave }); this.historyIndex = this.history.length - 1; } private getCurrentStateAsGeoJSON(): string | null { if (!this.map) return null; const geoJSONFormat = new GeoJSON(); return geoJSONFormat.writeFeatures(this.vectorSource.getFeatures(), { dataProjection: "EPSG:4326", featureProjection: this.map.getView().getProjection().getCode(), }); } public undo(): void { if (this.historyIndex >= 0) { const stateToRestore = this.history[this.historyIndex].state; this.applyHistoryState(stateToRestore); this.historyIndex--; } else { toast.info("Больше отменять нечего"); } } public redo(): void { if (this.historyIndex < this.history.length - 1) { this.historyIndex++; this.applyHistoryState(this.history[this.historyIndex].state); } else { toast.info("Больше повторять нечего"); } } private applyHistoryState(geoJSONState: string): void { if (!this.map) return; const geoJSONFormat = new GeoJSON(); const features = geoJSONFormat.readFeatures(geoJSONState, { dataProjection: "EPSG:4326", featureProjection: this.map.getView().getProjection().getCode(), }) as Feature[]; this.unselect(); this.vectorSource.clear(); if (features.length > 0) this.vectorSource.addFeatures(features); this.updateFeaturesInReact(); } private updateFeaturesInReact(): void { if (this.onFeaturesChange) { this.onFeaturesChange(this.vectorSource.getFeatures()); } } private handlePointerLeave(): void { if (this.hoveredFeatureId) { this.hoveredFeatureId = null; this.vectorLayer.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.vectorLayer.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; this.currentInteraction = new Draw({ source: this.vectorSource, type, style: styleForDrawing, }); let stateBeforeDraw: string | null = null; this.currentInteraction.on("drawstart", () => { stateBeforeDraw = this.getCurrentStateAsGeoJSON(); }); this.currentInteraction.on("drawend", async (event: DrawEvent) => { if (stateBeforeDraw) { this.addStateToHistory("draw-before", stateBeforeDraw); } const feature = event.feature as Feature; const fType = this.currentDrawingFeatureType; if (!fType) return; feature.set("featureType", fType); let baseName = "", namePrefix = ""; if (fType === "station") { baseName = "Станция"; namePrefix = "Станция "; } else if (fType === "sight") { baseName = "Достопримечательность"; namePrefix = "Достопримечательность "; } else if (fType === "route") { baseName = "Маршрут"; namePrefix = "Маршрут "; } const existingNamedFeatures = this.vectorSource .getFeatures() .filter( (f) => f !== feature && f.get("featureType") === fType && (f.get("name") as string)?.startsWith(namePrefix) ); let maxNumber = 0; existingNamedFeatures.forEach((f) => { const name = f.get("name") as string; if (name) { const num = parseInt(name.substring(namePrefix.length), 10); if (!isNaN(num) && num > maxNumber) maxNumber = num; } }); feature.set("name", `${baseName} ${maxNumber + 1}`); await this.saveNewFeature(feature); this.stopDrawing(); }); this.map.addInteraction(this.currentInteraction); } public startDrawingMarker(): void { this.startDrawing("Point", "station"); } public startDrawingLine(): void { this.startDrawing("LineString", "route"); } public startDrawingSight(): void { this.startDrawing("Point", "sight"); } 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; this.activateEditMode(); } public finishDrawing(): void { if (!this.currentInteraction) return; try { this.currentInteraction.finishDrawing(); } catch (e) { this.stopDrawing(); } } private handleContextMenu(event: MouseEvent): void { event.preventDefault(); if ( this.mode?.startsWith("drawing-") && this.currentInteraction instanceof Draw ) { this.finishDrawing(); } } private handlePointerMove(event: MapBrowserEvent): void { if (!this.map || event.dragging) { if (this.hoveredFeatureId) { this.hoveredFeatureId = null; this.vectorLayer.changed(); } if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); return; } const hit = this.map.hasFeatureAtPixel(event.pixel, { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5, }); this.map.getTargetElement().style.cursor = hit ? "pointer" : ""; const featureAtPixel: Feature | undefined = this.map.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 } ); const newHoveredFeatureId = featureAtPixel ? featureAtPixel.getId() : null; if (this.tooltipOverlay && this.tooltipElement) { if (this.mode === "edit" && featureAtPixel) { const name = featureAtPixel.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); } } // Only update hoveredFeatureId if not in edit mode if (this.mode !== "edit" && this.hoveredFeatureId !== newHoveredFeatureId) { this.hoveredFeatureId = newHoveredFeatureId as string | number | null; this.vectorLayer.changed(); } } public handleMapClick(event: MapBrowserEvent, ctrlKey: boolean): void { if (!this.map || this.mode !== "edit") return; const featureAtPixel: Feature | undefined = this.map.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 } ); if (!featureAtPixel) { if (!ctrlKey) this.unselect(); return; } const featureId = featureAtPixel.getId(); if (featureId === undefined) return; if (ctrlKey) { const newSet = new Set(this.selectedIds); if (newSet.has(featureId)) newSet.delete(featureId); else newSet.add(featureId); this.setSelectedIds(newSet); } else { this.setSelectedIds(new Set([featureId])); } } public selectFeature(featureId: string | number | undefined): void { if (!this.map || featureId === undefined) { this.unselect(); return; } const feature = this.vectorSource.getFeatureById(featureId); if (!feature) { this.unselect(); return; } if (this.mode === "edit") { this.selectInteraction.getFeatures().clear(); this.selectInteraction.getFeatures().push(feature); const selectEvent = new SelectEvent("select", [feature], []); this.selectInteraction.dispatchEvent(selectEvent); } this.setSelectedIds(new Set([featureId])); 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, 14), }); } else { view.fit(geometry.getExtent(), { duration: 500, padding: [50, 50, 50, 50], maxZoom: 15, }); } } } public deleteFeature( featureId: string | number | undefined, recourse: string ): void { if (featureId === undefined) return; const stateBeforeDelete = this.getCurrentStateAsGeoJSON(); const numericId = parseInt(String(featureId).split("-")[1], 10); if (!recourse || isNaN(numericId)) return; const feature = this.vectorSource.getFeatureById(featureId); if (!feature) return; mapStore .deleteFeature(recourse, numericId) .then(() => { toast.success("Объект успешно удален"); if (stateBeforeDelete) this.addStateToHistory("delete", stateBeforeDelete); this.vectorSource.removeFeature(feature); this.unselect(); }) .catch((err) => { toast.error("Ошибка при удалении объекта"); console.error("Delete failed:", err); }); } public deleteMultipleFeatures(featureIds: (string | number)[]): void { if (!featureIds || featureIds.length === 0) return; const stateBeforeDelete = this.getCurrentStateAsGeoJSON(); const deletePromises = Array.from(featureIds).map((id) => { const feature = this.vectorSource.getFeatureById(id); if (!feature) return Promise.resolve(); const recourse = String(id).split("-")[0]; const numericId = parseInt(String(id).split("-")[1], 10); if (recourse && !isNaN(numericId)) { return mapStore.deleteFeature(recourse, numericId).then(() => feature); // Возвращаем фичу в случае успеха } return Promise.resolve(); }); Promise.all(deletePromises) .then((deletedFeatures) => { const successfulDeletes = deletedFeatures.filter((f) => f); if (successfulDeletes.length > 0) { if (stateBeforeDelete) this.addStateToHistory("multiple-delete", stateBeforeDelete); successfulDeletes.forEach((f) => this.vectorSource.removeFeature(f as Feature) ); toast.success(`Удалено ${successfulDeletes.length} объект(ов).`); this.unselect(); } }) .catch((err) => { toast.error("Произошла ошибка при массовом удалении"); console.error("Bulk delete failed:", err); }); } public getAllFeaturesAsGeoJSON(): string | null { if (!this.vectorSource || !this.map) return null; const feats = this.vectorSource.getFeatures(); if (feats.length === 0) return null; const geoJSONFmt = new GeoJSON(); return geoJSONFmt.writeFeatures(feats, { dataProjection: "EPSG:4326", featureProjection: this.map.getView().getProjection(), }); } 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.vectorSource.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.vectorLayer.changed(); } 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; } private async saveModifiedFeature(feature: Feature) { 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(), }); const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature); try { await mapStore.updateFeature(featureType, featureGeoJSON); toast.success(`"${feature.get("name")}" успешно обновлен.`); } catch (error) { console.error("Failed to update feature:", error); toast.error( `Не удалось обновить "${feature.get("name")}". Отмена изменений...` ); this.undo(); } } 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(), }); const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature); const tempId = feature.getId(); try { const createdFeatureData = await mapStore.createFeature( featureType, featureGeoJSON ); const newName = featureType === "route" ? createdFeatureData.route_number : createdFeatureData.name; toast.success(`"${newName}" создано.`); const newFeatureId = `${featureType}-${createdFeatureData.id}`; feature.setId(newFeatureId); feature.set("name", newName); this.updateFeaturesInReact(); this.selectFeature(newFeatureId); } catch (error) { console.error("Failed to save new feature:", error); toast.error("Не удалось сохранить объект."); if (tempId) { const tempFeature = this.vectorSource.getFeatureById(tempId); if (tempFeature) this.vectorSource.removeFeature(tempFeature); } this.undo(); // Откатываем состояние до момента начала рисования } } } // --- 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, 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.startDrawingMarker(), }, { mode: "drawing-sight", title: "Достопримечательность", longTitle: "Добавить достопримечательность", icon: , action: () => mapService.startDrawingSight(), }, { mode: "drawing-route", title: "Маршрут", longTitle: "Добавить маршрут (Правый клик для завершения)", icon: , action: () => mapService.startDrawingLine(), }, { 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 filteredFeatures = useMemo(() => { if (!searchQuery.trim()) return mapFeatures; return mapFeatures.filter((feature) => ((feature.get("name") as string) || "") .toLowerCase() .includes(searchQuery.toLowerCase()) ); }, [mapFeatures, searchQuery]); const handleFeatureClick = useCallback( (id) => mapService?.selectFeature(id), [mapService] ); const handleDeleteFeature = useCallback( (id, recourse) => { if ( mapService && window.confirm("Вы действительно хотите удалить этот объект?") ) { mapService.deleteFeature(id, recourse); } }, [mapService] ); const handleCheckboxChange = useCallback( (id) => { if (id === undefined) return; const newSet = new Set(selectedIds); if (newSet.has(id)) newSet.delete(id); else newSet.add(id); setSelectedIds(newSet); }, [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, fullId) => { if (!featureType || !fullId) return; const numericId = String(fullId).split("-")[1]; if (numericId) navigate(`/${featureType}/${numericId}/edit`); }, [navigate] ); const sortFeatures = ( features, currentSelectedIds, currentSelectedFeature ) => { const selectedId = currentSelectedFeature?.getId(); return [...features].sort((a, b) => { const aId = a.getId(), bId = b.getId(); if (selectedId && aId === selectedId) return -1; if (selectedId && bId === selectedId) return 1; const aSelected = aId !== undefined && currentSelectedIds.has(aId); const bSelected = bId !== undefined && currentSelectedIds.has(bId); if (aSelected && !bSelected) return -1; if (!aSelected && bSelected) return 1; return ((a.get("name") as string) || "").localeCompare( (b.get("name") as string) || "", "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 sections = [ { id: "layers", title: `Остановки (${sortedStations.length})`, icon: , count: sortedStations.length, content: (
{sortedStations.length > 0 ? ( sortedStations.map((s) => { const sId = s.getId(), sName = (s.get("name") as string) || "Без названия"; const isSelected = selectedFeature?.getId() === sId, isChecked = sId !== undefined && selectedIds.has(sId); return (
handleCheckboxChange(sId)} onClick={(e) => e.stopPropagation()} aria-label={`Выбрать ${sName}`} />
handleFeatureClick(sId)} >
{sName}
); }) ) : (

Нет остановок.

)}
), }, { id: "lines", title: `Маршруты (${sortedLines.length})`, icon: , count: sortedLines.length, content: (
{sortedLines.length > 0 ? ( sortedLines.map((l) => { const lId = l.getId(), lName = (l.get("name") as string) || "Без названия"; const isSelected = selectedFeature?.getId() === lId, isChecked = lId !== undefined && selectedIds.has(lId); return (
handleCheckboxChange(lId)} onClick={(e) => e.stopPropagation()} aria-label={`Выбрать ${lName}`} />
handleFeatureClick(lId)} >
{lName}
); }) ) : (

Нет маршрутов.

)}
), }, { id: "sights", title: `Достопримечательности (${sortedSights.length})`, icon: , count: sortedSights.length, content: (
{sortedSights.length > 0 ? ( sortedSights.map((s) => { const sId = s.getId(), sName = (s.get("name") as string) || "Без названия"; const isSelected = selectedFeature?.getId() === sId, isChecked = sId !== undefined && selectedIds.has(sId); return (
handleCheckboxChange(sId)} onClick={(e) => e.stopPropagation()} aria-label={`Выбрать ${sName}`} />
handleFeatureClick(sId)} > {sName}
); }) ) : (

Нет достопримечательностей.

)}
), }, ]; 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 >("layers"); const handleFeaturesChange = useCallback( (feats: Feature[]) => setMapFeatures([...feats]), [] ); const handleFeatureSelectForSidebar = useCallback( (feat: Feature | null) => { 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); } }, [] ); const handleMapClick = useCallback( (event: any) => { if (!mapServiceInstance || isLassoActive) return; const ctrlKey = event.originalEvent.ctrlKey || event.originalEvent.metaKey; mapServiceInstance.handleMapClick(event, ctrlKey); }, [mapServiceInstance, isLassoActive] ); 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); }; }, []); useEffect(() => { const olMap = mapServiceInstance?.getMap(); if (olMap) { olMap.on("click", handleMapClick); return () => { olMap.un("click", handleMapClick); }; } }, [mapServiceInstance, handleMapClick]); useEffect(() => { mapServiceInstance?.setOnSelectionChange(setSelectedIds); }, [mapServiceInstance]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Shift" && mapServiceInstance) { 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]); useEffect(() => { if (mapServiceInstance) { mapServiceInstance.toggleLasso = function () { if (currentMapMode === "lasso") { this.deactivateLasso(); setIsLassoActive(false); } else { this.activateLasso(); setIsLassoActive(true); } }; } }, [mapServiceInstance, currentMapMode]); 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 && ( )}
); };