feat(ui): update UI v1
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import type { ProjectsMapAllProps } from './ProjectsMapAll'
|
||||
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
|
||||
|
||||
/**
|
||||
* بارگیری تنبل نقشهها — بستهی سنگین مپلیبر فقط وقتی دانلود میشود که
|
||||
* واقعاً نقشهای روی صفحه باشد؛ صفحههای بدون نقشه (مثل خانه) جریمهی حجم
|
||||
* نمیپردازند. Import زیر فقط نوع است و باندل را سنگین نمیکند.
|
||||
*/
|
||||
const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({ default: module.ProjectsMapAll })))
|
||||
const ProjectsMapSingle = lazy(() =>
|
||||
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
|
||||
)
|
||||
|
||||
/** جاینگهدار هماندازهی نقشه تا چیدمان هنگام بارگیری نلرزد */
|
||||
function MapSkeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
|
||||
}
|
||||
|
||||
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
|
||||
return (
|
||||
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||
<ProjectsMapAll {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
|
||||
return (
|
||||
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||
<ProjectsMapSingle {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, type MouseEvent, type PointerEvent } from 'react'
|
||||
import { MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||
import { ProjectImagePlaceholder } from '../components/ProjectImagePlaceholder'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
type ProjectImageSliderProps = {
|
||||
/** همان آرایهٔ تصاویر صفحهٔ جزئیات پروژه — در نبودش جاینگهدار میآید */
|
||||
images: string[]
|
||||
alt: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* اسلایدر تصویر لبهتالبهٔ بالای کارت پاپآپ (سبک Zillow): فلشهای چپ/راست،
|
||||
* شمارندهٔ اسلاید و گوشههای گرد هماندازهٔ کارت. همهٔ رویدادهای اشاره روی
|
||||
* اسلایدر متوقف میشوند تا کلیک/کشیدن آن، نقشه را جابهجا یا پاپآپ را نبندد.
|
||||
*/
|
||||
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) {
|
||||
const { t } = useLang()
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
/** هیچ رویدادی از اسلایدر به نقشهٔ زیرین نمیرسد */
|
||||
const stop = (event: MouseEvent | PointerEvent) => event.stopPropagation()
|
||||
|
||||
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
|
||||
|
||||
if (!images.length) {
|
||||
return (
|
||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||
<ProjectImagePlaceholder />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const current = Math.min(index, images.length - 1)
|
||||
const step = (delta: number) => setIndex((value) => (value + delta + images.length) % images.length)
|
||||
|
||||
const arrow =
|
||||
'absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/85 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char'
|
||||
|
||||
return (
|
||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('prevImage')}
|
||||
title={t('prevImage')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className={`${arrow} left-2`}
|
||||
>
|
||||
<MdChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nextImage')}
|
||||
title={t('nextImage')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className={`${arrow} right-2`}
|
||||
>
|
||||
<MdChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<span className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-ink/75 px-2.5 py-0.5 text-[10px] font-light text-ivory/85 backdrop-blur-md">
|
||||
{toPersianDigits(current + 1)} / {toPersianDigits(images.length)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MdLocationOn } from 'react-icons/md'
|
||||
|
||||
type ProjectPinProps = {
|
||||
label: string
|
||||
/** پروژهی انتخابشده — هالهی مسی پایدار */
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* سنجاق پروژه روی نقشه — آیکون MdLocationOn در رنگ مسی برند با هالهی
|
||||
* نورانی در هاور، کلیک و حالت انتخابشده. این کامپوننت داخل المان نشانگر
|
||||
* مپلیبر رندر میشود، پس خودش هیچ چیدمان بیرونی ندارد.
|
||||
*/
|
||||
export function ProjectPin({ label, active = false, onClick }: ProjectPinProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className="group flex cursor-pointer items-end justify-center bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
>
|
||||
<MdLocationOn
|
||||
className={`h-9 w-9 text-[#d5874c] transition-all duration-300 ease-lux group-hover:scale-125 group-hover:drop-shadow-[0_0_14px_rgba(213,135,76,0.95)] group-active:scale-110 group-active:drop-shadow-[0_0_18px_rgba(213,135,76,1)] ${
|
||||
active
|
||||
? 'scale-110 drop-shadow-[0_0_16px_rgba(213,135,76,0.95)]'
|
||||
: 'drop-shadow-[0_3px_6px_rgba(0,0,0,0.65)]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { MdClose } from 'react-icons/md'
|
||||
import type { MapProject } from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { ProjectImageSlider } from './ProjectImageSlider'
|
||||
|
||||
/** دکمههای کارت — تمامعرض، همخانوادهٔ دکمههای مسی سایت (در ستون هم درست مینشینند) */
|
||||
export const POPUP_PRIMARY_CLASS =
|
||||
'flex w-full items-center justify-center gap-1.5 rounded-xl bg-copper px-3 py-2.5 text-[12px] font-bold text-char transition-all duration-300 ease-lux hover:scale-[1.03] hover:shadow-[0_8px_22px_rgba(213,135,76,0.35)] active:scale-95'
|
||||
|
||||
export const POPUP_SECONDARY_CLASS =
|
||||
'flex w-full items-center justify-center gap-1.5 rounded-xl border border-ivory/20 px-3 py-2.5 text-[12px] font-light text-ivory/80 transition-colors duration-300 ease-lux hover:border-copper hover:text-copper'
|
||||
|
||||
type ProjectPopupCardProps = {
|
||||
project: MapProject
|
||||
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جاینگهدار میآید */
|
||||
images?: string[]
|
||||
onClose: () => void
|
||||
/** دکمههای پایهٔ کارت — هر نما اکشنهای خودش را میدهد (عمودی روی هم) */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* کارت پاپآپ پروژه به سبک Zillow — اسلایدر لبهتالبه در بالاترین بخش، سپس
|
||||
* عنوان و توضیح، و در پایه دکمههای عمودی. راستبهچپ، شیشهای تیره و
|
||||
* هماندازهٔ کارتهای سایت. سال پروژه عمداً نمایش داده نمیشود.
|
||||
*/
|
||||
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) {
|
||||
const { t } = useLang()
|
||||
const description = project.description.trim() || t('mapProjectDescription')
|
||||
|
||||
return (
|
||||
<article
|
||||
dir="rtl"
|
||||
className="w-[17rem] max-w-[calc(100vw-2.5rem)] animate-[luxPopupIn_0.22s_ease-out] overflow-hidden rounded-2xl border border-ivory/10 bg-[#27221c]/95 font-pinar text-ivory shadow-[0_24px_60px_-15px_rgba(0,0,0,0.9)] backdrop-blur-xl sm:w-72"
|
||||
>
|
||||
{/* اسلایدر تصویر: لبهتالبه، بدون حاشیه، با فلشهای چپ/راست */}
|
||||
<div className="relative">
|
||||
<ProjectImageSlider images={images} alt={project.name} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
className="absolute left-2.5 top-2.5 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char"
|
||||
>
|
||||
<MdClose className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{project.isNew && (
|
||||
<span className="absolute right-2.5 top-2.5 z-20 rounded-full bg-copper px-2.5 py-1 text-[10px] font-bold text-char shadow-[0_4px_14px_rgba(213,135,76,0.4)]">
|
||||
{t('mapNewBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1 pt-3.5">
|
||||
<h3 className="min-w-0 truncate text-base font-bold text-ivory">{project.name}</h3>
|
||||
<p className="mt-1.5 line-clamp-2 text-[11px] font-light leading-6 text-ivory/60">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 pt-2.5">{children}</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
|
||||
import {
|
||||
ISFAHAN_CENTER,
|
||||
MAP_PROJECTS,
|
||||
boundsForProjects,
|
||||
buildNeshanUrl,
|
||||
type MapProject,
|
||||
} from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { MapViewport } from './MapViewport'
|
||||
import { POPUP_PRIMARY_CLASS, POPUP_SECONDARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||
import { useMapLibre } from './useMapLibre'
|
||||
import { useProjectMarkers } from './useProjectMarkers'
|
||||
|
||||
export type ProjectsMapAllProps = {
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-[520px]» */
|
||||
className?: string
|
||||
/** فهرست پروژهها — پیشفرض همهی ردیفهای شیت */
|
||||
projects?: MapProject[]
|
||||
/**
|
||||
* نشانی صفحهی جزئیات هر پروژه. اگر تطبیقی پیدا نشود، دکمهی «مشاهده پروژه»
|
||||
* به فهرست پروژهها میرود تا هیچ لینکی بیمقصد نماند.
|
||||
*/
|
||||
resolveProjectHref?: (project: MapProject) => string | undefined
|
||||
/** آرایهٔ تصاویر هر پروژه (همان آرایهٔ صفحهٔ جزئیات) — در نبودش جاینگهدار */
|
||||
imagesFor?: (project: MapProject) => string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* نمای «همهی پروژهها» — همهی مختصات شیت روی یک نقشهی تمامعرض با
|
||||
* سنجاقهای مسی، پاپآپ کارتی و دو اکشن عمودی: صفحهی پروژه و آدرس نشان.
|
||||
* پاپآپ فقط با کلیک کاربر باز میشود و جابهجایی نقشه به محدودهٔ پروژهها
|
||||
* بسته است.
|
||||
*/
|
||||
export function ProjectsMapAll({
|
||||
className = '',
|
||||
projects = MAP_PROJECTS,
|
||||
resolveProjectHref,
|
||||
imagesFor,
|
||||
}: ProjectsMapAllProps) {
|
||||
const { t, href } = useLang()
|
||||
const bounds = useMemo(() => boundsForProjects(projects), [projects])
|
||||
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds })
|
||||
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||
|
||||
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
|
||||
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
|
||||
|
||||
return (
|
||||
<MapViewport
|
||||
containerRef={containerRef}
|
||||
map={map}
|
||||
status={status}
|
||||
onRetry={retry}
|
||||
label={t('mapAria')}
|
||||
className={className}
|
||||
>
|
||||
{popupHost &&
|
||||
activeProject &&
|
||||
createPortal(
|
||||
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
||||
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
|
||||
{t('viewProject')}
|
||||
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{/* منطق دکمهٔ آدرس دستنخورده — فقط در ستون عمودی نشسته است */}
|
||||
<a
|
||||
href={buildNeshanUrl(activeProject)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={POPUP_SECONDARY_CLASS}
|
||||
>
|
||||
{t('viewAddress')}
|
||||
<MdOpenInNew className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
</ProjectPopupCard>,
|
||||
popupHost,
|
||||
)}
|
||||
</MapViewport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MdOpenInNew } from 'react-icons/md'
|
||||
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { MapViewport } from './MapViewport'
|
||||
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||
import { useMapLibre } from './useMapLibre'
|
||||
import { useProjectMarkers } from './useProjectMarkers'
|
||||
|
||||
export type ProjectsMapSingleProps = {
|
||||
/** همان پروژهی شیت — یا محل جایگزین ساختهشده با createPlaceholderProject */
|
||||
location: MapProject
|
||||
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات (تصویر شاخص + گالری) */
|
||||
images?: string[]
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-80» */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* نمای «تکپروژه» — یک سنجاق دقیق روی مختصات همان پروژه و پاپآپی که فقط با
|
||||
* کلیک کاربر باز میشود. تنها اکشن کارت، مشاهدهٔ آدرس پروژه در نشان است.
|
||||
* جابهجایی نقشه به محدودهٔ همین پروژه بسته شده تا به بیرون نرود.
|
||||
*/
|
||||
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
|
||||
const { t } = useLang()
|
||||
const projects = useMemo(() => [location], [location])
|
||||
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects])
|
||||
const { containerRef, map, status, retry } = useMapLibre({
|
||||
center: [location.lng, location.lat],
|
||||
zoom: 15,
|
||||
maxBounds: bounds,
|
||||
})
|
||||
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||
|
||||
return (
|
||||
<MapViewport
|
||||
containerRef={containerRef}
|
||||
map={map}
|
||||
status={status}
|
||||
onRetry={retry}
|
||||
label={t('mapAria')}
|
||||
className={className}
|
||||
>
|
||||
{popupHost &&
|
||||
activeProject &&
|
||||
createPortal(
|
||||
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
||||
{/* منطق دکمهٔ آدرس دستنخورده است — تنها اکشن این کارت */}
|
||||
<a
|
||||
href={buildNeshanUrl(activeProject)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={POPUP_PRIMARY_CLASS}
|
||||
>
|
||||
{t('viewAddress')}
|
||||
<MdOpenInNew className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</ProjectPopupCard>,
|
||||
popupHost,
|
||||
)}
|
||||
</MapViewport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { Protocol } from 'pmtiles'
|
||||
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
||||
|
||||
let setup: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* آمادهسازی وبورکر نقشه — یکبار برای همه نقشهها.
|
||||
*
|
||||
* ۱) نشانی وبورکر مپلیبر صریح تعیین میشود: مپلیبر بهصورت پیشفرض
|
||||
* «./maplibre-gl-worker.mjs» را کنار باندل جستوجو میکند که در خروجی ویت
|
||||
* وجود ندارد و ورکر ۴۰۴ میشود؛ نسخهی ساختهشدهی ویت را با setWorkerUrl
|
||||
* میدهیم (نشانی مطلق، چون پایهی ورکر میتواند Blob باشد).
|
||||
* ۲) پروتکل pmtiles در نخ اصلی ثبت میشود.
|
||||
* ۳) همان پروتکل با importScriptInWorkers داخل وبورکر هم ثبت میشود —
|
||||
* کاشیهای برداری آنجا بارگیری میشوند و رجیستری پروتکل در هر کانتکست
|
||||
* جداست. اسکریپت وبورکر، خروجی خودبسندهی public/map/pmtiles-worker.mjs
|
||||
* است (ساختهشده از src/map/pmtiles.worker.ts با npm run build:map-worker).
|
||||
*
|
||||
* تا پایان بارگیری اسکریپت صبر میکنیم تا نخستین درخواستهای کاشی در وبورکر
|
||||
* بیپاسخ نمانند.
|
||||
*/
|
||||
export function ensureMapWorker(): Promise<void> {
|
||||
if (!setup) {
|
||||
maplibregl.setWorkerUrl(new URL(maplibreWorkerUrl, window.location.href).href)
|
||||
maplibregl.addProtocol('pmtiles', new Protocol().tile)
|
||||
|
||||
const protocolScript = new URL(
|
||||
`${import.meta.env.BASE_URL}map/pmtiles-worker.mjs`,
|
||||
window.location.href,
|
||||
).href
|
||||
setup = maplibregl.importScriptInWorkers(protocolScript)
|
||||
}
|
||||
return setup
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Protocol } from 'pmtiles'
|
||||
|
||||
/**
|
||||
* اسکریپت سمت وبورکر مپلیبر — با importScriptInWorkers داخل وبورکر اجرا
|
||||
* میشود و مپلیبر تابع addProtocol را روی self وبورکر گذاشته است.
|
||||
*
|
||||
* کاشیهای برداری در وبورکر بارگیری میشوند و رجیستری پروتکل در هر کانتکست
|
||||
* جداست؛ بدون این ثبت، درخواستهای pmtiles در وبورکر بیپاسخ میمانند و
|
||||
* نقشه فقط پسزمینه را نشان میدهد.
|
||||
*/
|
||||
const workerScope = self as unknown as {
|
||||
addProtocol: (name: string, loadFn: unknown) => void
|
||||
}
|
||||
|
||||
workerScope.addProtocol('pmtiles', new Protocol().tile)
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SourceSpecification, StyleSpecification } from 'maplibre-gl'
|
||||
import { MAP_BASE_URL } from '../config'
|
||||
|
||||
const PMTILES_SCHEME = 'pmtiles://'
|
||||
/** نشانیهای مطلق — دستنخورده میمانند */
|
||||
const EXTERNAL = /^(https?:|data:|blob:)/i
|
||||
/** تنها آرشیو برداری باکت — منبع برداری سبک همیشه به همین جا میرسد */
|
||||
const PMTILES_ARCHIVE = 'iran.pmtiles'
|
||||
/** نام منبع برداری در سبکهای استاندارد OpenMapTiles */
|
||||
const OPENMAPTILES_KEY = 'openmaptiles'
|
||||
/** پوشه فونتهای گلیف در باکت MinIO — سبک خام فقط «{fontstack}/{range}.pbf» دارد */
|
||||
const GLYPHS_FOLDER = 'v2.0'
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
/** نشانی آرشیو برداری روی MinIO — تنها منبع کاشی این پروژه */
|
||||
function archiveUrl(base: string): string {
|
||||
return `${PMTILES_SCHEME}${joinUrl(base, PMTILES_ARCHIVE)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* جایگذاری جانگهدارهای سبک — «{styleJsonFolder}» به آدرس پایه MinIO تبدیل
|
||||
* میشود. جانگهدارهای {fontstack} و {range} قانونی خود مپلیبرند و دستنخورده
|
||||
* میمانند. جانگهدار تازهای در سبکهای بعدی دیده شد، به همین نگاشت اضافه شود.
|
||||
*/
|
||||
function resolvePlaceholders(url: string, base: string): string {
|
||||
return url.replace(/\{styleJsonFolder\}/g, base)
|
||||
}
|
||||
|
||||
/** مطلقسازی نشانیهای عمومی (گلیف، اسپرایت، منابع غیربرداری) */
|
||||
function absolutize(resource: string, base: string): string {
|
||||
const url = resolvePlaceholders(resource.trim(), base)
|
||||
if (EXTERNAL.test(url)) return url
|
||||
|
||||
if (url.startsWith(PMTILES_SCHEME)) {
|
||||
const target = url.slice(PMTILES_SCHEME.length).trim()
|
||||
return EXTERNAL.test(target) ? url : PMTILES_SCHEME + joinUrl(base, target)
|
||||
}
|
||||
|
||||
return joinUrl(base, url)
|
||||
}
|
||||
|
||||
/**
|
||||
* نشانی گلیفها — سبک خام فقط الگوی «{fontstack}/{range}.pbf» را دارد و پوشه
|
||||
* فونتها را نمیآورد، اما فونتهای باکت زیر پوشه v2.0 هستند؛ پس الگو همیشه
|
||||
* به همان پوشه میرسد و جانگهدارهای {fontstack} و {range} دستنخورده میمانند.
|
||||
*/
|
||||
function resolveGlyphsUrl(url: string, base: string): string {
|
||||
const resolved = resolvePlaceholders(url.trim(), base)
|
||||
if (EXTERNAL.test(resolved)) return resolved
|
||||
|
||||
if (resolved.includes('{fontstack}')) {
|
||||
const template = resolved.slice(resolved.indexOf('{fontstack}'))
|
||||
return joinUrl(base, joinUrl(GLYPHS_FOLDER, template))
|
||||
}
|
||||
|
||||
return joinUrl(base, resolved)
|
||||
}
|
||||
|
||||
/** منبع برداری سبک — با نوع vector یا نام شناختهشده openmaptiles */
|
||||
function isVectorSource(key: string, source: SourceSpecification): boolean {
|
||||
return source.type === 'vector' || key === OPENMAPTILES_KEY
|
||||
}
|
||||
|
||||
/**
|
||||
* بازنویسی نشانی منبع برداری. سبکهای ساختهشده برای سرور MBTiles (مثل
|
||||
* «mbtiles://{v3}» در tileserver-gl) کاشی برداری را با طرح mbtiles آدرس
|
||||
* میدهند که مپلیبر نمیشناسد؛ هر شکلی از نشانی اینجا به آرشیو iran.pmtiles
|
||||
* روی MinIO هدایت میشود تا با پروتکل pmtiles رندر شود.
|
||||
*/
|
||||
function resolveVectorUrl(url: string, base: string): string {
|
||||
const resolved = resolvePlaceholders(url.trim(), base)
|
||||
|
||||
if (resolved.startsWith(PMTILES_SCHEME)) {
|
||||
const target = resolved.slice(PMTILES_SCHEME.length).trim()
|
||||
return EXTERNAL.test(target) ? resolved : PMTILES_SCHEME + joinUrl(base, target)
|
||||
}
|
||||
|
||||
return archiveUrl(base)
|
||||
}
|
||||
|
||||
/**
|
||||
* پالایش سبک نقشه پیش از تحویل به مپلیبر — style.json خام MinIO با
|
||||
* جانگهدار ({styleJsonFolder}) و نشانی MBTiles نوشته شده است؛ این تابع با یک
|
||||
* پاس، گلیف/اسپرایت را به آدرس مطلق MinIO و منبع برداری را به آرشیو
|
||||
* iran.pmtiles تبدیل میکند تا مستقیم به «new Map({ style })» داده شود.
|
||||
*/
|
||||
export function rewriteStyleUrls(
|
||||
style: StyleSpecification,
|
||||
base: string = MAP_BASE_URL,
|
||||
): StyleSpecification {
|
||||
const next: StyleSpecification = { ...style }
|
||||
|
||||
if (typeof next.glyphs === 'string') {
|
||||
next.glyphs = resolveGlyphsUrl(next.glyphs, base)
|
||||
}
|
||||
|
||||
// اسپرایت در نسخه ۸ مشخصات سبک، رشته یا فهرست {id, url} است
|
||||
if (typeof next.sprite === 'string') {
|
||||
next.sprite = absolutize(next.sprite, base)
|
||||
} else if (Array.isArray(next.sprite)) {
|
||||
next.sprite = next.sprite.map((sprite) => ({ ...sprite, url: absolutize(sprite.url, base) }))
|
||||
}
|
||||
|
||||
const sources: StyleSpecification['sources'] = {}
|
||||
for (const [key, source] of Object.entries(next.sources ?? {})) {
|
||||
const rewritten = { ...source } as SourceSpecification & { url?: unknown; tiles?: unknown }
|
||||
|
||||
if (typeof rewritten.url === 'string') {
|
||||
const raw = rewritten.url.trim()
|
||||
if (isVectorSource(key, source)) {
|
||||
rewritten.url = resolveVectorUrl(raw, base)
|
||||
} else {
|
||||
// رندر سختگیرانهی برداری: منابع غیربرداری (مثل raster) هرگز به آرشیو
|
||||
// برداری هدایت نمیشوند — کاشی MVT زیر منبع raster یعنی خروجی خراب.
|
||||
if (import.meta.env.DEV && source.type === 'raster') {
|
||||
console.warn(`[map] منبع غیربرداری «${key}» در سبک هست؛ افزایش رزولوشن برداری برای آن اعمال نمیشود`)
|
||||
}
|
||||
rewritten.url = absolutize(raw, base)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rewritten.tiles)) {
|
||||
rewritten.tiles = rewritten.tiles.map((tile) =>
|
||||
typeof tile === 'string' ? absolutize(tile, base) : tile,
|
||||
)
|
||||
}
|
||||
sources[key] = rewritten
|
||||
}
|
||||
next.sources = sources
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/** بارگیری style.json از MinIO و بازگرداندن سبک پالایششده برای «new Map({ style })» */
|
||||
export async function loadMapStyle(signal?: AbortSignal): Promise<StyleSpecification> {
|
||||
const res = await fetch(joinUrl(MAP_BASE_URL, 'style.json'), { signal })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return rewriteStyleUrls((await res.json()) as StyleSpecification)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { RequestTransformFunction } from 'maplibre-gl'
|
||||
|
||||
/** دو بخش آخر مسیر گلیف: پوشهی پشتهی فونت و فایل بازه */
|
||||
const GLYPH_PATH = /\/([^/?#]+)\/([^/?#]+\.pbf)$/
|
||||
|
||||
/**
|
||||
* نشانی گلیف با تنها فونت نخستِ پشته.
|
||||
*
|
||||
* مپلیبر برای هر لایه، تمام `text-font` را با کاما در {fontstack} میگذارد
|
||||
* (مثل «Metropolis Regular,Noto Sans Regular»)؛ سرورهای عمومی فونت، پوشهی
|
||||
* همین پشتهی ترکیبی را دارند اما MinIO این پروژه فونتها را در پوشههای
|
||||
* تکفونتی نگه میدارد. نتیجهی آن درخواستها ۴۰۴ بود، متنها رندر نمیشدند و
|
||||
* سیل خطا اپ را قفل میکرد. با این بازنویسی فقط فونت نخست — همان ترجیح اول
|
||||
* سبک — درخواست میشود و بقیهی مسیر (پوشهی v2.0 و فایل بازه) دستنخورده
|
||||
* میماند. نشانی بدون پشتهی ترکیبی یا ناشناس، بدون تغییر برمیگردد.
|
||||
*/
|
||||
export function singleFontGlyphUrl(url: string): string {
|
||||
const queryIndex = url.search(/[?#]/)
|
||||
const path = queryIndex === -1 ? url : url.slice(0, queryIndex)
|
||||
const suffix = queryIndex === -1 ? '' : url.slice(queryIndex)
|
||||
|
||||
const match = path.match(GLYPH_PATH)
|
||||
if (!match) return url
|
||||
|
||||
let fonts: string[]
|
||||
try {
|
||||
fonts = match[1].split(',').map((font) => decodeURIComponent(font).trim())
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
if (fonts.length < 2 || !fonts[0]) return url
|
||||
|
||||
return `${path.slice(0, match.index)}/${encodeURIComponent(fonts[0])}/${match[2]}${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* قلاب تبدیل درخواستهای نقشه — فقط گلیفها بازنویسی میشوند و هر درخواست
|
||||
* دیگری دستنخورده میماند تا رفتار پیشفرض مپلیبر حفظ شود.
|
||||
*/
|
||||
export const mapTransformRequest: RequestTransformFunction = (url, resourceType) =>
|
||||
resourceType === 'Glyphs' ? { url: singleFontGlyphUrl(url) } : { url }
|
||||
@@ -0,0 +1,2 @@
|
||||
/** وضعیت چرخهی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
|
||||
export type MapStatus = 'loading' | 'ready' | 'error'
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ensureMapWorker } from './mapWorker'
|
||||
import { loadMapStyle } from './styleLoader'
|
||||
import { mapTransformRequest } from './transformRequest'
|
||||
import type { MapStatus } from './types'
|
||||
|
||||
type UseMapLibreOptions = {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
/** بزرگنمایی با چرخ ماوس و پینچ لمسی — پیشفرض فعال */
|
||||
scrollZoom?: boolean
|
||||
/** محدودهٔ مجاز جابهجایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژهها */
|
||||
maxBounds?: maplibregl.LngLatBoundsLike
|
||||
}
|
||||
|
||||
/** سقف انتظار سبک نقشه — سرور کاشی بیپاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بیپایان */
|
||||
const STYLE_TIMEOUT_MS = 10_000
|
||||
/** اگر رویداد style.load دیر برسد، وضعیت آماده با این مهلت نمایش داده میشود */
|
||||
const READY_FALLBACK_MS = 2_000
|
||||
|
||||
/**
|
||||
* سقف بزرگنمایی = maxzoom آرشیو + همین مقدار سطح overzoom.
|
||||
*
|
||||
* سنجش روی تایلهای واقعی z14 اصفهان (۲۶٫۸۷۴ پارهخط جاده): میانهی وتر ۲۰
|
||||
* متر و صدک ۷۵ برابر ۳۶ متر است. با سه سطح overzoom (زوم ۱۷، ۰٫۵ متر بر
|
||||
* پیکسل) میانه ۴۰ و صدک ۷۵ حدود ۷۲ پیکسل میشود که خمها را هنوز صاف نشان
|
||||
* میدهد؛ از ۱۷٫۵ به بعد وترها به ۱۰۰ پیکسل و بیشتر میرسند و چندضلعیشدن
|
||||
* خمها دیده میشود. متن و آیکون هرگز تار نمیشوند (متن SDF و آیکون اسپرایت
|
||||
* است)؛ افت کیفیت فقط از هندسهی سادهشده میآید. با بازتولید آرشیو در z16
|
||||
* این سقف خودکار به ۱۹ میرسد.
|
||||
*/
|
||||
const OVERZOOM_LEVELS = 3
|
||||
/** سقف مطلق زوم — بالاتر از آن حتی با دادهی دقیقتر هم نمایش مرورگر بیمعناست */
|
||||
const ABSOLUTE_MAX_ZOOM = 22
|
||||
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
|
||||
const FALLBACK_MAX_ZOOM = 17
|
||||
|
||||
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [map, setMap] = useState<maplibregl.Map | null>(null)
|
||||
const [status, setStatus] = useState<MapStatus>('loading')
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let disposed = false
|
||||
let instance: maplibregl.Map | null = null
|
||||
let readyFallback: number | undefined
|
||||
let dprQuery: MediaQueryList | null = null
|
||||
let handleDprChange: (() => void) | null = null
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
|
||||
|
||||
setStatus('loading')
|
||||
setMap(null)
|
||||
|
||||
// وبورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
|
||||
// وگرنه نخستین درخواستهای کاشی در وبورکر بیپاسخ میمانند
|
||||
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
|
||||
.then(([style]) => {
|
||||
if (disposed) return
|
||||
instance = new maplibregl.Map({
|
||||
container,
|
||||
style,
|
||||
center,
|
||||
zoom,
|
||||
maxBounds,
|
||||
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق میکند
|
||||
maxZoom: FALLBACK_MAX_ZOOM,
|
||||
renderWorldCopies: false,
|
||||
// لبههای نرمتر خطوط و چندضلعیها — حس لوکس رندر برداری
|
||||
canvasContextAttributes: { antialias: true },
|
||||
// کیفیت حداکثری در بزرگنماییهای عمیق: هیچ سطحی با مقیاسکردن سطح
|
||||
// بالاتر رندر نمیشود و همهی سطحها تا maxZoom از تقسیم هندسه
|
||||
// میآیند (پیشفرض ۴ سطح آخر را overscale میکند). مبادله: کار
|
||||
// بیشتر پردازنده در زومهای عمیق.
|
||||
zoomLevelsToOverscale: 0,
|
||||
// چرخ ماوس، پینچ لمسی و دکمههای کنترل — مطابق درخواست کاربر
|
||||
scrollZoom,
|
||||
attributionControl: { compact: true },
|
||||
// MinIO فونتها را در پوشههای تکفونتی دارد، پس پشتهی ترکیبی
|
||||
// text-font («Metropolis Regular,Noto Sans Regular») به فونت نخست
|
||||
// بازنویسی میشود — توضیح کامل در transformRequest.ts
|
||||
transformRequest: mapTransformRequest,
|
||||
})
|
||||
|
||||
/**
|
||||
* سقف بزرگنمایی را از maxzoom واقعی آرشیو میسازد؛ با دادهی دقیقتر
|
||||
* (بازتولید آرشیو در z بالاتر) خودکار بالاتر میرود و کیفیت روی صفحه
|
||||
* ثابت میماند. زوم جاری هم اگر از سقف بیشتر باشد به آن محدود میشود.
|
||||
*/
|
||||
const applyZoomLimit = () => {
|
||||
if (!instance) return
|
||||
const source = instance.getSource('openmaptiles') as { maxzoom?: number } | undefined
|
||||
const sourceMaxZoom = typeof source?.maxzoom === 'number' ? source.maxzoom : null
|
||||
instance.setMaxZoom(
|
||||
sourceMaxZoom === null
|
||||
? FALLBACK_MAX_ZOOM
|
||||
: Math.min(sourceMaxZoom + OVERZOOM_LEVELS, ABSOLUTE_MAX_ZOOM),
|
||||
)
|
||||
}
|
||||
|
||||
instance.on('style.load', () => {
|
||||
if (disposed) return
|
||||
setStatus('ready')
|
||||
window.setTimeout(() => instance?.resize(), 150)
|
||||
})
|
||||
|
||||
/**
|
||||
* کنترل بزرگنمایی همراه خود نقشه ساخته میشود و با map.remove() پایین
|
||||
* میآید — map.remove بهتنهایی onRemove همهی کنترلها را صدا میزند،
|
||||
* پس removeControl دستی پس از آن، onRemove را دوباره اجرا میکند و با
|
||||
* «reading 'off' of undefined» کرش میکند. مالکیت کنترل همینجا، کنار
|
||||
* مالکیت نقشه میماند تا ترتیب پاکسازی هیچوقت به React وابسته نباشد.
|
||||
*/
|
||||
instance.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), 'top-right')
|
||||
|
||||
// maxzoom منبع همراه متادیتای TileJSON میرسد — همان لحظه سقف را قطعی کن
|
||||
instance.on('sourcedata', (e) => {
|
||||
if (e.sourceDataType === 'metadata' && e.sourceId === 'openmaptiles') applyZoomLimit()
|
||||
})
|
||||
instance.on('load', applyZoomLimit)
|
||||
applyZoomLimit()
|
||||
|
||||
// تایمر فالبک امنیتی
|
||||
readyFallback = window.setTimeout(() => {
|
||||
if (disposed) return
|
||||
setStatus('ready')
|
||||
instance?.resize()
|
||||
}, READY_FALLBACK_MS)
|
||||
|
||||
instance.on('error', (e) => {
|
||||
console.warn('MapLibre minor error:', e)
|
||||
})
|
||||
|
||||
/**
|
||||
* نسبت پیکسل دستگاه منبع حقیقت بافر بوم است؛ با زوم مرورگر
|
||||
* (Ctrl+zoom) یا جابهجایی پنجره روی مانیتور با چگالی دیگر عوض
|
||||
* میشود. اگر بافر با نسبت تازه همگام نشود، نقشه تا رفرش بعدی تار
|
||||
* میماند. matchMedia هر تغییر را دقیقاً همان لحظه گزارش میدهد و
|
||||
* پس از هر تغییر دوباره مسلح میشود.
|
||||
*/
|
||||
function syncPixelRatio() {
|
||||
if (!instance) return
|
||||
if (instance.getPixelRatio() !== window.devicePixelRatio) {
|
||||
instance.setPixelRatio(window.devicePixelRatio)
|
||||
}
|
||||
instance.resize()
|
||||
}
|
||||
|
||||
function watchPixelRatio() {
|
||||
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
|
||||
handleDprChange = () => {
|
||||
syncPixelRatio()
|
||||
watchPixelRatio()
|
||||
}
|
||||
dprQuery.addEventListener('change', handleDprChange)
|
||||
}
|
||||
|
||||
watchPixelRatio()
|
||||
|
||||
// دسترسی اشکالزدایی در محیط توسعه — در بیلد تولیدی وجود ندارد
|
||||
if (import.meta.env.DEV) {
|
||||
(window as unknown as { __map?: maplibregl.Map }).__map = instance
|
||||
}
|
||||
|
||||
setMap(instance)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setStatus('error')
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
window.clearTimeout(timeoutId)
|
||||
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
|
||||
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||
controller.abort()
|
||||
instance?.remove()
|
||||
}
|
||||
// مرکز و بزرگنمایی فقط موقعیت اولیه نقشهاند؛ فهرست وابستگی عمداً فقط reloadKey است
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reloadKey])
|
||||
|
||||
const retry = useCallback(() => setReloadKey((key) => key + 1), [])
|
||||
|
||||
return { containerRef, map, status, retry }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { MapProject } from '../data/mapProjects'
|
||||
import { ProjectPin } from './ProjectPin'
|
||||
|
||||
type PinHandle = { project: MapProject; marker: maplibregl.Marker; root: Root }
|
||||
|
||||
/** حاشیهٔ امن پاپآپ از لبههای نقشه — بالای صفحه بازتر، برای نوار برچسب و کنترلها */
|
||||
const AUTO_PAN_PADDING = { top: 150, bottom: 20, left: 20, right: 20 }
|
||||
|
||||
/**
|
||||
* نگهداشتن پاپآپ داخل کادر دید — معادل رفتار autoPan که مپلیبر (برخلاف
|
||||
* مپباکس) گزینهاش را ندارد: اگر کارت از حاشیهٔ امن بیرون بزند، نقشه نرم
|
||||
* جابهجا میشود تا کارت کامل دیده شود.
|
||||
*/
|
||||
function ensurePopupVisible(instance: maplibregl.Map, popup: maplibregl.Popup): void {
|
||||
const element = popup.getElement()
|
||||
const container = instance.getContainer()
|
||||
if (!element || !container) return
|
||||
|
||||
// چیدمان پاپآپ در همان فریم addTo کامل میشود، پس اندازهگیری به فریم بعد موکول است
|
||||
window.requestAnimationFrame(() => {
|
||||
const popupRect = element.getBoundingClientRect()
|
||||
const viewRect = container.getBoundingClientRect()
|
||||
|
||||
const excessTop = viewRect.top + AUTO_PAN_PADDING.top - popupRect.top
|
||||
const excessBottom = popupRect.bottom - (viewRect.bottom - AUTO_PAN_PADDING.bottom)
|
||||
const excessLeft = viewRect.left + AUTO_PAN_PADDING.left - popupRect.left
|
||||
const excessRight = popupRect.right - (viewRect.right - AUTO_PAN_PADDING.right)
|
||||
|
||||
// کمبود افقی و عمودی؛ دوربین به اندازهٔ بیرونزدگی جابهجا میشود
|
||||
const dx = Math.max(0, excessLeft) - Math.max(0, excessRight)
|
||||
const dy = Math.max(0, excessTop) - Math.max(0, excessBottom)
|
||||
if (!dx && !dy) return
|
||||
|
||||
instance.panBy([-dx, -dy], { duration: 320 })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* لایهی نشانگرهای پروژه — ساخت نشانگرها با React (هر نشانگر یک ریشهی
|
||||
* کوچک)، یک پاپآپ مشترک برای همه، و وضعیت پروژهی فعال.
|
||||
*
|
||||
* پاپآپ هیچوقت خودکار باز نمیشود؛ فقط با کلیک کاربر روی سنجاق. پاپآپ یک
|
||||
* المان میزبان دارد که بیرون از React ساخته میشود؛ مصرفکننده محتوای React را
|
||||
* با createPortal داخل همان میزبان میریزد تا Link روتر، حالت هاور و
|
||||
* انیمیشنها همه در درخت React بمانند.
|
||||
*/
|
||||
export function useProjectMarkers(map: maplibregl.Map | null, projects: MapProject[]) {
|
||||
const [popupHost, setPopupHost] = useState<HTMLDivElement | null>(null)
|
||||
const [activeProject, setActiveProject] = useState<MapProject | null>(null)
|
||||
|
||||
const popupRef = useRef<maplibregl.Popup | null>(null)
|
||||
const pinsRef = useRef<PinHandle[]>([])
|
||||
const activeRef = useRef<MapProject | null>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
mapRef.current = map
|
||||
|
||||
const openProject = useCallback((project: MapProject) => {
|
||||
const popup = popupRef.current
|
||||
const instance = mapRef.current
|
||||
if (!popup || !instance) return
|
||||
activeRef.current = project
|
||||
popup.setLngLat([project.lng, project.lat]).addTo(instance)
|
||||
ensurePopupVisible(instance, popup)
|
||||
setActiveProject(project)
|
||||
}, [])
|
||||
|
||||
const closePopup = useCallback(() => {
|
||||
activeRef.current = null
|
||||
const popup = popupRef.current
|
||||
// remove() فقط روی پاپآپ باز صدا زده میشود؛ پاپآپ بسته مالک مرورگر است
|
||||
if (popup?.isOpen()) popup.remove()
|
||||
setActiveProject(null)
|
||||
}, [])
|
||||
|
||||
/** پاپآپ مشترک + المان میزبان محتوا — یکبار برای هر نمونه نقشه */
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const host = document.createElement('div')
|
||||
const popup = new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: true,
|
||||
offset: 46,
|
||||
maxWidth: 'none',
|
||||
className: 'lux-map-popup',
|
||||
}).setDOMContent(host)
|
||||
|
||||
// بستن با کلیک روی نقشه یا دکمهی کارت، وضعیت فعال را هم پاک میکند
|
||||
popup.on('close', () => {
|
||||
activeRef.current = null
|
||||
setActiveProject(null)
|
||||
})
|
||||
|
||||
popupRef.current = popup
|
||||
setPopupHost(host)
|
||||
|
||||
return () => {
|
||||
// فقط پاپآپِ باز پایین آورده میشود؛ popup.remove خودش هم به رویداد
|
||||
// remove نقشه گوش میدهد، پس این پاکسازی تکراری و بیخطر است
|
||||
if (popup.isOpen()) popup.remove()
|
||||
popupRef.current = null
|
||||
setPopupHost(null)
|
||||
}
|
||||
}, [map])
|
||||
|
||||
/** نشانگرها — با تغییر فهرست پروژهها یا نمونهی نقشه بازساخته میشوند */
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const pins: PinHandle[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
const element = document.createElement('div')
|
||||
const marker = new maplibregl.Marker({ element, anchor: 'bottom' })
|
||||
.setLngLat([project.lng, project.lat])
|
||||
.addTo(map)
|
||||
const root = createRoot(element)
|
||||
root.render(<ProjectPin label={project.name} active={false} onClick={() => openProject(project)} />)
|
||||
pins.push({ project, marker, root })
|
||||
}
|
||||
|
||||
pinsRef.current = pins
|
||||
|
||||
return () => {
|
||||
for (const pin of pins) {
|
||||
// نشانگر خودش پاکسازی امن دارد (حذف شنوندهها + برداشتن المان)
|
||||
pin.marker.remove()
|
||||
/**
|
||||
* unmount ریشهی React هنگام جدا شدن والد، وسط چرخهی رندر رخ میدهد و
|
||||
* React 18 برای unmount همگام ریشهی تودرتو هشدار میدهد
|
||||
* («Attempted to synchronously unmount a root while React was already
|
||||
* rendering»). پس به میکروتسک بعد از پایان کامیت موکول میشود؛ المان
|
||||
* نشانگر تا آن لحظه از DOM جدا شده و unmount روی ظرف جدا امن است.
|
||||
*/
|
||||
queueMicrotask(() => pin.root.unmount())
|
||||
}
|
||||
pinsRef.current = []
|
||||
}
|
||||
}, [map, projects, openProject])
|
||||
|
||||
/** با تغییر پروژهی فعال، فقط هالهی سنجاقها بهروز میشود (بازسازی نمیشوند) */
|
||||
useEffect(() => {
|
||||
for (const pin of pinsRef.current) {
|
||||
pin.root.render(
|
||||
<ProjectPin
|
||||
label={pin.project.name}
|
||||
active={activeProject?.id === pin.project.id}
|
||||
onClick={() => openProject(pin.project)}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}, [activeProject, openProject])
|
||||
|
||||
return { popupHost, activeProject, openProject, closePopup }
|
||||
}
|
||||
Reference in New Issue
Block a user