From 89d7fc2748d04572813c1927159a67e57068ffac Mon Sep 17 00:00:00 2001 From: itoshi Date: Tue, 15 Jul 2025 05:29:27 +0300 Subject: [PATCH] feat: Add `scale` on group click, add `cache for map entities`, fix `map preview loading` --- src/pages/MapPage/index.tsx | 741 +++++++++++------- .../Route/route-preview/RightSidebar.tsx | 11 +- src/pages/Route/route-preview/index.tsx | 22 +- src/shared/modals/UploadMediaDialog/index.tsx | 38 +- src/shared/store/SightsStore/index.tsx | 22 +- src/widgets/LanguageSwitcher/index.tsx | 2 +- src/widgets/ModelViewer3D/index.tsx | 10 + 7 files changed, 547 insertions(+), 299 deletions(-) diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx index 0c877c0..e58d87c 100644 --- a/src/pages/MapPage/index.tsx +++ b/src/pages/MapPage/index.tsx @@ -11,7 +11,6 @@ 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, @@ -27,7 +26,7 @@ import { Stroke, Circle as CircleStyle, RegularShape, - Text, // --- ИЗМЕНЕНИЕ: Добавляем импорт Text для отображения числа в кластере + Text, } from "ol/style"; import { Point, LineString, Geometry, Polygon } from "ol/geom"; import { transform, toLonLat } from "ol/proj"; @@ -50,6 +49,7 @@ import { Feature } from "ol"; import Layer from "ol/layer/Layer"; import Source from "ol/source/Source"; import { FeatureLike } from "ol/Feature"; +import { createEmpty, extend, getCenter } from "ol/extent"; // --- CUSTOM SCROLLBAR STYLES --- const scrollbarHideStyles = ` @@ -62,7 +62,6 @@ const scrollbarHideStyles = ` } `; -// Inject styles into document head if (typeof document !== "undefined") { const styleElement = document.createElement("style"); styleElement.textContent = scrollbarHideStyles; @@ -71,13 +70,17 @@ if (typeof document !== "undefined") { // --- MAP STORE --- // @ts-ignore -import { languageInstance } from "@shared"; // Убедитесь, что этот импорт правильный +import { languageInstance } from "@shared"; import { makeAutoObservable } from "mobx"; +import { stationsStore, routeStore, sightsStore } from "@shared"; + interface ApiRoute { id: number; route_number: string; path: [number, number][]; + center_latitude: number; + center_longitude: number; } interface ApiStation { @@ -145,39 +148,89 @@ class MapStore { createFeature = async (featureType: string, geoJsonFeature: any) => { const { geometry, properties } = geoJsonFeature; - let data; + let createdItem; if (featureType === "station") { - data = { - name: properties.name || "Остановка 1", - latitude: geometry.coordinates[1], - longitude: geometry.coordinates[0], - }; + const name = properties.name || "Остановка 1"; + const latitude = geometry.coordinates[1]; + const longitude = geometry.coordinates[0]; + + stationsStore.setLanguageCreateStationData("ru", { + name, + address: "", + system_name: name, + }); + stationsStore.setLanguageCreateStationData("en", { + name, + address: "", + system_name: name, + }); + stationsStore.setLanguageCreateStationData("zh", { + name, + address: "", + system_name: name, + }); + stationsStore.setCreateCommonData({ latitude, longitude, city_id: 1 }); + + await stationsStore.createStation(); + createdItem = + stationsStore.stationLists["ru"].data[ + stationsStore.stationLists["ru"].data.length - 1 + ]; } else if (featureType === "route") { - data = { - route_number: properties.name || "Маршрут 1", - path: geometry.coordinates.map((c: any) => [c[1], c[0]]), - center_latitude: geometry.coordinates[0][1], - center_longitude: geometry.coordinates[0][0], + const route_number = properties.name || "Маршрут 1"; + const path = geometry.coordinates.map((c: any) => [c[1], c[0]]); + + const lineGeom = new GeoJSON().readGeometry(geometry, { + dataProjection: "EPSG:4326", + featureProjection: "EPSG:3857", + }); + const centerCoords = getCenter(lineGeom.getExtent()); + const [center_longitude, center_latitude] = toLonLat( + centerCoords, + "EPSG:3857" + ); + + const routeData = { + route_number, + path, + center_latitude, + center_longitude, + carrier: "", + carrier_id: 0, + governor_appeal: 0, + rotate: 0, + route_direction: false, + route_sys_number: route_number, + scale_max: 0, + scale_min: 0, }; + + await routeStore.createRoute(routeData); + createdItem = routeStore.routes.data[routeStore.routes.data.length - 1]; } else if (featureType === "sight") { - data = { - name: properties.name || "Достопримечательность 1", - description: properties.description || "", - latitude: geometry.coordinates[1], - longitude: geometry.coordinates[0], - }; + const name = properties.name || "Достопримечательность 1"; + const latitude = geometry.coordinates[1]; + const longitude = geometry.coordinates[0]; + + sightsStore.updateCreateSight("ru", { name, address: "" }); + sightsStore.updateCreateSight("en", { name, address: "" }); + sightsStore.updateCreateSight("zh", { name, address: "" }); + + await sightsStore.createSightAction(1, { latitude, longitude }); + createdItem = sightsStore.sights[sightsStore.sights.length - 1]; } else { throw new Error(`Unknown feature type for creation: ${featureType}`); } - const response = await languageInstance("ru").post(`/${featureType}`, data); + // @ts-ignore + if (featureType === "route") this.routes.push(createdItem); + // @ts-ignore + else if (featureType === "station") this.stations.push(createdItem); + // @ts-ignore + else if (featureType === "sight") this.sights.push(createdItem); - 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; + return createdItem; }; updateFeature = async (featureType: string, geoJsonFeature: any) => { @@ -195,9 +248,20 @@ class MapStore { longitude: geometry.coordinates[0], }; } else if (featureType === "route") { + const lineGeom = new GeoJSON().readGeometry(geometry, { + dataProjection: "EPSG:4326", + featureProjection: "EPSG:3857", + }); + const centerCoords = getCenter(lineGeom.getExtent()); + const [center_longitude, center_latitude] = toLonLat( + centerCoords, + "EPSG:3857" + ); data = { route_number: properties.name, path: geometry.coordinates.map((coord: any) => [coord[1], coord[0]]), + center_latitude, + center_longitude, }; } else if (featureType === "sight") { data = { @@ -220,6 +284,9 @@ class MapStore { oldData = findOldData(this.sights, numericId); if (!oldData) { + if (properties.isProxy) { + return; + } throw new Error( `Could not find old data for ${featureType} with id ${numericId}` ); @@ -230,15 +297,15 @@ class MapStore { requestBody = { ...oldData, ...data, - center_latitude: - data.path.length > 0 ? data.path[0][0] : oldData.center_latitude, - center_longitude: - data.path.length > 0 ? data.path[0][1] : oldData.center_longitude, }; } else { requestBody = { ...oldData, ...data }; } + if (properties.isProxy) { + return requestBody; + } + const response = await languageInstance("ru").patch( `/${featureType}/${numericId}`, requestBody @@ -281,7 +348,6 @@ const getStoredMapPosition = (): MapPosition | null => { const stored = localStorage.getItem(MAP_POSITION_KEY); if (stored) { const position = JSON.parse(stored); - // Validate the stored data if ( position && Array.isArray(position.center) && @@ -374,21 +440,20 @@ interface MapServiceConfig { } interface HistoryState { - state: string; // Always a full GeoJSON string of all features + state: string; } type FeatureType = "station" | "route" | "sight"; class MapService { private map: Map | null; - // --- ИЗМЕНЕНИЕ: Разделяем источники и слои --- - private pointSource: VectorSource>; // Для станций и достопримечательностей - private lineSource: VectorSource>; // Для маршрутов - private clusterSource: Cluster; // Источник для кластеризации - private clusterLayer: VectorLayer; // Слой для кластеров и отдельных точек - private routeLayer: VectorLayer>>; // Слой для маршрутов - private clusterStyleCache: { [key: number]: Style }; // Кэш для стилей кластеров - + public pointSource: VectorSource>; + public lineSource: VectorSource>; + public clusterLayer: VectorLayer; // Public for the deselect handler + public routeLayer: VectorLayer>>; // Public for deselect + private clusterSource: Cluster; + private clusterStyleCache: { [key: number]: Style }; + private unclusteredRouteIds: Set = new Set(); private tooltipElement: HTMLElement; private tooltipOverlay: Overlay | null; private mode: string | null; @@ -423,6 +488,8 @@ class MapService { private sightIconStyle: Style; private selectedSightIconStyle: Style; private drawSightIconStyle: Style; + private routeIconStyle: Style; + private selectedRouteIconStyle: Style; private universalHoverStylePoint: Style; private hoverSightIconStyle: Style; private universalHoverStyleLine: Style; @@ -507,6 +574,21 @@ class MapService { }), }); + this.routeIconStyle = new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ color: "rgba(34, 197, 94, 0.8)" }), // Green + stroke: new Stroke({ color: "#ffffff", width: 1.5 }), + }), + }); + this.selectedRouteIconStyle = new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }), // Orange on select + stroke: new Stroke({ color: "#ffffff", width: 2 }), + }), + }); + this.sightIconStyle = new Style({ image: new RegularShape({ fill: new Fill({ color: "rgba(139, 92, 246, 0.8)" }), @@ -562,21 +644,25 @@ class MapService { zIndex: Infinity, }); - // --- ИЗМЕНЕНИЕ: Инициализация раздельных источников --- this.pointSource = new VectorSource(); this.lineSource = new VectorSource(); this.clusterSource = new Cluster({ - distance: 45, // Расстояние в пикселях. Можно настроить. + distance: 45, source: this.pointSource, }); - // --- ИЗМЕНЕНИЕ: Слой для маршрутов --- this.routeLayer = new VectorLayer({ source: this.lineSource, + // @ts-ignore style: (featureLike: FeatureLike) => { const feature = featureLike as Feature; if (!feature) return this.defaultStyle; const fId = feature.getId(); + + if (fId === undefined || !this.unclusteredRouteIds.has(fId)) { + return null; + } + const isSelected = this.selectInteraction?.getFeatures().getArray().includes(feature) || (fId !== undefined && this.selectedIds.has(fId)); @@ -588,7 +674,6 @@ class MapService { }, }); - // --- ИЗМЕНЕНИЕ: Слой для кластеров и отдельных точек --- this.clusterLayer = new VectorLayer({ source: this.clusterSource, style: (featureLike: FeatureLike) => { @@ -599,13 +684,12 @@ class MapService { 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)" }), // Голубой цвет для кластера + 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({ @@ -618,18 +702,14 @@ class MapService { } return style; } else { - // Это одиночная точка const originalFeature = featuresInCluster[0]; const fId = originalFeature.getId(); const featureType = originalFeature.get("featureType"); + const isProxy = originalFeature.get("isProxy"); + if (isProxy) return new Style(); // Invisible empty style - const isEditSelected = this.selectInteraction - ?.getFeatures() - .getArray() - .includes(originalFeature); + const isSelected = fId !== undefined && this.selectedIds.has(fId); const isHovered = this.hoveredFeatureId === fId; - const isLassoSelected = - fId !== undefined && this.selectedIds.has(fId); if (isHovered) { return featureType === "sight" @@ -637,25 +717,54 @@ class MapService { : this.universalHoverStylePoint; } - if (isLassoSelected || isEditSelected) { - return featureType === "sight" - ? this.selectedSightIconStyle - : this.selectedBusIconStyle; + if (isSelected) { + if (featureType === "sight") return this.selectedSightIconStyle; + if (featureType === "route") return this.selectedRouteIconStyle; + return this.selectedBusIconStyle; } - return featureType === "sight" - ? this.sightIconStyle - : this.busIconStyle; + if (featureType === "sight") return this.sightIconStyle; + if (featureType === "route") return this.routeIconStyle; + return this.busIconStyle; } }, }); + this.clusterSource.on("change", () => { + const newUnclusteredRouteIds = new Set(); + this.clusterSource + .getFeatures() + .forEach((clusterFeature: Feature) => { + const originalFeatures = clusterFeature.get( + "features" + ) as Feature[]; + if (originalFeatures && originalFeatures.length === 1) { + const originalFeature = originalFeatures[0]; + if (originalFeature.get("featureType") === "route") { + const featureId = originalFeature.getId(); + if (featureId !== undefined) { + newUnclusteredRouteIds.add(featureId); + } + } + } + }); + + if ( + newUnclusteredRouteIds.size !== this.unclusteredRouteIds.size || + ![...newUnclusteredRouteIds].every((id) => + this.unclusteredRouteIds.has(id) + ) + ) { + this.unclusteredRouteIds = newUnclusteredRouteIds; + this.routeLayer.changed(); + } + }); + this.boundHandlePointerMove = this.handlePointerMove.bind(this); this.boundHandlePointerLeave = this.handlePointerLeave.bind(this); this.boundHandleContextMenu = this.handleContextMenu.bind(this); this.boundHandleKeyDown = this.handleKeyDown.bind(this); - // --- ИЗМЕНЕНИЕ: Слушатели событий для обоих источников --- this.pointSource.on( "addfeature", this.handleFeatureEvent.bind(this) as any @@ -681,7 +790,6 @@ class MapService { this.map = new Map({ target: config.target, - // --- ИЗМЕНЕНИЕ: Добавляем оба новых слоя на карту --- layers: [ new TileLayer({ source: new OSM() }), this.routeLayer, @@ -748,22 +856,23 @@ class MapService { } this.selectInteraction = new Select({ - style: null, // Стиль будет применен основным style-функциями слоев - condition: (event: MapBrowserEvent) => { - return ( - singleClick(event) && - !event.originalEvent.ctrlKey && - !event.originalEvent.metaKey - ); + style: null, + condition: singleClick, + filter: (feature: FeatureLike, l: Layer | null) => { + if (l !== this.clusterLayer && l !== this.routeLayer) return false; + const originalFeatures = feature.get("features"); + if ( + originalFeatures && + originalFeatures.length === 1 && + originalFeatures[0].get("isProxy") + ) + return false; // Ignore proxy points + return true; }, - // --- ИЗМЕНЕНИЕ: Фильтруем по обоим векторным слоям --- - filter: (_: FeatureLike, l: Layer | null) => - l === this.clusterLayer || l === this.routeLayer, - multi: false, + multi: true, }); this.modifyInteraction = new Modify({ - // --- ИЗМЕНЕНИЕ: Источник теперь не один, поэтому используем `features` --- features: this.selectInteraction.getFeatures(), style: new Style({ image: new CircleStyle({ @@ -819,7 +928,7 @@ class MapService { }); // @ts-ignore - this.modifyInteraction.on("modifystart", (event) => { + this.modifyInteraction.on("modifystart", () => { if (!this.beforeActionState) { this.beforeActionState = this.getCurrentStateAsGeoJSON(); } @@ -839,7 +948,6 @@ class MapService { this.map.on("dblclick", (event: MapBrowserEvent) => { if (this.mode !== "edit") return; - // --- ИЗМЕНЕНИЕ: Фильтр слоя для forEachFeatureAtPixel --- const layerFilter = (l: Layer) => l === this.routeLayer; const feature = this.map?.forEachFeatureAtPixel( @@ -908,8 +1016,8 @@ class MapService { const extent = geometry.getExtent(); const selected = new Set(); - // --- ИЗМЕНЕНИЕ: Искать объекты только в источнике точек --- this.pointSource.forEachFeatureInExtent(extent, (f) => { + if (f.get("isProxy")) return; // Ignore proxy in lasso const geom = f.getGeometry(); if (geom && geom.getType() === "Point") { const pointCoords = (geom as Point).getCoordinates(); @@ -919,6 +1027,21 @@ class MapService { } }); + this.lineSource.forEachFeatureInExtent( + extent, + (f: Feature) => { + const lineGeom = f.getGeometry(); + if (lineGeom) { + const intersects = lineGeom + .getCoordinates() + .some((coord) => geometry.intersectsCoordinate(coord)); + if (intersects && f.getId() !== undefined) { + selected.add(f.getId()!); + } + } + } + ); + this.setSelectedIds(selected); this.deactivateLasso(); }); @@ -931,46 +1054,76 @@ class MapService { this.selectInteraction.setActive(false); this.lassoInteraction.setActive(false); + // --- ИСПРАВЛЕНИЕ: Главный обработчик выбора объектов и кластеров this.selectInteraction.on("select", (e: SelectEvent) => { - if (this.mode === "edit") { - // --- ИЗМЕНЕНИЕ: Обработка выбора кластера --- - const selectedClusterFeatures = e.selected; - this.selectInteraction.getFeatures().clear(); // Очищаем перед добавлением + if (this.mode !== "edit" || !this.map) return; - 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); - } + const ctrlKey = + e.mapBrowserEvent.originalEvent.ctrlKey || + e.mapBrowserEvent.originalEvent.metaKey; + + // Проверяем, был ли клик по кластеру (группе) + if (e.selected.length === 1 && !ctrlKey) { + const clickedFeature = e.selected[0]; + const originalFeatures = clickedFeature.get("features"); + + if (originalFeatures && originalFeatures.length > 1) { + // Если да, то приближаем карту + const extent = createEmpty(); + originalFeatures.forEach((feat: Feature) => { + const geom = feat.getGeometry(); + if (geom) extend(extent, geom.getExtent()); + }); + this.map.getView().fit(extent, { + duration: 500, + padding: [60, 60, 60, 60], + maxZoom: 18, + }); + // Сбрасываем выделение, так как мы не хотим "выделять" сам кластер + this.selectInteraction.getFeatures().clear(); + this.setSelectedIds(new Set()); + return; // Завершаем обработку + } + } + + // Стандартная логика выделения для одиночных объектов (или с Ctrl) + const newSelectedIds = ctrlKey + ? new Set(this.selectedIds) + : new Set(); + + e.selected.forEach((feature) => { + const originalFeatures = feature.get("features"); + let targetId: string | number | undefined; + + if (originalFeatures && originalFeatures.length > 0) { + // Это фича из кластера (может быть и одна) + targetId = originalFeatures[0].getId(); } else { - // Если выбран маршрут (не из кластера) - const selectedRouteFeatures = e.target.getFeatures().getArray(); - if (selectedRouteFeatures.length > 0) { - this.onFeatureSelect(selectedRouteFeatures[0]); - } else { - this.onFeatureSelect(null); - } + // Это линия или что-то не из кластера + targetId = feature.getId(); } - this.modifyInteraction.setActive( - this.selectInteraction.getFeatures().getLength() > 0 - ); - } + if (targetId !== undefined) { + newSelectedIds.add(targetId); + } + }); + + e.deselected.forEach((feature) => { + const originalFeatures = feature.get("features"); + let targetId: string | number | undefined; + + if (originalFeatures && originalFeatures.length > 0) { + targetId = originalFeatures[0].getId(); + } else { + targetId = feature.getId(); + } + + if (targetId !== undefined) { + newSelectedIds.delete(targetId); + } + }); + + this.setSelectedIds(newSelectedIds); }); this.map.on("pointermove", this.boundHandlePointerMove as any); @@ -993,9 +1146,6 @@ class MapService { this.modifyInteraction.setActive(false); this.onFeatureSelect(null); this.setSelectedIds(new Set()); - // --- ИЗМЕНЕНИЕ: Обновляем оба слоя для сброса стилей --- - this.clusterLayer.changed(); - this.routeLayer.changed(); } public loadFeaturesFromApi( @@ -1047,14 +1197,37 @@ class MapService { transform([c[1], c[0]], "EPSG:4326", projection) ); if (coordinates.length === 0) return; + + const routeId = `route-${route.id}`; + 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); + const lineFeature = new Feature({ + geometry: line, + name: route.route_number, + }); + lineFeature.setId(routeId); + lineFeature.set("featureType", "route"); + lineFeatures.push(lineFeature); + + if (route.center_longitude != null && route.center_latitude != null) { + const centerPoint = new Point( + transform( + [route.center_longitude, route.center_latitude], + "EPSG:4326", + projection + ) + ); + const proxyPointFeature = new Feature({ + geometry: centerPoint, + name: route.route_number, + isProxy: true, + }); + proxyPointFeature.setId(routeId); + proxyPointFeature.set("featureType", "route"); + pointFeatures.push(proxyPointFeature); + } }); - // --- ИЗМЕНЕНИЕ: Добавляем объекты в соответствующие источники --- this.pointSource.addFeatures(pointFeatures); this.lineSource.addFeatures(lineFeatures); @@ -1074,7 +1247,6 @@ class MapService { private getCurrentStateAsGeoJSON(): string | null { if (!this.map) return null; const geoJSONFormat = new GeoJSON(); - // --- ИЗМЕНЕНИЕ: Собираем объекты из обоих источников --- const allFeatures = [ ...this.pointSource.getFeatures(), ...this.lineSource.getFeatures(), @@ -1097,7 +1269,6 @@ class MapService { ) as Feature[]; this.unselect(); - // --- ИЗМЕНЕНИЕ: Очищаем и заполняем оба источника --- this.pointSource.clear(); this.lineSource.clear(); @@ -1105,7 +1276,9 @@ class MapService { const lineFeatures: Feature[] = []; features.forEach((feature) => { - if (feature.getGeometry()?.getType() === "LineString") { + const featureType = feature.get("featureType"); + const isProxy = feature.get("isProxy"); + if (featureType === "route" && !isProxy) { lineFeatures.push(feature as Feature); } else { pointFeatures.push(feature as Feature); @@ -1123,7 +1296,8 @@ class MapService { features.forEach((feature) => { const id = feature.getId(); - if (!id) return; + if (!id || feature.get("isProxy")) return; + const [featureType, numericIdStr] = String(id).split("-"); const numericId = parseInt(numericIdStr, 10); if (isNaN(numericId)) return; @@ -1154,12 +1328,21 @@ class MapService { const coords = (geometry as LineString).getCoordinates(); const path = coords.map((c) => { const [lon, lat] = toLonLat(c, projection); - return [lat, lon]; + return [lat, lon] as [number, number]; }); + + const centerCoords = getCenter(geometry.getExtent()); + const [center_longitude, center_latitude] = toLonLat( + centerCoords, + projection + ); + newRoutes.push({ id: numericId, route_number: properties.name, - path: path as [number, number][], + path: path, + center_latitude, + center_longitude, }); } }); @@ -1177,9 +1360,8 @@ class MapService { const stateToRestore = this.history[this.historyIndex].state; this.applyHistoryState(stateToRestore); - // --- ИЗМЕНЕНИЕ: Собираем объекты из обоих источников для обновления --- const features = [ - ...this.pointSource.getFeatures(), + ...this.pointSource.getFeatures().filter((f) => !f.get("isProxy")), ...this.lineSource.getFeatures(), ]; const updatePromises = features.map((feature) => { @@ -1211,9 +1393,8 @@ class MapService { const stateToRestore = this.history[this.historyIndex].state; this.applyHistoryState(stateToRestore); - // --- ИЗМЕНЕНИЕ: Собираем объекты из обоих источников для обновления --- const features = [ - ...this.pointSource.getFeatures(), + ...this.pointSource.getFeatures().filter((f) => !f.get("isProxy")), ...this.lineSource.getFeatures(), ]; const updatePromises = features.map((feature) => { @@ -1244,7 +1425,6 @@ class MapService { private updateFeaturesInReact(): void { if (this.onFeaturesChange) { - // --- ИЗМЕНЕНИЕ: Передаем объекты из обоих источников --- const allFeatures = [ ...this.pointSource.getFeatures(), ...this.lineSource.getFeatures(), @@ -1256,7 +1436,6 @@ class MapService { private handlePointerLeave(): void { if (this.hoveredFeatureId) { this.hoveredFeatureId = null; - // --- ИЗМЕНЕНИЕ: Обновляем оба слоя --- this.clusterLayer.changed(); this.routeLayer.changed(); } @@ -1287,7 +1466,6 @@ class MapService { if (this.hoveredFeatureId && oldMode !== newMode) { this.hoveredFeatureId = null; - // --- ИЗМЕНЕНИЕ: Обновляем оба слоя --- this.clusterLayer.changed(); this.routeLayer.changed(); if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); @@ -1332,7 +1510,6 @@ class MapService { else if (featureType === "sight") styleForDrawing = this.drawSightIconStyle; else styleForDrawing = this.drawStyle; - // --- ИЗМЕНЕНИЕ: Выбираем правильный источник для рисования --- const sourceForDrawing = type === "Point" ? this.pointSource : this.lineSource; @@ -1358,7 +1535,6 @@ class MapService { feature.set("featureType", fType); let resourceName: string; - // --- ИЗМЕНЕНИЕ: Собираем объекты из обоих источников для поиска номера --- const allFeatures = [ ...this.pointSource.getFeatures(), ...this.lineSource.getFeatures(), @@ -1397,7 +1573,7 @@ class MapService { break; case "route": const existingRoutes = allFeatures.filter( - (f) => f.get("featureType") === "route" + (f) => f.get("featureType") === "route" && !f.get("isProxy") ); const routeNumbers = existingRoutes .map((f) => { @@ -1466,7 +1642,6 @@ class MapService { if (!this.map || event.dragging) { if (this.hoveredFeatureId) { this.hoveredFeatureId = null; - // --- ИЗМЕНЕНИЕ: Обновляем оба слоя --- this.clusterLayer.changed(); this.routeLayer.changed(); } @@ -1474,7 +1649,6 @@ class MapService { return; } - // --- ИЗМЕНЕНИЕ: Фильтр для поиска объектов на обоих слоях --- const layerFilter = (l: Layer) => l === this.clusterLayer || l === this.routeLayer; @@ -1495,10 +1669,9 @@ class MapService { if (featureAtPixel) { const originalFeatures = featureAtPixel.get("features"); if (originalFeatures && originalFeatures.length > 0) { - // Это объект из кластера + if (originalFeatures[0].get("isProxy")) return; // Ignore hover on proxy finalFeature = originalFeatures[0]; } else { - // Это маршрут finalFeature = featureAtPixel; } } @@ -1521,77 +1694,28 @@ class MapService { if (this.hoveredFeatureId !== newHoveredFeatureId) { this.hoveredFeatureId = newHoveredFeatureId as string | number | null; - // --- ИЗМЕНЕНИЕ: Обновляем оба слоя --- 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, hitTolerance: 5 } - ); - - 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 = 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); - } else { - if (newSet.size === 1 && newSet.has(featureId)) { - // Уже выделено, ничего не делаем - } else { - newSet.clear(); - newSet.add(featureId); - } - } - this.setSelectedIds(newSet); - } - public selectFeature(featureId: string | number | undefined): void { if (!this.map || featureId === undefined) { this.unselect(); return; } - // --- ИЗМЕНЕНИЕ: Ищем объект в обоих источниках --- + this.setSelectedIds(new Set([featureId])); + const feature = - this.pointSource.getFeatureById(featureId) || - this.lineSource.getFeatureById(featureId); + this.lineSource.getFeatureById(featureId) || + this.pointSource.getFeatureById(featureId); + if (!feature) { this.unselect(); return; } - this.setSelectedIds(new Set([featureId])); - const view = this.map.getView(); const geometry = feature.getGeometry(); if (geometry) { @@ -1599,7 +1723,7 @@ class MapService { view.animate({ center: geometry.getCoordinates(), duration: 500, - zoom: Math.max(view.getZoom() || 14, 16), // Увеличиваем сильнее, чтобы "раскрыть" кластер + zoom: Math.max(view.getZoom() || 14, 16), }); } else { view.fit(geometry.getExtent(), { @@ -1622,24 +1746,27 @@ class MapService { const numericId = parseInt(String(featureId).split("-")[1], 10); if (!recourse || isNaN(numericId)) return; - // --- ИЗМЕНЕНИЕ: Ищем объект в обоих источниках --- - const feature = - this.pointSource.getFeatureById(featureId) || - this.lineSource.getFeatureById(featureId); - if (!feature) return; - mapStore .deleteFeature(recourse, numericId) .then(() => { if (this.beforeActionState) this.addStateToHistory(this.beforeActionState); this.beforeActionState = null; - // --- ИЗМЕНЕНИЕ: Удаляем из правильного источника --- + if (recourse === "route") { - this.lineSource.removeFeature(feature as Feature); + const lineFeature = this.lineSource.getFeatureById(featureId); + if (lineFeature) + this.lineSource.removeFeature(lineFeature as Feature); + + const pointFeature = this.pointSource.getFeatureById(featureId); + if (pointFeature) + this.pointSource.removeFeature(pointFeature as Feature); } else { - this.pointSource.removeFeature(feature as Feature); + const feature = this.pointSource.getFeatureById(featureId); + if (feature) + this.pointSource.removeFeature(feature as Feature); } + this.unselect(); }) .catch((err) => { @@ -1653,35 +1780,40 @@ class MapService { this.beforeActionState = this.getCurrentStateAsGeoJSON(); const deletePromises = Array.from(featureIds).map((id) => { - // --- ИЗМЕНЕНИЕ: Ищем в обоих источниках --- - const feature = - this.pointSource.getFeatureById(id) || - this.lineSource.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 mapStore.deleteFeature(recourse, numericId).then(() => id); } - return Promise.resolve(); + return Promise.resolve(null); }); Promise.all(deletePromises) - .then((deletedFeatures) => { - const successfulDeletes = deletedFeatures.filter( - (f) => f - ) as Feature[]; + .then((deletedIds) => { + const successfulDeletes = deletedIds.filter((id) => id) as ( + | string + | number + )[]; if (successfulDeletes.length > 0) { if (this.beforeActionState) this.addStateToHistory(this.beforeActionState); this.beforeActionState = null; - successfulDeletes.forEach((f) => { - // --- ИЗМЕНЕНИЕ: Удаляем из правильного источника --- - if (f.getGeometry()?.getType() === "LineString") { - this.lineSource.removeFeature(f as Feature); + + successfulDeletes.forEach((id) => { + const recourse = String(id).split("-")[0]; + if (recourse === "route") { + const lineFeature = this.lineSource.getFeatureById(id); + if (lineFeature) + this.lineSource.removeFeature( + lineFeature as Feature + ); + const pointFeature = this.pointSource.getFeatureById(id); + if (pointFeature) + this.pointSource.removeFeature(pointFeature as Feature); } else { - this.pointSource.removeFeature(f as Feature); + const feature = this.pointSource.getFeatureById(id); + if (feature) + this.pointSource.removeFeature(feature as Feature); } }); toast.success(`Удалено ${successfulDeletes.length} объект(ов).`); @@ -1709,7 +1841,6 @@ class MapService { } this.map.un("pointermove", this.boundHandlePointerMove as any); if (this.tooltipOverlay) this.map.removeOverlay(this.tooltipOverlay); - // --- ИЗМЕНЕНИЕ: Очищаем оба источника --- this.pointSource.clear(); this.lineSource.clear(); this.map.setTarget(undefined); @@ -1753,28 +1884,26 @@ class MapService { this.selectedIds = new Set(ids); if (this.onSelectionChange) this.onSelectionChange(this.selectedIds); - if (this.selectInteraction) { - this.selectInteraction.getFeatures().clear(); - ids.forEach((id) => { - // --- ИЗМЕНЕНИЕ: Ищем в обоих источниках --- - const feature = - this.pointSource.getFeatureById(id) || - this.lineSource.getFeatureById(id); - if (feature) { - this.selectInteraction.getFeatures().push(feature); - } - }); - } + this.selectInteraction.getFeatures().clear(); + ids.forEach((id) => { + const lineFeature = this.lineSource.getFeatureById(id); + if (lineFeature) this.selectInteraction.getFeatures().push(lineFeature); - this.modifyInteraction.setActive(ids.size > 0); - // --- ИЗМЕНЕНИЕ: Обновляем оба слоя для отображения выделения --- + const pointFeature = this.pointSource.getFeatureById(id); + if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature); + }); + + this.modifyInteraction.setActive( + this.selectInteraction.getFeatures().getLength() > 0 + ); this.clusterLayer.changed(); this.routeLayer.changed(); if (ids.size === 1) { + const featureId = Array.from(ids)[0]; const feature = - this.pointSource.getFeatureById(Array.from(ids)[0]) || - this.lineSource.getFeatureById(Array.from(ids)[0]); + this.lineSource.getFeatureById(featureId) || + this.pointSource.getFeatureById(featureId); if (feature) { this.onFeatureSelect(feature); } @@ -1812,6 +1941,20 @@ class MapService { const featureId = feature.getId(); if (!featureType || featureId === undefined || !this.map) return; + if ( + featureType === "route" && + feature.getGeometry()?.getType() === "LineString" + ) { + const proxyPoint = this.pointSource.getFeatureById( + featureId + ) as Feature; + if (proxyPoint) { + const lineGeom = feature.getGeometry() as LineString; + const newCenter = getCenter(lineGeom.getExtent()); + proxyPoint.getGeometry()?.setCoordinates(newCenter); + } + } + if (typeof featureId === "number" || !String(featureId).includes("-")) { console.warn( "Skipping save for feature with non-standard ID:", @@ -1854,33 +1997,61 @@ class MapService { featureGeoJSON ); - // --- ИЗМЕНЕНИЕ: Удаляем временный объект из правильного источника --- - 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); + // @ts-ignore const displayName = featureType === "route" - ? createdFeatureData.route_number - : createdFeatureData.name; - feature.set("name", displayName); + ? // @ts-ignore + createdFeatureData.route_number + : // @ts-ignore + createdFeatureData.name; - // --- ИЗМЕНЕНИЕ: Добавляем сохраненный объект в правильный источник --- - if (feature.getGeometry()?.getType() === "LineString") { - this.lineSource.addFeature(feature as Feature); + if (featureType === "route") { + // @ts-ignore + const routeData = createdFeatureData as ApiRoute; + const projection = this.map.getView().getProjection(); + + // Update existing line feature + feature.setId(newFeatureId); + feature.set("name", displayName); + + // Optionally update geometry if server modified it + const lineGeom = new LineString( + routeData.path.map((c) => + transform([c[1], c[0]], "EPSG:4326", projection) + ) + ); + feature.setGeometry(lineGeom); + + // Create and add proxy point + const centerPointGeom = new Point( + transform( + [routeData.center_longitude, routeData.center_latitude], + "EPSG:4326", + projection + ) + ); + const proxyPointFeature = new Feature({ + geometry: centerPointGeom, + name: displayName, + isProxy: true, + }); + proxyPointFeature.setId(newFeatureId); + proxyPointFeature.set("featureType", "route"); + this.pointSource.addFeature(proxyPointFeature); } else { - this.pointSource.addFeature(feature as Feature); + // For points: update existing + feature.setId(newFeatureId); + feature.set("name", displayName); + // No need to remove and re-add since it's already in the source } this.updateFeaturesInReact(); + this.routeLayer.changed(); + this.clusterLayer.changed(); } catch (error) { console.error("Failed to save new feature:", error); toast.error("Не удалось сохранить объект."); - // --- ИЗМЕНЕНИЕ: Удаляем из правильного источника при ошибке --- if (feature.getGeometry()?.getType() === "LineString") { if (this.lineSource.hasFeature(feature as Feature)) this.lineSource.removeFeature(feature as Feature); @@ -1896,9 +2067,6 @@ class MapService { } } -// ... Оставшаяся часть файла (MapControls, MapSightbar, MapPage) остается без изменений, -// так как вся логика инкапсулирована в MapService. - // --- MAP CONTROLS COMPONENT --- interface MapControlsProps { mapService: MapService | null; @@ -2018,14 +2186,18 @@ const MapSightbar: React.FC = ({ const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(""); + const actualFeatures = useMemo(() => { + return mapFeatures.filter((feature) => !feature.get("isProxy")); + }, [mapFeatures]); + const filteredFeatures = useMemo(() => { - if (!searchQuery.trim()) return mapFeatures; - return mapFeatures.filter((feature) => + if (!searchQuery.trim()) return actualFeatures; + return actualFeatures.filter((feature) => ((feature.get("name") as string) || "") .toLowerCase() .includes(searchQuery.toLowerCase()) ); - }, [mapFeatures, searchQuery]); + }, [actualFeatures, searchQuery]); const handleFeatureClick = useCallback( (id: string | number | undefined) => { @@ -2371,6 +2543,7 @@ export const MapPage: React.FC = () => { const handleFeatureSelectForSidebar = useCallback( (feat: Feature | null) => { + // Logic to sync sidebar selection with map setSelectedFeatureForSidebar(feat); if (feat) { const featureType = feat.get("featureType"); @@ -2391,16 +2564,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); - }, - [mapServiceInstance, isLassoActive] - ); - useEffect(() => { let service: MapService | null = null; if (mapRef.current && tooltipRef.current && !mapServiceInstance) { @@ -2456,15 +2619,34 @@ export const MapPage: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // --- ИСПРАВЛЕНИЕ: Этот хук отвечает ТОЛЬКО за клики по ПУСТОМУ месту useEffect(() => { const olMap = mapServiceInstance?.getMap(); - if (olMap) { - olMap.on("click", handleMapClick); - return () => { - olMap.un("click", handleMapClick); - }; - } - }, [mapServiceInstance, handleMapClick]); + if (!olMap || !mapServiceInstance) return; + + const handleMapClickForDeselect = (event: any) => { + if (!mapServiceInstance) return; + + const hit = olMap.hasFeatureAtPixel(event.pixel, { + layerFilter: (layer) => + layer === mapServiceInstance.clusterLayer || + layer === mapServiceInstance.routeLayer, + hitTolerance: 5, + }); + + // Если клик был НЕ по объекту, снимаем выделение + if (!hit) { + mapServiceInstance.unselect(); + } + // Если клик был ПО объекту, НИЧЕГО не делаем. За это отвечает selectInteraction. + }; + + olMap.on("click", handleMapClickForDeselect); + + return () => { + olMap.un("click", handleMapClickForDeselect); + }; + }, [mapServiceInstance]); useEffect(() => { mapServiceInstance?.setOnSelectionChange(setSelectedIds); @@ -2505,7 +2687,6 @@ export const MapPage: React.FC = () => { } }, [mapServiceInstance, currentMapMode]); - // Сохраняем активную секцию в localStorage при её изменении useEffect(() => { saveActiveSection(activeSectionFromParent); }, [activeSectionFromParent]); diff --git a/src/pages/Route/route-preview/RightSidebar.tsx b/src/pages/Route/route-preview/RightSidebar.tsx index 212be38..727e618 100644 --- a/src/pages/Route/route-preview/RightSidebar.tsx +++ b/src/pages/Route/route-preview/RightSidebar.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useTransform } from "./TransformContext"; import { coordinatesToLocal, localToCoordinates } from "./utils"; import { SCALE_FACTOR } from "./Constants"; +import { toast } from "react-toastify"; export function RightSidebar() { const { @@ -360,8 +361,14 @@ export function RightSidebar() { variant="contained" color="secondary" sx={{ mt: 2 }} - onClick={() => { - saveChanges(); + onClick={async () => { + try { + await saveChanges(); + toast.success("Изменения сохранены"); + } catch (error) { + console.error(error); + toast.error("Ошибка при сохранении изменений"); + } }} > Сохранить изменения diff --git a/src/pages/Route/route-preview/index.tsx b/src/pages/Route/route-preview/index.tsx index b390fcf..39bbd67 100644 --- a/src/pages/Route/route-preview/index.tsx +++ b/src/pages/Route/route-preview/index.tsx @@ -26,6 +26,7 @@ import { Sight } from "./Sight"; import { SightData } from "./types"; import { Station } from "./Station"; import { UP_SCALE } from "./Constants"; +import CircularProgress from "@mui/material/CircularProgress"; extend({ Container, @@ -36,13 +37,27 @@ extend({ Text, }); +const Loading = () => { + const { isRouteLoading, isStationLoading, isSightLoading } = useMapData(); + + if (isRouteLoading || isStationLoading || isSightLoading) { + return ( +
+ +
+ ); + } + + return null; +}; export const RoutePreview = () => { + const { routeData, stationData, sightData } = useMapData(); return ( - - + {routeData && stationData && sightData ? : null} + @@ -145,8 +160,7 @@ export const RouteMap = observer(() => { ]); if (!routeData || !stationData || !sightData) { - console.error("routeData, stationData or sightData is null"); - return
Loading...
; + return null; } return ( diff --git a/src/shared/modals/UploadMediaDialog/index.tsx b/src/shared/modals/UploadMediaDialog/index.tsx index 7bd5eef..832b75a 100644 --- a/src/shared/modals/UploadMediaDialog/index.tsx +++ b/src/shared/modals/UploadMediaDialog/index.tsx @@ -81,6 +81,7 @@ export const UploadMediaDialog = observer( const [availableMediaTypes, setAvailableMediaTypes] = useState( [] ); + const [isPreviewLoaded, setIsPreviewLoaded] = useState(false); useEffect(() => { if (initialFile) { @@ -207,6 +208,7 @@ export const UploadMediaDialog = observer( useEffect(() => { if (mediaFile) { setMediaUrl(URL.createObjectURL(mediaFile as Blob)); + setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла } }, [mediaFile]); @@ -326,8 +328,22 @@ export const UploadMediaDialog = observer( alignItems: "center", justifyContent: "center", height: "100%", + position: "relative", }} > + {!isPreviewLoaded && mediaUrl && ( + + + + )} {mediaType == 2 && mediaUrl && (