diff --git a/src/pages/groupComparison/Survival.tsx b/src/pages/groupComparison/Survival.tsx index 6ccc088363e..e05566f3c90 100644 --- a/src/pages/groupComparison/Survival.tsx +++ b/src/pages/groupComparison/Survival.tsx @@ -771,6 +771,7 @@ export default class Survival extends React.Component { this.selectedSurvivalPlotPrefix ] } + compactMode={false} /> diff --git a/src/pages/resultsView/survival/SurvivalChart.tsx b/src/pages/resultsView/survival/SurvivalChart.tsx index 4e036b3adf3..aef9d09fb59 100644 --- a/src/pages/resultsView/survival/SurvivalChart.tsx +++ b/src/pages/resultsView/survival/SurvivalChart.tsx @@ -28,6 +28,7 @@ import { filterScatterData, SurvivalPlotFilters, SurvivalSummary, + SURVIVAL_COMPACT_MODE_THRESHOLD, } from './SurvivalUtil'; import { toConditionalPrecision } from 'shared/lib/NumberUtils'; import { getPatientViewUrl } from '../../../shared/api/urls'; @@ -88,10 +89,13 @@ export interface ISurvivalChartProps { legendLabelComponent?: any; yAxisTickCount?: number; xAxisTickCount?: number; + // Compact mode will hide censoring dots in the chart and do binning based on configuration + compactMode?: boolean; } const MIN_GROUP_SIZE_FOR_LOGRANK = 10; // Start to down sampling when there are more than 1000 dots in the plot. +// TODO: 1000 samples is our current setting, but we should make this configurable const SURVIVAL_DOWN_SAMPLING_THRESHOLD = 1000; @observer @@ -269,15 +273,30 @@ export default class SurvivalChart // The filter is only available when user zooms in the plot. @computed get scatterData(): GroupedScatterData { - return filterScatterData( - this.unfilteredScatterData, - this.scatterFilter, - { - xDenominator: this.downSamplingDenominators.x, - yDenominator: this.downSamplingDenominators.y, - threshold: SURVIVAL_DOWN_SAMPLING_THRESHOLD, - } - ); + if (this.props.compactMode) { + return filterScatterData( + this.unfilteredScatterData, + this.scatterFilter, + { + xDenominator: this.downSamplingDenominators.x, + yDenominator: this.downSamplingDenominators.y, + threshold: SURVIVAL_DOWN_SAMPLING_THRESHOLD, + enableCensoringCross: false, + floorTimeToMonth: true, + } + ); + } else { + return filterScatterData( + this.unfilteredScatterData, + this.scatterFilter, + { + xDenominator: this.downSamplingDenominators.x, + yDenominator: this.downSamplingDenominators.y, + threshold: SURVIVAL_DOWN_SAMPLING_THRESHOLD, + enableCensoringCross: true, + } + ); + } } public static defaultProps: Partial = { @@ -790,6 +809,77 @@ export default class SurvivalChart )); } + @computed get tooltipContent() { + return ( +
+ Patient ID:{' '} + + {this.tooltipModel.datum.patientId} + +
+ {!!this.props.showCurveInTooltip && [ + `Curve: ${this.tooltipModel.datum.group}`, +
, + ]} + {this.props.yLabelTooltip}:{' '} + {this.tooltipModel.datum.y.toFixed(2)}%
+ {this.tooltipModel.datum.status + ? this.props.xLabelWithEventTooltip + : this.props.xLabelWithoutEventTooltip} + : {this.tooltipModel.datum.x.toFixed(2)} months{' '} + {this.tooltipModel.datum.status ? '' : '(censored)'} +
+ {this.props.analysisClinicalAttribute && ( + + {this.props.analysisClinicalAttribute.displayName}:{' '} + { + this.props.patientToAnalysisGroups[ + this.tooltipModel.datum.uniquePatientKey + ] + } + + )} +
+ Number of patients at risk: {this.tooltipModel.datum.atRisk} +
+ ); + } + + @computed get compactTooltipContent() { + return ( +
+ Events during [{this.tooltipModel.datum.x}, + {this.tooltipModel.datum.x + 1}) months +
+ {this.tooltipModel.datum.numberOfEvents !== undefined && ( + <> + Patients with an event:{' '} + {this.tooltipModel.datum.numberOfEvents} + + )} +
+ {this.tooltipModel.datum.numberOfCensored !== undefined && ( + <> + Censored patients:{' '} + {this.tooltipModel.datum.numberOfCensored} + + )} +
+
% event free at interval end:{' '} + {this.tooltipModel.datum.y.toFixed(2)}% +
+ Patients at risk at interval end:{' '} + {this.tooltipModel.datum.atRisk} +
+ ); + } + public render() { if ( _.flatten(_.values(this.props.sortedGroupedSurvivals)).length === 0 @@ -829,51 +919,9 @@ export default class SurvivalChart onMouseEnter={this.tooltipMouseEnter} onMouseLeave={this.tooltipMouseLeave} > -
- Patient ID:{' '} - - {this.tooltipModel.datum.patientId} - -
- {!!this.props.showCurveInTooltip && [ - `Curve: ${this.tooltipModel.datum.group}`, -
, - ]} - {this.props.yLabelTooltip}:{' '} - {this.tooltipModel.datum.y.toFixed(2)}%
- {this.tooltipModel.datum.status - ? this.props.xLabelWithEventTooltip - : this.props.xLabelWithoutEventTooltip} - : {this.tooltipModel.datum.x.toFixed(2)} months{' '} - {this.tooltipModel.datum.status - ? '' - : '(censored)'} -
- {this.props.analysisClinicalAttribute && ( - - { - this.props.analysisClinicalAttribute - .displayName - } - :{' '} - { - this.props.patientToAnalysisGroups[ - this.tooltipModel.datum - .uniquePatientKey - ] - } - - )} -
- Number of patients at risk:{' '} - {this.tooltipModel.datum.atRisk} -
+ {this.props.compactMode + ? this.compactTooltipContent + : this.tooltipContent} )} {this.props.showTable && ( diff --git a/src/pages/resultsView/survival/SurvivalUtil.spec.tsx b/src/pages/resultsView/survival/SurvivalUtil.spec.tsx index 672da3adaf0..5c237a1c23d 100644 --- a/src/pages/resultsView/survival/SurvivalUtil.spec.tsx +++ b/src/pages/resultsView/survival/SurvivalUtil.spec.tsx @@ -19,6 +19,8 @@ import { createSurvivalAttributeIdsDict, getNumPatientsAtRisk, sortPatientSurvivals, + floorScatterData, + generateStudyViewSurvivalPlotTitle, } from './SurvivalUtil'; const exampleAlteredPatientSurvivals = [ @@ -13966,6 +13968,7 @@ describe('SurvivalUtil', () => { xDenominator: 100, yDenominator: 100, threshold: 100, + enableCensoringCross: true, }), [] ); @@ -13976,6 +13979,7 @@ describe('SurvivalUtil', () => { xDenominator: 0, yDenominator: 100, threshold: 100, + enableCensoringCross: true, }; assert.deepEqual( downSampling(allScatterData, opts), @@ -13988,6 +13992,7 @@ describe('SurvivalUtil', () => { xDenominator: -1, yDenominator: 100, threshold: 100, + enableCensoringCross: true, }; assert.deepEqual( downSampling(allScatterData, opts), @@ -14032,6 +14037,7 @@ describe('SurvivalUtil', () => { xDenominator: 4, yDenominator: 4, threshold: 100, + enableCensoringCross: true, } ), [ @@ -14094,6 +14100,7 @@ describe('SurvivalUtil', () => { threshold: 100, xDenominator: 4, yDenominator: 4, + enableCensoringCross: true, } ), [ @@ -14156,6 +14163,7 @@ describe('SurvivalUtil', () => { threshold: 100, xDenominator: 5, yDenominator: 5, + enableCensoringCross: true, } ), [ @@ -14227,6 +14235,7 @@ describe('SurvivalUtil', () => { threshold: 100, xDenominator: 8, yDenominator: 8, + enableCensoringCross: true, } ), [ @@ -14262,6 +14271,97 @@ describe('SurvivalUtil', () => { }); }); + describe('floorScatterData()', () => { + it('return correct data after do flooring', () => { + let testScatterData: ScatterData[] = [ + { + x: 0, + y: 10, + patientId: '', + uniquePatientKey: '', + studyId: '', + status: true, + opacity: 1, + }, + { + x: 0.5, + y: 9, + patientId: '', + uniquePatientKey: '', + studyId: '', + status: true, + opacity: 1, + }, + { + x: 1, + y: 8, + patientId: '', + uniquePatientKey: '', + studyId: '', + status: true, + opacity: 1, + }, + { + x: 1.2, + y: 7, + patientId: '', + uniquePatientKey: '', + studyId: '', + status: true, + opacity: 1, + }, + { + x: 1.4, + y: 6, + patientId: '', + uniquePatientKey: '', + studyId: '', + status: true, + opacity: 1, + }, + { + x: 1.5, + y: 6, + patientId: '', + uniquePatientKey: '', + studyId: '', + status: false, + opacity: 0, + }, + ]; + let result = [ + { + x: 0, + y: 9, + patientId: '', + uniquePatientKey: '', + studyId: '', + status: true, + opacity: 1, + numberOfEvents: 2, + numberOfCensored: 0, + atRisk: undefined, + group: undefined, + }, + { + x: 1, + y: 6, + patientId: '', + uniquePatientKey: '', + studyId: '', + status: true, + opacity: 1, + numberOfEvents: 3, + numberOfCensored: 1, + atRisk: undefined, + group: undefined, + }, + ]; + + assert.deepEqual(floorScatterData(testScatterData), result); + }); + }); + describe('filterScatterData()', () => { it('Return full data if the filers are undefined', () => { let testData = { @@ -14277,6 +14377,7 @@ describe('SurvivalUtil', () => { xDenominator: 100, yDenominator: 100, threshold: 100, + enableCensoringCross: true, }), testData ); @@ -14324,6 +14425,7 @@ describe('SurvivalUtil', () => { xDenominator: 2, yDenominator: 2, threshold: 2, + enableCensoringCross: true, } ); assert.deepEqual(result.altered.numOfCases, 2); @@ -14597,4 +14699,32 @@ describe('SurvivalUtil', () => { ); }); }); + + describe('generateStudyViewSurvivalPlotTitle()', () => { + const correctTitle = 'Overall'; + const originalTitle = 'Overall'; + const removeStatusTitle = 'Overall Status'; + const removeSurvivalTitle = 'Overall Survival'; + const removeStatusAndSurvivalTitle = 'Overall Survival Status'; + it('returns correct title', () => { + assert.equal( + generateStudyViewSurvivalPlotTitle(originalTitle), + correctTitle + ); + assert.equal( + generateStudyViewSurvivalPlotTitle(removeStatusTitle), + correctTitle + ); + assert.equal( + generateStudyViewSurvivalPlotTitle(removeSurvivalTitle), + correctTitle + ); + assert.equal( + generateStudyViewSurvivalPlotTitle( + removeStatusAndSurvivalTitle + ), + correctTitle + ); + }); + }); }); diff --git a/src/pages/resultsView/survival/SurvivalUtil.tsx b/src/pages/resultsView/survival/SurvivalUtil.tsx index 33e36ad0bcd..0e26ed373b3 100644 --- a/src/pages/resultsView/survival/SurvivalUtil.tsx +++ b/src/pages/resultsView/survival/SurvivalUtil.tsx @@ -15,12 +15,16 @@ export type ScatterData = { opacity?: number; group?: string; atRisk?: number; + numberOfEvents?: number; + numberOfCensored?: number; }; export type DownSamplingOpts = { xDenominator: number; yDenominator: number; threshold: number; + enableCensoringCross?: boolean; + floorTimeToMonth?: boolean; }; export type GroupedScatterData = { @@ -52,6 +56,13 @@ export type ParsedSurvivalData = { label: string; }; +type EventInfo = ScatterData & { + eventCount: number; + lastYInMonth: number; + censorCount: number; + lastRiskInMonth?: number; +}; + export const survivalCasesHeaderText: { [prefix: string]: string } = { OS: 'DECEASED', PFS: 'Progressed', @@ -87,6 +98,8 @@ export const survivalClinicalDataNullValueSet = new Set([ 'na', ]); +export const SURVIVAL_COMPACT_MODE_THRESHOLD = 1000; + export function sortPatientSurvivals(patientSurvivals: PatientSurvival[]) { // First sort by month in asc order (smaller number to the front) // Then sort by status in desc order (status is boolean, if status equals to true, then go to the front, false goes after it in the same time stamp) @@ -520,6 +533,81 @@ export function downSampling( }); } +export function floorScatterData( + scatterDataWithOpacity: ScatterData[] +): ScatterData[] { + let eventInfo: EventInfo; + let result: ScatterData[] = []; + const eventInfoByMonth: { [month: number]: EventInfo } = _.reduce( + scatterDataWithOpacity, + (eventInfoByMonth: { [month: string]: EventInfo }, item) => { + const month = Math.floor(item.x); + if (!(month in eventInfoByMonth)) { + // Provide initial value + eventInfo = { + ...item, + eventCount: 0, + lastYInMonth: 0, + censorCount: 0, + lastRiskInMonth: item.atRisk!, + }; + } else { + eventInfo = eventInfoByMonth[month]; + } + eventInfoByMonth[month] = updateEventInfo(eventInfo, item); + return eventInfoByMonth; + }, + {} + ); + + result = _.map(eventInfoByMonth, (eventInfo, month) => { + const aggregatedScatter: ScatterData = { + x: parseInt(month), + y: eventInfo.lastYInMonth, + patientId: eventInfo.patientId, + uniquePatientKey: eventInfo.uniquePatientKey, + studyId: eventInfo.studyId, + status: eventInfo.status, + opacity: eventInfo.opacity, + group: eventInfo.group, + atRisk: eventInfo.lastRiskInMonth, + numberOfEvents: eventInfo.eventCount, + numberOfCensored: eventInfo.censorCount, + }; + return aggregatedScatter; + }); + return result; +} + +// EventInfo is an object that keeps tracking the statistical information for KM plot during a period (e.g. a month) +// updateEventInfo will update EventInfo based on the new scatter data +// This is an internal function and only should be used by floorScatterData function +function updateEventInfo(eventInfo: EventInfo, data: ScatterData): EventInfo { + eventInfo.lastYInMonth = data.y; + eventInfo.lastRiskInMonth = data.atRisk; + eventInfo.eventCount = data.status + ? eventInfo.eventCount + 1 + : eventInfo.eventCount; + eventInfo.censorCount = data.status + ? eventInfo.censorCount + : eventInfo.censorCount + 1; + return eventInfo; +} + +export function getLineDataFromScatterData(data: ScatterData[]): any[] { + let chartData: any[] = []; + + chartData.push({ x: 0, y: 100 }); + data.forEach((item, index) => { + chartData.push({ + x: item.x, + y: item.y, + }); + }); + + return chartData; +} + export function filterScatterData( allScatterData: GroupedScatterData, filters: SurvivalPlotFilters | undefined, @@ -536,11 +624,15 @@ export function filterScatterData( _val => filterBasedOnCoordinates(filters, _val) ); } - value.scatter = downSampling(value.scatter, downSamplingOpts); - value.scatterWithOpacity = downSampling( - value.scatterWithOpacity, - downSamplingOpts - ); + if (downSamplingOpts.floorTimeToMonth) { + value.scatter = floorScatterData(value.scatterWithOpacity); + value.line = getLineDataFromScatterData(value.scatter); + } else { + value.scatter = downSampling(value.scatter, downSamplingOpts); + } + value.scatterWithOpacity = downSamplingOpts.enableCensoringCross + ? downSampling(value.scatterWithOpacity, downSamplingOpts) + : []; value.numOfCases = value.scatter.length; } }); @@ -600,9 +692,11 @@ export function generateSurvivalPlotYAxisLabelFromDisplayName( } } +// The plot title string come from a related survival status clinical attribute +// To get a correct title, we need to replace "status" and possible "survival" words with blank +// See related request here: https://github.com/cBioPortal/cbioportal/issues/8378 export function generateStudyViewSurvivalPlotTitle(title: string) { - let result = title.replace(/status/gi, ''); - return /survival/i.test(result) ? result : `${result} Survival`; + return title.replace(/status|survival/gi, '').trim(); } export function getSurvivalAttributes(clinicalAttributes: ClinicalAttribute[]) { diff --git a/src/pages/resultsView/survival/styles.module.scss b/src/pages/resultsView/survival/styles.module.scss index ece9442ff5c..a6690393fe3 100644 --- a/src/pages/resultsView/survival/styles.module.scss +++ b/src/pages/resultsView/survival/styles.module.scss @@ -22,6 +22,9 @@ } max-width: none !important; //override bootstrap white-space: nowrap; + div:last-child { + white-space: nowrap !important; + } } .NotAvailable { diff --git a/src/pages/studyView/StudyViewPageStore.ts b/src/pages/studyView/StudyViewPageStore.ts index 04559aa38e8..1afb762abd5 100644 --- a/src/pages/studyView/StudyViewPageStore.ts +++ b/src/pages/studyView/StudyViewPageStore.ts @@ -8071,9 +8071,10 @@ export class StudyViewPageStore this.survivalDescriptions.result![prefix][0] .displayName ) - : `${prefix} Survival`; + : `${prefix}`; plotTitle += ' (months)'; - + console.log(this.survivalDescriptions.result![prefix][0]); + console.log(plotTitle); const survivalStatusAttribute = this .clinicalAttributeIdToClinicalAttribute.result![ `${prefix}_STATUS` diff --git a/src/pages/studyView/StudyViewUtils.tsx b/src/pages/studyView/StudyViewUtils.tsx index d7f7f2b7395..1f3009782e3 100644 --- a/src/pages/studyView/StudyViewUtils.tsx +++ b/src/pages/studyView/StudyViewUtils.tsx @@ -456,6 +456,7 @@ export const COLORS = [ export const EXPONENTIAL_FRACTION_DIGITS = 3; export const MutationCountVsCnaYBinsMin = 52; // calibrated so that the dots are right up against each other. needs to correspond with the width and height of the chart +export const SURVIVAL_PLOT_ID_SUFFIX = 'SURVIVAL'; const OPERATOR_MAP: { [op: string]: string } = { '<=': '≤', @@ -467,7 +468,8 @@ const OPERATOR_MAP: { [op: string]: string } = { export function getClinicalAttributeOverlay( displayName: string, description: string, - clinicalAttributeId?: string + clinicalAttributeId?: string, + isCompactSurvivalChart?: boolean ): JSX.Element { const comparisonDisplayName = displayName.toLowerCase().trim(); let comparisonDescription = description @@ -487,6 +489,14 @@ export function getClinicalAttributeOverlay( {comparisonDescription !== comparisonDisplayName && (
{description}
)} +
+ {isCompactSurvivalChart && ( +
All events were floored to the nearest month
+ )} + {!!clinicalAttributeId && + new RegExp(`${SURVIVAL_PLOT_ID_SUFFIX}$`).test( + clinicalAttributeId + ) &&
x axis unit: months
} ); } diff --git a/src/pages/studyView/chartHeader/ChartHeader.tsx b/src/pages/studyView/chartHeader/ChartHeader.tsx index 7f804151944..401e398031c 100644 --- a/src/pages/studyView/chartHeader/ChartHeader.tsx +++ b/src/pages/studyView/chartHeader/ChartHeader.tsx @@ -58,6 +58,7 @@ export interface IChartHeaderProps { hugoGeneSymbols?: string[]; treatmentUniqueKeys?: string[]; }) => void; + isCompactSurvivalChart?: boolean; } export interface ChartControls { @@ -705,7 +706,8 @@ export class ChartHeader extends React.Component { this.props.description ? this.props.description.description : '', - this.props.chartMeta.uniqueKey + this.props.chartMeta.uniqueKey, + this.props.isCompactSurvivalChart )} destroyTooltipOnHide={true} > diff --git a/src/pages/studyView/charts/ChartContainer.tsx b/src/pages/studyView/charts/ChartContainer.tsx index 6d199828101..dfd99f008f0 100644 --- a/src/pages/studyView/charts/ChartContainer.tsx +++ b/src/pages/studyView/charts/ChartContainer.tsx @@ -65,6 +65,7 @@ import { import { getComparisonParamsForTable } from 'pages/studyView/StudyViewComparisonUtils'; import ComparisonVsIcon from 'shared/components/ComparisonVsIcon'; import { + SURVIVAL_COMPACT_MODE_THRESHOLD, SURVIVAL_PLOT_X_LABEL_WITH_EVENT_TOOLTIP, SURVIVAL_PLOT_X_LABEL_WITHOUT_EVENT_TOOLTIP, SURVIVAL_PLOT_Y_LABEL_TOOLTIP, @@ -438,6 +439,21 @@ export class ChartContainer extends React.Component { return this.highlightChart ? 2 : 1; } + @computed + get showCompactSurvivalChart() { + // returns true when selected survivals are larger than the threshold + if ( + !_.isEmpty(this.survivalChartData) && + !_.isEmpty(this.survivalChartData!.sortedGroupedSurvivals) + ) { + return _.some( + this.survivalChartData!.sortedGroupedSurvivals!, + survivals => survivals.length > SURVIVAL_COMPACT_MODE_THRESHOLD + ); + } + return false; + } + @computed get comparisonButtonForTables() { if (this.selectedRowsKeys!.length >= 2) { return { @@ -964,6 +980,7 @@ export class ChartContainer extends React.Component { // scatter the tick to avoid text overlaping on study view survival plots yAxisTickCount={2} xAxisTickCount={4} + compactMode={this.showCompactSurvivalChart} /> ); } else { @@ -1279,6 +1296,7 @@ export class ChartContainer extends React.Component { openComparisonPage={this.openComparisonPage} placement={this.placement} description={this.props.description} + isCompactSurvivalChart={this.showCompactSurvivalChart} />
{this.props.promise.isPending && (