UNPKG

vitessce

Version:

Vitessce app and React component library

630 lines (591 loc) 21.9 kB
import React, { useEffect, useState, useMemo, } from 'react'; import isEqual from 'lodash/isEqual'; import packageJson from '../../../package.json'; import { useCoordination, useLoaders, useSetWarning, } from '../../app/state/hooks'; import { COMPONENT_COORDINATION_TYPES } from '../../app/state/coordination'; import SetsManager from './SetsManager'; import TitleInfo from '../TitleInfo'; import { treeExportLevelZeroNode, treeExportSet, treeToExpectedCheckedLevel, nodeToLevelDescendantNamePaths, treeToIntersection, treeToUnion, treeToComplement, treeFindNodeByNamePath, treesConflict, nodeTransform, nodeAppendChild, nodePrependChild, nodeInsertChild, filterNode, treeInitialize, initializeCellSetColor, } from './cell-set-utils'; import { isEqualOrPrefix, tryRenamePath, PATH_SEP, } from './utils'; import { downloadForUser, handleExportJSON, handleExportTabular, tryUpgradeTreeToLatestSchema, } from './io'; import { FILE_EXTENSION_JSON, FILE_EXTENSION_TABULAR, SETS_DATATYPE_CELL, } from './constants'; import { useUrls, useReady } from '../hooks'; import { setCellSelection, mergeCellSets, getNextNumberedNodeName, } from '../utils'; import { useCellsData, useCellSetsData } from '../data-hooks'; const CELL_SETS_DATA_TYPES = ['cells', 'cell-sets']; /** * A subscriber wrapper around the SetsManager component * for the 'cell' datatype. * @param {object} props * @param {string} props.theme The current theme name. * @param {object} props.coordinationScopes The mapping from coordination types to coordination * scopes. * @param {function} props.removeGridComponent The callback function to pass to TitleInfo, * to call when the component has been removed from the grid. * @param {string} props.title The component title. */ export default function CellSetsManagerSubscriber(props) { const { coordinationScopes, removeGridComponent, theme, title = 'Cell Sets', } = props; const loaders = useLoaders(); const setWarning = useSetWarning(); // Get "props" from the coordination space. const [{ dataset, cellSetSelection, cellSetColor, additionalCellSets, cellColorEncoding, }, { setCellSetSelection, setCellColorEncoding, setCellSetColor, setAdditionalCellSets, }] = useCoordination(COMPONENT_COORDINATION_TYPES.cellSets, coordinationScopes); const [urls, addUrl, resetUrls] = useUrls(); const [ isReady, setItemIsReady, setItemIsNotReady, // eslint-disable-line no-unused-vars resetReadyItems, ] = useReady( CELL_SETS_DATA_TYPES, ); const [cellSetExpansion, setCellSetExpansion] = useState([]); // Reset file URLs and loader progress when the dataset has changed. useEffect(() => { resetUrls(); resetReadyItems(); setCellSetExpansion([]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loaders, dataset]); // Get data from loaders using the data hooks. const [cells] = useCellsData(loaders, dataset, setItemIsReady, addUrl, true); const [cellSets] = useCellSetsData( loaders, dataset, setItemIsReady, addUrl, true, { setCellSetSelection, setCellSetColor }, { cellSetSelection, cellSetColor }, ); // Validate and upgrade the additionalCellSets. useEffect(() => { if (additionalCellSets) { let upgradedCellSets; try { upgradedCellSets = tryUpgradeTreeToLatestSchema(additionalCellSets, SETS_DATATYPE_CELL); } catch (e) { setWarning(e.message); return; } setAdditionalCellSets(upgradedCellSets); } }, [additionalCellSets, setAdditionalCellSets, setWarning]); // Get an array of all cell IDs to use for set complement operations. const allCellIds = useMemo(() => (cells ? Object.keys(cells) : []), [cells]); // A helper function for updating the encoding for cell colors, // which may have previously been set to 'geneSelection'. function setCellSetColorEncoding() { setCellColorEncoding('cellSetSelection'); } // Merged cell sets are only to be used for convenience when reading // (if writing: update either `cellSets` _or_ `additionalCellSets`). const mergedCellSets = useMemo( () => mergeCellSets(cellSets, additionalCellSets), [cellSets, additionalCellSets], ); // Infer the state of the "checked level" radio button based on the selected cell sets. const checkedLevel = useMemo(() => { if (cellSetSelection && cellSetSelection.length > 0 && mergedCellSets && mergedCellSets.tree.length > 0) { return treeToExpectedCheckedLevel(mergedCellSets, cellSetSelection); } return null; }, [cellSetSelection, mergedCellSets]); // Callback functions // The user wants to select all nodes at a particular hierarchy level. function onCheckLevel(levelZeroName, levelIndex) { const lzn = mergedCellSets.tree.find(n => n.name === levelZeroName); if (lzn) { const newCellSetSelection = nodeToLevelDescendantNamePaths(lzn, levelIndex, [], true); setCellSetSelection(newCellSetSelection); setCellSetColorEncoding(); } } // The user wants to check or uncheck a cell set node. function onCheckNode(targetKey, checked) { const targetPath = (Array.isArray(targetKey) ? targetKey : targetKey.split(PATH_SEP)); if (!targetKey) { return; } if (checked) { setCellSetSelection([...cellSetSelection, targetPath]); } else { setCellSetSelection(cellSetSelection.filter(d => !isEqual(d, targetPath))); } setCellSetColorEncoding(); } // The user wants to expand or collapse a node in the tree. function onExpandNode(expandedKeys, targetKey, expanded) { if (expanded) { setCellSetExpansion(prev => ([...prev, targetKey.split(PATH_SEP)])); } else { setCellSetExpansion(prev => prev.filter(d => !isEqual(d, targetKey.split(PATH_SEP)))); } } // The user dragged a tree node and dropped it somewhere else in the tree // to re-arrange or re-order the nodes. // We need to verify that their drop target is valid, and if so, complete // the tree re-arrangement. function onDropNode(dropKey, dragKey, dropPosition, dropToGap) { const dropPath = dropKey.split(PATH_SEP); const dropNode = treeFindNodeByNamePath(additionalCellSets, dropPath); if (!dropNode.children && !dropToGap) { // Do not allow a node with a set (i.e. leaf) to become a child of another node with a set, // as this will result in an internal node having a set, which we do not allow. return; } const dropNodeLevel = dropPath.length - 1; const dropNodeIsLevelZero = dropNodeLevel === 0; // Get drag node. const dragPath = dragKey.split(PATH_SEP); const dragNode = treeFindNodeByNamePath(additionalCellSets, dragPath); if (dropNodeIsLevelZero && dropToGap && !dragNode.children) { // Do not allow a leaf node to become a level zero node. return; } let dropParentNode; let dropParentPath; let dropNodeCurrIndex; if (!dropNodeIsLevelZero) { dropParentPath = dropPath.slice(0, -1); dropParentNode = treeFindNodeByNamePath(additionalCellSets, dropParentPath); dropNodeCurrIndex = dropParentNode.children.findIndex(c => c.name === dropNode.name); } else { dropNodeCurrIndex = additionalCellSets.tree.findIndex( lzn => lzn.name === dropNode.name, ); } // Further, only allow dragging if the dragged node will have a unique // name among its new siblings. let hasSiblingNameConflict; const dragNodeName = dragNode.name; if (!dropNodeIsLevelZero && dropToGap) { hasSiblingNameConflict = dropParentNode.children .find(c => c !== dragNode && c.name === dragNodeName); } else if (!dropToGap) { hasSiblingNameConflict = dropNode.children .find(c => c !== dragNode && c.name === dragNodeName); } else { hasSiblingNameConflict = additionalCellSets.tree .find(lzn => lzn !== dragNode && lzn.name === dragNodeName); } if (hasSiblingNameConflict) { return; } // Remove the dragged object from its current position. // Recursively check whether each node path // matches the path of the node to delete. // If so, return null, and then always use // .filter(Boolean) to eliminate any null array elements. const nextAdditionalCellSets = { ...additionalCellSets, tree: additionalCellSets.tree.map(lzn => filterNode(lzn, [], dragPath)).filter(Boolean), }; // Update index values after temporarily removing the dragged node. // Names are unique as children of their parents. if (!dropNodeIsLevelZero) { dropNodeCurrIndex = dropParentNode.children.findIndex(c => c.name === dropNode.name); } else { dropNodeCurrIndex = nextAdditionalCellSets.tree.findIndex( lzn => lzn.name === dropNode.name, ); } let newDragPath = []; if (!dropToGap || !dropNodeIsLevelZero) { let addChildFunction; let checkPathFunction; const newPath = []; if (!dropToGap) { // Append the dragNode to dropNode's children if dropping _onto_ the dropNode. // Set dragNode as the last child of dropNode. addChildFunction = n => nodeAppendChild(n, dragNode); checkPathFunction = path => isEqual(path, dropPath); } else if (!dropNodeIsLevelZero) { // Prepend or insert the dragNode if dropping _between_ (above or below dropNode). // The dropNode is at a level greater than zero, // so it has a parent. checkPathFunction = path => isEqual(path, dropParentPath); if (dropPosition === -1) { // Set dragNode as first child of dropParentNode. addChildFunction = n => nodePrependChild(n, dragNode); } else { // Set dragNode before or after dropNode. const insertIndex = dropNodeCurrIndex + (dropPosition > dropNodeCurrIndex ? 1 : 0); addChildFunction = n => nodeInsertChild(n, dragNode, insertIndex); } } nextAdditionalCellSets.tree = nextAdditionalCellSets.tree.map( node => nodeTransform( node, (n, path) => checkPathFunction(path), (n) => { const newNode = addChildFunction(n); return newNode; }, newPath, ), ); // Done setAdditionalCellSets(nextAdditionalCellSets); newDragPath = [...newPath[0], dragNode.name]; setCellSetSelection([newDragPath]); } else if (dropPosition === -1) { // We need to drop the dragNode to level zero, // and level zero nodes do not have parents. // Set dragNode as first level zero node of the tree. nextAdditionalCellSets.tree.unshift(dragNode); setAdditionalCellSets(nextAdditionalCellSets); newDragPath = [dragNode.name]; setCellSetSelection([newDragPath]); } else { // Set dragNode before or after dropNode in level zero. const insertIndex = dropNodeCurrIndex + (dropPosition > dropNodeCurrIndex ? 1 : 0); const newLevelZero = Array.from(nextAdditionalCellSets.tree); newLevelZero.splice(insertIndex, 0, dragNode); nextAdditionalCellSets.tree = newLevelZero; setAdditionalCellSets(nextAdditionalCellSets); newDragPath = [dragNode.name]; setCellSetSelection([newDragPath]); } const oldColors = cellSetColor.filter( i => isEqualOrPrefix(dragPath, i.path), ); const newColors = oldColors.map( i => ( { ...i, path: !isEqual(i.path, dragPath) ? newDragPath.concat(i.path.slice(dragPath.length)) : newDragPath, } ), ); const newCellSetColor = cellSetColor.filter( i => !isEqualOrPrefix(dragPath, i.path), ); newCellSetColor.push(...newColors); setCellSetColor(newCellSetColor); } // The user wants to change the color of a cell set node. function onNodeSetColor(targetPath, color) { // Replace the color if an array element for this path already exists. const prevNodeColor = cellSetColor?.find(d => isEqual(d.path, targetPath)); if (!prevNodeColor) { setCellSetColor([ ...(cellSetColor || []), { path: targetPath, color, }, ]); } else { setCellSetColor([ ...cellSetColor.filter(d => !isEqual(d.path, targetPath)), { path: targetPath, color, }, ]); } } // The user wants to change the name of a cell set node. function onNodeSetName(targetPath, name) { const nextNamePath = [...targetPath]; nextNamePath.pop(); nextNamePath.push(name); // Recursively check whether each node path // matches the path or a prefix of the path of the node to rename. // If so, rename the node using the new path. function renameNode(node, prevPath) { if (isEqual([...prevPath, node.name], targetPath)) { return { ...node, name, }; } if (!node.children) { return node; } return { ...node, children: node.children.map(c => renameNode(c, [...prevPath, node.name])), }; } const nextAdditionalCellSets = { ...additionalCellSets, tree: additionalCellSets.tree.map(lzn => renameNode(lzn, [])), }; // Change all paths that have this node as a prefix (i.e. descendants). const nextCellSetColor = cellSetColor.map(d => ({ path: tryRenamePath(targetPath, d.path, nextNamePath), color: d.color, })); const nextCellSetSelection = cellSetSelection.map(d => ( tryRenamePath(targetPath, d, nextNamePath) )); const nextCellSetExpansion = cellSetExpansion.map(d => ( tryRenamePath(targetPath, d, nextNamePath) )); // Need to update the node path everywhere it may be present. setAdditionalCellSets(nextAdditionalCellSets); setCellSetColor(nextCellSetColor); setCellSetSelection(nextCellSetSelection); setCellSetExpansion(nextCellSetExpansion); } // Each time the user types while renaming a cell set node, // we need to check whether the potential new name conflicts // with any existing cell set node names. // If there are conflicts, we want to disable the "Save" button. function onNodeCheckNewName(targetPath, name) { const nextNamePath = [...targetPath]; nextNamePath.pop(); nextNamePath.push(name); const hasConflicts = ( !isEqual(targetPath, nextNamePath) && treeFindNodeByNamePath(additionalCellSets, nextNamePath) ); return hasConflicts; } // The user wants to delete a cell set node, and has confirmed their choice. function onNodeRemove(targetPath) { // Recursively check whether each node path // matches the path of the node to delete. // If so, return null, and then always use // .filter(Boolean) to eliminate any null array elements. const nextAdditionalCellSets = { ...additionalCellSets, tree: additionalCellSets.tree.map(lzn => filterNode(lzn, [], targetPath)).filter(Boolean), }; // Delete state for all paths that have this node // path as a prefix (i.e. delete all descendents). const nextCellSetColor = cellSetColor.filter(d => !isEqualOrPrefix(targetPath, d.path)); const nextCellSetSelection = cellSetSelection.filter(d => !isEqualOrPrefix(targetPath, d)); const nextCellSetExpansion = cellSetExpansion.filter(d => !isEqualOrPrefix(targetPath, d)); setAdditionalCellSets(nextAdditionalCellSets); setCellSetColor(nextCellSetColor); setCellSetSelection(nextCellSetSelection); setCellSetExpansion(nextCellSetExpansion); } // The user wants to view (i.e. select) a particular node, // or its expanded descendents. function onNodeView(targetPath) { // If parent node is clicked, and if it is expanded, // then select the expanded descendent nodes. const setsToView = []; // Recursively determine which descendent nodes are currently expanded. function viewNode(node, nodePath) { if (cellSetExpansion.find(expandedPath => isEqual(nodePath, expandedPath))) { if (node.children) { node.children.forEach((c) => { viewNode(c, [...nodePath, c.name]); }); } else { setsToView.push(nodePath); } } else { setsToView.push(nodePath); } } const targetNode = treeFindNodeByNamePath(mergedCellSets, targetPath); viewNode(targetNode, targetPath); setCellSetSelection(setsToView); setCellSetColorEncoding(); } // The user wants to create a new level zero node. function onCreateLevelZeroNode() { const nextName = getNextNumberedNodeName(additionalCellSets?.tree, 'My hierarchy '); setAdditionalCellSets({ ...(additionalCellSets || treeInitialize(SETS_DATATYPE_CELL)), tree: [ ...(additionalCellSets ? additionalCellSets.tree : []), { name: nextName, children: [], }, ], }); } // The user wants to create a new node corresponding to // the union of the selected sets. function onUnion() { const newSet = treeToUnion(mergedCellSets, cellSetSelection); setCellSelection( newSet, additionalCellSets, cellSetColor, setCellSetSelection, setAdditionalCellSets, setCellSetColor, setCellColorEncoding, 'Union ', ); } // The user wants to create a new node corresponding to // the intersection of the selected sets. function onIntersection() { const newSet = treeToIntersection(mergedCellSets, cellSetSelection); setCellSelection( newSet, additionalCellSets, cellSetColor, setCellSetSelection, setAdditionalCellSets, setCellSetColor, setCellColorEncoding, 'Intersection ', ); } // The user wants to create a new node corresponding to // the complement of the selected sets. function onComplement() { const newSet = treeToComplement(mergedCellSets, cellSetSelection, allCellIds); setCellSelection( newSet, additionalCellSets, cellSetColor, setCellSetSelection, setAdditionalCellSets, setCellSetColor, setCellColorEncoding, 'Complement ', ); } // The user wants to import a cell set hierarchy, // probably from a CSV or JSON file. function onImportTree(treeToImport) { // Check for any naming conflicts with the current sets // (both user-defined and dataset-defined) before importing. const hasConflict = treesConflict(mergedCellSets, treeToImport); if (!hasConflict) { setAdditionalCellSets({ ...(additionalCellSets || treeInitialize(SETS_DATATYPE_CELL)), tree: [ ...(additionalCellSets ? additionalCellSets.tree : []), ...treeToImport.tree, ], }); // Automatically initialize set colors for the imported sets. const importAutoSetColors = initializeCellSetColor(treeToImport, cellSetColor); setCellSetColor([ ...cellSetColor, ...importAutoSetColors, ]); } } // The user wants to download a particular hierarchy to a JSON file. function onExportLevelZeroNodeJSON(nodePath) { const { treeToExport, nodeName, } = treeExportLevelZeroNode(mergedCellSets, nodePath, SETS_DATATYPE_CELL, cellSetColor, theme); downloadForUser( handleExportJSON(treeToExport), `${nodeName}_${packageJson.name}-${SETS_DATATYPE_CELL}-hierarchy.${FILE_EXTENSION_JSON}`, ); } // The user wants to download a particular hierarchy to a CSV file. function onExportLevelZeroNodeTabular(nodePath) { const { treeToExport, nodeName, } = treeExportLevelZeroNode(mergedCellSets, nodePath, SETS_DATATYPE_CELL, cellSetColor, theme); downloadForUser( handleExportTabular(treeToExport), `${nodeName}_${packageJson.name}-${SETS_DATATYPE_CELL}-hierarchy.${FILE_EXTENSION_TABULAR}`, ); } // The user wants to download a particular set to a JSON file. function onExportSetJSON(nodePath) { const { setToExport, nodeName } = treeExportSet(mergedCellSets, nodePath); downloadForUser( handleExportJSON(setToExport), `${nodeName}_${packageJson.name}-${SETS_DATATYPE_CELL}-set.${FILE_EXTENSION_JSON}`, FILE_EXTENSION_JSON, ); } return ( <TitleInfo title={title} isScroll removeGridComponent={removeGridComponent} urls={urls} theme={theme} isReady={isReady} > <SetsManager setColor={cellSetColor} sets={cellSets} additionalSets={additionalCellSets} levelSelection={checkedLevel} setSelection={cellSetSelection} setExpansion={cellSetExpansion} hasColorEncoding={cellColorEncoding === 'cellSetSelection'} draggable datatype={SETS_DATATYPE_CELL} onError={setWarning} onCheckNode={onCheckNode} onExpandNode={onExpandNode} onDropNode={onDropNode} onCheckLevel={onCheckLevel} onNodeSetColor={onNodeSetColor} onNodeSetName={onNodeSetName} onNodeCheckNewName={onNodeCheckNewName} onNodeRemove={onNodeRemove} onNodeView={onNodeView} onImportTree={onImportTree} onCreateLevelZeroNode={onCreateLevelZeroNode} onExportLevelZeroNodeJSON={onExportLevelZeroNodeJSON} onExportLevelZeroNodeTabular={onExportLevelZeroNodeTabular} onExportSetJSON={onExportSetJSON} onUnion={onUnion} onIntersection={onIntersection} onComplement={onComplement} hasCheckedSetsToUnion={cellSetSelection?.length > 1} hasCheckedSetsToIntersect={cellSetSelection?.length > 1} hasCheckedSetsToComplement={cellSetSelection?.length > 0} theme={theme} /> </TitleInfo> ); }