From 39c70033e310323fca872f7887459638661cba27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 25 Aug 2026 15:26:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D0=BA=D0=B5=D1=80=D0=BE=D0=B2=20=D0=BE=D0=B1=D1=8A=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=20(ymaps.Clusterer=20+=20leaflet.marke?= =?UTF-8?q?rcluster),=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=B0=20=D1=81=D0=BE=20=D1=81=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20=D0=B2=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D0=B0=D0=BF=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/gps/GpsMap.tsx | 6 ++- client/src/components/gps/OsmMap.tsx | 36 +++++++++++++++--- client/src/components/gps/Yandex21Map.tsx | 46 ++++++++++++++++++++--- client/src/components/gps/marker-html.ts | 10 +++++ client/src/pages/gps/RoutesTab.tsx | 33 ++++++++++++++-- package-lock.json | 21 +++++++++++ package.json | 2 + 7 files changed, 138 insertions(+), 16 deletions(-) diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx index ed413fb..0d8951f 100644 --- a/client/src/components/gps/GpsMap.tsx +++ b/client/src/components/gps/GpsMap.tsx @@ -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 { diff --git a/client/src/components/gps/OsmMap.tsx b/client/src/components/gps/OsmMap.tsx index 79f5a17..ece1932 100644 --- a/client/src/components/gps/OsmMap.tsx +++ b/client/src/components/gps/OsmMap.tsx @@ -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(null); const mapRef = useRef(null); const markersLayerRef = useRef(null); + const clusterLayerRef = useRef(null); const featuresLayerRef = useRef(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]); diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index 953ec0f..5e7390b 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -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(null); const mapRef = useRef(null); const collectionRef = useRef(null); + const clustererRef = useRef(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]); diff --git a/client/src/components/gps/marker-html.ts b/client/src/components/gps/marker-html.ts index 8f7ef02..90e3f48 100644 --- a/client/src/components/gps/marker-html.ts +++ b/client/src/components/gps/marker-html.ts @@ -44,3 +44,13 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string { ${label} `; } + +/** + * HTML кластера — кружок с числом объектов в стиле маркеров. + * Используется обеими реализациями (ymaps.Clusterer и leaflet.markercluster). + * Для ymaps templateLayoutFactory допустима строка-шаблон + * '{{ properties.geoObjects.length }}' вместо числа. + */ +export function gpsClusterHtml(count: number | string): string { + return `
${count}
`; +} diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index cd54052..73578e2 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -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 `${time}
Скорость: ${speed}
${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() { Точек: {points.length} {trackData?.sampled && ` (всего ${trackData.total}, показана каждая N-я)`} + {pointsThinned && `, маркеров на карте: ${displayedPoints.length}`} Пробег: {stats.distance.toFixed(1)} км diff --git a/package-lock.json b/package-lock.json index 85d0f6e..4cbf25a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "jsuites": "^6.3.4", "jszip": "^3.10.1", "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.453.0", "lvovich": "^2.1.0", "mammoth": "^1.12.0", @@ -160,6 +161,7 @@ "@types/express": "4.17.21", "@types/express-session": "^1.18.0", "@types/leaflet": "^1.9.22", + "@types/leaflet.markercluster": "^1.5.6", "@types/node": "20.16.11", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38", @@ -6588,6 +6590,16 @@ "@types/geojson": "*" } }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -11225,6 +11237,15 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/lemonadejs": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/lemonadejs/-/lemonadejs-5.3.6.tgz", diff --git a/package.json b/package.json index b29e986..bda3c8a 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "jsuites": "^6.3.4", "jszip": "^3.10.1", "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.453.0", "lvovich": "^2.1.0", "mammoth": "^1.12.0", @@ -164,6 +165,7 @@ "@types/express": "4.17.21", "@types/express-session": "^1.18.0", "@types/leaflet": "^1.9.22", + "@types/leaflet.markercluster": "^1.5.6", "@types/node": "20.16.11", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38",