diff --git a/bin/profile_single_projectr_tsne_run.py b/bin/profile_single_projectr_tsne_run.py index cb5552f3..a0841ade 100644 --- a/bin/profile_single_projectr_tsne_run.py +++ b/bin/profile_single_projectr_tsne_run.py @@ -835,8 +835,8 @@ def run_tsne(dataset_id): # Rename to end the confusion adata.var = adata.var.rename(columns={adata.var.columns[0]: "ensembl_id"}) # Modify the AnnData object to not include any duplicated gene symbols (keep only first entry) + scanpy_copy = ana.dataset_path().replace('.h5ad', '.scanpy_dups_removed.h5ad') if len(df.columns) > 1: - scanpy_copy = ana.dataset_path().replace('.h5ad', '.scanpy_dups_removed.h5ad') if os.path.exists(scanpy_copy): os.remove(scanpy_copy) adata = adata[:, adata.var.index.duplicated() == False].copy(filename=scanpy_copy) @@ -845,7 +845,7 @@ def run_tsne(dataset_id): try: basis = PLOT_TYPE_TO_BASIS[plot_type] except: - raise("{} was not a valid plot type".format(plot_type)) + raise Exception("{} was not a valid plot type".format(plot_type)) # NOTE: This may change in the future if users want plots by group w/o the colorize_by plot added if plot_by_group: @@ -993,6 +993,9 @@ def run_tsne(dataset_id): plt.clf() plt.close() # Prevent zombie plots, which can cause issues + if os.path.exists(scanpy_copy): + os.remove(scanpy_copy) + return { "success": success, "message": message, diff --git a/docker/Dockerfile b/docker/Dockerfile index 72b9654b..304ce665 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -57,8 +57,7 @@ RUN apt-get -qq update \ # Required for R gfortran \ # Required for rpy2 - r-base-dev \ - r-base \ + r-cran-rjava \ # Required for R-package devtools (which is required for SJD) libharfbuzz-dev \ libfribidi-dev \ diff --git a/docker/install_bioc.R b/docker/install_bioc.R index e7a25f04..a671613c 100755 --- a/docker/install_bioc.R +++ b/docker/install_bioc.R @@ -1,5 +1,6 @@ #!/usr/bin/env Rscript --vanilla install.packages(c("BiocManager", "devtools"), dependencies=TRUE, repos="http://lib.stat.cmu.edu/R/CRAN/") +BiocManager::install(version = "3.19") # required for R 4.4.0 BiocManager::install(c("genesofeve/projectR", "biomaRt"), ask=FALSE) library(devtools); install_github("CHuanSite/SJD") \ No newline at end of file diff --git a/docker/install_bioc.sh b/docker/install_bioc.sh index 000b4a9d..06df8dec 100755 --- a/docker/install_bioc.sh +++ b/docker/install_bioc.sh @@ -5,7 +5,7 @@ Rver="${Rmaj}.4.0" current_dir=$(pwd) -curl -s -L http://lib.stat.cmu.edu/R/CRAN/src/base/${Rmaj}/${Rver}.tar.gz | tar xzv -C /opt +curl -s -L http://lib.stat.cmu.edu/R/CRAN/src/base/${Rmaj}/${Rver}.tar.gz | tar xzv -C /opt || exit 1 cd /opt/${Rver} /opt/${Rver}/configure --with-readline=no --enable-R-shlib --enable-BLAS-shlib --with-x=no || exit 1 make || exit 1 diff --git a/docker/requirements.txt b/docker/requirements.txt index 7f6d6700..fcb088bd 100644 --- a/docker/requirements.txt +++ b/docker/requirements.txt @@ -20,7 +20,7 @@ more_itertools==9.0.0 mysql-connector-python==8.0.20 numba==0.58.1 numexpr==2.8.4 -numpy==1.26.0 +numpy==1.26.4 opencv-python==4.5.5.64 openpyxl==3.1.5 pandas==2.2.1 @@ -29,11 +29,12 @@ pika==1.3.1 plotly==5.6.0 python-dotenv==0.20.0 requests==2.31.0 -rpy2==3.5.1 # 3.5.2 and up gives errors with rpy2py and py2rpy +rpy2==3.5.16 sanic scanpy==1.10.1 scikit-learn==1.0.2 scipy==1.11.04 seaborn==0.13.2 +shadows==0.1a0 tables==3.9.2 # Read hdf5 files into pandas xlrd==1.2.0 diff --git a/docs/setup.python.md b/docs/setup.python.md index 16c8a262..6b3bfa0c 100644 --- a/docs/setup.python.md +++ b/docs/setup.python.md @@ -60,7 +60,7 @@ Check the requirement.txt file in /docker for the latest packages mysql-connector-python==8.0.20 \ numba==0.58.1 \ numexpr==2.8.4 \ - numpy==1.26.0 \ + numpy==1.26.4 \ opencv-python==4.5.5.64 \ openpyxl==3.1.5 \ pandas==2.2.1 \ @@ -75,6 +75,7 @@ Check the requirement.txt file in /docker for the latest packages scikit-learn==1.0.2 \ scipy==1.11.04 \ seaborn==0.13.2 \ + shadows==0.1a0 \ tables==3.9.2 \ xlrd==1.2.0 $ sudo mkdir /opt/bin diff --git a/lib/gear/plotting.py b/lib/gear/plotting.py index 5d9dfa99..66dc2936 100644 --- a/lib/gear/plotting.py +++ b/lib/gear/plotting.py @@ -335,7 +335,7 @@ def _update_by_plot_type(fig, plot_type, force_overlay=False, use_jitter=False): def generate_plot(df, x=None, y=None, z=None, facet_row=None, facet_col=None, color_name=None, colormap=None, palette=None, - reverse_palette=False, category_orders=None, + reverse_palette=False, category_orders={}, plot_type='scatter', hide_x_labels=False, hide_y_labels=False, hide_legend=None, text_name=None, jitter=False, x_range=None, y_range=None, vlines=[], x_title=None, y_title=None, @@ -374,7 +374,7 @@ def generate_plot(df, x=None, y=None, z=None, facet_row=None, facet_col=None, , "facet_row":facet_row , "facet_col":facet_col , "color":color_name - , "category_orders": category_orders if category_orders else {} + , "category_orders": category_orders , "labels":labels_dict , "hover_name": text_name if text_name else "y_rounded" } diff --git a/lib/geardb.py b/lib/geardb.py index bc273a50..0b59f5d9 100644 --- a/lib/geardb.py +++ b/lib/geardb.py @@ -613,7 +613,10 @@ def __init__(self, id=None, dataset_id=None, user_id=None, session_id=None, labe def __repr__(self): pipeline_file = self.settings_path() - return open(pipeline_file).read() + json_data = json.loads(open(pipeline_file).read()) + # change "user_session_id" to "session_id" for consistency + json_data['session_id'] = json_data.pop('user_session_id') + return json.dumps(json_data, indent=4) def _serialize_json(self): # Called when json modules attempts to serialize @@ -1979,10 +1982,13 @@ def get_shape(self, session_id=None, tuple_only=False): ## File is under datasets/${id}.h5ad h5ad_file_path = self.get_file_path(session_id=session_id) - import scanpy as sc - sc.settings.verbosity = 0 - adata = sc.read_h5ad(h5ad_file_path) + from shadows import AnnDataShadow + adata = AnnDataShadow(h5ad_file_path) + (n_obs, n_vars) = adata.shape + + adata.close() + if tuple_only: return (n_obs, n_vars) diff --git a/services/projectr/Dockerfile b/services/projectr/Dockerfile index ed642cae..92adabcc 100644 --- a/services/projectr/Dockerfile +++ b/services/projectr/Dockerfile @@ -11,8 +11,7 @@ RUN apt-get -qq update \ # Required for R gfortran \ # Required for rpy2 - r-base-dev \ - r-base \ + r-cran-rjava \ # Required for R-package devtools (which is required for SJD) libharfbuzz-dev \ libfribidi-dev \ diff --git a/services/projectr/install_bioc.R b/services/projectr/install_bioc.R index e7a25f04..a671613c 100755 --- a/services/projectr/install_bioc.R +++ b/services/projectr/install_bioc.R @@ -1,5 +1,6 @@ #!/usr/bin/env Rscript --vanilla install.packages(c("BiocManager", "devtools"), dependencies=TRUE, repos="http://lib.stat.cmu.edu/R/CRAN/") +BiocManager::install(version = "3.19") # required for R 4.4.0 BiocManager::install(c("genesofeve/projectR", "biomaRt"), ask=FALSE) library(devtools); install_github("CHuanSite/SJD") \ No newline at end of file diff --git a/services/projectr/install_bioc.sh b/services/projectr/install_bioc.sh index 000b4a9d..06df8dec 100755 --- a/services/projectr/install_bioc.sh +++ b/services/projectr/install_bioc.sh @@ -5,7 +5,7 @@ Rver="${Rmaj}.4.0" current_dir=$(pwd) -curl -s -L http://lib.stat.cmu.edu/R/CRAN/src/base/${Rmaj}/${Rver}.tar.gz | tar xzv -C /opt +curl -s -L http://lib.stat.cmu.edu/R/CRAN/src/base/${Rmaj}/${Rver}.tar.gz | tar xzv -C /opt || exit 1 cd /opt/${Rver} /opt/${Rver}/configure --with-readline=no --enable-R-shlib --enable-BLAS-shlib --with-x=no || exit 1 make || exit 1 diff --git a/services/projectr/requirements.txt b/services/projectr/requirements.txt index d1315aa9..8605072d 100644 --- a/services/projectr/requirements.txt +++ b/services/projectr/requirements.txt @@ -1,8 +1,6 @@ Flask==3.0.0 gunicorn==20.1.0 -rpy2==3.5.1 # 3.5.2 and up gives errors with rpy2py and py2rpy -#rpy2==3.5.16 -#pandas==2.2.1 +rpy2==3.5.16 +pandas==2.2.1 numpy==1.26.4 # https://stackoverflow.com/a/78641304 -pandas==1.4.1 google-cloud-logging \ No newline at end of file diff --git a/services/projectr/rfuncs.py b/services/projectr/rfuncs.py index 6346c922..c7e15251 100644 --- a/services/projectr/rfuncs.py +++ b/services/projectr/rfuncs.py @@ -16,6 +16,10 @@ from rpy2.robjects.conversion import localconverter from rpy2.robjects.vectors import StrVector +# If running locally, need to ensure that multiple concurrent R calls do not conflict +from rpy2.rinterface_lib import openrlib + + class RError(Exception): """Error based on issues that would manifest in any particular R-language call.""" def __init__(self, message="") -> None: @@ -45,51 +49,54 @@ def run_projectR_cmd(target_df, loading_df, algorithm): Return Pandas dataframe of the projectR output """ - # Convert from pandas dataframe to R data.frame - with localconverter(ro.default_converter + pandas2ri.converter): - target_r_df = ro.conversion.py2rpy(target_df) - loading_r_df = ro.conversion.py2rpy(loading_df) - - # data.frame to matrix (projectR has no data.frame signature) - target_r_matrix = convert_r_df_to_r_matrix(target_r_df) - loading_r_matrix = convert_r_df_to_r_matrix(loading_r_df) - - # Assign Rownames to each matrix - # I don't know why but using ro.StrVector makes rpy2py fail where the output df is an incompatible class - # Guessing that there are some non-strings mixed into the indexes - target_r_matrix.rownames = StrVector(target_df.index) - loading_r_matrix.rownames = StrVector(loading_df.index) - - # The NMF projectR method signature is based on the LinearEmbeddedMatrix class, - # Which has a featureLoadings property. That matrix is loaded and the default - # projectR signature is returned and used. So we can just pass the matrix as-is. - # https://rdrr.io/bioc/SingleCellExperiment/man/LinearEmbeddingMatrix.html - - # Run project R command. Get projectionPatterns matrix - try: - if algorithm == "nmf": - projectR = importr('projectR') - projection_patterns_r_matrix = projectR.projectR(data=target_r_matrix, loadings=loading_r_matrix, full=False) - elif algorithm == "fixednmf": - sjd = importr('SJD') - loading_list = ro.ListVector({"genesig": loading_r_matrix}) - - projection = sjd.projectNMF(proj_dataset=target_r_matrix, proj_group=True, list_component=loading_list) - projection_patterns_r_matrix = projection.rx2("proj_score_list").rx2("genesig") - else: - raise ValueError("Algorithm {} is not supported".format(algorithm)) - except Exception as e: - # print stacktrace with line numbers - traceback.print_exc(file=sys.stderr) - raise RError("Error: Could not run projectR command.\tReason: {}".format(str(e))) - - # matrix back to data.frame - projection_patterns_r_df = convert_r_matrix_to_r_df(projection_patterns_r_matrix) - - # Convert from R data.frame to pandas dataframe - with localconverter(ro.default_converter + pandas2ri.converter): - projection_patterns_df = ro.conversion.rpy2py(projection_patterns_r_df) - - return projection_patterns_df + # Ensure multithreading if running locally -> https://rpy2.github.io/doc/v3.5.x/html/rinterface.html#multithreading + with openrlib.rlock: + + # Convert from pandas dataframe to R data.frame + with localconverter(ro.default_converter + pandas2ri.converter): + target_r_df = ro.conversion.py2rpy(target_df) + loading_r_df = ro.conversion.py2rpy(loading_df) + + # data.frame to matrix (projectR has no data.frame signature) + target_r_matrix = convert_r_df_to_r_matrix(target_r_df) + loading_r_matrix = convert_r_df_to_r_matrix(loading_r_df) + + # Assign Rownames to each matrix + # I don't know why but using ro.StrVector makes rpy2py fail where the output df is an incompatible class + # Guessing that there are some non-strings mixed into the indexes + target_r_matrix.rownames = StrVector(target_df.index) + loading_r_matrix.rownames = StrVector(loading_df.index) + + # The NMF projectR method signature is based on the LinearEmbeddedMatrix class, + # Which has a featureLoadings property. That matrix is loaded and the default + # projectR signature is returned and used. So we can just pass the matrix as-is. + # https://rdrr.io/bioc/SingleCellExperiment/man/LinearEmbeddingMatrix.html + + # Run project R command. Get projectionPatterns matrix + try: + if algorithm == "nmf": + projectR = importr('projectR') + projection_patterns_r_matrix = projectR.projectR(data=target_r_matrix, loadings=loading_r_matrix, full=False) + elif algorithm == "fixednmf": + sjd = importr('SJD') + loading_list = ro.ListVector({"genesig": loading_r_matrix}) + + projection = sjd.projectNMF(proj_dataset=target_r_matrix, proj_group=True, list_component=loading_list) + projection_patterns_r_matrix = projection.rx2("proj_score_list").rx2("genesig") + else: + raise ValueError("Algorithm {} is not supported".format(algorithm)) + except Exception as e: + # print stacktrace with line numbers + traceback.print_exc(file=sys.stderr) + raise RError("Error: Could not run projectR command.\tReason: {}".format(str(e))) + + # matrix back to data.frame + projection_patterns_r_df = convert_r_matrix_to_r_df(projection_patterns_r_matrix) + + # Convert from R data.frame to pandas dataframe + with localconverter(ro.default_converter + pandas2ri.converter): + projection_patterns_df = ro.conversion.rpy2py(projection_patterns_r_df) + + return projection_patterns_df diff --git a/www/api/resources/aggregations.py b/www/api/resources/aggregations.py index 519f0517..ab9d66b8 100644 --- a/www/api/resources/aggregations.py +++ b/www/api/resources/aggregations.py @@ -50,6 +50,7 @@ def post(self, dataset_id): ana = geardb.Analysis(id=analysis_id, dataset_id=dataset_id, session_id=session_id, user_id=user.id) ana.discover_type() + adata = ana.get_adata() else: # Dataset is primary type diff --git a/www/api/resources/plotly_data.py b/www/api/resources/plotly_data.py index 14c4cbde..f90c1735 100644 --- a/www/api/resources/plotly_data.py +++ b/www/api/resources/plotly_data.py @@ -232,6 +232,10 @@ def post(self, dataset_id): df = selected.to_df() df = pd.concat([df,selected.obs], axis=1) + # fill any missing adata.obs values with "NA" + # The below line gives the error - TypeError: Cannot setitem on a Categorical with a new category (NA), set the categories first + #df = df.fillna("NA") + # Valid analysis column names from api/resources/h5ad.py analysis_tsne_columns = ['X_tsne_1', 'X_tsne_2'] analysis_umap_columns = ['X_umap_1', 'X_umap_2'] @@ -268,6 +272,14 @@ def post(self, dataset_id): message = "WARNING: Color map has values not in the dataframe column '{}': {}\n".format(color_name, diff) message += "Will set color map key values to the unique values in the dataframe column." print(message, file=sys.stderr) + # If any element in diff is nan and color_map contains a valid missing value key like "NA", change the value in the dataframe to match the color_map key + for key in list(diff): + if pd.isna(key) and "NA" in color_map.keys(): + df[color_name] = df[color_name].replace({key: "NA"}) + col_values.remove(key) # Remove the nan value from the set + col_values = col_values.union({"NA"}) + break + # Sort both the colormap and dataframe column alphabetically sorted_column_values = sorted(col_values) updated_color_map = {} diff --git a/www/api/resources/projectr.py b/www/api/resources/projectr.py index 6063d935..5d3c2b9a 100644 --- a/www/api/resources/projectr.py +++ b/www/api/resources/projectr.py @@ -321,8 +321,8 @@ def projectr_callback(dataset_id, genecart_id, projection_id, session_id, scope, # If dataset genes have duplicated index names, we need to rename them to avoid errors # in collecting rownames in projectR (which gives invalid output) # This means these duplicated genes will not be in the intersection of the dataset and pattern genes + dedup_copy = Path(ana.dataset_path().replace('.h5ad', '.dups_removed.h5ad')) if (adata.var.index.duplicated(keep="first") == True).any(): - dedup_copy = Path(ana.dataset_path().replace('.h5ad', '.dups_removed.h5ad')) if dedup_copy.exists(): dedup_copy.unlink() adata = adata[:, adata.var.index.duplicated(keep="first") == False].copy(filename=dedup_copy) @@ -497,6 +497,9 @@ def projectr_callback(dataset_id, genecart_id, projection_id, session_id, scope, , "num_dataset_genes": num_target_genes } + adata.close() + if dedup_copy.exists(): + dedup_copy.unlink() # Have had cases where the column names are x1, x2, x3, etc. so load in the original pattern names projection_patterns_df = projection_patterns_df.set_axis(loading_df.columns, axis="columns") diff --git a/www/api/resources/tsne_data.py b/www/api/resources/tsne_data.py index 5dba0344..aa2e5d25 100644 --- a/www/api/resources/tsne_data.py +++ b/www/api/resources/tsne_data.py @@ -306,13 +306,6 @@ def post(self, dataset_id): if flip_y: adata.obsm[key][:,1] = -1 * adata.obsm[key][:,1] - # We also need to change the adata's Raw var dataframe - # We can't explicitly reset its index so we reinitialize it with - # the newer adata object. - # https://github.com/theislab/anndata/blob/master/anndata/base.py#L1020-L1022 - if adata.raw is not None: - adata.raw = adata - # Reorder the categorical values in the observation dataframe # Currently in UI only "plot_by_group" has reordering capabilities if order: @@ -363,11 +356,11 @@ def post(self, dataset_id): # Rename to end the confusion selected.var = selected.var.rename(columns={selected.var.columns[0]: "ensembl_id"}) # Modify the AnnData object to not include any duplicated gene symbols (keep only first entry) + dedup_copy = ana.dataset_path().replace('.h5ad', '.dups_removed.h5ad') if (selected.var.index.duplicated(keep="first") == True).any(): success = 2 message = "WARNING: Multiple Ensemble IDs found for gene symbol '{}'. Using the first stored Ensembl ID.".format(selected_gene) - dedup_copy = ana.dataset_path().replace('.h5ad', '.dups_removed.h5ad') if os.path.exists(dedup_copy): os.remove(dedup_copy) selected = selected[:, selected.var.index.duplicated() == False].copy(filename=dedup_copy) @@ -564,6 +557,9 @@ def post(self, dataset_id): if selected.isbacked: selected.file.close() + if os.path.exists(dedup_copy): + os.remove(dedup_copy) + with io.BytesIO() as io_pic: # Set the saved figure dpi based on the number of observations in the dataset after filtering if high_dpi: diff --git a/www/cgi/get_analysis_image.cgi b/www/cgi/get_analysis_image.cgi index 62d54559..557fd06f 100755 --- a/www/cgi/get_analysis_image.cgi +++ b/www/cgi/get_analysis_image.cgi @@ -41,11 +41,18 @@ def main(): if not image_path.startswith(ana_directory): raise Exception("Invalid filename: {}".format(image_path)) - with open(image_path, 'rb') as f: - print("Content-Type: image/png\n") - sys.stdout.flush() # <--- - sys.stdout.buffer.write(f.read()) - + try: + with open(image_path, 'rb') as f: + print("Content-Type: image/png\n") + sys.stdout.flush() # <--- + sys.stdout.buffer.write(f.read()) + except FileNotFoundError as e: + print(str(e), file=sys.stderr) + # ensure a 404 response + print("Status: 404 Not Found\n") + print("Content-Type: text/plain\n") + print("File not found: {0}".format(image_path)) + print("Error: {0}".format(e)) if __name__ == '__main__': main() diff --git a/www/cgi/get_stored_analysis.cgi b/www/cgi/get_stored_analysis.cgi index 10774d56..c2beca36 100755 --- a/www/cgi/get_stored_analysis.cgi +++ b/www/cgi/get_stored_analysis.cgi @@ -29,8 +29,15 @@ def main(): user_id=user.id, session_id=session_id, type=analysis_type) - print('Content-Type: application/json\n\n') - print(ana) + # try to read the analysis object and raise exception if FileNotFoundError + + try: + print('Content-Type: application/json\n\n') + print(ana) + except FileNotFoundError: + print('Content-Type: application/json\n\n') + print('{"error": "Analysis config file not found"}') + return if __name__ == '__main__': main() diff --git a/www/cgi/get_stored_analysis_list.cgi b/www/cgi/get_stored_analysis_list.cgi index 368771df..66bfad16 100755 --- a/www/cgi/get_stored_analysis_list.cgi +++ b/www/cgi/get_stored_analysis_list.cgi @@ -35,9 +35,10 @@ def main(): result['user_unsaved'] = acollection.user_unsaved ## get the vetting for each - for atype in result: - for ana in result[atype]: - ana.discover_vetting(current_user_id=user.id) + if user_id: + for atype in result: + for ana in result[atype]: + ana.discover_vetting(current_user_id=user_id) print('Content-Type: application/json\n\n') print(json.dumps(result)) diff --git a/www/cgi/get_user_layouts.cgi b/www/cgi/get_user_layouts.cgi index 113b1fa6..3c26a31d 100755 --- a/www/cgi/get_user_layouts.cgi +++ b/www/cgi/get_user_layouts.cgi @@ -105,6 +105,14 @@ def main(): # NOTE: "null" values can happen but will be queried out in the SQL (unless there is a actual "null" share_id) if layout_share_id: result['shared_layouts'] = geardb.LayoutCollection(include_datasets=bool_include_members).get_by_share_id(layout_share_id) + # remove shared layouts that are also in the user, domain, or group layouts. + for l in result['shared_layouts']: + for ltype in ['user', 'domain', 'group', 'public']: + for l2 in result[ltype + '_layouts']: + if l.share_id == l2.share_id: + result['shared_layouts'].remove(l) + break + ## Selected priority: ## - A passed share ID diff --git a/www/cgi/process_uploaded_expression_dataset.cgi b/www/cgi/process_uploaded_expression_dataset.cgi index dcbaaaad..93257a71 100755 --- a/www/cgi/process_uploaded_expression_dataset.cgi +++ b/www/cgi/process_uploaded_expression_dataset.cgi @@ -90,24 +90,27 @@ def main(): # This is the fork off of apache # https://stackoverflow.com/a/22181041/1368079 # https://stackoverflow.com/questions/6024472/start-background-process-daemon-from-cgi-script - sys.stdout = original_stdout - result['success'] = 1 - print(json.dumps(result)) - - sys.stdout.flush() - os.close(sys.stdout.fileno()) # Break web pipe - sys.stderr.flush() - os.close(sys.stderr.fileno()) # Break web pipe + # https://groups.google.com/g/comp.lang.python/c/gSRnd0RoVKY?pli=1 + do_fork = True + if do_fork: + sys.stdout = original_stdout + result['success'] = 1 + print(json.dumps(result)) + + sys.stdout.flush() + sys.stdout.close() + + sys.stderr.flush() + sys.stderr.close() - if os.fork(): # Get out of parent process - sys.exit(0) + f = os.fork() - # open a log file in /tmp - f_out = open('/home/jorvis/logs/apache.stdout.log', 'w') - f_err = open('/home/jorvis/logs/apache.stderr.log', 'w') + if f != 0: + # Terminate the parent + sys.exit(0) + # PARENT DEAD - time.sleep(1) # Be sure the parent process reach exit command. - os.setsid() # Become process group leader + # CHILD CONTINUES FROM HERE status['process_id'] = os.getpid() diff --git a/www/js/classes/analysis-ui.js b/www/js/classes/analysis-ui.js index 37277a09..c6ffa339 100644 --- a/www/js/classes/analysis-ui.js +++ b/www/js/classes/analysis-ui.js @@ -51,7 +51,7 @@ class AnalysisUI { // Initial info primaryInitialInfoSection = "#initial-info-s" primaryInitialPlotContainer = "#initial-plot-c" - primaryInitialLoadingPlotElt = "#initial-loading-plot" + primaryInitialLoadingElt = "#initial-loading-c" primaryInitialScatterContainer = "#initial-scatter-c" primaryInitialViolinContainer = "#initial-violin-c" selectedDatasetShapeInitialElt = "#selected-dataset-shape-initial" diff --git a/www/js/classes/analysis.js b/www/js/classes/analysis.js index ecfc00c3..884a6acd 100644 --- a/www/js/classes/analysis.js +++ b/www/js/classes/analysis.js @@ -18,12 +18,12 @@ class Analysis { label = `Unlabeled ${commonDateTime()}`, type, vetting, - userSessionId = CURRENT_USER.session_id, + analysisSessionId = CURRENT_USER.session_id, genesOfInterest = [], groupLabels = [] } = {}) { this.id = id; - this.userSessionId = userSessionId; + this.analysisSessionId = analysisSessionId; this.dataset = datasetObj; // The dataset object this.type = type; this.vetting = vetting; @@ -99,7 +99,7 @@ class Analysis { */ async copyDatasetAnalysis(destType) { const params = { - session_id: this.userSessionId, + session_id: this.analysisSessionId, dataset_id: this.dataset.id, source_analysis_id: this.id, dest_analysis_id: this.id, @@ -131,7 +131,7 @@ class Analysis { this.type = 'user_unsaved'; this.id = newAnalysisId; - this.userSessionId = CURRENT_USER.session_id; + this.analysisSessionId = CURRENT_USER.session_id; document.querySelector(UI.analysisActionContainer).classList.remove("is-hidden"); document.querySelector(UI.analysisStatusInfoContainer).classList.add("is-hidden"); @@ -155,7 +155,7 @@ class Analysis { try { const {data} = await axios.post("./cgi/delete_dataset_analysis.cgi", convertToFormData({ - session_id: this.userSessionId, + session_id: this.analysisSessionId, dataset_id: this.dataset.id, analysis_id: this.id, analysis_type: this.type @@ -181,7 +181,7 @@ class Analysis { // create URL parameters const params = { - session_id: this.userSessionId, + session_id: this.analysisSessionId, dataset_id: this.dataset.id, analysis_id: this.id, type: "h5ad", @@ -202,49 +202,6 @@ class Analysis { logErrorInConsole(error); createToast(`Error downloading analysis h5ad`); } - - } - - /** - * Retrieves the stored analysis data from the server. - * @returns {Promise} A promise that resolves when the analysis data is retrieved. - */ - async getStoredAnalysis() { - - // Some dataset info (like organism ID) may be lost when loading an analysis from JSON - const datasetObj = this.dataset; - - try { - const {data} = await axios.post("./cgi/get_stored_analysis.cgi", convertToFormData({ - analysis_id: this.id, - analysis_type: this.type, - session_id: this.userSessionId, - dataset_id: this.dataset.id - })); - - // Load the analysis data and assign it to the current instance - const ana = Analysis.loadFromJson(data); - Object.assign(this, ana); - - // If tSNE was calculate, show the labeled tSNE section - // Mainly for primary analyses - // ? verify claim - document.querySelector(UI.labeledTsneSection).classList.add("is-hidden"); - if (this.type === "primary" && data['tsne']['tsne_calculated']) { - document.querySelector(UI.labeledTsneSection).classList.remove("is-hidden"); - - // Initialize the labeled tSNE step - this.labeledTsne = new AnalysisStepLabeledTsne(this); - } - - } catch (error) { - logErrorInConsole(`Failed ID was: ${datasetId} because msg: ${error}`); - createToast(`Error getting stored analysis`); - } - - // Restore the dataset object - this.dataset = datasetObj; - } /** @@ -260,7 +217,7 @@ class Analysis { try { const {data} = await axios.post("./cgi/get_stored_analysis_list.cgi", convertToFormData({ dataset_id: datasetId, - session_id: this.userSessionId + session_id: this.analysisSessionId })); // Create an empty option for the analysis select element @@ -282,6 +239,7 @@ class Analysis { const option = document.createElement("option"); option.dataset.analysisId = analysis.id; option.dataset.analysisType = analysis.type; + option.dataset.analysisSessionId = analysis.session_id || CURRENT_USER.session_id; option.dataset.datasetId = analysis.dataset_id; option.textContent = analysis.label || "Unlabeled" // ? Using standard HTML, cannot add icons to options, so making icons by vetting status is not possible @@ -321,6 +279,15 @@ class Analysis { // unsaved if (data.user_unsaved.length) { + // if data has no label, it will be "Unlabeled" + const count = 1 + data.user_unsaved.forEach(analysis => { + if (!analysis.label) { + analysis.label = `Unlabeled ${count}`; + count++; + } + }); + // sort by label data.user_unsaved.sort((a, b) => a.label.localeCompare(b.label)); @@ -334,6 +301,15 @@ class Analysis { // saved if (data.user_saved.length) { + // if data has no label, it will be "Unlabeled" + const count = 1 + data.user_saved.forEach(analysis => { + if (!analysis.label) { + analysis.label = `Unlabeled ${count}`; + count++; + } + }); + // sort by label data.user_saved.sort((a, b) => a.label.localeCompare(b.label)); @@ -347,6 +323,15 @@ class Analysis { // public if (data.public.length) { + // if data has no label, it will be "Unlabeled" + const count = 1 + data.public.forEach(analysis => { + if (!analysis.label) { + analysis.label = `Unlabeled ${count}`; + count++; + } + }); + // sort by label data.public.sort((a, b) => a.label.localeCompare(b.label)); @@ -368,23 +353,71 @@ class Analysis { createToast(`Error getting saved analyses: ${error}`); logErrorInConsole(`Failed ID was: ${datasetId} because msg: ${error}`); } + } + + + /** + * Retrieves the stored analysis data from the server. + * @returns {Promise} A promise that resolves when the analysis data is retrieved. + */ + async getStoredAnalysis() { + + // Some dataset info (like organism ID) may be lost when loading an analysis from JSON + const datasetObj = this.dataset; + + try { + const {data} = await axios.post("./cgi/get_stored_analysis.cgi", convertToFormData({ + analysis_id: this.id, + analysis_type: this.type, + session_id: this.analysisSessionId, + dataset_id: this.dataset.id + })); + + if (data.error) { + throw new Error(data.error); + } + + // Load the analysis data and assign it to the current instance + const ana = await Analysis.loadFromJson(data, datasetObj); + Object.assign(this, ana); + + // If tSNE was calculate, show the labeled tSNE section + // Mainly for primary analyses + // ? verify claim + document.querySelector(UI.labeledTsneSection).classList.add("is-hidden"); + if (this.type === "primary" && data['tsne']['tsne_calculated']) { + document.querySelector(UI.labeledTsneSection).classList.remove("is-hidden"); + + // Initialize the labeled tSNE step + this.labeledTsne = new AnalysisStepLabeledTsne(this); + } + + } catch (error) { + logErrorInConsole(`Failed ID was: ${datasetId} because msg: ${error}`); + createToast(`Error retrieving stored analysis`); + } + + // Restore the dataset object + this.dataset = datasetObj; } /** * Loads an Analysis object from JSON data. * - * @param {Object} data - The JSON data representing the Analysis object. - * @returns {Analysis} The loaded Analysis object. + * @param {Object} data - The JSON data representing the Analysis. + * @param {Object} datasetObj - The dataset object associated with the Analysis. + * @returns {Promise} - The loaded Analysis object. */ - static async loadFromJson(data) { + static async loadFromJson(data, datasetObj) { + const analysis = new Analysis({ id: data.id, - datasetObj: data.dataset, + datasetObj, datasetIsRaw: data.dataset_is_raw, label: data.label, type: data.type, - userSessionId: data.user_session_id || CURRENT_USER.session_id, + analysisSessionId: data.session_id || CURRENT_USER.session_id, groupLabels: data.group_labels, genesOfInterest: data.genesOfInterest }); @@ -443,6 +476,7 @@ class Analysis { // Support legacy data. const clusteringEditData = data.clustering_edit || data.clustering + clusteringEditData.mode = "edit"; analysis.clusteringEdit = AnalysisStepClustering.loadFromJson(clusteringEditData, analysis); @@ -464,18 +498,16 @@ class Analysis { * @returns {Promise} A promise that resolves when the preliminary figures are loaded. */ async loadPreliminaryFigures() { + document.querySelector(UI.primaryInitialPlotContainer).classList.add("is-hidden"); - document.querySelector(UI.primaryInitialPlotContainer).classList.remove("is-hidden"); try { const {data} = await axios.post("./cgi/h5ad_preview_primary_filter.cgi", convertToFormData({ dataset_id: this.dataset.id, analysis_id: this.id, analysis_type: this.type, - session_id: this.userSessionId + session_id: this.analysisSessionId })); - document.querySelector(UI.primaryInitialLoadingPlotElt).classList.add("is-hidden"); - if (!data.success || data.success < 1) { document.querySelector(UI.primaryInitialViolinContainer).textContent = "Preliminary figures not yet generated. Continue your analysis."; createToast("Preliminary figures not found. You can still continue the analysis though.", "is-warning"); @@ -484,6 +516,7 @@ class Analysis { document.querySelector(UI.primaryInitialViolinContainer).innerHTML = ``; document.querySelector(UI.primaryInitialScatterContainer).innerHTML = ``; createToast("Preliminary plots displayed", "is-success"); + document.querySelector(UI.primaryInitialPlotContainer).classList.remove("is-hidden"); } catch (error) { createToast("Failed to access dataset"); @@ -540,17 +573,18 @@ class Analysis { */ async placeAnalysisImage({params, title, target} = {}) { const url = "./cgi/get_analysis_image.cgi"; - const response = await axios.get(url, { params }); - if (response.status === 200) { - const imgSrc = response.request.responseURL; - const html = `${title}`; - document.querySelector(target).innerHTML = html; - return; + try { + const response = await axios.get(url, { params }); + if (response?.status === 200) { + const imgSrc = response.request.responseURL; + const html = `${title}`; + document.querySelector(target).innerHTML = html; + } + } catch (error) { + console.error(`Error: ${error.response?.status}`); + createToast(`Error retrieving analysis image for at least one completed step. You can re-run those steps to generate images again.`, "is-warning"); } - - console.error(`Error: ${response.status}`); - createToast(`Error getting analysis image`); } /** @@ -604,7 +638,7 @@ class Analysis { async save() { - if (!this.userSessionId) { + if (!this.analysisSessionId) { console.warn("Cannot save analysis without a user session ID"); return; } @@ -638,6 +672,7 @@ class Analysis { // Some legacy things to change around state.dataset_id = state.dataset.id; + delete state.dataset; // redundant with other saved things. We can retrieve dataset info when loading if (state.qc_by_mito) { state.qc_by_mito.filter_mito_perc = state.qc_by_mito.filter_mito_percent; delete state.qc_by_mito.filter_mito_percent; @@ -645,7 +680,7 @@ class Analysis { try { const {data} = await axios.post("./cgi/save_dataset_analysis.cgi", convertToFormData({ - session_id: this.userSessionId, + session_id: this.analysisSessionId, dataset_id: this.dataset.id, analysis_id: this.id, analysis_type: this.type, @@ -690,7 +725,7 @@ class Analysis { this.type = 'user_saved'; document.querySelector(UI.btnSaveAnalysisElt).textContent = "Saved"; document.querySelector(UI.analysisActionContainer).classList.add("is-hidden"); - createToast("This analysis is stored in your profile.", "is-info", true); + createToast("This analysis has been saved in your private user profile.", "is-info", true); document.querySelector(UI.analysisStatusInfoContainer).classList.remove("is-hidden"); document.querySelector(UI.btnDeleteSavedAnalysisElt).classList.remove("is-hidden"); document.querySelector(UI.btnMakePublicCopyElt).classList.remove("is-hidden"); @@ -822,7 +857,7 @@ class AnalysisStepPrimaryFilter { analysis_id: this.analysis.id, analysis_type: this.analysis.type, dataset_id: this.analysis.dataset.id, - session_id: this.analysis.userSessionId, + session_id: this.analysis.analysisSessionId, filter_cells_lt_n_genes: this.filterCellsLtNGenes || "", filter_cells_gt_n_genes: this.filterCellsGtNGenes || "", filter_genes_lt_n_cells: this.filterGenesLtNCells || "", @@ -992,7 +1027,7 @@ class AnalysisStepPrimaryFilter { 'analysis_name': 'highest_expr_genes', 'analysis_type': ana.type, 'dataset_id': ana.dataset.id, - 'session_id': ana.userSessionId, + 'session_id': ana.analysisSessionId, // this saves the user from getting a cached image each time datetime: (new Date()).getTime() } @@ -1114,7 +1149,7 @@ class AnalysisStepQCByMito { dataset_id: this.analysis.dataset.id, analysis_id: this.analysis.id, analysis_type: this.analysis.type, - session_id: this.analysis.userSessionId, + session_id: this.analysis.analysisSessionId, genes_prefix: this.genePrefix, filter_mito_perc: this.filterMitoPercent, filter_mito_count: this.filterMitoCount, @@ -1180,7 +1215,7 @@ class AnalysisStepQCByMito { 'analysis_name': 'violin_qc_by_mito', 'analysis_type': ana.type, 'dataset_id': ana.dataset.id, - 'session_id': ana.userSessionId, + 'session_id': ana.analysisSessionId, // this saves the user from getting a cached image each time 'datetime': (new Date()).getTime() } @@ -1317,7 +1352,7 @@ class AnalysisStepSelectVariableGenes { 'dataset_id': this.analysis.dataset.id, 'analysis_id': this.analysis.id, 'analysis_type': this.analysis.type, - 'session_id': this.analysis.userSessionId, + 'session_id': this.analysis.analysisSessionId, 'norm_counts_per_cell': this.normCountsPerCell, 'flavor': this.flavor, 'n_top_genes': this.nTopGenes, @@ -1410,7 +1445,7 @@ class AnalysisStepSelectVariableGenes { 'analysis_name': 'filter_genes_dispersion', 'analysis_type': ana.type, 'dataset_id': ana.dataset.id, - 'session_id': ana.userSessionId, + 'session_id': ana.analysisSessionId, // this saves the user from getting a cached image each time 'datetime': (new Date()).getTime() @@ -1513,7 +1548,7 @@ class AnalysisStepPCA { dataset_id: this.analysis.dataset.id, analysis_id: this.analysis.id, analysis_type: this.analysis.type, - session_id: this.analysis.userSessionId, + session_id: this.analysis.analysisSessionId, genes_to_color: document.querySelector(UI.pcaGenesToColorElt).value, compute_pca: computePCA })); @@ -1566,7 +1601,7 @@ class AnalysisStepPCA { dataset_id: this.analysis.dataset.id, analysis_id: this.analysis.id, analysis_type: this.analysis.type, - session_id: this.analysis.userSessionId, + session_id: this.analysis.analysisSessionId, pcs: document.querySelector(UI.topPcaGenesElt).value })); @@ -1580,7 +1615,7 @@ class AnalysisStepPCA { 'analysis_name': 'pca_loadings', 'analysis_type': this.analysis.type, 'dataset_id': this.analysis.dataset.id, - 'session_id': this.analysis.userSessionId, + 'session_id': this.analysis.analysisSessionId, datetime: (new Date()).getTime() } @@ -1621,7 +1656,7 @@ class AnalysisStepPCA { 'analysis_name': 'pca', 'analysis_type': ana.type, 'dataset_id': ana.dataset.id, - 'session_id': ana.userSessionId, + 'session_id': ana.analysisSessionId, // this saves the user from getting a cached image each time 'datetime': (new Date()).getTime() } @@ -1784,7 +1819,7 @@ class AnalysisSteptSNE { 'dataset_id': this.analysis.dataset.id, 'analysis_id': this.analysis.id, 'analysis_type': this.analysis.type, - 'session_id': this.analysis.userSessionId, + 'session_id': this.analysis.analysisSessionId, 'genes_to_color': document.querySelector(UI.tsneGenesToColorElt).value, 'n_pcs': document.querySelector(UI.tsneNPcsElt).value, 'n_neighbors': document.querySelector(UI.dimReductionNNeighborsElt).value, @@ -1811,6 +1846,8 @@ class AnalysisSteptSNE { this.tsneCalculated = Boolean(computeTsne); this.umapCalculated = Boolean(computeUmap); this.genesToColor = document.querySelector(UI.tsneGenesToColorElt).value; + this.nNeighbors = document.querySelector(UI.dimReductionNNeighborsElt).value; + this.nPcs = document.querySelector(UI.tsneNPcsElt).value; this.plotTsne = plotTsne; this.plotUmap = plotUmap; this.updateUIWithResults(); @@ -1874,7 +1911,7 @@ class AnalysisSteptSNE { 'analysis_name': 'tsne', 'analysis_type': ana.type, 'dataset_id': ana.dataset.id, - 'session_id': ana.userSessionId, + 'session_id': ana.analysisSessionId, // this saves the user from getting a cached image each time 'datetime': (new Date()).getTime() } @@ -2043,7 +2080,7 @@ class AnalysisStepClustering { dataset_id: this.analysis.dataset.id, analysis_id: this.analysis.id, analysis_type: this.analysis.type, - session_id: this.analysis.userSessionId, + session_id: this.analysis.analysisSessionId, resolution, compute_clusters: computeClustering, plot_tsne: plotTsne, @@ -2125,7 +2162,7 @@ class AnalysisStepClustering { 'analysis_name': 'tsne_clustering', 'analysis_type': ana.type, 'dataset_id': ana.dataset.id, - 'session_id': ana.userSessionId, + 'session_id': ana.analysisSessionId, // this saves the user from getting a cached image each time 'datetime': (new Date()).getTime() } @@ -2139,14 +2176,29 @@ class AnalysisStepClustering { } if (Boolean(this.plotTsne)) { - ana.placeAnalysisImage( - {'params': params, 'title': 'Cluster groups', 'target': tsneTarget}); + try { + ana.placeAnalysisImage( + {'params': params, 'title': 'tSNE clustering', 'target': tsneTarget}); + } catch (error) { + // legacy + params['analysis_name'] = 'tsne_louvain' + ana.placeAnalysisImage( + {'params': params, 'title': 'tSNE clustering', 'target': tsneTarget}); + } } if (Boolean(this.plotUmap)) { params['analysis_name'] = 'umap_clustering' - ana.placeAnalysisImage( - {'params': params, 'title': 'Cluster groups', 'target': umapTarget}); + try { + ana.placeAnalysisImage( + {'params': params, 'title': 'Cluster groups', 'target': umapTarget}); + } catch (error) { + // legacy + params['analysis_name'] = 'umap_louvain' + ana.placeAnalysisImage( + {'params': params, 'title': 'Cluster groups', 'target': umapTarget}); + } + } if (this.mode === "initial") { @@ -2232,7 +2284,7 @@ class AnalysisStepMarkerGenes { 'dataset_id': this.analysis.dataset.id, 'analysis_id': this.analysis.id, 'analysis_type': this.analysis.type, - 'session_id': this.analysis.userSessionId, + 'session_id': this.analysis.analysisSessionId, 'n_genes': this.nGenes, 'compute_marker_genes': this.computeMarkerGenes })); @@ -2277,7 +2329,7 @@ class AnalysisStepMarkerGenes { 'dataset_id': this.analysis.dataset.id, 'analysis_id': this.analysis.id, 'analysis_type': this.analysis.type, - 'session_id': this.analysis.userSessionId, + 'session_id': this.analysis.analysisSessionId, 'marker_genes': JSON.stringify([...this.genesOfInterest]) })); @@ -2291,7 +2343,7 @@ class AnalysisStepMarkerGenes { 'analysis_name': 'dotplot_goi', 'analysis_type': this.analysis.type, 'dataset_id': this.analysis.dataset.id, - 'session_id': this.analysis.userSessionId, + 'session_id': this.analysis.analysisSessionId, // this saves the user from getting a cached image each time datetime: (new Date()).getTime() } @@ -2523,7 +2575,7 @@ class AnalysisStepMarkerGenes { 'analysis_name': `rank_genes_groups_${data.cluster_label}`, 'analysis_type': ana.type, 'dataset_id': ana.dataset.id, - 'session_id': ana.userSessionId, + 'session_id': ana.analysisSessionId, // this saves the user from getting a cached image each time 'datetime': (new Date()).getTime() } @@ -2739,7 +2791,7 @@ class AnalysisStepCompareGenes { 'dataset_id': this.analysis.dataset.id, 'analysis_id': this.analysis.id, 'analysis_type': this.analysis.type, - 'session_id': this.analysis.userSessionId, + 'session_id': this.analysis.analysisSessionId, 'n_genes': document.querySelector(UI.compareGenesNGenesElt).value, 'group_labels': JSON.stringify(this.analysis.groupLabels), 'query_cluster': document.querySelector(UI.queryClusterSelectElt).value, @@ -2811,7 +2863,7 @@ class AnalysisStepCompareGenes { 'analysis_name': `rank_genes_groups_${data.cluster_label}_comp_ranked`, 'analysis_type': ana.type, 'dataset_id': ana.dataset.id, - 'session_id': ana.userSessionId, + 'session_id': ana.analysisSessionId, // this saves the user from getting a cached image each time 'datetime': (new Date()).getTime() } diff --git a/www/js/curator_common.js b/www/js/curator_common.js index edcc2294..37c1f38f 100644 --- a/www/js/curator_common.js +++ b/www/js/curator_common.js @@ -489,14 +489,18 @@ const analysisSelectUpdate = async () => { * @returns {Promise} - A promise that resolves when the analysis is chosen and the UI is updated. */ const chooseAnalysis = async () => { - const analysisValue = analysisSelect.selectedOptions.length ? getSelect2Value(analysisSelect) : undefined; - const analysisId = (analysisValue && analysisValue > 0) ? analysisValue : null; - const analysisText = (analysisId?.length) ? analysisId : "Primary Analysis"; + const analysisId = getAnalysisId() + const analysisText = analysisSelect.selectedOptions.length ? analysisSelect.selectedOptions[0].data.text : "Primary Analysis"; // Display current selected analysis document.getElementById("current-analysis").textContent = analysisText; document.getElementById("current-analysis-post").textContent = analysisText; + // User may have chosen a new analysis with plot options + document.getElementById("plot-type-select").disabled = false; + + // Clear plot type options so user is forced to choose a new plot type instead of using a potentially incompatible one + plotTypeSelect.clear(); // NOTE: For now, we can just pass analysis id only to tSNE and be fine // Any private dataset will belong to our user. Any public datasets can be found by the API "get_analysis" code. @@ -511,9 +515,6 @@ const chooseAnalysis = async () => { plotTypeSelectUpdate(analysisId) , updateDatasetGenes(analysisId) ]); - - // Create facet widget - facetWidget = await createFacetWidget(datasetId, analysisId, {}); } } @@ -585,7 +586,9 @@ const choosePlotType = async () => { document.getElementById("current-plot-type").textContent = plotType; // Create facet widget, which will refresh filters - facetWidget = await createFacetWidget(datasetId, null, {}); + + const analysisId = getAnalysisId(); + facetWidget = await createFacetWidget(datasetId, analysisId, {}); document.getElementById("facet-content").classList.remove("is-hidden"); document.getElementById("selected-facets").classList.remove("is-hidden"); @@ -645,14 +648,15 @@ const cloneDisplay = async (event, display, scope="owner") => { // NOTE: Analysis will not be chosen if the user cannot access it (i.e. owner curation, private analysis) } - // plot types + // Set up plot types // Read clone config to populate analysis, plot type, gnee and plot-specific options let plotType = display.plot_type; plotType = curatorSpecificPlotTypeAdjustments(plotType); + // TODO: unify with plotTypeSelectUpdate code try { - const availablePlotTypes = await curatorApiCallsMixin.fetchAvailablePlotTypes(datasetId, undefined, isMultigene); + const availablePlotTypes = await curatorApiCallsMixin.fetchAvailablePlotTypes(datasetId, analysisObj?.id, isMultigene); for (const plotType in availablePlotTypes) { const isAllowed = availablePlotTypes[plotType]; setPlotTypeDisabledState(plotType, isAllowed); @@ -663,12 +667,12 @@ const cloneDisplay = async (event, display, scope="owner") => { await choosePlotType(); // In this step, a PlotStyle object is instantiated onto "plotStyle", and we will use that } catch (error) { - console.error(error); document.getElementById("plot-type-s-failed").classList.remove("is-hidden"); document.getElementById("plot-type-select-c-failed").classList.remove("is-hidden"); document.getElementById("plot-type-s-success").classList.add("is-hidden"); document.getElementById("plot-type-select-c-success").classList.add("is-hidden"); - + document.getElementById("plot-type-select").disabled = true; + plotTypeSelect.update(); return; } finally { cloneElt.classList.remove("is-loading"); @@ -868,6 +872,16 @@ const disableCheckboxLabel = (checkboxElt, state) => { } } +/** + * Retrieves the analysis ID from the selected options. + * + * @returns {string|null} The analysis ID, or null if no analysis is selected. + */ +const getAnalysisId = () => { + const analysisValue = analysisSelect.selectedOptions.length ? getSelect2Value(analysisSelect) : undefined; + return analysisValue || null; +} + /** * Retrieves updates and additions to the plot from the plot_display_config JS object. * @@ -1109,12 +1123,14 @@ const plotTypeSelectUpdate = async (analysisId=null) => { // set plot type to first option setSelectBoxByValue("plot-type-select", "nope"); - plotTypeSelect.update(); } catch (error) { document.getElementById("plot-type-s-failed").classList.remove("is-hidden"); document.getElementById("plot-type-select-c-failed").classList.remove("is-hidden"); document.getElementById("plot-type-s-success").classList.add("is-hidden"); - document.getElementById("plot-type-select-c-success").classList.add("is-hidden");; + document.getElementById("plot-type-select-c-success").classList.add("is-hidden"); + document.getElementById("plot-type-select").disabled = true; + } finally { + plotTypeSelect.update(); } } diff --git a/www/js/dataset_curator.js b/www/js/dataset_curator.js index 2b9df854..de465931 100644 --- a/www/js/dataset_curator.js +++ b/www/js/dataset_curator.js @@ -1100,8 +1100,7 @@ const renderColorPicker = (seriesName) => { * @returns {Promise} A promise that resolves when the options are set up. */ const setupPlotlyOptions = async () => { - const analysisValue = analysisSelect.selectedOptions.length ? getSelect2Value(analysisSelect) : undefined; - const analysisId = (analysisValue && analysisValue > 0) ? analysisValue : null; + const analysisId = getAnalysisId(); const plotType = getSelect2Value(plotTypeSelect); try { ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); @@ -1119,7 +1118,7 @@ const setupPlotlyOptions = async () => { if (!allColumns.length) { document.getElementById("plot-options-s-failed").classList.remove("is-hidden"); - createToast("No metadata columns found in dataset. Cannot create a plot. Please choose another dataset"); + createToast("No metadata columns found in dataset. Cannot create a plot. Please choose another analysis or choose another dataset."); return; } @@ -1358,8 +1357,7 @@ const setupPlotlyOptions = async () => { * @returns {Promise} A promise that resolves when the setup is complete. */ const setupScanpyOptions = async () => { - const analysisValue = analysisSelect.selectedOptions.length ? getSelect2Value(analysisSelect) : undefined; - const analysisId = (analysisValue && analysisValue > 0) ? analysisValue : null; + const analysisId = getAnalysisId(); const plotType = getSelect2Value(plotTypeSelect); try { ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); @@ -1378,7 +1376,7 @@ const setupScanpyOptions = async () => { if (!allColumns.length) { document.getElementById("plot-options-s-failed").classList.remove("is-hidden"); - createToast("No metadata columns found in dataset. Cannot create a plot. Please choose another dataset"); + createToast("No metadata columns found in dataset. Cannot create a plot. Please choose another analysis or choose another dataset."); return; } diff --git a/www/js/multigene_curator.js b/www/js/multigene_curator.js index 2a408dd5..55f4e1f0 100644 --- a/www/js/multigene_curator.js +++ b/www/js/multigene_curator.js @@ -212,7 +212,7 @@ class GenesAsAxisHandler extends PlotHandler { if (!catColumns.length) { document.getElementById("plot-options-s-failed").classList.remove("is-hidden"); - createToast("No categorical columns found in dataset. Cannot create a plot. Please choose another dataset"); + createToast("No metadata columns found in dataset. Cannot create a plot. Please choose another analysis or choose another dataset."); return; } @@ -584,7 +584,7 @@ class GenesAsDataHandler extends PlotHandler { if (!catColumns.length) { document.getElementById("plot-options-s-failed").classList.remove("is-hidden"); - createToast("No categorical columns found in dataset. Cannot create a plot. Please choose another dataset"); + createToast("No metadata columns found in dataset. Cannot create a plot. Please choose another analysis or choose another dataset."); return; } @@ -873,8 +873,7 @@ const fetchDashData = async (datasetId, analysis, plotType, plotConfig) => { } const getCategoryColumns = async () => { - const analysisValue = analysisSelect.selectedOptions.length ? getSelect2Value(analysisSelect) : undefined; - const analysisId = (analysisValue && analysisValue > 0) ? analysisValue : null; + const analysisId = getAnalysisId(); try { ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); } catch (error) { diff --git a/www/js/sc_workbench.js b/www/js/sc_workbench.js index d2b9cb6e..f4fd4750 100644 --- a/www/js/sc_workbench.js +++ b/www/js/sc_workbench.js @@ -80,13 +80,28 @@ const datasetTree = new DatasetTree({ // This is a placeholder to retrieve preliminary figures which are stored in the "primary" directory currentAnalysis = new Analysis({id: datasetId, type: "primary", datasetIsRaw: true}); - // Technically these could load asynchronously, but logically the progress logs make more sense sequentially + try { + document.querySelector(UI.analysisSelect).disabled = true; + analysisLabels = await currentAnalysis.getSavedAnalysesList(datasetId, -1, 'sc_workbench'); + } catch (error) { + createToast("Failed to access analyses for this dataset"); + logErrorInConsole(error); + } + + document.querySelector(UI.primaryInitialInfoSection).classList.remove("is-hidden"); + document.querySelector(UI.primaryInitialLoadingElt).classList.remove("is-hidden"); try { await getDatasetInfo(datasetId); - await currentAnalysis.loadPreliminaryFigures(); + await currentAnalysis.loadPreliminaryFigures(); // depends on dataset.id from getDatasetInfo + document.querySelector(UI.analysisSelect).disabled = false; } catch (error) { logErrorInConsole(error); + + // Cannot run analyses without a dataset + document.querySelector(UI.analysisSelect).disabled = true; // pass + } finally { + document.querySelector(UI.primaryInitialLoadingElt).classList.add("is-hidden"); } }) @@ -159,8 +174,6 @@ const downloadTableAsExcel = (tableId, filename) => { * @returns {Promise} - A promise that resolves when the dataset information is retrieved and UI updates are complete. */ const getDatasetInfo = async (datasetId) => { - document.querySelector(UI.analysisSelect).disabled = true; - try { const data = await apiCallsMixin.fetchDatasetInfo(datasetId); @@ -169,18 +182,12 @@ const getDatasetInfo = async (datasetId) => { currentAnalysis.dataset = ds; document.querySelector(UI.primaryFilterSection).classList.remove("is-hidden"); - analysisLabels = currentAnalysis.getSavedAnalysesList(ds.id, -1, 'sc_workbench'); // select first "selct an analysis" option - - document.querySelector(UI.primaryInitialInfoSection).classList.remove("is-hidden"); document.querySelector(UI.selectedDatasetShapeInitialElt).textContent = currentAnalysis.dataset.shape(); - document.querySelector(UI.analysisSelect).disabled = false; createToast("Dataset loaded", "is-success"); } catch (error) { createToast("Failed to access dataset"); logErrorInConsole(`Failed ID was: ${datasetId} because msg: ${error.message}`); - document.querySelector(UI.analysisSelect).disabled = true; - } } @@ -360,7 +367,7 @@ const savePcaGeneList = async () => { 'dataset_id': currentAnalysis.dataset.id, 'analysis_id': currentAnalysis.id, 'analysis_type': currentAnalysis.type, - 'session_id': currentAnalysis.userSessionId, + 'session_id': currentAnalysis.analysisSessionId, })); if (!data.success || data.success < 1) { @@ -919,6 +926,7 @@ document.querySelector(UI.analysisSelect).addEventListener("change", async (even const selectedOption = event.target.selectedOptions[0]; currentAnalysis.type = selectedOption.dataset.analysisType; currentAnalysis.id = selectedOption.dataset.analysisId; + currentAnalysis.analysisSessionId = selectedOption.dataset.analysisSessionId; await currentAnalysis.getStoredAnalysis(); // await-able @@ -933,7 +941,7 @@ document.querySelector(UI.analysisSelect).addEventListener("change", async (even document.querySelector(UI.analysisPrimaryNotificationElt).classList.add("is-hidden"); document.querySelector(UI.analysisActionContainer).classList.add("is-hidden"); document.querySelector(UI.analysisStatusInfoContainer).classList.remove("is-hidden"); - createToast("This analysis is stored in your profile.", "is-info", true); + createToast("This analysis is stored in your private user profile.", "is-info", true); document.querySelector(UI.btnMakePublicCopyElt).classList.remove("is-hidden"); } @@ -948,7 +956,7 @@ document.querySelector(UI.analysisSelect).addEventListener("change", async (even document.querySelector(UI.analysisPrimaryNotificationElt).classList.add("is-hidden"); document.querySelector(UI.analysisActionContainer).classList.add("is-hidden"); document.querySelector(UI.analysisStatusInfoContainer).classList.add("is-hidden"); - createToast("Changes made to this public analysis will spawn a local copy within your profile.", "is-info", true); + createToast("Changes made to this public analysis will create a local private copy within your profile.", "is-info", true); document.querySelector(UI.btnMakePublicCopyElt).classList.add("is-hidden"); } diff --git a/www/js/stepper-fxns.js b/www/js/stepper-fxns.js index d0b8258b..ff59a176 100644 --- a/www/js/stepper-fxns.js +++ b/www/js/stepper-fxns.js @@ -34,9 +34,9 @@ * @param {string} selectorHref - The href of the step to be blocked. */ const blockStepWithHref = (selectorHref) => { - document.querySelector(`.steps:not(.is-hidden) a[href='${selectorHref}']`).parentElement.classList.remove("is-dashed", "is-active"); + document.querySelector(`.steps:not(.is-hidden) a[href='${selectorHref}']`).parentElement.classList.remove("is-dashed", "is-active", "is-light"); document.querySelector(`.steps:not(.is-hidden) a[href='${selectorHref}']`).classList.add("is-dark"); - document.querySelector(`.steps:not(.is-hidden) a[href='${selectorHref}'] i`).classList.remove("mdi-check"); + document.querySelector(`.steps:not(.is-hidden) a[href='${selectorHref}'] i`).classList.remove("mdi-check", "mdi-pencil"); document.querySelector(`.steps:not(.is-hidden) a[href='${selectorHref}'] i`).classList.add("mdi-lock"); } @@ -45,9 +45,9 @@ const blockStepWithHref = (selectorHref) => { * @param {string} selector - The CSS selector of the step element. */ const blockStep = (selector) => { - document.querySelector(`.steps:not(.is-hidden) ${selector}`).parentElement.classList.remove("is-dashed", "is-active"); + document.querySelector(`.steps:not(.is-hidden) ${selector}`).parentElement.classList.remove("is-dashed", "is-active", "is-light"); document.querySelector(`.steps:not(.is-hidden) ${selector}`).classList.add("is-dark"); - document.querySelector(`.steps:not(.is-hidden) ${selector} i`).classList.remove("mdi-check"); + document.querySelector(`.steps:not(.is-hidden) ${selector} i`).classList.remove("mdi-check", "mdi-pencil"); document.querySelector(`.steps:not(.is-hidden) ${selector} i`).classList.add("mdi-lock"); } diff --git a/www/sc_workbench.html b/www/sc_workbench.html index 0c805652..ab8b0689 100644 --- a/www/sc_workbench.html +++ b/www/sc_workbench.html @@ -169,11 +169,11 @@
Initial shape:

+
Loading Loading initial gene/cell count plots +