init
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import useModalStore from "../stores/useModalStore";
|
||||
import DisclaimerIcon from "./icons/map/DisclaimerIcon";
|
||||
import DisclaimerModal from "./modals/DisclaimerModal";
|
||||
import Button from "./ui/Button";
|
||||
|
||||
export default function BottomPanel() {
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 w-full z-20 max-[1440px]:hidden flex justify-between items-end gap-2 touch-none p-6">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="!bg-black/40 !rounded-full px-2 py-1 lg:px-[0.556vw] lg:py-[0.278vw] hover:!bg-[#0D1922]/40 cursor-pointer"
|
||||
// onClick={() => setModal(<DisclaimerModal />)}
|
||||
>
|
||||
<span className="min-w-4 min-h-4 w-[1.111vw] h-[1.111vw] text-white">
|
||||
<DisclaimerIcon />
|
||||
</span>
|
||||
<span className="text-white text-xs lg:text-[0.833vw] font-semibold">
|
||||
Disclaimer
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
/* 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;
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useRef } from "react";
|
||||
import IMarker from "../types/IMarker";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface MarkerProps {
|
||||
marker: IMarker;
|
||||
zoom: number;
|
||||
onHover: (marker: IMarker | null) => void;
|
||||
hoveredMarker: IMarker | null;
|
||||
}
|
||||
|
||||
function Marker({ marker, zoom, onHover, hoveredMarker }: MarkerProps) {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const popupRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
key={marker.id}
|
||||
className={clsx(
|
||||
"absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer will-change-[transform,filter,scale] transition-[transform,filter,scale] duration-300",
|
||||
hoveredMarker
|
||||
? hoveredMarker.id === marker.id
|
||||
? "z-10 scale-110"
|
||||
: "brightness-[80%]"
|
||||
: ""
|
||||
)}
|
||||
style={{
|
||||
top: marker.y,
|
||||
left: marker.x,
|
||||
transform: `scale(${0.8 / zoom})`,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
onHover(marker);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onHover(null);
|
||||
}}
|
||||
onClick={() => {
|
||||
console.log(marker.name);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
ref={ref}
|
||||
src={marker.src}
|
||||
alt={marker.name}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute bottom-[10%]",
|
||||
marker.popupPosition === "left" ? "right-full" : "left-full"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
ref={popupRef}
|
||||
src={`/images/markers/popups/${marker.name}.png`}
|
||||
alt={marker.name}
|
||||
className="pointer-events-none"
|
||||
style={{
|
||||
minWidth: `${popupRef.current?.naturalWidth}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Marker;
|
||||
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useEffect, useRef } from "react";
|
||||
import useModalStore from "../stores/useModalStore";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import CloseIcon from "./icons/CloseIcon";
|
||||
import Button from "./ui/Button";
|
||||
import { clsx as cn } from "clsx";
|
||||
|
||||
function ModalContainer() {
|
||||
const { modal, setModal } = useModalStore();
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function handleResize() {
|
||||
if (!popoverRef.current) return;
|
||||
|
||||
if (divRef.current!.clientHeight > popoverRef.current!.clientHeight) {
|
||||
backdropRef.current!.style.height = `${divRef.current!.clientHeight}px`;
|
||||
} else {
|
||||
backdropRef.current!.style.height = `100%`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{modal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full"
|
||||
>
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/70 overflow-y-auto flex flex-col justify-center items-center",
|
||||
// position === "center" && "items-center",
|
||||
// position === "right" && "items-end"
|
||||
)}
|
||||
>
|
||||
<div className="max-h-full">
|
||||
<div ref={divRef} className="p-[0.972vw]">
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="absolute inset-0 cursor-pointer"
|
||||
onClick={() => setModal(null)}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full"
|
||||
// style={{
|
||||
// height: `calc(${backdropRef.current?.clientHeight}px - 0.972vw * 2)`,
|
||||
// }}
|
||||
>
|
||||
{modal}
|
||||
<Button
|
||||
onlyIcon
|
||||
className="absolute top-[1.667vw] right-[1.667vw] p-[0.556vw] !rounded-full bg-[#F9F9F9]"
|
||||
onClick={() => setModal(null)}
|
||||
>
|
||||
<span className="w-[1.389vw] h-[1.389vw] text-black">
|
||||
<CloseIcon />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalContainer;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getWeather } from "../api/weather";
|
||||
|
||||
export default function WeatherWidget() {
|
||||
const [temperature, setTemperature] = useState(0);
|
||||
|
||||
const date = new Date();
|
||||
|
||||
const day = date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
});
|
||||
const month = date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
});
|
||||
|
||||
const hours = date.getHours() > 12 ? date.getHours() - 12 : date.getHours();
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const dayPart = `${date.getHours() >= 12 ? "PM" : "AM"}`;
|
||||
const formattedTime = `${hours}:${minutes}`;
|
||||
|
||||
useEffect(() => {
|
||||
getWeather().then((data) => {
|
||||
setTemperature(Math.round(data));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="z-20 fixed left-10 top-10 rounded-2xl space-y-4 min-w-50 w-[7.5vw] p-4 font-medium text-white bg-black/40 pointer-events-none max-[1440px]:hidden">
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<p>{day}</p>
|
||||
<p>{formattedTime}</p>
|
||||
</div>
|
||||
<div className="flex justify-between opacity-60">
|
||||
<p>
|
||||
{date.getDate()} {month}
|
||||
</p>
|
||||
<p>{dayPart}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-white -mx-4" />
|
||||
<p className="text-[32px]">{temperature}°C</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function CloseIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m10 10 4.714-4.714M10 10 5.286 5.286M10 10l4.714 4.714M10 10l-4.714 4.714"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export default function DisclaimerIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14.167 8A6.167 6.167 0 1 1 1.833 8a6.167 6.167 0 0 1 12.334 0Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path d="M8 4.667v4" stroke="currentColor" strokeLinecap="round" />
|
||||
<circle cx="8" cy="11.333" fill="currentColor" r=".667" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
function MoveIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={49}
|
||||
height={48}
|
||||
viewBox="0 0 49 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m11.9 29-5.4-5m0 0 5.4-5m-5.4 5h11.7m12.6 0h11.7m0 0-5.4 5m5.4-5-5.4-5m-7.6 17.6-5 5.4m0 0-5-5.4m5 5.4V30.3m0-12.6V6m0 0 5 5.4m-5-5.4-5 5.4"
|
||||
stroke="#fff"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default MoveIcon;
|
||||
@@ -0,0 +1,21 @@
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={49}
|
||||
height={48}
|
||||
viewBox="0 0 49 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m41.5 41-7.686-7.686m0 0A15.95 15.95 0 0 0 38.5 22c0-8.837-7.163-16-16-16s-16 7.163-16 16 7.163 16 16 16a15.95 15.95 0 0 0 11.306-4.678zM16.5 22h12m-6-6v12"
|
||||
stroke="#fff"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchIcon;
|
||||
@@ -0,0 +1,35 @@
|
||||
export default function DisclaimerModal() {
|
||||
return (
|
||||
<div className="bg-white z-40 rounded-lg lg:col-span-4 col-span-8 col-start-3 lg:col-start-5 py-[37px] px-8 w-1/3 self-center">
|
||||
<h2 className="text-subheadline-m font-semibold py-6">Disclaimer</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-caption-m">
|
||||
This masterplan has been designed solely to provide an impression of
|
||||
the Rove Home projects as well as the approximate location of
|
||||
existing and proposed facilities, services, and destinations and is
|
||||
not intended for any other purpose.
|
||||
</p>
|
||||
<p className="text-caption-m">
|
||||
All elements including the interior design used in the units and
|
||||
images shown in the virtual tour are only for illustration. The
|
||||
pictures of the proposed residential units, furniture, landscaping,
|
||||
amenities, color schemes, fixtures, and accessories among all other
|
||||
items are illustrative to showcase the units.
|
||||
</p>
|
||||
<p className="text-caption-m">
|
||||
IRTH does not make any representation or give any warranty
|
||||
concerning the future developments shown, or the current or future
|
||||
amenities, location, or existence of any facilities, services, and
|
||||
destinations. Any indications of distance, sizes, travel times, and
|
||||
any other information are approximate and for indicative purposes
|
||||
only and are not to scale.
|
||||
</p>
|
||||
<p className="text-caption-m">
|
||||
IRTH gives notice that this virtual tour (including units,
|
||||
amenities, plans of the property) does not constitute any part of a
|
||||
sale offer or sale and purchase contract.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
variant?: 'link' | 'primary' | 'secondary' | 'tertiary';
|
||||
className?: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
onlyIcon?: boolean;
|
||||
ref?: React.RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
onlyIcon,
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={clsx(
|
||||
'transition-all rounded-lg flex items-center justify-center',
|
||||
variant !== 'link' && [
|
||||
size === 'small' && (onlyIcon ? 'p-2' : 'px-3 py-2 gap-2'),
|
||||
size === 'medium' && (onlyIcon ? 'p-3.5' : 'px-5 py-3.5 gap-3.5'),
|
||||
size === 'large' && (onlyIcon ? 'p-4' : 'px-6 py-4 gap-4'),
|
||||
],
|
||||
variant === 'link' && 'text-sm text-black/50 w-fit',
|
||||
variant === 'primary' && 'bg-[#1E1E1E] text-white',
|
||||
variant === 'secondary' && 'bg-white',
|
||||
variant === 'tertiary' &&
|
||||
'bg-transparent text-[#767676] hover:bg-black/5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
Reference in New Issue
Block a user