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(null) const [activeProject, setActiveProject] = useState(null) const popupRef = useRef(null) const pinsRef = useRef([]) const activeRef = useRef(null) const mapRef = useRef(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( 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( openProject(pin.project)} />, ) } }, [activeProject, openProject]) return { popupHost, activeProject, openProject, closePopup } }