diff options
Diffstat (limited to 'subprojects/frontend/src/graph/GraphArea.tsx')
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 318 |
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 | |||
7 | import Box from '@mui/material/Box'; | ||
8 | import * as d3 from 'd3'; | ||
9 | import { type Graphviz, graphviz } from 'd3-graphviz'; | ||
10 | import type { BaseType, Selection } from 'd3-selection'; | ||
11 | import { reaction, type IReactionDisposer } from 'mobx'; | ||
12 | import { useCallback, useRef, useState } from 'react'; | ||
13 | import { useResizeDetector } from 'react-resize-detector'; | ||
14 | |||
15 | import { useRootStore } from '../RootStoreProvider'; | ||
16 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
17 | |||
18 | function 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 | |||
74 | interface Transform { | ||
75 | x: number; | ||
76 | y: number; | ||
77 | k: number; | ||
78 | } | ||
79 | |||
80 | export 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 | } | ||