aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph/GraphArea.tsx
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2023-08-23 03:36:25 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2023-08-23 03:36:25 +0200
commit0e54d399424374d497d08a8631c4761dece57ceb (patch)
treebd0873080b4bc3b81984852def5e435e51292d0d /subprojects/frontend/src/graph/GraphArea.tsx
parentfix: predicate value translation (diff)
downloadrefinery-0e54d399424374d497d08a8631c4761dece57ceb.tar.gz
refinery-0e54d399424374d497d08a8631c4761dece57ceb.tar.zst
refinery-0e54d399424374d497d08a8631c4761dece57ceb.zip
feat: dot visualization
Diffstat (limited to 'subprojects/frontend/src/graph/GraphArea.tsx')
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx318
1 files changed, 318 insertions, 0 deletions
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx
new file mode 100644
index 00000000..b55245d8
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphArea.tsx
@@ -0,0 +1,318 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import Box from '@mui/material/Box';
8import * as d3 from 'd3';
9import { type Graphviz, graphviz } from 'd3-graphviz';
10import type { BaseType, Selection } from 'd3-selection';
11import { reaction, type IReactionDisposer } from 'mobx';
12import { useCallback, useRef, useState } from 'react';
13import { useResizeDetector } from 'react-resize-detector';
14
15import { useRootStore } from '../RootStoreProvider';
16import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
17
18function toGraphviz(
19 semantics: SemanticsSuccessResult | undefined,
20): string | undefined {
21 if (semantics === undefined) {
22 return undefined;
23 }
24 const lines = [
25 'digraph {',
26 'graph [bgcolor=transparent];',
27 'node [fontsize=16, shape=plain];',
28 'edge [fontsize=12, color=black];',
29 ];
30 const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`);
31 lines.push(
32 ...nodeIds.map(
33 (id, i) =>
34 `n${i} [id="${id}", label=<<table border="1" cellborder="0" cellspacing="0" cellpadding="4" style="rounded" bgcolor="green"><tr><td>${id}</td></tr><hr/><tr><td bgcolor="white">node</td></tr></table>>];`,
35 ),
36 );
37 Object.keys(semantics.partialInterpretation).forEach((relation) => {
38 if (relation === 'builtin::equals' || relation === 'builtin::contains') {
39 return;
40 }
41 const tuples = semantics.partialInterpretation[relation];
42 if (tuples === undefined) {
43 return;
44 }
45 const first = tuples[0];
46 if (first === undefined || first.length !== 3) {
47 return;
48 }
49 const nameFragments = relation.split('::');
50 const simpleName = nameFragments[nameFragments.length - 1] ?? relation;
51 lines.push(
52 ...tuples.map(([from, to, value]) => {
53 if (
54 typeof from !== 'number' ||
55 typeof to !== 'number' ||
56 typeof value !== 'string'
57 ) {
58 return '';
59 }
60 const isUnknown = value === 'UNKNOWN';
61 return `n${from} -> n${to} [
62 id="${nodeIds[from]},${nodeIds[to]},${relation}",
63 xlabel="${simpleName}",
64 style="${isUnknown ? 'dashed' : 'solid'}",
65 class="edge-${value}"
66 ];`;
67 }),
68 );
69 });
70 lines.push('}');
71 return lines.join('\n');
72}
73
74interface Transform {
75 x: number;
76 y: number;
77 k: number;
78}
79
80export default function GraphArea(): JSX.Element {
81 const { editorStore } = useRootStore();
82 const disposerRef = useRef<IReactionDisposer | undefined>();
83 const graphvizRef = useRef<
84 Graphviz<BaseType, unknown, null, undefined> | undefined
85 >();
86 const canvasRef = useRef<HTMLDivElement | undefined>();
87 const zoomRef = useRef<
88 d3.ZoomBehavior<HTMLDivElement, unknown> | undefined
89 >();
90 const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 });
91 const widthRef = useRef<number | undefined>();
92 const heightRef = useRef<number | undefined>();
93
94 const onResize = useCallback(
95 (width: number | undefined, height: number | undefined) => {
96 if (canvasRef.current === undefined || zoomRef.current === undefined) {
97 return;
98 }
99 let moveX = 0;
100 let moveY = 0;
101 if (widthRef.current !== undefined && width !== undefined) {
102 moveX = (width - widthRef.current) / 2;
103 }
104 if (heightRef.current !== undefined && height !== undefined) {
105 moveY = (height - heightRef.current) / 2;
106 }
107 widthRef.current = width;
108 heightRef.current = height;
109 if (moveX === 0 && moveY === 0) {
110 return;
111 }
112 const currentTransform = d3.zoomTransform(canvasRef.current);
113 zoomRef.current.translateBy(
114 d3.select(canvasRef.current),
115 moveX / currentTransform.k - moveX,
116 moveY / currentTransform.k - moveY,
117 );
118 },
119 [],
120 );
121
122 const { ref: setCanvasResize } = useResizeDetector({
123 onResize,
124 });
125
126 const setCanvas = useCallback(
127 (element: HTMLDivElement | null) => {
128 canvasRef.current = element ?? undefined;
129 setCanvasResize(element);
130 if (element === null) {
131 return;
132 }
133 const zoomBehavior = d3.zoom<HTMLDivElement, unknown>();
134 zoomBehavior.on(
135 'zoom',
136 (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) =>
137 setZoom(event.transform),
138 );
139 d3.select(element).call(zoomBehavior);
140 zoomRef.current = zoomBehavior;
141 },
142 [setCanvasResize],
143 );
144
145 const setElement = useCallback(
146 (element: HTMLDivElement | null) => {
147 if (disposerRef.current !== undefined) {
148 disposerRef.current();
149 disposerRef.current = undefined;
150 }
151 if (graphvizRef.current !== undefined) {
152 // `@types/d3-graphviz` does not contain the signature for the `destroy` method.
153 (graphvizRef.current as unknown as { destroy(): void }).destroy();
154 graphvizRef.current = undefined;
155 }
156 if (element !== null) {
157 element.replaceChildren();
158 const renderer = graphviz(element) as Graphviz<
159 BaseType,
160 unknown,
161 null,
162 undefined
163 >;
164 renderer.keyMode('id');
165 renderer.zoom(false);
166 renderer.tweenPrecision('5%');
167 renderer.tweenShapes(false);
168 renderer.convertEqualSidedPolygons(false);
169 const transition = () =>
170 d3.transition().duration(300).ease(d3.easeCubic);
171 /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument,
172 @typescript-eslint/no-explicit-any --
173 Workaround for error in `@types/d3-graphviz`.
174 */
175 renderer.transition(transition as any);
176 renderer.on(
177 'postProcessSVG',
178 // @ts-expect-error Custom `d3-graphviz` hook not covered by typings.
179 (
180 svgSelection: Selection<SVGSVGElement, unknown, BaseType, unknown>,
181 ) => {
182 svgSelection.selectAll('title').remove();
183 const svg = svgSelection.node();
184 if (svg === null) {
185 return;
186 }
187 svg.querySelectorAll('.node').forEach((node) => {
188 node.querySelectorAll('path').forEach((path) => {
189 const d = path.getAttribute('d') ?? '';
190 const points = d.split(/[A-Z ]/);
191 points.shift();
192 const x = points.map((p) => {
193 return Number(p.split(',')[0] ?? 0);
194 });
195 const y = points.map((p) => {
196 return Number(p.split(',')[1] ?? 0);
197 });
198 const xmin = Math.min.apply(null, x);
199 const xmax = Math.max.apply(null, x);
200 const ymin = Math.min.apply(null, y);
201 const ymax = Math.max.apply(null, y);
202 const rect = document.createElementNS(
203 'http://www.w3.org/2000/svg',
204 'rect',
205 );
206 rect.setAttribute('fill', path.getAttribute('fill') ?? '');
207 rect.setAttribute('stroke', path.getAttribute('stroke') ?? '');
208 rect.setAttribute('x', String(xmin));
209 rect.setAttribute('y', String(ymin));
210 rect.setAttribute('width', String(xmax - xmin));
211 rect.setAttribute('height', String(ymax - ymin));
212 rect.setAttribute('height', String(ymax - ymin));
213 rect.setAttribute('rx', '12');
214 rect.setAttribute('ry', '12');
215 node.replaceChild(rect, path);
216 });
217 });
218 },
219 );
220 disposerRef.current = reaction(
221 () => editorStore?.semantics,
222 (semantics) => {
223 const str = toGraphviz(semantics);
224 if (str !== undefined) {
225 renderer.renderDot(str);
226 }
227 },
228 { fireImmediately: true },
229 );
230 graphvizRef.current = renderer;
231 }
232 },
233 [editorStore],
234 );
235
236 return (
237 <Box
238 sx={(theme) => ({
239 width: '100%',
240 height: '100%',
241 position: 'relative',
242 overflow: 'hidden',
243 '& svg': {
244 userSelect: 'none',
245 '& .node': {
246 '& text': {
247 ...theme.typography.body2,
248 fill: theme.palette.text.primary,
249 },
250 '& [stroke="black"]': {
251 stroke: theme.palette.text.primary,
252 },
253 '& [fill="green"]': {
254 fill:
255 theme.palette.mode === 'dark'
256 ? theme.palette.primary.dark
257 : theme.palette.primary.light,
258 },
259 '& [fill="white"]': {
260 fill: theme.palette.background.default,
261 stroke: theme.palette.background.default,
262 },
263 },
264 '& .edge': {
265 '& text': {
266 ...theme.typography.caption,
267 fill: theme.palette.text.primary,
268 },
269 '& [stroke="black"]': {
270 stroke: theme.palette.text.primary,
271 },
272 '& [fill="black"]': {
273 fill: theme.palette.text.primary,
274 },
275 },
276 '& .edge-UNKNOWN': {
277 '& text': {
278 fill: theme.palette.text.secondary,
279 },
280 '& [stroke="black"]': {
281 stroke: theme.palette.text.secondary,
282 },
283 '& [fill="black"]': {
284 fill: theme.palette.text.secondary,
285 },
286 },
287 '& .edge-ERROR': {
288 '& text': {
289 fill: theme.palette.error.main,
290 },
291 '& [stroke="black"]': {
292 stroke: theme.palette.error.main,
293 },
294 '& [fill="black"]': {
295 fill: theme.palette.error.main,
296 },
297 },
298 },
299 })}
300 ref={setCanvas}
301 >
302 <Box
303 sx={{
304 position: 'absolute',
305 top: `${50 * zoom.k}%`,
306 left: `${50 * zoom.k}%`,
307 transform: `
308 translate(${zoom.x}px, ${zoom.y}px)
309 scale(${zoom.k})
310 translate(-50%, -50%)
311 `,
312 transformOrigin: '0 0',
313 }}
314 ref={setElement}
315 />
316 </Box>
317 );
318}