Code Tutorials — React: рисуем двумерный граф
- среда, 2 июля 2025 г. в 00:00:05
 

Привет, друзья!
В этой серии статей я делюсь с вами своим опытом решения различных задач из области веб-разработки и не только.
В этой статье мы изучим библиотеку 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, предоставляемый библиотекой.
| Свойство | Описание | Тип | По умолчанию | 
|---|---|---|---|
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 — вершина рисуется только с помощью nodeCanvasObjectbefore — сначала вершина рисуется с помощью 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 — ребро рисуется только с помощью linkCanvasObjectbefore — сначала ребро рисуется с помощью 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Реализуем граф с узлами и ребрами, выделяемыми цветом при наведении. При этом, мы хотим иметь возможность выделять не только сам узел, но также его соседей и ребра. Также мы хотим иметь возможность выделять не только само ребро, но также его источник и цель (вершины).
// 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 devHappy coding!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