/* * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ import AddIcon from '@mui/icons-material/Add'; import CropFreeIcon from '@mui/icons-material/CropFree'; import RemoveIcon from '@mui/icons-material/Remove'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; import { useTheme } from '@mui/material/styles'; import { CSSProperties } from '@mui/material/styles/createTypography'; import * as d3 from 'd3'; import { type Graphviz, graphviz } from 'd3-graphviz'; import type { BaseType, Selection } from 'd3-selection'; import { zoom as d3Zoom } from 'd3-zoom'; import { reaction, type IReactionDisposer } from 'mobx'; import { useCallback, useRef, useState } from 'react'; import { useRootStore } from '../RootStoreProvider'; import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; function toGraphviz( semantics: SemanticsSuccessResult | undefined, ): string | undefined { if (semantics === undefined) { return undefined; } const lines = [ 'digraph {', 'graph [bgcolor=transparent];', 'node [fontsize=16, shape=plain];', 'edge [fontsize=12, color=black];', ]; 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'); } interface Transform { x: number; y: number; k: number; } export default function GraphArea(): JSX.Element { const { editorStore } = useRootStore(); const theme = useTheme(); const disposerRef = useRef(); const graphvizRef = useRef< Graphviz | undefined >(); const canvasRef = useRef(); const elementRef = useRef(); const zoomRef = useRef< d3.ZoomBehavior | undefined >(); const [zoom, setZoom] = useState({ x: 0, y: 0, k: 1 }); const setCanvas = useCallback((element: HTMLDivElement | null) => { canvasRef.current = element ?? undefined; if (element === null) { return; } const zoomBehavior = d3Zoom(); // `@types/d3-zoom` does not contain the `center` function, because it is // only available as a pull request for `d3-zoom`. ( zoomBehavior as unknown as { center(callback: (event: MouseEvent) => [number, number]): unknown; } ).center((event: MouseEvent | Touch) => { const { width, height } = element.getBoundingClientRect(); const [x, y] = d3.pointer(event, element); return [x - width / 2, y - height / 2]; }); // Custom `centroid` method added via patch. ( zoomBehavior as unknown as { centroid(centroid: [number, number]): unknown; } ).centroid([0, 0]); zoomBehavior.on('zoom', (event: d3.D3ZoomEvent) => setZoom(event.transform), ); d3.select(element).call(zoomBehavior); zoomRef.current = zoomBehavior; }, []); const setElement = useCallback( (element: HTMLDivElement | null) => { elementRef.current = element ?? undefined; 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, ) => { svgSelection.selectAll('title').remove(); const svg = svgSelection.node(); if (svg === null) { return; } svg.querySelectorAll('.node').forEach((node) => { node.querySelectorAll('path').forEach((path) => { const d = path.getAttribute('d') ?? ''; const points = d.split(/[A-Z ]/); points.shift(); const x = points.map((p) => { return Number(p.split(',')[0] ?? 0); }); const y = points.map((p) => { return Number(p.split(',')[1] ?? 0); }); const xmin = Math.min.apply(null, x); const xmax = Math.max.apply(null, x); const ymin = Math.min.apply(null, y); const ymax = Math.max.apply(null, y); const rect = document.createElementNS( 'http://www.w3.org/2000/svg', 'rect', ); rect.setAttribute('fill', path.getAttribute('fill') ?? ''); rect.setAttribute('stroke', path.getAttribute('stroke') ?? ''); rect.setAttribute('x', String(xmin)); rect.setAttribute('y', String(ymin)); rect.setAttribute('width', String(xmax - xmin)); rect.setAttribute('height', String(ymax - ymin)); rect.setAttribute('height', String(ymax - ymin)); rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8'); node.replaceChild(rect, path); }); }); }, ); disposerRef.current = reaction( () => editorStore?.semantics, (semantics) => { const str = toGraphviz(semantics); if (str !== undefined) { renderer.renderDot(str); } }, { fireImmediately: true }, ); graphvizRef.current = renderer; } }, [editorStore], ); const changeZoom = useCallback((event: React.MouseEvent, factor: number) => { if (canvasRef.current === undefined || zoomRef.current === undefined) { return; } const selection = d3.select(canvasRef.current); const zoomTransition = selection.transition().duration(250); const center: [number, number] = [0, 0]; zoomRef.current.scaleBy(zoomTransition, factor, center); event.preventDefault(); event.stopPropagation(); }, []); const fitZoom = useCallback((event: React.MouseEvent) => { if ( canvasRef.current === undefined || zoomRef.current === undefined || elementRef.current === undefined ) { return; } const { width: canvasWidth, height: canvasHeight } = canvasRef.current.getBoundingClientRect(); const { width: scaledWidth, height: scaledHeight } = elementRef.current.getBoundingClientRect(); const currentFactor = d3.zoomTransform(canvasRef.current).k; const width = scaledWidth / currentFactor; const height = scaledHeight / currentFactor; if (width > 0 && height > 0) { const factor = Math.min( 1.0, (canvasWidth - 64) / width, (canvasHeight - 64) / height, ); const selection = d3.select(canvasRef.current); const zoomTransition = selection.transition().duration(250); zoomRef.current.transform(zoomTransition, d3.zoomIdentity.scale(factor)); } event.preventDefault(); event.stopPropagation(); }, []); return ( ), fill: theme.palette.text.primary, }, '& [stroke="black"]': { stroke: theme.palette.text.primary, }, '& [fill="green"]': { fill: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, }, '& [fill="white"]': { fill: theme.palette.background.default, stroke: theme.palette.background.default, }, }, '& .edge': { '& text': { ...(theme.typography.caption as Omit< CSSProperties, '@font-face' >), fill: theme.palette.text.primary, }, '& [stroke="black"]': { stroke: theme.palette.text.primary, }, '& [fill="black"]': { fill: theme.palette.text.primary, }, }, '& .edge-UNKNOWN': { '& text': { fill: theme.palette.text.secondary, }, '& [stroke="black"]': { stroke: theme.palette.text.secondary, }, '& [fill="black"]': { fill: theme.palette.text.secondary, }, }, '& .edge-ERROR': { '& text': { fill: theme.palette.error.main, }, '& [stroke="black"]': { stroke: theme.palette.error.main, }, '& [fill="black"]': { fill: theme.palette.error.main, }, }, }, }} ref={setElement} /> changeZoom(event, 2)} > changeZoom(event, 0.5)} > ); }