From a305753231b60cf9f94879b09917619616df26fd Mon Sep 17 00:00:00 2001 From: Ali Mourey <90707472+alimourey@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:14:27 -0400 Subject: [PATCH 01/67] WIP --- .../@depmap/api/src/legacyPortalAPI/index.ts | 2 + .../resources/experimental_genetea.ts | 134 ++++++++++ .../portal-frontend/src/apps/geneTea.tsx | 19 ++ .../src/geneTea/components/GeneTea.tsx | 34 +++ .../geneTea/components/GeneTeaMainContent.tsx | 123 ++++++++++ .../src/geneTea/components/GeneTeaTable.tsx | 1 + .../src/geneTea/components/PlotSection.tsx | 187 ++++++++++++++ .../src/geneTea/components/PlotSelections.tsx | 88 +++++++ .../components/SearchOptionsContainer.tsx | 146 +++++++++++ .../components/SearchOptionsListTab.tsx | 65 +++++ .../src/geneTea/styles/GeneTea.scss | 230 ++++++++++++++++++ .../portal-frontend/webpack.common.js | 1 + portal-backend/depmap/app.py | 2 + portal-backend/depmap/gene_tea/__init__.py | 0 portal-backend/depmap/gene_tea/views.py | 19 ++ portal-backend/depmap/settings/settings.py | 8 + .../depmap/templates/gene_tea/index.html | 22 ++ .../nav_footer/common_nav_elements.html | 3 + 18 files changed, 1084 insertions(+) create mode 100644 frontend/packages/@depmap/api/src/legacyPortalAPI/resources/experimental_genetea.ts create mode 100644 frontend/packages/portal-frontend/src/apps/geneTea.tsx create mode 100644 frontend/packages/portal-frontend/src/geneTea/components/GeneTea.tsx create mode 100644 frontend/packages/portal-frontend/src/geneTea/components/GeneTeaMainContent.tsx create mode 100644 frontend/packages/portal-frontend/src/geneTea/components/GeneTeaTable.tsx create mode 100644 frontend/packages/portal-frontend/src/geneTea/components/PlotSection.tsx create mode 100644 frontend/packages/portal-frontend/src/geneTea/components/PlotSelections.tsx create mode 100644 frontend/packages/portal-frontend/src/geneTea/components/SearchOptionsContainer.tsx create mode 100644 frontend/packages/portal-frontend/src/geneTea/components/SearchOptionsListTab.tsx create mode 100644 frontend/packages/portal-frontend/src/geneTea/styles/GeneTea.scss create mode 100644 portal-backend/depmap/gene_tea/__init__.py create mode 100644 portal-backend/depmap/gene_tea/views.py create mode 100644 portal-backend/depmap/templates/gene_tea/index.html diff --git a/frontend/packages/@depmap/api/src/legacyPortalAPI/index.ts b/frontend/packages/@depmap/api/src/legacyPortalAPI/index.ts index f34ba2a9d..058e3b6e8 100644 --- a/frontend/packages/@depmap/api/src/legacyPortalAPI/index.ts +++ b/frontend/packages/@depmap/api/src/legacyPortalAPI/index.ts @@ -7,6 +7,7 @@ import * as data_page from "./resources/data_page"; import * as download from "./resources/download"; import * as entity_summary from "./resources/entity_summary"; import * as genetea from "./resources/genetea"; +import * as experimental_genetea from "./resources/experimental_genetea"; import * as interactive from "./resources/interactive"; import * as tda from "./resources/tda"; import * as misc from "./resources/misc"; @@ -24,6 +25,7 @@ export const legacyPortalAPI = { ...interactive, ...tda, ...misc, + ...experimental_genetea, }; type Api = typeof legacyPortalAPI; diff --git a/frontend/packages/@depmap/api/src/legacyPortalAPI/resources/experimental_genetea.ts b/frontend/packages/@depmap/api/src/legacyPortalAPI/resources/experimental_genetea.ts new file mode 100644 index 000000000..a2ed77738 --- /dev/null +++ b/frontend/packages/@depmap/api/src/legacyPortalAPI/resources/experimental_genetea.ts @@ -0,0 +1,134 @@ +import qs from "qs"; +import { enabledFeatures } from "@depmap/globals"; +import { getJson } from "../client"; + +///// +///// ❌ ❌ ❌ WARNING: THIS IS EXPERIMENTAL AND WILL LIKELY CHANGE ❌ ❌ ❌ +///// DO NOT USE THIS FOR ANYTHING OTHER THAN THE NEW GENETEA TEA PARTY PAGE!!!!! +///// +///// + +// Do not use in production! For local development only. +const toCorsProxyUrl = (geneTeaUrl: string, params: object) => { + const query = qs.stringify(params, { arrayFormat: "repeat" }); + const url = `https://cds.team/${geneTeaUrl}/?${query}`; + return "https://corsproxy.io/?" + encodeURIComponent(url); +}; + +export async function fetchGeneTeaEnrichmentExperimental( + genes: string[], + limit: number | null +): Promise<{ + term: string[]; + synonyms: string[][]; + coincident: string[][]; + fdr: number[]; + matchingGenes: string[][]; + total: number; +}> { + if (!enabledFeatures.gene_tea) { + throw new Error("GeneTea is not supported in this environment!"); + } + + const geneTeaUrl = "genetea-api/enriched-terms"; + + const params = { + gene_list: genes, + remove_overlapping: "true", + n: limit || -1, + model: "v2", + }; + + interface RawResponse { + // TODO: Give the user feedback when some genes are invalid. + invalid_genes: string[]; + total_n_enriched_terms: number; + enriched_terms: { + Term: string[]; + // semicolon separated strings + Synonyms: (string | null)[]; + // semicolon separated strings + "Coincident Terms": (string | null)[]; + FDR: number[]; + // Gene lists are just space-separated strings like "ADSL CAD UMPS" + "Matching Genes in List": string[]; + }; + } + + const body = + process.env.NODE_ENV === "development" + ? await getJson( + toCorsProxyUrl(geneTeaUrl, params), + undefined, + { credentials: "omit" } + ) + : await getJson(`/../../${geneTeaUrl}/`, params); + + // `enriched_terms` can be null when there are no relevant terms. We'll + // return a wrapper object to distinguish this from some kind of error. + if (body.enriched_terms === null) { + return { + term: [], + synonyms: [], + coincident: [], + fdr: [], + matchingGenes: [], + total: 0, + }; + } + + const et = body.enriched_terms; + + return { + term: et.Term, + fdr: et.FDR, + total: body.total_n_enriched_terms, + synonyms: et.Synonyms.map((list) => list?.split(";") || []), + coincident: et["Coincident Terms"].map((list) => list?.split(";") || []), + matchingGenes: et["Matching Genes in List"].map((geneList) => { + return geneList.split(" "); + }), + }; +} + +export async function fetchGeneTeaTermContextExperimental( + term: string, + genes: string[] +): Promise> { + if (!enabledFeatures.gene_tea) { + throw new Error("GeneTea is not supported in this environment!"); + } + + const geneTeaUrl = "genetea-api/context"; + + const params = { + term, + gene_list: genes, + model: "v2", + html: true, + }; + + type RawResponse = + | { + valid_genes: string[]; + invalid_genes: string[]; + remapped_genes: Record; + context: Record; + } + | { message: string }; // error message + + const body = + process.env.NODE_ENV === "development" + ? await getJson( + toCorsProxyUrl(geneTeaUrl, params), + undefined, + { credentials: "omit" } + ) + : await getJson(`/../../${geneTeaUrl}/`, params); + + if ("message" in body) { + throw new Error(body.message); + } + + return body.context; +} diff --git a/frontend/packages/portal-frontend/src/apps/geneTea.tsx b/frontend/packages/portal-frontend/src/apps/geneTea.tsx new file mode 100644 index 000000000..b860e5ad5 --- /dev/null +++ b/frontend/packages/portal-frontend/src/apps/geneTea.tsx @@ -0,0 +1,19 @@ +import "src/public-path"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import ErrorBoundary from "src/common/components/ErrorBoundary"; +import "react-bootstrap-typeahead/css/Typeahead.css"; +import "src/common/styles/typeahead_fix.scss"; +import GeneTea from "src/geneTea/components/GeneTea"; + +const container = document.getElementById("react-gene-tea"); + +const App = () => { + return ( + + + + ); +}; + +ReactDOM.render(, container); diff --git a/frontend/packages/portal-frontend/src/geneTea/components/GeneTea.tsx b/frontend/packages/portal-frontend/src/geneTea/components/GeneTea.tsx new file mode 100644 index 000000000..ec481c54d --- /dev/null +++ b/frontend/packages/portal-frontend/src/geneTea/components/GeneTea.tsx @@ -0,0 +1,34 @@ +import React, { useState } from "react"; +import GeneTeaMainContent from "./GeneTeaMainContent"; +import "react-bootstrap-typeahead/css/Typeahead.css"; +import "src/common/styles/typeahead_fix.scss"; +import styles from "../styles/GeneTea.scss"; +import SearchOptions from "./SearchOptionsContainer"; + +function GeneTea() { + const [searchTerms, setSearchTerms] = useState>( + new Set(["CAD", "UMPS", "ADSL", "DHODH"]) + ); + + const [doGroupTerms, setDoGroupTerms] = useState(true); + const [doClusterGenes, setDoClusterGenes] = useState(true); + const [doClusterTerms, setDoClusterTerms] = useState(true); + + return ( +
+
+ +
+
+ +
+
+ ); +} + +export default GeneTea; diff --git a/frontend/packages/portal-frontend/src/geneTea/components/GeneTeaMainContent.tsx b/frontend/packages/portal-frontend/src/geneTea/components/GeneTeaMainContent.tsx new file mode 100644 index 000000000..f8ff9ebfe --- /dev/null +++ b/frontend/packages/portal-frontend/src/geneTea/components/GeneTeaMainContent.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from "react"; +// import ExtendedPlotType from "src/plot/models/ExtendedPlotType"; +import { cached, legacyPortalAPI, LegacyPortalApiResponse } from "@depmap/api"; +import styles from "../styles/GeneTea.scss"; +// import PlotSelections from "./PlotSelections"; +// import PlotSection from "./PlotSection"; + +// TODO: picked these numbers at random. Figure out what they should actually be. +const MIN_SELECTION = 3; +const MAX_SELECTION = 300; // TODO: The API will error at a certain number. Make sure this doesn't exceed that number. +interface GeneTeaMainContentProps { + searchTerms: Set; + doGroupTerms: boolean; + doClusterGenes: boolean; + doClusterTerms: boolean; +} + +type GeneTeaEnrichedTerms = LegacyPortalApiResponse["fetchGeneTeaEnrichmentExperimental"]; + +function GeneTeaMainContent({ + searchTerms, + doGroupTerms, + doClusterGenes, + doClusterTerms, +}: GeneTeaMainContentProps) { + const [isLoading, setIsLoading] = useState(false); + const [data, setData] = useState(null); + const [error, setError] = useState(false); + useEffect(() => { + if ( + searchTerms && + searchTerms.size >= MIN_SELECTION && + searchTerms.size <= MAX_SELECTION + ) { + setIsLoading(true); + setError(false); + + (async () => { + try { + const fetchedData = await cached( + legacyPortalAPI + ).fetchGeneTeaEnrichmentExperimental([...searchTerms], null); + setData(fetchedData); + } catch (e) { + setError(true); + window.console.error(e); + } finally { + setIsLoading(false); + } + })(); + } + }, [searchTerms]); + console.log("search terms", searchTerms); + console.log("data", data); + + return ( +
+
+

