diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index 6a47b04..1e61b4c 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -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) => 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(null); - const mapRef = useRef(null); - const collectionRef = useRef(null); - const objectManagerRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mapRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const collectionRef = useRef(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()); - const markersByIdRef = useRef(new Map()); 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 = 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(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> = []; - 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; count: number }; + marker?: MarkerPosition; + } + + const renderItems: RenderItem[] = useMemo(() => { + const items: RenderItem[] = []; + // Ячейки кластеризации только для кластеризуемых маркеров + const cells = new Map(); + 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 (
+ {/* Свой слой маркеров поверх карты: клики — обычные DOM/React-события */} + {mapReady && ( +
+ {renderItems.map((it) => { + if (it.cluster) { + return ( + // gpsClusterHtml сам центрирует кружок (translate(-50%,-50%) внутри), + // обёртка — просто якорь в точке x,y +
onClusterClickRef.current?.(it.cluster!.ids)} + > +
+
+ ); + } + const p = it.marker!; + const layout = gpsMarkerLayout(p.m); + return ( +
p.m.onClick?.()} + > +
+
+ ); + })} +
+ )} {!mapReady && (