diff --git a/src/components/UnitFilters.tsx b/src/components/UnitFilters.tsx index 047f2da..008452a 100644 --- a/src/components/UnitFilters.tsx +++ b/src/components/UnitFilters.tsx @@ -9,7 +9,14 @@ import FiltersPopup from "./popups/FiltersPopup"; import { useSearchParams } from "react-router"; import clsx from "clsx"; -function UnitFilters({ className }: { className?: string }) { +function UnitFilters({ + className, + showHypermarketFilter, +}: { + className?: string; + /** Только для MasterplanPage */ + showHypermarketFilter?: boolean; +}) { const { selectedUnitTypes, hasActiveFilters, setFiltersFromURLParams } = useUnitFiltersStore(); const [searchParams, setSearchParams] = useSearchParams(); @@ -67,7 +74,11 @@ function UnitFilters({ className }: { className?: string }) { setSide("right"); setHasBackdrop(true); setPopup( - + ); } diff --git a/src/components/layouts/NavMenu.tsx b/src/components/layouts/NavMenu.tsx index e265561..dca91a9 100644 --- a/src/components/layouts/NavMenu.tsx +++ b/src/components/layouts/NavMenu.tsx @@ -48,13 +48,14 @@ function NavMenu() { Surroundings clsx(!isActive && "max-md:hidden")} >
- About + Home
- About + Home
("ground"); @@ -63,14 +65,33 @@ function BlockPage() { setAllowPointerEventsThrough(false); }, [floor, setPopup, setAllowPointerEventsThrough]); - const { selectedUnitTypes, hasActiveFilters, setFiltersFromURLParams } = - useUnitFiltersStore(); + const { + selectedUnitTypes, + hasActiveFilters, + setFiltersFromURLParams, + removeFilterKeys, + } = useUnitFiltersStore(); - // Инициализация фильтров из URL при монтировании + // Hypermarket только на мастерплане — при заходе на блок сбрасываем и из стора, и из ?units= useEffect(() => { - const units = searchParams.get("units"); - if (units) setFiltersFromURLParams(units.split(",")); - }, [searchParams, setFiltersFromURLParams]); + removeFilterKeys(["Hypermarket"]); + + const unitsParam = searchParams.get("units"); + const keysFromUrl = unitsParam ? unitsParam.split(",").filter(Boolean) : []; + const cleaned = keysFromUrl.filter((k) => k !== "Hypermarket"); + + if (cleaned.length !== keysFromUrl.length) { + if (cleaned.length === 0) setSearchParams({}); + else setSearchParams({ units: cleaned.join(",") }); + } + + if (unitsParam) setFiltersFromURLParams(cleaned); + }, [ + searchParams, + setFiltersFromURLParams, + setSearchParams, + removeFilterKeys, + ]); function isUnitActive(unitTypeKey: UnitTypeKey) { return UNIT_TYPE_MAPPING[unitTypeKey].some((type) => @@ -178,12 +199,25 @@ function BlockPage() { d={unit.path} fill="#fff" fillOpacity={currentUnit === index ? 1 : 0.7} - onMouseEnter={(e) => handleUnitMouseEnter(e, unit, index)} - onMouseLeave={() => { - setCurrentUnit(null); - setPopup(null); - setAllowPointerEventsThrough(false); - }} + onMouseEnter={ + isMobile + ? undefined + : (e) => handleUnitMouseEnter(e, unit, index) + } + onClick={ + isMobile + ? (e) => handleUnitMouseEnter(e, unit, index) + : undefined + } + onMouseLeave={ + isMobile + ? undefined + : () => { + setCurrentUnit(null); + setPopup(null); + setAllowPointerEventsThrough(false); + } + } className={clsx( "transition-[fill-opacity,opacity]", isUnitActive(unitTypeByLabel(unit.type)) || @@ -250,7 +284,7 @@ function BlockPage() { y={unit.labelPosition.y + 2} className="translate-y-[10px] px-1.5 py-0.5 rounded-[20px] fill-[#A7A08E] leading-[120%] text-[10px] pointer-events-none" > - {unit.type} + {unitLegendLabel(unit.type)} )} diff --git a/src/components/pages/MasterplanPage.tsx b/src/components/pages/MasterplanPage.tsx index 2fd1aa3..7b64af6 100644 --- a/src/components/pages/MasterplanPage.tsx +++ b/src/components/pages/MasterplanPage.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import Button from "../ui/Button"; import gsap from "gsap"; import ChevronLeftIcon from "../icons/ChevronLeftIcon"; @@ -12,7 +12,10 @@ import { usePopupStore } from "../../stores/usePopupStore"; import { sequencePoints } from "../../consts/blocksPoints"; import { clsx } from "clsx"; import BlockPopup from "../popups/BlockPopup"; -import { blocksStats } from "../../consts/blocksStats"; +import { + blocksStats, + FACILITY_ONLY_UNIT_TYPES, +} from "../../consts/blocksStats"; import { isMobile } from "react-device-detect"; import FacilityPopup from "../popups/FacilitiesPopup"; import { facilities } from "../../consts/facilities"; @@ -125,6 +128,9 @@ function calculateCenterPosition( const FRAME_COUNT = 120; const FRAME_STEP = 30; +/** После закрытия попапа на тач-устройствах приходит синтетический click по маске под попапом — без подавления снова включается подсветка блока. */ +const MOBILE_BLOCK_CLICK_SUPPRESS_MS = 450; + function MasterplanPage() { const [currentIndex, setCurrentIndex] = useState(0); const [isAnimating, setIsAnimating] = useState(false); @@ -135,6 +141,8 @@ function MasterplanPage() { const directionRef = useRef<"next" | "prev">("next"); const loadedImagesRef = useRef>(new Set()); + const blockClickSuppressUntilRef = useRef(0); + const prevPopupRef = useRef(null); const { setModal } = useModalStore(); @@ -146,6 +154,12 @@ function MasterplanPage() { function blockHasSelectedUnits(blockIndex: number) { if (!hasActiveFilters()) return true; // Если фильтры не активны, показываем все блоки + const hasBlockUnitFilter = [...selectedUnitTypes].some( + (t) => !FACILITY_ONLY_UNIT_TYPES.has(t) + ); + // Только инфраструктурные фильтры (напр. Hypermarket) — маски блоков не скрываем + if (!hasBlockUnitFilter) return true; + const blockStats = blocksStats[blockIndex]; for (const [unitType] of blockStats.entries()) if (selectedUnitTypes.has(unitType)) return true; @@ -153,6 +167,11 @@ function MasterplanPage() { return false; } + /** Индекс 0 в facilitiesPoints соответствует Hypermarket в facilities */ + function isFacilityFilterHighlight(index: number) { + return index === 0 && selectedUnitTypes.has("Hypermarket"); + } + function handleImageLoad(index: number) { if (loadedImagesRef.current.has(index)) return; loadedImagesRef.current.add(index); @@ -671,8 +690,15 @@ function MasterplanPage() { }; }, [isDragging, startPosition, position]); - useEffect(() => { - if (!popup && isMobile) setHoveredMaskIndex(null); + useLayoutEffect(() => { + const hadPopup = prevPopupRef.current != null; + prevPopupRef.current = popup; + + if (hadPopup && !popup && isMobile) { + setHoveredMaskIndex(null); + blockClickSuppressUntilRef.current = + Date.now() + MOBILE_BLOCK_CLICK_SUPPRESS_MS; + } }, [popup, isMobile]); useEffect(() => { @@ -692,7 +718,7 @@ function MasterplanPage() { setHoveredMaskIndex(index); setSide("right"); setAlign("center"); - setAllowPointerEventsThrough(true); + setAllowPointerEventsThrough(!isMobile); setParentBoundingClientRect(e.currentTarget.getBoundingClientRect()); setPopup( ); + } + function showFacilityPopup( index: number, e: React.MouseEvent | React.TouchEvent @@ -780,14 +819,7 @@ function MasterplanPage() { caption={facilities[index].caption} description={facilities[index].description} showVideoButton={isLastPoint} - onPlayVideo={ - isLastPoint - ? () => { - setPopup(null); - setModal(); - } - : undefined - } + onPlayVideo={isLastPoint ? openFacilityVideoModal : undefined} /> ); } @@ -838,22 +870,6 @@ function MasterplanPage() { draggable={false} /> ))} - {/* - {isShowVideo && ( - = 768 ? "desktop" : "mobile" - }/${currentFrame}.jpg`} - alt="" - className="object-cover absolute inset-0 w-full h-full" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - draggable={false} - /> - )} - */} {/* SVG layer for masks and interactive elements */} @@ -915,7 +931,6 @@ function MasterplanPage() { exit={{ opacity: 0 }} key={`${currentFrame}-${index}`} style={{ - // display: !blockHasSelectedUnits(index) ? "none" : "block", scale: 1 / zoom, transformOrigin: `${ x - Math.max(0.00694 * innerWidth, 10) / 2 @@ -934,10 +949,7 @@ function MasterplanPage() { : "rgba(50, 77, 67, 0.15)" } strokeWidth={2} - className={clsx( - "cursor-pointer transition-[fill,stroke] duration-300" - // !blockHasSelectedUnits(index) && "hidden" - )} + className="cursor-pointer transition-[fill,stroke] duration-300" onMouseEnter={(e) => { if (index > 15) handleBlockMouseEnter(e, index); }} @@ -976,8 +988,9 @@ function MasterplanPage() { {isShowVideo && hoveredMaskIndex === null && - facilitiesPoints[currentFrame].map( - ({ x, y, icon }, index, { length }) => ( + facilitiesPoints[currentFrame].map(({ x, y, icon }, index) => { + const facilityHighlight = isFacilityFilterHighlight(index); + return ( { - if (!isMobile) + if (!isMobile && index !== facilities.length - 1) showFacilityPopup( index, e as unknown as React.MouseEvent ); }} onMouseLeave={ - index === length - 1 ? undefined : handleFacilityLeave + index === facilities.length - 1 + ? undefined + : handleFacilityLeave } onClick={(e) => { + if (index === facilities.length - 1) { + openFacilityVideoModal(); + return; + } if (isMobile) showFacilityPopup( index, @@ -1026,11 +1046,19 @@ function MasterplanPage() { height={Math.max(0.01111 * innerWidth, 16)} style={{ pointerEvents: "none" }} > -
{icon}
+
+ {icon} +
- ) - )} + ); + })}
@@ -1062,7 +1090,7 @@ function MasterplanPage() { className="absolute 2xl:bottom-[1.111vw] bottom-4 2xl:left-[1.111vw] left-4" /> - + )}
+ [...selectedUnits].some((t) => !FACILITY_ONLY_UNIT_TYPES.has(t)); + + // Если для блока фильтров нет — все лейблы и счётчики оранжевые (в т.ч. на мобилке при только Hypermarket на мастерплане). const isUnitHighlighted = (unitType: UnitType): boolean => { - if (!hasActiveFilters()) return true; // Если фильтры не активны, все юниты оранжевые - return selectedUnits.has(unitType); // Иначе только выбранные + if (!hasActiveBlockUnitFilters()) return true; + return selectedUnits.has(unitType); }; return ( diff --git a/src/components/popups/FacilitiesPopup.tsx b/src/components/popups/FacilitiesPopup.tsx index 51dfc71..5632442 100644 --- a/src/components/popups/FacilitiesPopup.tsx +++ b/src/components/popups/FacilitiesPopup.tsx @@ -7,7 +7,7 @@ import { isMobile } from "react-device-detect"; export interface FacilitiesPopupProps { title: string; - caption: string; + caption?: string; description: string; className?: string; showVideoButton?: boolean; @@ -37,17 +37,19 @@ function FacilityPopup({ setPopup(null); setHasBackdrop(false); }} - className="absolute top-1 right-1 max-md:hidden" + className="max-md:hidden absolute top-1 right-1" >
)} -
+

