Files
aramesh-backend/frontend/src/map/MapViewport.tsx
T

133 lines
5.6 KiB
TypeScript

import * as maplibregl from 'maplibre-gl'
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
import { Loader2Icon } from 'lucide-react'
import { MdClose, MdFullscreen } from 'react-icons/md'
import { useLang } from '../i18n/lang'
import type { MapStatus } from './types'
type MapViewportProps = {
containerRef: MutableRefObject<HTMLDivElement | null>
/** نمونه‌ی نقشه — فقط برای هم‌گام‌سازی اندازه؛ مالکیتش با useMapLibre است */
map: maplibregl.Map | null
status: MapStatus
onRetry: () => void
/** برچسب دسترس‌پذیری ناحیه‌ی نقشه */
label: string
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */
className?: string
/** محتوای شناور روی نقشه (پورتال پاپ‌آپ) */
children?: ReactNode
}
/**
* پوسته‌ی نقشه — قاب شیشه‌ای، حالت تمام‌صفحه، کنترل بزرگ‌نمایی و لایه‌های
* بارگذاری/خطا.
*
* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد می‌نشیند (نه absolute)؛
* استایل خود مپ‌لیبر بعد از تیلویند بار می‌شود و position: relative می‌دهد،
* پس کلاس absolute بی‌اثر می‌شد و ظرف صفر می‌ماند.
*
* در تمام‌صفحه فقط شفافیت و گوشه‌ها انیمیت می‌شوند تا بافر بوم هرگز کشیده
* و تار نشود؛ تغییر اندازه در همان فریم اعمال می‌شود.
*/
export function MapViewport({
containerRef,
map,
status,
onRetry,
label,
className = '',
children,
}: MapViewportProps) {
const { t } = useLang()
const [isFullscreen, setIsFullscreen] = useState(false)
/** تمام‌صفحه: Esc برای خروج و قفل اسکرول پس‌زمینه */
useEffect(() => {
if (!isFullscreen) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false)
}
window.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
}
}, [isFullscreen])
/** پس از پایان انتقال، بوم را هم‌گام کن (مپ‌لیبر خودش هم ResizeObserver دارد) */
useEffect(() => {
if (!isFullscreen) return
const timer = window.setTimeout(() => map?.resize(), 340)
return () => window.clearTimeout(timer)
}, [isFullscreen, map])
const shellClass = isFullscreen
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
return (
<div
dir="rtl"
aria-label={label}
className={`overflow-hidden bg-char font-pinar transition-all duration-300 ease-lux ${shellClass}`}
>
{/* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد است؛ کلاس relative صریح
است تا هم بستر موقعیت‌یابی فرزندان مپ‌لیبر باشد و هم هشدار
«container has a non-static position» و محاسبه‌ی اشتباه آفست اسکرول
رخ ندهد — ترتیب بار شدن CSS هیچ‌وقت تعیین‌کننده نباشد */}
<div ref={containerRef} className="relative h-full w-full" />
{isFullscreen ? (
<button
type="button"
onClick={() => setIsFullscreen(false)}
aria-label={t('mapExitFullscreen')}
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
>
<MdClose className="h-4 w-4" aria-hidden="true" />
{t('mapExitFullscreen')}
</button>
) : (
<button
type="button"
onClick={() => setIsFullscreen(true)}
aria-label={t('mapFullscreen')}
title={t('mapFullscreen')}
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
</button>
)}
{status !== 'ready' && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm">
{status === 'loading' ? (
<>
<Loader2Icon className="h-7 w-7 animate-spin text-copper" aria-hidden="true" />
<p className="text-xs font-light tracking-wide text-ivory/60">{t('mapLoading')}</p>
</>
) : (
<>
<p className="max-w-xs text-center text-xs font-light leading-6 text-ivory/60">
{t('mapErrorText')}
</p>
<button
type="button"
onClick={onRetry}
className="border border-copper/50 px-6 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
{t('retry')}
</button>
</>
)}
</div>
)}
{children}
</div>
)
}