← Библиотека ЭКВИЛИБРИУМ

EQUILIBRIUM UI KIT фигма

EQUILIBRIUM UI KIT / FIGMA ДОКУМЕНТ. Сейчас сделаем это как реальный UI-документ под Figma, а не просто описание. Ты это можешь отдать дизайнеру или сразу собирать сам. Мы даем:

EQUILIBRIUM UI KIT / FIGMA ДОКУМЕНТ

СТРУКТУРА ФАЙЛА FIGMA

Создай файл: EQUILIBRIUM / INTERFACE V1

Страницы (Pages):

  • 00 — Foundations (стили)
  • 01 — Components
  • 02 — Screens
  • 03 — Flows (логика)

PAGE 00 — FOUNDATIONS

Цветовая система

Основные:

  • Background: #0B0F1A (глубокий тёмный)
  • Primary: #4DA3FF (синий разум)
  • Accent: #7B61FF (фиолет трансформации)

Состояния:

  • 🟢 Harmony: #3DDC97
  • 🟡 Tension: #FFC857
  • 🔴 Crisis: #FF4D4F
  • 🔵 Growth: #4DA3FF
  • ⚫ Unknown: #2A2F3A

Поверхности:

  • Card: #121826
  • Border: #1F2633

Типографика

  • Заголовок: Inter / Bold / 20–28px
  • Текст: Inter / Regular / 14–16px
  • Мелкий: 12px

Эффекты

  • Glow (для гиперрёбер):
  • blur: 20–40
  • opacity: 0.3–0.6
  • Hover:
  • scale: 1.05
  • glow усиление

PAGE 01 — COMPONENTS

Node (узел). Размеры:

  • S: 16px
  • M: 32px
  • L: 64px

Свойства:

  • цвет = тип
  • размер = влияние

Состояния:

  • normal
  • hovered
  • active
  • overloaded (пульсация)

HyperEdge (гиперребро). Это НЕ линия.

Визуал:

  • мягкое облако
  • градиент
  • blur

Варианты

  • слабое
  • активное
  • перегруженное
  • конфликтное

Flow Line

  • тонкая линия
  • анимация движения
  • направление

Panel (карточка)

  • Используется справа.

Размер

  • ширина: 360–420px

Содержит:

  • заголовок
  • блоки данных
  • кнопки

Button

Типы:

  • Primary
  • Secondary
  • Ghost

Mode Switch. Переключатель:

  • Обзор
  • Диагностика
  • Сценарий
  • Действие

Timeline

  • горизонтальная шкала
  • draggable
  • отметки событий

PAGE 02 — SCREENS

SCREEN 1 — GLOBAL DASHBOARD

Frame 1440 × 900 (desktop)

Layout

TOP BAR (64px)


LEFT PANEL (240px) | CENTER GRAPH | RIGHT PANEL (400px)


BOTTOM TIMELINE (120px)

TOP BAR

Слева → направо:

  • Logo (EQUILIBRIUM)
  • Search
  • Level selector
  • Mode switch
  • AI button
  • Settings

LEFT PANEL

Sections:

  • Типы узлов
  • Типы связей
  • Состояния

👉 Используй чекбоксы

CENTER — GRAPH AREA

Это главный фрейм.

Внутри:

  • Nodes (Auto Layout disabled)
  • HyperEdges (under layer)
  • Flows (top layer animation) 👉 Используй абсолютное позиционирование

RIGHT PANEL

Состояние 1 — Node Vie

  • Title
  • Type
  • Metrics
  • Connections
  • Buttons

Состояние 2 — Edge View

  • Смысл
  • Участники
  • Ресурсы
  • Эффект
  • Метрики

BOTTOM — TIMELINE

  • линия времени
  • точки событий
  • drag

PAGE 03 — FLOWS

Flow 1 — Выбор узла

Клик →

→ подсветка

→ открытие панели

Flow 2 — Выбор гиперребра

Клик →

→ раскрытие протокола

Flow 3 — Создание связи

  • выбрать узел
  • нажать ➕
  • выбрать тип
  • заполнить поля

Flow 4 — Диагностика

Переключение режима →

→ подсветка проблем

Flow 5 — Сценарий

Изменение параметра →

→ обновление графа

КОМПОНЕНТЫ ДЛЯ СТАРТА (минимум)

Если хочешь быстро:

Сделай:

  • Node (1 компонент)
  • Panel
  • Graph area
  • Top bar

👉 Этого достаточно для первого прототипа

КРИТИЧЕСКИЕ ПРАВИЛА (запомни)

  • ❌ не перегружай экран
  • ❌ не делай мелкие элементы
  • ❌ не делай сложные формы

3 главных принципа:

👉 простота

👉 читаемость

👉 ощущение живой системы




Стек MVP

MVP

Идём в код так, чтобы у нас получился скелет MVP, а не набор красивых слов. Ниже даем:

  • стек,
  • структуру проекта,
  • модели данных,
  • фронт на React,
  • стор,
  • API,
  • Neo4j-схему,
  • первый рабочий сценарий.

Для визуального графа сейчас разумно брать React Flow; в актуальной версии используется пакет @xyflow/react, а кастомные узлы там делаются обычными React-компонентами. Для графовой БД под это естественно ложится официальный neo4j-driver. 

Стек MVP

Frontend

  • Next.js / React
  • TypeScript
  • @xyflow/react
  • Zustand

Backend

  • Next.js Route Handlers или Node/Express
  • TypeScript
  • Neo4j
  • neo4j-driver

Почему так

  • React Flow уже заточен под node-based UI.  
  • Neo4j хорошо подходит, потому что нам нужны узлы, связи, типы отношений и запросы по графу. Официальный JS-драйвер поддерживается напрямую.  


Структура проекта

equilibrium-mvp/

├─ app/

│  ├─ page.tsx

│  ├─ api/

│  │  ├─ graph/route.ts

│  │  ├─ node/route.ts

│  │  └─ edge/route.ts

├─ components/

│  ├─ graph/

│  │  ├─ EquilibriumCanvas.tsx

│  │  ├─ nodes/

│  │  │  ├─ MeaningNode.tsx

│  │  │  ├─ ActorNode.tsx

│  │  │  └─ EffectNode.tsx

│  │  ├─ panels/

│  │  │  ├─ LeftFilters.tsx

│  │  │  ├─ RightInspector.tsx

│  │  │  └─ BottomTimeline.tsx

│  │  └─ TopBar.tsx

├─ lib/

│  ├─ neo4j.ts

│  ├─ mapper.ts

│  └─ constants.ts

├─ store/

│  └─ equilibrium.store.ts

├─ types/

│  └─ equilibrium.ts

├─ cypher/

│  ├─ schema.cypher

│  ├─ seed.cypher

│  └─ queries.cypher

├─ package.json

└─ .env.local


Типы данных

Начинай не с 12 типов, а с 6. Иначе утонешь.

types/equilibrium.ts

export type EQNodeType =

  | 'meaning'

  | 'actor'

  | 'process'

  | 'resource'

  | 'norm'

  | 'effect';


export type EQEdgeType =

  | 'intent'

  | 'cooperation'

  | 'production'

  | 'feedback'

  | 'evolution';


export type EQState =

  | 'harmony'

  | 'tension'

  | 'crisis'

  | 'growth'

  | 'transformation'

  | 'unknown';


