231 lines
7.8 KiB
JavaScript
231 lines
7.8 KiB
JavaScript
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>
|
||
);
|
||
} |