/* Graphviz + React interactive graph editor Features implemented (best-effort): - Uses Viz.js (Graphviz compiled to WASM) to render DOT -> SVG (engine: neato) - Create nodes, delete nodes, rename nodes - Create & delete edges (connect nodes) - Drag nodes (hold Ctrl while dragging) to set fixed position in the graph - Drag a node and drop it onto an edge to insert it into that edge (edge A->B becomes A->dragged and dragged->B) - Double-click a node to open an in-app modal where you can create a subgraph assigned to that node - "Flatten" button combines the main graph and all subgraphs into one merged graph Limitations / notes: - This is a single-file React component (App.jsx). It assumes you have a React + Tailwind project set up. - You need to install dependencies: viz.js and d3. npm install react react-dom d3 viz.js How to use: - Run your React app (e.g. with Vite or CRA). Place this file as src/App.jsx and start. - The UI has controls on the left. Click "Add node" to add a node. Select source and target and click "Connect" to add edge. - Hold Ctrl and drag a node to move it. Drop onto an edge to split the edge as described. - Double-click a node to open its subgraph editor in a modal. - Click Flatten to view the merged graph. This is a non-trivial interactive example — adapt and harden for production. */ import React, {useEffect, useRef, useState} from 'react'; import Viz from 'viz.js'; import { Module, render } from 'viz.js/full.render.js'; import * as d3 from 'd3'; const viz = new Viz({ Module, render }); function makeId(prefix = 'n') { return prefix + Math.random().toString(36).slice(2, 9); } function defaultGraph() { return { nodes: [ { id: 'A', label: 'A' }, { id: 'B', label: 'B' }, { id: 'C', label: 'C' } ], edges: [ { from: 'A', to: 'B' }, { from: 'B', to: 'C' } ] }; } export default function App() { const [graph, setGraph] = useState(defaultGraph()); // map nodeId -> {nodes,edges} const [subgraphs, setSubgraphs] = useState({}); const [selectedSource, setSelectedSource] = useState(null); const [selectedTarget, setSelectedTarget] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [selectedEdge, setSelectedEdge] = useState(null); const [flatDot, setFlatDot] = useState(null); const svgRef = useRef(null); const containerRef = useRef(null); // modal for subgraph const [modalNode, setModalNode] = useState(null); useEffect(() => { renderGraph(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [graph, subgraphs]); async function renderGraph(overrideDot=null) { const dot = overrideDot || graphToDot(graph); try { const svgElement = await viz.renderSVGElement(dot, {engine: 'neato'}); const container = containerRef.current; if (!container) return; container.innerHTML = ''; container.appendChild(svgElement); svgRef.current = svgElement; attachInteractions(svgElement); } catch (e) { console.error('Viz render error', e); // try to recover viz.reset(); } } function graphToDot(g, options={includePositions:false}) { // Directed graph, use neato layout so we can use pos attributes const lines = []; lines.push('digraph G {'); lines.push(' graph [splines=true, overlap=false, sep="+8", rankdir=LR, layout=neato];'); lines.push(' node [shape=circle, style=filled, fillcolor="lightgoldenrod", fontsize=12];'); // nodes for (const n of g.nodes) { const attrs = []; attrs.push(`label=\"${escapeLabel(n.label)}\"`); if (n.pos) { // pin node if pos is set attrs.push(`pos=\"${n.pos}\"`); attrs.push(`pin=true`); } lines.push(` \"${n.id}\" [${attrs.join(', ')}];`); } // edges for (const e of g.edges) { lines.push(` \"${e.from}\" -> \"${e.to}\";`); } // close lines.push('}'); return lines.join('\n'); } function escapeLabel(s) { return String(s).replace(/"/g, '\\"'); } // Attach interactivity (d3) function attachInteractions(svgElement) { const svg = d3.select(svgElement); // mark edges and nodes with helpful data attributes svg.selectAll('g.edge').each(function() { const g = d3.select(this); const title = g.select('title').text(); // Graphviz gives "A->B" if (title) { // parse const match = title.match(/([^\s]+)\s*->\s*([^\s]+)/); if (match) { g.attr('data-from', match[1]); g.attr('data-to', match[2]); } } }); svg.selectAll('g.node').each(function() { const g = d3.select(this); const title = g.select('title').text(); // node id if (title) { g.attr('data-id', title); } }); // node click handlers svg.selectAll('g.node') .style('cursor', 'pointer') .on('click', function(event) { event.stopPropagation(); const id = d3.select(this).attr('data-id'); setSelectedNode(id); }) .on('dblclick', function(event) { event.stopPropagation(); const id = d3.select(this).attr('data-id'); openSubgraphModal(id); }); // Add dragging: user must hold Ctrl while dragging a node // We'll implement a manual pointer drag (not d3.drag) to get full control svg.selectAll('g.node').each(function() { const nodeG = d3.select(this); const id = nodeG.attr('data-id'); const shape = nodeG.select('ellipse, polygon, path'); nodeG.on('pointerdown', function(event) { if (!event.ctrlKey) return; // require Ctrl key event.preventDefault(); const pointerId = event.pointerId; const startPos = getEventPoint(event, svgElement); // capture pointer on this node nodeG.node().setPointerCapture(pointerId); const onPointerMove = (ev) => { ev.preventDefault(); const p = getEventPoint(ev, svgElement); // move the node by updating its pos temporarily and re-render with pin // convert to graph coords - Graphviz uses points; fortunately, render gives coords matching svg units const fixedPos = `${p.x},${p.y}!`; setGraph(prev => { // update node's pos const nodes = prev.nodes.map(n => n.id === id ? {...n, pos: `${p.x},${p.y}`} : n); return {...prev, nodes}; }); }; const onPointerUp = (ev) => { try { nodeG.node().releasePointerCapture(pointerId); } catch(e){} document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); const p = getEventPoint(ev, svgElement); // check if dropped on an edge const hitEdge = findEdgeUnderPoint(svgElement, p); if (hitEdge) { const from = hitEdge.getAttribute('data-from'); const to = hitEdge.getAttribute('data-to'); // perform split: remove from->to, add from->id and id->to setGraph(prev => { // ensure node id exists in prev const hasNode = prev.nodes.some(n => n.id === id); const nodes = hasNode ? prev.nodes : [...prev.nodes, {id, label: id, pos: `${p.x},${p.y}`}]; const edges = prev.edges.filter(e => !(e.from === from && e.to === to)); edges.push({from, to: id}); edges.push({from: id, to: to}); return {...prev, nodes, edges}; }); } else { // just set final pos on node (already set during move) // to be safe, re-render renderGraph(); } }; document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); }); }); // edge click select svg.selectAll('g.edge').style('cursor', 'pointer').on('click', function(event) { event.stopPropagation(); const g = d3.select(this); const from = g.attr('data-from'); const to = g.attr('data-to'); setSelectedEdge({from, to}); }); // click empty area resets selection svg.on('click', () => { setSelectedNode(null); setSelectedEdge(null); }); } function getEventPoint(event, svgElement) { const pt = svgElement.createSVGPoint(); pt.x = event.clientX; pt.y = event.clientY; const ctm = svgElement.getScreenCTM().inverse(); const loc = pt.matrixTransform(ctm); return {x: loc.x, y: loc.y}; } function findEdgeUnderPoint(svgElement, point) { // iterate edges, compute distance to their path const edges = svgElement.querySelectorAll('g.edge'); for (const g of edges) { const path = g.querySelector('path'); if (!path) continue; const total = path.getTotalLength(); // sample along path to find min distance const samples = Math.max(10, Math.floor(total / 10)); let minDist = Infinity; for (let i=0;i<=samples;i++){ const pt = path.getPointAtLength((i/samples) * total); const dx = pt.x - point.x; const dy = pt.y - point.y; const d = Math.sqrt(dx*dx + dy*dy); if (d < minDist) minDist = d; } if (minDist < 12) return g; // threshold } return null; } // UI actions function addNode() { const id = makeId('n'); setGraph(prev => ({...prev, nodes: [...prev.nodes, {id, label: id}]})); } function connectSelected() { if (!selectedSource || !selectedTarget) return; setGraph(prev => ({...prev, edges: [...prev.edges, {from: selectedSource, to: selectedTarget}]})); setSelectedSource(null); setSelectedTarget(null); } function deleteSelectedNode() { if (!selectedNode) return; const id = selectedNode; setGraph(prev => ({nodes: prev.nodes.filter(n=>n.id!==id), edges: prev.edges.filter(e=>e.from!==id && e.to!==id)})); // also remove subgraph setSubgraphs(prev => { const copy = {...prev}; delete copy[id]; return copy; }); setSelectedNode(null); } function renameSelectedNode(newLabel) { if (!selectedNode) return; setGraph(prev => ({...prev, nodes: prev.nodes.map(n=>n.id===selectedNode?{...n,label:newLabel}:n)})); } function deleteSelectedEdge() { if (!selectedEdge) return; setGraph(prev => ({...prev, edges: prev.edges.filter(e=>!(e.from===selectedEdge.from && e.to===selectedEdge.to))})); setSelectedEdge(null); } function openSubgraphModal(nodeId) { setModalNode(nodeId); // ensure subgraph exists setSubgraphs(prev => ({...prev, [nodeId]: prev[nodeId] || defaultGraph()})); } function saveSubgraph(nodeId, sub) { setSubgraphs(prev=> ({...prev, [nodeId]: sub})); setModalNode(null); } function flattenAll() { // produce a merged DOT: main graph + each subgraph where subgraph node ids are prefixed by parent id const lines = []; lines.push('digraph FLATTEN {'); lines.push(' graph [splines=true, overlap=false, layout=neato];'); lines.push(' node [shape=circle, style=filled, fillcolor="lightblue"];'); // main nodes for (const n of graph.nodes) { const attrs = [`label=\"${escapeLabel(n.label)}\"`]; if (n.pos) attrs.push(`pos=\"${n.pos}\"`); lines.push(` \"${n.id}\" [${attrs.join(',')}];`); } for (const e of graph.edges) lines.push(` \"${e.from}\" -> \"${e.to}\";`); // subgraphs for (const [parent, sub] of Object.entries(subgraphs)) { for (const n of sub.nodes) { const id = `${parent}::${n.id}`; const attrs = [`label=\"${escapeLabel(n.label)}\"`]; if (n.pos) attrs.push(`pos=\"${n.pos}\"`); lines.push(` \"${id}\" [${attrs.join(',')}];`); } for (const e of sub.edges) { const from = `${parent}::${e.from}`; const to = `${parent}::${e.to}`; lines.push(` \"${from}\" -> \"${to}\";`); } // connect parent node to subgraph root nodes (optional: connect parent -> root nodes) // We'll connect parent to every node without incoming edges in the subgraph to make structure visible. const incoming = new Set(sub.edges.map(e=>e.to)); for (const n of sub.nodes) { if (!incoming.has(n.id)) { lines.push(` \"${parent}\" -> \"${parent}::${n.id}\" [style=dashed];`); } } } lines.push('}'); const dot = lines.join('\n'); setFlatDot(dot); // render the flattened one in the main panel renderGraph(dot); } return (