aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend')
-rw-r--r--subprojects/frontend/config/graphvizUMDVitePlugin.ts6
-rw-r--r--subprojects/frontend/index.html2
-rw-r--r--subprojects/frontend/package.json3
-rw-r--r--subprojects/frontend/src/TopBar.tsx11
-rw-r--r--subprojects/frontend/src/editor/EditorTheme.ts3
-rw-r--r--subprojects/frontend/src/graph/DotGraphVisualizer.tsx142
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx380
-rw-r--r--subprojects/frontend/src/graph/GraphPane.tsx2
-rw-r--r--subprojects/frontend/src/graph/GraphTheme.tsx64
-rw-r--r--subprojects/frontend/src/graph/ZoomButtons.tsx43
-rw-r--r--subprojects/frontend/src/graph/ZoomCanvas.tsx177
-rw-r--r--subprojects/frontend/src/graph/parseBBox.ts68
-rw-r--r--subprojects/frontend/src/graph/postProcessSVG.ts79
-rw-r--r--subprojects/frontend/src/theme/ThemeProvider.tsx10
-rw-r--r--subprojects/frontend/vite.config.ts2
15 files changed, 607 insertions, 385 deletions
diff --git a/subprojects/frontend/config/graphvizUMDVitePlugin.ts b/subprojects/frontend/config/graphvizUMDVitePlugin.ts
index 7a42560b..9c60a84e 100644
--- a/subprojects/frontend/config/graphvizUMDVitePlugin.ts
+++ b/subprojects/frontend/config/graphvizUMDVitePlugin.ts
@@ -1,3 +1,9 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
1import { readFile } from 'node:fs/promises'; 7import { readFile } from 'node:fs/promises';
2import path from 'node:path'; 8import path from 'node:path';
3 9
diff --git a/subprojects/frontend/index.html b/subprojects/frontend/index.html
index 1bf3472e..f4b46da2 100644
--- a/subprojects/frontend/index.html
+++ b/subprojects/frontend/index.html
@@ -18,7 +18,7 @@
18 <meta name="theme-color" media="(prefers-color-scheme:light)" content="#f5f5f5"> 18 <meta name="theme-color" media="(prefers-color-scheme:light)" content="#f5f5f5">
19 <meta name="theme-color" media="(prefers-color-scheme:dark)" content="#21252b"> 19 <meta name="theme-color" media="(prefers-color-scheme:dark)" content="#21252b">
20 <style> 20 <style>
21 @import '@fontsource-variable/inter/wght.css'; 21 @import '@fontsource-variable/open-sans/wdth.css';
22 @import '@fontsource-variable/jetbrains-mono/wght.css'; 22 @import '@fontsource-variable/jetbrains-mono/wght.css';
23 @import '@fontsource-variable/jetbrains-mono/wght-italic.css'; 23 @import '@fontsource-variable/jetbrains-mono/wght-italic.css';
24 </style> 24 </style>
diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json
index 97f6baf6..3ebb1542 100644
--- a/subprojects/frontend/package.json
+++ b/subprojects/frontend/package.json
@@ -37,8 +37,8 @@
37 "@codemirror/view": "^6.16.0", 37 "@codemirror/view": "^6.16.0",
38 "@emotion/react": "^11.11.1", 38 "@emotion/react": "^11.11.1",
39 "@emotion/styled": "^11.11.0", 39 "@emotion/styled": "^11.11.0",
40 "@fontsource-variable/inter": "^5.0.8",
41 "@fontsource-variable/jetbrains-mono": "^5.0.9", 40 "@fontsource-variable/jetbrains-mono": "^5.0.9",
41 "@fontsource-variable/open-sans": "^5.0.9",
42 "@hpcc-js/wasm": "^2.13.1", 42 "@hpcc-js/wasm": "^2.13.1",
43 "@lezer/common": "^1.0.3", 43 "@lezer/common": "^1.0.3",
44 "@lezer/highlight": "^1.1.6", 44 "@lezer/highlight": "^1.1.6",
@@ -53,7 +53,6 @@
53 "d3-selection": "^3.0.0", 53 "d3-selection": "^3.0.0",
54 "d3-zoom": "patch:d3-zoom@npm%3A3.0.0#~/.yarn/patches/d3-zoom-npm-3.0.0-18f706a421.patch", 54 "d3-zoom": "patch:d3-zoom@npm%3A3.0.0#~/.yarn/patches/d3-zoom-npm-3.0.0-18f706a421.patch",
55 "escape-string-regexp": "^5.0.0", 55 "escape-string-regexp": "^5.0.0",
56 "json-stringify-pretty-compact": "^4.0.0",
57 "lodash-es": "^4.17.21", 56 "lodash-es": "^4.17.21",
58 "loglevel": "^1.8.1", 57 "loglevel": "^1.8.1",
59 "loglevel-plugin-prefix": "^0.8.4", 58 "loglevel-plugin-prefix": "^0.8.4",
diff --git a/subprojects/frontend/src/TopBar.tsx b/subprojects/frontend/src/TopBar.tsx
index f2542b14..c722c203 100644
--- a/subprojects/frontend/src/TopBar.tsx
+++ b/subprojects/frontend/src/TopBar.tsx
@@ -124,7 +124,16 @@ export default observer(function TopBar(): JSX.Element {
124 href="https://www.mcgill.ca/ece/daniel-varro" 124 href="https://www.mcgill.ca/ece/daniel-varro"
125 target="_blank" 125 target="_blank"
126 > 126 >
127 McGill ECE 127 M<span style={{ textTransform: 'none' }}>c</span>Gill ECE
128 </Button>
129 <Button
130 aria-label="Linkönping University, Department of Computer and Information Science"
131 className="rounded"
132 color="inherit"
133 href="https://liu.se/en/employee/danva91"
134 target="_blank"
135 >
136 L<span style={{ textTransform: 'none' }}>i</span>U IDA
128 </Button> 137 </Button>
129 <Button 138 <Button
130 aria-label="2022 Amazon Research Awards recipent" 139 aria-label="2022 Amazon Research Awards recipent"
diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts
index dd551a52..4508273b 100644
--- a/subprojects/frontend/src/editor/EditorTheme.ts
+++ b/subprojects/frontend/src/editor/EditorTheme.ts
@@ -56,8 +56,9 @@ export default styled('div', {
56 '.cm-activeLineGutter': { 56 '.cm-activeLineGutter': {
57 background: 'transparent', 57 background: 'transparent',
58 }, 58 },
59 '.cm-cursor, .cm-cursor-primary': { 59 '.cm-cursor, .cm-dropCursor, .cm-cursor-primary': {
60 borderLeft: `2px solid ${theme.palette.info.main}`, 60 borderLeft: `2px solid ${theme.palette.info.main}`,
61 marginLeft: -1,
61 }, 62 },
62 '.cm-selectionBackground': { 63 '.cm-selectionBackground': {
63 background: theme.palette.highlight.selection, 64 background: theme.palette.highlight.selection,
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
7import * as d3 from 'd3';
8import { type Graphviz, graphviz } from 'd3-graphviz';
9import type { BaseType, Selection } from 'd3-selection';
10import { reaction, type IReactionDisposer } from 'mobx';
11import { useCallback, useRef } from 'react';
12
13import { useRootStore } from '../RootStoreProvider';
14import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
15
16import GraphTheme from './GraphTheme';
17import postProcessSvg from './postProcessSVG';
18
19function 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
75export 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
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}
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
7import { styled, type CSSObject } from '@mui/material/styles';
8
9function 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
25export 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
7import AddIcon from '@mui/icons-material/Add';
8import CropFreeIcon from '@mui/icons-material/CropFree';
9import RemoveIcon from '@mui/icons-material/Remove';
10import IconButton from '@mui/material/IconButton';
11import Stack from '@mui/material/Stack';
12
13export 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
7import Box from '@mui/material/Box';
8import * as d3 from 'd3';
9import { zoom as d3Zoom } from 'd3-zoom';
10import React, { useCallback, useRef, useState } from 'react';
11
12import ZoomButtons from './ZoomButtons';
13
14declare 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
26interface Transform {
27 x: number;
28 y: number;
29 k: number;
30}
31
32export 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
173ZoomCanvas.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
18export interface BBox {
19 x: number;
20 y: number;
21 width: number;
22 height: number;
23}
24
25function 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 */
49export 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 */
63export 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
7import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox';
8
9const SVG_NS = 'http://www.w3.org/2000/svg';
10
11function 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
34function 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
48function 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
74export 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}
diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx
index 78146f25..90fea897 100644
--- a/subprojects/frontend/src/theme/ThemeProvider.tsx
+++ b/subprojects/frontend/src/theme/ThemeProvider.tsx
@@ -75,13 +75,15 @@ function createResponsiveTheme(
75 ...options, 75 ...options,
76 typography: { 76 typography: {
77 fontFamily: 77 fontFamily:
78 '"Inter Variable", "Inter", "Roboto", "Helvetica", "Arial", sans-serif', 78 '"Open Sans Variable", "Open Sans", "Roboto", "Helvetica", "Arial", sans-serif',
79 fontWeightMedium: 600, 79 fontWeightMedium: 500,
80 fontWeightEditorNormal: 400, 80 fontWeightEditorNormal: 400,
81 fontWeightEditorBold: 700, 81 fontWeightEditorBold: 700,
82 button: { 82 button: {
83 // 24px line height for 14px button text to fix browser rounding errors. 83 fontWeight: 600,
84 lineHeight: 1.714286, 84 fontVariationSettings: '"wdth" 87.5',
85 fontSize: '1rem',
86 lineHeight: 1.5,
85 }, 87 },
86 editor: { 88 editor: {
87 fontFamily: 89 fontFamily:
diff --git a/subprojects/frontend/vite.config.ts b/subprojects/frontend/vite.config.ts
index 5bda8071..82e432de 100644
--- a/subprojects/frontend/vite.config.ts
+++ b/subprojects/frontend/vite.config.ts
@@ -30,7 +30,7 @@ const { mode, isDevelopment, devModePlugins, serverOptions } =
30process.env['NODE_ENV'] ??= mode; 30process.env['NODE_ENV'] ??= mode;
31 31
32const fontsGlob = [ 32const fontsGlob = [
33 'inter-latin-wght-normal-*.woff2', 33 'open-sans-latin-wdth-normal-*.woff2',
34 'jetbrains-mono-latin-wght-{normal,italic}-*.woff2', 34 'jetbrains-mono-latin-wght-{normal,italic}-*.woff2',
35]; 35];
36 36