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
|
// 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user