Skip to content

Commit

Permalink
Merge pull request #4833 from TJMKuijpers/custom-interval-number-at-risk
Browse files Browse the repository at this point in the history
Updated Surivival Chart  for large cohort label overlap (number at risk)
inodb authored Mar 7, 2024
2 parents 8325737 + 46ce32e commit 80440f5
Showing 4 changed files with 153 additions and 31 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
167 changes: 136 additions & 31 deletions src/pages/resultsView/survival/SurvivalChart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { ReactNode } from 'react';
import { observer } from 'mobx-react';
import { PatientSurvival } from '../../../shared/model/PatientSurvival';
import { action, computed, observable, makeObservable } from 'mobx';
@@ -31,13 +32,15 @@ import {
SurvivalPlotFilters,
SurvivalSummary,
ScatterData,
calculateLabelWidth,
} from './SurvivalUtil';
import { toConditionalPrecision } from 'shared/lib/NumberUtils';
import { getPatientViewUrl } from '../../../shared/api/urls';
import {
DefaultTooltip,
DownloadControlOption,
DownloadControls,
setArrowLeft,
} from 'cbioportal-frontend-commons';
import autobind from 'autobind-decorator';
import { AnalysisGroup, DataBin } from '../../studyView/StudyViewUtils';
@@ -58,12 +61,12 @@ import {
} from 'pages/resultsView/survival/logRankTest';
import { getServerConfig } from 'config/config';
import LeftTruncationCheckbox from 'shared/components/survival/LeftTruncationCheckbox';
import * as victory from 'victory';
import { scaleLinear } from 'd3-scale';
import ReactSelect from 'react-select1';
import { categoryPlotTypeOptions } from 'pages/groupComparison/ClinicalData';
import SurvivalDescriptionTable from 'pages/resultsView/survival/SurvivalDescriptionTable';
import $ from 'jquery';
import SettingsMenu from 'shared/alterationFiltering/SettingsMenu';
export enum LegendLocation {
TOOLTIP = 'tooltip',
CHART = 'chart',
@@ -83,6 +86,12 @@ export type HazardInformationLegend = {
hazardInformation: string;
};

export type RiskPerGroup = {
groupName: string;
aliveSamples: number;
timePoint: number;
};

export interface LandmarkLineValues {
xStart: number;
xEnd: number;
@@ -298,6 +307,7 @@ export default class SurvivalChartExtended
this.props.analysisGroups.map((item: any) => item.name.length)
);
}

@computed
get downSamplingDenominators() {
return {
@@ -424,6 +434,7 @@ export default class SurvivalChartExtended
return null;
}
}

@computed get getOrderGroups() {
const selectedGroup = this.analysisGroupsWithData.filter(
item => item.legendText == this._controlGroup!.value
@@ -500,6 +511,7 @@ export default class SurvivalChartExtended
return null;
}
}

@action hazardRatioAtLandmark(threshold: number[]) {
const landmarkGroups: any = [];
const survivalData = this.props.sortedGroupedSurvivals;
@@ -739,6 +751,7 @@ export default class SurvivalChartExtended

return lines;
}

