diff options
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 @@ | |||
1 | Copyright 2017, Magnus Jacobsson | ||
2 | Copyright 2023, The Refinery Authors <https://refinery.tools/> | ||
3 | |||
4 | SPDX-License-Identifier: BSD-3-Clause | ||
5 | |||
6 | This file Incorporates patches from the Refinery authors. | ||
7 | |||
8 | However, but redistribution and use is only permitted if neither | ||
9 | the name of the copyright holder Magnus Jacobsson nor the names of other | ||
10 | contributors to the d3-graphviz project are used to endorse or promote | ||
11 | products derived from this software as per the 3rd clause of the | ||
12 | 3-clause BSD license. | ||
13 | |||
14 | See 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 @@ | |||
1 | Copyright 2010-2021 Mike Bostock | ||
2 | Copyright 2020-2023 Philippe Rivière | ||
3 | Copyright 2023 The Refinery Authors <https://refinery.tools/> | ||
4 | |||
5 | SPDX-License-Identifier: ISC OR EPL-2.0 | ||
6 | |||
7 | This file ncorporates patches from | ||
8 | https://github.com/d3/d3-zoom/tree/3afbe2ae2dfb3129231c5567db56dafb2d6a56a6 | ||
9 | by Philippe Rivière. | ||
10 | |||
11 | Morevoer, 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 @@ | |||
1 | Copyright (c) <year> <owner>. | ||
2 | |||
3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: | ||
4 | |||
5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. | ||
6 | |||
7 | 2. 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 | |||
9 | 3. 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 | |||
11 | THIS 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 @@ | |||
1 | ISC License: | ||
2 | |||
3 | Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC") | ||
4 | Copyright (c) 1995-2003 by Internet Software Consortium | ||
5 | |||
6 | Permission 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 | |||
8 | THE 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 | |||
1 | import { readFile } from 'node:fs/promises'; | 7 | import { readFile } from 'node:fs/promises'; |
2 | import path from 'node:path'; | 8 | import 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 | |||
7 | import * as d3 from 'd3'; | ||
8 | import { type Graphviz, graphviz } from 'd3-graphviz'; | ||
9 | import type { BaseType, Selection } from 'd3-selection'; | ||
10 | import { reaction, type IReactionDisposer } from 'mobx'; | ||
11 | import { useCallback, useRef } from 'react'; | ||
12 | |||
13 | import { useRootStore } from '../RootStoreProvider'; | ||
14 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
15 | |||
16 | import GraphTheme from './GraphTheme'; | ||
17 | import postProcessSvg from './postProcessSVG'; | ||
18 | |||
19 | function toGraphviz( | ||
20 | semantics: SemanticsSuccessResult | undefined, | ||
21 | ): string | undefined { | ||
22 | if (semantics === undefined) { | ||
23 | return undefined; | ||
24 | } | ||
25 | const lines = [ | ||
26 | 'digraph {', | ||
27 | 'graph [bgcolor=transparent];', | ||
28 | `node [fontsize=12, shape=plain, fontname="OpenSans"];`, | ||
29 | 'edge [fontsize=10.5, color=black, fontname="OpenSans"];', | ||
30 | ]; | ||
31 | const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`); | ||
32 | lines.push( | ||
33 | ...nodeIds.map( | ||
34 | (id, i) => | ||
35 | `n${i} [id="${id}", label=<<table border="1" cellborder="0" cellspacing="0" cellpadding="4.5" style="rounded" bgcolor="green"><tr><td>${id}</td></tr><hr/><tr><td bgcolor="white">node</td></tr></table>>];`, | ||
36 | ), | ||
37 | ); | ||
38 | Object.keys(semantics.partialInterpretation).forEach((relation) => { | ||
39 | if (relation === 'builtin::equals' || relation === 'builtin::contains') { | ||
40 | return; | ||
41 | } | ||
42 | const tuples = semantics.partialInterpretation[relation]; | ||
43 | if (tuples === undefined) { | ||
44 | return; | ||
45 | } | ||
46 | const first = tuples[0]; | ||
47 | if (first === undefined || first.length !== 3) { | ||
48 | return; | ||
49 | } | ||
50 | const nameFragments = relation.split('::'); | ||
51 | const simpleName = nameFragments[nameFragments.length - 1] ?? relation; | ||
52 | lines.push( | ||
53 | ...tuples.map(([from, to, value]) => { | ||
54 | if ( | ||
55 | typeof from !== 'number' || | ||
56 | typeof to !== 'number' || | ||
57 | typeof value !== 'string' | ||
58 | ) { | ||
59 | return ''; | ||
60 | } | ||
61 | const isUnknown = value === 'UNKNOWN'; | ||
62 | return `n${from} -> n${to} [ | ||
63 | id="${nodeIds[from]},${nodeIds[to]},${relation}", | ||
64 | xlabel="${simpleName}", | ||
65 | style="${isUnknown ? 'dashed' : 'solid'}", | ||
66 | class="edge-${value}" | ||
67 | ];`; | ||
68 | }), | ||
69 | ); | ||
70 | }); | ||
71 | lines.push('}'); | ||
72 | return lines.join('\n'); | ||
73 | } | ||
74 | |||
75 | export default function DotGraphVisualizer(): JSX.Element { | ||
76 | const { editorStore } = useRootStore(); | ||
77 | const disposerRef = useRef<IReactionDisposer | undefined>(); | ||
78 | const graphvizRef = useRef< | ||
79 | Graphviz<BaseType, unknown, null, undefined> | undefined | ||
80 | >(); | ||
81 | |||
82 | const setElement = useCallback( | ||
83 | (element: HTMLDivElement | null) => { | ||
84 | if (disposerRef.current !== undefined) { | ||
85 | disposerRef.current(); | ||
86 | disposerRef.current = undefined; | ||
87 | } | ||
88 | if (graphvizRef.current !== undefined) { | ||
89 | // `@types/d3-graphviz` does not contain the signature for the `destroy` method. | ||
90 | (graphvizRef.current as unknown as { destroy(): void }).destroy(); | ||
91 | graphvizRef.current = undefined; | ||
92 | } | ||
93 | if (element !== null) { | ||
94 | element.replaceChildren(); | ||
95 | const renderer = graphviz(element) as Graphviz< | ||
96 | BaseType, | ||
97 | unknown, | ||
98 | null, | ||
99 | undefined | ||
100 | >; | ||
101 | renderer.keyMode('id'); | ||
102 | renderer.zoom(false); | ||
103 | renderer.tweenPrecision('5%'); | ||
104 | renderer.tweenShapes(false); | ||
105 | renderer.convertEqualSidedPolygons(false); | ||
106 | const transition = () => | ||
107 | d3.transition().duration(300).ease(d3.easeCubic); | ||
108 | /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument, | ||
109 | @typescript-eslint/no-explicit-any -- | ||
110 | Workaround for error in `@types/d3-graphviz`. | ||
111 | */ | ||
112 | renderer.transition(transition as any); | ||
113 | renderer.on( | ||
114 | 'postProcessSVG', | ||
115 | // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. | ||
116 | ( | ||
117 | svgSelection: Selection<SVGSVGElement, unknown, BaseType, unknown>, | ||
118 | ) => { | ||
119 | const svg = svgSelection.node(); | ||
120 | if (svg !== null) { | ||
121 | postProcessSvg(svg); | ||
122 | } | ||
123 | }, | ||
124 | ); | ||
125 | disposerRef.current = reaction( | ||
126 | () => editorStore?.semantics, | ||
127 | (semantics) => { | ||
128 | const str = toGraphviz(semantics); | ||
129 | if (str !== undefined) { | ||
130 | renderer.renderDot(str); | ||
131 | } | ||
132 | }, | ||
133 | { fireImmediately: true }, | ||
134 | ); | ||
135 | graphvizRef.current = renderer; | ||
136 | } | ||
137 | }, | ||
138 | [editorStore], | ||
139 | ); | ||
140 | |||
141 | return <GraphTheme ref={setElement} />; | ||
142 | } | ||
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx index 6ca3bc87..32147d92 100644 --- a/subprojects/frontend/src/graph/GraphArea.tsx +++ b/subprojects/frontend/src/graph/GraphArea.tsx | |||
@@ -1,384 +1,16 @@ | |||
1 | /* | 1 | /* |
2 | * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/> | 2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> |
3 | * | 3 | * |
4 | * SPDX-License-Identifier: EPL-2.0 | 4 | * SPDX-License-Identifier: EPL-2.0 |
5 | */ | 5 | */ |
6 | 6 | ||
7 | import AddIcon from '@mui/icons-material/Add'; | 7 | import DotGraphVisualizer from './DotGraphVisualizer'; |
8 | import CropFreeIcon from '@mui/icons-material/CropFree'; | 8 | import ZoomCanvas from './ZoomCanvas'; |
9 | import RemoveIcon from '@mui/icons-material/Remove'; | ||
10 | import Box from '@mui/material/Box'; | ||
11 | import IconButton from '@mui/material/IconButton'; | ||
12 | import Stack from '@mui/material/Stack'; | ||
13 | import { useTheme } from '@mui/material/styles'; | ||
14 | import { CSSProperties } from '@mui/material/styles/createTypography'; | ||
15 | import * as d3 from 'd3'; | ||
16 | import { type Graphviz, graphviz } from 'd3-graphviz'; | ||
17 | import type { BaseType, Selection } from 'd3-selection'; | ||
18 | import { zoom as d3Zoom } from 'd3-zoom'; | ||
19 | import { reaction, type IReactionDisposer } from 'mobx'; | ||
20 | import { useCallback, useRef, useState } from 'react'; | ||
21 | |||
22 | import { useRootStore } from '../RootStoreProvider'; | ||
23 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
24 | |||
25 | function toGraphviz( | ||
26 | semantics: SemanticsSuccessResult | undefined, | ||
27 | ): string | undefined { | ||
28 | if (semantics === undefined) { | ||
29 | return undefined; | ||
30 | } | ||
31 | const lines = [ | ||
32 | 'digraph {', | ||
33 | 'graph [bgcolor=transparent];', | ||
34 | 'node [fontsize=16, shape=plain];', | ||
35 | 'edge [fontsize=12, color=black];', | ||
36 | ]; | ||
37 | const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`); | ||
38 | lines.push( | ||
39 | ...nodeIds.map( | ||
40 | (id, i) => | ||
41 | `n${i} [id="${id}", label=<<table border="1" cellborder="0" cellspacing="0" cellpadding="4" style="rounded" bgcolor="green"><tr><td>${id}</td></tr><hr/><tr><td bgcolor="white">node</td></tr></table>>];`, | ||
42 | ), | ||
43 | ); | ||
44 | Object.keys(semantics.partialInterpretation).forEach((relation) => { | ||
45 | if (relation === 'builtin::equals' || relation === 'builtin::contains') { | ||
46 | return; | ||
47 | } | ||
48 | const tuples = semantics.partialInterpretation[relation]; | ||
49 | if (tuples === undefined) { | ||
50 | return; | ||
51 | } | ||
52 | const first = tuples[0]; | ||
53 | if (first === undefined || first.length !== 3) { | ||
54 | return; | ||
55 | } | ||
56 | const nameFragments = relation.split('::'); | ||
57 | const simpleName = nameFragments[nameFragments.length - 1] ?? relation; | ||
58 | lines.push( | ||
59 | ...tuples.map(([from, to, value]) => { | ||
60 | if ( | ||
61 | typeof from !== 'number' || | ||
62 | typeof to !== 'number' || | ||
63 | typeof value !== 'string' | ||
64 | ) { | ||
65 | return ''; | ||
66 | } | ||
67 | const isUnknown = value === 'UNKNOWN'; | ||
68 | return `n${from} -> n${to} [ | ||
69 | id="${nodeIds[from]},${nodeIds[to]},${relation}", | ||
70 | xlabel="${simpleName}", | ||
71 | style="${isUnknown ? 'dashed' : 'solid'}", | ||
72 | class="edge-${value}" | ||
73 | ];`; | ||
74 | }), | ||
75 | ); | ||
76 | }); | ||
77 | lines.push('}'); | ||
78 | return lines.join('\n'); | ||
79 | } | ||
80 | |||
81 | interface Transform { | ||
82 | x: number; | ||
83 | y: number; | ||
84 | k: number; | ||
85 | } | ||
86 | 9 | ||
87 | export default function GraphArea(): JSX.Element { | 10 | export default function GraphArea(): JSX.Element { |
88 | const { editorStore } = useRootStore(); | ||
89 | const theme = useTheme(); | ||
90 | const disposerRef = useRef<IReactionDisposer | undefined>(); | ||
91 | const graphvizRef = useRef< | ||
92 | Graphviz<BaseType, unknown, null, undefined> | undefined | ||
93 | >(); | ||
94 | const canvasRef = useRef<HTMLDivElement | undefined>(); | ||
95 | const elementRef = useRef<HTMLDivElement | undefined>(); | ||
96 | const zoomRef = useRef< | ||
97 | d3.ZoomBehavior<HTMLDivElement, unknown> | undefined | ||
98 | >(); | ||
99 | const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 }); | ||
100 | |||
101 | const setCanvas = useCallback((element: HTMLDivElement | null) => { | ||
102 | canvasRef.current = element ?? undefined; | ||
103 | if (element === null) { | ||
104 | return; | ||
105 | } | ||
106 | const zoomBehavior = d3Zoom<HTMLDivElement, unknown>(); | ||
107 | // `@types/d3-zoom` does not contain the `center` function, because it is | ||
108 | // only available as a pull request for `d3-zoom`. | ||
109 | ( | ||
110 | zoomBehavior as unknown as { | ||
111 | center(callback: (event: MouseEvent) => [number, number]): unknown; | ||
112 | } | ||
113 | ).center((event: MouseEvent | Touch) => { | ||
114 | const { width, height } = element.getBoundingClientRect(); | ||
115 | const [x, y] = d3.pointer(event, element); | ||
116 | return [x - width / 2, y - height / 2]; | ||
117 | }); | ||
118 | // Custom `centroid` method added via patch. | ||
119 | ( | ||
120 | zoomBehavior as unknown as { | ||
121 | centroid(centroid: [number, number]): unknown; | ||
122 | } | ||
123 | ).centroid([0, 0]); | ||
124 | zoomBehavior.on('zoom', (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => | ||
125 | setZoom(event.transform), | ||
126 | ); | ||
127 | d3.select(element).call(zoomBehavior); | ||
128 | zoomRef.current = zoomBehavior; | ||
129 | }, []); | ||
130 | |||
131 | const setElement = useCallback( | ||
132 | (element: HTMLDivElement | null) => { | ||
133 | elementRef.current = element ?? undefined; | ||
134 | if (disposerRef.current !== undefined) { | ||
135 | disposerRef.current(); | ||
136 | disposerRef.current = undefined; | ||
137 | } | ||
138 | if (graphvizRef.current !== undefined) { | ||
139 | // `@types/d3-graphviz` does not contain the signature for the `destroy` method. | ||
140 | (graphvizRef.current as unknown as { destroy(): void }).destroy(); | ||
141 | graphvizRef.current = undefined; | ||
142 | } | ||
143 | if (element !== null) { | ||
144 | element.replaceChildren(); | ||
145 | const renderer = graphviz(element) as Graphviz< | ||
146 | BaseType, | ||
147 | unknown, | ||
148 | null, | ||
149 | undefined | ||
150 | >; | ||
151 | renderer.keyMode('id'); | ||
152 | renderer.zoom(false); | ||
153 | renderer.tweenPrecision('5%'); | ||
154 | renderer.tweenShapes(false); | ||
155 | renderer.convertEqualSidedPolygons(false); | ||
156 | const transition = () => | ||
157 | d3.transition().duration(300).ease(d3.easeCubic); | ||
158 | /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument, | ||
159 | @typescript-eslint/no-explicit-any -- | ||
160 | Workaround for error in `@types/d3-graphviz`. | ||
161 | */ | ||
162 | renderer.transition(transition as any); | ||
163 | renderer.on( | ||
164 | 'postProcessSVG', | ||
165 | // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. | ||
166 | ( | ||
167 | svgSelection: Selection<SVGSVGElement, unknown, BaseType, unknown>, | ||
168 | ) => { | ||
169 | svgSelection.selectAll('title').remove(); | ||
170 | const svg = svgSelection.node(); | ||
171 | if (svg === null) { | ||
172 | return; | ||
173 | } | ||
174 | svg.querySelectorAll('.node').forEach((node) => { | ||
175 | node.querySelectorAll('path').forEach((path) => { | ||
176 | const d = path.getAttribute('d') ?? ''; | ||
177 | const points = d.split(/[A-Z ]/); | ||
178 | points.shift(); | ||
179 | const x = points.map((p) => { | ||
180 | return Number(p.split(',')[0] ?? 0); | ||
181 | }); | ||
182 | const y = points.map((p) => { | ||
183 | return Number(p.split(',')[1] ?? 0); | ||
184 | }); | ||
185 | const xmin = Math.min.apply(null, x); | ||
186 | const xmax = Math.max.apply(null, x); | ||
187 | const ymin = Math.min.apply(null, y); | ||
188 | const ymax = Math.max.apply(null, y); | ||
189 | const rect = document.createElementNS( | ||
190 | 'http://www.w3.org/2000/svg', | ||
191 | 'rect', | ||
192 | ); | ||
193 | rect.setAttribute('fill', path.getAttribute('fill') ?? ''); | ||
194 | rect.setAttribute('stroke', path.getAttribute('stroke') ?? ''); | ||
195 | rect.setAttribute('x', String(xmin)); | ||
196 | rect.setAttribute('y', String(ymin)); | ||
197 | rect.setAttribute('width', String(xmax - xmin)); | ||
198 | rect.setAttribute('height', String(ymax - ymin)); | ||
199 | rect.setAttribute('height', String(ymax - ymin)); | ||
200 | rect.setAttribute('rx', '8'); | ||
201 | rect.setAttribute('ry', '8'); | ||
202 | node.replaceChild(rect, path); | ||
203 | }); | ||
204 | }); | ||
205 | }, | ||
206 | ); | ||
207 | disposerRef.current = reaction( | ||
208 | () => editorStore?.semantics, | ||
209 | (semantics) => { | ||
210 | const str = toGraphviz(semantics); | ||
211 | if (str !== undefined) { | ||
212 | renderer.renderDot(str); | ||
213 | } | ||
214 | }, | ||
215 | { fireImmediately: true }, | ||
216 | ); | ||
217 | graphvizRef.current = renderer; | ||
218 | } | ||
219 | }, | ||
220 | [editorStore], | ||
221 | ); | ||
222 | |||
223 | const changeZoom = useCallback((event: React.MouseEvent, factor: number) => { | ||
224 | if (canvasRef.current === undefined || zoomRef.current === undefined) { | ||
225 | return; | ||
226 | } | ||
227 | const selection = d3.select(canvasRef.current); | ||
228 | const zoomTransition = selection.transition().duration(250); | ||
229 | const center: [number, number] = [0, 0]; | ||
230 | zoomRef.current.scaleBy(zoomTransition, factor, center); | ||
231 | event.preventDefault(); | ||
232 | event.stopPropagation(); | ||
233 | }, []); | ||
234 | |||
235 | const fitZoom = useCallback((event: React.MouseEvent) => { | ||
236 | if ( | ||
237 | canvasRef.current === undefined || | ||
238 | zoomRef.current === undefined || | ||
239 | elementRef.current === undefined | ||
240 | ) { | ||
241 | return; | ||
242 | } | ||
243 | const { width: canvasWidth, height: canvasHeight } = | ||
244 | canvasRef.current.getBoundingClientRect(); | ||
245 | const { width: scaledWidth, height: scaledHeight } = | ||
246 | elementRef.current.getBoundingClientRect(); | ||
247 | const currentFactor = d3.zoomTransform(canvasRef.current).k; | ||
248 | const width = scaledWidth / currentFactor; | ||
249 | const height = scaledHeight / currentFactor; | ||
250 | if (width > 0 && height > 0) { | ||
251 | const factor = Math.min( | ||
252 | 1.0, | ||
253 | (canvasWidth - 64) / width, | ||
254 | (canvasHeight - 64) / height, | ||
255 | ); | ||
256 | const selection = d3.select(canvasRef.current); | ||
257 | const zoomTransition = selection.transition().duration(250); | ||
258 | zoomRef.current.transform(zoomTransition, d3.zoomIdentity.scale(factor)); | ||
259 | } | ||
260 | event.preventDefault(); | ||
261 | event.stopPropagation(); | ||
262 | }, []); | ||
263 | |||
264 | return ( | 11 | return ( |
265 | <Box | 12 | <ZoomCanvas> |
266 | sx={{ | 13 | <DotGraphVisualizer /> |
267 | width: '100%', | 14 | </ZoomCanvas> |
268 | height: '100%', | ||
269 | position: 'relative', | ||
270 | overflow: 'hidden', | ||
271 | }} | ||
272 | > | ||
273 | <Box | ||
274 | sx={{ | ||
275 | position: 'absolute', | ||
276 | overflow: 'hidden', | ||
277 | top: 0, | ||
278 | left: 0, | ||
279 | right: 0, | ||
280 | bottom: 0, | ||
281 | }} | ||
282 | ref={setCanvas} | ||
283 | > | ||
284 | <Box | ||
285 | sx={{ | ||
286 | position: 'absolute', | ||
287 | top: '50%', | ||
288 | left: '50%', | ||
289 | transform: ` | ||
290 | translate(${zoom.x}px, ${zoom.y}px) | ||
291 | scale(${zoom.k}) | ||
292 | translate(-50%, -50%) | ||
293 | `, | ||
294 | transformOrigin: '0 0', | ||
295 | '& svg': { | ||
296 | userSelect: 'none', | ||
297 | '& .node': { | ||
298 | '& text': { | ||
299 | ...(theme.typography.body2 as Omit< | ||
300 | CSSProperties, | ||
301 | '@font-face' | ||
302 | >), | ||
303 | fill: theme.palette.text.primary, | ||
304 | }, | ||
305 | '& [stroke="black"]': { | ||
306 | stroke: theme.palette.text.primary, | ||
307 | }, | ||
308 | '& [fill="green"]': { | ||
309 | fill: | ||
310 | theme.palette.mode === 'dark' | ||
311 | ? theme.palette.primary.dark | ||
312 | : theme.palette.primary.light, | ||
313 | }, | ||
314 | '& [fill="white"]': { | ||
315 | fill: theme.palette.background.default, | ||
316 | stroke: theme.palette.background.default, | ||
317 | }, | ||
318 | }, | ||
319 | '& .edge': { | ||
320 | '& text': { | ||
321 | ...(theme.typography.caption as Omit< | ||
322 | CSSProperties, | ||
323 | '@font-face' | ||
324 | >), | ||
325 | fill: theme.palette.text.primary, | ||
326 | }, | ||
327 | '& [stroke="black"]': { | ||
328 | stroke: theme.palette.text.primary, | ||
329 | }, | ||
330 | '& [fill="black"]': { | ||
331 | fill: theme.palette.text.primary, | ||
332 | }, | ||
333 | }, | ||
334 | '& .edge-UNKNOWN': { | ||
335 | '& text': { | ||
336 | fill: theme.palette.text.secondary, | ||
337 | }, | ||
338 | '& [stroke="black"]': { | ||
339 | stroke: theme.palette.text.secondary, | ||
340 | }, | ||
341 | '& [fill="black"]': { | ||
342 | fill: theme.palette.text.secondary, | ||
343 | }, | ||
344 | }, | ||
345 | '& .edge-ERROR': { | ||
346 | '& text': { | ||
347 | fill: theme.palette.error.main, | ||
348 | }, | ||
349 | '& [stroke="black"]': { | ||
350 | stroke: theme.palette.error.main, | ||
351 | }, | ||
352 | '& [fill="black"]': { | ||
353 | fill: theme.palette.error.main, | ||
354 | }, | ||
355 | }, | ||
356 | }, | ||
357 | }} | ||
358 | ref={setElement} | ||
359 | /> | ||
360 | </Box> | ||
361 | <Stack | ||
362 | direction="column" | ||
363 | p={1} | ||
364 | sx={{ position: 'absolute', bottom: 0, right: 0 }} | ||
365 | > | ||
366 | <IconButton | ||
367 | aria-label="Zoom in" | ||
368 | onClick={(event) => changeZoom(event, 2)} | ||
369 | > | ||
370 | <AddIcon fontSize="small" /> | ||
371 | </IconButton> | ||
372 | <IconButton | ||
373 | aria-label="Zoom out" | ||
374 | onClick={(event) => changeZoom(event, 0.5)} | ||
375 | > | ||
376 | <RemoveIcon fontSize="small" /> | ||
377 | </IconButton> | ||
378 | <IconButton aria-label="Fit screen" onClick={fitZoom}> | ||
379 | <CropFreeIcon fontSize="small" /> | ||
380 | </IconButton> | ||
381 | </Stack> | ||
382 | </Box> | ||
383 | ); | 15 | ); |
384 | } | 16 | } |
diff --git a/subprojects/frontend/src/graph/GraphPane.tsx b/subprojects/frontend/src/graph/GraphPane.tsx index f04b9931..c2ef8927 100644 --- a/subprojects/frontend/src/graph/GraphPane.tsx +++ b/subprojects/frontend/src/graph/GraphPane.tsx | |||
@@ -1,5 +1,5 @@ | |||
1 | /* | 1 | /* |
2 | * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/> | 2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> |
3 | * | 3 | * |
4 | * SPDX-License-Identifier: EPL-2.0 | 4 | * SPDX-License-Identifier: EPL-2.0 |
5 | */ | 5 | */ |
diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx new file mode 100644 index 00000000..41ba6ba5 --- /dev/null +++ b/subprojects/frontend/src/graph/GraphTheme.tsx | |||
@@ -0,0 +1,64 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import { styled, type CSSObject } from '@mui/material/styles'; | ||
8 | |||
9 | function createEdgeColor(suffix: string, color: string): CSSObject { | ||
10 | return { | ||
11 | [`& .edge-${suffix}`]: { | ||
12 | '& text': { | ||
13 | fill: color, | ||
14 | }, | ||
15 | '& [stroke="black"]': { | ||
16 | stroke: color, | ||
17 | }, | ||
18 | '& [fill="black"]': { | ||
19 | fill: color, | ||
20 | }, | ||
21 | }, | ||
22 | }; | ||
23 | } | ||
24 | |||
25 | export default styled('div', { | ||
26 | name: 'GraphTheme', | ||
27 | })(({ theme }) => ({ | ||
28 | '& svg': { | ||
29 | userSelect: 'none', | ||
30 | '& .node': { | ||
31 | '& text': { | ||
32 | fontFamily: theme.typography.fontFamily, | ||
33 | fill: theme.palette.text.primary, | ||
34 | }, | ||
35 | '& [stroke="black"]': { | ||
36 | stroke: theme.palette.text.primary, | ||
37 | }, | ||
38 | '& [fill="green"]': { | ||
39 | fill: | ||
40 | theme.palette.mode === 'dark' | ||
41 | ? theme.palette.primary.dark | ||
42 | : theme.palette.primary.light, | ||
43 | }, | ||
44 | '& [fill="white"]': { | ||
45 | fill: theme.palette.background.default, | ||
46 | stroke: theme.palette.background.default, | ||
47 | }, | ||
48 | }, | ||
49 | '& .edge': { | ||
50 | '& text': { | ||
51 | fontFamily: theme.typography.fontFamily, | ||
52 | fill: theme.palette.text.primary, | ||
53 | }, | ||
54 | '& [stroke="black"]': { | ||
55 | stroke: theme.palette.text.primary, | ||
56 | }, | ||
57 | '& [fill="black"]': { | ||
58 | fill: theme.palette.text.primary, | ||
59 | }, | ||
60 | }, | ||
61 | ...createEdgeColor('UNKNOWN', theme.palette.text.secondary), | ||
62 | ...createEdgeColor('ERROR', theme.palette.error.main), | ||
63 | }, | ||
64 | })); | ||
diff --git a/subprojects/frontend/src/graph/ZoomButtons.tsx b/subprojects/frontend/src/graph/ZoomButtons.tsx new file mode 100644 index 00000000..72f54774 --- /dev/null +++ b/subprojects/frontend/src/graph/ZoomButtons.tsx | |||
@@ -0,0 +1,43 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import AddIcon from '@mui/icons-material/Add'; | ||
8 | import CropFreeIcon from '@mui/icons-material/CropFree'; | ||
9 | import RemoveIcon from '@mui/icons-material/Remove'; | ||
10 | import IconButton from '@mui/material/IconButton'; | ||
11 | import Stack from '@mui/material/Stack'; | ||
12 | |||
13 | export default function ZoomButtons({ | ||
14 | changeZoom, | ||
15 | fitZoom, | ||
16 | }: { | ||
17 | changeZoom: (event: React.MouseEvent, factor: number) => void; | ||
18 | fitZoom: (event: React.MouseEvent) => void; | ||
19 | }): JSX.Element { | ||
20 | return ( | ||
21 | <Stack | ||
22 | direction="column" | ||
23 | p={1} | ||
24 | sx={{ position: 'absolute', bottom: 0, right: 0 }} | ||
25 | > | ||
26 | <IconButton | ||
27 | aria-label="Zoom in" | ||
28 | onClick={(event) => changeZoom(event, 2)} | ||
29 | > | ||
30 | <AddIcon fontSize="small" /> | ||
31 | </IconButton> | ||
32 | <IconButton | ||
33 | aria-label="Zoom out" | ||
34 | onClick={(event) => changeZoom(event, 0.5)} | ||
35 | > | ||
36 | <RemoveIcon fontSize="small" /> | ||
37 | </IconButton> | ||
38 | <IconButton aria-label="Fit screen" onClick={fitZoom}> | ||
39 | <CropFreeIcon fontSize="small" /> | ||
40 | </IconButton> | ||
41 | </Stack> | ||
42 | ); | ||
43 | } | ||
diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx new file mode 100644 index 00000000..eb3e9285 --- /dev/null +++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx | |||
@@ -0,0 +1,177 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import Box from '@mui/material/Box'; | ||
8 | import * as d3 from 'd3'; | ||
9 | import { zoom as d3Zoom } from 'd3-zoom'; | ||
10 | import React, { useCallback, useRef, useState } from 'react'; | ||
11 | |||
12 | import ZoomButtons from './ZoomButtons'; | ||
13 | |||
14 | declare module 'd3-zoom' { | ||
15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Redeclaring type parameters. | ||
16 | interface ZoomBehavior<ZoomRefElement extends Element, Datum> { | ||
17 | // `@types/d3-zoom` does not contain the `center` function, because it is | ||
18 | // only available as a pull request for `d3-zoom`. | ||
19 | center(callback: (event: MouseEvent | Touch) => [number, number]): this; | ||
20 | |||
21 | // Custom `centroid` method added via patch. | ||
22 | centroid(centroid: [number, number]): this; | ||
23 | } | ||
24 | } | ||
25 | |||
26 | interface Transform { | ||
27 | x: number; | ||
28 | y: number; | ||
29 | k: number; | ||
30 | } | ||
31 | |||
32 | export default function ZoomCanvas({ | ||
33 | children, | ||
34 | fitPadding, | ||
35 | transitionTime, | ||
36 | }: { | ||
37 | children?: React.ReactNode; | ||
38 | fitPadding?: number; | ||
39 | transitionTime?: number; | ||
40 | }): JSX.Element { | ||
41 | const canvasRef = useRef<HTMLDivElement | undefined>(); | ||
42 | const elementRef = useRef<HTMLDivElement | undefined>(); | ||
43 | const zoomRef = useRef< | ||
44 | d3.ZoomBehavior<HTMLDivElement, unknown> | undefined | ||
45 | >(); | ||
46 | const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding; | ||
47 | const transitionTimeOrDefault = | ||
48 | transitionTime ?? ZoomCanvas.defaultProps.transitionTime; | ||
49 | |||
50 | const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 }); | ||
51 | |||
52 | const setCanvas = useCallback( | ||
53 | (canvas: HTMLDivElement | null) => { | ||
54 | canvasRef.current = canvas ?? undefined; | ||
55 | if (canvas === null) { | ||
56 | return; | ||
57 | } | ||
58 | const zoomBehavior = d3Zoom<HTMLDivElement, unknown>() | ||
59 | .duration(transitionTimeOrDefault) | ||
60 | .center((event) => { | ||
61 | const { width, height } = canvas.getBoundingClientRect(); | ||
62 | const [x, y] = d3.pointer(event, canvas); | ||
63 | return [x - width / 2, y - height / 2]; | ||
64 | }) | ||
65 | .centroid([0, 0]); | ||
66 | zoomBehavior.on( | ||
67 | 'zoom', | ||
68 | (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => | ||
69 | setZoom(event.transform), | ||
70 | ); | ||
71 | d3.select(canvas).call(zoomBehavior); | ||
72 | zoomRef.current = zoomBehavior; | ||
73 | }, | ||
74 | [transitionTimeOrDefault], | ||
75 | ); | ||
76 | |||
77 | const makeTransition = useCallback( | ||
78 | (element: HTMLDivElement) => | ||
79 | d3.select(element).transition().duration(transitionTimeOrDefault), | ||
80 | [transitionTimeOrDefault], | ||
81 | ); | ||
82 | |||
83 | const changeZoom = useCallback( | ||
84 | (event: React.MouseEvent, factor: number) => { | ||
85 | if (canvasRef.current === undefined || zoomRef.current === undefined) { | ||
86 | return; | ||
87 | } | ||
88 | const zoomTransition = makeTransition(canvasRef.current); | ||
89 | const center: [number, number] = [0, 0]; | ||
90 | zoomRef.current.scaleBy(zoomTransition, factor, center); | ||
91 | event.preventDefault(); | ||
92 | event.stopPropagation(); | ||
93 | }, | ||
94 | [makeTransition], | ||
95 | ); | ||
96 | |||
97 | const fitZoom = useCallback( | ||
98 | (event: React.MouseEvent) => { | ||
99 | if ( | ||
100 | canvasRef.current === undefined || | ||
101 | zoomRef.current === undefined || | ||
102 | elementRef.current === undefined | ||
103 | ) { | ||
104 | return; | ||
105 | } | ||
106 | const { width: canvasWidth, height: canvasHeight } = | ||
107 | canvasRef.current.getBoundingClientRect(); | ||
108 | const { width: scaledWidth, height: scaledHeight } = | ||
109 | elementRef.current.getBoundingClientRect(); | ||
110 | const currentFactor = d3.zoomTransform(canvasRef.current).k; | ||
111 | const width = scaledWidth / currentFactor; | ||
112 | const height = scaledHeight / currentFactor; | ||
113 | if (width > 0 && height > 0) { | ||
114 | const factor = Math.min( | ||
115 | 1.0, | ||
116 | (canvasWidth - fitPaddingOrDefault) / width, | ||
117 | (canvasHeight - fitPaddingOrDefault) / height, | ||
118 | ); | ||
119 | const zoomTransition = makeTransition(canvasRef.current); | ||
120 | zoomRef.current.transform( | ||
121 | zoomTransition, | ||
122 | d3.zoomIdentity.scale(factor), | ||
123 | ); | ||
124 | } | ||
125 | event.preventDefault(); | ||
126 | event.stopPropagation(); | ||
127 | }, | ||
128 | [fitPaddingOrDefault, makeTransition], | ||
129 | ); | ||
130 | |||
131 | return ( | ||
132 | <Box | ||
133 | sx={{ | ||
134 | width: '100%', | ||
135 | height: '100%', | ||
136 | position: 'relative', | ||
137 | overflow: 'hidden', | ||
138 | }} | ||
139 | > | ||
140 | <Box | ||
141 | sx={{ | ||
142 | position: 'absolute', | ||
143 | overflow: 'hidden', | ||
144 | top: 0, | ||
145 | left: 0, | ||
146 | right: 0, | ||
147 | bottom: 0, | ||
148 | }} | ||
149 | ref={setCanvas} | ||
150 | > | ||
151 | <Box | ||
152 | sx={{ | ||
153 | position: 'absolute', | ||
154 | top: '50%', | ||
155 | left: '50%', | ||
156 | transform: ` | ||
157 | translate(${zoom.x}px, ${zoom.y}px) | ||
158 | scale(${zoom.k}) | ||
159 | translate(-50%, -50%) | ||
160 | `, | ||
161 | transformOrigin: '0 0', | ||
162 | }} | ||
163 | ref={elementRef} | ||
164 | > | ||
165 | {children} | ||
166 | </Box> | ||
167 | </Box> | ||
168 | <ZoomButtons changeZoom={changeZoom} fitZoom={fitZoom} /> | ||
169 | </Box> | ||
170 | ); | ||
171 | } | ||
172 | |||
173 | ZoomCanvas.defaultProps = { | ||
174 | children: undefined, | ||
175 | fitPadding: 64, | ||
176 | transitionTime: 250, | ||
177 | }; | ||
diff --git a/subprojects/frontend/src/graph/parseBBox.ts b/subprojects/frontend/src/graph/parseBBox.ts new file mode 100644 index 00000000..9806cbca --- /dev/null +++ b/subprojects/frontend/src/graph/parseBBox.ts | |||
@@ -0,0 +1,68 @@ | |||
1 | /* | ||
2 | * Copyright 2017, Magnus Jacobsson | ||
3 | * Copyright 2023, The Refinery Authors <https://refinery.tools/> | ||
4 | * | ||
5 | * SPDX-License-Identifier: BSD-3-Clause AND EPL-2.0 | ||
6 | * | ||
7 | * This file Incorporates patches from the Refinery authors. | ||
8 | * | ||
9 | * Redistribution and use is only permitted if neither | ||
10 | * the name of the copyright holder Magnus Jacobsson nor the names of other | ||
11 | * contributors to the d3-graphviz project are used to endorse or promote | ||
12 | * products derived from this software as per the 3rd clause of the | ||
13 | * 3-clause BSD license. | ||
14 | * | ||
15 | * See LICENSES/BSD-3-Clause.txt for more details. | ||
16 | */ | ||
17 | |||
18 | export interface BBox { | ||
19 | x: number; | ||
20 | y: number; | ||
21 | width: number; | ||
22 | height: number; | ||
23 | } | ||
24 | |||
25 | function parsePoints(points: string[]): BBox { | ||
26 | const x = points.map((p) => Number(p.split(',')[0] ?? 0)); | ||
27 | const y = points.map((p) => Number(p.split(',')[1] ?? 0)); | ||
28 | const xmin = Math.min.apply(null, x); | ||
29 | const xmax = Math.max.apply(null, x); | ||
30 | const ymin = Math.min.apply(null, y); | ||
31 | const ymax = Math.max.apply(null, y); | ||
32 | return { | ||
33 | x: xmin, | ||
34 | y: ymin, | ||
35 | width: xmax - xmin, | ||
36 | height: ymax - ymin, | ||
37 | }; | ||
38 | } | ||
39 | |||
40 | /** | ||
41 | * Compute the bounding box of a polygon without adding it to the DOM. | ||
42 | * | ||
43 | * Copyed from | ||
44 | * https://github.com/magjac/d3-graphviz/blob/81ab523fe5189a90da2d9d9cc9015c7079eea780/src/element.js#L36-L53 | ||
45 | * | ||
46 | * @param path The polygon to compute the bounding box of. | ||
47 | * @returns The computed bounding box. | ||
48 | */ | ||
49 | export function parsePolygonBBox(polygon: SVGPolygonElement): BBox { | ||
50 | const points = (polygon.getAttribute('points') ?? '').split(' '); | ||
51 | return parsePoints(points); | ||
52 | } | ||
53 | |||
54 | /** | ||
55 | * Compute the bounding box of a path without adding it to the DOM. | ||
56 | * | ||
57 | * Copyed from | ||
58 | * https://github.com/magjac/d3-graphviz/blob/81ab523fe5189a90da2d9d9cc9015c7079eea780/src/element.js#L56-L75 | ||
59 | * | ||
60 | * @param path The path to compute the bounding box of. | ||
61 | * @returns The computed bounding box. | ||
62 | */ | ||
63 | export function parsePathBBox(path: SVGPathElement): BBox { | ||
64 | const d = path.getAttribute('d') ?? ''; | ||
65 | const points = d.split(/[A-Z ]/); | ||
66 | points.shift(); | ||
67 | return parsePoints(points); | ||
68 | } | ||
diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts new file mode 100644 index 00000000..59cc15b9 --- /dev/null +++ b/subprojects/frontend/src/graph/postProcessSVG.ts | |||
@@ -0,0 +1,79 @@ | |||
1 | /* | ||
2 | * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> | ||
3 | * | ||
4 | * SPDX-License-Identifier: EPL-2.0 | ||
5 | */ | ||
6 | |||
7 | import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; | ||
8 | |||
9 | const SVG_NS = 'http://www.w3.org/2000/svg'; | ||
10 | |||
11 | function clipCompartmentBackground(node: SVGGElement) { | ||
12 | // Background rectangle of the node created by the `<table bgcolor="green">` | ||
13 | // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. | ||
14 | const container = node.querySelector<SVGRectElement>('rect[fill="green"]'); | ||
15 | // Background rectangle of the lower compartment created by the `<td bgcolor="white">` | ||
16 | // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. | ||
17 | // Since dot doesn't round the coners of `<td>` background, | ||
18 | // we have to clip it ourselves. | ||
19 | const compartment = node.querySelector<SVGPolygonElement>( | ||
20 | 'polygon[fill="white"]', | ||
21 | ); | ||
22 | if (container === null || compartment === null) { | ||
23 | return; | ||
24 | } | ||
25 | const copyOfContainer = container.cloneNode() as SVGRectElement; | ||
26 | const clipPath = document.createElementNS(SVG_NS, 'clipPath'); | ||
27 | const clipId = `${node.id},,clip`; | ||
28 | clipPath.setAttribute('id', clipId); | ||
29 | clipPath.appendChild(copyOfContainer); | ||
30 | node.appendChild(clipPath); | ||
31 | compartment.setAttribute('clip-path', `url(#${clipId})`); | ||
32 | } | ||
33 | |||
34 | function createRect( | ||
35 | { x, y, width, height }: BBox, | ||
36 | original: SVGElement, | ||
37 | ): SVGRectElement { | ||
38 | const rect = document.createElementNS(SVG_NS, 'rect'); | ||
39 | rect.setAttribute('fill', original.getAttribute('fill') ?? ''); | ||
40 | rect.setAttribute('stroke', original.getAttribute('stroke') ?? ''); | ||
41 | rect.setAttribute('x', String(x)); | ||
42 | rect.setAttribute('y', String(y)); | ||
43 | rect.setAttribute('width', String(width)); | ||
44 | rect.setAttribute('height', String(height)); | ||
45 | return rect; | ||
46 | } | ||
47 | |||
48 | function optimizeNodeShapes(node: SVGGElement) { | ||
49 | node.querySelectorAll('path').forEach((path) => { | ||
50 | const bbox = parsePathBBox(path); | ||
51 | const rect = createRect(bbox, path); | ||
52 | rect.setAttribute('rx', '12'); | ||
53 | rect.setAttribute('ry', '12'); | ||
54 | node.replaceChild(rect, path); | ||
55 | }); | ||
56 | node.querySelectorAll('polygon').forEach((polygon) => { | ||
57 | const bbox = parsePolygonBBox(polygon); | ||
58 | if (bbox.height === 0) { | ||
59 | const polyline = document.createElementNS(SVG_NS, 'polyline'); | ||
60 | polyline.setAttribute('stroke', polygon.getAttribute('stroke') ?? ''); | ||
61 | polyline.setAttribute( | ||
62 | 'points', | ||
63 | `${bbox.x},${bbox.y} ${bbox.x + bbox.width},${bbox.y}`, | ||
64 | ); | ||
65 | node.replaceChild(polyline, polygon); | ||
66 | } else { | ||
67 | const rect = createRect(bbox, polygon); | ||
68 | node.replaceChild(rect, polygon); | ||
69 | } | ||
70 | }); | ||
71 | clipCompartmentBackground(node); | ||
72 | } | ||
73 | |||
74 | export default function postProcessSvg(svg: SVGSVGElement) { | ||
75 | svg | ||
76 | .querySelectorAll<SVGTitleElement>('title') | ||
77 | .forEach((title) => title.parentNode?.removeChild(title)); | ||
78 | svg.querySelectorAll<SVGGElement>('g.node').forEach(optimizeNodeShapes); | ||
79 | } | ||
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 } = | |||
30 | process.env['NODE_ENV'] ??= mode; | 30 | process.env['NODE_ENV'] ??= mode; |
31 | 31 | ||
32 | const fontsGlob = [ | 32 | const 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 | ||
@@ -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" |