diff options
Diffstat (limited to 'subprojects/frontend/src/graph/GraphArea.tsx')
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 380 |
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 | ||
7 | import AddIcon from '@mui/icons-material/Add'; | 7 | import DotGraphVisualizer from './DotGraphVisualizer'; |
8 | import CropFreeIcon from '@mui/icons-material/CropFree'; | 8 | import ZoomCanvas from './ZoomCanvas'; |
9 | import RemoveIcon from '@mui/icons-material/Remove'; | ||
10 | import Box from '@mui/material/Box'; | ||
11 | import IconButton from '@mui/material/IconButton'; | ||
12 | import Stack from '@mui/material/Stack'; | ||
13 | import { useTheme } from '@mui/material/styles'; | ||
14 | import { CSSProperties } from '@mui/material/styles/createTypography'; | ||
15 | import * as d3 from 'd3'; | ||
16 | import { type Graphviz, graphviz } from 'd3-graphviz'; | ||
17 | import type { BaseType, Selection } from 'd3-selection'; | ||
18 | import { zoom as d3Zoom } from 'd3-zoom'; | ||
19 | import { reaction, type IReactionDisposer } from 'mobx'; | ||
20 | import { useCallback, useRef, useState } from 'react'; | ||
21 | |||
22 | import { useRootStore } from '../RootStoreProvider'; | ||
23 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
24 | |||
25 | function 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 | |||
81 | interface Transform { | ||
82 | x: number; | ||
83 | y: number; | ||
84 | k: number; | ||
85 | } | ||
86 | 9 | ||
87 | export default function GraphArea(): JSX.Element { | 10 | export 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 | } |