diff --git a/tests/test/gene_collection_manager.test.js b/tests/test/gene_collection_manager.test.js index 0e25c355..a6d4eb94 100644 --- a/tests/test/gene_collection_manager.test.js +++ b/tests/test/gene_collection_manager.test.js @@ -129,6 +129,16 @@ const mockSaveNewGeneCollection = async (page) => { }) } +const mockSaveGeneCollectionChanges = async (page) => { + await page.route(`${gearBase}/cgi/save_genecart_changes.cgi`, async route => { + const json = { + "gene_cart": "{\"id\": 334, \"user_id\": 1, \"gctype\": \"unweighted-list\", \"label\": \"updated test\", \"organism_id\": 1, \"ldesc\": \"This is the unweighted description\", \"share_id\": \"06c1cdd3\", \"is_public\": 1, \"is_domain\": null, \"date_added\": \"2023-11-01 17:10:36\", \"genes\": [\"0610040J01Rik\", \"Zkscan1\", \"1500011K16Rik\", \"1700025G04Rik\", \"Zfp60\", \"1810037I17Rik\", \"2010111I01Rik\", \"2300009A05Rik\", \"Zfp24\", \"Zdhhc2\", \"4931406P16Rik\", \"Ypel2\", \"5031439G07Rik\", \"Wwp1\", \"Wsb2\", \"Wls\", \"Wasf1\", \"Vwc2\", \"Vwa1\", \"Aatk\", \"Vps36\", \"Vps37b\", \"Abca8a\", \"Vldlr\", \"Abhd4\", \"Vcl\", \"Utrn\", \"Acsbg1\", \"Usp1\", \"Adam10\", \"Adam17\", \"Adam23\", \"Adamts17\", \"Adamts5\", \"Ung\", \"Unc119\", \"Adgrg6\", \"Ucp2\", \"Ado\", \"Ado\", \"Uchl1\", \"Adra2c\", \"Afap1l2\", \"Ubl3\", \"Ube2e2\", \"Ube2c\", \"Ahr\", \"Ak3\", \"Ak6\", \"Tyms\", \"Akr1b8\", \"Twf1\", \"Alcam\", \"Alad\", \"Tubg1\", \"Ttyh1\", \"Ttyh2\", \"Tst\", \"Tspo\", \"Angpt2\", \"Ank3\", \"Tspan18\", \"Tspan13\", \"Tspan15\", \"Tspan17\", \"Tsc22d4\", \"Anxa2\", \"Trim2\", \"Trim13\", \"Arfip1\", \"Trappc2\", \"Arhgap19\", \"Arhgap24\", \"Arhgap32\", \"Arhgap39\", \"Arhgef10\", \"Arhgef28\", \"Tpst1\", \"Tppp3\", \"Tpp1\", \"Arid5b\", \"Tpd52\", \"Tox\", \"Arl8a\", \"Arnt2\", \"Top2a\", \"Arrdc3\", \"Art3\", \"Arvcf\", \"Arvcf\", \"Arxes2\", \"Tns3\", \"Asb8\", \"Aspa\", \"Asrgl1\", \"Atad2\", \"Asxl3\", \"Tnik\", \"Atad2\", \"Tnfrsf21\", \"Atg3\", \"Tnfaip6\", \"Tmpo\", \"Atp1b1\", \"Tmod2\", \"Tmem9b\", \"Tmem63b\", \"Atp8a1\", \"Aurkb\", \"Tmem245\", \"B4galt6\", \"Bach1\", \"Bambi\", \"Tmem117\", \"Tm7sf3\", \"Bcas1\", \"Tk1\", \"Tjp1\", \"Bcl7a\", \"Thtpa\", \"Bicd1\", \"Bin3\", \"Birc5\", \"Tgfbr3\", \"Borcs5\", \"Bpnt1\", \"Tex30\", \"Tdrkh\", \"Tead1\", \"Bzw2\", \"Tcp11l1\", \"Tcf19\", \"Tbx2\", \"Cab39l\", \"Cadm1\", \"Cadm4\", \"Camk2d\", \"Tbc1d10a\", \"Tax1bp3\", \"Capg\", \"Tanc2\", \"Capn5\", \"Taldo1\", \"Tagln2\", \"Taf13\", \"Tacc1\", \"Syt11\", \"Syngr1\", \"Cbx6\", \"Svip\", \"Ccdc13\", \"Ccdc28b\", \"Stx7\", \"Strbp\", \"Ccna2\", \"Ccnd1\", \"Ccnb2\", \"Stmn2\", \"Ccser2\"], \"folder_id\": null, \"folder_parent_id\": null, \"folder_label\": null, \"user_name\": \"Test Armstrong\", \"gene_count\": 159, \"organism\": \"Mus musculus\", \"is_owner\": false}", + "success": 1 + }; + await route.fulfill({ json }); + }); +} + /** * Mocks the delete gene collection functionality. * @param {Page} page - The page object. @@ -281,7 +291,6 @@ const mockDownloadUnweightedGeneCollectionMembers = async (page) => { describe('Gene Collection Manager', function () { this.retries(3); - this.timeout(10000); // default is 2000 let browserIndex = 0; @@ -498,7 +507,7 @@ describe('Gene Collection Manager', function () { // Selecting should deselect "All" await groupAffiliatedOption.click(); - // ! This class seems to not be detected + // TODO: detecting the class seems to be flaky when all tests are run await expect(groupAffiliatedOption).toHaveClass(/js-selected/); await expect(yourCollectionsOption).toHaveClass(/js-selected/); await expect(allFacet).not.toHaveClass(/js-selected/); @@ -628,6 +637,9 @@ describe('Gene Collection Manager', function () { it("should update gene collection when save button is clicked", async () => { await page.locator("css=#result_gc_id_334 .js-edit-gc").click(); await page.locator("css=#result_gc_id_334_editable_title").fill("updated test"); + + await mockSaveGeneCollectionChanges(page); + await page.locator("css=#result_gc_id_334 .js-edit-gc-save").click(); await expect(page.locator("css=#result_gc_id_334_editable_title")).not.toBeVisible(); // Check that the search results were updated diff --git a/tests/test/multigene_curator.test.js b/tests/test/multigene_curator.test.js index 38308ced..7922eac0 100644 --- a/tests/test/multigene_curator.test.js +++ b/tests/test/multigene_curator.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env mocha /* - Unit tests for dataset_curator.js + Unit tests for multigene_curator.js */ // Some setup help by https://publishing-project.rivendellweb.net/testing-front-end-with-mocha-and-playwright-2/ diff --git a/www/compare_datasets.html b/www/compare_datasets.html index efb12b72..91f90e20 100644 --- a/www/compare_datasets.html +++ b/www/compare_datasets.html @@ -35,7 +35,7 @@ - + @@ -493,7 +493,7 @@
- + diff --git a/www/css/compare_datasets.css b/www/css/compare_datasets.css index ca6a16bb..6ff09530 100644 --- a/www/css/compare_datasets.css +++ b/www/css/compare_datasets.css @@ -1,103 +1,62 @@ -dd { - margin-bottom: 25px; -} -dd div { - margin-bottom: 10px; -} -div#chart_settings h3 { - margin-top: 20px; - margin-bottom: 0px; -} -div#chart_settings label { - font-size: 90%; -} -div#chart_settings ul { - list-style: none; - font-size: 90%; - margin: 20px 0 0 10px; - padding: 0; -} -div.code { - font-size: 90%; - font-family: courier; - letter-spacing: 1px; -} -div#error_loading_c { - display: none; -} -div#create_gene_cart_dialog { - display: none; - margin: 15px 0 15px 0; +/* Keep at a reasonable length */ +input[type=number] { + width: 6em; } -div.loading_indicator { - display: none; -} -div.loading_indicator img { - width: 100px; -} -div#result_table_c { - margin-top: 30px; -} -div#saved_gene_cart_info_c { - display: none; -} -div#saved_gene_cart_info_c h3 { - margin-bottom: 5px; +/* reflecting gEAR "select" form CSS to "nice-select" elements */ +.select .nice-select { + background-color: #EFEDF7; + border-color: #544A8E; + border-radius: 5px; + color: #363636; } -div#selected_label { - display: none; - margin-bottom: 10px; -} -div#sidebar { - padding: 20px 0 0 10px; -} -div#stat_action_options_c { - margin-top: 10px; - padding-left: 5px; + +.is-fullwidth .nice-select{ + width:100%; } -div#viewport { - min-height: 500px; + +.nice-select::after { + content: unset; /* remove their version of dropdown arrow (behind the Bulma select arrow) */ } -div.svg-container { - max-width: 100%; + +.nice-select-dropdown { + width: 100%; /* Override auto-width setting by nice-select */ } -p#saved_gene_cart_info { - font-size: 80%; + +.nice-select .list { + overflow-y: scroll; /* Make it apparent that there are more options */ + overflow-x: hidden; } -#gene_list_c { - display: none; +/* match nice-select disabled state style with select style from common.css */ +.select .nice-select.disabled { + border-color: grey; + background-color: lightgrey; } -table#tbl_selected_genes { - border-collapse: collapse; + +.nice-select .list::-webkit-scrollbar-thumb { + background: #888; } -table#tbl_selected_genes td { - padding: 3px; - font-size: 90%; - border: 1px solid rgb(200,200,200); + +.nice-select .list::-webkit-scrollbar-thumb:hover { + background: #555; } -table#tbl_selected_genes th { - border: 1px solid rgb(200,200,200); - padding: 3px; + +.nice-select .list::-webkit-scrollbar { + width: 10px; /* overwrite nice-select default of 0 */ } -/* Overwritting bootstrap default */ -.nav-tabs .nav-link.active { - text-decoration-line: underline; +/* Track */ +.nice-select .list::-webkit-scrollbar-track { + background: #f1f1f1; } -.btn-outline-purple { - color: #562a6f; - background-color: transparent; - background-image: none; - border-color: #562a6f; +#post_plot_content_c { + height:92vh; } -.btn-outline-purple:hover { - color: white; - background-color: #401362; - background-image: none; - border-color: #401362; -} \ No newline at end of file +/* override loader from the themed CSS */ +.button.is-loading::after, .loader, .select.is-loading::after, .control.is-loading::after { + border-color: black; +} diff --git a/www/css/compare_datasets.v2.css b/www/css/compare_datasets.v2.css deleted file mode 100644 index 6ff09530..00000000 --- a/www/css/compare_datasets.v2.css +++ /dev/null @@ -1,62 +0,0 @@ -/* Keep at a reasonable length */ -input[type=number] { - width: 6em; -} - -/* reflecting gEAR "select" form CSS to "nice-select" elements */ -.select .nice-select { - background-color: #EFEDF7; - border-color: #544A8E; - border-radius: 5px; - color: #363636; -} - -.is-fullwidth .nice-select{ - width:100%; -} - -.nice-select::after { - content: unset; /* remove their version of dropdown arrow (behind the Bulma select arrow) */ -} - -.nice-select-dropdown { - width: 100%; /* Override auto-width setting by nice-select */ -} - -.nice-select .list { - overflow-y: scroll; /* Make it apparent that there are more options */ - overflow-x: hidden; -} - -/* match nice-select disabled state style with select style from common.css */ -.select .nice-select.disabled { - border-color: grey; - background-color: lightgrey; -} - - -.nice-select .list::-webkit-scrollbar-thumb { - background: #888; -} - -.nice-select .list::-webkit-scrollbar-thumb:hover { - background: #555; -} - -.nice-select .list::-webkit-scrollbar { - width: 10px; /* overwrite nice-select default of 0 */ -} - -/* Track */ -.nice-select .list::-webkit-scrollbar-track { - background: #f1f1f1; -} - -#post_plot_content_c { - height:92vh; -} - -/* override loader from the themed CSS */ -.button.is-loading::after, .loader, .select.is-loading::after, .control.is-loading::after { - border-color: black; -} diff --git a/www/css/dataset_curator.css b/www/css/dataset_curator.css index f9e1366b..e69de29b 100644 --- a/www/css/dataset_curator.css +++ b/www/css/dataset_curator.css @@ -1,148 +0,0 @@ -div#login_checking, -div#login_warning { - padding-bottom: 200px; -} -div#login_warning { - display: none; -} -div#login_checking, -div#login_warning { - margin-top: 50px; -} -div#main_content { - margin-top: 45px; -} -label.control-label { - margin: 8px 0 0 0; -} - -img#checking_indicator, -img#submit_wait_indicator { - height: 30px; - width: 30px; - margin-right: 10px; -} - -.fa-plus { - color: var(--banner-bg-color); -} - -.elevation { - -webkit-box-shadow: 0 4px 6px hsla(281, 63%, 15%, .1), 0 5px 15px hsla(281, 63%, 15%, 0.1) !important; - box-shadow: 0 4px 6px hsla(281, 63%, 15%, .1), 0 5px 15px hsla(281, 63%, 15%, 0.1) !important; -} - -.display-card { - width:300px; - height:300px; -} - -.hovering { - background-color:#FCFAFC; - cursor: pointer; -} - -.fade-enter-active, -.fade-leave-active { - transition-duration: 0.3s; - transition-property: opacity; - transition-timing-function: ease; -} -.fade-enter, -.fade-leave-active { - opacity: 0; -} - -#dataset_info { - padding-top: 45px; -} - -/* This is here mostly to overwrite the "grey" set by common.css... not the most elegant way to fix this */ -.text-white h4 { - color: white; -} - -.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active, a.ui-button:active, .ui-button:active, .ui-button.ui-state-active:hover { - border: 1px solid var(--banner-bg-color); - background: var(--banner-bg-color); - font-weight: normal; - color: #ffffff; -} - -.purple { - background-color: var(--banner-bg-color); -} - -.btn-purple { - color: white; - background-color: #562a6f; - border-color: #562a6f; -} - -.btn-purple:hover { - background-color: var(--banner-bg-color); - border-color: var(--banner-bg-color); -} - -img.searching_indicator { - display: block; - margin: 0 auto; - width: 50px; -} - -#dataset_info { - padding-bottom: 25px; -} - -.list-group-item.active, .list-group-item.active:focus, .list-group-item.active:hover { - text-shadow: 0 -1px 0 var(--banner-bg-color); - background-image: -webkit-linear-gradient(top, var(--banner-bg-color) 0, var(--banner-bg-color) 100%); - background-image: -o-linear-gradient(top, var(--banner-bg-color) 0,var(--banner-bg-color) 100%); - background-image: -webkit-gradient(linear,left top,left bottom,from(var(--banner-bg-color)),to(var(--banner-bg-color))); - background-image: linear-gradient(to bottom,var(--banner-bg-color) 0,var(--banner-bg-color) 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#2f103e', endColorstr='#2f103e', GradientType=0); - background-repeat: repeat-x; - border-color: var(--banner-bg-color); -} - -.sticky-chart { position: sticky; top: 5px;} -#searching_indicator_chart { position: sticky; top: 250px; } - -.sortable { list-style-type: none; margin: 0; padding: 0;} -.sortable li {display: inline;}/** { margin: 3px 3px 3px 0; padding: 1px; float: left; width: 100px; height: 90px; font-size: 4em; text-align: center; }**/ - -ul.sortable { - padding-bottom: 20px; -} -ul.sortable li { - cursor:pointer; -} - -div.svg-container { - /* https://css-tricks.com/scale-svg/ */ - position: relative; - height: 0; - width: 100%; - padding: 0; - padding-bottom: 80%; - flex: 1 1 100%; -} -div.svg-content { - display: flex; - max-width: 100%; - left: 0; - top: 0; -} -div.svg-content > svg { - flex: 1; -} - -.draggable { - cursor: move; -} - -.tsne-no-color { - /* Just like .img-fluid but with max-width smaller */ - max-width: 70%; - height: auto; -} diff --git a/www/css/dataset_curator.v2.css b/www/css/dataset_curator.v2.css deleted file mode 100644 index e69de29b..00000000 diff --git a/www/css/multigene_curator.css b/www/css/multigene_curator.css index c90b58bc..e69de29b 100644 --- a/www/css/multigene_curator.css +++ b/www/css/multigene_curator.css @@ -1,69 +0,0 @@ -div#login_checking, -div#login_warning { - padding-bottom: 200px; -} -div#login_warning { - display: none; -} -div#login_checking, -div#login_warning { - margin-top: 50px; -} -img#checking_indicator { - height: 30px; - width: 30px; - margin-right: 10px; -} -img.modal-display-loading { - height: 50px; -} -main { - margin-top: 45px; -} -label.control-label { - margin: 8px 0 0 0; -} -.js-plotly-plot .plotly svg a { - fill: inherit !important; /* override plotly defaults */ -} - -.btn-purple { - color: white; - background-color: #562a6f; - border-color: #562a6f; -} - -.btn-purple:hover { - background-color: #401362; - border-color: #401362; -} - -.btn-outline-purple { - color: #562a6f; - background-color: transparent; - background-image: none; - border-color: #562a6f; -} - -.btn-outline-purple:hover { - color: white; - background-color: #401362; - background-image: none; - border-color: #401362; -} - -#genes_list_bar { - overflow-y: auto; - height: 700px; /* slightly less than dataset plot volcanoes/quadrants */ -} - -#selected_label { - position: sticky; - top: 0; - z-index: 1; - background-color: white; /* To avoid text overlap with scrolling table */ -} - -#select2-colorscale_select-results { - max-height: 300px; /* Personal preference. Felt the default box height was too short for this selection. */ -} \ No newline at end of file diff --git a/www/css/multigene_curator.v2.css b/www/css/multigene_curator.v2.css deleted file mode 100644 index e69de29b..00000000 diff --git a/www/css/user_profile.css b/www/css/user_profile.css index 09bd7fa7..c635bc0f 100644 --- a/www/css/user_profile.css +++ b/www/css/user_profile.css @@ -22,4 +22,4 @@ img#submit_wait_indicator { height: 30px; width: 30px; margin-right: 10px; -} \ No newline at end of file +} diff --git a/www/css/user_profile.v2.css b/www/css/user_profile.v2.css deleted file mode 100644 index c635bc0f..00000000 --- a/www/css/user_profile.v2.css +++ /dev/null @@ -1,25 +0,0 @@ -div#login_checking, -div#login_warning { - padding-bottom: 200px; -} -div#login_warning { - display: none; -} -div#login_checking, -div#login_warning { - margin-top: 50px; -} -div#main_content { - display: none; - margin-top: 45px; -} -label.control-label { - margin: 8px 0 0 0; -} - -img#checking_indicator, -img#submit_wait_indicator { - height: 30px; - width: 30px; - margin-right: 10px; -} diff --git a/www/dataset_curator.html b/www/dataset_curator.html index c021bce4..1586a400 100644 --- a/www/dataset_curator.html +++ b/www/dataset_curator.html @@ -38,7 +38,7 @@ - + @@ -424,7 +424,7 @@

- + diff --git a/www/js/compare_datasets.js b/www/js/compare_datasets.js index 935f22a5..c696b9b4 100644 --- a/www/js/compare_datasets.js +++ b/www/js/compare_datasets.js @@ -1,14 +1,8 @@ -// NOTE: - SAdkins - 11/3/21 - Refactored code using P42 VSCode extension https://p42.ai/documentation/code-action/ - -let plot_data = null; -let selected_data = null; - -let condition_x = null; -let condition_y = null; +'use strict'; // SAdkins - 2/15/21 - This is a list of datasets already log10-transformed where if selected will use log10 as the default dropdown option // This is meant to be a short-term solution until more people specify their data is transformed via the metadata -const log10_transformed_datasets = [ +const LOG10_TRANSFORMED_DATASETS = [ "320ca057-0119-4f32-8397-7761ea084ed1" , "df726e89-b7ac-d798-83bf-2bd69d7f3b52" , "bad48d04-db27-26bc-2324-e88506f751fd" @@ -50,808 +44,920 @@ const log10_transformed_datasets = [ , "80eadbe6-49ac-8eaf-f2fb-e07706cf117b" ]; -// TODO: Have mechanism to convert non-categorical column to categorical if it was erroneously added as numerical - -const dataset_tree = new DatasetTree({treeDiv: '#dataset_tree'}); +let sessionId; +let facetWidget; +let datasetId; +let organismId; // Used for saving as gene cart +let compareData;; +let selectedGeneData; +let geneSelect; + +// Storing user's plot text edits, so they can be restored if user replots +let titleText = null; +let xaxisText = null; +let yaxisText = null; + +const datasetTree = new DatasetTree({ + element: document.getElementById("dataset_tree") + , searchElement: document.getElementById("dataset_query") + , selectCallback: (async (e) => { + if (e.node.type !== "dataset") { + return; + } + document.getElementById("current_dataset_c").classList.remove("is-hidden"); + document.getElementById("current_dataset").textContent = e.node.title; + document.getElementById("current_dataset_post").textContent = e.node.title; + + const newDatasetId = e.node.data.dataset_id; + organismId = e.node.data.organism_id; + + // We don't want to needless run this if the same dataset was clicked + if (newDatasetId === datasetId) { + return; + } + + datasetId = newDatasetId; + + // Click to get to next step + document.getElementById("condition_compare_s").click(); + + // Clear "success/failure" icons + for (const elt of document.getElementsByClassName("js-step-success")) { + elt.classList.add("is-hidden"); + } + for (const elt of document.getElementsByClassName("js-step-failure")) { + elt.classList.add("is-hidden"); + } -window.onload = () => { - $(".btn-apply-filter").on("click", () => { - $(".initial_instructions").hide(); - $("#myChart").html(""); - $("#error_loading_c").hide(); - $("#plot_loading").show(); - load_comparison_graph(); - }); + const compareSeriesElt = document.getElementById("compare_series"); + compareSeriesElt.parentElement.classList.add("is-loading"); - /***** gene cart stuff *****/ - $("#create_gene_cart").on("click", () => { - $("#create_gene_cart_dialog").show("fade"); - }); - $("#cancel_save_gene_cart").on("click", () => { - $("#create_gene_cart_dialog").hide("fade"); - $("#gene_cart_name").val(""); - }); + // Clear selected gene tags + document.getElementById("gene_tags").replaceChildren(); - $("#gene_cart_name").on("input", function () { - if ($(this).val() == "") { - $("#save_gene_cart").prop("disabled", true); - } else { - $("#save_gene_cart").prop("disabled", false); + // Clear compare groups + for (const classElt of document.getElementsByClassName("js-compare-groups")) { + classElt.replaceChildren(); } - }); - $("#save_gene_cart").on("click", () => { - $("#save_gene_cart").prop("disabled", true); - if (CURRENT_USER) { - save_gene_cart(); - } else { - alert("You must be signed in to do that."); - } - }); + // Creates gene select instance that allows for multiple selection + geneSelect = createGeneSelectInstance("gene_select", geneSelect); + // Populate gene select element + await geneSelectUpdate() + + + // Create facet widget, which will refresh filters + facetWidget = await createFacetWidget(datasetId, null, {}); + document.getElementById("facet_content").classList.remove("is-hidden"); + document.getElementById("selected_facets").classList.remove("is-hidden"); + + // Update compare series options + const catColumns = facetWidget.aggregations.map((agg) => agg.name); + updateSeriesOptions("js-compare", catColumns); + + compareSeriesElt.parentElement.classList.remove("is-loading"); + + }) +}); + +const geneCartTree = new GeneCartTree({ + element: document.getElementById("genecart_tree") + , searchElement: document.getElementById("genecart_query") + , selectCallback: (async (e) => { + if (e.node.type !== "genecart") { + return; + } + + // Get gene symbols from gene cart + const geneCartId = e.node.data.orig_id; + const geneCartMembers = await fetchGeneCartMembers(geneCartId); + const geneCartSymbols = geneCartMembers.map((item) => item.label); + + // Normalize gene symbols to lowercase + const geneSelectSymbols = geneSelect.data.map((opt) => opt.value); + const geneCartSymbolsLowerCase = geneCartSymbols.map((x) => x.toLowerCase()); + + const geneSelectedOptions = geneSelect.selectedOptions.map((opt) => opt.data.value); + + // Get genes from gene cart that are present in dataset's genes. Preserve casing of dataset's genes. + const geneCartIntersection = geneSelectSymbols.filter((x) => geneCartSymbolsLowerCase.includes(x.toLowerCase())); + // Add in already selected genes (union) + const geneSelectIntersection = [...new Set(geneCartIntersection.concat(geneSelectedOptions))]; + + // change all options to be unselected + const origSelect = document.getElementById("gene_select"); + for (const opt of origSelect.options) { + opt.removeAttribute("selected"); + } + + // Assign intersection genes to geneSelect "selected" options + for (const gene of geneSelectIntersection) { + const opt = origSelect.querySelector(`option[value="${gene}"]`); + try { + opt.setAttribute("selected", "selected"); + } catch (error) { + // sanity check + const msg = `Could not add gene ${gene} to gene select.`; + console.warn(msg); + } + } + + geneSelect.update(); + trigger(document.getElementById("gene_select"), "change"); // triggers chooseGene() to load tags + }) +}); + +const adjustGeneTableLabels = () => { + const geneFoldchanges = document.getElementById("tbl_gene_foldchanges"); + const log_base = document.getElementById("log_base").value; + + const spanIcon = document.createElement("span"); + spanIcon.classList.add("icon"); + const i = document.createElement("i"); + i.classList.add("mdi", "mdi-sort-numeric-ascending"); + i.setAttribute("aria-hidden", "true"); + spanIcon.appendChild(i); + geneFoldchanges.appendChild(spanIcon); + + if (log_base === "raw") { + geneFoldchanges.prepend("Fold Change "); + return; + } + geneFoldchanges.prepend(`Log${log_base} Fold Change `); +} - $("#weighted_gene_cart_name").on("input", function () { - if ($(this).val() == "") { - $("#save_weighted_gene_cart").prop("disabled", true); - } else { - $("#save_weighted_gene_cart").prop("disabled", false); - } - }); - $("#save_weighted_gene_cart").on("click", () => { - $("#save_weighted_gene_cart").prop("disabled", true); - if (CURRENT_USER) { - save_weighted_gene_cart(); - } else { - alert("You must be signed in to do that."); - } - }); - /***** end gene cart stuff *****/ +const appendGeneTagButton = (geneTagElt) => { + // Add delete button + const deleteBtnElt = document.createElement("button"); + deleteBtnElt.classList.add("delete", "is-small"); + geneTagElt.appendChild(deleteBtnElt); + deleteBtnElt.addEventListener("click", (event) => { + // Remove gene from geneSelect + const gene = event.target.parentNode.textContent; + const geneSelectElt = document.getElementById("gene_select"); + geneSelectElt.querySelector(`option[value="${gene}"]`).removeAttribute("selected"); + + geneSelect.update(); + trigger(document.getElementById("gene_select"), "change"); // triggers chooseGene() to load tags + }); + + // ? Should i add ellipses for too many genes? Should I make the box collapsable? +} - $("#dataset_id").on("change", () => { - populate_condition_selection_control(); - // Change the default if the dataset is already log10 transformed - if (log10_transformed_datasets.includes($('#dataset_id').val())) { - $('#log_base').val('10'); - } else { - $('#log_base').val('2'); - } - }); +const chooseGene = (event) => { + // Triggered when a gene is selected + + // Delete existing tags + const geneTagsElt = document.getElementById("gene_tags"); + geneTagsElt.replaceChildren(); + + if (!geneSelect.selectedOptions.length) return; // Do not trigger after initial population + + // Update list of gene tags + const sortedGenes = geneSelect.selectedOptions.map((opt) => opt.data.value).sort(); + for (const opt in sortedGenes) { + const geneTagElt = document.createElement("span"); + geneTagElt.classList.add("tag", "is-primary", "mx-1"); + geneTagElt.textContent = sortedGenes[opt]; + appendGeneTagButton(geneTagElt); + geneTagsElt.appendChild(geneTagElt); + } + + document.getElementById("gene_tags_c").classList.remove("is-hidden"); + if (!geneSelect.selectedOptions.length) { + document.getElementById("gene_tags_c").classList.add("is-hidden"); + } + + // If more than 10 tags, hide the rest and add a "show more" button + if (geneSelect.selectedOptions.length > 10) { + const geneTags = geneTagsElt.querySelectorAll("span.tag"); + for (let i = 10; i < geneTags.length; i++) { + geneTags[i].classList.add("is-hidden"); + } + // Add show more button + const showMoreBtnElt = document.createElement("button"); + showMoreBtnElt.classList.add("tag", "button", "is-small", "is-primary", "is-light"); + const numToDisplay = geneSelect.selectedOptions.length - 10; + showMoreBtnElt.textContent = `+${numToDisplay} more`; + showMoreBtnElt.addEventListener("click", (event) => { + const geneTags = geneTagsElt.querySelectorAll("span.tag"); + for (let i = 10; i < geneTags.length; i++) { + geneTags[i].classList.remove("is-hidden"); + } + event.target.remove(); + }); + geneTagsElt.appendChild(showMoreBtnElt); + } + + updatePlotAnnotations(sortedGenes); - $("#statistical_test").on("change", () => { - if ($("#statistical_test").val()) { - $("#test_pval_cutoff").prop("disabled", false); - } else { - $("#test_pval_cutoff").prop("disabled", true); - } - }); +} - // Create observer to watch if user changes (ie. successful login does not refresh page) - // See: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver +const clearGenes = (event) => { + document.getElementById("clear_genes_btn").classList.add("is-loading"); + geneSelect.clear(); + updatePlotAnnotations([]); + document.getElementById("clear_genes_btn").classList.remove("is-loading"); +} - // But we need to wait for navigation_bar to load first (in common.js) so do some polling - // See: https://stackoverflow.com/q/38881301 +const createFacetWidget = async (datasetId, analysisId, filters) => { + document.getElementById("selected_facets_loader").classList.remove("is-hidden") + + const {aggregations, total_count:totalCount} = await fetchAggregations(datasetId, analysisId, filters); + document.getElementById("num_selected").textContent = totalCount; + + + const facetWidget = new FacetWidget({ + aggregations, + filters, + onFilterChange: async (filters) => { + if (filters) { + try { + const {aggregations, total_count:totalCount} = await fetchAggregations(datasetId, analysisId, filters); + facetWidget.updateAggregations(aggregations); + document.getElementById("num_selected").textContent = totalCount; + } catch (error) { + logErrorInConsole(error); + } + } else { + // Save an extra API call + facetWidget.updateAggregations(facetWidget.aggregations); + } + }, + filterHeaderExtraClasses:"has-background-white" + }); + document.getElementById("selected_facets_loader").classList.add("is-hidden") + return facetWidget; +} - // Select the node that will be observed for mutations - const target_node = document.getElementById('loggedin_controls'); - const safer_node = document.getElementById("navigation_bar"); // Empty div until loaded - // Create an observer instance linked to the callback function - const observer = new MutationObserver(function(mutationList, observer) { - if (target_node) { - populate_dataset_selection_controls(); - this.disconnect(); // Don't need to reload once the trees are updated - } - }); - // For the "config" settings, do not monitor the subtree of nodes as that will trigger the callback multiple times. - // Just seeing #loggedin_controls go from hidden (not logged in) to shown (logged in) is enough to trigger. - observer.observe(target_node || safer_node , { attributes: true }); +const createGeneSelectInstance = (idSelector, geneSelect=null) => { + // NOTE: Updating the list of genes can be memory-intensive if there are a lot of genes + // and (I've noticed) if multiple select2 elements for genes are present. + + // If object exists, just update it with the revised data and return + if (geneSelect) { + geneSelect.update(); + return geneSelect; + } + + return NiceSelect.bind(document.getElementById(idSelector), { + placeholder: 'To search, start typing a gene name', + searchtext: 'To search, start typing a gene name', + searchable: true, + allowClear: true, + }); +} -}; +const downloadSelectedGenes = (event) => { + event.preventDefault(); -function download_selected_genes() { // Builds a file in memory for the user to download. Completely client-side. // plot_data contains three keys: x, y and symbols // build the file string from this - const x_label = $('#x_label').val().length ? $('#x_label').val() : "x-condition"; - const y_label = $('#y_label').val().length ? $('#y_label').val() : "y-condition"; + // Adjust headers to the plot type + const xLabel = JSON.stringify([...document.querySelectorAll("#compare_x input:checked")].map((elt) => elt.value)); + const yLabel = JSON.stringify([...document.querySelectorAll("#compare_y input:checked")].map((elt) => elt.value)); + + const logBase = document.getElementById("log_base").value; - let file_contents = - $("#log_base").val() == "raw" + let fileContents = + logBase === "raw" ? "gene_symbol\tp-value\traw fold change\t" - + x_label + "\t" - + y_label + "\n" + + xLabel + "\t" + + yLabel + "\n" : "gene_symbol\tp-value\traw fold change\t" - + x_label + " (log" + $("#log_base").val() +")\t" - + y_label + " (log" + $("#log_base").val() +")\n"; + + xLabel + " (log" + logBase +")\t" + + yLabel + " (log" + logBase +")\n"; - - selected_data.points.forEach((pt) => { + selectedGeneData.forEach((gene) => { // Some warnings on using toFixed() here: https://stackoverflow.com/a/12698296/1368079 - file_contents += - `${pt.data.id[pt.pointNumber]}\t` - + ($("#statistical_test").val() ? pt.data.pvals[pt.pointNumber].toExponential(2) : "NA") + "\t" - + pt.data.foldchange[pt.pointNumber].toFixed(1) + "\t" - + pt.x.toFixed(1) + "\t" - + pt.y.toFixed(1) + "\n"; + fileContents += + `${gene.gene_symbol}\t` + + `${gene.pval}\t` + + `${gene.foldchange}\t` + + `${gene.x}\t` + + `${gene.y}\n`; }); const element = document.createElement("a"); element.setAttribute( "href", - `data:text/tab-separated-values;charset=utf-8,${encodeURIComponent(file_contents)}` + `data:text/tab-separated-values;charset=utf-8,${encodeURIComponent(fileContents)}` ); element.setAttribute("download", "selected_genes.tsv"); element.style.display = "none"; document.body.appendChild(element); element.click(); document.body.removeChild(element); -} -// Build list of selected categories and groups -function update_selected_conditions() { - const condition = {}; - // Create current object of which groups are to be included (checked) - $('#conditions_accordion').find('.js-group-check').each(function(){ - const id = $(this).data("group"); - const category = id.split(';-;')[0]; - const group = id.split(';-;')[1]; +} - if (Object.keys(condition).indexOf(category) === -1) { - condition[category] = []; - } - if ($(this).prop("checked")) { - condition[category].push(group); - } - }); - return condition; +const fetchAggregations = async (datasetId, analysisId, filters) => { + try { + const data = await apiCallsMixin.fetchAggregations(datasetId, analysisId, filters) + if (data.hasOwnProperty("success") && data.success < 1) { + throw new Error(data?.message || "Could not fetch number of observations for this dataset. Please contact the gEAR team."); + } + const {aggregations, total_count} = data; + return {aggregations, total_count}; + } catch (error) { + logErrorInConsole(error); + } } -function load_comparison_graph() { - // Save current state of active condition tab - if ($("#condition_x_tab").hasClass("active")) { - condition_x = update_selected_conditions(); - } else { - // on #condition_y_tab - condition_y = update_selected_conditions(); +const fetchDatasetComparison = async (datasetId, filters, compareKey, conditionX, conditionY, foldChangeCutoff, stDevNumCutoff, logBase, statisticalTestAction) => { + try { + return await apiCallsMixin.fetchDatasetComparison(datasetId, filters, compareKey, conditionX, conditionY, foldChangeCutoff, stDevNumCutoff, logBase, statisticalTestAction); + } catch (error) { + const msg = "Could not fetch dataset comparison. Please contact the gEAR team." + throw new Error(msg); } - - const dataset_id = $("#dataset_id").val(); - const dataset_text = $("#dataset_id").text(); - const sanitized_condition_x = sanitize_condition(condition_x); - const sanitized_condition_y = sanitize_condition(condition_y); - const condition_x_string = JSON.stringify(sanitized_condition_x); - const condition_y_string = JSON.stringify(sanitized_condition_y); - - // empty error message, so that user/helper won't get confused - $("#ticket_error_msg").empty(); - $("#error_loading_c").hide(); - $("#genes_not_found").empty().hide(); - $("#gene_list_c").hide(); - $('#weighted_gene_cart_c').hide(); - - $.ajax({ - url: "./cgi/get_dataset_comparison.cgi", - type: "POST", - data: { - dataset_id, - condition_x: condition_x_string, - condition_y: condition_y_string, - fold_change_cutoff: $("#fold_change_cutoff").val(), - std_dev_num_cutoff: $("#std_dev_num_cutoff").val(), - log_transformation: $("#log_base").val(), - statistical_test: $("#statistical_test").val(), - }, - dataType: "json" - }).done((data) => { - if (data.success == 1) { - $("#fold_change_std_dev").html(data.fold_change_std_dev); - plot_data = data; - plot_data_to_graph(data); - return; - } - handle_get_comparison_error(dataset_id, dataset_text, condition_x_string, condition_y_string); - }).fail((data) => { - handle_get_comparison_error(dataset_id, dataset_text, condition_x_string, condition_y_string); - }); } -function handle_get_comparison_error(dataset_id, dataset_text, condition_x_string, condition_y_string) { - // Handle graphing failures - $("#plot_loading").hide(); - $("#ticket_dataset_id").text(dataset_id); - $("#ticket_dataset_text").text(dataset_text); - $("#ticket_datasetx_condition").text(condition_x_string); - $("#ticket_datasety_condition").text(condition_y_string); - $("#error_loading_c").show(); -} - -async function fetch_h5ad_observations (dataset_id) { - const base = `./api/h5ad/${dataset_id}`; - const { data } = await axios.get(base); - return data; -} - -async function populate_condition_selection_control() { - const dataset_id = $("#dataset_id").val(); - $("#conditions_accordion").removeClass("alert-info alert-danger"); - $("#conditions_accordion").html("

Loading ...

"); - const obs_data = await fetch_h5ad_observations(dataset_id); - const cat_obs = obs_data.obs_levels; // cat->groups - const all_obs = obs_data.obs_columns; // Array - const noncat_obs = Object.values(all_obs).filter(x => Object.keys(cat_obs).indexOf(x) === -1); // Array - - // If dataset has no observations, indicate it does not and hide condition controls - if (! Object.keys(cat_obs).length) { - $('#condition_tabs').hide(); - $("#conditions_accordion").removeClass("alert-info").addClass("alert-danger"); - $("#conditions_accordion").text("There are no categorical observations found for this dataset."); - return; - } +const fetchDatasets = async () => { + try { + return await apiCallsMixin.fetchDatasets(); + } catch (error) { + logErrorInConsole(error); + const msg = "Could not fetch datasets. Please contact the gEAR team." + createToast(msg); + throw new Error(msg); + } +} - $('#condition_tabs').show(); +/* Fetch gene collection members */ +const fetchGeneCartMembers = async (geneCartId) => { + try { + const {gene_symbols, success} = await apiCallsMixin.fetchGeneCartMembers(geneCartId); + if (!success) { + throw new Error("Could not fetch gene collection members. You can still enter genes manually."); + } + return gene_symbols; + } catch (error) { + logErrorInConsole(error); + const msg = "Could not fetch gene collection members. You can still enter genes manually."; + createToast(msg); + throw new Error(msg); + } +} - // Render templates - const selector_tmpl = $.templates("#dataset_condition_options"); - const selector_html = selector_tmpl.render(cat_obs); - $("#conditions_accordion").html(selector_html); +/* Fetch gene collections */ +const fetchGeneCarts = async () => { + const cartType = "unweighted-list"; + try { + return await apiCallsMixin.fetchGeneCarts(cartType); + } catch (error) { + logErrorInConsole(error); + const msg = "Could not fetch gene collections. You can still enter genes manually."; + createToast(msg); + throw new Error(msg); + } +} - if (noncat_obs.length) { - const noncat_tmpl = $.templates("#non_categories_list"); - const noncat_html = noncat_tmpl.render({noncat_obs}); - $("#noncats").html(noncat_html); +const fetchGeneSymbols = async (datasetId, analysisId) => { + try { + const data = await apiCallsMixin.fetchGeneSymbols(datasetId, analysisId); + return [...new Set(data.gene_symbols)]; // Dataset may have a gene repeated in it, so resolve this. + } catch (error) { + logErrorInConsole(error); + const msg = "Could not fetch gene symbols for this dataset. Please contact the gEAR team." + createToast(msg); + return []; } +} - // Since we want the default state of the category groups to be unchecked, - // we do not set an initial state of stored checked conditions - if (obs_data.has_replicates == 1) { - $("#statistical_test_label").html(""); - $("#statistical_test").attr("disabled", false); - } else { - $("#statistical_test_label").html( - "Not applicable since this dataset has no replicates" - ); - $("#statistical_test").attr("disabled", true); +const geneSelectUpdate = async (analysisId=null) => { + // Populate gene select element + try { + const geneSymbols = await fetchGeneSymbols(datasetId, analysisId); + updateGeneOptions(geneSymbols); // Come from curator specific code + } catch (error) { + logErrorInConsole(error); } } -$('#condition_x_tab').on('shown.bs.tab', (e) => { +const getComparisons = async (event) => { - condition_y = update_selected_conditions(); + // set loading icon + event.target.classList.add("is-loading"); - // Load condition_x stuff - $('.js-cat-check').prop("checked", false); - $('.js-group-check').prop("checked", false); - for (const cat in condition_x) { - for (const elem of condition_x[cat]) { - $(`input[data-group="${cat};-;${elem}"]`).prop("checked", true); - } - $('.js-group-check').change(); // trigger so the cat checkbox matches up - } + const filters = JSON.stringify(facetWidget.filters); -}) + const compareSeries = document.getElementById("compare_series").value -$('#condition_y_tab').on('shown.bs.tab', (e) => { + // Get all checked x and y series + const checkedX = JSON.stringify([...document.querySelectorAll("#compare_x input:checked")].map((elt) => elt.value)); + const checkedY = JSON.stringify([...document.querySelectorAll("#compare_y input:checked")].map((elt) => elt.value)); - condition_x = update_selected_conditions(); + const foldChangeCutoff = document.getElementById("fc_cutoff").value; + const stdDevNumCutoff = document.getElementById("standard_deviation").value; + const logTransformation = document.getElementById("log_base").value; + const statisticalTest = document.getElementById("statistical_test").value; - // Load condition_y stuff - $('.js-cat-check').prop("checked", false); - $('.js-group-check').prop("checked", false); - for (const cat in condition_y) { - for (const elem of condition_y[cat]) { - $(`input[data-group="${cat};-;${elem}"]`).prop("checked", true); + try { + const data = await fetchDatasetComparison(datasetId, filters, compareSeries, checkedX, checkedY, foldChangeCutoff, stdDevNumCutoff, logTransformation, statisticalTest); + if (data?.success < 1) { + throw new Error(data?.message || "Could not fetch dataset comparison. Please contact the gEAR team."); } - $('.js-group-check').change(); // trigger so the cat checkbox matches up + compareData = data; + plotDataToGraph(compareData); + + // If any genes selected, update plot annotations (since plot was previously purged) + const sortedGenes = geneSelect.selectedOptions.map((opt) => opt.data.value).sort(); + updatePlotAnnotations(sortedGenes); + + // Show button to add genes to gene cart + document.getElementById("gene_cart_btn_c").classList.remove("is-hidden"); + + // Hide this view + document.getElementById("content_c").classList.add("is-hidden"); + // Generate and display "post-plotting" view/container + document.getElementById("post_plot_content_c").classList.remove("is-hidden"); + + } catch (error) { + console.error(error); + handleGetComparisonError(datasetId, checkedX, checkedY); + } finally { + event.target.classList.remove("is-loading"); } -}) - -$(document).on('change', '.js-cat-check', function (e) { - // If turned on, check all group boxes - // If turned off, uncheck all group boxes - const checked = $(this).prop("checked"); - - const id = this.id; - const category = id.replace('_check', ''); - const escapedCategory = $.escapeSelector(category); - const category_collaspable = $(`#${escapedCategory}_body`); - category_collaspable.find('input[type="checkbox"]').prop({checked}); + // When a plot configuration ID is selected, populate the plot configuration post textbox + const plotConfigElts = ["statistical_test", "pval_cutoff", "cutoff_filter_action", "log_base", "fc_cutoff", "standard_deviation"]; + for (const elt of plotConfigElts) { + // if value is empty, set to "None", or if disabled, set to "N/A" + let value = document.getElementById(elt).disabled ? "N/A" : document.getElementById(elt).value || "None" - // Expand collaspable since category was focused on - category_collaspable.collapse('show'); - - update_axis_labels(); -}) - -$(document).on('click', '.js-cat-collapse', function (e) { - // If category was clicked, then toggle collapsable element - // Controlling via JS instead of "data-target" since we may need to escape CSS selectors - const { id } = this; - const category = id.replace('_collapse', ''); - const escapedCategory = $.escapeSelector(category); - const category_collaspable = $(`#${escapedCategory}_body`); - category_collaspable.collapse('toggle'); -}) + // Append extra flavor text + if (elt == "log_base" && !(value === "raw" )) { + value = `log${value}` + } -$(document).on('change', '.js-group-check', function(e) { - // https://css-tricks.com/indeterminate-checkboxes/ - // After changing checkbox status, check siblings - // and determine if category checkbox should be - // checked, not checked, or indeterminate - - const checked = $(this).prop("checked"); - - // Get category name out of the checkbox ID - const id = $(this).data("group"); - const category = id.split(';-;')[0] - const escapedCategory = $.escapeSelector(category); - const category_header = $(`#${escapedCategory}_check`); - const category_collaspable = $(`#${escapedCategory}_body`); - - // Get checked status of all other checkboxes in this category - // If there is a combination of checked/unchecked the "each" loop breaks early - let all = true; - $(category_collaspable).find('input[type="checkbox"]').each(function(){ - return all = ($(this).prop("checked") === checked); - }); + if (elt == "standard_deviation" && !(value === "0" )) { + value = `±${value}` + } - if (all) { - // All group checkboxes are the same as the category checkbox - category_header.prop({ - "indeterminate": false, - "checked": checked - }); - } else { - // All group checkbox states are mixed. Category checkbox is indeterminate and unchecked - category_header.prop({ - "indeterminate": true, - "checked": false - }); + document.getElementById(`${elt}_post`).textContent = value; } - update_axis_labels(); -}) - -function update_axis_labels() { - // Update the "axis label" input boxes - if ($("#condition_x_tab").hasClass("active")) { - const curr_condition_x = update_selected_conditions(); - $('#x_label').val(stringify_all_conditions(curr_condition_x)); - } else { - // on #condition_y_tab - const curr_condition_y = update_selected_conditions(); - $('#y_label').val(stringify_all_conditions(curr_condition_y)); - } } -// Get all chosen condition groups and return as a semicolon-joined string -function stringify_all_conditions(condition) { - const all_conditions = [] - for (const property in condition) { - // If no groups for an observation are selected, delete filter - if (condition[property].length) { - all_conditions.push(...condition[property]) - - } - } - return all_conditions.join(";"); - } - - function sanitize_condition(condition) { - const sanitized_condition = {} - for (const property in condition) { - // If no groups for an observation are selected, delete filter - if (condition[property].length) { - sanitized_condition[property] = condition[property]; - } - } - return sanitized_condition; +const getSeriesItems = (series) => { + return facetWidget.aggregations.find((agg) => agg.name === series).items; } -$(document).on("build_jstrees", () => populate_dataset_selection_controls()); +const getSeriesNames = (seriesItems) => { + return seriesItems.map((item) => item.name); +} -async function populate_dataset_selection_controls() { - const dataset_id = getUrlParameter("dataset_id"); - $('#pre_dataset_spinner').show(); - await $.ajax({ - type: "POST", - url: "./cgi/get_h5ad_dataset_list.cgi", - data: { - session_id: CURRENT_USER.session_id, - for_page: "compare_dataset", - include_dataset_id: dataset_id, - }, - dataType: "json", - success(data) { - let counter = 0 - // Populate select box with dataset information owned by the user - const user_datasets = []; - if (data.user.datasets.length > 0) { - // User has some profiles - $.each(data.user.datasets, (_i, item) => { - if (item) { - user_datasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }); - } - // Next, add datasets shared with the user - const shared_datasets = []; - if (data.shared_with_user.datasets.length > 0) { - // User has some profiles - $.each(data.shared_with_user.datasets, (_i, item) => { - if (item) { - shared_datasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }); - } - // Now, add public datasets - const domain_datasets = []; - if (data.public.datasets.length > 0) { - // User has some profiles - $.each(data.public.datasets, (_i, item) => { - if (item) { - domain_datasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }); - } +const handleGetComparisonError = (datasetID, conditionX, conditionY) => { + const msg = `Could not fetch dataset comparison. Please contact the gEAR team.`; + createToast(msg); + console.error(msg); +} - dataset_tree.userDatasets = user_datasets; - dataset_tree.sharedDatasets = shared_datasets; - dataset_tree.domainDatasets = domain_datasets; - dataset_tree.generateTree(); - - // was there a requested dataset ID already? - if (dataset_id !== undefined) { - $("#dataset_id").val(dataset_id); - try { - // Had difficulties triggering a "select_node.jstree" event, so just add the data info here - const tree_leaf = dataset_tree.treeData.find(e => e.dataset_id === dataset_id); - $('#dataset_id').text(tree_leaf.text); - $('#dataset_id').data("organism-id", tree_leaf.organism_id); - $('#dataset_id').data("dataset-id", tree_leaf.dataset_id); - $("#dataset_id").trigger("change"); - } catch { - console.error(`Dataset id ${dataset_id} was not returned as a public/private/shared dataset`); - } - } - }, - error(xhr, status, msg) { - report_error(`Failed to load dataset list because msg: ${msg}`); - }, - }); - $('#pre_dataset_spinner').hide(); +/* Transform and load dataset data into a "tree" format */ +const loadDatasetTree = async () => { + const userDatasets = []; + const sharedDatasets = []; + const domainDatasets = []; + try { + const datasetData = await fetchDatasets(); + + let counter = 0; + + // Populate select box with dataset information owned by the user + if (datasetData.user.datasets.length > 0) { + // User has some profiles + for (const item of datasetData.user.datasets) { + if (item) { + userDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); + } + }; + } + // Next, add datasets shared with the user + if (datasetData.shared_with_user.datasets.length > 0) { + for (const item of datasetData.shared_with_user.datasets) { + if (item) { + sharedDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); + } + }; + } + // Now, add public datasets + if (datasetData.public.datasets.length > 0) { + for (const item of datasetData.public.datasets) { + if (item) { + domainDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); + } + }; + } + datasetTree.userDatasets = userDatasets; + datasetTree.sharedDatasets = sharedDatasets; + datasetTree.domainDatasets = domainDatasets; + datasetTree.generateTree(); + } catch (error) { + document.getElementById("dataset_s_failed").classList.remove("is-hidden"); + } } -function plot_data_to_graph(data) { - $("#selection_methods_c").show(); +/* Transform and load gene collection data into a "tree" format */ +const loadGeneCarts = async () => { + try { + const geneCartData = await fetchGeneCarts(); + const carts = {}; + const cartTypes = ['domain', 'user', 'group', 'shared', 'public']; + let cartsFound = false; + + // Loop through the different types of gene collections and add them to the carts object + for (const ctype of cartTypes) { + carts[ctype] = []; + + if (geneCartData[`${ctype}_carts`].length > 0) { + cartsFound = true; + + for (const item of geneCartData[`${ctype}_carts`]) { + carts[ctype].push({value: item.id, text: item.label }); + }; + } + } + + geneCartTree.domainGeneCarts = carts.domain; + geneCartTree.userGeneCarts = carts.user; + geneCartTree.groupGeneCarts = carts.group; + geneCartTree.sharedGeneCarts = carts.shared; + geneCartTree.publicGeneCarts = carts.public; + geneCartTree.generateTree(); + /*if (!cartsFound ) { + // ? Put some warning if carts not found + $('#gene_cart_container').show(); + }*/ + + } catch (error) { + document.getElementById("gene_s_failed").classList.remove("is-hidden"); + } +} - const point_labels = []; - let perform_ranking = false; +const plotDataToGraph = (data) => { - if ($("#statistical_test").val()) { - perform_ranking = true; - } + const statisticalTest = document.getElementById("statistical_test").value; - let plotdata = null; + const pointLabels = []; + const performRanking = statisticalTest ? true : false; - if (!perform_ranking) { - for (i = 0; i < data.symbols.length; i++) { - point_labels.push(`Gene symbol: ${data.symbols[i]}`); - } + const plotData = []; - plotdata = [ - { - id: data.symbols, - pvals: data.pvals_adj, - x: data.x, - y: data.y, - foldchange: data.fold_changes, - mode: "markers", - type: "scatter", - text: point_labels, - marker: { - color: "#2F103E", - size: 4, - }, - }, - ]; - } else { - const pval_cutoff = parseFloat($("#test_pval_cutoff").val()); + if (performRanking) { + const pValCutoff = document.getElementById("pval_cutoff").value; + const pvalCutoff = parseFloat(pValCutoff); const passing = { x: [], y: [], labels: [], id: [], pvals: [], foldchange: []}; const failing = { x: [], y: [], labels: [], id: [], pvals: [], foldchange: []}; - for (i = 0; i < data.x.length; i++) { - // pvals_adj array consist of 1-element arrays, so let's flatten to prevent potential issues - // Caused by rank_genes_groups output (1 inner array per query comparison group) - data.pvals_adj = data.pvals_adj.flat(); + data.x.forEach((trace, i) => { + // pvals_adj array consist of 1-element arrays, so let's flatten to prevent potential issues + // Caused by rank_genes_groups output (1 inner array per query comparison group) + data.pvals_adj = data.pvals_adj.flat(); + + const thisPval = parseFloat(data.pvals_adj[i]); - const this_pval = parseFloat(data.pvals_adj[i]); + const arrayToPushInto = (thisPval <= pvalCutoff) ? passing : failing; - if ((this_pval <= pval_cutoff)) { - // good scoring match - passing.x.push(data.x[i]); - passing.y.push(data.y[i]); - passing.foldchange.push(data.fold_changes[i]); - passing.labels.push( + arrayToPushInto.x.push(trace); + arrayToPushInto.y.push(data.y[i]); + arrayToPushInto.foldchange.push(data.fold_changes[i]); + arrayToPushInto.labels.push( "Gene symbol: " + data.symbols[i] + " P-value: " + - this_pval.toPrecision(6) + thisPval.toPrecision(6) ); - passing.id.push(data.symbols[i]); - passing.pvals.push(data.pvals_adj[i]); + arrayToPushInto.id.push(data.symbols[i]); + arrayToPushInto.pvals.push(data.pvals_adj[i]); + + }); + + const passColor = CURRENT_USER.colorblind_mode ? 'rgb(0, 34, 78)' : "#FF0000"; + const failColor = CURRENT_USER.colorblind_mode ? 'rgb(254, 232, 56)' : "#A1A1A1"; + + const statAction = document.getElementById("cutoff_filter_action").value; + if (statAction === "colorize") { + const passingObj = { + id: passing.id, + pvals: passing.pvals, + x: passing.x, + y: passing.y, + foldchange: passing.foldchange, + mode: "markers", + name: "Passed cutoff", + type: "scatter", + text: passing.labels, + marker: { + color: new Array(passing.x.length).fill(passColor, 0, passing.x.length), + size: 4, + }, + } + // store original marker color as a deep copy + passingObj.marker.origColor = JSON.parse(JSON.stringify(passingObj.marker.color)); + + const failingObj = { + id: failing.id, + pvals: failing.pvals, + x: failing.x, + y: failing.y, + foldchange: failing.foldchange, + mode: "markers", + name: "Did not pass cutoff", + type: "scatter", + text: failing.labels, + marker: { + color: new Array(failing.x.length).fill(failColor, 0, failing.x.length), + size: 4, + }, + } + // store original marker color as a deep copy + failingObj.marker.origColor = JSON.parse(JSON.stringify(failingObj.marker.color)); + + plotData.push(passingObj); + plotData.push(failingObj); } else { - // this one didn't pass the p-value cutoff - failing.x.push(data.x[i]); - failing.y.push(data.y[i]); - failing.foldchange.push(data.fold_changes[i]); - failing.labels.push( - "Gene symbol: " + - data.symbols[i] + - " P-value: " + - this_pval.toPrecision(6) - ); - failing.id.push(data.symbols[i]); - failing.pvals.push(data.pvals_adj[i]); - } + + const passingObj = { + id: passing.id, + pvals: passing.pvals, + x: passing.x, + y: passing.y, + foldchange: passing.foldchange, + mode: "markers", + type: "scatter", + text: passing.labels, + marker: { + color: new Array(passing.x.length).fill("#000000" , 0, passing.x.length), + size: 4, + }, + } + // store original marker color as a deep copy + passingObj.marker.origColor = JSON.parse(JSON.stringify(passingObj.marker.color)); + + plotData.push(passingObj); } - pass_color = CURRENT_USER.colorblind_mode ? 'rgb(0, 34, 78)' : "#FF0000"; - fail_color = CURRENT_USER.colorblind_mode ? 'rgb(254, 232, 56)' : "#A1A1A1"; + } else { + for (const gene of data.symbols) { + pointLabels.push(`Gene symbol: ${gene}`); + } - plotdata = $("input[name='stat_action']:checked").val() == "colorize" ? [ - { - id: passing.id, - pvals: passing.pvals, - x: passing.x, - y: passing.y, - foldchange: passing.foldchange, - mode: "markers", - name: "Passed cutoff", - type: "scatter", - text: passing.labels, - marker: { - color: pass_color, - size: 4, - }, - }, - { - id: failing.id, - pvals: failing.pvals, - x: failing.x, - y: failing.y, - foldchange: failing.foldchange, - mode: "markers", - name: "Did not pass cutoff", - type: "scatter", - text: failing.labels, - marker: { - color: fail_color, - size: 4, - }, - }, - ] : [ - { - id: passing.id, - pvals: passing.pvals, - x: passing.x, - y: passing.y, - foldchange: passing.foldchange, + const dataObj = { + id: data.symbols, + pvals: data.pvals_adj, + x: data.x, + y: data.y, + foldchange: data.fold_changes, mode: "markers", type: "scatter", - text: passing.labels, + text: pointLabels, marker: { - color: "#2F103E", - size: 4, + color: new Array(data.x.length).fill("#000000", 0, data.x.length), + size: 4, }, - }, - ]; + } + // store original marker color as a deep copy + dataObj.marker.origColor = JSON.parse(JSON.stringify(dataObj.marker.color)); + + plotData.push(dataObj); } const layout = { - title: $("#dataset_id").text(), + title: titleText || "Dataset Comparison", xaxis: { - title: $('#x_label').val().length ? $('#x_label').val() : JSON.stringify(data.condition_x_idx), - type: "", + title: xaxisText || data.condition_x.join(", "), }, yaxis: { - title: $('#y_label').val().length ? $('#y_label').val() : JSON.stringify(data.condition_y_idx), - type: "", + title: yaxisText || data.condition_y.join(", "), }, annotations: [], - margin: { t: 40 }, hovermode: "closest", dragmode: "select", - modebar: {orientation: "v"} }; - annotation_color = CURRENT_USER.colorblind_mode ? 'rgb(125, 124, 118)' : "crimson"; - - // Take genes to search for and highlight their datapoint in the plot - const genes_not_found = []; - if ($('#highlighted_genes').val()) { - const searched_genes = $('#highlighted_genes').val().replace(/,?\s/g, ",").split(","); - searched_genes.forEach((gene) => { - let found = false; - plots: - for (i = 0; i < plotdata.length; i++) { - genes: - for (j = 0; j < plotdata[i].id.length; j++) { - if (gene.toLowerCase() === plotdata[i].id[j].toLowerCase() ) { - // If gene is found add an annotation arrow - layout.annotations.push({ - xref: "x", - yref: "y", - x: plotdata[i].x[j], - y: plotdata[i].y[j], - text:plotdata[i].id[j], - font: { - color: annotation_color, - }, - showarrow: true, - arrowcolor: annotation_color, - }); - found = true; - break plots; - } - } - } - if (! found) - genes_not_found.push(gene); - }); + const config = { + editable: true, // allows user to edit plot title, axis labels, and legend + showLink: false } + const plotContainer = document.getElementById("plot_container"); + plotContainer.replaceChildren(); // erase plot - $("#plot_loading").hide(); - const graphDiv = document.getElementById("myChart"); - Plotly.newPlot(graphDiv, plotdata, layout, { showLink: false }); - $("#selected_label").hide(); - $("#controls_label").show(); - $('#weighted_gene_cart_c').show(); + // NOTE: Plot initially is created to a default width but is responsive. + // Noticed container within our "column" will make full-width go beyond the screen - // If searched-for genes were not found, display under plot - if (genes_not_found.length) { - const genes_not_found_str = genes_not_found.join(", "); - $("#genes_not_found").text(`Searched genes not found: ${genes_not_found_str}`); - $("#genes_not_found").show(); - } else { - $("#genes_not_found").hide(); - } + const plotlyPreview = document.createElement("div"); + plotlyPreview.id = "plotly_preview"; + plotlyPreview.classList.add("container", "is-max-desktop"); + plotContainer.append(plotlyPreview); - // If plot data is selected, create the right-column table and do other misc things - graphDiv.on("plotly_selected", (eventData) => { - selected_data = eventData; - selected_gene_data = []; - - eventData.points.forEach((pt) => { - // Some warnings on using toFixed() here: https://stackoverflow.com/a/12698296/1368079 - // Each trace has its own "pointNumber" ids so gene symbols and pvalues needed to be passed in for each plotdata trace - selected_gene_data.push({ - gene_symbol: pt.data.id[pt.pointNumber], - pvals: $("#statistical_test").val() ? pt.data.pvals[pt.pointNumber].toExponential(2) : "NA", - foldchange: pt.data.foldchange[pt.pointNumber].toFixed(1), - }); - }); + Plotly.purge("plotly_preview"); // clear old Plotly plots - // Sort by adjusted p-value in descending order either by fold change or p-values - selected_gene_data.sort((a, b) => b.foldchange - a.foldchange); - if ($("#statistical_test").val()) - selected_gene_data.sort((a, b) => a.pvals - b.pvals); + Plotly.newPlot("plotly_preview", plotData, layout, config); - const template = $.templates("#selected_genes_tmpl"); - const htmlOutput = template.render(selected_gene_data); - $("#selected_genes_c").html(htmlOutput); + // If plot data is selected, create the right-column table and do other misc things + plotlyPreview.on("plotly_selected", (eventData) => { + + // Hide selected genes table and disable unweighted radio button if no genes are selected + document.getElementById("tbl_selected_genes").classList.add("is-hidden"); + document.getElementById("download_selected_genes_btn").classList.add("is-hidden"); + document.querySelector("input[name='genecart_type'][value='unweighted']").disabled = true; + document.querySelector("input[name='genecart_type'][value='unweighted']").parentElement.setAttribute("disabled", "disabled"); + + if (eventData?.points.length) { + document.getElementById("tbl_selected_genes").classList.remove("is-hidden"); + document.getElementById("download_selected_genes_btn").classList.remove("is-hidden"); + document.querySelector("input[name='genecart_type'][value='unweighted']").disabled = false; + document.querySelector("input[name='genecart_type'][value='unweighted']").parentElement.removeAttribute("disabled"); + + adjustGeneTableLabels(); + populateGeneTable(eventData); + } + + // Get genes from gene tags + const geneTags = document.querySelectorAll("#gene_tags span.tag"); + const searchedGenes = []; + for (const tag of geneTags) { + searchedGenes.push(tag.textContent); + } // Highlight table rows that match searched genes - if ($('#highlighted_genes').val()) { - const searched_genes = $('#highlighted_genes').val().replace(/\s/g, "").split(","); + if (searchedGenes) { + const geneTableBody = document.getElementById("gene_table_body"); // Select the first column (gene_symbols) in each row - $("#selected_genes_c tr td:first-child").each(function() { - const table_gene = $(this).text(); - searched_genes.forEach((gene) => { - if (gene.toLowerCase() === table_gene.toLowerCase() ) { - $(this).parent().addClass("table-success"); + for (const row of geneTableBody.children) { + const tableGene = row.children[0].textContent; + for (const gene of searchedGenes) { + if (gene.toLowerCase() === tableGene.toLowerCase() ) { + row.classList.add("has-background-success-light"); } - }); - }) + }; + } } + }); - // toggle visibilities - $("#selection_methods_c").hide(); - $("#saved_gene_cart_info_c").hide(); - $("#gene_list_c").show(); - - $("#controls_label").hide(); - $("#selected_label").show(); - + // Handler for when plot text is edited + plotlyPreview.on("plotly_relayout", (eventData) => { + // If plot title, x-axis or y-axis is edited, save the text + if (eventData?.["title.text"]) { + titleText = eventData["title.text"]; + } + if (eventData?.["xaxis.title.text"]) { + xaxisText = eventData["xaxis.title.text"]; + } + if (eventData?.["yaxis.title.text"]) { + yaxisText = eventData["yaxis.title.text"]; + } }); - window.onresize = () => { - Plotly.Plots.resize(graphDiv); - }; + const plotlyNote = document.createElement("div"); + plotlyNote.id = "tip_on_editing"; + plotlyNote.classList.add("notification", "content", "is-info", "is-light"); + plotlyNote.innerHTML = `

Tip: Use the Plotly box and lasso select tools (upper-right) to select genes to view as a table.

+ +

You can also click the plot title or axis labels to edit them. Hit Enter to apply edit.

`; + plotlyPreview.append(plotlyNote); } -function save_gene_cart() { - // must have access to USER_SESSION_ID - const gc = new GeneCart({ - session_id: CURRENT_USER.session_id, - label: $("#gene_cart_name").val(), - gctype: 'unweighted-list', - organism_id: $("#dataset_id").data('organism-id'), - is_public: 0 - }); - selected_data.points.forEach((pt) => { - const gene = new Gene({ - id: plot_data.gene_ids[pt.pointNumber], - gene_symbol: plot_data.symbols[pt.pointNumber], +const populateGeneTable = (data) => { + const statisticalTest = document.getElementById("statistical_test").value; + + selectedGeneData = []; + + data.points.forEach((pt) => { + // Some warnings on using toFixed() here: https://stackoverflow.com/a/12698296/1368079 + // Each trace has its own "pointNumber" ids so gene symbols and pvalues needed to be passed in for each plotdata trace + selectedGeneData.push({ + gene_symbol: pt.data.id[pt.pointNumber], + pval: statisticalTest ? pt.data.pvals[pt.pointNumber].toExponential(2) : "NA", + foldchange: pt.data.foldchange[pt.pointNumber].toFixed(1), + x: pt.data.x[pt.pointNumber].toFixed(1), + y: pt.data.y[pt.pointNumber].toFixed(1) }); - gc.add_gene(gene); }); - gc.save(update_ui_after_gene_cart_save_success, update_ui_after_gene_cart_save_failure); + // Sort by adjusted p-value in descending order either by fold change or p-values + selectedGeneData.sort((a, b) => b.foldchange - a.foldchange); + if (statisticalTest) + selectedGeneData.sort((a, b) => a.pval - b.pval); + + + const geneTableBody = document.getElementById("gene_table_body"); + geneTableBody.replaceChildren(); + + for (const gene of selectedGeneData) { + const row = document.createElement("tr"); + row.innerHTML = `${gene.gene_symbol}${gene.pval}${gene.foldchange}`; + geneTableBody.appendChild(row); + } + + // If not statistical test, delete p-value column + if (!statisticalTest) { + const pvalColumn = document.querySelector("#tbl_selected_genes thead tr th:nth-child(2)"); + pvalColumn.remove(); + for (const pvalCell of document.querySelectorAll("#tbl_selected_genes tbody tr td:nth-child(2)")) { + pvalCell.remove(); + } + } + // Should be sorted by logFC now + } -function save_weighted_gene_cart() { - // must have access to USER_SESSION_ID - let foldchange_label = "FC" - - const foldchange_to_save = Number($('input[name=foldchange_to_save]:checked').val()); - - switch (foldchange_to_save) { - case 2: - foldchange_label = "Log2FC"; - break; - case 10: - foldchange_label = "Log10FC"; - break; - default: // 'raw' - foldchange_label = foldchange_label; +const populatePostCompareBox = (scope, series, groups) => { + // Find box + const boxElt = document.querySelector(`#${scope}_post_c .notification`); + boxElt.replaceChildren(); + + // Add series as mini-subtitle and group as tag + const seriesElt = document.createElement("div"); + seriesElt.classList.add("has-text-weight-semibold", "mb-2"); + seriesElt.textContent = series; + boxElt.append(seriesElt); + + const tagsElt = document.createElement("div"); + tagsElt.classList.add("tags"); + boxElt.append(tagsElt); + + for (const group of groups) { + + const groupElt = document.createElement("span"); + groupElt.classList.add("tag", "is-dark", "is-rounded"); + groupElt.textContent = group; + tagsElt.append(groupElt); + } +} + +const sanitizeCondition = (condition) => { + const sanitized_condition = {} + for (const property in condition) { + // If no groups for an observation are selected, delete filter + if (condition[property].length) { + sanitized_condition[property] = condition[property]; + } } + return sanitized_condition; +} + +const saveGeneCart = () => { + // must have access to USER_SESSION_ID + const gc = new GeneCart({ + session_id: sessionId + , label: document.getElementById("new_genecart_label").value + , gctype: "unweighted-list" + , organism_id: organismId + , is_public: 0 + }); + + for (const sg of selectedGeneData) { + const gene = new Gene({ + id: sg.ensembl_id, // Ensembl ID stored in "customdata" property + gene_symbol: sg.gene_symbol, + }); + gc.addGene(gene); + } + + gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); +} - const weight_labels = [foldchange_label]; +const saveWeightedGeneCart = () => { + + // must have access to USER_SESSION_ID + + + // Saving raw FC by default so it is easy to transform weight as needed + const weightLabels = ["FC"]; const gc = new WeightedGeneCart({ - session_id: CURRENT_USER.session_id, - label: $("#weighted_gene_cart_name").val(), - gctype: 'weighted-list', - organism_id: $("#dataset_id").data('organism-id'), - is_public: 0 - }, weight_labels); - - - plot_data.gene_ids.forEach((gene_id, i) => { - let foldchange = plot_data.fold_changes[i] // currently "raw" values - - switch (foldchange_to_save) { - case 2: - foldchange = Math.log2(foldchange); - break; - case 10: - foldchange = Math.log10(foldchange); - break; - default: // 'raw' - foldchange = foldchange; - } + session_id: sessionId + , label: document.getElementById("new_genecart_label").value + , gctype: 'weighted-list' + , organism_id: organismId + , is_public: 0 + }, weightLabels); - const weights = [foldchange]; + compareData.gene_ids.forEach((gene_id, i) => { + const weights = [compareData.fold_changes[i]]; const gene = new WeightedGene({ id: gene_id, - gene_symbol: plot_data.symbols[i] + gene_symbol: compareData.symbols[i] }, weights); - gc.add_gene(gene); + gc.addGene(gene); }); - gc.save(update_ui_after_weighted_gene_cart_save_success, update_ui_after_weighted_gene_cart_save_failure); + gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); } -// Sort selected gene table (using already generated table data) // Taken from https://www.w3schools.com/howto/howto_js_sort_table.asp -function sortTable(n) { +const sortGeneTable = (mode) => { let table; let rows; let switching; @@ -875,81 +981,453 @@ function sortTable(n) { /* Loop through all table rows (except the first, which contains table headers): */ for (i = 1; i < rows.length - 1; i++) { - // Start by saying there should be no switching: - shouldSwitch = false; - /* Get the two elements you want to compare, - one from current row and one from the next: */ - x = rows[i].getElementsByTagName("td")[n]; - y = rows[i + 1].getElementsByTagName("td")[n]; - /* Check if the two rows should switch place, - based on the direction, asc or desc: */ - if (dir == "asc") { - // First column is gene_symbol... rest are numbers - if (n === 0 && x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) { - // If so, mark as a switch and break the loop: - shouldSwitch = true; - break; - } - if (Number(x.innerHTML) > Number(y.innerHTML)) { - shouldSwitch = true; - break; - } - } else if (dir == "desc") { - if (n === 0 && x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) { - // If so, mark as a switch and break the loop: - shouldSwitch = true; - break; - } - if (Number(x.innerHTML) < Number(y.innerHTML)) { - shouldSwitch = true; - break; - } - } + // Start by saying there should be no switching: + shouldSwitch = false; + /* Get the two elements you want to compare, + one from current row and one from the next: */ + x = rows[i].getElementsByTagName("td")[mode]; + y = rows[i + 1].getElementsByTagName("td")[mode]; + /* Check if the two rows should switch place, + based on the direction, asc or desc: */ + if (dir == "asc") { + // First column is gene_symbol... rest are numbers + if (mode === 0 && x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) { + // If so, mark as a switch and break the loop: + shouldSwitch = true; + break; + } + if (Number(x.innerHTML) > Number(y.innerHTML)) { + shouldSwitch = true; + break; + } + } else if (dir == "desc") { + if (mode === 0 && x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) { + // If so, mark as a switch and break the loop: + shouldSwitch = true; + break; + } + if (Number(x.innerHTML) < Number(y.innerHTML)) { + shouldSwitch = true; + break; + } + } } if (shouldSwitch) { - /* If a switch has been marked, make the switch - and mark that a switch has been done: */ - rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); - switching = true; - // Each time a switch is done, increase this count by 1: - switchcount++; + /* If a switch has been marked, make the switch + and mark that a switch has been done: */ + rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); + switching = true; + // Each time a switch is done, increase this count by 1: + switchcount++; + + } else { + /* If no switching has been done AND the direction is "asc", + set the direction to "desc" and run the while loop again. */ + if (switchcount == 0 && dir == "asc") { + dir = "desc"; + switching = true; + } + } + } + + // Reset other sort icons to "ascending" state, to show what direction they will sort when clicked + const otherTblHeaders = document.querySelectorAll(`.js-tbl-gene-header:not(:nth-child(${mode + 1}))`); + for (const tblHeader of otherTblHeaders) { + const currIcon = tblHeader.querySelector("i"); + if (mode == 0) { + currIcon.classList.remove("mdi-sort-alphabetical-descending"); + currIcon.classList.add("mdi-sort-alphabetical-ascending"); + } else { + currIcon.classList.remove("mdi-sort-numeric-descending"); + currIcon.classList.add("mdi-sort-numeric-ascending"); + } + } + + // toggle the mdi icons between ascending / descending + // icon needs to reflect the current state of the sort + const selectedTblHeader = document.querySelector(`.js-tbl-gene-header:nth-child(${mode + 1})`); + const currIcon = selectedTblHeader.querySelector("i"); + if (dir == "asc") { + if (mode == 0) { + currIcon.classList.remove("mdi-sort-alphabetical-descending"); + currIcon.classList.add("mdi-sort-alphabetical-ascending"); + } else { + currIcon.classList.remove("mdi-sort-numeric-descending"); + currIcon.classList.add("mdi-sort-numeric-ascending"); + } + } else { + if (mode == 0) { + currIcon.classList.remove("mdi-sort-alphabetical-ascending"); + currIcon.classList.add("mdi-sort-alphabetical-descending"); + } else { + currIcon.classList.remove("mdi-sort-numeric-ascending"); + currIcon.classList.add("mdi-sort-numeric-descending"); + } + } +} + +const updateGeneOptions = (geneSymbols) => { + + const geneSelectElt = document.getElementById("gene_select"); + geneSelectElt.replaceChildren(); + + // Append empty placeholder element + const firstOption = document.createElement("option"); + firstOption.textContent = "Please select a gene"; + geneSelectElt.append(firstOption); + + for (const gene of geneSymbols.sort()) { + const option = document.createElement("option"); + option.textContent = gene; + option.value = gene; + geneSelectElt.append(option); + } + + // Update the nice-select2 element to reflect this. + // This function is always called in the 1st view, so only update that + geneSelect.update(); + +} + +// For a given categorical series (e.g. "celltype"), add checkboxes for each category +const updateGroupOptions = (selectorId, groupsArray, series) => { + + const elt = document.getElementById(selectorId); + elt.classList.remove("is-hidden"); + + // Add categories + for (const group of groupsArray.sort()) { + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = `${selectorId}_${group}`; + checkbox.name = group; + checkbox.value = group; + + const label = document.createElement("label"); + label.classList.add("checkbox"); + label.htmlFor = `${selectorId}_${group}`; + label.textContent = ` ${group}`; + label.prepend(checkbox); + + // If group has aggregations count of 0 (no data after filtering), disable checkbox and label + if (facetWidget.aggregations.find((agg) => agg.name === series).items.find((item) => item.name === group).count === 0) { + checkbox.disabled = true; + label.setAttribute("disabled", "disabled"); + } + + + // create .control div to ensure checkboxs are vertically aligned + const control = document.createElement("div"); + control.classList.add("control", "m-1"); + control.append(label); + + elt.append(control); + } +} + +// Update the plotly graph with the selected genes +const updatePlotAnnotations = (genes) => { + // Take genes to search for and highlight their datapoint in the plot + + const plotlyPreview = document.getElementById("plotly_preview"); + const plotData = plotlyPreview.data; + const layout = plotlyPreview.layout; + + const annotationColor = CURRENT_USER.colorblind_mode ? "orange" : "cyan"; + + layout.annotations = []; + + // Reset all trace colors + for (const trace of plotData) { + trace.marker.color = trace.marker.origColor; + } + + genes.forEach((gene) => { + let found = false; + for (const trace of plotData) { + trace.id.forEach((element, i) => { + if (gene.toLowerCase() !== element.toLowerCase() ) { + return; + } + + // If gene is found add an annotation arrow + layout.annotations.push({ + xref: "x", + yref: "y", + x: trace.x[i], + y: trace.y[i], + text:element, + bgcolor: annotationColor, + showarrow: true, + arrowcolor: annotationColor, + opacity: 0.8, + + }); + + // change trace dot to match annotation + trace.marker.color[i] = annotationColor; + + found = true; + }); + } + }); + + // If no annotations, add warning text that all genes were filtered out + if (!layout.annotations.length && genes.length) { + layout.annotations.push({ + xref: "paper", + yref: "paper", + x: 0, + y: 1, + text: "No selected genes were found in this plot.", + bgcolor: "lightyellow", + showarrow: false, + opacity: 0.8, + }); + } + + // update the Plotly layout + Plotly.relayout(plotlyPreview, layout); +} + +// For plotting options, populate select menus with category groups +const updateSeriesOptions = (classSelector, seriesArray) => { + + for (const elt of document.getElementsByClassName(classSelector)) { + elt.replaceChildren(); + + // Append empty placeholder element + const firstOption = document.createElement("option"); + elt.append(firstOption); + + // Add categories + for (const group of seriesArray.sort()) { + + const option = document.createElement("option"); + option.textContent = group; + option.value = group; + elt.append(option); + } + } +} + +const updateUIAfterGeneCartSaveSuccess = (gc) => { +} + +const updateUIAfterGeneCartSaveFailure = (gc, message) => { + createToast(message); +} + +const validateCompareGroups = (event) => { + + const group = event.target.value; + const checked = event.target.checked; + + for (const innerClassElt of document.getElementsByClassName("js-compare-groups")) { + // BUG: Checking via label click enables X compare group + if (checked) { + // disable unique groups in other compare groups + if (innerClassElt.closest(".js-compare-groups").id !== event.target.closest(".js-compare-groups").id) { + const checkbox = innerClassElt.querySelector(`input[value="${group}"]`); + checkbox.setAttribute("disabled", "disabled"); + checkbox.parentElement.setAttribute("disabled", "disabled"); + } } else { - /* If no switching has been done AND the direction is "asc", - set the direction to "desc" and run the while loop again. */ - if (switchcount == 0 && dir == "asc") { - dir = "desc"; - switching = true; + const checkbox = innerClassElt.querySelector(`input[value="${group}"]`); + checkbox.removeAttribute("disabled"); + checkbox.parentElement.removeAttribute("disabled"); } + } +} + +const validatePlotRequirements = (event) => { + const elt = event.target; + // Reset "status" classes + elt.classList.remove("is-success", "is-danger"); + + for (const plotBtn of document.getElementsByClassName("js-plot-btn")) { + plotBtn.disabled = true; + } + + // We need at least one compare-x and one compare-y checkbox checked + const checkedX = [...document.querySelectorAll(".js-compare-x input:checked")].map((elt) => elt.value); + const checkedY = [...document.querySelectorAll(".js-compare-y input:checked")].map((elt) => elt.value); + + if (checkedX.length && checkedY.length) { + // Enable plot button + for (const plotBtn of document.getElementsByClassName("js-plot-btn")) { + plotBtn.disabled = false; + + document.getElementById("condition_compare_s_failed").classList.add("is-hidden"); } + return; } + + document.getElementById("condition_compare_s_success").classList.add("is-hidden"); +} + +/* --- Event listeners --- */ + +document.getElementById("statistical_test").addEventListener("change", (event) => { + const pvalCutoff = document.getElementById("pval_cutoff"); + const cutoffFilterAction = document.getElementById("cutoff_filter_action"); + pvalCutoff.disabled = event.target.value ? false : true; + cutoffFilterAction.disabled = event.target.value ? false : true; +}); + +// When compare series changes, update the compare groups +for (const classElt of document.getElementsByClassName("js-compare")) { + const compareSeriesNotification = document.getElementById("select_compare_series_notification"); + classElt.addEventListener("change", async (event) => { + const compareSeries = event.target.value; + compareSeriesNotification.classList.remove("is-hidden", "is-danger"); + compareSeriesNotification.classList.add("is-warning"); + compareSeriesNotification.textContent = "Please select a series to compare first"; + + for (const classElt of document.getElementsByClassName("js-compare-groups")) { + classElt.classList.add("is-hidden"); + classElt.replaceChildren(); + } + + if (!compareSeries) return; + + const seriesItems = getSeriesItems(compareSeries); + const seriesNames = getSeriesNames(seriesItems); + + // at least 2 of the series items must have 1+ aggregation total, or else we can't compare + const seriesAggCountsFiltered = seriesItems.filter((item) => item.count > 0); + if (seriesAggCountsFiltered.length < 2) { + compareSeriesNotification.classList.remove("is-warning"); + compareSeriesNotification.classList.add("is-danger"); + compareSeriesNotification.textContent = `At least 2 groups within ${compareSeries} must each have one or more observations (after filtering) to compare`; + return; + } + + compareSeriesNotification.classList.add("is-hidden"); + + updateGroupOptions("compare_x", seriesNames, compareSeries); + updateGroupOptions("compare_y", seriesNames, compareSeries); + }) +} + +// When compare groups change, prevent the same group from being selected in the other compare groups +for (const classElt of document.getElementsByClassName("js-compare-groups")) { + classElt.addEventListener("change", validateCompareGroups); + classElt.addEventListener("change", validatePlotRequirements); } -function update_ui_after_gene_cart_save_success(gc) { - $("#create_gene_cart_dialog").hide("fade"); - $("#saved_gene_cart_info_c > h3").html(`Cart: ${gc.label}`); - $("#gene_cart_member_count").html(gc.genes.length); - $("#saved_gene_cart_info_c").show(); +for (const classElt of document.getElementsByClassName("js-compare-x")) { + classElt.addEventListener("change", (event) => { + // We need at least one compare-x and one compare-y checkbox checked + const checkedX = [...document.querySelectorAll(".js-compare-x input:checked")].map((elt) => elt.value); + const compareSeries = document.getElementById("compare_series").value; + populatePostCompareBox("compare_x", compareSeries, checkedX); + }) } -function update_ui_after_gene_cart_save_failure(gc, message) { - $("#create_gene_cart_dialog").hide("fade"); - $("#saved_gene_cart_info_c > h3").html(`There was an issue saving the gene cart. ${message}`); - $("#saved_gene_cart_info_c").show(); - $("#save_gene_cart").prop("disabled", false); +for (const classElt of document.getElementsByClassName("js-compare-y")) { + classElt.addEventListener("change", (event) => { + const checkedY = [...document.querySelectorAll(".js-compare-y input:checked")].map((elt) => elt.value); + const compareSeries = document.getElementById("compare_series").value; + populatePostCompareBox("compare_y", compareSeries, checkedY); + }) } -function update_ui_after_weighted_gene_cart_save_success(gc) { - $("#saved_weighted_gene_cart_info_c > .status").html(`Cart "${gc.label}" successfully saved.`); - $("#saved_weighted_gene_cart_info_c > .status").removeClass("text-danger").addClass("text-success"); - $("#saved_weighted_gene_cart_info_c").show(); - $("#saved_weighted_gene_cart_info_c > .alert").hide(); +for (const classElt of document.getElementsByClassName("js-plot-btn")) { + classElt.addEventListener("click", getComparisons); } -function update_ui_after_weighted_gene_cart_save_failure(gc, message) { - $("#saved_weighted_gene_cart_info_c > .status").html("There was an issue saving the weighted gene cart."); - $("#saved_weighted_gene_cart_info_c > .status").removeClass("text-success").addClass("text-danger"); - $("#saved_weighted_gene_cart_info_c > .alert").show(); - $("#saved_weighted_gene_cart_info_c > .message").html(message); - $("#saved_weighted_gene_cart_info_c").show(); - $("#save_weighted_gene_cart").prop("disabled", false); +document.getElementById("edit_params").addEventListener("click", (event) => { + event.target.classList.add("is-loading"); + // Hide this view + document.getElementById("content_c").classList.remove("is-hidden"); + // Generate and display "post-plotting" view/container + document.getElementById("post_plot_content_c").classList.add("is-hidden"); + + event.target.classList.remove("is-loading"); +}) + +document.getElementById("clear_genes_btn").addEventListener("click", clearGenes); + +const geneSelectElts = document.querySelectorAll("select.js-gene-select"); +for (const geneSelectElt of geneSelectElts) { + geneSelectElt.addEventListener("change", chooseGene); } + +// code from Bulma documentation to handle modals +document.getElementById("gene_cart_btn").addEventListener("click", ($trigger) => { + const closestButton = $trigger.target.closest(".button"); + const modal = closestButton.dataset.target; + const $target = document.getElementById(modal); + openModal($target); + +}); + +document.getElementById("new_genecart_label").addEventListener("input", (event) => { + const saveBtn = document.getElementById("save_genecart_btn"); + saveBtn.disabled = event.target.value ? false : true; +}); + +document.getElementById("save_genecart_btn").addEventListener("click", (event) => { + event.preventDefault(); + event.target.classList.add("is-loading"); + // get value of genecart radio button group + const geneCartName = document.querySelector("input[name='genecart_type']:checked").value; + if (CURRENT_USER) { + if (geneCartName === "unweighted") { + saveGeneCart(); + } else { + saveWeightedGeneCart(); + } + } + event.target.classList.remove("is-loading"); +}); + +document.getElementById("download_selected_genes_btn").addEventListener("click", downloadSelectedGenes); + +/* --- Entry point --- */ +const handlePageSpecificLoginUIUpdates = async (event) => { + + // Update with current page info + document.querySelector("#header_bar .navbar-item").textContent = "Comparison Tool"; + for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { + elt.classList.remove("is-active"); + } + + document.querySelector("a[tool='compare'").classList.add("is-active"); + + sessionId = CURRENT_USER.session_id; + + if (! sessionId ) { + // TODO: Add master override to prevent other triggers from enabling saving + createToast("Not logged in so saving gene carts is disabled."); + document.getElementById("gene_cart_btn").disabled = true; + } + + + try { + await Promise.all([ + loadDatasetTree(), + loadGeneCarts() + ]); + // If brought here by the "gene search results" page, curate on the dataset ID that referred us + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has("dataset_id")) { + const linkedDatasetId = urlParams.get("dataset_id"); + try { + // find DatasetTree node and trigger "activate" + const foundNode = datasetTree.findFirst(e => e.data.dataset_id === linkedDatasetId); + foundNode.setActive(true); + datasetId = linkedDatasetId; + } catch (error) { + createToast(`Dataset id ${linkedDatasetId} was not found as a public/private/shared dataset`); + throw new Error(error); + } + } + } catch (error) { + logErrorInConsole(error); + } + + +}; \ No newline at end of file diff --git a/www/js/compare_datasets.v2.js b/www/js/compare_datasets.v2.js deleted file mode 100644 index c696b9b4..00000000 --- a/www/js/compare_datasets.v2.js +++ /dev/null @@ -1,1433 +0,0 @@ -'use strict'; - -// SAdkins - 2/15/21 - This is a list of datasets already log10-transformed where if selected will use log10 as the default dropdown option -// This is meant to be a short-term solution until more people specify their data is transformed via the metadata -const LOG10_TRANSFORMED_DATASETS = [ -"320ca057-0119-4f32-8397-7761ea084ed1" -, "df726e89-b7ac-d798-83bf-2bd69d7f3b52" -, "bad48d04-db27-26bc-2324-e88506f751fd" -, "dbd715bf-778a-4923-6fe7-c587987cdb00" -, "c8d99d13-394f-a87f-5d3a-395968fdb619" -, "bee735e5-d180-332c-7892-dd751dd76bb8" -, "c4f16a12-9e98-47be-4335-b8321282919e" -, "17a07bf4-b41a-d9c3-9aa7-b4729390f57a" -, "6a0a2bca-0f86-59d0-4e3d-4457be3a71ff" -, "39e01b71-415f-afa7-0c64-f0e996be0fb7" -, "6482c608-a6bd-d8b1-6bc1-5b53c34ed61c" -, "0c5a4c18-c2a9-930c-6e52-ef411f54eb67" -, "3c02d449-61ab-4bcd-f100-5f5937b1794e" -, "23e3797f-3016-8142-cbe8-69b03131ad95" -, "b16eeb8d-d68e-c7c9-9dc9-a3f4821e9192" -, "b96f448a-315d-549d-6e8a-83cdf1ce1b5c" -, "b0420910-a0fa-e920-152d-420b6275d3af" -, "f1ce4e63-3577-8020-8307-e88f1fb98953" -, "2f79f784-f7f7-7dc3-9b3e-4c87a4346d91" -, "c32835d3-cac4-bb0e-a90a-0b41dec6617a" -, '48bab518-439e-4a17-b868-6b225abf2c73' -, "1b12dde9-1762-7564-8fbd-1b07b750505f" -, "a2dd9f06-5223-0779-8dfc-8dce7a3897e1" -, "f7de7db2-b4cb-ebe3-7f1f-b278f46f1a7f" -, "e34fa5c6-1083-cacb-eedf-23f59f2e005f" -, "0c5fb6b0-31ab-6bfc-075d-76756ccd56b4" -, "a183b2e6-ab38-458a-52a6-5eb014d073da" -, "c4f16a12-9e98-47be-4335-b8321282919e" -, "2a25e445-2776-8913-076f-9a147a43e8b4" -, "2786d849-f11c-2de6-b22e-12c940aafe07" -, "2e3423b3-74db-d436-8357-abb3031d47e9" -, "4cb2ac62-c283-86a9-83cb-2c1b381948f2" -, "d0659d69-1a33-8b84-252c-f7ded46aa3d6" -, "cee5325d-434f-fefe-d2e6-e0be39421951" -, "34f8f131-8158-db83-7df9-db9003797dff" -, "7ddb4965-e710-faf7-ee26-4ce95d7602a8" -, "f122cac5-c79f-8ea2-166e-42415916db11" -, "173ab634-a2b1-87bc-f1ef-d288de0bcd1a" -, "80eadbe6-49ac-8eaf-f2fb-e07706cf117b" -]; - -let sessionId; -let facetWidget; -let datasetId; -let organismId; // Used for saving as gene cart -let compareData;; -let selectedGeneData; -let geneSelect; - -// Storing user's plot text edits, so they can be restored if user replots -let titleText = null; -let xaxisText = null; -let yaxisText = null; - -const datasetTree = new DatasetTree({ - element: document.getElementById("dataset_tree") - , searchElement: document.getElementById("dataset_query") - , selectCallback: (async (e) => { - if (e.node.type !== "dataset") { - return; - } - document.getElementById("current_dataset_c").classList.remove("is-hidden"); - document.getElementById("current_dataset").textContent = e.node.title; - document.getElementById("current_dataset_post").textContent = e.node.title; - - const newDatasetId = e.node.data.dataset_id; - organismId = e.node.data.organism_id; - - // We don't want to needless run this if the same dataset was clicked - if (newDatasetId === datasetId) { - return; - } - - datasetId = newDatasetId; - - // Click to get to next step - document.getElementById("condition_compare_s").click(); - - // Clear "success/failure" icons - for (const elt of document.getElementsByClassName("js-step-success")) { - elt.classList.add("is-hidden"); - } - for (const elt of document.getElementsByClassName("js-step-failure")) { - elt.classList.add("is-hidden"); - } - - const compareSeriesElt = document.getElementById("compare_series"); - compareSeriesElt.parentElement.classList.add("is-loading"); - - // Clear selected gene tags - document.getElementById("gene_tags").replaceChildren(); - - // Clear compare groups - for (const classElt of document.getElementsByClassName("js-compare-groups")) { - classElt.replaceChildren(); - } - - // Creates gene select instance that allows for multiple selection - geneSelect = createGeneSelectInstance("gene_select", geneSelect); - // Populate gene select element - await geneSelectUpdate() - - - // Create facet widget, which will refresh filters - facetWidget = await createFacetWidget(datasetId, null, {}); - document.getElementById("facet_content").classList.remove("is-hidden"); - document.getElementById("selected_facets").classList.remove("is-hidden"); - - // Update compare series options - const catColumns = facetWidget.aggregations.map((agg) => agg.name); - updateSeriesOptions("js-compare", catColumns); - - compareSeriesElt.parentElement.classList.remove("is-loading"); - - }) -}); - -const geneCartTree = new GeneCartTree({ - element: document.getElementById("genecart_tree") - , searchElement: document.getElementById("genecart_query") - , selectCallback: (async (e) => { - if (e.node.type !== "genecart") { - return; - } - - // Get gene symbols from gene cart - const geneCartId = e.node.data.orig_id; - const geneCartMembers = await fetchGeneCartMembers(geneCartId); - const geneCartSymbols = geneCartMembers.map((item) => item.label); - - // Normalize gene symbols to lowercase - const geneSelectSymbols = geneSelect.data.map((opt) => opt.value); - const geneCartSymbolsLowerCase = geneCartSymbols.map((x) => x.toLowerCase()); - - const geneSelectedOptions = geneSelect.selectedOptions.map((opt) => opt.data.value); - - // Get genes from gene cart that are present in dataset's genes. Preserve casing of dataset's genes. - const geneCartIntersection = geneSelectSymbols.filter((x) => geneCartSymbolsLowerCase.includes(x.toLowerCase())); - // Add in already selected genes (union) - const geneSelectIntersection = [...new Set(geneCartIntersection.concat(geneSelectedOptions))]; - - // change all options to be unselected - const origSelect = document.getElementById("gene_select"); - for (const opt of origSelect.options) { - opt.removeAttribute("selected"); - } - - // Assign intersection genes to geneSelect "selected" options - for (const gene of geneSelectIntersection) { - const opt = origSelect.querySelector(`option[value="${gene}"]`); - try { - opt.setAttribute("selected", "selected"); - } catch (error) { - // sanity check - const msg = `Could not add gene ${gene} to gene select.`; - console.warn(msg); - } - } - - geneSelect.update(); - trigger(document.getElementById("gene_select"), "change"); // triggers chooseGene() to load tags - }) -}); - -const adjustGeneTableLabels = () => { - const geneFoldchanges = document.getElementById("tbl_gene_foldchanges"); - const log_base = document.getElementById("log_base").value; - - const spanIcon = document.createElement("span"); - spanIcon.classList.add("icon"); - const i = document.createElement("i"); - i.classList.add("mdi", "mdi-sort-numeric-ascending"); - i.setAttribute("aria-hidden", "true"); - spanIcon.appendChild(i); - geneFoldchanges.appendChild(spanIcon); - - if (log_base === "raw") { - geneFoldchanges.prepend("Fold Change "); - return; - } - geneFoldchanges.prepend(`Log${log_base} Fold Change `); -} - -const appendGeneTagButton = (geneTagElt) => { - // Add delete button - const deleteBtnElt = document.createElement("button"); - deleteBtnElt.classList.add("delete", "is-small"); - geneTagElt.appendChild(deleteBtnElt); - deleteBtnElt.addEventListener("click", (event) => { - // Remove gene from geneSelect - const gene = event.target.parentNode.textContent; - const geneSelectElt = document.getElementById("gene_select"); - geneSelectElt.querySelector(`option[value="${gene}"]`).removeAttribute("selected"); - - geneSelect.update(); - trigger(document.getElementById("gene_select"), "change"); // triggers chooseGene() to load tags - }); - - // ? Should i add ellipses for too many genes? Should I make the box collapsable? -} - -const chooseGene = (event) => { - // Triggered when a gene is selected - - // Delete existing tags - const geneTagsElt = document.getElementById("gene_tags"); - geneTagsElt.replaceChildren(); - - if (!geneSelect.selectedOptions.length) return; // Do not trigger after initial population - - // Update list of gene tags - const sortedGenes = geneSelect.selectedOptions.map((opt) => opt.data.value).sort(); - for (const opt in sortedGenes) { - const geneTagElt = document.createElement("span"); - geneTagElt.classList.add("tag", "is-primary", "mx-1"); - geneTagElt.textContent = sortedGenes[opt]; - appendGeneTagButton(geneTagElt); - geneTagsElt.appendChild(geneTagElt); - } - - document.getElementById("gene_tags_c").classList.remove("is-hidden"); - if (!geneSelect.selectedOptions.length) { - document.getElementById("gene_tags_c").classList.add("is-hidden"); - } - - // If more than 10 tags, hide the rest and add a "show more" button - if (geneSelect.selectedOptions.length > 10) { - const geneTags = geneTagsElt.querySelectorAll("span.tag"); - for (let i = 10; i < geneTags.length; i++) { - geneTags[i].classList.add("is-hidden"); - } - // Add show more button - const showMoreBtnElt = document.createElement("button"); - showMoreBtnElt.classList.add("tag", "button", "is-small", "is-primary", "is-light"); - const numToDisplay = geneSelect.selectedOptions.length - 10; - showMoreBtnElt.textContent = `+${numToDisplay} more`; - showMoreBtnElt.addEventListener("click", (event) => { - const geneTags = geneTagsElt.querySelectorAll("span.tag"); - for (let i = 10; i < geneTags.length; i++) { - geneTags[i].classList.remove("is-hidden"); - } - event.target.remove(); - }); - geneTagsElt.appendChild(showMoreBtnElt); - } - - updatePlotAnnotations(sortedGenes); - -} - -const clearGenes = (event) => { - document.getElementById("clear_genes_btn").classList.add("is-loading"); - geneSelect.clear(); - updatePlotAnnotations([]); - document.getElementById("clear_genes_btn").classList.remove("is-loading"); -} - -const createFacetWidget = async (datasetId, analysisId, filters) => { - document.getElementById("selected_facets_loader").classList.remove("is-hidden") - - const {aggregations, total_count:totalCount} = await fetchAggregations(datasetId, analysisId, filters); - document.getElementById("num_selected").textContent = totalCount; - - - const facetWidget = new FacetWidget({ - aggregations, - filters, - onFilterChange: async (filters) => { - if (filters) { - try { - const {aggregations, total_count:totalCount} = await fetchAggregations(datasetId, analysisId, filters); - facetWidget.updateAggregations(aggregations); - document.getElementById("num_selected").textContent = totalCount; - } catch (error) { - logErrorInConsole(error); - } - } else { - // Save an extra API call - facetWidget.updateAggregations(facetWidget.aggregations); - } - }, - filterHeaderExtraClasses:"has-background-white" - }); - document.getElementById("selected_facets_loader").classList.add("is-hidden") - return facetWidget; -} - -const createGeneSelectInstance = (idSelector, geneSelect=null) => { - // NOTE: Updating the list of genes can be memory-intensive if there are a lot of genes - // and (I've noticed) if multiple select2 elements for genes are present. - - // If object exists, just update it with the revised data and return - if (geneSelect) { - geneSelect.update(); - return geneSelect; - } - - return NiceSelect.bind(document.getElementById(idSelector), { - placeholder: 'To search, start typing a gene name', - searchtext: 'To search, start typing a gene name', - searchable: true, - allowClear: true, - }); -} - -const downloadSelectedGenes = (event) => { - event.preventDefault(); - - // Builds a file in memory for the user to download. Completely client-side. - // plot_data contains three keys: x, y and symbols - // build the file string from this - - // Adjust headers to the plot type - const xLabel = JSON.stringify([...document.querySelectorAll("#compare_x input:checked")].map((elt) => elt.value)); - const yLabel = JSON.stringify([...document.querySelectorAll("#compare_y input:checked")].map((elt) => elt.value)); - - const logBase = document.getElementById("log_base").value; - - let fileContents = - logBase === "raw" - ? "gene_symbol\tp-value\traw fold change\t" - + xLabel + "\t" - + yLabel + "\n" - : "gene_symbol\tp-value\traw fold change\t" - + xLabel + " (log" + logBase +")\t" - + yLabel + " (log" + logBase +")\n"; - - - selectedGeneData.forEach((gene) => { - // Some warnings on using toFixed() here: https://stackoverflow.com/a/12698296/1368079 - fileContents += - `${gene.gene_symbol}\t` - + `${gene.pval}\t` - + `${gene.foldchange}\t` - + `${gene.x}\t` - + `${gene.y}\n`; - }); - - const element = document.createElement("a"); - element.setAttribute( - "href", - `data:text/tab-separated-values;charset=utf-8,${encodeURIComponent(fileContents)}` - ); - element.setAttribute("download", "selected_genes.tsv"); - element.style.display = "none"; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); - - -} - - -const fetchAggregations = async (datasetId, analysisId, filters) => { - try { - const data = await apiCallsMixin.fetchAggregations(datasetId, analysisId, filters) - if (data.hasOwnProperty("success") && data.success < 1) { - throw new Error(data?.message || "Could not fetch number of observations for this dataset. Please contact the gEAR team."); - } - const {aggregations, total_count} = data; - return {aggregations, total_count}; - } catch (error) { - logErrorInConsole(error); - } -} - -const fetchDatasetComparison = async (datasetId, filters, compareKey, conditionX, conditionY, foldChangeCutoff, stDevNumCutoff, logBase, statisticalTestAction) => { - try { - return await apiCallsMixin.fetchDatasetComparison(datasetId, filters, compareKey, conditionX, conditionY, foldChangeCutoff, stDevNumCutoff, logBase, statisticalTestAction); - } catch (error) { - const msg = "Could not fetch dataset comparison. Please contact the gEAR team." - throw new Error(msg); - } -} - -const fetchDatasets = async () => { - try { - return await apiCallsMixin.fetchDatasets(); - } catch (error) { - logErrorInConsole(error); - const msg = "Could not fetch datasets. Please contact the gEAR team." - createToast(msg); - throw new Error(msg); - } -} - -/* Fetch gene collection members */ -const fetchGeneCartMembers = async (geneCartId) => { - try { - const {gene_symbols, success} = await apiCallsMixin.fetchGeneCartMembers(geneCartId); - if (!success) { - throw new Error("Could not fetch gene collection members. You can still enter genes manually."); - } - return gene_symbols; - } catch (error) { - logErrorInConsole(error); - const msg = "Could not fetch gene collection members. You can still enter genes manually."; - createToast(msg); - throw new Error(msg); - } -} - -/* Fetch gene collections */ -const fetchGeneCarts = async () => { - const cartType = "unweighted-list"; - try { - return await apiCallsMixin.fetchGeneCarts(cartType); - } catch (error) { - logErrorInConsole(error); - const msg = "Could not fetch gene collections. You can still enter genes manually."; - createToast(msg); - throw new Error(msg); - } -} - -const fetchGeneSymbols = async (datasetId, analysisId) => { - try { - const data = await apiCallsMixin.fetchGeneSymbols(datasetId, analysisId); - return [...new Set(data.gene_symbols)]; // Dataset may have a gene repeated in it, so resolve this. - } catch (error) { - logErrorInConsole(error); - const msg = "Could not fetch gene symbols for this dataset. Please contact the gEAR team." - createToast(msg); - return []; - } -} - -const geneSelectUpdate = async (analysisId=null) => { - // Populate gene select element - try { - const geneSymbols = await fetchGeneSymbols(datasetId, analysisId); - updateGeneOptions(geneSymbols); // Come from curator specific code - } catch (error) { - logErrorInConsole(error); - } -} - -const getComparisons = async (event) => { - - // set loading icon - event.target.classList.add("is-loading"); - - const filters = JSON.stringify(facetWidget.filters); - - const compareSeries = document.getElementById("compare_series").value - - // Get all checked x and y series - const checkedX = JSON.stringify([...document.querySelectorAll("#compare_x input:checked")].map((elt) => elt.value)); - const checkedY = JSON.stringify([...document.querySelectorAll("#compare_y input:checked")].map((elt) => elt.value)); - - const foldChangeCutoff = document.getElementById("fc_cutoff").value; - const stdDevNumCutoff = document.getElementById("standard_deviation").value; - const logTransformation = document.getElementById("log_base").value; - const statisticalTest = document.getElementById("statistical_test").value; - - try { - const data = await fetchDatasetComparison(datasetId, filters, compareSeries, checkedX, checkedY, foldChangeCutoff, stdDevNumCutoff, logTransformation, statisticalTest); - if (data?.success < 1) { - throw new Error(data?.message || "Could not fetch dataset comparison. Please contact the gEAR team."); - } - compareData = data; - plotDataToGraph(compareData); - - // If any genes selected, update plot annotations (since plot was previously purged) - const sortedGenes = geneSelect.selectedOptions.map((opt) => opt.data.value).sort(); - updatePlotAnnotations(sortedGenes); - - // Show button to add genes to gene cart - document.getElementById("gene_cart_btn_c").classList.remove("is-hidden"); - - // Hide this view - document.getElementById("content_c").classList.add("is-hidden"); - // Generate and display "post-plotting" view/container - document.getElementById("post_plot_content_c").classList.remove("is-hidden"); - - } catch (error) { - console.error(error); - handleGetComparisonError(datasetId, checkedX, checkedY); - } finally { - event.target.classList.remove("is-loading"); - } - - // When a plot configuration ID is selected, populate the plot configuration post textbox - const plotConfigElts = ["statistical_test", "pval_cutoff", "cutoff_filter_action", "log_base", "fc_cutoff", "standard_deviation"]; - for (const elt of plotConfigElts) { - // if value is empty, set to "None", or if disabled, set to "N/A" - let value = document.getElementById(elt).disabled ? "N/A" : document.getElementById(elt).value || "None" - - // Append extra flavor text - if (elt == "log_base" && !(value === "raw" )) { - value = `log${value}` - } - - if (elt == "standard_deviation" && !(value === "0" )) { - value = `±${value}` - } - - document.getElementById(`${elt}_post`).textContent = value; - } - -} - -const getSeriesItems = (series) => { - return facetWidget.aggregations.find((agg) => agg.name === series).items; -} - -const getSeriesNames = (seriesItems) => { - return seriesItems.map((item) => item.name); -} - -const handleGetComparisonError = (datasetID, conditionX, conditionY) => { - const msg = `Could not fetch dataset comparison. Please contact the gEAR team.`; - createToast(msg); - console.error(msg); -} - -/* Transform and load dataset data into a "tree" format */ -const loadDatasetTree = async () => { - const userDatasets = []; - const sharedDatasets = []; - const domainDatasets = []; - try { - const datasetData = await fetchDatasets(); - - let counter = 0; - - // Populate select box with dataset information owned by the user - if (datasetData.user.datasets.length > 0) { - // User has some profiles - for (const item of datasetData.user.datasets) { - if (item) { - userDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }; - } - // Next, add datasets shared with the user - if (datasetData.shared_with_user.datasets.length > 0) { - for (const item of datasetData.shared_with_user.datasets) { - if (item) { - sharedDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }; - } - // Now, add public datasets - if (datasetData.public.datasets.length > 0) { - for (const item of datasetData.public.datasets) { - if (item) { - domainDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }; - } - datasetTree.userDatasets = userDatasets; - datasetTree.sharedDatasets = sharedDatasets; - datasetTree.domainDatasets = domainDatasets; - datasetTree.generateTree(); - } catch (error) { - document.getElementById("dataset_s_failed").classList.remove("is-hidden"); - } -} - -/* Transform and load gene collection data into a "tree" format */ -const loadGeneCarts = async () => { - try { - const geneCartData = await fetchGeneCarts(); - const carts = {}; - const cartTypes = ['domain', 'user', 'group', 'shared', 'public']; - let cartsFound = false; - - // Loop through the different types of gene collections and add them to the carts object - for (const ctype of cartTypes) { - carts[ctype] = []; - - if (geneCartData[`${ctype}_carts`].length > 0) { - cartsFound = true; - - for (const item of geneCartData[`${ctype}_carts`]) { - carts[ctype].push({value: item.id, text: item.label }); - }; - } - } - - geneCartTree.domainGeneCarts = carts.domain; - geneCartTree.userGeneCarts = carts.user; - geneCartTree.groupGeneCarts = carts.group; - geneCartTree.sharedGeneCarts = carts.shared; - geneCartTree.publicGeneCarts = carts.public; - geneCartTree.generateTree(); - /*if (!cartsFound ) { - // ? Put some warning if carts not found - $('#gene_cart_container').show(); - }*/ - - } catch (error) { - document.getElementById("gene_s_failed").classList.remove("is-hidden"); - } -} - -const plotDataToGraph = (data) => { - - const statisticalTest = document.getElementById("statistical_test").value; - - const pointLabels = []; - const performRanking = statisticalTest ? true : false; - - const plotData = []; - - if (performRanking) { - const pValCutoff = document.getElementById("pval_cutoff").value; - const pvalCutoff = parseFloat(pValCutoff); - const passing = { x: [], y: [], labels: [], id: [], pvals: [], foldchange: []}; - const failing = { x: [], y: [], labels: [], id: [], pvals: [], foldchange: []}; - - data.x.forEach((trace, i) => { - // pvals_adj array consist of 1-element arrays, so let's flatten to prevent potential issues - // Caused by rank_genes_groups output (1 inner array per query comparison group) - data.pvals_adj = data.pvals_adj.flat(); - - const thisPval = parseFloat(data.pvals_adj[i]); - - const arrayToPushInto = (thisPval <= pvalCutoff) ? passing : failing; - - arrayToPushInto.x.push(trace); - arrayToPushInto.y.push(data.y[i]); - arrayToPushInto.foldchange.push(data.fold_changes[i]); - arrayToPushInto.labels.push( - "Gene symbol: " + - data.symbols[i] + - " P-value: " + - thisPval.toPrecision(6) - ); - arrayToPushInto.id.push(data.symbols[i]); - arrayToPushInto.pvals.push(data.pvals_adj[i]); - - }); - - const passColor = CURRENT_USER.colorblind_mode ? 'rgb(0, 34, 78)' : "#FF0000"; - const failColor = CURRENT_USER.colorblind_mode ? 'rgb(254, 232, 56)' : "#A1A1A1"; - - const statAction = document.getElementById("cutoff_filter_action").value; - if (statAction === "colorize") { - const passingObj = { - id: passing.id, - pvals: passing.pvals, - x: passing.x, - y: passing.y, - foldchange: passing.foldchange, - mode: "markers", - name: "Passed cutoff", - type: "scatter", - text: passing.labels, - marker: { - color: new Array(passing.x.length).fill(passColor, 0, passing.x.length), - size: 4, - }, - } - // store original marker color as a deep copy - passingObj.marker.origColor = JSON.parse(JSON.stringify(passingObj.marker.color)); - - const failingObj = { - id: failing.id, - pvals: failing.pvals, - x: failing.x, - y: failing.y, - foldchange: failing.foldchange, - mode: "markers", - name: "Did not pass cutoff", - type: "scatter", - text: failing.labels, - marker: { - color: new Array(failing.x.length).fill(failColor, 0, failing.x.length), - size: 4, - }, - } - // store original marker color as a deep copy - failingObj.marker.origColor = JSON.parse(JSON.stringify(failingObj.marker.color)); - - plotData.push(passingObj); - plotData.push(failingObj); - } else { - - const passingObj = { - id: passing.id, - pvals: passing.pvals, - x: passing.x, - y: passing.y, - foldchange: passing.foldchange, - mode: "markers", - type: "scatter", - text: passing.labels, - marker: { - color: new Array(passing.x.length).fill("#000000" , 0, passing.x.length), - size: 4, - }, - } - // store original marker color as a deep copy - passingObj.marker.origColor = JSON.parse(JSON.stringify(passingObj.marker.color)); - - plotData.push(passingObj); - } - - } else { - for (const gene of data.symbols) { - pointLabels.push(`Gene symbol: ${gene}`); - } - - const dataObj = { - id: data.symbols, - pvals: data.pvals_adj, - x: data.x, - y: data.y, - foldchange: data.fold_changes, - mode: "markers", - type: "scatter", - text: pointLabels, - marker: { - color: new Array(data.x.length).fill("#000000", 0, data.x.length), - size: 4, - }, - } - // store original marker color as a deep copy - dataObj.marker.origColor = JSON.parse(JSON.stringify(dataObj.marker.color)); - - plotData.push(dataObj); - } - - const layout = { - title: titleText || "Dataset Comparison", - xaxis: { - title: xaxisText || data.condition_x.join(", "), - }, - yaxis: { - title: yaxisText || data.condition_y.join(", "), - }, - annotations: [], - hovermode: "closest", - dragmode: "select", - }; - - - const config = { - editable: true, // allows user to edit plot title, axis labels, and legend - showLink: false - } - - const plotContainer = document.getElementById("plot_container"); - plotContainer.replaceChildren(); // erase plot - - // NOTE: Plot initially is created to a default width but is responsive. - // Noticed container within our "column" will make full-width go beyond the screen - - const plotlyPreview = document.createElement("div"); - plotlyPreview.id = "plotly_preview"; - plotlyPreview.classList.add("container", "is-max-desktop"); - plotContainer.append(plotlyPreview); - - Plotly.purge("plotly_preview"); // clear old Plotly plots - - Plotly.newPlot("plotly_preview", plotData, layout, config); - - // If plot data is selected, create the right-column table and do other misc things - plotlyPreview.on("plotly_selected", (eventData) => { - - // Hide selected genes table and disable unweighted radio button if no genes are selected - document.getElementById("tbl_selected_genes").classList.add("is-hidden"); - document.getElementById("download_selected_genes_btn").classList.add("is-hidden"); - document.querySelector("input[name='genecart_type'][value='unweighted']").disabled = true; - document.querySelector("input[name='genecart_type'][value='unweighted']").parentElement.setAttribute("disabled", "disabled"); - - if (eventData?.points.length) { - document.getElementById("tbl_selected_genes").classList.remove("is-hidden"); - document.getElementById("download_selected_genes_btn").classList.remove("is-hidden"); - document.querySelector("input[name='genecart_type'][value='unweighted']").disabled = false; - document.querySelector("input[name='genecart_type'][value='unweighted']").parentElement.removeAttribute("disabled"); - - adjustGeneTableLabels(); - populateGeneTable(eventData); - } - - // Get genes from gene tags - const geneTags = document.querySelectorAll("#gene_tags span.tag"); - const searchedGenes = []; - for (const tag of geneTags) { - searchedGenes.push(tag.textContent); - } - - // Highlight table rows that match searched genes - if (searchedGenes) { - const geneTableBody = document.getElementById("gene_table_body"); - // Select the first column (gene_symbols) in each row - for (const row of geneTableBody.children) { - const tableGene = row.children[0].textContent; - for (const gene of searchedGenes) { - if (gene.toLowerCase() === tableGene.toLowerCase() ) { - row.classList.add("has-background-success-light"); - } - }; - } - } - }); - - // Handler for when plot text is edited - plotlyPreview.on("plotly_relayout", (eventData) => { - // If plot title, x-axis or y-axis is edited, save the text - if (eventData?.["title.text"]) { - titleText = eventData["title.text"]; - } - if (eventData?.["xaxis.title.text"]) { - xaxisText = eventData["xaxis.title.text"]; - } - if (eventData?.["yaxis.title.text"]) { - yaxisText = eventData["yaxis.title.text"]; - } - }); - - const plotlyNote = document.createElement("div"); - plotlyNote.id = "tip_on_editing"; - plotlyNote.classList.add("notification", "content", "is-info", "is-light"); - plotlyNote.innerHTML = `

Tip: Use the Plotly box and lasso select tools (upper-right) to select genes to view as a table.

- -

You can also click the plot title or axis labels to edit them. Hit Enter to apply edit.

`; - plotlyPreview.append(plotlyNote); -} - - -const populateGeneTable = (data) => { - const statisticalTest = document.getElementById("statistical_test").value; - - selectedGeneData = []; - - data.points.forEach((pt) => { - // Some warnings on using toFixed() here: https://stackoverflow.com/a/12698296/1368079 - // Each trace has its own "pointNumber" ids so gene symbols and pvalues needed to be passed in for each plotdata trace - selectedGeneData.push({ - gene_symbol: pt.data.id[pt.pointNumber], - pval: statisticalTest ? pt.data.pvals[pt.pointNumber].toExponential(2) : "NA", - foldchange: pt.data.foldchange[pt.pointNumber].toFixed(1), - x: pt.data.x[pt.pointNumber].toFixed(1), - y: pt.data.y[pt.pointNumber].toFixed(1) - }); - }); - - // Sort by adjusted p-value in descending order either by fold change or p-values - selectedGeneData.sort((a, b) => b.foldchange - a.foldchange); - if (statisticalTest) - selectedGeneData.sort((a, b) => a.pval - b.pval); - - - const geneTableBody = document.getElementById("gene_table_body"); - geneTableBody.replaceChildren(); - - for (const gene of selectedGeneData) { - const row = document.createElement("tr"); - row.innerHTML = `${gene.gene_symbol}${gene.pval}${gene.foldchange}`; - geneTableBody.appendChild(row); - } - - // If not statistical test, delete p-value column - if (!statisticalTest) { - const pvalColumn = document.querySelector("#tbl_selected_genes thead tr th:nth-child(2)"); - pvalColumn.remove(); - for (const pvalCell of document.querySelectorAll("#tbl_selected_genes tbody tr td:nth-child(2)")) { - pvalCell.remove(); - } - } - // Should be sorted by logFC now - -} - -const populatePostCompareBox = (scope, series, groups) => { - // Find box - const boxElt = document.querySelector(`#${scope}_post_c .notification`); - boxElt.replaceChildren(); - - // Add series as mini-subtitle and group as tag - const seriesElt = document.createElement("div"); - seriesElt.classList.add("has-text-weight-semibold", "mb-2"); - seriesElt.textContent = series; - boxElt.append(seriesElt); - - const tagsElt = document.createElement("div"); - tagsElt.classList.add("tags"); - boxElt.append(tagsElt); - - for (const group of groups) { - - const groupElt = document.createElement("span"); - groupElt.classList.add("tag", "is-dark", "is-rounded"); - groupElt.textContent = group; - tagsElt.append(groupElt); - } -} - -const sanitizeCondition = (condition) => { - const sanitized_condition = {} - for (const property in condition) { - // If no groups for an observation are selected, delete filter - if (condition[property].length) { - sanitized_condition[property] = condition[property]; - } - } - return sanitized_condition; -} - -const saveGeneCart = () => { - // must have access to USER_SESSION_ID - const gc = new GeneCart({ - session_id: sessionId - , label: document.getElementById("new_genecart_label").value - , gctype: "unweighted-list" - , organism_id: organismId - , is_public: 0 - }); - - for (const sg of selectedGeneData) { - const gene = new Gene({ - id: sg.ensembl_id, // Ensembl ID stored in "customdata" property - gene_symbol: sg.gene_symbol, - }); - gc.addGene(gene); - } - - gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); -} - -const saveWeightedGeneCart = () => { - - // must have access to USER_SESSION_ID - - - // Saving raw FC by default so it is easy to transform weight as needed - const weightLabels = ["FC"]; - - const gc = new WeightedGeneCart({ - session_id: sessionId - , label: document.getElementById("new_genecart_label").value - , gctype: 'weighted-list' - , organism_id: organismId - , is_public: 0 - }, weightLabels); - - compareData.gene_ids.forEach((gene_id, i) => { - const weights = [compareData.fold_changes[i]]; - - const gene = new WeightedGene({ - id: gene_id, - gene_symbol: compareData.symbols[i] - }, weights); - gc.addGene(gene); - }); - - gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); -} - -// Taken from https://www.w3schools.com/howto/howto_js_sort_table.asp -const sortGeneTable = (mode) => { - let table; - let rows; - let switching; - let i; - let x; - let y; - let shouldSwitch; - let dir; - let switchcount = 0; - table = document.getElementById("tbl_selected_genes"); - - switching = true; - // Set the sorting direction to ascending: - dir = "asc"; - /* Make a loop that will continue until - no switching has been done: */ - while (switching) { - // Start by saying: no switching is done: - switching = false; - rows = table.rows; - /* Loop through all table rows (except the - first, which contains table headers): */ - for (i = 1; i < rows.length - 1; i++) { - // Start by saying there should be no switching: - shouldSwitch = false; - /* Get the two elements you want to compare, - one from current row and one from the next: */ - x = rows[i].getElementsByTagName("td")[mode]; - y = rows[i + 1].getElementsByTagName("td")[mode]; - /* Check if the two rows should switch place, - based on the direction, asc or desc: */ - if (dir == "asc") { - // First column is gene_symbol... rest are numbers - if (mode === 0 && x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) { - // If so, mark as a switch and break the loop: - shouldSwitch = true; - break; - } - if (Number(x.innerHTML) > Number(y.innerHTML)) { - shouldSwitch = true; - break; - } - } else if (dir == "desc") { - if (mode === 0 && x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) { - // If so, mark as a switch and break the loop: - shouldSwitch = true; - break; - } - if (Number(x.innerHTML) < Number(y.innerHTML)) { - shouldSwitch = true; - break; - } - } - } - if (shouldSwitch) { - /* If a switch has been marked, make the switch - and mark that a switch has been done: */ - rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); - switching = true; - // Each time a switch is done, increase this count by 1: - switchcount++; - - } else { - /* If no switching has been done AND the direction is "asc", - set the direction to "desc" and run the while loop again. */ - if (switchcount == 0 && dir == "asc") { - dir = "desc"; - switching = true; - } - } - } - - // Reset other sort icons to "ascending" state, to show what direction they will sort when clicked - const otherTblHeaders = document.querySelectorAll(`.js-tbl-gene-header:not(:nth-child(${mode + 1}))`); - for (const tblHeader of otherTblHeaders) { - const currIcon = tblHeader.querySelector("i"); - if (mode == 0) { - currIcon.classList.remove("mdi-sort-alphabetical-descending"); - currIcon.classList.add("mdi-sort-alphabetical-ascending"); - } else { - currIcon.classList.remove("mdi-sort-numeric-descending"); - currIcon.classList.add("mdi-sort-numeric-ascending"); - } - } - - // toggle the mdi icons between ascending / descending - // icon needs to reflect the current state of the sort - const selectedTblHeader = document.querySelector(`.js-tbl-gene-header:nth-child(${mode + 1})`); - const currIcon = selectedTblHeader.querySelector("i"); - if (dir == "asc") { - if (mode == 0) { - currIcon.classList.remove("mdi-sort-alphabetical-descending"); - currIcon.classList.add("mdi-sort-alphabetical-ascending"); - } else { - currIcon.classList.remove("mdi-sort-numeric-descending"); - currIcon.classList.add("mdi-sort-numeric-ascending"); - } - } else { - if (mode == 0) { - currIcon.classList.remove("mdi-sort-alphabetical-ascending"); - currIcon.classList.add("mdi-sort-alphabetical-descending"); - } else { - currIcon.classList.remove("mdi-sort-numeric-ascending"); - currIcon.classList.add("mdi-sort-numeric-descending"); - } - } -} - -const updateGeneOptions = (geneSymbols) => { - - const geneSelectElt = document.getElementById("gene_select"); - geneSelectElt.replaceChildren(); - - // Append empty placeholder element - const firstOption = document.createElement("option"); - firstOption.textContent = "Please select a gene"; - geneSelectElt.append(firstOption); - - for (const gene of geneSymbols.sort()) { - const option = document.createElement("option"); - option.textContent = gene; - option.value = gene; - geneSelectElt.append(option); - } - - // Update the nice-select2 element to reflect this. - // This function is always called in the 1st view, so only update that - geneSelect.update(); - -} - -// For a given categorical series (e.g. "celltype"), add checkboxes for each category -const updateGroupOptions = (selectorId, groupsArray, series) => { - - const elt = document.getElementById(selectorId); - elt.classList.remove("is-hidden"); - - // Add categories - for (const group of groupsArray.sort()) { - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.id = `${selectorId}_${group}`; - checkbox.name = group; - checkbox.value = group; - - const label = document.createElement("label"); - label.classList.add("checkbox"); - label.htmlFor = `${selectorId}_${group}`; - label.textContent = ` ${group}`; - label.prepend(checkbox); - - // If group has aggregations count of 0 (no data after filtering), disable checkbox and label - if (facetWidget.aggregations.find((agg) => agg.name === series).items.find((item) => item.name === group).count === 0) { - checkbox.disabled = true; - label.setAttribute("disabled", "disabled"); - } - - - // create .control div to ensure checkboxs are vertically aligned - const control = document.createElement("div"); - control.classList.add("control", "m-1"); - control.append(label); - - elt.append(control); - } -} - -// Update the plotly graph with the selected genes -const updatePlotAnnotations = (genes) => { - // Take genes to search for and highlight their datapoint in the plot - - const plotlyPreview = document.getElementById("plotly_preview"); - const plotData = plotlyPreview.data; - const layout = plotlyPreview.layout; - - const annotationColor = CURRENT_USER.colorblind_mode ? "orange" : "cyan"; - - layout.annotations = []; - - // Reset all trace colors - for (const trace of plotData) { - trace.marker.color = trace.marker.origColor; - } - - genes.forEach((gene) => { - let found = false; - for (const trace of plotData) { - trace.id.forEach((element, i) => { - if (gene.toLowerCase() !== element.toLowerCase() ) { - return; - } - - // If gene is found add an annotation arrow - layout.annotations.push({ - xref: "x", - yref: "y", - x: trace.x[i], - y: trace.y[i], - text:element, - bgcolor: annotationColor, - showarrow: true, - arrowcolor: annotationColor, - opacity: 0.8, - - }); - - // change trace dot to match annotation - trace.marker.color[i] = annotationColor; - - found = true; - }); - } - }); - - // If no annotations, add warning text that all genes were filtered out - if (!layout.annotations.length && genes.length) { - layout.annotations.push({ - xref: "paper", - yref: "paper", - x: 0, - y: 1, - text: "No selected genes were found in this plot.", - bgcolor: "lightyellow", - showarrow: false, - opacity: 0.8, - }); - } - - // update the Plotly layout - Plotly.relayout(plotlyPreview, layout); -} - -// For plotting options, populate select menus with category groups -const updateSeriesOptions = (classSelector, seriesArray) => { - - for (const elt of document.getElementsByClassName(classSelector)) { - elt.replaceChildren(); - - // Append empty placeholder element - const firstOption = document.createElement("option"); - elt.append(firstOption); - - // Add categories - for (const group of seriesArray.sort()) { - - const option = document.createElement("option"); - option.textContent = group; - option.value = group; - elt.append(option); - } - } -} - -const updateUIAfterGeneCartSaveSuccess = (gc) => { -} - -const updateUIAfterGeneCartSaveFailure = (gc, message) => { - createToast(message); -} - -const validateCompareGroups = (event) => { - - const group = event.target.value; - const checked = event.target.checked; - - for (const innerClassElt of document.getElementsByClassName("js-compare-groups")) { - // BUG: Checking via label click enables X compare group - if (checked) { - // disable unique groups in other compare groups - if (innerClassElt.closest(".js-compare-groups").id !== event.target.closest(".js-compare-groups").id) { - const checkbox = innerClassElt.querySelector(`input[value="${group}"]`); - checkbox.setAttribute("disabled", "disabled"); - checkbox.parentElement.setAttribute("disabled", "disabled"); - } - } else { - const checkbox = innerClassElt.querySelector(`input[value="${group}"]`); - checkbox.removeAttribute("disabled"); - checkbox.parentElement.removeAttribute("disabled"); - } - } -} - -const validatePlotRequirements = (event) => { - const elt = event.target; - // Reset "status" classes - elt.classList.remove("is-success", "is-danger"); - - for (const plotBtn of document.getElementsByClassName("js-plot-btn")) { - plotBtn.disabled = true; - } - - // We need at least one compare-x and one compare-y checkbox checked - const checkedX = [...document.querySelectorAll(".js-compare-x input:checked")].map((elt) => elt.value); - const checkedY = [...document.querySelectorAll(".js-compare-y input:checked")].map((elt) => elt.value); - - if (checkedX.length && checkedY.length) { - // Enable plot button - for (const plotBtn of document.getElementsByClassName("js-plot-btn")) { - plotBtn.disabled = false; - - document.getElementById("condition_compare_s_failed").classList.add("is-hidden"); - } - return; - } - - document.getElementById("condition_compare_s_success").classList.add("is-hidden"); -} - -/* --- Event listeners --- */ - -document.getElementById("statistical_test").addEventListener("change", (event) => { - const pvalCutoff = document.getElementById("pval_cutoff"); - const cutoffFilterAction = document.getElementById("cutoff_filter_action"); - pvalCutoff.disabled = event.target.value ? false : true; - cutoffFilterAction.disabled = event.target.value ? false : true; -}); - -// When compare series changes, update the compare groups -for (const classElt of document.getElementsByClassName("js-compare")) { - const compareSeriesNotification = document.getElementById("select_compare_series_notification"); - classElt.addEventListener("change", async (event) => { - const compareSeries = event.target.value; - compareSeriesNotification.classList.remove("is-hidden", "is-danger"); - compareSeriesNotification.classList.add("is-warning"); - compareSeriesNotification.textContent = "Please select a series to compare first"; - - for (const classElt of document.getElementsByClassName("js-compare-groups")) { - classElt.classList.add("is-hidden"); - classElt.replaceChildren(); - } - - if (!compareSeries) return; - - const seriesItems = getSeriesItems(compareSeries); - const seriesNames = getSeriesNames(seriesItems); - - // at least 2 of the series items must have 1+ aggregation total, or else we can't compare - const seriesAggCountsFiltered = seriesItems.filter((item) => item.count > 0); - if (seriesAggCountsFiltered.length < 2) { - compareSeriesNotification.classList.remove("is-warning"); - compareSeriesNotification.classList.add("is-danger"); - compareSeriesNotification.textContent = `At least 2 groups within ${compareSeries} must each have one or more observations (after filtering) to compare`; - return; - } - - compareSeriesNotification.classList.add("is-hidden"); - - updateGroupOptions("compare_x", seriesNames, compareSeries); - updateGroupOptions("compare_y", seriesNames, compareSeries); - }) -} - -// When compare groups change, prevent the same group from being selected in the other compare groups -for (const classElt of document.getElementsByClassName("js-compare-groups")) { - classElt.addEventListener("change", validateCompareGroups); - classElt.addEventListener("change", validatePlotRequirements); -} - -for (const classElt of document.getElementsByClassName("js-compare-x")) { - classElt.addEventListener("change", (event) => { - // We need at least one compare-x and one compare-y checkbox checked - const checkedX = [...document.querySelectorAll(".js-compare-x input:checked")].map((elt) => elt.value); - const compareSeries = document.getElementById("compare_series").value; - populatePostCompareBox("compare_x", compareSeries, checkedX); - }) -} - -for (const classElt of document.getElementsByClassName("js-compare-y")) { - classElt.addEventListener("change", (event) => { - const checkedY = [...document.querySelectorAll(".js-compare-y input:checked")].map((elt) => elt.value); - const compareSeries = document.getElementById("compare_series").value; - populatePostCompareBox("compare_y", compareSeries, checkedY); - }) -} - -for (const classElt of document.getElementsByClassName("js-plot-btn")) { - classElt.addEventListener("click", getComparisons); -} - -document.getElementById("edit_params").addEventListener("click", (event) => { - event.target.classList.add("is-loading"); - // Hide this view - document.getElementById("content_c").classList.remove("is-hidden"); - // Generate and display "post-plotting" view/container - document.getElementById("post_plot_content_c").classList.add("is-hidden"); - - event.target.classList.remove("is-loading"); -}) - -document.getElementById("clear_genes_btn").addEventListener("click", clearGenes); - -const geneSelectElts = document.querySelectorAll("select.js-gene-select"); -for (const geneSelectElt of geneSelectElts) { - geneSelectElt.addEventListener("change", chooseGene); -} - -// code from Bulma documentation to handle modals -document.getElementById("gene_cart_btn").addEventListener("click", ($trigger) => { - const closestButton = $trigger.target.closest(".button"); - const modal = closestButton.dataset.target; - const $target = document.getElementById(modal); - openModal($target); - -}); - -document.getElementById("new_genecart_label").addEventListener("input", (event) => { - const saveBtn = document.getElementById("save_genecart_btn"); - saveBtn.disabled = event.target.value ? false : true; -}); - -document.getElementById("save_genecart_btn").addEventListener("click", (event) => { - event.preventDefault(); - event.target.classList.add("is-loading"); - // get value of genecart radio button group - const geneCartName = document.querySelector("input[name='genecart_type']:checked").value; - if (CURRENT_USER) { - if (geneCartName === "unweighted") { - saveGeneCart(); - } else { - saveWeightedGeneCart(); - } - } - event.target.classList.remove("is-loading"); -}); - -document.getElementById("download_selected_genes_btn").addEventListener("click", downloadSelectedGenes); - -/* --- Entry point --- */ -const handlePageSpecificLoginUIUpdates = async (event) => { - - // Update with current page info - document.querySelector("#header_bar .navbar-item").textContent = "Comparison Tool"; - for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { - elt.classList.remove("is-active"); - } - - document.querySelector("a[tool='compare'").classList.add("is-active"); - - sessionId = CURRENT_USER.session_id; - - if (! sessionId ) { - // TODO: Add master override to prevent other triggers from enabling saving - createToast("Not logged in so saving gene carts is disabled."); - document.getElementById("gene_cart_btn").disabled = true; - } - - - try { - await Promise.all([ - loadDatasetTree(), - loadGeneCarts() - ]); - // If brought here by the "gene search results" page, curate on the dataset ID that referred us - const urlParams = new URLSearchParams(window.location.search); - if (urlParams.has("dataset_id")) { - const linkedDatasetId = urlParams.get("dataset_id"); - try { - // find DatasetTree node and trigger "activate" - const foundNode = datasetTree.findFirst(e => e.data.dataset_id === linkedDatasetId); - foundNode.setActive(true); - datasetId = linkedDatasetId; - } catch (error) { - createToast(`Dataset id ${linkedDatasetId} was not found as a public/private/shared dataset`); - throw new Error(error); - } - } - } catch (error) { - logErrorInConsole(error); - } - - -}; \ No newline at end of file diff --git a/www/js/dataset_curator.js b/www/js/dataset_curator.js index 30cc654c..809a3a67 100644 --- a/www/js/dataset_curator.js +++ b/www/js/dataset_curator.js @@ -1,3626 +1,1467 @@ +// I use camelCase for my variable/function names to adhere to JS style standards +// Exception being functions that do fetch calls, so we can use JS destructuring on the payload + 'use strict'; -/* - This script relies on the source having also included the - common.js within this project (for login purposes) -*/ - -/* global $ Vue */ - -Vue.use(Vuex); -Vue.use(VueRouter); -Vue.use(BootstrapVue); -Vue.use(BootstrapVueIcons); - -// Install VeeValidate components globally -Vue.component("ValidationObserver", VeeValidate.ValidationObserver); -Vue.component("ValidationProvider", VeeValidate.ValidationProvider); - -window.onload=() => { - const datasetTitle = Vue.component("dataset-title", { - template: ` - -
-

Dataset: {{ title }}

-
-
-
- `, - computed: { - ...Vuex.mapState(["dataset_id", "title"]), - }, - }); - - const addDisplayBtn = Vue.component("add-display-btn", { - template: ` - -
-
- -
-

Add Display

-
-
- `, - data() { - return { - isHovering: false, - displayCard: true, - }; - }, - computed: { - ...Vuex.mapState(["dataset_id"]), - classed() { - return { - hovering: this.isHovering, - "display-card": true, - elevation: true, - "border-0": true, - }; - }, - }, - methods: { - routeToAddDisplay() { - this.$router.push(`displays/new`); - }, - }, - }); - - const tsneChart = Vue.component("tsne-chart", { - template: ` -
-
- -
- -
- `, - props: { - preconfigured: { - default: false, - }, - display_data: {}, - }, - data() { - return { - // local variables to consolidate Vuex version of these, and user/owner versions - display_tsne_is_loading: true, - display_image_data: null, - // stolen from https://gist.github.com/gordonbrander/2230317 - preview_id: `tsne_preview_${Math.random().toString(36).substr(2, 9)}`, - }; - }, - computed: { - ...Vuex.mapState([ - "dataset_id", - "config", - "image_data", - "success", - "message", - "tsne_is_loading", - "colorblind_mode", - ]), - is_loading() { - return (this.preconfigured && this.display_tsne_is_loading == true) || - this.tsne_is_loading; - }, - plot_params_ready() { - return this.config.x_axis && this.config.y_axis; - }, - }, - created() { - if (this.preconfigured) { - this.display_tsne_is_loading = this.tsne_is_loading; - this.display_image_data = this.image_data; - this.draw_image(); - } - }, - watch: { - //TODO: Redo all this - display_image_data() { - if (this.display_image_data) { - // applies to tSNE plots within user/owner display previews - $(`#${this.preview_id}`).addClass("img-fluid"); - } - $(`#${this.preview_id}`).css({ "max-height": "205px" }); - $(`#${this.preview_id}`).attr( - "src", - `data:image/png;base64,${this.display_image_data}` - ); - }, - // This is the Vuex store and is for tsne plots within the DatasetDisplay component - image_data() { - if (!this.preconfigured && this.image_data) { - $(`#${this.preview_id}`).addClass("img-fluid"); - $(`#${this.preview_id}`).attr( - "src", - `data:image/png;base64,${this.image_data}` - ); - } - }, - }, - methods: { - async draw_image() { - const { dataset_id, colorblind_mode } = this; - const { plot_type } = this.display_data; - const config = this.display_data.plotly_config; - const { analysis } = config; - const analysis_owner_id = this.display_data.user_id; - - // This has to be separate from "fetch_tsne_image" because in user/owner displays, different image data may be returned - const payload = { ...config, plot_type, analysis, analysis_owner_id, colorblind_mode}; - const { data } = await axios.post(`/api/plot/${dataset_id}/tsne`, payload); - - this.display_tsne_is_loading = false; - this.display_image_data = data.image; - }, - }, - }); - - const svgChart = Vue.component("svg-chart", { - template: ` -
-
- -
-
- - {{ message }} - - - - {{ message }} - -
-
-
- `, - props: { - chart_data: { - default: null, - }, - low_color: { - default: "", - }, - mid_color: { - default: "", - }, - high_color: { - default: "", - }, - display_data: { - default: null, - }, - }, - data() { - return { - loading: false, - paths: [], - svg: {}, - scoring_method: "gene", // need to make this a toggle - success: 0, - message: "", - }; - }, - computed: { - ...Vuex.mapState(["dataset_id", "colorblind_mode"]), - }, - watch: { - async svg(svg) { - this.paths = svg.selectAll("path, circle"); - svg.select("svg").attr({ - width: "100%", - height: this.display_data ? "200px" : "", - }); - const snap = Snap(this.$refs.chart); - snap.append(svg); - - if (this.display_data) { - const { plotly_config } = this.display_data; - const payload = { ...plotly_config }; - const { gene_symbol } = payload; - const { data } = await axios.get( - `/api/plot/${this.dataset_id}/svg?gene=${gene_symbol}` - ); - this.color_svg(data); - } else { - this.color_svg(); - } - }, - low_color() { - this.color_svg(); - }, - mid_color() { - this.color_svg(); - }, - high_color() { - this.color_svg(); - }, - chart_data() { - this.color_svg(); - this.success = this.chart_data.success; - this.message = this.chart_data.message; - }, - }, - async created() { - const svg_path = `datasets_uploaded/${this.dataset_id}.svg`; - Snap.load(svg_path, (svg) => { - this.svg = svg; - }); - - if (this.display_data) { - const { plotly_config } = this.display_data; - const { gene_symbol } = { ...plotly_config }; - const { data } = await axios.get( - `/api/plot/${this.dataset_id}/svg?gene=${gene_symbol}` - ); - this.color_svg(data); - } - }, - methods: { - color_svg(data) { - let chart_data; - let low_color; - let mid_color; - let high_color; - if (data) { - const { plotly_config } = this.display_data; - const { colors } = { ...plotly_config }; - low_color = colors.low_color; - mid_color = colors.mid_color; - high_color = colors.high_color; - chart_data = data; - } else { - chart_data = this.chart_data; - low_color = this.low_color; - mid_color = this.mid_color; - high_color = this.high_color; +const isMultigene = 0; + +let geneSelect = null; +let geneSelectPost = null; + +const plotlyPlots = ["bar", "line", "scatter", "tsne_dyna", "violin"]; +const scanpyPlots = ["pca_static", "tsne_static", "umap_static"]; + +/** + * Represents a PlotlyHandler, a class that handles Plotly plots. + * @class + * @extends PlotHandler + */ +class PlotlyHandler extends PlotHandler { + constructor(plotType) { + super(); + this.plotType = plotType; + this.apiPlotType = plotType; + } + + classElt2Prop = { + "js-plotly-x-axis":"x_axis" + , "js-plotly-y-axis":"y_axis" + , "js-plotly-label":"point_label" + , "js-plotly-hide-x-ticks":"hide_x_labels" + , "js-plotly-hide-y-ticks":"hide_y_labels" + , "js-plotly-color":"color_name" + , "js-plotly-size":"size_by_group" + , "js-plotly-facet-row":"facet_row" + , "js-plotly-facet-col":"facet_col" + , "js-plotly-x-title":"x_title" + , "js-plotly-y-title":"y_title" + , "js-plotly-x-min":"x_min" + , "js-plotly-y-min":"y_min" + , "js-plotly-x-max":"x_max" + , "js-plotly-y-max":"y_max" + , "js-plotly-hide-legend":"hide_legend" + , "js-plotly-add-jitter":"jitter" + , "js-plotly-marker-size":"marker_size" + , "js-plotly-color-palette":"color_palette" + , "js-plotly-reverse-palette":"reverse_palette" + } + + configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); + + plotConfig = {}; // Plot config that is passed to API + + /** + * Clones the display based on the provided configuration. + * + * @param {Object} config - The configuration object. + */ + cloneDisplay(config) { + // plotly plots + for (const prop in config) { + setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config[prop]); } - // If colorblind mode activated, replace using "cividis" palette - if (this.colorblind_mode) { - // Got the colors by importing plotly.express as px and then running - // px.colors.sample_colorscale(px.colors.get_colorscale("cividis"), 3) - low_color = 'rgb(254, 232, 56)'; - mid_color = null; // I found adding the mid color skews the whole scheme towards the high color - high_color = 'rgb(0, 34, 78)'; - }; - - const score = chart_data.scores[this.scoring_method]; - const { paths } = this; - const { data: expression } = chart_data; - if ( - this.scoring_method === "gene" || - this.scoring_method === "dataset" - ) { - const { min, max } = score; - let color = null; - // are we doing a three- or two-color gradient? - if (mid_color) { - if (min >= 0) { - // All values greater than 0, do right side of three-color - color = d3 - .scaleLinear() - .domain([min, max]) - .range([mid_color, high_color]); - } else if (max <= 0) { - // All values under 0, do left side of three-color - color = d3 - .scaleLinear() - .domain([min, max]) - .range([low_color, mid_color]); - } else { - // We have a good value range, do the three-color - color = d3 - .scaleLinear() - .domain([min, 0, max]) - .range([low_color, mid_color, high_color]); - } - } else { - color = d3 - .scaleLinear() - .domain([min, max]) - .range([low_color, high_color]); - } - - const tissues = Object.keys(chart_data.data); - - paths.forEach((path) => { - const tissue = path.node.className.baseVal; - if (tissues.includes(tissue)) { - path.attr("fill", color(expression[tissue])); + // Handle order + if (config["order"]) { + for (const series in config["order"]) { + const order = config["order"][series]; + // sort "levels" series by order + levels[series].sort((a, b) => order.indexOf(a) - order.indexOf(b)); + renderOrderSortableSeries(series); } - }); + + document.getElementById("order_section").classList.remove("is-hidden"); } - }, - }, - }); - - const plotlyChart = Vue.component("plotly-chart", { - template: ` -
-
-
- -
-
- -
- - {{ message }} - - - - {{ message }} - -
-
-
-

Loading

-
-
- -
-
-
- `, - props: { - data: { - default: null, - }, - display_data: { - default: null, - }, - img: Boolean, - }, - data() { - return { - loading: false, - imgData: "", - success: 0, - message: "" - }; - }, - async created() { - if (this.display_data) { - this.loading = true; - const { plotly_config, plot_type } = this.display_data; - const { colorblind_mode } = this; - const payload = { ...plotly_config, plot_type, colorblind_mode }; - const { data } = await axios.post( - `/api/plot/${this.dataset_id}`, - payload - ); - this.loading = false; - this.draw_chart(data); - } - }, - mounted() { - if (this.is_there_data) { - this.draw_chart(); - } - }, - computed: { - ...Vuex.mapState([ - "dataset_id" - , "colorblind_mode" - , "plot_type" - ]), - is_there_data() { - if (this.data === null) return false; - return ( - Object.entries(this.data).length !== 0 && - this.data.constructor === Object - ); - }, - }, - watch: { - data() { - // because data isn't included in template it - // is not reactive. So here we explicitly watch - // for changes and redraw - this.draw_chart(); - }, - }, - methods: { - draw_chart(data) { - // Reset some params in case of failures - this.imgData = ''; - this.loading = true; - - // Reset the div to plot a new image - if (!this.img) { - (this.$refs.chart).innerHTML = ""; + + // Handle filters + if (config["obs_filters"]) { + facetWidget.filters = config["obs_filters"]; } - const { plot_json } = data ? data : this.data; - if (data) { - this.success = data.success; - this.message = data.message; - } else { - this.success = this.data.success; - this.message = this.data.message; + // Handle colors + if (config["colors"]) { + // do nothing if color_name is not set + if (!config["color_name"]) return; + + try { + const series = config["color_name"]; + renderColorPicker(series); + for (const group in config["colors"]) { + const color = config["colors"][group]; + const colorField = document.getElementById(`${CSS.escape(group)}_color`); + try { + // Found a case where the group in config was truncated compared to the (older) dataset's actual group + colorField.value = color; + } catch (error) { + console.warn(`Could not set color for ${group} to ${color}.`); + // pass + } + } + } catch (error) { + console.error(error); + // pass + } } - if (this.success >= 1 ) { - if (this.img) { - Plotly.toImage({ ...plot_json, ...{static_plot:true} }).then((url) => { - this.imgData = url; - }); - } else { - const curator_conf = postPlotlyConfig.curator; - const plot_config = this.get_plotly_updates(curator_conf, this.plot_type, "config"); - Plotly.newPlot(this.$refs.chart, plot_json.data, plot_json.layout, plot_config); - // Update plot with custom plot config stuff stored in plot_display_config.js - const update_layout = this.get_plotly_updates(curator_conf, this.plot_type, "layout") - Plotly.relayout(this.$refs.chart, update_layout) - } + + if (config["color_palette"]) { + setSelectBoxByValue("color_palette_post", config["color_palette"]); + //colorscaleSelect.update(); } - this.loading = false; - }, - get_plotly_updates(conf_area, plot_type, category) { - // Get updates and additions to plot from the plot_display_config JS object - let updates = {}; - for (const idx in conf_area) { - const conf = conf_area[idx]; - // Get config (data and/or layout info) for the plot type chosen, if it exists - if (conf.plot_type == "all" || conf.plot_type == plot_type) { - const update = category in conf ? conf[category] : {}; - updates = {...updates, ...update}; // Merge updates + + // Handle vlines + if (config["vlines"]) { + const vLinesBody = document.getElementById("vlines_body"); + const vlineField = document.querySelector(".js-plotly-vline-field"); + // For each vline object create and populate a new vline field + for (const vlineObj in config["vlines"]) { + const newVlineField = vlineField.cloneNode(true); + newVlineField.querySelector(":scope .js-vline-pos").value = vlineObj["vl_pos"]; + newVlineField.querySelector(":scope .js-vline-style-select select").value = vlineObj["vl_style"]; + vLinesBody.prepend(newVlineField); } } - return updates; - } - }, - }); - - const userDisplays = Vue.component("user-displays", { - template: ` - -
-
Your Displays
-
-
- -
-
- - -
-

- {{ display_data.plotly_config.gene_symbol }} - - Default - -

-
- - - -
- Delete - - Make Default - Edit - -
-
-
- -
-
-
-
- `, - components: { - plotlyChart, - svgChart, - tsneChart, - addDisplayBtn, - }, - data() { - return { - // deleteModal: false, - loading: false, - }; - }, - computed: { - ...Vuex.mapState(["user", "dataset_id", "default_display_id", "config"]), - ...Vuex.mapGetters(["user_displays"]), - get_config_analysis_id() { - return typeof this.config.analysis == "undefined" || - this.config.analysis == null ? null : this.config.analysis.id; - }, - user_id() { - return this.user.id; - }, - }, - methods: { - ...Vuex.mapActions([ - "fetch_user_displays", - "fetch_default_display", - "remove_display", - "update_default_display_id", - ]), - is_type_tsne(display_data) { - return ( - display_data.plot_type === "tsne_static" || - display_data.plot_type === "umap_static" || - display_data.plot_type === "pca_static" || - display_data.plot_type === "tsne" - ); - }, - edit_display(display_id) { - // this.$router.replace(`/dataset/${this.dataset_id}/displays/${display_id}/edit`) - this.$router.push(`displays/${display_id}/edit`); - }, - get_default_display() { - const user_id = this.user_id; - const dataset_id = this.dataset_id; - - return $.ajax({ - url: "./cgi/get_default_display.cgi", - type: "POST", - data: { user_id, dataset_id }, - dataType: "json", - }); - }, - async save_as_default(display_id) { - const payload = { - display_id, - user_id: this.user.id, - dataset_id: this.dataset_id, - }; - await $.ajax({ - url: "./cgi/save_default_display.cgi", - type: "POST", - data: payload, - dataType: "json", - }); + } - this.update_default_display_id({ display_id }); - }, - async delete_display(display_id) { - const payload = { - id: display_id, - user_id: this.user_id, - }; - - const res = await $.ajax({ - url: "./cgi/delete_dataset_display.cgi", - type: "POST", - data: payload, - dataType: "json", - }); + /** + * Creates a plot using the provided dataset ID and analysis object. + * @param {string} datasetId - The ID of the dataset. + * @param {Object} analysisObj - The analysis object. + * @returns {void} + */ + async createPlot(datasetId, analysisObj) { + // Get data and set up the image area + let plotJson; + try { + const data = await fetchPlotlyData(datasetId, analysisObj, this.apiPlotType, this.plotConfig); + ({plot_json: plotJson} = data); + } catch (error) { + return; + } - if (res.success) { - // update our displays so it is removed from the - // cards that are currently rendered - this.remove_display({ display_id }); + const plotContainer = document.getElementById("plot_container"); + plotContainer.replaceChildren(); // erase plot + + // NOTE: Plot initially is created to a default width but is responsive. + // Noticed container within our "column" will make full-width go beyond the screen + const plotlyPreview = document.createElement("div"); + plotlyPreview.classList.add("container", "is-max-desktop"); + plotlyPreview.id = "plotly_preview"; + plotContainer.append(plotlyPreview); + Plotly.purge("plotly_preview"); // clear old Plotly plots + + if (!plotJson) { + createToast("Could not retrieve plot information. Cannot make plot."); + return; } - }, - }, - created() { - if (this.dataset_id) { - const user_id = this.user_id; - const dataset_id = this.dataset_id; - - this.fetch_default_display({ user_id, dataset_id }); - this.fetch_user_displays({ user_id, dataset_id }); - } - }, - beforeMount() { - if (this.dataset_id) { - const user_id = this.user_id; - const dataset_id = this.dataset_id; - - this.fetch_default_display({ user_id, dataset_id }); - this.fetch_user_displays({ user_id, dataset_id }); - } - }, - }); - const ownerDisplays = Vue.component("owner-displays", { - template: ` - -
-
Owner's Displays
-
-
- -
-

- {{ display_data.plotly_config.gene_symbol }} - - Default - -

-
- - - -
- Make Default - + // Update plot with custom plot config stuff stored in plot_display_config.js + const curatorDisplayConf = postPlotlyConfig.curator; + const custonConfig = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "config"); + Plotly.newPlot("plotly_preview", plotJson.data, plotJson.layout, custonConfig); + const custonLayout = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "layout") + Plotly.relayout("plotly_preview", custonLayout) + + // If any categorical series in ".js_plot_req", and the series has more then 20 groups, display a warning about overcrowding + const plotlyReqSeries = document.getElementsByClassName("js_plot_req"); + const overcrowdedSeries = [...plotlyReqSeries].filter((series) => { + const seriesName = series.id.replace("_color", ""); + const seriesGroups = levels[seriesName]; + return seriesGroups.length > 20; + }); + if (!overcrowdedSeries.length) { + return; + } + const overcrowdedSeriesWarning = document.createElement("article"); + overcrowdedSeriesWarning.classList.add("message", "is-warning"); + overcrowdedSeriesWarning.id = "overcrowded_series_warning"; + overcrowdedSeriesWarning.innerHTML = ` +
+ WARNING: One or more of the selected categorical series has more than 20 groups. This may cause the plot to be more difficult to read or render properly.
- -
-
-
- - `, - components: { - plotlyChart, - svgChart, - tsneChart, - }, - data() { - return { - loading: false, - }; - }, - computed: { - ...Vuex.mapState([ - "user", - "owner_id", - "dataset_id", - "default_display_id", - "config", - ]), - ...Vuex.mapGetters(["is_user_owner", "owner_displays"]), - }, - methods: { - ...Vuex.mapActions([ - "fetch_owner_displays", - "fetch_default_display", - "update_default_display_id", - ]), - async save_as_default(display_id) { - const payload = { - display_id, - user_id: this.user.id, - dataset_id: this.dataset_id, - }; - - await $.ajax({ - url: "./cgi/save_default_display.cgi", - type: "POST", - data: payload, - dataType: "json", + `; + plotContainer.prepend(overcrowdedSeriesWarning); + + // Add event listener to delete button + const deleteButton = document.getElementById("overcrowded_series_warning").querySelector(".delete"); + deleteButton.addEventListener("click", (event) => { + event.target.parentElement.parentElement.remove(); }); - this.update_default_display_id({ display_id }); - }, - is_type_tsne(display_data) { - return ( - display_data.plot_type === "tsne_static" || - display_data.plot_type === "umap_static" || - display_data.plot_type === "pca_static" || - display_data.plot_type === "tsne" - ); - }, - }, - created() { - if (this.dataset_id) { - const owner_id = this.owner_id; - const dataset_id = this.dataset_id; - - this.fetch_owner_displays({ owner_id, dataset_id }); - } - }, - }); - - const datasetCurator = Vue.component("dataset-curator", { - template: ` - - - - - - - - - `, - props: ["dataset_id", "user"], - components: { - datasetTitle, - }, - watch: { - user() { - location.reload(); - }, - dataset_id() { - // not elegant, but watch for ids to change - // and force a reload... - location.reload(); - }, - }, - created() { - this.fetch_dataset_info(this.dataset_id); - }, - methods: { - ...Vuex.mapActions(["fetch_dataset_info"]), - }, - }); - - const datasetDisplays = Vue.component("dataset-displays", { - template: ` -
- - -
- `, - computed: { - ...Vuex.mapState(["owner_id"]), - ...Vuex.mapGetters(["is_user_owner"]), - }, - components: { - userDisplays, - ownerDisplays, - }, - }); - - const chooseDisplayType = Vue.component("choose-display-type", { - template: ` -
- - - -

Display Type

- - - - - - - - -
-
-
-
- `, - props: ['analysis_id'], - computed: { - ...Vuex.mapState([ - "user", - "plot_type", - "dataset_id", - "available_plot_types", - "dataset_type", - ]), - display_options() { - const display_options = Object.keys(this.available_plot_types) - .filter((type) => this.available_plot_types[type]) - .map((type, i) => type); - - return display_options; - }, - loading() { - return ( - Object.entries(this.available_plot_types).length === 0 && - this.available_plot_types.constructor === Object - ); - }, - }, - created() { - const user_id = this.user.id; - const session_id = this.user.session_id; - const dataset_id = this.dataset_id; - const analysis_id = this.analysis_id; - - this.fetch_available_plot_types({ user_id, session_id, dataset_id, analysis_id }); - }, - methods: { - ...Vuex.mapActions(["fetch_available_plot_types", "set_plot_type"]), - update_plot_type(plot_type) { - this.set_plot_type(plot_type); - this.$emit("input", plot_type); - }, - }, - watch: { - analysis_id(analysis_id) { - // When analysis ID changes, update plot types dropdown list - const user_id = this.user.id; - const session_id = this.user.session_id; - const dataset_id = this.dataset_id; - - this.fetch_available_plot_types({ user_id, session_id, dataset_id, analysis_id }); - }, } - }); - - const verticalLine = Vue.component("vertical-line", { - template: ` - - - - - - - - - `, - props: { vl: Object }, - data() { - return { - vl_pos: null, - vl_style: "solid", - options: [ - { value: "solid", text: "Solid" }, - { value: "dash", text: "Dashed" }, - { value: "dot", text: "Dotted" }, - { value: "dashdot", text: "Dash/Dot" }, - ], - }; - }, - }); - - const PlotlyArguments = Vue.component("plotly-arguments", { - template: ` -
- - -

Display Parameters

- - - - - - - - - - Hide X Tickmarks - - - - - - - - - - Hide Y Tickmarks - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Advanced Options - - - - - - - - - - - - {{ errors[0] }} - - - - - - - {{ errors[0] }} - - - - - - - - - - - - - - - - - - - - - - {{ errors[0] }} - - - - - - - {{ errors[0] }} - - - - - - - - - - - - - - - - - - - - - - - - Check to convert scatter plot into strip plot - Check to convert violin plot into beeswarm plot - - - - - - - - - Check to not display legend in plot - - - - - - - - - -
- - -
-
-
- - - - Preview Chart - - -
-
-
-
-
- `, - data() { - return { - x_axis: null, - y_axis: "raw_value", - z_axis: "raw_value", - point_label: null, - color_name: null, - facet_row: null, - facet_col: null, - size_by_group: null, // Marker size is based on a group. - marker_size: 3, // if size_by_group set, then this will be min marker size - jitter: false, - hide_x_labels: false, - hide_y_labels: false, - hide_legend: false, - x_min: null, - x_max: null, - y_min: null, - y_max: null, - x_title: null, - y_title: null, - - vlines: [], - vline_counter: 1, - }; - }, - computed: { - ...Vuex.mapState([ - "dataset_id", - "config", - "columns", - "levels", // Can use to determine categorical series - "plot_type", - "colorblind_mode" - ]), - }, - created() { - if ("x_axis" in this.config) this.x_axis = this.config.x_axis; - if ("y_axis" in this.config && this.config.y_axis) { - // we only want to change y_axis if there's a value other than null so - // it defaults to "raw_value" set in data above - this.y_axis = this.config.y_axis; - } - if ("z_axis" in this.config && this.config.z_axis) { - // we only want to change z_axis if there's a value other than null so - // it defaults to "raw_value" set in data above - this.z_axis = this.config.z_axis; - } - if ("hide_x_labels" in this.config) - this.hide_x_labels = this.config.hide_x_labels; - if ("hide_y_labels" in this.config) - this.hide_y_labels = this.config.hide_y_labels; - if ("hide_legend" in this.config) - this.hide_legend = this.config.hide_legend; - if ("color_name" in this.config) this.color_name = this.config.color_name; - if ("facet_row" in this.config) this.facet_row = this.config.facet_row; - if ("facet_col" in this.config) this.facet_col = this.config.facet_col; - if ("size_by_group" in this.config) - this.size_by_group = this.config.size_by_group; - if ("marker_size" in this.config && this.config.marker_size) { - // we only want to change marker_size if there's a value other than null so - // it defaults to the default size (3) - this.marker_size = this.config.marker_size; - } - if ("jitter" in this.config && this.config.jitter) { - // see 'marker_size' comment - this.jitter = this.config.jitter; - } else if (this.plot_type === 'violin') { - this.jitter = true; - } - if ("point_label" in this.config) - this.point_label = this.config.point_label; - if ("x_min" in this.config) this.x_min = this.config.x_min; - if ("x_max" in this.config) this.x_max = this.config.x_max; - if ("y_min" in this.config) this.y_min = this.config.y_min; - if ("y_max" in this.config) this.y_max = this.config.y_max; - if ("x_title" in this.config) this.x_title = this.config.x_title; - if ("y_title" in this.config) this.y_title = this.config.y_title; - if ("vlines" in this.config) this.vlines = this.config.vlines; - }, - watch: { - // Ensure a group is not used for two parameters - x_axis(val) { - if (this.y_axis === val) this.y_axis = null; - if (this.z_axis === val) this.z_axis = null; - if (this.size_by_group === val) this.size_by_group = null; - if (this.facet_row === val) this.facet_row = null; - if (this.facet_col === val) this.facet_col = null; - }, - y_axis(val) { - if (this.x_axis === val) this.x_axis = null; - if (this.z_axis === val) this.z_axis = null; - if (this.size_by_group === val) this.size_by_group = null; - if (this.facet_row === val) this.facet_row = null; - if (this.facet_col === val) this.facet_col = null; - }, - z_axis(val) { - if (this.x_axis === val) this.x_axis = null; - if (this.y_axis === val) this.y_axis = null; // TODO: 'Y' and 'Z' start as "raw_value"... need to prevent that - if (this.size_by_group === val) this.size_by_group = null; - // 'z' must be continuous and facets must be discrete so they will not overlap - }, - color_name(val) { - // Colors need to be cleared since the category is different. New colors will be set after plot creation - this.set_colors(null); - this.set_color_palette(null); - this.set_reverse_palette(false); - }, - size_by_group(val) { - if (this.x_axis === val) this.x_axis = null; - if (this.y_axis === val) this.y_axis = null; - if (this.z_axis === val) this.z_axis = null; - if (this.facet_row === val) this.facet_row = null; - if (this.facet_col === val) this.facet_col = null; - }, - facet_row(val) { - if (this.x_axis === val) this.x_axis = null; - if (this.y_axis === val) this.y_axis = null; - if (this.size_by_group === val) this.size_by_group = null; - if (this.facet_col === val) this.facet_col = null; - }, - facet_col(val) { - if (this.x_axis === val) this.x_axis = null; - if (this.y_axis === val) this.y_axis = null; - if (this.size_by_group === val) this.size_by_group = null; - if (this.facet_row === val) this.facet_row = null; - }, - }, - methods: { - ...Vuex.mapActions([ - "fetch_plotly_data", - "set_colors", - "set_color_palette", - "set_reverse_palette", - ]), - preview() { - const { - gene_symbol, - analysis, - colors, - order, - color_palette, - reverse_palette, - } = this.config; - - const config = { - gene_symbol, - analysis, - colors, - order, - color_palette, - reverse_palette, - x_axis: this.x_axis, - y_axis: this.y_axis, - z_axis: this.z_axis, - point_label: this.point_label, - color_name: this.color_name, - facet_row: this.facet_row, - facet_col: this.facet_col, - size_by_group: this.size_by_group, - marker_size: this.marker_size, - jitter: this.jitter, - hide_x_labels: this.hide_x_labels, - hide_y_labels: this.hide_y_labels, - hide_legend: this.hide_legend, - x_min: this.x_min, - x_max: this.x_max, - y_min: this.y_min, - y_max: this.y_max, - x_title: this.x_title, - y_title: this.y_title, - vlines: this.vlines, - }; - - const { plot_type, dataset_id, colorblind_mode } = this; - this.fetch_plotly_data({ config, plot_type, dataset_id, colorblind_mode}); - }, - addRow() { - // Add new 'vertical-line' component - this.vlines.push({ - id: this.vline_counter++, - vl_pos: null, - vl_style: "solid", - }); - }, - removeLast() { - // Remove last 'vertical-line component - this.vlines.pop(); - }, - }, - components: { - verticalLine, - }, - }); - - const displayNameInput = Vue.component("display-name-input", { - template: ` -
- - -

Display Name

- - - -
-
-
-
- `, - computed: { - ...Vuex.mapState(["label"]), - }, - methods: { - ...Vuex.mapActions(["set_label"]), - update_display_name(label) { - this.set_label(label); - }, - }, - }); - - const saveDisplayBtn = Vue.component("save-display-btn", { - template: ` - - Save Display - - `, - props: ["display_id"], - data() { - return { - variant: "", - }; - }, - computed: { - ...Vuex.mapState(["user", "dataset_id", "plot_type", "config", "label"]), - }, - methods: { - ...Vuex.mapActions(["update_display"]), - async save() { - const payload = { - id: this.display_id, - dataset_id: this.dataset_id, - user_id: this.user.id, - label: this.label, - plot_type: this.plot_type, - plotly_config: JSON.stringify({ - // depending on display type, this object will - // have different properties - ...this.config, - }), - }; - - const res = await $.ajax({ - url: "./cgi/save_dataset_display.cgi", - type: "POST", - data: payload, - dataType: "json", - }); - if (res?.success) { - if (this.display_id) { - this.update_display(payload); - } - this.$router.push(`/dataset/${this.dataset_id}/displays`); + /** + * Loads the plot HTML by replacing the content of prePlotOptionsElt and postPlotOptionsElt elements. + * Populates advanced options for specific plot types. + * @returns {Promise} A promise that resolves when the plot HTML is loaded. + */ + async loadPlotHtml() { + const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); + prePlotOptionsElt.replaceChildren(); + + const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); + postPlotOptionsElt.replaceChildren(); + + prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/single_gene_plotly.html"); + postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/single_gene_plotly.html"); + + // populate advanced options for specific plot types + const prePlotSpecificOptionsElt = document.getElementById("plot_specific_options"); + const postPlotSpecificOptionselt = document.getElementById("post_plot_specific_options"); + + // Load color palette select options + if (["violin"].includes(this.plotType)) { + // TODO: Discrete scale should go to color mapping + loadColorscaleSelect(false); } else { - // not used - this.variant = "danger"; + loadColorscaleSelect(true); } - }, - }, - }); - - const geneSymbolInput = Vue.component("gene-symbol-input", { - template: ` -
- - - -

Gene Symbol

- - - - - - -
-
-
-
- `, - props: ["analysis"], - components: { - VueBootstrapTypeahead, - }, - data() { - return { - loading: false, - }; - }, - watch: { - analysis(analysis_id) { - // when the analysis changes creating a tsne, - // we want to fetch gene symbols for this h5ad - const dataset_id = this.dataset_id; - this.fetch_gene_symbols({ dataset_id, analysis_id }); - }, - }, - computed: { - ...Vuex.mapState(["dataset_id", "config", "gene_symbols"]), - is_gene_available() { - return this.gene_symbols - .map((gene) => gene.toLowerCase()) - .includes(this.config.gene_symbol.toLowerCase()); - }, - }, - async created() { - const dataset_id = this.dataset_id; - const analysis_id = this.analysis; - this.loading = true; - await this.fetch_gene_symbols({ dataset_id, analysis_id }); - this.loading = false; - }, - async mounted() { - // small hack to get around typeahead not allowing - // a default value - // https://github.com/alexurquhart/vue-bootstrap-typeahead/issues/22 - if (this.config.gene_symbol) { - this.$refs.gene_type_ahead.inputValue = this.config.gene_symbol; - // if there's a gene symbol we know that this is a saved - // analysis and this gene exists - this.$emit("gene-updated", true); - } - }, - methods: { - ...Vuex.mapActions(["set_gene_symbol", "fetch_gene_symbols"]), - update_gene_symbol(gene_symbol) { - this.set_gene_symbol(gene_symbol); - this.$emit("gene-updated", this.is_gene_available); - }, - }, - }); - - const displayOrder = Vue.component("display-order", { - template: ` -
- - -

Order

- - - -

{{ dataseries.key }}

- - - - - {{ elem }} - - - - - - - - {{ elem }} - - - - -
-
-
-
-
-
-
- `, - components: { - draggable: vuedraggable, - }, - data() { - return { - order: [], - }; - }, - computed: { - ...Vuex.mapState(["config", "plot_type", "dataset_id", "user", "colorblind_mode"]), - }, - created() { - // Needed for initial display after first plotting preview - this.get_order(); - - this.unsubscribe = this.$store.subscribe((mutation, state) => { - if (mutation.type === "set_order") { - this.get_order(); + + if (["scatter", "tsne_dyna"].includes(this.plotType)) { + prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_scatter.html"); + postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_scatter.html"); + return; } - }); - }, - beforeDestroy() { - // If not present, subscriber will not stop even after component is destroyed - this.unsubscribe(); - }, - methods: { - ...Vuex.mapActions(["set_order", "fetch_plotly_data", "fetch_tsne_image"]), - get_order() { - const keys = Object.keys(this.config.order); - const order = keys.map((key) => { - return { - key, - value: [...this.config.order[key]], - }; - }); - this.order = order; - }, - reorder_plotly_display() { - // Convert order from array of objects to a single object - const order = this.order.reduce( - (obj, item) => ((obj[item.key] = item.value), obj), - {} - ); - this.set_order(order); - - const { config, plot_type, dataset_id, colorblind_mode } = this; - - this.fetch_plotly_data({ config, plot_type, dataset_id, colorblind_mode }); - }, - reorder_tsne_display() { - // Convert order from array of objects to a single object - const order = this.order.reduce( - (obj, item) => ((obj[item.key] = item.value), obj), - {} - ); - this.set_order(order); - const { config, plot_type, dataset_id, colorblind_mode } = this; - const { analysis } = config - const analysis_owner_id = this.user.id; - this.fetch_tsne_image({ config, plot_type, dataset_id, analysis, analysis_owner_id, colorblind_mode }); - }, - }, - }); - - const displayColors = Vue.component("display-colors", { - template: ` -
- - -

Color

- - - - - - -
-
-
-
- `, - data() { - return { - colors_array: [], - }; - }, - computed: { - ...Vuex.mapState(["config"]), - }, - created() { - // Needed for initial display after first plotting preview - this.get_colors_array(); - - this.unsubscribe = this.$store.subscribe((mutation, state) => { - if (mutation.type === "set_colors") { - this.get_colors_array(); + if (this.plotType === "violin") { + prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_violin.html"); + postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_violin.html"); + return; } - }); - }, - beforeDestroy() { - // If not present, subscriber will not stop even after component is destroyed - this.unsubscribe(); - }, - methods: { - ...Vuex.mapActions(["set_color"]), - get_colors_array() { - this.colors_array = Object.entries(this.config.colors).map( - ([key, val]) => { - return { - name: key, - color: val, - }; - } - ); - }, - update_color(name, color) { - this.set_color({ name, color }); - }, - }, - }); - - const displayPalettes = Vue.component("display-palettes", { - template: ` -
- - -

Color

- - - - - - - - - - - Reverse colorscale - - - - -
-
-
-
- `, - data() { - return { - options: [ - { - label: "Multi-color scales", - options: [ - { value: "YlOrRd", text: "Yellow-Orange-Red" }, - { value: "Viridis", text: "Viridis" }, - ], - }, - { - label: "Single-color scales", - options: [ - { value: "Greys", text: "Greyscale" }, - { value: "Blues", text: "Bluescale" }, - { value: "Purp", text: "Purplescale" }, - ], - }, - { - label: "Diverging Colorscales", - options: [ - { value: "RdBu", text: "Red-Blue" }, - { value: "PiYG", text: "Pink-Yellow-Green" }, - ], - }, - ], - palette: null, - reverse_palette: false, - }; - }, - computed: { - ...Vuex.mapState(["config", "plot_type", "dataset_id", "colorblind_mode"]), - }, - created() { - // Needed for initial display after first plotting preview - if ("color_palette" in this.config) - this.palette = this.config.color_palette; - if ("reverse_palette" in this.config) - this.reverse_palette = this.config.reverse_palette; - }, - watch: { - palette(newval) { - this.set_color_palette(newval); - this.update_display(); - }, - reverse_palette(newval) { - this.set_reverse_palette(newval); - this.update_display(); - }, - }, - methods: { - ...Vuex.mapActions([ - "set_color_palette", - "set_reverse_palette", - "fetch_plotly_data", - ]), - update_display() { - const { config, plot_type, dataset_id, colorblind_mode } = this; - - this.fetch_plotly_data({ config, plot_type, dataset_id, colorblind_mode }); - }, - }, - }); - - const barDisplay = Vue.component("bar-display", { - template: ` -
- - - - - - - - - - - - - - - - - - - - -
- `, - props: ["display_id"], - data() { - return { - is_gene_available: true, - }; - }, - computed: { - ...Vuex.mapState(["config", "plot_type"]), - is_there_data_to_save() { - return ( - "x_axis" in this.config && - "gene_symbol" in this.config && - this.config.gene_symbol !== "" - ); - }, - }, - components: { - PlotlyArguments, - geneSymbolInput, - displayNameInput, - displayOrder, - displayColors, - displayPalettes, - saveDisplayBtn, - }, - }); - - const lineDisplay = Vue.component("line-display", { - extends: barDisplay, - }); - - const violinDisplay = Vue.component("violin-display", { - extends: barDisplay, - }); - - const scatterDisplay = Vue.component("scatter-display", { - extends: barDisplay, - }); - - const contourDisplay = Vue.component("contour-display", { - extends: barDisplay, - }); - - const tsnePlotlyDisplay = Vue.component("tsne-plotly-display", { - extends: scatterDisplay, - }); - - const plotlyDisplay = Vue.component("plotly-display", { - template: ` -
-
- - - - -
-
-
-
- - - - -
-
-
-
- - - - -
-
-
-
- - - - -
-
-
-
- - - - -
-
-
-
- - - - -
-
-
- `, - props: { - display_id: { - type: [String, null], - default: null, - }, - }, - components: { - plotlyChart, - barDisplay, - lineDisplay, - violinDisplay, - scatterDisplay, - contourDisplay, - tsnePlotlyDisplay, - }, - data() { - return { - loading: false, - }; - }, - computed: { - ...Vuex.mapState(["dataset_id", "plot_type", "config", "chart_data", "colorblind_mode"]), - is_creating_new_display() { - return this.display_id === null; - }, - is_there_data_to_draw() { - return ( - Object.entries(this.chart_data).length !== 0 && - this.chart_data.constructor === Object - ); - }, - }, - created() { - this.fetch_h5ad_info({ - dataset_id: this.dataset_id, - analysis: this.config.analysis, - }); - if (!this.is_creating_new_display) { - // if we are creating a new display, we do not - // want to automatically generate a chart, and - // wait for user to specify config options - const { config, plot_type, colorblind_mode } = this; - - const dataset_id = this.dataset_id; - this.fetch_plotly_data({ config, plot_type, dataset_id, colorblind_mode }); - } - }, - methods: { - ...Vuex.mapActions(["fetch_h5ad_info", "fetch_plotly_data"]), - update_color({ name, color }) { - const { data } = this.chart_data.plot_json; - data - .filter((el) => el.name === name) - .forEach(({ marker }) => { - marker.color = color; - }); - - const { colors } = this.config; - colors[name] = color; - - // because vue wont detect these changes - // we explitly reassign chart data with - // new object - this.chart_data = { ...this.chart_data }; - }, - }, - }); - - const svgDisplay = Vue.component("svg-display", { - template: ` -
- -
-
- - - - - - - - - - - - - - - - - - - - - Preview SVG - - -
-
- - - - - - -
- `, - props: { - display_id: { - default: null, - }, - }, - components: { - geneSymbolInput, - svgChart, - displayNameInput, - saveDisplayBtn, - }, - data() { - return { - loading: false, - is_gene_available: true, - }; - }, - computed: { - ...Vuex.mapState(["dataset_id", "config", "chart_data"]), - low_color: { - get() { - return this.config.colors.low_color; - }, - set(color) { - this.set_color({ name: "low_color", color }); - }, - }, - mid_color: { - get() { - return this.config.colors.mid_color; - }, - set(color) { - this.set_color({ name: "mid_color", color }); - }, - }, - high_color: { - get() { - return this.config.colors.high_color; - }, - set(color) { - this.set_color({ name: "high_color", color }); - }, - }, - is_creating_new_display() { - return this.display_id === null; - }, - is_there_data_to_draw() { - return ( - Object.entries(this.chart_data).length !== 0 && - this.chart_data.constructor === Object - ); - }, - is_there_data_to_save() { - return "colors" in this.config && "gene_symbol" in this.config; - }, - }, - async created() { - if (!this.is_creating_new_display) { - // if we are creating a new display, we do not - // want to automatically generate a chart, and - // wait for user to specify config options - const { gene_symbol } = this.config; - const dataset_id = this.dataset_id; - - this.fetch_svg_data({ gene_symbol, dataset_id }); - } - }, - methods: { - ...Vuex.mapActions(["fetch_svg_data", "set_color"]), - preview() { - const { gene_symbol } = this.config; - const dataset_id = this.dataset_id; - - this.fetch_svg_data({ gene_symbol, dataset_id }); - }, - }, - }); - - const chooseAnalysis = Vue.component("choose-analysis", { - template: ` - - - -

Choose Analyses

-
- - - - - - - -
-
- `, - data() { - return { - private_or_public: null, - selected_analysis: null, - private: [], - public: [], - }; - }, - computed: { - ...Vuex.mapState(["dataset_id", "config"]), - analysis() { - return this.private_or_public === "Public" ? this.public_labels.find( - (el) => el.value === this.config.analysis_id - ) : this.private_labels.find( - (el) => el.value === this.config.analysis_id - ); - }, - ana_private_or_public() { - // If an analaysis id is passed, - // check if its public or private - return this.public - .map((ana) => ana.id) - .includes(this.config.analysis_id) - ? "Public" - : "Private"; - }, - private_labels() { - return this.private.map((ana) => { - return { - value: ana.id, - text: ana.label, - }; - }); - }, - public_labels() { - return this.public.map((ana) => { - return { - value: ana.id, - text: ana.label, - }; - }); - }, - analyses() { - return this.private_or_public === "Public" ? this.public_labels : this.private_labels; - }, - }, - async created() { - const { data } = await axios.get( - `./api/h5ad/${this.dataset_id}/analyses` - ); - const public_analysis = data.public; - const private_analysis = data.private; - this.public = public_analysis; - this.private = private_analysis; - - if (this.config.analysis_id) { - this.private_or_public = this.ana_private_or_public; - this.selected_analysis = this.analysis.value; - } - }, - methods: { - ...Vuex.mapActions(["set_analysis_id"]), - analysis_selected(analysis) { - this.set_analysis_id(analysis); - }, - }, - }); - - const tsneArguments = Vue.component("tsne-arguments", { - template: ` -
- - -

Display Parameters

- - - - - - - - - - - - - - - - - - - - - - - - Check to enable ability to color by a category - - - - - - - - - - - - - - - - - - - Plot generation may be slow if the selected category has a large number of groups. - - - - - - - - - - - - - - - Check to skip the gene symbol plot - - - - - - - - - Check to make horizontal legend along the bottom of the plotspace - - - - -
-
-
-
- `, - components: {}, - data() { - return { - // Since the config may not have these values, create so they aren't undefined - show_colorized_legend: false, - horizontal_legend: false, - skip_gene_plot: false, - plot_by_group: null, - max_columns: 4, - colorize_legend_by: null, - }; - }, - computed: { - ...Vuex.mapState([ - "user", - "dataset_id", - "config", - "columns", - "plot_type", - "image_data", - "tsne_is_loading", - "levels", - "colorblind_mode" - ]), - x_axis: { - get() { - return this.$store.state.config.x_axis; - }, - set(value) { - this.$store.commit("set_x_axis", value); - }, - }, - y_axis: { - get() { - return this.$store.state.config.y_axis; - }, - set(value) { - this.$store.commit("set_y_axis", value); - }, - }, - num_plot_by_group_levels() { - if (this.plot_by_group) { - return Object.keys(this.levels[this.plot_by_group]).length; + } + + /** + * Populates the plot configuration based on the current state of the dataset curator. + */ + populatePlotConfig() { + this.plotConfig = {}; // Reset plot config + + for (const classElt in this.classElt2Prop) { + this.plotConfig[this.classElt2Prop[classElt]] = getPlotConfigValueFromClassName(classElt) + } + + // Small fix for tsne/umap dynamic plots + if (this.plotType.toLowerCase() === "tsne_dyna") { + this.apiPlotType = "tsne/umap_dynamic"; + } + + // Violin plots will error (from API) if color_palette is provided + if (this.plotType.toLowerCase() === "violin") { + this.plotConfig["color_palette"] = null; + } + + // Filtered observation groups + this.plotConfig["obs_filters"] = facetWidget?.filters || {}; + + // Get order + this.plotConfig["order"] = getPlotOrderFromSortable(); + + // Get colors + const colorElts = document.getElementsByClassName("js-plot-color"); + const colorSeries = document.getElementById("color_series_post").value; + if (colorSeries && colorElts.length) { + // Input is either color mapping or just the series + this.plotConfig["colors"] = {}; + [...colorElts].map((field) => { + const group = field.id.replace("_color", ""); + this.plotConfig["colors"][group] = field.value; + }) } - return -1; - } - }, - created() { - if ("x_axis" in this.config) this.x_axis = this.config.x_axis; - if ("y_axis" in this.config) this.y_axis = this.config.y_axis; - if ("colorize_legend_by" in this.config) - this.show_colorized_legend = true; - this.colorize_legend_by = this.config.colorize_legend_by; - if ("plot_by_group" in this.config) - this.plot_by_group = this.config.plot_by_group; - if ("max_columns" in this.config) - this.max_columns = this.config.max_columns; - if ("skip_gene_plot" in this.config) - this.skip_gene_plot = this.config.skip_gene_plot; - if ("horizontal_legend" in this.config) - this.horizontal_legend = this.config.horizontal_legend; - - this.fetch_h5ad_info({ - dataset_id: this.dataset_id, - analysis: this.config.analysis, - }); - - if (this.plot_params_ready()) { - this.draw_image(); - } - - }, - watch: { - show_colorized_legend(newval, oldval) { - // if deselected, clear colorize legend select box - if (newval !== true) { - this.colorize_legend_by = null; - this.skip_gene_plot = null; - this.horizontal_legend = null; - this.plot_by_group = null; - this.max_columns = null; + + // Get vlines + const vlineFields = document.getElementsByClassName("js-plotly-vline-field"); + this.plotConfig["vlines"] = [...vlineFields].map((field) => { + const vlinePos = field.querySelector(":scope .js-plotly-vline-pos").value; + const vlineStyle = field.querySelector(":scope .js-plotly-vline-style-select select").value; + // Return either objects or nothing (which will be filtered out) + return vlinePos ? {"vl_pos":vlinePos, "vl_style":vlineStyle} : null; + }).filter(x => x !== null); + } + + /** + * Sets up the event for copying parameter values. + * @async + * @function setupParamValueCopyEvent + * @returns {Promise} + */ + async setupParamValueCopyEvent() { + //pass + } + + /** + * Sets up plot-specific events. + * @async + * @function setupPlotSpecificEvents + * @returns {Promise} + */ + async setupPlotSpecificEvents() { + await setupPlotlyOptions(); + } + +} + +/** + * Represents a ScanpyHandler class that extends PlotHandler. + * This class is responsible for creating and manipulating plots for a given dataset using the Scanpy analysis object. + */ +class ScanpyHandler extends PlotHandler { + constructor(plotType) { + super(); + this.plotType = plotType; + this.apiPlotType = plotType; + } + + classElt2Prop = { + "js-tsne-x-axis":"x_axis" + , "js-tsne-y-axis":"y_axis" + , "js-tsne-flip-x":"flip_x" + , "js-tsne-flip-y":"flip_y" + , "js-tsne-colorize-legend-by":"colorize_legend_by" + , "js-tsne-plot-by-series":"plot_by_group" + , "js-tsne-max-columns":"max_columns" + , "js-tsne-skip-gene-plot":"skip_gene_plot" + , "js-tsne-horizontal-legend":"horizontal_legend" + , "js-tsne-marker-size":"marker_size" + } + + configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); + + plotConfig = {}; // Plot config that is passed to API + + /** + * Clones the display based on the given configuration. + * @param {Object} config - The configuration object. + */ + cloneDisplay(config) { + for (const prop in config) { + setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config[prop]); } - }, - colorize_legend_by(newval, oldval) { - if (newval != oldval && this.plot_params_ready()) { - // Set order in config so "display-order" will render - if (newval !== null && this.levels) { - const colorize_key = this.colorize_legend_by; - const order = {}; - order[colorize_key] = this.levels[colorize_key]; - - if (this.plot_by_group !== null) { - // Add separately in case both are same dataseries group - const group_key = this.plot_by_group; - order[group_key] = this.levels[group_key]; - } - - // This is to prevent a bug where the levels have not been set yet when loading a display. - if (oldval !== null) { - this.$store.commit("set_order", order); - } + + // Handle order + if (config["order"]) { + for (const series in config["order"]) { + const order = config["order"][series]; + // sort "levels" series by order + levels[series].sort((a, b) => order.indexOf(a) - order.indexOf(b)); + renderOrderSortableSeries(series); } - this.draw_image(); + + document.getElementById("order_section").classList.remove("is-hidden"); } - }, - x_axis(newval, oldval) { - if (newval != oldval && this.plot_params_ready()) { - this.draw_image(); + + // Handle filters + if (config["obs_filters"]) { + facetWidget.filters = config["obs_filters"]; } - }, - y_axis(newval, oldval) { - if (newval != oldval && this.plot_params_ready()) { - this.draw_image(); + + // Restoring some disabled/checked elements in UI + const plotBySeries = document.getElementsByClassName("js-tsne-plot-by-series"); + const maxColumns = document.getElementsByClassName('js-tsne-max-columns'); + const skipGenePlot = document.getElementsByClassName("js-tsne-skip-gene-plot"); + const horizontalLegend = document.getElementsByClassName("js-tsne-horizontal-legend"); + + if (config["colorize_legend_by"]) { + const series = config["colorize_legend_by"]; + for (const targetElt of [...plotBySeries, ...horizontalLegend]) { + targetElt.disabled = true; + if (catColumns.includes(series)) { + targetElt.disabled = false; + } + + // Applies to horizontal legend + disableCheckboxLabel(targetElt, targetElt.disabled); + } + + // The "max columns" parameter is only available for categorical series + if (!(catColumns.includes(series))) { + for (const targetElt of maxColumns) { + targetElt.disabled = true; + } + } + + + // Handle colors + if (config["colors"]) { + renderColorPicker(series); + for (const group in config["colors"]) { + const color = config["colors"][group]; + const colorField = document.getElementById(`${CSS.escape(group)}_color`); + colorField.value = color; + } + } + } + + if (config["plot_by_group"]) { + for (const targetElt of skipGenePlot) { + targetElt.disabled = true; + targetElt.checked = false; + disableCheckboxLabel(targetElt, targetElt.disabled); + } + for (const targetElt of maxColumns) { + targetElt.disabled = false; + } + } + + // If marker size is present, enable the override option + if (config["marker_size"]) { + for (const classElt of document.getElementsByClassName("js-tsne-marker-size")) { + classElt.disabled = false; + } + for (const classElt of document.getElementsByClassName("js-tsne-override-marker-size")) { + classElt.checked = true; + } + } + } + + /** + * Creates a plot for a given dataset using the provided analysis object. + * @param {string} datasetId - The ID of the dataset. + * @param {Object} analysisObj - The analysis object. + * @returns {void} + */ + async createPlot(datasetId, analysisObj) { + let image; + try { + const data = await fetchTsneImage(datasetId, analysisObj, this.apiPlotType, this.plotConfig); + ({image} = data); + } catch (error) { + return; } - }, - skip_gene_plot(newval, oldval) { - if (newval != oldval && this.plot_params_ready()) { - this.draw_image(); + + const plotContainer = document.getElementById("plot_container"); + plotContainer.replaceChildren(); // erase plot + + const tsnePreview = document.createElement("img"); + tsnePreview.classList.add("image"); + tsnePreview.id = "tsne_preview"; + plotContainer.append(tsnePreview); + + if (image) { + document.getElementById("tsne_preview").setAttribute("src", `data:image/png;base64,${image}`); + } else { + createToast("Could not retrieve plot image. Cannot make plot."); + return; } - }, - horizontal_legend(newval, oldval) { - if (newval != oldval && this.plot_params_ready()) { - this.draw_image(); + } + + /** + * Loads the plot HTML by replacing the content of prePlotOptionsElt and postPlotOptionsElt elements. + * @returns {Promise} A promise that resolves when the plot HTML is loaded. + */ + async loadPlotHtml() { + const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); + prePlotOptionsElt.replaceChildren(); + + const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); + postPlotOptionsElt.replaceChildren(); + + prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/tsne_static.html"); + postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/tsne_static.html"); + } + + /** + * Populates the plot configuration based on various elements and values. + */ + populatePlotConfig() { + this.plotConfig = {}; // Reset plot config + + for (const classElt in this.classElt2Prop) { + this.plotConfig[this.classElt2Prop[classElt]] = getPlotConfigValueFromClassName(classElt) + } + + // Get order + this.plotConfig["order"] = getPlotOrderFromSortable(); + + // Filtered observation groups + this.plotConfig["obs_filters"] = facetWidget?.filters || {}; + + // Get colors + const colorElts = document.getElementsByClassName("js-plot-color"); + const colorSeries = document.getElementById("colorize_legend_by_post").textContent; + if (colorSeries && colorElts.length) { + this.plotConfig["colors"] = {}; + [...colorElts].map((field) => { + const group = field.id.replace("_color", ""); + this.plotConfig["colors"][group] = field.value; + }) + } + + // If user did not want to have a colorized annotation, ensure it does not get passed to the scanpy code + if (!(colorSeries)) { + this.plotConfig["plot_by_group"] = null; + this.plotConfig["max_columns"] = null; + this.plotConfig["skip_gene_plot"] = false; + this.plotConfig["horizontal_legend"] = false; + } + + // If override marker size is not checked, ensure it does not get passed to the scanpy code + if (!(document.getElementById("override_marker_size_post").checked)) { + this.plotConfig["marker_size"] = null; } - }, - plot_by_group(newval, oldval) { - if (newval != oldval && this.plot_params_ready()) { - // Plotting by group colors by gene symbol, so cannot skip gene plot - if (newval !== null) this.skip_gene_plot = null; - - // Currently only works if colorize_legend_by is set - if (this.colorize_legend_by !== null) { - // Set order in config so "display-order" will render - if (newval !== null && this.levels) { - const group_key = this.plot_by_group; - // Add separately in case both are same dataseries group - const colorize_key = this.colorize_legend_by; - const order = {}; - order[group_key] = this.levels[group_key]; - order[colorize_key] = this.levels[colorize_key]; - this.$store.commit("set_order", order); + } + + /** + * Sets up the event for copying parameter values. + * @returns {Promise} A promise that resolves when the event setup is complete. + */ + async setupParamValueCopyEvent() { + //pass + } + + /** + * Sets up plot-specific events. + * @returns {Promise} A promise that resolves when the setup is complete. + */ + async setupPlotSpecificEvents() { + await setupScanpyOptions(); + } + +} + +/** + * Represents a SvgHandler, a class that handles SVG plots. + * @extends PlotHandler + */ +class SvgHandler extends PlotHandler { + constructor() { + super(); + } + + // These do not get passed into the API call, but want to keep the same data structure for cloning display + classElt2Prop = { + "js-svg-low-color":"low_color" + , "js-svg-mid-color":"mid_color" + , "js-svg-high-color":"high_color" + } + + configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); + + plotConfig = {colors: {}}; // Plot config to color SVG + + /** + * Clones the display based on the provided configuration. + * @param {Object} config - The configuration object. + */ + cloneDisplay(config) { + // Props are in a "colors" dict + for (const prop in config) { + setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config.colors[prop]); + } + + // If a mid-level color was provided, ensure checkbox to enable it is checked (for aesthetics) + if (config.colors["mid_color"]) { + for (const elt of document.getElementsByClassName("js-svg-enable-mid")) { + elt.checked = true; } - this.draw_image(); - } } - }, - max_columns(newval, oldval) { - if (newval != oldval && this.plot_params_ready()) { - this.draw_image(); + + } + + /** + * Creates a plot for a given dataset and gene symbol. + * @param {string} datasetId - The ID of the dataset. + * @returns {void} + */ + async createPlot(datasetId) { + let data; + try { + data = await fetchSvgData(datasetId, this.plotConfig) + } catch (error) { + return; + } + const plotContainer = document.getElementById("plot_container"); + plotContainer.replaceChildren(); // erase plot + + colorSVG(data, this.plotConfig["colors"]); + } + + /** + * Loads the plot HTML and updates the DOM elements accordingly. + * @returns {Promise} A promise that resolves once the plot HTML is loaded and the DOM elements are updated. + */ + async loadPlotHtml() { + document.getElementById("facet_content").classList.add("is-hidden"); + document.getElementById("selected_facets").classList.add("is-hidden"); + + const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); + prePlotOptionsElt.replaceChildren(); + + const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); + postPlotOptionsElt.replaceChildren(); + + prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/svg.html"); + postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/svg.html"); + } + + /** + * Populates the plot configuration with color values based on user input. + */ + populatePlotConfig() { + this.plotConfig["colors"] = {}; // Reset plot config + + this.plotConfig["colors"]["low_color"] = document.getElementById("low_color").value; + this.plotConfig["colors"]["mid_color"] = document.getElementById("mid_color").value; + this.plotConfig["colors"]["high_color"] = document.getElementById("high_color").value; + + // If user did not choose a mid-color, set it as null instead of to black + if (!(document.getElementById("enable_mid_color").checked)) { + this.plotConfig["colors"]["mid_color"] = null; } - }, - }, - methods: { - ...Vuex.mapActions([ - "fetch_h5ad_info", - "fetch_tsne_image", - "set_image_data", - "set_success", - "set_message", - "set_tsne_is_loading", - "set_order", - ]), - plot_params_ready() { - return this.x_axis && this.x_axis !== "null" && this.y_axis && this.y_axis !== "null"; - }, - draw_image() { - const { dataset_id, plot_type, colorblind_mode } = this; - const { analysis } = this.config; - const analysis_owner_id = this.user.id; - - const config = { - gene_symbol: this.config.gene_symbol, - colorize_legend_by: this.colorize_legend_by, - horizontal_legend: this.horizontal_legend, - skip_gene_plot: this.skip_gene_plot, - plot_by_group: this.plot_by_group, - max_columns: this.max_columns, - x_axis: this.x_axis, - y_axis: this.y_axis, - colors: this.colors, - order: this.config.order, - // helps stop caching issues - timestamp: new Date().getTime(), - }; - - this.fetch_tsne_image({config, plot_type, dataset_id, analysis, analysis_owner_id, colorblind_mode}) - }, - }, - }); - - const tsneDisplay = Vue.component("tsne-display", { - template: ` -
- - - - - - - - - - - - - -
- `, - props: ["display_id"], - components: { - chooseAnalysis, - geneSymbolInput, - tsneChart, - displayNameInput, - displayOrder, - saveDisplayBtn, - }, - data() { - return { - is_gene_available: true, - show_tsne: false, - loading: false, - }; - }, - computed: { - ...Vuex.mapState(["dataset_id", "config", "dataset_type", "analysis"]), - }, - }); - - const primaryConfig = Vue.component("primary-config", { - template: ` -
- - -
- -
-
- -
-
- -
-
- `, - components: { - chooseDisplayType, - plotlyDisplay, - svgDisplay, - tsneDisplay - }, - computed: { - ...Vuex.mapState(["config", "plot_type", "dataset_type"]), - is_type_plotly() { - return ( - this.plot_type === "bar" || - this.plot_type === "scatter" || - this.plot_type === "line" || - this.plot_type === "violin" || - this.plot_type === "contour" || - this.plot_type === "tsne/umap_dynamic" - ); - }, - is_type_svg() { - return this.plot_type === "svg"; - }, - is_type_tsne() { - return ( - this.plot_type === "tsne_static" || - this.plot_type === "umap_static" || - this.plot_type === "pca_static" || - this.plot_type === "tsne" - ); - }, - }, - }); - - const chooseStoredAnalysis = Vue.component("choose-stored-analysis", { - template: ` -
- - -

Stored Analysis

- - - -
- - - - -
- - - - -
-
-
-
- `, - data() { - return { - selected_analysis: null, - loading: true, - public_analyses: [], - private_analyses: [], - }; - }, - async created() { - this.loading = true; - const { data } = await axios.get( - `/./api/h5ad/${this.dataset_id}/analyses` - ); - const { public: public_analyses, private: private_analyses } = data; - - this.public_analyses = public_analyses; - this.private_analyses = private_analyses; - this.loading = false; - }, - watch: { - selected_analysis(new_analysis, old_analysis) { - this.set_analysis(new_analysis); - }, - }, - methods: { - ...Vuex.mapActions(["set_analysis"]), - }, - computed: { - ...Vuex.mapState(["dataset_id", "config"]), - }, - }); - - const storedAnalysisConfig = Vue.component("stored-analysis-config", { - template: ` -
- -
- -
- `, - components: { chooseStoredAnalysis, primaryConfig }, - computed: { - ...Vuex.mapState(["analysis"]), - }, - }); - - const configurationPanel = Vue.component("configuration-panel", { - template: ` -
- - -

Dataset Type

- - Primary Data - Stored Analysis - -
-
-
- - -
- -
-
- -
-
-
-
- `, - components: { - primaryConfig, - storedAnalysisConfig, - }, - data() { - return { - selected: "primary", - }; - }, - computed: { - ...Vuex.mapState(['dataset_type', 'dataset_id']), - }, - watch: { - selected(newValue) { - this.set_dataset_type(newValue); - }, - }, - created() { - this.selected = this.dataset_type; - }, - methods: { - ...Vuex.mapActions(["set_dataset_type"]), - }, - }); - - const newDisplay = Vue.component("new-display", { - template: ` - - - - - - - - - - - - - - `, - components: { - plotlyChart, - svgChart, - tsneChart, - configurationPanel, - }, - computed: { - ...Vuex.mapState(["config", "plot_type", "chart_data", "analysis"]), - is_type_plotly() { - return ( - this.plot_type === "bar" || - this.plot_type === "scatter" || - this.plot_type === "line" || - this.plot_type === "violin" || - this.plot_type === "contour" || - this.plot_type === "tsne/umap_dynamic" - ); - }, - is_type_svg() { - return this.plot_type === "svg"; - }, - is_type_tsne() { - // TODO: move to methods() - return ( - this.plot_type === "tsne_static" || - this.plot_type === "umap_static" || - this.plot_type === "pca_static" || - this.plot_type === "tsne" - ); - }, - is_there_data_to_draw() { - return ( - Object.entries(this.chart_data).length !== 0 && - this.chart_data.constructor === Object - ); - }, - gene_selected() { - return this.config.gene_symbol; - }, - }, - }); - - const datasetDisplay = Vue.component("dataset-display", { - template: ` - - - - - - - - - - - - - - - - - `, - props: ["display_id"], - components: { - plotlyDisplay, - svgDisplay, - tsneDisplay, - plotlyChart, - svgChart, - tsneChart, - chooseDisplayType, - }, - data() { - return { - loading: false, - }; - }, - computed: { - ...Vuex.mapState(["plot_type", "chart_data", "config", "user", "dataset_id"]), - ...Vuex.mapGetters(["user_display"]), - is_creating_new_display() { - return this.display_id === "new"; - }, - is_type_plotly() { - // handle legacy tsne dynamic plot option - let plot_type = this.plot_type; - if (plot_type === "tsne_dynamic") { - plot_type = "tsne/umap_dynamic"; + + } + + /** + * Sets up an event listener for copying parameter values. + * @returns {Promise} A promise that resolves when the event listener is set up. + */ + async setupParamValueCopyEvent() { + setupParamValueCopyEvent("js-svg-enable-mid"); + } + + /** + * Sets up plot-specific events. + */ + setupPlotSpecificEvents() { + setupSVGOptions(); + } + +} + +/** + * Applies color to an SVG chart based on the provided data and plot configuration. + * @param {Object} chartData - The data used to color the chart. + * @param {Object} plotConfig - The configuration settings for the chart. + */ +const colorSVG = (chartData, plotConfig) => { + // I found adding the mid color for the colorblind mode skews the whole scheme towards the high color + const colorblindMode = CURRENT_USER.colorblind_mode; + const lowColor = colorblindMode ? 'rgb(254, 232, 56)' : plotConfig["low_color"]; + const midColor = colorblindMode ? null : plotConfig["mid_color"]; + const highColor = colorblindMode ? 'rgb(0, 34, 78)' : plotConfig["high_color"]; + + // for those fields which have no reading, a specific value is sometimes put in instead + // These are colored a neutral color + const NA_FIELD_PLACEHOLDER = -0.012345679104328156; + const NA_FIELD_COLOR = '#808080'; + + //const scoreMethod = document.getElementById("scoring_method").value; + const score = chartData.scores["gene"] + const { min, max } = score; + let color = null; + // are we doing a three- or two-color gradient? + if (midColor) { + if (min >= 0) { + // All values greater than 0, do right side of three-color + color = d3 + .scaleLinear() + .domain([min, max]) + .range([midColor, highColor]); + } else if (max <= 0) { + // All values under 0, do left side of three-color + color = d3 + .scaleLinear() + .domain([min, max]) + .range([lowColor, midColor]); + } else { + // We have a good value range, do the three-color + color = d3 + .scaleLinear() + .domain([min, 0, max]) + .range([lowColor, midColor, highColor]); } - return ( - plot_type === "bar" || - plot_type === "scatter" || - plot_type === "line" || - plot_type === "violin" || - plot_type === "contour" || - plot_type === "tsne/umap_dynamic" - ); - }, - is_type_svg() { - return this.plot_type === "svg"; - }, - is_type_tsne() { - return ( - this.plot_type === "tsne_static" || - this.plot_type === "umap_static" || - this.plot_type === "pca_static" || - this.plot_type === "tsne" - ); - }, - is_there_data_to_draw() { - return ( - Object.entries(this.chart_data).length !== 0 && - this.chart_data.constructor === Object - ); - }, - }, - async created() { - // If user displays not generated (such as refreshing "edit" route page, then generate first) - if (! this.user_displays) { - const user_id = this.user.id; - const dataset_id = this.dataset_id; - await this.fetch_user_displays({user_id, dataset_id}); - } - const display_data = this.user_display(this.display_id); - this.set_display_data(display_data); - }, - methods: { - ...Vuex.mapActions([ - "set_display_data", - "fetch_user_displays", - ]), - }, - }); - - const store = new Vuex.Store({ - state: { - user: null, - colorblind_mode: false, - display_id: null, - user_displays: [], - owner_displays: [], - default_display_id: 0, - dataset_id: "", - owner_id: null, - config: { - gene_symbol: "", - analysis: null, - colors: null, // Color mapping for dataseries groups - color_palette: null, // Predefined swatches for continuous data - reverse_palette: false, - order: null, - // Other properties will be set reactive (must add via Vue.set) - // depending on the chart type - // TODO: Branch off into its own module with states/getters/mutations/actions - }, - gene_symbols: [], - dataset_type: "primary", - // why is analysis here too and within config? - analysis: null, - columns: [], - levels: {}, - chart_data: {}, - is_public: false, - label: "", - title: "", - plot_type: null, - loading_chart: false, - available_plot_types: {}, - plot_type_cancel_source: null, // Cancel token for API call - image_data: null, - tsne_is_loading: false, - success: 0, - message: "", - }, - - getters: { - is_user_owner(state) { - return state.user.id === state.owner_id; - }, - user_display(state) { - return (display_id) => - state.user_displays.find((display) => display.id == display_id); - }, - owner_display(state) { - return (display_id) => - state.owner_displays.find((display) => display.id == display_id); - }, - user_displays(state) { - return state.user_displays.map((display) => { - return { - is_default: state.default_display_id == display.id, - ...display, - }; + } else { + color = d3 + .scaleLinear() + .domain([min, max]) + .range([lowColor, highColor]); + } + + + // Load SVG file and set up the window + const svg = document.getElementById("plot_container"); + const snap = Snap(svg); + const svg_path = `datasets_uploaded/${datasetId}.svg`; + Snap.load(svg_path, async (path) => { + await snap.append(path) + + snap.select("svg").attr({ + width: "100%", }); - }, - owner_displays(state) { - return state.owner_displays.map((display) => { - return { - is_default: state.default_display_id == display.id, - ...display, - }; + + // Fill in tissue classes with the expression colors + const {data: expression} = chartData; + const tissues = Object.keys(chartData.data); // dataframe + const paths = Snap.selectAll("path, circle"); + + // NOTE: This must use the SnapSVG API Set.forEach function to iterate + paths.forEach(path => { + const tissue = path.node.className.baseVal; + if (tissues.includes(tissue)) { + if (expression[tissue] == NA_FIELD_PLACEHOLDER) { + path.attr('fill', NA_FIELD_COLOR); + } else { + path.attr('fill', color(expression[tissue])); + } + } }); - }, - }, - - mutations: { - set_user(state, user) { - state.user = user; - }, - set_colorblind_mode(state, cb_mode) { - state.colorblind_mode = cb_mode; - }, - set_dataset_id(state, dataset_id) { - state.dataset_id = dataset_id; - }, - set_default_display_id(state, default_display_id) { - state.default_display_id = default_display_id; - }, - set_owner_id(state, owner_id) { - state.owner_id = owner_id; - }, - set_is_public(state, is_public) { - state.is_public = is_public; - }, - set_title(state, title) { - state.title = title; - }, - set_dataset_type(state, dataset_type) { - state.dataset_type = dataset_type; - }, - set_plot_type(state, plot_type) { - // reset config, as different display types - // has different configs - state.config = { - gene_symbol: "", // Commenting out since switching plot types should not affect the gene symbol. - analysis: state.config.analysis, - colors: null, - }; - - if ( - ["bar", "line", "violin", "scatter", "contour", "tsne/umap_dynamic"].includes(plot_type) - ) { - Vue.set(state.config, "x_axis", null); - Vue.set(state.config, "y_axis", null); - Vue.set(state.config, "z_axis", null); - Vue.set(state.config, "x_min", null); - Vue.set(state.config, "y_min", null); - Vue.set(state.config, "x_title", null); - Vue.set(state.config, "y_title", null); - Vue.set(state.config, "point_label", null); - Vue.set(state.config, "hide_x_labels", false); - Vue.set(state.config, "hide_y_labels", false); - Vue.set(state.config, "hide_legend", false); - Vue.set(state.config, "color_name", null); - Vue.set(state.config, "facet_row", null); - Vue.set(state.config, "facet_col", null); - Vue.set(state.config, "size_by_group", null); - Vue.set(state.config, "marker_size", null); - Vue.set(state.config, "jitter", null); - Vue.set(state.config, "vlines", []); - Vue.set(state.config, "colors", {}); - Vue.set(state.config, "color_palette", null); - Vue.set(state.config, "reverse_palette", false); - Vue.set(state.config, "order", {}); - } else if (plot_type === "svg") { - Vue.set(state.config, "colors", { - // arbituary default colors (purple) - low_color: "#e7d1d5", - mid_color: null, - high_color: "#401362", - }); - } else if ( - ["tsne_static", "umap_static", "pca_static", "tsne"].includes(plot_type) - ) { - // tsne - Vue.set(state.config, "x_axis", null); - Vue.set(state.config, "y_axis", null); - Vue.set(state.config, "colors", {}); - Vue.set(state.config, "order", {}); - Vue.set(state.config, "colorize_legend_by", null); - Vue.set(state.config, "plot_by_group", null); - Vue.set(state.config, "max_columns", null); - Vue.set(state.config, "skip_gene_plot", false); - Vue.set(state.config, "horizontal_legend", false); + + // TODO: Potentially replicate some of the features in display.js like log-transforms and tooltips + }); + +} + +/** + * Handles the event when a select element is updated in the curatorSpecifcChooseGene function. + * If one select element was updated, it ensures the other is updated as well. + * It copies data from one select2 to the other and renders the dropdown for the other select2. + * If no gene is selected, it disables the plot button and displays an error message. + * @param {Event} event - The event object triggered by the select element update. + */ +const curatorSpecifcChooseGene = (event) => { + // If one select element was updated ensure the other is updated as well + const select2 = event.target.id === "gene_select" ? geneSelect : geneSelectPost; + const oppSelect2 = event.target.id === "gene_select" ? geneSelectPost : geneSelect; + const oppEltId = event.target.id === "gene_select" ? "gene_select_post" : "gene_select"; + + if (!select2.selectedOptions.length) return; // Do not trigger after initial population + + const val = getSelect2Value(select2); + + // NOTE: I thought about updating the select2 element directly with updateSelectValue() + // but this triggers the "change" event for the regular "select" element, which causes a max stack call error + setSelectBoxByValue(oppEltId, val); + + // copy data from one select2 to the other + // Render the dropdown for the other select2 + oppSelect2.data = select2.data; + oppSelect2.options = select2.options; + oppSelect2.selectedOptions = select2.selectedOptions; + + // Recreate update() function without the extractData() call, which is causing noticeable slowdown/hanging + if (oppSelect2.dropdown) { + const open = oppSelect2.dropdown.classList.contains("open"); + oppSelect2.dropdown.parentNode.removeChild(oppSelect2.dropdown); + oppSelect2.create(); + + if (open) { + triggerClick(oppSelect2.dropdown); + } + } + + // Cannot plot if no gene is selected + if (document.getElementById("gene_select").value === "Please select a gene") { + document.getElementById("gene_s_failed").classList.remove("is-hidden"); + document.getElementById("gene_s_success").classList.add("is-hidden"); + document.getElementById("current_gene").textContent = ""; + for (const plotBtn of document.getElementsByClassName("js-plot-btn")) { + plotBtn.disabled = true; + } + return; + } + + document.getElementById("gene_s_failed").classList.add("is-hidden"); + document.getElementById("gene_s_success").classList.remove("is-hidden"); + // Display current selected gene + document.getElementById("current_gene_c").classList.remove("is-hidden"); + document.getElementById("current_gene").textContent = val; + // Force validationcheck to see if plot button should be enabled + trigger(document.querySelector(".js-plot-req"), "change"); + document.getElementById("plot_options_s").click(); +} + +/** + * Creates a plot based on the specified plot type. + * @param {string} plotType - The type of plot to create. + * @returns {Promise} - A promise that resolves when the plot is created. + */ +const curatorSpecifcCreatePlot = async (plotType) => { + // Call API route by plot type + if (plotlyPlots.includes(plotType)) { + await plotStyle.createPlot(datasetId, analysisObj); + + } else if (scanpyPlots.includes(plotType)) { + await plotStyle.createPlot(datasetId, analysisObj); + + } else if (plotType === "svg") { + await plotStyle.createPlot(datasetId); + } else { + console.warn(`Plot type ${plotType} selected for plotting is not a valid type.`) + return; + } + +} + +/** + * Callback function for curator specific dataset tree. + * Creates gene select2 elements for both views. + * @returns {void} + */ +const curatorSpecifcDatasetTreeCallback = () => { + + // Not providing the object in the argument could duplicate the nice-select2 structure if called multiple times + geneSelect = createGeneSelectInstance("gene_select", geneSelect); + geneSelectPost = createGeneSelectInstance("gene_select_post", geneSelectPost); + document.getElementById("current_gene").textContent = ""; + +} + +/** + * Updates the curator-specific navbar with the current page information. + */ +const curatorSpecificNavbarUpdates = () => { + document.querySelector("#header_bar .navbar-item").textContent = "Single-gene Curator"; + + for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { + elt.classList.remove("is-active"); + } + + document.querySelector("a[tool='sg_curator'").classList.add("is-active"); +} + +const curatorSpecificOnLoad = async () => { + // pass +} + +/** + * Returns a specific plot style handler based on the given plot type. + * @param {string} plotType - The type of plot. + * @returns {PlotlyHandler|ScanpyHandler|SvgHandler|null} - The plot style handler. + */ +const curatorSpecificPlotStyle = (plotType) => { + // include plotting backend options + if (plotlyPlots.includes(plotType)) { + return new PlotlyHandler(plotType); + } else if (scanpyPlots.includes(plotType)) { + return new ScanpyHandler(plotType); + } else if (plotType === "svg") { + return new SvgHandler(); + } else { + return null; + } +} + +/** + * Adjusts the plot type for the dataset curator. + * @param {string} plotType - The original plot type. + * @returns {string} - The adjusted plot type. + */ +const curatorSpecificPlotTypeAdjustments = (plotType) => { + // ? Move this to class constructor to handle + if (plotType.toLowerCase() === "tsne") { + // Handle legacy plots + plotType = "tsne_static"; + } else if (["tsne/umap_dynamic", "tsne_dynamic"].includes(plotType.toLowerCase())) { + plotType = "tsne_dyna"; + } + return plotType +} + +/** + * Updates the gene options in the curator specific section. + * + * @param {Array} geneSymbols - The array of gene symbols to update the options with. + */ +const curatorSpecificUpdateGeneOptions = (geneSymbols) => { + // copy to "#gene_select_post" + const geneSelectEltPost = document.getElementById("gene_select_post"); + geneSelectEltPost.replaceChildren(); + for (const gene of geneSymbols.sort()) { + const option = document.createElement("option"); + option.textContent = gene; + option.value = gene; + geneSelectEltPost.append(option); + } + +} + +/** + * Fetches Plotly data for a given dataset, analysis, plot type, and plot configuration. + * @param {string} datasetId - The ID of the dataset. + * @param {string} analysis - The analysis to perform. + * @param {string} plotType - The type of plot to create. + * @param {object} plotConfig - The configuration options for the plot. + * @returns {Promise} - The fetched Plotly data. + * @throws {Error} - If the data fetch fails or an error occurs. + */ +const fetchPlotlyData = async (datasetId, analysis, plotType, plotConfig) => { + // NOTE: gene_symbol already passed to plotConfig + try { + const data = await apiCallsMixin.fetchPlotlyData(datasetId, analysis, plotType, plotConfig); + if (data?.success < 1) { + throw new Error (data?.message ? data.message : "Unknown error.") } - state.plot_type = plot_type; - }, - set_display_label(state, display_label) { - state.display_label = display_label; - }, - set_user_displays(state, user_displays) { - state.user_displays = [...user_displays]; - }, - set_owner_displays(state, owner_displays) { - state.owner_displays = [...owner_displays]; - }, - set_available_plot_types(state, available_plot_types) { - state.available_plot_types = { ...available_plot_types }; - }, - set_plot_type_cancel_source (state, cancel_source) { - state.cancel_source = cancel_source; - }, - set_columns(state, columns) { - state.columns = [...columns]; - }, - set_levels(state, levels) { - state.levels = { ...levels }; - }, - reset_config(state) { - state.config = { gene_symbol: "" }; - }, - set_config(state, config) { - state.config = { ...config }; - }, - set_chart_data(state, data) { - state.chart_data = { ...data }; - }, - set_x_axis(state, x_axis) { - state.config.x_axis = x_axis; - }, - set_y_axis(state, y_axis) { - state.config.y_axis = y_axis; - }, - set_z_axis(state, z_axis) { - state.config.z_axis = z_axis; - }, - set_x_min(state, x_min) { - state.config.x_min = x_min; - }, - set_y_min(state, y_min) { - state.config.y_min = y_min; - }, - set_x_max(state, x_max) { - state.config.x_max = x_max; - }, - set_y_max(state, y_max) { - state.config.y_max = y_max; - }, - set_x_title(state, x_title) { - state.config.x_title = x_title; - }, - set_y_title(state, y_title) { - state.config.y_title = y_title; - }, - set_vlines(state, vlines) { - state.config.vlines = vlines; - }, - set_point_label(state, point_label) { - state.config.point_label = point_label; - }, - set_hide_x_labels(state, hide) { - state.config.hide_x_labels = hide; - }, - set_hide_y_labels(state, hide) { - state.config.hide_y_labels = hide; - }, - set_hide_legend(state, hide) { - state.config.hide_legend = hide; - }, - set_color_name(state, color) { - state.config.color_name = color; - }, - set_facet_row(state, facet_row) { - state.config.facet_row = facet_row; - }, - set_facet_col(state, facet_col) { - state.config.facet_col = facet_col; - }, - set_size_by_group(state, size_by_group) { - state.config.size_by_group = size_by_group; - }, - set_marker_size(state, marker_size) { - state.config.marker_size = marker_size; - }, - set_jitter(state, jitter) { - state.config.jitter = jitter; - }, - set_order(state, order) { - state.config.order = { ...order }; - }, - set_colors(state, colors) { - if (typeof colors !== "string") state.config.colors = { ...colors }; - }, - set_color_palette(state, palette) { - state.config.color_palette = palette; - }, - set_reverse_palette(state, isReverse) { - state.config.reverse_palette = isReverse; - }, - set_color(state, { name, color }) { - if (state.plot_type !== "svg") { - // update plotly chart data - const { data } = state.chart_data.plot_json; - data - .filter((el) => el.name === name) - .forEach(({ marker }) => { - marker.color = color; + return data + } catch (error) { + logErrorInConsole(error); + const msg = "Could not create Plotly plot for this dataset and parameters. Please contact the gEAR team." + createToast(msg); + throw new Error(msg); + } +} + +/** + * Fetches SVG data for a given dataset and gene symbol. + * @param {string} datasetId - The ID of the dataset. + * @param {object} plotConfig - The configuration options for the plot. + * @returns {Promise} - The fetched SVG data. + * @throws {Error} - If there is an error fetching the SVG data. + */ +const fetchSvgData = async (datasetId, plotConfig) => { + try { + const {gene_symbol: geneSymbol} = plotConfig; + const data = await apiCallsMixin.fetchSvgData(datasetId, geneSymbol); + if (data?.success < 1) { + throw new Error (data?.message ? data.message : "Unknown error.") + } + return data + } catch (error) { + logErrorInConsole(error); + const msg = "Could not fetch SVG data for this dataset and parameters. Please contact the gEAR team." + createToast(msg); + throw new Error(msg); + } +}; + +/** + * Fetches the TSNE image for a given dataset, analysis, plot type, and plot configuration. + * + * @param {string} datasetId - The ID of the dataset. + * @param {string} analysis - The analysis type. + * @param {string} plotType - The type of plot. + * @param {object} plotConfig - The configuration for the plot. + * @returns {Promise} - The fetched data. + * @throws {Error} - If there is an error fetching the data or creating the plot image. + */ +const fetchTsneImage = async (datasetId, analysis, plotType, plotConfig) => { + // NOTE: gene_symbol already passed to plotConfig + try { + const data = await apiCallsMixin.fetchTsneImage(datasetId, analysis, plotType, plotConfig); + if (data?.success < 1) { + throw new Error (data?.message ? data.message : "Unknown error.") + } + return data; + } catch (error) { + logErrorInConsole(error); + const msg = "Could not create plot image for this dataset and parameters. Please contact the gEAR team." + createToast(msg); + throw new Error(msg); + } +} + + +/** + * Renders the color picker for a given series name. + * + * @param {string} seriesName - The name of the series. + */ +const renderColorPicker = (seriesName) => { + const colorsContainer = document.getElementById("colors_container"); + const colorsSection = document.getElementById("colors_section"); + + colorsSection.classList.add("is-hidden"); + colorsContainer.replaceChildren(); + if (!seriesName) { + return; + } + + if (!(catColumns.includes(seriesName))) { + // ? Continuous series colorbar picker + return; + } + + const seriesNameElt = document.createElement("p"); + seriesNameElt.classList.add("has-text-weight-bold", "is-underlined"); + seriesNameElt.textContent = seriesName; + colorsContainer.append(seriesNameElt); + + // Otherwise d3 category10 colors + const swatchColors = ["#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"]; + + let counter = 0; + for (const group of levels[seriesName]) { + const darkerLevel = Math.floor(counter / 10); + const baseColor = swatchColors[counter%10]; + const groupColor = darkerLevel > 0 + ? d3.color(baseColor).darker(darkerLevel).formatHex() + : baseColor; // Cycle through swatch but make darker if exceeding 10 groups + counter++; + + const groupElt = document.createElement("p"); + groupElt.classList.add("is-flex", "is-justify-content-space-between", "pr-3"); + + const groupText = document.createElement("span"); + groupText.classList.add("has-text-weight-medium"); + groupText.textContent = group; + + const colorInput = document.createElement("input"); + colorInput.classList.add("js-plot-color"); + colorInput.id = `${group}_color`; + colorInput.type = "color"; + colorInput.value = groupColor; + colorInput.setAttribute("aria-label", "Select a color"); + + groupElt.append(groupText, colorInput); + colorsContainer.append(groupElt); + } + + colorsSection.classList.remove("is-hidden"); +} + +/** + * Sets up the options for Plotly. + * @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 plotType = getSelect2Value(plotTypeSelect); + try { + ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); + } catch (error) { + document.getElementById("plot_options_s_failed").classList.remove("is-hidden"); + return; + } + // Filter out values we don't want of "levels", like "colors" + allColumns = allColumns.filter((col) => !col.includes("_colors")); + for (const key in levels) { + if (key.includes("_colors")) { + delete levels[key]; + } + } + + catColumns = Object.keys(levels); + + + const difference = (arr1, arr2) => arr1.filter(x => !arr2.includes(x)) + const continuousColumns = difference(allColumns, catColumns); + + // class name, list of columns, add expression, default category + + const xColumns = ["bar", "violin"].includes(plotType) ? catColumns : allColumns; + const xUseRaw = ["bar", "violin"].includes(plotType) ? false : true; + const yColumns = ["bar", "violin"].includes(plotType) ? continuousColumns : allColumns; + + updateSeriesOptions("js-plotly-x-axis", xColumns, xUseRaw); + updateSeriesOptions("js-plotly-y-axis", yColumns, true, "raw_value"); + updateSeriesOptions("js-plotly-color", allColumns, true); + updateSeriesOptions("js-plotly-label", allColumns, true); + updateSeriesOptions("js-plotly-facet-row", catColumns, false); + updateSeriesOptions("js-plotly-facet-col", catColumns, false); + + // If plot_type is bar or line, disable the marker size options + if (["bar", "line", "violin"].includes(plotType)) { + for (const elt of document.getElementsByClassName("js-plotly-size")) { + elt.disabled = true; + elt.value = ""; + } + + for (const elt of document.getElementsByClassName("js-plotly-marker-size")) { + elt.disabled = true; + elt.value = ""; + } + } + + + const xAxisSeriesElts = document.getElementsByClassName("js-plotly-x-axis"); + // If x-axis is categorical, enable jitter plots + if (["violin", "scatter", "tsne_dyna"].includes(plotType)) { + for (const elt of xAxisSeriesElts) { + elt.addEventListener("change", (event) => { + const jitterElts = document.getElementsByClassName("js-plotly-add-jitter"); + if ((catColumns.includes(event.target.value))) { + // categorical x-axis + for (const jitterElt of jitterElts) { + jitterElt.disabled = false; + disableCheckboxLabel(jitterElt, false); + } + } else { + for (const jitterElt of jitterElts) { + jitterElt.disabled = true; + jitterElt.checked = false; + disableCheckboxLabel(jitterElt, true); + } + + } + }); } - state.config.colors[name] = color; - - // because vue wont detect these changes - // we explitly reassign chart data with - // new object - state.chart_data = { ...state.chart_data }; - }, - set_gene_symbol(state, gene_symbol) { - state.config.gene_symbol = gene_symbol; - }, - set_gene_symbols(state, gene_symbols) { - state.gene_symbols = [...gene_symbols]; - }, - set_label(state, label) { - state.label = label; - }, - set_analysis_id(state, analysis) { - state.config.analysis_id = analysis; - }, - set_analysis(state, analysis) { - // reset config, as different display types - // has different configs - state.config = { - gene_symbol: "", - colors: null, - }; - state.plot_type = null; - state.available_plot_types = {}; - - state.analysis = analysis; - state.config.analysis = analysis; - }, - update_display(state, display) { - const { id } = display; - const i = state.user_displays.findIndex((el) => el.id == id); - Vue.set(state.user_displays, i, display); - }, - delete_display(state, { display_id }) { - state.user_displays = [ - ...state.user_displays.filter((display) => display.id != display_id), - ]; - }, - set_loading_chart(state, is_chart_loading) { - state.loading_chart = is_chart_loading; - }, - set_colorize_legend_by(state, legend_by) { - state.config.colorize_legend_by = legend_by; - }, - set_skip_gene_plot(state, skip_plot) { - state.config.skip_gene_plot = skip_plot; - }, - set_horizontal_legend(state, horizontal_legend) { - state.config.horizontal_legend = horizontal_legend; - }, - set_plot_by_group(state, group) { - state.config.plot_by_group = group; - }, - set_max_columns(state, max_cols) { - state.config.max_columns = max_cols; - }, - set_image_data(state, image_data) { - state.image_data = image_data; - }, - set_success(state, success) { - state.success = success; - }, - set_message(state, message) { - state.message = message; - }, - set_tsne_is_loading(state, is_loading) { - state.tsne_is_loading = is_loading; - }, - }, - - actions: { - set_dataset_type({ commit }, dataset_type) { - commit("set_dataset_type", dataset_type); - // When display type is changed, we want to - // reset these other settings so dependent - // components - commit("set_plot_type", null); - commit("set_chart_data", {}); - commit("set_gene_symbol", ""); - commit("set_analysis", null); - }, - update_display_label({ commit }, display_label) { - commit("set_display_label", display_label); - }, - set_dataset_id({ commit }, dataset_id) { - commit("set_dataset_id", dataset_id); - }, - set_owner_id({ commit }, owner_id) { - commit("set_owner_id", owner_id); - }, - set_is_public({ commit }, is_public) { - commit("set_dataset_id", is_public); - }, - set_title({ commit }, title) { - commit("set_title", title); - }, - set_tsne_is_loading({ commit }, is_loading) { - commit("set_tsne_is_loading", is_loading); - }, - set_dataset_info({ commit }, payload) { - const { dataset_id, title, owner_id, is_public } = payload; - commit("set_dataset_id", dataset_id); - commit("set_owner_id", owner_id); - commit("set_is_public", is_public); - commit("set_title", title); - }, - set_plot_type({ commit }, plot_type) { - commit("set_plot_type", plot_type); - // When display type is changed, we want to - // reset these other settings so dependent - // components - commit("set_chart_data", {}); - commit("set_gene_symbol", ""); - }, - async fetch_dataset_info({ commit }, dataset_id) { - commit("set_dataset_id", dataset_id); - const { title, is_public, owner_id } = await $.ajax({ - url: "./cgi/get_dataset_info.cgi", - type: "POST", - data: { dataset_id }, - dataType: "json", + } + + + if (["scatter", "tsne_dyna"].includes(plotType)) { + updateSeriesOptions("js-plotly-size", continuousColumns, true); + // If x-axis is continuous show vline stuff, otherwise hide + // If x-axis is categorical, enable jitter plots + for (const elt of xAxisSeriesElts) { + elt.addEventListener("change", (event) => { + const vLinesContainer = document.getElementById("vlines_container") + if ((catColumns.includes(event.target.value))) { + vLinesContainer.classList.add("is-hidden"); + // Remove all but first existing vline + const toRemove = document.querySelectorAll(".js-plotly-vline-field:not(:first-of-type)"); + for (const elt of toRemove) { + elt.remove(); + } + // Clear the first vline + document.querySelector(".js-plotly-vline-pos").value = ""; + document.querySelector(".js-plotly-vline-style-select").value = "solid"; + return; + } + vLinesContainer.classList.remove("is-hidden"); + + }); + } + + + // Vertical line add and remove events + const vLinesBody = document.getElementById("vlines_body"); + const vLineField = document.querySelector(".js-plotly-vline-field"); + document.getElementById("vline_add_btn").addEventListener("click", (event) => { + vLinesBody.append(vLineField.cloneNode(true)); + // clear the values of the clone + document.querySelector(".js-plotly-vline-field:last-of-type .js-plotly-vline-pos").value = ""; + document.querySelector(".js-plotly-vline-field:last-of-type .js-plotly-vline-style-select").value = "solid"; + // NOTE: Currently if original is set before cloning, values are copied to clone + document.getElementById("vline_remove_btn").disabled = false; + }) + document.getElementById("vline_remove_btn").addEventListener("click", (event) => { + // Remove last vline + const lastVLine = document.querySelector(".js-plotly-vline-field:last-of-type"); + lastVLine.remove(); + if (vLineField.length < 2) document.getElementById("vline_remove_btn").disabled = true; + }) + } + + // If color series is selected, let user choose colors. + const colorSeriesElts = document.getElementsByClassName("js-plotly-color"); + const colorPaletteElts = document.getElementsByClassName("js-plotly-color-palette"); + const reversePaletteElts = document.getElementsByClassName("js-plotly-reverse-palette"); + const hideLegend = document.getElementsByClassName("js-plotly-hide-legend"); + for (const elt of colorSeriesElts) { + elt.addEventListener("change", (event) => { + if ((catColumns.includes(event.target.value))) { + renderColorPicker(event.target.value); + for (const paletteElt of [...colorPaletteElts, ...reversePaletteElts]) { + paletteElt.disabled = true; + } + for (const legendElt of hideLegend) { + legendElt.disabled = false; + disableCheckboxLabel(legendElt, false); + } + document.getElementById("color_palette_post").disabled = true; + //colorscaleSelect.disable() + } else { + // Enable the color palette select + for (const paletteElt of [...colorPaletteElts, ...reversePaletteElts]) { + paletteElt.disabled = false; + } + for (const legendElt of hideLegend) { + legendElt.disabled = true; + legendElt.checked = false; + disableCheckboxLabel(legendElt, true); + } + document.getElementById("color_palette_post").disabled = false; + //colorscaleSelect.enable() + } + }) + } + + + // Certain elements trigger plot order + const plotOrderElts = document.getElementsByClassName("js-plot-order"); + for (const elt of plotOrderElts) { + elt.addEventListener("change", (event) => { + const paramId = event.target.id; + const param = paramId.replace("_series", "").replace("_post", ""); + // NOTE: continuous series will be handled in the function + updateOrderSortable(); }); - commit("set_owner_id", owner_id); - commit("set_is_public", is_public); - commit("set_title", title); - }, - async fetch_user_displays({ commit }, { user_id, dataset_id }) { - const displays = await $.ajax({ - url: "./cgi/get_dataset_displays.cgi", - type: "POST", - data: { user_id, dataset_id }, - dataType: "json", + } + + // Trigger event to enable plot button (in case we switched between plot types, since the HTML vals are saved) + const xSeries = document.getElementById("x_axis_series"); + const ySeries = document.getElementById("y_axis_series"); + if (xSeries.value) { + // If value is categorical, disable min and max boundaries + for (const elt of [...document.getElementsByClassName("js-plotly-x-min"), ...document.getElementsByClassName("js-plotly-x-max")]) { + elt.disabled = catColumns.includes(xSeries.value) + } + trigger(xSeries, "change"); + } + if (ySeries.value) { + // If value is categorical, disable min and max boundaries + for (const elt of [...document.getElementsByClassName("js-plotly-y-min"), ...document.getElementsByClassName("js-plotly-y-max")]) { + elt.disabled = catColumns.includes(ySeries.value) + } + trigger(ySeries, "change"); + } + + // Setup the dropdown menu on the post-plot view + const plotlyDropdown = document.getElementById("plotly_param_dropdown"); + plotlyDropdown.addEventListener("click", (event) => { + event.stopPropagation(); // This prevents the document from being clicked as well. + plotlyDropdown.classList.toggle("is-active"); + }) + + // Close dropdown if it is clicked off of, or ESC is pressed + // https://siongui.github.io/2018/01/19/bulma-dropdown-with-javascript/#footnote-1 + document.addEventListener('click', () => { + plotlyDropdown.classList.remove("is-active"); + }); + document.addEventListener('keydown', (event) => { + if (event.key === "Escape") { + plotlyDropdown.classList.remove("is-active"); + } + }); + + const plotlyDropdownMenuItems = document.querySelectorAll("#plotly_param_dropdown .dropdown-item"); + for (const item of plotlyDropdownMenuItems) { + item.addEventListener("click", showPostPlotlyParamSubsection); + } + +} + +/** + * Sets up the options for Scanpy analysis. + * @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 plotType = getSelect2Value(plotTypeSelect); + try { + ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); + } catch (error) { + document.getElementById("plot_options_s_failed").classList.remove("is-hidden"); + return; + } + + // Filter out values we don't want of "levels", like "colors" + allColumns = allColumns.filter((col) => !col.includes("_colors")); + for (const key in levels) { + if (key.includes("_colors")) { + delete levels[key]; + } + } + catColumns = Object.keys(levels); + + let xDefaultOption = null; + let yDefaultOption = null; + + // If these exist, make the default option + switch (plotType) { + case "pca_static": + xDefaultOption = "X_pca_1"; + yDefaultOption = "X_pca_2"; + break; + case "tsne_static": + xDefaultOption = "X_tsne_1"; + yDefaultOption = "X_tsne_2"; + break; + case "umap_static": + xDefaultOption = "X_umap_1"; + yDefaultOption = "X_umap_2"; + break; + } + + updateSeriesOptions("js-tsne-x-axis", allColumns, true, xDefaultOption); + updateSeriesOptions("js-tsne-y-axis", allColumns, true, yDefaultOption); + updateSeriesOptions("js-tsne-colorize-legend-by", allColumns, false); + updateSeriesOptions("js-tsne-plot-by-series", catColumns, false); + + const colorizeLegendBy = document.getElementsByClassName("js-tsne-colorize-legend-by"); + const plotBySeries = document.getElementsByClassName("js-tsne-plot-by-series"); + const maxColumns = document.getElementsByClassName('js-tsne-max-columns'); + const skipGenePlot = document.getElementsByClassName("js-tsne-skip-gene-plot"); + const horizontalLegend = document.getElementsByClassName("js-tsne-horizontal-legend"); + + // Do certain things if the chosen annotation series is categorical or continuous + for (const elt of colorizeLegendBy) { + elt.addEventListener("change", (event) => { + for (const targetElt of [...plotBySeries, ...horizontalLegend]) { + targetElt.disabled = true; + // If colorized legend is continuous, we cannot plot by group + // So all dependencies need to be disabled. + if ((catColumns.includes(event.target.value))) { + renderColorPicker(event.target.value); + targetElt.disabled = false; + disableCheckboxLabel(targetElt, false); + } + } + + // The "max columns" parameter should only be disabled if the colorized legend is continuous + if (!(catColumns.includes(event.target.value))) { + for (const targetElt of maxColumns) { + targetElt.disabled = true; + disableCheckboxLabel(targetElt, true); + } + } }); - // Filter out the multigene displays, which do not have the "gene_symbol" config property - const curated_displays = displays.filter( display => display.plotly_config.hasOwnProperty('gene_symbol')); - commit("set_user_displays", curated_displays); - }, - async fetch_owner_displays({ commit }, { owner_id, dataset_id }) { - const displays = await $.ajax({ - url: "./cgi/get_dataset_displays.cgi", - type: "POST", - data: { user_id: owner_id, dataset_id }, - dataType: "json", + + elt.addEventListener("change", (event) => { + renderColorPicker(event.target.value); + return; + }) + } + + // Plotting by group plots gene expression, so cannot skip gene plots. + for (const elt of plotBySeries) { + elt.addEventListener("change", (event) => { + // Must plot gene expression if series value selected + for (const targetElt of skipGenePlot) { + targetElt.disabled = event.target.value ? true : false; + if (event.target.value) targetElt.checked = false; + disableCheckboxLabel(targetElt, targetElt.disabled); + } + // Must be allowed to specify max columns if series value selected + for (const targetElt of maxColumns) { + targetElt.disabled = event.target.value ? false : true; + } + updateOrderSortable(); + }); - // Filter out the multigene displays, which do not have the "gene_symbol" config property - const curated_displays = displays.filter( display => display.plotly_config.hasOwnProperty('gene_symbol')); - commit("set_owner_displays", curated_displays); - }, - async fetch_available_plot_types( - { commit, state }, - { user_id, session_id, dataset_id, analysis_id } - ) { - // Cancelling last axios call, if applicable - if (state.cancel_source) { - state.cancel_source.cancel('Newer "fetch_available_plot_types" call detected.'); - } - - // Create cancel token to cancel last request of this to prevent race condition - // reference: https://github.com/axios/axios#cancellation - const CancelToken = axios.CancelToken; - const cancel_source = CancelToken.source() - commit('set_plot_type_cancel_source', cancel_source); - - await axios.post( - `/api/h5ad/${dataset_id}/availableDisplayTypes`, - { - user_id, - session_id, - dataset_id, - analysis_id, - }, - { cancelToken: cancel_source.token - }).then((response) => { - commit('set_available_plot_types', response.data); - }).catch((thrown) => { - if (axios.isCancel(thrown)) { - console.info('Request canceled:', thrown.message); - } else { - // handle error - console.error(thrown); + } + + // Trigger event to enable plot button (in case we switched between plot types, since the HTML vals are saved) + if (document.getElementById("x_axis_series").value) { + trigger(document.getElementById("x_axis_series"), "change"); + } + if (document.getElementById("y_axis_series").value) { + trigger(document.getElementById("y_axis_series"), "change"); + } + + // If override marker size is checked, enable the marker size field + const overrideMarkerSize = document.getElementsByClassName("js-tsne-override-marker-size"); + const markerSize = document.getElementsByClassName("js-tsne-marker-size"); + for (const elt of overrideMarkerSize) { + elt.addEventListener("change", (event) => { + for (const targetElt of markerSize) { + targetElt.disabled = event.target.checked ? false : true; } }); - }, - async fetch_h5ad_info({ commit }, payload) { - const { dataset_id, analysis } = payload; + } - let data; - if (analysis) { - const response = await axios.get( - `/api/h5ad/${dataset_id}?analysis_id=${analysis.id}` - ); - data = response.data; - } else { - const response = await axios.get(`/api/h5ad/${dataset_id}`); - data = response.data; - } - const { obs_columns, obs_levels } = data; - commit("set_columns", obs_columns); - commit("set_levels", obs_levels); - }, - async fetch_plotly_data({ commit }, { config, plot_type, dataset_id, colorblind_mode}) { - commit("set_loading_chart", true); - const payload = { ...config, plot_type, colorblind_mode }; - const { data } = await axios.post(`/api/plot/${dataset_id}`, payload); - commit("set_chart_data", data); - - const { - plot_colors, - plot_palette, - reverse_palette, - plot_order, - x_axis, - y_axis, - z_axis, - point_label, - hide_x_labels, - hide_y_labels, - hide_legend, - color_name, - facet_row, - facet_col, - size_by_group, - marker_size, - jitter, - x_min, - y_min, - x_max, - y_max, - x_title, - y_title, - vlines, - success, - message, - } = data; - - commit("set_order", plot_order); - commit("set_colors", plot_colors); - commit("set_color_palette", plot_palette); - commit("set_reverse_palette", reverse_palette); - commit("set_x_axis", x_axis); - commit("set_y_axis", y_axis); - commit("set_z_axis", z_axis); - commit("set_point_label", point_label); - commit("set_hide_x_labels", hide_x_labels); - commit("set_hide_y_labels", hide_y_labels); - commit("set_hide_legend", hide_legend); - commit("set_color_name", color_name); - commit("set_facet_row", facet_row); - commit("set_facet_col", facet_col); - commit("set_size_by_group", size_by_group); - commit("set_marker_size", marker_size); - commit("set_jitter", jitter); - commit("set_x_min", x_min); - commit("set_y_min", y_min); - commit("set_x_max", x_max); - commit("set_y_max", y_max); - commit("set_x_title", x_title); - commit("set_y_title", y_title); - commit("set_vlines", vlines); - - commit("set_success", success); - commit("set_message", message); - commit("set_loading_chart", false); - }, - async fetch_tsne_image( - { commit }, - { config, plot_type, dataset_id, analysis, analysis_owner_id, colorblind_mode } - ) { - commit("set_tsne_is_loading", true); - const payload = { ...config, plot_type, analysis, analysis_owner_id, colorblind_mode }; - - const { data } = await axios.post(`/api/plot/${dataset_id}/tsne`, payload); - - commit("set_x_axis", config.x_axis); - commit("set_y_axis", config.y_axis); - commit("set_colorize_legend_by", config.colorize_legend_by); - commit("set_horizontal_legend", config.horizontal_legend); - commit("set_skip_gene_plot", config.skip_gene_plot); - commit("set_plot_by_group", config.plot_by_group); - commit("set_max_columns", config.max_columns); - commit("set_colors", config.colors); - commit("set_order", config.order); - - commit("set_image_data", data.image); - commit("set_success", data.success); - commit("set_message", data.message); - commit("set_tsne_is_loading", false); - }, - set_index({ commit }, index) { - commit("set_index", index); - }, - set_order({ commit }, order) { - commit("set_order", order); - commit("set_levels", order); - }, - set_color({ commit }, { name, color }) { - commit("set_color", { name, color }); - }, - set_colors({ commit }, colors) { - commit("set_colors", colors); - }, - set_color_palette({ commit }, palette) { - commit("set_color_palette", palette); - }, - set_reverse_palette({ commit }, isReverse) { - commit("set_reverse_palette", isReverse); - }, - set_gene_symbol({ commit }, gene_symbol) { - commit("set_gene_symbol", gene_symbol); - }, - async fetch_gene_symbols({ commit }, { dataset_id, analysis_id }) { - const base = `./api/h5ad/${dataset_id}/genes`; - const query = analysis_id ? `?analysis=${analysis_id}` : ""; - - const { data } = await axios.get(`${base}${query}`); - commit("set_gene_symbols", data.gene_symbols); - }, - set_label({ commit }, label) { - commit("set_label", label); - }, - set_display_data({ commit }, display) { - let { label, plot_type, plotly_config: config } = display; - - commit("set_label", label); - commit("set_plot_type", plot_type); - commit("set_config", config); - }, - reset({ commit }) { - commit("set_label", ""); - commit("set_plot_type", null); - commit("reset_config"); - }, - async fetch_svg_data({ commit }, { gene_symbol, dataset_id }) { - const { data } = await axios.get( - `/api/plot/${dataset_id}/svg?gene=${gene_symbol}` - ); - commit("set_chart_data", data); - }, - async fetch_default_display({ commit }, { user_id, dataset_id }) { - const { default_display_id } = await $.ajax({ - url: "./cgi/get_default_display.cgi", - type: "POST", - data: { user_id, dataset_id }, - dataType: "json", +} + +/** + * Sets up SVG options for dataset curator. + */ +const setupSVGOptions = () => { + const enableMidColorElts = document.getElementsByClassName("js-svg-enable-mid"); + const midColorElts = document.getElementsByClassName("js-svg-mid-color"); + const midColorFields = document.getElementsByClassName("js-mid-color-field"); + for (const elt of enableMidColorElts) { + elt.addEventListener("change", (event) => { + for (const field of midColorFields) { + field.style.display = (event.target.checked) ? "" : "none"; + } + for (const midColor of midColorElts) { + midColor.disabled = !(event.target.checked); + } }); - commit("set_default_display_id", default_display_id); - }, - set_analysis_id({ commit }, analysis) { - commit("set_analysis_id", analysis); - }, - set_analysis({ commit }, analysis) { - commit("set_analysis", analysis); - }, - update_display({ commit }, display) { - commit("update_display", display); - }, - set_image_data({ commit }, image_data) { - commit("set_image_data", image_data); - }, - set_success({ commit }, success) { - commit("set_success", success); - }, - set_message({ commit }, message) { - commit("set_message", message); - }, - remove_display({ commit }, display_id) { - commit("delete_display", display_id); - }, - update_default_display_id({ commit }, { display_id }) { - commit("set_default_display_id", display_id); - }, - }, - }); - - const routes = [ - { - path: "/dataset/:dataset_id/displays", - component: datasetCurator, - props: true, - children: [ - { - path: "", - name: "dashboard", - component: datasetDisplays, - }, - { - path: "new", - name: "new", - component: newDisplay, - beforeEnter(to, from, next) { - // We want to reset our data that may have been loaded - // from a previous display - store.dispatch("reset"); - next(); - }, - }, - { - path: ":display_id/edit", - name: "edit", - component: datasetDisplay, - props: true, - }, - ], } - ]; - - const router = new VueRouter({ - routes, - }); - - const app = new Vue({ - el: "#app", - router, - store, - computed: { - ...Vuex.mapState(["user"]), - }, - created() { - // We want to check for session when the curator app is first created - sleep(500).then(() => { - // If CURRENT_USER is defined at this point, add information as placeholder test - if (CURRENT_USER) { - this.$store.commit("set_user", CURRENT_USER); - this.$store.commit("set_colorblind_mode", CURRENT_USER.colorblind_mode); + + + // Trigger event to enable plot button (in case we switched between plot types, since the HTML vals are saved) + if (document.getElementById("low_color").value) { + trigger(document.getElementById("low_color"), "change"); + } + if (document.getElementById("high_color").value) { + trigger(document.getElementById("high_color"), "change"); + } +} + +/** + * Shows the corresponding subsection based on the selected option in the plot configuration menu. + * @param {Event} event - The event triggered by the user's selection. + */ +const showPostPlotlyParamSubsection = (event) => { + for (const subsection of document.getElementsByClassName("js-plot-config-section")) { + subsection.classList.add("is-hidden"); + } + + switch (event.target.textContent.trim()) { + case "X-axis": + document.getElementById("x_axis_section_post").classList.remove("is-hidden"); + break; + case "Y-axis": + document.getElementById("y_axis_section_post").classList.remove("is-hidden"); + break; + case "Color": + document.getElementById("color_section_post").classList.remove("is-hidden"); + break; + case "Marker Size": + document.getElementById("size_section_post").classList.remove("is-hidden"); + break; + case "Subplots": + document.getElementById("subplots_section_post").classList.remove("is-hidden"); + break; + default: + document.getElementById("misc_section_post").classList.remove("is-hidden"); + break; + } + event.preventDefault(); // Prevent "link" clicking from "a" elements +} + +/** + * Updates the series options in a select element based on the provided parameters. + * + * @param {string} classSelector - The class selector for the select elements to update. + * @param {Array} seriesArray - An array of series names. + * @param {boolean} addExpression - Indicates whether to add an expression option. + * @param {string} defaultOption - The default option to select. + */ +const updateSeriesOptions = (classSelector, seriesArray, addExpression, defaultOption) => { + + for (const elt of document.getElementsByClassName(classSelector)) { + elt.replaceChildren(); + + // Create continuous and categorical optgroups + const contOptgroup = document.createElement("optgroup"); + contOptgroup.setAttribute("label", "Continuous data"); + const catOptgroup = document.createElement("optgroup"); + catOptgroup.setAttribute("label", "Categorical data"); + + // Append empty placeholder element + const firstOption = document.createElement("option"); + elt.append(firstOption); + + // Add an expression option (since expression is not in the categories) + if (addExpression) { + const expression = document.createElement("option"); + contOptgroup.append(expression); + expression.textContent = "expression"; + expression.value = "raw_value"; + if ("raw_value" === defaultOption) { + expression.selected = true; + } } - }); - }, - }); -}; + + // Add categories + for (const group of seriesArray.sort()) { + + const option = document.createElement("option"); + option.textContent = group; + // Change X_pca/X_tsne/X_umap text content to be more user_friendly + if (group.includes("X_") && ( + group.includes("pca") + || group.includes("tsne") + || group.includes("umap") + )) { + option.textContent = `${group} (from selected analysis)`; + } + option.value = group; + if (catColumns.includes(group)) { + catOptgroup.append(option); + } else { + contOptgroup.append(option); + } + // NOTE: It is possible for a default option to not be in the list of groups. + if (group === defaultOption) { + option.selected = true; + } + } + + // Only append optgroup if it has children + if (contOptgroup.children.length) elt.append(contOptgroup); + if (catOptgroup.children.length) elt.append(catOptgroup); + + } +} \ No newline at end of file diff --git a/www/js/dataset_curator.v2.js b/www/js/dataset_curator.v2.js deleted file mode 100644 index 809a3a67..00000000 --- a/www/js/dataset_curator.v2.js +++ /dev/null @@ -1,1467 +0,0 @@ -// I use camelCase for my variable/function names to adhere to JS style standards -// Exception being functions that do fetch calls, so we can use JS destructuring on the payload - -'use strict'; - -const isMultigene = 0; - -let geneSelect = null; -let geneSelectPost = null; - -const plotlyPlots = ["bar", "line", "scatter", "tsne_dyna", "violin"]; -const scanpyPlots = ["pca_static", "tsne_static", "umap_static"]; - -/** - * Represents a PlotlyHandler, a class that handles Plotly plots. - * @class - * @extends PlotHandler - */ -class PlotlyHandler extends PlotHandler { - constructor(plotType) { - super(); - this.plotType = plotType; - this.apiPlotType = plotType; - } - - classElt2Prop = { - "js-plotly-x-axis":"x_axis" - , "js-plotly-y-axis":"y_axis" - , "js-plotly-label":"point_label" - , "js-plotly-hide-x-ticks":"hide_x_labels" - , "js-plotly-hide-y-ticks":"hide_y_labels" - , "js-plotly-color":"color_name" - , "js-plotly-size":"size_by_group" - , "js-plotly-facet-row":"facet_row" - , "js-plotly-facet-col":"facet_col" - , "js-plotly-x-title":"x_title" - , "js-plotly-y-title":"y_title" - , "js-plotly-x-min":"x_min" - , "js-plotly-y-min":"y_min" - , "js-plotly-x-max":"x_max" - , "js-plotly-y-max":"y_max" - , "js-plotly-hide-legend":"hide_legend" - , "js-plotly-add-jitter":"jitter" - , "js-plotly-marker-size":"marker_size" - , "js-plotly-color-palette":"color_palette" - , "js-plotly-reverse-palette":"reverse_palette" - } - - configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); - - plotConfig = {}; // Plot config that is passed to API - - /** - * Clones the display based on the provided configuration. - * - * @param {Object} config - The configuration object. - */ - cloneDisplay(config) { - // plotly plots - for (const prop in config) { - setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config[prop]); - } - - // Handle order - if (config["order"]) { - for (const series in config["order"]) { - const order = config["order"][series]; - // sort "levels" series by order - levels[series].sort((a, b) => order.indexOf(a) - order.indexOf(b)); - renderOrderSortableSeries(series); - } - - document.getElementById("order_section").classList.remove("is-hidden"); - } - - // Handle filters - if (config["obs_filters"]) { - facetWidget.filters = config["obs_filters"]; - } - - // Handle colors - if (config["colors"]) { - // do nothing if color_name is not set - if (!config["color_name"]) return; - - try { - const series = config["color_name"]; - renderColorPicker(series); - for (const group in config["colors"]) { - const color = config["colors"][group]; - const colorField = document.getElementById(`${CSS.escape(group)}_color`); - try { - // Found a case where the group in config was truncated compared to the (older) dataset's actual group - colorField.value = color; - } catch (error) { - console.warn(`Could not set color for ${group} to ${color}.`); - // pass - } - } - } catch (error) { - console.error(error); - // pass - } - } - - if (config["color_palette"]) { - setSelectBoxByValue("color_palette_post", config["color_palette"]); - //colorscaleSelect.update(); - } - - // Handle vlines - if (config["vlines"]) { - const vLinesBody = document.getElementById("vlines_body"); - const vlineField = document.querySelector(".js-plotly-vline-field"); - // For each vline object create and populate a new vline field - for (const vlineObj in config["vlines"]) { - const newVlineField = vlineField.cloneNode(true); - newVlineField.querySelector(":scope .js-vline-pos").value = vlineObj["vl_pos"]; - newVlineField.querySelector(":scope .js-vline-style-select select").value = vlineObj["vl_style"]; - vLinesBody.prepend(newVlineField); - } - } - } - - /** - * Creates a plot using the provided dataset ID and analysis object. - * @param {string} datasetId - The ID of the dataset. - * @param {Object} analysisObj - The analysis object. - * @returns {void} - */ - async createPlot(datasetId, analysisObj) { - // Get data and set up the image area - let plotJson; - try { - const data = await fetchPlotlyData(datasetId, analysisObj, this.apiPlotType, this.plotConfig); - ({plot_json: plotJson} = data); - } catch (error) { - return; - } - - const plotContainer = document.getElementById("plot_container"); - plotContainer.replaceChildren(); // erase plot - - // NOTE: Plot initially is created to a default width but is responsive. - // Noticed container within our "column" will make full-width go beyond the screen - const plotlyPreview = document.createElement("div"); - plotlyPreview.classList.add("container", "is-max-desktop"); - plotlyPreview.id = "plotly_preview"; - plotContainer.append(plotlyPreview); - Plotly.purge("plotly_preview"); // clear old Plotly plots - - if (!plotJson) { - createToast("Could not retrieve plot information. Cannot make plot."); - return; - } - // Update plot with custom plot config stuff stored in plot_display_config.js - const curatorDisplayConf = postPlotlyConfig.curator; - const custonConfig = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "config"); - Plotly.newPlot("plotly_preview", plotJson.data, plotJson.layout, custonConfig); - const custonLayout = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "layout") - Plotly.relayout("plotly_preview", custonLayout) - - // If any categorical series in ".js_plot_req", and the series has more then 20 groups, display a warning about overcrowding - const plotlyReqSeries = document.getElementsByClassName("js_plot_req"); - const overcrowdedSeries = [...plotlyReqSeries].filter((series) => { - const seriesName = series.id.replace("_color", ""); - const seriesGroups = levels[seriesName]; - return seriesGroups.length > 20; - }); - if (!overcrowdedSeries.length) { - return; - } - const overcrowdedSeriesWarning = document.createElement("article"); - overcrowdedSeriesWarning.classList.add("message", "is-warning"); - overcrowdedSeriesWarning.id = "overcrowded_series_warning"; - overcrowdedSeriesWarning.innerHTML = ` -
- WARNING: One or more of the selected categorical series has more than 20 groups. This may cause the plot to be more difficult to read or render properly. -
- `; - plotContainer.prepend(overcrowdedSeriesWarning); - - // Add event listener to delete button - const deleteButton = document.getElementById("overcrowded_series_warning").querySelector(".delete"); - deleteButton.addEventListener("click", (event) => { - event.target.parentElement.parentElement.remove(); - }); - - } - - /** - * Loads the plot HTML by replacing the content of prePlotOptionsElt and postPlotOptionsElt elements. - * Populates advanced options for specific plot types. - * @returns {Promise} A promise that resolves when the plot HTML is loaded. - */ - async loadPlotHtml() { - const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); - prePlotOptionsElt.replaceChildren(); - - const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); - postPlotOptionsElt.replaceChildren(); - - prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/single_gene_plotly.html"); - postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/single_gene_plotly.html"); - - // populate advanced options for specific plot types - const prePlotSpecificOptionsElt = document.getElementById("plot_specific_options"); - const postPlotSpecificOptionselt = document.getElementById("post_plot_specific_options"); - - // Load color palette select options - if (["violin"].includes(this.plotType)) { - // TODO: Discrete scale should go to color mapping - loadColorscaleSelect(false); - } else { - loadColorscaleSelect(true); - } - - if (["scatter", "tsne_dyna"].includes(this.plotType)) { - prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_scatter.html"); - postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_scatter.html"); - return; - } - if (this.plotType === "violin") { - prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_violin.html"); - postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_violin.html"); - return; - } - } - - /** - * Populates the plot configuration based on the current state of the dataset curator. - */ - populatePlotConfig() { - this.plotConfig = {}; // Reset plot config - - for (const classElt in this.classElt2Prop) { - this.plotConfig[this.classElt2Prop[classElt]] = getPlotConfigValueFromClassName(classElt) - } - - // Small fix for tsne/umap dynamic plots - if (this.plotType.toLowerCase() === "tsne_dyna") { - this.apiPlotType = "tsne/umap_dynamic"; - } - - // Violin plots will error (from API) if color_palette is provided - if (this.plotType.toLowerCase() === "violin") { - this.plotConfig["color_palette"] = null; - } - - // Filtered observation groups - this.plotConfig["obs_filters"] = facetWidget?.filters || {}; - - // Get order - this.plotConfig["order"] = getPlotOrderFromSortable(); - - // Get colors - const colorElts = document.getElementsByClassName("js-plot-color"); - const colorSeries = document.getElementById("color_series_post").value; - if (colorSeries && colorElts.length) { - // Input is either color mapping or just the series - this.plotConfig["colors"] = {}; - [...colorElts].map((field) => { - const group = field.id.replace("_color", ""); - this.plotConfig["colors"][group] = field.value; - }) - } - - // Get vlines - const vlineFields = document.getElementsByClassName("js-plotly-vline-field"); - this.plotConfig["vlines"] = [...vlineFields].map((field) => { - const vlinePos = field.querySelector(":scope .js-plotly-vline-pos").value; - const vlineStyle = field.querySelector(":scope .js-plotly-vline-style-select select").value; - // Return either objects or nothing (which will be filtered out) - return vlinePos ? {"vl_pos":vlinePos, "vl_style":vlineStyle} : null; - }).filter(x => x !== null); - } - - /** - * Sets up the event for copying parameter values. - * @async - * @function setupParamValueCopyEvent - * @returns {Promise} - */ - async setupParamValueCopyEvent() { - //pass - } - - /** - * Sets up plot-specific events. - * @async - * @function setupPlotSpecificEvents - * @returns {Promise} - */ - async setupPlotSpecificEvents() { - await setupPlotlyOptions(); - } - -} - -/** - * Represents a ScanpyHandler class that extends PlotHandler. - * This class is responsible for creating and manipulating plots for a given dataset using the Scanpy analysis object. - */ -class ScanpyHandler extends PlotHandler { - constructor(plotType) { - super(); - this.plotType = plotType; - this.apiPlotType = plotType; - } - - classElt2Prop = { - "js-tsne-x-axis":"x_axis" - , "js-tsne-y-axis":"y_axis" - , "js-tsne-flip-x":"flip_x" - , "js-tsne-flip-y":"flip_y" - , "js-tsne-colorize-legend-by":"colorize_legend_by" - , "js-tsne-plot-by-series":"plot_by_group" - , "js-tsne-max-columns":"max_columns" - , "js-tsne-skip-gene-plot":"skip_gene_plot" - , "js-tsne-horizontal-legend":"horizontal_legend" - , "js-tsne-marker-size":"marker_size" - } - - configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); - - plotConfig = {}; // Plot config that is passed to API - - /** - * Clones the display based on the given configuration. - * @param {Object} config - The configuration object. - */ - cloneDisplay(config) { - for (const prop in config) { - setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config[prop]); - } - - // Handle order - if (config["order"]) { - for (const series in config["order"]) { - const order = config["order"][series]; - // sort "levels" series by order - levels[series].sort((a, b) => order.indexOf(a) - order.indexOf(b)); - renderOrderSortableSeries(series); - } - - document.getElementById("order_section").classList.remove("is-hidden"); - } - - // Handle filters - if (config["obs_filters"]) { - facetWidget.filters = config["obs_filters"]; - } - - // Restoring some disabled/checked elements in UI - const plotBySeries = document.getElementsByClassName("js-tsne-plot-by-series"); - const maxColumns = document.getElementsByClassName('js-tsne-max-columns'); - const skipGenePlot = document.getElementsByClassName("js-tsne-skip-gene-plot"); - const horizontalLegend = document.getElementsByClassName("js-tsne-horizontal-legend"); - - if (config["colorize_legend_by"]) { - const series = config["colorize_legend_by"]; - for (const targetElt of [...plotBySeries, ...horizontalLegend]) { - targetElt.disabled = true; - if (catColumns.includes(series)) { - targetElt.disabled = false; - } - - // Applies to horizontal legend - disableCheckboxLabel(targetElt, targetElt.disabled); - } - - // The "max columns" parameter is only available for categorical series - if (!(catColumns.includes(series))) { - for (const targetElt of maxColumns) { - targetElt.disabled = true; - } - } - - - // Handle colors - if (config["colors"]) { - renderColorPicker(series); - for (const group in config["colors"]) { - const color = config["colors"][group]; - const colorField = document.getElementById(`${CSS.escape(group)}_color`); - colorField.value = color; - } - } - } - - if (config["plot_by_group"]) { - for (const targetElt of skipGenePlot) { - targetElt.disabled = true; - targetElt.checked = false; - disableCheckboxLabel(targetElt, targetElt.disabled); - } - for (const targetElt of maxColumns) { - targetElt.disabled = false; - } - } - - // If marker size is present, enable the override option - if (config["marker_size"]) { - for (const classElt of document.getElementsByClassName("js-tsne-marker-size")) { - classElt.disabled = false; - } - for (const classElt of document.getElementsByClassName("js-tsne-override-marker-size")) { - classElt.checked = true; - } - } - } - - /** - * Creates a plot for a given dataset using the provided analysis object. - * @param {string} datasetId - The ID of the dataset. - * @param {Object} analysisObj - The analysis object. - * @returns {void} - */ - async createPlot(datasetId, analysisObj) { - let image; - try { - const data = await fetchTsneImage(datasetId, analysisObj, this.apiPlotType, this.plotConfig); - ({image} = data); - } catch (error) { - return; - } - - const plotContainer = document.getElementById("plot_container"); - plotContainer.replaceChildren(); // erase plot - - const tsnePreview = document.createElement("img"); - tsnePreview.classList.add("image"); - tsnePreview.id = "tsne_preview"; - plotContainer.append(tsnePreview); - - if (image) { - document.getElementById("tsne_preview").setAttribute("src", `data:image/png;base64,${image}`); - } else { - createToast("Could not retrieve plot image. Cannot make plot."); - return; - } - } - - /** - * Loads the plot HTML by replacing the content of prePlotOptionsElt and postPlotOptionsElt elements. - * @returns {Promise} A promise that resolves when the plot HTML is loaded. - */ - async loadPlotHtml() { - const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); - prePlotOptionsElt.replaceChildren(); - - const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); - postPlotOptionsElt.replaceChildren(); - - prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/tsne_static.html"); - postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/tsne_static.html"); - } - - /** - * Populates the plot configuration based on various elements and values. - */ - populatePlotConfig() { - this.plotConfig = {}; // Reset plot config - - for (const classElt in this.classElt2Prop) { - this.plotConfig[this.classElt2Prop[classElt]] = getPlotConfigValueFromClassName(classElt) - } - - // Get order - this.plotConfig["order"] = getPlotOrderFromSortable(); - - // Filtered observation groups - this.plotConfig["obs_filters"] = facetWidget?.filters || {}; - - // Get colors - const colorElts = document.getElementsByClassName("js-plot-color"); - const colorSeries = document.getElementById("colorize_legend_by_post").textContent; - if (colorSeries && colorElts.length) { - this.plotConfig["colors"] = {}; - [...colorElts].map((field) => { - const group = field.id.replace("_color", ""); - this.plotConfig["colors"][group] = field.value; - }) - } - - // If user did not want to have a colorized annotation, ensure it does not get passed to the scanpy code - if (!(colorSeries)) { - this.plotConfig["plot_by_group"] = null; - this.plotConfig["max_columns"] = null; - this.plotConfig["skip_gene_plot"] = false; - this.plotConfig["horizontal_legend"] = false; - } - - // If override marker size is not checked, ensure it does not get passed to the scanpy code - if (!(document.getElementById("override_marker_size_post").checked)) { - this.plotConfig["marker_size"] = null; - } - } - - /** - * Sets up the event for copying parameter values. - * @returns {Promise} A promise that resolves when the event setup is complete. - */ - async setupParamValueCopyEvent() { - //pass - } - - /** - * Sets up plot-specific events. - * @returns {Promise} A promise that resolves when the setup is complete. - */ - async setupPlotSpecificEvents() { - await setupScanpyOptions(); - } - -} - -/** - * Represents a SvgHandler, a class that handles SVG plots. - * @extends PlotHandler - */ -class SvgHandler extends PlotHandler { - constructor() { - super(); - } - - // These do not get passed into the API call, but want to keep the same data structure for cloning display - classElt2Prop = { - "js-svg-low-color":"low_color" - , "js-svg-mid-color":"mid_color" - , "js-svg-high-color":"high_color" - } - - configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); - - plotConfig = {colors: {}}; // Plot config to color SVG - - /** - * Clones the display based on the provided configuration. - * @param {Object} config - The configuration object. - */ - cloneDisplay(config) { - // Props are in a "colors" dict - for (const prop in config) { - setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config.colors[prop]); - } - - // If a mid-level color was provided, ensure checkbox to enable it is checked (for aesthetics) - if (config.colors["mid_color"]) { - for (const elt of document.getElementsByClassName("js-svg-enable-mid")) { - elt.checked = true; - } - } - - } - - /** - * Creates a plot for a given dataset and gene symbol. - * @param {string} datasetId - The ID of the dataset. - * @returns {void} - */ - async createPlot(datasetId) { - let data; - try { - data = await fetchSvgData(datasetId, this.plotConfig) - } catch (error) { - return; - } - const plotContainer = document.getElementById("plot_container"); - plotContainer.replaceChildren(); // erase plot - - colorSVG(data, this.plotConfig["colors"]); - } - - /** - * Loads the plot HTML and updates the DOM elements accordingly. - * @returns {Promise} A promise that resolves once the plot HTML is loaded and the DOM elements are updated. - */ - async loadPlotHtml() { - document.getElementById("facet_content").classList.add("is-hidden"); - document.getElementById("selected_facets").classList.add("is-hidden"); - - const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); - prePlotOptionsElt.replaceChildren(); - - const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); - postPlotOptionsElt.replaceChildren(); - - prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/svg.html"); - postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/svg.html"); - } - - /** - * Populates the plot configuration with color values based on user input. - */ - populatePlotConfig() { - this.plotConfig["colors"] = {}; // Reset plot config - - this.plotConfig["colors"]["low_color"] = document.getElementById("low_color").value; - this.plotConfig["colors"]["mid_color"] = document.getElementById("mid_color").value; - this.plotConfig["colors"]["high_color"] = document.getElementById("high_color").value; - - // If user did not choose a mid-color, set it as null instead of to black - if (!(document.getElementById("enable_mid_color").checked)) { - this.plotConfig["colors"]["mid_color"] = null; - } - - } - - /** - * Sets up an event listener for copying parameter values. - * @returns {Promise} A promise that resolves when the event listener is set up. - */ - async setupParamValueCopyEvent() { - setupParamValueCopyEvent("js-svg-enable-mid"); - } - - /** - * Sets up plot-specific events. - */ - setupPlotSpecificEvents() { - setupSVGOptions(); - } - -} - -/** - * Applies color to an SVG chart based on the provided data and plot configuration. - * @param {Object} chartData - The data used to color the chart. - * @param {Object} plotConfig - The configuration settings for the chart. - */ -const colorSVG = (chartData, plotConfig) => { - // I found adding the mid color for the colorblind mode skews the whole scheme towards the high color - const colorblindMode = CURRENT_USER.colorblind_mode; - const lowColor = colorblindMode ? 'rgb(254, 232, 56)' : plotConfig["low_color"]; - const midColor = colorblindMode ? null : plotConfig["mid_color"]; - const highColor = colorblindMode ? 'rgb(0, 34, 78)' : plotConfig["high_color"]; - - // for those fields which have no reading, a specific value is sometimes put in instead - // These are colored a neutral color - const NA_FIELD_PLACEHOLDER = -0.012345679104328156; - const NA_FIELD_COLOR = '#808080'; - - //const scoreMethod = document.getElementById("scoring_method").value; - const score = chartData.scores["gene"] - const { min, max } = score; - let color = null; - // are we doing a three- or two-color gradient? - if (midColor) { - if (min >= 0) { - // All values greater than 0, do right side of three-color - color = d3 - .scaleLinear() - .domain([min, max]) - .range([midColor, highColor]); - } else if (max <= 0) { - // All values under 0, do left side of three-color - color = d3 - .scaleLinear() - .domain([min, max]) - .range([lowColor, midColor]); - } else { - // We have a good value range, do the three-color - color = d3 - .scaleLinear() - .domain([min, 0, max]) - .range([lowColor, midColor, highColor]); - } - } else { - color = d3 - .scaleLinear() - .domain([min, max]) - .range([lowColor, highColor]); - } - - - // Load SVG file and set up the window - const svg = document.getElementById("plot_container"); - const snap = Snap(svg); - const svg_path = `datasets_uploaded/${datasetId}.svg`; - Snap.load(svg_path, async (path) => { - await snap.append(path) - - snap.select("svg").attr({ - width: "100%", - }); - - // Fill in tissue classes with the expression colors - const {data: expression} = chartData; - const tissues = Object.keys(chartData.data); // dataframe - const paths = Snap.selectAll("path, circle"); - - // NOTE: This must use the SnapSVG API Set.forEach function to iterate - paths.forEach(path => { - const tissue = path.node.className.baseVal; - if (tissues.includes(tissue)) { - if (expression[tissue] == NA_FIELD_PLACEHOLDER) { - path.attr('fill', NA_FIELD_COLOR); - } else { - path.attr('fill', color(expression[tissue])); - } - } - }); - - // TODO: Potentially replicate some of the features in display.js like log-transforms and tooltips - }); - -} - -/** - * Handles the event when a select element is updated in the curatorSpecifcChooseGene function. - * If one select element was updated, it ensures the other is updated as well. - * It copies data from one select2 to the other and renders the dropdown for the other select2. - * If no gene is selected, it disables the plot button and displays an error message. - * @param {Event} event - The event object triggered by the select element update. - */ -const curatorSpecifcChooseGene = (event) => { - // If one select element was updated ensure the other is updated as well - const select2 = event.target.id === "gene_select" ? geneSelect : geneSelectPost; - const oppSelect2 = event.target.id === "gene_select" ? geneSelectPost : geneSelect; - const oppEltId = event.target.id === "gene_select" ? "gene_select_post" : "gene_select"; - - if (!select2.selectedOptions.length) return; // Do not trigger after initial population - - const val = getSelect2Value(select2); - - // NOTE: I thought about updating the select2 element directly with updateSelectValue() - // but this triggers the "change" event for the regular "select" element, which causes a max stack call error - setSelectBoxByValue(oppEltId, val); - - // copy data from one select2 to the other - // Render the dropdown for the other select2 - oppSelect2.data = select2.data; - oppSelect2.options = select2.options; - oppSelect2.selectedOptions = select2.selectedOptions; - - // Recreate update() function without the extractData() call, which is causing noticeable slowdown/hanging - if (oppSelect2.dropdown) { - const open = oppSelect2.dropdown.classList.contains("open"); - oppSelect2.dropdown.parentNode.removeChild(oppSelect2.dropdown); - oppSelect2.create(); - - if (open) { - triggerClick(oppSelect2.dropdown); - } - } - - // Cannot plot if no gene is selected - if (document.getElementById("gene_select").value === "Please select a gene") { - document.getElementById("gene_s_failed").classList.remove("is-hidden"); - document.getElementById("gene_s_success").classList.add("is-hidden"); - document.getElementById("current_gene").textContent = ""; - for (const plotBtn of document.getElementsByClassName("js-plot-btn")) { - plotBtn.disabled = true; - } - return; - } - - document.getElementById("gene_s_failed").classList.add("is-hidden"); - document.getElementById("gene_s_success").classList.remove("is-hidden"); - // Display current selected gene - document.getElementById("current_gene_c").classList.remove("is-hidden"); - document.getElementById("current_gene").textContent = val; - // Force validationcheck to see if plot button should be enabled - trigger(document.querySelector(".js-plot-req"), "change"); - document.getElementById("plot_options_s").click(); -} - -/** - * Creates a plot based on the specified plot type. - * @param {string} plotType - The type of plot to create. - * @returns {Promise} - A promise that resolves when the plot is created. - */ -const curatorSpecifcCreatePlot = async (plotType) => { - // Call API route by plot type - if (plotlyPlots.includes(plotType)) { - await plotStyle.createPlot(datasetId, analysisObj); - - } else if (scanpyPlots.includes(plotType)) { - await plotStyle.createPlot(datasetId, analysisObj); - - } else if (plotType === "svg") { - await plotStyle.createPlot(datasetId); - } else { - console.warn(`Plot type ${plotType} selected for plotting is not a valid type.`) - return; - } - -} - -/** - * Callback function for curator specific dataset tree. - * Creates gene select2 elements for both views. - * @returns {void} - */ -const curatorSpecifcDatasetTreeCallback = () => { - - // Not providing the object in the argument could duplicate the nice-select2 structure if called multiple times - geneSelect = createGeneSelectInstance("gene_select", geneSelect); - geneSelectPost = createGeneSelectInstance("gene_select_post", geneSelectPost); - document.getElementById("current_gene").textContent = ""; - -} - -/** - * Updates the curator-specific navbar with the current page information. - */ -const curatorSpecificNavbarUpdates = () => { - document.querySelector("#header_bar .navbar-item").textContent = "Single-gene Curator"; - - for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { - elt.classList.remove("is-active"); - } - - document.querySelector("a[tool='sg_curator'").classList.add("is-active"); -} - -const curatorSpecificOnLoad = async () => { - // pass -} - -/** - * Returns a specific plot style handler based on the given plot type. - * @param {string} plotType - The type of plot. - * @returns {PlotlyHandler|ScanpyHandler|SvgHandler|null} - The plot style handler. - */ -const curatorSpecificPlotStyle = (plotType) => { - // include plotting backend options - if (plotlyPlots.includes(plotType)) { - return new PlotlyHandler(plotType); - } else if (scanpyPlots.includes(plotType)) { - return new ScanpyHandler(plotType); - } else if (plotType === "svg") { - return new SvgHandler(); - } else { - return null; - } -} - -/** - * Adjusts the plot type for the dataset curator. - * @param {string} plotType - The original plot type. - * @returns {string} - The adjusted plot type. - */ -const curatorSpecificPlotTypeAdjustments = (plotType) => { - // ? Move this to class constructor to handle - if (plotType.toLowerCase() === "tsne") { - // Handle legacy plots - plotType = "tsne_static"; - } else if (["tsne/umap_dynamic", "tsne_dynamic"].includes(plotType.toLowerCase())) { - plotType = "tsne_dyna"; - } - return plotType -} - -/** - * Updates the gene options in the curator specific section. - * - * @param {Array} geneSymbols - The array of gene symbols to update the options with. - */ -const curatorSpecificUpdateGeneOptions = (geneSymbols) => { - // copy to "#gene_select_post" - const geneSelectEltPost = document.getElementById("gene_select_post"); - geneSelectEltPost.replaceChildren(); - for (const gene of geneSymbols.sort()) { - const option = document.createElement("option"); - option.textContent = gene; - option.value = gene; - geneSelectEltPost.append(option); - } - -} - -/** - * Fetches Plotly data for a given dataset, analysis, plot type, and plot configuration. - * @param {string} datasetId - The ID of the dataset. - * @param {string} analysis - The analysis to perform. - * @param {string} plotType - The type of plot to create. - * @param {object} plotConfig - The configuration options for the plot. - * @returns {Promise} - The fetched Plotly data. - * @throws {Error} - If the data fetch fails or an error occurs. - */ -const fetchPlotlyData = async (datasetId, analysis, plotType, plotConfig) => { - // NOTE: gene_symbol already passed to plotConfig - try { - const data = await apiCallsMixin.fetchPlotlyData(datasetId, analysis, plotType, plotConfig); - if (data?.success < 1) { - throw new Error (data?.message ? data.message : "Unknown error.") - } - return data - } catch (error) { - logErrorInConsole(error); - const msg = "Could not create Plotly plot for this dataset and parameters. Please contact the gEAR team." - createToast(msg); - throw new Error(msg); - } -} - -/** - * Fetches SVG data for a given dataset and gene symbol. - * @param {string} datasetId - The ID of the dataset. - * @param {object} plotConfig - The configuration options for the plot. - * @returns {Promise} - The fetched SVG data. - * @throws {Error} - If there is an error fetching the SVG data. - */ -const fetchSvgData = async (datasetId, plotConfig) => { - try { - const {gene_symbol: geneSymbol} = plotConfig; - const data = await apiCallsMixin.fetchSvgData(datasetId, geneSymbol); - if (data?.success < 1) { - throw new Error (data?.message ? data.message : "Unknown error.") - } - return data - } catch (error) { - logErrorInConsole(error); - const msg = "Could not fetch SVG data for this dataset and parameters. Please contact the gEAR team." - createToast(msg); - throw new Error(msg); - } -}; - -/** - * Fetches the TSNE image for a given dataset, analysis, plot type, and plot configuration. - * - * @param {string} datasetId - The ID of the dataset. - * @param {string} analysis - The analysis type. - * @param {string} plotType - The type of plot. - * @param {object} plotConfig - The configuration for the plot. - * @returns {Promise} - The fetched data. - * @throws {Error} - If there is an error fetching the data or creating the plot image. - */ -const fetchTsneImage = async (datasetId, analysis, plotType, plotConfig) => { - // NOTE: gene_symbol already passed to plotConfig - try { - const data = await apiCallsMixin.fetchTsneImage(datasetId, analysis, plotType, plotConfig); - if (data?.success < 1) { - throw new Error (data?.message ? data.message : "Unknown error.") - } - return data; - } catch (error) { - logErrorInConsole(error); - const msg = "Could not create plot image for this dataset and parameters. Please contact the gEAR team." - createToast(msg); - throw new Error(msg); - } -} - - -/** - * Renders the color picker for a given series name. - * - * @param {string} seriesName - The name of the series. - */ -const renderColorPicker = (seriesName) => { - const colorsContainer = document.getElementById("colors_container"); - const colorsSection = document.getElementById("colors_section"); - - colorsSection.classList.add("is-hidden"); - colorsContainer.replaceChildren(); - if (!seriesName) { - return; - } - - if (!(catColumns.includes(seriesName))) { - // ? Continuous series colorbar picker - return; - } - - const seriesNameElt = document.createElement("p"); - seriesNameElt.classList.add("has-text-weight-bold", "is-underlined"); - seriesNameElt.textContent = seriesName; - colorsContainer.append(seriesNameElt); - - // Otherwise d3 category10 colors - const swatchColors = ["#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"]; - - let counter = 0; - for (const group of levels[seriesName]) { - const darkerLevel = Math.floor(counter / 10); - const baseColor = swatchColors[counter%10]; - const groupColor = darkerLevel > 0 - ? d3.color(baseColor).darker(darkerLevel).formatHex() - : baseColor; // Cycle through swatch but make darker if exceeding 10 groups - counter++; - - const groupElt = document.createElement("p"); - groupElt.classList.add("is-flex", "is-justify-content-space-between", "pr-3"); - - const groupText = document.createElement("span"); - groupText.classList.add("has-text-weight-medium"); - groupText.textContent = group; - - const colorInput = document.createElement("input"); - colorInput.classList.add("js-plot-color"); - colorInput.id = `${group}_color`; - colorInput.type = "color"; - colorInput.value = groupColor; - colorInput.setAttribute("aria-label", "Select a color"); - - groupElt.append(groupText, colorInput); - colorsContainer.append(groupElt); - } - - colorsSection.classList.remove("is-hidden"); -} - -/** - * Sets up the options for Plotly. - * @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 plotType = getSelect2Value(plotTypeSelect); - try { - ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); - } catch (error) { - document.getElementById("plot_options_s_failed").classList.remove("is-hidden"); - return; - } - // Filter out values we don't want of "levels", like "colors" - allColumns = allColumns.filter((col) => !col.includes("_colors")); - for (const key in levels) { - if (key.includes("_colors")) { - delete levels[key]; - } - } - - catColumns = Object.keys(levels); - - - const difference = (arr1, arr2) => arr1.filter(x => !arr2.includes(x)) - const continuousColumns = difference(allColumns, catColumns); - - // class name, list of columns, add expression, default category - - const xColumns = ["bar", "violin"].includes(plotType) ? catColumns : allColumns; - const xUseRaw = ["bar", "violin"].includes(plotType) ? false : true; - const yColumns = ["bar", "violin"].includes(plotType) ? continuousColumns : allColumns; - - updateSeriesOptions("js-plotly-x-axis", xColumns, xUseRaw); - updateSeriesOptions("js-plotly-y-axis", yColumns, true, "raw_value"); - updateSeriesOptions("js-plotly-color", allColumns, true); - updateSeriesOptions("js-plotly-label", allColumns, true); - updateSeriesOptions("js-plotly-facet-row", catColumns, false); - updateSeriesOptions("js-plotly-facet-col", catColumns, false); - - // If plot_type is bar or line, disable the marker size options - if (["bar", "line", "violin"].includes(plotType)) { - for (const elt of document.getElementsByClassName("js-plotly-size")) { - elt.disabled = true; - elt.value = ""; - } - - for (const elt of document.getElementsByClassName("js-plotly-marker-size")) { - elt.disabled = true; - elt.value = ""; - } - } - - - const xAxisSeriesElts = document.getElementsByClassName("js-plotly-x-axis"); - // If x-axis is categorical, enable jitter plots - if (["violin", "scatter", "tsne_dyna"].includes(plotType)) { - for (const elt of xAxisSeriesElts) { - elt.addEventListener("change", (event) => { - const jitterElts = document.getElementsByClassName("js-plotly-add-jitter"); - if ((catColumns.includes(event.target.value))) { - // categorical x-axis - for (const jitterElt of jitterElts) { - jitterElt.disabled = false; - disableCheckboxLabel(jitterElt, false); - } - } else { - for (const jitterElt of jitterElts) { - jitterElt.disabled = true; - jitterElt.checked = false; - disableCheckboxLabel(jitterElt, true); - } - - } - - }); - } - } - - - if (["scatter", "tsne_dyna"].includes(plotType)) { - updateSeriesOptions("js-plotly-size", continuousColumns, true); - // If x-axis is continuous show vline stuff, otherwise hide - // If x-axis is categorical, enable jitter plots - for (const elt of xAxisSeriesElts) { - elt.addEventListener("change", (event) => { - const vLinesContainer = document.getElementById("vlines_container") - if ((catColumns.includes(event.target.value))) { - vLinesContainer.classList.add("is-hidden"); - // Remove all but first existing vline - const toRemove = document.querySelectorAll(".js-plotly-vline-field:not(:first-of-type)"); - for (const elt of toRemove) { - elt.remove(); - } - // Clear the first vline - document.querySelector(".js-plotly-vline-pos").value = ""; - document.querySelector(".js-plotly-vline-style-select").value = "solid"; - return; - } - vLinesContainer.classList.remove("is-hidden"); - - }); - } - - - // Vertical line add and remove events - const vLinesBody = document.getElementById("vlines_body"); - const vLineField = document.querySelector(".js-plotly-vline-field"); - document.getElementById("vline_add_btn").addEventListener("click", (event) => { - vLinesBody.append(vLineField.cloneNode(true)); - // clear the values of the clone - document.querySelector(".js-plotly-vline-field:last-of-type .js-plotly-vline-pos").value = ""; - document.querySelector(".js-plotly-vline-field:last-of-type .js-plotly-vline-style-select").value = "solid"; - // NOTE: Currently if original is set before cloning, values are copied to clone - document.getElementById("vline_remove_btn").disabled = false; - }) - document.getElementById("vline_remove_btn").addEventListener("click", (event) => { - // Remove last vline - const lastVLine = document.querySelector(".js-plotly-vline-field:last-of-type"); - lastVLine.remove(); - if (vLineField.length < 2) document.getElementById("vline_remove_btn").disabled = true; - }) - } - - // If color series is selected, let user choose colors. - const colorSeriesElts = document.getElementsByClassName("js-plotly-color"); - const colorPaletteElts = document.getElementsByClassName("js-plotly-color-palette"); - const reversePaletteElts = document.getElementsByClassName("js-plotly-reverse-palette"); - const hideLegend = document.getElementsByClassName("js-plotly-hide-legend"); - for (const elt of colorSeriesElts) { - elt.addEventListener("change", (event) => { - if ((catColumns.includes(event.target.value))) { - renderColorPicker(event.target.value); - for (const paletteElt of [...colorPaletteElts, ...reversePaletteElts]) { - paletteElt.disabled = true; - } - for (const legendElt of hideLegend) { - legendElt.disabled = false; - disableCheckboxLabel(legendElt, false); - } - document.getElementById("color_palette_post").disabled = true; - //colorscaleSelect.disable() - } else { - // Enable the color palette select - for (const paletteElt of [...colorPaletteElts, ...reversePaletteElts]) { - paletteElt.disabled = false; - } - for (const legendElt of hideLegend) { - legendElt.disabled = true; - legendElt.checked = false; - disableCheckboxLabel(legendElt, true); - } - document.getElementById("color_palette_post").disabled = false; - //colorscaleSelect.enable() - } - }) - } - - - // Certain elements trigger plot order - const plotOrderElts = document.getElementsByClassName("js-plot-order"); - for (const elt of plotOrderElts) { - elt.addEventListener("change", (event) => { - const paramId = event.target.id; - const param = paramId.replace("_series", "").replace("_post", ""); - // NOTE: continuous series will be handled in the function - updateOrderSortable(); - }); - } - - // Trigger event to enable plot button (in case we switched between plot types, since the HTML vals are saved) - const xSeries = document.getElementById("x_axis_series"); - const ySeries = document.getElementById("y_axis_series"); - if (xSeries.value) { - // If value is categorical, disable min and max boundaries - for (const elt of [...document.getElementsByClassName("js-plotly-x-min"), ...document.getElementsByClassName("js-plotly-x-max")]) { - elt.disabled = catColumns.includes(xSeries.value) - } - trigger(xSeries, "change"); - } - if (ySeries.value) { - // If value is categorical, disable min and max boundaries - for (const elt of [...document.getElementsByClassName("js-plotly-y-min"), ...document.getElementsByClassName("js-plotly-y-max")]) { - elt.disabled = catColumns.includes(ySeries.value) - } - trigger(ySeries, "change"); - } - - // Setup the dropdown menu on the post-plot view - const plotlyDropdown = document.getElementById("plotly_param_dropdown"); - plotlyDropdown.addEventListener("click", (event) => { - event.stopPropagation(); // This prevents the document from being clicked as well. - plotlyDropdown.classList.toggle("is-active"); - }) - - // Close dropdown if it is clicked off of, or ESC is pressed - // https://siongui.github.io/2018/01/19/bulma-dropdown-with-javascript/#footnote-1 - document.addEventListener('click', () => { - plotlyDropdown.classList.remove("is-active"); - }); - document.addEventListener('keydown', (event) => { - if (event.key === "Escape") { - plotlyDropdown.classList.remove("is-active"); - } - }); - - const plotlyDropdownMenuItems = document.querySelectorAll("#plotly_param_dropdown .dropdown-item"); - for (const item of plotlyDropdownMenuItems) { - item.addEventListener("click", showPostPlotlyParamSubsection); - } - -} - -/** - * Sets up the options for Scanpy analysis. - * @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 plotType = getSelect2Value(plotTypeSelect); - try { - ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); - } catch (error) { - document.getElementById("plot_options_s_failed").classList.remove("is-hidden"); - return; - } - - // Filter out values we don't want of "levels", like "colors" - allColumns = allColumns.filter((col) => !col.includes("_colors")); - for (const key in levels) { - if (key.includes("_colors")) { - delete levels[key]; - } - } - catColumns = Object.keys(levels); - - let xDefaultOption = null; - let yDefaultOption = null; - - // If these exist, make the default option - switch (plotType) { - case "pca_static": - xDefaultOption = "X_pca_1"; - yDefaultOption = "X_pca_2"; - break; - case "tsne_static": - xDefaultOption = "X_tsne_1"; - yDefaultOption = "X_tsne_2"; - break; - case "umap_static": - xDefaultOption = "X_umap_1"; - yDefaultOption = "X_umap_2"; - break; - } - - updateSeriesOptions("js-tsne-x-axis", allColumns, true, xDefaultOption); - updateSeriesOptions("js-tsne-y-axis", allColumns, true, yDefaultOption); - updateSeriesOptions("js-tsne-colorize-legend-by", allColumns, false); - updateSeriesOptions("js-tsne-plot-by-series", catColumns, false); - - const colorizeLegendBy = document.getElementsByClassName("js-tsne-colorize-legend-by"); - const plotBySeries = document.getElementsByClassName("js-tsne-plot-by-series"); - const maxColumns = document.getElementsByClassName('js-tsne-max-columns'); - const skipGenePlot = document.getElementsByClassName("js-tsne-skip-gene-plot"); - const horizontalLegend = document.getElementsByClassName("js-tsne-horizontal-legend"); - - // Do certain things if the chosen annotation series is categorical or continuous - for (const elt of colorizeLegendBy) { - elt.addEventListener("change", (event) => { - for (const targetElt of [...plotBySeries, ...horizontalLegend]) { - targetElt.disabled = true; - // If colorized legend is continuous, we cannot plot by group - // So all dependencies need to be disabled. - if ((catColumns.includes(event.target.value))) { - renderColorPicker(event.target.value); - targetElt.disabled = false; - disableCheckboxLabel(targetElt, false); - } - } - - // The "max columns" parameter should only be disabled if the colorized legend is continuous - if (!(catColumns.includes(event.target.value))) { - for (const targetElt of maxColumns) { - targetElt.disabled = true; - disableCheckboxLabel(targetElt, true); - } - } - }); - - elt.addEventListener("change", (event) => { - renderColorPicker(event.target.value); - return; - }) - } - - // Plotting by group plots gene expression, so cannot skip gene plots. - for (const elt of plotBySeries) { - elt.addEventListener("change", (event) => { - // Must plot gene expression if series value selected - for (const targetElt of skipGenePlot) { - targetElt.disabled = event.target.value ? true : false; - if (event.target.value) targetElt.checked = false; - disableCheckboxLabel(targetElt, targetElt.disabled); - } - // Must be allowed to specify max columns if series value selected - for (const targetElt of maxColumns) { - targetElt.disabled = event.target.value ? false : true; - } - updateOrderSortable(); - - }); - } - - // Trigger event to enable plot button (in case we switched between plot types, since the HTML vals are saved) - if (document.getElementById("x_axis_series").value) { - trigger(document.getElementById("x_axis_series"), "change"); - } - if (document.getElementById("y_axis_series").value) { - trigger(document.getElementById("y_axis_series"), "change"); - } - - // If override marker size is checked, enable the marker size field - const overrideMarkerSize = document.getElementsByClassName("js-tsne-override-marker-size"); - const markerSize = document.getElementsByClassName("js-tsne-marker-size"); - for (const elt of overrideMarkerSize) { - elt.addEventListener("change", (event) => { - for (const targetElt of markerSize) { - targetElt.disabled = event.target.checked ? false : true; - } - }); - } - -} - -/** - * Sets up SVG options for dataset curator. - */ -const setupSVGOptions = () => { - const enableMidColorElts = document.getElementsByClassName("js-svg-enable-mid"); - const midColorElts = document.getElementsByClassName("js-svg-mid-color"); - const midColorFields = document.getElementsByClassName("js-mid-color-field"); - for (const elt of enableMidColorElts) { - elt.addEventListener("change", (event) => { - for (const field of midColorFields) { - field.style.display = (event.target.checked) ? "" : "none"; - } - for (const midColor of midColorElts) { - midColor.disabled = !(event.target.checked); - } - }); - } - - - // Trigger event to enable plot button (in case we switched between plot types, since the HTML vals are saved) - if (document.getElementById("low_color").value) { - trigger(document.getElementById("low_color"), "change"); - } - if (document.getElementById("high_color").value) { - trigger(document.getElementById("high_color"), "change"); - } -} - -/** - * Shows the corresponding subsection based on the selected option in the plot configuration menu. - * @param {Event} event - The event triggered by the user's selection. - */ -const showPostPlotlyParamSubsection = (event) => { - for (const subsection of document.getElementsByClassName("js-plot-config-section")) { - subsection.classList.add("is-hidden"); - } - - switch (event.target.textContent.trim()) { - case "X-axis": - document.getElementById("x_axis_section_post").classList.remove("is-hidden"); - break; - case "Y-axis": - document.getElementById("y_axis_section_post").classList.remove("is-hidden"); - break; - case "Color": - document.getElementById("color_section_post").classList.remove("is-hidden"); - break; - case "Marker Size": - document.getElementById("size_section_post").classList.remove("is-hidden"); - break; - case "Subplots": - document.getElementById("subplots_section_post").classList.remove("is-hidden"); - break; - default: - document.getElementById("misc_section_post").classList.remove("is-hidden"); - break; - } - event.preventDefault(); // Prevent "link" clicking from "a" elements -} - -/** - * Updates the series options in a select element based on the provided parameters. - * - * @param {string} classSelector - The class selector for the select elements to update. - * @param {Array} seriesArray - An array of series names. - * @param {boolean} addExpression - Indicates whether to add an expression option. - * @param {string} defaultOption - The default option to select. - */ -const updateSeriesOptions = (classSelector, seriesArray, addExpression, defaultOption) => { - - for (const elt of document.getElementsByClassName(classSelector)) { - elt.replaceChildren(); - - // Create continuous and categorical optgroups - const contOptgroup = document.createElement("optgroup"); - contOptgroup.setAttribute("label", "Continuous data"); - const catOptgroup = document.createElement("optgroup"); - catOptgroup.setAttribute("label", "Categorical data"); - - // Append empty placeholder element - const firstOption = document.createElement("option"); - elt.append(firstOption); - - // Add an expression option (since expression is not in the categories) - if (addExpression) { - const expression = document.createElement("option"); - contOptgroup.append(expression); - expression.textContent = "expression"; - expression.value = "raw_value"; - if ("raw_value" === defaultOption) { - expression.selected = true; - } - } - - // Add categories - for (const group of seriesArray.sort()) { - - const option = document.createElement("option"); - option.textContent = group; - // Change X_pca/X_tsne/X_umap text content to be more user_friendly - if (group.includes("X_") && ( - group.includes("pca") - || group.includes("tsne") - || group.includes("umap") - )) { - option.textContent = `${group} (from selected analysis)`; - } - option.value = group; - if (catColumns.includes(group)) { - catOptgroup.append(option); - } else { - contOptgroup.append(option); - } - // NOTE: It is possible for a default option to not be in the list of groups. - if (group === defaultOption) { - option.selected = true; - } - } - - // Only append optgroup if it has children - if (contOptgroup.children.length) elt.append(contOptgroup); - if (catOptgroup.children.length) elt.append(catOptgroup); - - } -} \ No newline at end of file diff --git a/www/js/gene_collection_manager.js b/www/js/gene_collection_manager.js index 408efba4..c655897c 100644 --- a/www/js/gene_collection_manager.js +++ b/www/js/gene_collection_manager.js @@ -154,7 +154,10 @@ const addGeneCollectionEventListeners = () => { classElt.addEventListener("click", async (e) => { const gcId = e.currentTarget.dataset.gcId; const selectorBase = `#result_gc_id_${gcId}`; - const newVisibility = document.querySelector(`${selectorBase}_editable_visibility`).value; + // + const newVisibility = document.querySelector(`${selectorBase}_editable_visibility`).checked; + // convert "true/false" visibility to 1/0 + const intNewVisibility = newVisibility ? 1 : 0; const newTitle = document.querySelector(`${selectorBase}_editable_title`).value; const newOrgId = document.querySelector(`${selectorBase}_editable_organism_id`).value; const newLdesc = document.querySelector(`${selectorBase}_editable_ldesc`).value; @@ -163,49 +166,56 @@ const addGeneCollectionEventListeners = () => { const {data} = await axios.post('./cgi/save_genecart_changes.cgi', convertToFormData({ 'session_id': CURRENT_USER.session_id, 'gc_id': gcId, - 'visibility': newVisibility, + 'visibility': intNewVisibility, 'title': newTitle, 'organism_id': newOrgId, 'ldesc': newLdesc || "" })); - // Update the UI for the new values - document.querySelector(`${selectorBase}_editable_visibility`).dataset.isPublic = newVisibility; - if (newVisibility) { - document.querySelector(`${selectorBase}_display_visibility`).textContent = "Public gene collection"; - document.querySelector(`${selectorBase}_table_visibility`).textContent = "Public"; - document.querySelector(`${selectorBase}_display_visibility`).classList.remove("is-danger"); - document.querySelector(`${selectorBase}_display_visibility`).classList.add("is-light"); - } else { - document.querySelector(`${selectorBase}_display_visibility`).textContent = "Private gene collection"; - document.querySelector(`${selectorBase}_table_visibility`).textContent = "Private"; - document.querySelector(`${selectorBase}_display_visibility`).classList.remove("is-light"); - document.querySelector(`${selectorBase}_display_visibility`).classList.add("is-danger"); - } - - document.querySelector(`${selectorBase}_editable_title`).dataset.originalVal = newTitle; - document.querySelector(`${selectorBase}_display_title`).textContent = newTitle; - document.querySelector(`${selectorBase}_table_title`).textContent = newTitle; - - document.querySelector(`${selectorBase}_editable_ldesc`).dataset.originalVal = newLdesc; - document.querySelector(`${selectorBase}_display_ldesc`).textContent = newLdesc || "No description entered";; - - document.querySelector(`${selectorBase}_display_organism`).textContent = - document.querySelector(`${selectorBase}_editable_organism_id > option[value='${newOrgId}']`).textContent; - document.querySeleector(`${selectorBase}_table_organism`).textContent = document.querySelector(`${selectorBase}_display_organism`).textContent; - document.querySelector(`${selectorBase}_editable_organism_id`).dataset.originalVal = newOrgId; - - // Put interface back to view mode. - toggleEditableMode(true, selectorBase); - createToast("Gene collection changes saved", "is-success"); } catch (error) { logErrorInConsole(error); createToast("Failed to save gene collection changes"); + return; } finally { document.querySelector(`${selectorBase} .js-action-links`).classList.remove("is-hidden"); } + + // Update the UI for the new values + document.querySelector(`${selectorBase}_editable_visibility`).dataset.isPublic = newVisibility; + if (newVisibility) { + document.querySelector(`${selectorBase}_display_visibility`).textContent = "Public gene collection"; + document.querySelector(`${selectorBase}_table_visibility`).textContent = "Public"; + document.querySelector(`${selectorBase}_display_visibility`).classList.remove("is-danger"); + document.querySelector(`${selectorBase}_display_visibility`).classList.add("is-light", "is-primary"); + document.querySelector(`${selectorBase}_table_visibility`).classList.remove("has-background-danger"); + document.querySelector(`${selectorBase}_table_visibility`).classList.add("has-background-primary-light"); + + } else { + document.querySelector(`${selectorBase}_display_visibility`).textContent = "Private gene collection"; + document.querySelector(`${selectorBase}_table_visibility`).textContent = "Private"; + document.querySelector(`${selectorBase}_display_visibility`).classList.remove("is-light", "is-primary"); + document.querySelector(`${selectorBase}_display_visibility`).classList.add("is-danger"); + document.querySelector(`${selectorBase}_table_visibility`).classList.remove("has-background-primary-light"); + document.querySelector(`${selectorBase}_table_visibility`).classList.add("has-background-danger"); + } + + document.querySelector(`${selectorBase}_editable_title`).dataset.originalVal = newTitle; + document.querySelector(`${selectorBase}_display_title`).textContent = newTitle; + document.querySelector(`${selectorBase}_table_title`).textContent = newTitle; + + document.querySelector(`${selectorBase}_editable_ldesc`).dataset.originalVal = newLdesc; + document.querySelector(`${selectorBase}_display_ldesc`).textContent = newLdesc || "No description entered";; + + document.querySelector(`${selectorBase}_display_organism`).textContent = + document.querySelector(`${selectorBase}_editable_organism_id > option[value='${newOrgId}']`).textContent; + document.querySelector(`${selectorBase}_table_organism`).textContent = document.querySelector(`${selectorBase}_display_organism`).textContent; + document.querySelector(`${selectorBase}_editable_organism_id`).dataset.originalVal = newOrgId; + + // Put interface back to view mode. + toggleEditableMode(true, selectorBase); + }); } diff --git a/www/js/multigene_curator.js b/www/js/multigene_curator.js index 993241f8..5782cba8 100644 --- a/www/js/multigene_curator.js +++ b/www/js/multigene_curator.js @@ -1,1028 +1,806 @@ -/* - SAdkins note - The styling on this page differs from other gEAR JS pages - I was trying to follow the recommended Javascript style in using camelCase - in addition using the StandardJS style linter ("semistandrd" variant) - with some refactoring done with the P42+ VSCode extension (to modernize code) - However, code inherited from common.js is still in snake_case rather than camelCase -*/ - 'use strict'; -/* global $, axios, Plotly, CURRENT_USER, session_id */ - -let numObs = 0; // dummy value to initialize with - -let obsFilters = {}; -let sortCategories = {"primary": null, "secondary": null}; // Control sorting order of chosen categories -let genesFilter = []; - -let plotConfig = {}; // Plot config that is passed to API or stored in DB -let plotData = null; // Plotly "data" JSON -let selectedGenes = null; // Genes selected in a plot (e.g. volcano with lasso tool) - -let datasetId = null; -let displayId = null; -let obsLevels = null; -let obsNotUsed = null; -let geneSymbols = null; - -const datasetTree = new DatasetTree({treeDiv: '#dataset_tree'}); -const geneCartTree = new GeneCartTree({treeDiv: '#gene_cart_tree'}); - -// Listing plot types divided by category, as well as create a master list -const genesAsAxisPlotTypes = ["dotplot", "heatmap", "mg_violin"]; -const genesAsDataPlotTypes = ["quadrant", "volcano"]; -const plotTypes = [...genesAsAxisPlotTypes, ...genesAsDataPlotTypes]; - -const dotplotOptsIds = ["#obs_primary_container", "#obs_secondary_container", "#colorscale_container"]; -const heatmapOptsIds = ["#heatmap_options_container", "#obs_primary_container", "#obs_secondary_container", '#colorscale_container']; -const quadrantOptsIds = ["#quadrant_options_container", "#de_test_container"]; -const violinOptsIds = ["#violin_options_container", "#obs_primary_container", "#obs_secondary_container", '#colorscale_container']; -const volcanoOptsIds = ["#volcano_options_container", "#de_test_container"]; - -window.onload = () => { - // Hide further configs until a dataset is chosen. - // Changing the dataset will start triggering these to show - $('#plot_type_container').hide(); - $('#gene_container').hide(); - - // Initialize plot types - $('#plot_type_select').select2({ - placeholder: 'Choose how to plot', - width: '25%', - minimumResultsForSearch: -1 - }); - // Create observer to watch if user changes (ie. successful login does not refresh page) - // See: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver +const isMultigene = 1; - // But we need to wait for navigation_bar to load first (in common.js) so do some polling - // See: https://stackoverflow.com/q/38881301 +let geneSelect = null; - // Select the node that will be observed for mutations - const targetNode = document.getElementById('loggedin_controls'); - const saferNode = document.getElementById("navigation_bar"); // Empty div until loaded - // Create an observer instance linked to the callback function - const observer = new MutationObserver(function(mutationList, observer) { - if (targetNode) { - reloadTrees(); - this.disconnect(); // Don't need to reload once the trees are updated - } - }); - // For the "config" settings, do not monitor the subtree of nodes as that will trigger the callback multiple times. - // Just seeing #loggedin_controls go from hidden (not logged in) to shown (logged in) is enough to trigger. - observer.observe(targetNode || saferNode , { attributes: true }); -}; - -// Call API to return plot JSON data -async function getData (datasetId, payload) { - payload.colorblind_mode = CURRENT_USER.colorblind_mode; - try { - const {data} = await axios.post(`/api/plot/${datasetId}/mg_dash`, { - ...payload - }) - return {data} - } catch (e) { - - const message = "There was an error in making this plot. Please contact the gEAR team using the 'Contact' button at the top of the page and provide as much information as possible."; - const success = -1; - const data = {message, success}; - return {data}; +let selectedGenes = []; // genes selected from plot "select" utility + +const genesAsAxisPlots = ["dotplot", "heatmap", "mg_violin"]; +const genesAsDataPlots = ["quadrant", "volcano"]; + +class GenesAsAxisHandler extends PlotHandler { + constructor(plotType) { + super(); + this.plotType = plotType; + this.apiPlotType = plotType; } -} -// Call API to return a list of the dataset's gene symbols -async function fetchGeneSymbols (payload) { - const { datasetId, analysis } = payload; - const base = `./api/h5ad/${datasetId}/genes`; - const query = analysis ? `?analysis=${analysis.id}` : ''; + classElt2Prop = { + "js-dash-primary":"primary_col" + , "js-dash-secondary":"secondary_col" + , "js-dash-color-palette":"colorscale" + , "js-dash-reverse-palette":"reverse_colorscale" + , "js-dash-distance-metric":"distance_metric" + , "js-dash-matrixplot":"matrixplot" + , "js-dash-center-mean": "center_around_zero" + , "js-dash-cluster-obs": "cluster_obs" + , "js-dash-cluster-genes": "cluster_genes" + , "js-dash-flip-axes": "flip_axes" + , "js-dash-hide-obs-labels": "hide_obs_labels" + , "js-dash-hide-gene-labels": "hide_gene_labels" + , "js-dash-add-jitter": "violin_add_points" + , "js-dash-subsampling-limit": "subsample_limit" + , "js-dash-stacked-violin": "stacked_violin" + , "js-dash-plot-title": "plot_title" + , "js-dash-legend-title": "legend_title" + } - const { data } = await axios.get(`${base}${query}`); - return [...new Set(data.gene_symbols)]; // Dataset may have a gene repeated in it, so resolve this. -} + // deal with clusterbar separately since it is an array of selected options -// Call API to return all observations -async function fetchH5adInfo (payload) { - const { datasetId, analysis } = payload; - const base = `./api/h5ad/${datasetId}`; - const query = analysis ? `?analysis=${analysis.id}` : ''; - const { data } = await axios.get(`${base}${query}`); - return data; -} + configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); -async function loadDatasets () { - $('#pre_dataset_spinner').show(); - await $.ajax({ - type: 'POST', - url: './cgi/get_h5ad_dataset_list.cgi', - data: { - session_id - }, - dataType: 'json', - success(data) { - let counter = 0; - - // Populate select box with dataset information owned by the user - const userDatasets = []; - if (data.user.datasets.length > 0) { - // User has some profiles - $.each(data.user.datasets, (_i, item) => { - if (item) { - userDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }); - } - // Next, add datasets shared with the user - const sharedDatasets = []; - if (data.shared_with_user.datasets.length > 0) { - $.each(data.shared_with_user.datasets, (_i, item) => { - if (item) { - sharedDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }); + plotConfig = {}; // Plot config that is passed to API + + plotJson = null; // Plotly plot JSON + + cloneDisplay(config) { + // load plot values + for (const prop in config) { + setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config[prop]); + } + + // Handle order + if (config["sort_order"]) { + for (const series in config["sort_order"]) { + const order = config["sort_order"][series]; + // sort "levels" series by order + levels[series].sort((a, b) => order.indexOf(a) - order.indexOf(b)); + renderOrderSortableSeries(series); } - // Now, add public datasets - const domainDatasets = []; - if (data.public.datasets.length > 0) { - $.each(data.public.datasets, (_i, item) => { - if (item) { - domainDatasets.push({ value: counter++, text: item.title, dataset_id : item.id, organism_id: item.organism_id }); - } - }); + + document.getElementById("order_section").classList.remove("is-hidden"); + } + + // Handle filters + if (config["obs_filters"]) { + facetWidget.filters = config["obs_filters"]; + } + + // handle colors + if (config["color_palette"]) { + setSelectBoxByValue("color_palette_post", config["color_palette"]); + //colorscaleSelect.update(); + } + + // handle clusterbar values + if (config["clusterbar_fields"]) { + for (const field of config["clusterbar_fields"]) { + const elt = document.querySelector(`#clusterbar_c .js-dash-clusterbar-checkbox[value="${field}"]`); + elt.checked = true; } + } - datasetTree.userDatasets = userDatasets; - datasetTree.sharedDatasets = sharedDatasets; - datasetTree.domainDatasets = domainDatasets; - datasetTree.generateTree(); - }, - error(_xhr, _status, msg) { - console.error(`Failed to load dataset list because msg: ${msg}`); + // restore subsample limit + if (config["subsample_limit"]) { + for (const classElt of document.getElementsByClassName("js-dash-enable-subsampling")) { + classElt.checked = true; + } } - }); - $('#pre_dataset_spinner').hide(); -} -// Draw plotly chart to image -async function drawPreviewImage (display) { - // check if config has been stringified - const config = typeof display.plotly_config === 'string' ? JSON.parse(display.plotly_config) : display.plotly_config; - const { data } = await getData(datasetId, config); - - // If there was an error in the plot, put alert up - if ( data.success < 1 ) { - $(`#modal-display-img-${display.id} + .js-plot-error`).show(); - $(`#modal-display-img-${display.id} + .js-plot-error`).html(data.message); - $(`#modal-display-${display.id}-loading`).hide(); - return; } - const { plot_json: plotlyJson } = data; - Plotly.toImage( - { ...plotlyJson, ...{static_plot:true} }, - { height: 500, width: 500 } - ).then(url => { - $(`#modal-display-img-${display.id}`).prop('src', url); - }).then( - () => { $(`#modal-display-${display.id}-loading`).hide(); } - ); -} + async createPlot(datasetId, analysisObj) { + // Get data and set up the image area + try { + const data = await fetchDashData(datasetId, analysisObj, this.apiPlotType, this.plotConfig); + ({plot_json: this.plotJson} = data); + } catch (error) { + return; + } -// Invert a log function -function invertLogFunction(value, base=10) { - return base ** value; -} + const plotContainer = document.getElementById("plot_container"); + plotContainer.replaceChildren(); // erase plot + + // NOTE: Plot initially is created to a default width but is responsive. + // Noticed container within our "column" will make full-width go beyond the screen + const plotlyPreview = document.createElement("div"); + plotlyPreview.classList.add("container", "is-max-desktop"); + plotlyPreview.id = "plotly_preview"; + plotContainer.append(plotlyPreview); + Plotly.purge("plotly_preview"); // clear old Plotly plots + + if (!this.plotJson) { + createToast("Could not retrieve plot information. Cannot make plot."); + return; + } -// Draw plotly chart in HTML -function drawChart (data, datasetId) { - const targetDiv = `dataset_${datasetId}_h5ad`; - const parentDiv = `dataset_${datasetId}`; - const { plot_json: plotlyJson, message, success } = data; + if (this.plotType === 'heatmap') { + setHeatmapHeightBasedOnGenes(this.plotJson.layout, this.plotConfig.gene_symbols); + } else if (this.plotType === "mg_violin" && this.plotConfig.stacked_violin){ + adjustStackedViolinHeight(this.plotJson.layout); + } - // Since default plots are now added after dataset selection, wipe the plot when a new one needs to be drawn - $(`#${targetDiv}`).empty() + // Update plot with custom plot config stuff stored in plot_display_config.js + const curatorDisplayConf = postPlotlyConfig.curator; + const custonConfig = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "config"); + Plotly.newPlot("plotly_preview", this.plotJson.data, this.plotJson.layout, custonConfig); + const custonLayout = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "layout") + Plotly.relayout("plotly_preview", custonLayout) - // If there was an error in the plot, put alert up - if ( success < 1 || !plotlyJson.layout) { - $(`#${parentDiv} .js-plot-error`).show(); - $(`#${parentDiv} .js-plot-error`).html(message); - return; - } + document.getElementById("legend_title_container").classList.remove("is-hidden"); + if (this.plotType === "dotplot") { + document.getElementById("legend_title_container").classList.add("is-hidden"); + } - // Make some complex edits to the plotly layout - const plotType = $('#plot_type_select').select2('data')[0].id; - if (plotType === 'heatmap') { - setHeatmapHeightBasedOnGenes(plotlyJson.layout, genesFilter); - } else if (plotType=== "mg_violin" && $("#stacked_violin").is(":checked")){ - adjustStackedViolinHeight(plotlyJson.layout); } - // Get config - const curatorConf = post_plotly_config.curator; - const plotlyConfig = getPlotlyUpdates(curatorConf, plotType, "config"); + async loadPlotHtml() { + const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); + prePlotOptionsElt.replaceChildren(); - // Update plot with custom plot config stuff stored in plot_display_config.js - const updateLayout = getPlotlyUpdates(curatorConf, plotType, "layout"); + const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); + postPlotOptionsElt.replaceChildren(); - Plotly.newPlot(targetDiv, plotlyJson.data, plotlyJson.layout, plotlyConfig); - Plotly.relayout(targetDiv, updateLayout) + prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/multi_gene_as_axis.html"); + postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/multi_gene_as_axis.html"); - plotData = plotlyJson.data; + // populate advanced options for specific plot types + const prePlotSpecificOptionsElt = document.getElementById("plot_specific_options"); + const postPlotSpecificOptionselt = document.getElementById("post_plot_specific_options"); - // Show any warnings from the API call - if (message && success === 2) { - $(`#${parentDiv} .js-plot-warning`).show(); - $(`#${parentDiv} .js-plot-warning`).html(`
    ${message}
`); - } + // Load color palette select options + const isContinuous = ["dotplot", "heatmap"].includes(this.plotType) ? true : false; + loadColorscaleSelect(isContinuous); - // If plot data is selected, create the right-column table and do other misc things - $(`#dataset_${datasetId}_h5ad`).on("plotly_selected", (_e, data) => { - if (!(['volcano', 'quadrant'].includes(plotConfig.plot_type))) { + if (this.plotType === "heatmap") { + prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_heatmap.html"); + postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_heatmap.html"); return; } + if (this.plotType === "mg_violin") { + prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_mg_violin.html"); + postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_mg_violin.html"); + return; + } + } - // Note: the jQuery implementation has slightly different arguments than what is in the plotlyJS implementation - // We want 'data', which returns the eventData PlotlyJS events normally return - selectedGenes = []; - - data.points.forEach((pt) => { - selectedGenes.push({ - gene_symbol: pt.data.text[pt.pointNumber], - ensembl_id: pt.data.customdata[pt.pointNumber], // Ensembl ID stored in "customdata" property - x: pt.data.x[pt.pointNumber].toFixed(1), - y: plotConfig.plot_type === "volcano" ? invertLogFunction(-pt.data.y[pt.pointNumber]).toExponential(2) : pt.data.y[pt.pointNumber].toFixed(2), - }); - }); - - // Sort in alphabetical order - selectedGenes.sort(); + populatePlotConfig() { + this.plotConfig = {}; // Reset plot config - // Adjust headers to the plot type - if (plotConfig.plot_type === "quadrant") { - $("#gene_x").html('X LFC '); - $("#gene_y").html('Y LFC '); - } else { - // volcano - $("#gene_x").html('LFC '); - $("#gene_y").html('Pval '); + for (const classElt in this.classElt2Prop) { + this.plotConfig[this.classElt2Prop[classElt]] = getPlotConfigValueFromClassName(classElt) } - const template = $.templates("#selected_genes_tmpl"); - const htmlOutput = template.render(selectedGenes); - $("#selected_genes_c").html(htmlOutput); - - // Highlight table rows that match searched genes - if (genesFilter.length) { - // Select the first column (gene_symbols) in each row - $("#selected_genes_c tr td:first-child").each(function() { - const tableGene = $(this).text(); - genesFilter.forEach((gene) => { - if (gene.toLowerCase() === tableGene.toLowerCase() ) { - $(this).parent().addClass("table-success"); - } - }); - }) - } + // Get checked clusterbar values + if (this.plotType === "heatmap") { + const clusterbarValues = []; + // They should be synced so just grab the first set of clusterbar values + for (const elt of document.querySelectorAll("#clusterbar_c .js-dash-clusterbar-checkbox")) { + if (elt.checked) { + clusterbarValues.push(elt.value); + } + } + this.plotConfig["clusterbar_fields"] = clusterbarValues; - $("#saved_gene_cart_info_c").hide(); - $("#tbl_selected_genes").show(); - }); -} -// Submit API request and draw the HTML -async function draw (datasetId, payload) { - const {data } = await getData(datasetId, payload); - drawChart(data, datasetId); -} + // if subsampling is checked, add subsampling limit to plot config + this.plotConfig["subsample_limit"] = 0; + if (document.querySelector(".js-dash-enable-subsampling").checked) { + this.plotConfig["subsample_limit"] = Number(document.querySelector(".js-dash-subsampling-limit").value); + } + } -// Load gene carts and datasets before the dropdown appears -$(document).on("build_jstrees", async () => { - await reloadTrees(); + // Filtered observation groups + this.plotConfig["obs_filters"] = facetWidget?.filters || {}; + + // Get order + this.plotConfig["sort_order"] = getPlotOrderFromSortable(); - // If brought here by the "gene search results" page, curate on the dataset ID that referred us - const linkedDatasetId = getUrlParameter("dataset_id"); - if (linkedDatasetId) { - $("#dataset").val(linkedDatasetId); - try { - // Had difficulties triggering a "select_node.jstree" event, so just add the data info here - const tree_leaf = datasetTree.treeData.find(e => e.dataset_id === linkedDatasetId); - $("#dataset").text(tree_leaf.text); - $("#dataset").data("organism-id", tree_leaf.organism_id); - $("#dataset").data("dataset-id", tree_leaf.dataset_id); - $("#dataset").trigger('change'); - } catch { - console.error(`Dataset id ${linkedDatasetId} was not returned as a public/private/shared dataset`); - } } -}); -// If user changes, update genecart/profile trees -async function reloadTrees(){ - // Update dataset and genecart trees in parallel - // Works if they were not populated or previously populated - await Promise.allSettled([loadDatasets(), loadGeneCarts()]) - .catch((err) => { - console.error(err) - }); - console.info("Trees loaded"); -} + async setupParamValueCopyEvent() { + // These plot parameters do not directly correlate to a plot config property + //setupParamValueCopyEvent("js-dash-enable-subsampling") + } -// Taken from https://www.w3schools.com/howto/howto_js_sort_table.asp -function sortTable(n) { - let table; - let rows; - let switching; - let i; - let x; - let y; - let shouldSwitch; - let dir; - let switchcount = 0; - table = document.getElementById("tbl_selected_genes"); + async setupPlotSpecificEvents() { + catColumns = await getCategoryColumns(); - switching = true; - // Set the sorting direction to ascending: - dir = "asc"; - /* Make a loop that will continue until - no switching has been done: */ - while (switching) { - // Start by saying: no switching is done: - switching = false; - rows = table.rows; - /* Loop through all table rows (except the - first, which contains table headers): */ - for (i = 1; i < rows.length - 1; i++) { - // Start by saying there should be no switching: - shouldSwitch = false; - /* Get the two elements you want to compare, - one from current row and one from the next: */ - x = rows[i].getElementsByTagName("td")[n]; - y = rows[i + 1].getElementsByTagName("td")[n]; - /* Check if the two rows should switch place, - based on the direction, asc or desc: */ - if (dir == "asc") { - // First column is gene_symbol... rest are numbers - if (n === 0 && x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) { - // If so, mark as a switch and break the loop: - shouldSwitch = true; - break; - } - if (Number(x.innerHTML) > Number(y.innerHTML)) { - shouldSwitch = true; - break; - } - } else if (dir == "desc") { - if (n === 0 && x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) { - // If so, mark as a switch and break the loop: - shouldSwitch = true; - break; - } - if (Number(x.innerHTML) < Number(y.innerHTML)) { - shouldSwitch = true; - break; - } - } - } - if (shouldSwitch) { - /* If a switch has been marked, make the switch - and mark that a switch has been done: */ - rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); - switching = true; - // Each time a switch is done, increase this count by 1: - switchcount++; - } else { - /* If no switching has been done AND the direction is "asc", - set the direction to "desc" and run the while loop again. */ - if (switchcount == 0 && dir == "asc") { - dir = "desc"; - switching = true; - } - } - } -} + updateSeriesOptions("js-dash-primary", catColumns); + updateSeriesOptions("js-dash-secondary", catColumns); -// Render the gene-selection dropdown menu -function createGeneDropdown (genes) { - const tmpl = $.templates('#gene_dropdown_tmpl'); - const html = tmpl.render({ genes }); - $('#gene_dropdown_container').html(html); - $('#gene_dropdown').select2({ - placeholder: 'To search, click to select or start typing some gene names', - allowClear: true, - width: 'resolve' - }); - $('#gene_spinner').hide(); -} + // If primary series changes, disable chosen option in secondary series + for (const classElt of document.getElementsByClassName("js-dash-primary")) { + classElt.addEventListener("change", (event) => { + const primarySeries = event.target.value; + for (const secondaryClassElt of document.getElementsByClassName("js-dash-secondary")) { + // enable all series + for (const opt of secondaryClassElt.options) { + opt.removeAttribute("disabled"); + } + // disable primary series in secondary series + const opt = secondaryClassElt.querySelector(`option[value="${primarySeries}"]`); + opt.setAttribute("disabled", "disabled"); + // If this option was selected, unselect it + if (secondaryClassElt.value === primarySeries) { + secondaryClassElt.value = ""; + } + } + }) + } -// Render the observation primary field HTML -function createObsPrimaryField (obsLevels) { - const tmpl = $.templates('#obs_primary_tmpl'); // Get compiled template using jQuery selector for the script block - const html = tmpl.render(obsLevels); // Render template using data - as HTML string - $('#obs_primary_container').html(html); // Insert HTML string into DOM -} + // if subsampling is checked, enable subsampling limit + for (const classElt of document.getElementsByClassName("js-dash-enable-subsampling")) { + classElt.addEventListener("change", (event) => { + const checked = event.target.checked; + for (const innerClassElt of document.getElementsByClassName("js-dash-subsampling-limit")) { + innerClassElt.disabled = !(checked); + } + }) + } -// Render the observation secondary field -function createObsSecondaryField (obsLevels) { - const tmpl = $.templates('#obs_secondary_tmpl'); - const html = tmpl.render(obsLevels); - $('#obs_secondary_container').html(html); -} + // For clusterbar options, create checkboxes for all catColumns + for (const classElt of document.getElementsByClassName("js-dash-clusterbar")) { + for (const catColumn of catColumns) { -// Render the observation filter dropdowns -function createObsFilterDropdowns (obsLevels) { - const tmpl = $.templates('#obs_filters_tmpl'); - const html = tmpl.render(obsLevels); - $('#obs_filters_container').html(html); - $('select.js-obs-levels').select2({ - placeholder: 'Start typing to include groups from this category. Click "All" to use all groups', - allowClear: true, - width: 'resolve' - }); -} + const clusterbarElt = document.createElement("div"); + clusterbarElt.classList.add("control"); -// Render clusterbars for heatmap -function createObsClusterBarField (obsLevels) { - const tmpl = $.templates('#obs_clusterbar_tmpl'); - const html = tmpl.render(obsLevels); - $('#obs_clusterbar_container').html(html); -} + const label = document.createElement("label"); + label.classList.add("checkbox"); -// Render the sortable list for the chosen category -function createObsSortable (obsLevel, scope) { - const escapedObsLevel = $.escapeSelector(obsLevel); - const propData = $(`#${escapedObsLevel}_dropdown`).select2('data'); - const sortData = propData.map((elem) => elem.id); - const tmpl = $.templates('#sortable_tmpl'); - const html = tmpl.render({ sortData }); - $(`#${scope}_sortable`).html(html); - $(`#${scope}_sortable`).sortable(); + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = catColumn; + input.classList.add("js-dash-clusterbar-checkbox"); + label.appendChild(input); + label.innerHTML += catColumn; + clusterbarElt.appendChild(label); - // Store category name to retrieve later. - sortCategories[scope] = obsLevel; + classElt.appendChild(clusterbarElt); - $(`#${scope}_order_label`).show(); -} + } + } -// Render the sortable list for selected genes. -function createGeneSortable (sortData) { - const tmpl = $.templates('#sortable_tmpl'); - const html = tmpl.render({ sortData }); - $('#gene_sortable').html(html); - $('#gene_sortable').sortable(); -} + // Add event listener to sync checkboxes with same value + for (const inputElt of document.getElementsByClassName("js-dash-clusterbar-checkbox")) { + inputElt.addEventListener("change", (event) => { + const checked = event.target.checked; + const value = event.target.value; + for (const innerClassElt of document.getElementsByClassName("js-dash-clusterbar-checkbox")) { + if (innerClassElt.value === value) { + innerClassElt.checked = checked; + } + } + }) + } -// Render dropdowns specific to the dot plot -function createDotplotDropdowns (obsLevels) { - createObsPrimaryField (obsLevels); - $('#none_primary_group').hide(); // Primary is required - createObsSecondaryField(obsLevels); -} + // If stacked violin is checked, disable legend title (since there is no legend) + for (const classElt of document.getElementsByClassName("js-dash-stacked-violin")) { + classElt.addEventListener("change", (event) => { + const checked = event.target.checked; + for (const legendTitleElt of document.getElementsByClassName("js-dash-legend-title")) { + if (checked) { + legendTitleElt.setAttribute("disabled", "disabled"); + } else { + legendTitleElt.removeAttribute("disabled"); + } + } + }) + } -// Render dropdowns specific to the heatmap plot -function createHeatmapDropdowns (obsLevels) { - createObsPrimaryField(obsLevels); - createObsSecondaryField(obsLevels); - createObsClusterBarField(obsLevels); - - // Initialize differential expression test dropdown - $('#distance_select').select2({ - placeholder: 'Choose distance metric', - width: '25%', - minimumResultsForSearch: -1 - }); + // Certain elements trigger plot order + const plotOrderElts = document.getElementsByClassName("js-plot-order"); + for (const elt of plotOrderElts) { + elt.addEventListener("change", (event) => { + const paramId = event.target.id; + const param = paramId.replace("_series", "").replace("_post", ""); + // NOTE: continuous series will be handled in the function + updateOrderSortable(); + }); + } + } - $('#cluster_obs_warning').hide(); } -// Render dropdowns specific to the quadrant plot -function createQuadrantDropdowns (obsLevels) { - // Filter only categories with at least 3 conditions - const goodObsLevels = {} - for (const category in obsLevels) { - if (obsLevels[category].length >= 3) { - goodObsLevels[category] = obsLevels[category] - } +class GenesAsDataHandler extends PlotHandler { + constructor(plotType) { + super(); + this.plotType = plotType; + this.apiPlotType = plotType; } - const tmpl = $.templates('#select_conditions_tmpl'); - const html = tmpl.render(goodObsLevels); - $('#quadrant_compare1_condition').html(html); - $('#quadrant_compare1_condition').select2({ - placeholder: 'Select the first query condition.', - width: '25%' - }); - $('#quadrant_compare2_condition').html(html); - $('#quadrant_compare2_condition').select2({ - placeholder: 'Select the second query condition.', - width: '25%' - }); - $('#quadrant_ref_condition').html(html); - $('#quadrant_ref_condition').select2({ - placeholder: 'Select the reference condition.', - width: '25%' - }); + compareSeparator = ";-;"; + + classElt2Prop = { + "js-dash-de-test": "de_test_algo" + , "js-dash-fold-change-cutoff": "fold_change_cutoff" + , "js-dash-fdu-cutoff": "fdr_cutoff" + , "js-dash-include-zero-fc": "include_zero_fc" + , "js-dash-annot-nonsig": "annot_nonsignificant" + , "js-dash-pvalue-threshold": "pvalue_threshold" + , "js-dash-use-adj-pvalues": "adj_pvals" + , "js-dash-lower-logfc-threshold": "lower_logfc_threshold" + , "js-dash-upper-logfc-threshold": "upper_logfc_threshold" + , "js-dash-plot-title": "plot_title" + , "js-dash-legend-title": "legend_title" + }; + // Deal with js-dash-query/reference/compare1/compare2 separately since they combine with js-dash-compare - // Initialize differential expression test dropdown - $('#de_test_select').select2({ - placeholder: 'Choose DE testing algorithm', - width: '25%', - minimumResultsForSearch: -1 - }); -} + configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); -// Render dropdowns specific to the violin plot -function createViolinDropdowns (obsLevels) { - createObsPrimaryField (obsLevels); - $('#none_primary_group').hide(); // Primary is required - createObsSecondaryField(obsLevels); -} + plotConfig = {}; // Plot config that is passed to API -// Render dropdowns specific to the volcano plot -function createVolcanoDropdowns (obsLevels) { - // Filter only categories wtih at least 2 conditions - const goodObsLevels = {} - for (const category in obsLevels) { - if (obsLevels[category].length >= 2) { - goodObsLevels[category] = obsLevels[category] + plotJson = null; // Plotly plot JSON + + cloneDisplay(config) { + // load plot values + for (const prop in config) { + setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config[prop]); } - } - const tmpl = $.templates('#select_conditions_tmpl'); - const html = tmpl.render(goodObsLevels); - $('#volcano_query_condition').html(html); - $('#volcano_query_condition').select2({ - placeholder: 'Select the query condition.', - width: '25%' - }); + // Handle filters + if (config["obs_filters"]) { + facetWidget.filters = config["obs_filters"]; + } - for (const category in goodObsLevels) { - // Add the "union" option if the category has 3 or more groups. - // "Union" in 2-group categories would just be the same as choosing the 2nd category - if (goodObsLevels[category].length > 2) { - goodObsLevels[category].push("Union of the rest of the groups"); + // Split compare series and groups + const refCondition = config["ref_condition"]; + const [compareSeries, refGroup] = refCondition.split(this.compareSeparator); + for (const classElt of document.getElementsByClassName("js-dash-compare")) { + classElt.value = compareSeries; } - } - const html2 = tmpl.render(goodObsLevels); - $('#volcano_ref_condition').html(html2); - $('#volcano_ref_condition').select2({ - placeholder: 'Select the reference condition.', - width: '25%' - }); - // Initialize differential expression test dropdown - $('#de_test_select').select2({ - placeholder: 'Choose DE testing algorithm', - width: '25%', - minimumResultsForSearch: -1 - }); + // populate group options + updateGroupOptions("js-dash-reference", levels[compareSeries]); + for (const classElt of document.getElementsByClassName("js-dash-reference")) { + classElt.value = refGroup; + } -} + if (this.plotType === "volcano") { + updateGroupOptions("js-dash-query", levels[compareSeries]); + const queryCondition = config["query_condition"]; + const queryGroup = queryCondition.split(this.compareSeparator)[1]; + for (const classElt of document.getElementsByClassName("js-dash-query")) { + classElt.value = queryGroup; + } + trigger(document.querySelector(".js-dash-query"), "change"); // trigger change event to start validation -// Load colorscale select2 object and populate with data -function loadColorscaleSelect (isContinuous=false) { + } + if (this.plotType === "quadrant") { + updateGroupOptions("js-dash-compare1", levels[compareSeries]); + updateGroupOptions("js-dash-compare2", levels[compareSeries]); + + const compare1Condition = config["compare1_condition"]; + const compare1Group = compare1Condition.split(this.compareSeparator)[1]; + const compare2Condition = config["compare2_condition"]; + const compare2Group = compare2Condition.split(this.compareSeparator)[1]; + for (const classElt of document.getElementsByClassName("js-dash-compare1")) { + classElt.value = compare1Group; + } + for (const classElt of document.getElementsByClassName("js-dash-compare2")) { + classElt.value = compare2Group; + } - let filteredPalettes = availablePalettes; + trigger(document.querySelector(".js-dash-compare1"), "change"); // trigger change event to start validation + } - // If plot that uses continuous colorscales is chosen, then filter availablePalettes to only those for continuous plots - // If not continuous, then filter to only those for discrete plots - filteredPalettes = isContinuous ? - availablePalettes.filter((type) => type.continuous) : - availablePalettes.filter((type) => !type.continuous); + // for some reason triggering .js-dash-compare did not populate the compare groups into the plot config + } - const tmpl = $.templates('#select_colorscale_tmpl'); - const html = tmpl.render(filteredPalettes); - $('#colorscale_select').html(html); + async createPlot(datasetId, analysisObj) { + // Get data and set up the image area + try { + const data = await fetchDashData(datasetId, analysisObj, this.apiPlotType, this.plotConfig); + ({plot_json: this.plotJson} = data); + } catch (error) { + return; + } + const plotContainer = document.getElementById("plot_container"); + plotContainer.replaceChildren(); // erase plot - $('#colorscale_select').select2({ - placeholder: 'OPTIONAL: Choose a color palette', - templateResult: (result) => {return formatColorscaleState(result, isContinuous)}, // Executes every time the dropdown is opened - width: '50%', - minimumResultsForSearch: -1 - }); -} + // NOTE: Plot initially is created to a default width but is responsive. + // Noticed container within our "column" will make full-width go beyond the screen + const plotlyPreviewElt = document.createElement("div"); + plotlyPreviewElt.classList.add("container", "is-max-desktop"); + plotlyPreviewElt.id = "plotly_preview"; + plotContainer.append(plotlyPreviewElt); + Plotly.purge("plotly_preview"); // clear old Plotly plots -// Create the template for the colorscale select2 option dropdown -function formatColorscaleState (state, isContinuous=false) { - // Needs to be here to avoid catching the "loading" state JSON object - if (!state.id) { - return state.text; - } - // TODO: Drop jQuery here and use vanilla JS - const fragment = $(document.createDocumentFragment()); - const canvas = $(``); - fragment.append(canvas); - // Add [0] to "canvas" to return the DOM object instead of the jQuery object - if (isContinuous) { - createCanvasGradient(paletteInformation[state.element.value], canvas[0]); - } else { - createCanvasScale(paletteInformation[state.element.value], canvas[0]); - } - const text_span = $(`${state.text}`); - fragment.append(text_span); + if (!this.plotJson) { + createToast("Could not retrieve plot information. Cannot make plot."); + return; + } + // Update plot with custom plot config stuff stored in plot_display_config.js + const curatorDisplayConf = postPlotlyConfig.curator; + const custonConfig = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "config"); + Plotly.newPlot("plotly_preview", this.plotJson.data, this.plotJson.layout, custonConfig); + const custonLayout = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "layout") + Plotly.relayout("plotly_preview", custonLayout) + + // Show button to add genes to gene cart + document.getElementById("gene_cart_btn_c").classList.remove("is-hidden"); + + const plotlyPreview = document.getElementById("plotly_preview"); + + // Append small note about using the Plotly selection utilities + const plotlyNote = document.createElement("div"); + plotlyNote.classList.add("notification", "is-info", "is-light"); + plotlyNote.innerHTML = `

Tip: Use the Plotly box and lasso select tools (upper-right) to select genes to view as a table.

`; + plotlyPreview.append(plotlyNote); + + // If plot data is selected, create the right-column table and do other misc things + plotlyPreview.on("plotly_selected", async (eventData) => { + + // Hide selected genes table and disable unweighted radio button if no genes are selected + document.getElementById("tbl_selected_genes").classList.add("is-hidden"); + document.getElementById("download_selected_genes_btn").classList.add("is-hidden"); + document.querySelector("input[name='genecart_type'][value='unweighted']").disabled = true; + document.querySelector("input[name='genecart_type'][value='unweighted']").parentElement.removeAttribute("disabled"); + if (eventData?.points.length) { + document.getElementById("tbl_selected_genes").classList.remove("is-hidden"); + document.getElementById("download_selected_genes_btn").classList.remove("is-hidden"); + document.querySelector("input[name='genecart_type'][value='unweighted']").disabled = false; + document.querySelector("input[name='genecart_type'][value='unweighted']").parentElement.setAttribute("disabled", "disabled"); + + adjustGeneTableLabels(this.plotType); + populateGeneTable(eventData, this.plotType); + } - return fragment[0]; -} + // Highlight table rows that match searched genes + const searchedGenes = this.plotConfig.gene_symbols; + if (searchedGenes) { + const geneTableBody = document.getElementById("gene_table_body"); + // Select the first column (gene_symbols) in each row + for (const row of geneTableBody.children) { + const tableGene = row.children[0].textContent; + for (const gene of searchedGenes) { + if (gene.toLowerCase() === tableGene.toLowerCase() ) { + row.classList.add("has-background-success-light"); + } + }; + } + } + }); -// Create the gradient for the canvas element using a given colorscale's information and the element HTML object -function createCanvasGradient(data, elem) { - const ctx = elem.getContext("2d"); // canvas element - const grid = ctx.createLinearGradient(0, 0, elem.width, 0); // Fill across but not down - // Add the colors to the gradient - for (const color of data) { - grid.addColorStop(color[0], color[1]); } - // Fill the canvas with the gradient - ctx.fillStyle = grid; - ctx.fillRect(0, 0, elem.width, 20); -} -function createCanvasScale(data, elem) { - const elemWidth = elem.width; - const ctx = elem.getContext("2d"); // canvas element - // Add the colors to the scale - const { length } = data; - const width = elemWidth/length; // 150 is length of canvas - for (const color of data) { - ctx.fillStyle = color[1]; - // The length/length+1 is to account for the fact that the last color has a value of 1.0 - // Otherwise the last color would be cut off - const x = color[0] * (length/(length+1)) * elemWidth; - ctx.fillRect(x, 0, width, 20); - } -} + async loadPlotHtml() { + + const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); + prePlotOptionsElt.replaceChildren(); -function curateObservations (obsLevels) { - const obsNotUsed = []; // Observations that will not be added to obsFilters due to issues (1 group, meaningless category) - - // Delete useless filters - for (const property in obsLevels) { - if (property === 'color' || property.endsWith('colors')) { - obsNotUsed.push(property); - delete obsLevels[property]; - } else if (obsLevels[property].length === 1) { - obsNotUsed.push(property); - delete obsLevels[property]; + const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); + postPlotOptionsElt.replaceChildren(); + + prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/multi_gene_as_data.html"); + postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/multi_gene_as_data.html"); + + // populate advanced options for specific plot types + const prePlotSpecificOptionsElt = document.getElementById("plot_specific_options"); + const postPlotSpecificOptionselt = document.getElementById("post_plot_specific_options"); + + // For quadrants and volcanos we load the "series" options in the plot-specific HTML, so that should come first + if (this.plotType === "quadrant") { + prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_quadrant.html"); + postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_quadrant.html"); + return; + } + if (this.plotType === "volcano") { + prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_volcano.html"); + postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_volcano.html"); + return; } } - return [obsLevels, obsNotUsed]; -} -// Generate a list of saved plot displays the user has access in viewing. -async function loadSavedDisplays (datasetId, defaultDisplayId=null) { - const datasetData = await fetchDatasetInfo(datasetId); - const { owner_id: ownerId } = datasetData; - const userDisplays = await fetchUserDisplays(CURRENT_USER.id, datasetId); - // Do not duplicate user displays in the owner display area as it can cause HTML element issues - const ownerDisplays = CURRENT_USER.id === ownerId ? [] : await fetchOwnerDisplays(ownerId, datasetId); - - // Filter displays to those only with multigene plot types - const mgUserDisplays = userDisplays.filter(d => plotTypes.includes(d.plot_type)); - const mgOwnerDisplays = ownerDisplays.filter(d => plotTypes.includes(d.plot_type)); - - // - mgUserDisplays.forEach(display => { - display.is_default = false; - if (defaultDisplayId && display.id === defaultDisplayId ) { - display.is_default = true; + populatePlotConfig() { + this.plotConfig = {}; // Reset plot config + + for (const classElt in this.classElt2Prop) { + this.plotConfig[this.classElt2Prop[classElt]] = getPlotConfigValueFromClassName(classElt) } - drawPreviewImage(display); - }); - mgOwnerDisplays.forEach(display => { - display.is_default = false; - if (defaultDisplayId && display.id === defaultDisplayId ) { - display.is_default = true; + + // convert numerical inputs from plotConfig into Number type + for (const prop of ["fold_change_cutoff", "fdr_cutoff", "pvalue_threshold", "lower_logfc_threshold", "upper_logfc_threshold"]) { + if (this.plotConfig[prop]) { + this.plotConfig[prop] = Number(this.plotConfig[prop]); + } } - drawPreviewImage(display); - }); - const displaysTmpl = $.templates('#saved_display_modal_tmpl'); - const displaysHtml = displaysTmpl.render({ - dataset_id: datasetId, - user_displays: mgUserDisplays, - owner_displays: mgOwnerDisplays - }); + // Get compare series and groups and combine + const combineSeries = document.querySelector(".js-dash-compare").value; + facetWidget.filters[combineSeries] = []; + const refGroup = document.querySelector(".js-dash-reference").value; + this.plotConfig["ref_condition"] = combineSeries + this.compareSeparator + refGroup + facetWidget.filters[combineSeries].push(refGroup); + + if (this.plotType === "volcano") { + const queryGroup = document.querySelector(".js-dash-query").value; + this.plotConfig["query_condition"] = combineSeries + this.compareSeparator + queryGroup; + facetWidget.filters[combineSeries].push(queryGroup); + } + if (this.plotType === "quadrant") { + const compare1Group = document.querySelector(".js-dash-compare1").value; + const compare2Group = document.querySelector(".js-dash-compare2").value; + this.plotConfig["compare1_condition"] = combineSeries + this.compareSeparator + compare1Group; + this.plotConfig["compare2_condition"] = combineSeries + this.compareSeparator + compare2Group; + facetWidget.filters[combineSeries].push(compare1Group); + facetWidget.filters[combineSeries].push(compare2Group); + } - $('#saved_display_modal').html(displaysHtml); -} + // Filtered observation groups + this.plotConfig["obs_filters"] = facetWidget?.filters || {}; -// Populate the HTML config options based on what was in the plot -function loadDisplayConfigHtml (plotConfig) { - // NOTE: The calling function also clicks "#reset_opts", so the options are rendered already - // Populate filter-by dropdowns - obsFilters = plotConfig.obs_filters; - for (const property in obsFilters) { - const escapedProperty = $.escapeSelector(property); - $(`#${escapedProperty}_dropdown`).val(obsFilters[property]).trigger('change'); } - const escapedPrimary = $.escapeSelector(plotConfig.primary_col); - const escapedSecondary = $.escapeSelector(plotConfig.secondary_col); + async setupParamValueCopyEvent() { + // These plot parameters do not directly correlate to a plot config property + for (const classElt of ["js-dash-compare", "js-dash-reference", "js-dash-query", "js-dash-compare1", "js-dash-compare2"]) { + setupParamValueCopyEvent(classElt) + } + } - $(`#${escapedPrimary}_primary`).prop('checked', true).click(); - $(`#${escapedSecondary}_secondary`).prop('checked', true).click(); + async setupPlotSpecificEvents() { - $('#plot_title').val(plotConfig.plot_title); + catColumns = await getCategoryColumns(); + updateSeriesOptions("js-dash-compare", catColumns); - $('#colorscale_select').val(plotConfig.colorscale).trigger('change'); - $('#reverse_colorscale').prop('checked', plotConfig.reverse_colorscale); + // When compare series changes, update the compare groups + for (const classElt of document.getElementsByClassName("js-dash-compare")) { + classElt.addEventListener("change", async (event) => { + const compareSeries = event.target.value; + updateGroupOptions("js-dash-reference", levels[compareSeries]); + if (this.plotType === "quadrant") { + updateGroupOptions("js-dash-compare1", levels[compareSeries]); + updateGroupOptions("js-dash-compare2", levels[compareSeries]); + } + if (this.plotType === "volcano") { + updateGroupOptions("js-dash-query", levels[compareSeries]); + } + }) + } - // Populate plot type-specific dropdowns and checkbox options - switch ($('#plot_type_select').val()) { - case 'dotplot': - break; - case 'heatmap': - for (const field in plotConfig.clusterbar_fields) { - const escapedField = $.escapeSelector(field); - $(`#${escapedField}_clusterbar`).prop('checked', true).click(); + // When compare groups change, prevent the same group from being selected in the other compare groups + for (const classElt of document.getElementsByClassName("js-compare-groups")) { + classElt.addEventListener("change", (event) => { + const compareGroups = [...document.getElementsByClassName("js-compare-groups")].map((elt) => elt.value); + // Filter out empty values and duplicates + const uniqueGroups = [...new Set(compareGroups)].filter(x => x); + // Get all unselected groups + const series = document.querySelector(".js-dash-compare").value; + const unselectedGroups = levels[series].filter((group) => !uniqueGroups.includes(group)); + + for (const innerClassElt of document.getElementsByClassName("js-compare-groups")) { + // enable all unselected groups + for (const group of unselectedGroups) { + const opt = innerClassElt.querySelector(`option[value="${group}"]`); + opt.removeAttribute("disabled"); + } + // disable unique groups in other compare groups + for (const group of uniqueGroups) { + if (innerClassElt.id !== event.target.id) { + const opt = innerClassElt.querySelector(`option[value="${group}"]`); + opt.setAttribute("disabled", "disabled"); + } + } + } + }) } - $('#matrixplot').prop('checked', plotConfig.matrixplot); - $('#center_around_zero').prop('checked', plotConfig.center_around_zero); - $('#cluster_obs').prop('checked', plotConfig.cluster_obs); - $('#cluster_genes').prop('checked', plotConfig.cluster_obs); - $('#flip_axes').prop('checked', plotConfig.flip_axes); - $('#hide_obs_labels').prop('checked', plotConfig.hide_obs_labels); - $('#hide_gene_labels').prop('checked', plotConfig.hide_gene_labels); - $('#distance_select').val(plotConfig.distance_metric); - $('#distance_select').trigger('change'); - break; - case 'mg_violin': - $('#stacked_violin').prop('checked', plotConfig.stacked_violin); - $('#violin_add_points').prop('checked', plotConfig.violin_add_points); - break; - case 'quadrant': - $('#include_zero_foldchange').prop('checked', plotConfig.include_zero_fc); - $("#quadrant_foldchange_cutoff").val(plotConfig.fold_change_cutoff); - $("#quadrant_fdr_cutoff").val(plotConfig.fdr_cutoff); - $('#quadrant_compare1_condition').val(plotConfig.compare1_condition); - $('#quadrant_compare1_condition').trigger('change'); - $('#quadrant_compare2_condition').val(plotConfig.compare2_condition); - $('#quadrant_compare2_condition').trigger('change'); - $('#quadrant_ref_condition').val(plotConfig.ref_condition); - $('#quadrant_ref_condition').trigger('change'); - $('#de_test_select').val(plotConfig.de_test_algo); - $('#de_test_select').trigger('change'); - break; - default: - // volcano - $('#volcano_pvalue_threshold').val(plotConfig.pvalue_threshold); - $('#volcano_lower_logfc_threshold').val(plotConfig.lower_logfc_threshold); - $('#volcano_upper_logfc_threshold').val(plotConfig.upper_logfc_threshold); - $('#adj_pvals').prop('checked', plotConfig.adj_pvals); - $('#annot_nonsig').prop('checked', plotConfig.annotate_nonsignificant) - $('#volcano_query_condition').val(plotConfig.query_condition); - $('#volcano_query_condition').trigger('change'); - $('#volcano_ref_condition').val(plotConfig.ref_condition); - $('#volcano_ref_condition').trigger('change'); - $('#de_test_select').val(plotConfig.de_test_algo); - $('#de_test_select').trigger('change'); } } -// Get plotly updates and additions from the plot_display_config JS object -function getPlotlyUpdates(confArea, plotType, category) { - let updates = {}; - for (const idx in confArea) { - const conf = confArea[idx]; - // Get config (data and/or layout info) for the plot type chosen, if it exists - if (conf.plot_type == "all" || conf.plot_type == plotType) { - const update = category in conf ? conf[category] : {}; - updates = {...updates, ...update}; // Merge the updates +const geneCartTree = new GeneCartTree({ + element: document.getElementById("genecart_tree") + , searchElement: document.getElementById("genecart_query") + , selectCallback: (async (e) => { + if (e.node.type !== "genecart") { + return; } - } - return updates; -} -// Load all saved gene carts for the current user -async function loadGeneCarts () { - if (!session_id) { - // User is not logged in. Hide gene carts container - $('#gene_cart_container').hide(); - return; - } - await $.ajax({ - url: './cgi/get_user_gene_carts.cgi', - type: 'post', - data: { session_id }, - dataType: 'json' - }).done((data) => { - const carts = {}; - const cartTypes = ['domain', 'user', 'group', 'shared', 'public']; - let cartsFound = false; + // Get gene symbols from gene cart + const geneCartId = e.node.data.orig_id; + const geneCartMembers = await fetchGeneCartMembers(geneCartId); + const geneCartSymbols = geneCartMembers.map((item) => item.label); - for (const ctype of cartTypes) { - carts[ctype] = []; + // Normalize gene symbols to lowercase + const geneSelectSymbols = geneSelect.data.map((opt) => opt.value); + const geneCartSymbolsLowerCase = geneCartSymbols.map((x) => x.toLowerCase()); - if (data[`${ctype}_carts`].length > 0) { - cartsFound = true; + const geneSelectedOptions = geneSelect.selectedOptions.map((opt) => opt.data.value); - //User has some profiles - $.each(data[`${ctype}_carts`], (_i, item) => { - carts[ctype].push({value: item.id, text: item.label }); - }); + // Get genes from gene cart that are present in dataset's genes. Preserve casing of dataset's genes. + const geneCartIntersection = geneSelectSymbols.filter((x) => geneCartSymbolsLowerCase.includes(x.toLowerCase())); + // Add in already selected genes (union) + const geneSelectIntersection = [...new Set(geneCartIntersection.concat(geneSelectedOptions))]; + + // change all options to be unselected + const origSelect = document.getElementById("gene_select"); + for (const opt of origSelect.options) { + opt.removeAttribute("selected"); + } + + // Assign intersection genes to geneSelect "selected" options + for (const gene of geneSelectIntersection) { + const opt = origSelect.querySelector(`option[value="${gene}"]`); + try { + opt.setAttribute("selected", "selected"); + } catch (error) { + // sanity check + const msg = `Could not add gene ${gene} to gene select.`; + console.warn(msg); } } - geneCartTree.domainGeneCarts = carts.domain; - geneCartTree.userGeneCarts = carts.user; - geneCartTree.groupGeneCarts = carts.group; - geneCartTree.sharedGeneCarts = carts.shared; - geneCartTree.publicGeneCarts = carts.public; - geneCartTree.generateTree(); + geneSelect.update(); + trigger(document.getElementById("gene_select"), "change"); // triggers chooseGene() to load tags + }) +}); - if (cartsFound ) { - $('#gene_cart_container').show(); - } +const adjustGeneTableLabels = (plotType) => { + const geneX = document.getElementById("tbl_gene_x"); + const geneY = document.getElementById("tbl_gene_y"); - }).fail((jqXHR, textStatus, errorThrown) => { - console.error(`Error getting session info: ${textStatus}`); - }); + // Adjust headers to the plot type + if (plotType === "quadrant") { + geneX.innerHTML = 'X Log2 FC '; + geneY.innerHTML = 'Y Log2 FC '; + } else { + // volcano + geneX.innerHTML = 'Log2 FC '; + geneY.innerHTML = 'P-value '; + } } -function saveGeneCart () { - // must have access to USER_SESSION_ID - const gc = new GeneCart({ - session_id - , label: $("#gene_cart_name").val() - , gctype: "unweighted-list" - , organism_id: $("#dataset").data('organism-id') - , is_public: 0 - }); - selectedGenes.forEach((sg) => { - const gene = new Gene({ - id: sg.ensembl_id, // Ensembl ID stored in "customdata" property - gene_symbol: sg.gene_symbol, - }); - gc.add_gene(gene); +const appendGeneTagButton = (geneTagElt) => { + // Add delete button + const deleteBtnElt = document.createElement("button"); + deleteBtnElt.classList.add("delete", "is-small"); + geneTagElt.appendChild(deleteBtnElt); + deleteBtnElt.addEventListener("click", (event) => { + // Remove gene from geneSelect + const gene = event.target.parentNode.textContent; + const geneSelectElt = document.getElementById("gene_select"); + geneSelectElt.querySelector(`option[value="${gene}"]`).removeAttribute("selected"); + + geneSelect.update(); + trigger(document.getElementById("gene_select"), "change"); // triggers chooseGene() to load tags }); - gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); + // ? Should i add ellipses for too many genes? Should I make the box collapsable? } -function saveWeightedGeneCart() { - // must have access to USER_SESSION_ID - const plotType = plotConfig.plot_type; - - let foldchangeLabel = "FC" - const foldchange_to_save = Number($('input[name=foldchange_to_save]:checked').val()); - - switch (foldchange_to_save) { - case "log2": - foldchangeLabel = "Log2FC"; - break; - case "log10": - foldchangeLabel = "Log10FC"; - break; - default: // 'raw' - foldchangeLabel = foldchangeLabel; - } - - let weight_labels = [foldchangeLabel]; +const clearGenes = (event) => { + document.getElementById("clear_genes_btn").classList.add("is-loading"); + geneSelect.clear(); + document.getElementById("clear_genes_btn").classList.remove("is-loading"); +} +const curatorSpecifcChooseGene = (event) => { + // Triggered when a gene is selected - if (plotType === "quadrant") { - const query1 = plotConfig.compare1_condition.split(';-;')[1]; - const query2 = plotConfig.compare2_condition.split(';-;')[1]; - const ref = plotConfig.ref_condition.split(';-;')[1]; - const xLabel = `${query1}-vs-${ref}`; - const yLabel = `${query2}-vs-${ref}`; + // Delete existing tags + const geneTagsElt = document.getElementById("gene_tags"); + geneTagsElt.replaceChildren(); - const fcl1 = `${xLabel}-${foldchangeLabel}` - const fcl2 = `${yLabel}-${foldchangeLabel}` + if (!geneSelect.selectedOptions.length) return; // Do not trigger after initial population - weight_labels = [fcl1, fcl2]; + // Update list of gene tags + const sortedGenes = geneSelect.selectedOptions.map((opt) => opt.data.value).sort(); + for (const opt in sortedGenes) { + const geneTagElt = document.createElement("span"); + geneTagElt.classList.add("tag", "is-primary"); + geneTagElt.textContent = sortedGenes[opt]; + appendGeneTagButton(geneTagElt); + geneTagsElt.appendChild(geneTagElt); } - const gc = new WeightedGeneCart({ - session_id - , label: $("#weighted_gene_cart_name").val() - , gctype: 'weighted-list' - , organism_id: $("#dataset").data('organism-id') - , is_public: 0 - }, weight_labels); + document.getElementById("gene_tags_c").classList.remove("is-hidden"); + if (!geneSelect.selectedOptions.length) { + document.getElementById("gene_tags_c").classList.add("is-hidden"); + } - // Volcano and Quadrant plots have multiple traces of genes, broken into groups. - // Loop through these to get the info we need. - plotData.forEach((trace) => { - for (const pt in trace.x) { - - // TODO: Handle quadrant plot situation - let foldchange = trace.x[pt].toFixed(1); + // Cannot plot if 2+ genes are not selected + if (geneSelect.selectedOptions.length < 2) { + document.getElementById("gene_s_failed").classList.remove("is-hidden"); + document.getElementById("gene_s_success").classList.add("is-hidden"); + for (const plotBtn of document.getElementsByClassName("js-plot-btn")) { + plotBtn.disabled = true; + } + document.getElementById("continue_to_plot_options").classList.add("is-hidden"); + return; + } - switch (foldchange_to_save) { - case "log2": - foldchange = Math.log2(foldchange); - break; - case "log10": - foldchange = Math.log10(foldchange); - break; - default: // 'raw' - foldchange = foldchange; + // If more than 10 tags, hide the rest and add a "show more" button + if (geneSelect.selectedOptions.length > 10) { + const geneTags = geneTagsElt.querySelectorAll("span.tag"); + for (let i = 10; i < geneTags.length; i++) { + geneTags[i].classList.add("is-hidden"); + } + // Add show more button + const showMoreBtnElt = document.createElement("button"); + showMoreBtnElt.classList.add("tag", "button", "is-small", "is-primary", "is-light"); + const numToDisplay = geneSelect.selectedOptions.length - 10; + showMoreBtnElt.textContent = `+${numToDisplay} more`; + showMoreBtnElt.addEventListener("click", (event) => { + const geneTags = geneTagsElt.querySelectorAll("span.tag"); + for (let i = 10; i < geneTags.length; i++) { + geneTags[i].classList.remove("is-hidden"); } - const weights = [foldchange]; + event.target.remove(); + }); + geneTagsElt.appendChild(showMoreBtnElt); - // If quadrant plot was specified, there are fold changes in x and y axis - if (plotType === "quadrant") { - let foldchange2 = trace.y[pt].toFixed(1); - switch (foldchange_to_save) { - case "log2": - foldchange2 = Math.log2(foldchange2); - break; - case "log10": - foldchange2 = Math.log10(foldchange2); - break; - default: // 'raw' - foldchange2 = foldchange2; - } - weights.push(foldchange2); - } + } - const gene = new WeightedGene({ - id: trace.customdata[pt], // Ensembl ID stored in "customdata" property - gene_symbol: trace.text[pt] - }, weights); - gc.add_gene(gene); - } - }); - gc.save(updateUIAfterWeightedGeneCartSaveSuccess, updateUIAfterWeightedGeneCartSaveFailure); -} + document.getElementById("gene_s_failed").classList.add("is-hidden"); + document.getElementById("gene_s_success").classList.remove("is-hidden"); + + // Force validation check to see if plot button should be enabled + //trigger(document.querySelector(".js-plot-req"), "change"); + + document.getElementById("continue_to_plot_options").classList.remove("is-hidden"); -function updateUIAfterGeneCartSaveSuccess(gc) { - $("#saved_gene_cart_info_c > h3").html(`Cart: ${gc.label}`); - $('#saved_gene_cart_info_c > h3').addClass('text-success'); - $("#gene_cart_member_count").html(gc.genes.length); - $("#saved_gene_cart_info_c").show(); } -function updateUIAfterGeneCartSaveFailure(gc) { - $('#saved_gene_cart_info_c > h3').text('Issue with saving gene cart.'); - $('#saved_gene_cart_info_c > h3').addClass('text-danger'); - $('#saved_gene_cart_info_c').show(); +const curatorSpecifcCreatePlot = async (plotType) => { + // Call API route by plot type + await plotStyle.createPlot(datasetId, analysisObj); } -function updateUIAfterWeightedGeneCartSaveSuccess(gc) { - $("#saved_weighted_gene_cart_info_c > .status").html(`Cart "${gc.label}" successfully saved.`); - $("#saved_weighted_gene_cart_info_c > .status").removeClass("text-danger").addClass("text-success"); - $("#saved_weighted_gene_cart_info_c").show(); - $("#saved_weighted_gene_cart_info_c > .alert").hide(); +const curatorSpecifcDatasetTreeCallback = async () => { + // Creates gene select instance that allows for multiple selection + geneSelect = createGeneSelectInstance("gene_select", geneSelect); } -function updateUIAfterWeightedGeneCartSaveFailure(gc, message) { - $("#saved_weighted_gene_cart_info_c > .status").html("There was an issue saving the weighted gene cart."); - $("#saved_weighted_gene_cart_info_c > .status").removeClass("text-success").addClass("text-danger"); - $("#saved_weighted_gene_cart_info_c > .alert").show(); - $("#saved_weighted_gene_cart_info_c > .message").html(message); - $("#saved_weighted_gene_cart_info_c").show(); - $("#save_weighted_gene_cart").prop("disabled", false); +const curatorSpecificNavbarUpdates = () => { + // Update with current page info + document.querySelector("#header_bar .navbar-item").textContent = "Multi-gene Displays"; + + for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { + elt.classList.remove("is-active"); + } + + document.querySelector("a[tool='mg_displays'").classList.add("is-active"); } -function fetchDatasetInfo (datasetId) { - return $.ajax({ - url: './cgi/get_dataset_info.cgi', - type: 'POST', - data: { dataset_id: datasetId }, - dataType: 'json' - }); +const curatorSpecificOnLoad = async () => { + // Load gene carts + await loadGeneCarts(); } -function fetchUserDisplays (userId, datasetId) { - return $.ajax({ - url: './cgi/get_dataset_displays.cgi', - type: 'POST', - data: { user_id: userId, dataset_id: datasetId }, - dataType: 'json' - }); +const curatorSpecificPlotStyle = (plotType) => { + // include plotting backend options + if (genesAsAxisPlots.includes(plotType)) { + return new GenesAsAxisHandler(plotType); + } else if (genesAsDataPlots.includes(plotType)) { + return new GenesAsDataHandler(plotType); + } else { + return null; + } } -function fetchOwnerDisplays (ownerId, datasetId) { - return $.ajax({ - url: './cgi/get_dataset_displays.cgi', - type: 'POST', - data: { user_id: ownerId, dataset_id: datasetId }, - dataType: 'json' - }); + +const curatorSpecificPlotTypeAdjustments = (plotType) => { + return plotType; } -function getDefaultDisplay (datasetId) { - return $.ajax({ - url: './cgi/get_default_display.cgi', - type: 'POST', - data: { - user_id: CURRENT_USER.id, - dataset_id: datasetId, - is_multigene: 1 - }, - dataType: 'json' - }); +const curatorSpecificUpdateGeneOptions = async (geneSymbols) => { + //pass } -function downloadSelectedGenes() { +const downloadSelectedGenes = (event) => { + event.preventDefault(); + // Builds a file in memory for the user to download. Completely client-side. // plot_data contains three keys: x, y and symbols // build the file string from this - const plotType = plotConfig.plot_type; + const plotType = plotStyle.plotType; + const plotConfig = plotStyle.plotConfig; // Adjust headers to the plot type let xLabel; @@ -1066,850 +844,404 @@ function downloadSelectedGenes() { document.body.removeChild(element); } -$('#dataset').change(async function () { - datasetId = $('#dataset').val(); - displayId = null; - - $('#post_dataset_spinner').show(); - - // Obtain default display ID for this dataset - const {default_display_id: defaultDisplayId} = await getDefaultDisplay(datasetId); - - // Populate saved displays modal - loadSavedDisplays(datasetId, defaultDisplayId); - - $('#load_saved_plots').show(); - $('#plot_type_container').show(); - - $('#gene_container').show(); - $('#gene_spinner').show(); - $('#gene_cart_clear').click(); - $('#categories_not_used').hide(); - $('#no_observations_error').hide(); - - // Create promises to get genes and observations for this dataset - const geneSymbolsPromise = fetchGeneSymbols({ datasetId, analysis: undefined }) - const h5adPromise = fetchH5adInfo({ datasetId, analysis: undefined }); - - // Execute both in parallel - geneSymbols = await geneSymbolsPromise; - const data = await h5adPromise; - - createGeneDropdown(geneSymbols); // gene_spinner hidden here - - $('#post_dataset_spinner').hide(); - - // Cannot cluster with just one gene (because function is only available - // in dash.clustergram which requires 2 or more genes in plot) - // Adding a gene will trigger a change to enable the property - $("#cluster_obs").prop("disabled", true); - $("#cluster_genes").prop("disabled", true); - - // Catch datasets with no observations (before filtering), and indicate this dataset cannot be used. - if (! Object.keys(data.obs_levels).length) { - $('#no_observations_error').show(); +const fetchDashData = async (datasetId, analysis, plotType, plotConfig) => { + try { + const data = await apiCallsMixin.fetchDashData(datasetId, analysis, plotType, plotConfig); + if (data?.success < 1) { + throw new Error (data?.message || "Unknown error.") + } + return data + } catch (error) { + logErrorInConsole(error); + const msg = "Could not create plot for this dataset and parameters. Please contact the gEAR team." + createToast(msg); + throw new Error(msg); } +} - // Get categorical observations for this dataset - [obsLevels, obsNotUsed] = curateObservations(data.obs_levels); - numObs = data.num_obs; - - // Determine if at least one category has at least two groups (for volcanos) or at least three groups (for quadrants) - // If condition is not met, disable the plot option - let hasTwoGroupCategory = false; - let hasThreeGroupCategory = false; +/* Fetch gene collections */ +const fetchGeneCarts = async () => { + const cartType = "unweighted-list"; + try { + return await apiCallsMixin.fetchGeneCarts(cartType); + } catch (error) { + logErrorInConsole(error); + const msg = "Could not fetch gene collections. You can still enter genes manually."; + createToast(msg); + throw new Error(msg); + } +} - for (const category in obsLevels) { - if (obsLevels[category].length >= 3) { - hasThreeGroupCategory = true; - } - if (obsLevels[category].length >= 2) { - hasTwoGroupCategory = true; - } - if (hasTwoGroupCategory && hasThreeGroupCategory) { - break; +/* Fetch gene collection members */ +const fetchGeneCartMembers = async (geneCartId) => { + try { + const {gene_symbols, success} = await apiCallsMixin.fetchGeneCartMembers(geneCartId); + if (!success) { + throw new Error("Could not fetch gene collection members. You can still enter genes manually."); } + return gene_symbols; + } catch (error) { + logErrorInConsole(error); + const msg = "Could not fetch gene collection members. You can still enter genes manually."; + createToast(msg); + throw new Error(msg); } - $('#volcano_opt').prop("disabled", false); - if (!hasTwoGroupCategory) { - $('#volcano_opt').prop("disabled", true); - } - $('#quadrant_opt').prop("disabled", false); - if (!hasThreeGroupCategory) { - $('#quadrant_opt').prop("disabled", true); - } - - $('#create_plot').show(); - $("#create_plot").prop("disabled", true); - $('#reset_opts').show(); +} - // If a plot type was already selected, - // reset the options so configs are populated for the current dataset - if ($('#plot_type_select').val() ) { - $("#create_plot").prop("disabled", false); - $('#reset_opts').click(); +const getCategoryColumns = async () => { + const analysisValue = analysisSelect.selectedOptions.length ? getSelect2Value(analysisSelect) : undefined; + const analysisId = (analysisValue && analysisValue > 0) ? analysisValue : null; + try { + ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); + } catch (error) { + document.getElementById("plot_options_s_failed").classList.remove("is-hidden"); + return; } -}); - -$("#create_gene_cart").on("click", () => { - $("#create_gene_cart_dialog").show("fade"); -}); - -$("#cancel_save_gene_cart").on("click", () => { - $("#create_gene_cart_dialog").hide("fade"); - $("#gene_cart_name").val(""); -}); - -$("#gene_cart_name").on("input", function () { - if ($(this).val() == "") { - $("#save_gene_cart").prop("disabled", true); - } else { - $("#save_gene_cart").prop("disabled", false); + // Filter out values we don't want of "levels", like "colors" + for (const key in levels) { + if (key.includes("_colors")) { + delete levels[key]; + } } -}); + return Object.keys(levels); +} -$("#save_gene_cart").on("click", () => { - $("#save_gene_cart").prop("disabled", true); +// Invert a log function +const invertLogFunction = (value, base=10) => { + return base ** value; +} - if (CURRENT_USER) { - saveGeneCart(); - } else { - window.alert("You must be signed in to do that."); - } - $("#save_gene_cart").prop('disabled', false); -}); +/* Transform and load gene collection data into a "tree" format */ +const loadGeneCarts = async () => { + try { + const geneCartData = await fetchGeneCarts(); + const carts = {}; + const cartTypes = ['domain', 'user', 'group', 'shared', 'public']; + let cartsFound = false; -$("#weighted_gene_cart_name").on("input", function () { - if ($(this).val() == "") { - $("#save_weighted_gene_cart").prop("disabled", true); - } else { - $("#save_weighted_gene_cart").prop("disabled", false); - } -}); -$("#save_weighted_gene_cart").on("click", () => { - $("#save_weighted_gene_cart").prop("disabled", true); - if (CURRENT_USER) { - saveWeightedGeneCart(); - } else { - alert("You must be signed in to do that."); - } -}); + // Loop through the different types of gene collections and add them to the carts object + for (const ctype of cartTypes) { + carts[ctype] = []; -$("#download_plot").on("click", () => { - Plotly.downloadImage( - `dataset_${datasetId}_h5ad`, { - width: $('#plot_download_width').val() - , height: $('#plot_download_height').val() - , scale: $('#plot_download_scale').val() - } - ) -}); + if (geneCartData[`${ctype}_carts`].length > 0) { + cartsFound = true; -// Load user's gene carts -$('#gene_cart').change(function () { - const geneCartId = $(this).val(); - const params = { session_id, gene_cart_id: geneCartId }; - const d = new $.Deferred(); // Causes editable to wait until results are returned - // User is not logged in - if (!session_id) { - d.resolve(); - } else { - // User is logged in - - $('#gene_spinner').show(); - // Get the gene cart members and populate the gene symbol search bar - $.ajax({ - url: './cgi/get_gene_cart_members.cgi', - type: 'post', - data: params, - success(data, _newValue, _oldValue) { - if (data.success === 1) { - // Append gene symbols to search bar - const geneCartSymbols = []; - - // format gene symbols into search string - $.each(data.gene_symbols, (_i, item) => { - geneCartSymbols.push(item.label); - }); - - const geneCartSymbolsLowerCase = geneCartSymbols.map(x => x.toLowerCase()); - const geneSymbolsLowerCase = geneSymbols.map(x => x.toLowerCase()); - - // Get genes from gene cart that are present in dataset's genes. Preserve casing of dataset's genes. - const intersection = geneSymbols.filter(x => geneCartSymbolsLowerCase.includes(x.toLowerCase())); - - // Get genes from gene cart that are not in dataset's genes - const difference = geneCartSymbols.filter(x => !geneSymbolsLowerCase.includes(x.toLowerCase())); - - $('#gene_dropdown').val(intersection); - $('#gene_dropdown').trigger('change'); - - $('#genes_not_found').hide(); - if (difference.length > 0) { - const differenceString = difference.join(', '); - $('#genes_not_found').text(`The following gene cart genes were not found in this dataset: ${differenceString}`); - $('#genes_not_found').show(); - } - } - d.resolve(); + for (const item of geneCartData[`${ctype}_carts`]) { + carts[ctype].push({value: item.id, text: item.label }); + }; } - }); - $('#gene_spinner').hide(); - } - return d.promise(); -}); - -// Some options are specific to certain plot types -$('#plot_type_select').change(() => { - $('#reset_opts').click(); // Reset all options - $("#create_plot").prop("disabled", false); + } - dotplotOptsIds.forEach(id => { - $(id).hide(); - }) - heatmapOptsIds.forEach(id => { - $(id).hide(); - }); - quadrantOptsIds.forEach(id => { - $(id).hide(); - }) - violinOptsIds.forEach(id => { - $(id).hide(); - }); - volcanoOptsIds.forEach(id => { - $(id).hide(); - }); + geneCartTree.domainGeneCarts = carts.domain; + geneCartTree.userGeneCarts = carts.user; + geneCartTree.groupGeneCarts = carts.group; + geneCartTree.sharedGeneCarts = carts.shared; + geneCartTree.publicGeneCarts = carts.public; + geneCartTree.generateTree(); + /*if (!cartsFound ) { + // ? Put some warning if carts not found + $('#gene_cart_container').show(); + }*/ - let isContinuous = false; - - switch ($('#plot_type_select').val()) { - case 'dotplot': - isContinuous = true; - dotplotOptsIds.forEach(id => { - $(id).show(); - }) - $("#gene_selection_help").text("Choose the genes to include in plot."); - break; - case 'heatmap': - isContinuous = true; - heatmapOptsIds.forEach(id => { - $(id).show(); - }); - $("#gene_selection_help").text("Choose the genes to include in plot."); - break; - case 'mg_violin': - violinOptsIds.forEach(id => { - $(id).show(); - }); - $("#gene_selection_help").text("Choose the genes to include in plot."); - break; - case 'quadrant': - quadrantOptsIds.forEach(id => { - $(id).show(); - }) - $("#gene_selection_help").text("OPTIONAL: Gene selection is optional for this plot type. Selected genes are annotated in the plot."); - break; - default: - // volcano - volcanoOptsIds.forEach(id => { - $(id).show(); - }); - $("#gene_selection_help").text("OPTIONAL: Gene selection is optional for this plot type. Selected genes are annotated in the plot."); + } catch (error) { + document.getElementById("gene_s_failed").classList.remove("is-hidden"); } - // Filter the colorscale dropdown based on the plot type - loadColorscaleSelect(isContinuous); - -}); - -$(document).on('change', '#cluster_obs', () => { - $('#cluster_obs_warning').hide(); - if ($('#cluster_obs').is(':checked') && numObs >= 1000){ - $('#cluster_obs_warning').show(); - } -}); +} -$(document).on('change', '#gene_dropdown', () => { - const genes = $('#gene_dropdown').select2('data').map((elem) => elem.id); - // Show warning if too many genes are entered - $("#too_many_genes_warning").hide(); - if (genes.length > 10) { - $("#too_many_genes_warning").text(`There are currently ${genes.length} genes to be plotted. Be aware that with some plots, a high number of genes can make the plot congested or unreadable.`); - $("#too_many_genes_warning").show(); - } +const populateGeneTable = (data, plotType) => { + selectedGenes = []; - $('#gene_sort_container').hide(); + for (const pt of data.points) { + selectedGenes.push({ + gene_symbol: pt.data.text[pt.pointNumber], + ensembl_id: pt.data.customdata[pt.pointNumber], // Ensembl ID stored in "customdata" property + x: pt.data.x[pt.pointNumber].toFixed(1), + y: plotType === "volcano" ? invertLogFunction(-pt.data.y[pt.pointNumber]).toExponential(2) : pt.data.y[pt.pointNumber].toFixed(2), + }); + }; - // Cannot cluster columns with just one gene (because function is only available - // in dash.clustergram which requires 2 or more genes in plot) - if (genes.length > 1) { - $("#cluster_obs").prop("disabled", false); - $("#cluster_genes").prop("disabled", false); + // Sort in alphabetical order + selectedGenes.sort(); - if (genesAsAxisPlotTypes.includes($('#plot_type_select').val())) { - $('#gene_sort_container').show(); - createGeneSortable(genes); - } - return; - } - $("#cluster_obs").prop("disabled", true); - $("#cluster_obs").prop("checked", false); - $("#cluster_genes").prop("disabled", true); - $("#cluster_genes").prop("checked", false); -}); -// When a column is chosen, populate the sortable list -$(document).on('change', 'input[name="obs_primary"]', () => { - const obsLevel = $('input[name="obs_primary"]:checked').val(); - if (obsLevel !== "none") { - createObsSortable(obsLevel, "primary"); - $('#primary_order_label').show(); - $('#primary_order_help').show(); - return; - } - $('#primary_sortable').empty(); - $('#primary_order_label').hide(); - $('#primary_order_help').hide(); -}); + const geneTableBody = document.getElementById("gene_table_body"); + geneTableBody.replaceChildren(); -$(document).on('change', 'input[name="obs_secondary"]', () => { - const obsLevel = $('input[name="obs_secondary"]:checked').val(); - if ( obsLevel !== "none") { - createObsSortable(obsLevel, "secondary"); - $('#secondary_order_label').show(); - $('#secondary_order_help').show(); - return; + for (const gene of selectedGenes) { + const row = document.createElement("tr"); + row.innerHTML = `${gene.gene_symbol}${gene.x}${gene.y}`; + geneTableBody.appendChild(row); } - $('#secondary_sortable').empty(); - $('#secondary_order_label').hide(); - $('#secondary_order_help').hide(); -}); +} -// Determine if condition has no groups so the sort container will be disabled or not. -$(document).on('change', '.js-obs-levels', function () { - const id = this.id; // This is not escaped - const group = id.replace('_dropdown', ''); - const escapedGroup = $.escapeSelector(group); - const propData = $(`#${escapedGroup}_dropdown`).select2('data'); - const props = propData.map((elem) => elem.id); - $(`#${escapedGroup}_primary`).prop("disabled", false); - $(`#${escapedGroup}_secondary`).prop("disabled", false); - // Update sortables with current filters list - if ($(`#${escapedGroup}_primary`).is(":checked")) { - createObsSortable(group, "primary"); +const saveGeneCart = () => { + // must have access to USER_SESSION_ID + const gc = new GeneCart({ + session_id: sessionId + , label: document.getElementById("new_genecart_label").value + , gctype: "unweighted-list" + , organism_id: organismId + , is_public: 0 + }); + for (const sg of selectedGenes) { + const gene = new Gene({ + id: sg.ensembl_id, // Ensembl ID stored in "customdata" property + gene_symbol: sg.gene_symbol, + }); + gc.addGene(gene); } - if ($(`#${escapedGroup}_secondary`).is(":checked")) { - createObsSortable(group, "secondary"); - } - // Disable sorting until it is known that filters have length - if (!props.length) { - $(`#${escapedGroup}_primary`).prop("disabled", true); - $(`#${escapedGroup}_secondary`).prop("disabled", true); - } -}); -$(document).on('click', '#create_plot', async () => { - - // Reset plot errors and warnings for both plots - $('.js-plot-error').empty().hide(); - $('.js-plot-warning').empty().hide(); + gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); +} - plotConfig = {}; +const saveWeightedGeneCart = () => { + // must have access to USER_SESSION_ID + const plotType = plotStyle.plotType; + const plotConfig = plotStyle.plotConfig; - const plotType = $('#plot_type_select').select2('data')[0].id; - plotConfig.plot_type = plotType; + // Saving raw FC by default so it is easy to transform weight as needed + const foldchangeLabel = "FC" + let weightLabels = [foldchangeLabel]; - // Update filters based on selection - obsFilters = {}; - for (const property in obsLevels) { - const escapedProperty = $.escapeSelector(property); - const propData = $(`#${escapedProperty}_dropdown`).select2('data'); - obsFilters[property] = propData.map((elem) => elem.id); - // If no groups for an observation are selected, delete filter - if (!obsFilters[property].length) { - delete obsFilters[property]; - } - } - plotConfig.obs_filters = obsFilters; - - if (!plotType) { - window.alert('Please select a plot type.'); - return; - } + if (plotType === "quadrant") { + const query1 = plotConfig.compare1_condition.split(';-;')[1]; + const query2 = plotConfig.compare2_condition.split(';-;')[1]; + const ref = plotConfig.ref_condition.split(';-;')[1]; + const xLabel = `${query1}-vs-${ref}`; + const yLabel = `${query2}-vs-${ref}`; - plotConfig.gene_symbols = genesFilter = $('#gene_dropdown').select2('data').map((elem) => elem.id); + const fcl1 = `${xLabel}-${foldchangeLabel}` + const fcl2 = `${yLabel}-${foldchangeLabel}` - // If genes were selected and sorted, set to the sort order - if ($('#gene_sortable').children().length > 0) { - plotConfig.gene_symbols = genesFilter = $('#gene_sortable').sortable('toArray', {attribute: "value"}); + weightLabels = [fcl1, fcl2]; } - const sortOrder = {}; - const categoriesUsed = []; - // Grab the sorted order of the list and convert to array - if (sortCategories.primary) { - sortOrder[sortCategories.primary] = $('#primary_sortable').sortable("toArray", {attribute:"value"}); - categoriesUsed.push(sortCategories.primary); - } - // This should be rare, but just use the primary order if both are the same category - if (sortCategories.secondary && !(categoriesUsed.includes(sortCategories.secondary))) { - sortOrder[sortCategories.secondary] = $('#secondary_sortable').sortable("toArray", {attribute:"value"}); - } - plotConfig.sort_order = sortOrder; + const gc = new WeightedGeneCart({ + session_id: sessionId + , label: document.getElementById("new_genecart_label").value + , gctype: 'weighted-list' + , organism_id: organismId + , is_public: 0 + }, weightLabels); - if ($('input[name="obs_primary"]:checked').val() !== "none"){ - plotConfig.primary_col = $('input[name="obs_primary"]:checked').val(); - } - if ($('input[name="obs_secondary"]:checked').val() !== "none"){ - plotConfig.secondary_col = $('input[name="obs_secondary"]:checked').val(); - } + // Volcano and Quadrant plots have multiple traces of genes, broken into groups. + // Loop through these to get the info we need. + for (const trace of plotStyle.plotJson.data) { + for (const pt in trace.x) { - plotConfig.plot_title = $('#plot_title').val(); + const foldchange = Number(trace.x[pt].toFixed(1)); + const weights = [foldchange]; - // Colorscale settings - plotConfig.colorscale = $('#colorscale_select').select2('data')[0].id; - plotConfig.reverse_colorscale = $('#reverse_colorscale').is(':checked'); + // If quadrant plot was specified, there are fold changes in x and y axis + if (plotType === "quadrant") { + const foldchange2 = Number(trace.y[pt].toFixed(1)); + weights.push(foldchange2); + } - // Add specific plotConfig options depending on plot type - switch (plotType) { - case 'dotplot': - if ((plotConfig.gene_symbols).length < 1) { - window.alert('At least one gene must be provided.'); - return; - } - if (!plotConfig.primary_col) { - window.alert("Must select at least a primary category to aggregate groups by for dot plots."); - return; - } - break; - case 'heatmap': - if ((plotConfig.gene_symbols).length < 2) { - window.alert("Must select at least 2 genes to generate a heatmap"); - return; - } - plotConfig.clusterbar_fields = []; - $('input[name="obs_clusterbar"]:checked').each( (idx, elem) => { - plotConfig.clusterbar_fields.push($(elem).val()); - }); - plotConfig.matrixplot = $('#matrixplot').is(':checked'); - plotConfig.center_around_zero = $('#center_around_zero').is(':checked'); - plotConfig.cluster_obs = $('#cluster_obs').is(':checked'); - plotConfig.cluster_genes = $('#cluster_genes').is(':checked'); - plotConfig.flip_axes = $('#flip_axes').is(':checked'); - plotConfig.hide_obs_labels = $('#hide_obs_labels').is(':checked'); - plotConfig.hide_gene_labels = $('#hide_gene_labels').is(':checked'); - plotConfig.distance_metric = $('#distance_select').select2('data')[0].id; - break; - case 'mg_violin': - if ((plotConfig.gene_symbols).length < 1) { - window.alert('At least one gene must be provided.'); - return; - } - if (!plotConfig.primary_col) { - window.alert("Must select at least a primary category to aggregate groups by for violin plots."); - return; - } - plotConfig.stacked_violin = $('#stacked_violin').is(':checked'); - plotConfig.violin_add_points = $('#violin_add_points').is(':checked'); - break; - case 'quadrant': - plotConfig.include_zero_fc = $('#include_zero_foldchange').is(':checked'); - plotConfig.fold_change_cutoff = Number($("#quadrant_foldchange_cutoff").val()); - plotConfig.fdr_cutoff = Number($("#quadrant_fdr_cutoff").val()); - if (! $('#de_test_select').select2('data')[0].id) { - window.alert('Must select a DE statistical test.'); - return; - } - plotConfig.de_test_algo = $('#de_test_select').select2('data')[0].id; - plotConfig.compare1_condition = $('#quadrant_compare1_condition').select2('data')[0].id; - plotConfig.compare2_condition = $('#quadrant_compare2_condition').select2('data')[0].id; - plotConfig.ref_condition = $('#quadrant_ref_condition').select2('data')[0].id; - if (!(plotConfig.compare1_condition && plotConfig.compare2_condition && plotConfig.ref_condition)) { - window.alert('All comparision conditions must be selected to generate a quadrant plot.'); - return; - } - const condition1Key = plotConfig.compare1_condition.split(';-;')[0]; - const condition2Key = plotConfig.compare2_condition.split(';-;')[0]; - const refQuadrantKey = plotConfig.ref_condition.split(';-;')[0]; - - const condition1Val = plotConfig.compare1_condition.split(';-;')[1]; - const condition2Val = plotConfig.compare2_condition.split(';-;')[1]; - const refQuadrantVal = plotConfig.ref_condition.split(';-;')[1]; - if ((condition1Key !== condition2Key) && (condition1Key !== refQuadrantKey)) { - window.alert('Please choose 3 conditions from the same observation group.'); - return; + const gene = new WeightedGene({ + id: trace.customdata[pt], // Ensembl ID stored in "customdata" property + gene_symbol: trace.text[pt] + }, weights); + gc.addGene(gene); } + }; - if ((condition1Val === refQuadrantVal) || (condition2Val === refQuadrantVal) || (condition1Val === condition2Val)) { - window.alert('Please choose 3 different conditions.'); - return; - } + gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); +} - // If condition category was filtered, the selected groups must be present - if (condition1Key in obsFilters - && !(obsFilters[condition1Key].includes(condition1Val) - && obsFilters[condition2Key].includes(condition2Val) - && obsFilters[condition1Key].includes(refQuadrantVal))) { - window.alert('One of the selected conditions is also chosen to be filtered out. Please adjust.'); - return; - } +// Taken from https://www.w3schools.com/howto/howto_js_sort_table.asp +const sortGeneTable = (mode) => { + let table; + let rows; + let switching; + let i; + let x; + let y; + let shouldSwitch; + let dir; + let switchcount = 0; + table = document.getElementById("tbl_selected_genes"); - break; - default: - // volcano - plotConfig.pvalue_threshold = $('#volcano_pvalue_threshold').val(); - plotConfig.lower_logfc_threshold = $('#volcano_lower_logfc_threshold').val(); - plotConfig.upper_logfc_threshold = $('#volcano_upper_logfc_threshold').val(); - plotConfig.adj_pvals = $('#adj_pvals').is(':checked'); - plotConfig.annotate_nonsignificant = $('#annot_nonsig').is(':checked'); - if (! $('#de_test_select').select2('data')[0].id) { - window.alert('Must select a DE statistical test.'); - return; - } - plotConfig.de_test_algo = $('#de_test_select').select2('data')[0].id; - plotConfig.query_condition = $('#volcano_query_condition').select2('data')[0].id; - plotConfig.ref_condition = $('#volcano_ref_condition').select2('data')[0].id; - // Validation related to the conditions - if (!(plotConfig.query_condition && plotConfig.ref_condition)) { - window.alert('Both comparision conditions must be selected to generate a volcano plot.'); - return; - } - const queryKey = plotConfig.query_condition.split(';-;')[0]; - const refKey = plotConfig.ref_condition.split(';-;')[0]; - const queryVal = plotConfig.query_condition.split(';-;')[1]; - const refVal = plotConfig.ref_condition.split(';-;')[1]; - if (queryKey !== refKey) { - window.alert('Please choose 2 conditions from the same observation group.'); - return; - } + switching = true; + // Set the sorting direction to ascending: + dir = "asc"; + /* Make a loop that will continue until + no switching has been done: */ + while (switching) { + // Start by saying: no switching is done: + switching = false; + rows = table.rows; + /* Loop through all table rows (except the + first, which contains table headers): */ + for (i = 1; i < rows.length - 1; i++) { + // Start by saying there should be no switching: + shouldSwitch = false; + /* Get the two elements you want to compare, + one from current row and one from the next: */ + x = rows[i].getElementsByTagName("td")[mode]; + y = rows[i + 1].getElementsByTagName("td")[mode]; + /* Check if the two rows should switch place, + based on the direction, asc or desc: */ + if (dir == "asc") { + // First column is gene_symbol... rest are numbers + if (mode === 0 && x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) { + // If so, mark as a switch and break the loop: + shouldSwitch = true; + break; + } + if (Number(x.innerHTML) > Number(y.innerHTML)) { + shouldSwitch = true; + break; + } + } else if (dir == "desc") { + if (mode === 0 && x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) { + // If so, mark as a switch and break the loop: + shouldSwitch = true; + break; + } + if (Number(x.innerHTML) < Number(y.innerHTML)) { + shouldSwitch = true; + break; + } + } + } + if (shouldSwitch) { + /* If a switch has been marked, make the switch + and mark that a switch has been done: */ + rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); + switching = true; + // Each time a switch is done, increase this count by 1: + switchcount++; - if (queryVal === refVal) { - window.alert('Please choose 2 different conditions.'); - return; - } + } else { + /* If no switching has been done AND the direction is "asc", + set the direction to "desc" and run the while loop again. */ + if (switchcount == 0 && dir == "asc") { + dir = "desc"; + switching = true; + } + } + } - // If condition category was filtered, the selected groups must be present - if (queryKey in obsFilters - && !(obsFilters[queryKey].includes(queryVal) - && obsFilters[queryKey].includes(refVal)) - && refVal !== "Union of the rest of the groups") { - window.alert('One of the selected conditions is also chosen to be filtered out. Please adjust.'); - return; + // Reset other sort icons to "ascending" state, to show what direction they will sort when clicked + const otherTblHeaders = document.querySelectorAll(`.js-tbl-gene-header:not(:nth-child(${mode + 1}))`); + for (const tblHeader of otherTblHeaders) { + const currIcon = tblHeader.querySelector("i"); + if (mode == 0) { + currIcon.classList.remove("mdi-sort-alphabetical-descending"); + currIcon.classList.add("mdi-sort-alphabetical-ascending"); + } else { + currIcon.classList.remove("mdi-sort-numeric-descending"); + currIcon.classList.add("mdi-sort-numeric-ascending"); } } - // Render dataset plot HTML - const plotTemplate = $.templates('#dataset_plot_tmpl'); - const plotHtml = plotTemplate.render({ dataset_id: datasetId }); - $('#dataset_plot').html(plotHtml); - - - $("#tbl_selected_genes").hide(); - if (["quadrant", "volcano"].includes(plotType)) { - $('#dataset_plot').removeClass("col").addClass("col-9"); - $('#genes_list_bar').show(); - $('#save_weighted_gene_cart_btn').show(); + // toggle the mdi icons between ascending / descending + // icon needs to reflect the current state of the sort + const selectedTblHeader = document.querySelector(`.js-tbl-gene-header:nth-child(${mode + 1})`); + const currIcon = selectedTblHeader.querySelector("i"); + if (dir == "asc") { + if (mode == 0) { + currIcon.classList.remove("mdi-sort-alphabetical-descending"); + currIcon.classList.add("mdi-sort-alphabetical-ascending"); + } else { + currIcon.classList.remove("mdi-sort-numeric-descending"); + currIcon.classList.add("mdi-sort-numeric-ascending"); + } } else { - $('#dataset_plot').addClass("col").removeClass("col-9"); - $('#genes_list_bar').hide(); - $('#save_weighted_gene_cart_btn').hide(); + if (mode == 0) { + currIcon.classList.remove("mdi-sort-alphabetical-ascending"); + currIcon.classList.add("mdi-sort-alphabetical-descending"); + } else { + currIcon.classList.remove("mdi-sort-numeric-ascending"); + currIcon.classList.add("mdi-sort-numeric-descending"); + } } +} - // Draw the updated chart - $('#plot_spinner').show(); - await draw(datasetId, plotConfig); - $('#plot_spinner').hide(); - - // Show plot options and disable selected genes button (since genes are not selected anymore) - $('#post_plot_options').show(); -}); - -// If "all" button is clicked, populate dropdown with all groups in this observation -$(document).on('click', '.js-all', function () { - const { id } = this; - const group = id.replace('_all', ''); - const escapedGroup = $.escapeSelector(group); - - $(`#${escapedGroup}_dropdown`).val(obsLevels[group]); - $(`#${escapedGroup}_dropdown`).trigger('change'); // This actually triggers select2 to show the dropdown vals -}); - -// If "all" button is clicked, populate dropdown with all groups in this observation -$(document).on('click', '.js-clear', function () { - const { id } = this; - const group = id.replace('_clear', ''); - const escapedGroup = $.escapeSelector(group); - - $(`#${escapedGroup}_dropdown`).val(''); - $(`#${escapedGroup}_dropdown`).trigger('change'); // This actually triggers select2 to clear the dropdown vals -}); -// Clear gene cart -$(document).on('click', '#gene_cart_clear', () => { - $('#gene_dropdown').val(''); // Clear genes - $('#gene_dropdown').trigger('change'); - $('#gene_cart').text('Choose gene cart'); // Reset gene cart text - $('#gene_cart').val(''); - $('#genes_not_found').hide(); - $('#too_many_genes_warning').hide(); -}); +// For plotting options, populate select menus with category groups +const updateSeriesOptions = (classSelector, seriesArray) => { -// Reset observation filters choices to be empty -$(document).on('click', '#reset_opts', async function () { - $('#options_container').show(); - $('#options_spinner').show(); + for (const elt of document.getElementsByClassName(classSelector)) { + elt.replaceChildren(); - if ( !genesFilter ) { - $('#gene_sort_container').hide(); - } + // Append empty placeholder element + const firstOption = document.createElement("option"); + elt.append(firstOption); - // Reset sorting order - sortCategories = {"primary": null, "secondary": null}; - $('#primary_order_label').hide(); - $('#primary_order_help').hide(); - $('#secondary_order_label').hide(); - $('#secondary_order_help').hide(); - - // Update fields dependent on dataset observations - createObsFilterDropdowns(obsLevels); - $('#categories_not_used').hide(); - if (obsNotUsed && obsNotUsed.length) { - const obsNotUsedString = obsNotUsed.join(", ") - $('#categories_not_used').show(); - $('#categories_not_used').html(`The following observation categories were excluded either due to having one group or being considered to have no meaning: ${obsNotUsedString}`); - } + // Add categories + for (const group of seriesArray.sort()) { - switch ($('#plot_type_select').val()) { - case 'dotplot': - createDotplotDropdowns(obsLevels); - break; - case 'heatmap': - createHeatmapDropdowns(obsLevels); - break; - case 'mg_violin': - createViolinDropdowns(obsLevels); - break; - case 'quadrant': - createQuadrantDropdowns(obsLevels); - break; - default: - // volcano - createVolcanoDropdowns(obsLevels); + const option = document.createElement("option"); + option.textContent = group; + option.value = group; + elt.append(option); + } } +} - $('.js-all').click(); // Include all groups for every category (filter nothing) - - $('#options_spinner').hide(); -}); -// Save plot -$(document).on('click', '#save_display_btn', async function () { - $('#saved_plot_confirmation').hide(); - $('#saved_plot_confirmation').removeClass('text-success'); - $('#saved_plot_confirmation').removeClass('text-danger'); - - const plotType = $('#plot_type_select').select2('data')[0].id; - - const payload = { - id: null, // Want to save as a new display - dataset_id: datasetId, - user_id: CURRENT_USER.id, - label: $('#display_name').val(), - plot_type: plotType, - plotly_config: JSON.stringify({ - // depending on display type, this object will - // have different properties - ...plotConfig - }) - }; +// For a given categorical series (e.g. "celltype"), update the "group" options +const updateGroupOptions = (classSelector, groupsArray) => { - const res = await $.ajax({ - url: './cgi/save_dataset_display.cgi', - type: 'POST', - data: payload, - dataType: 'json' - }); + for (const elt of document.getElementsByClassName(classSelector)) { + elt.replaceChildren(); - if (res?.success) { - let msg = 'Plot successfully saved' - - if ($("#save_as_default_check").is(':checked') && res.display_id) { - displayId = res.display_id; - const res2 = await $.ajax({ - url: './cgi/save_default_display.cgi', - type: 'POST', - data: { - user_id: CURRENT_USER.id, - dataset_id: datasetId, - display_id: displayId, - is_multigene: 1 - }, - dataType: 'json' - }); + // Append empty placeholder element + const firstOption = document.createElement("option"); + elt.append(firstOption); - if (res2?.success) { - // Swap current default buttons - $('.js-current-default') - .prop('disabled', false) - .addClass('js-save-default') - .addClass('btn-purple') - .removeClass('btn-secondary') - .removeClass('js-current-default') - .text("Make Default"); - $(`#${displayId}_default`) - .prop('disabled', true) - .removeClass('js-save-default') - .removeClass('btn-purple') - .addClass('btn-secondary') - .addClass('js-current-default') - .text("Default"); - msg += " and was set as the default display."; - } else { - msg += " but there was an issue saving as the default display."; - } - } else { - msg += " but not set as default display." + // Add categories + for (const group of groupsArray.sort()) { + const option = document.createElement("option"); + option.textContent = group; + option.value = group; + elt.append(option); } - - $('#saved_plot_confirmation').text(msg); - $('#saved_plot_confirmation').addClass('text-success'); - } else { - $('#saved_plot_confirmation').text('There was an issue saving the plot'); - $('#saved_plot_confirmation').addClass('text-danger'); } - $('#saved_plot_confirmation').show(); - - // Update saved displays modal so new plot is included - loadSavedDisplays(datasetId); -}); - -// Load display information back into the curator page -$(document).on('click', '.js-load-display', async function () { - const id = this.id; - displayId = id.replace('_load', ''); - - const display = await $.ajax({ - url: './cgi/get_dataset_display.cgi', - type: 'POST', - data: { display_id: displayId }, - dataType: 'json' - }); - - plotConfig = display.plotly_config; - - // Load plot type - $('#plot_type_select').val(display.plot_type); - $('#plot_type_select').trigger('change'); - - // Load gene symbols - geneSymbols = plotConfig.gene_symbols; - $('#gene_dropdown').val(geneSymbols); - $('#gene_dropdown').trigger('change'); - // Load config options - loadDisplayConfigHtml(plotConfig); +} - // Hide modal box - $('#load_plots_modal').modal('hide'); +const updateUIAfterGeneCartSaveSuccess = (gc) => { +} - // Draw the updated chart - $('#plot_spinner').show(); - const plotTemplate = $.templates('#dataset_plot_tmpl'); - const plotHtml = plotTemplate.render({ dataset_id: datasetId }); - $('#dataset_plot').html(plotHtml); +const updateUIAfterGeneCartSaveFailure = (gc, message) => { + createToast(message); +} - if (["quadrant", "volcano"].includes(display.plot_type)) { - $('#dataset_plot').removeClass("col").addClass("col-10"); - $('#genes_list_bar').show(); - $('#save_weighted_gene_cart_btn').show(); - } else { - $('#dataset_plot').addClass("col").removeClass("col-10"); - $('#genes_list_bar').hide(); - $('#save_weighted_gene_cart_btn').hide(); - } - await draw(datasetId, plotConfig); - $('#plot_spinner').hide(); +document.getElementById("clear_genes_btn").addEventListener("click", clearGenes); - // Show plot options - $('#post_plot_options').show(); +// code from Bulma documentation to handle modals +document.getElementById("gene_cart_btn").addEventListener("click", ($trigger) => { + const closestButton = $trigger.target.closest(".button"); + const modal = closestButton.dataset.target; + const $target = document.getElementById(modal); + openModal($target); }); -// Delete user display -$(document).on('click', '.js-delete-display', async function () { - $('#delete_display_confirmation').hide(); - $('#delete_display_confirmation').removeClass('alert-success'); - $('#delete_display_confirmation').removeClass('alert-danger'); - - const id = this.id; - const displayId = id.replace('_delete', ''); - - const res = await $.ajax({ - url: './cgi/delete_dataset_display.cgi', - type: 'POST', - data: { id: displayId, user_id: CURRENT_USER.id }, - dataType: 'json' - }); - - $('#load_plots_modal').modal('hide'); - $('#delete_display_confirmation').show(); - if (res?.success) { - $('#delete_display_confirmation_text').text('Display was successfully deleted.'); - $('#delete_display_confirmation').addClass('alert-success'); - } else { - $('#delete_display_confirmation_text').text('There was an issue deleting the saved display.'); - $('#delete_display_confirmation').addClass('alert-danger'); - } - - // Update saved displays, now that display has been deleted - loadSavedDisplays(datasetId); +document.getElementById("new_genecart_label").addEventListener("input", (event) => { + const saveBtn = document.getElementById("save_genecart_btn"); + saveBtn.disabled = event.target.value ? false : true; }); -// Save this particular display as the user's default display -$(document).on('click', '.js-save-default', async function () { - $('#saved_default_confirmation').hide(); - $('#saved_default_confirmation').removeClass('alert-success'); - $('#saved_default_confirmation').removeClass('alert-danger'); - - const id = this.id; - const displayId = id.replace('_default', ''); - - const res = await $.ajax({ - url: './cgi/save_default_display.cgi', - type: 'POST', - data: { - user_id: CURRENT_USER.id, - dataset_id: datasetId, - display_id: displayId, - is_multigene: 1 - }, - dataType: 'json' - }); - - // Swap current default buttons - $('.js-current-default') - .prop('disabled', false) - .addClass('js-save-default') - .addClass('btn-purple') - .removeClass('btn-secondary') - .removeClass('js-current-default') - .text("Make Default") - $(`#${displayId}_default`) - .prop('disabled', true) - .removeClass('js-save-default') - .removeClass('btn-purple') - .addClass('btn-secondary') - .addClass('js-current-default') - .text("Default") - - $('#saved_default_confirmation').show(); - if (res?.success) { - $('#saved_default_confirmation_text').text('Display successfully saved as your new default.'); - $('#saved_default_confirmation').addClass('alert-success'); - } else { - $('#saved_default_confirmation_text').text('There was an issue setting the default display.'); - $('#saved_default_confirmation').addClass('alert-danger'); +document.getElementById("save_genecart_btn").addEventListener("click", (event) => { + event.preventDefault(); + event.target.classList.add("is-loading"); + // get value of genecart radio button group + const geneCartName = document.querySelector("input[name='genecart_type']:checked").value; + if (CURRENT_USER) { + if (geneCartName === "unweighted") { + saveGeneCart(); + } else { + saveWeightedGeneCart(); + } } + event.target.classList.remove("is-loading"); }); + +document.getElementById("download_selected_genes_btn").addEventListener("click", downloadSelectedGenes); \ No newline at end of file diff --git a/www/js/multigene_curator.v2.js b/www/js/multigene_curator.v2.js deleted file mode 100644 index 5782cba8..00000000 --- a/www/js/multigene_curator.v2.js +++ /dev/null @@ -1,1247 +0,0 @@ -'use strict'; - -const isMultigene = 1; - -let geneSelect = null; - -let selectedGenes = []; // genes selected from plot "select" utility - -const genesAsAxisPlots = ["dotplot", "heatmap", "mg_violin"]; -const genesAsDataPlots = ["quadrant", "volcano"]; - -class GenesAsAxisHandler extends PlotHandler { - constructor(plotType) { - super(); - this.plotType = plotType; - this.apiPlotType = plotType; - } - - classElt2Prop = { - "js-dash-primary":"primary_col" - , "js-dash-secondary":"secondary_col" - , "js-dash-color-palette":"colorscale" - , "js-dash-reverse-palette":"reverse_colorscale" - , "js-dash-distance-metric":"distance_metric" - , "js-dash-matrixplot":"matrixplot" - , "js-dash-center-mean": "center_around_zero" - , "js-dash-cluster-obs": "cluster_obs" - , "js-dash-cluster-genes": "cluster_genes" - , "js-dash-flip-axes": "flip_axes" - , "js-dash-hide-obs-labels": "hide_obs_labels" - , "js-dash-hide-gene-labels": "hide_gene_labels" - , "js-dash-add-jitter": "violin_add_points" - , "js-dash-subsampling-limit": "subsample_limit" - , "js-dash-stacked-violin": "stacked_violin" - , "js-dash-plot-title": "plot_title" - , "js-dash-legend-title": "legend_title" - } - - // deal with clusterbar separately since it is an array of selected options - - configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); - - plotConfig = {}; // Plot config that is passed to API - - plotJson = null; // Plotly plot JSON - - cloneDisplay(config) { - // load plot values - for (const prop in config) { - setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config[prop]); - } - - // Handle order - if (config["sort_order"]) { - for (const series in config["sort_order"]) { - const order = config["sort_order"][series]; - // sort "levels" series by order - levels[series].sort((a, b) => order.indexOf(a) - order.indexOf(b)); - renderOrderSortableSeries(series); - } - - document.getElementById("order_section").classList.remove("is-hidden"); - } - - // Handle filters - if (config["obs_filters"]) { - facetWidget.filters = config["obs_filters"]; - } - - // handle colors - if (config["color_palette"]) { - setSelectBoxByValue("color_palette_post", config["color_palette"]); - //colorscaleSelect.update(); - } - - // handle clusterbar values - if (config["clusterbar_fields"]) { - for (const field of config["clusterbar_fields"]) { - const elt = document.querySelector(`#clusterbar_c .js-dash-clusterbar-checkbox[value="${field}"]`); - elt.checked = true; - } - } - - // restore subsample limit - if (config["subsample_limit"]) { - for (const classElt of document.getElementsByClassName("js-dash-enable-subsampling")) { - classElt.checked = true; - } - } - - } - - async createPlot(datasetId, analysisObj) { - // Get data and set up the image area - try { - const data = await fetchDashData(datasetId, analysisObj, this.apiPlotType, this.plotConfig); - ({plot_json: this.plotJson} = data); - } catch (error) { - return; - } - - const plotContainer = document.getElementById("plot_container"); - plotContainer.replaceChildren(); // erase plot - - // NOTE: Plot initially is created to a default width but is responsive. - // Noticed container within our "column" will make full-width go beyond the screen - const plotlyPreview = document.createElement("div"); - plotlyPreview.classList.add("container", "is-max-desktop"); - plotlyPreview.id = "plotly_preview"; - plotContainer.append(plotlyPreview); - Plotly.purge("plotly_preview"); // clear old Plotly plots - - if (!this.plotJson) { - createToast("Could not retrieve plot information. Cannot make plot."); - return; - } - - if (this.plotType === 'heatmap') { - setHeatmapHeightBasedOnGenes(this.plotJson.layout, this.plotConfig.gene_symbols); - } else if (this.plotType === "mg_violin" && this.plotConfig.stacked_violin){ - adjustStackedViolinHeight(this.plotJson.layout); - } - - // Update plot with custom plot config stuff stored in plot_display_config.js - const curatorDisplayConf = postPlotlyConfig.curator; - const custonConfig = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "config"); - Plotly.newPlot("plotly_preview", this.plotJson.data, this.plotJson.layout, custonConfig); - const custonLayout = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "layout") - Plotly.relayout("plotly_preview", custonLayout) - - document.getElementById("legend_title_container").classList.remove("is-hidden"); - if (this.plotType === "dotplot") { - document.getElementById("legend_title_container").classList.add("is-hidden"); - } - - } - - async loadPlotHtml() { - const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); - prePlotOptionsElt.replaceChildren(); - - const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); - postPlotOptionsElt.replaceChildren(); - - prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/multi_gene_as_axis.html"); - postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/multi_gene_as_axis.html"); - - // populate advanced options for specific plot types - const prePlotSpecificOptionsElt = document.getElementById("plot_specific_options"); - const postPlotSpecificOptionselt = document.getElementById("post_plot_specific_options"); - - // Load color palette select options - const isContinuous = ["dotplot", "heatmap"].includes(this.plotType) ? true : false; - loadColorscaleSelect(isContinuous); - - - if (this.plotType === "heatmap") { - prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_heatmap.html"); - postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_heatmap.html"); - return; - } - if (this.plotType === "mg_violin") { - prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_mg_violin.html"); - postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_mg_violin.html"); - return; - } - } - - populatePlotConfig() { - this.plotConfig = {}; // Reset plot config - - for (const classElt in this.classElt2Prop) { - this.plotConfig[this.classElt2Prop[classElt]] = getPlotConfigValueFromClassName(classElt) - } - - // Get checked clusterbar values - if (this.plotType === "heatmap") { - const clusterbarValues = []; - // They should be synced so just grab the first set of clusterbar values - for (const elt of document.querySelectorAll("#clusterbar_c .js-dash-clusterbar-checkbox")) { - if (elt.checked) { - clusterbarValues.push(elt.value); - } - } - this.plotConfig["clusterbar_fields"] = clusterbarValues; - - - // if subsampling is checked, add subsampling limit to plot config - this.plotConfig["subsample_limit"] = 0; - if (document.querySelector(".js-dash-enable-subsampling").checked) { - this.plotConfig["subsample_limit"] = Number(document.querySelector(".js-dash-subsampling-limit").value); - } - } - - // Filtered observation groups - this.plotConfig["obs_filters"] = facetWidget?.filters || {}; - - // Get order - this.plotConfig["sort_order"] = getPlotOrderFromSortable(); - - } - - async setupParamValueCopyEvent() { - // These plot parameters do not directly correlate to a plot config property - //setupParamValueCopyEvent("js-dash-enable-subsampling") - } - - async setupPlotSpecificEvents() { - catColumns = await getCategoryColumns(); - - updateSeriesOptions("js-dash-primary", catColumns); - updateSeriesOptions("js-dash-secondary", catColumns); - - // If primary series changes, disable chosen option in secondary series - for (const classElt of document.getElementsByClassName("js-dash-primary")) { - classElt.addEventListener("change", (event) => { - const primarySeries = event.target.value; - for (const secondaryClassElt of document.getElementsByClassName("js-dash-secondary")) { - // enable all series - for (const opt of secondaryClassElt.options) { - opt.removeAttribute("disabled"); - } - // disable primary series in secondary series - const opt = secondaryClassElt.querySelector(`option[value="${primarySeries}"]`); - opt.setAttribute("disabled", "disabled"); - // If this option was selected, unselect it - if (secondaryClassElt.value === primarySeries) { - secondaryClassElt.value = ""; - } - } - }) - } - - // if subsampling is checked, enable subsampling limit - for (const classElt of document.getElementsByClassName("js-dash-enable-subsampling")) { - classElt.addEventListener("change", (event) => { - const checked = event.target.checked; - for (const innerClassElt of document.getElementsByClassName("js-dash-subsampling-limit")) { - innerClassElt.disabled = !(checked); - } - }) - } - - // For clusterbar options, create checkboxes for all catColumns - for (const classElt of document.getElementsByClassName("js-dash-clusterbar")) { - for (const catColumn of catColumns) { - - const clusterbarElt = document.createElement("div"); - clusterbarElt.classList.add("control"); - - const label = document.createElement("label"); - label.classList.add("checkbox"); - - const input = document.createElement("input"); - input.type = "checkbox"; - input.value = catColumn; - input.classList.add("js-dash-clusterbar-checkbox"); - label.appendChild(input); - label.innerHTML += catColumn; - clusterbarElt.appendChild(label); - - classElt.appendChild(clusterbarElt); - - } - } - - // Add event listener to sync checkboxes with same value - for (const inputElt of document.getElementsByClassName("js-dash-clusterbar-checkbox")) { - inputElt.addEventListener("change", (event) => { - const checked = event.target.checked; - const value = event.target.value; - for (const innerClassElt of document.getElementsByClassName("js-dash-clusterbar-checkbox")) { - if (innerClassElt.value === value) { - innerClassElt.checked = checked; - } - } - }) - } - - // If stacked violin is checked, disable legend title (since there is no legend) - for (const classElt of document.getElementsByClassName("js-dash-stacked-violin")) { - classElt.addEventListener("change", (event) => { - const checked = event.target.checked; - for (const legendTitleElt of document.getElementsByClassName("js-dash-legend-title")) { - if (checked) { - legendTitleElt.setAttribute("disabled", "disabled"); - } else { - legendTitleElt.removeAttribute("disabled"); - } - } - }) - } - - // Certain elements trigger plot order - const plotOrderElts = document.getElementsByClassName("js-plot-order"); - for (const elt of plotOrderElts) { - elt.addEventListener("change", (event) => { - const paramId = event.target.id; - const param = paramId.replace("_series", "").replace("_post", ""); - // NOTE: continuous series will be handled in the function - updateOrderSortable(); - }); - } - } - -} - -class GenesAsDataHandler extends PlotHandler { - constructor(plotType) { - super(); - this.plotType = plotType; - this.apiPlotType = plotType; - } - - compareSeparator = ";-;"; - - classElt2Prop = { - "js-dash-de-test": "de_test_algo" - , "js-dash-fold-change-cutoff": "fold_change_cutoff" - , "js-dash-fdu-cutoff": "fdr_cutoff" - , "js-dash-include-zero-fc": "include_zero_fc" - , "js-dash-annot-nonsig": "annot_nonsignificant" - , "js-dash-pvalue-threshold": "pvalue_threshold" - , "js-dash-use-adj-pvalues": "adj_pvals" - , "js-dash-lower-logfc-threshold": "lower_logfc_threshold" - , "js-dash-upper-logfc-threshold": "upper_logfc_threshold" - , "js-dash-plot-title": "plot_title" - , "js-dash-legend-title": "legend_title" - }; - // Deal with js-dash-query/reference/compare1/compare2 separately since they combine with js-dash-compare - - configProp2ClassElt = Object.fromEntries(Object.entries(this.classElt2Prop).map(([key, value]) => [value, key])); - - plotConfig = {}; // Plot config that is passed to API - - plotJson = null; // Plotly plot JSON - - cloneDisplay(config) { - // load plot values - for (const prop in config) { - setPlotEltValueFromConfig(this.configProp2ClassElt[prop], config[prop]); - } - - // Handle filters - if (config["obs_filters"]) { - facetWidget.filters = config["obs_filters"]; - } - - // Split compare series and groups - const refCondition = config["ref_condition"]; - const [compareSeries, refGroup] = refCondition.split(this.compareSeparator); - for (const classElt of document.getElementsByClassName("js-dash-compare")) { - classElt.value = compareSeries; - } - - // populate group options - updateGroupOptions("js-dash-reference", levels[compareSeries]); - for (const classElt of document.getElementsByClassName("js-dash-reference")) { - classElt.value = refGroup; - } - - if (this.plotType === "volcano") { - updateGroupOptions("js-dash-query", levels[compareSeries]); - const queryCondition = config["query_condition"]; - const queryGroup = queryCondition.split(this.compareSeparator)[1]; - for (const classElt of document.getElementsByClassName("js-dash-query")) { - classElt.value = queryGroup; - } - trigger(document.querySelector(".js-dash-query"), "change"); // trigger change event to start validation - - } - if (this.plotType === "quadrant") { - updateGroupOptions("js-dash-compare1", levels[compareSeries]); - updateGroupOptions("js-dash-compare2", levels[compareSeries]); - - const compare1Condition = config["compare1_condition"]; - const compare1Group = compare1Condition.split(this.compareSeparator)[1]; - const compare2Condition = config["compare2_condition"]; - const compare2Group = compare2Condition.split(this.compareSeparator)[1]; - for (const classElt of document.getElementsByClassName("js-dash-compare1")) { - classElt.value = compare1Group; - } - for (const classElt of document.getElementsByClassName("js-dash-compare2")) { - classElt.value = compare2Group; - } - - trigger(document.querySelector(".js-dash-compare1"), "change"); // trigger change event to start validation - } - - // for some reason triggering .js-dash-compare did not populate the compare groups into the plot config - } - - async createPlot(datasetId, analysisObj) { - // Get data and set up the image area - try { - const data = await fetchDashData(datasetId, analysisObj, this.apiPlotType, this.plotConfig); - ({plot_json: this.plotJson} = data); - } catch (error) { - return; - } - - const plotContainer = document.getElementById("plot_container"); - plotContainer.replaceChildren(); // erase plot - - // NOTE: Plot initially is created to a default width but is responsive. - // Noticed container within our "column" will make full-width go beyond the screen - const plotlyPreviewElt = document.createElement("div"); - plotlyPreviewElt.classList.add("container", "is-max-desktop"); - plotlyPreviewElt.id = "plotly_preview"; - plotContainer.append(plotlyPreviewElt); - Plotly.purge("plotly_preview"); // clear old Plotly plots - - if (!this.plotJson) { - createToast("Could not retrieve plot information. Cannot make plot."); - return; - } - // Update plot with custom plot config stuff stored in plot_display_config.js - const curatorDisplayConf = postPlotlyConfig.curator; - const custonConfig = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "config"); - Plotly.newPlot("plotly_preview", this.plotJson.data, this.plotJson.layout, custonConfig); - const custonLayout = getPlotlyDisplayUpdates(curatorDisplayConf, this.plotType, "layout") - Plotly.relayout("plotly_preview", custonLayout) - - // Show button to add genes to gene cart - document.getElementById("gene_cart_btn_c").classList.remove("is-hidden"); - - const plotlyPreview = document.getElementById("plotly_preview"); - - // Append small note about using the Plotly selection utilities - const plotlyNote = document.createElement("div"); - plotlyNote.classList.add("notification", "is-info", "is-light"); - plotlyNote.innerHTML = `

Tip: Use the Plotly box and lasso select tools (upper-right) to select genes to view as a table.

`; - plotlyPreview.append(plotlyNote); - - // If plot data is selected, create the right-column table and do other misc things - plotlyPreview.on("plotly_selected", async (eventData) => { - - // Hide selected genes table and disable unweighted radio button if no genes are selected - document.getElementById("tbl_selected_genes").classList.add("is-hidden"); - document.getElementById("download_selected_genes_btn").classList.add("is-hidden"); - document.querySelector("input[name='genecart_type'][value='unweighted']").disabled = true; - document.querySelector("input[name='genecart_type'][value='unweighted']").parentElement.removeAttribute("disabled"); - if (eventData?.points.length) { - document.getElementById("tbl_selected_genes").classList.remove("is-hidden"); - document.getElementById("download_selected_genes_btn").classList.remove("is-hidden"); - document.querySelector("input[name='genecart_type'][value='unweighted']").disabled = false; - document.querySelector("input[name='genecart_type'][value='unweighted']").parentElement.setAttribute("disabled", "disabled"); - - adjustGeneTableLabels(this.plotType); - populateGeneTable(eventData, this.plotType); - } - - // Highlight table rows that match searched genes - const searchedGenes = this.plotConfig.gene_symbols; - if (searchedGenes) { - const geneTableBody = document.getElementById("gene_table_body"); - // Select the first column (gene_symbols) in each row - for (const row of geneTableBody.children) { - const tableGene = row.children[0].textContent; - for (const gene of searchedGenes) { - if (gene.toLowerCase() === tableGene.toLowerCase() ) { - row.classList.add("has-background-success-light"); - } - }; - } - } - }); - - } - - async loadPlotHtml() { - - const prePlotOptionsElt = document.getElementById("plot_options_collapsable"); - prePlotOptionsElt.replaceChildren(); - - const postPlotOptionsElt = document.getElementById("post_plot_adjustments"); - postPlotOptionsElt.replaceChildren(); - - prePlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/multi_gene_as_data.html"); - postPlotOptionsElt.innerHTML = await includeHtml("../include/plot_config/post_plot/multi_gene_as_data.html"); - - // populate advanced options for specific plot types - const prePlotSpecificOptionsElt = document.getElementById("plot_specific_options"); - const postPlotSpecificOptionselt = document.getElementById("post_plot_specific_options"); - - // For quadrants and volcanos we load the "series" options in the plot-specific HTML, so that should come first - if (this.plotType === "quadrant") { - prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_quadrant.html"); - postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_quadrant.html"); - return; - } - if (this.plotType === "volcano") { - prePlotSpecificOptionsElt.innerHTML = await includeHtml("../include/plot_config/pre_plot/advanced_volcano.html"); - postPlotSpecificOptionselt.innerHTML = await includeHtml("../include/plot_config/post_plot/advanced_volcano.html"); - return; - } - } - - populatePlotConfig() { - this.plotConfig = {}; // Reset plot config - - for (const classElt in this.classElt2Prop) { - this.plotConfig[this.classElt2Prop[classElt]] = getPlotConfigValueFromClassName(classElt) - } - - // convert numerical inputs from plotConfig into Number type - for (const prop of ["fold_change_cutoff", "fdr_cutoff", "pvalue_threshold", "lower_logfc_threshold", "upper_logfc_threshold"]) { - if (this.plotConfig[prop]) { - this.plotConfig[prop] = Number(this.plotConfig[prop]); - } - } - - // Get compare series and groups and combine - const combineSeries = document.querySelector(".js-dash-compare").value; - facetWidget.filters[combineSeries] = []; - const refGroup = document.querySelector(".js-dash-reference").value; - this.plotConfig["ref_condition"] = combineSeries + this.compareSeparator + refGroup - facetWidget.filters[combineSeries].push(refGroup); - - if (this.plotType === "volcano") { - const queryGroup = document.querySelector(".js-dash-query").value; - this.plotConfig["query_condition"] = combineSeries + this.compareSeparator + queryGroup; - facetWidget.filters[combineSeries].push(queryGroup); - } - if (this.plotType === "quadrant") { - const compare1Group = document.querySelector(".js-dash-compare1").value; - const compare2Group = document.querySelector(".js-dash-compare2").value; - this.plotConfig["compare1_condition"] = combineSeries + this.compareSeparator + compare1Group; - this.plotConfig["compare2_condition"] = combineSeries + this.compareSeparator + compare2Group; - facetWidget.filters[combineSeries].push(compare1Group); - facetWidget.filters[combineSeries].push(compare2Group); - } - - // Filtered observation groups - this.plotConfig["obs_filters"] = facetWidget?.filters || {}; - - } - - async setupParamValueCopyEvent() { - // These plot parameters do not directly correlate to a plot config property - for (const classElt of ["js-dash-compare", "js-dash-reference", "js-dash-query", "js-dash-compare1", "js-dash-compare2"]) { - setupParamValueCopyEvent(classElt) - } - } - - async setupPlotSpecificEvents() { - - catColumns = await getCategoryColumns(); - updateSeriesOptions("js-dash-compare", catColumns); - - // When compare series changes, update the compare groups - for (const classElt of document.getElementsByClassName("js-dash-compare")) { - classElt.addEventListener("change", async (event) => { - const compareSeries = event.target.value; - updateGroupOptions("js-dash-reference", levels[compareSeries]); - if (this.plotType === "quadrant") { - updateGroupOptions("js-dash-compare1", levels[compareSeries]); - updateGroupOptions("js-dash-compare2", levels[compareSeries]); - } - if (this.plotType === "volcano") { - updateGroupOptions("js-dash-query", levels[compareSeries]); - } - }) - } - - // When compare groups change, prevent the same group from being selected in the other compare groups - for (const classElt of document.getElementsByClassName("js-compare-groups")) { - classElt.addEventListener("change", (event) => { - const compareGroups = [...document.getElementsByClassName("js-compare-groups")].map((elt) => elt.value); - // Filter out empty values and duplicates - const uniqueGroups = [...new Set(compareGroups)].filter(x => x); - // Get all unselected groups - const series = document.querySelector(".js-dash-compare").value; - const unselectedGroups = levels[series].filter((group) => !uniqueGroups.includes(group)); - - for (const innerClassElt of document.getElementsByClassName("js-compare-groups")) { - // enable all unselected groups - for (const group of unselectedGroups) { - const opt = innerClassElt.querySelector(`option[value="${group}"]`); - opt.removeAttribute("disabled"); - } - // disable unique groups in other compare groups - for (const group of uniqueGroups) { - if (innerClassElt.id !== event.target.id) { - const opt = innerClassElt.querySelector(`option[value="${group}"]`); - opt.setAttribute("disabled", "disabled"); - } - } - } - }) - } - } -} - -const geneCartTree = new GeneCartTree({ - element: document.getElementById("genecart_tree") - , searchElement: document.getElementById("genecart_query") - , selectCallback: (async (e) => { - if (e.node.type !== "genecart") { - return; - } - - // Get gene symbols from gene cart - const geneCartId = e.node.data.orig_id; - const geneCartMembers = await fetchGeneCartMembers(geneCartId); - const geneCartSymbols = geneCartMembers.map((item) => item.label); - - // Normalize gene symbols to lowercase - const geneSelectSymbols = geneSelect.data.map((opt) => opt.value); - const geneCartSymbolsLowerCase = geneCartSymbols.map((x) => x.toLowerCase()); - - const geneSelectedOptions = geneSelect.selectedOptions.map((opt) => opt.data.value); - - // Get genes from gene cart that are present in dataset's genes. Preserve casing of dataset's genes. - const geneCartIntersection = geneSelectSymbols.filter((x) => geneCartSymbolsLowerCase.includes(x.toLowerCase())); - // Add in already selected genes (union) - const geneSelectIntersection = [...new Set(geneCartIntersection.concat(geneSelectedOptions))]; - - // change all options to be unselected - const origSelect = document.getElementById("gene_select"); - for (const opt of origSelect.options) { - opt.removeAttribute("selected"); - } - - // Assign intersection genes to geneSelect "selected" options - for (const gene of geneSelectIntersection) { - const opt = origSelect.querySelector(`option[value="${gene}"]`); - try { - opt.setAttribute("selected", "selected"); - } catch (error) { - // sanity check - const msg = `Could not add gene ${gene} to gene select.`; - console.warn(msg); - } - } - - geneSelect.update(); - trigger(document.getElementById("gene_select"), "change"); // triggers chooseGene() to load tags - }) -}); - -const adjustGeneTableLabels = (plotType) => { - const geneX = document.getElementById("tbl_gene_x"); - const geneY = document.getElementById("tbl_gene_y"); - - // Adjust headers to the plot type - if (plotType === "quadrant") { - geneX.innerHTML = 'X Log2 FC '; - geneY.innerHTML = 'Y Log2 FC '; - } else { - // volcano - geneX.innerHTML = 'Log2 FC '; - geneY.innerHTML = 'P-value '; - } -} - - -const appendGeneTagButton = (geneTagElt) => { - // Add delete button - const deleteBtnElt = document.createElement("button"); - deleteBtnElt.classList.add("delete", "is-small"); - geneTagElt.appendChild(deleteBtnElt); - deleteBtnElt.addEventListener("click", (event) => { - // Remove gene from geneSelect - const gene = event.target.parentNode.textContent; - const geneSelectElt = document.getElementById("gene_select"); - geneSelectElt.querySelector(`option[value="${gene}"]`).removeAttribute("selected"); - - geneSelect.update(); - trigger(document.getElementById("gene_select"), "change"); // triggers chooseGene() to load tags - }); - - // ? Should i add ellipses for too many genes? Should I make the box collapsable? -} - -const clearGenes = (event) => { - document.getElementById("clear_genes_btn").classList.add("is-loading"); - geneSelect.clear(); - document.getElementById("clear_genes_btn").classList.remove("is-loading"); -} - -const curatorSpecifcChooseGene = (event) => { - // Triggered when a gene is selected - - // Delete existing tags - const geneTagsElt = document.getElementById("gene_tags"); - geneTagsElt.replaceChildren(); - - if (!geneSelect.selectedOptions.length) return; // Do not trigger after initial population - - // Update list of gene tags - const sortedGenes = geneSelect.selectedOptions.map((opt) => opt.data.value).sort(); - for (const opt in sortedGenes) { - const geneTagElt = document.createElement("span"); - geneTagElt.classList.add("tag", "is-primary"); - geneTagElt.textContent = sortedGenes[opt]; - appendGeneTagButton(geneTagElt); - geneTagsElt.appendChild(geneTagElt); - } - - document.getElementById("gene_tags_c").classList.remove("is-hidden"); - if (!geneSelect.selectedOptions.length) { - document.getElementById("gene_tags_c").classList.add("is-hidden"); - } - - // Cannot plot if 2+ genes are not selected - if (geneSelect.selectedOptions.length < 2) { - document.getElementById("gene_s_failed").classList.remove("is-hidden"); - document.getElementById("gene_s_success").classList.add("is-hidden"); - for (const plotBtn of document.getElementsByClassName("js-plot-btn")) { - plotBtn.disabled = true; - } - document.getElementById("continue_to_plot_options").classList.add("is-hidden"); - return; - } - - // If more than 10 tags, hide the rest and add a "show more" button - if (geneSelect.selectedOptions.length > 10) { - const geneTags = geneTagsElt.querySelectorAll("span.tag"); - for (let i = 10; i < geneTags.length; i++) { - geneTags[i].classList.add("is-hidden"); - } - // Add show more button - const showMoreBtnElt = document.createElement("button"); - showMoreBtnElt.classList.add("tag", "button", "is-small", "is-primary", "is-light"); - const numToDisplay = geneSelect.selectedOptions.length - 10; - showMoreBtnElt.textContent = `+${numToDisplay} more`; - showMoreBtnElt.addEventListener("click", (event) => { - const geneTags = geneTagsElt.querySelectorAll("span.tag"); - for (let i = 10; i < geneTags.length; i++) { - geneTags[i].classList.remove("is-hidden"); - } - event.target.remove(); - }); - geneTagsElt.appendChild(showMoreBtnElt); - - } - - - document.getElementById("gene_s_failed").classList.add("is-hidden"); - document.getElementById("gene_s_success").classList.remove("is-hidden"); - - // Force validation check to see if plot button should be enabled - //trigger(document.querySelector(".js-plot-req"), "change"); - - document.getElementById("continue_to_plot_options").classList.remove("is-hidden"); - -} - -const curatorSpecifcCreatePlot = async (plotType) => { - // Call API route by plot type - await plotStyle.createPlot(datasetId, analysisObj); -} - -const curatorSpecifcDatasetTreeCallback = async () => { - // Creates gene select instance that allows for multiple selection - geneSelect = createGeneSelectInstance("gene_select", geneSelect); -} - -const curatorSpecificNavbarUpdates = () => { - // Update with current page info - document.querySelector("#header_bar .navbar-item").textContent = "Multi-gene Displays"; - - for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { - elt.classList.remove("is-active"); - } - - document.querySelector("a[tool='mg_displays'").classList.add("is-active"); -} - -const curatorSpecificOnLoad = async () => { - // Load gene carts - await loadGeneCarts(); -} - -const curatorSpecificPlotStyle = (plotType) => { - // include plotting backend options - if (genesAsAxisPlots.includes(plotType)) { - return new GenesAsAxisHandler(plotType); - } else if (genesAsDataPlots.includes(plotType)) { - return new GenesAsDataHandler(plotType); - } else { - return null; - } -} - -const curatorSpecificPlotTypeAdjustments = (plotType) => { - return plotType; -} - -const curatorSpecificUpdateGeneOptions = async (geneSymbols) => { - //pass -} - -const downloadSelectedGenes = (event) => { - event.preventDefault(); - - // Builds a file in memory for the user to download. Completely client-side. - // plot_data contains three keys: x, y and symbols - // build the file string from this - - const plotType = plotStyle.plotType; - const plotConfig = plotStyle.plotConfig; - - // Adjust headers to the plot type - let xLabel; - let yLabel; - - if (plotType === "quadrant") { - const query1 = plotConfig.compare1_condition.split(';-;')[1]; - const query2 = plotConfig.compare2_condition.split(';-;')[1]; - const ref = plotConfig.ref_condition.split(';-;')[1]; - xLabel = `${query1} vs ${ref} Log2FC`; - yLabel = `${query2} vs ${ref} Log2FC`; - } else { - const query = plotConfig.query_condition.split(';-;')[1]; - let ref = plotConfig.ref_condition.split(';-;')[1]; - - ref = ref === "Union of the rest of the groups" ? "rest" : ref; - - // volcano - xLabel = `${query} vs ${ref} Log2FC`; - yLabel = `${query} vs ${ref} p-value`; - } - let fileContents = `gene_symbol\t${xLabel}\t${yLabel}\n`; - - // Entering genes and info now. - selectedGenes.forEach((gene) => { - fileContents += - `${gene.gene_symbol}\t` - + `${gene.x}\t` - + `${gene.y}\n` - }); - - const element = document.createElement("a"); - element.setAttribute( - "href", - `data:text/tab-separated-values;charset=utf-8,${encodeURIComponent(fileContents)}` - ); - element.setAttribute("download", "selected_genes.tsv"); - element.style.display = "none"; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); -} - -const fetchDashData = async (datasetId, analysis, plotType, plotConfig) => { - try { - const data = await apiCallsMixin.fetchDashData(datasetId, analysis, plotType, plotConfig); - if (data?.success < 1) { - throw new Error (data?.message || "Unknown error.") - } - return data - } catch (error) { - logErrorInConsole(error); - const msg = "Could not create plot for this dataset and parameters. Please contact the gEAR team." - createToast(msg); - throw new Error(msg); - } -} - -/* Fetch gene collections */ -const fetchGeneCarts = async () => { - const cartType = "unweighted-list"; - try { - return await apiCallsMixin.fetchGeneCarts(cartType); - } catch (error) { - logErrorInConsole(error); - const msg = "Could not fetch gene collections. You can still enter genes manually."; - createToast(msg); - throw new Error(msg); - } -} - -/* Fetch gene collection members */ -const fetchGeneCartMembers = async (geneCartId) => { - try { - const {gene_symbols, success} = await apiCallsMixin.fetchGeneCartMembers(geneCartId); - if (!success) { - throw new Error("Could not fetch gene collection members. You can still enter genes manually."); - } - return gene_symbols; - } catch (error) { - logErrorInConsole(error); - const msg = "Could not fetch gene collection members. You can still enter genes manually."; - createToast(msg); - throw new Error(msg); - } -} - -const getCategoryColumns = async () => { - const analysisValue = analysisSelect.selectedOptions.length ? getSelect2Value(analysisSelect) : undefined; - const analysisId = (analysisValue && analysisValue > 0) ? analysisValue : null; - try { - ({obs_columns: allColumns, obs_levels: levels} = await curatorApiCallsMixin.fetchH5adInfo(datasetId, analysisId)); - } catch (error) { - document.getElementById("plot_options_s_failed").classList.remove("is-hidden"); - return; - } - // Filter out values we don't want of "levels", like "colors" - for (const key in levels) { - if (key.includes("_colors")) { - delete levels[key]; - } - } - return Object.keys(levels); -} - -// Invert a log function -const invertLogFunction = (value, base=10) => { - return base ** value; -} - -/* Transform and load gene collection data into a "tree" format */ -const loadGeneCarts = async () => { - try { - const geneCartData = await fetchGeneCarts(); - const carts = {}; - const cartTypes = ['domain', 'user', 'group', 'shared', 'public']; - let cartsFound = false; - - // Loop through the different types of gene collections and add them to the carts object - for (const ctype of cartTypes) { - carts[ctype] = []; - - if (geneCartData[`${ctype}_carts`].length > 0) { - cartsFound = true; - - for (const item of geneCartData[`${ctype}_carts`]) { - carts[ctype].push({value: item.id, text: item.label }); - }; - } - } - - geneCartTree.domainGeneCarts = carts.domain; - geneCartTree.userGeneCarts = carts.user; - geneCartTree.groupGeneCarts = carts.group; - geneCartTree.sharedGeneCarts = carts.shared; - geneCartTree.publicGeneCarts = carts.public; - geneCartTree.generateTree(); - /*if (!cartsFound ) { - // ? Put some warning if carts not found - $('#gene_cart_container').show(); - }*/ - - } catch (error) { - document.getElementById("gene_s_failed").classList.remove("is-hidden"); - } - -} - -const populateGeneTable = (data, plotType) => { - selectedGenes = []; - - for (const pt of data.points) { - selectedGenes.push({ - gene_symbol: pt.data.text[pt.pointNumber], - ensembl_id: pt.data.customdata[pt.pointNumber], // Ensembl ID stored in "customdata" property - x: pt.data.x[pt.pointNumber].toFixed(1), - y: plotType === "volcano" ? invertLogFunction(-pt.data.y[pt.pointNumber]).toExponential(2) : pt.data.y[pt.pointNumber].toFixed(2), - }); - }; - - // Sort in alphabetical order - selectedGenes.sort(); - - - const geneTableBody = document.getElementById("gene_table_body"); - geneTableBody.replaceChildren(); - - for (const gene of selectedGenes) { - const row = document.createElement("tr"); - row.innerHTML = `${gene.gene_symbol}${gene.x}${gene.y}`; - geneTableBody.appendChild(row); - } -} - -const saveGeneCart = () => { - // must have access to USER_SESSION_ID - const gc = new GeneCart({ - session_id: sessionId - , label: document.getElementById("new_genecart_label").value - , gctype: "unweighted-list" - , organism_id: organismId - , is_public: 0 - }); - - for (const sg of selectedGenes) { - const gene = new Gene({ - id: sg.ensembl_id, // Ensembl ID stored in "customdata" property - gene_symbol: sg.gene_symbol, - }); - gc.addGene(gene); - } - - gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); -} - -const saveWeightedGeneCart = () => { - // must have access to USER_SESSION_ID - const plotType = plotStyle.plotType; - const plotConfig = plotStyle.plotConfig; - - // Saving raw FC by default so it is easy to transform weight as needed - const foldchangeLabel = "FC" - let weightLabels = [foldchangeLabel]; - - - if (plotType === "quadrant") { - const query1 = plotConfig.compare1_condition.split(';-;')[1]; - const query2 = plotConfig.compare2_condition.split(';-;')[1]; - const ref = plotConfig.ref_condition.split(';-;')[1]; - const xLabel = `${query1}-vs-${ref}`; - const yLabel = `${query2}-vs-${ref}`; - - const fcl1 = `${xLabel}-${foldchangeLabel}` - const fcl2 = `${yLabel}-${foldchangeLabel}` - - weightLabels = [fcl1, fcl2]; - } - - const gc = new WeightedGeneCart({ - session_id: sessionId - , label: document.getElementById("new_genecart_label").value - , gctype: 'weighted-list' - , organism_id: organismId - , is_public: 0 - }, weightLabels); - - // Volcano and Quadrant plots have multiple traces of genes, broken into groups. - // Loop through these to get the info we need. - for (const trace of plotStyle.plotJson.data) { - for (const pt in trace.x) { - - const foldchange = Number(trace.x[pt].toFixed(1)); - const weights = [foldchange]; - - // If quadrant plot was specified, there are fold changes in x and y axis - if (plotType === "quadrant") { - const foldchange2 = Number(trace.y[pt].toFixed(1)); - weights.push(foldchange2); - } - - const gene = new WeightedGene({ - id: trace.customdata[pt], // Ensembl ID stored in "customdata" property - gene_symbol: trace.text[pt] - }, weights); - gc.addGene(gene); - } - }; - - gc.save(updateUIAfterGeneCartSaveSuccess, updateUIAfterGeneCartSaveFailure); -} - -// Taken from https://www.w3schools.com/howto/howto_js_sort_table.asp -const sortGeneTable = (mode) => { - let table; - let rows; - let switching; - let i; - let x; - let y; - let shouldSwitch; - let dir; - let switchcount = 0; - table = document.getElementById("tbl_selected_genes"); - - switching = true; - // Set the sorting direction to ascending: - dir = "asc"; - /* Make a loop that will continue until - no switching has been done: */ - while (switching) { - // Start by saying: no switching is done: - switching = false; - rows = table.rows; - /* Loop through all table rows (except the - first, which contains table headers): */ - for (i = 1; i < rows.length - 1; i++) { - // Start by saying there should be no switching: - shouldSwitch = false; - /* Get the two elements you want to compare, - one from current row and one from the next: */ - x = rows[i].getElementsByTagName("td")[mode]; - y = rows[i + 1].getElementsByTagName("td")[mode]; - /* Check if the two rows should switch place, - based on the direction, asc or desc: */ - if (dir == "asc") { - // First column is gene_symbol... rest are numbers - if (mode === 0 && x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) { - // If so, mark as a switch and break the loop: - shouldSwitch = true; - break; - } - if (Number(x.innerHTML) > Number(y.innerHTML)) { - shouldSwitch = true; - break; - } - } else if (dir == "desc") { - if (mode === 0 && x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) { - // If so, mark as a switch and break the loop: - shouldSwitch = true; - break; - } - if (Number(x.innerHTML) < Number(y.innerHTML)) { - shouldSwitch = true; - break; - } - } - } - if (shouldSwitch) { - /* If a switch has been marked, make the switch - and mark that a switch has been done: */ - rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); - switching = true; - // Each time a switch is done, increase this count by 1: - switchcount++; - - } else { - /* If no switching has been done AND the direction is "asc", - set the direction to "desc" and run the while loop again. */ - if (switchcount == 0 && dir == "asc") { - dir = "desc"; - switching = true; - } - } - } - - // Reset other sort icons to "ascending" state, to show what direction they will sort when clicked - const otherTblHeaders = document.querySelectorAll(`.js-tbl-gene-header:not(:nth-child(${mode + 1}))`); - for (const tblHeader of otherTblHeaders) { - const currIcon = tblHeader.querySelector("i"); - if (mode == 0) { - currIcon.classList.remove("mdi-sort-alphabetical-descending"); - currIcon.classList.add("mdi-sort-alphabetical-ascending"); - } else { - currIcon.classList.remove("mdi-sort-numeric-descending"); - currIcon.classList.add("mdi-sort-numeric-ascending"); - } - } - - // toggle the mdi icons between ascending / descending - // icon needs to reflect the current state of the sort - const selectedTblHeader = document.querySelector(`.js-tbl-gene-header:nth-child(${mode + 1})`); - const currIcon = selectedTblHeader.querySelector("i"); - if (dir == "asc") { - if (mode == 0) { - currIcon.classList.remove("mdi-sort-alphabetical-descending"); - currIcon.classList.add("mdi-sort-alphabetical-ascending"); - } else { - currIcon.classList.remove("mdi-sort-numeric-descending"); - currIcon.classList.add("mdi-sort-numeric-ascending"); - } - } else { - if (mode == 0) { - currIcon.classList.remove("mdi-sort-alphabetical-ascending"); - currIcon.classList.add("mdi-sort-alphabetical-descending"); - } else { - currIcon.classList.remove("mdi-sort-numeric-ascending"); - currIcon.classList.add("mdi-sort-numeric-descending"); - } - } -} - - -// For plotting options, populate select menus with category groups -const updateSeriesOptions = (classSelector, seriesArray) => { - - for (const elt of document.getElementsByClassName(classSelector)) { - elt.replaceChildren(); - - // Append empty placeholder element - const firstOption = document.createElement("option"); - elt.append(firstOption); - - // Add categories - for (const group of seriesArray.sort()) { - - const option = document.createElement("option"); - option.textContent = group; - option.value = group; - elt.append(option); - } - } -} - - -// For a given categorical series (e.g. "celltype"), update the "group" options -const updateGroupOptions = (classSelector, groupsArray) => { - - for (const elt of document.getElementsByClassName(classSelector)) { - elt.replaceChildren(); - - // Append empty placeholder element - const firstOption = document.createElement("option"); - elt.append(firstOption); - - // Add categories - for (const group of groupsArray.sort()) { - const option = document.createElement("option"); - option.textContent = group; - option.value = group; - elt.append(option); - } - } - -} - -const updateUIAfterGeneCartSaveSuccess = (gc) => { -} - -const updateUIAfterGeneCartSaveFailure = (gc, message) => { - createToast(message); -} - -document.getElementById("clear_genes_btn").addEventListener("click", clearGenes); - -// code from Bulma documentation to handle modals -document.getElementById("gene_cart_btn").addEventListener("click", ($trigger) => { - const closestButton = $trigger.target.closest(".button"); - const modal = closestButton.dataset.target; - const $target = document.getElementById(modal); - openModal($target); - -}); - -document.getElementById("new_genecart_label").addEventListener("input", (event) => { - const saveBtn = document.getElementById("save_genecart_btn"); - saveBtn.disabled = event.target.value ? false : true; -}); - -document.getElementById("save_genecart_btn").addEventListener("click", (event) => { - event.preventDefault(); - event.target.classList.add("is-loading"); - // get value of genecart radio button group - const geneCartName = document.querySelector("input[name='genecart_type']:checked").value; - if (CURRENT_USER) { - if (geneCartName === "unweighted") { - saveGeneCart(); - } else { - saveWeightedGeneCart(); - } - } - event.target.classList.remove("is-loading"); -}); - -document.getElementById("download_selected_genes_btn").addEventListener("click", downloadSelectedGenes); \ No newline at end of file diff --git a/www/js/user_profile.js b/www/js/user_profile.js index 9ecb61b5..4ca0956f 100644 --- a/www/js/user_profile.js +++ b/www/js/user_profile.js @@ -1,101 +1,98 @@ -/* - This script relies on the source having also included the - common.js within this project -*/ +'use strict'; -window.onload=() => { - sleep(500).then(() => { - // If CURRENT_USER is defined at this point, add information as placeholder test - if (CURRENT_USER) { - $('#email').attr('placeholder', CURRENT_USER.email); - $('#institution').attr('placeholder', CURRENT_USER.institution); - $('#colorblind_mode').prop('checked', CURRENT_USER.colorblind_mode); - $('#wantUpdates').prop('checked', CURRENT_USER.updates_wanted); - } - }) - -}; +// When password and repeated password are not the same, add a tooltip +for (const classElt of document.getElementsByClassName("js-password")) { + classElt.addEventListener("keyup", () => { + const newPasswordElt = document.getElementById("new_password"); + const repeatPasswordElt = document.getElementById("repeat_password"); -$(document).on('keyup', '#newPassword, #repeatPassword', () => { - // Originally was adding "match/nomatch" classes to #passwordMatch - // but for some reason, class does not want to be removed once added - if ($('#newPassword').val() == $('#repeatPassword').val()) { - $('#passwordMatch').html('Matching').css('color', 'green'); - $("#passwordInvalidTooltip").remove(); - $('#newPassword').removeClass('is-invalid'); - } else { - $('#passwordMatch').html('Not Matching').css('color', 'red'); - } -}); + if (newPasswordElt.value !== repeatPasswordElt.value) { + repeatPasswordElt.classList.add("is-danger"); + repeatPasswordElt.classList.remove("is-success"); + document.getElementById("password_match").classList.add("is-hidden"); + document.getElementById("password_no_match").classList.remove("is-hidden"); + // disable submit button + document.getElementById("submit_preferences").disabled = true; + return; + } + repeatPasswordElt.classList.add("is-success"); + repeatPasswordElt.classList.remove("is-danger"); + document.getElementById("password_match").classList.remove("is-hidden"); + document.getElementById("password_no_match").classList.add("is-hidden"); + // enable submit button + document.getElementById("submit_preferences").disabled = false; + }); +} // Toggle if password is visible or not -$('#showPassword').on('click', () => { - const x = document.getElementById("newPassword"); - x.type = x.type === "password" ? "text" : "password"; +document.getElementById("show_password").addEventListener("click", () => { + const newPasswordElt = document.getElementById("new_password"); + newPasswordElt.type = newPasswordElt.type === "password" ? "text" : "password"; }); -// Save changed user settings -$(document).on('click', "#submit_preferences", (e) => { - +// submit the form +document.getElementById("submit_preferences").addEventListener("click", async (event) => { // Prevent page refresh - e.preventDefault(); - - // ensure password matches - if ( $('#newPassword').val() !== $('#repeatPassword').val()) { - $('#newPassword').addClass('is-invalid'); - $('#newPassword').after('
Passwords must match.
') - //console.log("passwords did not match"); - return; - } + event.preventDefault(); - // disable the submit button and put the UID into the form - $("#submit_preferences").text("Processing"); - $("#submit_preferences").attr("disabled", true); - $('#help_id').val(CURRENT_USER.help_id); + event.target.classList.add("is-loading"); - const formData = $("#settings_form").serializeArray(); - formData.push({ - 'name':'scope', - 'value':'settings_change' - }, - { - 'name':'user_id', - 'value':CURRENT_USER.id - }); + const formData ={ + scope: "settings_change", + session_id: CURRENT_USER.session_id, + help_id: CURRENT_USER.help_id, + email: document.getElementById("email").value, + institution: document.getElementById("institution").value, + colorblind_mode: document.getElementById("colorblind_mode").checked, + want_updates: document.getElementById("want_updates").checked, + new_password: document.getElementById("new_password").value, + }; - $.ajax({ - url: './cgi/save_user_account_changes.cgi', - type: "POST", - data : formData, - dataType:"json", - success(data, _textStatus, _jqXHR) { - if (data.success == 1) { - var msg = "Settings have been saved!"; - alert_user_success(msg); - } else { - var msg = "An error occurred while updating preferences. Please try again."; - alert_user_error(msg); - } - }, - error(jqXHR, textStatus, errorThrown) { - const msg = "Unable to update preferences. Please try again."; - alert_user_error(msg); + try { + const {data} = await axios.post('./cgi/save_user_account_changes.cgi', convertToFormData(formData)); + if (data?.success === 1) { + var msg = "Settings have been saved!"; + createToast(msg, "is-success"); + } else { + var msg = "An error occurred while updating preferences. Please try again."; + throw new Error(msg); } - }); //end ajax + } catch (error) { + const msg = "Unable to update preferences. Please try again."; + logErrorInConsole(msg); + createToast(msg); + } finally { + event.target.classList.remove("is-loading"); + } - //reset submit button area no matter the outcome - $("#submit_preferences").text("Update Profile"); - $("#submit_preferences").attr("disabled", false); }); -function alert_user_error(message){ - $('.alert-container').html('').show(); -}; +/* --- Entry point --- */ +/** + * Handles page-specific login UI updates. + * + * @param {Event} event - The event object. + * @returns {Promise} - A promise that resolves when the UI updates are complete. + */ +const handlePageSpecificLoginUIUpdates = async (event) => { + + // User settings has no "active" state for the sidebar + document.querySelector("#header_bar .navbar-item").textContent = "User Profile"; + for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { + elt.classList.remove("is-active"); + } + + const sessionId = CURRENT_USER.session_id; + + if (! sessionId ) { + document.getElementById("not_logged_in_msg").classList.remove("is-hidden"); + return; + } + + // Get the user's settings from the server + document.getElementById("email").value = CURRENT_USER.email; + document.getElementById("institution").value = CURRENT_USER.institution; + document.getElementById("colorblind_mode").checked = CURRENT_USER.colorblind_mode; + document.getElementById("want_updates").checked = CURRENT_USER.updates_wanted; -function alert_user_success(message){ - $('.alert-container').html('').show(); -}; +}; \ No newline at end of file diff --git a/www/js/user_profile.v2.js b/www/js/user_profile.v2.js deleted file mode 100644 index 4ca0956f..00000000 --- a/www/js/user_profile.v2.js +++ /dev/null @@ -1,98 +0,0 @@ -'use strict'; - -// When password and repeated password are not the same, add a tooltip -for (const classElt of document.getElementsByClassName("js-password")) { - classElt.addEventListener("keyup", () => { - const newPasswordElt = document.getElementById("new_password"); - const repeatPasswordElt = document.getElementById("repeat_password"); - - if (newPasswordElt.value !== repeatPasswordElt.value) { - repeatPasswordElt.classList.add("is-danger"); - repeatPasswordElt.classList.remove("is-success"); - document.getElementById("password_match").classList.add("is-hidden"); - document.getElementById("password_no_match").classList.remove("is-hidden"); - // disable submit button - document.getElementById("submit_preferences").disabled = true; - return; - } - repeatPasswordElt.classList.add("is-success"); - repeatPasswordElt.classList.remove("is-danger"); - document.getElementById("password_match").classList.remove("is-hidden"); - document.getElementById("password_no_match").classList.add("is-hidden"); - // enable submit button - document.getElementById("submit_preferences").disabled = false; - }); -} - -// Toggle if password is visible or not -document.getElementById("show_password").addEventListener("click", () => { - const newPasswordElt = document.getElementById("new_password"); - newPasswordElt.type = newPasswordElt.type === "password" ? "text" : "password"; -}); - -// submit the form -document.getElementById("submit_preferences").addEventListener("click", async (event) => { - // Prevent page refresh - event.preventDefault(); - - event.target.classList.add("is-loading"); - - const formData ={ - scope: "settings_change", - session_id: CURRENT_USER.session_id, - help_id: CURRENT_USER.help_id, - email: document.getElementById("email").value, - institution: document.getElementById("institution").value, - colorblind_mode: document.getElementById("colorblind_mode").checked, - want_updates: document.getElementById("want_updates").checked, - new_password: document.getElementById("new_password").value, - }; - - try { - const {data} = await axios.post('./cgi/save_user_account_changes.cgi', convertToFormData(formData)); - if (data?.success === 1) { - var msg = "Settings have been saved!"; - createToast(msg, "is-success"); - } else { - var msg = "An error occurred while updating preferences. Please try again."; - throw new Error(msg); - } - } catch (error) { - const msg = "Unable to update preferences. Please try again."; - logErrorInConsole(msg); - createToast(msg); - } finally { - event.target.classList.remove("is-loading"); - } - -}); - -/* --- Entry point --- */ -/** - * Handles page-specific login UI updates. - * - * @param {Event} event - The event object. - * @returns {Promise} - A promise that resolves when the UI updates are complete. - */ -const handlePageSpecificLoginUIUpdates = async (event) => { - - // User settings has no "active" state for the sidebar - document.querySelector("#header_bar .navbar-item").textContent = "User Profile"; - for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { - elt.classList.remove("is-active"); - } - - const sessionId = CURRENT_USER.session_id; - - if (! sessionId ) { - document.getElementById("not_logged_in_msg").classList.remove("is-hidden"); - return; - } - - // Get the user's settings from the server - document.getElementById("email").value = CURRENT_USER.email; - document.getElementById("institution").value = CURRENT_USER.institution; - document.getElementById("colorblind_mode").checked = CURRENT_USER.colorblind_mode; - document.getElementById("want_updates").checked = CURRENT_USER.updates_wanted; - -}; \ No newline at end of file diff --git a/www/multigene_curator.html b/www/multigene_curator.html index cfb3fa69..3d542e64 100644 --- a/www/multigene_curator.html +++ b/www/multigene_curator.html @@ -38,7 +38,7 @@ - + @@ -500,7 +500,7 @@

- + diff --git a/www/user_profile.html b/www/user_profile.html index 8bdcf9d6..e446b4b2 100644 --- a/www/user_profile.html +++ b/www/user_profile.html @@ -28,7 +28,7 @@ - + - +