Files
WhiteNightsAdminPanel/src/pages/MapPage/index.tsx

2811 lines
90 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,
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 Cluster from "ol/source/Cluster";
import {
Draw,
Modify,
Select,
defaults as defaultInteractions,
} from "ol/interaction";
import { DrawEvent } from "ol/interaction/Draw";
import { SelectEvent } from "ol/interaction/Select";
import {
Style,
Fill,
Stroke,
Circle as CircleStyle,
RegularShape,
Text,
} from "ol/style";
import { Point, LineString, Geometry, Polygon } from "ol/geom";
import { transform, toLonLat } from "ol/proj";
import { GeoJSON } from "ol/format";
import {
Bus,
RouteIcon,
MapPin,
Trash2,
ArrowRightLeft,
Landmark,
Pencil,
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";
import { createEmpty, extend, getCenter } from "ol/extent";
// --- CUSTOM SCROLLBAR STYLES ---
const scrollbarHideStyles = `
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
`;
if (typeof document !== "undefined") {
const styleElement = document.createElement("style");
styleElement.textContent = scrollbarHideStyles;
document.head.appendChild(styleElement);
}
// --- MAP STORE ---
// @ts-ignore
import { languageInstance } from "@shared";
import { makeAutoObservable } from "mobx";
import { stationsStore, routeStore, sightsStore } from "@shared";
interface ApiRoute {
id: number;
route_number: string;
path: [number, number][];
center_latitude: number;
center_longitude: number;
}
interface ApiStation {
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");
const routesIds = response.data.map((route: any) => route.id);
const routePromises = routesIds.map((id: number) =>
languageInstance("ru").get(`/route/${id}`)
);
const routeResponses = await Promise.all(routePromises);
this.routes = routeResponses.map((res) => res.data);
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) => ({
...station,
}));
};
getSights = async () => {
const sights = await languageInstance("ru").get("/sight");
this.sights = sights.data.map((sight: any) => ({
...sight,
}));
};
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 createdItem;
if (featureType === "station") {
const name = properties.name || "Остановка 1";
const latitude = geometry.coordinates[1];
const longitude = geometry.coordinates[0];
stationsStore.setLanguageCreateStationData("ru", {
name,
address: "",
system_name: name,
});
stationsStore.setLanguageCreateStationData("en", {
name,
address: "",
system_name: name,
});
stationsStore.setLanguageCreateStationData("zh", {
name,
address: "",
system_name: name,
});
stationsStore.setCreateCommonData({ latitude, longitude, city_id: 1 });
await stationsStore.createStation();
createdItem =
stationsStore.stationLists["ru"].data[
stationsStore.stationLists["ru"].data.length - 1
];
} else if (featureType === "route") {
const route_number = properties.name || "Маршрут 1";
const path = geometry.coordinates.map((c: any) => [c[1], c[0]]);
const lineGeom = new GeoJSON().readGeometry(geometry, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
const centerCoords = getCenter(lineGeom.getExtent());
const [center_longitude, center_latitude] = toLonLat(
centerCoords,
"EPSG:3857"
);
const routeData = {
route_number,
path,
center_latitude,
center_longitude,
carrier: "",
carrier_id: 0,
governor_appeal: 0,
rotate: 0,
route_direction: false,
route_sys_number: route_number,
scale_max: 0,
scale_min: 0,
};
await routeStore.createRoute(routeData);
createdItem = routeStore.routes.data[routeStore.routes.data.length - 1];
} else if (featureType === "sight") {
const name = properties.name || "Достопримечательность 1";
const latitude = geometry.coordinates[1];
const longitude = geometry.coordinates[0];
sightsStore.updateCreateSight("ru", { name, address: "" });
sightsStore.updateCreateSight("en", { name, address: "" });
sightsStore.updateCreateSight("zh", { name, address: "" });
await sightsStore.createSightAction(1, { latitude, longitude });
createdItem = sightsStore.sights[sightsStore.sights.length - 1];
} else {
throw new Error(`Unknown feature type for creation: ${featureType}`);
}
// @ts-ignore
if (featureType === "route") this.routes.push(createdItem);
// @ts-ignore
else if (featureType === "station") this.stations.push(createdItem);
// @ts-ignore
else if (featureType === "sight") this.sights.push(createdItem);
return createdItem;
};
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") {
const lineGeom = new GeoJSON().readGeometry(geometry, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
const centerCoords = getCenter(lineGeom.getExtent());
const [center_longitude, center_latitude] = toLonLat(
centerCoords,
"EPSG:3857"
);
data = {
route_number: properties.name,
path: geometry.coordinates.map((coord: any) => [coord[1], coord[0]]),
center_latitude,
center_longitude,
};
} else if (featureType === "sight") {
data = {
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 findOldData = (store: any[], id: number) =>
store.find((f: any) => f.id === id);
let oldData;
if (featureType === "route") oldData = findOldData(this.routes, numericId);
else if (featureType === "station")
oldData = findOldData(this.stations, numericId);
else if (featureType === "sight")
oldData = findOldData(this.sights, numericId);
if (!oldData) {
if (properties.isProxy) {
return;
}
throw new Error(
`Could not find old data for ${featureType} with id ${numericId}`
);
}
let requestBody: any;
if (featureType === "route") {
requestBody = {
...oldData,
...data,
};
} else {
requestBody = { ...oldData, ...data };
}
if (properties.isProxy) {
return requestBody;
}
const response = await languageInstance("ru").patch(
`/${featureType}/${numericId}`,
requestBody
);
const updateStore = (store: any[], updatedItem: any) => {
const index = store.findIndex((f) => f.id === updatedItem.id);
if (index !== -1) store[index] = updatedItem;
else store.push(updatedItem);
};
if (featureType === "route") updateStore(this.routes, response.data);
else if (featureType === "station")
updateStore(this.stations, response.data);
else if (featureType === "sight") updateStore(this.sights, response.data);
return response.data;
};
}
const mapStore = new MapStore();
// --- CONFIGURATION ---
export const mapConfig = {
center: [30.311, 59.94] as [number, number],
zoom: 13,
};
// --- MAP POSITION STORAGE ---
const MAP_POSITION_KEY = "mapPosition";
const ACTIVE_SECTION_KEY = "mapActiveSection";
interface MapPosition {
center: [number, number];
zoom: number;
}
const getStoredMapPosition = (): MapPosition | null => {
try {
const stored = localStorage.getItem(MAP_POSITION_KEY);
if (stored) {
const position = JSON.parse(stored);
if (
position &&
Array.isArray(position.center) &&
position.center.length === 2 &&
typeof position.zoom === "number" &&
position.zoom >= 0 &&
position.zoom <= 20
) {
return position;
}
}
} catch (error) {
console.warn("Failed to parse stored map position:", error);
}
return null;
};
const saveMapPosition = (position: MapPosition): void => {
try {
localStorage.setItem(MAP_POSITION_KEY, JSON.stringify(position));
} catch (error) {
console.warn("Failed to save map position:", error);
}
};
// --- ACTIVE SECTION STORAGE ---
const getStoredActiveSection = (): string | null => {
try {
const stored = localStorage.getItem(ACTIVE_SECTION_KEY);
if (stored) {
return stored;
}
} catch (error) {
console.warn("Failed to get stored active section:", error);
}
return null;
};
const saveActiveSection = (section: string | null): void => {
try {
if (section) {
localStorage.setItem(ACTIVE_SECTION_KEY, section);
} else {
localStorage.removeItem(ACTIVE_SECTION_KEY);
}
} catch (error) {
console.warn("Failed to save active section:", error);
}
};
// --- 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 {
state: string;
}
type FeatureType = "station" | "route" | "sight";
class MapService {
private map: Map | null;
public pointSource: VectorSource<Feature<Point>>;
public lineSource: VectorSource<Feature<LineString>>;
public clusterLayer: VectorLayer<Cluster>; // Public for the deselect handler
public routeLayer: VectorLayer<VectorSource<Feature<LineString>>>; // Public for deselect
private clusterSource: Cluster;
private clusterStyleCache: { [key: number]: Style };
private unclusteredRouteIds: Set<string | number> = new Set();
private tooltipElement: HTMLElement;
private tooltipOverlay: Overlay | null;
private mode: string | null;
// @ts-ignore
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 beforeActionState: string | null = 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 routeIconStyle: Style;
private selectedRouteIconStyle: 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.clusterStyleCache = {};
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: 8 }),
});
this.selectedStyle = new Style({
fill: new Fill({ color: "rgba(221, 107, 32, 0.3)" }),
stroke: new Stroke({ color: "#dd6b20", width: 8 }),
});
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: 8,
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.routeIconStyle = new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: "rgba(34, 197, 94, 0.8)" }), // Green
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
}),
});
this.selectedRouteIconStyle = new Style({
image: new CircleStyle({
radius: 10,
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }), // Orange on select
stroke: new Stroke({ color: "#ffffff", width: 2 }),
}),
});
this.sightIconStyle = new Style({
image: new RegularShape({
fill: new Fill({ color: "rgba(139, 92, 246, 0.8)" }),
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: 8 }),
zIndex: Infinity,
});
this.pointSource = new VectorSource();
this.lineSource = new VectorSource();
this.clusterSource = new Cluster({
distance: 45,
source: this.pointSource,
});
this.routeLayer = new VectorLayer({
source: this.lineSource,
// @ts-ignore
style: (featureLike: FeatureLike) => {
const feature = featureLike as Feature<Geometry>;
if (!feature) return this.defaultStyle;
const fId = feature.getId();
if (fId === undefined || !this.unclusteredRouteIds.has(fId)) {
return null;
}
const isSelected =
this.selectInteraction?.getFeatures().getArray().includes(feature) ||
(fId !== undefined && this.selectedIds.has(fId));
const isHovered = this.hoveredFeatureId === fId;
if (isHovered) return this.universalHoverStyleLine;
if (isSelected) return this.selectedStyle;
return this.defaultStyle;
},
});
this.clusterLayer = new VectorLayer({
source: this.clusterSource,
style: (featureLike: FeatureLike) => {
const clusterFeature = featureLike as Feature<Point>;
const featuresInCluster = clusterFeature.get(
"features"
) as Feature<Point>[];
const size = featuresInCluster.length;
if (size > 1) {
let style = this.clusterStyleCache[size];
if (!style) {
style = new Style({
image: new CircleStyle({
radius: 12 + Math.log(size) * 3,
fill: new Fill({ color: "rgba(56, 189, 248, 0.9)" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
}),
text: new Text({
text: size.toString(),
fill: new Fill({ color: "#fff" }),
font: "bold 12px sans-serif",
}),
});
this.clusterStyleCache[size] = style;
}
return style;
} else {
const originalFeature = featuresInCluster[0];
const fId = originalFeature.getId();
const featureType = originalFeature.get("featureType");
const isProxy = originalFeature.get("isProxy");
if (isProxy) return new Style(); // Invisible empty style
const isSelected = fId !== undefined && this.selectedIds.has(fId);
const isHovered = this.hoveredFeatureId === fId;
if (isHovered) {
return featureType === "sight"
? this.hoverSightIconStyle
: this.universalHoverStylePoint;
}
if (isSelected) {
if (featureType === "sight") return this.selectedSightIconStyle;
if (featureType === "route") return this.selectedRouteIconStyle;
return this.selectedBusIconStyle;
}
if (featureType === "sight") return this.sightIconStyle;
if (featureType === "route") return this.routeIconStyle;
return this.busIconStyle;
}
},
});
this.clusterSource.on("change", () => {
const newUnclusteredRouteIds = new Set<string | number>();
this.clusterSource
.getFeatures()
.forEach((clusterFeature: Feature<any>) => {
const originalFeatures = clusterFeature.get(
"features"
) as Feature<Point>[];
if (originalFeatures && originalFeatures.length === 1) {
const originalFeature = originalFeatures[0];
if (originalFeature.get("featureType") === "route") {
const featureId = originalFeature.getId();
if (featureId !== undefined) {
newUnclusteredRouteIds.add(featureId);
}
}
}
});
if (
newUnclusteredRouteIds.size !== this.unclusteredRouteIds.size ||
![...newUnclusteredRouteIds].every((id) =>
this.unclusteredRouteIds.has(id)
)
) {
this.unclusteredRouteIds = newUnclusteredRouteIds;
this.routeLayer.changed();
}
});
this.boundHandlePointerMove = this.handlePointerMove.bind(this);
this.boundHandlePointerLeave = this.handlePointerLeave.bind(this);
this.boundHandleContextMenu = this.handleContextMenu.bind(this);
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.pointSource.on(
"addfeature",
this.handleFeatureEvent.bind(this) as any
);
this.pointSource.on("removefeature", () => this.updateFeaturesInReact());
this.pointSource.on(
"changefeature",
this.handleFeatureChange.bind(this) as any
);
this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any);
this.lineSource.on("removefeature", () => this.updateFeaturesInReact());
this.lineSource.on(
"changefeature",
this.handleFeatureChange.bind(this) as any
);
let renderCompleteHandled = false;
const MAP_LOAD_TIMEOUT = 15000;
try {
const storedPosition = getStoredMapPosition();
const initialCenter = storedPosition?.center || config.center;
const initialZoom = storedPosition?.zoom || config.zoom;
this.map = new Map({
target: config.target,
layers: [
new TileLayer({ source: new OSM() }),
this.routeLayer,
this.clusterLayer,
],
view: new View({
center: transform(initialCenter, "EPSG:4326", "EPSG:3857"),
zoom: initialZoom,
}),
interactions: defaultInteractions({ doubleClickZoom: false }),
controls: [],
});
this.map.getView().on("change:center", () => {
const center = this.map?.getView().getCenter();
const zoom = this.map?.getView().getZoom();
if (center && zoom !== undefined && this.map) {
const [lon, lat] = toLonLat(
center,
this.map.getView().getProjection()
);
saveMapPosition({ center: [lon, lat], zoom });
}
});
this.map.getView().on("change:resolution", () => {
const center = this.map?.getView().getCenter();
const zoom = this.map?.getView().getZoom();
if (center && zoom !== undefined && this.map) {
const [lon, lat] = toLonLat(
center,
this.map.getView().getProjection()
);
saveMapPosition({ center: [lon, lat], zoom });
}
});
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.selectInteraction = new Select({
style: null,
condition: singleClick,
filter: (feature: FeatureLike, l: Layer<Source, any> | null) => {
if (l !== this.clusterLayer && l !== this.routeLayer) return false;
const originalFeatures = feature.get("features");
if (
originalFeatures &&
originalFeatures.length === 1 &&
originalFeatures[0].get("isProxy")
)
return false; // Ignore proxy points
return true;
},
multi: true,
});
this.modifyInteraction = new Modify({
features: this.selectInteraction.getFeatures(),
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>) => {
if (!doubleClick(e)) {
return false;
}
const selectedFeatures = this.selectInteraction.getFeatures();
if (selectedFeatures.getLength() !== 1) {
return true;
}
const feature = selectedFeatures.item(0) as Feature<Geometry>;
const geometry = feature.getGeometry();
if (!geometry || geometry.getType() !== "LineString") {
return true;
}
const lineString = geometry as LineString;
const coordinates = lineString.getCoordinates();
if (coordinates.length <= 2) {
toast.info("В маршруте должно быть не менее 2 точек.");
return false;
}
const clickCoordinate = e.coordinate;
let closestVertexIndex = -1;
let minDistanceSq = Infinity;
coordinates.forEach((vertex, index) => {
const dx = vertex[0] - clickCoordinate[0];
const dy = vertex[1] - clickCoordinate[1];
const distanceSq = dx * dx + dy * dy;
if (distanceSq < minDistanceSq) {
minDistanceSq = distanceSq;
closestVertexIndex = index;
}
});
if (
closestVertexIndex === 0 ||
closestVertexIndex === coordinates.length - 1
) {
return false;
}
return true;
},
});
// @ts-ignore
this.modifyInteraction.on("modifystart", () => {
if (!this.beforeActionState) {
this.beforeActionState = this.getCurrentStateAsGeoJSON();
}
});
this.modifyInteraction.on("modifyend", (event) => {
if (this.beforeActionState) {
this.addStateToHistory(this.beforeActionState);
}
event.features.getArray().forEach((feature) => {
this.saveModifiedFeature(feature as Feature<Geometry>);
});
this.beforeActionState = null;
});
if (this.map) {
this.map.on("dblclick", (event: MapBrowserEvent<any>) => {
if (this.mode !== "edit") return;
const layerFilter = (l: Layer<Source, any>) => l === this.routeLayer;
const feature = this.map?.forEachFeatureAtPixel(
event.pixel,
(f: FeatureLike) => f as Feature<Geometry>,
{ layerFilter, hitTolerance: 5 }
);
if (!feature) return;
const featureType = feature.get("featureType");
if (featureType !== "route") return;
const geometry = feature.getGeometry();
if (!geometry || geometry.getType() !== "LineString") return;
const lineString = geometry as LineString;
const coordinates = lineString.getCoordinates();
if (coordinates.length <= 2) {
toast.info("В маршруте должно быть не менее 2 точек.");
return;
}
const clickCoordinate = event.coordinate;
let closestIndex = -1;
let minDistanceSq = Infinity;
coordinates.forEach((vertex, index) => {
const dx = vertex[0] - clickCoordinate[0];
const dy = vertex[1] - clickCoordinate[1];
const distanceSq = dx * dx + dy * dy;
if (distanceSq < minDistanceSq) {
minDistanceSq = distanceSq;
closestIndex = index;
}
});
if (closestIndex === 0 || closestIndex === coordinates.length - 1) {
return;
}
const beforeState = this.getCurrentStateAsGeoJSON();
if (beforeState) {
this.addStateToHistory(beforeState);
}
const newCoordinates = coordinates.filter(
(_, index) => index !== closestIndex
);
lineString.setCoordinates(newCoordinates);
this.saveModifiedFeature(feature);
});
}
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.pointSource.forEachFeatureInExtent(extent, (f) => {
if (f.get("isProxy")) return; // Ignore proxy in lasso
const geom = f.getGeometry();
if (geom && geom.getType() === "Point") {
const pointCoords = (geom as Point).getCoordinates();
if (geometry.intersectsCoordinate(pointCoords)) {
if (f.getId() !== undefined) selected.add(f.getId()!);
}
}
});
this.lineSource.forEachFeatureInExtent(
extent,
(f: Feature<LineString>) => {
const lineGeom = f.getGeometry();
if (lineGeom) {
const intersects = lineGeom
.getCoordinates()
.some((coord) => geometry.intersectsCoordinate(coord));
if (intersects && f.getId() !== undefined) {
selected.add(f.getId()!);
}
}
}
);
this.setSelectedIds(selected);
this.deactivateLasso();
});
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" || !this.map) return;
const ctrlKey =
e.mapBrowserEvent.originalEvent.ctrlKey ||
e.mapBrowserEvent.originalEvent.metaKey;
// Проверяем, был ли клик по кластеру (группе)
if (e.selected.length === 1 && !ctrlKey) {
const clickedFeature = e.selected[0];
const originalFeatures = clickedFeature.get("features");
if (originalFeatures && originalFeatures.length > 1) {
// Если да, то приближаем карту
const extent = createEmpty();
originalFeatures.forEach((feat: Feature<Point>) => {
const geom = feat.getGeometry();
if (geom) extend(extent, geom.getExtent());
});
this.map.getView().fit(extent, {
duration: 500,
padding: [60, 60, 60, 60],
maxZoom: 18,
});
// Сбрасываем выделение, так как мы не хотим "выделять" сам кластер
this.selectInteraction.getFeatures().clear();
this.setSelectedIds(new Set());
return; // Завершаем обработку
}
}
// Стандартная логика выделения для одиночных объектов (или с Ctrl)
const newSelectedIds = ctrlKey
? new Set(this.selectedIds)
: new Set<string | number>();
e.selected.forEach((feature) => {
const originalFeatures = feature.get("features");
let targetId: string | number | undefined;
if (originalFeatures && originalFeatures.length > 0) {
// Это фича из кластера (может быть и одна)
targetId = originalFeatures[0].getId();
} else {
// Это линия или что-то не из кластера
targetId = feature.getId();
}
if (targetId !== undefined) {
newSelectedIds.add(targetId);
}
});
e.deselected.forEach((feature) => {
const originalFeatures = feature.get("features");
let targetId: string | number | undefined;
if (originalFeatures && originalFeatures.length > 0) {
targetId = originalFeatures[0].getId();
} else {
targetId = feature.getId();
}
if (targetId !== undefined) {
newSelectedIds.delete(targetId);
}
});
this.setSelectedIds(newSelectedIds);
});
this.map.on("pointermove", this.boundHandlePointerMove as any);
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 pointFeatures: Feature<Point>[] = [];
const lineFeatures: Feature<LineString>[] = [];
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");
pointFeatures.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");
pointFeatures.push(feature);
});
apiRoutes.forEach((route) => {
if (!route.path || route.path.length === 0) return;
const coordinates = route.path
.filter((c) => c && c[0] != null && c[1] != null)
.map((c: [number, number]) =>
transform([c[1], c[0]], "EPSG:4326", projection)
);
if (coordinates.length === 0) return;
const routeId = `route-${route.id}`;
const line = new LineString(coordinates);
const lineFeature = new Feature({
geometry: line,
name: route.route_number,
});
lineFeature.setId(routeId);
lineFeature.set("featureType", "route");
lineFeatures.push(lineFeature);
if (route.center_longitude != null && route.center_latitude != null) {
const centerPoint = new Point(
transform(
[route.center_longitude, route.center_latitude],
"EPSG:4326",
projection
)
);
const proxyPointFeature = new Feature({
geometry: centerPoint,
name: route.route_number,
isProxy: true,
});
proxyPointFeature.setId(routeId);
proxyPointFeature.set("featureType", "route");
pointFeatures.push(proxyPointFeature);
}
});
this.pointSource.addFeatures(pointFeatures);
this.lineSource.addFeatures(lineFeatures);
this.updateFeaturesInReact();
const initialState = this.getCurrentStateAsGeoJSON();
if (initialState) {
this.addStateToHistory(initialState);
}
}
private addStateToHistory(stateToSave: string): void {
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push({ state: stateToSave });
this.historyIndex = this.history.length - 1;
}
private getCurrentStateAsGeoJSON(): string | null {
if (!this.map) return null;
const geoJSONFormat = new GeoJSON();
const allFeatures = [
...this.pointSource.getFeatures(),
...this.lineSource.getFeatures(),
];
return geoJSONFormat.writeFeatures(allFeatures, {
dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection().getCode(),
});
}
private applyHistoryState(geoJSONState: string) {
if (!this.map) return;
const projection = this.map.getView().getProjection();
const geoJSONFormat = new GeoJSON({
dataProjection: "EPSG:4326",
featureProjection: projection.getCode(),
});
const features = geoJSONFormat.readFeatures(
geoJSONState
) as Feature<Geometry>[];
this.unselect();
this.pointSource.clear();
this.lineSource.clear();
const pointFeatures: Feature<Point>[] = [];
const lineFeatures: Feature<LineString>[] = [];
features.forEach((feature) => {
const featureType = feature.get("featureType");
const isProxy = feature.get("isProxy");
if (featureType === "route" && !isProxy) {
lineFeatures.push(feature as Feature<LineString>);
} else {
pointFeatures.push(feature as Feature<Point>);
}
});
this.pointSource.addFeatures(pointFeatures);
this.lineSource.addFeatures(lineFeatures);
this.updateFeaturesInReact();
const newStations: ApiStation[] = [];
const newRoutes: ApiRoute[] = [];
const newSights: ApiSight[] = [];
features.forEach((feature) => {
const id = feature.getId();
if (!id || feature.get("isProxy")) return;
const [featureType, numericIdStr] = String(id).split("-");
const numericId = parseInt(numericIdStr, 10);
if (isNaN(numericId)) return;
const geometry = feature.getGeometry();
if (!geometry) return;
const properties = feature.getProperties();
if (featureType === "station") {
const coords = (geometry as Point).getCoordinates();
const [lon, lat] = toLonLat(coords, projection);
newStations.push({
id: numericId,
name: properties.name,
latitude: lat,
longitude: lon,
});
} else if (featureType === "sight") {
const coords = (geometry as Point).getCoordinates();
const [lon, lat] = toLonLat(coords, projection);
newSights.push({
id: numericId,
name: properties.name,
description: properties.description,
latitude: lat,
longitude: lon,
});
} else if (featureType === "route") {
const coords = (geometry as LineString).getCoordinates();
const path = coords.map((c) => {
const [lon, lat] = toLonLat(c, projection);
return [lat, lon] as [number, number];
});
const centerCoords = getCenter(geometry.getExtent());
const [center_longitude, center_latitude] = toLonLat(
centerCoords,
projection
);
newRoutes.push({
id: numericId,
route_number: properties.name,
path: path,
center_latitude,
center_longitude,
});
}
});
mapStore.stations = newStations;
mapStore.routes = newRoutes.sort((a, b) =>
a.route_number.localeCompare(b.route_number)
);
mapStore.sights = newSights;
}
public undo(): void {
if (this.historyIndex > 0) {
this.historyIndex--;
const stateToRestore = this.history[this.historyIndex].state;
this.applyHistoryState(stateToRestore);
const features = [
...this.pointSource.getFeatures().filter((f) => !f.get("isProxy")),
...this.lineSource.getFeatures(),
];
const updatePromises = features.map((feature) => {
const featureType = feature.get("featureType");
const geoJSONFormat = new GeoJSON({
dataProjection: "EPSG:4326",
featureProjection: this.map?.getView().getProjection().getCode(),
});
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
return mapStore.updateFeature(featureType, featureGeoJSON);
});
Promise.all(updatePromises)
.then(() => {})
.catch((error) => {
console.error("Failed to update backend after undo:", error);
this.historyIndex++;
const previousState = this.history[this.historyIndex].state;
this.applyHistoryState(previousState);
});
} else {
toast.info("Больше отменять нечего");
}
}
public redo(): void {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
const stateToRestore = this.history[this.historyIndex].state;
this.applyHistoryState(stateToRestore);
const features = [
...this.pointSource.getFeatures().filter((f) => !f.get("isProxy")),
...this.lineSource.getFeatures(),
];
const updatePromises = features.map((feature) => {
const featureType = feature.get("featureType");
const geoJSONFormat = new GeoJSON({
dataProjection: "EPSG:4326",
featureProjection: this.map?.getView().getProjection().getCode(),
});
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
return mapStore.updateFeature(featureType, featureGeoJSON);
});
Promise.all(updatePromises)
.then(() => {
toast.info("Действие повторено");
})
.catch((error) => {
console.error("Failed to update backend after redo:", error);
toast.error("Не удалось обновить данные на сервере");
this.historyIndex--;
const previousState = this.history[this.historyIndex].state;
this.applyHistoryState(previousState);
});
} else {
toast.info("Больше повторять нечего");
}
}
private updateFeaturesInReact(): void {
if (this.onFeaturesChange) {
const allFeatures = [
...this.pointSource.getFeatures(),
...this.lineSource.getFeatures(),
];
this.onFeaturesChange(allFeatures);
}
}
private handlePointerLeave(): void {
if (this.hoveredFeatureId) {
this.hoveredFeatureId = null;
this.clusterLayer.changed();
this.routeLayer.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.clusterLayer.changed();
this.routeLayer.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;
const sourceForDrawing =
type === "Point" ? this.pointSource : this.lineSource;
this.currentInteraction = new Draw({
source: sourceForDrawing,
type,
style: styleForDrawing,
});
this.currentInteraction.on("drawstart", () => {
this.beforeActionState = this.getCurrentStateAsGeoJSON();
});
this.currentInteraction.on("drawend", async (event: DrawEvent) => {
if (this.beforeActionState) {
this.addStateToHistory(this.beforeActionState);
}
this.beforeActionState = null;
const feature = event.feature as Feature<Geometry>;
const fType = this.currentDrawingFeatureType;
if (!fType) return;
feature.set("featureType", fType);
let resourceName: string;
const allFeatures = [
...this.pointSource.getFeatures(),
...this.lineSource.getFeatures(),
];
switch (fType) {
case "station":
const existingStations = allFeatures.filter(
(f) => f.get("featureType") === "station"
);
const stationNumbers = existingStations
.map((f) => {
const name = f.get("name") as string;
const match = name?.match(/^Остановка (\d+)$/);
return match ? parseInt(match[1], 10) : 0;
})
.filter((num) => num > 0);
const nextStationNumber =
stationNumbers.length > 0 ? Math.max(...stationNumbers) + 1 : 1;
resourceName = `Остановка ${nextStationNumber}`;
break;
case "sight":
const existingSights = allFeatures.filter(
(f) => f.get("featureType") === "sight"
);
const sightNumbers = existingSights
.map((f) => {
const name = f.get("name") as string;
const match = name?.match(/^Достопримечательность (\d+)$/);
return match ? parseInt(match[1], 10) : 0;
})
.filter((num) => num > 0);
const nextSightNumber =
sightNumbers.length > 0 ? Math.max(...sightNumbers) + 1 : 1;
resourceName = `Достопримечательность ${nextSightNumber}`;
break;
case "route":
const existingRoutes = allFeatures.filter(
(f) => f.get("featureType") === "route" && !f.get("isProxy")
);
const routeNumbers = existingRoutes
.map((f) => {
const name = f.get("name") as string;
const match = name?.match(/^Маршрут (\d+)$/);
return match ? parseInt(match[1], 10) : 0;
})
.filter((num) => num > 0);
const nextRouteNumber =
routeNumbers.length > 0 ? Math.max(...routeNumbers) + 1 : 1;
resourceName = `Маршрут ${nextRouteNumber}`;
break;
default:
resourceName = "Объект";
}
feature.set("name", resourceName);
if (fType === "route") {
this.activateEditMode();
}
await this.saveNewFeature(feature);
});
this.map.addInteraction(this.currentInteraction);
}
private handleContextMenu(event: MouseEvent): void {
event.preventDefault();
if (
this.mode?.startsWith("drawing-") &&
this.currentInteraction instanceof Draw
) {
this.finishDrawing();
if (this.currentDrawingType === "LineString") {
this.stopDrawing();
}
}
}
private stopDrawing() {
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;
}
public finishDrawing(): void {
if (!this.currentInteraction) return;
try {
this.currentInteraction.finishDrawing();
} catch (e) {
this.stopDrawing();
}
}
private handlePointerMove(event: MapBrowserEvent<PointerEvent>): void {
if (!this.map || event.dragging) {
if (this.hoveredFeatureId) {
this.hoveredFeatureId = null;
this.clusterLayer.changed();
this.routeLayer.changed();
}
if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
return;
}
const layerFilter = (l: Layer<Source, any>) =>
l === this.clusterLayer || l === this.routeLayer;
const hit = this.map.hasFeatureAtPixel(event.pixel, {
layerFilter,
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, hitTolerance: 5 }
);
let finalFeature: Feature<Geometry> | null = null;
if (featureAtPixel) {
const originalFeatures = featureAtPixel.get("features");
if (originalFeatures && originalFeatures.length > 0) {
if (originalFeatures[0].get("isProxy")) return; // Ignore hover on proxy
finalFeature = originalFeatures[0];
} else {
finalFeature = featureAtPixel;
}
}
const newHoveredFeatureId = finalFeature ? finalFeature.getId() : null;
if (this.tooltipOverlay && this.tooltipElement) {
if (this.mode === "edit" && finalFeature) {
const name = finalFeature.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);
}
}
if (this.hoveredFeatureId !== newHoveredFeatureId) {
this.hoveredFeatureId = newHoveredFeatureId as string | number | null;
this.clusterLayer.changed();
this.routeLayer.changed();
}
}
public selectFeature(featureId: string | number | undefined): void {
if (!this.map || featureId === undefined) {
this.unselect();
return;
}
this.setSelectedIds(new Set([featureId]));
const feature =
this.lineSource.getFeatureById(featureId) ||
this.pointSource.getFeatureById(featureId);
if (!feature) {
this.unselect();
return;
}
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, 16),
});
} else {
view.fit(geometry.getExtent(), {
duration: 500,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
}
}
}
public deleteFeature(
featureId: string | number | undefined,
recourse: string
): void {
if (featureId === undefined) return;
this.beforeActionState = this.getCurrentStateAsGeoJSON();
const numericId = parseInt(String(featureId).split("-")[1], 10);
if (!recourse || isNaN(numericId)) return;
mapStore
.deleteFeature(recourse, numericId)
.then(() => {
if (this.beforeActionState)
this.addStateToHistory(this.beforeActionState);
this.beforeActionState = null;
if (recourse === "route") {
const lineFeature = this.lineSource.getFeatureById(featureId);
if (lineFeature)
this.lineSource.removeFeature(lineFeature as Feature<LineString>);
const pointFeature = this.pointSource.getFeatureById(featureId);
if (pointFeature)
this.pointSource.removeFeature(pointFeature as Feature<Point>);
} else {
const feature = this.pointSource.getFeatureById(featureId);
if (feature)
this.pointSource.removeFeature(feature as Feature<Point>);
}
this.unselect();
})
.catch((err) => {
console.error("Delete failed:", err);
});
}
public deleteMultipleFeatures(featureIds: (string | number)[]): void {
if (!featureIds || featureIds.length === 0) return;
this.beforeActionState = this.getCurrentStateAsGeoJSON();
const deletePromises = Array.from(featureIds).map((id) => {
const recourse = String(id).split("-")[0];
const numericId = parseInt(String(id).split("-")[1], 10);
if (recourse && !isNaN(numericId)) {
return mapStore.deleteFeature(recourse, numericId).then(() => id);
}
return Promise.resolve(null);
});
Promise.all(deletePromises)
.then((deletedIds) => {
const successfulDeletes = deletedIds.filter((id) => id) as (
| string
| number
)[];
if (successfulDeletes.length > 0) {
if (this.beforeActionState)
this.addStateToHistory(this.beforeActionState);
this.beforeActionState = null;
successfulDeletes.forEach((id) => {
const recourse = String(id).split("-")[0];
if (recourse === "route") {
const lineFeature = this.lineSource.getFeatureById(id);
if (lineFeature)
this.lineSource.removeFeature(
lineFeature as Feature<LineString>
);
const pointFeature = this.pointSource.getFeatureById(id);
if (pointFeature)
this.pointSource.removeFeature(pointFeature as Feature<Point>);
} else {
const feature = this.pointSource.getFeatureById(id);
if (feature)
this.pointSource.removeFeature(feature as Feature<Point>);
}
});
toast.success(`Удалено ${successfulDeletes.length} объект(ов).`);
this.unselect();
}
})
.catch((err) => {
console.error("Bulk delete failed:", err);
});
}
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.pointSource.clear();
this.lineSource.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.selectInteraction.getFeatures().clear();
ids.forEach((id) => {
const lineFeature = this.lineSource.getFeatureById(id);
if (lineFeature) this.selectInteraction.getFeatures().push(lineFeature);
const pointFeature = this.pointSource.getFeatureById(id);
if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature);
});
this.modifyInteraction.setActive(
this.selectInteraction.getFeatures().getLength() > 0
);
this.clusterLayer.changed();
this.routeLayer.changed();
if (ids.size === 1) {
const featureId = Array.from(ids)[0];
const feature =
this.lineSource.getFeatureById(featureId) ||
this.pointSource.getFeatureById(featureId);
if (feature) {
this.onFeatureSelect(feature);
}
} else {
this.onFeatureSelect(null);
}
}
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;
}
public saveCurrentPosition(): void {
if (!this.map) return;
const center = this.map.getView().getCenter();
const zoom = this.map.getView().getZoom();
if (center && zoom !== undefined) {
const [lon, lat] = toLonLat(center, this.map.getView().getProjection());
saveMapPosition({ center: [lon, lat], zoom });
}
}
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 (
featureType === "route" &&
feature.getGeometry()?.getType() === "LineString"
) {
const proxyPoint = this.pointSource.getFeatureById(
featureId
) as Feature<Point>;
if (proxyPoint) {
const lineGeom = feature.getGeometry() as LineString;
const newCenter = getCenter(lineGeom.getExtent());
proxyPoint.getGeometry()?.setCoordinates(newCenter);
}
}
if (typeof featureId === "number" || !String(featureId).includes("-")) {
console.warn(
"Skipping save for feature with non-standard ID:",
featureId
);
return;
}
const geoJSONFormat = new GeoJSON({
dataProjection: "EPSG:4326",
featureProjection: this.map.getView().getProjection().getCode(),
});
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
try {
await mapStore.updateFeature(featureType, featureGeoJSON);
} catch (error) {
console.error("Failed to update feature:", error);
toast.error(`Не удалось обновить: ${error}`);
if (this.beforeActionState) {
this.applyHistoryState(this.beforeActionState);
}
}
}
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().getCode(),
});
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
try {
const createdFeatureData = await mapStore.createFeature(
featureType,
featureGeoJSON
);
const newFeatureId = `${featureType}-${createdFeatureData.id}`;
// @ts-ignore
const displayName =
featureType === "route"
? // @ts-ignore
createdFeatureData.route_number
: // @ts-ignore
createdFeatureData.name;
if (featureType === "route") {
// @ts-ignore
const routeData = createdFeatureData as ApiRoute;
const projection = this.map.getView().getProjection();
// Update existing line feature
feature.setId(newFeatureId);
feature.set("name", displayName);
// Optionally update geometry if server modified it
const lineGeom = new LineString(
routeData.path.map((c) =>
transform([c[1], c[0]], "EPSG:4326", projection)
)
);
feature.setGeometry(lineGeom);
// Create and add proxy point
const centerPointGeom = new Point(
transform(
[routeData.center_longitude, routeData.center_latitude],
"EPSG:4326",
projection
)
);
const proxyPointFeature = new Feature({
geometry: centerPointGeom,
name: displayName,
isProxy: true,
});
proxyPointFeature.setId(newFeatureId);
proxyPointFeature.set("featureType", "route");
this.pointSource.addFeature(proxyPointFeature);
} else {
// For points: update existing
feature.setId(newFeatureId);
feature.set("name", displayName);
// No need to remove and re-add since it's already in the source
}
this.updateFeaturesInReact();
this.routeLayer.changed();
this.clusterLayer.changed();
} catch (error) {
console.error("Failed to save new feature:", error);
toast.error("Не удалось сохранить объект.");
if (feature.getGeometry()?.getType() === "LineString") {
if (this.lineSource.hasFeature(feature as Feature<LineString>))
this.lineSource.removeFeature(feature as Feature<LineString>);
} else {
if (this.pointSource.hasFeature(feature as Feature<Point>))
this.pointSource.removeFeature(feature as Feature<Point>);
}
if (this.beforeActionState) {
this.applyHistoryState(this.beforeActionState);
}
this.beforeActionState = null;
}
}
}
// --- 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,
// @ts-ignore
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.startDrawing("Point", "station"),
},
{
mode: "drawing-sight",
title: "Достопримечательность",
longTitle: "Добавить достопримечательность",
icon: <Landmark size={16} className="mr-1 sm:mr-2" />,
action: () => mapService.startDrawing("Point", "sight"),
},
{
mode: "drawing-route",
title: "Маршрут",
longTitle: "Добавить маршрут (Правый клик для завершения)",
icon: <LineIconSvg />,
action: () => mapService.startDrawing("LineString", "route"),
},
{
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 actualFeatures = useMemo(() => {
return mapFeatures.filter((feature) => !feature.get("isProxy"));
}, [mapFeatures]);
const filteredFeatures = useMemo(() => {
if (!searchQuery.trim()) return actualFeatures;
return actualFeatures.filter((feature) =>
((feature.get("name") as string) || "")
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}, [actualFeatures, searchQuery]);
const handleFeatureClick = useCallback(
(id: string | number | undefined) => {
if (!id || !mapService) return;
mapService.selectFeature(id);
},
[mapService]
);
const handleDeleteFeature = useCallback(
// @ts-ignore
(id, recourse) => {
if (
mapService &&
window.confirm("Вы действительно хотите удалить этот объект?")
) {
mapService.deleteFeature(id, recourse);
}
},
[mapService]
);
const handleCheckboxChange = useCallback(
(id: string | number | undefined) => {
if (!id || !mapService) return;
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
mapService.setSelectedIds(newSet);
},
[mapService, 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]);
// @ts-ignore
const handleEditFeature = useCallback(
// @ts-ignore
(featureType, fullId) => {
if (!featureType || !fullId) return;
const numericId = String(fullId).split("-")[1];
if (numericId) navigate(`/${featureType}/${numericId}/edit`);
},
[navigate]
);
const sortFeatures = (
features: Feature<Geometry>[],
currentSelectedIds: Set<string | number>,
currentSelectedFeature: Feature<Geometry> | null
) => {
const selectedId = currentSelectedFeature?.getId();
return [...features].sort((a, b) => {
const aId = a.getId();
const bId = b.getId();
if (selectedId) {
if (aId === selectedId) return -1;
if (bId === selectedId) return 1;
}
const aIsChecked = aId !== undefined && currentSelectedIds.has(aId);
const bIsChecked = bId !== undefined && currentSelectedIds.has(bId);
if (aIsChecked && !bIsChecked) return -1;
if (!aIsChecked && bIsChecked) return 1;
const aNumericId = aId ? parseInt(String(aId).split("-")[1], 10) : 0;
const bNumericId = bId ? parseInt(String(bId).split("-")[1], 10) : 0;
if (
!isNaN(aNumericId) &&
!isNaN(bNumericId) &&
aNumericId !== bNumericId
) {
return aNumericId - bNumericId;
}
const aName = (a.get("name") as string) || "";
const bName = (b.get("name") as string) || "";
return aName.localeCompare(bName, "ru");
});
};
const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
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 renderFeatureList = (
features: Feature<Geometry>[],
featureType: "station" | "route" | "sight",
IconComponent: React.ElementType
) => (
<div className="space-y-1 pr-1">
{features.length > 0 ? (
features.map((feature) => {
const fId = feature.getId();
const fName = (feature.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === fId;
const isChecked = fId !== undefined && selectedIds.has(fId);
return (
<div
key={String(fId)}
data-feature-id={fId}
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(fId)}
onClick={(e) => e.stopPropagation()}
aria-label={`Выбрать ${fName}`}
/>
</div>
<div
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
onClick={() => handleFeatureClick(fId)}
>
<div className="flex items-center">
<IconComponent
{...({
className: [
"mr-1.5",
"flex-shrink-0",
isSelected ? "text-orange-500" : "text-blue-500",
!isSelected && "group-hover:text-blue-600",
]
.filter(Boolean)
.join(" "),
} as React.HTMLAttributes<HTMLElement>)}
// @ts-ignore
size={16}
/>
<span
className={`font-medium truncate ${
isSelected
? "text-orange-600"
: "group-hover:text-blue-600"
}`}
title={fName}
>
{fName}
</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(feature.get("featureType"), fId);
}}
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(fId, featureType);
}}
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-2">Нет объектов этого типа.</p>
)}
</div>
);
const sections = [
{
id: "layers",
title: `Остановки (${sortedStations.length})`,
icon: <Bus size={20} />,
count: sortedStations.length,
content: renderFeatureList(sortedStations, "station", MapPin),
},
{
id: "lines",
title: `Маршруты (${sortedLines.length})`,
icon: <RouteIcon size={20} />,
count: sortedLines.length,
content: renderFeatureList(sortedLines, "route", ArrowRightLeft),
},
{
id: "sights",
title: `Достопримечательности (${sortedSights.length})`,
icon: <Landmark size={20} />,
count: sortedSights.length,
content: renderFeatureList(sortedSights, "sight", Landmark),
},
];
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">
{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 flex flex-col transition-all duration-300 ease-in-out ${
activeSection === s.id ? "flex-1 min-h-0" : "flex-none"
}`}
>
<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 flex-shrink-0 ${
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-y-auto scrollbar-hide bg-white ${
activeSection === s.id ? "block" : "hidden"
}`}
>
<div className="p-3 text-sm text-gray-600">{s.content}</div>
</div>
</div>
)
)
)}
</div>
{selectedIds.size > 0 && (
<div className="p-3 border-t border-gray-200 bg-white flex-shrink-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
>(() => getStoredActiveSection() || "layers");
const handleFeaturesChange = useCallback(
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
[]
);
const handleFeatureSelectForSidebar = useCallback(
(feat: Feature<Geometry> | null) => {
// Logic to sync sidebar selection with map
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);
}
},
[]
);
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);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// --- ИСПРАВЛЕНИЕ: Этот хук отвечает ТОЛЬКО за клики по ПУСТОМУ месту
useEffect(() => {
const olMap = mapServiceInstance?.getMap();
if (!olMap || !mapServiceInstance) return;
const handleMapClickForDeselect = (event: any) => {
if (!mapServiceInstance) return;
const hit = olMap.hasFeatureAtPixel(event.pixel, {
layerFilter: (layer) =>
layer === mapServiceInstance.clusterLayer ||
layer === mapServiceInstance.routeLayer,
hitTolerance: 5,
});
// Если клик был НЕ по объекту, снимаем выделение
if (!hit) {
mapServiceInstance.unselect();
}
// Если клик был ПО объекту, НИЧЕГО не делаем. За это отвечает selectInteraction.
};
olMap.on("click", handleMapClickForDeselect);
return () => {
olMap.un("click", handleMapClickForDeselect);
};
}, [mapServiceInstance]);
useEffect(() => {
mapServiceInstance?.setOnSelectionChange(setSelectedIds);
}, [mapServiceInstance]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift" && mapServiceInstance && !isLassoActive) {
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, isLassoActive]);
useEffect(() => {
if (mapServiceInstance) {
mapServiceInstance.toggleLasso = function () {
if (currentMapMode === "lasso") {
this.deactivateLasso();
setIsLassoActive(false);
} else {
this.activateLasso();
setIsLassoActive(true);
}
};
}
}, [mapServiceInstance, currentMapMode]);
useEffect(() => {
saveActiveSection(activeSectionFromParent);
}, [activeSectionFromParent]);
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>
);
};