Привет, друзья!


В этой серии статей я делюсь с вами своим опытом решения различных задач из области веб-разработки и не только.


В этой статье мы изучим библиотеку react-force-graph-2d для рисования двумерных графов.


Демо приложения:



Локальный запуск приложения:


git clone https://github.com/harryheman/react-graph.git
cd react-graph
npm i
npm run dev

Интересно? Тогда прошу под кат.


❯ Создание проекта и установка зависимостей


Создаем чистый React+Typescript проект с помощью Vite:


npm create vite react-graph --template react-ts

Переходим в созданную директорию и устанавливаем интересующую нас библиотеку:


cd react-graph
# Выполнение этой команды также установит основные зависимости проекта
npm i react-force-graph-2d

Далее работаем в директории src.


Удаляем директорию assets и файл App.css и определяем минимальные стили в index.css:


html,
body,
#root {
  min-height: 100%;
}

body {
  margin: 0;
}

#root {
  display: flex;
  justify-content: center;
  align-items: center;
}

h3 {
  margin: 0;
}

hr {
  width: 100%;
}

Создаем директорию components.


Определяем небольшой вспомогательный контейнер в components/Flex.tsx:


import { forwardRef, type CSSProperties, type PropsWithChildren } from 'react'

export const Flex = forwardRef<
  HTMLDivElement,
  PropsWithChildren<CSSProperties>
>(({ children, ...styles }, ref) => {
  return (
    <div
      ref={ref}
      style={{
        display: 'flex',
        ...styles,
      }}
    >
      {children}
    </div>
  )
})

Создаем директорию components/Graph для графов.


Нам потребуется функция для генерации фиктивных данных. Определяем ее в Graph/utils.ts:


import type { LinkObject, NodeObject } from 'react-force-graph-2d'

export function generateGraphData(
  n = 10,
  reverse = false,
): {
  nodes: (NodeObject & {
    neighbors?: NodeObject[]
    links?: LinkObject[]
  })[]
  links: LinkObject[]
} {
  return {
    // Узел должен содержать хотя бы `id`
    nodes: [...Array(n).keys()].map((i) => ({
      id: i,
      name: `node ${i + 1}`,
      neighbors: [],
      links: [],
    })),
    // Ребро должно содержать хотя бы `source` и `target`
    links: [...Array(n).keys()]
      .filter((id) => id)
      .map((id) => ({
        [reverse ? 'target' : 'source']: id,
        [reverse ? 'source' : 'target']: Math.round(Math.random() * (id - 1)),
        name: `link ${id}`,
      })),
  }
}

❯ API


Рассмотрим основной API, предоставляемый библиотекой.


Данные


Свойство Описание Тип По умолчанию
graphData Данные { nodes: NodeObject[], links: LinkObject[] } { nodes: [], links: [] }
nodeId Идентификатор вершины string id
linkSource Идентификатор вершины-источника string source
linkTarget Идентификатор вершины-цели string target

Контейнер


Свойство Описание Тип По умолчанию
width Ширина холста в пикселях number Ширина области просмотра
height Высота холста в пикселях number Высота области просмотра
backgroundColor Цвет фона string undefined

Вершина


Свойство Описание Тип По умолчанию
nodeRelSize Соотношение площади окружности вершины на единицу значения number 4
nodeVal Размер вершины number \| string \| function val
nodeLabel Подпись вершины string \| function name
nodeVisibility Видимость вершины boolean \| string \| function true
nodeColor Цвет вершины string \| function color
nodeAutoColorBy Группировка цветов string \| function undefined
nodeCanvasObject Функция рисования вершины function Круг размером val и цветом color
nodeCanvasObjectMode Строка или функция, определяющая режим рисования вершины (см. ниже) string \| function () => 'replace'



nodeCanvasObjectMode используется в сочетании с nodeCanvasObject для кастомизации рисования вершин. Возможные значения:


  • replace — вершина рисуется только с помощью nodeCanvasObject
  • before — сначала вершина рисуется с помощью nodeCanvasObject, затем рисуется дефолтная вершина
  • after — сначала рисуется вершина по умолчанию, затем вызывается nodeCanvasObject

Ребро


Свойство Описание Тип По умолчанию
linkLabel Подпись ребра string \| function name
linkVisibility Видимость ребра boolean \| string \| function val
linkColor Цвет ребра string \| function color
linkAutoColorBy Группировка цветов string \| function undefined
linkLineDash Массив чисел, строка или функция рисования прерывистой линии number[] \| string \| function undefined
linkWidth Ширина линии number \| string \| function 1
linkCurvature Радиус кривизны линии number \| string \| function 0
linkCanvasObject Функция рисования ребра function Линия шириной width и цветом color
linkCanvasObjectMode Строка или функция, определяющая режим рисования ребра (см. ниже) string \| function () => 'replace'
linkDirectionalArrowLength Ширина стрелки number \| string \| function 0
linkDirectionalArrowColor Цвет стрелки string \| function color
linkDirectionalArrowRelPos Положение стрелки (от 0 до 1) number \| string \| function 0.5 (стрелка рисуется посередине)
linkDirectionalParticles Анимируемые частицы (маленькие круги) поверх ребра number \| string \| function 0
linkDirectionalParticleSpeed Скорость анимации частиц number \| string \| function 0.01
linkDirectionalParticleWidth Ширина частицы number \| string \| function 0.5
linkDirectionalParticleColor Цвет частицы string \| function color



linkCanvasObjectMode используется в сочетании с linkCanvasObject для кастомизации рисования ребер. Возможные значения:


  • replace — ребро рисуется только с помощью linkCanvasObject
  • before — сначала ребро рисуется с помощью linkCanvasObject, затем рисуется дефолтное ребро
  • after — сначала рисуется ребро по умолчанию, затем вызывается linkCanvasObject

Управление рендерингом


Свойство Описание Тип По умолчанию
autoPauseRedraw Индикатор автоматической перерисовки холста на каждом кадре анимации boolean true
minZoom Минимальный масштаб number 0.01
maxZoom Максимальный масштаб number 1000
onRenderFramePre Функция, вызываемая на каждом кадре перед отрисовкой вершины/ребра function undefined
onRenderFramePost Функция, вызываемая на каждом кадре после отрисовки вершины/ребра function undefined

Методы