export interface EQMetrics {

  trust?: number;

  activity?: number;

  load?: number;

  influence?: number;

  stability?: number;

  ethics?: number;

}


export interface EQNodeData {

  label: string;

  nodeType: EQNodeType;

  description?: string;

  state: EQState;

  metrics: EQMetrics;

  tags?: string[];

}


export interface EQNode {

  id: string;

  type: EQNodeType;

  position: { x: number; y: number };

  data: EQNodeData;

}


export interface EQEdgeData {

  label: string;

  edgeType: EQEdgeType;

  meaning?: string;

  process?: string;

  norm?: string;

  metric?: string;

  ethics?: string;

  intensity?: number;

  risk?: number;

  state: EQState;

}


export interface EQEdge {

  id: string;

  source: string;

  target: string;

  type: 'default' | 'hyper';

  data: EQEdgeData;

}


export interface GraphPayload {

  nodes: EQNode[];

  edges: EQEdge[];

}

Zustand store

store/equilibrium.store.ts

'use client';


import { create } from 'zustand';

import type { GraphPayload, EQNode, EQEdge, EQState, EQNodeType, EQEdgeType } from '@/types/equilibrium';


interface FiltersState {

  nodeTypes: EQNodeType[];

  edgeTypes: EQEdgeType[];

  states: EQState[];

}


interface EquilibriumStore {

  graph: GraphPayload;

  selectedNode: EQNode | null;

  selectedEdge: EQEdge | null;

  filters: FiltersState;


  setGraph: (graph: GraphPayload) => void;

  selectNode: (node: EQNode | null) => void;

  selectEdge: (edge: EQEdge | null) => void;

  updateFilters: (filters: Partial<FiltersState>) => void;

  upsertNode: (node: EQNode) => void;

  upsertEdge: (edge: EQEdge) => void;

}


export const useEquilibriumStore = create<EquilibriumStore>((set) => ({

  graph: { nodes: [], edges: [] },

  selectedNode: null,

  selectedEdge: null,

  filters: {

    nodeTypes: ['meaning', 'actor', 'process', 'resource', 'norm', 'effect'],

    edgeTypes: ['intent', 'cooperation', 'production', 'feedback', 'evolution'],

    states: ['harmony', 'tension', 'crisis', 'growth', 'transformation', 'unknown'],

  },


  setGraph: (graph) => set({ graph }),

  selectNode: (node) => set({ selectedNode: node, selectedEdge: null }),

  selectEdge: (edge) => set({ selectedEdge: edge, selectedNode: null }),

  updateFilters: (filters) =>

    set((state) => ({

      filters: { ...state.filters, ...filters },

    })),


  upsertNode: (node) =>

    set((state) => {

      const exists = state.graph.nodes.some((n) => n.id === node.id);

      return {

        graph: {

          ...state.graph,

          nodes: exists

            ? state.graph.nodes.map((n) => (n.id === node.id ? node : n))

            : [...state.graph.nodes, node],

        },

      };

    }),


  upsertEdge: (edge) =>

    set((state) => {

      const exists = state.graph.edges.some((e) => e.id === edge.id);

      return {

        graph: {

          ...state.graph,

          edges: exists

            ? state.graph.edges.map((e) => (e.id === edge.id ? edge : e))

            : [...state.graph.edges, edge],

        },

      };

    }),

}));


Компонент графа

React Flow рендерит nodes, edges и обрабатывает интеракцию через колбэки вроде onNodesChange. Это как раз то, что нам нужно для базового холста. 

components/graph/EquilibriumCanvas.tsx


'use client';


import { useEffect, useMemo } from 'react';

import {

  Background,

  Controls,

  MiniMap,

  ReactFlow,

  ReactFlowProvider,

  addEdge,

  useEdgesState,

  useNodesState,

  Connection,

  Edge,

  NodeMouseHandler,

  EdgeMouseHandler,

} from '@xyflow/react';

import '@xyflow/react/dist/style.css';


import { useEquilibriumStore } from '@/store/equilibrium.store';

import MeaningNode from './nodes/MeaningNode';

import ActorNode from './nodes/ActorNode';

import EffectNode from './nodes/EffectNode';


const nodeTypes = {

  meaning: MeaningNode,

  actor: ActorNode,

  effect: EffectNode,

};


function CanvasInner() {

  const { graph, selectNode, selectEdge } = useEquilibriumStore();


  const initialNodes = useMemo(() => graph.nodes, [graph.nodes]);

  const initialEdges = useMemo(

    () =>

      graph.edges.map((e) => ({

        ...e,

        animated: e.data.state === 'growth',

        style: {

          strokeWidth: 2,

        },

      })),

    [graph.edges]

  );


  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);

  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);


  useEffect(() => setNodes(initialNodes), [initialNodes, setNodes]);

  useEffect(() => setEdges(initialEdges), [initialEdges, setEdges]);


  const onConnect = (connection: Connection) => {

    setEdges((eds) =>

      addEdge(

        {

          ...connection,

          type: 'default',

          data: {

            label: 'New relation',

            edgeType: 'cooperation',

            state: 'unknown',

          },

        },

        eds

      )

    );

  };


  const onNodeClick: NodeMouseHandler = (_, node) => {

    selectNode(node as any);

  };


  const onEdgeClick: EdgeMouseHandler = (_, edge) => {

    selectEdge(edge as any);

  };


  return (

    <div style={{ width: '100%', height: '100%' }}>

      <ReactFlow

        nodes={nodes}

        edges={edges as Edge[]}

        onNodesChange={onNodesChange}

        onEdgesChange={onEdgesChange}

        onConnect={onConnect}

        onNodeClick={onNodeClick}

        onEdgeClick={onEdgeClick}

        nodeTypes={nodeTypes}

        fitView

      >

        <MiniMap />

        <Controls />

        <Background gap={20} />

      </ReactFlow>

    </div>

  );

}


export default function EquilibriumCanvas() {

  return (

    <ReactFlowProvider>

      <CanvasInner />

    </ReactFlowProvider>

  );

}

Кастомные узлы

В React Flow кастомные узлы — это обычные React-компоненты, которые передаются в nodeTypes. Это официально поддерживаемый путь. 

components/graph/nodes/MeaningNode.tsx

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';


export default function MeaningNode({ data }: NodeProps) {

  return (

    <div

      style={{

        minWidth: 180,

        borderRadius: 16,

        padding: 12,

        background: '#121826',

        border: '1px solid #1F2633',

        boxShadow: '0 0 24px rgba(123, 97, 255, 0.22)',

      }}

    >

      <Handle type="target" position={Position.Left} />

      <div style={{ fontSize: 12, opacity: 0.7 }}>Смысл</div>

      <div style={{ fontSize: 16, fontWeight: 700 }}>{data.label}</div>

      <div style={{ fontSize: 12, marginTop: 8 }}>

        Этика: {data.metrics?.ethics ?? '-'}

      </div>

      <Handle type="source" position={Position.Right} />

    </div>

  );

}

components/graph/nodes/ActorNode.tsx

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';


