Files
irth-new-client/src/components/Map.tsx
T
2025-04-08 23:22:18 +05:00

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;