Оргструктура на тысячи сотрудников: отделы появляются и закрываются, люди меняют руководителей чуть ли не каждую неделю. Такую иерархию мне нужно было показать на корпоративном портале: наглядно, интерактивно и без единой захардкоженной координаты.
Как схема выглядела раньше
Раньше функциональная схема в системе была плагином для Kanboard на PHP. Он появился в 2023 году, и схему на нём рисовали до 2025-го. Устроен он был так:
- Дерево собирал сервер. Рекурсивная PHP-функция склеивала вложенные
divв одну HTML-строку, в том числе карточки и меню действий. - Положение блоков решал CSS. Вложенные flex-контейнеры, подогнанные отступы и отдельные правила под конкретные id.
- Линии рисовал отдельный canvas. Скрипт брал координаты каждой пары блоков и проводил ломаную со смещениями, подобранными вручную:
// SchemeDraw/Assets/js/schemedraw.js
ctx.moveTo(to.left - 10 - scrollX - dx, from.bottom - 20 - scrollY - dy);
ctx.lineTo(to.left - 10 - scrollX - dx, (to.bottom + to.top) / 2 - scrollY - dy);
ctx.lineTo(to.left + 20 - scrollX - dx, (to.bottom + to.top) / 2 - scrollY - dy);
ctx.stroke();
- Панорамирование было самодельным. Мышь меняла прокрутку контейнера.
Схема работала, но раскладка держалась на подогнанных числах и правилах под конкретные блоки. В 2025 году схему перенесли на React Flow, а раскладку отдали ELK.js.
Почему не ручная раскладка?
Старая схема по сути и была ручной раскладкой: её подгоняли под текущее дерево. Но связи в компании живые: появляются отделы, сотрудники переходят к другим руководителям, меняются роли. Зафиксированные координаты устарели бы к следующему релизу. Нужен был layout-движок, который сам пересчитывает граф при любом изменении данных.
Я выбрал связку React Flow (для рендеринга и интерактивности: зум, панорамирование, кастомные узлы) и ELK.js (Eclipse Layout Kernel для сложных расчетов положения узлов на графе).
ReactFlowProvider в layout раздела
Хуки вроде useReactFlow работают только внутри ReactFlowProvider. Я поставил его в layout раздела схемы: и холст, и шапка с кнопками видят один и тот же экземпляр React Flow.
// frontend/src/app/(authorized)/schema/layout.tsx
import { SchemaHeader } from "@/_2_widgets";
import { ReactFlowProvider } from "@xyflow/react";
export default function SchemaLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ReactFlowProvider>
<main className="flex flex-col flex-1 min-h-0 gap-4 bg-background">
<SchemaHeader />
<section className="flex flex-col flex-1 h-full w-full">{children}</section>
</main>
</ReactFlowProvider>
);
}
Преобразование дерева в плоский граф (Tree-to-Graph Traverser)
Данные о функциональной иерархии возвращаются сервером в виде вложенного дерева (Nested Tree). React Flow, в свою очередь, ожидает на входе плоский список узлов (Nodes) и массив связей (Edges).
Для этого я написал хук useSchemaFlow, который рекурсивно обходит дерево и собирает узлы и рёбра. Каждый узел получает позицию { x: 0, y: 0 }: настоящие координаты посчитает ELK.
// frontend/src/_2_widgets/schema/model/useShemaView.ts
import { useMemo } from 'react';
import { Node, Edge, MarkerType } from '@xyflow/react';
import { FunctionalSchemaNode } from '@/_shared/types/functional-schema';
function traverse(
schemaNode: FunctionalSchemaNode,
nodes: Node<FunctionalSchemaNode>[],
edges: Edge[],
parent?: FunctionalSchemaNode
) {
// Добавляем текущую ноду в плоский список
nodes.push({
id: String(schemaNode.id),
type: 'custom',
position: { x: 0, y: 0 },
data: schemaNode,
});
// Если есть родитель - создаем направленное ребро
if (parent) {
edges.push({
id: `e-${parent.id}-${schemaNode.id}`,
source: String(parent.id),
target: String(schemaNode.id),
type: 'step',
markerEnd: {
type: MarkerType.Arrow
}
});
}
// Рекурсивно обходим дочерние узлы
if (schemaNode.children_functions && schemaNode.children_functions.length > 0) {
schemaNode.children_functions.forEach((child) => {
traverse(child, nodes, edges, schemaNode);
});
}
}
export const useSchemaFlow = (rootNode: FunctionalSchemaNode | undefined) => {
const flowData = useMemo(() => {
const nodes: Node<FunctionalSchemaNode>[] = [];
const edges: Edge[] = [];
if (!rootNode) {
return { nodes, edges };
}
traverse(rootNode, nodes, edges);
return { nodes, edges };
}, [rootNode]);
return flowData;
};
Динамический расчет раскладки с помощью ELK.js
Размер кастомных узлов зависит от контента (список исполнителей, админов и т.д.), поэтому заранее задать ширину и высоту нельзя. Схема такая:
- React Flow сначала получает позиции по умолчанию
{ x: 0, y: 0 }. - React Flow рендерит DOM-элементы, и я измеряю их реальные габариты (
measured.widthиmeasured.height). - Измеренные ноды и рёбра я отдаю в ELK.js, он считает координаты, и я обновляю состояние React Flow.
Всё это живёт в компоненте-контроллере LayoutController:
// frontend/src/_2_widgets/schema/ui/LayoutController.tsx
import { useEffect, useLayoutEffect } from 'react';
import { useReactFlow, Node } from '@xyflow/react';
import ELK, { ElkNode, LayoutOptions } from 'elkjs/lib/elk.bundled.js';
const elk = new ELK();
// Конфигурация ELK.js: иерархический алгоритм рендеринга с ортогональными связями
const elkOptions: LayoutOptions = {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
"elk.spacing.nodeNode": "40",
"elk.layered.spacing.nodeNodeBetweenLayers": "80",
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
"elk.layered.nodePlacement.bk.fixedAlignment": "RIGHTDOWN",
};
const getNodeSize = (node: Node) => {
const width = node.measured?.width || node.width;
const height = node.measured?.height || node.height;
return width && height ? { width, height } : null;
};
export const LayoutController = () => {
const { getNodes, getEdges, setNodes, setViewport } = useReactFlow();
const nodes = getNodes();
const edges = getEdges();
// При монтировании сбрасываем вьюпорт в центр
useLayoutEffect(() => {
window.requestAnimationFrame(() => {
setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 300 });
});
}, []);
useEffect(() => {
if (nodes.length === 0) return;
const timerId = setTimeout(() => {
const currentNodes = getNodes();
const currentEdges = getEdges();
// Ждем пока React Flow измерит размеры абсолютно всех нод в DOM
const allNodesMeasured = currentNodes.every(node =>
node.measured?.width && node.measured?.height
);
if (!allNodesMeasured) return;
const graph: ElkNode = {
id: "root",
layoutOptions: elkOptions,
children: currentNodes.map((node) => {
const size = getNodeSize(node)!;
return {
id: node.id,
width: size.width,
height: size.height,
layoutOptions: {
'elk.alignment': 'TOP'
},
};
}),
edges: currentEdges.map((edge) => ({
id: edge.id,
sources: [edge.source!],
targets: [edge.target!],
})),
};
// Вызов Layout расчетника ELK
elk.layout(graph)
.then((layoutedGraph) => {
if (!layoutedGraph.children) return;
// Обновляем координаты нод на основе результатов ELK
const newNodes = currentNodes.map((node) => {
const layoutedNode = layoutedGraph.children!.find(
(n) => n.id === node.id
);
if (layoutedNode?.x !== undefined && layoutedNode?.y !== undefined) {
return {
...node,
position: {
x: layoutedNode.x,
y: layoutedNode.y,
},
};
}
return node;
});
setNodes(newNodes);
})
.catch(console.error);
}, 100);
return () => clearTimeout(timerId);
}, [nodes, edges]);
return null;
};
Кастомный узел графа: SchemaNode
Узел отображает карточку с именем функции, списком администраторов и команды, ожидаемым результатом, а также контекстным меню действий (удаление, редактирование, перемещение и добавление подфункции). Списки людей могут сворачиваться с помощью кнопки во избежание чрезмерного растягивания карточки по вертикали.
Ниже упрощённая версия компонента: в рабочем коде действия открывают диалоги подтверждения, здесь вместо них заглушки.
// frontend/src/_2_widgets/schema/ui/schema-node.tsx
import React, { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import {
BoxIcon,
EllipsisVertical,
ChevronUpIcon,
ChevronDownIcon
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent
} from '@/shared/ui/dropdown-menu';
import { Button } from '@/shared/ui/button';
import { FunctionalSchemaNode } from '@/_shared/types/functional-schema';
interface SchemaNodeProps {
data: FunctionalSchemaNode;
}
export const SchemaNode = ({ data: node }: SchemaNodeProps) => {
const [showAll, setShowAll] = useState(false);
const router = useRouter();
const displayedExecutors = showAll ? node.executors : node.executors?.slice(0, 4);
const displayedAdmins = showAll ? node.admins : node.admins?.slice(0, 4);
const actionHandlers = useMemo(() => ({
"function.move": () => console.log('Moving node', node.id),
"function.add-sub-function": () => router.push(`/schema/create-function?parent_id=${node.id}`),
"function.edit": () => router.push(`/schema/edit-function?id=${node.id}`),
"function.open-management": () => router.push(`/schema/function/${node.id}`),
"function.remove": () => console.log('Removing node', node.id),
}), [node]);
return (
<div className="p-4 bg-slate-900 border border-slate-800 rounded-xl min-w-[280px] text-slate-100 shadow-md">
<div className="flex items-center justify-between font-medium border-b border-slate-800 pb-2 mb-3">
<div className="flex items-center">
<BoxIcon className="inline-block mr-2 h-5 w-5 stroke-cyan-500" />
<span>{node.name}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="p-1 hover:bg-slate-800 rounded-full">
<EllipsisVertical className="h-5 w-5 text-slate-400" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{node.actions.map((action) => (
<button
key={action}
className="w-full text-left p-2 hover:bg-slate-800 text-sm"
onClick={actionHandlers[action as keyof typeof actionHandlers]}
>
{action}
</button>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{node.admins && node.admins.length > 0 && (
<div className="mb-3">
<span className="text-xs text-slate-500 font-semibold block mb-1">Администраторы</span>
<div className="flex flex-wrap gap-2">
{displayedAdmins.map((admin) => (
<span key={admin.id} className="text-xs bg-slate-800 px-2 py-1 rounded">
{admin.name}
</span>
))}
</div>
</div>
)}
{node.executors && node.executors.length > 0 && (
<div className="mb-3">
<span className="text-xs text-slate-500 font-semibold block mb-1">Исполнители</span>
<div className="flex flex-wrap gap-2">
{displayedExecutors.map((exe) => (
<span key={exe.id} className="text-xs bg-slate-800 px-2 py-1 rounded">
{exe.name}
</span>
))}
</div>
</div>
)}
{node.result && (
<div className="mb-3 bg-slate-950 p-2 rounded border border-slate-800/50">
<span className="text-xs text-slate-500 block mb-1">Ожидаемый результат</span>
<span className="text-xs text-slate-300">{node.result}</span>
</div>
)}
{((node.executors?.length || 0) > 4 || (node.admins?.length || 0) > 4) && (
<div className="flex justify-center mt-2">
<Button
onClick={() => setShowAll(!showAll)}
className="text-[11px] text-slate-400 flex items-center gap-1 hover:text-white"
>
{showAll ? (
<>Свернуть <ChevronUpIcon className="h-3 w-3" /></>
) : (
<>Развернуть <ChevronDownIcon className="h-3 w-3" /></>
)}
</Button>
</div>
)}
</div>
);
};
Компоновка интерактивной доски холста (Schema Canvas)
Наконец, я объединяю все части в главном виджете холста Schema. Связываю ReactFlow со своими кастомными nodeTypes и внедряю <LayoutController /> прямо внутрь холста:
// frontend/src/_2_widgets/schema/ui/schema.tsx
'use client';
import React from 'react';
import { ReactFlow } from '@xyflow/react';
import { SchemaNode } from './schema-node';
import { LayoutController } from './LayoutController';
import { useGetSchema } from '@/_shared/api/schema/useGetSchema';
import { useSchemaFlow } from '../model/useShemaView';
import { LoadingSpinner } from '@/_shared/ui/loading-spinner';
const nodeTypes = {
custom: SchemaNode,
};
export const Schema = () => {
const { data: schemaData, isLoading } = useGetSchema();
const rootNode = Array.isArray(schemaData) ? schemaData[0] : schemaData;
const { nodes, edges } = useSchemaFlow(rootNode);
if (isLoading) return <LoadingSpinner />;
if (nodes.length === 0) {
return <div>Не удалось загрузить схему или схема пуста.</div>;
}
return (
<div className="h-full w-full bg-slate-950 rounded-2xl overflow-hidden border border-slate-800">
<ReactFlow
defaultNodes={nodes}
defaultEdges={edges}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
panOnScroll
proOptions={{ hideAttribution: true }}
>
<LayoutController />
</ReactFlow>
</div>
);
};
Цена решения
Схема держится на двух проходах (рендер и измерение, затем раскладка), и у этого есть ограничения:
- Раскладка считается целиком. При любом изменении данных ELK пересчитывает весь граф, а не только изменившуюся ветку. На нашем дереве это не мешает, но замеров на тысячах узлов у меня нет.
- Все карточки живут в DOM. Опцию
onlyRenderVisibleElementsя не включал, а карточки не обёрнуты вReact.memo. Если дерево сильно вырастет, начинать нужно с этого. - Раскладка ждёт измерений. Если хотя бы один узел ещё не измерен, пересчёт пропускается до следующего изменения. Задержка в 100 мс нужна, чтобы React Flow успел измерить карточки.
- Сворачивается только содержимое карточки. Списки администраторов и команды показывают первые четыре человека и разворачиваются кнопкой. Сворачивать поддеревья целиком схема пока не умеет.
Демонстрация работы интерактивной схемы
Итог
Раскладку графа больше никто не считает вручную. Штатное расписание меняется, граф перерисовывается сам. Вот что изменилось по сравнению со старым плагином: