281 lines
8.8 KiB
TypeScript
281 lines
8.8 KiB
TypeScript
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<React.SetStateAction<string>>;
|
|
graph: GraphModel;
|
|
setGraph: React.Dispatch<React.SetStateAction<GraphModel>>;
|
|
}
|
|
|
|
export interface OpenNodeContext {
|
|
opened: boolean;
|
|
nodeContext: NodeContext | undefined;
|
|
}
|
|
|
|
const viz = new Viz({ Module, render });
|
|
export const graphContext = createContext<GraphContext | null>(null);
|
|
|
|
export default function Graph({ setGraphPath }: { setGraphPath: React.Dispatch<React.SetStateAction<BreadcrumbItemType[]>> }) {
|
|
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 | NodeContext>(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<string, GraphModel> };
|
|
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<string, GraphModel> };
|
|
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<string, GraphModel> };
|
|
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 (
|
|
<div className="flex-1 p-4">
|
|
<div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{ minHeight: '600px', overflow: 'auto' }}>
|
|
|
|
</div>
|
|
<graphContext.Provider value={graphContextValue}>
|
|
<NodeContextMenu
|
|
nodeContext={nodeContext!}
|
|
contextMenuOpened={contextMenuOpened}
|
|
openContextMenu={openContextMenu}
|
|
openRenameModal={openRenameModal}>
|
|
</NodeContextMenu>
|
|
<NodeRenameModal
|
|
nodeContext={nodeContext!}
|
|
renameModalOpened={renameModalOpened}
|
|
openRenameModal={openRenameModal}>
|
|
</NodeRenameModal>
|
|
</graphContext.Provider>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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() },
|
|
]
|
|
};
|
|
}
|