{title}

-

{caption}

+ {caption && ( +

{caption}

+ )}

diff --git a/src/components/popups/FiltersPopup.tsx b/src/components/popups/FiltersPopup.tsx index cf9aad0..a154b87 100644 --- a/src/components/popups/FiltersPopup.tsx +++ b/src/components/popups/FiltersPopup.tsx @@ -10,9 +10,15 @@ import { useClickAway } from "@uidotdev/usehooks"; interface FiltersPopupProps { onUpdateURL: () => void; onResetURL: () => void; + /** Только на MasterplanPage — фильтр точки Hypermarket на карте */ + showHypermarketFilter?: boolean; } -function FiltersPopup({ onUpdateURL, onResetURL }: FiltersPopupProps) { +function FiltersPopup({ + onUpdateURL, + onResetURL, + showHypermarketFilter = false, +}: FiltersPopupProps) { const { parentBoundingClientRect, setPopup, setHasBackdrop } = usePopupStore(); @@ -134,8 +140,20 @@ function FiltersPopup({ onUpdateURL, onResetURL }: FiltersPopupProps) { isFilterActive("Healthcare") && "!bg-[#F47F52] !text-white" )} > - Healthcare + Health & Wellness + {showHypermarketFilter && ( + + )}

diff --git a/src/components/ui/FullscreenButton.tsx b/src/components/ui/FullscreenButton.tsx index d2dfd69..a0182ba 100644 --- a/src/components/ui/FullscreenButton.tsx +++ b/src/components/ui/FullscreenButton.tsx @@ -6,7 +6,9 @@ import FullscreenIcon from "../icons/FullscreenIcon"; import clsx from "clsx"; function FullscreenButton({ className }: { className?: string }) { - const [isFullScreen, setIsFullScreen] = useState(false); + const [isFullScreen, setIsFullScreen] = useState(() => + typeof document !== "undefined" ? !!document.fullscreenElement : false + ); async function handleFullScreenClick() { try { @@ -26,23 +28,22 @@ function FullscreenButton({ className }: { className?: string }) { } useEffect(() => { - const handleFullScreenChange = () => { - setIsFullScreen(!!document.fullscreenElement); - }; + const sync = () => setIsFullScreen(!!document.fullscreenElement); - document.addEventListener("fullscreenchange", handleFullScreenChange); + sync(); + document.addEventListener("fullscreenchange", sync); return () => { - document.removeEventListener("fullscreenchange", handleFullScreenChange); + document.removeEventListener("fullscreenchange", sync); }; - }, [handleFullScreenClick]); + }, []); return (