31 июля 2026 г.

Как сделать графовый интерфейс в своём приложении на React Flow и Dagre.

Когда данные приложения это набор сущностей, которые ссылаются друг на друга, граф из узлов и рёбер часто оказывается самым понятным способом их показать. Нам это понадобилось в Generative Grammar Engine, который показывает генеративную грамматику именно так: каждое правило это узел, а ссылка #reference# из одного правила в другое это ребро. Граф рендерится через React Flow, а раскладка считается через Dagre. Ни то ни другое не новый алгоритм. Подумать пришлось над связкой между данными и этими двумя библиотеками, поэтому дальше рассказ о трёх частях, которые в итоге пришлось написать самим, на случай если те же части всплывут, когда вы будете добавлять граф-вид у себя.

React Flow рисует узлы и рёбра и берёт на себя панорамирование, зум и выделение. Он не знает, что означают узлы, где им стоять и когда их перестраивать. Эти решения остаются на стороне приложения, и с теми же решениями столкнётся любой, кто встраивает граф у себя.

Строим граф из своих данных

Узлы берутся прямо из ключей объекта грамматики, по узлу на правило. С рёбрами сложнее: каждая альтернатива каждого правила обходится в поисках маркеров #reference#, которые называют другое правило, и на каждую различную пару выдаётся по одному ребру. Ссылка на правило, которого не существует, здесь пропускается, а в другом месте показывается предупреждением, чтобы она не отваливалась молча.

function buildEdges(grammar) {
  const keys = new Set(Object.keys(grammar));
  const seen = new Set();
  const edges = [];
  for (const [from, alternatives] of Object.entries(grammar)) {
    for (const alt of alternatives) {
      for (const ref of referencesIn(alt)) {
        if (!keys.has(ref)) continue;          // skip references with no rule
        const id = `${from}->${ref}`;
        if (seen.has(id)) continue;            // one edge per pair
        seen.add(id);
        edges.push({ source: from, target: ref });
      }
    }
  }
  return edges;
}

Каждому узлу задаётся кастомный тип, чтобы он рисовался как редактируемая карточка, а не дефолтный прямоугольник React Flow. Тип регистрируется один раз, и React Flow использует его везде, где узел его указывает:

const nodeTypes = { grammarSymbol: GrammarSymbolNode };
// ...
<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} />

Размещаем узлы через Dagre

React Flow нужны координаты x и y для каждого узла, и сам он их не вычисляет, так что позиции должен кто-то задать. Эту задачу берёт на себя Dagre, библиотека, которая раскладывает направленные графы по аккуратным ярусам. Ей передаются узлы и рёбра вместе с размером каждого узла, запускается раскладка, а полученные координаты копируются обратно на узлы. Одно расхождение приходится учесть: Dagre отдаёт центр каждого узла, а React Flow позиционирует по левому верхнему углу, поэтому вычитается половина ширины и высоты.

const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'TB', nodesep: 48, ranksep: 72 });   // top to bottom
nodes.forEach((n) => g.setNode(n.id, { width: W, height: heightOf(n) }));
edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g);

const placed = nodes.map((n) => {
  const d = g.node(n.id);
  return { ...n, position: { x: d.x - d.width / 2, y: d.y - d.height / 2 } };
});

Высота каждого узла оценивается по его содержимому, шапка плюс по строке на каждую альтернативу, чтобы Dagre резервировал достаточно вертикального места и карточки не накладывались после размещения.

Не раскладывать на каждое нажатие клавиши

Эту часть мы бы перенесли в похожее приложение не задумываясь. Раскладка не бесплатна, а грамматика меняется на каждое нажатие клавиши, но большинство правок не меняют форму графа. Набор внутри альтернативы меняет литеральную строку, но не добавляет ни узла, ни ребра. Перезапуск Dagre на такой правке расталкивал бы узлы, пока человек ещё печатает.

Поэтому раскладка привязана к отпечатку одной только структуры: имена правил, сколько у каждого правила альтернатив и набор ссылок внутри каждой альтернативы. Правка литерала оставляет этот отпечаток прежним, поэтому предыдущая раскладка остаётся на месте и граф стоит спокойно:

const fingerprint = (grammar) =>
  Object.keys(grammar).sort().map((k) =>
    grammar[k].map((alt) => referencesIn(alt).sort().join(",")).join("|")
  ).join("\n");

// re-run Dagre only when the fingerprint changes; otherwise reuse positions

Правки ещё и фиксируются по двум отдельным таймерам: короткий перед отправкой изменений в движок и отдельный для раскладки. За счёт этого генерация остаётся отзывчивой, пока граф стоит на месте.

Остальное это настройки

Оставшееся поведение задаётся опциями React Flow. Грамматику редактируют внутри карточек-узлов, поэтому перетаскивание и соединение выключены, а холст оставлен для чтения. fitView при инициализации вписывает весь граф, а когда правка добавляет узел, вид перецентрируется на нём. Ссылка на несуществующее правило становится видимым предупреждением, а не молча пропавшим ребром.

<ReactFlow
  nodes={nodes} edges={edges} nodeTypes={nodeTypes}
  nodesDraggable={false} nodesConnectable={false}
  onInit={(flow) => flow.fitView({ padding: 0.2 })}
  onlyRenderVisibleElements
>
  <Background /> <Controls />
</ReactFlow>

Что пришлось написать нам

Рендер и раскладку React Flow и Dagre взяли на себя. Нам пришлось написать отображение данных в узлы и рёбра, оценки высоты, которые дают раскладке место, и правило, когда пересчитывать раскладку, а не повторять её на каждое изменение. Эта последняя часть, структурный отпечаток, и делает граф устойчивым, а не дёрганым, и именно её мы бы перенесли первой, если бы делали подобное снова.

Generative Grammar Engine бесплатен на grammar.tinygods.dev, а код открыт на github.com/tinygodsdev/metatracery.