Files
WhiteNightsAdminPanel/src/pages/MapPage/index.tsx
2025-06-13 09:17:24 +03:00

2198 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {
useEffect,
useRef,
useState,
useCallback,
ReactNode,
useMemo,
} from "react";
import { useNavigate } from "react-router-dom";
import { Map, View, Overlay, MapBrowserEvent } from "ol";
import TileLayer from "ol/layer/Tile";
import OSM from "ol/source/OSM";
import VectorLayer from "ol/layer/Vector";
import VectorSource, { VectorSourceEvent } from "ol/source/Vector";
import { Draw, Modify, Select } from "ol/interaction";
import { DrawEvent } from "ol/interaction/Draw";
import { SelectEvent } from "ol/interaction/Select";
import {
Style,
Fill,
Stroke,
Circle as CircleStyle,
RegularShape,
} from "ol/style";
import { Point, LineString, Geometry, Polygon } from "ol/geom";
import { transform } from "ol/proj";
import { GeoJSON } from "ol/format";
import {
Bus,
RouteIcon,
MapPin,
Trash2,
ArrowRightLeft,
Landmark,
Pencil,
Lasso,
InfoIcon,
X,
Loader2,
} from "lucide-react";
import { toast } from "react-toastify";
import { singleClick, doubleClick } from "ol/events/condition";
import { Feature } from "ol";
import Layer from "ol/layer/Layer";
import Source from "ol/source/Source";
import { FeatureLike } from "ol/Feature";
// --- MAP STORE ---
import { languageInstance } from "@shared"; // Убедитесь, что этот импорт правильный
import { makeAutoObservable } from "mobx";
interface ApiRoute {
id: number;
route_number: string;
path: [number, number][];
}
interface ApiStation {
id: number;
name: string;
latitude: number;
longitude: number;
}
interface ApiSight {
id: number;
name: string;
description: string;
latitude: number;
longitude: number;
}
class MapStore {
constructor() {
makeAutoObservable(this);
}
routes: ApiRoute[] = [];
stations: ApiStation[] = [];
sights: ApiSight[] = [];
getRoutes = async () => {
const response = await languageInstance("ru").get("/route");
console.log(response.data);
const routesIds = response.data.map((route: any) => route.id);
for (const id of routesIds) {
const route = await languageInstance("ru").get(`/route/${id}`);
this.routes.push({
id: route.data.id,
route_number: route.data.route_number,
path: route.data.path,
});
}
this.routes = this.routes.sort((a, b) =>
a.route_number.localeCompare(b.route_number)
);
};
getStations = async () => {
const stations = await languageInstance("ru").get("/station");
this.stations = stations.data.map((station: any) => ({
id: station.id,
name: station.name,
latitude: station.latitude,
longitude: station.longitude,
}));
};
getSights = async () => {
const sights = await languageInstance("ru").get("/sight");
this.sights = sights.data.map((sight: any) => ({
id: sight.id,
name: sight.name,
description: sight.description,
latitude: sight.latitude,
longitude: sight.longitude,
}));
};
deleteFeature = async (featureType: string, id: number) => {
await languageInstance("ru").delete(`/${featureType}/${id}`);
if (featureType === "route") {
this.routes = this.routes.filter((route) => route.id !== id);
} else if (featureType === "station") {
this.stations = this.stations.filter((station) => station.id !== id);
} else if (featureType === "sight") {
this.sights = this.sights.filter((sight) => sight.id !== id);
}
};
createFeature = async (featureType: string, geoJsonFeature: any) => {
const { geometry, properties } = geoJsonFeature;
let data;
if (featureType === "station") {
data = {
name: properties.name || "Новая станция",
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
};
} else if (featureType === "route") {
data = {
route_number: properties.name || "Новый маршрут",
path: geometry.coordinates,
};
} else if (featureType === "sight") {
data = {
name: properties.name || "Новая достопримечательность",
description: properties.description || "",
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
};
} else {
throw new Error(`Unknown feature type for creation: ${featureType}`);
}
const response = await languageInstance("ru").post(`/${featureType}`, data);
if (featureType === "route") this.routes.push(response.data);
else if (featureType === "station") this.stations.push(response.data);
else if (featureType === "sight") this.sights.push(response.data);
return response.data;
};
updateFeature = async (featureType: string, geoJsonFeature: any) => {
const { geometry, properties, id } = geoJsonFeature;
const numericId = parseInt(String(id).split("-")[1], 10);
if (isNaN(numericId)) {
throw new Error(`Invalid feature ID for update: ${id}`);
}
let data;
if (featureType === "station") {
data = {
name: properties.name,
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
};
} else if (featureType === "route") {
data = {
route_number: properties.name,
path: geometry.coordinates,
};
} else if (featureType === "sight") {
data = {
name: properties.name,
description: properties.description,
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
};
} else {
throw new Error(`Unknown feature type for update: ${featureType}`);
}
const response = await languageInstance("ru").patch(
`/${featureType}/${numericId}`,
data
);
if (featureType === "route") {
const index = this.routes.findIndex((f) => f.id === numericId);
if (index !== -1) this.routes[index] = response.data;
} else if (featureType === "station") {
const index = this.stations.findIndex((f) => f.id === numericId);
if (index !== -1) this.stations[index] = response.data;
} else if (featureType === "sight") {
const index = this.sights.findIndex((f) => f.id === numericId);
if (index !== -1) this.sights[index] = response.data;
}
return response.data;
};
}
const mapStore = new MapStore();
// --- CONFIGURATION ---
export const mapConfig = {
center: [30.311, 59.94] as [number, number],
zoom: 13,
};
// --- SVG ICONS ---
const EditIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-1 sm:mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
);
const LineIconSvg = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-1 sm:mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2h10a2 2 0 002-2v-1a2 2 0 012-2h1.945M7.732 4.064A2.5 2.5 0 105.23 6.24m13.54 0a2.5 2.5 0 10-2.502-2.176M12 16.05V21m0-17.948V3"
/>
</svg>
);
// --- TYPE DEFINITIONS ---
interface MapServiceConfig {
target: HTMLElement;
center: [number, number];
zoom: number;
}
interface HistoryState {
action: string;
state: string;
}
type FeatureType = "station" | "route" | "sight";
class MapService {
private map: Map | null;
private vectorSource: VectorSource<Feature<Geometry>>;
private vectorLayer: VectorLayer<VectorSource<Feature<Geometry>>>;
private tooltipElement: HTMLElement;
private tooltipOverlay: Overlay | null;
private mode: string | null;
private currentDrawingType: "Point" | "LineString" | null;
private currentDrawingFeatureType: FeatureType | null;
private currentInteraction: Draw | null;
private modifyInteraction: Modify;
private selectInteraction: Select;
private hoveredFeatureId: string | number | null;
private history: HistoryState[];
private historyIndex: number;
private beforeModifyState: string | null;
private boundHandlePointerMove: (
event: MapBrowserEvent<PointerEvent>
) => void;
private boundHandlePointerLeave: () => void;
private boundHandleContextMenu: (event: MouseEvent) => void;
private boundHandleKeyDown: (event: KeyboardEvent) => void;
private lassoInteraction: Draw | null = null;
private selectedIds: Set<string | number> = new Set();
private onSelectionChange: ((ids: Set<string | number>) => void) | null =
null;
// Styles
private defaultStyle: Style;
private selectedStyle: Style;
private drawStyle: Style;
private busIconStyle: Style;
private selectedBusIconStyle: Style;
private drawBusIconStyle: Style;
private sightIconStyle: Style;
private selectedSightIconStyle: Style;
private drawSightIconStyle: Style;
private universalHoverStylePoint: Style;
private hoverSightIconStyle: Style;
private universalHoverStyleLine: Style;
// Callbacks
private setLoading: (loading: boolean) => void;
private setError: (error: string | null) => void;
private onModeChangeCallback: (mode: string) => void;
private onFeaturesChange: (features: Feature<Geometry>[]) => void;
private onFeatureSelect: (feature: Feature<Geometry> | null) => void;
constructor(
config: MapServiceConfig,
setLoading: (loading: boolean) => void,
setError: (error: string | null) => void,
onModeChangeCallback: (mode: string) => void,
onFeaturesChange: (features: Feature<Geometry>[]) => void,
onFeatureSelect: (feature: Feature<Geometry> | null) => void,
tooltipElement: HTMLElement,
onSelectionChange?: (ids: Set<string | number>) => void
) {
this.map = null;
this.tooltipElement = tooltipElement;
this.tooltipOverlay = null;
this.mode = null;
this.currentDrawingType = null;
this.currentDrawingFeatureType = null;
this.currentInteraction = null;
this.hoveredFeatureId = null;
this.history = [];
this.historyIndex = -1;
this.beforeModifyState = null;
this.setLoading = setLoading;
this.setError = setError;
this.onModeChangeCallback = onModeChangeCallback;
this.onFeaturesChange = onFeaturesChange;
this.onFeatureSelect = onFeatureSelect;
this.defaultStyle = new Style({
fill: new Fill({ color: "rgba(66, 153, 225, 0.2)" }),
stroke: new Stroke({ color: "#3182ce", width: 3 }),
});
// ИСПРАВЛЕНИЕ: Удалено свойство image из этого стиля.
// Оно предназначалось для линий, но применялось и к точкам,
// создавая ненужный центральный круг.
this.selectedStyle = new Style({
fill: new Fill({ color: "rgba(221, 107, 32, 0.3)" }),
stroke: new Stroke({ color: "#dd6b20", width: 4 }),
});
this.drawStyle = new Style({
fill: new Fill({ color: "rgba(74, 222, 128, 0.3)" }),
stroke: new Stroke({
color: "rgba(34, 197, 94, 0.7)",
width: 2,
lineDash: [5, 5],
}),
image: new CircleStyle({
radius: 5,
fill: new Fill({ color: "rgba(34, 197, 94, 0.7)" }),
}),
});
this.busIconStyle = new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: "rgba(0, 60, 255, 0.8)" }),
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
}),
});
this.selectedBusIconStyle = new Style({
image: new CircleStyle({
radius: 10,
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
}),
});
this.drawBusIconStyle = new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: "rgba(100, 180, 100, 0.8)" }),
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
}),
});
this.sightIconStyle = new Style({
image: new RegularShape({
fill: new Fill({ color: "rgba(139, 92, 246, 0.8)" }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
points: 5,
radius: 12,
radius2: 6,
angle: 0,
}),
});
this.selectedSightIconStyle = new Style({
image: new RegularShape({
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
points: 5,
radius: 12,
radius2: 6,
angle: 0,
}),
});
this.drawSightIconStyle = new Style({
image: new RegularShape({
fill: new Fill({ color: "rgba(100, 180, 100, 0.8)" }),
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
points: 5,
radius: 12,
radius2: 6,
angle: 0,
}),
});
this.universalHoverStylePoint = new Style({
image: new CircleStyle({
radius: 11,
fill: new Fill({ color: "rgba(255, 165, 0, 0.7)" }),
stroke: new Stroke({ color: "rgba(255,255,255,0.8)", width: 2 }),
}),
zIndex: Infinity,
});
this.hoverSightIconStyle = new Style({
image: new RegularShape({
fill: new Fill({ color: "rgba(255, 165, 0, 0.7)" }),
stroke: new Stroke({ color: "rgba(255,255,255,0.8)", width: 2 }),
points: 5,
radius: 15,
radius2: 7.5,
angle: 0,
}),
zIndex: Infinity,
});
this.universalHoverStyleLine = new Style({
stroke: new Stroke({ color: "rgba(255, 165, 0, 0.8)", width: 5 }),
zIndex: Infinity,
});
this.vectorSource = new VectorSource<Feature<Geometry>>();
this.vectorLayer = new VectorLayer({
source: this.vectorSource,
style: (featureLike: FeatureLike) => {
const feature = featureLike as Feature<Geometry>;
if (!feature) return this.defaultStyle;
const geometryType = feature.getGeometry()?.getType();
const fId = feature.getId();
const featureType = feature.get("featureType");
const isEditSelected = this.selectInteraction
?.getFeatures()
.getArray()
.includes(feature);
const isHovered = this.hoveredFeatureId === fId;
const isLassoSelected = fId !== undefined && this.selectedIds.has(fId);
if (geometryType === "Point") {
const defaultPointStyle =
featureType === "sight" ? this.sightIconStyle : this.busIconStyle;
const selectedPointStyle =
featureType === "sight"
? this.selectedSightIconStyle
: this.selectedBusIconStyle;
if (isEditSelected) {
return selectedPointStyle;
}
if (isHovered) {
// Only apply hover styles if not in edit mode
if (this.mode !== "edit") {
return featureType === "sight"
? this.hoverSightIconStyle
: this.universalHoverStylePoint;
}
return defaultPointStyle;
}
if (isLassoSelected) {
let imageStyle;
if (featureType === "sight") {
imageStyle = new RegularShape({
fill: new Fill({ color: "#14b8a6" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
points: 5,
radius: 12,
radius2: 6,
angle: 0,
});
} else {
imageStyle = new CircleStyle({
radius: 10,
fill: new Fill({ color: "#14b8a6" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
});
}
return new Style({ image: imageStyle, zIndex: Infinity });
}
return defaultPointStyle;
} else if (geometryType === "LineString") {
if (isEditSelected) {
return this.selectedStyle;
}
if (isHovered) {
return this.universalHoverStyleLine;
}
if (isLassoSelected) {
return new Style({
stroke: new Stroke({ color: "#14b8a6", width: 6 }),
zIndex: Infinity,
});
}
return this.defaultStyle;
}
return this.defaultStyle;
},
});
this.boundHandlePointerMove = this.handlePointerMove.bind(this);
this.boundHandlePointerLeave = this.handlePointerLeave.bind(this);
this.boundHandleContextMenu = this.handleContextMenu.bind(this);
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.vectorSource.on(
"addfeature",
this.handleFeatureEvent.bind(this) as any
);
this.vectorSource.on("removefeature", () => this.updateFeaturesInReact());
this.vectorSource.on(
"changefeature",
this.handleFeatureChange.bind(this) as any
);
let renderCompleteHandled = false;
const MAP_LOAD_TIMEOUT = 15000;
try {
this.map = new Map({
target: config.target,
layers: [new TileLayer({ source: new OSM() }), this.vectorLayer],
view: new View({
center: transform(config.center, "EPSG:4326", "EPSG:3857"),
zoom: config.zoom,
}),
controls: [],
});
if (this.tooltipElement && this.map) {
this.tooltipOverlay = new Overlay({
element: this.tooltipElement,
offset: [15, 0],
positioning: "center-left",
});
this.map.addOverlay(this.tooltipOverlay);
}
this.map.once("rendercomplete", () => {
if (!renderCompleteHandled) {
this.setLoading(false);
renderCompleteHandled = true;
setError(null);
}
});
setTimeout(() => {
if (!renderCompleteHandled && this.map) {
this.setLoading(false);
this.setError("Карта не загрузилась вовремя.");
renderCompleteHandled = true;
}
}, MAP_LOAD_TIMEOUT);
} catch (error) {
this.setError("Критическая ошибка при инициализации карты.");
this.setLoading(false);
renderCompleteHandled = true;
}
this.modifyInteraction = new Modify({
source: this.vectorSource,
style: new Style({
image: new CircleStyle({
radius: 6,
fill: new Fill({
color: "rgba(255, 255, 255, 0.8)",
}),
stroke: new Stroke({
color: "#0099ff",
width: 2.5,
}),
}),
}),
deleteCondition: (e: MapBrowserEvent<any>) => doubleClick(e),
});
this.selectInteraction = new Select({
style: (featureLike: FeatureLike) => {
if (!featureLike || !featureLike.getGeometry) return this.defaultStyle;
const feature = featureLike as Feature<Geometry>;
const featureType = feature.get("featureType");
const geometryType = feature.getGeometry()?.getType();
if (geometryType === "Point") {
return featureType === "sight"
? this.selectedSightIconStyle
: this.selectedBusIconStyle;
}
return this.selectedStyle;
},
condition: singleClick,
filter: (_: FeatureLike, l: Layer<Source, any> | null) =>
l === this.vectorLayer,
});
this.modifyInteraction.on("modifystart", (event) => {
const geoJSONFormat = new GeoJSON();
if (!this.map) return;
this.beforeModifyState = geoJSONFormat.writeFeatures(
this.vectorSource.getFeatures(), // Сохраняем все фичи для отката
{
dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection().getCode(),
}
);
});
this.modifyInteraction.on("modifyend", (event) => {
if (this.beforeModifyState) {
this.addStateToHistory("modify", this.beforeModifyState);
this.beforeModifyState = null;
}
this.updateFeaturesInReact();
event.features.getArray().forEach((feature) => {
this.saveModifiedFeature(feature as Feature<Geometry>);
});
});
this.lassoInteraction = new Draw({
type: "Polygon",
style: new Style({
stroke: new Stroke({ color: "#14b8a6", width: 2 }),
fill: new Fill({ color: "rgba(20, 184, 166, 0.1)" }),
}),
});
this.lassoInteraction.setActive(false);
this.lassoInteraction.on("drawend", (event: DrawEvent) => {
const geometry = event.feature.getGeometry() as Polygon;
const extent = geometry.getExtent();
const selected = new Set<string | number>();
this.vectorSource.forEachFeatureInExtent(extent, (f) => {
const geom = f.getGeometry();
if (geom && geom.getType() === "Point") {
const pointCoords = (geom as Point).getCoordinates();
if (geometry.intersectsCoordinate(pointCoords)) {
if (f.getId() !== undefined) selected.add(f.getId()!);
}
} else if (geom && geom.intersectsExtent(extent)) {
if (f.getId() !== undefined) selected.add(f.getId()!);
}
});
this.setSelectedIds(selected);
this.deactivateLasso();
});
if (this.map) {
this.map.addInteraction(this.modifyInteraction);
this.map.addInteraction(this.selectInteraction);
this.map.addInteraction(this.lassoInteraction);
this.modifyInteraction.setActive(false);
this.selectInteraction.setActive(false);
this.lassoInteraction.setActive(false);
this.selectInteraction.on("select", (e: SelectEvent) => {
if (this.mode === "edit") {
const selFs = e.selected as Feature<Geometry>[];
this.modifyInteraction.setActive(selFs.length > 0);
if (selFs.length > 0) {
this.onFeatureSelect(selFs[0]);
} else {
this.onFeatureSelect(null);
}
}
});
this.map.on("pointermove", this.boundHandlePointerMove as any);
const targetEl = this.map.getTargetElement();
if (targetEl instanceof HTMLElement) {
targetEl.addEventListener("contextmenu", this.boundHandleContextMenu);
targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave);
}
document.addEventListener("keydown", this.boundHandleKeyDown);
this.activateEditMode();
}
if (onSelectionChange) {
this.onSelectionChange = onSelectionChange;
}
}
public unselect(): void {
this.selectInteraction.getFeatures().clear();
this.modifyInteraction.setActive(false);
this.onFeatureSelect(null);
this.setSelectedIds(new Set());
}
public loadFeaturesFromApi(
apiStations: typeof mapStore.stations,
apiRoutes: typeof mapStore.routes,
apiSights: typeof mapStore.sights
): void {
if (!this.map) return;
const projection = this.map.getView().getProjection();
const featuresToAdd: Feature<Geometry>[] = [];
apiStations.forEach((station) => {
if (station.longitude == null || station.latitude == null) return;
const point = new Point(
transform(
[station.longitude, station.latitude],
"EPSG:4326",
projection
)
);
const feature = new Feature({ geometry: point, name: station.name });
feature.setId(`station-${station.id}`);
feature.set("featureType", "station");
featuresToAdd.push(feature);
});
apiRoutes.forEach((route) => {
if (!route.path || route.path.length === 0) return;
const coordinates = route.path
.filter((c) => c[0] != null && c[1] != null)
.map((c) => transform(c, "EPSG:4326", projection));
if (coordinates.length === 0) return;
const line = new LineString(coordinates);
const feature = new Feature({ geometry: line, name: route.route_number });
feature.setId(`route-${route.id}`);
feature.set("featureType", "route");
featuresToAdd.push(feature);
});
apiSights.forEach((sight) => {
if (sight.longitude == null || sight.latitude == null) return;
const point = new Point(
transform([sight.longitude, sight.latitude], "EPSG:4326", projection)
);
const feature = new Feature({
geometry: point,
name: sight.name,
description: sight.description,
});
feature.setId(`sight-${sight.id}`);
feature.set("featureType", "sight");
featuresToAdd.push(feature);
});
this.vectorSource.addFeatures(featuresToAdd);
this.updateFeaturesInReact();
}
private addStateToHistory(
actionDescription: string,
stateToSave: string
): void {
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push({ action: actionDescription, state: stateToSave });
this.historyIndex = this.history.length - 1;
}
private getCurrentStateAsGeoJSON(): string | null {
if (!this.map) return null;
const geoJSONFormat = new GeoJSON();
return geoJSONFormat.writeFeatures(this.vectorSource.getFeatures(), {
dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection().getCode(),
});
}
public undo(): void {
if (this.historyIndex >= 0) {
const stateToRestore = this.history[this.historyIndex].state;
this.applyHistoryState(stateToRestore);
this.historyIndex--;
} else {
toast.info("Больше отменять нечего");
}
}
public redo(): void {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.applyHistoryState(this.history[this.historyIndex].state);
} else {
toast.info("Больше повторять нечего");
}
}
private applyHistoryState(geoJSONState: string): void {
if (!this.map) return;
const geoJSONFormat = new GeoJSON();
const features = geoJSONFormat.readFeatures(geoJSONState, {
dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection().getCode(),
}) as Feature<Geometry>[];
this.unselect();
this.vectorSource.clear();
if (features.length > 0) this.vectorSource.addFeatures(features);
this.updateFeaturesInReact();
}
private updateFeaturesInReact(): void {
if (this.onFeaturesChange) {
this.onFeaturesChange(this.vectorSource.getFeatures());
}
}
private handlePointerLeave(): void {
if (this.hoveredFeatureId) {
this.hoveredFeatureId = null;
this.vectorLayer.changed();
}
if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
}
private handleKeyDown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key === "z") {
event.preventDefault();
this.undo();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key === "y") {
event.preventDefault();
this.redo();
return;
}
if (event.key === "Escape") {
this.unselect();
}
}
private setMode(newMode: string): void {
if (!this.map) return;
const oldMode = this.mode;
this.mode = newMode;
if (this.onModeChangeCallback) this.onModeChangeCallback(newMode);
if (this.hoveredFeatureId && oldMode !== newMode) {
this.hoveredFeatureId = null;
this.vectorLayer.changed();
if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
}
if (this.currentInteraction instanceof Draw) {
this.map.removeInteraction(this.currentInteraction);
this.currentInteraction = null;
}
if (newMode === "edit") {
this.selectInteraction.setActive(true);
} else {
this.unselect();
this.selectInteraction.setActive(false);
}
}
public activateEditMode(): void {
this.currentDrawingType = null;
this.setMode("edit");
}
public startDrawing(
type: "Point" | "LineString",
featureType: FeatureType
): void {
if (!this.map) return;
this.currentDrawingType = type;
this.currentDrawingFeatureType = featureType;
const drawingMode = `drawing-${featureType}`;
this.setMode(drawingMode);
if (this.currentInteraction instanceof Draw) {
this.map.removeInteraction(this.currentInteraction);
}
let styleForDrawing: Style;
if (featureType === "station") styleForDrawing = this.drawBusIconStyle;
else if (featureType === "sight") styleForDrawing = this.drawSightIconStyle;
else styleForDrawing = this.drawStyle;
this.currentInteraction = new Draw({
source: this.vectorSource,
type,
style: styleForDrawing,
});
let stateBeforeDraw: string | null = null;
this.currentInteraction.on("drawstart", () => {
stateBeforeDraw = this.getCurrentStateAsGeoJSON();
});
this.currentInteraction.on("drawend", async (event: DrawEvent) => {
if (stateBeforeDraw) {
this.addStateToHistory("draw-before", stateBeforeDraw);
}
const feature = event.feature as Feature<Geometry>;
const fType = this.currentDrawingFeatureType;
if (!fType) return;
feature.set("featureType", fType);
let baseName = "",
namePrefix = "";
if (fType === "station") {
baseName = "Станция";
namePrefix = "Станция ";
} else if (fType === "sight") {
baseName = "Достопримечательность";
namePrefix = "Достопримечательность ";
} else if (fType === "route") {
baseName = "Маршрут";
namePrefix = "Маршрут ";
}
const existingNamedFeatures = this.vectorSource
.getFeatures()
.filter(
(f) =>
f !== feature &&
f.get("featureType") === fType &&
(f.get("name") as string)?.startsWith(namePrefix)
);
let maxNumber = 0;
existingNamedFeatures.forEach((f) => {
const name = f.get("name") as string;
if (name) {
const num = parseInt(name.substring(namePrefix.length), 10);
if (!isNaN(num) && num > maxNumber) maxNumber = num;
}
});
feature.set("name", `${baseName} ${maxNumber + 1}`);
await this.saveNewFeature(feature);
this.stopDrawing();
});
this.map.addInteraction(this.currentInteraction);
}
public startDrawingMarker(): void {
this.startDrawing("Point", "station");
}
public startDrawingLine(): void {
this.startDrawing("LineString", "route");
}
public startDrawingSight(): void {
this.startDrawing("Point", "sight");
}
private stopDrawing() {
if (this.map && this.currentInteraction) {
try {
// @ts-ignore
this.currentInteraction.abortDrawing();
} catch (e) {
/* ignore */
}
this.map.removeInteraction(this.currentInteraction);
}
this.currentInteraction = null;
this.currentDrawingType = null;
this.currentDrawingFeatureType = null;
this.activateEditMode();
}
public finishDrawing(): void {
if (!this.currentInteraction) return;
try {
this.currentInteraction.finishDrawing();
} catch (e) {
this.stopDrawing();
}
}
private handleContextMenu(event: MouseEvent): void {
event.preventDefault();
if (
this.mode?.startsWith("drawing-") &&
this.currentInteraction instanceof Draw
) {
this.finishDrawing();
}
}
private handlePointerMove(event: MapBrowserEvent<PointerEvent>): void {
if (!this.map || event.dragging) {
if (this.hoveredFeatureId) {
this.hoveredFeatureId = null;
this.vectorLayer.changed();
}
if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
return;
}
const hit = this.map.hasFeatureAtPixel(event.pixel, {
layerFilter: (l) => l === this.vectorLayer,
hitTolerance: 5,
});
this.map.getTargetElement().style.cursor = hit ? "pointer" : "";
const featureAtPixel: Feature<Geometry> | undefined =
this.map.forEachFeatureAtPixel(
event.pixel,
(f: FeatureLike) => f as Feature<Geometry>,
{ layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 }
);
const newHoveredFeatureId = featureAtPixel ? featureAtPixel.getId() : null;
if (this.tooltipOverlay && this.tooltipElement) {
if (this.mode === "edit" && featureAtPixel) {
const name = featureAtPixel.get("name");
if (name) {
this.tooltipElement.innerHTML = name as string;
this.tooltipOverlay.setPosition(event.coordinate);
} else {
this.tooltipOverlay.setPosition(undefined);
}
} else {
this.tooltipOverlay.setPosition(undefined);
}
}
// Only update hoveredFeatureId if not in edit mode
if (this.mode !== "edit" && this.hoveredFeatureId !== newHoveredFeatureId) {
this.hoveredFeatureId = newHoveredFeatureId as string | number | null;
this.vectorLayer.changed();
}
}
public handleMapClick(event: MapBrowserEvent<any>, ctrlKey: boolean): void {
if (!this.map || this.mode !== "edit") return;
const featureAtPixel: Feature<Geometry> | undefined =
this.map.forEachFeatureAtPixel(
event.pixel,
(f: FeatureLike) => f as Feature<Geometry>,
{ layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 }
);
if (!featureAtPixel) {
if (!ctrlKey) this.unselect();
return;
}
const featureId = featureAtPixel.getId();
if (featureId === undefined) return;
if (ctrlKey) {
const newSet = new Set(this.selectedIds);
if (newSet.has(featureId)) newSet.delete(featureId);
else newSet.add(featureId);
this.setSelectedIds(newSet);
} else {
this.setSelectedIds(new Set([featureId]));
}
}
public selectFeature(featureId: string | number | undefined): void {
if (!this.map || featureId === undefined) {
this.unselect();
return;
}
const feature = this.vectorSource.getFeatureById(featureId);
if (!feature) {
this.unselect();
return;
}
if (this.mode === "edit") {
this.selectInteraction.getFeatures().clear();
this.selectInteraction.getFeatures().push(feature);
const selectEvent = new SelectEvent("select", [feature], []);
this.selectInteraction.dispatchEvent(selectEvent);
}
this.setSelectedIds(new Set([featureId]));
const view = this.map.getView();
const geometry = feature.getGeometry();
if (geometry) {
if (geometry instanceof Point) {
view.animate({
center: geometry.getCoordinates(),
duration: 500,
zoom: Math.max(view.getZoom() || 14, 14),
});
} else {
view.fit(geometry.getExtent(), {
duration: 500,
padding: [50, 50, 50, 50],
maxZoom: 15,
});
}
}
}
public deleteFeature(
featureId: string | number | undefined,
recourse: string
): void {
if (featureId === undefined) return;
const stateBeforeDelete = this.getCurrentStateAsGeoJSON();
const numericId = parseInt(String(featureId).split("-")[1], 10);
if (!recourse || isNaN(numericId)) return;
const feature = this.vectorSource.getFeatureById(featureId);
if (!feature) return;
mapStore
.deleteFeature(recourse, numericId)
.then(() => {
toast.success("Объект успешно удален");
if (stateBeforeDelete)
this.addStateToHistory("delete", stateBeforeDelete);
this.vectorSource.removeFeature(feature);
this.unselect();
})
.catch((err) => {
toast.error("Ошибка при удалении объекта");
console.error("Delete failed:", err);
});
}
public deleteMultipleFeatures(featureIds: (string | number)[]): void {
if (!featureIds || featureIds.length === 0) return;
const stateBeforeDelete = this.getCurrentStateAsGeoJSON();
const deletePromises = Array.from(featureIds).map((id) => {
const feature = this.vectorSource.getFeatureById(id);
if (!feature) return Promise.resolve();
const recourse = String(id).split("-")[0];
const numericId = parseInt(String(id).split("-")[1], 10);
if (recourse && !isNaN(numericId)) {
return mapStore.deleteFeature(recourse, numericId).then(() => feature); // Возвращаем фичу в случае успеха
}
return Promise.resolve();
});
Promise.all(deletePromises)
.then((deletedFeatures) => {
const successfulDeletes = deletedFeatures.filter((f) => f);
if (successfulDeletes.length > 0) {
if (stateBeforeDelete)
this.addStateToHistory("multiple-delete", stateBeforeDelete);
successfulDeletes.forEach((f) =>
this.vectorSource.removeFeature(f as Feature<Geometry>)
);
toast.success(`Удалено ${successfulDeletes.length} объект(ов).`);
this.unselect();
}
})
.catch((err) => {
toast.error("Произошла ошибка при массовом удалении");
console.error("Bulk delete failed:", err);
});
}
public getAllFeaturesAsGeoJSON(): string | null {
if (!this.vectorSource || !this.map) return null;
const feats = this.vectorSource.getFeatures();
if (feats.length === 0) return null;
const geoJSONFmt = new GeoJSON();
return geoJSONFmt.writeFeatures(feats, {
dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection(),
});
}
public destroy(): void {
if (this.map) {
document.removeEventListener("keydown", this.boundHandleKeyDown);
const targetEl = this.map.getTargetElement();
if (targetEl instanceof HTMLElement) {
targetEl.removeEventListener(
"contextmenu",
this.boundHandleContextMenu
);
targetEl.removeEventListener(
"pointerleave",
this.boundHandlePointerLeave
);
}
this.map.un("pointermove", this.boundHandlePointerMove as any);
if (this.tooltipOverlay) this.map.removeOverlay(this.tooltipOverlay);
this.vectorSource.clear();
this.map.setTarget(undefined);
this.map = null;
}
}
private handleFeatureEvent(
event: VectorSourceEvent<Feature<Geometry>>
): void {
if (!event.feature) return;
const feature = event.feature;
if (!feature.getId()) {
feature.setId(Date.now() + Math.random().toString(36).substr(2, 9));
}
this.updateFeaturesInReact();
}
private handleFeatureChange(
event: VectorSourceEvent<Feature<Geometry>>
): void {
if (!event.feature) return;
this.updateFeaturesInReact();
}
public activateLasso() {
if (this.lassoInteraction && this.map) {
this.lassoInteraction.setActive(true);
this.setMode("lasso");
}
}
public deactivateLasso() {
if (this.lassoInteraction && this.map) {
this.lassoInteraction.setActive(false);
this.setMode("edit");
}
}
public setSelectedIds(ids: Set<string | number>) {
this.selectedIds = new Set(ids);
if (this.onSelectionChange) this.onSelectionChange(this.selectedIds);
this.vectorLayer.changed();
}
public getSelectedIds() {
return new Set(this.selectedIds);
}
public setOnSelectionChange(cb: (ids: Set<string | number>) => void) {
this.onSelectionChange = cb;
}
public toggleLasso() {
if (this.mode === "lasso") this.deactivateLasso();
else this.activateLasso();
}
public getMap(): Map | null {
return this.map;
}
private async saveModifiedFeature(feature: Feature<Geometry>) {
const featureType = feature.get("featureType") as FeatureType;
const featureId = feature.getId();
if (!featureType || featureId === undefined || !this.map) return;
if (typeof featureId === "number" || !String(featureId).includes("-")) {
console.warn(
"Skipping save for feature with non-standard ID:",
featureId
);
return;
}
const geoJSONFormat = new GeoJSON({
dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection(),
});
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
try {
await mapStore.updateFeature(featureType, featureGeoJSON);
toast.success(`"${feature.get("name")}" успешно обновлен.`);
} catch (error) {
console.error("Failed to update feature:", error);
toast.error(
`Не удалось обновить "${feature.get("name")}". Отмена изменений...`
);
this.undo();
}
}
private async saveNewFeature(feature: Feature<Geometry>) {
const featureType = feature.get("featureType") as FeatureType;
if (!featureType || !this.map) return;
const geoJSONFormat = new GeoJSON({
dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection(),
});
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
const tempId = feature.getId();
try {
const createdFeatureData = await mapStore.createFeature(
featureType,
featureGeoJSON
);
const newName =
featureType === "route"
? createdFeatureData.route_number
: createdFeatureData.name;
toast.success(`"${newName}" создано.`);
const newFeatureId = `${featureType}-${createdFeatureData.id}`;
feature.setId(newFeatureId);
feature.set("name", newName);
this.updateFeaturesInReact();
this.selectFeature(newFeatureId);
} catch (error) {
console.error("Failed to save new feature:", error);
toast.error("Не удалось сохранить объект.");
if (tempId) {
const tempFeature = this.vectorSource.getFeatureById(tempId);
if (tempFeature) this.vectorSource.removeFeature(tempFeature);
}
this.undo(); // Откатываем состояние до момента начала рисования
}
}
}
// --- MAP CONTROLS COMPONENT ---
interface MapControlsProps {
mapService: MapService | null;
activeMode: string;
isLassoActive: boolean;
isUnselectDisabled: boolean;
}
interface ControlItem {
mode: string;
title: string;
longTitle: string;
icon: React.ReactNode;
action: () => void;
isActive?: boolean;
disabled?: boolean;
}
const MapControls: React.FC<MapControlsProps> = ({
mapService,
activeMode,
isLassoActive,
isUnselectDisabled,
}) => {
if (!mapService) return null;
const controls: ControlItem[] = [
{
mode: "edit",
title: "Редактировать",
longTitle: "Редактирование",
icon: <EditIcon />,
action: () => mapService.activateEditMode(),
},
{
mode: "drawing-station",
title: "Станция",
longTitle: "Добавить станцию",
icon: <Bus size={16} className="mr-1 sm:mr-2" />,
action: () => mapService.startDrawingMarker(),
},
{
mode: "drawing-sight",
title: "Достопримечательность",
longTitle: "Добавить достопримечательность",
icon: <Landmark size={16} className="mr-1 sm:mr-2" />,
action: () => mapService.startDrawingSight(),
},
{
mode: "drawing-route",
title: "Маршрут",
longTitle: "Добавить маршрут (Правый клик для завершения)",
icon: <LineIconSvg />,
action: () => mapService.startDrawingLine(),
},
{
mode: "unselect",
title: "Сбросить",
longTitle: "Сбросить выделение (Esc)",
icon: <X size={16} className="mr-1 sm:mr-2" />,
action: () => mapService.unselect(),
disabled: isUnselectDisabled,
},
];
return (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex flex-nowrap justify-center p-2 bg-white/90 backdrop-blur-sm rounded-lg shadow-xl space-x-1 sm:space-x-2">
{controls.map((c) => {
const isActive =
c.isActive !== undefined ? c.isActive : activeMode === c.mode;
const isDisabled = c.disabled;
const buttonClasses = `flex items-center px-3 py-2 rounded-md transition-all duration-200 text-xs sm:text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 focus:ring-opacity-75 ${
isDisabled
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: isActive
? "bg-blue-600 text-white shadow-md hover:bg-blue-700"
: "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700"
}`;
return (
<button
key={c.mode}
className={buttonClasses}
onClick={c.action}
title={c.longTitle}
disabled={isDisabled}
>
{c.icon}
<span className="hidden sm:inline ml-0 sm:ml-1">{c.title}</span>
</button>
);
})}
</div>
);
};
// --- MAP SIGHTBAR COMPONENT ---
interface MapSightbarProps {
mapService: MapService | null;
mapFeatures: Feature<Geometry>[];
selectedFeature: Feature<Geometry> | null;
selectedIds: Set<string | number>;
setSelectedIds: (ids: Set<string | number>) => void;
activeSection: string | null;
setActiveSection: (section: string | null) => void;
}
const MapSightbar: React.FC<MapSightbarProps> = ({
mapService,
mapFeatures,
selectedFeature,
selectedIds,
setSelectedIds,
activeSection,
setActiveSection,
}) => {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
const filteredFeatures = useMemo(() => {
if (!searchQuery.trim()) return mapFeatures;
return mapFeatures.filter((feature) =>
((feature.get("name") as string) || "")
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}, [mapFeatures, searchQuery]);
const handleFeatureClick = useCallback(
(id) => mapService?.selectFeature(id),
[mapService]
);
const handleDeleteFeature = useCallback(
(id, recourse) => {
if (
mapService &&
window.confirm("Вы действительно хотите удалить этот объект?")
) {
mapService.deleteFeature(id, recourse);
}
},
[mapService]
);
const handleCheckboxChange = useCallback(
(id) => {
if (id === undefined) return;
const newSet = new Set(selectedIds);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
setSelectedIds(newSet);
},
[selectedIds, setSelectedIds]
);
const handleBulkDelete = useCallback(() => {
if (!mapService || selectedIds.size === 0) return;
if (
window.confirm(
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`
)
) {
mapService.deleteMultipleFeatures(Array.from(selectedIds));
setSelectedIds(new Set());
}
}, [mapService, selectedIds, setSelectedIds]);
const handleEditFeature = useCallback(
(featureType, fullId) => {
if (!featureType || !fullId) return;
const numericId = String(fullId).split("-")[1];
if (numericId) navigate(`/${featureType}/${numericId}/edit`);
},
[navigate]
);
const sortFeatures = (
features,
currentSelectedIds,
currentSelectedFeature
) => {
const selectedId = currentSelectedFeature?.getId();
return [...features].sort((a, b) => {
const aId = a.getId(),
bId = b.getId();
if (selectedId && aId === selectedId) return -1;
if (selectedId && bId === selectedId) return 1;
const aSelected = aId !== undefined && currentSelectedIds.has(aId);
const bSelected = bId !== undefined && currentSelectedIds.has(bId);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
return ((a.get("name") as string) || "").localeCompare(
(b.get("name") as string) || "",
"ru"
);
});
};
const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
const stations = filteredFeatures.filter(
(f) => f.get("featureType") === "station"
);
const lines = filteredFeatures.filter(
(f) => f.get("featureType") === "route"
);
const sights = filteredFeatures.filter(
(f) => f.get("featureType") === "sight"
);
const sortedStations = sortFeatures(stations, selectedIds, selectedFeature);
const sortedLines = sortFeatures(lines, selectedIds, selectedFeature);
const sortedSights = sortFeatures(sights, selectedIds, selectedFeature);
const sections = [
{
id: "layers",
title: `Остановки (${sortedStations.length})`,
icon: <Bus size={20} />,
count: sortedStations.length,
content: (
<div className="space-y-1 max-h-[500px] overflow-y-auto pr-1">
{sortedStations.length > 0 ? (
sortedStations.map((s) => {
const sId = s.getId(),
sName = (s.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === sId,
isChecked = sId !== undefined && selectedIds.has(sId);
return (
<div
key={String(sId)}
data-feature-id={sId}
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected
? "bg-orange-100 border border-orange-300"
: "hover:bg-blue-50"
}`}
>
<div className="flex-shrink-0 pr-2 pt-1">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={!!isChecked}
onChange={() => handleCheckboxChange(sId)}
onClick={(e) => e.stopPropagation()}
aria-label={`Выбрать ${sName}`}
/>
</div>
<div
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
onClick={() => handleFeatureClick(sId)}
>
<div className="flex items-center">
<MapPin
size={16}
className={`mr-1.5 flex-shrink-0 ${
isSelected
? "text-orange-500"
: "text-blue-500 group-hover:text-blue-600"
}`}
/>
<span
className={`font-medium truncate ${
isSelected
? "text-orange-600"
: "group-hover:text-blue-600"
}`}
title={sName}
>
{sName}
</span>
</div>
</div>
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
handleEditFeature(s.get("featureType"), sId);
}}
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
title="Редактировать детали"
>
<Pencil size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(sId, "station");
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors"
title="Удалить"
>
<Trash2 size={16} />
</button>
</div>
</div>
);
})
) : (
<p className="text-gray-500 text-sm">Нет остановок.</p>
)}
</div>
),
},
{
id: "lines",
title: `Маршруты (${sortedLines.length})`,
icon: <RouteIcon size={20} />,
count: sortedLines.length,
content: (
<div className="space-y-1 max-h-60 overflow-y-auto pr-1">
{sortedLines.length > 0 ? (
sortedLines.map((l) => {
const lId = l.getId(),
lName = (l.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === lId,
isChecked = lId !== undefined && selectedIds.has(lId);
return (
<div
key={String(lId)}
data-feature-id={lId}
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected
? "bg-orange-100 border border-orange-300"
: "hover:bg-blue-50"
}`}
>
<div className="flex-shrink-0 pr-2 pt-1">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={!!isChecked}
onChange={() => handleCheckboxChange(lId)}
onClick={(e) => e.stopPropagation()}
aria-label={`Выбрать ${lName}`}
/>
</div>
<div
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
onClick={() => handleFeatureClick(lId)}
>
<div className="flex items-center">
<ArrowRightLeft
size={16}
className={`mr-1.5 flex-shrink-0 ${
isSelected
? "text-orange-500"
: "text-purple-500 group-hover:text-purple-600"
}`}
/>
<span
className={`font-medium truncate ${
isSelected
? "text-orange-600"
: "group-hover:text-purple-600"
}`}
title={lName}
>
{lName}
</span>
</div>
</div>
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
handleEditFeature(l.get("featureType"), lId);
}}
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
title="Редактировать детали"
>
<Pencil size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(lId, "route");
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors"
title="Удалить"
>
<Trash2 size={16} />
</button>
</div>
</div>
);
})
) : (
<p className="text-gray-500 text-sm">Нет маршрутов.</p>
)}
</div>
),
},
{
id: "sights",
title: `Достопримечательности (${sortedSights.length})`,
icon: <Landmark size={20} />,
count: sortedSights.length,
content: (
<div className="space-y-1 max-h-60 overflow-y-auto pr-1">
{sortedSights.length > 0 ? (
sortedSights.map((s) => {
const sId = s.getId(),
sName = (s.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === sId,
isChecked = sId !== undefined && selectedIds.has(sId);
return (
<div
key={String(sId)}
data-feature-id={sId}
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
isSelected
? "bg-orange-100 border border-orange-300"
: "hover:bg-blue-50"
}`}
>
<div className="flex-shrink-0 pr-2 pt-1">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={!!isChecked}
onChange={() => handleCheckboxChange(sId)}
onClick={(e) => e.stopPropagation()}
aria-label={`Выбрать ${sName}`}
/>
</div>
<div
className="flex items-center text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
onClick={() => handleFeatureClick(sId)}
>
<Landmark
size={16}
className={`mr-1.5 flex-shrink-0 ${
isSelected
? "text-orange-500"
: "text-purple-500 group-hover:text-purple-600"
}`}
/>
<span
className={`font-medium truncate ${
isSelected
? "text-orange-600"
: "group-hover:text-purple-600"
}`}
title={sName}
>
{sName}
</span>
</div>
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
handleEditFeature(s.get("featureType"), sId);
}}
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
title="Редактировать детали"
>
<Pencil size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteFeature(sId, "sight");
}}
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors"
title="Удалить"
>
<Trash2 size={16} />
</button>
</div>
</div>
);
})
) : (
<p className="text-gray-500 text-sm">Нет достопримечательностей.</p>
)}
</div>
),
},
];
return (
<div className="w-[360px] relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
<div className="p-4 bg-gray-700 text-white">
<h2 className="text-lg font-semibold">Панель управления</h2>
</div>
<div className="p-3 border-b border-gray-200 bg-white">
<input
type="text"
placeholder="Поиск по названию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto">
{filteredFeatures.length === 0 && searchQuery ? (
<div className="p-4 text-center text-gray-500">
Ничего не найдено.
</div>
) : (
sections.map(
(s) =>
(s.count > 0 || !searchQuery) && (
<div
key={s.id}
className="border-b border-gray-200 last:border-b-0"
>
<button
onClick={() => toggleSection(s.id)}
className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 ${
activeSection === s.id
? "bg-gray-100 text-blue-600"
: "text-gray-700"
}`}
>
<div className="flex items-center space-x-3">
<span
className={
activeSection === s.id
? "text-blue-600"
: "text-gray-600"
}
>
{s.icon}
</span>
<span className="font-medium text-sm">{s.title}</span>
</div>
<span
className={`transform transition-transform duration-200 text-gray-500 ${
activeSection === s.id ? "rotate-180" : ""
}`}
>
</span>
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
activeSection === s.id
? "max-h-[600px] opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="p-3 text-sm text-gray-600 bg-white border-t border-gray-100">
{s.content}
</div>
</div>
</div>
)
)
)}
</div>
</div>
<div className="p-3 border-t border-gray-200 bg-gray-50/95 space-y-2">
{selectedIds.size > 0 && (
<button
onClick={handleBulkDelete}
className="w-full flex items-center justify-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
>
<Trash2 size={16} className="mr-2" />
Удалить выбранное ({selectedIds.size})
</button>
)}
</div>
</div>
);
};
// --- MAP PAGE COMPONENT ---
export const MapPage: React.FC = () => {
const mapRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [mapServiceInstance, setMapServiceInstance] =
useState<MapService | null>(null);
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [isDataLoading, setIsDataLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [currentMapMode, setCurrentMapMode] = useState<string>("edit");
const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]);
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
useState<Feature<Geometry> | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
new Set()
);
const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
const [showHelp, setShowHelp] = useState<boolean>(false);
const [activeSectionFromParent, setActiveSectionFromParent] = useState<
string | null
>("layers");
const handleFeaturesChange = useCallback(
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
[]
);
const handleFeatureSelectForSidebar = useCallback(
(feat: Feature<Geometry> | null) => {
setSelectedFeatureForSidebar(feat);
if (feat) {
const featureType = feat.get("featureType");
const sectionId =
featureType === "sight"
? "sights"
: featureType === "route"
? "lines"
: "layers";
setActiveSectionFromParent(sectionId);
setTimeout(() => {
document
.querySelector(`[data-feature-id="${feat.getId()}"]`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
}
},
[]
);
const handleMapClick = useCallback(
(event: any) => {
if (!mapServiceInstance || isLassoActive) return;
const ctrlKey =
event.originalEvent.ctrlKey || event.originalEvent.metaKey;
mapServiceInstance.handleMapClick(event, ctrlKey);
},
[mapServiceInstance, isLassoActive]
);
useEffect(() => {
let service: MapService | null = null;
if (mapRef.current && tooltipRef.current && !mapServiceInstance) {
setIsMapLoading(true);
setIsDataLoading(true);
setError(null);
const loadInitialData = async (mapService: MapService) => {
try {
await Promise.all([
mapStore.getRoutes(),
mapStore.getStations(),
mapStore.getSights(),
]);
mapService.loadFeaturesFromApi(
mapStore.stations,
mapStore.routes,
mapStore.sights
);
} catch (e) {
console.error("Failed to load initial map data:", e);
setError("Не удалось загрузить данные для карты.");
} finally {
setIsDataLoading(false);
}
};
try {
service = new MapService(
{ ...mapConfig, target: mapRef.current },
setIsMapLoading,
setError,
setCurrentMapMode,
handleFeaturesChange,
handleFeatureSelectForSidebar,
tooltipRef.current,
setSelectedIds
);
setMapServiceInstance(service);
loadInitialData(service);
} catch (e: any) {
setError(
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`
);
setIsMapLoading(false);
setIsDataLoading(false);
}
}
return () => {
service?.destroy();
setMapServiceInstance(null);
};
}, []);
useEffect(() => {
const olMap = mapServiceInstance?.getMap();
if (olMap) {
olMap.on("click", handleMapClick);
return () => {
olMap.un("click", handleMapClick);
};
}
}, [mapServiceInstance, handleMapClick]);
useEffect(() => {
mapServiceInstance?.setOnSelectionChange(setSelectedIds);
}, [mapServiceInstance]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift" && mapServiceInstance) {
mapServiceInstance.activateLasso();
setIsLassoActive(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Shift" && mapServiceInstance) {
mapServiceInstance.deactivateLasso();
setIsLassoActive(false);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [mapServiceInstance]);
useEffect(() => {
if (mapServiceInstance) {
mapServiceInstance.toggleLasso = function () {
if (currentMapMode === "lasso") {
this.deactivateLasso();
setIsLassoActive(false);
} else {
this.activateLasso();
setIsLassoActive(true);
}
};
}
}, [mapServiceInstance, currentMapMode]);
const showLoader = isMapLoading || isDataLoading;
const showContent = mapServiceInstance && !showLoader && !error;
const isAnythingSelected =
selectedFeatureForSidebar !== null || selectedIds.size > 0;
return (
<div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
<div className="relative flex-grow flex">
<div
ref={mapRef}
id="map"
className="flex-1 h-full relative bg-gray-200"
>
<div
ref={tooltipRef}
className="tooltip ol-tooltip bg-white text-black p-2 rounded shadow-lg text-xs whitespace-nowrap"
style={{ position: "absolute", pointerEvents: "none" }}
></div>
{showLoader && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-500 bg-opacity-50 z-[1001]">
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-3" />
<div className="text-md font-semibold text-white drop-shadow-md">
{isMapLoading ? "Загрузка карты..." : "Загрузка данных..."}
</div>
</div>
)}
{error && !showLoader && (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-red-100 border border-red-400 text-red-700 p-6 rounded-lg shadow-lg z-[1002] text-center max-w-md">
<h3 className="font-semibold text-lg mb-2">Произошла ошибка</h3>
<p className="text-sm">{error}</p>
<button
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"
>
Перезагрузить
</button>
</div>
)}
{isLassoActive && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white py-2 px-4 rounded-full shadow-lg text-sm font-medium z-20">
Режим выделения области.
</div>
)}
</div>
{showContent && (
<MapControls
mapService={mapServiceInstance}
activeMode={currentMapMode}
isLassoActive={isLassoActive}
isUnselectDisabled={!isAnythingSelected}
/>
)}
<button
onClick={() => setShowHelp(!showHelp)}
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
title="Помощь по клавишам"
>
<InfoIcon size={20} />
</button>
{showHelp && (
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs">
<h4 className="font-bold mb-2">Горячие клавиши:</h4>
<ul className="text-sm space-y-2">
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Shift
</span>{" "}
- Режим выделения (лассо)
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Ctrl + клик
</span>{" "}
- Добавить/убрать из выделения
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "}
- Отменить выделение
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Ctrl+Z
</span>{" "}
- Отменить действие
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Ctrl+Y
</span>{" "}
- Повторить действие
</li>
</ul>
<button
onClick={() => setShowHelp(false)}
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
>
Закрыть
</button>
</div>
)}
</div>
{showContent && (
<MapSightbar
mapService={mapServiceInstance}
mapFeatures={mapFeatures}
selectedFeature={selectedFeatureForSidebar}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
activeSection={activeSectionFromParent}
setActiveSection={setActiveSectionFromParent}
/>
)}
</div>
);
};