177 lines
7.1 KiB
JavaScript
177 lines
7.1 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { whenYmapsReady } from "../utils/ymapsApi";
|
||
|
||
const YANDEX_GEOCODER_API_KEY = process.env.REACT_APP_YANDEX_GEOCODER_KEY;
|
||
|
||
const DEFAULT_CENTER = [55.751574, 37.573856];
|
||
|
||
export function useYandexMap(enabled, initialCoords = null, initialAddress = "") {
|
||
const { t } = useTranslation();
|
||
// tRef читается из эффекта инициализации карты. Добавлять t в его deps нельзя:
|
||
// смена языка тогда пересоздавала бы карту и теряла бы выбранную метку.
|
||
const tRef = useRef(t);
|
||
tRef.current = t;
|
||
const mapRef = useRef(null);
|
||
const ymapRef = useRef(null);
|
||
const placemarkRef = useRef(null);
|
||
const initializedRef = useRef(false); // защита от двойного вызова StrictMode
|
||
const requestIdRef = useRef(0); // защита от гонки при быстрых кликах/драгах
|
||
const pendingFocusRef = useRef(null); // фокус, который ждёт, пока карта инициализируется
|
||
|
||
const [coords, setCoords] = useState(initialCoords);
|
||
const [address, setAddress] = useState(initialAddress ?? "");
|
||
const addressRef = useRef(initialAddress ?? "");
|
||
const [loading, setLoading] = useState(false);
|
||
// Ошибка загрузки API карт: без неё пользователь смотрел бы в вечный спиннер.
|
||
const [loadError, setLoadError] = useState("");
|
||
// Всегда хранит последние координаты, чтобы при повторной инициализации
|
||
// карты (например, при повторном открытии модалки) маркер появлялся там,
|
||
// где пользователь его оставил, а не сбрасывался на initialCoords.
|
||
const coordsRef = useRef(initialCoords);
|
||
useEffect(() => { coordsRef.current = coords; }, [coords]);
|
||
|
||
const resolveAddress = useCallback((lat, lng) => {
|
||
const requestId = ++requestIdRef.current;
|
||
setLoading(true);
|
||
|
||
fetch(
|
||
`https://geocode-maps.yandex.ru/1.x/?apikey=${YANDEX_GEOCODER_API_KEY}&geocode=${lng},${lat}&format=json&lang=ru_RU`
|
||
)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (requestId !== requestIdRef.current) return;
|
||
|
||
const found =
|
||
data?.response?.GeoObjectCollection?.featureMember?.[0]
|
||
?.GeoObject?.metaDataProperty?.GeocoderMetaData?.text;
|
||
const resolved = found || `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
||
setAddress(resolved);
|
||
addressRef.current = resolved;
|
||
setLoading(false);
|
||
})
|
||
.catch(() => {
|
||
if (requestId !== requestIdRef.current) return;
|
||
|
||
const fallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
||
setAddress(fallback);
|
||
addressRef.current = fallback;
|
||
setLoading(false);
|
||
});
|
||
}, []);
|
||
|
||
const attachPlacemarkDrag = useCallback((map, pm) => {
|
||
pm.events.add("dragend", () => {
|
||
const [lat, lng] = pm.geometry.getCoordinates();
|
||
setCoords({ latitude: lat, longitude: lng });
|
||
resolveAddress(lat, lng);
|
||
});
|
||
}, [resolveAddress]);
|
||
|
||
/**
|
||
* Устанавливает маркер и центр карты на указанные координаты.
|
||
* Если карта ещё не инициализирована — откладывает фокус до её создания.
|
||
*/
|
||
const moveTo = useCallback((lat, lng) => {
|
||
const map = ymapRef.current;
|
||
|
||
if (!map) {
|
||
pendingFocusRef.current = { lat, lng };
|
||
return;
|
||
}
|
||
|
||
pendingFocusRef.current = null;
|
||
setCoords({ latitude: lat, longitude: lng });
|
||
|
||
if (placemarkRef.current) {
|
||
placemarkRef.current.geometry.setCoordinates([lat, lng]);
|
||
} else {
|
||
const pm = new window.ymaps.Placemark([lat, lng], {}, { draggable: true });
|
||
map.geoObjects.add(pm);
|
||
placemarkRef.current = pm;
|
||
attachPlacemarkDrag(map, pm);
|
||
}
|
||
|
||
map.setCenter([lat, lng], 14);
|
||
resolveAddress(lat, lng);
|
||
}, [attachPlacemarkDrag, resolveAddress]);
|
||
|
||
const init = useCallback(() => {
|
||
if (ymapRef.current) return;
|
||
|
||
const startCoords = coordsRef.current;
|
||
const center = startCoords
|
||
? [startCoords.latitude, startCoords.longitude]
|
||
: DEFAULT_CENTER;
|
||
|
||
const map = new window.ymaps.Map(mapRef.current, {
|
||
center,
|
||
zoom: 14,
|
||
controls: ["zoomControl", "geolocationControl"],
|
||
});
|
||
ymapRef.current = map;
|
||
|
||
if (startCoords) {
|
||
const pm = new window.ymaps.Placemark(center, {}, { draggable: true });
|
||
map.geoObjects.add(pm);
|
||
placemarkRef.current = pm;
|
||
attachPlacemarkDrag(map, pm);
|
||
resolveAddress(startCoords.latitude, startCoords.longitude);
|
||
}
|
||
|
||
map.events.add("click", e => {
|
||
const [lat, lng] = e.get("coords");
|
||
moveTo(lat, lng);
|
||
});
|
||
|
||
// Если геолокация успела вернуть координаты раньше, чем создалась карта,
|
||
// применяем отложенный фокус сразу после инициализации.
|
||
if (pendingFocusRef.current) {
|
||
const pending = pendingFocusRef.current;
|
||
pendingFocusRef.current = null;
|
||
moveTo(pending.lat, pending.lng);
|
||
}
|
||
}, [attachPlacemarkDrag, resolveAddress, moveTo]);
|
||
|
||
useEffect(() => {
|
||
if (!enabled || !mapRef.current) return;
|
||
if (initializedRef.current) return; // уже инициализировали
|
||
|
||
let cancelled = false;
|
||
|
||
// Сбрасываем прошлую ошибку на новой попытке, иначе после успешной
|
||
// загрузки карты старая плашка об ошибке осталась бы на экране.
|
||
setLoadError("");
|
||
|
||
whenYmapsReady()
|
||
.then((ymaps) => {
|
||
if (cancelled) return;
|
||
initializedRef.current = true;
|
||
ymaps.ready(init);
|
||
})
|
||
.catch((e) => {
|
||
if (cancelled) return;
|
||
// API карт так и не подгрузился: сбрасываем флаг, чтобы следующая
|
||
// попытка открытия карты её повторила, и гасим бесконечный спиннер.
|
||
initializedRef.current = false;
|
||
setLoadError(tRef.current("post.mapLoadFailed"));
|
||
console.warn("Yandex Maps API failed to load:", e);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
ymapRef.current?.destroy();
|
||
ymapRef.current = null;
|
||
placemarkRef.current = null;
|
||
initializedRef.current = false;
|
||
pendingFocusRef.current = null;
|
||
};
|
||
// enabled намеренно в deps — карта инициализируется/разрушается при открытии
|
||
// и закрытии модалки. Начальные координаты читаются из coordsRef, чтобы
|
||
// при повторном открытии маркер вставал на последний выбранный адрес.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [enabled, init]);
|
||
|
||
return { mapRef, coords, address, addressRef, loading, loadError, moveTo };
|
||
}
|