diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx index 9333ad4..0c877c0 100644 --- a/src/pages/MapPage/index.tsx +++ b/src/pages/MapPage/index.tsx @@ -11,6 +11,8 @@ 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"; +// --- ИЗМЕНЕНИЕ: Добавляем импорт Cluster --- +import Cluster from "ol/source/Cluster"; import { Draw, Modify, @@ -25,6 +27,7 @@ import { Stroke, Circle as CircleStyle, RegularShape, + Text, // --- ИЗМЕНЕНИЕ: Добавляем импорт Text для отображения числа в кластере } from "ol/style"; import { Point, LineString, Geometry, Polygon } from "ol/geom"; import { transform, toLonLat } from "ol/proj"; @@ -378,8 +381,14 @@ type FeatureType = "station" | "route" | "sight"; class MapService { private map: Map | null; - private vectorSource: VectorSource>; - private vectorLayer: VectorLayer>>; + // --- ИЗМЕНЕНИЕ: Разделяем источники и слои --- + private pointSource: VectorSource>; // Для станций и достопримечательностей + private lineSource: VectorSource>; // Для маршрутов + private clusterSource: Cluster; // Источник для кластеризации + private clusterLayer: VectorLayer; // Слой для кластеров и отдельных точек + private routeLayer: VectorLayer>>; // Слой для маршрутов + private clusterStyleCache: { [key: number]: Style }; // Кэш для стилей кластеров + private tooltipElement: HTMLElement; private tooltipOverlay: Overlay | null; private mode: string | null; @@ -445,6 +454,7 @@ class MapService { this.hoveredFeatureId = null; this.history = []; this.historyIndex = -1; + this.clusterStyleCache = {}; this.setLoading = setLoading; this.setError = setError; @@ -552,48 +562,91 @@ class MapService { zIndex: Infinity, }); - this.vectorSource = new VectorSource>(); - this.vectorLayer = new VectorLayer({ - source: this.vectorSource, + // --- ИЗМЕНЕНИЕ: Инициализация раздельных источников --- + this.pointSource = new VectorSource(); + this.lineSource = new VectorSource(); + this.clusterSource = new Cluster({ + distance: 45, // Расстояние в пикселях. Можно настроить. + source: this.pointSource, + }); + + // --- ИЗМЕНЕНИЕ: Слой для маршрутов --- + this.routeLayer = new VectorLayer({ + source: this.lineSource, 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 isSelected = + this.selectInteraction?.getFeatures().getArray().includes(feature) || + (fId !== undefined && this.selectedIds.has(fId)); const isHovered = this.hoveredFeatureId === fId; - const isLassoSelected = fId !== undefined && this.selectedIds.has(fId); - if (isHovered) { - if (geometryType === "Point") { + 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 isEditSelected = this.selectInteraction + ?.getFeatures() + .getArray() + .includes(originalFeature); + const isHovered = this.hoveredFeatureId === fId; + const isLassoSelected = + fId !== undefined && this.selectedIds.has(fId); + + if (isHovered) { return featureType === "sight" ? this.hoverSightIconStyle : this.universalHoverStylePoint; } - return this.universalHoverStyleLine; - } - if (isLassoSelected || isEditSelected) { - if (geometryType === "Point") { + if (isLassoSelected || isEditSelected) { return featureType === "sight" ? this.selectedSightIconStyle : this.selectedBusIconStyle; } - return this.selectedStyle; - } - if (geometryType === "Point") { return featureType === "sight" ? this.sightIconStyle : this.busIconStyle; } - return this.defaultStyle; }, }); @@ -602,12 +655,19 @@ class MapService { this.boundHandleContextMenu = this.handleContextMenu.bind(this); this.boundHandleKeyDown = this.handleKeyDown.bind(this); - this.vectorSource.on( + // --- ИЗМЕНЕНИЕ: Слушатели событий для обоих источников --- + this.pointSource.on( "addfeature", this.handleFeatureEvent.bind(this) as any ); - this.vectorSource.on("removefeature", () => this.updateFeaturesInReact()); - this.vectorSource.on( + 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 ); @@ -615,14 +675,18 @@ class MapService { let renderCompleteHandled = false; const MAP_LOAD_TIMEOUT = 15000; try { - // Get stored position or use default 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.vectorLayer], + // --- ИЗМЕНЕНИЕ: Добавляем оба новых слоя на карту --- + layers: [ + new TileLayer({ source: new OSM() }), + this.routeLayer, + this.clusterLayer, + ], view: new View({ center: transform(initialCenter, "EPSG:4326", "EPSG:3857"), zoom: initialZoom, @@ -631,7 +695,6 @@ class MapService { controls: [], }); - // Add view change listener to save position this.map.getView().on("change:center", () => { const center = this.map?.getView().getCenter(); const zoom = this.map?.getView().getZoom(); @@ -685,34 +748,23 @@ class MapService { } 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; - }, + style: null, // Стиль будет применен основным style-функциями слоев condition: (event: MapBrowserEvent) => { - // Only allow single click selection when Ctrl is not pressed return ( singleClick(event) && !event.originalEvent.ctrlKey && !event.originalEvent.metaKey ); }, + // --- ИЗМЕНЕНИЕ: Фильтруем по обоим векторным слоям --- filter: (_: FeatureLike, l: Layer | null) => - l === this.vectorLayer, + l === this.clusterLayer || l === this.routeLayer, multi: false, }); this.modifyInteraction = new Modify({ - source: this.vectorSource, + // --- ИЗМЕНЕНИЕ: Источник теперь не один, поэтому используем `features` --- + features: this.selectInteraction.getFeatures(), style: new Style({ image: new CircleStyle({ radius: 6, @@ -725,43 +777,28 @@ class MapService { }), }), }), - // --- НАЧАЛО ИЗМЕНЕНИЯ --- - // Кастомная логика для удаления вершин deleteCondition: (e: MapBrowserEvent) => { - // Удаление по-прежнему происходит по двойному клику if (!doubleClick(e)) { return false; } - const selectedFeatures = this.selectInteraction.getFeatures(); - // Эта логика применима только когда редактируется один объект if (selectedFeatures.getLength() !== 1) { - return true; // Разрешаем удаление по умолчанию для других случаев + return true; } - const feature = selectedFeatures.item(0) as Feature; const geometry = feature.getGeometry(); - - // Проверяем, что это линия (маршрут) if (!geometry || geometry.getType() !== "LineString") { - return true; // Если это не линия, разрешаем удаление (например, всего полигона) + return true; } - const lineString = geometry as LineString; const coordinates = lineString.getCoordinates(); - - // Если в линии всего 2 точки, не даем удалить ни одну из них, - // так как линия перестанет быть линией. 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]; @@ -771,27 +808,18 @@ class MapService { closestVertexIndex = index; } }); - - // Проверяем, является ли ближайшая вершина начальной или конечной if ( closestVertexIndex === 0 || closestVertexIndex === coordinates.length - 1 ) { - // Это конечная точка, запрещаем удаление - return false; } - - // Если это не начальная и не конечная точка, разрешаем удаление return true; }, - // --- КОНЕЦ ИЗМЕНЕНИЯ --- - features: this.selectInteraction.getFeatures(), }); // @ts-ignore this.modifyInteraction.on("modifystart", (event) => { - // Only save state if we don't already have a beforeActionState if (!this.beforeActionState) { this.beforeActionState = this.getCurrentStateAsGeoJSON(); } @@ -801,22 +829,23 @@ class MapService { if (this.beforeActionState) { this.addStateToHistory(this.beforeActionState); } - event.features.getArray().forEach((feature) => { this.saveModifiedFeature(feature as Feature); }); this.beforeActionState = null; }); - // Add double-click handler for route point deletion if (this.map) { this.map.on("dblclick", (event: MapBrowserEvent) => { if (this.mode !== "edit") return; + // --- ИЗМЕНЕНИЕ: Фильтр слоя для forEachFeatureAtPixel --- + const layerFilter = (l: Layer) => l === this.routeLayer; + const feature = this.map?.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, - { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 } + { layerFilter, hitTolerance: 5 } ); if (!feature) return; @@ -830,13 +859,11 @@ class MapService { const lineString = geometry as LineString; const coordinates = lineString.getCoordinates(); - // If the line has only 2 points, don't allow deletion if (coordinates.length <= 2) { toast.info("В маршруте должно быть не менее 2 точек."); return; } - // Find the closest coordinate to the click point const clickCoordinate = event.coordinate; let closestIndex = -1; let minDistanceSq = Infinity; @@ -851,24 +878,19 @@ class MapService { } }); - // Check if the closest vertex is an endpoint if (closestIndex === 0 || closestIndex === coordinates.length - 1) { return; } - // Save state before modification const beforeState = this.getCurrentStateAsGeoJSON(); if (beforeState) { this.addStateToHistory(beforeState); } - // Remove the point and update the route const newCoordinates = coordinates.filter( (_, index) => index !== closestIndex ); lineString.setCoordinates(newCoordinates); - - // Save the modified feature this.saveModifiedFeature(feature); }); } @@ -886,15 +908,14 @@ class MapService { const extent = geometry.getExtent(); const selected = new Set(); - this.vectorSource.forEachFeatureInExtent(extent, (f) => { + // --- ИЗМЕНЕНИЕ: Искать объекты только в источнике точек --- + this.pointSource.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()!); } }); @@ -912,13 +933,43 @@ class MapService { 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]); + // --- ИЗМЕНЕНИЕ: Обработка выбора кластера --- + const selectedClusterFeatures = e.selected; + this.selectInteraction.getFeatures().clear(); // Очищаем перед добавлением + + if (selectedClusterFeatures.length > 0) { + const clusterFeature = selectedClusterFeatures[0]; + const originalFeatures = clusterFeature.get("features"); + if (originalFeatures && originalFeatures.length === 1) { + // Если это одиночный объект (не кластер) + const originalFeature = originalFeatures[0] as Feature; + this.selectInteraction.getFeatures().push(originalFeature); + this.onFeatureSelect(originalFeature); + } else if (e.mapBrowserEvent.type === "click" && this.map) { + // Если это кластер - приближаемся к нему + const view = this.map.getView(); + view.animate({ + center: ( + clusterFeature.getGeometry() as Point + ).getCoordinates(), + zoom: view.getZoom()! + 2, + duration: 500, + }); + this.onFeatureSelect(null); + } } else { - this.onFeatureSelect(null); + // Если выбран маршрут (не из кластера) + const selectedRouteFeatures = e.target.getFeatures().getArray(); + if (selectedRouteFeatures.length > 0) { + this.onFeatureSelect(selectedRouteFeatures[0]); + } else { + this.onFeatureSelect(null); + } } + + this.modifyInteraction.setActive( + this.selectInteraction.getFeatures().getLength() > 0 + ); } }); @@ -942,6 +993,9 @@ class MapService { this.modifyInteraction.setActive(false); this.onFeatureSelect(null); this.setSelectedIds(new Set()); + // --- ИЗМЕНЕНИЕ: Обновляем оба слоя для сброса стилей --- + this.clusterLayer.changed(); + this.routeLayer.changed(); } public loadFeaturesFromApi( @@ -952,7 +1006,8 @@ class MapService { if (!this.map) return; const projection = this.map.getView().getProjection(); - const featuresToAdd: Feature[] = []; + const pointFeatures: Feature[] = []; + const lineFeatures: Feature[] = []; apiStations.forEach((station) => { if (station.longitude == null || station.latitude == null) return; @@ -966,22 +1021,7 @@ class MapService { 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 && c[0] != null && c[1] != null) - .map((c: [number, number]) => - transform([c[1], c[0]], "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); + pointFeatures.push(feature); }); apiSights.forEach((sight) => { @@ -996,10 +1036,28 @@ class MapService { }); feature.setId(`sight-${sight.id}`); feature.set("featureType", "sight"); - featuresToAdd.push(feature); + pointFeatures.push(feature); }); - this.vectorSource.addFeatures(featuresToAdd); + 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 line = new LineString(coordinates); + const feature = new Feature({ geometry: line, name: route.route_number }); + feature.setId(`route-${route.id}`); + feature.set("featureType", "route"); + lineFeatures.push(feature); + }); + + // --- ИЗМЕНЕНИЕ: Добавляем объекты в соответствующие источники --- + this.pointSource.addFeatures(pointFeatures); + this.lineSource.addFeatures(lineFeatures); + this.updateFeaturesInReact(); const initialState = this.getCurrentStateAsGeoJSON(); if (initialState) { @@ -1016,7 +1074,12 @@ class MapService { private getCurrentStateAsGeoJSON(): string | null { if (!this.map) return null; const geoJSONFormat = new GeoJSON(); - return geoJSONFormat.writeFeatures(this.vectorSource.getFeatures(), { + // --- ИЗМЕНЕНИЕ: Собираем объекты из обоих источников --- + const allFeatures = [ + ...this.pointSource.getFeatures(), + ...this.lineSource.getFeatures(), + ]; + return geoJSONFormat.writeFeatures(allFeatures, { dataProjection: "EPSG:4326", featureProjection: this.map.getView().getProjection().getCode(), }); @@ -1034,8 +1097,24 @@ class MapService { ) as Feature[]; this.unselect(); - this.vectorSource.clear(); - this.vectorSource.addFeatures(features); + // --- ИЗМЕНЕНИЕ: Очищаем и заполняем оба источника --- + this.pointSource.clear(); + this.lineSource.clear(); + + const pointFeatures: Feature[] = []; + const lineFeatures: Feature[] = []; + + features.forEach((feature) => { + if (feature.getGeometry()?.getType() === "LineString") { + lineFeatures.push(feature as Feature); + } else { + pointFeatures.push(feature as Feature); + } + }); + + this.pointSource.addFeatures(pointFeatures); + this.lineSource.addFeatures(lineFeatures); + this.updateFeaturesInReact(); const newStations: ApiStation[] = []; @@ -1098,8 +1177,11 @@ class MapService { const stateToRestore = this.history[this.historyIndex].state; this.applyHistoryState(stateToRestore); - // Update each feature in the backend - const features = this.vectorSource.getFeatures(); + // --- ИЗМЕНЕНИЕ: Собираем объекты из обоих источников для обновления --- + const features = [ + ...this.pointSource.getFeatures(), + ...this.lineSource.getFeatures(), + ]; const updatePromises = features.map((feature) => { const featureType = feature.get("featureType"); const geoJSONFormat = new GeoJSON({ @@ -1114,8 +1196,6 @@ class MapService { .then(() => {}) .catch((error) => { console.error("Failed to update backend after undo:", error); - - // Revert to the previous state if backend update fails this.historyIndex++; const previousState = this.history[this.historyIndex].state; this.applyHistoryState(previousState); @@ -1131,8 +1211,11 @@ class MapService { const stateToRestore = this.history[this.historyIndex].state; this.applyHistoryState(stateToRestore); - // Update each feature in the backend - const features = this.vectorSource.getFeatures(); + // --- ИЗМЕНЕНИЕ: Собираем объекты из обоих источников для обновления --- + const features = [ + ...this.pointSource.getFeatures(), + ...this.lineSource.getFeatures(), + ]; const updatePromises = features.map((feature) => { const featureType = feature.get("featureType"); const geoJSONFormat = new GeoJSON({ @@ -1150,7 +1233,6 @@ class MapService { .catch((error) => { console.error("Failed to update backend after redo:", error); toast.error("Не удалось обновить данные на сервере"); - // Revert to the previous state if backend update fails this.historyIndex--; const previousState = this.history[this.historyIndex].state; this.applyHistoryState(previousState); @@ -1162,14 +1244,21 @@ class MapService { private updateFeaturesInReact(): void { if (this.onFeaturesChange) { - this.onFeaturesChange(this.vectorSource.getFeatures()); + // --- ИЗМЕНЕНИЕ: Передаем объекты из обоих источников --- + const allFeatures = [ + ...this.pointSource.getFeatures(), + ...this.lineSource.getFeatures(), + ]; + this.onFeaturesChange(allFeatures); } } private handlePointerLeave(): void { if (this.hoveredFeatureId) { this.hoveredFeatureId = null; - this.vectorLayer.changed(); + // --- ИЗМЕНЕНИЕ: Обновляем оба слоя --- + this.clusterLayer.changed(); + this.routeLayer.changed(); } if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); } @@ -1198,7 +1287,9 @@ class MapService { if (this.hoveredFeatureId && oldMode !== newMode) { this.hoveredFeatureId = null; - this.vectorLayer.changed(); + // --- ИЗМЕНЕНИЕ: Обновляем оба слоя --- + this.clusterLayer.changed(); + this.routeLayer.changed(); if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); } @@ -1241,8 +1332,12 @@ class MapService { else if (featureType === "sight") styleForDrawing = this.drawSightIconStyle; else styleForDrawing = this.drawStyle; + // --- ИЗМЕНЕНИЕ: Выбираем правильный источник для рисования --- + const sourceForDrawing = + type === "Point" ? this.pointSource : this.lineSource; + this.currentInteraction = new Draw({ - source: this.vectorSource, + source: sourceForDrawing, type, style: styleForDrawing, }); @@ -1260,59 +1355,57 @@ class MapService { 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 = this.vectorSource - .getFeatures() - .filter((f) => f.get("featureType") === "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+)$/); + 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 = this.vectorSource - .getFeatures() - .filter((f) => f.get("featureType") === "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+)$/); + 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 = this.vectorSource - .getFeatures() - .filter((f) => f.get("featureType") === "route"); + const existingRoutes = allFeatures.filter( + (f) => f.get("featureType") === "route" + ); const routeNumbers = existingRoutes .map((f) => { const name = f.get("name") as string; - const match = name.match(/^Маршрут (\d+)$/); + 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}`; @@ -1321,28 +1414,28 @@ class MapService { resourceName = "Объект"; } feature.set("name", resourceName); - // --- КОНЕЦ ИЗМЕНЕНИЯ --- if (fType === "route") { this.activateEditMode(); } await this.saveNewFeature(feature); - - // --- ИЗМЕНЕНИЕ: Автоматический переход в режим редактирования для маршрутов --- }); 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 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() { @@ -1369,31 +1462,24 @@ class MapService { } } - 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 handlePointerMove(event: MapBrowserEvent): void { if (!this.map || event.dragging) { if (this.hoveredFeatureId) { this.hoveredFeatureId = null; - this.vectorLayer.changed(); + // --- ИЗМЕНЕНИЕ: Обновляем оба слоя --- + 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: (l) => l === this.vectorLayer, + layerFilter, hitTolerance: 5, }); this.map.getTargetElement().style.cursor = hit ? "pointer" : ""; @@ -1402,13 +1488,26 @@ class MapService { this.map.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, - { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 } + { layerFilter, hitTolerance: 5 } ); - const newHoveredFeatureId = featureAtPixel ? featureAtPixel.getId() : null; + + let finalFeature: Feature | null = null; + if (featureAtPixel) { + const originalFeatures = featureAtPixel.get("features"); + if (originalFeatures && originalFeatures.length > 0) { + // Это объект из кластера + finalFeature = originalFeatures[0]; + } else { + // Это маршрут + finalFeature = featureAtPixel; + } + } + + const newHoveredFeatureId = finalFeature ? finalFeature.getId() : null; if (this.tooltipOverlay && this.tooltipElement) { - if (this.mode === "edit" && featureAtPixel) { - const name = featureAtPixel.get("name"); + if (this.mode === "edit" && finalFeature) { + const name = finalFeature.get("name"); if (name) { this.tooltipElement.innerHTML = name as string; this.tooltipOverlay.setPosition(event.coordinate); @@ -1422,45 +1521,57 @@ class MapService { if (this.hoveredFeatureId !== newHoveredFeatureId) { this.hoveredFeatureId = newHoveredFeatureId as string | number | null; - this.vectorLayer.changed(); + // --- ИЗМЕНЕНИЕ: Обновляем оба слоя --- + this.clusterLayer.changed(); + this.routeLayer.changed(); } } public handleMapClick(event: MapBrowserEvent, ctrlKey: boolean): void { if (!this.map || this.mode !== "edit") return; + // --- ИЗМЕНЕНИЕ: Фильтр для обоих слоев --- + const layerFilter = (l: Layer) => + l === this.clusterLayer || l === this.routeLayer; + const featureAtPixel: Feature | undefined = this.map.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, - { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 } + { layerFilter, hitTolerance: 5 } ); - if (!featureAtPixel) { + let finalFeature: Feature | null = null; + if (featureAtPixel) { + const originalFeatures = featureAtPixel.get("features"); + if (originalFeatures && originalFeatures.length === 1) { + finalFeature = originalFeatures[0]; // Одиночная точка + } else if (!originalFeatures) { + finalFeature = featureAtPixel; // Маршрут + } + // Если originalFeatures.length > 1, это кластер, и мы его не выделяем кликом (он приближается) + } + + if (!finalFeature) { if (!ctrlKey) this.unselect(); return; } - const featureId = featureAtPixel.getId(); + const featureId = finalFeature.getId(); if (featureId === undefined) return; const newSet = new Set(this.selectedIds); - if (ctrlKey) { - if (newSet.has(featureId)) { - newSet.delete(featureId); - } else { - newSet.add(featureId); - } + if (newSet.has(featureId)) newSet.delete(featureId); + else newSet.add(featureId); } else { if (newSet.size === 1 && newSet.has(featureId)) { - // Already selected, do nothing to allow dragging + // Уже выделено, ничего не делаем } else { newSet.clear(); newSet.add(featureId); } } - this.setSelectedIds(newSet); } @@ -1470,7 +1581,10 @@ class MapService { return; } - const feature = this.vectorSource.getFeatureById(featureId); + // --- ИЗМЕНЕНИЕ: Ищем объект в обоих источниках --- + const feature = + this.pointSource.getFeatureById(featureId) || + this.lineSource.getFeatureById(featureId); if (!feature) { this.unselect(); return; @@ -1485,7 +1599,7 @@ class MapService { view.animate({ center: geometry.getCoordinates(), duration: 500, - zoom: Math.max(view.getZoom() || 14, 15), + zoom: Math.max(view.getZoom() || 14, 16), // Увеличиваем сильнее, чтобы "раскрыть" кластер }); } else { view.fit(geometry.getExtent(), { @@ -1508,7 +1622,10 @@ class MapService { const numericId = parseInt(String(featureId).split("-")[1], 10); if (!recourse || isNaN(numericId)) return; - const feature = this.vectorSource.getFeatureById(featureId); + // --- ИЗМЕНЕНИЕ: Ищем объект в обоих источниках --- + const feature = + this.pointSource.getFeatureById(featureId) || + this.lineSource.getFeatureById(featureId); if (!feature) return; mapStore @@ -1517,7 +1634,12 @@ class MapService { if (this.beforeActionState) this.addStateToHistory(this.beforeActionState); this.beforeActionState = null; - this.vectorSource.removeFeature(feature); + // --- ИЗМЕНЕНИЕ: Удаляем из правильного источника --- + if (recourse === "route") { + this.lineSource.removeFeature(feature as Feature); + } else { + this.pointSource.removeFeature(feature as Feature); + } this.unselect(); }) .catch((err) => { @@ -1531,7 +1653,10 @@ class MapService { this.beforeActionState = this.getCurrentStateAsGeoJSON(); const deletePromises = Array.from(featureIds).map((id) => { - const feature = this.vectorSource.getFeatureById(id); + // --- ИЗМЕНЕНИЕ: Ищем в обоих источниках --- + const feature = + this.pointSource.getFeatureById(id) || + this.lineSource.getFeatureById(id); if (!feature) return Promise.resolve(); const recourse = String(id).split("-")[0]; @@ -1544,14 +1669,21 @@ class MapService { Promise.all(deletePromises) .then((deletedFeatures) => { - const successfulDeletes = deletedFeatures.filter((f) => f); + const successfulDeletes = deletedFeatures.filter( + (f) => f + ) as Feature[]; if (successfulDeletes.length > 0) { if (this.beforeActionState) this.addStateToHistory(this.beforeActionState); this.beforeActionState = null; - successfulDeletes.forEach((f) => - this.vectorSource.removeFeature(f as Feature) - ); + successfulDeletes.forEach((f) => { + // --- ИЗМЕНЕНИЕ: Удаляем из правильного источника --- + if (f.getGeometry()?.getType() === "LineString") { + this.lineSource.removeFeature(f as Feature); + } else { + this.pointSource.removeFeature(f as Feature); + } + }); toast.success(`Удалено ${successfulDeletes.length} объект(ов).`); this.unselect(); } @@ -1577,7 +1709,9 @@ class MapService { } this.map.un("pointermove", this.boundHandlePointerMove as any); if (this.tooltipOverlay) this.map.removeOverlay(this.tooltipOverlay); - this.vectorSource.clear(); + // --- ИЗМЕНЕНИЕ: Очищаем оба источника --- + this.pointSource.clear(); + this.lineSource.clear(); this.map.setTarget(undefined); this.map = null; } @@ -1622,7 +1756,10 @@ class MapService { if (this.selectInteraction) { this.selectInteraction.getFeatures().clear(); ids.forEach((id) => { - const feature = this.vectorSource.getFeatureById(id); + // --- ИЗМЕНЕНИЕ: Ищем в обоих источниках --- + const feature = + this.pointSource.getFeatureById(id) || + this.lineSource.getFeatureById(id); if (feature) { this.selectInteraction.getFeatures().push(feature); } @@ -1630,9 +1767,14 @@ class MapService { } this.modifyInteraction.setActive(ids.size > 0); + // --- ИЗМЕНЕНИЕ: Обновляем оба слоя для отображения выделения --- + this.clusterLayer.changed(); + this.routeLayer.changed(); if (ids.size === 1) { - const feature = this.vectorSource.getFeatureById(Array.from(ids)[0]); + const feature = + this.pointSource.getFeatureById(Array.from(ids)[0]) || + this.lineSource.getFeatureById(Array.from(ids)[0]); if (feature) { this.onFeatureSelect(feature); } @@ -1689,7 +1831,6 @@ class MapService { } catch (error) { console.error("Failed to update feature:", error); toast.error(`Не удалось обновить: ${error}`); - // Revert to the state before modification on failure if (this.beforeActionState) { this.applyHistoryState(this.beforeActionState); } @@ -1713,31 +1854,51 @@ class MapService { featureGeoJSON ); - this.vectorSource.removeFeature(feature); + // --- ИЗМЕНЕНИЕ: Удаляем временный объект из правильного источника --- + if (feature.getGeometry()?.getType() === "LineString") { + this.lineSource.removeFeature(feature as Feature); + } else { + this.pointSource.removeFeature(feature as Feature); + } const newFeatureId = `${featureType}-${createdFeatureData.id}`; feature.setId(newFeatureId); - // Используем route_number для маршрутов, name для остальных const displayName = featureType === "route" ? createdFeatureData.route_number : createdFeatureData.name; feature.set("name", displayName); - this.vectorSource.addFeature(feature); + // --- ИЗМЕНЕНИЕ: Добавляем сохраненный объект в правильный источник --- + if (feature.getGeometry()?.getType() === "LineString") { + this.lineSource.addFeature(feature as Feature); + } else { + this.pointSource.addFeature(feature as Feature); + } + this.updateFeaturesInReact(); } catch (error) { console.error("Failed to save new feature:", error); toast.error("Не удалось сохранить объект."); - this.vectorSource.removeFeature(feature); // Ensure temporary feature is removed on 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); // Revert to the state before drawing + this.applyHistoryState(this.beforeActionState); } this.beforeActionState = null; } } } +// ... Оставшаяся часть файла (MapControls, MapSightbar, MapPage) остается без изменений, +// так как вся логика инкапсулирована в MapService. + // --- MAP CONTROLS COMPONENT --- interface MapControlsProps { mapService: MapService | null; @@ -1778,21 +1939,21 @@ const MapControls: React.FC = ({ title: "Остановка", longTitle: "Добавить остановку", icon: , - action: () => mapService.startDrawingMarker(), + action: () => mapService.startDrawing("Point", "station"), }, { mode: "drawing-sight", title: "Достопримечательность", longTitle: "Добавить достопримечательность", icon: , - action: () => mapService.startDrawingSight(), + action: () => mapService.startDrawing("Point", "sight"), }, { mode: "drawing-route", title: "Маршрут", longTitle: "Добавить маршрут (Правый клик для завершения)", icon: , - action: () => mapService.startDrawingLine(), + action: () => mapService.startDrawing("LineString", "route"), }, { @@ -1925,7 +2086,6 @@ const MapSightbar: React.FC = ({ [navigate] ); - // --- ИЗМЕНЕНИЕ: Логика сортировки с приоритетом для новых объектов --- const sortFeatures = ( features: Feature[], currentSelectedIds: Set, @@ -1936,19 +2096,16 @@ const MapSightbar: React.FC = ({ const aId = a.getId(); const bId = b.getId(); - // 1. Приоритет для явно выделенного объекта if (selectedId) { if (aId === selectedId) return -1; if (bId === selectedId) return 1; } - // 2. Приоритет для остальных выделенных (чекбоксами) объектов const aIsChecked = aId !== undefined && currentSelectedIds.has(aId); const bIsChecked = bId !== undefined && currentSelectedIds.has(bId); if (aIsChecked && !bIsChecked) return -1; if (!aIsChecked && bIsChecked) return 1; - // 3. Сортировка по ID (объекты остаются в порядке создания) const aNumericId = aId ? parseInt(String(aId).split("-")[1], 10) : 0; const bNumericId = bId ? parseInt(String(bId).split("-")[1], 10) : 0; if ( @@ -1956,16 +2113,14 @@ const MapSightbar: React.FC = ({ !isNaN(bNumericId) && aNumericId !== bNumericId ) { - return aNumericId - bNumericId; // По возрастанию - старые сверху, новые снизу + return aNumericId - bNumericId; } - // 4. Запасная сортировка по имени 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); diff --git a/src/pages/Media/MediaEditPage/index.tsx b/src/pages/Media/MediaEditPage/index.tsx index 9b82b4b..d640ef2 100644 --- a/src/pages/Media/MediaEditPage/index.tsx +++ b/src/pages/Media/MediaEditPage/index.tsx @@ -45,10 +45,10 @@ export const MediaEditPage = observer(() => { if (id) { mediaStore.getOneMedia(id); } - console.log(newFile); - console.log(uploadDialogOpen); }, [id]); + useEffect(() => {}, [newFile, uploadDialogOpen]); + useEffect(() => { if (media) { setMediaName(media.media_name); diff --git a/src/pages/Route/LinekedStations.tsx b/src/pages/Route/LinekedStations.tsx index 6d2e537..43ebb56 100644 --- a/src/pages/Route/LinekedStations.tsx +++ b/src/pages/Route/LinekedStations.tsx @@ -140,9 +140,7 @@ export const LinkedItemsContents = < const [activeTab, setActiveTab] = useState(0); const [searchQuery, setSearchQuery] = useState(""); - useEffect(() => { - console.log(error); - }, [error]); + useEffect(() => {}, [error]); const parentResource = "route"; const childResource = "station"; diff --git a/src/pages/Route/route-preview/Sight.tsx b/src/pages/Route/route-preview/Sight.tsx index d5545fb..e1c4e25 100644 --- a/src/pages/Route/route-preview/Sight.tsx +++ b/src/pages/Route/route-preview/Sight.tsx @@ -82,11 +82,7 @@ export const Sight = ({ sight, id }: Readonly) => { Assets.load("/SightIcon.png").then(setTexture); }, []); - useEffect(() => { - console.log( - `Rendering Sight ${id + 1} at [${sight.latitude}, ${sight.longitude}]` - ); - }, [id, sight.latitude, sight.longitude]); + useEffect(() => {}, [id, sight.latitude, sight.longitude]); if (!sight) { console.error("sight is null"); diff --git a/src/pages/Sight/SightListPage/index.tsx b/src/pages/Sight/SightListPage/index.tsx index 6e6bc73..2db8750 100644 --- a/src/pages/Sight/SightListPage/index.tsx +++ b/src/pages/Sight/SightListPage/index.tsx @@ -132,7 +132,6 @@ export const SightListPage = observer(() => { loading={isLoading} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} onRowSelectionModelChange={(newSelection) => { - console.log(newSelection); setIds(Array.from(newSelection.ids as unknown as number[])); }} slots={{ diff --git a/src/pages/Station/LinkedSights.tsx b/src/pages/Station/LinkedSights.tsx index 0a59cab..cacb2b6 100644 --- a/src/pages/Station/LinkedSights.tsx +++ b/src/pages/Station/LinkedSights.tsx @@ -93,9 +93,7 @@ export const LinkedSightsContents = < const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - console.log(error); - }, [error]); + useEffect(() => {}, [error]); const parentResource = "station"; const childResource = "sight"; diff --git a/src/shared/store/EditSightStore/index.tsx b/src/shared/store/EditSightStore/index.tsx index cf3a677..2fbfe1b 100644 --- a/src/shared/store/EditSightStore/index.tsx +++ b/src/shared/store/EditSightStore/index.tsx @@ -497,9 +497,7 @@ class EditSightStore { media_name: media_name, media_type: type, }; - } catch (error) { - console.log(error); - } + } catch (error) {} }; createLinkWithArticle = async (media: { diff --git a/src/shared/store/RouteStore/index.ts b/src/shared/store/RouteStore/index.ts index c00d305..273ba5c 100644 --- a/src/shared/store/RouteStore/index.ts +++ b/src/shared/store/RouteStore/index.ts @@ -82,11 +82,6 @@ class RouteStore { }; setRouteStations = (routeId: number, stationId: number, data: any) => { - console.log( - this.routeStations[routeId], - stationId, - this.routeStations[routeId].find((station) => station.id === stationId) - ); this.routeStations[routeId] = this.routeStations[routeId]?.map((station) => station.id === stationId ? { ...station, ...data } : station ); diff --git a/src/widgets/DevicesTable/index.tsx b/src/widgets/DevicesTable/index.tsx index f119d37..5cfbcc9 100644 --- a/src/widgets/DevicesTable/index.tsx +++ b/src/widgets/DevicesTable/index.tsx @@ -202,7 +202,6 @@ export const DevicesTable = observer(() => { try { // Create an array of promises for all snapshot requests const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { - console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`); return send(deviceUuid); });