From 670cecfe05e0e149d31579e3a3f72c5ac07924b2 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 16:15:02 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BA=D0=BB=D0=B8=D0=BA=D1=83=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80,=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=BB=D0=BA=D0=B8=20=D0=BD=D0=B0=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B0=D1=85=20=D1=82=D1=80=D0=B5=D0=BA=D0=B0?= =?UTF-8?q?;=20fix:=20=D1=85=D0=B8=D1=82=D0=B1=D0=BE=D0=BA=D1=81=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BA=D0=B5=D1=80=D0=BE=D0=B2=20(=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D1=8B=20=D1=82=D0=BE=D1=87=D0=B5=D0=BA=20?= =?UTF-8?q?=D1=82=D1=80=D0=B5=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/gps/GpsMap.tsx | 2 + client/src/components/gps/OsmMap.tsx | 66 +++++++++++++++---- client/src/components/gps/Yandex21Map.tsx | 74 +++++++++++++++++---- client/src/components/gps/marker-html.ts | 78 +++++++++++++++++++---- client/src/pages/gps/RoutesTab.tsx | 8 ++- 5 files changed, 185 insertions(+), 43 deletions(-) diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx index 0d8951f..6c2d62b 100644 --- a/client/src/components/gps/GpsMap.tsx +++ b/client/src/components/gps/GpsMap.tsx @@ -32,6 +32,8 @@ export interface GpsMapMarker { popup?: string; /** Не включать маркер в кластеризацию (точки трека, вершины, старт/финиш) */ noCluster?: boolean; + /** Курс в градусах (0 = север) — для dot-маркеров рисуется стрелка направления */ + course?: number; } export interface GpsMapPolygon { diff --git a/client/src/components/gps/OsmMap.tsx b/client/src/components/gps/OsmMap.tsx index ece1932..62180e8 100644 --- a/client/src/components/gps/OsmMap.tsx +++ b/client/src/components/gps/OsmMap.tsx @@ -5,7 +5,7 @@ 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, gpsMarkerHtml } from './marker-html'; +import { gpsClusterHtml, gpsClusterListHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html'; /** * Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка, @@ -31,6 +31,9 @@ interface OsmMapProps { className?: string; } +/** Опции маркера с нашим id (для списка кластера) */ +type GpsMarkerOptions = L.MarkerOptions & { gpsMarkerId?: string | number }; + export function OsmMap({ center, zoom = 10, @@ -47,12 +50,12 @@ export function OsmMap({ const clusterLayerRef = useRef(null); const featuresLayerRef = useRef(null); - // Колбэки кликов маркеров и карты — через ref, чтобы не пересоздавать слои - // при смене идентичности функций (react-query рефетчи) - const markerClicksRef = useRef(new Map void>()); + // Колбэки кликов и данные маркеров — через ref, чтобы не пересоздавать слои + // при смене идентичности массивов (react-query рефетчи) + const markersByIdRef = useRef(new Map()); const onMapClickRef = useRef(onMapClick); useEffect(() => { - markerClicksRef.current = new Map(markers.filter((m) => m.onClick).map((m) => [m.id, m.onClick!])); + markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); onMapClickRef.current = onMapClick; }); @@ -67,18 +70,46 @@ export function OsmMap({ featuresLayerRef.current = L.layerGroup().addTo(map); // Кластер маркеров объектов (vehicle/person): при отдалении перекрывающиеся - // маркеры схлопываются в кружок с числом; на max zoom — spiderfy из коробки - clusterLayerRef.current = L.markerClusterGroup({ + // маркеры схлопываются в кружок с числом. Клик по кластеру — popup + // со списком объектов (обработчик ниже), а не зум. + const clusterLayer = L.markerClusterGroup({ iconCreateFunction: (cluster) => L.divIcon({ html: gpsClusterHtml(cluster.getChildCount()), className: '', iconSize: [0, 0] }), - spiderfyOnMaxZoom: true, + 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 () => { @@ -110,7 +141,7 @@ export function OsmMap({ // Маркеры: перестраиваем слои при изменении данных (без учёта колбэков — они в 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, m.popup, m.noCluster]) + 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, m.course]) ); useEffect(() => { const layer = markersLayerRef.current; @@ -119,16 +150,25 @@ export function OsmMap({ 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: [0, 0] }), + 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', () => { - markerClicksRef.current.get(m.id)?.(); + markersByIdRef.current.get(String(m.id))?.onClick?.(); }); } - // Попап по клику (точки трека: время/скорость/координаты) + // Попап по клику (точки трека: время/скорость/курс) if (m.popup) { leafletMarker.bindPopup(m.popup); } diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index 5e7390b..e03fc2e 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 { gpsClusterHtml, gpsMarkerHtml } from './marker-html'; +import { gpsClusterHtml, gpsClusterListHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html'; /** * Реализация GPS-карты на Яндекс.Картах JS API 2.1 (императивный ymaps, @@ -52,16 +52,17 @@ export function Yandex21Map({ const clustererRef = useRef(null); const [mapReady, setMapReady] = useState(false); - // Колбэки через ref, чтобы эффекты не перезапускались при каждом рендере + // Колбэки и данные маркеров через ref, чтобы эффекты не перезапускались + // при каждом рендере (react-query рефетчи меняют идентичность массивов) const onReadyRef = useRef(onReady); const onErrorRef = useRef(onError); const onMapClickRef = useRef(onMapClick); - const markerClicksRef = useRef(new Map void>()); + const markersByIdRef = useRef(new Map()); useEffect(() => { onReadyRef.current = onReady; onErrorRef.current = onError; onMapClickRef.current = onMapClick; - markerClicksRef.current = new Map(markers.filter((m) => m.onClick).map((m) => [m.id, m.onClick!])); + markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m])); }); // Загрузка скрипта и создание карты (один раз на apiKey) @@ -89,20 +90,41 @@ export function Yandex21Map({ // Кластер маркеров объектов (vehicle/person): при отдалении // перекрывающиеся маркеры схлопываются в кружок с числом. - // NB: в @types/yandex-maps IClustererOptions неполный — clusterIconLayout - // и clusterDisableClickZoom существуют в рантайме 2.1 (см. документацию), - // поэтому опции приводим кастом. + // NB: в @types/yandex-maps IClustererOptions неполный — clusterIconLayout, + // clusterDisableClickZoom и clusterOpenBalloonOnClick существуют + // в рантайме 2.1 (см. документацию), поэтому опции приводим кастом. const clusterer = new ymaps.Clusterer({ clusterIconLayout: ymaps.templateLayoutFactory.createClass( gpsClusterHtml('{{ properties.geoObjects.length }}') ), - // Клик по кластеру — зум до разложения (стандартное поведение) - clusterDisableClickZoom: false, + // Одиночный клик по кластеру — НЕ зум, а наш balloon со списком + // объектов (обработчик ниже); зум — колесом/двойным кликом/контролом + clusterDisableClickZoom: true, + clusterOpenBalloonOnClick: false, } as ymaps.IClustererOptions); // В типах map.geoObjects.add не принимает Clusterer (он не IGeoObject // по декларациям, хотя рантайм принимает) — приводим. map.geoObjects.add(clusterer as unknown as ymaps.IGeoObject); + // Клик по кластеру → balloon со списком объектов; клик по пункту + // списка обрабатывается делегированием (data-gps-marker-id) + clusterer.events.add('click', (e: ymaps.IEvent) => { + const target = e.get('target') as { + getGeoObjects?: () => ymaps.IGeoObject[]; + geometry?: { getCoordinates?: () => number[] }; + } | undefined; + // Клик по обычному маркеру (не кластеру) — у него нет getGeoObjects + if (!target || typeof target.getGeoObjects !== 'function') return; + const items = target + .getGeoObjects() + .map((g) => (g.properties as unknown as { get(path: string): unknown }).get('gpsMarkerId') as string | number | null) + .map((id) => (id != null ? markersByIdRef.current.get(String(id)) : undefined)) + .filter((m): m is GpsMapMarker => !!m); + if (items.length === 0) return; + const coords = target.geometry?.getCoordinates?.() ?? map.getCenter(); + map.balloon.open(coords, gpsClusterListHtml(items)); + }); + mapRef.current = map; collectionRef.current = collection; clustererRef.current = clusterer; @@ -129,6 +151,24 @@ export function Yandex21Map({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiKey]); + // Делегированный клик по пункту списка кластера в balloon + // (контент balloon рендерится внутри контейнера карты) + useEffect(() => { + const container = containerRef.current; + if (!mapReady || !container) return; + const handler = (ev: MouseEvent) => { + 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(); + mapRef.current?.balloon.close(); + } + }; + container.addEventListener('click', handler); + return () => container.removeEventListener('click', handler); + }, [mapReady]); + // Перемещение viewport только при реальном изменении center/zoom/bounds, // чтобы рефетчи не сбрасывали позицию, которую двигает пользователь const viewKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`; @@ -149,7 +189,7 @@ 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, m.popup, m.noCluster]), + 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, m.course]), polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]), polylines.map((l) => [l.id, l.points, l.color, l.dashed]), ]); @@ -185,18 +225,26 @@ export function Yandex21Map({ } const createPlacemark = (m: GpsMapMarker) => { + // Якорь layout: блок фиксированной ширины позиционируется так, чтобы + // точка привязки совпала с координатой (кликабельная область = видимой) + const layout = gpsMarkerLayout(m); const placemark = new ymaps.Placemark( [m.lat, m.lng], - // Попап по клику (balloon) — для точек трека (время/скорость/координаты) - m.popup ? { balloonContent: m.popup } : {}, + { + // id маркера — для списка кластера + gpsMarkerId: m.id, + // Попап по клику (balloon) — для точек трека (время/скорость/курс) + ...(m.popup ? { balloonContent: m.popup } : {}), + }, { iconLayout: ymaps.templateLayoutFactory.createClass(gpsMarkerHtml(m)), + iconOffset: [-layout.anchorX, -layout.anchorY], ...(m.zIndex !== undefined ? { zIndex: m.zIndex } : {}), } ); if (m.onClick) { placemark.events.add('click', () => { - markerClicksRef.current.get(m.id)?.(); + markersByIdRef.current.get(String(m.id))?.onClick?.(); }); } return placemark; diff --git a/client/src/components/gps/marker-html.ts b/client/src/components/gps/marker-html.ts index 90e3f48..668e320 100644 --- a/client/src/components/gps/marker-html.ts +++ b/client/src/components/gps/marker-html.ts @@ -4,6 +4,13 @@ import type { GpsMapMarker } from './GpsMap'; * Общая HTML-разметка кастомных маркеров GPS-модуля. * Используется обеими реализациями карты: Leaflet (divIcon) и Яндекс.Карты 2.1 * (templateLayoutFactory) — визуально маркеры одинаковые на обеих подложках. + * + * ВАЖНО: разметка БЕЗ transform-центрирования. Каждый маркер — блок фиксированной + * ширины, провайдер позиционирует его через iconAnchor (Leaflet) / iconOffset + * (Яндекс) по данным gpsMarkerLayout(). Это нужно, чтобы кликабельная область + * совпадала с видимым маркером: у ymaps layout-контейнер ставится левым верхним + * углом в точку, и с translate(-50%,-50%) ~75% видимой площади маркера + * оказывалось вне контейнера — клики по центру точки не открывали balloon. */ const DEFAULT_COLOR = '#3b82f6'; @@ -13,36 +20,60 @@ const DEFAULT_COLOR = '#3b82f6'; const CAR_SVG = ``; const USER_SVG = ``; +/** Стрелка направления (треугольник, при course=0 смотрит на север) */ +function arrowSvg(color: string): string { + return ``; +} + /** Экранирование пользовательского текста для HTML-разметки маркера */ function escapeHtml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +/** Геометрия маркера: размеры блока и точка привязки (якорь) к координате */ +export interface GpsMarkerLayout { + width: number; + height: number; + /** Якорь: какая точка блока совпадает с координатой маркера */ + anchorX: number; + anchorY: number; +} + +const LABEL_HEIGHT = 18; // подпись под маркером (16px + отступ 2px) + +export function gpsMarkerLayout(marker: GpsMapMarker): GpsMarkerLayout { + if (marker.kind === 'dot') { + return { width: 24, height: 16 + (marker.label ? LABEL_HEIGHT : 0), anchorX: 12, anchorY: 8 }; + } + return { width: 120, height: 28 + (marker.label ? LABEL_HEIGHT : 0), anchorX: 60, anchorY: 14 }; +} + /** - * HTML маркера: цветной кружок с иконкой (vehicle/person) или маленькая точка - * (dot) + подпись снизу; offline — приглушённый (opacity 0.5). - * Внутренний контейнер центрируется через translate(-50%, -50%) — точка - * привязки карты должна быть в левом верхнем углу элемента. + * HTML маркера: цветной кружок с иконкой (vehicle/person), маленькая точка + * или стрелка курса (dot с course) + подпись снизу; offline — приглушённый. + * Корневой блок — фиксированной ширины, без transform (см. комментарий выше). */ export function gpsMarkerHtml(marker: GpsMapMarker): string { const color = marker.color || DEFAULT_COLOR; const opacity = marker.offline ? 'opacity:0.5;' : ''; + const layout = gpsMarkerLayout(marker); + const title = escapeHtml(marker.label || ''); const label = marker.label - ? `
${escapeHtml(marker.label)}
` + ? `
${title}
` : ''; + let body: string; if (marker.kind === 'dot') { - return `
-
- ${label} -
`; + body = + marker.course != null + ? `
${arrowSvg(color)}
` + : `
`; + } else { + const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG; + body = `
${icon}
`; } - const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG; - return `
-
${icon}
- ${label} -
`; + return `
${body}${label}
`; } /** @@ -50,7 +81,26 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string { * Используется обеими реализациями (ymaps.Clusterer и leaflet.markercluster). * Для ymaps templateLayoutFactory допустима строка-шаблон * '{{ properties.geoObjects.length }}' вместо числа. + * (translate(-50%,-50%) здесь допустим: кликабельность кластера не критична + * по краям, центр кружка всегда попадает в контейнер.) */ export function gpsClusterHtml(count: number | string): string { return `
${count}
`; } + +/** + * HTML списка объектов кластера (открывается по клику на кластер). + * Клики по пунктам разбирает делегированный обработчик провайдера + * (по атрибуту data-gps-marker-id) — вызывается onClick маркера. + */ +export function gpsClusterListHtml(markers: GpsMapMarker[]): string { + const rows = markers + .map( + (m) => `` + ) + .join(''); + return `
${rows}
`; +} diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index 73578e2..4b019b8 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -173,11 +173,12 @@ export function GpsRoutesTab() { 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 course = p.course != null ? `
Направление: ${Math.round(p.course)}°` : ''; + return `${time}
Скорость: ${speed}${course}
${p.lat.toFixed(6)}, ${p.lng.toFixed(6)}`; }; // Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи @@ -186,13 +187,14 @@ export function GpsRoutesTab() { ? [{ id: 'track', points: trackCoords, color: selectedAsset?.color || '#3b82f6' }] : []; const mapMarkers: GpsMapMarker[] = [ - // Точки записи координат (мелкие, с попапом; в кластер не включаем) + // Точки записи координат (стрелки курса с попапом; в кластер не включаем) ...displayedPoints.map((p) => ({ id: `tp-${p.id}`, lat: p.lat, lng: p.lng, kind: 'dot' as const, color: '#64748b', + course: p.course ?? undefined, popup: pointPopupHtml(p), noCluster: true, zIndex: 2,