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
Свойства:
Состояния:
HyperEdge (гиперребро)
. Это НЕ линия.
Визуал:
Варианты
Flow Line
Panel (карточка)
Размер
Содержит:
Button
Типы:
- Primary
- Secondary
- Ghost
Mode Switch. Переключатель:
- Обзор
- Диагностика
- Сценарий
- Действие
Timeline
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. Реестр связей. Что делает:
- определяет визуал и поведение связей
- задаёт стили для intent, cooperation, feedback, evolution
- позже может включать кастомные рёбра с подписями и индикаторами риска
Protocol Engine
Это ключевой модуль ЭКВИЛИБРИУМ. Что делает:
- собирает многомерное действие из нескольких сущностей
- фактически реализует “гиперребро” как отдельный protocol-узел
- связывает смысл, акторов, ресурсы, нормы и эффекты в один контур
Для MVP это лучший путь, потому что React Flow по своей природе работает с обычными ребрами между двумя узлами; многомерную связь удобнее представить как отдельный узел-хаб.
Inspector Panel. Правая панель. Что делает:
- показывает паспорт выбранного узла
- показывает паспорт выбранной связи
- позволяет редактировать атрибуты
- отображает метрики, риски, этику, нагрузку
Filter & Views. Левая панель. Что делает:
- фильтрует по типам узлов
- фильтрует по состояниям
- фильтрует по типам связей
- переключает режимы: обзор, диагностика, сценарий, действие
Timeline & Events. Нижняя панель. Что делает:
- показывает события во времени
- позволяет проигрывать состояние графа
- в будущем даёт симуляцию сценариев
Diagnostics Engine. Диагностика системы. Что делает:
- ищет перегруженные узлы
- находит конфликтные связи
- подсвечивает кризисные зоны
- вычисляет “дырки” в контуре: например, есть процесс, но нет нормы
Persistence Layer. Слой хранения.. Что делает:
- читает и пишет граф в Neo4j
- сохраняет положения узлов
- сохраняет метрики и состояния
- отдаёт JSON на фронт
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. Самый важный. Центральный хаб. У него несколько точек подключения:
- слева смысл
- сверху акторы
- снизу ресурсы
- справа эффекты
- отдельный handle для норм
- отдельный handle для метрик
Базовый общий контейнер узла
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, чтобы это можно было просто вставить в репозиторий и запустить.