feat(gps): кластеризация маркеров объектов (ymaps.Clusterer + leaflet.markercluster), точки трека со скоростью в попапах

This commit is contained in:
2026-08-25 15:26:35 +03:00
parent bde9c2a641
commit 39c70033e3
7 changed files with 138 additions and 16 deletions

View File

@@ -21,13 +21,17 @@ export interface GpsMapMarker {
lat: number;
lng: number;
color?: string | null;
/** vehicle/person — кружок с иконкой; dot — маленькая точка (старт/финиш/вершина) */
/** vehicle/person — кружок с иконкой; dot — маленькая точка (старт/финиш/вершина/точка трека) */
kind: 'vehicle' | 'person' | 'dot';
label?: string;
/** Приглушённый маркер для offline-объектов */
offline?: boolean;
onClick?: () => void;
zIndex?: number;
/** HTML/text попапа по клику (balloon на Яндексе, popup на Leaflet) */
popup?: string;
/** Не включать маркер в кластеризацию (точки трека, вершины, старт/финиш) */
noCluster?: boolean;
}
export interface GpsMapPolygon {

View File

@@ -1,8 +1,11 @@
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 { gpsMarkerHtml } from './marker-html';
import { gpsClusterHtml, gpsMarkerHtml } from './marker-html';
/**
* Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка,
@@ -41,6 +44,7 @@ export function OsmMap({
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, чтобы не пересоздавать слои
@@ -62,6 +66,15 @@ export function OsmMap({
markersLayerRef.current = L.layerGroup().addTo(map);
featuresLayerRef.current = L.layerGroup().addTo(map);
// Кластер маркеров объектов (vehicle/person): при отдалении перекрывающиеся
// маркеры схлопываются в кружок с числом; на max zoom — spiderfy из коробки
clusterLayerRef.current = L.markerClusterGroup({
iconCreateFunction: (cluster) =>
L.divIcon({ html: gpsClusterHtml(cluster.getChildCount()), className: '', iconSize: [0, 0] }),
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
}).addTo(map);
map.on('click', (e: L.LeafletMouseEvent) => {
onMapClickRef.current?.(e.latlng.lat, e.latlng.lng);
});
@@ -72,6 +85,7 @@ export function OsmMap({
map.remove();
mapRef.current = null;
markersLayerRef.current = null;
clusterLayerRef.current = null;
featuresLayerRef.current = null;
};
// Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект
@@ -94,14 +108,16 @@ export function OsmMap({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewKey]);
// Маркеры: перестраиваем слой при изменении данных (без учёта колбэков — они в ref)
// Маркеры: перестраиваем слои при изменении данных (без учёта колбэков — они в ref)
const markersKey = JSON.stringify(
markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex])
markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex, m.popup, m.noCluster])
);
useEffect(() => {
const layer = markersLayerRef.current;
if (!layer) return;
const clusterLayer = clusterLayerRef.current;
if (!layer || !clusterLayer) return;
layer.clearLayers();
clusterLayer.clearLayers();
for (const m of markers) {
const leafletMarker = L.marker([m.lat, m.lng], {
icon: L.divIcon({ html: gpsMarkerHtml(m), className: '', iconSize: [0, 0] }),
@@ -112,7 +128,17 @@ export function OsmMap({
markerClicksRef.current.get(m.id)?.();
});
}
leafletMarker.addTo(layer);
// Попап по клику (точки трека: время/скорость/координаты)
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]);

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap';
import { loadYandexMaps } from './ymaps-loader';
import { gpsMarkerHtml } from './marker-html';
import { gpsClusterHtml, gpsMarkerHtml } from './marker-html';
/**
* Реализация GPS-карты на Яндекс.Картах JS API 2.1 (императивный ymaps,
@@ -49,6 +49,7 @@ export function Yandex21Map({
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<ymaps.Map | null>(null);
const collectionRef = useRef<ymaps.GeoObjectCollection | null>(null);
const clustererRef = useRef<ymaps.Clusterer | null>(null);
const [mapReady, setMapReady] = useState(false);
// Колбэки через ref, чтобы эффекты не перезапускались при каждом рендере
@@ -86,8 +87,25 @@ export function Yandex21Map({
const collection = new ymaps.GeoObjectCollection();
map.geoObjects.add(collection);
// Кластер маркеров объектов (vehicle/person): при отдалении
// перекрывающиеся маркеры схлопываются в кружок с числом.
// NB: в @types/yandex-maps IClustererOptions неполный — clusterIconLayout
// и clusterDisableClickZoom существуют в рантайме 2.1 (см. документацию),
// поэтому опции приводим кастом.
const clusterer = new ymaps.Clusterer({
clusterIconLayout: ymaps.templateLayoutFactory.createClass(
gpsClusterHtml('{{ properties.geoObjects.length }}')
),
// Клик по кластеру — зум до разложения (стандартное поведение)
clusterDisableClickZoom: false,
} as ymaps.IClustererOptions);
// В типах map.geoObjects.add не принимает Clusterer (он не IGeoObject
// по декларациям, хотя рантайм принимает) — приводим.
map.geoObjects.add(clusterer as unknown as ymaps.IGeoObject);
mapRef.current = map;
collectionRef.current = collection;
clustererRef.current = clusterer;
setMapReady(true);
onReadyRef.current?.();
})
@@ -104,6 +122,7 @@ export function Yandex21Map({
mapRef.current.destroy();
mapRef.current = null;
collectionRef.current = null;
clustererRef.current = null;
}
};
// Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект
@@ -130,14 +149,16 @@ export function Yandex21Map({
// (объектов немного — removeAll + добавить заново дёшево);
// колбэки кликов не входят в ключ — они читаются из ref в момент клика
const dataKey = JSON.stringify([
markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex]),
markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex, m.popup, m.noCluster]),
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]),
polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
]);
useEffect(() => {
const collection = collectionRef.current;
if (!mapReady || !collection) return;
const clusterer = clustererRef.current;
if (!mapReady || !collection || !clusterer) return;
collection.removeAll();
clusterer.removeAll();
for (const p of polygons) {
if (p.points.length < 3) continue;
@@ -163,10 +184,11 @@ export function Yandex21Map({
);
}
for (const m of markers) {
const createPlacemark = (m: GpsMapMarker) => {
const placemark = new ymaps.Placemark(
[m.lat, m.lng],
{},
// Попап по клику (balloon) — для точек трека (время/скорость/координаты)
m.popup ? { balloonContent: m.popup } : {},
{
iconLayout: ymaps.templateLayoutFactory.createClass(gpsMarkerHtml(m)),
...(m.zIndex !== undefined ? { zIndex: m.zIndex } : {}),
@@ -177,7 +199,19 @@ export function Yandex21Map({
markerClicksRef.current.get(m.id)?.();
});
}
collection.add(placemark);
return placemark;
};
for (const m of markers) {
// Кластерим только объекты (vehicle/person); dot-маркеры и noCluster —
// в обычную коллекцию (точки трека, старт/финиш, вершины геозон)
const clusterable = (m.kind === 'vehicle' || m.kind === 'person') && !m.noCluster;
const placemark = createPlacemark(m);
if (clusterable) {
clusterer.add(placemark);
} else {
collection.add(placemark);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataKey, mapReady]);

View File

@@ -44,3 +44,13 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string {
${label}
</div>`;
}
/**
* HTML кластера — кружок с числом объектов в стиле маркеров.
* Используется обеими реализациями (ymaps.Clusterer и leaflet.markercluster).
* Для ymaps templateLayoutFactory допустима строка-шаблон
* '{{ properties.geoObjects.length }}' вместо числа.
*/
export function gpsClusterHtml(count: number | string): string {
return `<div style="display:flex;align-items:center;justify-content:center;transform:translate(-50%,-50%);width:36px;height:36px;border-radius:50%;border:2px solid #fff;background:${DEFAULT_COLOR};box-shadow:0 1px 4px rgba(0,0,0,0.4);color:#fff;font-size:13px;font-weight:600;user-select:none;cursor:pointer;">${count}</div>`;
}

View File

@@ -167,18 +167,42 @@ export function GpsRoutesTab() {
const first = points[0];
const last = points[points.length - 1];
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда
// Точки записи координат вдоль трека: при > 300 прореживаем равномерно
const TRACK_POINTS_LIMIT = 300;
const trackPointsStep = Math.max(1, Math.ceil(points.length / TRACK_POINTS_LIMIT));
const displayedPoints = points.filter((_, idx) => idx % trackPointsStep === 0);
const pointsThinned = displayedPoints.length < points.length;
// Попап точки трека: время, скорость (км/ч), координаты
const pointPopupHtml = (p: GpsTrackPoint): string => {
const time = new Date(p.recordedAt).toLocaleString('ru-RU');
const speed = p.speed != null ? `${Math.round(p.speed)} км/ч` : '—';
return `<b>${time}</b><br/>Скорость: ${speed}<br/>${p.lat.toFixed(6)}, ${p.lng.toFixed(6)}`;
};
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи
const mapPolylines: GpsMapPolyline[] =
trackCoords.length >= 2
? [{ id: 'track', points: trackCoords, color: selectedAsset?.color || '#3b82f6' }]
: [];
const mapMarkers: GpsMapMarker[] = [
...(first ? [{ id: 'start', lat: first.lat, lng: first.lng, kind: 'dot' as const, color: '#22c55e', label: 'Старт', zIndex: 5 }] : []),
// Точки записи координат (мелкие, с попапом; в кластер не включаем)
...displayedPoints.map((p) => ({
id: `tp-${p.id}`,
lat: p.lat,
lng: p.lng,
kind: 'dot' as const,
color: '#64748b',
popup: pointPopupHtml(p),
noCluster: true,
zIndex: 2,
})),
...(first ? [{ id: 'start', lat: first.lat, lng: first.lng, kind: 'dot' as const, color: '#22c55e', label: 'Старт', zIndex: 5, noCluster: true }] : []),
...(last && points.length > 1
? [{ id: 'finish', lat: last.lat, lng: last.lng, kind: 'dot' as const, color: '#ef4444', label: 'Финиш', zIndex: 5 }]
? [{ id: 'finish', lat: last.lat, lng: last.lng, kind: 'dot' as const, color: '#ef4444', label: 'Финиш', zIndex: 5, noCluster: true }]
: []),
...(playPoint
? [{ id: 'playhead', lat: playPoint.lat, lng: playPoint.lng, kind: 'dot' as const, color: selectedAsset?.color || '#3b82f6', zIndex: 20 }]
? [{ id: 'playhead', lat: playPoint.lat, lng: playPoint.lng, kind: 'dot' as const, color: selectedAsset?.color || '#3b82f6', zIndex: 20, noCluster: true }]
: []),
];
@@ -233,6 +257,7 @@ export function GpsRoutesTab() {
<span>
Точек: <span className="text-foreground font-medium">{points.length}</span>
{trackData?.sampled && ` (всего ${trackData.total}, показана каждая N-я)`}
{pointsThinned && `, маркеров на карте: ${displayedPoints.length}`}
</span>
<span>
Пробег: <span className="text-foreground font-medium">{stats.distance.toFixed(1)} км</span>