fixed map by redis

This commit is contained in:
SlimusMinus
2026-09-29 01:06:59 +03:00
parent 721162ad35
commit a4a9f955d8
2 changed files with 282 additions and 87 deletions

2
.env
View File

@@ -1,3 +1,3 @@
REACT_APP_YANDEX_GEOCODER_KEY=c2d3c717-a109-4494-8863-7edbc32044e3 REACT_APP_YANDEX_GEOCODER_KEY=c2d3c717-a109-4494-8863-7edbc32044e3
REACT_APP_YANDEX_MAPS_KEY=d4696cc8-6a1f-4356-9219-108ac474093b REACT_APP_YANDEX_MAPS_KEY=d4696cc8-6a1f-4356-9219-108ac474093b
REACT_APP_API_URL=http://192.168.1.75:8080 REACT_APP_API_URL=http://192.168.1.76:8080

View File

@@ -40,54 +40,54 @@ function createMarkerLayout(color, imageUrl) {
const FOCUS_ZOOM = 16; const FOCUS_ZOOM = 16;
const COORD_MATCH_TOLERANCE = 0.005; 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 }) { /**
const { t } = useTranslation(); * GET /api/v1/posts/map
const mapRef = useRef(null); *
const ymapRef = useRef(null); * Контракт требует границы видимой области карты — без любого из четырёх
const userMarkerRef = useRef(null); * параметров бэкенд отвечает 400. type/category остаются опциональными и
const [loading, setLoading] = useState(true); * уезжают тем же запросом.
const [error, setError] = useState(null); */
async function loadMarkers(bounds, type, category) {
const [[minLat, minLng], [maxLat, maxLng]] = bounds;
const { coords: geoCoords, status: geoStatus } = useGeolocation({ onMount: true }); const params = new URLSearchParams({
minLat: minLat.toString(),
const onPostClickRef = useRef(onPostClick); maxLat: maxLat.toString(),
onPostClickRef.current = onPostClick; minLng: minLng.toString(),
maxLng: maxLng.toString(),
useEffect(() => {
let map;
const destroyed = { current: false };
const init = async () => {
if (!mapRef.current || destroyed.current) return;
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();
if (destroyed.current) return;
map = new window.ymaps.Map(mapRef.current, {
center: [55.751574, 37.573856],
zoom: 11,
controls: ["zoomControl", "geolocationControl"],
}); });
ymapRef.current = map; if (type) params.append("type", type);
if (category) params.append("category", category);
const placemarksById = new Map(); 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();
}
markers.forEach((m) => { // Ключ для отсечения повторных запросов с одними и теми же границами.
if (m.latitude == null || m.longitude == null) return; // Нужен, потому что границы меняются и от действий пользователя (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 color = MARKER_COLORS[m.type] || MARKER_COLORS.LOST;
const typeLabel = m.type === "FOUND" ? "Находка" : "Потеря"; const typeLabel = m.type === "FOUND" ? "Находка" : "Потеря";
const imgSrc = m.imageUrl ? getImageUrl(m.imageUrl) : null; const imgSrc = m.imageUrl ? getImageUrl(m.imageUrl) : null;
const iconLayout = createMarkerLayout(color, imgSrc); const iconLayout = createMarkerLayout(color, imgSrc);
const coords = [m.latitude, m.longitude];
const placemark = new window.ymaps.Placemark( return new window.ymaps.Placemark(
coords, [m.latitude, m.longitude],
{}, {},
{ {
iconLayout, iconLayout,
@@ -106,11 +106,64 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
hintContent: m.title || typeLabel, 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);
const { coords: geoCoords, status: geoStatus } = useGeolocation({ onMount: true });
const onPostClickRef = useRef(onPostClick);
onPostClickRef.current = onPostClick;
// Фильтры читаются через ref: их смена не должна пересоздавать карту,
// они просто уезжают вместе с границами в том же самом запросе.
const filtersRef = useRef({ type, category });
filtersRef.current = { type, category };
useEffect(() => {
let map;
let destroyed = false;
let debounceTimer = null;
let boundsRetryTimer = null;
let firstLoadDone = false;
let lastBoundsKey = null;
let requestId = 0;
const subscriptions = [];
// Перерисовывает маркеры в текущей видимой области и возвращает
// словарь 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);
map.geoObjects.add(placemark);
if (m.id != null) { if (m.id != null) {
const postId = String(m.id); const postId = String(m.id);
placemarksById.set(postId, { placemark, coords }); placemarksById.set(postId, [m.latitude, m.longitude]);
placemark.events.add("click", () => { placemark.events.add("click", () => {
if (onPostClickRef.current) { if (onPostClickRef.current) {
onPostClickRef.current(postId); onPostClickRef.current(postId);
@@ -119,6 +172,11 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
} }
}); });
return placemarksById;
};
renderMarkersRef.current = renderMarkers;
const focusOnPost = (placemarksById) => {
let target = focusPostId != null let target = focusPostId != null
? placemarksById.get(String(focusPostId)) ? placemarksById.get(String(focusPostId))
: null; : null;
@@ -126,13 +184,13 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
if (!target && Number.isFinite(focusLat) && Number.isFinite(focusLng)) { if (!target && Number.isFinite(focusLat) && Number.isFinite(focusLng)) {
let best = null; let best = null;
let bestDist = Infinity; let bestDist = Infinity;
placemarksById.forEach((entry) => { placemarksById.forEach((coords) => {
const d = const d =
Math.pow(entry.coords[0] - focusLat, 2) + Math.pow(coords[0] - focusLat, 2) +
Math.pow(entry.coords[1] - focusLng, 2); Math.pow(coords[1] - focusLng, 2);
if (d < bestDist) { if (d < bestDist) {
bestDist = d; bestDist = d;
best = entry; best = coords;
} }
}); });
if (best && bestDist < COORD_MATCH_TOLERANCE * COORD_MATCH_TOLERANCE) { if (best && bestDist < COORD_MATCH_TOLERANCE * COORD_MATCH_TOLERANCE) {
@@ -141,17 +199,123 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
} }
if (target) { if (target) {
map.setCenter(target.coords, FOCUS_ZOOM); map.setCenter(target, FOCUS_ZOOM);
} else if (placemarksById.size > 0) {
map.setBounds(
map.geoObjects.getBounds(),
{ checkZoomRange: true, zoomMargin: 40 }
);
} }
};
const load = async (bounds, isInitial) => {
if (isInitial) setLoading(true);
const request = ++requestId;
try {
const markers = await loadMarkers(
bounds,
filtersRef.current.type,
filtersRef.current.category
);
if (destroyed || request !== requestId) return;
const placemarksById = renderMarkers(Array.isArray(markers) ? markers : []);
if (isInitial) focusOnPost(placemarksById);
} catch (e) { } catch (e) {
if (!destroyed.current) setError(e.message); // Фоновая догрузка не должна перекрывать рабочую карту ошибкой —
// показываем её только на первом запросе.
if (!destroyed && request === requestId && isInitial) setError(e.message);
} finally { } finally {
if (!destroyed.current) setLoading(false); 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: hasFocusCoords ? [focusLat, focusLng] : MAP_CENTER,
zoom: hasFocusCoords ? FOCUS_ZOOM : MAP_ZOOM,
controls: ["zoomControl", "geolocationControl"],
});
ymapRef.current = map;
const collection = new window.ymaps.GeoObjectCollection();
map.geoObjects.add(collection);
markersRef.current = collection;
subscriptions.push(map.events.add("boundschange", () => {
if (!firstLoadDone) {
clearTimeout(boundsRetryTimer);
loadInitial();
}
scheduleMarkers();
}));
// Подстраховка на случай, если boundschange не придёт.
loadInitial();
} catch (e) {
if (!destroyed) {
setError(e.message);
setLoading(false);
}
} }
}; };
@@ -160,13 +324,44 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
} }
return () => { 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?.destroy();
ymapRef.current = null; ymapRef.current = null;
markersRef.current = null;
renderMarkersRef.current = null;
userMarkerRef.current = null; userMarkerRef.current = null;
}; };
}, [focusPostId, focusLat, focusLng]); }, [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)); const hasFocus = focusPostId != null || (Number.isFinite(focusLat) && Number.isFinite(focusLng));
@@ -195,7 +390,7 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
return ( return (
<div className="f-mapview"> <div className="f-mapview">
{loading && ( {loading && (
<div className="f-mapview-status"> <div className="f-mapview-status" data-testid="mapview-loading">
<div className="f-spinner" /> <div className="f-spinner" />
</div> </div>
)} )}