Top Tea Terms

+
+ {false ? ( +
Error loading plot data.
+ ) : ( + <> +
+
+ {/* */} +
+
+ {/* */} +
+
{" "} + + )} +
+
+

Enrichment Term Table

+

Terms selected in the plot will appear checked in this table.

+
+
+ {/* */} +
+
+ ); +} + +export default GeneTeaMainContent; diff --git a/frontend/packages/portal-frontend/src/geneTea/components/GeneTeaTable.tsx b/frontend/packages/portal-frontend/src/geneTea/components/GeneTeaTable.tsx new file mode 100644 index 000000000..f5403f75c --- /dev/null +++ b/frontend/packages/portal-frontend/src/geneTea/components/GeneTeaTable.tsx @@ -0,0 +1 @@ +import styles from "../styles/GeneTea.scss"; diff --git a/frontend/packages/portal-frontend/src/geneTea/components/PlotSection.tsx b/frontend/packages/portal-frontend/src/geneTea/components/PlotSection.tsx new file mode 100644 index 000000000..860141928 --- /dev/null +++ b/frontend/packages/portal-frontend/src/geneTea/components/PlotSection.tsx @@ -0,0 +1,187 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React, { useMemo } from "react"; +import PlotControls, { + PlotToolOptions, +} from "src/plot/components/PlotControls"; +import PlotSpinner from "src/plot/components/PlotSpinner"; +import ExtendedPlotType from "src/plot/models/ExtendedPlotType"; +import styles from "../styles/GeneTea.scss"; + +interface PlotSectionProps { + isLoading: boolean; + showUnselectedLines: boolean; + compoundName: string; + plotElement: ExtendedPlotType | null; + heatmapFormattedData: HeatmapFormattedData | null; + doseMin: number | null; + doseMax: number | null; + doseUnits: string; + selectedModelIds: Set; + handleSetSelectedPlotModels: ( + selections: Set, + shiftKey: boolean + ) => void; + handleSetPlotElement: (element: ExtendedPlotType | null) => void; + handleClearSelection: () => void; + displayNameModelIdMap: Map; + visibleZIndexes: number[]; +} + +function PlotSection({ + isLoading, + compoundName, + heatmapFormattedData, + doseMin, + doseMax, + selectedModelIds, + handleSetSelectedPlotModels, + handleSetPlotElement, + handleClearSelection, + plotElement, + displayNameModelIdMap, + visibleZIndexes, + doseUnits, + showUnselectedLines, +}: PlotSectionProps) { + // Sort data by ascending mean viability + const sortedHeatmapFormattedData = useMemo( + () => sortHeatmapByViability(heatmapFormattedData), + [heatmapFormattedData] + ); + + // Keep track of visible column indexes so that columns can be turned on/off when specific + // cell lines are selected, and the user has toggled "Show unselected lines" to OFF. + const visibleSortedModelIdIndices = useMemo( + () => + getVisibleSortedModelIdIndices( + sortedHeatmapFormattedData, + selectedModelIds, + showUnselectedLines + ), + [sortedHeatmapFormattedData, selectedModelIds, showUnselectedLines] + ); + + // Mask Heatmap data that is not visible using the visibleSortedModelIdIndices to determine masked columns + // and visibleZIndexes to further determine which z indexes should be masked due to Filter by Dose. + const maskedHeatmapData = useMemo( + () => + maskHeatmapData( + sortedHeatmapFormattedData, + visibleSortedModelIdIndices, + visibleZIndexes + ), + [sortedHeatmapFormattedData, visibleSortedModelIdIndices, visibleZIndexes] + ); + const selectedColumns = useMemo( + () => getSelectedColumns(maskedHeatmapData, selectedModelIds), + [selectedModelIds, maskedHeatmapData] + ); + const searchOptions = useMemo( + () => getSearchOptions(maskedHeatmapData, displayNameModelIdMap), + [maskedHeatmapData, displayNameModelIdMap] + ); + + // This is somewhat of a hack for the purpose of hiding the tooltips of masked Heatmap columns. + const customdata = useMemo(() => getCustomData(maskedHeatmapData), [ + maskedHeatmapData, + ]); + + const handleSearch = (selection: { + label: string; + value: number; + stringId?: string; + }) => { + if (selection.stringId) { + // The shiftKey parameter is false because you cannot hold down the shift key and search to add + // to your selection. + const shiftKey = false; + handleSetSelectedPlotModels(new Set([selection.stringId]), shiftKey); + } + }; + + const handleSelectColumnRange = ( + start: number, + end: number, + shiftKey: boolean + ) => { + // Get a set of data from the click or click and drag column selection + // ignoring any columns that are masked due to filter on dose. + const newlySelected = new Set(); + for (let i = start; i <= end; i += 1) { + if (maskedHeatmapData && maskedHeatmapData.modelIds[i]) { + newlySelected.add(maskedHeatmapData.modelIds[i]!); + } + } + handleSetSelectedPlotModels(newlySelected, shiftKey); + }; + + // HACK: so that Plotly will resize the plot when the user switches to this tab. + // Without this hack, if the plot loads while this tab is inactive, Plotly does not + // properly calculate plot size, and this can cause the plot to drastically overflow its bounds. + const [key, setKey] = React.useState(0); + + React.useEffect(() => { + const handler = () => setKey((k) => k + 1); + window.addEventListener("changeTab:heatmap", handler); + return () => window.removeEventListener("changeTab:heatmap", handler); + }, []); + + return ( +
+
+ {plotElement && ( + {}} + zoomToSelectedSelections={selectedColumns} + altContainerStyle={{ backgroundColor: "#7B8CB2" }} + hideCSVDownload + /> + )} +
+
+ {(!plotElement || isLoading) && ( +
+ +
+ )} + {maskedHeatmapData && doseMin && doseMax && !isLoading && ( +
+ handleClearSelection()} + onSelectColumnRange={handleSelectColumnRange} + /> +
+ )} +
+
+ ); +} + +export default React.memo(PlotSection); diff --git a/frontend/packages/portal-frontend/src/geneTea/components/PlotSelections.tsx b/frontend/packages/portal-frontend/src/geneTea/components/PlotSelections.tsx new file mode 100644 index 000000000..b77ddb091 --- /dev/null +++ b/frontend/packages/portal-frontend/src/geneTea/components/PlotSelections.tsx @@ -0,0 +1,88 @@ +import React, { useRef } from "react"; +import { Button } from "react-bootstrap"; +import styles from "@depmap/data-explorer-2/src/components/DataExplorerPage/styles/DataExplorer2.scss"; +import LabelsVirtualList from "@depmap/data-explorer-2/src/components/DataExplorerPage/components/plot/PlotSelections/LabelsVirtualList"; +import geneTeaStyles from "../styles/GeneTea.scss"; + +interface PlotSelectionsProps { + selectedIds: Set | null; + selectedLabels: Set | null; + onClickSaveSelectionAsContext: () => void; + onClickClearSelection?: () => void; + onClickSetSelectionFromContext?: () => void; +} + +function PlotSelections({ + selectedIds, + selectedLabels, + onClickSaveSelectionAsContext, + onClickClearSelection = undefined, + onClickSetSelectionFromContext = undefined, +}: PlotSelectionsProps) { + const listRef = useRef(null); + + const maxHeightOfList = 400; + + return ( +
+
Plot Selections
+
+
+
Select cell lines to populate list
+ {onClickClearSelection && selectedLabels && selectedLabels.size > 0 && ( +
+ +
+ )} + {onClickSetSelectionFromContext && + selectedLabels && + selectedLabels.size === 0 && ( +
+ or{" "} + +
+ )} +
+
+
+ +
+
+ +
+
+
+
+ ); +} + +export default PlotSelections; diff --git a/frontend/packages/portal-frontend/src/geneTea/components/SearchOptionsContainer.tsx b/frontend/packages/portal-frontend/src/geneTea/components/SearchOptionsContainer.tsx new file mode 100644 index 000000000..d00321e8e --- /dev/null +++ b/frontend/packages/portal-frontend/src/geneTea/components/SearchOptionsContainer.tsx @@ -0,0 +1,146 @@ +import { ToggleSwitch } from "@depmap/common-components"; +import React, { useState } from "react"; +import Select from "react-select"; +import styles from "../styles/GeneTea.scss"; + +const MultiSelectTextarea = () => { + const [inputValue, setInputValue] = useState(""); + const [chips, setChips] = useState([]); + + const handleInputChange = (e: any) => { + setInputValue(e.target.value); + }; + + const handleKeyDown = (e: any) => { + if (e.key === "Enter" && inputValue.trim() !== "") { + e.preventDefault(); // Prevent newline in textarea + const newItems = inputValue + .split(/[, ]+/) + .filter((item) => item.trim() !== ""); + setChips((prevChips) => [...new Set([...prevChips, ...newItems])]); // Add only unique items + setInputValue(""); // Clear input + } + }; + + const handleRemoveChip = (chipToRemove: string) => { + setChips((prevChips) => prevChips.filter((chip) => chip !== chipToRemove)); + }; + + return ( +
+
+
+ {chips.map((chip, index) => ( + + {chip} + + + ))} +
+