From 4746d5e671a50fb900ff8c9252c26cca72278dc0 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Wed, 30 Aug 2023 02:12:23 +0200 Subject: feat(frontend): projection dialog --- .../frontend/src/graph/DotGraphVisualizer.tsx | 7 +- subprojects/frontend/src/graph/GraphArea.tsx | 46 +++- subprojects/frontend/src/graph/GraphStore.ts | 146 +++++++++-- subprojects/frontend/src/graph/RelationName.tsx | 72 ++++++ .../frontend/src/graph/VisibilityDialog.tsx | 285 +++++++++++++++++++++ subprojects/frontend/src/graph/VisibilityPanel.tsx | 85 ++++++ subprojects/frontend/src/graph/dotSource.ts | 36 +-- 7 files changed, 637 insertions(+), 40 deletions(-) create mode 100644 subprojects/frontend/src/graph/RelationName.tsx create mode 100644 subprojects/frontend/src/graph/VisibilityDialog.tsx create mode 100644 subprojects/frontend/src/graph/VisibilityPanel.tsx (limited to 'subprojects/frontend') 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'; import { observer } from 'mobx-react-lite'; import { useCallback, useRef } from 'react'; -import { useRootStore } from '../RootStoreProvider'; import getLogger from '../utils/getLogger'; +import type GraphStore from './GraphStore'; import GraphTheme from './GraphTheme'; import { FitZoomCallback } from './ZoomCanvas'; import dotSource from './dotSource'; @@ -26,17 +26,16 @@ function ptToPx(pt: number): number { } function DotGraphVisualizer({ + graph, fitZoom, transitionTime, }: { + graph: GraphStore; fitZoom?: FitZoomCallback; transitionTime?: number; }): JSX.Element { const transitionTimeOrDefault = transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; - - const { editorStore } = useRootStore(); - const graph = editorStore?.graph; const disposerRef = useRef(); const graphvizRef = useRef< Graphviz | 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 @@ * SPDX-License-Identifier: EPL-2.0 */ +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; +import { observer } from 'mobx-react-lite'; +import { useResizeDetector } from 'react-resize-detector'; + +import Loading from '../Loading'; +import { useRootStore } from '../RootStoreProvider'; + import DotGraphVisualizer from './DotGraphVisualizer'; +import VisibilityPanel from './VisibilityPanel'; import ZoomCanvas from './ZoomCanvas'; -export default function GraphArea(): JSX.Element { +function GraphArea(): JSX.Element { + const { editorStore } = useRootStore(); + const { breakpoints } = useTheme(); + const { ref, width, height } = useResizeDetector({ + refreshMode: 'debounce', + }); + + if (editorStore === undefined) { + return ; + } + + const { graph } = editorStore; + const breakpoint = breakpoints.values.sm; + const dialog = + width === undefined || + height === undefined || + width < breakpoint || + height < breakpoint; + return ( - - {(fitZoom) => } - + + + {(fitZoom) => } + + + ); } + +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 @@ import { makeAutoObservable, observable } from 'mobx'; -import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; +import type { + RelationMetadata, + SemanticsSuccessResult, +} from '../xtext/xtextServiceResults'; export type Visibility = 'all' | 'must' | 'none'; +export function getDefaultVisibility( + metadata: RelationMetadata | undefined, +): Visibility { + if (metadata === undefined || metadata.arity <= 0 || metadata.arity > 2) { + return 'none'; + } + const { detail } = metadata; + switch (detail.type) { + case 'class': + case 'reference': + case 'opposite': + return 'all'; + case 'predicate': + return detail.error ? 'must' : 'none'; + default: + return 'none'; + } +} + +export function isVisibilityAllowed( + metadata: RelationMetadata | undefined, + visibility: Visibility, +): boolean { + if (metadata === undefined || metadata.arity <= 0 || metadata.arity > 2) { + return visibility === 'none'; + } + const { detail } = metadata; + if (detail.type === 'predicate' && detail.error) { + // We can't display may matches of error predicates, + // because they have none by definition. + return visibility !== 'all'; + } + return true; +} + export default class GraphStore { semantics: SemanticsSuccessResult = { nodes: [], @@ -17,35 +55,111 @@ export default class GraphStore { partialInterpretation: {}, }; + relationMetadata = new Map(); + visibility = new Map(); + abbreviate = true; + constructor() { makeAutoObservable(this, { semantics: observable.ref, }); } - getVisiblity(relation: string): Visibility { - return this.visibility.get(relation) ?? 'none'; + getVisibility(relation: string): Visibility { + const visibilityOverride = this.visibility.get(relation); + if (visibilityOverride !== undefined) { + return visibilityOverride; + } + return this.getDefaultVisibility(relation); + } + + getDefaultVisibility(relation: string): Visibility { + const metadata = this.relationMetadata.get(relation); + return getDefaultVisibility(metadata); + } + + isVisibilityAllowed(relation: string, visibility: Visibility): boolean { + const metadata = this.relationMetadata.get(relation); + return isVisibilityAllowed(metadata, visibility); + } + + setVisibility(relation: string, visibility: Visibility): void { + const metadata = this.relationMetadata.get(relation); + if (metadata === undefined || !isVisibilityAllowed(metadata, visibility)) { + return; + } + const defaultVisiblity = getDefaultVisibility(metadata); + if (defaultVisiblity === visibility) { + this.visibility.delete(relation); + } else { + this.visibility.set(relation, visibility); + } + } + + cycleVisibility(relation: string): void { + const metadata = this.relationMetadata.get(relation); + if (metadata === undefined) { + return; + } + switch (this.getVisibility(relation)) { + case 'none': + if (isVisibilityAllowed(metadata, 'must')) { + this.setVisibility(relation, 'must'); + } + break; + case 'must': + { + const next = isVisibilityAllowed(metadata, 'all') ? 'all' : 'none'; + this.setVisibility(relation, next); + } + break; + default: + this.setVisibility(relation, 'none'); + break; + } + } + + hideAll(): void { + this.relationMetadata.forEach((metadata, name) => { + if (getDefaultVisibility(metadata) === 'none') { + this.visibility.delete(name); + } else { + this.visibility.set(name, 'none'); + } + }); + } + + resetFilter(): void { + this.visibility.clear(); + } + + getName({ name, simpleName }: { name: string; simpleName: string }): string { + return this.abbreviate ? simpleName : name; + } + + toggleAbbrevaite(): void { + this.abbreviate = !this.abbreviate; } setSemantics(semantics: SemanticsSuccessResult) { this.semantics = semantics; - this.visibility.clear(); - const names = new Set(); - this.semantics.relations.forEach(({ name, detail }) => { - names.add(name); - if (!this.visibility.has(name)) { - const newVisibility = detail.type === 'builtin' ? 'none' : 'all'; - this.visibility.set(name, newVisibility); - } + this.relationMetadata.clear(); + this.semantics.relations.forEach((metadata) => { + this.relationMetadata.set(metadata.name, metadata); }); - const oldNames = new Set(); - this.visibility.forEach((_, key) => oldNames.add(key)); - oldNames.forEach((key) => { - if (!names.has(key)) { - this.visibility.delete(key); + const toRemove = new Set(); + this.visibility.forEach((value, key) => { + if ( + !this.isVisibilityAllowed(key, value) || + this.getDefaultVisibility(key) === value + ) { + toRemove.add(key); } }); + toRemove.forEach((key) => { + this.visibility.delete(key); + }); } } 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { styled } from '@mui/material/styles'; +import { observer } from 'mobx-react-lite'; + +import { RelationMetadata } from '../xtext/xtextServiceResults'; + +const Error = styled('span', { + name: 'RelationName-Error', +})(({ theme }) => ({ + color: theme.palette.error.main, +})); + +const Qualifier = styled('span', { + name: 'RelationName-Qualifier', +})(({ theme }) => ({ + color: theme.palette.text.secondary, +})); + +const FormattedName = observer(function FormattedName({ + name, + metadata, +}: { + name: string; + metadata: RelationMetadata; +}): React.ReactNode { + const { detail } = metadata; + if (detail.type === 'class' && detail.abstractClass) { + return {name}; + } + if (detail.type === 'reference' && detail.containment) { + return {name}; + } + if (detail.type === 'predicate' && detail.error) { + return {name}; + } + return name; +}); + +function RelationName({ + metadata, + abbreviate, +}: { + metadata: RelationMetadata; + abbreviate?: boolean; +}): JSX.Element { + const { name, simpleName } = metadata; + if (abbreviate ?? RelationName.defaultProps.abbreviate) { + return ; + } + if (name.endsWith(simpleName)) { + return ( + <> + + {name.substring(0, name.length - simpleName.length)} + + + + ); + } + return ; +} + +RelationName.defaultProps = { + abbreviate: false, +}; + +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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import CloseIcon from '@mui/icons-material/Close'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import LabelIcon from '@mui/icons-material/Label'; +import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; +import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import IconButton from '@mui/material/IconButton'; +import Switch from '@mui/material/Switch'; +import { styled } from '@mui/material/styles'; +import { observer } from 'mobx-react-lite'; + +import type GraphStore from './GraphStore'; +import { isVisibilityAllowed } from './GraphStore'; +import RelationName from './RelationName'; + +const VisibilityDialogRoot = styled('div', { + name: 'VisibilityDialog-Root', + shouldForwardProp: (propName) => propName !== 'dialog', +})<{ dialog: boolean }>(({ theme, dialog }) => { + const overlayOpacity = dialog ? 0.16 : 0.09; + return { + maxHeight: '100%', + maxWidth: '100%', + overflow: 'hidden', + display: 'flex', + padding: theme.spacing(2), + flexDirection: 'column', + '.VisibilityDialog-switch': { + display: 'flex', + flexDirection: 'row', + paddingLeft: theme.spacing(1), + marginBottom: theme.spacing(1), + '.MuiFormControlLabel-root': { + flexGrow: 1, + }, + '.MuiIconButton-root': { + flexGrow: 0, + flexShrink: 0, + marginLeft: theme.spacing(2), + }, + }, + '.VisibilityDialog-scroll': { + display: 'flex', + flexDirection: 'column', + height: 'auto', + overflowX: 'hidden', + overflowY: 'auto', + '& table': { + // We use flexbox instead of `display: table` to get proper text-overflow + // behavior for overly long relation names. + display: 'flex', + flexDirection: 'column', + }, + '& thead, & tbody': { + display: 'flex', + flexDirection: 'column', + }, + '& thead': { + position: 'sticky', + top: 0, + zIndex: 999, + backgroundColor: theme.palette.background.paper, + ...(theme.palette.mode === 'dark' + ? { + // In dark mode, MUI Paper gets a lighter overlay. + backgroundImage: `linear-gradient( + rgba(255, 255, 255, ${overlayOpacity}), + rgba(255, 255, 255, ${overlayOpacity}) + )`, + } + : {}), + '& tr': { + height: '44px', + }, + }, + '& tr': { + display: 'flex', + flexDirection: 'row', + maxWidth: '100%', + }, + '& tbody tr': { + transition: theme.transitions.create('background', { + duration: theme.transitions.duration.shortest, + }), + '&:hover': { + background: theme.palette.action.hover, + '@media (hover: none)': { + background: 'transparent', + }, + }, + }, + '& th, & td': { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + // Set width in advance, since we can't rely on `display: table-cell`. + width: '44px', + }, + '& th:nth-of-type(3), & td:nth-of-type(3)': { + justifyContent: 'start', + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(2), + // Only let the last column grow or shrink. + flexGrow: 1, + flexShrink: 1, + // Compute the maximum available space in advance to let the text overflow. + maxWidth: 'calc(100% - 88px)', + width: 'min-content', + }, + '& td:nth-of-type(3)': { + cursor: 'pointer', + userSelect: 'none', + WebkitTapHighlightColor: 'transparent', + }, + + '& thead th, .VisibilityDialog-custom tr:last-child td': { + borderBottom: `1px solid ${theme.palette.divider}`, + }, + }, + // Hack to apply `text-overflow`. + '.VisibilityDialog-nowrap': { + maxWidth: '100%', + overflow: 'hidden', + wordWrap: 'nowrap', + textOverflow: 'ellipsis', + }, + '.VisibilityDialog-buttons': { + marginTop: theme.spacing(2), + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + '.VisibilityDialog-empty': { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + color: theme.palette.text.secondary, + }, + '.VisibilityDialog-emptyIcon': { + fontSize: '6rem', + marginBottom: theme.spacing(1), + }, + }; +}); + +function VisibilityDialog({ + graph, + close, + dialog, +}: { + graph: GraphStore; + close: () => void; + dialog?: boolean; +}): JSX.Element { + const builtinRows: JSX.Element[] = []; + const rows: JSX.Element[] = []; + graph.relationMetadata.forEach((metadata, name) => { + if (!isVisibilityAllowed(metadata, 'must')) { + return; + } + const visibility = graph.getVisibility(name); + const row = ( + + + + graph.setVisibility(name, visibility === 'none' ? 'must' : 'none') + } + /> + + + + graph.setVisibility(name, visibility === 'all' ? 'must' : 'all') + } + /> + + graph.cycleVisibility(name)}> +
+ +
+ + + ); + if (name.startsWith('builtin::')) { + builtinRows.push(row); + } else { + rows.push(row); + } + }); + + const hasRows = rows.length > 0 || builtinRows.length > 0; + + return ( + +
+ graph.toggleAbbrevaite()} + /> + } + label="Fully qualified names" + /> + {dialog && ( + + + + )} +
+
+ {hasRows ? ( + + + + + + + + + {...rows} + {...builtinRows} +
+ + + + Symbol
+ ) : ( +
+ +
Partial model is empty
+
+ )} +
+
+ + + {!dialog && ( + + )} +
+
+ ); +} + +VisibilityDialog.defaultProps = { + dialog: false, +}; + +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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import TuneIcon from '@mui/icons-material/Tune'; +import Badge from '@mui/material/Badge'; +import Dialog from '@mui/material/Dialog'; +import IconButton from '@mui/material/IconButton'; +import Paper from '@mui/material/Paper'; +import Slide from '@mui/material/Slide'; +import { styled } from '@mui/material/styles'; +import { observer } from 'mobx-react-lite'; +import { useCallback, useId, useState } from 'react'; + +import type GraphStore from './GraphStore'; +import VisibilityDialog from './VisibilityDialog'; + +const VisibilityPanelRoot = styled('div', { + name: 'VisibilityPanel-Root', +})(({ theme }) => ({ + position: 'absolute', + padding: theme.spacing(1), + top: 0, + left: 0, + maxHeight: '100%', + maxWidth: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + alignItems: 'start', + '.VisibilityPanel-drawer': { + overflow: 'hidden', + display: 'flex', + maxWidth: '100%', + margin: theme.spacing(1), + }, +})); + +function VisibilityPanel({ + graph, + dialog, +}: { + graph: GraphStore; + dialog: boolean; +}): JSX.Element { + const id = useId(); + const [showFilter, setShowFilter] = useState(false); + const close = useCallback(() => setShowFilter(false), []); + + return ( + + setShowFilter(!showFilter)} + > + + {showFilter && !dialog ? : } + + + {dialog ? ( + + + + ) : ( + + + + + + )} + + ); +} + +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; const CONTAINMENT_WEIGHT = 5; const UNKNOWN_WEIGHT_FACTOR = 0.5; -function nodeName({ simpleName, kind }: NodeMetadata): string { - switch (kind) { +function nodeName(graph: GraphStore, metadata: NodeMetadata): string { + const name = graph.getName(metadata); + switch (metadata.kind) { case 'INDIVIDUAL': - return `${simpleName}`; + return `${name}`; case 'NEW': - return `${simpleName}`; + return `${name}`; default: - return simpleName; + return name; } } -function relationName({ simpleName, detail }: RelationMetadata): string { +function relationName(graph: GraphStore, metadata: RelationMetadata): string { + const name = graph.getName(metadata); + const { detail } = metadata; if (detail.type === 'class' && detail.abstractClass) { - return `${simpleName}`; + return `${name}`; } if (detail.type === 'reference' && detail.containment) { - return `${simpleName}`; + return `${name}`; } - return simpleName; + return name; } interface NodeData { @@ -57,7 +60,7 @@ function computeNodeData(graph: GraphStore): NodeData[] { if (relation.arity !== 1) { return; } - const visibility = graph.getVisiblity(relation.name); + const visibility = graph.getVisibility(relation.name); if (visibility === 'none') { return; } @@ -112,7 +115,7 @@ function createNodes(graph: GraphStore, lines: string[]): void { const classes = [ `node-${node.kind} node-exists-${data.exists} node-equalsSelf-${data.equalsSelf}`, ].join(' '); - const name = nodeName(node); + const name = nodeName(graph, node); const border = node.kind === 'INDIVIDUAL' ? 2 : 1; lines.push(`n${i} [id="${node.name}", class="${classes}", label=< @@ -128,7 +131,7 @@ function createNodes(graph: GraphStore, lines: string[]): void { + },label">${relationName(graph, relation)}`, ); }); @@ -205,14 +208,15 @@ function createRelationEdges( let constraint: 'true' | 'false' = 'true'; let weight = EDGE_WEIGHT; let penwidth = 1; - let label = `"${relation.simpleName}"`; + const name = graph.getName(relation); + let label = `"${name}"`; if (detail.type === 'reference' && detail.containment) { weight = CONTAINMENT_WEIGHT; - label = `<${relation.simpleName}>`; + label = `<${name}>`; penwidth = 2; } else if ( detail.type === 'opposite' && - graph.getVisiblity(detail.opposite) !== 'none' + graph.getVisibility(detail.opposite) !== 'none' ) { constraint = 'false'; weight = 0; @@ -284,7 +288,7 @@ function createEdges(graph: GraphStore, lines: string[]): void { if (relation.arity !== 2) { return; } - const visibility = graph.getVisiblity(relation.name); + const visibility = graph.getVisibility(relation.name); if (visibility !== 'none') { createRelationEdges(graph, relation, visibility === 'all', lines); } -- cgit v1.2.3-54-g00ecf
${relationName(relation)}