Files
WhiteNightsAdminPanel/src/pages/MapPage/index.tsx
fisenko 03fd04a420 #18 Корректировки 01.11.25 (#19)
Reviewed-on: #19
Reviewed-by: Микаэл Оганесян <15lu.akari@unprism.ru>
Co-authored-by: fisenko <kkzemeow@gmail.com>
Co-committed-by: fisenko <kkzemeow@gmail.com>
2025-11-07 07:16:29 +00:00

3296 lines
104 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 as OLMap, 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,
DragPan,
MouseWheelZoom,
KeyboardPan,
KeyboardZoom,
PinchZoom,
PinchRotate,
} 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,
EyeOff,
Eye,
Map as MapIcon,
} 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";
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);
}
import { languageInstance } from "@shared";
import { makeAutoObservable } from "mobx";
import {
stationsStore,
routeStore,
sightsStore,
menuStore,
selectedCityStore,
carrierStore,
} from "@shared";
export const clearMapCaches = () => {
mapStore.routes = [];
mapStore.stations = [];
mapStore.sights = [];
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;
carrier_id: number;
}
interface ApiStation {
id: number;
name: string;
description?: 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";
const HIDDEN_ROUTES_KEY = "mapHiddenRoutes";
const HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY = "mapHideSightsByHiddenRoutes";
const getStoredHiddenRoutes = (): Set<number> => {
try {
const stored = localStorage.getItem(HIDDEN_ROUTES_KEY);
if (stored) {
const routes = JSON.parse(stored);
if (
Array.isArray(routes) &&
routes.every((id) => typeof id === "number")
) {
return new Set(routes);
}
}
} catch (error) {
console.warn("Failed to parse stored hidden routes:", error);
}
return new Set();
};
const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
try {
localStorage.setItem(
HIDDEN_ROUTES_KEY,
JSON.stringify(Array.from(hiddenRoutes))
);
} catch (error) {
console.warn("Failed to save hidden routes:", error);
}
};
class MapStore {
constructor() {
makeAutoObservable(this);
this.hiddenRoutes = getStoredHiddenRoutes();
try {
const stored = localStorage.getItem(HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY);
this.hideSightsByHiddenRoutes = stored
? JSON.parse(stored) === true
: false;
} catch (e) {
this.hideSightsByHiddenRoutes = false;
}
}
routes: ApiRoute[] = [];
stations: ApiStation[] = [];
sights: ApiSight[] = [];
hiddenRoutes: Set<number>;
hideSightsByHiddenRoutes: boolean = false;
routeStationsCache: Map<number, number[]> = new Map();
routeSightsCache: Map<number, number[]> = new Map();
setHideSightsByHiddenRoutes(val: boolean) {
this.hideSightsByHiddenRoutes = val;
try {
localStorage.setItem(
HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY,
JSON.stringify(!!val)
);
} catch (e) {}
}
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()
);
}
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()
);
}
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 filteredRoutes(): ApiRoute[] {
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) {
return this.routes;
}
const carriers = carrierStore.carriers.ru.data;
return this.routes.filter((route: ApiRoute) => {
const carrier = carriers.find((c: any) => c.id === route.carrier_id);
return carrier && carrier.city_id === selectedCityId;
});
}
get filteredSights(): ApiSight[] {
const selectedCityId = selectedCityStore.selectedCityId;
const cityFiltered = !selectedCityId
? this.sortedSights
: this.sortedSights.filter((sight) => sight.city_id === selectedCityId);
if (!this.hideSightsByHiddenRoutes || this.hiddenRoutes.size === 0) {
return cityFiltered;
}
const hiddenSightIds = new Set<number>();
this.hiddenRoutes.forEach((routeId) => {
const sightIds = this.routeSightsCache.get(routeId) || [];
sightIds.forEach((id) => hiddenSightIds.add(id));
});
return cityFiltered.filter((s) => !hiddenSightIds.has(s.id));
}
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) => ({
id: res.data.id,
route_number: res.data.route_number,
path: res.data.path,
center_latitude: res.data.center_latitude,
center_longitude: res.data.center_longitude,
carrier_id: res.data.carrier_id,
}));
this.routes = this.routes.sort((a, b) =>
a.route_number.localeCompare(b.route_number)
);
await this.preloadRouteStations(routesIds);
await this.preloadRouteSights(routesIds);
};
preloadRouteStations = async (routesIds: number[]) => {
const stationPromises = routesIds.map(async (routeId) => {
try {
const stationsResponse = await languageInstance("ru").get(
`/route/${routeId}/station`
);
const stationIds = stationsResponse.data.map((s: any) => s.id);
this.routeStationsCache.set(routeId, stationIds);
} catch (error) {
console.error(
`Failed to preload stations for route ${routeId}:`,
error
);
}
});
await Promise.all(stationPromises);
};
preloadRouteSights = async (routesIds: number[]) => {
const sightPromises = routesIds.map(async (routeId) => {
try {
const sightsResponse = await languageInstance("ru").get(
`/route/${routeId}/sight`
);
const sightIds = sightsResponse.data.map((s: any) => s.id);
this.routeSightsCache.set(routeId, sightIds);
} catch (error) {
console.error(`Failed to preload sights for route ${routeId}:`, error);
}
});
await Promise.all(sightPromises);
};
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: any;
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"
);
let carrier_id = 0;
let carrier = "";
if (selectedCityStore.selectedCityId) {
const carriersInCity = carrierStore.carriers.ru.data.filter(
(c: any) => c.city_id === selectedCityStore.selectedCityId
);
if (carriersInCity.length > 0) {
carrier_id = carriersInCity[0].id;
carrier = carriersInCity[0].full_name;
}
}
const routeData = {
route_number,
path,
center_latitude,
center_longitude,
carrier,
carrier_id,
governor_appeal: 0,
rotate: 0,
route_direction: false,
route_sys_number: route_number,
scale_max: 100,
scale_min: 10,
};
await routeStore.createRoute(routeData);
if (!carrier_id && selectedCityStore.selectedCityId) {
toast.error(
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке"
);
}
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}`);
}
if (featureType === "route") this.routes.push(createdItem);
else if (featureType === "station") this.stations.push(createdItem);
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();
if (typeof window !== "undefined") {
(window as any).mapStore = mapStore;
}
export const mapConfig = {
center: [30.311, 59.94] as [number, number],
zoom: 13,
};
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);
}
};
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);
}
};
interface MapServiceConfig {
target: HTMLElement;
center: [number, number];
zoom: number;
}
type FeatureType = "station" | "route" | "sight";
class MapService {
private map: OLMap | null;
public pointSource: VectorSource<Feature<Point>>;
public lineSource: VectorSource<Feature<LineString>>;
public clusterLayer: VectorLayer<Cluster>;
public routeLayer: VectorLayer<VectorSource<Feature<LineString>>>;
private clusterSource: Cluster;
private clusterStyleCache: { [key: number]: Style };
private tooltipElement: HTMLElement;
private tooltipOverlay: Overlay | null;
private mode: string | null;
private currentDrawingType: "Point" | "LineString" | null;
private currentDrawingFeatureType: FeatureType | null;
private currentInteraction: Draw | null;
private modifyInteraction: Modify;
private selectInteraction: Select;
private hoveredFeatureId: string | number | null;
private 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;
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;
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,
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", () => {
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 OLMap({
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: [
new MouseWheelZoom(),
new KeyboardPan(),
new KeyboardZoom(),
new PinchZoom(),
new PinchRotate(),
new DragPan({
condition: (event) => {
const originalEvent = event.originalEvent;
if (!originalEvent) return false;
if (
originalEvent.type === "pointerdown" ||
originalEvent.type === "pointermove"
) {
const pointerEvent = originalEvent as PointerEvent;
return pointerEvent.buttons === 4;
}
return 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;
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;
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;
}
}
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) {
if (ctrlKey && newSelectedIds.has(targetId)) {
newSelectedIds.delete(targetId);
} else {
newSelectedIds.add(targetId);
}
}
});
if (!ctrlKey) {
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.style.cursor = "pointer";
targetEl.addEventListener("contextmenu", this.boundHandleContextMenu);
targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave);
targetEl.addEventListener("pointerdown", (e) => {
if (e.buttons === 4) {
e.preventDefault();
targetEl.style.cursor = "grabbing";
}
});
targetEl.addEventListener("pointerup", (e) => {
if (e.button === 1) {
e.preventDefault();
targetEl.style.cursor = "pointer";
}
});
targetEl.addEventListener("mousedown", (e) => {
if (e.button === 1) {
e.preventDefault();
targetEl.style.cursor = "grabbing";
}
});
targetEl.addEventListener("mouseup", (e) => {
if (e.button === 1) {
e.preventDefault();
targetEl.style.cursor = "pointer";
}
});
targetEl.addEventListener("auxclick", (e) => {
if (e.button === 1) {
e.preventDefault();
}
});
}
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>[] = [];
const filteredStations = mapStore.filteredStations;
const filteredSights = mapStore.filteredSights;
const filteredRoutes = mapStore.filteredRoutes;
const stationsInVisibleRoutes = new Set<number>();
filteredRoutes
.filter((route) => !mapStore.hiddenRoutes.has(route.id))
.forEach((route) => {
const stationIds = mapStore.routeStationsCache.get(route.id) || [];
stationIds.forEach((id) => stationsInVisibleRoutes.add(id));
});
let skippedStations = 0;
filteredStations.forEach((station) => {
if (station.longitude == null || station.latitude == null) return;
if (!stationsInVisibleRoutes.has(station.id)) {
skippedStations++;
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);
});
filteredRoutes.forEach((route) => {
if (!route.path || route.path.length === 0) return;
if (mapStore.hiddenRoutes.has(route.id)) 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);
if (this.map) {
const targetEl = this.map.getTargetElement();
if (targetEl instanceof HTMLElement) {
targetEl.style.cursor = "pointer";
}
}
}
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":
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":
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":
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 {
this.currentInteraction.abortDrawing();
} catch (e) {}
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" : "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;
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(): OLMap | 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}`;
const displayName =
featureType === "route"
? createdFeatureData.route_number
: createdFeatureData.name;
if (featureType === "route") {
const routeData = createdFeatureData as ApiRoute;
const projection = this.map.getView().getProjection();
feature.setId(newFeatureId);
feature.set("name", displayName);
const lineGeom = new LineString(
routeData.path.map((c) =>
transform([c[1], c[0]], "EPSG:4326", projection)
)
);
feature.setGeometry(lineGeom);
} else {
feature.setId(newFeatureId);
feature.set("name", displayName);
}
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;
}
}
}
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: <Pencil size={16} className="mr-1 sm:mr-2" />,
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: <RouteIcon size={16} className="mr-1 sm:mr-2" />,
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";
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,
description: station.description || "",
});
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 = mapStore.filteredRoutes.map((route) => {
const feature = new Feature({
geometry: new LineString(route.path),
name: route.route_number,
});
feature.setId(`route-${route.id}`);
feature.set("featureType", "route");
return feature;
});
return [...stations, ...sights, ...lines];
}, [
mapStore.filteredStations,
mapStore.filteredSights,
mapStore.filteredRoutes,
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, event?: React.MouseEvent) => {
if (!mapService) return;
const ctrlKey = event?.ctrlKey || event?.metaKey;
if (ctrlKey) {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
mapService.setSelectedIds(newSet);
} else {
mapService.selectFeature(id);
}
},
[mapService, selectedIds, setSelectedIds]
);
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 handleHideRoute = useCallback(
async (routeId: string | number) => {
if (!mapService) return;
const numericRouteId = parseInt(String(routeId).split("-")[1], 10);
if (isNaN(numericRouteId)) return;
const isHidden = mapStore.hiddenRoutes.has(numericRouteId);
try {
if (isHidden) {
const route = mapStore.routes.find((r) => r.id === numericRouteId);
if (!route) {
return;
}
const projection = mapService.getMap()?.getView().getProjection();
if (!projection) {
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) {
const line = new LineString(coordinates);
const lineFeature = new Feature({
geometry: line,
name: route.route_number,
});
lineFeature.setId(routeId);
lineFeature.set("featureType", "route");
mapService.lineSource.addFeature(lineFeature);
} else {
}
const routeStationIds =
mapStore.routeStationsCache.get(numericRouteId) || [];
const allRouteIds = mapStore.routes.map((r) => r.id);
const visibleRouteIds = allRouteIds.filter(
(id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
);
const stationsInVisibleRoutes = new Set<number>();
visibleRouteIds.forEach((otherRouteId) => {
const stationIds =
mapStore.routeStationsCache.get(otherRouteId) || [];
stationIds.forEach((id: number) =>
stationsInVisibleRoutes.add(id)
);
});
const stationsToShow = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id)
);
for (const stationId of stationsToShow) {
const station = mapStore.stations.find((s) => s.id === stationId);
if (!station) continue;
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");
const existingFeature = mapService.pointSource.getFeatureById(
`station-${station.id}`
);
if (!existingFeature) {
mapService.pointSource.addFeature(feature);
}
}
mapStore.hiddenRoutes.delete(numericRouteId);
saveHiddenRoutes(mapStore.hiddenRoutes);
} else {
const routeStationIds =
mapStore.routeStationsCache.get(numericRouteId) || [];
const allRouteIds = mapStore.routes.map((r) => r.id);
const visibleRouteIds = allRouteIds.filter(
(id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
);
const stationsInVisibleRoutes = new Set<number>();
visibleRouteIds.forEach((otherRouteId) => {
const stationIds =
mapStore.routeStationsCache.get(otherRouteId) || [];
stationIds.forEach((id: number) =>
stationsInVisibleRoutes.add(id)
);
});
const stationsToHide = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id)
);
stationsToHide.forEach((stationId: number) => {
const pointFeature = mapService.pointSource.getFeatureById(
`station-${stationId}`
);
if (pointFeature) {
mapService.pointSource.removeFeature(
pointFeature as Feature<Point>
);
}
});
const lineFeature = mapService.lineSource.getFeatureById(routeId);
if (lineFeature) {
mapService.lineSource.removeFeature(
lineFeature as Feature<LineString>
);
}
mapStore.hiddenRoutes.add(numericRouteId);
saveHiddenRoutes(mapStore.hiddenRoutes);
}
mapService.unselect();
} catch (error) {
console.error(
"[handleHideRoute] Error toggling route visibility:",
error
);
toast.error("Ошибка при изменении видимости маршрута");
}
},
[mapService]
);
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;
const fName = (feature.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === fId;
const isChecked = selectedIds.has(fId);
const numericRouteId =
featureType === "route"
? parseInt(String(fId).split("-")[1], 10)
: null;
const isRouteHidden =
numericRouteId !== null &&
mapStore.hiddenRoutes.has(numericRouteId);
const description = feature.get("description") as
| string
| undefined;
const showDescription =
featureType === "station" &&
description &&
description.trim() !== "";
return (
<div
key={String(fId)}
data-feature-id={fId}
className={`flex items-start p-2 rounded-md group transition-colors duration-150 relative ${
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={(e) => handleFeatureClick(fId, e)}
>
<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>
{showDescription && (
<div className="mt-1 text-xs text-gray-600 line-clamp-2">
{description}
</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>
{featureType === "route" && (
<button
onClick={(e) => {
e.stopPropagation();
const routeId = parseInt(String(fId).split("-")[1], 10);
navigate(`/route-preview/${routeId}`);
}}
className="p-1 rounded-full text-gray-400 hover:text-green-600 hover:bg-green-100 transition-colors"
title="Предпросмотр маршрута"
>
<MapIcon size={16} />
</button>
)}
{featureType === "route" && (
<button
onClick={(e) => {
e.stopPropagation();
handleHideRoute(fId);
}}
className={`p-1 rounded-full transition-colors ${
isRouteHidden
? "text-yellow-600 hover:text-yellow-700 hover:bg-yellow-100"
: "text-gray-400 hover:text-yellow-600 hover:bg-yellow-100"
}`}
title={
isRouteHidden ? "Показать на карте" : "Скрыть с карты"
}
>
{isRouteHidden ? <Eye size={16} /> : <EyeOff 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 [showSightsOptions, setShowSightsOptions] = useState(false);
const sightsOptionsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (sightsOptionsTimeoutRef.current) {
clearTimeout(sightsOptionsTimeoutRef.current);
}
};
}, []);
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 justify-between gap-4 p-3 bg-white border-b border-gray-200">
<div className="flex items-center space-x-2">
<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>
<div
className="relative"
onMouseEnter={() => {
sightsOptionsTimeoutRef.current = setTimeout(() => {
setShowSightsOptions(true);
}, 1000);
}}
onMouseLeave={() => {
if (sightsOptionsTimeoutRef.current) {
clearTimeout(sightsOptionsTimeoutRef.current);
sightsOptionsTimeoutRef.current = null;
}
setShowSightsOptions(false);
}}
>
<button
className={`px-2 py-1 rounded text-sm transition-colors ${
mapStore.hideSightsByHiddenRoutes
? "bg-blue-100 text-blue-700 hover:bg-blue-200"
: "text-gray-600 hover:text-gray-800 hover:bg-gray-100"
}`}
onClick={() =>
mapStore.setHideSightsByHiddenRoutes(
!mapStore.hideSightsByHiddenRoutes
)
}
>
Скрыть
</button>
{showSightsOptions && (
<div className="absolute right-0 mt-2 w-50 bg-white border border-gray-200 rounded-md shadow-md p-3 z-5000">
<div className="text-xs text-gray-600">
Будут скрыты все достопримечательности, привязанные к
скрытым маршрутам.
</div>
</div>
)}
</div>
</div>
),
content: renderFeatureList(sortedSights, "sight", Landmark),
},
];
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>
);
}
);
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) => {
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(),
carrierStore.getCarriers("ru"),
]);
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);
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;
}
};
}, []);
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();
}
};
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]);
useEffect(() => {
if (mapServiceInstance && !isDataLoading) {
mapServiceInstance.pointSource.clear();
mapServiceInstance.lineSource.clear();
mapServiceInstance.loadFeaturesFromApi(
mapStore.stations,
mapStore.routes,
mapStore.sights
);
}
}, [
mapStore.hideSightsByHiddenRoutes,
mapStore.hiddenRoutes.size,
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>
);
});