Files
iistwin/client/src/components/gps/OsmMap.tsx

218 lines
9.1 KiB
TypeScript
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 } 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} />;
}