From 42735362629b80d29e9d96deb5d2f45a007647d2 Mon Sep 17 00:00:00 2001 From: asizemore Date: Tue, 3 Dec 2024 18:24:52 -0500 Subject: [PATCH] added differential expression --- .../plugins/differentialExpression.tsx | 485 ++++++++++++++++++ .../components/computations/plugins/index.ts | 2 + 2 files changed, 487 insertions(+) create mode 100644 packages/libs/eda/src/lib/core/components/computations/plugins/differentialExpression.tsx diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/differentialExpression.tsx b/packages/libs/eda/src/lib/core/components/computations/plugins/differentialExpression.tsx new file mode 100644 index 0000000000..cd23befce1 --- /dev/null +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/differentialExpression.tsx @@ -0,0 +1,485 @@ +import { + ContinuousVariableDataShape, + LabeledRange, + usePromise, + useStudyMetadata, +} from '../../..'; +import { + VariableDescriptor, + VariableCollectionDescriptor, +} from '../../../types/variable'; +import { volcanoPlotVisualization } from '../../visualizations/implementations/VolcanoPlotVisualization'; +import { ComputationConfigProps, ComputationPlugin } from '../Types'; +import { partial } from 'lodash'; +import { + useConfigChangeHandler, + assertComputationWithConfig, + partialToCompleteCodec, +} from '../Utils'; +import * as t from 'io-ts'; +import { Computation } from '../../../types/visualization'; +import { + useDataClient, + useFindEntityAndVariable, + useFindEntityAndVariableCollection, +} from '../../../hooks/workspace'; +import { ReactNode, useCallback, useMemo } from 'react'; +import { ComputationStepContainer } from '../ComputationStepContainer'; +import VariableTreeDropdown from '../../variableSelectors/VariableTreeDropdown'; +import { ValuePicker } from '../../visualizations/implementations/ValuePicker'; +import { useToggleStarredVariable } from '../../../hooks/starredVariables'; +import { Filter } from '../../..'; +import { FloatingButton, H6 } from '@veupathdb/coreui'; +import { SwapHorizOutlined } from '@material-ui/icons'; +import './Plugins.scss'; +import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; +import { Tooltip } from '@veupathdb/coreui'; +import { + GetBinRangesProps, + getBinRanges, +} from '../../../../map/analysis/utils/defaultOverlayConfig'; +import { VariableCollectionSelectList } from '../../variableSelectors/VariableCollectionSingleSelect'; +import { IsEnabledInPickerParams } from '../../visualizations/VisualizationTypes'; +import { entityTreeToArray } from '../../../utils/study-metadata'; + +const cx = makeClassNameHelper('AppStepConfigurationContainer'); + +/** + * Differential Expression + * + * The differential expression app is used to find genes that + * are more abundant in one group of samples than another. This app takes in a count data variable + * collection (for example, RNA-Seq count for 20,000 genes for all samples) as well as a way to split the samples + * into two groups (for example, red hair and green hair). The computation then returns information on how + * differentially expression each gene is between the two groups. See VolcanoPlotDataPoint + * for more details. Importantly, the returned data lives outside the variable tree because each returned + * data point corresponds to a gene. + * + * Currently the differential expression app will be implemented with only a volcano visualization. Plans for + * the future of this app include a lefse diagram, tables of results, adding new computation methods, and + * strategies to create user-defined collections from the output of the computation. + */ + +export type DifferentialExpressionConfig = t.TypeOf< + typeof DifferentialExpressionConfig +>; + +const Comparator = t.intersection([ + t.partial({ + groupA: t.array(LabeledRange), + groupB: t.array(LabeledRange), + }), + t.type({ + variable: VariableDescriptor, + }), +]); + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const DifferentialExpressionConfig = t.partial({ + collectionVariable: VariableCollectionDescriptor, + comparator: Comparator, + differentialExpressionMethod: t.string, + pValueFloor: t.string, +}); + +const CompleteDifferentialExpressionConfig = partialToCompleteCodec( + DifferentialExpressionConfig +); + +// Check to ensure the entirety of the configuration is filled out before enabling the +// Generate Results button. +function isCompleteDifferentialExpressionConfig(config: unknown) { + return ( + CompleteDifferentialExpressionConfig.is(config) && + config.comparator.groupA != null && + config.comparator.groupB != null + ); +} + +export const plugin: ComputationPlugin = { + configurationComponent: DifferentialExpressionConfiguration, + configurationDescriptionComponent: + DifferentialExpressionConfigDescriptionComponent, + createDefaultConfiguration: () => ({}), + isConfigurationComplete: isCompleteDifferentialExpressionConfig, + visualizationPlugins: { + volcanoplot: volcanoPlotVisualization.withOptions({ + getPlotSubtitle(config) { + if ( + DifferentialExpressionConfig.is(config) && + config.differentialExpressionMethod && + config.differentialExpressionMethod in + DIFFERENTIAL_EXPRESSION_METHOD_CITATIONS + ) { + return ( + + Differential expression computed using{' '} + {config.differentialExpressionMethod}{' '} + { + DIFFERENTIAL_EXPRESSION_METHOD_CITATIONS[ + config.differentialExpressionMethod as keyof typeof DIFFERENTIAL_EXPRESSION_METHOD_CITATIONS + ] + }{' '} + with default parameters. + + ); + } + }, + }), // Must match name in data service and in visualization.tsx + }, + isEnabledInPicker: isEnabledInPicker, + studyRequirements: + 'These visualizations are only available for studies with compatible assay data.', +}; + +function DifferentialExpressionConfigDescriptionComponent({ + computation, + filters, +}: { + computation: Computation; + filters: Filter[]; +}) { + const findEntityAndVariableCollection = useFindEntityAndVariableCollection(); + assertComputationWithConfig(computation, DifferentialExpressionConfig); + const findEntityAndVariable = useFindEntityAndVariable(filters); + const { configuration } = computation.descriptor; + const collectionVariable = + 'collectionVariable' in configuration + ? configuration.collectionVariable + : undefined; + const comparatorVariable = configuration.comparator + ? findEntityAndVariable(configuration.comparator.variable) + : undefined; + + const entityAndCollectionVariableTreeNode = + findEntityAndVariableCollection(collectionVariable); + + return ( +
+

