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:
265
src/components/Graph.tsx
Normal file
265
src/components/Graph.tsx
Normal 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 },
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user