Кастомный рендер точки через nodeComponent

Во многих компонентах библиотеки Nivo точки данных рендерятся стандартным SVG-элементом <circle>. Такой подход удобен для базовой визуализации, однако быстро становится ограничением при создании сложных интерфейсов:

  • отображение пользовательских иконок;
  • визуализация состояний;
  • анимированные точки;
  • условное оформление;
  • интерактивные элементы;
  • интеграция React-компонентов внутрь графика.

Свойство nodeComponent позволяет полностью переопределить рендер узла графика и внедрить собственный React-компонент вместо стандартной точки.

Чаще всего nodeComponent используется в:

  • ResponsiveLine
  • ResponsiveScatterPlot
  • ResponsiveNetwork
  • ResponsiveSwarmPlot

Наиболее востребован сценарий в ResponsiveLine.


Базовая структура nodeComponent

Стандартный пример:

import { ResponsiveLine } from '@nivo/line'

const CustomPoint = ({ x, y, size, color }) => {
    return (
        <g transform={`translate(${x}, ${y})`}>
            <circle
                r={size / 2}
                fill={color}
                stroke="#000"
                strokeWidth={2}
            />
        </g>
    )
}

export const Chart = ({ data }) => {
    return (
        <ResponsiveLine
            data={data}
            nodeComponent={CustomPoint}
        />
    )
}

Как работает кастомный узел

Во время рендера графика Nivo вызывает пользовательский компонент для каждой точки данных.

В компонент передаётся большой набор параметров:

const CustomPoint = (props) => {
    console.log(props)

    return null
}

Типичный набор свойств:

{
    x,
    y,
    size,
    color,
    borderWidth,
    borderColor,
    data,
    animatedProps,
    onMouseEnter,
    onMouseMove,
    onMouseLeave,
    onClick
}

Координаты точки

Свойства:

x
y

представляют итоговые координаты точки внутри SVG-контейнера.

Пример:

const CustomPoint = ({ x, y }) => {
    return (
        <g transform={`translate(${x}, ${y})`}>
            <circle r={6} fill="red" />
        </g>
    )
}

Группа <g> используется практически всегда, поскольку позволяет удобно позиционировать содержимое через transform.


Использование данных точки

Свойство data содержит исходные данные элемента.

Пример структуры:

{
    x: '2025-01',
    y: 120,
    xFormatted: '2025-01',
    yFormatted: '120'
}

Это позволяет строить условный рендеринг.


Условное оформление

Выделение критических значений

const CustomPoint = ({ x, y, data }) => {
    const isCritical = data.y > 100

    return (
        <g transform={`translate(${x}, ${y})`}>
            <circle
                r={isCritical ? 12 : 6}
                fill={isCritical ? '#ff0000' : '#3b82f6'}
            />
        </g>
    )
}

Рендер текста внутри точки

SVG поддерживает текстовые элементы.

const CustomPoint = ({ x, y, data }) => {
    return (
        <g transform={`translate(${x}, ${y})`}>
            <circle r={14} fill="#2563eb" />

            <text
                textAnchor="middle"
                dominantBaseline="central"
                fill="#fff"
                fontSize={10}
            >
                {data.y}
            </text>
        </g>
    )
}

Использование SVG-иконок

Кастомная форма точки

const DiamondPoint = ({ x, y, color }) => {
    return (
        <g transform={`translate(${x}, ${y})`}>
            <rect
                x={-8}
                y={-8}
                width={16}
                height={16}
                fill={color}
                transform="rotate(45)"
            />
        </g>
    )
}

Создание сложных SVG-узлов

nodeComponent не ограничивается одной фигурой.

Полноценный составной узел:

const ComplexPoint = ({ x, y, color }) => {
    return (
        <g transform={`translate(${x}, ${y})`}>
            <circle
                r={16}
                fill="rgba(59,130,246,0.15)"
            />

            <circle
                r={8}
                fill={color}
            />

            <circle
                r={3}
                fill="#fff"
            />
        </g>
    )
}

Подобный подход позволяет создавать:

  • radar-style узлы;
  • glow-эффекты;
  • двойные маркеры;
  • индикаторы активности;
  • точки со статусами.

Добавление анимации

