From f49caf3ec8456a87e825673fa127bec4d8aed87a Mon Sep 17 00:00:00 2001 From: itoshi Date: Fri, 13 Jun 2025 09:17:24 +0300 Subject: [PATCH] fix: Map page finish --- src/pages/MapPage/index.tsx | 975 +++++++++++++++++----------------- src/pages/MapPage/mapStore.ts | 13 +- 2 files changed, 504 insertions(+), 484 deletions(-) diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx index 5e78be1..3df771b 100644 --- a/src/pages/MapPage/index.tsx +++ b/src/pages/MapPage/index.tsx @@ -33,11 +33,10 @@ import { ArrowRightLeft, Landmark, Pencil, - Save, - Loader2, Lasso, InfoIcon, - X, // --- ИЗМЕНЕНО --- Импортируем иконку крестика + X, + Loader2, } from "lucide-react"; import { toast } from "react-toastify"; import { singleClick, doubleClick } from "ol/events/condition"; @@ -46,10 +45,176 @@ import Layer from "ol/layer/Layer"; import Source from "ol/source/Source"; import { FeatureLike } from "ol/Feature"; -import mapStore from "./mapStore"; -// --- API INTERFACES --- +// --- MAP STORE --- +import { languageInstance } from "@shared"; // Убедитесь, что этот импорт правильный +import { makeAutoObservable } from "mobx"; -// --- MOCK API --- +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 = { @@ -185,15 +350,15 @@ class MapService { 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 }), - image: new CircleStyle({ - radius: 6, - fill: new Fill({ color: "#dd6b20" }), - stroke: new Stroke({ color: "white", width: 1.5 }), - }), }); + this.drawStyle = new Style({ fill: new Fill({ color: "rgba(74, 222, 128, 0.3)" }), stroke: new Stroke({ @@ -314,9 +479,13 @@ class MapService { return selectedPointStyle; } if (isHovered) { - return featureType === "sight" - ? this.hoverSightIconStyle - : this.universalHoverStylePoint; + // Only apply hover styles if not in edit mode + if (this.mode !== "edit") { + return featureType === "sight" + ? this.hoverSightIconStyle + : this.universalHoverStylePoint; + } + return defaultPointStyle; } if (isLassoSelected) { @@ -418,28 +587,18 @@ class MapService { this.modifyInteraction = new Modify({ source: this.vectorSource, - // @ts-ignore - style: (feature: FeatureLike) => { - const originalFeature = feature.get("features")[0]; - if ( - originalFeature && - originalFeature.getGeometry()?.getType() === "Point" - ) { - return null; - } - return new Style({ - image: new CircleStyle({ - radius: 5, - fill: new Fill({ - color: "rgba(255, 255, 255, 0.7)", - }), - stroke: new Stroke({ - color: "#0099ff", - width: 2, - }), + 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), }); @@ -457,54 +616,16 @@ class MapService { } return this.selectedStyle; }, - condition: (e: MapBrowserEvent) => { - const isEdit = this.mode === "edit"; - const isSingle = singleClick(e); - if (!isEdit || !isSingle) return false; - - let clickModify = false; - if (this.modifyInteraction.getActive() && this.map) { - const px = e.pixel; - const internalModify = this.modifyInteraction as Modify; - const sketchFs: Feature[] | undefined = ( - internalModify as any - ).overlay_ - ?.getSource() - ?.getFeatures(); - - if (sketchFs) { - for (const sf of sketchFs) { - const g = sf.getGeometry(); - if (g) { - const coord = this.map.getCoordinateFromPixel(px); - if (!coord) continue; - const cp = g.getClosestPoint(coord); - const cppx = this.map.getPixelFromCoordinate(cp); - if (!cppx) continue; - const pixelTolerance = - (internalModify as any).pixelTolerance_ || 10; - if ( - Math.sqrt((px[0] - cppx[0]) ** 2 + (px[1] - cppx[1]) ** 2) < - pixelTolerance + 2 - ) { - clickModify = true; - break; - } - } - } - } - } - return !clickModify; - }, + condition: singleClick, filter: (_: FeatureLike, l: Layer | null) => l === this.vectorLayer, }); - this.modifyInteraction.on("modifystart", () => { + this.modifyInteraction.on("modifystart", (event) => { const geoJSONFormat = new GeoJSON(); if (!this.map) return; this.beforeModifyState = geoJSONFormat.writeFeatures( - this.vectorSource.getFeatures(), + this.vectorSource.getFeatures(), // Сохраняем все фичи для отката { dataProjection: "EPSG:4326", featureProjection: this.map.getView().getProjection().getCode(), @@ -512,12 +633,16 @@ class MapService { ); }); - this.modifyInteraction.on("modifyend", () => { + this.modifyInteraction.on("modifyend", (event) => { if (this.beforeModifyState) { - this.addStateToHistory("modify-before", 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({ @@ -541,7 +666,6 @@ class MapService { if (f.getId() !== undefined) selected.add(f.getId()!); } } else if (geom && geom.intersectsExtent(extent)) { - // For lines/polygons if (f.getId() !== undefined) selected.add(f.getId()!); } }); @@ -569,6 +693,7 @@ class MapService { } } }); + this.map.on("pointermove", this.boundHandlePointerMove as any); const targetEl = this.map.getTargetElement(); if (targetEl instanceof HTMLElement) { @@ -584,26 +709,11 @@ class MapService { } } - // --- ИЗМЕНЕНО --- Добавляем новый публичный метод для сброса выделения public unselect(): void { - // Сбрасываем основное (одиночное) выделение this.selectInteraction.getFeatures().clear(); - this.onFeatureSelect(null); // Оповещаем React - - // Сбрасываем множественное выделение - this.setSelectedIds(new Set()); // Это вызовет onSelectionChange и перерисовку - } - - public saveMapState(): void { - const geoJSON = this.getAllFeaturesAsGeoJSON(); - if (geoJSON) { - console.log("Сохранение состояния карты (GeoJSON):", geoJSON); - alert( - "Данные карты выведены в консоль разработчика и готовы к отправке!" - ); - } else { - alert("Нет объектов для сохранения."); - } + this.modifyInteraction.setActive(false); + this.onFeatureSelect(null); + this.setSelectedIds(new Set()); } public loadFeaturesFromApi( @@ -617,10 +727,7 @@ class MapService { const featuresToAdd: Feature[] = []; apiStations.forEach((station) => { - if (station.longitude == null || station.latitude == null) { - console.warn(`Station ${station.id} has null coordinates, skipping...`); - return; - } + if (station.longitude == null || station.latitude == null) return; const point = new Point( transform( [station.longitude, station.latitude], @@ -635,19 +742,11 @@ class MapService { }); apiRoutes.forEach((route) => { - if (!route.path || route.path.length === 0) { - console.warn(`Route ${route.id} has no path coordinates, skipping...`); - return; - } + if (!route.path || route.path.length === 0) return; const coordinates = route.path - .filter((coord) => coord[0] != null && coord[1] != null) - .map((coord) => transform(coord, "EPSG:4326", projection)); - if (coordinates.length === 0) { - console.warn( - `Route ${route.id} has no valid coordinates after filtering, skipping...` - ); - return; - } + .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}`); @@ -656,10 +755,7 @@ class MapService { }); apiSights.forEach((sight) => { - if (sight.longitude == null || sight.latitude == null) { - console.warn(`Sight ${sight.id} has null coordinates, skipping...`); - return; - } + if (sight.longitude == null || sight.latitude == null) return; const point = new Point( transform([sight.longitude, sight.latitude], "EPSG:4326", projection) ); @@ -701,9 +797,7 @@ class MapService { this.applyHistoryState(stateToRestore); this.historyIndex--; } else { - this.vectorSource.clear(); - this.updateFeaturesInReact(); - this.onFeatureSelect(null); + toast.info("Больше отменять нечего"); } } @@ -711,6 +805,8 @@ class MapService { if (this.historyIndex < this.history.length - 1) { this.historyIndex++; this.applyHistoryState(this.history[this.historyIndex].state); + } else { + toast.info("Больше повторять нечего"); } } @@ -721,12 +817,10 @@ class MapService { 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(); - this.onFeatureSelect(null); - this.selectInteraction.getFeatures().clear(); - this.vectorLayer.changed(); } private updateFeaturesInReact(): void { @@ -755,7 +849,7 @@ class MapService { return; } if (event.key === "Escape") { - this.unselect(); // --- ИЗМЕНЕНО --- Esc теперь тоже сбрасывает все выделения + this.unselect(); } } @@ -763,6 +857,7 @@ class MapService { 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; @@ -770,8 +865,6 @@ class MapService { if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); } - if (this.onModeChangeCallback) this.onModeChangeCallback(newMode); - if (this.currentInteraction instanceof Draw) { this.map.removeInteraction(this.currentInteraction); this.currentInteraction = null; @@ -780,23 +873,14 @@ class MapService { if (newMode === "edit") { this.selectInteraction.setActive(true); } else { - this.selectInteraction.getFeatures().clear(); + this.unselect(); this.selectInteraction.setActive(false); - this.modifyInteraction.setActive(false); } } public activateEditMode(): void { this.currentDrawingType = null; this.setMode("edit"); - if (this.selectInteraction.getFeatures().getLength() > 0) { - const firstSelectedFeature = this.selectInteraction - .getFeatures() - .item(0) as Feature; - this.onFeatureSelect(firstSelectedFeature); - } else { - this.onFeatureSelect(null); - } } public startDrawing( @@ -816,13 +900,9 @@ class MapService { } let styleForDrawing: Style; - if (featureType === "station") { - styleForDrawing = this.drawBusIconStyle; - } else if (featureType === "sight") { - styleForDrawing = this.drawSightIconStyle; - } else { - styleForDrawing = this.drawStyle; - } + if (featureType === "station") styleForDrawing = this.drawBusIconStyle; + else if (featureType === "sight") styleForDrawing = this.drawSightIconStyle; + else styleForDrawing = this.drawStyle; this.currentInteraction = new Draw({ source: this.vectorSource, @@ -835,7 +915,7 @@ class MapService { stateBeforeDraw = this.getCurrentStateAsGeoJSON(); }); - this.currentInteraction.on("drawend", (event: DrawEvent) => { + this.currentInteraction.on("drawend", async (event: DrawEvent) => { if (stateBeforeDraw) { this.addStateToHistory("draw-before", stateBeforeDraw); } @@ -845,9 +925,8 @@ class MapService { feature.set("featureType", fType); - let baseName = ""; - let namePrefix = ""; - + let baseName = "", + namePrefix = ""; if (fType === "station") { baseName = "Станция"; namePrefix = "Станция "; @@ -865,26 +944,22 @@ class MapService { (f) => f !== feature && f.get("featureType") === fType && - f.get("name") && - (f.get("name") as string).startsWith(namePrefix) + (f.get("name") as string)?.startsWith(namePrefix) ); let maxNumber = 0; existingNamedFeatures.forEach((f) => { const name = f.get("name") as string; if (name) { - const numStr = name.substring(namePrefix.length); - const num = parseInt(numStr, 10); + const num = parseInt(name.substring(namePrefix.length), 10); if (!isNaN(num) && num > maxNumber) maxNumber = num; } }); - const newNumber = maxNumber + 1; - feature.set("name", `${baseName} ${newNumber}`); + feature.set("name", `${baseName} ${maxNumber + 1}`); - if (this.currentDrawingType === "LineString") { - this.finishDrawing(); - } + await this.saveNewFeature(feature); + this.stopDrawing(); }); this.map.addInteraction(this.currentInteraction); @@ -900,46 +975,37 @@ class MapService { this.startDrawing("Point", "sight"); } - public finishDrawing(): void { - if (!this.map || !this.currentInteraction) return; - const drawInteraction = this.currentInteraction as Draw; - if ((drawInteraction as any).sketchFeature_) { + private stopDrawing() { + if (this.map && this.currentInteraction) { try { - this.currentInteraction.finishDrawing(); + // @ts-ignore + this.currentInteraction.abortDrawing(); } catch (e) { - // Drawing could not be finished + /* ignore */ } + this.map.removeInteraction(this.currentInteraction); } - 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.map || - !( - this.mode && - this.mode.startsWith("drawing-") && - this.currentInteraction instanceof Draw - ) + this.mode?.startsWith("drawing-") && + this.currentInteraction instanceof Draw ) { - return; - } - const drawInteraction = this.currentInteraction as Draw; - if ( - this.currentDrawingType === "LineString" && - (drawInteraction as any).sketchFeature_ - ) { - try { - this.currentInteraction.finishDrawing(); - } catch (e) { - this.finishDrawing(); - } - } else { this.finishDrawing(); } } @@ -954,14 +1020,18 @@ class MapService { return; } - const pixel = this.map.getEventPixel(event.originalEvent as PointerEvent); + 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( - pixel, + 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) { @@ -978,66 +1048,61 @@ class MapService { } } - if (this.hoveredFeatureId !== newHoveredFeatureId) { + // 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) return; + if (!this.map || this.mode !== "edit") return; - const pixel = this.map.getEventPixel(event.originalEvent); const featureAtPixel: Feature | undefined = this.map.forEachFeatureAtPixel( - pixel, + event.pixel, (f: FeatureLike) => f as Feature, { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 } ); - if (!featureAtPixel) return; + 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); - } + if (newSet.has(featureId)) newSet.delete(featureId); + else newSet.add(featureId); this.setSelectedIds(newSet); - this.vectorLayer.changed(); } else { - this.selectFeature(featureId); - const newSet = new Set([featureId]); - this.setSelectedIds(newSet); + this.setSelectedIds(new Set([featureId])); } } public selectFeature(featureId: string | number | undefined): void { if (!this.map || featureId === undefined) { - this.onFeatureSelect(null); - if (this.mode === "edit") this.selectInteraction.getFeatures().clear(); - this.vectorLayer.changed(); + this.unselect(); return; } const feature = this.vectorSource.getFeatureById(featureId); if (!feature) { - this.onFeatureSelect(null); - if (this.mode === "edit") this.selectInteraction.getFeatures().clear(); - this.vectorLayer.changed(); + this.unselect(); return; } if (this.mode === "edit") { this.selectInteraction.getFeatures().clear(); this.selectInteraction.getFeatures().push(feature); - this.onFeatureSelect(feature); + const selectEvent = new SelectEvent("select", [feature], []); + this.selectInteraction.dispatchEvent(selectEvent); } - this.vectorLayer.changed(); + + this.setSelectedIds(new Set([featureId])); const view = this.map.getView(); const geometry = feature.getGeometry(); @@ -1046,7 +1111,7 @@ class MapService { view.animate({ center: geometry.getCoordinates(), duration: 500, - zoom: Math.max(view.getZoom() || mapConfig.zoom, 14), + zoom: Math.max(view.getZoom() || 14, 14), }); } else { view.fit(geometry.getExtent(), { @@ -1063,75 +1128,71 @@ class MapService { recourse: string ): void { if (featureId === undefined) return; - const id = featureId.toString().split("-")[1]; - if (recourse) { - mapStore.deleteRecourse(recourse, Number(id)); - toast.success("Объект успешно удален"); - } + + 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) { - const currentState = this.getCurrentStateAsGeoJSON(); - if (currentState) this.addStateToHistory("delete", currentState); + if (!feature) return; - const selectedFeaturesCollection = this.selectInteraction?.getFeatures(); - if (selectedFeaturesCollection?.getArray().includes(feature)) { - selectedFeaturesCollection.clear(); - this.onFeatureSelect(null); - } - - this.vectorSource.removeFeature(feature); - this.vectorLayer.changed(); - } + 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; - console.log("Запрос на множественное удаление. ID объектов:", featureIds); + const stateBeforeDelete = this.getCurrentStateAsGeoJSON(); - const currentState = this.getCurrentStateAsGeoJSON(); - if (currentState) { - this.addStateToHistory("multiple delete", currentState); - } - - const selectedFeaturesCollection = this.selectInteraction?.getFeatures(); - let deletedCount = 0; - - featureIds.forEach((id) => { + const deletePromises = Array.from(featureIds).map((id) => { const feature = this.vectorSource.getFeatureById(id); - if (feature) { - const recourse = String(id).split("-")[0]; - const numericId = String(id).split("-")[1]; - if (recourse && numericId) { - mapStore.deleteRecourse(recourse, Number(numericId)); - } + if (!feature) return Promise.resolve(); - if (selectedFeaturesCollection?.getArray().includes(feature)) { - selectedFeaturesCollection.remove(feature); - } - - this.vectorSource.removeFeature(feature); - deletedCount++; + 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(); }); - if (deletedCount > 0) { - if (selectedFeaturesCollection?.getLength() === 0) { - this.onFeatureSelect(null); - } - toast.success(`Удалено ${deletedCount} объект(ов).`); - } else { - toast.warn("Не найдено объектов для удаления."); - } + 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(), @@ -1201,26 +1262,90 @@ class MapService { 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(); + 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(); } } - public getMap(): Map | null { - return this.map; + 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 --- -// --- ИЗМЕНЕНО --- Добавляем проп isUnselectDisabled interface MapControlsProps { mapService: MapService | null; activeMode: string; @@ -1228,15 +1353,25 @@ interface MapControlsProps { 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, // --- ИЗМЕНЕНО --- + isUnselectDisabled, }) => { if (!mapService) return null; - const controls = [ + const controls: ControlItem[] = [ { mode: "edit", title: "Редактировать", @@ -1261,19 +1396,11 @@ const MapControls: React.FC = ({ { mode: "drawing-route", title: "Маршрут", - longTitle: "Добавить маршрут", + longTitle: "Добавить маршрут (Правый клик для завершения)", icon: , action: () => mapService.startDrawingLine(), }, - // { - // mode: "lasso", - // title: "Выделение", - // longTitle: "Выделение области (или зажмите Shift)", - // icon: , - // action: () => mapService.toggleLasso(), - // isActive: isLassoActive, - // }, - // --- ИЗМЕНЕНО --- Добавляем кнопку сброса + { mode: "unselect", title: "Сбросить", @@ -1286,11 +1413,9 @@ const MapControls: React.FC = ({ 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" @@ -1298,7 +1423,6 @@ const MapControls: React.FC = ({ ? "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 ( @@ -1562,7 +1645,7 @@ const MapSightbar: React.FC = ({ ); }) ) : ( -

Нет добавленных остановок.

+

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

)}
), @@ -1576,23 +1659,14 @@ const MapSightbar: React.FC = ({
{sortedLines.length > 0 ? ( sortedLines.map((l) => { - const lId = l.getId(); - const lName = (l.get("name") as string) || "Без названия"; - const isSelected = selectedFeature?.getId() === lId; - const isCheckedForDeletion = - lId !== undefined && selectedIds.has(lId); - const lGeom = l.getGeometry(); - let lineLengthText: string | null = null; - if (lGeom instanceof LineString) { - const length = lGeom.getLength(); - lineLengthText = `Длина: ${length.toFixed(1)} м`; - } - + 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} для удаления`} + aria-label={`Выбрать ${lName}`} />
handleFeatureClick(lId)} > -
+
= ({ {lName}
- {lineLengthText && ( -

- {lineLengthText} -

- )}
@@ -1665,7 +1734,7 @@ const MapSightbar: React.FC = ({ ); }) ) : ( -

Нет добавленных маршрутов.

+

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

)}
), @@ -1679,16 +1748,14 @@ const MapSightbar: React.FC = ({
{sortedSights.length > 0 ? ( sortedSights.map((s) => { - const sId = s.getId(); - const sName = (s.get("name") as string) || "Без названия"; - const isSelected = selectedFeature?.getId() === sId; - const isCheckedForDeletion = - sId !== undefined && selectedIds.has(sId); + 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} для удаления`} + aria-label={`Выбрать ${sName}`} />
= ({ handleDeleteFeature(sId, "sight"); }} className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors" - title="Удалить достопримечательность" + title="Удалить" > @@ -1754,30 +1821,18 @@ const MapSightbar: React.FC = ({ ); }) ) : ( -

- Нет данных о достопримечательностях. -

+

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

)}
), }, ]; - if (!sections.length && activeSection) { - setActiveSection(null); - } else if ( - sections.length > 0 && - !sections.find((s) => s.id === activeSection) - ) { - setActiveSection(sections[0]?.id || null); - } - return (

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

-
= ({ 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 ? ( @@ -1847,7 +1901,6 @@ const MapSightbar: React.FC = ({ )}
-
{selectedIds.size > 0 && ( )} -
); @@ -1901,10 +1942,10 @@ export const MapPage: React.FC = () => { (feats: Feature[]) => setMapFeatures([...feats]), [] ); + const handleFeatureSelectForSidebar = useCallback( (feat: Feature | null) => { setSelectedFeatureForSidebar(feat); - if (feat) { const featureType = feat.get("featureType"); const sectionId = @@ -1913,16 +1954,11 @@ export const MapPage: React.FC = () => { : featureType === "route" ? "lines" : "layers"; - setActiveSectionFromParent(sectionId); - setTimeout(() => { - const element = document.querySelector( - `[data-feature-id="${feat.getId()}"]` - ); - if (element) { - element.scrollIntoView({ behavior: "smooth", block: "center" }); - } + document + .querySelector(`[data-feature-id="${feat.getId()}"]`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); }, 100); } }, @@ -1932,7 +1968,6 @@ export const MapPage: React.FC = () => { const handleMapClick = useCallback( (event: any) => { if (!mapServiceInstance || isLassoActive) return; - const ctrlKey = event.originalEvent.ctrlKey || event.originalEvent.metaKey; mapServiceInstance.handleMapClick(event, ctrlKey); @@ -1948,7 +1983,6 @@ export const MapPage: React.FC = () => { setError(null); const loadInitialData = async (mapService: MapService) => { - console.log("Starting data load..."); try { await Promise.all([ mapStore.getRoutes(), @@ -1980,46 +2014,33 @@ export const MapPage: React.FC = () => { setSelectedIds ); setMapServiceInstance(service); - loadInitialData(service); } catch (e: any) { - console.error("MapPage useEffect error:", e); setError( - `Ошибка инициализации карты: ${ - e.message || "Неизвестная ошибка" - }. Пожалуйста, проверьте консоль.` + `Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.` ); setIsMapLoading(false); setIsDataLoading(false); } } return () => { - if (service) { - service.destroy(); - setMapServiceInstance(null); - } + service?.destroy(); + setMapServiceInstance(null); }; }, []); useEffect(() => { - if (mapServiceInstance) { - const olMap = mapServiceInstance.getMap(); - if (olMap) { - olMap.on("click", handleMapClick); - - return () => { - if (olMap) { - olMap.un("click", handleMapClick); - } - }; - } + const olMap = mapServiceInstance?.getMap(); + if (olMap) { + olMap.on("click", handleMapClick); + return () => { + olMap.un("click", handleMapClick); + }; } }, [mapServiceInstance, handleMapClick]); useEffect(() => { - if (mapServiceInstance) { - mapServiceInstance.setOnSelectionChange(setSelectedIds); - } + mapServiceInstance?.setOnSelectionChange(setSelectedIds); }, [mapServiceInstance]); useEffect(() => { @@ -2055,12 +2076,10 @@ export const MapPage: React.FC = () => { } }; } - }, [mapServiceInstance, currentMapMode, setIsLassoActive]); + }, [mapServiceInstance, currentMapMode]); const showLoader = isMapLoading || isDataLoading; const showContent = mapServiceInstance && !showLoader && !error; - - // --- ИЗМЕНЕНО --- Логика для определения, активна ли кнопка сброса const isAnythingSelected = selectedFeatureForSidebar !== null || selectedIds.size > 0; @@ -2075,14 +2094,11 @@ export const MapPage: React.FC = () => {
{showLoader && (
-
+
{isMapLoading ? "Загрузка карты..." : "Загрузка данных..."}
@@ -2096,14 +2112,13 @@ export const MapPage: React.FC = () => { onClick={() => window.location.reload()} className="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm" > - Перезагрузить страницу + Перезагрузить
)} {isLassoActive && (
- Режим выделения области. Нарисуйте многоугольник для выбора - объектов. + Режим выделения области.
)}
@@ -2112,11 +2127,10 @@ export const MapPage: React.FC = () => { mapService={mapServiceInstance} activeMode={currentMapMode} isLassoActive={isLassoActive} - isUnselectDisabled={!isAnythingSelected} // --- ИЗМЕНЕНО --- Передаем состояние disabled + isUnselectDisabled={!isAnythingSelected} /> )} - {/* Help button */} - {/* Help popup */} {showHelp && (

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

@@ -2134,13 +2147,13 @@ export const MapPage: React.FC = () => { Shift {" "} - - Режим выделения области (лассо) + - Режим выделения (лассо)
  • Ctrl + клик {" "} - - Добавить объект к выбранным + - Добавить/убрать из выделения
  • Esc{" "} @@ -2150,13 +2163,13 @@ export const MapPage: React.FC = () => { Ctrl+Z {" "} - - Отменить последнее действие + - Отменить действие
  • Ctrl+Y {" "} - - Повторить отменённое действие + - Повторить действие