2811 lines
90 KiB
TypeScript
2811 lines
90 KiB
TypeScript
import React, {
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
useCallback,
|
||
useMemo,
|
||
} from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { Map, View, Overlay, MapBrowserEvent } from "ol";
|
||
import TileLayer from "ol/layer/Tile";
|
||
import OSM from "ol/source/OSM";
|
||
import VectorLayer from "ol/layer/Vector";
|
||
import VectorSource, { VectorSourceEvent } from "ol/source/Vector";
|
||
import Cluster from "ol/source/Cluster";
|
||
import {
|
||
Draw,
|
||
Modify,
|
||
Select,
|
||
defaults as defaultInteractions,
|
||
} from "ol/interaction";
|
||
import { DrawEvent } from "ol/interaction/Draw";
|
||
import { SelectEvent } from "ol/interaction/Select";
|
||
import {
|
||
Style,
|
||
Fill,
|
||
Stroke,
|
||
Circle as CircleStyle,
|
||
RegularShape,
|
||
Text,
|
||
} from "ol/style";
|
||
import { Point, LineString, Geometry, Polygon } from "ol/geom";
|
||
import { transform, toLonLat } from "ol/proj";
|
||
import { GeoJSON } from "ol/format";
|
||
import {
|
||
Bus,
|
||
RouteIcon,
|
||
MapPin,
|
||
Trash2,
|
||
ArrowRightLeft,
|
||
Landmark,
|
||
Pencil,
|
||
InfoIcon,
|
||
X,
|
||
Loader2,
|
||
} from "lucide-react";
|
||
import { toast } from "react-toastify";
|
||
import { singleClick, doubleClick } from "ol/events/condition";
|
||
import { Feature } from "ol";
|
||
import Layer from "ol/layer/Layer";
|
||
import Source from "ol/source/Source";
|
||
import { FeatureLike } from "ol/Feature";
|
||
import { createEmpty, extend, getCenter } from "ol/extent";
|
||
|
||
// --- CUSTOM SCROLLBAR STYLES ---
|
||
const scrollbarHideStyles = `
|
||
.scrollbar-hide {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
.scrollbar-hide::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
`;
|
||
|
||
if (typeof document !== "undefined") {
|
||
const styleElement = document.createElement("style");
|
||
styleElement.textContent = scrollbarHideStyles;
|
||
document.head.appendChild(styleElement);
|
||
}
|
||
|
||
// --- MAP STORE ---
|
||
// @ts-ignore
|
||
import { languageInstance } from "@shared";
|
||
import { makeAutoObservable } from "mobx";
|
||
|
||
import { stationsStore, routeStore, sightsStore } from "@shared";
|
||
|
||
interface ApiRoute {
|
||
id: number;
|
||
route_number: string;
|
||
path: [number, number][];
|
||
center_latitude: number;
|
||
center_longitude: number;
|
||
}
|
||
|
||
interface ApiStation {
|
||
id: number;
|
||
name: string;
|
||
latitude: number;
|
||
longitude: number;
|
||
}
|
||
|
||
interface ApiSight {
|
||
id: number;
|
||
name: string;
|
||
description: string;
|
||
latitude: number;
|
||
longitude: number;
|
||
}
|
||
|
||
class MapStore {
|
||
constructor() {
|
||
makeAutoObservable(this);
|
||
}
|
||
|
||
routes: ApiRoute[] = [];
|
||
stations: ApiStation[] = [];
|
||
sights: ApiSight[] = [];
|
||
|
||
getRoutes = async () => {
|
||
const response = await languageInstance("ru").get("/route");
|
||
const routesIds = response.data.map((route: any) => route.id);
|
||
const routePromises = routesIds.map((id: number) =>
|
||
languageInstance("ru").get(`/route/${id}`)
|
||
);
|
||
const routeResponses = await Promise.all(routePromises);
|
||
this.routes = routeResponses.map((res) => res.data);
|
||
|
||
this.routes = this.routes.sort((a, b) =>
|
||
a.route_number.localeCompare(b.route_number)
|
||
);
|
||
};
|
||
|
||
getStations = async () => {
|
||
const stations = await languageInstance("ru").get("/station");
|
||
this.stations = stations.data.map((station: any) => ({
|
||
...station,
|
||
}));
|
||
};
|
||
|
||
getSights = async () => {
|
||
const sights = await languageInstance("ru").get("/sight");
|
||
this.sights = sights.data.map((sight: any) => ({
|
||
...sight,
|
||
}));
|
||
};
|
||
|
||
deleteFeature = async (featureType: string, id: number) => {
|
||
await languageInstance("ru").delete(`/${featureType}/${id}`);
|
||
if (featureType === "route") {
|
||
this.routes = this.routes.filter((route) => route.id !== id);
|
||
} else if (featureType === "station") {
|
||
this.stations = this.stations.filter((station) => station.id !== id);
|
||
} else if (featureType === "sight") {
|
||
this.sights = this.sights.filter((sight) => sight.id !== id);
|
||
}
|
||
};
|
||
|
||
createFeature = async (featureType: string, geoJsonFeature: any) => {
|
||
const { geometry, properties } = geoJsonFeature;
|
||
let createdItem;
|
||
|
||
if (featureType === "station") {
|
||
const name = properties.name || "Остановка 1";
|
||
const latitude = geometry.coordinates[1];
|
||
const longitude = geometry.coordinates[0];
|
||
|
||
stationsStore.setLanguageCreateStationData("ru", {
|
||
name,
|
||
address: "",
|
||
system_name: name,
|
||
});
|
||
stationsStore.setLanguageCreateStationData("en", {
|
||
name,
|
||
address: "",
|
||
system_name: name,
|
||
});
|
||
stationsStore.setLanguageCreateStationData("zh", {
|
||
name,
|
||
address: "",
|
||
system_name: name,
|
||
});
|
||
stationsStore.setCreateCommonData({ latitude, longitude, city_id: 1 });
|
||
|
||
await stationsStore.createStation();
|
||
createdItem =
|
||
stationsStore.stationLists["ru"].data[
|
||
stationsStore.stationLists["ru"].data.length - 1
|
||
];
|
||
} else if (featureType === "route") {
|
||
const route_number = properties.name || "Маршрут 1";
|
||
const path = geometry.coordinates.map((c: any) => [c[1], c[0]]);
|
||
|
||
const lineGeom = new GeoJSON().readGeometry(geometry, {
|
||
dataProjection: "EPSG:4326",
|
||
featureProjection: "EPSG:3857",
|
||
});
|
||
const centerCoords = getCenter(lineGeom.getExtent());
|
||
const [center_longitude, center_latitude] = toLonLat(
|
||
centerCoords,
|
||
"EPSG:3857"
|
||
);
|
||
|
||
const routeData = {
|
||
route_number,
|
||
path,
|
||
center_latitude,
|
||
center_longitude,
|
||
carrier: "",
|
||
carrier_id: 0,
|
||
governor_appeal: 0,
|
||
rotate: 0,
|
||
route_direction: false,
|
||
route_sys_number: route_number,
|
||
scale_max: 0,
|
||
scale_min: 0,
|
||
};
|
||
|
||
await routeStore.createRoute(routeData);
|
||
createdItem = routeStore.routes.data[routeStore.routes.data.length - 1];
|
||
} else if (featureType === "sight") {
|
||
const name = properties.name || "Достопримечательность 1";
|
||
const latitude = geometry.coordinates[1];
|
||
const longitude = geometry.coordinates[0];
|
||
|
||
sightsStore.updateCreateSight("ru", { name, address: "" });
|
||
sightsStore.updateCreateSight("en", { name, address: "" });
|
||
sightsStore.updateCreateSight("zh", { name, address: "" });
|
||
|
||
await sightsStore.createSightAction(1, { latitude, longitude });
|
||
createdItem = sightsStore.sights[sightsStore.sights.length - 1];
|
||
} else {
|
||
throw new Error(`Unknown feature type for creation: ${featureType}`);
|
||
}
|
||
|
||
// @ts-ignore
|
||
if (featureType === "route") this.routes.push(createdItem);
|
||
// @ts-ignore
|
||
else if (featureType === "station") this.stations.push(createdItem);
|
||
// @ts-ignore
|
||
else if (featureType === "sight") this.sights.push(createdItem);
|
||
|
||
return createdItem;
|
||
};
|
||
|
||
updateFeature = async (featureType: string, geoJsonFeature: any) => {
|
||
const { geometry, properties, id } = geoJsonFeature;
|
||
const numericId = parseInt(String(id).split("-")[1], 10);
|
||
if (isNaN(numericId)) {
|
||
throw new Error(`Invalid feature ID for update: ${id}`);
|
||
}
|
||
|
||
let data;
|
||
if (featureType === "station") {
|
||
data = {
|
||
name: properties.name,
|
||
latitude: geometry.coordinates[1],
|
||
longitude: geometry.coordinates[0],
|
||
};
|
||
} else if (featureType === "route") {
|
||
const lineGeom = new GeoJSON().readGeometry(geometry, {
|
||
dataProjection: "EPSG:4326",
|
||
featureProjection: "EPSG:3857",
|
||
});
|
||
const centerCoords = getCenter(lineGeom.getExtent());
|
||
const [center_longitude, center_latitude] = toLonLat(
|
||
centerCoords,
|
||
"EPSG:3857"
|
||
);
|
||
data = {
|
||
route_number: properties.name,
|
||
path: geometry.coordinates.map((coord: any) => [coord[1], coord[0]]),
|
||
center_latitude,
|
||
center_longitude,
|
||
};
|
||
} else if (featureType === "sight") {
|
||
data = {
|
||
name: properties.name,
|
||
description: properties.description,
|
||
latitude: geometry.coordinates[1],
|
||
longitude: geometry.coordinates[0],
|
||
};
|
||
} else {
|
||
throw new Error(`Unknown feature type for update: ${featureType}`);
|
||
}
|
||
|
||
const findOldData = (store: any[], id: number) =>
|
||
store.find((f: any) => f.id === id);
|
||
let oldData;
|
||
if (featureType === "route") oldData = findOldData(this.routes, numericId);
|
||
else if (featureType === "station")
|
||
oldData = findOldData(this.stations, numericId);
|
||
else if (featureType === "sight")
|
||
oldData = findOldData(this.sights, numericId);
|
||
|
||
if (!oldData) {
|
||
if (properties.isProxy) {
|
||
return;
|
||
}
|
||
throw new Error(
|
||
`Could not find old data for ${featureType} with id ${numericId}`
|
||
);
|
||
}
|
||
|
||
let requestBody: any;
|
||
if (featureType === "route") {
|
||
requestBody = {
|
||
...oldData,
|
||
...data,
|
||
};
|
||
} else {
|
||
requestBody = { ...oldData, ...data };
|
||
}
|
||
|
||
if (properties.isProxy) {
|
||
return requestBody;
|
||
}
|
||
|
||
const response = await languageInstance("ru").patch(
|
||
`/${featureType}/${numericId}`,
|
||
requestBody
|
||
);
|
||
|
||
const updateStore = (store: any[], updatedItem: any) => {
|
||
const index = store.findIndex((f) => f.id === updatedItem.id);
|
||
if (index !== -1) store[index] = updatedItem;
|
||
else store.push(updatedItem);
|
||
};
|
||
|
||
if (featureType === "route") updateStore(this.routes, response.data);
|
||
else if (featureType === "station")
|
||
updateStore(this.stations, response.data);
|
||
else if (featureType === "sight") updateStore(this.sights, response.data);
|
||
|
||
return response.data;
|
||
};
|
||
}
|
||
|
||
const mapStore = new MapStore();
|
||
|
||
// --- CONFIGURATION ---
|
||
export const mapConfig = {
|
||
center: [30.311, 59.94] as [number, number],
|
||
zoom: 13,
|
||
};
|
||
|
||
// --- MAP POSITION STORAGE ---
|
||
const MAP_POSITION_KEY = "mapPosition";
|
||
const ACTIVE_SECTION_KEY = "mapActiveSection";
|
||
|
||
interface MapPosition {
|
||
center: [number, number];
|
||
zoom: number;
|
||
}
|
||
|
||
const getStoredMapPosition = (): MapPosition | null => {
|
||
try {
|
||
const stored = localStorage.getItem(MAP_POSITION_KEY);
|
||
if (stored) {
|
||
const position = JSON.parse(stored);
|
||
if (
|
||
position &&
|
||
Array.isArray(position.center) &&
|
||
position.center.length === 2 &&
|
||
typeof position.zoom === "number" &&
|
||
position.zoom >= 0 &&
|
||
position.zoom <= 20
|
||
) {
|
||
return position;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn("Failed to parse stored map position:", error);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const saveMapPosition = (position: MapPosition): void => {
|
||
try {
|
||
localStorage.setItem(MAP_POSITION_KEY, JSON.stringify(position));
|
||
} catch (error) {
|
||
console.warn("Failed to save map position:", error);
|
||
}
|
||
};
|
||
|
||
// --- ACTIVE SECTION STORAGE ---
|
||
const getStoredActiveSection = (): string | null => {
|
||
try {
|
||
const stored = localStorage.getItem(ACTIVE_SECTION_KEY);
|
||
if (stored) {
|
||
return stored;
|
||
}
|
||
} catch (error) {
|
||
console.warn("Failed to get stored active section:", error);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const saveActiveSection = (section: string | null): void => {
|
||
try {
|
||
if (section) {
|
||
localStorage.setItem(ACTIVE_SECTION_KEY, section);
|
||
} else {
|
||
localStorage.removeItem(ACTIVE_SECTION_KEY);
|
||
}
|
||
} catch (error) {
|
||
console.warn("Failed to save active section:", error);
|
||
}
|
||
};
|
||
|
||
// --- SVG ICONS ---
|
||
const EditIcon = () => (
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className="h-4 w-4 mr-1 sm:mr-2"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||
/>
|
||
</svg>
|
||
);
|
||
const LineIconSvg = () => (
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className="h-4 w-4 mr-1 sm:mr-2"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2h10a2 2 0 002-2v-1a2 2 0 012-2h1.945M7.732 4.064A2.5 2.5 0 105.23 6.24m13.54 0a2.5 2.5 0 10-2.502-2.176M12 16.05V21m0-17.948V3"
|
||
/>
|
||
</svg>
|
||
);
|
||
|
||
// --- TYPE DEFINITIONS ---
|
||
interface MapServiceConfig {
|
||
target: HTMLElement;
|
||
center: [number, number];
|
||
zoom: number;
|
||
}
|
||
|
||
interface HistoryState {
|
||
state: string;
|
||
}
|
||
|
||
type FeatureType = "station" | "route" | "sight";
|
||
|
||
class MapService {
|
||
private map: Map | null;
|
||
public pointSource: VectorSource<Feature<Point>>;
|
||
public lineSource: VectorSource<Feature<LineString>>;
|
||
public clusterLayer: VectorLayer<Cluster>; // Public for the deselect handler
|
||
public routeLayer: VectorLayer<VectorSource<Feature<LineString>>>; // Public for deselect
|
||
private clusterSource: Cluster;
|
||
private clusterStyleCache: { [key: number]: Style };
|
||
private unclusteredRouteIds: Set<string | number> = new Set();
|
||
private tooltipElement: HTMLElement;
|
||
private tooltipOverlay: Overlay | null;
|
||
private mode: string | null;
|
||
// @ts-ignore
|
||
private currentDrawingType: "Point" | "LineString" | null;
|
||
private currentDrawingFeatureType: FeatureType | null;
|
||
private currentInteraction: Draw | null;
|
||
private modifyInteraction: Modify;
|
||
private selectInteraction: Select;
|
||
private hoveredFeatureId: string | number | null;
|
||
private history: HistoryState[];
|
||
private historyIndex: number;
|
||
private beforeActionState: string | null = null;
|
||
private boundHandlePointerMove: (
|
||
event: MapBrowserEvent<PointerEvent>
|
||
) => void;
|
||
private boundHandlePointerLeave: () => void;
|
||
private boundHandleContextMenu: (event: MouseEvent) => void;
|
||
private boundHandleKeyDown: (event: KeyboardEvent) => void;
|
||
private lassoInteraction: Draw | null = null;
|
||
private selectedIds: Set<string | number> = new Set();
|
||
private onSelectionChange: ((ids: Set<string | number>) => void) | null =
|
||
null;
|
||
|
||
// Styles
|
||
private defaultStyle: Style;
|
||
private selectedStyle: Style;
|
||
private drawStyle: Style;
|
||
private busIconStyle: Style;
|
||
private selectedBusIconStyle: Style;
|
||
private drawBusIconStyle: Style;
|
||
private sightIconStyle: Style;
|
||
private selectedSightIconStyle: Style;
|
||
private drawSightIconStyle: Style;
|
||
private routeIconStyle: Style;
|
||
private selectedRouteIconStyle: Style;
|
||
private universalHoverStylePoint: Style;
|
||
private hoverSightIconStyle: Style;
|
||
private universalHoverStyleLine: Style;
|
||
|
||
// Callbacks
|
||
private setLoading: (loading: boolean) => void;
|
||
private setError: (error: string | null) => void;
|
||
private onModeChangeCallback: (mode: string) => void;
|
||
private onFeaturesChange: (features: Feature<Geometry>[]) => void;
|
||
private onFeatureSelect: (feature: Feature<Geometry> | null) => void;
|
||
|
||
constructor(
|
||
config: MapServiceConfig,
|
||
setLoading: (loading: boolean) => void,
|
||
setError: (error: string | null) => void,
|
||
onModeChangeCallback: (mode: string) => void,
|
||
onFeaturesChange: (features: Feature<Geometry>[]) => void,
|
||
onFeatureSelect: (feature: Feature<Geometry> | null) => void,
|
||
tooltipElement: HTMLElement,
|
||
onSelectionChange?: (ids: Set<string | number>) => void
|
||
) {
|
||
this.map = null;
|
||
this.tooltipElement = tooltipElement;
|
||
this.tooltipOverlay = null;
|
||
this.mode = null;
|
||
this.currentDrawingType = null;
|
||
this.currentDrawingFeatureType = null;
|
||
this.currentInteraction = null;
|
||
this.hoveredFeatureId = null;
|
||
this.history = [];
|
||
this.historyIndex = -1;
|
||
this.clusterStyleCache = {};
|
||
|
||
this.setLoading = setLoading;
|
||
this.setError = setError;
|
||
this.onModeChangeCallback = onModeChangeCallback;
|
||
this.onFeaturesChange = onFeaturesChange;
|
||
this.onFeatureSelect = onFeatureSelect;
|
||
|
||
this.defaultStyle = new Style({
|
||
fill: new Fill({ color: "rgba(66, 153, 225, 0.2)" }),
|
||
stroke: new Stroke({ color: "#3182ce", width: 8 }),
|
||
});
|
||
|
||
this.selectedStyle = new Style({
|
||
fill: new Fill({ color: "rgba(221, 107, 32, 0.3)" }),
|
||
stroke: new Stroke({ color: "#dd6b20", width: 8 }),
|
||
});
|
||
|
||
this.drawStyle = new Style({
|
||
fill: new Fill({ color: "rgba(74, 222, 128, 0.3)" }),
|
||
stroke: new Stroke({
|
||
color: "rgba(34, 197, 94, 0.7)",
|
||
width: 8,
|
||
lineDash: [5, 5],
|
||
}),
|
||
image: new CircleStyle({
|
||
radius: 5,
|
||
fill: new Fill({ color: "rgba(34, 197, 94, 0.7)" }),
|
||
}),
|
||
});
|
||
|
||
this.busIconStyle = new Style({
|
||
image: new CircleStyle({
|
||
radius: 8,
|
||
fill: new Fill({ color: "rgba(0, 60, 255, 0.8)" }),
|
||
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
|
||
}),
|
||
});
|
||
this.selectedBusIconStyle = new Style({
|
||
image: new CircleStyle({
|
||
radius: 10,
|
||
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
|
||
stroke: new Stroke({ color: "#ffffff", width: 2 }),
|
||
}),
|
||
});
|
||
this.drawBusIconStyle = new Style({
|
||
image: new CircleStyle({
|
||
radius: 8,
|
||
fill: new Fill({ color: "rgba(100, 180, 100, 0.8)" }),
|
||
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
|
||
}),
|
||
});
|
||
|
||
this.routeIconStyle = new Style({
|
||
image: new CircleStyle({
|
||
radius: 8,
|
||
fill: new Fill({ color: "rgba(34, 197, 94, 0.8)" }), // Green
|
||
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
|
||
}),
|
||
});
|
||
this.selectedRouteIconStyle = new Style({
|
||
image: new CircleStyle({
|
||
radius: 10,
|
||
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }), // Orange on select
|
||
stroke: new Stroke({ color: "#ffffff", width: 2 }),
|
||
}),
|
||
});
|
||
|
||
this.sightIconStyle = new Style({
|
||
image: new RegularShape({
|
||
fill: new Fill({ color: "rgba(139, 92, 246, 0.8)" }),
|
||
stroke: new Stroke({ color: "#ffffff", width: 2 }),
|
||
points: 5,
|
||
radius: 12,
|
||
radius2: 6,
|
||
angle: 0,
|
||
}),
|
||
});
|
||
this.selectedSightIconStyle = new Style({
|
||
image: new RegularShape({
|
||
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
|
||
stroke: new Stroke({ color: "#ffffff", width: 2 }),
|
||
points: 5,
|
||
radius: 12,
|
||
radius2: 6,
|
||
angle: 0,
|
||
}),
|
||
});
|
||
this.drawSightIconStyle = new Style({
|
||
image: new RegularShape({
|
||
fill: new Fill({ color: "rgba(100, 180, 100, 0.8)" }),
|
||
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
|
||
points: 5,
|
||
radius: 12,
|
||
radius2: 6,
|
||
angle: 0,
|
||
}),
|
||
});
|
||
|
||
this.universalHoverStylePoint = new Style({
|
||
image: new CircleStyle({
|
||
radius: 11,
|
||
fill: new Fill({ color: "rgba(255, 165, 0, 0.7)" }),
|
||
stroke: new Stroke({ color: "rgba(255,255,255,0.8)", width: 2 }),
|
||
}),
|
||
zIndex: Infinity,
|
||
});
|
||
this.hoverSightIconStyle = new Style({
|
||
image: new RegularShape({
|
||
fill: new Fill({ color: "rgba(255, 165, 0, 0.7)" }),
|
||
stroke: new Stroke({ color: "rgba(255,255,255,0.8)", width: 2 }),
|
||
points: 5,
|
||
radius: 15,
|
||
radius2: 7.5,
|
||
angle: 0,
|
||
}),
|
||
zIndex: Infinity,
|
||
});
|
||
this.universalHoverStyleLine = new Style({
|
||
stroke: new Stroke({ color: "rgba(255, 165, 0, 0.8)", width: 8 }),
|
||
zIndex: Infinity,
|
||
});
|
||
|
||
this.pointSource = new VectorSource();
|
||
this.lineSource = new VectorSource();
|
||
this.clusterSource = new Cluster({
|
||
distance: 45,
|
||
source: this.pointSource,
|
||
});
|
||
|
||
this.routeLayer = new VectorLayer({
|
||
source: this.lineSource,
|
||
// @ts-ignore
|
||
style: (featureLike: FeatureLike) => {
|
||
const feature = featureLike as Feature<Geometry>;
|
||
if (!feature) return this.defaultStyle;
|
||
const fId = feature.getId();
|
||
|
||
if (fId === undefined || !this.unclusteredRouteIds.has(fId)) {
|
||
return null;
|
||
}
|
||
|
||
const isSelected =
|
||
this.selectInteraction?.getFeatures().getArray().includes(feature) ||
|
||
(fId !== undefined && this.selectedIds.has(fId));
|
||
const isHovered = this.hoveredFeatureId === fId;
|
||
|
||
if (isHovered) return this.universalHoverStyleLine;
|
||
if (isSelected) return this.selectedStyle;
|
||
return this.defaultStyle;
|
||
},
|
||
});
|
||
|
||
this.clusterLayer = new VectorLayer({
|
||
source: this.clusterSource,
|
||
style: (featureLike: FeatureLike) => {
|
||
const clusterFeature = featureLike as Feature<Point>;
|
||
const featuresInCluster = clusterFeature.get(
|
||
"features"
|
||
) as Feature<Point>[];
|
||
const size = featuresInCluster.length;
|
||
|
||
if (size > 1) {
|
||
let style = this.clusterStyleCache[size];
|
||
if (!style) {
|
||
style = new Style({
|
||
image: new CircleStyle({
|
||
radius: 12 + Math.log(size) * 3,
|
||
fill: new Fill({ color: "rgba(56, 189, 248, 0.9)" }),
|
||
stroke: new Stroke({ color: "#fff", width: 2 }),
|
||
}),
|
||
text: new Text({
|
||
text: size.toString(),
|
||
fill: new Fill({ color: "#fff" }),
|
||
font: "bold 12px sans-serif",
|
||
}),
|
||
});
|
||
this.clusterStyleCache[size] = style;
|
||
}
|
||
return style;
|
||
} else {
|
||
const originalFeature = featuresInCluster[0];
|
||
const fId = originalFeature.getId();
|
||
const featureType = originalFeature.get("featureType");
|
||
const isProxy = originalFeature.get("isProxy");
|
||
if (isProxy) return new Style(); // Invisible empty style
|
||
|
||
const isSelected = fId !== undefined && this.selectedIds.has(fId);
|
||
const isHovered = this.hoveredFeatureId === fId;
|
||
|
||
if (isHovered) {
|
||
return featureType === "sight"
|
||
? this.hoverSightIconStyle
|
||
: this.universalHoverStylePoint;
|
||
}
|
||
|
||
if (isSelected) {
|
||
if (featureType === "sight") return this.selectedSightIconStyle;
|
||
if (featureType === "route") return this.selectedRouteIconStyle;
|
||
return this.selectedBusIconStyle;
|
||
}
|
||
|
||
if (featureType === "sight") return this.sightIconStyle;
|
||
if (featureType === "route") return this.routeIconStyle;
|
||
return this.busIconStyle;
|
||
}
|
||
},
|
||
});
|
||
|
||
this.clusterSource.on("change", () => {
|
||
const newUnclusteredRouteIds = new Set<string | number>();
|
||
this.clusterSource
|
||
.getFeatures()
|
||
.forEach((clusterFeature: Feature<any>) => {
|
||
const originalFeatures = clusterFeature.get(
|
||
"features"
|
||
) as Feature<Point>[];
|
||
if (originalFeatures && originalFeatures.length === 1) {
|
||
const originalFeature = originalFeatures[0];
|
||
if (originalFeature.get("featureType") === "route") {
|
||
const featureId = originalFeature.getId();
|
||
if (featureId !== undefined) {
|
||
newUnclusteredRouteIds.add(featureId);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (
|
||
newUnclusteredRouteIds.size !== this.unclusteredRouteIds.size ||
|
||
![...newUnclusteredRouteIds].every((id) =>
|
||
this.unclusteredRouteIds.has(id)
|
||
)
|
||
) {
|
||
this.unclusteredRouteIds = newUnclusteredRouteIds;
|
||
this.routeLayer.changed();
|
||
}
|
||
});
|
||
|
||
this.boundHandlePointerMove = this.handlePointerMove.bind(this);
|
||
this.boundHandlePointerLeave = this.handlePointerLeave.bind(this);
|
||
this.boundHandleContextMenu = this.handleContextMenu.bind(this);
|
||
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
|
||
|
||
this.pointSource.on(
|
||
"addfeature",
|
||
this.handleFeatureEvent.bind(this) as any
|
||
);
|
||
this.pointSource.on("removefeature", () => this.updateFeaturesInReact());
|
||
this.pointSource.on(
|
||
"changefeature",
|
||
this.handleFeatureChange.bind(this) as any
|
||
);
|
||
this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any);
|
||
this.lineSource.on("removefeature", () => this.updateFeaturesInReact());
|
||
this.lineSource.on(
|
||
"changefeature",
|
||
this.handleFeatureChange.bind(this) as any
|
||
);
|
||
|
||
let renderCompleteHandled = false;
|
||
const MAP_LOAD_TIMEOUT = 15000;
|
||
try {
|
||
const storedPosition = getStoredMapPosition();
|
||
const initialCenter = storedPosition?.center || config.center;
|
||
const initialZoom = storedPosition?.zoom || config.zoom;
|
||
|
||
this.map = new Map({
|
||
target: config.target,
|
||
layers: [
|
||
new TileLayer({ source: new OSM() }),
|
||
this.routeLayer,
|
||
this.clusterLayer,
|
||
],
|
||
view: new View({
|
||
center: transform(initialCenter, "EPSG:4326", "EPSG:3857"),
|
||
zoom: initialZoom,
|
||
}),
|
||
interactions: defaultInteractions({ doubleClickZoom: false }),
|
||
controls: [],
|
||
});
|
||
|
||
this.map.getView().on("change:center", () => {
|
||
const center = this.map?.getView().getCenter();
|
||
const zoom = this.map?.getView().getZoom();
|
||
if (center && zoom !== undefined && this.map) {
|
||
const [lon, lat] = toLonLat(
|
||
center,
|
||
this.map.getView().getProjection()
|
||
);
|
||
saveMapPosition({ center: [lon, lat], zoom });
|
||
}
|
||
});
|
||
|
||
this.map.getView().on("change:resolution", () => {
|
||
const center = this.map?.getView().getCenter();
|
||
const zoom = this.map?.getView().getZoom();
|
||
if (center && zoom !== undefined && this.map) {
|
||
const [lon, lat] = toLonLat(
|
||
center,
|
||
this.map.getView().getProjection()
|
||
);
|
||
saveMapPosition({ center: [lon, lat], zoom });
|
||
}
|
||
});
|
||
|
||
if (this.tooltipElement && this.map) {
|
||
this.tooltipOverlay = new Overlay({
|
||
element: this.tooltipElement,
|
||
offset: [15, 0],
|
||
positioning: "center-left",
|
||
});
|
||
this.map.addOverlay(this.tooltipOverlay);
|
||
}
|
||
this.map.once("rendercomplete", () => {
|
||
if (!renderCompleteHandled) {
|
||
this.setLoading(false);
|
||
renderCompleteHandled = true;
|
||
setError(null);
|
||
}
|
||
});
|
||
setTimeout(() => {
|
||
if (!renderCompleteHandled && this.map) {
|
||
this.setLoading(false);
|
||
this.setError("Карта не загрузилась вовремя.");
|
||
renderCompleteHandled = true;
|
||
}
|
||
}, MAP_LOAD_TIMEOUT);
|
||
} catch (error) {
|
||
this.setError("Критическая ошибка при инициализации карты.");
|
||
this.setLoading(false);
|
||
renderCompleteHandled = true;
|
||
}
|
||
|
||
this.selectInteraction = new Select({
|
||
style: null,
|
||
condition: singleClick,
|
||
filter: (feature: FeatureLike, l: Layer<Source, any> | null) => {
|
||
if (l !== this.clusterLayer && l !== this.routeLayer) return false;
|
||
const originalFeatures = feature.get("features");
|
||
if (
|
||
originalFeatures &&
|
||
originalFeatures.length === 1 &&
|
||
originalFeatures[0].get("isProxy")
|
||
)
|
||
return false; // Ignore proxy points
|
||
return true;
|
||
},
|
||
multi: true,
|
||
});
|
||
|
||
this.modifyInteraction = new Modify({
|
||
features: this.selectInteraction.getFeatures(),
|
||
style: new Style({
|
||
image: new CircleStyle({
|
||
radius: 6,
|
||
fill: new Fill({
|
||
color: "rgba(255, 255, 255, 0.8)",
|
||
}),
|
||
stroke: new Stroke({
|
||
color: "#0099ff",
|
||
width: 2.5,
|
||
}),
|
||
}),
|
||
}),
|
||
deleteCondition: (e: MapBrowserEvent<any>) => {
|
||
if (!doubleClick(e)) {
|
||
return false;
|
||
}
|
||
const selectedFeatures = this.selectInteraction.getFeatures();
|
||
if (selectedFeatures.getLength() !== 1) {
|
||
return true;
|
||
}
|
||
const feature = selectedFeatures.item(0) as Feature<Geometry>;
|
||
const geometry = feature.getGeometry();
|
||
if (!geometry || geometry.getType() !== "LineString") {
|
||
return true;
|
||
}
|
||
const lineString = geometry as LineString;
|
||
const coordinates = lineString.getCoordinates();
|
||
if (coordinates.length <= 2) {
|
||
toast.info("В маршруте должно быть не менее 2 точек.");
|
||
return false;
|
||
}
|
||
const clickCoordinate = e.coordinate;
|
||
let closestVertexIndex = -1;
|
||
let minDistanceSq = Infinity;
|
||
coordinates.forEach((vertex, index) => {
|
||
const dx = vertex[0] - clickCoordinate[0];
|
||
const dy = vertex[1] - clickCoordinate[1];
|
||
const distanceSq = dx * dx + dy * dy;
|
||
if (distanceSq < minDistanceSq) {
|
||
minDistanceSq = distanceSq;
|
||
closestVertexIndex = index;
|
||
}
|
||
});
|
||
if (
|
||
closestVertexIndex === 0 ||
|
||
closestVertexIndex === coordinates.length - 1
|
||
) {
|
||
return false;
|
||
}
|
||
return true;
|
||
},
|
||
});
|
||
|
||
// @ts-ignore
|
||
this.modifyInteraction.on("modifystart", () => {
|
||
if (!this.beforeActionState) {
|
||
this.beforeActionState = this.getCurrentStateAsGeoJSON();
|
||
}
|
||
});
|
||
|
||
this.modifyInteraction.on("modifyend", (event) => {
|
||
if (this.beforeActionState) {
|
||
this.addStateToHistory(this.beforeActionState);
|
||
}
|
||
event.features.getArray().forEach((feature) => {
|
||
this.saveModifiedFeature(feature as Feature<Geometry>);
|
||
});
|
||
this.beforeActionState = null;
|
||
});
|
||
|
||
if (this.map) {
|
||
this.map.on("dblclick", (event: MapBrowserEvent<any>) => {
|
||
if (this.mode !== "edit") return;
|
||
|
||
const layerFilter = (l: Layer<Source, any>) => l === this.routeLayer;
|
||
|
||
const feature = this.map?.forEachFeatureAtPixel(
|
||
event.pixel,
|
||
(f: FeatureLike) => f as Feature<Geometry>,
|
||
{ layerFilter, hitTolerance: 5 }
|
||
);
|
||
|
||
if (!feature) return;
|
||
|
||
const featureType = feature.get("featureType");
|
||
if (featureType !== "route") return;
|
||
|
||
const geometry = feature.getGeometry();
|
||
if (!geometry || geometry.getType() !== "LineString") return;
|
||
|
||
const lineString = geometry as LineString;
|
||
const coordinates = lineString.getCoordinates();
|
||
|
||
if (coordinates.length <= 2) {
|
||
toast.info("В маршруте должно быть не менее 2 точек.");
|
||
return;
|
||
}
|
||
|
||
const clickCoordinate = event.coordinate;
|
||
let closestIndex = -1;
|
||
let minDistanceSq = Infinity;
|
||
|
||
coordinates.forEach((vertex, index) => {
|
||
const dx = vertex[0] - clickCoordinate[0];
|
||
const dy = vertex[1] - clickCoordinate[1];
|
||
const distanceSq = dx * dx + dy * dy;
|
||
if (distanceSq < minDistanceSq) {
|
||
minDistanceSq = distanceSq;
|
||
closestIndex = index;
|
||
}
|
||
});
|
||
|
||
if (closestIndex === 0 || closestIndex === coordinates.length - 1) {
|
||
return;
|
||
}
|
||
|
||
const beforeState = this.getCurrentStateAsGeoJSON();
|
||
if (beforeState) {
|
||
this.addStateToHistory(beforeState);
|
||
}
|
||
|
||
const newCoordinates = coordinates.filter(
|
||
(_, index) => index !== closestIndex
|
||
);
|
||
lineString.setCoordinates(newCoordinates);
|
||
this.saveModifiedFeature(feature);
|
||
});
|
||
}
|
||
|
||
this.lassoInteraction = new Draw({
|
||
type: "Polygon",
|
||
style: new Style({
|
||
stroke: new Stroke({ color: "#14b8a6", width: 2 }),
|
||
fill: new Fill({ color: "rgba(20, 184, 166, 0.1)" }),
|
||
}),
|
||
});
|
||
this.lassoInteraction.setActive(false);
|
||
this.lassoInteraction.on("drawend", (event: DrawEvent) => {
|
||
const geometry = event.feature.getGeometry() as Polygon;
|
||
const extent = geometry.getExtent();
|
||
const selected = new Set<string | number>();
|
||
|
||
this.pointSource.forEachFeatureInExtent(extent, (f) => {
|
||
if (f.get("isProxy")) return; // Ignore proxy in lasso
|
||
const geom = f.getGeometry();
|
||
if (geom && geom.getType() === "Point") {
|
||
const pointCoords = (geom as Point).getCoordinates();
|
||
if (geometry.intersectsCoordinate(pointCoords)) {
|
||
if (f.getId() !== undefined) selected.add(f.getId()!);
|
||
}
|
||
}
|
||
});
|
||
|
||
this.lineSource.forEachFeatureInExtent(
|
||
extent,
|
||
(f: Feature<LineString>) => {
|
||
const lineGeom = f.getGeometry();
|
||
if (lineGeom) {
|
||
const intersects = lineGeom
|
||
.getCoordinates()
|
||
.some((coord) => geometry.intersectsCoordinate(coord));
|
||
if (intersects && f.getId() !== undefined) {
|
||
selected.add(f.getId()!);
|
||
}
|
||
}
|
||
}
|
||
);
|
||
|
||
this.setSelectedIds(selected);
|
||
this.deactivateLasso();
|
||
});
|
||
|
||
if (this.map) {
|
||
this.map.addInteraction(this.modifyInteraction);
|
||
this.map.addInteraction(this.selectInteraction);
|
||
this.map.addInteraction(this.lassoInteraction);
|
||
this.modifyInteraction.setActive(false);
|
||
this.selectInteraction.setActive(false);
|
||
this.lassoInteraction.setActive(false);
|
||
|
||
// --- ИСПРАВЛЕНИЕ: Главный обработчик выбора объектов и кластеров
|
||
this.selectInteraction.on("select", (e: SelectEvent) => {
|
||
if (this.mode !== "edit" || !this.map) return;
|
||
|
||
const ctrlKey =
|
||
e.mapBrowserEvent.originalEvent.ctrlKey ||
|
||
e.mapBrowserEvent.originalEvent.metaKey;
|
||
|
||
// Проверяем, был ли клик по кластеру (группе)
|
||
if (e.selected.length === 1 && !ctrlKey) {
|
||
const clickedFeature = e.selected[0];
|
||
const originalFeatures = clickedFeature.get("features");
|
||
|
||
if (originalFeatures && originalFeatures.length > 1) {
|
||
// Если да, то приближаем карту
|
||
const extent = createEmpty();
|
||
originalFeatures.forEach((feat: Feature<Point>) => {
|
||
const geom = feat.getGeometry();
|
||
if (geom) extend(extent, geom.getExtent());
|
||
});
|
||
this.map.getView().fit(extent, {
|
||
duration: 500,
|
||
padding: [60, 60, 60, 60],
|
||
maxZoom: 18,
|
||
});
|
||
// Сбрасываем выделение, так как мы не хотим "выделять" сам кластер
|
||
this.selectInteraction.getFeatures().clear();
|
||
this.setSelectedIds(new Set());
|
||
return; // Завершаем обработку
|
||
}
|
||
}
|
||
|
||
// Стандартная логика выделения для одиночных объектов (или с Ctrl)
|
||
const newSelectedIds = ctrlKey
|
||
? new Set(this.selectedIds)
|
||
: new Set<string | number>();
|
||
|
||
e.selected.forEach((feature) => {
|
||
const originalFeatures = feature.get("features");
|
||
let targetId: string | number | undefined;
|
||
|
||
if (originalFeatures && originalFeatures.length > 0) {
|
||
// Это фича из кластера (может быть и одна)
|
||
targetId = originalFeatures[0].getId();
|
||
} else {
|
||
// Это линия или что-то не из кластера
|
||
targetId = feature.getId();
|
||
}
|
||
|
||
if (targetId !== undefined) {
|
||
newSelectedIds.add(targetId);
|
||
}
|
||
});
|
||
|
||
e.deselected.forEach((feature) => {
|
||
const originalFeatures = feature.get("features");
|
||
let targetId: string | number | undefined;
|
||
|
||
if (originalFeatures && originalFeatures.length > 0) {
|
||
targetId = originalFeatures[0].getId();
|
||
} else {
|
||
targetId = feature.getId();
|
||
}
|
||
|
||
if (targetId !== undefined) {
|
||
newSelectedIds.delete(targetId);
|
||
}
|
||
});
|
||
|
||
this.setSelectedIds(newSelectedIds);
|
||
});
|
||
|
||
this.map.on("pointermove", this.boundHandlePointerMove as any);
|
||
const targetEl = this.map.getTargetElement();
|
||
if (targetEl instanceof HTMLElement) {
|
||
targetEl.addEventListener("contextmenu", this.boundHandleContextMenu);
|
||
targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave);
|
||
}
|
||
document.addEventListener("keydown", this.boundHandleKeyDown);
|
||
this.activateEditMode();
|
||
}
|
||
|
||
if (onSelectionChange) {
|
||
this.onSelectionChange = onSelectionChange;
|
||
}
|
||
}
|
||
|
||
public unselect(): void {
|
||
this.selectInteraction.getFeatures().clear();
|
||
this.modifyInteraction.setActive(false);
|
||
this.onFeatureSelect(null);
|
||
this.setSelectedIds(new Set());
|
||
}
|
||
|
||
public loadFeaturesFromApi(
|
||
apiStations: typeof mapStore.stations,
|
||
apiRoutes: typeof mapStore.routes,
|
||
apiSights: typeof mapStore.sights
|
||
): void {
|
||
if (!this.map) return;
|
||
|
||
const projection = this.map.getView().getProjection();
|
||
const pointFeatures: Feature<Point>[] = [];
|
||
const lineFeatures: Feature<LineString>[] = [];
|
||
|
||
apiStations.forEach((station) => {
|
||
if (station.longitude == null || station.latitude == null) return;
|
||
const point = new Point(
|
||
transform(
|
||
[station.longitude, station.latitude],
|
||
"EPSG:4326",
|
||
projection
|
||
)
|
||
);
|
||
const feature = new Feature({ geometry: point, name: station.name });
|
||
feature.setId(`station-${station.id}`);
|
||
feature.set("featureType", "station");
|
||
pointFeatures.push(feature);
|
||
});
|
||
|
||
apiSights.forEach((sight) => {
|
||
if (sight.longitude == null || sight.latitude == null) return;
|
||
const point = new Point(
|
||
transform([sight.longitude, sight.latitude], "EPSG:4326", projection)
|
||
);
|
||
const feature = new Feature({
|
||
geometry: point,
|
||
name: sight.name,
|
||
description: sight.description,
|
||
});
|
||
feature.setId(`sight-${sight.id}`);
|
||
feature.set("featureType", "sight");
|
||
pointFeatures.push(feature);
|
||
});
|
||
|
||
apiRoutes.forEach((route) => {
|
||
if (!route.path || route.path.length === 0) return;
|
||
const coordinates = route.path
|
||
.filter((c) => c && c[0] != null && c[1] != null)
|
||
.map((c: [number, number]) =>
|
||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||
);
|
||
if (coordinates.length === 0) return;
|
||
|
||
const routeId = `route-${route.id}`;
|
||
|
||
const line = new LineString(coordinates);
|
||
const lineFeature = new Feature({
|
||
geometry: line,
|
||
name: route.route_number,
|
||
});
|
||
lineFeature.setId(routeId);
|
||
lineFeature.set("featureType", "route");
|
||
lineFeatures.push(lineFeature);
|
||
|
||
if (route.center_longitude != null && route.center_latitude != null) {
|
||
const centerPoint = new Point(
|
||
transform(
|
||
[route.center_longitude, route.center_latitude],
|
||
"EPSG:4326",
|
||
projection
|
||
)
|
||
);
|
||
const proxyPointFeature = new Feature({
|
||
geometry: centerPoint,
|
||
name: route.route_number,
|
||
isProxy: true,
|
||
});
|
||
proxyPointFeature.setId(routeId);
|
||
proxyPointFeature.set("featureType", "route");
|
||
pointFeatures.push(proxyPointFeature);
|
||
}
|
||
});
|
||
|
||
this.pointSource.addFeatures(pointFeatures);
|
||
this.lineSource.addFeatures(lineFeatures);
|
||
|
||
this.updateFeaturesInReact();
|
||
const initialState = this.getCurrentStateAsGeoJSON();
|
||
if (initialState) {
|
||
this.addStateToHistory(initialState);
|
||
}
|
||
}
|
||
|
||
private addStateToHistory(stateToSave: string): void {
|
||
this.history = this.history.slice(0, this.historyIndex + 1);
|
||
this.history.push({ state: stateToSave });
|
||
this.historyIndex = this.history.length - 1;
|
||
}
|
||
|
||
private getCurrentStateAsGeoJSON(): string | null {
|
||
if (!this.map) return null;
|
||
const geoJSONFormat = new GeoJSON();
|
||
const allFeatures = [
|
||
...this.pointSource.getFeatures(),
|
||
...this.lineSource.getFeatures(),
|
||
];
|
||
return geoJSONFormat.writeFeatures(allFeatures, {
|
||
dataProjection: "EPSG:4326",
|
||
featureProjection: this.map.getView().getProjection().getCode(),
|
||
});
|
||
}
|
||
|
||
private applyHistoryState(geoJSONState: string) {
|
||
if (!this.map) return;
|
||
const projection = this.map.getView().getProjection();
|
||
const geoJSONFormat = new GeoJSON({
|
||
dataProjection: "EPSG:4326",
|
||
featureProjection: projection.getCode(),
|
||
});
|
||
const features = geoJSONFormat.readFeatures(
|
||
geoJSONState
|
||
) as Feature<Geometry>[];
|
||
|
||
this.unselect();
|
||
this.pointSource.clear();
|
||
this.lineSource.clear();
|
||
|
||
const pointFeatures: Feature<Point>[] = [];
|
||
const lineFeatures: Feature<LineString>[] = [];
|
||
|
||
features.forEach((feature) => {
|
||
const featureType = feature.get("featureType");
|
||
const isProxy = feature.get("isProxy");
|
||
if (featureType === "route" && !isProxy) {
|
||
lineFeatures.push(feature as Feature<LineString>);
|
||
} else {
|
||
pointFeatures.push(feature as Feature<Point>);
|
||
}
|
||
});
|
||
|
||
this.pointSource.addFeatures(pointFeatures);
|
||
this.lineSource.addFeatures(lineFeatures);
|
||
|
||
this.updateFeaturesInReact();
|
||
|
||
const newStations: ApiStation[] = [];
|
||
const newRoutes: ApiRoute[] = [];
|
||
const newSights: ApiSight[] = [];
|
||
|
||
features.forEach((feature) => {
|
||
const id = feature.getId();
|
||
if (!id || feature.get("isProxy")) return;
|
||
|
||
const [featureType, numericIdStr] = String(id).split("-");
|
||
const numericId = parseInt(numericIdStr, 10);
|
||
if (isNaN(numericId)) return;
|
||
const geometry = feature.getGeometry();
|
||
if (!geometry) return;
|
||
const properties = feature.getProperties();
|
||
|
||
if (featureType === "station") {
|
||
const coords = (geometry as Point).getCoordinates();
|
||
const [lon, lat] = toLonLat(coords, projection);
|
||
newStations.push({
|
||
id: numericId,
|
||
name: properties.name,
|
||
latitude: lat,
|
||
longitude: lon,
|
||
});
|
||
} else if (featureType === "sight") {
|
||
const coords = (geometry as Point).getCoordinates();
|
||
const [lon, lat] = toLonLat(coords, projection);
|
||
newSights.push({
|
||
id: numericId,
|
||
name: properties.name,
|
||
description: properties.description,
|
||
latitude: lat,
|
||
longitude: lon,
|
||
});
|
||
} else if (featureType === "route") {
|
||
const coords = (geometry as LineString).getCoordinates();
|
||
const path = coords.map((c) => {
|
||
const [lon, lat] = toLonLat(c, projection);
|
||
return [lat, lon] as [number, number];
|
||
});
|
||
|
||
const centerCoords = getCenter(geometry.getExtent());
|
||
const [center_longitude, center_latitude] = toLonLat(
|
||
centerCoords,
|
||
projection
|
||
);
|
||
|
||
newRoutes.push({
|
||
id: numericId,
|
||
route_number: properties.name,
|
||
path: path,
|
||
center_latitude,
|
||
center_longitude,
|
||
});
|
||
}
|
||
});
|
||
|
||
mapStore.stations = newStations;
|
||
mapStore.routes = newRoutes.sort((a, b) =>
|
||
a.route_number.localeCompare(b.route_number)
|
||
);
|
||
mapStore.sights = newSights;
|
||
}
|
||
|
||
public undo(): void {
|
||
if (this.historyIndex > 0) {
|
||
this.historyIndex--;
|
||
const stateToRestore = this.history[this.historyIndex].state;
|
||
this.applyHistoryState(stateToRestore);
|
||
|
||
const features = [
|
||
...this.pointSource.getFeatures().filter((f) => !f.get("isProxy")),
|
||
...this.lineSource.getFeatures(),
|
||
];
|
||
const updatePromises = features.map((feature) => {
|
||
const featureType = feature.get("featureType");
|
||
const geoJSONFormat = new GeoJSON({
|
||
dataProjection: "EPSG:4326",
|
||
featureProjection: this.map?.getView().getProjection().getCode(),
|
||
});
|
||
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
|
||
return mapStore.updateFeature(featureType, featureGeoJSON);
|
||
});
|
||
|
||
Promise.all(updatePromises)
|
||
.then(() => {})
|
||
.catch((error) => {
|
||
console.error("Failed to update backend after undo:", error);
|
||
this.historyIndex++;
|
||
const previousState = this.history[this.historyIndex].state;
|
||
this.applyHistoryState(previousState);
|
||
});
|
||
} else {
|
||
toast.info("Больше отменять нечего");
|
||
}
|
||
}
|
||
|
||
public redo(): void {
|
||
if (this.historyIndex < this.history.length - 1) {
|
||
this.historyIndex++;
|
||
const stateToRestore = this.history[this.historyIndex].state;
|
||
this.applyHistoryState(stateToRestore);
|
||
|
||
const features = [
|
||
...this.pointSource.getFeatures().filter((f) => !f.get("isProxy")),
|
||
...this.lineSource.getFeatures(),
|
||
];
|
||
const updatePromises = features.map((feature) => {
|
||
const featureType = feature.get("featureType");
|
||
const geoJSONFormat = new GeoJSON({
|
||
dataProjection: "EPSG:4326",
|
||
featureProjection: this.map?.getView().getProjection().getCode(),
|
||
});
|
||
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
|
||
return mapStore.updateFeature(featureType, featureGeoJSON);
|
||
});
|
||
|
||
Promise.all(updatePromises)
|
||
.then(() => {
|
||
toast.info("Действие повторено");
|
||
})
|
||
.catch((error) => {
|
||
console.error("Failed to update backend after redo:", error);
|
||
toast.error("Не удалось обновить данные на сервере");
|
||
this.historyIndex--;
|
||
const previousState = this.history[this.historyIndex].state;
|
||
this.applyHistoryState(previousState);
|
||
});
|
||
} else {
|
||
toast.info("Больше повторять нечего");
|
||
}
|
||
}
|
||
|
||
private updateFeaturesInReact(): void {
|
||
if (this.onFeaturesChange) {
|
||
const allFeatures = [
|
||
...this.pointSource.getFeatures(),
|
||
...this.lineSource.getFeatures(),
|
||
];
|
||
this.onFeaturesChange(allFeatures);
|
||
}
|
||
}
|
||
|
||
private handlePointerLeave(): void {
|
||
if (this.hoveredFeatureId) {
|
||
this.hoveredFeatureId = null;
|
||
this.clusterLayer.changed();
|
||
this.routeLayer.changed();
|
||
}
|
||
if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
|
||
}
|
||
|
||
private handleKeyDown(event: KeyboardEvent): void {
|
||
if ((event.ctrlKey || event.metaKey) && event.key === "z") {
|
||
event.preventDefault();
|
||
this.undo();
|
||
return;
|
||
}
|
||
if ((event.ctrlKey || event.metaKey) && event.key === "y") {
|
||
event.preventDefault();
|
||
this.redo();
|
||
return;
|
||
}
|
||
if (event.key === "Escape") {
|
||
this.unselect();
|
||
}
|
||
}
|
||
|
||
private setMode(newMode: string): void {
|
||
if (!this.map) return;
|
||
const oldMode = this.mode;
|
||
this.mode = newMode;
|
||
if (this.onModeChangeCallback) this.onModeChangeCallback(newMode);
|
||
|
||
if (this.hoveredFeatureId && oldMode !== newMode) {
|
||
this.hoveredFeatureId = null;
|
||
this.clusterLayer.changed();
|
||
this.routeLayer.changed();
|
||
if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
|
||
}
|
||
|
||
if (this.currentInteraction instanceof Draw) {
|
||
this.map.removeInteraction(this.currentInteraction);
|
||
this.currentInteraction = null;
|
||
}
|
||
|
||
if (newMode === "edit") {
|
||
this.selectInteraction.setActive(true);
|
||
} else {
|
||
this.unselect();
|
||
this.selectInteraction.setActive(false);
|
||
}
|
||
}
|
||
|
||
public activateEditMode(): void {
|
||
this.currentDrawingType = null;
|
||
this.setMode("edit");
|
||
}
|
||
|
||
public startDrawing(
|
||
type: "Point" | "LineString",
|
||
featureType: FeatureType
|
||
): void {
|
||
if (!this.map) return;
|
||
|
||
this.currentDrawingType = type;
|
||
this.currentDrawingFeatureType = featureType;
|
||
|
||
const drawingMode = `drawing-${featureType}`;
|
||
this.setMode(drawingMode);
|
||
|
||
if (this.currentInteraction instanceof Draw) {
|
||
this.map.removeInteraction(this.currentInteraction);
|
||
}
|
||
|
||
let styleForDrawing: Style;
|
||
if (featureType === "station") styleForDrawing = this.drawBusIconStyle;
|
||
else if (featureType === "sight") styleForDrawing = this.drawSightIconStyle;
|
||
else styleForDrawing = this.drawStyle;
|
||
|
||
const sourceForDrawing =
|
||
type === "Point" ? this.pointSource : this.lineSource;
|
||
|
||
this.currentInteraction = new Draw({
|
||
source: sourceForDrawing,
|
||
type,
|
||
style: styleForDrawing,
|
||
});
|
||
|
||
this.currentInteraction.on("drawstart", () => {
|
||
this.beforeActionState = this.getCurrentStateAsGeoJSON();
|
||
});
|
||
|
||
this.currentInteraction.on("drawend", async (event: DrawEvent) => {
|
||
if (this.beforeActionState) {
|
||
this.addStateToHistory(this.beforeActionState);
|
||
}
|
||
this.beforeActionState = null;
|
||
|
||
const feature = event.feature as Feature<Geometry>;
|
||
const fType = this.currentDrawingFeatureType;
|
||
if (!fType) return;
|
||
feature.set("featureType", fType);
|
||
|
||
let resourceName: string;
|
||
const allFeatures = [
|
||
...this.pointSource.getFeatures(),
|
||
...this.lineSource.getFeatures(),
|
||
];
|
||
|
||
switch (fType) {
|
||
case "station":
|
||
const existingStations = allFeatures.filter(
|
||
(f) => f.get("featureType") === "station"
|
||
);
|
||
const stationNumbers = existingStations
|
||
.map((f) => {
|
||
const name = f.get("name") as string;
|
||
const match = name?.match(/^Остановка (\d+)$/);
|
||
return match ? parseInt(match[1], 10) : 0;
|
||
})
|
||
.filter((num) => num > 0);
|
||
const nextStationNumber =
|
||
stationNumbers.length > 0 ? Math.max(...stationNumbers) + 1 : 1;
|
||
resourceName = `Остановка ${nextStationNumber}`;
|
||
break;
|
||
case "sight":
|
||
const existingSights = allFeatures.filter(
|
||
(f) => f.get("featureType") === "sight"
|
||
);
|
||
const sightNumbers = existingSights
|
||
.map((f) => {
|
||
const name = f.get("name") as string;
|
||
const match = name?.match(/^Достопримечательность (\d+)$/);
|
||
return match ? parseInt(match[1], 10) : 0;
|
||
})
|
||
.filter((num) => num > 0);
|
||
const nextSightNumber =
|
||
sightNumbers.length > 0 ? Math.max(...sightNumbers) + 1 : 1;
|
||
resourceName = `Достопримечательность ${nextSightNumber}`;
|
||
break;
|
||
case "route":
|
||
const existingRoutes = allFeatures.filter(
|
||
(f) => f.get("featureType") === "route" && !f.get("isProxy")
|
||
);
|
||
const routeNumbers = existingRoutes
|
||
.map((f) => {
|
||
const name = f.get("name") as string;
|
||
const match = name?.match(/^Маршрут (\d+)$/);
|
||
return match ? parseInt(match[1], 10) : 0;
|
||
})
|
||
.filter((num) => num > 0);
|
||
const nextRouteNumber =
|
||
routeNumbers.length > 0 ? Math.max(...routeNumbers) + 1 : 1;
|
||
resourceName = `Маршрут ${nextRouteNumber}`;
|
||
break;
|
||
default:
|
||
resourceName = "Объект";
|
||
}
|
||
feature.set("name", resourceName);
|
||
|
||
if (fType === "route") {
|
||
this.activateEditMode();
|
||
}
|
||
|
||
await this.saveNewFeature(feature);
|
||
});
|
||
|
||
this.map.addInteraction(this.currentInteraction);
|
||
}
|
||
|
||
private handleContextMenu(event: MouseEvent): void {
|
||
event.preventDefault();
|
||
if (
|
||
this.mode?.startsWith("drawing-") &&
|
||
this.currentInteraction instanceof Draw
|
||
) {
|
||
this.finishDrawing();
|
||
if (this.currentDrawingType === "LineString") {
|
||
this.stopDrawing();
|
||
}
|
||
}
|
||
}
|
||
|
||
private stopDrawing() {
|
||
if (this.map && this.currentInteraction) {
|
||
try {
|
||
// @ts-ignore
|
||
this.currentInteraction.abortDrawing();
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
this.map.removeInteraction(this.currentInteraction);
|
||
}
|
||
this.currentInteraction = null;
|
||
this.currentDrawingType = null;
|
||
this.currentDrawingFeatureType = null;
|
||
}
|
||
|
||
public finishDrawing(): void {
|
||
if (!this.currentInteraction) return;
|
||
try {
|
||
this.currentInteraction.finishDrawing();
|
||
} catch (e) {
|
||
this.stopDrawing();
|
||
}
|
||
}
|
||
|
||
private handlePointerMove(event: MapBrowserEvent<PointerEvent>): void {
|
||
if (!this.map || event.dragging) {
|
||
if (this.hoveredFeatureId) {
|
||
this.hoveredFeatureId = null;
|
||
this.clusterLayer.changed();
|
||
this.routeLayer.changed();
|
||
}
|
||
if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
|
||
return;
|
||
}
|
||
|
||
const layerFilter = (l: Layer<Source, any>) =>
|
||
l === this.clusterLayer || l === this.routeLayer;
|
||
|
||
const hit = this.map.hasFeatureAtPixel(event.pixel, {
|
||
layerFilter,
|
||
hitTolerance: 5,
|
||
});
|
||
this.map.getTargetElement().style.cursor = hit ? "pointer" : "";
|
||
|
||
const featureAtPixel: Feature<Geometry> | undefined =
|
||
this.map.forEachFeatureAtPixel(
|
||
event.pixel,
|
||
(f: FeatureLike) => f as Feature<Geometry>,
|
||
{ layerFilter, hitTolerance: 5 }
|
||
);
|
||
|
||
let finalFeature: Feature<Geometry> | null = null;
|
||
if (featureAtPixel) {
|
||
const originalFeatures = featureAtPixel.get("features");
|
||
if (originalFeatures && originalFeatures.length > 0) {
|
||
if (originalFeatures[0].get("isProxy")) return; // Ignore hover on proxy
|
||
finalFeature = originalFeatures[0];
|
||
} else {
|
||
finalFeature = featureAtPixel;
|
||
}
|
||
}
|
||
|
||
const newHoveredFeatureId = finalFeature ? finalFeature.getId() : null;
|
||
|
||
if (this.tooltipOverlay && this.tooltipElement) {
|
||
if (this.mode === "edit" && finalFeature) {
|
||
const name = finalFeature.get("name");
|
||
if (name) {
|
||
this.tooltipElement.innerHTML = name as string;
|
||
this.tooltipOverlay.setPosition(event.coordinate);
|
||
} else {
|
||
this.tooltipOverlay.setPosition(undefined);
|
||
}
|
||
} else {
|
||
this.tooltipOverlay.setPosition(undefined);
|
||
}
|
||
}
|
||
|
||
if (this.hoveredFeatureId !== newHoveredFeatureId) {
|
||
this.hoveredFeatureId = newHoveredFeatureId as string | number | null;
|
||
this.clusterLayer.changed();
|
||
this.routeLayer.changed();
|
||
}
|
||
}
|
||
|
||
public selectFeature(featureId: string | number | undefined): void {
|
||
if (!this.map || featureId === undefined) {
|
||
this.unselect();
|
||
return;
|
||
}
|
||
|
||
this.setSelectedIds(new Set([featureId]));
|
||
|
||
const feature =
|
||
this.lineSource.getFeatureById(featureId) ||
|
||
this.pointSource.getFeatureById(featureId);
|
||
|
||
if (!feature) {
|
||
this.unselect();
|
||
return;
|
||
}
|
||
|
||
const view = this.map.getView();
|
||
const geometry = feature.getGeometry();
|
||
if (geometry) {
|
||
if (geometry instanceof Point) {
|
||
view.animate({
|
||
center: geometry.getCoordinates(),
|
||
duration: 500,
|
||
zoom: Math.max(view.getZoom() || 14, 16),
|
||
});
|
||
} else {
|
||
view.fit(geometry.getExtent(), {
|
||
duration: 500,
|
||
padding: [50, 50, 50, 50],
|
||
maxZoom: 16,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
public deleteFeature(
|
||
featureId: string | number | undefined,
|
||
recourse: string
|
||
): void {
|
||
if (featureId === undefined) return;
|
||
|
||
this.beforeActionState = this.getCurrentStateAsGeoJSON();
|
||
|
||
const numericId = parseInt(String(featureId).split("-")[1], 10);
|
||
if (!recourse || isNaN(numericId)) return;
|
||
|
||
mapStore
|
||
.deleteFeature(recourse, numericId)
|
||
.then(() => {
|
||
if (this.beforeActionState)
|
||
this.addStateToHistory(this.beforeActionState);
|
||
this.beforeActionState = null;
|
||
|
||
if (recourse === "route") {
|
||
const lineFeature = this.lineSource.getFeatureById(featureId);
|
||
if (lineFeature)
|
||
this.lineSource.removeFeature(lineFeature as Feature<LineString>);
|
||
|
||
const pointFeature = this.pointSource.getFeatureById(featureId);
|
||
if (pointFeature)
|
||
this.pointSource.removeFeature(pointFeature as Feature<Point>);
|
||
} else {
|
||
const feature = this.pointSource.getFeatureById(featureId);
|
||
if (feature)
|
||
this.pointSource.removeFeature(feature as Feature<Point>);
|
||
}
|
||
|
||
this.unselect();
|
||
})
|
||
.catch((err) => {
|
||
console.error("Delete failed:", err);
|
||
});
|
||
}
|
||
|
||
public deleteMultipleFeatures(featureIds: (string | number)[]): void {
|
||
if (!featureIds || featureIds.length === 0) return;
|
||
|
||
this.beforeActionState = this.getCurrentStateAsGeoJSON();
|
||
|
||
const deletePromises = Array.from(featureIds).map((id) => {
|
||
const recourse = String(id).split("-")[0];
|
||
const numericId = parseInt(String(id).split("-")[1], 10);
|
||
if (recourse && !isNaN(numericId)) {
|
||
return mapStore.deleteFeature(recourse, numericId).then(() => id);
|
||
}
|
||
return Promise.resolve(null);
|
||
});
|
||
|
||
Promise.all(deletePromises)
|
||
.then((deletedIds) => {
|
||
const successfulDeletes = deletedIds.filter((id) => id) as (
|
||
| string
|
||
| number
|
||
)[];
|
||
if (successfulDeletes.length > 0) {
|
||
if (this.beforeActionState)
|
||
this.addStateToHistory(this.beforeActionState);
|
||
this.beforeActionState = null;
|
||
|
||
successfulDeletes.forEach((id) => {
|
||
const recourse = String(id).split("-")[0];
|
||
if (recourse === "route") {
|
||
const lineFeature = this.lineSource.getFeatureById(id);
|
||
if (lineFeature)
|
||
this.lineSource.removeFeature(
|
||
lineFeature as Feature<LineString>
|
||
);
|
||
const pointFeature = this.pointSource.getFeatureById(id);
|
||
if (pointFeature)
|
||
this.pointSource.removeFeature(pointFeature as Feature<Point>);
|
||
} else {
|
||
const feature = this.pointSource.getFeatureById(id);
|
||
if (feature)
|
||
this.pointSource.removeFeature(feature as Feature<Point>);
|
||
}
|
||
});
|
||
toast.success(`Удалено ${successfulDeletes.length} объект(ов).`);
|
||
this.unselect();
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.error("Bulk delete failed:", err);
|
||
});
|
||
}
|
||
|
||
public destroy(): void {
|
||
if (this.map) {
|
||
document.removeEventListener("keydown", this.boundHandleKeyDown);
|
||
const targetEl = this.map.getTargetElement();
|
||
if (targetEl instanceof HTMLElement) {
|
||
targetEl.removeEventListener(
|
||
"contextmenu",
|
||
this.boundHandleContextMenu
|
||
);
|
||
targetEl.removeEventListener(
|
||
"pointerleave",
|
||
this.boundHandlePointerLeave
|
||
);
|
||
}
|
||
this.map.un("pointermove", this.boundHandlePointerMove as any);
|
||
if (this.tooltipOverlay) this.map.removeOverlay(this.tooltipOverlay);
|
||
this.pointSource.clear();
|
||
this.lineSource.clear();
|
||
this.map.setTarget(undefined);
|
||
this.map = null;
|
||
}
|
||
}
|
||
|
||
private handleFeatureEvent(
|
||
event: VectorSourceEvent<Feature<Geometry>>
|
||
): void {
|
||
if (!event.feature) return;
|
||
const feature = event.feature;
|
||
if (!feature.getId()) {
|
||
feature.setId(Date.now() + Math.random().toString(36).substr(2, 9));
|
||
}
|
||
this.updateFeaturesInReact();
|
||
}
|
||
|
||
private handleFeatureChange(
|
||
event: VectorSourceEvent<Feature<Geometry>>
|
||
): void {
|
||
if (!event.feature) return;
|
||
this.updateFeaturesInReact();
|
||
}
|
||
|
||
public activateLasso() {
|
||
if (this.lassoInteraction && this.map) {
|
||
this.lassoInteraction.setActive(true);
|
||
this.setMode("lasso");
|
||
}
|
||
}
|
||
|
||
public deactivateLasso() {
|
||
if (this.lassoInteraction && this.map) {
|
||
this.lassoInteraction.setActive(false);
|
||
this.setMode("edit");
|
||
}
|
||
}
|
||
|
||
public setSelectedIds(ids: Set<string | number>) {
|
||
this.selectedIds = new Set(ids);
|
||
if (this.onSelectionChange) this.onSelectionChange(this.selectedIds);
|
||
|
||
this.selectInteraction.getFeatures().clear();
|
||
ids.forEach((id) => {
|
||
const lineFeature = this.lineSource.getFeatureById(id);
|
||
if (lineFeature) this.selectInteraction.getFeatures().push(lineFeature);
|
||
|
||
const pointFeature = this.pointSource.getFeatureById(id);
|
||
if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature);
|
||
});
|
||
|
||
this.modifyInteraction.setActive(
|
||
this.selectInteraction.getFeatures().getLength() > 0
|
||
);
|
||
this.clusterLayer.changed();
|
||
this.routeLayer.changed();
|
||
|
||
if (ids.size === 1) {
|
||
const featureId = Array.from(ids)[0];
|
||
const feature =
|
||
this.lineSource.getFeatureById(featureId) ||
|
||
this.pointSource.getFeatureById(featureId);
|
||
if (feature) {
|
||
this.onFeatureSelect(feature);
|
||
}
|
||
} else {
|
||
this.onFeatureSelect(null);
|
||
}
|
||
}
|
||
|
||
public getSelectedIds() {
|
||
return new Set(this.selectedIds);
|
||
}
|
||
public setOnSelectionChange(cb: (ids: Set<string | number>) => void) {
|
||
this.onSelectionChange = cb;
|
||
}
|
||
public toggleLasso() {
|
||
if (this.mode === "lasso") this.deactivateLasso();
|
||
else this.activateLasso();
|
||
}
|
||
public getMap(): Map | null {
|
||
return this.map;
|
||
}
|
||
|
||
public saveCurrentPosition(): void {
|
||
if (!this.map) return;
|
||
const center = this.map.getView().getCenter();
|
||
const zoom = this.map.getView().getZoom();
|
||
if (center && zoom !== undefined) {
|
||
const [lon, lat] = toLonLat(center, this.map.getView().getProjection());
|
||
saveMapPosition({ center: [lon, lat], zoom });
|
||
}
|
||
}
|
||
|
||
private async saveModifiedFeature(feature: Feature<Geometry>) {
|
||
const featureType = feature.get("featureType") as FeatureType;
|
||
const featureId = feature.getId();
|
||
if (!featureType || featureId === undefined || !this.map) return;
|
||
|
||
if (
|
||
featureType === "route" &&
|
||
feature.getGeometry()?.getType() === "LineString"
|
||
) {
|
||
const proxyPoint = this.pointSource.getFeatureById(
|
||
featureId
|
||
) as Feature<Point>;
|
||
if (proxyPoint) {
|
||
const lineGeom = feature.getGeometry() as LineString;
|
||
const newCenter = getCenter(lineGeom.getExtent());
|
||
proxyPoint.getGeometry()?.setCoordinates(newCenter);
|
||
}
|
||
}
|
||
|
||
if (typeof featureId === "number" || !String(featureId).includes("-")) {
|
||
console.warn(
|
||
"Skipping save for feature with non-standard ID:",
|
||
featureId
|
||
);
|
||
return;
|
||
}
|
||
|
||
const geoJSONFormat = new GeoJSON({
|
||
dataProjection: "EPSG:4326",
|
||
featureProjection: this.map.getView().getProjection().getCode(),
|
||
});
|
||
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
|
||
|
||
try {
|
||
await mapStore.updateFeature(featureType, featureGeoJSON);
|
||
} catch (error) {
|
||
console.error("Failed to update feature:", error);
|
||
toast.error(`Не удалось обновить: ${error}`);
|
||
if (this.beforeActionState) {
|
||
this.applyHistoryState(this.beforeActionState);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async saveNewFeature(feature: Feature<Geometry>) {
|
||
const featureType = feature.get("featureType") as FeatureType;
|
||
if (!featureType || !this.map) return;
|
||
|
||
const geoJSONFormat = new GeoJSON({
|
||
dataProjection: "EPSG:4326",
|
||
featureProjection: this.map.getView().getProjection().getCode(),
|
||
});
|
||
|
||
const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature);
|
||
|
||
try {
|
||
const createdFeatureData = await mapStore.createFeature(
|
||
featureType,
|
||
featureGeoJSON
|
||
);
|
||
|
||
const newFeatureId = `${featureType}-${createdFeatureData.id}`;
|
||
// @ts-ignore
|
||
const displayName =
|
||
featureType === "route"
|
||
? // @ts-ignore
|
||
createdFeatureData.route_number
|
||
: // @ts-ignore
|
||
createdFeatureData.name;
|
||
|
||
if (featureType === "route") {
|
||
// @ts-ignore
|
||
const routeData = createdFeatureData as ApiRoute;
|
||
const projection = this.map.getView().getProjection();
|
||
|
||
// Update existing line feature
|
||
feature.setId(newFeatureId);
|
||
feature.set("name", displayName);
|
||
|
||
// Optionally update geometry if server modified it
|
||
const lineGeom = new LineString(
|
||
routeData.path.map((c) =>
|
||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||
)
|
||
);
|
||
feature.setGeometry(lineGeom);
|
||
|
||
// Create and add proxy point
|
||
const centerPointGeom = new Point(
|
||
transform(
|
||
[routeData.center_longitude, routeData.center_latitude],
|
||
"EPSG:4326",
|
||
projection
|
||
)
|
||
);
|
||
const proxyPointFeature = new Feature({
|
||
geometry: centerPointGeom,
|
||
name: displayName,
|
||
isProxy: true,
|
||
});
|
||
proxyPointFeature.setId(newFeatureId);
|
||
proxyPointFeature.set("featureType", "route");
|
||
this.pointSource.addFeature(proxyPointFeature);
|
||
} else {
|
||
// For points: update existing
|
||
feature.setId(newFeatureId);
|
||
feature.set("name", displayName);
|
||
// No need to remove and re-add since it's already in the source
|
||
}
|
||
|
||
this.updateFeaturesInReact();
|
||
this.routeLayer.changed();
|
||
this.clusterLayer.changed();
|
||
} catch (error) {
|
||
console.error("Failed to save new feature:", error);
|
||
toast.error("Не удалось сохранить объект.");
|
||
if (feature.getGeometry()?.getType() === "LineString") {
|
||
if (this.lineSource.hasFeature(feature as Feature<LineString>))
|
||
this.lineSource.removeFeature(feature as Feature<LineString>);
|
||
} else {
|
||
if (this.pointSource.hasFeature(feature as Feature<Point>))
|
||
this.pointSource.removeFeature(feature as Feature<Point>);
|
||
}
|
||
if (this.beforeActionState) {
|
||
this.applyHistoryState(this.beforeActionState);
|
||
}
|
||
this.beforeActionState = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- MAP CONTROLS COMPONENT ---
|
||
interface MapControlsProps {
|
||
mapService: MapService | null;
|
||
activeMode: string;
|
||
isLassoActive: boolean;
|
||
isUnselectDisabled: boolean;
|
||
}
|
||
|
||
interface ControlItem {
|
||
mode: string;
|
||
title: string;
|
||
longTitle: string;
|
||
icon: React.ReactNode;
|
||
action: () => void;
|
||
isActive?: boolean;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
const MapControls: React.FC<MapControlsProps> = ({
|
||
mapService,
|
||
activeMode,
|
||
// @ts-ignore
|
||
isLassoActive,
|
||
isUnselectDisabled,
|
||
}) => {
|
||
if (!mapService) return null;
|
||
|
||
const controls: ControlItem[] = [
|
||
{
|
||
mode: "edit",
|
||
title: "Редактировать",
|
||
longTitle: "Редактирование",
|
||
icon: <EditIcon />,
|
||
action: () => mapService.activateEditMode(),
|
||
},
|
||
{
|
||
mode: "drawing-station",
|
||
title: "Остановка",
|
||
longTitle: "Добавить остановку",
|
||
icon: <Bus size={16} className="mr-1 sm:mr-2" />,
|
||
action: () => mapService.startDrawing("Point", "station"),
|
||
},
|
||
{
|
||
mode: "drawing-sight",
|
||
title: "Достопримечательность",
|
||
longTitle: "Добавить достопримечательность",
|
||
icon: <Landmark size={16} className="mr-1 sm:mr-2" />,
|
||
action: () => mapService.startDrawing("Point", "sight"),
|
||
},
|
||
{
|
||
mode: "drawing-route",
|
||
title: "Маршрут",
|
||
longTitle: "Добавить маршрут (Правый клик для завершения)",
|
||
icon: <LineIconSvg />,
|
||
action: () => mapService.startDrawing("LineString", "route"),
|
||
},
|
||
|
||
{
|
||
mode: "unselect",
|
||
title: "Сбросить",
|
||
longTitle: "Сбросить выделение (Esc)",
|
||
icon: <X size={16} className="mr-1 sm:mr-2" />,
|
||
action: () => mapService.unselect(),
|
||
disabled: isUnselectDisabled,
|
||
},
|
||
];
|
||
return (
|
||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex flex-nowrap justify-center p-2 bg-white/90 backdrop-blur-sm rounded-lg shadow-xl space-x-1 sm:space-x-2">
|
||
{controls.map((c) => {
|
||
const isActive =
|
||
c.isActive !== undefined ? c.isActive : activeMode === c.mode;
|
||
const isDisabled = c.disabled;
|
||
const buttonClasses = `flex items-center px-3 py-2 rounded-md transition-all duration-200 text-xs sm:text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 focus:ring-opacity-75 ${
|
||
isDisabled
|
||
? "bg-gray-200 text-gray-400 cursor-not-allowed"
|
||
: isActive
|
||
? "bg-blue-600 text-white shadow-md hover:bg-blue-700"
|
||
: "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700"
|
||
}`;
|
||
return (
|
||
<button
|
||
key={c.mode}
|
||
className={buttonClasses}
|
||
onClick={c.action}
|
||
title={c.longTitle}
|
||
disabled={isDisabled}
|
||
>
|
||
{c.icon}
|
||
<span className="hidden sm:inline ml-0 sm:ml-1">{c.title}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- MAP SIGHTBAR COMPONENT ---
|
||
interface MapSightbarProps {
|
||
mapService: MapService | null;
|
||
mapFeatures: Feature<Geometry>[];
|
||
selectedFeature: Feature<Geometry> | null;
|
||
selectedIds: Set<string | number>;
|
||
setSelectedIds: (ids: Set<string | number>) => void;
|
||
activeSection: string | null;
|
||
setActiveSection: (section: string | null) => void;
|
||
}
|
||
|
||
const MapSightbar: React.FC<MapSightbarProps> = ({
|
||
mapService,
|
||
mapFeatures,
|
||
selectedFeature,
|
||
selectedIds,
|
||
setSelectedIds,
|
||
activeSection,
|
||
setActiveSection,
|
||
}) => {
|
||
const navigate = useNavigate();
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
|
||
const actualFeatures = useMemo(() => {
|
||
return mapFeatures.filter((feature) => !feature.get("isProxy"));
|
||
}, [mapFeatures]);
|
||
|
||
const filteredFeatures = useMemo(() => {
|
||
if (!searchQuery.trim()) return actualFeatures;
|
||
return actualFeatures.filter((feature) =>
|
||
((feature.get("name") as string) || "")
|
||
.toLowerCase()
|
||
.includes(searchQuery.toLowerCase())
|
||
);
|
||
}, [actualFeatures, searchQuery]);
|
||
|
||
const handleFeatureClick = useCallback(
|
||
(id: string | number | undefined) => {
|
||
if (!id || !mapService) return;
|
||
mapService.selectFeature(id);
|
||
},
|
||
[mapService]
|
||
);
|
||
|
||
const handleDeleteFeature = useCallback(
|
||
// @ts-ignore
|
||
(id, recourse) => {
|
||
if (
|
||
mapService &&
|
||
window.confirm("Вы действительно хотите удалить этот объект?")
|
||
) {
|
||
mapService.deleteFeature(id, recourse);
|
||
}
|
||
},
|
||
[mapService]
|
||
);
|
||
|
||
const handleCheckboxChange = useCallback(
|
||
(id: string | number | undefined) => {
|
||
if (!id || !mapService) return;
|
||
const newSet = new Set(selectedIds);
|
||
if (newSet.has(id)) {
|
||
newSet.delete(id);
|
||
} else {
|
||
newSet.add(id);
|
||
}
|
||
setSelectedIds(newSet);
|
||
mapService.setSelectedIds(newSet);
|
||
},
|
||
[mapService, selectedIds, setSelectedIds]
|
||
);
|
||
|
||
const handleBulkDelete = useCallback(() => {
|
||
if (!mapService || selectedIds.size === 0) return;
|
||
if (
|
||
window.confirm(
|
||
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`
|
||
)
|
||
) {
|
||
mapService.deleteMultipleFeatures(Array.from(selectedIds));
|
||
setSelectedIds(new Set());
|
||
}
|
||
}, [mapService, selectedIds, setSelectedIds]);
|
||
|
||
// @ts-ignore
|
||
const handleEditFeature = useCallback(
|
||
// @ts-ignore
|
||
(featureType, fullId) => {
|
||
if (!featureType || !fullId) return;
|
||
const numericId = String(fullId).split("-")[1];
|
||
if (numericId) navigate(`/${featureType}/${numericId}/edit`);
|
||
},
|
||
[navigate]
|
||
);
|
||
|
||
const sortFeatures = (
|
||
features: Feature<Geometry>[],
|
||
currentSelectedIds: Set<string | number>,
|
||
currentSelectedFeature: Feature<Geometry> | null
|
||
) => {
|
||
const selectedId = currentSelectedFeature?.getId();
|
||
return [...features].sort((a, b) => {
|
||
const aId = a.getId();
|
||
const bId = b.getId();
|
||
|
||
if (selectedId) {
|
||
if (aId === selectedId) return -1;
|
||
if (bId === selectedId) return 1;
|
||
}
|
||
|
||
const aIsChecked = aId !== undefined && currentSelectedIds.has(aId);
|
||
const bIsChecked = bId !== undefined && currentSelectedIds.has(bId);
|
||
if (aIsChecked && !bIsChecked) return -1;
|
||
if (!aIsChecked && bIsChecked) return 1;
|
||
|
||
const aNumericId = aId ? parseInt(String(aId).split("-")[1], 10) : 0;
|
||
const bNumericId = bId ? parseInt(String(bId).split("-")[1], 10) : 0;
|
||
if (
|
||
!isNaN(aNumericId) &&
|
||
!isNaN(bNumericId) &&
|
||
aNumericId !== bNumericId
|
||
) {
|
||
return aNumericId - bNumericId;
|
||
}
|
||
|
||
const aName = (a.get("name") as string) || "";
|
||
const bName = (b.get("name") as string) || "";
|
||
return aName.localeCompare(bName, "ru");
|
||
});
|
||
};
|
||
|
||
const toggleSection = (id: string) =>
|
||
setActiveSection(activeSection === id ? null : id);
|
||
|
||
const stations = filteredFeatures.filter(
|
||
(f) => f.get("featureType") === "station"
|
||
);
|
||
const lines = filteredFeatures.filter(
|
||
(f) => f.get("featureType") === "route"
|
||
);
|
||
const sights = filteredFeatures.filter(
|
||
(f) => f.get("featureType") === "sight"
|
||
);
|
||
|
||
const sortedStations = sortFeatures(stations, selectedIds, selectedFeature);
|
||
const sortedLines = sortFeatures(lines, selectedIds, selectedFeature);
|
||
const sortedSights = sortFeatures(sights, selectedIds, selectedFeature);
|
||
|
||
const renderFeatureList = (
|
||
features: Feature<Geometry>[],
|
||
featureType: "station" | "route" | "sight",
|
||
IconComponent: React.ElementType
|
||
) => (
|
||
<div className="space-y-1 pr-1">
|
||
{features.length > 0 ? (
|
||
features.map((feature) => {
|
||
const fId = feature.getId();
|
||
const fName = (feature.get("name") as string) || "Без названия";
|
||
const isSelected = selectedFeature?.getId() === fId;
|
||
const isChecked = fId !== undefined && selectedIds.has(fId);
|
||
return (
|
||
<div
|
||
key={String(fId)}
|
||
data-feature-id={fId}
|
||
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
|
||
isSelected
|
||
? "bg-orange-100 border border-orange-300"
|
||
: "hover:bg-blue-50"
|
||
}`}
|
||
>
|
||
<div className="flex-shrink-0 pr-2 pt-1">
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||
checked={isChecked}
|
||
onChange={() => handleCheckboxChange(fId)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
aria-label={`Выбрать ${fName}`}
|
||
/>
|
||
</div>
|
||
<div
|
||
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
|
||
onClick={() => handleFeatureClick(fId)}
|
||
>
|
||
<div className="flex items-center">
|
||
<IconComponent
|
||
{...({
|
||
className: [
|
||
"mr-1.5",
|
||
"flex-shrink-0",
|
||
isSelected ? "text-orange-500" : "text-blue-500",
|
||
!isSelected && "group-hover:text-blue-600",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" "),
|
||
} as React.HTMLAttributes<HTMLElement>)}
|
||
// @ts-ignore
|
||
size={16}
|
||
/>
|
||
<span
|
||
className={`font-medium truncate ${
|
||
isSelected
|
||
? "text-orange-600"
|
||
: "group-hover:text-blue-600"
|
||
}`}
|
||
title={fName}
|
||
>
|
||
{fName}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleEditFeature(feature.get("featureType"), fId);
|
||
}}
|
||
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
|
||
title="Редактировать детали"
|
||
>
|
||
<Pencil size={16} />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteFeature(fId, featureType);
|
||
}}
|
||
className="p-1 rounded-full text-gray-400 hover:text-red-600 hover:bg-red-100 transition-colors"
|
||
title="Удалить"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
) : (
|
||
<p className="text-gray-500 text-sm p-2">Нет объектов этого типа.</p>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const sections = [
|
||
{
|
||
id: "layers",
|
||
title: `Остановки (${sortedStations.length})`,
|
||
icon: <Bus size={20} />,
|
||
count: sortedStations.length,
|
||
content: renderFeatureList(sortedStations, "station", MapPin),
|
||
},
|
||
{
|
||
id: "lines",
|
||
title: `Маршруты (${sortedLines.length})`,
|
||
icon: <RouteIcon size={20} />,
|
||
count: sortedLines.length,
|
||
content: renderFeatureList(sortedLines, "route", ArrowRightLeft),
|
||
},
|
||
{
|
||
id: "sights",
|
||
title: `Достопримечательности (${sortedSights.length})`,
|
||
icon: <Landmark size={20} />,
|
||
count: sortedSights.length,
|
||
content: renderFeatureList(sortedSights, "sight", Landmark),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="w-[360px] relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
|
||
<div className="p-4 bg-gray-700 text-white">
|
||
<h2 className="text-lg font-semibold">Панель управления</h2>
|
||
</div>
|
||
<div className="p-3 border-b border-gray-200 bg-white">
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск по названию..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1 flex flex-col min-h-0">
|
||
{filteredFeatures.length === 0 && searchQuery ? (
|
||
<div className="p-4 text-center text-gray-500">
|
||
Ничего не найдено.
|
||
</div>
|
||
) : (
|
||
sections.map(
|
||
(s) =>
|
||
(s.count > 0 || !searchQuery) && (
|
||
<div
|
||
key={s.id}
|
||
className={`border-b border-gray-200 last:border-b-0 flex flex-col transition-all duration-300 ease-in-out ${
|
||
activeSection === s.id ? "flex-1 min-h-0" : "flex-none"
|
||
}`}
|
||
>
|
||
<button
|
||
onClick={() => toggleSection(s.id)}
|
||
className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 flex-shrink-0 ${
|
||
activeSection === s.id
|
||
? "bg-gray-100 text-blue-600"
|
||
: "text-gray-700"
|
||
}`}
|
||
>
|
||
<div className="flex items-center space-x-3">
|
||
<span
|
||
className={
|
||
activeSection === s.id
|
||
? "text-blue-600"
|
||
: "text-gray-600"
|
||
}
|
||
>
|
||
{s.icon}
|
||
</span>
|
||
<span className="font-medium text-sm">{s.title}</span>
|
||
</div>
|
||
<span
|
||
className={`transform transition-transform duration-200 text-gray-500 ${
|
||
activeSection === s.id ? "rotate-180" : ""
|
||
}`}
|
||
>
|
||
▼
|
||
</span>
|
||
</button>
|
||
<div
|
||
className={`overflow-y-auto scrollbar-hide bg-white ${
|
||
activeSection === s.id ? "block" : "hidden"
|
||
}`}
|
||
>
|
||
<div className="p-3 text-sm text-gray-600">{s.content}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
{selectedIds.size > 0 && (
|
||
<div className="p-3 border-t border-gray-200 bg-white flex-shrink-0">
|
||
<button
|
||
onClick={handleBulkDelete}
|
||
className="w-full flex items-center justify-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||
>
|
||
<Trash2 size={16} className="mr-2" />
|
||
Удалить выбранное ({selectedIds.size})
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
// --- MAP PAGE COMPONENT ---
|
||
export const MapPage: React.FC = () => {
|
||
const mapRef = useRef<HTMLDivElement | null>(null);
|
||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||
const [mapServiceInstance, setMapServiceInstance] =
|
||
useState<MapService | null>(null);
|
||
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
|
||
const [isDataLoading, setIsDataLoading] = useState<boolean>(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [currentMapMode, setCurrentMapMode] = useState<string>("edit");
|
||
const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]);
|
||
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
|
||
useState<Feature<Geometry> | null>(null);
|
||
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
|
||
new Set()
|
||
);
|
||
const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
|
||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||
const [activeSectionFromParent, setActiveSectionFromParent] = useState<
|
||
string | null
|
||
>(() => getStoredActiveSection() || "layers");
|
||
|
||
const handleFeaturesChange = useCallback(
|
||
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
|
||
[]
|
||
);
|
||
|
||
const handleFeatureSelectForSidebar = useCallback(
|
||
(feat: Feature<Geometry> | null) => {
|
||
// Logic to sync sidebar selection with map
|
||
setSelectedFeatureForSidebar(feat);
|
||
if (feat) {
|
||
const featureType = feat.get("featureType");
|
||
const sectionId =
|
||
featureType === "sight"
|
||
? "sights"
|
||
: featureType === "route"
|
||
? "lines"
|
||
: "layers";
|
||
setActiveSectionFromParent(sectionId);
|
||
setTimeout(() => {
|
||
document
|
||
.querySelector(`[data-feature-id="${feat.getId()}"]`)
|
||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
}, 100);
|
||
}
|
||
},
|
||
[]
|
||
);
|
||
|
||
useEffect(() => {
|
||
let service: MapService | null = null;
|
||
if (mapRef.current && tooltipRef.current && !mapServiceInstance) {
|
||
setIsMapLoading(true);
|
||
setIsDataLoading(true);
|
||
setError(null);
|
||
|
||
const loadInitialData = async (mapService: MapService) => {
|
||
try {
|
||
await Promise.all([
|
||
mapStore.getRoutes(),
|
||
mapStore.getStations(),
|
||
mapStore.getSights(),
|
||
]);
|
||
mapService.loadFeaturesFromApi(
|
||
mapStore.stations,
|
||
mapStore.routes,
|
||
mapStore.sights
|
||
);
|
||
} catch (e) {
|
||
console.error("Failed to load initial map data:", e);
|
||
setError("Не удалось загрузить данные для карты.");
|
||
} finally {
|
||
setIsDataLoading(false);
|
||
}
|
||
};
|
||
|
||
try {
|
||
service = new MapService(
|
||
{ ...mapConfig, target: mapRef.current },
|
||
setIsMapLoading,
|
||
setError,
|
||
setCurrentMapMode,
|
||
handleFeaturesChange,
|
||
handleFeatureSelectForSidebar,
|
||
tooltipRef.current,
|
||
setSelectedIds
|
||
);
|
||
setMapServiceInstance(service);
|
||
loadInitialData(service);
|
||
} catch (e: any) {
|
||
setError(
|
||
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`
|
||
);
|
||
setIsMapLoading(false);
|
||
setIsDataLoading(false);
|
||
}
|
||
}
|
||
return () => {
|
||
service?.destroy();
|
||
setMapServiceInstance(null);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// --- ИСПРАВЛЕНИЕ: Этот хук отвечает ТОЛЬКО за клики по ПУСТОМУ месту
|
||
useEffect(() => {
|
||
const olMap = mapServiceInstance?.getMap();
|
||
if (!olMap || !mapServiceInstance) return;
|
||
|
||
const handleMapClickForDeselect = (event: any) => {
|
||
if (!mapServiceInstance) return;
|
||
|
||
const hit = olMap.hasFeatureAtPixel(event.pixel, {
|
||
layerFilter: (layer) =>
|
||
layer === mapServiceInstance.clusterLayer ||
|
||
layer === mapServiceInstance.routeLayer,
|
||
hitTolerance: 5,
|
||
});
|
||
|
||
// Если клик был НЕ по объекту, снимаем выделение
|
||
if (!hit) {
|
||
mapServiceInstance.unselect();
|
||
}
|
||
// Если клик был ПО объекту, НИЧЕГО не делаем. За это отвечает selectInteraction.
|
||
};
|
||
|
||
olMap.on("click", handleMapClickForDeselect);
|
||
|
||
return () => {
|
||
olMap.un("click", handleMapClickForDeselect);
|
||
};
|
||
}, [mapServiceInstance]);
|
||
|
||
useEffect(() => {
|
||
mapServiceInstance?.setOnSelectionChange(setSelectedIds);
|
||
}, [mapServiceInstance]);
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Shift" && mapServiceInstance && !isLassoActive) {
|
||
mapServiceInstance.activateLasso();
|
||
setIsLassoActive(true);
|
||
}
|
||
};
|
||
const handleKeyUp = (e: KeyboardEvent) => {
|
||
if (e.key === "Shift" && mapServiceInstance) {
|
||
mapServiceInstance.deactivateLasso();
|
||
setIsLassoActive(false);
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
window.addEventListener("keyup", handleKeyUp);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeyDown);
|
||
window.removeEventListener("keyup", handleKeyUp);
|
||
};
|
||
}, [mapServiceInstance, isLassoActive]);
|
||
|
||
useEffect(() => {
|
||
if (mapServiceInstance) {
|
||
mapServiceInstance.toggleLasso = function () {
|
||
if (currentMapMode === "lasso") {
|
||
this.deactivateLasso();
|
||
setIsLassoActive(false);
|
||
} else {
|
||
this.activateLasso();
|
||
setIsLassoActive(true);
|
||
}
|
||
};
|
||
}
|
||
}, [mapServiceInstance, currentMapMode]);
|
||
|
||
useEffect(() => {
|
||
saveActiveSection(activeSectionFromParent);
|
||
}, [activeSectionFromParent]);
|
||
|
||
const showLoader = isMapLoading || isDataLoading;
|
||
const showContent = mapServiceInstance && !showLoader && !error;
|
||
const isAnythingSelected =
|
||
selectedFeatureForSidebar !== null || selectedIds.size > 0;
|
||
|
||
return (
|
||
<div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
|
||
<div className="relative flex-grow flex">
|
||
<div
|
||
ref={mapRef}
|
||
id="map"
|
||
className="flex-1 h-full relative bg-gray-200"
|
||
>
|
||
<div
|
||
ref={tooltipRef}
|
||
className="tooltip ol-tooltip bg-white text-black p-2 rounded shadow-lg text-xs whitespace-nowrap"
|
||
style={{ position: "absolute", pointerEvents: "none" }}
|
||
></div>
|
||
{showLoader && (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-500 bg-opacity-50 z-[1001]">
|
||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-3" />
|
||
<div className="text-md font-semibold text-white drop-shadow-md">
|
||
{isMapLoading ? "Загрузка карты..." : "Загрузка данных..."}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{error && !showLoader && (
|
||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-red-100 border border-red-400 text-red-700 p-6 rounded-lg shadow-lg z-[1002] text-center max-w-md">
|
||
<h3 className="font-semibold text-lg mb-2">Произошла ошибка</h3>
|
||
<p className="text-sm">{error}</p>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm"
|
||
>
|
||
Перезагрузить
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isLassoActive && (
|
||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white py-2 px-4 rounded-full shadow-lg text-sm font-medium z-20">
|
||
Режим выделения области.
|
||
</div>
|
||
)}
|
||
</div>
|
||
{showContent && (
|
||
<MapControls
|
||
mapService={mapServiceInstance}
|
||
activeMode={currentMapMode}
|
||
isLassoActive={isLassoActive}
|
||
isUnselectDisabled={!isAnythingSelected}
|
||
/>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => setShowHelp(!showHelp)}
|
||
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
|
||
title="Помощь по клавишам"
|
||
>
|
||
<InfoIcon size={20} />
|
||
</button>
|
||
|
||
{showHelp && (
|
||
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs">
|
||
<h4 className="font-bold mb-2">Горячие клавиши:</h4>
|
||
<ul className="text-sm space-y-2">
|
||
<li>
|
||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||
Shift
|
||
</span>{" "}
|
||
- Режим выделения (лассо)
|
||
</li>
|
||
<li>
|
||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||
Ctrl + клик
|
||
</span>{" "}
|
||
- Добавить/убрать из выделения
|
||
</li>
|
||
<li>
|
||
<span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "}
|
||
- Отменить выделение
|
||
</li>
|
||
<li>
|
||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||
Ctrl+Z
|
||
</span>{" "}
|
||
- Отменить действие
|
||
</li>
|
||
<li>
|
||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||
Ctrl+Y
|
||
</span>{" "}
|
||
- Повторить действие
|
||
</li>
|
||
</ul>
|
||
<button
|
||
onClick={() => setShowHelp(false)}
|
||
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
|
||
>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{showContent && (
|
||
<MapSightbar
|
||
mapService={mapServiceInstance}
|
||
mapFeatures={mapFeatures}
|
||
selectedFeature={selectedFeatureForSidebar}
|
||
selectedIds={selectedIds}
|
||
setSelectedIds={setSelectedIds}
|
||
activeSection={activeSectionFromParent}
|
||
setActiveSection={setActiveSectionFromParent}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|