fix(gps): маркеры Яндекса — собственный DOM-слой поверх карты (клики React, пиксельная кластеризация); отказ от Clusterer/ObjectManager с их багами кастомных макетов
This commit is contained in:
@@ -1,42 +1,48 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, 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 { gpsClusterHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html';
|
import { gpsClusterHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html';
|
||||||
|
import { loadYandexMaps } from './ymaps-loader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Реализация GPS-карты на Яндекс.Картах JS API 2.1 (императивный ymaps,
|
* Яндекс.Карты 2.1: подложка + проекция. МАРКЕРЫ РИСУЕМ САМИ поверх карты
|
||||||
* тот же стиль, что OsmMap на Leaflet: ref-контейнер, карта создаётся один
|
* обычным DOM (абсолютно позиционированные div), клики — нативные React-события.
|
||||||
* раз, обновления слоёв через эффекты).
|
|
||||||
*
|
*
|
||||||
* ВАЖНО: в 2.1 координаты уже в порядке [lat, lng] — конвертация не нужна.
|
* Зачем: ymaps.Clusterer/ObjectManager с кастомными iconLayout-маркерами в нашей
|
||||||
|
* связке теряют кликабельность и/или не рендерятся (известные баги движка,
|
||||||
|
* зафиксированы 2026-09-23). Свой DOM-слой надёжен по построению: позиции
|
||||||
|
* считаем через проекцию карты, hit-test больше не зависит от Яндекса.
|
||||||
|
* Геозоны/полилинии — штатной GeoObjectCollection, они рендерятся надёжно.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MOSCOW: [number, number] = [55.755864, 37.617698];
|
|
||||||
const DEFAULT_COLOR = '#3b82f6';
|
const DEFAULT_COLOR = '#3b82f6';
|
||||||
|
const MOSCOW: [number, number] = [55.755864, 37.617698];
|
||||||
|
// Размер ячейки пиксельной кластеризации маркеров
|
||||||
|
const CLUSTER_GRID = 60;
|
||||||
|
|
||||||
interface Yandex21MapProps {
|
interface Yandex21MapProps {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
/** Центр [lat, lng]; по умолчанию — Москва */
|
|
||||||
center?: [number, number];
|
center?: [number, number];
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
/** Границы для автоподгонки [[lat, lng], [lat, lng]]; приоритетнее center/zoom */
|
|
||||||
bounds?: [[number, number], [number, number]];
|
bounds?: [[number, number], [number, number]];
|
||||||
markers?: GpsMapMarker[];
|
markers?: GpsMapMarker[];
|
||||||
polygons?: GpsMapPolygon[];
|
polygons?: GpsMapPolygon[];
|
||||||
polylines?: GpsMapPolyline[];
|
polylines?: GpsMapPolyline[];
|
||||||
onMapClick?: (lat: number, lng: number) => void;
|
onMapClick?: (lat: number, lng: number) => void;
|
||||||
/** Правый клик по карте (создание объекта с координатами) */
|
|
||||||
onMapContextMenu?: (lat: number, lng: number) => void;
|
onMapContextMenu?: (lat: number, lng: number) => void;
|
||||||
/** Клик по кластеру — id маркеров (свой список вместо balloon) */
|
|
||||||
onClusterClick?: (markerIds: Array<string | number>) => void;
|
onClusterClick?: (markerIds: Array<string | number>) => void;
|
||||||
/** Карта создана и готова к работе */
|
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
/** Ошибка загрузки скрипта/API (GpsMap использует для fallback на OSM) */
|
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MarkerPosition {
|
||||||
|
m: GpsMapMarker;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
hidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function Yandex21Map({
|
export function Yandex21Map({
|
||||||
apiKey,
|
apiKey,
|
||||||
center,
|
center,
|
||||||
@@ -46,37 +52,34 @@ export function Yandex21Map({
|
|||||||
polygons = [],
|
polygons = [],
|
||||||
polylines = [],
|
polylines = [],
|
||||||
onMapClick,
|
onMapClick,
|
||||||
|
onMapContextMenu,
|
||||||
|
onClusterClick,
|
||||||
onReady,
|
onReady,
|
||||||
onError,
|
onError,
|
||||||
className, onMapContextMenu,
|
className,
|
||||||
onClusterClick,
|
|
||||||
|
|
||||||
}: Yandex21MapProps) {
|
}: Yandex21MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<ymaps.Map | null>(null);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const collectionRef = useRef<ymaps.GeoObjectCollection | null>(null);
|
const mapRef = useRef<any | null>(null);
|
||||||
const objectManagerRef = useRef<any | null>(null);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const collectionRef = useRef<any | null>(null);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
// Тик пересчёта позиций маркеров (панорамирование/зум)
|
||||||
// Колбэки и данные маркеров через ref, чтобы эффекты не перезапускались
|
const [viewportTick, setViewportTick] = useState(0);
|
||||||
// при каждом рендере (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 onMapContextMenuRef = useRef(onMapContextMenu);
|
const onMapContextMenuRef = useRef(onMapContextMenu);
|
||||||
const onClusterClickRef = useRef(onClusterClick);
|
const onClusterClickRef = useRef(onClusterClick);
|
||||||
const placemarksRef = useRef(new Map<string, any>());
|
|
||||||
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;
|
||||||
markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m]));
|
|
||||||
onMapContextMenuRef.current = onMapContextMenu;
|
onMapContextMenuRef.current = onMapContextMenu;
|
||||||
onClusterClickRef.current = onClusterClick;
|
onClusterClickRef.current = onClusterClick;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Загрузка скрипта и создание карты (один раз на apiKey)
|
// Создание карты (один раз)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setMapReady(false);
|
setMapReady(false);
|
||||||
@@ -84,71 +87,42 @@ export function Yandex21Map({
|
|||||||
loadYandexMaps(apiKey)
|
loadYandexMaps(apiKey)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (cancelled || !containerRef.current || mapRef.current) return;
|
if (cancelled || !containerRef.current || mapRef.current) return;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ymaps = (window as any).ymaps;
|
||||||
|
|
||||||
const map = new ymaps.Map(containerRef.current, {
|
const map = new ymaps.Map(containerRef.current, {
|
||||||
center: center ?? MOSCOW,
|
center: center ?? MOSCOW,
|
||||||
zoom,
|
zoom,
|
||||||
controls: ['zoomControl'],
|
controls: ['zoomControl'],
|
||||||
}, {
|
}, {
|
||||||
// Отключаем интерактивность POI (кликабельные организации Яндекса):
|
// Отключаем интерактивность POI: хотспоты дёргают services/search/v2
|
||||||
// хотспоты дёргают services/search/v2 (403 на бесплатном ключе) и
|
// (403 на бесплатном ключе) и вмешиваются в обработку кликов
|
||||||
// вмешиваются в обработку клика по нашим маркерам
|
|
||||||
yandexMapDisablePoiInteractivity: true,
|
yandexMapDisablePoiInteractivity: true,
|
||||||
suppressMapOpenBlock: true,
|
suppressMapOpenBlock: true,
|
||||||
} as ymaps.IMapOptions);
|
});
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
map.events.add('click', (e: ymaps.IEvent) => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
map.events.add('click', (e: any) => {
|
||||||
const coords = e.get('coords') as [number, number];
|
const coords = e.get('coords') as [number, number];
|
||||||
onMapClickRef.current?.(coords[0], coords[1]);
|
onMapClickRef.current?.(coords[0], coords[1]);
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
// Правый клик — создание объекта с координатами
|
map.events.add('contextmenu', (e: any) => {
|
||||||
map.events.add('contextmenu', (e: ymaps.IEvent) => {
|
|
||||||
if (!onMapContextMenuRef.current) return;
|
if (!onMapContextMenuRef.current) return;
|
||||||
const coords = e.get('coords') as [number, number];
|
const coords = e.get('coords') as [number, number];
|
||||||
onMapContextMenuRef.current(coords[0], coords[1]);
|
onMapContextMenuRef.current(coords[0], coords[1]);
|
||||||
});
|
});
|
||||||
|
// Пересчёт позиций маркеров при любом движении карты
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const bump = () => setViewportTick((t) => t + 1);
|
||||||
|
map.events.add('boundschange', bump);
|
||||||
|
map.events.add('actiontick', bump);
|
||||||
|
|
||||||
const collection = new ymaps.GeoObjectCollection();
|
const collection = new ymaps.GeoObjectCollection();
|
||||||
map.geoObjects.add(collection);
|
map.geoObjects.add(collection);
|
||||||
|
|
||||||
// Слой кластеризуемых маркеров — ObjectManager (рекомендуемый Яндексом
|
|
||||||
// механизм): в отличие от Clusterer, кастомные iconLayout-маркеры
|
|
||||||
// в нём НЕ теряют кликабельность (известный баг Clusterer).
|
|
||||||
const objectManager = new ymaps.ObjectManager({
|
|
||||||
clusterize: true,
|
|
||||||
gridSize: 64,
|
|
||||||
clusterIconLayout: ymaps.templateLayoutFactory.createClass(
|
|
||||||
gpsClusterHtml('{{ properties.geoObjects.length }}')
|
|
||||||
),
|
|
||||||
// Одиночный клик по кластеру — НЕ зум, а наш список (обработчик ниже)
|
|
||||||
clusterDisableClickZoom: true,
|
|
||||||
clusterOpenBalloonOnClick: false,
|
|
||||||
} as unknown as ymaps.IObjectManagerOptions);
|
|
||||||
map.geoObjects.add(objectManager as unknown as ymaps.IGeoObject);
|
|
||||||
|
|
||||||
// Клик по маркеру (в т.ч. вне кластера)
|
|
||||||
objectManager.objects.events.add('click', (e: ymaps.IEvent) => {
|
|
||||||
const id = e.get('objectId');
|
|
||||||
console.log('[GPS-DIAG] om object click', id); // ДИАГНОСТИКА: убрать
|
|
||||||
markersByIdRef.current.get(String(id))?.onClick?.();
|
|
||||||
});
|
|
||||||
// Клик по кластеру → id маркеров в наш вертикальный список
|
|
||||||
objectManager.clusters.events.add('click', (e: ymaps.IEvent) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const cluster: any = objectManager.clusters.getById(e.get('objectId'));
|
|
||||||
const ids: Array<string | number> = cluster
|
|
||||||
? cluster.properties.geoObjects
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
.map((o: any) => (o.id !== undefined ? o.id : (o.properties?.get?.('gpsMarkerId') ?? null)))
|
|
||||||
.filter((id: string | number | null) => id != null)
|
|
||||||
: [];
|
|
||||||
if (ids.length > 0) onClusterClickRef.current?.(ids);
|
|
||||||
});
|
|
||||||
|
|
||||||
mapRef.current = map;
|
|
||||||
collectionRef.current = collection;
|
collectionRef.current = collection;
|
||||||
objectManagerRef.current = objectManager;
|
|
||||||
setMapReady(true);
|
setMapReady(true);
|
||||||
onReadyRef.current?.();
|
onReadyRef.current?.();
|
||||||
})
|
})
|
||||||
@@ -165,37 +139,12 @@ export function Yandex21Map({
|
|||||||
mapRef.current.destroy();
|
mapRef.current.destroy();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
collectionRef.current = null;
|
collectionRef.current = null;
|
||||||
objectManagerRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
||||||
// Делегированный клик по пункту списка кластера в balloon
|
// Перемещение viewport только при реальном изменении center/zoom/bounds
|
||||||
// (контент balloon рендерится внутри контейнера карты)
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!mapReady || !container) return;
|
|
||||||
const handler = (ev: MouseEvent) => {
|
|
||||||
// ДИАГНОСТИКА кликов: убрать после починки
|
|
||||||
console.log('[GPS-DIAG] container click, target=', (ev.target as HTMLElement)?.tagName, (ev.target as HTMLElement)?.className?.toString?.().slice(0, 60));
|
|
||||||
const el = (ev.target as HTMLElement | null)?.closest?.('[data-gps-action]') as HTMLElement | null;
|
|
||||||
if (!el) return;
|
|
||||||
const action = el.dataset.gpsAction || '';
|
|
||||||
if (!action.startsWith('open:')) return;
|
|
||||||
const marker = markersByIdRef.current.get(action.slice(5));
|
|
||||||
if (marker?.onClick) {
|
|
||||||
marker.onClick();
|
|
||||||
mapRef.current?.balloon.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
container.addEventListener('click', handler, true); // capture: ymaps/leaflet глушат bubble
|
|
||||||
return () => container.removeEventListener('click', handler, true);
|
|
||||||
}, [mapReady]);
|
|
||||||
|
|
||||||
// Перемещение 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}`;
|
||||||
const lastViewKeyRef = useRef<string | null>(null);
|
const lastViewKeyRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -210,159 +159,155 @@ export function Yandex21Map({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [viewKey, mapReady]);
|
}, [viewKey, mapReady]);
|
||||||
|
|
||||||
// Маркеры/полигоны/полилинии: перестраиваем коллекцию при изменении данных
|
// Геозоны и полилинии — штатной коллекцией (рендерятся надёжно)
|
||||||
// (объектов немного — removeAll + добавить заново дёшево);
|
const featuresKey = JSON.stringify([
|
||||||
// колбэки кликов не входят в ключ — они читаются из ref в момент клика
|
|
||||||
const dataKey = JSON.stringify([
|
|
||||||
// Плейхед без координат: его перемещение не должно пересобирать коллекцию
|
|
||||||
// (иначе открытый balloon точки трека уничтожается ребилдом при seek)
|
|
||||||
markers.map((m) => m.id === 'playhead'
|
|
||||||
? [m.id, m.kind, m.color, m.zIndex]
|
|
||||||
: [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, m.approximate, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course]),
|
|
||||||
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]),
|
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]),
|
||||||
polylines.map((l) => [l.id, l.points, l.color, l.dashed, l.width]),
|
polylines.map((l) => [l.id, l.points, l.color, l.dashed, l.width]),
|
||||||
]);
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const collection = collectionRef.current;
|
const collection = collectionRef.current;
|
||||||
const objectManager = objectManagerRef.current;
|
if (!mapReady || !collection) return;
|
||||||
if (!mapReady || !collection || !objectManager) return;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ymaps = (window as any).ymaps;
|
||||||
collection.removeAll();
|
collection.removeAll();
|
||||||
objectManager.removeAll();
|
|
||||||
placemarksRef.current.clear();
|
|
||||||
|
|
||||||
for (const p of polygons) {
|
for (const p of polygons) {
|
||||||
if (p.points.length < 3) continue;
|
if (p.points.length < 3) continue;
|
||||||
const color = p.color || DEFAULT_COLOR;
|
const color = p.color || DEFAULT_COLOR;
|
||||||
collection.add(
|
collection.add(new ymaps.Polygon([p.points], {}, {
|
||||||
new ymaps.Polygon([p.points], {}, {
|
fillColor: color,
|
||||||
fillColor: color,
|
fillOpacity: p.fillOpacity ?? 0.15,
|
||||||
fillOpacity: p.fillOpacity ?? 0.15,
|
strokeColor: color,
|
||||||
strokeColor: color,
|
strokeWidth: p.strokeWidth ?? 2,
|
||||||
strokeWidth: p.strokeWidth ?? 2,
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const l of polylines) {
|
for (const l of polylines) {
|
||||||
if (l.points.length < 2) continue;
|
if (l.points.length < 2) continue;
|
||||||
collection.add(
|
collection.add(new ymaps.Polyline(l.points, {}, {
|
||||||
new ymaps.Polyline(l.points, {}, {
|
strokeColor: l.color || DEFAULT_COLOR,
|
||||||
strokeColor: l.color || DEFAULT_COLOR,
|
strokeWidth: l.width ?? 4,
|
||||||
strokeWidth: l.width ?? 4,
|
strokeStyle: l.dashed ? 'dash' : 'solid',
|
||||||
strokeStyle: l.dashed ? 'dash' : 'solid',
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [featuresKey, mapReady]);
|
||||||
|
|
||||||
const createPlacemark = (m: GpsMapMarker) => {
|
// === Свой DOM-слой маркеров ===
|
||||||
// Якорь layout: блок фиксированной ширины позиционируется так, чтобы
|
// Позиции считаем через проекцию карты: гео → глобальные пиксели → пиксели
|
||||||
// точка привязки совпала с координатой (кликабельная область = видимой)
|
// контейнера. Кластеризация — простая пиксельная сетка (как у машин).
|
||||||
const layout = gpsMarkerLayout(m);
|
const markerPositions: MarkerPosition[] = useMemo(() => {
|
||||||
const placemark = new ymaps.Placemark(
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
[m.lat, m.lng],
|
const map: any = mapRef.current;
|
||||||
{
|
if (!mapReady || !map || !containerRef.current) return [];
|
||||||
// id маркера — для списка кластера
|
void viewportTick; // пересчёт при движении карты
|
||||||
gpsMarkerId: m.id,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
// Попап по клику (balloon) — для точек трека (время/скорость/курс)
|
const ymaps = (window as any).ymaps;
|
||||||
...(m.popup ? { balloonContent: m.popup } : {}),
|
const projection = map.options.get('projection');
|
||||||
},
|
const zoom = map.getZoom();
|
||||||
{
|
const centerGP = map.getGlobalPixelCenter();
|
||||||
iconLayout: ymaps.templateLayoutFactory.createClass(gpsMarkerHtml(m)),
|
const w = containerRef.current.clientWidth;
|
||||||
iconOffset: [-layout.anchorX, -layout.anchorY],
|
const h = containerRef.current.clientHeight;
|
||||||
// Активная область клика: без iconShape у кастомного iconLayout
|
return markers.map((m) => {
|
||||||
// hit-test Яндекса не находит метку — клики уходят в events-pane.
|
// toGlobalPixels принимает [lat, lng]
|
||||||
// Координаты iconShape — ОТ ТОЧКИ ПРИВЯЗКИ (anchor), а не от угла:
|
const gp = projection.toGlobalPixels([m.lat, m.lng], zoom);
|
||||||
// прямоугольник [-anchor .. width-anchor] покрывает весь маркер.
|
return {
|
||||||
iconShape: {
|
m,
|
||||||
type: 'Rectangle',
|
x: gp[0] - centerGP[0] + w / 2,
|
||||||
coordinates: [
|
y: gp[1] - centerGP[1] + h / 2,
|
||||||
[-layout.anchorX, -layout.anchorY],
|
hidden: gp[0] === null || !isFinite(gp[0]),
|
||||||
[layout.width - layout.anchorX, layout.height - layout.anchorY],
|
};
|
||||||
],
|
|
||||||
} as unknown as ymaps.IGeometryJson,
|
|
||||||
...(m.zIndex !== undefined ? { zIndex: m.zIndex } : {}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Клик по маркеру: если задан popup (balloonContent) — открываем его,
|
|
||||||
// onClick вызывается только из элементов внутри balloon (делегирование)
|
|
||||||
if (m.onClick && !m.popup) {
|
|
||||||
placemark.events.add('click', () => {
|
|
||||||
// ДИАГНОСТИКА: убрать после починки
|
|
||||||
console.log('[GPS-DIAG] placemark click', m.id);
|
|
||||||
markersByIdRef.current.get(String(m.id))?.onClick?.();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return placemark;
|
|
||||||
};
|
|
||||||
|
|
||||||
const omObjects: Array<Record<string, unknown>> = [];
|
|
||||||
for (const m of markers) {
|
|
||||||
// Кластеризуемые (объекты/задачи) — в ObjectManager; dot-маркеры
|
|
||||||
// (точки трека, старт/финиш, вершины геозон, плейхед) — в коллекцию
|
|
||||||
const clusterable = ((m.kind === 'vehicle' || m.kind === 'person') || m.clusterable === true) && !m.noCluster;
|
|
||||||
if (clusterable) {
|
|
||||||
const l = gpsMarkerLayout(m);
|
|
||||||
omObjects.push({
|
|
||||||
id: String(m.id),
|
|
||||||
// ObjectManager принимает GeoJSON-подобный формат:
|
|
||||||
// type: 'Feature', geometry { type: 'Point', coordinates: [lng, lat] }
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: { type: 'Point', coordinates: [m.lng, m.lat] },
|
|
||||||
options: {
|
|
||||||
iconLayout: ymaps.templateLayoutFactory.createClass(gpsMarkerHtml(m)),
|
|
||||||
iconOffset: [-l.anchorX, -l.anchorY],
|
|
||||||
// Активная область от точки привязки (anchor) — без неё hit-test
|
|
||||||
// не находит кастомную метку
|
|
||||||
iconShape: {
|
|
||||||
type: 'Rectangle',
|
|
||||||
coordinates: [
|
|
||||||
[-l.anchorX, -l.anchorY],
|
|
||||||
[l.width - l.anchorX, l.height - l.anchorY],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
...(m.zIndex !== undefined ? { zIndex: m.zIndex } : {}),
|
|
||||||
...(m.popup ? { balloonContent: m.popup } : {}),
|
|
||||||
},
|
|
||||||
properties: { gpsMarkerId: m.id },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const placemark = createPlacemark(m);
|
|
||||||
placemarksRef.current.set(String(m.id), placemark);
|
|
||||||
collection.add(placemark);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ДИАГНОСТИКА: убрать после починки — тестовый объект со СТАНДАРТНОЙ
|
|
||||||
// иконкой в центре карты: если он виден, ObjectManager работает, а ломает
|
|
||||||
// рендер наш кастомный iconLayout
|
|
||||||
omObjects.push({
|
|
||||||
id: '__diag_test__',
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: { type: 'Point', coordinates: [mapRef.current ? mapRef.current.getCenter()[1] : 37.6, mapRef.current ? mapRef.current.getCenter()[0] : 55.7] },
|
|
||||||
options: { preset: 'islands#redDotIcon' },
|
|
||||||
properties: {},
|
|
||||||
});
|
});
|
||||||
try {
|
|
||||||
if (omObjects.length > 0) objectManager.add(omObjects);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
console.log('[GPS-DIAG] OM added', omObjects.length, 'objects; getLength=', (objectManager.objects as any).getLength?.());
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[GPS-DIAG] OM add FAILED', err);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dataKey, mapReady]);
|
}, [markers, mapReady, viewportTick]);
|
||||||
|
|
||||||
// Плейхед двигаем напрямую (без removeAll — иначе закрывается balloon)
|
interface RenderItem {
|
||||||
const playhead = markers.find((m) => m.id === 'playhead');
|
key: string;
|
||||||
useEffect(() => {
|
x: number;
|
||||||
if (!mapReady || !playhead) return;
|
y: number;
|
||||||
const pm = placemarksRef.current.get('playhead');
|
zIndex?: number;
|
||||||
if (pm) pm.geometry?.setCoordinates([playhead.lat, playhead.lng]);
|
cluster?: { ids: Array<string | number>; count: number };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
marker?: MarkerPosition;
|
||||||
}, [playhead?.lat, playhead?.lng, mapReady]);
|
}
|
||||||
|
|
||||||
|
const renderItems: RenderItem[] = useMemo(() => {
|
||||||
|
const items: RenderItem[] = [];
|
||||||
|
// Ячейки кластеризации только для кластеризуемых маркеров
|
||||||
|
const cells = new Map<string, MarkerPosition[]>();
|
||||||
|
const singles: MarkerPosition[] = [];
|
||||||
|
for (const p of markerPositions) {
|
||||||
|
if (p.hidden) continue;
|
||||||
|
const clusterable =
|
||||||
|
((p.m.kind === 'vehicle' || p.m.kind === 'person') || p.m.clusterable === true) && !p.m.noCluster;
|
||||||
|
if (!clusterable) {
|
||||||
|
singles.push(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cx = Math.floor(p.x / CLUSTER_GRID);
|
||||||
|
const cy = Math.floor(p.y / CLUSTER_GRID);
|
||||||
|
const key = `${cx}:${cy}`;
|
||||||
|
const cell = cells.get(key);
|
||||||
|
if (cell) cell.push(p);
|
||||||
|
else cells.set(key, [p]);
|
||||||
|
}
|
||||||
|
for (const cell of cells.values()) {
|
||||||
|
if (cell.length === 1) {
|
||||||
|
singles.push(cell[0]);
|
||||||
|
} else {
|
||||||
|
const x = cell.reduce((a, p) => a + p.x, 0) / cell.length;
|
||||||
|
const y = cell.reduce((a, p) => a + p.y, 0) / cell.length;
|
||||||
|
items.push({
|
||||||
|
key: `cl-${cell.map((p) => String(p.m.id)).join('-')}`,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
cluster: { ids: cell.map((p) => p.m.id), count: cell.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of singles) {
|
||||||
|
items.push({ key: String(p.m.id), x: p.x, y: p.y, zIndex: p.m.zIndex, marker: p });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [markerPositions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={{ position: 'relative' }}>
|
<div className={className} style={{ position: 'relative' }}>
|
||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
|
{/* Свой слой маркеров поверх карты: клики — обычные DOM/React-события */}
|
||||||
|
{mapReady && (
|
||||||
|
<div className="absolute inset-0 z-[700] pointer-events-none overflow-hidden">
|
||||||
|
{renderItems.map((it) => {
|
||||||
|
if (it.cluster) {
|
||||||
|
return (
|
||||||
|
// gpsClusterHtml сам центрирует кружок (translate(-50%,-50%) внутри),
|
||||||
|
// обёртка — просто якорь в точке x,y
|
||||||
|
<div
|
||||||
|
key={it.key}
|
||||||
|
className="absolute pointer-events-auto cursor-pointer"
|
||||||
|
style={{ left: it.x, top: it.y }}
|
||||||
|
onClick={() => onClusterClickRef.current?.(it.cluster!.ids)}
|
||||||
|
>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: gpsClusterHtml(it.cluster!.count) }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const p = it.marker!;
|
||||||
|
const layout = gpsMarkerLayout(p.m);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={it.key}
|
||||||
|
className="absolute pointer-events-auto"
|
||||||
|
style={{
|
||||||
|
left: it.x - layout.anchorX,
|
||||||
|
top: it.y - layout.anchorY,
|
||||||
|
zIndex: it.zIndex,
|
||||||
|
}}
|
||||||
|
onClick={() => p.m.onClick?.()}
|
||||||
|
>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: gpsMarkerHtml(p.m) }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!mapReady && (
|
{!mapReady && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
|
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
|||||||
Reference in New Issue
Block a user