diff --git a/.env b/.env index 2a2b4ec..b5cd69a 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ REACT_APP_YANDEX_GEOCODER_KEY=c2d3c717-a109-4494-8863-7edbc32044e3 REACT_APP_YANDEX_MAPS_KEY=d4696cc8-6a1f-4356-9219-108ac474093b -REACT_APP_API_URL=http://192.168.1.75:8080 \ No newline at end of file +REACT_APP_API_URL=http://192.168.1.76:8080 \ No newline at end of file diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0c6f4c0..a0a8b1a 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -40,12 +40,89 @@ function createMarkerLayout(color, imageUrl) { const FOCUS_ZOOM = 16; const COORD_MATCH_TOLERANCE = 0.005; +const MAP_CENTER = [55.751574, 37.573856]; +const MAP_ZOOM = 11; +const BOUNDS_DEBOUNCE_MS = 400; +const BOUNDS_RETRY_MS = 120; +const MAX_BOUNDS_ATTEMPTS = 25; -export default function MapView({ focusPostId, focusLat, focusLng, onPostClick, onBack }) { +/** + * GET /api/v1/posts/map + * + * Контракт требует границы видимой области карты — без любого из четырёх + * параметров бэкенд отвечает 400. type/category остаются опциональными и + * уезжают тем же запросом. + */ +async function loadMarkers(bounds, type, category) { + const [[minLat, minLng], [maxLat, maxLng]] = bounds; + + const params = new URLSearchParams({ + minLat: minLat.toString(), + maxLat: maxLat.toString(), + minLng: minLng.toString(), + maxLng: maxLng.toString(), + }); + if (type) params.append("type", type); + if (category) params.append("category", category); + + const response = await fetch(`${API_URL}/api/v1/posts/map?${params.toString()}`); + if (!response.ok) { + throw new Error(`Failed to load markers: ${response.status}`); + } + return response.json(); +} + +// Ключ для отсечения повторных запросов с одними и теми же границами. +// Нужен, потому что границы меняются и от действий пользователя (pan/zoom), +// и от программной установки центра — иначе на каждое такое изменение +// улетал бы лишний одинаковый запрос. +function boundsKey(bounds) { + return bounds.map(([lat, lng]) => `${lat.toFixed(5)}:${lng.toFixed(5)}`).join("|"); +} + +function createPostPlacemark(m) { + const color = MARKER_COLORS[m.type] || MARKER_COLORS.LOST; + const typeLabel = m.type === "FOUND" ? "Находка" : "Потеря"; + const imgSrc = m.imageUrl ? getImageUrl(m.imageUrl) : null; + const iconLayout = createMarkerLayout(color, imgSrc); + + return new window.ymaps.Placemark( + [m.latitude, m.longitude], + {}, + { + iconLayout, + iconImageSize: [40, 40], + iconImageOffset: [-20, -20], + // Без явного iconShape карта не может корректно вычислить + // кликабельную область для кастомного HTML-лейаута — + // маркер рисуется, но клики по нему не срабатывают. + iconShape: { + type: "Circle", + coordinates: [20, 20], + radius: 20, + }, + hasBalloon: false, + hasHint: true, + hintContent: m.title || typeLabel, + } + ); +} + +export default function MapView({ + focusPostId, + focusLat, + focusLng, + type = null, + category = null, + onPostClick, + onBack, +}) { const { t } = useTranslation(); const mapRef = useRef(null); const ymapRef = useRef(null); const userMarkerRef = useRef(null); + const markersRef = useRef(null); + const renderMarkersRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -54,104 +131,191 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick, const onPostClickRef = useRef(onPostClick); onPostClickRef.current = onPostClick; + // Фильтры читаются через ref: их смена не должна пересоздавать карту, + // они просто уезжают вместе с границами в том же самом запросе. + const filtersRef = useRef({ type, category }); + filtersRef.current = { type, category }; + useEffect(() => { let map; - const destroyed = { current: false }; + let destroyed = false; + let debounceTimer = null; + let boundsRetryTimer = null; + let firstLoadDone = false; + let lastBoundsKey = null; + let requestId = 0; + const subscriptions = []; - const init = async () => { - if (!mapRef.current || destroyed.current) return; + // Перерисовывает маркеры в текущей видимой области и возвращает + // словарь postId -> [lat, lng] для логики фокуса. + const renderMarkers = (markers) => { + const collection = markersRef.current; + const placemarksById = new Map(); + if (!collection) return placemarksById; + + collection.removeAll(); + + markers.forEach((m) => { + if (m.latitude == null || m.longitude == null) return; + + const placemark = createPostPlacemark(m); + collection.add(placemark); + + if (m.id != null) { + const postId = String(m.id); + placemarksById.set(postId, [m.latitude, m.longitude]); + placemark.events.add("click", () => { + if (onPostClickRef.current) { + onPostClickRef.current(postId); + } + }); + } + }); + + return placemarksById; + }; + renderMarkersRef.current = renderMarkers; + + const focusOnPost = (placemarksById) => { + let target = focusPostId != null + ? placemarksById.get(String(focusPostId)) + : null; + + if (!target && Number.isFinite(focusLat) && Number.isFinite(focusLng)) { + let best = null; + let bestDist = Infinity; + placemarksById.forEach((coords) => { + const d = + Math.pow(coords[0] - focusLat, 2) + + Math.pow(coords[1] - focusLng, 2); + if (d < bestDist) { + bestDist = d; + best = coords; + } + }); + if (best && bestDist < COORD_MATCH_TOLERANCE * COORD_MATCH_TOLERANCE) { + target = best; + } + } + + if (target) { + map.setCenter(target, FOCUS_ZOOM); + } + }; + + const load = async (bounds, isInitial) => { + if (isInitial) setLoading(true); + const request = ++requestId; try { - const res = await fetch(`${API_URL}/api/v1/posts/map`); - if (!res.ok) throw new Error("Failed to fetch map markers"); - const markers = await res.json(); + const markers = await loadMarkers( + bounds, + filtersRef.current.type, + filtersRef.current.category + ); + if (destroyed || request !== requestId) return; - if (destroyed.current) return; + const placemarksById = renderMarkers(Array.isArray(markers) ? markers : []); + if (isInitial) focusOnPost(placemarksById); + } catch (e) { + // Фоновая догрузка не должна перекрывать рабочую карту ошибкой — + // показываем её только на первом запросе. + if (!destroyed && request === requestId && isInitial) setError(e.message); + } finally { + if (!destroyed && request === requestId) setLoading(false); + // Спиннер снимаем в любом случае: иначе при пустых границах карта + // навсегда останется закрыта оверлеем загрузки. + if (!destroyed && isInitial) { + firstLoadDone = true; + setLoading(false); + } + } + }; + + const requestMarkers = (bounds, isInitial) => { + if (destroyed) return; + if (!bounds || bounds.length < 2) return; + + const key = boundsKey(bounds); + if (key === lastBoundsKey) return; + lastBoundsKey = key; + + load(bounds, isInitial); + }; + + // Перемещение/зум: перезапрашиваем маркеры, но не на каждый пиксель. + const scheduleMarkers = () => { + if (destroyed) return; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + requestMarkers(readBounds(), false); + }, BOUNDS_DEBOUNCE_MS); + }; + + // Безопасное чтение границ: до полного рендера карты getBounds() + // возвращает null/undefined или координаты-заглушки. + const readBounds = () => { + if (destroyed || !map) return null; + const bounds = map.getBounds(); + if (!bounds || bounds.length < 2) return null; + const [[minLat, minLng], [maxLat, maxLng]] = bounds; + if (![minLat, minLng, maxLat, maxLng].every(Number.isFinite)) return null; + return bounds; + }; + + // Первая загрузка. У ymaps.Map в 2.1 нет события 'load', поэтому + // готовность границ отслеживаем через boundschange (он прилетает уже + // при первом расчёте вьюпорта) + короткий ретрай, если событие не пришло. + const loadInitial = (attempt = 0) => { + if (destroyed || firstLoadDone) return; + + const bounds = readBounds(); + if (bounds) { + requestMarkers(bounds, true); + return; + } + + if (attempt >= MAX_BOUNDS_ATTEMPTS) { + firstLoadDone = true; + setLoading(false); + return; + } + + boundsRetryTimer = setTimeout(() => loadInitial(attempt + 1), BOUNDS_RETRY_MS); + }; + + const init = () => { + if (!mapRef.current || destroyed) return; + + try { + const hasFocusCoords = Number.isFinite(focusLat) && Number.isFinite(focusLng); map = new window.ymaps.Map(mapRef.current, { - center: [55.751574, 37.573856], - zoom: 11, + center: hasFocusCoords ? [focusLat, focusLng] : MAP_CENTER, + zoom: hasFocusCoords ? FOCUS_ZOOM : MAP_ZOOM, controls: ["zoomControl", "geolocationControl"], }); ymapRef.current = map; - const placemarksById = new Map(); + const collection = new window.ymaps.GeoObjectCollection(); + map.geoObjects.add(collection); + markersRef.current = collection; - markers.forEach((m) => { - if (m.latitude == null || m.longitude == null) return; - - const color = MARKER_COLORS[m.type] || MARKER_COLORS.LOST; - const typeLabel = m.type === "FOUND" ? "Находка" : "Потеря"; - const imgSrc = m.imageUrl ? getImageUrl(m.imageUrl) : null; - const iconLayout = createMarkerLayout(color, imgSrc); - const coords = [m.latitude, m.longitude]; - - const placemark = new window.ymaps.Placemark( - coords, - {}, - { - iconLayout, - iconImageSize: [40, 40], - iconImageOffset: [-20, -20], - // Без явного iconShape карта не может корректно вычислить - // кликабельную область для кастомного HTML-лейаута — - // маркер рисуется, но клики по нему не срабатывают. - iconShape: { - type: "Circle", - coordinates: [20, 20], - radius: 20, - }, - hasBalloon: false, - hasHint: true, - hintContent: m.title || typeLabel, - } - ); - - map.geoObjects.add(placemark); - if (m.id != null) { - const postId = String(m.id); - placemarksById.set(postId, { placemark, coords }); - placemark.events.add("click", () => { - if (onPostClickRef.current) { - onPostClickRef.current(postId); - } - }); + subscriptions.push(map.events.add("boundschange", () => { + if (!firstLoadDone) { + clearTimeout(boundsRetryTimer); + loadInitial(); } - }); + scheduleMarkers(); + })); - let target = focusPostId != null - ? placemarksById.get(String(focusPostId)) - : null; - - if (!target && Number.isFinite(focusLat) && Number.isFinite(focusLng)) { - let best = null; - let bestDist = Infinity; - placemarksById.forEach((entry) => { - const d = - Math.pow(entry.coords[0] - focusLat, 2) + - Math.pow(entry.coords[1] - focusLng, 2); - if (d < bestDist) { - bestDist = d; - best = entry; - } - }); - if (best && bestDist < COORD_MATCH_TOLERANCE * COORD_MATCH_TOLERANCE) { - target = best; - } - } - - if (target) { - map.setCenter(target.coords, FOCUS_ZOOM); - } else if (placemarksById.size > 0) { - map.setBounds( - map.geoObjects.getBounds(), - { checkZoomRange: true, zoomMargin: 40 } - ); - } + // Подстраховка на случай, если boundschange не придёт. + loadInitial(); } catch (e) { - if (!destroyed.current) setError(e.message); - } finally { - if (!destroyed.current) setLoading(false); + if (!destroyed) { + setError(e.message); + setLoading(false); + } } }; @@ -160,13 +324,44 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick, } return () => { - destroyed.current = true; + destroyed = true; + clearTimeout(debounceTimer); + clearTimeout(boundsRetryTimer); + subscriptions.forEach((sub) => { + try { + map?.events.remove(sub); + } catch (e) { + // карта уже уничтожена — ничего не делаем + } + }); ymapRef.current?.destroy(); ymapRef.current = null; + markersRef.current = null; + renderMarkersRef.current = null; userMarkerRef.current = null; }; }, [focusPostId, focusLat, focusLng]); + // Смена фильтров type/category: догружаем маркеры для текущих границ, + // карту при этом не пересоздаём. + useEffect(() => { + if (type == null && category == null) return; + const map = ymapRef.current; + if (!map) return; + const bounds = map.getBounds(); + if (!bounds || bounds.length < 2) return; + + const timer = setTimeout(async () => { + try { + const markers = await loadMarkers(bounds, type, category); + renderMarkersRef.current?.(Array.isArray(markers) ? markers : []); + } catch (e) { + // Фоновый запрос: молча оставляем прежние маркеры на месте. + } + }, BOUNDS_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [type, category]); + // Геолокация: если юзер разрешил и карта уже создана — центр на его месте. // Не трогаем, если карта была открыта с фокусом на конкретном объявлении. const hasFocus = focusPostId != null || (Number.isFinite(focusLat) && Number.isFinite(focusLng)); @@ -195,7 +390,7 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick, return (
{loading && ( -
+
)} @@ -228,4 +423,4 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick, )}
); -} \ No newline at end of file +}