Files
baraha-town/src/components/pages/SurroundingsPage.tsx
T

1308 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable react-hooks/exhaustive-deps */
import clsx from "clsx";
import { Fragment, useEffect, useRef, useState } from "react";
import {
surroundingPoints,
categories,
CENTER_POINT,
type ISurroundingPoint,
} from "../../consts/surroundingPoints";
import { usePopupStore } from "../../stores/usePopupStore";
import { AnimatePresence, motion } from "framer-motion";
import LocationPopup from "../popups/LocationPopup";
import SurroundingsFilter from "../SurroundingsFilter";
import Button from "../ui/Button";
import PlusIcon from "../icons/PlusIcon";
import MinusIcon from "../icons/MinusIcon";
import FullscreenButton from "../ui/FullscreenButton";
interface Position {
x: number;
y: number;
}
interface Size {
width: number;
height: number;
}
interface MapProps {
maxZoom?: number;
}
const getEventPosition = (
e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
): Position => {
if ("touches" in e)
return {
x: e.touches[0].clientX,
y: e.touches[0].clientY,
};
return {
x: e.clientX,
y: e.clientY,
};
};
const constrainPosition = (
position: Position,
containerSize: Size,
imageSize: Size,
zoom: number
): Position => {
const scaledWidth = imageSize.width * zoom;
const scaledHeight = imageSize.height * zoom;
const minX = containerSize.width - scaledWidth;
const minY = containerSize.height - scaledHeight;
return {
x: Math.min(0, Math.max(minX, position.x)),
y: Math.min(0, Math.max(minY, position.y)),
};
};
const calculateMinZoom = (containerSize: Size, imageSize: Size): number => {
if (imageSize.width === 0 || imageSize.height === 0) return 0.1;
const widthRatio = containerSize.width / imageSize.width;
const heightRatio = containerSize.height / imageSize.height;
return Math.max(widthRatio, heightRatio);
};
function SurroundingsPage({ maxZoom = 3 }: MapProps) {
const mapRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const containerSizeRef = useRef<Size>({ width: 0, height: 0 });
const previousTouchDistance = useRef<number | null>(null);
const initialTouchDistance = useRef<number | null>(null);
const minZoomRef = useRef<number>(1);
const markersContainerRef = useRef<HTMLDivElement>(null);
const [originalSize, setOriginalSize] = useState<Size>({
width: 0,
height: 0,
});
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState<Position>({ x: 0, y: 0 });
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(0);
const [selectedPoint, setSelectedPoint] = useState<ISurroundingPoint | null>(
null
);
const [isImageLoaded, setIsImageLoaded] = useState(false);
const [lastClickTime, setLastClickTime] = useState(0);
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(
new Set(Array.from(categories.keys()))
);
const { setPopup, setParentBoundingClientRect, setSide, setHasBackdrop } =
usePopupStore();
useEffect(() => {
if (!containerRef.current || !isImageLoaded || originalSize.width === 0)
return;
// TODO: отцентрировать карту
// const containerRect = containerRef.current.getBoundingClientRect();
// const scaledWidth = originalSize.width * zoom;
// const scaledHeight = originalSize.height * zoom;
// const maxOffsetX = Math.max(0, scaledWidth - containerRect.width);
// const maxOffsetY = Math.max(0, scaledHeight - containerRect.height);
// const desiredOffsetX = containerRect.width * 0.35;
// const desiredOffsetY = containerRect.height * 0.58;
// const boundedOffsetX = Math.min(maxOffsetX);
// const boundedOffsetY = Math.min(maxOffsetY);
setPosition({
x: 0,
y: 0,
});
}, [isImageLoaded]);
useEffect(() => {
const updateContainerSize = () => {
if (!containerRef.current) return;
const { width, height } = containerRef.current.getBoundingClientRect();
if (
width === containerSizeRef.current.width &&
height === containerSizeRef.current.height
)
return;
containerSizeRef.current = { width, height };
const newMinZoom = calculateMinZoom({ width, height }, originalSize);
minZoomRef.current = newMinZoom;
setZoom(newMinZoom);
const scaledWidth = originalSize.width * newMinZoom;
const scaledHeight = originalSize.height * newMinZoom;
const maxOffsetX = Math.max(0, scaledWidth - width);
const maxOffsetY = Math.max(0, scaledHeight - height);
const boundedOffsetX = Math.min(maxOffsetX);
const boundedOffsetY = Math.min(maxOffsetY);
setPosition({
x: -boundedOffsetX,
y: -boundedOffsetY,
});
};
updateContainerSize();
addEventListener("resize", updateContainerSize);
return () => {
removeEventListener("resize", updateContainerSize);
};
}, [originalSize, zoom]);
function handleLoad() {
if (!mapRef.current || !containerRef.current) return;
const img = new Image();
img.src = mapRef.current.src;
img.onload = () => {
const newOriginalSize = {
width: img.naturalWidth || img.width,
height: img.naturalHeight || img.height,
};
setOriginalSize(newOriginalSize);
const containerRect = containerRef.current!.getBoundingClientRect();
const minZoom = calculateMinZoom(
{
width: containerRect.width,
height: containerRect.height,
},
newOriginalSize
);
minZoomRef.current = minZoom;
setZoom(minZoom);
setIsImageLoaded(true);
};
}
const getContainerSize = (): Size => containerSizeRef.current;
const updatePosition = (newPosition: Position, newZoom: number = zoom) => {
if (!containerRef.current) return;
const containerSize = getContainerSize();
const constrainedPosition = constrainPosition(
newPosition,
containerSize,
originalSize,
newZoom
);
setPosition(constrainedPosition);
};
const zoomToPoint = (point: Position, targetZoom: number) => {
if (!containerRef.current) return;
const boundedZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, targetZoom)
);
const containerRect = containerRef.current.getBoundingClientRect();
const mouseX = point.x - containerRect.left;
const mouseY = point.y - containerRect.top;
const scale = boundedZoom / zoom;
const dx = mouseX - position.x;
const dy = mouseY - position.y;
const newPosition = {
x: mouseX - dx * scale,
y: mouseY - dy * scale,
};
setZoom(boundedZoom);
updatePosition(newPosition, boundedZoom);
};
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
if (e.touches.length === 2) {
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);
initialTouchDistance.current = distance;
previousTouchDistance.current = distance;
return;
}
if (e.touches.length === 1) handleStart(e);
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
if (e.touches.length === 2) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);
if (initialTouchDistance.current === null) {
initialTouchDistance.current = distance;
previousTouchDistance.current = distance;
return;
}
if (previousTouchDistance.current === null) {
previousTouchDistance.current = distance;
return;
}
const distanceChange = Math.abs(distance - previousTouchDistance.current);
const changePercentage =
(distanceChange / previousTouchDistance.current) * 100;
if (changePercentage >= 5) {
const zoomFactor = distance > previousTouchDistance.current ? 1.1 : 0.9;
const centerX = (touch1.clientX + touch2.clientX) / 2;
const centerY = (touch1.clientY + touch2.clientY) / 2;
const newZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, zoom * zoomFactor)
);
if (Math.abs(newZoom - zoom) > 0.001) {
setZoom(newZoom);
const containerRect = containerRef.current.getBoundingClientRect();
const mouseX = centerX - containerRect.left;
const mouseY = centerY - containerRect.top;
const scale = newZoom / zoom;
const dx = mouseX - position.x;
const dy = mouseY - position.y;
const newPosition = {
x: mouseX - dx * scale,
y: mouseY - dy * scale,
};
updatePosition(newPosition, newZoom);
}
previousTouchDistance.current = distance;
}
return;
}
if (isDragging && e.touches.length === 1) {
const { x, y } = getEventPosition(e);
const newPosition = {
x: x - startPosition.x,
y: y - startPosition.y,
};
updatePosition(newPosition);
}
};
const handleEnd = () => setIsDragging(false);
const handleTouchEnd = () => {
setIsDragging(false);
previousTouchDistance.current = null;
initialTouchDistance.current = null;
};
const handleWheel = (e: WheelEvent) => {
setPopup(null);
// Проверяем, находится ли событие внутри scrollable контейнера
const target = e.target as HTMLElement;
let element: HTMLElement | null = target;
while (element && element !== containerRef.current) {
const style = window.getComputedStyle(element);
const overflowY = style.overflowY || style.overflow;
// Если элемент имеет overflow: auto или scroll и может прокручиваться
if (
(overflowY === "auto" || overflowY === "scroll") &&
element.scrollHeight > element.clientHeight
) {
// Проверяем, может ли элемент прокрутиться в направлении события
const canScrollUp = element.scrollTop > 0;
const canScrollDown =
element.scrollTop < element.scrollHeight - element.clientHeight;
if ((e.deltaY < 0 && canScrollUp) || (e.deltaY > 0 && canScrollDown)) {
// Позволяем элементу прокручиваться
return;
}
}
element = element.parentElement;
}
e.preventDefault();
if (!containerRef.current || !mapRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, zoom * zoomFactor)
);
if (Math.abs(newZoom - zoom) < 0.01) return;
const scale = newZoom / zoom;
const dx = mouseX - position.x;
const dy = mouseY - position.y;
const newPosition = {
x: mouseX - dx * scale,
y: mouseY - dy * scale,
};
if (isDragging) {
const eventPosition = getEventPosition(e);
setStartPosition({
x: eventPosition.x - newPosition.x,
y: eventPosition.y - newPosition.y,
});
}
setZoom(newZoom);
updatePosition(newPosition, newZoom);
};
const handleMouseMove = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
if (!isDragging || !containerRef.current) return;
const { x, y } = getEventPosition(e);
const newPosition = {
x: x - startPosition.x,
y: y - startPosition.y,
};
updatePosition(newPosition);
};
const handleStart = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
if (!mapRef.current) return;
setIsDragging(true);
const { x, y } = getEventPosition(e);
setStartPosition({
x: x - position.x,
y: y - position.y,
});
};
function smoothZoomTo(targetZoom: number, point: Position) {
const duration = 400;
const startZoom = zoom;
const startTime = performance.now();
function animateZoom(now: number) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
const currentZoom = startZoom + (targetZoom - startZoom) * ease;
zoomToPoint(point, currentZoom);
if (t < 1) {
requestAnimationFrame(animateZoom);
} else {
setZoom(targetZoom);
zoomToPoint(point, targetZoom);
}
}
requestAnimationFrame(animateZoom);
}
// Вычисляет границы SVG пути
const getPathBounds = (
pathData: string
): { minX: number; minY: number; maxX: number; maxY: number } | null => {
try {
// Создаем временный SVG элемент для получения границ пути
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 4096 2176");
svg.style.position = "absolute";
svg.style.visibility = "hidden";
svg.style.width = "0";
svg.style.height = "0";
const path = document.createElementNS(
"http://www.w3.org/2000/svg",
"path"
);
path.setAttribute("d", pathData);
svg.appendChild(path);
document.body.appendChild(svg);
const bbox = path.getBBox();
document.body.removeChild(svg);
return {
minX: bbox.x,
minY: bbox.y,
maxX: bbox.x + bbox.width,
maxY: bbox.y + bbox.height,
};
} catch {
return null;
}
};
// Получает размеры фильтра относительно контейнера
const getFilterBounds = (): {
width: number;
height: number;
left: number;
top: number;
} | null => {
if (!containerRef.current) return null;
// Ищем фильтр по характерным классам - ищем элемент с классом, содержащим "top-4" и "left-4"
const allElements = containerRef.current.querySelectorAll("*");
let filterElement: HTMLElement | null = null;
for (const el of allElements) {
const htmlEl = el as HTMLElement;
const className = htmlEl.className || "";
if (
typeof className === "string" &&
className.includes("absolute") &&
(className.includes("top-4") || className.includes("top-[1rem]")) &&
(className.includes("left-4") || className.includes("left-[1rem]"))
) {
filterElement = htmlEl;
break;
}
}
if (!filterElement) return null;
const containerRect = containerRef.current.getBoundingClientRect();
const filterRect = filterElement.getBoundingClientRect();
return {
width: filterRect.width,
height: filterRect.height,
left: filterRect.left - containerRect.left,
top: filterRect.top - containerRect.top,
};
};
// Проверяет, помещается ли область на экране при заданном зуме без перекрытия фильтром
const isAreaFitting = (
minX: number,
minY: number,
maxX: number,
maxY: number,
centerX: number,
centerY: number,
testZoom: number,
containerSize: Size,
filterBounds: {
width: number;
height: number;
left: number;
top: number;
} | null,
filterPadding: number
): boolean => {
const centerScreenX = containerSize.width / 2;
const centerScreenY = containerSize.height / 2;
const centerMapX = centerScreenX - centerX * testZoom;
const centerMapY = centerScreenY - centerY * testZoom;
// Вычисляем границы области на экране
const areaScreenLeft = centerMapX + minX * testZoom;
const areaScreenTop = centerMapY + minY * testZoom;
const areaScreenRight = centerMapX + maxX * testZoom;
const areaScreenBottom = centerMapY + maxY * testZoom;
// Проверяем, помещается ли область на экране
if (
areaScreenLeft < 0 ||
areaScreenTop < 0 ||
areaScreenRight > containerSize.width ||
areaScreenBottom > containerSize.height
) {
return false;
}
// Проверяем, не перекрывается ли область фильтром
if (filterBounds) {
const filterRight =
filterBounds.left + filterBounds.width + filterPadding;
const filterBottom =
filterBounds.top + filterBounds.height + filterPadding;
// Проверяем, не перекрывается ли область фильтром
if (areaScreenLeft < filterRight && areaScreenTop < filterBottom) {
return false;
}
// Проверяем, не перекрывается ли центральная локация фильтром
const centerPointScreenX = centerMapX + CENTER_POINT.x * testZoom;
const centerPointScreenY = centerMapY + CENTER_POINT.y * testZoom;
const centerPointSize = 30;
if (
centerPointScreenX < filterRight &&
centerPointScreenY < filterBottom &&
centerPointScreenX + centerPointSize > filterBounds.left &&
centerPointScreenY + centerPointSize > filterBounds.top
) {
return false;
}
}
return true;
};
// Вычисляет оптимальный зум для отображения области с точками и путем
const calculateOptimalZoom = (
point: ISurroundingPoint,
containerSize: Size
): number => {
// Находим границы области, включающей обе точки
let minX = Math.min(point.coordinates.x, CENTER_POINT.x);
let minY = Math.min(point.coordinates.y, CENTER_POINT.y);
let maxX = Math.max(point.coordinates.x, CENTER_POINT.x);
let maxY = Math.max(point.coordinates.y, CENTER_POINT.y);
// Если есть путь, учитываем его границы
if (point.path) {
const paths = Array.isArray(point.path) ? point.path : [point.path];
for (const pathData of paths) {
const bounds = getPathBounds(pathData);
if (bounds) {
minX = Math.min(minX, bounds.minX);
minY = Math.min(minY, bounds.minY);
maxX = Math.max(maxX, bounds.maxX);
maxY = Math.max(maxY, bounds.maxY);
}
}
}
// Получаем размеры фильтра
const filterBounds = getFilterBounds();
const padding = 0.2; // Базовый отступ (20% с каждой стороны)
const filterPadding = 20; // Отступ от фильтра в пикселях
// Вычисляем размеры области на карте
const areaWidth = maxX - minX;
const areaHeight = maxY - minY;
const paddedWidth = areaWidth * (1 + padding * 2);
const paddedHeight = areaHeight * (1 + padding * 2);
// Вычисляем центр области на карте
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
// Начальный зум без учета фильтра
const baseZoom = Math.min(
containerSize.width / paddedWidth,
containerSize.height / paddedHeight
);
// Используем бинарный поиск для нахождения максимального зума
let minZoom = minZoomRef.current;
let maxZoomValue = Math.min(maxZoom, baseZoom * 2); // Увеличиваем верхнюю границу для поиска
let optimalZoom = baseZoom;
// Бинарный поиск максимального зума
for (let i = 0; i < 20; i++) {
const testZoom = (minZoom + maxZoomValue) / 2;
if (
isAreaFitting(
minX,
minY,
maxX,
maxY,
centerX,
centerY,
testZoom,
containerSize,
filterBounds,
filterPadding
)
) {
optimalZoom = testZoom;
minZoom = testZoom;
} else {
maxZoomValue = testZoom;
}
// Если разница между границами очень мала, прекращаем поиск
if (maxZoomValue - minZoom < 0.01) {
break;
}
}
// Ограничиваем зум между minZoom и maxZoom
return Math.min(maxZoom, Math.max(minZoomRef.current, optimalZoom));
};
function smoothZoomToCenterPoint(point: ISurroundingPoint) {
if (!containerRef.current) return;
const duration = 600;
const startZoom = zoom;
const startPosition = { ...position };
const startTime = performance.now();
// Вычисляем оптимальный зум для отображения области с точками и путем
const targetZoom = calculateOptimalZoom(point, containerSizeRef.current);
// Вычисляем центр области (между точками и путем)
let minX = Math.min(point.coordinates.x, CENTER_POINT.x);
let minY = Math.min(point.coordinates.y, CENTER_POINT.y);
let maxX = Math.max(point.coordinates.x, CENTER_POINT.x);
let maxY = Math.max(point.coordinates.y, CENTER_POINT.y);
// Если есть путь, учитываем его границы
if (point.path) {
const paths = Array.isArray(point.path) ? point.path : [point.path];
for (const pathData of paths) {
const bounds = getPathBounds(pathData);
if (bounds) {
minX = Math.min(minX, bounds.minX);
minY = Math.min(minY, bounds.minY);
maxX = Math.max(maxX, bounds.maxX);
maxY = Math.max(maxY, bounds.maxY);
}
}
}
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
// Вычисляем целевую позицию, чтобы центр был по середине экрана
const containerRect = containerRef.current.getBoundingClientRect();
const centerScreenX = containerRect.width / 2;
const centerScreenY = containerRect.height / 2;
// Целевая позиция карты, чтобы центр был по середине экрана
let targetPosition = {
x: centerScreenX - centerX * targetZoom,
y: centerScreenY - centerY * targetZoom,
};
// Проверяем, не попадает ли центральная локация в область фильтра
const filterBounds = getFilterBounds();
const filterPadding = 20; // Отступ от фильтра в пикселях
const centerPointSize = 30; // Примерный размер центральной локации с отступом
if (filterBounds) {
// Вычисляем, где будет центральная локация на экране
const centerPointScreenX = targetPosition.x + CENTER_POINT.x * targetZoom;
const centerPointScreenY = targetPosition.y + CENTER_POINT.y * targetZoom;
// Проверяем, попадает ли центральная локация в область фильтра
const filterRight =
filterBounds.left + filterBounds.width + filterPadding;
const filterBottom =
filterBounds.top + filterBounds.height + filterPadding;
// Если центральная локация попадает в область фильтра, корректируем позицию
if (
centerPointScreenX < filterRight &&
centerPointScreenY < filterBottom
) {
// Вычисляем, на сколько нужно сдвинуть карту, чтобы центральная локация была справа/снизу от фильтра
const neededShiftX = Math.max(
0,
filterRight - centerPointScreenX + centerPointSize
);
const neededShiftY = Math.max(
0,
filterBottom - centerPointScreenY + centerPointSize
);
// Сдвигаем позицию карты
targetPosition = {
x: targetPosition.x + neededShiftX,
y: targetPosition.y + neededShiftY,
};
}
}
// Ограничиваем позицию
const constrainedTargetPosition = constrainPosition(
targetPosition,
containerSizeRef.current,
originalSize,
targetZoom
);
function animateZoom(now: number) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
// Используем easing функцию для плавности
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
const currentZoom = startZoom + (targetZoom - startZoom) * ease;
const currentPosition = {
x:
startPosition.x +
(constrainedTargetPosition.x - startPosition.x) * ease,
y:
startPosition.y +
(constrainedTargetPosition.y - startPosition.y) * ease,
};
// Ограничиваем текущую позицию
const constrainedPosition = constrainPosition(
currentPosition,
containerSizeRef.current,
originalSize,
currentZoom
);
setZoom(currentZoom);
setPosition(constrainedPosition);
if (t < 1) {
requestAnimationFrame(animateZoom);
} else {
setZoom(targetZoom);
setPosition(constrainedTargetPosition);
}
}
requestAnimationFrame(animateZoom);
}
const handleZoomIn = () => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const centerX = containerRect.left + containerRect.width / 2;
const centerY = containerRect.top + containerRect.height / 2;
const currentZoom = zoom;
const zoomStep = 0.5;
const targetZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, currentZoom + zoomStep)
);
// Если зум уже на максимуме, не делаем ничего
if (Math.abs(targetZoom - currentZoom) < 0.01) return;
smoothZoomTo(targetZoom, { x: centerX, y: centerY });
};
const handleZoomOut = () => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const centerX = containerRect.left + containerRect.width / 2;
const centerY = containerRect.top + containerRect.height / 2;
const currentZoom = zoom;
const zoomStep = 0.5;
const targetZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, currentZoom - zoomStep)
);
// Если зум уже на минимуме, не делаем ничего
if (Math.abs(targetZoom - currentZoom) < 0.01) return;
smoothZoomTo(targetZoom, { x: centerX, y: centerY });
};
const handleClick = (
e: React.MouseEvent<HTMLElement> | React.TouchEvent<HTMLElement>
) => {
const currentTime = Date.now();
if (
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest("#selected-complex-card")
)
return;
if (currentTime - lastClickTime < 200) {
const targetZoom =
Math.abs(zoom - maxZoom) < 0.01 ? minZoomRef.current : maxZoom;
const point = getEventPosition(e);
smoothZoomTo(targetZoom, point);
setLastClickTime(0);
} else {
setLastClickTime(currentTime);
}
};
useEffect(() => {
containerRef.current?.addEventListener("wheel", handleWheel, {
passive: false,
});
return () =>
containerRef.current?.removeEventListener("wheel", handleWheel);
}, [isDragging, position]);
// Устанавливаем stroke-dasharray для пунктирных линий после рендера
useEffect(() => {
if (!selectedPoint || !Array.isArray(selectedPoint.path)) return;
// Находим все пунктирные пути для выбранной точки
const svg = document.querySelector('svg[viewBox="0 0 4096 2176"]');
if (!svg) return;
const dashedPaths = svg.querySelectorAll(
`path[data-dashed="true"][data-point="${selectedPoint.title}"][data-index="1"]`
);
dashedPaths.forEach((path) => {
const element = path as SVGPathElement;
element.setAttribute("stroke-dasharray", "6 6");
});
}, [selectedPoint]);
const handlePointMouseEnter = (
e: React.MouseEvent<HTMLDivElement>,
point: ISurroundingPoint
) => {
if (!containerRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
setParentBoundingClientRect(rect);
setSide("top");
setHasBackdrop(false);
setPopup(
<LocationPopup
title={point.title}
travelTime={point.travelTime}
style={{
transform: `translateY(-${rect.width / 2}px)`,
}}
/>
);
};
const handlePointMouseLeave = () => {
setPopup(null);
};
const handleToggleCategory = (category: string) => {
if (selectedCategories.has(category)) selectedCategories.delete(category);
else selectedCategories.add(category);
setSelectedCategories(new Set(selectedCategories));
};
const filteredPoints = surroundingPoints.filter((point) =>
selectedCategories.has(point.category)
);
// Сбрасываем выбранный поинт, если его категория отключена
useEffect(() => {
if (selectedPoint && !selectedCategories.has(selectedPoint.category)) {
setSelectedPoint(null);
}
}, [selectedCategories, selectedPoint]);
const handlePointClick = (
e: React.MouseEvent<HTMLDivElement>,
point: ISurroundingPoint
) => {
e.stopPropagation();
// Если кликнули по уже выбранной точке - снимаем выделение
if (selectedPoint?.title === point.title) {
setSelectedPoint(null);
} else {
setSelectedPoint(point);
// Плавно зумим и центрируем точку и центральную локацию
smoothZoomToCenterPoint(point);
}
};
const imageStyle = {
transform: `translateX(${position.x}px) translateY(${position.y}px) translateZ(0px) scale(${zoom})`,
transformOrigin: "0 0",
...originalSize,
};
return (
<div
ref={containerRef}
className={clsx(
"touch-none overflow-hidden relative h-full select-none",
isDragging ? "cursor-grabbing" : "cursor-grab"
)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
onTouchMove={handleTouchMove}
onMouseDown={handleStart}
onMouseUp={handleEnd}
onMouseLeave={() => {
handleEnd();
handlePointMouseLeave();
}}
onMouseMove={handleMouseMove}
onClick={handleClick}
>
{containerRef.current?.clientWidth && (
<img
ref={mapRef}
src="/img/surroundings/map.jpg"
style={imageStyle}
className="absolute max-w-none pointer-events-none"
alt="map"
onLoad={handleLoad}
/>
)}
<SurroundingsFilter
selectedLocation={selectedPoint?.title}
onSelectLocation={(location) => {
const point =
surroundingPoints.find((point) => point.title === location) || null;
setSelectedPoint(point);
// Плавно зумим и центрируем точку и центральную локацию при выборе через фильтр
if (point) {
smoothZoomToCenterPoint(point);
}
}}
selectedCategories={selectedCategories}
onToggleCategory={handleToggleCategory}
filteredPoints={filteredPoints}
/>
<svg
className="absolute pointer-events-none"
style={imageStyle}
viewBox="0 0 4096 2176"
>
<AnimatePresence>
{selectedPoint &&
(Array.isArray(selectedPoint.path) ? (
// Для составных путей: сначала пунктир (index 1), потом сплошная (index 0)
selectedPoint.path.map((pathData, index) => {
const isDashed = index === 1; // Второй путь - пунктирный (короткий)
const delay = isDashed ? 0 : 0.3; // Сплошная линия начинается после появления пунктира (0.3s фейд + 0.2s пауза)
// Transition для animate (с delay)
const animateTransition = {
pathLength: {
duration: 0.7,
ease: "easeInOut" as const,
delay,
},
pathOffset: {
duration: 0.7,
ease: "easeInOut" as const,
delay,
},
opacity: { duration: 0.3, delay },
};
// Transition для exit (без delay) - одинаковый для всех линий
const exitTransition = {
pathLength: {
duration: 0.7,
ease: "easeInOut" as const,
},
pathOffset: {
duration: 0.7,
ease: "easeInOut" as const,
},
opacity: { duration: 0.3 },
};
// Variants для пунктирной линии с transition
const dashedVariants = {
hidden: {
opacity: 0,
transition: {
opacity: {
duration: 0.3,
ease: "easeInOut" as const,
},
},
},
visible: {
opacity: 1,
transition: {
opacity: {
duration: 0.3,
delay,
ease: "easeInOut" as const,
},
},
},
exit: {
opacity: 0,
transition: {
opacity: {
duration: 0.3,
ease: "easeInOut" as const,
},
},
},
};
// Variants для сплошной линии с transition
const solidVariants = {
hidden: {
pathLength: 0,
pathOffset: 1,
opacity: 0,
transition: exitTransition,
},
visible: {
pathLength: 1,
pathOffset: 0,
opacity: 1,
transition: animateTransition,
},
exit: {
pathLength: 0,
pathOffset: 1,
opacity: 0,
transition: exitTransition,
},
};
return (
<Fragment key={`route-${selectedPoint.title}-${index}`}>
{isDashed ? (
// Для пунктирной линии используем обычный path с JavaScript анимацией
<>
<motion.path
key={`route-${selectedPoint.title}-${index}-white`}
d={pathData}
fill="none"
stroke="#FFFFFF"
strokeWidth="clamp(5px, 0.347vw, 6px)"
strokeLinecap="round"
strokeDasharray="6 6"
variants={dashedVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
<motion.path
key={`route-${selectedPoint.title}-${index}-color`}
d={pathData}
fill="none"
strokeWidth="clamp(3px, 0.208vw, 4px)"
stroke="#F47F52"
strokeLinecap="round"
strokeDasharray="6 6"
variants={dashedVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
</>
) : (
// Для сплошной линии используем pathOffset для анимации от точки к центру
<>
<motion.path
key={`route-${selectedPoint.title}-${index}-white`}
d={pathData}
fill="none"
stroke="#FFFFFF"
strokeWidth="clamp(5px, 0.347vw, 6px)"
strokeLinecap="round"
variants={solidVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
<motion.path
key={`route-${selectedPoint.title}-${index}-color`}
d={pathData}
fill="none"
stroke="#F47F52"
strokeWidth="clamp(3px, 0.208vw, 4px)"
strokeLinecap="round"
variants={solidVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
</>
)}
</Fragment>
);
})
) : (
// Для простых путей: анимация от точки к центру
<>
<motion.path
key={`route-${selectedPoint.title}-white`}
d={selectedPoint.path}
fill="none"
stroke="#FFFFFF"
strokeWidth="clamp(5px, 0.347vw, 6px)"
strokeLinecap="round"
initial={{ pathLength: 0, pathOffset: 1, opacity: 0 }}
animate={{ pathLength: 1, pathOffset: 0, opacity: 1 }}
exit={{ pathLength: 0, pathOffset: 1, opacity: 0 }}
transition={{
pathOffset: { duration: 0.7, ease: "easeInOut" },
opacity: { duration: 0.3 },
}}
/>
<motion.path
key={`route-${selectedPoint.title}-color`}
d={selectedPoint.path}
fill="none"
stroke="#F47F52"
strokeWidth="clamp(3px, 0.208vw, 4px)"
strokeLinecap="round"
initial={{ pathLength: 0, pathOffset: 1, opacity: 0 }}
animate={{ pathLength: 1, pathOffset: 0, opacity: 1 }}
exit={{ pathLength: 0, pathOffset: 1, opacity: 0 }}
transition={{
pathOffset: { duration: 0.7, ease: "easeInOut" },
opacity: { duration: 0.3 },
}}
/>
</>
))}
</AnimatePresence>
</svg>
<div className="absolute" ref={markersContainerRef} style={imageStyle}>
<div className="relative" style={originalSize}>
<div
className="absolute transition-alla origin-center"
ref={(el) => {
if (!el) return;
el.style.transform = `scale(${Math.min(
1 / zoom,
1
)}) translate(-${(el.clientWidth * zoom) / 2}px, -${
(el.clientHeight * zoom) / 2
}px)`;
}}
style={{
left: CENTER_POINT.x,
top: CENTER_POINT.y,
}}
>
<div className="2xl:p-[0.37vw] p-[5.33px] bg-[#F47F52] 2xl:rounded-[0.556vw] rounded-lg shadow-[0px_4px_40px_0px_rgba(244,127,82,0.3),0px_2px_2px_0px_rgba(244,127,82,0.25)]">
<img
ref={(el) => {
if (!el) return;
el.style.maxWidth =
innerWidth >= 1440
? `${(el.naturalWidth / 3 / 1440) * 100}vw`
: `${el.naturalWidth / 3}px`;
}}
src="/img/surroundings/location.png"
className="select-none"
draggable={false}
/>
</div>
</div>
<AnimatePresence>
{filteredPoints.map((point) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
key={point.title}
className="absolute"
style={{
left: point.coordinates.x,
top: point.coordinates.y,
}}
>
<div
onMouseEnter={(e) => handlePointMouseEnter(e, point)}
onMouseLeave={handlePointMouseLeave}
onClick={(e) => handlePointClick(e, point)}
ref={(el) => {
if (!el) return;
el.style.transform = `scale(${Math.min(
1 / zoom,
1
)}) translate(-${(el.clientWidth * zoom) / 2}px, -${
(el.clientHeight * zoom) / 2
}px)`;
}}
className="2xl:size-[1.111vw] size-4 aspect-square rounded-full 2xl:ring-[0.069vw] ring-1 ring-white cursor-pointer transition-colors"
style={{
backgroundColor:
selectedPoint?.title === point.title
? "#F47F52"
: categories.get(point.category) || "#F47F52",
}}
/>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
<div className="max-2xl:hidden space-y-[0.417vw] fixed top-1/2 -translate-y-1/2 right-[1.111vw]">
<Button variant="primary" onClick={handleZoomIn}>
<div className="2xl:size-[1.111vw] size-4">
<PlusIcon />
</div>
</Button>
<Button variant="primary" onClick={handleZoomOut}>
<div className="2xl:size-[1.111vw] size-4">
<MinusIcon />
</div>
</Button>
</div>
<FullscreenButton />
<img
src="/img/surroundings/logo.png"
alt="map"
className="fixed 2xl:top-[1.111vw] 2xl:right-[1.111vw] top-4 right-4 2xl:w-[13.681vw] w-[197px] max-md:hidden select-none cursor-pointer"
draggable={false}
onClick={() => {
window.location.href = "/";
}}
/>
</div>
);
}
export default SurroundingsPage;