540 lines
16 KiB
TypeScript
540 lines
16 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import clsx from "clsx";
|
|
import { useRef, useState, useEffect } from "react";
|
|
import Marker from "./Marker";
|
|
import IMarker from "../types/IMarker";
|
|
import { markers } from "../data/markers";
|
|
import SearchIcon from "./icons/map/SearchIcon";
|
|
import MoveIcon from "./icons/map/MoveIcon";
|
|
import WeatherWidget from "./WeatherWidget";
|
|
import BottomPanel from "./BottomPanel";
|
|
|
|
interface Position {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
interface Size {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface MapProps {
|
|
maxZoom?: number;
|
|
}
|
|
|
|
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 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 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 Map({ maxZoom = 0.8 }: MapProps) {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [startPosition, setStartPosition] = useState<Position>({ x: 0, y: 0 });
|
|
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const mapRef = useRef<HTMLImageElement>(null);
|
|
const [zoom, setZoom] = useState(0);
|
|
const [originalSize, setOriginalSize] = useState<Size>({
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
const previousTouchDistance = useRef<number | null>(null);
|
|
const initialTouchDistance = useRef<number | null>(null);
|
|
const containerSizeRef = useRef<Size>({ width: 0, height: 0 });
|
|
const minZoomRef = useRef<number>(1);
|
|
const markersContainerRef = useRef<HTMLDivElement>(null);
|
|
const [hoveredMarker, setHoveredMarker] = useState<IMarker | null>(null);
|
|
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
|
const animationRef = useRef<number | null>(null);
|
|
const [lastClickTime, setLastClickTime] = useState(0);
|
|
const [isShowInstruction, setIsShowInstruction] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current || !isImageLoaded || originalSize.width === 0)
|
|
return;
|
|
|
|
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.46;
|
|
const desiredOffsetY = containerRect.height * 0.5;
|
|
|
|
const boundedOffsetX = Math.min(desiredOffsetX, maxOffsetX);
|
|
const boundedOffsetY = Math.min(desiredOffsetY, maxOffsetY);
|
|
|
|
setPosition({
|
|
x: -boundedOffsetX,
|
|
y: -boundedOffsetY,
|
|
});
|
|
}, [originalSize, isImageLoaded]);
|
|
|
|
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);
|
|
};
|
|
}
|
|
|
|
// Update container size and min zoom on resize
|
|
useEffect(() => {
|
|
const updateContainerSize = () => {
|
|
if (!containerRef.current) return;
|
|
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
const newContainerSize = {
|
|
width: containerRect.width,
|
|
height: containerRect.height,
|
|
};
|
|
containerSizeRef.current = newContainerSize;
|
|
|
|
// Recalculate min zoom when container size changes
|
|
const newMinZoom = calculateMinZoom(newContainerSize, originalSize);
|
|
minZoomRef.current = newMinZoom;
|
|
|
|
// Adjust zoom if it's below new minimum
|
|
if (zoom < newMinZoom) {
|
|
setZoom(newMinZoom);
|
|
updatePosition(position, newMinZoom);
|
|
}
|
|
};
|
|
|
|
updateContainerSize();
|
|
|
|
window.addEventListener("resize", updateContainerSize);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", updateContainerSize);
|
|
};
|
|
}, [originalSize, zoom]);
|
|
|
|
const getContainerSize = (): Size => {
|
|
return 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;
|
|
|
|
// Ensure zoom is within bounds
|
|
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 animateZoom = (
|
|
point: Position,
|
|
startZoom: number,
|
|
endZoom: number,
|
|
startTime: number,
|
|
duration: number = 300
|
|
) => {
|
|
const currentTime = Date.now();
|
|
const elapsed = currentTime - startTime;
|
|
|
|
if (elapsed >= duration) {
|
|
zoomToPoint(point, endZoom);
|
|
animationRef.current = null;
|
|
return;
|
|
}
|
|
|
|
// Используем easeOutCubic для плавной анимации
|
|
const progress = 1 - Math.pow(1 - elapsed / duration, 3);
|
|
const currentZoom = startZoom + (endZoom - startZoom) * progress;
|
|
|
|
zoomToPoint(point, currentZoom);
|
|
animationRef.current = requestAnimationFrame(() =>
|
|
animateZoom(point, startZoom, endZoom, startTime, duration)
|
|
);
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
|
if (isShowInstruction) {
|
|
setIsShowInstruction(false);
|
|
}
|
|
|
|
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) {
|
|
// Уменьшаем порог с 10 до 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) => {
|
|
e.preventDefault();
|
|
setIsShowInstruction(false);
|
|
|
|
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;
|
|
|
|
if (isShowInstruction) {
|
|
setIsShowInstruction(false);
|
|
}
|
|
|
|
setIsDragging(true);
|
|
const { x, y } = getEventPosition(e);
|
|
setStartPosition({
|
|
x: x - position.x,
|
|
y: y - position.y,
|
|
});
|
|
};
|
|
|
|
const handleClick = (
|
|
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
|
) => {
|
|
const currentTime = Date.now();
|
|
|
|
if (currentTime - lastClickTime < 200) {
|
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
|
|
|
const targetZoom =
|
|
Math.abs(zoom - maxZoom) < 0.01 ? minZoomRef.current : maxZoom;
|
|
const point = getEventPosition(e);
|
|
|
|
animationRef.current = requestAnimationFrame(() =>
|
|
animateZoom(point, zoom, targetZoom, Date.now())
|
|
);
|
|
setLastClickTime(0);
|
|
} else {
|
|
setLastClickTime(currentTime);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
document.addEventListener("wheel", handleWheel, { passive: false });
|
|
|
|
return () => {
|
|
document.removeEventListener("wheel", handleWheel);
|
|
};
|
|
}, [isDragging, position]);
|
|
|
|
// Разделяем стили трансформации для изображения и контейнера маркеров
|
|
const imageStyle = {
|
|
translate: `${position.x}px ${position.y}px`,
|
|
scale: zoom,
|
|
...originalSize,
|
|
transformOrigin: "0 0",
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="overflow-hidden h-screen w-screen relative select-none touch-none"
|
|
style={{ cursor: isDragging ? "grabbing" : "grab" }}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
onTouchCancel={handleTouchEnd}
|
|
onTouchMove={handleTouchMove}
|
|
onMouseDown={handleStart}
|
|
onMouseUp={handleEnd}
|
|
onMouseLeave={handleEnd}
|
|
onMouseMove={handleMouseMove}
|
|
onClick={handleClick}
|
|
>
|
|
<img
|
|
ref={mapRef}
|
|
src="/images/map.jpg"
|
|
alt="map"
|
|
className={clsx(
|
|
"pointer-events-none absolute max-w-none will-change-[transform,scale,opacity,filter]",
|
|
"transition-[opacity,filter] duration-300",
|
|
isImageLoaded && originalSize.width !== 0
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
hoveredMarker && "brightness-[80%]"
|
|
)}
|
|
style={imageStyle}
|
|
onLoad={handleLoad}
|
|
/>
|
|
<div ref={markersContainerRef} className="absolute" style={imageStyle}>
|
|
<div className="relative h-full">
|
|
{markers.map((marker) => (
|
|
<Marker
|
|
key={marker.id}
|
|
marker={marker}
|
|
zoom={zoom}
|
|
onHover={setHoveredMarker}
|
|
hoveredMarker={hoveredMarker}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<WeatherWidget />
|
|
<AnimatePresence>
|
|
{isShowInstruction && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none"
|
|
>
|
|
<div className="w-fit bg-[#0D1922]/40 rounded-lg backdrop-blur-sm space-y-3 p-4 text-white">
|
|
<div className="flex items-center justify-center gap-4">
|
|
<SearchIcon />
|
|
<div className="h-4 w-px bg-white"></div>
|
|
<MoveIcon />
|
|
</div>
|
|
<div className="">
|
|
<p className="text-sm">Zoom and Move to select a location</p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
<BottomPanel />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Map;
|