feat(gps): список объектов по клику на кластер, стрелки направления на точках трека; fix: хитбокс маркеров (попапы точек трека)

This commit is contained in:
2026-08-25 16:15:02 +03:00
parent 39c70033e3
commit 670cecfe05
5 changed files with 185 additions and 43 deletions

View File

@@ -32,6 +32,8 @@ export interface GpsMapMarker {
popup?: string;
/** Не включать маркер в кластеризацию (точки трека, вершины, старт/финиш) */
noCluster?: boolean;
/** Курс в градусах (0 = север) — для dot-маркеров рисуется стрелка направления */
course?: number;
}
export interface GpsMapPolygon {

View File

@@ -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<L.MarkerClusterGroup | null>(null);
const featuresLayerRef = useRef<L.LayerGroup | null>(null);
// Колбэки кликов маркеров и карты — через ref, чтобы не пересоздавать слои
// при смене идентичности функций (react-query рефетчи)
const markerClicksRef = useRef(new Map<string | number, () => void>());
// Колбэки кликов и данные маркеров — через ref, чтобы не пересоздавать слои
// при смене идентичности массивов (react-query рефетчи)
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
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);
}

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 { 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<ymaps.Clusterer | null>(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<string | number, () => void>());
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
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;

View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg>`;
const USER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
/** Стрелка направления (треугольник, при course=0 смотрит на север) */
function arrowSvg(color: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="${color}" stroke="#fff" stroke-width="1"><path d="M12 2 L19 21 L12 16.5 L5 21 Z"/></svg>`;
}
/** Экранирование пользовательского текста для HTML-разметки маркера */
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Геометрия маркера: размеры блока и точка привязки (якорь) к координате */
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
? `<div style="margin-top:2px;padding:0 4px;border-radius:4px;font-size:10px;line-height:16px;font-weight:500;background:rgba(255,255,255,0.9);border:1px solid #d4d4d8;white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis;color:#18181b;">${escapeHtml(marker.label)}</div>`
? `<div style="margin-top:2px;padding:0 4px;border-radius:4px;font-size:10px;line-height:16px;font-weight:500;background:rgba(255,255,255,0.9);border:1px solid #d4d4d8;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis;color:#18181b;">${title}</div>`
: '';
let body: string;
if (marker.kind === 'dot') {
return `<div style="display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-50%);user-select:none;${opacity}">
<div style="width:16px;height:16px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 3px rgba(0,0,0,0.3);" title="${escapeHtml(marker.label || '')}"></div>
${label}
</div>`;
body =
marker.course != null
? `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;transform:rotate(${marker.course}deg);" title="${title}">${arrowSvg(color)}</div>`
: `<div style="width:16px;height:16px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 3px rgba(0,0,0,0.3);" title="${title}"></div>`;
} else {
const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
body = `<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>`;
}
const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
return `<div style="display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-50%);user-select:none;cursor:pointer;${opacity}">
<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>
${label}
</div>`;
return `<div style="display:flex;flex-direction:column;align-items:center;width:${layout.width}px;user-select:none;cursor:pointer;${opacity}">${body}${label}</div>`;
}
/**
@@ -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 `<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>`;
}
/**
* HTML списка объектов кластера (открывается по клику на кластер).
* Клики по пунктам разбирает делегированный обработчик провайдера
* (по атрибуту data-gps-marker-id) — вызывается onClick маркера.
*/
export function gpsClusterListHtml(markers: GpsMapMarker[]): string {
const rows = markers
.map(
(m) => `<button type="button" data-gps-marker-id="${escapeHtml(String(m.id))}" style="display:flex;align-items:center;gap:6px;width:100%;padding:4px 6px;border:0;background:none;cursor:pointer;font-size:12px;text-align:left;border-radius:4px;">
<span style="width:10px;height:10px;border-radius:50%;background:${m.color || DEFAULT_COLOR};border:1px solid #fff;box-shadow:0 0 1px rgba(0,0,0,0.4);flex-shrink:0;"></span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(m.label || 'Объект')}</span>
</button>`
)
.join('');
return `<div style="max-height:220px;overflow-y:auto;min-width:160px;">${rows}</div>`;
}

View File

@@ -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 `<b>${time}</b><br/>Скорость: ${speed}<br/>${p.lat.toFixed(6)}, ${p.lng.toFixed(6)}`;
const course = p.course != null ? `<br/>Направление: ${Math.round(p.course)}°` : '';
return `<b>${time}</b><br/>Скорость: ${speed}${course}<br/>${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,