fix(gps): плавное движение маркеров при зуме — позиции обновляются напрямую в DOM на actiontick (без React-рендера), кластеры перестраиваются на actionend/zoomchange

This commit is contained in:
2026-09-24 21:36:48 +03:00
parent 69039e14a1
commit c0b8910094

View File

@@ -64,8 +64,6 @@ export function Yandex21Map({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const collectionRef = useRef<any | null>(null); const collectionRef = useRef<any | null>(null);
const [mapReady, setMapReady] = useState(false); const [mapReady, setMapReady] = useState(false);
// Тик пересчёта позиций маркеров (панорамирование/зум)
const [viewportTick, setViewportTick] = useState(0);
const onReadyRef = useRef(onReady); const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
const onMapClickRef = useRef(onMapClick); const onMapClickRef = useRef(onMapClick);
@@ -113,11 +111,12 @@ export function Yandex21Map({
const coords = e.get('coords') as [number, number]; const coords = e.get('coords') as [number, number];
onMapContextMenuRef.current(coords[0], coords[1]); onMapContextMenuRef.current(coords[0], coords[1]);
}); });
// Пересчёт позиций маркеров при любом движении карты // Позиции маркеров — напрямую в DOM каждый кадр (плавно, без лага);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // структура (кластеры) — только на завершении действия и смене зума
const bump = () => setViewportTick((t) => t + 1); map.events.add('actiontick', () => applyPositionsRef.current());
map.events.add('boundschange', bump); map.events.add('boundschange', () => applyPositionsRef.current());
map.events.add('actiontick', bump); map.events.add('actionend', () => rebuildItemsRef.current());
map.events.add('zoomchange', () => rebuildItemsRef.current());
const collection = new ymaps.GeoObjectCollection(); const collection = new ymaps.GeoObjectCollection();
map.geoObjects.add(collection); map.geoObjects.add(collection);
@@ -192,117 +191,153 @@ export function Yandex21Map({
}, [featuresKey, mapReady]); }, [featuresKey, mapReady]);
// === Свой DOM-слой маркеров === // === Свой DOM-слой маркеров ===
// Позиции считаем через проекцию карты: гео → глобальные пиксели → пиксели // Позиции обновляются НАПРЯМУЮ В DOM на каждый кадр анимации (actiontick),
// контейнера. Кластеризация — простая пиксельная сетка (как у машин). // минуя React — без этого слой отставал от карты при зуме.
const markerPositions: MarkerPosition[] = useMemo(() => { // React отвечает только за структуру (кластеры/маркеры как элементы),
// перестраиваемую при смене данных и на actionend/zoomchange.
interface RenderItemGeo {
key: string;
lat: number;
lng: number;
zIndex?: number;
cluster?: { ids: Array<string | number>; count: number };
marker?: GpsMapMarker;
}
const itemDivRefs = useRef(new Map<string, HTMLDivElement>());
const [renderItems, setRenderItems] = useState<RenderItemGeo[]>([]);
// Проекция гео → пиксели контейнера (по текущему состоянию карты)
const projectPoint = (lat: number, lng: number): [number, number] | null => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const map: any = mapRef.current; const map: any = mapRef.current;
if (!mapReady || !map || !containerRef.current) return []; if (!map || !containerRef.current) return null;
void viewportTick; // пересчёт при движении карты
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ymaps = (window as any).ymaps;
const projection = map.options.get('projection'); const projection = map.options.get('projection');
const zoom = map.getZoom(); const gp = projection.toGlobalPixels([lat, lng], map.getZoom());
const centerGP = map.getGlobalPixelCenter(); const centerGP = map.getGlobalPixelCenter();
const w = containerRef.current.clientWidth; const w = containerRef.current.clientWidth;
const h = containerRef.current.clientHeight; const h = containerRef.current.clientHeight;
return markers.map((m) => { return [gp[0] - centerGP[0] + w / 2, gp[1] - centerGP[1] + h / 2];
// 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]);
interface RenderItem { // Быстрое обновление позиций без React — вызывается на actiontick
key: string; const applyPositionsRef = useRef<() => void>(() => {});
x: number; applyPositionsRef.current = () => {
y: number; for (const it of renderItemsRef.current) {
zIndex?: number; const div = itemDivRefs.current.get(it.key);
cluster?: { ids: Array<string | number>; count: number }; if (!div) continue;
marker?: MarkerPosition; 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<RenderItemGeo[]>([]);
renderItemsRef.current = renderItems;
const renderItems: RenderItem[] = useMemo(() => { // Перестройка структуры (кластеризация на текущем зуме)
const items: RenderItem[] = []; const rebuildItemsRef = useRef<() => void>(() => {});
// Ячейки кластеризации только для кластеризуемых маркеров rebuildItemsRef.current = () => {
const cells = new Map<string, MarkerPosition[]>(); const items: RenderItemGeo[] = [];
const singles: MarkerPosition[] = []; const cells = new Map<string, Array<{ m: GpsMapMarker; x: number; y: number }>>();
for (const p of markerPositions) { const singles: GpsMapMarker[] = [];
if (p.hidden) continue; for (const m of markersRef.current) {
const pt = projectPoint(m.lat, m.lng);
const clusterable = const clusterable =
((p.m.kind === 'vehicle' || p.m.kind === 'person') || p.m.clusterable === true) && !p.m.noCluster; ((m.kind === 'vehicle' || m.kind === 'person') || m.clusterable === true) && !m.noCluster;
if (!clusterable) { if (!clusterable || !pt) {
singles.push(p); singles.push(m);
continue; continue;
} }
const cx = Math.floor(p.x / CLUSTER_GRID); const key = `${Math.floor(pt[0] / CLUSTER_GRID)}:${Math.floor(pt[1] / CLUSTER_GRID)}`;
const cy = Math.floor(p.y / CLUSTER_GRID);
const key = `${cx}:${cy}`;
const cell = cells.get(key); const cell = cells.get(key);
if (cell) cell.push(p); if (cell) cell.push({ m, x: pt[0], y: pt[1] });
else cells.set(key, [p]); else cells.set(key, [{ m, x: pt[0], y: pt[1] }]);
} }
for (const cell of cells.values()) { for (const cell of cells.values()) {
if (cell.length === 1) { if (cell.length === 1) {
singles.push(cell[0]); singles.push(cell[0].m);
} else { } 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({ items.push({
key: `cl-${cell.map((p) => String(p.m.id)).join('-')}`, key: `cl-${cell.map((c) => String(c.m.id)).join('-')}`,
x, lat,
y, lng,
cluster: { ids: cell.map((p) => p.m.id), count: cell.length }, cluster: { ids: cell.map((c) => c.m.id), count: cell.length },
}); });
} }
} }
for (const p of singles) { for (const m of singles) {
items.push({ key: String(p.m.id), x: p.x, y: p.y, zIndex: p.m.zIndex, marker: p }); items.push({ key: String(m.id), lat: m.lat, lng: m.lng, zIndex: m.zIndex, marker: m });
} }
return items; setRenderItems(items);
}, [markerPositions]); };
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 ( return (
<div className={className} style={{ position: 'relative' }}> <div className={className} style={{ position: 'relative' }}>
<div ref={containerRef} className="h-full w-full" /> <div ref={containerRef} className="h-full w-full" />
{/* Свой слой маркеров поверх карты: клики — обычные DOM/React-события */}
{mapReady && ( {mapReady && (
<div className="absolute inset-0 z-[700] pointer-events-none overflow-hidden"> <div className="absolute inset-0 z-[700] pointer-events-none overflow-hidden">
{renderItems.map((it) => { {renderItems.map((it) => {
if (it.cluster) { if (it.cluster) {
return ( return (
// gpsClusterHtml сам центрирует кружок (translate(-50%,-50%) внутри),
// обёртка — просто якорь в точке x,y
<div <div
key={it.key} key={it.key}
ref={(el) => {
if (el) itemDivRefs.current.set(it.key, el);
else itemDivRefs.current.delete(it.key);
}}
className="absolute pointer-events-auto cursor-pointer" className="absolute pointer-events-auto cursor-pointer"
style={{ left: it.x, top: it.y }}
onClick={() => onClusterClickRef.current?.(it.cluster!.ids)} onClick={() => onClusterClickRef.current?.(it.cluster!.ids)}
> >
<div dangerouslySetInnerHTML={{ __html: gpsClusterHtml(it.cluster!.count) }} /> <div dangerouslySetInnerHTML={{ __html: gpsClusterHtml(it.cluster!.count) }} />
</div> </div>
); );
} }
const p = it.marker!; const m = it.marker!;
const layout = gpsMarkerLayout(p.m); const layout = gpsMarkerLayout(m);
return ( return (
<div <div
key={it.key} key={it.key}
ref={(el) => {
if (el) itemDivRefs.current.set(it.key, el);
else itemDivRefs.current.delete(it.key);
}}
className="absolute pointer-events-auto" className="absolute pointer-events-auto"
style={{ style={{
left: it.x - layout.anchorX, marginLeft: -layout.anchorX,
top: it.y - layout.anchorY, marginTop: -layout.anchorY,
zIndex: it.zIndex, zIndex: it.zIndex,
}} }}
onClick={() => p.m.onClick?.()} onClick={() => m.onClick?.()}
> >
<div dangerouslySetInnerHTML={{ __html: gpsMarkerHtml(p.m) }} /> <div dangerouslySetInnerHTML={{ __html: gpsMarkerHtml(m) }} />
</div> </div>
); );
})} })}