157 lines
6.9 KiB
TypeScript
157 lines
6.9 KiB
TypeScript
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 }
|
|
}
|