aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src')
-rw-r--r--subprojects/frontend/src/DirectionalSplitPane.tsx159
-rw-r--r--subprojects/frontend/src/PaneButtons.tsx144
-rw-r--r--subprojects/frontend/src/Refinery.tsx4
-rw-r--r--subprojects/frontend/src/TopBar.tsx85
-rw-r--r--subprojects/frontend/src/WorkArea.tsx33
-rw-r--r--subprojects/frontend/src/editor/AnalysisErrorNotification.tsx74
-rw-r--r--subprojects/frontend/src/editor/AnimatedButton.tsx9
-rw-r--r--subprojects/frontend/src/editor/DiagnosticValue.ts1
-rw-r--r--subprojects/frontend/src/editor/EditorButtons.tsx6
-rw-r--r--subprojects/frontend/src/editor/EditorErrors.tsx93
-rw-r--r--subprojects/frontend/src/editor/EditorPane.tsx4
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts47
-rw-r--r--subprojects/frontend/src/editor/EditorTheme.ts15
-rw-r--r--subprojects/frontend/src/editor/GenerateButton.tsx48
-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
-rw-r--r--subprojects/frontend/src/index.tsx29
-rw-r--r--subprojects/frontend/src/language/indentation.ts2
-rw-r--r--subprojects/frontend/src/table/RelationGrid.tsx109
-rw-r--r--subprojects/frontend/src/table/SymbolSelector.tsx65
-rw-r--r--subprojects/frontend/src/table/TableArea.tsx24
-rw-r--r--subprojects/frontend/src/table/TablePane.tsx22
-rw-r--r--subprojects/frontend/src/table/TableToolbar.tsx41
-rw-r--r--subprojects/frontend/src/table/ValueRenderer.tsx62
-rw-r--r--subprojects/frontend/src/theme/ThemeProvider.tsx14
-rw-r--r--subprojects/frontend/src/theme/ThemeStore.ts48
-rw-r--r--subprojects/frontend/src/utils/svgURL.ts9
-rw-r--r--subprojects/frontend/src/xtext/BackendConfig.ts2
-rw-r--r--subprojects/frontend/src/xtext/ContentAssistService.ts18
-rw-r--r--subprojects/frontend/src/xtext/SemanticsService.ts32
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts2
-rw-r--r--subprojects/frontend/src/xtext/ValidationService.ts44
-rw-r--r--subprojects/frontend/src/xtext/XtextClient.ts13
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts5
-rw-r--r--subprojects/frontend/src/xtext/xtextMessages.ts6
-rw-r--r--subprojects/frontend/src/xtext/xtextServiceResults.ts46
47 files changed, 3084 insertions, 127 deletions
diff --git a/subprojects/frontend/src/DirectionalSplitPane.tsx b/subprojects/frontend/src/DirectionalSplitPane.tsx
new file mode 100644
index 00000000..59c8b739
--- /dev/null
+++ b/subprojects/frontend/src/DirectionalSplitPane.tsx
@@ -0,0 +1,159 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
8import MoreVertIcon from '@mui/icons-material/MoreVert';
9import Box from '@mui/material/Box';
10import Stack from '@mui/material/Stack';
11import { alpha, useTheme } from '@mui/material/styles';
12import { useCallback, useRef, useState } from 'react';
13import { useResizeDetector } from 'react-resize-detector';
14
15export default function DirectionalSplitPane({
16 primary: left,
17 secondary: right,
18 primaryOnly: showLeftOnly,
19 secondaryOnly: showRightOnly,
20}: {
21 primary: React.ReactNode;
22 secondary: React.ReactNode;
23 primaryOnly?: boolean;
24 secondaryOnly?: boolean;
25}): JSX.Element {
26 const theme = useTheme();
27 const stackRef = useRef<HTMLDivElement | null>(null);
28 const { ref: resizeRef, width, height } = useResizeDetector();
29 const sliderRef = useRef<HTMLDivElement>(null);
30 const [resizing, setResizing] = useState(false);
31 const [fraction, setFraction] = useState(0.5);
32
33 const horizontalSplit =
34 width !== undefined && height !== undefined && height > width;
35 const direction = horizontalSplit ? 'column' : 'row';
36 const axis = horizontalSplit ? 'height' : 'width';
37 const primarySize = showLeftOnly
38 ? '100%'
39 : `calc(${fraction * 100}% - 0.5px)`;
40 const secondarySize = showRightOnly
41 ? '100%'
42 : `calc(${(1 - fraction) * 100}% - 0.5px)`;
43 const ref = useCallback(
44 (element: HTMLDivElement | null) => {
45 resizeRef(element);
46 stackRef.current = element;
47 },
48 [resizeRef],
49 );
50
51 return (
52 <Stack
53 direction={direction}
54 height="100%"
55 width="100%"
56 overflow="hidden"
57 ref={ref}
58 >
59 {!showRightOnly && <Box {...{ [axis]: primarySize }}>{left}</Box>}
60 <Box
61 sx={{
62 overflow: 'visible',
63 position: 'relative',
64 [axis]: '0px',
65 display: showLeftOnly || showRightOnly ? 'none' : 'flex',
66 flexDirection: direction,
67 [horizontalSplit
68 ? 'borderBottom'
69 : 'borderRight']: `1px solid ${theme.palette.outer.border}`,
70 }}
71 >
72 <Box
73 ref={sliderRef}
74 sx={{
75 display: 'flex',
76 position: 'absolute',
77 [axis]: theme.spacing(2),
78 ...(horizontalSplit
79 ? {
80 top: theme.spacing(-1),
81 left: 0,
82 right: 0,
83 transform: 'translateY(0.5px)',
84 }
85 : {
86 left: theme.spacing(-1),
87 top: 0,
88 bottom: 0,
89 transform: 'translateX(0.5px)',
90 }),
91 zIndex: 999,
92 alignItems: 'center',
93 justifyContent: 'center',
94 color: theme.palette.text.secondary,
95 cursor: horizontalSplit ? 'ns-resize' : 'ew-resize',
96 '.MuiSvgIcon-root': {
97 opacity: resizing ? 1 : 0,
98 },
99 ...(resizing
100 ? {
101 background: alpha(
102 theme.palette.text.primary,
103 theme.palette.action.activatedOpacity,
104 ),
105 }
106 : {
107 '&:hover': {
108 background: alpha(
109 theme.palette.text.primary,
110 theme.palette.action.hoverOpacity,
111 ),
112 '.MuiSvgIcon-root': {
113 opacity: 1,
114 },
115 },
116 }),
117 }}
118 onPointerDown={(event) => {
119 if (event.button !== 0) {
120 return;
121 }
122 sliderRef.current?.setPointerCapture(event.pointerId);
123 setResizing(true);
124 }}
125 onPointerUp={(event) => {
126 if (event.button !== 0) {
127 return;
128 }
129 sliderRef.current?.releasePointerCapture(event.pointerId);
130 setResizing(false);
131 }}
132 onPointerMove={(event) => {
133 if (!resizing) {
134 return;
135 }
136 const container = stackRef.current;
137 if (container === null) {
138 return;
139 }
140 const rect = container.getBoundingClientRect();
141 const newFraction = horizontalSplit
142 ? (event.clientY - rect.top) / rect.height
143 : (event.clientX - rect.left) / rect.width;
144 setFraction(Math.min(0.9, Math.max(0.1, newFraction)));
145 }}
146 onDoubleClick={() => setFraction(0.5)}
147 >
148 {horizontalSplit ? <MoreHorizIcon /> : <MoreVertIcon />}
149 </Box>
150 </Box>
151 {!showLeftOnly && <Box {...{ [axis]: secondarySize }}>{right}</Box>}
152 </Stack>
153 );
154}
155
156DirectionalSplitPane.defaultProps = {
157 primaryOnly: false,
158 secondaryOnly: false,
159};
diff --git a/subprojects/frontend/src/PaneButtons.tsx b/subprojects/frontend/src/PaneButtons.tsx
new file mode 100644
index 00000000..7e884ab0
--- /dev/null
+++ b/subprojects/frontend/src/PaneButtons.tsx
@@ -0,0 +1,144 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import CodeIcon from '@mui/icons-material/Code';
8import SchemaRoundedIcon from '@mui/icons-material/SchemaRounded';
9import TableChartIcon from '@mui/icons-material/TableChart';
10import ToggleButton from '@mui/material/ToggleButton';
11import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
12import { alpha, styled } from '@mui/material/styles';
13import { observer } from 'mobx-react-lite';
14
15import type ThemeStore from './theme/ThemeStore';
16
17const PaneButtonGroup = styled(ToggleButtonGroup, {
18 name: 'PaneButtons-Group',
19 shouldForwardProp: (prop) => prop !== 'hideLabel',
20})<{ hideLabel: boolean }>(({ theme, hideLabel }) => {
21 const color =
22 theme.palette.mode === 'dark'
23 ? theme.palette.primary.main
24 : theme.palette.text.primary;
25 return {
26 gap: theme.spacing(1),
27 '.MuiToggleButton-root': {
28 fontSize: '1rem',
29 lineHeight: '1.5',
30 border: 'none',
31 ...(hideLabel ? {} : { paddingBlock: 6 }),
32 '&::before': {
33 content: '" "',
34 position: 'absolute',
35 bottom: 0,
36 left: 0,
37 width: '0%',
38 height: '2px',
39 background: color,
40 transition: theme.transitions.create('width', {
41 duration: theme.transitions.duration.standard,
42 }),
43 },
44 '&.MuiToggleButtonGroup-grouped': {
45 borderTopLeftRadius: theme.shape.borderRadius,
46 borderTopRightRadius: theme.shape.borderRadius,
47 borderBottomLeftRadius: 0,
48 borderBottomRightRadius: 0,
49 },
50 '&:not(.Mui-selected)': {
51 color: theme.palette.text.secondary,
52 },
53 '&.Mui-selected': {
54 color,
55 '&::before': {
56 width: '100%',
57 },
58 '&:not(:active)': {
59 background: 'transparent',
60 },
61 '&:hover': {
62 background: alpha(
63 theme.palette.text.primary,
64 theme.palette.action.hoverOpacity,
65 ),
66 '@media (hover: none)': {
67 background: 'transparent',
68 },
69 },
70 },
71 },
72 ...(hideLabel
73 ? {}
74 : {
75 '& svg': {
76 margin: '0 6px 0 -4px',
77 },
78 }),
79 };
80});
81
82function PaneButtons({
83 themeStore,
84 hideLabel,
85}: {
86 themeStore: ThemeStore;
87 hideLabel?: boolean;
88}): JSX.Element {
89 return (
90 <PaneButtonGroup
91 size={hideLabel ? 'small' : 'medium'}
92 hideLabel={hideLabel ?? PaneButtons.defaultProps.hideLabel}
93 >
94 <ToggleButton
95 value="code"
96 selected={themeStore.showCode}
97 onClick={(event) => {
98 if (event.shiftKey || event.ctrlKey) {
99 themeStore.setSelectedPane('code');
100 } else {
101 themeStore.toggleCode();
102 }
103 }}
104 >
105 <CodeIcon fontSize="small" />
106 {!hideLabel && 'Code'}
107 </ToggleButton>
108 <ToggleButton
109 value="graph"
110 selected={themeStore.showGraph}
111 onClick={(event) => {
112 if (event.shiftKey || event.ctrlKey) {
113 themeStore.setSelectedPane('graph', event.shiftKey);
114 } else {
115 themeStore.toggleGraph();
116 }
117 }}
118 >
119 <SchemaRoundedIcon fontSize="small" />
120 {!hideLabel && 'Graph'}
121 </ToggleButton>
122 <ToggleButton
123 value="table"
124 selected={themeStore.showTable}
125 onClick={(event) => {
126 if (event.shiftKey || event.ctrlKey) {
127 themeStore.setSelectedPane('table', event.shiftKey);
128 } else {
129 themeStore.toggleTable();
130 }
131 }}
132 >
133 <TableChartIcon fontSize="small" />
134 {!hideLabel && 'Table'}
135 </ToggleButton>
136 </PaneButtonGroup>
137 );
138}
139
140PaneButtons.defaultProps = {
141 hideLabel: false,
142};
143
144export default observer(PaneButtons);
diff --git a/subprojects/frontend/src/Refinery.tsx b/subprojects/frontend/src/Refinery.tsx
index b5ff94e1..5ad16000 100644
--- a/subprojects/frontend/src/Refinery.tsx
+++ b/subprojects/frontend/src/Refinery.tsx
@@ -10,7 +10,7 @@ import { SnackbarProvider } from 'notistack';
10 10
11import TopBar from './TopBar'; 11import TopBar from './TopBar';
12import UpdateNotification from './UpdateNotification'; 12import UpdateNotification from './UpdateNotification';
13import EditorPane from './editor/EditorPane'; 13import WorkArea from './WorkArea';
14 14
15export default function Refinery(): JSX.Element { 15export default function Refinery(): JSX.Element {
16 return ( 16 return (
@@ -18,7 +18,7 @@ export default function Refinery(): JSX.Element {
18 <UpdateNotification /> 18 <UpdateNotification />
19 <Stack direction="column" height="100%" overflow="auto"> 19 <Stack direction="column" height="100%" overflow="auto">
20 <TopBar /> 20 <TopBar />
21 <EditorPane /> 21 <WorkArea />
22 </Stack> 22 </Stack>
23 </SnackbarProvider> 23 </SnackbarProvider>
24 ); 24 );
diff --git a/subprojects/frontend/src/TopBar.tsx b/subprojects/frontend/src/TopBar.tsx
index f2542b14..867a24a0 100644
--- a/subprojects/frontend/src/TopBar.tsx
+++ b/subprojects/frontend/src/TopBar.tsx
@@ -6,7 +6,6 @@
6 6
7import GitHubIcon from '@mui/icons-material/GitHub'; 7import GitHubIcon from '@mui/icons-material/GitHub';
8import AppBar from '@mui/material/AppBar'; 8import AppBar from '@mui/material/AppBar';
9import Button from '@mui/material/Button';
10import IconButton from '@mui/material/IconButton'; 9import IconButton from '@mui/material/IconButton';
11import Stack from '@mui/material/Stack'; 10import Stack from '@mui/material/Stack';
12import Toolbar from '@mui/material/Toolbar'; 11import Toolbar from '@mui/material/Toolbar';
@@ -17,6 +16,7 @@ import { throttle } from 'lodash-es';
17import { observer } from 'mobx-react-lite'; 16import { observer } from 'mobx-react-lite';
18import { useEffect, useMemo, useState } from 'react'; 17import { useEffect, useMemo, useState } from 'react';
19 18
19import PaneButtons from './PaneButtons';
20import { useRootStore } from './RootStoreProvider'; 20import { useRootStore } from './RootStoreProvider';
21import ToggleDarkModeButton from './ToggleDarkModeButton'; 21import ToggleDarkModeButton from './ToggleDarkModeButton';
22import GenerateButton from './editor/GenerateButton'; 22import GenerateButton from './editor/GenerateButton';
@@ -65,11 +65,12 @@ const DevModeBadge = styled('div')(({ theme }) => ({
65})); 65}));
66 66
67export default observer(function TopBar(): JSX.Element { 67export default observer(function TopBar(): JSX.Element {
68 const { editorStore } = useRootStore(); 68 const { editorStore, themeStore } = useRootStore();
69 const overlayVisible = useWindowControlsOverlayVisible(); 69 const overlayVisible = useWindowControlsOverlayVisible();
70 const { breakpoints } = useTheme(); 70 const { breakpoints } = useTheme();
71 const small = useMediaQuery(breakpoints.down('sm')); 71 const medium = useMediaQuery(breakpoints.up('sm'));
72 const large = useMediaQuery(breakpoints.up('md')); 72 const large = useMediaQuery(breakpoints.up('md'));
73 const veryLarge = useMediaQuery(breakpoints.up('lg'));
73 74
74 return ( 75 return (
75 <AppBar 76 <AppBar
@@ -100,50 +101,46 @@ export default observer(function TopBar(): JSX.Element {
100 py: 0.5, 101 py: 0.5,
101 }} 102 }}
102 > 103 >
103 <Typography variant="h6" component="h1" flexGrow={1}> 104 <Typography variant="h6" component="h1">
104 Refinery {import.meta.env.DEV && <DevModeBadge>Dev</DevModeBadge>} 105 Refinery {import.meta.env.DEV && <DevModeBadge>Dev</DevModeBadge>}
105 </Typography> 106 </Typography>
106 <Stack direction="row" marginRight={1}> 107 <Stack direction="row" alignItems="center" flexGrow={1} marginLeft={1}>
107 <GenerateButton editorStore={editorStore} hideWarnings={small} /> 108 {medium && !large && (
109 <PaneButtons themeStore={themeStore} hideLabel />
110 )}
111 </Stack>
112 {large && (
113 <Stack
114 direction="row"
115 alignItems="center"
116 sx={{
117 position: 'absolute',
118 top: 0,
119 bottom: 0,
120 left: '50%',
121 transform: 'translateX(-50%)',
122 }}
123 >
124 <PaneButtons themeStore={themeStore} />
125 </Stack>
126 )}
127 <Stack
128 direction="row"
129 marginLeft={1}
130 marginRight={1}
131 gap={1}
132 alignItems="center"
133 >
134 <GenerateButton editorStore={editorStore} hideWarnings={!veryLarge} />
108 {large && ( 135 {large && (
109 <> 136 <IconButton
110 <Button 137 aria-label="GitHub"
111 arial-label="Budapest University of Technology and Economics, Critical Systems Research Group" 138 href="https://github.com/graphs4value/refinery"
112 className="rounded" 139 target="_blank"
113 color="inherit" 140 color="inherit"
114 href="https://ftsrg.mit.bme.hu" 141 >
115 target="_blank" 142 <GitHubIcon />
116 sx={{ marginLeft: 1 }} 143 </IconButton>
117 >
118 BME FTSRG
119 </Button>
120 <Button
121 aria-label="McGill University, Department of Electrical and Computer Engineering"
122 className="rounded"
123 color="inherit"
124 href="https://www.mcgill.ca/ece/daniel-varro"
125 target="_blank"
126 >
127 McGill ECE
128 </Button>
129 <Button
130 aria-label="2022 Amazon Research Awards recipent"
131 className="rounded"
132 color="inherit"
133 href="https://www.amazon.science/research-awards/recipients/daniel-varro-fall-2021"
134 target="_blank"
135 >
136 Amazon Science
137 </Button>
138 <IconButton
139 aria-label="GitHub"
140 href="https://github.com/graphs4value/refinery"
141 target="_blank"
142 color="inherit"
143 >
144 <GitHubIcon />
145 </IconButton>
146 </>
147 )} 144 )}
148 </Stack> 145 </Stack>
149 <ToggleDarkModeButton /> 146 <ToggleDarkModeButton />
diff --git a/subprojects/frontend/src/WorkArea.tsx b/subprojects/frontend/src/WorkArea.tsx
new file mode 100644
index 00000000..adb29a50
--- /dev/null
+++ b/subprojects/frontend/src/WorkArea.tsx
@@ -0,0 +1,33 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { observer } from 'mobx-react-lite';
8
9import DirectionalSplitPane from './DirectionalSplitPane';
10import { useRootStore } from './RootStoreProvider';
11import EditorPane from './editor/EditorPane';
12import GraphPane from './graph/GraphPane';
13import TablePane from './table/TablePane';
14
15export default observer(function WorkArea(): JSX.Element {
16 const { themeStore } = useRootStore();
17
18 return (
19 <DirectionalSplitPane
20 primary={<EditorPane />}
21 secondary={
22 <DirectionalSplitPane
23 primary={<GraphPane />}
24 secondary={<TablePane />}
25 primaryOnly={!themeStore.showTable}
26 secondaryOnly={!themeStore.showGraph}
27 />
28 }
29 primaryOnly={!themeStore.showGraph && !themeStore.showTable}
30 secondaryOnly={!themeStore.showCode}
31 />
32 );
33});
diff --git a/subprojects/frontend/src/editor/AnalysisErrorNotification.tsx b/subprojects/frontend/src/editor/AnalysisErrorNotification.tsx
new file mode 100644
index 00000000..591a3600
--- /dev/null
+++ b/subprojects/frontend/src/editor/AnalysisErrorNotification.tsx
@@ -0,0 +1,74 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { reaction } from 'mobx';
8import { type SnackbarKey, useSnackbar } from 'notistack';
9import { useEffect, useState } from 'react';
10
11import type EditorStore from './EditorStore';
12
13function MessageObserver({
14 editorStore,
15}: {
16 editorStore: EditorStore;
17}): React.ReactNode {
18 const [message, setMessage] = useState(
19 editorStore.delayedErrors.semanticsError ?? '',
20 );
21 // Instead of making this component an `observer`,
22 // we only update the message is one is present to make sure that the
23 // disappear animation has a chance to complete.
24 useEffect(
25 () =>
26 reaction(
27 () => editorStore.delayedErrors.semanticsError,
28 (newMessage) => {
29 if (newMessage !== undefined) {
30 setMessage(newMessage);
31 }
32 },
33 { fireImmediately: false },
34 ),
35 [editorStore],
36 );
37 return message;
38}
39
40export default function AnalysisErrorNotification({
41 editorStore,
42}: {
43 editorStore: EditorStore;
44}): null {
45 const { enqueueSnackbar, closeSnackbar } = useSnackbar();
46 useEffect(() => {
47 let key: SnackbarKey | undefined;
48 const disposer = reaction(
49 () => editorStore.delayedErrors.semanticsError !== undefined,
50 (hasError) => {
51 if (hasError) {
52 if (key === undefined) {
53 key = enqueueSnackbar({
54 message: <MessageObserver editorStore={editorStore} />,
55 variant: 'error',
56 persist: true,
57 });
58 }
59 } else if (key !== undefined) {
60 closeSnackbar(key);
61 key = undefined;
62 }
63 },
64 { fireImmediately: true },
65 );
66 return () => {
67 disposer();
68 if (key !== undefined) {
69 closeSnackbar(key);
70 }
71 };
72 }, [editorStore, enqueueSnackbar, closeSnackbar]);
73 return null;
74}
diff --git a/subprojects/frontend/src/editor/AnimatedButton.tsx b/subprojects/frontend/src/editor/AnimatedButton.tsx
index dbbda618..24ec69be 100644
--- a/subprojects/frontend/src/editor/AnimatedButton.tsx
+++ b/subprojects/frontend/src/editor/AnimatedButton.tsx
@@ -48,7 +48,7 @@ export default function AnimatedButton({
48 onClick?: () => void; 48 onClick?: () => void;
49 color: 'error' | 'warning' | 'primary' | 'inherit'; 49 color: 'error' | 'warning' | 'primary' | 'inherit';
50 disabled?: boolean; 50 disabled?: boolean;
51 startIcon: JSX.Element; 51 startIcon?: JSX.Element;
52 sx?: SxProps<Theme> | undefined; 52 sx?: SxProps<Theme> | undefined;
53 children?: ReactNode; 53 children?: ReactNode;
54}): JSX.Element { 54}): JSX.Element {
@@ -79,7 +79,11 @@ export default function AnimatedButton({
79 className="rounded shaded" 79 className="rounded shaded"
80 disabled={disabled ?? false} 80 disabled={disabled ?? false}
81 startIcon={startIcon} 81 startIcon={startIcon}
82 width={width === undefined ? 'auto' : `calc(${width} + 50px)`} 82 width={
83 width === undefined
84 ? 'auto'
85 : `calc(${width} + ${startIcon === undefined ? 28 : 50}px)`
86 }
83 > 87 >
84 <Box 88 <Box
85 display="flex" 89 display="flex"
@@ -100,6 +104,7 @@ AnimatedButton.defaultProps = {
100 'aria-label': undefined, 104 'aria-label': undefined,
101 onClick: undefined, 105 onClick: undefined,
102 disabled: false, 106 disabled: false,
107 startIcon: undefined,
103 sx: undefined, 108 sx: undefined,
104 children: undefined, 109 children: undefined,
105}; 110};
diff --git a/subprojects/frontend/src/editor/DiagnosticValue.ts b/subprojects/frontend/src/editor/DiagnosticValue.ts
index 20478262..410a46b7 100644
--- a/subprojects/frontend/src/editor/DiagnosticValue.ts
+++ b/subprojects/frontend/src/editor/DiagnosticValue.ts
@@ -14,6 +14,7 @@ export default class DiagnosticValue extends RangeValue {
14 error: new DiagnosticValue('error'), 14 error: new DiagnosticValue('error'),
15 warning: new DiagnosticValue('warning'), 15 warning: new DiagnosticValue('warning'),
16 info: new DiagnosticValue('info'), 16 info: new DiagnosticValue('info'),
17 hint: new DiagnosticValue('hint'),
17 }; 18 };
18 19
19 private constructor(public readonly severity: Severity) { 20 private constructor(public readonly severity: Severity) {
diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx
index 9b187e5c..ca51f975 100644
--- a/subprojects/frontend/src/editor/EditorButtons.tsx
+++ b/subprojects/frontend/src/editor/EditorButtons.tsx
@@ -5,8 +5,8 @@
5 */ 5 */
6 6
7import type { Diagnostic } from '@codemirror/lint'; 7import type { Diagnostic } from '@codemirror/lint';
8import CancelIcon from '@mui/icons-material/Cancel';
8import CheckIcon from '@mui/icons-material/Check'; 9import CheckIcon from '@mui/icons-material/Check';
9import ErrorIcon from '@mui/icons-material/Error';
10import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; 10import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
11import FormatPaint from '@mui/icons-material/FormatPaint'; 11import FormatPaint from '@mui/icons-material/FormatPaint';
12import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; 12import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
@@ -28,7 +28,7 @@ import type EditorStore from './EditorStore';
28function getLintIcon(severity: Diagnostic['severity'] | undefined) { 28function getLintIcon(severity: Diagnostic['severity'] | undefined) {
29 switch (severity) { 29 switch (severity) {
30 case 'error': 30 case 'error':
31 return <ErrorIcon fontSize="small" />; 31 return <CancelIcon fontSize="small" />;
32 case 'warning': 32 case 'warning':
33 return <WarningIcon fontSize="small" />; 33 return <WarningIcon fontSize="small" />;
34 case 'info': 34 case 'info':
@@ -95,7 +95,7 @@ export default observer(function EditorButtons({
95 })} 95 })}
96 value="show-lint-panel" 96 value="show-lint-panel"
97 > 97 >
98 {getLintIcon(editorStore?.highestDiagnosticLevel)} 98 {getLintIcon(editorStore?.delayedErrors?.highestDiagnosticLevel)}
99 </ToggleButton> 99 </ToggleButton>
100 </ToggleButtonGroup> 100 </ToggleButtonGroup>
101 <IconButton 101 <IconButton
diff --git a/subprojects/frontend/src/editor/EditorErrors.tsx b/subprojects/frontend/src/editor/EditorErrors.tsx
new file mode 100644
index 00000000..40becf7e
--- /dev/null
+++ b/subprojects/frontend/src/editor/EditorErrors.tsx
@@ -0,0 +1,93 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { Diagnostic } from '@codemirror/lint';
8import { type IReactionDisposer, makeAutoObservable, reaction } from 'mobx';
9
10import type EditorStore from './EditorStore';
11
12const HYSTERESIS_TIME_MS = 250;
13
14export interface State {
15 analyzing: boolean;
16 errorCount: number;
17 warningCount: number;
18 infoCount: number;
19 semanticsError: string | undefined;
20}
21
22export default class EditorErrors implements State {
23 private readonly disposer: IReactionDisposer;
24
25 private timer: number | undefined;
26
27 analyzing = false;
28
29 errorCount = 0;
30
31 warningCount = 0;
32
33 infoCount = 0;
34
35 semanticsError: string | undefined;
36
37 constructor(private readonly store: EditorStore) {
38 this.updateImmediately(this.getNextState());
39 makeAutoObservable<EditorErrors, 'disposer' | 'timer'>(this, {
40 disposer: false,
41 timer: false,
42 });
43 this.disposer = reaction(
44 () => this.getNextState(),
45 (nextState) => {
46 if (this.timer !== undefined) {
47 clearTimeout(this.timer);
48 this.timer = undefined;
49 }
50 if (nextState.analyzing) {
51 this.timer = setTimeout(
52 () => this.updateImmediately(nextState),
53 HYSTERESIS_TIME_MS,
54 );
55 } else {
56 this.updateImmediately(nextState);
57 }
58 },
59 { fireImmediately: true },
60 );
61 }
62
63 get highestDiagnosticLevel(): Diagnostic['severity'] | undefined {
64 if (this.errorCount > 0) {
65 return 'error';
66 }
67 if (this.warningCount > 0) {
68 return 'warning';
69 }
70 if (this.infoCount > 0) {
71 return 'info';
72 }
73 return undefined;
74 }
75
76 private getNextState(): State {
77 return {
78 analyzing: this.store.analyzing,
79 errorCount: this.store.errorCount,
80 warningCount: this.store.warningCount,
81 infoCount: this.store.infoCount,
82 semanticsError: this.store.semanticsError,
83 };
84 }
85
86 private updateImmediately(nextState: State) {
87 Object.assign(this, nextState);
88 }
89
90 dispose() {
91 this.disposer();
92 }
93}
diff --git a/subprojects/frontend/src/editor/EditorPane.tsx b/subprojects/frontend/src/editor/EditorPane.tsx
index 87f408fe..1125a0ec 100644
--- a/subprojects/frontend/src/editor/EditorPane.tsx
+++ b/subprojects/frontend/src/editor/EditorPane.tsx
@@ -13,6 +13,7 @@ import { useState } from 'react';
13 13
14import { useRootStore } from '../RootStoreProvider'; 14import { useRootStore } from '../RootStoreProvider';
15 15
16import AnalysisErrorNotification from './AnalysisErrorNotification';
16import ConnectionStatusNotification from './ConnectionStatusNotification'; 17import ConnectionStatusNotification from './ConnectionStatusNotification';
17import EditorArea from './EditorArea'; 18import EditorArea from './EditorArea';
18import EditorButtons from './EditorButtons'; 19import EditorButtons from './EditorButtons';
@@ -39,7 +40,7 @@ export default observer(function EditorPane(): JSX.Element {
39 const { editorStore } = useRootStore(); 40 const { editorStore } = useRootStore();
40 41
41 return ( 42 return (
42 <Stack direction="column" flexGrow={1} flexShrink={1} overflow="auto"> 43 <Stack direction="column" height="100%" overflow="auto">
43 <Toolbar variant="dense"> 44 <Toolbar variant="dense">
44 <EditorButtons editorStore={editorStore} /> 45 <EditorButtons editorStore={editorStore} />
45 </Toolbar> 46 </Toolbar>
@@ -48,6 +49,7 @@ export default observer(function EditorPane(): JSX.Element {
48 <EditorLoading /> 49 <EditorLoading />
49 ) : ( 50 ) : (
50 <> 51 <>
52 <AnalysisErrorNotification editorStore={editorStore} />
51 <ConnectionStatusNotification editorStore={editorStore} /> 53 <ConnectionStatusNotification editorStore={editorStore} />
52 <SearchPanelPortal editorStore={editorStore} /> 54 <SearchPanelPortal editorStore={editorStore} />
53 <EditorArea editorStore={editorStore} /> 55 <EditorArea editorStore={editorStore} />
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index b98f085e..b5989ad1 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -26,9 +26,12 @@ import { makeAutoObservable, observable, runInAction } from 'mobx';
26import { nanoid } from 'nanoid'; 26import { nanoid } from 'nanoid';
27 27
28import type PWAStore from '../PWAStore'; 28import type PWAStore from '../PWAStore';
29import GraphStore from '../graph/GraphStore';
29import getLogger from '../utils/getLogger'; 30import getLogger from '../utils/getLogger';
30import type XtextClient from '../xtext/XtextClient'; 31import type XtextClient from '../xtext/XtextClient';
32import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
31 33
34import EditorErrors from './EditorErrors';
32import LintPanelStore from './LintPanelStore'; 35import LintPanelStore from './LintPanelStore';
33import SearchPanelStore from './SearchPanelStore'; 36import SearchPanelStore from './SearchPanelStore';
34import createEditorState from './createEditorState'; 37import createEditorState from './createEditorState';
@@ -54,13 +57,22 @@ export default class EditorStore {
54 57
55 readonly lintPanel: LintPanelStore; 58 readonly lintPanel: LintPanelStore;
56 59
60 readonly delayedErrors: EditorErrors;
61
57 showLineNumbers = false; 62 showLineNumbers = false;
58 63
59 disposed = false; 64 disposed = false;
60 65
66 analyzing = false;
67
68 semanticsError: string | undefined;
69
70 graph: GraphStore;
71
61 constructor(initialValue: string, pwaStore: PWAStore) { 72 constructor(initialValue: string, pwaStore: PWAStore) {
62 this.id = nanoid(); 73 this.id = nanoid();
63 this.state = createEditorState(initialValue, this); 74 this.state = createEditorState(initialValue, this);
75 this.delayedErrors = new EditorErrors(this);
64 this.searchPanel = new SearchPanelStore(this); 76 this.searchPanel = new SearchPanelStore(this);
65 this.lintPanel = new LintPanelStore(this); 77 this.lintPanel = new LintPanelStore(this);
66 (async () => { 78 (async () => {
@@ -75,6 +87,7 @@ export default class EditorStore {
75 })().catch((error) => { 87 })().catch((error) => {
76 log.error('Failed to load XtextClient', error); 88 log.error('Failed to load XtextClient', error);
77 }); 89 });
90 this.graph = new GraphStore();
78 makeAutoObservable<EditorStore, 'client'>(this, { 91 makeAutoObservable<EditorStore, 'client'>(this, {
79 id: false, 92 id: false,
80 state: observable.ref, 93 state: observable.ref,
@@ -213,19 +226,6 @@ export default class EditorStore {
213 this.doCommand(nextDiagnostic); 226 this.doCommand(nextDiagnostic);
214 } 227 }
215 228
216 get highestDiagnosticLevel(): Diagnostic['severity'] | undefined {
217 if (this.errorCount > 0) {
218 return 'error';
219 }
220 if (this.warningCount > 0) {
221 return 'warning';
222 }
223 if (this.infoCount > 0) {
224 return 'info';
225 }
226 return undefined;
227 }
228
229 updateSemanticHighlighting(ranges: IHighlightRange[]): void { 229 updateSemanticHighlighting(ranges: IHighlightRange[]): void {
230 this.dispatch(setSemanticHighlighting(ranges)); 230 this.dispatch(setSemanticHighlighting(ranges));
231 } 231 }
@@ -282,8 +282,29 @@ export default class EditorStore {
282 return true; 282 return true;
283 } 283 }
284 284
285 analysisStarted() {
286 this.analyzing = true;
287 }
288
289 analysisCompleted(semanticAnalysisSkipped = false) {
290 this.analyzing = false;
291 if (semanticAnalysisSkipped) {
292 this.semanticsError = undefined;
293 }
294 }
295
296 setSemanticsError(semanticsError: string) {
297 this.semanticsError = semanticsError;
298 }
299
300 setSemantics(semantics: SemanticsSuccessResult) {
301 this.semanticsError = undefined;
302 this.graph.setSemantics(semantics);
303 }
304
285 dispose(): void { 305 dispose(): void {
286 this.client?.dispose(); 306 this.client?.dispose();
307 this.delayedErrors.dispose();
287 this.disposed = true; 308 this.disposed = true;
288 } 309 }
289} 310}
diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts
index e057ce18..055b62e2 100644
--- a/subprojects/frontend/src/editor/EditorTheme.ts
+++ b/subprojects/frontend/src/editor/EditorTheme.ts
@@ -4,15 +4,13 @@
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
6 6
7import errorSVG from '@material-icons/svg/svg/error/baseline.svg?raw'; 7import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw';
8import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw'; 8import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw';
9import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; 9import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw';
10import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; 10import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw';
11import { alpha, styled, type CSSObject } from '@mui/material/styles'; 11import { alpha, styled, type CSSObject } from '@mui/material/styles';
12 12
13function svgURL(svg: string): string { 13import svgURL from '../utils/svgURL';
14 return `url('data:image/svg+xml;utf8,${svg}')`;
15}
16 14
17export default styled('div', { 15export default styled('div', {
18 name: 'EditorTheme', 16 name: 'EditorTheme',
@@ -56,15 +54,16 @@ export default styled('div', {
56 '.cm-activeLineGutter': { 54 '.cm-activeLineGutter': {
57 background: 'transparent', 55 background: 'transparent',
58 }, 56 },
59 '.cm-cursor, .cm-cursor-primary': { 57 '.cm-cursor, .cm-dropCursor, .cm-cursor-primary': {
60 borderLeft: `2px solid ${theme.palette.info.main}`, 58 borderLeft: `2px solid ${theme.palette.info.main}`,
59 marginLeft: -1,
61 }, 60 },
62 '.cm-selectionBackground': { 61 '.cm-selectionBackground': {
63 background: theme.palette.highlight.selection, 62 background: theme.palette.highlight.selection,
64 }, 63 },
65 '.cm-focused': { 64 '.cm-focused': {
66 outline: 'none', 65 outline: 'none',
67 '.cm-selectionBackground': { 66 '& > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
68 background: theme.palette.highlight.selection, 67 background: theme.palette.highlight.selection,
69 }, 68 },
70 }, 69 },
@@ -106,7 +105,7 @@ export default styled('div', {
106 color: theme.palette.text.primary, 105 color: theme.palette.text.primary,
107 }, 106 },
108 }, 107 },
109 '.tok-problem-abstract, .tok-problem-new': { 108 '.tok-problem-abstract': {
110 fontStyle: 'italic', 109 fontStyle: 'italic',
111 }, 110 },
112 '.tok-problem-containment': { 111 '.tok-problem-containment': {
@@ -331,7 +330,7 @@ export default styled('div', {
331 '.cm-lintRange-active': { 330 '.cm-lintRange-active': {
332 background: theme.palette.highlight.activeLintRange, 331 background: theme.palette.highlight.activeLintRange,
333 }, 332 },
334 ...lintSeverityStyle('error', errorSVG, 120), 333 ...lintSeverityStyle('error', cancelSVG, 120),
335 ...lintSeverityStyle('warning', warningSVG, 110), 334 ...lintSeverityStyle('warning', warningSVG, 110),
336 ...lintSeverityStyle('info', infoSVG, 100), 335 ...lintSeverityStyle('info', infoSVG, 100),
337 }; 336 };
diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx
index 3837ef8e..5bac0464 100644
--- a/subprojects/frontend/src/editor/GenerateButton.tsx
+++ b/subprojects/frontend/src/editor/GenerateButton.tsx
@@ -4,10 +4,8 @@
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
6 6
7import DangerousOutlinedIcon from '@mui/icons-material/DangerousOutlined'; 7import CancelIcon from '@mui/icons-material/Cancel';
8import PlayArrowIcon from '@mui/icons-material/PlayArrow'; 8import PlayArrowIcon from '@mui/icons-material/PlayArrow';
9import Button from '@mui/material/Button';
10import type { SxProps, Theme } from '@mui/material/styles';
11import { observer } from 'mobx-react-lite'; 9import { observer } from 'mobx-react-lite';
12 10
13import AnimatedButton from './AnimatedButton'; 11import AnimatedButton from './AnimatedButton';
@@ -18,26 +16,45 @@ const GENERATE_LABEL = 'Generate';
18const GenerateButton = observer(function GenerateButton({ 16const GenerateButton = observer(function GenerateButton({
19 editorStore, 17 editorStore,
20 hideWarnings, 18 hideWarnings,
21 sx,
22}: { 19}: {
23 editorStore: EditorStore | undefined; 20 editorStore: EditorStore | undefined;
24 hideWarnings?: boolean | undefined; 21 hideWarnings?: boolean | undefined;
25 sx?: SxProps<Theme> | undefined;
26}): JSX.Element { 22}): JSX.Element {
27 if (editorStore === undefined) { 23 if (editorStore === undefined) {
28 return ( 24 return (
29 <Button 25 <AnimatedButton color="inherit" disabled>
30 color="inherit"
31 className="rounded shaded"
32 disabled
33 {...(sx === undefined ? {} : { sx })}
34 >
35 Loading&hellip; 26 Loading&hellip;
36 </Button> 27 </AnimatedButton>
28 );
29 }
30
31 const { analyzing, errorCount, warningCount, semanticsError } =
32 editorStore.delayedErrors;
33
34 if (analyzing) {
35 return (
36 <AnimatedButton color="inherit" disabled>
37 Analyzing&hellip;
38 </AnimatedButton>
37 ); 39 );
38 } 40 }
39 41
40 const { errorCount, warningCount } = editorStore; 42 if (semanticsError !== undefined && editorStore.opened) {
43 return (
44 <AnimatedButton
45 color="error"
46 disabled
47 startIcon={<CancelIcon />}
48 sx={(theme) => ({
49 '&.Mui-disabled': {
50 color: `${theme.palette.error.main} !important`,
51 },
52 })}
53 >
54 Analysis error
55 </AnimatedButton>
56 );
57 }
41 58
42 const diagnostics: string[] = []; 59 const diagnostics: string[] = [];
43 if (errorCount > 0) { 60 if (errorCount > 0) {
@@ -54,8 +71,7 @@ const GenerateButton = observer(function GenerateButton({
54 aria-label={`Select next diagnostic out of ${summary}`} 71 aria-label={`Select next diagnostic out of ${summary}`}
55 onClick={() => editorStore.nextDiagnostic()} 72 onClick={() => editorStore.nextDiagnostic()}
56 color="error" 73 color="error"
57 startIcon={<DangerousOutlinedIcon />} 74 startIcon={<CancelIcon />}
58 {...(sx === undefined ? {} : { sx })}
59 > 75 >
60 {summary} 76 {summary}
61 </AnimatedButton> 77 </AnimatedButton>
@@ -67,7 +83,6 @@ const GenerateButton = observer(function GenerateButton({
67 disabled={!editorStore.opened} 83 disabled={!editorStore.opened}
68 color={warningCount > 0 ? 'warning' : 'primary'} 84 color={warningCount > 0 ? 'warning' : 'primary'}
69 startIcon={<PlayArrowIcon />} 85 startIcon={<PlayArrowIcon />}
70 {...(sx === undefined ? {} : { sx })}
71 > 86 >
72 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`} 87 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`}
73 </AnimatedButton> 88 </AnimatedButton>
@@ -76,7 +91,6 @@ const GenerateButton = observer(function GenerateButton({
76 91
77GenerateButton.defaultProps = { 92GenerateButton.defaultProps = {
78 hideWarnings: false, 93 hideWarnings: false,
79 sx: undefined,
80}; 94};
81 95
82export default GenerateButton; 96export default GenerateButton;
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}
diff --git a/subprojects/frontend/src/index.tsx b/subprojects/frontend/src/index.tsx
index cb11e6c3..e8a22e82 100644
--- a/subprojects/frontend/src/index.tsx
+++ b/subprojects/frontend/src/index.tsx
@@ -4,45 +4,46 @@
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
6 6
7import { styled } from '@mui/material/styles';
7import { configure } from 'mobx'; 8import { configure } from 'mobx';
8import { type Root, createRoot } from 'react-dom/client'; 9import { type Root, createRoot } from 'react-dom/client';
9 10
10import App from './App'; 11import App from './App';
11import RootStore from './RootStore'; 12import RootStore from './RootStore';
12 13
13const initialValue = `// Metamodel 14// Make sure `styled` ends up in the entry chunk.
15// https://github.com/mui/material-ui/issues/32727#issuecomment-1659945548
16(window as unknown as { fixViteIssue: unknown }).fixViteIssue = styled;
17
18const initialValue = `% Metamodel
14class Person { 19class Person {
20 contains Post[] posts opposite author
15 Person[] friend opposite friend 21 Person[] friend opposite friend
16} 22}
17 23
18class Post { 24class Post {
19 Person author 25 container Person author opposite posts
20 Post[0..1] replyTo 26 Post replyTo
21} 27}
22 28
23// Constraints 29% Constraints
24error replyToNotFriend(Post x, Post y) <-> 30error replyToNotFriend(Post x, Post y) <->
25 replyTo(x, y), 31 replyTo(x, y),
26 author(x, xAuthor), 32 author(x, xAuthor),
27 author(y, yAuthor), 33 author(y, yAuthor),
34 xAuthor != yAuthor,
28 !friend(xAuthor, yAuthor). 35 !friend(xAuthor, yAuthor).
29 36
30error replyToCycle(Post x) <-> replyTo+(x,x). 37error replyToCycle(Post x) <-> replyTo+(x, x).
31 38
32// Instance model 39% Instance model
33Person(a).
34Person(b).
35friend(a, b). 40friend(a, b).
36friend(b, a).
37Post(p1).
38author(p1, a). 41author(p1, a).
39Post(p2).
40author(p2, b). 42author(p2, b).
41replyTo(p2, p1).
42 43
43!author(Post::new, a). // Automatically inferred: author(Post::new, b). 44!author(Post::new, a).
44 45
45// Scope 46% Scope
46scope Post = 10..15, Person += 0. 47scope Post = 10..15, Person += 0.
47`; 48`;
48 49
diff --git a/subprojects/frontend/src/language/indentation.ts b/subprojects/frontend/src/language/indentation.ts
index 8446d7fa..6806147b 100644
--- a/subprojects/frontend/src/language/indentation.ts
+++ b/subprojects/frontend/src/language/indentation.ts
@@ -2,7 +2,7 @@
2 * Copyright (C) 2018-2021 by Marijn Haverbeke <marijnh@gmail.com> and others 2 * Copyright (C) 2018-2021 by Marijn Haverbeke <marijnh@gmail.com> and others
3 * Copyright (C) 2021-2023 The Refinery Authors <https://refinery.tools/> 3 * Copyright (C) 2021-2023 The Refinery Authors <https://refinery.tools/>
4 * 4 *
5 * SPDX-License-Identifier: MIT OR EPL-2.0 5 * SPDX-License-Identifier: MIT AND EPL-2.0
6 */ 6 */
7 7
8import type { TreeIndentContext } from '@codemirror/language'; 8import type { TreeIndentContext } from '@codemirror/language';
diff --git a/subprojects/frontend/src/table/RelationGrid.tsx b/subprojects/frontend/src/table/RelationGrid.tsx
new file mode 100644
index 00000000..004982c9
--- /dev/null
+++ b/subprojects/frontend/src/table/RelationGrid.tsx
@@ -0,0 +1,109 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import Box from '@mui/material/Box';
8import {
9 DataGrid,
10 type GridRenderCellParams,
11 type GridColDef,
12} from '@mui/x-data-grid';
13import { observer } from 'mobx-react-lite';
14import { useMemo } from 'react';
15
16import type GraphStore from '../graph/GraphStore';
17
18import TableToolbar from './TableToolbar';
19import ValueRenderer from './ValueRenderer';
20
21interface Row {
22 nodes: string[];
23 value: string;
24}
25
26function RelationGrid({ graph }: { graph: GraphStore }): JSX.Element {
27 const {
28 selectedSymbol,
29 semantics: { nodes, partialInterpretation },
30 } = graph;
31 const symbolName = selectedSymbol?.name;
32 const arity = selectedSymbol?.arity ?? 0;
33
34 const columns = useMemo<GridColDef<Row>[]>(() => {
35 const defs: GridColDef<Row>[] = [];
36 for (let i = 0; i < arity; i += 1) {
37 defs.push({
38 field: `n${i}`,
39 headerName: String(i + 1),
40 valueGetter: (row) => row.row.nodes[i] ?? '',
41 flex: 1,
42 });
43 }
44 defs.push({
45 field: 'value',
46 headerName: 'Value',
47 flex: 1,
48 renderCell: ({ value }: GridRenderCellParams<Row, string>) => (
49 <ValueRenderer value={value} />
50 ),
51 });
52 return defs;
53 }, [arity]);
54
55 const rows = useMemo<Row[]>(() => {
56 if (symbolName === undefined) {
57 return [];
58 }
59 const interpretation = partialInterpretation[symbolName] ?? [];
60 return interpretation.map((tuple) => {
61 const nodeNames: string[] = [];
62 for (let i = 0; i < arity; i += 1) {
63 const index = tuple[i];
64 if (typeof index === 'number') {
65 const node = nodes[index];
66 if (node !== undefined) {
67 nodeNames.push(node.name);
68 }
69 }
70 }
71 return {
72 nodes: nodeNames,
73 value: String(tuple[arity]),
74 };
75 });
76 }, [arity, nodes, partialInterpretation, symbolName]);
77
78 return (
79 <Box
80 width="100%"
81 height="100%"
82 p={1}
83 sx={(theme) => ({
84 '.MuiDataGrid-withBorderColor': {
85 borderColor:
86 theme.palette.mode === 'dark'
87 ? theme.palette.divider
88 : theme.palette.outer.border,
89 },
90 })}
91 >
92 <DataGrid
93 slots={{ toolbar: TableToolbar }}
94 slotProps={{
95 toolbar: {
96 graph,
97 },
98 }}
99 density="compact"
100 rowSelection={false}
101 columns={columns}
102 rows={rows}
103 getRowId={(row) => row.nodes.join(',')}
104 />
105 </Box>
106 );
107}
108
109export default observer(RelationGrid);
diff --git a/subprojects/frontend/src/table/SymbolSelector.tsx b/subprojects/frontend/src/table/SymbolSelector.tsx
new file mode 100644
index 00000000..5272f8ed
--- /dev/null
+++ b/subprojects/frontend/src/table/SymbolSelector.tsx
@@ -0,0 +1,65 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import Autocomplete from '@mui/material/Autocomplete';
8import Box from '@mui/material/Box';
9import TextField from '@mui/material/TextField';
10import { observer } from 'mobx-react-lite';
11
12import type GraphStore from '../graph/GraphStore';
13import RelationName from '../graph/RelationName';
14
15function SymbolSelector({ graph }: { graph: GraphStore }): JSX.Element {
16 const {
17 selectedSymbol,
18 semantics: { relations },
19 } = graph;
20
21 return (
22 <Autocomplete
23 renderInput={(params) => (
24 <TextField
25 {...{
26 ...params,
27 InputLabelProps: {
28 ...params.InputLabelProps,
29 // Workaround for type errors.
30 className: params.InputLabelProps.className ?? '',
31 style: params.InputLabelProps.style ?? {},
32 },
33 }}
34 variant="standard"
35 size="medium"
36 placeholder="Symbol"
37 />
38 )}
39 options={relations}
40 getOptionLabel={(option) => option.name}
41 renderOption={(props, option) => (
42 <Box component="li" {...props}>
43 <RelationName metadata={option} />
44 </Box>
45 )}
46 value={selectedSymbol ?? null}
47 isOptionEqualToValue={(option, value) => option.name === value.name}
48 onChange={(_event, value) => graph.setSelectedSymbol(value ?? undefined)}
49 sx={(theme) => ({
50 flexBasis: 200,
51 maxWidth: 600,
52 flexGrow: 1,
53 flexShrink: 1,
54 '.MuiInput-underline::before': {
55 borderColor:
56 theme.palette.mode === 'dark'
57 ? theme.palette.divider
58 : theme.palette.outer.border,
59 },
60 })}
61 />
62 );
63}
64
65export default observer(SymbolSelector);
diff --git a/subprojects/frontend/src/table/TableArea.tsx b/subprojects/frontend/src/table/TableArea.tsx
new file mode 100644
index 00000000..cf37b96a
--- /dev/null
+++ b/subprojects/frontend/src/table/TableArea.tsx
@@ -0,0 +1,24 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { observer } from 'mobx-react-lite';
8
9import Loading from '../Loading';
10import { useRootStore } from '../RootStoreProvider';
11
12import RelationGrid from './RelationGrid';
13
14function TablePane(): JSX.Element {
15 const { editorStore } = useRootStore();
16
17 if (editorStore === undefined) {
18 return <Loading />;
19 }
20
21 return <RelationGrid graph={editorStore.graph} />;
22}
23
24export default observer(TablePane);
diff --git a/subprojects/frontend/src/table/TablePane.tsx b/subprojects/frontend/src/table/TablePane.tsx
new file mode 100644
index 00000000..01442c3a
--- /dev/null
+++ b/subprojects/frontend/src/table/TablePane.tsx
@@ -0,0 +1,22 @@
1/*
2 * SPDX-FileCopyrightText: 2021-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 TableArea = lazy(() => import('./TableArea'));
13
14export default function TablePane(): JSX.Element {
15 return (
16 <Stack direction="column" height="100%" overflow="auto" alignItems="center">
17 <Suspense fallback={<Loading />}>
18 <TableArea />
19 </Suspense>
20 </Stack>
21 );
22}
diff --git a/subprojects/frontend/src/table/TableToolbar.tsx b/subprojects/frontend/src/table/TableToolbar.tsx
new file mode 100644
index 00000000..b14e73c5
--- /dev/null
+++ b/subprojects/frontend/src/table/TableToolbar.tsx
@@ -0,0 +1,41 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import Stack from '@mui/material/Stack';
8import {
9 GridToolbarColumnsButton,
10 GridToolbarContainer,
11 GridToolbarExport,
12 GridToolbarFilterButton,
13} from '@mui/x-data-grid';
14
15import type GraphStore from '../graph/GraphStore';
16
17import SymbolSelector from './SymbolSelector';
18
19export default function TableToolbar({
20 graph,
21}: {
22 graph: GraphStore;
23}): JSX.Element {
24 return (
25 <GridToolbarContainer
26 sx={{
27 display: 'flex',
28 flexDirection: 'row',
29 flexWrap: 'wrap-reverse',
30 justifyContent: 'space-between',
31 }}
32 >
33 <Stack direction="row" flexWrap="wrap">
34 <GridToolbarColumnsButton />
35 <GridToolbarFilterButton />
36 <GridToolbarExport />
37 </Stack>
38 <SymbolSelector graph={graph} />
39 </GridToolbarContainer>
40 );
41}
diff --git a/subprojects/frontend/src/table/ValueRenderer.tsx b/subprojects/frontend/src/table/ValueRenderer.tsx
new file mode 100644
index 00000000..ac5700e4
--- /dev/null
+++ b/subprojects/frontend/src/table/ValueRenderer.tsx
@@ -0,0 +1,62 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import CancelIcon from '@mui/icons-material/Cancel';
8import LabelIcon from '@mui/icons-material/Label';
9import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined';
10import { styled } from '@mui/material/styles';
11
12const Label = styled('div', {
13 name: 'ValueRenderer-Label',
14 shouldForwardProp: (prop) => prop !== 'value',
15})<{
16 value: 'TRUE' | 'UNKNOWN' | 'ERROR';
17}>(({ theme, value }) => ({
18 display: 'flex',
19 alignItems: 'center',
20 ...(value === 'UNKNOWN'
21 ? {
22 color: theme.palette.text.secondary,
23 }
24 : {}),
25 ...(value === 'ERROR'
26 ? {
27 color: theme.palette.error.main,
28 }
29 : {}),
30 '& svg': {
31 marginRight: theme.spacing(0.5),
32 },
33}));
34
35export default function ValueRenderer({
36 value,
37}: {
38 value: string | undefined;
39}): React.ReactNode {
40 switch (value) {
41 case 'TRUE':
42 return (
43 <Label value={value}>
44 <LabelIcon fontSize="small" /> true
45 </Label>
46 );
47 case 'UNKNOWN':
48 return (
49 <Label value={value}>
50 <LabelOutlinedIcon fontSize="small" /> unknown
51 </Label>
52 );
53 case 'ERROR':
54 return (
55 <Label value={value}>
56 <CancelIcon fontSize="small" /> error
57 </Label>
58 );
59 default:
60 return value;
61 }
62}
diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx
index 78146f25..18310147 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:
@@ -151,7 +153,7 @@ function createResponsiveTheme(
151 }, {}), 153 }, {}),
152 }, 154 },
153 }, 155 },
154 sizeSmall: { fontSize: '0.75rem' }, 156 sizeSmall: { fontSize: '0.875rem', lineHeight: '1.75' },
155 sizeLarge: { fontSize: '1rem' }, 157 sizeLarge: { fontSize: '1rem' },
156 text: { '&.rounded': { padding: '6px 14px' } }, 158 text: { '&.rounded': { padding: '6px 14px' } },
157 textSizeSmall: { '&.rounded': { padding: '4px 8px' } }, 159 textSizeSmall: { '&.rounded': { padding: '4px 8px' } },
@@ -287,7 +289,7 @@ const darkTheme = (() => {
287 secondary: secondaryText, 289 secondary: secondaryText,
288 disabled: '#5c6370', 290 disabled: '#5c6370',
289 }, 291 },
290 divider: alpha(secondaryText, 0.24), 292 divider: alpha(primaryText, 0.24),
291 outer: { 293 outer: {
292 background: darkBackground, 294 background: darkBackground,
293 border: '#181a1f', 295 border: '#181a1f',
diff --git a/subprojects/frontend/src/theme/ThemeStore.ts b/subprojects/frontend/src/theme/ThemeStore.ts
index 7c657449..12449b94 100644
--- a/subprojects/frontend/src/theme/ThemeStore.ts
+++ b/subprojects/frontend/src/theme/ThemeStore.ts
@@ -12,11 +12,19 @@ export enum ThemePreference {
12 PreferDark, 12 PreferDark,
13} 13}
14 14
15export type SelectedPane = 'code' | 'graph' | 'table';
16
15export default class ThemeStore { 17export default class ThemeStore {
16 preference = ThemePreference.System; 18 preference = ThemePreference.System;
17 19
18 systemDarkMode: boolean; 20 systemDarkMode: boolean;
19 21
22 showCode = true;
23
24 showGraph = true;
25
26 showTable = false;
27
20 constructor() { 28 constructor() {
21 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 29 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
22 this.systemDarkMode = mediaQuery.matches; 30 this.systemDarkMode = mediaQuery.matches;
@@ -48,4 +56,44 @@ export default class ThemeStore {
48 : ThemePreference.PreferDark; 56 : ThemePreference.PreferDark;
49 } 57 }
50 } 58 }
59
60 toggleCode(): void {
61 if (!this.showGraph && !this.showTable) {
62 return;
63 }
64 this.showCode = !this.showCode;
65 }
66
67 toggleGraph(): void {
68 if (!this.showCode && !this.showTable) {
69 return;
70 }
71 this.showGraph = !this.showGraph;
72 }
73
74 toggleTable(): void {
75 if (!this.showCode && !this.showGraph) {
76 return;
77 }
78 this.showTable = !this.showTable;
79 }
80
81 get selectedPane(): SelectedPane {
82 if (this.showCode) {
83 return 'code';
84 }
85 if (this.showGraph) {
86 return 'graph';
87 }
88 if (this.showTable) {
89 return 'table';
90 }
91 return 'code';
92 }
93
94 setSelectedPane(pane: SelectedPane, keepCode = true): void {
95 this.showCode = pane === 'code' || (keepCode && this.showCode);
96 this.showGraph = pane === 'graph';
97 this.showTable = pane === 'table';
98 }
51} 99}
diff --git a/subprojects/frontend/src/utils/svgURL.ts b/subprojects/frontend/src/utils/svgURL.ts
new file mode 100644
index 00000000..9b8ecbd5
--- /dev/null
+++ b/subprojects/frontend/src/utils/svgURL.ts
@@ -0,0 +1,9 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7export default function svgURL(svg: string): string {
8 return `url('data:image/svg+xml;utf8,${svg}')`;
9}
diff --git a/subprojects/frontend/src/xtext/BackendConfig.ts b/subprojects/frontend/src/xtext/BackendConfig.ts
index 4c7eac5f..e7043bd5 100644
--- a/subprojects/frontend/src/xtext/BackendConfig.ts
+++ b/subprojects/frontend/src/xtext/BackendConfig.ts
@@ -11,7 +11,7 @@ import { z } from 'zod';
11export const ENDPOINT = 'config.json'; 11export const ENDPOINT = 'config.json';
12 12
13const BackendConfig = z.object({ 13const BackendConfig = z.object({
14 webSocketURL: z.string().url(), 14 webSocketURL: z.string().url().optional(),
15}); 15});
16 16
17type BackendConfig = z.infer<typeof BackendConfig>; 17type BackendConfig = z.infer<typeof BackendConfig>;
diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts
index fd30c4f9..ac8ab36a 100644
--- a/subprojects/frontend/src/xtext/ContentAssistService.ts
+++ b/subprojects/frontend/src/xtext/ContentAssistService.ts
@@ -248,10 +248,20 @@ export default class ContentAssistService {
248 if (lastTo === undefined) { 248 if (lastTo === undefined) {
249 return true; 249 return true;
250 } 250 }
251 const [transformedFrom, transformedTo] = this.mapRangeInclusive( 251 let transformedFrom: number;
252 lastFrom, 252 let transformedTo: number;
253 lastTo, 253 try {
254 ); 254 [transformedFrom, transformedTo] = this.mapRangeInclusive(
255 lastFrom,
256 lastTo,
257 );
258 } catch (error) {
259 if (error instanceof RangeError) {
260 log.debug('Invalidating cache due to invalid range', error);
261 return true;
262 }
263 throw error;
264 }
255 let invalidate = false; 265 let invalidate = false;
256 transaction.changes.iterChangedRanges((fromA, toA) => { 266 transaction.changes.iterChangedRanges((fromA, toA) => {
257 if (fromA < transformedFrom || toA > transformedTo) { 267 if (fromA < transformedFrom || toA > transformedTo) {
diff --git a/subprojects/frontend/src/xtext/SemanticsService.ts b/subprojects/frontend/src/xtext/SemanticsService.ts
new file mode 100644
index 00000000..d68b87a9
--- /dev/null
+++ b/subprojects/frontend/src/xtext/SemanticsService.ts
@@ -0,0 +1,32 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import type EditorStore from '../editor/EditorStore';
8
9import type ValidationService from './ValidationService';
10import { SemanticsResult } from './xtextServiceResults';
11
12export default class SemanticsService {
13 constructor(
14 private readonly store: EditorStore,
15 private readonly validationService: ValidationService,
16 ) {}
17
18 onPush(push: unknown): void {
19 const result = SemanticsResult.parse(push);
20 if ('issues' in result) {
21 this.validationService.setSemanticsIssues(result.issues);
22 } else {
23 this.validationService.setSemanticsIssues([]);
24 if ('error' in result) {
25 this.store.setSemanticsError(result.error);
26 } else {
27 this.store.setSemantics(result);
28 }
29 }
30 this.store.analysisCompleted();
31 }
32}
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts
index ee5ebde2..1ac722e1 100644
--- a/subprojects/frontend/src/xtext/UpdateService.ts
+++ b/subprojects/frontend/src/xtext/UpdateService.ts
@@ -133,6 +133,7 @@ export default class UpdateService {
133 return; 133 return;
134 } 134 }
135 log.trace('Editor delta', delta); 135 log.trace('Editor delta', delta);
136 this.store.analysisStarted();
136 const result = await this.webSocketClient.send({ 137 const result = await this.webSocketClient.send({
137 resource: this.resourceName, 138 resource: this.resourceName,
138 serviceType: 'update', 139 serviceType: 'update',
@@ -157,6 +158,7 @@ export default class UpdateService {
157 private async updateFullTextExclusive(): Promise<void> { 158 private async updateFullTextExclusive(): Promise<void> {
158 log.debug('Performing full text update'); 159 log.debug('Performing full text update');
159 this.tracker.prepareFullTextUpdateExclusive(); 160 this.tracker.prepareFullTextUpdateExclusive();
161 this.store.analysisStarted();
160 const result = await this.webSocketClient.send({ 162 const result = await this.webSocketClient.send({
161 resource: this.resourceName, 163 resource: this.resourceName,
162 serviceType: 'update', 164 serviceType: 'update',
diff --git a/subprojects/frontend/src/xtext/ValidationService.ts b/subprojects/frontend/src/xtext/ValidationService.ts
index 64fb63eb..1a896db3 100644
--- a/subprojects/frontend/src/xtext/ValidationService.ts
+++ b/subprojects/frontend/src/xtext/ValidationService.ts
@@ -9,7 +9,7 @@ import type { Diagnostic } from '@codemirror/lint';
9import type EditorStore from '../editor/EditorStore'; 9import type EditorStore from '../editor/EditorStore';
10 10
11import type UpdateService from './UpdateService'; 11import type UpdateService from './UpdateService';
12import { ValidationResult } from './xtextServiceResults'; 12import { Issue, ValidationResult } from './xtextServiceResults';
13 13
14export default class ValidationService { 14export default class ValidationService {
15 constructor( 15 constructor(
@@ -17,11 +17,41 @@ export default class ValidationService {
17 private readonly updateService: UpdateService, 17 private readonly updateService: UpdateService,
18 ) {} 18 ) {}
19 19
20 private lastValidationIssues: Issue[] = [];
21
22 private lastSemanticsIssues: Issue[] = [];
23
20 onPush(push: unknown): void { 24 onPush(push: unknown): void {
21 const { issues } = ValidationResult.parse(push); 25 ({ issues: this.lastValidationIssues } = ValidationResult.parse(push));
26 this.lastSemanticsIssues = [];
27 this.updateDiagnostics();
28 if (
29 this.lastValidationIssues.some(({ severity }) => severity === 'error')
30 ) {
31 this.store.analysisCompleted(true);
32 }
33 }
34
35 onDisconnect(): void {
36 this.store.updateDiagnostics([]);
37 this.lastValidationIssues = [];
38 this.lastSemanticsIssues = [];
39 }
40
41 setSemanticsIssues(issues: Issue[]): void {
42 this.lastSemanticsIssues = issues;
43 this.updateDiagnostics();
44 }
45
46 private updateDiagnostics(): void {
22 const allChanges = this.updateService.computeChangesSinceLastUpdate(); 47 const allChanges = this.updateService.computeChangesSinceLastUpdate();
23 const diagnostics: Diagnostic[] = []; 48 const diagnostics: Diagnostic[] = [];
24 issues.forEach(({ offset, length, severity, description }) => { 49 function createDiagnostic({
50 offset,
51 length,
52 severity,
53 description,
54 }: Issue): void {
25 if (severity === 'ignore') { 55 if (severity === 'ignore') {
26 return; 56 return;
27 } 57 }
@@ -31,11 +61,9 @@ export default class ValidationService {
31 severity, 61 severity,
32 message: description, 62 message: description,
33 }); 63 });
34 }); 64 }
65 this.lastValidationIssues.forEach(createDiagnostic);
66 this.lastSemanticsIssues.forEach(createDiagnostic);
35 this.store.updateDiagnostics(diagnostics); 67 this.store.updateDiagnostics(diagnostics);
36 } 68 }
37
38 onDisconnect(): void {
39 this.store.updateDiagnostics([]);
40 }
41} 69}
diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts
index e8181af0..87778084 100644
--- a/subprojects/frontend/src/xtext/XtextClient.ts
+++ b/subprojects/frontend/src/xtext/XtextClient.ts
@@ -17,6 +17,7 @@ import getLogger from '../utils/getLogger';
17import ContentAssistService from './ContentAssistService'; 17import ContentAssistService from './ContentAssistService';
18import HighlightingService from './HighlightingService'; 18import HighlightingService from './HighlightingService';
19import OccurrencesService from './OccurrencesService'; 19import OccurrencesService from './OccurrencesService';
20import SemanticsService from './SemanticsService';
20import UpdateService from './UpdateService'; 21import UpdateService from './UpdateService';
21import ValidationService from './ValidationService'; 22import ValidationService from './ValidationService';
22import XtextWebSocketClient from './XtextWebSocketClient'; 23import XtextWebSocketClient from './XtextWebSocketClient';
@@ -37,7 +38,12 @@ export default class XtextClient {
37 38
38 private readonly occurrencesService: OccurrencesService; 39 private readonly occurrencesService: OccurrencesService;
39 40
40 constructor(store: EditorStore, private readonly pwaStore: PWAStore) { 41 private readonly semanticsService: SemanticsService;
42
43 constructor(
44 private readonly store: EditorStore,
45 private readonly pwaStore: PWAStore,
46 ) {
41 this.webSocketClient = new XtextWebSocketClient( 47 this.webSocketClient = new XtextWebSocketClient(
42 () => this.onReconnect(), 48 () => this.onReconnect(),
43 () => this.onDisconnect(), 49 () => this.onDisconnect(),
@@ -51,6 +57,7 @@ export default class XtextClient {
51 ); 57 );
52 this.validationService = new ValidationService(store, this.updateService); 58 this.validationService = new ValidationService(store, this.updateService);
53 this.occurrencesService = new OccurrencesService(store, this.updateService); 59 this.occurrencesService = new OccurrencesService(store, this.updateService);
60 this.semanticsService = new SemanticsService(store, this.validationService);
54 } 61 }
55 62
56 start(): void { 63 start(): void {
@@ -64,6 +71,7 @@ export default class XtextClient {
64 } 71 }
65 72
66 private onDisconnect(): void { 73 private onDisconnect(): void {
74 this.store.analysisCompleted(true);
67 this.highlightingService.onDisconnect(); 75 this.highlightingService.onDisconnect();
68 this.validationService.onDisconnect(); 76 this.validationService.onDisconnect();
69 this.occurrencesService.onDisconnect(); 77 this.occurrencesService.onDisconnect();
@@ -111,6 +119,9 @@ export default class XtextClient {
111 case 'validate': 119 case 'validate':
112 this.validationService.onPush(push); 120 this.validationService.onPush(push);
113 return; 121 return;
122 case 'semantics':
123 this.semanticsService.onPush(push);
124 return;
114 default: 125 default:
115 throw new Error('Unknown service'); 126 throw new Error('Unknown service');
116 } 127 }
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
index 6bb7eec8..963c1d4c 100644
--- a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
+++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
@@ -282,7 +282,10 @@ export default class XtextWebSocketClient {
282 log.debug('Creating WebSocket'); 282 log.debug('Creating WebSocket');
283 283
284 (async () => { 284 (async () => {
285 const { webSocketURL } = await fetchBackendConfig(); 285 let { webSocketURL } = await fetchBackendConfig();
286 if (webSocketURL === undefined) {
287 webSocketURL = `${window.origin.replace(/^http/, 'ws')}/xtext-service`;
288 }
286 this.openWebSocketWithURL(webSocketURL); 289 this.openWebSocketWithURL(webSocketURL);
287 })().catch((error) => { 290 })().catch((error) => {
288 log.error('Error while initializing connection', error); 291 log.error('Error while initializing connection', error);
diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts
index bbbff064..971720e1 100644
--- a/subprojects/frontend/src/xtext/xtextMessages.ts
+++ b/subprojects/frontend/src/xtext/xtextMessages.ts
@@ -34,7 +34,11 @@ export const XtextWebErrorResponse = z.object({
34 34
35export type XtextWebErrorResponse = z.infer<typeof XtextWebErrorResponse>; 35export type XtextWebErrorResponse = z.infer<typeof XtextWebErrorResponse>;
36 36
37export const XtextWebPushService = z.enum(['highlight', 'validate']); 37export const XtextWebPushService = z.enum([
38 'highlight',
39 'validate',
40 'semantics',
41]);
38 42
39export type XtextWebPushService = z.infer<typeof XtextWebPushService>; 43export type XtextWebPushService = z.infer<typeof XtextWebPushService>;
40 44
diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts
index d3b467ad..caf2cf0b 100644
--- a/subprojects/frontend/src/xtext/xtextServiceResults.ts
+++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts
@@ -125,3 +125,49 @@ export const FormattingResult = DocumentStateResult.extend({
125}); 125});
126 126
127export type FormattingResult = z.infer<typeof FormattingResult>; 127export type FormattingResult = z.infer<typeof FormattingResult>;
128
129export const NodeMetadata = z.object({
130 name: z.string(),
131 simpleName: z.string(),
132 kind: z.enum(['IMPLICIT', 'INDIVIDUAL', 'NEW']),
133});
134
135export type NodeMetadata = z.infer<typeof NodeMetadata>;
136
137export const RelationMetadata = z.object({
138 name: z.string(),
139 simpleName: z.string(),
140 arity: z.number().nonnegative(),
141 detail: z.union([
142 z.object({ type: z.literal('class'), abstractClass: z.boolean() }),
143 z.object({ type: z.literal('reference'), containment: z.boolean() }),
144 z.object({
145 type: z.literal('opposite'),
146 container: z.boolean(),
147 opposite: z.string(),
148 }),
149 z.object({ type: z.literal('predicate'), error: z.boolean() }),
150 z.object({ type: z.literal('builtin') }),
151 ]),
152});
153
154export type RelationMetadata = z.infer<typeof RelationMetadata>;
155
156export const SemanticsSuccessResult = z.object({
157 nodes: NodeMetadata.array(),
158 relations: RelationMetadata.array(),
159 partialInterpretation: z.record(
160 z.string(),
161 z.union([z.number(), z.string()]).array().array(),
162 ),
163});
164
165export type SemanticsSuccessResult = z.infer<typeof SemanticsSuccessResult>;
166
167export const SemanticsResult = z.union([
168 z.object({ error: z.string() }),
169 z.object({ issues: Issue.array() }),
170 SemanticsSuccessResult,
171]);
172
173export type SemanticsResult = z.infer<typeof SemanticsResult>;