Стандартный подход к рендерингу графов в веб-приложениях базируется на создании отдельных DOM-узлов (HTML <div> для вершин) и векторных путей (SVG <path> для ребер). Для графов малого и среднего масштаба (до 300–500 элементов) эта модель обеспечивает превосходную гибкость: доступен полный спектр CSS-стилизации, событийная модель браузера и доступность (a11y).
Однако при масштабировании топологии до 5 000–50 000 узлов (телекоммуникационные сети, распределенные трейсинг-спаны OpenTelemetry, геномные графы) DOM-подход терпит катастрофический коллапс:
Решение задачи экстремальной производительности строится на трех архитектурных паттернах:
АРХИТЕКТУРА ЭКСТРЕМАЛЬНОЙ ПРОИЗВОДИТЕЛЬНОСТИ ГРАФА:
┌────────────────────────────────────────────────────────────────────────┐
│ ВСЕГО В ПАМЯТИ: 10 000 УЗЛОВ / 20 000 РЕБЕР │
│ Хранятся в виде плоских типизированных буферов (Float32Array / Quadtree)│
├────────────────────────────────────────────────────────────────────────┤
│ VIEWPORT CULLING ФИЛЬТРАЦИЯ (Выполняется по матрице камеры): │
│ │
│ Видимая область (Камера): [x0, y0, x1, y1] с буфером +15% │
│ ───────────────────────────────────────────────────────── │
│ 9 600 узлов ЗА ПРЕДЕЛАМИ ЭКРАНА ──► [ПОЛНОЕ ОТСЕЧЕНИЕ ИЗ DOM] │
│ 400 узлов ВНУТРИ ВЬЮПОРТА ──► [ОТПРАВЛЯЮТСЯ НА РЕНДЕРИНГ] │
├────────────────────────────────────────────────────────────────────────┤
│ РАЗДЕЛЕНИЕ РЕНДЕР-СЛОЕВ: │
│ │
│ 1. Слой ребер (Canvas Overlay): │
│ Один элемент <canvas>, отрисовка 20 000 линий через Path2D │
│ без единого DOM-узла (60 FPS, нативное GPU-ускорение). │
│ │
│ 2. Слой видимых узлов (DOM с динамическим LOD): │
│ ├─ Zoom >= 0.75 ──► LOD 0 (Полный DOM: формы, аватары, кнопки) │
│ ├─ 0.35 <= Zoom < 0.75 ──► LOD 1 (Упрощенный: только текст) │
│ └─ Zoom < 0.35 ──► LOD 2 (Минимальный: плоский цветной бокс) │
└────────────────────────────────────────────────────────────────────────┘Для мгновенного определения видимости узлов без линейного перебора массива $O(N)$ применяется структура данных Quadtree или пространственное хэширование (Spatial Grid).
При панорамировании холста экранные координаты преобразуются в мировое пространство графа с учетом текущего смещения $(t_x, t_y)$ и зума $z$:
x_min = (0 - t_x) / z - delta_buffer, x_max = (W_screen - t_x) / z + delta_buffer
y_min = (0 - t_y) / z - delta_buffer, y_max = (H_screen - t_y) / z + delta_bufferУзел с координатами (x, y), шириной w и высотой h попадает во вьюпорт тогда и только тогда, когда выполняется условие пересечения AABB:
(x + w >= x_min) and (x <= x_max) and (y + h >= y_min) and (y <= y_max)Буферная зона delta_buffer (обычно 100–200 пикселей) исключает появление пустых областей при быстром инерционном скролле до того, как сработает очередной кадр вычислений.
При отдалении камеры (Zoom-out) физический размер узла на экране уменьшается до 10–20 пикселей. Рендерить сложный HTML с полями ввода, SVG-иконками и субпиксельными тенями в этом масштабе бессмысленно — человеческий глаз воспринимает узел как размытую точку, но браузер продолжает оплачивать стоимость стилизации всего поддерева.
Интерфейс узла разделяется на три уровня детализации:
zoom >= 0.75). Отображается весь интерактивный DOM: кнопки, инпуты, порты <Handle>, аватары, бейджи.zoom < 0.35). Узел схлопывается в плоский одноцветный <div> с фиксированным цветом статуса без вложенных DOM-элементов.МАТРИЦА ПЕРЕКЛЮЧЕНИЯ LOD:
Zoom: 1.0 (100%) Zoom: 0.5 (50%) Zoom: 0.2 (20%)
┌──────────────────────┐ ┌─────────────────┐ ┌───────┐
│ [API Service: auth] │ │ [auth-service] │ │ │
│ CPU: 42% RAM: 1.2GB │ ──► │ Status: OK │ ──► │ ● │
│ [Restart] [Metrics] │ └─────────────────┘ └───────┘
│ ● InPort OutPort ●│ LOD 2
└──────────────────────┘ LOD 1 (Flat Dot)
LOD 0 (Simple Text)
(Rich DOM)Хук useOnViewportChange получает матрицу трансформации [x, y, zoom] при панорамировании или зуме с ограничением частоты (RAF Throttle).
По координатам фрустума за O(log N) извлекаются идентификаторы узлов, попадающих в расширенную область экрана.
В зависимости от текущего zoom каждому видимому узлу назначается уровень детализации (LOD 0, 1 или 2).
Координаты связей между видимыми вершинами передаются в единственный элемент <canvas>, отрисовывающий линии за один проход GPU.
Зачем к вычисленному прямоугольнику видимости [x_min, y_min, x_max, y_max] добавляется буферная зона (overscan / padding) размером 150-200 пикселей?
В enterprise-архитектурах сквозной сетевой трейс бизнес-транзакции охватывает тысячи связанных спанов, баз данных, очередей Kafka и кэшей Redis. Визуализация полного графа зависимостей на 15 000 нод требует обязательного Viewport Culling: пользователь видит 40–50 сервисов текущего контекста на 60 FPS, а остальная топология мгновенно подгружается при перемещении камеры.
Криминалистические аналитические платформы (Chainalysis, Elliptic) загружают графы переводов криптовалюты с миллионами кошельков и транзакций. Использование LOD позволяет аналитику на общем плане (zoom 0.1) видеть кластеры и тепловые зоны криминальной активности в виде абстрактных точек, а при приближении к конкретному кошельку (zoom 1.0) разворачивать полный аудит балансов и подписей.
Молекулярные биологи исследуют графы белок-белковых взаимодействий (Interactome), содержащие более 50 000 вершин. Гибридный Canvas/DOM рендерер позволяет рисовать сотни тысяч связей через аппаратный Canvas, сохраняя возможность кликать по ключевым белковым узлам через DOM-порталы.
Редакторы микроэлектроники отображают электрические принципиальные схемы с десятками тысяч дорожек и контактов. Отсечение ребер и смена уровня детализации резисторов и микросхем позволяют масштабировать плату без зависания браузера.
Построим высокопроизводительную систему виртуализации для React Flow, включающую:
import { useMemo } from 'react';
import { useStore, type Node, type ReactFlowState } from '@xyflow/react';
interface CullingOptions {
overscanBuffer?: number; // Буфер упреждения в пикселях
minZoomForFullDom?: number;
}
// Селектор стора React Flow: получаем размеры холста и текущую матрицу трансформации
const viewportSelector = (state: ReactFlowState) => ({
transform: state.transform, // [x, y, zoom]
width: state.width,
height: state.height,
});
export function useViewportCulling(
allNodes: Node[],
options: CullingOptions = {}
) {
const { overscanBuffer = 200 } = options;
const { transform, width, height } = useStore(viewportSelector);
const [tx, ty, zoom] = transform;
const visibleNodes = useMemo(() => {
// Если холст еще не инициализирован размерами, возвращаем узлы без фильтрации
if (width === 0 || height === 0) return allNodes;
// 1. Вычисляем границы видимого фрустума в координатах графа
const minVisibleX = (0 - tx) / zoom - overscanBuffer;
const maxVisibleX = (width - tx) / zoom + overscanBuffer;
const minVisibleY = (0 - ty) / zoom - overscanBuffer;
const maxVisibleY = (height - ty) / zoom + overscanBuffer;
// 2. AABB фильтрация объектов
return allNodes.filter((node) => {
const nodeX = node.position.x;
const nodeY = node.position.y;
const nodeWidth = node.measured?.width ?? node.width ?? 180;
const nodeHeight = node.measured?.height ?? node.height ?? 80;
// Проверка пересечения двух прямоугольников
const isVisible =
nodeX + nodeWidth >= minVisibleX &&
nodeX <= maxVisibleX &&
nodeY + nodeHeight >= minVisibleY &&
nodeY <= maxVisibleY;
return isVisible;
});
}, [allNodes, tx, ty, zoom, width, height, overscanBuffer]);
return {
visibleNodes,
currentZoom: zoom,
totalNodesCount: allNodes.length,
visibleNodesCount: visibleNodes.length,
};
}Компонент читает текущий zoom через селектор и мгновенно переключает разметку, предотвращая рендеринг тяжелых дочерних структур при удалении камеры.
import React, { memo } from 'react';
import { Handle, Position, useStore, type NodeProps, type ReactFlowState } from '@xyflow/react';
export interface HighloadNodeData {
title: string;
category: 'database' | 'service' | 'gateway';
qps: number;
errorRate: number;
}
const zoomSelector = (s: ReactFlowState) => s.transform[2];
export const LODNode = memo(function LODNode({
data,
selected,
}: NodeProps & { data: HighloadNodeData }) {
const zoom = useStore(zoomSelector);
// Определение уровня детализации на основе порога зума
// LOD 2: Абстрактный маркер при масштабе менее 0.35
if (zoom < 0.35) {
return (
<div
className={`h-4 w-4 rounded-full border ${
data.errorRate > 0.05
? 'bg-status-danger border-status-danger'
: 'bg-accent-blue border-white/40'
}`}
title={`${data.title} (${data.category})`}
/>
);
}
// LOD 1: Упрощенный компактный блок при масштабе от 0.35 до 0.70
if (zoom < 0.7) {
return (
<div
className={`min-w-[120px] rounded border px-2 py-1 text-[11px] shadow-sm backdrop-blur ${
selected ? 'border-accent-blue bg-surface-card' : 'border-border-subtle bg-surface-card/80'
}`}
>
<Handle type="target" position={Position.Left} className="!h-1.5 !w-1.5" />
<div className="truncate font-semibold text-text-primary">{data.title}</div>
<div className="text-[9px] text-text-muted">{data.category}</div>
<Handle type="source" position={Position.Right} className="!h-1.5 !w-1.5" />
</div>
);
}
// LOD 0: Полный интерактивный узел при масштабе 0.70 и выше
return (
<div
className={`min-w-[220px] rounded-lg border bg-surface-card p-3 shadow-lg transition-all ${
selected ? 'border-accent-blue ring-2 ring-accent-blue/30' : 'border-border-subtle'
}`}
>
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !bg-accent-blue"
/>
<div className="flex items-center justify-between border-b border-border-subtle pb-1.5">
<span className="font-mono text-xs font-bold text-text-primary">{data.title}</span>
<span className="rounded bg-surface-overlay px-1.5 py-0.5 text-[10px] uppercase text-text-muted">
{data.category}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
<div className="rounded bg-surface-background p-1.5">
<div className="text-[10px] text-text-muted">Нагрузка QPS</div>
<div className="font-mono font-semibold text-accent-cyan">{data.qps} rps</div>
</div>
<div className="rounded bg-surface-background p-1.5">
<div className="text-[10px] text-text-muted">Ошибки</div>
<div
className={`font-mono font-semibold ${
data.errorRate > 0.05 ? 'text-status-danger' : 'text-status-success'
}`}
>
{(data.errorRate * 100).toFixed(1)}%
</div>
</div>
</div>
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !bg-status-success"
/>
</div>
);
});При 20 000 связей создание 20 000 SVG элементов <path> блокирует браузер. Заменим рендеринг ребер на прозрачный HTML5 Canvas, синхронизированный с матрицей камеры.
import React, { useRef, useEffect } from 'react';
import { useStore, type Edge, type Node, type ReactFlowState } from '@xyflow/react';
interface CanvasEdgeOverlayProps {
edges: Edge[];
nodes: Node[];
}
export function CanvasEdgeOverlay({ edges, nodes }: CanvasEdgeOverlayProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Подписка на трансформацию и размеры
const transform = useStore((s: ReactFlowState) => s.transform);
const width = useStore((s: ReactFlowState) => s.width);
const height = useStore((s: ReactFlowState) => s.height);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || width === 0 || height === 0) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Учет плотности пикселей Retina/HiDPI
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// Индексация позиций узлов для быстрого поиска O(1)
const nodePositionMap = new Map<string, { x: number; y: number }>();
for (const node of nodes) {
const w = node.measured?.width ?? 180;
const h = node.measured?.height ?? 80;
nodePositionMap.set(node.id, {
x: node.position.x + w / 2,
y: node.position.y + h / 2,
});
}
// Очистка предыдущего кадра
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Применение матрицы трансформации камеры [zoom, 0, 0, zoom, tx, ty]
ctx.save();
ctx.scale(dpr, dpr);
const [tx, ty, zoom] = transform;
ctx.translate(tx, ty);
ctx.scale(zoom, zoom);
// Пакетная отрисовка путей через единый Path2D
ctx.lineWidth = 1.5 / zoom; // Постоянная визуальная толщина линии
ctx.strokeStyle = 'rgba(100, 116, 139, 0.4)';
ctx.beginPath();
for (const edge of edges) {
const src = nodePositionMap.get(edge.source);
const tgt = nodePositionMap.get(edge.target);
if (!src || !tgt) continue;
// Прямая линия или сплайн
ctx.moveTo(src.x, src.y);
ctx.lineTo(tgt.x, tgt.y);
}
ctx.stroke();
ctx.restore();
}, [edges, nodes, transform, width, height]);
return (
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 z-0"
/>
);
}import React, { useMemo } from 'react';
import {
ReactFlow,
Panel,
type Node,
type Edge,
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { LODNode } from './nodes/LODNode';
import { CanvasEdgeOverlay } from './CanvasEdgeOverlay';
import { useViewportCulling } from '@/hooks/useViewportCulling';
const nodeTypes: NodeTypes = {
lodNode: LODNode,
};
interface HighloadGraphViewProps {
datasetNodes: Node[];
datasetEdges: Edge[];
}
export function HighloadGraphView({
datasetNodes,
datasetEdges,
}: HighloadGraphViewProps) {
// Применение Viewport Culling с буфером 200 пикселей
const { visibleNodes, visibleNodesCount, totalNodesCount, currentZoom } =
useViewportCulling(datasetNodes, { overscanBuffer: 200 });
return (
<div className="relative h-screen w-full bg-surface-background">
{/* React Flow рендерит ТОЛЬКО узлы, попавшие во вьюпорт */}
<ReactFlow
nodes={visibleNodes}
edges={[]} // Отключаем SVG-ребра для обхода DOM-ограничений
nodeTypes={nodeTypes}
minZoom={0.05}
maxZoom={2.5}
elementsSelectable={true}
>
{/* Высокоскоростной Canvas слой для 20 000 связей */}
<CanvasEdgeOverlay edges={datasetEdges} nodes={datasetNodes} />
<Panel
position="bottom-left"
className="rounded border border-border-subtle bg-surface-overlay p-2 text-xs font-mono text-text-primary shadow backdrop-blur"
>
<div>Всего нод в графе: {totalNodesCount.toLocaleString()}</div>
<div className="text-status-success">
Видимо в DOM (Culling): {visibleNodesCount}
</div>
<div>Масштаб (Zoom): {currentZoom.toFixed(2)}x</div>
</Panel>
</ReactFlow>
</div>
);
}Пользователь скроллит или зумирует граф. useStore фиксирует вектор [tx, ty, zoom] без ререндера соседних узлов.
Хук useViewportCulling за O(N) или O(log N) отсекает 95% узлов, не пересекающихся с расширенным окном экрана.
Для 5% оставшихся в DOM узлов вычисляется детализация (LOD 0, 1 или 2), исключая тяжелые дочерние компоненты.
Связи между вершинами объединяются в непрерывный буфер Path2D и отрисовываются на видеокарте за один системный вызов stroke().
Команда перенесла рендеринг 15 000 ребер из SVG в слой HTML5 2D Canvas. Какое функциональное ограничение возникает при таком архитектурном решении и как его преодолеть?
Вызов nodes.filter(...) прямо в теле рендера без мемоизации и троттлинга по таймингам requestAnimationFrame:
// ❌ АНТИПАТТЕРН: фильтрация 10 000 элементов 120 раз в секунду
export function BadCulling({ allNodes }) {
const { transform } = useStore();
// Вычисляется на каждый микропиксель движения мыши, сжигая CPU
const visible = allNodes.filter(n => isInside(n, transform));
return <ReactFlow nodes={visible} ... />;
}Решение: Ограничивайте частоту обновлений через requestAnimationFrame или применяйте встроенное пространственное индексирование на базе Quadtree / R-Tree.
Если при создании <canvas> не умножить его внутреннее разрешение (canvas.width, canvas.height) на window.devicePixelRatio, на всех экранах Retina / 4K линии связей будут выглядеть размытыми и пикселизированными.
Решение: Всегда масштабируйте контекст: canvas.width = width * dpr; ctx.scale(dpr, dpr).
Попытка скрыть детали узла через opacity: 0 или visibility: hidden вместо условного рендеринга:
// ❌ ОШИБКА: DOM-узлы остаются в дереве и участвуют в Layout
<div style={{ opacity: zoom < 0.5 ? 0 : 1 }}>
<ExpensiveChartComponent />
</div>
// ✅ ПРАВИЛЬНО: полное исключение из JSX дерева
{zoom >= 0.5 && <ExpensiveChartComponent />}Если прозрачный слой <canvas> помещен поверх узлов без CSS-класса pointer-events-none, он перехватит все клики мыши, и пользователь не сможет взаимодействовать с узлами графа.