Метод Аргументы Описание
pauseAnimation - Приостанавливает рендеринг компонента, "замораживая" текущее отображение и отключая пользовательские взаимодействия
resumeAnimation - Возобновляет рендеринг компонента
centerAt (x?, y?, ms?) Устанавливает координаты центра области просмотра
zoom (number?, ms?) Устанавливает масштаб холста
zoomToFit (ms?, px?, nodeFilterFn?) Масштабирует граф до размеров области просмотра

Пользовательские взаимодействия


Свойство Описание Тип По умолчанию
onNodeClick Обработчик клика по вершине function undefined
onNodeRightClick Обработчик клика по вершине правой кнопкой мыши function undefined
onNodeHover Обработчик наведения курсора на вершину function undefined
onNodeDrag Обработчик перетаскивания вершины function undefined
onNodeDragEnd Обработчик завершения перетаскивания вершины function undefined
onLinkClick Обработчик клика по ребру function undefined
onLinkRightClick Обработчик клика по ребру правой кнопкой мыши function undefined
onLinkHover Обработчик наведения курсора на ребро function undefined
onBackgroundClick Обработчик клика по контейнеру графа function undefined
onBackgroundRightClick Обработчик клика по контейнеру графа правой кнопкой мыши function undefined
linkHoverPrecision Точность наведения курсора на ребро, определяющая отображение подписи number 4
onZoom Обработчик масштабирования function undefined
onZoomEnd Обработчик завершения масштабирования function undefined
enableZoomInteraction Индикатор возможности масштабирования boolean true
enablePanInteraction Индикатор возможности перетаскивания графа boolean true
enablePointerInteraction Индикатор отслеживания событий указателя (клик, наведение курсора и др.) boolean true
enableNodeDrag Индикатор возможности перетаскивания вершин boolean true
nodePointerAreaPaint Функция рисования области взаимодействия вершины function Круг размером с вершину
linkPointerAreaPaint Функция рисования области взаимодействия ребра function Прямая линия между вершинами

❯ Компоненты


Реализуем несколько вариантов графа.


Базовый вариант


По умолчанию граф является масштабируемым, перетаскиваемым (сам граф и узлы) и реагирующим на события указателя (наведение курсора, клик по узлу/вершине и т.п.). По умолчанию граф принимает размер области просмотра. При наведении курсора на узел/граф по умолчанию рендерится тултип с его названием (по умолчанию поле name соответствующего объекта, кастомизируется с помощью пропов nodeLabel и linkLabel).


// Graph/Basic.tsx
import { useState } from 'react'
import ForceGraph from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { generateGraphData } from './utils'

const graphData = generateGraphData()

function Basic() {
  // Масштабирование
  const [enableZoomInteraction, setEnableZoomInteraction] = useState(true)
  // Перетаскивание графа
  const [enablePanInteraction, setEnablePanInteraction] = useState(true)
  // Перетаскивание узлов
  const [enableNodeDrag, setEnableNodeDrag] = useState(true)
  // События указателя
  const [enablePointerInteraction, setEnablePointerInteraction] = useState(true)

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Базовый вариант</h3>
      <fieldset>
        <legend>Настройки</legend>
        <Flex flexDirection='column' gap={8}>
          <label>
            <input
              type='checkbox'
              checked={enableZoomInteraction}
              onChange={(e) => setEnableZoomInteraction(e.target.checked)}
            />{' '}
            Масштабирование графа
          </label>
          <label>
            <input
              type='checkbox'
              checked={enablePanInteraction}
              onChange={(e) => setEnablePanInteraction(e.target.checked)}
            />{' '}
            Перетаскивание графа
          </label>
          <label>
            <input
              type='checkbox'
              checked={enableNodeDrag}
              onChange={(e) => setEnableNodeDrag(e.target.checked)}
            />{' '}
            Перетаскивание вершин
          </label>
          <label>
            <input
              type='checkbox'
              checked={enablePointerInteraction}
              onChange={(e) => setEnablePointerInteraction(e.target.checked)}
            />{' '}
            События указателя
          </label>
        </Flex>
      </fieldset>
      <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          enableZoomInteraction={enableZoomInteraction}
          enablePanInteraction={enablePanInteraction}
          enableNodeDrag={enableNodeDrag}
          enablePointerInteraction={enablePointerInteraction}
        />
      </Flex>
    </Flex>
  )
}

export default Basic

Кастомизация вершин


// Graph/Node.tsx
import { useState } from 'react'
import ForceGraph from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { generateGraphData } from './utils'

const graphData = generateGraphData()

function Node() {
  // Видимость вершин
  const [nodeVisibility, setNodeVisibility] = useState(true)
  // Размер вершин
  const [nodeRelSize, setNodeRelSize] = useState(4)
  // Цвет вершин
  const [nodeColor, setNodeColor] = useState('deepskyblue')

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Кастомизация вершин</h3>
      <fieldset>
        <legend>Настройки</legend>
        <Flex flexDirection='column' gap={8}>
          <label>
            <input
              type='checkbox'
              checked={nodeVisibility}
              onChange={(e) => setNodeVisibility(e.target.checked)}
            />{' '}
            Видимость вершин
          </label>
          <label>
            Размер вершин{' '}
            <input
              type='number'
              value={nodeRelSize}
              onChange={(e) => setNodeRelSize(Number(e.target.value))}
              min={4}
              max={12}
            />
          </label>
          <Flex gap={8} alignItems='center'>
            <label>Цвет вершин</label>
            <input
              type='color'
              value={nodeColor}
              onChange={(e) => setNodeColor(e.target.value)}
            />

            <button onClick={() => setNodeColor('deepskyblue')}>Сброс</button>
          </Flex>
        </Flex>
      </fieldset>
      <Flex
        width={768}
        height={480}
        border='1px dashed rgba(0,0,0,0.25)'
        justifyContent='center'
        alignItems='center'
      >
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          nodeVisibility={nodeVisibility}
          nodeRelSize={nodeRelSize}
          nodeColor={() => nodeColor}
        />
      </Flex>
    </Flex>
  )
}

export default Node

Размер вершины может определяться с помощью поля val, а цвет — с помощью поля color.


Кастомизация ребер


// Graph/Link.tsx
import { useEffect, useState } from 'react'
import ForceGraph from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { generateGraphData } from './utils'

const initialGraphData = generateGraphData()

