1308 lines
43 KiB
TypeScript
1308 lines
43 KiB
TypeScript
/* 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;
|