Implemented:

- node rename  working in modal
- add child node on click
- removing  node and  reasigning the edges
- creating subgraphs
-  breadcrumbs for navigtion
This commit is contained in:
2025-11-08 22:21:15 +01:00
parent 5463923423
commit 33141ce865
6 changed files with 316 additions and 181 deletions

View File

@@ -1,18 +1,38 @@
import { useEffect, useRef, useState } from "react";
import { GraphRenderer } from "./Graph";
import React, { useState } from 'react';
import { Layout, theme, Breadcrumb } from 'antd';
import Graph, { NodeModel } from './components/Graph';
import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb';
export default function App() {
const containerRef = useRef(null);
const firstLevelGraph = new GraphRenderer(containerRef);
const { Content } = Layout;
const App: React.FC = () => {
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const [graphLevel, setGraphLevel] = useState<BreadcrumbItemType[]>([])
function setGraphLevelPath(path: BreadcrumbItemType[]) {
setGraphLevel(path)
}
useEffect(() => {
firstLevelGraph.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
});
return (
<div className="flex-1 p-4">
<div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{minHeight: '600px', overflow: 'auto'}}></div>
</div>
<Layout>
<Layout>
<Breadcrumb style={{ margin: '8px 0 0 16px' }} items={graphLevel} />
<Content
style={{
margin: '8px 16px',
padding: 24,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Graph setGraphPath={setGraphLevelPath} />
</Content>
</Layout>
</Layout>
);
}
};
export default App;

View File

@@ -1,99 +0,0 @@
import React, { useState } from "react";
import Viz from 'viz.js';
import { Module, render } from 'viz.js/full.render.js';
import { graphToDot } from "./Graphviz";
import * as d3 from 'd3';
import { NodeContextMenu } from "./NodeContextMenu";
const viz = new Viz({ Module, render });
export class GraphRenderer {
graph: Graph;
setGraph: React.Dispatch<React.SetStateAction<Graph>>;
containerRef: React.RefObject<null>;
contextMenu: NodeContextMenu;
constructor(containerRef: React.RefObject<null>) {
[this.graph, this.setGraph] = useState(defaultGraph());
this.containerRef = containerRef;
this.contextMenu = new NodeContextMenu(containerRef);
}
public async render() {
const dot = graphToDot(this.graph);
try {
const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' });
const container = this.containerRef.current;
if (!container) return;
container.innerHTML = '';
container.appendChild(svgElement);
this.attachInteractions(svgElement);
} catch (e) {
console.error('Viz render error', e);
}
}
attachInteractions(svgElement: SVGSVGElement) {
const svg = d3.select(svgElement);
const self = this;
svg.selectAll('g.node')
.style('cursor', 'pointer')
.on('click', function (event) {
event.stopPropagation();
const id = d3.select(this).attr('id');
self.createChildNode(id);
self.render();
})
.on('contextmenu', function (event) {
event.preventDefault();
event.stopPropagation();
const id = d3.select(this).attr('id');
const { clientX: x, clientY: y } = event;
self.contextMenu.setContextMenu({ x, y, nodeId: id });
});
}
createChildNode(parentId: string) {
const id = crypto.randomUUID();
this.setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: 'New node' }], edges: [...prev.edges, { from: parentId, to: id }] }));
}
}
function defaultGraph(): Graph {
return {
nodes: [
{ id: 'A', label: 'A' },
{ id: 'B', label: 'B' },
{ id: 'C', label: 'C' }
],
edges: [
{ from: 'A', to: 'B' },
{ from: 'B', to: 'C' }
]
};
}
export class Graph {
nodes: Node[] = [];
edges: Edge[] = [];
}
export class Edge {
from: string;
to: string;
constructor(from: string, to: string) {
this.from = from;
this.to = to;
}
}
export class Node {
public id: string;
public label?: string;
constructor(id: string) {
this.id = id;
}
}

265
src/components/Graph.tsx Normal file
View File

