Skip to content

Commit

Permalink
Updated Surivival Chart for large cohort label overlap (number at risk)
Browse files Browse the repository at this point in the history
Logic: when labels overlap, draw label at index 0 and skip all labels at uneven index

Updated PR after code review

Updated util function

Remove commented code which is obsolete

code cleanup

Code cleanup of SurvivalUtil

updated survival chart

resolved rebase conflict

update screenshots
  • Loading branch information
TJMKuijpers committed Mar 7, 2024
1 parent 60b8f22 commit 46ce32e
Show file tree
Hide file tree
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';
Expand Down Expand Up @@ -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';
Expand All @@ -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',
Expand All @@ -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;
Expand Down Expand Up @@ -298,6 +307,7 @@ export default class SurvivalChartExtended
this.props.analysisGroups.map((item: any) => item.name.length)
);
}

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

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

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

return lines;
}

@computed get groupLandMarkLine() {
const landmarkLineLegend = this.analysisGroupsWithData.map(
(item: any) => item.name
Expand Down Expand Up @@ -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;
}

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

@observable _inputFieldVisible: boolean = false;
@observable _calculateHazardRatio: boolean = false;
@observable landmarkPoint: LandmarkLineValues[];
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1115,6 +1215,7 @@ export default class SurvivalChartExtended
@action updateVisibilityLandmarkLines() {
return (this.showLandmarkLine = true);
}

@action.bound
onSliderTextChange(text: string) {
this.sliderValue = Number.parseFloat(text);
Expand All @@ -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(
Expand Down Expand Up @@ -1162,6 +1266,7 @@ export default class SurvivalChartExtended
];
}
}

@action.bound changeControlGroup(groupValue: {
label: string;
value: string;
Expand Down
17 changes: 17 additions & 0 deletions src/pages/resultsView/survival/SurvivalUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 46ce32e

Please sign in to comment.