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_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
|
||||||
@@ -40,12 +40,89 @@ 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 }) {
|
/**
|
||||||
|
* 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 { t } = useTranslation();
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const ymapRef = useRef(null);
|
const ymapRef = useRef(null);
|
||||||
const userMarkerRef = useRef(null);
|
const userMarkerRef = useRef(null);
|
||||||
|
const markersRef = useRef(null);
|
||||||
|
const renderMarkersRef = useRef(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
@@ -54,104 +131,191 @@ export default function MapView({ focusPostId, focusLat, focusLng, onPostClick,
|
|||||||
const onPostClickRef = useRef(onPostClick);
|
const onPostClickRef = useRef(onPostClick);
|
||||||
onPostClickRef.current = onPostClick;
|
onPostClickRef.current = onPostClick;
|
||||||
|
|
||||||
|
// Фильтры читаются через ref: их смена не должна пересоздавать карту,
|
||||||
|
// они просто уезжают вместе с границами в том же самом запросе.
|
||||||
|
const filtersRef = useRef({ type, category });
|
||||||
|
filtersRef.current = { type, category };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let map;
|
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 {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/v1/posts/map`);
|
const markers = await loadMarkers(
|
||||||
if (!res.ok) throw new Error("Failed to fetch map markers");
|
bounds,
|
||||||
const markers = await res.json();
|
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, {
|
map = new window.ymaps.Map(mapRef.current, {
|
||||||
center: [55.751574, 37.573856],
|
center: hasFocusCoords ? [focusLat, focusLng] : MAP_CENTER,
|
||||||
zoom: 11,
|
zoom: hasFocusCoords ? FOCUS_ZOOM : MAP_ZOOM,
|
||||||
controls: ["zoomControl", "geolocationControl"],
|
controls: ["zoomControl", "geolocationControl"],
|
||||||
});
|
});
|
||||||
ymapRef.current = map;
|
ymapRef.current = map;
|
||||||
|
|
||||||
const placemarksById = new Map();
|
const collection = new window.ymaps.GeoObjectCollection();
|
||||||
|
map.geoObjects.add(collection);
|
||||||
|
markersRef.current = collection;
|
||||||
|
|
||||||
markers.forEach((m) => {
|
subscriptions.push(map.events.add("boundschange", () => {
|
||||||
if (m.latitude == null || m.longitude == null) return;
|
if (!firstLoadDone) {
|
||||||
|
clearTimeout(boundsRetryTimer);
|
||||||
const color = MARKER_COLORS[m.type] || MARKER_COLORS.LOST;
|
loadInitial();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
scheduleMarkers();
|
||||||
|
}));
|
||||||
|
|
||||||
let target = focusPostId != null
|
// Подстраховка на случай, если boundschange не придёт.
|
||||||
? placemarksById.get(String(focusPostId))
|
loadInitial();
|
||||||
: 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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!destroyed.current) setError(e.message);
|
if (!destroyed) {
|
||||||
} finally {
|
setError(e.message);
|
||||||
if (!destroyed.current) setLoading(false);
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user