function Link() {
  const [graphData, setGraphData] = useState(initialGraphData)

  // Видимость ребер
  const [linkVisibility, setLinkVisibility] = useState(true)
  // Цвет ребер
  const [linkColor, setLinkColor] = useState('deepskyblue')
  // Ширина ребер
  const [linkWidth, setLinkWidth] = useState(1)
  // Прерывистость линии
  const [linkLineDash, setLinkLineDash] = useState(false)
  // Кривизна линии
  const [linkCurvature, setLinkCurvature] = useState(false)
  // Длина стрелки
  const [linkDirectionalArrowLength, setLinkDirectionalArrowLength] =
    useState(0)
  // Положение стрелки
  const [linkDirectionalArrowRelPos, setLinkDirectionalArrowRelPos] =
    useState(0.5)
  // Двойные стрелки
  const [doubleArrows, setDoubleArrows] = useState(false)

  useEffect(() => {
    if (doubleArrows) {
      // Удваиваем количество ребер
      const links = [...initialGraphData.links]
      const reversedLinks = links.map((link, i) => {
        return {
          id: links.length + i + 1,
          source: link.target,
          target: link.source,
        }
      })
      const allLinks = links.concat(reversedLinks)
      const newGraphData = {
        nodes: [...initialGraphData.nodes],
        links: allLinks,
      }
      setGraphData(newGraphData)
    } else {
      setGraphData(initialGraphData)
    }
  }, [doubleArrows])

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Кастомизация ребер</h3>
      <fieldset>
        <legend>Настройки</legend>
        <Flex flexDirection='column' gap={8}>
          <label>
            <input
              type='checkbox'
              checked={linkVisibility}
              onChange={(e) => setLinkVisibility(e.target.checked)}
            />{' '}
            Видимость вершин
          </label>
          <label>
            Ширина ребер{' '}
            <input
              type='number'
              value={linkWidth}
              onChange={(e) => setLinkWidth(Number(e.target.value))}
              min={1}
              max={4}
            />
          </label>
          <Flex gap={8} alignItems='center'>
            <label>Цвет ребер</label>
            <input
              type='color'
              value={linkColor}
              onChange={(e) => setLinkColor(e.target.value)}
            />

            <button onClick={() => setLinkColor('deepskyblue')}>Сброс</button>
          </Flex>
          <label>
            <input
              type='checkbox'
              checked={linkLineDash}
              onChange={(e) => setLinkLineDash(e.target.checked)}
            />{' '}
            Прерывистая линия
          </label>
          <label>
            <input
              type='checkbox'
              checked={linkCurvature}
              onChange={(e) => setLinkCurvature(e.target.checked)}
            />{' '}
            Кривая линия
          </label>
          <label>
            Длина стрелки{' '}
            <input
              type='number'
              value={linkDirectionalArrowLength}
              onChange={(e) =>
                setLinkDirectionalArrowLength(Number(e.target.value))
              }
              min={0}
              max={8}
            />
          </label>
          <label>
            Положение стрелки{' '}
            <input
              type='number'
              value={linkDirectionalArrowRelPos}
              onChange={(e) =>
                setLinkDirectionalArrowRelPos(Number(e.target.value))
              }
              min={0}
              max={1}
              step={0.1}
            />
          </label>
          <label>
            <input
              type='checkbox'
              checked={doubleArrows}
              onChange={(e) => setDoubleArrows(e.target.checked)}
            />{' '}
            Двойные стрелки
          </label>
        </Flex>
      </fieldset>
      <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          linkVisibility={linkVisibility}
          linkColor={() => linkColor}
          linkWidth={linkWidth}
          // [ширина линии, ширина отступа]
          linkLineDash={linkLineDash ? [4, 2] : undefined}
          // от 0 до 1
          linkCurvature={linkCurvature ? 1 : undefined}
          linkDirectionalArrowColor={() => linkColor}
          linkDirectionalArrowLength={linkDirectionalArrowLength}
          linkDirectionalArrowRelPos={linkDirectionalArrowRelPos}
        />
      </Flex>
    </Flex>
  )
}

export default Link

Цвет вершины может определяться с помощью поля color.


Иконка в узле


Для рисования иконки поверх узла нам потребуется специальная функция. Определим ее в Graph/utils.ts:


export type DrawNodeImageProps = {
  // Узел
  node: NodeObject
  // Контекст рисования
  ctx: CanvasRenderingContext2D
  // Изображение
  image: CanvasImageSource | OffscreenCanvas
}

// Дефолтный размер узла
const defaultNodeSize = 4

export const drawNodeImage = ({ node, ctx, image }: DrawNodeImageProps) => {
  if (!image) return

  // Начальные координаты и размер узла
  const nodeX = node.x || 0
  const nodeY = node.y || 0
  const nodeSize = Number(node.val) || defaultNodeSize

  // Рисуем изображение
  ctx.drawImage(
    image,
    nodeX - nodeSize,
    nodeY - nodeSize,
    nodeSize * 2,
    nodeSize * 2,
  )
}

Применяем эту функцию в пропе nodeCanvasObject:


// Graph/NodeIcon.tsx
import { useEffect, useRef, useState } from 'react'
import ForceGraph from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { drawNodeImage, generateGraphData } from './utils'

const graphData = generateGraphData()

function NodeIcon() {
  const spanRef = useRef<HTMLSpanElement>(null)
  const [images, setImages] = useState<HTMLImageElement[]>([])

  useEffect(() => {
    if (!spanRef.current) return
    const images = [...spanRef.current.querySelectorAll('img')]
    setImages(images)
  }, [])

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Узел с иконкой</h3>
      {/* Небольшой хак */}
      <span
        ref={spanRef}
        style={{
          display: 'none',
        }}
      >
        {/* Изображения лежат в директории `public/graph` */}
        <img src='/graph/briefcase.svg' alt='' />
        <img src='/graph/folder.svg' alt='' />
        <img src='/graph/font.svg' alt='' />
        <img src='/graph/paste.svg' alt='' />
        <img src='/graph/user.svg' alt='' />
      </span>
      <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25'>
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          nodeRelSize={6}
          nodeCanvasObject={(node, ctx) => {
            // Выбираем изображение
            const image = images[Number(node.id) % 5]

            // Рисуем его
            drawNodeImage({ node, ctx, image })
          }}
          // Сначала рисуем дефолтный узел, затем - иконку
          nodeCanvasObjectMode={() => 'after'}
        />
      </Flex>
    </Flex>
  )
}

export default NodeIcon

Узел с подписью


