diff options
author | Kristóf Marussy <kristof@marussy.com> | 2023-08-23 03:36:25 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2023-08-23 03:36:25 +0200 |
commit | 0e54d399424374d497d08a8631c4761dece57ceb (patch) | |
tree | bd0873080b4bc3b81984852def5e435e51292d0d /subprojects/frontend/src | |
parent | fix: predicate value translation (diff) | |
download | refinery-0e54d399424374d497d08a8631c4761dece57ceb.tar.gz refinery-0e54d399424374d497d08a8631c4761dece57ceb.tar.zst refinery-0e54d399424374d497d08a8631c4761dece57ceb.zip |
feat: dot visualization
Diffstat (limited to 'subprojects/frontend/src')
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 318 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphPane.tsx | 30 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/SemanticsService.ts | 12 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/xtextServiceResults.ts | 17 |
5 files changed, 358 insertions, 24 deletions
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts index 563725bb..10f01099 100644 --- a/subprojects/frontend/src/editor/EditorStore.ts +++ b/subprojects/frontend/src/editor/EditorStore.ts | |||
@@ -28,6 +28,7 @@ import { nanoid } from 'nanoid'; | |||
28 | import type PWAStore from '../PWAStore'; | 28 | import type PWAStore from '../PWAStore'; |
29 | import getLogger from '../utils/getLogger'; | 29 | import getLogger from '../utils/getLogger'; |
30 | import type XtextClient from '../xtext/XtextClient'; | 30 | import type XtextClient from '../xtext/XtextClient'; |
31 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
31 | 32 | ||
32 | import EditorErrors from './EditorErrors'; | 33 | import EditorErrors from './EditorErrors'; |
33 | import LintPanelStore from './LintPanelStore'; | 34 | import LintPanelStore from './LintPanelStore'; |
@@ -65,7 +66,7 @@ export default class EditorStore { | |||
65 | 66 | ||
66 | semanticsError: string | undefined; | 67 | semanticsError: string | undefined; |
67 | 68 | ||
68 | semantics: unknown = {}; | 69 | semantics: SemanticsSuccessResult | undefined; |
69 | 70 | ||
70 | constructor(initialValue: string, pwaStore: PWAStore) { | 71 | constructor(initialValue: string, pwaStore: PWAStore) { |
71 | this.id = nanoid(); | 72 | this.id = nanoid(); |
@@ -295,7 +296,7 @@ export default class EditorStore { | |||
295 | this.semanticsError = semanticsError; | 296 | this.semanticsError = semanticsError; |
296 | } | 297 | } |
297 | 298 | ||
298 | setSemantics(semantics: unknown) { | 299 | setSemantics(semantics: SemanticsSuccessResult) { |
299 | this.semanticsError = undefined; | 300 | this.semanticsError = undefined; |
300 | this.semantics = semantics; | 301 | this.semantics = semantics; |
301 | } | 302 | } |
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 | } | ||
diff --git a/subprojects/frontend/src/graph/GraphPane.tsx b/subprojects/frontend/src/graph/GraphPane.tsx index f69f52a6..f04b9931 100644 --- a/subprojects/frontend/src/graph/GraphPane.tsx +++ b/subprojects/frontend/src/graph/GraphPane.tsx | |||
@@ -5,24 +5,24 @@ | |||
5 | */ | 5 | */ |
6 | 6 | ||
7 | import Stack from '@mui/material/Stack'; | 7 | import Stack from '@mui/material/Stack'; |
8 | import { styled } from '@mui/material/styles'; | 8 | import { Suspense, lazy } from 'react'; |
9 | import stringify from 'json-stringify-pretty-compact'; | ||
10 | import { observer } from 'mobx-react-lite'; | ||
11 | 9 | ||
12 | import { useRootStore } from '../RootStoreProvider'; | 10 | import Loading from '../Loading'; |
13 | 11 | ||
14 | const StyledCode = styled('code')(({ theme }) => ({ | 12 | const GraphArea = lazy(() => import('./GraphArea')); |
15 | ...theme.typography.editor, | ||
16 | fontWeight: theme.typography.fontWeightEditorNormal, | ||
17 | margin: theme.spacing(2), | ||
18 | whiteSpace: 'pre', | ||
19 | })); | ||
20 | 13 | ||
21 | export default observer(function GraphPane(): JSX.Element { | 14 | export default function GraphPane(): JSX.Element { |
22 | const { editorStore } = useRootStore(); | ||
23 | return ( | 15 | return ( |
24 | <Stack direction="column" height="100%" overflow="auto"> | 16 | <Stack |
25 | <StyledCode>{stringify(editorStore?.semantics ?? {})}</StyledCode> | 17 | direction="column" |
18 | height="100%" | ||
19 | overflow="auto" | ||
20 | alignItems="center" | ||
21 | justifyContent="center" | ||
22 | > | ||
23 | <Suspense fallback={<Loading />}> | ||
24 | <GraphArea /> | ||
25 | </Suspense> | ||
26 | </Stack> | 26 | </Stack> |
27 | ); | 27 | ); |
28 | }); | 28 | } |
diff --git a/subprojects/frontend/src/xtext/SemanticsService.ts b/subprojects/frontend/src/xtext/SemanticsService.ts index 50ec371a..d68b87a9 100644 --- a/subprojects/frontend/src/xtext/SemanticsService.ts +++ b/subprojects/frontend/src/xtext/SemanticsService.ts | |||
@@ -17,11 +17,15 @@ export default class SemanticsService { | |||
17 | 17 | ||
18 | onPush(push: unknown): void { | 18 | onPush(push: unknown): void { |
19 | const result = SemanticsResult.parse(push); | 19 | const result = SemanticsResult.parse(push); |
20 | this.validationService.setSemanticsIssues(result.issues ?? []); | 20 | if ('issues' in result) { |
21 | if (result.error !== undefined) { | 21 | this.validationService.setSemanticsIssues(result.issues); |
22 | this.store.setSemanticsError(result.error); | ||
23 | } else { | 22 | } else { |
24 | this.store.setSemantics(push); | 23 | this.validationService.setSemanticsIssues([]); |
24 | if ('error' in result) { | ||
25 | this.store.setSemanticsError(result.error); | ||
26 | } else { | ||
27 | this.store.setSemantics(result); | ||
28 | } | ||
25 | } | 29 | } |
26 | this.store.analysisCompleted(); | 30 | this.store.analysisCompleted(); |
27 | } | 31 | } |
diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts index cae95771..12f87b26 100644 --- a/subprojects/frontend/src/xtext/xtextServiceResults.ts +++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts | |||
@@ -126,9 +126,20 @@ export const FormattingResult = DocumentStateResult.extend({ | |||
126 | 126 | ||
127 | export type FormattingResult = z.infer<typeof FormattingResult>; | 127 | export type FormattingResult = z.infer<typeof FormattingResult>; |
128 | 128 | ||
129 | export const SemanticsResult = z.object({ | 129 | export const SemanticsSuccessResult = z.object({ |
130 | error: z.string().optional(), | 130 | nodes: z.string().nullable().array(), |
131 | issues: Issue.array().optional(), | 131 | partialInterpretation: z.record( |
132 | z.string(), | ||
133 | z.union([z.number(), z.string()]).array().array(), | ||
134 | ), | ||
132 | }); | 135 | }); |
133 | 136 | ||
137 | export type SemanticsSuccessResult = z.infer<typeof SemanticsSuccessResult>; | ||
138 | |||
139 | export const SemanticsResult = z.union([ | ||
140 | z.object({ error: z.string() }), | ||
141 | z.object({ issues: Issue.array() }), | ||
142 | SemanticsSuccessResult, | ||
143 | ]); | ||
144 | |||
134 | export type SemanticsResult = z.infer<typeof SemanticsResult>; | 145 | export type SemanticsResult = z.infer<typeof SemanticsResult>; |