@@ -0,0 +1,265 @@
import { createContext, useContext, useEffect, useRef, useState, type MouseEventHandler } from "react";
import Viz from 'viz.js';
import { Module, render } from 'viz.js/full.render.js';
import * as d3 from 'd3';
import { graphToDot } from "../Graphviz";
import { Dropdown, type MenuProps } from "antd";
import { Modal } from "antd";
import { Input } from "antd";
import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb";
import { cloneDeep } from "lodash";
export class GraphModel {
nodes: NodeModel[] = [];
edges: EdgeModel[] = [];
}
export class EdgeModel {
from: string;
to: string;
constructor(from: string, to: string) {
this.from = from;
this.to = to;
}
}
export class NodeModel {
public id: string;
public label?: string;
constructor(id: string, name: string | undefined = undefined) {
this.id = id;
this.label = name;
}
}
export interface GraphLevel extends BreadcrumbItemType {
}
const viz = new Viz({ Module, render });
const items: MenuProps['items'] = [
{
key: 'rename',
label: 'Rename',
extra: 'ctrl + n',
},
{
key: 'subgraph',
label: 'Subgraph',
extra: 'ctrl + s',
},
{
key: 'remove',
label: 'Remove',
extra: 'ctrl + r'
}
]
const graphsById = new Map<string, GraphModel>();
export default function Graph({ setGraphPath }) {
const containerRef = useRef(null);
const [graph, setGraph] = useState(defaultGraph());
const [contextMenuOpened, openContextMenu] = useState(false);
const [renameModalOpened, openRenameModal] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [nodeId, selectNodeId] = useState('');
const [nodeName, setSelectedNodeName] = useState('');
const [graphId, selectGraphId] = useState('main');
const [graphsPath, setGraphsPath] = useState([createPathSegment('main', 'Main')])
useEffect(() => {
console.info(graphsPath);
setGraphPath(graphsPath);
}, [graphsPath])
useEffect(() => {
renderGraph();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [graph]);
async function renderGraph() {
const dot = graphToDot(graph);
try {
const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' });
const container = containerRef.current;
if (!container) return;
container.innerHTML = '';
container.appendChild(svgElement);
attachInteractions(svgElement);
} catch (e) {
console.error('Viz render error', e);
}
}
function attachInteractions(svgElement: SVGSVGElement) {
const svg = d3.select(svgElement);
svg.selectAll('g.node')
.style('cursor', 'pointer')
.on('click', function (event) {
const id = d3.select(this).attr('id');
createChildNode(id);
renderGraph();
})
.on('contextmenu', function (event) {
const id = d3.select(this).attr('id');
const name = d3.select(this).attr('label');
handleOpenContextMenuClick(event, id, name);
});
}
function contextMenuOpenChange(open: boolean) {
if (!open) {
openContextMenu(false)
}
}
function handleOpenContextMenuClick(event: React.MouseEvent<HTMLDivElement, MouseEvent>, id: string, name: string) {
event.preventDefault();
selectNodeId(id);
setSelectedNodeName(name)
setCoords({ x: event.clientX, y: event.clientY });
openContextMenu(true);
}
function createChildNode(parentId: string) {
const id = crypto.randomUUID();
setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: 'New node' }], edges: [...prev.edges, { from: parentId, to: id }] }));
}
function removeNode(id: string) {
setGraph(prev => ({ ...prev, nodes: [...prev.nodes.filter(n => n.id !== nodeId)], edges: removeEdgesOfNode(id, prev.edges) }));
}
function removeEdgesOfNode(nodeId: string, edges: EdgeModel[]): EdgeModel[] {
const parentConnection = edges.find(e => e.to === nodeId);
if (parentConnection) {
edges.filter(e => e.from === nodeId).forEach(e => {
e.from = parentConnection.from;
});
edges = edges.filter(e => e.to !== nodeId);
}
if (!parentConnection) {
edges = edges.filter(e => e.from !== nodeId);
}
const result = [...edges];
return result;
}
function renameNode() {
const node = graph.nodes.find(n => n.id === nodeId);
if (!node) {
return;
}
node.label = nodeName;
setGraph(prev => ({ ...prev, nodes: graph.nodes }));
openRenameModal(false);
}
const onMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'rename': {
openRenameModal(true);
break;
}
case 'remove': {
removeNode(nodeId);
break;
}
case 'subgraph': {
const selectedNode = graph.nodes.find(n => n.id === nodeId);
const selectedNodeName = selectedNode?.label;
graphsById.set(graphId, cloneDeep(graph));
selectGraphId(nodeId);
let selectedGraph = graphsById.get(nodeId);
if (!selectedGraph) {
selectedGraph = defaultGraph();
}
setGraph(selectedGraph);
renderGraph();
graphsPath.push(createPathSegment(nodeId, selectedNodeName));
setGraphsPath([...graphsPath])
break;
}
}
};
function createPathSegment(pathSegmentId: string, selectedNodeName: string | undefined): BreadcrumbItemType {
return {
title: selectedNodeName,
key: pathSegmentId,
onClick: (e) => {
console.info(e);
const index = graphsPath.findIndex(p => p.key === pathSegmentId);
setGraphsPath(prev => {
prev.splice(index + 1);
return [...prev];
});
const graph = graphsById.get(pathSegmentId);
if (graph) {
selectGraphId(pathSegmentId);
setGraph(graph);
renderGraph();
} else {
throw Error(`No graph with id '${pathSegmentId}'`);
}
}
} as BreadcrumbItemType;
}
return (
<div className="flex-1 p-4">
<div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{ minHeight: '600px', overflow: 'auto' }}>
</div>
<Dropdown menu={{ items, onClick: onMenuClick }} trigger={['contextMenu']} open={contextMenuOpened} onOpenChange={contextMenuOpenChange} getPopupContainer={() => document.body}
// 👇 Key part: manually position the dropdown
overlayStyle={{
position: "absolute",
left: coords.x,
top: coords.y,
}}>
</Dropdown>
<Modal
title="Rename"
open={renameModalOpened}
onOk={() => renameNode()}
onCancel={() => openRenameModal(false)}
>
<Input value={nodeName} onChange={(e) => setSelectedNodeName(e.target.value)} />
</Modal>
</div>
)
}
function defaultGraph(): GraphModel {
const start = crypto.randomUUID();
const end = crypto.randomUUID();
return {
nodes: [
{ id: start, label: 'Start' },
{ id: end, label: 'End' }
],
edges: [
{ from: start, to: end },
]
};
}

View File

@@ -1,68 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}