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
const collectionRef = useRef<any | null>(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<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
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<string | number>; 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<RenderItemGeo[]>([]);
renderItemsRef.current = renderItems;
const renderItems: RenderItem[] = useMemo(() => {
const items: RenderItem[] = [];
// Ячейки кластеризации только для кластеризуемых маркеров
const cells = new Map<string, MarkerPosition[]>();
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<string, Array<{ m: GpsMapMarker; x: number; y: number }>>();
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 (
<div className={className} style={{ position: 'relative' }}>
<div ref={containerRef} className="h-full w-full" />
{/* Свой слой маркеров поверх карты: клики — обычные DOM/React-события */}
{mapReady && (
<div className="absolute inset-0 z-[700] pointer-events-none overflow-hidden">
{renderItems.map((it) => {
if (it.cluster) {
return (
// gpsClusterHtml сам центрирует кружок (translate(-50%,-50%) внутри),
// обёртка — просто якорь в точке x,y
<div
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"
style={{ left: it.x, top: it.y }}
onClick={() => onClusterClickRef.current?.(it.cluster!.ids)}
>
<div dangerouslySetInnerHTML={{ __html: gpsClusterHtml(it.cluster!.count) }} />
</div>
);
}
const p = it.marker!;
const layout = gpsMarkerLayout(p.m);
const m = it.marker!;
const layout = gpsMarkerLayout(m);
return (
<div
key={it.key}
ref={(el) => {
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?.()}
>
<div dangerouslySetInnerHTML={{ __html: gpsMarkerHtml(p.m) }} />
<div dangerouslySetInnerHTML={{ __html: gpsMarkerHtml(m) }} />
</div>
);
})}