Что если мы хотим, чтобы названия узлов рендерились не в тултипе, а под узлами? Для этого нам также потребуются специальные функции. Определим их в Graph/utils.ts:


export type DrawNodeLabelProps = {
  // Узел
  node: NodeObject
  // Контекст рисования
  ctx: CanvasRenderingContext2D
  // Глобальный масштаб
  globalScale?: number
  // Размер шрифта
  fontSize?: number
  // Отступ от узла
  offset?: number
  // Узлы в состоянии hover
  hoverNodes?: (NodeObject | null)[]
  // Выбранные узлы
  clickNodes?: (NodeObject | null)[]
  // Режим отладки
  debug?: boolean
}

// Дефолтные цвета
export const defaultColors = {
  nodeColor: '#827e7e',
  activeNodeColor: '#1d75db',
  labelColor: '#1a1818',
  tooltipColor: '#f7ebeb',
}

export const drawNodeLabel = ({
  node,
  ctx,
  globalScale = 1,
  fontSize = 6,
  offset = 4,
  hoverNodes = [],
  clickNodes = [],
  debug,
}: DrawNodeLabelProps) => {
  const { activeNodeColor, labelColor } = defaultColors

  const nodeX = node.x || 0
  const nodeY = node.y || 0
  const nodeSize = Number(node.size) || defaultNodeSize

  // Рисуем текст
  const label = String(node.name) || ''
  const _fontSize = fontSize / globalScale
  ctx.font = `${_fontSize}px sans-serif`
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  const _labelColor = node.labelColor || labelColor
  const labelActiveColor = node.labelActiveColor || activeNodeColor
  // Цвет подписи зависит от состояния узла
  ctx.fillStyle =
    hoverNodes.includes(node) || clickNodes.includes(node)
      ? labelActiveColor
      : _labelColor
  ctx.fillText(label, nodeX, nodeY + nodeSize + offset)

  // Вычисляем значения для области выделения/клика
  const textWidth = ctx.measureText(label).width
  const pointerArea = {
    x: nodeX - textWidth / 2,
    y: nodeY - nodeSize / 2 - offset / 2,
    width: textWidth,
    height: nodeSize + fontSize + offset,
  }

  // Если включен режим отладки
  if (debug) {
    // Рисуем область выделения/клика
    ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'
    ctx.fillRect(
      pointerArea.x,
      pointerArea.y,
      pointerArea.width,
      pointerArea.height,
    )
  }

  // Для повторного использования в `drawNodePointerArea`
  node.pointerArea = pointerArea
}

export type NodePointerArea = {
  x: number
  y: number
  width: number
  height: number
}

export type DrawNodePointerAreaProps = {
  // Узел
  node: NodeObject
  // Цвет
  color: string
  // Контекст рисования
  ctx: CanvasRenderingContext2D
}

export const drawNodePointerArea = ({
  node,
  color,
  ctx,
}: DrawNodePointerAreaProps) => {
  ctx.fillStyle = color
  const pointerArea: NodePointerArea = node.pointerArea
  pointerArea &&
    ctx.fillRect(
      pointerArea.x,
      pointerArea.y,
      pointerArea.width,
      pointerArea.height,
    )
}

Применяем их в пропе nodeCanvasObject:


// Graph/NodeWithLabel.tsx
import ForceGraph from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { drawNodeLabel, drawNodePointerArea, generateGraphData } from './utils'

const graphData = generateGraphData()

function NodeWithLabel() {
  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Узел с подписью</h3>
      <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          // Рисуем подпись
          nodeCanvasObject={(node, ctx) =>
            drawNodeLabel({
              node,
              ctx,
            })
          }
          // Сначала рисуем дефолтный узел, затем - подпись
          nodeCanvasObjectMode={() => 'after'}
          // Рисуем область выделения/клика
          nodePointerAreaPaint={(node, color, ctx) =>
            drawNodePointerArea({ node, color, ctx })
          }
          // Отключаем тултипы
          nodeLabel='label'
          linkLabel='label'
        />
      </Flex>
    </Flex>
  )
}

export default NodeWithLabel

Узел в состоянии hover


Реализуем граф с узлами и ребрами, выделяемыми цветом при наведении. При этом, мы хотим иметь возможность выделять не только сам узел, но также его соседей и ребра. Также мы хотим иметь возможность выделять не только само ребро, но также его источник и цель (вершины).


// Graph/Hover.tsx
import { useState } from 'react'
import ForceGraph, {
  type LinkObject,
  type NodeObject,
} from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { defaultColors, generateGraphData } from './utils'

const graphData = generateGraphData()
// Мы хотим иметь возможность выделять не только узел, но также его соседей и ребра
graphData.links.forEach((link) => {
  if (typeof link.source === 'undefined' || typeof link.target === 'undefined')
    return
  const a = graphData.nodes[link.source as number]
  const b = graphData.nodes[link.target as number]

  if (!a || !b) return
  // Соседи узла
  !a.neighbors && (a.neighbors = [])
  !b.neighbors && (b.neighbors = [])
  a.neighbors.push(b)
  b.neighbors.push(a)

  // Ребра узла
  !a.links && (a.links = [])
  !b.links && (b.links = [])
  a.links.push(link)
  b.links.push(link)
})

