916 lines
29 KiB
TypeScript
916 lines
29 KiB
TypeScript
/* 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<string, number>;
|
|
totalUnits: number;
|
|
};
|
|
East: {
|
|
types: Record<string, number>;
|
|
totalUnits: number;
|
|
};
|
|
others: {
|
|
types: Record<string, number>;
|
|
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<string | null>(null);
|
|
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
|
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Image size constants
|
|
const originalSize = { width: 4096, height: 1752 };
|
|
|
|
// Zoom and pan state
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [startPosition, setStartPosition] = useState<Position>({ x: 0, y: 0 });
|
|
const [position, setImagePosition] = useState<Position>({ x: 0, y: 0 });
|
|
const [zoom, setZoom] = useState(1);
|
|
const containerRef = useRef<SVGSVGElement>(null);
|
|
const previousTouchDistance = useRef<number | null>(null);
|
|
const initialTouchDistance = useRef<number | null>(null);
|
|
const minZoomRef = useRef<number>(0.1); // Минимальный зум будет вычислен динамически
|
|
const maxZoomRef = useRef<number>(1.5); // Максимальный зум будет вычислен динамически
|
|
const maxZoomMultiplier = 1.5; // Коэффициент для максимального зума (50% больше минимального)
|
|
const containerSizeRef = useRef<Size>({ width: 0, height: 0 });
|
|
const lastPanTime = useRef<number>(0);
|
|
const panVelocity = useRef<Position>({ 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<Position>({ 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<SVGSVGElement>) => {
|
|
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<SVGSVGElement>) => {
|
|
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<SVGSVGElement>) => {
|
|
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<SVGSVGElement>) => {
|
|
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<SVGPathElement>) {
|
|
const x = e.clientX;
|
|
const y = e.clientY;
|
|
setPosition({ x, y });
|
|
}
|
|
|
|
const ref = useClickAway<SVGGElement>(() => {
|
|
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<FloorsData[]>(),
|
|
});
|
|
|
|
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(
|
|
<FloorPopup
|
|
title={floor}
|
|
complexName={complexName}
|
|
data={
|
|
data.find(
|
|
(floorData) =>
|
|
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 (
|
|
<div
|
|
className={clsx(
|
|
"overflow-hidden h-full w-full relative transition-transform duration-300",
|
|
selectedFloor && "2xl:-translate-x-1/4"
|
|
)}
|
|
ref={rootRef}
|
|
>
|
|
<div className="overflow-hidden relative w-full h-full">
|
|
<svg
|
|
ref={containerRef}
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
preserveAspectRatio="none"
|
|
viewBox="0 0 4096 1752"
|
|
className={clsx(
|
|
"touch-none absolute inset-0 select-none will-change-[opacity,scale,transform] transition-opacity duration-300",
|
|
isImageLoaded && originalSize.width !== 0
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
style={{
|
|
cursor: isMobile ? (isDragging ? "grabbing" : "grab") : "default",
|
|
...imageStyle,
|
|
}}
|
|
onMouseDown={isMobile ? handleMouseDown : undefined}
|
|
onMouseMove={isMobile ? handleMouseMove : undefined}
|
|
onMouseUp={isMobile ? handleMouseUp : undefined}
|
|
onTouchStart={isMobile ? handleTouchStart : undefined}
|
|
onTouchEnd={isMobile ? handleTouchEnd : undefined}
|
|
onTouchCancel={isMobile ? handleTouchEnd : undefined}
|
|
onTouchMove={isMobile ? handleTouchMove : undefined}
|
|
>
|
|
<defs>
|
|
<pattern
|
|
id={`background-image-${complexName}`}
|
|
patternUnits="userSpaceOnUse"
|
|
width="4096"
|
|
height="1752"
|
|
>
|
|
<image
|
|
href={`/images/${complexName}-floors.jpg`}
|
|
x="0"
|
|
y="0"
|
|
width="4096"
|
|
height="1752"
|
|
preserveAspectRatio="none"
|
|
/>
|
|
</pattern>
|
|
</defs>
|
|
<rect
|
|
x="0"
|
|
y="0"
|
|
width="4096"
|
|
height="1752"
|
|
fill={`url(#background-image-${complexName})`}
|
|
className="pointer-events-none"
|
|
/>
|
|
<g ref={ref}>
|
|
{Object.entries(
|
|
floorsMasks[complexName as keyof typeof floorsMasks]
|
|
).map(([floorTitle, d]) => (
|
|
<path
|
|
onMouseMove={!isMobile ? handleFloorMouseMove : undefined}
|
|
onClick={() => {
|
|
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 }) => (
|
|
<Fragment
|
|
key={`${floorTitle}-${x}-${y}-${width}-${height}-${d}`}
|
|
>
|
|
<rect
|
|
x={x}
|
|
y={y}
|
|
width={width}
|
|
height={height}
|
|
rx={height / 2}
|
|
fillOpacity={0.4}
|
|
className={clsx(
|
|
"transition-[fill] pointer-events-none",
|
|
((hoveredFloor &&
|
|
floorTitle ===
|
|
(complexName === "dubai-marina"
|
|
? hoveredFloor
|
|
: SPECIAL_FLOORS.includes(hoveredFloor)
|
|
? hoveredFloor
|
|
: hoveredFloor.split(" ").at(-1)!)) ||
|
|
selectedFloor?.split(" ").at(-1) === floorTitle) &&
|
|
"fill-[#00BED7]"
|
|
)}
|
|
/>
|
|
<path d={d} className="fill-white pointer-events-none" />
|
|
</Fragment>
|
|
))
|
|
) : (
|
|
// <rect
|
|
// x={x[1]}
|
|
// y={y[1]}
|
|
// width={width[1]}
|
|
// height={height[1]}
|
|
// rx={complexName === "marasi-drive" ? 14.2 : 10}
|
|
// fillOpacity={0.4}
|
|
// className={clsx(
|
|
// "transition-[fill] pointer-events-none",
|
|
// ((hoveredFloor &&
|
|
// floorTitle ===
|
|
// (complexName === "dubai-marina"
|
|
// ? hoveredFloor
|
|
// : SPECIAL_FLOORS.includes(hoveredFloor)
|
|
// ? hoveredFloor
|
|
// : hoveredFloor.split(" ").at(-1)!)) ||
|
|
// selectedFloor?.split(" ").at(-1) === floorTitle) &&
|
|
// "fill-[#00BED7]"
|
|
// )}
|
|
// />
|
|
// <path d={d[1]} className="fill-white pointer-events-none" />
|
|
<Fragment key={floorTitle}>
|
|
<rect
|
|
x={mask.x}
|
|
y={mask.y}
|
|
width={mask.width}
|
|
height={mask.height}
|
|
rx={mask.height / 2}
|
|
fillOpacity={0.4}
|
|
className={clsx(
|
|
"transition-[fill] pointer-events-none",
|
|
((hoveredFloor &&
|
|
floorTitle ===
|
|
(complexName === "dubai-marina"
|
|
? hoveredFloor
|
|
: SPECIAL_FLOORS.includes(hoveredFloor)
|
|
? hoveredFloor
|
|
: hoveredFloor.split(" ").at(-1)!)) ||
|
|
selectedFloor?.split(" ").at(-1) === floorTitle) &&
|
|
"fill-[#00BED7]"
|
|
)}
|
|
/>
|
|
<path
|
|
d={mask.d}
|
|
className="fill-white pointer-events-none"
|
|
/>
|
|
</Fragment>
|
|
)
|
|
)}
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
<Compass imgStyle={{ rotate: "180deg" }} />
|
|
<Button
|
|
variant="secondary"
|
|
size="small"
|
|
onlyIcon={innerWidth < 768}
|
|
className="!bg-white absolute 2xl:left-[2.222vw] 2xl:top-[2.222vw] md:max-2xl:left-6 md:max-2xl:top-6 left-4 top-4"
|
|
onClick={() => navigate(`/complex/${complexName}`)}
|
|
>
|
|
<span className="2xl:size-[1.389vw] size-5">
|
|
<ArrowLeftIcon />
|
|
</span>
|
|
<span className="max-md:hidden">Project view</span>
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="small"
|
|
onlyIcon={innerWidth < 768}
|
|
onClick={() => navigate(`/complex/${complexName}/about`)}
|
|
className="absolute 2xl:right-[2.222vw] 2xl:top-[2.222vw] md:max-2xl:right-6 md:max-2xl:top-6 right-4 top-4"
|
|
>
|
|
<span className="2xl:size-[1.389vw] size-5">
|
|
<InfoIcon />
|
|
</span>
|
|
<span className="max-md:hidden">About</span>
|
|
</Button>
|
|
{/* Reset zoom button (only for mobile) */}
|
|
{isMobile && (
|
|
<Button
|
|
variant="secondary"
|
|
size="small"
|
|
onlyIcon={true}
|
|
onClick={resetView}
|
|
className="absolute 2xl:right-[2.222vw] 2xl:bottom-[2.222vw] md:max-2xl:right-6 md:max-2xl:bottom-6 right-4 bottom-4"
|
|
title="Reset view"
|
|
>
|
|
<span className="2xl:size-[1.389vw] size-5">🔄</span>
|
|
</Button>
|
|
)}
|
|
<p className="absolute md:text-h4 text-h5 font-medium text-white -translate-x-1/2 select-none left-1/2 2xl:top-[2.5vw] md:max-2xl:top-6 top-5 drop-shadow pointer-events-none">
|
|
{markers.find((marker) => marker.name === complexName)?.title}
|
|
</p>
|
|
<ButtonGroup />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default FloorSelect;
|