export default function ActorNode({ data }: NodeProps) {

  return (

    <div

      style={{

        minWidth: 180,

        borderRadius: 16,

        padding: 12,

        background: '#121826',

        border: '1px solid #1F2633',

        boxShadow: '0 0 24px rgba(77, 163, 255, 0.18)',

      }}

    >

      <Handle type="target" position={Position.Left} />

      <div style={{ fontSize: 12, opacity: 0.7 }}>Субъект</div>

      <div style={{ fontSize: 16, fontWeight: 700 }}>{data.label}</div>

      <div style={{ fontSize: 12, marginTop: 8 }}>

        Доверие: {data.metrics?.trust ?? '-'}

      </div>

      <Handle type="source" position={Position.Right} />

    </div>

  );

}

components/graph/nodes/EffectNode.tsx

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';


export default function EffectNode({ data }: NodeProps) {

  return (

    <div

      style={{

        minWidth: 180,

        borderRadius: 16,

        padding: 12,

        background: '#121826',

        border: '1px solid #1F2633',

        boxShadow: '0 0 24px rgba(61, 220, 151, 0.18)',

      }}

    >

      <Handle type="target" position={Position.Left} />

      <div style={{ fontSize: 12, opacity: 0.7 }}>Эффект</div>

      <div style={{ fontSize: 16, fontWeight: 700 }}>{data.label}</div>

      <div style={{ fontSize: 12, marginTop: 8 }}>

        Стабильность: {data.metrics?.stability ?? '-'}

      </div>

      <Handle type="source" position={Position.Right} />

    </div>

  );

}

Главная страница

app/page.tsx

'use client';


import { useEffect } from 'react';

import EquilibriumCanvas from '@/components/graph/EquilibriumCanvas';

import { useEquilibriumStore } from '@/store/equilibrium.store';


export default function HomePage() {

  const { setGraph, selectedNode, selectedEdge } = useEquilibriumStore();


  useEffect(() => {

    fetch('/api/graph')

      .then((r) => r.json())

      .then(setGraph)

      .catch(console.error);

  }, [setGraph]);


  return (

    <main

      style={{

        display: 'grid',

        gridTemplateColumns: '240px 1fr 400px',

        gridTemplateRows: '64px 1fr 120px',

        height: '100vh',

        background: '#0B0F1A',

        color: 'white',

      }}

    >

      <header style={{ gridColumn: '1 / 4', borderBottom: '1px solid #1F2633', display: 'flex', alignItems: 'center', padding: '0 16px' }}>

        EQUILIBRIUM

      </header>


      <aside style={{ borderRight: '1px solid #1F2633', padding: 16 }}>

        <h3>Фильтры</h3>

        <div>Узлы, связи, состояния</div>

      </aside>


      <section style={{ position: 'relative' }}>

        <EquilibriumCanvas />

      </section>


      <aside style={{ borderLeft: '1px solid #1F2633', padding: 16 }}>

        {!selectedNode && !selectedEdge && <div>Выбери узел или связь</div>}


        {selectedNode && (

          <>

            <h3>{selectedNode.data.label}</h3>

            <p>Тип: {selectedNode.data.nodeType}</p>

            <p>Состояние: {selectedNode.data.state}</p>

            <pre style={{ whiteSpace: 'pre-wrap' }}>

              {JSON.stringify(selectedNode.data.metrics, null, 2)}

            </pre>

          </>

        )}


        {selectedEdge && (

          <>

            <h3>{selectedEdge.data.label}</h3>

            <p>Тип: {selectedEdge.data.edgeType}</p>

            <p>Состояние: {selectedEdge.data.state}</p>

            <p>Смысл: {selectedEdge.data.meaning || '-'}</p>

            <p>Этика: {selectedEdge.data.ethics || '-'}</p>

          </>

        )}

      </aside>


      <footer style={{ gridColumn: '1 / 4', borderTop: '1px solid #1F2633', padding: 16 }}>

        Timeline / Simulation

      </footer>

    </main>

  );

}


Neo4j подключение

Официальный драйвер подключается через neo4j.driver(...); в документации также рекомендуют явно указывать целевую базу для производительности. 

lib/neo4j.ts

import neo4j from 'neo4j-driver';


const uri = process.env.NEO4J_URI!;

const username = process.env.NEO4J_USERNAME!;

const password = process.env.NEO4J_PASSWORD!;

const database = process.env.NEO4J_DATABASE || 'neo4j';


export const driver = neo4j.driver(uri, neo4j.auth.basic(username, password));


export { database };

API чтения графа

app/api/graph/route.ts

import { NextResponse } from 'next/server';

import { driver, database } from '@/lib/neo4j';


export async function GET() {

  const query = `

    MATCH (n:EQNode)

    OPTIONAL MATCH (n)-[r:RELATES_TO]->(m:EQNode)

    RETURN collect(DISTINCT {

      id: n.id,

      type: n.nodeType,

      position: { x: n.x, y: n.y },

      data: {

        label: n.label,

        nodeType: n.nodeType,

        description: n.description,

        state: n.state,

        metrics: {

          trust: n.trust,

          activity: n.activity,

          load: n.load,

          influence: n.influence,

          stability: n.stability,

          ethics: n.ethics

        },

        tags: n.tags

      }

    }) AS nodes,

    collect(DISTINCT CASE WHEN r IS NULL THEN NULL ELSE {

      id: r.id,

      source: startNode(r).id,

      target: endNode(r).id,

      type: 'default',

      data: {

        label: r.label,

        edgeType: r.edgeType,

        meaning: r.meaning,

        process: r.process,

        norm: r.norm,

        metric: r.metric,

        ethics: r.ethics,

        intensity: r.intensity,

        risk: r.risk,

        state: r.state

      }

    } END) AS edges

  `;


  const result = await driver.executeQuery(query, {}, { database });

  const record = result.records[0];


  const nodes = record.get('nodes');

  const edges = record.get('edges').filter(Boolean);


  return NextResponse.json({ nodes, edges });

}

Создание узла

app/api/node/route.ts

import { NextRequest, NextResponse } from 'next/server';

import { driver, database } from '@/lib/neo4j';


export async function POST(req: NextRequest) {

  const body = await req.json();


  const query = `

    CREATE (n:EQNode {

      id: $id,

      label: $label,

      nodeType: $nodeType,

      description: $description,

      state: $state,

      x: $x,

      y: $y,

      trust: $trust,

      activity: $activity,

      load: $load,

      influence: $influence,

      stability: $stability,

      ethics: $ethics,

      tags: $tags

    })

    RETURN n

  `;


  await driver.executeQuery(

    query,

    {

      id: body.id,

      label: body.data.label,

      nodeType: body.data.nodeType,

      description: body.data.description || '',

      state: body.data.state,

      x: body.position.x,

      y: body.position.y,

      trust: body.data.metrics?.trust ?? null,

      activity: body.data.metrics?.activity ?? null,

      load: body.data.metrics?.load ?? null,

      influence: body.data.metrics?.influence ?? null,

      stability: body.data.metrics?.stability ?? null,

      ethics: body.data.metrics?.ethics ?? null,

      tags: body.data.tags ?? [],

    },

    { database }

  );


  return NextResponse.json({ ok: true });

}

Создание связи

app/api/edge/route.ts

import { NextRequest, NextResponse } from 'next/server';

import { driver, database } from '@/lib/neo4j';


