Merge pull request #73 from SlimusMinus/added-box_map
fixed map by redis
This commit is contained in:
2
.env
2
.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
|
||||
REACT_APP_API_URL=http://192.168.1.76:8080
|
||||
@@ -40,54 +40,54 @@ 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 }) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef(null);
|
||||
const ymapRef = useRef(null);
|
||||
const userMarkerRef = useRef(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
/**
|
||||
* GET /api/v1/posts/map
|
||||
*
|
||||
* Контракт требует границы видимой области карты — без любого из четырёх
|
||||
* параметров бэкенд отвечает 400. type/category остаются опциональными и
|
||||
* уезжают тем же запросом.
|
||||
*/
|
||||
async function loadMarkers(bounds, type, category) {
|
||||
const [[minLat, minLng], [maxLat, maxLng]] = bounds;
|
||||
|
||||
const { coords: geoCoords, status: geoStatus } = useGeolocation({ onMount: true });
|
||||
|
||||
const onPostClickRef = useRef(onPostClick);
|
||||
onPostClickRef.current = onPostClick;
|
||||
|
||||
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"],
|
||||
const params = new URLSearchParams({
|
||||
minLat: minLat.toString(),
|
||||
maxLat: maxLat.toString(),
|
||||
minLng: minLng.toString(),
|
||||
maxLng: maxLng.toString(),
|
||||
});
|
||||
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 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,
|
||||
return new window.ymaps.Placemark(
|
||||
[m.latitude, m.longitude],
|
||||
{},
|
||||
{
|
||||
iconLayout,
|
||||
@@ -106,11 +106,64 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
|
||||
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) {
|
||||
const postId = String(m.id);
|
||||
placemarksById.set(postId, { placemark, coords });
|
||||
placemarksById.set(postId, [m.latitude, m.longitude]);
|
||||
placemark.events.add("click", () => {
|
||||
if (onPostClickRef.current) {
|
||||
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
|
||||
? placemarksById.get(String(focusPostId))
|
||||
: null;
|
||||
@@ -126,13 +184,13 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
|
||||
if (!target && Number.isFinite(focusLat) && Number.isFinite(focusLng)) {
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
placemarksById.forEach((entry) => {
|
||||
placemarksById.forEach((coords) => {
|
||||
const d =
|
||||
Math.pow(entry.coords[0] - focusLat, 2) +
|
||||
Math.pow(entry.coords[1] - focusLng, 2);
|
||||
Math.pow(coords[0] - focusLat, 2) +
|
||||
Math.pow(coords[1] - focusLng, 2);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = entry;
|
||||
best = coords;
|
||||
}
|
||||
});
|
||||
if (best && bestDist < COORD_MATCH_TOLERANCE * COORD_MATCH_TOLERANCE) {
|
||||
@@ -141,17 +199,123 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
|
||||
}
|
||||
|
||||
if (target) {
|
||||
map.setCenter(target.coords, FOCUS_ZOOM);
|
||||
} else if (placemarksById.size > 0) {
|
||||
map.setBounds(
|
||||
map.geoObjects.getBounds(),
|
||||
{ checkZoomRange: true, zoomMargin: 40 }
|
||||
);
|
||||
map.setCenter(target, FOCUS_ZOOM);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
if (!destroyed.current) setError(e.message);
|
||||
// Фоновая догрузка не должна перекрывать рабочую карту ошибкой —
|
||||
// показываем её только на первом запросе.
|
||||
if (!destroyed && request === requestId && isInitial) setError(e.message);
|
||||
} 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 () => {
|
||||
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 (
|
||||
<div className="f-mapview">
|
||||
{loading && (
|
||||
<div className="f-mapview-status">
|
||||
<div className="f-mapview-status" data-testid="mapview-loading">
|
||||
<div className="f-spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user