function Hover() {
  // Узлы в состоянии hover
  const [hoverNodes, setHoverNodes] = useState<(NodeObject | null)[]>([])
  // Ребра в состоянии hover
  const [hoverLinks, setHoverLinks] = useState<(LinkObject | null)[]>([])
  // Выделение ребер узла
  const [links, setLinks] = useState(false)
  // Выделение соседей узла
  const [neighbors, setNeighbors] = useState(false)
  // Выделение источника и цели ребра
  const [nodes, setNodes] = useState(false)

  const { nodeColor, activeNodeColor } = defaultColors

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Узел в состоянии hover</h3>
      <Flex gap='$4' flexDirection='column'>
        <fieldset>
          <legend>Вершина</legend>
          <Flex flexDirection='column' gap={8}>
            <label>
              <input
                type='checkbox'
                checked={links}
                onChange={(e) => setLinks(e.target.checked)}
              />{' '}
              Ребра
            </label>
            <label>
              <input
                type='checkbox'
                checked={neighbors}
                onChange={(e) => setNeighbors(e.target.checked)}
              />{' '}
              Соседи
            </label>
          </Flex>
        </fieldset>
        <fieldset>
          <legend>Ребро</legend>
          <label>
            <input
              type='checkbox'
              checked={nodes}
              onChange={(e) => setNodes(e.target.checked)}
            />{' '}
            Источник и цель
          </label>
        </fieldset>
      </Flex>

      <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          onNodeHover={(node) => {
            const newHoverNodes = [node]
            const newHoverLinks: LinkObject[] = []
            if (node) {
              // Выделение ребер узла
              if (links) {
                newHoverLinks.push(...(node.links as LinkObject[]))
              }
              // Выделение соседей узла
              if (neighbors) {
                newHoverNodes.push(...(node.neighbors as NodeObject[]))
              }
            }
            setHoverLinks(newHoverLinks)
            setHoverNodes(newHoverNodes)
          }}
          onLinkHover={(link) => {
            const newHoverLinks = [link]
            const newHoverNodes: NodeObject[] = []
            if (link) {
              // Выделение узлов ребра
              if (nodes) {
                newHoverNodes.push(
                  link.source as NodeObject,
                  link.target as NodeObject,
                )
              }
            }
            setHoverLinks(newHoverLinks)
            setHoverNodes(newHoverNodes)
          }}
          nodeColor={(node) =>
            hoverNodes.includes(node) ? activeNodeColor : nodeColor
          }
          linkColor={(link) =>
            hoverLinks.includes(link) ? activeNodeColor : nodeColor
          }
          linkDirectionalArrowColor={(link) =>
            hoverLinks.includes(link) ? activeNodeColor : nodeColor
          }
        />
      </Flex>
    </Flex>
  )
}

export default Hover

Узел с подписью и тултипом


Что если в дополнение к подписи мы хотим рендерить собственный тултип при наведении на узел? Для этого нам потребуется специальная функция. Определим ее в Graph/utils.ts:


export type DrawNodeTooltipProps = {
  // Узел
  node: NodeObject
  // Контекст рисования
  ctx: CanvasRenderingContext2D
  // Подсказка
  tooltip: string
  // Глобальный масштаб
  globalScale?: number
  // Размер шрифта
  fontSize?: number
  // Отступ от узла
  offset?: number
  // Горизонтальный отступ
  horizontalPadding?: number
  // Вертикальный отступ
  verticalPadding?: number
}

export const drawNodeTooltip = ({
  node,
  ctx,
  tooltip,
  globalScale = 1,
  fontSize = 5,
  offset = 7,
  horizontalPadding = 8,
  verticalPadding = 6,
}: DrawNodeTooltipProps) => {
  const { tooltipColor, labelColor } = defaultColors

  const nodeX = node.x || 0
  const nodeY = node.y || 0
  const nodeSize = Number(node.size) || defaultNodeSize

  // Настраиваем текст
  const _fontSize = fontSize / globalScale
  ctx.font = `${_fontSize}px sans-serif`
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'

  // Рисуем прямоугольник
  const textWidth = ctx.measureText(tooltip).width
  const tooltipContainerColor = node.labelColor || labelColor
  ctx.fillStyle = tooltipContainerColor
  ctx.fillRect(
    nodeX - textWidth / 2 - horizontalPadding / 2,
    nodeY - nodeSize - offset - verticalPadding / 2 - fontSize / 2,
    textWidth + horizontalPadding,
    fontSize + verticalPadding,
  )

  // Рисуем текст
  const _tooltipColor = node.tooltipColor || tooltipColor
  ctx.fillStyle = _tooltipColor
  ctx.fillText(tooltip, nodeX, nodeY - nodeSize - offset)
}

Применяем ее в пропе nodeCanvasObject:


// Graph/NodeWithLabelAndTooltip.tsx
import { useState } from 'react'
import ForceGraph, { type NodeObject } from 'react-force-graph-2d'
import { Flex } from '../Flex'
import {
  defaultColors,
  drawNodeLabel,
  drawNodePointerArea,
  drawNodeTooltip,
  generateGraphData,
} from './utils'

const graphData = generateGraphData()

function NodeWithLabelAndTooltip() {
  // Узел в состоянии hover
  const [hoverNode, setHoverNode] = useState<NodeObject | null>(null)

  const { nodeColor, activeNodeColor } = defaultColors

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Узел с подписью и тултипом</h3>
      <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          onNodeHover={(node) => {
            setHoverNode(node)
          }}
          nodeCanvasObject={(node, ctx) => {
            // Рисуем подпись
            drawNodeLabel({
              node,
              ctx,
              hoverNodes: [hoverNode],
            })
            // Если узел находится в состоянии hover
            if (node === hoverNode) {
              // Рисуем тултип
              drawNodeTooltip({
                node,
                ctx,
                tooltip: `Подсказка к ${node.name}`,
              })
            }
          }}
          // Сначала рисуем дефолтный узел, затем - подпись и тултип
          // (для узла, находящегося в состоянии hover)
          nodeCanvasObjectMode={() => 'after'}
          // Рисуем область выделения/клика
          nodePointerAreaPaint={(node, color, ctx) =>
            drawNodePointerArea({ node, color, ctx })
          }
          // Цвет узла зависит от его состояния
          nodeColor={(node) =>
            node === hoverNode ? activeNodeColor : nodeColor
          }
          // Отключаем встроенные тултипы
          nodeLabel='label'
          linkLabel='label'
        />
      </Flex>
    </Flex>
  )
}

export default NodeWithLabelAndTooltip

Выделение узлов


Реализуем граф с возможность выбора узлов. Мы хотим, чтобы выбранные узлы и их подписи выделялись цветом, а также цветным кольцом вокруг узла. Для этого нам потребуется специальная функция. Определим ее в Graph/utils.ts:


export type DrawNodeRingProps = {
  // Узел
  node: NodeObject
  // Контекст рисования
  ctx: CanvasRenderingContext2D
  // Отступ от узла
  offset?: number
  // Ширина линии
  lineWidth?: number
}

export const drawNodeRing = ({
  node,
  ctx,
  offset = 5,
  lineWidth = 1,
}: DrawNodeRingProps) => {
  const { activeNodeColor } = defaultColors
  const nodeX = node.x || 0
  const nodeY = node.y || 0
  const nodeSize = Number(node.size) || defaultNodeSize
  ctx.beginPath()
  ctx.arc(nodeX, nodeY, nodeSize + offset, 0, 2 * Math.PI)
  ctx.lineWidth = lineWidth
  const ringColor = node.activeColor || activeNodeColor
  ctx.strokeStyle = ringColor
  ctx.stroke()
}

