fix: Fix Map
page
This commit is contained in:
@ -4,6 +4,7 @@ import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Map, View, Overlay, MapBrowserEvent } from "ol";
|
||||
@ -21,7 +22,7 @@ import {
|
||||
Circle as CircleStyle,
|
||||
RegularShape,
|
||||
} from "ol/style";
|
||||
import { Point, LineString, Geometry } from "ol/geom";
|
||||
import { Point, LineString, Geometry, Polygon } from "ol/geom";
|
||||
import { transform } from "ol/proj";
|
||||
import { GeoJSON } from "ol/format";
|
||||
import {
|
||||
@ -34,6 +35,9 @@ import {
|
||||
Pencil,
|
||||
Save,
|
||||
Loader2,
|
||||
Lasso,
|
||||
InfoIcon,
|
||||
X, // --- ИЗМЕНЕНО --- Импортируем иконку крестика
|
||||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { singleClick, doubleClick } from "ol/events/condition";
|
||||
@ -123,6 +127,10 @@ class MapService {
|
||||
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;
|
||||
@ -152,7 +160,8 @@ class MapService {
|
||||
onModeChangeCallback: (mode: string) => void,
|
||||
onFeaturesChange: (features: Feature<Geometry>[]) => void,
|
||||
onFeatureSelect: (feature: Feature<Geometry> | null) => void,
|
||||
tooltipElement: HTMLElement
|
||||
tooltipElement: HTMLElement,
|
||||
onSelectionChange?: (ids: Set<string | number>) => void
|
||||
) {
|
||||
this.map = null;
|
||||
this.tooltipElement = tooltipElement;
|
||||
@ -233,10 +242,10 @@ class MapService {
|
||||
this.selectedSightIconStyle = new Style({
|
||||
image: new RegularShape({
|
||||
fill: new Fill({ color: "rgba(221, 107, 32, 0.9)" }),
|
||||
stroke: new Stroke({ color: "#ffffff", width: 2.5 }),
|
||||
stroke: new Stroke({ color: "#ffffff", width: 2 }),
|
||||
points: 5,
|
||||
radius: 14,
|
||||
radius2: 7,
|
||||
radius: 12,
|
||||
radius2: 6,
|
||||
angle: 0,
|
||||
}),
|
||||
});
|
||||
@ -291,6 +300,7 @@ class MapService {
|
||||
.getArray()
|
||||
.includes(feature);
|
||||
const isHovered = this.hoveredFeatureId === fId;
|
||||
const isLassoSelected = fId !== undefined && this.selectedIds.has(fId);
|
||||
|
||||
if (geometryType === "Point") {
|
||||
const defaultPointStyle =
|
||||
@ -308,6 +318,28 @@ class MapService {
|
||||
? this.hoverSightIconStyle
|
||||
: this.universalHoverStylePoint;
|
||||
}
|
||||
|
||||
if (isLassoSelected) {
|
||||
let imageStyle;
|
||||
if (featureType === "sight") {
|
||||
imageStyle = new RegularShape({
|
||||
fill: new Fill({ color: "#14b8a6" }),
|
||||
stroke: new Stroke({ color: "#fff", width: 2 }),
|
||||
points: 5,
|
||||
radius: 12,
|
||||
radius2: 6,
|
||||
angle: 0,
|
||||
});
|
||||
} else {
|
||||
imageStyle = new CircleStyle({
|
||||
radius: 10,
|
||||
fill: new Fill({ color: "#14b8a6" }),
|
||||
stroke: new Stroke({ color: "#fff", width: 2 }),
|
||||
});
|
||||
}
|
||||
return new Style({ image: imageStyle, zIndex: Infinity });
|
||||
}
|
||||
|
||||
return defaultPointStyle;
|
||||
} else if (geometryType === "LineString") {
|
||||
if (isEditSelected) {
|
||||
@ -316,6 +348,12 @@ class MapService {
|
||||
if (isHovered) {
|
||||
return this.universalHoverStyleLine;
|
||||
}
|
||||
if (isLassoSelected) {
|
||||
return new Style({
|
||||
stroke: new Stroke({ color: "#14b8a6", width: 6 }),
|
||||
zIndex: Infinity,
|
||||
});
|
||||
}
|
||||
return this.defaultStyle;
|
||||
}
|
||||
|
||||
@ -482,11 +520,43 @@ class MapService {
|
||||
this.updateFeaturesInReact();
|
||||
});
|
||||
|
||||
this.lassoInteraction = new Draw({
|
||||
type: "Polygon",
|
||||
style: new Style({
|
||||
stroke: new Stroke({ color: "#14b8a6", width: 2 }),
|
||||
fill: new Fill({ color: "rgba(20, 184, 166, 0.1)" }),
|
||||
}),
|
||||
});
|
||||
this.lassoInteraction.setActive(false);
|
||||
this.lassoInteraction.on("drawend", (event: DrawEvent) => {
|
||||
const geometry = event.feature.getGeometry() as Polygon;
|
||||
const extent = geometry.getExtent();
|
||||
const selected = new Set<string | number>();
|
||||
|
||||
this.vectorSource.forEachFeatureInExtent(extent, (f) => {
|
||||
const geom = f.getGeometry();
|
||||
if (geom && geom.getType() === "Point") {
|
||||
const pointCoords = (geom as Point).getCoordinates();
|
||||
if (geometry.intersectsCoordinate(pointCoords)) {
|
||||
if (f.getId() !== undefined) selected.add(f.getId()!);
|
||||
}
|
||||
} else if (geom && geom.intersectsExtent(extent)) {
|
||||
// For lines/polygons
|
||||
if (f.getId() !== undefined) selected.add(f.getId()!);
|
||||
}
|
||||
});
|
||||
|
||||
this.setSelectedIds(selected);
|
||||
this.deactivateLasso();
|
||||
});
|
||||
|
||||
if (this.map) {
|
||||
this.map.addInteraction(this.modifyInteraction);
|
||||
this.map.addInteraction(this.selectInteraction);
|
||||
this.map.addInteraction(this.lassoInteraction);
|
||||
this.modifyInteraction.setActive(false);
|
||||
this.selectInteraction.setActive(false);
|
||||
this.lassoInteraction.setActive(false);
|
||||
|
||||
this.selectInteraction.on("select", (e: SelectEvent) => {
|
||||
if (this.mode === "edit") {
|
||||
@ -508,6 +578,20 @@ class MapService {
|
||||
document.addEventListener("keydown", this.boundHandleKeyDown);
|
||||
this.activateEditMode();
|
||||
}
|
||||
|
||||
if (onSelectionChange) {
|
||||
this.onSelectionChange = onSelectionChange;
|
||||
}
|
||||
}
|
||||
|
||||
// --- ИЗМЕНЕНО --- Добавляем новый публичный метод для сброса выделения
|
||||
public unselect(): void {
|
||||
// Сбрасываем основное (одиночное) выделение
|
||||
this.selectInteraction.getFeatures().clear();
|
||||
this.onFeatureSelect(null); // Оповещаем React
|
||||
|
||||
// Сбрасываем множественное выделение
|
||||
this.setSelectedIds(new Set()); // Это вызовет onSelectionChange и перерисовку
|
||||
}
|
||||
|
||||
public saveMapState(): void {
|
||||
@ -671,14 +755,7 @@ class MapService {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (this.mode && this.mode.startsWith("drawing-")) this.finishDrawing();
|
||||
else if (
|
||||
this.mode === "edit" &&
|
||||
this.selectInteraction?.getFeatures().getLength() > 0
|
||||
) {
|
||||
this.selectInteraction.getFeatures().clear();
|
||||
this.onFeatureSelect(null);
|
||||
}
|
||||
this.unselect(); // --- ИЗМЕНЕНО --- Esc теперь тоже сбрасывает все выделения
|
||||
}
|
||||
}
|
||||
|
||||
@ -907,6 +984,38 @@ class MapService {
|
||||
}
|
||||
}
|
||||
|
||||
public handleMapClick(event: MapBrowserEvent<any>, ctrlKey: boolean): void {
|
||||
if (!this.map) return;
|
||||
|
||||
const pixel = this.map.getEventPixel(event.originalEvent);
|
||||
const featureAtPixel: Feature<Geometry> | undefined =
|
||||
this.map.forEachFeatureAtPixel(
|
||||
pixel,
|
||||
(f: FeatureLike) => f as Feature<Geometry>,
|
||||
{ layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 }
|
||||
);
|
||||
|
||||
if (!featureAtPixel) return;
|
||||
|
||||
const featureId = featureAtPixel.getId();
|
||||
if (featureId === undefined) return;
|
||||
|
||||
if (ctrlKey) {
|
||||
const newSet = new Set(this.selectedIds);
|
||||
if (newSet.has(featureId)) {
|
||||
newSet.delete(featureId);
|
||||
} else {
|
||||
newSet.add(featureId);
|
||||
}
|
||||
this.setSelectedIds(newSet);
|
||||
this.vectorLayer.changed();
|
||||
} else {
|
||||
this.selectFeature(featureId);
|
||||
const newSet = new Set<string | number>([featureId]);
|
||||
this.setSelectedIds(newSet);
|
||||
}
|
||||
}
|
||||
|
||||
public selectFeature(featureId: string | number | undefined): void {
|
||||
if (!this.map || featureId === undefined) {
|
||||
this.onFeatureSelect(null);
|
||||
@ -975,12 +1084,9 @@ class MapService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- НОВОЕ ---
|
||||
// Метод для множественного удаления объектов по их ID
|
||||
public deleteMultipleFeatures(featureIds: (string | number)[]): void {
|
||||
if (!featureIds || featureIds.length === 0) return;
|
||||
|
||||
// Вывод в консоль по требованию
|
||||
console.log("Запрос на множественное удаление. ID объектов:", featureIds);
|
||||
|
||||
const currentState = this.getCurrentStateAsGeoJSON();
|
||||
@ -994,26 +1100,22 @@ class MapService {
|
||||
featureIds.forEach((id) => {
|
||||
const feature = this.vectorSource.getFeatureById(id);
|
||||
if (feature) {
|
||||
// Удаление из "бэкенда"/стора для каждого объекта
|
||||
const recourse = String(id).split("-")[0];
|
||||
const numericId = String(id).split("-")[1];
|
||||
if (recourse && numericId) {
|
||||
mapStore.deleteRecourse(recourse, Number(numericId));
|
||||
}
|
||||
|
||||
// Если удаляемый объект выбран для редактирования, убираем его из выделения
|
||||
if (selectedFeaturesCollection?.getArray().includes(feature)) {
|
||||
selectedFeaturesCollection.remove(feature);
|
||||
}
|
||||
|
||||
// Удаляем объект с карты
|
||||
this.vectorSource.removeFeature(feature);
|
||||
deletedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (deletedCount > 0) {
|
||||
// Если основное выделение стало пустым, оповещаем React
|
||||
if (selectedFeaturesCollection?.getLength() === 0) {
|
||||
this.onFeatureSelect(null);
|
||||
}
|
||||
@ -1075,17 +1177,62 @@ class MapService {
|
||||
if (!event.feature) return;
|
||||
this.updateFeaturesInReact();
|
||||
}
|
||||
|
||||
public activateLasso() {
|
||||
if (this.lassoInteraction && this.map) {
|
||||
this.lassoInteraction.setActive(true);
|
||||
this.setMode("lasso");
|
||||
}
|
||||
}
|
||||
|
||||
public deactivateLasso() {
|
||||
if (this.lassoInteraction && this.map) {
|
||||
this.lassoInteraction.setActive(false);
|
||||
this.setMode("edit");
|
||||
}
|
||||
}
|
||||
|
||||
public setSelectedIds(ids: Set<string | number>) {
|
||||
this.selectedIds = new Set(ids);
|
||||
if (this.onSelectionChange) this.onSelectionChange(this.selectedIds);
|
||||
this.vectorLayer.changed();
|
||||
}
|
||||
|
||||
public getSelectedIds() {
|
||||
return new Set(this.selectedIds);
|
||||
}
|
||||
|
||||
public setOnSelectionChange(cb: (ids: Set<string | number>) => void) {
|
||||
this.onSelectionChange = cb;
|
||||
}
|
||||
|
||||
public toggleLasso() {
|
||||
if (this.mode === "lasso") {
|
||||
this.deactivateLasso();
|
||||
} else {
|
||||
this.activateLasso();
|
||||
}
|
||||
}
|
||||
|
||||
public getMap(): Map | null {
|
||||
return this.map;
|
||||
}
|
||||
}
|
||||
|
||||
// --- MAP CONTROLS COMPONENT ---
|
||||
// --- ИЗМЕНЕНО --- Добавляем проп isUnselectDisabled
|
||||
interface MapControlsProps {
|
||||
mapService: MapService | null;
|
||||
activeMode: string;
|
||||
isLassoActive: boolean;
|
||||
isUnselectDisabled: boolean;
|
||||
}
|
||||
|
||||
const MapControls: React.FC<MapControlsProps> = ({
|
||||
mapService,
|
||||
activeMode,
|
||||
isLassoActive,
|
||||
isUnselectDisabled, // --- ИЗМЕНЕНО ---
|
||||
}) => {
|
||||
if (!mapService) return null;
|
||||
|
||||
@ -1118,24 +1265,53 @@ const MapControls: React.FC<MapControlsProps> = ({
|
||||
icon: <LineIconSvg />,
|
||||
action: () => mapService.startDrawingLine(),
|
||||
},
|
||||
// {
|
||||
// mode: "lasso",
|
||||
// title: "Выделение",
|
||||
// longTitle: "Выделение области (или зажмите Shift)",
|
||||
// icon: <Lasso size={16} className="mr-1 sm:mr-2" />,
|
||||
// action: () => mapService.toggleLasso(),
|
||||
// isActive: isLassoActive,
|
||||
// },
|
||||
// --- ИЗМЕНЕНО --- Добавляем кнопку сброса
|
||||
{
|
||||
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) => (
|
||||
<button
|
||||
key={c.mode}
|
||||
className={`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 ${
|
||||
activeMode === c.mode
|
||||
? "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"
|
||||
}`}
|
||||
onClick={c.action}
|
||||
title={c.longTitle}
|
||||
>
|
||||
{c.icon}
|
||||
<span className="hidden sm:inline ml-0 sm:ml-1">{c.title}</span>
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@ -1145,24 +1321,34 @@ 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 [activeSection, setActiveSection] = useState<string | null>("layers");
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// --- НОВОЕ ---
|
||||
// Состояние для хранения ID объектов, выбранных для удаления
|
||||
const [selectedForDeletion, setSelectedForDeletion] = useState<
|
||||
Set<string | number>
|
||||
>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const toggleSection = (id: string) =>
|
||||
setActiveSection(activeSection === id ? null : id);
|
||||
const filteredFeatures = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return mapFeatures;
|
||||
}
|
||||
return mapFeatures.filter((feature) => {
|
||||
const name = (feature.get("name") as string) || "";
|
||||
return name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
}, [mapFeatures, searchQuery]);
|
||||
|
||||
const handleFeatureClick = useCallback(
|
||||
(id: string | number | undefined) => {
|
||||
@ -1184,39 +1370,33 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
|
||||
[mapService]
|
||||
);
|
||||
|
||||
// --- НОВОЕ ---
|
||||
// Обработчик изменения состояния чекбокса
|
||||
const handleCheckboxChange = useCallback(
|
||||
(id: string | number | undefined) => {
|
||||
if (id === undefined) return;
|
||||
setSelectedForDeletion((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
const newSet = new Set(selectedIds);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
setSelectedIds(newSet);
|
||||
},
|
||||
[]
|
||||
[selectedIds, setSelectedIds]
|
||||
);
|
||||
|
||||
// --- НОВОЕ ---
|
||||
// Обработчик для запуска множественного удаления
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
if (!mapService || selectedForDeletion.size === 0) return;
|
||||
if (!mapService || selectedIds.size === 0) return;
|
||||
|
||||
if (
|
||||
window.confirm(
|
||||
`Вы уверены, что хотите удалить ${selectedForDeletion.size} объект(ов)? Это действие нельзя отменить.`
|
||||
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)? Это действие нельзя отменить.`
|
||||
)
|
||||
) {
|
||||
const idsToDelete = Array.from(selectedForDeletion);
|
||||
const idsToDelete = Array.from(selectedIds);
|
||||
mapService.deleteMultipleFeatures(idsToDelete);
|
||||
setSelectedForDeletion(new Set()); // Очищаем выбор после удаления
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
}, [mapService, selectedForDeletion]);
|
||||
}, [mapService, selectedIds, setSelectedIds]);
|
||||
|
||||
const handleEditFeature = useCallback(
|
||||
(featureType: string | undefined, fullId: string | number | undefined) => {
|
||||
@ -1243,51 +1423,83 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
|
||||
setIsLoading(false);
|
||||
}, [mapService]);
|
||||
|
||||
const stations = mapFeatures.filter(
|
||||
function sortFeatures(
|
||||
features: Feature<Geometry>[],
|
||||
selectedIds: Set<string | number>,
|
||||
selectedFeature: Feature<Geometry> | null
|
||||
) {
|
||||
const selectedId = selectedFeature?.getId();
|
||||
return features.slice().sort((a, b) => {
|
||||
const aId = a.getId();
|
||||
const bId = b.getId();
|
||||
if (selectedId && aId === selectedId) return -1;
|
||||
if (selectedId && bId === selectedId) return 1;
|
||||
const aSelected = selectedIds.has(aId!);
|
||||
const bSelected = selectedIds.has(bId!);
|
||||
if (aSelected && !bSelected) return -1;
|
||||
if (!aSelected && bSelected) return 1;
|
||||
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" ||
|
||||
(f.getGeometry()?.getType() === "Point" && !f.get("featureType"))
|
||||
);
|
||||
const lines = mapFeatures.filter(
|
||||
const lines = (filteredFeatures || []).filter(
|
||||
(f) =>
|
||||
f.get("featureType") === "route" ||
|
||||
(f.getGeometry()?.getType() === "LineString" && !f.get("featureType"))
|
||||
);
|
||||
const sights = mapFeatures.filter((f) => f.get("featureType") === "sight");
|
||||
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);
|
||||
|
||||
interface SidebarSection {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
content: ReactNode;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const sections: SidebarSection[] = [
|
||||
{
|
||||
id: "layers",
|
||||
title: `Остановки (${stations.length})`,
|
||||
title: `Остановки (${sortedStations.length})`,
|
||||
icon: <Bus size={20} />,
|
||||
count: sortedStations.length,
|
||||
content: (
|
||||
<div className="space-y-1 max-h-[500px] overflow-y-auto pr-1">
|
||||
{stations.length > 0 ? (
|
||||
stations.map((s) => {
|
||||
{sortedStations.length > 0 ? (
|
||||
sortedStations.map((s) => {
|
||||
const sId = s.getId();
|
||||
const sName = (s.get("name") as string) || "Без названия";
|
||||
const isSelected = selectedFeature?.getId() === sId;
|
||||
// --- ИЗМЕНЕНИЕ ---
|
||||
const isCheckedForDeletion =
|
||||
sId !== undefined && selectedForDeletion.has(sId);
|
||||
sId !== undefined && selectedIds.has(sId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(sId)}
|
||||
data-feature-id={sId}
|
||||
data-feature-type="station"
|
||||
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"
|
||||
@ -1357,17 +1569,18 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
|
||||
},
|
||||
{
|
||||
id: "lines",
|
||||
title: `Маршруты (${lines.length})`,
|
||||
title: `Маршруты (${sortedLines.length})`,
|
||||
icon: <RouteIcon size={20} />,
|
||||
count: sortedLines.length,
|
||||
content: (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto pr-1">
|
||||
{lines.length > 0 ? (
|
||||
lines.map((l) => {
|
||||
{sortedLines.length > 0 ? (
|
||||
sortedLines.map((l) => {
|
||||
const lId = l.getId();
|
||||
const lName = (l.get("name") as string) || "Без названия";
|
||||
const isSelected = selectedFeature?.getId() === lId;
|
||||
const isCheckedForDeletion =
|
||||
lId !== undefined && selectedForDeletion.has(lId);
|
||||
lId !== undefined && selectedIds.has(lId);
|
||||
const lGeom = l.getGeometry();
|
||||
let lineLengthText: string | null = null;
|
||||
if (lGeom instanceof LineString) {
|
||||
@ -1378,6 +1591,8 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={String(lId)}
|
||||
data-feature-id={lId}
|
||||
data-feature-type="route"
|
||||
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
|
||||
isSelected
|
||||
? "bg-orange-100 border border-orange-300"
|
||||
@ -1457,20 +1672,23 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
|
||||
},
|
||||
{
|
||||
id: "sights",
|
||||
title: `Достопримечательности (${sights.length})`,
|
||||
title: `Достопримечательности (${sortedSights.length})`,
|
||||
icon: <Landmark size={20} />,
|
||||
count: sortedSights.length,
|
||||
content: (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto pr-1">
|
||||
{sights.length > 0 ? (
|
||||
sights.map((s) => {
|
||||
{sortedSights.length > 0 ? (
|
||||
sortedSights.map((s) => {
|
||||
const sId = s.getId();
|
||||
const sName = (s.get("name") as string) || "Без названия";
|
||||
const isSelected = selectedFeature?.getId() === sId;
|
||||
const isCheckedForDeletion =
|
||||
sId !== undefined && selectedForDeletion.has(sId);
|
||||
sId !== undefined && selectedIds.has(sId);
|
||||
return (
|
||||
<div
|
||||
key={String(sId)}
|
||||
data-feature-id={sId}
|
||||
data-feature-type="sight"
|
||||
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${
|
||||
isSelected
|
||||
? "bg-orange-100 border border-orange-300"
|
||||
@ -1555,69 +1773,89 @@ const MapSightbar: React.FC<MapSightbarProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
// --- ИЗМЕНЕНИЕ: Реструктуризация для футера с кнопками ---
|
||||
<div className="w-72 relative md:w-80 bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
|
||||
<div className="w-[360px] relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]">
|
||||
<div className="p-4 bg-gray-700 text-white">
|
||||
<h2 className="text-lg font-semibold">Панель управления</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-b border-gray-200 bg-white">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sections.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSection(s.id)}
|
||||
className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 ${
|
||||
activeSection === s.id
|
||||
? "bg-gray-100 text-blue-600"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span
|
||||
className={
|
||||
activeSection === s.id ? "text-blue-600" : "text-gray-600"
|
||||
}
|
||||
>
|
||||
{s.icon}
|
||||
</span>
|
||||
<span className="font-medium text-sm">{s.title}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`transform transition-transform duration-200 text-gray-500 ${
|
||||
activeSection === s.id ? "rotate-180" : ""
|
||||
}`}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
activeSection === s.id
|
||||
? "max-h-[600px] opacity-100"
|
||||
: "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3 text-sm text-gray-600 bg-white border-t border-gray-100">
|
||||
{s.content}
|
||||
</div>
|
||||
</div>
|
||||
{filteredFeatures.length === 0 && searchQuery ? (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Ничего не найдено.
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
sections.map(
|
||||
(s) =>
|
||||
(s.count > 0 || !searchQuery) && (
|
||||
<div
|
||||
key={s.id}
|
||||
className="border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSection(s.id)}
|
||||
className={`w-full p-3 flex items-center justify-between text-left hover:bg-gray-100 transition-colors duration-150 focus:outline-none focus:bg-gray-100 ${
|
||||
activeSection === s.id
|
||||
? "bg-gray-100 text-blue-600"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span
|
||||
className={
|
||||
activeSection === s.id
|
||||
? "text-blue-600"
|
||||
: "text-gray-600"
|
||||
}
|
||||
>
|
||||
{s.icon}
|
||||
</span>
|
||||
<span className="font-medium text-sm">{s.title}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`transform transition-transform duration-200 text-gray-500 ${
|
||||
activeSection === s.id ? "rotate-180" : ""
|
||||
}`}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
activeSection === s.id
|
||||
? "max-h-[600px] opacity-100"
|
||||
: "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3 text-sm text-gray-600 bg-white border-t border-gray-100">
|
||||
{s.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- НОВОЕ: Футер сайдбара с кнопками действий --- */}
|
||||
<div className="p-3 border-t border-gray-200 bg-gray-50/95 space-y-2">
|
||||
{selectedForDeletion.size > 0 && (
|
||||
{selectedIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="w-full flex items-center justify-center px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} className="mr-2" />
|
||||
Удалить выбранное ({selectedForDeletion.size})
|
||||
Удалить выбранное ({selectedIds.size})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@ -1650,6 +1888,14 @@ export const MapPage: React.FC = () => {
|
||||
const [mapFeatures, setMapFeatures] = useState<Feature<Geometry>[]>([]);
|
||||
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
|
||||
useState<Feature<Geometry> | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
|
||||
new Set()
|
||||
);
|
||||
const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||
const [activeSectionFromParent, setActiveSectionFromParent] = useState<
|
||||
string | null
|
||||
>("layers");
|
||||
|
||||
const handleFeaturesChange = useCallback(
|
||||
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
|
||||
@ -1658,10 +1904,42 @@ export const MapPage: React.FC = () => {
|
||||
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(() => {
|
||||
const element = document.querySelector(
|
||||
`[data-feature-id="${feat.getId()}"]`
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleMapClick = useCallback(
|
||||
(event: any) => {
|
||||
if (!mapServiceInstance || isLassoActive) return;
|
||||
|
||||
const ctrlKey =
|
||||
event.originalEvent.ctrlKey || event.originalEvent.metaKey;
|
||||
mapServiceInstance.handleMapClick(event, ctrlKey);
|
||||
},
|
||||
[mapServiceInstance, isLassoActive]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let service: MapService | null = null;
|
||||
if (mapRef.current && tooltipRef.current && !mapServiceInstance) {
|
||||
@ -1698,7 +1976,8 @@ export const MapPage: React.FC = () => {
|
||||
setCurrentMapMode,
|
||||
handleFeaturesChange,
|
||||
handleFeatureSelectForSidebar,
|
||||
tooltipRef.current
|
||||
tooltipRef.current,
|
||||
setSelectedIds
|
||||
);
|
||||
setMapServiceInstance(service);
|
||||
|
||||
@ -1720,12 +1999,71 @@ export const MapPage: React.FC = () => {
|
||||
setMapServiceInstance(null);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapServiceInstance) {
|
||||
const olMap = mapServiceInstance.getMap();
|
||||
if (olMap) {
|
||||
olMap.on("click", handleMapClick);
|
||||
|
||||
return () => {
|
||||
if (olMap) {
|
||||
olMap.un("click", handleMapClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [mapServiceInstance, handleMapClick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapServiceInstance) {
|
||||
mapServiceInstance.setOnSelectionChange(setSelectedIds);
|
||||
}
|
||||
}, [mapServiceInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift" && mapServiceInstance) {
|
||||
mapServiceInstance.activateLasso();
|
||||
setIsLassoActive(true);
|
||||
}
|
||||
};
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift" && mapServiceInstance) {
|
||||
mapServiceInstance.deactivateLasso();
|
||||
setIsLassoActive(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [mapServiceInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapServiceInstance) {
|
||||
mapServiceInstance.toggleLasso = function () {
|
||||
if (currentMapMode === "lasso") {
|
||||
this.deactivateLasso();
|
||||
setIsLassoActive(false);
|
||||
} else {
|
||||
this.activateLasso();
|
||||
setIsLassoActive(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [mapServiceInstance, currentMapMode, setIsLassoActive]);
|
||||
|
||||
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">
|
||||
@ -1762,19 +2100,83 @@ export const MapPage: React.FC = () => {
|
||||
</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} // --- ИЗМЕНЕНО --- Передаем состояние disabled
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Help button */}
|
||||
<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>
|
||||
|
||||
{/* Help popup */}
|
||||
{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>
|
||||
|
Reference in New Issue
Block a user