diff options
Diffstat (limited to 'subprojects/frontend')
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 | ||
19 | val viteOutputDir = "$buildDir/vite" | 19 | val viteOutputDir = layout.buildDirectory.dir("vite") |
20 | 20 | ||
21 | val productionResources = file("$viteOutputDir/production") | 21 | val productionResources = viteOutputDir.map { it.dir("production") } |
22 | 22 | ||
23 | val productionAssets: Configuration by configurations.creating { | 23 | val 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 { | |||
140 | sonarqube.properties { | 140 | sonarqube.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 | |||
7 | import { readFile } from 'node:fs/promises'; | ||
8 | import path from 'node:path'; | ||
9 | |||
10 | import pnpapi from 'pnpapi'; | ||
11 | import type { PluginOption, ResolvedConfig } from 'vite'; | ||
12 | |||
13 | // Use a CJS file as the PnP resolution issuer to force resolution to a non-ESM export. | ||
14 | const issuerFileName = 'worker.cjs'; | ||
15 | |||
16 | export 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 | |||
7 | import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; | ||
8 | import MoreVertIcon from '@mui/icons-material/MoreVert'; | ||
9 | import Box from '@mui/material/Box'; | ||
10 | import Stack from '@mui/material/Stack'; | ||
11 | import { alpha, useTheme } from '@mui/material/styles'; | ||
12 | import { useCallback, useRef, useState } from 'react'; | ||
13 | import { useResizeDetector } from 'react-resize-detector'; | ||
14 | |||
15 | export 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 | |||
156 | DirectionalSplitPane.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 | |||
7 | import CloseIcon from '@mui/icons-material/Close'; | ||
8 | import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; | ||
9 | import CircularProgress from '@mui/material/CircularProgress'; | ||
10 | import IconButton from '@mui/material/IconButton'; | ||
11 | import Stack from '@mui/material/Stack'; | ||
12 | import Tab from '@mui/material/Tab'; | ||
13 | import Tabs from '@mui/material/Tabs'; | ||
14 | import { styled } from '@mui/material/styles'; | ||
15 | import { observer } from 'mobx-react-lite'; | ||
16 | |||
17 | import DirectionalSplitPane from './DirectionalSplitPane'; | ||
18 | import Loading from './Loading'; | ||
19 | import { useRootStore } from './RootStoreProvider'; | ||
20 | import type GeneratedModelStore from './editor/GeneratedModelStore'; | ||
21 | import GraphPane from './graph/GraphPane'; | ||
22 | import type GraphStore from './graph/GraphStore'; | ||
23 | import TablePane from './table/TablePane'; | ||
24 | import type ThemeStore from './theme/ThemeStore'; | ||
25 | |||
26 | const 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 | |||
43 | const 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 | |||
50 | const 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 | |||
106 | function 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 | |||
193 | export 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 | |||
7 | import CodeIcon from '@mui/icons-material/Code'; | ||
8 | import SchemaRoundedIcon from '@mui/icons-material/SchemaRounded'; | ||
9 | import TableChartIcon from '@mui/icons-material/TableChart'; | ||
10 | import ToggleButton from '@mui/material/ToggleButton'; | ||
11 | import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; | ||
12 | import { alpha, styled } from '@mui/material/styles'; | ||
13 | import { observer } from 'mobx-react-lite'; | ||
14 | |||
15 | import type ThemeStore from './theme/ThemeStore'; | ||
16 | |||
17 | const 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 | |||
82 | function 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 | |||
140 | PaneButtons.defaultProps = { | ||
141 | hideLabel: false, | ||
142 | }; | ||
143 | |||
144 | export 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 | ||
11 | import TopBar from './TopBar'; | 11 | import TopBar from './TopBar'; |
12 | import UpdateNotification from './UpdateNotification'; | 12 | import UpdateNotification from './UpdateNotification'; |
13 | import EditorPane from './editor/EditorPane'; | 13 | import WorkArea from './WorkArea'; |
14 | 14 | ||
15 | export default function Refinery(): JSX.Element { | 15 | export 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 | ||
7 | import GitHubIcon from '@mui/icons-material/GitHub'; | 7 | import GitHubIcon from '@mui/icons-material/GitHub'; |
8 | import AppBar from '@mui/material/AppBar'; | 8 | import AppBar from '@mui/material/AppBar'; |
9 | import Button from '@mui/material/Button'; | ||
10 | import IconButton from '@mui/material/IconButton'; | 9 | import IconButton from '@mui/material/IconButton'; |
11 | import Stack from '@mui/material/Stack'; | 10 | import Stack from '@mui/material/Stack'; |
12 | import Toolbar from '@mui/material/Toolbar'; | 11 | import Toolbar from '@mui/material/Toolbar'; |
@@ -17,6 +16,7 @@ import { throttle } from 'lodash-es'; | |||
17 | import { observer } from 'mobx-react-lite'; | 16 | import { observer } from 'mobx-react-lite'; |
18 | import { useEffect, useMemo, useState } from 'react'; | 17 | import { useEffect, useMemo, useState } from 'react'; |
19 | 18 | ||
19 | import PaneButtons from './PaneButtons'; | ||
20 | import { useRootStore } from './RootStoreProvider'; | 20 | import { useRootStore } from './RootStoreProvider'; |
21 | import ToggleDarkModeButton from './ToggleDarkModeButton'; | 21 | import ToggleDarkModeButton from './ToggleDarkModeButton'; |
22 | import GenerateButton from './editor/GenerateButton'; | 22 | import GenerateButton from './editor/GenerateButton'; |
@@ -65,11 +65,12 @@ const DevModeBadge = styled('div')(({ theme }) => ({ | |||
65 | })); | 65 | })); |
66 | 66 | ||
67 | export default observer(function TopBar(): JSX.Element { | 67 | export 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 | |||
7 | import { observer } from 'mobx-react-lite'; | ||
8 | |||
9 | import DirectionalSplitPane from './DirectionalSplitPane'; | ||
10 | import ModelWorkArea from './ModelWorkArea'; | ||
11 | import { useRootStore } from './RootStoreProvider'; | ||
12 | import EditorPane from './editor/EditorPane'; | ||
13 | |||
14 | export 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 | |||
7 | import { reaction } from 'mobx'; | ||
8 | import { type SnackbarKey, useSnackbar } from 'notistack'; | ||
9 | import { useEffect, useState } from 'react'; | ||
10 | |||
11 | import type EditorStore from './EditorStore'; | ||
12 | |||
13 | function 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 | |||
40 | export 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 | ||
7 | import type { Diagnostic } from '@codemirror/lint'; | 7 | import type { Diagnostic } from '@codemirror/lint'; |
8 | import CancelIcon from '@mui/icons-material/Cancel'; | ||
8 | import CheckIcon from '@mui/icons-material/Check'; | 9 | import CheckIcon from '@mui/icons-material/Check'; |
9 | import ErrorIcon from '@mui/icons-material/Error'; | ||
10 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; | 10 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; |
11 | import FormatPaint from '@mui/icons-material/FormatPaint'; | 11 | import FormatPaint from '@mui/icons-material/FormatPaint'; |
12 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | 12 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; |
@@ -28,7 +28,7 @@ import type EditorStore from './EditorStore'; | |||
28 | function getLintIcon(severity: Diagnostic['severity'] | undefined) { | 28 | function 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 | |||
7 | import { Diagnostic } from '@codemirror/lint'; | ||
8 | import { type IReactionDisposer, makeAutoObservable, reaction } from 'mobx'; | ||
9 | |||
10 | import type EditorStore from './EditorStore'; | ||
11 | |||
12 | const HYSTERESIS_TIME_MS = 250; | ||
13 | |||
14 | export interface State { | ||
15 | analyzing: boolean; | ||
16 | errorCount: number; | ||
17 | warningCount: number; | ||
18 | infoCount: number; | ||
19 | semanticsError: string | undefined; | ||
20 | } | ||
21 | |||
22 | export 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 | ||
14 | import { useRootStore } from '../RootStoreProvider'; | 14 | import { useRootStore } from '../RootStoreProvider'; |
15 | 15 | ||
16 | import AnalysisErrorNotification from './AnalysisErrorNotification'; | ||
16 | import ConnectionStatusNotification from './ConnectionStatusNotification'; | 17 | import ConnectionStatusNotification from './ConnectionStatusNotification'; |
17 | import EditorArea from './EditorArea'; | 18 | import EditorArea from './EditorArea'; |
18 | import EditorButtons from './EditorButtons'; | 19 | import 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'; | |||
26 | import { nanoid } from 'nanoid'; | 26 | import { nanoid } from 'nanoid'; |
27 | 27 | ||
28 | import type PWAStore from '../PWAStore'; | 28 | import type PWAStore from '../PWAStore'; |
29 | import GraphStore from '../graph/GraphStore'; | ||
29 | import getLogger from '../utils/getLogger'; | 30 | import getLogger from '../utils/getLogger'; |
30 | import type XtextClient from '../xtext/XtextClient'; | 31 | import type XtextClient from '../xtext/XtextClient'; |
32 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
31 | 33 | ||
34 | import EditorErrors from './EditorErrors'; | ||
35 | import GeneratedModelStore from './GeneratedModelStore'; | ||
32 | import LintPanelStore from './LintPanelStore'; | 36 | import LintPanelStore from './LintPanelStore'; |
33 | import SearchPanelStore from './SearchPanelStore'; | 37 | import SearchPanelStore from './SearchPanelStore'; |
34 | import createEditorState from './createEditorState'; | 38 | import 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 | ||
7 | import errorSVG from '@material-icons/svg/svg/error/baseline.svg?raw'; | 7 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; |
8 | import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw'; | 8 | import expandMoreSVG from '@material-icons/svg/svg/expand_more/baseline.svg?raw'; |
9 | import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; | 9 | import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; |
10 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; | 10 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; |
11 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; | 11 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; |
12 | 12 | ||
13 | function svgURL(svg: string): string { | 13 | import svgURL from '../utils/svgURL'; |
14 | return `url('data:image/svg+xml;utf8,${svg}')`; | ||
15 | } | ||
16 | 14 | ||
17 | export default styled('div', { | 15 | export 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 | ||
7 | import DangerousOutlinedIcon from '@mui/icons-material/DangerousOutlined'; | 7 | import CancelIcon from '@mui/icons-material/Cancel'; |
8 | import CloseIcon from '@mui/icons-material/Close'; | ||
8 | import PlayArrowIcon from '@mui/icons-material/PlayArrow'; | 9 | import PlayArrowIcon from '@mui/icons-material/PlayArrow'; |
9 | import Button from '@mui/material/Button'; | ||
10 | import type { SxProps, Theme } from '@mui/material/styles'; | ||
11 | import { observer } from 'mobx-react-lite'; | 10 | import { observer } from 'mobx-react-lite'; |
12 | 11 | ||
13 | import AnimatedButton from './AnimatedButton'; | 12 | import AnimatedButton from './AnimatedButton'; |
@@ -18,26 +17,59 @@ const GENERATE_LABEL = 'Generate'; | |||
18 | const GenerateButton = observer(function GenerateButton({ | 17 | const 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… | ||
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… | ||
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… | 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 | ||
77 | GenerateButton.defaultProps = { | 114 | GenerateButton.defaultProps = { |
78 | hideWarnings: false, | 115 | hideWarnings: false, |
79 | sx: undefined, | ||
80 | }; | 116 | }; |
81 | 117 | ||
82 | export default GenerateButton; | 118 | export 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 | |||
7 | import { makeAutoObservable } from 'mobx'; | ||
8 | |||
9 | import GraphStore from '../graph/GraphStore'; | ||
10 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
11 | |||
12 | export 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 | |||
7 | import * as d3 from 'd3'; | ||
8 | import { type Graphviz, graphviz } from 'd3-graphviz'; | ||
9 | import type { BaseType, Selection } from 'd3-selection'; | ||
10 | import { reaction, type IReactionDisposer } from 'mobx'; | ||
11 | import { observer } from 'mobx-react-lite'; | ||
12 | import { useCallback, useRef, useState } from 'react'; | ||
13 | |||
14 | import getLogger from '../utils/getLogger'; | ||
15 | |||
16 | import type GraphStore from './GraphStore'; | ||
17 | import GraphTheme from './GraphTheme'; | ||
18 | import { FitZoomCallback } from './ZoomCanvas'; | ||
19 | import dotSource from './dotSource'; | ||
20 | import postProcessSvg from './postProcessSVG'; | ||
21 | |||
22 | const LOG = getLogger('graph.DotGraphVisualizer'); | ||
23 | |||
24 | function ptToPx(pt: number): number { | ||
25 | return (pt * 4) / 3; | ||
26 | } | ||
27 | |||
28 | function 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 | |||
156 | DotGraphVisualizer.defaultProps = { | ||
157 | fitZoom: undefined, | ||
158 | transitionTime: 250, | ||
159 | animateThreshold: 100, | ||
160 | }; | ||
161 | |||
162 | export 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 | |||
7 | import Box from '@mui/material/Box'; | ||
8 | import { useTheme } from '@mui/material/styles'; | ||
9 | import { observer } from 'mobx-react-lite'; | ||
10 | import { useResizeDetector } from 'react-resize-detector'; | ||
11 | |||
12 | import DotGraphVisualizer from './DotGraphVisualizer'; | ||
13 | import type GraphStore from './GraphStore'; | ||
14 | import VisibilityPanel from './VisibilityPanel'; | ||
15 | import ZoomCanvas from './ZoomCanvas'; | ||
16 | |||
17 | function 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 | |||
46 | export 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 | |||
7 | import Stack from '@mui/material/Stack'; | ||
8 | import { Suspense, lazy } from 'react'; | ||
9 | |||
10 | import Loading from '../Loading'; | ||
11 | |||
12 | import type GraphStore from './GraphStore'; | ||
13 | |||
14 | const GraphArea = lazy(() => import('./GraphArea')); | ||
15 | |||
16 | export 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 | |||
7 | import { makeAutoObservable, observable } from 'mobx'; | ||
8 | |||
9 | import type { | ||
10 | RelationMetadata, | ||
11 | SemanticsSuccessResult, | ||
12 | } from '../xtext/xtextServiceResults'; | ||
13 | |||
14 | export type Visibility = 'all' | 'must' | 'none'; | ||
15 | |||
16 | export 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 | |||
35 | export 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 | |||
51 | export 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 | |||
7 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; | ||
8 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; | ||
9 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; | ||
10 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; | ||
11 | |||
12 | import svgURL from '../utils/svgURL'; | ||
13 | |||
14 | function 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 | |||
34 | export 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 | |||
7 | import { styled } from '@mui/material/styles'; | ||
8 | import { observer } from 'mobx-react-lite'; | ||
9 | |||
10 | import { RelationMetadata } from '../xtext/xtextServiceResults'; | ||
11 | |||
12 | const Error = styled('span', { | ||
13 | name: 'RelationName-Error', | ||
14 | })(({ theme }) => ({ | ||
15 | color: theme.palette.error.main, | ||
16 | })); | ||
17 | |||
18 | const Qualifier = styled('span', { | ||
19 | name: 'RelationName-Qualifier', | ||
20 | })(({ theme }) => ({ | ||
21 | color: theme.palette.text.secondary, | ||
22 | })); | ||
23 | |||
24 | const 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 | |||
44 | function 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 | |||
68 | RelationName.defaultProps = { | ||
69 | abbreviate: false, | ||
70 | }; | ||
71 | |||
72 | export 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 | |||
7 | import CloseIcon from '@mui/icons-material/Close'; | ||
8 | import FilterListIcon from '@mui/icons-material/FilterList'; | ||
9 | import LabelIcon from '@mui/icons-material/Label'; | ||
10 | import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; | ||
11 | import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; | ||
12 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; | ||
13 | import Button from '@mui/material/Button'; | ||
14 | import Checkbox from '@mui/material/Checkbox'; | ||
15 | import FormControlLabel from '@mui/material/FormControlLabel'; | ||
16 | import IconButton from '@mui/material/IconButton'; | ||
17 | import Switch from '@mui/material/Switch'; | ||
18 | import Typography from '@mui/material/Typography'; | ||
19 | import { styled } from '@mui/material/styles'; | ||
20 | import { observer } from 'mobx-react-lite'; | ||
21 | import { useId } from 'react'; | ||
22 | |||
23 | import type GraphStore from './GraphStore'; | ||
24 | import { isVisibilityAllowed } from './GraphStore'; | ||
25 | import RelationName from './RelationName'; | ||
26 | |||
27 | const 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 | |||
174 | function 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 | |||
311 | VisibilityDialog.defaultProps = { | ||
312 | dialog: false, | ||
313 | }; | ||
314 | |||
315 | export 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 | |||
7 | import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; | ||
8 | import TuneIcon from '@mui/icons-material/Tune'; | ||
9 | import Badge from '@mui/material/Badge'; | ||
10 | import Dialog from '@mui/material/Dialog'; | ||
11 | import IconButton from '@mui/material/IconButton'; | ||
12 | import Paper from '@mui/material/Paper'; | ||
13 | import Slide from '@mui/material/Slide'; | ||
14 | import { styled } from '@mui/material/styles'; | ||
15 | import { observer } from 'mobx-react-lite'; | ||
16 | import { useCallback, useId, useState } from 'react'; | ||
17 | |||
18 | import type GraphStore from './GraphStore'; | ||
19 | import VisibilityDialog from './VisibilityDialog'; | ||
20 | |||
21 | const 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 | |||
42 | function 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 | |||
91 | export 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 | |||
7 | import AddIcon from '@mui/icons-material/Add'; | ||
8 | import CropFreeIcon from '@mui/icons-material/CropFree'; | ||
9 | import RemoveIcon from '@mui/icons-material/Remove'; | ||
10 | import IconButton from '@mui/material/IconButton'; | ||
11 | import Stack from '@mui/material/Stack'; | ||
12 | import ToggleButton from '@mui/material/ToggleButton'; | ||
13 | |||
14 | import type { ChangeZoomCallback, SetFitZoomCallback } from './ZoomCanvas'; | ||
15 | |||
16 | export 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 | |||
7 | import Box from '@mui/material/Box'; | ||
8 | import * as d3 from 'd3'; | ||
9 | import { zoom as d3Zoom } from 'd3-zoom'; | ||
10 | import React, { useCallback, useRef, useState } from 'react'; | ||
11 | import { useResizeDetector } from 'react-resize-detector'; | ||
12 | |||
13 | import ZoomButtons from './ZoomButtons'; | ||
14 | |||
15 | declare 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 | |||
27 | interface Transform { | ||
28 | x: number; | ||
29 | y: number; | ||
30 | k: number; | ||
31 | } | ||
32 | |||
33 | export type ChangeZoomCallback = (factor: number) => void; | ||
34 | |||
35 | export type SetFitZoomCallback = (fitZoom: boolean) => void; | ||
36 | |||
37 | export type FitZoomCallback = ((newSize?: { | ||
38 | width: number; | ||
39 | height: number; | ||
40 | }) => void) & | ||
41 | ((newSize: boolean) => void); | ||
42 | |||
43 | export 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 | |||
220 | ZoomCanvas.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 | |||
7 | import type { | ||
8 | NodeMetadata, | ||
9 | RelationMetadata, | ||
10 | } from '../xtext/xtextServiceResults'; | ||
11 | |||
12 | import type GraphStore from './GraphStore'; | ||
13 | |||
14 | const EDGE_WEIGHT = 1; | ||
15 | const CONTAINMENT_WEIGHT = 5; | ||
16 | const UNKNOWN_WEIGHT_FACTOR = 0.5; | ||
17 | |||
18 | function 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 | |||
28 | function 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 | |||
40 | interface NodeData { | ||
41 | isolated: boolean; | ||
42 | exists: string; | ||
43 | equalsSelf: string; | ||
44 | unaryPredicates: Map<RelationMetadata, string>; | ||
45 | count: string; | ||
46 | } | ||
47 | |||
48 | function 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 | |||
122 | function 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 | |||
173 | function 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 | |||
196 | function 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 | |||
226 | function 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 | |||
324 | function 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 | |||
349 | export 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 | |||
18 | export interface BBox { | ||
19 | x: number; | ||
20 | y: number; | ||
21 | width: number; | ||
22 | height: number; | ||
23 | } | ||
24 | |||
25 | function parsePoints(points: string[]): BBox { | ||
26 | const x = points.map((p) => Number(p.split(',')[0] ?? 0)); | ||
27 | const y = points.map((p) => Number(p.split(',')[1] ?? 0)); | ||
28 | const xmin = Math.min.apply(null, x); | ||
29 | const xmax = Math.max.apply(null, x); | ||
30 | const ymin = Math.min.apply(null, y); | ||
31 | const ymax = Math.max.apply(null, y); | ||
32 | return { | ||
33 | x: xmin, | ||
34 | y: ymin, | ||
35 | width: xmax - xmin, | ||
36 | height: ymax - ymin, | ||
37 | }; | ||
38 | } | ||
39 | |||
40 | /** | ||
41 | * Compute the bounding box of a polygon without adding it to the DOM. | ||
42 | * | ||
43 | * Copyed from | ||
44 | * https://github.com/magjac/d3-graphviz/blob/81ab523fe5189a90da2d9d9cc9015c7079eea780/src/element.js#L36-L53 | ||
45 | * | ||
46 | * @param path The polygon to compute the bounding box of. | ||
47 | * @returns The computed bounding box. | ||
48 | */ | ||
49 | export function parsePolygonBBox(polygon: SVGPolygonElement): BBox { | ||
50 | const points = (polygon.getAttribute('points') ?? '').split(' '); | ||
51 | return parsePoints(points); | ||
52 | } | ||
53 | |||
54 | /** | ||
55 | * Compute the bounding box of a path without adding it to the DOM. | ||
56 | * | ||
57 | * Copyed from | ||
58 | * https://github.com/magjac/d3-graphviz/blob/81ab523fe5189a90da2d9d9cc9015c7079eea780/src/element.js#L56-L75 | ||
59 | * | ||
60 | * @param path The path to compute the bounding box of. | ||
61 | * @returns The computed bounding box. | ||
62 | */ | ||
63 | export function parsePathBBox(path: SVGPathElement): BBox { | ||
64 | const d = path.getAttribute('d') ?? ''; | ||
65 | const points = d.split(/[A-Z ]/); | ||
66 | points.shift(); | ||
67 | return parsePoints(points); | ||
68 | } | ||
diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts new file mode 100644 index 00000000..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 | |||
7 | import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; | ||
8 | |||
9 | const SVG_NS = 'http://www.w3.org/2000/svg'; | ||
10 | const XLINK_NS = 'http://www.w3.org/1999/xlink'; | ||
11 | |||
12 | function 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 | |||
21 | function 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 | |||
40 | function 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 | |||
76 | function 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 | |||
90 | function 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 | |||
116 | function 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 | |||
138 | function 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 | |||
169 | export 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 | ||
7 | import { styled } from '@mui/material/styles'; | ||
7 | import { configure } from 'mobx'; | 8 | import { configure } from 'mobx'; |
8 | import { type Root, createRoot } from 'react-dom/client'; | 9 | import { type Root, createRoot } from 'react-dom/client'; |
9 | 10 | ||
10 | import App from './App'; | 11 | import App from './App'; |
11 | import RootStore from './RootStore'; | 12 | import RootStore from './RootStore'; |
12 | 13 | ||
13 | const initialValue = `// Metamodel | 14 | // Make sure `styled` ends up in the entry chunk. |
14 | class 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 | |||
18 | const initialValue = `% Metamodel | ||
19 | |||
20 | abstract class CompositeElement { | ||
21 | contains Region[] regions | ||
16 | } | 22 | } |
17 | 23 | ||
18 | class Post { | 24 | class Region { |
19 | Person author | 25 | contains Vertex[] vertices opposite region |
20 | Post[0..1] replyTo | ||
21 | } | 26 | } |
22 | 27 | ||
23 | // Constraints | 28 | abstract class Vertex { |
24 | error 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 | 34 | class Transition { | |
30 | error replyToCycle(Post x) <-> replyTo+(x,x). | 35 | container Vertex source opposite outgoingTransition |
31 | 36 | Vertex[1] target opposite incomingTransition | |
32 | // Instance model | 37 | } |
33 | Person(a). | 38 | |
34 | Person(b). | 39 | abstract class Pseudostate extends Vertex. |
35 | friend(a, b). | 40 | |
36 | friend(b, a). | 41 | abstract class RegularState extends Vertex. |
37 | Post(p1). | 42 | |
38 | author(p1, a). | 43 | class Entry extends Pseudostate. |
39 | Post(p2). | 44 | |
40 | author(p2, b). | 45 | class Exit extends Pseudostate. |
41 | replyTo(p2, p1). | 46 | |
42 | 47 | class Choice extends Pseudostate. | |
43 | !author(Post::new, a). // Automatically inferred: author(Post::new, b). | 48 | |
44 | 49 | class FinalState extends RegularState. | |
45 | // Scope | 50 | |
46 | scope Post = 10..15, Person += 0. | 51 | class State extends RegularState, CompositeElement. |
52 | |||
53 | class Statechart extends CompositeElement. | ||
54 | |||
55 | % Constraints | ||
56 | |||
57 | %% Entry | ||
58 | |||
59 | pred entryInRegion(Region r, Entry e) <-> | ||
60 | vertices(r, e). | ||
61 | |||
62 | error noEntryInRegion(Region r) <-> | ||
63 | !entryInRegion(r, _). | ||
64 | |||
65 | error multipleEntryInRegion(Region r) <-> | ||
66 | entryInRegion(r, e1), | ||
67 | entryInRegion(r, e2), | ||
68 | e1 != e2. | ||
69 | |||
70 | error incomingToEntry(Transition t, Entry e) <-> | ||
71 | target(t, e). | ||
72 | |||
73 | error noOutgoingTransitionFromEntry(Entry e) <-> | ||
74 | !source(_, e). | ||
75 | |||
76 | error multipleTransitionFromEntry(Entry e, Transition t1, Transition t2) <-> | ||
77 | outgoingTransition(e, t1), | ||
78 | outgoingTransition(e, t2), | ||
79 | t1 != t2. | ||
80 | |||
81 | %% Exit | ||
82 | |||
83 | error outgoingFromExit(Transition t, Exit e) <-> | ||
84 | source(t, e). | ||
85 | |||
86 | %% Final | ||
87 | |||
88 | error outgoingFromFinal(Transition t, FinalState e) <-> | ||
89 | source(t, e). | ||
90 | |||
91 | %% State vs Region | ||
92 | |||
93 | pred stateInRegion(Region r, State s) <-> | ||
94 | vertices(r, s). | ||
95 | |||
96 | error noStateInRegion(Region r) <-> | ||
97 | !stateInRegion(r, _). | ||
98 | |||
99 | %% Choice | ||
100 | |||
101 | error choiceHasNoOutgoing(Choice c) <-> | ||
102 | !source(_, c). | ||
103 | |||
104 | error choiceHasNoIncoming(Choice c) <-> | ||
105 | !target(_, c). | ||
106 | |||
107 | % Instance model | ||
108 | |||
109 | Statechart(sct). | ||
110 | |||
111 | % Scope | ||
112 | |||
113 | scope node = 20..30, Region = 2..*, Choice = 1..*, Statechart += 0. | ||
47 | `; | 114 | `; |
48 | 115 | ||
49 | configure({ | 116 | configure({ |
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 | ||
8 | import type { TreeIndentContext } from '@codemirror/language'; | 8 | import 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 | |||
7 | import Autocomplete from '@mui/material/Autocomplete'; | ||
8 | import Box from '@mui/material/Box'; | ||
9 | import TextField from '@mui/material/TextField'; | ||
10 | import { observer } from 'mobx-react-lite'; | ||
11 | |||
12 | import type GraphStore from '../graph/GraphStore'; | ||
13 | import RelationName from '../graph/RelationName'; | ||
14 | |||
15 | function 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 | |||
65 | export 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 | |||
7 | import Box from '@mui/material/Box'; | ||
8 | import { | ||
9 | DataGrid, | ||
10 | type GridRenderCellParams, | ||
11 | type GridColDef, | ||
12 | } from '@mui/x-data-grid'; | ||
13 | import { observer } from 'mobx-react-lite'; | ||
14 | import { useMemo } from 'react'; | ||
15 | |||
16 | import type GraphStore from '../graph/GraphStore'; | ||
17 | |||
18 | import TableToolbar from './TableToolbar'; | ||
19 | import ValueRenderer from './ValueRenderer'; | ||
20 | |||
21 | interface Row { | ||
22 | nodes: string[]; | ||
23 | value: string; | ||
24 | } | ||
25 | |||
26 | function 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 | |||
109 | export 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 | |||
7 | import Stack from '@mui/material/Stack'; | ||
8 | import { Suspense, lazy } from 'react'; | ||
9 | |||
10 | import Loading from '../Loading'; | ||
11 | import type GraphStore from '../graph/GraphStore'; | ||
12 | |||
13 | const TableArea = lazy(() => import('./TableArea')); | ||
14 | |||
15 | export 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 | |||
7 | import Stack from '@mui/material/Stack'; | ||
8 | import { | ||
9 | GridToolbarColumnsButton, | ||
10 | GridToolbarContainer, | ||
11 | GridToolbarExport, | ||
12 | GridToolbarFilterButton, | ||
13 | } from '@mui/x-data-grid'; | ||
14 | |||
15 | import type GraphStore from '../graph/GraphStore'; | ||
16 | |||
17 | import SymbolSelector from './SymbolSelector'; | ||
18 | |||
19 | export 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 | |||
7 | import CancelIcon from '@mui/icons-material/Cancel'; | ||
8 | import LabelIcon from '@mui/icons-material/Label'; | ||
9 | import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; | ||
10 | import { styled } from '@mui/material/styles'; | ||
11 | |||
12 | const 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 | |||
35 | export 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 | ||
15 | export type SelectedPane = 'code' | 'graph' | 'table'; | ||
16 | |||
15 | export default class ThemeStore { | 17 | export 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 | |||
7 | export 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'; | |||
11 | export const ENDPOINT = 'config.json'; | 11 | export const ENDPOINT = 'config.json'; |
12 | 12 | ||
13 | const BackendConfig = z.object({ | 13 | const BackendConfig = z.object({ |
14 | webSocketURL: z.string().url(), | 14 | webSocketURL: z.string().url().optional(), |
15 | }); | 15 | }); |
16 | 16 | ||
17 | type BackendConfig = z.infer<typeof BackendConfig>; | 17 | type 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 | |||
7 | import type { Transaction } from '@codemirror/state'; | ||
8 | |||
9 | import type EditorStore from '../editor/EditorStore'; | ||
10 | |||
11 | import type UpdateService from './UpdateService'; | ||
12 | import { ModelGenerationResult } from './xtextServiceResults'; | ||
13 | |||
14 | const INITIAL_RANDOM_SEED = 1; | ||
15 | |||
16 | export 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 | |||
7 | import type EditorStore from '../editor/EditorStore'; | ||
8 | |||
9 | import type ValidationService from './ValidationService'; | ||
10 | import { SemanticsResult } from './xtextServiceResults'; | ||
11 | |||
12 | export 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 | ||
27 | const UPDATE_TIMEOUT_MS = 500; | 28 | const 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'; | |||
9 | import type EditorStore from '../editor/EditorStore'; | 9 | import type EditorStore from '../editor/EditorStore'; |
10 | 10 | ||
11 | import type UpdateService from './UpdateService'; | 11 | import type UpdateService from './UpdateService'; |
12 | import { ValidationResult } from './xtextServiceResults'; | 12 | import { Issue, ValidationResult } from './xtextServiceResults'; |
13 | 13 | ||
14 | export default class ValidationService { | 14 | export 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'; |
11 | import type { Transaction } from '@codemirror/state'; | 11 | import type { Transaction } from '@codemirror/state'; |
12 | import { type IReactionDisposer, reaction } from 'mobx'; | ||
12 | 13 | ||
13 | import type PWAStore from '../PWAStore'; | 14 | import type PWAStore from '../PWAStore'; |
14 | import type EditorStore from '../editor/EditorStore'; | 15 | import type EditorStore from '../editor/EditorStore'; |
@@ -16,7 +17,9 @@ import getLogger from '../utils/getLogger'; | |||
16 | 17 | ||
17 | import ContentAssistService from './ContentAssistService'; | 18 | import ContentAssistService from './ContentAssistService'; |
18 | import HighlightingService from './HighlightingService'; | 19 | import HighlightingService from './HighlightingService'; |
20 | import ModelGenerationService from './ModelGenerationService'; | ||
19 | import OccurrencesService from './OccurrencesService'; | 21 | import OccurrencesService from './OccurrencesService'; |
22 | import SemanticsService from './SemanticsService'; | ||
20 | import UpdateService from './UpdateService'; | 23 | import UpdateService from './UpdateService'; |
21 | import ValidationService from './ValidationService'; | 24 | import ValidationService from './ValidationService'; |
22 | import XtextWebSocketClient from './XtextWebSocketClient'; | 25 | import 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 | ||
32 | export default createMachine( | 34 | export 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 | ||
35 | export type XtextWebErrorResponse = z.infer<typeof XtextWebErrorResponse>; | 35 | export type XtextWebErrorResponse = z.infer<typeof XtextWebErrorResponse>; |
36 | 36 | ||
37 | export const XtextWebPushService = z.enum(['highlight', 'validate']); | 37 | export const XtextWebPushService = z.enum([ |
38 | 'highlight', | ||
39 | 'validate', | ||
40 | 'semantics', | ||
41 | 'modelGeneration', | ||
42 | ]); | ||
38 | 43 | ||
39 | export type XtextWebPushService = z.infer<typeof XtextWebPushService>; | 44 | export 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 | ||
127 | export type FormattingResult = z.infer<typeof FormattingResult>; | 127 | export type FormattingResult = z.infer<typeof FormattingResult>; |
128 | |||
129 | export const ModelGenerationStartedResult = z.object({ | ||
130 | uuid: z.string().nonempty(), | ||
131 | }); | ||
132 | |||
133 | export type ModelGenerationStartedResult = z.infer< | ||
134 | typeof ModelGenerationStartedResult | ||
135 | >; | ||
136 | |||
137 | export const NodeMetadata = z.object({ | ||
138 | name: z.string(), | ||
139 | simpleName: z.string(), | ||
140 | kind: z.enum(['IMPLICIT', 'INDIVIDUAL', 'NEW']), | ||
141 | }); | ||
142 | |||
143 | export type NodeMetadata = z.infer<typeof NodeMetadata>; | ||
144 | |||
145 | export 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 | |||
162 | export type RelationMetadata = z.infer<typeof RelationMetadata>; | ||
163 | |||
164 | export 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 | |||
173 | export type SemanticsSuccessResult = z.infer<typeof SemanticsSuccessResult>; | ||
174 | |||
175 | export const SemanticsResult = z.union([ | ||
176 | z.object({ error: z.string() }), | ||
177 | z.object({ issues: Issue.array() }), | ||
178 | SemanticsSuccessResult, | ||
179 | ]); | ||
180 | |||
181 | export type SemanticsResult = z.infer<typeof SemanticsResult>; | ||
182 | |||
183 | export 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 | |||
197 | export 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 | ||
8 | interface ImportMeta { | 8 | interface 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 | ||
8 | declare module '*.grammar' { | 8 | declare 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'; |
19 | import fetchPackageMetadata from './config/fetchPackageMetadata'; | 19 | import fetchPackageMetadata from './config/fetchPackageMetadata'; |
20 | import graphvizUMDVitePlugin from './config/graphvizUMDVitePlugin'; | ||
20 | import manifest from './config/manifest'; | 21 | import manifest from './config/manifest'; |
21 | import minifyHTMLVitePlugin from './config/minifyHTMLVitePlugin'; | 22 | import minifyHTMLVitePlugin from './config/minifyHTMLVitePlugin'; |
22 | import preloadFontsVitePlugin from './config/preloadFontsVitePlugin'; | 23 | import preloadFontsVitePlugin from './config/preloadFontsVitePlugin'; |
@@ -29,8 +30,8 @@ const { mode, isDevelopment, devModePlugins, serverOptions } = | |||
29 | process.env['NODE_ENV'] ??= mode; | 30 | process.env['NODE_ENV'] ??= mode; |
30 | 31 | ||
31 | const fontsGlob = [ | 32 | const 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 | ||
36 | const viteConfig: ViteConfig = { | 37 | const 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', |