Интерактивность в ScatterPlot и Bubble

Диаграммы рассеяния и пузырьковые диаграммы в библиотеке Nivo предназначены для отображения взаимосвязей между числовыми значениями. Визуализация становится особенно полезной при добавлении интерактивности: всплывающих подсказок, реакций на наведение, выделения точек, масштабирования и пользовательских обработчиков событий.

Компонент ScatterPlot в Nivo построен поверх SVG или Canvas и поддерживает множество механизмов взаимодействия без необходимости вручную работать с DOM-событиями.

Базовый интерактивный график:

import { ResponsiveScatterPlot } from '@nivo/scatterplot'

const data = [
  {
    id: 'dataset',
    data: [
      { x: 10, y: 20 },
      { x: 15, y: 35 },
      { x: 25, y: 10 },
    ],
  },
]

export default function Chart() {
  return (
    <div style={{ height: 500 }}>
      <ResponsiveScatterPlot
        data={data}
        margin={{ top: 40, right: 40, bottom: 70, left: 90 }}
        xScale={{ type: 'linear', min: 0, max: 30 }}
        yScale={{ type: 'linear', min: 0, max: 40 }}
        axisBottom={{
          legend: 'X',
          legendPosition: 'middle',
          legendOffset: 46,
        }}
        axisLeft={{
          legend: 'Y',
          legendPosition: 'middle',
          legendOffset: -60,
        }}
        colors={{ scheme: 'category10' }}
        nodeSize={10}
        useMesh={true}
      />
    </div>
  )
}

Свойство useMesh

Одной из важнейших возможностей интерактивности является свойство useMesh.

useMesh={true}

При включении mesh-слоя библиотека создаёт невидимую интерактивную сетку поверх графика. Это значительно повышает точность наведения курсора на точки.

Без mesh пользователь должен попасть точно в SVG-элемент точки. При большом количестве данных или маленьком размере узлов взаимодействие становится неудобным.

Преимущества mesh:

  • точное определение ближайшей точки;
  • более плавные hover-эффекты;
  • улучшение UX на плотных графиках;
  • повышение удобства работы на мобильных устройствах.

Обработчики событий

ScatterPlot поддерживает стандартные обработчики событий React.

Обработка клика

<ResponsiveScatterPlot
  data={data}
  onCl ick={(node, event) => {
    console.log(node)
  }}
/>

Объект node содержит:

{
  id,
  serieId,
  data,
  x,
  y,
  color
}

Пример открытия карточки объекта:

onCl ick={(node) => {
  setSelectedUser(node.data.user)
}}

Обработка наведения

onMouseEn ter={(node) => {
  console.log('hover', node)
}}

onMouseLe ave={(node) => {
  console.log('leave', node)
}}

Часто используется для:

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

Контекстное меню

onContextM enu={(node, event) => {
  event.preventDefault()

  console.log('context menu')
}}

Подобный подход применяется в аналитических системах и BI-панелях.

Tooltip-система

Tooltip — основной механизм отображения информации о точке.

Стандартный tooltip:

enableTooltip={true}

Nivo автоматически показывает:

  • координаты;
  • идентификатор серии;
  • цвет;
  • дополнительные поля объекта.

Кастомный tooltip

Наиболее распространённый сценарий — создание собственного компонента.

const CustomTooltip = ({ node }) => {
  return (
    <div
      style={{
        background: 'white',
        padding: 12,
        border: '1px solid #ccc',
      }}
    >
      <strong>{node.serieId}</strong>

      <div>X: {node.data.x}</div>
      <div>Y: {node.data.y}</div>
    </div>
  )
}

Подключение:

<ResponsiveScatterPlot
  tooltip={CustomTooltip}
/>

Передача дополнительных данных

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

const data = [
  {
    id: 'users',
    data: [
      {
        x: 10,
        y: 20,
        name: 'Alex',
        country: 'Germany',
        sales: 1400,
      },
    ],
  },
]

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

const CustomTooltip = ({ node }) => (
  <div>
    <div>{node.data.name}</div>
    <div>{node.data.country}</div>
    <div>{node.data.sales}</div>
  </div>
)

Динамическое изменение размера точек

Bubble-диаграммы используют размер точки как дополнительное измерение данных.

nodeSize={(node) => node.data.sales / 100}

Полный пример:

<ResponsiveScatterPlot
  data={data}
  nodeSize={(node) => node.data.population / 1000000}
/>

В результате:

  • большие значения создают крупные пузыри;
  • маленькие значения отображаются меньшими элементами.

Интерактивное изменение размера при hover

Размер пузыря можно менять при наведении.

nodeSize={(node) =>
  hoveredId === node.id ? 30 : 12
}

Пример с состоянием:

const [hoveredId, setHoveredId] = useState(null)

<ResponsiveScatterPlot
  data={data}
  nodeSize={(node) =>
    hoveredId === node.id ? 25 : 10
  }
  onMouseEn ter={(node) => {
    setHoveredId(node.id)
  }}
  onMouseLe ave={() => {
    setHoveredId(null)
  }}
