/*
* SPDX-FileCopyrightText: 2021-2023 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
import Box from '@mui/material/Box';
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=<
>];`,
),
);
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 disposerRef = useRef();
const graphvizRef = useRef<
Graphviz | undefined
>();
const canvasRef = 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) => {
const { width, height } = element.getBoundingClientRect();
const [x, y] = d3.pointer(event, element);
return [x - width / 2, y - height / 2];
});
zoomBehavior.on('zoom', (event: d3.D3ZoomEvent) =>
setZoom(event.transform),
);
d3.select(element).call(zoomBehavior);
zoomRef.current = zoomBehavior;
}, []);
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,
) => {
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],
);
return (
({
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
'& svg': {
userSelect: 'none',
'& .node': {
'& text': {
...theme.typography.body2,
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,
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={setCanvas}
>
);
}