Nivo активно использует react-spring.

В nodeComponent доступны анимированные параметры.

Пример:

const AnimatedPoint = ({
    animatedProps,
    color
}) => {
    return (
        <animated.g
            transform={animatedProps.transform}
        >
            <animated.circle
                r={animatedProps.size}
                fill={color}
            />
        </animated.g>
    )
}

Hover-эффекты

Изменение размера при наведении

import { useState } from 'react'

const HoverPoint = ({ x, y, color }) => {
    const [hovered, setHovered] = useState(false)

    return (
        <g
            transform={`translate(${x}, ${y})`}
            onMouseEn ter={() => setHovered(true)}
            onMouseLe ave={() => setHovered(false)}
        >
            <circle
                r={hovered ? 12 : 6}
                fill={color}
                style={{
                    transition: 'all .2s ease'
                }}
            />
        </g>
    )
}

Работа с событиями

Компонент получает стандартные обработчики событий.

const InteractivePoint = ({
    x,
    y,
    data,
    onClick
}) => {
    return (
        <g
            transform={`translate(${x}, ${y})`}
            onCl ick={(event) => {
                onClick(data, event)
            }}
        >
            <circle
                r={8}
                fill="#10b981"
            />
        </g>
    )
}

Интеграция tooltip

Кастомный tooltip для точки

const CustomPoint = ({
    x,
    y,
    data,
    onMouseEnter,
    onMouseLeave
}) => {
    return (
        <g
            transform={`translate(${x}, ${y})`}
            onMouseEn ter={(event) => {
                onMouseEnter(data, event)
            }}
            onMouseLe ave={onMouseLeave}
        >
            <circle
                r={8}
                fill="#6366f1"
            />
        </g>
    )
}

Использование HTML внутри SVG

SVG не умеет напрямую рендерить HTML, однако можно применять foreignObject.

const HtmlPoint = ({ x, y, data }) => {
    return (
        <foreignObject
            x={x - 20}
            y={y - 20}
            width={40}
            height={40}
        >
            <div
                style={{
                    width: 40,
                    height: 40,
                    borderRadius: '50%',
                    background: '#2563eb',
                    color: '#fff',
                    display: 'flex',
                    alignItems: 'center',
                    justifyContent: 'center',
                    fontSize: 12
                }}
            >
                {data.y}
            </div>
        </foreignObject>
    )
}

Оптимизация производительности

При большом количестве точек кастомный рендер способен серьёзно нагружать React.

Особенно опасны:

  • локальный state;
  • тяжёлые вычисления;
  • inline-объекты;
  • динамические функции;
  • сложные DOM-деревья.

Использование React.memo

import React from 'react'

const CustomPoint = React.memo(
    ({ x, y, color }) => {
        return (
            <g transform={`translate(${x}, ${y})`}>
                <circle
                    r={6}
                    fill={color}
                />
            </g>
        )
    }
)

Минимизация количества DOM-элементов

Плохой вариант:

<g>
    <circle />
    <circle />
    <circle />
    <circle />
    <circle />
</g>

Хороший вариант:

<circle />

На графиках с тысячами точек разница становится критичной.


Ошибки позиционирования

Распространённая ошибка:

<circle
    cx={x}
    cy={y}
/>

вместо:

<g transform={`translate(${x}, ${y})`}>
    <circle />
</g>

Использование <g> делает компонент более масштабируемым и упрощает композицию.


Работа с размерами

Свойство:

size

обычно представляет диаметр точки.

Правильный радиус:

r={size / 2}

Использование изображений

Аватары вместо точек

const AvatarPoint = ({ x, y, data }) => {
    return (
        <g transform={`translate(${x}, ${y})`}>
            <clipPath id={`clip-${data.x}`}>
                <circle r={14} />
            </clipPath>

            <image
                href={data.avatar}
                x={-14}
                y={-14}
                width={28}
                height={28}
                clipPath={`url(#clip-${data.x})`}
            />
        </g>
    )
}

Добавление теней

SVG поддерживает фильтры.

