
Привет, друзья!
В этой серии статей я делюсь с вами своим опытом решения различных задач из области веб-разработки и не только.
В этой статье мы изучим библиотеку 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-канале ↩
Vitaly_js
А вы зачем все это сделали в таком виде? Т.е. зачем тут все эти развернутые исходники? Очень неудобно все это читать, но больше всего непонятно именно зачем?
Я рандомно, листая, остановился на одном из таких кусков и не очень понял зачем это показывать. У статьи же явно какие то другие цели, чем обсуждать это. Или все таки не против обсудить?
Пример:
Скрытый текст
Первый же вопрос по форме, почему это называется Click?
Дальше, просто...
1. Если у вас ForceGraph мэм-ный компонент, то в useCallback нужно оборачивать все, а не один handleNodeClick. В этом просто нет никакого смысла.
2. Так как у вас ForceGraph не зависит от clickNodes, и параметризуется только обработчиками то получается Реакту вообще нет никакого смысла постоянно перерисовывать/пересчитывать этот компонент при изменении обработчиков. Он должен быть отрисован один раз, а потом уже заниматься своей работой никак не перерисовывая постоянно ваш Click
3. Все для чего нужен перерендер Реактом - это работа с элементом формы, чекбосом. Поэтому делать clickNodes состоянием компонента нет никакого смысла и даже вредно. Его изменения не должны влиять на рендер.
4. При работе с управляемым чекбоксом лезть в объект события нет никакой необходимости. Поэтому если есть желание тоже можно добавить обработчик через useCallback. А можно и не добавлять, т.к. в вашем случае чекбокс не библиотечный компонент.
Скрытый текст
В результате мы получили компонент, которые перерисовывается только по чекбоксу. Что тоже можно убрать, например, добавив контекст.
И вот, возвращаемся к тому с чего я начал. Я вот не очень понимаю, зачем было выкладывать такое? Лучше же просто расписать человеческим языком и все. Отвлекаться на такой код и тем более тратить на него место, по моему, контрпродуктивно. Или все таки была цель показать как именно Реакт использовать для таких задач?