Історично бібліотека 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 відповідає за життєвий цикл компонентів, доступність (a11y), інтеграцію з Tailwind CSS та генерацію чистої 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" гарантує стабільну фізичну товщину ліній графіка при адаптивному масштабуванні контейнера.