diff options
Diffstat (limited to 'subprojects/frontend/src/graph')
-rw-r--r-- | subprojects/frontend/src/graph/DotGraphVisualizer.tsx | 142 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 380 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphPane.tsx | 2 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphTheme.tsx | 64 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/ZoomButtons.tsx | 43 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/ZoomCanvas.tsx | 177 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/parseBBox.ts | 68 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/postProcessSVG.ts | 79 |
8 files changed, 580 insertions, 375 deletions
diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx new file mode 100644 index 00000000..7c25488a --- /dev/null +++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx | |||
@@ -0,0 +1,142 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import * as d3 from 'd3'; | ||
8 | import { type Graphviz, graphviz } from 'd3-graphviz'; | ||
9 | import type { BaseType, Selection } from 'd3-selection'; | ||
10 | import { reaction, type IReactionDisposer } from 'mobx'; | ||
11 | import { useCallback, useRef } from 'react'; | ||
12 | |||
13 | import { useRootStore } from '../RootStoreProvider'; | ||
14 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
15 | |||
16 | import GraphTheme from './GraphTheme'; | ||
17 | import postProcessSvg from './postProcessSVG'; | ||
18 | |||
19 | function toGraphviz( | ||
20 | semantics: SemanticsSuccessResult | undefined, | ||
21 | ): string | undefined { | ||
22 | if (semantics === undefined) { | ||
23 | return undefined; | ||
24 | } | ||
25 | const lines = [ | ||
26 | 'digraph {', | ||
27 | 'graph [bgcolor=transparent];', | ||
28 | `node [fontsize=12, shape=plain, fontname="OpenSans"];`, | ||
29 | 'edge [fontsize=10.5, color=black, fontname="OpenSans"];', | ||
30 | ]; | ||
31 | const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`); | ||
32 | lines.push( | ||
33 | ...nodeIds.map( | ||
34 | (id, i) => | ||
35 | `n${i} [id="${id}", label=<<table border="1" cellborder="0" cellspacing="0" cellpadding="4.5" style="rounded" bgcolor="green"><tr><td>${id}</td></tr><hr/><tr><td bgcolor="white">node</td></tr></table>>];`, | ||
36 | ), | ||
37 | ); | ||
38 | Object.keys(semantics.partialInterpretation).forEach((relation) => { | ||
39 | if (relation === 'builtin::equals' || relation === 'builtin::contains') { | ||
40 | return; | ||
41 | } | ||
42 | const tuples = semantics.partialInterpretation[relation]; | ||
43 | if (tuples === undefined) { | ||
44 | return; | ||
45 | } | ||
46 | const first = tuples[0]; | ||
47 | if (first === undefined || first.length !== 3) { | ||
48 | return; | ||
49 | } | ||
50 | const nameFragments = relation.split('::'); | ||
51 | const simpleName = nameFragments[nameFragments.length - 1] ?? relation; | ||
52 | lines.push( | ||
53 | ...tuples.map(([from, to, value]) => { | ||
54 | if ( | ||
55 | typeof from !== 'number' || | ||
56 | typeof to !== 'number' || | ||
57 | typeof value !== 'string' | ||
58 | ) { | ||
59 | return ''; | ||
60 | } | ||
61 | const isUnknown = value === 'UNKNOWN'; | ||
62 | return `n${from} -> n${to} [ | ||
63 | id="${nodeIds[from]},${nodeIds[to]},${relation}", | ||
64 | xlabel="${simpleName}", | ||
65 | style="${isUnknown ? 'dashed' : 'solid'}", | ||
66 | class="edge-${value}" | ||
67 | ];`; | ||
68 | }), | ||
69 | ); | ||
70 | }); | ||
71 | lines.push('}'); | ||
72 | return lines.join('\n'); | ||
73 | } | ||
74 | |||
75 | export default function DotGraphVisualizer(): JSX.Element { | ||
76 | const { editorStore } = useRootStore(); | ||
77 | const disposerRef = useRef<IReactionDisposer | undefined>(); | ||
78 | const graphvizRef = useRef< | ||
79 | Graphviz<BaseType, unknown, null, undefined> | undefined | ||
80 | >(); | ||
81 | |||
82 | const setElement = useCallback( | ||
83 | (element: HTMLDivElement | null) => { | ||
84 | if (disposerRef.current !== undefined) { | ||
85 | disposerRef.current(); | ||
86 | disposerRef.current = undefined; | ||
87 | } | ||
88 | if (graphvizRef.current !== undefined) { | ||
89 | // `@types/d3-graphviz` does not contain the signature for the `destroy` method. | ||
90 | (graphvizRef.current as unknown as { destroy(): void }).destroy(); | ||
91 | graphvizRef.current = undefined; | ||
92 | } | ||
93 | if (element !== null) { | ||
94 | element.replaceChildren(); | ||
95 | const renderer = graphviz(element) as Graphviz< | ||
96 | BaseType, | ||
97 | unknown, | ||
98 | null, | ||
99 | undefined | ||
100 | >; | ||
101 | renderer.keyMode('id'); | ||
102 | renderer.zoom(false); | ||
103 | renderer.tweenPrecision('5%'); | ||
104 | renderer.tweenShapes(false); | ||
105 | renderer.convertEqualSidedPolygons(false); | ||
106 | const transition = () => | ||
107 | d3.transition().duration(300).ease(d3.easeCubic); | ||
108 | /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument, | ||
109 | @typescript-eslint/no-explicit-any -- | ||
110 | Workaround for error in `@types/d3-graphviz`. | ||
111 | */ | ||
112 | renderer.transition(transition as any); | ||
113 | renderer.on( | ||
114 | 'postProcessSVG', | ||
115 | // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. | ||
116 | ( | ||
117 | svgSelection: Selection<SVGSVGElement, unknown, BaseType, unknown>, | ||
118 | ) => { | ||
119 | const svg = svgSelection.node(); | ||
120 | if (svg !== null) { | ||
121 | postProcessSvg(svg); | ||
122 | } | ||
123 | }, | ||
124 | ); | ||
125 | disposerRef.current = reaction( | ||
126 | () => editorStore?.semantics, | ||
127 | (semantics) => { | ||
128 | const str = toGraphviz(semantics); | ||
129 | if (str !== undefined) { | ||
130 | renderer.renderDot(str); | ||
131 | } | ||
132 | }, | ||
133 | { fireImmediately: true }, | ||
134 | ); | ||
135 | graphvizRef.current = renderer; | ||
136 | } | ||
137 | }, | ||
138 | [editorStore], | ||
139 | ); | ||
140 | |||
141 | return <GraphTheme ref={setElement} />; | ||
142 | } | ||
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 | } |
diff --git a/subprojects/frontend/src/graph/GraphPane.tsx b/subprojects/frontend/src/graph/GraphPane.tsx index f04b9931..c2ef8927 100644 --- a/subprojects/frontend/src/graph/GraphPane.tsx +++ b/subprojects/frontend/src/graph/GraphPane.tsx | |||
@@ -1,5 +1,5 @@ | |||
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 | */ |
diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx new file mode 100644 index 00000000..41ba6ba5 --- /dev/null +++ b/subprojects/frontend/src/graph/GraphTheme.tsx | |||
@@ -0,0 +1,64 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import { styled, type CSSObject } from '@mui/material/styles'; | ||
8 | |||
9 | function createEdgeColor(suffix: string, color: string): CSSObject { | ||
10 | return { | ||
11 | [`& .edge-${suffix}`]: { | ||
12 | '& text': { | ||
13 | fill: color, | ||
14 | }, | ||
15 | '& [stroke="black"]': { | ||
16 | stroke: color, | ||
17 | }, | ||
18 | '& [fill="black"]': { | ||
19 | fill: color, | ||
20 | }, | ||
21 | }, | ||
22 | }; | ||
23 | } | ||
24 | |||
25 | export default styled('div', { | ||
26 | name: 'GraphTheme', | ||
27 | })(({ theme }) => ({ | ||
28 | '& svg': { | ||
29 | userSelect: 'none', | ||
30 | '& .node': { | ||
31 | '& text': { | ||
32 | fontFamily: theme.typography.fontFamily, | ||
33 | fill: theme.palette.text.primary, | ||
34 | }, | ||
35 | '& [stroke="black"]': { | ||
36 | stroke: theme.palette.text.primary, | ||
37 | }, | ||
38 | '& [fill="green"]': { | ||
39 | fill: | ||
40 | theme.palette.mode === 'dark' | ||
41 | ? theme.palette.primary.dark | ||
42 | : theme.palette.primary.light, | ||
43 | }, | ||
44 | '& [fill="white"]': { | ||
45 | fill: theme.palette.background.default, | ||
46 | stroke: theme.palette.background.default, | ||
47 | }, | ||
48 | }, | ||
49 | '& .edge': { | ||
50 | '& text': { | ||
51 | fontFamily: theme.typography.fontFamily, | ||
52 | fill: theme.palette.text.primary, | ||
53 | }, | ||
54 | '& [stroke="black"]': { | ||
55 | stroke: theme.palette.text.primary, | ||
56 | }, | ||
57 | '& [fill="black"]': { | ||
58 | fill: theme.palette.text.primary, | ||
59 | }, | ||
60 | }, | ||
61 | ...createEdgeColor('UNKNOWN', theme.palette.text.secondary), | ||
62 | ...createEdgeColor('ERROR', theme.palette.error.main), | ||
63 | }, | ||
64 | })); | ||
diff --git a/subprojects/frontend/src/graph/ZoomButtons.tsx b/subprojects/frontend/src/graph/ZoomButtons.tsx new file mode 100644 index 00000000..72f54774 --- /dev/null +++ b/subprojects/frontend/src/graph/ZoomButtons.tsx | |||
@@ -0,0 +1,43 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import AddIcon from '@mui/icons-material/Add'; | ||
8 | import CropFreeIcon from '@mui/icons-material/CropFree'; | ||
9 | import RemoveIcon from '@mui/icons-material/Remove'; | ||
10 | import IconButton from '@mui/material/IconButton'; | ||
11 | import Stack from '@mui/material/Stack'; | ||
12 | |||
13 | export default function ZoomButtons({ | ||
14 | changeZoom, | ||
15 | fitZoom, | ||
16 | }: { | ||
17 | changeZoom: (event: React.MouseEvent, factor: number) => void; | ||
18 | fitZoom: (event: React.MouseEvent) => void; | ||
19 | }): JSX.Element { | ||
20 | return ( | ||
21 | <Stack | ||
22 | direction="column" | ||
23 | p={1} | ||
24 | sx={{ position: 'absolute', bottom: 0, right: 0 }} | ||
25 | > | ||
26 | <IconButton | ||
27 | aria-label="Zoom in" | ||
28 | onClick={(event) => changeZoom(event, 2)} | ||
29 | > | ||
30 | <AddIcon fontSize="small" /> | ||
31 | </IconButton> | ||
32 | <IconButton | ||
33 | aria-label="Zoom out" | ||
34 | onClick={(event) => changeZoom(event, 0.5)} | ||
35 | > | ||
36 | <RemoveIcon fontSize="small" /> | ||
37 | </IconButton> | ||
38 | <IconButton aria-label="Fit screen" onClick={fitZoom}> | ||
39 | <CropFreeIcon fontSize="small" /> | ||
40 | </IconButton> | ||
41 | </Stack> | ||
42 | ); | ||
43 | } | ||
diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx new file mode 100644 index 00000000..eb3e9285 --- /dev/null +++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx | |||
@@ -0,0 +1,177 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import Box from '@mui/material/Box'; | ||
8 | import * as d3 from 'd3'; | ||
9 | import { zoom as d3Zoom } from 'd3-zoom'; | ||
10 | import React, { useCallback, useRef, useState } from 'react'; | ||
11 | |||
12 | import ZoomButtons from './ZoomButtons'; | ||
13 | |||
14 | declare module 'd3-zoom' { | ||
15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Redeclaring type parameters. | ||
16 | interface ZoomBehavior<ZoomRefElement extends Element, Datum> { | ||
17 | // `@types/d3-zoom` does not contain the `center` function, because it is | ||
18 | // only available as a pull request for `d3-zoom`. | ||
19 | center(callback: (event: MouseEvent | Touch) => [number, number]): this; | ||
20 | |||
21 | // Custom `centroid` method added via patch. | ||
22 | centroid(centroid: [number, number]): this; | ||
23 | } | ||
24 | } | ||
25 | |||
26 | interface Transform { | ||
27 | x: number; | ||
28 | y: number; | ||
29 | k: number; | ||
30 | } | ||
31 | |||
32 | export default function ZoomCanvas({ | ||
33 | children, | ||
34 | fitPadding, | ||
35 | transitionTime, | ||
36 | }: { | ||
37 | children?: React.ReactNode; | ||
38 | fitPadding?: number; | ||
39 | transitionTime?: number; | ||
40 | }): JSX.Element { | ||
41 | const canvasRef = useRef<HTMLDivElement | undefined>(); | ||
42 | const elementRef = useRef<HTMLDivElement | undefined>(); | ||
43 | const zoomRef = useRef< | ||
44 | d3.ZoomBehavior<HTMLDivElement, unknown> | undefined | ||
45 | >(); | ||
46 | const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding; | ||
47 | const transitionTimeOrDefault = | ||
48 | transitionTime ?? ZoomCanvas.defaultProps.transitionTime; | ||
49 | |||
50 | const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 }); | ||
51 | |||
52 | const setCanvas = useCallback( | ||
53 | (canvas: HTMLDivElement | null) => { | ||
54 | canvasRef.current = canvas ?? undefined; | ||
55 | if (canvas === null) { | ||
56 | return; | ||
57 | } | ||
58 | const zoomBehavior = d3Zoom<HTMLDivElement, unknown>() | ||
59 | .duration(transitionTimeOrDefault) | ||
60 | .center((event) => { | ||
61 | const { width, height } = canvas.getBoundingClientRect(); | ||
62 | const [x, y] = d3.pointer(event, canvas); | ||
63 | return [x - width / 2, y - height / 2]; | ||
64 | }) | ||
65 | .centroid([0, 0]); | ||
66 | zoomBehavior.on( | ||
67 | 'zoom', | ||
68 | (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => | ||
69 | setZoom(event.transform), | ||
70 | ); | ||
71 | d3.select(canvas).call(zoomBehavior); | ||
72 | zoomRef.current = zoomBehavior; | ||
73 | }, | ||
74 | [transitionTimeOrDefault], | ||
75 | ); | ||
76 | |||
77 | const makeTransition = useCallback( | ||
78 | (element: HTMLDivElement) => | ||
79 | d3.select(element).transition().duration(transitionTimeOrDefault), | ||
80 | [transitionTimeOrDefault], | ||
81 | ); | ||
82 | |||
83 | const changeZoom = useCallback( | ||
84 | (event: React.MouseEvent, factor: number) => { | ||
85 | if (canvasRef.current === undefined || zoomRef.current === undefined) { | ||
86 | return; | ||
87 | } | ||
88 | const zoomTransition = makeTransition(canvasRef.current); | ||
89 | const center: [number, number] = [0, 0]; | ||
90 | zoomRef.current.scaleBy(zoomTransition, factor, center); | ||
91 | event.preventDefault(); | ||
92 | event.stopPropagation(); | ||
93 | }, | ||
94 | [makeTransition], | ||
95 | ); | ||
96 | |||
97 | const fitZoom = useCallback( | ||
98 | (event: React.MouseEvent) => { | ||
99 | if ( | ||
100 | canvasRef.current === undefined || | ||
101 | zoomRef.current === undefined || | ||
102 | elementRef.current === undefined | ||
103 | ) { | ||
104 | return; | ||
105 | } | ||
106 | const { width: canvasWidth, height: canvasHeight } = | ||
107 | canvasRef.current.getBoundingClientRect(); | ||
108 | const { width: scaledWidth, height: scaledHeight } = | ||
109 | elementRef.current.getBoundingClientRect(); | ||
110 | const currentFactor = d3.zoomTransform(canvasRef.current).k; | ||
111 | const width = scaledWidth / currentFactor; | ||
112 | const height = scaledHeight / currentFactor; | ||
113 | if (width > 0 && height > 0) { | ||
114 | const factor = Math.min( | ||
115 | 1.0, | ||
116 | (canvasWidth - fitPaddingOrDefault) / width, | ||
117 | (canvasHeight - fitPaddingOrDefault) / height, | ||
118 | ); | ||
119 | const zoomTransition = makeTransition(canvasRef.current); | ||
120 | zoomRef.current.transform( | ||
121 | zoomTransition, | ||
122 | d3.zoomIdentity.scale(factor), | ||
123 | ); | ||
124 | } | ||
125 | event.preventDefault(); | ||
126 | event.stopPropagation(); | ||
127 | }, | ||
128 | [fitPaddingOrDefault, makeTransition], | ||
129 | ); | ||
130 | |||
131 | return ( | ||
132 | <Box | ||
133 | sx={{ | ||
134 | width: '100%', | ||
135 | height: '100%', | ||
136 | position: 'relative', | ||
137 | overflow: 'hidden', | ||
138 | }} | ||
139 | > | ||
140 | <Box | ||
141 | sx={{ | ||
142 | position: 'absolute', | ||
143 | overflow: 'hidden', | ||
144 | top: 0, | ||
145 | left: 0, | ||
146 | right: 0, | ||
147 | bottom: 0, | ||
148 | }} | ||
149 | ref={setCanvas} | ||
150 | > | ||
151 | <Box | ||
152 | sx={{ | ||
153 | position: 'absolute', | ||
154 | top: '50%', | ||
155 | left: '50%', | ||
156 | transform: ` | ||
157 | translate(${zoom.x}px, ${zoom.y}px) | ||
158 | scale(${zoom.k}) | ||
159 | translate(-50%, -50%) | ||
160 | `, | ||
161 | transformOrigin: '0 0', | ||
162 | }} | ||
163 | ref={elementRef} | ||
164 | > | ||
165 | {children} | ||
166 | </Box> | ||
167 | </Box> | ||
168 | <ZoomButtons changeZoom={changeZoom} fitZoom={fitZoom} /> | ||
169 | </Box> | ||
170 | ); | ||
171 | } | ||
172 | |||
173 | ZoomCanvas.defaultProps = { | ||
174 | children: undefined, | ||
175 | fitPadding: 64, | ||
176 | transitionTime: 250, | ||
177 | }; | ||
diff --git a/subprojects/frontend/src/graph/parseBBox.ts b/subprojects/frontend/src/graph/parseBBox.ts new file mode 100644 index 00000000..9806cbca --- /dev/null +++ b/subprojects/frontend/src/graph/parseBBox.ts | |||
@@ -0,0 +1,68 @@ | |||
1 | /* | ||
2 | * Copyright 2017, Magnus Jacobsson | ||
3 | * Copyright 2023, The Refinery Authors <https://refinery.tools/> | ||
4 | * | ||
5 | * SPDX-License-Identifier: BSD-3-Clause AND EPL-2.0 | ||
6 | * | ||
7 | * This file Incorporates patches from the Refinery authors. | ||
8 | * | ||
9 | * Redistribution and use is only permitted if neither | ||
10 | * the name of the copyright holder Magnus Jacobsson nor the names of other | ||
11 | * contributors to the d3-graphviz project are used to endorse or promote | ||
12 | * products derived from this software as per the 3rd clause of the | ||
13 | * 3-clause BSD license. | ||
14 | * | ||
15 | * See LICENSES/BSD-3-Clause.txt for more details. | ||
16 | */ | ||
17 | |||
18 | export interface BBox { | ||
19 | x: number; | ||
20 | y: number; | ||
21 | width: number; | ||
22 | height: number; | ||
23 | } | ||
24 | |||
25 | function parsePoints(points: string[]): BBox { | ||
26 | const x = points.map((p) => Number(p.split(',')[0] ?? 0)); | ||
27 | const y = points.map((p) => Number(p.split(',')[1] ?? 0)); | ||
28 | const xmin = Math.min.apply(null, x); | ||
29 | const xmax = Math.max.apply(null, x); | ||
30 | const ymin = Math.min.apply(null, y); | ||
31 | const ymax = Math.max.apply(null, y); | ||
32 | return { | ||
33 | x: xmin, | ||
34 | y: ymin, | ||
35 | width: xmax - xmin, | ||
36 | height: ymax - ymin, | ||
37 | }; | ||
38 | } | ||
39 | |||
40 | /** | ||
41 | * Compute the bounding box of a polygon without adding it to the DOM. | ||
42 | * | ||
43 | * Copyed from | ||
44 | * https://github.com/magjac/d3-graphviz/blob/81ab523fe5189a90da2d9d9cc9015c7079eea780/src/element.js#L36-L53 | ||
45 | * | ||
46 | * @param path The polygon to compute the bounding box of. | ||
47 | * @returns The computed bounding box. | ||
48 | */ | ||
49 | export function parsePolygonBBox(polygon: SVGPolygonElement): BBox { | ||
50 | const points = (polygon.getAttribute('points') ?? '').split(' '); | ||
51 | return parsePoints(points); | ||
52 | } | ||
53 | |||
54 | /** | ||
55 | * Compute the bounding box of a path without adding it to the DOM. | ||
56 | * | ||
57 | * Copyed from | ||
58 | * https://github.com/magjac/d3-graphviz/blob/81ab523fe5189a90da2d9d9cc9015c7079eea780/src/element.js#L56-L75 | ||
59 | * | ||
60 | * @param path The path to compute the bounding box of. | ||
61 | * @returns The computed bounding box. | ||
62 | */ | ||
63 | export function parsePathBBox(path: SVGPathElement): BBox { | ||
64 | const d = path.getAttribute('d') ?? ''; | ||
65 | const points = d.split(/[A-Z ]/); | ||
66 | points.shift(); | ||
67 | return parsePoints(points); | ||
68 | } | ||
diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts new file mode 100644 index 00000000..59cc15b9 --- /dev/null +++ b/subprojects/frontend/src/graph/postProcessSVG.ts | |||
@@ -0,0 +1,79 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; | ||
8 | |||
9 | const SVG_NS = 'http://www.w3.org/2000/svg'; | ||
10 | |||
11 | function clipCompartmentBackground(node: SVGGElement) { | ||
12 | // Background rectangle of the node created by the `<table bgcolor="green">` | ||
13 | // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. | ||
14 | const container = node.querySelector<SVGRectElement>('rect[fill="green"]'); | ||
15 | // Background rectangle of the lower compartment created by the `<td bgcolor="white">` | ||
16 | // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. | ||
17 | // Since dot doesn't round the coners of `<td>` background, | ||
18 | // we have to clip it ourselves. | ||
19 | const compartment = node.querySelector<SVGPolygonElement>( | ||
20 | 'polygon[fill="white"]', | ||
21 | ); | ||
22 | if (container === null || compartment === null) { | ||
23 | return; | ||
24 | } | ||
25 | const copyOfContainer = container.cloneNode() as SVGRectElement; | ||
26 | const clipPath = document.createElementNS(SVG_NS, 'clipPath'); | ||
27 | const clipId = `${node.id},,clip`; | ||
28 | clipPath.setAttribute('id', clipId); | ||
29 | clipPath.appendChild(copyOfContainer); | ||
30 | node.appendChild(clipPath); | ||
31 | compartment.setAttribute('clip-path', `url(#${clipId})`); | ||
32 | } | ||
33 | |||
34 | function createRect( | ||
35 | { x, y, width, height }: BBox, | ||
36 | original: SVGElement, | ||
37 | ): SVGRectElement { | ||
38 | const rect = document.createElementNS(SVG_NS, 'rect'); | ||
39 | rect.setAttribute('fill', original.getAttribute('fill') ?? ''); | ||
40 | rect.setAttribute('stroke', original.getAttribute('stroke') ?? ''); | ||
41 | rect.setAttribute('x', String(x)); | ||
42 | rect.setAttribute('y', String(y)); | ||
43 | rect.setAttribute('width', String(width)); | ||
44 | rect.setAttribute('height', String(height)); | ||
45 | return rect; | ||
46 | } | ||
47 | |||
48 | function optimizeNodeShapes(node: SVGGElement) { | ||
49 | node.querySelectorAll('path').forEach((path) => { | ||
50 | const bbox = parsePathBBox(path); | ||
51 | const rect = createRect(bbox, path); | ||
52 | rect.setAttribute('rx', '12'); | ||
53 | rect.setAttribute('ry', '12'); | ||
54 | node.replaceChild(rect, path); | ||
55 | }); | ||
56 | node.querySelectorAll('polygon').forEach((polygon) => { | ||
57 | const bbox = parsePolygonBBox(polygon); | ||
58 | if (bbox.height === 0) { | ||
59 | const polyline = document.createElementNS(SVG_NS, 'polyline'); | ||
60 | polyline.setAttribute('stroke', polygon.getAttribute('stroke') ?? ''); | ||
61 | polyline.setAttribute( | ||
62 | 'points', | ||
63 | `${bbox.x},${bbox.y} ${bbox.x + bbox.width},${bbox.y}`, | ||
64 | ); | ||
65 | node.replaceChild(polyline, polygon); | ||
66 | } else { | ||
67 | const rect = createRect(bbox, polygon); | ||
68 | node.replaceChild(rect, polygon); | ||
69 | } | ||
70 | }); | ||
71 | clipCompartmentBackground(node); | ||
72 | } | ||
73 | |||
74 | export default function postProcessSvg(svg: SVGSVGElement) { | ||
75 | svg | ||
76 | .querySelectorAll<SVGTitleElement>('title') | ||
77 | .forEach((title) => title.parentNode?.removeChild(title)); | ||
78 | svg.querySelectorAll<SVGGElement>('g.node').forEach(optimizeNodeShapes); | ||
79 | } | ||