Привет, друзья!
В этой серии статей я делюсь с вами своим опытом решения различных задач из области веб-разработки и не только.
В этой статье мы изучим библиотеку 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— вершина рисуется только с помощью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
Узел в состоянии 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-канале ↩
ссылка на оригинал статьи https://habr.com/ru/articles/917064/

Добавить комментарий