export async function POST(req: NextRequest) {

  const body = await req.json();


  const query = `

    MATCH (a:EQNode {id: $source})

    MATCH (b:EQNode {id: $target})

    CREATE (a)-[r:RELATES_TO {

      id: $id,

      label: $label,

      edgeType: $edgeType,

      meaning: $meaning,

      process: $process,

      norm: $norm,

      metric: $metric,

      ethics: $ethics,

      intensity: $intensity,

      risk: $risk,

      state: $state

    }]->(b)

    RETURN r

  `;


  await driver.executeQuery(

    query,

    {

      id: body.id,

      source: body.source,

      target: body.target,

      label: body.data.label,

      edgeType: body.data.edgeType,

      meaning: body.data.meaning || '',

      process: body.data.process || '',

      norm: body.data.norm || '',

      metric: body.data.metric || '',

      ethics: body.data.ethics || '',

      intensity: body.data.intensity ?? null,

      risk: body.data.risk ?? null,

      state: body.data.state,

    },

    { database }

  );


  return NextResponse.json({ ok: true });

}

Cypher-схема

cypher/schema.cypher

CREATE CONSTRAINT eqnode_id IF NOT EXISTS

FOR (n:EQNode)REQUIRE n.id IS UNIQUE;

cypher/seed.cypher

CREATE (:EQNode {

  id: 'meaning-1',

  label: 'Развитие компетенций',

  nodeType: 'meaning',

  state: 'growth',

  x: 100,

  y: 120,

  ethics: 0.9

});


CREATE (:EQNode {

  id: 'actor-1',

  label: 'Кооператив ФЕНИКС',

  nodeType: 'actor',

  state: 'harmony',

  x: 420,

  y: 180,

  trust: 0.82,

  activity: 0.77

});


CREATE (:EQNode {

  id: 'effect-1',

  label: 'Рост занятости',

  nodeType: 'effect',

  state: 'growth',

  x: 760,

  y: 200,

  stability: 0.71

});


MATCH (a:EQNode {id: 'meaning-1'})

MATCH (b:EQNode {id: 'actor-1'})

CREATE (a)-[:RELATES_TO {

  id: 'edge-1',

  label: 'Намерение',

  edgeType: 'intent',

  meaning: 'Запуск образовательного контура',

  ethics: 'Доступность',

  state: 'growth',

  intensity: 0.8

}]->(b);


MATCH (a:EQNode {id: 'actor-1'})

MATCH (b:EQNode {id: 'effect-1'})

CREATE (a)-[:RELATES_TO {

  id: 'edge-2',

  label: 'Обратная связь',

  edgeType: 'feedback',

  meaning: 'Оценка результата программы',

  metric: 'Индекс занятости',

  state: 'harmony',

  intensity: 0.7

}]->(b);

Пример JSON для фронта

  "nodes": [

    {

      "id": "meaning-1",

      "type": "meaning",

      "position": { "x": 100, "y": 120 },

      "data": {

        "label": "Развитие компетенций",

        "nodeType": "meaning",

        "state": "growth",

        "metrics": { "ethics": 0.9 }

      }

    },

    {

      "id": "actor-1",

      "type": "actor",

      "position": { "x": 420, "y": 180 },

      "data": {

        "label": "Кооператив ФЕНИКС",

        "nodeType": "actor",

        "state": "harmony",

        "metrics": { "trust": 0.82, "activity": 0.77 }

      }

    }

  ],

  "edges": [

    {

      "id": "edge-1",

      "source": "meaning-1",

      "target": "actor-1",

      "type": "default",

      "data": {

        "label": "Намерение",

        "edgeType": "intent",

        "meaning": "Запуск образовательного контура",

        "state": "growth",

        "intensity": 0.8

      }

    }

  ]

}

Что делать с “гиперрёбрами” по-настоящему

Вот важный момент. React Flow работает с обычными edges между двумя узлами. Это его базовая модель как flowgraph UI.  Поэтому для MVP делай так:

Вариант A — практичный

  • хранить “гиперребро” как отдельный узел типа process или protocol
  • связывать с ним участников обычными edges

То есть:

Meaning -> ProtocolHub -> Actor

Resource -> ProtocolHub

Norm -> ProtocolHub

ProtocolHub -> Effect

Это честное решение. Оно не ломает UI и позволяет показывать многомерную связь.

Тип для этого

export type EQNodeType =

  | 'meaning'

  | 'actor'

  | 'process'

  | 'resource'

  | 'norm'

  | 'effect'

  | 'protocol';

И тогда “гиперребро” на уровне данных — это protocol-узел с набором входящих/исходящих отношений. Это лучший старт.

Первый рабочий сценарий

Не распыляйся. Делай один сценарий:

Сценарий: “Кооперативное образование”

Узлы:

  • Смысл: развитие компетенций
  • Субъект: кооператив
  • Ресурс: фонд
  • Норма: регламент
  • Protocol: образовательная программа
  • Эффект: рост занятости

Связи:

  • meaning → protocol
  • actor → protocol
  • resource → protocol
  • norm → protocol
  • protocol → effect

Вот это уже MVP, который можно показать.

Команды для запуска

package.json

зависимости

{

  "dependencies": {

    "@xyflow/react": "^12",

    "neo4j-driver": "^6",

    "next": "^15",

    "react": "^19",

    "react-dom": "^19",

    "zustand": "^5"

  },

  "devDependencies": {

    "@types/node": "^22",

    "@types/react": "^19",

    "typescript": "^5"

  }

}

Пакет @xyflow/react — это актуальное имя для React Flow, а Neo4j JS driver сейчас идёт в актуальной ветке 6.x. 

.env.local

NEO4J_URI=neo4j://localhost

NEO4J_USERNAME=neo4j

NEO4J_PASSWORD=your_password

NEO4J_DATABASE=neo4j

Что у тебя уже будет после этого

После внедрения этого каркаса у тебя появится:

  • интерактивный граф,
  • кастомные узлы,
  • правая инспектор-панель,
  • загрузка данных из Neo4j,
  • создание узлов и связей через API,
  • основа под сценарии, диагностику и симуляцию.

Это уже не концепция. Это уже живой каркас продукта.


Ниже даю уже собранный каркас системы: что такое каждый модуль, какие нужны кастомные узлы, как устроить интерактивный граф, и код, который можно сразу класть в MVP. Для React Flow актуальный пакет — @xyflow/react; кастомные узлы там делаются как обычные React-компоненты, передаются через nodeTypes, а точки подключения задаются через <Handle />. Для Neo4j сейчас нормальный путь — использовать официальный JS-драйвер и driver.executeQuery() с параметрами и явным указанием базы. 

Определения модулей системы

Мы рекомендуем для первой рабочей версии зафиксировать 10 модулей.

Graph Core

Сердце системы. Хранит узлы, связи, выбор пользователя, режимы, фильтры, layout и события интерфейса. Что делает:

  • держит текущее состояние графа
  • обновляет узлы и связи
  • сообщает UI, что выбрано
  • запускает перерисовку

Node Registry

Реестр типов узлов. Что делает:

  • связывает node.type → React-компонент
  • задаёт, как рендерить meaning, actor, resource, protocol и так далее
  • позволяет расширять систему без переписывания холста

Edge Registry. Реестр связей. Что делает:

Protocol Engine

Это ключевой модуль ЭКВИЛИБРИУМ. Что делает:

  • собирает многомерное действие из нескольких сущностей
  • фактически реализует “гиперребро” как отдельный protocol-узел
  • связывает смысл, акторов, ресурсы, нормы и эффекты в один контур

