Исторически библиотека D3.js (Data-Driven Documents) создавалась в эпоху до компонентных фреймворков и совмещала в себе две принципиально разные роли: мощный математический вычислительный движок (интерполяция, картографические проекции, шкалы координат) и низкоуровневый DOM-манипулятор (d3.select().append().attr()).
В современной компонентной парадигме React 19 прямое вмешательство D3 в структуру DOM-дерева является грубейшим архитектурным антипаттерном:
append и remove мутируют дерево узлов в обход планировщика React Fiber, вызывая рассинхронизацию виртуального дерева и реального DOM.document. Попытка исполнить код императивного построения графиков на сервере в среде Node.js в Next.js App Router приводит к фатальному ReferenceError: document is not defined.Современный индустриальный стандарт изоморфной визуализации данных строится на строгом разделении обязанностей: D3 используется исключительно как чистый математический процессор (Math Engine), а React 19 выступает единственным владельцем и декларативным рендерером DOM-дерева SVG.
React отвечает за жизненный цикл компонентов, доступность, интеграцию с Tailwind CSS v4 и генерацию чистой SVG-разметки на сервере и клиенте. Модули D3 (d3-scale, d3-shape, d3-interpolate) используются как чистые функции без побочных эффектов: они принимают массивы данных и возвращают математические координаты и строки путей d внутри мемоизированных хуков useMemo.
Архитектура изоморфной визуализации данных представляет собой однонаправленный конвейер: от сырых бизнес-данных через математические трансформаторы к декларативным SVG-узлам:
┌─────────────────────────────────────────────────────────────┐
│ СЫРЫЕ БИЗНЕС-ДАННЫЕ (Raw Dataset) │
│ readonly TelemetryPoint[]: { timestamp: number, value: 42 }│
└──────────────────────────────┬──────────────────────────────┘
│
▼ (useMemo: Чистая математика D3)
┌─────────────────────────────────────────────────────────────┐
│ D3 MATH SUBMODULES (Без обращения к DOM!) │
│ • d3-scale: scaleTime(), scaleLinear() (Domain -> Range) │
│ • d3-shape: line(), area(), curveMonotoneX() -> SVG "d" │
│ • flubber: interpolate() (Триангуляция и морфинг путей) │
└──────────────────────────────┬──────────────────────────────┘
│
▼ (Декларативный коммит в Fiber)
┌─────────────────────────────────────────────────────────────┐
│ REACT 19 SVG DOM (Детерминированный рендер) │
│ <svg viewBox="0 0 640 240"> │
│ <path d={areaPathD} fill="url(#gradient)" /> │
│ <path d={linePathD} stroke="#8B5CF6" /> │
│ </svg> │
└─────────────────────────────────────────────────────────────┘area()) и сглаженные линии графиков (line()).Сортировка временных рядов, валидация типов через TypeScript и мемоизация массива через useMemo.
Функции scaleTime и scaleLinear проецируют диапазоны метрик в пиксельные координаты внутренней сетки viewBox.
Генераторы line() и area() с интерполятором curveMonotoneX рассчитывают SVG-строку d для элемента <path>.
React 19 монтирует семантические теги <svg>, <g>, <path> с поддержкой SSR, градиентов и vectorEffect='non-scaling-stroke'.
Почему в приложениях на React 19 и Next.js категорически запрещено использовать традиционный синтаксис D3 вида d3.select(svgRef.current).append('path').attr('d', lineGen(data))?
Высокочастотный рендеринг котировок акций и криптовалют с интерактивной визирной линией (Crosshair), расчетом экстремумов, сглаживанием сплайнами и градиентной заливкой объемов торгов.
Интерактивные круговые диаграммы (Donut Charts), воронки конверсий и многолинейные сравнительные графики с плавной анимацией морфинга контуров при переключении временных интервалов (день / неделя / месяц).
Компактные адаптивные спарклайны (Sparklines) загрузки CPU, утилизации памяти и сетевого трафика, автоматически подстраивающиеся под ширину карточки через ResizeObserver.
Проецирование географических координат на плоскость экрана с помощью проекций d3-geo (Меркатор, Альперс) и рендеринг регионов в виде интерактивных SVG-полигонов.
Стандартом построения графиков является разделение внешних габаритов контейнера и внутренней рабочей области (Margin Convention):
innerWidth = width - margin.left - margin.right
innerHeight = height - margin.top - margin.bottomВнутри SVG создается группа смещения <g transform="translate(margin.left, margin.top)">, благодаря чему все шкалы проецируются от координаты (0, 0), а место под метки осей резервируется автоматически.
Пользователь перемещает курсор мыши по графику метрик. Как наиболее производительно и точно определить точную дату из массива данных, над которой находится курсор, без перебора всего массива в цикле O(N)?
Построим два законченных производственных компонента для React 19:
src/components/charts/MetricsAreaChart.tsx: Полнофункциональный график метрик с градиентной заливкой площади, сплайновым сглаживанием curveMonotoneX, интерактивной визирной линией (Crosshair) и тултипом с нулевым Reflow.src/components/charts/ResponsiveSparkline.tsx: Адаптивный спарклайн с интеграцией ResizeObserver, поддержкой темной темы и сохранением толщины обводки vectorEffect="non-scaling-stroke".'use client';
import * as React from 'react';
import { scaleTime, scaleLinear } from 'd3-scale';
import { area, line, curveMonotoneX } from 'd3-shape';
export interface DataPoint {
readonly date: Date;
readonly value: number;
}
interface MetricsAreaChartProps {
readonly data: readonly DataPoint[];
readonly width?: number;
readonly height?: number;
readonly title?: string;
}
export function MetricsAreaChart({
data,
width = 680,
height = 260,
title = 'Пропускная способность запросов (RPS)',
}: MetricsAreaChartProps) {
const svgRef = React.useRef<SVGSVGElement>(null);
const [hoveredPoint, setHoveredPoint] = React.useState<DataPoint | null>(null);
const [crosshairX, setCrosshairX] = React.useState<number | null>(null);
const margin = { top: 20, right: 20, bottom: 28, left: 40 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Чистая математика D3 мемоизируется в useMemo
const { xScale, yScale, areaPath, linePath } = React.useMemo(() => {
if (data.length < 2) {
return { xScale: null, yScale: null, areaPath: '', linePath: '' };
}
const x = scaleTime()
.domain([data[0].date, data[data.length - 1].date])
.range([0, innerWidth]);
const maxValue = Math.max(...data.map((d) => d.value));
const y = scaleLinear()
.domain([0, maxValue * 1.15]) // 15% запас сверху для эстетики
.range([innerHeight, 0]);
const areaGen = area<DataPoint>()
.x((d) => x(d.date))
.y0(innerHeight)
.y1((d) => y(d.value))
.curve(curveMonotoneX);
const lineGen = line<DataPoint>()
.x((d) => x(d.date))
.y((d) => y(d.value))
.curve(curveMonotoneX);
return {
xScale: x,
yScale: y,
areaPath: areaGen(data as DataPoint[]) || '',
linePath: lineGen(data as DataPoint[]) || '',
};
}, [data, innerWidth, innerHeight]);
// Обработка наведения курсора для расчета положения визира
const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
if (!xScale || !svgRef.current || data.length === 0) return;
const rect = svgRef.current.getBoundingClientRect();
const clientX = e.clientX - rect.left - margin.left;
const clampedX = Math.max(0, Math.min(innerWidth, clientX));
// Обратная проекция координаты курсора в домен времени (O(1))
const hoveredDate = xScale.invert(clampedX);
// Бисекция ближайшей точки данных
let closest = data[0];
let minDiff = Math.abs(hoveredDate.getTime() - data[0].date.getTime());
for (let i = 1; i < data.length; i++) {
const diff = Math.abs(hoveredDate.getTime() - data[i].date.getTime());
if (diff < minDiff) {
minDiff = diff;
closest = data[i];
}
}
setHoveredPoint(closest);
setCrosshairX(xScale(closest.date));
};
const handlePointerLeave = () => {
setHoveredPoint(null);
setCrosshairX(null);
};
if (!xScale || !yScale) return null;
return (
<div className="relative p-5 rounded-3xl bg-bg-surface border border-border shadow-xl space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-text-primary">{title}</h3>
<span className="text-[10px] font-mono text-text-muted uppercase">D3 Math + React Fiber</span>
</div>
<div className="relative">
<svg
ref={svgRef}
viewBox={`0 0 ${width} ${height}`}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
className="w-full h-auto overflow-visible cursor-crosshair touch-none select-none"
role="img"
aria-label={title}
>
<defs>
<linearGradient id="metric-area-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#8B5CF6" stopOpacity="0.45" />
<stop offset="100%" stopColor="#8B5CF6" stopOpacity="0.0" />
</linearGradient>
</defs>
<g transform={`translate(${margin.left},${margin.top})`}>
{/* Горизонтальная сетка значений Y */}
{yScale.ticks(4).map((tick) => (
<g key={tick} transform={`translate(0, ${yScale(tick)})`}>
<line x1={0} x2={innerWidth} stroke="#272738" strokeDasharray="3 3" />
<text
x={-8}
y={4}
textAnchor="end"
className="text-[9px] font-mono fill-text-muted select-none"
>
{tick}
</text>
</g>
))}
{/* Заливка площади */}
<path d={areaPath} fill="url(#metric-area-gradient)" />
{/* Линия контура */}
<path
d={linePath}
fill="none"
stroke="#8B5CF6"
strokeWidth={2.5}
vectorEffect="non-scaling-stroke"
/>
{/* Интерактивная визирная линия */}
{crosshairX !== null && hoveredPoint && (
<g>
<line
x1={crosshairX}
y1={0}
x2={crosshairX}
y2={innerHeight}
stroke="#06B6D4"
strokeWidth={1.5}
strokeDasharray="2 2"
/>
<circle
cx={crosshairX}
cy={yScale(hoveredPoint.value)}
r={5}
fill="#06B6D4"
stroke="#0D0D15"
strokeWidth={2}
/>
</g>
)}
</g>
</svg>
{/* Плавающий тултип */}
{hoveredPoint && crosshairX !== null && (
<div
style={{
left: `${((crosshairX + margin.left) / width) * 100}%`,
top: '4px',
}}
className="absolute -translate-x-1/2 pointer-events-none px-3 py-1.5 rounded-xl bg-bg-elevated/95 backdrop-blur-md border border-border shadow-2xl text-xs font-mono"
>
<div className="text-[10px] text-text-muted">
{hoveredPoint.date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
<div className="text-accent-cyan font-bold text-sm">
{hoveredPoint.value.toFixed(1)} req/s
</div>
</div>
)}
</div>
</div>
);
}'use client';
import * as React from 'react';
import { scaleLinear } from 'd3-scale';
import { line, curveBasis } from 'd3-shape';
export interface ResponsiveSparklineProps {
readonly values: readonly number[];
readonly strokeColor?: string;
readonly height?: number;
}
export function ResponsiveSparkline({
values,
strokeColor = '#06B6D4',
height = 54,
}: ResponsiveSparklineProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState<number>(180);
React.useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0) {
setWidth(entry.contentRect.width);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const pathD = React.useMemo(() => {
if (values.length < 2) return '';
const xScale = scaleLinear()
.domain([0, values.length - 1])
.range([2, width - 2]);
const min = Math.min(...values);
const max = Math.max(...values);
const yScale = scaleLinear()
.domain([min, max === min ? max + 1 : max])
.range([height - 3, 3]);
const lineGen = line<number>()
.x((_, i) => xScale(i))
.y((d) => yScale(d))
.curve(curveBasis);
return lineGen(values as number[]) || '';
}, [values, width, height]);
return (
<div ref={containerRef} className="w-full relative overflow-hidden" style={{ height }}>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-full overflow-visible"
aria-hidden="true"
>
<path
d={pathD}
fill="none"
stroke={strokeColor}
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
</svg>
</div>
);
}Попытка манипулировать DOM через D3 внутри useEffect ломает изоморфность, дублирует узлы графиков при ререндерах и полностью исключает работу SSR.
Решение: Используйте D3 только как чистый калькулятор координат в useMemo, а отрисовку доверяйте JSX-тегам <path> и <svg>.
Координата e.clientX не соответствует системе координат SVG viewBox, если график масштабируется адаптивными стилями CSS (width: 100%).
Решение: Всегда пересчитывайте координаты относительно габаритов контейнера через svgRef.current.getBoundingClientRect().
При сжатии контейнера толщина линии графика искажается: на мобильных устройствах она становится тоньше волоса, а на десктопах раздувается.
Решение: Всегда добавляйте vectorEffect="non-scaling-stroke" к элементам <path>, чтобы толщина обводки всегда равнялась физическим пикселям экрана.
Генераторы curveMonotoneX и curveBasis выполняют ресурсоемкие полиномиальные расчеты. Отсутствие useMemo при 10 000+ точек замораживает главный поток приложения.
Решение: Оборачивайте создание шкал и вычисление путей в useMemo([data, width, height]).
<g transform> изолирует рабочую область графика от полей осей и подписей.xScale.invert(pixelX) обеспечивает мгновенную обратную проекцию координат курсора в исходные значения данных за $O(1)$ для интерактивных визиров (Crosshair).vector-effect="non-scaling-stroke" гарантирует стабильную физическую толщину линий графика при адаптивном масштабировании контейнера.