Files
nahodka-front/src/components/MapView.jsx
2026-09-07 01:32:22 +03:00

231 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { API_URL, getImageUrl } from "../config/authConstants";
import { useGeolocation, GEO_STATUS } from "../hooks/useGeolocation";
const MARKER_COLORS = {
LOST: "#ff3b30",
LOSS: "#ff3b30",
FOUND: "#34c759",
};
function createMarkerLayout(color, imageUrl) {
const bgStyle = imageUrl
? `background-image:url('${imageUrl}');background-size:cover;background-position:center;`
: "";
return window.ymaps.templateLayoutFactory.createClass(
`<div style="
width:40px;height:40px;
border-radius:50%;
border:3px solid ${color};
box-shadow:0 2px 8px rgba(0,0,0,.35);
overflow:hidden;
background:${imageUrl ? "transparent" : color};
display:flex;align-items:center;justify-content:center;
cursor:pointer;
">
<div style="
width:100%;height:100%;border-radius:50%;
${bgStyle}
"></div>
</div>`,
{
build() {
this.constructor.superclass.build.call(this);
},
}
);
}
const FOCUS_ZOOM = 16;
const COORD_MATCH_TOLERANCE = 0.005;
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);
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"],
});
ymapRef.current = map;
const placemarksById = new Map();
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);
}
});
}
});
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 }
);
}
} catch (e) {
if (!destroyed.current) setError(e.message);
} finally {
if (!destroyed.current) setLoading(false);
}
};
if (window.ymaps?.ready) {
window.ymaps.ready(init);
}
return () => {
destroyed.current = true;
ymapRef.current?.destroy();
ymapRef.current = null;
userMarkerRef.current = null;
};
}, [focusPostId, focusLat, focusLng]);
// Геолокация: если юзер разрешил и карта уже создана — центр на его месте.
// Не трогаем, если карта была открыта с фокусом на конкретном объявлении.
const hasFocus = focusPostId != null || (Number.isFinite(focusLat) && Number.isFinite(focusLng));
useEffect(() => {
if (hasFocus) return;
if (geoStatus !== GEO_STATUS.SUCCESS || !geoCoords) return;
const map = ymapRef.current;
if (!map) return;
const [lat, lng] = [geoCoords.latitude, geoCoords.longitude];
map.setCenter([lat, lng], 14);
if (userMarkerRef.current) {
userMarkerRef.current.geometry.setCoordinates([lat, lng]);
} else {
const pm = new window.ymaps.Placemark(
[lat, lng],
{},
{ preset: "islands#blueCircleIcon" }
);
map.geoObjects.add(pm);
userMarkerRef.current = pm;
}
}, [hasFocus, geoStatus, geoCoords, loading]);
return (
<div className="f-mapview">
{loading && (
<div className="f-mapview-status">
<div className="f-spinner" />
</div>
)}
{error && (
<div className="f-mapview-status f-mapview-error">
Ошибка загрузки карты
</div>
)}
<div ref={mapRef} className="f-mapview-canvas" />
{geoStatus === GEO_STATUS.LOADING && !hasFocus && (
<div className="f-mapview-geo f-mapview-geo-info">Определение местоположения…</div>
)}
{geoStatus === GEO_STATUS.DENIED && (
<div className="f-mapview-geo f-mapview-geo-warn">Доступ к геолокации запрещён</div>
)}
{geoStatus === GEO_STATUS.TIMEOUT && (
<div className="f-mapview-geo f-mapview-geo-warn">Геолокация не определилась</div>
)}
{geoStatus === GEO_STATUS.UNSUPPORTED && (
<div className="f-mapview-geo f-mapview-geo-warn">Геолокация недоступна</div>
)}
{onBack && (
<button type="button" className="f-mapview-back" onClick={onBack}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className="f-mapview-back-icon">
<path d="M19 12H5" />
<path d="m12 19-7-7 7-7" />
</svg>
{t('map.backToListings')}
</button>
)}
</div>
);
}