feat(gps): список объектов по клику на кластер, стрелки направления на точках трека; fix: хитбокс маркеров (попапы точек трека)
This commit is contained in:
@@ -32,6 +32,8 @@ export interface GpsMapMarker {
|
|||||||
popup?: string;
|
popup?: string;
|
||||||
/** Не включать маркер в кластеризацию (точки трека, вершины, старт/финиш) */
|
/** Не включать маркер в кластеризацию (точки трека, вершины, старт/финиш) */
|
||||||
noCluster?: boolean;
|
noCluster?: boolean;
|
||||||
|
/** Курс в градусах (0 = север) — для dot-маркеров рисуется стрелка направления */
|
||||||
|
course?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GpsMapPolygon {
|
export interface GpsMapPolygon {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'leaflet.markercluster';
|
|||||||
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||||
import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap';
|
import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap';
|
||||||
import { gpsClusterHtml, gpsMarkerHtml } from './marker-html';
|
import { gpsClusterHtml, gpsClusterListHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка,
|
* Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка,
|
||||||
@@ -31,6 +31,9 @@ interface OsmMapProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Опции маркера с нашим id (для списка кластера) */
|
||||||
|
type GpsMarkerOptions = L.MarkerOptions & { gpsMarkerId?: string | number };
|
||||||
|
|
||||||
export function OsmMap({
|
export function OsmMap({
|
||||||
center,
|
center,
|
||||||
zoom = 10,
|
zoom = 10,
|
||||||
@@ -47,12 +50,12 @@ export function OsmMap({
|
|||||||
const clusterLayerRef = useRef<L.MarkerClusterGroup | null>(null);
|
const clusterLayerRef = useRef<L.MarkerClusterGroup | null>(null);
|
||||||
const featuresLayerRef = useRef<L.LayerGroup | null>(null);
|
const featuresLayerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
|
||||||
// Колбэки кликов маркеров и карты — через ref, чтобы не пересоздавать слои
|
// Колбэки кликов и данные маркеров — через ref, чтобы не пересоздавать слои
|
||||||
// при смене идентичности функций (react-query рефетчи)
|
// при смене идентичности массивов (react-query рефетчи)
|
||||||
const markerClicksRef = useRef(new Map<string | number, () => void>());
|
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
|
||||||
const onMapClickRef = useRef(onMapClick);
|
const onMapClickRef = useRef(onMapClick);
|
||||||
useEffect(() => {
|
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;
|
onMapClickRef.current = onMapClick;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,18 +70,46 @@ export function OsmMap({
|
|||||||
featuresLayerRef.current = L.layerGroup().addTo(map);
|
featuresLayerRef.current = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
// Кластер маркеров объектов (vehicle/person): при отдалении перекрывающиеся
|
// Кластер маркеров объектов (vehicle/person): при отдалении перекрывающиеся
|
||||||
// маркеры схлопываются в кружок с числом; на max zoom — spiderfy из коробки
|
// маркеры схлопываются в кружок с числом. Клик по кластеру — popup
|
||||||
clusterLayerRef.current = L.markerClusterGroup({
|
// со списком объектов (обработчик ниже), а не зум.
|
||||||
|
const clusterLayer = L.markerClusterGroup({
|
||||||
iconCreateFunction: (cluster) =>
|
iconCreateFunction: (cluster) =>
|
||||||
L.divIcon({ html: gpsClusterHtml(cluster.getChildCount()), className: '', iconSize: [0, 0] }),
|
L.divIcon({ html: gpsClusterHtml(cluster.getChildCount()), className: '', iconSize: [0, 0] }),
|
||||||
spiderfyOnMaxZoom: true,
|
zoomToBoundsOnClick: false,
|
||||||
|
spiderfyOnMaxZoom: false,
|
||||||
showCoverageOnHover: false,
|
showCoverageOnHover: false,
|
||||||
}).addTo(map);
|
}).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) => {
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
onMapClickRef.current?.(e.latlng.lat, e.latlng.lng);
|
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;
|
mapRef.current = map;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -110,7 +141,7 @@ export function OsmMap({
|
|||||||
|
|
||||||
// Маркеры: перестраиваем слои при изменении данных (без учёта колбэков — они в ref)
|
// Маркеры: перестраиваем слои при изменении данных (без учёта колбэков — они в ref)
|
||||||
const markersKey = JSON.stringify(
|
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(() => {
|
useEffect(() => {
|
||||||
const layer = markersLayerRef.current;
|
const layer = markersLayerRef.current;
|
||||||
@@ -119,16 +150,25 @@ export function OsmMap({
|
|||||||
layer.clearLayers();
|
layer.clearLayers();
|
||||||
clusterLayer.clearLayers();
|
clusterLayer.clearLayers();
|
||||||
for (const m of markers) {
|
for (const m of markers) {
|
||||||
|
// Якорь иконки: блок фиксированной ширины позиционируется так, чтобы
|
||||||
|
// точка привязки совпала с координатой (кликабельная область = видимой)
|
||||||
|
const layout = gpsMarkerLayout(m);
|
||||||
const leafletMarker = L.marker([m.lat, m.lng], {
|
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,
|
zIndexOffset: m.zIndex ?? 0,
|
||||||
});
|
gpsMarkerId: m.id,
|
||||||
|
} as GpsMarkerOptions);
|
||||||
if (m.onClick) {
|
if (m.onClick) {
|
||||||
leafletMarker.on('click', () => {
|
leafletMarker.on('click', () => {
|
||||||
markerClicksRef.current.get(m.id)?.();
|
markersByIdRef.current.get(String(m.id))?.onClick?.();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Попап по клику (точки трека: время/скорость/координаты)
|
// Попап по клику (точки трека: время/скорость/курс)
|
||||||
if (m.popup) {
|
if (m.popup) {
|
||||||
leafletMarker.bindPopup(m.popup);
|
leafletMarker.bindPopup(m.popup);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap';
|
import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap';
|
||||||
import { loadYandexMaps } from './ymaps-loader';
|
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,
|
* Реализация GPS-карты на Яндекс.Картах JS API 2.1 (императивный ymaps,
|
||||||
@@ -52,16 +52,17 @@ export function Yandex21Map({
|
|||||||
const clustererRef = useRef<ymaps.Clusterer | null>(null);
|
const clustererRef = useRef<ymaps.Clusterer | null>(null);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
||||||
// Колбэки через ref, чтобы эффекты не перезапускались при каждом рендере
|
// Колбэки и данные маркеров через ref, чтобы эффекты не перезапускались
|
||||||
|
// при каждом рендере (react-query рефетчи меняют идентичность массивов)
|
||||||
const onReadyRef = useRef(onReady);
|
const onReadyRef = useRef(onReady);
|
||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
const onMapClickRef = useRef(onMapClick);
|
const onMapClickRef = useRef(onMapClick);
|
||||||
const markerClicksRef = useRef(new Map<string | number, () => void>());
|
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onReadyRef.current = onReady;
|
onReadyRef.current = onReady;
|
||||||
onErrorRef.current = onError;
|
onErrorRef.current = onError;
|
||||||
onMapClickRef.current = onMapClick;
|
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)
|
// Загрузка скрипта и создание карты (один раз на apiKey)
|
||||||
@@ -89,20 +90,41 @@ export function Yandex21Map({
|
|||||||
|
|
||||||
// Кластер маркеров объектов (vehicle/person): при отдалении
|
// Кластер маркеров объектов (vehicle/person): при отдалении
|
||||||
// перекрывающиеся маркеры схлопываются в кружок с числом.
|
// перекрывающиеся маркеры схлопываются в кружок с числом.
|
||||||
// NB: в @types/yandex-maps IClustererOptions неполный — clusterIconLayout
|
// NB: в @types/yandex-maps IClustererOptions неполный — clusterIconLayout,
|
||||||
// и clusterDisableClickZoom существуют в рантайме 2.1 (см. документацию),
|
// clusterDisableClickZoom и clusterOpenBalloonOnClick существуют
|
||||||
// поэтому опции приводим кастом.
|
// в рантайме 2.1 (см. документацию), поэтому опции приводим кастом.
|
||||||
const clusterer = new ymaps.Clusterer({
|
const clusterer = new ymaps.Clusterer({
|
||||||
clusterIconLayout: ymaps.templateLayoutFactory.createClass(
|
clusterIconLayout: ymaps.templateLayoutFactory.createClass(
|
||||||
gpsClusterHtml('{{ properties.geoObjects.length }}')
|
gpsClusterHtml('{{ properties.geoObjects.length }}')
|
||||||
),
|
),
|
||||||
// Клик по кластеру — зум до разложения (стандартное поведение)
|
// Одиночный клик по кластеру — НЕ зум, а наш balloon со списком
|
||||||
clusterDisableClickZoom: false,
|
// объектов (обработчик ниже); зум — колесом/двойным кликом/контролом
|
||||||
|
clusterDisableClickZoom: true,
|
||||||
|
clusterOpenBalloonOnClick: false,
|
||||||
} as ymaps.IClustererOptions);
|
} as ymaps.IClustererOptions);
|
||||||
// В типах map.geoObjects.add не принимает Clusterer (он не IGeoObject
|
// В типах map.geoObjects.add не принимает Clusterer (он не IGeoObject
|
||||||
// по декларациям, хотя рантайм принимает) — приводим.
|
// по декларациям, хотя рантайм принимает) — приводим.
|
||||||
map.geoObjects.add(clusterer as unknown as ymaps.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;
|
mapRef.current = map;
|
||||||
collectionRef.current = collection;
|
collectionRef.current = collection;
|
||||||
clustererRef.current = clusterer;
|
clustererRef.current = clusterer;
|
||||||
@@ -129,6 +151,24 @@ export function Yandex21Map({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [apiKey]);
|
}, [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,
|
// Перемещение viewport только при реальном изменении center/zoom/bounds,
|
||||||
// чтобы рефетчи не сбрасывали позицию, которую двигает пользователь
|
// чтобы рефетчи не сбрасывали позицию, которую двигает пользователь
|
||||||
const viewKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`;
|
const viewKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`;
|
||||||
@@ -149,7 +189,7 @@ export function Yandex21Map({
|
|||||||
// (объектов немного — removeAll + добавить заново дёшево);
|
// (объектов немного — removeAll + добавить заново дёшево);
|
||||||
// колбэки кликов не входят в ключ — они читаются из ref в момент клика
|
// колбэки кликов не входят в ключ — они читаются из ref в момент клика
|
||||||
const dataKey = JSON.stringify([
|
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]),
|
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]),
|
||||||
polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
|
polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
|
||||||
]);
|
]);
|
||||||
@@ -185,18 +225,26 @@ export function Yandex21Map({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createPlacemark = (m: GpsMapMarker) => {
|
const createPlacemark = (m: GpsMapMarker) => {
|
||||||
|
// Якорь layout: блок фиксированной ширины позиционируется так, чтобы
|
||||||
|
// точка привязки совпала с координатой (кликабельная область = видимой)
|
||||||
|
const layout = gpsMarkerLayout(m);
|
||||||
const placemark = new ymaps.Placemark(
|
const placemark = new ymaps.Placemark(
|
||||||
[m.lat, m.lng],
|
[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)),
|
iconLayout: ymaps.templateLayoutFactory.createClass(gpsMarkerHtml(m)),
|
||||||
|
iconOffset: [-layout.anchorX, -layout.anchorY],
|
||||||
...(m.zIndex !== undefined ? { zIndex: m.zIndex } : {}),
|
...(m.zIndex !== undefined ? { zIndex: m.zIndex } : {}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (m.onClick) {
|
if (m.onClick) {
|
||||||
placemark.events.add('click', () => {
|
placemark.events.add('click', () => {
|
||||||
markerClicksRef.current.get(m.id)?.();
|
markersByIdRef.current.get(String(m.id))?.onClick?.();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return placemark;
|
return placemark;
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import type { GpsMapMarker } from './GpsMap';
|
|||||||
* Общая HTML-разметка кастомных маркеров GPS-модуля.
|
* Общая HTML-разметка кастомных маркеров GPS-модуля.
|
||||||
* Используется обеими реализациями карты: Leaflet (divIcon) и Яндекс.Карты 2.1
|
* Используется обеими реализациями карты: Leaflet (divIcon) и Яндекс.Карты 2.1
|
||||||
* (templateLayoutFactory) — визуально маркеры одинаковые на обеих подложках.
|
* (templateLayoutFactory) — визуально маркеры одинаковые на обеих подложках.
|
||||||
|
*
|
||||||
|
* ВАЖНО: разметка БЕЗ transform-центрирования. Каждый маркер — блок фиксированной
|
||||||
|
* ширины, провайдер позиционирует его через iconAnchor (Leaflet) / iconOffset
|
||||||
|
* (Яндекс) по данным gpsMarkerLayout(). Это нужно, чтобы кликабельная область
|
||||||
|
* совпадала с видимым маркером: у ymaps layout-контейнер ставится левым верхним
|
||||||
|
* углом в точку, и с translate(-50%,-50%) ~75% видимой площади маркера
|
||||||
|
* оказывалось вне контейнера — клики по центру точки не открывали balloon.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_COLOR = '#3b82f6';
|
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 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>`;
|
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-разметки маркера */
|
/** Экранирование пользовательского текста для HTML-разметки маркера */
|
||||||
function escapeHtml(text: string): string {
|
function escapeHtml(text: string): string {
|
||||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return text.replace(/&/g, '&').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) или маленькая точка
|
* HTML маркера: цветной кружок с иконкой (vehicle/person), маленькая точка
|
||||||
* (dot) + подпись снизу; offline — приглушённый (opacity 0.5).
|
* или стрелка курса (dot с course) + подпись снизу; offline — приглушённый.
|
||||||
* Внутренний контейнер центрируется через translate(-50%, -50%) — точка
|
* Корневой блок — фиксированной ширины, без transform (см. комментарий выше).
|
||||||
* привязки карты должна быть в левом верхнем углу элемента.
|
|
||||||
*/
|
*/
|
||||||
export function gpsMarkerHtml(marker: GpsMapMarker): string {
|
export function gpsMarkerHtml(marker: GpsMapMarker): string {
|
||||||
const color = marker.color || DEFAULT_COLOR;
|
const color = marker.color || DEFAULT_COLOR;
|
||||||
const opacity = marker.offline ? 'opacity:0.5;' : '';
|
const opacity = marker.offline ? 'opacity:0.5;' : '';
|
||||||
|
const layout = gpsMarkerLayout(marker);
|
||||||
|
const title = escapeHtml(marker.label || '');
|
||||||
const label = 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') {
|
if (marker.kind === 'dot') {
|
||||||
return `<div style="display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-50%);user-select:none;${opacity}">
|
body =
|
||||||
<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>
|
marker.course != null
|
||||||
${label}
|
? `<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>`;
|
: `<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;width:${layout.width}px;user-select:none;cursor:pointer;${opacity}">${body}${label}</div>`;
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +81,26 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string {
|
|||||||
* Используется обеими реализациями (ymaps.Clusterer и leaflet.markercluster).
|
* Используется обеими реализациями (ymaps.Clusterer и leaflet.markercluster).
|
||||||
* Для ymaps templateLayoutFactory допустима строка-шаблон
|
* Для ymaps templateLayoutFactory допустима строка-шаблон
|
||||||
* '{{ properties.geoObjects.length }}' вместо числа.
|
* '{{ properties.geoObjects.length }}' вместо числа.
|
||||||
|
* (translate(-50%,-50%) здесь допустим: кликабельность кластера не критична
|
||||||
|
* по краям, центр кружка всегда попадает в контейнер.)
|
||||||
*/
|
*/
|
||||||
export function gpsClusterHtml(count: number | string): string {
|
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>`;
|
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>`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,11 +173,12 @@ export function GpsRoutesTab() {
|
|||||||
const displayedPoints = points.filter((_, idx) => idx % trackPointsStep === 0);
|
const displayedPoints = points.filter((_, idx) => idx % trackPointsStep === 0);
|
||||||
const pointsThinned = displayedPoints.length < points.length;
|
const pointsThinned = displayedPoints.length < points.length;
|
||||||
|
|
||||||
// Попап точки трека: время, скорость (км/ч), координаты
|
// Попап точки трека: время, скорость (км/ч), направление, координаты
|
||||||
const pointPopupHtml = (p: GpsTrackPoint): string => {
|
const pointPopupHtml = (p: GpsTrackPoint): string => {
|
||||||
const time = new Date(p.recordedAt).toLocaleString('ru-RU');
|
const time = new Date(p.recordedAt).toLocaleString('ru-RU');
|
||||||
const speed = p.speed != null ? `${Math.round(p.speed)} км/ч` : '—';
|
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' }]
|
? [{ id: 'track', points: trackCoords, color: selectedAsset?.color || '#3b82f6' }]
|
||||||
: [];
|
: [];
|
||||||
const mapMarkers: GpsMapMarker[] = [
|
const mapMarkers: GpsMapMarker[] = [
|
||||||
// Точки записи координат (мелкие, с попапом; в кластер не включаем)
|
// Точки записи координат (стрелки курса с попапом; в кластер не включаем)
|
||||||
...displayedPoints.map((p) => ({
|
...displayedPoints.map((p) => ({
|
||||||
id: `tp-${p.id}`,
|
id: `tp-${p.id}`,
|
||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
lng: p.lng,
|
lng: p.lng,
|
||||||
kind: 'dot' as const,
|
kind: 'dot' as const,
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
|
course: p.course ?? undefined,
|
||||||
popup: pointPopupHtml(p),
|
popup: pointPopupHtml(p),
|
||||||
noCluster: true,
|
noCluster: true,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user