import * as React from 'react';
import { observer, Observer } from 'mobx-react';
import autobind from 'autobind-decorator';
import LoadingIndicator from 'shared/components/loadingIndicator/LoadingIndicator';
import {
action,
autorun,
computed,
IReactionDisposer,
makeObservable,
observable,
} from 'mobx';
import { SimpleGetterLazyMobXTableApplicationDataStore } from 'shared/lib/ILazyMobXTableApplicationDataStore';
import ClinicalDataEnrichmentsTable from './ClinicalDataEnrichmentsTable';
import _ from 'lodash';
import { DownloadControls, remoteData } from 'cbioportal-frontend-commons';
import { getRemoteDataGroupStatus } from 'cbioportal-utils';
import client from 'shared/api/cbioportalClientInstance';
import {
basicAppearance,
boxPlotTooltip,
IAxisData,
IAxisLogScaleParams,
IBoxScatterPlotPoint,
INumberAxisData,
isNumberData,
isStringData,
IStringAxisData,
makeBoxScatterPlotData,
} from 'pages/resultsView/plots/PlotsTabUtils';
import ScrollBar from 'shared/components/Scrollbar/ScrollBar';
import { IBoxScatterPlotData } from 'shared/components/plots/BoxScatterPlot';
import { scatterPlotSize } from 'shared/components/plots/PlotUtils';
import {
CLINICAL_TAB_NOT_ENOUGH_GROUPS_MSG,
ClinicalDataEnrichmentWithQ,
getStatisticalCautionInfo,
} from './GroupComparisonUtils';
import ReactSelect from 'react-select1';
import { MakeMobxView } from 'shared/components/MobxView';
import OverlapExclusionIndicator from './OverlapExclusionIndicator';
import { RESERVED_CLINICAL_VALUE_COLORS } from '../../shared/lib/Colors';
import ErrorMessage from '../../shared/components/ErrorMessage';
import ComplexKeyMap from 'shared/lib/complexKeyDataStructures/ComplexKeyMap';
import { Sample } from 'cbioportal-ts-api-client';
import ComparisonStore, {
OverlapStrategy,
} from '../../shared/lib/comparison/ComparisonStore';
import { createSurvivalAttributeIdsDict } from 'pages/resultsView/survival/SurvivalUtil';
import {
filterSampleList,
getComparisonCategoricalNaValue,
} from './ClinicalDataUtils';
import {
ClinicalNumericalDataVisualisation,
ClinicalNumericalVisualisationType,
} from 'pages/groupComparison/ClinicalNumericalDataVisualisation';
import { SummaryStatisticsTable } from './SummaryStatisticsTable';
import CategoryPlot, {
CategoryPlotType,
} from 'pages/groupComparison/CategoryPlot';
import { OncoprintJS } from 'oncoprintjs';
export interface IClinicalDataProps {
store: ComparisonStore;
}
const SVG_ID = 'clinical-plot-svg';
export const numericalVisualisationTypeOptions = [
{ value: ClinicalNumericalVisualisationType.Plot, label: 'Plot' },
{ value: ClinicalNumericalVisualisationType.Table, label: 'Table' },
];
export class ClinicalDataEnrichmentStore extends SimpleGetterLazyMobXTableApplicationDataStore<
ClinicalDataEnrichmentWithQ
> {
private reactionDisposer: IReactionDisposer;
constructor(
getData: () => ClinicalDataEnrichmentWithQ[],
getHighlighted: () => ClinicalDataEnrichmentWithQ | undefined,
public setHighlighted: (c: ClinicalDataEnrichmentWithQ) => void
) {
super(getData);
this.dataHighlighter = (d: ClinicalDataEnrichmentWithQ) => {
const highlighted = getHighlighted();
return !!(
highlighted &&
d.clinicalAttribute.clinicalAttributeId ===
highlighted.clinicalAttribute.clinicalAttributeId
);
};
this.reactionDisposer = autorun(() => {
if (
this.sortMetric &&
this.sortedFilteredData.length > 0 &&
!getHighlighted()
) {
this.setHighlighted(this.sortedFilteredData[0]);
}
});
}
public destroy() {
this.reactionDisposer();
}
}
export const categoryPlotTypeOptions = [
{ value: CategoryPlotType.Bar, label: 'Bar chart' },
{ value: CategoryPlotType.StackedBar, label: 'Stacked bar chart' },
{
value: CategoryPlotType.PercentageStackedBar,
label: '100% stacked bar chart',
},
{ value: CategoryPlotType.Heatmap, label: 'Heatmap' },
];
function isNumerical(datatype?: string) {
return datatype && datatype.toLowerCase() === 'number';
}
@observer
export default class ClinicalData extends React.Component<
IClinicalDataProps,
{}
> {
constructor(props: IClinicalDataProps) {
super(props);
makeObservable(this);
}
@observable.ref highlightedRow: ClinicalDataEnrichmentWithQ | undefined;
private scrollPane: HTMLDivElement;
private oncoprintJs: OncoprintJS | null = null;
@autobind
private oncoprintJsRef(oncoprint: OncoprintJS) {
this.oncoprintJs = oncoprint;
}
readonly tabUI = MakeMobxView({
await: () => {
const ret: any[] = [this.props.store.activeGroups];
if (
this.props.store.activeGroups.isComplete &&
this.props.store.activeGroups.result.length < 2
) {
// dont bother loading data for and computing clinical tab if not enough groups for it
} else {
ret.push(this.overlapUI);
}
return ret;
},
render: () => {
let content: any = [];
if (this.props.store.activeGroups.result!.length < 2) {
content.push(
{CLINICAL_TAB_NOT_ENOUGH_GROUPS_MSG(
this.props.store._activeGroupsNotOverlapRemoved
.result!.length
)}
);
} else {
content.push(
{getStatisticalCautionInfo()}
);
content.push(this.overlapUI.component);
}
return (
{content}
);
},
renderPending: () => (
),
renderError: () => ,
});
readonly overlapUI = MakeMobxView({
await: () => [this.props.store.clinicalDataEnrichmentsWithQValues],
render: () => {
return (
{this.getUtilitiesMenu}
{this.plot}
);
},
renderPending: () => (
),
renderError: () => ,
});
@autobind
private getScrollPane() {
return this.scrollPane;
}
private tableDataStore = new ClinicalDataEnrichmentStore(
() => {
// ClinicalData is only used in Group Comparison Page and Group Comparison Tab
// And survival related attributes are used to create KM plots in survival tab
// We are doing the proper statistical tests in the survival tab and the used tests for the clinical tab are not appropriate for that type of data
// There is no need to still keep them in clinical tab
// So, exclude them from the data store
const survivalAttributeIdsDict = createSurvivalAttributeIdsDict(
this.props.store.survivalClinicalAttributesPrefix.result || []
);
// In addition remove a few more attributes from the comparison page
// for GENIE BPC related to years. In the future we might want to
// have a more elegant way to exclude clinical attributes from
// comparison. See also
// https://github.com/cBioPortal/cbioportal/issues/8445
const excludeGenieHardcodedAttributeIds = {
AGE_LAST_FU_YEARS: 'AGE_LAST_FU_YEARS',
BIRTH_YEAR: 'BIRTH_YEAR',
};
return this.props.store.clinicalDataEnrichmentsWithQValues.result.filter(
d =>
!(
d.clinicalAttribute.clinicalAttributeId in
survivalAttributeIdsDict
) &&
!(
d.clinicalAttribute.clinicalAttributeId in
excludeGenieHardcodedAttributeIds
)
);
},
() => {
return this.highlightedRow;
},
(c: ClinicalDataEnrichmentWithQ) => {
this.highlightedRow = c;
this.swapAxes = false;
this.logScale = false;
}
);
@computed get isNumericalPlot() {
return isNumerical(this.highlightedRow!.clinicalAttribute.datatype);
}
@computed get showLogScaleControls() {
return this.isNumericalPlot;
}
@computed get showPAndQControls() {
return !this.isTable && !this.isHeatmap;
}
@computed get showHorizontalBarControls() {
return !this.showLogScaleControls && !this.isHeatmap;
}
@computed get showSwapAxisControls() {
return !this.isTable;
}
@computed get isTable() {
return (
this.isNumericalPlot &&
this.numericalVisualisationType ===
ClinicalNumericalVisualisationType.Table
);
}
@computed get isHeatmap() {
return (
!this.isNumericalPlot &&
this.categoryPlotType === CategoryPlotType.Heatmap
);
}
@observable private logScale = false;
@observable logScaleFunction: IAxisLogScaleParams | undefined;
@observable swapAxes = false;
@observable showPAndQ = false;
@observable horizontalBars = false;
@observable showNA = true;
@action.bound
private onClickLogScale() {
this.logScale = !this.logScale;
if (this.logScale) {
const MIN_LOG_ARGUMENT = 0.01;
this.logScaleFunction = {
label: 'log2',
fLogScale: (x: number, offset: number) =>
Math.log2(Math.max(x, MIN_LOG_ARGUMENT)),
fInvLogScale: (x: number) => Math.pow(2, x),
};
} else {
this.logScaleFunction = undefined;
}
}
@action.bound
private onClickSwapAxes() {
this.swapAxes = !this.swapAxes;
}
@action.bound
private onClickTogglePAndQ() {
this.showPAndQ = !this.showPAndQ;
}
@action.bound
private onClickHorizontalBars() {
this.horizontalBars = !this.horizontalBars;
}
@action.bound
private onClickShowNA() {
this.showNA = !this.showNA;
}
private readonly clinicalDataPromiseFiltered = remoteData({
await: () => [this.clinicalDataPromise, this.props.store.samples],
invoke: () => {
const axisData = this.clinicalDataPromise.result!;
// for string like values include NA values if requested
// TODO: implement for numbers
// Compare clinical data(axisData) samples with sample list, assign NA to difference
// If include overlapped patients and samples, keep all sample in sample list
// If exclude overlapped patients and samples, remove overlapped samples from sample list
const sampleList: Sample[] =
this.props.store.overlapStrategy === OverlapStrategy.EXCLUDE
? filterSampleList(
this.props.store.samples.result!,
this.props.store._activeGroupsNotOverlapRemoved
.result!
)
: this.props.store.samples.result!;
if (
this.showNA &&
axisData.datatype === 'string' &&
sampleList.length > 0
) {
const naSamples = _.difference(
_.uniq(sampleList.map(x => x.uniqueSampleKey)),
_.uniq(axisData.data.map(x => x.uniqueSampleKey))
);
return Promise.resolve({
...axisData,
data: axisData.data.concat(
naSamples.map(x => ({
uniqueSampleKey: x,
value: 'NA',
}))
),
});
} else {
// filter out NA-like values (e.g. unknown)
return Promise.resolve({
...axisData,
data: axisData.data.filter(
x =>
typeof x.value !== 'string' ||
_.every(
getComparisonCategoricalNaValue(),
naValue =>
naValue.toLowerCase() !==
(x.value as string).toLowerCase()
)
),
});
}
},
});
private readonly clinicalDataPromise = remoteData({
await: () => [
this.props.store.patientKeyToSamples,
this.props.store.activeGroups,
],
invoke: async () => {
const axisData: IAxisData = { data: [], datatype: 'string' };
if (this.highlightedRow) {
let attribute = this.highlightedRow!.clinicalAttribute;
let patientKeyToSamples = this.props.store.patientKeyToSamples
.result!;
let sampleIdentifiers = _.flatMap(
this.props.store.activeGroups.result,
group =>
_.flatMap(group.studies, study => {
return study.samples.map(sample => ({
studyId: study.id,
entityId: sample,
}));
})
);
let patientidentifiers = _.flatMap(
this.props.store.activeGroups.result,
group =>
_.flatMap(group.studies, study => {
return study.patients.map(patient => ({
studyId: study.id,
entityId: patient,
}));
})
);
let clinicalData = await client.fetchClinicalDataUsingPOST({
clinicalDataType: attribute.patientAttribute
? 'PATIENT'
: 'SAMPLE',
clinicalDataMultiStudyFilter: {
attributeIds: [attribute.clinicalAttributeId],
identifiers: attribute.patientAttribute
? patientidentifiers
: sampleIdentifiers,
},
});
let normalizedCategory: { [id: string]: string } = {};
for (const d of clinicalData) {
const lowerCaseValue = d.value.toLowerCase();
if (normalizedCategory[lowerCaseValue] === undefined) {
//consider first value as category value
normalizedCategory[lowerCaseValue] = d.value;
}
}
axisData.datatype = attribute.datatype.toLowerCase();
const axisData_Data = axisData.data;
if (attribute.patientAttribute) {
// produce sample data from patient clinical data
for (const d of clinicalData) {
const samples = patientKeyToSamples[d.uniquePatientKey];
for (const sample of samples) {
axisData_Data.push({
uniqueSampleKey: sample.uniqueSampleKey,
value:
normalizedCategory[d.value.toLowerCase()],
});
}
}
} else {
// produce sample data from sample clinical data
for (const d of clinicalData) {
axisData_Data.push({
uniqueSampleKey: d.uniqueSampleKey,
value: normalizedCategory[d.value.toLowerCase()],
});
}
}
if (isNumerical(attribute.datatype)) {
for (const d of axisData_Data) {
d.value = parseFloat(d.value as string); // we know its a string bc all clinical data comes back as string
}
}
}
return Promise.resolve(axisData);
},
});
private readonly groupMembershipAxisData = remoteData({
await: () => [
this.props.store.sampleMap,
this.props.store.activeGroups,
],
invoke: async () => {
const categoryOrder = _.map(
this.props.store.activeGroups.result!,
group => group.nameWithOrdinal
);
const axisData = {
data: [],
datatype: 'string',
categoryOrder,
} as IStringAxisData;
const sampleSet =
this.props.store.sampleMap.result ||
new ComplexKeyMap();
const sampleKeyToGroupSampleData = _.reduce(
this.props.store.activeGroups.result,
(acc, group) => {
group.studies.forEach(studyEntry => {
studyEntry.samples.forEach(sampleId => {
if (
sampleSet.has({
studyId: studyEntry.id,
sampleId,
})
) {
const uniqueSampleKey = sampleSet.get({
studyId: studyEntry.id,
sampleId,
})!.uniqueSampleKey;
if (acc[uniqueSampleKey] === undefined) {
acc[uniqueSampleKey] = {
uniqueSampleKey,
value: [],
};
}
acc[uniqueSampleKey].value.push(
group.nameWithOrdinal
);
}
});
});
return acc;
},
{} as {
[uniqueSampleKey: string]: {
uniqueSampleKey: string;
value: string[];
};
}
);
axisData.data = _.values(sampleKeyToGroupSampleData);
return Promise.resolve(axisData);
},
});
@computed get vertAxisDataPromise() {
return this.swapAxes
? this.groupMembershipAxisData
: this.clinicalDataPromiseFiltered;
}
@computed get horzAxisDataPromise() {
return this.swapAxes
? this.clinicalDataPromiseFiltered
: this.groupMembershipAxisData;
}
@computed get horzLabel() {
return this.swapAxes
? `${this.highlightedRow!.clinicalAttribute.displayName}${
this.logScale ? ' (log2)' : ''
}`
: `Group`;
}
@computed get vertLabel() {
return this.swapAxes
? 'Group'
: `${this.highlightedRow!.clinicalAttribute.displayName}${
this.logScale ? ' (log2)' : ''
}`;
}
@autobind
private getSvg() {
if (this.categoryPlotType === CategoryPlotType.Heatmap) {
return this.oncoprintJs && this.oncoprintJs.toSVG(true);
}
return document.getElementById(SVG_ID) as SVGElement | null;
}
@autobind
private assignScrollPaneRef(el: HTMLDivElement) {
this.scrollPane = el;
}
private readonly boxPlotData = remoteData<{
horizontal: boolean;
data: IBoxScatterPlotData[];
}>({
await: () => [
this.horzAxisDataPromise,
this.vertAxisDataPromise,
this.props.store.sampleKeyToSample,
],
invoke: () => {
const horzAxisData = this.horzAxisDataPromise.result;
const vertAxisData = this.vertAxisDataPromise.result;
if (!horzAxisData || !vertAxisData) {
return new Promise(() => 0); // dont resolve
} else {
let categoryData: IStringAxisData;
let numberData: INumberAxisData;
let horizontal: boolean;
if (isNumberData(horzAxisData) && isStringData(vertAxisData)) {
categoryData = vertAxisData;
numberData = horzAxisData;
horizontal = true;
} else if (
isStringData(horzAxisData) &&
isNumberData(vertAxisData)
) {
categoryData = horzAxisData;
numberData = vertAxisData;
horizontal = false;
} else {
return Promise.resolve({ horizontal: false, data: [] });
}
return Promise.resolve({
horizontal,
data: makeBoxScatterPlotData(
categoryData,
numberData,
this.props.store.sampleKeyToSample.result!,
{},
undefined,
undefined
),
});
}
},
});
@computed get scatterPlotTooltip() {
return (d: IBoxScatterPlotPoint) => {
let content = ;
if (this.boxPlotData.isComplete) {
content = boxPlotTooltip(
d,
this.props.store.activeStudyIdToStudy.result!,
this.boxPlotData.result.horizontal
);
}
return content;
};
}
@computed get boxPlotTooltip() {
return (data: any[], labels: string[]) => (
);
}
@computed get categoryToColor() {
//add group colors and reserved category colors
return _.reduce(
this.props.store.uidToGroup.result!,
(acc, next) => {
acc[next.nameWithOrdinal] = next.color;
return acc;
},
RESERVED_CLINICAL_VALUE_COLORS
);
}
@computed get groupToColor() {
let groups = this.props.store?.activeGroups?.result;
if (!groups) {
return {};
}
return groups.reduce((result, ag) => {
result[ag.nameWithOrdinal as string] = ag.color;
return result;
}, {} as any);
}
@observable categoryPlotType: CategoryPlotType =
CategoryPlotType.PercentageStackedBar;
@observable numericalVisualisationType: ClinicalNumericalVisualisationType =
ClinicalNumericalVisualisationType.Plot;
@action.bound
private onPlotTypeSelect(option: any) {
this.categoryPlotType = option.value;
}
@action.bound
private onNumericalVisualisationTypeSelect(option: any) {
this.numericalVisualisationType = option.value;
}
@computed private get getUtilitiesMenu() {
if (!this.highlightedRow) {
return ;
}
return (
);
}
@computed get plot() {
if (this.tableDataStore.allData.length === 0 || !this.highlightedRow) {
return ;
}
const promises = [this.horzAxisDataPromise, this.vertAxisDataPromise];
const groupStatus = getRemoteDataGroupStatus(...promises);
const isPercentage =
this.categoryPlotType === CategoryPlotType.PercentageStackedBar;
const isStacked =
isPercentage ||
this.categoryPlotType === CategoryPlotType.StackedBar;
switch (groupStatus) {
case 'pending':
return (
);
case 'error':
return Error loading plot data.;
default: {
if (
!this.horzAxisDataPromise.result ||
!this.vertAxisDataPromise.result
) {
return Error loading plot data.;
}
let plotElt: any = null;
if (this.isNumericalPlot) {
if (this.boxPlotData.isComplete) {
plotElt = (
7
? 30
: 60
}
axisLabelX={this.horzLabel}
axisLabelY={this.vertLabel}
data={this.boxPlotData.result.data}
chartBase={500}
scatterPlotTooltip={this.scatterPlotTooltip}
boxPlotTooltip={this.boxPlotTooltip}
horizontal={this.boxPlotData.result.horizontal}
logScale={this.logScaleFunction}
size={scatterPlotSize}
fill={basicAppearance.fill}
stroke={basicAppearance.stroke}
strokeOpacity={basicAppearance.strokeOpacity}
symbol="circle"
useLogSpaceTicks={true}
legendLocationWidthThreshold={550}
/>
);
} else if (this.boxPlotData.isError) {
return Error loading plot data.;
} else {
return (
);
}
} else {
plotElt = (
);
}
return (
);
}
}
}
@autobind
private toolbar() {
if (this.isTable) {
return <>>;
}
return (
);
}
componentWillUnmount() {
this.tableDataStore.destroy();
}
public render() {
return this.tabUI.component;
}
}