@computed get groupLandMarkLine() {
const landmarkLineLegend = this.analysisGroupsWithData.map(
(item: any) => item.name
@@ -863,50 +876,132 @@ export default class SurvivalChartExtended
);
return point;
}
@computed get numberOfSamplesAtRisk() {

@computed get timePointsForNumberAtRiskLabels() {
return scaleLinear()
.domain([0, this.sliderValue])
.ticks(18);
}

@computed get numberOfSamplesAtRisk(): ReactNode[] {
const orderOfLabels = this.analysisGroupsWithData.map(
item => item.value
);
const definedTimePoints: number[] = scaleLinear()
const timePoints: number[] = scaleLinear()
.domain([0, this.sliderValue])
.ticks(18);
const numberAtRisk = _.groupBy(
this.calculateGroupSize(definedTimePoints).sort(
this.calculateGroupSize(timePoints).sort(
(a, b) =>
orderOfLabels.indexOf(a.groupName) -
orderOfLabels.indexOf(b.groupName)
),
'timePoint'
);
const labelComponents: ReactNode[] = [];
let someTimePointsOverlap: boolean = false;

// Hide overlapping labels of necessary -> start with time point at index 0 and check
// if time point (index 0) and the second time point (index 1) overlap.
// If yes -> remove all labels at index 1,3,5, and so on
const checkOverlap = (
labelX: number,
labelWidth: number,
existingLabel: ReactNode
): boolean => {
const existingLabelX: number = (existingLabel as any).props.x; // Assuming VictoryLabel has an x attribute
const existingLabelWidth: number = calculateLabelWidth(
(existingLabel as any).props.text,
CBIOPORTAL_VICTORY_THEME.legend.style.labels.fontFamily,
CBIOPORTAL_VICTORY_THEME.legend.style.labels.fontSize
);

return Object.keys(numberAtRisk).map(item =>
numberAtRisk[item].map((grp, i) => {
return (
<VictoryLabel
text={numberAtRisk[item][i].aliveSamples}
x={
numberAtRisk[item][i].timePoint * this.scaleFactor +
this.styleOpts.padding.left
}
y={
this.styleOptsDefaultProps.height -
this.styleOpts.padding.bottom +
80 +
i * 20
}
style={{
fontFamily:
CBIOPORTAL_VICTORY_THEME.legend.style.labels
.fontFamily,
}}
textAnchor="middle"
/>
);
})
);
return !(
labelX + labelWidth < existingLabelX ||
labelX > existingLabelX + existingLabelWidth
);
};

const addLabelsForTimePoint = (
timePoint: number,
index: number
): void => {
const rowLabels: ReactNode[] = numberAtRisk[timePoint].map(
(grp, i) => {
const labelX: number =
grp.timePoint * this.scaleFactor +
this.styleOpts.padding.left;

const labelY: number =
this.styleOptsDefaultProps.height -
this.styleOpts.padding.bottom +
80 +
i * 20;

const labelWidth: number = calculateLabelWidth(
numberAtRisk[timePoint][i].aliveSamples,
CBIOPORTAL_VICTORY_THEME.legend.style.labels.fontFamily,
CBIOPORTAL_VICTORY_THEME.legend.style.labels.fontSize
);

const labelComponent: ReactNode = (
<VictoryLabel
key={`${timePoint}-${i}`}
text={numberAtRisk[timePoint][i].aliveSamples}
x={labelX}
y={labelY}
style={{
fontFamily:
CBIOPORTAL_VICTORY_THEME.legend.style.labels
.fontFamily,
}}
textAnchor="middle"
/>
);

labelComponents.push(labelComponent);
return labelComponent;
}
);
};
timePoints.forEach((timePoint, index) => {
const timePointOverlap: boolean = numberAtRisk[timePoint].some(
(grp, i) => {
const labelX: number =
grp.timePoint * this.scaleFactor +
this.styleOpts.padding.left;

const labelWidth: number = calculateLabelWidth(
numberAtRisk[timePoint][i].aliveSamples,
CBIOPORTAL_VICTORY_THEME.legend.style.labels.fontFamily,
CBIOPORTAL_VICTORY_THEME.legend.style.labels.fontSize
);

return labelComponents.some(existingLabel =>
checkOverlap(labelX, labelWidth, existingLabel)
);
}
);

if (!timePointOverlap) {
if (
!someTimePointsOverlap ||
(index % 2 === 0 && index + 1 !== timePoints.length - 1) ||
index === timePoints.length - 1
) {
addLabelsForTimePoint(timePoint, index);
}
} else {
someTimePointsOverlap = true;
}
});

return labelComponents;
}

@observable _latestLandMarkPoint: number = 0;
@action updatelatestLandMarkPoint(value: number) {

@action updateLatestLandMarkPoint(value: number) {
this._latestLandMarkPoint = value;
}

@@ -1005,6 +1100,7 @@ export default class SurvivalChartExtended
this.styleOpts.padding.right) /
value;
}

@observable _inputFieldVisible: boolean = false;
@observable _calculateHazardRatio: boolean = false;
@observable landmarkPoint: LandmarkLineValues[];
@@ -1021,12 +1117,15 @@ export default class SurvivalChartExtended
@observable hazardRatio: HazardRatioInformation[];
@observable labelOffset: number =
65 + (Object.keys(this.props.sortedGroupedSurvivals).length + 1) * 20;

@action openHooverBox() {
return (this.hooverBoxVisible = true);
}

@action closeHooverBox() {
return (this.hooverBoxVisible = false);
}

@action landmarkLinesChecked() {
if (!this._inputFieldVisible) {
return (this._inputFieldVisible = true);
@@ -1073,11 +1172,12 @@ export default class SurvivalChartExtended
yEnd: 103,
} as LandmarkLineValues)
);
this.updatelatestLandMarkPoint(landmarkArray[0].xStart);
this.updateLatestLandMarkPoint(landmarkArray[0].xStart);
this.updateVisibilityLandmarkLines();
this.calculateGroupSize(landmarkArray.map(obj => obj.xStart));
return (this.landmarkPoint = landmarkArray);
}

@action calculateHazardRatio() {
if (!this.showHazardRatio) {
this.showNormalLegend = false;
@@ -1115,6 +1215,7 @@ export default class SurvivalChartExtended
@action updateVisibilityLandmarkLines() {
return (this.showLandmarkLine = true);
}

@action.bound
onSliderTextChange(text: string) {
this.sliderValue = Number.parseFloat(text);
@@ -1124,13 +1225,16 @@ export default class SurvivalChartExtended
this.styleOpts.padding.right) /
Number.parseFloat(text);
}

@observable _controlGroup: { label: string; value: string } = {
label: this.availableGroups[0].label,
value: this.availableGroups[0].value,
};

@computed get selectedControlGroup() {
return this._controlGroup;
}

@computed get availableGroups() {
if (Object.keys(this.props.sortedGroupedSurvivals).length > 1) {
const filteredObjects = Object.keys(
@@ -1162,6 +1266,7 @@ export default class SurvivalChartExtended
];
}
}

@action.bound changeControlGroup(groupValue: {
label: string;
value: string;
17 changes: 17 additions & 0 deletions src/pages/resultsView/survival/SurvivalUtil.tsx
Original file line number Diff line number Diff line change
@@ -738,3 +738,20 @@ export function calculateNumberOfPatients(
s.uniquePatientKey in patientToAnalysisGroups ? 1 : 0
);
}

export function calculateLabelWidth(
text: number,
fontFamily: string,
fontSize: number
) {
const tempElement = document.createElement('div');
tempElement.style.position = 'absolute';
tempElement.style.opacity = '0';
tempElement.style.fontFamily = fontFamily;
tempElement.style.fontSize = fontSize.toString();
tempElement.textContent = text.toString();
document.body.appendChild(tempElement);
const labelWidth = tempElement.offsetWidth;
document.body.removeChild(tempElement);
return labelWidth;
}

0 comments on commit 80440f5

Please sign in to comment.