fix(gps): слой маркеров Яндекса переведён с Clusterer на ObjectManager — кастомные макеты в Clusterer теряли кликабельность (известный баг), ObjectManager лишён этой болезни

This commit is contained in:
2026-09-23 14:33:49 +03:00
parent a231f64837
commit e9f072c0c3

View File

@@ -55,7 +55,7 @@ export function Yandex21Map({
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<ymaps.Map | null>(null);
const collectionRef = useRef<ymaps.GeoObjectCollection | null>(null);
const clustererRef = useRef<ymaps.Clusterer | null>(null);
const objectManagerRef = useRef<any | null>(null);
const [mapReady, setMapReady] = useState(false);
// Колбэки и данные маркеров через ref, чтобы эффекты не перезапускались
@@ -112,42 +112,43 @@ export function Yandex21Map({
const collection = new ymaps.GeoObjectCollection();
map.geoObjects.add(collection);
// Кластер маркеров объектов (vehicle/person): при отдалении
// перекрывающиеся маркеры схлопываются в кружок с числом.
// NB: в @types/yandex-maps IClustererOptions неполный — clusterIconLayout,
// clusterDisableClickZoom и clusterOpenBalloonOnClick существуют
// в рантайме 2.1 (см. документацию), поэтому опции приводим кастом.
const clusterer = new ymaps.Clusterer({
// Слой кластеризуемых маркеров — ObjectManager (рекомендуемый Яндексом
// механизм): в отличие от Clusterer, кастомные iconLayout-маркеры
// в нём НЕ теряют кликабельность (известный баг Clusterer).
const objectManager = new ymaps.ObjectManager({
clusterize: true,
gridSize: 64,
clusterIconLayout: ymaps.templateLayoutFactory.createClass(
gpsClusterHtml('{{ properties.geoObjects.length }}')
),
// Одиночный клик по кластеру — НЕ зум, а наш balloon со списком
// объектов (обработчик ниже); зум — колесом/двойным кликом/контролом
// Одиночный клик по кластеру — НЕ зум, а наш список (обработчик ниже)
clusterDisableClickZoom: true,
clusterOpenBalloonOnClick: false,
} as ymaps.IClustererOptions);
// В типах map.geoObjects.add не принимает Clusterer (он не IGeoObject
// по декларациям, хотя рантайм принимает) — приводим.
map.geoObjects.add(clusterer as unknown as ymaps.IGeoObject);
} as unknown as ymaps.IObjectManagerOptions);
map.geoObjects.add(objectManager as unknown as ymaps.IGeoObject);
// Клик по кластеру → id маркеров в свой список (balloon карты
// в связке с кастомными layout-маркерами ведёт себя ненадёжно)
clusterer.events.add('click', (e: ymaps.IEvent) => {
const target = e.get('target') as {
getGeoObjects?: () => ymaps.IGeoObject[];
} | undefined;
// Клик по обычному маркеру (не кластеру) — у него нет getGeoObjects
if (!target || typeof target.getGeoObjects !== 'function') return;
const ids = target
.getGeoObjects()
.map((g) => (g.properties as unknown as { get(path: string): unknown }).get('gpsMarkerId') as string | number | null)
.filter((id): id is string | number => id != null);
// Клик по маркеру (в т.ч. вне кластера)
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) => {
const cluster = objectManager.clusters.getById(e.get('objectId')) as
| { properties: { geoObjects: Array<{ id?: string | number; properties?: { get(p: string): unknown } }> } }
| undefined;
const ids = cluster
? cluster.properties.geoObjects
.map((o) => (o.id !== undefined ? o.id : (o.properties?.get('gpsMarkerId') as string | number | null)))
.filter((id): id is string | number => id != null)
: [];
if (ids.length > 0) onClusterClickRef.current?.(ids);
});
mapRef.current = map;
collectionRef.current = collection;
clustererRef.current = clusterer;
objectManagerRef.current = objectManager;
setMapReady(true);
onReadyRef.current?.();
})
@@ -164,7 +165,7 @@ export function Yandex21Map({
mapRef.current.destroy();
mapRef.current = null;
collectionRef.current = null;
clustererRef.current = null;
objectManagerRef.current = null;
}
};
// Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект
@@ -223,10 +224,10 @@ export function Yandex21Map({
]);
useEffect(() => {
const collection = collectionRef.current;
const clusterer = clustererRef.current;
if (!mapReady || !collection || !clusterer) return;
const objectManager = objectManagerRef.current;
if (!mapReady || !collection || !objectManager) return;
collection.removeAll();
clusterer.removeAll();
objectManager.removeAll();
placemarksRef.current.clear();
for (const p of polygons) {
@@ -294,18 +295,41 @@ export function Yandex21Map({
return placemark;
};
const omObjects: Array<Record<string, unknown>> = [];
for (const m of markers) {
// Кластерим только объекты (vehicle/person); dot-маркеры и noCluster —
// в обычную коллекцию (точки трека, старт/финиш, вершины геозон)
// Кластеризуемые (объекты/задачи) — в ObjectManager; dot-маркеры
// (точки трека, старт/финиш, вершины геозон, плейхед) — в коллекцию
const clusterable = ((m.kind === 'vehicle' || m.kind === 'person') || m.clusterable === true) && !m.noCluster;
const placemark = createPlacemark(m);
placemarksRef.current.set(String(m.id), placemark);
if (clusterable) {
clusterer.add(placemark);
const l = gpsMarkerLayout(m);
omObjects.push({
id: String(m.id),
type: 'Point',
geometry: [m.lat, m.lng],
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);
}
}
if (omObjects.length > 0) objectManager.add(omObjects);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataKey, mapReady]);