Для MVP это лучший путь, потому что React Flow по своей природе работает с обычными ребрами между двумя узлами; многомерную связь удобнее представить как отдельный узел-хаб. 

Inspector Panel. Правая панель. Что делает:

Filter & Views. Левая панель. Что делает:

Timeline & Events. Нижняя панель. Что делает:

Diagnostics Engine. Диагностика системы. Что делает:

Persistence Layer. Слой хранения.. Что делает:

AI/Rule Layer. Слой рекомендаций. Что делает:

Типы узлов системы

Для старта не надо 30 типов. Нужны 7.

export type EQNodeType =

  | 'meaning'   // смысл

  | 'actor'     // субъект

  | 'resource'  // ресурс

  | 'norm'      // норма

  | 'effect'    // эффект

  | 'protocol'  // гиперребро как узел-хаб

  | 'metric';   // метрика


Meaning. Смысл, миссия, ценность. Отвечает на вопрос: “зачем”.

Actor. Человек, организация, агент, кооператив. Отвечает на вопрос: “кто”.

Resource. Деньги, фонд, данные, энергия, время. Отвечает на вопрос: “за счёт чего”.

Norm. Регламент, правило, договор, закон. Отвечает на вопрос: “по каким ограничениям”.

Effect Результат. Отвечает на вопрос: “что получилось”.

Protocol. Центральный рабочий модуль. Это и есть реализация многомерной связи. Он собирает вокруг себя остальные сущности.

Metric. Измеритель результата: индекс доверия, коэффициент устойчивости, индекс занятости.

Типы связей

export type EQEdgeType =

  | 'intent'

  | 'input'

  | 'constraint'

  | 'output'

  | 'feedback'

  | 'evolution';

Смысл:

  • intent — смысл входит в протокол
  • input — ресурс или актор участвует в протоколе
  • constraint — норма ограничивает протокол
  • output — протокол создаёт эффект
  • feedback — эффект влияет обратно
  • evolution — изменение состояния во времени

Базовые типы данных

types/equilibrium.ts

export type EQState =

  | 'harmony'

  | 'tension'

  | 'crisis'

  | 'growth'

  | 'transformation'

  | 'unknown';


export type EQNodeType =

  | 'meaning'

  | 'actor'

  | 'resource'

  | 'norm'

  | 'effect'

  | 'protocol'

  | 'metric';


export type EQEdgeType =

  | 'intent'

  | 'input'

  | 'constraint'

  | 'output'

  | 'feedback'

  | 'evolution';


export interface EQMetrics {

  trust?: number;

  load?: number;

  activity?: number;

  influence?: number;

  stability?: number;

  ethics?: number;

  value?: number;

}


export interface EQNodeData {

  label: string;

  nodeType: EQNodeType;

  description?: string;

  state: EQState;

  metrics: EQMetrics;

  tags?: string[];

}


export interface EQNode {

  id: string;

  type: EQNodeType;

  position: { x: number; y: number };

  data: EQNodeData;

}


export interface EQEdgeData {

  label: string;

  edgeType: EQEdgeType;

  state: EQState;

  meaning?: string;

  risk?: number;

  intensity?: number;

}


export interface EQEdge {

  id: string;

  source: string;

  target: string;

  type?: string;

  data: EQEdgeData;

}


export interface EQGraph {

  nodes: EQNode[];

  edges: EQEdge[];

}

Кастомные узлы: описание

В React Flow кастомный узел — это обычный React-компонент, который получает NodeProps. Узел можно делать с несколькими Handle, с интерактивными контролами и собственным UI. Это официально поддерживаемая модель. Ниже — набор узлов, который уже похож на систему.

MeaningNode. Смысловой узел. Обычно небольшой, яркий, с одним входом и одним выходом.

ActorNode. Узел субъекта. Показывает доверие, активность и нагрузку.

ResourceNode. Ресурсный узел. Обычно компактный, с показателем доступности.

NormNode. Нормативный узел. Показывает тип ограничения.

EffectNode. Узел результата. Показывает влияние и устойчивость.

MetricNode. Узел измерения. Используется как оценочный индикатор.

ProtocolNode. Самый важный. Центральный хаб. У него несколько точек подключения:

Базовый общий контейнер узла

components/graph/nodes/NodeShell.tsx

'use client';


import { ReactNode } from 'react';


type Props = {

  title: string;

  subtitle: string;

  children?: ReactNode;

  glow?: string;

};


