diff --git a/src/pages/studyView/StudyViewConfig.ts b/src/pages/studyView/StudyViewConfig.ts index 166ef974a1f..afa09668b39 100644 --- a/src/pages/studyView/StudyViewConfig.ts +++ b/src/pages/studyView/StudyViewConfig.ts @@ -23,6 +23,7 @@ export type StudyViewColorTheme = { export type StudyViewThreshold = { pieToTable: number; + pieToBar: number; piePadding: number; barRatio: number; rowsInTableForOneGrid: number; @@ -61,9 +62,14 @@ export type StudyViewFrontEndConfig = { export type StudyViewConfig = StudyView & StudyViewFrontEndConfig; +export type ChangeChartOptionsMap = { + [chartType in ChartTypeEnum]?: ChartType[]; +}; + export enum ChartTypeEnum { PIE_CHART = 'PIE_CHART', BAR_CHART = 'BAR_CHART', + BAR_CATEGORICAL_CHART = 'BAR_CATEGORICAL_CHART', SURVIVAL = 'SURVIVAL', TABLE = 'TABLE', SCATTER = 'SCATTER', @@ -88,6 +94,7 @@ export enum ChartTypeEnum { export enum ChartTypeNameEnum { PIE_CHART = 'pie chart', BAR_CHART = 'bar chart', + BAR_CATEGORICAL_CHART = 'categorical bar chart', SURVIVAL = 'survival plot', TABLE = 'table', SCATTER = 'density plot', @@ -160,6 +167,7 @@ const studyViewFrontEnd = { }, thresholds: { pieToTable: 20, + pieToBar: 5, piePadding: 20, barRatio: 0.8, rowsInTableForOneGrid: 4, @@ -186,6 +194,10 @@ const studyViewFrontEnd = { w: 2, h: 1, }, + [ChartTypeEnum.BAR_CATEGORICAL_CHART]: { + w: 2, + h: 1, + }, [ChartTypeEnum.SCATTER]: { w: 2, h: 2, diff --git a/src/pages/studyView/StudyViewPageStore.ts b/src/pages/studyView/StudyViewPageStore.ts index faf5c3cc105..b9a96defeee 100644 --- a/src/pages/studyView/StudyViewPageStore.ts +++ b/src/pages/studyView/StudyViewPageStore.ts @@ -184,6 +184,7 @@ import { MutationCategorization, getChartMetaSet, getVisibleAttributes, + CategoryDataBin, } from './StudyViewUtils'; import { SingleGeneQuery } from 'shared/lib/oql/oql-parser'; import autobind from 'autobind-decorator'; @@ -592,7 +593,9 @@ export class StudyViewPageStore } }; - public isShowNAToggleVisible(dataBins: DataBin[]): boolean { + public isShowNAToggleVisible( + dataBins: DataBin[] | CategoryDataBin[] + ): boolean { return ( dataBins.length !== 0 && dataBins.some(dataBin => dataBin.specialValue === 'NA') @@ -2155,6 +2158,11 @@ export class StudyViewPageStore ChartDimension >(); + @observable public availableChartTypes = observable.map< + ChartUniqueKey, + ChartType[] + >(); + @observable public chartsType = observable.map(); private newlyAddedCharts = observable.array(); @@ -4532,7 +4540,7 @@ export class StudyViewPageStore data.forEach(item => { const uniqueKey = item.attributeId; if (this.isNewlyAdded(uniqueKey)) { - this.showAsPieChart(uniqueKey, item.counts.length); + this.showAsPieChart(uniqueKey, item.counts); this.newlyAddedCharts.remove(uniqueKey); } }); @@ -4559,7 +4567,7 @@ export class StudyViewPageStore data.forEach(item => { const uniqueKey = item.attributeId; if (this.isNewlyAdded(uniqueKey)) { - this.showAsPieChart(uniqueKey, item.counts.length); + this.showAsPieChart(uniqueKey, item.counts); this.newlyAddedCharts.remove(uniqueKey); } }); @@ -4588,7 +4596,7 @@ export class StudyViewPageStore data.forEach(item => { const uniqueKey = item.attributeId; this.unfilteredClinicalDataCountCache[uniqueKey] = item; - this.showAsPieChart(uniqueKey, item.counts.length); + this.showAsPieChart(uniqueKey, item.counts); this.newlyAddedCharts.remove(uniqueKey); }); }, @@ -5881,7 +5889,10 @@ export class StudyViewPageStore onError: () => {}, onResult: clinicalAttributes => { clinicalAttributes.forEach((obj: ClinicalAttribute) => { - if (obj.datatype === DataType.NUMBER) { + if ( + obj.datatype === DataType.NUMBER || + obj.datatype === DataType.STRING + ) { const uniqueKey = getUniqueKey(obj); let filter = getDefaultClinicalDataBinFilter(obj); @@ -7638,7 +7649,7 @@ export class StudyViewPageStore if (this.queriedPhysicalStudyIds.result.length > 1) { this.showAsPieChart( SpecialChartsUniqueKeyEnum.CANCER_STUDIES, - this.queriedPhysicalStudyIds.result.length + this.queriedPhysicalStudyIds.result ); } } @@ -7681,7 +7692,7 @@ export class StudyViewPageStore this.getTableDimensionByNumberOfRecords(data.result!.length) ); } - this.chartsType.set(attr.uniqueKey, ChartTypeEnum.TABLE); + this.chartsType.set(attr.uniqueKey, newChartType); } else { this.chartsDimension.set( attr.uniqueKey, @@ -7830,7 +7841,7 @@ export class StudyViewPageStore _.each( this.initialVisibleAttributesClinicalDataCountData.result, item => { - this.showAsPieChart(item.attributeId, item.counts.length); + this.showAsPieChart(item.attributeId, item.counts); } ); } @@ -9917,31 +9928,93 @@ export class StudyViewPageStore }); @action - showAsPieChart(uniqueKey: string, dataSize: number): void { + showAsPieChart( + uniqueKey: string, + data: ClinicalDataCount[] | string[] + ): void { + // Aggregate the total count and the count of 'NA' + // values from the ClinicalDataCount[] array + const { totalCount, naCount } = (data as ( + | ClinicalDataCount + | string + )[]).reduce( + (acc: { totalCount: number; naCount: number }, item) => { + if ( + typeof item === 'object' && + 'value' in item && + 'count' in item + ) { + acc.totalCount += item.count; + if (item.value === 'NA') { + acc.naCount += item.count; + } + } + return acc; + }, + { totalCount: 0, naCount: 0 } + ); + + // Determine the proportion of 'NA' values relative to the total data count + const naProportion = totalCount > 0 ? naCount / totalCount : 0; + if ( shouldShowChart( this.initialFilters, - dataSize, + data.length, this.samples.result.length ) ) { this.changeChartVisibility(uniqueKey, true); - + // Display the data as a table if it exceeds the defined threshold + // or contains specific table attributes if ( - dataSize > STUDY_VIEW_CONFIG.thresholds.pieToTable || - _.includes(STUDY_VIEW_CONFIG.tableAttrs, uniqueKey) + data.length > STUDY_VIEW_CONFIG.thresholds.pieToTable || + STUDY_VIEW_CONFIG.tableAttrs.includes(uniqueKey) ) { this.chartsType.set(uniqueKey, ChartTypeEnum.TABLE); this.chartsDimension.set( uniqueKey, - this.getTableDimensionByNumberOfRecords(dataSize) + this.getTableDimensionByNumberOfRecords(data.length) ); - } else { + this.availableChartTypes.set(uniqueKey, [ + ChartTypeEnum.PIE_CHART, + ChartTypeEnum.TABLE, + ]); + } + // Use a bar chart if the data is exceeds the pie chart + // threshold or if 'NA' values are greater than 50% + else if ( + data.length > STUDY_VIEW_CONFIG.thresholds.pieToBar || + naProportion > 0.5 + ) { + this.chartsType.set( + uniqueKey, + ChartTypeEnum.BAR_CATEGORICAL_CHART + ); + this.chartsDimension.set( + uniqueKey, + STUDY_VIEW_CONFIG.layout.dimensions[ + ChartTypeEnum.BAR_CATEGORICAL_CHART + ] + ); + this.availableChartTypes.set(uniqueKey, [ + ChartTypeEnum.PIE_CHART, + ChartTypeEnum.BAR_CATEGORICAL_CHART, + ChartTypeEnum.TABLE, + ]); + } + // Default to a pie chart for simpler data sets + else { this.chartsType.set(uniqueKey, ChartTypeEnum.PIE_CHART); this.chartsDimension.set( uniqueKey, STUDY_VIEW_CONFIG.layout.dimensions[ChartTypeEnum.PIE_CHART] ); + this.availableChartTypes.set(uniqueKey, [ + ChartTypeEnum.PIE_CHART, + ChartTypeEnum.BAR_CATEGORICAL_CHART, + ChartTypeEnum.TABLE, + ]); } } } diff --git a/src/pages/studyView/StudyViewUtils.tsx b/src/pages/studyView/StudyViewUtils.tsx index 06c56f02131..851aec20e8e 100644 --- a/src/pages/studyView/StudyViewUtils.tsx +++ b/src/pages/studyView/StudyViewUtils.tsx @@ -35,7 +35,6 @@ import { } from 'cbioportal-ts-api-client'; import * as React from 'react'; import { buildCBioPortalPageUrl } from '../../shared/api/urls'; -import { BarDatum } from './charts/barChart/BarChart'; import { BinMethodOption, GenericAssayChart, @@ -226,6 +225,18 @@ export type DataBin = { start: number; }; +export type BarDatum = { + x: number; + y: number; + dataBin: DataBin; +}; + +export type CategoryDataBin = { + id: string; + count: number; + specialValue: string; +}; + export type MutationCategorization = 'MUTATED' | 'MUTATION_TYPE'; export const SPECIAL_CHARTS: ChartMetaWithDimensionAndChartType[] = [ @@ -1253,6 +1264,16 @@ export function filterIntervalBins(numericalBins: DataBin[]) { ); } +export function clinicalDataToDataBin( + data: ClinicalDataCountSummary[] +): CategoryDataBin[] { + return data.map(item => ({ + id: item.value, + count: item.count, + specialValue: `${item.value}`, + })); +} + export function calcIntervalBinValues(intervalBins: DataBin[]) { const values = intervalBins.map(dataBin => dataBin.start); @@ -1339,6 +1360,19 @@ export function generateCategoricalData( })); } +export function generateCategoricalBarData( + categoryBins: CategoryDataBin[], + startIndex: number +): BarDatum[] { + // x is not the actual data value, it is the normalized data for representation + // y is the actual count value + return categoryBins.map((dataBin: DataBin, index: number) => ({ + x: startIndex + index + 1, + y: dataBin.count, + dataBin, + })); +} + export function isLogScaleByValues(values: number[]) { return ( // empty list is not considered log scale @@ -1369,6 +1403,14 @@ export function isEveryBinDistinct(data?: DataBin[]) { ); } +export const onlyContainsNA = (element: BarDatum): boolean => { + return element.dataBin.specialValue === 'NA'; +}; + +export const doesNotContainNA = (element: BarDatum): boolean => { + return !onlyContainsNA(element); +}; + function createRangeForDataBinOrFilter( start?: number, end?: number, diff --git a/src/pages/studyView/chartHeader/ChartHeader.tsx b/src/pages/studyView/chartHeader/ChartHeader.tsx index 83a273aa57d..7e9d9e33fd8 100644 --- a/src/pages/studyView/chartHeader/ChartHeader.tsx +++ b/src/pages/studyView/chartHeader/ChartHeader.tsx @@ -8,7 +8,7 @@ import { import classnames from 'classnames'; import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; -import { ChartTypeEnum } from '../StudyViewConfig'; +import { ChartTypeEnum, ChartTypeNameEnum } from '../StudyViewConfig'; import { ChartMeta, getClinicalAttributeOverlay } from '../StudyViewUtils'; import { DataType, @@ -82,6 +82,7 @@ export interface ChartControls { showSwapAxes?: boolean; showSurvivalPlotLeftTruncationToggle?: boolean; survivalPlotLeftTruncationChecked?: boolean; + showChartChangeOptions?: boolean; } @observer @@ -90,6 +91,7 @@ export class ChartHeader extends React.Component { @observable downloadSubmenuOpen = false; @observable comparisonSubmenuOpen = false; @observable showCustomBinModal: boolean = false; + @observable showChartChangeOptions: boolean = false; private closeMenuTimeout: number | undefined = undefined; constructor(props: IChartHeaderProps) { @@ -414,6 +416,88 @@ export class ChartHeader extends React.Component { ); } + if (this.props.chartControls?.showChartChangeOptions) { + const submenuWidth = 120; + const availableCharts = + this.props.store.availableChartTypes.get( + this.props.chartMeta.uniqueKey + ) || []; + items.push( +
  • +
    + (this.showChartChangeOptions = true) + } + onMouseLeave={() => + (this.showChartChangeOptions = false) + } + > +
    +