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