/* * SPDX-FileCopyrightText: 2023 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ import * as d3 from 'd3'; import { type Graphviz, graphviz } from 'd3-graphviz'; import type { BaseType, Selection } from 'd3-selection'; import { reaction, type IReactionDisposer } from 'mobx'; import { useCallback, useRef } from 'react'; import { useRootStore } from '../RootStoreProvider'; import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; import GraphTheme from './GraphTheme'; import postProcessSvg from './postProcessSVG'; function toGraphviz( semantics: SemanticsSuccessResult | undefined, ): string | undefined { if (semantics === undefined) { return undefined; } const lines = [ 'digraph {', 'graph [bgcolor=transparent];', `node [fontsize=12, shape=plain, fontname="OpenSans"];`, 'edge [fontsize=10.5, color=black, fontname="OpenSans"];', ]; const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`); lines.push( ...nodeIds.map( (id, i) => `n${i} [id="${id}", label=<
${id}
node
>];`, ), ); Object.keys(semantics.partialInterpretation).forEach((relation) => { if (relation === 'builtin::equals' || relation === 'builtin::contains') { return; } const tuples = semantics.partialInterpretation[relation]; if (tuples === undefined) { return; } const first = tuples[0]; if (first === undefined || first.length !== 3) { return; } const nameFragments = relation.split('::'); const simpleName = nameFragments[nameFragments.length - 1] ?? relation; lines.push( ...tuples.map(([from, to, value]) => { if ( typeof from !== 'number' || typeof to !== 'number' || typeof value !== 'string' ) { return ''; } const isUnknown = value === 'UNKNOWN'; return `n${from} -> n${to} [ id="${nodeIds[from]},${nodeIds[to]},${relation}", xlabel="${simpleName}", style="${isUnknown ? 'dashed' : 'solid'}", class="edge-${value}" ];`; }), ); }); lines.push('}'); return lines.join('\n'); } export default function DotGraphVisualizer(): JSX.Element { const { editorStore } = useRootStore(); const disposerRef = useRef(); const graphvizRef = useRef< Graphviz | undefined >(); const setElement = useCallback( (element: HTMLDivElement | null) => { if (disposerRef.current !== undefined) { disposerRef.current(); disposerRef.current = undefined; } if (graphvizRef.current !== undefined) { // `@types/d3-graphviz` does not contain the signature for the `destroy` method. (graphvizRef.current as unknown as { destroy(): void }).destroy(); graphvizRef.current = undefined; } if (element !== null) { element.replaceChildren(); const renderer = graphviz(element) as Graphviz< BaseType, unknown, null, undefined >; renderer.keyMode('id'); renderer.zoom(false); renderer.tweenPrecision('5%'); renderer.tweenShapes(false); renderer.convertEqualSidedPolygons(false); const transition = () => d3.transition().duration(300).ease(d3.easeCubic); /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any -- Workaround for error in `@types/d3-graphviz`. */ renderer.transition(transition as any); renderer.on( 'postProcessSVG', // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. ( svgSelection: Selection, ) => { const svg = svgSelection.node(); if (svg !== null) { postProcessSvg(svg); } }, ); disposerRef.current = reaction( () => editorStore?.semantics, (semantics) => { const str = toGraphviz(semantics); if (str !== undefined) { renderer.renderDot(str); } }, { fireImmediately: true }, ); graphvizRef.current = renderer; } }, [editorStore], ); return ; }