Применяем ее в пропе nodeCanvasObject:


// Graph/Click.tsx
import { useCallback, useState } from 'react'
import ForceGraph, { type NodeObject } from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { defaultColors, drawNodeRing, generateGraphData } from './utils'

const graphData = generateGraphData()

function Click() {
  // Выделенные узлы
  const [clickNodes, setClickNodes] = useState<(NodeObject | null)[]>([])
  // Индикатор выделения нескольких узлов
  const [multiple, setMultiple] = useState(false)

  const handleNodeClick = useCallback(
    (node: NodeObject) => {
      if (!multiple) {
        setClickNodes([node])
        return
      }
      let newClickNodes = [...clickNodes]
      if (newClickNodes.includes(node)) {
        newClickNodes = newClickNodes.filter((n) => n !== node)
      } else {
        newClickNodes.push(node)
      }
      setClickNodes(newClickNodes)
    },
    [clickNodes, multiple],
  )

  const { nodeColor, activeNodeColor } = defaultColors

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Выделение узлов</h3>
      <fieldset>
        <legend>Настройки</legend>
        <label>
          <input
            type='checkbox'
            checked={multiple}
            onChange={(e) => setMultiple(e.target.checked)}
          />{' '}
          Выделение нескольких вершин
        </label>
      </fieldset>
      <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          onNodeClick={handleNodeClick}
          nodeCanvasObject={(node, ctx) => drawNodeRing({ node, ctx })}
          // Рисуем кольцо вокруг выделенных узлов
          nodeCanvasObjectMode={(node) =>
            clickNodes.includes(node) ? 'before' : undefined
          }
          // При клике по фону очищаем выделенные узлы
          onBackgroundClick={() => {
            setClickNodes([])
          }}
          nodeColor={(node) =>
            clickNodes.includes(node) ? activeNodeColor : nodeColor
          }
          // Отключаем перетаскивание узлов
          enableNodeDrag={false}
        />
      </Flex>
    </Flex>
  )
}

export default Click

Вложенные узлы


Мы хотим, чтобы узлы, содержащие другие узлы, как-то обозначались. Например, в закрытом состоянии они могут обозначаться иконкой плюса, а в раскрытом — иконкой минуса.


// Graph/Children.tsx
import { useCallback, useEffect, useRef, useState } from 'react'
import ForceGraph, {
  type LinkObject,
  type NodeObject,
} from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { drawNodeImage } from './utils'

type NodeObjectWithChildren = NodeObject & {
  children?: NodeObject[]
}

const initialGraphData: {
  nodes: NodeObjectWithChildren[]
  links: LinkObject[]
} = {
  nodes: [
    {
      id: 0,
      name: 'node 0',
    },
    {
      id: 1,
      name: 'node 1',
      children: [
        {
          id: 5,
          name: 'node 5',
        },
        {
          id: 6,
          name: 'node 6',
        },
        {
          id: 7,
          name: 'node 7',
        },
      ],
    },
    {
      id: 2,
      name: 'node 2',
    },
    {
      id: 3,
      name: 'node 3',
      children: [
        {
          id: 8,
          name: 'node 8',
        },
        {
          id: 9,
          name: 'node 9',
        },
      ],
    },
    {
      id: 4,
      name: 'node 4',
    },
  ],
  links: [
    {
      source: 1,
      target: 0,
      name: 'link 1',
    },
    {
      source: 2,
      target: 0,
      name: 'link 2',
    },
    {
      source: 3,
      target: 1,
      name: 'link 3',
    },
    {
      source: 4,
      target: 3,
      name: 'link 4',
    },
  ],
}

function Children() {
  const spanRef = useRef<HTMLSpanElement>(null)
  const [images, setImages] = useState<HTMLImageElement[]>([])

  const [graphData, setGraphData] = useState(initialGraphData)
  // Раскрытые узлы
  const [expandedNodes, setExpandedNodes] = useState<NodeObject[]>([])

  useEffect(() => {
    if (!spanRef.current) return
    const images = [...spanRef.current.querySelectorAll('img')]
    setImages(images)
  }, [])

  const handleNodeClick = useCallback(
    (node: NodeObject) => {
      if (!node.children) return
      // Отслеживаем раскрытые узлы
      let newExpandedNodes = [...expandedNodes]
      if (!expandedNodes.includes(node)) {
        newExpandedNodes.push(node)
      } else {
        newExpandedNodes = newExpandedNodes.filter((n) => n !== node)
      }
      setExpandedNodes(newExpandedNodes)
      // Добавляем/удаляем вложенные вершины и ребра
      let nodes = [...graphData.nodes]
      let links = [...graphData.links]
      const children: NodeObjectWithChildren[] = node.children
      const childIds = children.map((n) => n.id)
      if (!expandedNodes.includes(node)) {
        nodes.push(...children)
        const newLinks = children.map((n, i) => ({
          id: links.length + i + 1,
          source: n.id,
          target: node.id,
        }))
        links.push(...newLinks)
      } else {
        nodes = nodes.filter((n) => !childIds.includes(n.id))
        links = links.filter((l) => {
          const sourceId = typeof l.source === 'object' ? l.source.id : l.source
          return !childIds.includes(sourceId)
        })
      }
      setGraphData({ nodes, links })
    },
    [expandedNodes, graphData],
  )

  return (
    <Flex flexDirection='column' gap={12}>
      {/* Небольшой хак */}
      <span
        ref={spanRef}
        style={{
          display: 'none',
        }}
      >
        {/* Изображения лежат в директории `public/graph` */}
        <img src='/graph/plus.svg' alt='' />
        <img src='/graph/minus.svg' alt='' />
      </span>
      <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
        <ForceGraph
          width={768}
          height={480}
          graphData={graphData}
          nodeRelSize={6}
          nodeCanvasObject={(node, ctx) => {
            // Нас интересуют только узлы с потомками
            if (node.children) {
              // images[1] - иконка минуса, images[0] - иконка плюса
              const image = expandedNodes.includes(node) ? images[1] : images[0]

              drawNodeImage({ node, ctx, image })
            }
          }}
          // Сначала рисуем дефолтный узел, затем - соответствующую иконку
          nodeCanvasObjectMode={() => 'after'}
          onNodeClick={handleNodeClick}
        />
      </Flex>
    </Flex>
  )
}

