134 lines
5.7 KiB
TypeScript
134 lines
5.7 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 'maplibre-gl/dist/maplibre-gl.css'
|
|
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>
|
|
)
|
|
}
|