218 lines
9.1 KiB
TypeScript
218 lines
9.1 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
||
import L from 'leaflet';
|
||
import 'leaflet/dist/leaflet.css';
|
||
import 'leaflet.markercluster';
|
||
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||
import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap';
|
||
import { gpsClusterHtml, gpsClusterListHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html';
|
||
|
||
/**
|
||
* Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка,
|
||
* fallback для Яндекс.Карт). Чистый Leaflet без react-leaflet:
|
||
* карта создаётся один раз на ref-контейнере, слои перестраиваются эффектами.
|
||
*/
|
||
|
||
const OSM_TILES_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||
const OSM_ATTRIBUTION = '© OpenStreetMap contributors';
|
||
const MOSCOW: [number, number] = [55.755864, 37.617698];
|
||
const DEFAULT_COLOR = '#3b82f6';
|
||
|
||
interface OsmMapProps {
|
||
/** Центр [lat, lng]; по умолчанию — Москва */
|
||
center?: [number, number];
|
||
zoom?: number;
|
||
/** Границы для автоподгонки [[lat, lng], [lat, lng]]; приоритетнее center/zoom */
|
||
bounds?: [[number, number], [number, number]];
|
||
markers?: GpsMapMarker[];
|
||
polygons?: GpsMapPolygon[];
|
||
polylines?: GpsMapPolyline[];
|
||
onMapClick?: (lat: number, lng: number) => void;
|
||
className?: string;
|
||
}
|
||
|
||
/** Опции маркера с нашим id (для списка кластера) */
|
||
type GpsMarkerOptions = L.MarkerOptions & { gpsMarkerId?: string | number };
|
||
|
||
export function OsmMap({
|
||
center,
|
||
zoom = 10,
|
||
bounds,
|
||
markers = [],
|
||
polygons = [],
|
||
polylines = [],
|
||
onMapClick,
|
||
className,
|
||
}: OsmMapProps) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<L.Map | null>(null);
|
||
const markersLayerRef = useRef<L.LayerGroup | null>(null);
|
||
const clusterLayerRef = useRef<L.MarkerClusterGroup | null>(null);
|
||
const featuresLayerRef = useRef<L.LayerGroup | null>(null);
|
||
|
||
// Колбэки кликов и данные маркеров — через ref, чтобы не пересоздавать слои
|
||
// при смене идентичности массивов (react-query рефетчи)
|
||
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
|
||
const onMapClickRef = useRef(onMapClick);
|
||
useEffect(() => {
|
||
markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m]));
|
||
onMapClickRef.current = onMapClick;
|
||
});
|
||
|
||
// Создание карты (один раз)
|
||
useEffect(() => {
|
||
if (!containerRef.current || mapRef.current) return;
|
||
|
||
const map = L.map(containerRef.current).setView(center ?? MOSCOW, zoom);
|
||
L.tileLayer(OSM_TILES_URL, { attribution: OSM_ATTRIBUTION, maxZoom: 19 }).addTo(map);
|
||
|
||
markersLayerRef.current = L.layerGroup().addTo(map);
|
||
featuresLayerRef.current = L.layerGroup().addTo(map);
|
||
|
||
// Кластер маркеров объектов (vehicle/person): при отдалении перекрывающиеся
|
||
// маркеры схлопываются в кружок с числом. Клик по кластеру — popup
|
||
// со списком объектов (обработчик ниже), а не зум.
|
||
const clusterLayer = L.markerClusterGroup({
|
||
iconCreateFunction: (cluster) =>
|
||
L.divIcon({ html: gpsClusterHtml(cluster.getChildCount()), className: '', iconSize: [0, 0] }),
|
||
zoomToBoundsOnClick: false,
|
||
spiderfyOnMaxZoom: false,
|
||
showCoverageOnHover: false,
|
||
}).addTo(map);
|
||
clusterLayerRef.current = clusterLayer;
|
||
|
||
clusterLayer.on('clusterclick', (e: L.LeafletEvent) => {
|
||
const cluster = (e as L.LeafletEvent & { layer?: L.MarkerCluster }).layer;
|
||
const latlng = (e as L.LeafletEvent & { latlng?: L.LatLng }).latlng;
|
||
if (!cluster || !latlng) return;
|
||
const items = cluster
|
||
.getAllChildMarkers()
|
||
.map((cm: L.Marker) => (cm.options as GpsMarkerOptions).gpsMarkerId)
|
||
.map((id: string | number | undefined) => (id !== undefined ? markersByIdRef.current.get(String(id)) : undefined))
|
||
.filter((m: GpsMapMarker | undefined): m is GpsMapMarker => !!m);
|
||
if (items.length === 0) return;
|
||
L.popup({ maxWidth: 260 }).setLatLng(latlng).setContent(gpsClusterListHtml(items)).openOn(map);
|
||
});
|
||
|
||
map.on('click', (e: L.LeafletMouseEvent) => {
|
||
onMapClickRef.current?.(e.latlng.lat, e.latlng.lng);
|
||
});
|
||
|
||
// Делегированный клик по пункту списка кластера в popup
|
||
// (контент popup рендерится внутри контейнера карты)
|
||
map.getContainer().addEventListener('click', (ev) => {
|
||
const el = (ev.target as HTMLElement | null)?.closest?.('[data-gps-marker-id]') as HTMLElement | null;
|
||
if (!el) return;
|
||
const marker = markersByIdRef.current.get(el.dataset.gpsMarkerId || '');
|
||
if (marker?.onClick) {
|
||
marker.onClick();
|
||
map.closePopup();
|
||
}
|
||
});
|
||
|
||
mapRef.current = map;
|
||
|
||
return () => {
|
||
map.remove();
|
||
mapRef.current = null;
|
||
markersLayerRef.current = null;
|
||
clusterLayerRef.current = null;
|
||
featuresLayerRef.current = null;
|
||
};
|
||
// Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Перемещение viewport только при реальном изменении center/zoom/bounds,
|
||
// чтобы рефетчи не сбрасывали позицию, которую двигает пользователь
|
||
const viewKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`;
|
||
const lastViewKeyRef = useRef<string | null>(null);
|
||
useEffect(() => {
|
||
const map = mapRef.current;
|
||
if (!map || lastViewKeyRef.current === viewKey) return;
|
||
lastViewKeyRef.current = viewKey;
|
||
if (bounds) {
|
||
map.fitBounds(bounds);
|
||
} else if (center) {
|
||
map.setView(center, zoom);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [viewKey]);
|
||
|
||
// Маркеры: перестраиваем слои при изменении данных (без учёта колбэков — они в ref)
|
||
const markersKey = JSON.stringify(
|
||
markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, m.approximate, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course])
|
||
);
|
||
useEffect(() => {
|
||
const layer = markersLayerRef.current;
|
||
const clusterLayer = clusterLayerRef.current;
|
||
if (!layer || !clusterLayer) return;
|
||
layer.clearLayers();
|
||
clusterLayer.clearLayers();
|
||
for (const m of markers) {
|
||
// Якорь иконки: блок фиксированной ширины позиционируется так, чтобы
|
||
// точка привязки совпала с координатой (кликабельная область = видимой)
|
||
const layout = gpsMarkerLayout(m);
|
||
const leafletMarker = L.marker([m.lat, m.lng], {
|
||
icon: L.divIcon({
|
||
html: gpsMarkerHtml(m),
|
||
className: '',
|
||
iconSize: [layout.width, layout.height],
|
||
iconAnchor: [layout.anchorX, layout.anchorY],
|
||
}),
|
||
zIndexOffset: m.zIndex ?? 0,
|
||
gpsMarkerId: m.id,
|
||
} as GpsMarkerOptions);
|
||
if (m.onClick) {
|
||
leafletMarker.on('click', () => {
|
||
markersByIdRef.current.get(String(m.id))?.onClick?.();
|
||
});
|
||
}
|
||
// Попап по клику (точки трека: время/скорость/курс)
|
||
if (m.popup) {
|
||
leafletMarker.bindPopup(m.popup);
|
||
}
|
||
// Кластерим только объекты; dot-маркеры и noCluster — в обычный слой
|
||
const clusterable = (m.kind === 'vehicle' || m.kind === 'person') && !m.noCluster;
|
||
if (clusterable) {
|
||
leafletMarker.addTo(clusterLayer);
|
||
} else {
|
||
leafletMarker.addTo(layer);
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [markersKey]);
|
||
|
||
// Полигоны и полилинии
|
||
const featuresKey = JSON.stringify([
|
||
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]),
|
||
polylines.map((l) => [l.id, l.points, l.color, l.dashed, l.width]),
|
||
]);
|
||
useEffect(() => {
|
||
const layer = featuresLayerRef.current;
|
||
if (!layer) return;
|
||
layer.clearLayers();
|
||
for (const p of polygons) {
|
||
if (p.points.length < 3) continue;
|
||
const color = p.color || DEFAULT_COLOR;
|
||
L.polygon(p.points, {
|
||
color,
|
||
weight: p.strokeWidth ?? 2,
|
||
fillColor: color,
|
||
fillOpacity: p.fillOpacity ?? 0.15,
|
||
}).addTo(layer);
|
||
}
|
||
for (const l of polylines) {
|
||
if (l.points.length < 2) continue;
|
||
L.polyline(l.points, {
|
||
color: l.color || DEFAULT_COLOR,
|
||
weight: l.width ?? 4,
|
||
dashArray: l.dashed ? '8 6' : undefined,
|
||
}).addTo(layer);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [featuresKey]);
|
||
|
||
return <div ref={containerRef} className={className} />;
|
||
}
|