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,