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 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" />
|
||||
|
||||
Reference in New Issue
Block a user