/* eslint-disable react-hooks/exhaustive-deps */ import { Fragment, useEffect, useRef, useState } from "react"; import { markers } from "../data/markers"; import { enumerationMasks, floorsMasks } from "../data/masks"; import Compass from "./Compass"; import ArrowLeftIcon from "./icons/ArrowLeftIcon"; import InfoIcon from "./icons/InfoIcon"; import Button from "./ui/Button"; import { useLocation, useNavigate } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { api } from "../api/ky"; import clsx from "clsx"; import { usePopupStore } from "../stores/usePopupStore"; import FloorPopup from "./FloorPopup"; import { isMobile } from "react-device-detect"; import { useClickAway } from "@uidotdev/usehooks"; import ButtonGroup from "./ButtonGroup"; import { SPECIAL_FLOORS } from "../constants/floors"; import { ComplexName } from "../types/ComplexName"; interface Position { x: number; y: number; } interface Size { width: number; height: number; } const constrainPosition = ( position: Position, containerSize: Size, imageSize: Size, zoom: number ): Position => { const scaledWidth = imageSize.width * zoom; const scaledHeight = imageSize.height * zoom; // Calculate center position for X axis const centerX = (containerSize.width - scaledWidth) / 2; // Ограничение по X: 10% от ширины изображения const limitXPercent = 0.1; const maxOffsetX = scaledWidth * limitXPercent; const minX = centerX - maxOffsetX; const maxX = centerX + maxOffsetX; // Ограничение по Y: обычные границы контейнера const minY = containerSize.height - scaledHeight; const maxY = 0; return { x: Math.min(maxX, Math.max(minX, position.x)), y: Math.min(maxY, 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 heightRatio = containerSize.height / imageSize.height; return heightRatio; }; const calculateCenterPosition = ( containerSize: Size, imageSize: Size, zoom: number ): Position => { const scaledWidth = imageSize.width * zoom; const scaledHeight = imageSize.height * zoom; return { x: (containerSize.width - scaledWidth) / 2, y: (containerSize.height - scaledHeight) / 2, }; }; export interface FloorsData { floor: number; West: { types: Record; totalUnits: number; }; East: { types: Record; totalUnits: number; }; others: { types: Record; totalUnits: number; }; } function FloorSelect({ complexName, selectedFloor, onSelect, }: { complexName: ComplexName; selectedFloor: string | null; onSelect: (floor: string) => void; }) { const navigate = useNavigate(); const { popup, setPopup, setPosition, setSide } = usePopupStore(); const [hoveredFloor, setHoveredFloor] = useState(null); const [isImageLoaded, setIsImageLoaded] = useState(false); const rootRef = useRef(null); // Image size constants const originalSize = { width: 4096, height: 1752 }; // Zoom and pan state const [isDragging, setIsDragging] = useState(false); const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [position, setImagePosition] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const containerRef = useRef(null); const previousTouchDistance = useRef(null); const initialTouchDistance = useRef(null); const minZoomRef = useRef(0.1); // Минимальный зум будет вычислен динамически const maxZoomRef = useRef(1.5); // Максимальный зум будет вычислен динамически const maxZoomMultiplier = 1.5; // Коэффициент для максимального зума (50% больше минимального) const containerSizeRef = useRef({ width: 0, height: 0 }); const lastPanTime = useRef(0); const panVelocity = useRef({ x: 0, y: 0 }); // Track interaction state to prevent clicks during drag/zoom const [hasMoved, setHasMoved] = useState(false); const [hasZoomed, setHasZoomed] = useState(false); const dragStartPosition = useRef({ x: 0, y: 0 }); const dragThreshold = 5; // pixels // Enhanced zoom and pan handlers const updatePosition = (newPosition: Position, newZoom: number = zoom) => { if (!containerRef.current || !rootRef.current) return; const containerRect = rootRef.current.getBoundingClientRect(); const constrainedPosition = constrainPosition( newPosition, { width: containerRect.width, height: containerRect.height }, originalSize, newZoom ); setImagePosition(constrainedPosition); }; // Check if zoom is at limits const isAtMaxZoom = () => zoom >= maxZoomRef.current; const isAtMinZoom = () => zoom <= minZoomRef.current; const handleMouseDown = (e: React.MouseEvent) => { if (e.button !== 0) return; // Only handle left mouse button setIsDragging(true); setHasMoved(false); const { x, y } = getEventPosition(e); dragStartPosition.current = { x, y }; setStartPosition({ x: x - position.x, y: y - position.y, }); lastPanTime.current = Date.now(); panVelocity.current = { x: 0, y: 0 }; }; const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging) return; const { x, y } = getEventPosition(e); // Check if we've moved beyond the threshold const distanceMoved = Math.hypot( x - dragStartPosition.current.x, y - dragStartPosition.current.y ); if (distanceMoved > dragThreshold) { setHasMoved(true); } const currentTime = Date.now(); const deltaTime = currentTime - lastPanTime.current; const newPosition = { x: x - startPosition.x, y: y - startPosition.y, }; // Calculate velocity for momentum if (deltaTime > 0) { panVelocity.current = { x: (newPosition.x - position.x) / deltaTime, y: (newPosition.y - position.y) / deltaTime, }; } updatePosition(newPosition); lastPanTime.current = currentTime; }; const handleMouseUp = () => { setIsDragging(false); // Apply momentum if velocity is significant const velocityThreshold = 0.1; if ( Math.abs(panVelocity.current.x) > velocityThreshold || Math.abs(panVelocity.current.y) > velocityThreshold ) { const momentum = { x: position.x + panVelocity.current.x * 100, y: position.y + panVelocity.current.y * 100, }; updatePosition(momentum); } // Reset interaction state after a short delay setTimeout(() => { setHasMoved(false); setHasZoomed(false); }, 200); }; const handleTouchStart = (e: React.TouchEvent) => { 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; setHasZoomed(false); return; } if (e.touches.length === 1) { setIsDragging(true); setHasMoved(false); const { x, y } = getEventPosition(e); dragStartPosition.current = { x, y }; setStartPosition({ x: x - position.x, y: y - position.y, }); lastPanTime.current = Date.now(); panVelocity.current = { x: 0, y: 0 }; } }; const handleTouchMove = (e: React.TouchEvent) => { if (!rootRef.current) return; // setHasMoved(true); // setHasZoomed(true); 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 = distance - previousTouchDistance.current; const minDistanceChange = 5; // минимальное изменение в пикселях if (Math.abs(distanceChange) >= minDistanceChange) { // Zoom based on distance change, not time const zoomSensitivity = 0.003; // увеличенная чувствительность зума const zoomFactor = 1 + distanceChange * zoomSensitivity; const centerX = (touch1.clientX + touch2.clientX) / 2; const centerY = (touch1.clientY + touch2.clientY) / 2; const newZoom = Math.min( maxZoomRef.current, Math.max(minZoomRef.current, zoom * zoomFactor) ); // Prevent zoom if at limits or change is too small if ( Math.abs(newZoom - zoom) > 0.005 && !( (zoomFactor > 1 && isAtMaxZoom()) || (zoomFactor < 1 && isAtMinZoom()) ) ) { setHasZoomed(true); setHasMoved(true); setZoom(newZoom); const containerRect = rootRef.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); // Update previous distance only after successful zoom previousTouchDistance.current = distance; } } return; } if (isDragging && e.touches.length === 1) { const { x, y } = getEventPosition(e); // Check if we've moved beyond the threshold const distanceMoved = Math.hypot( x - dragStartPosition.current.x, y - dragStartPosition.current.y ); if (distanceMoved > dragThreshold) { setHasMoved(true); } const currentTime = Date.now(); const deltaTime = currentTime - lastPanTime.current; const newPosition = { x: x - startPosition.x, y: y - startPosition.y, }; // Calculate velocity for momentum if (deltaTime > 0) { panVelocity.current = { x: (newPosition.x - position.x) / deltaTime, y: (newPosition.y - position.y) / deltaTime, }; } // Удаляем неиспользуемые переменные и console.log updatePosition(newPosition); lastPanTime.current = currentTime; } }; const handleTouchEnd = () => { if (isDragging) { // Apply momentum if velocity is significant const velocityThreshold = 0.1; if ( Math.abs(panVelocity.current.x) > velocityThreshold || Math.abs(panVelocity.current.y) > velocityThreshold ) { const momentum = { x: position.x + panVelocity.current.x * 100, y: position.y + panVelocity.current.y * 100, }; updatePosition(momentum); } } setIsDragging(false); previousTouchDistance.current = null; initialTouchDistance.current = null; // Reset interaction state after a longer delay to prevent accidental clicks after zoom setTimeout(() => { setHasMoved(false); setHasZoomed(false); }, 200); }; const handleWheel = (e: WheelEvent) => { e.preventDefault(); if (!rootRef.current) return; const containerRect = rootRef.current.getBoundingClientRect(); const mouseX = e.clientX - containerRect.left; const mouseY = e.clientY - containerRect.top; // Enhanced zoom with better sensitivity const zoomSensitivity = 0.001; const zoomFactor = 1 - e.deltaY * zoomSensitivity; const newZoom = Math.min( maxZoomRef.current, Math.max(minZoomRef.current, zoom * zoomFactor) ); // Prevent zoom if at limits or change is too small if ( Math.abs(newZoom - zoom) < 0.005 || (zoomFactor > 1 && isAtMaxZoom()) || (zoomFactor < 1 && isAtMinZoom()) ) { 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, }; setZoom(newZoom); updatePosition(newPosition, newZoom); }; // Reset zoom and position const resetView = () => { if (!rootRef.current) return; const { width, height } = rootRef.current.getBoundingClientRect(); const newMinZoom = calculateMinZoom({ width, height }, originalSize); const centerPosition = calculateCenterPosition( { width, height }, originalSize, newMinZoom // Сбрасываем к минимальному зуму (изображение на всю высоту) ); setZoom(newMinZoom); setImagePosition(centerPosition); }; // Initialize and update container size on resize useEffect(() => { const updateContainerSize = () => { if (!rootRef.current) return; const { width, height } = rootRef.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; // Устанавливаем максимальный зум как минимальный + 50% maxZoomRef.current = newMinZoom * maxZoomMultiplier; // Инициализируем при первой загрузке или если текущий зум меньше нового минимального if (!isImageLoaded || zoom < newMinZoom) { const centerPosition = calculateCenterPosition( { width, height }, originalSize, newMinZoom // Используем вычисленный минимальный зум ); setZoom(newMinZoom); setImagePosition(centerPosition); setIsImageLoaded(true); } }; // Initial setup setTimeout(updateContainerSize, 0); window.addEventListener("resize", updateContainerSize); return () => { window.removeEventListener("resize", updateContainerSize); }; }, [isImageLoaded, zoom]); // Add wheel event listener (only for mobile) useEffect(() => { if (!isMobile) return; const container = containerRef.current; if (!container) return; container.addEventListener("wheel", handleWheel, { passive: false }); return () => { container.removeEventListener("wheel", handleWheel); }; }, [zoom, position, isMobile]); // Add global mouse events for better dragging experience (only for mobile) useEffect(() => { if (!isMobile || !isDragging) return; const handleGlobalMouseMove = (e: MouseEvent) => { const { x, y } = getEventPosition(e); const currentTime = Date.now(); const deltaTime = currentTime - lastPanTime.current; const newPosition = { x: x - startPosition.x, y: y - startPosition.y, }; // Calculate velocity for momentum if (deltaTime > 0) { panVelocity.current = { x: (newPosition.x - position.x) / deltaTime, y: (newPosition.y - position.y) / deltaTime, }; } updatePosition(newPosition); lastPanTime.current = currentTime; }; const handleGlobalMouseUp = () => { handleMouseUp(); }; document.addEventListener("mousemove", handleGlobalMouseMove); document.addEventListener("mouseup", handleGlobalMouseUp); return () => { document.removeEventListener("mousemove", handleGlobalMouseMove); document.removeEventListener("mouseup", handleGlobalMouseUp); }; }, [isMobile, isDragging, startPosition, position]); function handleFloorMouseMove(e: React.MouseEvent) { const x = e.clientX; const y = e.clientY; setPosition({ x, y }); } const ref = useClickAway(() => { if (isMobile && innerWidth >= 768 && !selectedFloor) { setPopup(null); setHoveredFloor(null); } }); useEffect(() => { if (!selectedFloor) setPopup(null); }, [selectedFloor, setPopup]); const { data } = useQuery({ queryKey: ["floors-data", complexName], queryFn: () => api.get(`units/get-floors-data/${complexName}`).json(), }); function handleFloorClick(floor: string) { if (SPECIAL_FLOORS.includes(floor) || complexName === "marasi-drive") { onSelect(floor); } else { onSelect(floor.split(" ").at(-1)!); } } function openPopup(floor: string, wing?: "West" | "East") { if (!data) return; if (isMobile) setSide("bottom"); else setSide(!wing || wing === "East" ? "left" : "right"); if ( data.some( (floorData) => floorData.floor === +floor!.split(" ").at(-1)! || floorData.floor === +floor!.split(" ").at(-1)!.split("-")[0] ) || SPECIAL_FLOORS.includes(floor) || (["19-20", "23-24", "27-28"].includes(floor) && complexName === "hq") ) setPopup( floorData.floor === +floor!.split(" ").at(-1)! || floorData.floor === +floor!.split(" ").at(-1)!.split("-")[0] )! } onSelect={handleFloorClick} /> ); } const { pathname } = useLocation(); useEffect(() => { if (!popup) setHoveredFloor(null); }, [popup]); useEffect(() => { setPopup(null); }, [pathname]); const imageStyle = { transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`, transformOrigin: "0 0", width: originalSize.width, height: originalSize.height, }; return (
{Object.entries( floorsMasks[complexName as keyof typeof floorsMasks] ).map(([floorTitle, d]) => ( { if (!isMobile && !hasMoved && !hasZoomed) { handleFloorClick(floorTitle); } }} // onTouchStart={(e) => { // // e.stopPropagation(); // setPosition({ // x: e.touches[0].clientX, // y: e.touches[0].clientY, // }); // }} onTouchEnd={(e) => { if (isMobile && !hasMoved && !hasZoomed) { e.stopPropagation(); setHoveredFloor( SPECIAL_FLOORS.includes(floorTitle) || complexName === "marasi-drive" ? floorTitle : floorTitle.split(" ").at(-1)! ); openPopup( floorTitle, !SPECIAL_FLOORS.includes(floorTitle) && complexName === "marasi-drive" ? (floorTitle.split(" ")[0] as "West" | "East") : undefined ); } }} onMouseEnter={() => { setHoveredFloor( SPECIAL_FLOORS.includes(floorTitle) || complexName === "marasi-drive" ? floorTitle : floorTitle.split(" ").at(-1)! ); if (!isMobile) openPopup( floorTitle, !SPECIAL_FLOORS.includes(floorTitle) && complexName === "marasi-drive" ? (floorTitle.split(" ")[0] as "West" | "East") : undefined ); }} onMouseLeave={(e) => { if (!isMobile) { const relatedTarget = (e.nativeEvent as MouseEvent) .relatedTarget; const movingToAnotherFloor = relatedTarget instanceof SVGPathElement && ref.current?.contains(relatedTarget); if (!movingToAnotherFloor) { setPopup(null); setHoveredFloor(null); } } }} key={floorTitle} d={d} className={clsx( `fill-[#00BED7] cursor-pointer transition-opacity duration-300 hover:opacity-60 pointer-events-auto`, selectedFloor === (SPECIAL_FLOORS.includes(floorTitle) || complexName === "marasi-drive" ? floorTitle : floorTitle.split(" ").at(-1)!) || hoveredFloor === (SPECIAL_FLOORS.includes(floorTitle) || complexName === "marasi-drive" ? floorTitle : floorTitle.split(" ").at(-1)!) ? "opacity-60" : "opacity-20" )} /> ))} {Object.entries(enumerationMasks[complexName]).map( ([floorTitle, mask]) => Array.isArray(mask) ? ( mask.map(({ x, y, width, height, d }) => ( )) ) : ( // // ) )}
{/* Reset zoom button (only for mobile) */} {isMobile && ( )}

{markers.find((marker) => marker.name === complexName)?.title}

); } export default FloorSelect;