aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/graph')
-rw-r--r--subprojects/frontend/src/graph/DotGraphVisualizer.tsx162
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx54
-rw-r--r--subprojects/frontend/src/graph/GraphPane.tsx28
-rw-r--r--subprojects/frontend/src/graph/GraphStore.ts187
-rw-r--r--subprojects/frontend/src/graph/GraphTheme.tsx120
-rw-r--r--subprojects/frontend/src/graph/RelationName.tsx72
-rw-r--r--subprojects/frontend/src/graph/VisibilityDialog.tsx315
-rw-r--r--subprojects/frontend/src/graph/VisibilityPanel.tsx91
-rw-r--r--subprojects/frontend/src/graph/ZoomButtons.tsx49
-rw-r--r--subprojects/frontend/src/graph/ZoomCanvas.tsx224
-rw-r--r--subprojects/frontend/src/graph/dotSource.ts340
-rw-r--r--subprojects/frontend/src/graph/parseBBox.ts68
-rw-r--r--subprojects/frontend/src/graph/postProcessSVG.ts186
13 files changed, 1896 insertions, 0 deletions
diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
new file mode 100644
index 00000000..eec72a7d
--- /dev/null
+++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
@@ -0,0 +1,162 @@
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 { observer } from 'mobx-react-lite';
12import { useCallback, useRef, useState } from 'react';
13
14import getLogger from '../utils/getLogger';
15
16import type GraphStore from './GraphStore';
17import GraphTheme from './GraphTheme';
18import { FitZoomCallback } from './ZoomCanvas';
19import dotSource from './dotSource';
20import postProcessSvg from './postProcessSVG';
21
22const LOG = getLogger('graph.DotGraphVisualizer');
23
24function ptToPx(pt: number): number {
25 return (pt * 4) / 3;
26}
27
28function DotGraphVisualizer({
29 graph,
30 fitZoom,
31 transitionTime,
32 animateThreshold,
33}: {
34 graph: GraphStore;
35 fitZoom?: FitZoomCallback;
36 transitionTime?: number;
37 animateThreshold?: number;
38}): JSX.Element {
39 const transitionTimeOrDefault =
40 transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime;
41 const animateThresholdOrDefault =
42 animateThreshold ?? DotGraphVisualizer.defaultProps.animateThreshold;
43 const disposerRef = useRef<IReactionDisposer | undefined>();
44 const graphvizRef = useRef<
45 Graphviz<BaseType, unknown, null, undefined> | undefined
46 >();
47 const [animate, setAnimate] = useState(true);
48
49 const setElement = useCallback(
50 (element: HTMLDivElement | null) => {
51 if (disposerRef.current !== undefined) {
52 disposerRef.current();
53 disposerRef.current = undefined;
54 }
55 if (graphvizRef.current !== undefined) {
56 // `@types/d3-graphviz` does not contain the signature for the `destroy` method.
57 (graphvizRef.current as unknown as { destroy(): void }).destroy();
58 graphvizRef.current = undefined;
59 }
60 if (element !== null) {
61 element.replaceChildren();
62 const renderer = graphviz(element) as Graphviz<
63 BaseType,
64 unknown,
65 null,
66 undefined
67 >;
68 renderer.keyMode('id');
69 ['TRUE', 'UNKNOWN', 'ERROR'].forEach((icon) =>
70 renderer.addImage(`#${icon}`, 16, 16),
71 );
72 renderer.zoom(false);
73 renderer.tweenPrecision('5%');
74 renderer.tweenShapes(false);
75 renderer.convertEqualSidedPolygons(false);
76 if (animate) {
77 const transition = () =>
78 d3
79 .transition()
80 .duration(transitionTimeOrDefault)
81 .ease(d3.easeCubic);
82 /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument,
83 @typescript-eslint/no-explicit-any --
84 Workaround for error in `@types/d3-graphviz`.
85 */
86 renderer.transition(transition as any);
87 } else {
88 renderer.tweenPaths(false);
89 }
90 let newViewBox = { width: 0, height: 0 };
91 renderer.onerror(LOG.error.bind(LOG));
92 renderer.on(
93 'postProcessSVG',
94 // @ts-expect-error Custom `d3-graphviz` hook not covered by typings.
95 (
96 svgSelection: Selection<SVGSVGElement, unknown, BaseType, unknown>,
97 ) => {
98 const svg = svgSelection.node();
99 if (svg !== null) {
100 postProcessSvg(svg);
101 newViewBox = {
102 width: ptToPx(svg.viewBox.baseVal.width),
103 height: ptToPx(svg.viewBox.baseVal.height),
104 };
105 } else {
106 // Do not trigger fit zoom.
107 newViewBox = { width: 0, height: 0 };
108 }
109 },
110 );
111 renderer.on('renderEnd', () => {
112 // `d3-graphviz` uses `<title>` elements for traceability,
113 // so we only remove them after the rendering is finished.
114 d3.select(element).selectAll('title').remove();
115 });
116 if (fitZoom !== undefined) {
117 if (animate) {
118 renderer.on('transitionStart', () => fitZoom(newViewBox));
119 } else {
120 renderer.on('end', () => fitZoom(false));
121 }
122 }
123 disposerRef.current = reaction(
124 () => dotSource(graph),
125 (result) => {
126 if (result === undefined) {
127 return;
128 }
129 const [source, size] = result;
130 // Disable tweening for large graphs to improve performance.
131 // See https://github.com/magjac/d3-graphviz/issues/232#issuecomment-1157555213
132 const newAnimate = size < animateThresholdOrDefault;
133 if (animate === newAnimate) {
134 renderer.renderDot(source);
135 } else {
136 setAnimate(newAnimate);
137 }
138 },
139 { fireImmediately: true },
140 );
141 graphvizRef.current = renderer;
142 }
143 },
144 [
145 graph,
146 fitZoom,
147 transitionTimeOrDefault,
148 animateThresholdOrDefault,
149 animate,
150 ],
151 );
152
153 return <GraphTheme ref={setElement} />;
154}
155
156DotGraphVisualizer.defaultProps = {
157 fitZoom: undefined,
158 transitionTime: 250,
159 animateThreshold: 100,
160};
161
162export default observer(DotGraphVisualizer);
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx
new file mode 100644
index 00000000..f8f40d22
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphArea.tsx
@@ -0,0 +1,54 @@
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 { useTheme } from '@mui/material/styles';
9import { observer } from 'mobx-react-lite';
10import { useResizeDetector } from 'react-resize-detector';
11
12import Loading from '../Loading';
13import { useRootStore } from '../RootStoreProvider';
14
15import DotGraphVisualizer from './DotGraphVisualizer';
16import VisibilityPanel from './VisibilityPanel';
17import ZoomCanvas from './ZoomCanvas';
18
19function GraphArea(): JSX.Element {
20 const { editorStore } = useRootStore();
21 const { breakpoints } = useTheme();
22 const { ref, width, height } = useResizeDetector({
23 refreshMode: 'debounce',
24 });
25
26 if (editorStore === undefined) {
27 return <Loading />;
28 }
29
30 const { graph } = editorStore;
31 const breakpoint = breakpoints.values.sm;
32 const dialog =
33 width === undefined ||
34 height === undefined ||
35 width < breakpoint ||
36 height < breakpoint;
37
38 return (
39 <Box
40 width="100%"
41 height="100%"
42 overflow="hidden"
43 position="relative"
44 ref={ref}
45 >
46 <ZoomCanvas>
47 {(fitZoom) => <DotGraphVisualizer graph={graph} fitZoom={fitZoom} />}
48 </ZoomCanvas>
49 <VisibilityPanel graph={graph} dialog={dialog} />
50 </Box>
51 );
52}
53
54export default observer(GraphArea);
diff --git a/subprojects/frontend/src/graph/GraphPane.tsx b/subprojects/frontend/src/graph/GraphPane.tsx
new file mode 100644
index 00000000..c2ef8927
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphPane.tsx
@@ -0,0 +1,28 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import Stack from '@mui/material/Stack';
8import { Suspense, lazy } from 'react';
9
10import Loading from '../Loading';
11
12const GraphArea = lazy(() => import('./GraphArea'));
13
14export default function GraphPane(): JSX.Element {
15 return (
16 <Stack
17 direction="column"
18 height="100%"
19 overflow="auto"
20 alignItems="center"
21 justifyContent="center"
22 >
23 <Suspense fallback={<Loading />}>
24 <GraphArea />
25 </Suspense>
26 </Stack>
27 );
28}
diff --git a/subprojects/frontend/src/graph/GraphStore.ts b/subprojects/frontend/src/graph/GraphStore.ts
new file mode 100644
index 00000000..ecb016b5
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphStore.ts
@@ -0,0 +1,187 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { makeAutoObservable, observable } from 'mobx';
8
9import type {
10 RelationMetadata,
11 SemanticsSuccessResult,
12} from '../xtext/xtextServiceResults';
13
14export type Visibility = 'all' | 'must' | 'none';
15
16export function getDefaultVisibility(
17 metadata: RelationMetadata | undefined,
18): Visibility {
19 if (metadata === undefined || metadata.arity <= 0 || metadata.arity > 2) {
20 return 'none';
21 }
22 const { detail } = metadata;
23 switch (detail.type) {
24 case 'class':
25 case 'reference':
26 case 'opposite':
27 return 'all';
28 case 'predicate':
29 return detail.error ? 'must' : 'none';
30 default:
31 return 'none';
32 }
33}
34
35export function isVisibilityAllowed(
36 metadata: RelationMetadata | undefined,
37 visibility: Visibility,
38): boolean {
39 if (metadata === undefined || metadata.arity <= 0 || metadata.arity > 2) {
40 return visibility === 'none';
41 }
42 const { detail } = metadata;
43 if (detail.type === 'predicate' && detail.error) {
44 // We can't display may matches of error predicates,
45 // because they have none by definition.
46 return visibility !== 'all';
47 }
48 return true;
49}
50
51export default class GraphStore {
52 semantics: SemanticsSuccessResult = {
53 nodes: [],
54 relations: [],
55 partialInterpretation: {},
56 };
57
58 relationMetadata = new Map<string, RelationMetadata>();
59
60 visibility = new Map<string, Visibility>();
61
62 abbreviate = true;
63
64 scopes = false;
65
66 selectedSymbol: RelationMetadata | undefined;
67
68 constructor() {
69 makeAutoObservable(this, {
70 semantics: observable.ref,
71 });
72 }
73
74 getVisibility(relation: string): Visibility {
75 const visibilityOverride = this.visibility.get(relation);
76 if (visibilityOverride !== undefined) {
77 return visibilityOverride;
78 }
79 return this.getDefaultVisibility(relation);
80 }
81
82 getDefaultVisibility(relation: string): Visibility {
83 const metadata = this.relationMetadata.get(relation);
84 return getDefaultVisibility(metadata);
85 }
86
87 isVisibilityAllowed(relation: string, visibility: Visibility): boolean {
88 const metadata = this.relationMetadata.get(relation);
89 return isVisibilityAllowed(metadata, visibility);
90 }
91
92 setVisibility(relation: string, visibility: Visibility): void {
93 const metadata = this.relationMetadata.get(relation);
94 if (metadata === undefined || !isVisibilityAllowed(metadata, visibility)) {
95 return;
96 }
97 const defaultVisiblity = getDefaultVisibility(metadata);
98 if (defaultVisiblity === visibility) {
99 this.visibility.delete(relation);
100 } else {
101 this.visibility.set(relation, visibility);
102 }
103 }
104
105 cycleVisibility(relation: string): void {
106 const metadata = this.relationMetadata.get(relation);
107 if (metadata === undefined) {
108 return;
109 }
110 switch (this.getVisibility(relation)) {
111 case 'none':
112 if (isVisibilityAllowed(metadata, 'must')) {
113 this.setVisibility(relation, 'must');
114 }
115 break;
116 case 'must':
117 {
118 const next = isVisibilityAllowed(metadata, 'all') ? 'all' : 'none';
119 this.setVisibility(relation, next);
120 }
121 break;
122 default:
123 this.setVisibility(relation, 'none');
124 break;
125 }
126 }
127
128 hideAll(): void {
129 this.relationMetadata.forEach((metadata, name) => {
130 if (getDefaultVisibility(metadata) === 'none') {
131 this.visibility.delete(name);
132 } else {
133 this.visibility.set(name, 'none');
134 }
135 });
136 }
137
138 resetFilter(): void {
139 this.visibility.clear();
140 }
141
142 getName({ name, simpleName }: { name: string; simpleName: string }): string {
143 return this.abbreviate ? simpleName : name;
144 }
145
146 toggleAbbrevaite(): void {
147 this.abbreviate = !this.abbreviate;
148 }
149
150 toggleScopes(): void {
151 this.scopes = !this.scopes;
152 }
153
154 setSelectedSymbol(option: RelationMetadata | undefined): void {
155 if (option === undefined) {
156 this.selectedSymbol = undefined;
157 return;
158 }
159 const metadata = this.relationMetadata.get(option.name);
160 if (metadata !== undefined) {
161 this.selectedSymbol = metadata;
162 } else {
163 this.selectedSymbol = undefined;
164 }
165 }
166
167 setSemantics(semantics: SemanticsSuccessResult) {
168 this.semantics = semantics;
169 this.relationMetadata.clear();
170 this.semantics.relations.forEach((metadata) => {
171 this.relationMetadata.set(metadata.name, metadata);
172 });
173 const toRemove = new Set<string>();
174 this.visibility.forEach((value, key) => {
175 if (
176 !this.isVisibilityAllowed(key, value) ||
177 this.getDefaultVisibility(key) === value
178 ) {
179 toRemove.add(key);
180 }
181 });
182 toRemove.forEach((key) => {
183 this.visibility.delete(key);
184 });
185 this.setSelectedSymbol(this.selectedSymbol);
186 }
187}
diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx
new file mode 100644
index 00000000..989bd0c2
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphTheme.tsx
@@ -0,0 +1,120 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw';
8import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw';
9import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw';
10import { alpha, styled, type CSSObject } from '@mui/material/styles';
11
12import svgURL from '../utils/svgURL';
13
14function createEdgeColor(
15 suffix: string,
16 stroke: string,
17 fill?: string,
18): CSSObject {
19 return {
20 [`.edge-${suffix}`]: {
21 '& text': {
22 fill: stroke,
23 },
24 '& [stroke="black"]': {
25 stroke,
26 },
27 '& [fill="black"]': {
28 fill: fill ?? stroke,
29 },
30 },
31 };
32}
33
34export default styled('div', {
35 name: 'GraphTheme',
36})(({ theme }) => ({
37 '& svg': {
38 userSelect: 'none',
39 '.node': {
40 '& text': {
41 fontFamily: theme.typography.fontFamily,
42 fill: theme.palette.text.primary,
43 },
44 '& [stroke="black"]': {
45 stroke: theme.palette.text.primary,
46 },
47 '& [fill="green"]': {
48 fill:
49 theme.palette.mode === 'dark'
50 ? theme.palette.primary.dark
51 : theme.palette.primary.light,
52 },
53 '& [fill="white"]': {
54 fill: theme.palette.background.default,
55 },
56 },
57 '.node-INDIVIDUAL': {
58 '& [stroke="black"]': {
59 strokeWidth: 2,
60 },
61 },
62 '.node-shadow[fill="white"]': {
63 fill: alpha(
64 theme.palette.text.primary,
65 theme.palette.mode === 'dark' ? 0.32 : 0.24,
66 ),
67 },
68 '.node-exists-UNKNOWN [stroke="black"]': {
69 strokeDasharray: '5 2',
70 },
71 '.node-exists-FALSE': {
72 '& [fill="green"]': {
73 fill: theme.palette.background.default,
74 },
75 '& [stroke="black"]': {
76 strokeDasharray: '1 3',
77 stroke: theme.palette.text.secondary,
78 },
79 },
80 '.edge': {
81 '& text': {
82 fontFamily: theme.typography.fontFamily,
83 fill: theme.palette.text.primary,
84 },
85 '& [stroke="black"]': {
86 stroke: theme.palette.text.primary,
87 },
88 '& [fill="black"]': {
89 fill: theme.palette.text.primary,
90 },
91 },
92 ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'),
93 ...createEdgeColor('ERROR', theme.palette.error.main),
94 '.icon': {
95 maskSize: '12px 12px',
96 maskPosition: '50% 50%',
97 maskRepeat: 'no-repeat',
98 width: '100%',
99 height: '100%',
100 },
101 '.icon-TRUE': {
102 maskImage: svgURL(labelSVG),
103 background: theme.palette.text.primary,
104 },
105 '.icon-UNKNOWN': {
106 maskImage: svgURL(labelOutlinedSVG),
107 background: theme.palette.text.secondary,
108 },
109 '.icon-ERROR': {
110 maskImage: svgURL(cancelSVG),
111 background: theme.palette.error.main,
112 },
113 'text.label-UNKNOWN': {
114 fill: theme.palette.text.secondary,
115 },
116 'text.label-ERROR': {
117 fill: theme.palette.error.main,
118 },
119 },
120}));
diff --git a/subprojects/frontend/src/graph/RelationName.tsx b/subprojects/frontend/src/graph/RelationName.tsx
new file mode 100644
index 00000000..ec26fb21
--- /dev/null
+++ b/subprojects/frontend/src/graph/RelationName.tsx
@@ -0,0 +1,72 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { styled } from '@mui/material/styles';
8import { observer } from 'mobx-react-lite';
9
10import { RelationMetadata } from '../xtext/xtextServiceResults';
11
12const Error = styled('span', {
13 name: 'RelationName-Error',
14})(({ theme }) => ({
15 color: theme.palette.error.main,
16}));
17
18const Qualifier = styled('span', {
19 name: 'RelationName-Qualifier',
20})(({ theme }) => ({
21 color: theme.palette.text.secondary,
22}));
23
24const FormattedName = observer(function FormattedName({
25 name,
26 metadata,
27}: {
28 name: string;
29 metadata: RelationMetadata;
30}): React.ReactNode {
31 const { detail } = metadata;
32 if (detail.type === 'class' && detail.abstractClass) {
33 return <i>{name}</i>;
34 }
35 if (detail.type === 'reference' && detail.containment) {
36 return <b>{name}</b>;
37 }
38 if (detail.type === 'predicate' && detail.error) {
39 return <Error>{name}</Error>;
40 }
41 return name;
42});
43
44function RelationName({
45 metadata,
46 abbreviate,
47}: {
48 metadata: RelationMetadata;
49 abbreviate?: boolean;
50}): JSX.Element {
51 const { name, simpleName } = metadata;
52 if (abbreviate ?? RelationName.defaultProps.abbreviate) {
53 return <FormattedName name={simpleName} metadata={metadata} />;
54 }
55 if (name.endsWith(simpleName)) {
56 return (
57 <>
58 <Qualifier>
59 {name.substring(0, name.length - simpleName.length)}
60 </Qualifier>
61 <FormattedName name={simpleName} metadata={metadata} />
62 </>
63 );
64 }
65 return <FormattedName name={name} metadata={metadata} />;
66}
67
68RelationName.defaultProps = {
69 abbreviate: false,
70};
71
72export default observer(RelationName);
diff --git a/subprojects/frontend/src/graph/VisibilityDialog.tsx b/subprojects/frontend/src/graph/VisibilityDialog.tsx
new file mode 100644
index 00000000..f1fef28b
--- /dev/null
+++ b/subprojects/frontend/src/graph/VisibilityDialog.tsx
@@ -0,0 +1,315 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import CloseIcon from '@mui/icons-material/Close';
8import FilterListIcon from '@mui/icons-material/FilterList';
9import LabelIcon from '@mui/icons-material/Label';
10import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined';
11import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied';
12import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
13import Button from '@mui/material/Button';
14import Checkbox from '@mui/material/Checkbox';
15import FormControlLabel from '@mui/material/FormControlLabel';
16import IconButton from '@mui/material/IconButton';
17import Switch from '@mui/material/Switch';
18import Typography from '@mui/material/Typography';
19import { styled } from '@mui/material/styles';
20import { observer } from 'mobx-react-lite';
21import { useId } from 'react';
22
23import type GraphStore from './GraphStore';
24import { isVisibilityAllowed } from './GraphStore';
25import RelationName from './RelationName';
26
27const VisibilityDialogRoot = styled('div', {
28 name: 'VisibilityDialog-Root',
29 shouldForwardProp: (propName) => propName !== 'dialog',
30})<{ dialog: boolean }>(({ theme, dialog }) => {
31 const overlayOpacity = dialog ? 0.16 : 0.09;
32 return {
33 maxHeight: '100%',
34 maxWidth: '100%',
35 overflow: 'hidden',
36 display: 'flex',
37 flexDirection: 'column',
38 '.VisibilityDialog-title': {
39 display: 'flex',
40 flexDirection: 'row',
41 alignItems: 'center',
42 padding: theme.spacing(1),
43 paddingLeft: theme.spacing(2),
44 borderBottom: `1px solid ${theme.palette.divider}`,
45 '& h2': {
46 flexGrow: 1,
47 },
48 '.MuiIconButton-root': {
49 flexGrow: 0,
50 flexShrink: 0,
51 marginLeft: theme.spacing(2),
52 },
53 },
54 '.MuiFormControlLabel-root': {
55 marginLeft: 0,
56 paddingTop: theme.spacing(1),
57 paddingLeft: theme.spacing(1),
58 '& + .MuiFormControlLabel-root': {
59 paddingTop: 0,
60 },
61 },
62 '.VisibilityDialog-scroll': {
63 display: 'flex',
64 flexDirection: 'column',
65 height: 'auto',
66 overflowX: 'hidden',
67 overflowY: 'auto',
68 margin: `0 ${theme.spacing(2)}`,
69 '& table': {
70 // We use flexbox instead of `display: table` to get proper text-overflow
71 // behavior for overly long relation names.
72 display: 'flex',
73 flexDirection: 'column',
74 },
75 '& thead, & tbody': {
76 display: 'flex',
77 flexDirection: 'column',
78 },
79 '& thead': {
80 position: 'sticky',
81 top: 0,
82 zIndex: 999,
83 backgroundColor: theme.palette.background.paper,
84 ...(theme.palette.mode === 'dark'
85 ? {
86 // In dark mode, MUI Paper gets a lighter overlay.
87 backgroundImage: `linear-gradient(
88 rgba(255, 255, 255, ${overlayOpacity}),
89 rgba(255, 255, 255, ${overlayOpacity})
90 )`,
91 }
92 : {}),
93 '& tr': {
94 height: '44px',
95 },
96 },
97 '& tr': {
98 display: 'flex',
99 flexDirection: 'row',
100 maxWidth: '100%',
101 },
102 '& tbody tr': {
103 transition: theme.transitions.create('background', {
104 duration: theme.transitions.duration.shortest,
105 }),
106 '&:hover': {
107 background: theme.palette.action.hover,
108 '@media (hover: none)': {
109 background: 'transparent',
110 },
111 },
112 },
113 '& th, & td': {
114 display: 'flex',
115 flexDirection: 'row',
116 alignItems: 'center',
117 justifyContent: 'center',
118 // Set width in advance, since we can't rely on `display: table-cell`.
119 width: '44px',
120 },
121 '& th:nth-of-type(3), & td:nth-of-type(3)': {
122 justifyContent: 'start',
123 paddingLeft: theme.spacing(1),
124 paddingRight: theme.spacing(2),
125 // Only let the last column grow or shrink.
126 flexGrow: 1,
127 flexShrink: 1,
128 // Compute the maximum available space in advance to let the text overflow.
129 maxWidth: 'calc(100% - 88px)',
130 width: 'min-content',
131 },
132 '& td:nth-of-type(3)': {
133 cursor: 'pointer',
134 userSelect: 'none',
135 WebkitTapHighlightColor: 'transparent',
136 },
137
138 '& thead th, .VisibilityDialog-custom tr:last-child td': {
139 borderBottom: `1px solid ${theme.palette.divider}`,
140 },
141 },
142 // Hack to apply `text-overflow`.
143 '.VisibilityDialog-nowrap': {
144 maxWidth: '100%',
145 overflow: 'hidden',
146 wordWrap: 'nowrap',
147 textOverflow: 'ellipsis',
148 },
149 '.VisibilityDialog-buttons': {
150 padding: theme.spacing(1),
151 display: 'flex',
152 flexDirection: 'row',
153 justifyContent: 'flex-end',
154 ...(dialog
155 ? {
156 marginTop: theme.spacing(1),
157 borderTop: `1px solid ${theme.palette.divider}`,
158 }
159 : {}),
160 },
161 '.VisibilityDialog-empty': {
162 display: 'flex',
163 flexDirection: 'column',
164 alignItems: 'center',
165 color: theme.palette.text.secondary,
166 },
167 '.VisibilityDialog-emptyIcon': {
168 fontSize: '6rem',
169 marginBottom: theme.spacing(1),
170 },
171 };
172});
173
174function VisibilityDialog({
175 graph,
176 close,
177 dialog,
178}: {
179 graph: GraphStore;
180 close: () => void;
181 dialog?: boolean;
182}): JSX.Element {
183 const titleId = useId();
184
185 const builtinRows: JSX.Element[] = [];
186 const rows: JSX.Element[] = [];
187 graph.relationMetadata.forEach((metadata, name) => {
188 if (!isVisibilityAllowed(metadata, 'must')) {
189 return;
190 }
191 const visibility = graph.getVisibility(name);
192 const row = (
193 <tr key={metadata.name}>
194 <td>
195 <Checkbox
196 checked={visibility !== 'none'}
197 aria-label={`Show true and error values of ${metadata.simpleName}`}
198 onClick={() =>
199 graph.setVisibility(name, visibility === 'none' ? 'must' : 'none')
200 }
201 />
202 </td>
203 <td>
204 <Checkbox
205 checked={visibility === 'all'}
206 disabled={!isVisibilityAllowed(metadata, 'all')}
207 aria-label={`Show all values of ${metadata.simpleName}`}
208 onClick={() =>
209 graph.setVisibility(name, visibility === 'all' ? 'must' : 'all')
210 }
211 />
212 </td>
213 <td onClick={() => graph.cycleVisibility(name)}>
214 <div className="VisibilityDialog-nowrap">
215 <RelationName metadata={metadata} abbreviate={graph.abbreviate} />
216 </div>
217 </td>
218 </tr>
219 );
220 if (name.startsWith('builtin::')) {
221 builtinRows.push(row);
222 } else {
223 rows.push(row);
224 }
225 });
226
227 const hasRows = rows.length > 0 || builtinRows.length > 0;
228
229 return (
230 <VisibilityDialogRoot
231 dialog={dialog ?? VisibilityDialog.defaultProps.dialog}
232 aria-labelledby={dialog ? titleId : undefined}
233 >
234 {dialog && (
235 <div className="VisibilityDialog-title">
236 <Typography variant="h6" component="h2" id={titleId}>
237 Customize view
238 </Typography>
239 <IconButton aria-label="Close" onClick={close}>
240 <CloseIcon />
241 </IconButton>
242 </div>
243 )}
244 <FormControlLabel
245 control={
246 <Switch
247 checked={!graph.abbreviate}
248 onClick={() => graph.toggleAbbrevaite()}
249 />
250 }
251 label="Fully qualified names"
252 />
253 <FormControlLabel
254 control={
255 <Switch checked={graph.scopes} onClick={() => graph.toggleScopes()} />
256 }
257 label="Object scopes"
258 />
259 <div className="VisibilityDialog-scroll">
260 {hasRows ? (
261 <table cellSpacing={0}>
262 <thead>
263 <tr>
264 <th>
265 <LabelIcon />
266 </th>
267 <th>
268 <LabelOutlinedIcon />
269 </th>
270 <th>Symbol</th>
271 </tr>
272 </thead>
273 <tbody className="VisibilityDialog-custom">{...rows}</tbody>
274 <tbody className="VisibilityDialog-builtin">{...builtinRows}</tbody>
275 </table>
276 ) : (
277 <div className="VisibilityDialog-empty">
278 <SentimentVeryDissatisfiedIcon
279 className="VisibilityDialog-emptyIcon"
280 fontSize="inherit"
281 />
282 <div>Partial model is empty</div>
283 </div>
284 )}
285 </div>
286 <div className="VisibilityDialog-buttons">
287 <Button
288 color="inherit"
289 onClick={() => graph.hideAll()}
290 startIcon={<VisibilityOffIcon />}
291 >
292 Hide all
293 </Button>
294 <Button
295 color="inherit"
296 onClick={() => graph.resetFilter()}
297 startIcon={<FilterListIcon />}
298 >
299 Reset filter
300 </Button>
301 {!dialog && (
302 <Button color="inherit" onClick={close}>
303 Close
304 </Button>
305 )}
306 </div>
307 </VisibilityDialogRoot>
308 );
309}
310
311VisibilityDialog.defaultProps = {
312 dialog: false,
313};
314
315export default observer(VisibilityDialog);
diff --git a/subprojects/frontend/src/graph/VisibilityPanel.tsx b/subprojects/frontend/src/graph/VisibilityPanel.tsx
new file mode 100644
index 00000000..20c4ffca
--- /dev/null
+++ b/subprojects/frontend/src/graph/VisibilityPanel.tsx
@@ -0,0 +1,91 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
8import TuneIcon from '@mui/icons-material/Tune';
9import Badge from '@mui/material/Badge';
10import Dialog from '@mui/material/Dialog';
11import IconButton from '@mui/material/IconButton';
12import Paper from '@mui/material/Paper';
13import Slide from '@mui/material/Slide';
14import { styled } from '@mui/material/styles';
15import { observer } from 'mobx-react-lite';
16import { useCallback, useId, useState } from 'react';
17
18import type GraphStore from './GraphStore';
19import VisibilityDialog from './VisibilityDialog';
20
21const VisibilityPanelRoot = styled('div', {
22 name: 'VisibilityPanel-Root',
23})(({ theme }) => ({
24 position: 'absolute',
25 padding: theme.spacing(1),
26 top: 0,
27 left: 0,
28 maxHeight: '100%',
29 maxWidth: '100%',
30 overflow: 'hidden',
31 display: 'flex',
32 flexDirection: 'column',
33 alignItems: 'start',
34 '.VisibilityPanel-drawer': {
35 overflow: 'hidden',
36 display: 'flex',
37 maxWidth: '100%',
38 margin: theme.spacing(1),
39 },
40}));
41
42function VisibilityPanel({
43 graph,
44 dialog,
45}: {
46 graph: GraphStore;
47 dialog: boolean;
48}): JSX.Element {
49 const id = useId();
50 const [showFilter, setShowFilter] = useState(false);
51 const close = useCallback(() => setShowFilter(false), []);
52
53 return (
54 <VisibilityPanelRoot>
55 <IconButton
56 role="switch"
57 aria-checked={showFilter}
58 aria-controls={dialog ? undefined : id}
59 aria-label="Show filter panel"
60 onClick={() => setShowFilter(!showFilter)}
61 >
62 <Badge
63 color="primary"
64 variant="dot"
65 invisible={graph.visibility.size === 0}
66 >
67 {showFilter && !dialog ? <ChevronLeftIcon /> : <TuneIcon />}
68 </Badge>
69 </IconButton>
70 {dialog ? (
71 <Dialog open={showFilter} onClose={close} maxWidth="xl">
72 <VisibilityDialog graph={graph} close={close} dialog />
73 </Dialog>
74 ) : (
75 <Slide
76 direction="right"
77 in={showFilter}
78 id={id}
79 mountOnEnter
80 unmountOnExit
81 >
82 <Paper className="VisibilityPanel-drawer" elevation={4}>
83 <VisibilityDialog graph={graph} close={close} />
84 </Paper>
85 </Slide>
86 )}
87 </VisibilityPanelRoot>
88 );
89}
90
91export default observer(VisibilityPanel);
diff --git a/subprojects/frontend/src/graph/ZoomButtons.tsx b/subprojects/frontend/src/graph/ZoomButtons.tsx
new file mode 100644
index 00000000..83938cf4
--- /dev/null
+++ b/subprojects/frontend/src/graph/ZoomButtons.tsx
@@ -0,0 +1,49 @@
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';
12import ToggleButton from '@mui/material/ToggleButton';
13
14import type { ChangeZoomCallback, SetFitZoomCallback } from './ZoomCanvas';
15
16export default function ZoomButtons({
17 changeZoom,
18 fitZoom,
19 setFitZoom,
20}: {
21 changeZoom: ChangeZoomCallback;
22 fitZoom: boolean;
23 setFitZoom: SetFitZoomCallback;
24}): JSX.Element {
25 return (
26 <Stack
27 direction="column"
28 p={1}
29 sx={{ position: 'absolute', bottom: 0, right: 0 }}
30 >
31 <IconButton aria-label="Zoom in" onClick={() => changeZoom(2)}>
32 <AddIcon fontSize="small" />
33 </IconButton>
34 <IconButton aria-label="Zoom out" onClick={() => changeZoom(0.5)}>
35 <RemoveIcon fontSize="small" />
36 </IconButton>
37 <ToggleButton
38 value="show-replace"
39 selected={fitZoom}
40 onClick={() => setFitZoom(!fitZoom)}
41 aria-label="Fit screen"
42 size="small"
43 className="iconOnly"
44 >
45 <CropFreeIcon fontSize="small" />
46 </ToggleButton>
47 </Stack>
48 );
49}
diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx
new file mode 100644
index 00000000..0254bc59
--- /dev/null
+++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx
@@ -0,0 +1,224 @@
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';
11import { useResizeDetector } from 'react-resize-detector';
12
13import ZoomButtons from './ZoomButtons';
14
15declare module 'd3-zoom' {
16 // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Redeclaring type parameters.
17 interface ZoomBehavior<ZoomRefElement extends Element, Datum> {
18 // `@types/d3-zoom` does not contain the `center` function, because it is
19 // only available as a pull request for `d3-zoom`.
20 center(callback: (event: MouseEvent | Touch) => [number, number]): this;
21
22 // Custom `centroid` method added via patch.
23 centroid(centroid: [number, number]): this;
24 }
25}
26
27interface Transform {
28 x: number;
29 y: number;
30 k: number;
31}
32
33export type ChangeZoomCallback = (factor: number) => void;
34
35export type SetFitZoomCallback = (fitZoom: boolean) => void;
36
37export type FitZoomCallback = ((newSize?: {
38 width: number;
39 height: number;
40}) => void) &
41 ((newSize: boolean) => void);
42
43export default function ZoomCanvas({
44 children,
45 fitPadding,
46 transitionTime,
47}: {
48 children?: React.ReactNode | ((fitZoom: FitZoomCallback) => React.ReactNode);
49 fitPadding?: number;
50 transitionTime?: number;
51}): JSX.Element {
52 const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding;
53 const transitionTimeOrDefault =
54 transitionTime ?? ZoomCanvas.defaultProps.transitionTime;
55
56 const canvasRef = useRef<HTMLDivElement | undefined>();
57 const elementRef = useRef<HTMLDivElement | undefined>();
58 const zoomRef = useRef<
59 d3.ZoomBehavior<HTMLDivElement, unknown> | undefined
60 >();
61 const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 });
62 const [fitZoom, setFitZoom] = useState(true);
63 const fitZoomRef = useRef(fitZoom);
64
65 const makeTransition = useCallback(
66 (element: HTMLDivElement) =>
67 d3.select(element).transition().duration(transitionTimeOrDefault),
68 [transitionTimeOrDefault],
69 );
70
71 const fitZoomCallback = useCallback<FitZoomCallback>(
72 (newSize) => {
73 if (
74 !fitZoomRef.current ||
75 canvasRef.current === undefined ||
76 zoomRef.current === undefined ||
77 elementRef.current === undefined
78 ) {
79 return;
80 }
81 let width = 0;
82 let height = 0;
83 if (newSize === undefined || typeof newSize === 'boolean') {
84 const elementRect = elementRef.current.getBoundingClientRect();
85 const currentFactor = d3.zoomTransform(canvasRef.current).k;
86 width = elementRect.width / currentFactor;
87 height = elementRect.height / currentFactor;
88 } else {
89 ({ width, height } = newSize);
90 }
91 if (width === 0 || height === 0) {
92 return;
93 }
94 const canvasRect = canvasRef.current.getBoundingClientRect();
95 const factor = Math.min(
96 1.0,
97 (canvasRect.width - 2 * fitPaddingOrDefault) / width,
98 (canvasRect.height - 2 * fitPaddingOrDefault) / height,
99 );
100 const target =
101 newSize === false
102 ? d3.select(canvasRef.current)
103 : makeTransition(canvasRef.current);
104 zoomRef.current.transform(target, d3.zoomIdentity.scale(factor));
105 },
106 [fitPaddingOrDefault, makeTransition],
107 );
108
109 const setFitZoomCallback = useCallback<SetFitZoomCallback>(
110 (newFitZoom) => {
111 setFitZoom(newFitZoom);
112 fitZoomRef.current = newFitZoom;
113 if (newFitZoom) {
114 fitZoomCallback();
115 }
116 },
117 [fitZoomCallback],
118 );
119
120 const changeZoomCallback = useCallback<ChangeZoomCallback>(
121 (factor) => {
122 setFitZoomCallback(false);
123 if (canvasRef.current === undefined || zoomRef.current === undefined) {
124 return;
125 }
126 const zoomTransition = makeTransition(canvasRef.current);
127 const center: [number, number] = [0, 0];
128 zoomRef.current.scaleBy(zoomTransition, factor, center);
129 },
130 [makeTransition, setFitZoomCallback],
131 );
132
133 const onResize = useCallback(() => fitZoomCallback(), [fitZoomCallback]);
134
135 const { ref: resizeRef } = useResizeDetector({
136 onResize,
137 refreshMode: 'debounce',
138 refreshRate: transitionTimeOrDefault,
139 });
140
141 const setCanvas = useCallback(
142 (canvas: HTMLDivElement | null) => {
143 canvasRef.current = canvas ?? undefined;
144 resizeRef(canvas);
145 if (canvas === null) {
146 return;
147 }
148 const zoomBehavior = d3Zoom<HTMLDivElement, unknown>()
149 .duration(transitionTimeOrDefault)
150 .center((event) => {
151 const { width, height } = canvas.getBoundingClientRect();
152 const [x, y] = d3.pointer(event, canvas);
153 return [x - width / 2, y - height / 2];
154 })
155 .centroid([0, 0])
156 .scaleExtent([1 / 32, 8]);
157 zoomBehavior.on(
158 'zoom',
159 (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => {
160 setZoom(event.transform);
161 if (event.sourceEvent) {
162 setFitZoomCallback(false);
163 }
164 },
165 );
166 d3.select(canvas).call(zoomBehavior);
167 zoomRef.current = zoomBehavior;
168 },
169 [transitionTimeOrDefault, setFitZoomCallback, resizeRef],
170 );
171
172 return (
173 <Box
174 sx={{
175 width: '100%',
176 height: '100%',
177 position: 'relative',
178 overflow: 'hidden',
179 }}
180 >
181 <Box
182 sx={{
183 position: 'absolute',
184 overflow: 'hidden',
185 top: 0,
186 left: 0,
187 right: 0,
188 bottom: 0,
189 }}
190 ref={setCanvas}
191 >
192 <Box
193 sx={{
194 position: 'absolute',
195 top: '50%',
196 left: '50%',
197 transform: `
198 translate(${zoom.x}px, ${zoom.y}px)
199 scale(${zoom.k})
200 translate(-50%, -50%)
201 `,
202 transformOrigin: '0 0',
203 }}
204 ref={elementRef}
205 >
206 {typeof children === 'function'
207 ? children(fitZoomCallback)
208 : children}
209 </Box>
210 </Box>
211 <ZoomButtons
212 changeZoom={changeZoomCallback}
213 fitZoom={fitZoom}
214 setFitZoom={setFitZoomCallback}
215 />
216 </Box>
217 );
218}
219
220ZoomCanvas.defaultProps = {
221 children: undefined,
222 fitPadding: 8,
223 transitionTime: 250,
224};
diff --git a/subprojects/frontend/src/graph/dotSource.ts b/subprojects/frontend/src/graph/dotSource.ts
new file mode 100644
index 00000000..5e0b44c8
--- /dev/null
+++ b/subprojects/frontend/src/graph/dotSource.ts
@@ -0,0 +1,340 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import type {
8 NodeMetadata,
9 RelationMetadata,
10} from '../xtext/xtextServiceResults';
11
12import type GraphStore from './GraphStore';
13
14const EDGE_WEIGHT = 1;
15const CONTAINMENT_WEIGHT = 5;
16const UNKNOWN_WEIGHT_FACTOR = 0.5;
17
18function nodeName(graph: GraphStore, metadata: NodeMetadata): string {
19 const name = graph.getName(metadata);
20 switch (metadata.kind) {
21 case 'INDIVIDUAL':
22 return `<b>${name}</b>`;
23 default:
24 return name;
25 }
26}
27
28function relationName(graph: GraphStore, metadata: RelationMetadata): string {
29 const name = graph.getName(metadata);
30 const { detail } = metadata;
31 if (detail.type === 'class' && detail.abstractClass) {
32 return `<i>${name}</i>`;
33 }
34 if (detail.type === 'reference' && detail.containment) {
35 return `<b>${name}</b>`;
36 }
37 return name;
38}
39
40interface NodeData {
41 isolated: boolean;
42 exists: string;
43 equalsSelf: string;
44 unaryPredicates: Map<RelationMetadata, string>;
45 count: string;
46}
47
48function computeNodeData(graph: GraphStore): NodeData[] {
49 const {
50 semantics: { nodes, relations, partialInterpretation },
51 } = graph;
52
53 const nodeData = Array.from(Array(nodes.length)).map(() => ({
54 isolated: true,
55 exists: 'FALSE',
56 equalsSelf: 'FALSE',
57 unaryPredicates: new Map(),
58 count: '[0]',
59 }));
60
61 relations.forEach((relation) => {
62 const visibility = graph.getVisibility(relation.name);
63 if (visibility === 'none') {
64 return;
65 }
66 const { arity } = relation;
67 const interpretation = partialInterpretation[relation.name] ?? [];
68 interpretation.forEach((tuple) => {
69 const value = tuple[arity];
70 if (visibility !== 'all' && value === 'UNKNOWN') {
71 return;
72 }
73 for (let i = 0; i < arity; i += 1) {
74 const index = tuple[i];
75 if (typeof index === 'number') {
76 const data = nodeData[index];
77 if (data !== undefined) {
78 data.isolated = false;
79 if (arity === 1) {
80 data.unaryPredicates.set(relation, value);
81 }
82 }
83 }
84 }
85 });
86 });
87
88 partialInterpretation['builtin::exists']?.forEach(([index, value]) => {
89 if (typeof index === 'number' && typeof value === 'string') {
90 const data = nodeData[index];
91 if (data !== undefined) {
92 data.exists = value;
93 }
94 }
95 });
96
97 partialInterpretation['builtin::equals']?.forEach(([index, other, value]) => {
98 if (
99 typeof index === 'number' &&
100 index === other &&
101 typeof value === 'string'
102 ) {
103 const data = nodeData[index];
104 if (data !== undefined) {
105 data.equalsSelf = value;
106 }
107 }
108 });
109
110 partialInterpretation['builtin::count']?.forEach(([index, value]) => {
111 if (typeof index === 'number' && typeof value === 'string') {
112 const data = nodeData[index];
113 if (data !== undefined) {
114 data.count = value;
115 }
116 }
117 });
118
119 return nodeData;
120}
121
122function createNodes(graph: GraphStore, lines: string[]): void {
123 const nodeData = computeNodeData(graph);
124 const {
125 semantics: { nodes },
126 scopes,
127 } = graph;
128
129 nodes.forEach((node, i) => {
130 const data = nodeData[i];
131 if (data === undefined || data.isolated) {
132 return;
133 }
134 const classList = [
135 `node-${node.kind}`,
136 `node-exists-${data.exists}`,
137 `node-equalsSelf-${data.equalsSelf}`,
138 ];
139 if (data.unaryPredicates.size === 0) {
140 classList.push('node-empty');
141 }
142 const classes = classList.join(' ');
143 const name = nodeName(graph, node);
144 const border = node.kind === 'INDIVIDUAL' ? 2 : 1;
145 const count = scopes ? ` ${data.count}` : '';
146 lines.push(`n${i} [id="${node.name}", class="${classes}", label=<
147 <table border="${border}" cellborder="0" cellspacing="0" style="rounded" bgcolor="white">
148 <tr><td cellpadding="4.5" width="32" bgcolor="green">${name}${count}</td></tr>`);
149 if (data.unaryPredicates.size > 0) {
150 lines.push(
151 '<hr/><tr><td cellpadding="4.5"><table fixedsize="TRUE" align="left" border="0" cellborder="0" cellspacing="0" cellpadding="1.5">',
152 );
153 data.unaryPredicates.forEach((value, relation) => {
154 lines.push(
155 `<tr>
156 <td><img src="#${value}"/></td>
157 <td width="1.5"></td>
158 <td align="left" href="#${value}" id="${node.name},${
159 relation.name
160 },label">${relationName(graph, relation)}</td>
161 </tr>`,
162 );
163 });
164 lines.push('</table></td></tr>');
165 }
166 lines.push('</table>>]');
167 });
168}
169
170function compare(
171 a: readonly (number | string)[],
172 b: readonly number[],
173): number {
174 if (a.length !== b.length + 1) {
175 throw new Error('Tuple length mismatch');
176 }
177 for (let i = 0; i < b.length; i += 1) {
178 const aItem = a[i];
179 const bItem = b[i];
180 if (typeof aItem !== 'number' || typeof bItem !== 'number') {
181 throw new Error('Invalid tuple');
182 }
183 if (aItem < bItem) {
184 return -1;
185 }
186 if (aItem > bItem) {
187 return 1;
188 }
189 }
190 return 0;
191}
192
193function binarySerach(
194 tuples: readonly (readonly (number | string)[])[],
195 key: readonly number[],
196): string | undefined {
197 let lower = 0;
198 let upper = tuples.length - 1;
199 while (lower <= upper) {
200 const middle = Math.floor((lower + upper) / 2);
201 const tuple = tuples[middle];
202 if (tuple === undefined) {
203 throw new Error('Range error');
204 }
205 const result = compare(tuple, key);
206 if (result === 0) {
207 const found = tuple[key.length];
208 if (typeof found !== 'string') {
209 throw new Error('Invalid tuple value');
210 }
211 return found;
212 }
213 if (result < 0) {
214 lower = middle + 1;
215 } else {
216 // result > 0
217 upper = middle - 1;
218 }
219 }
220 return undefined;
221}
222
223function createRelationEdges(
224 graph: GraphStore,
225 relation: RelationMetadata,
226 showUnknown: boolean,
227 lines: string[],
228): void {
229 const {
230 semantics: { nodes, partialInterpretation },
231 } = graph;
232 const { detail } = relation;
233
234 let constraint: 'true' | 'false' = 'true';
235 let weight = EDGE_WEIGHT;
236 let penwidth = 1;
237 const name = graph.getName(relation);
238 let label = `"${name}"`;
239 if (detail.type === 'reference' && detail.containment) {
240 weight = CONTAINMENT_WEIGHT;
241 label = `<<b>${name}</b>>`;
242 penwidth = 2;
243 } else if (
244 detail.type === 'opposite' &&
245 graph.getVisibility(detail.opposite) !== 'none'
246 ) {
247 constraint = 'false';
248 weight = 0;
249 }
250
251 const tuples = partialInterpretation[relation.name] ?? [];
252 tuples.forEach(([from, to, value]) => {
253 const isUnknown = value === 'UNKNOWN';
254 if (
255 (!showUnknown && isUnknown) ||
256 typeof from !== 'number' ||
257 typeof to !== 'number' ||
258 typeof value !== 'string'
259 ) {
260 return;
261 }
262
263 const fromNode = nodes[from];
264 const toNode = nodes[to];
265 if (fromNode === undefined || toNode === undefined) {
266 return;
267 }
268
269 let dir = 'forward';
270 let edgeConstraint = constraint;
271 let edgeWeight = weight;
272 const opposite = binarySerach(tuples, [to, from]);
273 const oppositeUnknown = opposite === 'UNKNOWN';
274 const oppositeSet = opposite !== undefined;
275 const oppositeVisible = oppositeSet && (showUnknown || !oppositeUnknown);
276 if (opposite === value) {
277 if (to < from) {
278 // We already added this edge in the reverse direction.
279 return;
280 }
281 if (to > from) {
282 dir = 'both';
283 }
284 } else if (oppositeVisible && to < from) {
285 // Let the opposite edge drive the graph layout.
286 edgeConstraint = 'false';
287 edgeWeight = 0;
288 } else if (isUnknown && (!oppositeSet || oppositeUnknown)) {
289 // Only apply the UNKNOWN value penalty if we aren't the opposite
290 // edge driving the graph layout from above, or the penalty would
291 // be applied anyway.
292 edgeWeight *= UNKNOWN_WEIGHT_FACTOR;
293 }
294
295 lines.push(`n${from} -> n${to} [
296 id="${fromNode.name},${toNode.name},${relation.name}",
297 dir="${dir}",
298 constraint=${edgeConstraint},
299 weight=${edgeWeight},
300 xlabel=${label},
301 penwidth=${penwidth},
302 arrowsize=${penwidth >= 2 ? 0.875 : 1},
303 style="${isUnknown ? 'dashed' : 'solid'}",
304 class="edge-${value}"
305 ]`);
306 });
307}
308
309function createEdges(graph: GraphStore, lines: string[]): void {
310 const {
311 semantics: { relations },
312 } = graph;
313 relations.forEach((relation) => {
314 if (relation.arity !== 2) {
315 return;
316 }
317 const visibility = graph.getVisibility(relation.name);
318 if (visibility !== 'none') {
319 createRelationEdges(graph, relation, visibility === 'all', lines);
320 }
321 });
322}
323
324export default function dotSource(
325 graph: GraphStore | undefined,
326): [string, number] | undefined {
327 if (graph === undefined) {
328 return undefined;
329 }
330 const lines = [
331 'digraph {',
332 'graph [bgcolor=transparent];',
333 `node [fontsize=12, shape=plain, fontname="OpenSans"];`,
334 'edge [fontsize=10.5, color=black, fontname="OpenSans"];',
335 ];
336 createNodes(graph, lines);
337 createEdges(graph, lines);
338 lines.push('}');
339 return [lines.join('\n'), lines.length];
340}
diff --git a/subprojects/frontend/src/graph/parseBBox.ts b/subprojects/frontend/src/graph/parseBBox.ts
new file mode 100644
index 00000000..34df746b
--- /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
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..a580f5c6
--- /dev/null
+++ b/subprojects/frontend/src/graph/postProcessSVG.ts
@@ -0,0 +1,186 @@
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';
10const XLINK_NS = 'http://www.w3.org/1999/xlink';
11
12function modifyAttribute(element: Element, attribute: string, change: number) {
13 const valueString = element.getAttribute(attribute);
14 if (valueString === null) {
15 return;
16 }
17 const value = parseInt(valueString, 10);
18 element.setAttribute(attribute, String(value + change));
19}
20
21function addShadow(
22 node: SVGGElement,
23 container: SVGRectElement,
24 offset: number,
25): void {
26 const shadow = container.cloneNode() as SVGRectElement;
27 // Leave space for 1pt stroke around the original container.
28 const offsetWithStroke = offset - 0.5;
29 modifyAttribute(shadow, 'x', offsetWithStroke);
30 modifyAttribute(shadow, 'y', offsetWithStroke);
31 modifyAttribute(shadow, 'width', 1);
32 modifyAttribute(shadow, 'height', 1);
33 modifyAttribute(shadow, 'rx', 0.5);
34 modifyAttribute(shadow, 'ry', 0.5);
35 shadow.setAttribute('class', 'node-shadow');
36 shadow.id = `${node.id},shadow`;
37 node.insertBefore(shadow, node.firstChild);
38}
39
40function clipCompartmentBackground(node: SVGGElement) {
41 // Background rectangle of the node created by the `<table bgcolor="white">`
42 // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`.
43 const container = node.querySelector<SVGRectElement>('rect[fill="white"]');
44 // Background rectangle of the lower compartment created by the `<td bgcolor="green">`
45 // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`.
46 // Since dot doesn't round the coners of `<td>` background,
47 // we have to clip it ourselves.
48 const compartment = node.querySelector<SVGRectElement>('rect[fill="green"]');
49 // Make sure we provide traceability with IDs also for the border.
50 const border = node.querySelector<SVGRectElement>('rect[stroke="black"]');
51 if (container === null || compartment === null || border === null) {
52 return;
53 }
54 const copyOfContainer = container.cloneNode() as SVGRectElement;
55 const clipPath = document.createElementNS(SVG_NS, 'clipPath');
56 const clipId = `${node.id},,clip`;
57 clipPath.setAttribute('id', clipId);
58 clipPath.appendChild(copyOfContainer);
59 node.appendChild(clipPath);
60 compartment.setAttribute('clip-path', `url(#${clipId})`);
61 // Enlarge the compartment to completely cover the background.
62 modifyAttribute(compartment, 'y', -5);
63 modifyAttribute(compartment, 'x', -5);
64 modifyAttribute(compartment, 'width', 10);
65 const isEmpty = node.classList.contains('node-empty');
66 // Make sure that empty nodes are fully filled.
67 modifyAttribute(compartment, 'height', isEmpty ? 10 : 5);
68 if (node.classList.contains('node-equalsSelf-UNKNOWN')) {
69 addShadow(node, container, 6);
70 }
71 container.id = `${node.id},container`;
72 compartment.id = `${node.id},compartment`;
73 border.id = `${node.id},border`;
74}
75
76function createRect(
77 { x, y, width, height }: BBox,
78 original: SVGElement,
79): SVGRectElement {
80 const rect = document.createElementNS(SVG_NS, 'rect');
81 rect.setAttribute('fill', original.getAttribute('fill') ?? '');
82 rect.setAttribute('stroke', original.getAttribute('stroke') ?? '');
83 rect.setAttribute('x', String(x));
84 rect.setAttribute('y', String(y));
85 rect.setAttribute('width', String(width));
86 rect.setAttribute('height', String(height));
87 return rect;
88}
89
90function optimizeNodeShapes(node: SVGGElement) {
91 node.querySelectorAll('path').forEach((path) => {
92 const bbox = parsePathBBox(path);
93 const rect = createRect(bbox, path);
94 rect.setAttribute('rx', '12');
95 rect.setAttribute('ry', '12');
96 path.parentNode?.replaceChild(rect, path);
97 });
98 node.querySelectorAll('polygon').forEach((polygon) => {
99 const bbox = parsePolygonBBox(polygon);
100 if (bbox.height === 0) {
101 const polyline = document.createElementNS(SVG_NS, 'polyline');
102 polyline.setAttribute('stroke', polygon.getAttribute('stroke') ?? '');
103 polyline.setAttribute(
104 'points',
105 `${bbox.x},${bbox.y} ${bbox.x + bbox.width},${bbox.y}`,
106 );
107 polygon.parentNode?.replaceChild(polyline, polygon);
108 } else {
109 const rect = createRect(bbox, polygon);
110 polygon.parentNode?.replaceChild(rect, polygon);
111 }
112 });
113 clipCompartmentBackground(node);
114}
115
116function hrefToClass(node: SVGGElement) {
117 node.querySelectorAll<SVGAElement>('a').forEach((a) => {
118 if (a.parentNode === null) {
119 return;
120 }
121 const href = a.getAttribute('href') ?? a.getAttributeNS(XLINK_NS, 'href');
122 if (href === 'undefined' || !href?.startsWith('#')) {
123 return;
124 }
125 while (a.lastChild !== null) {
126 const child = a.lastChild;
127 a.removeChild(child);
128 if (child.nodeType === Node.ELEMENT_NODE) {
129 const element = child as Element;
130 element.classList.add('label', `label-${href.replace('#', '')}`);
131 a.after(child);
132 }
133 }
134 a.parentNode.removeChild(a);
135 });
136}
137
138function replaceImages(node: SVGGElement) {
139 node.querySelectorAll<SVGImageElement>('image').forEach((image) => {
140 const href =
141 image.getAttribute('href') ?? image.getAttributeNS(XLINK_NS, 'href');
142 if (href === 'undefined' || !href?.startsWith('#')) {
143 return;
144 }
145 const width = image.getAttribute('width')?.replace('px', '') ?? '';
146 const height = image.getAttribute('height')?.replace('px', '') ?? '';
147 const foreign = document.createElementNS(SVG_NS, 'foreignObject');
148 foreign.setAttribute('x', image.getAttribute('x') ?? '');
149 foreign.setAttribute('y', image.getAttribute('y') ?? '');
150 foreign.setAttribute('width', width);
151 foreign.setAttribute('height', height);
152 const div = document.createElement('div');
153 div.classList.add('icon', `icon-${href.replace('#', '')}`);
154 foreign.appendChild(div);
155 const sibling = image.nextElementSibling;
156 // Since dot doesn't respect the `id` attribute on table cells with a single image,
157 // compute the ID based on the ID of the next element (the label).
158 if (
159 sibling !== null &&
160 sibling.tagName.toLowerCase() === 'g' &&
161 sibling.id !== ''
162 ) {
163 foreign.id = `${sibling.id},icon`;
164 }
165 image.parentNode?.replaceChild(foreign, image);
166 });
167}
168
169export default function postProcessSvg(svg: SVGSVGElement) {
170 // svg
171 // .querySelectorAll<SVGTitleElement>('title')
172 // .forEach((title) => title.parentElement?.removeChild(title));
173 svg.querySelectorAll<SVGGElement>('g.node').forEach((node) => {
174 optimizeNodeShapes(node);
175 hrefToClass(node);
176 replaceImages(node);
177 });
178 // Increase padding to fit box shadows for multi-objects.
179 const viewBox = [
180 svg.viewBox.baseVal.x - 6,
181 svg.viewBox.baseVal.y - 6,
182 svg.viewBox.baseVal.width + 12,
183 svg.viewBox.baseVal.height + 12,
184 ];
185 svg.setAttribute('viewBox', viewBox.join(' '));
186}