feat(gps): кластеризация маркеров объектов (ymaps.Clusterer + leaflet.markercluster), точки трека со скоростью в попапах
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user