gendesign/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx
bot-frontend fdbd6ed70d
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
fix(tradein): MapPicker через portal в document.body — z-index конфликт с SourcesProgress (#771) (#782)
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-05-30 16:37:03 +00:00

330 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
);
}