Skip to content

Commit

Permalink
Merge pull request #183 from cytoscape/feature/data_export
Browse files Browse the repository at this point in the history
Feature/data export
  • Loading branch information
mikekucera authored Jul 26, 2023
2 parents 8f2170e + 120fb17 commit 95f1ab8
Show file tree
Hide file tree
Showing 5 changed files with 310 additions and 66 deletions.
68 changes: 32 additions & 36 deletions src/client/components/network-editor/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import ReplyIcon from '@material-ui/icons/Reply';
import MoreIcon from '@material-ui/icons/MoreVert';
import { Add, Remove } from '@material-ui/icons';
import CloseIcon from '@material-ui/icons/Close';
import CircularProgressIcon from '@material-ui/core/CircularProgress';

const MOBILE_MENU_ID = "menu-mobile";
const SHARE_MENU_ID = "menu-share";
Expand Down Expand Up @@ -85,8 +86,14 @@ export function Header({ controller, classes, showControlPanel, isMobile, onShow
const [ mobileMoreAnchorEl, setMobileMoreAnchorEl ] = useState(null);
const [ anchorEl, setAnchorEl ] = useState(null);
const [ networkLoaded, setNetworkLoaded ] = useState(() => controller.isNetworkLoaded());
const [ snackOpen, setSnackOpen ] = useState(false);
const [ snackMessage, setSnackMessage ] = useState('');

const [ snackBarState, setSnackBarState ] = useState({
open: false,
message: "",
autoHideDelay: 4000,
closeable: true,
spinner: false
});

const panner = createPanner(controller);

Expand Down Expand Up @@ -141,11 +148,6 @@ export function Header({ controller, classes, showControlPanel, isMobile, onShow
setAnchorEl(event.currentTarget);
};

const showSnackbar = (open, message='') => {
setSnackOpen(open);
setSnackMessage(message);
};

const buttonsDef = [
{
title: "Zoom In",
Expand Down Expand Up @@ -198,18 +200,25 @@ export function Header({ controller, classes, showControlPanel, isMobile, onShow
<Snackbar
className={classes.snackBar}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackOpen}
autoHideDuration={4000}
onClose={() => showSnackbar(false)}
open={snackBarState.open || false}
autoHideDuration={snackBarState.autoHideDelay || null}
onClose={() => setSnackBarState({ open: false })}
>
<SnackbarContent
className={classes.snackBarContent}
message={<span>{snackMessage}</span>}
action={
<IconButton size='small' onClick={() => showSnackbar(false)}>
<CloseIcon />
</IconButton>
}
message={<span>{snackBarState.message || ""}</span>}
action={(() => {
if(snackBarState.closeable) {
return (
<IconButton size='small'
onClick={() => setSnackBarState({ open: false })}>
<CloseIcon />
</IconButton>
);
} else if(snackBarState.spinner) {
return <CircularProgressIcon size={20}/>;
}
})()}
/>
</Snackbar>
<AppBar
Expand Down Expand Up @@ -259,26 +268,13 @@ export function Header({ controller, classes, showControlPanel, isMobile, onShow
</div>
</Toolbar>
<MobileMenu />
{anchorEl && (
<Popover
id="menu-popover"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
onClose={handleMenuClose}
>
{menuName === SHARE_MENU_ID && (
<ShareMenu
controller={controller}
onClose={handleMenuClose}
showMessage={message => showSnackbar(true, message)}
/>
)}
</Popover>
)}
<ShareMenu
visible={menuName === SHARE_MENU_ID}
target={anchorEl}
controller={controller}
onClose={handleMenuClose}
setSnackBarState={setSnackBarState}
/>
</AppBar>
</>;
}
Expand Down
138 changes: 109 additions & 29 deletions src/client/components/network-editor/share-panel.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { saveAs } from 'file-saver';
import JSZip from 'jszip';
import { NetworkEditorController } from './controller';
import { MenuList, MenuItem, ListItemIcon, ListItemText } from '@material-ui/core';
import { MenuList, MenuItem, ListItemIcon, ListItemText, Popover } from '@material-ui/core';
import { getSVGString } from './legend-svg';
import { NODE_COLOR_SVG_ID } from './legend-button';
import CloudDownloadIcon from '@material-ui/icons/CloudDownload';
Expand All @@ -22,6 +22,10 @@ const ImageArea = {
};


function wait(millis, value="") {
return new Promise(resolve => setTimeout(resolve, millis, value));
}

async function createNetworkImageBlob(controller, imageSize, imageArea=ImageArea.FULL) {
return await controller.cy.png({
output: 'blob-promise',
Expand All @@ -42,17 +46,22 @@ async function clearSelectionStyle(controller) {
return async () => eles.addClass('unselected');
}


function getZipFileName(controller) {
function getZipFileName(controller, suffix) {
const networkName = controller.cy.data('name');
if(networkName) {
// eslint-disable-next-line no-control-regex
const reserved = /[<>:"/\\|?*\u0000-\u001F]/g;
if(!reserved.test(networkName)) {
return networkName + '.zip';
return `${networkName}_${suffix}.zip`;
}
}
return 'enrichment_map_images.zip';
return `enrichment_map_${suffix}.zip`;
}

async function saveZip(controller, zip, type) {
const archiveBlob = await zip.generateAsync({ type: 'blob' });
const fileName = getZipFileName(controller, type);
await saveAs(archiveBlob, fileName);
}


Expand All @@ -74,10 +83,30 @@ async function handleExportImageArchive(controller) {
zip.file('enrichment_map_large.png', blobs[2]);
zip.file('node_color_legend.svg', blobs[3]);

const archiveBlob = await zip.generateAsync({ type: 'blob' });

const fileName = getZipFileName(controller);
await saveAs(archiveBlob, fileName);
saveZip(controller, zip, 'images');
}


async function handleExportDataArchive(controller) {
const netID = controller.networkIDStr;

const fetchExport = async path => {
const res = await fetch(path);
return await res.text();
};

const files = await Promise.all([
fetchExport(`/api/export/enrichment/${netID}`),
fetchExport(`/api/export/ranks/${netID}`),
fetchExport(`/api/export/gmt/${netID}`),
]);

const zip = new JSZip();
zip.file('enrichment_results.txt', files[0]);
zip.file('ranks.txt', files[1]);
zip.file('gene_sets.gmt', files[2]);

saveZip(controller, zip, 'enrichment');
}


Expand All @@ -87,39 +116,90 @@ function handleCopyToClipboard() {
}


export function ShareMenu({ controller, onClose = ()=>null, showMessage = ()=>null }) {
function snackBarOps(setSnackBarState) {
return {
close: () => setSnackBarState({ open: false }),
showMessage: message => setSnackBarState({ open: true, closeable: true, autoHideDelay: 3000, message }),
showSpinner: message => setSnackBarState({ open: true, closeable: false, spinner: true, message }),
};
}


export function ShareMenu({ controller, target, visible, onClose = ()=>null, setSnackBarState = ()=>null }) {
const [ imageExportEnabled, setImageExportEnabled ] = useState(true);
const [ dataExportEnabled, setDataExportEnabled ] = useState(true);

const snack = snackBarOps(setSnackBarState);

const handleCopyLink = async () => {
await handleCopyToClipboard();
onClose();
showMessage("Link copied to clipboard");
await handleCopyToClipboard();
snack.showMessage("Link copied to clipboard");
};

const handleExportImages = async () => {
await handleExportImageArchive(controller);
onClose();
setImageExportEnabled(false);
await handleExportImageArchive(controller);
setImageExportEnabled(true);
};

const handleExportData = async () => {
onClose();
setDataExportEnabled(false);

const dataExportPromise = handleExportDataArchive(controller);

const value = await Promise.race([ dataExportPromise, wait(1000,"waiting") ]);

if(value === "waiting") // if the "waiting" promise resolved first then show a progress indicator
snack.showSpinner("Exporting enrichment data...");

await dataExportPromise; // wait for the export to finish
snack.close();
setDataExportEnabled(true);
};

return (
<MenuList>
<MenuItem onClick={handleCopyLink}>
<ListItemIcon>
<LinkIcon />
</ListItemIcon>
<ListItemText>Share Link to Network</ListItemText>
</MenuItem>
<MenuItem onClick={handleExportImages}>
<ListItemIcon>
<CloudDownloadIcon />
</ListItemIcon>
<ListItemText>Save Network Images</ListItemText>
</MenuItem>
</MenuList>
<Popover
id="menu-popover"
anchorEl={target}
open={visible && Boolean(target)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
onClose={onClose}
>
<MenuList>
<MenuItem onClick={handleCopyLink}>
<ListItemIcon>
<LinkIcon />
</ListItemIcon>
<ListItemText>Share Link to Network</ListItemText>
</MenuItem>
<MenuItem onClick={handleExportImages} disabled={!imageExportEnabled}>
<ListItemIcon>
<CloudDownloadIcon />
</ListItemIcon>
<ListItemText>Save Network Images</ListItemText>
</MenuItem>
<MenuItem onClick={handleExportData} disabled={!dataExportEnabled}>
<ListItemIcon>
<CloudDownloadIcon />
</ListItemIcon>
<ListItemText>Export Enrichment Data</ListItemText>
</MenuItem>
</MenuList>
</Popover>
);
}
ShareMenu.propTypes = {
controller: PropTypes.instanceOf(NetworkEditorController).isRequired,
onClose: PropTypes.func,
showMessage: PropTypes.func
setSnackBarState: PropTypes.func,
target: PropTypes.any,
visible: PropTypes.bool,
};

export default ShareMenu;
80 changes: 79 additions & 1 deletion src/server/datastore.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,85 @@ class Datastore {
}

/**
* Returns the network document.
* Returns an cursor of objects of the form:
* [ { "name": "PYROPTOSIS%REACTOME%R-HSA-5620971.3", "padj": 0.0322, "NES": -1.8049, "pval": 0.0022, "size": 27 }, ... ]
*/
async getEnrichmentResultsCursor(networkIDString) {
const networkID = makeID(networkIDString);

const cursor = await this.db
.collection(NETWORKS_COLLECTION)
.aggregate([
{ $match: { _id: networkID.bson } },
{ $replaceWith: { path: "$network.elements.nodes.data" } },
{ $unwind: { path: "$path" } },
{ $replaceRoot: { newRoot: "$path" } },
{ $project: {
name: { $arrayElemAt: [ "$name", 0 ] },
pval: "$pvalue",
padj: true,
NES: true,
size: "$gs_size"
}}
]);

return cursor;
}

/**
* Returns an cursor of objects of the form (sorted by rank):
* [ { "gene": "ABCD", "rank": 0.0322 }, ... ]
*/
async getRankedGeneListCursor(networkIDString) {
const networkID = makeID(networkIDString);

const cursor = await this.db
.collection(GENE_LISTS_COLLECTION)
.aggregate([
{ $match: { networkID: networkID.bson } },
{ $unwind: { path: "$genes" } },
{ $replaceRoot: { newRoot: "$genes" } },
{ $sort: { rank: -1 }}
]);

return cursor;
}

/**
* Returns an cursor of objects of the form:
* [ { "name": "My Gene Set", "description": "blah blah", "genes": ["ABC", "DEF"] }, ... ]
*/
async getGMTCursor(geneSetCollection, networkIDString) {
const networkID = makeID(networkIDString);

const cursor = await this.db
.collection(NETWORKS_COLLECTION)
.aggregate([
{ $match: { _id: networkID.bson } },
{ $replaceWith: { path: "$network.elements.nodes.data" } },
{ $unwind: { path: "$path" } },
{ $replaceRoot: { newRoot: "$path" } },
{ $project: { name: { $arrayElemAt: [ "$name", 0 ] } } },
{ $lookup: {
from: geneSetCollection,
localField: "name",
foreignField: "name",
as: "geneSet"
}},
{ $unwind: "$geneSet" },
{ $project: {
name: true,
description: "$geneSet.description",
genes: "$geneSet.genes",
}}
]);

return cursor;
}


/**
* Returns names
*/
async getNodeDataSetNames(networkIDString, options) {
const { nodeLimit } = options;
Expand Down
Loading

0 comments on commit 95f1ab8

Please sign in to comment.