fix(gps): плавное движение маркеров при зуме — позиции обновляются напрямую в DOM на actiontick (без React-рендера), кластеры перестраиваются на actionend/zoomchange
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user