diff options
Diffstat (limited to 'subprojects/frontend/src')
-rw-r--r-- | subprojects/frontend/src/graph/DotGraphVisualizer.tsx | 7 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 46 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphStore.ts | 146 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/RelationName.tsx | 72 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/VisibilityDialog.tsx | 285 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/VisibilityPanel.tsx | 85 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/dotSource.ts | 36 |
7 files changed, 637 insertions, 40 deletions
diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx index 291314ec..41fd7225 100644 --- a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx +++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx | |||
@@ -11,9 +11,9 @@ import { reaction, type IReactionDisposer } from 'mobx'; | |||
11 | import { observer } from 'mobx-react-lite'; | 11 | import { observer } from 'mobx-react-lite'; |
12 | import { useCallback, useRef } from 'react'; | 12 | import { useCallback, useRef } from 'react'; |
13 | 13 | ||
14 | import { useRootStore } from '../RootStoreProvider'; | ||
15 | import getLogger from '../utils/getLogger'; | 14 | import getLogger from '../utils/getLogger'; |
16 | 15 | ||
16 | import type GraphStore from './GraphStore'; | ||
17 | import GraphTheme from './GraphTheme'; | 17 | import GraphTheme from './GraphTheme'; |
18 | import { FitZoomCallback } from './ZoomCanvas'; | 18 | import { FitZoomCallback } from './ZoomCanvas'; |
19 | import dotSource from './dotSource'; | 19 | import dotSource from './dotSource'; |
@@ -26,17 +26,16 @@ function ptToPx(pt: number): number { | |||
26 | } | 26 | } |
27 | 27 | ||
28 | function DotGraphVisualizer({ | 28 | function DotGraphVisualizer({ |
29 | graph, | ||
29 | fitZoom, | 30 | fitZoom, |
30 | transitionTime, | 31 | transitionTime, |
31 | }: { | 32 | }: { |
33 | graph: GraphStore; | ||
32 | fitZoom?: FitZoomCallback; | 34 | fitZoom?: FitZoomCallback; |
33 | transitionTime?: number; | 35 | transitionTime?: number; |
34 | }): JSX.Element { | 36 | }): JSX.Element { |
35 | const transitionTimeOrDefault = | 37 | const transitionTimeOrDefault = |
36 | transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; | 38 | transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; |
37 | |||
38 | const { editorStore } = useRootStore(); | ||
39 | const graph = editorStore?.graph; | ||
40 | const disposerRef = useRef<IReactionDisposer | undefined>(); | 39 | const disposerRef = useRef<IReactionDisposer | undefined>(); |
41 | const graphvizRef = useRef< | 40 | const graphvizRef = useRef< |
42 | Graphviz<BaseType, unknown, null, undefined> | undefined | 41 | Graphviz<BaseType, unknown, null, undefined> | undefined |
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx index a1a741f3..f8f40d22 100644 --- a/subprojects/frontend/src/graph/GraphArea.tsx +++ b/subprojects/frontend/src/graph/GraphArea.tsx | |||
@@ -4,13 +4,51 @@ | |||
4 | * SPDX-License-Identifier: EPL-2.0 | 4 | * SPDX-License-Identifier: EPL-2.0 |
5 | */ | 5 | */ |
6 | 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 Loading from '../Loading'; | ||
13 | import { useRootStore } from '../RootStoreProvider'; | ||
14 | |||
7 | import DotGraphVisualizer from './DotGraphVisualizer'; | 15 | import DotGraphVisualizer from './DotGraphVisualizer'; |
16 | import VisibilityPanel from './VisibilityPanel'; | ||
8 | import ZoomCanvas from './ZoomCanvas'; | 17 | import ZoomCanvas from './ZoomCanvas'; |
9 | 18 | ||
10 | export default function GraphArea(): JSX.Element { | 19 | function GraphArea(): JSX.Element { |
20 | const { editorStore } = useRootStore(); | ||
21 | const { breakpoints } = useTheme(); | ||
22 | const { ref, width, height } = useResizeDetector({ | ||
23 | refreshMode: 'debounce', | ||
24 | }); | ||
25 | |||
26 | if (editorStore === undefined) { | ||
27 | return <Loading />; | ||
28 | } | ||
29 | |||
30 | const { graph } = editorStore; | ||
31 | const breakpoint = breakpoints.values.sm; | ||
32 | const dialog = | ||
33 | width === undefined || | ||
34 | height === undefined || | ||
35 | width < breakpoint || | ||
36 | height < breakpoint; | ||
37 | |||
11 | return ( | 38 | return ( |
12 | <ZoomCanvas> | 39 | <Box |
13 | {(fitZoom) => <DotGraphVisualizer fitZoom={fitZoom} />} | 40 | width="100%" |
14 | </ZoomCanvas> | 41 | height="100%" |
42 | overflow="hidden" | ||
43 | position="relative" | ||
44 | ref={ref} | ||
45 | > | ||
46 | <ZoomCanvas> | ||
47 | {(fitZoom) => <DotGraphVisualizer graph={graph} fitZoom={fitZoom} />} | ||
48 | </ZoomCanvas> | ||
49 | <VisibilityPanel graph={graph} dialog={dialog} /> | ||
50 | </Box> | ||
15 | ); | 51 | ); |
16 | } | 52 | } |
53 | |||
54 | export default observer(GraphArea); | ||
diff --git a/subprojects/frontend/src/graph/GraphStore.ts b/subprojects/frontend/src/graph/GraphStore.ts index b59bfb7d..f81b4db4 100644 --- a/subprojects/frontend/src/graph/GraphStore.ts +++ b/subprojects/frontend/src/graph/GraphStore.ts | |||
@@ -6,10 +6,48 @@ | |||
6 | 6 | ||
7 | import { makeAutoObservable, observable } from 'mobx'; | 7 | import { makeAutoObservable, observable } from 'mobx'; |
8 | 8 | ||
9 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | 9 | import type { |
10 | RelationMetadata, | ||
11 | SemanticsSuccessResult, | ||
12 | } from '../xtext/xtextServiceResults'; | ||
10 | 13 | ||
11 | export type Visibility = 'all' | 'must' | 'none'; | 14 | export type Visibility = 'all' | 'must' | 'none'; |
12 | 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 | |||
13 | export default class GraphStore { | 51 | export default class GraphStore { |
14 | semantics: SemanticsSuccessResult = { | 52 | semantics: SemanticsSuccessResult = { |
15 | nodes: [], | 53 | nodes: [], |
@@ -17,35 +55,111 @@ export default class GraphStore { | |||
17 | partialInterpretation: {}, | 55 | partialInterpretation: {}, |
18 | }; | 56 | }; |
19 | 57 | ||
58 | relationMetadata = new Map<string, RelationMetadata>(); | ||
59 | |||
20 | visibility = new Map<string, Visibility>(); | 60 | visibility = new Map<string, Visibility>(); |
21 | 61 | ||
62 | abbreviate = true; | ||
63 | |||
22 | constructor() { | 64 | constructor() { |
23 | makeAutoObservable(this, { | 65 | makeAutoObservable(this, { |
24 | semantics: observable.ref, | 66 | semantics: observable.ref, |
25 | }); | 67 | }); |
26 | } | 68 | } |
27 | 69 | ||
28 | getVisiblity(relation: string): Visibility { | 70 | getVisibility(relation: string): Visibility { |
29 | return this.visibility.get(relation) ?? 'none'; | 71 | const visibilityOverride = this.visibility.get(relation); |
72 | if (visibilityOverride !== undefined) { | ||
73 | return visibilityOverride; | ||
74 | } | ||
75 | return this.getDefaultVisibility(relation); | ||
76 | } | ||
77 | |||
78 | getDefaultVisibility(relation: string): Visibility { | ||
79 | const metadata = this.relationMetadata.get(relation); | ||
80 | return getDefaultVisibility(metadata); | ||
81 | } | ||
82 | |||
83 | isVisibilityAllowed(relation: string, visibility: Visibility): boolean { | ||
84 | const metadata = this.relationMetadata.get(relation); | ||
85 | return isVisibilityAllowed(metadata, visibility); | ||
86 | } | ||
87 | |||
88 | setVisibility(relation: string, visibility: Visibility): void { | ||
89 | const metadata = this.relationMetadata.get(relation); | ||
90 | if (metadata === undefined || !isVisibilityAllowed(metadata, visibility)) { | ||
91 | return; | ||
92 | } | ||
93 | const defaultVisiblity = getDefaultVisibility(metadata); | ||
94 | if (defaultVisiblity === visibility) { | ||
95 | this.visibility.delete(relation); | ||
96 | } else { | ||
97 | this.visibility.set(relation, visibility); | ||
98 | } | ||
99 | } | ||
100 | |||
101 | cycleVisibility(relation: string): void { | ||
102 | const metadata = this.relationMetadata.get(relation); | ||
103 | if (metadata === undefined) { | ||
104 | return; | ||
105 | } | ||
106 | switch (this.getVisibility(relation)) { | ||
107 | case 'none': | ||
108 | if (isVisibilityAllowed(metadata, 'must')) { | ||
109 | this.setVisibility(relation, 'must'); | ||
110 | } | ||
111 | break; | ||
112 | case 'must': | ||
113 | { | ||
114 | const next = isVisibilityAllowed(metadata, 'all') ? 'all' : 'none'; | ||
115 | this.setVisibility(relation, next); | ||
116 | } | ||
117 | break; | ||
118 | default: | ||
119 | this.setVisibility(relation, 'none'); | ||
120 | break; | ||
121 | } | ||
122 | } | ||
123 | |||
124 | hideAll(): void { | ||
125 | this.relationMetadata.forEach((metadata, name) => { | ||
126 | if (getDefaultVisibility(metadata) === 'none') { | ||
127 | this.visibility.delete(name); | ||
128 | } else { | ||
129 | this.visibility.set(name, 'none'); | ||
130 | } | ||
131 | }); | ||
132 | } | ||
133 | |||
134 | resetFilter(): void { | ||
135 | this.visibility.clear(); | ||
136 | } | ||
137 | |||
138 | getName({ name, simpleName }: { name: string; simpleName: string }): string { | ||
139 | return this.abbreviate ? simpleName : name; | ||
140 | } | ||
141 | |||
142 | toggleAbbrevaite(): void { | ||
143 | this.abbreviate = !this.abbreviate; | ||
30 | } | 144 | } |
31 | 145 | ||
32 | setSemantics(semantics: SemanticsSuccessResult) { | 146 | setSemantics(semantics: SemanticsSuccessResult) { |
33 | this.semantics = semantics; | 147 | this.semantics = semantics; |
34 | this.visibility.clear(); | 148 | this.relationMetadata.clear(); |
35 | const names = new Set<string>(); | 149 | this.semantics.relations.forEach((metadata) => { |
36 | this.semantics.relations.forEach(({ name, detail }) => { | 150 | this.relationMetadata.set(metadata.name, metadata); |
37 | names.add(name); | ||
38 | if (!this.visibility.has(name)) { | ||
39 | const newVisibility = detail.type === 'builtin' ? 'none' : 'all'; | ||
40 | this.visibility.set(name, newVisibility); | ||
41 | } | ||
42 | }); | 151 | }); |
43 | const oldNames = new Set<string>(); | 152 | const toRemove = new Set<string>(); |
44 | this.visibility.forEach((_, key) => oldNames.add(key)); | 153 | this.visibility.forEach((value, key) => { |
45 | oldNames.forEach((key) => { | 154 | if ( |
46 | if (!names.has(key)) { | 155 | !this.isVisibilityAllowed(key, value) || |
47 | this.visibility.delete(key); | 156 | this.getDefaultVisibility(key) === value |
157 | ) { | ||
158 | toRemove.add(key); | ||
48 | } | 159 | } |
49 | }); | 160 | }); |
161 | toRemove.forEach((key) => { | ||
162 | this.visibility.delete(key); | ||
163 | }); | ||
50 | } | 164 | } |
51 | } | 165 | } |
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..b28ba31a --- /dev/null +++ b/subprojects/frontend/src/graph/VisibilityDialog.tsx | |||
@@ -0,0 +1,285 @@ | |||
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 { styled } from '@mui/material/styles'; | ||
19 | import { observer } from 'mobx-react-lite'; | ||
20 | |||
21 | import type GraphStore from './GraphStore'; | ||
22 | import { isVisibilityAllowed } from './GraphStore'; | ||
23 | import RelationName from './RelationName'; | ||
24 | |||
25 | const VisibilityDialogRoot = styled('div', { | ||
26 | name: 'VisibilityDialog-Root', | ||
27 | shouldForwardProp: (propName) => propName !== 'dialog', | ||
28 | })<{ dialog: boolean }>(({ theme, dialog }) => { | ||
29 | const overlayOpacity = dialog ? 0.16 : 0.09; | ||
30 | return { | ||
31 | maxHeight: '100%', | ||
32 | maxWidth: '100%', | ||
33 | overflow: 'hidden', | ||
34 | display: 'flex', | ||
35 | padding: theme.spacing(2), | ||
36 | flexDirection: 'column', | ||
37 | '.VisibilityDialog-switch': { | ||
38 | display: 'flex', | ||
39 | flexDirection: 'row', | ||
40 | paddingLeft: theme.spacing(1), | ||
41 | marginBottom: theme.spacing(1), | ||
42 | '.MuiFormControlLabel-root': { | ||
43 | flexGrow: 1, | ||
44 | }, | ||
45 | '.MuiIconButton-root': { | ||
46 | flexGrow: 0, | ||
47 | flexShrink: 0, | ||
48 | marginLeft: theme.spacing(2), | ||
49 | }, | ||
50 | }, | ||
51 | '.VisibilityDialog-scroll': { | ||
52 | display: 'flex', | ||
53 | flexDirection: 'column', | ||
54 | height: 'auto', | ||
55 | overflowX: 'hidden', | ||
56 | overflowY: 'auto', | ||
57 | '& table': { | ||
58 | // We use flexbox instead of `display: table` to get proper text-overflow | ||
59 | // behavior for overly long relation names. | ||
60 | display: 'flex', | ||
61 | flexDirection: 'column', | ||
62 | }, | ||
63 | '& thead, & tbody': { | ||
64 | display: 'flex', | ||
65 | flexDirection: 'column', | ||
66 | }, | ||
67 | '& thead': { | ||
68 | position: 'sticky', | ||
69 | top: 0, | ||
70 | zIndex: 999, | ||
71 | backgroundColor: theme.palette.background.paper, | ||
72 | ...(theme.palette.mode === 'dark' | ||
73 | ? { | ||
74 | // In dark mode, MUI Paper gets a lighter overlay. | ||
75 | backgroundImage: `linear-gradient( | ||
76 | rgba(255, 255, 255, ${overlayOpacity}), | ||
77 | rgba(255, 255, 255, ${overlayOpacity}) | ||
78 | )`, | ||
79 | } | ||
80 | : {}), | ||
81 | '& tr': { | ||
82 | height: '44px', | ||
83 | }, | ||
84 | }, | ||
85 | '& tr': { | ||
86 | display: 'flex', | ||
87 | flexDirection: 'row', | ||
88 | maxWidth: '100%', | ||
89 | }, | ||
90 | '& tbody tr': { | ||
91 | transition: theme.transitions.create('background', { | ||
92 | duration: theme.transitions.duration.shortest, | ||
93 | }), | ||
94 | '&:hover': { | ||
95 | background: theme.palette.action.hover, | ||
96 | '@media (hover: none)': { | ||
97 | background: 'transparent', | ||
98 | }, | ||
99 | }, | ||
100 | }, | ||
101 | '& th, & td': { | ||
102 | display: 'flex', | ||
103 | flexDirection: 'row', | ||
104 | alignItems: 'center', | ||
105 | justifyContent: 'center', | ||
106 | // Set width in advance, since we can't rely on `display: table-cell`. | ||
107 | width: '44px', | ||
108 | }, | ||
109 | '& th:nth-of-type(3), & td:nth-of-type(3)': { | ||
110 | justifyContent: 'start', | ||
111 | paddingLeft: theme.spacing(1), | ||
112 | paddingRight: theme.spacing(2), | ||
113 | // Only let the last column grow or shrink. | ||
114 | flexGrow: 1, | ||
115 | flexShrink: 1, | ||
116 | // Compute the maximum available space in advance to let the text overflow. | ||
117 | maxWidth: 'calc(100% - 88px)', | ||
118 | width: 'min-content', | ||
119 | }, | ||
120 | '& td:nth-of-type(3)': { | ||
121 | cursor: 'pointer', | ||
122 | userSelect: 'none', | ||
123 | WebkitTapHighlightColor: 'transparent', | ||
124 | }, | ||
125 | |||
126 | '& thead th, .VisibilityDialog-custom tr:last-child td': { | ||
127 | borderBottom: `1px solid ${theme.palette.divider}`, | ||
128 | }, | ||
129 | }, | ||
130 | // Hack to apply `text-overflow`. | ||
131 | '.VisibilityDialog-nowrap': { | ||
132 | maxWidth: '100%', | ||
133 | overflow: 'hidden', | ||
134 | wordWrap: 'nowrap', | ||
135 | textOverflow: 'ellipsis', | ||
136 | }, | ||
137 | '.VisibilityDialog-buttons': { | ||
138 | marginTop: theme.spacing(2), | ||
139 | display: 'flex', | ||
140 | flexDirection: 'row', | ||
141 | justifyContent: 'flex-end', | ||
142 | }, | ||
143 | '.VisibilityDialog-empty': { | ||
144 | display: 'flex', | ||
145 | flexDirection: 'column', | ||
146 | alignItems: 'center', | ||
147 | color: theme.palette.text.secondary, | ||
148 | }, | ||
149 | '.VisibilityDialog-emptyIcon': { | ||
150 | fontSize: '6rem', | ||
151 | marginBottom: theme.spacing(1), | ||
152 | }, | ||
153 | }; | ||
154 | }); | ||
155 | |||
156 | function VisibilityDialog({ | ||
157 | graph, | ||
158 | close, | ||
159 | dialog, | ||
160 | }: { | ||
161 | graph: GraphStore; | ||
162 | close: () => void; | ||
163 | dialog?: boolean; | ||
164 | }): JSX.Element { | ||
165 | const builtinRows: JSX.Element[] = []; | ||
166 | const rows: JSX.Element[] = []; | ||
167 | graph.relationMetadata.forEach((metadata, name) => { | ||
168 | if (!isVisibilityAllowed(metadata, 'must')) { | ||
169 | return; | ||
170 | } | ||
171 | const visibility = graph.getVisibility(name); | ||
172 | const row = ( | ||
173 | <tr key={metadata.name}> | ||
174 | <td> | ||
175 | <Checkbox | ||
176 | checked={visibility !== 'none'} | ||
177 | aria-label={`Show true and error values of ${metadata.simpleName}`} | ||
178 | onClick={() => | ||
179 | graph.setVisibility(name, visibility === 'none' ? 'must' : 'none') | ||
180 | } | ||
181 | /> | ||
182 | </td> | ||
183 | <td> | ||
184 | <Checkbox | ||
185 | checked={visibility === 'all'} | ||
186 | disabled={!isVisibilityAllowed(metadata, 'all')} | ||
187 | aria-label={`Show all values of ${metadata.simpleName}`} | ||
188 | onClick={() => | ||
189 | graph.setVisibility(name, visibility === 'all' ? 'must' : 'all') | ||
190 | } | ||
191 | /> | ||
192 | </td> | ||
193 | <td onClick={() => graph.cycleVisibility(name)}> | ||
194 | <div className="VisibilityDialog-nowrap"> | ||
195 | <RelationName metadata={metadata} abbreviate={graph.abbreviate} /> | ||
196 | </div> | ||
197 | </td> | ||
198 | </tr> | ||
199 | ); | ||
200 | if (name.startsWith('builtin::')) { | ||
201 | builtinRows.push(row); | ||
202 | } else { | ||
203 | rows.push(row); | ||
204 | } | ||
205 | }); | ||
206 | |||
207 | const hasRows = rows.length > 0 || builtinRows.length > 0; | ||
208 | |||
209 | return ( | ||
210 | <VisibilityDialogRoot | ||
211 | dialog={dialog ?? VisibilityDialog.defaultProps.dialog} | ||
212 | > | ||
213 | <div className="VisibilityDialog-switch"> | ||
214 | <FormControlLabel | ||
215 | control={ | ||
216 | <Switch | ||
217 | checked={!graph.abbreviate} | ||
218 | onClick={() => graph.toggleAbbrevaite()} | ||
219 | /> | ||
220 | } | ||
221 | label="Fully qualified names" | ||
222 | /> | ||
223 | {dialog && ( | ||
224 | <IconButton aria-label="Close" onClick={close}> | ||
225 | <CloseIcon /> | ||
226 | </IconButton> | ||
227 | )} | ||
228 | </div> | ||
229 | <div className="VisibilityDialog-scroll"> | ||
230 | {hasRows ? ( | ||
231 | <table cellSpacing={0}> | ||
232 | <thead> | ||
233 | <tr> | ||
234 | <th> | ||
235 | <LabelIcon /> | ||
236 | </th> | ||
237 | <th> | ||
238 | <LabelOutlinedIcon /> | ||
239 | </th> | ||
240 | <th>Symbol</th> | ||
241 | </tr> | ||
242 | </thead> | ||
243 | <tbody className="VisibilityDialog-custom">{...rows}</tbody> | ||
244 | <tbody className="VisibilityDialog-builtin">{...builtinRows}</tbody> | ||
245 | </table> | ||
246 | ) : ( | ||
247 | <div className="VisibilityDialog-empty"> | ||
248 | <SentimentVeryDissatisfiedIcon | ||
249 | className="VisibilityDialog-emptyIcon" | ||
250 | fontSize="inherit" | ||
251 | /> | ||
252 | <div>Partial model is empty</div> | ||
253 | </div> | ||
254 | )} | ||
255 | </div> | ||
256 | <div className="VisibilityDialog-buttons"> | ||
257 | <Button | ||
258 | color="inherit" | ||
259 | onClick={() => graph.hideAll()} | ||
260 | startIcon={<VisibilityOffIcon />} | ||
261 | > | ||
262 | Hide all | ||
263 | </Button> | ||
264 | <Button | ||
265 | color="inherit" | ||
266 | onClick={() => graph.resetFilter()} | ||
267 | startIcon={<FilterListIcon />} | ||
268 | > | ||
269 | Reset filter | ||
270 | </Button> | ||
271 | {!dialog && ( | ||
272 | <Button color="inherit" onClick={close}> | ||
273 | Close | ||
274 | </Button> | ||
275 | )} | ||
276 | </div> | ||
277 | </VisibilityDialogRoot> | ||
278 | ); | ||
279 | } | ||
280 | |||
281 | VisibilityDialog.defaultProps = { | ||
282 | dialog: false, | ||
283 | }; | ||
284 | |||
285 | 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..c951dee2 --- /dev/null +++ b/subprojects/frontend/src/graph/VisibilityPanel.tsx | |||
@@ -0,0 +1,85 @@ | |||
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 direction="right" in={showFilter} id={id}> | ||
76 | <Paper className="VisibilityPanel-drawer" elevation={4}> | ||
77 | <VisibilityDialog graph={graph} close={close} /> | ||
78 | </Paper> | ||
79 | </Slide> | ||
80 | )} | ||
81 | </VisibilityPanelRoot> | ||
82 | ); | ||
83 | } | ||
84 | |||
85 | export default observer(VisibilityPanel); | ||
diff --git a/subprojects/frontend/src/graph/dotSource.ts b/subprojects/frontend/src/graph/dotSource.ts index 2d6b57de..701453f4 100644 --- a/subprojects/frontend/src/graph/dotSource.ts +++ b/subprojects/frontend/src/graph/dotSource.ts | |||
@@ -15,25 +15,28 @@ const EDGE_WEIGHT = 1; | |||
15 | const CONTAINMENT_WEIGHT = 5; | 15 | const CONTAINMENT_WEIGHT = 5; |
16 | const UNKNOWN_WEIGHT_FACTOR = 0.5; | 16 | const UNKNOWN_WEIGHT_FACTOR = 0.5; |
17 | 17 | ||
18 | function nodeName({ simpleName, kind }: NodeMetadata): string { | 18 | function nodeName(graph: GraphStore, metadata: NodeMetadata): string { |
19 | switch (kind) { | 19 | const name = graph.getName(metadata); |
20 | switch (metadata.kind) { | ||
20 | case 'INDIVIDUAL': | 21 | case 'INDIVIDUAL': |
21 | return `<b>${simpleName}</b>`; | 22 | return `<b>${name}</b>`; |
22 | case 'NEW': | 23 | case 'NEW': |
23 | return `<i>${simpleName}</i>`; | 24 | return `<i>${name}</i>`; |
24 | default: | 25 | default: |
25 | return simpleName; | 26 | return name; |
26 | } | 27 | } |
27 | } | 28 | } |
28 | 29 | ||
29 | function relationName({ simpleName, detail }: RelationMetadata): string { | 30 | function relationName(graph: GraphStore, metadata: RelationMetadata): string { |
31 | const name = graph.getName(metadata); | ||
32 | const { detail } = metadata; | ||
30 | if (detail.type === 'class' && detail.abstractClass) { | 33 | if (detail.type === 'class' && detail.abstractClass) { |
31 | return `<i>${simpleName}</i>`; | 34 | return `<i>${name}</i>`; |
32 | } | 35 | } |
33 | if (detail.type === 'reference' && detail.containment) { | 36 | if (detail.type === 'reference' && detail.containment) { |
34 | return `<b>${simpleName}</b>`; | 37 | return `<b>${name}</b>`; |
35 | } | 38 | } |
36 | return simpleName; | 39 | return name; |
37 | } | 40 | } |
38 | 41 | ||
39 | interface NodeData { | 42 | interface NodeData { |
@@ -57,7 +60,7 @@ function computeNodeData(graph: GraphStore): NodeData[] { | |||
57 | if (relation.arity !== 1) { | 60 | if (relation.arity !== 1) { |
58 | return; | 61 | return; |
59 | } | 62 | } |
60 | const visibility = graph.getVisiblity(relation.name); | 63 | const visibility = graph.getVisibility(relation.name); |
61 | if (visibility === 'none') { | 64 | if (visibility === 'none') { |
62 | return; | 65 | return; |
63 | } | 66 | } |
@@ -112,7 +115,7 @@ function createNodes(graph: GraphStore, lines: string[]): void { | |||
112 | const classes = [ | 115 | const classes = [ |
113 | `node-${node.kind} node-exists-${data.exists} node-equalsSelf-${data.equalsSelf}`, | 116 | `node-${node.kind} node-exists-${data.exists} node-equalsSelf-${data.equalsSelf}`, |
114 | ].join(' '); | 117 | ].join(' '); |
115 | const name = nodeName(node); | 118 | const name = nodeName(graph, node); |
116 | const border = node.kind === 'INDIVIDUAL' ? 2 : 1; | 119 | const border = node.kind === 'INDIVIDUAL' ? 2 : 1; |
117 | lines.push(`n${i} [id="${node.name}", class="${classes}", label=< | 120 | lines.push(`n${i} [id="${node.name}", class="${classes}", label=< |
118 | <table border="${border}" cellborder="0" cellspacing="0" style="rounded" bgcolor="white"> | 121 | <table border="${border}" cellborder="0" cellspacing="0" style="rounded" bgcolor="white"> |
@@ -128,7 +131,7 @@ function createNodes(graph: GraphStore, lines: string[]): void { | |||
128 | <td width="1.5"></td> | 131 | <td width="1.5"></td> |
129 | <td align="left" href="#${value}" id="${node.name},${ | 132 | <td align="left" href="#${value}" id="${node.name},${ |
130 | relation.name | 133 | relation.name |
131 | },label">${relationName(relation)}</td> | 134 | },label">${relationName(graph, relation)}</td> |
132 | </tr>`, | 135 | </tr>`, |
133 | ); | 136 | ); |
134 | }); | 137 | }); |
@@ -205,14 +208,15 @@ function createRelationEdges( | |||
205 | let constraint: 'true' | 'false' = 'true'; | 208 | let constraint: 'true' | 'false' = 'true'; |
206 | let weight = EDGE_WEIGHT; | 209 | let weight = EDGE_WEIGHT; |
207 | let penwidth = 1; | 210 | let penwidth = 1; |
208 | let label = `"${relation.simpleName}"`; | 211 | const name = graph.getName(relation); |
212 | let label = `"${name}"`; | ||
209 | if (detail.type === 'reference' && detail.containment) { | 213 | if (detail.type === 'reference' && detail.containment) { |
210 | weight = CONTAINMENT_WEIGHT; | 214 | weight = CONTAINMENT_WEIGHT; |
211 | label = `<<b>${relation.simpleName}</b>>`; | 215 | label = `<<b>${name}</b>>`; |
212 | penwidth = 2; | 216 | penwidth = 2; |
213 | } else if ( | 217 | } else if ( |
214 | detail.type === 'opposite' && | 218 | detail.type === 'opposite' && |
215 | graph.getVisiblity(detail.opposite) !== 'none' | 219 | graph.getVisibility(detail.opposite) !== 'none' |
216 | ) { | 220 | ) { |
217 | constraint = 'false'; | 221 | constraint = 'false'; |
218 | weight = 0; | 222 | weight = 0; |
@@ -284,7 +288,7 @@ function createEdges(graph: GraphStore, lines: string[]): void { | |||
284 | if (relation.arity !== 2) { | 288 | if (relation.arity !== 2) { |
285 | return; | 289 | return; |
286 | } | 290 | } |
287 | const visibility = graph.getVisiblity(relation.name); | 291 | const visibility = graph.getVisibility(relation.name); |
288 | if (visibility !== 'none') { | 292 | if (visibility !== 'none') { |
289 | createRelationEdges(graph, relation, visibility === 'all', lines); | 293 | createRelationEdges(graph, relation, visibility === 'all', lines); |
290 | } | 294 | } |