aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend')
-rw-r--r--subprojects/frontend/build.gradle.kts10
-rw-r--r--subprojects/frontend/config/detectDevModeOptions.ts4
-rw-r--r--subprojects/frontend/config/graphvizUMDVitePlugin.ts69
-rw-r--r--subprojects/frontend/index.html3
-rw-r--r--subprojects/frontend/package.json89
-rw-r--r--subprojects/frontend/src/DirectionalSplitPane.tsx159
-rw-r--r--subprojects/frontend/src/ModelWorkArea.tsx193
-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.tsx25
-rw-r--r--subprojects/frontend/src/editor/AnalysisErrorNotification.tsx74
-rw-r--r--subprojects/frontend/src/editor/AnimatedButton.tsx11
-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.ts132
-rw-r--r--subprojects/frontend/src/editor/EditorTheme.ts15
-rw-r--r--subprojects/frontend/src/editor/GenerateButton.tsx68
-rw-r--r--subprojects/frontend/src/editor/GeneratedModelStore.ts50
-rw-r--r--subprojects/frontend/src/graph/DotGraphVisualizer.tsx162
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx46
-rw-r--r--subprojects/frontend/src/graph/GraphPane.tsx34
-rw-r--r--subprojects/frontend/src/graph/GraphStore.ts187
-rw-r--r--subprojects/frontend/src/graph/GraphTheme.tsx111
-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.ts366
-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.tsx127
-rw-r--r--subprojects/frontend/src/language/indentation.ts2
-rw-r--r--subprojects/frontend/src/table/SymbolSelector.tsx65
-rw-r--r--subprojects/frontend/src/table/TableArea.tsx109
-rw-r--r--subprojects/frontend/src/table/TablePane.tsx27
-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/ModelGenerationService.ts66
-rw-r--r--subprojects/frontend/src/xtext/SemanticsService.ts32
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts42
-rw-r--r--subprojects/frontend/src/xtext/ValidationService.ts44
-rw-r--r--subprojects/frontend/src/xtext/XtextClient.ts44
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts13
-rw-r--r--subprojects/frontend/src/xtext/webSocketMachine.ts40
-rw-r--r--subprojects/frontend/src/xtext/xtextMessages.ts7
-rw-r--r--subprojects/frontend/src/xtext/xtextServiceResults.ts70
-rw-r--r--subprojects/frontend/tsconfig.base.json2
-rw-r--r--subprojects/frontend/types/ImportMeta.d.ts2
-rw-r--r--subprojects/frontend/types/grammar.d.ts2
-rw-r--r--subprojects/frontend/types/node/@lezer-generator-rollup.d.ts2
-rw-r--r--subprojects/frontend/vite.config.ts6
60 files changed, 3845 insertions, 201 deletions
diff --git a/subprojects/frontend/build.gradle.kts b/subprojects/frontend/build.gradle.kts
index d0839371..286dd05c 100644
--- a/subprojects/frontend/build.gradle.kts
+++ b/subprojects/frontend/build.gradle.kts
@@ -16,9 +16,9 @@ frontend {
16 assembleScript.set("run build") 16 assembleScript.set("run build")
17} 17}
18 18
19val viteOutputDir = "$buildDir/vite" 19val viteOutputDir = layout.buildDirectory.dir("vite")
20 20
21val productionResources = file("$viteOutputDir/production") 21val productionResources = viteOutputDir.map { it.dir("production") }
22 22
23val productionAssets: Configuration by configurations.creating { 23val productionAssets: Configuration by configurations.creating {
24 isCanBeConsumed = true 24 isCanBeConsumed = true
@@ -81,7 +81,7 @@ tasks {
81 dependsOn(installFrontend) 81 dependsOn(installFrontend)
82 dependsOn(generateXStateTypes) 82 dependsOn(generateXStateTypes)
83 inputs.files(lintingFiles) 83 inputs.files(lintingFiles)
84 outputs.dir("$buildDir/typescript") 84 outputs.dir(layout.buildDirectory.dir("typescript"))
85 script.set("run typecheck") 85 script.set("run typecheck")
86 group = "verification" 86 group = "verification"
87 description = "Check for TypeScript type errors." 87 description = "Check for TypeScript type errors."
@@ -92,7 +92,7 @@ tasks {
92 dependsOn(generateXStateTypes) 92 dependsOn(generateXStateTypes)
93 dependsOn(typeCheckFrontend) 93 dependsOn(typeCheckFrontend)
94 inputs.files(lintingFiles) 94 inputs.files(lintingFiles)
95 outputs.file("$buildDir/eslint.json") 95 outputs.file(layout.buildDirectory.file("eslint.json"))
96 script.set("run lint") 96 script.set("run lint")
97 group = "verification" 97 group = "verification"
98 description = "Check for TypeScript lint errors and warnings." 98 description = "Check for TypeScript lint errors and warnings."
@@ -140,5 +140,5 @@ artifacts {
140sonarqube.properties { 140sonarqube.properties {
141 SonarPropertiesUtils.addToList(properties, "sonar.sources", "src") 141 SonarPropertiesUtils.addToList(properties, "sonar.sources", "src")
142 property("sonar.nodejs.executable", "${frontend.nodeInstallDirectory.get()}/bin/node") 142 property("sonar.nodejs.executable", "${frontend.nodeInstallDirectory.get()}/bin/node")
143 property("sonar.eslint.reportPaths", "$buildDir/eslint.json") 143 property("sonar.eslint.reportPaths", "${layout.buildDirectory.get()}/eslint.json")
144} 144}
diff --git a/subprojects/frontend/config/detectDevModeOptions.ts b/subprojects/frontend/config/detectDevModeOptions.ts
index 665204dc..6052e047 100644
--- a/subprojects/frontend/config/detectDevModeOptions.ts
+++ b/subprojects/frontend/config/detectDevModeOptions.ts
@@ -30,8 +30,8 @@ function detectListenOptions(
30 fallbackHost: string, 30 fallbackHost: string,
31 fallbackPort: number, 31 fallbackPort: number,
32): ListenOptions { 32): ListenOptions {
33 const host = process.env[`${name}_HOST`] ?? fallbackHost; 33 const host = process.env[`REFINERY_${name}_HOST`] ?? fallbackHost;
34 const rawPort = process.env[`${name}_PORT`]; 34 const rawPort = process.env[`REFINERY_${name}_PORT`];
35 const port = rawPort === undefined ? fallbackPort : parseInt(rawPort, 10); 35 const port = rawPort === undefined ? fallbackPort : parseInt(rawPort, 10);
36 const secure = port === 443; 36 const secure = port === 443;
37 return { host, port, secure }; 37 return { host, port, secure };
diff --git a/subprojects/frontend/config/graphvizUMDVitePlugin.ts b/subprojects/frontend/config/graphvizUMDVitePlugin.ts
new file mode 100644
index 00000000..9c60a84e
--- /dev/null
+++ b/subprojects/frontend/config/graphvizUMDVitePlugin.ts
@@ -0,0 +1,69 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { readFile } from 'node:fs/promises';
8import path from 'node:path';
9
10import pnpapi from 'pnpapi';
11import type { PluginOption, ResolvedConfig } from 'vite';
12
13// Use a CJS file as the PnP resolution issuer to force resolution to a non-ESM export.
14const issuerFileName = 'worker.cjs';
15
16export default function graphvizUMDVitePlugin(): PluginOption {
17 let command: ResolvedConfig['command'] = 'build';
18 let root: string | undefined;
19 let url: string | undefined;
20
21 return {
22 name: 'graphviz-umd',
23 enforce: 'post',
24 configResolved(config) {
25 ({ command, root } = config);
26 },
27 async buildStart() {
28 const issuer =
29 root === undefined ? issuerFileName : path.join(issuerFileName);
30 const resolvedPath = pnpapi.resolveRequest(
31 '@hpcc-js/wasm/graphviz',
32 issuer,
33 );
34 if (resolvedPath === null) {
35 return;
36 }
37 if (command === 'serve') {
38 url = `/@fs/${resolvedPath}`;
39 } else {
40 const content = await readFile(resolvedPath, null);
41 url = this.emitFile({
42 name: path.basename(resolvedPath),
43 type: 'asset',
44 source: content,
45 });
46 }
47 },
48 renderStart() {
49 if (url !== undefined && command !== 'serve') {
50 url = this.getFileName(url);
51 }
52 },
53 transformIndexHtml() {
54 if (url === undefined) {
55 return undefined;
56 }
57 return [
58 {
59 tag: 'script',
60 attrs: {
61 src: url,
62 type: 'javascript/worker',
63 },
64 injectTo: 'head',
65 },
66 ];
67 },
68 };
69}
diff --git a/subprojects/frontend/index.html b/subprojects/frontend/index.html
index 1bf3472e..8992d538 100644
--- a/subprojects/frontend/index.html
+++ b/subprojects/frontend/index.html
@@ -18,7 +18,8 @@
18 <meta name="theme-color" media="(prefers-color-scheme:light)" content="#f5f5f5"> 18 <meta name="theme-color" media="(prefers-color-scheme:light)" content="#f5f5f5">
19 <meta name="theme-color" media="(prefers-color-scheme:dark)" content="#21252b"> 19 <meta name="theme-color" media="(prefers-color-scheme:dark)" content="#21252b">
20 <style> 20 <style>
21 @import '@fontsource-variable/inter/wght.css'; 21 @import '@fontsource-variable/open-sans/wdth.css';
22 @import '@fontsource-variable/open-sans/wdth-italic.css';
22 @import '@fontsource-variable/jetbrains-mono/wght.css'; 23 @import '@fontsource-variable/jetbrains-mono/wght.css';
23 @import '@fontsource-variable/jetbrains-mono/wght-italic.css'; 24 @import '@fontsource-variable/jetbrains-mono/wght-italic.css';
24 </style> 25 </style>
diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json
index ba8a0a58..873e5170 100644
--- a/subprojects/frontend/package.json
+++ b/subprojects/frontend/package.json
@@ -28,72 +28,85 @@
28 }, 28 },
29 "homepage": "https://refinery.tools", 29 "homepage": "https://refinery.tools",
30 "dependencies": { 30 "dependencies": {
31 "@codemirror/autocomplete": "^6.8.0", 31 "@codemirror/autocomplete": "^6.9.0",
32 "@codemirror/commands": "^6.2.4", 32 "@codemirror/commands": "^6.2.5",
33 "@codemirror/language": "^6.8.0", 33 "@codemirror/language": "^6.9.0",
34 "@codemirror/lint": "^6.2.2", 34 "@codemirror/lint": "^6.4.1",
35 "@codemirror/search": "^6.5.0", 35 "@codemirror/search": "^6.5.2",
36 "@codemirror/state": "^6.2.1", 36 "@codemirror/state": "^6.2.1",
37 "@codemirror/view": "^6.13.2", 37 "@codemirror/view": "^6.18.1",
38 "@emotion/react": "^11.11.1", 38 "@emotion/react": "^11.11.1",
39 "@emotion/styled": "^11.11.0", 39 "@emotion/styled": "^11.11.0",
40 "@fontsource-variable/inter": "^5.0.3", 40 "@fontsource-variable/jetbrains-mono": "^5.0.13",
41 "@fontsource-variable/jetbrains-mono": "^5.0.3", 41 "@fontsource-variable/open-sans": "^5.0.13",
42 "@lezer/common": "^1.0.3", 42 "@hpcc-js/wasm": "^2.13.1",
43 "@lezer/common": "^1.0.4",
43 "@lezer/highlight": "^1.1.6", 44 "@lezer/highlight": "^1.1.6",
44 "@lezer/lr": "^1.3.6", 45 "@lezer/lr": "^1.3.10",
45 "@material-icons/svg": "^1.0.33", 46 "@material-icons/svg": "^1.0.33",
46 "@mui/icons-material": "5.11.16", 47 "@mui/icons-material": "5.14.8",
47 "@mui/material": "5.13.5", 48 "@mui/material": "5.14.8",
48 "@vitejs/plugin-react-swc": "^3.3.2", 49 "@mui/system": "^5.14.8",
50 "@mui/x-data-grid": "^6.10.0 <6.10.1",
49 "ansi-styles": "^6.2.1", 51 "ansi-styles": "^6.2.1",
50 "csstype": "^3.1.2", 52 "csstype": "^3.1.2",
53 "d3": "^7.8.5",
54 "d3-graphviz": "patch:d3-graphviz@npm%3A5.1.0#~/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch",
55 "d3-selection": "^3.0.0",
56 "d3-zoom": "patch:d3-zoom@npm%3A3.0.0#~/.yarn/patches/d3-zoom-npm-3.0.0-18f706a421.patch",
51 "escape-string-regexp": "^5.0.0", 57 "escape-string-regexp": "^5.0.0",
52 "lodash-es": "^4.17.21", 58 "lodash-es": "^4.17.21",
53 "loglevel": "^1.8.1", 59 "loglevel": "^1.8.1",
54 "loglevel-plugin-prefix": "^0.8.4", 60 "loglevel-plugin-prefix": "^0.8.4",
55 "mobx": "^6.9.0", 61 "mobx": "^6.10.2",
56 "mobx-react-lite": "^3.4.3", 62 "mobx-react-lite": "^4.0.4",
57 "ms": "^2.1.3", 63 "ms": "^2.1.3",
58 "nanoid": "^4.0.2", 64 "nanoid": "^4.0.2",
59 "notistack": "^3.0.1", 65 "notistack": "^3.0.1",
60 "react": "^18.2.0", 66 "react": "^18.2.0",
61 "react-dom": "^18.2.0", 67 "react-dom": "^18.2.0",
62 "xstate": "^4.37.2", 68 "react-resize-detector": "^9.1.0",
63 "zod": "^3.21.4" 69 "xstate": "^4.38.2",
70 "zod": "^3.22.2"
64 }, 71 },
65 "devDependencies": { 72 "devDependencies": {
66 "@lezer/generator": "^1.3.0", 73 "@lezer/generator": "^1.5.0",
67 "@types/eslint": "^8.40.2", 74 "@types/d3": "^7.4.0",
75 "@types/d3-graphviz": "^2.6.7",
76 "@types/d3-selection": "^3.0.6",
77 "@types/d3-zoom": "^3.0.4",
78 "@types/eslint": "^8.44.2",
68 "@types/html-minifier-terser": "^7.0.0", 79 "@types/html-minifier-terser": "^7.0.0",
69 "@types/lodash-es": "^4.17.7", 80 "@types/lodash-es": "^4.17.9",
70 "@types/micromatch": "^4.0.2", 81 "@types/micromatch": "^4.0.2",
71 "@types/ms": "^0.7.31", 82 "@types/ms": "^0.7.31",
72 "@types/node": "^18.16.18", 83 "@types/node": "^18.17.15",
73 "@types/prettier": "^2.7.3", 84 "@types/pnpapi": "^0.0.2",
74 "@types/react": "^18.2.12", 85 "@types/react": "^18.2.21",
75 "@types/react-dom": "^18.2.5", 86 "@types/react-dom": "^18.2.7",
76 "@typescript-eslint/eslint-plugin": "^5.59.11", 87 "@typescript-eslint/eslint-plugin": "^6.7.0",
77 "@typescript-eslint/parser": "^5.59.11", 88 "@typescript-eslint/parser": "^6.7.0",
78 "@xstate/cli": "^0.5.1", 89 "@vitejs/plugin-react-swc": "^3.3.2",
90 "@xstate/cli": "^0.5.2",
79 "cross-env": "^7.0.3", 91 "cross-env": "^7.0.3",
80 "eslint": "^8.43.0", 92 "eslint": "^8.49.0",
81 "eslint-config-airbnb": "^19.0.4", 93 "eslint-config-airbnb": "^19.0.4",
82 "eslint-config-airbnb-typescript": "^17.0.0", 94 "eslint-config-airbnb-typescript": "^17.1.0",
83 "eslint-config-prettier": "^8.8.0", 95 "eslint-config-prettier": "^9.0.0",
84 "eslint-import-resolver-typescript": "^3.5.5", 96 "eslint-import-resolver-typescript": "^3.6.0",
85 "eslint-plugin-import": "^2.27.5", 97 "eslint-plugin-import": "^2.28.1",
86 "eslint-plugin-jsx-a11y": "^6.7.1", 98 "eslint-plugin-jsx-a11y": "^6.7.1",
87 "eslint-plugin-mobx": "^0.0.9", 99 "eslint-plugin-mobx": "^0.0.9",
88 "eslint-plugin-prettier": "^4.2.1", 100 "eslint-plugin-prettier": "^5.0.0",
89 "eslint-plugin-react": "^7.32.2", 101 "eslint-plugin-react": "^7.33.2",
90 "eslint-plugin-react-hooks": "^4.6.0", 102 "eslint-plugin-react-hooks": "^4.6.0",
91 "html-minifier-terser": "^7.2.0", 103 "html-minifier-terser": "^7.2.0",
92 "micromatch": "^4.0.5", 104 "micromatch": "^4.0.5",
93 "prettier": "^2.8.8", 105 "pnpapi": "^0.0.0",
94 "typescript": "5.1.3", 106 "prettier": "^3.0.3",
95 "vite": "^4.3.9", 107 "typescript": "5.2.2",
96 "vite-plugin-pwa": "^0.16.4", 108 "vite": "^4.4.9",
109 "vite-plugin-pwa": "^0.16.5",
97 "workbox-window": "^7.0.0" 110 "workbox-window": "^7.0.0"
98 } 111 }
99} 112}
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/ModelWorkArea.tsx b/subprojects/frontend/src/ModelWorkArea.tsx
new file mode 100644
index 00000000..16e16a97
--- /dev/null
+++ b/subprojects/frontend/src/ModelWorkArea.tsx
@@ -0,0 +1,193 @@
1/*
2 * SPDX-FileCopyrightText: 2021-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 SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied';
9import CircularProgress from '@mui/material/CircularProgress';
10import IconButton from '@mui/material/IconButton';
11import Stack from '@mui/material/Stack';
12import Tab from '@mui/material/Tab';
13import Tabs from '@mui/material/Tabs';
14import { styled } from '@mui/material/styles';
15import { observer } from 'mobx-react-lite';
16
17import DirectionalSplitPane from './DirectionalSplitPane';
18import Loading from './Loading';
19import { useRootStore } from './RootStoreProvider';
20import type GeneratedModelStore from './editor/GeneratedModelStore';
21import GraphPane from './graph/GraphPane';
22import type GraphStore from './graph/GraphStore';
23import TablePane from './table/TablePane';
24import type ThemeStore from './theme/ThemeStore';
25
26const SplitGraphPane = observer(function SplitGraphPane({
27 graph,
28 themeStore,
29}: {
30 graph: GraphStore;
31 themeStore: ThemeStore;
32}): JSX.Element {
33 return (
34 <DirectionalSplitPane
35 primary={<GraphPane graph={graph} />}
36 secondary={<TablePane graph={graph} />}
37 primaryOnly={!themeStore.showTable}
38 secondaryOnly={!themeStore.showGraph}
39 />
40 );
41});
42
43const GenerationStatus = styled('div', {
44 name: 'ModelWorkArea-GenerationStatus',
45 shouldForwardProp: (prop) => prop !== 'error',
46})<{ error: boolean }>(({ error, theme }) => ({
47 color: error ? theme.palette.error.main : theme.palette.text.primary,
48}));
49
50const GeneratedModelPane = observer(function GeneratedModelPane({
51 generatedModel,
52 themeStore,
53}: {
54 generatedModel: GeneratedModelStore;
55 themeStore: ThemeStore;
56}): JSX.Element {
57 const { message, error, graph } = generatedModel;
58
59 if (graph !== undefined) {
60 return <SplitGraphPane graph={graph} themeStore={themeStore} />;
61 }
62
63 return (
64 <Stack
65 direction="column"
66 alignItems="center"
67 justifyContent="center"
68 height="100%"
69 width="100%"
70 overflow="hidden"
71 my={2}
72 >
73 <Stack
74 direction="column"
75 alignItems="center"
76 flexGrow={1}
77 flexShrink={1}
78 flexBasis={0}
79 sx={(theme) => ({
80 maxHeight: '6rem',
81 height: 'calc(100% - 8rem)',
82 marginBottom: theme.spacing(1),
83 padding: error ? 0 : theme.spacing(1),
84 color: theme.palette.text.secondary,
85 '.MuiCircularProgress-root, .MuiCircularProgress-svg, .MuiSvgIcon-root':
86 {
87 height: '100% !important',
88 width: '100% !important',
89 },
90 })}
91 >
92 {error ? (
93 <SentimentVeryDissatisfiedIcon
94 className="VisibilityDialog-emptyIcon"
95 fontSize="inherit"
96 />
97 ) : (
98 <CircularProgress color="inherit" />
99 )}
100 </Stack>
101 <GenerationStatus error={error}>{message}</GenerationStatus>
102 </Stack>
103 );
104});
105
106function ModelWorkArea(): JSX.Element {
107 const { editorStore, themeStore } = useRootStore();
108
109 if (editorStore === undefined) {
110 return <Loading />;
111 }
112
113 const { graph, generatedModels, selectedGeneratedModel } = editorStore;
114
115 const generatedModelNames: string[] = [];
116 const generatedModelTabs: JSX.Element[] = [];
117 generatedModels.forEach((value, key) => {
118 generatedModelNames.push(key);
119 /* eslint-disable react/no-array-index-key -- Key is a string here, not the array index. */
120 generatedModelTabs.push(
121 <Tab
122 label={value.title}
123 key={key}
124 onAuxClick={(event) => {
125 if (event.button === 1) {
126 editorStore.deleteGeneratedModel(key);
127 event.preventDefault();
128 event.stopPropagation();
129 }
130 }}
131 />,
132 );
133 /* eslint-enable react/no-array-index-key */
134 });
135 const generatedModel =
136 selectedGeneratedModel === undefined
137 ? undefined
138 : generatedModels.get(selectedGeneratedModel);
139 const selectedIndex =
140 selectedGeneratedModel === undefined
141 ? 0
142 : generatedModelNames.indexOf(selectedGeneratedModel) + 1;
143
144 return (
145 <Stack direction="column" height="100%" width="100%" overflow="hidden">
146 <Stack
147 direction="row"
148 sx={(theme) => ({
149 display: generatedModelNames.length === 0 ? 'none' : 'flex',
150 alignItems: 'center',
151 borderBottom: `1px solid ${theme.palette.outer.border}`,
152 })}
153 >
154 <Tabs
155 value={selectedIndex}
156 onChange={(_event, value) => {
157 if (value === 0) {
158 editorStore.selectGeneratedModel(undefined);
159 } else if (typeof value === 'number') {
160 editorStore.selectGeneratedModel(generatedModelNames[value - 1]);
161 }
162 }}
163 variant="scrollable"
164 scrollButtons="auto"
165 sx={{ flexGrow: 1 }}
166 >
167 <Tab label="Initial model" />
168 {generatedModelTabs}
169 </Tabs>
170 <IconButton
171 aria-label="Close generated model"
172 onClick={() =>
173 editorStore.deleteGeneratedModel(selectedGeneratedModel)
174 }
175 disabled={selectedIndex === 0}
176 sx={{ mx: 1 }}
177 >
178 <CloseIcon fontSize="small" />
179 </IconButton>
180 </Stack>
181 {generatedModel === undefined ? (
182 <SplitGraphPane graph={graph} themeStore={themeStore} />
183 ) : (
184 <GeneratedModelPane
185 generatedModel={generatedModel}
186 themeStore={themeStore}
187 />
188 )}
189 </Stack>
190 );
191}
192
193export default observer(ModelWorkArea);
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..a1fbf7dc
--- /dev/null
+++ b/subprojects/frontend/src/WorkArea.tsx
@@ -0,0 +1,25 @@
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 ModelWorkArea from './ModelWorkArea';
11import { useRootStore } from './RootStoreProvider';
12import EditorPane from './editor/EditorPane';
13
14export default observer(function WorkArea(): JSX.Element {
15 const { themeStore } = useRootStore();
16
17 return (
18 <DirectionalSplitPane
19 primary={<EditorPane />}
20 secondary={<ModelWorkArea />}
21 primaryOnly={!themeStore.showGraph && !themeStore.showTable}
22 secondaryOnly={!themeStore.showCode}
23 />
24 );
25});
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..606aabea 100644
--- a/subprojects/frontend/src/editor/AnimatedButton.tsx
+++ b/subprojects/frontend/src/editor/AnimatedButton.tsx
@@ -45,10 +45,10 @@ export default function AnimatedButton({
45 children, 45 children,
46}: { 46}: {
47 'aria-label'?: string; 47 'aria-label'?: string;
48 onClick?: () => void; 48 onClick?: React.MouseEventHandler<HTMLElement>;
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..9508858d 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -26,9 +26,13 @@ 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';
35import GeneratedModelStore from './GeneratedModelStore';
32import LintPanelStore from './LintPanelStore'; 36import LintPanelStore from './LintPanelStore';
33import SearchPanelStore from './SearchPanelStore'; 37import SearchPanelStore from './SearchPanelStore';
34import createEditorState from './createEditorState'; 38import createEditorState from './createEditorState';
@@ -54,13 +58,26 @@ export default class EditorStore {
54 58
55 readonly lintPanel: LintPanelStore; 59 readonly lintPanel: LintPanelStore;
56 60
61 readonly delayedErrors: EditorErrors;
62
57 showLineNumbers = false; 63 showLineNumbers = false;
58 64
59 disposed = false; 65 disposed = false;
60 66
67 analyzing = false;
68
69 semanticsError: string | undefined;
70
71 graph: GraphStore;
72
73 generatedModels = new Map<string, GeneratedModelStore>();
74
75 selectedGeneratedModel: string | undefined;
76
61 constructor(initialValue: string, pwaStore: PWAStore) { 77 constructor(initialValue: string, pwaStore: PWAStore) {
62 this.id = nanoid(); 78 this.id = nanoid();
63 this.state = createEditorState(initialValue, this); 79 this.state = createEditorState(initialValue, this);
80 this.delayedErrors = new EditorErrors(this);
64 this.searchPanel = new SearchPanelStore(this); 81 this.searchPanel = new SearchPanelStore(this);
65 this.lintPanel = new LintPanelStore(this); 82 this.lintPanel = new LintPanelStore(this);
66 (async () => { 83 (async () => {
@@ -75,6 +92,7 @@ export default class EditorStore {
75 })().catch((error) => { 92 })().catch((error) => {
76 log.error('Failed to load XtextClient', error); 93 log.error('Failed to load XtextClient', error);
77 }); 94 });
95 this.graph = new GraphStore();
78 makeAutoObservable<EditorStore, 'client'>(this, { 96 makeAutoObservable<EditorStore, 'client'>(this, {
79 id: false, 97 id: false,
80 state: observable.ref, 98 state: observable.ref,
@@ -213,19 +231,6 @@ export default class EditorStore {
213 this.doCommand(nextDiagnostic); 231 this.doCommand(nextDiagnostic);
214 } 232 }
215 233
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 { 234 updateSemanticHighlighting(ranges: IHighlightRange[]): void {
230 this.dispatch(setSemanticHighlighting(ranges)); 235 this.dispatch(setSemanticHighlighting(ranges));
231 } 236 }
@@ -282,8 +287,109 @@ export default class EditorStore {
282 return true; 287 return true;
283 } 288 }
284 289
290 analysisStarted() {
291 this.analyzing = true;
292 }
293
294 analysisCompleted(semanticAnalysisSkipped = false) {
295 this.analyzing = false;
296 if (semanticAnalysisSkipped) {
297 this.semanticsError = undefined;
298 }
299 }
300
301 setSemanticsError(semanticsError: string) {
302 this.semanticsError = semanticsError;
303 }
304
305 setSemantics(semantics: SemanticsSuccessResult) {
306 this.semanticsError = undefined;
307 this.graph.setSemantics(semantics);
308 }
309
285 dispose(): void { 310 dispose(): void {
286 this.client?.dispose(); 311 this.client?.dispose();
312 this.delayedErrors.dispose();
287 this.disposed = true; 313 this.disposed = true;
288 } 314 }
315
316 startModelGeneration(randomSeed?: number): void {
317 this.client
318 ?.startModelGeneration(randomSeed)
319 ?.catch((error) => log.error('Could not start model generation', error));
320 }
321
322 addGeneratedModel(uuid: string, randomSeed: number): void {
323 this.generatedModels.set(uuid, new GeneratedModelStore(randomSeed));
324 this.selectGeneratedModel(uuid);
325 }
326
327 cancelModelGeneration(): void {
328 this.client
329 ?.cancelModelGeneration()
330 ?.catch((error) => log.error('Could not start model generation', error));
331 }
332
333 selectGeneratedModel(uuid: string | undefined): void {
334 if (uuid === undefined) {
335 this.selectedGeneratedModel = uuid;
336 return;
337 }
338 if (this.generatedModels.has(uuid)) {
339 this.selectedGeneratedModel = uuid;
340 return;
341 }
342 this.selectedGeneratedModel = undefined;
343 }
344
345 deleteGeneratedModel(uuid: string | undefined): void {
346 if (uuid === undefined) {
347 return;
348 }
349 if (this.selectedGeneratedModel === uuid) {
350 let previous: string | undefined;
351 let found: string | undefined;
352 this.generatedModels.forEach((_value, key) => {
353 if (key === uuid) {
354 found = previous;
355 }
356 previous = key;
357 });
358 this.selectGeneratedModel(found);
359 }
360 const generatedModel = this.generatedModels.get(uuid);
361 if (generatedModel !== undefined && generatedModel.running) {
362 this.cancelModelGeneration();
363 }
364 this.generatedModels.delete(uuid);
365 }
366
367 modelGenerationCancelled(): void {
368 this.generatedModels.forEach((value) =>
369 value.setError('Model generation cancelled'),
370 );
371 }
372
373 setGeneratedModelMessage(uuid: string, message: string): void {
374 this.generatedModels.get(uuid)?.setMessage(message);
375 }
376
377 setGeneratedModelError(uuid: string, message: string): void {
378 this.generatedModels.get(uuid)?.setError(message);
379 }
380
381 setGeneratedModelSemantics(
382 uuid: string,
383 semantics: SemanticsSuccessResult,
384 ): void {
385 this.generatedModels.get(uuid)?.setSemantics(semantics);
386 }
387
388 get generating(): boolean {
389 let generating = false;
390 this.generatedModels.forEach((value) => {
391 generating = generating || value.running;
392 });
393 return generating;
394 }
289} 395}
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..b6b1655a 100644
--- a/subprojects/frontend/src/editor/GenerateButton.tsx
+++ b/subprojects/frontend/src/editor/GenerateButton.tsx
@@ -4,10 +4,9 @@
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 CloseIcon from '@mui/icons-material/Close';
8import PlayArrowIcon from '@mui/icons-material/PlayArrow'; 9import 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'; 10import { observer } from 'mobx-react-lite';
12 11
13import AnimatedButton from './AnimatedButton'; 12import AnimatedButton from './AnimatedButton';
@@ -18,26 +17,59 @@ const GENERATE_LABEL = 'Generate';
18const GenerateButton = observer(function GenerateButton({ 17const GenerateButton = observer(function GenerateButton({
19 editorStore, 18 editorStore,
20 hideWarnings, 19 hideWarnings,
21 sx,
22}: { 20}: {
23 editorStore: EditorStore | undefined; 21 editorStore: EditorStore | undefined;
24 hideWarnings?: boolean | undefined; 22 hideWarnings?: boolean | undefined;
25 sx?: SxProps<Theme> | undefined;
26}): JSX.Element { 23}): JSX.Element {
27 if (editorStore === undefined) { 24 if (editorStore === undefined) {
28 return ( 25 return (
29 <Button 26 <AnimatedButton color="inherit" disabled>
27 Loading&hellip;
28 </AnimatedButton>
29 );
30 }
31
32 const {
33 delayedErrors: { analyzing, errorCount, warningCount, semanticsError },
34 generating,
35 } = editorStore;
36
37 if (analyzing) {
38 return (
39 <AnimatedButton color="inherit" disabled>
40 Analyzing&hellip;
41 </AnimatedButton>
42 );
43 }
44
45 if (generating) {
46 return (
47 <AnimatedButton
30 color="inherit" 48 color="inherit"
31 className="rounded shaded" 49 onClick={() => editorStore.cancelModelGeneration()}
32 disabled 50 startIcon={<CloseIcon />}
33 {...(sx === undefined ? {} : { sx })}
34 > 51 >
35 Loading&hellip; 52 Cancel
36 </Button> 53 </AnimatedButton>
37 ); 54 );
38 } 55 }
39 56
40 const { errorCount, warningCount } = editorStore; 57 if (semanticsError !== undefined && editorStore.opened) {
58 return (
59 <AnimatedButton
60 color="error"
61 disabled
62 startIcon={<CancelIcon />}
63 sx={(theme) => ({
64 '&.Mui-disabled': {
65 color: `${theme.palette.error.main} !important`,
66 },
67 })}
68 >
69 Analysis error
70 </AnimatedButton>
71 );
72 }
41 73
42 const diagnostics: string[] = []; 74 const diagnostics: string[] = [];
43 if (errorCount > 0) { 75 if (errorCount > 0) {
@@ -54,8 +86,7 @@ const GenerateButton = observer(function GenerateButton({
54 aria-label={`Select next diagnostic out of ${summary}`} 86 aria-label={`Select next diagnostic out of ${summary}`}
55 onClick={() => editorStore.nextDiagnostic()} 87 onClick={() => editorStore.nextDiagnostic()}
56 color="error" 88 color="error"
57 startIcon={<DangerousOutlinedIcon />} 89 startIcon={<CancelIcon />}
58 {...(sx === undefined ? {} : { sx })}
59 > 90 >
60 {summary} 91 {summary}
61 </AnimatedButton> 92 </AnimatedButton>
@@ -67,7 +98,13 @@ const GenerateButton = observer(function GenerateButton({
67 disabled={!editorStore.opened} 98 disabled={!editorStore.opened}
68 color={warningCount > 0 ? 'warning' : 'primary'} 99 color={warningCount > 0 ? 'warning' : 'primary'}
69 startIcon={<PlayArrowIcon />} 100 startIcon={<PlayArrowIcon />}
70 {...(sx === undefined ? {} : { sx })} 101 onClick={(event) => {
102 if (event.shiftKey) {
103 editorStore.startModelGeneration(1);
104 } else {
105 editorStore.startModelGeneration();
106 }
107 }}
71 > 108 >
72 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`} 109 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`}
73 </AnimatedButton> 110 </AnimatedButton>
@@ -76,7 +113,6 @@ const GenerateButton = observer(function GenerateButton({
76 113
77GenerateButton.defaultProps = { 114GenerateButton.defaultProps = {
78 hideWarnings: false, 115 hideWarnings: false,
79 sx: undefined,
80}; 116};
81 117
82export default GenerateButton; 118export default GenerateButton;
diff --git a/subprojects/frontend/src/editor/GeneratedModelStore.ts b/subprojects/frontend/src/editor/GeneratedModelStore.ts
new file mode 100644
index 00000000..5088d603
--- /dev/null
+++ b/subprojects/frontend/src/editor/GeneratedModelStore.ts
@@ -0,0 +1,50 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { makeAutoObservable } from 'mobx';
8
9import GraphStore from '../graph/GraphStore';
10import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
11
12export default class GeneratedModelStore {
13 title: string;
14
15 message = 'Waiting for server';
16
17 error = false;
18
19 graph: GraphStore | undefined;
20
21 constructor(randomSeed: number) {
22 const time = new Date().toLocaleTimeString(undefined, { hour12: false });
23 this.title = `Generated at ${time} (${randomSeed})`;
24 makeAutoObservable(this);
25 }
26
27 get running(): boolean {
28 return !this.error && this.graph === undefined;
29 }
30
31 setMessage(message: string): void {
32 if (this.running) {
33 this.message = message;
34 }
35 }
36
37 setError(message: string): void {
38 if (this.running) {
39 this.error = true;
40 this.message = message;
41 }
42 }
43
44 setSemantics(semantics: SemanticsSuccessResult): void {
45 if (this.running) {
46 this.graph = new GraphStore();
47 this.graph.setSemantics(semantics);
48 }
49 }
50}
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..d5801b9a
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphArea.tsx
@@ -0,0 +1,46 @@
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 DotGraphVisualizer from './DotGraphVisualizer';
13import type GraphStore from './GraphStore';
14import VisibilityPanel from './VisibilityPanel';
15import ZoomCanvas from './ZoomCanvas';
16
17function GraphArea({ graph }: { graph: GraphStore }): JSX.Element {
18 const { breakpoints } = useTheme();
19 const { ref, width, height } = useResizeDetector({
20 refreshMode: 'debounce',
21 });
22
23 const breakpoint = breakpoints.values.sm;
24 const dialog =
25 width === undefined ||
26 height === undefined ||
27 width < breakpoint ||
28 height < breakpoint;
29
30 return (
31 <Box
32 width="100%"
33 height="100%"
34 overflow="hidden"
35 position="relative"
36 ref={ref}
37 >
38 <ZoomCanvas>
39 {(fitZoom) => <DotGraphVisualizer graph={graph} fitZoom={fitZoom} />}
40 </ZoomCanvas>
41 <VisibilityPanel graph={graph} dialog={dialog} />
42 </Box>
43 );
44}
45
46export 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..67dbfcbd
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphPane.tsx
@@ -0,0 +1,34 @@
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
12import type GraphStore from './GraphStore';
13
14const GraphArea = lazy(() => import('./GraphArea'));
15
16export default function GraphPane({
17 graph,
18}: {
19 graph: GraphStore;
20}): JSX.Element {
21 return (
22 <Stack
23 direction="column"
24 height="100%"
25 overflow="auto"
26 alignItems="center"
27 justifyContent="center"
28 >
29 <Suspense fallback={<Loading />}>
30 <GraphArea graph={graph} />
31 </Suspense>
32 </Stack>
33 );
34}
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..14d58b96
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphTheme.tsx
@@ -0,0 +1,111 @@
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 '.edge': {
72 '& text': {
73 fontFamily: theme.typography.fontFamily,
74 fill: theme.palette.text.primary,
75 },
76 '& [stroke="black"]': {
77 stroke: theme.palette.text.primary,
78 },
79 '& [fill="black"]': {
80 fill: theme.palette.text.primary,
81 },
82 },
83 ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'),
84 ...createEdgeColor('ERROR', theme.palette.error.main),
85 '.icon': {
86 maskSize: '12px 12px',
87 maskPosition: '50% 50%',
88 maskRepeat: 'no-repeat',
89 width: '100%',
90 height: '100%',
91 },
92 '.icon-TRUE': {
93 maskImage: svgURL(labelSVG),
94 background: theme.palette.text.primary,
95 },
96 '.icon-UNKNOWN': {
97 maskImage: svgURL(labelOutlinedSVG),
98 background: theme.palette.text.secondary,
99 },
100 '.icon-ERROR': {
101 maskImage: svgURL(cancelSVG),
102 background: theme.palette.error.main,
103 },
104 'text.label-UNKNOWN': {
105 fill: theme.palette.text.secondary,
106 },
107 'text.label-ERROR': {
108 fill: theme.palette.error.main,
109 },
110 },
111}));
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..bd358dfa
--- /dev/null
+++ b/subprojects/frontend/src/graph/dotSource.ts
@@ -0,0 +1,366 @@
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(
123 graph: GraphStore,
124 nodeData: NodeData[],
125 lines: string[],
126): void {
127 const {
128 semantics: { nodes },
129 scopes,
130 } = graph;
131
132 nodes.forEach((node, i) => {
133 const data = nodeData[i];
134 if (data === undefined || data.isolated || data.exists === 'FALSE') {
135 return;
136 }
137 const classList = [
138 `node-${node.kind}`,
139 `node-exists-${data.exists}`,
140 `node-equalsSelf-${data.equalsSelf}`,
141 ];
142 if (data.unaryPredicates.size === 0) {
143 classList.push('node-empty');
144 }
145 const classes = classList.join(' ');
146 const name = nodeName(graph, node);
147 const border = node.kind === 'INDIVIDUAL' ? 2 : 1;
148 const count = scopes ? ` ${data.count}` : '';
149 lines.push(`n${i} [id="${node.name}", class="${classes}", label=<
150 <table border="${border}" cellborder="0" cellspacing="0" style="rounded" bgcolor="white">
151 <tr><td cellpadding="4.5" width="32" bgcolor="green">${name}${count}</td></tr>`);
152 if (data.unaryPredicates.size > 0) {
153 lines.push(
154 '<hr/><tr><td cellpadding="4.5"><table fixedsize="TRUE" align="left" border="0" cellborder="0" cellspacing="0" cellpadding="1.5">',
155 );
156 data.unaryPredicates.forEach((value, relation) => {
157 lines.push(
158 `<tr>
159 <td><img src="#${value}"/></td>
160 <td width="1.5"></td>
161 <td align="left" href="#${value}" id="${node.name},${
162 relation.name
163 },label">${relationName(graph, relation)}</td>
164 </tr>`,
165 );
166 });
167 lines.push('</table></td></tr>');
168 }
169 lines.push('</table>>]');
170 });
171}
172
173function compare(
174 a: readonly (number | string)[],
175 b: readonly number[],
176): number {
177 if (a.length !== b.length + 1) {
178 throw new Error('Tuple length mismatch');
179 }
180 for (let i = 0; i < b.length; i += 1) {
181 const aItem = a[i];
182 const bItem = b[i];
183 if (typeof aItem !== 'number' || typeof bItem !== 'number') {
184 throw new Error('Invalid tuple');
185 }
186 if (aItem < bItem) {
187 return -1;
188 }
189 if (aItem > bItem) {
190 return 1;
191 }
192 }
193 return 0;
194}
195
196function binarySerach(
197 tuples: readonly (readonly (number | string)[])[],
198 key: readonly number[],
199): string | undefined {
200 let lower = 0;
201 let upper = tuples.length - 1;
202 while (lower <= upper) {
203 const middle = Math.floor((lower + upper) / 2);
204 const tuple = tuples[middle];
205 if (tuple === undefined) {
206 throw new Error('Range error');
207 }
208 const result = compare(tuple, key);
209 if (result === 0) {
210 const found = tuple[key.length];
211 if (typeof found !== 'string') {
212 throw new Error('Invalid tuple value');
213 }
214 return found;
215 }
216 if (result < 0) {
217 lower = middle + 1;
218 } else {
219 // result > 0
220 upper = middle - 1;
221 }
222 }
223 return undefined;
224}
225
226function createRelationEdges(
227 graph: GraphStore,
228 nodeData: NodeData[],
229 relation: RelationMetadata,
230 showUnknown: boolean,
231 lines: string[],
232): void {
233 const {
234 semantics: { nodes, partialInterpretation },
235 } = graph;
236 const { detail } = relation;
237
238 let constraint: 'true' | 'false' = 'true';
239 let weight = EDGE_WEIGHT;
240 let penwidth = 1;
241 const name = graph.getName(relation);
242 let label = `"${name}"`;
243 if (detail.type === 'reference' && detail.containment) {
244 weight = CONTAINMENT_WEIGHT;
245 label = `<<b>${name}</b>>`;
246 penwidth = 2;
247 } else if (
248 detail.type === 'opposite' &&
249 graph.getVisibility(detail.opposite) !== 'none'
250 ) {
251 constraint = 'false';
252 weight = 0;
253 }
254
255 const tuples = partialInterpretation[relation.name] ?? [];
256 tuples.forEach(([from, to, value]) => {
257 const isUnknown = value === 'UNKNOWN';
258 if (
259 (!showUnknown && isUnknown) ||
260 typeof from !== 'number' ||
261 typeof to !== 'number' ||
262 typeof value !== 'string'
263 ) {
264 return;
265 }
266
267 const fromNode = nodes[from];
268 const toNode = nodes[to];
269 if (fromNode === undefined || toNode === undefined) {
270 return;
271 }
272
273 const fromData = nodeData[from];
274 const toData = nodeData[to];
275 if (
276 fromData === undefined ||
277 fromData.exists === 'FALSE' ||
278 toData === undefined ||
279 toData.exists === 'FALSE'
280 ) {
281 return;
282 }
283
284 let dir = 'forward';
285 let edgeConstraint = constraint;
286 let edgeWeight = weight;
287 const opposite = binarySerach(tuples, [to, from]);
288 const oppositeUnknown = opposite === 'UNKNOWN';
289 const oppositeSet = opposite !== undefined;
290 const oppositeVisible = oppositeSet && (showUnknown || !oppositeUnknown);
291 if (opposite === value) {
292 if (to < from) {
293 // We already added this edge in the reverse direction.
294 return;
295 }
296 if (to > from) {
297 dir = 'both';
298 }
299 } else if (oppositeVisible && to < from) {
300 // Let the opposite edge drive the graph layout.
301 edgeConstraint = 'false';
302 edgeWeight = 0;
303 } else if (isUnknown && (!oppositeSet || oppositeUnknown)) {
304 // Only apply the UNKNOWN value penalty if we aren't the opposite
305 // edge driving the graph layout from above, or the penalty would
306 // be applied anyway.
307 edgeWeight *= UNKNOWN_WEIGHT_FACTOR;
308 }
309
310 lines.push(`n${from} -> n${to} [
311 id="${fromNode.name},${toNode.name},${relation.name}",
312 dir="${dir}",
313 constraint=${edgeConstraint},
314 weight=${edgeWeight},
315 xlabel=${label},
316 penwidth=${penwidth},
317 arrowsize=${penwidth >= 2 ? 0.875 : 1},
318 style="${isUnknown ? 'dashed' : 'solid'}",
319 class="edge-${value}"
320 ]`);
321 });
322}
323
324function createEdges(
325 graph: GraphStore,
326 nodeData: NodeData[],
327 lines: string[],
328): void {
329 const {
330 semantics: { relations },
331 } = graph;
332 relations.forEach((relation) => {
333 if (relation.arity !== 2) {
334 return;
335 }
336 const visibility = graph.getVisibility(relation.name);
337 if (visibility !== 'none') {
338 createRelationEdges(
339 graph,
340 nodeData,
341 relation,
342 visibility === 'all',
343 lines,
344 );
345 }
346 });
347}
348
349export default function dotSource(
350 graph: GraphStore | undefined,
351): [string, number] | undefined {
352 if (graph === undefined) {
353 return undefined;
354 }
355 const lines = [
356 'digraph {',
357 'graph [bgcolor=transparent];',
358 `node [fontsize=12, shape=plain, fontname="OpenSans"];`,
359 'edge [fontsize=10.5, color=black, fontname="OpenSans"];',
360 ];
361 const nodeData = computeNodeData(graph);
362 createNodes(graph, nodeData, lines);
363 createEdges(graph, nodeData, lines);
364 lines.push('}');
365 return [lines.join('\n'), lines.length];
366}
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..60debd6b 100644
--- a/subprojects/frontend/src/index.tsx
+++ b/subprojects/frontend/src/index.tsx
@@ -4,46 +4,113 @@
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.
14class Person { 15// https://github.com/mui/material-ui/issues/32727#issuecomment-1659945548
15 Person[] friend opposite friend 16(window as unknown as { fixViteIssue: unknown }).fixViteIssue = styled;
17
18const initialValue = `% Metamodel
19
20abstract class CompositeElement {
21 contains Region[] regions
16} 22}
17 23
18class Post { 24class Region {
19 Person author 25 contains Vertex[] vertices opposite region
20 Post[0..1] replyTo
21} 26}
22 27
23// Constraints 28abstract class Vertex {
24error replyToNotFriend(Post x, Post y) <-> 29 container Region region opposite vertices
25 replyTo(x, y), 30 contains Transition[] outgoingTransition opposite source
26 author(x, xAuthor), 31 Transition[] incomingTransition opposite target
27 author(y, yAuthor), 32}
28 !friend(xAuthor, yAuthor). 33
29 34class Transition {
30error replyToCycle(Post x) <-> replyTo+(x,x). 35 container Vertex source opposite outgoingTransition
31 36 Vertex[1] target opposite incomingTransition
32// Instance model 37}
33Person(a). 38
34Person(b). 39abstract class Pseudostate extends Vertex.
35friend(a, b). 40
36friend(b, a). 41abstract class RegularState extends Vertex.
37Post(p1). 42
38author(p1, a). 43class Entry extends Pseudostate.
39Post(p2). 44
40author(p2, b). 45class Exit extends Pseudostate.
41replyTo(p2, p1). 46
42 47class Choice extends Pseudostate.
43!author(Post::new, a). // Automatically inferred: author(Post::new, b). 48
44 49class FinalState extends RegularState.
45// Scope 50
46scope Post = 10..15, Person += 0. 51class State extends RegularState, CompositeElement.
52
53class Statechart extends CompositeElement.
54
55% Constraints
56
57%% Entry
58
59pred entryInRegion(Region r, Entry e) <->
60 vertices(r, e).
61
62error noEntryInRegion(Region r) <->
63 !entryInRegion(r, _).
64
65error multipleEntryInRegion(Region r) <->
66 entryInRegion(r, e1),
67 entryInRegion(r, e2),
68 e1 != e2.
69
70error incomingToEntry(Transition t, Entry e) <->
71 target(t, e).
72
73error noOutgoingTransitionFromEntry(Entry e) <->
74 !source(_, e).
75
76error multipleTransitionFromEntry(Entry e, Transition t1, Transition t2) <->
77 outgoingTransition(e, t1),
78 outgoingTransition(e, t2),
79 t1 != t2.
80
81%% Exit
82
83error outgoingFromExit(Transition t, Exit e) <->
84 source(t, e).
85
86%% Final
87
88error outgoingFromFinal(Transition t, FinalState e) <->
89 source(t, e).
90
91%% State vs Region
92
93pred stateInRegion(Region r, State s) <->
94 vertices(r, s).
95
96error noStateInRegion(Region r) <->
97 !stateInRegion(r, _).
98
99%% Choice
100
101error choiceHasNoOutgoing(Choice c) <->
102 !source(_, c).
103
104error choiceHasNoIncoming(Choice c) <->
105 !target(_, c).
106
107% Instance model
108
109Statechart(sct).
110
111% Scope
112
113scope node = 20..30, Region = 2..*, Choice = 1..*, Statechart += 0.
47`; 114`;
48 115
49configure({ 116configure({
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/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..166b8adf
--- /dev/null
+++ b/subprojects/frontend/src/table/TableArea.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 TableArea({ 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(TableArea);
diff --git a/subprojects/frontend/src/table/TablePane.tsx b/subprojects/frontend/src/table/TablePane.tsx
new file mode 100644
index 00000000..8b640217
--- /dev/null
+++ b/subprojects/frontend/src/table/TablePane.tsx
@@ -0,0 +1,27 @@
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';
11import type GraphStore from '../graph/GraphStore';
12
13const TableArea = lazy(() => import('./TableArea'));
14
15export default function TablePane({
16 graph,
17}: {
18 graph: GraphStore;
19}): JSX.Element {
20 return (
21 <Stack direction="column" height="100%" overflow="auto" alignItems="center">
22 <Suspense fallback={<Loading />}>
23 <TableArea graph={graph} />
24 </Suspense>
25 </Stack>
26 );
27}
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/ModelGenerationService.ts b/subprojects/frontend/src/xtext/ModelGenerationService.ts
new file mode 100644
index 00000000..29a70623
--- /dev/null
+++ b/subprojects/frontend/src/xtext/ModelGenerationService.ts
@@ -0,0 +1,66 @@
1/*
2 * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import type { Transaction } from '@codemirror/state';
8
9import type EditorStore from '../editor/EditorStore';
10
11import type UpdateService from './UpdateService';
12import { ModelGenerationResult } from './xtextServiceResults';
13
14const INITIAL_RANDOM_SEED = 1;
15
16export default class ModelGenerationService {
17 private nextRandomSeed = INITIAL_RANDOM_SEED;
18
19 constructor(
20 private readonly store: EditorStore,
21 private readonly updateService: UpdateService,
22 ) {}
23
24 onPush(push: unknown): void {
25 const result = ModelGenerationResult.parse(push);
26 if ('status' in result) {
27 this.store.setGeneratedModelMessage(result.uuid, result.status);
28 } else if ('error' in result) {
29 this.store.setGeneratedModelError(result.uuid, result.error);
30 } else {
31 this.store.setGeneratedModelSemantics(result.uuid, result);
32 }
33 }
34
35 onTransaction(transaction: Transaction): void {
36 if (transaction.docChanged) {
37 this.resetRandomSeed();
38 }
39 }
40
41 onDisconnect(): void {
42 this.store.modelGenerationCancelled();
43 this.resetRandomSeed();
44 }
45
46 async start(randomSeed?: number): Promise<void> {
47 const randomSeedOrNext = randomSeed ?? this.nextRandomSeed;
48 this.nextRandomSeed = randomSeedOrNext + 1;
49 const result =
50 await this.updateService.startModelGeneration(randomSeedOrNext);
51 if (!result.cancelled) {
52 this.store.addGeneratedModel(result.data.uuid, randomSeedOrNext);
53 }
54 }
55
56 async cancel(): Promise<void> {
57 const result = await this.updateService.cancelModelGeneration();
58 if (!result.cancelled) {
59 this.store.modelGenerationCancelled();
60 }
61 }
62
63 private resetRandomSeed() {
64 this.nextRandomSeed = INITIAL_RANDOM_SEED;
65 }
66}
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..70e79764 100644
--- a/subprojects/frontend/src/xtext/UpdateService.ts
+++ b/subprojects/frontend/src/xtext/UpdateService.ts
@@ -22,6 +22,7 @@ import {
22 FormattingResult, 22 FormattingResult,
23 isConflictResult, 23 isConflictResult,
24 OccurrencesResult, 24 OccurrencesResult,
25 ModelGenerationStartedResult,
25} from './xtextServiceResults'; 26} from './xtextServiceResults';
26 27
27const UPDATE_TIMEOUT_MS = 500; 28const UPDATE_TIMEOUT_MS = 500;
@@ -133,6 +134,7 @@ export default class UpdateService {
133 return; 134 return;
134 } 135 }
135 log.trace('Editor delta', delta); 136 log.trace('Editor delta', delta);
137 this.store.analysisStarted();
136 const result = await this.webSocketClient.send({ 138 const result = await this.webSocketClient.send({
137 resource: this.resourceName, 139 resource: this.resourceName,
138 serviceType: 'update', 140 serviceType: 'update',
@@ -157,6 +159,7 @@ export default class UpdateService {
157 private async updateFullTextExclusive(): Promise<void> { 159 private async updateFullTextExclusive(): Promise<void> {
158 log.debug('Performing full text update'); 160 log.debug('Performing full text update');
159 this.tracker.prepareFullTextUpdateExclusive(); 161 this.tracker.prepareFullTextUpdateExclusive();
162 this.store.analysisStarted();
160 const result = await this.webSocketClient.send({ 163 const result = await this.webSocketClient.send({
161 resource: this.resourceName, 164 resource: this.resourceName,
162 serviceType: 'update', 165 serviceType: 'update',
@@ -339,4 +342,43 @@ export default class UpdateService {
339 } 342 }
340 return { cancelled: false, data: parsedOccurrencesResult }; 343 return { cancelled: false, data: parsedOccurrencesResult };
341 } 344 }
345
346 async startModelGeneration(
347 randomSeed: number,
348 ): Promise<CancellableResult<ModelGenerationStartedResult>> {
349 try {
350 await this.updateOrThrow();
351 } catch (error) {
352 if (error instanceof CancelledError || error instanceof TimeoutError) {
353 return { cancelled: true };
354 }
355 throw error;
356 }
357 log.debug('Starting model generation');
358 const data = await this.webSocketClient.send({
359 resource: this.resourceName,
360 serviceType: 'modelGeneration',
361 requiredStateId: this.xtextStateId,
362 start: true,
363 randomSeed,
364 });
365 if (isConflictResult(data)) {
366 return { cancelled: true };
367 }
368 const parsedResult = ModelGenerationStartedResult.parse(data);
369 return { cancelled: false, data: parsedResult };
370 }
371
372 async cancelModelGeneration(): Promise<CancellableResult<unknown>> {
373 log.debug('Cancelling model generation');
374 const data = await this.webSocketClient.send({
375 resource: this.resourceName,
376 serviceType: 'modelGeneration',
377 cancel: true,
378 });
379 if (isConflictResult(data)) {
380 return { cancelled: true };
381 }
382 return { cancelled: false, data };
383 }
342} 384}
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..7486d737 100644
--- a/subprojects/frontend/src/xtext/XtextClient.ts
+++ b/subprojects/frontend/src/xtext/XtextClient.ts
@@ -9,6 +9,7 @@ import type {
9 CompletionResult, 9 CompletionResult,
10} from '@codemirror/autocomplete'; 10} from '@codemirror/autocomplete';
11import type { Transaction } from '@codemirror/state'; 11import type { Transaction } from '@codemirror/state';
12import { type IReactionDisposer, reaction } from 'mobx';
12 13
13import type PWAStore from '../PWAStore'; 14import type PWAStore from '../PWAStore';
14import type EditorStore from '../editor/EditorStore'; 15import type EditorStore from '../editor/EditorStore';
@@ -16,7 +17,9 @@ import getLogger from '../utils/getLogger';
16 17
17import ContentAssistService from './ContentAssistService'; 18import ContentAssistService from './ContentAssistService';
18import HighlightingService from './HighlightingService'; 19import HighlightingService from './HighlightingService';
20import ModelGenerationService from './ModelGenerationService';
19import OccurrencesService from './OccurrencesService'; 21import OccurrencesService from './OccurrencesService';
22import SemanticsService from './SemanticsService';
20import UpdateService from './UpdateService'; 23import UpdateService from './UpdateService';
21import ValidationService from './ValidationService'; 24import ValidationService from './ValidationService';
22import XtextWebSocketClient from './XtextWebSocketClient'; 25import XtextWebSocketClient from './XtextWebSocketClient';
@@ -37,7 +40,16 @@ export default class XtextClient {
37 40
38 private readonly occurrencesService: OccurrencesService; 41 private readonly occurrencesService: OccurrencesService;
39 42
40 constructor(store: EditorStore, private readonly pwaStore: PWAStore) { 43 private readonly semanticsService: SemanticsService;
44
45 private readonly modelGenerationService: ModelGenerationService;
46
47 private readonly keepAliveDisposer: IReactionDisposer;
48
49 constructor(
50 private readonly store: EditorStore,
51 private readonly pwaStore: PWAStore,
52 ) {
41 this.webSocketClient = new XtextWebSocketClient( 53 this.webSocketClient = new XtextWebSocketClient(
42 () => this.onReconnect(), 54 () => this.onReconnect(),
43 () => this.onDisconnect(), 55 () => this.onDisconnect(),
@@ -51,6 +63,16 @@ export default class XtextClient {
51 ); 63 );
52 this.validationService = new ValidationService(store, this.updateService); 64 this.validationService = new ValidationService(store, this.updateService);
53 this.occurrencesService = new OccurrencesService(store, this.updateService); 65 this.occurrencesService = new OccurrencesService(store, this.updateService);
66 this.semanticsService = new SemanticsService(store, this.validationService);
67 this.modelGenerationService = new ModelGenerationService(
68 store,
69 this.updateService,
70 );
71 this.keepAliveDisposer = reaction(
72 () => store.generating,
73 (generating) => this.webSocketClient.setKeepAlive(generating),
74 { fireImmediately: true },
75 );
54 } 76 }
55 77
56 start(): void { 78 start(): void {
@@ -64,9 +86,11 @@ export default class XtextClient {
64 } 86 }
65 87
66 private onDisconnect(): void { 88 private onDisconnect(): void {
89 this.store.analysisCompleted(true);
67 this.highlightingService.onDisconnect(); 90 this.highlightingService.onDisconnect();
68 this.validationService.onDisconnect(); 91 this.validationService.onDisconnect();
69 this.occurrencesService.onDisconnect(); 92 this.occurrencesService.onDisconnect();
93 this.modelGenerationService.onDisconnect();
70 } 94 }
71 95
72 onTransaction(transaction: Transaction): void { 96 onTransaction(transaction: Transaction): void {
@@ -75,6 +99,7 @@ export default class XtextClient {
75 this.contentAssistService.onTransaction(transaction); 99 this.contentAssistService.onTransaction(transaction);
76 this.updateService.onTransaction(transaction); 100 this.updateService.onTransaction(transaction);
77 this.occurrencesService.onTransaction(transaction); 101 this.occurrencesService.onTransaction(transaction);
102 this.modelGenerationService.onTransaction(transaction);
78 } 103 }
79 104
80 private onPush( 105 private onPush(
@@ -93,7 +118,7 @@ export default class XtextClient {
93 ); 118 );
94 return; 119 return;
95 } 120 }
96 if (stateId !== xtextStateId) { 121 if (stateId !== xtextStateId && service !== 'modelGeneration') {
97 log.error( 122 log.error(
98 'Unexpected xtext state id: expected:', 123 'Unexpected xtext state id: expected:',
99 xtextStateId, 124 xtextStateId,
@@ -111,6 +136,12 @@ export default class XtextClient {
111 case 'validate': 136 case 'validate':
112 this.validationService.onPush(push); 137 this.validationService.onPush(push);
113 return; 138 return;
139 case 'semantics':
140 this.semanticsService.onPush(push);
141 return;
142 case 'modelGeneration':
143 this.modelGenerationService.onPush(push);
144 return;
114 default: 145 default:
115 throw new Error('Unknown service'); 146 throw new Error('Unknown service');
116 } 147 }
@@ -120,6 +151,14 @@ export default class XtextClient {
120 return this.contentAssistService.contentAssist(context); 151 return this.contentAssistService.contentAssist(context);
121 } 152 }
122 153
154 startModelGeneration(randomSeed?: number): Promise<void> {
155 return this.modelGenerationService.start(randomSeed);
156 }
157
158 cancelModelGeneration(): Promise<void> {
159 return this.modelGenerationService.cancel();
160 }
161
123 formatText(): void { 162 formatText(): void {
124 this.updateService.formatText().catch((e) => { 163 this.updateService.formatText().catch((e) => {
125 log.error('Error while formatting text', e); 164 log.error('Error while formatting text', e);
@@ -127,6 +166,7 @@ export default class XtextClient {
127 } 166 }
128 167
129 dispose(): void { 168 dispose(): void {
169 this.keepAliveDisposer();
130 this.webSocketClient.disconnect(); 170 this.webSocketClient.disconnect();
131 } 171 }
132} 172}
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
index 6bb7eec8..280ac875 100644
--- a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
+++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
@@ -204,7 +204,7 @@ export default class XtextWebSocketClient {
204 204
205 get state() { 205 get state() {
206 this.stateAtom.reportObserved(); 206 this.stateAtom.reportObserved();
207 return this.interpreter.state; 207 return this.interpreter.getSnapshot();
208 } 208 }
209 209
210 get opening(): boolean { 210 get opening(): boolean {
@@ -270,6 +270,12 @@ export default class XtextWebSocketClient {
270 return promise; 270 return promise;
271 } 271 }
272 272
273 setKeepAlive(keepAlive: boolean): void {
274 this.interpreter.send({
275 type: keepAlive ? 'GENERATION_STARTED' : 'GENERATION_ENDED',
276 });
277 }
278
273 private updateVisibility(): void { 279 private updateVisibility(): void {
274 this.interpreter.send(document.hidden ? 'TAB_HIDDEN' : 'TAB_VISIBLE'); 280 this.interpreter.send(document.hidden ? 'TAB_HIDDEN' : 'TAB_VISIBLE');
275 } 281 }
@@ -282,7 +288,10 @@ export default class XtextWebSocketClient {
282 log.debug('Creating WebSocket'); 288 log.debug('Creating WebSocket');
283 289
284 (async () => { 290 (async () => {
285 const { webSocketURL } = await fetchBackendConfig(); 291 let { webSocketURL } = await fetchBackendConfig();
292 if (webSocketURL === undefined) {
293 webSocketURL = `${window.origin.replace(/^http/, 'ws')}/xtext-service`;
294 }
286 this.openWebSocketWithURL(webSocketURL); 295 this.openWebSocketWithURL(webSocketURL);
287 })().catch((error) => { 296 })().catch((error) => {
288 log.error('Error while initializing connection', error); 297 log.error('Error while initializing connection', error);
diff --git a/subprojects/frontend/src/xtext/webSocketMachine.ts b/subprojects/frontend/src/xtext/webSocketMachine.ts
index 2fb1f52f..9113286f 100644
--- a/subprojects/frontend/src/xtext/webSocketMachine.ts
+++ b/subprojects/frontend/src/xtext/webSocketMachine.ts
@@ -27,6 +27,8 @@ export type WebSocketEvent =
27 | { type: 'PAGE_RESUME' } 27 | { type: 'PAGE_RESUME' }
28 | { type: 'ONLINE' } 28 | { type: 'ONLINE' }
29 | { type: 'OFFLINE' } 29 | { type: 'OFFLINE' }
30 | { type: 'GENERATION_STARTED' }
31 | { type: 'GENERATION_ENDED' }
30 | { type: 'ERROR'; message: string }; 32 | { type: 'ERROR'; message: string };
31 33
32export default createMachine( 34export default createMachine(
@@ -105,7 +107,7 @@ export default createMachine(
105 initial: 'opening', 107 initial: 'opening',
106 states: { 108 states: {
107 opening: { 109 opening: {
108 always: [{ target: '#timedOut', in: '#tabHidden' }], 110 always: [{ target: '#timedOut', in: '#mayDisconnect' }],
109 after: { 111 after: {
110 OPEN_TIMEOUT: { 112 OPEN_TIMEOUT: {
111 actions: 'raiseTimeoutError', 113 actions: 'raiseTimeoutError',
@@ -143,7 +145,7 @@ export default createMachine(
143 initial: 'active', 145 initial: 'active',
144 states: { 146 states: {
145 active: { 147 active: {
146 always: [{ target: 'inactive', in: '#tabHidden' }], 148 always: [{ target: 'inactive', in: '#mayDisconnect' }],
147 }, 149 },
148 inactive: { 150 inactive: {
149 always: [{ target: 'active', in: '#tabVisible' }], 151 always: [{ target: 'active', in: '#tabVisible' }],
@@ -173,14 +175,44 @@ export default createMachine(
173 visibleOrUnknown: { 175 visibleOrUnknown: {
174 id: 'tabVisible', 176 id: 'tabVisible',
175 on: { 177 on: {
176 TAB_HIDDEN: 'hidden', 178 TAB_HIDDEN: [
179 { target: 'hidden.mayDisconnect', in: '#generationIdle' },
180 { target: 'hidden.keepAlive', in: '#generationRunning' },
181 ],
177 }, 182 },
178 }, 183 },
179 hidden: { 184 hidden: {
180 id: 'tabHidden',
181 on: { 185 on: {
182 TAB_VISIBLE: 'visibleOrUnknown', 186 TAB_VISIBLE: 'visibleOrUnknown',
183 }, 187 },
188 initial: 'mayDisconnect',
189 states: {
190 mayDisconnect: {
191 id: 'mayDisconnect',
192 always: { target: 'keepAlive', in: '#generationRunning' },
193 },
194 keepAlive: {
195 id: 'keepAlive',
196 always: { target: 'mayDisconnect', in: '#generationIdle' },
197 },
198 },
199 },
200 },
201 },
202 generation: {
203 initial: 'idle',
204 states: {
205 idle: {
206 id: 'generationIdle',
207 on: {
208 GENERATION_STARTED: 'running',
209 },
210 },
211 running: {
212 id: 'generationRunning',
213 on: {
214 GENERATION_ENDED: 'idle',
215 },
184 }, 216 },
185 }, 217 },
186 }, 218 },
diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts
index bbbff064..15831c5a 100644
--- a/subprojects/frontend/src/xtext/xtextMessages.ts
+++ b/subprojects/frontend/src/xtext/xtextMessages.ts
@@ -34,7 +34,12 @@ 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 'modelGeneration',
42]);
38 43
39export type XtextWebPushService = z.infer<typeof XtextWebPushService>; 44export type XtextWebPushService = z.infer<typeof XtextWebPushService>;
40 45
diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts
index d3b467ad..e473bd48 100644
--- a/subprojects/frontend/src/xtext/xtextServiceResults.ts
+++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts
@@ -125,3 +125,73 @@ 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 ModelGenerationStartedResult = z.object({
130 uuid: z.string().nonempty(),
131});
132
133export type ModelGenerationStartedResult = z.infer<
134 typeof ModelGenerationStartedResult
135>;
136
137export const NodeMetadata = z.object({
138 name: z.string(),
139 simpleName: z.string(),
140 kind: z.enum(['IMPLICIT', 'INDIVIDUAL', 'NEW']),
141});
142
143export type NodeMetadata = z.infer<typeof NodeMetadata>;
144
145export const RelationMetadata = z.object({
146 name: z.string(),
147 simpleName: z.string(),
148 arity: z.number().nonnegative(),
149 detail: z.union([
150 z.object({ type: z.literal('class'), abstractClass: z.boolean() }),
151 z.object({ type: z.literal('reference'), containment: z.boolean() }),
152 z.object({
153 type: z.literal('opposite'),
154 container: z.boolean(),
155 opposite: z.string(),
156 }),
157 z.object({ type: z.literal('predicate'), error: z.boolean() }),
158 z.object({ type: z.literal('builtin') }),
159 ]),
160});
161
162export type RelationMetadata = z.infer<typeof RelationMetadata>;
163
164export const SemanticsSuccessResult = z.object({
165 nodes: NodeMetadata.array(),
166 relations: RelationMetadata.array(),
167 partialInterpretation: z.record(
168 z.string(),
169 z.union([z.number(), z.string()]).array().array(),
170 ),
171});
172
173export type SemanticsSuccessResult = z.infer<typeof SemanticsSuccessResult>;
174
175export const SemanticsResult = z.union([
176 z.object({ error: z.string() }),
177 z.object({ issues: Issue.array() }),
178 SemanticsSuccessResult,
179]);
180
181export type SemanticsResult = z.infer<typeof SemanticsResult>;
182
183export const ModelGenerationResult = z.union([
184 z.object({
185 uuid: z.string().nonempty(),
186 status: z.string(),
187 }),
188 z.object({
189 uuid: z.string().nonempty(),
190 error: z.string(),
191 }),
192 SemanticsSuccessResult.extend({
193 uuid: z.string().nonempty(),
194 }),
195]);
196
197export type ModelGenerationResult = z.infer<typeof ModelGenerationResult>;
diff --git a/subprojects/frontend/tsconfig.base.json b/subprojects/frontend/tsconfig.base.json
index 5ef50b5e..545eca35 100644
--- a/subprojects/frontend/tsconfig.base.json
+++ b/subprojects/frontend/tsconfig.base.json
@@ -2,7 +2,7 @@
2 * Copyright (c) Microsoft Corporation. 2 * Copyright (c) Microsoft Corporation.
3 * Copyright (c) 2023 The Refinery Authors <https://refinery.tools/> 3 * Copyright (c) 2023 The Refinery Authors <https://refinery.tools/>
4 * 4 *
5 * SPDX-License-Identifier: MIT OR EPL-2.0 5 * SPDX-License-Identifier: MIT
6 * 6 *
7 * This file is based on 7 * This file is based on
8 * https://github.com/tsconfig/bases/blob/7db25a41bc5a9c0f66d91f6f3aa28438afcb2f18/bases/strictest.json 8 * https://github.com/tsconfig/bases/blob/7db25a41bc5a9c0f66d91f6f3aa28438afcb2f18/bases/strictest.json
diff --git a/subprojects/frontend/types/ImportMeta.d.ts b/subprojects/frontend/types/ImportMeta.d.ts
index f5a32ef1..096f088b 100644
--- a/subprojects/frontend/types/ImportMeta.d.ts
+++ b/subprojects/frontend/types/ImportMeta.d.ts
@@ -2,7 +2,7 @@
2 * Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors 2 * Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors
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
6 */ 6 */
7 7
8interface ImportMeta { 8interface ImportMeta {
diff --git a/subprojects/frontend/types/grammar.d.ts b/subprojects/frontend/types/grammar.d.ts
index e7a7eebf..92f99ec3 100644
--- a/subprojects/frontend/types/grammar.d.ts
+++ b/subprojects/frontend/types/grammar.d.ts
@@ -2,7 +2,7 @@
2 * Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others 2 * Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> 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
6 */ 6 */
7 7
8declare module '*.grammar' { 8declare module '*.grammar' {
diff --git a/subprojects/frontend/types/node/@lezer-generator-rollup.d.ts b/subprojects/frontend/types/node/@lezer-generator-rollup.d.ts
index 4ef9f4e3..ce89a44c 100644
--- a/subprojects/frontend/types/node/@lezer-generator-rollup.d.ts
+++ b/subprojects/frontend/types/node/@lezer-generator-rollup.d.ts
@@ -2,7 +2,7 @@
2 * Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> and others 2 * Copyright (C) 2018 by Marijn Haverbeke <marijn@haverbeke.berlin> 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
6 */ 6 */
7 7
8// We have to explicitly redeclare the type of the `./rollup` ESM export of `@lezer/generator`, 8// We have to explicitly redeclare the type of the `./rollup` ESM export of `@lezer/generator`,
diff --git a/subprojects/frontend/vite.config.ts b/subprojects/frontend/vite.config.ts
index 9e08ccc4..63d5245f 100644
--- a/subprojects/frontend/vite.config.ts
+++ b/subprojects/frontend/vite.config.ts
@@ -17,6 +17,7 @@ import detectDevModeOptions, {
17 API_ENDPOINT, 17 API_ENDPOINT,
18} from './config/detectDevModeOptions'; 18} from './config/detectDevModeOptions';
19import fetchPackageMetadata from './config/fetchPackageMetadata'; 19import fetchPackageMetadata from './config/fetchPackageMetadata';
20import graphvizUMDVitePlugin from './config/graphvizUMDVitePlugin';
20import manifest from './config/manifest'; 21import manifest from './config/manifest';
21import minifyHTMLVitePlugin from './config/minifyHTMLVitePlugin'; 22import minifyHTMLVitePlugin from './config/minifyHTMLVitePlugin';
22import preloadFontsVitePlugin from './config/preloadFontsVitePlugin'; 23import preloadFontsVitePlugin from './config/preloadFontsVitePlugin';
@@ -29,8 +30,8 @@ const { mode, isDevelopment, devModePlugins, serverOptions } =
29process.env['NODE_ENV'] ??= mode; 30process.env['NODE_ENV'] ??= mode;
30 31
31const fontsGlob = [ 32const fontsGlob = [
32 'inter-latin-variable-wghtOnly-normal-*.woff2', 33 'open-sans-latin-wdth-{normal,italic}-*.woff2',
33 'jetbrains-mono-latin-variable-wghtOnly-{normal,italic}-*.woff2', 34 'jetbrains-mono-latin-wght-{normal,italic}-*.woff2',
34]; 35];
35 36
36const viteConfig: ViteConfig = { 37const viteConfig: ViteConfig = {
@@ -43,6 +44,7 @@ const viteConfig: ViteConfig = {
43 lezer(), 44 lezer(),
44 preloadFontsVitePlugin(fontsGlob), 45 preloadFontsVitePlugin(fontsGlob),
45 minifyHTMLVitePlugin(), 46 minifyHTMLVitePlugin(),
47 graphvizUMDVitePlugin(),
46 VitePWA({ 48 VitePWA({
47 strategies: 'generateSW', 49 strategies: 'generateSW',
48 registerType: 'prompt', 50 registerType: 'prompt',