export default Children

Панель управления


Реализуем граф с возможностью программного масштабирования и центрирования.


// Graph/Toolkit.tsx
import { useEffect, useRef, useState } from 'react'
import ForceGraph, {
  type ForceGraphMethods,
  type LinkObject,
  type NodeObject,
} from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { generateGraphData } from './utils'

const graphData = generateGraphData()

type NodeType = (typeof graphData.nodes)[number]
type LinkType = (typeof graphData.links)[number]

function Toolkit() {
  // Текущий масштаб
  const [currentZoom, setCurrentZoom] = useState(1)

  const graphRef = useRef<
    | ForceGraphMethods<NodeObject<NodeType>, LinkObject<NodeType, LinkType>>
    | undefined
  >()

  // Эффект изменения масштаба
  useEffect(() => {
    if (!graphRef.current) return
    graphRef.current.zoom(currentZoom)
  }, [currentZoom])

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Панель управления</h3>

      <Flex
        width={768}
        height={480}
        border='1px dashed rgba(0,0,0,0.25)'
        position='relative'
      >
        <Flex
          position='absolute'
          top='50%'
          transform='translateY(-50%)'
          right={12}
          zIndex={1}
          flexDirection='column'
          gap={8}
          backgroundColor='gray'
          padding={8}
        >
          <button
            onClick={() => setCurrentZoom((currentZoom) => currentZoom + 0.5)}
          >
            Увеличить <br />
            масштаб
          </button>
          <button
            onClick={() => setCurrentZoom((currentZoom) => currentZoom - 0.5)}
          >
            Уменьшить <br />
            масштаб
          </button>
          <button onClick={() => graphRef.current?.zoomToFit()}>
            Увеличить <br />
            до контейнера
          </button>
          <button onClick={() => graphRef.current?.centerAt(0, 0)}>
            Выровнять <br />
            по центру
          </button>
        </Flex>
        <ForceGraph
          ref={graphRef}
          width={768}
          height={480}
          graphData={graphData}
          // После начального масштабирования (после первого рендеринга),
          // а также после масштабирования до контейнера,
          // необходимо обновить состояние текущего масштаба
          onZoomEnd={({ k }) => {
            if (k !== currentZoom) {
              setCurrentZoom(k)
            }
          }}
          // Отключаем масштабирование прокруткой
          enableZoomInteraction={false}
        />
      </Flex>
    </Flex>
  )
}

export default Toolkit

Поиск


Реализуем граф с возможностью фильтрации узлов и ребер по названиям узлов.


// Graph/Search.tsx
import { useEffect, useMemo, useState } from 'react'
import ForceGraph from 'react-force-graph-2d'
import { Flex } from '../Flex'
import { drawNodeLabel, drawNodePointerArea, generateGraphData } from './utils'

const { nodes, links } = generateGraphData(25)

const Search = () => {
  // Отфильтрованные узлы
  const [filteredNodes, setFilteredNodes] = useState(nodes)
  // Отфильтрованные ребра
  const [filteredLinks, setFilteredLinks] = useState(links)
  // Строка поиска
  const [searchQuery, setSearchQuery] = useState('')
  // Значение инпута
  const [value, setValue] = useState('')

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const query = value.trim().toLowerCase()
    setSearchQuery(query)
  }

  useEffect(() => {
    if (value === '') {
      setSearchQuery('')
    }
  }, [value])

  useEffect(() => {
    if (!searchQuery) {
      setFilteredLinks(links)
      setFilteredNodes(nodes)
      return
    }

    // Фильтруем узлы
    const _nodes = nodes.filter((n) => {
      const label = n.name as string
      return label.toLowerCase().includes(searchQuery)
    })
    const nodeIds = _nodes.map((n) => String(n.id))
    // Фильтруем ребра
    const _links = links.filter((l) => {
      const sourceId = typeof l.source === 'object' ? l.source.id : l.source
      const targetId = typeof l.target === 'object' ? l.target.id : l.target

      return (
        nodeIds.includes(String(sourceId)) && nodeIds.includes(String(targetId))
      )
    })
    setFilteredLinks(_links)
    setFilteredNodes(_nodes)
  }, [searchQuery])

  const graphData = useMemo(
    () => ({
      nodes: filteredNodes,
      links: filteredLinks,
    }),
    [filteredNodes, filteredLinks],
  )

  return (
    <Flex flexDirection='column' gap={12}>
      <h3>Поиск</h3>

      <form
        onSubmit={onSubmit}
        style={{
          display: 'flex',
          alignSelf: 'center',
        }}
      >
        <input
          value={value}
          onChange={(e) => setValue(e.target.value)}
          placeholder='Поиск...'
        />{' '}
        <button>Поиск</button>
      </form>
      <Flex width={768} height={768} border='1px dashed rgba(0,0,0,0.25)'>
        <ForceGraph
          width={768}
          height={768}
          graphData={graphData}
          nodeCanvasObject={(node, ctx) =>
            drawNodeLabel({
              node,
              ctx,
            })
          }
          nodeCanvasObjectMode={() => 'after'}
          nodePointerAreaPaint={(node, color, ctx) =>
            drawNodePointerArea({ node, color, ctx })
          }
          // Отключаем тултипы
          nodeLabel='label'
          linkLabel='label'
        />
      </Flex>
    </Flex>
  )
}

export default Search

❯ Заключение


Мы с вами рассмотрели не все возможности, предоставляемые react-force-graph-2d, но думаю, вы получили довольно полное представление о том, что позволяет делать эта библиотека. Обратите внимание, что react-force-graph-2d является частью более широкого набора инструментов для рисования графов, включая трехмерные и VR/AR варианты.


Демо приложения:



Локальный запуск приложения:


git clone https://github.com/harryheman/react-graph.git
cd react-graph
npm i
npm run dev

