diff --git a/src/charts/CytoViz/CytoViz.js b/src/charts/CytoViz/CytoViz.js index ad2f6501..6f41ed66 100644 --- a/src/charts/CytoViz/CytoViz.js +++ b/src/charts/CytoViz/CytoViz.js @@ -14,6 +14,14 @@ import { Tab, Tabs, Tooltip, + Checkbox, + TextField, + Button, + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Menu, Switch, } from '@material-ui/core'; import { @@ -21,7 +29,9 @@ import { ChevronLeft as ChevronLeftIcon, Settings as SettingsIcon, AccountTree as AccountTreeIcon, + ExpandMore as ExpandMoreIcon, } from '@material-ui/icons'; +import { Autocomplete } from '@material-ui/lab'; import CytoscapeComponent from 'react-cytoscapejs'; import cytoscape from 'cytoscape'; import BubbleSets from 'cytoscape-bubblesets'; @@ -29,12 +39,14 @@ import dagre from 'cytoscape-dagre'; import useStyles from './style'; import { ElementData, TabPanel } from './components'; import { ErrorBanner } from '../../misc'; - cytoscape.use(BubbleSets); cytoscape.use(dagre); const DEFAULT_LAYOUTS = ['dagre']; - +const initialContextMenuState = { + mouseX: null, + mouseY: null, +}; export const CytoViz = (props) => { const classes = useStyles(); const { @@ -63,6 +75,8 @@ export const CytoViz = (props) => { const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [currentDrawerTab, setCurrentDrawerTab] = useState(0); const [currentElementDetails, setCurrentElementDetails] = useState(null); + const [expandedPanel, setExpandedPanel] = useState('nodeDetailsPanel'); + const closeDrawer = () => { setIsDrawerOpen(false); }; @@ -72,6 +86,9 @@ export const CytoViz = (props) => { const changeDrawerTab = (event, newValue) => { setCurrentDrawerTab(newValue); }; + const expandAccordionPanel = (panel) => (event, newExpanded) => { + setExpandedPanel(newExpanded ? panel : false); + }; // Settings const [currentLayout, setCurrentLayout] = useState(defaultSettings.layout); @@ -96,9 +113,116 @@ export const CytoViz = (props) => { const changeZoomPrecision = (event, newValue) => { setZoomPrecision(newValue); }; - // Cyto const cytoRef = useRef(null); + const [graphNodes, setGraphNodes] = useState([]); + const [selectedNodes, setSelectedNodes] = useState([]); + const [selectedNodesFieldHasError, setSelectedNodesFieldHasError] = useState(false); + const [explorationDepth, setExplorationDepth] = useState(1); + const [flowDirection, setFlowDirection] = useState({ inEdges: false, outEdges: true }); + const [childrenAreNeighbors, setChildrenAreNeighbors] = useState(true); + const [edgeClassOptions, setEdgeClassOptions] = useState([]); + const [excludedEdgeClasses, setExcludedEdgeClasses] = useState([]); + const [explorationDepthFieldHasError, setExplorationDepthFieldHasError] = useState(false); + const [positionContextMenu, setPositionContextMenu] = useState(initialContextMenuState); + const [isExplorationRunning, setIsExplorationRunning] = useState(false); + + const closeContextMenu = () => { + setPositionContextMenu(initialContextMenuState); + }; + const checkSetDepthValue = (event) => { + const newValue = event.target.value; + if (newValue.match(/^[0-9][0-9]*/)) { + setExplorationDepth(parseInt(newValue)); + setExplorationDepthFieldHasError(false); + } else if (newValue.match(/^$/)) { + setExplorationDepth(NaN); + setExplorationDepthFieldHasError(true); + } else { + setExplorationDepthFieldHasError(true); + } + }; + const launchExplore = () => { + setIsExplorationRunning(true); + closeDrawer(); + + const selectedNodesCyto = cytoRef.current.collection(); + selectedNodes.forEach((node) => { + selectedNodesCyto.merge(cytoRef.current.getElementById(node._private.data.id)); + }); + const visitedNodes = cytoRef.current.collection(); + cytoRef.current.edges().data({ asInEdgeHighlighted: false, asOutEdgeHighlighted: false }); + cytoRef.current.elements().data('hidden', true); + // remove excluded edges + let edgesToRemove = cytoRef.current.collection(); + excludedEdgeClasses.forEach((excludedEdgeClass) => + edgesToRemove.merge(cytoRef.current.elements(`edge.${excludedEdgeClass}`)) + ); + cytoRef.current.remove(edgesToRemove); + const onComplete = () => { + edgesToRemove = cytoRef.current.add(edgesToRemove); + cytoRef.current.batch(() => { + edgesToRemove.data('hidden', true); + }); + setIsExplorationRunning(false); + openDrawer(); + }; + breadthFirstExplore( + selectedNodesCyto, + visitedNodes, + explorationDepth, + flowDirection.inEdges, + flowDirection.outEdges, + onComplete + ); + }; + const breadthFirstExplore = (startingNodes, visitedNodes, depth, followInEdges, followOutEdges, onComplete) => { + let neighbors = cytoRef.current.collection(); // get an empty collection + startingNodes.forEach((node) => { + if (followInEdges) { + neighbors.merge(node.incomers('node')); + neighbors.merge(node.parent()); + } + if (followOutEdges) { + neighbors.merge(node.outgoers('node')); + neighbors.merge(node.children()); + } + if (childrenAreNeighbors) { + neighbors.merge(node.parent().children()); + } + }); + startingNodes.data('hidden', false); + startingNodes.parent().data('hidden', false); // otherwise the element wont be shown + visitedNodes.edgesWith(visitedNodes).data('hidden', false); + startingNodes.edgesWith(visitedNodes).data('hidden', false); + + visitedNodes.merge(startingNodes); // mark all visited nodes + neighbors = neighbors.subtract(visitedNodes); // do not put already discovered nodes in the next searchrun + + cytoRef.current.animate({ + fit: { eles: startingNodes }, + center: { eles: startingNodes }, + duration: 1000, + complete: depth === 0 ? onComplete() : {}, + }); + if (depth < 1) { + return; + } + // to reduce maximal runtime + if (depth > 1 && neighbors.length === 0) { + depth = 1; + } + setTimeout(() => { + breadthFirstExplore(neighbors, visitedNodes, depth - 1, followInEdges, followOutEdges, onComplete); + }, 1500); + }; + const getEdgesClasses = (cytoscapeRef) => { + const edgeClassesSet = new Set(); + cytoscapeRef.edges().forEach((edge) => { + edge.classes().forEach((currentClass) => edgeClassesSet.add(currentClass)); + }); + return Array.from(edgeClassesSet); + }; useEffect(() => { Object.values(extraLayouts).forEach((layout) => { @@ -113,6 +237,8 @@ export const CytoViz = (props) => { return; } cytoRef.current = cytoscapeRef; + setGraphNodes(cytoscapeRef.nodes().toArray()); + setEdgeClassOptions(getEdgesClasses(cytoscapeRef)); cytoscapeRef.removeAllListeners(); cytoscapeRef.elements().removeAllListeners(); // Prevent multiple selection & init elements selection behavior @@ -122,6 +248,7 @@ export const CytoViz = (props) => { selectedElement.select(); selectedElement.outgoers('edge').data('asOutEdgeHighlighted', true); selectedElement.incomers('edge').data('asInEdgeHighlighted', true); + selectedElement.neighborhood().data('hidden', false); setCurrentElementDetails(getElementDetailsCallback(selectedElement)); }); cytoscapeRef.on('unselect', 'node, edge', function (e) { @@ -131,12 +258,26 @@ export const CytoViz = (props) => { // Add handling of double click events cytoscapeRef.on('dbltap', 'node, edge', function (e) { const selectedElement = e.target; + selectedElement.neighborhood().data('hidden', false); if (selectedElement.selectable()) { - setCurrentDrawerTab(0); - setIsDrawerOpen(true); + openDrawer(); + setExpandedPanel('nodeDetailsPanel'); setCurrentElementDetails(getElementDetailsCallback(selectedElement)); } }); + cytoscapeRef.on('cxttap', function (e) { + if (cytoscapeRef.nodes('node:selected').length > 0) { + e.preventDefault(); + setPositionContextMenu({ + mouseX: e.originalEvent.clientX - 2, + mouseY: e.originalEvent.clientY - 4, + }); + } + }); + cytoscapeRef.on('click', function (e) { + closeContextMenu(); + }); + // Init bubblesets const bb = cytoscapeRef.bubbleSets(); for (const groupName in bubblesets) { @@ -226,8 +367,181 @@ export const CytoViz = (props) => {
- - {currentElementDetails || labels_.noSelectedElement} + + + } + > + {labels_.accordion.nodeDetails} + + {currentElementDetails || labels_.noSelectedElement} + + + } + > + {labels_.accordion.findNode.headline} + + +
+ {labels_.accordion.findNode.searchByID} + { + if (node) { + node.parent().data('hidden', false); + node.closedNeighborhood().data('hidden', false); + cytoRef.current?.animate({ + center: { eles: node }, + duration: 1000, + }); + node.select(); + } + }} + options={graphNodes} + getOptionLabel={(node) => node.data('label')} + getOptionSelected={(option, node) => node.data('label') === option.data('label')} + renderInput={(params) => } + /> +
+
+
+ + } + > + {labels_.accordion.exploreGraph.headline} + + +
+ {labels_.accordion.exploreGraph.startingNodes} + { + nodes.map((node) => node.select()); + setSelectedNodes(nodes); + if (nodes.length === 0) { + setSelectedNodesFieldHasError(true); + } else { + setSelectedNodesFieldHasError(false); + } + }} + options={graphNodes} + getOptionLabel={(node) => node.data('label')} + getOptionSelected={(option, node) => node.data('label') === option.data('label')} + renderInput={(params) => ( + + )} + /> +
+ {labels_.accordion.exploreGraph.limitDepth} + +
+

