diff --git a/src/pages/groupComparison/Survival.tsx b/src/pages/groupComparison/Survival.tsx index 25144dd5ec7..1d850748cb7 100644 --- a/src/pages/groupComparison/Survival.tsx +++ b/src/pages/groupComparison/Survival.tsx @@ -726,6 +726,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..dcc2003264e 100644 --- a/src/pages/resultsView/survival/SurvivalChart.tsx +++ b/src/pages/resultsView/survival/SurvivalChart.tsx @@ -88,10 +88,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 +272,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 +808,79 @@ 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() { + console.log(this.tooltipModel.datum.lastYInMonth); + return ( +
+ {this.props.xLabelWithEventTooltip}: {this.tooltipModel.datum.x} + -{this.tooltipModel.datum.x + 1} months
+ {this.props.yLabelTooltip} at the end of months ( + {this.tooltipModel.datum.x} months) :{' '} + {this.tooltipModel.datum.y.toFixed(2)}% +
+ Number of patients at risk at the end of months ( + {this.tooltipModel.datum.x} months) :{' '} + {this.tooltipModel.datum.atRisk} +
+ {this.tooltipModel.datum.numberOfEvents !== undefined && ( + <> + Number of patients have event:{' '} + {this.tooltipModel.datum.numberOfEvents} + + )} +
+ {this.tooltipModel.datum.numberOfCensored !== undefined && ( + <> + Number of patients are censored:{' '} + {this.tooltipModel.datum.numberOfCensored} + + )} +
+ ); + } + public render() { if ( _.flatten(_.values(this.props.sortedGroupedSurvivals)).length === 0 @@ -829,51 +920,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..9a12a2959dc 100644 --- a/src/pages/resultsView/survival/SurvivalUtil.spec.tsx +++ b/src/pages/resultsView/survival/SurvivalUtil.spec.tsx @@ -19,6 +19,7 @@ import { createSurvivalAttributeIdsDict, getNumPatientsAtRisk, sortPatientSurvivals, + floorScatterData, } from './SurvivalUtil'; const exampleAlteredPatientSurvivals = [ @@ -13966,6 +13967,7 @@ describe('SurvivalUtil', () => { xDenominator: 100, yDenominator: 100, threshold: 100, + enableCensoringCross: true, }), [] ); @@ -13976,6 +13978,7 @@ describe('SurvivalUtil', () => { xDenominator: 0, yDenominator: 100, threshold: 100, + enableCensoringCross: true, }; assert.deepEqual( downSampling(allScatterData, opts), @@ -13988,6 +13991,7 @@ describe('SurvivalUtil', () => { xDenominator: -1, yDenominator: 100, threshold: 100, + enableCensoringCross: true, }; assert.deepEqual( downSampling(allScatterData, opts), @@ -14032,6 +14036,7 @@ describe('SurvivalUtil', () => { xDenominator: 4, yDenominator: 4, threshold: 100, + enableCensoringCross: true, } ), [ @@ -14094,6 +14099,7 @@ describe('SurvivalUtil', () => { threshold: 100, xDenominator: 4, yDenominator: 4, + enableCensoringCross: true, } ), [ @@ -14156,6 +14162,7 @@ describe('SurvivalUtil', () => { threshold: 100, xDenominator: 5, yDenominator: 5, + enableCensoringCross: true, } ), [ @@ -14227,6 +14234,7 @@ describe('SurvivalUtil', () => { threshold: 100, xDenominator: 8, yDenominator: 8, + enableCensoringCross: true, } ), [ @@ -14262,6 +14270,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 +14376,7 @@ describe('SurvivalUtil', () => { xDenominator: 100, yDenominator: 100, threshold: 100, + enableCensoringCross: true, }), testData ); @@ -14324,6 +14424,7 @@ describe('SurvivalUtil', () => { xDenominator: 2, yDenominator: 2, threshold: 2, + enableCensoringCross: true, } ); assert.deepEqual(result.altered.numOfCases, 2); diff --git a/src/pages/resultsView/survival/SurvivalUtil.tsx b/src/pages/resultsView/survival/SurvivalUtil.tsx index 33e36ad0bcd..b79db0b08b1 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', @@ -520,6 +531,80 @@ export function downSampling( }); } +export function floorScatterData( + scatterDataWithOpacity: ScatterData[] +): ScatterData[] { + const eventInfoByMonth: { [month: number]: EventInfo } = {}; + let eventInfo: EventInfo; + let result: ScatterData[] = []; + _.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; + }, + 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; +} + +function updateEventInfo(eventInfo: EventInfo, data: ScatterData): EventInfo { + let updatedEventInfo: EventInfo = eventInfo; + updatedEventInfo.lastYInMonth = data.y; + updatedEventInfo.lastRiskInMonth = data.atRisk; + updatedEventInfo.eventCount = data.status + ? eventInfo.eventCount + 1 + : eventInfo.eventCount; + updatedEventInfo.censorCount = data.status + ? eventInfo.censorCount + : eventInfo.censorCount + 1; + return updatedEventInfo; +} + +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 +621,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; } }); diff --git a/src/pages/studyView/charts/ChartContainer.tsx b/src/pages/studyView/charts/ChartContainer.tsx index eb8e0815558..91699eda797 100644 --- a/src/pages/studyView/charts/ChartContainer.tsx +++ b/src/pages/studyView/charts/ChartContainer.tsx @@ -936,6 +936,7 @@ export class ChartContainer extends React.Component { // scatter the tick to avoid text overlaping on study view survival plots yAxisTickCount={2} xAxisTickCount={4} + compactMode={true} /> ); } else {