/>

Подобный эффект часто применяется в:

  • аналитических панелях;
  • дашбордах;
  • CRM-системах;
  • финансовых графиках.

Изменение цвета при взаимодействии

Цвет точки также может меняться динамически.

nodeColor={(node) =>
  selectedId === node.id
    ? '#ff0000'
    : node.color
}

Пример выбора точки:

const [selectedId, setSelectedId] = useState(null)

<ResponsiveScatterPlot
  data={data}
  onCl ick={(node) => {
    setSelectedId(node.id)
  }}
  nodeColor={(node) =>
    selectedId === node.id
      ? '#ff5722'
      : '#468df3'
  }
/>

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

Nivo позволяет полностью переопределять визуальные слои.

Стандартные слои:

layers={[
  'grid',
  'axes',
  'nodes',
  'markers',
  'mesh',
  'legends',
]}

Можно добавить собственный слой:

const CustomLayer = ({ nodes }) => {
  return (
    <g>
      {nodes.map(node => (
        <text
          key={node.id}
          x={node.x}
          y={node.y - 10}
          textAnchor="middle"
        >
          {node.data.label}
        </text>
      ))}
    </g>
  )
}

Подключение:

layers={[
  'grid',
  'axes',
  'nodes',
  CustomLayer,
  'mesh',
]}

Интерактивные подписи

Подписи можно показывать только при наведении.

const LabelsLayer = ({ nodes }) => {
  return (
    <g>
      {nodes.map(node => {
        if (hoveredId !== node.id) {
          return null
        }

        return (
          <text
            key={node.id}
            x={node.x}
            y={node.y - 15}
          >
            {node.data.name}
          </text>
        )
      })}
    </g>
  )
}

Анимация взаимодействий

Nivo использует библиотеку React Spring.

Включение анимации:

animate={true}
motionConfig="gentle"

Доступные пресеты:

  • default
  • gentle
  • wobbly
  • stiff
  • slow
  • molasses

Пример:

<ResponsiveScatterPlot
  data={data}
  animate={true}
  motionConfig="wobbly"
/>

Bubble Chart с несколькими измерениями

Bubble-график обычно использует:

  • X — первое значение;
  • Y — второе значение;
  • размер пузыря — третье значение;
  • цвет — четвёртое измерение.

Пример:

const data = [
  {
    id: 'countries',
    data: [
      {
        x: 82,
        y: 91,
        population: 145000000,
        gdp: 18000,
      },
    ],
  },
]

Размер:

nodeSize={(node) =>
  Math.sqrt(node.data.population) / 500
}

Цвет:

nodeColor={(node) =>
  node.data.gdp > 20000
    ? '#2e7d32'
    : '#c62828'
}

Выделение группы точек

Иногда требуется подсветить целую серию.

nodeOpacity={(node) =>
  activeSerie === node.serieId ? 1 : 0.2
}

Наведение на легенду:

legends={[
  {
    anchor: 'bottom-right',
    direction: 'column',
    itemDirection: 'left-to-right',
    translateX: 130,
    itemWidth: 100,
    itemHeight: 12,

    effects: [
      {
        on: 'hover',
        style: {
          itemOpacity: 1,
        },
      },
    ],
  },
]}

Интерактивные легенды

Легенды могут выступать элементом управления графиком.

Пример фильтрации:

const [hiddenSeries, setHiddenSeries] = useState([])

const filteredData = data.filter(
  item => !hiddenSeries.includes(item.id)
)

Переключение:

onCl ick={(serie) => {
  setHiddenSeries(prev =>
    prev.includes(serie.id)
      ? prev.filter(id => id !== serie.id)
      : [...prev, serie.id]
  )
}}

Работа с Canvas-версией

Для больших объёмов данных рекомендуется Canvas-компонент.

import {
  ResponsiveScatterPlotCanvas
} from '@nivo/scatterplot'

Canvas особенно эффективен при:

  • десятках тысяч точек;
  • потоковых данных;
  • real-time визуализации;
  • высокочастотных обновлениях.

Пример:

<ResponsiveScatterPlotCanvas
  data={data}
  pixelRatio={window.devicePixelRatio}
  useMesh={true}
/>

Ограничения Canvas-интерактивности

Canvas быстрее SVG, но имеет ограничения:

Возможность SVG Canvas
CSS-стилизация Да Нет
DOM-доступ Да Нет
SVG-анимации Да Нет
Производительность Средняя Высокая

Однако Nivo реализует систему виртуального hit detection, поэтому tooltip и hover продолжают работать.

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

Полный контроль над отображением точки.

const CustomNode = ({
  node,
  x,
  y,
  size,
  color
}) => {
  return (
    <g transform={`translate(${x}, ${y})`}>
      <circle
        r={size / 2}
        fill={color}
      />

      <text
        y={-20}
        textAnchor="middle"
      >
        {node.data.label}
      </text>
    </g>
  )
}

