aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph/GraphArea.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/graph/GraphArea.tsx')
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx380
1 files changed, 6 insertions, 374 deletions
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx
index 6ca3bc87..32147d92 100644
--- a/subprojects/frontend/src/graph/GraphArea.tsx
+++ b/subprojects/frontend/src/graph/GraphArea.tsx
@@ -1,384 +1,16 @@
1/* 1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/> 2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 * 3 *
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
6 6
7import AddIcon from '@mui/icons-material/Add'; 7import DotGraphVisualizer from './DotGraphVisualizer';
8import CropFreeIcon from '@mui/icons-material/CropFree'; 8import ZoomCanvas from './ZoomCanvas';
9import RemoveIcon from '@mui/icons-material/Remove';
10import Box from '@mui/material/Box';
11import IconButton from '@mui/material/IconButton';
12import Stack from '@mui/material/Stack';
13import { useTheme } from '@mui/material/styles';
14import { CSSProperties } from '@mui/material/styles/createTypography';
15import * as d3 from 'd3';
16import { type Graphviz, graphviz } from 'd3-graphviz';
17import type { BaseType, Selection } from 'd3-selection';
18import { zoom as d3Zoom } from 'd3-zoom';
19import { reaction, type IReactionDisposer } from 'mobx';
20import { useCallback, useRef, useState } from 'react';
21
22import { useRootStore } from '../RootStoreProvider';
23import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
24
25function toGraphviz(
26 semantics: SemanticsSuccessResult | undefined,
27): string | undefined {
28 if (semantics === undefined) {
29 return undefined;
30 }
31 const lines = [
32 'digraph {',
33 'graph [bgcolor=transparent];',
34 'node [fontsize=16, shape=plain];',
35 'edge [fontsize=12, color=black];',
36 ];
37 const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`);
38 lines.push(
39 ...nodeIds.map(
40 (id, i) =>
41 `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>>];`,
42 ),
43 );
44 Object.keys(semantics.partialInterpretation).forEach((relation) => {
45 if (relation === 'builtin::equals' || relation === 'builtin::contains') {
46 return;
47 }
48 const tuples = semantics.partialInterpretation[relation];
49 if (tuples === undefined) {
50 return;
51 }
52 const first = tuples[0];
53 if (first === undefined || first.length !== 3) {
54 return;
55 }
56 const nameFragments = relation.split('::');
57 const simpleName = nameFragments[nameFragments.length - 1] ?? relation;
58 lines.push(
59 ...tuples.map(([from, to, value]) => {
60 if (
61 typeof from !== 'number' ||
62 typeof to !== 'number' ||
63 typeof value !== 'string'
64 ) {
65 return '';
66 }
67 const isUnknown = value === 'UNKNOWN';
68 return `n${from} -> n${to} [
69 id="${nodeIds[from]},${nodeIds[to]},${relation}",
70 xlabel="${simpleName}",
71 style="${isUnknown ? 'dashed' : 'solid'}",
72 class="edge-${value}"
73 ];`;
74 }),
75 );
76 });
77 lines.push('}');
78 return lines.join('\n');
79}
80
81interface Transform {
82 x: number;
83 y: number;
84 k: number;
85}
86 9
87export default function GraphArea(): JSX.Element { 10export default function GraphArea(): JSX.Element {
88 const { editorStore } = useRootStore();
89 const theme = useTheme();
90 const disposerRef = useRef<IReactionDisposer | undefined>();
91 const graphvizRef = useRef<
92 Graphviz<BaseType, unknown, null, undefined> | undefined
93 >();
94 const canvasRef = useRef<HTMLDivElement | undefined>();
95 const elementRef = useRef<HTMLDivElement | undefined>();
96 const zoomRef = useRef<
97 d3.ZoomBehavior<HTMLDivElement, unknown> | undefined
98 >();
99 const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 });
100
101 const setCanvas = useCallback((element: HTMLDivElement | null) => {
102 canvasRef.current = element ?? undefined;
103 if (element === null) {
104 return;
105 }
106 const zoomBehavior = d3Zoom<HTMLDivElement, unknown>();
107 // `@types/d3-zoom` does not contain the `center` function, because it is
108 // only available as a pull request for `d3-zoom`.
109 (
110 zoomBehavior as unknown as {
111 center(callback: (event: MouseEvent) => [number, number]): unknown;
112 }
113 ).center((event: MouseEvent | Touch) => {
114 const { width, height } = element.getBoundingClientRect();
115 const [x, y] = d3.pointer(event, element);
116 return [x - width / 2, y - height / 2];
117 });
118 // Custom `centroid` method added via patch.
119 (
120 zoomBehavior as unknown as {
121 centroid(centroid: [number, number]): unknown;
122 }
123 ).centroid([0, 0]);
124 zoomBehavior.on('zoom', (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) =>
125 setZoom(event.transform),
126 );
127 d3.select(element).call(zoomBehavior);
128 zoomRef.current = zoomBehavior;
129 }, []);
130
131 const setElement = useCallback(
132 (element: HTMLDivElement | null) => {
133 elementRef.current = element ?? undefined;
134 if (disposerRef.current !== undefined) {
135 disposerRef.current();
136 disposerRef.current = undefined;
137 }
138 if (graphvizRef.current !== undefined) {
139 // `@types/d3-graphviz` does not contain the signature for the `destroy` method.
140 (graphvizRef.current as unknown as { destroy(): void }).destroy();
141 graphvizRef.current = undefined;
142 }
143 if (element !== null) {
144 element.replaceChildren();
145 const renderer = graphviz(element) as Graphviz<
146 BaseType,
147 unknown,
148 null,
149 undefined
150 >;
151 renderer.keyMode('id');
152 renderer.zoom(false);
153 renderer.tweenPrecision('5%');
154 renderer.tweenShapes(false);
155 renderer.convertEqualSidedPolygons(false);
156 const transition = () =>
157 d3.transition().duration(300).ease(d3.easeCubic);
158 /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument,
159 @typescript-eslint/no-explicit-any --
160 Workaround for error in `@types/d3-graphviz`.
161 */
162 renderer.transition(transition as any);
163 renderer.on(
164 'postProcessSVG',
165 // @ts-expect-error Custom `d3-graphviz` hook not covered by typings.
166 (
167 svgSelection: Selection<SVGSVGElement, unknown, BaseType, unknown>,
168 ) => {
169 svgSelection.selectAll('title').remove();
170 const svg = svgSelection.node();
171 if (svg === null) {
172 return;
173 }
174 svg.querySelectorAll('.node').forEach((node) => {
175 node.querySelectorAll('path').forEach((path) => {
176 const d = path.getAttribute('d') ?? '';
177 const points = d.split(/[A-Z ]/);
178 points.shift();
179 const x = points.map((p) => {
180 return Number(p.split(',')[0] ?? 0);
181 });
182 const y = points.map((p) => {
183 return Number(p.split(',')[1] ?? 0);
184 });
185 const xmin = Math.min.apply(null, x);
186 const xmax = Math.max.apply(null, x);
187 const ymin = Math.min.apply(null, y);
188 const ymax = Math.max.apply(null, y);
189 const rect = document.createElementNS(
190 'http://www.w3.org/2000/svg',
191 'rect',
192 );
193 rect.setAttribute('fill', path.getAttribute('fill') ?? '');
194 rect.setAttribute('stroke', path.getAttribute('stroke') ?? '');
195 rect.setAttribute('x', String(xmin));
196 rect.setAttribute('y', String(ymin));
197 rect.setAttribute('width', String(xmax - xmin));
198 rect.setAttribute('height', String(ymax - ymin));
199 rect.setAttribute('height', String(ymax - ymin));
200 rect.setAttribute('rx', '8');
201 rect.setAttribute('ry', '8');
202 node.replaceChild(rect, path);
203 });
204 });
205 },
206 );
207 disposerRef.current = reaction(
208 () => editorStore?.semantics,
209 (semantics) => {
210 const str = toGraphviz(semantics);
211 if (str !== undefined) {
212 renderer.renderDot(str);
213 }
214 },
215 { fireImmediately: true },
216 );
217 graphvizRef.current = renderer;
218 }
219 },
220 [editorStore],
221 );
222
223 const changeZoom = useCallback((event: React.MouseEvent, factor: number) => {
224 if (canvasRef.current === undefined || zoomRef.current === undefined) {
225 return;
226 }
227 const selection = d3.select(canvasRef.current);
228 const zoomTransition = selection.transition().duration(250);
229 const center: [number, number] = [0, 0];
230 zoomRef.current.scaleBy(zoomTransition, factor, center);
231 event.preventDefault();
232 event.stopPropagation();
233 }, []);
234
235 const fitZoom = useCallback((event: React.MouseEvent) => {
236 if (
237 canvasRef.current === undefined ||
238 zoomRef.current === undefined ||
239 elementRef.current === undefined
240 ) {
241 return;
242 }
243 const { width: canvasWidth, height: canvasHeight } =
244 canvasRef.current.getBoundingClientRect();
245 const { width: scaledWidth, height: scaledHeight } =
246 elementRef.current.getBoundingClientRect();
247 const currentFactor = d3.zoomTransform(canvasRef.current).k;
248 const width = scaledWidth / currentFactor;
249 const height = scaledHeight / currentFactor;
250 if (width > 0 && height > 0) {
251 const factor = Math.min(
252 1.0,
253 (canvasWidth - 64) / width,
254 (canvasHeight - 64) / height,
255 );
256 const selection = d3.select(canvasRef.current);
257 const zoomTransition = selection.transition().duration(250);
258 zoomRef.current.transform(zoomTransition, d3.zoomIdentity.scale(factor));
259 }
260 event.preventDefault();
261 event.stopPropagation();
262 }, []);
263
264 return ( 11 return (
265 <Box 12 <ZoomCanvas>
266 sx={{ 13 <DotGraphVisualizer />
267 width: '100%', 14 </ZoomCanvas>
268 height: '100%',
269 position: 'relative',
270 overflow: 'hidden',
271 }}
272 >
273 <Box
274 sx={{
275 position: 'absolute',
276 overflow: 'hidden',
277 top: 0,
278 left: 0,
279 right: 0,
280 bottom: 0,
281 }}
282 ref={setCanvas}
283 >
284 <Box
285 sx={{
286 position: 'absolute',
287 top: '50%',
288 left: '50%',
289 transform: `
290 translate(${zoom.x}px, ${zoom.y}px)
291 scale(${zoom.k})
292 translate(-50%, -50%)
293 `,
294 transformOrigin: '0 0',
295 '& svg': {
296 userSelect: 'none',
297 '& .node': {
298 '& text': {
299 ...(theme.typography.body2 as Omit<
300 CSSProperties,
301 '@font-face'
302 >),
303 fill: theme.palette.text.primary,
304 },
305 '& [stroke="black"]': {
306 stroke: theme.palette.text.primary,
307 },
308 '& [fill="green"]': {
309 fill:
310 theme.palette.mode === 'dark'
311 ? theme.palette.primary.dark
312 : theme.palette.primary.light,
313 },
314 '& [fill="white"]': {
315 fill: theme.palette.background.default,
316 stroke: theme.palette.background.default,
317 },
318 },
319 '& .edge': {
320 '& text': {
321 ...(theme.typography.caption as Omit<
322 CSSProperties,
323 '@font-face'
324 >),
325 fill: theme.palette.text.primary,
326 },
327 '& [stroke="black"]': {
328 stroke: theme.palette.text.primary,
329 },
330 '& [fill="black"]': {
331 fill: theme.palette.text.primary,
332 },
333 },
334 '& .edge-UNKNOWN': {
335 '& text': {
336 fill: theme.palette.text.secondary,
337 },
338 '& [stroke="black"]': {
339 stroke: theme.palette.text.secondary,
340 },
341 '& [fill="black"]': {
342 fill: theme.palette.text.secondary,
343 },
344 },
345 '& .edge-ERROR': {
346 '& text': {
347 fill: theme.palette.error.main,
348 },
349 '& [stroke="black"]': {
350 stroke: theme.palette.error.main,
351 },
352 '& [fill="black"]': {
353 fill: theme.palette.error.main,
354 },
355 },
356 },
357 }}
358 ref={setElement}
359 />
360 </Box>
361 <Stack
362 direction="column"
363 p={1}
364 sx={{ position: 'absolute', bottom: 0, right: 0 }}
365 >
366 <IconButton
367 aria-label="Zoom in"
368 onClick={(event) => changeZoom(event, 2)}
369 >
370 <AddIcon fontSize="small" />
371 </IconButton>
372 <IconButton
373 aria-label="Zoom out"
374 onClick={(event) => changeZoom(event, 0.5)}
375 >
376 <RemoveIcon fontSize="small" />
377 </IconButton>
378 <IconButton aria-label="Fit screen" onClick={fitZoom}>
379 <CropFreeIcon fontSize="small" />
380 </IconButton>
381 </Stack>
382 </Box>
383 ); 15 );
384} 16}