import { createContext, useEffect, useRef, useState } 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 type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb"; import NodeContextMenu from "./NodeContextMenu"; import NodeRenameModal from "./NodeRenameModal"; import { useGraphsStore } from "../stores/GraphsStore"; import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore"; import { useLoadStore } from "../stores/LoadStore"; export class GraphModel { nodes: NodeModel[] = []; edges: EdgeModel[] = []; } export class EdgeModel { from: string; to: string; id: string; constructor(from: string, to: string, id: string) { this.from = from; this.to = to; this.id = id; } } 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 { } export interface NodeContext { nodeId: string; nodeName?: string; coords: { x: number, y: number }; } export interface GraphContext { graphId: string; selectGraphId: React.Dispatch>; graph: GraphModel; setGraph: React.Dispatch>; } export interface OpenNodeContext { opened: boolean; nodeContext: NodeContext | undefined; } const viz = new Viz({ Module, render }); export const graphContext = createContext(null); export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch> }) { const containerRef = useRef(null); const [graph, setGraph] = useState(defaultGraph()); const [contextMenuOpened, openContextMenu] = useState(false); const [renameModalOpened, openRenameModal] = useState(false); const [graphId, selectGraphId] = useState('main'); const graphIdRef = useRef(graphId); const [graphsPath, setGraphsPath] = useState([createPathSegment('main', 'Main')]) const [nodeContext, openNodeContext] = useState(null); graphIdRef.current = graphId; const graphContextValue = { graphId: graphId, selectGraphId: selectGraphId, graph: graph, setGraph: setGraph }; const isKeyPressed = useKeysdownStore(store => store.has); const selectNode = useSelectedNodesStore(store => store.add); const deselectNode = useSelectedNodesStore(store => store.remove); const isNodeSelected = useSelectedNodesStore(store => store.has); const anyNodeSelected = useSelectedNodesStore(store => store.hasAny); const deselectAllNodes = useSelectedNodesStore(store => store.clear); const loadCount = useLoadStore(state => state.loadCount); useEffect(() => { setGraphPath(graphsPath); }, [graphsPath]) useEffect(() => { renderGraph(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [graph]); // Persist graph edits to the store so they survive breadcrumb navigation. // We intentionally read graphId via ref (not as a dep) so this effect only // fires on graph content changes, never on navigation (graphId changes). useEffect(() => { const state = useGraphsStore.getState() as { graphsById: Map }; state.graphsById.set(graphIdRef.current, graph); // eslint-disable-next-line react-hooks/exhaustive-deps }, [graph]); useEffect(() => { if (nodeContext) { openContextMenu(true); } }, [nodeContext]); useEffect(() => { if (loadCount === 0) return; const state = useGraphsStore.getState() as { graphsById: Map }; const mainGraph = state.graphsById.get('main'); if (mainGraph) setGraph(mainGraph); selectGraphId('main'); setGraphsPath([createPathSegment('main', 'Main')]); openNodeContext(null); openContextMenu(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadCount]); useEffect(() => { if (nodeContext?.nodeId === graphId) { graphsPath.push(createPathSegment(nodeContext.nodeId, nodeContext.nodeName)); setGraphsPath([...graphsPath]) } const state = useGraphsStore.getState() as { graphsById: Map }; const graphsById = state.graphsById; const graph = graphsById.get(graphId); if (graph) { setGraph(graph); } }, [graphId]); async function renderGraph() { const dot = graphToDot(graph); try { const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' }); const container = containerRef.current; if (!container) return; (container as HTMLElement).innerHTML = ''; (container as HTMLElement).appendChild(svgElement); attachInteractions(svgElement); } catch (e) { console.error('Viz render error', e); } } function attachInteractions(svgElement: SVGSVGElement) { const svg = d3.select(svgElement); svg.selectAll('g.edge') .style('cursor', 'not-allowed') .on('click', function () { const d3Node = d3.select(this); const id = d3Node.attr('id'); setGraph(prev => ({ ...prev, edges: [...prev.edges.filter(e => e.id !== id)] })); }) svg.selectAll('g.node') .style('cursor', 'pointer') .on('click', function () { const d3Node = d3.select(this); const d3Rect = d3Node.select('g.node polygon'); const id = d3Node.attr('id'); if (isKeyPressed('Control')) { if (isNodeSelected(id)) { deselectNode(id); d3Rect .attr("stroke", "#000000"); } else { selectNode(id); d3Rect .attr("stroke", "#1677ff") } } else { if (anyNodeSelected()) { linkSelectedNodesAsParents(id); deselectAllNodes(); d3.selectAll('g.node polygon') .attr("stroke", "#000000"); } else { createChildNode(id); } } }) .on('contextmenu', function (event) { const id = d3.select(this).attr('id'); const node = graph.nodes.find(n => n.id === id); if (!node) { return; } event.preventDefault(); openNodeContext({ nodeId: id, nodeName: node.label, coords: { x: event.clientX, y: event.clientY }, }) }); } function linkSelectedNodesAsParents(childNodeId: string) { const selectedNodesIds = useSelectedNodesStore.getState().items; setGraph(prev => ({ ...prev, edges: [...prev.edges, ...selectedNodesIds.map(parentId => ({ from: parentId, to: childNodeId, id: crypto.randomUUID() }))] })) } function createChildNode(parentId: string) { const id = crypto.randomUUID(); setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: getRandomWords(1)[0] }], edges: [...prev.edges, { from: parentId, to: id, id: crypto.randomUUID() }] })); } function getRandomWords(count: number) { const wordList = ['Apple', 'Sun', 'Flame', 'Earth', 'Forest', 'Dream', 'Sky', 'Shadow', 'Flower', 'Ocean', 'River', 'Path', 'Sand', 'Night', 'Star', 'Rain', 'Light', 'Tree', 'Wave', 'Storm', 'Stone', 'Snow', 'Cloud', 'Heart', 'Mountain', 'Leaf', 'Bird', 'Wind', 'Fire', 'Wolf']; return wordList .sort(() => Math.random() - 0.5) .slice(0, count); } function createPathSegment( pathSegmentId: string, selectedNodeName: string | undefined) : BreadcrumbItemType { return { title: selectedNodeName, key: pathSegmentId, onClick: () => { const index = graphsPath.findIndex(p => p.key === pathSegmentId); setGraphsPath(prev => { prev.splice(index + 1); return [...prev]; }); selectGraphId(pathSegmentId); } } as BreadcrumbItemType; } return (
) } export 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, id: crypto.randomUUID() }, ] }; }