const ShadowPoint = ({ x, y }) => {
    return (
        <g transform={`translate(${x}, ${y})`}>
            <defs>
                <filter id="shadow">
                    <feDropShadow
                        dx="0"
                        dy="2"
                        stdDeviation="3"
                        floodOpacity="0.35"
                    />
                </filter>
            </defs>

            <circle
                r={10}
                fill="#3b82f6"
                filter="url(#shadow)"
            />
        </g>
    )
}

Использование gradients

const GradientPoint = ({ x, y }) => {
    return (
        <g transform={`translate(${x}, ${y})`}>
            <defs>
                <radialGradient id="pointGradient">
                    <stop
                        offset="0%"
                        stopColor="#fff"
                    />

                    <stop
                        offset="100%"
                        stopColor="#2563eb"
                    />
                </radialGradient>
            </defs>

            <circle
                r={10}
                fill="url(#pointGradient)"
            />
        </g>
    )
}

Полноценный production-пример

import React, { memo, useState } from 'react'
import { ResponsiveLine } from '@nivo/line'

const CustomNode = memo((props) => {
    const {
        x,
        y,
        color,
        size,
        data
    } = props

    const [hovered, setHovered] = useState(false)

    const radius = hovered
        ? size
        : size / 2

    const isDanger = data.y > 80

    return (
        <g
            transform={`translate(${x}, ${y})`}
            onMouseEn ter={() => setHovered(true)}
            onMouseLe ave={() => setHovered(false)}
            style={{
                cursor: 'pointer'
            }}
        >
            <circle
                r={radius + 6}
                fill={
                    isDanger
                        ? 'rgba(239,68,68,.2)'
                        : 'rgba(59,130,246,.2)'
                }
            />

            <circle
                r={radius}
                fill={
                    isDanger
                        ? '#ef4444'
                        : color
                }
                stroke="#fff"
                strokeWidth={2}
                style={{
                    transition: 'all .2s ease'
                }}
            />

            <text
                y={-18}
                textAnchor="middle"
                fontSize={11}
                fill="#111827"
            >
                {data.y}
            </text>
        </g>
    )
})

export const Chart = ({ data }) => {
    return (
        <ResponsiveLine
            data={data}
            enablePoints={true}
            pointSize={12}
            nodeComponent={CustomNode}
            margin={{
                top: 50,
                right: 50,
                bottom: 50,
                left: 60
            }}
        />
    )
}

Когда nodeComponent особенно полезен

Dashboard-интерфейсы

Позволяет отображать:

  • статусы;
  • уведомления;
  • приоритеты;
  • health indicators.

Финансовые графики

Подходит для:

  • отметок сделок;
  • сигналов;
  • зон риска;
  • торговых событий.

Аналитические системы

Используется для:

  • heat markers;
  • anomaly points;
  • event overlays;
  • realtime indicators.

Социальные сервисы

Позволяет рендерить:

  • аватары;
  • реакции;
  • пользовательские статусы;
  • онлайн-индикаторы.

Ограничения nodeComponent

Несмотря на гибкость, кастомный рендер имеет ограничения.

Большое количество узлов

SVG плохо масштабируется на десятках тысяч элементов.

При огромных объёмах данных лучше использовать:

  • Canvas-версии графиков;
  • упрощённый рендер;
  • агрегацию данных.

Стоимость React-рендера

Каждая точка становится отдельным React-компонентом.

1000 точек = 1000 React-компонентов.


Ограничения SVG

Некоторые CSS-возможности работают иначе:

  • z-index;
  • overflow;
  • blend modes;
  • сложные transform-анимации.

Сравнение стандартного и кастомного рендера

Возможность Стандартные точки nodeComponent
Цвет Да Да
Размер Да Да
SVG-фигуры Ограниченно Полностью
HTML-вставки Нет Да
Анимации Базовые Полностью
React-логика Нет Да
Состояния Нет Да
События Частично Полностью
Аватары Нет Да
Сложные композиции Нет Да

Практический подход

На production-проектах nodeComponent обычно применяется для:

  • выделения отдельных категорий данных;
  • интеграции UI-элементов в график;
  • построения брендированных визуализаций;
  • создания интерактивной аналитики;
  • расширения стандартного поведения Nivo без форка библиотеки.

Именно nodeComponent превращает Nivo из библиотеки типовых графиков в полноценный инструмент кастомной визуализации на React.