export default function NodeShell({ title, subtitle, children, glow }: Props) {

  return (

    <div

      style={{

        minWidth: 190,

        borderRadius: 18,

        padding: 12,

        background: '#121826',

        border: '1px solid #1F2633',

        boxShadow: glow || '0 0 18px rgba(255,255,255,0.08)',

        color: '#fff',

      }}

    >

      <div

        className="drag-handle"

        style={{

          fontSize: 11,

          opacity: 0.65,

          marginBottom: 6,

          cursor: 'grab',

        }}

      >

        {subtitle}

      </div>

      <div style={{ fontWeight: 700, fontSize: 15 }}>{title}</div>

      <div style={{ marginTop: 10 }}>{children}</div>

    </div>


Кастомные узлы: код

MeaningNode.tsx

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';

import NodeShell from './NodeShell';


export default function MeaningNode({ data }: NodeProps) {

  return (

    <NodeShell

      title={data.label}

      subtitle="Смысл"

      glow="0 0 24px rgba(123, 97, 255, 0.24)"

    >

      <div style={{ fontSize: 12, opacity: 0.85 }}>

        Этика: {data.metrics?.ethics ?? '-'}

      </div>


      <Handle type="target" position={Position.Left} id="in" />

      <Handle type="source" position={Position.Right} id="out" />

    </NodeShell>

  );

ActorNode.tsx


'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';

import NodeShell from './NodeShell';


export default function ActorNode({ data }: NodeProps) {

  return (

    <NodeShell

      title={data.label}

      subtitle="Субъект"

      glow="0 0 24px rgba(77, 163, 255, 0.20)"

    >

      <div style={{ fontSize: 12 }}>Доверие: {data.metrics?.trust ?? '-'}</div>

      <div style={{ fontSize: 12 }}>Активность: {data.metrics?.activity ?? '-'}</div>

      <div style={{ fontSize: 12 }}>Нагрузка: {data.metrics?.load ?? '-'}</div>


      <Handle type="target" position={Position.Left} id="in" />

      <Handle type="source" position={Position.Right} id="out" />

    </NodeShell>

  );

}

ResourceNode.tsx

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';

import NodeShell from './NodeShell';


export default function ResourceNode({ data }: NodeProps) {

  return (

    <NodeShell

      title={data.label}

      subtitle="Ресурс"

      glow="0 0 24px rgba(255, 200, 87, 0.18)"

    >

      <div style={{ fontSize: 12 }}>Доступность: {data.metrics?.value ?? '-'}</div>

      <div style={{ fontSize: 12 }}>Стабильность: {data.metrics?.stability ?? '-'}</div>


      <Handle type="target" position={Position.Left} id="in" />

      <Handle type="source" position={Position.Right} id="out" />

    </NodeShell>

  );

NormNode.tsx

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';

import NodeShell from './NodeShell';


export default function NormNode({ data }: NodeProps) {

  return (

    <NodeShell

      title={data.label}

      subtitle="Норма"

      glow="0 0 24px rgba(255,255,255,0.12)"

    >

      <div style={{ fontSize: 12 }}>{data.description || 'Правило / ограничение'}</div>


      <Handle type="target" position={Position.Left} id="in" />

      <Handle type="source" position={Position.Right} id="out" />

    </NodeShell>

  );

}

EffectNode.tsx

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';

import NodeShell from './NodeShell';


export default function EffectNode({ data }: NodeProps) {

  return (

    <NodeShell

      title={data.label}

      subtitle="Эффект"

      glow="0 0 24px rgba(61, 220, 151, 0.22)"

    >

      <div style={{ fontSize: 12 }}>Влияние: {data.metrics?.influence ?? '-'}</div>

      <div style={{ fontSize: 12 }}>Устойчивость: {data.metrics?.stability ?? '-'}</div>


      <Handle type="target" position={Position.Left} id="in" />

      <Handle type="source" position={Position.Right} id="out" />

    </NodeShell>

  );

}

MetricNode.tsx

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';

import NodeShell from './NodeShell';


export default function MetricNode({ data }: NodeProps) {

  return (

    <NodeShell

      title={data.label}

      subtitle="Метрика"

      glow="0 0 24px rgba(80, 180, 255, 0.16)"

    >

      <div style={{ fontSize: 12 }}>Значение: {data.metrics?.value ?? '-'}</div>


      <Handle type="target" position={Position.Left} id="in" />

      <Handle type="source" position={Position.Right} id="out" />

    </NodeShell>

  );

ProtocolNode.tsx. Это главный узел.

'use client';


import { Handle, Position, NodeProps } from '@xyflow/react';


export default function ProtocolNode({ data }: NodeProps) {

  return (

    <div

      style={{

        minWidth: 260,

        borderRadius: 20,

        padding: 14,

        background: '#161d2d',

        border: '1px solid #2a3550',

        boxShadow: '0 0 28px rgba(123, 97, 255, 0.28)',

        color: '#fff',

      }}

    >

      <div className="drag-handle" style={{ fontSize: 11, opacity: 0.65, marginBottom: 6 }}>

        Протокол / Гиперребро

      </div>


      <div style={{ fontSize: 16, fontWeight: 800 }}>{data.label}</div>


      <div style={{ marginTop: 10, fontSize: 12, opacity: 0.9 }}>

        {data.description || 'Многомерный контур действия'}

      </div>


      <div

        style={{

          display: 'grid',

          gridTemplateColumns: '1fr 1fr',

          gap: 6,

          marginTop: 10,

          fontSize: 12,

        }}

      >

        <div>Этика: {data.metrics?.ethics ?? '-'}</div>

        <div>Нагрузка: {data.metrics?.load ?? '-'}</div>

        <div>Стабильность: {data.metrics?.stability ?? '-'}</div>

        <div>Активность: {data.metrics?.activity ?? '-'}</div>

      </div>


      <Handle type="target" position={Position.Left} id="meaning" style={{ top: '20%' }} />

      <Handle type="target" position={Position.Top} id="actors" style={{ left: '35%' }} />

      <Handle type="target" position={Position.Bottom} id="resources" style={{ left: '35%' }} />

      <Handle type="target" position={Position.Left} id="norms" style={{ top: '75%' }} />


      <Handle type="source" position={Position.Right} id="effects" style={{ top: '35%' }} />

      <Handle type="source" position={Position.Right} id="metrics" style={{ top: '75%' }} />

    </div>

  );


Узел с несколькими Handle полностью соответствует модели React Flow, где в кастомных узлах можно иметь сколько угодно source/target connection points. 

Реестр кастомных узлов

components/graph/nodes/index.ts

import MeaningNode from './MeaningNode';

import ActorNode from './ActorNode';

import ResourceNode from './ResourceNode';

import NormNode from './NormNode';

import EffectNode from './EffectNode';

import MetricNode from './MetricNode';

import ProtocolNode from './ProtocolNode';


export const nodeTypes = {

  meaning: MeaningNode,

  actor: ActorNode,

  resource: ResourceNode,

  norm: NormNode,

  effect: EffectNode,

  metric: MetricNode,

  protocol: ProtocolNode,

};

Интерактивный граф: код холста

<ReactFlow /> — основной компонент, который рендерит узлы и рёбра, обрабатывает интеракции и работает со state. Это официальная центральная точка библиотеки. 

components/graph/EquilibriumCanvas.tsx


'use client';


import { useEffect, useMemo, useState } from 'react';

import {

  ReactFlow,

  ReactFlowProvider,

  Background,

  MiniMap,

  Controls,

  addEdge,

  useNodesState,

  useEdgesState,

  Connection,

  Edge,

  NodeMouseHandler,

  EdgeMouseHandler,

  BackgroundVariant,

} from '@xyflow/react';

import '@xyflow/react/dist/style.css';


import { nodeTypes } from './nodes';

import { EQGraph } from '@/types/equilibrium';


type Props = {

  graph: EQGraph;

  onSelectNode?: (node: any) => void;

  onSelectEdge?: (edge: any) => void;

};


function edgeStyleByState(state?: string) {

  switch (state) {

    case 'growth':

      return { stroke: '#4DA3FF', strokeWidth: 2.5 };

    case 'crisis':

      return { stroke: '#FF4D4F', strokeWidth: 2.5 };

    case 'harmony':

      return { stroke: '#3DDC97', strokeWidth: 2.5 };

    case 'tension':

      return { stroke: '#FFC857', strokeWidth: 2.5 };

    default:

      return { stroke: '#76839a', strokeWidth: 1.8 };

  }

}


function CanvasInner({ graph, onSelectNode, onSelectEdge }: Props) {

  const initialNodes = useMemo(() => graph.nodes, [graph.nodes]);


  const initialEdges = useMemo(

    () =>

      graph.edges.map((e) => ({

        ...e,

        animated: e.data.state === 'growth' || e.data.edgeType === 'feedback',

        style: edgeStyleByState(e.data.state),

        label: e.data.label,

      })),

    [graph.edges]

  );


  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);

  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges as Edge[]);

  const [selectedId, setSelectedId] = useState<string | null>(null);


  useEffect(() => setNodes(initialNodes), [initialNodes, setNodes]);

  useEffect(() => setEdges(initialEdges as Edge[]), [initialEdges, setEdges]);


  const onConnect = (connection: Connection) => {

    setEdges((eds) =>

      addEdge(

        {

          ...connection,

          type: 'smoothstep',

          data: {

            label: 'Новая связь',

            edgeType: 'input',

            state: 'unknown',

          },

          style: edgeStyleByState('unknown'),

        },

        eds

      )

    );

  };


  const handleNodeClick: NodeMouseHandler = (_, node) => {

    setSelectedId(node.id);

    onSelectNode?.(node);

  };


  const handleEdgeClick: EdgeMouseHandler = (_, edge) => {

    setSelectedId(edge.id);

    onSelectEdge?.(edge);

  };


  return (

    <div style={{ width: '100%', height: '100%' }}>

      <ReactFlow

        nodes={nodes.map((n) => ({

          ...n,

          selected: n.id === selectedId,

          dragHandle: '.drag-handle',

        }))}

        edges={edges}

        nodeTypes={nodeTypes}

        onNodesChange={onNodesChange}

        onEdgesChange={onEdgesChange}

        onConnect={onConnect}

        onNodeClick={handleNodeClick}

        onEdgeClick={handleEdgeClick}

        fitView

      >

        <MiniMap />

        <Controls />

        <Background variant={BackgroundVariant.Dots} gap={20} size={1} />

      </ReactFlow>

    </div>

  );

}


export default function EquilibriumCanvas(props: Props) {

  return (

    <ReactFlowProvider>

      <CanvasInner {...props} />

    </ReactFlowProvider>

  );

}

Пример интерактивного экрана

app/page.tsx

'use client';


import { useEffect, useState } from 'react';

import EquilibriumCanvas from '@/components/graph/EquilibriumCanvas';

import { EQGraph } from '@/types/equilibrium';


export default function HomePage() {

  const [graph, setGraph] = useState<EQGraph>({ nodes: [], edges: [] });

  const [selectedNode, setSelectedNode] = useState<any>(null);

  const [selectedEdge, setSelectedEdge] = useState<any>(null);


  useEffect(() => {

    fetch('/api/graph')

      .then((r) => r.json())

      .then(setGraph)

      .catch(console.error);

  }, []);


  return (

    <main

      style={{

        display: 'grid',

        gridTemplateColumns: '260px 1fr 400px',

        gridTemplateRows: '64px 1fr 120px',

        height: '100vh',

        background: '#0B0F1A',

        color: '#fff',

      }}

    >

      <header

        style={{

          gridColumn: '1 / 4',

          borderBottom: '1px solid #1F2633',

          display: 'flex',

          alignItems: 'center',

          padding: '0 16px',

          fontWeight: 800,

        }}

      >

        EQUILIBRIUM

      </header>


      <aside style={{ borderRight: '1px solid #1F2633', padding: 16 }}>

        <h3>Фильтры</h3>

        <div>Типы узлов</div>

        <div>Состояния</div>

        <div>Типы связей</div>

      </aside>


      <section style={{ position: 'relative' }}>

        <EquilibriumCanvas

          graph={graph}

          onSelectNode={(node) => {

            setSelectedEdge(null);

            setSelectedNode(node);

          }}

          onSelectEdge={(edge) => {

            setSelectedNode(null);

            setSelectedEdge(edge);

          }}

        />

      </section>


      <aside style={{ borderLeft: '1px solid #1F2633', padding: 16, overflow: 'auto' }}>

        {!selectedNode && !selectedEdge && <div>Выберите узел или связь</div>}


        {selectedNode && (

          <>

            <h3>{selectedNode.data.label}</h3>

            <div>Тип: {selectedNode.data.nodeType}</div>

            <div>Состояние: {selectedNode.data.state}</div>

            <div style={{ marginTop: 12, fontSize: 13, opacity: 0.9 }}>

              {selectedNode.data.description || 'Без описания'}

            </div>

            <pre style={{ marginTop: 12, fontSize: 12, whiteSpace: 'pre-wrap' }}>

              {JSON.stringify(selectedNode.data.metrics, null, 2)}

            </pre>

          </>

        )}


        {selectedEdge && (

          <>

            <h3>{selectedEdge.data.label}</h3>

            <div>Тип: {selectedEdge.data.edgeType}</div>

            <div>Состояние: {selectedEdge.data.state}</div>

            <div>Смысл: {selectedEdge.data.meaning || '-'}</div>

            <div>Риск: {selectedEdge.data.risk ?? '-'}</div>

            <div>Интенсивность: {selectedEdge.data.intensity ?? '-'}</div>

          </>

        )}

      </aside>


      <footer

        style={{

          gridColumn: '1 / 4',

          borderTop: '1px solid #1F2633',

          padding: 16,

        }}

      >

        Timeline / Events / Simulation

      </footer>

    </main>

  );

}


Пример графа для ЭКВИЛИБРИУМ. Это уже не абстракция, а рабочий сценарий.

lib/mockGraph.ts

import { EQGraph } from '@/types/equilibrium';


export const mockGraph: EQGraph = {

  nodes: [

    {

      id: 'meaning-1',

      type: 'meaning',

      position: { x: 60, y: 220 },

      data: {

        label: 'Развитие компетенций',

        nodeType: 'meaning',

        description: 'Рост человеческого потенциала региона',

        state: 'growth',

        metrics: { ethics: 0.92 },

      },

    },

    {

      id: 'actor-1',

      type: 'actor',

      position: { x: 280, y: 60 },

      data: {

        label: 'Кооператив ФЕНИКС',

        nodeType: 'actor',

        state: 'harmony',

        metrics: { trust: 0.82, activity: 0.77, load: 0.42 },

      },

    },

    {

      id: 'resource-1',

      type: 'resource',

      position: { x: 280, y: 360 },

      data: {

        label: 'Образовательный фонд',

        nodeType: 'resource',

        state: 'harmony',

        metrics: { value: 0.76, stability: 0.66 },

      },

    },

    {

      id: 'norm-1',

      type: 'norm',

      position: { x: 280, y: 500 },

      data: {

        label: 'Регламент программы',

        nodeType: 'norm',

        description: 'Прозрачность отбора и доступность участия',

        state: 'harmony',

        metrics: {},

      },

    },

    {

      id: 'protocol-1',

      type: 'protocol',

      position: { x: 640, y: 230 },

      data: {

        label: 'Протокол кооперативного образования',

        nodeType: 'protocol',

        description: 'Контур: обучение → участие → занятость',

        state: 'growth',

        metrics: {

          ethics: 0.9,

          load: 0.58,

          stability: 0.71,

          activity: 0.79,

        },

      },

    },

    {

      id: 'effect-1',

      type: 'effect',

      position: { x: 1020, y: 180 },

      data: {

        label: 'Рост занятости',

        nodeType: 'effect',

        state: 'growth',

        metrics: { influence: 0.74, stability: 0.69 },

      },

    },

    {

      id: 'metric-1',

      type: 'metric',

      position: { x: 1020, y: 340 },

      data: {

        label: 'Индекс занятости',

        nodeType: 'metric',

        state: 'harmony',

        metrics: { value: 0.63 },

      },

    },

  ],

  edges: [

    {

      id: 'e1',

      source: 'meaning-1',

      target: 'protocol-1',

      type: 'smoothstep',

      data: {

        label: 'Намерение',

        edgeType: 'intent',

        state: 'growth',

        meaning: 'Смысл входит в протокол',

        intensity: 0.88,

      },

    },

    {

      id: 'e2',

      source: 'actor-1',

      target: 'protocol-1',

      type: 'smoothstep',

      data: {

        label: 'Участие',

        edgeType: 'input',

        state: 'harmony',

        meaning: 'Субъект исполняет протокол',

        intensity: 0.72,

      },

    },

    {

      id: 'e3',

      source: 'resource-1',

      target: 'protocol-1',

      type: 'smoothstep',

      data: {

        label: 'Ресурс',

        edgeType: 'input',

        state: 'harmony',

        intensity: 0.68,

      },

    },

    {

      id: 'e4',

      source: 'norm-1',

      target: 'protocol-1',

      type: 'smoothstep',

      data: {

        label: 'Ограничение',

        edgeType: 'constraint',

        state: 'harmony',

        meaning: 'Норма ограничивает и направляет',

      },

    },

    {

      id: 'e5',

      source: 'protocol-1',

      target: 'effect-1',

      type: 'smoothstep',

      data: {

        label: 'Результат',

        edgeType: 'output',

        state: 'growth',

        intensity: 0.81,

      },

    },

    {

      id: 'e6',

      source: 'protocol-1',

      target: 'metric-1',

      type: 'smoothstep',

      data: {

        label: 'Измерение',

        edgeType: 'output',

        state: 'harmony',

      },

    },

    {

      id: 'e7',

      source: 'effect-1',

      target: 'protocol-1',

      type: 'smoothstep',

      data: {

        label: 'Обратная связь',

        edgeType: 'feedback',

        state: 'harmony',

        risk: 0.21,

      },

    },

  ],

};

Neo4j: схема хранения

Официальный JS driver Neo4j рекомендует использовать параметризованные запросы, а executeQuery() сам создаёт транзакцию. Также полезно всегда явно указывать database для снижения лишнего overhead. 

lib/neo4j.ts

import neo4j from 'neo4j-driver';


const uri = process.env.NEO4J_URI!;

const username = process.env.NEO4J_USERNAME!;

const password = process.env.NEO4J_PASSWORD!;

const database = process.env.NEO4J_DATABASE || 'neo4j';


export const driver = neo4j.driver(uri, neo4j.auth.basic(username, password));

export { database };

cypher/schema.cypher

CREATE CONSTRAINT eqnode_id IF NOT EXISTS

FOR (n:EQNode)

REQUIRE n.id IS UNIQUE;

app/api/graph/route.ts

import { NextResponse } from 'next/server';

import { driver, database } from '@/lib/neo4j';


export async function GET() {

  const query = `

    MATCH (n:EQNode)

    OPTIONAL MATCH (n)-[r:RELATES_TO]->(m:EQNode)

    RETURN

      collect(DISTINCT {

        id: n.id,

        type: n.nodeType,

        position: { x: n.x, y: n.y },

        data: {

          label: n.label,

          nodeType: n.nodeType,

          description: n.description,

          state: n.state,

          metrics: {

            trust: n.trust,

            load: n.load,

            activity: n.activity,

            influence: n.influence,

            stability: n.stability,

            ethics: n.ethics,

            value: n.value

          }

        }

      }) AS nodes,

      collect(DISTINCT CASE WHEN r IS NULL THEN NULL ELSE {

        id: r.id,

        source: startNode(r).id,

        target: endNode(r).id,

        type: 'smoothstep',

        data: {

          label: r.label,

          edgeType: r.edgeType,

          state: r.state,

          meaning: r.meaning,

          risk: r.risk,

          intensity: r.intensity

        }

      } END) AS edges

  `;


  const result = await driver.executeQuery(query, {}, { database });

  const record = result.records[0];


  const nodes = record.get('nodes');

  const edges = record.get('edges').filter(Boolean);


  return NextResponse.json({ nodes, edges });

}


Диагностика: базовые правила. Это не ИИ, а жёсткая логика. С неё и надо начинать.

lib/diagnostics.ts

import { EQGraph } from '@/types/equilibrium';


export interface DiagnosticItem {

  type: 'warning' | 'error' | 'info';

  targetId: string;

  message: string;

}


export function runDiagnostics(graph: EQGraph): DiagnosticItem[] {

  const result: DiagnosticItem[] = [];


  for (const node of graph.nodes) {

    if (node.type === 'protocol') {

      const incoming = graph.edges.filter((e) => e.target === node.id);

      const outgoing = graph.edges.filter((e) => e.source === node.id);


      const hasMeaning = incoming.some((e) => e.data.edgeType === 'intent');

      const hasActor = incoming.some((e) => e.source.startsWith('actor-'));

      const hasResource = incoming.some((e) => e.source.startsWith('resource-'));

      const hasNorm = incoming.some((e) => e.data.edgeType === 'constraint');

      const hasOutput = outgoing.some((e) => e.data.edgeType === 'output');


      if (!hasMeaning) {

        result.push({

          type: 'error',

          targetId: node.id,

          message: 'У протокола нет смыслового основания',

        });

      }


      if (!hasActor) {

        result.push({

          type: 'warning',

          targetId: node.id,

          message: 'У протокола нет субъекта-исполнителя',

        });

      }


      if (!hasResource) {

        result.push({

          type: 'warning',

          targetId: node.id,

          message: 'У протокола нет ресурсного входа',

        });

      }


      if (!hasNorm) {

        result.push({

          type: 'warning',

          targetId: node.id,

          message: 'У протокола нет нормативного ограничения',

        });

      }


      if (!hasOutput) {

        result.push({

          type: 'error',

          targetId: node.id,

          message: 'У протокола нет результата',

        });

      }


      if ((node.data.metrics.load ?? 0) > 0.85) {

        result.push({

          type: 'warning',

          targetId: node.id,

          message: 'Протокол перегружен',

        });

      }

    }

  }


  return result;

}

Что такое “интерактивный граф” по сути

Не просто холст с узлами, а 7 функций:

  • Drag
    Перемещение узлов.
  • Connect
    Создание новой связи через handles.
  • Select
    Выбор узла или связи.
  • Inspect
    Показ содержимого справа.
  • Filter
    Оставить на экране только нужные типы.
  • Diagnose
    Подсветить ошибки и риски.
  • Evolve
    Добавить новый протокол, ресурс, норму, метрику.

React Flow как раз и предназначен для node-based editors и интерактивных диаграмм; документация отдельно описывает кастомизацию узлов, handles, theming и примеры. 


Что ещё нужно обязательно добавить в версию 2

Вот уже по делу, без украшений. Нужны следующие модули:

  • LeftFilters.tsx
  • RightInspector.tsx
  • BottomTimeline.tsx
  • CreateProtocolModal.tsx
  • DiagnosticsPanel.tsx

И следующие улучшения:

  • сохранение позиций узлов в БД
  • форма создания протокола
  • редактирование метрик
  • layout-алгоритм
  • подсветка конфликтных связей
  • SSR-safe конфигурация, если будет серверный рендер; в React Flow для SSR отдельно есть NodeHandle, когда DOM ещё не измерен.  

Как это собрать в MVP

Минимальный стек:

  • Next.js
  • TypeScript
  • @xyflow/react
  • Neo4j
  • Zustand

Минимальные сущности:

  • 7 типов узлов
  • 6 типов связей
  • 1 экран
  • 1 правая панель
  • 1 диагностика
  • 1 сценарий

Минимальный сценарий:

  • смысл
  • субъект
  • ресурс
  • норма
  • протокол
  • эффект
  • метрика

Это уже будет выглядеть как настоящая операционная панель, а не как схема “для презентации”.

Самый важный архитектурный вывод

Гиперребро в ЭКВИЛИБРИУМ не надо пытаться насильно рисовать как магическую супер-линию.

Для рабочего интерфейса его надо делать как protocol-узел, вокруг которого собираются все участники процесса.

Это решение:

  • не ломает React Flow,
  • нормально хранится в Neo4j,
  • легко диагностируется,
  • легко расширяется,
  • хорошо показывается в UI.

И это самый здравый способ сделать систему реальной.

Дальше логично собрать уже полный набор файлов проекта: package.json, tsconfig, app/page.tsx, все node-компоненты, API-роуты, seed для Neo4j и CSS, чтобы это можно было просто вставить в репозиторий и запустить.