From c0b8910094eb4d0d988ba79503abcd73e751ac68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Thu, 24 Sep 2026 21:36:48 +0300 Subject: [PATCH] =?UTF-8?q?fix(gps):=20=D0=BF=D0=BB=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B5=D1=80=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B7=D1=83=D0=BC=D0=B5=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D1=8F=D1=8E=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D1=8F=D0=BC=D1=83=D1=8E=20=D0=B2=20DOM=20=D0=BD=D0=B0=20?= =?UTF-8?q?actiontick=20(=D0=B1=D0=B5=D0=B7=20React-=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B0),=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B8=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=20actionend/zoomchange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/gps/Yandex21Map.tsx | 177 +++++++++++++--------- 1 file changed, 106 insertions(+), 71 deletions(-) diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index 1e61b4c..1a2b92f 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -64,8 +64,6 @@ export function Yandex21Map({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const collectionRef = useRef(null); const [mapReady, setMapReady] = useState(false); - // Тик пересчёта позиций маркеров (панорамирование/зум) - const [viewportTick, setViewportTick] = useState(0); const onReadyRef = useRef(onReady); const onErrorRef = useRef(onError); const onMapClickRef = useRef(onMapClick); @@ -113,11 +111,12 @@ export function Yandex21Map({ 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); + // Позиции маркеров — напрямую в DOM каждый кадр (плавно, без лага); + // структура (кластеры) — только на завершении действия и смене зума + map.events.add('actiontick', () => applyPositionsRef.current()); + map.events.add('boundschange', () => applyPositionsRef.current()); + map.events.add('actionend', () => rebuildItemsRef.current()); + map.events.add('zoomchange', () => rebuildItemsRef.current()); const collection = new ymaps.GeoObjectCollection(); map.geoObjects.add(collection); @@ -192,117 +191,153 @@ export function Yandex21Map({ }, [featuresKey, mapReady]); // === Свой DOM-слой маркеров === - // Позиции считаем через проекцию карты: гео → глобальные пиксели → пиксели - // контейнера. Кластеризация — простая пиксельная сетка (как у машин). - const markerPositions: MarkerPosition[] = useMemo(() => { + // Позиции обновляются НАПРЯМУЮ В DOM на каждый кадр анимации (actiontick), + // минуя React — без этого слой отставал от карты при зуме. + // React отвечает только за структуру (кластеры/маркеры как элементы), + // перестраиваемую при смене данных и на actionend/zoomchange. + + interface RenderItemGeo { + key: string; + lat: number; + lng: number; + zIndex?: number; + cluster?: { ids: Array; count: number }; + marker?: GpsMapMarker; + } + + const itemDivRefs = useRef(new Map()); + const [renderItems, setRenderItems] = useState([]); + + // Проекция гео → пиксели контейнера (по текущему состоянию карты) + const projectPoint = (lat: number, lng: number): [number, number] | null => { // 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; + if (!map || !containerRef.current) return null; const projection = map.options.get('projection'); - const zoom = map.getZoom(); + const gp = projection.toGlobalPixels([lat, lng], 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]), - }; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [markers, mapReady, viewportTick]); + return [gp[0] - centerGP[0] + w / 2, gp[1] - centerGP[1] + h / 2]; + }; - interface RenderItem { - key: string; - x: number; - y: number; - zIndex?: number; - cluster?: { ids: Array; count: number }; - marker?: MarkerPosition; - } + // Быстрое обновление позиций без React — вызывается на actiontick + const applyPositionsRef = useRef<() => void>(() => {}); + applyPositionsRef.current = () => { + for (const it of renderItemsRef.current) { + const div = itemDivRefs.current.get(it.key); + if (!div) continue; + const pt = projectPoint(it.lat, it.lng); + if (!pt) continue; + div.style.left = `${pt[0]}px`; + div.style.top = `${pt[1]}px`; + } + }; + const renderItemsRef = useRef([]); + renderItemsRef.current = renderItems; - const renderItems: RenderItem[] = useMemo(() => { - const items: RenderItem[] = []; - // Ячейки кластеризации только для кластеризуемых маркеров - const cells = new Map(); - const singles: MarkerPosition[] = []; - for (const p of markerPositions) { - if (p.hidden) continue; + // Перестройка структуры (кластеризация на текущем зуме) + const rebuildItemsRef = useRef<() => void>(() => {}); + rebuildItemsRef.current = () => { + const items: RenderItemGeo[] = []; + const cells = new Map>(); + const singles: GpsMapMarker[] = []; + for (const m of markersRef.current) { + const pt = projectPoint(m.lat, m.lng); const clusterable = - ((p.m.kind === 'vehicle' || p.m.kind === 'person') || p.m.clusterable === true) && !p.m.noCluster; - if (!clusterable) { - singles.push(p); + ((m.kind === 'vehicle' || m.kind === 'person') || m.clusterable === true) && !m.noCluster; + if (!clusterable || !pt) { + singles.push(m); continue; } - const cx = Math.floor(p.x / CLUSTER_GRID); - const cy = Math.floor(p.y / CLUSTER_GRID); - const key = `${cx}:${cy}`; + const key = `${Math.floor(pt[0] / CLUSTER_GRID)}:${Math.floor(pt[1] / CLUSTER_GRID)}`; const cell = cells.get(key); - if (cell) cell.push(p); - else cells.set(key, [p]); + if (cell) cell.push({ m, x: pt[0], y: pt[1] }); + else cells.set(key, [{ m, x: pt[0], y: pt[1] }]); } for (const cell of cells.values()) { if (cell.length === 1) { - singles.push(cell[0]); + singles.push(cell[0].m); } 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; + // Центроид кластера в гео-координатах — позиция считается от него + // каждый тик (плавное движение без перегруппировки) + const lat = cell.reduce((a, c) => a + c.m.lat, 0) / cell.length; + const lng = cell.reduce((a, c) => a + c.m.lng, 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 }, + key: `cl-${cell.map((c) => String(c.m.id)).join('-')}`, + lat, + lng, + cluster: { ids: cell.map((c) => c.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 }); + for (const m of singles) { + items.push({ key: String(m.id), lat: m.lat, lng: m.lng, zIndex: m.zIndex, marker: m }); } - return items; - }, [markerPositions]); + setRenderItems(items); + }; + const markersRef = useRef(markers); + markersRef.current = markers; + + // Перестройка при смене данных маркеров + const markersKey = JSON.stringify( + markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, m.approximate, !!m.onClick, m.zIndex, m.noCluster, m.clusterable, m.course]) + ); + useEffect(() => { + if (mapReady) { + rebuildItemsRef.current(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [markersKey, mapReady]); + + // После смены структуры — применить позиции (refs уже расставлены React'ом) + useEffect(() => { + const raf = requestAnimationFrame(() => applyPositionsRef.current()); + return () => cancelAnimationFrame(raf); + }, [renderItems]); + + // События карты для позиций/перестройки (навешиваются при создании карты) + // (см. init: actiontick → applyPositions; actionend/zoomchange → rebuildItems) return (
- {/* Свой слой маркеров поверх карты: клики — обычные DOM/React-события */} {mapReady && (
{renderItems.map((it) => { if (it.cluster) { return ( - // gpsClusterHtml сам центрирует кружок (translate(-50%,-50%) внутри), - // обёртка — просто якорь в точке x,y
{ + if (el) itemDivRefs.current.set(it.key, el); + else itemDivRefs.current.delete(it.key); + }} className="absolute pointer-events-auto cursor-pointer" - style={{ left: it.x, top: it.y }} onClick={() => onClusterClickRef.current?.(it.cluster!.ids)} >
); } - const p = it.marker!; - const layout = gpsMarkerLayout(p.m); + const m = it.marker!; + const layout = gpsMarkerLayout(m); return (
{ + if (el) itemDivRefs.current.set(it.key, el); + else itemDivRefs.current.delete(it.key); + }} className="absolute pointer-events-auto" style={{ - left: it.x - layout.anchorX, - top: it.y - layout.anchorY, + marginLeft: -layout.anchorX, + marginTop: -layout.anchorY, zIndex: it.zIndex, }} - onClick={() => p.m.onClick?.()} + onClick={() => m.onClick?.()} > -
+
); })}