/* 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({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 }); const containerRef = useRef(null); const mapRef = useRef(null); const [zoom, setZoom] = useState(0); const [originalSize, setOriginalSize] = useState({ width: 0, height: 0, }); const previousTouchDistance = useRef(null); const initialTouchDistance = useRef(null); const containerSizeRef = useRef({ width: 0, height: 0 }); const minZoomRef = useRef(1); const markersContainerRef = useRef(null); const [hoveredMarker, setHoveredMarker] = useState(null); const [isImageLoaded, setIsImageLoaded] = useState(false); const animationRef = useRef(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) => { 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) => { 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 | React.TouchEvent ) => { 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 | React.TouchEvent ) => { 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 | React.TouchEvent ) => { 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 (
map
{markers.map((marker) => ( ))}
{isShowInstruction && (

Zoom and Move to select a location

)}
); } export default Map;