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
  • Loading branch information
TJMKuijpers committed Mar 6, 2024
1 parent bbbf795 commit ff2ce7b
Show file tree
Hide file tree
Showing 2 changed files with 151 additions and 33 deletions.
167 changes: 134 additions & 33 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 @@ -423,6 +433,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 @@ -499,6 +510,7 @@ export default class SurvivalChartExtended
return null;
}
}

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

return lines;
}

@computed get groupLandMarkLine() {
const landmarkLineLegend = this.analysisGroupsWithData.map(
(item: any) => item.name
Expand Down Expand Up @@ -847,46 +860,124 @@ export default class SurvivalChartExtended
);
return point;
}
@computed get numberOfSamplesAtRisk() {
var timePoints = scaleLinear()
@observable userDefinedTimePoints: boolean = false;

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

@computed get numberOfSamplesAtRisk(): ReactNode[] {
const timePoints: number[] = this.timePointsForNumberAtRiskLabels;

const numberAtRisk = _.groupBy(
this.calculateGroupSize(timePoints.map(item => item)),
const numberAtRisk: Record<number, RiskPerGroup[]> = _.groupBy(
this.calculateGroupSize(timePoints),
'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
);

const valueAtAxis = 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 valueAtAxis;
return labelComponents;
}
@observable _latestLandMarkPoint: number = 0;
@action updatelatestLandMarkPoint(value: number) {

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

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

@observable _inputFieldVisible: boolean = false;
@observable _calculateHazardRatio: boolean = false;
@observable landmarkPoint: LandmarkLineValues[];
Expand All @@ -1001,12 +1093,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 @@ -1053,11 +1148,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 @@ -1095,6 +1191,7 @@ export default class SurvivalChartExtended
@action updateVisibilityLandmarkLines() {
return (this.showLandmarkLine = true);
}

@action.bound
onSliderTextChange(text: string) {
this.sliderValue = Number.parseFloat(text);
Expand All @@ -1104,13 +1201,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 @@ -1142,6 +1242,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 ff2ce7b

Please sign in to comment.