2198 lines
72 KiB
TypeScript
2198 lines
72 KiB
TypeScript
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>
|
||
);
|
||
};
|