diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index e6881ea..60fa363 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -55,7 +55,7 @@ export function Yandex21Map({ const containerRef = useRef(null); const mapRef = useRef(null); const collectionRef = useRef(null); - const clustererRef = useRef(null); + const objectManagerRef = useRef(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> = []; 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]);