Любое приложение с бесконечным двумерным холстом (Figma, Miro, React Flow, tldraw, Excalidraw) сталкивается с необходимостью оперировать одновременно как минимум тремя независимыми координатными пространствами:
clientX, clientY). В этой системе генерируются все события указателя (Pointer Events).getBoundingClientRect().Ошибки в согласовании этих координатных систем приводят к критическим багам UX:
Математическим фундаментом Infinite Canvas является теория аффинных преобразований.
В двухмерном пространстве матрица аффинных трансформаций с однородными координатами представляется в виде:
[ x_screen ] [ a c e ] [ x_world ]
[ y_screen ] = [ b d f ] * [ y_world ]
[ 1 ] [ 0 0 1 ] [ 1 ]Для стандартного холста без вращения и скоса (где a = d = zoom, b = c = 0, e = tx, f = ty) прямое проецирование сводится к формуле:
x_screen = x_world * zoom + tx
y_screen = y_world * zoom + tyСВЯЗЬ КООРДИНАТНЫХ ПРОСТРАНСТВ:
┌────────────────────────────────────────────────────────────────────────┐
│ SCREEN SPACE (События мыши в окне браузера: event.clientX, clientY) │
└────────────────────────────────────────────────────────────────────────┘
│
Вычитание rect.left, rect.top (Bounding Box)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ CONTAINER SPACE (Координаты внутри div.react-flow: px, py) │
└────────────────────────────────────────────────────────────────────────┘
│
ОБРАТНАЯ МАТРИЦА: [ (px - tx) / zoom, (py - ty) / zoom ]
▼
┌────────────────────────────────────────────────────────────────────────┐
│ WORLD / CANVAS SPACE (Истинные координаты нод в памяти: x, y) │
└────────────────────────────────────────────────────────────────────────┘
│
ПРЯМАЯ МАТРИЦА МИНИКАРТЫ: [ x * miniScale, y * miniScale ]
▼
┌────────────────────────────────────────────────────────────────────────┐
│ MINIMAP SPACE (Уменьшенная проекция в углу экрана) │
└────────────────────────────────────────────────────────────────────────┘Естественное для пользователя масштабирование требует, чтобы точка холста, находящаяся непосредственно под курсором мыши, оставалась неподвижной относительно экрана во время изменения зума.
Пусть курсор мыши находится в экранной точке P = (x_s, y_s), текущий масштаб равен z_1, а смещение холста равно (t_x1, t_y1).
x_w = (x_s - t_x1) / z_1, y_w = (y_s - t_y1) / z_1z_2 экранная координата этой же мировой точки x_w должна остаться равной x_s:x_s = x_w * z_2 + t_x2(t_x2, t_y2):t_x2 = x_s - x_w * z_2 = x_s - ((x_s - t_x1) / z_1) * z_2
t_y2 = y_s - y_w * z_2 = y_s - ((y_s - t_y1) / z_1) * z_2Эта формула гарантирует идеальную плавность приближения: холст расширяется точно из-под курсора мыши.
ГЕОМЕТРИЯ ZOOM TO PIVOT POINT:
Курсор мыши P (Экранный инвариант)
● (xs, ys)
╱ ╲
╱ ╲ Масштабирование от z1 к z2
▼ ▼
┌───────┐ ┌─────────────┐
│ x_w │ │ x_w │ Мировая координата x_w под курсором
└───────┘ └─────────────┘ остается строго под точкой P (xs, ys)
Зум 1.0 Зум 2.0На современных ноутбуках (MacBook, Windows Precision Touchpad) пользователь взаимодействует с холстом несколькими способами через единое событие wheel:
wheel со значениями deltaX и deltaY, при этом флаг event.ctrlKey === false. Холст должен смещаться: tx -= deltaX, ty -= deltaY.wheel с зажатым флагом event.ctrlKey === true. Значение deltaY отражает скорость сведения/разведения пальцев. Холст должен зумироваться вокруг точки касания.deltaY (обычно около 100 пикселей).T1 и T2. Расстояние между пальцами определяет масштаб, а средняя точка (T1 + T2) / 2 определяет центр панорамирования.Слушатель перехватывает событие с опцией { passive: false }, блокируя нативное масштабирование страницы через e.preventDefault().
Координаты clientX/clientY вычитают границы холста container.getBoundingClientRect(), формируя точку опоры (pivot).
Формула инварианта вычисляет обновленные tx, ty и clamp(zoom, minZoom, maxZoom) без создания объектов в куче.
CSS transform: matrix(zoom, 0, 0, zoom, tx, ty) применяется к DOM-контейнеру с аппаратным ускорением GPU.
Текущий зум холста равен 1.0, смещение камеры tx = 0. Курсор мыши находится в экранной координате xs = 400px. Пользователь крутит колесико мыши, увеличивая зум до 2.0. Каким должно стать новое смещение камеры tx, чтобы точка под курсором осталась неподвижной?
Инженер манипулирует векторными компонентами. При масштабировании от общего плана макета (zoom 0.05) до пиксельной сетки иконок (zoom 32.0) математика инварианта гарантирует, что точка фокуса взгляда под курсором не смещается ни на один пиксель.
Навигационные сервисы проецируют сферические координаты Меркатора в экранные пиксели. Двумерные аффинные трансформации обеспечивают плавное панорамирование карты с инерционным затуханием кинетической энергии жеста.
Чертежи зданий и механических узлов требуют субмиллиметровой точности. Привязка объектов (Object Snapping к сетке и узлам) выполняется в пространстве World Space, полностью абстрагируясь от того, на каком мониторе и при каком зуме открыт проект.
Двусторонняя синхронизация: перетаскивание узлов на основном холсте мгновенно обновляет проекцию миникарты, а клик или драг прямоугольника вьюпорта на миникарте мгновенно панорамирует главный экран.
Создадим математическое ядро бесконечного холста на TypeScript:
TransformMatrix на базе нативного DOMMatrix.export interface Point2D {
x: number;
y: number;
}
export interface ViewportTransform {
x: number;
y: number;
zoom: number;
}
export class InfiniteCanvasMatrix {
private matrix: DOMMatrix;
constructor(x = 0, y = 0, zoom = 1) {
// DOMMatrix представляет матрицу 3x3: [a, b, c, d, e, f]
// a=zoom, b=0, c=0, d=zoom, e=x, f=y
this.matrix = new DOMMatrix([zoom, 0, 0, zoom, x, y]);
}
public get x(): number {
return this.matrix.e;
}
public get y(): number {
return this.matrix.f;
}
public get zoom(): number {
return this.matrix.a;
}
// Прямое проецирование: Canvas World Space ──► Screen/Container Space
public toScreen(worldPoint: Point2D): Point2D {
const domPoint = new DOMPoint(worldPoint.x, worldPoint.y);
const transformed = domPoint.matrixTransform(this.matrix);
return { x: transformed.x, y: transformed.y };
}
// Обратное проецирование: Screen/Container Space ──► Canvas World Space
public toWorld(screenPoint: Point2D): Point2D {
// Вычисляем инверсную матрицу трансформации
const inverse = this.matrix.inverse();
const domPoint = new DOMPoint(screenPoint.x, screenPoint.y);
const transformed = domPoint.matrixTransform(inverse);
return { x: transformed.x, y: transformed.y };
}
// Смещение камеры (Pan)
public translate(dx: number, dy: number): void {
this.matrix.e += dx;
this.matrix.f += dy;
}
// Масштабирование с сохранением инварианта точки курсора (Zoom to Pivot)
public zoomToPoint(targetZoom: number, pivotScreen: Point2D): void {
const clampedZoom = Math.min(Math.max(targetZoom, 0.05), 20.0);
const currentZoom = this.matrix.a;
if (clampedZoom === currentZoom) return;
// 1. Находим мировую точку под курсором ДО масштабирования
const worldPivot = this.toWorld(pivotScreen);
// 2. Устанавливаем новый зум
this.matrix.a = clampedZoom;
this.matrix.d = clampedZoom;
// 3. Пересчитываем смещение так, чтобы worldPivot остался в pivotScreen
this.matrix.e = pivotScreen.x - worldPivot.x * clampedZoom;
this.matrix.f = pivotScreen.y - worldPivot.y * clampedZoom;
}
// Получение CSS строки для аппаратного ускорения композитинга
public toCSS(): string {
return `matrix(${this.matrix.a}, ${this.matrix.b}, ${this.matrix.c}, ${this.matrix.d}, ${this.matrix.e}, ${this.matrix.f})`;
}
public getTransform(): ViewportTransform {
return { x: this.matrix.e, y: this.matrix.f, zoom: this.matrix.a };
}
}import { useRef, useEffect, useState, useCallback } from 'react';
import { InfiniteCanvasMatrix, type ViewportTransform, type Point2D } from '@/lib/TransformMatrix';
interface GestureOptions {
minZoom?: number;
maxZoom?: number;
zoomSpeed?: number;
}
export function useCanvasGestures(options: GestureOptions = {}) {
const { zoomSpeed = 0.0015 } = options;
const containerRef = useRef<HTMLDivElement | null>(null);
const matrixRef = useRef<InfiniteCanvasMatrix>(new InfiniteCanvasMatrix(0, 0, 1));
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, zoom: 1 });
const isDraggingRef = useRef(false);
const lastMousePosRef = useRef<Point2D>({ x: 0, y: 0 });
// Функция принудительного обновления стейта React
const syncViewport = useCallback(() => {
setViewport(matrixRef.current.getTransform());
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// 1. Обработка колесика мыши и pinch-to-zoom жестов трекпада
const handleWheel = (event: WheelEvent) => {
event.preventDefault(); // Блокируем зум всей страницы браузера
const rect = container.getBoundingClientRect();
const pivotScreen: Point2D = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (event.ctrlKey) {
// Жест Pinch-to-zoom на трекпаде или зум колесиком с Ctrl
const zoomDelta = -event.deltaY * zoomSpeed * 2;
const targetZoom = matrixRef.current.zoom * Math.exp(zoomDelta);
matrixRef.current.zoomToPoint(targetZoom, pivotScreen);
} else {
// Обычный 2D-скролл трекпадом или колесико
matrixRef.current.translate(-event.deltaX, -event.deltaY);
}
syncViewport();
};
// 2. Панорамирование зажатой средней кнопкой мыши (или ЛКМ по фону)
const handleMouseDown = (event: MouseEvent) => {
// Клик средней кнопкой мыши (колесико) или клик с зажатым Space
if (event.button === 1 || event.button === 0) {
isDraggingRef.current = true;
lastMousePosRef.current = { x: event.clientX, y: event.clientY };
}
};
const handleMouseMove = (event: MouseEvent) => {
if (!isDraggingRef.current) return;
const dx = event.clientX - lastMousePosRef.current.x;
const dy = event.clientY - lastMousePosRef.current.y;
lastMousePosRef.current = { x: event.clientX, y: event.clientY };
matrixRef.current.translate(dx, dy);
syncViewport();
};
const handleMouseUp = () => {
isDraggingRef.current = false;
};
container.addEventListener('wheel', handleWheel, { passive: false });
container.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [syncViewport, zoomSpeed]);
return {
containerRef,
viewport,
matrix: matrixRef.current,
};
}Миникарта рендерит уменьшенную копию всех узлов и отображает полупрозрачный прямоугольник видоискателя (Viewport Rectangle). Перетаскивание этого видоискателя перемещает главный холст.
import React, { useRef, useCallback } from 'react';
import type { ViewportTransform } from '@/lib/TransformMatrix';
interface MinimapProps {
nodes: Array<{ id: string; x: number; y: number; width: number; height: number }>;
viewport: ViewportTransform;
containerWidth: number;
containerHeight: number;
onPanTo: (worldX: number, worldY: number) => void;
}
export function InteractiveMinimap({
nodes,
viewport,
containerWidth,
containerHeight,
onPanTo,
}: MinimapProps) {
const mapWidth = 220;
const mapHeight = 150;
const minimapRef = useRef<HTMLDivElement | null>(null);
// Вычисляем охватывающий прямоугольник (Bounding Box) всего графа
const bounds = nodes.reduce(
(acc, node) => ({
minX: Math.min(acc.minX, node.x),
minY: Math.min(acc.minY, node.y),
maxX: Math.max(acc.maxX, node.x + node.width),
maxY: Math.max(acc.maxY, node.y + node.height),
}),
{ minX: -500, minY: -500, maxX: 1500, maxY: 1500 }
);
const worldWidth = bounds.maxX - bounds.minX;
const worldHeight = bounds.maxY - bounds.minY;
// Масштабный коэффициент сжатия мира в окно миникарты
const scale = Math.min(mapWidth / worldWidth, mapHeight / worldHeight);
// Вычисляем мировые границы текущей камеры
const cameraWorldX = (0 - viewport.x) / viewport.zoom;
const cameraWorldY = (0 - viewport.y) / viewport.zoom;
const cameraWorldW = containerWidth / viewport.zoom;
const cameraWorldH = containerHeight / viewport.zoom;
// Проекция прямоугольника камеры на миникарту
const viewRectX = (cameraWorldX - bounds.minX) * scale;
const viewRectY = (cameraWorldY - bounds.minY) * scale;
const viewRectW = cameraWorldW * scale;
const viewRectH = cameraWorldH * scale;
// Клик по миникарте центрирует камеру на выбранной точке
const handleMinimapClick = useCallback(
(event: React.MouseEvent) => {
const rect = minimapRef.current?.getBoundingClientRect();
if (!rect) return;
const clickX = event.clientX - rect.left;
const clickY = event.clientY - rect.top;
// Переводим пиксели миникарты обратно в мировые координаты графа
const targetWorldX = bounds.minX + clickX / scale;
const targetWorldY = bounds.minY + clickY / scale;
onPanTo(targetWorldX, targetWorldY);
},
[bounds, scale, onPanTo]
);
return (
<div
ref={minimapRef}
onClick={handleMinimapClick}
style={{ width: mapWidth, height: mapHeight }}
className="absolute bottom-4 right-4 cursor-crosshair overflow-hidden rounded-lg border border-border-subtle bg-surface-overlay/90 shadow-2xl backdrop-blur-md"
>
{/* Проекции всех узлов */}
{nodes.map((node) => {
const nx = (node.x - bounds.minX) * scale;
const ny = (node.y - bounds.minY) * scale;
const nw = Math.max(node.width * scale, 3);
const nh = Math.max(node.height * scale, 3);
return (
<div
key={node.id}
style={{
transform: `translate(${nx}px, ${ny}px)`,
width: nw,
height: nh,
}}
className="absolute left-0 top-0 rounded-sm bg-accent-blue/50"
/>
);
})}
{/* Рамка текущего видоискателя камеры */}
<div
style={{
transform: `translate(${viewRectX}px, ${viewRectY}px)`,
width: Math.max(viewRectW, 10),
height: Math.max(viewRectH, 10),
}}
className="pointer-events-none absolute left-0 top-0 border-2 border-accent-cyan bg-accent-cyan/15 transition-all duration-75"
/>
</div>
);
}import React, { useCallback } from 'react';
import { useCanvasGestures } from '@/hooks/useCanvasGestures';
import { InteractiveMinimap } from './InteractiveMinimap';
const demoNodes = [
{ id: '1', x: 100, y: 150, width: 160, height: 80, label: 'Генератор событий' },
{ id: '2', x: 450, y: 220, width: 180, height: 90, label: 'Потоковый фильтр' },
{ id: '3', x: 800, y: 120, width: 200, height: 100, label: 'Хранилище ClickHouse' },
];
export function CustomInfiniteCanvas() {
const { containerRef, viewport, matrix } = useCanvasGestures();
const handlePanToWorldPoint = useCallback(
(targetWorldX: number, targetWorldY: number) => {
const container = containerRef.current;
if (!container) return;
// Центрируем камеру так, чтобы targetWorld оказался по центру экрана
const halfW = container.clientWidth / 2;
const halfH = container.clientHeight / 2;
const newTx = halfW - targetWorldX * viewport.zoom;
const newTy = halfH - targetWorldY * viewport.zoom;
matrix.translate(newTx - matrix.x, newTy - matrix.y);
// Срабатывает слушатель
},
[matrix, viewport.zoom, containerRef]
);
return (
<div
ref={containerRef}
className="relative h-screen w-full select-none overflow-hidden bg-surface-background cursor-grab active:cursor-grabbing"
>
{/* Слой трансформации с аппаратным ускорением GPU */}
<div
style={{
transform: matrix.toCSS(),
transformOrigin: '0 0',
willChange: 'transform',
}}
className="absolute inset-0"
>
{/* Рендеринг узлов в пространстве World Space */}
{demoNodes.map((node) => (
<div
key={node.id}
style={{
transform: `translate3d(${node.x}px, ${node.y}px, 0)`,
width: node.width,
height: node.height,
}}
className="absolute left-0 top-0 flex items-center justify-center rounded-xl border border-border-subtle bg-surface-card p-3 shadow-xl backdrop-blur-sm"
>
<span className="font-mono text-xs font-semibold text-text-primary">
{node.label}
</span>
</div>
))}
</div>
{/* Интерактивная миникарта с двусторонней связью */}
<InteractiveMinimap
nodes={demoNodes}
viewport={viewport}
containerWidth={window.innerWidth}
containerHeight={window.innerHeight}
onPanTo={handlePanToWorldPoint}
/>
</div>
);
}Событие указателя (Pointer / Wheel Event) считывает clientX и clientY в пикселях браузера.
Вычитаются смещения родительского контейнера getBoundingClientRect() для нахождения локальной точки P.
Формула P_world = (P_screen - T) / Zoom проецирует точку курсора в абсолютные координаты декартовой плоскости.
Результирующая матрица передается в CSS transform: matrix(a,b,c,d,e,f), выполняя рендеринг через GPU Compositor без Layout Reflow.
Миникарта имеет ширину 200px, а весь граф занимает область шириной 4 000px (bounds.minX = 0). Пользователь кликает в точку x = 50px на миникарте. В какую мировую координату World X должна быть спозиционирована целевая точка главного холста?
Попытка обновлять масштаб простым умножением зума без коррекции смещения tx и ty:
// ❌ АНТИПАТТЕРН: зум от центра экрана, а не от курсора
const handleZoom = (delta: number) => {
setZoom((prev) => prev * delta); // Точка под курсором улетает в сторону!
};Пользователь целится курсором в конкретный узел, начинает крутить колесико мыши, и узел мгновенно улетает за пределы видимости.
Решение: Всегда пересчитывайте смещение по формуле инварианта: tx_new = xs - xw * z_new.
По умолчанию в современных браузерах слушатели событий колесика мыши помечаются как пассивные (passive: true). Вызов event.preventDefault() внутри пассивного слушателя игнорируется браузером, и при попытке сделать pinch-to-zoom на трекпаде масштабируется весь сайт целиком, ломая верстку страницы.
Решение: Обязательно передавайте флаг: container.addEventListener('wheel', handler, { passive: false }).
Если в CSS не указать transform-origin: 0 0 (или top left), браузер применит значение по умолчанию transform-origin: 50% 50%. Это добавит скрытое смещение на половину ширины и высоты контейнера при каждом изменении зума, что полностью разрушит соответствие между экранными и мировыми координатами.
Вызов new DOMMatrix() внутри слушателя mousemove 120 раз в секунду нагружает сборщик мусора (Garbage Collector), вызывая микрофризы анимации.
Решение: Мутируйте внутренние свойства матрицы (matrix.e += dx, matrix.f += dy) либо храните вектор [tx, ty, zoom] в мутабельном ref-хранилище.