"use client"; /* Выбор адреса на карте ЕКБ (Leaflet + OSM). Клик по дому → reverse-геокодинг. */ /* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { API_BASE_URL } from "@/lib/api"; const LEAFLET_VER = "1.9.4"; const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; const EKB_CENTER: [number, number] = [56.8389, 60.6057]; /** Precision значения от reverse endpoint, при которых имеет смысл двигать * marker на snapped point (центр здания от Yandex / нашего FDW). Для * street/range/near/locality marker остаётся на клике — иначе соврём * пользователю, что нашли точное здание. */ const SNAP_PRECISIONS = new Set(["exact", "number", "cadastral"]); /** Минимальная дистанция click→snap в метрах для перемещения marker'а: * меньше — не двигаем (иначе marker «дёргается» при кликах прямо в дом). */ const SNAP_MIN_DISTANCE_M = 5; /** Расстояние в метрах между двумя точками (haversine, достаточно точно для <1km). */ function haversineMeters( lat1: number, lon1: number, lat2: number, lon2: number, ): number { const R = 6_371_000; // м const toRad = (d: number) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } interface ReverseResponse { address: string; lat: number; lon: number; snapped_lat: number; snapped_lon: number; precision: string; provider: string; } /** Подгружает Leaflet с CDN один раз, резолвит window.L. */ function loadLeaflet(): Promise { return new Promise((resolve, reject) => { const w = window as any; if (w.L) { resolve(w.L); return; } if (!document.querySelector(`link[data-leaflet]`)) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = LEAFLET_CSS; link.integrity = LEAFLET_CSS_SRI; link.crossOrigin = "anonymous"; link.setAttribute("data-leaflet", "1"); document.head.appendChild(link); } const existing = document.querySelector(`script[data-leaflet]`); if (existing) { existing.addEventListener("load", () => resolve(w.L)); existing.addEventListener("error", () => reject(new Error("leaflet load failed"))); return; } const script = document.createElement("script"); script.src = LEAFLET_JS; script.integrity = LEAFLET_JS_SRI; script.crossOrigin = "anonymous"; script.setAttribute("data-leaflet", "1"); script.onload = () => resolve(w.L); script.onerror = () => reject(new Error("leaflet load failed")); document.body.appendChild(script); }); } interface Props { onPick: (address: string) => void; onClose: () => void; } export function MapPicker({ onPick, onClose }: Props) { const mapRef = useRef(null); const [address, setAddress] = useState(null); const [loading, setLoading] = useState(false); const [mapError, setMapError] = useState(false); // True если последний reverse snap'нул marker на точку здания. Показываем // тонкий hint «Точка дома по Яндексу», чтобы user понимал почему marker // переехал на пару метров от его клика. const [snapped, setSnapped] = useState(false); // Portal-mount guard (SSR-safe): document доступен только после монтирования. // Рендерим оверлей в document.body, чтобы position:fixed/z-index:1000 не были // заперты в stacking-context формы (#771: полоски SourcesProgress наезжали). const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); useEffect(() => { let map: any = null; let marker: any = null; let cancelled = false; loadLeaflet() .then((L) => { if (cancelled || !mapRef.current) return; map = L.map(mapRef.current).setView(EKB_CENTER, 12); L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap", maxZoom: 19, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); map.on("click", (e: any) => { const lat = e.latlng.lat as number; const lon = e.latlng.lng as number; if (marker) marker.setLatLng([lat, lon]); else marker = L.circleMarker([lat, lon], { radius: 8, color: "#1d4ed8", weight: 2, fillColor: "#1d4ed8", fillOpacity: 0.45, }).addTo(map); setAddress(null); setSnapped(false); setLoading(true); fetch(`${API_BASE_URL}/api/v1/geocode/reverse?lat=${lat}&lon=${lon}`) .then((r) => (r.ok ? (r.json() as Promise) : null)) .then((d) => { if (cancelled || !d) { setAddress(null); return; } setAddress(d.address); // Snap marker → центр здания если provider дал точное precision // и snapped >5m от клика (иначе скачок незаметен / создаёт jitter). if ( SNAP_PRECISIONS.has(d.precision) && typeof d.snapped_lat === "number" && typeof d.snapped_lon === "number" ) { const dist = haversineMeters( lat, lon, d.snapped_lat, d.snapped_lon, ); if (dist >= SNAP_MIN_DISTANCE_M && marker && map) { marker.setLatLng([d.snapped_lat, d.snapped_lon]); map.panTo([d.snapped_lat, d.snapped_lon], { animate: true, duration: 0.3, }); setSnapped(true); } } }) .catch(() => setAddress(null)) .finally(() => { if (!cancelled) setLoading(false); }); }); }) .catch(() => setMapError(true)); return () => { cancelled = true; if (map) map.remove(); }; }, []); // Focus management: initial focus to close button, restore on unmount, Esc closes const closeBtnRef = useRef(null); const previouslyFocused = useRef(null); useEffect(() => { previouslyFocused.current = document.activeElement as HTMLElement | null; closeBtnRef.current?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; window.addEventListener("keydown", onKey); return () => { window.removeEventListener("keydown", onKey); previouslyFocused.current?.focus(); }; }, [onClose]); // Focus trap: keep Tab/Shift+Tab inside the modal const dialogRef = useRef(null); const onDialogKeyDown = (e: React.KeyboardEvent) => { if (e.key !== "Tab" || !dialogRef.current) return; const focusables = dialogRef.current.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' ); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; if (!mounted) return null; return createPortal(
e.stopPropagation()} onKeyDown={onDialogKeyDown} style={{ background: "#fff", borderRadius: 12, width: "min(900px, 100%)", maxHeight: "92vh", display: "flex", flexDirection: "column", overflow: "hidden", }} >
Выбор адреса на карте ЕКБ
{mapError ? (
Не удалось загрузить карту. Проверьте интернет-соединение.
) : (
)}
{loading ? "Определяем адрес…" : address ? address : "Кликните по дому на карте"} {address && snapped && !loading ? (
Точка дома по Яндексу
) : null}
, document.body, ); }