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:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"antd": "^5.28.0",
|
||||
"d3": "^7.9.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"viz.js": "^2.1.2"
|
||||
@@ -17,6 +18,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -1984,6 +1986,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
||||
@@ -3754,6 +3763,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"antd": "^5.28.0",
|
||||
"d3": "^7.9.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"viz.js": "^2.1.2"
|
||||
@@ -19,6 +20,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
|
||||
46
src/App.tsx
46
src/App.tsx
@@ -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;
|
||||
|
||||
useEffect(() => {
|
||||
firstLevelGraph.render();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
});
|
||||
const App: React.FC = () => {
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
const [graphLevel, setGraphLevel] = useState<BreadcrumbItemType[]>([])
|
||||
|
||||
function setGraphLevelPath(path: BreadcrumbItemType[]) {
|
||||
setGraphLevel(path)
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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
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 },
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user