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

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 },
]
};
}