diff --git a/drc-portals/app/data/c2m2/graph/api/pathway/route.ts b/drc-portals/app/data/c2m2/graph/api/pathway/route.ts index 130c0744..e1112674 100644 --- a/drc-portals/app/data/c2m2/graph/api/pathway/route.ts +++ b/drc-portals/app/data/c2m2/graph/api/pathway/route.ts @@ -1,6 +1,7 @@ import { Session } from "neo4j-driver"; import { NextRequest } from "next/server"; +import { UNIQUE_TO_GENERIC_REL } from "@/lib/neo4j/constants"; import { createPathwaySearchAllPathsCypher, createUpperPageBoundCypher, @@ -19,7 +20,7 @@ import { RelationshipResult, TreeParseResult, } from "@/lib/neo4j/types"; -import { parsePathwayTree } from "@/lib/neo4j/utils"; +import { isRelationshipResult, parsePathwayTree } from "@/lib/neo4j/utils"; const MAX_LIMIT = 1000; const MAX_PAGE_SIBLINGS = 9; @@ -172,9 +173,20 @@ export async function POST(request: NextRequest) { ); }), ]); - const paths: PathwaySearchResultRow[] = pathwaySearchResult.map((record) => - Object.values(record.toObject()) - ); + const paths: PathwaySearchResultRow[] = pathwaySearchResult + .map((record) => Object.values(record.toObject())) + .map((val) => + val.map((column) => { + if (isRelationshipResult(column)) { + return { + ...column, + type: UNIQUE_TO_GENERIC_REL.get(column.type) || "Unkown", // Transform the unique rels back to their generic format for UI presentation + }; + } else { + return column; + } + }) + ); const upperPageBound = upperPageBoundResult.toObject().upperPageBound; // If we reach the end of the table, we can fix the lower bound to maintain a constant number of page items diff --git a/drc-portals/app/data/documentation/markdown/GQI.mdx b/drc-portals/app/data/documentation/markdown/GQI.mdx index ca573199..a09ded96 100644 --- a/drc-portals/app/data/documentation/markdown/GQI.mdx +++ b/drc-portals/app/data/documentation/markdown/GQI.mdx @@ -61,7 +61,7 @@ The UI is a clean, intuitive, interactive canvas with minimal control tools. The
Labeled Interface
-
*__Figure 5.__ Clean, intuitive, minimalistic graph query user interface. The canvas shows 1. Input field, a collapsible toolbar, including 2. Start over, 3. Export Cypher 4. Download Pathway, 5. Upload Pathway, 6. Help, 7. Zoom In, 8. Zoom Out and 9. Fit Graph.*
+
*__Figure 5.__ Clean, intuitive, minimalistic graph query user interface. The canvas shows 1. Input field, a collapsible toolbar, including 2. Start over, 2. Stop Loading 4. Export Cypher 5. Download Pathway, 6. Upload Pathway, 7. Export PNG, 8. Help 9. Zoom In, 10. Zoom Out and 11. Fit Graph.*
The 'Expand' option will cue the user to explore the possible relationships based on the underlying schema and data availability for the entity/search path. With the 'Filter' option, one can subset the data by instantiating a specific value to a class object (ex, asthma for the 'Disease' class). With the 'Prune' option, one can edit the search path by removing a node(s) and their relationship(s). diff --git a/drc-portals/app/data/graph/help/page.mdx b/drc-portals/app/data/graph/help/page.mdx index d6dd03f6..e492f62a 100644 --- a/drc-portals/app/data/graph/help/page.mdx +++ b/drc-portals/app/data/graph/help/page.mdx @@ -15,31 +15,33 @@ You can view an interactive version of the C2M2 Graph Schema [here](/data/graph/ ## Quick Start Tips -### __1. Start with a Keyword__ +### **1. Start with a Keyword** Enter a keyword (e.g., human, blood, asthma, RNA-seq, TP53, aspirin) to search ontology-encoded metadata, such as species, anatomy, disease/phenotype, gene, compound, and assay, to discover entities like Subject, Biosample, and File. ![Menu Options](/img/graph/GQI_carousel_1.png) -### __2. Build as You Explore__ +### **2. Build as You Explore** -Use node menu options (point the cursor/mouse to a node) — __Expand__ (to explore related entities), __Filter__ (to narrow by values), and __Prune__ (to trim paths) — to build your query one step at a time. +Use node menu options (point the cursor/mouse to a node) — **Expand** (to explore related entities), **Filter** (to narrow by values), and **Prune** (to trim paths) — to build your query one step at a time. -![Menu Options](/img/graph/GQI_docs_streamlined.png) +![Menu Options 1](/img/graph/GQI_docs_streamlined_1.png) +![Menu Options 2](/img/graph/GQI_docs_streamlined_2.png) -### __3. View & Download__ +### **3. View & Download** -See results in __Tabular__ or __Network View__, and export them as JSON to share, revisit, or analyze further. +See results in **Tabular** or **Network View**, and export them as JSON to share, revisit, or analyze further. ![Tabular View](/img/graph/GQI_docs_tabular_view.png) diff --git a/drc-portals/components/prototype/components/GraphPathway.tsx b/drc-portals/components/prototype/components/GraphPathway.tsx index 7e1c4d00..a9be0bcf 100644 --- a/drc-portals/components/prototype/components/GraphPathway.tsx +++ b/drc-portals/components/prototype/components/GraphPathway.tsx @@ -33,8 +33,10 @@ import { PathwaySearchContextProps, } from "../contexts/PathwaySearchContext"; import { + ColumnData, ConnectionMenuItem, PathwaySearchNode, + PathwaySearchNodeData, } from "../interfaces/pathway-search"; import { PathwaySearchElement } from "../types/pathway-search"; import { @@ -245,7 +247,35 @@ export default function GraphPathway() { const handleReturnBtnClick = () => { setShowResults(false); - }; + } + + const handleVisibilityChange = useCallback((columns: ColumnData[]) => { + if (tree !== undefined) { + const newElements = [ + ...searchElements.map((element) => { + if (isPathwaySearchEdgeElement(element)) { + return deepCopyPathwaySearchEdge(element); + } else { + for (let col of columns) { + if (col.key === element.data.id) { + return deepCopyPathwaySearchNode( + element, + { visible: col.visible }, + [col.visible ? "solid" : "transparent"], + [col.visible ? "transparent" : "solid"] + ); + } + } + // This should never happen + console.warn(`Could not find corresponding column for node element with ID: ${element.data.id}`) + return deepCopyPathwaySearchNode(element); + } + }), + ]; + setSearchElements(newElements); + setTree(createTree(newElements)); + } + }, [tree]); const handleSearchBarSubmit = (cvTerm: NodeResult) => { // TODO: Direct node results *should* always have at least one label since they are required on all Neo4j nodes, so maybe this check @@ -263,16 +293,14 @@ export default function GraphPathway() { return; } - const initialNode = createPathwaySearchNode( - { - id: cvTerm.uuid, - dbLabel: cvTerm.labels[0], - props: { - name: [cvTerm.properties.name], - }, + const initialNode = createPathwaySearchNode({ + id: cvTerm.uuid, + dbLabel: cvTerm.labels[0], + visible: true, + props: { + name: [cvTerm.properties.name], }, - ["path-element"] - ); + }); let initialTree: PathwayNode; try { @@ -368,13 +396,11 @@ export default function GraphPathway() { const candidateSearchElements = [ // The newly connected node - createPathwaySearchNode( - { - id: item.nodeId, - dbLabel: item.label, - }, - ["path-element"] - ), + createPathwaySearchNode({ + id: item.nodeId, + dbLabel: item.label, + visible: true, + }), ...fallbackElements, // And the newly connected edge createPathwaySearchEdge( @@ -386,9 +412,7 @@ export default function GraphPathway() { item.direction === Direction.OUTGOING ? item.target : item.source, type: item.type, }, - item.direction === Direction.INCOMING - ? ["source-arrow-only", "path-element"] - : ["path-element"] + item.direction === Direction.INCOMING ? ["source-arrow-only"] : [] ), ]; updateCounts(candidateSearchElements, loadingElements, fallbackElements); @@ -531,6 +555,37 @@ export default function GraphPathway() { } }, [searchElements]); + const handleShowHide = useCallback( + (node: NodeSingular) => { + const nodeData: PathwaySearchNodeData = node.data(); + const nodeClasses = node.classes(); + const newVisibility = !nodeData.visible; + const newNode: PathwaySearchNode = { + classes: nodeClasses + // Don't duplicate the solid/transparent classes, and remove the "hovered" class so the node immediately becomes transparent if hidden + .filter(cls => !["solid", "transparent", "hovered"].includes(cls)) + .concat([newVisibility ? "solid" : "transparent"]), + data: { + ...nodeData, + visible: newVisibility, + }, + }; + const newElements = [ + newNode, + ...searchElements + .filter((el) => el.data.id !== nodeData.id) + .map((element) => + isPathwaySearchEdgeElement(element) + ? deepCopyPathwaySearchEdge(element) + : deepCopyPathwaySearchNode(element) + ), + ]; + setSearchElements(newElements); + setTree(createTree(newElements)); + }, + [searchElements] + ); + // On the initial load of the page, populate the pathway with an initial node if one exists in the query params useEffect(() => { const id = searchParams.get("id"); @@ -556,14 +611,17 @@ export default function GraphPathway() { }, []); return ( - + {showResults && tree !== undefined ? ( ) : ( @@ -576,6 +634,7 @@ export default function GraphPathway() { onPruneSelected={handlePruneSelected} onPruneConfirm={handlePruneConfirm} onPruneCancel={handlePruneCancel} + onShowHideSelected={handleShowHide} onDownload={handleExport} onUpload={handleImport} onCopyCypher={handleCopyCypher} diff --git a/drc-portals/components/prototype/components/PathwaySearch/GraphPathwayResults.tsx b/drc-portals/components/prototype/components/PathwaySearch/GraphPathwayResults.tsx index 05ff2531..366d3ab9 100644 --- a/drc-portals/components/prototype/components/PathwaySearch/GraphPathwayResults.tsx +++ b/drc-portals/components/prototype/components/PathwaySearch/GraphPathwayResults.tsx @@ -33,13 +33,14 @@ import TableViewSkeleton from "./PathwayResults/TableViewSkeleton"; interface GraphPathwayResultsProps { tree: PathwayNode; + onVisibilityChange: (columns: ColumnData[]) => void; onReturnBtnClick: () => void; } export default function GraphPathwayResults( cmpProps: GraphPathwayResultsProps ) { - const { tree, onReturnBtnClick } = cmpProps; + const { tree, onVisibilityChange, onReturnBtnClick } = cmpProps; const [paths, setPaths] = useState([]); const [page, setPage] = useState(1); const [lowerPageBound, setLowerPageBound] = useState(Math.max(page - 5, 1)); @@ -79,6 +80,10 @@ export default function GraphPathwayResults( return { data: await response.json(), status: response.status }; }; + const handleReturnBtnClick = () => { + onReturnBtnClick() + } + const handlePageChange = useCallback( async (newPage: number) => { setLoading(true); @@ -167,7 +172,7 @@ export default function GraphPathwayResults( [tree, limit, page, columns] ); - const handleColumnChange = useCallback( + const handleColumnPropertyChange = useCallback( async (changedColumn: number, changes: Partial) => { try { const newColumns = columns.map((col, idx) => @@ -200,6 +205,10 @@ export default function GraphPathwayResults( [tree, limit, page, columns, orderBy, order] ); + const handleColumnVisibilityChange = (columns: ColumnData[]) => { + onVisibilityChange(columns); + } + const handleDownloadAllClicked = useCallback(async () => { try { const columnData = orderBy === undefined ? undefined : columns[orderBy]; @@ -233,10 +242,13 @@ export default function GraphPathwayResults( }; useEffect(() => { + const newColumns = getColumnDataFromTree(tree) + const columnData = + orderBy === undefined ? undefined : newColumns[orderBy]; setLoading(true); - setColumns(getColumnDataFromTree(tree)); + setColumns(newColumns); Promise.all([ - getPathwaySearchResults(tree, page, limit), + getPathwaySearchResults(tree, page, limit, columnData?.key, columnData?.displayProp, order), ]).then(([searchResult]) => { setLoading(false); setPaths(searchResult.data.paths); @@ -266,7 +278,7 @@ export default function GraphPathwayResults( lowerPageBound={lowerPageBound} upperPageBound={upperPageBound} columns={columns} - onReturnBtnClick={onReturnBtnClick} + onReturnBtnClick={handleReturnBtnClick} /> ) : ( )} @@ -291,7 +304,7 @@ export default function GraphPathwayResults( diff --git a/drc-portals/components/prototype/components/PathwaySearch/GraphPathwaySearch.tsx b/drc-portals/components/prototype/components/PathwaySearch/GraphPathwaySearch.tsx index ba7c57b7..236f5860 100644 --- a/drc-portals/components/prototype/components/PathwaySearch/GraphPathwaySearch.tsx +++ b/drc-portals/components/prototype/components/PathwaySearch/GraphPathwaySearch.tsx @@ -4,11 +4,13 @@ import ContentCutIcon from "@mui/icons-material/ContentCut"; import FileDownloadIcon from "@mui/icons-material/FileDownload"; import FileUploadIcon from "@mui/icons-material/FileUpload"; import FilterAltIcon from "@mui/icons-material/FilterAlt"; -import HelpIcon from '@mui/icons-material/Help'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import HelpIcon from "@mui/icons-material/Help"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import SearchIcon from "@mui/icons-material/Search"; -import StopIcon from '@mui/icons-material/Stop'; +import StopIcon from "@mui/icons-material/Stop"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import VisibilityIcon from "@mui/icons-material/Visibility"; import { Box, Button, @@ -38,7 +40,11 @@ import { useState, } from "react"; -import { ADMIN_LABELS, FILTER_LABELS, NAME_FILTER_LABELS, TERM_LABELS } from "@/lib/neo4j/constants"; +import { + ADMIN_LABELS, + FILTER_LABELS, + TERM_LABELS, +} from "@/lib/neo4j/constants"; import { NodeResult } from "@/lib/neo4j/types"; import { @@ -52,16 +58,30 @@ import { } from "../../constants/pathway-search"; import { SearchBarContainer } from "../../constants/search-bar"; import { CFDE_DARK_BLUE, VisuallyHiddenInput } from "../../constants/shared"; -import { ADD_CONNECTION_ITEM_ID, PRUNE_ID, SHOW_FILTERS_ID } from "../../constants/cxt-menu"; +import { + ADD_CONNECTION_ITEM_ID, + SHOW_HIDE_NODE_ID, + PRUNE_ID, + SHOW_FILTERS_ID, +} from "../../constants/cxt-menu"; import { CxtMenuItem } from "../../interfaces/cxt-menu"; import { CytoscapeEvent } from "../../interfaces/cy"; import { ConnectionMenuItem, PathwaySearchNode, } from "../../interfaces/pathway-search"; -import { CustomToolbarFnFactory } from "../../types/cy"; -import { PathwaySearchElement, PropertyConfigs, PropertyValueType } from "../../types/pathway-search"; -import { getRootFromElements, isPathwaySearchEdgeElement, updatePathwayNodeProps } from "../../utils/pathway-search"; +import { CustomToolbarFnFactory, CytoscapeReference } from "../../types/cy"; +import { + PathwaySearchElement, + PropertyConfigs, + PropertyValueType, +} from "../../types/pathway-search"; +import { downloadChartPNG } from "../../utils/cy"; +import { + getRootFromElements, + isPathwaySearchEdgeElement, + updatePathwayNodeProps, +} from "../../utils/pathway-search"; import CytoscapeChart from "../CytoscapeChart/CytoscapeChart"; import ChartCxtMenuItem from "../CytoscapeChart/ChartCxtMenuItem"; @@ -83,6 +103,7 @@ interface GraphPathwaySearchProps { onPruneSelected: (node: NodeSingular) => void; onPruneConfirm: () => void; onPruneCancel: () => void; + onShowHideSelected: (node: NodeSingular) => void; onReset: () => void; onStopLoading: () => void; onDownload: () => void; @@ -103,6 +124,7 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { onPruneSelected, onPruneConfirm, onPruneCancel, + onShowHideSelected, onReset, onStopLoading, onDownload, @@ -157,7 +179,7 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { const nodesToAnimate = loadingNodes.reduce( (prev, curr) => cy.getElementById(curr).union(prev), cy.collection() - ) + ); // Cytoscape crashes if you try to animate empty collections if (nodesToAnimate.size() > 0) { @@ -218,13 +240,20 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { ); const handleNodeFilterChange = useCallback( - (value: PropertyValueType, propName: K) => { + ( + value: PropertyValueType, + propName: K + ) => { if (selectedNode !== undefined) { const newSelectedNode: PathwaySearchNode = { classes: [...(selectedNode.classes || [])], data: { ...selectedNode.data, - props: updatePathwayNodeProps(selectedNode.data.props, propName, value), + props: updatePathwayNodeProps( + selectedNode.data.props, + propName, + value + ), }, }; setSelectedNode(newSelectedNode); @@ -252,31 +281,51 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { const showFilterNode = useCallback( (event: EventObjectNode) => { const root = getRootFromElements(elements); - return event.target.data("id") !== root?.data.id && FILTER_LABELS.has(event.target.data("dbLabel")) - }, [elements] + return ( + event.target.data("id") !== root?.data.id && + FILTER_LABELS.has(event.target.data("dbLabel")) + ); + }, + [elements] ); + const handleShowHideNodeSelected = (event: EventObjectNode) => { + onShowHideSelected(event.target); + } - const showExpandNode = useCallback((event: EventObjectNode) => { - const root = getRootFromElements(elements); - const nodeLabel: string = event.target.data("dbLabel"); - const nodeId: string = event.target.data("id"); - - // If the node is the root... - if (nodeId === root?.data.id) { - if (elements.filter(isPathwaySearchEdgeElement).some((edge) => edge.data.source === nodeId || edge.data.target === nodeId)) { - // Don't allow expansion if already has some connection - return false - } - } else { - // If the node is NOT the root... - if (ADMIN_LABELS.includes(nodeLabel) || TERM_LABELS.includes(nodeLabel)) { - // Don't allow expansion if the node has an admin label or a term label - return false; + const showExpandNode = useCallback( + (event: EventObjectNode) => { + const root = getRootFromElements(elements); + const nodeLabel: string = event.target.data("dbLabel"); + const nodeId: string = event.target.data("id"); + + // If the node is the root... + if (nodeId === root?.data.id) { + if ( + elements + .filter(isPathwaySearchEdgeElement) + .some( + (edge) => + edge.data.source === nodeId || edge.data.target === nodeId + ) + ) { + // Don't allow expansion if already has some connection + return false; + } + } else { + // If the node is NOT the root... + if ( + ADMIN_LABELS.includes(nodeLabel) || + TERM_LABELS.includes(nodeLabel) + ) { + // Don't allow expansion if the node has an admin label or a term label + return false; + } } - } - return true; - }, [elements]); + return true; + }, + [elements] + ); const handlePruneNodeSelected = (event: EventObjectNode) => { setSnackbarOpen(true); @@ -299,36 +348,45 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { () => { return ( - { - elements.length === 0 - ? + {elements.length === 0 ? ( + + + + ) : ( + + - : - - - - - } - + + )} ); }, () => { return ( - { - loadingNodes.length === 0 - ? + {loadingNodes.length === 0 ? ( + + + + ) : ( + + - : - - - - - } - + + )} ); }, @@ -384,6 +442,12 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { ); }, + (cyRef: CytoscapeReference) => + downloadChartPNG( + "search-chart-toolbar-download-png", + "Download PNG", + cyRef + ), () => ( { - const targetNode = event.target.source().hasClass("path-element") - ? event.target.target() - : event.target.source(); - handleSelectedNodeChange(targetNode.id(), event.cy); - }, - }, { event: "cxttap", target: "node", @@ -444,34 +498,6 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { handleSelectedNodeChange(event.target.id(), event.cy); }, }, - { - event: "mouseover", - target: "node", - callback: (event: EventObjectNode) => { - event.target.neighborhood().addClass("solid"); - }, - }, - { - event: "mouseout", - target: "node", - callback: (event: EventObjectNode) => { - event.target.neighborhood().removeClass("solid"); - }, - }, - { - event: "mouseover", - target: "edge", - callback: (event: EventObjectEdge) => { - event.target.connectedNodes().addClass("solid"); - }, - }, - { - event: "mouseout", - target: "edge", - callback: (event: EventObjectEdge) => { - event.target.connectedNodes().removeClass("solid"); - }, - }, ], [handleSelectedNodeChange] ); @@ -479,55 +505,94 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { const nodeCxtMenuItems: CxtMenuItem[] = useMemo( () => [ { - content: , + content: ( + + ), tree: { id: ADD_CONNECTION_ITEM_ID, children: [], open: false, - } + }, }, { - content: ( - - - Filters - - )} - action={handleFilterNodeSelected} - showFn={showFilterNode} - >, + content: ( + ( + + + Filters + + )} + action={handleFilterNodeSelected} + showFn={showFilterNode} + > + ), tree: { id: SHOW_FILTERS_ID, children: [], open: false, - } + }, }, { - content: ( - - - Prune - - )} - action={handlePruneNodeSelected} - >, + content: ( + ( + + + {event.target.data("visible") ? ( + <> + {" "} + Hide Node + + ) : ( + <> + {" "} + Show Node + + )} + + + )} + action={handleShowHideNodeSelected} + > + ), + tree: { + id: SHOW_HIDE_NODE_ID, + children: [], + open: false, + }, + }, + { + content: ( + ( + + + Prune + + )} + action={handlePruneNodeSelected} + > + ), tree: { id: PRUNE_ID, children: [], open: false, - } + }, }, ], [elements] @@ -594,14 +659,16 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) { ) : null} {loadingInitial ? ( - + diff --git a/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/ColumnsPanel.tsx b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/ColumnsPanel.tsx new file mode 100644 index 00000000..91d33eea --- /dev/null +++ b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/ColumnsPanel.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { + Box, + FormControl, + FormControlLabel, + FormGroup, + Switch, +} from "@mui/material"; + +import { ColumnData } from "@/components/prototype/interfaces/pathway-search"; + +interface ColumnsPanelProps { + columns: ColumnData[]; + onSwitch: (column: number) => void; + // onShowAllClicked: () => void; + // onHideAllClicked: () => void; +} + +export default function ColumnsPanel(cmpProps: ColumnsPanelProps) { + const { columns, onSwitch } = cmpProps; + + return ( + <> + {/* Header */} + {/* */} + {/* Content */} + + + + {columns.map((col, idx) => ( + onSwitch(idx)} color="secondary" /> + } + label={col.label} + labelPlacement="end" + /> + ))} + + + + {/* Footer */} + {/* + + + */} + + ); +} diff --git a/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableView.tsx b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableView.tsx index 4fe7db52..0202cb5a 100644 --- a/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableView.tsx +++ b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableView.tsx @@ -6,11 +6,13 @@ import DownloadIcon from "@mui/icons-material/Download"; import IndeterminateCheckBoxIcon from "@mui/icons-material/IndeterminateCheckBox"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import MoreVertIcon from "@mui/icons-material/MoreVert"; +import ViewColumnIcon from "@mui/icons-material/ViewColumn"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import VisibilityIcon from "@mui/icons-material/Visibility"; import { Box, Button, Checkbox, - CircularProgress, FormControl, IconButton, ListItemIcon, @@ -51,6 +53,8 @@ import { getPropertyListFromNodeLabel } from "@/components/prototype/utils/pathw import { downloadBlob } from "@/components/prototype/utils/shared"; import ReturnBtn from "../ReturnBtn"; + +import ColumnsPanel from "./ColumnsPanel"; import PathwayTablePagination from "./PathwayTablePagination"; interface TableViewProps { @@ -66,7 +70,8 @@ interface TableViewProps { onPageChange: (page: number) => void; onLimitChange: (limit: number) => void; onOrderByChange: (column: number | undefined, order: Order) => void; - onColumnChange: (column: number, changes: Partial) => void; + onColumnPropertyChange: (column: number, changes: Partial) => void; + onColumnVisibilityChange: (columns: ColumnData[]) => void; onDownloadAll: () => Promise; } @@ -84,7 +89,8 @@ export default function TableView(cmpProps: TableViewProps) { onPageChange, onLimitChange, onOrderByChange, - onColumnChange, + onColumnPropertyChange, + onColumnVisibilityChange, onDownloadAll, } = cmpProps; const [selected, setSelected] = useState( @@ -97,14 +103,19 @@ export default function TableView(cmpProps: TableViewProps) { ); const [colMenuColumn, setColMenuColumn] = useState(); const colMenuOpen = Boolean(colMenuAnchorEl); + const [colVisibilityMenuAnchorEl, setColVisibilityMenuAnchorEl] = + useState(null); + const colVisibilityMenuOpen = Boolean(colVisibilityMenuAnchorEl); - const drsBundleData = useMemo(() => - data - .filter((_, idx) => selected[idx]) - .flat() - .filter((element) => !isRelationshipResult(element)) - .map(node => node.properties) - , [data, selected]) + const drsBundleData = useMemo( + () => + data + .filter((_, idx) => selected[idx]) + .flat() + .filter((element) => !isRelationshipResult(element)) + .map((node) => node.properties), + [data, selected] + ); const getColumnHeaderText = (column: ColumnData) => { return ( @@ -142,6 +153,16 @@ export default function TableView(cmpProps: TableViewProps) { setColMenuAnchorEl(null); }; + const handleColVisibilityMenuClick = ( + event: React.MouseEvent + ) => { + setColVisibilityMenuAnchorEl(event.currentTarget); + }; + + const handleColVisibilityMenuClose = () => { + setColVisibilityMenuAnchorEl(null); + }; + const colMenuFnWrapper = ( fn: (...args: Args) => void, ...args: Args @@ -155,11 +176,21 @@ export default function TableView(cmpProps: TableViewProps) { }; const handleColMenuPropertyUpdate = (column: number, property: string) => { - colMenuFnWrapper(onColumnChange, column, { + colMenuFnWrapper(onColumnPropertyChange, column, { displayProp: property, }); }; + const handleColumnVisibilitySwitch = useCallback( + (changedColumn: number) => { + const newColumns = columns.map((col, idx) => + idx === changedColumn ? { ...col, visible: !columns[changedColumn].visible } : { ...col } + ); + onColumnVisibilityChange(newColumns); + }, + [columns] + ); + const handleSelectAllClick = (event: React.ChangeEvent) => { if (event.target.checked) { setSelected(new Array(data.length).fill(true)); @@ -174,8 +205,10 @@ export default function TableView(cmpProps: TableViewProps) { let newOrderBy: number | undefined = column; if (sortedColumn === column) { - if (order === "asc") newOrder = "desc"; // order column by desc - else if (order === "desc") newOrderBy = undefined; // unorder column + if (order === "asc") + newOrder = "desc"; // order column by desc + else if (order === "desc") + newOrderBy = undefined; // unorder column else newOrder = "asc"; // order column by asc } else { newOrder = "asc"; // order column by asc @@ -206,15 +239,50 @@ export default function TableView(cmpProps: TableViewProps) { return ( <> - {/* Start table */} + {/* Start table header */} - + + {/*Table Toolbar*/} + + + + + - {/* width: 1% forces minimal use of space */} # - {columns.map((col, idx) => ( - - - handleSortBtnClicked(event, idx)} + {columns + .map((col, idx) => ( + + - {getColumnHeaderText(col)} - {sortedColumn === idx ? ( - - {order === "desc" - ? "sorted descending" - : "sorted ascending"} - - ) : null} - - handleColMenuClick(event, idx)} - > - - - - - ))} + handleSortBtnClicked(event, idx)} + > + {getColumnHeaderText(col)} + {sortedColumn === idx ? ( + + {order === "desc" + ? "sorted descending" + : "sorted ascending"} + + ) : null} + + handleColMenuClick(event, idx)} + > + + + + + )) + .filter((_, j) => columns[j].visible) // Skip hidden columns + } @@ -282,16 +355,21 @@ export default function TableView(cmpProps: TableViewProps) { /> {(page - 1) * limit + i + 1} - {row - .filter((col) => !isRelationshipResult(col)) - .map((nodeCol, j) => ( - - {columns[j].valueGetter( - nodeCol as NodeResult, - columns[j].displayProp - )} - - ))} + { + row + .filter((col) => !isRelationshipResult(col)) // Skip relationship data (this shrinks the row width to match the length of `columns`) + .map((nodeCol, j) => { + const visibleColumns = columns.filter(col => col.visible); + return ( + + {visibleColumns[j].valueGetter( + nodeCol as NodeResult, + visibleColumns[j].displayProp + )} + + ) + }) + } ))} @@ -365,6 +443,26 @@ export default function TableView(cmpProps: TableViewProps) { + + + + {/* Individual Column Menu */} {colMenuColumn !== undefined ? ( - } - parentMenuOpen={colMenuOpen} - renderLabel={() => "Set column property"} - sx={{ paddingX: "16px" }} - > - {getPropertyListFromNodeLabel(columns[colMenuColumn].label).map( - (property, idx) => ( - - handleColMenuPropertyUpdate(colMenuColumn, property) - } - > - {property} - - ) - )} - + <> + handleColumnVisibilitySwitch(colMenuColumn)}> + + + + Hide Column + + } + parentMenuOpen={colMenuOpen} + renderLabel={() => "Set column property"} + sx={{ paddingX: "16px" }} + > + {getPropertyListFromNodeLabel(columns[colMenuColumn].label).map( + (property, idx) => ( + + handleColMenuPropertyUpdate(colMenuColumn, property) + } + > + {property} + + ) + )} + + ) : null} {colMenuColumn !== sortedColumn || order === undefined || diff --git a/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableViewSkeleton.tsx b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableViewSkeleton.tsx index 383a1481..334c8053 100644 --- a/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableViewSkeleton.tsx +++ b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableViewSkeleton.tsx @@ -1,9 +1,7 @@ "use client"; -import DownloadIcon from "@mui/icons-material/Download"; import { Box, - Button, Checkbox, FormControl, MenuItem, diff --git a/drc-portals/components/prototype/constants/cxt-menu.tsx b/drc-portals/components/prototype/constants/cxt-menu.tsx index 625a1b5c..9d4b24a6 100644 --- a/drc-portals/components/prototype/constants/cxt-menu.tsx +++ b/drc-portals/components/prototype/constants/cxt-menu.tsx @@ -1,6 +1,7 @@ // Cxt Menu Item IDs export const ADD_CONNECTION_ITEM_ID = "chart-cxt-add-connection"; export const SHOW_FILTERS_ID = "chart-cxt-show-filters"; +export const SHOW_HIDE_NODE_ID = "chart-cxt-show-hide-node"; export const PRUNE_ID = "chart-cxt-prune"; export const RESET_HIGHLIGHTS = "cxt-menu-reset-highlights"; export const DOWNLOAD_SELECTION = "chart-cxt-download-selection"; diff --git a/drc-portals/components/prototype/constants/cy.tsx b/drc-portals/components/prototype/constants/cy.tsx index 93f0d379..c9b22802 100644 --- a/drc-portals/components/prototype/constants/cy.tsx +++ b/drc-portals/components/prototype/constants/cy.tsx @@ -403,7 +403,6 @@ export const PATHWAY_SEARCH_STYLESHEET: any[] = [ } }, - opacity: TRANSPARENT_OPACITY, }, }, { @@ -421,17 +420,9 @@ export const PATHWAY_SEARCH_STYLESHEET: any[] = [ ? "" : ` (${element.data("count")})` }`, - opacity: TRANSPARENT_OPACITY, }, }, ...STYLESHEET_CLASSES, // Classes must be last so they are not overwritten by selector styles! - { - selector: ".path-element", - style: { - opacity: SOLID_OPACITY, - "z-index": HIGH_Z_INDEX, - }, - }, { selector: "node.prune-candidate", style: { @@ -2891,22 +2882,6 @@ export const SCHEMA_STYLESHEET: any[] = [ selector: `edge#${PHENOTYPE_ASSOCIATED_WITH_DISEASE_EDGE_ID}`, style: {}, }, - { - selector: "node.path-element", - style: { - "border-color": PATH_COLOR, - "border-width": NODE_BORDER_WIDTH, - opacity: SOLID_OPACITY, - }, - }, - { - selector: "edge.path-element", - style: { - "line-color": PATH_COLOR, - "target-arrow-color": PATH_COLOR, - opacity: SOLID_OPACITY, - }, - }, ]; export const SCHEMA_LAYOUT = { diff --git a/drc-portals/components/prototype/constants/pathway-search.tsx b/drc-portals/components/prototype/constants/pathway-search.tsx index 8b42c1a3..48133c6d 100644 --- a/drc-portals/components/prototype/constants/pathway-search.tsx +++ b/drc-portals/components/prototype/constants/pathway-search.tsx @@ -29,7 +29,7 @@ export const NodeFilterBox = styled(Box)(() => ({ export const PathwayResultTabPanel = styled(BaseTabPanel)(() => ({ width: "100%", - height: "573px", + height: "603px", })); export const TableViewContainer = styled(Paper)(() => ({ diff --git a/drc-portals/components/prototype/interfaces/pathway-search.ts b/drc-portals/components/prototype/interfaces/pathway-search.ts index 630c97cd..bd35e8cf 100644 --- a/drc-portals/components/prototype/interfaces/pathway-search.ts +++ b/drc-portals/components/prototype/interfaces/pathway-search.ts @@ -8,6 +8,7 @@ import { PathwaySearchNodeDataProps } from "../types/pathway-search"; export interface PathwaySearchNodeData { id: string; dbLabel: string; + visible: boolean; count?: number; props?: PathwaySearchNodeDataProps; } @@ -44,6 +45,7 @@ export interface ColumnData { key: string; label: string; displayProp: string; + visible: boolean; postfix?: number; valueGetter: (node: NodeResult, displayProp: string) => ReactNode; } diff --git a/drc-portals/components/prototype/utils/pathway-search.tsx b/drc-portals/components/prototype/utils/pathway-search.tsx index ebaeb556..706def63 100644 --- a/drc-portals/components/prototype/utils/pathway-search.tsx +++ b/drc-portals/components/prototype/utils/pathway-search.tsx @@ -82,6 +82,7 @@ export const createTree = (elements: PathwaySearchElement[]): PathwayNode => { return { id: root.data.id, label: root.data.dbLabel, + visible: root.data.visible, props: root.data.props, parentRelationship: undefined, children: [], @@ -104,6 +105,7 @@ export const createTree = (elements: PathwaySearchElement[]): PathwayNode => { return { id: root.data.id, label: root.data.dbLabel, + visible: root.data.visible, props: root.data.props, parentRelationship: parentEdge === undefined @@ -121,8 +123,7 @@ export const createTree = (elements: PathwaySearchElement[]): PathwayNode => { elements.filter( (el) => childIds.has(el.data.id) && // el is a child of the root - !isPathwaySearchEdgeElement(el) && // el is not an edge - el.classes?.includes("path-element") // el is part of the path + !isPathwaySearchEdgeElement(el) // el is not an edge ) as PathwaySearchNode[] ) .map((node) => createTreeFromRoot(node)) @@ -233,6 +234,7 @@ export const getColumnDataFromTree = (tree: PathwayNode): ColumnData[] => { const nodeId = node.id; const label = node.label; const labelCount = labelCounts.get(label); + const visible = node.visible; let postfix, valueGetter, displayProp; if (labelCount === undefined) { @@ -330,6 +332,7 @@ export const getColumnDataFromTree = (tree: PathwayNode): ColumnData[] => { return { key: nodeId, label, + visible, displayProp, postfix, valueGetter, diff --git a/drc-portals/lib/neo4j/cypher.ts b/drc-portals/lib/neo4j/cypher.ts index 1776c901..85c0c8f6 100644 --- a/drc-portals/lib/neo4j/cypher.ts +++ b/drc-portals/lib/neo4j/cypher.ts @@ -1,3 +1,5 @@ +import set_difference from "@/utils/set"; + import { CORE_LABELS } from "./constants"; import { Direction } from "./enums"; import { TreeParseResult } from "./types"; @@ -176,8 +178,12 @@ export const createPathwaySearchAllPathsCypher = ( orderByProp?: string, order?: "asc" | "desc" | undefined ) => { - const nodeIds = Array.from(treeParseResult.nodeIds).map(escapeCypherString); - const relIds = Array.from(treeParseResult.relIds).map(escapeCypherString); + const nodeIds = Array.from( + set_difference(treeParseResult.nodeIds, treeParseResult.hiddenNodeIds) + ).map(escapeCypherString); + const relIds = Array.from( + set_difference(treeParseResult.relIds, treeParseResult.hiddenRelIds) + ).map(escapeCypherString); const usingJoinStmts = usingJoin ? treeParseResult.usingJoinStmts : []; return [ @@ -187,7 +193,7 @@ export const createPathwaySearchAllPathsCypher = ( ...(treeParseResult.filterMap.size > 0 ? ["WHERE", Array.from(treeParseResult.filterMap.values()).join(" AND ")] : []), - "WITH *", + `WITH DISTINCT ${nodeIds.concat(relIds).join(", ")}`, // Need to order/paginate before aliasing the results to the return values. In other words: "First order *all* the results by this node // and property, then paginate the results, then map that page into the final result." If we did the ordering/pagination *after* the // return, we would be ordering the *page* and not the entire result set. @@ -218,6 +224,12 @@ export const createUpperPageBoundCypher = ( treeParseResult: TreeParseResult, usingJoin = false ) => { + const nodeIds = Array.from( + set_difference(treeParseResult.nodeIds, treeParseResult.hiddenNodeIds) + ).map(escapeCypherString); + const relIds = Array.from( + set_difference(treeParseResult.relIds, treeParseResult.hiddenRelIds) + ).map(escapeCypherString); const usingJoinStmts = usingJoin ? treeParseResult.usingJoinStmts : []; return [ @@ -227,7 +239,7 @@ export const createUpperPageBoundCypher = ( ...(treeParseResult.filterMap.size > 0 ? ["WHERE", Array.from(treeParseResult.filterMap.values()).join(" AND ")] : []), - "WITH *", + `WITH DISTINCT ${nodeIds.concat(relIds).join(", ")}`, "SKIP $skip", "LIMIT ($maxSiblings - (($skip / $limit) - $lowerPageBound)) * $limit", "RETURN toInteger(ceil(toFloat(count(*)) / $limit)) + ($skip / $limit) AS upperPageBound", diff --git a/drc-portals/lib/neo4j/types.ts b/drc-portals/lib/neo4j/types.ts index 80b70ffb..b9fe3ca0 100644 --- a/drc-portals/lib/neo4j/types.ts +++ b/drc-portals/lib/neo4j/types.ts @@ -39,6 +39,7 @@ export interface PathwayNode { id: string; label: string; children: PathwayNode[]; + visible: boolean; props?: { [key: string]: any }; parentRelationship?: PathwayRelationship; } @@ -60,7 +61,9 @@ export interface TreeParseResult { patterns: string[]; filterMap: Map; nodeIds: Set; + hiddenNodeIds: Set; relIds: Set; + hiddenRelIds: Set; nodes: PathwayNode[]; outgoingCnxns: Map>; incomingCnxns: Map>; diff --git a/drc-portals/lib/neo4j/utils.ts b/drc-portals/lib/neo4j/utils.ts index 8fe90612..a064b77a 100644 --- a/drc-portals/lib/neo4j/utils.ts +++ b/drc-portals/lib/neo4j/utils.ts @@ -134,7 +134,9 @@ export const parsePathwayTree = ( const patterns: string[] = []; const filterMap = new Map(); const nodeIds = new Set(); + const hiddenNodeIds = new Set(); const relIds = new Set(); + const hiddenRelIds = new Set(); const outgoingCnxns = new Map>(); const incomingCnxns = new Map>(); const nodes: PathwayNode[] = []; @@ -166,6 +168,10 @@ export const parsePathwayTree = ( if (!nodeIds.has(node.id)) { nodeIds.add(node.id); nodes.push(node); + + if (!node.visible) { + hiddenNodeIds.add(node.id); + } } if (node.parentRelationship !== undefined && parent !== undefined) { @@ -184,6 +190,10 @@ export const parsePathwayTree = ( const escapedRelId = escapeCypherString(node.parentRelationship.id); relIds.add(node.parentRelationship.id); + if (!parent.visible || !node.visible) { + hiddenRelIds.add(node.parentRelationship.id); + } + currentPattern += `${relIsIncoming ? "<" : ""}-[${escapedRelId}:${type}]-${!relIsIncoming ? ">" : ""}`; if (node.parentRelationship.props !== undefined) { @@ -239,7 +249,9 @@ export const parsePathwayTree = ( patterns, filterMap, nodeIds, + hiddenNodeIds, relIds, + hiddenRelIds, nodes, outgoingCnxns, incomingCnxns, diff --git a/drc-portals/public/img/graph/GQI_docs_labeled_interface.png b/drc-portals/public/img/graph/GQI_docs_labeled_interface.png index c8a84a02..5d5b5979 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_labeled_interface.png and b/drc-portals/public/img/graph/GQI_docs_labeled_interface.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_pathway_builder.png b/drc-portals/public/img/graph/GQI_docs_pathway_builder.png index 0c702b26..822d291c 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_pathway_builder.png and b/drc-portals/public/img/graph/GQI_docs_pathway_builder.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_streamlined_1.png b/drc-portals/public/img/graph/GQI_docs_streamlined_1.png new file mode 100644 index 00000000..976e582d Binary files /dev/null and b/drc-portals/public/img/graph/GQI_docs_streamlined_1.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_streamlined_2.png b/drc-portals/public/img/graph/GQI_docs_streamlined_2.png new file mode 100644 index 00000000..66416c1c Binary files /dev/null and b/drc-portals/public/img/graph/GQI_docs_streamlined_2.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_tabular_view.png b/drc-portals/public/img/graph/GQI_docs_tabular_view.png index 06e23354..4d7fb460 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_tabular_view.png and b/drc-portals/public/img/graph/GQI_docs_tabular_view.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_uc1_2.png b/drc-portals/public/img/graph/GQI_docs_uc1_2.png index 015beaf4..b771ed23 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_uc1_2.png and b/drc-portals/public/img/graph/GQI_docs_uc1_2.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_uc1_3.png b/drc-portals/public/img/graph/GQI_docs_uc1_3.png index ccee9c3a..b516eaad 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_uc1_3.png and b/drc-portals/public/img/graph/GQI_docs_uc1_3.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_uc2_2.png b/drc-portals/public/img/graph/GQI_docs_uc2_2.png index 905b46c2..5665a595 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_uc2_2.png and b/drc-portals/public/img/graph/GQI_docs_uc2_2.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_uc2_3.png b/drc-portals/public/img/graph/GQI_docs_uc2_3.png index f3cf2da5..7245013c 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_uc2_3.png and b/drc-portals/public/img/graph/GQI_docs_uc2_3.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_uc3_2.png b/drc-portals/public/img/graph/GQI_docs_uc3_2.png index 24b69153..32367ed0 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_uc3_2.png and b/drc-portals/public/img/graph/GQI_docs_uc3_2.png differ diff --git a/drc-portals/public/img/graph/GQI_docs_uc3_3.png b/drc-portals/public/img/graph/GQI_docs_uc3_3.png index 973a0a9f..a3ef8946 100644 Binary files a/drc-portals/public/img/graph/GQI_docs_uc3_3.png and b/drc-portals/public/img/graph/GQI_docs_uc3_3.png differ