{labels_.accordion.exploreGraph.flowDirection}

+ {!(flowDirection.inEdges || flowDirection.outEdges) && ( + + {labels_.accordion.exploreGraph.flowDirectionError} + + )} +
+
+ {labels_.accordion.exploreGraph.inEdges} + { + setFlowDirection({ ...flowDirection, inEdges: event.target.checked }); + }} + /> + {labels_.accordion.exploreGraph.outEdges} + { + setFlowDirection({ ...flowDirection, outEdges: event.target.checked }); + }} + /> +
+
+ {labels_.accordion.exploreGraph.excludeEdges} + { + setExcludedEdgeClasses(newValue); + }} + options={edgeClassOptions} + renderInput={(params) => } + /> +
+ {labels_.accordion.exploreGraph.compoundNeighbors} + { + setChildrenAreNeighbors(event.target.checked); + }} + /> +
+ +
+
+
@@ -258,7 +572,7 @@ export const CytoViz = (props) => {
{
+ + { + setSelectedNodes(cytoRef.current.nodes('node:selected').toArray()); + openDrawer(); + setCurrentDrawerTab(0); + setExpandedPanel('exploreGraphPanel'); + closeContextMenu(); + }} + > + {labels_.accordion.exploreGraph.launch} + + ); @@ -415,6 +752,27 @@ const DEFAULT_LABELS = { dictKey: 'Key', dictValue: 'Value', }, + accordion: { + nodeDetails: 'Node details', + findNode: { + headline: 'Find a node', + searchByID: 'Search by ID', + }, + exploreGraph: { + headline: 'Explore a subgraph', + startingNodes: 'Select the starting node(s)', + startingNodesError: 'Select at least one node', + limitDepth: 'Limit the search depth', + limitDepthError: 'Enter a positive integer', + flowDirection: 'Choose the flow direction', + flowDirectionError: 'Select at least one', + inEdges: 'IN-Edges', + outEdges: 'OUT-Edges', + excludeEdges: 'Exclude relation types', + compoundNeighbors: 'Include the other entities of a compound', + launch: 'Explore', + }, + }, }; CytoViz.defaultProps = { diff --git a/src/charts/CytoViz/components/ElementData/ElementData.js b/src/charts/CytoViz/components/ElementData/ElementData.js index f441401d..4dc1f091 100644 --- a/src/charts/CytoViz/components/ElementData/ElementData.js +++ b/src/charts/CytoViz/components/ElementData/ElementData.js @@ -15,6 +15,7 @@ const _generateAttributeDetails = (classes, labels, attributeName, attributeValu 'target', 'asOutEdgeHighlighted', 'asInEdgeHighlighted', + 'hidden', ]; if (attributesToIgnore.indexOf(attributeName) !== -1) { return null; diff --git a/src/charts/CytoViz/style.js b/src/charts/CytoViz/style.js index 318d4c1f..0c909c2f 100644 --- a/src/charts/CytoViz/style.js +++ b/src/charts/CytoViz/style.js @@ -94,6 +94,64 @@ const useStyles = makeStyles((theme) => ({ color: theme.palette.primary.main, }, }, + queryTextfields: { + display: 'flex', + flexDirection: 'column', + gap: '1em', + }, + queryHeader: { + display: 'flex', + flexDirection: 'row', + gap: '0.5em', + }, + querySearchDepth: { + display: 'grid', + gridTemplateColumns: '2fr 3fr', + gap: '1em', + alignItems: 'center', + }, + querySearchByID: { + display: 'grid', + gridTemplateColumns: '2fr 5fr', + gap: '1em', + alignItems: 'center', + }, + queryEdgetypes: { + display: 'grid', + gridTemplateColumns: '2fr 1fr', + gap: '1em', + alignItems: 'center', + }, + tabPanel: { + '& .MuiBox-root': { + padding: 0, + }, + '& .MuiAccordion-root': { + border: '1px solid rgba(0, 0, 0, .125)', + boxShadow: 'none', + '&:not(:last-child)': { + borderBottom: 0, + }, + '&:before': { + display: 'none', + }, + }, + '& .MuiAccordion-root.Mui-expanded': { + margin: 0, + }, + '& .MuiAccordionSummary-root': { + borderBottom: '1px solid rgba(0, 0, 0, .125)', + minHeight: 56, + }, + '& .MuiAccordionSummary-content.Mui-expanded': {}, + '& .MuiAccordionDetails-root': { + padding: '16px 16px 16px', + }, + '& .MuiButton-root': { + width: 'min-content', + alignSelf: 'flex-end', + }, + }, })); export default useStyles;