Files
WhiteNightsAdminPanel/src/pages/MapPage/index.tsx
2025-10-10 08:40:39 +03:00

2862 lines
92 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 scrollbarStyles = `
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-visible {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.scrollbar-visible::-webkit-scrollbar {
width: 8px;
}
.scrollbar-visible::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.scrollbar-visible::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
`;
if (typeof document !== "undefined") {
const styleElement = document.createElement("style");
styleElement.textContent = scrollbarStyles;
document.head.appendChild(styleElement);
}
// --- MAP STORE ---
// @ts-ignore
import { languageInstance } from "@shared";
import { makeAutoObservable } from "mobx";
import {
stationsStore,
routeStore,
sightsStore,
menuStore,
selectedCityStore,
} from "@shared";
// Функция для сброса кешей карты
export const clearMapCaches = () => {
// Сброс кешей маршрутов
mapStore.routes = [];
mapStore.stations = [];
mapStore.sights = [];
// Сброс кешей MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches();
}
};
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;
city_id: number;
created_at?: string;
updated_at?: string;
}
interface ApiSight {
id: number;
name: string;
description: string;
latitude: number;
longitude: number;
city_id: number;
created_at?: string;
updated_at?: string;
}
export type SortType =
| "name_asc"
| "name_desc"
| "created_asc"
| "created_desc"
| "updated_asc"
| "updated_desc";
class MapStore {
constructor() {
makeAutoObservable(this);
}
routes: ApiRoute[] = [];
stations: ApiStation[] = [];
sights: ApiSight[] = [];
// НОВЫЕ ПОЛЯ ДЛЯ СОРТИРОВКИ
stationSort: SortType = "name_asc";
sightSort: SortType = "name_asc";
// НОВЫЕ МЕТОДЫ-СЕТТЕРЫ
setStationSort = (sortType: SortType) => {
this.stationSort = sortType;
};
setSightSort = (sortType: SortType) => {
this.sightSort = sortType;
};
// ПРИВАТНЫЙ МЕТОД ДЛЯ ОБЩЕЙ ЛОГИКИ СОРТИРОВКИ
private sortFeatures<T extends ApiStation | ApiSight>(
features: T[],
sortType: SortType
): T[] {
const sorted = [...features];
switch (sortType) {
case "name_asc":
return sorted.sort((a, b) => a.name.localeCompare(b.name));
case "name_desc":
return sorted.sort((a, b) => b.name.localeCompare(a.name));
case "created_asc":
return sorted.sort((a, b) => {
if (
"created_at" in a &&
"created_at" in b &&
a.created_at &&
b.created_at
) {
return (
new Date(a.created_at).getTime() -
new Date(b.created_at).getTime()
);
}
// Фоллбэк: сортировка по ID, если дата недоступна
return a.id - b.id;
});
case "created_desc":
return sorted.sort((a, b) => {
if (
"created_at" in a &&
"created_at" in b &&
a.created_at &&
b.created_at
) {
return (
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime()
);
}
// Фоллбэк: сортировка по ID, если дата недоступна
return b.id - a.id;
});
case "updated_asc":
return sorted.sort((a, b) => {
const aUpdated =
("updated_at" in a && a.updated_at) ||
("created_at" in a && a.created_at);
const bUpdated =
("updated_at" in b && b.updated_at) ||
("created_at" in b && b.created_at);
if (typeof aUpdated === "string" && typeof bUpdated === "string") {
return new Date(aUpdated).getTime() - new Date(bUpdated).getTime();
}
return a.id - b.id;
});
case "updated_desc":
return sorted.sort((a, b) => {
const aUpdated =
("updated_at" in a && a.updated_at) ||
("created_at" in a && a.created_at);
const bUpdated =
("updated_at" in b && b.updated_at) ||
("created_at" in b && b.created_at);
if (typeof aUpdated === "string" && typeof bUpdated === "string") {
return new Date(bUpdated).getTime() - new Date(aUpdated).getTime();
}
return b.id - a.id;
});
default:
return sorted;
}
}
// НОВЫЕ ГЕТТЕРЫ, ВОЗВРАЩАЮЩИЕ ОТСОРТИРОВАННЫЕ СПИСКИ
get sortedStations(): ApiStation[] {
return this.sortFeatures(this.stations, this.stationSort);
}
get sortedSights(): ApiSight[] {
return this.sortFeatures(this.sights, this.sightSort);
}
// ГЕТТЕРЫ ДЛЯ ФИЛЬТРАЦИИ ПО ГОРОДУ
get filteredStations(): ApiStation[] {
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) {
return this.sortedStations;
}
return this.sortedStations.filter(
(station) => station.city_id === selectedCityId
);
}
get filteredSights(): ApiSight[] {
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) {
return this.sortedSights;
}
return this.sortedSights.filter(
(sight) => sight.city_id === selectedCityId
);
}
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,
});
const selectedCityId = selectedCityStore.selectedCityId || 1;
const selectedCityName =
selectedCityStore.selectedCityName || "Неизвестный город";
stationsStore.setCreateCommonData({
latitude,
longitude,
city_id: selectedCityId,
city: selectedCityName,
});
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: "" });
const selectedCityId = selectedCityStore.selectedCityId || 1;
await sightsStore.createSightAction(selectedCityId, {
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();
// Делаем mapStore доступным глобально для сброса кешей
if (typeof window !== "undefined") {
(window as any).mapStore = 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;
}
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 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 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;
private isCreating: boolean = false;
// 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.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.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();
// Все маршруты всегда отображаются, так как они не кластеризуются
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 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;
return this.selectedBusIconStyle;
}
if (featureType === "sight") return this.sightIconStyle;
return this.busIconStyle;
}
},
});
this.clusterSource.on("change", () => {
// Поскольку маршруты больше не добавляются как точки,
// нам не нужно отслеживать unclusteredRouteIds
// Все маршруты всегда отображаются как линии
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;
},
});
this.modifyInteraction.on("modifyend", (event) => {
event.features.getArray().forEach((feature) => {
this.saveModifiedFeature(feature as Feature<Geometry>);
});
});
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 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>[] = [];
// Используем фильтрованные данные из mapStore
const filteredStations = mapStore.filteredStations;
const filteredSights = mapStore.filteredSights;
filteredStations.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);
});
filteredSights.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);
// Не создаем прокси-точки для маршрутов - они должны оставаться только линиями
});
this.pointSource.addFeatures(pointFeatures);
this.lineSource.addFeatures(lineFeatures);
this.updateFeaturesInReact();
}
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.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("drawend", async (event: DrawEvent) => {
const feature = event.feature as Feature<Geometry>;
const fType = this.currentDrawingFeatureType;
if (!fType) return;
// Проверяем, не идет ли уже процесс создания
if (this.isCreating) {
toast.warning("Дождитесь завершения создания предыдущего объекта.");
// Удаляем созданный объект из источника
const sourceForDrawing =
type === "Point" ? this.pointSource : this.lineSource;
setTimeout(() => {
if (sourceForDrawing.hasFeature(feature as any)) {
sourceForDrawing.removeFeature(feature as any);
}
}, 0);
return;
}
feature.set("featureType", fType);
let resourceName: string;
switch (fType) {
case "station":
// Используем полный список из mapStore, а не отфильтрованный
const stationNumbers = mapStore.stations
.map((station) => {
const match = station.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":
// Используем полный список из mapStore, а не отфильтрованный
const sightNumbers = mapStore.sights
.map((sight) => {
const match = sight.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":
// Используем полный список из mapStore, а не отфильтрованный
const routeNumbers = mapStore.routes
.map((route) => {
const match = route.route_number?.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;
// Блокируем завершение рисования, если идет процесс создания
if (this.isCreating) {
toast.warning("Дождитесь завершения создания предыдущего объекта.");
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;
const numericId = parseInt(String(featureId).split("-")[1], 10);
if (!recourse || isNaN(numericId)) return;
mapStore
.deleteFeature(recourse, numericId)
.then(() => {
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;
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) {
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 clearCaches() {
this.clusterStyleCache = {};
this.hoveredFeatureId = null;
this.selectedIds.clear();
// Очищаем источники данных
if (this.pointSource) {
this.pointSource.clear();
}
if (this.lineSource) {
this.lineSource.clear();
}
// Обновляем слои
if (this.clusterLayer) {
this.clusterLayer.changed();
}
if (this.routeLayer) {
this.routeLayer.changed();
}
}
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 (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}`);
}
}
private async saveNewFeature(feature: Feature<Geometry>) {
const featureType = feature.get("featureType") as FeatureType;
if (!featureType || !this.map) return;
// Проверяем, не идет ли уже процесс создания
if (this.isCreating) {
toast.warning("Дождитесь завершения создания предыдущего объекта.");
// Удаляем незавершенный объект с карты
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>);
}
return;
}
this.isCreating = true;
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);
// Не создаем прокси-точку для маршрута - только линия
} 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>);
}
} finally {
this.isCreating = false;
}
}
}
// --- 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>
);
};
import { observer } from "mobx-react-lite";
// --- 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> = observer(
({
mapService,
mapFeatures,
selectedFeature,
selectedIds,
setSelectedIds,
activeSection,
setActiveSection,
}) => {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
const [stationSort, setStationSort] = useState<SortType>("name_asc");
const [sightSort, setSightSort] = useState<SortType>("name_asc");
const { isOpen } = menuStore;
const { selectedCityId } = selectedCityStore;
const actualFeatures = useMemo(
() => mapFeatures.filter((f) => !f.get("isProxy")),
[mapFeatures]
);
// Создаем объединенный список всех объектов для поиска
const allFeatures = useMemo(() => {
const stations = mapStore.filteredStations.map((station) => {
const feature = new Feature({
geometry: new Point(
transform(
[station.longitude, station.latitude],
"EPSG:4326",
"EPSG:3857"
)
),
name: station.name,
});
feature.setId(`station-${station.id}`);
feature.set("featureType", "station");
feature.set("created_at", station.created_at);
return feature;
});
const sights = mapStore.filteredSights.map((sight) => {
const feature = new Feature({
geometry: new Point(
transform(
[sight.longitude, sight.latitude],
"EPSG:4326",
"EPSG:3857"
)
),
name: sight.name,
description: sight.description,
});
feature.setId(`sight-${sight.id}`);
feature.set("featureType", "sight");
feature.set("created_at", sight.created_at);
return feature;
});
const lines = actualFeatures.filter(
(f) => f.get("featureType") === "route"
);
return [...stations, ...sights, ...lines];
}, [
mapStore.filteredStations,
mapStore.filteredSights,
actualFeatures,
selectedCityId,
mapStore,
]);
const filteredFeatures = useMemo(() => {
if (!searchQuery.trim()) return allFeatures;
return allFeatures.filter((f) =>
((f.get("name") as string) || "")
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}, [allFeatures, searchQuery]);
const handleFeatureClick = useCallback(
(id: string | number) => {
if (!mapService) return;
mapService.selectFeature(id);
},
[mapService]
);
const handleDeleteFeature = useCallback(
(id: string | number, resource: string) => {
if (!mapService) return;
if (window.confirm("Вы действительно хотите удалить этот объект?")) {
mapService.deleteFeature(id, resource);
}
},
[mapService]
);
const handleCheckboxChange = useCallback(
(id: string | number) => {
if (!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]);
const handleEditFeature = useCallback(
(featureType: string, fullId: string | number) => {
const numericId = String(fullId).split("-")[1];
if (!featureType || !numericId) return;
navigate(`/${featureType}/${numericId}/edit`);
},
[navigate]
);
const sortFeaturesByType = <T extends Feature<Geometry>>(
features: T[],
sortType: SortType
): T[] => {
const sorted = [...features];
switch (sortType) {
case "name_asc":
return sorted.sort((a, b) =>
((a.get("name") as string) || "").localeCompare(
(b.get("name") as string) || ""
)
);
case "name_desc":
return sorted.sort((a, b) =>
((b.get("name") as string) || "").localeCompare(
(a.get("name") as string) || ""
)
);
case "created_asc":
return sorted.sort((a, b) => {
const aDate = a.get("created_at")
? new Date(a.get("created_at"))
: new Date(0);
const bDate = b.get("created_at")
? new Date(b.get("created_at"))
: new Date(0);
return aDate.getTime() - bDate.getTime();
});
case "created_desc":
return sorted.sort((a, b) => {
const aDate = a.get("created_at")
? new Date(a.get("created_at"))
: new Date(0);
const bDate = b.get("created_at")
? new Date(b.get("created_at"))
: new Date(0);
return bDate.getTime() - aDate.getTime();
});
case "updated_asc":
return sorted.sort((a, b) => {
const aDate = a.get("updated_at")
? new Date(a.get("updated_at"))
: a.get("created_at")
? new Date(a.get("created_at"))
: new Date(0);
const bDate = b.get("updated_at")
? new Date(b.get("updated_at"))
: b.get("created_at")
? new Date(b.get("created_at"))
: new Date(0);
return aDate.getTime() - bDate.getTime();
});
case "updated_desc":
return sorted.sort((a, b) => {
const aDate = a.get("updated_at")
? new Date(a.get("updated_at"))
: a.get("created_at")
? new Date(a.get("created_at"))
: new Date(0);
const bDate = b.get("updated_at")
? new Date(b.get("updated_at"))
: b.get("created_at")
? new Date(b.get("created_at"))
: new Date(0);
return bDate.getTime() - aDate.getTime();
});
default:
return sorted;
}
};
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 = sortFeaturesByType(stations, stationSort);
const sortedSights = sortFeaturesByType(sights, sightSort);
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();
if (fId === undefined) return null; // TypeScript-safe
const fName = (feature.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === fId;
const isChecked = 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 whitespace-nowrap overflow-x-auto block
scrollbar-visible`}
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();
const featureTypeVal = feature.get("featureType");
if (featureTypeVal)
handleEditFeature(featureTypeVal, 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 toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
const sections = [
{
id: "layers",
title: `Остановки (${sortedStations.length})`,
icon: <Bus size={20} />,
count: sortedStations.length,
sortControl: (
<div className="flex items-center space-x-2 p-3 bg-white border-b border-gray-200">
<label className="text-sm text-gray-700">Сортировка:</label>
<select
value={stationSort}
onChange={(e) => setStationSort(e.target.value as SortType)}
className="border rounded px-2 py-1 text-sm"
>
<option value="name_asc">Имя </option>
<option value="name_desc">Имя </option>
<option value="created_asc">Дата создания </option>
<option value="created_desc">Дата создания </option>
<option value="updated_asc">Дата обновления </option>
<option value="updated_desc">Дата обновления </option>
</select>
</div>
),
content: renderFeatureList(sortedStations, "station", MapPin),
},
{
id: "lines",
title: `Маршруты (${lines.length})`,
icon: <RouteIcon size={20} />,
count: lines.length,
sortControl: null,
content: renderFeatureList(lines, "route", ArrowRightLeft),
},
{
id: "sights",
title: `Достопримечательности (${sortedSights.length})`,
icon: <Landmark size={20} />,
count: sortedSights.length,
sortControl: (
<div className="flex items-center space-x-2 p-3 bg-white border-b border-gray-200">
<label className="text-sm text-gray-700">Сортировка:</label>
<select
value={sightSort}
onChange={(e) => setSightSort(e.target.value as SortType)}
className="border rounded px-2 py-1 text-sm"
>
<option value="name_asc">Имя </option>
<option value="name_desc">Имя </option>
<option value="created_asc">Дата создания </option>
<option value="created_desc">Дата создания </option>
<option value="updated_asc">Дата обновления </option>
<option value="updated_desc">Дата обновления </option>
</select>
</div>
),
content: renderFeatureList(sortedSights, "sight", Landmark),
},
];
React.useEffect(() => {
console.log("isOpen changed:", isOpen);
}, [isOpen]);
return (
<div
className={`${
isOpen ? "w-[360px]" : "w-[590px]"
} transition-all duration-300 ease-in-out 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 overflow-y-auto scrollbar-visible">
{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>
{activeSection === s.id && (
<>
{s.sortControl && (
<div className="flex-shrink-0">{s.sortControl}</div>
)}
<div className="overflow-y-auto scrollbar-visible bg-white flex-1 min-h-0">
<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 = observer(() => {
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 { selectedCityId } = selectedCityStore;
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);
// Делаем mapServiceInstance доступным глобально для сброса кешей
if (typeof window !== "undefined") {
(window as any).mapServiceInstance = service;
}
loadInitialData(service);
} catch (e: any) {
setError(
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`
);
setIsMapLoading(false);
setIsDataLoading(false);
}
}
return () => {
service?.destroy();
setMapServiceInstance(null);
// Удаляем глобальную ссылку
if (typeof window !== "undefined") {
delete (window as any).mapServiceInstance;
}
};
// 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]);
// Перезагружаем данные при изменении города
useEffect(() => {
if (mapServiceInstance && !isDataLoading) {
// Очищаем текущие объекты на карте
mapServiceInstance.pointSource.clear();
mapServiceInstance.lineSource.clear();
// Загружаем новые данные с фильтрацией по городу
mapServiceInstance.loadFeaturesFromApi(
mapStore.stations,
mapStore.routes,
mapStore.sights
);
}
}, [selectedCityId, mapServiceInstance, isDataLoading]);
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>
</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>
);
});