Happy coding!




Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Комментарии (1)


  1. Vitaly_js
    01.07.2025 10:04

    А вы зачем все это сделали в таком виде? Т.е. зачем тут все эти развернутые исходники? Очень неудобно все это читать, но больше всего непонятно именно зачем?

    Я рандомно, листая, остановился на одном из таких кусков и не очень понял зачем это показывать. У статьи же явно какие то другие цели, чем обсуждать это. Или все таки не против обсудить?

    Пример:

    Скрытый текст
    // Graph/Click.tsx
    import { useCallback, useState } from 'react'
    import ForceGraph, { type NodeObject } from 'react-force-graph-2d'
    import { Flex } from '../Flex'
    import { defaultColors, drawNodeRing, generateGraphData } from './utils'
    
    const graphData = generateGraphData()
    
    function Click() {
      // Выделенные узлы
      const [clickNodes, setClickNodes] = useState<(NodeObject | null)[]>([])
      // Индикатор выделения нескольких узлов
      const [multiple, setMultiple] = useState(false)
    
      const handleNodeClick = useCallback(
        (node: NodeObject) => {
          if (!multiple) {
            setClickNodes([node])
            return
          }
          let newClickNodes = [...clickNodes]
          if (newClickNodes.includes(node)) {
            newClickNodes = newClickNodes.filter((n) => n !== node)
          } else {
            newClickNodes.push(node)
          }
          setClickNodes(newClickNodes)
        },
        [clickNodes, multiple],
      )
    
      const { nodeColor, activeNodeColor } = defaultColors
    
      return (
        <Flex flexDirection='column' gap={12}>
          <h3>Выделение узлов</h3>
          <fieldset>
            <legend>Настройки</legend>
            <label>
              <input
                type='checkbox'
                checked={multiple}
                onChange={(e) => setMultiple(e.target.checked)}
              />{' '}
              Выделение нескольких вершин
            </label>
          </fieldset>
          <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
            <ForceGraph
              width={768}
              height={480}
              graphData={graphData}
              onNodeClick={handleNodeClick}
              nodeCanvasObject={(node, ctx) => drawNodeRing({ node, ctx })}
              // Рисуем кольцо вокруг выделенных узлов
              nodeCanvasObjectMode={(node) =>
                clickNodes.includes(node) ? 'before' : undefined
              }
              // При клике по фону очищаем выделенные узлы
              onBackgroundClick={() => {
                setClickNodes([])
              }}
              nodeColor={(node) =>
                clickNodes.includes(node) ? activeNodeColor : nodeColor
              }
              // Отключаем перетаскивание узлов
              enableNodeDrag={false}
            />
          </Flex>
        </Flex>
      )
    }
    
    export default Click

    Первый же вопрос по форме, почему это называется Click?

    Дальше, просто...

    1. Если у вас ForceGraph мэм-ный компонент, то в useCallback нужно оборачивать все, а не один handleNodeClick. В этом просто нет никакого смысла.

    2. Так как у вас ForceGraph не зависит от clickNodes, и параметризуется только обработчиками то получается Реакту вообще нет никакого смысла постоянно перерисовывать/пересчитывать этот компонент при изменении обработчиков. Он должен быть отрисован один раз, а потом уже заниматься своей работой никак не перерисовывая постоянно ваш Click

    3. Все для чего нужен перерендер Реактом - это работа с элементом формы, чекбосом. Поэтому делать clickNodes состоянием компонента нет никакого смысла и даже вредно. Его изменения не должны влиять на рендер.

    4. При работе с управляемым чекбоксом лезть в объект события нет никакой необходимости. Поэтому если есть желание тоже можно добавить обработчик через useCallback. А можно и не добавлять, т.к. в вашем случае чекбокс не библиотечный компонент.

    Скрытый текст
    // Graph/Click.tsx
    import { useCallback, useState } from 'react'
    import ForceGraph, { type NodeObject } from 'react-force-graph-2d'
    import { Flex } from '../Flex'
    import { defaultColors, drawNodeRing, generateGraphData } from './utils'
    
    const graphData = generateGraphData()
    
    function Click() {
      // Выделенные узлы
      const clickNodes = useRef<(NodeObject | null)[]>([])
      // Индикатор выделения нескольких узлов
      const [multiple, setMultiple] = useState(false)
      const isMultiple = useRef(multiple)
    
      const handleNodeClick = useCallback(
        (node: NodeObject) => {
          if (!isMultiple.current) {
            clickNodes.current = [node]
            return
          }
    
          let workIndex = clickNodes.current.indexOf(node)
          
          if (workIndex >= 0) {
            clickNodes.current.splice(workIndex, 1)
          } else {
            clickNodes.current.push(node)
          }
        },
        [],
      )
    
      const nodeCanvasObjectHandle = useCallback((node, ctx) => drawNodeRing({ node, ctx }), [])
      const nodeCanvasObjectModeHandle = useCallback((node) => clickNodes.current.includes(node) ? 'before' : undefined, [])
      const onBackgroundClick = useCallback(() => { clickNodes.current = [] }, [])
      const nodeColorHandle = useCallback((node) => clickNodes.current.includes(node) ? activeNodeColor : nodeColor, [])
      const onChangeMultiple = useCallback(() => {
        setMultiple(v => {
          const newValue = !v
    
          isMultiple.current = newValue
    
          return newValue
        })
      }, [])
    
      const { nodeColor, activeNodeColor } = defaultColors
    
      return (
        <Flex flexDirection='column' gap={12}>
          <h3>Выделение узлов</h3>
          <fieldset>
            <legend>Настройки</legend>
            <label>
              <input
                type='checkbox'
                checked={multiple}
                onChange={onChangeMultiple}
              />{' '}
              Выделение нескольких вершин
            </label>
          </fieldset>
          <Flex width={768} height={480} border='1px dashed rgba(0,0,0,0.25)'>
            <ForceGraph
              width={768}
              height={480}
              graphData={graphData}
              onNodeClick={handleNodeClick}
              nodeCanvasObject={nodeCanvasObjectHandle}
              // Рисуем кольцо вокруг выделенных узлов
              nodeCanvasObjectMode={nodeCanvasObjectModeHandle}
              // При клике по фону очищаем выделенные узлы
              onBackgroundClick={onBackgroundClick}
              nodeColor={nodeColorHandle}
              // Отключаем перетаскивание узлов
              enableNodeDrag={false}
            />
          </Flex>
        </Flex>
      )
    }
    
    export default Click

    В результате мы получили компонент, которые перерисовывается только по чекбоксу. Что тоже можно убрать, например, добавив контекст.

    И вот, возвращаемся к тому с чего я начал. Я вот не очень понимаю, зачем было выкладывать такое? Лучше же просто расписать человеческим языком и все. Отвлекаться на такой код и тем более тратить на него место, по моему, контрпродуктивно. Или все таки была цель показать как именно Реакт использовать для таких задач?