This commit is contained in:
2025-11-26 17:43:26 +05:00
parent 90cacefc8f
commit 32bdcc9a53
15 changed files with 570 additions and 134 deletions
@@ -1,17 +1,17 @@
import clsx from "clsx";
import { categories } from "../../consts/surroundingPoints";
import { categories } from "../consts/surroundingPoints";
interface CategoryFilterProps {
selectedCategories: Set<string>;
onToggleCategory: (category: string) => void;
}
function CategoryFilter({
function CategoriesFilter({
selectedCategories,
onToggleCategory,
}: CategoryFilterProps) {
return (
<div className="flex flex-wrap 2xl:gap-[0.556vw] gap-2">
<div className="grid grid-cols-2 gap-2 max-md:hidden">
{Array.from(categories.entries()).map(([category, color]) => {
const isSelected = selectedCategories.has(category);
return (
@@ -19,22 +19,22 @@ function CategoryFilter({
key={category}
onClick={() => onToggleCategory(category)}
className={clsx(
"flex items-center 2xl:gap-[0.556vw] gap-2 2xl:px-0 px-0 2xl:py-[0.556vw] py-2 transition-colors",
"flex items-center 2xl:gap-[0.556vw] gap-2 2xl:py-[0.556vw] py-2 transition-opacity outline-none",
"hover:opacity-80"
)}
>
<div
className={clsx(
"2xl:size-4 size-4 rounded-full border-2 transition-colors",
isSelected ? "border-transparent" : "border-[#E2DCCF]"
"2xl:size-[1.111vw] size-4 rounded-full aspect-square transition-colors"
)}
style={{
backgroundColor: isSelected ? color : "transparent",
boxShadow: `0 0 0 0.069vw ${color}`,
}}
/>
<p
className={clsx(
"text-s [font-family:Poppins] font-normal transition-colors",
"text-s transition-colors text-nowrap",
isSelected ? "text-[#324D43]" : "text-[#324D43]"
)}
>
@@ -47,4 +47,4 @@ function CategoryFilter({
);
}
export default CategoryFilter;
export default CategoriesFilter;
+55
View File
@@ -0,0 +1,55 @@
import { type ISurroundingPoint } from "../consts/surroundingPoints";
import CategoriesFilter from "./CategoriesFilter";
import SurroundingsIcon from "./icons/SurroundingsIcon";
import Select from "./ui/Select";
interface SurroundingsFilterProps {
selectedLocation?: string;
onSelectLocation: (location: string | undefined) => void;
selectedCategories: Set<string>;
onToggleCategory: (category: string) => void;
filteredPoints: ISurroundingPoint[];
}
function SurroundingsFilter({
selectedLocation,
onSelectLocation,
selectedCategories,
onToggleCategory,
filteredPoints,
}: SurroundingsFilterProps) {
return (
<div className="2xl:space-y-[1.111vw] space-y-4 absolute top-4 left-4 z-10 bg-[#F7F6F3] 2xl:rounded-[1.111vw] rounded-2xl 2xl:p-4 p-4 2xl:shadow-[0_0.278vw_2.778vw_0_rgba(15,16,17,0.1),0_0.139vw_0.139vw_0_rgba(0,0,0,0.06)] shadow-[0px_4px_40px_0px_rgba(15,16,17,0.1),0px_2px_2px_0px_rgba(0,0,0,0.06)] 2xl:w-[20.556vw] md:w-[296px] w-[93.333vw]">
<div className="flex 2xl:gap-[0.556vw] gap-2 items-center">
<div className="2xl:space-y-[0.139vw] space-y-0.5 flex-1 2xl:max-w-[16.667vw] md:max-w-[240px] max-w-[84.444vw]">
<p className="bg-[#F0EDE6] 2xl:px-[0.833vw] px-3 2xl:py-[0.764vw] py-[11px] 2xl:rounded-t-[0.833vw] 2xl:rounded-b-[0.139vw] rounded-b-[2px] rounded-t-xl text-s">
Baraha Town
</p>
<Select
className="2xl:!rounded-t-[0.139vw] !rounded-t-[2px]"
options={filteredPoints.map((point) => point.title)}
onChange={onSelectLocation}
placeholder="Select location on the map"
value={selectedLocation}
/>
</div>
<div className="flex flex-col 2xl:gap-[0.278vw] gap-1 items-center">
<div className="2xl:size-[0.833vw] size-3 rounded-full 2xl:ring-[0.069vw] ring ring-[#A7A08E] aspect-square 2xl:m-[0.139vw] m-0.5" />
<div className="2xl:size-[0.139vw] size-0.5 bg-[#A7A08E] rounded-full aspect-square" />
<div className="2xl:size-[0.139vw] size-0.5 bg-[#A7A08E] rounded-full aspect-square" />
<div className="2xl:size-[0.139vw] size-0.5 bg-[#A7A08E] rounded-full aspect-square" />
<div className="2xl:size-[1.111vw] size-4 text-[#F47F52]">
<SurroundingsIcon />
</div>
</div>
</div>
<hr className="border-[#E2DCCF] 2xl:border-[0.069vw] border max-md:hidden" />
<CategoriesFilter
onToggleCategory={onToggleCategory}
selectedCategories={selectedCategories}
/>
</div>
);
}
export default SurroundingsFilter;
@@ -13,10 +13,7 @@ export default function CentralPlazaAccordeonContent() {
events and activities.
</p>
<hr className="2xl:my-[1.667vw] md:my-[3.125vw] my-[6.667vw] h-[1px] bg-[#ECE8DF]" />
<div
className="2xl:subheadline-m subheadline-s text-[#324D43] font-[font-family:New_York_Large]
max-md:pb-[6.667vw]"
>
<div className="2xl:subheadline-m subheadline-s text-[#324D43] [font-family:New_York_Large] max-md:pb-[6.667vw]">
The charm of the town <br />
in one spot.
</div>
@@ -15,7 +15,7 @@ export default function ContemporaryOfficesAccordeonContent() {
one of our
</div>
<div
className="number max-md:!text-[17.778vw] 2xl:mb-[0.556vw] md:mb-[1.042vw] mb-[2.222vw] font-[font-family:New_York_Large]
className="number max-md:!text-[17.778vw] 2xl:mb-[0.556vw] md:mb-[1.042vw] mb-[2.222vw] [font-family:New_York_Large]
"
>
68
@@ -15,7 +15,7 @@ export default function CulinaryExperienceAccordeonContent() {
The culinary district will be <br /> the home of
</div>
<div
className="number max-md:!text-[17.778vw] 2xl:mb-[0.556vw] md:mb-[1.042vw] mb-[2.222vw] font-[font-family:New_York_Large]
className="number max-md:!text-[17.778vw] 2xl:mb-[0.556vw] md:mb-[1.042vw] mb-[2.222vw] [font-family:New_York_Large]
"
>
7,764
+2 -2
View File
@@ -1,8 +1,8 @@
function CarIcon() {
return (
<svg viewBox="0 0 30 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20.63 0a5.5 5.5 0 0 1 4.976 3.154L28.408 9.1a2.33 2.33 0 0 1 1.259 2.069v11.665a3.5 3.5 0 0 1-3.5 3.5h-1.834a3.5 3.5 0 0 1-3.5-3.5v-.666h-12v.666a3.5 3.5 0 0 1-3.5 3.5H3.5a3.5 3.5 0 0 1-3.5-3.5V11.168c0-.9.511-1.682 1.259-2.07l2.803-5.944A5.5 5.5 0 0 1 9.036 0zM3 22.833a.5.5 0 0 0 .5.5h1.833a.5.5 0 0 0 .5-.5v-.666h-.166a1.5 1.5 0 0 1 0-3H24a1.5 1.5 0 0 1 0 3h-.167v.666a.5.5 0 0 0 .5.5h1.834a.5.5 0 0 0 .5-.5V11.835H3zm5.167-8.666a1.5 1.5 0 0 1 0 3H6.5a1.5 1.5 0 0 1 0-3zm15 0a1.5 1.5 0 0 1 0 3H21.5a1.5 1.5 0 0 1 0-3zM9.037 3a2.5 2.5 0 0 0-2.262 1.434L4.7 8.835h20.269l-2.076-4.401A2.5 2.5 0 0 0 20.63 3z"
d="M13.278 8.26v3.888c0 .138-.056.27-.155.367a.53.53 0 0 1-.373.152h-.528a.53.53 0 0 1-.373-.152.5.5 0 0 1-.154-.367v-.518h-7.39v.518c0 .138-.055.27-.154.367a.53.53 0 0 1-.373.152H3.25a.53.53 0 0 1-.373-.152.5.5 0 0 1-.155-.367V8.26l-.656-.16a.53.53 0 0 1-.288-.184.5.5 0 0 1-.111-.32v-.373a.26.26 0 0 1 .077-.183.27.27 0 0 1 .187-.076h.99l1.128-2.956a1.04 1.04 0 0 1 .387-.489 1.06 1.06 0 0 1 .601-.184h5.926c.215 0 .425.064.601.184.177.12.312.29.387.489l1.129 2.956h.99a.26.26 0 0 1 .263.259v.374a.5.5 0 0 1-.111.319.53.53 0 0 1-.288.184zm-9.5.777v1.037c0 .138.055.27.154.367a.53.53 0 0 0 .374.152h1.712a.27.27 0 0 0 .226-.126.26.26 0 0 0 .006-.256q-.637-1.174-2.472-1.174m8.444 0q-1.834 0-2.473 1.174a.256.256 0 0 0 .103.348q.06.034.13.034h1.713c.14 0 .274-.055.373-.152a.5.5 0 0 0 .154-.367zM4.833 4.371 4.01 6.799a.51.51 0 0 0 .072.467.53.53 0 0 0 .428.216h6.98a.54.54 0 0 0 .428-.216.51.51 0 0 0 .073-.467l-.824-2.428z"
fill="currentColor"
/>
</svg>
+12
View File
@@ -0,0 +1,12 @@
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M19.047 6.033a.65.65 0 0 1 .905.934l-8.91 8.64a2.65 2.65 0 0 1-3.704-.015l-3.295-3.245a.65.65 0 0 1 .913-.925l3.294 3.244a1.35 1.35 0 0 0 1.887.007z"
fill="currentColor"
/>
</svg>
);
}
export default CheckIcon;
+12
View File
@@ -0,0 +1,12 @@
function MinusIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20 11.35a.65.65 0 0 1 0 1.3H4a.65.65 0 0 1 0-1.3z"
fill="currentColor"
/>
</svg>
);
}
export default MinusIcon;
+12
View File
@@ -0,0 +1,12 @@
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.024 2.016a.65.65 0 0 1 .65.651L8.667 7.35h4.667a.65.65 0 0 1 0 1.3H8.666l-.007 4.685a.65.65 0 0 1-1.3-.003l.006-4.682H2.667a.65.65 0 0 1 0-1.3h4.7l.006-4.684a.65.65 0 0 1 .651-.65"
fill="currentColor"
/>
</svg>
);
}
export default PlusIcon;
+12
View File
@@ -0,0 +1,12 @@
function SurroundingsIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.28 10.785c.478-.176.99.117 1.123.609.132.485-.155.981-.625 1.16-1.835.702-2.978 1.806-2.978 2.938 0 1.737 2.957 3.672 7.2 3.672s7.2-1.935 7.2-3.672c0-1.132-1.143-2.237-2.977-2.937-.47-.18-.757-.675-.625-1.16.133-.493.645-.785 1.124-.609 2.662.98 4.278 2.733 4.278 4.706C21 18.58 17.046 21 12 21s-9-2.42-9-5.508c0-1.973 1.616-3.726 4.28-4.707M12 3c1.93 0 3.5 1.614 3.5 3.6 0 1.672-1.12 3.07-2.625 3.472v4.398c0 .151-.034.301-.1.437l-.326.668a.5.5 0 0 1-.898 0l-.325-.668a1 1 0 0 1-.101-.437v-4.398C9.619 9.67 8.5 8.272 8.5 6.6 8.5 4.614 10.07 3 12 3m0 1.8c-.965 0-1.75.807-1.75 1.8 0 .992.785 1.8 1.75 1.8s1.75-.808 1.75-1.8c0-.993-.785-1.8-1.75-1.8"
fill="currentColor"
/>
</svg>
);
}
export default SurroundingsIcon;
+342 -106
View File
@@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import {
surroundingPoints,
categories,
@@ -8,8 +8,12 @@ import {
type ISurroundingPoint,
} from "../../consts/surroundingPoints";
import { usePopupStore } from "../../stores/usePopupStore";
import { AnimatePresence, motion } from "framer-motion";
import LocationPopup from "../popups/LocationPopup";
import CategoryFilter from "../ui/CategoryFilter";
import SurroundingsFilter from "../SurroundingsFilter";
import Button from "../ui/Button";
import PlusIcon from "../icons/PlusIcon";
import MinusIcon from "../icons/MinusIcon";
interface Position {
x: number;
@@ -65,28 +69,23 @@ const calculateMinZoom = (containerSize: Size, imageSize: Size): number => {
return Math.max(widthRatio, heightRatio);
};
function SurroundingsPage({ maxZoom = 1 }: MapProps) {
const [originalSize, setOriginalSize] = useState<Size>({
width: 0,
height: 0,
});
function SurroundingsPage({ maxZoom = 3 }: MapProps) {
const mapRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const containerSizeRef = useRef<Size>({ width: 0, height: 0 });
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState<Position>({ x: 0, y: 0 });
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(0);
const previousTouchDistance = useRef<number | null>(null);
const initialTouchDistance = useRef<number | null>(null);
const minZoomRef = useRef<number>(1);
const markersContainerRef = useRef<HTMLDivElement>(null);
const [hoveredPoint, setHoveredPoint] = useState<ISurroundingPoint | null>(
null
);
const [originalSize, setOriginalSize] = useState<Size>({
width: 0,
height: 0,
});
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState<Position>({ x: 0, y: 0 });
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(0);
const [selectedPoint, setSelectedPoint] = useState<ISurroundingPoint | null>(
null
);
@@ -95,6 +94,7 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(
new Set(Array.from(categories.keys()))
);
const { setPopup, setPosition: setPopupPosition, setSide } = usePopupStore();
useEffect(() => {
@@ -145,9 +145,6 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
const maxOffsetX = Math.max(0, scaledWidth - width);
const maxOffsetY = Math.max(0, scaledHeight - height);
// const desiredOffsetX = width * 0.35;
// const desiredOffsetY = height * 0.58;
const boundedOffsetX = Math.min(maxOffsetX);
const boundedOffsetY = Math.min(maxOffsetY);
@@ -331,6 +328,33 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
};
const handleWheel = (e: WheelEvent) => {
// Проверяем, находится ли событие внутри scrollable контейнера
const target = e.target as HTMLElement;
let element: HTMLElement | null = target;
while (element && element !== containerRef.current) {
const style = window.getComputedStyle(element);
const overflowY = style.overflowY || style.overflow;
// Если элемент имеет overflow: auto или scroll и может прокручиваться
if (
(overflowY === "auto" || overflowY === "scroll") &&
element.scrollHeight > element.clientHeight
) {
// Проверяем, может ли элемент прокрутиться в направлении события
const canScrollUp = element.scrollTop > 0;
const canScrollDown =
element.scrollTop < element.scrollHeight - element.clientHeight;
if ((e.deltaY < 0 && canScrollUp) || (e.deltaY > 0 && canScrollDown)) {
// Позволяем элементу прокручиваться
return;
}
}
element = element.parentElement;
}
e.preventDefault();
if (!containerRef.current || !mapRef.current) return;
@@ -418,6 +442,46 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
requestAnimationFrame(animateZoom);
}
const handleZoomIn = () => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const centerX = containerRect.left + containerRect.width / 2;
const centerY = containerRect.top + containerRect.height / 2;
const currentZoom = zoom;
const zoomStep = 0.5;
const targetZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, currentZoom + zoomStep)
);
// Если зум уже на максимуме, не делаем ничего
if (Math.abs(targetZoom - currentZoom) < 0.01) return;
smoothZoomTo(targetZoom, { x: centerX, y: centerY });
};
const handleZoomOut = () => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const centerX = containerRect.left + containerRect.width / 2;
const centerY = containerRect.top + containerRect.height / 2;
const currentZoom = zoom;
const zoomStep = 0.5;
const targetZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, currentZoom - zoomStep)
);
// Если зум уже на минимуме, не делаем ничего
if (Math.abs(targetZoom - currentZoom) < 0.01) return;
smoothZoomTo(targetZoom, { x: centerX, y: centerY });
};
const handleClick = (
e: React.MouseEvent<HTMLElement> | React.TouchEvent<HTMLElement>
) => {
@@ -451,6 +515,23 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
containerRef.current?.removeEventListener("wheel", handleWheel);
}, [isDragging, position]);
// Устанавливаем stroke-dasharray для пунктирных линий после рендера
useEffect(() => {
if (!selectedPoint || !Array.isArray(selectedPoint.path)) return;
// Находим все пунктирные пути для выбранной точки
const svg = document.querySelector('svg[viewBox="0 0 4096 2176"]');
if (!svg) return;
const dashedPaths = svg.querySelectorAll(
`path[data-dashed="true"][data-point="${selectedPoint.title}"][data-index="1"]`
);
dashedPaths.forEach((path) => {
const element = path as SVGPathElement;
element.setAttribute("stroke-dasharray", "6 6");
});
}, [selectedPoint]);
const handlePointMouseEnter = (
_e: React.MouseEvent<HTMLDivElement>,
point: ISurroundingPoint
@@ -464,41 +545,35 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
const screenX = containerRect.left + pointX;
const screenY = containerRect.top + pointY;
setHoveredPoint(point);
setPopupPosition({ x: screenX, y: screenY });
setSide("top");
setPopup(
<LocationPopup title={point.title} travelTime={point.travelTime} />
);
// Определяем сторону для попапа
const windowWidth = window.innerWidth;
if (screenX < windowWidth / 2) {
setSide("right");
} else {
setSide("left");
}
};
const handlePointMouseLeave = () => {
setHoveredPoint(null);
setPopup(null);
};
const handleToggleCategory = (category: string) => {
const newSelected = new Set(selectedCategories);
if (newSelected.has(category)) {
newSelected.delete(category);
} else {
newSelected.add(category);
}
setSelectedCategories(newSelected);
if (selectedCategories.has(category)) selectedCategories.delete(category);
else selectedCategories.add(category);
setSelectedCategories(new Set(selectedCategories));
};
const filteredPoints = surroundingPoints.filter((point) =>
selectedCategories.has(point.category)
);
// Сбрасываем выбранный поинт, если его категория отключена
useEffect(() => {
if (selectedPoint && !selectedCategories.has(selectedPoint.category)) {
setSelectedPoint(null);
}
}, [selectedCategories, selectedPoint]);
const handlePointClick = (
e: React.MouseEvent<HTMLDivElement>,
point: ISurroundingPoint
@@ -519,26 +594,6 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
};
return (
// <div className="relative h-full w-full">
// {/* Переключатель категорий */}
// <div className="absolute top-4 left-4 z-10 2xl:max-w-[296px] max-w-[296px] bg-[#F7F6F3] 2xl:rounded-2xl rounded-2xl 2xl:p-4 p-4 2xl:shadow-[0px_4px_40px_0px_rgba(15,16,17,0.1),0px_2px_2px_0px_rgba(0,0,0,0.06)] shadow-[0px_4px_40px_0px_rgba(15,16,17,0.1),0px_2px_2px_0px_rgba(0,0,0,0.06)]">
// <div className="2xl:space-y-4 space-y-4">
// <div className="2xl:space-y-2 space-y-2">
// <p className="text-s [font-family:Poppins] font-normal text-[#324D43]">
// Baraha Town
// </p>
// <p className="text-s [font-family:Poppins] font-normal text-[rgba(50,77,67,0.6)]">
// Select location on the map
// </p>
// </div>
// <hr className="border-[#E2DCCF] border-[1px]" />
// <CategoryFilter
// selectedCategories={selectedCategories}
// onToggleCategory={handleToggleCategory}
// />
// </div>
// </div>
<div
ref={containerRef}
className={clsx(
@@ -568,74 +623,239 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
onLoad={handleLoad}
/>
)}
<SurroundingsFilter
selectedLocation={selectedPoint?.title}
onSelectLocation={(location) => {
setSelectedPoint(
surroundingPoints.find((point) => point.title === location) || null
);
}}
selectedCategories={selectedCategories}
onToggleCategory={handleToggleCategory}
filteredPoints={filteredPoints}
/>
{/* SVG для путей из Figma */}
<svg
className="absolute pointer-events-none"
style={imageStyle}
width={originalSize.width}
height={originalSize.height}
viewBox="0 0 4096 2176"
>
{selectedPoint && (
<>
{Array.isArray(selectedPoint.path) ? (
// Если path - массив, отрисовываем два пути
<AnimatePresence mode="wait">
{selectedPoint &&
(Array.isArray(selectedPoint.path) ? (
// Для составных путей: сначала пунктир (index 1), потом сплошная (index 0)
selectedPoint.path.map((pathData, index) => {
const color =
categories.get(selectedPoint.category) || "#F47F52";
const isDashed = index === 1; // Второй путь - пунктирный (короткий)
const delay = isDashed ? 0 : 0.3; // Сплошная линия начинается после появления пунктира (0.3s фейд + 0.2s пауза)
// Transition для animate (с delay)
const animateTransition = {
pathLength: {
duration: 0.7,
ease: "easeInOut" as const,
delay,
},
pathOffset: {
duration: 0.7,
ease: "easeInOut" as const,
delay,
},
opacity: { duration: 0.3, delay },
};
// Transition для exit (без delay) - одинаковый для всех линий
const exitTransition = {
pathLength: {
duration: 0.7,
ease: "easeInOut" as const,
},
pathOffset: {
duration: 0.7,
ease: "easeInOut" as const,
},
opacity: { duration: 0.3 },
};
// Variants для пунктирной линии с transition
const dashedVariants = {
hidden: {
opacity: 0,
transition: {
opacity: {
duration: 0.3,
ease: "easeInOut" as const,
},
},
},
visible: {
opacity: 1,
transition: {
opacity: {
duration: 0.3,
delay,
ease: "easeInOut" as const,
},
},
},
exit: {
opacity: 0,
transition: {
opacity: {
duration: 0.3,
ease: "easeInOut" as const,
},
},
},
};
// Variants для сплошной линии с transition
const solidVariants = {
hidden: {
pathLength: 0,
pathOffset: 1,
opacity: 0,
transition: exitTransition,
},
visible: {
pathLength: 1,
pathOffset: 0,
opacity: 1,
transition: animateTransition,
},
exit: {
pathLength: 0,
pathOffset: 1,
opacity: 0,
transition: exitTransition,
},
};
return (
<path
key={`route-${selectedPoint.title}-${index}`}
d={pathData}
fill="none"
stroke={index === 0 ? "#FFFFFF" : color}
strokeWidth={index === 0 ? 5 : 3}
strokeDasharray={index === 0 ? "none" : "6 6"}
strokeLinecap="round"
className="transition-opacity"
/>
<Fragment key={`route-${selectedPoint.title}-${index}`}>
{isDashed ? (
// Для пунктирной линии используем обычный path с JavaScript анимацией
<>
<motion.path
key={`route-${selectedPoint.title}-${index}-white`}
d={pathData}
fill="none"
stroke="#FFFFFF"
strokeWidth="max(5px, 0.347vw)"
strokeLinecap="round"
strokeDasharray="6 6"
variants={dashedVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
<motion.path
key={`route-${selectedPoint.title}-${index}-color`}
d={pathData}
fill="none"
strokeWidth="max(3px, 0.208vw)"
stroke="#F47F52"
strokeLinecap="round"
strokeDasharray="6 6"
variants={dashedVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
</>
) : (
// Для сплошной линии используем pathOffset для анимации от точки к центру
<>
<motion.path
key={`route-${selectedPoint.title}-${index}-white`}
d={pathData}
fill="none"
stroke="#FFFFFF"
strokeWidth="max(5px, 0.347vw)"
strokeLinecap="round"
variants={solidVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
<motion.path
key={`route-${selectedPoint.title}-${index}-color`}
d={pathData}
fill="none"
stroke="#F47F52"
strokeWidth="max(3px, 0.208vw)"
strokeLinecap="round"
variants={solidVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
</>
)}
</Fragment>
);
})
) : (
// Если path - строка, отрисовываем один путь
// Для простых путей: анимация от точки к центру
<>
<path
<motion.path
key={`route-${selectedPoint.title}-white`}
d={selectedPoint.path}
fill="none"
stroke="#FFFFFF"
strokeWidth="5"
strokeWidth="max(5px, 0.347vw)"
strokeLinecap="round"
initial={{ pathLength: 0, pathOffset: 1, opacity: 0 }}
animate={{ pathLength: 1, pathOffset: 0, opacity: 1 }}
exit={{ pathLength: 0, pathOffset: 1, opacity: 0 }}
transition={{
pathOffset: { duration: 0.7, ease: "easeInOut" },
opacity: { duration: 0.3 },
}}
/>
<path
<motion.path
key={`route-${selectedPoint.title}-color`}
d={selectedPoint.path}
fill="none"
stroke={categories.get(selectedPoint.category) || "#F47F52"}
strokeWidth="3"
stroke="#F47F52"
strokeWidth="max(3px, 0.208vw)"
strokeLinecap="round"
initial={{ pathLength: 0, pathOffset: 1, opacity: 0 }}
animate={{ pathLength: 1, pathOffset: 0, opacity: 1 }}
exit={{ pathLength: 0, pathOffset: 1, opacity: 0 }}
transition={{
pathOffset: { duration: 0.7, ease: "easeInOut" },
opacity: { duration: 0.3 },
}}
/>
</>
)}
</>
)}
))}
</AnimatePresence>
</svg>
{/* Контейнер для точек */}
<div className="absolute" ref={markersContainerRef} style={imageStyle}>
<div className="relative h-full">
{/* Центральная точка (Baraha Town) */}
<div
className="absolute transition-all -translate-x-1/2 -translate-y-1/2"
className="absolute transition-all"
style={{
left: CENTER_POINT.x,
top: CENTER_POINT.y,
transform: `scale(${maxZoom / zoom})`,
transform: `scale(${Math.min(
1 / zoom,
1
)}) translate(-50%, -50%)`,
// translate(-${Math.min(zoom, 1) * 50}%, -${
// Math.min(zoom, 1) * 50
// }%)`,
}}
>
<div className="2xl:p-[0.37vw] p-[5.33px] bg-[#F47F52] 2xl:rounded-[0.556vw] rounded-lg shadow-[0px_4px_40px_0px_rgba(244,127,82,0.3),0px_2px_2px_0px_rgba(244,127,82,0.25)] w-max">
<div className="2xl:p-[0.37vw] p-[5.33px] bg-[#F47F52] 2xl:rounded-[0.556vw] rounded-lg shadow-[0px_4px_40px_0px_rgba(244,127,82,0.3),0px_2px_2px_0px_rgba(244,127,82,0.25)]">
<img
ref={(el) => {
if (!el) return;
el.style.minWidth = `${el.naturalWidth}px`;
el.style.maxWidth =
innerWidth >= 1440
? `${(el.naturalWidth / 3 / 1440) * 100}vw`
: `${el.naturalWidth / 3}px`;
}}
src="/img/surroundings/location.png"
className="select-none"
@@ -643,36 +863,52 @@ function SurroundingsPage({ maxZoom = 1 }: MapProps) {
/>
</div>
</div>
{/* Точки на карте */}
{filteredPoints.map((point) => {
const color = categories.get(point.category) || "#F47F52";
return (
<div
<AnimatePresence>
{filteredPoints.map((point) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease: "easeInOut" }}
key={point.title}
className="absolute cursor-pointer transition-all"
className="absolute cursor-pointer origin-center"
style={{
left: point.coordinates.x,
top: point.coordinates.y,
transform: `scale(${maxZoom / zoom})`,
transform: `scale(${Math.min(
1 / zoom,
1
)}) translate(-50%, -50%)`,
}}
onMouseEnter={(e) => handlePointMouseEnter(e, point)}
onMouseLeave={handlePointMouseLeave}
onClick={(e) => handlePointClick(e, point)}
>
<div
className="2xl:size-[1.111] size-4 aspect-square rounded-full 2xl:ring-[0.069vw] ring ring-white transition-all"
className="2xl:size-[1.111vw] size-4 aspect-square rounded-full 2xl:ring-[0.069vw] ring-1 ring-white"
style={{
backgroundColor: color,
backgroundColor:
categories.get(point.category) || "#F47F52",
}}
/>
</div>
);
})}
</motion.div>
))}
</AnimatePresence>
</div>
</div>
<div className="max-2xl:hidden space-y-[0.417vw] fixed top-1/2 -translate-y-1/2 right-[1.111vw]">
<Button variant="primary" onClick={handleZoomIn}>
<div className="2xl:size-[1.111vw] size-4">
<PlusIcon />
</div>
</Button>
<Button variant="primary" onClick={handleZoomOut}>
<div className="2xl:size-[1.111vw] size-4">
<MinusIcon />
</div>
</Button>
</div>
</div>
// </div>
);
}
+4 -6
View File
@@ -7,12 +7,10 @@ interface LocationPopupProps {
function LocationPopup({ title, travelTime }: LocationPopupProps) {
return (
<div className="2xl:space-y-[0.556vw] space-y-2">
<p className="text-s [font-family:Poppins] font-normal text-[#324D43]">
{title}
</p>
<div className="flex items-center 2xl:gap-[0.556vw] gap-2 2xl:px-[0.833vw] px-3 2xl:py-[0.556vw] py-2 bg-[#F0EDE6] rounded-xl">
<div className="2xl:size-[1.111vw] size-4 text-[#324D43]">
<div className="2xl:gap-y-[0.556vw] gap-y-2 flex flex-col items-center 2xl:px-[0.833vw] px-3 2xl:py-[0.694vw] py-2.5 2xl:rounded-[1.111vw] rounded-2xl bg-[#F7F6F3] shadow-[0px_4px_40px_0px_rgba(15,16,17,0.1),0px_2px_2px_0px_rgba(0,0,0,0.06)]">
<p className="text-s [font-family:Poppins] text-[#324D43]">{title}</p>
<div className="flex items-center 2xl:gap-[0.556vw] gap-2">
<div className="2xl:size-[1.111vw] size-4 text-[#A7A08E]">
<CarIcon />
</div>
<p className="caption font-medium text-[#A7A08E]">{travelTime} min</p>
+4 -4
View File
@@ -40,10 +40,10 @@ function PopupContainer() {
: { bottom: 0, left: 0 }
}
className={clsx(
"fixed md:absolute z-2 2xl:w-[18.472vw] md:max-2xl:w-[266px] w-dvw bg-[#F7F6F3]",
isUnitPopup
? "2xl:rounded-[0.833vw] md:max-2xl:rounded-xl 2xl:p-[0.833vw] md:max-2xl:p-3 p-4 max-md:rounded-t-2xl"
: "2xl:rounded-[1.111vw] 2xl:p-[1.111vw] p-4 md:max-2xl:rounded-2xl rounded-t-2xl",
"fixed md:absolute z-20 2xl:w-[18.472vw]md:max-2xl:w-[266px]w-dvwbg-[#F7F6F3]",
// isUnitPopup
// ? "2xl:rounded-[0.833vw] md:max-2xl:rounded-xl 2xl:p-[0.833vw] md:max-2xl:p-3 p-4 max-md:rounded-t-2xl"
// : "2xl:rounded-[1.111vw] 2xl:p-[1.111vw] p-4 md:max-2xl:rounded-2xl rounded-t-2xl",
side === "left" &&
"md:-translate-y-1/2 2xl:-translate-x-[calc(100%+1.25vw)] md:max-2xl:-translate-x-[calc(100%+18px)]",
side === "right" && "md:-translate-y-1/2 2xl:translate-x-[1.25vw]",
+102
View File
@@ -0,0 +1,102 @@
import clsx from "clsx";
import ChevronDownIcon from "../icons/ChevronDownIcon";
import { useState } from "react";
import CheckIcon from "../icons/CheckIcon";
import { useClickAway } from "@uidotdev/usehooks";
import CloseIcon from "../icons/CloseIcon";
import { AnimatePresence, motion } from "framer-motion";
interface SelectProps {
options: string[];
value?: string;
onChange: (value: string | undefined) => void;
placeholder: string;
className?: string;
}
function Select({
options,
value,
onChange,
placeholder,
className,
}: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const ref = useClickAway<HTMLDivElement>(() => setIsOpen(false));
return (
<div
ref={ref}
className={clsx(
"bg-[#F0EDE6] 2xl:p-[0.833vw] p-3 2xl:rounded-[1.111vw] rounded-2xl flex items-center 2xl:gap-[1.111vw] gap-4 relative cursor-pointer justify-between",
className
)}
onClick={() => setIsOpen(!isOpen)}
>
<p
className={clsx(
"text-s text-nowrap text-ellipsis line-clamp-1",
value ? "text-[#324D43]" : "text-[#A7A08E]"
)}
>
{value || placeholder}
</p>
{value ? (
<button
onClick={(e) => {
e.stopPropagation();
onChange(undefined);
}}
className="cursor-pointer"
>
<div className="2xl:size-[1.111vw] size-4 text-[#A7A08E]">
<CloseIcon />
</div>
</button>
) : (
<div
className={clsx(
"2xl:size-[1.111vw] size-4 text-[#A7A08E] transition-transform",
isOpen && "rotate-180"
)}
>
<ChevronDownIcon />
</div>
)}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="absolute top-[calc(100%+0.556vw)] left-0 w-full z-10 bg-[#F7F6F3] 2xl:shadow-[0_0.556vw_1.111vw_0_#2327341A] shadow-[0_8px_16px_0_#2327341A] 2xl:p-[0.278vw] p-1 2xl:rounded-[1.111vw] rounded-2xl 2xl:max-h-[15.556vw] max-h-[224px] overflow-auto"
>
{options.map((option) => (
<div
key={option}
className={clsx(
"text-s w-full 2xl:p-[0.833vw] p-3 flex justify-between items-center 2xl:gap-[0.139vw] gap-0.5 hover:bg-[#F0EDE6] hover:text-[#324D43] 2xl:rounded-[0.833vw] rounded-xl",
value === option ? "text-[#324D43]" : "text-[#A7A08E]"
)}
onClick={() => {
onChange(option);
}}
>
<p>{option}</p>
{value === option && (
<div className="2xl:size-[1.111vw] size-4 text-[#F47F52]">
<CheckIcon />
</div>
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default Select;
+2 -2
View File
@@ -63,7 +63,7 @@ export const surroundingPoints: ISurroundingPoint[] = [
{
title: "Al Khebra Driving Academy",
category: "Education",
coordinates: { x: 2002, y: 1199 },
coordinates: { x: 1996, y: 1195 },
travelTime: 5,
path: "m1869.5 1149.5 7.59-5.19c1.01-.69 2.39-.34 2.92.76 7.58 15.4 21.4 42.34 32.84 54.28.1.1.19.21.26.33 1.39 2.2 4.22 5.88 5.39 4.32 1.48-1.98 2.48-1.51-.88-5.37-.08-.08-.15-.18-.21-.28l-7.57-12.47a2 2 0 0 1 .32-2.49l29.74-28.34a1.99 1.99 0 0 1 2.41-.26l67.73 40.73a2 2 0 0 1 .22 3.27l-1.76 1.41c-.61.49-1.45.58-2.14.23l-1.86-.93",
},
@@ -119,7 +119,7 @@ export const surroundingPoints: ISurroundingPoint[] = [
{
title: "Al Bidda Park",
category: "Parks",
coordinates: { x: 2117, y: 515 },
coordinates: { x: 2111, y: 511 },
travelTime: 17,
path: "m1869.5 1149.5 7.59-5.19c1.01-.69 2.39-.34 2.92.76 7.58 15.4 21.4 42.34 32.84 54.28q.15.15.27.33c1.38 2.2 4.21 5.87 5.38 4.32 1.5-2 2.5-1.5-1-5.5s-22.5-30.5-30.5-49-22.5-34-32.5-51.5-51.5-83-53.5-87.5c-2.33-5.67-9.2-19.601-18-30.001-11-13-28-28.5-45-55.5-13.6-21.6-27.67-52.667-33-65.5-6.5-18.333-20-57.5-22-67.5-2.5-12.5-3-19-6-25.5s0-9 6-9.5c5.84-.487 178.49-15.19 200.54-16.429.9-.051 1.6-.663 1.81-1.537 4.82-20.228 14.75-64.056 18.65-89.034 5-32 11.5-76 29.5-95s60-43.5 94-54 51.5-19 58.5-21 28.5-.5 30.5 1.5c1.54 1.537 4.43 20.719 5.83 31.243.11.771-.25 1.525-.9 1.942L2117 517.5",
},