aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2023-08-24 00:06:37 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2023-08-24 01:17:45 +0200
commit2e2ebbf75b12784ac664d864865f01729b3eb8c4 (patch)
tree6002405f0759015c57d161775c94b9e0df138872
parentrefactor(web): move d3-zoom patch into repo (diff)
downloadrefinery-2e2ebbf75b12784ac664d864865f01729b3eb8c4.tar.gz
refinery-2e2ebbf75b12784ac664d864865f01729b3eb8c4.tar.zst
refinery-2e2ebbf75b12784ac664d864865f01729b3eb8c4.zip
refactor(web): clean up graphviz visualization
-rw-r--r--.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch.license14
-rw-r--r--.yarn/patches/d3-zoom-npm-3.0.0-18f706a421.patch.license11
-rw-r--r--LICENSES/BSD-3-Clause.txt11
-rw-r--r--LICENSES/ISC.txt8
-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
-rw-r--r--yarn.lock24
20 files changed, 659 insertions, 401 deletions
diff --git a/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch.license b/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch.license
new file mode 100644
index 00000000..0c7bddfb
--- /dev/null
+++ b/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch.license
@@ -0,0 +1,14 @@
1Copyright 2017, Magnus Jacobsson
2Copyright 2023, The Refinery Authors <https://refinery.tools/>
3
4SPDX-License-Identifier: BSD-3-Clause
5
6This file Incorporates patches from the Refinery authors.
7
8However, but redistribution and use is only permitted if neither
9the name of the copyright holder Magnus Jacobsson nor the names of other
10contributors to the d3-graphviz project are used to endorse or promote
11products derived from this software as per the 3rd clause of the
123-clause BSD license.
13
14See LICENSES/BSD-3-Clause.txt for more details.
diff --git a/.yarn/patches/d3-zoom-npm-3.0.0-18f706a421.patch.license b/.yarn/patches/d3-zoom-npm-3.0.0-18f706a421.patch.license
new file mode 100644
index 00000000..d95053c1
--- /dev/null
+++ b/.yarn/patches/d3-zoom-npm-3.0.0-18f706a421.patch.license
@@ -0,0 +1,11 @@
1Copyright 2010-2021 Mike Bostock
2Copyright 2020-2023 Philippe Rivière
3Copyright 2023 The Refinery Authors <https://refinery.tools/>
4
5SPDX-License-Identifier: ISC OR EPL-2.0
6
7This file ncorporates patches from
8https://github.com/d3/d3-zoom/tree/3afbe2ae2dfb3129231c5567db56dafb2d6a56a6
9by Philippe Rivière.
10
11Morevoer, it includes other modifications by the Refinery authors.
diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt
new file mode 100644
index 00000000..ea890afb
--- /dev/null
+++ b/LICENSES/BSD-3-Clause.txt
@@ -0,0 +1,11 @@
1Copyright (c) <year> <owner>.
2
3Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
51. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
72. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
93. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10
11THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/LICENSES/ISC.txt b/LICENSES/ISC.txt
new file mode 100644
index 00000000..b9c199c9
--- /dev/null
+++ b/LICENSES/ISC.txt
@@ -0,0 +1,8 @@
1ISC License:
2
3Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC")
4Copyright (c) 1995-2003 by Internet Software Consortium
5
6Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
7
8THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
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
diff --git a/yarn.lock b/yarn.lock
index ca10e7c7..59835487 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1708,13 +1708,6 @@ __metadata:
1708 languageName: node 1708 languageName: node
1709 linkType: hard 1709 linkType: hard
1710 1710
1711"@fontsource-variable/inter@npm:^5.0.8":
1712 version: 5.0.8
1713 resolution: "@fontsource-variable/inter@npm:5.0.8"
1714 checksum: 74175d6051584d7b50410eefc5d454de5ff96d809aeaf6ae46ff4c7f2b04f2d412025ac241cd7a082f93d7456b644e324eeb7089418bd9e39800d92e33ac3225
1715 languageName: node
1716 linkType: hard
1717
1718"@fontsource-variable/jetbrains-mono@npm:^5.0.9": 1711"@fontsource-variable/jetbrains-mono@npm:^5.0.9":
1719 version: 5.0.9 1712 version: 5.0.9
1720 resolution: "@fontsource-variable/jetbrains-mono@npm:5.0.9" 1713 resolution: "@fontsource-variable/jetbrains-mono@npm:5.0.9"
@@ -1722,6 +1715,13 @@ __metadata:
1722 languageName: node 1715 languageName: node
1723 linkType: hard 1716 linkType: hard
1724 1717
1718"@fontsource-variable/open-sans@npm:^5.0.9":
1719 version: 5.0.9
1720 resolution: "@fontsource-variable/open-sans@npm:5.0.9"
1721 checksum: 320f4dfa3ed58e42d3f3b895fd620d8d4f1eff0226b05273a18edf50dcb3617ceb6bc00827db72c83904c665bde163130a9374b3756706e775ecefb34d22d89c
1722 languageName: node
1723 linkType: hard
1724
1725"@hpcc-js/wasm@npm:2.13.1, @hpcc-js/wasm@npm:^2.13.1": 1725"@hpcc-js/wasm@npm:2.13.1, @hpcc-js/wasm@npm:^2.13.1":
1726 version: 2.13.1 1726 version: 2.13.1
1727 resolution: "@hpcc-js/wasm@npm:2.13.1" 1727 resolution: "@hpcc-js/wasm@npm:2.13.1"
@@ -2117,8 +2117,8 @@ __metadata:
2117 "@codemirror/view": "npm:^6.16.0" 2117 "@codemirror/view": "npm:^6.16.0"
2118 "@emotion/react": "npm:^11.11.1" 2118 "@emotion/react": "npm:^11.11.1"
2119 "@emotion/styled": "npm:^11.11.0" 2119 "@emotion/styled": "npm:^11.11.0"
2120 "@fontsource-variable/inter": "npm:^5.0.8"
2121 "@fontsource-variable/jetbrains-mono": "npm:^5.0.9" 2120 "@fontsource-variable/jetbrains-mono": "npm:^5.0.9"
2121 "@fontsource-variable/open-sans": "npm:^5.0.9"
2122 "@hpcc-js/wasm": "npm:^2.13.1" 2122 "@hpcc-js/wasm": "npm:^2.13.1"
2123 "@lezer/common": "npm:^1.0.3" 2123 "@lezer/common": "npm:^1.0.3"
2124 "@lezer/generator": "npm:^1.4.0" 2124 "@lezer/generator": "npm:^1.4.0"
@@ -2165,7 +2165,6 @@ __metadata:
2165 eslint-plugin-react: "npm:^7.33.1" 2165 eslint-plugin-react: "npm:^7.33.1"
2166 eslint-plugin-react-hooks: "npm:^4.6.0" 2166 eslint-plugin-react-hooks: "npm:^4.6.0"
2167 html-minifier-terser: "npm:^7.2.0" 2167 html-minifier-terser: "npm:^7.2.0"
2168 json-stringify-pretty-compact: "npm:^4.0.0"
2169 lodash-es: "npm:^4.17.21" 2168 lodash-es: "npm:^4.17.21"
2170 loglevel: "npm:^1.8.1" 2169 loglevel: "npm:^1.8.1"
2171 loglevel-plugin-prefix: "npm:^0.8.4" 2170 loglevel-plugin-prefix: "npm:^0.8.4"
@@ -5977,13 +5976,6 @@ __metadata:
5977 languageName: node 5976 languageName: node
5978 linkType: hard 5977 linkType: hard
5979 5978
5980"json-stringify-pretty-compact@npm:^4.0.0":
5981 version: 4.0.0
5982 resolution: "json-stringify-pretty-compact@npm:4.0.0"
5983 checksum: 505781b4be7c72047ae8dfa667b520d20461ceac451b6516cb8ac5e12a758fbd7491d99d5e3f7e60423ce9d26ed4e4bcaccab3420bf651298901635c849017cf
5984 languageName: node
5985 linkType: hard
5986
5987"json5@npm:^1.0.2": 5979"json5@npm:^1.0.2":
5988 version: 1.0.2 5980 version: 1.0.2
5989 resolution: "json5@npm:1.0.2" 5981 resolution: "json5@npm:1.0.2"