Подключение:

renderNode={CustomNode}

Создание сложных hover-эффектов

Пример свечения:

const CustomNode = ({
  node,
  x,
  y,
  size,
}) => {
  const isHovered = hoveredId === node.id

  return (
    <g transform={`translate(${x}, ${y})`}>
      <circle
        r={size / 2}
        fill="#1976d2"
        filter={
          isHovered
            ? 'url(#shadow)'
            : undefined
        }
      />
    </g>
  )
}

Использование SVG-фильтров

Добавление glow-эффекта:

<defs>
  <filter id="shadow">
    <feDropShadow
      dx="0"
      dy="0"
      stdDeviation="4"
      floodColor="#2196f3"
    />
  </filter>
</defs>

Синхронизация нескольких графиков

ScatterPlot можно синхронизировать с другими визуализациями.

Пример:

const [selectedPoint, setSelectedPoint] =
  useState(null)

Обработка:

onMouseEn ter={(node) => {
  setSelectedPoint(node.id)
}}

Передача в другой график:

<BarChart
  activeId={selectedPoint}
/>

Такой подход применяется в:

  • BI-панелях;
  • системах мониторинга;
  • аналитических платформах;
  • финансовых приложениях.

Масштабирование и zoom

Nivo не предоставляет встроенный zoom для ScatterPlot, но интеграция возможна через D3 Zoom.

Подключение:

npm install d3-zoom

Базовая интеграция:

import { zoom } from 'd3-zoom'
import { sel ect } from 'd3-selection'

Инициализация:

useEffect(() => {
  const svg = sel ect(svgRef.current)

  svg.call(
    zoom().on('zoom', (event) => {
      console.log(event.transform)
    })
  )
}, [])

Brush-выделение области

Brush позволяет выделять диапазон точек.

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

npm install d3-brush

Пример:

import { brush } fr om 'd3-brush'

Создание brush:

brush().on('end', (event) => {
  console.log(event.selection)
})

Оптимизация интерактивности

При работе с большими наборами данных важно:

  • отключать лишние анимации;
  • минимизировать количество re-render;
  • использовать Canvas;
  • мемоизировать tooltip;
  • избегать тяжёлых вычислений в nodeSize.

Пример оптимизации:

const memoizedData = useMemo(() => {
  return transformData(rawData)
}, [rawData])

Мемоизация tooltip:

const CustomTooltip = React.memo(({ node }) => {
  return (
    <div>
      {node.data.label}
    </div>
  )
})

Accessibility и интерактивность

Nivo поддерживает accessibility-атрибуты.

isInteractive={true}

ARIA-подписи:

ariaLabel="Sales scatter plot"

Описание точек:

nodeAriaLabel={(node) =>
  `${node.serieId}: x ${node.data.x}, y ${node.data.y}`
}

Это особенно важно для:

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

Управление курсором

Изменение курсора:

theme={{
  tooltip: {
    container: {
      cursor: 'pointer',
    },
  },
}}

Через custom node:

<circle
  style={{
    cursor: 'pointer',
  }}
/>

Использование React state для интерактивности

Большинство интерактивных сценариев строится вокруг состояния.

Пример:

const [hoveredNode, setHoveredNode] =
  useState(null)

const [selectedNode, setSelectedNode] =
  useState(null)

Далее состояние используется для:

  • изменения цвета;
  • фильтрации;
  • обновления tooltip;
  • отображения модальных окон;
  • синхронизации графиков.

Полноценный пример Bubble Chart

import { ResponsiveScatterPlot } fr om '@nivo/scatterplot'
import { useState } from 'react'

export default function BubbleChart() {
  const [selectedId, setSelectedId] =
    useState(null)

  const data = [
    {
      id: 'countries',
      data: [
        {
          x: 75,
          y: 90,
          population: 145,
          label: 'Country A',
          id: 'a',
        },
        {
          x: 55,
          y: 45,
          population: 80,
          label: 'Country B',
          id: 'b',
        },
      ],
    },
  ]

  return (
    <div style={{ height: 600 }}>
      <ResponsiveScatterPlot
        data={data}
        margin={{
          top: 60,
          right: 140,
          bottom: 70,
          left: 90,
        }}
        xScale={{
          type: 'linear',
          min: 0,
          max: 100,
        }}
        yScale={{
          type: 'linear',
          min: 0,
          max: 100,
        }}
        nodeSize={(node) =>
          node.data.population
        }
        nodeColor={(node) =>
          selectedId === node.id
            ? '#ff5722'
            : '#1976d2'
        }
        tooltip={({ node }) => (
          <div
            style={{
              background: '#fff',
              padding: 12,
              border: '1px solid #ccc',
            }}
          >
            <strong>
              {node.data.label}
            </strong>

            <div>
              Population:
              {node.data.population}
            </div>
          </div>
        )}
        onCl ick={(node) => {
          setSelectedId(node.id)
        }}
        useMesh={true}
        animate={true}
      />
    </div>
  )
}