fix(gps): маркеры Яндекса — собственный DOM-слой поверх карты (клики React, пиксельная кластеризация); отказ от Clusterer/ObjectManager с их багами кастомных макетов

This commit is contained in:
2026-09-24 16:36:34 +03:00
parent 0409a547fc
commit 69039e14a1

View File

@@ -1,42 +1,48 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap';
import { loadYandexMaps } from './ymaps-loader';
import { gpsClusterHtml, gpsMarkerHtml, gpsMarkerLayout } from './marker-html';
import { loadYandexMaps } from './ymaps-loader';
/**
* Реализация GPS-карты на Яндекс.Картах JS API 2.1 (императивный ymaps,
* тот же стиль, что OsmMap на Leaflet: ref-контейнер, карта создаётся один
* раз, обновления слоёв через эффекты).
* Яндекс.Карты 2.1: подложка + проекция. МАРКЕРЫ РИСУЕМ САМИ поверх карты
* обычным 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 MOSCOW: [number, number] = [55.755864, 37.617698];
// Размер ячейки пиксельной кластеризации маркеров
const CLUSTER_GRID = 60;
interface Yandex21MapProps {
apiKey: string;
/** Центр [lat, lng]; по умолчанию — Москва */
center?: [number, number];
zoom?: number;
/** Границы для автоподгонки [[lat, lng], [lat, lng]]; приоритетнее center/zoom */
bounds?: [[number, number], [number, number]];
markers?: GpsMapMarker[];
polygons?: GpsMapPolygon[];
polylines?: GpsMapPolyline[];
onMapClick?: (lat: number, lng: number) => void;
/** Правый клик по карте (создание объекта с координатами) */
onMapContextMenu?: (lat: number, lng: number) => void;
/** Клик по кластеру — id маркеров (свой список вместо balloon) */
onClusterClick?: (markerIds: Array<string | number>) => void;
/** Карта создана и готова к работе */
onReady?: () => void;
/** Ошибка загрузки скрипта/API (GpsMap использует для fallback на OSM) */
onError?: (message: string) => void;
className?: string;
}
interface MarkerPosition {
m: GpsMapMarker;
x: number;
y: number;
hidden: boolean;
}
export function Yandex21Map({
apiKey,
center,
@@ -46,37 +52,34 @@ export function Yandex21Map({
polygons = [],
polylines = [],
onMapClick,
onMapContextMenu,
onClusterClick,
onReady,
onError,
className, onMapContextMenu,
onClusterClick,
className,
}: Yandex21MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<ymaps.Map | null>(null);
const collectionRef = useRef<ymaps.GeoObjectCollection | null>(null);
const objectManagerRef = useRef<any | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const collectionRef = useRef<any | null>(null);
const [mapReady, setMapReady] = useState(false);
// Колбэки и данные маркеров через ref, чтобы эффекты не перезапускались
// при каждом рендере (react-query рефетчи меняют идентичность массивов)
// Тик пересчёта позиций маркеров (панорамирование/зум)
const [viewportTick, setViewportTick] = useState(0);
const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError);
const onMapClickRef = useRef(onMapClick);
const onMapContextMenuRef = useRef(onMapContextMenu);
const onClusterClickRef = useRef(onClusterClick);
const placemarksRef = useRef(new Map<string, any>());
const markersByIdRef = useRef(new Map<string, GpsMapMarker>());
useEffect(() => {
onReadyRef.current = onReady;
onErrorRef.current = onError;
onMapClickRef.current = onMapClick;
markersByIdRef.current = new Map(markers.map((m) => [String(m.id), m]));
onMapContextMenuRef.current = onMapContextMenu;
onClusterClickRef.current = onClusterClick;
});
// Загрузка скрипта и создание карты (один раз на apiKey)
// Создание карты (один раз)
useEffect(() => {
let cancelled = false;
setMapReady(false);
@@ -84,71 +87,42 @@ export function Yandex21Map({
loadYandexMaps(apiKey)
.then(() => {
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, {
center: center ?? MOSCOW,
zoom,
controls: ['zoomControl'],
}, {
// Отключаем интерактивность POI (кликабельные организации Яндекса):
// хотспоты дёргают services/search/v2 (403 на бесплатном ключе) и
// вмешиваются в обработку клика по нашим маркерам
// Отключаем интерактивность POI: хотспоты дёргают services/search/v2
// (403 на бесплатном ключе) и вмешиваются в обработку кликов
yandexMapDisablePoiInteractivity: 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];
onMapClickRef.current?.(coords[0], coords[1]);
});
// Правый клик — создание объекта с координатами
map.events.add('contextmenu', (e: ymaps.IEvent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.events.add('contextmenu', (e: any) => {
if (!onMapContextMenuRef.current) return;
const coords = e.get('coords') as [number, number];
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();
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;
objectManagerRef.current = objectManager;
setMapReady(true);
onReadyRef.current?.();
})
@@ -165,37 +139,12 @@ export function Yandex21Map({
mapRef.current.destroy();
mapRef.current = null;
collectionRef.current = null;
objectManagerRef.current = null;
}
};
// Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiKey]);
// Делегированный клик по пункту списка кластера в balloon
// (контент 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,
// чтобы рефетчи не сбрасывали позицию, которую двигает пользователь
// Перемещение viewport только при реальном изменении center/zoom/bounds
const viewKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`;
const lastViewKeyRef = useRef<string | null>(null);
useEffect(() => {
@@ -210,159 +159,155 @@ export function Yandex21Map({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewKey, mapReady]);
// Маркеры/полигоны/полилинии: перестраиваем коллекцию при изменении данных
// (объектов немного — removeAll + добавить заново дёшево);
// колбэки кликов не входят в ключ — они читаются из 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]),
// Геозоны и полилинии — штатной коллекцией (рендерятся надёжно)
const featuresKey = JSON.stringify([
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]),
]);
useEffect(() => {
const collection = collectionRef.current;
const objectManager = objectManagerRef.current;
if (!mapReady || !collection || !objectManager) return;
if (!mapReady || !collection) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ymaps = (window as any).ymaps;
collection.removeAll();
objectManager.removeAll();
placemarksRef.current.clear();
for (const p of polygons) {
if (p.points.length < 3) continue;
const color = p.color || DEFAULT_COLOR;
collection.add(
new ymaps.Polygon([p.points], {}, {
fillColor: color,
fillOpacity: p.fillOpacity ?? 0.15,
strokeColor: color,
strokeWidth: p.strokeWidth ?? 2,
})
);
collection.add(new ymaps.Polygon([p.points], {}, {
fillColor: color,
fillOpacity: p.fillOpacity ?? 0.15,
strokeColor: color,
strokeWidth: p.strokeWidth ?? 2,
}));
}
for (const l of polylines) {
if (l.points.length < 2) continue;
collection.add(
new ymaps.Polyline(l.points, {}, {
strokeColor: l.color || DEFAULT_COLOR,
strokeWidth: l.width ?? 4,
strokeStyle: l.dashed ? 'dash' : 'solid',
})
);
collection.add(new ymaps.Polyline(l.points, {}, {
strokeColor: l.color || DEFAULT_COLOR,
strokeWidth: l.width ?? 4,
strokeStyle: l.dashed ? 'dash' : 'solid',
}));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [featuresKey, mapReady]);
const createPlacemark = (m: GpsMapMarker) => {
// Якорь layout: блок фиксированной ширины позиционируется так, чтобы
// точка привязки совпала с координатой (кликабельная область = видимой)
const layout = gpsMarkerLayout(m);
const placemark = new ymaps.Placemark(
[m.lat, m.lng],
{
// id маркера — для списка кластера
gpsMarkerId: m.id,
// Попап по клику (balloon) — для точек трека (время/скорость/курс)
...(m.popup ? { balloonContent: m.popup } : {}),
},
{
iconLayout: ymaps.templateLayoutFactory.createClass(gpsMarkerHtml(m)),
iconOffset: [-layout.anchorX, -layout.anchorY],
// Активная область клика: без iconShape у кастомного iconLayout
// hit-test Яндекса не находит метку — клики уходят в events-pane.
// Координаты iconShape — ОТ ТОЧКИ ПРИВЯЗКИ (anchor), а не от угла:
// прямоугольник [-anchor .. width-anchor] покрывает весь маркер.
iconShape: {
type: 'Rectangle',
coordinates: [
[-layout.anchorX, -layout.anchorY],
[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: {},
// === Свой DOM-слой маркеров ===
// Позиции считаем через проекцию карты: гео → глобальные пиксели → пиксели
// контейнера. Кластеризация — простая пиксельная сетка (как у машин).
const markerPositions: MarkerPosition[] = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const map: any = mapRef.current;
if (!mapReady || !map || !containerRef.current) return [];
void viewportTick; // пересчёт при движении карты
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ymaps = (window as any).ymaps;
const projection = map.options.get('projection');
const zoom = map.getZoom();
const centerGP = map.getGlobalPixelCenter();
const w = containerRef.current.clientWidth;
const h = containerRef.current.clientHeight;
return markers.map((m) => {
// toGlobalPixels принимает [lat, lng]
const gp = projection.toGlobalPixels([m.lat, m.lng], zoom);
return {
m,
x: gp[0] - centerGP[0] + w / 2,
y: gp[1] - centerGP[1] + h / 2,
hidden: gp[0] === null || !isFinite(gp[0]),
};
});
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
}, [dataKey, mapReady]);
}, [markers, mapReady, viewportTick]);
// Плейхед двигаем напрямую (без removeAll — иначе закрывается balloon)
const playhead = markers.find((m) => m.id === 'playhead');
useEffect(() => {
if (!mapReady || !playhead) return;
const pm = placemarksRef.current.get('playhead');
if (pm) pm.geometry?.setCoordinates([playhead.lat, playhead.lng]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playhead?.lat, playhead?.lng, mapReady]);
interface RenderItem {
key: string;
x: number;
y: number;
zIndex?: number;
cluster?: { ids: Array<string | number>; count: number };
marker?: MarkerPosition;
}
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 (
<div className={className} style={{ position: 'relative' }}>
<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 && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />