feat(ui): update UI v1

This commit is contained in:
fatemeh sane
2026-09-25 22:49:30 +03:30
parent 805f4594d4
commit 853ad67db5
82 changed files with 10311 additions and 505 deletions
+34
View File
@@ -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>
)
}
+133
View File
@@ -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>
)
}
+81
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+66
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+35
View File
@@ -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
}
+15
View File
@@ -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)
+141
View File
@@ -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)
}
+41
View File
@@ -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 }
+2
View File
@@ -0,0 +1,2 @@
/** وضعیت چرخه‌ی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
export type MapStatus = 'loading' | 'ready' | 'error'
+192
View File
@@ -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 }
}
+156
View File
@@ -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 }
}