+ Data:{' '} + + {entityAndCollectionVariableTreeNode ? ( + `${entityAndCollectionVariableTreeNode.entity.displayName} > ${entityAndCollectionVariableTreeNode.variableCollection.displayName}` + ) : ( + Not selected + )} + +

+

+ Comparator Variable:{' '} + + {comparatorVariable ? ( + comparatorVariable.variable.displayName + ) : ( + Not selected + )} + +

+
+ ); +} + +// Include available methods in this array. +// 10/10/23 - decided to only release Maaslin for the first roll-out. DESeq is still available +// and we're poised to release it in the future. +type DifferentialExpressionMethodCitations = { DESeq: ReactNode }; +const DIFFERENTIAL_EXPRESSION_METHOD_CITATIONS: DifferentialExpressionMethodCitations = + { + DESeq: ( + + (Love et al., 2014) + + ), + }; // + deseq paper in the future + +const DIFFERENTIAL_EXPRESSION_METHODS = Object.keys( + DIFFERENTIAL_EXPRESSION_METHOD_CITATIONS +); // + 'DESeq' in the future + +export function DifferentialExpressionConfiguration( + props: ComputationConfigProps +) { + const { + computationAppOverview, + computation, + analysisState, + visualizationId, + } = props; + + const configuration = computation.descriptor + .configuration as DifferentialExpressionConfig; + const studyMetadata = useStudyMetadata(); + const dataClient = useDataClient(); + const toggleStarredVariable = useToggleStarredVariable(props.analysisState); + const filters = analysisState.analysis?.descriptor.subset.descriptor; + const findEntityAndVariable = useFindEntityAndVariable(filters); + + assertComputationWithConfig(computation, DifferentialExpressionConfig); + + const changeConfigHandler = useConfigChangeHandler( + analysisState, + computation, + visualizationId + ); + + // Set the pValueFloor here. May change for other apps. + // Note this is intentionally different than the default pValueFloor used in the Volcano component. By default + // that component does not floor the data, but we know we want the diff abund computation to use a floor. + if (configuration && !configuration.pValueFloor) { + changeConfigHandler('pValueFloor', '1e-200'); + } + + // Only releasing Maaslin for b66 + if (configuration && !configuration.differentialExpressionMethod) { + changeConfigHandler( + 'differentialExpressionMethod', + DIFFERENTIAL_EXPRESSION_METHODS[0] + ); + } + + const selectedComparatorVariable = useMemo(() => { + if ( + configuration && + configuration.comparator && + 'variable' in configuration.comparator + ) { + return findEntityAndVariable(configuration.comparator.variable); + } + }, [configuration, findEntityAndVariable]); + + // If the variable is continuous, ask the backend for a list of bins + const continuousVariableBins = usePromise( + useCallback(async () => { + if ( + !ContinuousVariableDataShape.is( + selectedComparatorVariable?.variable.dataShape + ) || + configuration.comparator == null + ) + return; + + const binRangeProps: GetBinRangesProps = { + studyId: studyMetadata.id, + ...configuration.comparator.variable, + filters: filters ?? [], + dataClient, + binningMethod: 'quantile', + }; + const bins = await getBinRanges(binRangeProps); + return bins; + }, [ + dataClient, + configuration?.comparator, + filters, + selectedComparatorVariable, + studyMetadata.id, + ]) + ); + + const disableSwapGroupValuesButton = + !configuration?.comparator?.groupA && !configuration?.comparator?.groupB; + const disableGroupValueSelectors = !configuration?.comparator?.variable; + + // Create the options for groupA and groupB. Organizing into the LabeledRange[] format + // here in order to keep the later code clean. + const groupValueOptions = continuousVariableBins.value + ? continuousVariableBins.value.map((bin): LabeledRange => { + return { + min: bin.binStart, + max: bin.binEnd, + label: bin.binLabel, + }; + }) + : selectedComparatorVariable?.variable.vocabulary?.map( + (value): LabeledRange => { + return { + label: value, + }; + } + ); + + return ( + +
+
+
Input Data
+
+ Data + true} + /> +
+
+
+
Group Comparison
+
+
+ Variable + { + changeConfigHandler('comparator', { + variable: variable as VariableDescriptor, + }); + }, + }} + /> +
+ +
+ Group A + option.label) + : undefined + } + selectedValues={configuration.comparator?.groupA?.map( + (entry) => entry.label + )} + disabledValues={configuration.comparator?.groupB?.map( + (entry) => entry.label + )} + onSelectedValuesChange={(newValues) => { + assertConfigWithComparator(configuration); + changeConfigHandler('comparator', { + variable: configuration.comparator.variable, + groupA: newValues.length + ? groupValueOptions?.filter((option) => + newValues.includes(option.label) + ) + : undefined, + groupB: configuration.comparator.groupB ?? undefined, + }); + }} + disabledCheckboxTooltipContent="Values cannot overlap between groups" + showClearSelectionButton={false} + disableInput={disableGroupValueSelectors} + isLoading={continuousVariableBins.pending} + /> + { + assertConfigWithComparator(configuration); + changeConfigHandler('comparator', { + variable: + configuration?.comparator?.variable ?? undefined, + groupA: configuration?.comparator?.groupB ?? undefined, + groupB: configuration?.comparator?.groupA ?? undefined, + }); + }} + styleOverrides={{ + container: { + padding: 0, + margin: '0 5px', + }, + }} + disabled={ + disableGroupValueSelectors || disableSwapGroupValuesButton + } + /** + * For some reason the tooltip content renders when the parent container is in the disabled state. + * To prevent such ghastly behavior, let's not pass in the tooltip prop when the parent is disabled. + */ + {...(!disableGroupValueSelectors + ? { tooltip: 'Swap Group A and Group B values' } + : {})} + /> + Group B + option.label) + : undefined + } + selectedValues={configuration?.comparator?.groupB?.map( + (entry) => entry.label + )} + disabledValues={configuration?.comparator?.groupA?.map( + (entry) => entry.label + )} + onSelectedValuesChange={(newValues) => { + assertConfigWithComparator(configuration); + changeConfigHandler('comparator', { + variable: + configuration?.comparator?.variable ?? undefined, + groupA: configuration?.comparator?.groupA ?? undefined, + groupB: newValues.length + ? groupValueOptions?.filter((option) => + newValues.includes(option.label) + ) + : undefined, + }); + }} + disabledCheckboxTooltipContent="Values cannot overlap between groups" + showClearSelectionButton={false} + disableInput={disableGroupValueSelectors} + isLoading={continuousVariableBins.pending} + /> +
+
+
+
+
+
+ ); +} + +function assertConfigWithComparator( + configuration: DifferentialExpressionConfig +): asserts configuration is Required { + if (configuration.comparator == null) { + throw new Error( + 'Unexpected condition: `configuration.comparator.variable` is not defined.' + ); + } +} + +// Differential expression requires that the study +// has at least one collection. +function isEnabledInPicker({ + studyMetadata, +}: IsEnabledInPickerParams): boolean { + if (!studyMetadata) return false; + + const entities = entityTreeToArray(studyMetadata.rootEntity); + // Ensure there are collections in this study. Otherwise, disable app + const studyHasCollections = entities.some( + (entity) => !!entity.collections?.length + ); + + return studyHasCollections; +} diff --git a/packages/libs/eda/src/lib/core/components/computations/plugins/index.ts b/packages/libs/eda/src/lib/core/components/computations/plugins/index.ts index 58053638d3..0c52dd245c 100644 --- a/packages/libs/eda/src/lib/core/components/computations/plugins/index.ts +++ b/packages/libs/eda/src/lib/core/components/computations/plugins/index.ts @@ -6,6 +6,7 @@ import { plugin as distributions } from './distributions'; import { plugin as countsandproportions } from './countsAndProportions'; import { plugin as abundance } from './abundance'; import { plugin as differentialabundance } from './differentialabundance'; +import { plugin as differentialexpression } from './differentialExpression'; import { plugin as correlationassaymetadata } from './correlationAssayMetadata'; // mbio import { plugin as correlationassayassay } from './correlationAssayAssay'; // mbio import { plugin as correlation } from './correlation'; // genomics (- vb) @@ -16,6 +17,7 @@ export const plugins: Record = { alphadiv, betadiv, differentialabundance, + differentialexpression, correlationassaymetadata, correlationassayassay, correlation,