Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local> Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
"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<any> {
|
||
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<HTMLScriptElement>(`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<HTMLDivElement>(null);
|
||
const [address, setAddress] = useState<string | null>(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<ReverseResponse>) : 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<HTMLButtonElement>(null);
|
||
const previouslyFocused = useRef<HTMLElement | null>(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<HTMLDivElement>(null);
|
||
const onDialogKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||
if (e.key !== "Tab" || !dialogRef.current) return;
|
||
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
|
||
'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(
|
||
<div
|
||
onClick={onClose}
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
background: "rgba(0,0,0,.5)",
|
||
zIndex: 1000,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 20,
|
||
}}
|
||
>
|
||
<div
|
||
ref={dialogRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="map-picker-title"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={onDialogKeyDown}
|
||
style={{
|
||
background: "#fff",
|
||
borderRadius: 12,
|
||
width: "min(900px, 100%)",
|
||
maxHeight: "92vh",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
padding: "12px 16px",
|
||
borderBottom: "1px solid #e5e5e5",
|
||
}}
|
||
>
|
||
<b id="map-picker-title">Выбор адреса на карте ЕКБ</b>
|
||
<button
|
||
ref={closeBtnRef}
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="Закрыть"
|
||
style={{ border: "none", background: "none", fontSize: 22, cursor: "pointer", lineHeight: 1 }}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{mapError ? (
|
||
<div style={{ padding: 48, textAlign: "center", color: "#c0392b" }}>
|
||
Не удалось загрузить карту. Проверьте интернет-соединение.
|
||
</div>
|
||
) : (
|
||
<div ref={mapRef} style={{ height: 460, width: "100%", background: "#eef1f4" }} />
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
padding: "12px 16px",
|
||
borderTop: "1px solid #e5e5e5",
|
||
display: "flex",
|
||
gap: 12,
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, fontSize: 14, color: address ? "#111" : "#888" }}>
|
||
{loading
|
||
? "Определяем адрес…"
|
||
: address
|
||
? address
|
||
: "Кликните по дому на карте"}
|
||
{address && snapped && !loading ? (
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
marginTop: 2,
|
||
}}
|
||
aria-live="polite"
|
||
>
|
||
Точка дома по Яндексу
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (address) {
|
||
onPick(address);
|
||
onClose();
|
||
}
|
||
}}
|
||
disabled={!address || loading}
|
||
className="btn btn-primary"
|
||
style={{ opacity: address && !loading ? 1 : 0.5, whiteSpace: "nowrap" }}
|
||
>
|
||
Подставить адрес
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|